@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,617 @@
1
+ import { createWorldContext, FixedTime, World } from '@forgeax/engine-ecs';
2
+ import { createBoxGeometry, createMeshBuilder } from '@forgeax/engine-geometry';
3
+ import {
4
+ Atmosphere,
5
+ type FrameReceipt,
6
+ Materials,
7
+ MeshFilter,
8
+ MeshRenderer,
9
+ type Renderer,
10
+ type RenderInspection,
11
+ type RenderWorldLease,
12
+ VolumetricFog,
13
+ } from '@forgeax/engine-render';
14
+ import { propagateTransforms, scenePlugin, Transform } from '@forgeax/engine-scene';
15
+ import type { MeshAsset, TextureAsset, VertexAttributeMap } from '@forgeax/engine-types';
16
+ import { expect, it } from 'vitest';
17
+ import { page } from 'vitest/browser';
18
+ import {
19
+ createWave1RenderingRecipe,
20
+ WAVE1_ATMOSPHERE_PRESET,
21
+ } from '../../../../scripts/dev-verify/wave1-rendering/recipe';
22
+ import { createRenderer } from '../createRenderer';
23
+
24
+ interface ScreenshotPixels {
25
+ readonly width: number;
26
+ readonly height: number;
27
+ readonly pixels: Uint8Array;
28
+ }
29
+
30
+ interface PixelStats {
31
+ readonly nonBlackPixels: number;
32
+ readonly meanLuma: number;
33
+ readonly channelTotals: readonly [number, number, number];
34
+ }
35
+
36
+ interface PixelDelta {
37
+ readonly changedPixels: number;
38
+ readonly meanAbsoluteChannelDelta: number;
39
+ readonly maxChannelDelta: number;
40
+ }
41
+
42
+ interface FrameSample {
43
+ readonly receipt: FrameReceipt;
44
+ readonly pixels: ScreenshotPixels;
45
+ readonly inspection: RenderInspection;
46
+ readonly observedIncludes: readonly ('timings' | 'draws' | 'bindings' | 'target-readbacks')[];
47
+ }
48
+
49
+ const ARTIFACT_ROOT = '../../../../artifacts/wave1-rendering/';
50
+
51
+ async function screenshotPixels(
52
+ canvas: HTMLCanvasElement,
53
+ label: string,
54
+ ): Promise<ScreenshotPixels> {
55
+ const shot = await page.elementLocator(canvas).screenshot({
56
+ path: `${ARTIFACT_ROOT}wave1-materials-${label}.png`,
57
+ base64: true,
58
+ });
59
+ const base64 = typeof shot === 'string' ? shot : shot.base64;
60
+ const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
61
+ const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
62
+ const surface = new OffscreenCanvas(bitmap.width, bitmap.height);
63
+ const context = surface.getContext('2d', { willReadFrequently: true });
64
+ if (context === null) {
65
+ bitmap.close();
66
+ throw new Error('wave1-rendering materials: screenshot pixel context unavailable');
67
+ }
68
+ context.drawImage(bitmap, 0, 0);
69
+ const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
70
+ bitmap.close();
71
+ return {
72
+ width: image.width,
73
+ height: image.height,
74
+ pixels: new Uint8Array(image.data),
75
+ };
76
+ }
77
+
78
+ function pixelStats(readback: ScreenshotPixels): PixelStats {
79
+ let nonBlackPixels = 0;
80
+ let lumaTotal = 0;
81
+ let redTotal = 0;
82
+ let greenTotal = 0;
83
+ let blueTotal = 0;
84
+ for (let offset = 0; offset < readback.pixels.length; offset += 4) {
85
+ const red = readback.pixels[offset] ?? 0;
86
+ const green = readback.pixels[offset + 1] ?? 0;
87
+ const blue = readback.pixels[offset + 2] ?? 0;
88
+ if (red + green + blue > 3) nonBlackPixels += 1;
89
+ lumaTotal += 0.2126 * red + 0.7152 * green + 0.0722 * blue;
90
+ redTotal += red;
91
+ greenTotal += green;
92
+ blueTotal += blue;
93
+ }
94
+ const pixelCount = Math.max(1, readback.width * readback.height);
95
+ return {
96
+ nonBlackPixels,
97
+ meanLuma: lumaTotal / pixelCount,
98
+ channelTotals: [redTotal, greenTotal, blueTotal],
99
+ };
100
+ }
101
+
102
+ function pixelDelta(left: ScreenshotPixels, right: ScreenshotPixels): PixelDelta {
103
+ if (left.width !== right.width || left.height !== right.height) {
104
+ return {
105
+ changedPixels: Math.max(left.width * left.height, right.width * right.height),
106
+ meanAbsoluteChannelDelta: 255,
107
+ maxChannelDelta: 255,
108
+ };
109
+ }
110
+ let changedPixels = 0;
111
+ let absoluteDelta = 0;
112
+ let maxChannelDelta = 0;
113
+ for (let offset = 0; offset < left.pixels.length; offset += 4) {
114
+ let changed = false;
115
+ for (let channel = 0; channel < 3; channel += 1) {
116
+ const delta = Math.abs(
117
+ (left.pixels[offset + channel] ?? 0) - (right.pixels[offset + channel] ?? 0),
118
+ );
119
+ if (delta > 0) changed = true;
120
+ absoluteDelta += delta;
121
+ maxChannelDelta = Math.max(maxChannelDelta, delta);
122
+ }
123
+ if (changed) changedPixels += 1;
124
+ }
125
+ return {
126
+ changedPixels,
127
+ meanAbsoluteChannelDelta: absoluteDelta / Math.max(1, left.width * left.height * 3),
128
+ maxChannelDelta,
129
+ };
130
+ }
131
+
132
+ function channelTotalsInRegion(
133
+ readback: ScreenshotPixels,
134
+ centerX: number,
135
+ centerY: number,
136
+ halfWidth: number,
137
+ halfHeight: number,
138
+ ): readonly [number, number, number] {
139
+ const x0 = Math.max(0, Math.floor((centerX - halfWidth) * readback.width));
140
+ const y0 = Math.max(0, Math.floor((centerY - halfHeight) * readback.height));
141
+ const x1 = Math.min(readback.width, Math.ceil((centerX + halfWidth) * readback.width));
142
+ const y1 = Math.min(readback.height, Math.ceil((centerY + halfHeight) * readback.height));
143
+ let red = 0;
144
+ let green = 0;
145
+ let blue = 0;
146
+ for (let y = y0; y < y1; y += 1) {
147
+ for (let x = x0; x < x1; x += 1) {
148
+ const offset = (y * readback.width + x) * 4;
149
+ red += readback.pixels[offset] ?? 0;
150
+ green += readback.pixels[offset + 1] ?? 0;
151
+ blue += readback.pixels[offset + 2] ?? 0;
152
+ }
153
+ }
154
+ return [red, green, blue];
155
+ }
156
+
157
+ function passNamesContain(inspection: RenderInspection, expected: readonly string[]): boolean {
158
+ return expected.every((name) => inspection.perFramePassNames.includes(name));
159
+ }
160
+
161
+ async function submitFrame(
162
+ world: World,
163
+ renderer: Renderer,
164
+ lease: RenderWorldLease,
165
+ canvas: HTMLCanvasElement,
166
+ label: string,
167
+ ): Promise<FrameSample> {
168
+ propagateTransforms(world).unwrap();
169
+ const drawn = renderer.draw({
170
+ leases: [lease],
171
+ camera: { lease },
172
+ environment: { lease },
173
+ fixedStep: world.getResource(FixedTime).tick,
174
+ });
175
+ if (!drawn.ok) throw drawn.error;
176
+ const receipt = drawn.value;
177
+ const completed = await receipt.completed;
178
+ if (!completed.ok) throw completed.error;
179
+ const observation = await renderer.observe(receipt, {
180
+ include: ['draws', 'bindings'],
181
+ });
182
+ if (!observation.ok) throw observation.error;
183
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
184
+ const pixels = await screenshotPixels(canvas, label);
185
+ const inspection = renderer.inspect();
186
+ // biome-ignore lint/suspicious/noConsole: this test emits real browser GPU evidence.
187
+ console.info(
188
+ '[wave1-rendering:materials]',
189
+ JSON.stringify({
190
+ label,
191
+ receipt: {
192
+ frameId: receipt.frameId,
193
+ deviceGeneration: receipt.deviceGeneration,
194
+ completed: true,
195
+ },
196
+ pixel: pixelStats(pixels),
197
+ passNames: inspection.perFramePassNames,
198
+ volume: inspection.volumetricFog,
199
+ bloom: inspection.bloom,
200
+ observedIncludes: observation.value.include,
201
+ }),
202
+ );
203
+ return { receipt, pixels, inspection, observedIncludes: observation.value.include };
204
+ }
205
+
206
+ function createCanonicalBox(withVertexColor: boolean): MeshAsset {
207
+ const box = createBoxGeometry(1.8, 1.8, 0.6).unwrap();
208
+ const position = box.attributes.position;
209
+ const normal = box.attributes.normal;
210
+ const uv = box.attributes.uv;
211
+ const tangent = box.attributes.tangent;
212
+ if (
213
+ !(position instanceof Float32Array) ||
214
+ !(normal instanceof Float32Array) ||
215
+ !(uv instanceof Float32Array) ||
216
+ !(tangent instanceof Float32Array) ||
217
+ box.indices === undefined
218
+ ) {
219
+ throw new Error('wave1-rendering materials: canonical box attributes unavailable');
220
+ }
221
+ const attributes: VertexAttributeMap = {
222
+ position,
223
+ normal,
224
+ uv,
225
+ tangent,
226
+ ...(withVertexColor ? { color: new Float32Array((position.length / 3) * 4).fill(1) } : {}),
227
+ };
228
+ const built = createMeshBuilder({
229
+ attributes,
230
+ indices: box.indices,
231
+ submeshes: [
232
+ {
233
+ indexOffset: 0,
234
+ indexCount: box.indices.length,
235
+ vertexCount: position.length / 3,
236
+ topology: 'triangle-list',
237
+ materialSlot: 0,
238
+ },
239
+ ],
240
+ materialSlots: [{ slotName: 'surface' }],
241
+ }).build();
242
+ if (!built.ok) throw built.error;
243
+ return built.value;
244
+ }
245
+
246
+ function createDensity(): TextureAsset {
247
+ const size = 8;
248
+ return {
249
+ kind: 'texture',
250
+ shape: {
251
+ viewDimension: '3d',
252
+ extent: { width: size, height: size, depth: size },
253
+ },
254
+ format: 'r8unorm',
255
+ colorSpace: 'linear',
256
+ mips: { kind: 'none' },
257
+ data: new Uint8Array(size * size * size).fill(96),
258
+ };
259
+ }
260
+
261
+ function noLightScene(recipe: ReturnType<typeof createWave1RenderingRecipe>): void {
262
+ recipe.destroy(recipe.entities.wall);
263
+ recipe.destroy(recipe.entities.floor);
264
+ recipe.destroy(recipe.entities.roof);
265
+ recipe.destroy(recipe.entities.sun);
266
+ recipe.destroy(recipe.entities.skylight);
267
+ recipe.destroy(recipe.entities.localLight);
268
+ }
269
+
270
+ async function createCanvas(width = 256, height = 256): Promise<HTMLCanvasElement> {
271
+ if (!navigator.gpu) throw new Error('WebGPU is required for Wave1 material regressions');
272
+ const canvas = document.createElement('canvas');
273
+ canvas.width = width;
274
+ canvas.height = height;
275
+ canvas.style.width = `${width}px`;
276
+ canvas.style.height = `${height}px`;
277
+ document.body.append(canvas);
278
+ return canvas;
279
+ }
280
+
281
+ it('proves emissive intensity and color pattern with every scene light off', async () => {
282
+ const canvas = await createCanvas();
283
+ const rendererResult = await createRenderer(
284
+ canvas,
285
+ {},
286
+ { shaderManifestUrl: '/shaders/manifest.json' },
287
+ );
288
+ if (!rendererResult.ok) throw rendererResult.error;
289
+ const renderer = rendererResult.value;
290
+ const world = new World();
291
+ const scene = await createWorldContext(world, [scenePlugin()]);
292
+ const recipe = createWave1RenderingRecipe(world, { aspect: 1 });
293
+ const lowIntensity = world.allocSharedRef(
294
+ 'MaterialAsset',
295
+ Materials.standard({
296
+ baseColor: [0, 0, 0, 1],
297
+ metallic: 0,
298
+ roughness: 0.5,
299
+ emissive: [1, 0.1, 0.01],
300
+ emissiveIntensity: 0.45,
301
+ }),
302
+ );
303
+ const highIntensity = world.allocSharedRef(
304
+ 'MaterialAsset',
305
+ Materials.standard({
306
+ baseColor: [0, 0, 0, 1],
307
+ metallic: 0,
308
+ roughness: 0.5,
309
+ emissive: [1, 0.1, 0.01],
310
+ emissiveIntensity: 2.5,
311
+ }),
312
+ );
313
+ const errors: string[] = [];
314
+ const unsubscribe = renderer.subscribe((event) => {
315
+ if (event.kind === 'error') errors.push(event.error.code);
316
+ });
317
+ const attached = renderer.attach(world);
318
+ if (!attached.ok) throw attached.error;
319
+ try {
320
+ noLightScene(recipe);
321
+ world
322
+ .set(recipe.entities.screen, MeshRenderer, {
323
+ materials: [lowIntensity, lowIntensity, lowIntensity],
324
+ })
325
+ .unwrap();
326
+ world.update(1 / 60).unwrap();
327
+ const low = await submitFrame(world, renderer, attached.value, canvas, 'emissive-low');
328
+ expect(low.inspection.bloom.enabled).toBe(false);
329
+ expect(low.inspection.bloom.graphStatus).toBe('empty');
330
+ expect(pixelStats(low.pixels).nonBlackPixels).toBeGreaterThan(0);
331
+
332
+ world
333
+ .set(recipe.entities.screen, MeshRenderer, {
334
+ materials: [highIntensity, highIntensity, highIntensity],
335
+ })
336
+ .unwrap();
337
+ world.update(1 / 60).unwrap();
338
+ const high = await submitFrame(world, renderer, attached.value, canvas, 'emissive-high');
339
+ expect(pixelDelta(low.pixels, high.pixels).changedPixels).toBeGreaterThan(0);
340
+ expect(pixelStats(high.pixels).meanLuma).toBeGreaterThan(pixelStats(low.pixels).meanLuma);
341
+ expect(pixelStats(high.pixels).nonBlackPixels).toBeGreaterThan(0);
342
+
343
+ world
344
+ .set(recipe.entities.screen, MeshRenderer, {
345
+ materials: [
346
+ recipe.assets.emissiveAmber,
347
+ recipe.assets.emissiveBlue,
348
+ recipe.assets.emissiveGold,
349
+ ],
350
+ })
351
+ .unwrap();
352
+ world.update(1 / 60).unwrap();
353
+ const pattern = await submitFrame(world, renderer, attached.value, canvas, 'emissive-pattern');
354
+ world
355
+ .set(recipe.entities.screen, MeshRenderer, {
356
+ materials: [
357
+ recipe.assets.emissiveBlue,
358
+ recipe.assets.emissiveGold,
359
+ recipe.assets.emissiveAmber,
360
+ ],
361
+ })
362
+ .unwrap();
363
+ world.update(1 / 60).unwrap();
364
+ const rotatedPattern = await submitFrame(
365
+ world,
366
+ renderer,
367
+ attached.value,
368
+ canvas,
369
+ 'emissive-pattern-rotated',
370
+ );
371
+ expect(pixelDelta(pattern.pixels, rotatedPattern.pixels).changedPixels).toBeGreaterThan(0);
372
+ const left = channelTotalsInRegion(pattern.pixels, 0.43, 0.5, 0.045, 0.16);
373
+ const center = channelTotalsInRegion(pattern.pixels, 0.5, 0.5, 0.045, 0.16);
374
+ expect(left[0]).toBeGreaterThan(left[2]);
375
+ expect(center[2]).toBeGreaterThan(center[0]);
376
+ expect(errors).toEqual([]);
377
+ // biome-ignore lint/suspicious/noConsole: this test emits bounded acceptance evidence.
378
+ console.info(
379
+ '[wave1-rendering:materials] emissive-acceptance',
380
+ JSON.stringify({
381
+ bloom: pattern.inspection.bloom,
382
+ low: pixelStats(low.pixels),
383
+ high: pixelStats(high.pixels),
384
+ pattern: pixelStats(pattern.pixels),
385
+ rotatedPattern: pixelStats(rotatedPattern.pixels),
386
+ patternDelta: pixelDelta(pattern.pixels, rotatedPattern.pixels),
387
+ }),
388
+ );
389
+ } finally {
390
+ unsubscribe();
391
+ await renderer.dispose();
392
+ world.sharedRefs.release(lowIntensity).unwrap();
393
+ world.sharedRefs.release(highIntensity).unwrap();
394
+ recipe.dispose();
395
+ await scene.fiber.dispose();
396
+ canvas.remove();
397
+ }
398
+ }, 120000);
399
+
400
+ it('matches a canonical box without color to the same box with white vertex color', async () => {
401
+ const canvas = await createCanvas();
402
+ const rendererResult = await createRenderer(
403
+ canvas,
404
+ {},
405
+ { shaderManifestUrl: '/shaders/manifest.json' },
406
+ );
407
+ if (!rendererResult.ok) throw rendererResult.error;
408
+ const renderer = rendererResult.value;
409
+ const world = new World();
410
+ const scene = await createWorldContext(world, [scenePlugin()]);
411
+ const recipe = createWave1RenderingRecipe(world, { aspect: 1 });
412
+ const material = world.allocSharedRef(
413
+ 'MaterialAsset',
414
+ Materials.standard({
415
+ baseColor: [0, 0, 0, 1],
416
+ metallic: 0,
417
+ roughness: 0.5,
418
+ emissive: [0.08, 0.55, 1],
419
+ emissiveIntensity: 2,
420
+ }),
421
+ );
422
+ const noColorMesh = world.allocSharedRef('MeshAsset', createCanonicalBox(false));
423
+ const whiteColorMesh = world.allocSharedRef('MeshAsset', createCanonicalBox(true));
424
+ const errors: string[] = [];
425
+ const unsubscribe = renderer.subscribe((event) => {
426
+ if (event.kind === 'error') errors.push(event.error.code);
427
+ });
428
+ const attached = renderer.attach(world);
429
+ if (!attached.ok) throw attached.error;
430
+ const entity = world
431
+ .spawn(
432
+ { component: Transform, data: { pos: [0, 0, 0] } },
433
+ { component: MeshFilter, data: { assetHandle: noColorMesh } },
434
+ { component: MeshRenderer, data: { materials: [material] } },
435
+ )
436
+ .unwrap();
437
+ try {
438
+ noLightScene(recipe);
439
+ world.update(1 / 60).unwrap();
440
+ const noColor = await submitFrame(world, renderer, attached.value, canvas, 'box-no-color');
441
+ expect(pixelStats(noColor.pixels).nonBlackPixels).toBeGreaterThan(0);
442
+
443
+ world.set(entity, MeshFilter, { assetHandle: whiteColorMesh }).unwrap();
444
+ world.update(1 / 60).unwrap();
445
+ const whiteColor = await submitFrame(
446
+ world,
447
+ renderer,
448
+ attached.value,
449
+ canvas,
450
+ 'box-white-color',
451
+ );
452
+ expect(pixelDelta(noColor.pixels, whiteColor.pixels)).toEqual({
453
+ changedPixels: 0,
454
+ meanAbsoluteChannelDelta: 0,
455
+ maxChannelDelta: 0,
456
+ });
457
+ expect(errors).toEqual([]);
458
+ // biome-ignore lint/suspicious/noConsole: this test emits bounded acceptance evidence.
459
+ console.info(
460
+ '[wave1-rendering:materials] vertex-color-acceptance',
461
+ JSON.stringify({
462
+ noColor: pixelStats(noColor.pixels),
463
+ whiteColor: pixelStats(whiteColor.pixels),
464
+ delta: pixelDelta(noColor.pixels, whiteColor.pixels),
465
+ }),
466
+ );
467
+ } finally {
468
+ world.despawn(entity).unwrap();
469
+ unsubscribe();
470
+ await renderer.dispose();
471
+ world.sharedRefs.release(material).unwrap();
472
+ world.sharedRefs.release(noColorMesh).unwrap();
473
+ world.sharedRefs.release(whiteColorMesh).unwrap();
474
+ recipe.dispose();
475
+ await scene.fiber.dispose();
476
+ canvas.remove();
477
+ }
478
+ }, 120000);
479
+
480
+ it('runs the bounded VolumetricFog air layer independently from Atmosphere', async () => {
481
+ const canvas = await createCanvas();
482
+ const rendererResult = await createRenderer(
483
+ canvas,
484
+ {},
485
+ { shaderManifestUrl: '/shaders/manifest.json' },
486
+ );
487
+ if (!rendererResult.ok) throw rendererResult.error;
488
+ const renderer = rendererResult.value;
489
+ const world = new World();
490
+ const scene = await createWorldContext(world, [scenePlugin()]);
491
+ const recipe = createWave1RenderingRecipe(world, {
492
+ aspect: 1,
493
+ includeAtmosphere: true,
494
+ });
495
+ const density = world.allocSharedRef('TextureAsset', createDensity());
496
+ const fogEntity = world
497
+ .spawn({
498
+ component: VolumetricFog,
499
+ data: {
500
+ light: recipe.entities.sun,
501
+ density,
502
+ boundsMin: [-8, -4, -2],
503
+ boundsMax: [8, 4, 6],
504
+ extinction: [0.22, 0.22, 0.22],
505
+ albedo: [0.75, 0.75, 0.75],
506
+ emission: [0, 0, 0],
507
+ anisotropy: 0,
508
+ maxDistance: 30,
509
+ },
510
+ })
511
+ .unwrap();
512
+ const errors: string[] = [];
513
+ const unsubscribe = renderer.subscribe((event) => {
514
+ if (event.kind === 'error') errors.push(event.error.code);
515
+ });
516
+ const attached = renderer.attach(world);
517
+ if (!attached.ok) throw attached.error;
518
+ let fogPresent = true;
519
+ try {
520
+ world.update(1 / 60).unwrap();
521
+ const both = await submitFrame(world, renderer, attached.value, canvas, 'air-both-on');
522
+ expect(both.inspection.volumetricFog?.status).toBe('available');
523
+ expect(
524
+ passNamesContain(both.inspection, [
525
+ 'atmosphere-background',
526
+ 'volume-inject',
527
+ 'volume-integrate',
528
+ 'volume-temporal',
529
+ 'volume-composite',
530
+ ]),
531
+ ).toBe(true);
532
+ expect(both.observedIncludes).toEqual(['draws', 'bindings']);
533
+
534
+ const atmosphere = recipe.entities.atmosphere;
535
+ if (atmosphere === undefined) {
536
+ throw new Error('wave1-rendering materials: Atmosphere fixture entity missing');
537
+ }
538
+ world.removeComponent(atmosphere, Atmosphere).unwrap();
539
+ world.update(1 / 60).unwrap();
540
+ const fogOnly = await submitFrame(
541
+ world,
542
+ renderer,
543
+ attached.value,
544
+ canvas,
545
+ 'air-atmosphere-off',
546
+ );
547
+ expect(fogOnly.inspection.volumetricFog?.status).toBe('available');
548
+ expect(fogOnly.inspection.perFramePassNames).not.toContain('atmosphere-background');
549
+ expect(fogOnly.inspection.perFramePassNames).toContain('volume-composite');
550
+ expect(fogOnly.observedIncludes).toEqual(['draws', 'bindings']);
551
+ expect(pixelDelta(both.pixels, fogOnly.pixels).changedPixels).toBeGreaterThan(0);
552
+
553
+ world.removeComponent(fogEntity, VolumetricFog).unwrap();
554
+ fogPresent = false;
555
+ world.update(1 / 60).unwrap();
556
+ const clear = await submitFrame(world, renderer, attached.value, canvas, 'air-volume-off');
557
+ expect(clear.inspection.volumetricFog?.status).toBe('off');
558
+ expect(clear.inspection.perFramePassNames).not.toContain('volume-inject');
559
+ expect(clear.observedIncludes).toEqual(['draws', 'bindings']);
560
+ expect(pixelDelta(fogOnly.pixels, clear.pixels).changedPixels).toBeGreaterThan(0);
561
+
562
+ world
563
+ .addComponent(atmosphere, {
564
+ component: Atmosphere,
565
+ data: WAVE1_ATMOSPHERE_PRESET,
566
+ })
567
+ .unwrap();
568
+ world.update(1 / 60).unwrap();
569
+ const atmosphereOnly = await submitFrame(
570
+ world,
571
+ renderer,
572
+ attached.value,
573
+ canvas,
574
+ 'air-atmosphere-only',
575
+ );
576
+ expect(atmosphereOnly.inspection.volumetricFog?.status).toBe('off');
577
+ expect(atmosphereOnly.inspection.perFramePassNames).toContain('atmosphere-background');
578
+ expect(atmosphereOnly.inspection.perFramePassNames).not.toContain('volume-composite');
579
+ expect(atmosphereOnly.observedIncludes).toEqual(['draws', 'bindings']);
580
+ expect(pixelDelta(clear.pixels, atmosphereOnly.pixels).changedPixels).toBeGreaterThan(0);
581
+ expect(errors).toEqual([]);
582
+ // biome-ignore lint/suspicious/noConsole: this test emits bounded acceptance evidence.
583
+ console.info(
584
+ '[wave1-rendering:materials] air-layer-acceptance',
585
+ JSON.stringify({
586
+ both: {
587
+ volume: both.inspection.volumetricFog,
588
+ passes: both.inspection.perFramePassNames,
589
+ pixel: pixelStats(both.pixels),
590
+ },
591
+ fogOnly: {
592
+ volume: fogOnly.inspection.volumetricFog,
593
+ passes: fogOnly.inspection.perFramePassNames,
594
+ pixel: pixelStats(fogOnly.pixels),
595
+ },
596
+ clear: {
597
+ volume: clear.inspection.volumetricFog,
598
+ passes: clear.inspection.perFramePassNames,
599
+ pixel: pixelStats(clear.pixels),
600
+ },
601
+ atmosphereOnly: {
602
+ volume: atmosphereOnly.inspection.volumetricFog,
603
+ passes: atmosphereOnly.inspection.perFramePassNames,
604
+ pixel: pixelStats(atmosphereOnly.pixels),
605
+ },
606
+ }),
607
+ );
608
+ } finally {
609
+ if (fogPresent) world.despawn(fogEntity).unwrap();
610
+ world.sharedRefs.release(density).unwrap();
611
+ unsubscribe();
612
+ await renderer.dispose();
613
+ recipe.dispose();
614
+ await scene.fiber.dispose();
615
+ canvas.remove();
616
+ }
617
+ }, 120000);