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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/skills/jgengine-world/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/render/cullPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
116 changes: 116 additions & 0 deletions packages/core/src/render/cullPolicy.ts
Original file line number Diff line number Diff line change
@@ -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<number>,
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;
}
9 changes: 9 additions & 0 deletions packages/core/src/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
85 changes: 85 additions & 0 deletions packages/core/src/world/occluderGrid.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});



Loading
Loading