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..52b24ce01 --- /dev/null +++ b/packages/core/src/world/occluderGrid.test.ts @@ -0,0 +1,85 @@ +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 at the blocker's near edge, because it may be partly visible", () => { + const built = grid(); + 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", () => { + 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..1224052ff --- /dev/null +++ b/packages/core/src/world/occluderGrid.ts @@ -0,0 +1,296 @@ +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 samples one ray may take. Default 4096. */ + readonly maxSamples?: number; + readonly restore?: OccluderGridState; +} + +/** 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; + 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 touches. */ + 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 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`. 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 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()); + +/** + * Build a coarse occluder grid from authored solids. The grid is built once from geometry — never by + * 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 + */ +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 maxSamples = config.maxSamples ?? 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; + + let boxes: OccluderBox[] = []; + let cells: number[][] = []; + let buildMs = 0; + let tests = 0; + let rejected = 0; + let testMs = 0; + + if (restore !== undefined) { + for (const box of restore.boxes) if (box.maxY - box.minY >= minHeight) boxes.push(box); + index(); + } + + function colOf(x: number): number { + const col = Math.floor((x - minX) / cellSize); + return col < 0 ? 0 : col >= cols ? cols - 1 : col; + } + function rowOf(z: number): number { + 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 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(); + boxes = []; + for (const box of next) { + if (box.maxY - box.minY < minHeight) continue; + boxes.push(box); + } + index(); + buildMs = now() - started; + } + + /** 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; + } + + 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(maxSamples, Math.ceil(length / (cellSize * 0.5))); + if (samples <= 0) return false; + for (let sample = 1; sample < samples; sample += 1) { + const t = sample / samples; + const px = cameraX + dx * t; + const pz = cameraZ + dz * t; + 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 { + 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; + 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, cameraY, cameraZ, cornersX[corner]!, cornersZ[corner]!)) return false; + } + return true; + } + + function isBoxHidden(camera: { readonly x: number; readonly y: number; readonly z: number }, box: OccluderBox): boolean { + const started = now(); + tests += 1; + // 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; + if (hidden) rejected += 1; + return hidden; + } + + return { + cols, + rows, + cellSize, + minX, + minZ, + build, + colOf, + rowOf, + cellIndex, + isCellBlocked, + 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 (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 }; + tests = 0; + rejected = 0; + testMs = 0; + return delta; + }, + clear() { + boxes = []; + cells = []; + buildMs = 0; + tests = 0; + rejected = 0; + testMs = 0; + }, + toState() { + 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; + }, }; }