@vgai/engine 0.5.41 → 0.5.44

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 (129) hide show
  1. package/dist/adapter/adapter-module.d.ts +28 -29
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +11 -34
  4. package/dist/adapter/authoring-seam-contract.d.ts +10 -0
  5. package/dist/adapter/authoring-seam-contract.d.ts.map +1 -1
  6. package/dist/adapter/authoring-seam-contract.js +2 -0
  7. package/dist/adapter/authoring.d.ts +31 -0
  8. package/dist/adapter/authoring.d.ts.map +1 -1
  9. package/dist/adapter/finders/finder-result.d.ts +2 -2
  10. package/dist/adapter/finders/finder-result.d.ts.map +1 -1
  11. package/dist/adapter/finders/index.d.ts +15 -2
  12. package/dist/adapter/finders/index.d.ts.map +1 -1
  13. package/dist/adapter/finders/index.js +38 -10
  14. package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -1
  15. package/dist/adapter/finders/registry.d.ts +45 -0
  16. package/dist/adapter/finders/registry.d.ts.map +1 -0
  17. package/dist/adapter/finders/registry.js +56 -0
  18. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  19. package/dist/adapter/ingest/scene-capture.js +50 -20
  20. package/dist/adapter/system-adapter.d.ts +3 -1
  21. package/dist/adapter/system-adapter.d.ts.map +1 -1
  22. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  23. package/dist/canvas-react/pixi-react-root-factory.js +21 -11
  24. package/dist/data/vite-plugin-data.d.ts +1 -1
  25. package/dist/data/vite-plugin-data.d.ts.map +1 -1
  26. package/dist/data/vite-plugin-data.js +2 -2
  27. package/dist/ecs/user-data.d.ts +6 -0
  28. package/dist/ecs/user-data.d.ts.map +1 -1
  29. package/dist/ecs/user-data.js +4 -0
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/input/host-pointer.d.ts +18 -0
  34. package/dist/input/host-pointer.d.ts.map +1 -1
  35. package/dist/input/host-pointer.js +54 -5
  36. package/dist/input/input-manager.d.ts +83 -1
  37. package/dist/input/input-manager.d.ts.map +1 -1
  38. package/dist/input/input-manager.js +357 -8
  39. package/dist/manifest/configuration-kinds.d.ts +97 -0
  40. package/dist/manifest/configuration-kinds.d.ts.map +1 -0
  41. package/dist/manifest/configuration-kinds.js +148 -0
  42. package/dist/manifest/index.d.ts +2 -1
  43. package/dist/manifest/index.d.ts.map +1 -1
  44. package/dist/manifest/index.js +4 -3
  45. package/dist/manifest/kind-modules.d.ts +18 -0
  46. package/dist/manifest/kind-modules.d.ts.map +1 -0
  47. package/dist/manifest/kind-modules.js +58 -0
  48. package/dist/manifest/load-file.d.ts +3 -3
  49. package/dist/manifest/load-file.d.ts.map +1 -1
  50. package/dist/manifest/load-file.js +4 -4
  51. package/dist/manifest/load.d.ts +17 -12
  52. package/dist/manifest/load.d.ts.map +1 -1
  53. package/dist/manifest/load.js +29 -19
  54. package/dist/manifest/runtime-environment.d.ts +11 -0
  55. package/dist/manifest/runtime-environment.d.ts.map +1 -0
  56. package/dist/manifest/runtime-environment.js +44 -0
  57. package/dist/manifest/schema.d.ts +10 -9
  58. package/dist/manifest/schema.d.ts.map +1 -1
  59. package/dist/manifest/schema.js +32 -27
  60. package/dist/react/use-data.d.ts +1 -1
  61. package/dist/react/use-data.js +1 -1
  62. package/dist/render/environment-capture.d.ts +10 -1
  63. package/dist/render/environment-capture.d.ts.map +1 -1
  64. package/dist/render/environment-capture.js +25 -0
  65. package/dist/render/ibl-override-material.d.ts +11 -2
  66. package/dist/render/ibl-override-material.d.ts.map +1 -1
  67. package/dist/render/ibl-override-material.js +16 -2
  68. package/dist/render/matcap-texture.d.ts +5 -0
  69. package/dist/render/matcap-texture.d.ts.map +1 -0
  70. package/dist/render/matcap-texture.js +82 -0
  71. package/dist/render/soft-particle-depth.d.ts +14 -8
  72. package/dist/render/soft-particle-depth.d.ts.map +1 -1
  73. package/dist/render/soft-particle-depth.js +37 -14
  74. package/dist/render/viewport-shading.d.ts +11 -1
  75. package/dist/render/viewport-shading.d.ts.map +1 -1
  76. package/dist/render/viewport-shading.js +25 -0
  77. package/dist/runtime/create-runtime.js +6 -5
  78. package/dist/runtime/debug-bridge.d.ts.map +1 -1
  79. package/dist/runtime/debug-bridge.js +17 -3
  80. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  81. package/dist/runtime/mount-manifest.js +11 -2
  82. package/dist/settings/schema.d.ts +99 -0
  83. package/dist/settings/schema.d.ts.map +1 -0
  84. package/dist/settings/schema.js +110 -0
  85. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  86. package/dist/world3d-react/r3f-root-factory.js +34 -36
  87. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  88. package/dist/world3d-react/rapier-physics-bridge.js +21 -6
  89. package/dist-config/config.js +1 -1
  90. package/dist-config/config.js.map +2 -2
  91. package/package.json +2 -1
  92. package/schemas/engine-capabilities.json +4 -4
  93. package/schemas/vgai-project.schema.json +41 -36
  94. package/schemas/vgai-settings.schema.json +112 -0
  95. package/src/adapter/adapter-module.ts +33 -51
  96. package/src/adapter/authoring-seam-contract.ts +2 -0
  97. package/src/adapter/authoring.ts +31 -0
  98. package/src/adapter/finders/finder-result.ts +2 -2
  99. package/src/adapter/finders/index.ts +68 -12
  100. package/src/adapter/finders/prefabs-from-stories.ts +2 -2
  101. package/src/adapter/finders/registry.ts +95 -0
  102. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +4 -4
  103. package/src/adapter/ingest/scene-capture.ts +48 -19
  104. package/src/adapter/system-adapter.ts +3 -1
  105. package/src/canvas-react/pixi-react-root-factory.tsx +21 -11
  106. package/src/data/vite-plugin-data.ts +4 -2
  107. package/src/ecs/user-data.ts +6 -0
  108. package/src/index.ts +1 -1
  109. package/src/input/host-pointer.ts +84 -5
  110. package/src/input/input-manager.ts +404 -7
  111. package/src/manifest/configuration-kinds.ts +241 -0
  112. package/src/manifest/index.ts +4 -3
  113. package/src/manifest/kind-modules.ts +58 -0
  114. package/src/manifest/load-file.ts +11 -5
  115. package/src/manifest/load.ts +53 -22
  116. package/src/manifest/runtime-environment.ts +48 -0
  117. package/src/manifest/schema.ts +39 -33
  118. package/src/react/use-data.ts +1 -1
  119. package/src/render/environment-capture.ts +30 -1
  120. package/src/render/ibl-override-material.ts +32 -2
  121. package/src/render/matcap-texture.ts +92 -0
  122. package/src/render/soft-particle-depth.ts +43 -14
  123. package/src/render/viewport-shading.ts +33 -1
  124. package/src/runtime/create-runtime.ts +6 -5
  125. package/src/runtime/debug-bridge.ts +21 -3
  126. package/src/runtime/mount-manifest.ts +13 -2
  127. package/src/settings/schema.ts +127 -0
  128. package/src/world3d-react/r3f-root-factory.tsx +45 -39
  129. package/src/world3d-react/rapier-physics-bridge.tsx +22 -8
@@ -10,7 +10,7 @@
10
10
  * game altered.
11
11
  *
12
12
  * Consumers today: the `reflections` capability's native probe system, and
13
- * `godot-compat`'s Godot 3 `ReflectionProbe`.
13
+ * `godot-compat`'s versioned Godot `ReflectionProbe` binding.
14
14
  */
15
15
  import type { Light, Object3D, Scene, WebGLRenderer } from 'three';
16
16
 
@@ -88,6 +88,35 @@ export function withCaptureShadows(scene: Scene, enabled: boolean, capture: () =
88
88
  }
89
89
  }
90
90
 
91
+ /**
92
+ * Include or suppress the scene's global sky/environment for one cube capture.
93
+ *
94
+ * This is renderer plumbing rather than a Godot policy: the caller decides whether the capture is
95
+ * interior. Clearing both properties is necessary because Three uses `background` for the cube's
96
+ * visible pixels and `environment` for the materials being photographed. Restoring only one makes
97
+ * a nominally isolated capture retain half of the outside lighting.
98
+ */
99
+ export function withCaptureSceneEnvironment(
100
+ scene: Scene,
101
+ enabled: boolean,
102
+ capture: () => void,
103
+ ): void {
104
+ if (enabled) {
105
+ capture();
106
+ return;
107
+ }
108
+ const background = scene.background;
109
+ const environment = scene.environment;
110
+ scene.background = null;
111
+ scene.environment = null;
112
+ try {
113
+ capture();
114
+ } finally {
115
+ scene.background = background;
116
+ scene.environment = environment;
117
+ }
118
+ }
119
+
91
120
  /**
92
121
  * Whether this renderer can run an environment capture at all.
93
122
  *
@@ -24,10 +24,19 @@
24
24
  * hands back three's own shader object and gets out of the way.
25
25
  *
26
26
  * Consumers today: the `reflections` capability's native probe system, and
27
- * `godot-compat`'s Godot 3 `ReflectionProbe`, whose per-fragment models are
27
+ * `godot-compat`'s Godot `ReflectionProbe`, whose per-fragment models are
28
28
  * deliberately different renderers over this one splice.
29
29
  */
30
- import { type Mesh, type MeshStandardMaterial, ShaderChunk } from 'three';
30
+ import {
31
+ CubeUVReflectionMapping,
32
+ DataTexture,
33
+ type Mesh,
34
+ type MeshStandardMaterial,
35
+ RGBAFormat,
36
+ ShaderChunk,
37
+ type Texture,
38
+ UnsignedByteType,
39
+ } from 'three';
31
40
 
32
41
  /** The shader object three hands to `onBeforeCompile`. */
33
42
  export type IblOverrideShader = Parameters<MeshStandardMaterial['onBeforeCompile']>[0];
@@ -67,6 +76,27 @@ export function standardMaterialsOf(mesh: Mesh): readonly MeshStandardMaterial[]
67
76
  );
68
77
  }
69
78
 
79
+ /**
80
+ * A black native texture whose only job is to compile Three's IBL branch.
81
+ *
82
+ * Local reflection systems can light a scene from captured probes when neither
83
+ * `scene.environment` nor a material `envMap` exists. Three removes both IBL
84
+ * entry points from that program unless one native input is present, so the
85
+ * caller temporarily assigns this neutral texture and restores what it found.
86
+ */
87
+ export function createIblSentinelTexture(): Texture {
88
+ const texture = new DataTexture(
89
+ new Uint8Array(16 * 16 * 4),
90
+ 16,
91
+ 16,
92
+ RGBAFormat,
93
+ UnsignedByteType,
94
+ );
95
+ texture.mapping = CubeUVReflectionMapping;
96
+ texture.needsUpdate = true;
97
+ return texture;
98
+ }
99
+
70
100
  export interface IblOverrideOptions {
71
101
  /**
72
102
  * The GLSL that replaces `#include <envmap_physical_pars_fragment>`. Read at
@@ -0,0 +1,92 @@
1
+ import * as THREE from 'three';
2
+
3
+ /**
4
+ * The neutral matcap sphere, DRAWN rather than shipped.
5
+ *
6
+ * A matcap is just a picture of a lit sphere sampled by view-space normal, so
7
+ * there is nothing to vendor: the same three numbers a shader would use
8
+ * (key, fill, rim) evaluated once per texel produce the image. Keeping it
9
+ * procedural means the diagnostic look has no binary asset, no license, no
10
+ * provenance row, and no way to go missing from a build.
11
+ *
12
+ * The look is deliberately CLAY: one warm key from the upper left, a cool
13
+ * fill from the lower right, a tight rim, and a broad soft highlight — the
14
+ * neutral sculpting material whose whole job is to let form read without
15
+ * colour, texture or authored lighting getting a vote.
16
+ */
17
+
18
+ const SIZE = 256;
19
+
20
+ /** sRGB transfer curve — the canvas holds display-referred bytes. */
21
+ function encode(value: number): number {
22
+ const c = Math.min(1, Math.max(0, value));
23
+ return c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
24
+ }
25
+
26
+ function normalize(x: number, y: number, z: number): [number, number, number] {
27
+ const length = Math.hypot(x, y, z) || 1;
28
+ return [x / length, y / length, z / length];
29
+ }
30
+
31
+ export function drawNeutralMatcap(size = SIZE): HTMLCanvasElement {
32
+ const canvas = document.createElement('canvas');
33
+ canvas.width = size;
34
+ canvas.height = size;
35
+ const context = canvas.getContext('2d');
36
+ if (!context) return canvas;
37
+ const image = context.createImageData(size, size);
38
+
39
+ const key = normalize(-0.45, 0.62, 0.64);
40
+ const fill = normalize(0.65, -0.35, 0.5);
41
+ // View direction is +Z for a matcap: the sphere is drawn facing the camera.
42
+ const half = normalize(key[0], key[1], key[2] + 1);
43
+ const base = [0.7, 0.69, 0.68];
44
+ const fillColor = [0.34, 0.4, 0.52];
45
+ const rimColor = [0.85, 0.88, 1];
46
+
47
+ for (let py = 0; py < size; py++) {
48
+ for (let px = 0; px < size; px++) {
49
+ const nx = (px + 0.5) / size / 0.5 - 1;
50
+ const ny = 1 - (py + 0.5) / size / 0.5;
51
+ const r2 = nx * nx + ny * ny;
52
+ // Outside the unit disc a matcap is never sampled by a facing surface,
53
+ // but bilinear filtering reaches one texel past the silhouette. Clamp
54
+ // to the rim normal so the edge stays the rim colour instead of
55
+ // bleeding whatever happened to be there.
56
+ const clamped = r2 > 1;
57
+ const scale = clamped ? 1 / Math.sqrt(r2) : 1;
58
+ const x = nx * scale;
59
+ const y = ny * scale;
60
+ const z = clamped ? 0 : Math.sqrt(Math.max(0, 1 - r2));
61
+
62
+ const diffuse = Math.max(0, x * key[0] + y * key[1] + z * key[2]);
63
+ const fillTerm = Math.max(0, x * fill[0] + y * fill[1] + z * fill[2]);
64
+ const specular = Math.max(0, x * half[0] + y * half[1] + z * half[2]) ** 42 * 0.5;
65
+ const rim = (1 - z) ** 3.2 * 0.4;
66
+
67
+ const offset = (py * size + px) * 4;
68
+ for (let channel = 0; channel < 3; channel++) {
69
+ const lit =
70
+ base[channel]! * (0.16 + 0.8 * diffuse) +
71
+ fillColor[channel]! * 0.3 * fillTerm +
72
+ rimColor[channel]! * rim +
73
+ specular;
74
+ image.data[offset + channel] = Math.round(encode(lit) * 255);
75
+ }
76
+ image.data[offset + 3] = 255;
77
+ }
78
+ }
79
+ context.putImageData(image, 0, 0);
80
+ return canvas;
81
+ }
82
+
83
+ /** The drawn sphere as a ready-to-sample texture. Callers own disposal. */
84
+ export function createNeutralMatcapTexture(size = SIZE): THREE.CanvasTexture {
85
+ const texture = new THREE.CanvasTexture(drawNeutralMatcap(size));
86
+ texture.colorSpace = THREE.SRGBColorSpace;
87
+ texture.minFilter = THREE.LinearFilter;
88
+ texture.magFilter = THREE.LinearFilter;
89
+ texture.generateMipmaps = false;
90
+ texture.needsUpdate = true;
91
+ return texture;
92
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Scene DEPTH for three.quarks' soft particles the pass that makes
3
- * `ParticlesDescriptor.softParticles` a real effect instead of a footgun.
2
+ * Opaque scene DEPTH for native consumers, including three.quarks soft particles and translated
3
+ * Godot `hint_depth_texture` shader uniforms.
4
4
  *
5
5
  * ## What three.quarks needs, and what it does when nobody supplies it
6
6
  *
@@ -61,12 +61,10 @@
61
61
  * with the armed particle renderers and every transparent surface hidden and a
62
62
  * colour-write-disabled override material in place.
63
63
  *
64
- * That cost is paid ONLY while something is armed. {@link armSoftParticleDepth}
65
- * is called from `particles-factory.ts`'s `registerParticleSystem` when the
66
- * system it is registering asked for `softParticles`; with the registry empty
67
- * {@link SoftParticleDepthPass.render} returns before it touches the scene, so a
68
- * world with no soft particles pays one `Set.size` read per frame and nothing
69
- * else. Armed, per frame per camera it costs: one scene traverse (to find the
64
+ * That cost is paid ONLY while something is armed. Soft particles enter through
65
+ * {@link armSoftParticleDepth}; translated depth-texture shader programs enter through
66
+ * {@link armSceneDepthTexture}. With both registries empty {@link SoftParticleDepthPass.render}
67
+ * returns before it touches the scene. Armed, per frame per camera it costs: one scene traverse (to find the
70
68
  * transparent surfaces to hide), one extra geometry-only scene draw with shadow
71
69
  * updates suppressed, and a drawing-buffer-sized depth target
72
70
  * (`UnsignedIntType` depth + an unread RGBA8 colour attachment ≈ 8
@@ -110,6 +108,13 @@ import type { BatchedRenderer } from 'three.quarks';
110
108
  */
111
109
  const armed = new Map<BatchedRenderer, number>();
112
110
 
111
+ /** Any native material/program that samples Godot's opaque-pass depth texture. */
112
+ export interface SceneDepthTextureConsumer {
113
+ setDepthTexture(texture: THREE.Texture): void;
114
+ }
115
+
116
+ const depthTextureConsumers = new Map<SceneDepthTextureConsumer, number>();
117
+
113
118
  let farDepth: THREE.DataTexture | undefined;
114
119
 
115
120
  /**
@@ -155,6 +160,23 @@ export function disarmSoftParticleDepth(renderer: BatchedRenderer): void {
155
160
  renderer.setDepthTexture(farDepthTexture());
156
161
  }
157
162
 
163
+ /** Retain one consumer of the same opaque scene-depth image Godot exposes as hint_depth_texture. */
164
+ export function armSceneDepthTexture(consumer: SceneDepthTextureConsumer): void {
165
+ depthTextureConsumers.set(consumer, (depthTextureConsumers.get(consumer) ?? 0) + 1);
166
+ consumer.setDepthTexture(farDepthTexture());
167
+ }
168
+
169
+ /** Release one retained Godot depth-texture consumer without leaving a disposed target bound. */
170
+ export function disarmSceneDepthTexture(consumer: SceneDepthTextureConsumer): void {
171
+ const remaining = (depthTextureConsumers.get(consumer) ?? 0) - 1;
172
+ if (remaining > 0) {
173
+ depthTextureConsumers.set(consumer, remaining);
174
+ return;
175
+ }
176
+ if (!depthTextureConsumers.delete(consumer)) return;
177
+ consumer.setDepthTexture(farDepthTexture());
178
+ }
179
+
158
180
  /** Test/diagnostic read of the registry — how many renderers a pass would consider. */
159
181
  export function armedSoftParticleRendererCount(): number {
160
182
  return armed.size;
@@ -243,12 +265,12 @@ export function createSoftParticleDepthPass(): SoftParticleDepthPass {
243
265
 
244
266
  return {
245
267
  render(renderer, scene, camera): void {
246
- if (armed.size === 0) return;
268
+ if (armed.size === 0 && depthTextureConsumers.size === 0) return;
247
269
  const consumers: BatchedRenderer[] = [];
248
270
  for (const candidate of armed.keys()) {
249
271
  if (isUnder(candidate, scene)) consumers.push(candidate);
250
272
  }
251
- if (consumers.length === 0) return;
273
+ if (consumers.length === 0 && depthTextureConsumers.size === 0) return;
252
274
 
253
275
  // See REQUIRED_RENDERER_MEMBERS: a renderer that cannot swap render
254
276
  // targets cannot run a prepass, and the frame is worth more than the
@@ -259,9 +281,9 @@ export function createSoftParticleDepthPass(): SoftParticleDepthPass {
259
281
  warnedIncapable = true;
260
282
  // biome-ignore lint/suspicious/noConsole: deliberate loud degrade — the documented alternative to throwing out of the frame
261
283
  console.warn(
262
- `soft-particle depth prepass skipped: this renderer implements none of ${missing.join(', ')}. ` +
263
- `${String(consumers.length)} particle batch(es) keep the inert far-depth default, so they ` +
264
- 'draw unfaded the same pixels as a batch that never declared a proximity fade. A real ' +
284
+ `opaque scene-depth prepass skipped: this renderer is missing ${missing.join(', ')}. ` +
285
+ `${String(consumers.length)} particle batch(es) and ${String(depthTextureConsumers.size)} ` +
286
+ 'shader consumer(s) keep the inert far-depth default. A real ' +
265
287
  'WebGLRenderer has these; a design-time settle stand-in does not.',
266
288
  );
267
289
  }
@@ -317,12 +339,19 @@ export function createSoftParticleDepthPass(): SoftParticleDepthPass {
317
339
  hidden.length = 0;
318
340
  }
319
341
 
320
- for (const consumer of consumers) consumer.setDepthTexture(rt.depthTexture);
342
+ const renderedDepth = rt.depthTexture;
343
+ if (renderedDepth === null) {
344
+ throw new Error('opaque scene-depth prepass lost its authored depth texture.');
345
+ }
346
+ for (const consumer of consumers) consumer.setDepthTexture(renderedDepth);
347
+ for (const consumer of depthTextureConsumers.keys()) consumer.setDepthTexture(renderedDepth);
321
348
  },
322
349
  dispose(): void {
323
350
  // Every consumer this pass fed now points at a texture that is about to
324
351
  // go away. Hand them the inert default back rather than a disposed one.
325
352
  for (const candidate of armed.keys()) candidate.setDepthTexture(farDepthTexture());
353
+ for (const consumer of depthTextureConsumers.keys())
354
+ consumer.setDepthTexture(farDepthTexture());
326
355
  target?.dispose();
327
356
  target = null;
328
357
  override.dispose();
@@ -1,7 +1,15 @@
1
1
  import * as THREE from 'three';
2
+ import { createNeutralMatcapTexture } from './matcap-texture';
2
3
 
3
4
  /** Temporary developer-facing shading modes. These never become scene data. */
4
- export type ViewportShadingMode = 'solid' | 'clay' | 'unlit' | 'wireframe' | 'normals' | 'overdraw';
5
+ export type ViewportShadingMode =
6
+ | 'solid'
7
+ | 'clay'
8
+ | 'unlit'
9
+ | 'wireframe'
10
+ | 'matcap'
11
+ | 'normals'
12
+ | 'overdraw';
5
13
 
6
14
  type MaterialPair = {
7
15
  clay: THREE.MeshStandardMaterial;
@@ -30,6 +38,16 @@ function textureOf(material: THREE.Material, key: 'map' | 'alphaMap'): THREE.Tex
30
38
  export class ViewportShadingRenderer {
31
39
  private readonly _derived = new Map<THREE.Material, MaterialPair>();
32
40
  private readonly _normals = new THREE.MeshNormalMaterial();
41
+ /**
42
+ * The clay/sculpt look. Built on FIRST USE, not at construction: the matcap
43
+ * sphere is drawn into a 2D canvas, and every ViewportShadingRenderer that
44
+ * only ever draws `solid` would otherwise pay for a texture nobody samples.
45
+ * `toneMapped: false` for the same reason the overdraw material sets it —
46
+ * a diagnostic look must read exactly as authored, not as the document's
47
+ * exposure and tone curve happen to grade it.
48
+ */
49
+ private _matcap: THREE.MeshMatcapMaterial | null = null;
50
+ private _matcapTexture: THREE.Texture | null = null;
33
51
  private readonly _overdraw = new THREE.MeshBasicMaterial({
34
52
  color: 0xffffff,
35
53
  transparent: true,
@@ -81,6 +99,10 @@ export class ViewportShadingRenderer {
81
99
  this._derived.clear();
82
100
  this._normals.dispose();
83
101
  this._overdraw.dispose();
102
+ this._matcap?.dispose();
103
+ this._matcapTexture?.dispose();
104
+ this._matcap = null;
105
+ this._matcapTexture = null;
84
106
  }
85
107
 
86
108
  private _materialFor(
@@ -89,6 +111,16 @@ export class ViewportShadingRenderer {
89
111
  ): THREE.Material {
90
112
  if (mode === 'normals') return this._normals;
91
113
  if (mode === 'overdraw') return this._overdraw;
114
+ if (mode === 'matcap') {
115
+ if (!this._matcap) {
116
+ this._matcapTexture = createNeutralMatcapTexture();
117
+ this._matcap = new THREE.MeshMatcapMaterial({
118
+ matcap: this._matcapTexture,
119
+ toneMapped: false,
120
+ });
121
+ }
122
+ return this._matcap;
123
+ }
92
124
 
93
125
  let pair = this._derived.get(material);
94
126
  if (!pair) {
@@ -375,14 +375,15 @@ export async function createGameRuntime(config: RootsRuntimeConfig): Promise<Gam
375
375
  // ---------------------------------------------------------------------------
376
376
 
377
377
  /** A stand-in `THREE.WebGLRenderer` for headless (`headless:true`) three
378
- * roots: a headless mount never calls a render-phase method on
379
- * `host.renderer` at all, so
380
- * this only needs to satisfy the handful of calls THIS file itself makes
378
+ * roots: draw submission is inert while native scene/component evaluation
379
+ * still runs. This satisfies Fiber's custom-renderer gate and the calls
381
380
  * (`setPixelRatio`/`setClearColor`/`setSize` at mount, `dispose`/
382
381
  * `forceContextLoss` at teardown) — never a real GL call. Node conformance
383
382
  * tests only; never used when `headless` is left `false`/absent. */
384
- function createHeadlessRendererStub(): THREE.WebGLRenderer {
383
+ function createHeadlessRendererStub(canvas: HTMLCanvasElement): THREE.WebGLRenderer {
385
384
  return {
385
+ domElement: canvas,
386
+ render() {},
386
387
  setPixelRatio() {},
387
388
  setClearColor() {},
388
389
  setSize() {},
@@ -477,7 +478,7 @@ async function mountOneThreeRoot(
477
478
  ): Promise<OneRootResult> {
478
479
  const { game, canvas, w, h, dpr, isBottom, headless, assets, antialias } = ctx;
479
480
  const renderer = headless
480
- ? createHeadlessRendererStub()
481
+ ? createHeadlessRendererStub(canvas)
481
482
  : createHostRenderer(
482
483
  canvas,
483
484
  w,
@@ -246,10 +246,13 @@ const HOLD_FOR_POLL_MS = 50;
246
246
  * the same neighborhood. */
247
247
  const HOLD_FOR_STALL_POLL_LIMIT = 100;
248
248
 
249
- /** Ticks driven per synchronous starved-loop batch. Sized like
249
+ /** Maximum ticks driven per synchronous starved-loop batch. Sized like
250
250
  * `fast-forward.ts`'s own batching rationale: big enough that per-batch
251
251
  * bookkeeping is negligible, small enough that one batch of a complex game's
252
- * phases stays a short synchronous burst. */
252
+ * phases stays a short synchronous burst. The actual batch is capped to the
253
+ * hold's remaining fixed-tick budget below; otherwise a one-tick `tap()` in a
254
+ * hidden tab becomes a 60-tick hold and releases only after short gameplay
255
+ * interactions have already completed. */
253
256
  const STARVED_DRIVE_BATCH_TICKS = 60;
254
257
 
255
258
  /** Hard ceiling on starved-drive batches for ONE hold — `STARVED_DRIVE_BATCH_TICKS
@@ -276,6 +279,9 @@ interface HoldClockReading {
276
279
  simSeconds: number;
277
280
  tick: number;
278
281
  loopLiveness?: GameLoopLiveness | null;
282
+ /** Present for every real Game-backed debug registry. Optional only because a bare adapter
283
+ * stand-in can omit it; that case drives one conservative tick at a time. */
284
+ fixedDt?: number;
279
285
  }
280
286
 
281
287
  /**
@@ -335,7 +341,19 @@ export async function waitForHoldBudget(
335
341
  if (!driveStarvedTicks) return { stalled: true, starvedWithoutDriver: true };
336
342
  if (starvedBatches >= STARVED_DRIVE_MAX_BATCHES) return { stalled: true };
337
343
  starvedBatches += 1;
338
- driveStarvedTicks(STARVED_DRIVE_BATCH_TICKS);
344
+ const remaining = simSeconds - (current.simSeconds - start.simSeconds);
345
+ const fixedDt = current.fixedDt;
346
+ // A real Game publishes its fixed timestep, so drive exactly the number of whole ticks
347
+ // still needed (up to the throughput cap). Subtract a tiny ratio tolerance before `ceil`:
348
+ // repeated floating-point additions can leave an integer tick budget a few ulps above its
349
+ // mathematical value, and that must not manufacture one extra held-input frame. A bare
350
+ // adapter that does not publish `fixedDt` takes the conservative path: one tick, re-read,
351
+ // repeat. Exact input duration matters more than batching a non-Game test stand-in.
352
+ const ticksForRemaining =
353
+ fixedDt !== undefined && Number.isFinite(fixedDt) && fixedDt > 0
354
+ ? Math.max(1, Math.ceil(remaining / fixedDt - 1e-9))
355
+ : 1;
356
+ driveStarvedTicks(Math.min(STARVED_DRIVE_BATCH_TICKS, ticksForRemaining));
339
357
  const after = readTime();
340
358
  // A batch that moved neither the tick counter nor the sim clock means
341
359
  // the drive is a no-op (paused game, torn-down runtime) — the same
@@ -227,6 +227,17 @@ export type MountedManifestSession = GameSession;
227
227
  */
228
228
  function looksAlreadyResolved(value: unknown): value is ResolvedGameManifest {
229
229
  if (typeof value !== 'object' || value === null) return false;
230
+ // The loader's own output field is the surest mark: a raw manifest never
231
+ // carries it (the schema is strict). Without this, a RESOLVED manifest
232
+ // with `roots: []` — a models project, nothing to play — fell through to
233
+ // `loadGameManifest`, whose strict parse refused THIS key ("Unrecognized
234
+ // key: deferredConfigurationKinds") instead of saying "no roots"
235
+ // (blind lantern round 21, 2026-09-06, sent there by the never-played nag).
236
+ if (
237
+ Array.isArray((value as { deferredConfigurationKinds?: unknown }).deferredConfigurationKinds)
238
+ ) {
239
+ return true;
240
+ }
230
241
  const roots = (value as { roots?: unknown }).roots;
231
242
  if (!Array.isArray(roots) || roots.length === 0) return false;
232
243
  return roots.every((world) => {
@@ -245,7 +256,7 @@ function looksAlreadyResolved(value: unknown): value is ResolvedGameManifest {
245
256
  */
246
257
  export function resolveManifest(raw: unknown): ResolvedGameManifest {
247
258
  if (looksAlreadyResolved(raw)) return raw;
248
- return loadGameManifest(raw);
259
+ return loadGameManifest(raw, { configurationKinds: 'defer' });
249
260
  }
250
261
 
251
262
  // ---------------------------------------------------------------------------
@@ -454,7 +465,7 @@ export async function mountManifestRoots(opts: MountManifestOptions): Promise<Ga
454
465
  // `manifest/load.ts`) is `{ room, module } | undefined`; its mere presence
455
466
  // is the "this project declares a room" signal, independent of
456
467
  // whether/when the game actually joins it.
457
- if (manifest.server) {
468
+ if (manifest.configurations.some((configuration) => configuration.kind === 'process')) {
458
469
  debugRegistry.setRoomDeclared(true);
459
470
  }
460
471
  // Defect 5 fix: `maybeInstallDebugBridge` adds `window` listeners and
@@ -0,0 +1,127 @@
1
+ /**
2
+ * THE SETTINGS DOCUMENT — the Zod truth for the two shared settings layers
3
+ * (ARCHITECTURE-CORE §Editor chrome, "Settings have four layers with named
4
+ * homes"):
5
+ *
6
+ * ~/.vgai/settings.json the USER layer — one person, every project
7
+ * <project>/.vgai/settings.json the PROJECT layer — one project, committed,
8
+ * every person who opens it
9
+ *
10
+ * Both files carry the same shape; the project layer overrides the user layer
11
+ * key by key (`mergeEditorSettings`). Neither layer holds authored content —
12
+ * an unreadable file is reported by name and falls back to defaults, never
13
+ * migrated. The generated JSON Schema (`npm run generate-schema` →
14
+ * `packages/engine/schemas/vgai-settings.schema.json`) is what an editor
15
+ * autocompletes against when a person edits either file by hand.
16
+ *
17
+ * Every field here has a runtime reader in the editor's settings store
18
+ * consumers (`theme-preference.ts`, `workspace-regions.ts`,
19
+ * `keymap-presets.ts`, `workspace-presets.ts`, `device-preview.ts`); a field
20
+ * with no reader does not get declared (CLAUDE.md, "Do not author schema
21
+ * fields that have no runtime reader").
22
+ */
23
+ import { z } from 'zod';
24
+
25
+ const RegionVisibility = z.enum(['shown', 'hidden']);
26
+
27
+ export const ChromeRegionsSettingsSchema = z
28
+ .object({
29
+ tabs: RegionVisibility.optional().describe('The document tab strip.'),
30
+ header: RegionVisibility.optional().describe('The top header bar.'),
31
+ shelf: RegionVisibility.optional().describe('The utility shelf.'),
32
+ inspector: z
33
+ .enum(['column', 'properties'])
34
+ .optional()
35
+ .describe('Inspector presentation: the narrow docked column or a properties panel.'),
36
+ drawer: RegionVisibility.optional().describe('The bottom drawer.'),
37
+ })
38
+ .strict()
39
+ .describe('Which chrome regions a style bundle shows or hides.');
40
+
41
+ export const EditorSettingsSchema = z
42
+ .object({
43
+ $schema: z
44
+ .string()
45
+ .optional()
46
+ .describe('JSON Schema pointer for editor autocomplete; ignored by the editor.'),
47
+ appearance: z
48
+ .object({
49
+ palette: z
50
+ .string()
51
+ .optional()
52
+ .describe('Editor palette id — a built-in or a custom theme document.'),
53
+ material: z.string().optional().describe('Editor material id (`classic` or `glass`).'),
54
+ regions: ChromeRegionsSettingsSchema.optional(),
55
+ })
56
+ .strict()
57
+ .optional()
58
+ .describe('How the editor chrome looks.'),
59
+ keymap: z.string().optional().describe('Active keymap preset id (`vgai`, `blender`, …).'),
60
+ workspace: z
61
+ .object({
62
+ default: z
63
+ .string()
64
+ .optional()
65
+ .describe(
66
+ 'The workspace a fresh checkout opens in, when it applies to the project shape.',
67
+ ),
68
+ })
69
+ .strict()
70
+ .optional()
71
+ .describe('Workspace defaults.'),
72
+ devicePreview: z
73
+ .object({
74
+ preset: z.string().optional().describe('Device preview frame preset id.'),
75
+ touch: z
76
+ .boolean()
77
+ .nullable()
78
+ .optional()
79
+ .describe('Touch emulation override; null follows the preset.'),
80
+ })
81
+ .strict()
82
+ .optional()
83
+ .describe('The device frame the Game document previews in.'),
84
+ })
85
+ .strict()
86
+ .describe('Editor settings — the user layer and the project layer share this shape.');
87
+
88
+ export type EditorSettings = z.infer<typeof EditorSettingsSchema>;
89
+ export type ChromeRegionsSettings = z.infer<typeof ChromeRegionsSettingsSchema>;
90
+
91
+ /** Which layer a file is; the file paths are the server's. */
92
+ export type SettingsLayer = 'user' | 'project';
93
+
94
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
95
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
96
+ }
97
+
98
+ /** Deep-merge plain objects; anything else (primitives, arrays, null)
99
+ * replaces. `merge(user, project)` is the effective document. */
100
+ export function mergeEditorSettings(base: EditorSettings, over: EditorSettings): EditorSettings {
101
+ const out: Record<string, unknown> = { ...base };
102
+ const patch = over as Record<string, unknown>;
103
+ for (const key of Object.keys(patch)) {
104
+ const value = patch[key];
105
+ const current = out[key];
106
+ out[key] =
107
+ isPlainObject(current) && isPlainObject(value)
108
+ ? mergeEditorSettings(current as EditorSettings, value as EditorSettings)
109
+ : value;
110
+ }
111
+ return out as EditorSettings;
112
+ }
113
+
114
+ /** Validate a raw document. The result carries the parsed settings or the
115
+ * issues, spelled for a person who has the file open. */
116
+ export function parseEditorSettings(
117
+ raw: unknown,
118
+ ): { settings: EditorSettings; issues: [] } | { settings: null; issues: string[] } {
119
+ const result = EditorSettingsSchema.safeParse(raw);
120
+ if (result.success) return { settings: result.data, issues: [] };
121
+ return {
122
+ settings: null,
123
+ issues: result.error.issues.map((issue) =>
124
+ issue.path.length ? `${issue.path.join('.')}: ${issue.message}` : issue.message,
125
+ ),
126
+ };
127
+ }