@forgeax/engine-runtime 0.1.27 → 0.1.29

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 (114) hide show
  1. package/README.md +98 -0
  2. package/dist/collect-scene-asset.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +480 -15
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/scene-utils/mount-override-fold.d.ts +3 -1
  8. package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
  9. package/package.json +37 -30
  10. package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
  11. package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
  12. package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
  13. package/src/__tests__/asset-registry-mounts.test.ts +174 -734
  14. package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
  15. package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
  16. package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
  17. package/src/__tests__/collect-scene-asset.test.ts +96 -113
  18. package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
  19. package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
  20. package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
  21. package/src/__tests__/collect-transient-skip.test.ts +20 -27
  22. package/src/__tests__/components.test-d.ts +17 -1
  23. package/src/__tests__/components.unit.test.ts +75 -27
  24. package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
  25. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
  26. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
  27. package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
  28. package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
  29. package/src/__tests__/errors.unit.test.ts +13 -0
  30. package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
  31. package/src/__tests__/feature-depth-material.fixture.ts +473 -0
  32. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
  33. package/src/__tests__/geometry.unit.test.ts +42 -43
  34. package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
  35. package/src/__tests__/handle-migration.test.ts +0 -1
  36. package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
  37. package/src/__tests__/instances.test-d.ts +13 -52
  38. package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
  39. package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
  40. package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
  41. package/src/__tests__/material-publication.browser.test.ts +29 -0
  42. package/src/__tests__/material-publication.commands.ts +23 -0
  43. package/src/__tests__/material-publication.dawn.test.ts +24 -0
  44. package/src/__tests__/material-publication.fixture.ts +349 -0
  45. package/src/__tests__/material-publication.server.ts +109 -0
  46. package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
  47. package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
  48. package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
  49. package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
  50. package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
  51. package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
  52. package/src/__tests__/record-worldid-isolation.test.ts +47 -28
  53. package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
  54. package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
  55. package/src/__tests__/render-system-extract.test.ts +61 -0
  56. package/src/__tests__/render-system-extract.unit.test.ts +21 -7
  57. package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
  58. package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
  59. package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
  60. package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
  61. package/src/__tests__/render-system-mega.test.ts +50 -62
  62. package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
  63. package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
  64. package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
  65. package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
  66. package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
  67. package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
  68. package/src/__tests__/roundtrip-primitive.test.ts +24 -23
  69. package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
  70. package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
  71. package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
  72. package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
  73. package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
  74. package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
  75. package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
  76. package/src/__tests__/serialize-scene-asset.test.ts +36 -50
  77. package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
  79. package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
  80. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
  81. package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
  82. package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
  83. package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
  84. package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
  85. package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
  86. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
  87. package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
  88. package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
  89. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
  90. package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
  91. package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
  92. package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
  93. package/src/__tests__/transient-fields-collect.test.ts +4 -4
  94. package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
  95. package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
  96. package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
  97. package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
  98. package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
  99. package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
  100. package/src/__tests__/wave1-dynamic-geometry.browser.test.ts +326 -0
  101. package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
  102. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  103. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  104. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  105. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  106. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  107. package/src/__tests__/world-scene-nested.test.ts +90 -163
  108. package/src/__tests__/world-scene-override.test.ts +17 -16
  109. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  110. package/src/collect-scene-asset.ts +637 -19
  111. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  112. package/src/index.ts +1 -0
  113. package/src/scene-utils/mount-override-fold.ts +5 -14
  114. package/src/__tests__/node-shims.d.ts +0 -31
@@ -0,0 +1,582 @@
1
+ import { createWorldContext, World } from '@forgeax/engine-ecs';
2
+ import { createBoxGeometry, deriveVertexLayoutProjection } from '@forgeax/engine-geometry';
3
+ import {
4
+ DirectionalLight,
5
+ type FrameReceipt,
6
+ MeshFilter,
7
+ MeshRenderer,
8
+ type Renderer,
9
+ type RenderWorldLease,
10
+ } from '@forgeax/engine-render';
11
+ import {
12
+ attachRecorder,
13
+ buildFrameModel,
14
+ decodeTape,
15
+ type FrameModel,
16
+ type V7Tape,
17
+ type WorkEntry,
18
+ } from '@forgeax/engine-rhi-debug';
19
+ import { propagateTransforms, scenePlugin } from '@forgeax/engine-scene';
20
+ import type { Handle, MeshAsset } from '@forgeax/engine-types';
21
+ import { ok } from '@forgeax/engine-types';
22
+ import { expect, it } from 'vitest';
23
+ import { commands, page } from 'vitest/browser';
24
+ import { createWave1RenderingRecipe } from '../../../../scripts/dev-verify/wave1-rendering/recipe';
25
+ import { loadBackendPack } from '../backend-selection';
26
+ import { constructRuntimeRendererHost } from '../renderer-host';
27
+
28
+ interface ScreenshotPixels {
29
+ readonly width: number;
30
+ readonly height: number;
31
+ readonly pixels: Uint8Array;
32
+ }
33
+
34
+ interface PixelStats {
35
+ readonly nonBlackPixels: number;
36
+ readonly meanLuma: number;
37
+ readonly maxChannel: number;
38
+ }
39
+
40
+ interface PixelDelta {
41
+ readonly changedPixels: number;
42
+ readonly meanAbsoluteChannelDelta: number;
43
+ readonly maxChannelDelta: number;
44
+ }
45
+
46
+ interface FrameEvidence {
47
+ readonly receipt: FrameReceipt;
48
+ readonly pixels: ScreenshotPixels;
49
+ }
50
+
51
+ function inspect(value: unknown): string {
52
+ return JSON.stringify(value, (_key, item) =>
53
+ item instanceof Error ? { ...item, message: item.message } : item,
54
+ );
55
+ }
56
+
57
+ async function screenshotPixels(
58
+ canvas: HTMLCanvasElement,
59
+ artifactName: string,
60
+ ): Promise<ScreenshotPixels> {
61
+ const shot = await page.elementLocator(canvas).screenshot({
62
+ path: `../../../../artifacts/wave1-rendering/${artifactName}.png`,
63
+ base64: true,
64
+ });
65
+ const base64 = typeof shot === 'string' ? shot : shot.base64;
66
+ const binary = atob(base64);
67
+ const bytes = new Uint8Array(binary.length);
68
+ for (let index = 0; index < binary.length; index += 1) {
69
+ bytes[index] = binary.charCodeAt(index);
70
+ }
71
+ const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
72
+ const offscreen = new OffscreenCanvas(bitmap.width, bitmap.height);
73
+ const context = offscreen.getContext('2d', { willReadFrequently: true });
74
+ if (context === null) {
75
+ bitmap.close();
76
+ throw new Error('wave1-shadow-diagnostic: screenshot pixel context unavailable');
77
+ }
78
+ context.drawImage(bitmap, 0, 0);
79
+ const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
80
+ bitmap.close();
81
+ return { width: image.width, height: image.height, pixels: new Uint8Array(image.data) };
82
+ }
83
+
84
+ function pixelStats(readback: ScreenshotPixels): PixelStats {
85
+ let nonBlackPixels = 0;
86
+ let lumaTotal = 0;
87
+ let maxChannel = 0;
88
+ for (let offset = 0; offset < readback.pixels.length; offset += 4) {
89
+ const red = readback.pixels[offset] ?? 0;
90
+ const green = readback.pixels[offset + 1] ?? 0;
91
+ const blue = readback.pixels[offset + 2] ?? 0;
92
+ if (red + green + blue > 3) nonBlackPixels += 1;
93
+ lumaTotal += 0.2126 * red + 0.7152 * green + 0.0722 * blue;
94
+ maxChannel = Math.max(maxChannel, red, green, blue);
95
+ }
96
+ return {
97
+ nonBlackPixels,
98
+ meanLuma: lumaTotal / Math.max(1, readback.width * readback.height),
99
+ maxChannel,
100
+ };
101
+ }
102
+
103
+ function pixelDelta(left: ScreenshotPixels, right: ScreenshotPixels): PixelDelta {
104
+ if (left.width !== right.width || left.height !== right.height) {
105
+ return {
106
+ changedPixels: Math.max(left.width * left.height, right.width * right.height),
107
+ meanAbsoluteChannelDelta: 255,
108
+ maxChannelDelta: 255,
109
+ };
110
+ }
111
+ let changedPixels = 0;
112
+ let absoluteDelta = 0;
113
+ let maxChannelDelta = 0;
114
+ for (let offset = 0; offset < left.pixels.length; offset += 4) {
115
+ let changed = false;
116
+ for (let channel = 0; channel < 3; channel += 1) {
117
+ const delta = Math.abs(
118
+ (left.pixels[offset + channel] ?? 0) - (right.pixels[offset + channel] ?? 0),
119
+ );
120
+ if (delta > 0) changed = true;
121
+ absoluteDelta += delta;
122
+ maxChannelDelta = Math.max(maxChannelDelta, delta);
123
+ }
124
+ if (changed) changedPixels += 1;
125
+ }
126
+ return {
127
+ changedPixels,
128
+ meanAbsoluteChannelDelta: absoluteDelta / Math.max(1, left.width * left.height * 3),
129
+ maxChannelDelta,
130
+ };
131
+ }
132
+
133
+ function cropPixels(
134
+ readback: ScreenshotPixels,
135
+ centerX: number,
136
+ centerY: number,
137
+ halfWidth: number,
138
+ halfHeight: number,
139
+ ): ScreenshotPixels {
140
+ const minX = Math.max(0, Math.floor((centerX - halfWidth) * readback.width));
141
+ const maxX = Math.min(readback.width, Math.ceil((centerX + halfWidth) * readback.width));
142
+ const minY = Math.max(0, Math.floor((centerY - halfHeight) * readback.height));
143
+ const maxY = Math.min(readback.height, Math.ceil((centerY + halfHeight) * readback.height));
144
+ const width = Math.max(1, maxX - minX);
145
+ const height = Math.max(1, maxY - minY);
146
+ const pixels = new Uint8Array(width * height * 4);
147
+ for (let row = 0; row < height; row += 1) {
148
+ const sourceStart = ((minY + row) * readback.width + minX) * 4;
149
+ const sourceEnd = sourceStart + width * 4;
150
+ pixels.set(readback.pixels.subarray(sourceStart, sourceEnd), row * width * 4);
151
+ }
152
+ return { width, height, pixels };
153
+ }
154
+
155
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
156
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
157
+ ? (value as Record<string, unknown>)
158
+ : undefined;
159
+ }
160
+
161
+ function meshLayout(mesh: MeshAsset): Record<string, unknown> {
162
+ const projection = deriveVertexLayoutProjection(mesh.attributes);
163
+ return {
164
+ digest: projection.digest,
165
+ arrayStride: projection.arrayStride,
166
+ attributes: projection.attributes.map((attribute) => ({
167
+ key: attribute.key,
168
+ shaderLocation: attribute.shaderLocation,
169
+ offset: attribute.offset,
170
+ format: attribute.format,
171
+ })),
172
+ };
173
+ }
174
+
175
+ function pipelineVertexLayouts(work: WorkEntry): readonly unknown[] {
176
+ const pipeline = asRecord(work.pipeline.descriptor);
177
+ const desc = asRecord(pipeline?.desc);
178
+ const vertex = asRecord(desc?.vertex);
179
+ const buffers = vertex?.buffers;
180
+ return Array.isArray(buffers)
181
+ ? buffers.map((buffer) => {
182
+ const layout = asRecord(buffer);
183
+ return layout === undefined
184
+ ? null
185
+ : {
186
+ arrayStride: layout.arrayStride ?? null,
187
+ stepMode: layout.stepMode ?? null,
188
+ attributes: Array.isArray(layout.attributes) ? layout.attributes : [],
189
+ };
190
+ })
191
+ : [];
192
+ }
193
+
194
+ function arrayStrideOf(layout: unknown): number | undefined {
195
+ const record = asRecord(layout);
196
+ return typeof record?.arrayStride === 'number' ? record.arrayStride : undefined;
197
+ }
198
+
199
+ function sourceKind(work: WorkEntry): 'shadow' | 'forward' | 'unavailable' {
200
+ if (work.pipeline.status !== 'available') return 'unavailable';
201
+ if (
202
+ work.attachments?.colorViewHandleIds.length === 0 ||
203
+ work.pipeline.shaders.some(
204
+ (shader) =>
205
+ shader.entryPoint?.includes('shadow') === true ||
206
+ shader.source?.includes('fn fs_shadow') === true,
207
+ )
208
+ ) {
209
+ return 'shadow';
210
+ }
211
+ return 'forward';
212
+ }
213
+
214
+ function workEvidence(model: FrameModel): readonly Record<string, unknown>[] {
215
+ return model.works
216
+ .filter((work) => work.pipeline.kind === 'render')
217
+ .map((work) => {
218
+ const command = model.commands[work.commandIndex];
219
+ return {
220
+ workIndex: work.workIndex,
221
+ passIndex: work.passIndex,
222
+ kind: work.kind,
223
+ sourceKind: sourceKind(work),
224
+ commandKind: command?.kind ?? null,
225
+ commandGroup: command?.group ?? [],
226
+ vertexBuffers: work.vertexBuffers,
227
+ pipelineVertexLayouts: pipelineVertexLayouts(work),
228
+ shaders: work.pipeline.shaders.map((shader) => ({
229
+ stage: shader.stage,
230
+ entryPoint: shader.entryPoint,
231
+ sourceMarker:
232
+ shader.source === null
233
+ ? null
234
+ : shader.source.includes('default-shadow-caster')
235
+ ? 'default-shadow-caster'
236
+ : shader.source.includes('shadow')
237
+ ? 'shadow-related'
238
+ : 'other',
239
+ })),
240
+ attachments: work.attachments,
241
+ };
242
+ });
243
+ }
244
+
245
+ function bootstrapPipelineEvidence(tape: V7Tape): readonly Record<string, unknown>[] {
246
+ const shaders = new Map(
247
+ tape.bootstrap
248
+ .filter((resource) => resource.kind === 'shader-module')
249
+ .map((resource) => {
250
+ const create = asRecord(resource.create);
251
+ return [resource.handleId, create?.wgslCode];
252
+ }),
253
+ );
254
+ return tape.bootstrap
255
+ .filter((resource) => resource.kind === 'pipeline')
256
+ .map((resource) => {
257
+ const create = asRecord(resource.create);
258
+ const desc = asRecord(create?.desc);
259
+ const vertex = asRecord(desc?.vertex);
260
+ const fragment = asRecord(desc?.fragment);
261
+ const vertexShaderModuleHandleId = create?.vertexShaderModuleHandleId;
262
+ const fragmentShaderModuleHandleId = create?.fragmentShaderModuleHandleId;
263
+ const vertexSource =
264
+ typeof vertexShaderModuleHandleId === 'string'
265
+ ? shaders.get(vertexShaderModuleHandleId)
266
+ : undefined;
267
+ const fragmentSource =
268
+ typeof fragmentShaderModuleHandleId === 'string'
269
+ ? shaders.get(fragmentShaderModuleHandleId)
270
+ : undefined;
271
+ return {
272
+ handleId: resource.handleId,
273
+ vertexEntryPoint: vertex?.entryPoint ?? null,
274
+ fragmentEntryPoint: fragment?.entryPoint ?? null,
275
+ vertexSourceMarker:
276
+ typeof vertexSource === 'string'
277
+ ? vertexSource.includes('shadow')
278
+ ? 'shadow-related'
279
+ : 'other'
280
+ : null,
281
+ fragmentSourceMarker:
282
+ typeof fragmentSource === 'string'
283
+ ? fragmentSource.includes('shadow')
284
+ ? 'shadow-related'
285
+ : 'other'
286
+ : null,
287
+ vertexBuffers: Array.isArray(vertex?.buffers)
288
+ ? vertex.buffers.map((buffer) => {
289
+ const layout = asRecord(buffer);
290
+ return layout === undefined
291
+ ? null
292
+ : {
293
+ arrayStride: layout.arrayStride ?? null,
294
+ stepMode: layout.stepMode ?? null,
295
+ attributes: Array.isArray(layout.attributes) ? layout.attributes : [],
296
+ };
297
+ })
298
+ : [],
299
+ };
300
+ });
301
+ }
302
+
303
+ function bytesToBase64(bytes: Uint8Array): string {
304
+ let binary = '';
305
+ const chunkSize = 0x8000;
306
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
307
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
308
+ }
309
+ return btoa(binary);
310
+ }
311
+
312
+ async function submitFrame(
313
+ world: World,
314
+ renderer: Renderer,
315
+ lease: RenderWorldLease,
316
+ ): Promise<FrameReceipt> {
317
+ propagateTransforms(world).unwrap();
318
+ const drawn = renderer.draw({ leases: [lease], camera: { lease }, environment: { lease } });
319
+ if (!drawn.ok) throw drawn.error;
320
+ const completed = await drawn.value.completed;
321
+ if (!completed.ok) throw completed.error;
322
+ return drawn.value;
323
+ }
324
+
325
+ async function renderVariant(
326
+ world: World,
327
+ renderer: Renderer,
328
+ lease: RenderWorldLease,
329
+ canvas: HTMLCanvasElement,
330
+ artifactName: string,
331
+ ): Promise<FrameEvidence> {
332
+ world.update(1 / 60).unwrap();
333
+ const receipt = await submitFrame(world, renderer, lease);
334
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
335
+ return { receipt, pixels: await screenshotPixels(canvas, artifactName) };
336
+ }
337
+
338
+ it('captures one shadow layout tape and isolates jagged shadow casters', async () => {
339
+ const canvas = document.createElement('canvas');
340
+ canvas.width = 320;
341
+ canvas.height = 320;
342
+ canvas.style.width = '320px';
343
+ canvas.style.height = '320px';
344
+ document.body.appendChild(canvas);
345
+
346
+ const backend = await loadBackendPack({});
347
+ if (!backend.ok) throw backend.error;
348
+ if (backend.value.createShaderModule === undefined) {
349
+ throw new Error('wave1-shadow-diagnostic: backend shader factory unavailable');
350
+ }
351
+ const recording = attachRecorder({
352
+ ...backend.value,
353
+ createShaderModule: backend.value.createShaderModule,
354
+ });
355
+ if (!recording.ok) throw recording.error;
356
+ const recorder = recording.value;
357
+ const rendererResult = await constructRuntimeRendererHost(
358
+ canvas,
359
+ {
360
+ rhi: recorder.backend.rhi,
361
+ rhiInstrumentation: {
362
+ resolveSurfaceDevice(device) {
363
+ const unwrapped = recorder.backend.unwrapDeviceForSurface(device);
364
+ if (!unwrapped.ok) throw unwrapped.error;
365
+ return ok(unwrapped.value);
366
+ },
367
+ },
368
+ },
369
+ { shaderManifestUrl: '/shaders/manifest.json' },
370
+ );
371
+ if (!rendererResult.ok) throw rendererResult.error;
372
+ const renderer = rendererResult.value.renderer;
373
+ const world = new World();
374
+ const scene = await createWorldContext(world, [scenePlugin()]);
375
+ const recipe = createWave1RenderingRecipe(world, { aspect: 1, includeAtmosphere: true });
376
+ const attached = renderer.attach(world);
377
+ if (!attached.ok) throw attached.error;
378
+ const lease = attached.value;
379
+ let canonicalHandle: Handle<'MeshAsset', 'shared'> | undefined;
380
+ let canonicalFloorHandle: Handle<'MeshAsset', 'shared'> | undefined;
381
+ const rendererErrors: unknown[] = [];
382
+ const unsubscribe = renderer.subscribe((event) => {
383
+ if (event.kind === 'error') rendererErrors.push(event.error);
384
+ });
385
+
386
+ try {
387
+ // Warm the ordinary scene so the captured frame contains the actual
388
+ // shadow and forward pipelines rather than first-touch skip-draws.
389
+ for (let frame = 0; frame < 4; frame += 1) {
390
+ world.update(1 / 60).unwrap();
391
+ await submitFrame(world, renderer, lease);
392
+ }
393
+
394
+ // Make the capture frame a shadow-cache invalidation point. A steady
395
+ // frame is allowed to expose the shadow writer pass with zero work when
396
+ // its cached atlas is reused; this tiny transform change makes the
397
+ // producer record the caster draw in the same tape as the forward draw.
398
+ recipe.setPosition(recipe.entities.wall, [0.03, 0, 0]);
399
+ const capture = recorder.captureFrame();
400
+ const before = await recorder.frameBoundary();
401
+ if (!before.ok) throw before.error;
402
+ const full = await renderVariant(world, renderer, lease, canvas, 'wave1-shadow-full');
403
+ const after = await recorder.frameBoundary();
404
+ if (!after.ok) throw after.error;
405
+ const encoded = await capture;
406
+ if (!encoded.ok) throw encoded.error;
407
+ const decoded = decodeTape(encoded.value.bytes);
408
+ if (!decoded.ok) throw decoded.error;
409
+ const model = buildFrameModel(decoded.value);
410
+ const evidence = workEvidence(model);
411
+ const coloredLayout = meshLayout(recipe.assets.wallClosed);
412
+ const coloredFloorLayout = meshLayout(recipe.assets.floor);
413
+ const canonicalMesh = createBoxGeometry(2.8, 2.6, 0.55).unwrap();
414
+ const canonicalFloorMesh = createBoxGeometry(8, 0.2, 8).unwrap();
415
+ const canonicalLayout = meshLayout(canonicalMesh);
416
+ const canonicalFloorLayout = meshLayout(canonicalFloorMesh);
417
+ canonicalHandle = world.allocSharedRef('MeshAsset', canonicalMesh);
418
+ canonicalFloorHandle = world.allocSharedRef('MeshAsset', canonicalFloorMesh);
419
+
420
+ // Persist the raw tape through Vitest's host file command. Sending this
421
+ // multi-megabyte payload through console reporting can stall CI log transport.
422
+ const tapePath = 'artifacts/wave1-rendering/wave1-shadow.rhitape';
423
+ await commands.writeFile(tapePath, bytesToBase64(encoded.value.bytes), 'base64');
424
+ // Retain every render work's bound buffer and pipeline layout in the summary.
425
+ // biome-ignore lint/suspicious/noConsole: raw diagnostic evidence is intentional.
426
+ console.info(
427
+ '[wave1-shadow-diagnostic] capture',
428
+ JSON.stringify({
429
+ tape: {
430
+ path: tapePath,
431
+ digest: encoded.value.digest,
432
+ byteLength: encoded.value.bytes.byteLength,
433
+ formatVersion: decoded.value.header.formatVersion,
434
+ eventCount: decoded.value.events.length,
435
+ bootstrapCount: decoded.value.bootstrap.length,
436
+ },
437
+ sourceLayouts: {
438
+ colored: coloredLayout,
439
+ coloredFloor: coloredFloorLayout,
440
+ canonical: canonicalLayout,
441
+ canonicalFloor: canonicalFloorLayout,
442
+ },
443
+ resources: model.resourceLifecycle,
444
+ renderer: renderer.inspect(),
445
+ passes: model.passes,
446
+ bootstrapPipelines: bootstrapPipelineEvidence(decoded.value),
447
+ renderWorks: evidence,
448
+ }),
449
+ );
450
+ expect(decoded.value.header.formatVersion).toBe(7);
451
+ expect(evidence.some((work) => work.sourceKind === 'forward')).toBe(true);
452
+
453
+ const wallAndFloorOnly = recipe.entities.screen;
454
+ const roof = recipe.entities.roof;
455
+ recipe.destroy(wallAndFloorOnly);
456
+ recipe.destroy(roof);
457
+ const stripped = await renderVariant(
458
+ world,
459
+ renderer,
460
+ lease,
461
+ canvas,
462
+ 'wave1-shadow-wall-floor-only',
463
+ );
464
+
465
+ world.set(recipe.entities.sun, DirectionalLight, { castShadow: false }).unwrap();
466
+ const noCasterShadow = await renderVariant(
467
+ world,
468
+ renderer,
469
+ lease,
470
+ canvas,
471
+ 'wave1-shadow-cast-shadow-off',
472
+ );
473
+
474
+ world.set(recipe.entities.sun, DirectionalLight, { castShadow: true }).unwrap();
475
+ world.set(recipe.entities.wall, MeshRenderer, { materials: [recipe.assets.wood] }).unwrap();
476
+ world.set(recipe.entities.wall, MeshFilter, { assetHandle: canonicalHandle }).unwrap();
477
+ world
478
+ .set(recipe.entities.floor, MeshRenderer, { materials: [recipe.assets.floorMaterial] })
479
+ .unwrap();
480
+ world.set(recipe.entities.floor, MeshFilter, { assetHandle: canonicalFloorHandle }).unwrap();
481
+ const canonical = await renderVariant(
482
+ world,
483
+ renderer,
484
+ lease,
485
+ canvas,
486
+ 'wave1-shadow-canonical-box',
487
+ );
488
+
489
+ const floorRoi = (readback: ScreenshotPixels): ScreenshotPixels =>
490
+ cropPixels(readback, 0.5, 0.82, 0.38, 0.14);
491
+ const deltas = {
492
+ fullToWallFloor: pixelDelta(full.pixels, stripped.pixels),
493
+ wallFloorToShadowOff: pixelDelta(stripped.pixels, noCasterShadow.pixels),
494
+ shadowOffToCanonical: pixelDelta(noCasterShadow.pixels, canonical.pixels),
495
+ };
496
+ const floorRoiStats = {
497
+ full: pixelStats(floorRoi(full.pixels)),
498
+ wallFloorOnly: pixelStats(floorRoi(stripped.pixels)),
499
+ castShadowOff: pixelStats(floorRoi(noCasterShadow.pixels)),
500
+ canonicalBox: pixelStats(floorRoi(canonical.pixels)),
501
+ };
502
+ const floorRoiDeltas = {
503
+ wallFloorToShadowOff: pixelDelta(floorRoi(stripped.pixels), floorRoi(noCasterShadow.pixels)),
504
+ shadowOffToCanonical: pixelDelta(floorRoi(noCasterShadow.pixels), floorRoi(canonical.pixels)),
505
+ };
506
+ // biome-ignore lint/suspicious/noConsole: variant pixels are diagnostic evidence.
507
+ console.info(
508
+ '[wave1-shadow-diagnostic] variants',
509
+ JSON.stringify({
510
+ frames: {
511
+ full: { receipt: full.receipt, pixels: pixelStats(full.pixels) },
512
+ wallFloorOnly: { receipt: stripped.receipt, pixels: pixelStats(stripped.pixels) },
513
+ castShadowOff: {
514
+ receipt: noCasterShadow.receipt,
515
+ pixels: pixelStats(noCasterShadow.pixels),
516
+ },
517
+ canonicalBox: { receipt: canonical.receipt, pixels: pixelStats(canonical.pixels) },
518
+ },
519
+ deltas,
520
+ floorRoi: { stats: floorRoiStats, deltas: floorRoiDeltas },
521
+ rendererErrors: rendererErrors.map(inspect),
522
+ }),
523
+ );
524
+ const shadowPasses = model.passes.filter(
525
+ (pass) =>
526
+ pass.kind === 'render' &&
527
+ pass.depthStencilViewHandleId !== null &&
528
+ pass.colorAttachmentViewHandleIds.length === 0,
529
+ );
530
+ const shadowWorks = model.works.filter((work) =>
531
+ shadowPasses.some((pass) => pass.workIndices.includes(work.workIndex)),
532
+ );
533
+ const shadowStrides = shadowWorks.flatMap((work) =>
534
+ pipelineVertexLayouts(work).flatMap((layout) => {
535
+ const stride = arrayStrideOf(layout);
536
+ return stride === undefined ? [] : [stride];
537
+ }),
538
+ );
539
+ const forwardWorks = model.works.filter(
540
+ (work) =>
541
+ work.pipeline.kind === 'render' &&
542
+ (model.passes[work.passIndex]?.colorAttachmentViewHandleIds.length ?? 0) > 0,
543
+ );
544
+ const forwardStrides = forwardWorks.flatMap((work) =>
545
+ pipelineVertexLayouts(work).flatMap((layout) => {
546
+ const stride = arrayStrideOf(layout);
547
+ return stride === undefined ? [] : [stride];
548
+ }),
549
+ );
550
+ // This is deliberately a red reproducer on the pre-fix source: the
551
+ // shadow graph advertises writer passes but records no caster work when
552
+ // the default caster pipeline is not built for the colored mesh layout.
553
+ // Once the owner fixes the pipeline, the same gate locks shadow and
554
+ // forward vertex strides to the recipe's 64-byte layout.
555
+ expect(
556
+ shadowWorks.length,
557
+ JSON.stringify({ shadowPasses, shadowStrides, forwardStrides, evidence }),
558
+ ).toBeGreaterThan(0);
559
+ expect(new Set(shadowStrides)).toEqual(new Set([coloredLayout.arrayStride]));
560
+ expect(new Set(forwardStrides)).toEqual(new Set([coloredLayout.arrayStride]));
561
+ expect(full.pixels.width).toBeGreaterThan(0);
562
+ expect(full.pixels.height).toBeGreaterThan(0);
563
+ expect(pixelStats(full.pixels).nonBlackPixels).toBeGreaterThan(0);
564
+ expect(pixelStats(stripped.pixels).nonBlackPixels).toBeGreaterThan(0);
565
+ expect(pixelStats(noCasterShadow.pixels).nonBlackPixels).toBeGreaterThan(0);
566
+ expect(pixelStats(canonical.pixels).nonBlackPixels).toBeGreaterThan(0);
567
+ expect(full.receipt.frameId).toBeLessThan(stripped.receipt.frameId);
568
+ expect(stripped.receipt.frameId).toBeLessThan(noCasterShadow.receipt.frameId);
569
+ expect(noCasterShadow.receipt.frameId).toBeLessThan(canonical.receipt.frameId);
570
+ expect(rendererErrors).toEqual([]);
571
+ } finally {
572
+ unsubscribe();
573
+ await renderer.dispose();
574
+ recipe.dispose();
575
+ if (canonicalHandle !== undefined) world.sharedRefs.release(canonicalHandle).unwrap();
576
+ if (canonicalFloorHandle !== undefined) world.sharedRefs.release(canonicalFloorHandle).unwrap();
577
+ await scene.fiber.dispose();
578
+ const recorderDisposed = await recorder.dispose();
579
+ expect(recorderDisposed.ok, inspect(recorderDisposed)).toBe(true);
580
+ canvas.remove();
581
+ }
582
+ }, 120000);
@@ -34,10 +34,10 @@ describe('world.despawnScene (w19)', () => {
34
34
  const world = new World();
35
35
  const asset: SceneAsset = {
36
36
  kind: 'scene',
37
- entities: [
38
- { localId: 0 as never, components: { Transform: {} } },
39
- { localId: 1 as never, components: { Transform: {} } },
40
- ],
37
+ entities: {
38
+ 'entity-0': { components: { Transform: {} } },
39
+ 'entity-1': { components: { Transform: {} } },
40
+ },
41
41
  };
42
42
  const handle = registerSceneAsset(world, asset);
43
43
  const r = worldInstantiateScene(world, handle);
@@ -64,10 +64,10 @@ describe('world.despawnScene (w19)', () => {
64
64
  const world = new World();
65
65
  const asset: SceneAsset = {
66
66
  kind: 'scene',
67
- entities: [
68
- { localId: 0 as never, components: { Transform: {} } },
69
- { localId: 1 as never, components: { Transform: {} } },
70
- ],
67
+ entities: {
68
+ 'entity-0': { components: { Transform: {} } },
69
+ 'entity-1': { components: { Transform: {} } },
70
+ },
71
71
  };
72
72
  const handle = registerSceneAsset(world, asset);
73
73
  const r = worldInstantiateScene(world, handle);