From 4645f47173dbb1222f3ffb249b1871690cf9cf07 Mon Sep 17 00:00:00 2001 From: BinaryConstruct Date: Tue, 1 Sep 2026 15:10:51 +0200 Subject: [PATCH] Add HDR OpenEXR cube face export --- Docs/Design/ARCHITECTURE-MODERNIZATION.md | 4 +- Docs/EPIC-LIST.md | 3 + Docs/Instructions/USAGE.md | 3 +- Docs/Instructions/VERIFICATION.md | 2 +- .../2026-09-01-prd-cubemap-face-exr-export.md | 47 ++++++++++ README.md | 5 +- src/App.tsx | 93 ++++++++++++------- src/export/exporter.test.ts | 92 ++++++++++++++++++ src/export/exporter.ts | 39 +++++++- src/export/perLayer.test.ts | 7 ++ src/export/perLayer.ts | 2 + src/render/PreviewScene.ts | 41 ++++---- 12 files changed, 278 insertions(+), 60 deletions(-) create mode 100644 Docs/Plans/2026-09-01-prd-cubemap-face-exr-export.md create mode 100644 src/export/exporter.test.ts diff --git a/Docs/Design/ARCHITECTURE-MODERNIZATION.md b/Docs/Design/ARCHITECTURE-MODERNIZATION.md index 1577afe..2634774 100644 --- a/Docs/Design/ARCHITECTURE-MODERNIZATION.md +++ b/Docs/Design/ARCHITECTURE-MODERNIZATION.md @@ -132,8 +132,8 @@ composite below them. This also cleanly maps to engines: in Godot/UE the lens effect exports as a screen-space or sky-shader distortion using the same math. ### Phase 4 — Export -- Composited skybox: 6 face PNGs at 512–4096 (zip download), single cross-layout - PNG, equirect PNG. +- Composited skybox: 6 face PNGs or linear Half-Float OpenEXRs at 512–4096 + (zip download), single cross-layout PNG, equirect PNG. - HDR: float render targets (`EXT_color_buffer_float`, universal on desktop WebGL2) → EXR via three.js `EXRExporter`, or RGBE `.hdr`. - **Per-layer asset export** — each layer already renders to its own cube RTT, so diff --git a/Docs/EPIC-LIST.md b/Docs/EPIC-LIST.md index 22ac2d7..9d7da4f 100644 --- a/Docs/EPIC-LIST.md +++ b/Docs/EPIC-LIST.md @@ -39,6 +39,9 @@ added, completed, blocked, materially descoped, or superseded. floating toggle; slide-in drawer overlay below 760px, starts collapsed), scene-lock button (drags always pan, quads not grabbable), two-finger pinch zoom on the viewport (continuous FOV, OrbitControls-safe). +- **E10 — Cube-face OpenEXR export** ✅ Existing half-float HDR cubemaps export + as six canonical EXR faces across normal, per-layer, and batch exports. + [Plans/2026-09-01-prd-cubemap-face-exr-export.md](Plans/2026-09-01-prd-cubemap-face-exr-export.md) ## In progress / next diff --git a/Docs/Instructions/USAGE.md b/Docs/Instructions/USAGE.md index b40b2d3..c612839 100644 --- a/Docs/Instructions/USAGE.md +++ b/Docs/Instructions/USAGE.md @@ -85,7 +85,8 @@ generate or transform skyboxes, then paste the result back. preview). **Open** accepts `.zip` (and legacy `.sspj`), plain scene `.json`, and original Spacescape `.xml` saves. - **Export** — bake the skybox at 512–4096 px/face: - - cube faces (PNG zip) or a single equirectangular PNG + - cube faces (PNG or linear Half-Float OpenEXR zip) or a single + equirectangular PNG - Radiance `.hdr` (e.g. Unreal TextureCube) and OpenEXR (e.g. Godot `PanoramaSkyMaterial`), both HDR-capable - per-layer faces + a fully flattened composite, plus star positions as diff --git a/Docs/Instructions/VERIFICATION.md b/Docs/Instructions/VERIFICATION.md index 25c7168..2036d97 100644 --- a/Docs/Instructions/VERIFICATION.md +++ b/Docs/Instructions/VERIFICATION.md @@ -38,7 +38,7 @@ await page.locator('.viewport canvas').screenshot({ path: 'sky.png' }); // live inspector/workbench (controls.tsx wires htmlFor); export-panel checkboxes have NO label association — use `.locator('.export-panel input[type=checkbox]').nth(i)` - (order: faces, equirect, hdr, exr, per-layer). + (order: face PNG, face EXR, equirect PNG, HDR, equirect EXR, per-layer). - Downloads: `page.waitForEvent('download')` then `download.saveAs(...)`, unzip and probe contents with node. diff --git a/Docs/Plans/2026-09-01-prd-cubemap-face-exr-export.md b/Docs/Plans/2026-09-01-prd-cubemap-face-exr-export.md new file mode 100644 index 0000000..50fbd87 --- /dev/null +++ b/Docs/Plans/2026-09-01-prd-cubemap-face-exr-export.md @@ -0,0 +1,47 @@ +# Cube-face OpenEXR export + +Status: **Implemented and verified** + +## Goal + +Export the existing half-float cubemap bake as six linear HDR OpenEXR files, +without an 8-bit intermediate or a second rendering pipeline. Face order and +names remain `posx`, `negx`, `posy`, `negy`, `posz`, `negz`. + +## Implementation + +- Read each requested face once from the existing `WebGLCubeRenderTarget` into + a `Uint16Array`. +- Wrap that bottom-up half-float readback in a `THREE.DataTexture` and let the + existing Three.js `EXRExporter` perform its normal row reordering and encode + Half-Float OpenEXR output. +- Encode faces sequentially so EXR-only export never retains six uncompressed + `Float32Array` faces. Preserve the existing Float32/PNG path when PNG faces + are also requested. +- Add a distinct Cube faces EXR option to normal, per-layer/composite, and + batch export. Extend `composite.json` only with an optional face-EXR path + list; existing fields and version remain compatible. +- Keep the existing equirectangular PNG, Radiance HDR, and OpenEXR paths + unchanged. + +## Verification + +- Unit-test six-file packaging, canonical order/names, EXR dimensions, + preserved values above 1.0, deterministic bytes, and row orientation against + the PNG row convention. +- Run the existing full test, build, and lint gates. +- Export both PNG and EXR faces from a deterministic HDR scene in Chromium, + inspect the UI/preview, decode the downloads, and compare face orientation + and HDR range. Include a 4096-face smoke export where the environment permits. + +## Results + +- All 135 tests pass; production build and lint pass (apart from the documented + pre-existing `SpritesTab` fast-refresh warning). +- `deep-field` (`hdrMultiplier` up to 2.4) exported six 4096×4096 Half-Float + EXRs with decoded values up to 3.95703. +- At 512×512, decoded EXR faces and PNG faces matched pixel-for-pixel after + their normal file row mappings, confirming identical face orientation. +- Repeated bakes produced byte-identical EXR entries after unzipping. +- Normal, batch, per-layer/composite, and existing equirectangular PNG/HDR/EXR + exports were exercised through the built application. diff --git a/README.md b/README.md index 606d7db..cc6596c 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ no accounts, no servers, no telemetry. line-precise validation — built for AI-assisted authoring (see [AI policy](AI_POLICY.md) and the [scene-authoring skill](.claude/skills/skybox-scenes/SKILL.md)). -- **Export**: 512–4096/face cube-face PNG zip, equirectangular PNG, Radiance - `.hdr`, OpenEXR; per-layer and star-data exports; deterministic batch +- **Export**: 512–4096/face cube-face PNG or Half-Float OpenEXR zip, + equirectangular PNG, Radiance `.hdr`, OpenEXR; per-layer and star-data + exports; deterministic batch variation zips. - **Legacy compatible**: imports original Spacescape `.xml` saves (MSVC `rand()` LCG + exact Perlin port). Saves are plain `.zip` bundles diff --git a/src/App.tsx b/src/App.tsx index c1fa2a4..29d4342 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { importLegacyXml, fromJsonString } from './core/io'; import { defaultLayer, type Layer, type LayerType } from './core/layers'; import { manifestEntry, variantLayers, type VariationManifestEntry } from './export/batch'; import { compositeEntry, compositeJson, layerFileStem, starDataCsv, starDataJson, type ImageRefs } from './export/perLayer'; -import { FACE_NAMES, downloadBlob, floatToPngBlob, packageFacesZip } from './export/exporter'; +import { FACE_NAMES, downloadBlob, floatToPngBlob, packageFaceExrsZip, packageFacesZip } from './export/exporter'; import { encodeRadianceHdr } from './export/hdr'; import { strToU8, zipSync } from 'fflate'; import { buildProjectBundle, mimeForFileName, openProjectBundle } from './export/projectBundle'; @@ -59,8 +59,9 @@ export default function App() { const [grid, setGrid] = useState(false); const [spriteVersion, setSpriteVersion] = useState(0); const [exportOpen, setExportOpen] = useState(false); - const [exportSize, setExportSize] = useState(1024); - const [exportFaces, setExportFaces] = useState(true); + const [exportSize, setExportSize] = useState(1024); + const [exportFaces, setExportFaces] = useState(true); + const [exportFaceExr, setExportFaceExr] = useState(false); const [exportEquirect, setExportEquirect] = useState(true); const [exportExr, setExportExr] = useState(false); const [exportHdr, setExportHdr] = useState(false); @@ -306,21 +307,27 @@ export default function App() { for (let k = 0; k < count; k++) { setBatchProgress(`${k + 1}/${count}`); const vLayers = variantLayers(visibleLayers, k); - const { faces, equirect, exr } = await scene.bakeExport( - vLayers, - exportSize, - exportEquirect || exportHdr, - exportExr, - exportFaces, - ); + const { faces, faceExrs, equirect, exr } = await scene.bakeExport( + vLayers, + exportSize, + exportEquirect || exportHdr, + exportExr, + exportFaces, + exportFaceExr, + ); const tag = `v${String(k + 1).padStart(2, '0')}`; - if (exportFaces) { + if (exportFaces) { for (let i = 0; i < 6; i++) { const blob = await floatToPngBlob(faces[i], exportSize, exportSize); entries[`${tag}/${presetName}_${FACE_NAMES[i]}.png`] = new Uint8Array(await blob.arrayBuffer()); } - } + } + if (exportFaceExr && faceExrs) { + for (let i = 0; i < 6; i++) { + entries[`${tag}/${presetName}_${FACE_NAMES[i]}.exr`] = faceExrs[i]; + } + } if (exportEquirect && equirect) { const blob = await floatToPngBlob(equirect.data, equirect.width, equirect.height); entries[`${tag}/${presetName}-equirect.png`] = new Uint8Array(await blob.arrayBuffer()); @@ -378,7 +385,7 @@ export default function App() { bake: Awaited>, ): Promise => { const refs: ImageRefs = {}; - if (exportFaces) { + if (exportFaces) { refs.faces = []; for (let f = 0; f < 6; f++) { const blob = await floatToPngBlob(bake.faces[f], exportSize, exportSize); @@ -386,7 +393,15 @@ export default function App() { entries[path] = new Uint8Array(await blob.arrayBuffer()); refs.faces.push(path); } - } + } + if (exportFaceExr && bake.faceExrs) { + refs.faceExrs = []; + for (let f = 0; f < 6; f++) { + const path = `${prefix}/${FACE_NAMES[f]}.exr`; + entries[path] = bake.faceExrs[f]; + refs.faceExrs.push(path); + } + } if (exportEquirect && bake.equirect) { const blob = await floatToPngBlob(bake.equirect.data, bake.equirect.width, bake.equirect.height); refs.image = `${prefix}/equirect.png`; @@ -414,7 +429,9 @@ export default function App() { const stem = layerFileStem(layer, i); // a distortion layer solo-baked has nothing below it to bend — skip its image if (layer.type !== 'blackhole') { - const bake = await scene.bakeExport([layer], exportSize, wantEquirect, exportExr, exportFaces); + const bake = await scene.bakeExport( + [layer], exportSize, wantEquirect, exportExr, exportFaces, exportFaceExr, + ); Object.assign(composite[i], await writeImages(`layers/${stem}`, bake)); } const data = await scene.layerStarData(layer); @@ -434,7 +451,9 @@ export default function App() { // fully baked / flattened cubemap of the whole stack setBatchProgress('composite'); - const flattened = await scene.bakeExport(visibleLayers, exportSize, wantEquirect, exportExr, exportFaces); + const flattened = await scene.bakeExport( + visibleLayers, exportSize, wantEquirect, exportExr, exportFaces, exportFaceExr, + ); const compositeRefs = await writeImages('composite', flattened); entries['composite.json'] = strToU8( @@ -469,19 +488,26 @@ export default function App() { setExportOpen(false); return; } - const { faces, equirect, exr } = await scene.bakeExport( - visibleLayers, - exportSize, - exportEquirect || exportHdr, - exportExr, - exportFaces, - ); - if (exportFaces) { + const { faces, faceExrs, equirect, exr } = await scene.bakeExport( + visibleLayers, + exportSize, + exportEquirect || exportHdr, + exportExr, + exportFaces, + exportFaceExr, + ); + if (exportFaces) { downloadBlob( `${presetName}-${exportSize}-faces.zip`, await packageFacesZip(faces, exportSize, presetName), ); - } + } + if (exportFaceExr && faceExrs) { + downloadBlob( + `${presetName}-${exportSize}-faces-exr.zip`, + packageFaceExrsZip(faceExrs, presetName), + ); + } if (exportEquirect && equirect) { downloadBlob( `${presetName}-${exportSize}-equirect.png`, @@ -668,11 +694,16 @@ export default function App() { ))} -
- - setExportFaces(e.target.checked)} /> -
-
Unity 6-sided skybox · Unreal cubemap · source-style engines
+
+ + setExportFaces(e.target.checked)} /> +
+
Unity 6-sided skybox · Unreal cubemap · source-style engines
+
+ + setExportFaceExr(e.target.checked)} /> +
+
HDR cubemaps · DCC tools · lossless values above 1.0
setExportEquirect(e.target.checked)} /> @@ -715,7 +746,7 @@ export default function App() {