@forgeax/engine-shader 0.1.24 → 0.1.25

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.
@@ -17,6 +17,7 @@ export interface MaterialArtifactConflictError {
17
17
  readonly hint: string;
18
18
  readonly detail: {
19
19
  readonly key: string;
20
+ readonly dimension: 'bytes' | 'param-schema';
20
21
  readonly existingDigest?: string;
21
22
  readonly incomingDigest?: string;
22
23
  };
@@ -1 +1 @@
1
- {"version":3,"file":"artifact-registry.d.ts","sourceRoot":"","sources":["../../src/material/artifact-registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAE7D,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,IAAI,EAAE,4BAA4B,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;KAClC,CAAC;CACH;AAMD,qBAAa,wBAAwB;;IAGnC,QAAQ,CACN,QAAQ,EAAE,uBAAuB,GAChC,MAAM,CAAC,uBAAuB,EAAE,6BAA6B,CAAC;IAoBjE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS;IAIrD,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,0BAA0B,GAAG,SAAS;CAU7D"}
1
+ {"version":3,"file":"artifact-registry.d.ts","sourceRoot":"","sources":["../../src/material/artifact-registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAE7D,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,IAAI,EAAE,4BAA4B,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,SAAS,EAAE,OAAO,GAAG,cAAc,CAAC;QAC7C,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;KAClC,CAAC;CACH;AAUD,qBAAa,wBAAwB;;IAGnC,QAAQ,CACN,QAAQ,EAAE,uBAAuB,GAChC,MAAM,CAAC,uBAAuB,EAAE,6BAA6B,CAAC;IAkCjE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS;IAIrD,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,0BAA0B,GAAG,SAAS;CAU7D"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-shader",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,12 +22,12 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@forgeax/engine-rhi": "0.1.24",
26
- "@forgeax/engine-types": "0.1.24",
25
+ "@forgeax/engine-rhi": "0.1.25",
26
+ "@forgeax/engine-types": "0.1.25",
27
27
  "@webgpu/types": "^0.1.71"
28
28
  },
29
29
  "devDependencies": {
30
- "@forgeax/engine-vite-plugin-shader": "0.1.24"
30
+ "@forgeax/engine-vite-plugin-shader": "0.1.25"
31
31
  },
32
32
  "forgeax": {
33
33
  "metrics": {
@@ -49,7 +49,16 @@ describe('built-in texture coordinate records', () => {
49
49
  it('keeps custom coordinate metadata distinct from texture resources', () => {
50
50
  for (const file of shaderFiles) {
51
51
  const text = source(file);
52
- expect(text, `${file} must name coordinate metadata`).toMatch(/CoordinatesMetadata/);
52
+ if (file === 'default-standard-pbr-skin.wgsl') {
53
+ // Standard templates are parameter-agnostic inputs. The compiler
54
+ // lowers the generated coordinate metadata together with the selected
55
+ // Surface, so the skinned template only needs to expose that seam.
56
+ expect(text, `${file} must delegate material facts to Surface`).toContain(
57
+ 'evaluate_surface',
58
+ );
59
+ } else {
60
+ expect(text, `${file} must name coordinate metadata`).toMatch(/CoordinatesMetadata/);
61
+ }
53
62
  expect(text, `${file} must preserve texture resources`).toMatch(/texture_2d|texture_cube/);
54
63
  }
55
64
  });
@@ -39,7 +39,10 @@ beforeAll(async () => {
39
39
  const url = (await import(/* @vite-ignore */ urlId)) as NodeUrl;
40
40
  const here = url.fileURLToPath(import.meta.url);
41
41
  const srcDir = path.resolve(path.dirname(here), '..');
42
- pbrSource = fs.readFileSync(path.resolve(srcDir, 'default-standard-pbr.wgsl'), 'utf8');
42
+ pbrSource = [
43
+ fs.readFileSync(path.resolve(srcDir, 'default-standard-pbr.wgsl'), 'utf8'),
44
+ fs.readFileSync(path.resolve(srcDir, 'default_standard_surface.wgsl'), 'utf8'),
45
+ ].join('\n');
43
46
  });
44
47
 
45
48
  function stripComments(src: string): string {
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const standardPbrSource = readFileSync(
6
+ fileURLToPath(new URL('../default-standard-pbr.wgsl', import.meta.url)),
7
+ 'utf8',
8
+ );
9
+ const standardPbrSkinSource = readFileSync(
10
+ fileURLToPath(new URL('../default-standard-pbr-skin.wgsl', import.meta.url)),
11
+ 'utf8',
12
+ );
13
+
14
+ describe('standard PBR WebGL2 interface', () => {
15
+ it('packs the non-transmission position and view depth into one varying', () => {
16
+ for (const source of [standardPbrSource, standardPbrSkinSource]) {
17
+ expect(source).toContain('@location(7) positionOSAndViewZ : vec4<f32>');
18
+ expect(source).not.toContain('@location(4) positionOS : vec3<f32>');
19
+ expect(source).not.toContain('@location(15) positionOS : vec3<f32>');
20
+ expect(source).not.toContain('@location(4) @interpolate(flat) instanceIdx : u32');
21
+ }
22
+ });
23
+
24
+ it('retains the compact transmission basis locations for adapters that advertise it', () => {
25
+ for (const source of [standardPbrSource, standardPbrSkinSource]) {
26
+ expect(source).toContain('@location(4) @interpolate(flat) transmissionBasis0 : vec4<f32>');
27
+ expect(source).toContain('@location(13) @interpolate(flat) transmissionBasis1 : vec4<f32>');
28
+ expect(source).not.toContain(
29
+ '@location(15) @interpolate(flat) transmissionBasis1 : vec4<f32>',
30
+ );
31
+ }
32
+ });
33
+ });
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const source = readFileSync(
6
+ fileURLToPath(new URL('../material/standard-physical-layer.wgsl', import.meta.url)),
7
+ 'utf8',
8
+ );
9
+
10
+ describe('standard physical layer module', () => {
11
+ it('is an Engine-owned layer module after Surface evaluation', () => {
12
+ expect(source).toContain('standard_physical_layer');
13
+ expect(source).toContain('evaluate_standard_physical_layer');
14
+ expect(source).not.toContain('moduleSlots');
15
+ });
16
+ });
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ function source(name: string): string {
6
+ return readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
7
+ }
8
+
9
+ function entryBody(sourceText: string, entry: string): string {
10
+ const start = sourceText.indexOf(`fn ${entry}`);
11
+ const end = sourceText.indexOf('\n@fragment', start + 1);
12
+ return sourceText.slice(start, end < 0 ? sourceText.length : end);
13
+ }
14
+
15
+ describe('Standard Surface pass evaluation', () => {
16
+ it('evaluates the selected Surface once in every rigid and skinned material pass', () => {
17
+ for (const name of ['default-standard-pbr.wgsl', 'default-standard-pbr-skin.wgsl']) {
18
+ const shader = source(name);
19
+ // ShadowCaster is a shared `shadow_caster.wgsl` material entry, while
20
+ // each Standard material template owns the forward and deferred entries.
21
+ for (const entry of ['fs_main', 'fs_gbuffer']) {
22
+ const body = entryBody(shader, entry);
23
+ expect(body, `${name}:${entry} must exist`).toContain(`fn ${entry}`);
24
+ expect(body, `${name}:${entry}`).toMatch(
25
+ /(?:evaluateStandardSurface|evaluate_surface|forgeax_evaluate_surface_from_fragments)\s*\(/,
26
+ );
27
+ expect(body).toContain('alphaTestSurface(surface)');
28
+ if (entry === 'fs_main') expect(body).toContain('surface.opacity');
29
+ expect(body).toContain('surface.baseColor');
30
+ expect(body).toContain('surface.normalWS');
31
+ expect(body).toContain('surface.metallic');
32
+ expect(body).toContain('surface.roughness');
33
+ expect(body).toContain('surface.emissive');
34
+ expect(body).toContain('surface.occlusion');
35
+ }
36
+ }
37
+ });
38
+ });
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ function source(name: string): string {
6
+ return readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
7
+ }
8
+
9
+ describe('Standard Surface lighting provenance', () => {
10
+ const provenance = {
11
+ pass: 'forward',
12
+ module: 'game_3d::rusted_iron_surface',
13
+ closure: 'forgeax_material::surface_v1',
14
+ artifact: 'inline-wgsl:surface-standard-v1',
15
+ cook: 'createShaderModule',
16
+ generation: 1,
17
+ backend: 'webgpu',
18
+ lane: 'direct',
19
+ head: 'feature-head:m3-gpu-provenance',
20
+ } as const;
21
+
22
+ it('keeps one selected Surface closure across lighting pass adapters', () => {
23
+ const forward = source('default-standard-pbr.wgsl');
24
+ const skinned = source('default-standard-pbr-skin.wgsl');
25
+ const shadow = source('shadow_caster.wgsl');
26
+ for (const shader of [forward, skinned, shadow]) {
27
+ expect(shader).toContain('#import forgeax_material::surface_v1');
28
+ expect(shader).toContain('forgeax_material::slot::surface');
29
+ }
30
+ expect(forward).toContain('fn fs_main');
31
+ expect(skinned).toContain('fn fs_gbuffer');
32
+ expect(shadow).toContain('fn fs_shadow');
33
+ expect(provenance.closure).toBe('forgeax_material::surface_v1');
34
+ expect(provenance.generation).toBeGreaterThan(0);
35
+ expect(provenance.head).toMatch(/^feature-head:/);
36
+ });
37
+
38
+ it('keeps direct, clustered, shadow, and controlled feature owners observable', () => {
39
+ const pbr = source('default-standard-pbr.wgsl');
40
+ expect(pbr).toContain('evalDirectional');
41
+ expect(pbr).toContain('evalPoint');
42
+ expect(pbr).toContain('evalSpot');
43
+ expect(pbr).toContain('fs_gbuffer');
44
+ expect(pbr).toContain('sampleIbl');
45
+ expect(pbr).toContain('evaluateStandardSurface');
46
+ expect(pbr).toContain('alphaClipThreshold');
47
+ // Volume integration is a post-lighting producer. Standard must not
48
+ // reconstruct or apply a private analytic fog ray in any pass.
49
+ expect(pbr).not.toContain('apply_fog');
50
+ expect(pbr).not.toContain('applySceneFog');
51
+ expect(pbr).not.toMatch(/forwardFallback|deferredFallback/);
52
+ expect(provenance.pass).toBe('forward');
53
+ expect(provenance.module).toBe('game_3d::rusted_iron_surface');
54
+ expect(provenance.artifact).toContain('inline-wgsl:');
55
+ expect(provenance.cook).toBe('createShaderModule');
56
+ expect(provenance.backend).toBe('webgpu');
57
+ expect(provenance.lane).toBe('direct');
58
+ });
59
+ });
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ function source(name: string): string {
6
+ return readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
7
+ }
8
+
9
+ describe('Standard Surface pass templates', () => {
10
+ it('imports the same Surface ABI in rigid and skinned templates', () => {
11
+ for (const name of ['default-standard-pbr.wgsl', 'default-standard-pbr-skin.wgsl']) {
12
+ const shader = source(name);
13
+ expect(shader).toContain('#pragma material_slot surface');
14
+ expect(shader).toContain('#import forgeax_material::surface_v1');
15
+ expect(shader).toContain('evaluate_surface');
16
+ expect(shader).toContain('fs_gbuffer');
17
+ }
18
+ });
19
+
20
+ it('keeps alpha clip in the shared shadow template without vertex mutation', () => {
21
+ const shader = source('shadow_caster.wgsl');
22
+ expect(shader).toContain('#pragma material_slot surface');
23
+ expect(shader).toContain('#import forgeax_material::surface_v1');
24
+ expect(shader).toContain('alphaClipThreshold');
25
+ expect(shader).toContain('fn fs_shadow');
26
+ expect(shader).not.toMatch(/\bin\.position\s*=|\bin\.positionWS\s*=/);
27
+ });
28
+ });
@@ -961,6 +961,32 @@ fn fs_main(in : VsOut, @builtin(front_facing) frontFacing : bool) -> @location(0
961
961
  return vec4<f32>(color, alpha);
962
962
  }
963
963
 
964
+ // Keep the skinned Standard shader on the same multi-entry pass contract as
965
+ // the rigid Standard shader. Deferred draws select this entry point from the
966
+ // existing `forgeax::pbr-skin` artifact; lighting is performed by the deferred
967
+ // pass after these base surface facts are written to the shared G-buffer.
968
+ struct GBufferOutput {
969
+ @location(0) normal_roughness : vec4<f32>,
970
+ @location(1) albedo_metallic : vec4<f32>,
971
+ @location(2) emissive_ao : vec4<f32>,
972
+ };
973
+
974
+ @fragment
975
+ fn fs_gbuffer(in : VsOut, @builtin(front_facing) frontFacing : bool) -> GBufferOutput {
976
+ let surface = evaluateStandardSurface(in, frontFacing);
977
+ alphaTestSurface(surface);
978
+ let albedo = surface.baseColor;
979
+ let metallic = clamp(surface.metallic, 0.0, 1.0);
980
+ let roughness = clamp(surface.roughness, 0.04, 1.0);
981
+ let n = normalize(surface.normalWS);
982
+
983
+ var out : GBufferOutput;
984
+ out.normal_roughness = vec4<f32>(n * 0.5 + 0.5, roughness);
985
+ out.albedo_metallic = vec4<f32>(albedo, metallic);
986
+ out.emissive_ao = vec4<f32>(surface.emissive, surface.occlusion);
987
+ return out;
988
+ }
989
+
964
990
  struct TemporalVsOut {
965
991
  @builtin(position) clip : vec4<f32>,
966
992
  @location(0) uv : vec2<f32>,
package/src/index.ts CHANGED
@@ -107,6 +107,14 @@ export const BUILTIN_MATERIAL_MODULES = {
107
107
  sprite: 'forgeax_material::sprite',
108
108
  } as const;
109
109
 
110
+ /**
111
+ * The engine-owned Standard Surface selected by a built-in Standard material.
112
+ * Keep this explicit in the authored pass so the build-time cooker sees the
113
+ * same slot contract as Materials.standard().
114
+ */
115
+ export const DEFAULT_STANDARD_SURFACE_MODULE =
116
+ 'forgeax_material::default_standard_surface' as const;
117
+
110
118
  /** Stable build-time module id for the temporal-v1 accessor owner. */
111
119
  export const SCENE_DATA_TEMPORAL_V1_SHADER_MODULE = 'forgeax_scene_temporal' as const;
112
120
 
@@ -114,7 +122,9 @@ export const SCENE_DATA_TEMPORAL_V1_SHADER_MODULE = 'forgeax_scene_temporal' as
114
122
  * Material modules whose shader source and parameter contract are owned by
115
123
  * the Engine. They live in the runtime ShaderRegistry, so a Pack containing
116
124
  * one of these materials carries authored values only and does not require a
117
- * project material-cook artifact.
125
+ * project material-cook artifact. A Standard pass with a project Surface slot
126
+ * is deliberately excluded: its root composition is project-owned and must
127
+ * arrive with a cooked material publication.
118
128
  */
119
129
  export const ENGINE_MATERIAL_MODULES = [
120
130
  'forgeax::default-standard-pbr',
@@ -143,7 +153,15 @@ export function isEngineMaterialModule(module: string): boolean {
143
153
 
144
154
  export function isEngineMaterial(material: Pick<MaterialAsset, 'passes'>): boolean {
145
155
  const passes = material.passes ?? [];
146
- return passes.length > 0 && passes.every((pass) => isEngineMaterialModule(pass.program.module));
156
+ return (
157
+ passes.length > 0 &&
158
+ passes.every(
159
+ (pass) =>
160
+ isEngineMaterialModule(pass.program.module) &&
161
+ (pass.program.moduleSlots?.surface === undefined ||
162
+ pass.program.moduleSlots.surface === DEFAULT_STANDARD_SURFACE_MODULE),
163
+ )
164
+ );
147
165
  }
148
166
 
149
167
  export type BuiltinMaterialKind = keyof typeof BUILTIN_MATERIAL_MODULES;
@@ -175,7 +193,17 @@ const BUILTIN_VALUES: Readonly<
175
193
  export function createBuiltinMaterialAsset(kind: BuiltinMaterialKind): MaterialAsset {
176
194
  return {
177
195
  kind: 'material',
178
- passes: [{ name: 'forward', program: { module: BUILTIN_MATERIAL_MODULES[kind] } }],
196
+ passes: [
197
+ {
198
+ name: 'forward',
199
+ program: {
200
+ module: BUILTIN_MATERIAL_MODULES[kind],
201
+ ...(kind === 'standard'
202
+ ? { moduleSlots: { surface: DEFAULT_STANDARD_SURFACE_MODULE } }
203
+ : {}),
204
+ },
205
+ },
206
+ ],
179
207
  parameters: BUILTIN_PARAMETERS[kind],
180
208
  values: BUILTIN_VALUES[kind],
181
209
  };
@@ -20,6 +20,7 @@ export interface MaterialArtifactConflictError {
20
20
  readonly hint: string;
21
21
  readonly detail: {
22
22
  readonly key: string;
23
+ readonly dimension: 'bytes' | 'param-schema';
23
24
  readonly existingDigest?: string;
24
25
  readonly incomingDigest?: string;
25
26
  };
@@ -29,6 +30,10 @@ function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
29
30
  return left.length === right.length && left.every((value, index) => value === right[index]);
30
31
  }
31
32
 
33
+ function equalParamSchema(left: MaterialRuntimeArtifact, right: MaterialRuntimeArtifact): boolean {
34
+ return JSON.stringify(left.metadata?.paramSchema) === JSON.stringify(right.metadata?.paramSchema);
35
+ }
36
+
32
37
  export class MaterialArtifactRegistry {
33
38
  readonly #artifacts = new Map<string, MaterialRuntimeArtifact>();
34
39
 
@@ -37,13 +42,27 @@ export class MaterialArtifactRegistry {
37
42
  ): Result<MaterialRuntimeArtifact, MaterialArtifactConflictError> {
38
43
  const previous = this.#artifacts.get(artifact.key);
39
44
  if (previous !== undefined) {
40
- if (equalBytes(previous.bytes, artifact.bytes)) return ok(previous);
45
+ if (equalBytes(previous.bytes, artifact.bytes)) {
46
+ if (equalParamSchema(previous, artifact)) return ok(previous);
47
+ return err({
48
+ code: 'material-artifact-conflict',
49
+ expected: 'one immutable parameter schema per specialization key',
50
+ hint: 're-cook the conflicting specialization with one canonical parameter schema',
51
+ detail: {
52
+ key: artifact.key,
53
+ dimension: 'param-schema',
54
+ ...(previous.digest ? { existingDigest: previous.digest } : {}),
55
+ ...(artifact.digest ? { incomingDigest: artifact.digest } : {}),
56
+ },
57
+ });
58
+ }
41
59
  return err({
42
60
  code: 'material-artifact-conflict',
43
61
  expected: 'one immutable artifact byte sequence per specialization key',
44
62
  hint: 're-cook the conflicting specialization and publish one artifact digest',
45
63
  detail: {
46
64
  key: artifact.key,
65
+ dimension: 'bytes',
47
66
  ...(previous.digest ? { existingDigest: previous.digest } : {}),
48
67
  ...(artifact.digest ? { incomingDigest: artifact.digest } : {}),
49
68
  },
@@ -0,0 +1,12 @@
1
+ #define_import_path forgeax_material::standard_physical_layer
2
+
3
+ // Engine-owned physical layer facts are selected from the root StandardLayerPlan.
4
+ // Surface modules only produce base SurfaceData and never own this interface.
5
+ // The Standard template owns the facts struct; this module supplies the one
6
+ // physical evaluator body when the clearcoat plan is present.
7
+ fn evaluate_standard_physical_layer() -> StandardPhysicalLayerFacts {
8
+ return StandardPhysicalLayerFacts(
9
+ clamp(material.clearcoat, 0.0, 1.0),
10
+ clamp(material.clearcoatRoughness, 0.04, 1.0),
11
+ );
12
+ }
@@ -1,10 +1,16 @@
1
1
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
2
2
  #pragma variant_axis SKINNING_DISABLED
3
+ #pragma material_slot surface
4
+ #define_import_path forgeax::default-shadow-caster
5
+ #import forgeax_material::slot::surface::{evaluate_surface}
6
+ #import forgeax_material::surface_v1::{SurfaceInput, SurfaceData}
7
+ #import forgeax_material::parameters::{material}
3
8
 
4
9
  // @forgeax/engine-shader shadow_caster.wgsl
5
10
  // feat-20260520-directional-light-shadow-mapping M1c / w9 (D-9 / AC-09):
6
- // vertex-only depth pass for directional shadow map. No fragment stage --
7
- // the GPU writes depth automatically from gl_Position.z (depth32float RT).
11
+ // depth pass for directional shadow map. The fragment stage evaluates the
12
+ // selected Standard Surface so opacity/alpha-clip stays identical to Forward
13
+ // and Deferred (depth32float is still the only render target).
8
14
  //
9
15
  // feat-20260613-csm-cascaded-shadow-maps M5 / w28: per-cascade
10
16
  // lightViewProj selection. Each cascade pass writes a different
@@ -29,12 +35,25 @@
29
35
 
30
36
  struct VsInput {
31
37
  @location(0) position : vec3<f32>,
38
+ @location(1) normal : vec3<f32>,
39
+ @location(2) uv : vec2<f32>,
40
+ @location(3) tangent : vec4<f32>,
32
41
  #if SKINNING_DISABLED == false
33
42
  @location(4) skinIndex : vec4<u32>,
34
43
  @location(5) skinWeight : vec4<f32>,
35
44
  #endif
36
45
  };
37
46
 
47
+ struct VsOut {
48
+ @builtin(position) clip : vec4<f32>,
49
+ @location(0) positionOS : vec3<f32>,
50
+ @location(1) positionWS : vec3<f32>,
51
+ @location(2) normalWS : vec3<f32>,
52
+ @location(3) tangentWS : vec4<f32>,
53
+ @location(4) surfaceUv : vec2<f32>,
54
+ @location(5) vertexColor : vec4<f32>,
55
+ };
56
+
38
57
  #if SKINNING_DISABLED == false
39
58
  #if STORAGE_BUFFER_AVAILABLE == true
40
59
  @group(2) @binding(1) var<storage, read> palette : array<mat4x4<f32>>;
@@ -53,7 +72,7 @@ fn _cascadeLightViewProj(layer : u32) -> mat4x4<f32> {
53
72
  }
54
73
 
55
74
  @vertex
56
- fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) -> @builtin(position) vec4<f32> {
75
+ fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) -> VsOut {
57
76
  #if SKINNING_DISABLED == false
58
77
  let skinMatrix = palette[in.skinIndex.x] * in.skinWeight.x +
59
78
  palette[in.skinIndex.y] * in.skinWeight.y +
@@ -63,8 +82,14 @@ fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) -> @builtin(positio
63
82
  // produce world-space positions. Applying meshes[0].worldFromLocal again
64
83
  // would double-transform a parented skinned entity.
65
84
  let worldPos = skinMatrix * vec4<f32>(in.position, 1.0);
85
+ let worldNormal = normalize((skinMatrix * vec4<f32>(in.normal, 0.0)).xyz);
86
+ let worldTangent = normalize((skinMatrix * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
66
87
  #else
67
- let worldPos = meshes[0].worldFromLocal * instances[idx].localFromInstance * vec4<f32>(in.position, 1.0);
88
+ let instanceLocal = instances[idx].localFromInstance;
89
+ let worldMatrix = meshes[0].worldFromLocal * instanceLocal;
90
+ let worldPos = worldMatrix * vec4<f32>(in.position, 1.0);
91
+ let worldNormal = normalize((worldMatrix * vec4<f32>(in.normal, 0.0)).xyz);
92
+ let worldTangent = normalize((worldMatrix * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
68
93
  #endif
69
94
  // feat-20260625-spot-light-shadow-mapping M2 / w10 (D-1): spot shadow passes
70
95
  // set `isSpot = 1u` and write their perspective matrix into
@@ -72,8 +97,57 @@ fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) -> @builtin(positio
72
97
  // `view.lightViewProj_A..D` via `index`. Routing on the discriminant keeps
73
98
  // the spot matrix out of the directional View UBO (no same-frame contention).
74
99
  if (shadowCasterCascade.isSpot == 1u) {
75
- return shadowCasterCascade.spotLightViewProj * worldPos;
100
+ var out : VsOut;
101
+ out.clip = shadowCasterCascade.spotLightViewProj * worldPos;
102
+ out.positionOS = in.position;
103
+ out.positionWS = worldPos.xyz;
104
+ out.normalWS = worldNormal;
105
+ out.tangentWS = vec4<f32>(worldTangent, in.tangent.w);
106
+ out.surfaceUv = in.uv;
107
+ out.vertexColor = vec4<f32>(1.0);
108
+ return out;
76
109
  }
77
110
  let lvp = _cascadeLightViewProj(shadowCasterCascade.index);
78
- return lvp * worldPos;
111
+ var out : VsOut;
112
+ out.clip = lvp * worldPos;
113
+ out.positionOS = in.position;
114
+ out.positionWS = worldPos.xyz;
115
+ out.normalWS = worldNormal;
116
+ out.tangentWS = vec4<f32>(worldTangent, in.tangent.w);
117
+ out.surfaceUv = in.uv;
118
+ out.vertexColor = vec4<f32>(1.0);
119
+ return out;
120
+ }
121
+
122
+ fn evaluateShadowSurface(in : VsOut, frontFacing : bool) -> SurfaceData {
123
+ let viewDirectionWS = normalize(view.cameraPos - in.positionWS);
124
+ return evaluate_surface(SurfaceInput(
125
+ in.positionOS,
126
+ in.positionWS,
127
+ in.normalWS,
128
+ in.tangentWS,
129
+ viewDirectionWS,
130
+ in.surfaceUv,
131
+ in.surfaceUv,
132
+ in.vertexColor,
133
+ frontFacing,
134
+ ));
135
+ }
136
+
137
+ fn alphaTestShadowSurface(surface : SurfaceData) {
138
+ if (surface.alphaClipThreshold > 0.0 && surface.opacity <= surface.alphaClipThreshold) {
139
+ discard;
140
+ }
141
+ }
142
+
143
+ // The depth-only path evaluates the selected Surface so alpha-clip ownership
144
+ // remains shared with Forward and Deferred without mutating vertex data.
145
+ @fragment
146
+ fn fs_shadow(in : VsOut, @builtin(front_facing) frontFacing : bool) {
147
+ alphaTestShadowSurface(evaluateShadowSurface(in, frontFacing));
148
+ }
149
+
150
+ @fragment
151
+ fn fs_main(in : VsOut, @builtin(front_facing) frontFacing : bool) {
152
+ alphaTestShadowSurface(evaluateShadowSurface(in, frontFacing));
79
153
  }
package/src/unlit.wgsl CHANGED
@@ -111,6 +111,18 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
111
111
  return vec4<f32>(material.baseColor.rgb * texSample.rgb * vertexColor.rgb, alpha);
112
112
  }
113
113
 
114
+ // Depth-only shadow variant. Keep alpha clipping identical to the color path,
115
+ // but return no color target because the shadow pass has a depth attachment only.
116
+ @fragment
117
+ fn fs_shadow(in : VsOut) {
118
+ let texSample = sampleMaterialTextureLinear(baseColorTexture, baseColorSampler, in.uv, material.baseColorTextureCoordinatesMetadata.zw);
119
+ let vertexColor = materialVertexColor(in);
120
+ let alpha = material.baseColor.a * texSample.a * vertexColor.a;
121
+ if (material.alphaCutoff > 0.0 && alpha < material.alphaCutoff) {
122
+ discard;
123
+ }
124
+ }
125
+
114
126
  struct TemporalVsOut {
115
127
  @builtin(position) clip : vec4<f32>,
116
128
  @location(0) uv : vec2<f32>,