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
4 changes: 4 additions & 0 deletions .claude/skills/jgengine-ui/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Required<ModelLoaderConfig>> — 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

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 @@ -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"`
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <game> --state <name>` 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
Expand Down
2 changes: 2 additions & 0 deletions packages/shell/src/devtools/DevtoolsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
52 changes: 51 additions & 1 deletion packages/shell/src/render/modelLoad.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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([]);
});
});
76 changes: 75 additions & 1 deletion packages/shell/src/render/modelLoad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

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
Expand All @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions scripts/drive-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ type Args = {
look?: string;
lookFrom?: string;
view?: string;
state?: string;
site?: string;
record?: string;
recordWidth: number;
Expand Down Expand Up @@ -131,6 +132,9 @@ const HELP = `bun run drive <gameId> [options] --click "TEXT" --shot name ...
--view <name> 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 <name> 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 <path> drive a route from the managed apps/web server instead of a game
--rpc <json> call the page's agent/editor bridge with this JSON payload.
Compose an editor aerial in one call, e.g.
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
Expand Down
Loading