diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index 27e4681e..ac970921 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -2288,11 +2288,15 @@ ## @jgengine/shell/render/modelLoad +- `DEFAULT_MODEL_TRIANGLE_BUDGET` (const): const DEFAULT_MODEL_TRIANGLE_BUDGET: 100000 — Per-model triangle ceiling before the loader warns. A prop is a few thousand triangles; a hero rig tens of thousands. +- `HeavyModelRecord` (interface): interface HeavyModelRecord — One over-budget model as {@link heavyModels} reports it: the URL the loader fetched, its triangle count, and the budget it exceeded. - `ModelLoaderConfig` (interface): interface ModelLoaderConfig — CDN-backed decoder configuration for compressed model and texture assets. - `configureModelLoaders` (function): function configureModelLoaders(options: ModelLoaderConfig = {}): Readonly> — Configures shared Draco and KTX2 loaders. Repeated calls with the same paths are no-ops. - `detectKtx2Support` (function): function detectKtx2Support(renderer: THREE.WebGLRenderer): void — Detects GPU support for the configured KTX2 transcoder after a renderer exists. +- `heavyModels` (function): function heavyModels(): readonly HeavyModelRecord[] — Every model the shared loader resolved whose triangle count exceeds the budget, in load order. A heavy prop is invisible in a screenshot and only shows up as a slow frame, so the loader measures each GLB as it lands and this exposes the verdict for `debug_snapshot` probes and smoke tests to assert against. - `modelLoadFallbacks` (function): function modelLoadFallbacks(): readonly { url: string; message: string }[] — Every model the shared loader diagnosed as broken and replaced with a magenta placeholder, in load order. - `modelLoadIdleMs` (function): function modelLoadIdleMs(): number — How long the shared GLB loader has been idle, in ms — `0` while any model is still in flight. A capture host reads this to wait for streaming to finish instead of guessing a settle delay: models that pop in after the shot are why an establishing capture used to come back half-empty until someone hand-tuned `--settle`. +- `setModelTriangleBudget` (function): function setModelTriangleBudget(triangles: number): void — Retune the per-model triangle budget (a cinematic game may raise it; a mobile target lowers it). Non-finite or non-positive values restore the default. ## @jgengine/shell/render/modelRender diff --git a/.claude/skills/jgengine-ui/capabilities.md b/.claude/skills/jgengine-ui/capabilities.md index 4b6c67d2..b7c93822 100644 --- a/.claude/skills/jgengine-ui/capabilities.md +++ b/.claude/skills/jgengine-ui/capabilities.md @@ -205,6 +205,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `GameContextBridge` (function) · `import { GameContextBridge } from "@jgengine/react"` +## heavy-models — models over the per-model triangle budget, for perf probes and smoke assertions + +- `heavyModels` (function) · `import { heavyModels } from "@jgengine/shell/render/modelLoad"` + ## heightfield-mesh-update — in-place partial update of a displaced ground plane mesh - `displaceHeightfieldGeometry` (function) · `import { displaceHeightfieldGeometry } from "@jgengine/shell/terrain"` diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7e2c1c..d5b455db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ between (`--json` for structured output). - The built-in Graphics settings tab now exposes render scale plus ambient occlusion, bloom, depth-of-field, and SMAA toggles alongside the quality tier; picking a tier re-applies its defaults. `@jgengine/core/settings/graphicsSettings` (`readGraphicsSettings`, `applyGraphicsQuality`, `GRAPHICS_POST_STAGES`) resolves the stored choices onto the `GraphicsProfile` the shell renders with, and `SETTING_IDS` gains `graphics.renderScale` and `graphics.post.*`. Every game gets the rows in its existing settings menu with no wiring (#1688). +- Model triangle budget: the shared GLB loader counts each model's triangles as it lands and warns once per URL when one exceeds `DEFAULT_MODEL_TRIANGLE_BUDGET` (100k; retune with `setModelTriangleBudget`). `heavyModels()` lists the offenders and `debug_snapshot` reports them under `probes.heavyModels`, so a heavy prop shows up in evidence instead of only as a slow frame. +- `bun run drive --state ` boots into a `capture.states` entry the same way `shoot --state` does, so a staged scene can be measured with `--rpc debug_snapshot` or probed instead of clicked together by hand. - Building palettes take textured surfaces: a `BuildingPalette` part (and `BuildingKitPart.material`) may be `{ color?, maps?, repeat?, roughness?, metalness? }` with `maps` straight from `buildMaterialCatalog(...).resolve(id)!.maps`, not only a hex colour. Generated facade boxes and kit models tile the PBR maps per slot; unbound kinds keep their flat colour. `buildingSurfaceColor` / `resolveBuildingSurface` read either form. ### Fixed diff --git a/packages/shell/src/devtools/DevtoolsOverlay.tsx b/packages/shell/src/devtools/DevtoolsOverlay.tsx index 54f44be9..deeec8b0 100644 --- a/packages/shell/src/devtools/DevtoolsOverlay.tsx +++ b/packages/shell/src/devtools/DevtoolsOverlay.tsx @@ -14,6 +14,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 { collisionDebug } from "./collisionDebug"; import { readStoredOverrides } from "./devtoolsOverrides"; import { diagnose } from "./perfDiagnose"; @@ -250,6 +251,7 @@ export function DevtoolsOverlay({ devtools.probes.register("objects", () => ctx.scene.object.list().length), devtools.probes.register("fallbacks", () => fallbackSeamsSnapshot()), devtools.probes.register("textureErrors", () => textureErrorsSnapshot()), + devtools.probes.register("heavyModels", () => heavyModels()), ]; return () => { for (const dispose of disposers) dispose(); diff --git a/packages/shell/src/render/modelLoad.test.ts b/packages/shell/src/render/modelLoad.test.ts index 23d47d8c..7af6365f 100644 --- a/packages/shell/src/render/modelLoad.test.ts +++ b/packages/shell/src/render/modelLoad.test.ts @@ -9,7 +9,19 @@ import { textureErrorsSnapshot, } from "@jgengine/core/devtools/textureErrors"; -import { configureModelLoaders, createFallbackModel, handleModelLoadFailure, probeModelUrl, recordManagerLoadError } from "./modelLoad"; +import { + DEFAULT_MODEL_TRIANGLE_BUDGET, + clearHeavyModels, + configureModelLoaders, + countSceneTriangles, + createFallbackModel, + handleModelLoadFailure, + heavyModels, + probeModelUrl, + recordManagerLoadError, + recordModelTriangles, + setModelTriangleBudget, +} from "./modelLoad"; function stubResponse(body: Uint8Array, init: { status?: number; contentType?: string; statusText?: string }): Response { return new Response(body, { @@ -205,3 +217,41 @@ describe("handleModelLoadFailure", () => { expect(erroredWith).toBe(original); }); }); + +describe("model triangle budget", () => { + afterEach(() => { + clearHeavyModels(); + setModelTriangleBudget(DEFAULT_MODEL_TRIANGLE_BUDGET); + }); + + test("countSceneTriangles sums indexed, non-indexed, and instanced meshes", () => { + const root = new THREE.Group(); + root.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))); + root.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1).toNonIndexed())); + root.add(new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial(), 3)); + expect(countSceneTriangles(root)).toBe(12 + 12 + 36); + }); + + test("a model under budget records nothing", () => { + recordModelTriangles("/models/pack/Light.glb", 900, { warn: () => {} }); + expect(heavyModels()).toEqual([]); + }); + + test("a model over budget is recorded and warned once per URL", () => { + setModelTriangleBudget(1_000); + const warned: string[] = []; + recordModelTriangles("/models/pack/Heavy.glb", 700_000, { warn: (record) => warned.push(record.url) }); + recordModelTriangles("/models/pack/Heavy.glb", 700_000, { warn: (record) => warned.push(record.url) }); + expect(heavyModels()).toEqual([ + { url: "/models/pack/Heavy.glb", triangles: 700_000, budget: 1_000 }, + { url: "/models/pack/Heavy.glb", triangles: 700_000, budget: 1_000 }, + ]); + expect(warned).toEqual(["/models/pack/Heavy.glb"]); + }); + + test("a non-positive budget falls back to the default", () => { + setModelTriangleBudget(0); + recordModelTriangles("/models/pack/Mid.glb", DEFAULT_MODEL_TRIANGLE_BUDGET, { warn: () => {} }); + expect(heavyModels()).toEqual([]); + }); +}); diff --git a/packages/shell/src/render/modelLoad.ts b/packages/shell/src/render/modelLoad.ts index 6eba5184..c3ddc561 100644 --- a/packages/shell/src/render/modelLoad.ts +++ b/packages/shell/src/render/modelLoad.ts @@ -168,6 +168,77 @@ export function clearModelLoadFallbacks(): void { servedFallbacks.length = 0; } +/** Per-model triangle ceiling before the loader warns. A prop is a few thousand triangles; a hero rig tens of thousands. */ +export const DEFAULT_MODEL_TRIANGLE_BUDGET = 100_000; + +let modelTriangleBudget = DEFAULT_MODEL_TRIANGLE_BUDGET; + +/** Retune the per-model triangle budget (a cinematic game may raise it; a mobile target lowers it). Non-finite or non-positive values restore the default. */ +export function setModelTriangleBudget(triangles: number): void { + modelTriangleBudget = Number.isFinite(triangles) && triangles > 0 ? triangles : DEFAULT_MODEL_TRIANGLE_BUDGET; +} + +/** Triangles a loaded model contributes per draw, summing every mesh (instanced meshes count once per instance). @internal */ +export function countSceneTriangles(root: THREE.Object3D): number { + let total = 0; + root.traverse((node) => { + const mesh = node as THREE.Mesh & { isMesh?: boolean; isInstancedMesh?: boolean; count?: number }; + if (mesh.isMesh !== true || mesh.geometry === undefined) return; + const geometry = mesh.geometry; + const vertices = geometry.index !== null ? geometry.index.count : geometry.attributes.position?.count ?? 0; + const instances = mesh.isInstancedMesh === true && typeof mesh.count === "number" ? mesh.count : 1; + total += Math.floor(vertices / 3) * instances; + }); + return total; +} + +/** One over-budget model as {@link heavyModels} reports it: the URL the loader fetched, its triangle count, and the budget it exceeded. */ +export interface HeavyModelRecord { + url: string; + triangles: number; + budget: number; +} + +const heavyModelRecords: HeavyModelRecord[] = []; +const warnedHeavyUrls = new Set(); + +function warnHeavyModel(record: HeavyModelRecord): void { + if (typeof console === "undefined") return; + console.warn( + `[jgengine] model ${record.url} has ${record.triangles.toLocaleString()} triangles (budget ${record.budget.toLocaleString()}). A game prop should be a few thousand — pick a lighter asset or decimate it.`, + ); +} + +/** + * Record a loaded model's triangle count against the budget: over budget warns once per URL and lands in + * {@link heavyModels}; under budget is silent. Split out so the wiring is unit-testable without a GL context. + * @internal + */ +export function recordModelTriangles(url: string, triangles: number, overrides: { warn?: (record: HeavyModelRecord) => void } = {}): void { + if (triangles <= modelTriangleBudget) return; + const record = { url, triangles, budget: modelTriangleBudget }; + heavyModelRecords.push(record); + if (warnedHeavyUrls.has(url)) return; + warnedHeavyUrls.add(url); + (overrides.warn ?? warnHeavyModel)(record); +} + +/** + * Every model the shared loader resolved whose triangle count exceeds the budget, in load order. A heavy + * prop is invisible in a screenshot and only shows up as a slow frame, so the loader measures each GLB as it + * lands and this exposes the verdict for `debug_snapshot` probes and smoke tests to assert against. + * @capability heavy-models models over the per-model triangle budget, for perf probes and smoke assertions + */ +export function heavyModels(): readonly HeavyModelRecord[] { + return heavyModelRecords; +} + +/** Drops the heavy-model list and the once-per-URL warning memory. For tests and between capture runs. @internal */ +export function clearHeavyModels(): void { + heavyModelRecords.length = 0; + warnedHeavyUrls.clear(); +} + /** * A {@link GLTFLoader} whose success path is unchanged but whose failures degrade * gracefully: when a load errors, it probes the URL and — for a diagnosed broken @@ -186,7 +257,10 @@ export class DiagnosticGLTFLoader extends GLTFLoader { ): void { super.load( url, - onLoad, + (gltf: GLTF) => { + recordModelTriangles(url, countSceneTriangles(gltf.scene)); + onLoad(gltf); + }, onProgress, (event: unknown) => { if (onError === undefined) return; diff --git a/scripts/drive-dev.ts b/scripts/drive-dev.ts index 6b6a1c6a..7488dde0 100644 --- a/scripts/drive-dev.ts +++ b/scripts/drive-dev.ts @@ -90,6 +90,7 @@ type Args = { look?: string; lookFrom?: string; view?: string; + state?: string; site?: string; record?: string; recordWidth: number; @@ -131,6 +132,9 @@ const HELP = `bun run drive [options] --click "TEXT" --shot name ... --view replay a framing the game declares in capture.views (see shoot --list-views). Distinct from --shot, which names the output file: --view keep-enemy --shot before + --state boot into a capture.states entry (same as shoot --state), then + run the steps — pair with --rpc debug_snapshot to measure a + staged scene instead of clicking it together by hand --site drive a route from the managed apps/web server instead of a game --rpc call the page's agent/editor bridge with this JSON payload. Compose an editor aerial in one call, e.g. @@ -212,6 +216,7 @@ function parseArgs(argv: string[]): Args { for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (value === "--mode") args.mode = argv[++index] ?? args.mode; + else if (value === "--state") args.state = argv[++index]; else if (value === "--size") { args.size = parseSizeArg(argv[++index]); } else if (value === "--timeout") { @@ -674,6 +679,7 @@ const exitCode = await withBrowserSession( url.searchParams.set("capture", "1"); if (args.spawn !== undefined && args.spawn.length > 0) url.searchParams.set("spawn", args.spawn); if (args.view !== undefined) url.searchParams.set("view", args.view); + if (args.state !== undefined) url.searchParams.set("state", args.state); const aim = parseLookAim(args.look, args.lookFrom, { withNamedView: args.view !== undefined }); if (aim !== undefined) { for (const [key, value] of Object.entries(lookSearchParams(aim))) url.searchParams.set(key, value);