@vgai/engine 0.5.42 → 0.5.45
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.
- package/dist/adapter/adapter-module.d.ts +28 -29
- package/dist/adapter/adapter-module.d.ts.map +1 -1
- package/dist/adapter/adapter-module.js +11 -34
- package/dist/adapter/authoring-seam-contract.d.ts +5 -0
- package/dist/adapter/authoring-seam-contract.d.ts.map +1 -1
- package/dist/adapter/authoring-seam-contract.js +1 -0
- package/dist/adapter/authoring.d.ts +16 -0
- package/dist/adapter/authoring.d.ts.map +1 -1
- package/dist/adapter/finders/finder-result.d.ts +2 -2
- package/dist/adapter/finders/finder-result.d.ts.map +1 -1
- package/dist/adapter/finders/index.d.ts +15 -2
- package/dist/adapter/finders/index.d.ts.map +1 -1
- package/dist/adapter/finders/index.js +38 -10
- package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -1
- package/dist/adapter/finders/registry.d.ts +45 -0
- package/dist/adapter/finders/registry.d.ts.map +1 -0
- package/dist/adapter/finders/registry.js +56 -0
- package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
- package/dist/adapter/ingest/scene-capture.js +50 -20
- package/dist/adapter/system-adapter.d.ts +3 -1
- package/dist/adapter/system-adapter.d.ts.map +1 -1
- package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
- package/dist/canvas-react/pixi-react-root-factory.js +21 -11
- package/dist/data/vite-plugin-data.d.ts +1 -1
- package/dist/data/vite-plugin-data.d.ts.map +1 -1
- package/dist/data/vite-plugin-data.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/input/host-pointer.d.ts +18 -0
- package/dist/input/host-pointer.d.ts.map +1 -1
- package/dist/input/host-pointer.js +54 -5
- package/dist/input/input-manager.d.ts +83 -1
- package/dist/input/input-manager.d.ts.map +1 -1
- package/dist/input/input-manager.js +357 -8
- package/dist/manifest/configuration-kinds.d.ts +97 -0
- package/dist/manifest/configuration-kinds.d.ts.map +1 -0
- package/dist/manifest/configuration-kinds.js +148 -0
- package/dist/manifest/index.d.ts +2 -1
- package/dist/manifest/index.d.ts.map +1 -1
- package/dist/manifest/index.js +4 -3
- package/dist/manifest/kind-modules.d.ts +18 -0
- package/dist/manifest/kind-modules.d.ts.map +1 -0
- package/dist/manifest/kind-modules.js +58 -0
- package/dist/manifest/load-file.d.ts +3 -3
- package/dist/manifest/load-file.d.ts.map +1 -1
- package/dist/manifest/load-file.js +4 -4
- package/dist/manifest/load.d.ts +17 -12
- package/dist/manifest/load.d.ts.map +1 -1
- package/dist/manifest/load.js +29 -19
- package/dist/manifest/runtime-environment.d.ts +11 -0
- package/dist/manifest/runtime-environment.d.ts.map +1 -0
- package/dist/manifest/runtime-environment.js +44 -0
- package/dist/manifest/schema.d.ts +10 -9
- package/dist/manifest/schema.d.ts.map +1 -1
- package/dist/manifest/schema.js +32 -27
- package/dist/react/use-data.d.ts +1 -1
- package/dist/react/use-data.js +1 -1
- package/dist/render/matcap-texture.d.ts +5 -0
- package/dist/render/matcap-texture.d.ts.map +1 -0
- package/dist/render/matcap-texture.js +82 -0
- package/dist/render/soft-particle-depth.d.ts +14 -8
- package/dist/render/soft-particle-depth.d.ts.map +1 -1
- package/dist/render/soft-particle-depth.js +37 -14
- package/dist/render/viewport-shading.d.ts +11 -1
- package/dist/render/viewport-shading.d.ts.map +1 -1
- package/dist/render/viewport-shading.js +25 -0
- package/dist/runtime/create-runtime.js +6 -5
- package/dist/runtime/mount-manifest.d.ts.map +1 -1
- package/dist/runtime/mount-manifest.js +11 -2
- package/dist/settings/schema.d.ts +99 -0
- package/dist/settings/schema.d.ts.map +1 -0
- package/dist/settings/schema.js +110 -0
- package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
- package/dist/world3d-react/r3f-root-factory.js +34 -36
- package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
- package/dist/world3d-react/rapier-physics-bridge.js +21 -6
- package/dist-config/config.js +1 -1
- package/dist-config/config.js.map +2 -2
- package/package.json +2 -1
- package/schemas/engine-capabilities.json +4 -4
- package/schemas/vgai-project.schema.json +41 -36
- package/schemas/vgai-settings.schema.json +112 -0
- package/src/adapter/adapter-module.ts +33 -51
- package/src/adapter/authoring-seam-contract.ts +1 -0
- package/src/adapter/authoring.ts +16 -0
- package/src/adapter/finders/finder-result.ts +2 -2
- package/src/adapter/finders/index.ts +68 -12
- package/src/adapter/finders/prefabs-from-stories.ts +2 -2
- package/src/adapter/finders/registry.ts +95 -0
- package/src/adapter/finders/scenes-from-entrypoint-selection.ts +4 -4
- package/src/adapter/ingest/scene-capture.ts +48 -19
- package/src/adapter/system-adapter.ts +3 -1
- package/src/canvas-react/pixi-react-root-factory.tsx +21 -11
- package/src/data/vite-plugin-data.ts +4 -2
- package/src/index.ts +1 -1
- package/src/input/host-pointer.ts +84 -5
- package/src/input/input-manager.ts +404 -7
- package/src/manifest/configuration-kinds.ts +241 -0
- package/src/manifest/index.ts +4 -3
- package/src/manifest/kind-modules.ts +58 -0
- package/src/manifest/load-file.ts +11 -5
- package/src/manifest/load.ts +53 -22
- package/src/manifest/runtime-environment.ts +48 -0
- package/src/manifest/schema.ts +39 -33
- package/src/react/use-data.ts +1 -1
- package/src/render/matcap-texture.ts +92 -0
- package/src/render/soft-particle-depth.ts +43 -14
- package/src/render/viewport-shading.ts +33 -1
- package/src/runtime/create-runtime.ts +6 -5
- package/src/runtime/mount-manifest.ts +13 -2
- package/src/settings/schema.ts +127 -0
- package/src/world3d-react/r3f-root-factory.tsx +45 -39
- package/src/world3d-react/rapier-physics-bridge.tsx +22 -8
|
@@ -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
|
-
*
|
|
3
|
-
* `
|
|
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.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
`
|
|
263
|
-
`${String(consumers.length)} particle batch(es)
|
|
264
|
-
'
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
379
|
-
*
|
|
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,
|
|
@@ -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.
|
|
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
|
+
}
|
|
@@ -43,7 +43,14 @@ import {
|
|
|
43
43
|
events as pointerEvents,
|
|
44
44
|
type RootState,
|
|
45
45
|
} from '@react-three/fiber';
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
Component,
|
|
48
|
+
type ComponentType,
|
|
49
|
+
createElement,
|
|
50
|
+
Fragment,
|
|
51
|
+
type PropsWithChildren,
|
|
52
|
+
useEffect,
|
|
53
|
+
} from 'react';
|
|
47
54
|
import type { MountedThreeRoot, RootAdapter, ThreeHostContext } from '../adapter';
|
|
48
55
|
import type { SystemAdapters } from '../adapter/system-adapter';
|
|
49
56
|
import { type RenderVitalsRegistration, registerRenderVitals } from '../dev/register-render-vitals';
|
|
@@ -76,6 +83,24 @@ interface R3FEntryModuleExports {
|
|
|
76
83
|
readonly default?: ComponentType;
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
/** React errors must reject a mount in Node too, where there is no window
|
|
87
|
+
* error event. This boundary changes no authored content on successful mounts. */
|
|
88
|
+
class MountErrorBoundary extends Component<
|
|
89
|
+
PropsWithChildren<{ onError: (error: Error) => void }>,
|
|
90
|
+
{ failed: boolean }
|
|
91
|
+
> {
|
|
92
|
+
override state = { failed: false };
|
|
93
|
+
static getDerivedStateFromError(): { failed: boolean } {
|
|
94
|
+
return { failed: true };
|
|
95
|
+
}
|
|
96
|
+
override componentDidCatch(error: Error): void {
|
|
97
|
+
this.props.onError(error);
|
|
98
|
+
}
|
|
99
|
+
override render() {
|
|
100
|
+
return this.state.failed ? null : this.props.children;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
/**
|
|
80
105
|
* Build the `RootAdapter` for one default-exported R3F world. Mounts through
|
|
81
106
|
* react-three-fiber, gated entirely by the host's own loop and rendering
|
|
@@ -89,29 +114,9 @@ function threeWorldAdapter(id: string, component: ComponentType): RootAdapter {
|
|
|
89
114
|
id,
|
|
90
115
|
|
|
91
116
|
async mount(host: ThreeHostContext): Promise<MountedThreeRoot> {
|
|
92
|
-
// Headless
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// return a bare, real scene + camera instead (still `instanceof
|
|
96
|
-
// host.three.Scene` / `.Camera` — the identity rule holds even here,
|
|
97
|
-
// since this uses the host's own `three` instance, not a fresh import).
|
|
98
|
-
// Residual: the hierarchy is empty under headless conformance —
|
|
99
|
-
// recorded as design O2, not hidden; a react-nil-style headless
|
|
100
|
-
// reconciler mount is the known fix, out of scope.
|
|
101
|
-
if (host.headless) {
|
|
102
|
-
const scene = new host.three.Scene();
|
|
103
|
-
const camera = new host.three.PerspectiveCamera();
|
|
104
|
-
return {
|
|
105
|
-
kind: 'three',
|
|
106
|
-
scene,
|
|
107
|
-
camera,
|
|
108
|
-
drivesOwnLoop: false,
|
|
109
|
-
dispose(): void {
|
|
110
|
-
/* nothing was ever mounted */
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
117
|
+
// Headless mounts use this same Fiber reconciler and authored tree.
|
|
118
|
+
// Their host supplies an inert draw command; absence of a GPU is not
|
|
119
|
+
// permission to return a successful but empty scene.
|
|
115
120
|
// Fiber v9 made the THREE catalogue tree-shakeable: `<Canvas>` calls
|
|
116
121
|
// `extend(THREE)` for you, a bare `createRoot` does NOT — without this,
|
|
117
122
|
// the FIRST three intrinsic in the tree (`<color>`, `<ambientLight>`,
|
|
@@ -208,7 +213,7 @@ function threeWorldAdapter(id: string, component: ComponentType): RootAdapter {
|
|
|
208
213
|
// bound to the canvas, no error — and every mesh-level pointer prop
|
|
209
214
|
// (`onClick`, `onPointerOver`, `onPointerMissed`) is dead. Proven by a
|
|
210
215
|
// control experiment differing ONLY in this property.
|
|
211
|
-
events: pointerEvents,
|
|
216
|
+
...(host.headless ? {} : { events: pointerEvents }),
|
|
212
217
|
onCreated: (state) => resolveState(state),
|
|
213
218
|
});
|
|
214
219
|
|
|
@@ -231,24 +236,23 @@ function threeWorldAdapter(id: string, component: ComponentType): RootAdapter {
|
|
|
231
236
|
createElement(MountEffectsReady, { key: 'vgai-mount-effects-ready' }),
|
|
232
237
|
);
|
|
233
238
|
const provided = game ? createElement(WorldProvider, { game }, world) : world;
|
|
234
|
-
root.render(provided);
|
|
235
239
|
// A reconcile-time crash (e.g. a missing `extend` catalogue entry)
|
|
236
240
|
// surfaces as an uncaught window error and `onCreated` never fires —
|
|
237
241
|
// without this guard, `mount()` would await `statePromise` FOREVER and
|
|
238
242
|
// silently wedge every world declared after this one (roots mount
|
|
239
243
|
// sequentially). Convert that class of failure into a loud mount error.
|
|
240
244
|
const state = await new Promise<RootState>((resolve, reject) => {
|
|
241
|
-
const
|
|
245
|
+
const fail = (message: string) => {
|
|
242
246
|
cleanup();
|
|
243
247
|
root.unmount();
|
|
244
248
|
gameDebugRegistry?.strip(id);
|
|
245
249
|
reject(
|
|
246
|
-
new Error(
|
|
247
|
-
`three world "${id}": fiber crashed before its first commit — ${event.message} ` +
|
|
248
|
-
'(mount() fails loudly instead of hanging on onCreated)',
|
|
249
|
-
),
|
|
250
|
+
new Error(`three world "${id}": fiber crashed before its first commit — ${message}`),
|
|
250
251
|
);
|
|
251
252
|
};
|
|
253
|
+
const onError = (event: ErrorEvent) => {
|
|
254
|
+
fail(event.message);
|
|
255
|
+
};
|
|
252
256
|
const timer = setTimeout(() => {
|
|
253
257
|
cleanup();
|
|
254
258
|
root.unmount();
|
|
@@ -262,9 +266,12 @@ function threeWorldAdapter(id: string, component: ComponentType): RootAdapter {
|
|
|
262
266
|
}, 10_000);
|
|
263
267
|
const cleanup = () => {
|
|
264
268
|
clearTimeout(timer);
|
|
265
|
-
window.removeEventListener('error', onError);
|
|
269
|
+
if (typeof window !== 'undefined') window.removeEventListener('error', onError);
|
|
266
270
|
};
|
|
267
|
-
window.addEventListener('error', onError);
|
|
271
|
+
if (typeof window !== 'undefined') window.addEventListener('error', onError);
|
|
272
|
+
root.render(
|
|
273
|
+
createElement(MountErrorBoundary, { onError: (error) => fail(error.message) }, provided),
|
|
274
|
+
);
|
|
268
275
|
void Promise.all([statePromise, effectsReady]).then(([s]) => {
|
|
269
276
|
cleanup();
|
|
270
277
|
resolve(s);
|
|
@@ -301,10 +308,9 @@ function threeWorldAdapter(id: string, component: ComponentType): RootAdapter {
|
|
|
301
308
|
// to write. Gate: `devBuildEnabled()` (the ONE owner of "is this a dev
|
|
302
309
|
// context" — a ship build registers nothing and pays nothing) and a
|
|
303
310
|
// `Game` shell (the readings are folded out of that game's profiler
|
|
304
|
-
// frames).
|
|
305
|
-
// above, before fiber was ever configured.
|
|
311
|
+
// frames). A headless host cannot supply GPU readings.
|
|
306
312
|
let renderVitals: RenderVitalsRegistration | null = null;
|
|
307
|
-
if (host.game && devBuildEnabled()) {
|
|
313
|
+
if (!host.headless && host.game && devBuildEnabled()) {
|
|
308
314
|
// The readings are folded out of profiler frames, and the profiler is
|
|
309
315
|
// a flag the editor already sets on play — under the dev gate this is
|
|
310
316
|
// the same cost arriving a little earlier, not a new one.
|
|
@@ -322,11 +328,11 @@ function threeWorldAdapter(id: string, component: ComponentType): RootAdapter {
|
|
|
322
328
|
}
|
|
323
329
|
|
|
324
330
|
// Engine-owned render introspection, built over the SAME renderer and
|
|
325
|
-
// scene Fiber uses. A design-time stand-in has no context
|
|
326
|
-
//
|
|
331
|
+
// scene Fiber uses. A design-time stand-in has no context; a headless
|
|
332
|
+
// mount also honestly omits the GPU capture capability.
|
|
327
333
|
const rendererContext =
|
|
328
334
|
typeof host.renderer.getContext === 'function' ? host.renderer.getContext() : undefined;
|
|
329
|
-
const frameCaptureContext = frameCaptureContextFor(false, rendererContext);
|
|
335
|
+
const frameCaptureContext = frameCaptureContextFor(host.headless ?? false, rendererContext);
|
|
330
336
|
const frameCapture = frameCaptureContext
|
|
331
337
|
? createWebGLFrameCapture(frameCaptureContext)
|
|
332
338
|
: null;
|
|
@@ -427,17 +427,31 @@ let liveBridgeCount = 0;
|
|
|
427
427
|
* silent degrade): a second concurrent bridge mount — a scene that mounted
|
|
428
428
|
* two, or a second Play instance of a rapier world — is reported the moment
|
|
429
429
|
* it happens, naming what breaks and until when. */
|
|
430
|
+
/** A contention that resolves within this window is a hand-over, not a state:
|
|
431
|
+
* the editor's design session mounts the NEXT world before it disposes the
|
|
432
|
+
* one on screen (the swap waits for the hand), so every source edit put two
|
|
433
|
+
* bridges on the page for a fraction of a second and printed this warning —
|
|
434
|
+
* which every tester read as instability ("wrap your physics system…",
|
|
435
|
+
* runhuman pass 139). Only a contention that OUTLIVES the hand-over is one. */
|
|
436
|
+
const CONTENTION_GRACE_MS = 2_000;
|
|
437
|
+
let contentionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
438
|
+
|
|
430
439
|
function warnOnContendedMount(): void {
|
|
431
440
|
liveBridgeCount += 1;
|
|
432
441
|
if (liveBridgeCount <= 1) return;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
442
|
+
if (contentionTimer !== null) return;
|
|
443
|
+
contentionTimer = setTimeout(() => {
|
|
444
|
+
contentionTimer = null;
|
|
445
|
+
if (liveBridgeCount <= 1) return;
|
|
446
|
+
// biome-ignore lint/suspicious/noConsole: the standing-warning channel for a capability the adapter cannot reach — the editor console surfaces it during play
|
|
447
|
+
console.warn(
|
|
448
|
+
`[rapierPhysicsSystem] ${liveBridgeCount} <RapierPhysicsBridge> mounts are live on this ` +
|
|
449
|
+
'page, but the declared physics slot is one per page (engine modules are shared across ' +
|
|
450
|
+
"Play instances). Every instance's `systems.physics` now forwards to the LAST bridge " +
|
|
451
|
+
'mounted, so physics gizmo edits and instruments address that world — not necessarily ' +
|
|
452
|
+
'the one selected — until the extra mounts unmount.',
|
|
453
|
+
);
|
|
454
|
+
}, CONTENTION_GRACE_MS);
|
|
441
455
|
}
|
|
442
456
|
|
|
443
457
|
const physicsSlot = createSystemSlot<PhysicsAdapter>({
|