@forgeax/engine-render 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.
Files changed (86) hide show
  1. package/README.md +18 -0
  2. package/dist/assembly/factory.d.ts +1 -1
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/assembly/material/assembly.d.ts +4 -17
  5. package/dist/assembly/material/assembly.d.ts.map +1 -1
  6. package/dist/assembly/material/standard-layer-projection.d.ts.map +1 -1
  7. package/dist/assembly/material/surface-projection.d.ts.map +1 -1
  8. package/dist/assembly/webgpu-renderer.d.ts +0 -1
  9. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  10. package/dist/authoring.mjs +1 -1
  11. package/dist/{chunk-JSEDTZYS.mjs → chunk-GF523LHF.mjs} +6 -6
  12. package/dist/chunk-GF523LHF.mjs.map +1 -0
  13. package/dist/{chunk-TCLDX7SZ.mjs → chunk-TMIASV2N.mjs} +403 -457
  14. package/dist/chunk-TMIASV2N.mjs.map +1 -0
  15. package/dist/{chunk-N3RT2EUR.mjs → chunk-YUQXSEHG.mjs} +87 -53
  16. package/dist/chunk-YUQXSEHG.mjs.map +1 -0
  17. package/dist/construct-renderer.mjs +153 -13
  18. package/dist/construct-renderer.mjs.map +1 -1
  19. package/dist/extract/contracts.d.ts +6 -6
  20. package/dist/extract/contracts.d.ts.map +1 -1
  21. package/dist/index.mjs +1 -1
  22. package/dist/internal.mjs +1 -1
  23. package/dist/pipeline-spec-types.d.ts +2 -0
  24. package/dist/pipeline-spec-types.d.ts.map +1 -1
  25. package/dist/pipeline-spec.d.ts.map +1 -1
  26. package/dist/record/frame.d.ts.map +1 -1
  27. package/dist/record/main-pass-geometry.d.ts +2 -2
  28. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  29. package/dist/record/main-pass-material.d.ts +20 -2
  30. package/dist/record/main-pass-material.d.ts.map +1 -1
  31. package/dist/record/main-pass-sprite-draws.d.ts +3 -3
  32. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  33. package/dist/record/main-pass.d.ts.map +1 -1
  34. package/dist/record/material-uniforms.d.ts +4 -0
  35. package/dist/record/material-uniforms.d.ts.map +1 -0
  36. package/dist/record/render-context.d.ts +2 -2
  37. package/dist/record/render-context.d.ts.map +1 -1
  38. package/dist/record/shadow-pass.d.ts +2 -3
  39. package/dist/record/shadow-pass.d.ts.map +1 -1
  40. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  41. package/dist/render-system-extract-tail.d.ts +2 -0
  42. package/dist/render-system-extract-tail.d.ts.map +1 -1
  43. package/dist/render-system-extract.d.ts +21 -10
  44. package/dist/render-system-extract.d.ts.map +1 -1
  45. package/dist/render-system.d.ts.map +1 -1
  46. package/dist/temporal/index.mjs +2 -2
  47. package/package.json +20 -20
  48. package/src/__tests__/fixtures/ai-weapon-spirit/README.md +20 -0
  49. package/src/__tests__/fixtures/ai-weapon-spirit/low-poly-toon.wgsl +178 -0
  50. package/src/__tests__/fixtures/ai-weapon-spirit/material.json +97 -0
  51. package/src/__tests__/fixtures/ai-weapon-spirit/painterly-surface.wgsl +55 -0
  52. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +3 -3
  53. package/src/__tests__/gpu-driven-view.browser.test.ts +11 -3
  54. package/src/__tests__/material-contract-inventory.unit.test.ts +43 -0
  55. package/src/__tests__/{material-static-dynamic-route.unit.test.ts → material-layer-projection.unit.test.ts} +30 -47
  56. package/src/__tests__/material-program-dispatch.unit.test.ts +48 -0
  57. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +2 -2
  58. package/src/__tests__/skinned-shadow-caster.test.ts +14 -14
  59. package/src/__tests__/surface-variant-provenance.integration.test.ts +1 -1
  60. package/src/assembly/factory.ts +0 -3
  61. package/src/assembly/material/assembly.ts +8 -40
  62. package/src/assembly/material/standard-layer-projection.ts +3 -1
  63. package/src/assembly/material/surface-projection.ts +2 -1
  64. package/src/assembly/webgpu-renderer.ts +17 -9
  65. package/src/extract/contracts.ts +6 -6
  66. package/src/materials.ts +3 -3
  67. package/src/pipeline-spec-types.ts +2 -0
  68. package/src/pipeline-spec.ts +7 -3
  69. package/src/record/frame.ts +7 -1
  70. package/src/record/main-pass-geometry.ts +340 -279
  71. package/src/record/main-pass-material.ts +87 -2
  72. package/src/record/main-pass-sprite-draws.ts +65 -168
  73. package/src/record/main-pass.ts +24 -246
  74. package/src/record/material-uniforms.ts +186 -0
  75. package/src/record/render-context.ts +4 -0
  76. package/src/record/shadow-pass.ts +86 -28
  77. package/src/recovery/render-system-candidate.ts +5 -1
  78. package/src/render-system-extract-tail.ts +51 -11
  79. package/src/render-system-extract.ts +73 -63
  80. package/src/render-system.ts +19 -0
  81. package/dist/assembly/material/pipeline-projection.d.ts +0 -30
  82. package/dist/assembly/material/pipeline-projection.d.ts.map +0 -1
  83. package/dist/chunk-JSEDTZYS.mjs.map +0 -1
  84. package/dist/chunk-N3RT2EUR.mjs.map +0 -1
  85. package/dist/chunk-TCLDX7SZ.mjs.map +0 -1
  86. package/src/assembly/material/pipeline-projection.ts +0 -63
@@ -0,0 +1,55 @@
1
+ #define_import_path ai_weapon_spirit::painterly_surface
2
+
3
+ fn pigmentHash(point : vec2<f32>) -> f32 {
4
+ var p = fract(vec3<f32>(point.x, point.y, point.x) * .1031);
5
+ p += vec3<f32>(dot(p, p.yzx + vec3<f32>(33.33)));
6
+ return fract((p.x + p.y) * p.z);
7
+ }
8
+
9
+ fn pigmentNoise(point : vec2<f32>) -> f32 {
10
+ let cell = floor(point);
11
+ let f = fract(point);
12
+ let u = f * f * (vec2<f32>(3.0) - 2.0 * f);
13
+ return mix(mix(pigmentHash(cell), pigmentHash(cell + vec2<f32>(1.0, 0.0)), u.x),
14
+ mix(pigmentHash(cell + vec2<f32>(0.0, 1.0)), pigmentHash(cell + vec2<f32>(1.0, 1.0)), u.x), u.y);
15
+ }
16
+
17
+ fn pigmentPlane(position : vec3<f32>, normal : vec3<f32>) -> vec2<f32> {
18
+ let axis = abs(normal);
19
+ if (axis.y >= max(axis.x, axis.z)) { return position.xz; }
20
+ if (axis.x > axis.z) { return position.zy; }
21
+ return position.xy;
22
+ }
23
+
24
+ // Soft pigment pools, with no repeated stroke or bristle shapes. Rotate and
25
+ // gently warp the field so its broad transitions do not reveal the noise grid.
26
+ // Units are metres: moving actors use local coordinates, fixed terrain world coordinates.
27
+ fn applyPigment(base : vec3<f32>, position : vec3<f32>, normal : vec3<f32>, scale : f32, strength : f32) -> vec3<f32> {
28
+ let plane = pigmentPlane(position, normal) * scale;
29
+ let point = vec2<f32>(plane.x * .8 + plane.y * .6, plane.y * .8 - plane.x * .6);
30
+ let drift = vec2<f32>(
31
+ pigmentNoise(point * .11 + vec2<f32>(5.2, 11.7)),
32
+ pigmentNoise(point * .11 + vec2<f32>(19.3, -4.6)),
33
+ ) - vec2<f32>(.5);
34
+ let field = point * .18 + drift * .65;
35
+ let wash = pigmentNoise(field + vec2<f32>(3.2, 8.1)) - .5;
36
+ let bloom = pigmentNoise(field * .47 + vec2<f32>(-7.4, 2.9)) - .5;
37
+ let pigment = (wash * .72 + bloom * .28) * strength;
38
+ // Pigment changes local reflectance; it never emits light or changes normals.
39
+ return max(vec3<f32>(0.0), base * (1.0 + pigment) + vec3<f32>(.08, .02, -.055) * pigment);
40
+ }
41
+
42
+ fn paintedSpecular(base : vec3<f32>, normal : vec3<f32>, light : vec3<f32>, camera : vec3<f32>,
43
+ roughness : f32, metallic : f32, strength : f32, visibility : f32) -> vec3<f32> {
44
+ let halfVector = normalize(light + camera);
45
+ let smoothness = 1.0 - clamp(roughness, .04, 1.0);
46
+ let lobe = pow(max(dot(normal, halfVector), 0.0), mix(12.0, 112.0, smoothness));
47
+ let reflection = strength * smoothness * smoothness * lobe
48
+ * max(dot(normal, light), 0.0) * visibility;
49
+ return mix(vec3<f32>(.88, .86, .81), base, metallic) * reflection;
50
+ }
51
+
52
+ fn paintedRim(normal : vec3<f32>, camera : vec3<f32>, roughness : f32, strength : f32, visibility : f32) -> f32 {
53
+ let fresnel = pow(1.0 - clamp(dot(normal, camera), 0.0, 1.0), 4.0);
54
+ return fresnel * strength * mix(1.0, .18, roughness) * mix(.35, 1.0, visibility);
55
+ }
@@ -61,7 +61,9 @@ export interface GpuDrivenViewLifecycleEvidence {
61
61
  readonly candidateCapacity: number;
62
62
  }
63
63
 
64
- export async function runGpuDrivenViewLifecycleEvidence(): Promise<GpuDrivenViewLifecycleEvidence> {
64
+ export async function runGpuDrivenViewLifecycleEvidence(
65
+ frames = 300,
66
+ ): Promise<GpuDrivenViewLifecycleEvidence> {
65
67
  const adapter = (await rhi.requestAdapter()).unwrap();
66
68
  const device = (await adapter.requestDevice()).unwrap();
67
69
  const rawDevice = _internal_getRawDevice(device);
@@ -85,8 +87,6 @@ export async function runGpuDrivenViewLifecycleEvidence(): Promise<GpuDrivenView
85
87
  const retirements: Array<Promise<unknown>> = [];
86
88
  let activeCount = 0;
87
89
  let previous: CompiledRenderGraph<{ readonly encoder: RhiCommandEncoder }> | undefined;
88
- const frames = 300;
89
-
90
90
  for (let frame = 0; frame < frames; frame += 1) {
91
91
  const targetCount = frame < 4 ? 2 ** frame : frame % 2 === 0 ? 1 : 8;
92
92
  const operations: Array<
@@ -5,6 +5,14 @@ import {
5
5
  } from './gpu-driven-view-gpu-evidence';
6
6
 
7
7
  const browserReady = typeof navigator !== 'undefined' && navigator.gpu !== undefined;
8
+ const lifecycleFrames =
9
+ (
10
+ import.meta as ImportMeta & {
11
+ readonly env?: { readonly FORGEAX_BROWSER_CI_LIGHTWEIGHT?: string };
12
+ }
13
+ ).env?.FORGEAX_BROWSER_CI_LIGHTWEIGHT === '1'
14
+ ? 60
15
+ : 300;
8
16
 
9
17
  describe.skipIf(!browserReady)('GPU-driven View Browser WebGPU', () => {
10
18
  it('produces compact visible IDs and portable indirect args on the real browser device', async () => {
@@ -20,9 +28,9 @@ describe.skipIf(!browserReady)('GPU-driven View Browser WebGPU', () => {
20
28
  });
21
29
  });
22
30
 
23
- it('survives 300 frames of spawn/despawn capacity crossings without stale buffers', async () => {
24
- await expect(runGpuDrivenViewLifecycleEvidence()).resolves.toEqual({
25
- frames: 300,
31
+ it(`survives ${lifecycleFrames} frames of spawn/despawn capacity crossings without stale buffers`, async () => {
32
+ await expect(runGpuDrivenViewLifecycleEvidence(lifecycleFrames)).resolves.toEqual({
33
+ frames: lifecycleFrames,
26
34
  bufferRebuilds: 4,
27
35
  candidateCapacity: 8,
28
36
  });
@@ -2,10 +2,12 @@ import {
2
2
  createBuiltinMaterialAsset,
3
3
  DEFAULT_STANDARD_PBR_PARAM_SCHEMA,
4
4
  DEFAULT_UNLIT_PARAM_SCHEMA,
5
+ STANDARD_PIPELINE_PARAM_SCHEMA,
5
6
  } from '@forgeax/engine-shader';
6
7
  import { resolveMaterialAsset } from '@forgeax/engine-types';
7
8
  import { describe, expect, it } from 'vitest';
8
9
  import { Materials } from '../materials.js';
10
+ import { materialParametersToParamSchema } from '../render-system-extract.js';
9
11
 
10
12
  describe('vertex color does not change the material contract', () => {
11
13
  it('keeps colored and plain materials on the same schema and resource identity', () => {
@@ -62,6 +64,47 @@ describe('vertex color does not change the material contract', () => {
62
64
  expect(material.values).toHaveProperty('ior', 1.5);
63
65
  });
64
66
 
67
+ it('routes canonical Standard roots through the complete runtime UBO schema', () => {
68
+ const material = Materials.standard({ baseColor: [1, 1, 1, 1] });
69
+ const authoredSchema = materialParametersToParamSchema(material.parameters ?? []);
70
+ const canonicalSchema = materialParametersToParamSchema(
71
+ material.parameters ?? [],
72
+ 'forgeax::default-standard-pbr',
73
+ );
74
+
75
+ expect(authoredSchema.map((entry) => entry.name)).toEqual(
76
+ material.parameters?.map((parameter) => parameter.name),
77
+ );
78
+ expect(canonicalSchema).toBe(STANDARD_PIPELINE_PARAM_SCHEMA);
79
+ expect(authoredSchema).not.toBe(STANDARD_PIPELINE_PARAM_SCHEMA);
80
+ });
81
+
82
+ it('publishes the complete Standard contract when authoring a custom Surface', () => {
83
+ const custom = Materials.standard({
84
+ surfaceModule: 'project::paint',
85
+ parameters: [{ name: 'paintStrength', type: 'f32', default: 0.75 }],
86
+ values: { paintStrength: 0.5 },
87
+ });
88
+ const names = custom.parameters?.map((parameter) => parameter.name) ?? [];
89
+ expect(names).toEqual(
90
+ expect.arrayContaining([
91
+ 'baseColor',
92
+ 'metallic',
93
+ 'roughness',
94
+ 'baseColorTexture',
95
+ 'paintStrength',
96
+ ]),
97
+ );
98
+ expect(new Set(names).size).toBe(names.length);
99
+ expect(custom.parameters?.find((parameter) => parameter.name === 'paintStrength')).toEqual({
100
+ name: 'paintStrength',
101
+ type: 'f32',
102
+ default: 0.75,
103
+ });
104
+ expect(custom.values).toEqual({ paintStrength: 0.5 });
105
+ expect(names).not.toContain('clearcoat');
106
+ });
107
+
65
108
  it('routes active transmission to Forward and ShadowCaster only', () => {
66
109
  const material = Materials.standard({ baseColor: [1, 1, 1, 1], transmission: 0.5 });
67
110
  expect(material.passes?.map((pass) => pass.name)).toEqual(['forward', 'shadow-caster']);
@@ -1,14 +1,10 @@
1
1
  import type { CookedMaterialRecord } from '@forgeax/engine-pack';
2
2
  import { deriveStandardLayerPlan } from '@forgeax/engine-types';
3
- import { describe, expect, it, vi } from 'vitest';
3
+ import { describe, expect, it } from 'vitest';
4
4
  import { assembleMaterialProjection } from '../assembly/material/assembly.js';
5
- import {
6
- routeMaterialPipeline,
7
- updateMaterialRuntimeValues,
8
- } from '../assembly/material/pipeline-projection.js';
9
5
 
10
6
  const record: CookedMaterialRecord = {
11
- schemaVersion: 'material-cook/3',
7
+ schemaVersion: 'material-cook/4',
12
8
  guid: 'material-route',
13
9
  specializationKey: 'route-key',
14
10
  authored: {
@@ -24,14 +20,34 @@ const record: CookedMaterialRecord = {
24
20
  values: { baseColor: [1, 1, 1, 1], useNormalMap: false },
25
21
  },
26
22
  refs: { parent: [], textures: [], samplers: [], modules: ['project::standard'] },
27
- artifact: {
28
- mediaType: 'text/wgsl',
29
- path: 'material-route.wgsl',
30
- digest: 'sha256:route',
31
- bytes: new Uint8Array([4]),
32
- },
23
+ programs: [
24
+ {
25
+ specializationKey: 'route-program',
26
+ artifact: {
27
+ mediaType: 'text/wgsl',
28
+ path: 'material-route.wgsl',
29
+ digest: 'sha256:route',
30
+ bytes: new Uint8Array([4]),
31
+ },
32
+ selections: [
33
+ {
34
+ pass: 'forward',
35
+ context: {
36
+ backend: 'webgpu',
37
+ capability: 'storage-buffer',
38
+ pipeline: 'forward',
39
+ geometry: 'mesh',
40
+ pass: 'forward',
41
+ profile: 'forgeax-material-wgsl-v1',
42
+ toolchain: 'naga-oil',
43
+ instrumentation: 'none',
44
+ },
45
+ },
46
+ ],
47
+ },
48
+ ],
33
49
  receipt: {
34
- schemaVersion: 'material-cook/3',
50
+ schemaVersion: 'material-cook/4',
35
51
  sourceClosure: ['project::standard'],
36
52
  profile: 'forgeax-material-wgsl-v1',
37
53
  compilerVersion: 'compiler/1',
@@ -59,7 +75,7 @@ const record: CookedMaterialRecord = {
59
75
  },
60
76
  };
61
77
 
62
- describe('cooked material static and dynamic routing', () => {
78
+ describe('cooked material layer projection', () => {
63
79
  it('rejects a stale Standard layer plan before render projection', () => {
64
80
  const standardPass = { name: 'forward', program: { module: 'forgeax::default-standard-pbr' } };
65
81
  const standardResolved = { ...record.resolved, passes: [standardPass] };
@@ -87,37 +103,4 @@ describe('cooked material static and dynamic routing', () => {
87
103
  }),
88
104
  ).toThrow('Standard material layer-plan identity mismatch');
89
105
  });
90
-
91
- it('updates runtime values without requesting a compile or changing the artifact key', () => {
92
- const projection = assembleMaterialProjection(record);
93
- const updated = updateMaterialRuntimeValues(projection, {
94
- baseColor: [0.25, 0.5, 0.75, 1],
95
- });
96
-
97
- expect(updated.runtimeValues.baseColor).toEqual([0.25, 0.5, 0.75, 1]);
98
- expect(updated.specializationKey).toBe(projection.specializationKey);
99
- });
100
-
101
- it('selects a cooked static specialization and returns a structured miss', () => {
102
- const projection = assembleMaterialProjection(record);
103
- const lookup = vi.fn((key: string) =>
104
- key === 'route-key' ? { key, bytes: new Uint8Array([4]), digest: 'sha256:route' } : undefined,
105
- );
106
-
107
- const ready = routeMaterialPipeline({ projection, lookupArtifact: lookup });
108
- expect(ready).toMatchObject({ kind: 'ready', specializationKey: 'route-key' });
109
- expect(lookup).toHaveBeenCalledWith('route-key');
110
-
111
- const missing = routeMaterialPipeline({
112
- projection: { ...projection, specializationKey: 'uncooked-static-selection' },
113
- lookupArtifact: lookup,
114
- });
115
- expect(missing).toMatchObject({
116
- kind: 'error',
117
- error: {
118
- code: 'material-specialization-not-cooked',
119
- detail: { specializationKey: 'uncooked-static-selection' },
120
- },
121
- });
122
- });
123
106
  });
@@ -0,0 +1,48 @@
1
+ import type { EntityHandle } from '@forgeax/engine-ecs';
2
+ import type { MaterialPass } from '@forgeax/engine-types';
3
+ import { expect, it } from 'vitest';
4
+ import { appendMaterialDispatchEntries, type DispatchEntry } from '../render-system-extract.js';
5
+
6
+ it('dispatches each Pass with its published program, including an Engine-authored shadow', () => {
7
+ const passes: MaterialPass[] = [
8
+ {
9
+ name: 'Forward',
10
+ program: { module: 'game::toon', vertexEntry: 'vs_main', fragmentEntry: 'fs_toon' },
11
+ },
12
+ {
13
+ name: 'ShadowCaster',
14
+ program: { module: 'forgeax::default-shadow-caster', vertexEntry: 'vs_shadow' },
15
+ renderState: { tags: { LightMode: 'ShadowCaster' } },
16
+ },
17
+ ];
18
+ const dispatch: DispatchEntry[] = [];
19
+ appendMaterialDispatchEntries(dispatch, passes, 1 as EntityHandle, 7, 0, 0, { amount: 0 }, 0, {
20
+ Forward: 'cooked-toon',
21
+ ShadowCaster: 'cooked-shadow',
22
+ });
23
+ expect(
24
+ dispatch.map((entry) => [entry.materialShaderId, entry.vertexEntry, entry.fragmentEntry]),
25
+ ).toEqual([
26
+ ['cooked-toon', 'vs_main', 'fs_toon'],
27
+ ['cooked-shadow', 'vs_shadow', undefined],
28
+ ]);
29
+ expect(
30
+ dispatch.every((entry) => entry.materialHandle === 7 && entry.paramSnapshot?.amount === 0),
31
+ ).toBe(true);
32
+ });
33
+
34
+ it('refuses an incomplete published Pass map instead of using an authored shader', () => {
35
+ expect(() =>
36
+ appendMaterialDispatchEntries(
37
+ [],
38
+ [{ name: 'ShadowCaster', program: { module: 'forgeax::default-shadow-caster' } }],
39
+ 1 as EntityHandle,
40
+ 7,
41
+ 0,
42
+ 0,
43
+ {},
44
+ 0,
45
+ { Forward: 'cooked-toon' },
46
+ ),
47
+ ).toThrow('Missing published material program');
48
+ });
@@ -628,10 +628,10 @@ describe('material shader binding contract', () => {
628
628
  'utf8',
629
629
  );
630
630
  const projectionSource = readFileSync(
631
- resolve(import.meta.dirname, '../assembly/material/pipeline-projection.ts'),
631
+ resolve(import.meta.dirname, '../assembly/material/assembly.ts'),
632
632
  'utf8',
633
633
  );
634
- expect(projectionSource).toContain('routeMaterialPipeline');
634
+ expect(projectionSource).toContain('projectMaterialRecord');
635
635
  expect(recordSource).not.toMatch(/internals\.assets\.get<MaterialAsset>/);
636
636
  expect(recordSource).not.toMatch(/firstMaterial as \{/);
637
637
  expect(recordSource).not.toContain('baseColorHandle');
@@ -130,31 +130,31 @@ describe('skinned shadow caster', () => {
130
130
  expect(recordSource).toContain('binding: 12');
131
131
  });
132
132
 
133
- it('keeps the dispatch material handle, snapshot, and frame slot together', () => {
134
- const paramSnapshot = { alphaClipThreshold: 0.4 };
133
+ it('keeps each submesh material associated with its selected shadow program and entries', () => {
135
134
  const context = {
136
135
  dispatch: [
137
136
  {
138
137
  renderableIndex: 4,
139
138
  materialHandle: 42,
140
139
  materialShaderId: 'forgeax::default-standard-pbr',
141
- paramSnapshot,
140
+ tags: { LightMode: 'ShadowCaster' },
141
+ },
142
+ {
143
+ renderableIndex: 4,
144
+ materialHandle: 43,
145
+ materialShaderId: 'custom::cutout',
146
+ vertexEntry: 'vs_displaced',
147
+ fragmentEntry: 'fs_cutout',
142
148
  tags: { LightMode: 'ShadowCaster' },
143
149
  },
144
150
  ],
145
- validatedOrdered: [{ renderableIndex: 4 }],
146
- materialSlotIndices: [[3, 7]],
147
- materialSlots: Array.from({ length: 8 }, (_, slot) => ({
148
- materialHandle: slot === 7 ? 42 : 11,
149
- })),
150
151
  } as never;
151
-
152
152
  const bindings = shadowShaderMap(context);
153
- expect(bindings.get(4)?.get(42)).toMatchObject({
154
- materialHandle: 42,
155
- materialShaderId: SHADOW_CASTER_SHADER_ID,
156
- materialSlot: 7,
157
- paramSnapshot,
153
+ expect(bindings.get(4)?.get(42)?.materialShaderId).toBe(SHADOW_CASTER_SHADER_ID);
154
+ expect(bindings.get(4)?.get(43)).toMatchObject({
155
+ materialShaderId: 'custom::cutout',
156
+ vertexEntry: 'vs_displaced',
157
+ fragmentEntry: 'fs_cutout',
158
158
  });
159
159
  });
160
160
  });
@@ -23,7 +23,7 @@ describe('full-custom Surface provenance', () => {
23
23
  expect(passes.map((pass) => pass.name)).toEqual(['forward', 'shadow-caster']);
24
24
  expect(passes.map((pass) => pass.program.module)).toEqual([
25
25
  'forgeax_material::standard',
26
- 'forgeax_material::standard',
26
+ 'forgeax::default-shadow-caster',
27
27
  ]);
28
28
  for (const pass of passes) {
29
29
  expect(pass.program.moduleSlots?.surface).toBe(options.surfaceModule);
@@ -64,13 +64,11 @@ export {
64
64
  exposeRenderer,
65
65
  isSharedMaterialUserRegionCompatible,
66
66
  normalizeMaterialShaderVariantSet,
67
- projectMaterialPipeline,
68
67
  resolveMaterialShaderBackendArtifactKey,
69
68
  resolveMaterialShaderBindingContract,
70
69
  resolveMaterialShaderUvSetCount,
71
70
  resolveMaterialShaderVariantSet,
72
71
  resolveMaterialShaderVertexInputContract,
73
- routeMaterialPipeline,
74
72
  selectHdrpPbrPrewarmVariants,
75
73
  selectNoColorPbrVariant,
76
74
  selectPipelineLayoutForVariant,
@@ -78,7 +76,6 @@ export {
78
76
  selectSkinPrewarmVariants,
79
77
  selectStandardPbrTransmissionPrewarmVariants,
80
78
  shouldDeferMissingPreparedMaterialShader,
81
- updateMaterialRuntimeValues,
82
79
  } from './webgpu-renderer';
83
80
 
84
81
  const GPU_ENVIRONMENT_ERROR_CODES = new Set([
@@ -1,46 +1,19 @@
1
+ import {
2
+ type MaterialRenderProjection as AssetMaterialRenderProjection,
3
+ projectMaterialRecord,
4
+ } from '@forgeax/engine-assets-runtime';
1
5
  import {
2
6
  type CookedMaterialRecord,
3
7
  materialLayerPlanIdentity,
4
8
  } from '@forgeax/engine-pack/material-cook';
5
- import type { MaterialValue, StandardLayerPlan } from '@forgeax/engine-types';
9
+ import type { StandardLayerPlan } from '@forgeax/engine-types';
6
10
  import { projectStandardLayerPlan } from './standard-layer-projection.js';
7
11
 
8
- export interface MaterialRenderPassProjection {
9
- readonly name: string;
10
- readonly module: string;
11
- readonly vertexEntry?: string;
12
- readonly fragmentEntry?: string;
13
- readonly moduleSlots?: Readonly<Record<string, string>>;
14
- readonly renderState?: Readonly<Record<string, unknown>>;
15
- readonly artifactHash: string;
16
- }
17
-
18
- export interface MaterialRenderProjection {
19
- readonly materialGuid: string;
20
- readonly specializationKey: string;
21
- readonly artifactHash: string;
22
- readonly passes: readonly MaterialRenderPassProjection[];
23
- readonly runtimeValues: Readonly<Record<string, MaterialValue | null>>;
24
- readonly staticSelection: readonly string[];
12
+ export type { MaterialRenderPassProjection } from '@forgeax/engine-assets-runtime';
13
+ export interface MaterialRenderProjection extends AssetMaterialRenderProjection {
25
14
  readonly layerPlan: StandardLayerPlan;
26
15
  }
27
16
 
28
- function projectPass(
29
- record: CookedMaterialRecord,
30
- pass: CookedMaterialRecord['resolved']['passes'][number],
31
- ): MaterialRenderPassProjection {
32
- const program = pass.program;
33
- return {
34
- name: pass.name,
35
- module: program.module,
36
- ...(program.vertexEntry === undefined ? {} : { vertexEntry: program.vertexEntry }),
37
- ...(program.fragmentEntry === undefined ? {} : { fragmentEntry: program.fragmentEntry }),
38
- ...(program.moduleSlots === undefined ? {} : { moduleSlots: program.moduleSlots }),
39
- ...(pass.renderState === undefined ? {} : { renderState: pass.renderState }),
40
- artifactHash: record.artifact.digest,
41
- };
42
- }
43
-
44
17
  export function assembleMaterialProjection(record: CookedMaterialRecord): MaterialRenderProjection {
45
18
  const layerPlan = projectStandardLayerPlan(record.resolved.parameters, record.resolved.passes);
46
19
  const expectedLayerPlanIdentity = materialLayerPlanIdentity(record);
@@ -53,12 +26,7 @@ export function assembleMaterialProjection(record: CookedMaterialRecord): Materi
53
26
  );
54
27
  }
55
28
  return {
56
- materialGuid: record.guid,
57
- specializationKey: record.specializationKey ?? record.receipt.identity.programIdentity,
58
- artifactHash: record.artifact.digest,
59
- passes: record.resolved.passes.map((pass) => projectPass(record, pass)),
60
- runtimeValues: record.resolved.values,
61
- staticSelection: [],
29
+ ...projectMaterialRecord(record),
62
30
  layerPlan,
63
31
  };
64
32
  }
@@ -1,3 +1,4 @@
1
+ import { isStandardRootModule } from '@forgeax/engine-pack';
1
2
  import type { MaterialParameter, MaterialPass, StandardLayerPlan } from '@forgeax/engine-types';
2
3
  import { deriveStandardLayerPlan } from '@forgeax/engine-types';
3
4
 
@@ -12,5 +13,6 @@ export function projectStandardLayerPlan(
12
13
  parameters: readonly MaterialParameter[],
13
14
  passes: readonly MaterialPass[],
14
15
  ): StandardLayerPlan {
15
- return deriveStandardLayerPlan(parameters, passes);
16
+ const standard = passes.some((pass) => isStandardRootModule(pass.program.module));
17
+ return deriveStandardLayerPlan(standard ? parameters : [], standard ? passes : undefined);
16
18
  }
@@ -41,7 +41,8 @@ function pass(
41
41
  return {
42
42
  name,
43
43
  program: {
44
- module: STANDARD_MATERIAL_MODULE,
44
+ module:
45
+ name === 'shadow-caster' ? 'forgeax::default-shadow-caster' : STANDARD_MATERIAL_MODULE,
45
46
  fragmentEntry,
46
47
  moduleSlots: { surface: options.surfaceModule },
47
48
  },
@@ -229,11 +229,6 @@ import {
229
229
 
230
230
  export type { BundlerOptions } from './bundler-contract';
231
231
  export { assembleMaterialProjection } from './material/assembly';
232
- export {
233
- projectMaterialPipeline,
234
- routeMaterialPipeline,
235
- updateMaterialRuntimeValues,
236
- } from './material/pipeline-projection';
237
232
  export type {
238
233
  LayoutKind,
239
234
  MaterialShaderBindingContract,
@@ -2002,6 +1997,8 @@ async function makeWebGPURenderer(
2002
1997
  shaderModuleMode?: RenderFeatureShaderModuleMode,
2003
1998
  layoutKindOverride?: LayoutKind,
2004
1999
  additionalColorFormats?: readonly GPUTextureFormat[],
2000
+ vertexEntry?: string,
2001
+ authoredFragmentEntry?: string,
2005
2002
  ): RenderPipeline | null => {
2006
2003
  const group2Contract = resolvePipelineGroup2Contract(entry.source);
2007
2004
  const ctx = buildPipelineContext(
@@ -2024,10 +2021,11 @@ async function makeWebGPURenderer(
2024
2021
  if (currentState === null) return null;
2025
2022
  const ldrColorFormat = colorFormatOverride ?? currentState.colorAttachmentFormat;
2026
2023
  const fragmentEntry =
2027
- isHdr &&
2024
+ authoredFragmentEntry ??
2025
+ (isHdr &&
2028
2026
  (materialShaderId === 'forgeax::sprite' || materialShaderId === 'forgeax::sprite-lit')
2029
2027
  ? 'fs_main_hdr'
2030
- : undefined;
2028
+ : undefined);
2031
2029
  const built = buildPipelineForMaterialShader(
2032
2030
  cacheKey,
2033
2031
  // The catalog entry is projected into the material-shader entry shape.
@@ -2064,7 +2062,7 @@ async function makeWebGPURenderer(
2064
2062
  : {}),
2065
2063
  }
2066
2064
  : undefined,
2067
- undefined, // vertexEntry — default vs_main
2065
+ vertexEntry,
2068
2066
  fragmentEntry,
2069
2067
  undefined, // defines — none
2070
2068
  passKind,
@@ -2131,6 +2129,8 @@ async function makeWebGPURenderer(
2131
2129
  shaderModuleMode: RenderFeatureShaderModuleMode = 'validated',
2132
2130
  layoutKindOverride?: LayoutKind,
2133
2131
  additionalColorFormats?: readonly GPUTextureFormat[],
2132
+ vertexEntry?: string,
2133
+ fragmentEntry?: string,
2134
2134
  ): RenderPipeline | null => {
2135
2135
  // WebGL2 is a downlevel backend even when the record stage asks for the
2136
2136
  // native-capability variant string. Resolve the variant axes at this
@@ -2237,7 +2237,13 @@ async function makeWebGPURenderer(
2237
2237
  const colorFormat: GPUTextureFormat = isHdr ? 'rgba16float' : ldrColorFormat;
2238
2238
  const isDepthOnlyPass = passKindPolicyTable[passKind]?.shape === 'depth-only';
2239
2239
  const spec: PipelineSpec = {
2240
- shader: { id: pipelineShaderId, passKind, variantSet: effectiveVariantSet },
2240
+ shader: {
2241
+ id: pipelineShaderId,
2242
+ passKind,
2243
+ variantSet: effectiveVariantSet,
2244
+ ...(vertexEntry === undefined ? {} : { vertexEntry }),
2245
+ ...(fragmentEntry === undefined ? {} : { fragmentEntry }),
2246
+ },
2241
2247
  attachments: {
2242
2248
  colorFormats:
2243
2249
  additionalColorFormats === undefined
@@ -2476,6 +2482,8 @@ async function makeWebGPURenderer(
2476
2482
  shaderModuleMode,
2477
2483
  layoutKindOverride,
2478
2484
  additionalColorFormats,
2485
+ vertexEntry,
2486
+ fragmentEntry,
2479
2487
  );
2480
2488
  };
2481
2489
  const getMaterialShaderPipelineEntry = (
@@ -1,3 +1,4 @@
1
+ import type { MaterialCookProgramContext } from '@forgeax/engine-pack/material-cook';
1
2
  // Shared render-extract snapshot contracts.
2
3
  //
3
4
  // The extract orchestrator owns traversal and policy; this leaf owns the
@@ -658,6 +659,7 @@ export interface MaterialSnapshot {
658
659
  * discriminator (M4-T06).
659
660
  */
660
661
  readonly materialShaderId?: string | undefined;
662
+ readonly materialProgramKeys?: Readonly<Record<string, string>> | undefined;
661
663
  /**
662
664
  * Stable source handle for the material asset. Record uses this only for
663
665
  * same-frame assembly reuse; GPU bind-group validity still comes from the
@@ -763,12 +765,9 @@ export interface MaterialSnapshot {
763
765
  * from the legacy `shadingModel` discriminant (feat-20260625 M2 D-3,
764
766
  * finalised in w15; `shadingModel` field removed in tweak-20260701 M1).
765
767
  *
766
- * Extract derives this from the first pass's `renderState.blend !==
767
- * undefined` (post-feat-20260626-collapse: blend presence is the
768
- * single SSOT for "this material is transparent on the geometry
769
- * pipeline cache key"). Multi-pass materials whose mix of opaque +
770
- * transparent passes need finer routing should split into separate
771
- * MaterialAsset entries (the normal forgeax pattern).
768
+ * This describes the primary-pass snapshot for consumers that need a
769
+ * material summary. Actual draw routing and the LDR split read the selected
770
+ * DispatchEntry.renderState, so mixed opaque/transparent Passes remain valid.
772
771
  *
773
772
  * Type is `boolean | undefined` (derived): `undefined` means "no
774
773
  * passes / unknown"; consumers must read `=== true` / `!== true` to
@@ -961,6 +960,7 @@ export interface ExtractFramesOwner {
961
960
  }
962
961
 
963
962
  export interface PreparedExtractContext {
963
+ readonly materialContext?: MaterialCookProgramContext | undefined;
964
964
  readonly assets: AssetRegistry | null | undefined;
965
965
  readonly pipelineState: ExtractPipelineSurface | null | undefined;
966
966
  readonly materialSnapshotCache: MaterialSnapshotCache | undefined;
package/src/materials.ts CHANGED
@@ -8,7 +8,7 @@ import type {
8
8
  MaterialRenderState,
9
9
  MaterialValue,
10
10
  } from '@forgeax/engine-types';
11
- import { deriveStandardLayerPlan } from '@forgeax/engine-types';
11
+ import { deriveStandardLayerPlan, standardSurfaceParameters } from '@forgeax/engine-types';
12
12
  import {
13
13
  DEFAULT_STANDARD_SURFACE_MODULE,
14
14
  projectStandardSurfacePasses,
@@ -578,7 +578,7 @@ function standardDefault(opts: DefaultStandardOptions): MaterialAsset {
578
578
  if (opts.attenuationDistance !== undefined) values.attenuationDistance = opts.attenuationDistance;
579
579
  const forwardRenderState =
580
580
  transmission > 0 ? { ...(opts.renderState ?? {}), depthWriteEnabled: false } : opts.renderState;
581
- const parameters = standardParameters('linear', opts);
581
+ const parameters = standardSurfaceParameters(standardParameters('linear', opts));
582
582
  const declaredNames = new Set(parameters.map((parameter) => parameter.name));
583
583
  for (const name of Object.keys(values)) {
584
584
  if (!declaredNames.has(name)) delete values[name];
@@ -621,7 +621,7 @@ function standardCustom(opts: CustomStandardSurfaceOptions): MaterialAsset {
621
621
  ...(opts.queue === undefined ? {} : { queue: opts.queue }),
622
622
  layerPlan,
623
623
  }),
624
- parameters: opts.parameters,
624
+ parameters: standardSurfaceParameters(opts.parameters),
625
625
  values: opts.values,
626
626
  };
627
627
  }
@@ -10,6 +10,8 @@ export interface PipelineSpec {
10
10
  readonly id: string;
11
11
  readonly passKind: string;
12
12
  readonly variantSet: string | undefined;
13
+ readonly vertexEntry?: string;
14
+ readonly fragmentEntry?: string;
13
15
  };
14
16
  readonly attachments: {
15
17
  readonly colorFormats: readonly GPUTextureFormat[];