@forgeax/engine-runtime 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-runtime",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,37 +26,37 @@
26
26
  "LICENSE"
27
27
  ],
28
28
  "dependencies": {
29
- "@forgeax/engine-animation": "0.1.25",
30
- "@forgeax/engine-assets-runtime": "0.1.25",
31
- "@forgeax/engine-audio-webaudio": "0.1.25",
32
- "@forgeax/engine-codec": "0.1.25",
33
- "@forgeax/engine-debug-draw": "0.1.25",
34
- "@forgeax/engine-ecs": "0.1.25",
35
- "@forgeax/engine-font": "0.1.25",
36
- "@forgeax/engine-geometry": "0.1.25",
37
- "@forgeax/engine-graphics-extras": "0.1.25",
38
- "@forgeax/engine-image": "0.1.25",
39
- "@forgeax/engine-input": "0.1.25",
40
- "@forgeax/engine-math": "0.1.25",
41
- "@forgeax/engine-pack": "0.1.25",
42
- "@forgeax/engine-profiler": "0.1.25",
43
- "@forgeax/engine-render": "0.1.25",
44
- "@forgeax/engine-render-graph": "0.1.25",
45
- "@forgeax/engine-rhi": "0.1.25",
46
- "@forgeax/engine-rhi-null": "0.1.25",
47
- "@forgeax/engine-rhi-webgpu": "0.1.25",
48
- "@forgeax/engine-rhi-wgpu": "0.1.25",
49
- "@forgeax/engine-scene": "0.1.25",
50
- "@forgeax/engine-shader": "0.1.25",
51
- "@forgeax/engine-skinning": "0.1.25",
52
- "@forgeax/engine-types": "0.1.25",
53
- "@forgeax/engine-vfx": "0.1.25"
29
+ "@forgeax/engine-animation": "0.1.26",
30
+ "@forgeax/engine-assets-runtime": "0.1.26",
31
+ "@forgeax/engine-audio-webaudio": "0.1.26",
32
+ "@forgeax/engine-codec": "0.1.26",
33
+ "@forgeax/engine-debug-draw": "0.1.26",
34
+ "@forgeax/engine-ecs": "0.1.26",
35
+ "@forgeax/engine-font": "0.1.26",
36
+ "@forgeax/engine-geometry": "0.1.26",
37
+ "@forgeax/engine-graphics-extras": "0.1.26",
38
+ "@forgeax/engine-image": "0.1.26",
39
+ "@forgeax/engine-input": "0.1.26",
40
+ "@forgeax/engine-math": "0.1.26",
41
+ "@forgeax/engine-pack": "0.1.26",
42
+ "@forgeax/engine-profiler": "0.1.26",
43
+ "@forgeax/engine-render": "0.1.26",
44
+ "@forgeax/engine-render-graph": "0.1.26",
45
+ "@forgeax/engine-rhi": "0.1.26",
46
+ "@forgeax/engine-rhi-null": "0.1.26",
47
+ "@forgeax/engine-rhi-webgpu": "0.1.26",
48
+ "@forgeax/engine-rhi-wgpu": "0.1.26",
49
+ "@forgeax/engine-scene": "0.1.26",
50
+ "@forgeax/engine-shader": "0.1.26",
51
+ "@forgeax/engine-skinning": "0.1.26",
52
+ "@forgeax/engine-types": "0.1.26",
53
+ "@forgeax/engine-vfx": "0.1.26"
54
54
  },
55
55
  "devDependencies": {
56
- "@forgeax/engine-import": "0.1.25",
57
- "@forgeax/engine-naga": "0.1.25",
58
- "@forgeax/engine-state": "0.1.25",
59
- "@forgeax/engine-vite-plugin-shader": "0.1.25",
56
+ "@forgeax/engine-import": "0.1.26",
57
+ "@forgeax/engine-naga": "0.1.26",
58
+ "@forgeax/engine-state": "0.1.26",
59
+ "@forgeax/engine-vite-plugin-shader": "0.1.26",
60
60
  "@types/node": "^20.14.0"
61
61
  },
62
62
  "forgeax": {
@@ -5,7 +5,9 @@ import { describe, expect, it } from 'vitest';
5
5
  type CookedRecord = {
6
6
  readonly specializationKey: string;
7
7
  readonly artifactDigest: string;
8
- readonly artifact: { readonly digest: string; readonly bytes: readonly number[] };
8
+ readonly programs: readonly {
9
+ readonly artifact: { readonly digest: string; readonly bytes: readonly number[] };
10
+ }[];
9
11
  readonly resolved?: {
10
12
  readonly values?: unknown;
11
13
  };
@@ -54,9 +56,9 @@ describe('custom-shader cooked MaterialAsset fixture', () => {
54
56
  const cookedRecords = [root?.payload.cooked, derived?.payload.cooked];
55
57
  for (const cooked of cookedRecords) {
56
58
  expect(cooked).toBeDefined();
57
- expect(cooked?.artifact?.digest).toMatch(/^sha256:/);
58
- expect(cooked?.artifact?.digest).toBe(cooked?.artifactDigest);
59
- expect(cooked?.receipt?.schemaVersion).toBe('material-cook/3');
59
+ expect(cooked?.programs?.[0]?.artifact?.digest).toMatch(/^sha256:/);
60
+ expect(cooked?.artifactDigest).toMatch(/^sha256:/);
61
+ expect(cooked?.receipt?.schemaVersion).toBe('material-cook/4');
60
62
  expect(cooked?.receipt?.identity?.layoutIdentity).toMatch(/^sha256-/);
61
63
  expect(cooked?.receipt?.identity?.programIdentity).toMatch(/^sha256:/);
62
64
  expect(cooked?.receipt?.identity?.pipelineIdentity).toMatch(/^sha256:/);
@@ -67,7 +69,9 @@ describe('custom-shader cooked MaterialAsset fixture', () => {
67
69
  root?.payload.cooked?.specializationKey,
68
70
  );
69
71
  expect(derived?.payload.cooked?.artifactDigest).toBe(root?.payload.cooked?.artifactDigest);
70
- expect(derived?.payload.cooked?.artifact?.bytes).toEqual(root?.payload.cooked?.artifact?.bytes);
72
+ expect(derived?.payload.cooked?.programs?.[0]?.artifact?.bytes).toEqual(
73
+ root?.payload.cooked?.programs?.[0]?.artifact?.bytes,
74
+ );
71
75
  expect(root?.payload.cooked?.resolved?.values).toMatchObject({
72
76
  baseColor: [0.95, 0.45, 0.2, 1],
73
77
  time: 0,
@@ -82,7 +86,7 @@ describe('custom-shader cooked MaterialAsset fixture', () => {
82
86
 
83
87
  it('keeps cooked layout identity and authored coordinate transforms', () => {
84
88
  const cooked = root?.payload.cooked;
85
- expect(cooked?.receipt?.schemaVersion).toBe('material-cook/3');
89
+ expect(cooked?.receipt?.schemaVersion).toBe('material-cook/4');
86
90
  expect(cooked?.receipt?.identity?.layoutIdentity).toMatch(/^sha256-/);
87
91
  expect(cooked?.receipt?.identity?.programIdentity).toMatch(/^sha256:/);
88
92
  expect(cooked?.receipt?.identity?.pipelineIdentity).toMatch(/^sha256:/);
@@ -0,0 +1,15 @@
1
+ #define_import_path regression::center_right
2
+ #import forgeax_material::parameters::{material}
3
+ struct Input { @location(0) position: vec3<f32> }
4
+ @vertex fn vs_main(input: Input) -> @builtin(position) vec4<f32> {
5
+ return vec4<f32>(input.position.xy * 0.4, 0.5, 1.0);
6
+ }
7
+ @vertex fn vs_right(input: Input) -> @builtin(position) vec4<f32> {
8
+ return vec4<f32>(input.position.xy * 0.4 + vec2<f32>(0.6, 0.0), 0.5, 1.0);
9
+ }
10
+ @fragment fn fs_main() -> @location(0) vec4<f32> {
11
+ return vec4<f32>(0.0, material.strength, 0.0, 1.0);
12
+ }
13
+ @fragment fn fs_blue() -> @location(0) vec4<f32> {
14
+ return vec4<f32>(0.0, 0.0, material.strength, 1.0);
15
+ }
@@ -0,0 +1,9 @@
1
+ #define_import_path regression::left
2
+ #import forgeax_material::parameters::{material}
3
+ struct Input { @location(0) position: vec3<f32> }
4
+ @vertex fn vs_main(input: Input) -> @builtin(position) vec4<f32> {
5
+ return vec4<f32>(input.position.xy * 0.4 + vec2<f32>(-0.6, 0.0), 0.5, 1.0);
6
+ }
7
+ @fragment fn fs_main() -> @location(0) vec4<f32> {
8
+ return vec4<f32>(material.strength, 0.0, 0.0, 1.0);
9
+ }
@@ -45,6 +45,9 @@ function matrices(x: number, count = 1): Float32Array {
45
45
  return result;
46
46
  }
47
47
 
48
+ // Coverage instrumentation plus a contended heavy runner can make the
49
+ // 20,000-instance recovery case exceed the normal one-minute budget even
50
+ // though the operation remains bounded and completes quickly uninstrumented.
48
51
  it.each([
49
52
  1, 129, 20000,
50
53
  ])('renders %i World instances after replacing and recovering the Renderer', async (count) => {
@@ -158,4 +161,4 @@ it.each([
158
161
  expect(retained[(count - 1) * 16 + 12]).toBe(finalX);
159
162
  expect(b.inspect().instanceCollections[0]?.count).toBe(count);
160
163
  await b.dispose();
161
- }, 60000);
164
+ }, 120_000);
@@ -99,24 +99,25 @@ describe('loader-extract graceful handoff (M4 w24)', () => {
99
99
  expect('baseColorTexture' in pv).toBe(false);
100
100
  });
101
101
 
102
- it('(3) shader NOT registered (R-4 cross-worktree) -> graceful "try every int" fallback', () => {
103
- // No paramSchema lookup available -> getMaterialShaderTextureFieldNames
104
- // returns undefined for every shader id.
102
+ it('(3) shader NOT registered (R-4 cross-worktree) -> graceful structured texture fallback', () => {
103
+ // No paramSchema lookup is available, so the loader cannot classify bare
104
+ // integers as texture refs. Explicit structured texture values remain
105
+ // resolvable across worktrees without guessing at scalar fields.
105
106
  const ctx = makeCtx({
106
107
  shaderTextureFieldNames: () => undefined,
107
108
  });
108
109
  const out = materialLoader.load(
109
110
  {
110
111
  passes: [{ program: { module: 'forgeax::user-defined' } }],
111
- values: { customTexture: 0 },
112
+ values: { customTexture: { texture: 0 } },
112
113
  },
113
114
  ['tex-guid'],
114
115
  ctx,
115
116
  );
116
117
  expect(out).toMatchObject({ kind: 'material' });
117
118
  const pv = (out as { values: Record<string, unknown> }).values;
118
- // Graceful fallback resolves any in-range int to its refs[] GUID string.
119
- expect(pv.customTexture).toBe('tex-guid');
119
+ // Explicit structured refs resolve to the enclosing refs[] GUID.
120
+ expect(pv.customTexture).toEqual({ texture: 'tex-guid' });
120
121
  });
121
122
 
122
123
  it('(4) shader registered + scalar paramValue with refs[] index in range -> NOT misclassified', () => {
@@ -546,29 +546,22 @@ type __MergedKeep =
546
546
  expect(out).toMatchObject({ kind: 'material', parentGuid: 'parent-guid' });
547
547
  });
548
548
 
549
- it('materialLoader resolves heightTexture from refs index to its GUID (M4 / w22, D-19)', () => {
550
- // feat-20260613-material-paramschema-driven-binding M4 / w22:
551
- // the legacy hardcoded texture-field allowlist Set has been deleted.
552
- // When the shader is not yet registered (mock ctx returns undefined
553
- // from getMaterialShaderTextureFieldNames), the loader falls back to
554
- // a graceful "try every int paramValue in [0, refs.length)" walk and
555
- // resolves heightTexture (any field name) to the refs[] entry.
556
- //
557
- // feat-20260614 M8 (D-19): the embedded sub-asset ref is stored as its
558
- // GUID string verbatim (dash-form) -- the ECS/render side resolves the
559
- // GUID -> column handle at use time via world.allocSharedRef; the loader
560
- // no longer mints a numeric handle (resolveRefSync is deleted). Mirrors
561
- // the sibling `parentGuid` contract (refs index -> GUID string).
549
+ it('materialLoader resolves an explicit heightTexture ref index to its GUID (M4 / w22, D-19)', () => {
550
+ // Texture references use the explicit `{ texture: refsIndex }` wire
551
+ // shape. This remains safe when the shader is not registered because
552
+ // the loader does not guess whether a bare integer is a scalar.
562
553
  const out = materialLoader.load(
563
554
  {
564
555
  passes: [{ shader: 'test' }],
565
- values: { heightTexture: 0 },
556
+ values: { heightTexture: { texture: 0 } },
566
557
  },
567
558
  ['height-guid'],
568
559
  mockCtx(),
569
560
  );
570
561
  expect(out).toMatchObject({ kind: 'material' });
571
- expect((out as Record<string, unknown>)?.values?.heightTexture).toBe('height-guid');
562
+ expect((out as Record<string, unknown>)?.values?.heightTexture).toEqual({
563
+ texture: 'height-guid',
564
+ });
572
565
  });
573
566
 
574
567
  it('materialLoader returns normally when values has no heightTexture field', () => {
@@ -0,0 +1,145 @@
1
+ import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
2
+ import { World } from '@forgeax/engine-ecs';
3
+ import { Camera, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
4
+ import { Transform } from '@forgeax/engine-scene';
5
+ import type { MaterialAsset } from '@forgeax/engine-types';
6
+ import { expect, it } from 'vitest';
7
+ import { page } from 'vitest/browser';
8
+ import { constructRuntimeRendererHost } from '../renderer-host';
9
+ import { drawPublished } from './draw-published';
10
+
11
+ it.each([
12
+ false,
13
+ true,
14
+ ])('draws every selected material Pass with mixed blending=%s', async (mixed) => {
15
+ if (!navigator.gpu) throw new Error('WebGPU is required');
16
+ const canvas = document.createElement('canvas');
17
+ canvas.width = 384;
18
+ canvas.height = 256;
19
+ document.body.append(canvas);
20
+ const requestAdapter = navigator.gpu.requestAdapter.bind(navigator.gpu);
21
+ const validation: string[] = [];
22
+ const entries: string[] = [];
23
+ const devices: GPUDevice[] = [];
24
+ navigator.gpu.requestAdapter = async (options) => {
25
+ const adapter = await requestAdapter(options);
26
+ if (adapter === null) return adapter;
27
+ const requestDevice = adapter.requestDevice.bind(adapter);
28
+ adapter.requestDevice = async (descriptor) => {
29
+ const device = await requestDevice(descriptor);
30
+ devices.push(device);
31
+ device.addEventListener('uncapturederror', (event) => validation.push(event.error.message));
32
+ const record = (descriptor: GPURenderPipelineDescriptor) =>
33
+ entries.push(`${descriptor.vertex.entryPoint}/${descriptor.fragment?.entryPoint}`);
34
+ const create = device.createRenderPipeline.bind(device);
35
+ device.createRenderPipeline = (descriptor) => {
36
+ record(descriptor);
37
+ return create(descriptor);
38
+ };
39
+ const createAsync = device.createRenderPipelineAsync.bind(device);
40
+ device.createRenderPipelineAsync = async (descriptor) => {
41
+ record(descriptor);
42
+ return createAsync(descriptor);
43
+ };
44
+ return device;
45
+ };
46
+ return adapter;
47
+ };
48
+ const host = await constructRuntimeRendererHost(
49
+ canvas,
50
+ {},
51
+ { shaderManifestUrl: '/shaders/manifest.json' },
52
+ );
53
+ if (!host.ok) {
54
+ navigator.gpu.requestAdapter = requestAdapter;
55
+ canvas.remove();
56
+ throw host.error;
57
+ }
58
+ const errors: string[] = [];
59
+ const unsubscribe = host.value.renderer.subscribe((event) => {
60
+ if (event.kind === 'error') errors.push(event.error.message);
61
+ });
62
+ try {
63
+ const assets = host.value.assets;
64
+ assets.configurePackIndex(
65
+ mixed ? '/__material-programs-mixed/pack-index.json' : '/__material-programs/pack-index.json',
66
+ );
67
+ const material = (
68
+ await assets.loadByGuid<MaterialAsset>(
69
+ assets.parseGuid('8f50ae65-6e0a-40b4-8949-b47006edab90'),
70
+ )
71
+ ).unwrap();
72
+ const projection = assets.getMaterialProjectionForPayload(material);
73
+ expect(
74
+ new Set(
75
+ projection?.passes.flatMap((pass) =>
76
+ pass.programs.map((program) => program.specializationKey),
77
+ ),
78
+ ).size,
79
+ ).toBe(2);
80
+ const world = new World();
81
+ const handle = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', material);
82
+ world
83
+ .spawn(
84
+ { component: Transform, data: { pos: [0, 0, 3], scale: [1, 1, 1], quat: [0, 0, 0, 1] } },
85
+ {
86
+ component: Camera,
87
+ data: {
88
+ fov: Math.PI / 4,
89
+ aspect: 1.5,
90
+ near: 0.1,
91
+ far: 100,
92
+ clearColor: [0.02, 0.02, 0.02, 1],
93
+ },
94
+ },
95
+ )
96
+ .unwrap();
97
+ world
98
+ .spawn(
99
+ { component: Transform, data: { pos: [0, 0, 0], scale: [1, 1, 1], quat: [0, 0, 0, 1] } },
100
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
101
+ { component: MeshRenderer, data: { materials: [handle] } },
102
+ )
103
+ .unwrap();
104
+ for (let frame = 0; frame < 40; frame++) {
105
+ const receipt = drawPublished(host.value.renderer, world);
106
+ if (!receipt.ok) throw receipt.error;
107
+ const completed = await receipt.value.completed;
108
+ if (!completed.ok) throw completed.error;
109
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
110
+ }
111
+ await Promise.all(devices.map((device) => device.queue.onSubmittedWorkDone()));
112
+ const shot = await page.elementLocator(canvas).screenshot({ base64: true });
113
+ const bytes = Uint8Array.from(atob(typeof shot === 'string' ? shot : shot.base64), (char) =>
114
+ char.charCodeAt(0),
115
+ );
116
+ const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
117
+ const surface = new OffscreenCanvas(bitmap.width, bitmap.height);
118
+ const ctx = surface.getContext('2d');
119
+ if (ctx === null) throw new Error('pixel readback unavailable');
120
+ ctx.drawImage(bitmap, 0, 0);
121
+ bitmap.close();
122
+ const pixels = ctx.getImageData(0, 0, surface.width, surface.height);
123
+ for (const [fraction, channel] of [
124
+ [0.2, 0],
125
+ [0.5, 1],
126
+ [0.8, 2],
127
+ ] as const) {
128
+ const offset =
129
+ (Math.floor(surface.height * 0.5) * surface.width + Math.floor(surface.width * fraction)) *
130
+ 4;
131
+ const color = [...pixels.data.slice(offset, offset + 3)];
132
+ expect(color[channel], `Pass color at ${fraction}: ${color}`).toBeGreaterThan(200);
133
+ expect(color.filter((_, index) => index !== channel).every((value) => value < 40)).toBe(true);
134
+ }
135
+ expect(entries).toContain('vs_right/fs_blue');
136
+ expect(validation).toEqual([]);
137
+ expect(errors).toEqual([]);
138
+ } finally {
139
+ unsubscribe();
140
+ host.value.renderer.dispose();
141
+ for (const device of devices) device.destroy();
142
+ navigator.gpu.requestAdapter = requestAdapter;
143
+ canvas.remove();
144
+ }
145
+ }, 60_000);
@@ -80,10 +80,10 @@ describe('Materials.standard multi-pass (w14)', () => {
80
80
  expect(shadowPass).toBeDefined();
81
81
  });
82
82
 
83
- it('shadow-caster pass uses forgeax_material::standard shader', () => {
83
+ it('shadow-caster pass uses the canonical depth-only shader', () => {
84
84
  const mat = Materials.standard({ baseColor: [0.5, 0.5, 0.5, 1] });
85
85
  const shadowPass = mat.passes?.find((p) => p.name === ('shadow-caster' as PassKind));
86
- expect(shadowPass?.program.module).toBe('forgeax_material::standard');
86
+ expect(shadowPass?.program.module).toBe('forgeax::default-shadow-caster');
87
87
  });
88
88
 
89
89
  it('castShadow=false suppresses the shadow-caster pass', () => {
@@ -53,6 +53,18 @@ function specOf(
53
53
  describe('cacheKeyOf passKind dimension', () => {
54
54
  const SHADER_ID = 'forgeax::default-unlit';
55
55
 
56
+ it('separates authored entry selections within one published module', () => {
57
+ const base = specOf('custom::two-entries', false);
58
+ const first = {
59
+ ...base,
60
+ shader: { ...base.shader, vertexEntry: 'vs_main', fragmentEntry: 'fs_main' },
61
+ };
62
+ const second = { ...base, shader: { ...first.shader, fragmentEntry: 'fs_readability' } };
63
+ const shadow = { ...base, shader: { ...first.shader, vertexEntry: 'vs_shadow' } };
64
+ expect(new Set([first, second, shadow].map(cacheKeyOf)).size).toBe(3);
65
+ expect(cacheKeyOf({ ...first })).toBe(cacheKeyOf(first));
66
+ });
67
+
56
68
  it('same shaderId + entries produce different keys for forward vs shadow-caster', () => {
57
69
  const forwardKey = cacheKeyOf(specOf(SHADER_ID, false, 'forward'));
58
70
  const shadowKey = cacheKeyOf(specOf(SHADER_ID, false, 'shadow-caster'));
@@ -490,6 +490,20 @@ describe('buildPipelineDescriptor (M2-T3-TEST)', () => {
490
490
  };
491
491
  }
492
492
 
493
+ it('uses the entry selection carried by the pipeline spec', () => {
494
+ const base = makeForwardSpec();
495
+ const spec: PipelineSpec = {
496
+ ...base,
497
+ shader: { ...base.shader, vertexEntry: 'vs_custom', fragmentEntry: 'fs_readability' },
498
+ };
499
+ const descriptor = buildPipelineDescriptor(spec, {
500
+ vertex: mockVertexModule,
501
+ fragment: mockFragmentModule,
502
+ });
503
+ expect(descriptor.vertex.entryPoint).toBe('vs_custom');
504
+ expect(descriptor.fragment?.entryPoint).toBe('fs_readability');
505
+ });
506
+
493
507
  function makeShadowCasterSpec(sampleCount: 1 | 4 = 1): PipelineSpec {
494
508
  return {
495
509
  shader: { id: 'forgeax::shadow-caster', passKind: 'shadow-caster', variantSet: undefined },
@@ -103,7 +103,6 @@ const SPRITE_FILE = fileURLToPath(
103
103
  describe('production-path shared instance bind-group resolver', () => {
104
104
  it('sprite identity and sprite-instance paths use the shared resolver', () => {
105
105
  const src = readFileSync(SPRITE_FILE, 'utf8');
106
- expect(src).toContain('const identityInstBg = resolveGeometryInstancesBindGroup(');
107
106
  expect(src).toContain('const spriteInstBg = resolveGeometryInstancesBindGroup(');
108
107
  expect(src).toContain('const spriteInstancesBg = resolveGeometryInstancesBindGroup(');
109
108
  });
@@ -185,6 +185,17 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
185
185
  const mesh = registerMesh(world);
186
186
  const materialGuid = '019ffa97-4000-7000-8000-000000000101';
187
187
  const cookedShaderId = 'sha256:rusted-iron-cooked-specialization';
188
+ const cookedShadowShaderId = 'sha256:rusted-iron-cooked-shadow-specialization';
189
+ const materialContext = {
190
+ backend: 'webgpu' as const,
191
+ capability: 'storage-buffer' as const,
192
+ pipeline: 'forward' as const,
193
+ geometry: 'mesh' as const,
194
+ pass: 'forward' as const,
195
+ profile: 'forgeax-material-wgsl-v1' as const,
196
+ toolchain: 'naga-oil' as const,
197
+ instrumentation: 'none' as const,
198
+ };
188
199
  const material = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
189
200
  kind: 'material',
190
201
  passes: [
@@ -223,6 +234,26 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
223
234
  module: 'forgeax_material::standard',
224
235
  moduleSlots: { surface: 'game_3d::rusted_iron_surface' },
225
236
  artifactHash: 'sha256:rusted-iron-artifact',
237
+ programs: [
238
+ {
239
+ context: materialContext,
240
+ specializationKey: cookedShaderId,
241
+ artifactHash: 'sha256:rusted-iron-forward-artifact',
242
+ },
243
+ ],
244
+ },
245
+ {
246
+ name: 'shadow-caster',
247
+ module: 'forgeax_material::standard',
248
+ moduleSlots: { surface: 'game_3d::rusted_iron_surface' },
249
+ artifactHash: 'sha256:rusted-iron-artifact',
250
+ programs: [
251
+ {
252
+ context: { ...materialContext, pass: 'shadow' as const },
253
+ specializationKey: cookedShadowShaderId,
254
+ artifactHash: 'sha256:rusted-iron-shadow-artifact',
255
+ },
256
+ ],
226
257
  },
227
258
  ],
228
259
  runtimeValues: {},
@@ -236,7 +267,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
236
267
  )
237
268
  .unwrap();
238
269
 
239
- const frame = extractFrame(world, prepareExtractContext(world, { assets }));
270
+ const frame = extractFrame(world, prepareExtractContext(world, { assets, materialContext }));
240
271
  expect(frame.renderables[0]?.material.materialShaderId).toBe(cookedShaderId);
241
272
  const materialDispatch = frame.dispatch.filter((entry) => entry.materialHandle === material);
242
273
  expect(materialDispatch).toEqual(
@@ -247,7 +278,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
247
278
  }),
248
279
  expect.objectContaining({
249
280
  tags: { LightMode: 'ShadowCaster' },
250
- materialShaderId: 'forgeax::default-shadow-caster',
281
+ materialShaderId: cookedShadowShaderId,
251
282
  }),
252
283
  ]),
253
284
  );
@@ -233,11 +233,14 @@ describe('Standard Surface runtime Browser WebGPU provenance', () => {
233
233
  const projection = assets.getMaterialProjectionForPayload(material);
234
234
  if (surfaceCase.id.startsWith('custom-')) {
235
235
  expect(projection?.specializationKey).toBeTruthy();
236
- expect(
237
- projection === undefined
238
- ? undefined
239
- : assets.getMaterialArtifact(projection.specializationKey),
240
- ).toBeDefined();
236
+ const programKeys =
237
+ projection?.passes.flatMap((pass) =>
238
+ pass.programs.map((program) => program.specializationKey),
239
+ ) ?? [];
240
+ expect(programKeys.length).toBeGreaterThan(0);
241
+ for (const specializationKey of new Set(programKeys)) {
242
+ expect(assets.getMaterialArtifact(specializationKey)).toBeDefined();
243
+ }
241
244
  }
242
245
  materials.push(material);
243
246
  publications.push({ surfaceCase, row, publication, material });
@@ -0,0 +1,249 @@
1
+ import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
2
+ import { World } from '@forgeax/engine-ecs';
3
+ import {
4
+ Camera,
5
+ DirectionalLight,
6
+ Materials,
7
+ MeshFilter,
8
+ MeshRenderer,
9
+ type Renderer,
10
+ } from '@forgeax/engine-render';
11
+ import { Transform } from '@forgeax/engine-scene';
12
+ import type { MaterialAsset } from '@forgeax/engine-types';
13
+ import { expect, it } from 'vitest';
14
+ import { page } from 'vitest/browser';
15
+ import { constructRuntimeRendererHost } from '../renderer-host';
16
+ import { drawPublished } from './draw-published';
17
+
18
+ async function readPresentedPixels(canvas: HTMLCanvasElement) {
19
+ const shot = await page.elementLocator(canvas).screenshot({ base64: true });
20
+ const base64 = typeof shot === 'string' ? shot : shot.base64;
21
+ const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
22
+ const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
23
+ const surface = new OffscreenCanvas(bitmap.width, bitmap.height);
24
+ const context = surface.getContext('2d');
25
+ if (context === null) throw new Error('2D readback is required for pixel assertions');
26
+ context.drawImage(bitmap, 0, 0);
27
+ bitmap.close();
28
+ return context.getImageData(0, 0, surface.width, surface.height);
29
+ }
30
+
31
+ it.each([
32
+ false,
33
+ true,
34
+ ])('renders the ai-weapon-spirit child Toon through real WebGPU (Engine shadow: %s)', async (engineShadow) => {
35
+ if (!navigator.gpu) throw new Error('WebGPU is required for the material regression');
36
+ const canvas = document.createElement('canvas');
37
+ canvas.width = 384;
38
+ canvas.height = 256;
39
+ document.body.append(canvas);
40
+ const originalRequestAdapter = navigator.gpu.requestAdapter.bind(navigator.gpu);
41
+ const validationErrors: string[] = [];
42
+ const pipelineEntries: string[] = [];
43
+ const pipelineLabels: string[] = [];
44
+ const shaderSources = new WeakMap<GPUShaderModule, string>();
45
+ const shadowPipelineSources: string[] = [];
46
+ const devices: GPUDevice[] = [];
47
+ navigator.gpu.requestAdapter = async (options) => {
48
+ const adapter = await originalRequestAdapter(options);
49
+ if (adapter === null) return adapter;
50
+ const requestDevice = adapter.requestDevice.bind(adapter);
51
+ adapter.requestDevice = async (descriptor) => {
52
+ const device = await requestDevice(descriptor);
53
+ devices.push(device);
54
+ device.addEventListener('uncapturederror', (event) =>
55
+ validationErrors.push(event.error.message),
56
+ );
57
+ const createShaderModule = device.createShaderModule.bind(device);
58
+ device.createShaderModule = (descriptor) => {
59
+ const module = createShaderModule(descriptor);
60
+ shaderSources.set(module, descriptor.code);
61
+ return module;
62
+ };
63
+ const observeShadow = (descriptor: GPURenderPipelineDescriptor) => {
64
+ if (
65
+ descriptor.depthStencil?.format === 'depth32float' &&
66
+ Array.from(descriptor.fragment?.targets ?? []).length === 0
67
+ ) {
68
+ const source = shaderSources.get(descriptor.vertex.module);
69
+ if (source !== undefined) shadowPipelineSources.push(source);
70
+ }
71
+ };
72
+ const createPipeline = device.createRenderPipeline.bind(device);
73
+ device.createRenderPipeline = (descriptor) => {
74
+ observeShadow(descriptor);
75
+ pipelineLabels.push(descriptor.label ?? '<unlabelled>');
76
+ pipelineEntries.push(descriptor.vertex.entryPoint ?? '<implicit>');
77
+ return createPipeline(descriptor);
78
+ };
79
+ const createPipelineAsync = device.createRenderPipelineAsync.bind(device);
80
+ device.createRenderPipelineAsync = async (descriptor) => {
81
+ observeShadow(descriptor);
82
+ pipelineLabels.push(descriptor.label ?? '<unlabelled>');
83
+ pipelineEntries.push(descriptor.vertex.entryPoint ?? '<implicit>');
84
+ return createPipelineAsync(descriptor);
85
+ };
86
+ return device;
87
+ };
88
+ return adapter;
89
+ };
90
+ let renderer: Renderer | undefined;
91
+ let unsubscribe: (() => void) | undefined;
92
+ try {
93
+ const host = await constructRuntimeRendererHost(
94
+ canvas,
95
+ {},
96
+ { shaderManifestUrl: '/shaders/manifest.json' },
97
+ );
98
+ if (!host.ok) throw host.error;
99
+ renderer = host.value.renderer;
100
+ const errors: string[] = [];
101
+ unsubscribe = renderer.subscribe((event) => {
102
+ if (event.kind === 'error') errors.push(event.error.message);
103
+ });
104
+ const assets = host.value.assets;
105
+ assets.configurePackIndex(
106
+ engineShadow
107
+ ? '/__weapon-spirit-engine-shadow/pack-index.json'
108
+ : '/__weapon-spirit/pack-index.json',
109
+ );
110
+ const child = await assets.loadByGuid<MaterialAsset>(
111
+ assets.parseGuid('4846fa5b-8f80-57c0-9cdc-e345102fdb6c'),
112
+ );
113
+ if (!child.ok) throw child.error;
114
+ const readiness = assets.getMaterialReadiness('4846fa5b-8f80-57c0-9cdc-e345102fdb6b');
115
+ expect(
116
+ readiness,
117
+ `root material must publish one complete Ready generation: ${JSON.stringify(readiness)}`,
118
+ ).toMatchObject({ status: 'Ready' });
119
+ const projection = assets.getMaterialProjectionForPayload(child.value);
120
+ const shadowProgram = projection?.passes.find((pass) => pass.name === 'ShadowCaster')
121
+ ?.programs[0];
122
+ if (shadowProgram === undefined) throw new Error('published shadow program missing');
123
+ const shadowArtifact = assets.getMaterialArtifact(shadowProgram.specializationKey);
124
+ if (shadowArtifact === undefined) throw new Error('installed shadow artifact missing');
125
+ const expectedShadowSource = new TextDecoder().decode(shadowArtifact.bytes);
126
+ expect(projection?.specializationKey).toBeTruthy();
127
+ expect(child.value.values).toMatchObject({
128
+ emissionStrength: 0,
129
+ pigmentStrength: 0,
130
+ surfaceMetallic: 0,
131
+ sideShade: 0.84,
132
+ });
133
+ const world = new World();
134
+ const toon = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', child.value);
135
+ const standard = world.allocSharedRef<'MaterialAsset', MaterialAsset>(
136
+ 'MaterialAsset',
137
+ Materials.standard({ baseColor: [0.6, 0.6, 0.6, 1] }),
138
+ );
139
+ const transform = (
140
+ pos: readonly [number, number, number],
141
+ scale: readonly [number, number, number] = [1, 1, 1],
142
+ ) => ({ pos, scale, quat: [0, 0, 0, 1] as const });
143
+ world
144
+ .spawn(
145
+ { component: Transform, data: transform([0, 8, 18]) },
146
+ {
147
+ component: Camera,
148
+ data: {
149
+ fov: Math.PI / 4,
150
+ aspect: 1.5,
151
+ near: 0.1,
152
+ far: 100,
153
+ clearColor: [0.02, 0.02, 0.03, 1],
154
+ },
155
+ },
156
+ )
157
+ .unwrap();
158
+ const light = world
159
+ .spawn({
160
+ component: DirectionalLight,
161
+ data: {
162
+ direction: [0.25, -1, -0.45],
163
+ color: [1, 1, 1],
164
+ intensity: 1,
165
+ castShadow: true,
166
+ mapSize: 512,
167
+ shadowDistance: 50,
168
+ },
169
+ })
170
+ .unwrap();
171
+ world
172
+ .spawn(
173
+ { component: Transform, data: transform([0, 5, 5], [10, 0.1, 10]) },
174
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
175
+ { component: MeshRenderer, data: { materials: [standard] } },
176
+ )
177
+ .unwrap();
178
+ for (const [index, material] of [toon, standard, toon].entries()) {
179
+ world
180
+ .spawn(
181
+ { component: Transform, data: transform([(index - 1) * 2, 6, 5]) },
182
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
183
+ { component: MeshRenderer, data: { materials: [material] } },
184
+ )
185
+ .unwrap();
186
+ }
187
+ const drawFrames = async (count: number) => {
188
+ for (let frame = 0; frame < count; frame += 1) {
189
+ const receipt = drawPublished(host.value.renderer, world);
190
+ if (!receipt.ok) throw receipt.error;
191
+ const completed = await receipt.value.completed;
192
+ if (!completed.ok) throw completed.error;
193
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
194
+ }
195
+ await Promise.all(devices.map((device) => device.queue.onSubmittedWorkDone()));
196
+ };
197
+ await drawFrames(60);
198
+ const shadowed = await readPresentedPixels(canvas);
199
+ world.set(light, DirectionalLight, { castShadow: false }).unwrap();
200
+ expect(world.get(light, DirectionalLight).unwrap().castShadow).toBe(false);
201
+ await drawFrames(10);
202
+ expect(renderer.inspect().perFramePassNames).not.toContain('shadowCascade0');
203
+ const unshadowed = await readPresentedPixels(canvas);
204
+ expect([unshadowed.width, unshadowed.height]).toEqual([shadowed.width, shadowed.height]);
205
+ for (const [startX, endX] of [
206
+ [0.29, 0.45],
207
+ [0.55, 0.71],
208
+ ] as const) {
209
+ let toonPixels = 0;
210
+ let shadowPixels = 0;
211
+ for (let y = 0; y < shadowed.height; y += 1) {
212
+ for (let x = Math.floor(shadowed.width * startX); x < shadowed.width * endX; x += 1) {
213
+ const offset = (y * shadowed.width + x) * 4;
214
+ const red = shadowed.data[offset] ?? 0;
215
+ const green = shadowed.data[offset + 1] ?? 0;
216
+ const blue = shadowed.data[offset + 2] ?? 0;
217
+ if (green > red + 10 && blue > red + 5) toonPixels += 1;
218
+ // Fixed camera receiver regions under the two Toon cubes exclude
219
+ // the Standard cube's shadow, so its success cannot mask either caster.
220
+ if (
221
+ y > shadowed.height * 0.75 &&
222
+ y < shadowed.height * 0.85 &&
223
+ (unshadowed.data[offset] ?? 0) > red + 20
224
+ )
225
+ shadowPixels += 1;
226
+ }
227
+ }
228
+ expect(toonPixels, `visible Toon pixels in region ${startX}`).toBeGreaterThan(30);
229
+ expect(shadowPixels, `shadow contribution in region ${startX}`).toBeGreaterThan(10);
230
+ }
231
+ await Promise.all(devices.map((device) => device.queue.onSubmittedWorkDone()));
232
+ expect(validationErrors, 'raw WebGPU validation messages').toEqual([]);
233
+ expect(errors, 'renderer errors').toEqual([]);
234
+ expect(
235
+ pipelineEntries,
236
+ `the authored shadow vertex entry must reach a real PSO: ${JSON.stringify(pipelineLabels)}`,
237
+ ).toContain(engineShadow ? 'vs_main' : 'vs_shadow');
238
+ expect(
239
+ shadowPipelineSources,
240
+ 'the published shadow program must reach a depth-only PSO',
241
+ ).toContain(expectedShadowSource);
242
+ } finally {
243
+ navigator.gpu.requestAdapter = originalRequestAdapter;
244
+ unsubscribe?.();
245
+ renderer?.dispose();
246
+ for (const device of devices) device.destroy();
247
+ canvas.remove();
248
+ }
249
+ }, 60_000);