@forgeax/engine-runtime 0.1.7 → 0.1.20

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 (88) hide show
  1. package/README.md +45 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/helpers/standard-material-manifest.d.ts +12 -0
  4. package/dist/__tests__/helpers/standard-material-manifest.d.ts.map +1 -0
  5. package/dist/__tests__/helpers/tilemap-assets.d.ts.map +1 -1
  6. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
  7. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-environment-consumer.integration.test.d.ts +2 -0
  9. package/dist/__tests__/render-environment-consumer.integration.test.d.ts.map +1 -0
  10. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  11. package/dist/__tests__/render-feature-prepared-graphics.fixture.d.ts.map +1 -1
  12. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts +2 -0
  13. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts +2 -0
  15. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts.map +1 -0
  16. package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
  17. package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
  18. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
  19. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
  20. package/dist/backend-selection.d.ts.map +1 -1
  21. package/dist/createRenderer.d.ts.map +1 -1
  22. package/dist/index.mjs +21 -1
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/renderer-host.d.ts +2 -1
  25. package/dist/renderer-host.d.ts.map +1 -1
  26. package/dist/renderer-host.mjs +21 -2
  27. package/dist/renderer-host.mjs.map +1 -1
  28. package/package.json +30 -30
  29. package/src/__tests__/asset-registry.recursive.spec.ts +12 -18
  30. package/src/__tests__/asset.unit.test.ts +28 -25
  31. package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
  32. package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
  33. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +2 -3
  34. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +12 -11
  35. package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
  36. package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
  37. package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
  38. package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
  39. package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
  40. package/src/__tests__/derive-bgl-integration.test.ts +11 -0
  41. package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
  42. package/src/__tests__/errors.unit.test.ts +56 -0
  43. package/src/__tests__/extract-record-no-hardcoded-texture-fields.test.ts +4 -5
  44. package/src/__tests__/four-path-compression.integration.test.ts +2 -2
  45. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +461 -18
  46. package/src/__tests__/geometry.unit.test.ts +3 -1
  47. package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
  48. package/src/__tests__/helpers/standard-material-manifest.ts +45 -0
  49. package/src/__tests__/helpers/tilemap-assets.ts +3 -4
  50. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +4 -2
  51. package/src/__tests__/lights.unit.test.ts +38 -17
  52. package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
  53. package/src/__tests__/loader-name-survival.test.ts +2 -3
  54. package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
  55. package/src/__tests__/material-texture-uv-scale.unit.test.ts +18 -16
  56. package/src/__tests__/materials.unit.test.ts +2 -0
  57. package/src/__tests__/mip-gate.unit.test.ts +11 -8
  58. package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
  59. package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
  60. package/src/__tests__/pbr-pipeline.unit.test.ts +31 -12
  61. package/src/__tests__/pipeline-cache-keying.unit.test.ts +6 -4
  62. package/src/__tests__/pipeline.unit.test.ts +23 -21
  63. package/src/__tests__/point-light-shadow.unit.test.ts +6 -5
  64. package/src/__tests__/render-environment-consumer.integration.test.ts +33 -0
  65. package/src/__tests__/render-error-exhaustive.test-d.ts +86 -0
  66. package/src/__tests__/render-feature-prepared-graphics.fixture.ts +10 -0
  67. package/src/__tests__/render-system-extract.test.ts +4 -6
  68. package/src/__tests__/render-system-mega.test.ts +3 -1
  69. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +7 -7
  70. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -4
  71. package/src/__tests__/render-system-record.test.ts +8 -6
  72. package/src/__tests__/renderer-host-fallback.unit.test.ts +112 -0
  73. package/src/__tests__/renderer-lifecycle.integration.test.ts +10 -0
  74. package/src/__tests__/renderer-surface.unit.test.ts +66 -0
  75. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +5 -1
  76. package/src/__tests__/shadow-csm-tile-consistency.test.ts +5 -1
  77. package/src/__tests__/shadow-fields-observable.dawn.test.ts +56 -39
  78. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +385 -0
  79. package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
  80. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -9
  81. package/src/__tests__/ssao-passes.test.ts +28 -12
  82. package/src/__tests__/systems.unit.test.ts +63 -33
  83. package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
  84. package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
  85. package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
  86. package/src/backend-selection.ts +4 -0
  87. package/src/createRenderer.ts +3 -0
  88. package/src/renderer-host.ts +30 -3
@@ -33,6 +33,11 @@ const WIDTH = 256;
33
33
  const HEIGHT = 256;
34
34
  const BPR = Math.ceil((WIDTH * 4) / 256) * 256;
35
35
 
36
+ // Each falsifier performs four fresh 256x256 Dawn readbacks. Windows' Dawn
37
+ // prebuild can exceed Vitest's 90-second bound for that sequence while still
38
+ // completing well inside the nightly platform job's 45-minute budget.
39
+ const SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS = 300_000;
40
+
36
41
  const ENGINE_MANIFEST = await (async () => {
37
42
  const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
38
43
  return buildEngineShaderManifest();
@@ -582,45 +587,57 @@ describe('M6 shadow fields observability', () => {
582
587
  // (physically removing the UBO write), comment out the matching
583
588
  // `viewPayload[126|127|128] = ...` line in render-system-record.ts and re-run
584
589
  // the corresponding `varied` leg — its diff collapses to 0, matching `held`.
585
- it('falsification: depthBias varied differs, held does not (signal attributable to the field)', async () => {
586
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
587
- const varied = diff(
588
- await renderConfig(true, 0.0, 0.05, 3),
589
- await renderConfig(true, 0.5, 0.05, 3),
590
- );
591
- const held = diff(
592
- await renderConfig(true, 0.0, 0.05, 3),
593
- await renderConfig(true, 0.0, 0.05, 3),
594
- );
595
- expect(varied.diff).toBeGreaterThan(0);
596
- expect(held.diff).toBe(0);
597
- }, 90000);
590
+ it(
591
+ 'falsification: depthBias varied differs, held does not (signal attributable to the field)',
592
+ async () => {
593
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
594
+ const varied = diff(
595
+ await renderConfig(true, 0.0, 0.05, 3),
596
+ await renderConfig(true, 0.5, 0.05, 3),
597
+ );
598
+ const held = diff(
599
+ await renderConfig(true, 0.0, 0.05, 3),
600
+ await renderConfig(true, 0.0, 0.05, 3),
601
+ );
602
+ expect(varied.diff).toBeGreaterThan(0);
603
+ expect(held.diff).toBe(0);
604
+ },
605
+ SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
606
+ );
598
607
 
599
- it('falsification: normalBias varied differs, held does not', async () => {
600
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
601
- const varied = diff(
602
- await renderConfig(true, 0.005, 0.0, 3),
603
- await renderConfig(true, 0.005, 0.5, 3),
604
- );
605
- const held = diff(
606
- await renderConfig(true, 0.005, 0.0, 3),
607
- await renderConfig(true, 0.005, 0.0, 3),
608
- );
609
- expect(varied.diff).toBeGreaterThan(0);
610
- expect(held.diff).toBe(0);
611
- }, 90000);
608
+ it(
609
+ 'falsification: normalBias varied differs, held does not',
610
+ async () => {
611
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
612
+ const varied = diff(
613
+ await renderConfig(true, 0.005, 0.0, 3),
614
+ await renderConfig(true, 0.005, 0.5, 3),
615
+ );
616
+ const held = diff(
617
+ await renderConfig(true, 0.005, 0.0, 3),
618
+ await renderConfig(true, 0.005, 0.0, 3),
619
+ );
620
+ expect(varied.diff).toBeGreaterThan(0);
621
+ expect(held.diff).toBe(0);
622
+ },
623
+ SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
624
+ );
612
625
 
613
- it('falsification: pcfKernelSize varied differs, held does not', async () => {
614
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
615
- const varied = diff(
616
- await renderConfig(true, 0.005, 0.05, 1),
617
- await renderConfig(true, 0.005, 0.05, 5),
618
- );
619
- const held = diff(
620
- await renderConfig(true, 0.005, 0.05, 1),
621
- await renderConfig(true, 0.005, 0.05, 1),
622
- );
623
- expect(varied.diff).toBeGreaterThan(0);
624
- expect(held.diff).toBe(0);
625
- }, 90000);
626
+ it(
627
+ 'falsification: pcfKernelSize varied differs, held does not',
628
+ async () => {
629
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
630
+ const varied = diff(
631
+ await renderConfig(true, 0.005, 0.05, 1),
632
+ await renderConfig(true, 0.005, 0.05, 5),
633
+ );
634
+ const held = diff(
635
+ await renderConfig(true, 0.005, 0.05, 1),
636
+ await renderConfig(true, 0.005, 0.05, 1),
637
+ );
638
+ expect(varied.diff).toBeGreaterThan(0);
639
+ expect(held.diff).toBe(0);
640
+ },
641
+ SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
642
+ );
626
643
  });
@@ -0,0 +1,385 @@
1
+ // Real Dawn regression for the static/skinned shadow pipeline transition.
2
+ //
3
+ // The static shadow path uses the canonical all-true variant key (`''`) while
4
+ // the skinned path uses an explicit negative skinning axis. Keep both entries
5
+ // in one production render so the pipeline layout and group-2 bind group must
6
+ // change at the same command-recording boundary.
7
+
8
+ import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
9
+ import { World } from '@forgeax/engine-ecs';
10
+ import type { Renderer } from '@forgeax/engine-render';
11
+ import { Camera, DirectionalLight, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
12
+ import { Transform } from '@forgeax/engine-scene';
13
+ import { Skin } from '@forgeax/engine-skinning';
14
+ import type { Handle, MaterialAsset, MeshAsset, SkeletonAsset } from '@forgeax/engine-types';
15
+ import { describe, expect, it } from 'vitest';
16
+ import { constructRuntimeRendererHost } from '../renderer-host';
17
+ import { drawPublished } from './draw-published';
18
+
19
+ const WIDTH = 384;
20
+ const HEIGHT = 256;
21
+ const BYTES_PER_ROW = Math.ceil((WIDTH * 4) / 256) * 256;
22
+ const TEXTURE_USAGE_COPY_SRC = 0x01;
23
+ const TEXTURE_USAGE_RENDER_ATTACHMENT = 0x10;
24
+ const BUFFER_USAGE_MAP_READ = 0x0001;
25
+ const BUFFER_USAGE_COPY_DST = 0x0008;
26
+ const MAP_MODE_READ = 0x0001;
27
+
28
+ const ENGINE_MANIFEST = await (async () => {
29
+ const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
30
+ return buildEngineShaderManifest();
31
+ })();
32
+ const ENGINE_MANIFEST_URL = `data:application/json,${encodeURIComponent(
33
+ JSON.stringify(ENGINE_MANIFEST),
34
+ )}`;
35
+
36
+ type MixedCapture = {
37
+ readonly pixels: Uint8Array;
38
+ readonly errors: readonly string[];
39
+ };
40
+
41
+ function staticMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
42
+ return world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
43
+ kind: 'material',
44
+ passes: [
45
+ {
46
+ name: 'Forward',
47
+ program: { module: 'forgeax::default-standard-pbr' },
48
+ renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
49
+ },
50
+ {
51
+ name: 'ShadowCaster',
52
+ program: { module: 'forgeax::default-shadow-caster' },
53
+ renderState: {
54
+ tags: { LightMode: 'ShadowCaster' },
55
+ passKind: 'shadow-caster',
56
+ },
57
+ },
58
+ ],
59
+ values: {
60
+ baseColor: [0.8, 0.35, 0.15, 1],
61
+ metallic: 0.1,
62
+ roughness: 0.55,
63
+ emissive: [0, 0, 0],
64
+ emissiveIntensity: 0,
65
+ occlusionStrength: 1,
66
+ },
67
+ });
68
+ }
69
+
70
+ function skinnedMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
71
+ return world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
72
+ kind: 'material',
73
+ passes: [
74
+ {
75
+ name: 'Forward',
76
+ program: { module: 'forgeax::pbr-skin' },
77
+ renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
78
+ },
79
+ {
80
+ name: 'ShadowCaster',
81
+ program: { module: 'forgeax::default-shadow-caster' },
82
+ renderState: {
83
+ tags: { LightMode: 'ShadowCaster' },
84
+ passKind: 'shadow-caster',
85
+ },
86
+ },
87
+ ],
88
+ values: {
89
+ baseColor: [0.15, 0.45, 0.9, 1],
90
+ metallic: 0.1,
91
+ roughness: 0.55,
92
+ emissive: [0, 0, 0],
93
+ emissiveIntensity: 0,
94
+ occlusionStrength: 1,
95
+ },
96
+ });
97
+ }
98
+
99
+ function floorMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
100
+ return world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
101
+ kind: 'material',
102
+ passes: [
103
+ {
104
+ name: 'Forward',
105
+ program: { module: 'forgeax::default-standard-pbr' },
106
+ renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
107
+ },
108
+ ],
109
+ values: {
110
+ baseColor: [0.75, 0.75, 0.75, 1],
111
+ metallic: 0,
112
+ roughness: 0.9,
113
+ emissive: [0, 0, 0],
114
+ emissiveIntensity: 0,
115
+ occlusionStrength: 1,
116
+ },
117
+ });
118
+ }
119
+
120
+ function skinnedMesh(world: World): Handle<'MeshAsset', 'shared'> {
121
+ // Canonical interleaved order: position, normal, uv, tangent, skinIndex,
122
+ // skinWeight (72 bytes / vertex). All vertices use joint zero.
123
+ const vertices = new Float32Array([
124
+ -0.75, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0.75, 0, 0, 0, 0, 1, 1, 0, 1, 0,
125
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1.2, 0, 0, 0, 1, 0.5, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0,
126
+ ]);
127
+ return world.allocSharedRef<'MeshAsset', MeshAsset>('MeshAsset', {
128
+ kind: 'mesh',
129
+ vertices,
130
+ indices: new Uint16Array([0, 1, 2]),
131
+ attributes: {
132
+ position: new Float32Array([-0.75, 0, 0, 0.75, 0, 0, 0, 1.2, 0]),
133
+ normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]),
134
+ uv: new Float32Array([0, 0, 1, 0, 0.5, 1]),
135
+ tangent: new Float32Array([1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1]),
136
+ skinIndex: new Uint16Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
137
+ skinWeight: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]),
138
+ },
139
+ aabb: new Float32Array([-0.75, 0, 0, 0.75, 1.2, 0]),
140
+ materialSlots: [{ slotName: 'Default' }],
141
+ submeshes: [
142
+ {
143
+ indexOffset: 0,
144
+ indexCount: 3,
145
+ vertexCount: 3,
146
+ materialSlot: 0,
147
+ topology: 'triangle-list',
148
+ },
149
+ ],
150
+ });
151
+ }
152
+
153
+ function skeleton(world: World): Handle<'SkeletonAsset', 'shared'> {
154
+ const inverseBindMatrices = new Float32Array(16);
155
+ inverseBindMatrices[0] = 1;
156
+ inverseBindMatrices[5] = 1;
157
+ inverseBindMatrices[10] = 1;
158
+ inverseBindMatrices[15] = 1;
159
+ return world.allocSharedRef<'SkeletonAsset', SkeletonAsset>('SkeletonAsset', {
160
+ kind: 'skeleton',
161
+ inverseBindMatrices,
162
+ jointCount: 1,
163
+ });
164
+ }
165
+
166
+ function identityTransform(pos: readonly [number, number, number] = [0, 0, 0]) {
167
+ return { pos, quat: [0, 0, 0, 1] as const, scale: [1, 1, 1] as const };
168
+ }
169
+
170
+ function spawnMixedScene(world: World, castShadow: boolean): void {
171
+ const staticMat = staticMaterial(world);
172
+ const skinMat = skinnedMaterial(world);
173
+ const floorMat = floorMaterial(world);
174
+ const skinMesh = skinnedMesh(world);
175
+ const skinSkeleton = skeleton(world);
176
+
177
+ world.spawn(
178
+ { component: Transform, data: { ...identityTransform([0, 5, 5]), scale: [10, 0.1, 10] } },
179
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
180
+ { component: MeshRenderer, data: { materials: [floorMat] } },
181
+ );
182
+ world.spawn(
183
+ { component: Transform, data: identityTransform([0, 8, 18]) },
184
+ {
185
+ component: Camera,
186
+ data: {
187
+ fov: (45 * Math.PI) / 180,
188
+ aspect: WIDTH / HEIGHT,
189
+ near: 0.1,
190
+ far: 100,
191
+ clearColor: [0.02, 0.02, 0.03, 1],
192
+ },
193
+ },
194
+ );
195
+ world.spawn({
196
+ component: DirectionalLight,
197
+ data: {
198
+ direction: [0.25, -1, -0.45],
199
+ color: [1, 1, 1],
200
+ intensity: 1,
201
+ castShadow,
202
+ mapSize: 1024,
203
+ depthBias: 0.005,
204
+ normalBias: 0.05,
205
+ shadowDistance: 50,
206
+ pcfKernelSize: 3,
207
+ },
208
+ });
209
+
210
+ const spawnStaticCaster = (x: number): void => {
211
+ world.spawn(
212
+ { component: Transform, data: { ...identityTransform([x, 7, 5]), scale: [0.8, 0.8, 0.8] } },
213
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
214
+ { component: MeshRenderer, data: { materials: [staticMat] } },
215
+ );
216
+ };
217
+ // Entity order is intentional: static A -> skinned B -> static C.
218
+ spawnStaticCaster(-3);
219
+ const joint = world
220
+ .spawn({
221
+ component: Transform,
222
+ data: identityTransform([0, 7, 5]),
223
+ })
224
+ .unwrap();
225
+ world.spawn(
226
+ { component: Transform, data: identityTransform() },
227
+ { component: MeshFilter, data: { assetHandle: skinMesh } },
228
+ { component: MeshRenderer, data: { materials: [skinMat] } },
229
+ {
230
+ component: Skin,
231
+ data: {
232
+ skeleton: skinSkeleton,
233
+ joints: new Uint32Array([joint as unknown as number]),
234
+ },
235
+ },
236
+ );
237
+ spawnStaticCaster(3);
238
+ }
239
+
240
+ async function readPixels(device: GPUDevice, texture: GPUTexture): Promise<Uint8Array> {
241
+ const buffer = device.createBuffer({
242
+ size: BYTES_PER_ROW * HEIGHT,
243
+ usage: BUFFER_USAGE_MAP_READ | BUFFER_USAGE_COPY_DST,
244
+ });
245
+ const encoder = device.createCommandEncoder();
246
+ encoder.copyTextureToBuffer(
247
+ { texture },
248
+ { buffer, bytesPerRow: BYTES_PER_ROW, rowsPerImage: HEIGHT },
249
+ { width: WIDTH, height: HEIGHT, depthOrArrayLayers: 1 },
250
+ );
251
+ device.queue.submit([encoder.finish()]);
252
+ await buffer.mapAsync(MAP_MODE_READ);
253
+ const pixels = new Uint8Array(buffer.getMappedRange().slice(0));
254
+ buffer.unmap();
255
+ buffer.destroy();
256
+ return pixels;
257
+ }
258
+
259
+ async function renderMixed(castShadow: boolean): Promise<MixedCapture> {
260
+ let device: GPUDevice | undefined;
261
+ let target: GPUTexture | undefined;
262
+ const originalRequestAdapter = globalThis.navigator.gpu.requestAdapter.bind(
263
+ globalThis.navigator.gpu,
264
+ );
265
+ globalThis.navigator.gpu.requestAdapter = async (options) => {
266
+ const adapter = await originalRequestAdapter(options);
267
+ if (adapter === null) return adapter;
268
+ const originalRequestDevice = adapter.requestDevice.bind(adapter);
269
+ adapter.requestDevice = async (descriptor) => {
270
+ const created = await originalRequestDevice(descriptor);
271
+ device ??= created;
272
+ return created;
273
+ };
274
+ return adapter;
275
+ };
276
+ const canvas = {
277
+ width: WIDTH,
278
+ height: HEIGHT,
279
+ getContext(kind: string): unknown {
280
+ if (kind !== 'webgpu') return null;
281
+ return {
282
+ configure(descriptor: { device: GPUDevice; format?: GPUTextureFormat }) {
283
+ target ??= descriptor.device.createTexture({
284
+ size: { width: WIDTH, height: HEIGHT, depthOrArrayLayers: 1 },
285
+ format: descriptor.format ?? 'rgba8unorm',
286
+ usage: TEXTURE_USAGE_RENDER_ATTACHMENT | TEXTURE_USAGE_COPY_SRC,
287
+ viewFormats: ['rgba8unorm-srgb'],
288
+ });
289
+ },
290
+ unconfigure() {},
291
+ getCurrentTexture(): GPUTexture {
292
+ if (target === undefined) throw new Error('render target requested before configure');
293
+ return target;
294
+ },
295
+ };
296
+ },
297
+ addEventListener() {},
298
+ removeEventListener() {},
299
+ } as unknown as HTMLCanvasElement;
300
+
301
+ let renderer: Renderer | undefined;
302
+ let unsubscribe: (() => void) | undefined;
303
+ const errors: string[] = [];
304
+ try {
305
+ const host = await constructRuntimeRendererHost(
306
+ canvas,
307
+ {},
308
+ {
309
+ shaderManifestUrl: ENGINE_MANIFEST_URL,
310
+ },
311
+ );
312
+ if (!host.ok) throw host.error;
313
+ renderer = host.value.renderer;
314
+ unsubscribe = renderer.subscribe((event) => {
315
+ if (event.kind === 'error') {
316
+ errors.push(event.error.code);
317
+ }
318
+ });
319
+ const world = new World();
320
+ spawnMixedScene(world, castShadow);
321
+ // The first frame warms the static and skinned PSOs; the second frame is
322
+ // the asserted mixed command stream after both cache entries exist.
323
+ for (let frame = 0; frame < 2; frame += 1) {
324
+ const receipt = drawPublished(renderer, world);
325
+ expect(receipt.ok, `mixed ${castShadow ? 'shadow' : 'baseline'} draw`).toBe(true);
326
+ if (!receipt.ok) throw receipt.error;
327
+ const completed = await receipt.value.completed;
328
+ expect(completed.ok, 'Queue::submit completion').toBe(true);
329
+ if (!completed.ok) throw completed.error;
330
+ }
331
+ if (device === undefined || target === undefined)
332
+ throw new Error('Dawn target not initialized');
333
+ await device.queue.onSubmittedWorkDone();
334
+ return { pixels: await readPixels(device, target), errors };
335
+ } finally {
336
+ globalThis.navigator.gpu.requestAdapter = originalRequestAdapter;
337
+ unsubscribe?.();
338
+ renderer?.dispose();
339
+ target?.destroy();
340
+ device?.destroy();
341
+ }
342
+ }
343
+
344
+ function regionDiff(a: Uint8Array, b: Uint8Array, x0: number, x1: number): number {
345
+ let changed = 0;
346
+ for (let y = Math.floor(HEIGHT * 0.32); y < HEIGHT; y += 1) {
347
+ for (let x = x0; x < x1; x += 1) {
348
+ const index = y * BYTES_PER_ROW + x * 4;
349
+ if (
350
+ Math.abs((a[index] ?? 0) - (b[index] ?? 0)) > 2 ||
351
+ Math.abs((a[index + 1] ?? 0) - (b[index + 1] ?? 0)) > 2 ||
352
+ Math.abs((a[index + 2] ?? 0) - (b[index + 2] ?? 0)) > 2
353
+ ) {
354
+ changed += 1;
355
+ }
356
+ }
357
+ }
358
+ return changed;
359
+ }
360
+
361
+ describe('mixed static/skinned shadow pipeline (Dawn)', () => {
362
+ it('submits static → skinned → static casters with zero validation errors', async () => {
363
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') {
364
+ throw new Error('dawn-node navigator.gpu not injected');
365
+ }
366
+ const shadow = await renderMixed(true);
367
+ const baseline = await renderMixed(false);
368
+ expect(shadow.errors).toEqual([]);
369
+ expect(baseline.errors).toEqual([]);
370
+ expect(shadow.pixels.length).toBeGreaterThan(0);
371
+ expect(baseline.pixels.length).toBe(shadow.pixels.length);
372
+
373
+ // Each caster owns a separate x-region. Comparing against the identical
374
+ // no-shadow scene proves the depth pass contributed in all three regions,
375
+ // rather than merely proving that a color frame was non-black.
376
+ const regions = [
377
+ [0, Math.floor(WIDTH / 3)],
378
+ [Math.floor(WIDTH / 3), Math.floor((2 * WIDTH) / 3)],
379
+ [Math.floor((2 * WIDTH) / 3), WIDTH],
380
+ ] as const;
381
+ for (const [x0, x1] of regions) {
382
+ expect(regionDiff(shadow.pixels, baseline.pixels, x0, x1)).toBeGreaterThan(0);
383
+ }
384
+ }, 120000);
385
+ });
@@ -229,12 +229,11 @@ describe('feat-20260608 M2 m2-t5: sprite pipeline cullMode "none" lets H/V flipp
229
229
  const synth = buildSyntheticRgba();
230
230
  const synthPod = {
231
231
  kind: 'texture' as const,
232
- width: synth.width,
233
- height: synth.height,
232
+ shape: { viewDimension: '2d' as const, extent: { width: synth.width, height: synth.height } },
234
233
  format: 'rgba8unorm-srgb' as const,
235
234
  data: synth.data,
236
235
  colorSpace: 'srgb' as const,
237
- mipmap: false,
236
+ mips: { kind: 'none' as const },
238
237
  };
239
238
  const textureHandle = worldFlip.allocSharedRef('TextureAsset', synthPod as never);
240
239
  const samplerHandle = worldFlip.allocSharedRef('SamplerAsset', {
@@ -382,12 +381,11 @@ describe('feat-20260608 M2 m2-t5: sprite pipeline cullMode "none" lets H/V flipp
382
381
  const synth = buildSyntheticRgba();
383
382
  const synthPod = {
384
383
  kind: 'texture' as const,
385
- width: synth.width,
386
- height: synth.height,
384
+ shape: { viewDimension: '2d' as const, extent: { width: synth.width, height: synth.height } },
387
385
  format: 'rgba8unorm-srgb' as const,
388
386
  data: synth.data,
389
387
  colorSpace: 'srgb' as const,
390
- mipmap: false,
388
+ mips: { kind: 'none' as const },
391
389
  };
392
390
  const textureHandle = worldFlipBoth.allocSharedRef('TextureAsset', synthPod as never);
393
391
  const samplerHandle = worldFlipBoth.allocSharedRef('SamplerAsset', {
@@ -47,7 +47,7 @@ function makeSpec(shaderId: string): PipelineSpec {
47
47
 
48
48
  describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
49
49
  describe('pbr-view BGL (light buffers; binding 1+2)', () => {
50
- it('sprite-lit vs sprite share the same 10-entry pbr-view BGL under storage-buffer caps', () => {
50
+ it('sprite-lit vs sprite share the same 12-entry pbr-view BGL under storage-buffer caps', () => {
51
51
  const spriteSpec = makeSpec('forgeax::sprite');
52
52
  const spriteLitSpec = makeSpec('forgeax::sprite-lit');
53
53
  const sprite = buildBindGroupLayoutDescriptor(spriteSpec, {
@@ -59,7 +59,9 @@ describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
59
59
  caps: { storageBuffer: true },
60
60
  });
61
61
  expect(JSON.stringify(spriteLit)).toBe(JSON.stringify(sprite));
62
- expect(spriteLit.entries.length).toBe(10); // binding 10 is the shared Points/Lines view UBO
62
+ // binding 10 is the shared Points/Lines view UBO; bindings 11/12 are the
63
+ // optional SpotLight projector texture and sampler on capable devices.
64
+ expect(spriteLit.entries.length).toBe(12);
63
65
  });
64
66
 
65
67
  it('sprite-lit vs sprite share the same 10-entry pbr-view BGL under uniform fallback caps (AC-10)', () => {
@@ -118,24 +120,24 @@ describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
118
120
  });
119
121
 
120
122
  describe('material BGL congruence (pbr-material-merged / unlit-material)', () => {
121
- it('pbr-material-user-region entries are 13 (PBR layout reused by sprite & sprite-lit)', () => {
123
+ it('pbr-material-user-region entries are 17 (PBR layout reused by sprite & sprite-lit)', () => {
122
124
  // sprite + sprite-lit use the default standard-PBR user-region
123
- // schema (5 user fields produce 7 BGL entries after std140 merge).
125
+ // schema (20 value fields + 8 texture fields produce 17 BGL entries).
124
126
  // Both share the same userRegion shape and therefore the same BGL.
125
127
  const base = buildPbrMaterialUserRegionEntries();
126
- expect(base.length).toBe(13);
128
+ expect(base.length).toBe(17);
127
129
  });
128
130
 
129
- it('pbr-material-merged stays 24 entries (sprite/sprite-lit do not change material BGL shape)', () => {
131
+ it('pbr-material-merged stays 26 entries (sprite/sprite-lit do not change material BGL shape)', () => {
130
132
  const merged = buildBindGroupLayoutDescriptor(makeSpec('forgeax::sprite-lit'), {
131
133
  kind: 'pbr-material-merged',
132
134
  });
133
- expect(merged.entries.length).toBe(24);
134
- // mergeSkylightIntoMaterialBgl + lightmap injection must stay
135
+ expect(merged.entries.length).toBe(26);
136
+ // mergeSkylightIntoMaterialBgl + transmission injection must stay
135
137
  // append-only against the 7-entry PBR base.
136
138
  const base = buildPbrMaterialUserRegionEntries();
137
139
  const afterSky = mergeSkylightIntoMaterialBgl(base);
138
- const expected = [...afterSky, ...appendInjection(afterSky, 'lightmap')];
140
+ const expected = [...afterSky, ...appendInjection(afterSky, 'transmission')];
139
141
  expect(merged.entries).toEqual(expected);
140
142
  });
141
143
  });
@@ -8,6 +8,8 @@
8
8
  import { RenderGraph } from '@forgeax/engine-render-graph';
9
9
  import type { RhiDevice } from '@forgeax/engine-rhi';
10
10
  import { describe, expect, it, vi } from 'vitest';
11
+ import { DeviceScope } from '../../../render/src/device/device-scope';
12
+ import type { BloomPersistentBundle } from '../../../render/src/record/render-context';
11
13
  import {
12
14
  recordBloomBlurHPass,
13
15
  recordBloomBlurVPass,
@@ -680,6 +682,31 @@ function makePostProcessRecordCtx(): {
680
682
  queue: { writeBuffer: vi.fn(() => ({ ok: true, value: undefined })) },
681
683
  };
682
684
  const sampler = { label: 'postprocess-sampler' };
685
+ const bloomScope = DeviceScope.create(1, 'runtime-ssao-bloom-fixture');
686
+ const ownBloomHandle = (
687
+ kind: 'pipeline' | 'binding' | 'post-effect' | 'buffer',
688
+ label: string,
689
+ ) => {
690
+ const handle = { label };
691
+ bloomScope._adopt(kind, handle, () => undefined);
692
+ return handle;
693
+ };
694
+ const bloomResources = {
695
+ scope: bloomScope,
696
+ generation: bloomScope.generation,
697
+ bloomBrightPipeline: ownBloomHandle('pipeline', 'bloom-bright-pipeline'),
698
+ bloomBlurHPipeline: ownBloomHandle('pipeline', 'bloom-blur-h-pipeline'),
699
+ bloomBlurVPipeline: ownBloomHandle('pipeline', 'bloom-blur-v-pipeline'),
700
+ bloomCompositePipeline: ownBloomHandle('pipeline', 'bloom-composite-pipeline'),
701
+ bloomBrightBindGroupLayout: ownBloomHandle('binding', 'bloom-bright-bgl'),
702
+ bloomBlurBindGroupLayout: ownBloomHandle('binding', 'bloom-blur-bgl'),
703
+ bloomCompositeBindGroupLayout: ownBloomHandle('binding', 'bloom-composite-bgl'),
704
+ bloomSampler: ownBloomHandle('post-effect', 'bloom-sampler'),
705
+ bloomBrightParamsBuffer: ownBloomHandle('buffer', 'bloom-bright-params'),
706
+ bloomBlurHParamsBuffer: ownBloomHandle('buffer', 'bloom-blur-h-params'),
707
+ bloomBlurVParamsBuffer: ownBloomHandle('buffer', 'bloom-blur-v-params'),
708
+ bloomCompositeParamsBuffer: ownBloomHandle('buffer', 'bloom-composite-params'),
709
+ } as unknown as BloomPersistentBundle;
683
710
  const ctx = {
684
711
  runtime: {
685
712
  device: { ...device, caps: { storageBuffer: true } },
@@ -702,23 +729,12 @@ function makePostProcessRecordCtx(): {
702
729
  skyboxBindGroupLayout: { label: 'skybox-bgl' },
703
730
  skyboxSampler: sampler,
704
731
  skyboxRotationBuffer: { label: 'skybox-rotation' },
705
- bloomBrightPipeline: { label: 'bloom-bright-pipeline' },
706
- bloomBrightBindGroupLayout: { label: 'bloom-bright-bgl' },
707
- bloomBrightParamsBuffer: { label: 'bloom-bright-params' },
708
- bloomBlurHPipeline: { label: 'bloom-blur-h-pipeline' },
709
- bloomBlurVPipeline: { label: 'bloom-blur-v-pipeline' },
710
- bloomBlurBindGroupLayout: { label: 'bloom-blur-bgl' },
711
- bloomBlurHParamsBuffer: { label: 'bloom-blur-h-params' },
712
- bloomBlurVParamsBuffer: { label: 'bloom-blur-v-params' },
713
- bloomCompositePipeline: { label: 'bloom-composite-pipeline' },
714
- bloomCompositeBindGroupLayout: { label: 'bloom-composite-bgl' },
715
- bloomCompositeParamsBuffer: { label: 'bloom-composite-params' },
716
- bloomSampler: sampler,
717
732
  fxaaPipeline: { label: 'fxaa-pipeline' },
718
733
  fxaaBindGroupLayout: { label: 'fxaa-bgl' },
719
734
  fxaaSampler: sampler,
720
735
  },
721
736
  },
737
+ bloomResources,
722
738
  frameState: { postProcessBgCache: new WeakMap() },
723
739
  bindGroupCounts: { createBindGroup: 0, keys: [] },
724
740
  camera: {