@forgeax/engine-shader 0.1.19 → 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 (51) 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 +71 -22
  13. package/src/__tests__/fog-oracle.unit.test.ts +110 -114
  14. package/src/__tests__/fog-orthographic-ray.unit.test.ts +12 -37
  15. package/src/__tests__/fog-producer-matrix.integration.test.ts +16 -51
  16. package/src/__tests__/lighting-point-spot-volume.unit.test.ts +56 -0
  17. package/src/__tests__/lighting-punctual.unit.test.ts +32 -6
  18. package/src/__tests__/lighting-spot-volume-attenuation.unit.test.ts +26 -0
  19. package/src/__tests__/lighting-spot-volume.unit.test.ts +25 -0
  20. package/src/__tests__/material-derived-builtins.integration.test.ts +27 -0
  21. package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -0
  22. package/src/__tests__/scene-temporal.unit.test.ts +20 -0
  23. package/src/__tests__/shader.unit.test.ts +35 -13
  24. package/src/__tests__/shadow-pcss.unit.test.ts +131 -0
  25. package/src/__tests__/sprite-lit-shader.test.ts +24 -11
  26. package/src/__tests__/sprite-variants.unit.test.ts +2 -0
  27. package/src/__tests__/transmission-thickness-scale.unit.test.ts +7 -1
  28. package/src/__tests__/transparent-pbr.unit.test.ts +7 -0
  29. package/src/common.wgsl +72 -73
  30. package/src/default-standard-pbr-skin.wgsl +31 -89
  31. package/src/default-standard-pbr.wgsl +94 -146
  32. package/src/fxaa.wgsl +27 -6
  33. package/src/hdrp-deferred-lighting.wgsl +3 -27
  34. package/src/ibl-sampling.wgsl +54 -0
  35. package/src/index.ts +6 -0
  36. package/src/lighting-attenuation.wgsl +58 -0
  37. package/src/lighting-directional.wgsl +188 -21
  38. package/src/lighting-punctual.wgsl +94 -19
  39. package/src/msdf-text.wgsl +4 -23
  40. package/src/scene-temporal.wgsl +14 -8
  41. package/src/shadow-pcf.wgsl +75 -11
  42. package/src/skybox.wgsl +2 -7
  43. package/src/sprite-lit.wgsl +38 -73
  44. package/src/sprite.wgsl +3 -22
  45. package/src/{hdrp-cluster-forward.wgsl → standard-cluster.wgsl} +74 -23
  46. package/src/tonemap.wgsl +8 -3
  47. package/src/unlit.wgsl +2 -24
  48. package/src/volume/volume-composite.wgsl +113 -0
  49. package/src/volume/volume-inject.wgsl +261 -0
  50. package/src/volume/volume-integrate.wgsl +337 -0
  51. package/src/volume/volume-temporal.wgsl +70 -0
@@ -37,28 +37,77 @@ 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(16);
41
- expect(new Set(transmissionVariants.map((variant) => variant.definesKey)).size).toBe(16);
42
- expect(new Set(transmissionVariants.map((variant) => variant.composedWgsl)).size).toBe(16);
43
- for (const storageBufferAvailable of [false, true]) {
44
- for (const transmissionAvailable of [false, true]) {
45
- const expectedKey = [
46
- 'CLUSTER_FORWARD_AVAILABLE=false',
47
- `STORAGE_BUFFER_AVAILABLE=${storageBufferAvailable}`,
48
- `TRANSMISSION_AVAILABLE=${transmissionAvailable}`,
49
- 'VERTEX_COLOR_AVAILABLE=false',
50
- ].join('+');
51
- expect(transmissionVariants).toContainEqual(
52
- expect.objectContaining({
53
- definesKey: expectedKey,
54
- defines: expect.objectContaining({
55
- CLUSTER_FORWARD_AVAILABLE: false,
56
- STORAGE_BUFFER_AVAILABLE: storageBufferAvailable,
57
- TRANSMISSION_AVAILABLE: transmissionAvailable,
58
- VERTEX_COLOR_AVAILABLE: false,
59
- }),
60
- }),
61
- );
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.
51
+ expect(new Set(transmissionVariants.map((variant) => variant.composedWgsl)).size).toBe(32);
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
+ }
110
+ }
62
111
  }
63
112
  }
64
113
  });
@@ -2,131 +2,127 @@ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { describe, expect, it } from 'vitest';
4
4
 
5
- const fogWgsl = readFileSync(fileURLToPath(new URL('../fog.wgsl', import.meta.url)), 'utf8');
6
- const commonWgsl = readFileSync(fileURLToPath(new URL('../common.wgsl', import.meta.url)), 'utf8');
5
+ const integrateWgsl = readFileSync(
6
+ fileURLToPath(new URL('../volume/volume-integrate.wgsl', import.meta.url)),
7
+ 'utf8',
8
+ );
9
+ const compositeWgsl = readFileSync(
10
+ fileURLToPath(new URL('../volume/volume-composite.wgsl', import.meta.url)),
11
+ 'utf8',
12
+ );
13
+ const temporalWgsl = readFileSync(
14
+ fileURLToPath(new URL('../volume/volume-temporal.wgsl', import.meta.url)),
15
+ 'utf8',
16
+ );
17
+ const injectWgsl = readFileSync(
18
+ fileURLToPath(new URL('../volume/volume-inject.wgsl', import.meta.url)),
19
+ 'utf8',
20
+ );
7
21
 
8
- interface FogCase {
9
- readonly name: string;
10
- readonly originY: number;
11
- readonly directionY: number;
12
- readonly distance: number;
13
- readonly density: number;
14
- readonly heightFalloff: number;
15
- readonly maxOpacity: number;
16
- readonly expectedOpacity: number;
22
+ function hash32(value: number): number {
23
+ let hash = value >>> 0;
24
+ hash = (hash ^ 61 ^ (hash >>> 16)) >>> 0;
25
+ hash = (hash + (hash << 3)) >>> 0;
26
+ hash = (hash ^ (hash >>> 4)) >>> 0;
27
+ hash = Math.imul(hash, 668265261) >>> 0;
28
+ return (hash ^ (hash >>> 15)) >>> 0;
17
29
  }
18
30
 
19
- function opticalDepth(input: FogCase): number {
20
- const rho0 = input.density * Math.exp(-input.heightFalloff * input.originY);
21
- const q = input.heightFalloff * input.directionY;
22
- if (Math.abs(q) < 1e-6) return rho0 * input.distance;
23
- return (rho0 * (1 - Math.exp(-q * input.distance))) / q;
31
+ function stratified32(seed: number, frame: number, stride: number): number {
32
+ const bin = (hash32(seed) + (frame & 31) * stride) & 31;
33
+ return (bin + 0.5) / 32;
24
34
  }
25
35
 
26
- function fogOpacity(input: FogCase): number {
27
- return input.maxOpacity * (1 - Math.exp(-Math.max(opticalDepth(input), 0)));
36
+ function ditheredByte(value: number, noise: number): number {
37
+ return Math.round(Math.min(1, Math.max(0, value + (noise - 0.5) / 255)) * 255);
28
38
  }
29
39
 
30
- const cases: readonly FogCase[] = [
31
- {
32
- name: 'uniform',
33
- originY: 0,
34
- directionY: 0,
35
- distance: 10,
36
- density: 0.1,
37
- heightFalloff: 0,
38
- maxOpacity: 1,
39
- expectedOpacity: 0.6321205588,
40
- },
41
- {
42
- name: 'horizontal-height',
43
- originY: 2,
44
- directionY: 0,
45
- distance: 5,
46
- density: 0.2,
47
- heightFalloff: 0.5,
48
- maxOpacity: 1,
49
- expectedOpacity: 0.3077993724,
50
- },
51
- {
52
- name: 'upward-height',
53
- originY: 0,
54
- directionY: 1,
55
- distance: 4,
56
- density: 0.2,
57
- heightFalloff: 0.5,
58
- maxOpacity: 1,
59
- expectedOpacity: 0.2923926197,
60
- },
61
- {
62
- name: 'downward-height',
63
- originY: 0,
64
- directionY: -1,
65
- distance: 4,
66
- density: 0.2,
67
- heightFalloff: 0.5,
68
- maxOpacity: 1,
69
- expectedOpacity: 0.9223561116,
70
- },
71
- {
72
- name: 'small-q-limit',
73
- originY: 1.5,
74
- directionY: 0.5,
75
- distance: 7,
76
- density: 0.03,
77
- heightFalloff: 1e-12,
78
- maxOpacity: 1,
79
- expectedOpacity: 0.189415754,
80
- },
81
- {
82
- name: 'max-opacity',
83
- originY: 0,
84
- directionY: 0,
85
- distance: 10,
86
- density: 0.5,
87
- heightFalloff: 0,
88
- maxOpacity: 0.25,
89
- expectedOpacity: 0.2483155133,
90
- },
91
- ];
40
+ describe('Volumetric optics shader ownership', () => {
41
+ it('uses filterable history with exact reprojection and deterministic source jitter', () => {
42
+ expect(temporalWgsl).toContain('var accepted : texture_2d<f32>');
43
+ expect(temporalWgsl).toContain('fn bilinear');
44
+ expect(temporalWgsl).toContain('textureLoad(accepted');
45
+ expect(temporalWgsl).toContain('0.875');
46
+ expect(temporalWgsl).not.toContain('temporal_jitter');
47
+ expect(temporalWgsl).toContain('let prior = bilinear(previous_uv, size);');
48
+ });
92
49
 
93
- describe('Fog optical-depth CPU oracle (M2)', () => {
94
- it('reports case-wise expected, observed, delta, and verdict', () => {
95
- const report = cases.map((input) => {
96
- const observed = fogOpacity(input);
97
- const delta = Math.abs(observed - input.expectedOpacity);
98
- return {
99
- name: input.name,
100
- expected: input.expectedOpacity,
101
- observed,
102
- delta,
103
- verdict: delta <= 1e-9,
104
- };
105
- });
106
- for (const result of report) {
107
- expect(result.verdict, `${result.name}: ${JSON.stringify(result)}`).toBe(true);
108
- }
50
+ it('weights equal-alpha neighborhood samples by relative radiance', () => {
51
+ expect(compositeWgsl).toContain('radiance_luma_weight');
52
+ expect(compositeWgsl).toContain('relative_luma');
53
+ const center = 1;
54
+ const darkNeighbor = 0.02;
55
+ const equalAlpha = 0.5;
56
+ const relativeLuma = Math.abs(darkNeighbor - center) / Math.max(center, darkNeighbor, 1e-4);
57
+ const weight = Math.exp(-relativeLuma * 8) * Math.exp(-Math.abs(equalAlpha - equalAlpha) * 24);
58
+ expect(weight).toBeLessThan(0.01);
59
+ });
60
+
61
+ it('keeps optical depth and phase in the volume integration module', () => {
62
+ expect(integrateWgsl).toContain('#define_import_path forgeax_view::volume_integrate');
63
+ expect(integrateWgsl).toContain('fn hg');
64
+ expect(integrateWgsl).toContain('FOUR_PI');
65
+ expect(integrateWgsl).toContain('textureStore(resolved');
66
+ expect(integrateWgsl).not.toContain('apply_fog');
67
+ expect(integrateWgsl).toContain('full_step');
68
+ expect(integrateWgsl).toContain('let segment_start');
69
+ expect(integrateWgsl).toContain('let segment_length');
70
+ expect(integrateWgsl).toContain('sigma_scale * density_value * segment_length');
71
+ expect(integrateWgsl).toContain('scene_distance');
72
+ expect(injectWgsl).not.toContain('scene_depth');
73
+ expect(injectWgsl).not.toContain('textureStore(volume_history');
74
+ expect(injectWgsl).not.toContain('textureStore(volume_temporal');
109
75
  });
110
76
 
111
- it('keeps HDR scene radiance and alpha while mixing in linear space', () => {
112
- const scene = [2, 4, 8, 0.37] as const;
113
- const fog = [0.5, 1.5, 3] as const;
114
- const opacity = 0.25;
115
- const mixed = scene.map((value, index) =>
116
- index === 3 ? value : (1 - opacity) * value + opacity * (fog[index] ?? 0),
117
- );
118
- expect(mixed).toEqual([1.625, 3.375, 6.75, 0.37]);
77
+ it('composites the prepared integrated volume exactly once', () => {
78
+ expect(compositeWgsl).toContain('#define_import_path forgeax_view::volume_composite');
79
+ expect(compositeWgsl).toContain('resolved_volume');
80
+ expect(compositeWgsl).not.toContain('apply_fog');
119
81
  });
120
82
 
121
- it('has one shared Fog WGSL owner with finite ray and height inputs', () => {
122
- expect(fogWgsl).toContain('#define_import_path forgeax_view::fog');
123
- expect(fogWgsl).toContain('#import forgeax_view::common::{FogViewParams, FogRay}');
124
- expect(fogWgsl).toMatch(/fn apply_fog\s*\(/);
125
- expect((fogWgsl.match(/fn apply_fog\s*\(/g) ?? []).length).toBe(1);
126
- expect(fogWgsl).toContain('FOG_Q_EPSILON');
127
- expect(fogWgsl).toContain('FOG_EXP_LIMIT');
128
- expect(fogWgsl).toContain('max(tau, 0.0)');
129
- expect(fogWgsl).toContain('color.a');
130
- expect(commonWgsl).toMatch(/fog\s*:\s*FogViewParams/);
83
+ it('maps the fullscreen composite UV into the view-space Y convention', () => {
84
+ expect(compositeWgsl).toContain('positions[index].x * 0.5 + 0.5');
85
+ expect(compositeWgsl).toContain('0.5 - positions[index].y * 0.5');
86
+ });
87
+
88
+ it('selects volume cascades from the uploaded camera view-depth contract', () => {
89
+ expect(injectWgsl).toContain('view.temporalCurrentViewProj');
90
+ expect(injectWgsl).toContain('view.temporalProjection');
91
+ expect(injectWgsl).not.toContain('distance(world_position, view.cameraPos)');
92
+ });
93
+
94
+ it('uses bounded 32-phase stratification and unbiased unorm dithering', () => {
95
+ expect(injectWgsl).toContain('fn hash32');
96
+ expect(injectWgsl).toContain('fn stratified32');
97
+ expect(injectWgsl).toContain('fn dither_unorm8');
98
+ expect(injectWgsl).not.toContain('interleaved_gradient_noise');
99
+ expect(injectWgsl).toContain('frame_index & 31u');
100
+
101
+ const seeds = Array.from({ length: 4096 }, (_, index) => hash32(index + 17));
102
+ for (const value of [0.1, 0.25, 0.5, 0.9]) {
103
+ for (let frame = 0; frame < 32; frame += 1) {
104
+ const mean =
105
+ seeds.reduce(
106
+ (sum, seed) => sum + ditheredByte(value, stratified32(seed, frame, 5)) / 255,
107
+ 0,
108
+ ) / seeds.length;
109
+ expect(Math.abs(mean - value)).toBeLessThanOrEqual(1 / (32 * 255));
110
+ }
111
+ }
112
+
113
+ for (let frame = 0; frame < 32; frame += 1) {
114
+ expect(seeds.every((seed) => ditheredByte(0, stratified32(seed, frame, 5)) === 0)).toBe(true);
115
+ expect(seeds.every((seed) => ditheredByte(1, stratified32(seed, frame, 5)) === 255)).toBe(
116
+ true,
117
+ );
118
+ expect(
119
+ seeds.every((seed) => ditheredByte(96 / 255, stratified32(seed, frame, 5)) === 96),
120
+ ).toBe(true);
121
+ }
122
+
123
+ const changed = seeds.filter(
124
+ (seed) => stratified32(seed, 0, 5) !== stratified32(seed, 1, 5),
125
+ ).length;
126
+ expect(changed / seeds.length).toBeGreaterThanOrEqual(0.5);
131
127
  });
132
128
  });
@@ -9,54 +9,29 @@ const shaderFiles = [
9
9
  'sprite.wgsl',
10
10
  'sprite-lit.wgsl',
11
11
  'msdf-text.wgsl',
12
+ 'skybox.wgsl',
12
13
  ] as const;
13
14
 
14
15
  function source(name: (typeof shaderFiles)[number]): string {
15
16
  return readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
16
17
  }
17
18
 
18
- function subtract(a: readonly number[], b: readonly number[]): number[] {
19
- return a.map((value, index) => value - (b[index] ?? 0));
20
- }
21
-
22
- function dot(a: readonly number[], b: readonly number[]): number {
23
- return a.reduce((sum, value, index) => sum + value * (b[index] ?? 0), 0);
24
- }
25
-
26
- function scale(a: readonly number[], scalar: number): number[] {
27
- return a.map((value) => value * scalar);
28
- }
29
-
30
- describe('Fog orthographic fragment ray contract', () => {
31
- it('derives the camera-plane origin from the current fragment for every built-in material', () => {
19
+ describe('Built-in fragment volume handoff', () => {
20
+ it('does not reconstruct or apply a private analytic fog ray', () => {
32
21
  for (const name of shaderFiles) {
33
22
  const shader = source(name);
34
- expect(shader, name).toContain(
35
- 'worldPos - direction * dot(worldPos - viewParams.cameraPos, direction)',
36
- );
37
- expect(shader, name).not.toContain('origin = nearPoint;');
38
- expect(shader.match(/applySceneFog\(/g)?.length, name).toBeGreaterThanOrEqual(2);
23
+ expect(shader, name).not.toContain('applySceneFog(');
24
+ expect(shader, name).not.toContain('apply_fog(');
25
+ expect(shader, name).not.toContain('forgeax_view::fog');
39
26
  }
40
27
  });
41
28
 
42
- it('keeps a tilted orthographic camera pixel on its own ray rather than the center ray', () => {
43
- const camera = [4, 2, -3];
44
- const direction = [0.3, -0.8, 0.5];
45
- const length = Math.hypot(...direction);
46
- const unitDirection = direction.map((value) => value / length);
47
- const lateral = [1, 0, -0.6];
48
- const fragment = camera
49
- .map((value, index) => value + (lateral[index] ?? 0))
50
- .map((value, index) => value + (unitDirection[index] ?? 0) * 7);
51
- const origin = subtract(
52
- fragment,
53
- scale(unitDirection, dot(subtract(fragment, camera), unitDirection)),
29
+ it('keeps the volume composite as the shared post-lighting owner', () => {
30
+ const volume = readFileSync(
31
+ fileURLToPath(new URL('../volume/volume-composite.wgsl', import.meta.url)),
32
+ 'utf8',
54
33
  );
55
-
56
- expect(origin[0]).toBeCloseTo(5, 8);
57
- expect(origin[1]).toBeCloseTo(2, 8);
58
- expect(origin[2]).toBeCloseTo(-3.6, 8);
59
- expect(dot(subtract(fragment, origin), unitDirection)).toBeCloseTo(7, 8);
60
- expect(dot(subtract(origin, camera), unitDirection)).toBeCloseTo(0, 8);
34
+ expect(volume).toContain('resolved_volume');
35
+ expect(volume).toContain('composite_resolved_volume');
61
36
  });
62
37
  });
@@ -10,68 +10,33 @@ const SHADER_FILES = [
10
10
  'sprite-lit.wgsl',
11
11
  'msdf-text.wgsl',
12
12
  'skybox.wgsl',
13
+ 'hdrp-deferred-lighting.wgsl',
13
14
  ] as const;
14
15
 
15
16
  const shaderSource = (file: string): string =>
16
17
  readFileSync(fileURLToPath(new URL(`../${file}`, import.meta.url)), 'utf8');
17
18
 
18
- const count = (source: string, pattern: RegExp): number => source.match(pattern)?.length ?? 0;
19
-
20
- function producerContract(source: string): boolean {
21
- return (
22
- /#import\s+forgeax_view::common::\{[^}]*\bFogRay\b[^}]*\}/.test(source) &&
23
- /#import\s+forgeax_view::fog::\{[^}]*\bapply_fog\b[^}]*\}/.test(source) &&
24
- count(source, /\bapply_fog\s*\(/g) === 1 &&
25
- count(source, /\bFogRay\s*\(/g) >= 1 &&
26
- (source.includes('view.fog') || source.includes('viewParams.fog'))
27
- );
28
- }
29
-
30
- function body(source: string, entry: string, nextEntry: string): string {
31
- const start = source.indexOf(entry);
32
- const end = nextEntry === '' ? -1 : source.indexOf(nextEntry, start + entry.length);
33
- return source.slice(start, end === -1 ? source.length : end);
34
- }
35
-
36
- describe('built-in Fog producer matrix', () => {
37
- it('requires one shared Fog module call and a real FogRay for every built-in output', () => {
19
+ describe('Built-in volume producer matrix', () => {
20
+ it('removes every analytic fog producer from built-in outputs', () => {
38
21
  for (const file of SHADER_FILES) {
39
22
  const source = shaderSource(file);
40
- expect(producerContract(source), file).toBe(true);
23
+ expect(source, file).not.toContain('apply_fog');
24
+ expect(source, file).not.toContain('applySceneFog');
25
+ expect(source, file).not.toContain('forgeax_view::fog');
26
+ expect(source, file).not.toContain('FogRay');
41
27
  }
42
28
  });
43
29
 
44
- it('keeps deferred G-buffer and temporal/reactive outputs free of Fog', () => {
45
- const pbr = shaderSource('default-standard-pbr.wgsl');
46
- const gbuffer = body(pbr, 'fn fs_gbuffer', 'struct TemporalVsOut');
47
- const temporal = body(pbr, 'fn fs_temporal', '');
48
-
49
- expect(gbuffer).not.toContain('applySceneFog(');
50
- expect(gbuffer).not.toContain('apply_fog(');
51
- expect(temporal).not.toContain('applySceneFog(');
52
- expect(temporal).not.toContain('apply_fog(');
30
+ it('keeps the volume module as the sole optical integration owner', () => {
31
+ const composite = shaderSource('volume/volume-composite.wgsl');
32
+ expect(shaderSource('volume/volume-integrate.wgsl')).toContain('fn hg');
33
+ expect(shaderSource('volume/volume-integrate.wgsl')).toContain('FOUR_PI');
34
+ expect(composite).toContain('resolved_volume');
53
35
  });
54
36
 
55
- it('turns deletion, duplication, and non-scene output coverage into red verdicts', () => {
56
- const source = shaderSource('default-standard-pbr.wgsl');
57
- expect(producerContract(source)).toBe(true);
58
-
59
- const deleted = source.replace(/#import\s+forgeax_view::fog::\{[^\n]+\}\n?/, '');
60
- expect(producerContract(deleted)).toBe(false);
61
-
62
- const duplicated = `${source}\n${source.match(/\bapply_fog\s*\([\s\S]*?\);/)?.[0] ?? ''}`;
63
- expect(producerContract(duplicated)).toBe(false);
64
-
65
- const gbufferStart = source.indexOf('fn fs_gbuffer');
66
- const gbufferFogged = `${source.slice(0, gbufferStart)}${source
67
- .slice(gbufferStart)
68
- .replace(
69
- 'let baseUv =',
70
- 'let _falsify = applySceneFog(vec3<f32>(1.0), 1.0, vec3<f32>(0.0));\n let baseUv =',
71
- )}`;
72
- expect(gbufferFogged).not.toBe(source);
73
- expect(body(gbufferFogged, 'fn fs_gbuffer', 'struct TemporalVsOut')).toContain(
74
- 'applySceneFog(',
75
- );
37
+ it('keeps a single producer matrix without fallback fog entry points', () => {
38
+ const sources = SHADER_FILES.map(shaderSource).join('\n');
39
+ expect(sources.match(/apply_fog|applySceneFog|FogRay/g)).toBeNull();
40
+ expect(shaderSource('volume/volume-integrate.wgsl')).toContain('local_scatter');
76
41
  });
77
42
  });
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const punctual = readFileSync(new URL('../lighting-punctual.wgsl', import.meta.url), 'utf8');
5
+ const attenuation = readFileSync(new URL('../lighting-attenuation.wgsl', import.meta.url), 'utf8');
6
+ const inject = readFileSync(new URL('../volume/volume-inject.wgsl', import.meta.url), 'utf8');
7
+ const integrate = readFileSync(new URL('../volume/volume-integrate.wgsl', import.meta.url), 'utf8');
8
+
9
+ describe('shared punctual optics for volumetric fog', () => {
10
+ it('publishes one finite Point and Spot volume evaluator', () => {
11
+ expect(punctual).toContain('fn evalVolumePoint(');
12
+ expect(punctual).toContain('fn evalVolumeSpot(');
13
+ expect(punctual).toContain('evalDistanceAttenuation');
14
+ expect(punctual).toContain('evalSpotAttenuation');
15
+ expect(punctual).toContain('max(dSquared, 1e-4)');
16
+ expect(attenuation).toContain('smoothstep(cosOuter, cosInner');
17
+ expect(attenuation).toContain('safeDistance');
18
+ });
19
+
20
+ it('uses the shared evaluators from the fixed inject and integrate stages', () => {
21
+ expect(inject).toContain('forgeax_pbr::lighting_punctual');
22
+ expect(integrate).toContain('forgeax_pbr::lighting_punctual');
23
+ expect(inject).toContain('evalVolumePoint');
24
+ expect(inject).toContain('evalVolumeSpot');
25
+ expect(integrate).toContain('evalVolumePoint');
26
+ expect(integrate).toContain('evalVolumeSpot');
27
+ expect(inject).toContain('pointLightsBuffer');
28
+ expect(inject).toContain('spotLightsBuffer');
29
+ });
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
+
39
+ it('keeps the punctual volume path finite and monotonic at range boundaries', () => {
40
+ expect(punctual).toMatch(/max\([^\n]*1e-4/);
41
+ expect(attenuation).toMatch(/clamp\(1\.0 - \(safeDistance \* invRangeSquared\)/);
42
+ expect(attenuation).toContain('return distance * cone');
43
+ expect(integrate).toContain('isFinite');
44
+ });
45
+
46
+ it('uses the pinned Three density grain and smoke amount mapping', () => {
47
+ expect(integrate).toContain('fn volume_sample_grain(');
48
+ expect(integrate).toContain('fract((position + time_scaled * time_scale) * scale)');
49
+ expect(integrate).toContain('volume_sample_grain(position, 0.1, time_scaled, 1.0)');
50
+ expect(integrate).toContain('volume_sample_grain(position, 0.05, time_scaled, 1.0)');
51
+ expect(integrate).toContain('volume_sample_grain(position, 0.02, time_scaled, 2.0)');
52
+ expect(integrate).toContain('return 2.0 * grain - 1.0;');
53
+ expect(integrate).toContain('volume_scattering_density(world_position, frame_index)');
54
+ expect(integrate).not.toContain('density_uv');
55
+ });
56
+ });
@@ -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
  });