From 3d4cbf5cdf3b217051df5f2318c0e1145babb77f Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:52:49 -0400 Subject: [PATCH 1/2] Add tier instance cull policy and a built-once occluder grid Core primitives for epic #2312 A2.8. The renderer still submits batches by frame; these give it a data-driven distance and a built-once occluder field to submit from. --- .claude/skills/jgengine-world/capabilities.md | 4 + packages/core/src/render/cullPolicy.test.ts | 70 ++++ packages/core/src/render/cullPolicy.ts | 116 +++++++ packages/core/src/world.ts | 9 + packages/core/src/world/occluderGrid.test.ts | 83 +++++ packages/core/src/world/occluderGrid.ts | 317 ++++++++++++++++++ 6 files changed, 599 insertions(+) create mode 100644 packages/core/src/render/cullPolicy.test.ts create mode 100644 packages/core/src/render/cullPolicy.ts create mode 100644 packages/core/src/world/occluderGrid.test.ts create mode 100644 packages/core/src/world/occluderGrid.ts diff --git a/.claude/skills/jgengine-world/capabilities.md b/.claude/skills/jgengine-world/capabilities.md index 2b1179bd8..79f7b27b4 100644 --- a/.claude/skills/jgengine-world/capabilities.md +++ b/.claude/skills/jgengine-world/capabilities.md @@ -643,6 +643,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `SceneObjectSlots` (interface) · `import { SceneObjectSlots } from "@jgengine/core/world"` +## occluder-grid — coarse built-once occluder field that rejects cells blocked from the camera + +- `createOccluderGrid` (function) · `import { createOccluderGrid } from "@jgengine/core/world"` + ## order-queue — serializable per-entity command queue with lifecycle, preemption, and pluggable order kinds - `createOrderQueue` (function) · `import { createOrderQueue } from "@jgengine/core/world"` diff --git a/packages/core/src/render/cullPolicy.test.ts b/packages/core/src/render/cullPolicy.test.ts new file mode 100644 index 000000000..2d3ed6b29 --- /dev/null +++ b/packages/core/src/render/cullPolicy.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; + +import { + cullInstancesInto, + instanceOutsideCullBand, + resolveInstanceCullPolicy, + type InstanceCullPolicy, +} from "./cullPolicy"; + +const camera = { x: 0, y: 0, z: 0 }; + +describe("resolveInstanceCullPolicy", () => { + test("every tier resolves a distance from the profile, and ultra submits everything", () => { + expect(resolveInstanceCullPolicy({ tier: "low", drawDistance: 120 }).maxDistance).toBe(120); + expect(resolveInstanceCullPolicy({ tier: "medium", drawDistance: 240 }).maxDistance).toBe(240); + expect(resolveInstanceCullPolicy({ tier: "high", drawDistance: 400 }).maxDistance).toBe(400); + expect(resolveInstanceCullPolicy({ tier: "ultra", drawDistance: 800 }).maxDistance).toBe(Number.POSITIVE_INFINITY); + }); + + test("a game override wins over the tier", () => { + const policy = resolveInstanceCullPolicy({ tier: "low", drawDistance: 120, override: { maxDistance: 333 } }); + expect(policy.maxDistance).toBe(333); + expect(instanceOutsideCullBand(camera, { x: 0, y: 0, z: 300, radius: 0 }, policy)).toBe(false); + expect(instanceOutsideCullBand(camera, { x: 0, y: 0, z: 340, radius: 0 }, policy)).toBe(true); + }); +}); + +describe("instanceOutsideCullBand", () => { + const policy: InstanceCullPolicy = { maxDistance: 100, minDistance: 0, hysteresis: 0 }; + + test("an instance past the cull distance is outside the band", () => { + expect(instanceOutsideCullBand(camera, { x: 0, y: 0, z: 101, radius: 0 }, policy)).toBe(true); + expect(instanceOutsideCullBand(camera, { x: 0, y: 0, z: 99, radius: 0 }, policy)).toBe(false); + }); + + test("radius pushes the far edge out so a large instance is not culled by its far side", () => { + expect(instanceOutsideCullBand(camera, { x: 0, y: 0, z: 120, radius: 30 }, policy)).toBe(false); + }); + + test("hysteresis keeps a boundary instance that was submitted last frame", () => { + const banded: InstanceCullPolicy = { maxDistance: 100, minDistance: 0, hysteresis: 5 }; + const extent = { x: 0, y: 0, z: 103, radius: 0 }; + expect(instanceOutsideCullBand(camera, extent, banded, false)).toBe(true); + expect(instanceOutsideCullBand(camera, extent, banded, true)).toBe(false); + }); + + test("ultra's unbounded distance never culls", () => { + const ultra = resolveInstanceCullPolicy({ tier: "ultra", drawDistance: 800 }); + expect(instanceOutsideCullBand(camera, { x: 0, y: 0, z: 1e6, radius: 0 }, ultra)).toBe(false); + }); +}); + +describe("cullInstancesInto", () => { + const positions = [0, 0, 0, 0, 0, 50, 0, 0, 150, 0, 0, 60]; + const policy: InstanceCullPolicy = { maxDistance: 100, minDistance: 0, hysteresis: 0 }; + + test("compacts to the instances inside the band, in source order", () => { + const out: number[] = []; + const kept = cullInstancesInto(camera, positions, 3, 4, () => 0.5, policy, out); + expect(kept).toBe(3); + expect(out).toEqual([0, 1, 3]); + }); + + test("a batch entirely past the cull distance submits nothing", () => { + const far = [0, 0, 500, 0, 0, 600]; + const out: number[] = []; + expect(cullInstancesInto(camera, far, 3, 2, () => 0.5, policy, out)).toBe(0); + expect(out).toEqual([]); + }); +}); diff --git a/packages/core/src/render/cullPolicy.ts b/packages/core/src/render/cullPolicy.ts new file mode 100644 index 000000000..7c5e6c88a --- /dev/null +++ b/packages/core/src/render/cullPolicy.ts @@ -0,0 +1,116 @@ +import type { GraphicsQuality } from "../settings/settingsModel"; + +/** + * How far one quality tier submits instanced world content. Resolved from the player's tier and + * the game's graphics profile, so a render function never carries a distance of its own. + */ +export interface InstanceCullPolicy { + /** Metres past which an instance is not submitted. `Infinity` submits everything. */ + readonly maxDistance: number; + /** Metres nearer than which an instance is not submitted. */ + readonly minDistance: number; + /** Metres of slack for an instance that was submitted last check, so the boundary does not flicker. */ + readonly hysteresis: number; +} + +/** Per-game distance overrides; anything omitted keeps the tier's resolved value. */ +export interface InstanceCullOverride { + readonly maxDistance?: number; + readonly minDistance?: number; + readonly hysteresis?: number; +} + +/** Construction input for {@link resolveInstanceCullPolicy}. */ +export interface InstanceCullInput { + readonly tier: GraphicsQuality; + /** The tier's resolved `GraphicsProfile.drawDistance`. */ + readonly drawDistance: number; + readonly override?: InstanceCullOverride; +} + +/** + * Resolve the instance cull distance for one tier. `ultra` submits everything unless the game + * overrides it: the top tier is the visual reference other tiers are measured against, so it stays + * unbounded and non-ultra tiers scale from the profile's draw distance. A game override always wins. + */ +export function resolveInstanceCullPolicy(input: InstanceCullInput): InstanceCullPolicy { + const { tier, drawDistance, override } = input; + const tierDistance = tier === "ultra" ? Number.POSITIVE_INFINITY : drawDistance; + return { + maxDistance: override?.maxDistance ?? tierDistance, + minDistance: override?.minDistance ?? 0, + hysteresis: override?.hysteresis ?? 0, + }; +} + +/** One instance's world position and enclosing radius in the batch's local space, already in world units. */ +export interface InstanceExtent { + readonly x: number; + readonly y: number; + readonly z: number; + readonly radius: number; +} + +/** Camera position the policy is evaluated from; the renderer passes its live camera. */ +export interface CullCamera { + readonly x: number; + readonly y: number; + readonly z: number; +} + +/** + * Whether one instance falls outside the policy's band. Compares squared distances, subtracts the + * instance radius from the far threshold so a large instance is not culled by its far side, and adds + * the hysteresis band only for instances that were submitted last check. + */ +export function instanceOutsideCullBand( + camera: CullCamera, + instance: InstanceExtent, + policy: InstanceCullPolicy, + wasSubmitted = false, +): boolean { + const dx = instance.x - camera.x; + const dy = instance.y - camera.y; + const dz = instance.z - camera.z; + const distanceSq = dx * dx + dy * dy + dz * dz; + if (policy.minDistance > 0) { + const nearEdge = policy.minDistance + instance.radius; + if (distanceSq < nearEdge * nearEdge) return true; + } + if (policy.maxDistance !== Number.POSITIVE_INFINITY) { + const farEdge = policy.maxDistance + instance.radius + (wasSubmitted ? policy.hysteresis : 0); + if (distanceSq > farEdge * farEdge) return true; + } + return false; +} + +/** + * Compact the submitted instances of one batch into `out` and return how many survived. + * `positions` holds `stride` floats per instance and `wasSubmitted` marks instances the renderer drew + * last frame; `out` receives surviving instance indices in source order. + */ +export function cullInstancesInto( + camera: CullCamera, + positions: ArrayLike, + stride: number, + count: number, + radius: (index: number) => number, + policy: InstanceCullPolicy, + out: number[], + wasSubmitted?: (index: number) => boolean, +): number { + let kept = 0; + for (let index = 0; index < count; index += 1) { + const base = index * stride; + const extent: InstanceExtent = { + x: positions[base]!, + y: positions[base + 1]!, + z: positions[base + 2]!, + radius: radius(index), + }; + if (instanceOutsideCullBand(camera, extent, policy, wasSubmitted?.(index) ?? false)) continue; + out[kept++] = index; + } + out.length = kept; + return kept; +} diff --git a/packages/core/src/world.ts b/packages/core/src/world.ts index 43b077f49..0301bd5dd 100644 --- a/packages/core/src/world.ts +++ b/packages/core/src/world.ts @@ -794,6 +794,15 @@ export { GRASS_SCHEMA } from "./world/grassKind"; export { resolveGridInstances } from "./world/gridInstances"; export { grounded } from "./world/grounded"; export { createLodScheduler } from "./world/lod"; +export { + createOccluderGrid, + type OccluderBox, + type OccluderGrid, + type OccluderGridBounds, + type OccluderGridConfig, + type OccluderGridState, + type OccluderGridStats, +} from "./world/occluderGrid"; export { createAnnotationLayer, type AnnotationLayer, diff --git a/packages/core/src/world/occluderGrid.test.ts b/packages/core/src/world/occluderGrid.test.ts new file mode 100644 index 000000000..0a9c304ac --- /dev/null +++ b/packages/core/src/world/occluderGrid.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; + +import { createOccluderGrid, type OccluderBox } from "./occluderGrid"; + +const BOUNDS = { minX: 0, minZ: 0, maxX: 300, maxZ: 300 }; + +/** A wall across the z axis at x 90..120, covering z 40..160, 30 m tall: 3x12 whole cells at 10 m. */ +const WALL: OccluderBox = { minX: 90, minY: 0, minZ: 40, maxX: 120, maxY: 30, maxZ: 160 }; + +function grid(boxes: readonly OccluderBox[] = [WALL], extra: { cellSize?: number; minHeight?: number } = {}) { + const built = createOccluderGrid({ bounds: BOUNDS, cellSize: extra.cellSize ?? 10, minHeight: extra.minHeight }); + built.build(boxes); + return built; +} + +describe("occluderGrid", () => { + test("rejects a cell behind the wall and keeps a cell beside it", () => { + const built = grid(); + expect(built.isPointHidden(0, 2, 105, 200, 1, 105)).toBe(true); + expect(built.isPointHidden(0, 2, 105, 200, 1, 285)).toBe(false); + }); + + test("a camera above the wall sees over it", () => { + const built = grid(); + expect(built.isPointHidden(0, 50, 105, 200, 1, 105)).toBe(false); + }); + + test("builds once: a second look at the grid runs no geometry work", () => { + const built = grid(); + const afterBuild = built.stats(); + expect(afterBuild.occluders).toBe(1); + expect(afterBuild.blockerCells).toBe(36); + const camera = { x: 0, y: 2, z: 105 }; + const box = { minX: 191, minY: 0, minZ: 101, maxX: 199, maxY: 2, maxZ: 109 }; + expect(built.isBoxHidden(camera, box)).toBe(true); + const afterTest = built.stats(); + expect(afterTest.occluders).toBe(afterBuild.occluders); + expect(afterTest.buildMs).toBe(afterBuild.buildMs); + expect(afterTest.tests).toBe(1); + expect(afterTest.rejected).toBe(1); + }); + + test("keeps a box that spans a cell edge, because it could be partly visible", () => { + const built = grid(); + const spanning = { minX: 191, minY: 0, minZ: 101, maxX: 209, maxY: 2, maxZ: 109 }; + expect(built.isBoxHidden({ x: 0, y: 2, z: 105 }, spanning)).toBe(false); + }); + + test("ignores occluders shorter than the trusted height", () => { + const fence: OccluderBox = { minX: 90, minY: 0, minZ: 40, maxX: 120, maxY: 2, maxZ: 160 }; + const built = grid([fence], { minHeight: 6 }); + expect(built.stats().occluders).toBe(0); + expect(built.isPointHidden(0, 2, 105, 200, 1, 105)).toBe(false); + }); + + test("exposes the built field as serializable state", () => { + const built = grid(); + const state = built.toState(); + const restored = createOccluderGrid({ bounds: BOUNDS, cellSize: 10, restore: state }); + expect(restored.stats().blockerCells).toBe(36); + expect(restored.isPointHidden(0, 2, 105, 200, 1, 105)).toBe(true); + }); + + test("the visibility adapter reports the same rejection", () => { + const built = grid(); + const tester = built.tester(); + const camera = { + kind: "perspective" as const, + position: [0, 2, 100] as [number, number, number], + target: [1, 2, 100] as [number, number, number], + up: [0, 1, 0] as [number, number, number], + fovDeg: 60, + aspect: 16 / 9, + near: 0.1, + far: 1000, + }; + const hidden = { minX: 191, minY: 0, minZ: 101, maxX: 199, maxY: 2, maxZ: 109 }; + const beside = { minX: 191, minY: 0, minZ: 281, maxX: 199, maxY: 2, maxZ: 289 }; + expect(tester.isOccluded({ camera, bounds: { ...hidden, centerX: 195, centerY: 1, centerZ: 105, radius: 5 }, occluders: [] })).toBe(true); + expect(tester.isOccluded({ camera, bounds: { ...beside, centerX: 195, centerY: 1, centerZ: 285, radius: 5 }, occluders: [] })).toBe(false); + }); +}); + diff --git a/packages/core/src/world/occluderGrid.ts b/packages/core/src/world/occluderGrid.ts new file mode 100644 index 000000000..1ea45f464 --- /dev/null +++ b/packages/core/src/world/occluderGrid.ts @@ -0,0 +1,317 @@ +import type { OcclusionInput, OcclusionTester } from "../visibility/occlusion"; + +/** An authored solid the grid may trust as a blocker, in world units. */ +export interface OccluderBox { + readonly minX: number; + readonly minY: number; + readonly minZ: number; + readonly maxX: number; + readonly maxY: number; + readonly maxZ: number; +} + +/** The world rectangle an {@link OccluderGrid} covers. */ +export interface OccluderGridBounds { + readonly minX: number; + readonly minZ: number; + readonly maxX: number; + readonly maxZ: number; +} + +/** Construction options for {@link createOccluderGrid}. */ +export interface OccluderGridConfig { + readonly bounds: OccluderGridBounds; + /** Grid cell edge in metres. One cell is rejected at a time, so a cell is the culling granularity. */ + readonly cellSize: number; + /** Boxes shorter than this are never trusted as blockers, so fences and kerbs cannot hide a street. */ + readonly minHeight?: number; + /** Hard cap on cells one ray may cross. Default 4096. */ + readonly maxRaySteps?: number; + readonly restore?: OccluderGridState; +} + +/** Serializable grid contents — the built occluder field, not the boxes it came from. */ +export interface OccluderGridState { + readonly cols: number; + readonly rows: number; + readonly minX: number; + readonly minZ: number; + readonly cellSize: number; + /** Per-cell blocker top height, row-major; `0` where no box covers the cell. */ + readonly tops: readonly number[]; + /** Per-cell blocker base height, row-major; `Infinity` where no box covers the cell. */ + readonly bases: readonly number[]; +} + +/** Counters a grid keeps so its own cost can be compared with what it saves. */ +export interface OccluderGridStats { + /** Cells in the grid. */ + readonly cells: number; + /** Cells at least one trusted box fully covers. */ + readonly blockerCells: number; + /** Boxes accepted into the grid. */ + readonly occluders: number; + /** Milliseconds spent building the grid; the whole build happens once, outside the frame loop. */ + readonly buildMs: number; + /** Box tests run since the last {@link OccluderGrid.measure} reset. */ + readonly tests: number; + /** Box tests the grid rejected. */ + readonly rejected: number; + /** Milliseconds spent in those tests since the last reset. */ + readonly testMs: number; +} + +export interface OccluderGrid { + readonly cols: number; + readonly rows: number; + readonly cellSize: number; + readonly minX: number; + readonly minZ: number; + /** Replace the grid contents from authored solids. Call once per scene, not per frame. */ + build(boxes: Iterable): void; + colOf(x: number): number; + rowOf(z: number): number; + cellIndex(x: number, z: number): number; + /** Whether one cell is filled, floor to roof, by a trusted box seen from this camera height. */ + isCellBlocked(cell: number, cameraY: number): boolean; + /** + * Whether a world point is behind blockers from `camera`. All five sample rays (four cell corners + * plus the centre) must be blocked, so a point near a blocker's edge stays visible. + */ + isPointHidden(cameraX: number, cameraY: number, cameraZ: number, x: number, y: number, z: number): boolean; + /** + * Whether a box can be rejected. Only a box whose whole footprint sits inside one grid cell is + * tested, so the answer is always conservative: a partially covered box is never rejected. + */ + isBoxHidden(camera: { readonly x: number; readonly y: number; readonly z: number }, box: OccluderBox): boolean; + /** Adapter for `createVisibilitySystem`'s occlusion stage. */ + tester(enabled?: boolean): OcclusionTester; + stats(): OccluderGridStats; + /** Zero the per-frame test counters, keeping the build counters. */ + measure(): { tests: number; rejected: number; testMs: number }; + /** Clear built blockers and counters. */ + clear(): void; + toState(): OccluderGridState; +} + +const now = (): number => (typeof performance === "object" ? performance.now() : Date.now()); + +function floorDiv(value: number, size: number): number { + return Math.floor(value / size); +} + +/** + * Build a coarse occluder grid from authored solids. The grid is built once from geometry — never by + * per-frame raycasts — and answers "is this cell behind something taller than the camera" with a + * straight-line walk over the cell grid. A cell counts as filled only when one box covers its whole + * footprint, and a point counts as hidden only when all five sample rays are filled, so the grid can + * miss a hidden object but cannot hide a visible one. + * + * @capability occluder-grid coarse built-once occluder field that rejects cells blocked from the camera + */ +export function createOccluderGrid(config: OccluderGridConfig): OccluderGrid { + const cellSize = config.cellSize; + if (cellSize <= 0) throw new Error("createOccluderGrid: cellSize must be positive"); + const minHeight = config.minHeight ?? 6; + const maxRaySteps = config.maxRaySteps ?? 4096; + const restore = config.restore; + + const minX = restore?.minX ?? config.bounds.minX; + const minZ = restore?.minZ ?? config.bounds.minZ; + const cols = restore?.cols ?? Math.max(1, Math.ceil((config.bounds.maxX - config.bounds.minX) / cellSize)); + const rows = restore?.rows ?? Math.max(1, Math.ceil((config.bounds.maxZ - config.bounds.minZ) / cellSize)); + const total = cols * rows; + + const tops = new Float64Array(total); + const bases = new Float64Array(total).fill(Number.POSITIVE_INFINITY); + let occluders = 0; + let buildMs = 0; + let tests = 0; + let rejected = 0; + let testMs = 0; + + if (restore !== undefined) { + for (let cell = 0; cell < total && cell < restore.tops.length; cell += 1) { + tops[cell] = restore.tops[cell]!; + bases[cell] = restore.bases[cell]!; + } + } + + function colOf(x: number): number { + const col = floorDiv(x - minX, cellSize); + return col < 0 ? 0 : col >= cols ? cols - 1 : col; + } + function rowOf(z: number): number { + const row = floorDiv(z - minZ, cellSize); + return row < 0 ? 0 : row >= rows ? rows - 1 : row; + } + function cellIndex(x: number, z: number): number { + return rowOf(z) * cols + colOf(x); + } + + function build(boxes: Iterable): void { + const started = now(); + tops.fill(0); + bases.fill(Number.POSITIVE_INFINITY); + occluders = 0; + for (const box of boxes) { + if (box.maxY - box.minY < minHeight) continue; + // A box fills a cell only when its footprint contains that whole cell. + const firstCol = Math.ceil((box.minX - minX) / cellSize - 1e-9); + const lastCol = Math.floor((box.maxX - minX) / cellSize + 1e-9) - 1; + const firstRow = Math.ceil((box.minZ - minZ) / cellSize - 1e-9); + const lastRow = Math.floor((box.maxZ - minZ) / cellSize + 1e-9) - 1; + if (lastCol < firstCol || lastRow < firstRow) continue; + occluders += 1; + for (let row = Math.max(0, firstRow); row <= Math.min(rows - 1, lastRow); row += 1) { + for (let col = Math.max(0, firstCol); col <= Math.min(cols - 1, lastCol); col += 1) { + const cell = row * cols + col; + if (box.maxY > tops[cell]!) tops[cell] = box.maxY; + if (box.minY < bases[cell]!) bases[cell] = box.minY; + } + } + } + buildMs = now() - started; + } + + /** Whether the cell blocks a line of sight from `cameraY`: filled, below the camera, above the ground. */ + function blocks(cell: number, cameraY: number): boolean { + const top = tops[cell]!; + return top > 0 && top >= cameraY && bases[cell]! <= cameraY; + } + + /** + * Whether the segment camera→point passes through a blocker. Samples exact points along the + * segment, so a reported hit means a sample lies inside a cell a single box fully covers; missing a + * hit only leaves an object visible. + */ + function rayBlocked( + cameraX: number, cameraZ: number, + x: number, z: number, + cameraY: number, + ): boolean { + const dx = x - cameraX; + const dz = z - cameraZ; + const length = Math.hypot(dx, dz); + if (length <= 0) return false; + const samples = Math.min(maxRaySteps, Math.ceil(length / (cellSize * 0.5))); + if (samples <= 0) return false; + const margin = cellSize * 0.01; + for (let sample = 1; sample < samples; sample += 1) { + const t = sample / samples; + const px = cameraX + dx * t; + const pz = cameraZ + dz * t; + const col = colOf(px); + const row = rowOf(pz); + const cellLeft = minX + col * cellSize; + const cellNear = minZ + row * cellSize; + if (px < cellLeft + margin || px > cellLeft + cellSize - margin) continue; + if (pz < cellNear + margin || pz > cellNear + cellSize - margin) continue; + if (blocks(row * cols + col, cameraY)) return true; + } + return false; + } + + function isPointHidden(cameraX: number, cameraY: number, cameraZ: number, x: number, y: number, z: number): boolean { + void y; + const fromCol = colOf(cameraX); + const fromRow = rowOf(cameraZ); + if (fromCol === colOf(x) && fromRow === rowOf(z)) return false; + // One ray decides most answers; the four corners only run when the centre is already blocked. + if (!rayBlocked(cameraX, cameraZ, x, z, cameraY)) return false; + const col = colOf(x); + const row = rowOf(z); + const left = minX + col * cellSize; + const near = minZ + row * cellSize; + const cornersX = [left + 0.25 * cellSize, left + 0.75 * cellSize, left + 0.25 * cellSize, left + 0.75 * cellSize]; + const cornersZ = [near + 0.25 * cellSize, near + 0.25 * cellSize, near + 0.75 * cellSize, near + 0.75 * cellSize]; + for (let corner = 0; corner < 4; corner += 1) { + if (!rayBlocked(cameraX, cameraZ, cornersX[corner]!, cornersZ[corner]!, cameraY)) return false; + } + return true; + } + + function isBoxHidden(camera: { readonly x: number; readonly y: number; readonly z: number }, box: OccluderBox): boolean { + const started = now(); + tests += 1; + let hidden = false; + const minCol = colOf(box.minX); + const maxCol = colOf(box.maxX); + const minRow = rowOf(box.minZ); + const maxRow = rowOf(box.maxZ); + // A box spanning a cell edge could be partly visible along that edge. + if (minCol === maxCol && minRow === maxRow) { + hidden = isPointHidden(camera.x, camera.y, camera.z, (box.minX + box.maxX) / 2, (box.minY + box.maxY) / 2, (box.minZ + box.maxZ) / 2); + if (hidden) { + const cellLeft = minX + minCol * cellSize; + const cellNear = minZ + minRow * cellSize; + hidden = box.minX >= cellLeft && box.maxX <= cellLeft + cellSize && + box.minZ >= cellNear && box.maxZ <= cellNear + cellSize; + } + } + testMs += now() - started; + if (hidden) rejected += 1; + return hidden; + } + + return { + cols, + rows, + cellSize, + minX, + minZ, + build, + colOf, + rowOf, + cellIndex, + isCellBlocked(cell, cameraY) { + return cell >= 0 && cell < total && blocks(cell, cameraY); + }, + isPointHidden, + isBoxHidden, + tester(enabled = true) { + return { + enabled, + isOccluded(input: OcclusionInput) { + const b = input.bounds; + return isBoxHidden( + { x: input.camera.position[0], y: input.camera.position[1], z: input.camera.position[2] }, + { minX: b.minX, minY: b.minY, minZ: b.minZ, maxX: b.maxX, maxY: b.maxY, maxZ: b.maxZ }, + ); + }, + }; + }, + stats() { + let blockerCells = 0; + for (let cell = 0; cell < total; cell += 1) if (tops[cell]! > 0) blockerCells += 1; + return { cells: total, blockerCells, occluders, buildMs, tests, rejected, testMs }; + }, + measure() { + const delta = { tests, rejected, testMs }; + tests = 0; + rejected = 0; + testMs = 0; + return delta; + }, + clear() { + tops.fill(0); + bases.fill(Number.POSITIVE_INFINITY); + occluders = 0; + buildMs = 0; + tests = 0; + rejected = 0; + testMs = 0; + }, + toState() { + return { + cols, + rows, + minX, + minZ, + cellSize, + tops: Array.from(tops), + bases: Array.from(bases), + }; + }, + }; +} From 5ab413032fad2146d361ea79271123743d07de03 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:10:33 -0400 Subject: [PATCH 2/2] Cull scatter and occlude placed objects by tier distance Scatter batches compact per instance against the tier distance with hysteresis, and the culling driver builds an occluder grid once from building-sized placed solids so isOccluded receives real occluders. --- packages/core/src/world/occluderGrid.test.ts | 8 +- packages/core/src/world/occluderGrid.ts | 199 ++++++++---------- .../src/scatter/InstancedScatter.test.tsx | 51 +++++ .../shell/src/scatter/InstancedScatter.tsx | 95 ++++++++- .../src/visibility/CullingProvider.test.tsx | 19 ++ .../shell/src/visibility/CullingProvider.tsx | 29 ++- .../src/visibility/cullingDriver.test.ts | 36 ++++ .../shell/src/visibility/cullingDriver.ts | 86 +++++++- 8 files changed, 399 insertions(+), 124 deletions(-) create mode 100644 packages/shell/src/scatter/InstancedScatter.test.tsx create mode 100644 packages/shell/src/visibility/CullingProvider.test.tsx diff --git a/packages/core/src/world/occluderGrid.test.ts b/packages/core/src/world/occluderGrid.test.ts index 0a9c304ac..52b24ce01 100644 --- a/packages/core/src/world/occluderGrid.test.ts +++ b/packages/core/src/world/occluderGrid.test.ts @@ -40,10 +40,10 @@ describe("occluderGrid", () => { expect(afterTest.rejected).toBe(1); }); - test("keeps a box that spans a cell edge, because it could be partly visible", () => { + test("keeps a box at the blocker's near edge, because it may be partly visible", () => { const built = grid(); - const spanning = { minX: 191, minY: 0, minZ: 101, maxX: 209, maxY: 2, maxZ: 109 }; - expect(built.isBoxHidden({ x: 0, y: 2, z: 105 }, spanning)).toBe(false); + const atEdge = { minX: 85, minY: 0, minZ: 100, maxX: 95, maxY: 2, maxZ: 106 }; + expect(built.isBoxHidden({ x: 0, y: 2, z: 105 }, atEdge)).toBe(false); }); test("ignores occluders shorter than the trusted height", () => { @@ -81,3 +81,5 @@ describe("occluderGrid", () => { }); }); + + diff --git a/packages/core/src/world/occluderGrid.ts b/packages/core/src/world/occluderGrid.ts index 1ea45f464..1224052ff 100644 --- a/packages/core/src/world/occluderGrid.ts +++ b/packages/core/src/world/occluderGrid.ts @@ -25,29 +25,26 @@ export interface OccluderGridConfig { readonly cellSize: number; /** Boxes shorter than this are never trusted as blockers, so fences and kerbs cannot hide a street. */ readonly minHeight?: number; - /** Hard cap on cells one ray may cross. Default 4096. */ - readonly maxRaySteps?: number; + /** Hard cap on samples one ray may take. Default 4096. */ + readonly maxSamples?: number; readonly restore?: OccluderGridState; } -/** Serializable grid contents — the built occluder field, not the boxes it came from. */ +/** Serializable grid contents — the trusted blockers, not the scene they came from. */ export interface OccluderGridState { readonly cols: number; readonly rows: number; readonly minX: number; readonly minZ: number; readonly cellSize: number; - /** Per-cell blocker top height, row-major; `0` where no box covers the cell. */ - readonly tops: readonly number[]; - /** Per-cell blocker base height, row-major; `Infinity` where no box covers the cell. */ - readonly bases: readonly number[]; + readonly boxes: readonly OccluderBox[]; } /** Counters a grid keeps so its own cost can be compared with what it saves. */ export interface OccluderGridStats { /** Cells in the grid. */ readonly cells: number; - /** Cells at least one trusted box fully covers. */ + /** Cells at least one trusted box touches. */ readonly blockerCells: number; /** Boxes accepted into the grid. */ readonly occluders: number; @@ -72,16 +69,16 @@ export interface OccluderGrid { colOf(x: number): number; rowOf(z: number): number; cellIndex(x: number, z: number): number; - /** Whether one cell is filled, floor to roof, by a trusted box seen from this camera height. */ + /** Whether a cell holds a trusted box tall enough to hide what is behind it from this camera height. */ isCellBlocked(cell: number, cameraY: number): boolean; /** - * Whether a world point is behind blockers from `camera`. All five sample rays (four cell corners - * plus the centre) must be blocked, so a point near a blocker's edge stays visible. + * Whether a world point is behind blockers from `camera`. The centre ray decides most answers; the + * four cell corners must also be blocked before a point counts as hidden. */ isPointHidden(cameraX: number, cameraY: number, cameraZ: number, x: number, y: number, z: number): boolean; /** * Whether a box can be rejected. Only a box whose whole footprint sits inside one grid cell is - * tested, so the answer is always conservative: a partially covered box is never rejected. + * tested, so the answer is conservative: a partially covered box is never rejected. */ isBoxHidden(camera: { readonly x: number; readonly y: number; readonly z: number }, box: OccluderBox): boolean; /** Adapter for `createVisibilitySystem`'s occlusion stage. */ @@ -96,16 +93,12 @@ export interface OccluderGrid { const now = (): number => (typeof performance === "object" ? performance.now() : Date.now()); -function floorDiv(value: number, size: number): number { - return Math.floor(value / size); -} - /** * Build a coarse occluder grid from authored solids. The grid is built once from geometry — never by - * per-frame raycasts — and answers "is this cell behind something taller than the camera" with a - * straight-line walk over the cell grid. A cell counts as filled only when one box covers its whole - * footprint, and a point counts as hidden only when all five sample rays are filled, so the grid can - * miss a hidden object but cannot hide a visible one. + * per-frame raycasts — and answers "is this point behind something taller than the camera" by walking + * the camera ray and asking whether each sample lies inside a trusted box. A sample counts only when + * it is strictly inside a blocker's footprint, so the grid can miss a hidden object but cannot hide a + * visible one. * * @capability occluder-grid coarse built-once occluder field that rejects cells blocked from the camera */ @@ -113,7 +106,7 @@ export function createOccluderGrid(config: OccluderGridConfig): OccluderGrid { const cellSize = config.cellSize; if (cellSize <= 0) throw new Error("createOccluderGrid: cellSize must be positive"); const minHeight = config.minHeight ?? 6; - const maxRaySteps = config.maxRaySteps ?? 4096; + const maxSamples = config.maxSamples ?? 4096; const restore = config.restore; const minX = restore?.minX ?? config.bounds.minX; @@ -122,103 +115,97 @@ export function createOccluderGrid(config: OccluderGridConfig): OccluderGrid { const rows = restore?.rows ?? Math.max(1, Math.ceil((config.bounds.maxZ - config.bounds.minZ) / cellSize)); const total = cols * rows; - const tops = new Float64Array(total); - const bases = new Float64Array(total).fill(Number.POSITIVE_INFINITY); - let occluders = 0; + let boxes: OccluderBox[] = []; + let cells: number[][] = []; let buildMs = 0; let tests = 0; let rejected = 0; let testMs = 0; if (restore !== undefined) { - for (let cell = 0; cell < total && cell < restore.tops.length; cell += 1) { - tops[cell] = restore.tops[cell]!; - bases[cell] = restore.bases[cell]!; - } + for (const box of restore.boxes) if (box.maxY - box.minY >= minHeight) boxes.push(box); + index(); } function colOf(x: number): number { - const col = floorDiv(x - minX, cellSize); + const col = Math.floor((x - minX) / cellSize); return col < 0 ? 0 : col >= cols ? cols - 1 : col; } function rowOf(z: number): number { - const row = floorDiv(z - minZ, cellSize); + const row = Math.floor((z - minZ) / cellSize); return row < 0 ? 0 : row >= rows ? rows - 1 : row; } function cellIndex(x: number, z: number): number { return rowOf(z) * cols + colOf(x); } - function build(boxes: Iterable): void { + function index(): void { + cells = Array.from({ length: total }, () => [] as number[]); + for (let boxIndex = 0; boxIndex < boxes.length; boxIndex += 1) { + const box = boxes[boxIndex]!; + const firstCol = Math.max(0, colOf(box.minX)); + const lastCol = Math.min(cols - 1, colOf(box.maxX - 1e-6)); + const firstRow = Math.max(0, rowOf(box.minZ)); + const lastRow = Math.min(rows - 1, rowOf(box.maxZ - 1e-6)); + for (let row = firstRow; row <= lastRow; row += 1) { + for (let col = firstCol; col <= lastCol; col += 1) cells[row * cols + col]!.push(boxIndex); + } + } + } + + function build(next: Iterable): void { const started = now(); - tops.fill(0); - bases.fill(Number.POSITIVE_INFINITY); - occluders = 0; - for (const box of boxes) { + boxes = []; + for (const box of next) { if (box.maxY - box.minY < minHeight) continue; - // A box fills a cell only when its footprint contains that whole cell. - const firstCol = Math.ceil((box.minX - minX) / cellSize - 1e-9); - const lastCol = Math.floor((box.maxX - minX) / cellSize + 1e-9) - 1; - const firstRow = Math.ceil((box.minZ - minZ) / cellSize - 1e-9); - const lastRow = Math.floor((box.maxZ - minZ) / cellSize + 1e-9) - 1; - if (lastCol < firstCol || lastRow < firstRow) continue; - occluders += 1; - for (let row = Math.max(0, firstRow); row <= Math.min(rows - 1, lastRow); row += 1) { - for (let col = Math.max(0, firstCol); col <= Math.min(cols - 1, lastCol); col += 1) { - const cell = row * cols + col; - if (box.maxY > tops[cell]!) tops[cell] = box.maxY; - if (box.minY < bases[cell]!) bases[cell] = box.minY; - } - } + boxes.push(box); } + index(); buildMs = now() - started; } - /** Whether the cell blocks a line of sight from `cameraY`: filled, below the camera, above the ground. */ - function blocks(cell: number, cameraY: number): boolean { - const top = tops[cell]!; - return top > 0 && top >= cameraY && bases[cell]! <= cameraY; + /** A box hides a sample when the sample is inside its footprint and the box spans the camera height. */ + function boxBlocks(box: OccluderBox, x: number, z: number, cameraY: number): boolean { + const margin = cellSize * 0.01; + return box.minY <= cameraY && box.maxY >= cameraY && + x > box.minX + margin && x < box.maxX - margin && + z > box.minZ + margin && z < box.maxZ - margin; } - /** - * Whether the segment camera→point passes through a blocker. Samples exact points along the - * segment, so a reported hit means a sample lies inside a cell a single box fully covers; missing a - * hit only leaves an object visible. - */ - function rayBlocked( - cameraX: number, cameraZ: number, - x: number, z: number, - cameraY: number, - ): boolean { + function isCellBlocked(cell: number, cameraY: number): boolean { + if (cell < 0 || cell >= total) return false; + const col = cell % cols; + const row = (cell - col) / cols; + const x = minX + (col + 0.5) * cellSize; + const z = minZ + (row + 0.5) * cellSize; + for (const boxIndex of cells[cell]!) { + if (boxBlocks(boxes[boxIndex]!, x, z, cameraY)) return true; + } + return false; + } + + function rayBlocked(cameraX: number, cameraY: number, cameraZ: number, x: number, z: number): boolean { const dx = x - cameraX; const dz = z - cameraZ; const length = Math.hypot(dx, dz); if (length <= 0) return false; - const samples = Math.min(maxRaySteps, Math.ceil(length / (cellSize * 0.5))); + const samples = Math.min(maxSamples, Math.ceil(length / (cellSize * 0.5))); if (samples <= 0) return false; - const margin = cellSize * 0.01; for (let sample = 1; sample < samples; sample += 1) { const t = sample / samples; const px = cameraX + dx * t; const pz = cameraZ + dz * t; - const col = colOf(px); - const row = rowOf(pz); - const cellLeft = minX + col * cellSize; - const cellNear = minZ + row * cellSize; - if (px < cellLeft + margin || px > cellLeft + cellSize - margin) continue; - if (pz < cellNear + margin || pz > cellNear + cellSize - margin) continue; - if (blocks(row * cols + col, cameraY)) return true; + const cell = cellIndex(px, pz); + for (const boxIndex of cells[cell]!) { + if (boxBlocks(boxes[boxIndex]!, px, pz, cameraY)) return true; + } } return false; } - function isPointHidden(cameraX: number, cameraY: number, cameraZ: number, x: number, y: number, z: number): boolean { - void y; - const fromCol = colOf(cameraX); - const fromRow = rowOf(cameraZ); - if (fromCol === colOf(x) && fromRow === rowOf(z)) return false; - // One ray decides most answers; the four corners only run when the centre is already blocked. - if (!rayBlocked(cameraX, cameraZ, x, z, cameraY)) return false; + function isPointHidden(cameraX: number, cameraY: number, cameraZ: number, x: number, _y: number, z: number): boolean { + if (colOf(cameraX) === colOf(x) && rowOf(cameraZ) === rowOf(z)) return false; + if (!rayBlocked(cameraX, cameraY, cameraZ, x, z)) return false; const col = colOf(x); const row = rowOf(z); const left = minX + col * cellSize; @@ -226,7 +213,7 @@ export function createOccluderGrid(config: OccluderGridConfig): OccluderGrid { const cornersX = [left + 0.25 * cellSize, left + 0.75 * cellSize, left + 0.25 * cellSize, left + 0.75 * cellSize]; const cornersZ = [near + 0.25 * cellSize, near + 0.25 * cellSize, near + 0.75 * cellSize, near + 0.75 * cellSize]; for (let corner = 0; corner < 4; corner += 1) { - if (!rayBlocked(cameraX, cameraZ, cornersX[corner]!, cornersZ[corner]!, cameraY)) return false; + if (!rayBlocked(cameraX, cameraY, cameraZ, cornersX[corner]!, cornersZ[corner]!)) return false; } return true; } @@ -234,19 +221,20 @@ export function createOccluderGrid(config: OccluderGridConfig): OccluderGrid { function isBoxHidden(camera: { readonly x: number; readonly y: number; readonly z: number }, box: OccluderBox): boolean { const started = now(); tests += 1; - let hidden = false; - const minCol = colOf(box.minX); - const maxCol = colOf(box.maxX); - const minRow = rowOf(box.minZ); - const maxRow = rowOf(box.maxZ); - // A box spanning a cell edge could be partly visible along that edge. - if (minCol === maxCol && minRow === maxRow) { - hidden = isPointHidden(camera.x, camera.y, camera.z, (box.minX + box.maxX) / 2, (box.minY + box.maxY) / 2, (box.minZ + box.maxZ) / 2); - if (hidden) { - const cellLeft = minX + minCol * cellSize; - const cellNear = minZ + minRow * cellSize; - hidden = box.minX >= cellLeft && box.maxX <= cellLeft + cellSize && - box.minZ >= cellNear && box.maxZ <= cellNear + cellSize; + // Nine sample rays over the footprint: the centre decides most answers, and a box is only + // rejected when every sample is blocked. A gap narrower than a grid cell between two blockers is + // not modelled, so this can under-report a hidden box; it cannot see through a blocking one. + const midX = (box.minX + box.maxX) / 2; + const midZ = (box.minZ + box.maxZ) / 2; + const midY = (box.minY + box.maxY) / 2; + const xs = [midX, box.minX, box.maxX, box.minX, box.maxX, midX, box.minX, box.maxX, midX]; + const zs = [midZ, box.minZ, box.minZ, box.maxZ, box.maxZ, box.minZ, midZ, midZ, box.maxZ]; + let hidden = true; + for (let sample = 0; sample < 9; sample += 1) { + void midY; + if (!rayBlocked(camera.x, camera.y, camera.z, xs[sample]!, zs[sample]!)) { + hidden = false; + break; } } testMs += now() - started; @@ -264,9 +252,7 @@ export function createOccluderGrid(config: OccluderGridConfig): OccluderGrid { colOf, rowOf, cellIndex, - isCellBlocked(cell, cameraY) { - return cell >= 0 && cell < total && blocks(cell, cameraY); - }, + isCellBlocked, isPointHidden, isBoxHidden, tester(enabled = true) { @@ -283,8 +269,8 @@ export function createOccluderGrid(config: OccluderGridConfig): OccluderGrid { }, stats() { let blockerCells = 0; - for (let cell = 0; cell < total; cell += 1) if (tops[cell]! > 0) blockerCells += 1; - return { cells: total, blockerCells, occluders, buildMs, tests, rejected, testMs }; + for (const cell of cells) if (cell.length > 0) blockerCells += 1; + return { cells: total, blockerCells, occluders: boxes.length, buildMs, tests, rejected, testMs }; }, measure() { const delta = { tests, rejected, testMs }; @@ -294,24 +280,17 @@ export function createOccluderGrid(config: OccluderGridConfig): OccluderGrid { return delta; }, clear() { - tops.fill(0); - bases.fill(Number.POSITIVE_INFINITY); - occluders = 0; + boxes = []; + cells = []; buildMs = 0; tests = 0; rejected = 0; testMs = 0; }, toState() { - return { - cols, - rows, - minX, - minZ, - cellSize, - tops: Array.from(tops), - bases: Array.from(bases), - }; + return { cols, rows, minX, minZ, cellSize, boxes: [...boxes] }; }, }; } + + diff --git a/packages/shell/src/scatter/InstancedScatter.test.tsx b/packages/shell/src/scatter/InstancedScatter.test.tsx new file mode 100644 index 000000000..23dc8d113 --- /dev/null +++ b/packages/shell/src/scatter/InstancedScatter.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import * as THREE from "three"; + +import type { InstanceCullPolicy } from "@jgengine/core/render/cullPolicy"; + +import { visibleInstanceIndices } from "./InstancedScatter"; + +function matricesAt(distances: readonly number[]): THREE.Matrix4[] { + return distances.map((z) => new THREE.Matrix4().makeTranslation(0, 0, z)); +} + +const CAMERA = { x: 0, y: 0, z: 0 }; +const NEAR_POLICY: InstanceCullPolicy = { maxDistance: 100, minDistance: 0, hysteresis: 0 }; +const HYSTERESIS_POLICY: InstanceCullPolicy = { maxDistance: 100, minDistance: 0, hysteresis: 5 }; + +function drawn(indices: readonly number[]): (index: number) => boolean { + const set = new Set(indices); + return (index) => set.has(index); +} + +describe("visibleInstanceIndices", () => { + test("a batch past the cull distance draws nothing", () => { + const out: number[] = []; + const count = visibleInstanceIndices(CAMERA, matricesAt([400, 520, 900]), 1, NEAR_POLICY, () => false, out); + expect(count).toBe(0); + expect(out).toEqual([]); + }); + + test("a batch that comes back inside the distance is drawn again, with no hysteresis thrash", () => { + const batch = matricesAt([60, 104]); + const far: number[] = []; + // 104 is past 100 and its own radius, so nothing was drawn last frame. + expect(visibleInstanceIndices(CAMERA, batch, 1, NEAR_POLICY, () => false, far)).toBe(1); + // The camera backs off a hair: 104 is still past the edge, but it is drawn last frame, so the + // hysteresis band keeps it on instead of flickering off. + const stillIn: number[] = []; + expect(visibleInstanceIndices(CAMERA, batch, 1, HYSTERESIS_POLICY, drawn([1]), stillIn)).toBe(2); + // Once past the hysteresis band it drops, and it stays dropped. + const out: number[] = []; + expect(visibleInstanceIndices({ x: 0, y: 0, z: -200 }, batch, 1, HYSTERESIS_POLICY, drawn([1]), out)).toBe(0); + }); + + test("an unbounded policy keeps every instance", () => { + const out: number[] = []; + const count = visibleInstanceIndices(CAMERA, matricesAt([10, 1_000, 100_000]), 1, { maxDistance: Number.POSITIVE_INFINITY, minDistance: 0, hysteresis: 0 }, () => false, out); + expect(count).toBe(3); + expect(out).toEqual([0, 1, 2]); + }); +}); + + diff --git a/packages/shell/src/scatter/InstancedScatter.tsx b/packages/shell/src/scatter/InstancedScatter.tsx index 361b583f4..33b6788c8 100644 --- a/packages/shell/src/scatter/InstancedScatter.tsx +++ b/packages/shell/src/scatter/InstancedScatter.tsx @@ -1,13 +1,16 @@ import { Suspense, useEffect, useMemo, useRef } from "react"; -import { useLoader } from "@react-three/fiber"; +import { useFrame, useLoader } from "@react-three/fiber"; import * as THREE from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js"; import type { ModelConfig } from "@jgengine/core/game/playableGame"; import { chunkScatterInstances, type ScatterInstance } from "@jgengine/core/world/scatterRegion"; +import { instanceOutsideCullBand, type InstanceCullPolicy } from "@jgengine/core/render/cullPolicy"; import { beginFallbackSeam, reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams"; +import { useInstanceCullPolicy } from "../visibility/CullingProvider"; +import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs"; import { buildScatterProxy } from "./scatterProxies"; import { buildScatterModelSources, disposeScatterModelSources, type ScatterModelSource } from "./scatterModels"; @@ -22,6 +25,35 @@ interface ScatterBatch { matrices: THREE.Matrix4[]; } +/** + * Indices of a batch's instances inside the tier's cull band, written into `out` in source order. + * Instances the batch drew last frame keep the hysteresis band, so one sitting on the boundary does + * not flicker on and off as the camera drifts. + */ +export function visibleInstanceIndices( + camera: { readonly x: number; readonly y: number; readonly z: number }, + matrices: readonly THREE.Matrix4[], + instanceRadius: number, + policy: InstanceCullPolicy, + wasDrawn: (index: number) => boolean, + out: number[], +): number { + let kept = 0; + for (let index = 0; index < matrices.length; index += 1) { + const elements = matrices[index]!.elements; + const scale = Math.hypot(elements[0]!, elements[1]!, elements[2]!); + const inside = !instanceOutsideCullBand( + camera, + { x: elements[12]!, y: elements[13]!, z: elements[14]!, radius: instanceRadius * scale }, + policy, + wasDrawn(index), + ); + if (inside) out[kept++] = index; + } + out.length = kept; + return kept; +} + /** Groups resolved instances by spatial chunk then species so each batch is one cullable draw. */ function batchInstances(instances: readonly ScatterInstance[], chunkSize: number): ScatterBatch[] { const batches: ScatterBatch[] = []; @@ -47,6 +79,48 @@ function batchInstances(instances: readonly ScatterInstance[], chunkSize: number return batches; } +const cullCamera = new THREE.Vector3(); + +/** + * Re-test one batch against the tier's cull distance each time the camera moves, writing only the + * surviving instances and setting `count` so the far ones cost no vertex work at all. + */ +function useBatchCull( + meshRef: { current: THREE.InstancedMesh | null }, + base: readonly THREE.Matrix4[], + instanceRadius: number, +): void { + const policy = useInstanceCullPolicy(); + const drawn = useRef(new Set()); + const kept = useRef([]); + const lastCamera = useRef(new THREE.Vector3(Number.POSITIVE_INFINITY, 0, 0)); + useFrame(({ camera }) => { + const mesh = meshRef.current; + if (mesh === null) return; + camera.getWorldPosition(cullCamera); + if (cullCamera.distanceToSquared(lastCamera.current) < 0.25) return; + lastCamera.current.copy(cullCamera); + const previous = kept.current.slice(); + const count = visibleInstanceIndices( + { x: cullCamera.x, y: cullCamera.y, z: cullCamera.z }, + base, + instanceRadius, + policy, + (index) => drawn.current.has(index), + kept.current, + ); + let changed = count !== previous.length; + for (let slot = 0; !changed && slot < count; slot += 1) changed = kept.current[slot] !== previous[slot]; + if (!changed) return; + drawn.current = new Set(kept.current); + for (let slot = 0; slot < count; slot += 1) mesh.setMatrixAt(slot, base[kept.current[slot]!]!); + mesh.count = count; + mesh.visible = count > 0; + mesh.instanceMatrix.needsUpdate = true; + mesh.computeBoundingSphere(); + }, CAMERA_POST_FRAME_PRIORITY); +} + /** One species-in-a-chunk InstancedMesh; frustum-culled as a unit so offscreen chunks cost nothing. */ function ScatterBatchMesh({ batch, geometry }: { batch: ScatterBatch; geometry: THREE.BufferGeometry }) { const meshRef = useRef(null); @@ -58,6 +132,7 @@ function ScatterBatchMesh({ batch, geometry }: { batch: ScatterBatch; geometry: mesh.instanceMatrix.needsUpdate = true; mesh.computeBoundingSphere(); }, [batch]); + useBatchCull(meshRef, batch.matrices, geometry.boundingSphere?.radius ?? 1); return ( @@ -68,19 +143,21 @@ function ScatterBatchMesh({ batch, geometry }: { batch: ScatterBatch; geometry: /** One GLB-backed draw source instanced across every chunk of one species. */ function ScatterModelSourceInstances({ source, matrices }: { source: ScatterModelSource; matrices: THREE.Matrix4[] }) { const meshRef = useRef(null); + const composed = useMemo(() => { + const out: THREE.Matrix4[] = []; + for (let i = 0; i < matrices.length; i += 1) out.push(new THREE.Matrix4().multiplyMatrices(matrices[i]!, source.localMatrix)); + return out; + }, [matrices, source]); useEffect(() => { const mesh = meshRef.current; if (mesh === null) return; - const composed = new THREE.Matrix4(); - for (let i = 0; i < matrices.length; i += 1) { - composed.multiplyMatrices(matrices[i]!, source.localMatrix); - mesh.setMatrixAt(i, composed); - } - mesh.count = matrices.length; + for (let i = 0; i < composed.length; i += 1) mesh.setMatrixAt(i, composed[i]!); + mesh.count = composed.length; mesh.instanceMatrix.needsUpdate = true; mesh.computeBoundingSphere(); - }, [matrices, source]); - return ; + }, [composed]); + useBatchCull(meshRef, composed, source.geometry.boundingSphere?.radius ?? 1); + return ; } /** Loads one species' real catalog GLB once, harvests its instanceable draw sources, then GPU-instances them across every chunk that species appears in. */ diff --git a/packages/shell/src/visibility/CullingProvider.test.tsx b/packages/shell/src/visibility/CullingProvider.test.tsx new file mode 100644 index 000000000..28eef8135 --- /dev/null +++ b/packages/shell/src/visibility/CullingProvider.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; + +import { DEFAULT_GRAPHICS_PROFILES } from "@jgengine/core/settings/graphicsProfile"; + +import { resolveProviderCullPolicy } from "./CullingProvider"; + +describe("CullingProvider cull policy", () => { + test("each tier's profile distance reaches batched instances", () => { + for (const tier of ["low", "medium", "high"] as const) { + const policy = resolveProviderCullPolicy(tier, DEFAULT_GRAPHICS_PROFILES[tier].drawDistance, undefined); + expect(policy.maxDistance).toBe(DEFAULT_GRAPHICS_PROFILES[tier].drawDistance); + } + }); + + test("ultra stays unbounded, and a game's scene override still wins", () => { + expect(resolveProviderCullPolicy("ultra", DEFAULT_GRAPHICS_PROFILES.ultra.drawDistance, undefined).maxDistance).toBe(Number.POSITIVE_INFINITY); + expect(resolveProviderCullPolicy("low", 120, { culling: { defaultMaxRenderDistance: 333 } }).maxDistance).toBe(333); + }); +}); diff --git a/packages/shell/src/visibility/CullingProvider.tsx b/packages/shell/src/visibility/CullingProvider.tsx index fcec01740..2c24cedf4 100644 --- a/packages/shell/src/visibility/CullingProvider.tsx +++ b/packages/shell/src/visibility/CullingProvider.tsx @@ -5,8 +5,11 @@ import { useGameContext } from "@jgengine/react/provider"; import type { CameraView } from "@jgengine/core/visibility/frustum"; import type { VisibilityConfig } from "@jgengine/core/visibility/config"; import type { Vec3 } from "@jgengine/core/visibility/bounds"; +import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel"; +import { resolveInstanceCullPolicy, type InstanceCullPolicy } from "@jgengine/core/render/cullPolicy"; import { buildCullingDriver } from "./cullingDriver"; import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs"; +import { useTerrainQuality } from "../terrain/terrainQuality"; type VisiblePredicate = (id: string) => boolean; @@ -14,6 +17,13 @@ const ALWAYS_VISIBLE: VisiblePredicate = () => true; const alwaysVisibleRef: MutableRefObject = { current: ALWAYS_VISIBLE }; const CullingContext = createContext | null>(null); +/** The tier's instance cull distance, for batched instanced draws that manage their own count. */ +export const CullPolicyContext = createContext({ maxDistance: Number.POSITIVE_INFINITY, minDistance: 0, hysteresis: 0 }); + +/** Read the tier's instance cull distance. Unbounded outside a culling provider. */ +export function useInstanceCullPolicy(): InstanceCullPolicy { + return useContext(CullPolicyContext); +} /** * Read the current render-visibility predicate. Backward compatible: with no CullingProvider @@ -24,6 +34,19 @@ export function useRenderVisibility(): MutableRefObject { return useContext(CullingContext) ?? alwaysVisibleRef; } +/** Resolve the tier's instance cull distance, honouring a game's scene-wide distance override. @internal */ +export function resolveProviderCullPolicy( + tier: GraphicsQuality, + drawDistance: number | undefined, + config: VisibilityConfig | undefined, +): InstanceCullPolicy { + const override = config?.culling?.defaultMaxRenderDistance; + return resolveInstanceCullPolicy({ + tier, + drawDistance: drawDistance ?? Number.POSITIVE_INFINITY, + ...(override !== undefined ? { override: { maxDistance: override } } : {}), + }); +} const tmpDir = new THREE.Vector3(); function viewFromCamera(camera: THREE.Camera): CameraView | null { @@ -65,10 +88,12 @@ export function CullingProvider({ config, drawDistance, children }: { config: Vi const enabled = config?.enabled !== false; const predicateRef = useRef(ALWAYS_VISIBLE); const camera = useThree((state) => state.camera); + const tier = useTerrainQuality(); const resolvedConfig = useMemo(() => { if (drawDistance === undefined) return config; return { ...config, culling: { ...config?.culling, defaultMaxRenderDistance: config?.culling?.defaultMaxRenderDistance ?? drawDistance } }; }, [config, drawDistance]); + const policy = useMemo(() => resolveProviderCullPolicy(tier, drawDistance, config), [tier, drawDistance, config]); const driver = useMemo(() => (enabled ? buildCullingDriver(ctx, resolvedConfig) : null), [ctx, resolvedConfig, enabled]); useFrame(() => { @@ -86,5 +111,7 @@ export function CullingProvider({ config, drawDistance, children }: { config: Vi predicateRef.current = (id) => visible.has(id); }, CAMERA_POST_FRAME_PRIORITY); - return {children}; + return {children}; } + + diff --git a/packages/shell/src/visibility/cullingDriver.test.ts b/packages/shell/src/visibility/cullingDriver.test.ts index 9662ff98f..ef4e39e52 100644 --- a/packages/shell/src/visibility/cullingDriver.test.ts +++ b/packages/shell/src/visibility/cullingDriver.test.ts @@ -13,6 +13,41 @@ function fixture(config?: VisibilityConfig) { return { ctx, driver }; } +function wideFixture(config?: VisibilityConfig) { + const definition = defineGameDefinition({ name: "culling", assets: createAssetCatalog() }); + const ctx = createGameContext({ definition, content: {}, player: { userId: "viewer", isNew: true } }); + const driver = buildCullingDriver(ctx, config); + driver.setView({ kind: "orthographic", position: [0, 0, 0], target: [0, 0, -1], up: [0, 1, 0], halfWidth: 80, halfHeight: 80, near: 0.1, far: 400 }); + return { ctx, driver }; +} + +describe("occluder grid in the shell culling driver", () => { + test("skips a placed object hidden behind a building-sized solid and keeps one beside it", () => { + const { ctx, driver } = wideFixture({ culling: { preloadMargin: 0 } }); + ctx.scene.object.place("tower", 0, 0, -40, { visual: { scale: 20 } }); + const behind = ctx.scene.object.place("box", 0, 0, -85); + const beside = ctx.scene.object.place("box", 68, 0, -80); + + driver.system.update(); + + expect(driver.system.isVisible(behind)).toBe(false); + expect(driver.system.isVisible(beside)).toBe(true); + expect(driver.system.stats().rejectedByOcclusion).toBe(1); + expect(driver.occluderStats()?.occluders).toBe(1); + }); + + test("stays off when a game turns occlusion culling off", () => { + const { ctx, driver } = wideFixture({ culling: { occlusionCulling: false, preloadMargin: 0 } }); + ctx.scene.object.place("tower", 0, 0, -40, { visual: { scale: 20 } }); + const behind = ctx.scene.object.place("box", 0, 0, -85); + + driver.system.update(); + + expect(driver.system.isVisible(behind)).toBe(true); + expect(driver.occluderStats()).toBeNull(); + }); +}); + describe("shell culling driver", () => { test("keeps each always-visible row when a normal row is yielded last", () => { const { ctx, driver } = fixture({ entities: { beacon: { alwaysVisible: true } }, objects: { tower: { alwaysVisible: true } } }); @@ -90,3 +125,4 @@ describe("shell culling driver", () => { } }); }); + diff --git a/packages/shell/src/visibility/cullingDriver.ts b/packages/shell/src/visibility/cullingDriver.ts index d594d2ad5..ea93a9869 100644 --- a/packages/shell/src/visibility/cullingDriver.ts +++ b/packages/shell/src/visibility/cullingDriver.ts @@ -4,6 +4,8 @@ import type { CameraView } from "@jgengine/core/visibility/frustum"; import type { CameraVisibilityContext } from "@jgengine/core/visibility/camera"; import type { VisibilityConfig } from "@jgengine/core/visibility/config"; import type { BoundsSpec, Vec3 } from "@jgengine/core/visibility/bounds"; +import type { OcclusionTester } from "@jgengine/core/visibility/occlusion"; +import { createOccluderGrid, type OccluderBox, type OccluderGridStats } from "@jgengine/core/world/occluderGrid"; type MutableRenderable = { -readonly [Key in keyof Renderable]: Renderable[Key] }; @@ -21,6 +23,30 @@ interface Entry { const ENTITY_BOUNDS: BoundsSpec = { kind: "sphere", radius: 2, offset: [0, 1, 0] }; const EMPTY_CAMERAS: readonly CameraVisibilityContext[] = []; +/** Smallest placed-object radius trusted as an occluder; below it a prop is furniture, not a wall. */ +export const OCCLUDER_MIN_RADIUS = 6; +/** Occluder cell edge in metres. One cell is rejected at a time, so this is the culling granularity. */ +export const OCCLUDER_CELL_SIZE = 16; + +/** A placed solid large enough to hide what stands behind it, as an AABB a grid can index. */ +export function placedOccluderBox( + position: Vec3, + radius: number, + minRadius = OCCLUDER_MIN_RADIUS, +): OccluderBox | null { + if (radius < minRadius) return null; + return { + minX: position[0] - radius, + maxX: position[0] + radius, + minZ: position[2] - radius, + maxZ: position[2] + radius, + minY: 0, + // Half the footprint radius: a box that is too tall only hides things that are really behind it, + // while guessing high over-rejects, so the grid takes the shorter, safe reading. + maxY: radius, + }; +} + function objectRadius(scale: number | readonly [number, number, number] | undefined): number { if (scale === undefined) return 1; const size = typeof scale === "number" ? Math.abs(scale) : Math.max(Math.abs(scale[0]), Math.abs(scale[1]), Math.abs(scale[2])); @@ -72,11 +98,61 @@ export function buildCullingDriver(ctx: GameContext, config: VisibilityConfig | } } + const occlusionEnabled = config?.culling?.occlusionCulling ?? true; + let grid: ReturnType | null = null; + + /** Build the occluder grid once from the placed solids already in the scene. */ + function ensureGrid(): ReturnType { + if (grid !== null) return grid; + const boxes: OccluderBox[] = []; + let minX = Number.POSITIVE_INFINITY; + let minZ = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxZ = Number.NEGATIVE_INFINITY; + for (const object of ctx.scene.object.list()) { + const radius = objectRadius(object.visual?.scale); + const extentX = object.position[0] + radius; + const extentZ = object.position[2] + radius; + const nearX = object.position[0] - radius; + const nearZ = object.position[2] - radius; + if (nearX < minX) minX = nearX; + if (nearZ < minZ) minZ = nearZ; + if (extentX > maxX) maxX = extentX; + if (extentZ > maxZ) maxZ = extentZ; + const box = placedOccluderBox(object.position, radius); + if (box !== null) boxes.push(box); + } + if (boxes.length === 0) { + grid = createOccluderGrid({ bounds: { minX: 0, minZ: 0, maxX: OCCLUDER_CELL_SIZE, maxZ: OCCLUDER_CELL_SIZE }, cellSize: OCCLUDER_CELL_SIZE }); + return grid; + } + const pad = OCCLUDER_CELL_SIZE; + grid = createOccluderGrid({ + bounds: { minX: minX - pad, minZ: minZ - pad, maxX: maxX + pad, maxZ: maxZ + pad }, + cellSize: OCCLUDER_CELL_SIZE, + }); + grid.build(boxes); + return grid; + } + + const occlusion: OcclusionTester = { + enabled: occlusionEnabled, + isOccluded({ camera, bounds }) { + const built = ensureGrid(); + if (built.stats().occluders === 0) return false; + return built.isBoxHidden( + { x: camera.position[0], y: camera.position[1], z: camera.position[2] }, + { minX: bounds.minX, minY: bounds.minY, minZ: bounds.minZ, maxX: bounds.maxX, maxY: bounds.maxY, maxZ: bounds.maxZ }, + ); + }, + }; + const system = createVisibilitySystem({ renderables, cameras: () => currentView === null ? EMPTY_CAMERAS : [{ id: "main", view: currentView }], - ...(config?.culling !== undefined ? { settings: config.culling } : {}), + settings: { occlusionCulling: occlusionEnabled, ...config?.culling }, ...(config?.scene !== undefined ? { sceneOverrides: config.scene } : {}), + occlusion, }); return { system, @@ -86,5 +162,13 @@ export function buildCullingDriver(ctx: GameContext, config: VisibilityConfig | trackedCount() { return entries.size; }, + /** Built-grid counters, so a run can state what occlusion cost and what it rejected. */ + occluderStats(): OccluderGridStats | null { + return grid === null ? null : grid.stats(); + }, + /** Rebuild the occluder grid from the scene's placed solids on the next update. */ + refreshOccluders() { + grid = null; + }, }; }