@vgai/engine 0.5.40 → 0.5.42
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/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 +21 -0
- package/dist/adapter/authoring.d.ts.map +1 -1
- package/dist/adapter/root-adapter.d.ts +16 -0
- package/dist/adapter/root-adapter.d.ts.map +1 -1
- package/dist/adapter/root-seam-contract.d.ts +5 -0
- package/dist/adapter/root-seam-contract.d.ts.map +1 -1
- package/dist/adapter/root-seam-contract.js +1 -0
- package/dist/ai/navigation.d.ts +30 -0
- package/dist/ai/navigation.d.ts.map +1 -1
- package/dist/ai/navigation.js +248 -15
- package/dist/ecs/user-data.d.ts +12 -0
- package/dist/ecs/user-data.d.ts.map +1 -1
- package/dist/ecs/user-data.js +6 -0
- package/dist/render/environment-capture.d.ts +10 -1
- package/dist/render/environment-capture.d.ts.map +1 -1
- package/dist/render/environment-capture.js +25 -0
- package/dist/render/ibl-override-material.d.ts +11 -2
- package/dist/render/ibl-override-material.d.ts.map +1 -1
- package/dist/render/ibl-override-material.js +16 -2
- package/dist/runtime/debug-bridge.d.ts.map +1 -1
- package/dist/runtime/debug-bridge.js +17 -3
- package/dist/runtime/game.d.ts +13 -6
- package/dist/runtime/game.d.ts.map +1 -1
- package/dist/runtime/game.js +41 -6
- package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
- package/dist/world3d-react/r3f-root-factory.js +12 -5
- package/package.json +1 -1
- package/src/adapter/authoring-seam-contract.ts +1 -0
- package/src/adapter/authoring.ts +21 -0
- package/src/adapter/root-adapter.ts +17 -0
- package/src/adapter/root-seam-contract.ts +1 -0
- package/src/ai/navigation.ts +298 -13
- package/src/ecs/user-data.ts +12 -0
- package/src/render/environment-capture.ts +30 -1
- package/src/render/ibl-override-material.ts +32 -2
- package/src/runtime/debug-bridge.ts +21 -3
- package/src/runtime/game.ts +55 -11
- package/src/world3d-react/r3f-root-factory.tsx +12 -5
package/src/ai/navigation.ts
CHANGED
|
@@ -67,6 +67,37 @@ export interface NavMeshBuildParams {
|
|
|
67
67
|
mergeRegionArea?: number | undefined;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** One authored Detour polygon. Vertex order is clockwise in the engine's XZ world basis. */
|
|
71
|
+
export interface NavMeshPolygon {
|
|
72
|
+
readonly vertices: readonly number[];
|
|
73
|
+
readonly flags?: number | undefined;
|
|
74
|
+
readonly area?: number | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Detail-row offsets/counts for the polygon at the same index. */
|
|
78
|
+
export interface NavMeshDetailMesh {
|
|
79
|
+
readonly vertexBase: number;
|
|
80
|
+
readonly triangleBase: number;
|
|
81
|
+
readonly vertexCount: number;
|
|
82
|
+
readonly triangleCount: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* An already-authored polygon mesh, before Detour's ABI-specific tile serialization.
|
|
87
|
+
*
|
|
88
|
+
* This is the direct-data sibling of {@link buildFromMeshes}: use it when a source ecosystem has
|
|
89
|
+
* already baked navigation. Re-rasterizing an editor bake is lossy—the polygon borders and detail
|
|
90
|
+
* heights are the output, not another input mesh.
|
|
91
|
+
*/
|
|
92
|
+
export interface NavMeshPolygonData {
|
|
93
|
+
readonly vertices: readonly (readonly [number, number, number])[];
|
|
94
|
+
readonly polygons: readonly NavMeshPolygon[];
|
|
95
|
+
readonly detailMeshes: readonly NavMeshDetailMesh[];
|
|
96
|
+
readonly detailVertices: readonly (readonly [number, number, number])[];
|
|
97
|
+
/** Three local vertex indices plus Detour edge flags; all four values are uint8. */
|
|
98
|
+
readonly detailTriangles: readonly (readonly [number, number, number, number])[];
|
|
99
|
+
}
|
|
100
|
+
|
|
70
101
|
let wasmInitialized = false;
|
|
71
102
|
|
|
72
103
|
export async function initNavigation(): Promise<void> {
|
|
@@ -103,24 +134,47 @@ function reverseTriangleWinding(geometry: BufferGeometry): void {
|
|
|
103
134
|
index.needsUpdate = true;
|
|
104
135
|
}
|
|
105
136
|
|
|
137
|
+
/** Signed world-up area across a geometry's triangles. Closed obstacle meshes cancel to zero;
|
|
138
|
+
* broad walkable surfaces retain the winding Recast uses for its slope test. */
|
|
139
|
+
function upwardTriangleArea(geometry: BufferGeometry): number {
|
|
140
|
+
const position = geometry.getAttribute('position');
|
|
141
|
+
const index = geometry.getIndex();
|
|
142
|
+
const vertexAt = (offset: number): number => index?.getX(offset) ?? offset;
|
|
143
|
+
let area = 0;
|
|
144
|
+
const count = index?.count ?? position.count;
|
|
145
|
+
for (let triangle = 0; triangle + 2 < count; triangle += 3) {
|
|
146
|
+
const a = vertexAt(triangle);
|
|
147
|
+
const b = vertexAt(triangle + 1);
|
|
148
|
+
const c = vertexAt(triangle + 2);
|
|
149
|
+
const ux = position.getX(b) - position.getX(a);
|
|
150
|
+
const uz = position.getZ(b) - position.getZ(a);
|
|
151
|
+
const vx = position.getX(c) - position.getX(a);
|
|
152
|
+
const vz = position.getZ(c) - position.getZ(a);
|
|
153
|
+
area += uz * vx - ux * vz;
|
|
154
|
+
}
|
|
155
|
+
return area;
|
|
156
|
+
}
|
|
157
|
+
|
|
106
158
|
function recastInputMeshes(meshes: Mesh[]): {
|
|
107
159
|
readonly meshes: Mesh[];
|
|
108
160
|
readonly temporaryGeometries: BufferGeometry[];
|
|
109
161
|
} {
|
|
110
162
|
const temporaryGeometries: BufferGeometry[] = [];
|
|
111
|
-
const mirroredGeometries = new WeakMap<BufferGeometry, BufferGeometry>();
|
|
112
|
-
const geometryForMatrix = (source: BufferGeometry, matrix: Matrix4): BufferGeometry => {
|
|
113
|
-
if (matrix.determinant() >= 0) return source;
|
|
114
|
-
const cached = mirroredGeometries.get(source);
|
|
115
|
-
if (cached !== undefined) return cached;
|
|
116
|
-
const geometry = source.clone();
|
|
117
|
-
reverseTriangleWinding(geometry);
|
|
118
|
-
mirroredGeometries.set(source, geometry);
|
|
119
|
-
temporaryGeometries.push(geometry);
|
|
120
|
-
return geometry;
|
|
121
|
-
};
|
|
122
163
|
const proxyAt = (geometry: BufferGeometry, matrix: Matrix4): Mesh => {
|
|
123
|
-
|
|
164
|
+
if (matrix.determinant() < 0) {
|
|
165
|
+
// Bake the complete mirrored transform into this clone. Passing a negative-determinant
|
|
166
|
+
// matrix through `@recast-navigation/three` after reversing the source indices still lets
|
|
167
|
+
// its geometry merge recalculate the triangle orientation from that matrix. An identity
|
|
168
|
+
// proxy over already-world-space vertices has exactly one winding decision.
|
|
169
|
+
const transformed = geometry.clone().applyMatrix4(matrix);
|
|
170
|
+
// A negative determinant alone is insufficient: reflecting across a plane's normal axis
|
|
171
|
+
// leaves every vertex in place, while reflecting an in-plane axis reverses its triangles.
|
|
172
|
+
// Inspect the transformed walkable area instead of guessing from the 3D transform.
|
|
173
|
+
if (upwardTriangleArea(transformed) < 0) reverseTriangleWinding(transformed);
|
|
174
|
+
temporaryGeometries.push(transformed);
|
|
175
|
+
return new Mesh(transformed);
|
|
176
|
+
}
|
|
177
|
+
const proxy = new Mesh(geometry);
|
|
124
178
|
// The proxy has no parent, so its local matrix is the source mesh's complete world matrix.
|
|
125
179
|
// The recast bridge calls updateMatrixWorld() itself; disabling auto-update preserves it.
|
|
126
180
|
proxy.matrixAutoUpdate = false;
|
|
@@ -148,6 +202,224 @@ function recastInputMeshes(meshes: Mesh[]): {
|
|
|
148
202
|
return { meshes: corrected, temporaryGeometries };
|
|
149
203
|
}
|
|
150
204
|
|
|
205
|
+
const NAV_MESH_SET_MAGIC = 0x4d53_4554;
|
|
206
|
+
const NAV_MESH_SET_VERSION = 1;
|
|
207
|
+
const DETOUR_NAV_MESH_MAGIC = 0x444e_4156;
|
|
208
|
+
const DETOUR_NAV_MESH_VERSION = 7;
|
|
209
|
+
|
|
210
|
+
function nextPowerOfTwo(value: number): number {
|
|
211
|
+
let result = 1;
|
|
212
|
+
while (result < value) result *= 2;
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Serialize portable polygon/detail arrays as one ordinary Detour tile. */
|
|
217
|
+
function polygonNavMeshData(data: NavMeshPolygonData, params?: NavMeshBuildParams): Uint8Array {
|
|
218
|
+
const vertexCount = data.vertices.length;
|
|
219
|
+
const polygonCount = data.polygons.length;
|
|
220
|
+
if (vertexCount === 0 || polygonCount === 0) {
|
|
221
|
+
throw new Error('NavMeshManager.buildFromPolygons: vertices and polygons must be non-empty');
|
|
222
|
+
}
|
|
223
|
+
if (vertexCount > 0xffff) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`NavMeshManager.buildFromPolygons: ${vertexCount} vertices exceed Detour's uint16 tile index`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (data.detailMeshes.length !== polygonCount) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`NavMeshManager.buildFromPolygons: ${polygonCount} polygons require the same number of detail rows`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const neighbours = data.polygons.map((polygon) => polygon.vertices.map(() => 0));
|
|
235
|
+
const edges = new Map<string, readonly [number, number]>();
|
|
236
|
+
let maxLinkCount = 0;
|
|
237
|
+
for (let polygonIndex = 0; polygonIndex < polygonCount; polygonIndex += 1) {
|
|
238
|
+
const polygon = data.polygons[polygonIndex] as NavMeshPolygon;
|
|
239
|
+
if (polygon.vertices.length < 3 || polygon.vertices.length > 6) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`NavMeshManager.buildFromPolygons: polygon ${polygonIndex} has ` +
|
|
242
|
+
`${polygon.vertices.length} vertices; Detour permits 3..6`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
maxLinkCount += polygon.vertices.length;
|
|
246
|
+
for (let edgeIndex = 0; edgeIndex < polygon.vertices.length; edgeIndex += 1) {
|
|
247
|
+
const from = polygon.vertices[edgeIndex] as number;
|
|
248
|
+
const to = polygon.vertices[(edgeIndex + 1) % polygon.vertices.length] as number;
|
|
249
|
+
if (
|
|
250
|
+
!Number.isInteger(from) ||
|
|
251
|
+
!Number.isInteger(to) ||
|
|
252
|
+
from < 0 ||
|
|
253
|
+
to < 0 ||
|
|
254
|
+
from >= vertexCount ||
|
|
255
|
+
to >= vertexCount
|
|
256
|
+
) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`NavMeshManager.buildFromPolygons: polygon ${polygonIndex} references an invalid vertex`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const directed = `${from},${to}`;
|
|
262
|
+
if (edges.has(directed)) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`NavMeshManager.buildFromPolygons: directed edge ${directed} occurs more than once`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const reverse = edges.get(`${to},${from}`);
|
|
268
|
+
if (reverse === undefined) {
|
|
269
|
+
edges.set(directed, [polygonIndex, edgeIndex]);
|
|
270
|
+
} else {
|
|
271
|
+
const [otherPolygon, otherEdge] = reverse;
|
|
272
|
+
neighbours[polygonIndex]![edgeIndex] = otherPolygon + 1;
|
|
273
|
+
neighbours[otherPolygon]![otherEdge] = polygonIndex + 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (let polygonIndex = 0; polygonIndex < polygonCount; polygonIndex += 1) {
|
|
279
|
+
const row = data.detailMeshes[polygonIndex] as NavMeshDetailMesh;
|
|
280
|
+
if (
|
|
281
|
+
row.vertexBase < 0 ||
|
|
282
|
+
row.vertexCount < 0 ||
|
|
283
|
+
row.vertexBase + row.vertexCount > data.detailVertices.length ||
|
|
284
|
+
row.triangleBase < 0 ||
|
|
285
|
+
row.triangleCount < 0 ||
|
|
286
|
+
row.triangleBase + row.triangleCount > data.detailTriangles.length ||
|
|
287
|
+
row.vertexCount > 0xff ||
|
|
288
|
+
row.triangleCount > 0xff
|
|
289
|
+
) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`NavMeshManager.buildFromPolygons: polygon ${polygonIndex} has an invalid detail row`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const triangle of data.detailTriangles) {
|
|
296
|
+
if (triangle.some((value) => !Number.isInteger(value) || value < 0 || value > 0xff)) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
'NavMeshManager.buildFromPolygons: detail triangles must contain uint8 values',
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const boundsMin = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
|
|
304
|
+
const boundsMax = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
|
305
|
+
for (const vertex of [...data.vertices, ...data.detailVertices]) {
|
|
306
|
+
for (let component = 0; component < 3; component += 1) {
|
|
307
|
+
const value = vertex[component] as number;
|
|
308
|
+
if (!Number.isFinite(value)) {
|
|
309
|
+
throw new Error('NavMeshManager.buildFromPolygons: every vertex component must be finite');
|
|
310
|
+
}
|
|
311
|
+
boundsMin[component] = Math.min(boundsMin[component] as number, value);
|
|
312
|
+
boundsMax[component] = Math.max(boundsMax[component] as number, value);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Detour's in-memory structs are emitted in its stable 32-bit serialized layout. `importNavMesh`
|
|
317
|
+
// owns the ABI boundary and recreates live WASM objects from these ordinary bytes.
|
|
318
|
+
const headerBytes = 100;
|
|
319
|
+
const polygonBytes = 32;
|
|
320
|
+
const linkBytes = 12;
|
|
321
|
+
const detailMeshBytes = 12;
|
|
322
|
+
const detailTriangleBytes = 4;
|
|
323
|
+
const tileBytes =
|
|
324
|
+
headerBytes +
|
|
325
|
+
vertexCount * 12 +
|
|
326
|
+
polygonCount * polygonBytes +
|
|
327
|
+
maxLinkCount * linkBytes +
|
|
328
|
+
polygonCount * detailMeshBytes +
|
|
329
|
+
data.detailVertices.length * 12 +
|
|
330
|
+
data.detailTriangles.length * detailTriangleBytes;
|
|
331
|
+
const bytes = new Uint8Array(48 + tileBytes);
|
|
332
|
+
const view = new DataView(bytes.buffer);
|
|
333
|
+
let offset = 0;
|
|
334
|
+
const uint8 = (value: number): void => {
|
|
335
|
+
view.setUint8(offset, value);
|
|
336
|
+
offset += 1;
|
|
337
|
+
};
|
|
338
|
+
const uint16 = (value: number): void => {
|
|
339
|
+
view.setUint16(offset, value, true);
|
|
340
|
+
offset += 2;
|
|
341
|
+
};
|
|
342
|
+
const uint32 = (value: number): void => {
|
|
343
|
+
view.setUint32(offset, value, true);
|
|
344
|
+
offset += 4;
|
|
345
|
+
};
|
|
346
|
+
const int32 = (value: number): void => {
|
|
347
|
+
view.setInt32(offset, value, true);
|
|
348
|
+
offset += 4;
|
|
349
|
+
};
|
|
350
|
+
const float32 = (value: number): void => {
|
|
351
|
+
view.setFloat32(offset, value, true);
|
|
352
|
+
offset += 4;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const maxPolygons = nextPowerOfTwo(polygonCount);
|
|
356
|
+
uint32(NAV_MESH_SET_MAGIC);
|
|
357
|
+
uint32(NAV_MESH_SET_VERSION);
|
|
358
|
+
uint32(1);
|
|
359
|
+
for (const value of boundsMin) float32(value);
|
|
360
|
+
float32(Math.max((boundsMax[0] as number) - (boundsMin[0] as number), Number.EPSILON));
|
|
361
|
+
float32(Math.max((boundsMax[2] as number) - (boundsMin[2] as number), Number.EPSILON));
|
|
362
|
+
uint32(1);
|
|
363
|
+
uint32(maxPolygons);
|
|
364
|
+
// With one tile, Detour's first valid salt occupies the bits above maxPolygons.
|
|
365
|
+
uint32(maxPolygons);
|
|
366
|
+
uint32(tileBytes);
|
|
367
|
+
|
|
368
|
+
uint32(DETOUR_NAV_MESH_MAGIC);
|
|
369
|
+
uint32(DETOUR_NAV_MESH_VERSION);
|
|
370
|
+
int32(0);
|
|
371
|
+
int32(0);
|
|
372
|
+
int32(0);
|
|
373
|
+
uint32(0);
|
|
374
|
+
uint32(polygonCount);
|
|
375
|
+
uint32(vertexCount);
|
|
376
|
+
uint32(maxLinkCount);
|
|
377
|
+
uint32(polygonCount);
|
|
378
|
+
uint32(data.detailVertices.length);
|
|
379
|
+
uint32(data.detailTriangles.length);
|
|
380
|
+
uint32(0);
|
|
381
|
+
uint32(0);
|
|
382
|
+
uint32(polygonCount);
|
|
383
|
+
const defaults = DEFAULTS.navigation;
|
|
384
|
+
float32(params?.walkableHeight ?? defaults.walkableHeight);
|
|
385
|
+
float32(params?.walkableRadius ?? defaults.walkableRadius);
|
|
386
|
+
float32(params?.walkableClimb ?? defaults.walkableClimb);
|
|
387
|
+
for (const value of boundsMin) float32(value);
|
|
388
|
+
for (const value of boundsMax) float32(value);
|
|
389
|
+
float32(1 / (params?.cellSize ?? defaults.cellSize));
|
|
390
|
+
|
|
391
|
+
for (const vertex of data.vertices) for (const value of vertex) float32(value);
|
|
392
|
+
for (let polygonIndex = 0; polygonIndex < polygonCount; polygonIndex += 1) {
|
|
393
|
+
const polygon = data.polygons[polygonIndex] as NavMeshPolygon;
|
|
394
|
+
uint32(0xffff_ffff);
|
|
395
|
+
for (let index = 0; index < 6; index += 1) uint16(polygon.vertices[index] ?? 0);
|
|
396
|
+
for (let index = 0; index < 6; index += 1) {
|
|
397
|
+
uint16(neighbours[polygonIndex]?.[index] ?? 0);
|
|
398
|
+
}
|
|
399
|
+
uint16(polygon.flags ?? 1);
|
|
400
|
+
uint8(polygon.vertices.length);
|
|
401
|
+
uint8((polygon.area ?? 0) & 0x3f);
|
|
402
|
+
}
|
|
403
|
+
// `dtNavMesh::addTile` turns this zeroed capacity into its free-list, then populates the
|
|
404
|
+
// internal links from the polygon neighbour indices above.
|
|
405
|
+
offset += maxLinkCount * linkBytes;
|
|
406
|
+
for (const row of data.detailMeshes) {
|
|
407
|
+
uint32(row.vertexBase);
|
|
408
|
+
uint32(row.triangleBase);
|
|
409
|
+
uint8(row.vertexCount);
|
|
410
|
+
uint8(row.triangleCount);
|
|
411
|
+
uint16(0);
|
|
412
|
+
}
|
|
413
|
+
for (const vertex of data.detailVertices) for (const value of vertex) float32(value);
|
|
414
|
+
for (const triangle of data.detailTriangles) for (const value of triangle) uint8(value);
|
|
415
|
+
if (offset !== bytes.length) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`NavMeshManager.buildFromPolygons: encoded ${offset} bytes into a ${bytes.length}-byte allocation`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return bytes;
|
|
421
|
+
}
|
|
422
|
+
|
|
151
423
|
export class NavMeshManager {
|
|
152
424
|
private navMesh: NavMesh | null = null;
|
|
153
425
|
private query: NavMeshQuery | null = null;
|
|
@@ -187,7 +459,10 @@ export class NavMeshManager {
|
|
|
187
459
|
try {
|
|
188
460
|
result =
|
|
189
461
|
params?.tiled === true || input.meshes.length > 256 || triangleCount > 10_000
|
|
190
|
-
? threeToTiledNavMesh(input.meshes, {
|
|
462
|
+
? threeToTiledNavMesh(input.meshes, {
|
|
463
|
+
...config,
|
|
464
|
+
tileSize: params?.tileSize ?? 256,
|
|
465
|
+
})
|
|
191
466
|
: threeToSoloNavMesh(input.meshes, config);
|
|
192
467
|
} finally {
|
|
193
468
|
for (const geometry of input.temporaryGeometries) geometry.dispose();
|
|
@@ -202,6 +477,16 @@ export class NavMeshManager {
|
|
|
202
477
|
return true;
|
|
203
478
|
}
|
|
204
479
|
|
|
480
|
+
/** Load an authored polygon/detail mesh directly, without a second rasterization. */
|
|
481
|
+
buildFromPolygons(data: NavMeshPolygonData, params?: NavMeshBuildParams): boolean {
|
|
482
|
+
try {
|
|
483
|
+
this.loadFromData(polygonNavMeshData(data, params));
|
|
484
|
+
return true;
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
205
490
|
/**
|
|
206
491
|
* Destroy all live recast handles (crowd, query, navmesh) and dispose the
|
|
207
492
|
* debug helper. Recast objects live on the WASM heap and are NOT GC'd —
|
package/src/ecs/user-data.ts
CHANGED
|
@@ -82,6 +82,9 @@
|
|
|
82
82
|
* association; unlike the label, never user-facing copy.
|
|
83
83
|
* - `authoringDocument` — generic reference to the project-tool document
|
|
84
84
|
* that owns this scene instance's authored asset.
|
|
85
|
+
* - `authoringHierarchyId`, `authoringHierarchyParentId`,
|
|
86
|
+
* `authoringHierarchyOrder` — source-owned semantic identity, parent identity,
|
|
87
|
+
* and sibling ordinal shared across native surfaces.
|
|
85
88
|
* - `editorHelper` — `true` for editor-only helper objects (gizmos, wireframes).
|
|
86
89
|
*
|
|
87
90
|
* Hierarchy-presentation convention (written by GAME code, read by the editor —
|
|
@@ -193,6 +196,12 @@ export interface UserDataSchema {
|
|
|
193
196
|
readonly name: string;
|
|
194
197
|
readonly title: string;
|
|
195
198
|
};
|
|
199
|
+
/** Project-owned identity used to join authored hierarchy rows across adapter roots. */
|
|
200
|
+
authoringHierarchyId: string;
|
|
201
|
+
/** Project-owned parent identity when the authored parent can live on another surface. */
|
|
202
|
+
authoringHierarchyParentId: string;
|
|
203
|
+
/** Source-owned sibling ordinal used when hierarchy rows cross native roots. */
|
|
204
|
+
authoringHierarchyOrder: number;
|
|
196
205
|
editorHelper: boolean;
|
|
197
206
|
editorHelperType: EditorHelperType;
|
|
198
207
|
editorIcon: boolean;
|
|
@@ -249,6 +258,9 @@ export const UserDataKeys = {
|
|
|
249
258
|
vgaiBodyOwner: 'vgaiBodyOwner',
|
|
250
259
|
authoringComponent: 'authoringComponent',
|
|
251
260
|
authoringDocument: 'authoringDocument',
|
|
261
|
+
authoringHierarchyId: 'authoringHierarchyId',
|
|
262
|
+
authoringHierarchyParentId: 'authoringHierarchyParentId',
|
|
263
|
+
authoringHierarchyOrder: 'authoringHierarchyOrder',
|
|
252
264
|
editorHelper: 'editorHelper',
|
|
253
265
|
editorHelperType: 'editorHelperType',
|
|
254
266
|
editorIcon: 'editorIcon',
|
|
@@ -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
|
|
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
|
|
27
|
+
* `godot-compat`'s Godot `ReflectionProbe`, whose per-fragment models are
|
|
28
28
|
* deliberately different renderers over this one splice.
|
|
29
29
|
*/
|
|
30
|
-
import {
|
|
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
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
package/src/runtime/game.ts
CHANGED
|
@@ -122,8 +122,9 @@ export type AdapterSurface = AdapterSurfaceLeaf;
|
|
|
122
122
|
* A world's per-phase frame hooks.
|
|
123
123
|
* Populated on a `RootInstance` only for first-party mounts — an opaque/
|
|
124
124
|
* foreign mount has no phase-partitioned entry point, so it stays
|
|
125
|
-
* `undefined` and the Game's frame executor
|
|
126
|
-
*
|
|
125
|
+
* `undefined` and the Game's frame executor falls back to its single
|
|
126
|
+
* `mounted.update(dt)`. The mount's `updateCadence` chooses fixed-substep
|
|
127
|
+
* (the default) or display-rate dispatch.
|
|
127
128
|
*/
|
|
128
129
|
export interface RootFrameHooks {
|
|
129
130
|
/** Run this world's engine systems + component ticks + world-bound game
|
|
@@ -639,8 +640,8 @@ export interface Game {
|
|
|
639
640
|
* sway, a cosmetic bob. It is NOT a gameplay hook: it can fire twice
|
|
640
641
|
* between two fixed substeps (120 Hz display, 60 Hz sim) and zero times
|
|
641
642
|
* across a `runTicks` fast-forward, so anything that must be deterministic
|
|
642
|
-
* belongs in a fixed phase (`ctx.systems.add
|
|
643
|
-
* `useFrame`
|
|
643
|
+
* belongs in a fixed engine phase (`ctx.systems.add`) instead. Ecosystem
|
|
644
|
+
* presentation hooks such as R3F's `useFrame` share this display clock.
|
|
644
645
|
*
|
|
645
646
|
* Reached from game code as `ctx.game?.onRenderStep(...)`. Returns an
|
|
646
647
|
* unsubscribe function; pass `opts.signal` to unsubscribe with the same
|
|
@@ -685,13 +686,17 @@ export interface GameInternal extends Game {
|
|
|
685
686
|
* for world in roots: // after ALL phases
|
|
686
687
|
* if world.mounted.drivesOwnLoop: continue
|
|
687
688
|
* if world.frame: world.frame.endFrame?.()
|
|
688
|
-
* else
|
|
689
|
+
* else if updateCadence != 'display' OR render phases are included:
|
|
690
|
+
* world.mounted.update?.(dt) // opaque world fallback
|
|
689
691
|
* ```
|
|
690
692
|
*
|
|
691
693
|
* A single first-party world and its direct `mounted.update(dt)` entry run
|
|
692
694
|
* the same `SystemRunner` and `postFrame` work. A `drivesOwnLoop` world is
|
|
693
695
|
* never ticked here. An opaque host-driven world (no `frame`) gets exactly
|
|
694
|
-
* one `update(dt)` call per substep
|
|
696
|
+
* one `update(dt)` call per substep after the phase loop by default. A
|
|
697
|
+
* display-cadence opaque world is withheld when `skipRenderPhases` is true;
|
|
698
|
+
* the matching host calls it once from {@link runRenderFrame}. A direct
|
|
699
|
+
* `runFrame` with render phases included still calls it once.
|
|
695
700
|
*
|
|
696
701
|
* D10/T7.6 play-state addendum: when `Game.play.paused` is true, every
|
|
697
702
|
* `pausable` (and non-`drivesOwnLoop`) world skips every phase EXCEPT
|
|
@@ -729,6 +734,8 @@ export interface GameInternal extends Game {
|
|
|
729
734
|
* for world in roots (declaration order):
|
|
730
735
|
* if world.mounted.drivesOwnLoop: continue
|
|
731
736
|
* world.frame?.runPhase(phase, frozen ? 0 : displayDt)
|
|
737
|
+
* for opaque world with updateCadence == 'display':
|
|
738
|
+
* world.mounted.update?.(frozen ? 0 : displayDt)
|
|
732
739
|
* ```
|
|
733
740
|
*
|
|
734
741
|
* Advances NOTHING: no `tick`, no `simT`, no sim-clock flush, no state-bridge
|
|
@@ -1081,7 +1088,12 @@ export function createGame(opts: {
|
|
|
1081
1088
|
);
|
|
1082
1089
|
}
|
|
1083
1090
|
}
|
|
1084
|
-
|
|
1091
|
+
// A native entry's static `systems` export is this root's primary
|
|
1092
|
+
// declaration surface. `computeSystemAdapters()` already gives it
|
|
1093
|
+
// precedence over a substrate-seeded mounted adapter; pause must read
|
|
1094
|
+
// the same source or a valid entry-declared audio graph is visible to
|
|
1095
|
+
// the editor yet paradoxically reported as ungateable here.
|
|
1096
|
+
const audio = declaredSystemAdapters.get(world.id)?.audio ?? world.mounted.systems?.audio;
|
|
1085
1097
|
if (audio) {
|
|
1086
1098
|
audio.setMuted(next);
|
|
1087
1099
|
} else if (
|
|
@@ -1288,10 +1300,21 @@ export function createGame(opts: {
|
|
|
1288
1300
|
// Checklist item 2: same isolation for the opaque-world fallback —
|
|
1289
1301
|
// one foreign mount's `update` throwing must not starve its
|
|
1290
1302
|
// siblings' `endFrame`/`update` calls this same loop.
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1303
|
+
// A display-cadence opaque update is the root's indivisible native
|
|
1304
|
+
// presentation (R3F `advance()` is callbacks + draw). The real
|
|
1305
|
+
// rAF host withholds render phases from EVERY fixed catch-up step;
|
|
1306
|
+
// withhold this update beside them, then run it once in
|
|
1307
|
+
// `runRenderFrameImpl`. A direct/offline `runFrame` includes render
|
|
1308
|
+
// phases and therefore still advances the root exactly once.
|
|
1309
|
+
if (!(skipRenderPhases && world.mounted.updateCadence === 'display')) {
|
|
1310
|
+
try {
|
|
1311
|
+
world.mounted.update?.(dt);
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
console.error(
|
|
1314
|
+
`[game] world "${world.id}" (kind: ${world.kind}) update() threw:`,
|
|
1315
|
+
err,
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1295
1318
|
}
|
|
1296
1319
|
}
|
|
1297
1320
|
}
|
|
@@ -1390,6 +1413,27 @@ export function createGame(opts: {
|
|
|
1390
1413
|
profiler.endPhase(phase);
|
|
1391
1414
|
}
|
|
1392
1415
|
|
|
1416
|
+
// An opaque display-cadence root cannot expose phase hooks because its
|
|
1417
|
+
// native advance is inseparable. It still belongs to THIS presentation
|
|
1418
|
+
// frame, once — never to every fixed catch-up substep that preceded it.
|
|
1419
|
+
// A frozen world redraws at dt=0, preserving the established pause rule.
|
|
1420
|
+
for (let i = 0; i < n; i++) {
|
|
1421
|
+
const world = roots[i]!;
|
|
1422
|
+
if (world.disposed) continue;
|
|
1423
|
+
if (world.mounted.drivesOwnLoop) continue;
|
|
1424
|
+
if (world.frame) continue;
|
|
1425
|
+
if (world.mounted.updateCadence !== 'display') continue;
|
|
1426
|
+
const frozen = paused && world.pausable;
|
|
1427
|
+
try {
|
|
1428
|
+
world.mounted.update?.(frozen ? 0 : displayDt);
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
console.error(
|
|
1431
|
+
`[game] world "${world.id}" (kind: ${world.kind}) update() threw (display frame):`,
|
|
1432
|
+
err,
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1393
1437
|
profiler.endFrame();
|
|
1394
1438
|
if (rngTrapEnabled) rngTrap.disable();
|
|
1395
1439
|
}
|
|
@@ -348,11 +348,10 @@ function threeWorldAdapter(id: string, component: ComponentType): RootAdapter {
|
|
|
348
348
|
if (renderDebugWiring) systemAdapters.renderDebug = renderDebugWiring.adapter;
|
|
349
349
|
|
|
350
350
|
// The engine drives every `useFrame` through the mounted world's
|
|
351
|
-
// `update(dt)` hook, never off a raw
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// once.
|
|
351
|
+
// `update(dt)` hook, never off a raw Fiber loop. This mount declares
|
|
352
|
+
// that indivisible Fiber advance at DISPLAY cadence below: the real
|
|
353
|
+
// host calls it once per presentation, a paused world gets dt=0, and an
|
|
354
|
+
// explicit `Game.play.step()` still ticks it exactly once.
|
|
356
355
|
//
|
|
357
356
|
// `advance(timestamp, runGlobalEffects, state)`'s `timestamp` is
|
|
358
357
|
// consumed as `THREE.Clock.elapsedTime` DIRECTLY when
|
|
@@ -385,6 +384,14 @@ function threeWorldAdapter(id: string, component: ComponentType): RootAdapter {
|
|
|
385
384
|
return live().camera;
|
|
386
385
|
},
|
|
387
386
|
drivesOwnLoop: false,
|
|
387
|
+
// Fiber's `advance()` cannot separate its `useFrame` callbacks from
|
|
388
|
+
// its WebGL draw. Running this opaque update in every fixed catch-up
|
|
389
|
+
// substep renders N complete frames inside one rAF callback: once a
|
|
390
|
+
// scene costs more than 16.7ms, the accumulator asks for more draws,
|
|
391
|
+
// those draws make the next gap larger, and the loop spirals to its
|
|
392
|
+
// max-substep ceiling. The host's display pass is the one clock that
|
|
393
|
+
// must own this inseparable native advance.
|
|
394
|
+
updateCadence: 'display',
|
|
388
395
|
// Adapter surface: `debug` (the shared game registry) and, when a real
|
|
389
396
|
// WebGL2 context exists, `renderDebug` are engine-seeded. The game's
|
|
390
397
|
// own capabilities arrive as the entry module's declared `systems`.
|