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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/PRDs/assets/PRD-539-card-lod-levels-keep-the-canopy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# PRD-539 — Card LOD levels keep the canopy

**Status:** IN PROGRESS
**Priority:** P1 — Open: PRD-478's walking GPU p95 on Machinefall needs these trees to have a chain, and today a chain thins their needles visibly.
**Complexity:** 5 (MEDIUM) — one cook stage (`packages/assets/src/lod/`), the model pass's self-check, a Machinefall re-cook with timing and blind raters
**Owner:** João
**Depends on:** none. [PRD-478](../open-world/PRD-478-open-world-frame-architecture.md)'s AC-2 and AC-3 depend on this PRD.

## Context

Measured on Machinefall `map-walk`, desktop RTX 2080, quiet desktop (load under 10), 2026-10-08:

| Fact | Value |
| --- | --- |
| Early-route GPU p95 (route windows 1–2) | 9.2–9.8 ms; `gpuMain` p50 8.6 ms, 8.2 M main-pass triangles at the adaptive bias cap 2.5 |
| Single-level trees in that window | 9.4 M of 9.8 M triangles; `map_tree_kite_scotspinetall_01_far_0_001` alone is 545 instances x 8.9 k |
| Why single-level | the GLB's only mesh is the Fab pack's `*_far_LOD2`, and the cook skips any level-named mesh as `authored-lod` |
| With a chain (re-cook, same JS bundle) | route GPU p95 max 7.73 ms; the pooled walking bound has 1.6 % of frames over 8.3 ms (pass is 5 %) |
| What the chain costs | blind look: mid-distance pines read as bare trunks. The needle part takes the `cards` strategy, which keeps 50 % then 25 % of the cards **unscaled**, so canopy area falls to the keep ratio (`cards.ts`, PRD-458 AC-4) |

Keeping the needles at full cards is not an option: with every card part at LOD0 the early route goes to 11–13 M triangles, more than today.

## Solution

1. **A lone level name is not an authored chain.** The cook counts an asset as authored LOD only when it holds two or more level-named nodes. A lone `_LOD2` has no sibling for anything to select, so it is that asset's LOD0. Already written and red-green on PRD-478's branch (`d65fa1eab`); it moves here so it never ships without step 2.
2. **Card levels keep their coverage.** A card level appends a copy of each kept card, scaled about its centroid by `sqrt(1 / keep)`, to the primitive's own vertex arrays. The level's indices point at those copies. LOD0's indices do not change, and no schema changes: `TN_discrete_lod` stays index-only, and both readers (core's `model-lod.ts` and the world's `chainLevelParts`) already copy the base attributes and swap only the index buffer.
3. **Every chained level casts.** `shadows.castLevels` defaulted to 1 because a far level's shadow was taken to be sub-texel. That holds only outside the shadow window, and a shadow level draws only the caster clusters its window covers, so the default is now every level.
4. **The self-check measures what LOD0 draws.** `reachableStats` counts the vertices and the bounds that LOD0's indices reference. Appended level vertices are then not drift, and a real drift in LOD0 still fails.

Layer: the cook (`packages/assets/`). The game sets nothing; the scale is the keep ratio, which the reducer already owns.

## Decisions

- 2026-10-08, agent: a kept card scales by at most 2. The ratio levels (keep ≥ 0.25) reach full coverage under it. The terminal level keeps far fewer cards, and scaling them further would grow cards past the crown's silhouette, so it covers `4 × keep` of LOD0.

## Findings

- 2026-10-08 blind raters, PRD-478 tip vs this PRD's cook plus every level casting (same JS bundle except the cast default, laptop Intel GPU): 3 fresh raters, 8 poses, the candidate at or above on 24 of 24 votes (20 prefer, 4 ties, all ties on the bridge pose). All three note a few dark needle specks above the treetops on the highway pose: the bark part's one derived level drops the top 19 % of the trunk (top 0.920 → 0.746 of the quantized height) while the needles keep theirs, because a shallower part follows the deepest part's switch (`chainDistances`).

## Acceptance Criteria

- [x] AC-1: every ratio card level ships ≥ 0.95 of LOD0's card area, read back from the cooked bytes; the terminal level ships min(1, 4 × keep). proof: `foliage-lod.spec.ts` — 8 passed; red before the change: `expected 0.5162100266415837 to be greater than or equal to 0.95`
- [ ] AC-2: Machinefall `map-walk` route GPU p95 passes the pooled walking bound (≤ 5 % of walking frames over 8.3 ms) on 3 quiet runs. proof: `TN_FRAME_BUDGET` windows, quiet desktop RTX 2080
- [ ] AC-3: no visual loss against PRD-478's tip. proof: 3 fresh blind raters at or above on the 4 `map-walk` and 4 `map-views` poses, and a pop series with no candidate-only late object

## Execution Phases

#### Phase 1: The cook
**Status:** DONE
**Files:** `packages/assets/src/lod/{cards,generate,eligibility}.ts`, `packages/assets/src/passes/model.ts`, `packages/assets/__tests__/`

- [x] A lone level-named mesh gets a generated chain; two or more level-named nodes stay `authored-lod`. proof: red-green `lod-generation.spec.ts` — red `expected [ 'authored-lod' ] to not include 'authored-lod'`, then 44 passed (`e82e842e0`)
- [x] Card levels index scaled copies of their kept cards, LOD0 indices unchanged. proof: red-green `foliage-lod.spec.ts` (AC-1)
- [x] The model pass's self-check counts what LOD0 draws, so a card chain cooks without `TN_ASSETS_MODEL_DRIFT` and a LOD0 drift still throws. proof: `model-pass.spec.ts` drift cases green; the conifer cook threw `vertices 5997 -> 7573; bounding box drifted 2.109%` before; `vitest run packages/assets` 485 passed, 2 skipped

#### Phase 2: Both runtimes draw it
**Status:** DONE
**Files:** `packages/core/src/world-cells.ts`, `packages/core/__tests__/`

- [x] Core builds a card level whose geometry draws the scaled copies. proof: `model-lod-loader.spec.ts` "draws a level whose indices reach vertex copies appended after LOD0's" — 5 passed; no core change was needed
- [x] A streamed world's chained levels cast shadows by default (`shadows.castLevels` defaults to every level; a number still caps it). Under the old default of 1, every pine past its first switch, tens of metres out, lost its shadow, and 3 blind raters preferred the base on 6 of 8 poses ("weak shadows, flatter"). proof: red-green `world-cells.spec.ts` (`a far level minted no caster cluster: expected 0 to be greater than 0`); `vitest run packages/core packages/assets` 3086 passed, 1 load timeout that passes alone
- [x] The native desktop host draws the same chain. proof: `examples/auto-lod` gains a 1,200-card crown (`scripts/make-cards.mjs`); cooked to `cards` levels 2,400 → 1,252 → 646 triangles over 4,800 → 8,596 vertices. `lod-far-desktop` and `lod-near-desktop` pass on the native host built by `threenative build --target desktop` (prebuilt runtime): `cardCopies` 1 → 0 and `cardTriangles` 1,252 → 2,400 across the camera toggle. Web `lod-far` passes (`cardCopies` 0 → 1, `cardTriangles` 2,400 → 1,252, RTX 2080). Web `lod-near` is red on develop too (`triangles` 8192 → 8192, the first observation precedes selection), so it carries no card check.

#### Phase 3: Machinefall
**Status:** NOT STARTED
**Files:** none in the engine

- [ ] Re-cook and time it. proof: AC-2's quiet runs
- [ ] Blind raters and the pop series. proof: AC-3's sheets on the PR
Binary file added examples/auto-lod/assets/cards.glb
Binary file not shown.
12 changes: 12 additions & 0 deletions examples/auto-lod/playtests/lod-far-desktop.playtest.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@
"id": "GameState",
"lte": 4096,
"path": "triangles"
},
{
"id": "GameState",
"path": "cardCopies",
"gte": 1,
"allowTrivial": "The scene starts on the far route and holds it, so the far level is selected before the run; the matching near scenario drives cardCopies to 0 and cardTriangles to 2400 to prove the selection follows the camera."
},
{
"id": "GameState",
"path": "cardTriangles",
"lte": 1800,
"allowTrivial": "The scene starts on the far route and holds it, so the far level is selected before the run; the matching near scenario drives cardCopies to 0 and cardTriangles to 2400 to prove the selection follows the camera."
}
],
"sceneNodes": [
Expand Down
12 changes: 12 additions & 0 deletions examples/auto-lod/playtests/lod-far.playtest.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@
"id": "GameState",
"lte": 4096,
"path": "triangles"
},
{
"id": "GameState",
"path": "cardCopies",
"gte": 1,
"allowTrivial": "The scene starts on the far route and holds it, so the far level is selected before the run; the matching near scenario drives cardCopies to 0 and cardTriangles to 2400 to prove the selection follows the camera."
},
{
"id": "GameState",
"path": "cardTriangles",
"lte": 1800,
"allowTrivial": "The scene starts on the far route and holds it, so the far level is selected before the run; the matching near scenario drives cardCopies to 0 and cardTriangles to 2400 to prove the selection follows the camera."
}
],
"sceneNodes": [
Expand Down
26 changes: 23 additions & 3 deletions examples/auto-lod/playtests/lod-near-desktop.playtest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
"schemaVersion": 1,
"subject": "the native host's near camera selects LOD0 for the model",
"target": "desktop",
"viewport": { "width": 1280, "height": 720 },
"viewport": {
"width": 1280,
"height": 720
},
"warmupFrames": 90,
"artifacts": { "screenshots": false, "console": true },
"artifacts": {
"screenshots": false,
"console": true
},
"steps": [
{
"kind": "input",
Expand All @@ -28,11 +34,25 @@
"gte": 8000,
"id": "GameState",
"path": "triangles"
},
{
"changed": true,
"id": "GameState",
"lte": 0,
"path": "cardCopies"
},
{
"changed": true,
"gte": 2400,
"id": "GameState",
"path": "cardTriangles"
}
],
"sceneNodes": [
{
"select": { "nameContains": "hull-mesh" },
"select": {
"nameContains": "hull-mesh"
},
"minCount": 1,
"minTriangles": 8000,
"visible": true
Expand Down
118 changes: 118 additions & 0 deletions examples/auto-lod/scripts/make-cards.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Builds the example's card crown: 1,200 alpha-tested quads (2,400 triangles) scattered through a
// sphere, written as a dependency-free GLB. The cook gives it a `cards` chain whose levels draw
// scaled copies of the kept cards appended after LOD0's vertices (PRD-539), which is the shape the
// desktop scenarios prove the native host draws.
import { writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const CARDS = 1200;
const HALF = 0.09;

// A fixed LCG, so the file is the same bytes on every run.
let seed = 7;
const random = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 2 ** 32;
};

const positions = new Float32Array(CARDS * 4 * 3);
const indices = new Uint32Array(CARDS * 6);
for (let card = 0; card < CARDS; card += 1) {
let x;
let y;
let z;
do {
x = random() * 2 - 1;
y = random() * 2 - 1;
z = random() * 2 - 1;
} while (x * x + y * y + z * z > 1);
const angle = random() * Math.PI;
const ux = Math.cos(angle) * HALF;
const uz = Math.sin(angle) * HALF;
const corners = [
[x - ux, y - HALF, z - uz],
[x + ux, y - HALF, z + uz],
[x + ux, y + HALF, z + uz],
[x - ux, y + HALF, z - uz],
];
for (const [corner, point] of corners.entries()) positions.set(point, (card * 4 + corner) * 3);
const a = card * 4;
indices.set([a, a + 1, a + 2, a, a + 2, a + 3], card * 6);
}

const min = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
const max = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
for (let vertex = 0; vertex < positions.length / 3; vertex += 1) {
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], positions[vertex * 3 + axis]);
max[axis] = Math.max(max[axis], positions[vertex * 3 + axis]);
}
}

const pad = (buffer, fill) =>
buffer.length % 4 === 0
? buffer
: Buffer.concat([buffer, Buffer.alloc(4 - (buffer.length % 4), fill)]);
const positionBuffer = Buffer.from(positions.buffer);
const indexBuffer = Buffer.from(indices.buffer);
const bin = pad(Buffer.concat([positionBuffer, indexBuffer]), 0);
const json = pad(
Buffer.from(
JSON.stringify({
accessors: [
{
bufferView: 0,
componentType: 5126,
count: positions.length / 3,
max,
min,
type: "VEC3",
},
{ bufferView: 1, componentType: 5125, count: indices.length, type: "SCALAR" },
],
asset: { generator: "examples/auto-lod make-cards", version: "2.0" },
bufferViews: [
{ buffer: 0, byteLength: positionBuffer.length, byteOffset: 0, target: 34962 },
{
buffer: 0,
byteLength: indexBuffer.length,
byteOffset: positionBuffer.length,
target: 34963,
},
],
buffers: [{ byteLength: bin.length }],
materials: [
{
alphaCutoff: 0.5,
alphaMode: "MASK",
doubleSided: true,
name: "leaf",
pbrMetallicRoughness: { baseColorFactor: [0.2, 0.45, 0.15, 1], metallicFactor: 0 },
},
],
meshes: [
// biome-ignore lint/style/useNamingConvention: `POSITION` is the glTF attribute semantic, fixed by the spec.
{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0, mode: 4 }] },
],
nodes: [{ mesh: 0 }],
scene: 0,
scenes: [{ nodes: [0] }],
}),
),
0x20,
);

const total = 12 + 8 + json.length + 8 + bin.length;
const glb = Buffer.alloc(total);
glb.writeUInt32LE(0x46546c67, 0);
glb.writeUInt32LE(2, 4);
glb.writeUInt32LE(total, 8);
glb.writeUInt32LE(json.length, 12);
glb.writeUInt32LE(0x4e4f534a, 16);
json.copy(glb, 20);
glb.writeUInt32LE(bin.length, 20 + json.length);
glb.writeUInt32LE(0x004e4942, 24 + json.length);
bin.copy(glb, 28 + json.length);
writeFileSync(resolve(here, "../assets/cards.glb"), glb);
38 changes: 35 additions & 3 deletions examples/auto-lod/src/scenes/Lod.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ICtx, Scene, type SceneFrame } from "@threenative/core";
import { type ICtx, Scene, type SceneFrame, baseGeometryOf } from "@threenative/core";
import { AmbientLight, DirectionalLight, type Group, Mesh, type PerspectiveCamera } from "three";

/** Close enough that the model's own error projects past a one-pixel budget. */
Expand All @@ -16,10 +16,17 @@ const FAR = 140;
* engine swaps `mesh.geometry` before the render, so a frame after a settle reads the selected level.
*/
export class Lod extends Scene {
static override readonly initialState = { camera: "far", triangles: 0 };
static override readonly initialState = {
camera: "far",
cardCopies: 0,
cardTriangles: 0,
triangles: 0,
};

#loaded = false;
readonly #meshes: Mesh[] = [];
/** The card crown: its levels draw scaled copies appended after LOD0's vertices (PRD-539). */
readonly #cards: Mesh[] = [];

override async load(ctx: ICtx): Promise<void> {
const model = await ctx.assets.model<{ scene: Group }>("hull.glb");
Expand All @@ -30,6 +37,15 @@ export class Lod extends Scene {
this.#meshes.push(object);
});
ctx.scene.add(model.scene);
const crown = await ctx.assets.model<{ scene: Group }>("cards.glb");
crown.scene.name = "cards";
crown.scene.position.set(3, 0, 0);
crown.scene.traverse((object) => {
if (!(object instanceof Mesh)) return;
object.name = "cards-mesh";
this.#cards.push(object);
});
ctx.scene.add(crown.scene);
this.#loaded = true;
}

Expand All @@ -54,7 +70,23 @@ export class Lod extends Scene {
const drawn = mesh.geometry.index?.count ?? mesh.geometry.getAttribute("position")?.count;
triangles += Math.floor((drawn ?? 0) / 3);
}
frameCtx.state.set({ triangles });
// A level that reaches a vertex LOD0 never indexes is drawing the scaled card copies.
let cardTriangles = 0;
let cardCopies = 0;
for (const mesh of this.#cards) {
const drawn = mesh.geometry.index;
const lod0 = baseGeometryOf(mesh).index;
if (drawn === null || lod0 === null) continue;
cardTriangles += Math.floor(drawn.count / 3);
let lod0Max = 0;
for (let at = 0; at < lod0.count; at += 1) lod0Max = Math.max(lod0Max, lod0.getX(at));
for (let at = 0; at < drawn.count; at += 1)
if (drawn.getX(at) > lod0Max) {
cardCopies = 1;
break;
}
}
frameCtx.state.set({ cardCopies, cardTriangles, triangles });
if (!frameCtx.input.justPressed("toggleNear")) return;
// Toggle, so a scenario can drive the value and prove the selection follows the camera
// rather than reporting a number that never changed.
Expand Down
Loading
Loading