@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,240 @@
1
+ // @forgeax/engine-shader / __tests__ / sprite-lit-shader.test.ts
2
+ //
3
+ // tweak-20260701-sprite-lit-flat-default-drop-ndotl-for-2d M1 / m1-1.
4
+ //
5
+ // Grep gates + fixture tests for the sprite-lit shader after the flat 2D
6
+ // lighting fold: NdotL and Half-Lambert are removed, worldPos is emitted
7
+ // from the vertex stage via VsOut interpolant.
8
+ //
9
+ // SSOTs:
10
+ // - plan-strategy D-P1 (VsOut carries `@location(1) worldPos`)
11
+ // - plan-strategy D-P2 (light functions drop normal parameter; flat SSOT)
12
+ // - requirements AC-05 (LoC net-down), AC-06 (no halfLambert/nDotL residue),
13
+ // AC-07 (typecheck + test:unit green)
14
+ // - research.md F-4 (LDR clamp / HDR pass-through)
15
+
16
+ import { describe, expect, it } from 'vitest';
17
+ import { createBuiltinMaterialAsset } from '../index.js';
18
+
19
+ // Dynamic node:fs / node:module import via string ids — keeps the
20
+ // engine-shader package free of static `import 'node:fs'` (which
21
+ // shader.unit.test.ts already uses this pattern; see lines around the
22
+ // fsId = 'node:fs' helper). The TS strict mode in this package's
23
+ // tsconfig.lib does not ship @types/node by default; the dynamic-id
24
+ // import avoids the missing type error.
25
+ const fsId = 'node:fs';
26
+ const moduleId = 'node:module';
27
+
28
+ interface NodeFs {
29
+ readFileSync(path: string, encoding: string): string;
30
+ existsSync(path: string): boolean;
31
+ }
32
+
33
+ interface NodeModule {
34
+ createRequire(filename: string | URL): (id: string) => string;
35
+ }
36
+
37
+ async function loadFs(): Promise<NodeFs> {
38
+ const mod = (await import(fsId)) as NodeFs;
39
+ return mod;
40
+ }
41
+ async function loadModule(): Promise<NodeModule> {
42
+ const mod = (await import(moduleId)) as NodeModule;
43
+ return mod;
44
+ }
45
+
46
+ async function resolveSrcDir(): Promise<string> {
47
+ const m = await loadModule();
48
+ const require = m.createRequire(import.meta.url);
49
+ const packageJsonPath = require('@forgeax/engine-shader/package.json');
50
+ // require() returns the resolved JSON contents; we need the path, not
51
+ // the object. Fall back to require.resolve.
52
+ void packageJsonPath;
53
+ const resolved = m.createRequire(import.meta.url);
54
+ // Re-resolve by package.json path string.
55
+ const pkgJsonPath = (resolved as unknown as { resolve(id: string): string }).resolve(
56
+ '@forgeax/engine-shader/package.json',
57
+ );
58
+ return pkgJsonPath.replace(/package\.json$/, 'src');
59
+ }
60
+
61
+ async function readSpriteLitSource(): Promise<string> {
62
+ const fs = await loadFs();
63
+ const srcDir = await resolveSrcDir();
64
+ return fs.readFileSync(`${srcDir}/sprite-lit.wgsl`, 'utf8');
65
+ }
66
+
67
+ async function spriteLitWgslExists(): Promise<boolean> {
68
+ const fs = await loadFs();
69
+ const srcDir = await resolveSrcDir();
70
+ return fs.existsSync(`${srcDir}/sprite-lit.wgsl`);
71
+ }
72
+
73
+ describe('sprite-lit shader (flat 2D lighting, tweak-20260701 M1)', () => {
74
+ describe('sprite-lit.wgsl source artefact', () => {
75
+ it('file exists', async () => {
76
+ expect(await spriteLitWgslExists()).toBe(true);
77
+ });
78
+
79
+ it('first line is `#pragma variant_axis STORAGE_BUFFER_AVAILABLE`', async () => {
80
+ const src = await readSpriteLitSource();
81
+ const firstLine = src.split('\n')[0];
82
+ expect(firstLine).toBe('#pragma variant_axis STORAGE_BUFFER_AVAILABLE');
83
+ });
84
+
85
+ it('declares vs_main + fs_main + fs_main_hdr three entry points', async () => {
86
+ const src = await readSpriteLitSource();
87
+ expect(/@vertex[\s\S]*?fn vs_main\b/.test(src)).toBe(true);
88
+ expect(/@fragment[\s\S]*?fn fs_main\b/.test(src)).toBe(true);
89
+ expect(/@fragment[\s\S]*?fn fs_main_hdr\b/.test(src)).toBe(true);
90
+ });
91
+
92
+ it('does NOT reference normalTexture / normalSampler in fragment lighting code', async () => {
93
+ const src = await readSpriteLitSource();
94
+ expect(/textureSample\s*\(\s*normalTexture\b/.test(src)).toBe(false);
95
+ expect(/textureLoad\s*\(\s*normalTexture\b/.test(src)).toBe(false);
96
+ });
97
+
98
+ it('does NOT take a vertex normal attribute as a lighting input', async () => {
99
+ const src = await readSpriteLitSource();
100
+ expect(/dot\s*\(\s*in\.normal\b/.test(src)).toBe(false);
101
+ expect(/normalize\s*\(\s*in\.normal\b/.test(src)).toBe(false);
102
+ });
103
+
104
+ it('fs_main applies LDR clamp; fs_main_hdr does NOT clamp the output', async () => {
105
+ const src = await readSpriteLitSource();
106
+ const ldrIdx = src.indexOf('fn fs_main(');
107
+ const hdrIdx = src.indexOf('fn fs_main_hdr(');
108
+ expect(ldrIdx).toBeGreaterThan(0);
109
+ expect(hdrIdx).toBeGreaterThan(0);
110
+ const ldrBody = src.slice(ldrIdx, hdrIdx > ldrIdx ? hdrIdx : src.length);
111
+ const hdrBody = src.slice(hdrIdx);
112
+ const ldrClampToZeroOne =
113
+ /clamp\s*\([\s\S]+?,\s*(?:vec[234]<f32>\(\s*)?0\.0[\s\S]*?,\s*(?:vec[234]<f32>\(\s*)?1\.0/.test(
114
+ ldrBody,
115
+ );
116
+ expect(ldrClampToZeroOne).toBe(true);
117
+ const hdrVecClamp = /clamp\s*\(\s*vec[34]<f32>/.test(hdrBody);
118
+ const hdrVecBoundClamp = /clamp\s*\([^)]*,\s*vec[34]<f32>\(\s*0\.0/.test(hdrBody);
119
+ expect(hdrVecClamp).toBe(false);
120
+ expect(hdrVecBoundClamp).toBe(false);
121
+ });
122
+
123
+ it('imports lighting helpers via forgeax_pbr (no cascade shadow sampling)', async () => {
124
+ const src = await readSpriteLitSource();
125
+ expect(src.includes('_sampleShadowForCascade')).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe('flat 2D lighting fold (AC-06 grep gate: no NdotL / Half-Lambert residue)', () => {
130
+ it('no `halfLambert` identifier anywhere in the shader', async () => {
131
+ const src = await readSpriteLitSource();
132
+ // Match the camelCase identifier only; documentation strings containing
133
+ // hyphenated "Half-Lambert" or unrelated words are not the target.
134
+ expect(/halfLambert/.test(src)).toBe(false);
135
+ });
136
+
137
+ it('no `nDotL` identifier anywhere in the shader', async () => {
138
+ const src = await readSpriteLitSource();
139
+ // Match the camelCase identifier only; hyphenated feature-id strings
140
+ // like "sprite-lit-...-ndotl-for-2d" or comments spelling "NdotL" are
141
+ // not the target -- only the WGSL variable name `nDotL`.
142
+ expect(/\bnDotL\b/.test(src)).toBe(false);
143
+ });
144
+
145
+ it('no `spriteLitNormal` function (flat shading has no hardcoded normal)', async () => {
146
+ const src = await readSpriteLitSource();
147
+ expect(src.includes('spriteLitNormal')).toBe(false);
148
+ });
149
+
150
+ it('no `spriteLitWorldPos` function (worldPos now comes from VsOut interpolant)', async () => {
151
+ const src = await readSpriteLitSource();
152
+ expect(src.includes('spriteLitWorldPos')).toBe(false);
153
+ });
154
+
155
+ it('no `dot(N` or `dot(normal` lighting term in the shader body', async () => {
156
+ const src = await readSpriteLitSource();
157
+ // Directional / point / spot lighting must not project onto any surface
158
+ // normal — flat shading treats the sprite as omnidirectional.
159
+ expect(/dot\s*\(\s*N\s*,/.test(src)).toBe(false);
160
+ expect(/dot\s*\(\s*normal\s*,/.test(src)).toBe(false);
161
+ });
162
+ });
163
+
164
+ describe('VsOut carries per-fragment worldPos (D-P1)', () => {
165
+ it('VsOut declares `@location(1) worldPos: vec3<f32>` interpolant', async () => {
166
+ const src = await readSpriteLitSource();
167
+ // Match the WGSL declaration inside the VsOut struct. Whitespace-tolerant.
168
+ const re = /@location\(\s*1\s*\)\s+worldPos\s*:\s*vec3<f32>/;
169
+ expect(re.test(src)).toBe(true);
170
+ });
171
+
172
+ it('vs_main writes `out.worldPos = world.xyz`', async () => {
173
+ const src = await readSpriteLitSource();
174
+ const re = /out\.worldPos\s*=\s*world\.xyz\s*;/;
175
+ expect(re.test(src)).toBe(true);
176
+ });
177
+
178
+ it('fs_main / fs_main_hdr consume `in.worldPos` (not a reconstructed local)', async () => {
179
+ const src = await readSpriteLitSource();
180
+ const consumers = src.match(/in\.worldPos/g) ?? [];
181
+ // At minimum: one consumer in each of fs_main and fs_main_hdr.
182
+ expect(consumers.length).toBeGreaterThanOrEqual(2);
183
+ });
184
+ });
185
+
186
+ describe('flat light functions drop normal parameter (D-P2)', () => {
187
+ it('spriteLitDirectional signature is `(albedo)` only', async () => {
188
+ const src = await readSpriteLitSource();
189
+ const re = /fn\s+spriteLitDirectional\s*\(\s*albedo\s*:\s*vec3<f32>\s*\)/;
190
+ expect(re.test(src)).toBe(true);
191
+ });
192
+
193
+ it('spriteLitPoint signature is `(p, worldPos, albedo)` — no normal', async () => {
194
+ const src = await readSpriteLitSource();
195
+ const re =
196
+ /fn\s+spriteLitPoint\s*\(\s*p\s*:\s*PointLight\s*,\s*worldPos\s*:\s*vec3<f32>\s*,\s*albedo\s*:\s*vec3<f32>\s*\)/;
197
+ expect(re.test(src)).toBe(true);
198
+ });
199
+
200
+ it('spriteLitSpot signature is `(s, worldPos, albedo)` — no normal', async () => {
201
+ const src = await readSpriteLitSource();
202
+ const re =
203
+ /fn\s+spriteLitSpot\s*\(\s*s\s*:\s*SpotLight\s*,\s*worldPos\s*:\s*vec3<f32>\s*,\s*albedo\s*:\s*vec3<f32>\s*\)/;
204
+ expect(re.test(src)).toBe(true);
205
+ });
206
+
207
+ it('spriteLitShadeAccum signature is `(albedo, worldPos)` — no normal', async () => {
208
+ const src = await readSpriteLitSource();
209
+ const re =
210
+ /fn\s+spriteLitShadeAccum\s*\(\s*albedo\s*:\s*vec3<f32>\s*,\s*worldPos\s*:\s*vec3<f32>\s*\)/;
211
+ expect(re.test(src)).toBe(true);
212
+ });
213
+ });
214
+
215
+ describe('sprite-lit authored material contract', () => {
216
+ it('uses the built-in source catalog instead of a sidecar schema', () => {
217
+ const material = createBuiltinMaterialAsset('sprite');
218
+ expect(material.passes?.[0]?.program.module).toBe('forgeax_material::sprite');
219
+ expect(material.values).toBeDefined();
220
+ });
221
+ });
222
+
223
+ describe('LDR / HDR clamp boundary fixture (numeric SSOT)', () => {
224
+ // Fixture: total lit accumulator = 5.0 across all lights.
225
+ // Expected LDR output (clamped) = 1.0; expected HDR output > 1.0.
226
+ // The TS port mirrors what the WGSL must do.
227
+ const fixtureLitAccumulated = 5.0;
228
+ const ldrOut = Math.min(1.0, fixtureLitAccumulated);
229
+ const hdrOut = fixtureLitAccumulated;
230
+
231
+ it('LDR output is clamped to 1.0 at intensity=5.0 fixture', () => {
232
+ expect(ldrOut).toBe(1.0);
233
+ });
234
+
235
+ it('HDR output passes through > 1.0 at intensity=5.0 fixture', () => {
236
+ expect(hdrOut > 1.0).toBe(true);
237
+ expect(hdrOut).toBeCloseTo(5.0, 5);
238
+ });
239
+ });
240
+ });
@@ -0,0 +1,209 @@
1
+ // sprite-variants.unit.test.ts -- feat-20260625-sprite-instances-and-tilemap-terrain-static-batch
2
+ // M2 / w6 (red before w7).
3
+ //
4
+ // Structural test for the new PER_INSTANCE_REGION variant axis on sprite.wgsl.
5
+ // Pre-w7 expectations (RED):
6
+ // sprite.wgsl declares ONE axis (STORAGE_BUFFER_AVAILABLE) -- the variant
7
+ // matrix is 2x1 = 2; assertion (a) below expects 2x2 = 4 and fails.
8
+ // Post-w7 expectations (GREEN):
9
+ // sprite.wgsl declares TWO axes (STORAGE_BUFFER_AVAILABLE +
10
+ // PER_INSTANCE_REGION) for a 2x2 = 4 matrix; common.wgsl InstanceData
11
+ // gains an #if PER_INSTANCE_REGION == true conditional region field; the
12
+ // non-9-slice path in sprite.wgsl chooses its UV-region source via the
13
+ // same conditional, while the 9-slice path keeps reading material.region
14
+ // regardless (D-5; OOS-3 + 9-slice not in PER_INSTANCE_REGION scope).
15
+ //
16
+ // Reverse-falsifier assertions (b) lock the axis to sprite.wgsl alone -- if
17
+ // somebody copies the #pragma into common.wgsl the cartesian product would
18
+ // double pbr / unlit variant counts (D-4 explicitly rejected).
19
+ //
20
+ // Why structural / file-read rather than naga.parse: this package
21
+ // physically isolates naga (4 grep gates) -- cross-module compile
22
+ // validation lives in packages/runtime/src/__tests__/dawn/. Source-text
23
+ // grep is the established idiom (ibl-modules-parse.test.ts;
24
+ // register-material-shader.test.ts M1-w1 block).
25
+ //
26
+ // node:* are accessed via `await import(varId)` (vite-ignore) so the
27
+ // shader tsconfig keeps `types: ["@webgpu/types"]` without pulling in
28
+ // `@types/node` (the established cross-package pattern).
29
+ //
30
+ // Anchors:
31
+ // - requirements §AC-04 (4-variant cartesian matrix for sprite shading
32
+ // model under PER_INSTANCE_REGION x STORAGE_BUFFER_AVAILABLE)
33
+ // - plan-strategy §2 D-4 (axis declared only in sprite.wgsl, never in
34
+ // common.wgsl, so pbr/unlit do not pick up the new define)
35
+ // - plan-strategy §2 D-5 (9-slice path always reads material.region
36
+ // even when PER_INSTANCE_REGION=true; the instance-region field only
37
+ // drives the legacy single-quad path)
38
+ // - plan-strategy §4 R-2 / R-3 / R-6 (variant matrix isolation +
39
+ // useSlices compatibility + uniform-fallback safety)
40
+
41
+ import { beforeAll, describe, expect, it } from 'vitest';
42
+
43
+ interface NodeFs {
44
+ readFileSync: (p: string, enc: string) => string;
45
+ }
46
+ interface NodePath {
47
+ resolve: (...parts: string[]) => string;
48
+ dirname: (p: string) => string;
49
+ }
50
+ interface NodeUrl {
51
+ fileURLToPath: (u: string) => string;
52
+ }
53
+
54
+ let fs!: NodeFs;
55
+ let srcDir!: string;
56
+
57
+ beforeAll(async () => {
58
+ const fsId = 'node:fs';
59
+ const pathId = 'node:path';
60
+ const urlId = 'node:url';
61
+ fs = (await import(/* @vite-ignore */ fsId)) as NodeFs;
62
+ const path = (await import(/* @vite-ignore */ pathId)) as NodePath;
63
+ const url = (await import(/* @vite-ignore */ urlId)) as NodeUrl;
64
+ const here = url.fileURLToPath(import.meta.url);
65
+ srcDir = path.resolve(path.dirname(here), '..');
66
+ });
67
+
68
+ function readWgsl(file: string): string {
69
+ return fs.readFileSync(`${srcDir}/${file}`, 'utf8');
70
+ }
71
+
72
+ function stripComments(src: string): string {
73
+ return src
74
+ .split(/\r?\n/)
75
+ .map((line) => line.replace(/\/\/.*$/, ''))
76
+ .join('\n');
77
+ }
78
+
79
+ function variantAxes(src: string): readonly string[] {
80
+ const matches = stripComments(src).match(/#pragma\s+variant_axis\s+\w+/g) ?? [];
81
+ return matches;
82
+ }
83
+
84
+ describe('w6 (a) -- sprite.wgsl declares the PER_INSTANCE_REGION + STORAGE_BUFFER_AVAILABLE pair (AC-04)', () => {
85
+ it('sprite.wgsl carries exactly two #pragma variant_axis lines (2x2 = 4 cartesian variants)', () => {
86
+ const axes = variantAxes(readWgsl('sprite.wgsl'));
87
+ expect(axes).toHaveLength(2);
88
+ expect(axes).toContain('#pragma variant_axis STORAGE_BUFFER_AVAILABLE');
89
+ expect(axes).toContain('#pragma variant_axis PER_INSTANCE_REGION');
90
+ });
91
+ });
92
+
93
+ describe('w6 (b) -- pbr / unlit / sprite-adjacent shaders DO NOT pick up PER_INSTANCE_REGION (D-4 reverse falsifier)', () => {
94
+ // The reverse-falsifier guards plan-strategy D-4: if PER_INSTANCE_REGION
95
+ // is declared in common.wgsl the cartesian product would double the
96
+ // pbr / unlit variant count for zero runtime benefit. These assertions
97
+ // pin the axis to sprite.wgsl exclusively.
98
+ const NON_SPRITE_SHADERS = [
99
+ 'common.wgsl',
100
+ 'default-standard-pbr.wgsl',
101
+ 'default-standard-pbr-skin.wgsl',
102
+ 'unlit.wgsl',
103
+ 'shadow_caster.wgsl',
104
+ 'msdf-text.wgsl',
105
+ ] as const;
106
+
107
+ for (const file of NON_SPRITE_SHADERS) {
108
+ it(`${file} does NOT declare #pragma variant_axis PER_INSTANCE_REGION`, () => {
109
+ const src = readWgsl(file);
110
+ expect(src).not.toMatch(/#pragma\s+variant_axis\s+PER_INSTANCE_REGION/);
111
+ });
112
+ }
113
+
114
+ it('default-standard-pbr.wgsl keeps its storage, cluster, and vertex-color axes (none touch PER_INSTANCE_REGION)', () => {
115
+ const axes = variantAxes(readWgsl('default-standard-pbr.wgsl'));
116
+ expect(axes).toEqual([
117
+ '#pragma variant_axis STORAGE_BUFFER_AVAILABLE',
118
+ '#pragma variant_axis CLUSTER_FORWARD_AVAILABLE',
119
+ '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
120
+ ]);
121
+ });
122
+
123
+ it('unlit.wgsl keeps its storage and vertex-color axes', () => {
124
+ const axes = variantAxes(readWgsl('unlit.wgsl'));
125
+ expect(axes).toEqual([
126
+ '#pragma variant_axis STORAGE_BUFFER_AVAILABLE',
127
+ '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
128
+ ]);
129
+ });
130
+ });
131
+
132
+ describe('w6 (c) -- 9-slice path keeps reading material.region under PER_INSTANCE_REGION=true (D-5)', () => {
133
+ // OOS-3 + plan-strategy §2 D-5 lock 9-slice off the per-instance region
134
+ // path. The structural assertions encode that:
135
+ // 1. common.wgsl InstanceData carries the conditional region field so
136
+ // the WGSL compiles for PER_INSTANCE_REGION=true x useSlices=true.
137
+ // 2. sprite.wgsl useSlices branch still references material.region
138
+ // (not instances[idx].region) regardless of axis state.
139
+ // 3. The conditional that switches region sources lives ONLY inside
140
+ // the non-9-slice branch -- the useSlices branch keeps a literal
141
+ // `material.region` lookup (folded through region.zw / region.xy).
142
+
143
+ it('common.wgsl wraps the InstanceData region field in #if PER_INSTANCE_REGION == true', () => {
144
+ const src = readWgsl('common.wgsl');
145
+ // bug-20260610 SSOT (recorded in shader.unit.test.ts M1-w1): naga_oil's
146
+ // `#ifdef` only checks key presence, so the project standardised on
147
+ // `#if NAME == true` whenever a false branch is live. The new conditional
148
+ // follows the same idiom.
149
+ expect(src).toMatch(/#if\s+PER_INSTANCE_REGION\s*==\s*true[\s\S]*?region\s*:\s*vec4<f32>/);
150
+ });
151
+
152
+ it('common.wgsl InstanceData declares localFromInstance UNCONDITIONALLY (region is the only conditional field)', () => {
153
+ const src = readWgsl('common.wgsl');
154
+ // The struct still opens with localFromInstance regardless of axis state.
155
+ // The PER_INSTANCE_REGION conditional appends `region: vec4<f32>` after
156
+ // localFromInstance; it never wraps the base field.
157
+ expect(src).toMatch(
158
+ /struct\s+InstanceData\s*\{[^}]*localFromInstance\s*:\s*mat4x4<f32>[^}]*\}/s,
159
+ );
160
+ });
161
+
162
+ it('sprite.wgsl useSlices branch keeps reading material.region (D-5; not instances[idx].region)', () => {
163
+ const src = stripComments(readWgsl('sprite.wgsl'));
164
+ // Slice the useSlices block (`if (useSlices) { ... } else { ... }`) and
165
+ // assert it contains material.region but not instances[...].region.
166
+ const ifMatch = src.match(/if\s*\(\s*useSlices\s*\)\s*\{([\s\S]*?)\}\s*else\s*\{/);
167
+ expect(ifMatch, '9-slice branch (if (useSlices) { ... }) must be present').not.toBeNull();
168
+ const sliceBlock = ifMatch?.[1] ?? '';
169
+ expect(sliceBlock).toMatch(/material\.region/);
170
+ expect(sliceBlock).not.toMatch(/instances\s*\[[^\]]*\]\s*\.\s*region/);
171
+ });
172
+
173
+ it('sprite.wgsl non-9-slice branch switches region source via #if PER_INSTANCE_REGION == true', () => {
174
+ const src = readWgsl('sprite.wgsl');
175
+ // The else-branch (no slices) is the only place the conditional region
176
+ // source applies. Grep the file for the conditional + both legs --
177
+ // material.region (false leg) and instances[...].region (true leg).
178
+ expect(src).toMatch(/#if\s+PER_INSTANCE_REGION\s*==\s*true/);
179
+ // True leg must reference instances[idx].region (per-instance region).
180
+ expect(src).toMatch(/instances\s*\[[^\]]*\]\s*\.\s*region/);
181
+ // False leg must still reference material.region (default behaviour).
182
+ expect(src).toMatch(/material\.region/);
183
+ });
184
+ });
185
+
186
+ describe('w6 (d) -- MAX_UNIFORM_INSTANCES=128 stays unchanged under the new axis (R-6 uniform-fallback safety)', () => {
187
+ // Per research §Q-R-4.3 + plan-strategy §4 R-6:
188
+ // stride = mat4 64 B + vec4 16 B = 80 B per instance
189
+ // uniform-fallback worst case = 80 B x 128 = 10240 B
190
+ // WebGL2 minimum UBO binding size = 16384 B
191
+ // 10240 < 16384 so the existing 128-cap survives. The shader-side
192
+ // expression of that contract is the literal `array<InstanceData, 128>`
193
+ // in common.wgsl's uniform fallback branch; this test pins the 128 there.
194
+
195
+ it('common.wgsl uniform-fallback declares array<InstanceData, 128> (unchanged from pre-feat)', () => {
196
+ const src = readWgsl('common.wgsl');
197
+ expect(src).toMatch(
198
+ /#if\s+STORAGE_BUFFER_AVAILABLE\s*==\s*true[\s\S]*?@group\(3\)[\s\S]*?#else[\s\S]*?@group\(3\)\s+@binding\(0\)\s+var<uniform>\s+instances\s*:\s*array<InstanceData,\s*128>/,
199
+ );
200
+ });
201
+
202
+ it('common.wgsl mentions MAX_UNIFORM_INSTANCES=128 doc anchor (charter F1 grep gate)', () => {
203
+ // The plan locks the cap at 128 even under the new 80 B stride
204
+ // (10240 B < 16384 B WebGL2 floor). Pin the doc anchor that explains
205
+ // why so a future drift attempt has to update the explanation too.
206
+ const src = readWgsl('common.wgsl');
207
+ expect(src).toMatch(/MAX_UNIFORM_INSTANCES\s*=\s*128/);
208
+ });
209
+ });
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const source = readFileSync(resolve(import.meta.dirname, '../taa-resolve.wgsl'), 'utf8');
6
+
7
+ describe('taa-resolve.wgsl', () => {
8
+ it('keeps the fixed packed temporal and two-attachment ABI', () => {
9
+ expect(source).toContain('struct TaaResolveParams');
10
+ expect(source).toContain('@location(0) color');
11
+ expect(source).toContain('@location(1) temporal');
12
+ expect(source).toContain('output.temporal = temporal');
13
+ expect(source).toContain('current.a');
14
+ });
15
+
16
+ it('contains every v1 rejection and adaptive weighting term', () => {
17
+ expect(source).toContain('uv + params.currentJitterUv');
18
+ expect(source).toContain('in.uv - temporal.xy');
19
+ expect(source).toContain('closestCurrentTemporal(pixel, dimensions)');
20
+ expect(source).toContain('historyInBounds');
21
+ expect(source).toContain('depthDelta > depthThreshold');
22
+ expect(source).toContain('neighborhoodSquareMean');
23
+ expect(source).toContain('clamp(rgbToYCoCg(sampledHistory.rgb), clipMin, clipMax)');
24
+ expect(source).toContain('reactiveFactor');
25
+ expect(source).toContain('velocityFactor');
26
+ expect(source).toContain('depthFactor');
27
+ expect(source).toContain('lumaFactor');
28
+ expect(source).toContain('mix(0.88, 0.97, lumaFactor)');
29
+ expect(source).toContain('params.temporalFrameIndex < 8u');
30
+ expect(source).toContain('progressiveWeight');
31
+ });
32
+ });
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const shader = readFileSync(resolve(import.meta.dirname, '../default-standard-pbr.wgsl'), 'utf8');
6
+
7
+ describe('transparent PBR color domain contract', () => {
8
+ it('mixes transparent source and destination in the linear domain', () => {
9
+ expect(shader).toContain('blendLinearTransparent');
10
+ expect(shader).toContain('linearColorDomain');
11
+ });
12
+
13
+ it('does not contain a direct encoded-destination blend path', () => {
14
+ expect(shader).not.toContain('encodedDestinationBlend');
15
+ });
16
+
17
+ it('keeps HDR transparent output linear until the tone stage', () => {
18
+ expect(shader).toContain('linearHdrColorDomain');
19
+ expect(shader).toContain('toneStageInput');
20
+ });
21
+ });
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const SHADER_ROOT = new URL('../', import.meta.url);
5
+ const SOURCES = [
6
+ 'default-standard-pbr.wgsl',
7
+ 'default-standard-pbr-skin.wgsl',
8
+ 'unlit.wgsl',
9
+ ] as const;
10
+
11
+ function source(file: (typeof SOURCES)[number]): string {
12
+ return readFileSync(new URL(file, SHADER_ROOT), 'utf8');
13
+ }
14
+
15
+ describe('M4 vertex-color shader contract', () => {
16
+ it.each(SOURCES)('%s declares the geometry-owned color variant axis', (file) => {
17
+ const text = source(file);
18
+ expect(text).toContain('#pragma variant_axis VERTEX_COLOR_AVAILABLE');
19
+ expect(text).toMatch(
20
+ /#ifdef\s+VERTEX_COLOR_AVAILABLE[\s\S]*?@location\(13\)\s+color\s*:\s*vec4<f32>/,
21
+ );
22
+ expect(text).toMatch(
23
+ /#ifdef\s+VERTEX_COLOR_AVAILABLE[\s\S]*?@location\(14\)\s+color\s*:\s*vec4<f32>/,
24
+ );
25
+ if (file !== 'unlit.wgsl') {
26
+ expect(text).toMatch(/@location\(13\)\s+uv7\s*:\s*vec2<f32>/);
27
+ }
28
+ });
29
+
30
+ it.each(
31
+ SOURCES,
32
+ )('%s has an explicit white false variant and does not add a material flag', (file) => {
33
+ const text = source(file);
34
+ expect(text).toMatch(/#else[\s\S]*?vec4<f32>\(1\.0\)/);
35
+ expect(text).not.toMatch(/hasColor|VERTEX_COLOR\s*:/);
36
+ expect(text).not.toMatch(/struct\s+Material\s*\{[^}]*\bcolor\b/s);
37
+ });
38
+
39
+ it.each(SOURCES)('%s applies vertex color to linear RGB and all alpha consumers', (file) => {
40
+ const text = source(file);
41
+ expect(text).toMatch(/baseColor\.rgb\s*\*\s*(?:baseSample|texSample)\.rgb\s*\*\s*\w+\.rgb/);
42
+ expect(text).toMatch(/baseColor\.a\s*\*\s*(?:baseSample|texSample)\.a\s*\*\s*\w+\.a/);
43
+ expect(text).toMatch(/alphaCutoff/);
44
+ expect(text).toMatch(/fs_temporal/);
45
+ });
46
+
47
+ it('keeps the existing inter-stage uv7 location while reserving color at 14', () => {
48
+ const text = source('default-standard-pbr.wgsl');
49
+ expect(text).toMatch(/@location\(13\)\s+uv7\s*:\s*vec2<f32>/);
50
+ expect(text).toMatch(/@location\(14\)\s+color\s*:\s*vec4<f32>/);
51
+ });
52
+ });
@@ -0,0 +1,80 @@
1
+ #define_import_path forgeax_environment::background
2
+
3
+ // @forgeax/engine-shader - atmosphere-background.wgsl
4
+ //
5
+ // Background composition is deliberately separate from the Preetham cube.
6
+ // The sun disc is added exactly once at the background consumer and is never
7
+ // part of the sky-cube or IBL producer output.
8
+
9
+ #import forgeax_view::common::FullscreenOutput
10
+ #import forgeax_view::common::View
11
+ #import forgeax_view::common::fullscreen_triangle
12
+
13
+ struct AtmosphereBackgroundParams {
14
+ sunDirection: vec3<f32>,
15
+ sunIlluminance: f32,
16
+ sunColor: vec3<f32>,
17
+ _sunColorPad: f32,
18
+ turbidity: f32,
19
+ rayleigh: f32,
20
+ mieCoefficient: f32,
21
+ mieDirectionalG: f32,
22
+ sunAngularRadius: f32,
23
+ sunDiscEnabled: f32,
24
+ _tailPad: vec2<f32>,
25
+ };
26
+
27
+ @group(0) @binding(0) var sky: texture_cube<f32>;
28
+ @group(0) @binding(1) var skySampler: sampler;
29
+ @group(0) @binding(2) var<uniform> view: View;
30
+ @group(0) @binding(3) var<uniform> atmosphere: AtmosphereBackgroundParams;
31
+
32
+ fn atmosphere_sun_disc_radiance(
33
+ viewDirection: vec3<f32>,
34
+ sunDirection: vec3<f32>,
35
+ sunColor: vec3<f32>,
36
+ sunIlluminance: f32,
37
+ angularRadius: f32,
38
+ ) -> vec3<f32> {
39
+ let alignment = dot(normalize(viewDirection), normalize(sunDirection));
40
+ let edge = smoothstep(
41
+ cos(max(angularRadius * 1.5, 1e-6)),
42
+ cos(max(angularRadius, 1e-6)),
43
+ alignment,
44
+ );
45
+ let solidAngle = 2.0 * 3.14159265359 * (1.0 - cos(max(angularRadius, 1e-6)));
46
+ return max(sunColor, vec3<f32>(0.0)) * max(sunIlluminance, 0.0) * edge / solidAngle;
47
+ }
48
+
49
+ @vertex
50
+ fn atmosphere_background_vs(@builtin(vertex_index) vertexIndex: u32) -> FullscreenOutput {
51
+ // Keep the fullscreen primitive at the far plane. The render graph binds
52
+ // the existing depth authority read-only, so fixed-function less-equal
53
+ // rejects background fragments covered by geometry without sampling depth.
54
+ var output = fullscreen_triangle(vertexIndex);
55
+ output.position.z = 1.0;
56
+ return output;
57
+ }
58
+
59
+ fn atmosphere_background_direction(uv: vec2<f32>) -> vec3<f32> {
60
+ let ndc = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 1.0, 1.0);
61
+ let world = view.inverseViewProj * ndc;
62
+ return normalize(world.xyz / world.w - view.cameraPos);
63
+ }
64
+
65
+ @fragment
66
+ fn atmosphere_background_fs(input: FullscreenOutput) -> @location(0) vec4<f32> {
67
+ let direction = atmosphere_background_direction(input.uv);
68
+ let cubeDirection = vec3<f32>(direction.x, -direction.y, direction.z);
69
+ var radiance = textureSample(sky, skySampler, cubeDirection).rgb;
70
+ if (atmosphere.sunDiscEnabled > 0.5 && atmosphere.sunDirection.y > 0.0) {
71
+ radiance += atmosphere_sun_disc_radiance(
72
+ direction,
73
+ atmosphere.sunDirection,
74
+ atmosphere.sunColor,
75
+ atmosphere.sunIlluminance,
76
+ atmosphere.sunAngularRadius,
77
+ );
78
+ }
79
+ return vec4<f32>(radiance, 1.0);
80
+ }