@vgai/engine 0.5.12 → 0.5.14

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 (192) hide show
  1. package/dist/adapter/adapter-module.d.ts +447 -0
  2. package/dist/adapter/adapter-module.d.ts.map +1 -0
  3. package/dist/adapter/adapter-module.js +308 -0
  4. package/dist/adapter/authoring.d.ts +357 -10
  5. package/dist/adapter/authoring.d.ts.map +1 -1
  6. package/dist/adapter/authoring.js +87 -1
  7. package/dist/adapter/constraint.d.ts +9 -3
  8. package/dist/adapter/constraint.d.ts.map +1 -1
  9. package/dist/adapter/finders/finder-result.d.ts +28 -0
  10. package/dist/adapter/finders/finder-result.d.ts.map +1 -0
  11. package/dist/adapter/finders/finder-result.js +10 -0
  12. package/dist/adapter/finders/index.d.ts +38 -0
  13. package/dist/adapter/finders/index.d.ts.map +1 -0
  14. package/dist/adapter/finders/index.js +41 -0
  15. package/dist/adapter/finders/prefabs-from-stories.d.ts +64 -0
  16. package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -0
  17. package/dist/adapter/finders/prefabs-from-stories.js +92 -0
  18. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +67 -0
  19. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -0
  20. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +226 -0
  21. package/dist/adapter/first-party-systems.d.ts.map +1 -1
  22. package/dist/adapter/first-party-systems.js +29 -0
  23. package/dist/adapter/index.d.ts +7 -3
  24. package/dist/adapter/index.d.ts.map +1 -1
  25. package/dist/adapter/index.js +5 -2
  26. package/dist/adapter/ingest/contract-system-adapters.d.ts +24 -1
  27. package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -1
  28. package/dist/adapter/ingest/contract-system-adapters.js +100 -34
  29. package/dist/adapter/ingest/game-contract.d.ts +188 -2
  30. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  31. package/dist/adapter/ingest/game-contract.js +107 -0
  32. package/dist/adapter/ingest/mount-readiness.d.ts +84 -0
  33. package/dist/adapter/ingest/mount-readiness.d.ts.map +1 -0
  34. package/dist/adapter/ingest/mount-readiness.js +81 -0
  35. package/dist/adapter/ingest/scene-capture.d.ts +91 -2
  36. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  37. package/dist/adapter/ingest/scene-capture.js +110 -18
  38. package/dist/adapter/ingest/structural-ids.d.ts +11 -5
  39. package/dist/adapter/ingest/structural-ids.d.ts.map +1 -1
  40. package/dist/adapter/ingest/structural-ids.js +18 -9
  41. package/dist/adapter/ingest/upstream-pin.d.ts +4 -2
  42. package/dist/adapter/ingest/upstream-pin.d.ts.map +1 -1
  43. package/dist/adapter/ingest/upstream-pin.js +4 -2
  44. package/dist/adapter/ingest/visible-capture-window.d.ts +116 -0
  45. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -0
  46. package/dist/adapter/ingest/visible-capture-window.js +141 -0
  47. package/dist/adapter/loop-gate-report.d.ts +3 -4
  48. package/dist/adapter/loop-gate-report.d.ts.map +1 -1
  49. package/dist/adapter/loop-gate-report.js +3 -4
  50. package/dist/adapter/root-adapter.d.ts +22 -2
  51. package/dist/adapter/root-adapter.d.ts.map +1 -1
  52. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  53. package/dist/adapter/setup-three-root-adapter.js +15 -0
  54. package/dist/adapter/system-adapter.d.ts +161 -2
  55. package/dist/adapter/system-adapter.d.ts.map +1 -1
  56. package/dist/adapter/system-adapter.js +18 -1
  57. package/dist/animation/camera-ownership.d.ts +4 -2
  58. package/dist/animation/camera-ownership.d.ts.map +1 -1
  59. package/dist/animation/camera-ownership.js +58 -1
  60. package/dist/animation/runtime-inspection.d.ts +70 -0
  61. package/dist/animation/runtime-inspection.d.ts.map +1 -0
  62. package/dist/animation/runtime-inspection.js +25 -0
  63. package/dist/animation/xstate-animation-binding.d.ts +9 -1
  64. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  65. package/dist/animation/xstate-animation-binding.js +48 -1
  66. package/dist/asset-formats/material.d.ts +1 -1
  67. package/dist/asset-formats/mesh.d.ts +1 -1
  68. package/dist/asset-formats/particles.d.ts +13 -0
  69. package/dist/asset-formats/particles.d.ts.map +1 -1
  70. package/dist/asset-formats/particles.js +28 -0
  71. package/dist/behavior/xstate-inspection.d.ts +24 -0
  72. package/dist/behavior/xstate-inspection.d.ts.map +1 -0
  73. package/dist/behavior/xstate-inspection.js +25 -0
  74. package/dist/core/game-loop.d.ts +7 -5
  75. package/dist/core/game-loop.d.ts.map +1 -1
  76. package/dist/core/game-loop.js +39 -18
  77. package/dist/core/types.d.ts +11 -12
  78. package/dist/core/types.d.ts.map +1 -1
  79. package/dist/data/data-asset.js +1 -1
  80. package/dist/dev/instruments.d.ts.map +1 -1
  81. package/dist/dev/instruments.js +5 -1
  82. package/dist/dev/render-debug-adapter.d.ts +19 -2
  83. package/dist/dev/render-debug-adapter.d.ts.map +1 -1
  84. package/dist/dev/render-debug-adapter.js +10 -0
  85. package/dist/ecs/user-data.d.ts +10 -2
  86. package/dist/ecs/user-data.d.ts.map +1 -1
  87. package/dist/ecs/user-data.js +6 -2
  88. package/dist/input/binding-identity.d.ts +10 -0
  89. package/dist/input/binding-identity.d.ts.map +1 -0
  90. package/dist/input/binding-identity.js +36 -0
  91. package/dist/input/input-manager.d.ts +18 -185
  92. package/dist/input/input-manager.d.ts.map +1 -1
  93. package/dist/input/input-manager.js +78 -68
  94. package/dist/manifest/load.d.ts +14 -0
  95. package/dist/manifest/load.d.ts.map +1 -1
  96. package/dist/manifest/load.js +6 -1
  97. package/dist/manifest/schema.d.ts +18 -0
  98. package/dist/manifest/schema.d.ts.map +1 -1
  99. package/dist/manifest/schema.js +64 -7
  100. package/dist/pixi/authoring.d.ts +27 -0
  101. package/dist/pixi/authoring.d.ts.map +1 -1
  102. package/dist/pixi/authoring.js +10 -3
  103. package/dist/pixi/index.d.ts +1 -1
  104. package/dist/pixi/index.d.ts.map +1 -1
  105. package/dist/pixi/index.js +1 -1
  106. package/dist/pixi/ingest.d.ts +8 -0
  107. package/dist/pixi/ingest.d.ts.map +1 -1
  108. package/dist/pixi/ingest.js +10 -1
  109. package/dist/pixi/render-pass-bracket.d.ts +56 -0
  110. package/dist/pixi/render-pass-bracket.d.ts.map +1 -0
  111. package/dist/pixi/render-pass-bracket.js +98 -0
  112. package/dist/pixi/scene-capture.d.ts +32 -1
  113. package/dist/pixi/scene-capture.d.ts.map +1 -1
  114. package/dist/pixi/scene-capture.js +21 -9
  115. package/dist/pixi/system-adapters.d.ts +29 -0
  116. package/dist/pixi/system-adapters.d.ts.map +1 -1
  117. package/dist/pixi/system-adapters.js +27 -0
  118. package/dist/render/particles-factory.d.ts +16 -2
  119. package/dist/render/particles-factory.d.ts.map +1 -1
  120. package/dist/render/particles-factory.js +88 -5
  121. package/dist/render/soft-particle-depth.d.ts +149 -0
  122. package/dist/render/soft-particle-depth.d.ts.map +1 -0
  123. package/dist/render/soft-particle-depth.js +305 -0
  124. package/dist/render/voxel-surface.d.ts +117 -0
  125. package/dist/render/voxel-surface.d.ts.map +1 -0
  126. package/dist/render/voxel-surface.js +263 -0
  127. package/dist/runtime/create-runtime.js +7 -0
  128. package/dist/runtime/debug-bridge.d.ts +15 -15
  129. package/dist/runtime/debug-bridge.d.ts.map +1 -1
  130. package/dist/runtime/debug-bridge.js +27 -26
  131. package/dist/runtime/game.d.ts.map +1 -1
  132. package/dist/runtime/game.js +1 -2
  133. package/dist/setup/setup-particles.d.ts.map +1 -1
  134. package/dist/setup/setup-particles.js +9 -0
  135. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  136. package/dist/world3d-react/r3f-adapter.js +42 -3
  137. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  138. package/dist/world3d-react/rapier-physics-bridge.js +201 -11
  139. package/dist-config/config.js.map +1 -1
  140. package/package.json +4 -2
  141. package/schemas/engine-capabilities.json +12 -12
  142. package/schemas/vgai-project.schema.json +41 -2
  143. package/src/adapter/adapter-module.ts +607 -0
  144. package/src/adapter/authoring.ts +440 -10
  145. package/src/adapter/constraint.ts +10 -3
  146. package/src/adapter/finders/finder-result.ts +29 -0
  147. package/src/adapter/finders/index.ts +62 -0
  148. package/src/adapter/finders/prefabs-from-stories.ts +142 -0
  149. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +304 -0
  150. package/src/adapter/first-party-systems.ts +30 -0
  151. package/src/adapter/index.ts +32 -1
  152. package/src/adapter/ingest/contract-system-adapters.ts +116 -32
  153. package/src/adapter/ingest/game-contract.ts +265 -2
  154. package/src/adapter/ingest/mount-readiness.ts +140 -0
  155. package/src/adapter/ingest/scene-capture.ts +199 -22
  156. package/src/adapter/ingest/structural-ids.ts +21 -9
  157. package/src/adapter/ingest/upstream-pin.ts +4 -2
  158. package/src/adapter/ingest/visible-capture-window.ts +213 -0
  159. package/src/adapter/loop-gate-report.ts +3 -4
  160. package/src/adapter/root-adapter.ts +22 -2
  161. package/src/adapter/setup-three-root-adapter.ts +16 -0
  162. package/src/adapter/system-adapter.ts +188 -2
  163. package/src/animation/camera-ownership.ts +68 -3
  164. package/src/animation/runtime-inspection.ts +104 -0
  165. package/src/animation/xstate-animation-binding.ts +53 -1
  166. package/src/asset-formats/particles.ts +31 -0
  167. package/src/behavior/xstate-inspection.ts +51 -0
  168. package/src/core/game-loop.ts +39 -17
  169. package/src/core/types.ts +11 -12
  170. package/src/data/data-asset.ts +1 -1
  171. package/src/dev/instruments.ts +5 -1
  172. package/src/dev/render-debug-adapter.ts +22 -2
  173. package/src/ecs/user-data.ts +10 -2
  174. package/src/input/binding-identity.ts +37 -0
  175. package/src/input/input-manager.ts +93 -68
  176. package/src/manifest/load.ts +23 -2
  177. package/src/manifest/schema.ts +76 -7
  178. package/src/pixi/authoring.ts +37 -5
  179. package/src/pixi/index.ts +2 -0
  180. package/src/pixi/ingest.ts +15 -2
  181. package/src/pixi/render-pass-bracket.ts +117 -0
  182. package/src/pixi/scene-capture.ts +63 -13
  183. package/src/pixi/system-adapters.ts +41 -0
  184. package/src/render/particles-factory.ts +89 -6
  185. package/src/render/soft-particle-depth.ts +336 -0
  186. package/src/render/voxel-surface.ts +358 -0
  187. package/src/runtime/create-runtime.ts +7 -0
  188. package/src/runtime/debug-bridge.ts +32 -31
  189. package/src/runtime/game.ts +1 -2
  190. package/src/setup/setup-particles.ts +9 -0
  191. package/src/world3d-react/r3f-adapter.tsx +48 -2
  192. package/src/world3d-react/rapier-physics-bridge.tsx +249 -11
@@ -0,0 +1,358 @@
1
+ /**
2
+ * A face-culled surface mesh for a dense grid of cubic cells, as one real `THREE.BufferGeometry`.
3
+ *
4
+ * A game that stores its world as a grid of cubes — terrain a player digs, a builder's block
5
+ * palette, a level authored as a 3D tilemap — cannot draw a `BoxGeometry` per cell: a modest
6
+ * 80×7×80 volume is 44 800 boxes, and the ones buried inside the solid are invisible forever. The
7
+ * fix is a single surface built from only the faces that touch an empty neighbour. That is what
8
+ * this file is, and it is the whole of it.
9
+ *
10
+ * ## It is a HELPER, not a wrapper
11
+ *
12
+ * `buildVoxelSurface` takes plain arrays and hands back three's own `BufferGeometry`. Every call
13
+ * after it is three's API — pick your own material, wrap it in a `Mesh` or an `InstancedMesh`,
14
+ * `.translate()` it, hand it to Rapier's trimesh collider. Nothing here holds a scene, a camera, a
15
+ * frame loop or a cache, and nothing here is a class you inherit from.
16
+ *
17
+ * ## The rung audit that put ~200 lines here instead of a dependency (2026-08-14)
18
+ *
19
+ * Checked against the npm registry on the day this landed, not from memory:
20
+ *
21
+ * - **`voxel-mesh` 0.3.0** (voxel.js) — last publish **2013-06-13**. Its `index.js` builds a
22
+ * `THREE.Geometry` out of `THREE.Face3`, both REMOVED from three in r125. It cannot run against
23
+ * this repo's three at all.
24
+ * - **`voxel-mesher` 0.14.4** (2016-03-26) and **`greedy-mesher` 1.0.3** (2016-02-12) — the
25
+ * voxel.js meshing pair. Both are `ndarray`-shaped, untyped, and stop at a list of quads: the
26
+ * `BufferGeometry` is still yours to write. `voxel-mesher` additionally pulls a WebGL1 buffer
27
+ * stack (`gl-buffer`, `gl-vao`) that has nothing to do with three.
28
+ * - **`voxel-engine` 0.20.2** (2015-04-11) — a whole game framework (20 dependencies, its own
29
+ * camera, controls and physics). The thing this rule bans.
30
+ * - **`@voxelize/core` 3.0.0** (2026-08-03) — the one MAINTAINED option, and the one to say no to
31
+ * deliberately. It is a client for a multiplayer voxel platform: `socket.io-client`, its own
32
+ * wire protocol, its own physics engine and a WASM mesher fed by its own chunk/world objects.
33
+ * You do not call its mesher; you build your game inside it. Adopting it to draw a static grid
34
+ * would put a platform between the game and three.
35
+ *
36
+ * So: every maintained option is an ENGINE you build inside, and every option that is a helper died
37
+ * with voxel.js a decade ago against a three that no longer exists. The general shape *is* ready —
38
+ * "cull the hidden faces of a dense grid" has been settled since 2012 — it is the packaging that is
39
+ * not. Hence a helper here, in host vocabulary, that hands back three's own object.
40
+ *
41
+ * ## What is deliberately NOT here
42
+ *
43
+ * **Greedy meshing.** Merging coplanar same-type faces into larger quads is the standard next step
44
+ * and it does not fall out of this one: it needs a per-slice sweep with its own visited mask and
45
+ * its own bugs, and it changes the vertex↔cell relationship that per-face culling keeps exact.
46
+ * Per-face culling already removes the interior, which is the order-of-magnitude win. Add greedy
47
+ * meshing the day a measured frame budget asks for it, as its own function beside this one.
48
+ *
49
+ * **Lighting, ambient occlusion, textures, atlases, transparency sorting and chunking.** Each is a
50
+ * real decision a game makes for itself; this returns geometry and gets out of the way.
51
+ */
52
+ import { BufferAttribute, BufferGeometry, Color } from 'three';
53
+
54
+ /**
55
+ * A dense grid of cubic cells.
56
+ *
57
+ * `cells` is indexed **Y-major, then Z, then X** — `((y * sizeZ) + z) * sizeX + x`. Y outermost is
58
+ * the ordering that makes each horizontal LAYER contiguous, which is what lets a mostly-flat world
59
+ * ship as a handful of runs (see {@link expandVoxelRuns}) instead of a megabyte of literals.
60
+ */
61
+ export interface VoxelVolume {
62
+ /** Cell counts along X, Y and Z. */
63
+ readonly size: readonly [number, number, number];
64
+ /** `sizeX * sizeY * sizeZ` type ids. `0` is empty unless {@link VoxelSurfaceOptions.emptyType}. */
65
+ readonly cells: ArrayLike<number>;
66
+ /** World position of the volume's minimum corner. Defaults to the origin. */
67
+ readonly origin?: readonly [number, number, number];
68
+ /** World edge length of one cell. Defaults to 1. */
69
+ readonly cellSize?: number;
70
+ }
71
+
72
+ export interface VoxelSurfaceOptions {
73
+ /**
74
+ * Type id → `0xRRGGBB`, written into the geometry's `color` attribute. Supply it and the mesh
75
+ * draws with `vertexColors: true` in one draw call; omit it and the geometry carries no colour
76
+ * attribute at all. A solid type absent from the map is an ERROR, not a silent default: a
77
+ * missing colour is the shape of a forgotten row, and a mesh that quietly paints it white looks
78
+ * like a lighting bug three files away.
79
+ */
80
+ readonly colors?: ReadonlyMap<number, number>;
81
+ /** The type id that means "no cell here". Defaults to `0`. */
82
+ readonly emptyType?: number;
83
+ /**
84
+ * Whether cells just outside the volume count as solid. `false` (the default) walls the volume
85
+ * in — the right answer for a standalone model. `true` leaves the boundary open, which is what a
86
+ * chunk meshed beside its neighbours wants.
87
+ */
88
+ readonly closedBoundary?: boolean;
89
+ }
90
+
91
+ export interface VoxelSurface {
92
+ /** Indexed, with `position`, `normal` and (when colours were supplied) `color`. */
93
+ readonly geometry: BufferGeometry;
94
+ /** Faces that survived culling. `vertices` is `4 ×` this, `triangles` `2 ×`. */
95
+ readonly quads: number;
96
+ readonly vertices: number;
97
+ readonly triangles: number;
98
+ /** Cells whose type is not the empty type — the input's own size, for a sanity check. */
99
+ readonly solidCells: number;
100
+ /** Faces contributed per type id, in ascending id order. */
101
+ readonly quadsByType: ReadonlyMap<number, number>;
102
+ }
103
+
104
+ export class VoxelSurfaceError extends Error {
105
+ constructor(message: string) {
106
+ super(message);
107
+ this.name = 'VoxelSurfaceError';
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The six faces of the unit cell, each as four corners in counter-clockwise order seen from
113
+ * OUTSIDE — which is the winding three's default `FrontSide` draws.
114
+ */
115
+ const FACES: readonly {
116
+ readonly normal: readonly [number, number, number];
117
+ readonly corners: readonly (readonly [number, number, number])[];
118
+ }[] = [
119
+ {
120
+ normal: [1, 0, 0],
121
+ corners: [
122
+ [1, 0, 1],
123
+ [1, 0, 0],
124
+ [1, 1, 0],
125
+ [1, 1, 1],
126
+ ],
127
+ },
128
+ {
129
+ normal: [-1, 0, 0],
130
+ corners: [
131
+ [0, 0, 0],
132
+ [0, 0, 1],
133
+ [0, 1, 1],
134
+ [0, 1, 0],
135
+ ],
136
+ },
137
+ {
138
+ normal: [0, 1, 0],
139
+ corners: [
140
+ [0, 1, 1],
141
+ [1, 1, 1],
142
+ [1, 1, 0],
143
+ [0, 1, 0],
144
+ ],
145
+ },
146
+ {
147
+ normal: [0, -1, 0],
148
+ corners: [
149
+ [0, 0, 0],
150
+ [1, 0, 0],
151
+ [1, 0, 1],
152
+ [0, 0, 1],
153
+ ],
154
+ },
155
+ {
156
+ normal: [0, 0, 1],
157
+ corners: [
158
+ [0, 0, 1],
159
+ [1, 0, 1],
160
+ [1, 1, 1],
161
+ [0, 1, 1],
162
+ ],
163
+ },
164
+ {
165
+ normal: [0, 0, -1],
166
+ corners: [
167
+ [1, 0, 0],
168
+ [0, 0, 0],
169
+ [0, 1, 0],
170
+ [1, 1, 0],
171
+ ],
172
+ },
173
+ ];
174
+
175
+ /**
176
+ * Expand `[count, type, count, type, …]` run pairs into a dense cell array.
177
+ *
178
+ * A voxel volume is enormous and almost entirely repetitive, so the compact form is what a game
179
+ * SHIPS — in a source file, in a save, over the wire — and the dense form is what it meshes. The
180
+ * total of the counts must be exactly `cellCount`: a short or long run list is a corrupt volume,
181
+ * and silently padding it with empties would mesh a plausible wrong world.
182
+ */
183
+ export function expandVoxelRuns(
184
+ runs: ArrayLike<number>,
185
+ cellCount: number,
186
+ ): Uint16Array | Uint32Array {
187
+ if (runs.length % 2 !== 0) {
188
+ throw new VoxelSurfaceError(
189
+ `a run list is [count, type] pairs, so its length must be even; got ${runs.length}`,
190
+ );
191
+ }
192
+ let widest = 0;
193
+ let total = 0;
194
+ for (let pair = 0; pair < runs.length; pair += 2) {
195
+ const count = runs[pair] as number;
196
+ if (!Number.isInteger(count) || count <= 0) {
197
+ throw new VoxelSurfaceError(`run ${pair / 2} has a count of ${count}; counts are positive`);
198
+ }
199
+ total += count;
200
+ widest = Math.max(widest, runs[pair + 1] as number);
201
+ }
202
+ if (total !== cellCount) {
203
+ throw new VoxelSurfaceError(`the runs cover ${total} cells; the volume holds ${cellCount}`);
204
+ }
205
+ const cells = widest > 0xffff ? new Uint32Array(cellCount) : new Uint16Array(cellCount);
206
+ let at = 0;
207
+ for (let pair = 0; pair < runs.length; pair += 2) {
208
+ const count = runs[pair] as number;
209
+ cells.fill(runs[pair + 1] as number, at, at + count);
210
+ at += count;
211
+ }
212
+ return cells;
213
+ }
214
+
215
+ /** The volume's size, after checking that the cell array is exactly the size it claims. */
216
+ function assertVolumeShape(volume: VoxelVolume): readonly [number, number, number] {
217
+ const [sizeX, sizeY, sizeZ] = volume.size;
218
+ if (sizeX <= 0 || sizeY <= 0 || sizeZ <= 0) {
219
+ throw new VoxelSurfaceError(`a volume needs a positive size on every axis; got ${volume.size}`);
220
+ }
221
+ const cellCount = sizeX * sizeY * sizeZ;
222
+ if (volume.cells.length !== cellCount) {
223
+ throw new VoxelSurfaceError(
224
+ `a ${sizeX}×${sizeY}×${sizeZ} volume holds ${cellCount} cells; got ${volume.cells.length}`,
225
+ );
226
+ }
227
+ return [sizeX, sizeY, sizeZ];
228
+ }
229
+
230
+ /** Every array the walk appends to, so the per-cell step is a plain function. */
231
+ interface SurfaceBuffers {
232
+ readonly positions: number[];
233
+ readonly normals: number[];
234
+ readonly colors: number[];
235
+ readonly indices: number[];
236
+ readonly quadsByType: Map<number, number>;
237
+ }
238
+
239
+ /** The linear-space vertex colour for one type, or `undefined` when colours were not asked for. */
240
+ function vertexColor(
241
+ colors: ReadonlyMap<number, number> | undefined,
242
+ type: number,
243
+ where: string,
244
+ into: Color,
245
+ ): Color | undefined {
246
+ if (colors === undefined) return undefined;
247
+ const hex = colors.get(type);
248
+ if (hex === undefined) {
249
+ throw new VoxelSurfaceError(
250
+ `no colour for type ${type}, which cell ${where} holds — every solid type in the volume ` +
251
+ 'needs a row, so a forgotten one fails here rather than drawing white',
252
+ );
253
+ }
254
+ // `setHex` defaults to `SRGBColorSpace` and converts INTO the working space itself, which is
255
+ // exactly what a material does with a colour uniform. Do not convert again on top of it: an
256
+ // extra `convertSRGBToLinear()` here squared the conversion and drew a mid-green world in near
257
+ // black, with every channel still plausible enough to read as a lighting problem.
258
+ return into.setHex(hex);
259
+ }
260
+
261
+ /** Append the faces of one solid cell that face an empty neighbour. Returns how many. */
262
+ function emitCellFaces(
263
+ buffers: SurfaceBuffers,
264
+ cell: readonly [number, number, number],
265
+ type: number,
266
+ color: Color | undefined,
267
+ solidAt: (x: number, y: number, z: number) => boolean,
268
+ place: (x: number, y: number, z: number) => [number, number, number],
269
+ ): number {
270
+ const [x, y, z] = cell;
271
+ let emitted = 0;
272
+ for (const face of FACES) {
273
+ const [nx, ny, nz] = face.normal;
274
+ if (solidAt(x + nx, y + ny, z + nz)) continue;
275
+ const base = buffers.positions.length / 3;
276
+ for (const [cx, cy, cz] of face.corners) {
277
+ buffers.positions.push(...place(x + cx, y + cy, z + cz));
278
+ buffers.normals.push(nx, ny, nz);
279
+ if (color !== undefined) buffers.colors.push(color.r, color.g, color.b);
280
+ }
281
+ buffers.indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
282
+ buffers.quadsByType.set(type, (buffers.quadsByType.get(type) ?? 0) + 1);
283
+ emitted += 1;
284
+ }
285
+ return emitted;
286
+ }
287
+
288
+ /**
289
+ * Build the visible surface of a voxel volume: one indexed `BufferGeometry` carrying every face
290
+ * that touches an empty neighbour, and nothing else.
291
+ */
292
+ export function buildVoxelSurface(
293
+ volume: VoxelVolume,
294
+ options: VoxelSurfaceOptions = {},
295
+ ): VoxelSurface {
296
+ const [sizeX, sizeY, sizeZ] = assertVolumeShape(volume);
297
+ const empty = options.emptyType ?? 0;
298
+ const openBoundary = options.closedBoundary !== true;
299
+ const scale = volume.cellSize ?? 1;
300
+ const [originX, originY, originZ] = volume.origin ?? [0, 0, 0];
301
+ const cells = volume.cells;
302
+
303
+ const at = (x: number, y: number, z: number): number =>
304
+ cells[(y * sizeZ + z) * sizeX + x] as number;
305
+ const solidAt = (x: number, y: number, z: number): boolean => {
306
+ const outsideVolume = x < 0 || y < 0 || z < 0 || x >= sizeX || y >= sizeY || z >= sizeZ;
307
+ return outsideVolume ? !openBoundary : at(x, y, z) !== empty;
308
+ };
309
+ const place = (x: number, y: number, z: number): [number, number, number] => [
310
+ originX + x * scale,
311
+ originY + y * scale,
312
+ originZ + z * scale,
313
+ ];
314
+
315
+ const buffers: SurfaceBuffers = {
316
+ positions: [],
317
+ normals: [],
318
+ colors: [],
319
+ indices: [],
320
+ quadsByType: new Map(),
321
+ };
322
+ const scratch = new Color();
323
+ let solidCells = 0;
324
+ let quads = 0;
325
+ for (let y = 0; y < sizeY; y += 1) {
326
+ for (let z = 0; z < sizeZ; z += 1) {
327
+ for (let x = 0; x < sizeX; x += 1) {
328
+ const type = at(x, y, z);
329
+ if (type === empty) continue;
330
+ solidCells += 1;
331
+ const color = vertexColor(options.colors, type, `(${x}, ${y}, ${z})`, scratch);
332
+ quads += emitCellFaces(buffers, [x, y, z], type, color, solidAt, place);
333
+ }
334
+ }
335
+ }
336
+
337
+ const geometry = new BufferGeometry();
338
+ geometry.setAttribute('position', new BufferAttribute(new Float32Array(buffers.positions), 3));
339
+ geometry.setAttribute('normal', new BufferAttribute(new Float32Array(buffers.normals), 3));
340
+ if (options.colors !== undefined) {
341
+ geometry.setAttribute('color', new BufferAttribute(new Float32Array(buffers.colors), 3));
342
+ }
343
+ const vertices = buffers.positions.length / 3;
344
+ const indices =
345
+ vertices > 0xffff ? new Uint32Array(buffers.indices) : new Uint16Array(buffers.indices);
346
+ geometry.setIndex(new BufferAttribute(indices, 1));
347
+ geometry.computeBoundingSphere();
348
+ geometry.computeBoundingBox();
349
+
350
+ return {
351
+ geometry,
352
+ quads,
353
+ vertices,
354
+ triangles: quads * 2,
355
+ solidCells,
356
+ quadsByType: new Map([...buffers.quadsByType].sort(([a], [b]) => a - b)),
357
+ };
358
+ }
@@ -832,6 +832,13 @@ async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameS
832
832
  const isReact = kindById.get(entry.id) === 'dom';
833
833
  const surface = document.createElement(isReact ? 'div' : 'canvas') as HTMLElement;
834
834
  surface.dataset['vgaiRootSurface'] = 'true';
835
+ // WHICH ROOT THIS SURFACE PRESENTS — the host mounted it, so the host
836
+ // knows, and stamping it here is the difference between a declaration and
837
+ // the guess every late reader had to make instead ("the first <canvas> in
838
+ // DOM order is the game's"). Read by `packages/editor/src/presentation-
839
+ // surface.ts`, which is the one door the capture/staleness/screenshot
840
+ // sites now ask (ARCHITECTURE-CORE §The editor protocol, zero inference).
841
+ surface.dataset['vgaiRootId'] = entry.id;
835
842
  // The capture's marker (`composite-screenshot.ts` reads exactly this
836
843
  // attribute): a dev layer is excluded from a play capture unless the
837
844
  // capture asks for it, because a contaminated "does the game look right"
@@ -104,10 +104,10 @@ export interface VgaiDebugInputHandle {
104
104
  * `state` from another). */
105
105
  export interface VgaiDebugSnapshot {
106
106
  /** `loopLiveness` (issue #175): the REAL `GameLoop.liveness` behind this
107
- * session — `'hidden-paused'` while the T2.1 idle throttle has stopped
108
- * the loop (tab hidden), `null` only when no loop is wired at all (a
109
- * bare debug-registry test stand-in with no real `Game`). Never a
110
- * fabricated `'running'`. */
107
+ * session — `'loop-starved'` when no recent host rAF progress was observed,
108
+ * `null` only when no loop is wired at all (a bare debug-registry test
109
+ * stand-in with no real `Game`). Never a fabricated `'running'` and never
110
+ * a conclusion about tab visibility. */
111
111
  time: { simSeconds: number; tick: number; loopLiveness: GameLoopLiveness | null };
112
112
  state: Record<string, unknown>;
113
113
  events: TickStampedEvent[];
@@ -233,28 +233,29 @@ const HOLD_FOR_POLL_MS = 50;
233
233
  * the same neighborhood. */
234
234
  const HOLD_FOR_STALL_POLL_LIMIT = 100;
235
235
 
236
- /** Ticks driven per synchronous hidden-tab batch. Sized like
236
+ /** Ticks driven per synchronous starved-loop batch. Sized like
237
237
  * `fast-forward.ts`'s own batching rationale: big enough that per-batch
238
238
  * bookkeeping is negligible, small enough that one batch of a complex game's
239
239
  * phases stays a short synchronous burst. */
240
- const HIDDEN_DRIVE_BATCH_TICKS = 60;
240
+ const STARVED_DRIVE_BATCH_TICKS = 60;
241
241
 
242
- /** Hard ceiling on hidden-drive batches for ONE hold — `HIDDEN_DRIVE_BATCH_TICKS
242
+ /** Hard ceiling on starved-drive batches for ONE hold — `STARVED_DRIVE_BATCH_TICKS
243
243
  * * this` ≈ 33 sim-minutes. A bound, not a timeout: it exists so a
244
244
  * `runTicks` target that advances the tick counter without advancing the sim
245
245
  * clock can never spin forever, and it is far past any honest hold. */
246
- const HIDDEN_DRIVE_MAX_BATCHES = 2000;
246
+ const STARVED_DRIVE_MAX_BATCHES = 2000;
247
247
 
248
- /** The message a hold gets when the loop is hidden-paused and NOTHING can
248
+ /** The message a hold gets when the loop is loop-starved and NOTHING can
249
249
  * drive it — the one case where the platform genuinely prevents the verb
250
250
  * from working. Names the cause and the fix rather than expiring into a
251
251
  * generic stall (which reported the wrong cause: "play stopped during
252
252
  * hold"). One exported constant so both doors and their tests read the same
253
253
  * string. */
254
- export const HOLD_HIDDEN_NO_DRIVER_REASON =
255
- 'the editor tab is hidden and this session has no way to drive ticks — the engine ' +
256
- 'hidden-pauses its loop while document.hidden, so zero ticks are happening. Bring the ' +
257
- 'editor tab to the foreground (or start play, which wires the run-ticks target) and retry.';
254
+ export const HOLD_STARVED_NO_DRIVER_REASON =
255
+ "the host loop reported liveness 'loop-starved' and this session has no way to drive " +
256
+ 'ticks no recent rAF progress was observed. Check `vgai status` for the separate ' +
257
+ 'visibility readings; foreground/reload the editor, or start play to wire the run-ticks ' +
258
+ 'target, then retry.';
258
259
 
259
260
  /** What `adapter.state('time')` returns for the two fields this loop reads,
260
261
  * plus the liveness the hidden path branches on. */
@@ -279,8 +280,8 @@ interface HoldClockReading {
279
280
  * `HOLD_FOR_POLL_MS` and gives up after `HOLD_FOR_STALL_POLL_LIMIT`
280
281
  * consecutive unchanged-tick polls (`stalled: true` — play stopped/paused).
281
282
  *
282
- * - **Hidden-paused**: the host loop has stopped outright (T2.1's idle
283
- * throttle), so sim time only moves if this drives it. It therefore drives
283
+ * - **Loop-starved**: no recent host rAF progress is observable, so sim time
284
+ * only moves reliably if this drives it. It therefore drives
284
285
  * the WHOLE remaining budget in synchronous batches, yielding a MICROTASK
285
286
  * between them — never a timer. That distinction is load-bearing, not
286
287
  * stylistic: a hidden tab clamps `setTimeout` to ~1s (and to ~1/minute under
@@ -293,35 +294,35 @@ interface HoldClockReading {
293
294
  * are not throttled, so the hidden path now costs sim-work time and nothing
294
295
  * else.
295
296
  *
296
- * The regime is re-read every iteration, so a tab foregrounded (or hidden)
297
- * mid-hold crosses over without restarting the budget.
297
+ * The regime is re-read every iteration, so an rAF chain that resumes or
298
+ * becomes starved mid-hold crosses over without restarting the budget.
298
299
  *
299
- * `hiddenWithoutDriver: true` is the one honest refusal: hidden-paused with
300
- * no `driveHiddenTicks` hook means nothing in this process can advance the
300
+ * `starvedWithoutDriver: true` is the one honest refusal: loop-starved with
301
+ * no `driveStarvedTicks` hook means nothing in this process can advance the
301
302
  * clock, so it reports that IMMEDIATELY (see
302
- * {@link HOLD_HIDDEN_NO_DRIVER_REASON}) instead of burning the stall guard
303
+ * {@link HOLD_STARVED_NO_DRIVER_REASON}) instead of burning the stall guard
303
304
  * and then blaming a stopped game.
304
305
  */
305
306
  export async function waitForHoldBudget(
306
307
  adapter: DebugAdapter,
307
308
  simSeconds: number,
308
- driveHiddenTicks?: (n: number) => void,
309
- ): Promise<{ stalled: boolean; hiddenWithoutDriver?: boolean }> {
309
+ driveStarvedTicks?: (n: number) => void,
310
+ ): Promise<{ stalled: boolean; starvedWithoutDriver?: boolean }> {
310
311
  const readTime = () => adapter.state('time') as HoldClockReading;
311
312
  const start = readTime();
312
313
  let lastTick = start.tick;
313
314
  let stalledPolls = 0;
314
- let hiddenBatches = 0;
315
+ let starvedBatches = 0;
315
316
 
316
317
  for (;;) {
317
318
  const current = readTime();
318
319
  if (current.simSeconds - start.simSeconds >= simSeconds) return { stalled: false };
319
320
 
320
- if (current.loopLiveness === 'hidden-paused') {
321
- if (!driveHiddenTicks) return { stalled: true, hiddenWithoutDriver: true };
322
- if (hiddenBatches >= HIDDEN_DRIVE_MAX_BATCHES) return { stalled: true };
323
- hiddenBatches += 1;
324
- driveHiddenTicks(HIDDEN_DRIVE_BATCH_TICKS);
321
+ if (current.loopLiveness === 'loop-starved') {
322
+ if (!driveStarvedTicks) return { stalled: true, starvedWithoutDriver: true };
323
+ if (starvedBatches >= STARVED_DRIVE_MAX_BATCHES) return { stalled: true };
324
+ starvedBatches += 1;
325
+ driveStarvedTicks(STARVED_DRIVE_BATCH_TICKS);
325
326
  const after = readTime();
326
327
  // A batch that moved neither the tick counter nor the sim clock means
327
328
  // the drive is a no-op (paused game, torn-down runtime) — the same
@@ -333,7 +334,7 @@ export async function waitForHoldBudget(
333
334
  lastTick = after.tick;
334
335
  stalledPolls = 0;
335
336
  // Yield so the page can service other work between batches, WITHOUT
336
- // handing control to a clock a hidden tab is allowed to starve.
337
+ // handing control to the same timer lane the browser may be starving.
337
338
  await Promise.resolve();
338
339
  continue;
339
340
  }
@@ -481,7 +482,7 @@ function buildDebugHandle(opts: {
481
482
  : { delivered: false };
482
483
  }
483
484
  const runTicksTarget = registry.getRunTicksTarget();
484
- const { stalled, hiddenWithoutDriver } = await waitForHoldBudget(
485
+ const { stalled, starvedWithoutDriver } = await waitForHoldBudget(
485
486
  adapter,
486
487
  simSeconds,
487
488
  runTicksTarget ? (n) => runTicksTarget.runTicks(n, { render: 'last' }) : undefined,
@@ -490,7 +491,7 @@ function buildDebugHandle(opts: {
490
491
  if (!stalled) return { delivered: true };
491
492
  return {
492
493
  delivered: false,
493
- reason: hiddenWithoutDriver ? HOLD_HIDDEN_NO_DRIVER_REASON : 'play stopped during hold',
494
+ reason: starvedWithoutDriver ? HOLD_STARVED_NO_DRIVER_REASON : 'play stopped during hold',
494
495
  };
495
496
  },
496
497
  uninstall() {
@@ -987,8 +987,7 @@ export function createGame(opts: {
987
987
  // Other native `console.warn`/`console.error` call sites in this file are
988
988
  // unsuppressed and already counted in the lint baseline (see `runFrame`'s
989
989
  // impl below); this one is a NEW site, so it's suppressed to keep this
990
- // task's diff at zero NEW warnings (same reasoning as `achieved-tier.ts`'s
991
- // identical suppression).
990
+ // task's diff at zero NEW warnings.
992
991
  // biome-ignore lint/suspicious/noConsole: see comment above
993
992
  console.warn(message);
994
993
  }
@@ -1,5 +1,6 @@
1
1
  import type * as THREE from 'three';
2
2
  import { BatchedRenderer } from 'three.quarks';
3
+ import { farDepthTexture } from '../render/soft-particle-depth';
3
4
 
4
5
  export interface ParticlesContext {
5
6
  batchedRenderer: BatchedRenderer;
@@ -17,6 +18,14 @@ export interface ParticlesContext {
17
18
  */
18
19
  export function setupParticles(scene: THREE.Scene): ParticlesContext {
19
20
  const batchedRenderer = new BatchedRenderer();
21
+ // Seed the soft-particle depth sampler BEFORE any batch exists, so every
22
+ // batch this renderer later builds inherits it (`BatchedRenderer.addSystem`
23
+ // applies `this.depthTexture` to each new batch). A `softParticles` batch
24
+ // whose `depthTexture` uniform is still `null` samples black and multiplies
25
+ // every particle away; the far-depth default makes that same batch render
26
+ // exactly as an unfaded one until a real depth pass supplies the scene's own.
27
+ // See `render/soft-particle-depth.ts`.
28
+ batchedRenderer.setDepthTexture(farDepthTexture());
20
29
  scene.add(batchedRenderer);
21
30
 
22
31
  return { batchedRenderer };
@@ -25,8 +25,9 @@ import {
25
25
  extend,
26
26
  events as pointerEvents,
27
27
  type RootState,
28
+ useFrame,
28
29
  } from '@react-three/fiber';
29
- import { createElement, type ReactNode } from 'react';
30
+ import { createElement, Fragment, type ReactNode, useEffect, useMemo } from 'react';
30
31
  import type { MountedThreeRoot, RootAdapter, ThreeHostContext } from '../adapter';
31
32
  import { type RenderVitalsRegistration, registerRenderVitals } from '../dev/register-render-vitals';
32
33
  import {
@@ -38,6 +39,7 @@ import { collectRenderMemory } from '../dev/render-memory';
38
39
  import { RENDER_SUBMIT_PHASE } from '../dev/render-vitals';
39
40
  import { createWebGLFrameCapture } from '../dev/webgl-frame-capture';
40
41
  import { WorldProvider } from '../react/world-state';
42
+ import { createSoftParticleDepthPass } from '../render/soft-particle-depth';
41
43
  import { devLayersEnabled } from '../runtime/dev-layers';
42
44
  import { EngineBridge, type EngineBridgeValue } from './engine-bridge';
43
45
  import { applyWorldRendererConfig, type WorldRendererConfig } from './renderer-config';
@@ -52,6 +54,37 @@ interface RendererCounters {
52
54
  readonly memory: { readonly geometries: number; readonly textures: number };
53
55
  }
54
56
 
57
+ /**
58
+ * Scene depth for any soft-particle system this world mounted, drawn between
59
+ * gameplay and the draw.
60
+ *
61
+ * `useFrame`'s priority is the whole reason this is a component rather than an
62
+ * engine phase: `systems.run(dt)` below finishes BEFORE `advance()`, and
63
+ * `advance()` is where every `useFrame` gameplay callback moves the frame's
64
+ * transforms — depth taken from an engine phase would be one frame stale.
65
+ * Priority `0` puts it after every negative-priority callback (fiber sorts
66
+ * subscribers by priority) and before both a positive-priority composer and
67
+ * fiber's own `state.gl.render` — and, unlike a positive priority, it does NOT
68
+ * flip fiber into manual-render mode (`internal.priority` only counts
69
+ * subscribers with `priority > 0`), so mounting it can never stop an ordinary
70
+ * world from drawing itself.
71
+ *
72
+ * Rendered as the LAST child of the adapter's tree so its subscription lands
73
+ * after the world's own (React completes children before siblings, and fiber's
74
+ * sort is stable) — the tie-break among priority-0 callbacks is insertion
75
+ * order.
76
+ *
77
+ * Costs nothing while no soft-particle system is armed — see
78
+ * `../render/soft-particle-depth.ts` for what "armed" means and what the pass
79
+ * spends when it is.
80
+ */
81
+ function SoftParticleDepth(): null {
82
+ const pass = useMemo(() => createSoftParticleDepthPass(), []);
83
+ useEffect(() => () => pass.dispose(), [pass]);
84
+ useFrame(({ gl, scene, camera }) => pass.render(gl, scene, camera), 0);
85
+ return null;
86
+ }
87
+
55
88
  /** What {@link createR3FAdapter} needs to build one `RootAdapter`. */
56
89
  export interface CreateR3FAdapterOptions {
57
90
  /** Stable id (telemetry/registry/conformance) — `RootAdapter.id`. */
@@ -266,11 +299,19 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
266
299
  // hosts, so the provider is conditional and those hooks stay the inert
267
300
  // no-ops they already document themselves to be.
268
301
  const game = runtime.ctx.game;
302
+ // `SoftParticleDepth` trails the world's own tree deliberately — see its
303
+ // docblock for why the subscription must land last.
304
+ const world = createElement(
305
+ Fragment,
306
+ null,
307
+ content,
308
+ createElement(SoftParticleDepth, { key: 'vgai-soft-particle-depth' }),
309
+ );
269
310
  root.render(
270
311
  createElement(
271
312
  EngineBridge.Provider,
272
313
  { value: bridge },
273
- game ? createElement(WorldProvider, { game }, content) : content,
314
+ game ? createElement(WorldProvider, { game }, world) : world,
274
315
  ),
275
316
  );
276
317
  // A reconcile-time crash (e.g. a missing `extend` catalogue entry)
@@ -474,6 +515,11 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
474
515
  return live().camera;
475
516
  },
476
517
  drivesOwnLoop: false,
518
+ // REPORTED for a host that draws this scene with a renderer other than
519
+ // the one it mounted us against — see `MountedThreeRoot.rendererConfig`.
520
+ // It is the same object already applied to `host.renderer` above; the
521
+ // report never applies anything itself.
522
+ rendererConfig: rendererConfig,
477
523
  // Adapter surface: `debug` (the shared game registry) and, when a real
478
524
  // WebGL2 context exists, `renderDebug` are engine-seeded. Game code
479
525
  // adds its own capabilities via `ctx.registerSystemAdapter` exactly