Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Docs/Design/ARCHITECTURE-MODERNIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Docs/EPIC-LIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion Docs/Instructions/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Docs/Instructions/VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
62 changes: 62 additions & 0 deletions Docs/Plans/2026-09-01-prd-cubemap-face-exr-export.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions Docs/Research/2026-07-16-stellar-objects-layer-guidance.md
Original file line number Diff line number Diff line change
Expand Up @@ -1284,15 +1284,15 @@ 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` |
| Layer/data export | per-layer images plus JSON/CSV particle data | downstream customization |

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

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 71 additions & 33 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -378,15 +392,23 @@ export default function App() {
bake: Awaited<ReturnType<PreviewScene['bakeExport']>>,
): Promise<ImageRefs> => {
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`;
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -668,11 +701,16 @@ export default function App() {
))}
</select>
</div>
<div className="field-row">
<label>Cube faces (.zip)</label>
<input type="checkbox" checked={exportFaces} onChange={(e) => setExportFaces(e.target.checked)} />
</div>
<div className="fmt-engines">Unity 6-sided skybox · Unreal cubemap · source-style engines</div>
<div className="field-row">
<label>Cube faces PNG (.zip)</label>
<input type="checkbox" checked={exportFaces} onChange={(e) => setExportFaces(e.target.checked)} />
</div>
<div className="fmt-engines">Unity 6-sided skybox · Unreal cubemap · source-style engines</div>
<div className="field-row">
<label title="Linear Half-Float OpenEXR">Cube faces EXR (.zip)</label>
<input type="checkbox" checked={exportFaceExr} onChange={(e) => setExportFaceExr(e.target.checked)} />
</div>
<div className="fmt-engines">HDR cubemaps · DCC tools · lossless values above 1.0</div>
<div className="field-row">
<label>Equirect (.png)</label>
<input type="checkbox" checked={exportEquirect} onChange={(e) => setExportEquirect(e.target.checked)} />
Expand Down Expand Up @@ -715,7 +753,7 @@ export default function App() {
<button
type="button"
className="export-go"
disabled={exporting || (!exportFaces && !exportEquirect && !exportHdr && !exportExr)}
disabled={exporting || (!exportFaces && !exportFaceExr && !exportEquirect && !exportHdr && !exportExr)}
onClick={() => void runExport()}
>
{exporting
Expand Down
Loading