@forgeax/engine-runtime 0.1.6 → 0.1.19

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 (61) hide show
  1. package/README.md +61 -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__/render-environment-consumer.integration.test.d.ts +2 -0
  6. package/dist/__tests__/render-environment-consumer.integration.test.d.ts.map +1 -0
  7. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  8. package/dist/__tests__/render-feature-prepared-graphics.fixture.d.ts.map +1 -1
  9. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts +2 -0
  10. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts.map +1 -0
  11. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts +2 -0
  12. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts.map +1 -0
  13. package/dist/backend-selection.d.ts.map +1 -1
  14. package/dist/collect-scene-asset.d.ts +6 -3
  15. package/dist/collect-scene-asset.d.ts.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.mjs +25 -4
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/renderer-host.d.ts +2 -1
  21. package/dist/renderer-host.d.ts.map +1 -1
  22. package/dist/renderer-host.mjs +21 -2
  23. package/dist/renderer-host.mjs.map +1 -1
  24. package/package.json +30 -30
  25. package/src/__tests__/asset-registry-guid-reverse.test.ts +33 -0
  26. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +12 -11
  27. package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +39 -6
  28. package/src/__tests__/errors.unit.test.ts +60 -0
  29. package/src/__tests__/extract-record-no-hardcoded-texture-fields.test.ts +4 -5
  30. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +461 -18
  31. package/src/__tests__/geometry.unit.test.ts +3 -1
  32. package/src/__tests__/helpers/standard-material-manifest.ts +45 -0
  33. package/src/__tests__/lights.unit.test.ts +15 -11
  34. package/src/__tests__/material-texture-uv-scale.unit.test.ts +8 -14
  35. package/src/__tests__/materials.unit.test.ts +2 -0
  36. package/src/__tests__/pbr-pipeline.unit.test.ts +15 -12
  37. package/src/__tests__/pipeline-cache-keying.unit.test.ts +6 -4
  38. package/src/__tests__/pipeline.unit.test.ts +5 -2
  39. package/src/__tests__/render-environment-consumer.integration.test.ts +33 -0
  40. package/src/__tests__/render-error-exhaustive.test-d.ts +93 -0
  41. package/src/__tests__/render-feature-prepared-graphics.browser.test.ts +5 -1
  42. package/src/__tests__/render-feature-prepared-graphics.fixture.ts +10 -0
  43. package/src/__tests__/render-system-mega.test.ts +3 -1
  44. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +3 -1
  45. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +3 -1
  46. package/src/__tests__/render-system-record.test.ts +8 -6
  47. package/src/__tests__/renderer-host-fallback.unit.test.ts +112 -0
  48. package/src/__tests__/renderer-lifecycle.integration.test.ts +10 -0
  49. package/src/__tests__/renderer-surface.unit.test.ts +66 -0
  50. package/src/__tests__/renderer.test-d.ts +2 -0
  51. package/src/__tests__/rhi-null-command-flow.unit.test.ts +14 -0
  52. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +5 -1
  53. package/src/__tests__/shadow-csm-tile-consistency.test.ts +5 -1
  54. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +383 -0
  55. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +10 -10
  56. package/src/__tests__/ssao-passes.test.ts +32 -12
  57. package/src/__tests__/systems.unit.test.ts +48 -31
  58. package/src/backend-selection.ts +4 -0
  59. package/src/collect-scene-asset.ts +14 -7
  60. package/src/index.ts +5 -1
  61. package/src/renderer-host.ts +30 -3
@@ -0,0 +1,112 @@
1
+ import { EngineEnvironmentError } from '@forgeax/engine-render/internal/construct-renderer';
2
+ import { RhiError } from '@forgeax/engine-rhi';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ constructRendererHost: vi.fn(),
7
+ loadBackendPack: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('@forgeax/engine-render/internal/construct-renderer', async (importOriginal) => {
11
+ const actual =
12
+ await importOriginal<typeof import('@forgeax/engine-render/internal/construct-renderer')>();
13
+ return { ...actual, constructRendererHost: mocks.constructRendererHost };
14
+ });
15
+
16
+ vi.mock('../backend-selection', async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import('../backend-selection')>();
18
+ return { ...actual, loadBackendPack: mocks.loadBackendPack };
19
+ });
20
+
21
+ import { constructRuntimeRendererHost } from '../renderer-host';
22
+
23
+ function environmentError(code: 'adapter-unavailable' | 'limit-exceeded') {
24
+ return new EngineEnvironmentError('no usable rendering backend', {
25
+ webgpuError: new RhiError({
26
+ code,
27
+ expected: 'a usable WebGPU backend',
28
+ hint: 'select another backend',
29
+ }),
30
+ });
31
+ }
32
+
33
+ function canvas() {
34
+ return { getContext: vi.fn((_kind?: string) => null) };
35
+ }
36
+
37
+ describe('runtime renderer backend fallback boundary', () => {
38
+ beforeEach(() => {
39
+ vi.resetAllMocks();
40
+ });
41
+
42
+ it('preserves ordinary construction errors and does not enter Channel 3', async () => {
43
+ const firstPack = { name: 'webgpu' };
44
+ const fallbackPack = { name: 'wgpu' };
45
+ const target = canvas();
46
+ const ordinaryError = new Error('shader compile failed');
47
+ mocks.loadBackendPack
48
+ .mockResolvedValueOnce({ ok: true, value: firstPack })
49
+ .mockResolvedValueOnce({ ok: true, value: fallbackPack });
50
+ mocks.constructRendererHost.mockImplementation(
51
+ async (hostCanvas: typeof target, _options: unknown, _bundler: unknown, pack: unknown) => {
52
+ if (pack === fallbackPack) hostCanvas.getContext('webgl2');
53
+ return { ok: false, error: ordinaryError };
54
+ },
55
+ );
56
+
57
+ const result = await constructRuntimeRendererHost(target);
58
+
59
+ expect(result).toEqual({ ok: false, error: ordinaryError });
60
+ expect(mocks.loadBackendPack).toHaveBeenCalledTimes(1);
61
+ expect(mocks.constructRendererHost).toHaveBeenCalledTimes(1);
62
+ expect(target.getContext).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('uses one Channel 3 attempt for an environment-class WebGPU failure', async () => {
66
+ const firstPack = { name: 'webgpu' };
67
+ const fallbackPack = { name: 'wgpu' };
68
+ const target = canvas();
69
+ const environmentFailure = environmentError('adapter-unavailable');
70
+ mocks.loadBackendPack
71
+ .mockResolvedValueOnce({ ok: true, value: firstPack })
72
+ .mockResolvedValueOnce({ ok: true, value: fallbackPack });
73
+ mocks.constructRendererHost
74
+ .mockResolvedValueOnce({ ok: false, error: environmentFailure })
75
+ .mockImplementationOnce(async (hostCanvas: typeof target, _options, _bundler, pack) => {
76
+ if (pack === fallbackPack) hostCanvas.getContext('webgl2');
77
+ return { ok: true, value: {} };
78
+ });
79
+
80
+ const result = await constructRuntimeRendererHost(target);
81
+
82
+ expect(result.ok).toBe(true);
83
+ expect(mocks.loadBackendPack).toHaveBeenCalledTimes(2);
84
+ expect(mocks.loadBackendPack).toHaveBeenNthCalledWith(2, undefined, true);
85
+ expect(mocks.constructRendererHost).toHaveBeenNthCalledWith(
86
+ 2,
87
+ target,
88
+ undefined,
89
+ undefined,
90
+ fallbackPack,
91
+ );
92
+ expect(target.getContext).toHaveBeenCalledOnce();
93
+ });
94
+
95
+ it('never falls back for an explicitly injected RHI and retains structured detail', async () => {
96
+ const firstPack = { name: 'explicit' };
97
+ const target = canvas();
98
+ const environmentFailure = environmentError('limit-exceeded');
99
+ const explicitRhi = {};
100
+ mocks.loadBackendPack.mockResolvedValueOnce({ ok: true, value: firstPack });
101
+ mocks.constructRendererHost.mockResolvedValueOnce({ ok: false, error: environmentFailure });
102
+
103
+ const result = await constructRuntimeRendererHost(target, { rhi: explicitRhi as never });
104
+
105
+ expect(result).toEqual({ ok: false, error: environmentFailure });
106
+ expect(result.ok ? undefined : result.error).toBe(environmentFailure);
107
+ expect(environmentFailure.detail.webgpuError).toMatchObject({ code: 'limit-exceeded' });
108
+ expect(mocks.loadBackendPack).toHaveBeenCalledTimes(1);
109
+ expect(mocks.constructRendererHost).toHaveBeenCalledTimes(1);
110
+ expect(target.getContext).not.toHaveBeenCalled();
111
+ });
112
+ });
@@ -1,6 +1,7 @@
1
1
  import { World } from '@forgeax/engine-ecs';
2
2
  import { rhi } from '@forgeax/engine-rhi-null';
3
3
  import { describe, expect, it } from 'vitest';
4
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
4
5
  import { requireRenderer } from './renderer-test-utils';
5
6
 
6
7
  function canvas(): HTMLCanvasElement {
@@ -15,6 +16,15 @@ const manifest = `data:application/json,${encodeURIComponent(
15
16
  { hash: 'unlit000', wgsl: '/* unlit */', glsl: '', bindings: '' },
16
17
  { hash: 'tonemap0', wgsl: '/* tonemap */', glsl: '', bindings: '' },
17
18
  ],
19
+ materialShaders: [
20
+ {
21
+ identifier: 'forgeax::default-standard-pbr',
22
+ sourcePath: 'forgeax::default-standard-pbr.wgsl',
23
+ composedWgsl: '/* stub */',
24
+ paramSchema: '[]',
25
+ variants: standardMaterialShaderVariants(),
26
+ },
27
+ ],
18
28
  }),
19
29
  )}`;
20
30
 
@@ -345,6 +345,72 @@ describe('Surface retry (w5)', () => {
345
345
  expect(ctxCalls.n).toBe(1);
346
346
  expect(cfgCalls.n).toBe(0);
347
347
  });
348
+
349
+ it('keeps an existing configured LKG when a reconfigure candidate fails proof validation', () => {
350
+ const cfgCalls = { n: 0 };
351
+ const ctxCalls = { n: 0 };
352
+ const ps = makePipelineState();
353
+ const reg = new HealthListenerRegistry();
354
+ const mockCtx = {
355
+ ...makeSurfaceCtx(cfgCalls, ctxCalls, 1),
356
+ presentationProof: { descriptor: true, acquisition: false, validation: true },
357
+ };
358
+ const pipelineState = ps as unknown as Parameters<typeof acquireSwapChainTarget>[1];
359
+ const dev = makeMockDevice(ps);
360
+ dev.caps = { backendKind: 'wgpu-webgl2', storageBuffer: false };
361
+ const errors: unknown[] = [];
362
+
363
+ // biome-ignore lint/suspicious/noExplicitAny: mock internals
364
+ const internals: any = {
365
+ canvas: { width: 800, height: 600 },
366
+ device: dev,
367
+ context: mockCtx,
368
+ getPipelineState: () => ps,
369
+ errorRegistry: {
370
+ add: () => () => {},
371
+ fire: (error: unknown) => errors.push(error),
372
+ clear: () => {},
373
+ },
374
+ healthRegistry: reg,
375
+ };
376
+
377
+ const target = acquireSwapChainTarget(internals, pipelineState);
378
+
379
+ expect(target).toBeNull();
380
+ expect(cfgCalls.n).toBe(1);
381
+ expect(ctxCalls.n).toBe(1);
382
+ expect(ps.perPassResources.configured).toBe(true);
383
+ expect(errors).toHaveLength(1);
384
+ });
385
+
386
+ it('keeps the first surface failure unconfigured and skips the retry', () => {
387
+ const cfgCalls = { n: 0 };
388
+ const ctxCalls = { n: 0 };
389
+ const ps = makePipelineState();
390
+ ps.perPassResources.configured = false;
391
+ const mockCtx = {
392
+ ...makeSurfaceCtx(cfgCalls, ctxCalls, 1),
393
+ presentationProof: { descriptor: true, acquisition: false, validation: true },
394
+ };
395
+ const pipelineState = ps as unknown as Parameters<typeof acquireSwapChainTarget>[1];
396
+ const dev = makeMockDevice(ps);
397
+ dev.caps = { backendKind: 'wgpu-webgl2', storageBuffer: false };
398
+
399
+ // biome-ignore lint/suspicious/noExplicitAny: mock internals
400
+ const internals: any = {
401
+ canvas: { width: 800, height: 600 },
402
+ device: dev,
403
+ context: mockCtx,
404
+ getPipelineState: () => ps,
405
+ errorRegistry: { add: () => () => {}, fire: () => {}, clear: () => {} },
406
+ healthRegistry: new HealthListenerRegistry(),
407
+ };
408
+
409
+ expect(acquireSwapChainTarget(internals, pipelineState)).toBeNull();
410
+ expect(cfgCalls.n).toBe(1);
411
+ expect(ctxCalls.n).toBe(1);
412
+ expect(ps.perPassResources.configured).toBe(false);
413
+ });
348
414
  });
349
415
 
350
416
  // ── w6: consecutive surface failure -> internal-fault (AC-04) ────────────────
@@ -49,6 +49,8 @@ describe('subscribe projects the discriminated Renderer event contract', () => {
49
49
  return `${event.previous}->${event.current}`;
50
50
  case 'error':
51
51
  return event.error.code;
52
+ case 'frame-submitted':
53
+ return `frame:${event.frameId}@${event.deviceGeneration}`;
52
54
  }
53
55
  };
54
56
  expectTypeOf(describeEvent).returns.toEqualTypeOf<string>();
@@ -17,6 +17,12 @@ function frameRequest(lease: Parameters<Renderer['draw']>[0]['leases'][number])
17
17
  describe('RhiNull command flow', () => {
18
18
  it('returns the synchronous receipt after the host submits a frame', async () => {
19
19
  const renderer = await requireRenderer(canvas(), { rhi }, { shaderManifestUrl: manifest });
20
+ const events: Array<{
21
+ readonly kind: string;
22
+ readonly frameId?: number;
23
+ readonly deviceGeneration?: number;
24
+ }> = [];
25
+ const unsubscribe = renderer.subscribe((event) => events.push(event));
20
26
  const world = new World();
21
27
  const attached = renderer.attach(world);
22
28
  expect(attached.ok).toBe(true);
@@ -26,9 +32,17 @@ describe('RhiNull command flow', () => {
26
32
  const frame = renderer.draw(frameRequest(attached.value));
27
33
  expect(frame.ok).toBe(true);
28
34
  if (!frame.ok) return;
35
+ expect(events.filter((event) => event.kind === 'frame-submitted')).toEqual([
36
+ {
37
+ kind: 'frame-submitted',
38
+ frameId: frame.value.frameId,
39
+ deviceGeneration: frame.value.deviceGeneration,
40
+ },
41
+ ]);
29
42
  const completed = await frame.value.completed;
30
43
  expect(completed.ok).toBe(true);
31
44
  expect(renderer.inspect().frame.frameId).toBe(frame.value.frameId);
45
+ unsubscribe();
32
46
  renderer.dispose();
33
47
  });
34
48
 
@@ -21,6 +21,7 @@ import type { World as WorldType } from '@forgeax/engine-ecs';
21
21
  import type { Renderer as RendererType } from '@forgeax/engine-render';
22
22
  import type { Handle } from '@forgeax/engine-types';
23
23
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
24
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
24
25
 
25
26
  const ENGINE = '../createRenderer';
26
27
 
@@ -152,7 +153,10 @@ function buildManifestDataUrl(): string {
152
153
  sourcePath: `${identifier}.wgsl`,
153
154
  composedWgsl,
154
155
  paramSchema: '[]',
155
- variants: [],
156
+ variants:
157
+ identifier === 'forgeax::default-standard-pbr'
158
+ ? standardMaterialShaderVariants(composedWgsl)
159
+ : [],
156
160
  });
157
161
  const manifest = {
158
162
  schemaVersion: '1.0.0',
@@ -28,6 +28,7 @@ import type { World as WorldType } from '@forgeax/engine-ecs';
28
28
  import type { Renderer as RendererType } from '@forgeax/engine-render';
29
29
  import type { Handle } from '@forgeax/engine-types';
30
30
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
31
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
31
32
 
32
33
  const ENGINE = '../createRenderer';
33
34
 
@@ -143,7 +144,10 @@ function buildManifestDataUrl(): string {
143
144
  sourcePath: `${identifier}.wgsl`,
144
145
  composedWgsl,
145
146
  paramSchema: '[]',
146
- variants: [],
147
+ variants:
148
+ identifier === 'forgeax::default-standard-pbr'
149
+ ? standardMaterialShaderVariants(composedWgsl)
150
+ : [],
147
151
  });
148
152
  const manifest = {
149
153
  schemaVersion: '1.0.0',
@@ -0,0 +1,383 @@
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') errors.push(event.error.code);
316
+ });
317
+ const world = new World();
318
+ spawnMixedScene(world, castShadow);
319
+ // The first frame warms the static and skinned PSOs; the second frame is
320
+ // the asserted mixed command stream after both cache entries exist.
321
+ for (let frame = 0; frame < 2; frame += 1) {
322
+ const receipt = drawPublished(renderer, world);
323
+ expect(receipt.ok, `mixed ${castShadow ? 'shadow' : 'baseline'} draw`).toBe(true);
324
+ if (!receipt.ok) throw receipt.error;
325
+ const completed = await receipt.value.completed;
326
+ expect(completed.ok, 'Queue::submit completion').toBe(true);
327
+ if (!completed.ok) throw completed.error;
328
+ }
329
+ if (device === undefined || target === undefined)
330
+ throw new Error('Dawn target not initialized');
331
+ await device.queue.onSubmittedWorkDone();
332
+ return { pixels: await readPixels(device, target), errors };
333
+ } finally {
334
+ globalThis.navigator.gpu.requestAdapter = originalRequestAdapter;
335
+ unsubscribe?.();
336
+ renderer?.dispose();
337
+ target?.destroy();
338
+ device?.destroy();
339
+ }
340
+ }
341
+
342
+ function regionDiff(a: Uint8Array, b: Uint8Array, x0: number, x1: number): number {
343
+ let changed = 0;
344
+ for (let y = Math.floor(HEIGHT * 0.32); y < HEIGHT; y += 1) {
345
+ for (let x = x0; x < x1; x += 1) {
346
+ const index = y * BYTES_PER_ROW + x * 4;
347
+ if (
348
+ Math.abs((a[index] ?? 0) - (b[index] ?? 0)) > 2 ||
349
+ Math.abs((a[index + 1] ?? 0) - (b[index + 1] ?? 0)) > 2 ||
350
+ Math.abs((a[index + 2] ?? 0) - (b[index + 2] ?? 0)) > 2
351
+ ) {
352
+ changed += 1;
353
+ }
354
+ }
355
+ }
356
+ return changed;
357
+ }
358
+
359
+ describe('mixed static/skinned shadow pipeline (Dawn)', () => {
360
+ it('submits static → skinned → static casters with zero validation errors', async () => {
361
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') {
362
+ throw new Error('dawn-node navigator.gpu not injected');
363
+ }
364
+ const shadow = await renderMixed(true);
365
+ const baseline = await renderMixed(false);
366
+ expect(shadow.errors).toEqual([]);
367
+ expect(baseline.errors).toEqual([]);
368
+ expect(shadow.pixels.length).toBeGreaterThan(0);
369
+ expect(baseline.pixels.length).toBe(shadow.pixels.length);
370
+
371
+ // Each caster owns a separate x-region. Comparing against the identical
372
+ // no-shadow scene proves the depth pass contributed in all three regions,
373
+ // rather than merely proving that a color frame was non-black.
374
+ const regions = [
375
+ [0, Math.floor(WIDTH / 3)],
376
+ [Math.floor(WIDTH / 3), Math.floor((2 * WIDTH) / 3)],
377
+ [Math.floor((2 * WIDTH) / 3), WIDTH],
378
+ ] as const;
379
+ for (const [x0, x1] of regions) {
380
+ expect(regionDiff(shadow.pixels, baseline.pixels, x0, x1)).toBeGreaterThan(0);
381
+ }
382
+ }, 120000);
383
+ });