@forgeax/engine-shader 0.1.20 → 0.1.21

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 (38) hide show
  1. package/README.md +10 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/ShaderRegistry.d.ts.map +1 -1
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.mjs +52 -1
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +4 -4
  9. package/src/ShaderRegistry.ts +65 -0
  10. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -4
  11. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +7 -0
  12. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +69 -24
  13. package/src/__tests__/lighting-point-spot-volume.unit.test.ts +8 -0
  14. package/src/__tests__/lighting-punctual.unit.test.ts +32 -6
  15. package/src/__tests__/material-derived-builtins.integration.test.ts +27 -0
  16. package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -0
  17. package/src/__tests__/scene-temporal.unit.test.ts +20 -0
  18. package/src/__tests__/shader.unit.test.ts +34 -13
  19. package/src/__tests__/shadow-pcss.unit.test.ts +131 -0
  20. package/src/__tests__/sprite-lit-shader.test.ts +24 -11
  21. package/src/__tests__/sprite-variants.unit.test.ts +1 -0
  22. package/src/__tests__/transmission-thickness-scale.unit.test.ts +7 -1
  23. package/src/__tests__/transparent-pbr.unit.test.ts +7 -0
  24. package/src/common.wgsl +42 -12
  25. package/src/default-standard-pbr-skin.wgsl +30 -71
  26. package/src/default-standard-pbr.wgsl +92 -163
  27. package/src/fxaa.wgsl +27 -6
  28. package/src/ibl-sampling.wgsl +54 -0
  29. package/src/index.ts +6 -0
  30. package/src/lighting-directional.wgsl +157 -18
  31. package/src/lighting-punctual.wgsl +51 -21
  32. package/src/scene-temporal.wgsl +14 -8
  33. package/src/shadow-pcf.wgsl +44 -1
  34. package/src/sprite-lit.wgsl +36 -52
  35. package/src/{hdrp-cluster-forward.wgsl → standard-cluster.wgsl} +73 -60
  36. package/src/tonemap.wgsl +8 -3
  37. package/src/volume/volume-inject.wgsl +14 -2
  38. package/src/volume/volume-integrate.wgsl +1 -6
@@ -37,31 +37,76 @@ describe('default Standard PBR transmission manifest contract', () => {
37
37
  const transmissionVariants = standard.variants.filter(
38
38
  (variant) => 'TRANSMISSION_AVAILABLE' in variant.defines,
39
39
  );
40
- expect(transmissionVariants).toHaveLength(32);
41
- expect(new Set(transmissionVariants.map((variant) => variant.definesKey)).size).toBe(32);
40
+ // Cluster-forward + storage-fallback is not a valid ABI pair: the
41
+ // clustered Standard group(2) requires storage buffers. The producer
42
+ // emits the six-axis Cartesian product minus those sixteen impossible
43
+ // combinations, i.e. 48 exact variants.
44
+ expect(transmissionVariants).toHaveLength(48);
45
+ expect(new Set(transmissionVariants.map((variant) => variant.definesKey)).size).toBe(48);
46
+ // Projector sampling is owned by the shared Cluster module. The
47
+ // non-cluster capability rows intentionally have no projector consumer
48
+ // after the direct-light/fallback cut, so their true/false projector keys
49
+ // alias one source instead of carrying duplicate WGSL. The 16 non-cluster
50
+ // pairs therefore collapse 48 logical keys to 32 source identities.
42
51
  expect(new Set(transmissionVariants.map((variant) => variant.composedWgsl)).size).toBe(32);
43
- for (const storageBufferAvailable of [false, true]) {
44
- for (const transmissionAvailable of [false, true]) {
45
- for (const projectorAvailable of [false, true]) {
46
- const expectedKey = [
47
- 'CLUSTER_FORWARD_AVAILABLE=false',
48
- `PROJECTOR_AVAILABLE=${projectorAvailable}`,
49
- `STORAGE_BUFFER_AVAILABLE=${storageBufferAvailable}`,
50
- `TRANSMISSION_AVAILABLE=${transmissionAvailable}`,
51
- 'VERTEX_COLOR_AVAILABLE=false',
52
- ].join('+');
53
- expect(transmissionVariants).toContainEqual(
54
- expect.objectContaining({
55
- definesKey: expectedKey,
56
- defines: expect.objectContaining({
57
- CLUSTER_FORWARD_AVAILABLE: false,
58
- PROJECTOR_AVAILABLE: projectorAvailable,
59
- STORAGE_BUFFER_AVAILABLE: storageBufferAvailable,
60
- TRANSMISSION_AVAILABLE: transmissionAvailable,
61
- VERTEX_COLOR_AVAILABLE: false,
62
- }),
63
- }),
64
- );
52
+ const nonClusterPairs = transmissionVariants.filter(
53
+ (variant) => variant.defines.CLUSTER_FORWARD_AVAILABLE === false,
54
+ );
55
+ for (const variant of nonClusterPairs) {
56
+ const projectorTwin = nonClusterPairs.find(
57
+ (candidate) =>
58
+ candidate.defines.STORAGE_BUFFER_AVAILABLE === variant.defines.STORAGE_BUFFER_AVAILABLE &&
59
+ candidate.defines.TRANSMISSION_AVAILABLE === variant.defines.TRANSMISSION_AVAILABLE &&
60
+ candidate.defines.VERTEX_COLOR_AVAILABLE === variant.defines.VERTEX_COLOR_AVAILABLE &&
61
+ candidate.defines.DIRECTIONAL_PCSS_AVAILABLE ===
62
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE &&
63
+ candidate.defines.PROJECTOR_AVAILABLE !== variant.defines.PROJECTOR_AVAILABLE,
64
+ );
65
+ expect(projectorTwin?.composedWgsl).toBe(variant.composedWgsl);
66
+ }
67
+ expect(
68
+ transmissionVariants.every(
69
+ (variant) =>
70
+ !(
71
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === true &&
72
+ variant.defines.STORAGE_BUFFER_AVAILABLE === false
73
+ ),
74
+ ),
75
+ ).toBe(true);
76
+ for (const clusterForwardAvailable of [false, true]) {
77
+ for (const storageBufferAvailable of [false, true]) {
78
+ if (clusterForwardAvailable && !storageBufferAvailable) continue;
79
+ for (const vertexColorAvailable of [false, true]) {
80
+ for (const directionalPcssAvailable of [false, true]) {
81
+ for (const transmissionAvailable of [false, true]) {
82
+ for (const projectorAvailable of [false, true]) {
83
+ const expectedKeyEntries = [
84
+ `CLUSTER_FORWARD_AVAILABLE=${clusterForwardAvailable}`,
85
+ `DIRECTIONAL_PCSS_AVAILABLE=${directionalPcssAvailable}`,
86
+ `PROJECTOR_AVAILABLE=${projectorAvailable}`,
87
+ `STORAGE_BUFFER_AVAILABLE=${storageBufferAvailable}`,
88
+ `TRANSMISSION_AVAILABLE=${transmissionAvailable}`,
89
+ `VERTEX_COLOR_AVAILABLE=${vertexColorAvailable}`,
90
+ ];
91
+ const expectedKey = expectedKeyEntries.every((entry) => entry.endsWith('=true'))
92
+ ? ''
93
+ : expectedKeyEntries.join('+');
94
+ expect(transmissionVariants).toContainEqual(
95
+ expect.objectContaining({
96
+ definesKey: expectedKey,
97
+ defines: expect.objectContaining({
98
+ CLUSTER_FORWARD_AVAILABLE: clusterForwardAvailable,
99
+ DIRECTIONAL_PCSS_AVAILABLE: directionalPcssAvailable,
100
+ PROJECTOR_AVAILABLE: projectorAvailable,
101
+ STORAGE_BUFFER_AVAILABLE: storageBufferAvailable,
102
+ TRANSMISSION_AVAILABLE: transmissionAvailable,
103
+ VERTEX_COLOR_AVAILABLE: vertexColorAvailable,
104
+ }),
105
+ }),
106
+ );
107
+ }
108
+ }
109
+ }
65
110
  }
66
111
  }
67
112
  }
@@ -28,6 +28,14 @@ describe('shared punctual optics for volumetric fog', () => {
28
28
  expect(inject).toContain('spotLightsBuffer');
29
29
  });
30
30
 
31
+ it('keeps volume View aligned with the directional filter carrier', () => {
32
+ expect(inject).toContain('directionalShadowFilter : vec4<f32>');
33
+ expect(inject).toContain('normalBias : f32,\n directionalShadowFilter');
34
+ expect(inject).not.toContain('view.pcfKernelSize');
35
+ expect(inject).toContain('filter_profile >= 4u');
36
+ expect(inject).toContain('stable PCF3 receiver');
37
+ });
38
+
31
39
  it('keeps the punctual volume path finite and monotonic at range boundaries', () => {
32
40
  expect(punctual).toMatch(/max\([^\n]*1e-4/);
33
41
  expect(attenuation).toMatch(/clamp\(1\.0 - \(safeDistance \* invRangeSquared\)/);
@@ -4,11 +4,15 @@ import { fileURLToPath } from 'node:url';
4
4
  import { describe, expect, it } from 'vitest';
5
5
 
6
6
  const shaderPath = join(dirname(fileURLToPath(import.meta.url)), '../lighting-punctual.wgsl');
7
- const hdrpShaderPath = join(
7
+ const standardClusterShaderPath = join(
8
8
  dirname(fileURLToPath(import.meta.url)),
9
- '../hdrp-cluster-forward.wgsl',
9
+ '../standard-cluster.wgsl',
10
10
  );
11
11
  const pbrShaderPath = join(dirname(fileURLToPath(import.meta.url)), '../default-standard-pbr.wgsl');
12
+ const directionalShaderPath = join(
13
+ dirname(fileURLToPath(import.meta.url)),
14
+ '../lighting-directional.wgsl',
15
+ );
12
16
 
13
17
  describe('direct punctual lighting shader contract', () => {
14
18
  it('exposes independent point and spot paths with explicit range and cone inputs', async () => {
@@ -16,6 +20,8 @@ describe('direct punctual lighting shader contract', () => {
16
20
 
17
21
  expect(source).toContain('fn evalPoint(');
18
22
  expect(source).toContain('fn evalSpot(');
23
+ expect(source).toContain('fn evalPointFlat(');
24
+ expect(source).toContain('fn evalSpotFlat(');
19
25
  expect(source).toContain('invRangeSquared');
20
26
  expect(source).toContain('cosInner');
21
27
  expect(source).toContain('cosOuter');
@@ -37,7 +43,7 @@ describe('direct punctual lighting shader contract', () => {
37
43
  });
38
44
 
39
45
  it('keeps cluster punctual evaluation in the shared lighting owner', async () => {
40
- const source = await readFile(hdrpShaderPath, 'utf8');
46
+ const source = await readFile(standardClusterShaderPath, 'utf8');
41
47
 
42
48
  expect(source).toContain('kind_and_shadow');
43
49
  expect(source).toContain('evalPoint');
@@ -49,18 +55,38 @@ describe('direct punctual lighting shader contract', () => {
49
55
  });
50
56
 
51
57
  it('decodes HDRP spot cone lanes in the shared evaluator order', async () => {
52
- const source = await readFile(hdrpShaderPath, 'utf8');
58
+ const source = await readFile(standardClusterShaderPath, 'utf8');
53
59
 
54
60
  expect(source).toContain('light.color.w, light.direction.w, light.position.w');
55
61
  expect(source).not.toContain('light.direction.w, light.color.w, light.position.w');
56
62
  });
57
63
 
64
+ it('keeps the unshadowed spot sentinel out of the projector flag branch', async () => {
65
+ const source = await readFile(standardClusterShaderPath, 'utf8');
66
+
67
+ expect(source).toContain('if (encoded_tile >= 0 && (encoded_tile & PROJECTOR_FLAG) != 0)');
68
+ });
69
+
58
70
  it('passes precomputed base and clearcoat roughness/F0 facts to cluster lights', async () => {
59
71
  const source = await readFile(pbrShaderPath, 'utf8');
60
72
 
61
- expect(source).toContain(
62
- 'evaluate_cluster_lights(in.ndc, in.viewZ, in.worldPos, n, v, diffuseAlbedo, metallic, a, f0)',
73
+ 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,
63
75
  );
64
76
  expect(source).toContain('coatAlpha, vec3<f32>(0.04)');
65
77
  });
78
+
79
+ it('requires one shared Directional PCSS orchestration owner', async () => {
80
+ const source = await readFile(directionalShaderPath, 'utf8');
81
+
82
+ expect(source).toContain('PCSS_MEDIUM_RAW_TAPS');
83
+ expect(source).toContain('PCSS_MEDIUM_COMPARE_TAPS');
84
+ expect(source).toContain('PCSS_HIGH_RAW_TAPS');
85
+ expect(source).toContain('PCSS_HIGH_COMPARE_TAPS');
86
+ expect(source).toContain('fn _samplePcssForCascade');
87
+ expect(source).toContain('if (blockerCount == 0u)');
88
+ expect(source).toContain('shadow_load_raw_depth');
89
+ expect(source).toContain('shadow_sample_compare');
90
+ expect(source).not.toMatch(/frameIndex|frame_index|taaJitter|taa_jitter|jitter/);
91
+ });
66
92
  });
@@ -1,8 +1,35 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
1
3
  import { derive } from '@forgeax/engine-types';
2
4
  import { describe, expect, it } from 'vitest';
3
5
  import { DEFAULT_STANDARD_PBR_PARAM_SCHEMA } from '../material-schemas.js';
4
6
 
5
7
  describe('material derived built-in integration contract', () => {
8
+ it('routes every Standard lit consumer through the single Cluster accessor', () => {
9
+ const sources = [
10
+ readFileSync(resolve(import.meta.dirname, '../default-standard-pbr.wgsl'), 'utf8'),
11
+ readFileSync(resolve(import.meta.dirname, '../default-standard-pbr-skin.wgsl'), 'utf8'),
12
+ ];
13
+
14
+ for (const source of sources) {
15
+ expect(source).toContain('evaluateStandardClusterLights');
16
+ expect(source).not.toContain('evaluate_cluster_lights');
17
+ expect(source).not.toContain('forgeax_hdrp');
18
+ expect(source).not.toContain('forgeax_urp');
19
+ }
20
+
21
+ const sprite = readFileSync(resolve(import.meta.dirname, '../sprite-lit.wgsl'), 'utf8');
22
+ expect(sprite).toContain('evaluateStandardClusterLights');
23
+ expect(sprite).toContain('CLUSTER_FORWARD_AVAILABLE');
24
+ expect(sprite).toContain('ndc, viewZ, worldPos');
25
+ expect(sprite).not.toContain('forgeax_hdrp');
26
+ expect(sprite).not.toContain('forgeax_urp');
27
+
28
+ for (const source of [...sources, sprite]) {
29
+ expect(source).not.toMatch(/in\.clip\.xy\s*\/\s*in\.clip\.w/);
30
+ }
31
+ });
32
+
6
33
  it('uses the same derived identity for standard and skinned standard schemas', () => {
7
34
  const standard = derive(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
8
35
  const skinned = derive(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const samplingSource = readFileSync(new URL('../ibl-sampling.wgsl', import.meta.url), 'utf8');
5
+ const standardSource = readFileSync(
6
+ new URL('../default-standard-pbr.wgsl', import.meta.url),
7
+ 'utf8',
8
+ );
9
+
10
+ describe('Standard reflection probe sampling contract', () => {
11
+ it('keeps global IBL helpers and adds a bounded local probe sampling helper', () => {
12
+ expect(samplingSource).toMatch(/fn\s+sampleIblDiffuse\s*\(/);
13
+ expect(samplingSource).toMatch(/fn\s+sampleIblSpecular\s*\(/);
14
+ expect(samplingSource).toMatch(/fn\s+sampleReflectionProbeSpecular\s*\(/);
15
+ });
16
+
17
+ it('uses box projection and explicit Skylight fallback in the Standard shader', () => {
18
+ expect(standardSource).toMatch(/reflection_probe/);
19
+ expect(standardSource).toMatch(/box_project/);
20
+ expect(standardSource).toMatch(/sampleIblSpecular/);
21
+ expect(standardSource).toMatch(/skylight/);
22
+ });
23
+ });
@@ -20,6 +20,25 @@ describe('scene temporal varyings', () => {
20
20
  expect(source).toContain('packed.z < 0.0');
21
21
  expect(source).toContain('SCENE_DATA_TEMPORAL_V1_CLEAR');
22
22
  expect(source).toContain('rgba16float');
23
+ expect(source).toContain('fn sceneViewZ');
24
+ expect(source).toContain('orthographicViewZ = -(temporalProjection.x');
25
+ });
26
+
27
+ it('uses projection depth rather than Euclidean distance for cluster view Z', () => {
28
+ const sceneViewZ = (
29
+ clip: { readonly z: number; readonly w: number },
30
+ temporalProjection: { readonly x: number; readonly y: number; readonly z: number },
31
+ ) => {
32
+ const ndcDepth = clip.z / Math.max(Math.abs(clip.w), 1e-6);
33
+ const orthographicViewZ = -(
34
+ temporalProjection.x +
35
+ ndcDepth * (temporalProjection.y - temporalProjection.x)
36
+ );
37
+ return temporalProjection.z >= 0.5 ? orthographicViewZ : -clip.w;
38
+ };
39
+
40
+ expect(sceneViewZ({ z: 0.5, w: 1 }, { x: 1, y: 11, z: 1 })).toBe(-6);
41
+ expect(sceneViewZ({ z: 3, w: 7 }, { x: 1, y: 11, z: 0 })).toBe(-7);
23
42
  });
24
43
 
25
44
  it.each(localTemporalShaders)('%s perspective-interpolates both clip authorities', (file) => {
@@ -39,6 +58,7 @@ describe('scene temporal varyings', () => {
39
58
 
40
59
  it.each(pbrTemporalConsumers)('%s imports the shared PBR temporal authority', (file) => {
41
60
  const source = readFileSync(resolve(import.meta.dirname, '..', file), 'utf8');
61
+ expect(source).toContain('#import forgeax_scene_temporal::{sceneViewZ}');
42
62
  expect(source).toContain('#import forgeax_pbr::temporal::{projectPbrSceneTemporal}');
43
63
  expect(source).toMatch(/@interpolate\(perspective\) currentClip : vec4<f32>/);
44
64
  expect(source).toMatch(/@interpolate\(perspective\) previousClip : vec4<f32>/);
@@ -164,6 +164,7 @@ import {
164
164
  }
165
165
 
166
166
  let fxaaSource!: string;
167
+ let commonSource!: string;
167
168
 
168
169
  beforeAll(async () => {
169
170
  const fsId = 'node:fs';
@@ -174,7 +175,9 @@ import {
174
175
  const url = (await import(/* @vite-ignore */ urlId)) as NodeUrl;
175
176
  const here = url.fileURLToPath(import.meta.url);
176
177
  const fxaaPath = path.resolve(path.dirname(here), '..', 'fxaa.wgsl');
178
+ const commonPath = path.resolve(path.dirname(here), '..', 'common.wgsl');
177
179
  fxaaSource = fs.readFileSync(fxaaPath, 'utf8');
180
+ commonSource = fs.readFileSync(commonPath, 'utf8');
178
181
  });
179
182
 
180
183
  describe('fxaa.wgsl content markers', () => {
@@ -203,7 +206,7 @@ import {
203
206
  });
204
207
 
205
208
  it('imports forgeax_view::common::FullscreenOutput', () => {
206
- expect(fxaaSource).toContain('#import forgeax_view::common::FullscreenOutput');
209
+ expect(fxaaSource).toContain('#import forgeax_view::common::{FullscreenOutput');
207
210
  });
208
211
 
209
212
  it('declares display-encoded input/output without an OETF owner', () => {
@@ -212,9 +215,24 @@ import {
212
215
  expect(fxaaSource).not.toContain('linearToSrgbOetf');
213
216
  });
214
217
 
215
- it('returns encoded colors directly from early and final paths', () => {
216
- expect(fxaaSource).toMatch(/return vec4<f32>\(centerColor, 1\.0\);/);
217
- expect(fxaaSource).toMatch(/return vec4<f32>\(finalColor, 1\.0\);/);
218
+ it('gates dither at both early and final paths with the per-pass policy UBO', () => {
219
+ expect(fxaaSource).toContain('struct FxaaParams');
220
+ expect(fxaaSource).toMatch(
221
+ /@group\(0\)\s+@binding\(2\)\s+var<uniform> params\s*:\s*FxaaParams/,
222
+ );
223
+ expect(fxaaSource).toContain(
224
+ 'select(centerColor, ditherUnorm8(centerColor, in.position.xy), params.ditherEnabled > 0.5)',
225
+ );
226
+ expect(fxaaSource).toContain(
227
+ 'select(finalColor, ditherUnorm8(finalColor, in.position.xy), params.ditherEnabled > 0.5)',
228
+ );
229
+ });
230
+
231
+ it('uses the shared output-boundary dither implementation', () => {
232
+ expect(commonSource).toContain('fn ditherUnorm8');
233
+ expect(commonSource).toContain('fn ditherNoise');
234
+ expect(fxaaSource).not.toContain('fn ditherUnorm8');
235
+ expect(fxaaSource).not.toContain('fn ditherNoise');
218
236
  });
219
237
  });
220
238
 
@@ -227,8 +245,8 @@ import {
227
245
  expect(fxaaSource).toMatch(/@binding\(1\)\s+var\s+samp.*sampler/);
228
246
  });
229
247
 
230
- it('has no @binding(2) (no UBO, 2-entry BGL per D-2)', () => {
231
- expect(fxaaSource).not.toMatch(/@binding\(2\)/);
248
+ it('declares @group(0) @binding(2) FxaaParams uniform', () => {
249
+ expect(fxaaSource).toMatch(/@binding\(2\)\s+var<uniform>\s+params\s*:\s*FxaaParams/);
232
250
  });
233
251
  });
234
252
  }
@@ -805,6 +823,7 @@ import {
805
823
  '#pragma variant_axis CLUSTER_FORWARD_AVAILABLE',
806
824
  '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
807
825
  '#pragma variant_axis TRANSMISSION_AVAILABLE',
826
+ '#pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE',
808
827
  '#pragma variant_axis PROJECTOR_AVAILABLE',
809
828
  ]);
810
829
  });
@@ -1741,10 +1760,11 @@ import {
1741
1760
  expect(src).toMatch(/applyTBN/);
1742
1761
  });
1743
1762
 
1744
- it('default-standard-pbr.wgsl imports forgeax_pbr::lighting_directional + lighting_punctual', () => {
1763
+ it('default-standard-pbr.wgsl imports directional lighting and the shared Standard Cluster owner', () => {
1745
1764
  const src = readSource('default-standard-pbr.wgsl');
1746
1765
  expect(src).toMatch(/#import\s+forgeax_pbr::lighting_directional::/);
1747
- expect(src).toMatch(/#import\s+forgeax_pbr::lighting_punctual::/);
1766
+ expect(src).toMatch(/#import\s+forgeax_standard::cluster::/);
1767
+ expect(src).toMatch(/evaluateStandardClusterLights/);
1748
1768
  });
1749
1769
 
1750
1770
  it('default-standard-pbr.wgsl no longer defines fn evalDirectional / evalPoint / evalSpot inline', () => {
@@ -1843,9 +1863,10 @@ import {
1843
1863
  // ground-truth table (research F1 section 6 - "extended Reinhard
1844
1864
  // matches Reinhard 2002 luminance variant"). The TS port shadowed
1845
1865
  // below is the formula manifest.
1846
- // 2. TonemapParams std140 layout is 16 B (exposure + whitePoint
1847
- // + 2 padding f32 = 16 B). The shader-side struct in tonemap.wgsl
1848
- // keeps the same byte layout.
1866
+ // 2. TonemapParams std140 layout is 16 B (exposure + whitePoint + mode
1867
+ // + ditherEnabled = 16 B). The shader-side struct in tonemap.wgsl
1868
+ // keeps the same byte layout; the final output pass toggles only the
1869
+ // ditherEnabled slot in a per-pass copy.
1849
1870
  // 3. TONEMAP_LUMINANCE_EPSILON is the shared TS / WGSL const
1850
1871
  // (D-O3, 1e-5).
1851
1872
  //
@@ -1939,10 +1960,10 @@ import {
1939
1960
  });
1940
1961
 
1941
1962
  describe('TonemapParams std140 layout', () => {
1942
- it('TonemapParams stride is 16 B (4 f32 = exposure + whitePoint + 2 padding)', () => {
1963
+ it('TonemapParams stride is 16 B (exposure + whitePoint + mode + ditherEnabled)', () => {
1943
1964
  // The host writes `Float32Array` of length 4 to the UBO; the shader
1944
1965
  // declares `struct TonemapParams { exposure: f32, whitePoint: f32,
1945
- // _pad0: f32, _pad1: f32 }` — total = 16 B.
1966
+ // mode: u32, ditherEnabled: f32 }` — total = 16 B.
1946
1967
  const stride = 4 * 4;
1947
1968
  expect(stride).toBe(16);
1948
1969
  });
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const shaderRoot = fileURLToPath(new URL('..', import.meta.url));
6
+ const directional = readFileSync(`${shaderRoot}/lighting-directional.wgsl`, 'utf8');
7
+ const shadowPcf = readFileSync(`${shaderRoot}/shadow-pcf.wgsl`, 'utf8');
8
+
9
+ const PCSS_MEDIUM_RAW_TAPS = 8;
10
+ const PCSS_MEDIUM_COMPARE_TAPS = 16;
11
+ const PCSS_HIGH_RAW_TAPS = 16;
12
+ const PCSS_HIGH_COMPARE_TAPS = 32;
13
+
14
+ function biasedReceiverDepth(
15
+ receiverDepth: number,
16
+ normalBias: number,
17
+ depthBias: number,
18
+ nDotL: number,
19
+ ): number {
20
+ return receiverDepth - Math.max(normalBias * (1 - nDotL), depthBias);
21
+ }
22
+
23
+ function averageBlockerDepth(
24
+ rawDepths: readonly number[],
25
+ receiverDepth: number,
26
+ ): number | undefined {
27
+ const blockers = rawDepths.filter((depth) => Number.isFinite(depth) && depth < receiverDepth);
28
+ if (blockers.length === 0) return undefined;
29
+ return blockers.reduce((sum, depth) => sum + depth, 0) / blockers.length;
30
+ }
31
+
32
+ function penumbraTexels(
33
+ biasedDepth: number,
34
+ blockerDepth: number,
35
+ lightDepthWorldSpan: number,
36
+ angularRadiusRadians: number,
37
+ worldUnitsPerTexel: number,
38
+ maxPenumbraTexels: number,
39
+ ): number {
40
+ const worldDistance = Math.max(0, biasedDepth - blockerDepth) * lightDepthWorldSpan;
41
+ return Math.min(
42
+ maxPenumbraTexels,
43
+ Math.max(0, (worldDistance * Math.tan(angularRadiusRadians)) / worldUnitsPerTexel),
44
+ );
45
+ }
46
+
47
+ function clampShadowTexel(
48
+ texel: readonly [number, number],
49
+ tileOrigin: readonly [number, number],
50
+ tileSize: readonly [number, number],
51
+ inset: number,
52
+ ): [number, number] {
53
+ const minX = tileOrigin[0] + inset;
54
+ const minY = tileOrigin[1] + inset;
55
+ const maxX = tileOrigin[0] + Math.max(inset, tileSize[0] - 1 - inset);
56
+ const maxY = tileOrigin[1] + Math.max(inset, tileSize[1] - 1 - inset);
57
+ return [
58
+ Math.min(maxX, Math.max(minX, Math.trunc(texel[0]))),
59
+ Math.min(maxY, Math.max(minY, Math.trunc(texel[1]))),
60
+ ];
61
+ }
62
+
63
+ function stableDiskRotation(texelX: number, texelY: number, cascade: number): number {
64
+ const hash = Math.imul(Math.imul(texelX ^ 0x9e3779b9, 31) ^ texelY, 17) ^ cascade;
65
+ return (hash >>> 0) / 0x100000000;
66
+ }
67
+
68
+ describe('Directional PCSS numeric oracle', () => {
69
+ it('freezes normal and blend tap budgets for medium and high profiles', () => {
70
+ expect([PCSS_MEDIUM_RAW_TAPS, PCSS_MEDIUM_COMPARE_TAPS]).toEqual([8, 16]);
71
+ expect([PCSS_HIGH_RAW_TAPS, PCSS_HIGH_COMPARE_TAPS]).toEqual([16, 32]);
72
+ expect(PCSS_MEDIUM_RAW_TAPS + PCSS_MEDIUM_COMPARE_TAPS).toBe(24);
73
+ expect(PCSS_HIGH_RAW_TAPS + PCSS_HIGH_COMPARE_TAPS).toBe(48);
74
+ expect((PCSS_MEDIUM_RAW_TAPS + PCSS_MEDIUM_COMPARE_TAPS) * 2).toBe(48);
75
+ expect((PCSS_HIGH_RAW_TAPS + PCSS_HIGH_COMPARE_TAPS) * 2).toBe(96);
76
+ });
77
+
78
+ it('uses one biased receiver depth for blocker classification and compare', () => {
79
+ const receiver = biasedReceiverDepth(0.7, 0.02, 0.003, 0.5);
80
+ expect(receiver).toBeCloseTo(0.69, 7);
81
+ expect(averageBlockerDepth([0.68, 0.69, 0.71, Number.NaN], receiver)).toBeCloseTo(0.68, 7);
82
+ expect(averageBlockerDepth([0.7, 0.8, Number.POSITIVE_INFINITY], receiver)).toBeUndefined();
83
+ });
84
+
85
+ it('grows world-scale penumbra with blocker distance and clamps the texel radius', () => {
86
+ const near = penumbraTexels(0.7, 0.69, 20, 0.01, 0.1, 64);
87
+ const far = penumbraTexels(0.7, 0.4, 20, 0.01, 0.1, 64);
88
+ expect(near).toBeGreaterThan(0);
89
+ expect(far).toBeGreaterThan(near);
90
+ expect(penumbraTexels(0.7, 0.0, 20, 0.05, 0.001, 12)).toBe(12);
91
+ expect(penumbraTexels(0.7, 0.8, 20, 0.01, 0.1, 64)).toBe(0);
92
+ });
93
+
94
+ it('keeps raw and compare texels inside an integer one-texel tile inset', () => {
95
+ expect(clampShadowTexel([-100.8, 999.2], [32, 48], [64, 32], 1)).toEqual([33, 78]);
96
+ expect(clampShadowTexel([48.9, 60.1], [32, 48], [64, 32], 1)).toEqual([48, 60]);
97
+ expect(clampShadowTexel([32, 48], [32, 48], [1, 1], 1)).toEqual([33, 49]);
98
+ });
99
+
100
+ it('is stable for the same cascade-local integer texel and independent of frame jitter', () => {
101
+ const first = stableDiskRotation(107, 203, 2);
102
+ const second = stableDiskRotation(107, 203, 2);
103
+ expect(first).toBe(second);
104
+ expect(stableDiskRotation(108, 203, 2)).not.toBe(first);
105
+ expect(directional).not.toMatch(/frameIndex|frame_index|taaJitter|taa_jitter|jitter/);
106
+ });
107
+
108
+ it('requires the production three-stage receiver and shared sampling primitives', () => {
109
+ expect(directional).toContain('PCSS_MEDIUM_RAW_TAPS');
110
+ expect(directional).toContain('PCSS_MEDIUM_COMPARE_TAPS');
111
+ expect(directional).toContain('PCSS_HIGH_RAW_TAPS');
112
+ expect(directional).toContain('PCSS_HIGH_COMPARE_TAPS');
113
+ expect(directional).toMatch(/blocker/i);
114
+ expect(directional).toMatch(/penumbra/i);
115
+ expect(shadowPcf).toContain('fn shadow_load_raw_depth');
116
+ expect(shadowPcf).toContain('fn shadow_sample_compare');
117
+ expect(shadowPcf).toContain('fn shadow_biased_receiver_depth');
118
+ expect(shadowPcf).toContain('fn shadow_clamp_texel_to_tile');
119
+ });
120
+
121
+ it('does not accept fixed-radius or PCF5-as-PCSS receiver structure', () => {
122
+ expect(directional).not.toMatch(/fixed.?radius|fixedUvRadius|pcf5.*pcss/i);
123
+ expect(directional).not.toContain('M2 supplies the PCSS arithmetic');
124
+ });
125
+
126
+ it('maps the serialized PCF labels to distinct production kernel widths', () => {
127
+ expect(directional).toMatch(
128
+ /let kernel = select\(select\(3u, 5u, filterProfile == 3u\), 1u, filterProfile == 1u\)/,
129
+ );
130
+ });
131
+ });
@@ -181,34 +181,47 @@ describe('sprite-lit shader (flat 2D lighting, tweak-20260701 M1)', () => {
181
181
  // At minimum: one consumer in each of fs_main and fs_main_hdr.
182
182
  expect(consumers.length).toBeGreaterThanOrEqual(2);
183
183
  });
184
+
185
+ it('VsOut carries vertex-produced ndc and viewZ for Standard cluster lookup', async () => {
186
+ const src = await readSpriteLitSource();
187
+ expect(src).toMatch(/@location\(\s*2\s*\)\s+ndc\s*:\s*vec3<f32>/);
188
+ expect(src).toMatch(/@location\(\s*3\s*\)\s+viewZ\s*:\s*f32/);
189
+ expect(src).toMatch(/out\.ndc\s*=\s*clipPos\.xyz\s*\/\s*clipPos\.w\s*;/);
190
+ expect(src).toMatch(/out\.viewZ\s*=\s*sceneViewZ\(/);
191
+ expect(src).toMatch(/evaluateStandardClusterLights\(\s*ndc,\s*viewZ,\s*worldPos/);
192
+ });
184
193
  });
185
194
 
186
- describe('flat light functions drop normal parameter (D-P2)', () => {
195
+ describe('Standard local lights use the shared Cluster path (D-P2)', () => {
187
196
  it('spriteLitDirectional signature is `(albedo)` only', async () => {
188
197
  const src = await readSpriteLitSource();
189
198
  const re = /fn\s+spriteLitDirectional\s*\(\s*albedo\s*:\s*vec3<f32>\s*\)/;
190
199
  expect(re.test(src)).toBe(true);
191
200
  });
192
201
 
193
- it('spriteLitPoint signature is `(p, worldPos, albedo)` — no normal', async () => {
202
+ it('does not retain a direct point-light branch', async () => {
194
203
  const src = await readSpriteLitSource();
195
- const re =
196
- /fn\s+spriteLitPoint\s*\(\s*p\s*:\s*PointLight\s*,\s*worldPos\s*:\s*vec3<f32>\s*,\s*albedo\s*:\s*vec3<f32>\s*\)/;
197
- expect(re.test(src)).toBe(true);
204
+ expect(src).not.toContain('pointLightsBuffer');
205
+ expect(src).not.toContain('evalPointFlat(');
198
206
  });
199
207
 
200
- it('spriteLitSpot signature is `(s, worldPos, albedo)` — no normal', async () => {
208
+ it('does not retain a direct spot-light branch', async () => {
201
209
  const src = await readSpriteLitSource();
202
- const re =
203
- /fn\s+spriteLitSpot\s*\(\s*s\s*:\s*SpotLight\s*,\s*worldPos\s*:\s*vec3<f32>\s*,\s*albedo\s*:\s*vec3<f32>\s*\)/;
204
- expect(re.test(src)).toBe(true);
210
+ expect(src).not.toContain('spotLightsBuffer');
211
+ expect(src).not.toContain('evalSpotFlat(');
205
212
  });
206
213
 
207
- it('spriteLitShadeAccum signature is `(albedo, worldPos)` no normal', async () => {
214
+ it('spriteLitShadeAccum consumes vertex-produced ndc/viewZ without a normal', async () => {
208
215
  const src = await readSpriteLitSource();
209
216
  const re =
210
- /fn\s+spriteLitShadeAccum\s*\(\s*albedo\s*:\s*vec3<f32>\s*,\s*worldPos\s*:\s*vec3<f32>\s*\)/;
217
+ /fn\s+spriteLitShadeAccum\s*\(\s*albedo\s*:\s*vec3<f32>\s*,\s*worldPos\s*:\s*vec3<f32>\s*,\s*ndc\s*:\s*vec3<f32>\s*,\s*viewZ\s*:\s*f32\s*,?\s*\)/;
211
218
  expect(re.test(src)).toBe(true);
219
+ const bodyStart = src.indexOf('fn spriteLitShadeAccum');
220
+ const bodyEnd = src.indexOf('fn fs_main(', bodyStart);
221
+ expect(bodyStart).toBeGreaterThanOrEqual(0);
222
+ expect(src.slice(bodyStart, bodyEnd > bodyStart ? bodyEnd : src.length)).not.toContain(
223
+ '@builtin(position)',
224
+ );
212
225
  });
213
226
  });
214
227
 
@@ -118,6 +118,7 @@ describe('w6 (b) -- pbr / unlit / sprite-adjacent shaders DO NOT pick up PER_INS
118
118
  '#pragma variant_axis CLUSTER_FORWARD_AVAILABLE',
119
119
  '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
120
120
  '#pragma variant_axis TRANSMISSION_AVAILABLE',
121
+ '#pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE',
121
122
  '#pragma variant_axis PROJECTOR_AVAILABLE',
122
123
  ]);
123
124
  });
@@ -7,7 +7,13 @@ const shader = readFileSync(resolve(import.meta.dirname, '../default-standard-pb
7
7
  describe('standard transmission thickness scale contract', () => {
8
8
  it('converts local authored thickness through the combined local-to-world basis', () => {
9
9
  expect(shader).toContain('let localToWorld = entityWorld * instanceLocal;');
10
- expect(shader).toContain(
10
+ expect(shader).toContain('@location(4) @interpolate(flat) transmissionBasis0 : vec4<f32>');
11
+ expect(shader).toContain('@location(15) @interpolate(flat) transmissionBasis1 : vec4<f32>');
12
+ expect(shader).toContain('let localToWorld0 = in.transmissionBasis0.xyz;');
13
+ expect(shader).toContain('let localToWorld1 = vec3<f32>(');
14
+ expect(shader).toContain('let localToWorld2 = vec3<f32>(');
15
+ expect(shader).toContain('in.ndc.w,');
16
+ expect(shader).not.toContain(
11
17
  'let localToWorld = meshes[0].worldFromLocal * instances[in.instanceIdx].localFromInstance;',
12
18
  );
13
19
  expect(shader).toContain('let worldToLocal0 = vec3<f32>(');
@@ -18,4 +18,11 @@ describe('transparent PBR color domain contract', () => {
18
18
  expect(shader).toContain('linearHdrColorDomain');
19
19
  expect(shader).toContain('toneStageInput');
20
20
  });
21
+
22
+ it('keeps transparent PBR on the shared Directional factor path', () => {
23
+ expect(shader).toContain('lighting_directional');
24
+ expect(shader.match(/evalDirectionalShadowFactor\(/g)).toHaveLength(1);
25
+ expect(shader).toContain('directionalBase');
26
+ expect(shader).toContain('directionalClearcoat');
27
+ });
21
28
  });