diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index e6c7dd7d2..9fe7c009d 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -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. diff --git a/.claude/skills/jgengine-ui/capabilities.md b/.claude/skills/jgengine-ui/capabilities.md index 2d207f159..d7f234b21 100644 --- a/.claude/skills/jgengine-ui/capabilities.md +++ b/.claude/skills/jgengine-ui/capabilities.md @@ -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"` diff --git a/changes/lane-tri-budget.md b/changes/lane-tri-budget.md new file mode 100644 index 000000000..eb84065b3 --- /dev/null +++ b/changes/lane-tri-budget.md @@ -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. diff --git a/packages/shell/src/devtools/DevtoolsOverlay.tsx b/packages/shell/src/devtools/DevtoolsOverlay.tsx index 7e884d6d9..03912e4ac 100644 --- a/packages/shell/src/devtools/DevtoolsOverlay.tsx +++ b/packages/shell/src/devtools/DevtoolsOverlay.tsx @@ -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"; @@ -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 diff --git a/packages/shell/src/devtools/trianglesProbe.test.ts b/packages/shell/src/devtools/trianglesProbe.test.ts new file mode 100644 index 000000000..83f7290eb --- /dev/null +++ b/packages/shell/src/devtools/trianglesProbe.test.ts @@ -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"); + }); +}); diff --git a/packages/shell/src/render/triangleAttribution.test.ts b/packages/shell/src/render/triangleAttribution.test.ts new file mode 100644 index 000000000..3431ad8a1 --- /dev/null +++ b/packages/shell/src/render/triangleAttribution.test.ts @@ -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"); + }); +}); diff --git a/packages/shell/src/render/triangleAttribution.ts b/packages/shell/src/render/triangleAttribution.ts new file mode 100644 index 000000000..18adf1033 --- /dev/null +++ b/packages/shell/src/render/triangleAttribution.ts @@ -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(); + 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; + 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}`; + }, + }; +} + diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 506a3ab99..4d25a6c36 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -1054,6 +1054,7 @@ "./render/stableTextureSet", "./render/staticShapeInstances", "./render/staticTransforms", + "./render/triangleAttribution", "./render/triplanarMaterial", "./render/useDisposable", "./render/useEntityRenderCues",