@forgeax/engine-shader 0.1.23 → 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.
Files changed (60) hide show
  1. package/README.md +110 -4
  2. package/dist/ShaderRegistry.d.ts.map +1 -1
  3. package/dist/index.d.ts +10 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +59 -55
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/material/artifact-registry.d.ts +1 -0
  8. package/dist/material/artifact-registry.d.ts.map +1 -1
  9. package/dist/material-schemas.d.ts +31 -0
  10. package/dist/material-schemas.d.ts.map +1 -1
  11. package/package.json +4 -4
  12. package/src/ShaderRegistry.ts +0 -17
  13. package/src/__tests__/builtin-texture-sampling-contract.test.ts +10 -1
  14. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +9 -3
  15. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +48 -166
  16. package/src/__tests__/deferred-lighting-ssao.test.ts +7 -4
  17. package/src/__tests__/ibl-irradiance.unit.test.ts +9 -0
  18. package/src/__tests__/lighting-directional.unit.test.ts +36 -0
  19. package/src/__tests__/lighting-punctual.unit.test.ts +12 -4
  20. package/src/__tests__/material-builtins.unit.test.ts +21 -3
  21. package/src/__tests__/material-contract.unit.test.ts +131 -17
  22. package/src/__tests__/physical-clearcoat.unit.test.ts +65 -0
  23. package/src/__tests__/probe-lighting-composition.unit.test.ts +3 -2
  24. package/src/__tests__/shader-manifest-transmission.unit.test.ts +8 -10
  25. package/src/__tests__/shader.unit.test.ts +52 -87
  26. package/src/__tests__/spot-cookie-composition.unit.test.ts +2 -1
  27. package/src/__tests__/standard-pbr-webgl2-interface.unit.test.ts +33 -0
  28. package/src/__tests__/standard-physical-layer.unit.test.ts +16 -0
  29. package/src/__tests__/standard-surface-pass-evaluation.integration.test.ts +38 -0
  30. package/src/__tests__/standard-webgl2-varying-budget.unit.test.ts +17 -0
  31. package/src/__tests__/surface-lighting-provenance.integration.test.ts +59 -0
  32. package/src/__tests__/surface-pass-templates.integration.test.ts +28 -0
  33. package/src/__tests__/surface-v1-negative.unit.test.ts +37 -0
  34. package/src/__tests__/surface-v1.unit.test.ts +83 -0
  35. package/src/__tests__/transmission-thickness-scale.unit.test.ts +2 -6
  36. package/src/__tests__/vertex-color-variant.unit.test.ts +16 -5
  37. package/src/default-standard-pbr-skin.wgsl +704 -192
  38. package/src/default-standard-pbr.wgsl +563 -279
  39. package/src/default_standard_surface.wgsl +88 -0
  40. package/src/hdrp-cluster-forward.wgsl +225 -0
  41. package/src/ibl-irradiance.wgsl +43 -8
  42. package/src/ibl-prefilter.wgsl +13 -3
  43. package/src/ibl-shared.wgsl +9 -2
  44. package/src/index.ts +37 -3
  45. package/src/lighting-directional.wgsl +3 -1
  46. package/src/lighting-spot-modifiers.wgsl +8 -1
  47. package/src/lighting-spot-projector.wgsl +31 -0
  48. package/src/material/artifact-registry.ts +20 -1
  49. package/src/material/artifact-types.ts +2 -2
  50. package/src/material/physical/anisotropy.wgsl +26 -0
  51. package/src/material/physical/clearcoat.wgsl +24 -0
  52. package/src/material/physical/iridescence.wgsl +23 -0
  53. package/src/material/physical/sheen.wgsl +17 -0
  54. package/src/material/standard-physical-layer.wgsl +12 -0
  55. package/src/material-schemas.ts +65 -30
  56. package/src/register-default-standard-pbr-skin.ts +2 -2
  57. package/src/shadow_caster.wgsl +80 -6
  58. package/src/standard-cluster.wgsl +1 -36
  59. package/src/surface_v1.wgsl +26 -0
  60. package/src/unlit.wgsl +12 -0
@@ -150,6 +150,114 @@ describe('standard PBR transmission material contract', () => {
150
150
  kind: 'texture',
151
151
  binding: 16,
152
152
  },
153
+ {
154
+ name: 'clearcoatTexture_sampler',
155
+ parameter: 'clearcoatTexture',
156
+ kind: 'sampler',
157
+ binding: 17,
158
+ },
159
+ {
160
+ name: 'clearcoatTexture',
161
+ parameter: 'clearcoatTexture',
162
+ kind: 'texture',
163
+ binding: 18,
164
+ },
165
+ {
166
+ name: 'clearcoatRoughnessTexture_sampler',
167
+ parameter: 'clearcoatRoughnessTexture',
168
+ kind: 'sampler',
169
+ binding: 19,
170
+ },
171
+ {
172
+ name: 'clearcoatRoughnessTexture',
173
+ parameter: 'clearcoatRoughnessTexture',
174
+ kind: 'texture',
175
+ binding: 20,
176
+ },
177
+ {
178
+ name: 'clearcoatNormalTexture_sampler',
179
+ parameter: 'clearcoatNormalTexture',
180
+ kind: 'sampler',
181
+ binding: 21,
182
+ },
183
+ {
184
+ name: 'clearcoatNormalTexture',
185
+ parameter: 'clearcoatNormalTexture',
186
+ kind: 'texture',
187
+ binding: 22,
188
+ },
189
+ {
190
+ name: 'anisotropyTexture_sampler',
191
+ parameter: 'anisotropyTexture',
192
+ kind: 'sampler',
193
+ binding: 23,
194
+ },
195
+ {
196
+ name: 'anisotropyTexture',
197
+ parameter: 'anisotropyTexture',
198
+ kind: 'texture',
199
+ binding: 24,
200
+ },
201
+ {
202
+ name: 'sheenColorTexture_sampler',
203
+ parameter: 'sheenColorTexture',
204
+ kind: 'sampler',
205
+ binding: 25,
206
+ },
207
+ {
208
+ name: 'sheenColorTexture',
209
+ parameter: 'sheenColorTexture',
210
+ kind: 'texture',
211
+ binding: 26,
212
+ },
213
+ {
214
+ name: 'sheenRoughnessTexture_sampler',
215
+ parameter: 'sheenRoughnessTexture',
216
+ kind: 'sampler',
217
+ binding: 27,
218
+ },
219
+ {
220
+ name: 'sheenRoughnessTexture',
221
+ parameter: 'sheenRoughnessTexture',
222
+ kind: 'texture',
223
+ binding: 28,
224
+ },
225
+ {
226
+ name: 'iridescenceTexture_sampler',
227
+ parameter: 'iridescenceTexture',
228
+ kind: 'sampler',
229
+ binding: 29,
230
+ },
231
+ {
232
+ name: 'iridescenceTexture',
233
+ parameter: 'iridescenceTexture',
234
+ kind: 'texture',
235
+ binding: 30,
236
+ },
237
+ {
238
+ name: 'iridescenceThicknessTexture_sampler',
239
+ parameter: 'iridescenceThicknessTexture',
240
+ kind: 'sampler',
241
+ binding: 31,
242
+ },
243
+ {
244
+ name: 'iridescenceThicknessTexture',
245
+ parameter: 'iridescenceThicknessTexture',
246
+ kind: 'texture',
247
+ binding: 32,
248
+ },
249
+ {
250
+ name: 'specularTexture_sampler',
251
+ parameter: 'specularTexture',
252
+ kind: 'sampler',
253
+ binding: 33,
254
+ },
255
+ {
256
+ name: 'specularTexture',
257
+ parameter: 'specularTexture',
258
+ kind: 'texture',
259
+ binding: 34,
260
+ },
153
261
  ]);
154
262
 
155
263
  const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
@@ -159,32 +267,38 @@ describe('standard PBR transmission material contract', () => {
159
267
  );
160
268
  expect(material).toBeDefined();
161
269
  if (material === undefined) return;
162
- const transmissionVariant = material.variants.find(
270
+ const baseVariant = material.variants.find(
163
271
  (variant) =>
164
272
  variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
165
273
  variant.defines.STORAGE_BUFFER_AVAILABLE === true &&
166
- variant.defines.TRANSMISSION_AVAILABLE === true &&
274
+ variant.defines.TRANSMISSION_AVAILABLE === false &&
167
275
  variant.defines.VERTEX_COLOR_AVAILABLE === false,
168
276
  );
169
- expect(transmissionVariant).toBeDefined();
170
- if (transmissionVariant === undefined) return;
171
- expect(derived.totalBytes).toBe(384);
172
- expect(transmissionVariant.composedWgsl).toMatch(
173
- /@group\(1\)\s*@binding\(13\)\s*var transmissionSampler/,
174
- );
175
- expect(transmissionVariant.composedWgsl).toMatch(
176
- /@group\(1\)\s*@binding\(14\)\s*var transmissionTexture/,
277
+ expect(baseVariant).toBeDefined();
278
+ if (baseVariant === undefined) return;
279
+ expect(derived.totalBytes).toBe(736);
280
+ // The base capability variant owns only the ordinary material region and
281
+ // the IBL injection. Transmission and second-stage physical maps are
282
+ // specialized in authored roots, so no transmission/physical resource
283
+ // declaration may leak into this base artifact.
284
+ expect(baseVariant.composedWgsl).not.toMatch(
285
+ /@group\(1\)\s*@binding\((?:13|14)\)\s*\n?\s*var\s+transmission/u,
177
286
  );
178
- expect(transmissionVariant.composedWgsl).toMatch(
179
- /@group\(1\)\s*@binding\(15\)\s*var thicknessSampler/,
287
+ expect(baseVariant.composedWgsl).not.toMatch(
288
+ /@group\(1\)\s*@binding\((?:13|14|15|16|24|25|26|27|28|29|30|31|32|33|34|35|36|37|38|39|40|41|42|43|44|45)\)\s*\n?\s*var\s+(?:thickness|clearcoat|anisotropy|sheen|iridescence|specular)/u,
180
289
  );
181
- expect(transmissionVariant.composedWgsl).toMatch(
182
- /@group\(1\)\s*@binding\(16\)\s*var thicknessTexture/,
290
+ expect(baseVariant.composedWgsl).toMatch(
291
+ /@group\(1\)\s*@binding\(15\)\s*\n?\s*var\s+irradianceMap/u,
183
292
  );
184
293
  });
185
294
 
186
295
  it('publishes one receipt for direct and scene-index consumers', {
187
- timeout: 20_000,
296
+ // Manifest production is intentionally real and coverage instrumentation
297
+ // adds enough startup cost to exceed the old 20s budget on the shared
298
+ // heavy runner. Keep the same producer-backed budget as the neighboring
299
+ // contract cases so CPU contention cannot turn a valid compile into a
300
+ // timeout-only failure.
301
+ timeout: 60_000,
188
302
  }, async () => {
189
303
  const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
190
304
  const manifest = await buildEngineShaderManifest();
@@ -208,7 +322,7 @@ describe('standard PBR transmission material contract', () => {
208
322
  });
209
323
 
210
324
  it('composes the scene-index entry into the same validated PBR artifact as the direct entry', {
211
- timeout: 30_000,
325
+ timeout: 60_000,
212
326
  }, async () => {
213
327
  const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
214
328
  const manifest = await buildEngineShaderManifest();
@@ -225,7 +339,7 @@ describe('standard PBR transmission material contract', () => {
225
339
  });
226
340
 
227
341
  it('rejects a receipt that loses a required prepared ABI field', {
228
- timeout: 30_000,
342
+ timeout: 60_000,
229
343
  }, async () => {
230
344
  const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
231
345
  const manifest = await buildEngineShaderManifest();
@@ -0,0 +1,65 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const source = (name: string) =>
5
+ readFileSync(new URL(`../material/physical/${name}`, import.meta.url), 'utf8');
6
+
7
+ describe('clearcoat physical shader layer', () => {
8
+ it('owns the top-layer Fresnel attenuation and coat normal inputs', () => {
9
+ const clearcoat = source('clearcoat.wgsl');
10
+ expect(clearcoat).toContain('clearcoatRoughness');
11
+ expect(clearcoat).toContain('clearcoatNormalScale');
12
+ expect(clearcoat).toContain('fresnel');
13
+ expect(clearcoat).toMatch(/base.*attenuat|attenuat.*base/i);
14
+ });
15
+
16
+ it('keeps standard and skinned roots on one physical module contract', () => {
17
+ const standard = readFileSync(new URL('../default-standard-pbr.wgsl', import.meta.url), 'utf8');
18
+ const skin = readFileSync(
19
+ new URL('../default-standard-pbr-skin.wgsl', import.meta.url),
20
+ 'utf8',
21
+ );
22
+ expect(standard).toContain('clearcoat');
23
+ expect(skin).toContain('clearcoat');
24
+ expect(standard).toContain('#import forgeax_pbr::clearcoat');
25
+ expect(skin).toContain('#import forgeax_pbr::clearcoat');
26
+ expect(standard).not.toMatch(/clearcoat[A-Za-z]*Shadow/);
27
+ expect(skin).not.toMatch(/clearcoat[A-Za-z]*Shadow/);
28
+ });
29
+
30
+ it('routes clearcoat direct and IBL through complete lighting facts', () => {
31
+ const roots = [
32
+ readFileSync(new URL('../default-standard-pbr.wgsl', import.meta.url), 'utf8'),
33
+ readFileSync(new URL('../default-standard-pbr-skin.wgsl', import.meta.url), 'utf8'),
34
+ ];
35
+
36
+ for (const root of roots) {
37
+ expect(root).toMatch(/let clearcoatDirect = directionalShadow \* evalDirectionalNoShadow\(/);
38
+ expect(root).toContain('clearcoatRoughnessValue * clearcoatRoughnessValue');
39
+ expect(root).toContain('clearcoatIbl * skyColor * skylight.intensity');
40
+ expect(root).not.toContain('clearcoatIbl * skylight.colorR');
41
+ }
42
+ });
43
+
44
+ it('keeps rigid and skinned physical lighting on the same spot/transmission contract', () => {
45
+ const standard = readFileSync(new URL('../default-standard-pbr.wgsl', import.meta.url), 'utf8');
46
+ const skin = readFileSync(
47
+ new URL('../default-standard-pbr-skin.wgsl', import.meta.url),
48
+ 'utf8',
49
+ );
50
+
51
+ for (const root of [standard, skin]) {
52
+ expect(root).toContain(
53
+ 'let directionalBase = evalDirectionalNoShadow(physicalNormal, v, diffuseAlbedo, metallic, a, f0);',
54
+ );
55
+ expect(root).toContain('in.ndc.xyz, standardViewZ(in), in.worldPos, physicalNormal, v,');
56
+ expect(root).toContain('let safeIor = max(finiteScalar(material.ior, 1.5), 1.0);');
57
+ expect(root).toContain('transmissionBackdropTexture');
58
+ expect(root).toContain('let transmittedEnergy = select(');
59
+ }
60
+ expect(skin).toContain(
61
+ 'var anisotropyStrength = finiteScalar(material.anisotropyStrength, 0.0);',
62
+ );
63
+ expect(skin).toContain('var anisotropyDirection = vec2<f32>(1.0, 0.0);');
64
+ });
65
+ });
@@ -16,8 +16,9 @@ describe('Probe diffuse shader composition', () => {
16
16
  expect(standard).toContain('#ifdef PROBE_BLEND_AVAILABLE');
17
17
  expect(standard).toContain('@group(3) @binding(1) var<storage, read> probeBlendRecords');
18
18
  expect(standard).not.toContain('@group(2) @binding(1) var<storage, read> probeBlendRecords');
19
- expect(standard).toContain('evaluateProbeDiffuse(probeShPreblend');
20
- expect(skin).toContain('evaluateProbeDiffuse(probeShPreblend');
19
+ expect(standard).toContain('fn composeProbeDiffuse(');
20
+ expect(skin).toContain('fn composeProbeDiffuse(');
21
+ expect(probe).toContain('fn evaluateProbeDiffuse(');
21
22
  expect(skin).toContain('@group(3) @binding(1) var<storage, read> probeBlendRecords');
22
23
  });
23
24
 
@@ -14,16 +14,15 @@ function device(): ShaderRegistryDevice {
14
14
  };
15
15
  }
16
16
 
17
- function variant(definesKey: string, transmission: boolean) {
17
+ function variant(definesKey: string, cluster: boolean) {
18
18
  return {
19
19
  definesKey,
20
20
  defines: {
21
- CLUSTER_FORWARD_AVAILABLE: false,
21
+ CLUSTER_FORWARD_AVAILABLE: cluster,
22
22
  STORAGE_BUFFER_AVAILABLE: true,
23
- TRANSMISSION_AVAILABLE: transmission,
24
23
  VERTEX_COLOR_AVAILABLE: false,
25
24
  },
26
- composedWgsl: `standard-${transmission}`,
25
+ composedWgsl: `standard-${cluster}`,
27
26
  };
28
27
  }
29
28
 
@@ -47,19 +46,18 @@ async function load(materialShaders: readonly unknown[]) {
47
46
 
48
47
  describe('Standard transmission manifest validation', () => {
49
48
  const falseKey =
50
- 'CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true+TRANSMISSION_AVAILABLE=false+VERTEX_COLOR_AVAILABLE=false';
49
+ 'CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true+VERTEX_COLOR_AVAILABLE=false';
51
50
  const trueKey =
52
- 'CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true+TRANSMISSION_AVAILABLE=true+VERTEX_COLOR_AVAILABLE=false';
51
+ 'CLUSTER_FORWARD_AVAILABLE=true+STORAGE_BUFFER_AVAILABLE=true+VERTEX_COLOR_AVAILABLE=false';
53
52
 
54
- it('accepts one Standard row with both exact transmission states', async () => {
53
+ it('accepts one Standard row with the static capability/geometry states', async () => {
55
54
  const result = await load([standardRow([variant(falseKey, false), variant(trueKey, true)])]);
56
55
  expect(result.ok).toBe(true);
57
56
  });
58
57
 
59
- it('rejects a Standard row when either transmission state is missing', async () => {
58
+ it('does not require a runtime transmission axis for a Standard row', async () => {
60
59
  const result = await load([standardRow([variant(falseKey, false)])]);
61
- expect(result.ok).toBe(false);
62
- if (!result.ok) expect(result.error.code).toBe('manifest-malformed');
60
+ expect(result.ok).toBe(true);
63
61
  });
64
62
 
65
63
  it('rejects duplicate material rows and duplicate variant keys', async () => {
@@ -22,6 +22,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
22
22
  import type { ShaderError } from '../errors.js';
23
23
  import { err as errResult, ok as okResult } from '../errors.js';
24
24
  import { TONEMAP_LUMINANCE_EPSILON } from '../index.js';
25
+ import { STANDARD_PIPELINE_PARAM_SCHEMA } from '../material-schemas.js';
25
26
  import {
26
27
  registerDefaultStandardPbrSkin,
27
28
  type SkinCaps,
@@ -412,9 +413,45 @@ import {
412
413
  it('pads ibl-prefilter PrefilterUniforms to a 16-byte block', () => {
413
414
  const src = readSource('ibl-prefilter.wgsl');
414
415
  expect(src).toMatch(
415
- /struct\s+PrefilterUniforms\s*\{[\s\S]*?roughness:\s*f32,[\s\S]*?faceSize:\s*f32,[\s\S]*?_pad0:\s*f32,[\s\S]*?_pad1:\s*f32,/,
416
+ /struct\s+PrefilterUniforms\s*\{[\s\S]*?roughness:\s*f32,[\s\S]*?faceSize:\s*f32,[\s\S]*?sourceMipLevelCount:\s*f32,[\s\S]*?_pad1:\s*f32,/,
416
417
  );
417
418
  });
419
+
420
+ it('bounds prefilter mip selection to the allocated cube levels', () => {
421
+ const src = readSource('ibl-prefilter.wgsl');
422
+ expect(src).toContain('PREFILTER_MIP_LEVEL_COUNT');
423
+ expect(src).toMatch(/sourceMipLevelCount:\s*f32,/);
424
+ expect(src).toMatch(
425
+ /clamp\(\s*requestedMipLevel,\s*0\.0,\s*max\(\s*prefUniforms\.sourceMipLevelCount\s*-\s*1\.0,\s*0\.0\s*\),\s*\)/s,
426
+ );
427
+ expect(src).toMatch(/max\(4\.0\s*\*\s*HdotV,\s*0\.0001\)/);
428
+ });
429
+
430
+ it('evaluates the real Hammersley domain without a zero GGX denominator', () => {
431
+ const radicalInverseVdC = (bits: number) => {
432
+ let value = bits >>> 0;
433
+ value = ((value << 16) | (value >>> 16)) >>> 0;
434
+ value = (((value & 0x55555555) << 1) | ((value & 0xaaaaaaaa) >>> 1)) >>> 0;
435
+ value = (((value & 0x33333333) << 2) | ((value & 0xcccccccc) >>> 2)) >>> 0;
436
+ value = (((value & 0x0f0f0f0f) << 4) | ((value & 0xf0f0f0f0) >>> 4)) >>> 0;
437
+ value = (((value & 0x00ff00ff) << 8) | ((value & 0xff00ff00) >>> 8)) >>> 0;
438
+ return value * 2.3283064365386963e-10;
439
+ };
440
+ const sampleCount = 1024;
441
+ const roughness = 0;
442
+ let maximumXiY = 0;
443
+ let minimumDenominator = Number.POSITIVE_INFINITY;
444
+ for (let index = 0; index < sampleCount; index += 1) {
445
+ const xiY = radicalInverseVdC(index);
446
+ const denominator = 1 + (roughness ** 4 - 1) * xiY;
447
+ maximumXiY = Math.max(maximumXiY, xiY);
448
+ minimumDenominator = Math.min(minimumDenominator, denominator);
449
+ expect(Number.isFinite(denominator)).toBe(true);
450
+ }
451
+ expect(maximumXiY).toBeLessThan(1);
452
+ expect(minimumDenominator).toBeGreaterThan(0);
453
+ expect(minimumDenominator).toBeCloseTo(1 / sampleCount, 12);
454
+ });
418
455
  });
419
456
 
420
457
  describe('t41 (a) -- ibl-sampling exports sampleIblDiffuse + sampleIblSpecular', () => {
@@ -754,8 +791,7 @@ import {
754
791
  // Structural source-level assertions (same pattern as tbn-lighting-helpers.test.ts):
755
792
  // (a) common.wgsl declares @group(3) instances binding under STORAGE_BUFFER_AVAILABLE
756
793
  // (b) default-standard-pbr.wgsl and unlit.wgsl each carry exactly one
757
- // #pragma variant_axis STORAGE_BUFFER_AVAILABLE plus the
758
- // reflection-fallback axis owned by the Standard PBR shader
794
+ // #pragma variant_axis STORAGE_BUFFER_AVAILABLE (no second axis)
759
795
  // (c) No shader introduces a new variant axis like INSTANCE_STORAGE_AVAILABLE (OOS-6)
760
796
  //
761
797
  // RED before w3 (common.wgsl has no @group(3) yet); GREEN after w3 adds it.
@@ -813,7 +849,7 @@ import {
813
849
  expect(src).toMatch(/#if\s+STORAGE_BUFFER_AVAILABLE\s*==\s*true[\s\S]*@group\(3\)/);
814
850
  });
815
851
 
816
- it('(b) default-standard-pbr.wgsl declares its complete variant-axis contract', () => {
852
+ it('(b) default-standard-pbr.wgsl declares its complete capability variant-axis contract', () => {
817
853
  // feat-20260609-hdrp-cluster-fragment-ggx M1: added CLUSTER_FORWARD_AVAILABLE as a
818
854
  // second variant axis so the standard-pbr fragment can switch between URP-style
819
855
  // (single dirlight + ambient) and HDRP-style (cluster forward 256 punctual lights).
@@ -952,42 +988,6 @@ import {
952
988
  // - R-10 paramSchema reuse assertion
953
989
  // - plan-strategy section 5.3 critical test point register
954
990
 
955
- // Inline summary of the paramSchema fields for verification — mirrors
956
- // default-standard-pbr.material.json paramSchema[] field-for-field.
957
- // feat-20260613 fix-issue-1 (D-8): channelMap split into 4 f32 selectors;
958
- // emissive / emissiveIntensity / occlusionStrength are schema-owned values;
959
- // normalScale and the engine-injection textures remain in the same contract.
960
- const EXPECTED_PARAM_NAMES = [
961
- 'baseColor',
962
- 'metallic',
963
- 'roughness',
964
- 'metallicChannel',
965
- 'roughnessChannel',
966
- 'aoChannel',
967
- 'extraChannel',
968
- 'emissive',
969
- 'emissiveIntensity',
970
- 'occlusionStrength',
971
- 'alphaCutoff',
972
- 'clearcoat',
973
- 'clearcoatRoughness',
974
- 'specularTint',
975
- 'normalScale',
976
- 'transmission',
977
- 'ior',
978
- 'thickness',
979
- 'attenuationColor',
980
- 'attenuationDistance',
981
- 'baseColorTexture',
982
- 'metallicRoughnessTexture',
983
- 'normalTexture',
984
- 'specularTintTexture',
985
- 'emissiveTexture',
986
- 'occlusionTexture',
987
- 'transmissionTexture',
988
- 'thicknessTexture',
989
- ] as const;
990
-
991
991
  const STUB_WGSL = '// stub composed pbr-skin wgsl\n';
992
992
  const STORAGE_CAPS: SkinCaps = { storageBuffer: true };
993
993
 
@@ -1044,56 +1044,23 @@ import {
1044
1044
  const registry = makeMockRegistry();
1045
1045
  registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1046
1046
  const entry = lookup(registry);
1047
- expect(entry.paramSchema).toHaveLength(28);
1047
+ expect(entry.paramSchema).toHaveLength(STANDARD_PIPELINE_PARAM_SCHEMA.length);
1048
1048
  });
1049
1049
 
1050
1050
  it('paramSchema names match default-standard-pbr schema field-for-field', () => {
1051
1051
  const registry = makeMockRegistry();
1052
1052
  registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1053
1053
  const entry = lookup(registry);
1054
- const names = entry.paramSchema.map((p) => p.name);
1055
- expect(names).toEqual(EXPECTED_PARAM_NAMES);
1054
+ expect(entry.paramSchema).toEqual(STANDARD_PIPELINE_PARAM_SCHEMA);
1056
1055
  });
1057
1056
 
1058
1057
  it('paramSchema types match expected value-level types', () => {
1059
1058
  const registry = makeMockRegistry();
1060
1059
  registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1061
1060
  const entry = lookup(registry);
1062
- const types = entry.paramSchema.map((p) => p.type);
1063
- expect(types).toEqual([
1064
- 'color',
1065
- 'f32',
1066
- 'f32',
1067
- // 4 channel selectors (D-8 split).
1068
- 'f32',
1069
- 'f32',
1070
- 'f32',
1071
- 'f32',
1072
- // emissive vec3 + emissiveIntensity + occlusionStrength.
1073
- 'vec3',
1074
- 'f32',
1075
- 'f32',
1076
- // alpha cutoff + clearcoat controls.
1077
- 'f32',
1078
- 'f32',
1079
- 'f32',
1080
- // specular tint, transmission, and attenuation controls.
1081
- 'vec3',
1082
- 'f32',
1083
- 'f32',
1084
- 'f32',
1085
- 'f32',
1086
- 'vec3',
1087
- 'f32',
1088
- 'texture2d',
1089
- 'texture2d',
1090
- 'texture2d',
1091
- 'texture2d',
1092
- 'texture2d',
1093
- 'texture2d',
1094
- 'texture2d',
1095
- 'texture2d',
1096
- ]);
1061
+ expect(entry.paramSchema.map((p) => p.type)).toEqual(
1062
+ STANDARD_PIPELINE_PARAM_SCHEMA.map((p) => p.type),
1063
+ );
1097
1064
  });
1098
1065
  });
1099
1066
 
@@ -1764,11 +1731,10 @@ import {
1764
1731
  expect(src).toMatch(/applyTBN/);
1765
1732
  });
1766
1733
 
1767
- it('default-standard-pbr.wgsl imports directional lighting and the shared Standard Cluster owner', () => {
1734
+ it('default-standard-pbr.wgsl imports directional lighting and the shared cluster owner', () => {
1768
1735
  const src = readSource('default-standard-pbr.wgsl');
1769
1736
  expect(src).toMatch(/#import\s+forgeax_pbr::lighting_directional::/);
1770
1737
  expect(src).toMatch(/#import\s+forgeax_standard::cluster::/);
1771
- expect(src).toMatch(/evaluateStandardClusterLights/);
1772
1738
  });
1773
1739
 
1774
1740
  it('default-standard-pbr.wgsl no longer defines fn evalDirectional / evalPoint / evalSpot inline', () => {
@@ -1779,7 +1745,7 @@ import {
1779
1745
  expect(codeOnly).not.toMatch(/fn\s+evalPunctualBody\s*\(/);
1780
1746
  });
1781
1747
 
1782
- it('built-in PBR shaders evaluate directional CSM once and reuse it for clearcoat', () => {
1748
+ it('built-in PBR shaders evaluate directional CSM for base and clearcoat contributions', () => {
1783
1749
  for (const file of ['default-standard-pbr.wgsl', 'default-standard-pbr-skin.wgsl']) {
1784
1750
  const codeOnly = stripComments(readSource(file));
1785
1751
  expect(codeOnly.match(/evalDirectionalShadowFactor\s*\(/g)).toHaveLength(1);
@@ -1867,10 +1833,9 @@ import {
1867
1833
  // ground-truth table (research F1 section 6 - "extended Reinhard
1868
1834
  // matches Reinhard 2002 luminance variant"). The TS port shadowed
1869
1835
  // below is the formula manifest.
1870
- // 2. TonemapParams std140 layout is 16 B (exposure + whitePoint + mode
1871
- // + ditherEnabled = 16 B). The shader-side struct in tonemap.wgsl
1872
- // keeps the same byte layout; the final output pass toggles only the
1873
- // ditherEnabled slot in a per-pass copy.
1836
+ // 2. TonemapParams std140 layout is 16 B (exposure + whitePoint
1837
+ // + 2 padding f32 = 16 B). The shader-side struct in tonemap.wgsl
1838
+ // keeps the same byte layout.
1874
1839
  // 3. TONEMAP_LUMINANCE_EPSILON is the shared TS / WGSL const
1875
1840
  // (D-O3, 1e-5).
1876
1841
  //
@@ -1964,10 +1929,10 @@ import {
1964
1929
  });
1965
1930
 
1966
1931
  describe('TonemapParams std140 layout', () => {
1967
- it('TonemapParams stride is 16 B (exposure + whitePoint + mode + ditherEnabled)', () => {
1932
+ it('TonemapParams stride is 16 B (4 f32 = exposure + whitePoint + 2 padding)', () => {
1968
1933
  // The host writes `Float32Array` of length 4 to the UBO; the shader
1969
1934
  // declares `struct TonemapParams { exposure: f32, whitePoint: f32,
1970
- // mode: u32, ditherEnabled: f32 }` — total = 16 B.
1935
+ // _pad0: f32, _pad1: f32 }` — total = 16 B.
1971
1936
  const stride = 4 * 4;
1972
1937
  expect(stride).toBe(16);
1973
1938
  });
@@ -31,7 +31,8 @@ describe('Cookie RGBA Spot modifier composition', () => {
31
31
  expect(modifiers).toContain('cookieSample.rgb * cookieSample.a');
32
32
  expect(modifiers).not.toMatch(/textureSampleLevel\(cookieTexture[^\n]*\)\.r/);
33
33
  expect(modifiers).toContain('cookieMatrices[metadata.w]');
34
- expect(modifiers).toContain('metadata.w != 0xffffffffu && (metadata.y & PROJECTOR_FLAG) == 0u');
34
+ expect(modifiers).toContain('metadata.w != 0xffffffffu');
35
+ expect(modifiers).toContain('metadata.y == 0xffffffffu || (metadata.y & PROJECTOR_FLAG) == 0u');
35
36
  expect(modifiers).toContain('let right = normalize(cross(forward, referenceUp));');
36
37
  expect(modifiers).toContain('let depth = dot(outgoing, forward);');
37
38
  expect(modifiers).toContain(
@@ -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,17 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const shaderFiles = ['default-standard-pbr.wgsl', 'default-standard-pbr-skin.wgsl'] as const;
6
+
7
+ describe('Standard WebGL2 inter-stage varying budget', () => {
8
+ it.each(shaderFiles)('%s packs Surface position and view depth into the last varying', (file) => {
9
+ const source = readFileSync(resolve(import.meta.dirname, '..', file), 'utf8');
10
+
11
+ expect(source).toContain('@location(7) positionOSAndViewZ : vec4<f32>');
12
+ expect(source).not.toMatch(/@location\(15\)\s+positionOS\s*:/u);
13
+ expect(source).toContain('out.positionOSAndViewZ = vec4<f32>(in.pos, sceneViewZ(');
14
+ expect(source).toContain('in.positionOSAndViewZ.xyz');
15
+ expect(source).toContain('in.positionOSAndViewZ.w');
16
+ });
17
+ });