diff --git a/docs/PRDs/assets/PRD-539-card-lod-levels-keep-the-canopy.md b/docs/PRDs/assets/PRD-539-card-lod-levels-keep-the-canopy.md new file mode 100644 index 0000000000..c1149c9685 --- /dev/null +++ b/docs/PRDs/assets/PRD-539-card-lod-levels-keep-the-canopy.md @@ -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 diff --git a/examples/auto-lod/assets/cards.glb b/examples/auto-lod/assets/cards.glb new file mode 100644 index 0000000000..dd0de1c82d Binary files /dev/null and b/examples/auto-lod/assets/cards.glb differ diff --git a/examples/auto-lod/playtests/lod-far-desktop.playtest.json b/examples/auto-lod/playtests/lod-far-desktop.playtest.json index c50a884914..6b9203c252 100644 --- a/examples/auto-lod/playtests/lod-far-desktop.playtest.json +++ b/examples/auto-lod/playtests/lod-far-desktop.playtest.json @@ -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": [ diff --git a/examples/auto-lod/playtests/lod-far.playtest.json b/examples/auto-lod/playtests/lod-far.playtest.json index 4604dc57f6..4038ffe336 100644 --- a/examples/auto-lod/playtests/lod-far.playtest.json +++ b/examples/auto-lod/playtests/lod-far.playtest.json @@ -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": [ diff --git a/examples/auto-lod/playtests/lod-near-desktop.playtest.json b/examples/auto-lod/playtests/lod-near-desktop.playtest.json index 4aac079974..5a194a8e0c 100644 --- a/examples/auto-lod/playtests/lod-near-desktop.playtest.json +++ b/examples/auto-lod/playtests/lod-near-desktop.playtest.json @@ -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", @@ -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 diff --git a/examples/auto-lod/scripts/make-cards.mjs b/examples/auto-lod/scripts/make-cards.mjs new file mode 100644 index 0000000000..460365a636 --- /dev/null +++ b/examples/auto-lod/scripts/make-cards.mjs @@ -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); diff --git a/examples/auto-lod/src/scenes/Lod.ts b/examples/auto-lod/src/scenes/Lod.ts index 62f3d87633..4526cfb105 100644 --- a/examples/auto-lod/src/scenes/Lod.ts +++ b/examples/auto-lod/src/scenes/Lod.ts @@ -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. */ @@ -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 { const model = await ctx.assets.model<{ scene: Group }>("hull.glb"); @@ -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; } @@ -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. diff --git a/packages/assets/__tests__/foliage-lod.spec.ts b/packages/assets/__tests__/foliage-lod.spec.ts index 1feebee446..fec9d96e60 100644 --- a/packages/assets/__tests__/foliage-lod.spec.ts +++ b/packages/assets/__tests__/foliage-lod.spec.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { Document, Logger, NodeIO } from "@gltf-transform/core"; +import { type Accessor, Document, Logger, NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { MeshoptDecoder } from "meshoptimizer"; import { describe, expect, it } from "vitest"; @@ -60,6 +60,31 @@ async function cookConifer(lod: IModelPassOptions["lod"] = true): Promise { expect(card.cards).toBeGreaterThan(0); expect(card.keep).toBeGreaterThan(0); // The crown: no occupied cell of LOD0's stratification grid is emptied at any level, which - // is the property that separates a grid from a random or index-order sample. Cell coverage - // is the ≥ 70% figure; card *area* coverage cannot be, because `TN_discrete_lod` is - // index-only and cannot carry the per-level scale step (`cards.ts` says so at the top) — it - // is the keep ratio up to the per-cell rounding, asserted here so the number cannot drift - // into a claim it never met. + // is the property that separates a grid from a random or index-order sample. And the kept + // cards are scaled up about their centroids, so the level covers LOD0's card area rather + // than the keep ratio of it, which read as bare trunks on Machinefall (PRD-539). expect(card.cellCoverage).toBeGreaterThanOrEqual(0.7); - expect(card.areaCoverage).toBeCloseTo(card.keep, 1); + expect(card.areaCoverage).toBeGreaterThanOrEqual(0.95); } } - // Read back out of the *shipped* bytes: the cards really are a subset of LOD0's own indices, so - // a stock loader draws the crown and a level, and the chain validated against the written file. + // Read back out of the *shipped* bytes: each level draws at least 95 % of LOD0's card area + // from the primitive's own vertex arrays, so a stock loader still draws LOD0 and the runtime's + // index swap draws a covered crown. const leaves = document .getRoot() .listMeshes() .find((mesh) => mesh.getName() === "leaves"); const primitive = leaves?.listPrimitives()[0]; - const lod0 = Uint32Array.from(primitive?.getIndices()?.getArray() as ArrayLike); - const chain = primitive?.getExtension(TN_DISCRETE_LOD); + const position = primitive?.getAttribute("POSITION"); + if (primitive === undefined || position === null || position === undefined) + throw new Error("no needle primitive"); + const lod0 = Uint32Array.from(primitive.getIndices()?.getArray() as ArrayLike); + const chain = primitive.getExtension(TN_DISCRETE_LOD); expect(chain?.getStrategy()).toBe("cards"); for (const accessor of chain?.getIndices() ?? []) { const level = Uint32Array.from(accessor.getArray() as ArrayLike); expect(level.length).toBeGreaterThan(0); - for (const index of level) expect(lod0).toContain(index); + expect(drawnArea(position, level) / drawnArea(position, lod0)).toBeGreaterThanOrEqual(0.95); } }, 300_000); @@ -327,14 +354,14 @@ describe("foliage cutout conversion and LOD (PRD-458 AC-4)", () => { .listMeshes() .find((mesh) => mesh.getName() === "leaves") ?.listPrimitives()[0]; - const lod0 = Uint32Array.from(primitive?.getIndices()?.getArray() as ArrayLike); + const vertices = primitive?.getAttribute("POSITION")?.getCount() ?? 0; const chain = primitive?.getExtension(TN_DISCRETE_LOD); const accessors = chain?.getIndices() ?? []; expect(accessors).toHaveLength(entry.levels.length); for (const accessor of accessors) { const level = Uint32Array.from(accessor.getArray() as ArrayLike); expect(level.length).toBeGreaterThan(0); - for (const index of level) expect(lod0).toContain(index); + for (const index of level) expect(index).toBeLessThan(vertices); } }, 300_000); diff --git a/packages/assets/__tests__/lod-generation.spec.ts b/packages/assets/__tests__/lod-generation.spec.ts index e3c414c6b9..d35955c9b8 100644 --- a/packages/assets/__tests__/lod-generation.spec.ts +++ b/packages/assets/__tests__/lod-generation.spec.ts @@ -57,6 +57,8 @@ async function torusGlb( joints?: boolean; morph?: boolean; name?: string; + /** One node and mesh per name, each with its own copy of the primitive. */ + names?: readonly string[]; noIndices?: boolean; } = {}, ): Promise { @@ -114,10 +116,14 @@ async function torusGlb( accessor(document, buffer, "VEC4", new Float32Array(count * 4)), ); if (options.morph === true) primitive.addTarget(document.createPrimitiveTarget()); - const name = options.name ?? "hull"; - scene.addChild( - document.createNode(name).setMesh(document.createMesh(name).addPrimitive(primitive)), - ); + for (const [index, name] of (options.names ?? [options.name ?? "hull"]).entries()) + scene.addChild( + document + .createNode(name) + .setMesh( + document.createMesh(name).addPrimitive(index === 0 ? primitive : primitive.clone()), + ), + ); return Buffer.from(await new NodeIO().registerExtensions(ALL_EXTENSIONS).writeBinary(document)); } @@ -746,6 +752,17 @@ describe("automatic discrete LOD generation", () => { expect(summary.reasons).toContain("already-cooked"); }, 120_000); + it("generates a chain for a lone LOD-named mesh, which no authored chain selects", async () => { + // A pack's `_LOD2` scattered as a tree of its own: no sibling level exists in the asset, so + // skipping it as authored left Machinefall's 545 nearest pines at 8,900 triangles each. + const summary = await cook(await mediumGlb({ name: "pine_far_LOD2" }), { + lod: GENERATE, + virtual: "none", + }); + expect(summary.reasons).not.toContain("authored-lod"); + expect(summary.generated).toBeGreaterThan(0); + }, 120_000); + it("reports each structural skip reason on a fixture that triggers it", async () => { const cases: readonly { reason: string; @@ -773,7 +790,7 @@ describe("automatic discrete LOD generation", () => { // of LOD0's fragments, so a chain keeps its silhouette. `foliage-lod.spec.ts` proves the // foliage half; `BLEND` is still refused, because a simplified blended card is a hole. { build: () => smallGlb({ alpha: "BLEND" }), reason: "material-unsupported" }, - { build: () => smallGlb({ name: "hull_LOD1" }), reason: "authored-lod" }, + { build: () => smallGlb({ names: ["hull_LOD0", "hull_LOD1"] }), reason: "authored-lod" }, { // Edge (0,1) is shared by three triangles: a non-manifold fan. A tiny floor is needed so // the fixture is not declined as too-small first. @@ -1143,7 +1160,7 @@ describe("terminal coarse level (PRD-473)", () => { // The chain layout did not change, so a file cooked before this feature still reads. The cache // identity moved instead, which is what makes a re-cook pick the terminal level up. expect(LOD_SCHEMA_VERSION).toBe(1); - expect(LOD_GENERATOR_VERSION).toBe(3); + expect(LOD_GENERATOR_VERSION).toBe(4); const document = await readWithLod(await oldChainGlb()); const primitive = document .getRoot() diff --git a/packages/assets/src/lod/cards.ts b/packages/assets/src/lod/cards.ts index 7553390a6c..d3ec079f92 100644 --- a/packages/assets/src/lod/cards.ts +++ b/packages/assets/src/lod/cards.ts @@ -22,16 +22,13 @@ * hash of their own centroid. The same input bytes always produce the same levels, which is what * `determinism.spec.ts` and a cache keyed on a generation fingerprint both depend on. * - * **No scaling, and why.** A kept card is normally scaled about its centroid by `sqrt(1 / keep)`, so - * the surviving cards cover the area the dropped ones did. That needs *new vertex positions*, and - * `TN_discrete_lod` is index-only by construction: a level's POSITION/NORMAL/UV come from the - * primitive's own accessors and the runtime reader (`packages/core/src/model-lod.ts`) builds each - * derived level by copying the base's attributes and swapping in only an index buffer. So the scale - * step is not representable in the chain as it is specified today, and the levels shipped here are - * the un-scaled subset. The consequence is stated rather than hidden: card *area* coverage falls to - * the keep ratio, and what the grid buys is cell coverage — every occupied cell still holds a card. - * Lifting it needs a per-level vertex buffer in the chain (a core schema change, PRD-458 §4), not a - * different selection. + * **Scaled, so the crown stays covered.** Each kept card is scaled about its centroid by + * `sqrt(lod0Area / keptArea)`, so the survivors cover the area the dropped cards did. Un-scaled, a + * level's card area falls to the keep ratio, and on Machinefall the mid-distance pines read as bare + * trunks (PRD-539). `TN_discrete_lod` stays index-only: a level's scaled cards are *copies* appended + * to the primitive's own vertex arrays (see {@link ICardCopies}), and the level's indices point at + * them. LOD0's indices never reference a copy, so a stock loader draws LOD0 unchanged, and both + * runtime readers already build a level by copying the base attributes and swapping the index buffer. */ /** @@ -54,6 +51,12 @@ export const CARD_KEEP_RATIOS: readonly number[] = [0.5, 0.25]; const TRIANGLE = 3; +/** + * The largest scale a kept card takes. Above it, a card grows past the crown's own silhouette; the + * terminal level, which keeps far fewer cards than this restores, then covers `keep * 4` of LOD0. + */ +const MAX_CARD_SCALE = 2; + /** One card: the triangles of one connected component, its centroid and its area. */ export interface ICard { /** Indices into the primitive's index buffer, one entry per triangle of this card. */ @@ -64,18 +67,32 @@ export interface ICard { /** What one derived level kept, and what it cost the crown. Reported, never inferred. */ export interface ICardLevelSummary { - /** Kept card area as a fraction of LOD0's total card area — the un-scaled coverage, honestly low. */ + /** Kept card area after scaling, as a fraction of LOD0's total card area. */ readonly areaCoverage: number; readonly cards: number; /** Fraction of LOD0's occupied grid cells that still hold at least one kept card. */ readonly cellCoverage: number; /** The fraction of LOD0's cards this level kept. */ readonly keep: number; + /** The factor every kept card was scaled by about its centroid; `1` keeps it as authored. */ + readonly scale: number; +} + +/** + * One level's scaled cards, appended after the primitive's vertices (and any earlier level's copies) + * in level order. Copy `i` repeats every attribute of LOD0 vertex `source[i]`, except its position, + * which is `positions[3i..3i+2]`. + */ +export interface ICardCopies { + readonly positions: Float32Array; + readonly source: Uint32Array; } /** A card-thinned chain, shaped like the triangle chain so both take the same attachment path. */ export interface ICardChain { readonly absoluteErrors: readonly number[]; + /** Per derived level, aligned with `counts`: the scaled copies its indices point at. */ + readonly copies: readonly ICardCopies[]; readonly counts: readonly number[]; readonly errors: readonly number[]; readonly indices: readonly Uint32Array[]; @@ -283,6 +300,7 @@ function thinLevel( cards: kept.length, cellCoverage: lod0Cells === 0 ? 1 : cells / lod0Cells, keep, + scale: 1, }, }; } @@ -303,6 +321,49 @@ function indicesOf(cards: readonly ICard[], source: Uint32Array): Uint32Array { return out; } +/** + * A level drawn from scaled copies of its cards: the copies, numbered from `base`, and the index + * buffer over them in {@link indicesOf}'s order. The scale restores LOD0's card area, capped at + * {@link MAX_CARD_SCALE}; a card's vertices belong to that card alone, so each is copied once. + */ +function scaledLevel( + level: { readonly cards: readonly ICard[]; readonly summary: ICardLevelSummary }, + indices: Uint32Array, + positions: Float32Array, + base: number, +): { copies: ICardCopies; indices: Uint32Array; summary: ICardLevelSummary } { + const covered = level.summary.areaCoverage; + const scale = covered > 0 ? Math.min(MAX_CARD_SCALE, Math.sqrt(1 / covered)) : 1; + const copyOf = new Map(); + const source: number[] = []; + const moved: number[] = []; + const out = indicesOf(level.cards, indices); + let at = 0; + for (const card of level.cards) { + const [cx, cy, cz] = card.centroid; + for (let corner = 0; corner < card.triangles.length * TRIANGLE; corner += 1, at += 1) { + const vertex = out[at] as number; + let copy = copyOf.get(vertex); + if (copy === undefined) { + copy = base + source.length; + copyOf.set(vertex, copy); + source.push(vertex); + moved.push( + cx + ((positions[vertex * 3] as number) - cx) * scale, + cy + ((positions[vertex * 3 + 1] as number) - cy) * scale, + cz + ((positions[vertex * 3 + 2] as number) - cz) * scale, + ); + } + out[at] = copy; + } + } + return { + copies: { positions: Float32Array.from(moved), source: Uint32Array.from(source) }, + indices: out, + summary: { ...level.summary, areaCoverage: Math.min(1, covered * scale * scale), scale }, + }; +} + /** Triangles a card submits (one entry in `triangles` per triangle). */ function cardTriangles(card: ICard): number { return card.triangles.length; @@ -377,6 +438,7 @@ function terminalCardLevel( cards: kept.length, cellCoverage: lod0Cells === 0 ? 1 : covered.size / lod0Cells, keep: kept.length / cards.length, + scale: 1, }, }; } @@ -439,6 +501,9 @@ export function generateCardChain( const errors: number[] = []; const absoluteErrors: number[] = []; const buffers: Uint32Array[] = []; + const copies: ICardCopies[] = []; + // Where the next level's copies start: after LOD0's vertices and every earlier level's copies. + let base = Math.floor(positions.length / 3); let previousTriangles = lod0Triangles; for (const keep of keepRatios.slice(0, Math.max(0, maxLevels - 1))) { if (levels.length >= Math.max(0, maxLevels - 1)) break; @@ -450,11 +515,14 @@ export function generateCardChain( if (triangles >= previousTriangles) continue; if ((previousTriangles - triangles) / previousTriangles < minSaving) continue; const absoluteError = cellSize * (1 - keep); - levels.push(level.summary); + const scaled = scaledLevel(level, indices, positions, base); + base += scaled.copies.source.length; + levels.push(scaled.summary); counts.push(triangles); absoluteErrors.push(absoluteError); errors.push(absoluteError / Math.max(scale, 1e-6)); - buffers.push(buffer); + buffers.push(scaled.indices); + copies.push(scaled.copies); previousTriangles = triangles; } if (levels.length === 0) return null; @@ -477,17 +545,21 @@ export function generateCardChain( const triangles = Math.floor(buffer.length / TRIANGLE); if (triangles > 0 && triangles < previousTriangles) { const absoluteError = cellSize * terminal.factor; - levels.push(terminal.summary); + const scaled = scaledLevel(terminal, indices, positions, base); + base += scaled.copies.source.length; + levels.push(scaled.summary); counts.push(triangles); absoluteErrors.push(absoluteError); errors.push(absoluteError / Math.max(scale, 1e-6)); - buffers.push(buffer); + buffers.push(scaled.indices); + copies.push(scaled.copies); previousTriangles = triangles; } } } return { absoluteErrors, + copies, counts, errors, indices: buffers, diff --git a/packages/assets/src/lod/eligibility.ts b/packages/assets/src/lod/eligibility.ts index 3cbf54077d..d9ff657510 100644 --- a/packages/assets/src/lod/eligibility.ts +++ b/packages/assets/src/lod/eligibility.ts @@ -99,7 +99,7 @@ const TRIANGLE = 3; export interface IEligibilityFlags { /** The primitive already carries a `TN_discrete_lod` payload from an earlier cook. */ readonly alreadyCooked: boolean; - /** A node or mesh name marks this as a hand-authored LOD variant (`hull_LOD1`). */ + /** The asset is an authored chain: two or more of its nodes carry a level name (`hull_LOD1`). */ readonly authoredLod: boolean; /** `assets.models.simplify` is declared for this asset. */ readonly legacySimplify: boolean; @@ -290,7 +290,8 @@ export function classifyJoinCandidate( /** * Authored LOD detection: a node or mesh whose name carries a level suffix (`hull_LOD1`, * `hull-lod2`) is a hand-authored variant, and generating on top of it would stack two owners on - * one primitive (PRD-377 §4.2). + * one primitive (PRD-377 §4.2). The cook counts it as authored only when the asset holds two or + * more level-named nodes; a lone one is that asset's LOD0. */ export function authoredLodName(name: string): boolean { return /(^|[_\-.\s])lod[0-9]+($|[_\-.\s])/i.test(name); diff --git a/packages/assets/src/lod/generate.ts b/packages/assets/src/lod/generate.ts index b5a2a484a4..523c69d80e 100644 --- a/packages/assets/src/lod/generate.ts +++ b/packages/assets/src/lod/generate.ts @@ -7,12 +7,14 @@ import { createHash } from "node:crypto"; import type { + Accessor, Document, Node as GltfNode, Material, Mesh, Primitive, Scene, + TypedArray, } from "@gltf-transform/core"; import { compactPrimitive, @@ -24,7 +26,7 @@ import { MeshoptSimplifier } from "meshoptimizer"; import { Matrix4 } from "three"; import { type IFoliageCutoutSummary, convertFoliageCutout } from "../foliage.js"; import { TN_VIRTUAL_GEOMETRY } from "../virtual/extension.js"; -import { type ICardLevelSummary, generateCardChain } from "./cards.js"; +import { type ICardCopies, type ICardLevelSummary, generateCardChain } from "./cards.js"; import { type DiscreteLodSkipReason, type LodMinTrianglesScope, @@ -126,7 +128,7 @@ export const LOD_GENERATOR = "threenative-discrete-lod"; * Bumped when the *generated levels* change, so a stale cook cache cannot serve the old chain. The * artifact schema stays put: a chain with or without the terminal level reads the same. */ -export const LOD_GENERATOR_VERSION = 3; +export const LOD_GENERATOR_VERSION = 4; /** Bumped with the output layout so a stale cache entry cannot hide a schema change. */ export const LOD_ARTIFACT_SCHEMA_VERSION = 1; /** The pinned simplifier this artifact's error metric was produced with. */ @@ -629,6 +631,8 @@ interface IGeneratedChain { readonly errors: number[]; readonly indices: Uint32Array[]; readonly lod0Triangles: number; + /** Scaled card copies the levels' indices point at, appended in level order; card chains only. */ + readonly vertexCopies?: readonly ICardCopies[]; } /** @@ -926,9 +930,45 @@ function generateCardsChain( errors: [...chain.errors], indices: [...chain.indices], lod0Triangles: chain.lod0Triangles, + vertexCopies: chain.copies, }; } +/** + * Appends a card chain's scaled copies to every attribute of `primitive`, in level order, so the + * levels' indices resolve. Each attribute gets a new accessor: another primitive sharing the old one + * keeps its own vertex count. LOD0's indices are untouched and never reach a copy. + */ +function appendCardCopies(primitive: Primitive, copies: readonly ICardCopies[]): void { + const base = primitive.getAttribute("POSITION")?.getCount() ?? 0; + let added = 0; + for (const level of copies) added += level.source.length; + if (added === 0) return; + for (const semantic of primitive.listSemantics()) { + const accessor = primitive.getAttribute(semantic) as Accessor; + const size = accessor.getElementSize(); + const array = accessor.getArray() as TypedArray; + const grown = new (array.constructor as new (length: number) => TypedArray)( + (base + added) * size, + ); + grown.set(array.subarray(0, base * size)); + let at = base; + for (const level of copies) + for (const vertex of level.source) { + grown.set(array.subarray(vertex * size, vertex * size + size), at * size); + at += 1; + } + const extended = accessor.clone().setArray(grown); + if (semantic === "POSITION") { + at = base; + for (const level of copies) + for (let copy = 0; copy < level.source.length; copy += 1, at += 1) + extended.setElement(at, Array.from(level.positions.subarray(copy * 3, copy * 3 + 3))); + } + primitive.setAttribute(semantic, extended); + } +} + function reachableNodes(document: Document): GltfNode[] { const visited = new Set(); const nodes: GltfNode[] = []; @@ -970,15 +1010,25 @@ function animatedNodes(document: Document): Set { function meshFlags(document: Document, animated: Set): Map { const flags = new Map(); + const levelNamed = new Set(); + let levelNodes = 0; for (const node of reachableNodes(document)) { const mesh = node.getMesh(); if (mesh === null) continue; const current = flags.get(mesh) ?? { animated: false, authoredLod: false, skinned: false }; current.skinned ||= node.getSkin() !== null; current.animated ||= animated.has(node); - current.authoredLod ||= authoredLodName(node.getName()) || authoredLodName(mesh.getName()); + if (authoredLodName(node.getName()) || authoredLodName(mesh.getName())) { + levelNamed.add(mesh); + levelNodes += 1; + } flags.set(mesh, current); } + // An authored chain is two or more level-named nodes in one asset. A lone level name (a pack's + // `_LOD2` exported as a model of its own) has no sibling for anything to select, so it is this + // asset's LOD0 and gets a generated chain like any other mesh. + if (levelNodes > 1) + for (const mesh of levelNamed) (flags.get(mesh) as IMeshFlags).authoredLod = true; return flags; } @@ -1414,6 +1464,7 @@ export async function generateDiscreteLod( trianglesAfter += before; continue; } + if (chain.vertexCopies !== undefined) appendCardCopies(primitive, chain.vertexCopies); extension ??= document.createExtension(TNDiscreteLod).setRequired(false); const property = attachDiscreteLod(document, extension, primitive, { ...chain, diff --git a/packages/assets/src/passes/model.ts b/packages/assets/src/passes/model.ts index 15bae8175b..c2aefb88d9 100644 --- a/packages/assets/src/passes/model.ts +++ b/packages/assets/src/passes/model.ts @@ -486,6 +486,28 @@ function multiplyMatrices(a: readonly number[], b: readonly number[]): number[] const IDENTITY_MATRIX = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +/** + * The vertices a primitive draws: all of them when it has no index buffer, otherwise the distinct + * ones its indices name. A card LOD chain appends scaled copies of its cards that only the levels' + * indices reach (PRD-539); LOD0 never draws them, so they are not drift. + */ +function drawnVertices(primitive: GltfPrimitive, count: number): Uint32Array { + const indices = primitive.getIndices()?.getArray(); + if (indices === null || indices === undefined) + return Uint32Array.from({ length: count }, (_, at) => at); + const seen = new Uint8Array(count); + let distinct = 0; + for (const index of indices) + if (index < count && seen[index] === 0) { + seen[index] = 1; + distinct += 1; + } + const out = new Uint32Array(distinct); + let at = 0; + for (let index = 0; index < count; index += 1) if (seen[index] === 1) out[at++] = index; + return out; +} + /** * Stats over scene-reachable content only; DCC leftovers dropped by prune are not drift. * The bounding box is taken over bind-pose vertex positions evaluated the way the GPU @@ -540,8 +562,9 @@ export function reachableStats(root: RootOf): IModelStats { triangles += primitiveTriangles(primitive); const position = primitive.getAttribute("POSITION"); if (position === null) continue; - vertices += position.getCount(); - for (let index = 0; index < position.getCount(); index += 1) { + const drawn = drawnVertices(primitive, position.getCount()); + vertices += drawn.length; + for (const index of drawn) { const [wx, wy, wz] = evaluateVertex( position, index, diff --git a/packages/core/__tests__/model-lod-loader.spec.ts b/packages/core/__tests__/model-lod-loader.spec.ts index b1b040f655..cb6c21b673 100644 --- a/packages/core/__tests__/model-lod-loader.spec.ts +++ b/packages/core/__tests__/model-lod-loader.spec.ts @@ -33,8 +33,9 @@ function gridIndices(triangles: number): number[] { * `TN_discrete_lod` extension on the primitive. Written by hand so the loader — not the asset * package the pipeline owns — is what this test exercises. */ -function cookedGlb(): Buffer { - const positions = new Float32Array((QUADS + 1) * 2 * 3); +function cookedGlb(options: { copies?: boolean } = {}): Buffer { + const lod0Vertices = (QUADS + 1) * 2; + let positions = new Float32Array(lod0Vertices * 3); for (let vertex = 0; vertex <= QUADS; vertex += 1) { positions[vertex * 6] = (vertex / QUADS) * 2 - 1; positions[vertex * 6 + 1] = -1; @@ -44,7 +45,19 @@ function cookedGlb(): Buffer { positions[vertex * 6 + 5] = 0; } const baseIndices = new Uint32Array(gridIndices(LOD0_TRIANGLES)); - const levelIndices = new Uint32Array(gridIndices(COARSE_TRIANGLES)); + let levelIndices = new Uint32Array(gridIndices(COARSE_TRIANGLES)); + if (options.copies === true) { + // The card cook's shape (PRD-539): the level draws copies of its vertices, scaled and appended + // after LOD0's, which LOD0's own indices never reach. + const source = [...new Set(levelIndices)]; + const grown = new Float32Array((lod0Vertices + source.length) * 3); + grown.set(positions); + for (const [copy, vertex] of source.entries()) + for (let axis = 0; axis < 3; axis += 1) + grown[(lod0Vertices + copy) * 3 + axis] = (positions[vertex * 3 + axis] as number) * 2; + positions = grown; + levelIndices = levelIndices.map((vertex) => lod0Vertices + source.indexOf(vertex)); + } const binPadded = padTo4( Buffer.concat([ Buffer.from(positions.buffer, positions.byteOffset, positions.byteLength), @@ -60,9 +73,9 @@ function cookedGlb(): Buffer { { bufferView: 0, componentType: 5126, - count: (QUADS + 1) * 2, - max: [1, 1, 0], - min: [-1, -1, 0], + count: positions.length / 3, + max: options.copies === true ? [2, 2, 0] : [1, 1, 0], + min: options.copies === true ? [-2, -2, 0] : [-1, -1, 0], type: "VEC3", }, { bufferView: 1, componentType: 5125, count: baseIndices.length, type: "SCALAR" }, @@ -186,6 +199,30 @@ describe("createAssetLoader with a cooked TN_discrete_lod model", () => { expect(baseGeometryOf(mesh)).toBe(base); }, 120_000); + it("draws a level whose indices reach vertex copies appended after LOD0's", async () => { + stubFetch(cookedGlb({ copies: true })); + const loader = createAssetLoader({ basePath: "" }); + const value = await loader.model<{ scene: Object3D }>("cards.glb"); + const mesh = firstMesh(value.scene); + const lod0Vertices = (QUADS + 1) * 2; + const scene = new Scene(); + scene.add(mesh); + const camera = new PerspectiveCamera(60, 1, 0.1, 1000); + camera.position.set(0, 0, 100); + camera.updateMatrixWorld(true); + expect(updateModelLods(scene, camera, 1080)).toBe(COARSE_TRIANGLES); + const level = Array.from(mesh.geometry.index?.array ?? []); + expect(level.length).toBe(COARSE_TRIANGLES * 3); + // Every corner the level draws is a copy, and the copy is in the shared position buffer. + for (const vertex of level) expect(vertex).toBeGreaterThanOrEqual(lod0Vertices); + const position = mesh.geometry.getAttribute("position"); + expect(position).toBe(baseGeometryOf(mesh).getAttribute("position")); + expect(Math.max(...level)).toBeLessThan(position.count); + // LOD0 never reaches a copy. + const lod0 = Array.from(baseGeometryOf(mesh).index?.array ?? []); + expect(Math.max(...lod0)).toBeLessThan(lod0Vertices); + }, 120_000); + it("shares the base's bounds with every level, so bounds padded after load reach them", async () => { // A vertex-displacement material makes a game pad the base's bounds after the model loads, so // the renderer does not cull the object the shader pushed out of shape. Every level shares the diff --git a/packages/core/__tests__/world-cells.spec.ts b/packages/core/__tests__/world-cells.spec.ts index 9e7c2583bd..ac65d06ffb 100644 --- a/packages/core/__tests__/world-cells.spec.ts +++ b/packages/core/__tests__/world-cells.spec.ts @@ -2033,7 +2033,7 @@ describe("WorldCells", () => { } }); - it("casts shadows from the near levels and receives them everywhere only when asked", async () => { + it("casts shadows from every level and receives them everywhere only when asked", async () => { stubFixtureFetch(); const follow = followAt(-64, -64); const plain = await loadWorld({ @@ -2062,9 +2062,10 @@ describe("WorldCells", () => { }); shaded.update(); await flushed(shaded); - // Only the finest level casts by default: a far LOD's shadow is sub-texel and every caster is - // redrawn per shadow level. The main mesh casts nothing — its records reach the shadow maps - // through the caster cluster, which is what a level culls (PRD-458). + // Every level casts by default: a shadow level draws only the clusters its own window covers, so + // a far level's caster costs nothing outside it, and inside it its shadow is visible (PRD-539). + // The main mesh casts nothing — its records reach the shadow maps through the caster cluster, + // which is what a level culls (PRD-458). expect((levelMesh(shaded, "pine", 0, 0) as InstancedMesh).castShadow).toBe(false); expect((levelMesh(shaded, "pine", 1, 0) as InstancedMesh).receiveShadow).toBe(true); const casters = clustersOf(shaded, "pine", 0, 0); @@ -2073,7 +2074,10 @@ describe("WorldCells", () => { expect(caster.castShadow, "the finest level's caster cluster does not cast").toBe(true); expect(caster.layers.mask, "a caster is not alone on the caster layer").toBe(1 << 28); } - expect(clustersOf(shaded, "pine", 1, 0), "a far level minted a caster cluster").toEqual([]); + expect( + clustersOf(shaded, "pine", 1, 0).length, + "a far level minted no caster cluster", + ).toBeGreaterThan(0); const terrain: Mesh[] = []; shaded.traverse((object) => { // A mesh alone on a caster layer is a shadow-only proxy: it casts, and by construction it @@ -2087,6 +2091,24 @@ describe("WorldCells", () => { for (const mesh of terrain) expect(mesh.receiveShadow).toBe(true); shaded.dispose(); + // A cap still holds: `castLevels: 1` keeps the far level out of every shadow map. + const capped = await loadWorld({ + budgets: largeBudgets, + follow, + loadModel: treeLoader().load, + ring: 0, + shadows: { cast: true, castLevels: 1 }, + surface, + url: "/world/world.json", + }); + capped.update(); + await flushed(capped); + expect(clustersOf(capped, "pine", 0, 0).length).toBeGreaterThan(0); + expect(clustersOf(capped, "pine", 1, 0), "a capped far level minted a caster cluster").toEqual( + [], + ); + capped.dispose(); + await expect( loadWorld({ budgets: largeBudgets, diff --git a/packages/core/__tests__/world-shared-batch-clusters.spec.ts b/packages/core/__tests__/world-shared-batch-clusters.spec.ts index db2d05d305..5f2b900db8 100644 --- a/packages/core/__tests__/world-shared-batch-clusters.spec.ts +++ b/packages/core/__tests__/world-shared-batch-clusters.spec.ts @@ -329,10 +329,9 @@ describe("WorldCells clustered shadow casters", () => { const { key } = squareOf(name); expect(mesh.layers.mask, `${name} is not alone on the caster layer`).toBe(1 << 28); expect(mesh.frustumCulled, `${name} is not cullable`).toBe(true); + // Every level casts by default; the window bounds which clusters a shadow level draws (PRD-539). const level = Number(key.split(":")[1]); - expect(mesh.castShadow, `${name} (level ${String(level)}) cast the wrong way`).toBe( - level === 0, - ); + expect(mesh.castShadow, `${name} (level ${String(level)}) does not cast`).toBe(true); expect(mesh.receiveShadow, `${name} receives, and no camera it draws with will`).toBe(false); } // Every castable key is split into more than one cluster, or nothing was clustered. diff --git a/packages/core/src/world-cells.ts b/packages/core/src/world-cells.ts index b45b5a2bf6..03f1e0a8ea 100644 --- a/packages/core/src/world-cells.ts +++ b/packages/core/src/world-cells.ts @@ -399,9 +399,11 @@ export interface IWorldCellsLoadOptions { readonly transparentScatter?: "cutout" | "blend"; /** * Shadows for the streamed world, all off by default. `cast` makes scattered batches cast into - * the scene's shadow map, but only their `castLevels` finest distance levels (default 1: the - * near shape), since a far LOD's shadow is sub-texel in any open-world shadow window and every - * caster is redrawn per shadow level. `receive` lets scatter and terrain receive shadows. + * the scene's shadow map, every distance level by default: a shadow level draws only the caster + * clusters its own window covers, so a far level costs nothing past the window, and inside it its + * shadow is visible — a chain whose first switch is tens of metres out lost its trees' shadows + * there under the old default of 1 (PRD-539). `castLevels` caps it to the finest N levels. + * `receive` lets scatter and terrain receive shadows. */ readonly shadows?: { readonly cast?: boolean; @@ -4108,7 +4110,9 @@ export class WorldCells extends Group implements IComputeDriven { this.#gpuTally = init.gpuSceneTally === true; this.#castShadowLevels = init.shadows?.cast === true - ? positiveInteger(init.shadows.castLevels ?? 1, "shadows.castLevels") + ? init.shadows.castLevels === undefined + ? Number.POSITIVE_INFINITY + : positiveInteger(init.shadows.castLevels, "shadows.castLevels") : 0; this.#receiveShadow = init.shadows?.receive === true; this.#smallCasterMetres = positiveMetres(