@forgeax/engine-shader 0.1.21 → 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 (83) hide show
  1. package/README.md +14 -5
  2. package/dist/ShaderRegistry.d.ts.map +1 -1
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +33042 -55
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/ltc/provenance.d.ts +17 -0
  8. package/dist/ltc/provenance.d.ts.map +1 -0
  9. package/dist/ltc/tables.d.ts +22 -0
  10. package/dist/ltc/tables.d.ts.map +1 -0
  11. package/dist/material/artifact-types.d.ts +52 -0
  12. package/dist/material/artifact-types.d.ts.map +1 -1
  13. package/dist/material-schemas.d.ts +36 -0
  14. package/dist/material-schemas.d.ts.map +1 -1
  15. package/dist/register-default-sprite-lit.d.ts +3 -6
  16. package/dist/register-default-sprite-lit.d.ts.map +1 -1
  17. package/dist/types.d.ts +2 -1
  18. package/dist/types.d.ts.map +1 -1
  19. package/package.json +4 -4
  20. package/src/ShaderRegistry.ts +0 -17
  21. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +9 -3
  22. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +67 -122
  23. package/src/__tests__/deferred-lighting-ssao.test.ts +3 -3
  24. package/src/__tests__/direct-light-layout.unit.test.ts +40 -0
  25. package/src/__tests__/ibl-irradiance.unit.test.ts +16 -4
  26. package/src/__tests__/ibl-sampling.unit.test.ts +3 -4
  27. package/src/__tests__/lighting-directional.unit.test.ts +36 -0
  28. package/src/__tests__/lighting-point-spot-volume.unit.test.ts +8 -2
  29. package/src/__tests__/lighting-punctual.unit.test.ts +40 -5
  30. package/src/__tests__/lighting-spot-volume-attenuation.unit.test.ts +1 -1
  31. package/src/__tests__/ltc-provenance.unit.test.ts +33 -0
  32. package/src/__tests__/manifest.fixture.json +33 -0
  33. package/src/__tests__/material-builtins.unit.test.ts +21 -3
  34. package/src/__tests__/material-contract.unit.test.ts +188 -14
  35. package/src/__tests__/physical-clearcoat.unit.test.ts +65 -0
  36. package/src/__tests__/probe-lighting-composition.unit.test.ts +39 -0
  37. package/src/__tests__/rect-area-brdf.unit.test.ts +36 -0
  38. package/src/__tests__/reflection-probe-sampling.unit.test.ts +24 -2
  39. package/src/__tests__/shader-manifest-transmission.unit.test.ts +8 -10
  40. package/src/__tests__/shader.unit.test.ts +54 -85
  41. package/src/__tests__/spot-cookie-composition.unit.test.ts +58 -0
  42. package/src/__tests__/spot-modifier-composition.unit.test.ts +19 -0
  43. package/src/__tests__/sprite-variants.unit.test.ts +4 -1
  44. package/src/__tests__/standard-webgl2-varying-budget.unit.test.ts +17 -0
  45. package/src/__tests__/surface-v1-negative.unit.test.ts +37 -0
  46. package/src/__tests__/surface-v1.unit.test.ts +83 -0
  47. package/src/__tests__/transmission-thickness-scale.unit.test.ts +2 -6
  48. package/src/__tests__/vertex-color-variant.unit.test.ts +16 -5
  49. package/src/brdf.wgsl +115 -1
  50. package/src/common.wgsl +53 -79
  51. package/src/default-standard-pbr-skin.wgsl +687 -144
  52. package/src/default-standard-pbr.wgsl +600 -247
  53. package/src/default_standard_surface.wgsl +88 -0
  54. package/src/hdrp-cluster-forward.wgsl +225 -0
  55. package/src/ibl-irradiance.wgsl +47 -9
  56. package/src/ibl-prefilter.wgsl +13 -3
  57. package/src/ibl-sampling.wgsl +2 -2
  58. package/src/ibl-shared.wgsl +9 -2
  59. package/src/index.ts +23 -0
  60. package/src/lighting-directional.wgsl +9 -117
  61. package/src/lighting-probe.wgsl +51 -0
  62. package/src/lighting-punctual.wgsl +21 -11
  63. package/src/lighting-rect-area.wgsl +162 -0
  64. package/src/lighting-spot-modifiers.wgsl +100 -0
  65. package/src/lighting-spot-projector.wgsl +31 -0
  66. package/src/ltc/provenance.ts +19 -0
  67. package/src/ltc/tables.ts +2831 -0
  68. package/src/material/artifact-types.ts +137 -0
  69. package/src/material/physical/anisotropy.wgsl +26 -0
  70. package/src/material/physical/clearcoat.wgsl +24 -0
  71. package/src/material/physical/iridescence.wgsl +23 -0
  72. package/src/material/physical/sheen.wgsl +17 -0
  73. package/src/material-schemas.ts +77 -30
  74. package/src/register-default-sprite-lit.ts +3 -6
  75. package/src/register-default-standard-pbr-skin.ts +2 -2
  76. package/src/sprite-lit.wgsl +3 -3
  77. package/src/sprite.wgsl +1 -1
  78. package/src/standard-cluster.wgsl +80 -78
  79. package/src/surface_v1.wgsl +26 -0
  80. package/src/types.ts +2 -1
  81. package/src/volume/volume-inject.wgsl +34 -36
  82. package/src/volume/volume-integrate.wgsl +48 -38
  83. package/dist/.tsbuildinfo +0 -1
@@ -29,12 +29,11 @@ beforeAll(async () => {
29
29
  describe('IBL diffuse sampling payload contract', () => {
30
30
  it('names the sampled payload as E over pi at the canonical sampling owner', () => {
31
31
  expect(samplingSource).toContain('irradianceEOverPi');
32
- expect(samplingSource).toMatch(/irradianceEOverPi\s*\/\s*PI/);
32
+ expect(samplingSource).toMatch(/return\s+irradianceEOverPi\s*;/);
33
33
  });
34
34
 
35
- it('does not divide the same diffuse payload twice', () => {
35
+ it('does not divide the already Lambert-normalized diffuse payload again', () => {
36
36
  const diffuseBody = samplingSource.match(/fn sampleIblDiffuse\([\s\S]*?\n\}/)?.[0] ?? '';
37
- expect(diffuseBody.match(/\/\s*PI/g) ?? []).toHaveLength(1);
38
- expect(diffuseBody).not.toMatch(/irradianceEOverPi\s*\/\s*PI[\s\S]*\/\s*PI/);
37
+ expect(diffuseBody).not.toMatch(/\/\s*PI/);
39
38
  });
40
39
  });
@@ -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
+ });
@@ -24,8 +24,14 @@ describe('shared punctual optics for volumetric fog', () => {
24
24
  expect(inject).toContain('evalVolumeSpot');
25
25
  expect(integrate).toContain('evalVolumePoint');
26
26
  expect(integrate).toContain('evalVolumeSpot');
27
- expect(inject).toContain('pointLightsBuffer');
28
- expect(inject).toContain('spotLightsBuffer');
27
+ expect(inject).toContain('light_data');
28
+ expect(inject).toContain('cluster_uniform');
29
+ expect(inject).not.toContain('pointLightsBuffer');
30
+ expect(inject).not.toContain('spotLightsBuffer');
31
+ expect(integrate).toContain('light_data');
32
+ expect(integrate).toContain('cluster_uniform');
33
+ expect(integrate).not.toContain('pointLightsBuffer');
34
+ expect(integrate).not.toContain('spotLightsBuffer');
29
35
  });
30
36
 
31
37
  it('keeps volume View aligned with the directional filter carrier', () => {
@@ -8,7 +8,12 @@ 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');
16
+ const brdfShaderPath = join(dirname(fileURLToPath(import.meta.url)), '../brdf.wgsl');
12
17
  const directionalShaderPath = join(
13
18
  dirname(fileURLToPath(import.meta.url)),
14
19
  '../lighting-directional.wgsl',
@@ -42,10 +47,30 @@ describe('direct punctual lighting shader contract', () => {
42
47
  expect(source).not.toContain('normalize(lightDir)');
43
48
  });
44
49
 
50
+ it('shares Three r184 direct-PBR Fresnel and multiscatter ownership', async () => {
51
+ const [source, brdf, directional] = await Promise.all([
52
+ readFile(shaderPath, 'utf8'),
53
+ readFile(brdfShaderPath, 'utf8'),
54
+ readFile(directionalShaderPath, 'utf8'),
55
+ ]);
56
+
57
+ expect(brdf).toContain('exp2((-5.55473 * vDotH - 6.98316) * vDotH)');
58
+ expect(brdf).toContain('fn threeR184DirectMultiScatter(');
59
+ expect(brdf).toContain('energyLoss * favg * favg + vec3<f32>(1e-6)');
60
+ expect(brdf).not.toContain('energyLoss * favg + vec3<f32>(1e-6)');
61
+ expect(source).toContain('threeR184DirectMultiScatter(roughness, nDotV, nDotL, F0)');
62
+ expect(source).toContain('let diffuse = (1.0 - metallic) * baseColor / 3.14159265;');
63
+ expect(source).toContain('return factor * factor / dSquared;');
64
+ expect(source).not.toContain('let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);');
65
+ expect(directional).toContain('threeR184DirectMultiScatter');
66
+ expect(directional).not.toContain('THREE_R184_DFG_LUT');
67
+ });
68
+
45
69
  it('keeps cluster punctual evaluation in the shared lighting owner', async () => {
46
70
  const source = await readFile(standardClusterShaderPath, 'utf8');
47
71
 
48
- expect(source).toContain('kind_and_shadow');
72
+ expect(source).toContain('DirectLightSlot');
73
+ expect(source).toContain('light.metadata.x');
49
74
  expect(source).toContain('evalPoint');
50
75
  expect(source).toContain('evalSpot');
51
76
  expect(source).toContain('evalSpotShadowed');
@@ -57,23 +82,33 @@ describe('direct punctual lighting shader contract', () => {
57
82
  it('decodes HDRP spot cone lanes in the shared evaluator order', async () => {
58
83
  const source = await readFile(standardClusterShaderPath, 'utf8');
59
84
 
60
- expect(source).toContain('light.color.w, light.direction.w, light.position.w');
85
+ expect(source).toContain('light.colorTimesIntensity.w, light.direction.w, light.position.w');
61
86
  expect(source).not.toContain('light.direction.w, light.color.w, light.position.w');
62
87
  });
63
88
 
64
89
  it('keeps the unshadowed spot sentinel out of the projector flag branch', async () => {
65
- const source = await readFile(standardClusterShaderPath, 'utf8');
90
+ const [source, projector] = await Promise.all([
91
+ readFile(standardClusterShaderPath, 'utf8'),
92
+ readFile(spotProjectorShaderPath, 'utf8'),
93
+ ]);
66
94
 
67
95
  expect(source).toContain('if (encoded_tile >= 0 && (encoded_tile & PROJECTOR_FLAG) != 0)');
96
+ expect(source).toContain(
97
+ 'sampleStandardSpotProjector(view.spotLightViewProj[tile], world_pos, light.metadata)',
98
+ );
99
+ expect(projector).toContain(
100
+ 'textureSampleLevel(cookieTexture, spotModifierSampler, uv, metadata.w, 0.0).rgb',
101
+ );
68
102
  });
69
103
 
70
104
  it('passes precomputed base and clearcoat roughness/F0 facts to cluster lights', async () => {
71
105
  const source = await readFile(pbrShaderPath, 'utf8');
72
106
 
73
107
  expect(source).toMatch(
74
- /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,
75
109
  );
76
- expect(source).toContain('coatAlpha, vec3<f32>(0.04)');
110
+ expect(source).toContain('clearcoatAlpha,');
111
+ expect(source).toContain('vec3<f32>(0.04),');
77
112
  });
78
113
 
79
114
  it('requires one shared Directional PCSS orchestration owner', async () => {
@@ -14,7 +14,7 @@ describe('volume Spot attenuation precision', () => {
14
14
  expect(volumeInject).toContain('spot_shadow_visibility_at');
15
15
  expect(volumeIntegrate).toContain('evalSpotAttenuation');
16
16
  expect(volumeIntegrate).toContain('spot_attenuation_at');
17
- expect(volumeIntegrate).toContain('light.invRangeSquared');
17
+ expect(volumeIntegrate).toContain('light.position.w');
18
18
  // The paired Point+Spot owner applies visibility only to the Spot term;
19
19
  // the Point radiance remains independent of the spot shadow. Keep the
20
20
  // assertion at the semantic call boundary so WGSL formatting changes do
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ generateLtcTables,
4
+ hashLtcTable,
5
+ LTC_SOURCE_PROVENANCE,
6
+ LTC_TABLE_HASHES,
7
+ LTC_TABLE_HEIGHT,
8
+ LTC_TABLE_WIDTH,
9
+ LTC_TABLES,
10
+ } from '../ltc/tables';
11
+
12
+ describe('LTC provenance and tracked data contract', () => {
13
+ it('pins the fixed Three.js reference and two 64 by 64 rgba16float tables', () => {
14
+ expect(LTC_SOURCE_PROVENANCE.source).toBe('Three.js');
15
+ expect(LTC_SOURCE_PROVENANCE.revision).toBe('r184');
16
+ expect(LTC_SOURCE_PROVENANCE.generator).toContain('deterministic');
17
+ expect({ width: LTC_TABLE_WIDTH, height: LTC_TABLE_HEIGHT }).toEqual({ width: 64, height: 64 });
18
+ expect(LTC_TABLES.lambert.byteLength).toBe(64 * 64 * 4 * 2);
19
+ expect(LTC_TABLES.ggx.byteLength).toBe(64 * 64 * 4 * 2);
20
+ });
21
+
22
+ it('keeps generation deterministic and hashes the tracked outputs', () => {
23
+ const first = generateLtcTables();
24
+ const second = generateLtcTables();
25
+
26
+ expect(first).toEqual(second);
27
+ expect(first).toEqual(LTC_TABLES);
28
+ expect(hashLtcTable(first.lambert)).toBe(LTC_TABLE_HASHES.lambert);
29
+ expect(hashLtcTable(first.ggx)).toBe(LTC_TABLE_HASHES.ggx);
30
+ expect(LTC_TABLE_HASHES.lambert).toMatch(/^[a-f0-9]{64}$/);
31
+ expect(LTC_TABLE_HASHES.ggx).toMatch(/^[a-f0-9]{64}$/);
32
+ });
33
+ });
@@ -18,5 +18,38 @@
18
18
  "glsl": null,
19
19
  "bindings": "[]"
20
20
  }
21
+ ],
22
+ "materialShaders": [
23
+ {
24
+ "identifier": "forgeax::default-standard-pbr",
25
+ "sourcePath": "default-standard-pbr.wgsl",
26
+ "composedWgsl": "@vertex fn vs_main() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }",
27
+ "paramSchema": "[]",
28
+ "variants": [],
29
+ "directEntry": "vs_main",
30
+ "sceneIndexEntry": "vs_scene_index",
31
+ "materialRow": { "byteLength": 384, "fields": ["baseColor", "alphaCutoff"] },
32
+ "resourceSlots": [],
33
+ "uvSets": [],
34
+ "vertexInputs": [
35
+ { "semantic": "position", "location": 0, "format": "float32x3" },
36
+ { "semantic": "normal", "location": 1, "format": "float32x3" },
37
+ { "semantic": "uv", "location": 2, "format": "float32x2" },
38
+ { "semantic": "tangent", "location": 3, "format": "float32x4" }
39
+ ],
40
+ "alphaMask": { "cutoff": "alphaCutoff", "source": "baseColor.a" },
41
+ "reflection": {
42
+ "layoutIdentity": "standard-pbr/material-row-v1",
43
+ "resourceSlots": [],
44
+ "vertexInputs": [
45
+ { "semantic": "position", "location": 0, "format": "float32x3" },
46
+ { "semantic": "normal", "location": 1, "format": "float32x3" },
47
+ { "semantic": "uv", "location": 2, "format": "float32x2" },
48
+ { "semantic": "tangent", "location": 3, "format": "float32x4" }
49
+ ]
50
+ },
51
+ "receiptIdentity": "standard-pbr/material-row-v1",
52
+ "generation": 1
53
+ }
21
54
  ]
22
55
  }
@@ -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', () => {
@@ -102,7 +102,11 @@ describe('standard PBR transmission material contract', () => {
102
102
  });
103
103
 
104
104
  it('keeps the seven transmission inputs in the reflected UBO and coordinate contract', {
105
- timeout: 20_000,
105
+ // This test intentionally invokes the full engine shader manifest
106
+ // producer. Keep its budget local to the producer-backed contract so a
107
+ // concurrent coverage child cannot turn host CPU contention into a false
108
+ // product failure.
109
+ timeout: 60_000,
106
110
  }, async () => {
107
111
  const derived = derive(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
108
112
  const transmissionNames = [
@@ -146,6 +150,114 @@ describe('standard PBR transmission material contract', () => {
146
150
  kind: 'texture',
147
151
  binding: 16,
148
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
+ },
149
261
  ]);
150
262
 
151
263
  const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
@@ -155,27 +267,89 @@ describe('standard PBR transmission material contract', () => {
155
267
  );
156
268
  expect(material).toBeDefined();
157
269
  if (material === undefined) return;
158
- const transmissionVariant = material.variants.find(
270
+ const baseVariant = material.variants.find(
159
271
  (variant) =>
160
272
  variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
161
273
  variant.defines.STORAGE_BUFFER_AVAILABLE === true &&
162
- variant.defines.TRANSMISSION_AVAILABLE === true &&
274
+ variant.defines.TRANSMISSION_AVAILABLE === false &&
163
275
  variant.defines.VERTEX_COLOR_AVAILABLE === false,
164
276
  );
165
- expect(transmissionVariant).toBeDefined();
166
- if (transmissionVariant === undefined) return;
167
- expect(derived.totalBytes).toBe(384);
168
- expect(transmissionVariant.composedWgsl).toMatch(
169
- /@group\(1\)\s*@binding\(13\)\s*var transmissionSampler/,
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,
170
286
  );
171
- expect(transmissionVariant.composedWgsl).toMatch(
172
- /@group\(1\)\s*@binding\(14\)\s*var transmissionTexture/,
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,
173
289
  );
174
- expect(transmissionVariant.composedWgsl).toMatch(
175
- /@group\(1\)\s*@binding\(15\)\s*var thicknessSampler/,
290
+ expect(baseVariant.composedWgsl).toMatch(
291
+ /@group\(1\)\s*@binding\(15\)\s*\n?\s*var\s+irradianceMap/u,
176
292
  );
177
- expect(transmissionVariant.composedWgsl).toMatch(
178
- /@group\(1\)\s*@binding\(16\)\s*var thicknessTexture/,
293
+ });
294
+
295
+ it('publishes one receipt for direct and scene-index consumers', {
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,
302
+ }, async () => {
303
+ const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
304
+ const manifest = await buildEngineShaderManifest();
305
+ const standard = manifest.materialShaders.find(
306
+ (entry) => entry.identifier === 'forgeax::default-standard-pbr',
307
+ );
308
+ const skin = manifest.materialShaders.find((entry) => entry.identifier === 'forgeax::pbr-skin');
309
+ expect(standard).toBeDefined();
310
+ expect(skin).toBeDefined();
311
+ expect(standard).toMatchObject({
312
+ directEntry: expect.any(String),
313
+ sceneIndexEntry: expect.any(String),
314
+ materialRow: expect.any(Object),
315
+ resourceSlots: expect.any(Array),
316
+ uvSets: expect.any(Array),
317
+ vertexInputs: expect.any(Array),
318
+ alphaMask: expect.any(Object),
319
+ reflection: expect.any(Object),
320
+ });
321
+ expect(skin?.receiptIdentity).toBe(standard?.receiptIdentity);
322
+ });
323
+
324
+ it('composes the scene-index entry into the same validated PBR artifact as the direct entry', {
325
+ timeout: 60_000,
326
+ }, async () => {
327
+ const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
328
+ const manifest = await buildEngineShaderManifest();
329
+ const standard = manifest.materialShaders.find(
330
+ (entry) => entry.identifier === 'forgeax::default-standard-pbr',
331
+ );
332
+ expect(standard).toBeDefined();
333
+ if (standard === undefined) return;
334
+ expect(standard.composedWgsl).toMatch(/@vertex\s+fn\s+vs_main\s*\(/);
335
+ expect(standard.composedWgsl).toMatch(/@vertex\s+fn\s+vs_scene_index\s*\(/);
336
+ expect(standard.composedWgsl).toMatch(/@fragment\s+fn\s+fs_main\s*\(/);
337
+ expect(standard.sceneIndexEntry).toBe('vs_scene_index');
338
+ expect(standard.reflection?.layoutIdentity).toBe(standard.receiptIdentity);
339
+ });
340
+
341
+ it('rejects a receipt that loses a required prepared ABI field', {
342
+ timeout: 60_000,
343
+ }, async () => {
344
+ const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
345
+ const manifest = await buildEngineShaderManifest();
346
+ const standard = manifest.materialShaders.find(
347
+ (entry) => entry.identifier === 'forgeax::default-standard-pbr',
179
348
  );
349
+ expect(standard).toBeDefined();
350
+ if (standard === undefined) return;
351
+ const malformed = { ...standard } as Record<string, unknown>;
352
+ delete malformed.reflection;
353
+ expect(malformed).not.toHaveProperty('reflection');
180
354
  });
181
355
  });
@@ -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
+ });
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const shaderRoot = resolve(import.meta.dirname, '..');
6
+ const probe = readFileSync(resolve(shaderRoot, 'lighting-probe.wgsl'), 'utf8');
7
+ const standard = readFileSync(resolve(shaderRoot, 'default-standard-pbr.wgsl'), 'utf8');
8
+ const skin = readFileSync(resolve(shaderRoot, 'default-standard-pbr-skin.wgsl'), 'utf8');
9
+
10
+ describe('Probe diffuse shader composition', () => {
11
+ it('evaluates one SH9 record and derives Sky as the residual', () => {
12
+ expect(probe).toContain('array<vec4<f32>, 9>');
13
+ expect(probe).toContain('sh9');
14
+ expect(probe).toContain('1.0 - localBlendFraction');
15
+ expect(probe).toContain('max(');
16
+ expect(standard).toContain('#ifdef PROBE_BLEND_AVAILABLE');
17
+ expect(standard).toContain('@group(3) @binding(1) var<storage, read> probeBlendRecords');
18
+ expect(standard).not.toContain('@group(2) @binding(1) var<storage, read> probeBlendRecords');
19
+ expect(standard).toContain('fn composeProbeDiffuse(');
20
+ expect(skin).toContain('fn composeProbeDiffuse(');
21
+ expect(probe).toContain('fn evaluateProbeDiffuse(');
22
+ expect(skin).toContain('@group(3) @binding(1) var<storage, read> probeBlendRecords');
23
+ });
24
+
25
+ it('keeps Sky out of probe admission and preserves the existing specular owner', () => {
26
+ expect(probe).not.toContain('q_sky');
27
+ expect(probe).not.toContain('for (var probe');
28
+ expect(standard).toContain('sampleIblSpecular');
29
+ expect(skin).toContain('sampleIblSpecular');
30
+ expect(standard).not.toContain('sampleProbeSpecular');
31
+ expect(skin).not.toContain('sampleProbeSpecular');
32
+ });
33
+
34
+ it('suppresses probe diffuse for metal while retaining Skylight IBL inputs', () => {
35
+ expect(probe).toContain('(1.0 - metallic)');
36
+ expect(standard).toContain('sampleIblDiffuse');
37
+ expect(skin).toContain('sampleIblDiffuse');
38
+ });
39
+ });
@@ -0,0 +1,36 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ describe('RectAreaLight LTC BRDF shader contract', () => {
5
+ it('provides one-sided Lambert and GGX LTC polygon evaluators', async () => {
6
+ const source = await readFile(new URL('../lighting-rect-area.wgsl', import.meta.url), 'utf8');
7
+
8
+ expect(source).toContain('fn evalRectAreaLtcLambert(');
9
+ expect(source).toContain('fn evalRectAreaLtcGgx(');
10
+ expect(source).toContain('fn ltcUv(');
11
+ expect(source).toContain('fn ltcEdgeVectorFormFactor(');
12
+ expect(source).toContain('fn ltcClippedSphereFormFactor(');
13
+ expect(source).toContain('fn ltcEvaluate(');
14
+ expect(source).toContain('ltcLambertTexture');
15
+ expect(source).toContain('ltcGgxTexture');
16
+ expect(source).not.toContain('LTC_LAMBERT');
17
+ expect(source).not.toContain('LTC_GGX');
18
+ expect(source).toContain('let rect0 = lightPos - halfX - halfY;');
19
+ expect(source).toContain(
20
+ 'let transform = mInv * transpose(mat3x3<f32>(tangent, bitangent, n));',
21
+ );
22
+ expect(source).toContain('sqrt(1.0 - nDotV)');
23
+ expect(source).toContain('let diffuse = baseColor * (1.0 - metallic);');
24
+ expect(source).not.toContain('viewWeight');
25
+ expect(source).not.toContain('rectAreaSolidAngle');
26
+ });
27
+
28
+ it('does not route Rect through punctual inverse-square or shadow sampling', async () => {
29
+ const source = await readFile(new URL('../lighting-rect-area.wgsl', import.meta.url), 'utf8');
30
+
31
+ expect(source).not.toContain('/ distanceSquared');
32
+ expect(source).not.toContain('shadowAtlas');
33
+ expect(source).not.toContain('evalPoint(');
34
+ expect(source).not.toContain('evalSpot(');
35
+ });
36
+ });