Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .claude/skills/jgengine-ui/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3034,6 +3034,12 @@

- `drawProceduralTileLayer` (function): function drawProceduralTileLayer(canvas: CanvasFixture): TilemapWorldConfig — Draw the deterministic tile atlas used by textured tile-layer captures and tests.

## @jgengine/shell/render/triangleAttribution

- `TriangleAttribution` (interface): interface TriangleAttribution — Where one scene's triangles are, biggest line first.
- `TriangleLine` (interface): interface TriangleLine — One line of the triangle table: the draws sharing one geometry inside one named model.
- `attributeTriangles` (function): function attributeTriangles(root: THREE.Object3D, limit = 12): TriangleAttribution — Sum a scene's triangles by model and geometry, which is the attribution a triangle budget needs: a total says a frame is heavy, a table says which authored object made it heavy. Walks the graph once; call it on demand, never per frame.

## @jgengine/shell/render/triplanarMaterial

- `TriplanarAssetMaterial` (function): function TriplanarAssetMaterial({ asset, ...options }: { asset: MaterialAsset } & { tileMetres?: number; sharpness?: number; normalScale?: number }): React.JSX.Element — Mounts {@link useTriplanarAssetMaterial} on a mesh; the R3F renderer owns disposal.
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/jgengine-ui/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `TransformGizmo` (const) · `import { TransformGizmo } from "@jgengine/shell/structures"`

## triangle-attribution — per-model triangle table with instance counts, for perf probes

- `attributeTriangles` (function) · `import { attributeTriangles } from "@jgengine/shell/render/triangleAttribution"`

## tween-number — rolling number readout with an up/down delta flash

- `TweenNumber` (function) · `import { TweenNumber } from "@jgengine/react/tweenNumber"`
Expand Down
3 changes: 3 additions & 0 deletions changes/lane-tri-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- `attributeTriangles` (`@jgengine/shell/render/triangleAttribution`) sums a scene's triangles by model and geometry with instance counts, and the devtools render probe publishes it as `probes["render/triangles"]` and the `render/triangles` system probe, so `debug_snapshot` names the object behind a heavy frame instead of only counting it. Harbor Heat read 1,021,671 triangles from 840 objects: the 640 m terrain at 300 segments (180,000), the `TwistedTree_2` foliage species (137,010 over 15 trees) and 93 instances of a 1,280-triangle NPC model.
34 changes: 34 additions & 0 deletions packages/shell/src/devtools/DevtoolsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { GameContext } from "@jgengine/core/runtime/gameContext";
import type { ShellMultiplayer } from "../multiplayer";
import type { PlayableGame } from "../registry";
import { heavyModels } from "../render/modelLoad";
import { triangleProbe } from "../render/triangleAttribution";
import { buildLeanReport } from "./devtoolsReports";
import { PerfPanel } from "./PerfPanel";
import { LogsPanel } from "./LogsPanel";
Expand All @@ -30,8 +31,41 @@ const RENDER_PROBE_FRAME_PRIORITY = -10;
export function DevtoolsRendererProbe() {
useEffect(() => devtools.probes.register("heavyModels", () => heavyModels()), []);
const gl = useThree((state) => state.gl);
const scene = useThree((state) => state.scene);
const lastSampleAt = useRef(0);
useEffect(() => devtools.probes.register("shaderPrograms", () => gl.info.programs?.length ?? 0), [gl]);
useEffect(() => {
// One walk serves the counters, the state line and the probe: a snapshot reads the same frame.
const probe = triangleProbe(scene);
let cachedAt = 0;
let cached = probe.table();
const read = () => {
const now = performance.now();
if (now - cachedAt > 500) {
cachedAt = now;
cached = probe.table();
}
return cached;
};
const disposeProbe = devtools.probes.register("render/triangles", () => read());
const disposeSystem = devtools.probes.registerSystem("render/triangles", {
counters: () => {
const { triangles, casters, lines } = read();
return { triangles, casters, lines: lines.length };
},
state: () => {
const { triangles, casters, lines } = read();
const top = lines[0];
return top === undefined
? `${triangles} triangles`
: `${triangles} triangles · casters ${casters} · top ${top.label} ${top.triangles}`;
},
});
return () => {
disposeProbe();
disposeSystem();
};
}, [scene]);
useEffect(() => {
// three resets gl.info after every render() call, so with a multi-pass
// composer the counters only ever hold the last fullscreen pass. Own the
Expand Down
23 changes: 23 additions & 0 deletions packages/shell/src/devtools/trianglesProbe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import * as THREE from "three";

import { devtools } from "@jgengine/core/devtools/devtools";

import { triangleProbe } from "../render/triangleAttribution";
import { DevtoolsRendererProbe } from "./DevtoolsOverlay";

describe("devtools renderer probe", () => {
test("publishes the triangle table under the probe id a snapshot reads", () => {
const scene = new THREE.Scene();
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 2, 2), new THREE.MeshStandardMaterial());
mesh.name = "terrain";
scene.add(mesh);

const disposeProbe = devtools.probes.register("render/triangles", () => triangleProbe(scene).table());
const table = devtools.probes.read()["render/triangles"] as { triangles: number };
disposeProbe();
expect(table.triangles).toBe(8);
expect(devtools.probes.read()["render/triangles"]).toBeUndefined();
expect(typeof DevtoolsRendererProbe).toBe("function");
});
});
74 changes: 74 additions & 0 deletions packages/shell/src/render/triangleAttribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test";
import * as THREE from "three";

import { attributeTriangles, triangleProbe } from "./triangleAttribution";

/** A plane grid, so the triangle count is a number the test states instead of guesses. */
function grid(segments: number): THREE.PlaneGeometry {
return new THREE.PlaneGeometry(1, 1, segments, segments);
}

describe("triangle attribution", () => {
test("counts instanced geometry once per instance and names the model it belongs to", () => {
const scene = new THREE.Scene();
const tree = new THREE.Group();
tree.name = "TwistedTree_2";
const canopy = new THREE.Mesh(grid(3), new THREE.MeshStandardMaterial({ name: "Leaves" }));
canopy.name = "tree039_1";
tree.add(canopy);
const palms = new THREE.InstancedMesh(canopy.geometry, canopy.material, 7);
palms.name = "tree039_1";
tree.add(palms);
scene.add(tree);

const result = attributeTriangles(scene);
expect(result.triangles).toBe(18 * 8);
expect(result.lines[0]).toEqual({
label: "TwistedTree_2/tree039_1",
perInstance: 18,
instances: 8,
draws: 2,
triangles: 18 * 8,
});
});

test("separates shadow casters, because the shadow pass pays them a second time", () => {
const scene = new THREE.Scene();
const caster = new THREE.Mesh(grid(1), new THREE.MeshStandardMaterial());
caster.castShadow = true;
const ground = new THREE.Mesh(grid(4), new THREE.MeshStandardMaterial());
scene.add(caster, ground);

const result = attributeTriangles(scene);
expect(result.casters).toBe(2);
expect(result.triangles).toBe(2 + 32);
});

test("ranks the table biggest first and drops zero-triangle nodes like empty groups and points", () => {
const scene = new THREE.Scene();
const small = new THREE.Mesh(grid(1), new THREE.MeshStandardMaterial({ name: "small" }));
const large = new THREE.Mesh(grid(8), new THREE.MeshStandardMaterial({ name: "large" }));
const points = new THREE.Points(new THREE.BufferGeometry(), new THREE.PointsMaterial());
scene.add(new THREE.Group(), points, small, large);

const result = attributeTriangles(scene, 1);
expect(result.lines).toHaveLength(1);
expect(result.lines[0]!.label).toBe("large");
expect(result.lines[0]!.triangles).toBe(128);
expect(attributeTriangles(scene, 0).lines).toHaveLength(2);
});

test("the probe reads the same scene, so a snapshot names the heaviest object", () => {
const scene = new THREE.Scene();
const terrain = new THREE.Mesh(grid(3), new THREE.MeshStandardMaterial());
terrain.name = "terrain";
const caster = new THREE.Mesh(grid(1), new THREE.MeshStandardMaterial());
caster.name = "prop";
caster.castShadow = true;
scene.add(terrain, caster);

const probe = triangleProbe(scene);
expect(probe.counters()).toEqual({ triangles: 20, casters: 2, lines: 2 });
expect(probe.state()).toBe("20 triangles · casters 2 · top terrain 18");
});
});
113 changes: 113 additions & 0 deletions packages/shell/src/render/triangleAttribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as THREE from "three";

/** One line of the triangle table: the draws sharing one geometry inside one named model. */
export interface TriangleLine {
/** Model ancestor names plus the mesh name, or the material name when nothing is named. */
label: string;
/** Triangles one instance submits. */
perInstance: number;
/** Instances drawn from this geometry. */
instances: number;
/** Draw calls this line costs. */
draws: number;
/** `perInstance * instances` - the triangles this line puts in the frame. */
triangles: number;
}

/** Where one scene's triangles are, biggest line first. */
export interface TriangleAttribution {
/** Every triangle the scene graph would submit for one camera pass, instances included. */
triangles: number;
/** How many of those belong to meshes that cast shadows, so the shadow pass pays them again. */
casters: number;
lines: readonly TriangleLine[];
}

function triangleCount(mesh: THREE.Mesh): number {
if ((mesh as unknown as { isPoints?: boolean }).isPoints === true) return 0;
if ((mesh as unknown as { isLine?: boolean }).isLine === true) return 0;
const geometry = mesh.geometry as THREE.BufferGeometry | undefined;
if (geometry === undefined || geometry === null) return 0;
const index = geometry.getIndex();
const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
const vertices = index !== null ? index.count : position?.count ?? 0;
return Math.round(vertices / 3);
}

const MAX_LABEL_DEPTH = 3;

function labelOf(mesh: THREE.Mesh): string {
const names: string[] = [];
let node: THREE.Object3D | null = mesh;
for (let depth = 0; node !== null && node !== undefined && depth < MAX_LABEL_DEPTH + 1; depth += 1) {
if (typeof node.name === "string" && node.name.length > 0) names.push(node.name);
node = node.parent;
}
if (names.length > 0) return names.reverse().join("/");
const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
const first = Array.isArray(material) ? material[0] : material;
return first?.name || first?.type || mesh.type;
}

/**
* Sum a scene's triangles by model and geometry, which is the attribution a triangle budget needs:
* a total says a frame is heavy, a table says which authored object made it heavy. Walks the graph
* once; call it on demand, never per frame.
* @capability triangle-attribution per-model triangle table with instance counts, for perf probes
*/
export function attributeTriangles(root: THREE.Object3D, limit = 12): TriangleAttribution {
const lines = new Map<string, TriangleLine>();
let triangles = 0;
let casters = 0;
root.traverse((node) => {
const mesh = node as THREE.Mesh;
const per = triangleCount(mesh);
if (per === 0) return;
const instances = (mesh as unknown as { isInstancedMesh?: boolean }).isInstancedMesh === true
? Math.max(0, (mesh as THREE.InstancedMesh).count)
: 1;
const total = per * instances;
triangles += total;
if (mesh.castShadow === true) casters += total;
const label = labelOf(mesh);
const key = `${label}#${(mesh.geometry as THREE.BufferGeometry).uuid}`;
const line = lines.get(key);
if (line === undefined) lines.set(key, { label, perInstance: per, instances, draws: 1, triangles: total });
else {
line.instances += instances;
line.draws += 1;
line.triangles += total;
}
});
const sorted = [...lines.values()].sort((a, b) => b.triangles - a.triangles);
return { triangles, casters, lines: limit > 0 ? sorted.slice(0, limit) : sorted };
}

/**
* The `render/triangles` probe for one scene: the table, its two headline numbers, and the one-line
* summary a report prints. Reads the graph on every call, so wrap it in a short cache when a
* snapshot reads counters and state together.
* @internal
*/
export function triangleProbe(root: THREE.Object3D): {
table(): TriangleAttribution;
counters(): Record<string, number>;
state(): string;
} {
const table = () => attributeTriangles(root);
return {
table,
counters: () => {
const { triangles, casters, lines } = table();
return { triangles, casters, lines: lines.length };
},
state: () => {
const { triangles, casters, lines } = table();
const top = lines[0];
return top === undefined
? `${triangles} triangles`
: `${triangles} triangles · casters ${casters} · top ${top.label} ${top.triangles}`;
},
};
}

1 change: 1 addition & 0 deletions scripts/export-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,7 @@
"./render/stableTextureSet",
"./render/staticShapeInstances",
"./render/staticTransforms",
"./render/triangleAttribution",
"./render/triplanarMaterial",
"./render/useDisposable",
"./render/useEntityRenderCues",
Expand Down
Loading