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
3 changes: 3 additions & 0 deletions Docs/EPIC-LIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion Docs/Instructions/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
47 changes: 47 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,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.
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
93 changes: 62 additions & 31 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -378,15 +385,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 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 +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);
Expand All @@ -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(
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -668,11 +694,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 +746,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
92 changes: 92 additions & 0 deletions src/export/exporter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { unzipSync } from 'fflate';
import * as THREE from 'three';
import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
import { describe, expect, it } from 'vitest';
import { floatToBytes } from './hdr';
import {
FACE_NAMES,
halfFloatToExr,
packageFaceExrsZip,
rgbaBottomUpToTopDown,
} from './exporter';

const SIZE = 16; // Three's default EXR ZIP compression stores 16 scanlines per block.

function halfFace(red: number, green: number, blue: number): Uint16Array {
const out = new Uint16Array(SIZE * SIZE * 4);
for (let i = 0; i < SIZE * SIZE; i++) {
out[i * 4] = THREE.DataUtils.toHalfFloat(red);
out[i * 4 + 1] = THREE.DataUtils.toHalfFloat(green);
out[i * 4 + 2] = THREE.DataUtils.toHalfFloat(blue);
out[i * 4 + 3] = THREE.DataUtils.toHalfFloat(1);
}
return out;
}

interface DecodedExr {
width: number;
height: number;
data: Float32Array;
header: { channels: Array<{ pixelType: number }> };
}

function decodeExr(data: Uint8Array): DecodedExr {
const buffer = data.slice().buffer as ArrayBuffer;
return new EXRLoader().setDataType(THREE.FloatType).parse(buffer) as unknown as DecodedExr;
}

describe('cube face EXR export', () => {
it('packages six Half-Float EXRs with canonical names and dimensions', async () => {
const faceExrs: Uint8Array[] = [];
for (let i = 0; i < FACE_NAMES.length; i++) {
faceExrs.push(await halfFloatToExr(halfFace(i + 1, 0, 0), SIZE, SIZE));
}

const zip = unzipSync(new Uint8Array(await packageFaceExrsZip(faceExrs, 'sky').arrayBuffer()));
expect(Object.keys(zip)).toEqual(FACE_NAMES.map((name) => `sky_${name}.exr`));
for (const name of FACE_NAMES) {
const decoded = decodeExr(zip[`sky_${name}.exr`]);
expect([decoded.width, decoded.height]).toEqual([SIZE, SIZE]);
expect(decoded.header.channels.every((channel) => channel.pixelType === 1)).toBe(true);
}
});

it('preserves linear HDR values above 1.0', async () => {
const decoded = decodeExr(await halfFloatToExr(halfFace(4, 2.5, 0.5), SIZE, SIZE));
const pixels = decoded.data;
expect(pixels[0]).toBeCloseTo(4);
expect(pixels[1]).toBeCloseTo(2.5);
expect(Math.max(...pixels)).toBeGreaterThan(1);
});

it('is byte-deterministic for identical half-float input', async () => {
const face = halfFace(3, 1, 0.25);
const first = await halfFloatToExr(face, SIZE, SIZE);
const second = await halfFloatToExr(face, SIZE, SIZE);
expect(second).toEqual(first);
});

it('matches the PNG top-down row orientation without an extra EXR flip', async () => {
const half = new Uint16Array(2 * SIZE * 4);
const floats = new Float32Array(half.length);
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < 2; x++) {
const i = (y * 2 + x) * 4;
const rgba = y < SIZE / 2
? [1, x, 0, 1]
: [0, x, 1, 1];
for (let c = 0; c < 4; c++) {
floats[i + c] = rgba[c];
half[i + c] = THREE.DataUtils.toHalfFloat(rgba[c]);
}
}
}

const pngTopDown = rgbaBottomUpToTopDown(floatToBytes(floats), 2, SIZE);
const decoded = decodeExr(await halfFloatToExr(half, 2, SIZE));
const exrTopDown = rgbaBottomUpToTopDown(
floatToBytes(decoded.data), 2, SIZE,
);
expect(exrTopDown).toEqual(pngTopDown);
});
});
Loading