@forgeax/engine-shader 0.1.2

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 (162) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/ShaderRegistry.d.ts +208 -0
  5. package/dist/ShaderRegistry.d.ts.map +1 -0
  6. package/dist/__tests__/atmosphere-background.test.d.ts +2 -0
  7. package/dist/__tests__/atmosphere-background.test.d.ts.map +1 -0
  8. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts +2 -0
  9. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts.map +1 -0
  10. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts +2 -0
  11. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts.map +1 -0
  12. package/dist/__tests__/atmosphere-preetham-red.test.d.ts +2 -0
  13. package/dist/__tests__/atmosphere-preetham-red.test.d.ts.map +1 -0
  14. package/dist/__tests__/atmosphere-preetham.test.d.ts +2 -0
  15. package/dist/__tests__/atmosphere-preetham.test.d.ts.map +1 -0
  16. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts +2 -0
  17. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts.map +1 -0
  18. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts +2 -0
  19. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts +2 -0
  21. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts.map +1 -0
  22. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts +2 -0
  23. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/deferred-lighting-ssao.test.d.ts +2 -0
  25. package/dist/__tests__/deferred-lighting-ssao.test.d.ts.map +1 -0
  26. package/dist/__tests__/fog-oracle.unit.test.d.ts +2 -0
  27. package/dist/__tests__/fog-oracle.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts +2 -0
  29. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts +2 -0
  31. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts.map +1 -0
  32. package/dist/__tests__/hdrp-ssao-compile.test.d.ts +2 -0
  33. package/dist/__tests__/hdrp-ssao-compile.test.d.ts.map +1 -0
  34. package/dist/__tests__/ibl-irradiance.unit.test.d.ts +2 -0
  35. package/dist/__tests__/ibl-irradiance.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/ibl-sampling.unit.test.d.ts +2 -0
  37. package/dist/__tests__/ibl-sampling.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/lighting-punctual.unit.test.d.ts +2 -0
  39. package/dist/__tests__/lighting-punctual.unit.test.d.ts.map +1 -0
  40. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts +2 -0
  41. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/material-builtins.unit.test.d.ts +2 -0
  43. package/dist/__tests__/material-builtins.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/material-contract.unit.test.d.ts +2 -0
  45. package/dist/__tests__/material-contract.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/material-derived-builtins.integration.test.d.ts +2 -0
  47. package/dist/__tests__/material-derived-builtins.integration.test.d.ts.map +1 -0
  48. package/dist/__tests__/public-surface.unit.test.d.ts +2 -0
  49. package/dist/__tests__/public-surface.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/scene-temporal.unit.test.d.ts +2 -0
  51. package/dist/__tests__/scene-temporal.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts +2 -0
  53. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/shader.unit.test.d.ts +2 -0
  55. package/dist/__tests__/shader.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/sprite-lit-shader.test.d.ts +2 -0
  57. package/dist/__tests__/sprite-lit-shader.test.d.ts.map +1 -0
  58. package/dist/__tests__/sprite-variants.unit.test.d.ts +2 -0
  59. package/dist/__tests__/sprite-variants.unit.test.d.ts.map +1 -0
  60. package/dist/__tests__/taa-resolve.unit.test.d.ts +2 -0
  61. package/dist/__tests__/taa-resolve.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/transparent-pbr.unit.test.d.ts +2 -0
  63. package/dist/__tests__/transparent-pbr.unit.test.d.ts.map +1 -0
  64. package/dist/__tests__/vertex-color-variant.unit.test.d.ts +2 -0
  65. package/dist/__tests__/vertex-color-variant.unit.test.d.ts.map +1 -0
  66. package/dist/errors.d.ts +96 -0
  67. package/dist/errors.d.ts.map +1 -0
  68. package/dist/index.d.ts +44 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.mjs +560 -0
  71. package/dist/index.mjs.map +1 -0
  72. package/dist/material/artifact-registry.d.ts +31 -0
  73. package/dist/material/artifact-registry.d.ts.map +1 -0
  74. package/dist/material/artifact-types.d.ts +13 -0
  75. package/dist/material/artifact-types.d.ts.map +1 -0
  76. package/dist/material-schemas.d.ts +8 -0
  77. package/dist/material-schemas.d.ts.map +1 -0
  78. package/dist/register-default-sprite-lit.d.ts +40 -0
  79. package/dist/register-default-sprite-lit.d.ts.map +1 -0
  80. package/dist/register-default-standard-pbr-skin.d.ts +34 -0
  81. package/dist/register-default-standard-pbr-skin.d.ts.map +1 -0
  82. package/dist/tonemap.d.ts +13 -0
  83. package/dist/tonemap.d.ts.map +1 -0
  84. package/dist/types.d.ts +47 -0
  85. package/dist/types.d.ts.map +1 -0
  86. package/package.json +58 -0
  87. package/src/ShaderRegistry.ts +519 -0
  88. package/src/__tests__/atmosphere-background.test.ts +33 -0
  89. package/src/__tests__/atmosphere-cubemap-orientation.test.ts +68 -0
  90. package/src/__tests__/atmosphere-preetham-numeric.test.ts +122 -0
  91. package/src/__tests__/atmosphere-preetham-red.test.ts +52 -0
  92. package/src/__tests__/atmosphere-preetham.test.ts +132 -0
  93. package/src/__tests__/atmosphere-three-r184-reference.test.ts +122 -0
  94. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +31 -0
  95. package/src/__tests__/builtin-texture-sampling-contract.test.ts +56 -0
  96. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +19 -0
  97. package/src/__tests__/deferred-lighting-ssao.test.ts +114 -0
  98. package/src/__tests__/fog-oracle.unit.test.ts +132 -0
  99. package/src/__tests__/fog-orthographic-ray.unit.test.ts +62 -0
  100. package/src/__tests__/fog-producer-matrix.integration.test.ts +77 -0
  101. package/src/__tests__/hdrp-ssao-compile.test.ts +185 -0
  102. package/src/__tests__/ibl-irradiance.unit.test.ts +43 -0
  103. package/src/__tests__/ibl-sampling.unit.test.ts +40 -0
  104. package/src/__tests__/lighting-punctual.unit.test.ts +66 -0
  105. package/src/__tests__/manifest.fixture.json +22 -0
  106. package/src/__tests__/material-artifact-dedup.unit.test.ts +21 -0
  107. package/src/__tests__/material-builtins.unit.test.ts +111 -0
  108. package/src/__tests__/material-contract.unit.test.ts +79 -0
  109. package/src/__tests__/material-derived-builtins.integration.test.ts +30 -0
  110. package/src/__tests__/public-surface.unit.test.ts +9 -0
  111. package/src/__tests__/scene-temporal.unit.test.ts +32 -0
  112. package/src/__tests__/shader-registry-transaction.unit.test.ts +116 -0
  113. package/src/__tests__/shader.unit.test.ts +1909 -0
  114. package/src/__tests__/sprite-lit-shader.test.ts +240 -0
  115. package/src/__tests__/sprite-variants.unit.test.ts +209 -0
  116. package/src/__tests__/taa-resolve.unit.test.ts +32 -0
  117. package/src/__tests__/transparent-pbr.unit.test.ts +21 -0
  118. package/src/__tests__/vertex-color-variant.unit.test.ts +52 -0
  119. package/src/atmosphere-background.wgsl +80 -0
  120. package/src/atmosphere-cubemap.wgsl +72 -0
  121. package/src/atmosphere-ibl.wgsl +127 -0
  122. package/src/atmosphere-preetham.wgsl +214 -0
  123. package/src/bloom-blur.wgsl +89 -0
  124. package/src/bloom-bright.wgsl +50 -0
  125. package/src/bloom-composite.wgsl +54 -0
  126. package/src/brdf.wgsl +25 -0
  127. package/src/common.wgsl +523 -0
  128. package/src/default-standard-pbr-skin.wgsl +538 -0
  129. package/src/default-standard-pbr.wgsl +794 -0
  130. package/src/errors.ts +169 -0
  131. package/src/fog.wgsl +27 -0
  132. package/src/fxaa.wgsl +259 -0
  133. package/src/hdrp-cluster-forward.wgsl +186 -0
  134. package/src/hdrp-deferred-lighting.wgsl +69 -0
  135. package/src/hdrp-ssao.wgsl +174 -0
  136. package/src/ibl-brdf-lut.wgsl +92 -0
  137. package/src/ibl-equirect-to-cube.wgsl +56 -0
  138. package/src/ibl-irradiance.wgsl +89 -0
  139. package/src/ibl-prefilter.wgsl +101 -0
  140. package/src/ibl-sampling.wgsl +58 -0
  141. package/src/ibl-shared.wgsl +118 -0
  142. package/src/index.ts +166 -0
  143. package/src/lighting-directional.wgsl +471 -0
  144. package/src/lighting-punctual.wgsl +274 -0
  145. package/src/material/artifact-registry.ts +78 -0
  146. package/src/material/artifact-types.ts +26 -0
  147. package/src/material-schemas.ts +48 -0
  148. package/src/msdf-text.wgsl +208 -0
  149. package/src/pbr-temporal.wgsl +73 -0
  150. package/src/register-default-sprite-lit.ts +87 -0
  151. package/src/register-default-standard-pbr-skin.ts +74 -0
  152. package/src/shadow-pcf.wgsl +120 -0
  153. package/src/shadow_caster.wgsl +54 -0
  154. package/src/skybox.wgsl +75 -0
  155. package/src/sprite-lit.wgsl +307 -0
  156. package/src/sprite.wgsl +365 -0
  157. package/src/taa-resolve.wgsl +129 -0
  158. package/src/tbn.wgsl +54 -0
  159. package/src/tonemap.ts +13 -0
  160. package/src/tonemap.wgsl +177 -0
  161. package/src/types.ts +59 -0
  162. package/src/unlit.wgsl +186 -0
@@ -0,0 +1,132 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
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');
7
+
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;
17
+ }
18
+
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;
24
+ }
25
+
26
+ function fogOpacity(input: FogCase): number {
27
+ return input.maxOpacity * (1 - Math.exp(-Math.max(opticalDepth(input), 0)));
28
+ }
29
+
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
+ ];
92
+
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
+ }
109
+ });
110
+
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]);
119
+ });
120
+
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/);
131
+ });
132
+ });
@@ -0,0 +1,62 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const shaderFiles = [
6
+ 'default-standard-pbr.wgsl',
7
+ 'default-standard-pbr-skin.wgsl',
8
+ 'unlit.wgsl',
9
+ 'sprite.wgsl',
10
+ 'sprite-lit.wgsl',
11
+ 'msdf-text.wgsl',
12
+ ] as const;
13
+
14
+ function source(name: (typeof shaderFiles)[number]): string {
15
+ return readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
16
+ }
17
+
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', () => {
32
+ for (const name of shaderFiles) {
33
+ 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);
39
+ }
40
+ });
41
+
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)),
54
+ );
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);
61
+ });
62
+ });
@@ -0,0 +1,77 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const SHADER_FILES = [
6
+ 'default-standard-pbr.wgsl',
7
+ 'default-standard-pbr-skin.wgsl',
8
+ 'unlit.wgsl',
9
+ 'sprite.wgsl',
10
+ 'sprite-lit.wgsl',
11
+ 'msdf-text.wgsl',
12
+ 'skybox.wgsl',
13
+ ] as const;
14
+
15
+ const shaderSource = (file: string): string =>
16
+ readFileSync(fileURLToPath(new URL(`../${file}`, import.meta.url)), 'utf8');
17
+
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', () => {
38
+ for (const file of SHADER_FILES) {
39
+ const source = shaderSource(file);
40
+ expect(producerContract(source), file).toBe(true);
41
+ }
42
+ });
43
+
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(');
53
+ });
54
+
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
+ );
76
+ });
77
+ });
@@ -0,0 +1,185 @@
1
+ // @forgeax/engine-shader/__tests__/hdrp-ssao-compile.test.ts -
2
+ // Structural unit test for hdrp-ssao.wgsl (M2 / w7).
3
+ // feat-20260612-hdrp-ssao.
4
+ //
5
+ // This package physically isolates naga (4 grep gates in scripts/), so the
6
+ // test is source-level structural (same pattern as fxaa-shader.test.ts +
7
+ // ibl-modules-parse.test.ts). Read the WGSL source via node:fs and assert:
8
+ //
9
+ // (a) file exists and declares define_import_path
10
+ // (b) two fragment entry points — fs_ssao_calc + fs_ssao_blur
11
+ // (c) imports fullscreen_triangle + FullscreenOutput from common.wgsl
12
+ // (d) calc output is @location(0) f32 (R8 scalar)
13
+ // (e) SSAO uniform group (@group(0)) with 3 mat4 fields
14
+ // (f) kernel uniform buffer @binding
15
+ // (g) noise texture + depth + normal texture bindings
16
+ // (h) blur @location(0) output is f32 (R8 scalar)
17
+ //
18
+ // RED before w8 (hdrp-ssao.wgsl does not exist yet).
19
+ // GREEN after w8 writes the file.
20
+
21
+ import { beforeAll, describe, expect, it } from 'vitest';
22
+
23
+ // ── node:* imports (dynamic, vite-ignore — same pattern as fxaa-shader.test.ts) ──
24
+
25
+ interface NodeFs {
26
+ readFileSync: (p: string, enc: string) => string;
27
+ }
28
+ interface NodePath {
29
+ resolve: (...parts: string[]) => string;
30
+ dirname: (p: string) => string;
31
+ }
32
+ interface NodeUrl {
33
+ fileURLToPath: (u: string) => string;
34
+ }
35
+
36
+ let ssaoSource!: string;
37
+ let srcDir!: string;
38
+ let fs!: NodeFs;
39
+
40
+ beforeAll(async () => {
41
+ const fsId = 'node:fs';
42
+ const pathId = 'node:path';
43
+ const urlId = 'node:url';
44
+ fs = (await import(/* @vite-ignore */ fsId)) as NodeFs;
45
+ const path = (await import(/* @vite-ignore */ pathId)) as NodePath;
46
+ const url = (await import(/* @vite-ignore */ urlId)) as NodeUrl;
47
+ const here = url.fileURLToPath(import.meta.url);
48
+ srcDir = path.resolve(path.dirname(here), '..');
49
+ ssaoSource = fs.readFileSync(path.resolve(srcDir, 'hdrp-ssao.wgsl'), 'utf8');
50
+ });
51
+
52
+ function stripComments(src: string): string {
53
+ return src
54
+ .split(/\r?\n/)
55
+ .map((line: string) => line.replace(/\/\/.*$/, ''))
56
+ .join('\n');
57
+ }
58
+
59
+ describe('hdrp-ssao.wgsl structural compile test (M2 / w7)', () => {
60
+ it('(a) declares #define_import_path', () => {
61
+ const m = /^\s*#define_import_path\s+(\S+)\s*$/m.exec(ssaoSource);
62
+ expect(m?.[1]).toBe('forgeax_hdrp::ssao');
63
+ });
64
+
65
+ it('(b1) has fs_ssao_calc fragment entry point', () => {
66
+ const codeOnly = stripComments(ssaoSource);
67
+ expect(codeOnly).toMatch(/@fragment\s+fn\s+fs_ssao_calc\b/);
68
+ });
69
+
70
+ it('(b2) has fs_ssao_blur fragment entry point', () => {
71
+ const codeOnly = stripComments(ssaoSource);
72
+ expect(codeOnly).toMatch(/@fragment\s+fn\s+fs_ssao_blur\b/);
73
+ });
74
+
75
+ it('(c1) imports fullscreen_triangle from forgeax_view::common', () => {
76
+ expect(ssaoSource).toMatch(
77
+ /#import\s+forgeax_view::common\s*::\s*\{[^}]*\bfullscreen_triangle\b[^}]*\}/,
78
+ );
79
+ });
80
+
81
+ it('(c2) imports FullscreenOutput from forgeax_view::common', () => {
82
+ expect(ssaoSource).toMatch(
83
+ /#import\s+forgeax_view::common\s*::\s*\{[^}]*\bFullscreenOutput\b[^}]*\}/,
84
+ );
85
+ });
86
+
87
+ it('(d) fs_ssao_calc outputs @location(0) f32 (R8 scalar)', () => {
88
+ // fs_ssao_calc fragment signature: @location(0) f32
89
+ const codeOnly = stripComments(ssaoSource);
90
+ const blockMatch = /fn\s+fs_ssao_calc\b[\s\S]*?->\s*@location\(0\)\s+f32/.exec(codeOnly);
91
+ expect(blockMatch).toBeTruthy();
92
+ });
93
+
94
+ it('(e1) declares SSAO uniform struct with view + projection + inverseProjection (3 mat4<f32>)', () => {
95
+ // The uniform struct must have exactly 3 mat4x4<f32> fields.
96
+ const codeOnly = stripComments(ssaoSource);
97
+ // Accept either SsaoUniform or unnamed struct at @group(0).
98
+ // Look for a struct with 3 mat4 fields before the @group(0) binding.
99
+ const mat4Matches = codeOnly.match(/mat4x4<f32>/g);
100
+ expect(mat4Matches).toBeTruthy();
101
+ expect(mat4Matches?.length).toBeGreaterThanOrEqual(3);
102
+ });
103
+
104
+ it('(e2) SSAO uniform UBO is at @group(0) @binding(0)', () => {
105
+ const codeOnly = stripComments(ssaoSource);
106
+ expect(codeOnly).toMatch(/@group\(0\)\s*@binding\(0\)\s+var<uniform>\s+\w+_uniform/);
107
+ });
108
+
109
+ it('(f) kernel uniform buffer is declared (array<vec4<f32>>)', () => {
110
+ const codeOnly = stripComments(ssaoSource);
111
+ expect(codeOnly).toMatch(/@group\(0\)\s*@binding\(1\)\s+var<uniform>\s+\w+/);
112
+ expect(codeOnly).toMatch(/array<vec4<f32>,\s*64>/);
113
+ });
114
+
115
+ it('(g1) noise texture binding is present (@binding(2) texture_2d<f32>)', () => {
116
+ const codeOnly = stripComments(ssaoSource);
117
+ expect(codeOnly).toMatch(
118
+ /@group\(0\)\s*@binding\(2\)\s+var\s+\w+_texture\s*:\s*texture_2d<f32>/,
119
+ );
120
+ });
121
+
122
+ it('(g2) noise sampler binding is present (@binding(3))', () => {
123
+ const codeOnly = stripComments(ssaoSource);
124
+ expect(codeOnly).toMatch(/@group\(0\)\s*@binding\(3\)\s+var\s+\w+_sampler\s*:\s*sampler/);
125
+ });
126
+
127
+ it('(g3) g-buffer normal texture binding is present (@binding(4) texture_2d<f32>)', () => {
128
+ const codeOnly = stripComments(ssaoSource);
129
+ expect(codeOnly).toMatch(/@group\(0\)\s*@binding\(4\)\s+var\s+\w+\s*:\s*texture_2d<f32>/);
130
+ });
131
+
132
+ it('(g4) hdrDepth texture binding is present (@binding(5) texture_depth_2d)', () => {
133
+ const codeOnly = stripComments(ssaoSource);
134
+ expect(codeOnly).toMatch(/@group\(0\)\s*@binding\(5\)\s+var\s+\w+\s*:\s*texture_depth_2d/);
135
+ });
136
+
137
+ it('(h) fs_ssao_blur outputs @location(0) f32 (R8 scalar)', () => {
138
+ const codeOnly = stripComments(ssaoSource);
139
+ const blockMatch = /fn\s+fs_ssao_blur\b[\s\S]*?->\s*@location\(0\)\s+f32/.exec(codeOnly);
140
+ expect(blockMatch).toBeTruthy();
141
+ });
142
+ });
143
+
144
+ // ── M8 / w36 + w37 ssao-blur input fix RED ──────────────────────────────────
145
+ //
146
+ // plan-strategy §D-D: fs_ssao_blur currently reads gbuffer_normal +
147
+ // ssao_noise_sampler — that is a typo carried from copy-paste of fs_ssao_calc.
148
+ // The blur should read the half-res ssaoRaw output of fs_ssao_calc through a
149
+ // dedicated ssaoSampler. These assertions go RED until w37 fixes the WGSL.
150
+
151
+ describe('hdrp-ssao.wgsl ssao-blur input fix (M8 / w36 + w37)', () => {
152
+ it('(i1) ssaoRaw texture binding is declared (@binding for half-res calc output)', () => {
153
+ const codeOnly = stripComments(ssaoSource);
154
+ // After D-D: ssao-blur reads ssaoRaw, so the WGSL must declare a
155
+ // ssaoRaw: texture_2d<f32> binding.
156
+ expect(codeOnly).toMatch(/var\s+ssaoRaw\s*:\s*texture_2d<f32>/);
157
+ });
158
+
159
+ it('(i2) ssaoSampler binding is declared (paired with ssaoRaw)', () => {
160
+ const codeOnly = stripComments(ssaoSource);
161
+ // The companion sampler bound alongside ssaoRaw for the blur tap loop.
162
+ expect(codeOnly).toMatch(/var\s+ssaoSampler\s*:\s*sampler/);
163
+ });
164
+
165
+ it('(i3) fs_ssao_blur body reads ssaoRaw, NOT gbuffer_normal', () => {
166
+ const codeOnly = stripComments(ssaoSource);
167
+ // Carve out the fs_ssao_blur function body.
168
+ const blurMatch = /fn\s+fs_ssao_blur\b[\s\S]*?\n\}/.exec(codeOnly);
169
+ expect(blurMatch).toBeTruthy();
170
+ if (!blurMatch) return;
171
+ const body = blurMatch[0];
172
+ expect(body).toMatch(/textureSample\s*\(\s*ssaoRaw\b/);
173
+ // The blur must not reference gbuffer_normal at all.
174
+ expect(body).not.toMatch(/\bgbuffer_normal\b/);
175
+ });
176
+
177
+ it('(i4) fs_ssao_blur body samples through ssaoSampler', () => {
178
+ const codeOnly = stripComments(ssaoSource);
179
+ const blurMatch = /fn\s+fs_ssao_blur\b[\s\S]*?\n\}/.exec(codeOnly);
180
+ expect(blurMatch).toBeTruthy();
181
+ if (!blurMatch) return;
182
+ const body = blurMatch[0];
183
+ expect(body).toMatch(/ssaoSampler\b/);
184
+ });
185
+ });
@@ -0,0 +1,43 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest';
2
+
3
+ interface NodeFs {
4
+ readFileSync: (path: string, encoding: string) => string;
5
+ }
6
+
7
+ interface NodePath {
8
+ resolve: (...parts: string[]) => string;
9
+ dirname: (path: string) => string;
10
+ }
11
+
12
+ interface NodeUrl {
13
+ fileURLToPath: (url: string) => string;
14
+ }
15
+
16
+ let fs!: NodeFs;
17
+ let path!: NodePath;
18
+ let srcDir!: string;
19
+ let irradianceSource!: string;
20
+
21
+ beforeAll(async () => {
22
+ fs = (await import(/* @vite-ignore */ 'node:fs')) as NodeFs;
23
+ path = (await import(/* @vite-ignore */ 'node:path')) as NodePath;
24
+ const url = (await import(/* @vite-ignore */ 'node:url')) as NodeUrl;
25
+ srcDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
26
+ irradianceSource = fs.readFileSync(path.resolve(srcDir, 'ibl-irradiance.wgsl'), 'utf8');
27
+ });
28
+
29
+ describe('IBL irradiance payload contract', () => {
30
+ it('records that convolution produces E times pi before the Lambert divide', () => {
31
+ expect(irradianceSource).toContain('IRRADIANCE_PAYLOAD_E_TIMES_PI');
32
+ expect(irradianceSource).toMatch(/irradiance\s*=\s*PI\s*\*\s*irradiance/);
33
+ });
34
+
35
+ it('keeps the constant-environment analytic oracle finite and explicit', () => {
36
+ const environment = 0.72;
37
+ const convolvedPayload = environment * Math.PI;
38
+ const diffuseRadiance = convolvedPayload / Math.PI;
39
+
40
+ expect(diffuseRadiance).toBeCloseTo(environment, 12);
41
+ expect(Number.isFinite(diffuseRadiance)).toBe(true);
42
+ });
43
+ });
@@ -0,0 +1,40 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest';
2
+
3
+ interface NodeFs {
4
+ readFileSync: (path: string, encoding: string) => string;
5
+ }
6
+
7
+ interface NodePath {
8
+ resolve: (...parts: string[]) => string;
9
+ dirname: (path: string) => string;
10
+ }
11
+
12
+ interface NodeUrl {
13
+ fileURLToPath: (url: string) => string;
14
+ }
15
+
16
+ let fs!: NodeFs;
17
+ let path!: NodePath;
18
+ let srcDir!: string;
19
+ let samplingSource!: string;
20
+
21
+ beforeAll(async () => {
22
+ fs = (await import(/* @vite-ignore */ 'node:fs')) as NodeFs;
23
+ path = (await import(/* @vite-ignore */ 'node:path')) as NodePath;
24
+ const url = (await import(/* @vite-ignore */ 'node:url')) as NodeUrl;
25
+ srcDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
26
+ samplingSource = fs.readFileSync(path.resolve(srcDir, 'ibl-sampling.wgsl'), 'utf8');
27
+ });
28
+
29
+ describe('IBL diffuse sampling payload contract', () => {
30
+ it('names the sampled payload as E over pi at the canonical sampling owner', () => {
31
+ expect(samplingSource).toContain('irradianceEOverPi');
32
+ expect(samplingSource).toMatch(/irradianceEOverPi\s*\/\s*PI/);
33
+ });
34
+
35
+ it('does not divide the same diffuse payload twice', () => {
36
+ const diffuseBody = samplingSource.match(/fn sampleIblDiffuse\([\s\S]*?\n\}/)?.[0] ?? '';
37
+ expect(diffuseBody.match(/\/\s*PI/g) ?? []).toHaveLength(1);
38
+ expect(diffuseBody).not.toMatch(/irradianceEOverPi\s*\/\s*PI[\s\S]*\/\s*PI/);
39
+ });
40
+ });
@@ -0,0 +1,66 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const shaderPath = join(dirname(fileURLToPath(import.meta.url)), '../lighting-punctual.wgsl');
7
+ const hdrpShaderPath = join(
8
+ dirname(fileURLToPath(import.meta.url)),
9
+ '../hdrp-cluster-forward.wgsl',
10
+ );
11
+ const pbrShaderPath = join(dirname(fileURLToPath(import.meta.url)), '../default-standard-pbr.wgsl');
12
+
13
+ describe('direct punctual lighting shader contract', () => {
14
+ it('exposes independent point and spot paths with explicit range and cone inputs', async () => {
15
+ const source = await readFile(shaderPath, 'utf8');
16
+
17
+ expect(source).toContain('fn evalPoint(');
18
+ expect(source).toContain('fn evalSpot(');
19
+ expect(source).toContain('invRangeSquared');
20
+ expect(source).toContain('cosInner');
21
+ expect(source).toContain('cosOuter');
22
+ expect(source).toContain('smoothstep(cosOuter, cosInner');
23
+ });
24
+
25
+ it('does not introduce a separate physical-light component or intensity profile', async () => {
26
+ const source = await readFile(shaderPath, 'utf8');
27
+
28
+ expect(source).not.toContain('PhysicalLight');
29
+ expect(source).not.toMatch(/intensityMultiplier|magicMultiplier|profile/);
30
+ });
31
+
32
+ it('leaves spot direction normalization to extract instead of HDRP', async () => {
33
+ const source = await readFile(shaderPath, 'utf8');
34
+
35
+ expect(source).toContain('lightDir');
36
+ expect(source).not.toContain('normalize(lightDir)');
37
+ });
38
+
39
+ it('keeps cluster punctual evaluation in the shared lighting owner', async () => {
40
+ const source = await readFile(hdrpShaderPath, 'utf8');
41
+
42
+ expect(source).toContain('kind_and_shadow');
43
+ expect(source).toContain('evalPoint');
44
+ expect(source).toContain('evalSpot');
45
+ expect(source).toContain('evalSpotShadowed');
46
+ expect(source).not.toContain('fn evaluate_point_light(');
47
+ expect(source).not.toContain('fn evaluate_spot_light(');
48
+ expect(source).not.toMatch(/kind\s*==\s*KIND_POINT[\s\S]{0,80}else\s*\{/);
49
+ });
50
+
51
+ it('decodes HDRP spot cone lanes in the shared evaluator order', async () => {
52
+ const source = await readFile(hdrpShaderPath, 'utf8');
53
+
54
+ expect(source).toContain('light.color.w, light.direction.w, light.position.w');
55
+ expect(source).not.toContain('light.direction.w, light.color.w, light.position.w');
56
+ });
57
+
58
+ it('passes precomputed base and clearcoat roughness/F0 facts to cluster lights', async () => {
59
+ const source = await readFile(pbrShaderPath, 'utf8');
60
+
61
+ expect(source).toContain(
62
+ 'evaluate_cluster_lights(in.ndc, in.viewZ, in.worldPos, n, v, albedo, metallic, a, f0)',
63
+ );
64
+ expect(source).toContain('coatAlpha, vec3<f32>(0.04)');
65
+ });
66
+ });
@@ -0,0 +1,22 @@
1
+ {
2
+ "entries": [
3
+ {
4
+ "hash": "abc12345",
5
+ "wgsl": "@vertex fn vs_main() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); } @fragment fn fs_main() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }",
6
+ "glsl": null,
7
+ "bindings": "[]"
8
+ },
9
+ {
10
+ "hash": "def67890",
11
+ "wgsl": "@vertex fn vs_main() -> @builtin(position) vec4<f32> { return vec4<f32>(1.0); } @fragment fn fs_main() -> @location(0) vec4<f32> { return vec4<f32>(0.5); }",
12
+ "glsl": "",
13
+ "bindings": "[]"
14
+ },
15
+ {
16
+ "hash": "ssao001",
17
+ "wgsl": "@vertex fn vs_ssao(@builtin(vertex_index) vertex_index : u32) -> SsaoVsOut { return SsaoVsOut(); } @fragment fn fs_ssao_calc(in : SsaoVsOut) -> @location(0) f32 { return 1.0; } @fragment fn fs_ssao_blur(in : SsaoVsOut) -> @location(0) f32 { return 1.0; }",
18
+ "glsl": null,
19
+ "bindings": "[]"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MaterialArtifactRegistry } from '../material/artifact-registry.js';
3
+
4
+ describe('material artifact deduplication', () => {
5
+ it('shares immutable artifacts by specialization key and rejects conflicts', () => {
6
+ const registry = new MaterialArtifactRegistry();
7
+ const first = Object.freeze({ key: 'key-a', bytes: new Uint8Array([1]) });
8
+ const second = Object.freeze({ key: 'key-a', bytes: new Uint8Array([2]) });
9
+
10
+ expect(registry.register(first)).toEqual({ ok: true, value: first });
11
+ expect(registry.register({ key: 'key-a', bytes: new Uint8Array([1]) })).toEqual({
12
+ ok: true,
13
+ value: first,
14
+ });
15
+ expect(registry.register(second)).toMatchObject({
16
+ ok: false,
17
+ error: { code: 'material-artifact-conflict' },
18
+ });
19
+ expect(registry.get('key-a')).toBe(first);
20
+ });
21
+ });