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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <develop> --after <candidate>` 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 <develop> --after <candidate>` 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

Expand All @@ -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 `<id>_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 `<id>_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.
59 changes: 59 additions & 0 deletions examples/prd493-terrain-splat/README.md
Original file line number Diff line number Diff line change
@@ -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).
```
17 changes: 17 additions & 0 deletions examples/prd493-terrain-splat/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>PRD-493 terrain splat</title>
<style>
html, body { height: 100%; margin: 0; }
body { overflow: hidden; background: #0b1a2a; }
canvas { display: block; }
</style>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions examples/prd493-terrain-splat/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
}
Empty file.
Binary file not shown.
Loading
Loading