@forgeax/engine-shader 0.1.23 → 0.1.24

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 (48) hide show
  1. package/README.md +13 -4
  2. package/dist/ShaderRegistry.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +28 -52
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/material-schemas.d.ts +31 -0
  8. package/dist/material-schemas.d.ts.map +1 -1
  9. package/package.json +4 -4
  10. package/src/ShaderRegistry.ts +0 -17
  11. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +9 -3
  12. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +48 -166
  13. package/src/__tests__/deferred-lighting-ssao.test.ts +3 -3
  14. package/src/__tests__/ibl-irradiance.unit.test.ts +9 -0
  15. package/src/__tests__/lighting-directional.unit.test.ts +36 -0
  16. package/src/__tests__/lighting-punctual.unit.test.ts +12 -4
  17. package/src/__tests__/material-builtins.unit.test.ts +21 -3
  18. package/src/__tests__/material-contract.unit.test.ts +131 -17
  19. package/src/__tests__/physical-clearcoat.unit.test.ts +65 -0
  20. package/src/__tests__/probe-lighting-composition.unit.test.ts +3 -2
  21. package/src/__tests__/shader-manifest-transmission.unit.test.ts +8 -10
  22. package/src/__tests__/shader.unit.test.ts +52 -87
  23. package/src/__tests__/spot-cookie-composition.unit.test.ts +2 -1
  24. package/src/__tests__/standard-webgl2-varying-budget.unit.test.ts +17 -0
  25. package/src/__tests__/surface-v1-negative.unit.test.ts +37 -0
  26. package/src/__tests__/surface-v1.unit.test.ts +83 -0
  27. package/src/__tests__/transmission-thickness-scale.unit.test.ts +2 -6
  28. package/src/__tests__/vertex-color-variant.unit.test.ts +16 -5
  29. package/src/default-standard-pbr-skin.wgsl +678 -192
  30. package/src/default-standard-pbr.wgsl +563 -279
  31. package/src/default_standard_surface.wgsl +88 -0
  32. package/src/hdrp-cluster-forward.wgsl +225 -0
  33. package/src/ibl-irradiance.wgsl +43 -8
  34. package/src/ibl-prefilter.wgsl +13 -3
  35. package/src/ibl-shared.wgsl +9 -2
  36. package/src/index.ts +6 -0
  37. package/src/lighting-directional.wgsl +3 -1
  38. package/src/lighting-spot-modifiers.wgsl +8 -1
  39. package/src/lighting-spot-projector.wgsl +31 -0
  40. package/src/material/artifact-types.ts +2 -2
  41. package/src/material/physical/anisotropy.wgsl +26 -0
  42. package/src/material/physical/clearcoat.wgsl +24 -0
  43. package/src/material/physical/iridescence.wgsl +23 -0
  44. package/src/material/physical/sheen.wgsl +17 -0
  45. package/src/material-schemas.ts +65 -30
  46. package/src/register-default-standard-pbr-skin.ts +2 -2
  47. package/src/standard-cluster.wgsl +1 -36
  48. package/src/surface_v1.wgsl +26 -0
@@ -3,6 +3,37 @@ import { type MaterialShaderArtifactReceipt } from './material/artifact-types.js
3
3
  export declare const STANDARD_PBR_ALPHA_CUTOFF_DEFAULT = 0;
4
4
  /** Shared material contract for the standard PBR and skinned PBR shaders. */
5
5
  export declare const DEFAULT_STANDARD_PBR_PARAM_SCHEMA: readonly ParamSchemaEntry[];
6
+ export type { StandardPhysicalTextureField } from '@forgeax/engine-types';
7
+ /**
8
+ * Backward-compatible export name for callers that need the physical
9
+ * projection. Every entry is selected from the Standard root schema above;
10
+ * no independent defaults or binding inventory can drift from it.
11
+ */
12
+ /**
13
+ * Stable physical texture injection order. The order is part of the
14
+ * Standard template's resource ABI and is derived from the root schema by
15
+ * `standardPhysicalTextureFields`; render/cook must not maintain another
16
+ * field inventory.
17
+ */
18
+ export { STANDARD_PHYSICAL_TEXTURE_FIELDS, standardPhysicalTextureFields, } from '@forgeax/engine-types';
19
+ export declare const STANDARD_PHYSICAL_LAYER_PARAM_SCHEMA: readonly ParamSchemaEntry[];
20
+ /**
21
+ * Canonical static Standard entry. It deliberately contains only the base
22
+ * contract (including the specular extension) so a base-only material keeps
23
+ * its Deferred path and carries no second-stage physical resources.
24
+ */
25
+ export declare const STANDARD_BASE_PARAM_SCHEMA: readonly ParamSchemaEntry[];
26
+ /**
27
+ * Canonical boot-time user region for the shared Standard material BGL.
28
+ *
29
+ * It is still a projection of the root schema, but unlike the author-facing
30
+ * base-only projection it retains the pre-existing transmission texture pair.
31
+ * The template reserves those two pairs before the IBL/backdrop injection so
32
+ * a base-only shader and a transmissive shader can share one boot layout;
33
+ * physical texture pairs are never included here and are appended only for a
34
+ * root contract that declares them.
35
+ */
36
+ export declare const STANDARD_PIPELINE_PARAM_SCHEMA: readonly ParamSchemaEntry[];
6
37
  /** The single producer receipt shared by direct and scene-index Standard PBR. */
7
38
  export declare const STANDARD_PBR_ARTIFACT_RECEIPT: MaterialShaderArtifactReceipt;
8
39
  /** Skinned Standard PBR keeps the material receipt identity and adds palette ABI. */
@@ -1 +1 @@
1
- {"version":3,"file":"material-schemas.d.ts","sourceRoot":"","sources":["../src/material-schemas.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAEL,KAAK,6BAA6B,EACnC,MAAM,8BAA8B,CAAC;AAEtC,eAAO,MAAM,iCAAiC,IAAI,CAAC;AAEnD,6EAA6E;AAC7E,eAAO,MAAM,iCAAiC,EAAE,SAAS,gBAAgB,EA6BxE,CAAC;AAEF,iFAAiF;AACjF,eAAO,MAAM,6BAA6B,EAAE,6BACR,CAAC;AAErC,qFAAqF;AACrF,eAAO,MAAM,kCAAkC,EAAE,6BACT,CAAC;AAEzC,eAAO,MAAM,0BAA0B,EAAE,SAAS,gBAAgB,EAIjE,CAAC;AAEF,eAAO,MAAM,2BAA2B,EAAE,SAAS,gBAAgB,EAMlE,CAAC;AAEF,eAAO,MAAM,8BAA8B,EAAE,SAAS,gBAAgB,EAIrE,CAAC"}
1
+ {"version":3,"file":"material-schemas.d.ts","sourceRoot":"","sources":["../src/material-schemas.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAK9D,OAAO,EAEL,KAAK,6BAA6B,EACnC,MAAM,8BAA8B,CAAC;AAEtC,eAAO,MAAM,iCAAiC,IAAI,CAAC;AAEnD,6EAA6E;AAC7E,eAAO,MAAM,iCAAiC,6BAAiC,CAAC;AAEhF,YAAY,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAC1E;;;;GAIG;AACH;;;;;GAKG;AACH,OAAO,EACL,gCAAgC,EAChC,6BAA6B,GAC9B,MAAM,uBAAuB,CAAC;AAE/B,eAAO,MAAM,oCAAoC,EAAE,SAAS,gBAAgB,EAGzE,CAAC;AAEJ;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,EAAE,SAAS,gBAAgB,EAY/D,CAAC;AAEJ;;;;;;;;;GASG;AACH,eAAO,MAAM,8BAA8B,EAAE,SAAS,gBAAgB,EAOnE,CAAC;AAEJ,iFAAiF;AACjF,eAAO,MAAM,6BAA6B,EAAE,6BACR,CAAC;AAErC,qFAAqF;AACrF,eAAO,MAAM,kCAAkC,EAAE,6BACT,CAAC;AAEzC,eAAO,MAAM,0BAA0B,EAAE,SAAS,gBAAgB,EAIjE,CAAC;AAEF,eAAO,MAAM,2BAA2B,EAAE,SAAS,gBAAgB,EAMlE,CAAC;AAEF,eAAO,MAAM,8BAA8B,EAAE,SAAS,gBAAgB,EAIrE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-shader",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
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.23",
26
- "@forgeax/engine-types": "0.1.23",
25
+ "@forgeax/engine-rhi": "0.1.24",
26
+ "@forgeax/engine-types": "0.1.24",
27
27
  "@webgpu/types": "^0.1.71"
28
28
  },
29
29
  "devDependencies": {
30
- "@forgeax/engine-vite-plugin-shader": "0.1.23"
30
+ "@forgeax/engine-vite-plugin-shader": "0.1.24"
31
31
  },
32
32
  "forgeax": {
33
33
  "metrics": {
@@ -356,23 +356,6 @@ export class ShaderRegistry {
356
356
  }
357
357
  variantKeys.add(variant.definesKey);
358
358
  }
359
- if (ms.identifier === 'forgeax::default-standard-pbr') {
360
- const transmissionStates = new Set(
361
- ms.variants
362
- .filter((variant) => 'TRANSMISSION_AVAILABLE' in variant.defines)
363
- .map((variant) => variant.defines.TRANSMISSION_AVAILABLE),
364
- );
365
- if (!transmissionStates.has(false) || !transmissionStates.has(true)) {
366
- return err(
367
- manifestMalformed({
368
- message:
369
- 'ShaderRegistry: Standard material shader is missing a transmission variant state',
370
- hint: 'forgeax::default-standard-pbr must declare TRANSMISSION_AVAILABLE=false and true',
371
- reason: `declared transmission states: ${JSON.stringify([...transmissionStates])}`,
372
- }),
373
- );
374
- }
375
- }
376
359
  validatedMaterialShaderEntries.push(ms);
377
360
  }
378
361
  }
@@ -6,15 +6,21 @@ const shader = readFileSync(
6
6
  fileURLToPath(new URL('../default-standard-pbr.wgsl', import.meta.url)),
7
7
  'utf8',
8
8
  );
9
+ const surface = readFileSync(
10
+ fileURLToPath(new URL('../default_standard_surface.wgsl', import.meta.url)),
11
+ 'utf8',
12
+ );
9
13
 
10
14
  describe('standard PBR MASK alpha contract', () => {
11
15
  it('multiplies factor alpha by sampled texture alpha before discard', () => {
12
- expect(shader).toContain('alphaTest(material.baseColor.a * baseSample.a * vertexColor.a)');
16
+ expect(surface).toContain(
17
+ 'clamp(material.baseColor.a * baseSample.a * vertexColor.a, 0.0, 1.0)',
18
+ );
13
19
  });
14
20
 
15
21
  it('matches Three r184 alphaTest by discarding values equal to or below cutoff', () => {
16
- expect(shader).toContain('alpha <= material.alphaCutoff');
17
- expect(shader).not.toContain('alpha < material.alphaCutoff');
22
+ expect(shader).toContain('surface.opacity <= surface.alphaClipThreshold');
23
+ expect(shader).not.toContain('surface.opacity < surface.alphaClipThreshold');
18
24
  });
19
25
 
20
26
  it('keeps alpha PBR on the shared Directional factor path', () => {
@@ -28,7 +28,7 @@ async function engineManifest(): Promise<EngineShaderManifest> {
28
28
  let engineManifestPromise: Promise<EngineShaderManifest> | undefined;
29
29
 
30
30
  describe('default Standard PBR transmission manifest contract', () => {
31
- it('publishes both TRANSMISSION_AVAILABLE variants from the cooked producer', {
31
+ it('publishes every supported capability combination including transmission', {
32
32
  timeout: 60_000,
33
33
  }, async () => {
34
34
  const manifest = await engineManifest();
@@ -38,63 +38,14 @@ describe('default Standard PBR transmission manifest contract', () => {
38
38
 
39
39
  expect(standard).toBeDefined();
40
40
  if (standard === undefined) return;
41
- const transmissionVariants = standard.variants.filter(
42
- (variant) => 'TRANSMISSION_AVAILABLE' in variant.defines,
43
- );
44
- // The producer emits the complete supported Cartesian product. Storage is
45
- // required for cluster/probe/extended-lighting variants, and extended
46
- // lighting additionally requires the projector texture lane. Keep the
47
- // expected keys derived here so adding a new axis cannot silently shrink
48
- // the published set.
49
- const expectedKeys = new Set<string>();
50
- for (const clusterForwardAvailable of [false, true]) {
51
- for (const storageBufferAvailable of [false, true]) {
52
- for (const vertexColorAvailable of [false, true]) {
53
- for (const probeBlendAvailable of [false, true]) {
54
- for (const extendedLightingAvailable of [false, true]) {
55
- for (const directionalPcssAvailable of [false, true]) {
56
- for (const transmissionAvailable of [false, true]) {
57
- for (const projectorAvailable of [false, true]) {
58
- for (const reflectionFallbackAvailable of [false, true]) {
59
- if (
60
- (clusterForwardAvailable && !storageBufferAvailable) ||
61
- (probeBlendAvailable && !storageBufferAvailable) ||
62
- (extendedLightingAvailable &&
63
- (!storageBufferAvailable || !projectorAvailable))
64
- ) {
65
- continue;
66
- }
67
- const expectedKeyEntries = [
68
- `CLUSTER_FORWARD_AVAILABLE=${clusterForwardAvailable}`,
69
- `DIRECTIONAL_PCSS_AVAILABLE=${directionalPcssAvailable}`,
70
- `EXTENDED_LIGHTING_AVAILABLE=${extendedLightingAvailable}`,
71
- `PROBE_BLEND_AVAILABLE=${probeBlendAvailable}`,
72
- `PROJECTOR_AVAILABLE=${projectorAvailable}`,
73
- `REFLECTION_FALLBACK_AVAILABLE=${reflectionFallbackAvailable}`,
74
- `STORAGE_BUFFER_AVAILABLE=${storageBufferAvailable}`,
75
- `TRANSMISSION_AVAILABLE=${transmissionAvailable}`,
76
- `VERTEX_COLOR_AVAILABLE=${vertexColorAvailable}`,
77
- ];
78
- expectedKeys.add(
79
- expectedKeyEntries.every((entry) => entry.endsWith('=true'))
80
- ? ''
81
- : expectedKeyEntries.join('+'),
82
- );
83
- }
84
- }
85
- }
86
- }
87
- }
88
- }
89
- }
90
- }
91
- }
92
- expect(transmissionVariants).toHaveLength(expectedKeys.size);
93
- expect(new Set(transmissionVariants.map((variant) => variant.definesKey))).toEqual(
94
- expectedKeys,
95
- );
41
+ // Nine declared axes produce 512 combinations. The compiler publishes
42
+ // the supported subset: storage is required by cluster/probe/extended
43
+ // lighting, and extended lighting also requires the projector lane.
44
+ expect(standard.variants).toHaveLength(224);
45
+ expect(new Set(standard.variants.map((variant) => variant.definesKey)).size).toBe(224);
46
+ expect(new Set(standard.variants.map((variant) => variant.composedWgsl)).size).toBe(224);
96
47
  expect(
97
- transmissionVariants.every(
48
+ standard.variants.every(
98
49
  (variant) =>
99
50
  !(
100
51
  variant.defines.CLUSTER_FORWARD_AVAILABLE === true &&
@@ -111,67 +62,12 @@ describe('default Standard PBR transmission manifest contract', () => {
111
62
  ),
112
63
  ),
113
64
  ).toBe(true);
114
- for (const clusterForwardAvailable of [false, true]) {
115
- for (const storageBufferAvailable of [false, true]) {
116
- if (clusterForwardAvailable && !storageBufferAvailable) continue;
117
- for (const vertexColorAvailable of [false, true]) {
118
- for (const probeBlendAvailable of [false, true]) {
119
- for (const extendedLightingAvailable of [false, true]) {
120
- for (const directionalPcssAvailable of [false, true]) {
121
- for (const transmissionAvailable of [false, true]) {
122
- for (const projectorAvailable of [false, true]) {
123
- for (const reflectionFallbackAvailable of [false, true]) {
124
- if (
125
- (probeBlendAvailable && !storageBufferAvailable) ||
126
- (extendedLightingAvailable &&
127
- (!storageBufferAvailable || !projectorAvailable))
128
- ) {
129
- continue;
130
- }
131
- const expectedKeyEntries = [
132
- `CLUSTER_FORWARD_AVAILABLE=${clusterForwardAvailable}`,
133
- `DIRECTIONAL_PCSS_AVAILABLE=${directionalPcssAvailable}`,
134
- `EXTENDED_LIGHTING_AVAILABLE=${extendedLightingAvailable}`,
135
- `PROBE_BLEND_AVAILABLE=${probeBlendAvailable}`,
136
- `PROJECTOR_AVAILABLE=${projectorAvailable}`,
137
- `REFLECTION_FALLBACK_AVAILABLE=${reflectionFallbackAvailable}`,
138
- `STORAGE_BUFFER_AVAILABLE=${storageBufferAvailable}`,
139
- `TRANSMISSION_AVAILABLE=${transmissionAvailable}`,
140
- `VERTEX_COLOR_AVAILABLE=${vertexColorAvailable}`,
141
- ];
142
- const expectedKey = expectedKeyEntries.every((entry) =>
143
- entry.endsWith('=true'),
144
- )
145
- ? ''
146
- : expectedKeyEntries.join('+');
147
- expect(transmissionVariants).toContainEqual(
148
- expect.objectContaining({
149
- definesKey: expectedKey,
150
- defines: expect.objectContaining({
151
- CLUSTER_FORWARD_AVAILABLE: clusterForwardAvailable,
152
- DIRECTIONAL_PCSS_AVAILABLE: directionalPcssAvailable,
153
- EXTENDED_LIGHTING_AVAILABLE: extendedLightingAvailable,
154
- PROBE_BLEND_AVAILABLE: probeBlendAvailable,
155
- PROJECTOR_AVAILABLE: projectorAvailable,
156
- REFLECTION_FALLBACK_AVAILABLE: reflectionFallbackAvailable,
157
- STORAGE_BUFFER_AVAILABLE: storageBufferAvailable,
158
- TRANSMISSION_AVAILABLE: transmissionAvailable,
159
- VERTEX_COLOR_AVAILABLE: vertexColorAvailable,
160
- }),
161
- }),
162
- );
163
- }
164
- }
165
- }
166
- }
167
- }
168
- }
169
- }
170
- }
171
- }
65
+ expect(standard.variants.every((variant) => 'TRANSMISSION_AVAILABLE' in variant.defines)).toBe(
66
+ true,
67
+ );
172
68
  });
173
69
 
174
- it('omits transmission-only texture bindings from the fallback variant', {
70
+ it('omits transmission and physical resources from the non-transmission fallback variant', {
175
71
  timeout: 60_000,
176
72
  }, async () => {
177
73
  const manifest = await engineManifest();
@@ -190,23 +86,34 @@ describe('default Standard PBR transmission manifest contract', () => {
190
86
  variant.defines.TRANSMISSION_AVAILABLE === false &&
191
87
  variant.defines.VERTEX_COLOR_AVAILABLE === false,
192
88
  );
193
- const transmission = standard.variants.find(
194
- (variant) =>
195
- variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
196
- variant.defines.STORAGE_BUFFER_AVAILABLE === true &&
197
- variant.defines.EXTENDED_LIGHTING_AVAILABLE === false &&
198
- variant.defines.PROBE_BLEND_AVAILABLE === false &&
199
- variant.defines.TRANSMISSION_AVAILABLE === true &&
200
- variant.defines.VERTEX_COLOR_AVAILABLE === false,
89
+ expect(fallback).toBeDefined();
90
+ if (fallback === undefined) return;
91
+ expect(fallback.composedWgsl).not.toMatch(
92
+ /var(?:<[^>]+>)?\s+(?:transmissionSampler|transmissionTexture|thicknessSampler|thicknessTexture|clearcoatTexture|clearcoatRoughnessTexture|clearcoatNormalTexture|anisotropyTexture|sheenColorTexture|sheenRoughnessTexture|iridescenceTexture|iridescenceThicknessTexture|specularTexture)\b/u,
201
93
  );
202
- expect(fallback?.composedWgsl).not.toContain('@binding(13)');
203
- expect(fallback?.composedWgsl).not.toContain('@binding(24)');
204
- expect(transmission?.composedWgsl).toContain('@binding(13)');
205
- expect(transmission?.composedWgsl).toContain('@binding(24)');
206
94
  });
207
95
 
208
- it('registers one Standard row and resolves a declared variant by exact key', {
96
+ it('publishes the same explicit transmission axis for the skin Standard template', {
209
97
  timeout: 60_000,
98
+ }, async () => {
99
+ const manifest = await engineManifest();
100
+ const skin = manifest.materialShaders.find((entry) => entry.identifier === 'forgeax::pbr-skin');
101
+ expect(skin).toBeDefined();
102
+ if (skin === undefined) return;
103
+ expect(skin.variants.length).toBeGreaterThan(0);
104
+ expect(skin.variants.every((variant) => 'TRANSMISSION_AVAILABLE' in variant.defines)).toBe(
105
+ true,
106
+ );
107
+ expect(skin.variants.some((variant) => variant.defines.TRANSMISSION_AVAILABLE === false)).toBe(
108
+ true,
109
+ );
110
+ expect(skin.variants.some((variant) => variant.defines.TRANSMISSION_AVAILABLE === true)).toBe(
111
+ true,
112
+ );
113
+ });
114
+
115
+ it('registers one Standard row and resolves a declared capability variant by exact key', {
116
+ timeout: 20_000,
210
117
  }, async () => {
211
118
  const manifest = await engineManifest();
212
119
  const standard = manifest.materialShaders.find(
@@ -229,38 +136,13 @@ describe('default Standard PBR transmission manifest contract', () => {
229
136
  expect(registered).toBeDefined();
230
137
  if (registered === undefined) return;
231
138
 
232
- const transmission = registered.variants.find(
233
- (variant) => variant.defines.TRANSMISSION_AVAILABLE === true,
139
+ const variant = registered.variants.find(
140
+ (candidate) => candidate.defines.CLUSTER_FORWARD_AVAILABLE === false,
234
141
  );
235
- expect(transmission).toBeDefined();
236
- if (transmission === undefined) return;
237
- expect(findVariantByKey(registered, transmission.definesKey)).toBe(transmission);
238
- expect(
239
- findVariantByKey(registered, `${transmission.definesKey}+UNDECLARED=true`),
240
- ).toBeUndefined();
241
- });
242
-
243
- it('rejects a manifest with a missing transmission variant before prewarm', async () => {
244
- const missingVariant = {
245
- identifier: 'forgeax::default-standard-pbr',
246
- sourcePath: 'default-standard-pbr.wgsl',
247
- composedWgsl: 'standard-default',
248
- paramSchema: '[]',
249
- variants: [
250
- {
251
- definesKey: 'TRANSMISSION_AVAILABLE=false',
252
- defines: { TRANSMISSION_AVAILABLE: false },
253
- composedWgsl: 'standard-no-transmission',
254
- },
255
- ],
256
- };
257
- const registry = new ShaderRegistry({
258
- device: device(),
259
- manifestUrl: dataUrl({ entries: [], materialShaders: [missingVariant] }),
260
- });
261
-
262
- const loaded = await registry.loadManifest();
263
- expect(loaded.ok).toBe(false);
142
+ expect(variant).toBeDefined();
143
+ if (variant === undefined) return;
144
+ expect(findVariantByKey(registered, variant.definesKey)).toBe(variant);
145
+ expect(findVariantByKey(registered, `${variant.definesKey}+UNDECLARED=true`)).toBeUndefined();
264
146
  });
265
147
 
266
148
  it('rejects duplicate variant keys instead of allowing ambiguous prewarm', async () => {
@@ -271,14 +153,14 @@ describe('default Standard PBR transmission manifest contract', () => {
271
153
  paramSchema: '[]',
272
154
  variants: [
273
155
  {
274
- definesKey: 'TRANSMISSION_AVAILABLE=true',
275
- defines: { TRANSMISSION_AVAILABLE: true },
276
- composedWgsl: 'standard-transmission-a',
156
+ definesKey: 'CLUSTER_FORWARD_AVAILABLE=false',
157
+ defines: { CLUSTER_FORWARD_AVAILABLE: false },
158
+ composedWgsl: 'standard-a',
277
159
  },
278
160
  {
279
- definesKey: 'TRANSMISSION_AVAILABLE=true',
280
- defines: { TRANSMISSION_AVAILABLE: true },
281
- composedWgsl: 'standard-transmission-b',
161
+ definesKey: 'CLUSTER_FORWARD_AVAILABLE=false',
162
+ defines: { CLUSTER_FORWARD_AVAILABLE: false },
163
+ composedWgsl: 'standard-b',
282
164
  },
283
165
  ],
284
166
  };
@@ -51,12 +51,12 @@ function stripComments(src: string): string {
51
51
 
52
52
  describe('deferred lighting ambient SSAO synthesis (M2 / w11)', () => {
53
53
  it('(a) ambient calculation uses occlusionStrength * bakedAO texture sample', () => {
54
- // The ambient expression starts from kD * irradiance before clearcoat and
55
- // skylight terms are applied; ao comes from material.occlusionStrength.
54
+ // The ambient expression starts from kD * irradiance before skylight terms
55
+ // are applied; ao comes from material.occlusionStrength.
56
56
  // M7/w32 changed `let` -> `var` so the SSAO blend line `ambient *= mix(...)` can mutate it.
57
57
  const codeOnly = stripComments(pbrSource);
58
58
  expect(codeOnly).toMatch(/var\s+ambient\s*=\s*\(*\s*kD\s*\*\s*irradiance/);
59
- expect(codeOnly).toMatch(/material\.occlusionStrength/);
59
+ expect(codeOnly).toMatch(/surface\.occlusion/);
60
60
  expect(codeOnly).toMatch(/skylight\.intensity/);
61
61
  });
62
62
 
@@ -43,4 +43,13 @@ describe('IBL irradiance payload contract', () => {
43
43
  expect(diffuseRadiance).toBeCloseTo(environment, 12);
44
44
  expect(Number.isFinite(diffuseRadiance)).toBe(true);
45
45
  });
46
+
47
+ it('rejects NaN and infinity before a sample reaches the accumulation sum', () => {
48
+ expect(irradianceSource).toMatch(/const IRRADIANCE_SAMPLE_DELTA:\s*f32\s*=\s*0\.05/);
49
+ expect(irradianceSource).toContain('fn irradianceFiniteScalar');
50
+ expect(irradianceSource).toContain('abs(value) <= 65504.0');
51
+ expect(irradianceSource).toMatch(
52
+ /irradianceFiniteVec3\(sampleVec\)[\s\S]*irradianceFiniteVec3\(sampleColor\)[\s\S]*irradianceFiniteScalar\(sampleWeight\)/,
53
+ );
54
+ });
46
55
  });
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const source = readFileSync(new URL('../lighting-directional.wgsl', import.meta.url), 'utf8');
5
+
6
+ type Vec3 = readonly [number, number, number];
7
+
8
+ function unsafeNormalize(value: Vec3): Vec3 {
9
+ const length = Math.hypot(value[0], value[1], value[2]);
10
+ return [value[0] / length, value[1] / length, value[2] / length];
11
+ }
12
+
13
+ function directionalHalfVector(viewDir: Vec3, lightDir: Vec3): Vec3 {
14
+ const sum: Vec3 = [viewDir[0] + lightDir[0], viewDir[1] + lightDir[1], viewDir[2] + lightDir[2]];
15
+ const lengthSquared = Math.max(sum[0] ** 2 + sum[1] ** 2 + sum[2] ** 2, 1e-8);
16
+ const inverseLength = 1 / Math.sqrt(lengthSquared);
17
+ return [sum[0] * inverseLength, sum[1] * inverseLength, sum[2] * inverseLength];
18
+ }
19
+
20
+ describe('directional GGX half-vector boundary', () => {
21
+ it('reproduces the old normalize zero-vector failure', () => {
22
+ const halfVector: Vec3 = [0, 0, 0];
23
+ expect(unsafeNormalize(halfVector).some((value) => !Number.isFinite(value))).toBe(true);
24
+ });
25
+
26
+ it.each([
27
+ ['anti-parallel', [0, 0, 1] as const, [0, 0, -1] as const],
28
+ ['near anti-parallel', [0, 0, 1] as const, [1e-5, 0, -1] as const],
29
+ ])('%s inputs remain finite in evalDirectionalNoShadow', (_name, viewDir, lightDir) => {
30
+ expect(directionalHalfVector(viewDir, lightDir).every(Number.isFinite)).toBe(true);
31
+ });
32
+
33
+ it('uses a finite lower bound for the half-vector length', () => {
34
+ expect(source).toContain('max(dot(halfVector, halfVector), 1e-8)');
35
+ });
36
+ });
@@ -8,6 +8,10 @@ const standardClusterShaderPath = join(
8
8
  dirname(fileURLToPath(import.meta.url)),
9
9
  '../standard-cluster.wgsl',
10
10
  );
11
+ const spotProjectorShaderPath = join(
12
+ dirname(fileURLToPath(import.meta.url)),
13
+ '../lighting-spot-projector.wgsl',
14
+ );
11
15
  const pbrShaderPath = join(dirname(fileURLToPath(import.meta.url)), '../default-standard-pbr.wgsl');
12
16
  const brdfShaderPath = join(dirname(fileURLToPath(import.meta.url)), '../brdf.wgsl');
13
17
  const directionalShaderPath = join(
@@ -83,13 +87,16 @@ describe('direct punctual lighting shader contract', () => {
83
87
  });
84
88
 
85
89
  it('keeps the unshadowed spot sentinel out of the projector flag branch', async () => {
86
- const source = await readFile(standardClusterShaderPath, 'utf8');
90
+ const [source, projector] = await Promise.all([
91
+ readFile(standardClusterShaderPath, 'utf8'),
92
+ readFile(spotProjectorShaderPath, 'utf8'),
93
+ ]);
87
94
 
88
95
  expect(source).toContain('if (encoded_tile >= 0 && (encoded_tile & PROJECTOR_FLAG) != 0)');
89
96
  expect(source).toContain(
90
97
  'sampleStandardSpotProjector(view.spotLightViewProj[tile], world_pos, light.metadata)',
91
98
  );
92
- expect(source).toContain(
99
+ expect(projector).toContain(
93
100
  'textureSampleLevel(cookieTexture, spotModifierSampler, uv, metadata.w, 0.0).rgb',
94
101
  );
95
102
  });
@@ -98,9 +105,10 @@ describe('direct punctual lighting shader contract', () => {
98
105
  const source = await readFile(pbrShaderPath, 'utf8');
99
106
 
100
107
  expect(source).toMatch(
101
- /evaluateStandardClusterLights\(\s*in\.ndc\.xyz,\s*in\.viewZ,\s*in\.worldPos,\s*n,\s*v,\s*diffuseAlbedo,\s*metallic,\s*a,\s*f0,\s*false,?\s*\)/u,
108
+ /evaluateStandardClusterLights\(\s*in\.ndc\.xyz,\s*standardViewZ\(in\),\s*in\.worldPos,\s*physicalNormal,\s*v,\s*diffuseAlbedo,\s*metallic,\s*a,\s*f0,\s*false,?\s*\)/u,
102
109
  );
103
- expect(source).toContain('coatAlpha, vec3<f32>(0.04)');
110
+ expect(source).toContain('clearcoatAlpha,');
111
+ expect(source).toContain('vec3<f32>(0.04),');
104
112
  });
105
113
 
106
114
  it('requires one shared Directional PCSS orchestration owner', async () => {
@@ -83,8 +83,6 @@ describe('built-in MaterialAsset sources', () => {
83
83
  'metallicRoughnessTexture',
84
84
  'normalSampler',
85
85
  'normalTexture',
86
- 'specularTintSampler',
87
- 'specularTintTexture',
88
86
  'emissiveSampler',
89
87
  'emissiveTexture',
90
88
  'occlusionSampler',
@@ -104,6 +102,13 @@ describe('built-in MaterialAsset sources', () => {
104
102
  expect(text).toContain('@binding(13) var transmissionSampler');
105
103
  expect(text).toContain('@binding(16) var thicknessTexture');
106
104
  expect(text).toContain('@binding(17) var irradianceMap');
105
+ expect(text).toMatch(
106
+ /#ifdef SPECULAR_COLOR_TEXTURE_AVAILABLE\s+@group\(1\) @binding\(44\) var specularColorTextureSampler/u,
107
+ );
108
+ expect(text).toMatch(
109
+ /#ifdef SPECULAR_COLOR_TEXTURE_AVAILABLE[\s\S]*?@group\(1\) @binding\(45\) var specularColorTexture/u,
110
+ );
111
+ expect(text).not.toMatch(/@binding\((?:7|8)\) var specularColorTexture/u);
107
112
  });
108
113
  });
109
114
 
@@ -116,7 +121,20 @@ describe('built-in and custom material derived layout matrix', () => {
116
121
  expect(names).toContain('thicknessTexture');
117
122
  expect(
118
123
  DEFAULT_STANDARD_PBR_PARAM_SCHEMA.filter((entry) => entry.type === 'texture2d'),
119
- ).toHaveLength(8);
124
+ ).toHaveLength(17);
125
+ expect(names).toEqual(
126
+ expect.arrayContaining([
127
+ 'clearcoatTexture',
128
+ 'clearcoatRoughnessTexture',
129
+ 'clearcoatNormalTexture',
130
+ 'anisotropyTexture',
131
+ 'sheenColorTexture',
132
+ 'sheenRoughnessTexture',
133
+ 'iridescenceTexture',
134
+ 'iridescenceThicknessTexture',
135
+ 'specularTexture',
136
+ ]),
137
+ );
120
138
  });
121
139
 
122
140
  it('derives a coordinate record for every texture parameter in an interleaved custom schema', () => {