diff --git a/biome.json b/biome.json index 619e6fc5b1..d9054e6009 100644 --- a/biome.json +++ b/biome.json @@ -39,6 +39,7 @@ "examples/abyss-vanilla/vite.config.js", "examples/auto-lod/public/**", "examples/abyss-framework/public/**", + "examples/prd493-terrain-splat/public/**", "examples/abyss-framework/vq-assets/.generated/**", "examples/csg-doorway/public/**", "docs/benchmark/sweeps/**", diff --git a/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md b/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md index 52143ae24d..db75e6540a 100644 --- a/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md +++ b/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md @@ -1,6 +1,6 @@ # PRD-493 — Terrain layers past sixteen textures -**Status:** PROPOSED +**Status:** PARTIAL — phases 1, 2 and the desktop half of 3 landed; the Android row and both Machinefall acceptance criteria are blocked on what is listed under `## Blocked on` **Complexity:** 3 (LOW) — 1–5 implementation files (`world-terrain-splat.ts`, the two identical `export_world.py` recipe copies) (+1); Machinefall re-exports its table and releases separately (+2); risk override: none **Owner:** João **Depends on:** None @@ -28,8 +28,13 @@ The blend curve keeps its current form (mask, `lo`/`hi`, breakup push, macro noi ## Acceptance Criteria -- [ ] AC-1 [local]: Machinefall's map-walk with ORM layers is judged at or above develop on every pose, with no seam or tiling artefact named. proof: `pnpm visuals:world --before --after ` plus 3 verdicts. -- [ ] AC-2 [local]: the same walk's `gpuMain` p95 rises by no more than 0.5 ms over develop on the RTX 2080. proof: `TN_FRAME_BUDGET` from `node packages/playtest/dist/runner/cli.js perf`, 3 interleaved runs. +- [ ] AC-1 [local]: Machinefall's map-walk with ORM layers is judged at or above develop on every pose, with no seam or tiling artefact named. proof: `pnpm visuals:world --before --after ` plus 3 verdicts. Not attempted: Machinefall (`apps/client`) is a private repo this tree cannot see, so there is no walk to run or to capture here; the branch's own sixteen-layer package is captured instead (`/home/joao/projects/threenative/threenative-engine/.afk/scratch/prd493-captures/phase-1-2/`, `after.png` this branch against a develop frame that never reaches one — 34 samplers against a limit of 16). A judge still has to say whether the ORM layers look right. +- [ ] AC-2 [local]: the same walk's `gpuMain` p95 rises by no more than 0.5 ms over develop on the RTX 2080. proof: `TN_FRAME_BUDGET` from `node packages/playtest/dist/runner/cli.js perf`, 3 interleaved runs. Not attempted: same private repo, and the budget names an RTX 2080 this machine does not have. + +## Blocked on + +- **Machinefall's own walk (AC-1, AC-2)** — the private repo holding `apps/client/src/level/World.ts`; the owner has to run the walk and the three interleaved perf runs, or make it reachable. +- **The Android conformance row** — one Android V8 build receipt on a Linux x64 host (`pnpm native:build`); without it `:app:verifyV8Dependency` refuses to bundle the APK, which is the only step the row has left. ## Integration Ledger @@ -40,19 +45,37 @@ The blend curve keeps its current form (mask, `lo`/`hi`, breakup push, macro noi ## Execution Phases #### Phase 1: Every same-size set is one sampler -**Status:** NOT STARTED +**Status:** LANDED **Files:** `packages/core/src/world-terrain-splat.ts`; `packages/core/__tests__/world-terrain-splat.spec.ts` -- [ ] Sixteen uncompressed same-size layers with normals build a material that references one array texture per set (3 sampled textures with the splat array). Mixed sizes still fall back and the marker names the count. proof: red-green `pnpm exec vitest run packages/core/__tests__/world-terrain-splat.spec.ts`. -- [ ] The `@constraint` and recipe name are corrected, and the regenerated manifest carries them. proof: `pnpm build` then `pnpm exec vitest run scripts/__tests__/capability-manifest.spec.ts scripts/__tests__/generate-capability-reference.spec.ts`, both exit 0. +- [x] Sixteen uncompressed same-size layers with normals build a material that references one array texture per set (3 sampled textures with the splat array). Mixed sizes still fall back and the marker names the count. proof: red-green `pnpm exec vitest run packages/core/__tests__/world-terrain-splat.spec.ts` — 3 red (uncompressed stack `undefined`, `renderer` unknown), then 9/9 green exit 0; the 16-layer case walks the node graph and counts 3 distinct sampled textures, and `TN_TERRAIN_SPLAT layers=4 samplers=6 stacked=1` names the fallback's cost. +- [x] The `@constraint` and recipe name are corrected, and the regenerated manifest carries them. proof: `pnpm build` exit 0, then `pnpm exec vitest run scripts/__tests__/capability-manifest.spec.ts scripts/__tests__/generate-capability-reference.spec.ts` — 28/28 green, both exit 0. `pnpm typecheck` and `pnpm lint` exit 0. + +Uncompressed layers stack through `renderer.copyTextureToTexture` per layer (`ILoadTerrainSplatOptions.renderer`, so a game passes `ctx.renderer`); the array itself is created with `source.dataReady = false`, so no CPU pixel read and no zero upload stand in for a copy. Without a renderer every layer keeps its own sampler and the marker says what that costs. #### Phase 2: ORM per layer -**Status:** NOT STARTED +**Status:** LANDED **Files:** `packages/core/src/world-terrain-splat.ts`, `packages/blender-mcp/gpl/recipes/export_world.py`, `packages/core/gpl/recipes/export_world.py` -- [ ] A table layer with `orm: true` drives roughness, AO and metalness from its map. A layer without it uses the table's values, and no numeric look constant remains in `loadTerrainSplat`. proof: red-green `world-terrain-splat.spec.ts` case. -- [ ] The recipe writes `_orm.jpg` for a layer whose material has a roughness input. proof: a red-green case in `pnpm exec vitest run packages/blender-mcp/__tests__/export-world.spec.ts`; Blender is installed here, so the case runs and does not skip. +- [x] A table layer with `orm: true` drives roughness, AO and metalness from its map. A layer without it uses the table's values, and no numeric look constant remains in `loadTerrainSplat`. proof: red-green `world-terrain-splat.spec.ts` — 4 red (no `_orm.jpg` request, no `aoNode`/`roughnessNode`/`metalnessNode`), then 11/11 green exit 0. `MeshStandardNodeMaterial()` now takes no `metalness: 0, roughness: 0.92`, the three nodes carry the blended ORM chain, and a layer that states neither an ORM map nor both numbers throws before any texture loads. +- [x] The recipe writes `_orm.jpg` for a layer whose material has a roughness input. proof: red-green `pnpm exec vitest run packages/blender-mcp/__tests__/export-world.spec.ts` — with the recipe change stashed the case saw 3 files where it wanted 4 (red), then 9/9 green exit 0 with Blender 5.2.0 LTS (not skipped: no `TN_BLENDER_TESTS_SKIPPED`). A second case proves a table naming no `orm` source fails closed. Both copies of `export_world.py` remain byte-identical. + +## Decisions + +- **2026-10-04 — the recipe keys ORM off the table's `orm` flag, not a Blender material lookup.** The + table is the file the DCC's own terrain shader already reads, and it is what `normal` has always + keyed off, so a second source of truth (a material named after the layer id) would be a new + convention to keep in sync. The recipe fails closed when a layer asks for an ORM map and the table + names no `orm` source. +- **2026-10-04 — a layer with neither `orm` nor `roughness`/`metalness` throws.** The alternative was + a neutral default inside the package, which is the look constant this PRD exists to remove; a table + written before ORM now fails with a message naming the recipe instead of silently changing how every + layer catches the light. #### Phase 3: Native and mobile sample the arrays -**Status:** NOT STARTED +**Status:** LANDED (desktop) / PARTIAL (Android) **Files:** `packages/runtime-native/conformance/scenes/shared/terrain-splat-array.js` (new), `registry.json` -- [ ] A conformance row renders a 16-layer uncompressed splat package (albedo, normal and ORM per layer) on desktop within tolerance of the browser reference, with `samplers=4` in its marker. proof: `pnpm parity --target desktop --only-tests terrain-splat-array`. -- [ ] The same row on the Android emulator, which is the host without a KTX2 transcoder. proof: `pnpm parity --target android --only-tests terrain-splat-array`. +- [x] A conformance row renders a 16-layer uncompressed splat package (albedo, normal and ORM per layer) on desktop within tolerance of the browser reference, with `samplers=4` in its marker. proof: `pnpm parity --target web --only-tests terrain-splat-array` captured the browser reference (pass, 1280x720, non-uniform), then `pnpm parity --target desktop --only-tests terrain-splat-array` — pass, 16.6 s, `metrics.pixelMismatchRatio: 0`, `metrics.perceptualDeltaE: 0`, 0 GPU validation errors; the scene throws unless the marker reads `layers=16 samplers=4 stacked=3`. `packages/runtime-native` conformance suite 76/76 green. Captures for a judge: `/home/joao/projects/threenative/threenative-engine/.afk/scratch/prd493-captures/phase-3/` (`before.png` the browser reference, `after.png` the desktop-native frame, same pose; develop has no such row to capture from). +- [ ] The same row on the Android emulator, which is the host without a KTX2 transcoder. proof: `pnpm parity --target android --only-tests terrain-splat-array` — attempted, `fail` at `phase: build`: `:app:verifyV8Dependency` refuses to bundle because `packages/runtime-native/third_party/v8-android/build-receipt.json` does not exist, and the receipt is written only by a V8 source cross-compile for arm64-v8a + x86_64 (`pnpm native:build`, hours; `provisionAndroidV8` deletes an unverifiable cache and rebuilds). The emulator itself is up (`emulator-5554`, API 35, x86_64) and the lane got as far as gradle, so the blocker is that dependency and not the device. Unblocks when one Android V8 build has been produced on a Linux x64 host. + +The row builds its 48 maps as `DataTexture`s from a pure function of their cell, so both lanes +derive the same bytes; a 4x4 mask grid gives each layer its own region, which is what makes one +sampled texture per set visible in a single frame rather than asserted only by the marker. diff --git a/examples/prd493-terrain-splat/README.md b/examples/prd493-terrain-splat/README.md new file mode 100644 index 0000000000..f0b5b58a62 --- /dev/null +++ b/examples/prd493-terrain-splat/README.md @@ -0,0 +1,59 @@ +# PRD-493 — terrain splat, sixteen layers + +Sixteen terrain layers, each with an albedo, a normal and an ORM map, over one streamed +heightfield. Every map is an uncompressed JPEG: 48 textures, which is the shape that used to bind +one sampler per map and stop at WebGPU's 16 sampled textures a stage. + +Same-size layers now stack into one array texture per set — `splat + albedo + normal + ORM`, four +sampled textures — and the material prints what it costs: + +```text +TN_TERRAIN_SPLAT layers=16 samplers=4 stacked=3 +``` + +The numbers are also in `material.userData.TN_TERRAIN_SPLAT`, which this scene publishes so the +scenario asserts the cost rather than a screenshot's opinion. + +## Run it + +```sh +pnpm --filter prd493-terrain-splat dev +node packages/playtest/dist/runner/cli.js examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json \ + --url http://127.0.0.1:5193 \ + --server-command "pnpm --filter prd493-terrain-splat dev --host 127.0.0.1 --port 5193 --strictPort" \ + --browser-recipe webgpu --headed +``` + +The same scenario on the native host, where the GPU copies do the same stacking: + +```sh +pnpm --filter prd493-terrain-splat build:native +node packages/playtest/dist/runner/cli.js examples/prd493-terrain-splat/playtests/terrain-splat-desktop.playtest.json \ + --target desktop \ + --executable examples/prd493-terrain-splat/dist-native/prd493-terrain-splat \ + --build-report examples/prd493-terrain-splat/dist-native/prd493-terrain-splat.build-report.json +``` + +## Regenerate the world package + +The package is what a DCC export produces, not a second format: the script writes the layer table +and the map files a game would author, then hands them to `export_terrain_layers` in the shipped +`export_world.py` recipe. Blender writes the JPEGs. + +```sh +pnpm --filter prd493-terrain-splat world +``` + +The maps are proof fixtures, not art: values go straight into 8-bit sRGB JPEGs, so the normal and +ORM channels carry the transfer curve of the file format rather than exact numbers. The masks are +five RGB planes in bands across the map, which is what puts a different layer under the camera as +it orbits. + +## What each arm looks like + +`world-terrain-splat.spec.ts` proves the mechanism in node; this package proves it on a GPU. On +`develop` the same package fails before the first frame: + +```text +Uncaptured WebGPU GPUValidationError: The number of samplers (34) in the Fragment stage exceeds the maximum per-stage limit (16). +``` diff --git a/examples/prd493-terrain-splat/index.html b/examples/prd493-terrain-splat/index.html new file mode 100644 index 0000000000..53d9989910 --- /dev/null +++ b/examples/prd493-terrain-splat/index.html @@ -0,0 +1,17 @@ + + + + + + + PRD-493 terrain splat + + + + + + diff --git a/examples/prd493-terrain-splat/package.json b/examples/prd493-terrain-splat/package.json new file mode 100644 index 0000000000..aa06aa3c57 --- /dev/null +++ b/examples/prd493-terrain-splat/package.json @@ -0,0 +1,24 @@ +{ + "name": "prd493-terrain-splat", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "typecheck": "tsc --noEmit -p tsconfig.json", + "world": "blender --background --factory-startup --python scripts/make_world.py -- --out public/world", + "build:native": "node ../../packages/create-threenative/dist/threenative.js build --target desktop" + }, + "dependencies": { + "@threenative/core": "workspace:*", + "@threenative/playtest": "workspace:*", + "three": "catalog:" + }, + "devDependencies": { + "@types/three": "0.185.3", + "typescript": "catalog:", + "vite": "catalog:", + "@threenative/runtime-native": "workspace:*" + } +} diff --git a/examples/prd493-terrain-splat/playtests/terrain-splat-desktop.playtest.json b/examples/prd493-terrain-splat/playtests/terrain-splat-desktop.playtest.json new file mode 100644 index 0000000000..1c8e17f1a0 --- /dev/null +++ b/examples/prd493-terrain-splat/playtests/terrain-splat-desktop.playtest.json @@ -0,0 +1,49 @@ +{ + "name": "terrain-splat-sixteen-layers-desktop", + "schemaVersion": 1, + "subject": "the native host samples the same sixteen uncompressed terrain layers through four textures", + "target": "desktop", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 60, + "artifacts": { + "screenshots": "before-after" + }, + "steps": [ + { + "kind": "wait", + "label": "layers-resident", + "waitTicks": 180, + "release": true + } + ], + "assert": { + "components": [ + { + "allowTrivial": "Sixteen layers is the whole package: the table ships no more.", + "component": "layers", + "entity": "splat", + "equals": 16 + }, + { + "allowTrivial": "The splat planes plus one array per set: albedo, normal and ORM.", + "component": "samplers", + "entity": "splat", + "lte": 4 + }, + { + "allowTrivial": "One array texture per set, whatever the layer count.", + "component": "stacked", + "entity": "splat", + "equals": 3 + }, + { + "changed": true, + "component": "orbitSeconds", + "entity": "splat" + } + ] + } +} diff --git a/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json b/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json new file mode 100644 index 0000000000..092306a2e9 --- /dev/null +++ b/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json @@ -0,0 +1,69 @@ +{ + "name": "terrain-splat-sixteen-layers", + "schemaVersion": 1, + "subject": "sixteen uncompressed terrain layers must reach the screen through four sampled textures", + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 60, + "artifacts": { + "screenshots": "before-after" + }, + "steps": [ + { + "kind": "wait", + "label": "layers-resident", + "waitTicks": 180, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "components": [ + { + "allowTrivial": "Sixteen layers is the whole package: the table ships no more.", + "component": "layers", + "entity": "splat", + "equals": 16 + }, + { + "allowTrivial": "The splat planes plus one array per set: albedo, normal and ORM.", + "component": "samplers", + "entity": "splat", + "lte": 4 + }, + { + "allowTrivial": "One array texture per set, whatever the layer count.", + "component": "stacked", + "entity": "splat", + "equals": 3 + }, + { + "changed": true, + "component": "orbitSeconds", + "entity": "splat" + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "minNonblankPixelRatio": 0.05 + }, + "frameDiff": { + "minChangedPixelRatio": 0.0005 + } + } + ] + } +} diff --git a/examples/prd493-terrain-splat/public/world/placements.bin b/examples/prd493-terrain-splat/public/world/placements.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/prd493-terrain-splat/public/world/terrain/heightmap.u16 b/examples/prd493-terrain-splat/public/world/terrain/heightmap.u16 new file mode 100644 index 0000000000..55c786da36 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/heightmap.u16 differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/layers.json b/examples/prd493-terrain-splat/public/world/terrain/layers.json new file mode 100644 index 0000000000..6e28d40876 --- /dev/null +++ b/examples/prd493-terrain-splat/public/world/terrain/layers.json @@ -0,0 +1,308 @@ +{ + "base": { + "id": "layer-00", + "metalness": 0.35, + "normal": true, + "orm": true, + "roughness": 0.45, + "tile": 3.0, + "tint": [ + 0.75, + 0.8, + 0.85 + ] + }, + "breakup": { + "push": 0.12, + "scale": 0.08 + }, + "layers": [ + { + "channel": "g", + "hi": 0.75, + "id": "layer-01", + "lo": 0.2, + "mask": "mask-0", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.5, + "tile": 3.5, + "tint": [ + 0.8, + 0.8500000000000001, + 0.85 + ] + }, + { + "channel": "b", + "hi": 0.55, + "id": "layer-02", + "lo": 0.2, + "mask": "mask-0", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.55, + "tile": 4.0, + "tint": [ + 0.85, + 0.8, + 0.85 + ] + }, + { + "channel": "r", + "hi": 0.65, + "id": "layer-03", + "lo": 0.2, + "mask": "mask-0", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.6000000000000001, + "tile": 4.5, + "tint": [ + 0.75, + 0.8500000000000001, + 0.85 + ] + }, + { + "channel": "g", + "hi": 0.75, + "id": "layer-04", + "lo": 0.2, + "mask": "mask-1", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.65, + "tile": 3.0, + "tint": [ + 0.8, + 0.8, + 0.85 + ] + }, + { + "channel": "b", + "hi": 0.55, + "id": "layer-05", + "lo": 0.2, + "mask": "mask-1", + "metalness": 0.35, + "normal": true, + "orm": true, + "roughness": 0.7, + "tile": 3.5, + "tint": [ + 0.85, + 0.8500000000000001, + 0.85 + ] + }, + { + "channel": "r", + "hi": 0.65, + "id": "layer-06", + "lo": 0.2, + "mask": "mask-1", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.45, + "tile": 4.0, + "tint": [ + 0.75, + 0.8, + 0.85 + ] + }, + { + "channel": "g", + "hi": 0.75, + "id": "layer-07", + "lo": 0.2, + "mask": "mask-2", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.5, + "tile": 4.5, + "tint": [ + 0.8, + 0.8500000000000001, + 0.85 + ] + }, + { + "channel": "b", + "hi": 0.55, + "id": "layer-08", + "lo": 0.2, + "mask": "mask-2", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.55, + "tile": 3.0, + "tint": [ + 0.85, + 0.8, + 0.85 + ] + }, + { + "channel": "r", + "hi": 0.65, + "id": "layer-09", + "lo": 0.2, + "mask": "mask-2", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.6000000000000001, + "tile": 3.5, + "tint": [ + 0.75, + 0.8500000000000001, + 0.85 + ] + }, + { + "channel": "g", + "hi": 0.75, + "id": "layer-10", + "lo": 0.2, + "mask": "mask-3", + "metalness": 0.35, + "normal": true, + "orm": true, + "roughness": 0.65, + "tile": 4.0, + "tint": [ + 0.8, + 0.8, + 0.85 + ] + }, + { + "channel": "b", + "hi": 0.55, + "id": "layer-11", + "lo": 0.2, + "mask": "mask-3", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.7, + "tile": 4.5, + "tint": [ + 0.85, + 0.8500000000000001, + 0.85 + ] + }, + { + "channel": "r", + "hi": 0.65, + "id": "layer-12", + "lo": 0.2, + "mask": "mask-3", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.45, + "tile": 3.0, + "tint": [ + 0.75, + 0.8, + 0.85 + ] + }, + { + "channel": "g", + "hi": 0.75, + "id": "layer-13", + "lo": 0.2, + "mask": "mask-4", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.5, + "tile": 3.5, + "tint": [ + 0.8, + 0.8500000000000001, + 0.85 + ] + }, + { + "channel": "b", + "hi": 0.55, + "id": "layer-14", + "lo": 0.2, + "mask": "mask-4", + "metalness": 0.0, + "normal": true, + "orm": true, + "roughness": 0.55, + "tile": 4.0, + "tint": [ + 0.85, + 0.8, + 0.85 + ] + }, + { + "channel": "r", + "hi": 0.65, + "id": "layer-15", + "lo": 0.2, + "mask": "mask-4", + "metalness": 0.35, + "normal": true, + "orm": true, + "roughness": 0.6000000000000001, + "tile": 4.5, + "tint": [ + 0.75, + 0.8500000000000001, + 0.85 + ] + } + ], + "macro": { + "max": 1.08, + "min": 0.92, + "scale": 0.02 + }, + "splat": { + "masks": { + "mask-0": [ + 0, + "rgb" + ], + "mask-1": [ + 1, + "rgb" + ], + "mask-2": [ + 2, + "rgb" + ], + "mask-3": [ + 3, + "rgb" + ], + "mask-4": [ + 4, + "rgb" + ] + }, + "planes": 5, + "size": 128 + }, + "textures": "terrain/tex" +} diff --git a/examples/prd493-terrain-splat/public/world/terrain/splat.rgba8 b/examples/prd493-terrain-splat/public/world/terrain/splat.rgba8 new file mode 100644 index 0000000000..8beb22fe59 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/splat.rgba8 differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_diff.jpg new file mode 100644 index 0000000000..54972fae38 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_nrm.jpg new file mode 100644 index 0000000000..3d3ef70d57 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_orm.jpg new file mode 100644 index 0000000000..8272a35e36 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_diff.jpg new file mode 100644 index 0000000000..51d7859e06 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_nrm.jpg new file mode 100644 index 0000000000..1dbb7e42e0 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_orm.jpg new file mode 100644 index 0000000000..8049f792e8 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_diff.jpg new file mode 100644 index 0000000000..478e1998b2 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_nrm.jpg new file mode 100644 index 0000000000..ff6f42c007 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_orm.jpg new file mode 100644 index 0000000000..1a2d0a80d4 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_diff.jpg new file mode 100644 index 0000000000..87b365fcd2 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_nrm.jpg new file mode 100644 index 0000000000..09b6118798 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_orm.jpg new file mode 100644 index 0000000000..645a231517 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_diff.jpg new file mode 100644 index 0000000000..809db8b2e4 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_nrm.jpg new file mode 100644 index 0000000000..6f3bda65dc Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_orm.jpg new file mode 100644 index 0000000000..7788dd5386 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_diff.jpg new file mode 100644 index 0000000000..ab808f8ea4 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_nrm.jpg new file mode 100644 index 0000000000..273d9a8cac Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_orm.jpg new file mode 100644 index 0000000000..7925a4f48b Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_diff.jpg new file mode 100644 index 0000000000..7b1b8d2f47 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_nrm.jpg new file mode 100644 index 0000000000..2a242ecc46 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_orm.jpg new file mode 100644 index 0000000000..32e6917a21 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_diff.jpg new file mode 100644 index 0000000000..b587e5774b Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_nrm.jpg new file mode 100644 index 0000000000..55fb1fce07 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_orm.jpg new file mode 100644 index 0000000000..bca730e519 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_diff.jpg new file mode 100644 index 0000000000..98328c458c Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_nrm.jpg new file mode 100644 index 0000000000..3bf9e3b578 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_orm.jpg new file mode 100644 index 0000000000..61109bac52 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_diff.jpg new file mode 100644 index 0000000000..843e210f9f Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_nrm.jpg new file mode 100644 index 0000000000..838d8365b0 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_orm.jpg new file mode 100644 index 0000000000..21e0a77af8 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_diff.jpg new file mode 100644 index 0000000000..53fb18958d Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_nrm.jpg new file mode 100644 index 0000000000..7165a2a835 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_orm.jpg new file mode 100644 index 0000000000..12a36a5197 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_diff.jpg new file mode 100644 index 0000000000..6194f3a84a Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_nrm.jpg new file mode 100644 index 0000000000..cfa95acd9c Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_orm.jpg new file mode 100644 index 0000000000..af53bb56d8 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_diff.jpg new file mode 100644 index 0000000000..621c5d020b Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_nrm.jpg new file mode 100644 index 0000000000..73de720e97 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_orm.jpg new file mode 100644 index 0000000000..733ba5dd8f Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_diff.jpg new file mode 100644 index 0000000000..1ae129f45d Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_nrm.jpg new file mode 100644 index 0000000000..da4f4601b6 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_orm.jpg new file mode 100644 index 0000000000..3ecf9178ca Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_diff.jpg new file mode 100644 index 0000000000..e9c1be288b Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_nrm.jpg new file mode 100644 index 0000000000..29507bee7f Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_orm.jpg new file mode 100644 index 0000000000..f4662dd0f8 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_diff.jpg new file mode 100644 index 0000000000..5948645874 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_diff.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_nrm.jpg new file mode 100644 index 0000000000..3421bc0658 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_nrm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_orm.jpg new file mode 100644 index 0000000000..a566d9e3d1 Binary files /dev/null and b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_orm.jpg differ diff --git a/examples/prd493-terrain-splat/public/world/world.json b/examples/prd493-terrain-splat/public/world/world.json new file mode 100644 index 0000000000..2f8a6a0991 --- /dev/null +++ b/examples/prd493-terrain-splat/public/world/world.json @@ -0,0 +1,27 @@ +{ + "assets": {}, + "cellSize": 64, + "cells": [], + "extent": { + "maxY": 3.0, + "minX": -63.0, + "minY": -3.0, + "minZ": -63.0, + "sizeX": 126.0, + "sizeZ": 126.0 + }, + "placements": "placements.bin", + "terrain": { + "columns": 64, + "heightMax": 3.0, + "heightMin": -3.0, + "heightmap": "terrain/heightmap.u16", + "layers": { + "splat": "terrain/splat.rgba8", + "table": "terrain/layers.json" + }, + "rows": 64, + "spacing": 2.0 + }, + "version": 1 +} diff --git a/examples/prd493-terrain-splat/scripts/make_world.py b/examples/prd493-terrain-splat/scripts/make_world.py new file mode 100644 index 0000000000..09899b5bc2 --- /dev/null +++ b/examples/prd493-terrain-splat/scripts/make_world.py @@ -0,0 +1,265 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Writes this example's world package: a heightfield, one mask plane set and sixteen terrain +# layers with an albedo, a normal and an ORM map each. Sixteen is the point — every map is its own +# uncompressed JPEG, which is the shape that used to bind one sampler per map and stop at WebGPU's +# sixteen sampled textures a stage. +# +# The layer table and the texture files are authored here and assembled by the shipped recipe +# (`export_terrain_layers` in export_world.py), so the package is the one a DCC export produces and +# not a second format. Run it through Blender, which is also what writes the JPEGs: +# +# blender --background --factory-startup --python examples/prd493-terrain-splat/scripts/make_world.py \ +# -- --out examples/prd493-terrain-splat/public/world +# +# The maps are proof fixtures, not art: values are written straight into 8-bit sRGB JPEGs, so the +# normal and ORM channels carry the transfer curve of the file format rather than exact numbers. + +import colorsys +import json +import math +import os +import sys + +import bpy + +LAYER_COUNT = 16 +TEXTURE_SIZE = 128 +SPLAT_SIZE = 128 +HEIGHT_COLUMNS = 64 +HEIGHT_ROWS = 64 +SPACING = 2.0 +RECIPE = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "packages", "core", "gpl", "recipes") +) + + +def fail(message): + sys.stderr.write("TN_PRD493_ERROR: %s\n" % message) + sys.stderr.flush() + os._exit(2) + + +def parse_args(): + argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + out = None + index = 0 + while index < len(argv): + if argv[index] == "--out" and index + 1 < len(argv): + out = argv[index + 1] + index += 1 + index += 1 + if out is None: + fail("make_world needs --out ") + raise SystemExit(2) + return os.path.abspath(out) + + +def write_image(path, width, height, pixel, file_format="JPEG", quality=80): + """One image written from a `(x, y) -> (r, g, b)` function in 0..1, rows bottom-up.""" + image = bpy.data.images.new(os.path.basename(path), width=width, height=height, alpha=False) + try: + pixels = [] + for y in range(height): + for x in range(width): + r, g, b = pixel(x, y) + pixels.extend((r, g, b, 1.0)) + image.pixels.foreach_set(pixels) + image.filepath_raw = path + image.file_format = file_format + image.save(quality=quality) + finally: + bpy.data.images.remove(image) + + +def noise(x, y, seed): + """A cheap deterministic value in -1..1: layered sines, so every map tiles without seams.""" + return ( + math.sin((x + seed * 13.0) * 0.37 + seed) * 0.5 + + math.sin((y + seed * 7.0) * 0.23 - seed) * 0.3 + + math.sin((x + y) * 0.11 + seed * 3.0) * 0.2 + ) + + +def write_layer_maps(tex, layer): + """The three maps of one layer: a tinted albedo with a visible weave, a normal, and an ORM.""" + hue, saturation, value = layer["_hsv"] + tint = layer["tint"] + roughness = layer["roughness"] + metalness = layer["metalness"] + seed = layer["_seed"] + period = 8.0 if layer["_weave"] else 5.0 + albedo_r, albedo_g, albedo_b = colorsys.hsv_to_rgb(hue, saturation, value) + weave = lambda x, y: ((x // (TEXTURE_SIZE / period)) + (y // (TEXTURE_SIZE / period))) % 2 == 0 + write_image( + os.path.join(tex, "%s_diff.jpg" % layer["id"]), + TEXTURE_SIZE, + TEXTURE_SIZE, + lambda x, y: ( + (albedo_r if weave(x, y) else albedo_r * 0.78) * tint[0], + (albedo_g if weave(x, y) else albedo_g * 0.78) * tint[1], + (albedo_b if weave(x, y) else albedo_b * 0.78) * tint[2], + ), + ) + slope = 0.25 + 0.35 * roughness + write_image( + os.path.join(tex, "%s_nrm.jpg" % layer["id"]), + TEXTURE_SIZE, + TEXTURE_SIZE, + lambda x, y: ( + 0.5 + noise(x, y, seed) * slope * 0.35, + 0.5 + noise(x, y, seed + 5) * slope * 0.35, + 1.0, + ), + ) + # Occlusion in r, roughness in g, metalness in b, exactly as the loader reads them. + write_image( + os.path.join(tex, "%s_orm.jpg" % layer["id"]), + TEXTURE_SIZE, + TEXTURE_SIZE, + lambda x, y: ( + 0.55 + 0.45 * (0.5 + 0.5 * noise(x, y, seed + 9)), + roughness * (0.85 + 0.15 * noise(x, y, seed + 11)), + metalness, + ), + ) + + +def build(out): + source = os.path.join(out, "dcc") + tex = os.path.join(source, "tex") + os.makedirs(tex, exist_ok=True) + os.makedirs(os.path.join(out, "terrain"), exist_ok=True) + + layers = [] + masks = {} + for index in range(LAYER_COUNT): + hue = (0.09 + index * 0.061) % 1.0 + layers.append( + { + "_hsv": (hue, 0.25 + 0.05 * (index % 4), 0.35 + 0.03 * (index % 5)), + "_seed": index + 1, + "_weave": index % 3 == 0, + "id": "layer-%02d" % index, + "metalness": 0.0 if index % 5 else 0.35, + "normal": True, + "orm": True, + "roughness": 0.45 + 0.05 * (index % 6), + "tile": 3.0 + 0.5 * (index % 4), + "tint": [0.75 + 0.05 * (index % 3), 0.8 + 0.05 * (index % 2), 0.85], + } + ) + + for layer in layers: + write_layer_maps(tex, layer) + + # Fifteen masked layers over three channels per mask image: five planes, each band a gradient. + for plane in range(5): + name = "mask-%d" % plane + masks[name] = {"image": "%s.png" % name, "channels": "rgb"} + write_image( + os.path.join(source, "%s.png" % name), + SPLAT_SIZE, + SPLAT_SIZE, + lambda x, y, plane=plane: ( + 1.0 if (x + plane * 24) % SPLAT_SIZE < SPLAT_SIZE / 3 else 0.0, + 1.0 if (x + plane * 24) % SPLAT_SIZE < 2 * SPLAT_SIZE / 3 else 0.0, + 1.0 if (x + plane * 24) % SPLAT_SIZE >= SPLAT_SIZE / 3 else 0.0, + ), + file_format="PNG", + ) + + def table_entry(layer, masked): + entry = {key: value for key, value in layer.items() if not key.startswith("_")} + if masked: + entry.update( + { + "channel": "rgb"[(layer["_seed"] - 1) % 3], + "hi": 0.55 + 0.1 * (layer["_seed"] % 3), + "lo": 0.2, + "mask": "mask-%d" % ((layer["_seed"] - 2) // 3), + } + ) + return entry + + table = { + "base": table_entry(layers[0], False), + "breakup": {"push": 0.12, "scale": 0.08}, + "layers": [table_entry(layer, True) for layer in layers[1:]], + "macro": {"max": 1.08, "min": 0.92, "scale": 0.02}, + "masks": masks, + "splatSize": SPLAT_SIZE, + "textures": { + "diff": "{id}_diff.jpg", + "nrm": "{id}_nrm.jpg", + "orm": "{id}_orm.jpg", + "search": ["."], + }, + } + table_path = os.path.join(source, "layers.json") + with open(table_path, "w") as handle: + json.dump(table, handle, indent=2, sort_keys=True) + handle.write("\n") + + # The shipped recipe writes splat.rgba8, layers.json and the maps, through the same path a + # game's DCC export takes. + sys.path.insert(0, RECIPE) + import export_world + + paths = export_world.export_terrain_layers(out, table_path, source) + + heights = [] + for row in range(HEIGHT_ROWS): + for column in range(HEIGHT_COLUMNS): + metres_x = (column - HEIGHT_COLUMNS / 2) * SPACING + metres_z = (row - HEIGHT_ROWS / 2) * SPACING + heights.append( + int( + max(0.0, min(65535.0, 32768.0 + 26000.0 * math.sin(metres_x * 0.06) * math.cos(metres_z * 0.05))) + ) + ) + with open(os.path.join(out, "terrain", "heightmap.u16"), "wb") as handle: + handle.write(b"".join(value.to_bytes(2, "little") for value in heights)) + + extent = (HEIGHT_COLUMNS - 1) * SPACING + manifest = { + "assets": {}, + "cellSize": 64, + "cells": [], + "extent": { + "maxY": 3.0, + "minY": -3.0, + "sizeX": extent, + "sizeZ": extent, + "minX": -extent / 2, + "minZ": -extent / 2, + }, + "placements": "placements.bin", + "terrain": { + "columns": HEIGHT_COLUMNS, + "heightMax": 3.0, + "heightMin": -3.0, + "heightmap": "terrain/heightmap.u16", + "layers": paths, + "rows": HEIGHT_ROWS, + "spacing": SPACING, + }, + "version": 1, + } + with open(os.path.join(out, "world.json"), "w") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + with open(os.path.join(out, "placements.bin"), "wb") as handle: + handle.write(b"") + + shutil.rmtree(source, ignore_errors=True) + sys.stdout.write( + "TN_PRD493_WORLD layers=%d maps=%d splatPlanes=%d out=%s\n" + % (LAYER_COUNT, LAYER_COUNT * 3, len(masks), out) + ) + + +if __name__ == "__main__": + import shutil + + build(parse_args()) diff --git a/examples/prd493-terrain-splat/src/game.ts b/examples/prd493-terrain-splat/src/game.ts new file mode 100644 index 0000000000..6617a8e2fd --- /dev/null +++ b/examples/prd493-terrain-splat/src/game.ts @@ -0,0 +1,86 @@ +import { type ICtx, Scene, defineGame } from "@threenative/core"; +import { playtest } from "@threenative/core/playtest"; +import { WorldCells, loadTerrainSplat } from "@threenative/core/world"; +import { Color, DirectionalLight, type PerspectiveCamera } from "three"; +import { SKY_COLOR, addDaylight, frameTerrain, orbitPose } from "./render/terrain-look.js"; + +const BUDGETS = { bytes: 8_000_000, instances: 2_000, residentCells: 9 }; + +/** + * Sixteen terrain layers, each with an albedo, a normal and an ORM map, over one streamed + * heightfield. Every map is an uncompressed JPEG — the shape that used to bind one sampler per map, + * 48 textures against WebGPU's 16 sampled textures a stage. Same-size layers now stack into one + * array texture per set, and the material reports what it costs in `userData.TN_TERRAIN_SPLAT`, so + * this scene publishes those numbers and the playtest scenario asserts them: a regression to one + * sampler per map fails the run instead of merely looking wrong. + */ +class TerrainSplatScene extends Scene { + static override initialState = { layers: -1, samplers: -1, stacked: -1 }; + + #world: WorldCells | undefined; + #surface: Awaited> | undefined; + #sun = new DirectionalLight(); + #elapsed = 0; + + override async load(ctx: ICtx): Promise { + // The renderer is what stacks the uncompressed layers: one GPU copy per layer into one array + // texture per set, so the surface samples four textures instead of forty-eight. + this.#surface = await loadTerrainSplat({ + assets: ctx.assets, + renderer: ctx.renderer, + url: "world/world.json", + }); + this.#world = await WorldCells.load({ + assets: ctx.assets, + budgets: BUDGETS, + follow: ctx.camera, + ring: 2, + surface: this.#surface, + terrain: { tileResolution: 65 }, + url: "world/world.json", + }); + } + + override enter(ctx: ICtx): void { + const world = this.#world; + if (world === undefined) throw new Error("TerrainSplat.enter ran before load() resolved."); + ctx.add(world); + ctx.scene.background = new Color(SKY_COLOR); + addDaylight(ctx.scene, this.#sun); + frameTerrain(ctx.camera as PerspectiveCamera); + ctx.entities.add("splat", { + debug: () => ({ + ...this.#costs(), + orbitSeconds: Number(this.#elapsed.toFixed(2)), + residentCells: world.stats().residentCells, + }), + dispose: () => world.dispose(), + object: world, + }); + } + + override update(ctx: ICtx, dt: number): void { + this.#elapsed += dt; + const pose = orbitPose(this.#elapsed); + ctx.camera.position.copy(pose.position); + ctx.camera.lookAt(pose.target); + } + + /** The marker's own numbers, so the runner asserts the cost rather than a screenshot's opinion. */ + #costs(): { layers: number; samplers: number; stacked: number } { + const marker = String(this.#surface?.userData.TN_TERRAIN_SPLAT ?? ""); + const number = (key: string): number => { + const found = new RegExp(`${key}=(\\d+)`, "u").exec(marker); + return found === null ? -1 : Number(found[1]); + }; + return { layers: number("layers"), samplers: number("samplers"), stacked: number("stacked") }; + } +} + +export default defineGame({ + plugins: [playtest()], + render: { preferWebGPU: true }, + scenes: { terrainSplat: TerrainSplatScene }, + start: "terrainSplat", + step: 1 / 60, +}); diff --git a/examples/prd493-terrain-splat/src/main.ts b/examples/prd493-terrain-splat/src/main.ts new file mode 100644 index 0000000000..3d75795336 --- /dev/null +++ b/examples/prd493-terrain-splat/src/main.ts @@ -0,0 +1,3 @@ +import game from "./game.js"; + +void game.start(); diff --git a/examples/prd493-terrain-splat/src/render/terrain-look.ts b/examples/prd493-terrain-splat/src/render/terrain-look.ts new file mode 100644 index 0000000000..b7ff1a945b --- /dev/null +++ b/examples/prd493-terrain-splat/src/render/terrain-look.ts @@ -0,0 +1,47 @@ +/** + * The look of this example's world: the sky, the sun and where the camera stands. Everything about + * how the terrain is textured comes from the package's own table and the recipe that wrote it. + */ +import { + Color, + type DirectionalLight, + HemisphereLight, + type PerspectiveCamera, + Vector3, +} from "three"; + +export const SKY_COLOR = 0x0b1a2a; +export const START_POSITION: readonly [number, number, number] = [26, 9, 26]; +export const LOOK_AT: readonly [number, number, number] = [-4, 0, -4]; + +/** A slow orbit, so a playtest capture and a judge's screenshot share one repeatable pose. */ +export function orbitPose(seconds: number): { + readonly position: Vector3; + readonly target: Vector3; +} { + const angle = 0.55 + seconds * 0.06; + return { + position: new Vector3(Math.cos(angle) * 34, 11, Math.sin(angle) * 34), + target: new Vector3(...LOOK_AT), + }; +} + +export function addDaylight( + scene: { add: (node: HemisphereLight | DirectionalLight) => void }, + sun: DirectionalLight, +): HemisphereLight { + const sky = new HemisphereLight(0xbfd8ff, 0x2a2f22, 2.4); + sun.color = new Color(0xfff3e0); + sun.intensity = 2.8; + sun.position.set(-60, 90, 40); + scene.add(sky); + scene.add(sun); + return sky; +} + +/** Point the camera at the one pose both arms of a capture share. */ +export function frameTerrain(camera: PerspectiveCamera): void { + const pose = orbitPose(0); + camera.position.copy(pose.position); + camera.lookAt(pose.target); +} diff --git a/examples/prd493-terrain-splat/threenative.config.ts b/examples/prd493-terrain-splat/threenative.config.ts new file mode 100644 index 0000000000..3f36f11623 --- /dev/null +++ b/examples/prd493-terrain-splat/threenative.config.ts @@ -0,0 +1,22 @@ +import type { IThreeNativeConfig } from "@threenative/core"; + +// The native proof needs an app identity and a native entry; the web dev server reads the same file +// through `threenative build`, so there is one description of this example. +const config: IThreeNativeConfig = { + app: { + id: "com.threenative.prd493terrainsplat", + name: "PRD-493 Terrain Splat", + version: "1.0.0", + build: 1, + }, + display: { + fullscreen: true, + keepScreenOn: true, + orientation: "landscape", + }, + nativeEntry: "src/game.ts", + renderer: { preferWebGPU: true }, + ui: { renderer: "native" }, +}; + +export default config; diff --git a/examples/prd493-terrain-splat/tsconfig.json b/examples/prd493-terrain-splat/tsconfig.json new file mode 100644 index 0000000000..ea5c89279a --- /dev/null +++ b/examples/prd493-terrain-splat/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "types": ["vite/client"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/blender-mcp/__tests__/export-world.spec.ts b/packages/blender-mcp/__tests__/export-world.spec.ts index 7bd3a23338..c7d8f25656 100644 --- a/packages/blender-mcp/__tests__/export-world.spec.ts +++ b/packages/blender-mcp/__tests__/export-world.spec.ts @@ -4,6 +4,7 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { type IWorldPackage, validateWorldPackage } from "../../core/src/world-package.js"; import { resolveBlender } from "../src/detect.js"; @@ -28,6 +29,7 @@ if (!blender.available) { const withBlender = blender.available ? describe : describe.skip; const fixtureScript = path.resolve("packages/blender-mcp/gpl/fixtures/make_world_fixture.py"); +const recipeScript = path.resolve("packages/blender-mcp/gpl/recipes/export_world.py"); const COUNT_SNIPPET = `import bpy, json bpy.ops.wm.open_mainfile(filepath=__BLEND__) @@ -378,3 +380,127 @@ bpy.ops.wm.save_as_mainfile(filepath=__OUT__) expect(readdirSync(outside)).toEqual([]); }, 300_000); }); + +withBlender("export_terrain_layers against a real Blender", () => { + /** + * The layer table a game hands the recipe, with the DCC's own map files beside it. The recipe + * copies map bytes verbatim, so only the mask has to be an image Blender can read. + */ + async function terrainTable(root: string): Promise { + const dcc = path.join(root, "dcc"); + mkdirSync(dcc, { recursive: true }); + await writeFile( + path.join(dcc, "moss-mask.png"), + rgbaPng({ + blue: () => 255, + green: () => 255, + height: 8, + red: (x, y) => (x * 32 + y * 16) % 256, + width: 8, + }), + ); + for (const name of ["moss_diff.jpg", "moss_nrm.jpg", "moss_orm.jpg", "rock_diff.jpg"]) + await writeFile(path.join(dcc, name), `TN_DCC_TEXTURE ${name}`); + const table = path.join(dcc, "layers.json"); + await writeFile( + table, + JSON.stringify({ + base: { + id: "moss", + metalness: 0, + normal: true, + orm: true, + roughness: 0.9, + tile: 3, + tint: [0.36, 0.47, 0.27], + }, + breakup: { push: 0.2, scale: 0.3 }, + layers: [ + { + channel: "r", + hi: 0.8, + id: "rock", + lo: 0.2, + mask: "moss-mask", + metalness: 0.1, + roughness: 0.7, + tile: 6, + tint: [1, 1, 1], + }, + ], + macro: { max: 1.1, min: 0.9, scale: 0.05 }, + masks: { "moss-mask": { image: "moss-mask.png" } }, + splatSize: 8, + textures: { diff: "{id}_diff.jpg", nrm: "{id}_nrm.jpg", orm: "{id}_orm.jpg" }, + }), + ); + return table; + } + + it("writes the ORM map of a layer whose material has a roughness input, beside its other maps", async () => { + const root = await makeTempDir("tn-export-terrain-layers-"); + const blend = await makeFixtureBlend(root); + const out = path.join(root, "package"); + await blenderRun(root, [ + "-b", + blend, + "--python", + recipeScript, + "--", + "--out", + out, + "--cell", + "64", + "--terrain-layers", + await terrainTable(root), + ]); + const tex = path.join(out, "terrain/tex"); + expect(jsonFiles(tex)).toEqual([ + "moss_diff.jpg", + "moss_nrm.jpg", + "moss_orm.jpg", + "rock_diff.jpg", + ]); + expect(readFileSync(path.join(tex, "moss_orm.jpg"), "utf8")).toBe( + "TN_DCC_TEXTURE moss_orm.jpg", + ); + // The shipped table carries each layer's light response: the ORM flag, or its two numbers. + const shipped = JSON.parse(readFileSync(path.join(out, "terrain/layers.json"), "utf8")) as { + base: { orm?: boolean }; + layers: { roughness: number }[]; + }; + expect(shipped.base.orm).toBe(true); + expect(shipped.layers[0]?.roughness).toBe(0.7); + expect( + JSON.parse(readFileSync(path.join(out, "world.json"), "utf8")).terrain.layers.table, + ).toBe("terrain/layers.json"); + }, 300_000); + + it("refuses a table whose ORM layer names no orm source texture", async () => { + const root = await makeTempDir("tn-export-terrain-layers-noorm-"); + const blend = await makeFixtureBlend(root); + const out = path.join(root, "package"); + const table = await terrainTable(root); + const authored = JSON.parse(readFileSync(table, "utf8")) as { + textures: Record; + }; + authored.textures = { diff: "{id}_diff.jpg", nrm: "{id}_nrm.jpg" }; + await writeFile(table, JSON.stringify(authored)); + await expect( + blenderRun(root, [ + "-b", + blend, + "--python", + recipeScript, + "--", + "--out", + out, + "--cell", + "64", + "--terrain-layers", + table, + ]), + ).rejects.toThrow(); + expect(existsSync(path.join(out, "terrain/tex/moss_orm.jpg"))).toBe(false); + }, 300_000); +}); diff --git a/packages/blender-mcp/gpl/recipes/export_world.py b/packages/blender-mcp/gpl/recipes/export_world.py index fab5f39822..45ea7d3eb1 100644 --- a/packages/blender-mcp/gpl/recipes/export_world.py +++ b/packages/blender-mcp/gpl/recipes/export_world.py @@ -388,6 +388,8 @@ def skipped_collections(layer, under_hidden=False): def terrain_layer_texture(table_dir, textures, layer_id, kind): """The source file of one texture set's map, searched in the table's `textures.search` dirs.""" + if kind not in textures: + fail("terrain layers: the table names no '%s' texture for a layer that asks for one" % kind) name = textures[kind].replace("{id}", layer_id) for relative in textures.get("search", ["."]): base = os.path.normpath(os.path.join(table_dir, relative)) @@ -422,7 +424,9 @@ def export_terrain_layers(out, table_path, root=None): The table (the game's; its DCC shader can read the same file) names its masks, texture sets and blend values. This writes `terrain/layers.json` (the table plus the splat layout), `terrain/splat.rgba8` (masks packed into RGBA8 planes, raw so no cook converts them) and - `terrain/tex/_diff.jpg` / `_nrm.jpg`, and returns the `terrain.layers` paths. + `terrain/tex/_diff.jpg`, `_nrm.jpg` and `_orm.jpg`, and returns the + `terrain.layers` paths. A layer's `normal` and `orm` flags say which maps it ships, and its + `roughness`/`metalness` say how it answers light when it ships no ORM map. Relative paths in the table resolve against `root` when given, else the table's folder. """ import numpy as np @@ -476,6 +480,13 @@ def export_terrain_layers(out, table_path, root=None): terrain_layer_texture(table_dir, textures, layer["id"], "nrm"), os.path.join(tex, "%s_nrm.jpg" % layer["id"]), ) + if layer.get("orm"): + # A material with a roughness input of its own ships that input as occlusion, + # roughness and metalness; the table's numbers stand in for one without it. + shutil.copyfile( + terrain_layer_texture(table_dir, textures, layer["id"], "orm"), + os.path.join(tex, "%s_orm.jpg" % layer["id"]), + ) shipped = {key: table[key] for key in ("base", "breakup", "macro", "layers")} shipped["splat"] = {"masks": placed, "planes": len(planes), "size": size} diff --git a/packages/core/__tests__/world-terrain-splat.spec.ts b/packages/core/__tests__/world-terrain-splat.spec.ts index 8e332cede4..79aeeffb81 100644 --- a/packages/core/__tests__/world-terrain-splat.spec.ts +++ b/packages/core/__tests__/world-terrain-splat.spec.ts @@ -1,20 +1,41 @@ -import { CompressedArrayTexture, CompressedTexture, Texture } from "three"; +import { CompressedArrayTexture, CompressedTexture, DataArrayTexture, Texture } from "three"; import { MeshStandardNodeMaterial } from "three/webgpu"; import { afterEach, describe, expect, it, vi } from "vitest"; import { loadTerrainSplat, stackLayers } from "../src/world-terrain-splat.js"; +/** Every layer states how it responds to light: an `orm` map, or the two numbers themselves. */ const table = { - base: { id: "moss", normal: true, tile: 3, tint: [0.36, 0.47, 0.27] }, + base: { + id: "moss", + metalness: 0, + normal: true, + roughness: 0.9, + tile: 3, + tint: [0.36, 0.47, 0.27], + }, breakup: { push: 0.25, scale: 0.35 }, layers: [ - { channel: "r", hi: 1.35, id: "litter", lo: 0.3, mask: "litter", tile: 3, tint: [1, 1, 1] }, + { + channel: "r", + hi: 1.35, + id: "litter", + lo: 0.3, + mask: "litter", + metalness: 0, + roughness: 0.95, + tile: 3, + tint: [1, 1, 1], + }, { channel: "b", hi: 0.75, id: "grass", lo: 0.25, mask: "b", + metalness: 0, normal: true, + orm: true, + roughness: 0.8, tile: 3.5, tint: [1, 1, 1], }, @@ -24,6 +45,8 @@ const table = { id: "rock", lo: 0.45, mask: "a", + metalness: 0.2, + roughness: 0.6, tile: 7, tint: [1, 1, 1], triplanar: true, @@ -57,13 +80,58 @@ function served(files: Record): { assets: never; requested: str resolve: async (path: string) => [path], texture: async (path: string) => { requested.push(path); - return new Texture(); + return bitmap(4, 4); }, }; return { assets: assets as never, requested }; } -afterEach(() => vi.unstubAllGlobals()); +/** An uncompressed layer of a given size, as the asset loader hands one back (an ImageBitmap). */ +function bitmap(width: number, height: number): Texture { + const texture = new Texture(); + texture.image = { width, height } as never; + return texture; +} + +/** A renderer that records the GPU copies it is asked for, one per array layer. */ +function copyingRenderer(): { raw: unknown; layers: number[] } { + const layers: number[] = []; + return { + layers, + raw: { + copyTextureToTexture: ( + _source: unknown, + _destination: unknown, + _r: unknown, + at: { z: number }, + ) => layers.push(at.z), + initTexture: () => undefined, + }, + }; +} + +/** Every distinct texture the material's node graphs sample: one binding each. */ +function sampledTextures(material: MeshStandardNodeMaterial): Set { + const found = new Set(); + const walk = ( + node: { value?: unknown; getChildren?: () => unknown[] } | null | undefined, + ): void => { + if (node === null || node === undefined) return; + if (node.value instanceof Texture) found.add(node.value); + for (const child of node.getChildren?.() ?? []) walk(child as typeof node); + }; + walk(material.colorNode as never); + walk(material.normalNode as never); + walk(material.roughnessNode as never); + walk(material.aoNode as never); + walk(material.metalnessNode as never); + return found; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); describe("loadTerrainSplat", () => { it("builds the package's splat surface from its own table, planes and texture sets", async () => { @@ -76,11 +144,12 @@ describe("loadTerrainSplat", () => { expect(surface).toBeInstanceOf(MeshStandardNodeMaterial); expect((surface as MeshStandardNodeMaterial).colorNode).toBeTruthy(); expect((surface as MeshStandardNodeMaterial).normalNode).toBeTruthy(); - // Every set's albedo, and a normal only where the table asks for one. + // Every set's albedo, a normal and an ORM map only where the table asks for one. expect(requested.sort()).toEqual( [ "world/terrain/tex/grass_diff.jpg", "world/terrain/tex/grass_nrm.jpg", + "world/terrain/tex/grass_orm.jpg", "world/terrain/tex/litter_diff.jpg", "world/terrain/tex/moss_diff.jpg", "world/terrain/tex/moss_nrm.jpg", @@ -89,6 +158,41 @@ describe("loadTerrainSplat", () => { ); }); + it("drives roughness, occlusion and metalness from the table and its ORM maps", async () => { + const { assets } = served({ + "world/world.json": world({ splat: "terrain/splat.rgba8", table: "terrain/layers.json" }), + "world/terrain/layers.json": table, + "world/terrain/splat.rgba8": new Uint8Array(2 * 4 * 4 * 4), + }); + const surface = (await loadTerrainSplat({ + assets, + renderer: copyingRenderer() as never, + url: "world/world.json", + })) as MeshStandardNodeMaterial; + // The surface's whole light response comes from the table: no scalar left in the package. + expect(surface.roughnessNode).toBeTruthy(); + expect(surface.aoNode).toBeTruthy(); + expect(surface.metalnessNode).toBeTruthy(); + expect(surface.roughness).toBe(1); + expect(surface.metalness).toBe(0); + // splat + albedo + normal + the single ORM layer's array. + expect(sampledTextures(surface).size).toBe(4); + }); + + it("refuses a layer that states neither an ORM map nor its own roughness and metalness", async () => { + const { assets } = served({ + "world/world.json": world({ splat: "terrain/splat.rgba8", table: "terrain/layers.json" }), + "world/terrain/layers.json": { + ...table, + layers: [{ channel: "r", hi: 1, id: "rock", lo: 0, mask: "a", tile: 7, tint: [1, 1, 1] }], + }, + "world/terrain/splat.rgba8": new Uint8Array(2 * 4 * 4 * 4), + }); + await expect(loadTerrainSplat({ assets, url: "world/world.json" })).rejects.toThrow( + /'rock' declares no orm map/u, + ); + }); + it("refuses a package without terrain layers instead of drawing a default", async () => { const { assets } = served({ "world/world.json": world(undefined) }); await expect(loadTerrainSplat({ assets, url: "world/world.json" })).rejects.toThrow( @@ -104,6 +208,74 @@ describe("loadTerrainSplat", () => { }); await expect(loadTerrainSplat({ assets, url: "world/world.json" })).rejects.toThrow(/plane 1/u); }); + + it("stacks sixteen same-size uncompressed layers into one array texture per set", async () => { + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const { assets } = served({ + "world/world.json": world({ splat: "terrain/splat.rgba8", table: "terrain/layers.json" }), + "world/terrain/layers.json": { + ...table, + layers: Array.from({ length: 15 }, (_unused, index) => ({ + channel: "r", + hi: 1, + id: `layer-${String(index)}`, + lo: 0, + mask: "a", + metalness: 0, + normal: true, + roughness: 0.7, + tile: 3, + tint: [1, 1, 1], + })), + splat: { masks: { a: [0, "a"] }, planes: 1, size: 4 }, + }, + "world/terrain/splat.rgba8": new Uint8Array(4 * 4 * 4), + }); + const surface = (await loadTerrainSplat({ + assets, + renderer: copyingRenderer() as never, + url: "world/world.json", + })) as MeshStandardNodeMaterial; + // The splat planes plus one array per set: three sampled textures for sixteen layers. + expect(sampledTextures(surface).size).toBe(3); + expect(info).toHaveBeenCalledWith("TN_TERRAIN_SPLAT layers=16 samplers=3 stacked=2"); + }); + + it("names the sampler count a set that cannot stack costs", async () => { + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const sizes = new Map(); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "world/world.json") + return new Response( + JSON.stringify(world({ splat: "terrain/splat.rgba8", table: "terrain/layers.json" })), + ); + if (url === "world/terrain/splat.rgba8") + return new Response(new Blob([new Uint8Array(2 * 4 * 4 * 4)])); + return new Response(JSON.stringify(table)); + }), + ); + // One layer at a size the others cannot match, so the albedo set keeps its own samplers. + sizes.set("world/terrain/tex/rock_diff.jpg", [8, 8]); + const assets = { + resolve: async (path: string) => [path], + texture: async (path: string) => { + const size = sizes.get(path) ?? [4, 4]; + return bitmap(size[0], size[1]); + }, + }; + const surface = (await loadTerrainSplat({ + assets: assets as never, + renderer: copyingRenderer() as never, + url: "world/world.json", + })) as MeshStandardNodeMaterial; + // splat + 4 albedos (one layer's size the rest do not share) + a stacked normal and ORM set. + expect(sampledTextures(surface).size).toBe(7); + expect(info).toHaveBeenCalledWith("TN_TERRAIN_SPLAT layers=4 samplers=7 stacked=2"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("bind 4 samplers")); + }); }); describe("stackLayers", () => { @@ -130,8 +302,23 @@ describe("stackLayers", () => { expect((stacked.mipmaps[1]?.data as Uint8Array).byteLength).toBe(24); }); - it("refuses to stack a mixed codec or an uncompressed layer", () => { + it("refuses to stack a mixed codec", () => { expect(stackLayers([layer(1), layer(2, 1024)])).toBeUndefined(); - expect(stackLayers([new Texture(), new Texture()])).toBeUndefined(); + }); + + it("stacks same-size uncompressed layers through one GPU copy per layer", () => { + const renderer = copyingRenderer(); + const stacked = stackLayers( + [bitmap(4, 4), bitmap(4, 4), bitmap(4, 4)], + renderer as never, + ) as DataArrayTexture; + expect(stacked).toBeInstanceOf(DataArrayTexture); + expect((stacked.image as { depth: number }).depth).toBe(3); + expect(renderer.layers).toEqual([0, 1, 2]); + }); + + it("refuses uncompressed layers of mixed sizes, and any set with no renderer to copy with", () => { + expect(stackLayers([bitmap(4, 4), bitmap(8, 8)], copyingRenderer() as never)).toBeUndefined(); + expect(stackLayers([bitmap(4, 4), bitmap(4, 4)])).toBeUndefined(); }); }); diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index a6686b28b8..e6f9776372 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -3730,10 +3730,10 @@ { "aliases": [], "constraints": [ - "the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by the `export_terrain_layers.py` recipe", - "WebGPU allows 16 sampled textures per stage: planes + diffuse maps + normal maps must fit" + "the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by `export_terrain_layers` in the `export_world.py` recipe", + "WebGPU allows 16 sampled textures per stage: same-size layers stack into one array texture per set, and `TN_TERRAIN_SPLAT samplers=` reports what is left" ], - "example": "const surface = await loadTerrainSplat({ assets: ctx.assets, url: \"world/world.json\" });\nconst world = await WorldCells.load({ assets: ctx.assets, url: \"world/world.json\", surface, follow, ring: 2 });", + "example": "const surface = await loadTerrainSplat({ assets: ctx.assets, renderer: ctx.renderer, url: \"world/world.json\" });\nconst world = await WorldCells.load({ assets: ctx.assets, url: \"world/world.json\", surface, follow, ring: 2 });", "importPath": "@threenative/core/world", "kind": "function", "overrides": [ @@ -3745,7 +3745,7 @@ "terrain textured by splat masks exported from Blender with the world package", "the game's terrain should match the DCC's terrain material without a second copy" ], - "summary": "The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Nothing here is a look choice: textures, tiles, tints, thresholds and noise scales all come from the package's table, which the game authors once and its DCC shares.", + "summary": "The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Each layer answers light from its own ORM map where the table ships one and from the table's own roughness and metalness where it does not. Nothing here is a look choice: textures, tiles, tints, thresholds, surface response and noise scales all come from the package's table, which the game authors once and its DCC shares.", "supersedes": [], "symbol": "loadTerrainSplat" }, diff --git a/packages/core/gpl/recipes/export_world.py b/packages/core/gpl/recipes/export_world.py index fab5f39822..45ea7d3eb1 100644 --- a/packages/core/gpl/recipes/export_world.py +++ b/packages/core/gpl/recipes/export_world.py @@ -388,6 +388,8 @@ def skipped_collections(layer, under_hidden=False): def terrain_layer_texture(table_dir, textures, layer_id, kind): """The source file of one texture set's map, searched in the table's `textures.search` dirs.""" + if kind not in textures: + fail("terrain layers: the table names no '%s' texture for a layer that asks for one" % kind) name = textures[kind].replace("{id}", layer_id) for relative in textures.get("search", ["."]): base = os.path.normpath(os.path.join(table_dir, relative)) @@ -422,7 +424,9 @@ def export_terrain_layers(out, table_path, root=None): The table (the game's; its DCC shader can read the same file) names its masks, texture sets and blend values. This writes `terrain/layers.json` (the table plus the splat layout), `terrain/splat.rgba8` (masks packed into RGBA8 planes, raw so no cook converts them) and - `terrain/tex/_diff.jpg` / `_nrm.jpg`, and returns the `terrain.layers` paths. + `terrain/tex/_diff.jpg`, `_nrm.jpg` and `_orm.jpg`, and returns the + `terrain.layers` paths. A layer's `normal` and `orm` flags say which maps it ships, and its + `roughness`/`metalness` say how it answers light when it ships no ORM map. Relative paths in the table resolve against `root` when given, else the table's folder. """ import numpy as np @@ -476,6 +480,13 @@ def export_terrain_layers(out, table_path, root=None): terrain_layer_texture(table_dir, textures, layer["id"], "nrm"), os.path.join(tex, "%s_nrm.jpg" % layer["id"]), ) + if layer.get("orm"): + # A material with a roughness input of its own ships that input as occlusion, + # roughness and metalness; the table's numbers stand in for one without it. + shutil.copyfile( + terrain_layer_texture(table_dir, textures, layer["id"], "orm"), + os.path.join(tex, "%s_orm.jpg" % layer["id"]), + ) shipped = {key: table[key] for key in ("base", "breakup", "macro", "layers")} shipped["splat"] = {"masks": placed, "planes": len(planes), "size": size} diff --git a/packages/core/src/world-terrain-splat.ts b/packages/core/src/world-terrain-splat.ts index 7c6cbca3bc..6d7c7c942d 100644 --- a/packages/core/src/world-terrain-splat.ts +++ b/packages/core/src/world-terrain-splat.ts @@ -10,6 +10,7 @@ import { RepeatWrapping, type Texture, UnsignedByteType, + Vector3, } from "three"; import { cameraViewMatrix, @@ -29,16 +30,22 @@ import { import type { Node } from "three/webgpu"; import { MeshStandardNodeMaterial } from "three/webgpu"; import type { IAssetLoader } from "./assets.js"; +import type { IRendererLike } from "./renderer.js"; import type { IWorldExtent } from "./world-package.js"; type Channel = "r" | "g" | "b" | "a"; -/** One texture set: albedo (and optionally a normal map) tiled in metres, tinted in linear. */ +/** One texture set: albedo (and optionally a normal and ORM map) tiled in metres, tinted in linear. */ export interface ITerrainSplatLayer { readonly id: string; readonly tile: number; readonly tint: readonly [number, number, number]; readonly normal?: boolean; + /** `_orm.jpg`: occlusion in r, roughness in g, metalness in b, read as linear data. */ + readonly orm?: boolean; + /** Without an ORM map, how this layer answers light. Required unless `orm` is true. */ + readonly metalness?: number; + readonly roughness?: number; readonly saturation?: number; /** Box-projected, for cliffs a top-down projection smears. */ readonly triplanar?: boolean; @@ -74,12 +81,17 @@ export interface ITerrainSplatTable { readonly planes: number; readonly masks: Readonly>; }; - /** Package-relative folder of `_diff.jpg` and `_nrm.jpg`. */ + /** Package-relative folder of `_diff.jpg`, `_nrm.jpg` and `_orm.jpg`. */ readonly textures: string; } export interface ILoadTerrainSplatOptions { readonly assets: IAssetLoader; + /** + * The live renderer. Uncompressed layers stack into one array texture per set through GPU copies, + * which need one; without it every layer keeps its own sampler and the marker says so. + */ + readonly renderer?: IRendererLike; /** The world package's `world.json`, as `WorldCells.load` takes it. */ readonly url: string; } @@ -136,17 +148,104 @@ function splatArray(bytes: Uint8Array, size: number, planes: number): Texture { return map; } +/** The two texture-copy calls `stackUncompressed` needs, as the live renderer exposes them. */ +interface IRawTextureCopier { + readonly copyTextureToTexture?: ( + source: Texture, + destination: Texture, + sourceRegion?: unknown, + destinationPosition?: unknown, + ) => void; + readonly initTexture?: (texture: Texture) => void; +} + +/** A loaded layer's texel size on the GPU, whatever its pixels are held in. */ +function gpuSize(map: Texture): { readonly width: number; readonly height: number } | undefined { + const image = map.image as { width?: number; height?: number } | undefined; + const { width, height } = image ?? {}; + return typeof width === "number" && typeof height === "number" && width > 0 && height > 0 + ? { width, height } + : undefined; +} + +/** + * Same-size uncompressed layers as one `DataArrayTexture`: one sampler, whatever the layer count. + * Every layer arrives as a GPU copy out of its own texture, so the pixels never come back to the + * CPU — and the array itself is created with no CPU data to upload, its layers written instead. + * + * `undefined` when they cannot stack (a size the others do not share) or when the host has no + * initialised renderer to copy with. + */ +function stackUncompressed( + maps: readonly Texture[], + renderer: IRendererLike | undefined, +): Texture | undefined { + const raw = renderer?.raw as IRawTextureCopier | undefined; + if (raw === undefined || raw.initTexture === undefined || raw.copyTextureToTexture === undefined) + return undefined; + const size = gpuSize(maps[0] as Texture); + if (size === undefined) return undefined; + const sameSize = maps.every((map) => { + const other = gpuSize(map); + return other !== undefined && other.width === size.width && other.height === size.height; + }); + if (!sameSize) return undefined; + + const first = maps[0] as Texture; + const array = new DataArrayTexture( + new Uint8Array(size.width * size.height * 4 * maps.length), + size.width, + size.height, + maps.length, + ); + array.format = first.format; + array.type = first.type; + array.colorSpace = first.colorSpace; + array.minFilter = first.minFilter; + array.magFilter = first.magFilter; + array.wrapS = RepeatWrapping; + array.wrapT = RepeatWrapping; + array.anisotropy = 8; + array.flipY = false; + try { + // One creation of the GPU texture, with every mip level it will ever have, and nothing to + // upload: `dataReady` false is how three is told a texture's texels are already on the GPU. + array.needsUpdate = true; + array.source.dataReady = false; + raw.initTexture(array); + maps.forEach((map, layer) => { + raw.initTexture?.(map); + raw.copyTextureToTexture?.(map, array, null, new Vector3(0, 0, layer)); + }); + } catch (error) { + // An array nothing finished writing would draw whatever the copy left behind: fall back. + array.dispose(); + console.warn( + `TN_TERRAIN_SPLAT: GPU stacking failed (${String(error)}); these layers bind their own samplers.`, + ); + return undefined; + } + array.image.data = new Uint8Array(0); + for (const map of maps) map.dispose(); + return array; +} + /** - * Same-format, same-size compressed layers as one `CompressedArrayTexture`, or `undefined` when - * they cannot stack (a mixed codec, a size the cap did not equalise, an uncompressed fallback). + * Same-format, same-size compressed layers as one `CompressedArrayTexture`, and same-size + * uncompressed ones as one `DataArrayTexture`, or `undefined` when they cannot stack (a mixed + * codec, a size the cap did not equalise, no renderer to copy uncompressed layers with). * * WebGPU guarantees 16 samplers a stage and three binds one per texture; a splat terrain's albedos - * and normals as separate textures plus an open-world shadow's maps pass 16 and the pipeline is + * and normals as separate textures plus an open-world shadow's maps passes 16 and the pipeline is * invalid. Stacked, a surface costs one sampler per array however many layers it blends. */ -export function stackLayers(maps: readonly Texture[]): Texture | undefined { +export function stackLayers( + maps: readonly Texture[], + renderer?: IRendererLike, +): Texture | undefined { const first = maps[0] as CompressedTexture | undefined; - if (first === undefined || first.isCompressedTexture !== true) return undefined; + if (first === undefined) return undefined; + if (first.isCompressedTexture !== true) return stackUncompressed(maps, renderer); const width = (first.image as { width: number }).width; const height = (first.image as { height: number }).height; const stackable = maps.every((map) => { @@ -200,16 +299,18 @@ export function stackLayers(maps: readonly Texture[]): Texture | undefined { * heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness * variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up * authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries - * a normal map. Nothing here is a look choice: textures, tiles, tints, thresholds and noise scales - * all come from the package's table, which the game authors once and its DCC shares. + * a normal map. Each layer answers light from its own ORM map where the table ships one and from + * the table's own roughness and metalness where it does not. Nothing here is a look choice: + * textures, tiles, tints, thresholds, surface response and noise scales all come from the package's + * table, which the game authors once and its DCC shares. * * @situation terrain textured by splat masks exported from Blender with the world package * @situation the game's terrain should match the DCC's terrain material without a second copy - * @constraint the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by the `export_terrain_layers.py` recipe - * @constraint WebGPU allows 16 sampled textures per stage: planes + diffuse maps + normal maps must fit + * @constraint the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by `export_terrain_layers` in the `export_world.py` recipe + * @constraint WebGPU allows 16 sampled textures per stage: same-size layers stack into one array texture per set, and `TN_TERRAIN_SPLAT samplers=` reports what is left * @override every value comes from the package's table; the returned material is the game's to adjust * @example - * const surface = await loadTerrainSplat({ assets: ctx.assets, url: "world/world.json" }); + * const surface = await loadTerrainSplat({ assets: ctx.assets, renderer: ctx.renderer, url: "world/world.json" }); * const world = await WorldCells.load({ assets: ctx.assets, url: "world/world.json", surface, follow, ring: 2 }); */ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promise { @@ -218,7 +319,7 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi const layerPaths = world.layers; if (layerPaths.table === undefined || layerPaths.splat === undefined) throw new Error( - "loadTerrainSplat: world.json has no terrain.layers.table/splat; export them with export_terrain_layers.py.", + "loadTerrainSplat: world.json has no terrain.layers.table/splat; export them with the export_world.py recipe.", ); const dir = packageDir(url); const table = (await ( @@ -241,9 +342,22 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi for (let plane = 0; plane < table.splat.planes; plane += 1) planes.push(texture(splat, maskUv).depth(int(plane))); - const load = async (layer: ITerrainSplatLayer, kind: "diff" | "nrm"): Promise => { + const all: ITerrainSplatLayer[] = [table.base, ...table.layers]; + for (const layer of all) + if ( + layer.orm !== true && + (typeof layer.roughness !== "number" || typeof layer.metalness !== "number") + ) + throw new Error( + `loadTerrainSplat: terrain layer '${layer.id}' declares no orm map and no roughness/metalness; give it "orm": true or both numbers in the table the export recipe wrote.`, + ); + + const load = async ( + layer: ITerrainSplatLayer, + kind: "diff" | "nrm" | "orm", + ): Promise => { const map = await assets.texture(`${dir}${table.textures}/${layer.id}_${kind}.jpg`, { - data: kind === "nrm", + data: kind !== "diff", }); map.wrapS = RepeatWrapping; map.wrapT = RepeatWrapping; @@ -251,27 +365,35 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi map.needsUpdate = true; return map; }; - const all: ITerrainSplatLayer[] = [table.base, ...table.layers]; const withNormals = all.filter((layer) => layer.normal === true); + const withOrm = all.filter((layer) => layer.orm === true); const diffuseMaps = await Promise.all(all.map((layer) => load(layer, "diff"))); const normalMaps = await Promise.all(withNormals.map((layer) => load(layer, "nrm"))); + const ormMaps = await Promise.all(withOrm.map((layer) => load(layer, "orm"))); // One sampler per set when the layers stack; separate textures (and a warning) when they do not. + let samplers = 1; + let stackedSets = 0; const sampler = ( maps: readonly Texture[], what: string, ): ((layer: number, uv: Node<"vec2">) => Node<"vec3">) => { - const stacked = stackLayers(maps); - if (stacked !== undefined) return (layer, uv) => texture(stacked, uv).depth(int(layer)).rgb; + const stacked = stackLayers(maps, options.renderer); + if (stacked !== undefined) { + samplers += 1; + stackedSets += 1; + return (layer, uv) => texture(stacked, uv).depth(int(layer)).rgb; + } + samplers += maps.length; if (maps.length > 1) console.warn( - `TN_TERRAIN_SPLAT: ${what} layers could not stack into one array (mixed format or size); ` + - `they bind ${String(maps.length)} samplers, and WebGPU guarantees 16 a stage.`, + `TN_TERRAIN_SPLAT: ${what} layers could not stack into one array (mixed format or size, or no renderer to copy them with); they bind ${String(maps.length)} samplers, and WebGPU guarantees 16 a stage.`, ); return (layer, uv) => texture(maps[layer] as Texture, uv).rgb; }; const diffuseAt = sampler(diffuseMaps, "albedo"); const normalAt = withNormals.length === 0 ? undefined : sampler(normalMaps, "normal"); + const ormAt = withOrm.length === 0 ? undefined : sampler(ormMaps, "ORM"); const ground = vec2(positionWorld.x, positionWorld.z.negate()); const maskOf = (layer: ITerrainSplatMaskedLayer): Node<"float"> => { @@ -312,9 +434,19 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi ? undefined : normalAt(slot, ground.div(layer.tile)); }; + /** + * A layer's answer to light, as occlusion in r, roughness in g and metalness in b: its ORM map + * where it ships one, else the two numbers the table states (and no occlusion to apply). + */ + const ormOf = (layer: ITerrainSplatLayer): Node<"vec3"> => { + const slot = withOrm.indexOf(layer); + if (slot !== -1 && ormAt !== undefined) return ormAt(slot, ground.div(layer.tile)); + return vec3(1, layer.roughness ?? 0, layer.metalness ?? 0); + }; let color = albedo(table.base, 0); let normalSample: Node<"vec3"> = normalOf(table.base) ?? vec3(0.5, 0.5, 1); + let ormSample: Node<"vec3"> = ormOf(table.base); table.layers.forEach((layer, offset) => { const weight = clamp( maskOf(layer) @@ -327,6 +459,7 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi color = mix(color, albedo(layer, offset + 1), weight); const nrm = normalOf(layer); if (nrm !== undefined) normalSample = mix(normalSample, nrm, weight); + ormSample = mix(ormSample, ormOf(layer), weight); }); const macro = mx_noise_float(vec3(ground.mul(table.macro.scale), 0)) .mul(0.5) @@ -334,7 +467,9 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi .mul(table.macro.max - table.macro.min) .add(table.macro.min); - const material = new MeshStandardNodeMaterial({ metalness: 0, roughness: 0.92 }); + // Every layer's own light response, blended by the same weights as its colour: the material + // carries no surface constant of its own. + const material = new MeshStandardNodeMaterial(); material.name = "terrain-splat"; material.colorNode = color.mul(macro); // A top-down projection's tangent frame: +x along u, ground +y (three -z) along v, OpenGL green @@ -345,5 +480,12 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi const t = normalSample.mul(2).sub(1); const perturbed = tangent.mul(t.x).add(bitangent.mul(t.y)).add(n.mul(t.z)).normalize(); material.normalNode = cameraViewMatrix.mul(vec4(perturbed, 0)).xyz.normalize(); + material.aoNode = ormSample.r; + material.roughnessNode = ormSample.g; + material.metalnessNode = ormSample.b; + // One line, printed and kept on the material: what this surface costs, readable by a game that + // wants to assert it rather than scrape a console. + material.userData.TN_TERRAIN_SPLAT = `layers=${String(all.length)} samplers=${String(samplers)} stacked=${String(stackedSets)}`; + console.info(`TN_TERRAIN_SPLAT ${material.userData.TN_TERRAIN_SPLAT as string}`); return material; } diff --git a/packages/create-threenative/agent-docs/references/capability-reference.md b/packages/create-threenative/agent-docs/references/capability-reference.md index 6789a58f6e..4b65a8776e 100644 --- a/packages/create-threenative/agent-docs/references/capability-reference.md +++ b/packages/create-threenative/agent-docs/references/capability-reference.md @@ -2917,18 +2917,18 @@ const sampleHeight = heightSamplerFromHeightmap(terrain, extent, await loadWorld ### `loadTerrainSplat` -`function` — The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Nothing here is a look choice: textures, tiles, tints, thresholds and noise scales all come from the package's table, which the game authors once and its DCC shares. +`function` — The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Each layer answers light from its own ORM map where the table ships one and from the table's own roughness and metalness where it does not. Nothing here is a look choice: textures, tiles, tints, thresholds, surface response and noise scales all come from the package's table, which the game authors once and its DCC shares. ```ts export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promise { … } ``` - **Use when:** terrain textured by splat masks exported from Blender with the world package · the game's terrain should match the DCC's terrain material without a second copy -- **Constraints:** the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by the `export_terrain_layers.py` recipe · WebGPU allows 16 sampled textures per stage: planes + diffuse maps + normal maps must fit +- **Constraints:** the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by `export_terrain_layers` in the `export_world.py` recipe · WebGPU allows 16 sampled textures per stage: same-size layers stack into one array texture per set, and `TN_TERRAIN_SPLAT samplers=` reports what is left - **Overrides:** every value comes from the package's table; the returned material is the game's to adjust ```ts -const surface = await loadTerrainSplat({ assets: ctx.assets, url: "world/world.json" }); +const surface = await loadTerrainSplat({ assets: ctx.assets, renderer: ctx.renderer, url: "world/world.json" }); const world = await WorldCells.load({ assets: ctx.assets, url: "world/world.json", surface, follow, ring: 2 }); ``` diff --git a/packages/create-threenative/capabilities.json b/packages/create-threenative/capabilities.json index a6686b28b8..e6f9776372 100644 --- a/packages/create-threenative/capabilities.json +++ b/packages/create-threenative/capabilities.json @@ -3730,10 +3730,10 @@ { "aliases": [], "constraints": [ - "the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by the `export_terrain_layers.py` recipe", - "WebGPU allows 16 sampled textures per stage: planes + diffuse maps + normal maps must fit" + "the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by `export_terrain_layers` in the `export_world.py` recipe", + "WebGPU allows 16 sampled textures per stage: same-size layers stack into one array texture per set, and `TN_TERRAIN_SPLAT samplers=` reports what is left" ], - "example": "const surface = await loadTerrainSplat({ assets: ctx.assets, url: \"world/world.json\" });\nconst world = await WorldCells.load({ assets: ctx.assets, url: \"world/world.json\", surface, follow, ring: 2 });", + "example": "const surface = await loadTerrainSplat({ assets: ctx.assets, renderer: ctx.renderer, url: \"world/world.json\" });\nconst world = await WorldCells.load({ assets: ctx.assets, url: \"world/world.json\", surface, follow, ring: 2 });", "importPath": "@threenative/core/world", "kind": "function", "overrides": [ @@ -3745,7 +3745,7 @@ "terrain textured by splat masks exported from Blender with the world package", "the game's terrain should match the DCC's terrain material without a second copy" ], - "summary": "The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Nothing here is a look choice: textures, tiles, tints, thresholds and noise scales all come from the package's table, which the game authors once and its DCC shares.", + "summary": "The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Each layer answers light from its own ORM map where the table ships one and from the table's own roughness and metalness where it does not. Nothing here is a look choice: textures, tiles, tints, thresholds, surface response and noise scales all come from the package's table, which the game authors once and its DCC shares.", "supersedes": [], "symbol": "loadTerrainSplat" }, diff --git a/packages/runtime-native/conformance/registry.json b/packages/runtime-native/conformance/registry.json index 3dc603cdf9..5d4879c9b1 100644 --- a/packages/runtime-native/conformance/registry.json +++ b/packages/runtime-native/conformance/registry.json @@ -788,6 +788,21 @@ "desktopGate": true, "required": true }, + { + "id": "terrain-splat-array", + "title": "sixteen-layer terrain splat through four array textures", + "category": "world", + "status": "implemented", + "scene": "conformance/scenes/shared/terrain-splat-array.js", + "browserRecipe": "webgpu", + "requiresHardwareAdapter": true, + "tolerance": { + "pixelMismatchRatio": 0.08, + "perceptualDeltaE": 10.0 + }, + "desktopGate": true, + "required": true + }, { "id": "100-world-field-hash", "title": "procedural world field hash parity", diff --git a/packages/runtime-native/conformance/scenes/shared/terrain-splat-array.js b/packages/runtime-native/conformance/scenes/shared/terrain-splat-array.js new file mode 100644 index 0000000000..8696d9fe95 --- /dev/null +++ b/packages/runtime-native/conformance/scenes/shared/terrain-splat-array.js @@ -0,0 +1,150 @@ +import { loadTerrainSplat } from "../../../../core/src/world-terrain-splat.ts"; +import { assertCondition, startVisualScene, THREE } from "./scene-support.js"; + +/** + * A sixteen-layer splat package whose 48 maps are uncompressed and the same size — the shape + * Android and iOS get, having no KTX2 transcoder. `loadTerrainSplat` stacks each set into one + * array texture by GPU copy, so the surface binds four sampled textures instead of forty-nine; + * this row is what says the native host agrees with the browser. + */ + +const LAYERS = 16; +const PLANE = 4; // mask texels a side; each layer owns one, and cell 0 is left to the base +const PLANES = 4; +const MAP = 8; // map texels a side +const HALF = 4; // the ground spans HALF metres either side of the origin + +/** Every fixture byte is a pure function of its cell: both lanes must derive the same one. */ +function mapRgb(layer, kind, x, y) { + if (kind === "nrm") return [128 + (layer % 5) * 24, 128 + (((layer >> 1) % 5) * 20) % 127, 255]; + if (kind === "orm") return [255, 40 + ((layer * 17) % 200), (layer % 3) * 40]; + const step = 2 + (layer % 4); + const on = (Math.floor(x / step) + Math.floor(y / step) + layer) % 2 === 0; + return on ? [230, 90 + (layer % 6) * 25, 60] : [50, 40 + (layer % 3) * 20, 90 + (layer % 5) * 20]; +} + +/** One map as the uncompressed RGBA8 texture an uncompressed layer ships. */ +function layerTexture(layer, kind, srgb) { + const data = new Uint8Array(MAP * MAP * 4); + for (let y = 0; y < MAP; y += 1) + for (let x = 0; x < MAP; x += 1) { + const rgb = mapRgb(layer, kind, x, y); + const at = (y * MAP + x) * 4; + data[at] = rgb[0]; + data[at + 1] = rgb[1]; + data[at + 2] = rgb[2]; + data[at + 3] = 255; + } + const texture = new THREE.DataTexture(data, MAP, MAP, THREE.RGBAFormat); + texture.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace; + texture.flipY = false; + texture.needsUpdate = true; + return texture; +} + +/** The mask planes: layer `slot` is lit in cell `slot + 1` of a 4x4 grid, and nowhere else. */ +function splatBytes() { + const bytes = new Uint8Array(PLANES * PLANE * PLANE * 4); + for (let slot = 0; slot < LAYERS - 1; slot += 1) { + const cell = slot + 1; + bytes[(Math.floor(cell / PLANE) * PLANE + (cell % PLANE)) * 4 + (slot % 4)] = 255; + } + return bytes; +} + +function layersTable() { + const masks = {}; + const layers = []; + for (let slot = 0; slot < LAYERS - 1; slot += 1) { + masks[`m${slot}`] = [Math.floor(slot / 4), "rgb"]; + layers.push({ + channel: ["r", "g", "b", "a"][slot % 4], + hi: 0.75, + id: `layer${slot}`, + lo: 0.25, + mask: `m${slot}`, + normal: true, + orm: true, + tile: 2, + tint: [0.4 + (slot % 5) * 0.14, 0.95 - (slot % 3) * 0.22, 0.35 + ((slot >> 1) % 5) * 0.13], + }); + } + return { + base: { id: "base", normal: true, orm: true, tile: 2, tint: [0.85, 0.85, 0.88] }, + breakup: { push: 0.06, scale: 0.35 }, + layers, + macro: { max: 1.1, min: 0.85, scale: 0.12 }, + splat: { masks, planes: PLANES, size: PLANE }, + textures: "layers", + }; +} + +/** world.json, the table and the mask bytes, served the way a game's own package is. */ +function packageAssets() { + const files = new Map([ + [ + "world.json", + JSON.stringify({ + extent: { minX: -HALF, minZ: -HALF, sizeX: HALF * 2, sizeZ: HALF * 2 }, + terrain: { layers: { splat: "layers/splat.rgba8", table: "layers/table.json" } }, + version: 1, + }), + ], + ["layers/table.json", JSON.stringify(layersTable())], + ["layers/splat.rgba8", splatBytes()], + ]); + return { + resolve: async (path) => { + const bytes = files.get(path); + assertCondition(bytes !== undefined, `terrain-splat-array: no fixture at '${path}'`); + return [URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }))]; + }, + texture: async (path, options = {}) => { + const match = /^layers\/(?:base|layer(\d+))_(diff|nrm|orm)\.jpg$/u.exec(path); + assertCondition(match !== null, `terrain-splat-array: unexpected map '${path}'`); + const texture = layerTexture( + match[1] === undefined ? 0 : Number(match[1]), + match[2], + options.data !== true, + ); + texture.name = path; + return texture; + }, + }; +} + +export async function startScene(canvas, dimensions) { + return startVisualScene( + canvas, + dimensions, + "terrain-splat-array", + async ({ renderer, scene }) => { + const material = await loadTerrainSplat({ + assets: packageAssets(), + renderer: { raw: renderer }, + url: "world.json", + }); + const marker = String(material.userData.TN_TERRAIN_SPLAT); + const number = (key) => Number(new RegExp(`${key}=(\\d+)`, "u").exec(marker)?.[1] ?? -1); + assertCondition(number("layers") === LAYERS, `sixteen layers expected, got ${marker}`); + assertCondition(number("stacked") === 3, `three stacked sets expected, got ${marker}`); + assertCondition(number("samplers") === 4, `four sampled textures expected, got ${marker}`); + + const ground = new THREE.Mesh(new THREE.PlaneGeometry(HALF * 2, HALF * 2), material); + ground.rotation.x = -Math.PI / 2; + const sun = new THREE.DirectionalLight(0xffffff, 2.6); + sun.position.set(2, 6, 3); + scene.add(ground, sun, new THREE.AmbientLight(0xa8c0ff, 0.6)); + return { detail: { layers: LAYERS, samplers: 4, stacked: 3 }, ground, material }; + }, + { + background: 0x0d1520, + camera: (size) => { + const camera = new THREE.PerspectiveCamera(50, size.width / size.height, 0.1, 100); + camera.position.set(0, 5.5, 6.5); + camera.lookAt(0, 0, -0.5); + return camera; + }, + }, + ); +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6035423074..7abaf1ed78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -577,6 +577,31 @@ importers: specifier: 'catalog:' version: 8.2.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5) + examples/prd493-terrain-splat: + dependencies: + '@threenative/core': + specifier: workspace:* + version: link:../../packages/core + '@threenative/playtest': + specifier: workspace:* + version: link:../../packages/playtest + three: + specifier: 'catalog:' + version: 0.185.1(patch_hash=c2e051df4c469d033b151cb9da5ce5112c90801aaaa984d6bfde0e09d80c9a66) + devDependencies: + '@threenative/runtime-native': + specifier: workspace:* + version: link:../../packages/runtime-native + '@types/three': + specifier: 0.185.3 + version: 0.185.3 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 'catalog:' + version: 8.2.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5) + examples/quarry: dependencies: '@threenative/core':