Skip to content

feat(world): terrain layers past sixteen textures (PRD-493) - #429

Closed
jonit-dev wants to merge 9 commits into
developfrom
feat/prd-493-terrain-layers
Closed

jonit-dev wants to merge 9 commits into
developfrom
feat/prd-493-terrain-layers

Conversation

@jonit-dev

@jonit-dev jonit-dev commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Terrain layers past sixteen textures (PRD-493)

Why: a splat terrain's layers are one texture each until they stack. Compressed layers already
collapse into one array texture per set; uncompressed ones did not, so any game without a KTX2 cook —
and every Android and iOS build, which has no transcoder — bound one sampler per map and hit WebGPU's
16 sampled textures a stage. The @constraint also named a recipe that does not exist, and the
material hard-coded roughness: 0.92 for every layer, so a Megascans-grade layer's own ORM never
reached the screen.

PRD: docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md

Checklist

  • Phase 1 — every same-size set is one sampler. Uncompressed layers stack through a
    copyTextureToTexture per layer (no CPU pixel read); mixed sizes still fall back.
    proof: packages/core/__tests__/world-terrain-splat.spec.ts 9/9 green;
    pnpm typecheck and pnpm lint exit 0.
  • Phase 1 — @constraint and recipe name corrected, manifest regenerated by pnpm build.
    proof: capability-manifest.spec.ts + generate-capability-reference.spec.ts 28/28 green.
  • Phase 2 — ORM per layer. orm: true in the table drives roughness/AO/metalness from
    <id>_orm; a layer without one uses the table's numbers and the 0.92 look constant is gone
    from the package; the recipe writes _orm beside _diff/_nrm in both copies.
    proof: world-terrain-splat.spec.ts 11/11, export-world.spec.ts 9/9 on Blender 5.2.0 LTS.
  • Phase 3 (desktop) — a conformance row samples the arrays natively.
    terrain-splat-array builds the 16-layer uncompressed package and throws unless the marker
    reads layers=16 samplers=4 stacked=3.
    proof: pnpm parity --target web --only-tests terrain-splat-array captured the reference;
    --target desktop pass with pixelMismatchRatio: 0, perceptualDeltaE: 0, 0 GPU validation
    errors; runtime-native conformance suite 76/76.
  • Phase 3 (Android) — the same row on the emulator. Attempted and stopped in the build:
    :app:verifyV8Dependency needs third_party/v8-android/build-receipt.json, written only by a
    V8 source cross-compile for arm64-v8a + x86_64. The emulator was up (API 35, x86_64) and the
    lane reached gradle, so the blocker is that dependency, not the device.
  • AC-1 / AC-2 — Machinefall's map-walk judgement and its gpuMain p95. apps/client is a
    private repo this branch cannot see, so neither was run; both are listed under ## Blocked on
    in the PRD rather than claimed here.

How the stacking works

flowchart LR
  A["16 layers, same size, uncompressed"] --> B["one DataArrayTexture per set"]
  B --> C["array created: source.dataReady = false, every mip level"]
  A --> D["renderer.initTexture per layer"]
  D --> E["copyTextureToTexture into layer n"]
  E --> F["material binds 4 sampled textures: splat + albedo + normal + ORM"]
Loading

A game passes renderer: ctx.renderer; without it every layer keeps its own sampler and
TN_TERRAIN_SPLAT layers= samplers= stacked= says what that costs.

Proof on a GPU

examples/prd493-terrain-splat is sixteen layers × (albedo, normal, ORM) = 48 uncompressed JPEGs,
written by the shipped export_terrain_layers recipe. On the web recipe and on the native desktop
host it renders with layers=16 samplers=4 stacked=3; the same package on develop never reaches a
frame:

Uncaptured WebGPU GPUValidationError: The number of samplers (34) in the Fragment stage exceeds the maximum per-stage limit (16).

Captures for a judge — before/after, same pose — are in
/home/joao/projects/threenative/threenative-engine/.afk/scratch/prd493-captures/: phase-1-2/
(package on develop vs this branch) and phase-3/ (browser reference vs the desktop-native frame).

Not claimed

The Android row, and Machinefall's own walk. Neither is ticked, and the PR stays a draft until AC-1
has a judge.

… (PRD-493 phase 1)

Sixteen same-size uncompressed layers bound one sampler each, which is what
makes WebGPU's 16 sampled textures a stage the real cap on Android and iOS —
the hosts with no KTX2 transcoder, where nothing stacks today.

Each set now becomes one DataArrayTexture whose layers are filled by a
renderer.copyTextureToTexture out of their own textures: the pixels never
come back to the CPU, and the array is created with source.dataReady = false
so three never uploads placeholder bytes over the copies. Mixed sizes still
fall back, and TN_TERRAIN_SPLAT layers= samplers= stacked= reports what the
material actually costs, one line per material.

Also corrects the stale @constraint: the recipe is export_terrain_layers in
export_world.py, and the cap is now a sampler count the marker names.
capabilities.json and the generated reference carry both.
@jonit-dev jonit-dev added the prd:25% PRD phase 1 verified label Oct 4, 2026
The material hard-coded metalness 0 / roughness 0.92 and gave every layer it,
so a Megascans-grade layer's own occlusion, roughness and metalness never
reached the screen. A layer with "orm": true in the table now reads
<id>_orm.jpg as linear data (r occlusion, g roughness, b metalness) through the
same stacked array and the same blend weights as its colour; a layer without one
uses the roughness and metalness the table states. A layer that states neither
now throws and names the recipe, rather than falling back to a number in the
package — the constant is gone.

The recipe writes _orm beside _diff and _nrm in both copies of export_world.py,
and refuses a table whose ORM layer names no orm source.
@jonit-dev jonit-dev added prd:50% PRD half the phases verified and removed prd:25% PRD phase 1 verified labels Oct 4, 2026
… captures

Unit specs prove the mechanism in node; this example proves it on a real
WebGPU adapter. The package is sixteen layers with an albedo, a normal and an
ORM map each — 48 uncompressed JPEGs — written by the shipped
export_terrain_layers recipe from a table and map files a game would author,
so the thing under test is a DCC export, not a second format.

The run is green: TN_TERRAIN_SPLAT layers=16 samplers=4 stacked=3, 180 frames,
a non-blank changing viewport, and the scenario asserts those numbers through
material.userData rather than trusting a screenshot. The same package on
develop never reaches a frame:

  Uncaptured WebGPU GPUValidationError: The number of samplers (34) in the
  Fragment stage exceeds the maximum per-stage limit (16).

Captures for the judge, same package and same pose:
.afk/scratch/prd493-captures/phase-1-2/{before,after}.png (4 KB uniform vs a
textured 16-band surface). Not a Machinefall result, so AC-1 stays open.
The browser lane proves the stacking; the hosts that matter most are the ones
with no KTX2 transcoder, and there nothing stacked before. This gives the
example a native identity (threenative.config.ts, one description read by both
the web dev server and `threenative build`) and the same assertions as a
desktop scenario: sixteen layers, at most four samplers, three stacked sets.

orbitSeconds is new because a native run that renders one frozen frame would
otherwise satisfy every cost assertion; the camera pose has to advance.

--target desktop on the built host: pass, layers=16 samplers=4 stacked=3,
orbitSeconds 2.03 -> 5.03, diagnostics clean, 0 console errors. The web
scenario with the same assertion is pass on the software-and-hardware
WebGPU recipe; world-terrain-splat.spec.ts 11/11.
The hosts this PRD is for cannot see KTX2, so nothing stacked there, and
nothing proved it: the browser lane cannot fail on a path the browser never
takes. terrain-splat-array builds a sixteen-layer package with an albedo, a
normal and an ORM map per layer — the shape Android and iOS ship — through
loadTerrainSplat, and the scene throws unless the marker reads
layers=16 samplers=4 stacked=3, so the row fails on a regression to one
sampler per map rather than merely looking different.

The 48 maps are DataTextures derived from a pure function of their cell and
the mask grid gives each layer its own region, so both lanes derive the same
frame from nothing but the scene file: the desktop host matches the browser
reference at pixelMismatchRatio 0 and perceptualDeltaE 0 over 1280x720.

The Android leg of the row is left open, not claimed: :app:verifyV8Dependency
refuses to bundle without a V8 build receipt that only an hours-long source
cross-compile writes, and the emulator was up and waiting when it stopped.

Gates: parity --target web pass (reference captured); parity --target desktop
pass, 0/0 metrics, 0 gpu validation errors; runtime-native conformance suite
76/76; pnpm typecheck and pnpm lint exit 0; pnpm check:docs exit 0.
@jonit-dev jonit-dev added prd:75% PRD phases in, acceptance open and removed prd:50% PRD half the phases verified labels Oct 4, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

PRD-493 terrain layers: 16-layer splat on the branch (develop never reached a frame) and web vs native parity

Same build otherwise, same pose. Images live on the prd-493-screenshots branch.

1-develop-before

1-develop-before

2-branch-16-layers

2-branch-16-layers

3-web-reference

3-web-reference

4-native-desktop

4-native-desktop

@jonit-dev

Copy link
Copy Markdown
Contributor Author

Consolidated into #437 (single runbook PR).

@jonit-dev jonit-dev closed this Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:75% PRD phases in, acceptance open

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant