@vgai/engine 0.5.8 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/adapter/ingest/contract-debug-adapter.d.ts +35 -0
  2. package/dist/adapter/ingest/contract-debug-adapter.d.ts.map +1 -0
  3. package/dist/adapter/ingest/contract-debug-adapter.js +90 -0
  4. package/dist/adapter/ingest/game-contract.d.ts +60 -1
  5. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  6. package/dist/adapter/ingest/game-contract.js +1 -1
  7. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  8. package/dist/adapter/setup-three-root-adapter.js +94 -2
  9. package/dist/dev/performance-profiler.d.ts +13 -7
  10. package/dist/dev/performance-profiler.d.ts.map +1 -1
  11. package/dist/dev/performance-profiler.js +31 -3
  12. package/dist/dev/register-render-vitals.d.ts +95 -0
  13. package/dist/dev/register-render-vitals.d.ts.map +1 -0
  14. package/dist/dev/register-render-vitals.js +182 -0
  15. package/dist/dev/render-census.d.ts +135 -0
  16. package/dist/dev/render-census.d.ts.map +1 -0
  17. package/dist/dev/render-census.js +257 -0
  18. package/dist/dev/render-vitals.d.ts +181 -0
  19. package/dist/dev/render-vitals.d.ts.map +1 -0
  20. package/dist/dev/render-vitals.js +232 -0
  21. package/dist/dev/static-batch-advisor.d.ts +106 -0
  22. package/dist/dev/static-batch-advisor.d.ts.map +1 -0
  23. package/dist/dev/static-batch-advisor.js +141 -0
  24. package/dist/render/render-batch-system.d.ts.map +1 -1
  25. package/dist/render/render-batch-system.js +7 -18
  26. package/dist/render/structural-signature.d.ts +148 -0
  27. package/dist/render/structural-signature.d.ts.map +1 -0
  28. package/dist/render/structural-signature.js +193 -0
  29. package/dist/runtime/dev-layers.d.ts.map +1 -1
  30. package/dist/runtime/dev-layers.js +6 -0
  31. package/package.json +1 -1
  32. package/schemas/engine-capabilities.json +5 -5
  33. package/src/adapter/ingest/contract-debug-adapter.ts +110 -0
  34. package/src/adapter/ingest/game-contract.ts +63 -1
  35. package/src/adapter/setup-three-root-adapter.ts +94 -2
  36. package/src/dev/performance-profiler.ts +47 -12
  37. package/src/dev/register-render-vitals.ts +249 -0
  38. package/src/dev/render-census.ts +351 -0
  39. package/src/dev/render-vitals.ts +338 -0
  40. package/src/dev/static-batch-advisor.ts +186 -0
  41. package/src/render/render-batch-system.ts +16 -19
  42. package/src/render/structural-signature.ts +231 -0
  43. package/src/runtime/dev-layers.ts +7 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * THE ROUTING from a draw-call reading to the one-line fix.
3
+ *
4
+ * `dev/render-vitals.ts` can already tell a game it is submitting 4,000 draws
5
+ * and `dev/render-census.ts` can already say which subtree they are in. Both
6
+ * require someone to ASK, and both require that someone to already know that
7
+ * static batching exists, is possible here, and is spelled `<Frozen>`. An
8
+ * agent building a game does not know any of that, so the measurement has to
9
+ * do the routing itself — the same idiom as the dev-menu's unconfigured-section
10
+ * warning: the reading names the exact edit.
11
+ *
12
+ * ── THE DECISION IS PURE, THE SCHEDULE IS NOT ───────────────────────────────
13
+ * {@link decideStaticBatchAdvisory} takes a draw-call count and two scan
14
+ * reports and answers with an advisory or `null`. It reads no clock, no
15
+ * scene, no console. `dev/register-render-vitals.ts` owns the impure half —
16
+ * when to scan, and warning once — because that is where the profiler
17
+ * subscription already lives.
18
+ *
19
+ * ── COST ────────────────────────────────────────────────────────────────────
20
+ * The scan is TWO walks of the scene graph, once, after the frame rate has
21
+ * settled ({@link ADVISOR_SETTLE_FRAMES}). Never per frame: a walk of 4,000
22
+ * nodes every frame is itself the kind of cost this advisory exists to
23
+ * remove, and the answer does not change from one frame to the next in a world
24
+ * whose scenery is mount-static — which is the only world the advice applies
25
+ * to anyway.
26
+ *
27
+ * ── WHY IT ASKS INSTEAD OF ACTING ───────────────────────────────────────────
28
+ * "These 2,600 meshes are the same draw" is measurable. "These 2,600 meshes
29
+ * never move" is NOT — nothing in a scene graph distinguishes scenery from a
30
+ * thing that will move on the next input. Inferring it and batching anyway is
31
+ * how a batcher freezes a door half-open. So the advisory names the subtree
32
+ * and the wrapper, and the author (who knows) places it.
33
+ */
34
+ /**
35
+ * Draw calls below which no advisory fires, however batchable the scene.
36
+ *
37
+ * The field measurement this capability came out of: ~4,000 draws cost ~11 ms
38
+ * of CPU submission per frame — about 2.75 µs each on a desktop browser. At
39
+ * 500 draws that is ~1.4 ms, roughly 8% of a 60 Hz frame: the first point
40
+ * where halving it is a visible win rather than noise a profiler cannot
41
+ * separate from jitter. Below it, an advisory would be a nag pointing at
42
+ * something that is not costing anything, and a nag that is usually wrong is
43
+ * one nobody reads when it is right.
44
+ */
45
+ export const ADVISOR_DRAW_CALL_THRESHOLD = 500;
46
+ /**
47
+ * The share of the draw calls that must be collapsible before the advice is
48
+ * worth an edit. Half: below that, the wrapper leaves most of the cost exactly
49
+ * where it was, and "you could remove a third of a third" is not a payoff
50
+ * anyone should restructure a scene for.
51
+ */
52
+ export const ADVISOR_COLLAPSIBLE_SHARE = 0.5;
53
+ /**
54
+ * A subtree must hold at least this share of the collapsible meshes to be
55
+ * named as THE address. Under it the advisory says "across the scene" — an
56
+ * invented address is worse than none, because the reader wraps the wrong
57
+ * group and measures no change.
58
+ */
59
+ export const ADVISOR_SUBTREE_SHARE = 0.4;
60
+ /**
61
+ * Presented frames to wait before scanning — ~2 s at 60 Hz. Long enough for
62
+ * asset loads and the first setup pass to finish populating the graph (a scan
63
+ * at frame one measures an empty world and stays silent forever), short enough
64
+ * that the line lands while the author is still looking at the boot.
65
+ */
66
+ export const ADVISOR_SETTLE_FRAMES = 120;
67
+ /** `2600` → `2,600`. Digits a person reads at a glance, in the one place the
68
+ * message is built, so every number in it is grouped the same way. */
69
+ function grouped(value) {
70
+ return value.toLocaleString('en-US');
71
+ }
72
+ /**
73
+ * Decide whether this frame's cost is worth an advisory, and what it should
74
+ * say. Pure — every input is an argument, and the same arguments always
75
+ * produce the same message.
76
+ */
77
+ export function decideStaticBatchAdvisory(input) {
78
+ const { drawCalls, census, structural } = input;
79
+ if (drawCalls === null || drawCalls < ADVISOR_DRAW_CALL_THRESHOLD)
80
+ return null;
81
+ const { collapsible } = structural;
82
+ if (collapsible < drawCalls * ADVISOR_COLLAPSIBLE_SHARE)
83
+ return null;
84
+ // The address, when one subtree genuinely dominates. `bySubtree` is already
85
+ // sorted, and a name that no census row confirms is not offered: it would
86
+ // send the reader looking for a node the other render.* commands cannot
87
+ // find either.
88
+ const addressable = new Set(census.subtrees.map((row) => row.name));
89
+ const leader = structural.bySubtree[0];
90
+ const subtree = leader &&
91
+ leader.collapsible >= collapsible * ADVISOR_SUBTREE_SHARE &&
92
+ addressable.has(leader.name)
93
+ ? leader.name
94
+ : null;
95
+ const fix = subtree === null ? '<Frozen>' : `<Frozen name="${subtree}">`;
96
+ const where = subtree === null ? 'spread across the scene' : `mostly under "${subtree}"`;
97
+ const message = `[static-batch] ~${grouped(collapsible)} of ${grouped(drawCalls)} draw calls are the same ` +
98
+ `handful of draws repeated (${grouped(structural.familyCount)} structural families), ` +
99
+ `${where}. If that scenery is mount-static — nothing under it moves, re-colours or ` +
100
+ `unmounts after mount — one wrapper collapses it to a few draws: wrap it in ` +
101
+ `${fix}…</Frozen> (vgai add static-batch). Reactive scenery goes outside the wrapper, ` +
102
+ `and a subtree that must stay unbatched declares it: userData={{ staticBatch: false }}.`;
103
+ return { drawCalls, collapsible, subtree, fix, message };
104
+ }
105
+ /**
106
+ * Once per PAGE, not once per module evaluation — a hot reload re-runs this
107
+ * module, and an advisory that reappears on every save is one that gets muted
108
+ * along with everything else on the console. Same mechanism, and the same
109
+ * reason, as the dev menu's unconfigured-section warning.
110
+ */
111
+ const WARNED_KEY = '__vgaiStaticBatchAdvised';
112
+ function alreadyWarned() {
113
+ return globalThis[WARNED_KEY] === true;
114
+ }
115
+ /**
116
+ * The console IS this advisory's channel: `vgai status` reports console
117
+ * warnings, which is where a building agent already looks. An in-editor
118
+ * banner would be one nobody opens, and a provider would be one nobody reads
119
+ * without already knowing to ask.
120
+ */
121
+ function warnOnConsole(message) {
122
+ // biome-ignore lint/suspicious/noConsole: this function's entire job — see above.
123
+ console.warn(message);
124
+ }
125
+ /**
126
+ * Emit `advisory` on the console, at most once per page. Answers whether it
127
+ * warned, so a caller can stop scanning.
128
+ */
129
+ export function warnStaticBatchAdvisory(advisory,
130
+ /** Injectable so the decision is testable without a console. */
131
+ warn = warnOnConsole) {
132
+ if (alreadyWarned())
133
+ return false;
134
+ globalThis[WARNED_KEY] = true;
135
+ warn(advisory.message);
136
+ return true;
137
+ }
138
+ /** Test-only: forget that the advisory was ever emitted. */
139
+ export function __resetStaticBatchAdvisoryForTest() {
140
+ globalThis[WARNED_KEY] = undefined;
141
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"render-batch-system.d.ts","sourceRoot":"","sources":["../../src/render/render-batch-system.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAgGhE,qBAAa,iBAAiB;IAU1B,OAAO,CAAC,KAAK;IACb,OAAO,CAAC,QAAQ;IAVlB,OAAO,CAAC,IAAI,CAAqB;IACjC,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,aAAa,CAAe;IAEpC,OAAO,CAAC,QAAQ,CAA6C;IAE7D,OAAO,CAAC,SAAS,CAAoD;gBAG3D,KAAK,EAAE,KAAK,CAAC,KAAK,EAClB,QAAQ,EAAE,sBAAsB;IAM1C,wEAAwE;IACxE,KAAK,IAAI,IAAI;IA0Bb,OAAO,CAAC,SAAS;IAyCjB;;;;;OAKG;IACH,MAAM,IAAI,IAAI;IAyBd;;;OAGG;IACH,OAAO,CAAC,SAAS,EAAE,KAAK,CAAC,SAAS,GAAG,KAAK,CAAC,IAAI,GAAG,IAAI;IAStD,kEAAkE;IAClE,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,4DAA4D;IAC5D,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,QAAQ,IAAI,IAAI;CAiBjB"}
1
+ {"version":3,"file":"render-batch-system.d.ts","sourceRoot":"","sources":["../../src/render/render-batch-system.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AA6FhE,qBAAa,iBAAiB;IAU1B,OAAO,CAAC,KAAK;IACb,OAAO,CAAC,QAAQ;IAVlB,OAAO,CAAC,IAAI,CAAqB;IACjC,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,aAAa,CAAe;IAEpC,OAAO,CAAC,QAAQ,CAA6C;IAE7D,OAAO,CAAC,SAAS,CAAoD;gBAG3D,KAAK,EAAE,KAAK,CAAC,KAAK,EAClB,QAAQ,EAAE,sBAAsB;IAM1C,wEAAwE;IACxE,KAAK,IAAI,IAAI;IA0Bb,OAAO,CAAC,SAAS;IAyCjB;;;;;OAKG;IACH,MAAM,IAAI,IAAI;IAyBd;;;OAGG;IACH,OAAO,CAAC,SAAS,EAAE,KAAK,CAAC,SAAS,GAAG,KAAK,CAAC,IAAI,GAAG,IAAI;IAStD,kEAAkE;IAClE,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,4DAA4D;IAC5D,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,QAAQ,IAAI,IAAI;CAiBjB"}
@@ -1,25 +1,14 @@
1
1
  import * as THREE from 'three';
2
2
  import { getUserData } from '../ecs/user-data';
3
+ import { staticBatchSignature } from './structural-signature';
3
4
  // Structural signature — groups meshes that are VALUE-identical, not object-identical.
4
5
  // The engine scene-loader instantiates a fresh geometry+material per entity (no dedup),
5
- // so keying on .uuid would never batch a real scene. Keying on geometry type+parameters
6
- // and material type+key-props groups identical authored primitives (e.g. 10k boxes of
7
- // the same size+color one batch). Non-primitive geometry (GLTF, no `.parameters`)
8
- // falls back to uuid it simply won't group, which is the safe default.
9
- const geoKey = (g) => {
10
- const params = g.parameters;
11
- return params ? `${g.type}:${JSON.stringify(params)}` : `uuid:${g.uuid}`;
12
- };
13
- const matKey = (m) => {
14
- const s = m;
15
- const col = s.color?.getHexString?.() ?? '';
16
- const map = s.map?.uuid ?? '';
17
- return `${m.type}:${col}:${s.roughness ?? ''}:${s.metalness ?? ''}:${map}:${m.side}:${m.transparent}:${m.vertexColors}`;
18
- };
19
- const SIG = (m) =>
20
- // include shadow flags: meshes with different cast/receive must not share a batch
21
- // (the InstancedMesh carries one flag for the whole group).
22
- `${geoKey(m.geometry)}|${matKey(m.material)}|${m.castShadow ? 1 : 0}${m.receiveShadow ? 1 : 0}`;
6
+ // so keying on .uuid would never batch a real scene; a TSX/R3F world writing inline
7
+ // `<meshStandardMaterial>` elements has exactly the same property. That key lives in
8
+ // `render/structural-signature.ts` ONE owner, shared with the `static-batch`
9
+ // capability's `<Frozen>`, so the two cannot drift into disagreeing about what
10
+ // "the same draw" means. `staticBatchSignature` is this file's original key, moved.
11
+ const SIG = staticBatchSignature;
23
12
  /** Match Three.js renderer visibility: a hidden ancestor hides the whole subtree. */
24
13
  function isEffectivelyVisible(obj) {
25
14
  let cur = obj;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * STRUCTURAL IDENTITY AND ELIGIBILITY for static render batching — the one
3
+ * place that answers "may this draw be collapsed at all?" and "are these two
4
+ * meshes the same thing drawn twice?".
5
+ *
6
+ * Three consumers, which is the whole reason it is a module rather than three
7
+ * private helpers: `render/render-batch-system.ts` (the engine's own
8
+ * lab-driven instancer), `dev/render-census.ts`'s structural scan (what the
9
+ * advisor and `render.families` measure), and the `static-batch` capability's
10
+ * `<Frozen>` (what a game actually installs). A batcher and the advisor that
11
+ * routes people to it MUST agree about what is batchable, or the advisor
12
+ * promises a win the wrapper then declines to take.
13
+ *
14
+ * ── THE UUID TRAP, WHICH IS WHY THIS MODULE EXISTS ──────────────────────────
15
+ * The obvious key for "can these two meshes be drawn as one" is object
16
+ * identity: same `geometry.uuid`, same `material.uuid`. It is also the key
17
+ * that silently batches NOTHING in the shape that matters most. A freshly
18
+ * scaffolded TSX/R3F world writes its materials INLINE —
19
+ *
20
+ * {crates.map((c) => (
21
+ * <mesh key={c.id} position={c.at}>
22
+ * <boxGeometry args={[1, 1, 1]} />
23
+ * <meshStandardMaterial color="#8a6a44" />
24
+ * </mesh>
25
+ * ))}
26
+ *
27
+ * — and every one of those `<meshStandardMaterial>` elements constructs its
28
+ * OWN `THREE.MeshStandardMaterial`. Five hundred crates are five hundred
29
+ * distinct uuids describing one identical appearance. A uuid-keyed grouper
30
+ * reports five hundred families of one, finds nothing to do, and is indistin-
31
+ * guishable from a correct batcher over an unbatchable scene. The engine's own
32
+ * scene construction has the same property, which is why
33
+ * `render/render-batch-system.ts` has keyed on VALUE since it was written.
34
+ *
35
+ * So the key here is the STRUCTURE: geometry `type` + its construction
36
+ * `parameters`, material `type` + the props that decide what the draw looks
37
+ * like. Two independently constructed `BoxGeometry(1,1,1)` +
38
+ * `MeshStandardMaterial({color:'#8a6a44'})` pairs are the same draw, and this
39
+ * module says so.
40
+ *
41
+ * ── WHERE VALUE CANNOT ANSWER, IDENTITY IS THE SAFE FALLBACK ────────────────
42
+ * Twice below, a structural comparison would be a guess, and the answer is
43
+ * `uuid:` — a key nothing else can equal, so the members simply do not group.
44
+ * Not grouping costs a draw call; grouping two things that only LOOK alike
45
+ * renders the wrong picture.
46
+ * - geometry with no `.parameters` (a loaded GLTF mesh, a hand-built
47
+ * `BufferGeometry`): its vertices are its identity and comparing them is
48
+ * not a signature, it is a diff.
49
+ * - a shader material ({@link materialMergeSignature} only): its appearance
50
+ * lives in shader source and uniforms that no fixed prop list can read.
51
+ *
52
+ * ── TWO KEYS, DELIBERATELY ──────────────────────────────────────────────────
53
+ * {@link staticBatchSignature} is `RenderBatchSystem`'s key, extracted here
54
+ * unchanged so there is one owner of the answer rather than two that drift.
55
+ * {@link materialMergeSignature} is STRICTER, and it is what the `static-batch`
56
+ * capability's `<Frozen>` groups by: that path hands ONE material instance to
57
+ * a merged/instanced product, so its members must be interchangeable, not
58
+ * merely similar. The looser key predates it and is kept exactly as it was —
59
+ * see that constant's own note.
60
+ */
61
+ import type * as THREE from 'three';
62
+ /**
63
+ * The `userData` key a node opts OUT of static batching with — the one
64
+ * declaration that beats every measurement:
65
+ *
66
+ * <group name="Beacons" userData={{ staticBatch: false }}>
67
+ *
68
+ * Set on a node, it covers that node's whole subtree (a walker stops there).
69
+ * It is deliberately `userData` and not a component prop: the thing being
70
+ * excluded is a three node, and every authoring lane — TSX, a loaded GLTF, a
71
+ * hand-built graph — can set `userData` on one.
72
+ */
73
+ export declare const STATIC_BATCH_OPT_OUT_KEY = "staticBatch";
74
+ /**
75
+ * Why one node may not be collapsed into a batch. `null` from
76
+ * {@link staticBatchSkipReason} means it may.
77
+ *
78
+ * Each of these is a case where a batched draw would render something DIFFERENT
79
+ * from the originals, not merely a case that is awkward to implement:
80
+ * - `instanced` / `batched` — already one draw; swallowing it would flatten
81
+ * per-instance transforms the batch does not carry.
82
+ * - `skinned` — its vertices are posed by a bone matrix palette every frame;
83
+ * baking one pose freezes the character mid-stride.
84
+ * - `morph-targets` — same, driven by influences instead of bones.
85
+ * - `multi-material` — the geometry's `groups` select a material per range;
86
+ * a batch carries one material.
87
+ * - `transparent` — blending is order-dependent and three sorts TRANSPARENT
88
+ * OBJECTS, not triangles. Collapsing them fixes their relative order to
89
+ * whatever the merge happened to write.
90
+ * - `opted-out` — see {@link STATIC_BATCH_OPT_OUT_KEY}.
91
+ */
92
+ export type StaticBatchSkipReason = 'not-a-mesh' | 'instanced' | 'batched' | 'skinned' | 'no-geometry' | 'multi-material' | 'morph-targets' | 'transparent' | 'opted-out';
93
+ /**
94
+ * Why `node` may not join a static batch, or `null` when it may. Pure, cheap,
95
+ * and the SINGLE owner of that answer — see the module note for why the
96
+ * batcher and the advisor cannot each keep their own copy.
97
+ *
98
+ * Checks the node itself only. Subtree exclusion (an opted-out ancestor) is
99
+ * the caller's walk, because the walkers that need this already prune.
100
+ */
101
+ export declare function staticBatchSkipReason(node: THREE.Object3D): StaticBatchSkipReason | null;
102
+ /**
103
+ * Geometry identity: construction `type` + `parameters` for the parametric
104
+ * primitives, object identity for everything else. See the module note.
105
+ */
106
+ export declare function geometrySignature(geometry: THREE.BufferGeometry): string;
107
+ /**
108
+ * Material identity as `RenderBatchSystem` has always computed it: type plus
109
+ * the standard-material props that change the draw.
110
+ *
111
+ * FROZEN ON PURPOSE. This is one half of {@link staticBatchSignature}, which
112
+ * is a live batcher's grouping key; widening or narrowing it silently
113
+ * regroups that batcher's scenes. New discrimination goes in
114
+ * {@link materialMergeSignature}, which is free to be stricter because
115
+ * stricter only ever means "batches less".
116
+ */
117
+ export declare function materialSignature(material: THREE.Material): string;
118
+ /**
119
+ * One mesh's batch key: geometry, material, and the shadow flags.
120
+ *
121
+ * The shadow flags are part of the key because a batched product carries ONE
122
+ * `castShadow`/`receiveShadow` pair for every member it swallowed — mixing a
123
+ * caster and a non-caster into one draw changes the picture.
124
+ */
125
+ export declare function staticBatchSignature(mesh: THREE.Mesh): string;
126
+ /**
127
+ * Which vertex attributes a geometry carries, and whether it is indexed —
128
+ * the compatibility precondition for `mergeGeometries`, which refuses a batch
129
+ * whose members disagree.
130
+ *
131
+ * Item size is in the key too: two geometries can both have `uv` and disagree
132
+ * about whether it is 2- or 3-component, which merges into silent garbage
133
+ * rather than a refusal.
134
+ */
135
+ export declare function geometryLayoutSignature(geometry: THREE.BufferGeometry): string;
136
+ /**
137
+ * Material identity for a path that will SHARE one material instance between
138
+ * every member it collapses — stricter than {@link materialSignature}.
139
+ *
140
+ * Everything the looser key reads, plus the props that decide the picture
141
+ * without touching colour/roughness/metalness: opacity and the depth/blend
142
+ * state, emissive, the remaining standard maps, and the flags that change the
143
+ * compiled program. A shader material answers with its uuid instead (see the
144
+ * module note): its appearance is source and uniforms, and there is no honest
145
+ * fixed-prop reading of it.
146
+ */
147
+ export declare function materialMergeSignature(material: THREE.Material): string;
148
+ //# sourceMappingURL=structural-signature.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"structural-signature.d.ts","sourceRoot":"","sources":["../../src/render/structural-signature.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAEH,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AAEpC;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,gBAAgB,CAAC;AAEtD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,qBAAqB,GAC7B,YAAY,GACZ,WAAW,GACX,SAAS,GACT,SAAS,GACT,aAAa,GACb,gBAAgB,GAChB,eAAe,GACf,aAAa,GACb,WAAW,CAAC;AAWhB;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,KAAK,CAAC,QAAQ,GAAG,qBAAqB,GAAG,IAAI,CAYxF;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,KAAK,CAAC,cAAc,GAAG,MAAM,CAGxE;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,KAAK,CAAC,QAAQ,GAAG,MAAM,CAKlE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,GAAG,MAAM,CAI7D;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,KAAK,CAAC,cAAc,GAAG,MAAM,CAM9E;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,KAAK,CAAC,QAAQ,GAAG,MAAM,CA2BvE"}
@@ -0,0 +1,193 @@
1
+ /**
2
+ * STRUCTURAL IDENTITY AND ELIGIBILITY for static render batching — the one
3
+ * place that answers "may this draw be collapsed at all?" and "are these two
4
+ * meshes the same thing drawn twice?".
5
+ *
6
+ * Three consumers, which is the whole reason it is a module rather than three
7
+ * private helpers: `render/render-batch-system.ts` (the engine's own
8
+ * lab-driven instancer), `dev/render-census.ts`'s structural scan (what the
9
+ * advisor and `render.families` measure), and the `static-batch` capability's
10
+ * `<Frozen>` (what a game actually installs). A batcher and the advisor that
11
+ * routes people to it MUST agree about what is batchable, or the advisor
12
+ * promises a win the wrapper then declines to take.
13
+ *
14
+ * ── THE UUID TRAP, WHICH IS WHY THIS MODULE EXISTS ──────────────────────────
15
+ * The obvious key for "can these two meshes be drawn as one" is object
16
+ * identity: same `geometry.uuid`, same `material.uuid`. It is also the key
17
+ * that silently batches NOTHING in the shape that matters most. A freshly
18
+ * scaffolded TSX/R3F world writes its materials INLINE —
19
+ *
20
+ * {crates.map((c) => (
21
+ * <mesh key={c.id} position={c.at}>
22
+ * <boxGeometry args={[1, 1, 1]} />
23
+ * <meshStandardMaterial color="#8a6a44" />
24
+ * </mesh>
25
+ * ))}
26
+ *
27
+ * — and every one of those `<meshStandardMaterial>` elements constructs its
28
+ * OWN `THREE.MeshStandardMaterial`. Five hundred crates are five hundred
29
+ * distinct uuids describing one identical appearance. A uuid-keyed grouper
30
+ * reports five hundred families of one, finds nothing to do, and is indistin-
31
+ * guishable from a correct batcher over an unbatchable scene. The engine's own
32
+ * scene construction has the same property, which is why
33
+ * `render/render-batch-system.ts` has keyed on VALUE since it was written.
34
+ *
35
+ * So the key here is the STRUCTURE: geometry `type` + its construction
36
+ * `parameters`, material `type` + the props that decide what the draw looks
37
+ * like. Two independently constructed `BoxGeometry(1,1,1)` +
38
+ * `MeshStandardMaterial({color:'#8a6a44'})` pairs are the same draw, and this
39
+ * module says so.
40
+ *
41
+ * ── WHERE VALUE CANNOT ANSWER, IDENTITY IS THE SAFE FALLBACK ────────────────
42
+ * Twice below, a structural comparison would be a guess, and the answer is
43
+ * `uuid:` — a key nothing else can equal, so the members simply do not group.
44
+ * Not grouping costs a draw call; grouping two things that only LOOK alike
45
+ * renders the wrong picture.
46
+ * - geometry with no `.parameters` (a loaded GLTF mesh, a hand-built
47
+ * `BufferGeometry`): its vertices are its identity and comparing them is
48
+ * not a signature, it is a diff.
49
+ * - a shader material ({@link materialMergeSignature} only): its appearance
50
+ * lives in shader source and uniforms that no fixed prop list can read.
51
+ *
52
+ * ── TWO KEYS, DELIBERATELY ──────────────────────────────────────────────────
53
+ * {@link staticBatchSignature} is `RenderBatchSystem`'s key, extracted here
54
+ * unchanged so there is one owner of the answer rather than two that drift.
55
+ * {@link materialMergeSignature} is STRICTER, and it is what the `static-batch`
56
+ * capability's `<Frozen>` groups by: that path hands ONE material instance to
57
+ * a merged/instanced product, so its members must be interchangeable, not
58
+ * merely similar. The looser key predates it and is kept exactly as it was —
59
+ * see that constant's own note.
60
+ */
61
+ /**
62
+ * The `userData` key a node opts OUT of static batching with — the one
63
+ * declaration that beats every measurement:
64
+ *
65
+ * <group name="Beacons" userData={{ staticBatch: false }}>
66
+ *
67
+ * Set on a node, it covers that node's whole subtree (a walker stops there).
68
+ * It is deliberately `userData` and not a component prop: the thing being
69
+ * excluded is a three node, and every authoring lane — TSX, a loaded GLTF, a
70
+ * hand-built graph — can set `userData` on one.
71
+ */
72
+ export const STATIC_BATCH_OPT_OUT_KEY = 'staticBatch';
73
+ /**
74
+ * Why `node` may not join a static batch, or `null` when it may. Pure, cheap,
75
+ * and the SINGLE owner of that answer — see the module note for why the
76
+ * batcher and the advisor cannot each keep their own copy.
77
+ *
78
+ * Checks the node itself only. Subtree exclusion (an opted-out ancestor) is
79
+ * the caller's walk, because the walkers that need this already prune.
80
+ */
81
+ export function staticBatchSkipReason(node) {
82
+ if (node.userData?.[STATIC_BATCH_OPT_OUT_KEY] === false)
83
+ return 'opted-out';
84
+ const mesh = node;
85
+ if (mesh.isInstancedMesh)
86
+ return 'instanced';
87
+ if (mesh.isBatchedMesh)
88
+ return 'batched';
89
+ if (mesh.isSkinnedMesh)
90
+ return 'skinned';
91
+ if (!mesh.isMesh)
92
+ return 'not-a-mesh';
93
+ if (!mesh.geometry || !mesh.material)
94
+ return 'no-geometry';
95
+ if (Array.isArray(mesh.material))
96
+ return 'multi-material';
97
+ if (Object.keys(mesh.geometry.morphAttributes).length > 0)
98
+ return 'morph-targets';
99
+ if (mesh.material.transparent)
100
+ return 'transparent';
101
+ return null;
102
+ }
103
+ /**
104
+ * Geometry identity: construction `type` + `parameters` for the parametric
105
+ * primitives, object identity for everything else. See the module note.
106
+ */
107
+ export function geometrySignature(geometry) {
108
+ const params = geometry.parameters;
109
+ return params ? `${geometry.type}:${JSON.stringify(params)}` : `uuid:${geometry.uuid}`;
110
+ }
111
+ /**
112
+ * Material identity as `RenderBatchSystem` has always computed it: type plus
113
+ * the standard-material props that change the draw.
114
+ *
115
+ * FROZEN ON PURPOSE. This is one half of {@link staticBatchSignature}, which
116
+ * is a live batcher's grouping key; widening or narrowing it silently
117
+ * regroups that batcher's scenes. New discrimination goes in
118
+ * {@link materialMergeSignature}, which is free to be stricter because
119
+ * stricter only ever means "batches less".
120
+ */
121
+ export function materialSignature(material) {
122
+ const standard = material;
123
+ const color = standard.color?.getHexString?.() ?? '';
124
+ const map = standard.map?.uuid ?? '';
125
+ return `${material.type}:${color}:${standard.roughness ?? ''}:${standard.metalness ?? ''}:${map}:${material.side}:${material.transparent}:${material.vertexColors}`;
126
+ }
127
+ /**
128
+ * One mesh's batch key: geometry, material, and the shadow flags.
129
+ *
130
+ * The shadow flags are part of the key because a batched product carries ONE
131
+ * `castShadow`/`receiveShadow` pair for every member it swallowed — mixing a
132
+ * caster and a non-caster into one draw changes the picture.
133
+ */
134
+ export function staticBatchSignature(mesh) {
135
+ const geometry = mesh.geometry;
136
+ const material = mesh.material;
137
+ return `${geometrySignature(geometry)}|${materialSignature(material)}|${mesh.castShadow ? 1 : 0}${mesh.receiveShadow ? 1 : 0}`;
138
+ }
139
+ /**
140
+ * Which vertex attributes a geometry carries, and whether it is indexed —
141
+ * the compatibility precondition for `mergeGeometries`, which refuses a batch
142
+ * whose members disagree.
143
+ *
144
+ * Item size is in the key too: two geometries can both have `uv` and disagree
145
+ * about whether it is 2- or 3-component, which merges into silent garbage
146
+ * rather than a refusal.
147
+ */
148
+ export function geometryLayoutSignature(geometry) {
149
+ const attributes = Object.keys(geometry.attributes)
150
+ .sort()
151
+ .map((name) => `${name}:${geometry.attributes[name]?.itemSize ?? '?'}`)
152
+ .join(',');
153
+ return `${attributes}${geometry.getIndex() ? '|i' : ''}`;
154
+ }
155
+ /**
156
+ * Material identity for a path that will SHARE one material instance between
157
+ * every member it collapses — stricter than {@link materialSignature}.
158
+ *
159
+ * Everything the looser key reads, plus the props that decide the picture
160
+ * without touching colour/roughness/metalness: opacity and the depth/blend
161
+ * state, emissive, the remaining standard maps, and the flags that change the
162
+ * compiled program. A shader material answers with its uuid instead (see the
163
+ * module note): its appearance is source and uniforms, and there is no honest
164
+ * fixed-prop reading of it.
165
+ */
166
+ export function materialMergeSignature(material) {
167
+ const shader = material;
168
+ if (shader.isShaderMaterial || shader.isRawShaderMaterial)
169
+ return `uuid:${material.uuid}`;
170
+ const rich = material;
171
+ const maps = [rich.normalMap, rich.aoMap, rich.emissiveMap, rich.roughnessMap, rich.metalnessMap]
172
+ .map((map) => map?.uuid ?? '')
173
+ .join(',');
174
+ // A material with a patched `onBeforeCompile` declares its variant through
175
+ // this hook (that is what three itself keys its program cache on), so a
176
+ // non-empty value discriminates here too.
177
+ const program = material.customProgramCacheKey?.() ?? '';
178
+ return [
179
+ materialSignature(material),
180
+ material.opacity,
181
+ material.depthWrite,
182
+ material.depthTest,
183
+ material.alphaTest,
184
+ material.blending,
185
+ material.toneMapped,
186
+ material.visible,
187
+ rich.emissive?.getHexString?.() ?? '',
188
+ rich.flatShading ?? '',
189
+ rich.wireframe ?? '',
190
+ maps,
191
+ program,
192
+ ].join('|');
193
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"dev-layers.d.ts","sourceRoot":"","sources":["../../src/runtime/dev-layers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAOxE"}
1
+ {"version":3,"file":"dev-layers.d.ts","sourceRoot":"","sources":["../../src/runtime/dev-layers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAaxE"}
@@ -33,6 +33,12 @@
33
33
  export function devLayersEnabled(override) {
34
34
  if (override !== undefined)
35
35
  return override;
36
+ // `import.meta` is cast whole, not just its `.env`: this module is reachable
37
+ // from programs whose tsconfig does not pull in `vite/client` (the session
38
+ // client's, for one, which reaches the three adapter transitively), and there
39
+ // `ImportMeta` has no declared `env` at all. The cast keeps the single owner
40
+ // of the dev answer importable from ANY program rather than forcing every
41
+ // downstream tsconfig to adopt Vite's ambient types.
36
42
  const env = import.meta.env;
37
43
  if (env?.DEV === true)
38
44
  return true;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.8",
5
+ "version": "0.5.9",
6
6
  "description": "Readable TypeScript game engine and universal host for Three.js, PixiJS, and React games.",
7
7
  "keywords": [
8
8
  "game-engine",
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "notes": "Grepped packages/engine/src for ccd/CCD/setCcdEnabled: zero matches. The engine constructs no rigid bodies itself — games build their own descs via ctx.RAPIER — and nothing engine-side ever enables ccd. Workaround: keep dynamic-body per-step travel small relative to the thinnest collider, or add intermediate colliders; a game may also call setCcdEnabled on its own bodies directly.",
17
17
  "evidenceHashes": {
18
- "packages/engine/src/adapter/setup-three-root-adapter.ts": "fd3035e557db23e002e52707145a6e17bb3ad15767da74ec8fbafd40879d1d34"
18
+ "packages/engine/src/adapter/setup-three-root-adapter.ts": "b55b5aa1b032a813ddeaa1dedb6488326ed4cf3497e8cf76a07bada1e40243ea"
19
19
  }
20
20
  },
21
21
  {
@@ -67,12 +67,12 @@
67
67
  "status": "supported-with-tiers",
68
68
  "claim": "Unmodified OSS PixiJS games run via the canvas ingest seam: manifest 'iframe-reachable' + bundleUrl — in-realm capture, editor edits, overlay persistence. extraDeps must be registry-known (unknown throws); capture failure degrades loudly.",
69
69
  "evidence": [
70
- "vendor/games/verify-unaltered.mjs (zero-diff vendoring gate)",
70
+ "vendor/games/verify-unaltered.mjs (vendored-bytes gate: zero UNRECORDED diff byte-identical files, plus any recorded per-file patch that must reverse-apply to the pinned upstream)",
71
71
  "packages/engine/src/manifest/schema.ts"
72
72
  ],
73
73
  "notes": "Track P shipped 2026-07-03; proven: bubbo-bubbo/puzzling-potions/flappy-pixi. Tiers/limits: ARCHITECTURE-CORE.md.",
74
74
  "evidenceHashes": {
75
- "vendor/games/verify-unaltered.mjs": "c41ce285aaa26a8fd0e6f5e20385b434d6fc73fc3f39ac1a61204d7141166201",
75
+ "vendor/games/verify-unaltered.mjs": "df875b09d698b3cfc03443ff3dba4929f5f08ea8913009d4fae253d478282312",
76
76
  "packages/engine/src/manifest/schema.ts": "63d3f6fdbe3e9323ecbd4910c540d8a77e57d95e9044016424b46ca15a5c0bea"
77
77
  }
78
78
  },
@@ -82,12 +82,12 @@
82
82
  "status": "supported-with-caveats",
83
83
  "claim": "Unmodified native-React DOM games run via the manifest `ingest-react` identity: vendored byte-for-byte (vendor/games/, zero-diff CI), mounted through the react world stack under the host React 19 (deduped). Embed-only in v1 — hosted, sized, disposed; NO authoring (the editor never writes JSX back into an unmodified game, D-N4).",
84
84
  "evidence": [
85
- "vendor/games/verify-unaltered.mjs (zero-diff vendoring gate)",
85
+ "vendor/games/verify-unaltered.mjs (vendored-bytes gate: zero UNRECORDED diff byte-identical files, plus any recorded per-file patch that must reverse-apply to the pinned upstream)",
86
86
  "packages/editor/src/adapter-resolver.ts"
87
87
  ],
88
88
  "notes": "Scope is native-DOM React ONLY (D-N1): R3F/@pixi/react games are canvas games and ride the three.js/pixi seams instead. Generality limit: ONE proven game (react-rpg — menu-to-dungeon e2e under React 19; both game-2 candidates rejected on evidence, R-N7). Game deps must be host-installed root deps (D-N6). Open R-N6: vendored react games' raw window listeners bypass the play-mode input gate.",
89
89
  "evidenceHashes": {
90
- "vendor/games/verify-unaltered.mjs": "c41ce285aaa26a8fd0e6f5e20385b434d6fc73fc3f39ac1a61204d7141166201",
90
+ "vendor/games/verify-unaltered.mjs": "df875b09d698b3cfc03443ff3dba4929f5f08ea8913009d4fae253d478282312",
91
91
  "packages/editor/src/adapter-resolver.ts": "ce88071c9a4061df0a3b7edef24031bf81704ae2c3ede62ed6bf43d4f34ac556"
92
92
  }
93
93
  },