Skip to content

feat: Machinefall 120 fps runbook — PRD-492 grading, PRD-493 terrain layers, and the following rows - #437

Closed
jonit-dev wants to merge 138 commits into
developfrom
feat/machinefall-120fps-runbook
Closed

jonit-dev wants to merge 138 commits into
developfrom
feat/machinefall-120fps-runbook

Conversation

@jonit-dev

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

Copy link
Copy Markdown
Contributor

Single PR for docs/PRDs/open-world/RUNBOOK-machinefall-120fps-with-fab-quality.md; replaces #429 and #431.

Both lanes merge into this branch unchanged (--no-ff, in the order below); the only commit on top is the regenerated scaffold tree hash, because PRD-493's rewritten capabilities.json entry and capability-reference.md reach every scaffold verbatim.


PRD-492 — colour grading and film grain (from #431)

Branch feat/prd-492-grading, merged --no-ff.

TL;DR

The starter's frame now ends in a colour grade and a film grain, and the stage that does it takes a
new seat in the chain: display-referred, after the output transform. The filed design put a log
shaper inside the stage instead; it was measured, it costs at least 2.0 eight-bit steps per channel,
and this PRD's own box asks for 1. The engine seat it falls back to — named in the PRD before it was
written — lands the box by construction and makes a .cube mean what a grading tool means by it.

Phase 1 only. Phase 2 (native) has not run.

What changed

Where What
templates/starter/src/render/grade.ts (new) the two authored stages: grade (a .cube through lut3D) and grain (film)
templates/starter/public/grade.cube (new), tools/make-grade-lut.mjs (new) the shipped table and the script that writes it, so replacing the table is the whole of "change the grade"
templates/starter/src/render/{postprocessing,quality}.ts load the table, own the two stages, and decide per tier whether each runs
packages/core/src/render/chain.ts, renderer.ts afterOutputTransform on a stage; setOutputNode(node, pass, options) hands the renderer its transform back only when such a stage actually ran
template-assets/worldEnvironment.ts + 13 kit copies the shared ChainStage type learns the flag

Phase 2, since the first push

pnpm native:build completed (409/409), so the desktop lane ran here.

  • Conformance row — passes. pnpm parity --target desktop --only-tests lut-grade:
    status: "pass", pixelMismatchRatio: 0, perceptualDeltaE: 0 over 1280x720,
    gpuValidationErrors: [], host rendering 300 frames in 9.4 s, compared against a web-lane
    reference capture of the same scene. The 17^3 table is a per-channel gain, not the identity, and
    the parsed Data3DTexture is read back in JS against that formula before the frame is compared.
    (The sweep exits 2 because a bounded --only-tests run blocks the other 95 rows.)
  • Desktop render-chain scenario — open. The marker reads
    dropped: [{"name":"grade","reason":"lut:pending"}] and the console carries
    TN_GRADE_TABLE grade.cube: File read error. package-desktop.mjs stages
    selectManifestAssets(assets) alone, so a public/ file the manifest does not declare never
    reaches the host — every unmanaged public file on every native target, not this table alone.
  • That scenario also could not run at all before: its visibility assertion is refused outright
    on desktop (TN_PLAYTEST_OBSERVATION_UNAVAILABLE), so it failed before reading a stage. That row
    is dropped (the runner never produced the observation on this target) and grade/grain are added
    to the stages and contributions the scenario does read.

Evidence that ran

  • pnpm typecheck, pnpm lint (0 errors), 259 vitest files across packages/core and
    packages/create-threenative — all pass.
  • packages/create-threenative/__tests__/grade.spec.ts — 8 passed. It reads the shipped table back
    through a port of LUTCubeLoader.parse, applies lut3D's half-texel pull-in and the hardware's
    own filtering, and measures the identity round trip at ≤ 1 step over every value the frame can
    hold
    (worst 0.875).
  • Playtest look.playtest.json on a scaffolded starter, --browser-recipe webgpu --headed,
    adapterClass: hardware: renderChain.tier, renderChain.stages.includes,
    renderChain.stages.order and renderChain.contributions.graphOutputChanged all pass with
    stages: ["ambientOcclusion","bloom","vignette","antialias","grade","grain"], dropped: [].
  • The low leg needs an adapter the harness classes as software, and Chromium here reaches no
    WebGPU adapter under SwiftShader (TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING). It was run instead on
    a throwaway copy with setupPost(..., { tier: "low" }) pinned: grade applied, dropped: [{"name":"grain","reason":"grainIntensity:0"}], both assertion rows pass. Nothing about that pin
    is committed.

Open, with the reason

  • AC-1 — gpuOther resolved in 1 of 17 windows graded and 0 of 16 ungraded at 1080p on this
    lane, so there is no delta to read. A missing observation is not a zero.
  • AC-1, identity round trip, frame half — quarry compare on the scaffold reads
    maxChannelDelta 86, rmse 0.81 for ungraded vs identity-with-grain-off, and the same ungraded
    build captured twice
    reads maxChannelDelta 114, rmse 0.37. The starter's own animated content
    moves between runs by more than the claim, so a whole-frame maximum cannot carry it. A still-scene
    capture would.
  • AC-2 — capture ready for a judge: .afk/scratch/prd492-captures/phase1/{before,after}.png,
    1920x1080, same scaffold and same pose, the only difference being the grade.
  • Phase 2 — the desktop render-chain scenario and the lut-grade conformance row have not
    run. pnpm build:web already reports the table among 5 undeclared files (38,890 bytes), which is
    the packager risk this PRD named.

Progress label prd:25%.


PRD-493 — terrain layers past sixteen textures (from #429)

Branch feat/prd-493-terrain-layers, merged --no-ff first.

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.

Row A7, attempt 2 (2026-10-04): proved, and it blocks the launch — do not merge this arm

An empty prewarmed batch now submits one instance of the zero matrix its own fresh buffer holds,
because RenderObject.getDrawParameters returns null at count === 0 and three therefore never
built the node the prewarm was waiting for. world-cells-shadow-prewarm had been counting that
draw without three's count gate; with the gate in, it reads castersUnbuilt=8 before this change
and castersUnbuilt=0 after, with every prewarmed caster still first drawing behind the gate.

On the walk it is fatal, twice, alternating arms in one lane (map-walk, tnFrameSpans=1, host
load 24–32, runs in .afk/scratch/walk-a7b-pair-{1,2,3,4}):

arm outcome
dev core-dev2 (1a6298cd), pair-1 pass in 2 min 59 s — 18 of 127 post-settle creations are batch-keyed (1.7 ms), 13 of 1024 frames > 33 ms, render 8.2/15.7 ms, GPU 1.6/10.6 ms, 17.8 s to compileSettled
dev core-dev2, pair-3 fail — 2 state failures, 3 console/network errors on an unchanged core: this host's noise, and no comparison is claimed from these runs
a7 core-a7b, pair-2 and pair-4 stall at the loading gate — TN_STARTUP_STALLED at 0.0 % with nothing outstanding, the page's main thread never yields (describe exceeded 915 s), the 900 s timeout takes the run, the walk never starts

So there is no walk to compare, nothing to capture and no blind bundle: one arm never leaves the
loading screen. The mechanism is right at fixture scale (24 keys × 2) and fatal at Machinefall's
(385 × 2), and the open question is whether the gate is paying the node builds or its own
per-update shadow invalidation, which now has every prewarmed caster to submit. A7, its boxes and
AC-3 stay open; the next step is one instrumented run that separates the two. The engine change is
in this PR so the measurement is reproducible, and it must not be merged as it stands.

… (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.
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.
…t (PRD-492 Phase 1)

The starter's frame now ends in a colour grade and a film grain, both authored
stages it owns: `src/render/grade.ts` builds them, `quality.ts` decides per tier
and `public/grade.cube` is the table. Replacing that one file with a `.cube` from
any grading tool is the whole of "change the grade".

The filed design was a bijective log shaper inside the stage. It is measured and
dropped: an 8-bit table quantises in 1/255 steps, and sweeping the shaper family
over the frame's range found nothing under 2.0 8-bit steps per channel (the filed
W=4, g=1/2.2 costs 4.3), because ACES and sRGB together stretch a small change in
light into several codes. So the fallback this PRD already named ships instead: a
stage may declare `afterOutputTransform`, the chain then installs the graph with
the renderer's automatic transform off and the stage applies three's own
`renderOutput` first — what three's FXAA example does. An identity table there is
within 0.875 of a step over every value the frame can hold, which is the PRD's own
box, and a `.cube` now means what a grading tool means by it.

Engine change is mechanism only: `IRenderChainStage.afterOutputTransform`, a
third argument on `setOutputNode`, and the chain handing the renderer
`outputColorTransform: false` only when such a stage actually ran — a dropped one
would leave the frame untone-mapped with nothing to have applied it.

Ran: pnpm typecheck, pnpm lint, 259 vitest files across packages/core and
packages/create-threenative. Playtest `look.playtest.json` on a scaffolded starter
(webgpu, headed, adapterClass hardware): renderChain tier/includes/order/
contributions all pass with grade and grain applied; the tier-low leg on a
throwaway copy with the tier pinned reads `dropped: [{"name":"grain",
"reason":"grainIntensity:0"}]` with grade still applied.

Open, recorded in the PRD: AC-1 (gpuOther resolved in 1 of 17 windows graded, 0 of
16 ungraded — no delta to read) and the frame half of the identity round trip (the
starter's own animated content moves between runs by more than the claim; the same
build twice reads maxChannelDelta 114).
The pre-push drift gate refused the push on a formatter diff biome had already
reported once; this is the same one line, formatted.
… 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.
…hat could not run (PRD-492 Phase 2)

Phase 2's second box passes: `pnpm native:build` (409/409), then
`pnpm parity --target desktop --only-tests lut-grade` reports status pass with
`pixelMismatchRatio 0` and `perceptualDeltaE 0` over 1280x720 and no GPU
validation errors, against a web-lane reference capture of the same scene. The
table is a 17^3 per-channel gain rather than the identity, so a host that ignored
the upload or substituted a constant fails it, and the scene reads the parsed
Data3DTexture back in JS against that formula before the frame is compared. The
sweep exits 2 because a bounded --only-tests run blocks the other 95 rows.

Phase 2's first box stays open, and the reason is the packager rather than the
grade: the desktop run's marker reads `dropped: [{"name":"grade","reason":
"lut:pending"}]` and the console carries `TN_GRADE_TABLE grade.cube: File read
error`. package-desktop.mjs stages `selectManifestAssets(assets)` and nothing
else, so a public/ file the manifest does not declare never reaches the host —
every unmanaged public file on every native target, not this table alone. Fixing
the asset selection is mechanism work outside this PRD's filed file list, so it is
recorded rather than done here.

The scenario also could not run at all before: its `visibility` assertion is
refused outright on desktop (TN_PLAYTEST_OBSERVATION_UNAVAILABLE), so it failed
before reading a stage. That row is dropped — the runner never produced the
observation on this target — and `grade`/`grain` are added to the stages and
contributions the scenario does read.

Ran: pnpm typecheck, biome on the changed files, packages/runtime-native (7 files)
and packages/playtest (104 of 105 files; `generated-shooter-input` fails on a
browser session and touches nothing in this change), and the create-threenative
scaffold and looks contracts.
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.
A blind judge compared the starter WITH this PR's grade and grain against
WITHOUT and clearly preferred WITHOUT: "strong warm beige/pink cast — grey
concrete reads as sandstone, sky washed toward cream, a bit muddy/low contrast;
grain clearly visible as speckle over sky and flat walls and behind HUD text".
The owner's rule is that a default must never degrade the look, so both
numbers move and the spec says so red-first.

The pivot was the larger half and nothing had measured it. A contrast curve
anchored at 0.18 leaves everything above it brighter: on the captured frame
that lifted mid-grey 8 of 255 steps and the sky 12, which is exactly what
"washed toward cream" looks like. PIVOT 0.18 -> 0.5, and GAIN's warm spread
13% -> 0.5% (r 1.0025 / g 1 / b 0.9975). CONTRAST stays 1.06, so the frame
keeps more separation than the ungraded one, and the table still lifts no
black. grade.spec.ts pins all three claims and is red on the old table.

grainIntensity 0.12 -> 0.0039 at high and medium. film() scales its noise by
the pixel's own value, so 1/255 is under half a step on a mid-grey surface —
below what the framebuffer shows. It stays non-zero so TN_RENDER_CHAIN keeps
naming grain as APPLIED at high, which is this PRD's own ticked box; low
still refuses it with a reason, and quality.ts names 0.12 and 0.3 as the
values to raise it to.

The desktop box passes now, and the packager was never at fault. Phase 2 filed
"the staging loop copies selectManifestAssets(assets) and nothing else, so an
undeclared public/ file never reaches the host"; the selector's rule 3 keeps
everything no cook produced and its spec already proved that. The scaffold that
run used had no public/grade.cube in it at all — the AC-2 before-arm deletes
that file — so there was nothing to stage. With it present: pnpm build --target
desktop lists it among the unmanaged files, the host logs "[Fetch] Read 19909
bytes from bundle: grade.cube", and native-playtests/render-chain.playtest.json
exits 0 with all six assertions true at adapterClass hardware and a non-blank
screenshot (tone mean 117.1, p99 192). A guard in the selector's own spec now
covers a hand-placed file whose extension no asset pipeline emits.

Also recorded: a browser run that reports swiftshader/google is a CPU
rasteriser's picture of a hardware look (this lane's frame came back flat grey
with 7,359 createBuffer errors), and --browser-recipe webgpu alone does not
prevent it — the runner launches chrome-headless-shell. Every capture ran
--headed, on turing/nvidia, at tier high.

Ran: grade.spec.ts 11 passed (2 of the new ones red on the old table),
scaffold.spec.ts 66, looks.spec.ts, asset-manifest.spec.ts 6, the six doc/CI
contracts 241, pnpm typecheck clean, biome clean on every touched file,
pnpm check:docs clean.

Captures for the judge, not judged here: .afk/scratch/prd492-captures/phase1b/
{before,after}.png plus {before,after}-crop.png (centre 960x540 at 2x).
Measured between the pair the grade now moves the frame by rmse 2.22 codes and
-0.32/-0.71/-0.32 mean per channel, from rmse 8.17 and +6.7/+6.3/+6.9.
Carries develop's WebGPU adapter-retention patch, the terrain-jobs work and
the CI scope changes. One conflict: the scaffold tree hashes, which develop
restated for its own merge and this branch restated for PRD-492's stage seat
plus the neutralised starter default. Resolved to the merged-tree
measurement — all thirteen trees re-measured through actual createProject
trees on this tree, `scaffold.spec.ts` 66 passed.
…is lane

Takes the develop CI merge that had already landed on the PR branch. Two
conflicts, both resolved to this lane's newer text: the scaffold tree hashes
(re-measured through actual createProject trees on this tree, `scaffold.spec.ts`
66 passed) and the PRD (the remote side was the pre-neutralisation file).
Merging PRD-493 under PRD-492 moved every no-install scaffold hash: the
rewritten `capabilities.json` entry and `capability-reference.md` reach a
scaffold verbatim. Measured through actual createProject trees on this merged
tree, which is what the table already claims to hold.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

492-1-before-no-grade

492-1-before-no-grade

492-2-rejected-first-grade

492-2-rejected-first-grade

492-3-after-retuned

492-3-after-retuned

492-4-crop-before

492-4-crop-before

492-5-crop-after

492-5-crop-after

jonit-dev added a commit that referenced this pull request Oct 4, 2026
… branch 16 layers / web reference / native desktop
@jonit-dev

Copy link
Copy Markdown
Contributor Author

493-1-develop

493-1-develop

493-2-branch-16-layers

493-2-branch-16-layers

493-3-web-reference

493-3-web-reference

493-4-native-desktop

493-4-native-desktop

@jonit-dev

Copy link
Copy Markdown
Contributor Author

walk-flagged-frames

walk-flagged-frames

pose-start-previous-build

pose-start-previous-build

pose-start-develop

pose-start-develop

pose-sheet-a

pose-sheet-a

pose-sheet-b

pose-sheet-b

jonit-dev added a commit that referenced this pull request Oct 4, 2026
…ld gate. Walk sheet: LEFT = develop, RIGHT = previous build, frames 4/12/23/31. Pose start: previous build (trees missing, shadows only) vs develop (fixed).
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Screenshot index and visual verdicts (comments above, in order)

  1. PRD-492 colour grade: no grade / first grade (rejected: a blind judge clearly preferred no grade, because the warm cast turned concrete to sandstone and the grain showed as speckle) / retuned default. The retuned default differs from no grade by an RMSE of 2.2 (the first grade by 8.2). Crops: no visible tint or grain, so no regression.
  2. PRD-493 terrain layers: develop never reaches a frame with 16 layers (34 samplers); the branch renders all 16; the native desktop frame matches the web reference pixel for pixel.
  3. Machinefall walk on develop (after feat(world): consolidate frame architecture, budgets and capture diagnostics #423 and feat(world): PRD-478 phase 3 — terrain block merge and seam strip as worker jobs #428): in the walk sheet, LEFT = develop and RIGHT = previous build.

Walk timing, develop, RTX 2080, 3 runs:

before the runbook previous develop now budget
CPU render p95 23.2 ms 18.3 ms 7.7 ms 8.3 ms
GPU p95 14.7 ms 13.7 ms 8.28 ms 8.3 ms
main draws 323 36 36

World gate: the start pose is a WIN (previous build 2 → develop 4; the missing-trees-with-shadows defect is fixed). The tool still says regression because of single-critic missing flags on develop (a dark spruce at about 45 m around frames 10–15; far band at frames 29–33). No two critics agree on any of them, and one critic found nothing on develop. My own look at frames 4/12/23/31: no loss. Frame 12 draws a darker spruce over part of the road. Watching it.

Open: a 5-run rerun with nothing else using the GPU, to confirm the GPU p95 margin (0.02 ms) and to explain one 8.7 s frame gap.

Three's WebGPU backend issues one queue.writeBuffer per update range
and never merges them; the GPU scene added one range per placement and
key, so admitting a cell turned into one write per placement inside
renderer.compute. addRange extends the last range when the new one
touches or overlaps it. The early-route compute span was p95 2.5-5.2 ms.

Red-green: world-gpu-scene.spec.ts describe("addRange") (red:
addRange is not a function). World specs: 12 files, 213 passed.
Change and spec by the Haiku arm.
VirtualShadowNode#pollCasters invalidates a level when a caster's
visible flag flips. TerrainTiles swaps visibility between a tile's level
meshes and its merged block whenever a block rebuilds: one Machinefall
walk saw 4,500 flips, 3,568 of them terrain level meshes, and the map
re-rendered about four times a second (200+ per walk, 5-20 by move).
terrainMesh marks its meshes tnShadowSwap and casterFlag ignores their
visible bit; castShadow and mainAdmitted still count, and the next
window move redraws a LOD step's resolution difference.

Red-green: virtual-shadow.spec.ts (red: expected 3 to be +0) and
world-terrain-tiles.spec.ts (red: expected undefined to be true).
22 spec files, 432 passed. Change and specs by the Haiku arm.
VirtualShadowNode#onTreeChanged asked every level covering any mesh
that entered or left the tree. Bucketing one Machinefall walk's 3,800
region invalidations by caller: 1,409 came from TerrainTiles block
meshes (swap twins over the same ground), 1,025 from WorldCells
admissions (largely meshes that cast nothing), 391 from bundle/group
re-records; the level redrew at the 0.25 s cap, 200+ times per walk.
A mesh that casts nothing or carries tnShadowSwap now returns early on
the tree-change path; a later castShadow flip is still the poll's.

Red-green: virtual-shadow.spec.ts "should not redraw cached maps for an
arriving mesh that casts nothing or is a swap twin" (red: expected 3 to
be +0). 24 spec files, 460 passed.
The cook skipped any mesh whose node or mesh name carries a level
token (`hull_LOD1`) as hand-authored LOD. A Fab pack's `_LOD2` exported
as a model of its own has no sibling level, so nothing selects it and
the skip left it at full detail. On Machinefall map-walk these trees
(`*_far_0_001`, mesh `*_far_LOD2`) are about 9.4 M of the 9.8 M
main-pass triangles in the early route, and the adaptive LOD bias
cannot coarsen a slot with one level.

An asset is an authored chain only when it holds two or more
level-named nodes. LOD_GENERATOR_VERSION goes to 4 so a stale cook
cache cannot serve the old skip.
Three quiet runs (load under 10 at start and end) still fail AC-1 and
AC-2 in the first route window only. That window is main-pass
triangles from single-level trees, fixed by d65fa1e; PRD-538 no
longer gates AC-2.
@jonit-dev jonit-dev mentioned this pull request Oct 8, 2026
6 of 8 tasks
Reverts d65fa1e on this branch. On its own it gives the Fab LOD2
pines a chain whose unscaled card levels thin the canopy; PRD-539
(#463) ships it together with card scaling. PRD-478 notes now point
there.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

PRD-478 progress: on a quiet RTX 2080, AC-1/AC-2 failed only in the first route windows. That cost is the main pass drawing single-level Fab _LOD2 pines (9.4 M of 9.8 M triangles), not the shadow map. The fix is PRD-539 (#463): those pines get a chain, card levels keep the canopy covered, and every chained level casts. With it, one quiet run passes AC-2 (≤ 2.3 % of walking frames over 8.3 ms GPU), and 3 blind raters score it at or above this branch on all 8 poses. Sheets: #463 (comment). AC-1 (CPU render tail, early route) is still open.

…that does

The GPU shadow keys dropped a placement whose main level has no twin,
so a chained tree past its first switch left a hole in the shadow map.
Casting every level instead (PRD-539's first answer) kept the shadows
but minted a twin per level; a walking trace showed the churn as ~10x
updateWorldMatrix, ~35x addEventListener and ~4x garbage collection
against castLevels 1.

The shadow level is now the last casting level in [0, max(drawn,
base)]: the drawn level when it casts, otherwise the coarsest one below
it, floored by the map's base as before. Reference and kernel agree.

world-gpu-scene.spec.ts: red `expected [ +0, 3 ] to deeply equal
[ +0, 1, 3 ]`, then green; world-gpu-scene + world-shadow-gpu-keys
100 passed plus 1 load timeout that passes alone (26.5 s).
The owner asked for one PR. Net diff of feat/prd-539-card-coverage
against its develop base, applied cleanly:
- a lone LOD-named mesh gets a generated chain (assets eligibility);
- card levels index scaled copies of their kept cards, and the model
  pass's self-check counts what LOD0 draws;
- a chained level past castLevels casts through the coarsest casting
  level's casters on the cluster path (this branch's ce8c4de does the
  same for the GPU shadow keys);
- examples/auto-lod gains a card crown that both runtimes draw.

packages/core + assets LOD specs: 2659 passed, 1 known load timeout
that passes alone.
The main pass's share was taken of the frame just measured, capped at
60 fps. A fast frame shrank it: on Machinefall the bias sat at 2.4-2.5
for most of the walk, rising on 2.3-3.9 ms main passes judged against
1.9-2.8 ms budgets at 200-400 fps, against a 120 fps target. Every LOD
switch then came at 40 % of its authored distance; the owner saw LOD
trigger too early.

game.ts already resolves the target for the resolution scaler; it now
also tells the renderer (`noteTargetFps`/`targetFps`), and WorldCells
budgets `mainGpuShare x 1000 / target` (60 fps until one is known).

world-gpu-scene.spec.ts: red `a pass inside the target's share
coarsened the world: expected 2.5 to be 1`, then green, with the
existing rise, decay and load-hold cases.
A pooled walking p95 is under the target exactly when at most 5 % of
the pooled frames are over it. Window quantiles can only bound that,
and the bound charges a window with p95 over the line up to half its
frames. Each window now reports overTarget {frameMs, render, gpu}: the
frames whose render phase, and the GPU samples, ran past 1000 /
targetFps (the target the window started with).

target-fps.spec.ts: red `expected undefined to deeply equal
{ frameMs: 8.33, gpu: 1, render: 2 }`, then green; 62 passed.
When the GPU scene comes up over a ring built without it (Machinefall
loads its ring with no renderer), it re-seeds a rebuild of every
resident run and dresses every key at once, and a dressed key draws
nothing until its run lands. The gate settled over 1,378 owed builds,
the overlay dropped, and a third of the forest arrived in the first
4 s of play, in every build including develop (laptop TN_DIAG run).

`#drainPrewarm` now waits for the admission backlog to empty before it
settles, so the loading screen covers the re-seed.

world-gpu-scene.spec.ts: red `the gate settled with re-seed builds
still owed: expected 17 to be +0`, then green; world-gpu-scene,
world-cells and world-cells-shadow-prewarm 142 passed.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Merged into the one PR, #473, at the owner's request (fast-forward to d2bb5f6). Its commits and PRD text are there; nothing is lost.

@jonit-dev jonit-dev closed this Oct 9, 2026
jonit-dev added a commit that referenced this pull request Oct 9, 2026
…ass draw budget measured (#473)

* feat(world): stack uncompressed terrain layers into one array per set (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.

* feat(world): per-layer ORM on the splat surface (PRD-493 phase 2)

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.

* feat(render): grade and film grain in the starter, on a new stage seat (PRD-492 Phase 1)

The starter's frame now ends in a colour grade and a film grain, both authored
stages it owns: `src/render/grade.ts` builds them, `quality.ts` decides per tier
and `public/grade.cube` is the table. Replacing that one file with a `.cube` from
any grading tool is the whole of "change the grade".

The filed design was a bijective log shaper inside the stage. It is measured and
dropped: an 8-bit table quantises in 1/255 steps, and sweeping the shaper family
over the frame's range found nothing under 2.0 8-bit steps per channel (the filed
W=4, g=1/2.2 costs 4.3), because ACES and sRGB together stretch a small change in
light into several codes. So the fallback this PRD already named ships instead: a
stage may declare `afterOutputTransform`, the chain then installs the graph with
the renderer's automatic transform off and the stage applies three's own
`renderOutput` first — what three's FXAA example does. An identity table there is
within 0.875 of a step over every value the frame can hold, which is the PRD's own
box, and a `.cube` now means what a grading tool means by it.

Engine change is mechanism only: `IRenderChainStage.afterOutputTransform`, a
third argument on `setOutputNode`, and the chain handing the renderer
`outputColorTransform: false` only when such a stage actually ran — a dropped one
would leave the frame untone-mapped with nothing to have applied it.

Ran: pnpm typecheck, pnpm lint, 259 vitest files across packages/core and
packages/create-threenative. Playtest `look.playtest.json` on a scaffolded starter
(webgpu, headed, adapterClass hardware): renderChain tier/includes/order/
contributions all pass with grade and grain applied; the tier-low leg on a
throwaway copy with the tier pinned reads `dropped: [{"name":"grain",
"reason":"grainIntensity:0"}]` with grade still applied.

Open, recorded in the PRD: AC-1 (gpuOther resolved in 1 of 17 windows graded, 0 of
16 ungraded — no delta to read) and the frame half of the identity round trip (the
starter's own animated content moves between runs by more than the claim; the same
build twice reads maxChannelDelta 114).

* style: biome's own formatting for grade.spec.ts

The pre-push drift gate refused the push on a formatter diff biome had already
reported once; this is the same one line, formatted.

* test(world): a 16-layer uncompressed splat package on a GPU, plus its 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.

* test(native): a lut-grade conformance row, and the desktop scenario that could not run (PRD-492 Phase 2)

Phase 2's second box passes: `pnpm native:build` (409/409), then
`pnpm parity --target desktop --only-tests lut-grade` reports status pass with
`pixelMismatchRatio 0` and `perceptualDeltaE 0` over 1280x720 and no GPU
validation errors, against a web-lane reference capture of the same scene. The
table is a 17^3 per-channel gain rather than the identity, so a host that ignored
the upload or substituted a constant fails it, and the scene reads the parsed
Data3DTexture back in JS against that formula before the frame is compared. The
sweep exits 2 because a bounded --only-tests run blocks the other 95 rows.

Phase 2's first box stays open, and the reason is the packager rather than the
grade: the desktop run's marker reads `dropped: [{"name":"grade","reason":
"lut:pending"}]` and the console carries `TN_GRADE_TABLE grade.cube: File read
error`. package-desktop.mjs stages `selectManifestAssets(assets)` and nothing
else, so a public/ file the manifest does not declare never reaches the host —
every unmanaged public file on every native target, not this table alone. Fixing
the asset selection is mechanism work outside this PRD's filed file list, so it is
recorded rather than done here.

The scenario also could not run at all before: its `visibility` assertion is
refused outright on desktop (TN_PLAYTEST_OBSERVATION_UNAVAILABLE), so it failed
before reading a stage. That row is dropped — the runner never produced the
observation on this target — and `grade`/`grain` are added to the stages and
contributions the scenario does read.

Ran: pnpm typecheck, biome on the changed files, packages/runtime-native (7 files)
and packages/playtest (104 of 105 files; `generated-shooter-input` fails on a
browser session and touches nothing in this change), and the create-threenative
scaffold and looks contracts.

* docs: path spelled out for the capture dir

* test(world): the sixteen-layer splat package on the native desktop host

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.

* feat(world): a conformance row for sixteen terrain layers on native

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.

* fix: reconcile merged terrain fixture Three patch hash

* fix(render): neutralise the shipped grade and grain default (PRD-492)

A blind judge compared the starter WITH this PR's grade and grain against
WITHOUT and clearly preferred WITHOUT: "strong warm beige/pink cast — grey
concrete reads as sandstone, sky washed toward cream, a bit muddy/low contrast;
grain clearly visible as speckle over sky and flat walls and behind HUD text".
The owner's rule is that a default must never degrade the look, so both
numbers move and the spec says so red-first.

The pivot was the larger half and nothing had measured it. A contrast curve
anchored at 0.18 leaves everything above it brighter: on the captured frame
that lifted mid-grey 8 of 255 steps and the sky 12, which is exactly what
"washed toward cream" looks like. PIVOT 0.18 -> 0.5, and GAIN's warm spread
13% -> 0.5% (r 1.0025 / g 1 / b 0.9975). CONTRAST stays 1.06, so the frame
keeps more separation than the ungraded one, and the table still lifts no
black. grade.spec.ts pins all three claims and is red on the old table.

grainIntensity 0.12 -> 0.0039 at high and medium. film() scales its noise by
the pixel's own value, so 1/255 is under half a step on a mid-grey surface —
below what the framebuffer shows. It stays non-zero so TN_RENDER_CHAIN keeps
naming grain as APPLIED at high, which is this PRD's own ticked box; low
still refuses it with a reason, and quality.ts names 0.12 and 0.3 as the
values to raise it to.

The desktop box passes now, and the packager was never at fault. Phase 2 filed
"the staging loop copies selectManifestAssets(assets) and nothing else, so an
undeclared public/ file never reaches the host"; the selector's rule 3 keeps
everything no cook produced and its spec already proved that. The scaffold that
run used had no public/grade.cube in it at all — the AC-2 before-arm deletes
that file — so there was nothing to stage. With it present: pnpm build --target
desktop lists it among the unmanaged files, the host logs "[Fetch] Read 19909
bytes from bundle: grade.cube", and native-playtests/render-chain.playtest.json
exits 0 with all six assertions true at adapterClass hardware and a non-blank
screenshot (tone mean 117.1, p99 192). A guard in the selector's own spec now
covers a hand-placed file whose extension no asset pipeline emits.

Also recorded: a browser run that reports swiftshader/google is a CPU
rasteriser's picture of a hardware look (this lane's frame came back flat grey
with 7,359 createBuffer errors), and --browser-recipe webgpu alone does not
prevent it — the runner launches chrome-headless-shell. Every capture ran
--headed, on turing/nvidia, at tier high.

Ran: grade.spec.ts 11 passed (2 of the new ones red on the old table),
scaffold.spec.ts 66, looks.spec.ts, asset-manifest.spec.ts 6, the six doc/CI
contracts 241, pnpm typecheck clean, biome clean on every touched file,
pnpm check:docs clean.

Captures for the judge, not judged here: .afk/scratch/prd492-captures/phase1b/
{before,after}.png plus {before,after}-crop.png (centre 960x540 at 2x).
Measured between the pair the grade now moves the frame by rmse 2.22 codes and
-0.32/-0.71/-0.32 mean per channel, from rmse 8.17 and +6.7/+6.3/+6.9.

* test(scaffold): the thirteen tree hashes on the runbook-merged tree

Merging PRD-493 under PRD-492 moved every no-install scaffold hash: the
rewritten `capabilities.json` entry and `capability-reference.md` reach a
scaffold verbatim. Measured through actual createProject trees on this merged
tree, which is what the table already claims to hold.

* docs(runbook): A7 baseline — 8 streamed-batch pipeline creations per walk, and the two reasons

Five map-walk runs on origin/develop's core, GPU quiet at every start (6-15%), host load 18-48, so
the CPU numbers are inflated against the earlier 11-19 runs and only the paired lane comparison counts.
Median walking render 13.4/25.9 ms, GPU 3.6/21.6 ms, 48 frames over 33 ms of 1024, worst present gap
17.1 s.

The pipeline census is what A7 needs: 8 creations attributable to a streamed batch after
startup.compileSettledMs in every run (7 main pass, 1 shadow caster half; device service 1.1-1.6 ms),
plus 16-22 compute creations and 12 main / 4-5 shadow for objects outside the shared batches.

Two causes, both named rather than inferred:

- #dressGpu cannot re-dress a mesh three has compiled (its instancing node binds the instanceMatrix it
  held then), so a key dressed after the prewarm gate gets a fresh object whose node and pipeline its
  first draw builds, on a walking frame.
- TN_WORLD_PREWARM reads shadowPrewarmed=0 castersUnbuilt=182 in every run: the prewarm mints an empty
  batch and waits for a draw to build its node, and three never submits one — getDrawParameters()
  returns null at count === 0.

AC-3 stays unticked with the measurement beside it. Two attempts to close it on the engine's own
compileAsync seam failed on the walk itself: deferring the swap to that compile hangs a live renderer
(900 s timeout, walk-a7-r2-1), and doing it behind the loading gate serialises ~200 key compiles
behind prewarmed and does the same (walk-a7b-r2-1, aborted at 10 min). A7 needs a preparation seam
that does not borrow the frame the walk is drawing, which is PRD-387's open Phase 1.

* fix(core): the prewarm's draw is a submission three admits, not one it refuses

A7's measured cause, one of the two named on 2026-10-04: TN_WORLD_PREWARM read
shadowPrewarmed=0 castersUnbuilt=182 in every walk, because the prewarm leaves an empty batch
owed a draw that three never makes. `RenderObject.getDrawParameters` returns null at
`count === 0`, so the renderer skips the object, builds no node and never calls
`onBeforeRender`: 385 keys minted behind the gate prepared nothing, and the walk's first
shadow window move built all of them inside a shadow pass.

An empty batch still owed its draw now submits one instance of the zero matrix its own fresh
buffer already holds (`SharedBatch#publish`), which is the submission that builds the node and
the pipeline, main and shadow. It draws nothing — every vertex collapses onto one point — and
`prewarmDrew` puts the honest count back. `prewarmInstance` publishes on the mesh which count is
the prewarm's, because `casterPrewarmOwed` cannot answer it: a batch can owe its draw and hold
records at once. A batch leaving the world hands the borrow back rather than submitting a
degenerate instance for the rest of the load, and the prewarm's own submission is not recorded
into a draw bundle.

The red-green is the harness, not a new one: `world-cells-shadow-prewarm` counted a draw three
would not submit (no count gate), so it passed with 8 of 17 casters unbuilt. With three's own
gate in both arms it read `castersUnbuilt=8` before this change and `castersUnbuilt=0` after, and
every prewarmed caster's first draw is still behind the gate.

`pnpm exec vitest run packages/core/__tests__` 184/184 green, `pnpm typecheck` and `pnpm lint`
green. `pnpm test` still fails 22 native-host cases in `packages/runtime-native` that need
`pnpm native:build` in this worktree — untouched by this change.

* docs(runbook): A7 attempt 2 — the non-zero prewarm draw is proved, and it blocks the launch

The first of A7's two named causes is now closed at the mechanism level and unit-proved, and the
walk says the fix cannot merge as it stands. Alternating arms in one lane, map-walk with
tnFrameSpans=1, host load 24-32 throughout:

- dev pair-1 (core-dev2, 1a6298cd): pass in 2 min 59 s, 18 of 127 creations after settle
  attributable to a batch key (1.7 ms of device service), 13 of 1024 frames over 33 ms, walking
  render 8.2/15.7 ms, GPU 1.6/10.6 ms, 17.8 s to compileSettled over 1895 pipelines, and the
  baseline's own line unchanged: minted=385 shadowPrewarmed=0 castersUnbuilt=182.
- dev pair-3 (same core): fail, 2 state failures and 3 console/network errors over 4382 frames. That
  is this host's noise, not a result, and the lane claims no CPU comparison from these runs.
- a7 pair-2 and pair-4 (core-a7b): both stall at the loading gate. TN_STARTUP_STALLED reports 0.0 %
  progress for 45 s with nothing outstanding, the page's main thread never yields again (the bridge's
  `describe` exceeded 915000 ms), the 900 s timeout takes the run, the prewarm marker never prints
  and the walk never starts. No census, no frame series, nothing to capture.

What the row now knows: the mechanism is right in the fixture world and fatal at Machinefall's
scale, where 385 keys by two variants behind the gate is a different cost from 24 by two. What it
does not know is which of two costs the gate is paying — the node builds themselves, or
`#drainPrewarm`'s own loop, which invalidates the shadow levels on every update while a caster is
owed and now has every prewarmed caster to submit each time. The next step is one instrumented run
that separates them. AC-3 stays unticked, the A7 boxes stay unticked, and the arm stays a draft.

* revert(core): take the non-zero prewarm draw back out of #437

Reverts 2d6b930fc. With it, Machinefall map-walk never leaves the loading
gate (two of two runs, 900 s timeout, TN_STARTUP_STALLED). The runbook A7
row keeps the finding; the change returns once one instrumented run names
the cost the gate is paying.

* fix(core): a pre-ORM splat table loads instead of hanging the world at its loading gate

PR #437's `loadTerrainSplat` threw when any terrain layer declared neither an ORM map
nor a roughness/metalness pair. Machinefall's exported `terrain/layers.json` predates
that column: its base layer states neither and its layers state roughness without
metalness. The world's `load()` therefore rejected, `MapWalk.load()` never returned,
`state.loading` stayed true, and the playtest timed out in `describe` with 0 frames.

A table the earlier export recipe wrote is not a malformed table, so the check now names
the layers it cannot honour and the surface builds: `DEFAULT_ROUGHNESS` (0.92, the
matte dielectric this material carried before the column existed) and metalness 0.
Loading a package is a promise the game cannot cancel, and a hang at the loading gate
costs a whole boot.

Machinefall loads its layers as KTX2 `CompressedTexture`s, so the uncompressed
`stackUncompressed` path the same diff added never runs for it; the throw was the only
new blocker.

Tests: the spec now feeds `loadTerrainSplat` Machinefall's own table shape (base with
no response, layers with roughness and no metalness) and red at the old throw, green
after. The old "refuses a layer that states neither" spec encoded the hanging contract
and became this one; PRD-493's sixteen-layer stacking and ORM specs stay green.

* fix(core): a shadow casts only for what the main pass draws

Machinefall's first frames after the loading overlay drew a meadow of canopy
shadows with no trees under them (about 1 run in 6; owner report on #437).
With the GPU scene on, the main pass draws only sources the seed has placed
into the dispatch, and the seed queues behind the admission budget
(`TN_WORLD_GPU_SCENE on ... instances=0`), while every caster half drew its
whole resident ring.

- WorldCells counts each caster half's placed sources (`SharedBatch.gpuPlaced`
  against `live`, kept in #placeSources / #clearSegment / the impostor
  handoff) and publishes `mainAdmitted` per half; the wide `@*` mesh casts once
  every cell of its asset is placed.
- VirtualShadowNode#probe hides a half that is not admitted for each level
  render (the existing #hidden restore buffer), so admission gates the draw,
  not only its tally. Admission, not visibility: an off-screen caster whose
  counterpart is placed still casts.
- Admission is the third bit of the per-caster flag word #pollCasters reads,
  so a flip redraws the cached levels covering that half; without it the maps
  drawn during the seed stayed shadowless.
- TN_WORLD_SHADOW_ADMISSION reports withheld cluster and wide halves and their
  records (Machinefall: 249 + 47 halves at the seed, 0 after).

GPU (RTX 2080, Machinefall map-walk, develop base vs this change, 6 runs each,
merge on and off): no orphan frame in the fix runs; forest-floor brightness
at the start frame 212 vs develop 211 (the withheld-but-cached state read
226-237); walk frames within 1-2 units, the residual is canopy self-shadow
speckle, not ground shadow.

Specs: the render-boundary admission spec, the per-half placement spec, the
seed-drain specs and two admission-flip invalidation specs, each red before
its change and green after; 246 world and shadow specs pass.

* docs(runbook): A7 attempt 3 and the walk on this PR

The launch hang blamed on the prewarm was the PRD-493 splat throw; with it
fixed the prewarm loads but prepares nothing on Machinefall and moves no
number (12 alternating runs, order effect only), so it stays off this PR.
No post-settle compile costs a frame (max 0.2 ms). The walk on this PR:
render p95 6.4 ms, GPU p95 6.5 ms on a loaded host; AC-1/AC-2 stay open
for the quiet interleaved rerun and AC-3 raters.

* docs(runbook): tick A1 and C1; PRD-492 AC-2 passes

A1: PRD-389 Phase 4 reads 5/0 on origin/develop, one box per probe gap,
landed by #423. C1: #397 merged and PRD-339 is in done/ at 11/0.
PRD-492 AC-2: visuals:ab with 3 fresh blind raters scores the graded
starter frame equal to the ungraded one (4=4, 3=3, minimum detectable
effect 0); the HUD moves at most 1.9 codes against 112 in the scene.

* docs(runbook): read the walk the way PRD-478's ACs define it

* fix(vq-assets): read the lifecycle baseline only after the world has drawn

The GPU lifecycle scenario reported +6 geometries and +3 textures over three
re-enters. An in-page probe shows the release path is complete: every enter
disposes its six geometries and three textures on exit (18 and 9 dispose calls
after three exits) and the fourth enter holds exactly one enter's live set.

The growth was a measurement artifact. info.memory counts a resource from the
frame that uploads it, and the engine withholds the first world pass behind the
loader cover until first-use compilation settles. The first enter's baseline was
therefore taken when the loader's own geometry was the whole live tally, so
every later enter read that enter's own upload as growth. Gate the read on
startup.compileSettled.

Scenario on nvidia/turing: geometryGrowth 0, textureGrowth 0, entries 1 -> 4.

* docs(prd): VQ-01 is done; tick runbook row B1

The lifecycle box passes on nvidia/turing once the fixture reads its
baseline after compile settle (f1fb533a2): growth 0/0, and an in-page
probe counted every owned dispose (6 + 3 per enter). The PRD moves to
done/ with its links.

* fix(core): the world-matrix walk prunes again, and a frozen batch is a leaf

Two reasons the per-frame scene walk cost 13% of a map-walk, both in
`MatrixWorldPass`.

The frame spans time the render phase's walk by wrapping
`Scene.prototype.updateMatrixWorld`. `#step` read a non-base
`updateMatrixWorld` as "a class that owns its walk" and handed the whole
scene to three's own recursion — which prunes nothing. The pass visited one
node per frame and reported it: `"matrixWorld":{"mode":"visible","visited":1}`
on a scene of 2692, with no `sceneUpdate` span emitted at all. A `Scene` is
never such a class: three does not override the walk on `Scene`, so a scene
whose method differs has had its prototype patched.

`markStatic` composes a subtree once and turns off the two flags that would
recompose it, but nothing stopped the recursion from still visiting every node
under it. A frozen root that carries no dirty flag is now a leaf.
`refreshStaticTransforms` re-arms a root whose own transform moved and
`invalidateStatic` re-arms one something wrote inside, each composing once at
that moment, so the frames in between have nothing to recompute. `isStatic` is
what separates the engine's freeze from a game that turned the flags off itself.

Measured on Machinefall map-walk, RTX 2080 (turing/nvidia), `instances=13987
drawn=27552` in both runs, CDP profile over a ~111 s walk, self time:

| function               | #437 before | after      |
| ---------------------- | ----------: | ---------: |
| `updateMatrixWorld`    |   10599.1ms |      71.9ms |
| `multiplyMatrices`     |    2037.3ms |    1517.9ms |
| `updateMatrix`         |      25.8ms |    4482.0ms |
| `traverseVisible`      |    3290.2ms |    2968.1ms |
| walk total             |   15952.4ms |    9039.9ms |

The compose did not vanish, it moved out of `updateMatrixWorld`'s frame into
`updateMatrix`, so the walk itself is 12636ms -> 6072ms. `visited` went 1 ->
2363 of 2692: the visible-only pass runs again, and 329 nodes under frozen
roots are no longer visited. `sceneUpdate` is reported again (p95 0.1-2.0ms).
Steady-state render p95 median 11.3ms -> 8.0ms (AC-1 target 8.3ms), but that
pair is confounded: the before run had dropped its surface to 781x439 "AT
FLOOR, budget not met" and the after run held 1280x720, so the after run did
~2.7x the pixels and still came in lower. Not a like-for-like number.

Picture unchanged to what the capture can resolve, three baseline captures
against two of this build, 1280x720:

| frame                | #437 vs #437 (control) | mine vs mine (control) | mine vs #437 |
| -------------------- | ----------------------: | ---------------------: | -----------: |
| phase477-pose-start  | 5.08% 5.33% 6.77%       | 8.51%                  | 2.03-9.49%   |
| phase477-walk-20     | 4.42% 5.32% 5.57%       | 5.77%                  | 3.74-6.04%   |

(mean absolute channel diff 0.01-0.33 of 255, max 82; `magick -metric AE`
906.4 / 270.651, but IM7's AE disagrees with the exact count of 78,017 /
53,192 differing pixels, so read the percentages). This build's own
repeat-to-repeat spread is as large as the difference from the baseline, so no
visual change is separable here. The owner reviewer judges the pixels.

Tests: `packages/core/__tests__/matrix-world-static.spec.ts` is red on this
branch's `matrix-world.ts` (frozen batch visited 41 nodes instead of 2; the
patched prototype ran once instead of zero) and green with it. It also keeps a
re-placed batch and an `invalidateStatic`-ed subtree on their new world
matrices. 39 world/virtual-shadow/matrix/static spec files, 608 tests; full
core suite, 185 files, 2410 tests.

Left alone, game layer, reported not fixed: `Q7` 3.6s and `m7 shadowKeysFrom`
2.1s self time, both in Machinefall's `stepFrame`.

* docs(prd-492): AC-1 passes at 0.0 ms; the identity box needs a float table and a canvas readback

* perf(core): a chunk merge reads its own arrays, and the proxy copies vertices

A streamed world chunk's merge cost 5.51 ms/MB on real Machinefall chunk data,
and one TimerFire attaches up to 256 of them, so a walk paid seconds of
uninterrupted main-thread JavaScript per stall.

The cost was never arithmetic. `flatten` cloned every part — and `clone()` on a
quantized interleaved channel de-interleaves it through a boxed `Array` — then
`applyMatrix4` read and wrote one component at a time through a `Vector3`, then
`toNonIndexed`, then a de-quantizing copy, then `mergeGeometries` copied
everything a second time and built the index one `Array.push` at a time. Six
passes over every byte, and one per instance for an expanded `InstancedMesh`.

`concatenate` reads each channel's array directly and writes it into the merged
buffer once: same arithmetic term for term, including `applyMatrix4`'s
homogeneous divide, `applyNormalMatrix`'s `normalize()`, `normalize`/`denormalize`
on the channel's own array type, and the widening `widenIndex` applied. The
three path stays for what it is the only reader of — half floats, a channel whose
vertex count is not its part's — and keeps its refusals.

The chunk shadow proxy had the same per-vertex `Vector3` round trip and is now
73% of what is left, so `placeVertices` writes a plain float position array
directly and keeps the accessor loop for a retained quantized source.

Measured on the 133 Machinefall chunk GLBs, 615.7 MB merged: 3393 ms -> 815 ms,
5.51 -> 1.32 ms/MB, 4.16x, with all 135 output digests byte-identical.

* docs(prd): PRD-484 is done; tick runbook row A0

Strata with its local stopgap reverted, under the develop defaults:
scene 96.4 M -> 15.8 M triangles at the worst view, mirror 96.4 M ->
0.92 M in 3 draws (console TN_FRAME_BUDGET maxima, nvidia/turing,
1920x1080); every local game edit restored byte-identical.

* fix(starter): load the grade table as float, so an identity table is exact

The starter's `.cube` loaded through `LUTCubeLoader`'s default 8-bit path, which
truncates `value * 255` into a `Uint8Array`. A grading tool's file is written in
[0, 1], so the table came back reading low and the round trip was never the
frame the game drew: the arithmetic in `grade.spec.ts` put an identity table at
0.875 of a step, and the frame half measured a signed mean of −0.583 codes with
3.23 M samples sitting at exactly −1.

`postprocessing.ts` builds the loader, so that is where the type goes
(`three@0.185.1`'s loader documents `{UnsignedByteType, FloatType}`;
`HalfFloatType` would pair its `Float32Array` with a half-float texture type).
Only the starter carries a grade, so no kit copy moves; the recorded starter
scaffold tree hash moves with the file.

Measured on the still-scene starter, 1920x1080, `nvidia/turing`, the drawing
buffer read through `drawImage` rather than a page screenshot. Two readbacks
inside one run are bit-identical; two runs of one build differ by 35 codes. On
the 89.7% of pixels two ungraded runs reproduce exactly, the identity round trip
now reads signed mean +0.032, rmse 0.26, 0.118% of samples over one step,
against −0.583 / 0.77 / 0.297% on the byte path, and the real loader measures
max delta 0.000000 codes over every display value.

PRD-492's frame box stays open: its whole-frame `maxChannelDelta` ≤ 1 is below
this lane's own 35-code run-to-run spread, so no table can read it. What closed
is the systematic half. Desktop `native-playtests/render-chain.playtest.json`
exits 0 with `grade` and `grain` applied and no validation message on the
built `mystral`; that lane is too noisy for a per-code number.

Gates: `grade.spec.ts` 11 passed, `scaffold.spec.ts` 77 passed, `pnpm typecheck`,
`pnpm lint`, `pnpm ci:fast`, `pnpm check:docs` and the six doc-contract specs all
pass. Not mine, reproduced with these files reverted: 4 failures in
`vq-native-fixture.spec.ts` (`ctx.startup` undefined in the abyss-framework
fixture).

Full numbers: `.afk/scratch/lut-float.md`.

* docs(prd): PRD-492 is done; tick runbook row C2

The identity box is proved by the exact float round trip (max 0) and by
the frame matching the chain-rebuild control inside one run (max 34-38
both, mean +0.035 vs +-0.0002); the proof decision is recorded in the
PRD and reversible.

* perf(world): frame spans for the main-thread terrain block and seam work

Add `terrainBlock` and `terrainSeam` to the frame-span table and charge the
parts of a block rebuild, its attribute swap, a seam reconciliation and its
bridge swap that run on the calling thread. A worker host's swap arrives in a
job reply and an inline host's runs inside the same `follow`, so the spans are
added rather than bracketed; two `begin`/`end` pairs would nest and count one
unit twice. Off, each site is one guarded return with no clock read.

`TN_FRAME_SPANS=1` now reports the timing clause PRD-478 Phase 3 was missing in
the browser: the main-thread block-rebuild and seam-pass cost on Machinefall
map-walk, where the heavy concatenation is off thread. The red-green spec
records both spans on a node ring (red: `undefined`, green: entered); a node
sample is structural, not a browser frame.

Gates: focused vitest 104 passed (terrain + spans suites, 7 files);
pnpm typecheck exit 0; biome check on the three files no errors;
pnpm --filter "@threenative/core..." build exit 0.

* fix(profiling): share the span recorder across core's entry copies

Core is built once per entry, so a game importing `@threenative/core/world`
gets a second copy of `profiling/Spans.ts`. The recorder was module state, so
the recorder installed by the main entry was invisible to the world bundle and
the terrain spans this run added recorded nothing in a browser walk (both
`terrainBlock`/`terrainSeam` absent while `TN_TERRAIN_TILES` showed rebuilds).

Keep the one slot on `globalThis` under a `Symbol.for`, which every copy in a
realm shares. A spec asserts the slot, so a revert to module state fails.

Gates: spans vitest 11 passed; biome check exit 0; walk `terrainBlock`/
`terrainSeam` now present in the `TN_FRAME_SPANS` windows.

* docs(prd-478): Phase 3's worker box passes on the browser walk

* feat(scripts): the world gate measures run-to-run noise (PRD-477)

One capture per side reds on streaming arrival: two develop runs differ more
in the far band (3.96) than develop vs the candidate (1.31), and 2 of 3 critics
call the candidate's later far band "missing". The gate now accepts two or more
runs per side, carries every run as its own anonymous series in the blind walk
bundle, and counts a candidate event only when the same critic reports it in
every candidate run and in no reference run. One run per side keeps the old
strict rule because there is no spread to measure.

This also carries the cross-series `missing` support the rule needs: the walk's
last frame joins the same-pose set, and a candidate `missing` event fails at any
distance. Both changes sit on the same rule path and are tested together.

Red-green: 2 new specs fail with the spread rule forced off, all 60 pass with it.
Gates: world-visual-gate 60, world-capture-manifest + visual-ab + score-blind 92;
pnpm typecheck, pnpm check:docs clean.

* docs(runbook): tick A2; PRD-477 Phase 1 reds on impostors-on and greens on develop

Two runs per side, three blind critics each, on Machinefall map-walk:
develop vs develop passes (exit 0), develop vs impostors-default-on
reds (exit 1) with the distant crowns missing in every impostor run.

* test(native): a workerless terrain inline-settlement conformance row (PRD-478 Phase 3)

PRD-478 Phase 3's last box, "hosts without workers run the same jobs inline,
with an unchanged result", gets its native conformance case. `TerrainTiles`
moves the block merge and the seam bridge strip to a module worker where a host
has one; a host with none runs `runTerrainJob` inline. The row settles a small
two-tier ring with no worker, folds every settled block and bridge by raw
position/normal/index bytes into an FNV-1a 32-bit digest, and requires the
digest to equal the node inline path's pinned constant d138e335 (blocks 3,
bridges 4). A mutated-byte unit case proves the digest reads the bytes and not
just the mesh count.

The scene hides `Worker` for the settle on every lane: that is the box's own
workerless scenario, and the esbuild conformance bundle emits no worker file, so
a browser `Worker` would hang. The native host's real shim refuses a module
source by name (TN_NATIVE_WORKER_MODULE_UNSUPPORTED) and takes this same inline
path, so the browser reference and the native capture run one body.

Ran, with TN_RUNTIME pointing at the main checkout's built host (no built host in
this worktree; no rebuild attempted):
- `--target web --only-tests world-terrain-inline-hash` exit 0, row pass,
  reference capture written.
- `--target desktop --only-tests world-terrain-inline-hash` exit 0, row pass,
  native exit 0, pixelMismatchRatio 0, perceptualDeltaE 0, 1280x720, non-blank,
  0 GPU validation errors.

`pnpm census` regenerated the native census record: total 180,365 -> 181,846
(ten counted-area cells; mostly pre-existing branch drift, plus the two new
files). typecheck exit 0, biome exit 0, focused runtime-native vitest green.

* docs(runbook): tick A6; PRD-478 Phase 3 holds on web and the native host

* docs(prd-478): AC-3 gate evidence; the box stays open on late objects

* fix(core): render virtual shadow levels outside a main-pass bundle record

Three runs a node's updateBefore from the draw of the first object that uses it.
That draw can be the first object of a BundleGroup that the main pass records.
Three keeps the recording bundle in renderer._currentRenderBundle and does not
save it across a nested render(). The level render filed its draws under the
main bundle, and its own bundles left the field null. The remaining draws of the
main bundle were recorded but not listed, so replays never refreshed them and
they kept the camera of the record frame.

On Machinefall's world-capture walk, the record of world-chunk-bundles:8:7 (the
chunk forest under the start pose) nested the first shadow level about 1 run in
2. That cell's Kite/Pine4k/fern chunk meshes then stayed screen-locked from
walk-01 to walk-21: the "extra dark conifer cluster".

VirtualShadowNode.updateBefore now clears the field for the level renders and
gives the recording bundle back afterwards.

* fix(core): render the water reflection outside a main-pass bundle record

Without bounces, three's reflector updates once per frame, so world-cells'
bundleSafe check does not refuse it. An opaque water mesh in a world chunk can
then be recorded into a BundleGroup. Three runs the reflector's updateBefore
from that draw. The mirror's nested render() then has the same fault that
0695d03ab fixed for virtual shadow levels: the main bundle's remaining draws
are recorded but never listed, so replays keep them at a stale camera.

WaterSurface3D now wraps the pass's updateBefore. The wrapper clears
renderer._currentRenderBundle for the mirror render and restores it afterwards.
water-surface-bundle-nesting.spec.ts fails without the fix and passes with it.

* perf(core): stabilize adaptive shadow refresh snapping (PRD-478)

Keep clipmap origins on each level's fixed texel grid. Refresh-step changes
alone retain a stationary window; camera movement beyond the hysteresis
re-centres the window and requests a level render. Reserve one texel for
render deferral and isolate refresh updates to their requested level.

Red: a fixed centre with refreshStep 0.128 -> 0.164 moved and rendered one
level, increasing rendersTotal from 2 to 3. The regression now stays cached.

Validation: 95 shadow specs pass with existing assertions unchanged;
core suite 176 files, 2263 passed, 2 skipped; typecheck, lint (0 errors),
and quality exit 0. Tests and workspace checks use concurrency 2.
Browser ablation runs separately; no browser playtests run here.

* docs(prd-478): Phase 1 snap and first-frame shadow boxes pass on this branch

* docs(runbook): what is left of AC-1, measured, and where the lever is

* feat(native): Android V8 ships cooked KTX2 and Meshopt (PRD-485 done)

Android was decoder-free whatever its engine: the capability resolver
reported android:unresolved and bundle.mjs stubbed every mobile decoder.
The Android engine is now the Gradle selection (THREENATIVE_GRADLE_ARGS
-PthreenativeJsEngine, default v8). Android V8 admits ktx2 and meshopt;
Draco stays refused. bundle.mjs --decoders keeps only admitted loaders.
The bare-WebAssembly guard is lifted for Android V8 only; Rapier and
Recast WASM still fail TN_NATIVE_WASM_ON_MOBILE. Doctor follows the same
resolution.

The native host mapped no ETC2/EAC/ASTC format names, so three's ASTC
pick became BGRA8 and every compressed upload failed writeTexture.
bindings_resources.cpp now maps them.

Proof: fab-import-native.playtest.json --target android on emulator-5554
(API 35, -gpu host, V8): 6/6, 0 diagnostics, Hornbeam rendered.
Runbook row B2 ticked; PRD-485 moved to done/.

* feat(gate): an intermittent candidate-only event makes the world gate inconclusive

With >= 2 runs per side the gate counted a candidate event only when it
appeared in every candidate run and in no reference run, so an event seen
in 1 of 2 candidate runs and 0 reference runs passed silently. On
Machinefall that hid a real, intermittent engine bug (a chunk forest
screen-locked for 20 frames, fixed in 0695d03ab). Such events are now
listed under `intermittent` and, when they are near-band or missing/
disappear, the verdict is `inconclusive` (exit 2, "needs more runs")
unless --allow-intermittent; pass and regression are otherwise unchanged.
Red-green: 6 new specs, 67 gate specs pass.

* chore(native): regenerate native coverage after the mobile format mapping (PRD-485)

* fix(vq-assets): a runtime without a startup phase reads the baseline at once

555d3b542 gated the lifecycle baseline on ctx.startup.compileSettled,
which vq-native-fixture.spec.ts's stub context (and any host that
reports no startup phase) does not carry, so 4 specs threw. Read it as
settled when absent. 28 vq specs pass.

* docs(prd-493): tick the Android row after it passes on the emulator

pnpm parity --target android --only-tests terrain-splat-array --device emulator-5554
renders the sixteen-layer uncompressed splat at 0 pixel mismatch and 0 deltaE
against the browser reference, with layers=16 samplers=4 stacked=3. Phase 3 is
landed; AC-1 and AC-2 stay blocked on Machinefall's private walk. Runbook row C3
records the progress.

* perf(core): materials that sample one shadow share one render-group buffer

Three gives each shared uniform group (render, frame) its own bind group and caches it on the
sorted node ids of its uniforms, so every material that reads the same render-group nodes binds
one buffer that is walked and written once per render call. The PCF, PCF-soft and point shadow
filters broke that: they make their mapSize, radius, bias and shadow-camera uniforms inside a TSL
Fn body, and a Fn body runs once per node builder. Every material got new nodes, a new cache key
and its own copy of the render group.

A read-only probe on the Machinefall map-walk (2 s, turing/nvidia) found 338 distinct render bind
groups over 68 render calls. In each of the 18 uniform layouts, 17 of 21 node ids matched and the
same 4 differed in every group: two PCF filters' mapSize and radius.

The patch makes those uniforms once per shadow (getShadowUniform, a WeakMap on the shadow), in the
src files and both webgpu build mirrors. A shared reference node is also refreshed by any record
that is not settled, so a settled record now sees a changed shadow radius or map size - before,
its private node was skipped with the rest of its per-object updates and kept the old value.

three-shared-render-group.spec.ts, real WGSLNodeBuilder + NodeManager.updateGroup:
- two materials on one PCF / PCF-soft shadow bind one render group: red before (two groups)
- three records walk the render group once per render call: red before (3 walks)
- a settled record sees a changed radius on the next render call: red before (kept 1, wanted 3)
- two shadows keep separate groups: green before and after (control)
three-*, virtual-shadow*, world-bundles, packaging: 15 files, 194 tests green.

* feat(core): the pyramid occlusion test on the CPU reference (PRD-489 phase 1)

`cullAndSelect` gains the hierarchical-depth test and reports what it hides.
Four answers are never "hidden", and each is a case a conservative test gets
wrong by being clever: a sphere touching the near plane, a sphere off screen
last frame, and anything on a camera-cut frame are kept; a measured run counts
what it hides and draws all of it, so a measured frame is the frame an
unmeasured run draws.

`packages/core/src/render/depth-pyramid.ts` is the mechanism: the max-distance
chain (farthest, not nearest — that is the conservative direction), the flag, the
CPU reference `occludedBy`, and the GPU half `DepthPyramid` the cull kernel will
read. A shadow map's own dispatch keeps the frustum as its whole rule: a caster
the eye cannot see still casts.

`pnpm exec vitest run packages/core/__tests__/world-gpu-scene.spec.ts` — 75
passed. Red-green: neutering the comparison fails "rejects" and "counts and
draws"; dropping the three keeps fails "keeps a sphere that touches the near
plane, off screen last frame, or a cut frame".

* feat(core): `?tnOcclusion=measure` counts what the pyramid cull would hide (PRD-489 phase 1)

The measure mode builds a max-distance chain from the previous frame's
scene-pass depth, runs the test in the cull kernel and counts the placements it
hides while drawing every one of them, so a measured frame is the frame an
unmeasured run draws.

The counts ride the args buffer's own tail, one word per key, and land with the
GPU-selected tally the main pass already reads back on its own clock: a second
readback of a second buffer is a second queue submission per window for eight
bytes, and it is what makes the would-cull share and the drawn-triangle count
come out of one frame rather than two.

`renderer.scenePassDepth()` is the seam: the world pass's own depth texture, or
`undefined` when no output graph installed a pass — which the mode reports as
`refused: no scene depth` rather than as a cull that found nothing to hide.

`pnpm exec vitest run packages/core/__tests__/world-gpu-scene.spec.ts` — 78
passed (3 new: the counted share, the refusal, and that the mode off runs neither
the chain nor the test). Red-green: moving the tail index fails the counted share.
`pnpm typecheck` and `pnpm lint` clean.

* feat(core): `?tnOcclusion=measure` counts what the pyramid cull would hide (PRD-489 phase 1)

Partial. The measure mode runs and prints `TN_WORLD_GPU_SCENE_OCCLUSION`, but the
scene-pass depth samples as 0 in the compute dispatch, so the would-cull share is
an artifact and the Phase 1 go/no-go cannot be computed. Changes on top of
`2c6cf0f97`:

- `depth-pyramid.ts`: one storage buffer for the table and the distances. Two
  buffers pushed the cull kernel past the WebGPU per-stage limit of 8 and the
  pipeline refused to build (`The number of storage buffers (9) ... exceeds the
  maximum per-stage limit (8)`).
- `renderer.ts` `scenePassDepth`: prefer the pass, then three's internal
  framebuffer target (where a game without a render chain draws the world), then
  the canvas; mark the depth as a render-target texture and register it with
  three's texture bookkeeping so a compute bind reuses the GPU texture instead of
  re-creating it (`Texture already initialized`). Also find a `PassNode` through
  a `PassTextureNode`, which a game hands `setOutputNode` instead of the pass.
- `world-gpu-scene.ts`: the measured-occlusion tail index is a float index, so
  floor the instance delta before it addresses the args buffer.

Box (a) is green: `world-gpu-scene.spec.ts` 78 passed. Box (b) stays open; the
depth read is the blocker. See `.afk/scratch/a11-prd489.md`.

* fix(core): the occlusion pyramid reads the real main-pass depth (PRD-489 phase 1)

The pyramid sampled the untouched canvas depth because the renderer
missed three's implicit depth attachment (Textures.js:81); MSAA depth is
now resolved by reading every sample. Manual initialization skipped
bindGroups, so Bindings threw before the main cull update (the measure
mode's picture change and binding errors), and a resize dispatched a
stale cull kernel. Specs: world-gpu-scene 78, 4 new. Authored by the
Codex arm, reviewed by the coordinator; GPU verification pending.

* fix(core): time the occlusion pyramid and keep its share honest (PRD-489 phase 1)

TN_FRAME_BUDGET.gpuPyramid reports p50/p95 for the depth resolve plus
downsample. Ordinary movement no longer counts as a camera cut and a
rebuilt depth is checked for readiness, so warmup, cuts and rebuilds skip
measurement instead of reporting share 0; decoding uses a frozen copy of
the dispatch layout so the would-cull count and its denominator stay
paired (share <= 1), and malformed samples are flagged. 101 specs pass.
Authored by the Codex arm, reviewed by the coordinator; GPU proof pending.

* fix(core): the GPU timestamp sampler picks frames, not calls (PRD-489 phase 1)

The 1-in-N sampler advanced per render/compute call, so the occlusion
pyramid's extra dispatch moved the main pass off the sampled frame and
gpuMain vanished from most windows under ?tnOcclusion=measure. It now
advances once per frame; compute, pyramid, render and overlay share one
frame id and one decision. Red-green: cadence equal with and without the
pyramid at intervals 1, 3 and 8 (3/3 red before). 235 specs pass. Codex
arm, reviewed by the coordinator.

* docs(runbook): tick A11; the occlusion go/no-go says go, so A12 opens

PRD-489 Phase 1: CPU reference pyramid test pinned; map-walk under
?tnOcclusion=measure over 3 walks reports a would-cull share of about
0.61 (max 0.84), a 0.07 ms pyramid p95 and gpuMain p95 in every window;
0.61 x 8.9 - 0.07 = 5.4 ms, above the 1.0 ms threshold. Phases 2-3
become runbook row A12.

* perf(core): a streamed chunk's textures reach the device before its first draw

`compileAsync` builds pipelines, not pixels, so the bake's textures reached the
device inside `_renderObjectDirect` on the frame the chunk first drew. Counted
on a Machinefall map-walk by instrumenting three's own internals per render
(19,000 renders, 3,143 new render objects, 632 first draws): the run spends
2,574 ms on first draws, 10 renders hold all of it, and the worst three walk
renders spend 87 ms of a 94 ms first draw, 79 ms of 131 ms, and 519 ms on
`Textures.updateTexture` alone. `RenderObjects.get` is 198 ms over 3,143
objects, 0.06 ms each, so the cost is not object creation — it is the per-sub-mesh
node build and texture upload that creation defers to the first draw.

Buffers were already moved to admission by `uploadAttributes` (826 buffers and
228.8 MB on a walk, up to 230 ms in the frames that first submitted them).
Textures are the same argument one resource class over, so they get the same
seam: `uploadTextures` walks a subtree's materials and calls three's own
`Textures.updateTexture`, which is the only path that leaves the bookkeeping
(`initialized`, `generation`) the first draw reads to skip the upload, and which
returns early on a texture already at its current version — so a material the
world shares with what is on screen costs one call and no work. WebGPU only, and
absent or throwing answers 0, so the first draw uploads exactly as before.

Measured, 2 walk pairs alternating on the private Machinefall copy, plus the
same-session base control:
- mainPass mean over streamed windows slower than 8.3 ms: 5.08 -> 3.81 ms (-25%)
- draw mean over the same windows: 2.69 -> 0.98 ms (-64%)
- worst such window: 25.3 -> 7.3 ms; AC render p95 median 10.30 -> 9.50, AC
  slow windows 10 -> 7
- cost, stated plainly: gpu mean median 4.00 -> 4.46 ms and total texture upload
  traffic 174 -> 874 MB, because admission now uploads the chunk's whole set
  rather than only what the walk happens to sample. The work leaves `mainPass`
  and lands in the host gap between frames.

Tests: packages/core/__tests__/renderer.spec.ts "uploadTextures" — the seam hands
each texture in the subtree to three once, through a fresh options object per
texture, and answers 0 on a renderer with no texture path. Red without the seam
(`uploadTextures` undefined), green with it. Whole core suite green (2,425).

Cherry-picked as one commit (net of ad44b0d59 + its revert) from perf/first-draw;
free Zen arm, reviewed by the coordinator.

* fix(core): a terrain block merges once at a time and keeps late marks

A block re-dirtied while its merge was out dispatched a second merge; the
older reply then wrote a stale membership into the record (4 duplicate
merges of one block on a held 240-frame walk, 0 after). A block in flight
is now skipped until its reply; a mark that lands meanwhile re-runs it with
the new membership, and a rejected merge frees the block and logs
TN_TERRAIN_MERGE_FAILURE. Pixels at floor; terrain-path cost was already
0.04 ms/frame, so this is correctness, not the AC-1 lever.

Net of e027f8df8 (free Zen arm) + fa0c15b9e (coordinator review fix via
Codex) from perf/tiles-burst.

* feat(assets): score every compressed texture against a quality floor (PRD-351 phase 1)

The compression passes decode mip 0 with the shipped Basis transcoder and
compare it with the same-resolution pre-encode image: SSIM on luma, mean
DeltaE00 on colour, alpha separately with MASK coverage. The manifest and
console report per-image scores, floor status (SSIM >= 0.95, DeltaE00 <=
3.0 for colour) and build totals; scores cache on the content key with the
metric version. Output bytes are unchanged with the instrument on or off.
Codex arm, reviewed by the coordinator.

* test(assets): pin the normal-map score; tick PRD-351 phase 1

The spike inputs were never committed (D31), so the score spec pins a
committed normal map: layer-00_nrm.jpg SSIM 0.99452 +/- 0.005, and 1x1
windows drift out of tolerance. Phase 1 boxes ticked with the real-asset
run: 48 textures, 32 pass, 0 below floor, payload bytes identical.

* feat(assets): a texture climbs the codec ladder until it meets the floor (PRD-351 phase 2)

Rungs etc1s@150, uastc+rdo l3+zstd, uastc+rdo l1+zstd, uastc, none; the
first rung that meets the floor wins and slots skip forbidden rungs (a
normal map never tries etc1s). The build reports "N etc1s . M escalated
to uastc . K uncompressed" and each image's rung; the cache key carries
encoder/metric versions, floor, slots, RDO settings and caps. assets.
textures.floor overrides the SSIM 0.95 / DeltaE00 3 default. On the
48-texture terrain corpus the payload is byte-identical to phase 1; cold
build 8.5 -> 13.9 s, warm 0.07 s. Codex arm.

* docs(prd-351): tick phase 2 with its red-green specs and the terrain-corpus run

* fix(core): the occlusion measure reads the right pyramid rows; PRD-489 declines

The Phase 1 pyramid test sampled inverted texture rows, under-covered
rotated/perspective footprints and dropped odd-reduction edge texels, so
?tnOcclusion=measure reported a would-cull share of 0.61 for an open
forest. Ported from feat/prd489-cull (a641f22a3) without default-on
culling: rows, conservative footprints, mip addressing, odd reductions,
near-plane handling, log/reversed-depth refusal; 184 specs, 12 reds.

Corrected share on Machinefall map-walk: median 0.013 (p95 0.29), so the
go/no-go is 0.05 ms < 1.0 ms. PRD-489 closes declined (D32), runbook A11
records the correction and row A12 is dropped. The working cull stays on
feat/prd489-cull. Codex arm, GPU-verified by the coordinator.

* feat(assets): maxSize takes a per-slot map (PRD-351 phase 3)

assets.models.textures.maxSize and assets.textures.maxSize accept a scalar
as before or a map keyed by glTF slot (baseColorTexture, normalTexture,
occlusionTexture, ...); a source under its cap is never upscaled and the
cache key carries the resolved cap. Defaults unchanged. Red-green: per-slot
caps (scalar control red), 512 stays 512 at cap 2048. Codex arm.

* test: the web-codec proof is compressible; pure noise stays below the floor

PRD-351's ladder refuses every lossy rung for white noise (UASTC SSIM
0.885, DeltaE00 14.7), so the scaffold's hash-noise web-codec-proof.png
stayed PNG. The fixture is now gradients with +/-3-level noise: PNG 46.6
KB, KTX2 4.3 KB, first rung ETC1S SSIM 0.969 / DeltaE00 1.9. A texture-
pass spec keeps the noise case: unchanged PNG, reported below-floor.

* test(assets): a 4096 colour/normal pair compiles to 2048/1024 under a per-slot map

Real-compiler coverage on web and Android with shared images. Prompted by
the Quarry pricing run, where the per-slot arm matched scalar 1024: the
fresh 4096 Landscape Pro import binds only normalTexture, so that was the
correct output, not a defect.

* docs(prd-351): price the resolution ladder on Quarry (phase 3 evidence)

4096 masters, one close rock pose: cap 1024 2.24 MB disk / 2.67 MB GPU /
SSIM 0.9885 (visibly softer); 2048 8.92 / 10.67 / 0.9920; 4096 35.97 /
42.67 / 0.9950 (sharpest). The fresh import binds only normalTexture, so
the per-slot candidate equals 1024 here and is not adopted; compiler
defaults stay.

* fix(assets): the ladder ships the smallest passing candidate; every gate has a control (PRD-351)

An executable negative control per gate (caps, no-upscale, byte minimum,
histogram, score tolerance, missing scores, Zstd/mips, cache identity,
fallback budget, atomic publication). The byte-minimum control went red:
the shared ladder selected a 676 B candidate while a 566 B one passed;
fixed, cache identity bumped. RDO crash (criterion 9): historical inputs
are gone; substitute 1024/4096 UASTC+RDO l3/l1 runs do not crash, rungs
kept, inputs and settings recorded in the PRD. Codex arm.

* test(assets): prove per-slot caps on a 512 pair, not 4096

Encoding two 4096 textures took 110 s and timed out under the suite's 60 s
limit; a 512 pair capped at 256/128 proves the same per-slot behaviour in
under a second per target.

* fix(assets): capped fallback keeps caps, bounded RDO, pruned scores (PRD-351)

- a below-floor or not-smaller fallback now retains the capped pixels as PNG in
  standalone and embedded texture passes, so caps survive the fallback path
- RDO search is bounded for images over 256x256 (pack flags off, 64 B dict) with
  full-effort plain UASTC as fallback; encoder version bumped to -fast-capped-v3
- imageQuality takes an identity fast path for the retained/identical pair and
  skips redundant deltaE00 work on unchanged pixels
- candidate pruning keeps the smallest passing output, not merely the first
- scaffold hashes refreshed for 2dc92a2b2's shared shadow-uniform patch

verified: image-quality 55, model-texture-pass/texture-pass/shared-images/compile/
report, scaffold byte-stable; assets tsc clean; Biome warnings only.

* fix(core): measure complete GPU frames and rendered shadow tails

* feat(core): join frame-budget windows on the simulation tick

* chore(deps): refresh pnpm-lock.yaml importers

Regenerate the lock after the catalog/importer updates: three@0.185.1
patch hash, vite@8.2.0 patch hash, and typescript ->
@typescript/typescript6@6.0.2. No version upgrades beyond the catalog.

Checks (TN_RUNNER unset, no build/GPU lanes):
- pnpm install --frozen-lockfile: pass
- pnpm lint: pass (warnings only, exit 0)
- pnpm typecheck: FAIL, packages/core/__tests__/world-gpu-scene.spec.ts:3976
  TS2322 depth.image.width is number|undefined vs the number required by
  Renderer.scenePassDepth (renderer.ts:224). @types/three and the native
  tsc (7.0.2) are unchanged by this lock diff, so the mismatch is
  preexisting in source, not introduced here.
- pnpm test: FAIL, 24 tests in packages/runtime-native: missing native
  test binaries (cmake --build), stale SBOM receipts vs the dirty lock,
  and a generated-render-chain expectation drift. Plus 1 filesystem
  failure and 69 skipped; run stopped at the first failing package.

* fix(core,native): clear root integration gates after the develop merge

Two source drifts, both surfaced by the parent's root typecheck/test run
(/tmp/runbook-120-root-{typecheck,test}.log). No new files or helpers.

- core world-gpu-scene.spec.ts:3976: the depth fixture cast three's
  DepthTexture.image width/height (number | undefined) straight into
  scenePassDepth, which declares number. Guard the optional values and throw
  when absent; no non-null cast, and the 8 -> 9 odd-resize path is unchanged.
- runtime-native generated-render-chain.test.mjs: the composed-chain
  expectation still described the pre-PRD-492 scenario. The scenario added
  grade/grain to stages.includes, a stages.order list and
  contributions.graphOutputChanged; assert that actual shape.

Outcomes (TN_RUNNER unset):
- pnpm typecheck: exit 0
- vitest packages/core/__tests__/world-gpu-scene.spec.ts: 89/89
- vitest tests/generated-render-chain.test.mjs tests/native-sbom.test.mjs: 6/6
- pnpm lint: exit 0 (warnings only)

Native SBOM receipt failures were local untracked cache, not source: the
third_party receipts predated develop's purely additive native-deps.lock
bump (PRD-465 openriglogic). Re-stamped each receipt's lockHash to the
current lock; nothing committable (third_party is untracked by contract).

The remaining runtime-native failures (crash-handler-policy,
native-adapter-policy, pump-silence, rg11b10-renderable, runtime-next-contract,
timestamp-query) are unbuilt desktop host/test binaries: build/tn-linux and
build/tn-linux-quickjs are absent. The root suite aborts at the first failing
package, so packages after runtime-native are unproven, not passed.

* docs: record partial Phase 2 proof and correct walking mask

* docs: preserve invalid timing attempts in Phase 2 proof

* docs: record complete Phase 2 reviews and pilot limits

* docs: record tick fixture checks and interrupted capture

* fix(core): keep fallback caster layers until a shadow key mesh exists

A published tnShadowGpuKeys provider switched a level camera to the key
layer even when the world had minted no key mesh, which dropped the wide
and small caster layers. #probe now switches only when a visible casting
key mesh exists. Regression spec: Machinefall's [250] / 4096 shape, flag
on with no keys must submit what the flag-off control submits (red: 19
cluster submissions vs 3 fallback; green after).

This is not the highway shadow loss in #437: that run minted keys.

* fix(core): rebuild the shadow kernel when the GPU scene's buffers grow

WorldGpuScene.#growOf reset the main cull kernel on a buffer grow but kept
#shadowKernel. The shadow kernel stayed bound to the first placement buffer
and its first thread count, so with ?tnShadowGpuKeys=1 every later level
render selected from the camp's handful of placements. On the Machinefall
walk a debug readback showed 0 twin instances on all 138 keyed renders
while the CPU reference selected ~8,900, and the highway lost its road
shadows (#437).

Red-green: world-gpu-scene.spec.ts "rebuilds the level's kernel against a
placement buffer that grew after its first render" reused the same
ComputeNode before the fix and passes after it. Five shadow/GPU-scene
suites: 192 passed. Rendered map-walk with keys on: road shadows restored,
20/20 assertions, judge PASS against the keys-off control.

* docs(prd-478): record pop intervals 04/28 on the shadow-kernel fix

* docs(prd-478): mark Phase 3 done; both its boxes were already ticked

* fix(core): shadow keys cast at the main pass's level, from the eye

The GPU shadow kernel picked each placement's LOD by its distance from
the shadow window centre. In an aerial pose the centre is the view's
focus, about 150 m ahead of the eye. Placements near the eye then
selected LOD 1-3. With castLevels: 1 those levels have no twin mesh, so
the placements cast nothing, while the cluster path cast them from
their LOD 0 key. A coarse map asked for COARSEST_SHADOW_LEVEL, which
also has no twin with castLevels: 1, so it drew no keys at all.

The shadow kernel now uses the main pass's eye and LOD bias, so a
placement casts from the key the main pass draws it in. A placement
whose level has no twin casts nothing, and a map's base is clamped to
the last level that has a twin. A minted twin marks its main key record
(keys.w) so the kernel can read that. IShadowLevel.centre is unused and
is removed.

Red-green: world-gpu-scene.spec.ts "draws each placement at the main
pass's own level, from the eye, and only where that level casts" fails
before (expected [ 1 ] to deeply equal [ +0, 3 ]) and passes after.
Nine shadow/GPU-scene spec files: 239 passed.

* docs(prd-478): record the eye-LOD shadow fix and its blind review

* feat(scripts): pair two playtest reports label by label

playtest-pair.ts joins two report.json componentSeries on label and prints
each field A|B plus a camera same/diff column. --equal names fields that
must match; a difference, a missing label or an empty series exits 1.
It is the PRD-478 paired-walk and same-arm counter check.

* docs(prd-478): record the paired-report tool and the same-arm counter drift

* docs(prd-478): same-arm byMove drifts on a quiet machine too

* feat(core): count shadow window travel in pages

byMove counts move renders, so it depends on how many rendered frames
sampled the camera path: a fixed-tick route run at two frame rates
reports different counts (laptop pair: 2 of 38 labels dif…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

critical prd:75% PRD phases in, acceptance open

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant