@forgeax/engine-shader 0.1.7 → 0.1.20

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 (117) hide show
  1. package/README.md +34 -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 +53 -2
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/material-schemas.d.ts.map +1 -1
  9. package/package.json +6 -3
  10. package/src/ShaderRegistry.ts +50 -0
  11. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +86 -12
  12. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +185 -0
  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 +48 -0
  17. package/src/__tests__/lighting-punctual.unit.test.ts +1 -1
  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-builtins.unit.test.ts +38 -1
  21. package/src/__tests__/material-contract.unit.test.ts +123 -0
  22. package/src/__tests__/motion-blur.unit.test.ts +44 -0
  23. package/src/__tests__/scene-temporal.unit.test.ts +23 -5
  24. package/src/__tests__/shader-manifest-transmission.unit.test.ts +75 -0
  25. package/src/__tests__/shader.unit.test.ts +44 -3
  26. package/src/__tests__/sprite-variants.unit.test.ts +20 -0
  27. package/src/__tests__/taa-resolve.unit.test.ts +75 -3
  28. package/src/__tests__/transmission-thickness-scale.unit.test.ts +19 -0
  29. package/src/common.wgsl +34 -65
  30. package/src/default-standard-pbr-skin.wgsl +29 -28
  31. package/src/default-standard-pbr.wgsl +257 -57
  32. package/src/fxaa.wgsl +5 -6
  33. package/src/hdrp-cluster-forward.wgsl +41 -3
  34. package/src/hdrp-deferred-lighting.wgsl +3 -27
  35. package/src/index.ts +3 -0
  36. package/src/lighting-attenuation.wgsl +58 -0
  37. package/src/lighting-directional.wgsl +31 -3
  38. package/src/lighting-punctual.wgsl +57 -12
  39. package/src/material-schemas.ts +7 -0
  40. package/src/motion-blur.wgsl +77 -0
  41. package/src/msdf-text.wgsl +4 -23
  42. package/src/pbr-temporal.wgsl +15 -2
  43. package/src/scene-temporal.wgsl +54 -0
  44. package/src/shadow-pcf.wgsl +31 -10
  45. package/src/shadow_caster.wgsl +27 -2
  46. package/src/skybox.wgsl +2 -7
  47. package/src/sprite-lit.wgsl +7 -25
  48. package/src/sprite.wgsl +7 -25
  49. package/src/taa-resolve.wgsl +76 -81
  50. package/src/tonemap.wgsl +7 -9
  51. package/src/unlit.wgsl +7 -28
  52. package/src/volume/volume-composite.wgsl +113 -0
  53. package/src/volume/volume-inject.wgsl +249 -0
  54. package/src/volume/volume-integrate.wgsl +342 -0
  55. package/src/volume/volume-temporal.wgsl +70 -0
  56. package/dist/__tests__/atmosphere-background.test.d.ts +0 -2
  57. package/dist/__tests__/atmosphere-background.test.d.ts.map +0 -1
  58. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts +0 -2
  59. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts.map +0 -1
  60. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts +0 -2
  61. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts.map +0 -1
  62. package/dist/__tests__/atmosphere-preetham-red.test.d.ts +0 -2
  63. package/dist/__tests__/atmosphere-preetham-red.test.d.ts.map +0 -1
  64. package/dist/__tests__/atmosphere-preetham.test.d.ts +0 -2
  65. package/dist/__tests__/atmosphere-preetham.test.d.ts.map +0 -1
  66. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts +0 -2
  67. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts.map +0 -1
  68. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts +0 -2
  69. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts.map +0 -1
  70. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts +0 -2
  71. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts.map +0 -1
  72. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts +0 -2
  73. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts.map +0 -1
  74. package/dist/__tests__/deferred-lighting-ssao.test.d.ts +0 -2
  75. package/dist/__tests__/deferred-lighting-ssao.test.d.ts.map +0 -1
  76. package/dist/__tests__/fog-oracle.unit.test.d.ts +0 -2
  77. package/dist/__tests__/fog-oracle.unit.test.d.ts.map +0 -1
  78. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts +0 -2
  79. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts.map +0 -1
  80. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts +0 -2
  81. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts.map +0 -1
  82. package/dist/__tests__/hdrp-ssao-compile.test.d.ts +0 -2
  83. package/dist/__tests__/hdrp-ssao-compile.test.d.ts.map +0 -1
  84. package/dist/__tests__/ibl-irradiance.unit.test.d.ts +0 -2
  85. package/dist/__tests__/ibl-irradiance.unit.test.d.ts.map +0 -1
  86. package/dist/__tests__/ibl-sampling.unit.test.d.ts +0 -2
  87. package/dist/__tests__/ibl-sampling.unit.test.d.ts.map +0 -1
  88. package/dist/__tests__/lighting-punctual.unit.test.d.ts +0 -2
  89. package/dist/__tests__/lighting-punctual.unit.test.d.ts.map +0 -1
  90. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts +0 -2
  91. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts.map +0 -1
  92. package/dist/__tests__/material-builtins.unit.test.d.ts +0 -2
  93. package/dist/__tests__/material-builtins.unit.test.d.ts.map +0 -1
  94. package/dist/__tests__/material-contract.unit.test.d.ts +0 -2
  95. package/dist/__tests__/material-contract.unit.test.d.ts.map +0 -1
  96. package/dist/__tests__/material-derived-builtins.integration.test.d.ts +0 -2
  97. package/dist/__tests__/material-derived-builtins.integration.test.d.ts.map +0 -1
  98. package/dist/__tests__/points-lines-shader.unit.test.d.ts +0 -2
  99. package/dist/__tests__/points-lines-shader.unit.test.d.ts.map +0 -1
  100. package/dist/__tests__/public-surface.unit.test.d.ts +0 -2
  101. package/dist/__tests__/public-surface.unit.test.d.ts.map +0 -1
  102. package/dist/__tests__/scene-temporal.unit.test.d.ts +0 -2
  103. package/dist/__tests__/scene-temporal.unit.test.d.ts.map +0 -1
  104. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts +0 -2
  105. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts.map +0 -1
  106. package/dist/__tests__/shader.unit.test.d.ts +0 -2
  107. package/dist/__tests__/shader.unit.test.d.ts.map +0 -1
  108. package/dist/__tests__/sprite-lit-shader.test.d.ts +0 -2
  109. package/dist/__tests__/sprite-lit-shader.test.d.ts.map +0 -1
  110. package/dist/__tests__/sprite-variants.unit.test.d.ts +0 -2
  111. package/dist/__tests__/sprite-variants.unit.test.d.ts.map +0 -1
  112. package/dist/__tests__/taa-resolve.unit.test.d.ts +0 -2
  113. package/dist/__tests__/taa-resolve.unit.test.d.ts.map +0 -1
  114. package/dist/__tests__/transparent-pbr.unit.test.d.ts +0 -2
  115. package/dist/__tests__/transparent-pbr.unit.test.d.ts.map +0 -1
  116. package/dist/__tests__/vertex-color-variant.unit.test.d.ts +0 -2
  117. package/dist/__tests__/vertex-color-variant.unit.test.d.ts.map +0 -1
@@ -0,0 +1,185 @@
1
+ import { ok, type ShaderModule } from '@forgeax/engine-rhi';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { findVariantByKey, ShaderRegistry, type ShaderRegistryDevice } from '../index.js';
4
+
5
+ type EngineShaderManifest = Awaited<
6
+ ReturnType<typeof import('@forgeax/engine-vite-plugin-shader').buildEngineShaderManifest>
7
+ >;
8
+
9
+ function dataUrl(payload: unknown): string {
10
+ return `data:application/json,${encodeURIComponent(JSON.stringify(payload))}`;
11
+ }
12
+
13
+ function device(): ShaderRegistryDevice {
14
+ return {
15
+ createShaderModule(): ReturnType<ShaderRegistryDevice['createShaderModule']> {
16
+ return ok({} as ShaderModule);
17
+ },
18
+ };
19
+ }
20
+
21
+ async function engineManifest(): Promise<EngineShaderManifest> {
22
+ const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
23
+ return buildEngineShaderManifest();
24
+ }
25
+
26
+ describe('default Standard PBR transmission manifest contract', () => {
27
+ it('publishes both TRANSMISSION_AVAILABLE variants from the cooked producer', {
28
+ timeout: 20_000,
29
+ }, async () => {
30
+ const manifest = await engineManifest();
31
+ const standard = manifest.materialShaders.find(
32
+ (entry) => entry.identifier === 'forgeax::default-standard-pbr',
33
+ );
34
+
35
+ expect(standard).toBeDefined();
36
+ if (standard === undefined) return;
37
+ const transmissionVariants = standard.variants.filter(
38
+ (variant) => 'TRANSMISSION_AVAILABLE' in variant.defines,
39
+ );
40
+ expect(transmissionVariants).toHaveLength(32);
41
+ expect(new Set(transmissionVariants.map((variant) => variant.definesKey)).size).toBe(32);
42
+ 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
+ );
65
+ }
66
+ }
67
+ }
68
+ });
69
+
70
+ it('omits transmission-only texture bindings from the fallback variant', {
71
+ timeout: 20_000,
72
+ }, async () => {
73
+ const manifest = await engineManifest();
74
+ const standard = manifest.materialShaders.find(
75
+ (entry) => entry.identifier === 'forgeax::default-standard-pbr',
76
+ );
77
+ expect(standard).toBeDefined();
78
+ if (standard === undefined) return;
79
+
80
+ const fallback = standard.variants.find(
81
+ (variant) =>
82
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
83
+ variant.defines.STORAGE_BUFFER_AVAILABLE === true &&
84
+ variant.defines.TRANSMISSION_AVAILABLE === false &&
85
+ variant.defines.VERTEX_COLOR_AVAILABLE === false,
86
+ );
87
+ const transmission = standard.variants.find(
88
+ (variant) =>
89
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
90
+ variant.defines.STORAGE_BUFFER_AVAILABLE === true &&
91
+ variant.defines.TRANSMISSION_AVAILABLE === true &&
92
+ variant.defines.VERTEX_COLOR_AVAILABLE === false,
93
+ );
94
+ expect(fallback?.composedWgsl).not.toContain('@binding(13)');
95
+ expect(fallback?.composedWgsl).not.toContain('@binding(24)');
96
+ expect(transmission?.composedWgsl).toContain('@binding(13)');
97
+ expect(transmission?.composedWgsl).toContain('@binding(24)');
98
+ });
99
+
100
+ it('registers one Standard row and resolves a declared variant by exact key', {
101
+ timeout: 20_000,
102
+ }, async () => {
103
+ const manifest = await engineManifest();
104
+ const standard = manifest.materialShaders.find(
105
+ (entry) => entry.identifier === 'forgeax::default-standard-pbr',
106
+ );
107
+ expect(standard).toBeDefined();
108
+ if (standard === undefined) return;
109
+
110
+ const registry = new ShaderRegistry({
111
+ device: device(),
112
+ manifestUrl: dataUrl({ entries: [], materialShaders: [standard] }),
113
+ });
114
+ const loaded = await registry.loadManifest();
115
+ expect(loaded.ok).toBe(true);
116
+ const rows = Array.from(registry.materialShaderManifestEntries()).filter(
117
+ (entry) => entry.identifier === 'forgeax::default-standard-pbr',
118
+ );
119
+ expect(rows).toHaveLength(1);
120
+ const registered = rows[0];
121
+ expect(registered).toBeDefined();
122
+ if (registered === undefined) return;
123
+
124
+ const transmission = registered.variants.find(
125
+ (variant) => variant.defines.TRANSMISSION_AVAILABLE === true,
126
+ );
127
+ expect(transmission).toBeDefined();
128
+ if (transmission === undefined) return;
129
+ expect(findVariantByKey(registered, transmission.definesKey)).toBe(transmission);
130
+ expect(
131
+ findVariantByKey(registered, `${transmission.definesKey}+UNDECLARED=true`),
132
+ ).toBeUndefined();
133
+ });
134
+
135
+ it('rejects a manifest with a missing transmission variant before prewarm', async () => {
136
+ const missingVariant = {
137
+ identifier: 'forgeax::default-standard-pbr',
138
+ sourcePath: 'default-standard-pbr.wgsl',
139
+ composedWgsl: 'standard-default',
140
+ paramSchema: '[]',
141
+ variants: [
142
+ {
143
+ definesKey: 'TRANSMISSION_AVAILABLE=false',
144
+ defines: { TRANSMISSION_AVAILABLE: false },
145
+ composedWgsl: 'standard-no-transmission',
146
+ },
147
+ ],
148
+ };
149
+ const registry = new ShaderRegistry({
150
+ device: device(),
151
+ manifestUrl: dataUrl({ entries: [], materialShaders: [missingVariant] }),
152
+ });
153
+
154
+ const loaded = await registry.loadManifest();
155
+ expect(loaded.ok).toBe(false);
156
+ });
157
+
158
+ it('rejects duplicate variant keys instead of allowing ambiguous prewarm', async () => {
159
+ const duplicateVariant = {
160
+ identifier: 'forgeax::default-standard-pbr',
161
+ sourcePath: 'default-standard-pbr.wgsl',
162
+ composedWgsl: 'standard-default',
163
+ paramSchema: '[]',
164
+ variants: [
165
+ {
166
+ definesKey: 'TRANSMISSION_AVAILABLE=true',
167
+ defines: { TRANSMISSION_AVAILABLE: true },
168
+ composedWgsl: 'standard-transmission-a',
169
+ },
170
+ {
171
+ definesKey: 'TRANSMISSION_AVAILABLE=true',
172
+ defines: { TRANSMISSION_AVAILABLE: true },
173
+ composedWgsl: 'standard-transmission-b',
174
+ },
175
+ ],
176
+ };
177
+ const registry = new ShaderRegistry({
178
+ device: device(),
179
+ manifestUrl: dataUrl({ entries: [], materialShaders: [duplicateVariant] }),
180
+ });
181
+
182
+ const loaded = await registry.loadManifest();
183
+ expect(loaded.ok).toBe(false);
184
+ });
185
+ });
@@ -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,48 @@
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 the punctual volume path finite and monotonic at range boundaries', () => {
32
+ expect(punctual).toMatch(/max\([^\n]*1e-4/);
33
+ expect(attenuation).toMatch(/clamp\(1\.0 - \(safeDistance \* invRangeSquared\)/);
34
+ expect(attenuation).toContain('return distance * cone');
35
+ expect(integrate).toContain('isFinite');
36
+ });
37
+
38
+ it('uses the pinned Three density grain and smoke amount mapping', () => {
39
+ expect(integrate).toContain('fn volume_sample_grain(');
40
+ expect(integrate).toContain('fract((position + time_scaled * time_scale) * scale)');
41
+ expect(integrate).toContain('volume_sample_grain(position, 0.1, time_scaled, 1.0)');
42
+ expect(integrate).toContain('volume_sample_grain(position, 0.05, time_scaled, 1.0)');
43
+ expect(integrate).toContain('volume_sample_grain(position, 0.02, time_scaled, 2.0)');
44
+ expect(integrate).toContain('return 2.0 * grain - 1.0;');
45
+ expect(integrate).toContain('volume_scattering_density(world_position, frame_index)');
46
+ expect(integrate).not.toContain('density_uv');
47
+ });
48
+ });
@@ -59,7 +59,7 @@ describe('direct punctual lighting shader contract', () => {
59
59
  const source = await readFile(pbrShaderPath, 'utf8');
60
60
 
61
61
  expect(source).toContain(
62
- 'evaluate_cluster_lights(in.ndc, in.viewZ, in.worldPos, n, v, albedo, metallic, a, f0)',
62
+ 'evaluate_cluster_lights(in.ndc, in.viewZ, in.worldPos, n, v, diffuseAlbedo, metallic, a, f0)',
63
63
  );
64
64
  expect(source).toContain('coatAlpha, vec3<f32>(0.04)');
65
65
  });