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..1d9f0c7 100644 --- a/Docs/EPIC-LIST.md +++ b/Docs/EPIC-LIST.md @@ -39,6 +39,10 @@ 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, GL-row-oriented 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..c72ebb6 100644 --- a/Docs/Instructions/USAGE.md +++ b/Docs/Instructions/USAGE.md @@ -85,7 +85,9 @@ 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) in canonical + `posx`, `negx`, `posy`, `negy`, `posz`, `negz` GL orientation, 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..61dd024 --- /dev/null +++ b/Docs/Plans/2026-09-01-prd-cubemap-face-exr-export.md @@ -0,0 +1,62 @@ +# 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`. +- Preserve the raw GL row order for cube-face files. PNG faces write the + readback without the normal 2D-image flip; EXR faces pre-flip the + `THREE.DataTexture` input to cancel the `EXRExporter` row reordering. This + keeps file row zero on the cubemap face's negative-t edge for consumers that + upload decoded pixels unchanged. +- 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 cubemap 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. + +## Orientation follow-up + +An Atmospace import of a 4096-face EXR export exposed that the original +implementation applied a normal top-down 2D-image flip to every cube face. +That reflects the cubemap across Y: its seams make `posy` and `negy` appear +swapped even though Three.js `CubeCamera` renders the canonical face indices +`+X`, `-X`, `+Y`, `-Y`, `+Z`, `-Z`. + +The cube-face PNG and EXR paths now preserve canonical GL face-row orientation; +equirectangular PNG/EXR/HDR exports retain their normal top-down 2D-image +orientation. A directional WebGL2 probe and the original exported face edges +confirmed the distinction. diff --git a/Docs/Research/2026-07-16-stellar-objects-layer-guidance.md b/Docs/Research/2026-07-16-stellar-objects-layer-guidance.md index 0d5c1e1..8c79e41 100644 --- a/Docs/Research/2026-07-16-stellar-objects-layer-guidance.md +++ b/Docs/Research/2026-07-16-stellar-objects-layer-guidance.md @@ -1284,7 +1284,7 @@ Use solid-angle-aware sampling near the poles. Do not generate content directly | Output | Implemented form | Intended consumer/use | | ----------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| Cubemap PNG | six vertically flipped PNG faces in a ZIP: `posx`, `negx`, `posy`, `negy`, `posz`, `negz` | generic face-based import; engine orientation still requires verification | +| Cubemap PNG | six GL-row-oriented PNG faces in a ZIP: `posx`, `negx`, `posy`, `negy`, `posz`, `negz` | generic face-based import; engine orientation still requires verification | | Panorama PNG | 2:1 equirectangular PNG | LDR preview/general use | | Radiance HDR | linear RGBE `.hdr`, equirectangular | primary Unreal Engine 5.8 path as a TextureCube | | OpenEXR | linear half-float `.exr`, equirectangular | primary Godot 4 path through `PanoramaSkyMaterial` | @@ -1292,7 +1292,7 @@ Use solid-angle-aware sampling near the poles. Do not generate content directly Selectable cube-face resolution is 512–4096 pixels. A 4096-per-face bake produces an 8192×4096 equirectangular panorama, conventionally described as 8K. Native 16K panorama output is outside launch scope. -The face order is OpenGL/Three.js `+X, -X, +Y, -Y, +Z, -Z`, with each face readback vertically flipped before PNG encoding. Equirectangular forward is `-Z`, up is `+Y`, and longitude increases toward `+X`. HDR and EXR values are linear. The current PNG path clamps linear values directly to bytes; its intended transfer function/color-space treatment must be documented or corrected before claiming color-managed output. +The face order is OpenGL/Three.js `+X, -X, +Y, -Y, +Z, -Z`, with each face preserving the GL readback row order for direct cubemap upload. Equirectangular forward is `-Z`, up is `+Y`, and longitude increases toward `+X`. HDR and EXR values are linear. The current PNG path clamps linear values directly to bytes; its intended transfer function/color-space treatment must be documented or corrected before claiming color-managed output. ### 14.5 Bake-sidecar metadata: current versus desired @@ -1577,7 +1577,7 @@ Require desktop WebGL2, `EXT_color_buffer_float`, and `MAX_CUBE_MAP_TEXTURE_SIZE The implemented outputs are: -* six vertically flipped PNG cube faces in a ZIP named `posx`, `negx`, `posy`, `negy`, `posz`, and `negz`; +* six GL-row-oriented PNG cube faces in a ZIP named `posx`, `negx`, `posy`, `negy`, `posz`, and `negz`; * 2:1 equirectangular PNG; * equirectangular Radiance RGBE `.hdr`, primarily for Unreal Engine 5.8; * equirectangular half-float OpenEXR `.exr`, primarily for Godot 4; @@ -1619,7 +1619,7 @@ Persist a generator/schema version and implement explicit preset migrations befo Target consumers are Unreal Engine 5.8 and Godot 4. Prefer equirectangular Radiance `.hdr` for Unreal TextureCube import and equirectangular `.exr` for Godot `PanoramaSkyMaterial`, reducing cube-face orientation ambiguity. -The generic six-face export currently uses OpenGL/Three.js order `+X, -X, +Y, -Y, +Z, -Z` and vertically flips each readback. Equirectangular mapping uses forward `-Z`, up `+Y`, and increasing longitude toward `+X`. HDR/EXR values are linear. PNG currently clamps linear values directly to bytes; document or correct the transfer function. Engine-specific orientation profiles and real import tests remain required. Product references: `src/render/noiseGlsl.ts:295`, `src/export/exporter.ts:1`, `docs/MODERNIZATION.md:152`. +The generic six-face export currently uses OpenGL/Three.js order `+X, -X, +Y, -Y, +Z, -Z` and preserves each face's GL readback row order. Equirectangular mapping uses forward `-Z`, up `+Y`, and increasing longitude toward `+X`. HDR/EXR values are linear. PNG currently clamps linear values directly to bytes; document or correct the transfer function. Engine-specific orientation profiles and real import tests remain required. Product references: `src/render/noiseGlsl.ts:295`, `src/export/exporter.ts:1`, `docs/MODERNIZATION.md:152`. ## Appendix D. Remaining Production Gaps 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..144ee32 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,14 @@ 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, + floatCubeFaceToPngBlob, + 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 +66,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 +314,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); + const blob = await floatCubeFaceToPngBlob(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,15 +392,23 @@ 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); + const blob = await floatCubeFaceToPngBlob(bake.faces[f], exportSize, exportSize); const path = `${prefix}/${FACE_NAMES[f]}.png`; 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 +436,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 +458,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 +495,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 +701,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 +753,7 @@ export default function App() {