@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,1909 @@
1
+ // @ts-nocheck — merged file: cross-source type narrowing failures
2
+ // Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
3
+ // biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
4
+ //
5
+ // Source files (N=9):
6
+ // - packages/shader/src/__tests__/fullscreen-triangle.test.ts
7
+ // - packages/shader/src/__tests__/fxaa-shader.test.ts
8
+ // - packages/shader/src/__tests__/ibl-modules-parse.test.ts
9
+ // - packages/shader/src/__tests__/register-material-shader.test.ts
10
+ // - packages/shader/src/__tests__/register-pbr-skin.test.ts
11
+ // - packages/shader/src/__tests__/registry.test.ts
12
+ // - packages/shader/src/__tests__/skybox-direction.test.ts
13
+ // - packages/shader/src/__tests__/tbn-lighting-helpers.test.ts
14
+ // - packages/shader/src/__tests__/tonemap-shader.test.ts
15
+ //
16
+ // Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
17
+ // source as ancestorTitles[0]. Top-level imports merged + deduped.
18
+
19
+ import type { Result, RhiError, ShaderModule } from '@forgeax/engine-rhi';
20
+ import { beforeAll, describe, expect, it } from 'vitest';
21
+ import type { ShaderError } from '../errors.js';
22
+ import { err as errResult, ok as okResult } from '../errors.js';
23
+ import { TONEMAP_LUMINANCE_EPSILON } from '../index.js';
24
+ import {
25
+ registerDefaultStandardPbrSkin,
26
+ type SkinCaps,
27
+ } from '../register-default-standard-pbr-skin.js';
28
+ import {
29
+ FORGEAX_RESERVED_PATH_PREFIX,
30
+ type MaterialShaderEntry,
31
+ ShaderRegistry,
32
+ } from '../ShaderRegistry.js';
33
+
34
+ {
35
+ // --- from fullscreen-triangle.test.ts ---
36
+ // @forgeax/engine-shader/__tests__/fullscreen-triangle.test.ts -
37
+ // TS-equivalence test for `fullscreen_triangle()` SSOT in common.wgsl
38
+ // (feat-20260519-tonemap-reinhard-mvp / T-M2.1).
39
+ //
40
+ // Shape: the WGSL function takes vertex_index (u32 0/1/2) and emits a
41
+ // large covering triangle in NDC — vertex 0 -> (-1, -1), vertex 1 ->
42
+ // (3, -1), vertex 2 -> (-1, 3). UV is derived as the half-open
43
+ // transform of clip-space xy back to [0, 1]: u = (x + 1) * 0.5,
44
+ // v = 1 - (y + 1) * 0.5 (Y flipped to keep texture sampling right-side-up
45
+ // against WebGPU's [0, 1] -> top-left UV convention).
46
+ //
47
+ // This file does not run the GPU; it asserts the math via a TS port that
48
+ // must stay byte-equivalent to the WGSL body. Drift between the two
49
+ // surfaces is caught by both this test (when TS port drifts) and the
50
+ // naga_oil composer (when WGSL drifts to break the import / signature).
51
+ //
52
+ // research §F3 §2.3 (single-large-triangle, 3-vertex idiom) is the SSOT
53
+ // for the (-1,-1)/(3,-1)/(-1,3) layout.
54
+
55
+ // TS port of the WGSL body. Must be kept byte-equivalent by hand; the
56
+ // shader-side function in `common.wgsl` is the runtime SSOT. See the
57
+ // comment block in that file for the canonical equation.
58
+ function fullscreenTriangleTS(vertexIndex: number): {
59
+ position: [number, number, number, number];
60
+ uv: [number, number];
61
+ } {
62
+ // x: vertex 0 / 2 -> -1; vertex 1 -> 3.
63
+ // y: vertex 0 / 1 -> -1; vertex 2 -> 3.
64
+ const x = vertexIndex === 1 ? 3.0 : -1.0;
65
+ const y = vertexIndex === 2 ? 3.0 : -1.0;
66
+ const u = (x + 1.0) * 0.5;
67
+ const v = 1.0 - (y + 1.0) * 0.5;
68
+ return {
69
+ position: [x, y, 0.0, 1.0],
70
+ uv: [u, v],
71
+ };
72
+ }
73
+
74
+ describe('fullscreen_triangle (TS-equivalence)', () => {
75
+ it('vertex 0 -> NDC (-1, -1, 0, 1) and UV (0, 1)', () => {
76
+ const out = fullscreenTriangleTS(0);
77
+ expect(out.position).toEqual([-1.0, -1.0, 0.0, 1.0]);
78
+ expect(out.uv).toEqual([0.0, 1.0]);
79
+ });
80
+
81
+ it('vertex 1 -> NDC (3, -1, 0, 1) and UV (2, 1)', () => {
82
+ const out = fullscreenTriangleTS(1);
83
+ expect(out.position).toEqual([3.0, -1.0, 0.0, 1.0]);
84
+ expect(out.uv).toEqual([2.0, 1.0]);
85
+ });
86
+
87
+ it('vertex 2 -> NDC (-1, 3, 0, 1) and UV (0, -1)', () => {
88
+ const out = fullscreenTriangleTS(2);
89
+ expect(out.position).toEqual([-1.0, 3.0, 0.0, 1.0]);
90
+ expect(out.uv).toEqual([0.0, -1.0]);
91
+ });
92
+
93
+ it('the three vertices form a triangle that fully covers the [-1, 1] NDC quad', () => {
94
+ // Coverage check: any point (px, py) in [-1, 1]^2 lies inside the
95
+ // triangle (-1, -1), (3, -1), (-1, 3). Sample the four corners + center.
96
+ const v = [
97
+ fullscreenTriangleTS(0).position,
98
+ fullscreenTriangleTS(1).position,
99
+ fullscreenTriangleTS(2).position,
100
+ ];
101
+ const samples: Array<[number, number]> = [
102
+ [-1.0, -1.0],
103
+ [1.0, -1.0],
104
+ [-1.0, 1.0],
105
+ [1.0, 1.0],
106
+ [0.0, 0.0],
107
+ ];
108
+ for (const [px, py] of samples) {
109
+ const [a, b, c] = v as [
110
+ [number, number, number, number],
111
+ [number, number, number, number],
112
+ [number, number, number, number],
113
+ ];
114
+ const denom = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
115
+ const w0 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / denom;
116
+ const w1 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / denom;
117
+ const w2 = 1.0 - w0 - w1;
118
+ expect(w0).toBeGreaterThanOrEqual(-1e-6);
119
+ expect(w1).toBeGreaterThanOrEqual(-1e-6);
120
+ expect(w2).toBeGreaterThanOrEqual(-1e-6);
121
+ }
122
+ });
123
+ });
124
+ }
125
+
126
+ {
127
+ // --- from fxaa-shader.test.ts ---
128
+ // @forgeax/engine-shader/__tests__/fxaa-shader.test.ts -
129
+ // TS-equivalence test for fxaa.wgsl content markers and SSOT
130
+ // compliance (feat-20260528-fxaa-post-processing / w5).
131
+ //
132
+ // Four concerns:
133
+ // 1. fxaa.wgsl source contains rgb2luma function (content marker per D-5)
134
+ // 2. fxaa.wgsl source does NOT contain fn fullscreen_triangle (SSOT in common.wgsl)
135
+ // 3. fxaa.wgsl source imports forgeax_view::common (AC-05)
136
+ // 4. EDGE_THRESHOLD_MIN / EDGE_THRESHOLD_MAX constants exist
137
+ //
138
+ // Reads the fxaa.wgsl source via dynamic node:fs import (same pattern as
139
+ // pbr-ibl-callsite.test.ts and ibl-modules-parse.test.ts).
140
+
141
+ interface NodeFs {
142
+ readFileSync: (p: string, enc: string) => string;
143
+ }
144
+ interface NodePath {
145
+ resolve: (...parts: string[]) => string;
146
+ dirname: (p: string) => string;
147
+ }
148
+ interface NodeUrl {
149
+ fileURLToPath: (u: string) => string;
150
+ }
151
+
152
+ let fxaaSource!: string;
153
+
154
+ beforeAll(async () => {
155
+ const fsId = 'node:fs';
156
+ const pathId = 'node:path';
157
+ const urlId = 'node:url';
158
+ const fs = (await import(/* @vite-ignore */ fsId)) as NodeFs;
159
+ const path = (await import(/* @vite-ignore */ pathId)) as NodePath;
160
+ const url = (await import(/* @vite-ignore */ urlId)) as NodeUrl;
161
+ const here = url.fileURLToPath(import.meta.url);
162
+ const fxaaPath = path.resolve(path.dirname(here), '..', 'fxaa.wgsl');
163
+ fxaaSource = fs.readFileSync(fxaaPath, 'utf8');
164
+ });
165
+
166
+ describe('fxaa.wgsl content markers', () => {
167
+ it('contains rgb2luma function definition (D-5 content marker)', () => {
168
+ expect(fxaaSource).toContain('fn rgb2luma');
169
+ });
170
+
171
+ it('contains EDGE_THRESHOLD_MIN constant', () => {
172
+ expect(fxaaSource).toContain('EDGE_THRESHOLD_MIN');
173
+ expect(fxaaSource).toContain('0.0312');
174
+ });
175
+
176
+ it('contains EDGE_THRESHOLD_MAX constant', () => {
177
+ expect(fxaaSource).toContain('EDGE_THRESHOLD_MAX');
178
+ expect(fxaaSource).toContain('0.125');
179
+ });
180
+ });
181
+
182
+ describe('fxaa.wgsl SSOT gate (AC-05)', () => {
183
+ it('does NOT define fullscreen_triangle (SSOT is in common.wgsl)', () => {
184
+ expect(fxaaSource).not.toMatch(/fn fullscreen_triangle/);
185
+ });
186
+
187
+ it('imports forgeax_view::common::fullscreen_triangle', () => {
188
+ expect(fxaaSource).toContain('#import forgeax_view::common::fullscreen_triangle');
189
+ });
190
+
191
+ it('imports forgeax_view::common::FullscreenOutput', () => {
192
+ expect(fxaaSource).toContain('#import forgeax_view::common::FullscreenOutput');
193
+ });
194
+ });
195
+
196
+ describe('fxaa.wgsl bindings', () => {
197
+ it('declares @group(0) @binding(0) texture_2d<f32>', () => {
198
+ expect(fxaaSource).toMatch(/@binding\(0\)\s+var\s+screenTexture.*texture_2d<f32>/);
199
+ });
200
+
201
+ it('declares @group(0) @binding(1) sampler', () => {
202
+ expect(fxaaSource).toMatch(/@binding\(1\)\s+var\s+samp.*sampler/);
203
+ });
204
+
205
+ it('has no @binding(2) (no UBO, 2-entry BGL per D-2)', () => {
206
+ expect(fxaaSource).not.toMatch(/@binding\(2\)/);
207
+ });
208
+ });
209
+ }
210
+
211
+ {
212
+ // --- from ibl-modules-parse.test.ts ---
213
+ // ibl-modules-parse.test.ts -- feat-20260520-skylight-ibl-cubemap M3 / t41.
214
+ //
215
+ // Structural red-phase tests for the 6-module ibl-* WGSL family. The
216
+ // single-file ibl.wgsl that round-1 shipped (commit 5dd45cb9) collided on
217
+ // @group(1) @binding(0) across 3 distinct texture types (texture_2d for
218
+ // equirect / texture_cube x2 for irradiance + prefilter), which naga
219
+ // rejects under the WGSL spec rule "(group, binding) must be globally
220
+ // unique within a module". round-2 splits the file into 6 physically
221
+ // independent modules so each module's @group/@binding namespace is its
222
+ // own.
223
+ //
224
+ // These tests are red BEFORE t42..t47 land (module files do not exist) and
225
+ // turn green when each module is in place. The package physically isolates
226
+ // naga (4 grep gates -- see scripts/check-shader-no-naga-in-dist.mjs), so
227
+ // these checks read the source files directly and assert structural rules
228
+ // rather than invoking naga.parse / composeShader (the cross-file
229
+ // composition path is exercised by the dawn test in
230
+ // packages/runtime/src/__tests__/dawn/ibl-modules-compose.dawn.test.ts
231
+ // where engine-naga is allowed as a devDependency).
232
+ //
233
+ // node:* are accessed via `await import(varId)` (vite-ignore) -- the
234
+ // established cross-package pattern in renderer-draw-world.test.ts -- so
235
+ // the shader tsconfig keeps `types: ["@webgpu/types"]` without pulling in
236
+ // `@types/node` (which would regress unrelated brand-type narrowing).
237
+
238
+ interface NodeFs {
239
+ readFileSync: (p: string, enc: string) => string;
240
+ }
241
+ interface NodePath {
242
+ resolve: (...parts: string[]) => string;
243
+ dirname: (p: string) => string;
244
+ }
245
+ interface NodeUrl {
246
+ fileURLToPath: (u: string) => string;
247
+ }
248
+
249
+ let fs!: NodeFs;
250
+ let srcDir!: string;
251
+
252
+ beforeAll(async () => {
253
+ const fsId = 'node:fs';
254
+ const pathId = 'node:path';
255
+ const urlId = 'node:url';
256
+ fs = (await import(/* @vite-ignore */ fsId)) as NodeFs;
257
+ const path = (await import(/* @vite-ignore */ pathId)) as NodePath;
258
+ const url = (await import(/* @vite-ignore */ urlId)) as NodeUrl;
259
+ const here = url.fileURLToPath(import.meta.url);
260
+ srcDir = path.resolve(path.dirname(here), '..');
261
+ });
262
+
263
+ interface ModuleSpec {
264
+ readonly file: string;
265
+ readonly importPath: string;
266
+ readonly expectedGroup1: 'texture_2d' | 'texture_cube' | 'none';
267
+ }
268
+
269
+ const MODULES: readonly ModuleSpec[] = [
270
+ { file: 'ibl-shared.wgsl', importPath: 'forgeax_pbr::ibl_shared', expectedGroup1: 'none' },
271
+ {
272
+ file: 'ibl-equirect-to-cube.wgsl',
273
+ importPath: 'forgeax_pbr::ibl_equirect_to_cube',
274
+ expectedGroup1: 'texture_2d',
275
+ },
276
+ {
277
+ file: 'ibl-irradiance.wgsl',
278
+ importPath: 'forgeax_pbr::ibl_irradiance',
279
+ expectedGroup1: 'texture_cube',
280
+ },
281
+ {
282
+ file: 'ibl-prefilter.wgsl',
283
+ importPath: 'forgeax_pbr::ibl_prefilter',
284
+ expectedGroup1: 'texture_cube',
285
+ },
286
+ { file: 'ibl-brdf-lut.wgsl', importPath: 'forgeax_pbr::ibl_brdf_lut', expectedGroup1: 'none' },
287
+ { file: 'ibl-sampling.wgsl', importPath: 'forgeax_pbr::ibl_sampling', expectedGroup1: 'none' },
288
+ ];
289
+
290
+ function readSource(file: string): string {
291
+ const p = `${srcDir}/${file}`;
292
+ return fs.readFileSync(p, 'utf8');
293
+ }
294
+
295
+ describe('t41 (a) -- 6 ibl-* WGSL modules: namespace isolation', () => {
296
+ for (const spec of MODULES) {
297
+ describe(spec.file, () => {
298
+ it('declares the exact #define_import_path', () => {
299
+ const src = readSource(spec.file);
300
+ const match = /^\s*#define_import_path\s+(\S+)\s*$/m.exec(src);
301
+ expect(match?.[1]).toBe(spec.importPath);
302
+ });
303
+
304
+ it(`@group(1) shape matches expected (${spec.expectedGroup1})`, () => {
305
+ const src = readSource(spec.file);
306
+ // Strip comments line-by-line so banner annotations like
307
+ // "// @group(1) ..." cannot fool the structural grep.
308
+ const codeOnly = src
309
+ .split(/\r?\n/)
310
+ .map((line: string) => line.replace(/\/\/.*$/, ''))
311
+ .join('\n');
312
+ const group1Lines = codeOnly
313
+ .split(/\r?\n/)
314
+ .filter((line: string) => /@group\(1\)/.test(line));
315
+
316
+ if (spec.expectedGroup1 === 'none') {
317
+ // Shared math + sampling helpers + brdf-lut entry are zero-binding.
318
+ expect(group1Lines).toHaveLength(0);
319
+ return;
320
+ }
321
+ // At least one binding line; every texture binding line in @group(1)
322
+ // must match the expected texture type (no mixing texture_2d /
323
+ // texture_cube inside a single module -- this was the round-1
324
+ // collision root cause).
325
+ expect(group1Lines.length).toBeGreaterThan(0);
326
+ const textureBindings = group1Lines.filter((line: string) =>
327
+ /var\s+\w+\s*:\s*texture_/.test(line),
328
+ );
329
+ for (const line of textureBindings) {
330
+ expect(line).toMatch(new RegExp(`texture_${spec.expectedGroup1.split('_')[1]}\\b`));
331
+ }
332
+ });
333
+
334
+ it('every @binding(N) inside @group(1) is unique within the module', () => {
335
+ const src = readSource(spec.file);
336
+ const codeOnly = src
337
+ .split(/\r?\n/)
338
+ .map((line: string) => line.replace(/\/\/.*$/, ''))
339
+ .join('\n');
340
+ const seen = new Set<number>();
341
+ const re = /@group\(1\)\s*@binding\((\d+)\)/g;
342
+ let m: RegExpExecArray | null = re.exec(codeOnly);
343
+ while (m !== null) {
344
+ const idx = Number(m[1]);
345
+ expect(seen.has(idx), `duplicate @binding(${idx}) in ${spec.file}`).toBe(false);
346
+ seen.add(idx);
347
+ m = re.exec(codeOnly);
348
+ }
349
+ });
350
+ });
351
+ }
352
+ });
353
+
354
+ describe('t41 (a) -- ibl-shared / ibl-sampling are zero-binding pure-function modules', () => {
355
+ it.each([
356
+ ['ibl-shared.wgsl'],
357
+ ['ibl-sampling.wgsl'],
358
+ ])('%s has zero @group/@binding declarations', (file: string) => {
359
+ const src = readSource(file);
360
+ const codeOnly = src
361
+ .split(/\r?\n/)
362
+ .map((line: string) => line.replace(/\/\/.*$/, ''))
363
+ .join('\n');
364
+ expect(codeOnly).not.toMatch(/@group\(\d+\)\s*@binding\(\d+\)/);
365
+ });
366
+ });
367
+
368
+ describe('WebGL2 uniform alignment', () => {
369
+ it('pads ibl-prefilter PrefilterUniforms to a 16-byte block', () => {
370
+ const src = readSource('ibl-prefilter.wgsl');
371
+ expect(src).toMatch(
372
+ /struct\s+PrefilterUniforms\s*\{[\s\S]*?roughness:\s*f32,[\s\S]*?faceSize:\s*f32,[\s\S]*?_pad0:\s*f32,[\s\S]*?_pad1:\s*f32,/,
373
+ );
374
+ });
375
+ });
376
+
377
+ describe('t41 (a) -- ibl-sampling exports sampleIblDiffuse + sampleIblSpecular', () => {
378
+ it('declares the two runtime sampling helpers consumed by pbr.wgsl', () => {
379
+ const src = readSource('ibl-sampling.wgsl');
380
+ expect(src).toMatch(/fn\s+sampleIblDiffuse\s*\(/);
381
+ expect(src).toMatch(/fn\s+sampleIblSpecular\s*\(/);
382
+ });
383
+ });
384
+ }
385
+
386
+ {
387
+ // --- from register-material-shader.test.ts ---
388
+ // register-material-shader.test.ts -- feat-20260523-shader-template-instance-split
389
+ // M5 / T06.
390
+ //
391
+ // Tests for the new ShaderRegistry.installMaterialArtifact / findMaterialArtifact
392
+ // surface (M5-T05 + plan-strategy D-DefaultStandardPbr-Identifier).
393
+ //
394
+ // 5 cases per plan acceptanceCheck:
395
+ // (a) register and lookup happy path
396
+ // (b) duplicate register throws (programmer error / fail-fast no overwrite)
397
+ // (c) non-`forgeax::` identifier (user path) is allowed
398
+ // (d) lookup of non-existent identifier returns Result.err with code
399
+ // 'material-shader-not-found'
400
+ // (e) materialShaderIdentifiers() iterator enumerates registrations in
401
+ // insertion order (charter F1 grep gate -- AI users iterate to discover)
402
+ //
403
+ // The original M5-T06 plan-task acceptanceCheck note (e) was "startup
404
+ // auto-registers 'forgeax::default-standard-pbr' via import side-effect".
405
+ // That formulation is incompatible with the package's
406
+ // instance-per-engine + zero-module-side-effect design rules
407
+ // (plan-strategy section S-10 + AGENTS.md "explicit registration"). The
408
+ // equivalent invariant landed as "the default identifier is registrable
409
+ // under the FORGEAX_RESERVED_PATH_PREFIX path namespace + iterator
410
+ // enumerates the registration" -- the host (createRenderer in M6) wires
411
+ // the actual default-standard-pbr triple at engine boot.
412
+ //
413
+ // Each test constructs a fresh ShaderRegistry instance (no shared state).
414
+
415
+ /// <reference types="@webgpu/types" />
416
+
417
+ const SHADER_MODULE_BRAND: unique symbol = Symbol('mock-shader-module');
418
+ type MockShaderModule = { readonly [SHADER_MODULE_BRAND]: 'mock' };
419
+
420
+ function createNoopDevice(): ShaderRegistryDevice {
421
+ return {
422
+ createShaderModule(): Result<ShaderModule, RhiError> {
423
+ const handle: MockShaderModule = { [SHADER_MODULE_BRAND]: 'mock' as const };
424
+ return { ok: true, value: handle as unknown as ShaderModule } as unknown as Result<
425
+ ShaderModule,
426
+ RhiError
427
+ >;
428
+ },
429
+ };
430
+ }
431
+
432
+ function makeRegistry(): ShaderRegistry {
433
+ return new ShaderRegistry({ device: createNoopDevice(), manifestUrl: undefined });
434
+ }
435
+
436
+ const STUB_SOURCE_DEFAULT = '// stub composed wgsl for forgeax::default-standard-pbr\n';
437
+ const STUB_SOURCE_USER = '// stub composed wgsl for user shader\n';
438
+
439
+ function makeDefaultEntry(): MaterialShaderEntry {
440
+ return {
441
+ source: STUB_SOURCE_DEFAULT,
442
+ paramSchema: [
443
+ { name: 'baseColor', type: 'color' },
444
+ { name: 'metallic', type: 'f32', default: 0.0 },
445
+ { name: 'roughness', type: 'f32', default: 0.5 },
446
+ ],
447
+ };
448
+ }
449
+
450
+ function makeUserEntry(): MaterialShaderEntry {
451
+ return {
452
+ source: STUB_SOURCE_USER,
453
+ paramSchema: [
454
+ { name: 'baseColor', type: 'color' },
455
+ { name: 'time', type: 'f32', default: 0.0 },
456
+ ],
457
+ };
458
+ }
459
+
460
+ describe('M5-T06 (a) -- register + lookup happy path', () => {
461
+ it('registers an engine-reserved identifier and looks it back up', () => {
462
+ const registry = makeRegistry();
463
+ const entry = makeDefaultEntry();
464
+ const id = `${FORGEAX_RESERVED_PATH_PREFIX}default-standard-pbr`;
465
+ registry.installMaterialArtifact(id, entry);
466
+
467
+ const r = registry.findMaterialArtifact(id);
468
+ expect(r.ok).toBe(true);
469
+ if (!r.ok) return;
470
+ expect(r.value.source).toBe(STUB_SOURCE_DEFAULT);
471
+ expect(r.value.paramSchema).toHaveLength(3);
472
+ expect(r.value.paramSchema[0]).toEqual({ name: 'baseColor', type: 'color' });
473
+ expect(r.value.paramSchemaProjection).toMatchObject({ ownerId: id, revision: 1 });
474
+ expect(r.value.paramSchemaProjection.schema).toBe(r.value.paramSchema);
475
+ expect(registry.paramSchemaProjectionStats()).toEqual({
476
+ admissions: 1,
477
+ derivations: 1,
478
+ projections: 1,
479
+ });
480
+ });
481
+ });
482
+
483
+ describe('M5-T06 (b) -- duplicate register throws (fail-fast no overwrite)', () => {
484
+ it('throws on same-name re-register', () => {
485
+ const registry = makeRegistry();
486
+ const entry = makeDefaultEntry();
487
+ const id = `${FORGEAX_RESERVED_PATH_PREFIX}default-standard-pbr`;
488
+ registry.installMaterialArtifact(id, entry);
489
+
490
+ expect(() => registry.installMaterialArtifact(id, entry)).toThrow(/already registered/);
491
+ // Original entry stays intact -- duplicate register did not perturb storage.
492
+ const lookup = registry.findMaterialArtifact(id);
493
+ expect(lookup.ok).toBe(true);
494
+ if (lookup.ok) {
495
+ expect(lookup.value.source).toBe(STUB_SOURCE_DEFAULT);
496
+ }
497
+ });
498
+
499
+ it('does not allow overwrite even when entry shape differs', () => {
500
+ const registry = makeRegistry();
501
+ const id = `${FORGEAX_RESERVED_PATH_PREFIX}default-standard-pbr`;
502
+ registry.installMaterialArtifact(id, makeDefaultEntry());
503
+ // Attempt a "drift" overwrite with a different source body.
504
+ expect(() =>
505
+ registry.installMaterialArtifact(id, {
506
+ source: '// drifted source\n',
507
+ paramSchema: [],
508
+ }),
509
+ ).toThrow(/already registered/);
510
+ });
511
+ });
512
+
513
+ describe('M5-T06 (c) -- non-forgeax:: user identifier is allowed', () => {
514
+ it('accepts a user-namespaced path identifier', () => {
515
+ const registry = makeRegistry();
516
+ const userId = 'my-game::pulse-material';
517
+ registry.installMaterialArtifact(userId, makeUserEntry());
518
+ const r = registry.findMaterialArtifact(userId);
519
+ expect(r.ok).toBe(true);
520
+ });
521
+
522
+ it('accepts a GUID-shaped identifier', () => {
523
+ const registry = makeRegistry();
524
+ const guidId = '01923a4b-7d8c-7c4e-9f12-345678901234';
525
+ registry.installMaterialArtifact(guidId, makeUserEntry());
526
+ const r = registry.findMaterialArtifact(guidId);
527
+ expect(r.ok).toBe(true);
528
+ });
529
+ });
530
+
531
+ // bug-20260619 -- register-time texture-declaration consistency fail-fast.
532
+ // A user shader that samples a user-region material texture but omits it from
533
+ // paramSchema would let the extract stage silently drop the handle and bind
534
+ // the default white texture (LO 4.3 blending demo grass + windows opaque
535
+ // white). See docs/handover/2026-06-19-blending-transparency-regression-bisect.md.
536
+ describe('bug-20260619 -- under-declared sampled texture throws at register', () => {
537
+ const wgslSamplingBaseColor =
538
+ '@group(1) @binding(2) var baseColorTexture : texture_2d<f32>;\n' +
539
+ '@fragment fn fs() { let s = textureSample(baseColorTexture, smp, in.uv); }';
540
+
541
+ it('throws when a user shader samples baseColorTexture absent from paramSchema', () => {
542
+ const registry = makeRegistry();
543
+ expect(() =>
544
+ registry.installMaterialArtifact('learn-render::alpha-test', {
545
+ source: wgslSamplingBaseColor,
546
+ paramSchema: [{ name: 'baseColor', type: 'color' }],
547
+ }),
548
+ ).toThrow(/samples texture\(s\) \[baseColorTexture\].*does not declare/s);
549
+ });
550
+
551
+ it('accepts the same shader once baseColorTexture is declared', () => {
552
+ const registry = makeRegistry();
553
+ expect(() =>
554
+ registry.installMaterialArtifact('learn-render::alpha-test', {
555
+ source: wgslSamplingBaseColor,
556
+ paramSchema: [
557
+ { name: 'baseColor', type: 'color' },
558
+ { name: 'baseColorTexture', type: 'texture2d' },
559
+ ],
560
+ }),
561
+ ).not.toThrow();
562
+ });
563
+
564
+ it('does NOT apply the check to engine forgeax:: shaders (build-time gate owns them)', () => {
565
+ // An engine shader may sample engine-injected textures (emissive /
566
+ // occlusion) absent from its schema by design; the runtime check is
567
+ // scoped to user shaders only.
568
+ const registry = makeRegistry();
569
+ const wgslEngineInjected =
570
+ '@fragment fn fs() { let e = textureSample(emissiveTexture, smp, in.uv); }';
571
+ expect(() =>
572
+ registry.installMaterialArtifact(`${FORGEAX_RESERVED_PATH_PREFIX}custom`, {
573
+ source: wgslEngineInjected,
574
+ paramSchema: [{ name: 'baseColor', type: 'color' }],
575
+ }),
576
+ ).not.toThrow();
577
+ });
578
+ });
579
+
580
+ describe('M5-T06 (d) -- lookup non-existent returns material-shader-not-found', () => {
581
+ it('returns Result.err with the structured error code', () => {
582
+ const registry = makeRegistry();
583
+ const r = registry.findMaterialArtifact('forgeax::no-such-shader');
584
+ expect(r.ok).toBe(false);
585
+ if (r.ok) return;
586
+ expect(r.error.code).toBe('material-shader-not-found');
587
+ expect(r.error.hint).toMatch(/installMaterialArtifact/);
588
+ expect(r.error.message).toContain('forgeax::no-such-shader');
589
+ });
590
+ });
591
+
592
+ describe('M5-T06 (e) -- materialShaderIdentifiers() iterator enumerates in order', () => {
593
+ it('enumerates every registered identifier (insertion order)', () => {
594
+ const registry = makeRegistry();
595
+ const ids = [
596
+ `${FORGEAX_RESERVED_PATH_PREFIX}default-standard-pbr`,
597
+ 'my-game::pulse-material',
598
+ 'my-game::wave-material',
599
+ ] as const;
600
+ for (const id of ids) {
601
+ registry.installMaterialArtifact(id, makeUserEntry());
602
+ }
603
+ const seen = Array.from(registry.materialShaderIdentifiers());
604
+ expect(seen).toEqual(ids);
605
+ });
606
+
607
+ it('returns an empty iterator on a fresh registry (no module-level singletons / no implicit auto-register at construction)', () => {
608
+ const registry = makeRegistry();
609
+ const seen = Array.from(registry.materialShaderIdentifiers());
610
+ expect(seen).toEqual([]);
611
+ });
612
+ });
613
+
614
+ describe('M5-T06 (f) -- FORGEAX_RESERVED_PATH_PREFIX SSOT', () => {
615
+ it("equals 'forgeax::' (charter F1 grep gate anchor)", () => {
616
+ expect(FORGEAX_RESERVED_PATH_PREFIX).toBe('forgeax::');
617
+ });
618
+ });
619
+
620
+ // feat-20260528-material-shader-registration-unification M3 / w13:
621
+ // materialShaderNotFound .expected field tests (charter P3 structured failure).
622
+ describe('M3-w13 -- materialShaderNotFound .expected field', () => {
623
+ it('(a) lookup miss after 3 registrations -> err.expected lists all 3 identifiers', () => {
624
+ const registry = makeRegistry();
625
+ const ids = [
626
+ `${FORGEAX_RESERVED_PATH_PREFIX}default-standard-pbr`,
627
+ 'my-game::pulse-material',
628
+ 'my-game::wave-material',
629
+ ] as const;
630
+ for (const id of ids) {
631
+ registry.installMaterialArtifact(id, makeUserEntry());
632
+ }
633
+ const r = registry.findMaterialArtifact('forgeax::no-such-shader');
634
+ expect(r.ok).toBe(false);
635
+ if (r.ok) return;
636
+ expect(r.error.code).toBe('material-shader-not-found');
637
+ const expectedList = r.error.expected;
638
+ expect(expectedList).toContain(ids[0]);
639
+ expect(expectedList).toContain(ids[1]);
640
+ expect(expectedList).toContain(ids[2]);
641
+ });
642
+
643
+ it('(b) lookup miss on empty registry -> err.expected is empty', () => {
644
+ const registry = makeRegistry();
645
+ const r = registry.findMaterialArtifact('forgeax::no-such-shader');
646
+ expect(r.ok).toBe(false);
647
+ if (r.ok) return;
648
+ expect(r.error.code).toBe('material-shader-not-found');
649
+ expect(r.error.expected).toContain('ShaderRegistry has registered identifiers: []');
650
+ });
651
+
652
+ it('(c) register + miss, add more, miss again -> err.expected updates', () => {
653
+ const registry = makeRegistry();
654
+ registry.installMaterialArtifact(
655
+ `${FORGEAX_RESERVED_PATH_PREFIX}default-standard-pbr`,
656
+ makeUserEntry(),
657
+ );
658
+ const r1 = registry.findMaterialArtifact('forgeax::no-such-shader');
659
+ expect(r1.ok).toBe(false);
660
+ if (r1.ok) return;
661
+ expect(r1.error.expected).toContain('forgeax::');
662
+
663
+ registry.installMaterialArtifact('my-game::pulse-material', makeUserEntry());
664
+ registry.installMaterialArtifact('my-game::wave-material', makeUserEntry());
665
+ const r2 = registry.findMaterialArtifact('different-miss');
666
+ expect(r2.ok).toBe(false);
667
+ if (r2.ok) return;
668
+ expect(r2.error.expected).toContain('forgeax::');
669
+ expect(r2.error.expected).toContain('my-game::pulse-material');
670
+ expect(r2.error.expected).toContain('my-game::wave-material');
671
+ });
672
+
673
+ // feat-20260624-sprite-lit F-P1 fix (Pure charter P3 finding):
674
+ // err.detail.registeredShaderIds carries the raw string[] so AI users can
675
+ // enumerate registered ids by property access instead of regex-parsing the
676
+ // human-formatted err.expected string.
677
+ it('(d) err.detail.registeredShaderIds exposes raw string[] for property-access enumeration', () => {
678
+ const registry = makeRegistry();
679
+ const ids = [
680
+ `${FORGEAX_RESERVED_PATH_PREFIX}default-standard-pbr`,
681
+ 'my-game::pulse-material',
682
+ 'my-game::wave-material',
683
+ ] as const;
684
+ for (const id of ids) {
685
+ registry.installMaterialArtifact(id, makeUserEntry());
686
+ }
687
+ const r = registry.findMaterialArtifact('forgeax::no-such-shader');
688
+ expect(r.ok).toBe(false);
689
+ if (r.ok) return;
690
+ expect(r.error.code).toBe('material-shader-not-found');
691
+ const registered = r.error.detail?.registeredShaderIds;
692
+ expect(Array.isArray(registered)).toBe(true);
693
+ expect(registered).toEqual(ids);
694
+ expect(registered).toContain('my-game::pulse-material');
695
+ });
696
+
697
+ it('(e) empty-registry miss -> err.detail.registeredShaderIds is empty array (not undefined)', () => {
698
+ const registry = makeRegistry();
699
+ const r = registry.findMaterialArtifact('forgeax::no-such-shader');
700
+ expect(r.ok).toBe(false);
701
+ if (r.ok) return;
702
+ const registered = r.error.detail?.registeredShaderIds;
703
+ expect(Array.isArray(registered)).toBe(true);
704
+ expect(registered).toEqual([]);
705
+ });
706
+ });
707
+
708
+ // feat-20260604-instances-per-instance-transform-shader-group3-bin M1 / w1:
709
+ // variant=2 compile unit test (AC-11: variants count stays 2, no new axis).
710
+ //
711
+ // Structural source-level assertions (same pattern as tbn-lighting-helpers.test.ts):
712
+ // (a) common.wgsl declares @group(3) instances binding under STORAGE_BUFFER_AVAILABLE
713
+ // (b) default-standard-pbr.wgsl and unlit.wgsl each carry exactly one
714
+ // #pragma variant_axis STORAGE_BUFFER_AVAILABLE (no second axis)
715
+ // (c) No shader introduces a new variant axis like INSTANCE_STORAGE_AVAILABLE (OOS-6)
716
+ //
717
+ // RED before w3 (common.wgsl has no @group(3) yet); GREEN after w3 adds it.
718
+
719
+ interface NodeFsW1 {
720
+ readFileSync: (p: string, enc: string) => string;
721
+ }
722
+ interface NodePathW1 {
723
+ resolve: (...parts: string[]) => string;
724
+ dirname: (p: string) => string;
725
+ }
726
+ interface NodeUrlW1 {
727
+ fileURLToPath: (u: string) => string;
728
+ }
729
+
730
+ describe('M1-w1 -- variant=2 structural compile unit test (AC-11)', () => {
731
+ let fsW1!: NodeFsW1;
732
+ let srcDirW1!: string;
733
+
734
+ beforeAll(async () => {
735
+ const fsId = 'node:fs';
736
+ const pathId = 'node:path';
737
+ const urlId = 'node:url';
738
+ fsW1 = (await import(/* @vite-ignore */ fsId)) as NodeFsW1;
739
+ const pathW1 = (await import(/* @vite-ignore */ pathId)) as NodePathW1;
740
+ const urlW1 = (await import(/* @vite-ignore */ urlId)) as NodeUrlW1;
741
+ const hereW1 = urlW1.fileURLToPath(import.meta.url);
742
+ srcDirW1 = pathW1.resolve(pathW1.dirname(hereW1), '..');
743
+ });
744
+
745
+ function readWgsl(file: string): string {
746
+ return fsW1.readFileSync(`${srcDirW1}/${file}`, 'utf8');
747
+ }
748
+
749
+ function stripComments(src: string): string {
750
+ return src
751
+ .split(/\r?\n/)
752
+ .map((line) => line.replace(/\/\/.*$/, ''))
753
+ .join('\n');
754
+ }
755
+
756
+ it('(a) common.wgsl declares @group(3) instances under STORAGE_BUFFER_AVAILABLE', () => {
757
+ const src = readWgsl('common.wgsl');
758
+ // After w3: common.wgsl must have @group(3) @binding(0) instances declaration
759
+ expect(src).toMatch(
760
+ /@group\(3\)\s+@binding\(0\)\s+var<storage,\s*read>\s+instances\s*:\s*array<InstanceData>/,
761
+ );
762
+ expect(src).toMatch(
763
+ /@group\(3\)\s+@binding\(0\)\s+var<uniform>\s+instances\s*:\s*array<InstanceData,\s*128>/,
764
+ );
765
+ // Both forms must be guarded by STORAGE_BUFFER_AVAILABLE.
766
+ // bug-20260610: switched `#ifdef X` → `#if X == true` (naga_oil's
767
+ // `#ifdef` only checks key presence in the def map, not the value;
768
+ // the ==/!= form is needed to make the false branch actually live).
769
+ expect(src).toMatch(/#if\s+STORAGE_BUFFER_AVAILABLE\s*==\s*true[\s\S]*@group\(3\)/);
770
+ });
771
+
772
+ it('(b) default-standard-pbr.wgsl declares storage, cluster, and vertex-color variant axes', () => {
773
+ // feat-20260609-hdrp-cluster-fragment-ggx M1: added CLUSTER_FORWARD_AVAILABLE as a
774
+ // second variant axis so the standard-pbr fragment can switch between URP-style
775
+ // (single dirlight + ambient) and HDRP-style (cluster forward 256 punctual lights).
776
+ const src = stripComments(readWgsl('default-standard-pbr.wgsl'));
777
+ const variantAxes = src.match(/#pragma\s+variant_axis\s+\w+/g) ?? [];
778
+ expect(variantAxes).toEqual([
779
+ '#pragma variant_axis STORAGE_BUFFER_AVAILABLE',
780
+ '#pragma variant_axis CLUSTER_FORWARD_AVAILABLE',
781
+ '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
782
+ ]);
783
+ });
784
+
785
+ it('(b) unlit.wgsl declares storage and vertex-color variant axes', () => {
786
+ const src = stripComments(readWgsl('unlit.wgsl'));
787
+ const variantAxes = src.match(/#pragma\s+variant_axis\s+\w+/g) ?? [];
788
+ expect(variantAxes).toEqual([
789
+ '#pragma variant_axis STORAGE_BUFFER_AVAILABLE',
790
+ '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
791
+ ]);
792
+ });
793
+
794
+ it('(c) no shader introduces INSTANCE_STORAGE_AVAILABLE (OOS-6)', () => {
795
+ const shaderFilesWgsl = [
796
+ 'common.wgsl',
797
+ 'default-standard-pbr.wgsl',
798
+ 'unlit.wgsl',
799
+ 'sprite.wgsl',
800
+ 'default-standard-pbr-skin.wgsl',
801
+ 'shadow_caster.wgsl',
802
+ ];
803
+ for (const file of shaderFilesWgsl) {
804
+ const src = readWgsl(file);
805
+ expect(src, `${file} must NOT declare INSTANCE_STORAGE_AVAILABLE axis`).not.toMatch(
806
+ /#pragma\s+variant_axis\s+INSTANCE_STORAGE_AVAILABLE/,
807
+ );
808
+ }
809
+ });
810
+ });
811
+
812
+ // feat-20260604-instances-per-instance-transform-shader-group3-bin M2 / w7:
813
+ // 4-shader @group(3) form consistency test (AC-04: structural-alignment guard).
814
+ //
815
+ // Asserts that the 4 material shaders (default-standard-pbr / unlit / sprite /
816
+ // default-standard-pbr-skin) plus shadow_caster all consume the same common.wgsl
817
+ // @group(3) `instances` binding via #import — no shader redeclares a divergent
818
+ // @group(3) form. The @group(3) @binding(0) declaration lives ONLY in common.wgsl.
819
+ //
820
+ // RED before w8/w9/w11: sprite, skin, and shadow_caster do not yet import
821
+ // `instances` from common.wgsl in their #import lines. GREEN after all three
822
+ // land their @group(3) alignment.
823
+ describe('M2-w7 -- 5-shader @group(3) form consistency test (AC-04)', () => {
824
+ let fsW7!: NodeFsW1;
825
+ let srcDirW7!: string;
826
+
827
+ beforeAll(async () => {
828
+ const fsId = 'node:fs';
829
+ const pathId = 'node:path';
830
+ const urlId = 'node:url';
831
+ fsW7 = (await import(/* @vite-ignore */ fsId)) as NodeFsW1;
832
+ const pathW7 = (await import(/* @vite-ignore */ pathId)) as NodePathW1;
833
+ const urlW7 = (await import(/* @vite-ignore */ urlId)) as NodeUrlW1;
834
+ const hereW7 = urlW7.fileURLToPath(import.meta.url);
835
+ srcDirW7 = pathW7.resolve(pathW7.dirname(hereW7), '..');
836
+ });
837
+
838
+ function readWgslW7(file: string): string {
839
+ return fsW7.readFileSync(`${srcDirW7}/${file}`, 'utf8');
840
+ }
841
+
842
+ function stripCommentsW7(src: string): string {
843
+ return src
844
+ .split(/\r?\n/)
845
+ .map((line) => line.replace(/\/\/.*$/, ''))
846
+ .join('\n');
847
+ }
848
+
849
+ it('(a) all 5 vertex shaders import instances from common.wgsl', () => {
850
+ const vertexShaderFiles = [
851
+ 'default-standard-pbr.wgsl',
852
+ 'unlit.wgsl',
853
+ 'sprite.wgsl',
854
+ 'default-standard-pbr-skin.wgsl',
855
+ 'shadow_caster.wgsl',
856
+ ];
857
+ for (const file of vertexShaderFiles) {
858
+ const src = readWgslW7(file);
859
+ expect(
860
+ src,
861
+ `${file} must import instances from forgeax_view::common (AC-04 structural alignment)`,
862
+ ).toMatch(/#import\s+forgeax_view::common\s*::\s*\{[^}]*\binstances\b[^}]*\}/);
863
+ }
864
+ });
865
+
866
+ it('(b) no vertex shader re-declares @group(3) directly (SSOT in common.wgsl only)', () => {
867
+ const vertexShaderFiles = [
868
+ 'default-standard-pbr.wgsl',
869
+ 'unlit.wgsl',
870
+ 'sprite.wgsl',
871
+ 'default-standard-pbr-skin.wgsl',
872
+ 'shadow_caster.wgsl',
873
+ ];
874
+ for (const file of vertexShaderFiles) {
875
+ const src = stripCommentsW7(readWgslW7(file));
876
+ expect(src, `${file} must NOT redeclare @group(3) @binding(0) directly`).not.toMatch(
877
+ /@group\(3\)\s+@binding\(0\)/,
878
+ );
879
+ }
880
+ });
881
+ });
882
+ }
883
+
884
+ {
885
+ // --- from register-pbr-skin.test.ts ---
886
+ // register-pbr-skin.test.ts — feat-20260523-skin-skeleton-animation M3 / T-31.
887
+ //
888
+ // Tests for registerDefaultStandardPbrSkin helper (T-30).
889
+ //
890
+ // 5 cases per plan acceptanceCheck (M3 / w13: case (d) deleted; the
891
+ // sidecar binding-layout field is gone — runtime/pbr-pipeline.ts is the
892
+ // BGL SSOT and exercises the dual-binding skin layout in
893
+ // render-system-skin-bg.unit.test.ts):
894
+ // (a) register + lookup happy path
895
+ // (b) lookup returns MaterialShaderEntry with non-empty source
896
+ // (c) paramSchema reuses default-standard-pbr schema
897
+ // (e) duplicate register throws (fail-fast no overwrite)
898
+ // (f) lookup of non-registered id after register returns material-shader-not-found
899
+ //
900
+ // Anchors:
901
+ // - plan acceptanceCheck AC-25 (paramSchema correct)
902
+ // - R-10 paramSchema reuse assertion
903
+ // - plan-strategy section 5.3 critical test point register
904
+
905
+ // Inline summary of the paramSchema fields for verification — mirrors
906
+ // default-standard-pbr.material.json paramSchema[] field-for-field.
907
+ // feat-20260613 fix-issue-1 (D-8): channelMap split into 4 f32 selectors;
908
+ // emissive / emissiveIntensity / occlusionStrength are schema-owned values;
909
+ // normalScale and the engine-injection textures remain in the same contract.
910
+ const EXPECTED_PARAM_NAMES = [
911
+ 'baseColor',
912
+ 'metallic',
913
+ 'roughness',
914
+ 'metallicChannel',
915
+ 'roughnessChannel',
916
+ 'aoChannel',
917
+ 'extraChannel',
918
+ 'emissive',
919
+ 'emissiveIntensity',
920
+ 'occlusionStrength',
921
+ 'alphaCutoff',
922
+ 'clearcoat',
923
+ 'clearcoatRoughness',
924
+ 'specularTint',
925
+ 'normalScale',
926
+ 'baseColorTexture',
927
+ 'metallicRoughnessTexture',
928
+ 'normalTexture',
929
+ 'specularTintTexture',
930
+ 'emissiveTexture',
931
+ 'occlusionTexture',
932
+ ] as const;
933
+
934
+ const STUB_WGSL = '// stub composed pbr-skin wgsl\n';
935
+ const STORAGE_CAPS: SkinCaps = { storageBuffer: true };
936
+
937
+ const SHADER_MODULE_BRAND: unique symbol = Symbol('mock-shader-module');
938
+ type MockShaderModule = { readonly [SHADER_MODULE_BRAND]: 'mock' };
939
+
940
+ function makeMockRegistry(): ShaderRegistry {
941
+ return new ShaderRegistry({
942
+ device: {
943
+ createShaderModule(): Result<ShaderModule, RhiError> {
944
+ const handle: MockShaderModule = { [SHADER_MODULE_BRAND]: 'mock' as const };
945
+ return { ok: true, value: handle as unknown as ShaderModule } as unknown as Result<
946
+ ShaderModule,
947
+ RhiError
948
+ >;
949
+ },
950
+ },
951
+ manifestUrl: undefined,
952
+ });
953
+ }
954
+
955
+ function lookup(registry: ShaderRegistry): MaterialShaderEntry {
956
+ const r = registry.findMaterialArtifact('forgeax::pbr-skin');
957
+ if (!r.ok) throw new Error(`lookup failed: ${r.error.code}`);
958
+ return r.value;
959
+ }
960
+
961
+ describe('T-31 (a) — register + lookup happy path', () => {
962
+ it('registers forgeax::pbr-skin and looks it back up', () => {
963
+ const registry = makeMockRegistry();
964
+ registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
965
+ const entry = lookup(registry);
966
+ expect(entry.source).toBe(STUB_WGSL);
967
+ });
968
+ });
969
+
970
+ describe('T-31 (b) — entry.source is non-empty', () => {
971
+ it('source matches the passed-in composed wgsl', () => {
972
+ const registry = makeMockRegistry();
973
+ const src = '// composed pbr-skin wgsl v2\n';
974
+ registerDefaultStandardPbrSkin(registry, src, STORAGE_CAPS);
975
+ expect(lookup(registry).source).toBe(src);
976
+ });
977
+
978
+ it('source is never empty after registration', () => {
979
+ const registry = makeMockRegistry();
980
+ registerDefaultStandardPbrSkin(registry, '// non-empty\n', STORAGE_CAPS);
981
+ expect(lookup(registry).source.length).toBeGreaterThan(0);
982
+ });
983
+ });
984
+
985
+ describe('T-31 (c) — paramSchema reuses default-standard-pbr schema (R-10)', () => {
986
+ it('paramSchema length matches the default standard-PBR schema', () => {
987
+ const registry = makeMockRegistry();
988
+ registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
989
+ const entry = lookup(registry);
990
+ expect(entry.paramSchema).toHaveLength(21);
991
+ });
992
+
993
+ it('paramSchema names match default-standard-pbr schema field-for-field', () => {
994
+ const registry = makeMockRegistry();
995
+ registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
996
+ const entry = lookup(registry);
997
+ const names = entry.paramSchema.map((p) => p.name);
998
+ expect(names).toEqual(EXPECTED_PARAM_NAMES);
999
+ });
1000
+
1001
+ it('paramSchema types match expected value-level types', () => {
1002
+ const registry = makeMockRegistry();
1003
+ registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1004
+ const entry = lookup(registry);
1005
+ const types = entry.paramSchema.map((p) => p.type);
1006
+ expect(types).toEqual([
1007
+ 'color',
1008
+ 'f32',
1009
+ 'f32',
1010
+ // 4 channel selectors (D-8 split).
1011
+ 'f32',
1012
+ 'f32',
1013
+ 'f32',
1014
+ 'f32',
1015
+ // emissive vec3 + emissiveIntensity + occlusionStrength.
1016
+ 'vec3',
1017
+ 'f32',
1018
+ 'f32',
1019
+ // alpha cutoff + clearcoat controls.
1020
+ 'f32',
1021
+ 'f32',
1022
+ 'f32',
1023
+ // specular tint and its texture map.
1024
+ 'vec3',
1025
+ 'f32',
1026
+ 'texture2d',
1027
+ 'texture2d',
1028
+ 'texture2d',
1029
+ 'texture2d',
1030
+ 'texture2d',
1031
+ 'texture2d',
1032
+ ]);
1033
+ });
1034
+ });
1035
+
1036
+ // T-31 (d) deleted by feat-20260613-material-paramschema-driven-binding
1037
+ // M3 / w13: the per-shader binding-layout sidecar field has been removed
1038
+ // from MaterialShaderEntry. The skin pipeline's @group(2) 2-binding layout
1039
+ // contract (meshes + palette + dynamic offset) is now owned by
1040
+ // runtime/src/pbr-pipeline.ts:buildPbrSkinLayouts and exercised by
1041
+ // packages/runtime/src/__tests__/render-system-skin-bg.unit.test.ts.
1042
+
1043
+ describe('T-31 (e) — duplicate register throws (fail-fast no overwrite)', () => {
1044
+ it('throws on same-name re-register', () => {
1045
+ const registry = makeMockRegistry();
1046
+ registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1047
+ expect(() =>
1048
+ registerDefaultStandardPbrSkin(registry, '// attempted drift\n', STORAGE_CAPS),
1049
+ ).toThrow(/already registered/);
1050
+ });
1051
+
1052
+ it('original entry stays intact after failed re-register', () => {
1053
+ const registry = makeMockRegistry();
1054
+ registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1055
+ try {
1056
+ registerDefaultStandardPbrSkin(registry, '// drift\n', STORAGE_CAPS);
1057
+ } catch {
1058
+ // expected
1059
+ }
1060
+ expect(lookup(registry).source).toBe(STUB_WGSL);
1061
+ });
1062
+ });
1063
+
1064
+ describe('T-31 (f) — non-registered identifier returns material-shader-not-found', () => {
1065
+ it('lookup after registration still fails for non-existent id', () => {
1066
+ const registry = makeMockRegistry();
1067
+ registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1068
+ const r = registry.findMaterialArtifact('forgeax::no-such-skin');
1069
+ expect(r.ok).toBe(false);
1070
+ if (r.ok) return;
1071
+ expect(r.error.code).toBe('material-shader-not-found');
1072
+ });
1073
+
1074
+ it("identifier prefix 'forgeax::' matches reserved prefix", () => {
1075
+ // Verify the reserved prefix is the same constant used by ShaderRegistry
1076
+ // (charter F1 grep gate — AI users grep FORGEAX_RESERVED_PATH_PREFIX).
1077
+ expect(FORGEAX_RESERVED_PATH_PREFIX).toBe('forgeax::');
1078
+ const id = 'forgeax::pbr-skin';
1079
+ expect(id.startsWith(FORGEAX_RESERVED_PATH_PREFIX)).toBe(true);
1080
+ });
1081
+ });
1082
+ }
1083
+
1084
+ {
1085
+ // --- from registry.test.ts ---
1086
+ // @forgeax/engine-shader/__tests__/registry.test.ts — MVP-2.2 ShaderRegistry node-mock integration tests.
1087
+ //
1088
+ // Shape rules (plan-strategy §S-10 + §S-11 + §4.3 MVP-2.2):
1089
+ // - The mock RHI Device reuses the upstream loop's gpu-device.ts shape: a sync
1090
+ // `device.createShaderModule` returning `Result<ShaderModule, RhiError>`.
1091
+ // - 5 test cases cover: (1) happy path / (2) hash miss / (3) manifest JSON
1092
+ // parse failure / (4) manifest schema missing `entries` / (5) RhiError
1093
+ // pass-through (`device.createShaderModule` fails).
1094
+ // - `registry.get(hash)` returns `Result<ShaderModule, RhiError | ShaderError>`
1095
+ // synchronously (AGENTS.md "RHI / Shader / error-model contract" 9-member
1096
+ // union).
1097
+ //
1098
+ // w16 red: before the ShaderRegistry impl lands, every test in this file
1099
+ // should fail (the registry class does not exist).
1100
+ // w17 green: after the ShaderRegistry instance-per-engine implementation lands,
1101
+ // every test passes.
1102
+ //
1103
+ // The fixture is injected via a `data:application/json,...` URL: this avoids a
1104
+ // node:fs dependency (same shape as the editor-target test fixtures in
1105
+ // packages/shader-compiler — pure ESM + the built-in fetch is enough).
1106
+
1107
+ /// <reference types="@webgpu/types" />
1108
+
1109
+ // ─── Mock device shape ──────────────────────────────────────────────────────────
1110
+
1111
+ const SHADER_MODULE_BRAND: unique symbol = Symbol('mock-shader-module');
1112
+ type MockShaderModule = { readonly [SHADER_MODULE_BRAND]: 'mock' };
1113
+
1114
+ interface MockDevice {
1115
+ readonly created: { readonly code: string; readonly label?: string | undefined }[];
1116
+ createShaderModule(desc: {
1117
+ readonly code: string;
1118
+ readonly label?: string | undefined;
1119
+ }): Result<ShaderModule, RhiError>;
1120
+ }
1121
+
1122
+ interface MockDeviceOptions {
1123
+ readonly failOnCode?: string;
1124
+ }
1125
+
1126
+ function createMockDevice(opts: MockDeviceOptions = {}): MockDevice {
1127
+ const created: { readonly code: string; readonly label?: string | undefined }[] = [];
1128
+ return {
1129
+ get created() {
1130
+ return created;
1131
+ },
1132
+ createShaderModule(desc) {
1133
+ created.push(desc);
1134
+ if (opts.failOnCode !== undefined && desc.code === opts.failOnCode) {
1135
+ const rhiError: RhiError = {
1136
+ name: 'RhiError',
1137
+ message: 'mock: shader-compile-failed forced',
1138
+ code: 'shader-compile-failed',
1139
+ expected: 'WGSL compiles cleanly',
1140
+ hint: 'fix WGSL source per detail.compilerMessages',
1141
+ detail: {
1142
+ compilerMessages: [
1143
+ {
1144
+ message: 'mock-forced compile error',
1145
+ type: 'error',
1146
+ lineNum: 1,
1147
+ linePos: 1,
1148
+ offset: 0,
1149
+ length: 0,
1150
+ } as GPUCompilationMessage,
1151
+ ],
1152
+ },
1153
+ } as unknown as RhiError;
1154
+ return errResult(rhiError);
1155
+ }
1156
+ const handle: MockShaderModule = { [SHADER_MODULE_BRAND]: 'mock' as const };
1157
+ return okResult(handle as unknown as ShaderModule);
1158
+ },
1159
+ };
1160
+ }
1161
+
1162
+ interface OneShotRefusalDevice extends MockDevice {
1163
+ readonly refusal: RhiError;
1164
+ clearRefusal(): void;
1165
+ }
1166
+
1167
+ function createOneShotRefusalDevice(targetCode: string): OneShotRefusalDevice {
1168
+ const refusal: RhiError = {
1169
+ name: 'RhiError',
1170
+ message: 'mock: transient webgpu-runtime-error forced',
1171
+ code: 'webgpu-runtime-error',
1172
+ expected: 'shader module creation succeeds',
1173
+ hint: 'retry the same shader hash after the transient refusal clears',
1174
+ detail: {
1175
+ error: {
1176
+ code: 'webgpu-runtime-error',
1177
+ message: 'mock one-shot refusal',
1178
+ },
1179
+ },
1180
+ } as unknown as RhiError;
1181
+ const created: { readonly code: string; readonly label?: string | undefined }[] = [];
1182
+ let refusalArmed = true;
1183
+ return {
1184
+ get created() {
1185
+ return created;
1186
+ },
1187
+ refusal,
1188
+ clearRefusal() {
1189
+ refusalArmed = false;
1190
+ },
1191
+ createShaderModule(desc) {
1192
+ created.push(desc);
1193
+ if (refusalArmed && desc.code === targetCode) {
1194
+ return errResult(refusal);
1195
+ }
1196
+ const handle: MockShaderModule = { [SHADER_MODULE_BRAND]: 'mock' as const };
1197
+ return okResult(handle as unknown as ShaderModule);
1198
+ },
1199
+ };
1200
+ }
1201
+
1202
+ // ─── Manifest fixture loader (data: URL inline) ────────────────────────────────
1203
+
1204
+ const VALID_FIXTURE = {
1205
+ entries: [
1206
+ {
1207
+ hash: 'abc12345',
1208
+ wgsl:
1209
+ '@vertex fn vs_main() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); } ' +
1210
+ '@fragment fn fs_main() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }',
1211
+ glsl: null,
1212
+ bindings: '[]',
1213
+ },
1214
+ {
1215
+ hash: 'def67890',
1216
+ wgsl:
1217
+ '@vertex fn vs_main() -> @builtin(position) vec4<f32> { return vec4<f32>(1.0); } ' +
1218
+ '@fragment fn fs_main() -> @location(0) vec4<f32> { return vec4<f32>(0.5); }',
1219
+ glsl: '',
1220
+ bindings: '[]',
1221
+ },
1222
+ ],
1223
+ };
1224
+
1225
+ function dataUrl(payload: string): string {
1226
+ return `data:application/json,${encodeURIComponent(payload)}`;
1227
+ }
1228
+
1229
+ const VALID_MANIFEST_URL = dataUrl(JSON.stringify(VALID_FIXTURE));
1230
+
1231
+ // ─── Tests ───────────────────────────────────────────────────────────────────────
1232
+
1233
+ describe('ShaderRegistry (MVP-2.2 node mock)', () => {
1234
+ it('happy path: loadManifest + get(existing hash) → Result.ok(ShaderModule)', async () => {
1235
+ const device = createMockDevice();
1236
+ const registry = new ShaderRegistry({
1237
+ device: device as never,
1238
+ manifestUrl: VALID_MANIFEST_URL,
1239
+ });
1240
+
1241
+ const loaded = await registry.loadManifest();
1242
+ expect(loaded.ok).toBe(true);
1243
+
1244
+ const result = registry.get('abc12345');
1245
+ expect(result.ok).toBe(true);
1246
+ if (result.ok) {
1247
+ expect(result.value).toBeDefined();
1248
+ }
1249
+ });
1250
+
1251
+ it('hash miss path: get(unknown hash) → Result.err(ShaderError.shader-not-found)', async () => {
1252
+ const device = createMockDevice();
1253
+ const registry = new ShaderRegistry({
1254
+ device: device as never,
1255
+ manifestUrl: VALID_MANIFEST_URL,
1256
+ });
1257
+
1258
+ await registry.loadManifest();
1259
+
1260
+ const result = registry.get('not-exist');
1261
+ expect(result.ok).toBe(false);
1262
+ if (!result.ok) {
1263
+ const err = result.error as ShaderError;
1264
+ expect(err.code).toBe('shader-not-found');
1265
+ }
1266
+ });
1267
+
1268
+ it('manifest malformed: invalid JSON → Result.err(ShaderError.manifest-malformed)', async () => {
1269
+ const device = createMockDevice();
1270
+ const broken = dataUrl('{invalid json');
1271
+ const registry = new ShaderRegistry({
1272
+ device: device as never,
1273
+ manifestUrl: broken,
1274
+ });
1275
+
1276
+ const loaded = await registry.loadManifest();
1277
+ expect(loaded.ok).toBe(false);
1278
+ if (!loaded.ok) {
1279
+ expect(loaded.error.code).toBe('manifest-malformed');
1280
+ }
1281
+ });
1282
+
1283
+ it('manifest schema malformed: missing entries field → Result.err(ShaderError.manifest-malformed)', async () => {
1284
+ const device = createMockDevice();
1285
+ const broken = dataUrl(JSON.stringify({ noEntriesField: true }));
1286
+ const registry = new ShaderRegistry({
1287
+ device: device as never,
1288
+ manifestUrl: broken,
1289
+ });
1290
+
1291
+ const loaded = await registry.loadManifest();
1292
+ expect(loaded.ok).toBe(false);
1293
+ if (!loaded.ok) {
1294
+ expect(loaded.error.code).toBe('manifest-malformed');
1295
+ }
1296
+ });
1297
+
1298
+ it('RhiError pass-through: device.createShaderModule fails → Result.err(RhiError)', async () => {
1299
+ const targetEntry = VALID_FIXTURE.entries[0];
1300
+ if (!targetEntry) throw new Error('fixture missing entries[0]');
1301
+ const device = createMockDevice({ failOnCode: targetEntry.wgsl });
1302
+ const registry = new ShaderRegistry({
1303
+ device: device as never,
1304
+ manifestUrl: VALID_MANIFEST_URL,
1305
+ });
1306
+
1307
+ const loaded = await registry.loadManifest();
1308
+ expect(loaded.ok).toBe(true);
1309
+
1310
+ const result = registry.get(targetEntry.hash);
1311
+ expect(result.ok).toBe(false);
1312
+ if (!result.ok) {
1313
+ // RhiError-path discrimination: check `.code === 'shader-compile-failed'`
1314
+ // (which is not a member of the 4-variant ShaderErrorCode union).
1315
+ expect(result.error.code).toBe('shader-compile-failed');
1316
+ }
1317
+ });
1318
+
1319
+ it('retries one transient refusal on the same registry and preserves the healthy sibling cache', async () => {
1320
+ const healthyEntry = VALID_FIXTURE.entries[0];
1321
+ const retryEntry = VALID_FIXTURE.entries[1];
1322
+ if (healthyEntry === undefined || retryEntry === undefined) {
1323
+ throw new Error('fixture missing sibling entries');
1324
+ }
1325
+ const device = createOneShotRefusalDevice(retryEntry.wgsl);
1326
+ const registry = new ShaderRegistry({
1327
+ device: device as never,
1328
+ manifestUrl: VALID_MANIFEST_URL,
1329
+ });
1330
+
1331
+ const loaded = await registry.loadManifest();
1332
+ expect(loaded.ok).toBe(true);
1333
+
1334
+ const healthyFirst = registry.get(healthyEntry.hash);
1335
+ expect(healthyFirst.ok).toBe(true);
1336
+ if (!healthyFirst.ok) return;
1337
+ const healthyModule = healthyFirst.value;
1338
+
1339
+ const firstRefusal = registry.get(retryEntry.hash);
1340
+ expect(firstRefusal.ok).toBe(false);
1341
+ if (firstRefusal.ok) return;
1342
+ expect(firstRefusal.error.code).toBe('webgpu-runtime-error');
1343
+ expect(firstRefusal.error).toBe(device.refusal);
1344
+ expect(device.created.map(({ label }) => label)).toEqual([
1345
+ healthyEntry.hash,
1346
+ retryEntry.hash,
1347
+ ]);
1348
+
1349
+ device.clearRefusal();
1350
+ const recovered = registry.get(retryEntry.hash);
1351
+ expect(recovered.ok).toBe(true);
1352
+ if (!recovered.ok) return;
1353
+ const recoveredModule = recovered.value;
1354
+ expect(device.created.map(({ label }) => label)).toEqual([
1355
+ healthyEntry.hash,
1356
+ retryEntry.hash,
1357
+ retryEntry.hash,
1358
+ ]);
1359
+
1360
+ const cachedRecovered = registry.get(retryEntry.hash);
1361
+ expect(cachedRecovered.ok).toBe(true);
1362
+ if (!cachedRecovered.ok) return;
1363
+ expect(cachedRecovered.value).toBe(recoveredModule);
1364
+ expect(device.created).toHaveLength(3);
1365
+
1366
+ const healthyAgain = registry.get(healthyEntry.hash);
1367
+ expect(healthyAgain.ok).toBe(true);
1368
+ if (!healthyAgain.ok) return;
1369
+ expect(healthyAgain.value).toBe(healthyModule);
1370
+ expect(device.created).toHaveLength(3);
1371
+ });
1372
+ });
1373
+ }
1374
+
1375
+ {
1376
+ // --- from skybox-direction.test.ts ---
1377
+ // @forgeax/engine-shader/__tests__/skybox-direction.test.ts -
1378
+ // TS-equivalence test for `skyboxDirection(uv)` in skybox.wgsl
1379
+ // (bug-20260608-skybox-cubemaps-upside-down).
1380
+ //
1381
+ // Bug repro:
1382
+ // `fullscreen_triangle()` (common.wgsl) emits `v = 1 - (y + 1) * 0.5`,
1383
+ // which is the Y-FLIPPED version of NDC.y (WebGPU top-left UV origin
1384
+ // convention). The skybox fragment stage then reconstructs `ndc.y` as
1385
+ // `uv.y * 2 - 1`, which produces the OPPOSITE sign of the actual NDC.y
1386
+ // at that fragment. Apply `inverseViewProj` to that flipped ndc and
1387
+ // you get a world-space "view direction" with Y inverted relative to
1388
+ // the real camera ray; the `dir.y = -dir.y` post-flip (kept for the IBL
1389
+ // bake convention, ibl-sampling.wgsl:30) then double-negates and the
1390
+ // skybox renders upside-down for any fragment whose actual NDC.y != 0.
1391
+ //
1392
+ // Acceptance: at the fragment under the screen center vertically (NDC y > 0,
1393
+ // i.e. upper half of screen), the reconstructed world-direction must point
1394
+ // "up" in world space (positive Y component, given an identity view at
1395
+ // origin looking down -Z). Pre-fix this test FAILS because the math returns
1396
+ // a "down" direction at the upper-half fragment.
1397
+
1398
+ // TS port of the WGSL `fullscreen_triangle` body (kept in lockstep with
1399
+ // common.wgsl; same equations as fullscreen-triangle.test.ts).
1400
+ function fullscreenTriangleUv(vertexIndex: number): [number, number] {
1401
+ const x = vertexIndex === 1 ? 3.0 : -1.0;
1402
+ const y = vertexIndex === 2 ? 3.0 : -1.0;
1403
+ const u = (x + 1.0) * 0.5;
1404
+ const v = 1.0 - (y + 1.0) * 0.5;
1405
+ return [u, v];
1406
+ }
1407
+
1408
+ // Linear interpolation of UV across the on-screen [-1, 1]^2 NDC box, given
1409
+ // the rasteriser barycentric of the 3-vertex covering triangle. The on-
1410
+ // screen quad portion of vertices (-1,-1)/(3,-1)/(-1,3) interpolates UV
1411
+ // linearly because the triangle's vertex attributes are linear in NDC.
1412
+ function interpolateUvAtNdc(ndc: [number, number]): [number, number] {
1413
+ const v0 = { ndc: [-1, -1] as [number, number], uv: fullscreenTriangleUv(0) };
1414
+ const v1 = { ndc: [3, -1] as [number, number], uv: fullscreenTriangleUv(1) };
1415
+ const v2 = { ndc: [-1, 3] as [number, number], uv: fullscreenTriangleUv(2) };
1416
+ const denom =
1417
+ (v1.ndc[1] - v2.ndc[1]) * (v0.ndc[0] - v2.ndc[0]) +
1418
+ (v2.ndc[0] - v1.ndc[0]) * (v0.ndc[1] - v2.ndc[1]);
1419
+ const w0 =
1420
+ ((v1.ndc[1] - v2.ndc[1]) * (ndc[0] - v2.ndc[0]) +
1421
+ (v2.ndc[0] - v1.ndc[0]) * (ndc[1] - v2.ndc[1])) /
1422
+ denom;
1423
+ const w1 =
1424
+ ((v2.ndc[1] - v0.ndc[1]) * (ndc[0] - v2.ndc[0]) +
1425
+ (v0.ndc[0] - v2.ndc[0]) * (ndc[1] - v2.ndc[1])) /
1426
+ denom;
1427
+ const w2 = 1.0 - w0 - w1;
1428
+ return [
1429
+ w0 * v0.uv[0] + w1 * v1.uv[0] + w2 * v2.uv[0],
1430
+ w0 * v0.uv[1] + w1 * v1.uv[1] + w2 * v2.uv[1],
1431
+ ];
1432
+ }
1433
+
1434
+ // 4x4 inverse-view-projection matrix utility. We bake one for an identity
1435
+ // view at origin looking down -Z (camera at world origin, +Y up, +Z back),
1436
+ // with a standard WebGPU [0, 1] NDC.z perspective projection. The result
1437
+ // matrix is `inv(P * V) = inv(P)` (V is identity).
1438
+ function invPerspectiveWebgpu(fovY: number, aspect: number, near: number, far: number): number[] {
1439
+ // mat4.perspective (WebGPU [0, 1] NDC.z), column-major.
1440
+ const f = 1 / Math.tan(fovY / 2);
1441
+ const nf = 1 / (near - far);
1442
+ // proj column-major laid out as [m0..m15]:
1443
+ // [f/a, 0, 0, 0]
1444
+ // [0, f, 0, 0]
1445
+ // [0, 0, far*nf, -1]
1446
+ // [0, 0, far*near*nf, 0]
1447
+ const m00 = f / aspect;
1448
+ const m11 = f;
1449
+ const m22 = far * nf;
1450
+ const m23 = -1;
1451
+ const m32 = far * near * nf;
1452
+ // Inverse of this perspective is straightforward (block-diagonal in 2x2
1453
+ // pieces). Derivation:
1454
+ // inv(diag(a, b, c, d)-ish) where the bottom-right 2x2 is
1455
+ // [[m22, m32], [-1, 0]]
1456
+ // det = m22 * 0 - m32 * -1 = m32
1457
+ // inv = (1/m32) * [[0, -m32], [1, m22]]
1458
+ // so inv(proj)[10] = 0, inv(proj)[11] = 1/m32,
1459
+ // inv(proj)[14] = -1, inv(proj)[15] = m22/m32
1460
+ const out = [1 / m00, 0, 0, 0, 0, 1 / m11, 0, 0, 0, 0, 0, -1, 0, 0, 1 / m32, m22 / m32];
1461
+ // ignore m23 reference for byte-stable structure
1462
+ void m23;
1463
+ return out;
1464
+ }
1465
+
1466
+ // Multiply 4x4 (column-major) by 4-vector.
1467
+ function mat4MulVec4(
1468
+ m: number[],
1469
+ v: [number, number, number, number],
1470
+ ): [number, number, number, number] {
1471
+ // column-major: out[i] = sum_j m[j*4 + i] * v[j]
1472
+ const o0 = (m[0] ?? 0) * v[0] + (m[4] ?? 0) * v[1] + (m[8] ?? 0) * v[2] + (m[12] ?? 0) * v[3];
1473
+ const o1 = (m[1] ?? 0) * v[0] + (m[5] ?? 0) * v[1] + (m[9] ?? 0) * v[2] + (m[13] ?? 0) * v[3];
1474
+ const o2 = (m[2] ?? 0) * v[0] + (m[6] ?? 0) * v[1] + (m[10] ?? 0) * v[2] + (m[14] ?? 0) * v[3];
1475
+ const o3 = (m[3] ?? 0) * v[0] + (m[7] ?? 0) * v[1] + (m[11] ?? 0) * v[2] + (m[15] ?? 0) * v[3];
1476
+ return [o0, o1, o2, o3];
1477
+ }
1478
+
1479
+ function normalize3(v: [number, number, number]): [number, number, number] {
1480
+ const m = Math.hypot(v[0], v[1], v[2]);
1481
+ return [v[0] / m, v[1] / m, v[2] / m];
1482
+ }
1483
+
1484
+ // TS port of `skyboxDirection(uv)` that is byte-equivalent to skybox.wgsl
1485
+ // (the function under test). `inverseViewProj` is column-major mat4.
1486
+ // Final `[x, -y, z]` is the IBL-bake compensation (matches
1487
+ // ibl-sampling.wgsl:30 / :47 convention; left intact post-fix).
1488
+ function skyboxDirectionTS(
1489
+ uv: [number, number],
1490
+ inverseViewProj: number[],
1491
+ ): [number, number, number] {
1492
+ // bug fix: the V-flip in fullscreen_triangle's `v = 1 - (y + 1) * 0.5`
1493
+ // is undone here so ndc.y matches the actual fragment NDC.y. Pre-fix
1494
+ // line was `uv.y * 2 - 1`, which produced the OPPOSITE sign of NDC.y.
1495
+ const ndcX = uv[0] * 2.0 - 1.0;
1496
+ const ndcY = 1.0 - uv[1] * 2.0;
1497
+ const worldDir = mat4MulVec4(inverseViewProj, [ndcX, ndcY, 1.0, 1.0]);
1498
+ const w = worldDir[3];
1499
+ const dir = normalize3([worldDir[0] / w, worldDir[1] / w, worldDir[2] / w]);
1500
+ return [dir[0], -dir[1], dir[2]];
1501
+ }
1502
+
1503
+ describe('skybox.wgsl skyboxDirection (TS-equivalence)', () => {
1504
+ // Identity view at origin looking down -Z; standard perspective.
1505
+ const fovY = Math.PI / 3;
1506
+ const aspect = 1.0;
1507
+ const near = 0.1;
1508
+ const far = 100.0;
1509
+ const invVP = invPerspectiveWebgpu(fovY, aspect, near, far);
1510
+
1511
+ it('center fragment (NDC 0, 0) -> sampling direction approximately +/- camera forward', () => {
1512
+ const uv = interpolateUvAtNdc([0, 0]);
1513
+ const dir = skyboxDirectionTS(uv, invVP);
1514
+ // Camera looks down -Z, so center-of-screen direction should be
1515
+ // (0, 0, -1) (forward). Y is small either way (uv flip cancels).
1516
+ expect(Math.abs(dir[0])).toBeLessThan(1e-5);
1517
+ expect(Math.abs(dir[1])).toBeLessThan(1e-5);
1518
+ expect(dir[2]).toBeLessThan(0);
1519
+ });
1520
+
1521
+ it('upper-half fragment (NDC y = +0.8) -> POST-IBL-FLIP sample direction has y < 0 (samples world UP face)', () => {
1522
+ // bug-20260608: this is the litmus test.
1523
+ // With the V-flip undone in skyboxDirection, the upper screen fragment
1524
+ // produces a world-space view direction with positive Y (looking UP).
1525
+ // The post-flip `[x, -y, z]` for cubemap sampling then makes the
1526
+ // SAMPLED direction y-component NEGATIVE, which is correct for the
1527
+ // engine's IBL-bake convention (CAPTURE_VIEW_PROJS up=-Y) -- the
1528
+ // -Y face of the bake is the world UP face (ceiling in the loft).
1529
+ const uv = interpolateUvAtNdc([0, 0.8]);
1530
+ const dir = skyboxDirectionTS(uv, invVP);
1531
+ // Pre-fix bug would yield dir[1] > 0 (sampling the world DOWN face
1532
+ // = floor at the top of the screen = upside-down skybox).
1533
+ expect(dir[1]).toBeLessThan(0);
1534
+ });
1535
+
1536
+ it('lower-half fragment (NDC y = -0.8) -> POST-IBL-FLIP sample direction has y > 0 (samples world DOWN face)', () => {
1537
+ const uv = interpolateUvAtNdc([0, -0.8]);
1538
+ const dir = skyboxDirectionTS(uv, invVP);
1539
+ expect(dir[1]).toBeGreaterThan(0);
1540
+ });
1541
+
1542
+ // Source-text grep gate so a regression that re-introduces the double-
1543
+ // Y-flip is caught at unit-test time, not on visual smoke. Dynamic node
1544
+ // imports mirror ibl-modules-parse.test.ts (the package's tsconfig does
1545
+ // not include @types/node); the WGSL is the SSOT, the TS port above
1546
+ // tracks the formula manually.
1547
+ it('keeps file-source `1.0 - uv.y * 2.0` rather than the buggy `uv.y * 2.0 - 1.0`', async () => {
1548
+ const fsId = 'node:fs';
1549
+ const pathId = 'node:path';
1550
+ const urlId = 'node:url';
1551
+ const fs = (await import(/* @vite-ignore */ fsId)) as {
1552
+ readFileSync: (p: string, enc: string) => string;
1553
+ };
1554
+ const path = (await import(/* @vite-ignore */ pathId)) as {
1555
+ resolve: (...parts: string[]) => string;
1556
+ dirname: (p: string) => string;
1557
+ };
1558
+ const url = (await import(/* @vite-ignore */ urlId)) as {
1559
+ fileURLToPath: (u: string) => string;
1560
+ };
1561
+ const here = url.fileURLToPath(import.meta.url);
1562
+ const src = fs.readFileSync(path.resolve(path.dirname(here), '..', 'skybox.wgsl'), 'utf8');
1563
+ expect(src).toEqual(expect.stringContaining('1.0 - uv.y * 2.0'));
1564
+ expect(src).not.toEqual(expect.stringContaining('uv.y * 2.0 - 1.0'));
1565
+ });
1566
+ });
1567
+ }
1568
+
1569
+ {
1570
+ // --- from tbn-lighting-helpers.test.ts ---
1571
+ // tbn-lighting-helpers.test.ts -- feat-20260523-shader-template-instance-split
1572
+ // M5 / T03.
1573
+ //
1574
+ // Structural unit tests for the three new helper ShaderModules extracted from
1575
+ // pbr.wgsl in M5-T01 + M5-T02:
1576
+ // - forgeax_pbr::tbn (tbn.wgsl)
1577
+ // - forgeax_pbr::lighting_directional (lighting-directional.wgsl)
1578
+ // - forgeax_pbr::lighting_punctual (lighting-punctual.wgsl)
1579
+ //
1580
+ // AC anchor: requirements AC-06 (ShaderModule extraction must not regress
1581
+ // pbr.wgsl semantics) + plan-strategy M5 §7 (TBN + lighting helper module
1582
+ // boundary). Following the established structural-grep pattern in
1583
+ // `ibl-modules-parse.test.ts` -- this package physically isolates naga
1584
+ // (4 grep gates), so cross-module composition is exercised by the dawn
1585
+ // integration tests in packages/runtime/src/__tests__/dawn/. Here we
1586
+ // assert source-file invariants only:
1587
+ // (a) every module declares the exact #define_import_path
1588
+ // (b) helpers are physically present (function declarations grep)
1589
+ // (c) pbr.wgsl no longer carries the inlined helper bodies (regression
1590
+ // guard: future edits to pbr.wgsl must not re-inline the helpers)
1591
+ // (d) tbn.wgsl is zero-binding (mirrors ibl-sampling style)
1592
+ // (e) lighting-punctual.wgsl is zero-binding (host owns light buffers)
1593
+ //
1594
+ // node:* are accessed via `await import(varId)` (vite-ignore) -- the
1595
+ // established cross-package pattern (see ibl-modules-parse.test.ts) keeps
1596
+ // the shader tsconfig free of @types/node dependency.
1597
+
1598
+ interface NodeFs {
1599
+ readFileSync: (p: string, enc: string) => string;
1600
+ }
1601
+ interface NodePath {
1602
+ resolve: (...parts: string[]) => string;
1603
+ dirname: (p: string) => string;
1604
+ }
1605
+ interface NodeUrl {
1606
+ fileURLToPath: (u: string) => string;
1607
+ }
1608
+
1609
+ let fs!: NodeFs;
1610
+ let srcDir!: string;
1611
+
1612
+ beforeAll(async () => {
1613
+ const fsId = 'node:fs';
1614
+ const pathId = 'node:path';
1615
+ const urlId = 'node:url';
1616
+ fs = (await import(/* @vite-ignore */ fsId)) as NodeFs;
1617
+ const path = (await import(/* @vite-ignore */ pathId)) as NodePath;
1618
+ const url = (await import(/* @vite-ignore */ urlId)) as NodeUrl;
1619
+ const here = url.fileURLToPath(import.meta.url);
1620
+ srcDir = path.resolve(path.dirname(here), '..');
1621
+ });
1622
+
1623
+ function readSource(file: string): string {
1624
+ return fs.readFileSync(`${srcDir}/${file}`, 'utf8');
1625
+ }
1626
+
1627
+ function stripComments(src: string): string {
1628
+ return src
1629
+ .split(/\r?\n/)
1630
+ .map((line: string) => line.replace(/\/\/.*$/, ''))
1631
+ .join('\n');
1632
+ }
1633
+
1634
+ interface HelperModuleSpec {
1635
+ readonly file: string;
1636
+ readonly importPath: string;
1637
+ readonly exportedFns: readonly string[];
1638
+ readonly zeroBinding: boolean;
1639
+ }
1640
+
1641
+ const MODULES: readonly HelperModuleSpec[] = [
1642
+ {
1643
+ file: 'tbn.wgsl',
1644
+ importPath: 'forgeax_pbr::tbn',
1645
+ exportedFns: ['decodeTangentSpaceNormalRg', 'applyTBN'],
1646
+ zeroBinding: true,
1647
+ },
1648
+ {
1649
+ file: 'lighting-directional.wgsl',
1650
+ importPath: 'forgeax_pbr::lighting_directional',
1651
+ exportedFns: ['evalDirectional', 'evalDirectionalNoShadow', 'evalDirectionalShadowFactor'],
1652
+ // lighting-directional reads view + shadowMap from forgeax_view::common
1653
+ // via #import; the helper itself declares no @group/@binding so the
1654
+ // zero-binding invariant still holds at the file level.
1655
+ zeroBinding: true,
1656
+ },
1657
+ {
1658
+ file: 'lighting-punctual.wgsl',
1659
+ importPath: 'forgeax_pbr::lighting_punctual',
1660
+ exportedFns: ['evalPunctualBody', 'evalPoint', 'evalSpot'],
1661
+ zeroBinding: true,
1662
+ },
1663
+ ];
1664
+
1665
+ describe('M5-T03 (a) -- helper modules declare the exact #define_import_path', () => {
1666
+ for (const spec of MODULES) {
1667
+ it(`${spec.file} declares ${spec.importPath}`, () => {
1668
+ const src = readSource(spec.file);
1669
+ const m = /^\s*#define_import_path\s+(\S+)\s*$/m.exec(src);
1670
+ expect(m?.[1]).toBe(spec.importPath);
1671
+ });
1672
+ }
1673
+ });
1674
+
1675
+ describe('M5-T03 (b) -- helper modules export the expected fn names', () => {
1676
+ for (const spec of MODULES) {
1677
+ describe(spec.file, () => {
1678
+ for (const fnName of spec.exportedFns) {
1679
+ it(`exports fn ${fnName}`, () => {
1680
+ const codeOnly = stripComments(readSource(spec.file));
1681
+ const re = new RegExp(`fn\\s+${fnName}\\s*\\(`);
1682
+ expect(codeOnly).toMatch(re);
1683
+ });
1684
+ }
1685
+ });
1686
+ }
1687
+ });
1688
+
1689
+ describe('M5-T03 (c) -- engine PBR entry re-imports the extracted helpers (no re-inline)', () => {
1690
+ // Regression guard: the M5-T01 / M5-T02 commits removed the inlined
1691
+ // helper bodies from pbr.wgsl and replaced them with #import directives.
1692
+ // M5 / T09 retired pbr.wgsl in favour of default-standard-pbr.wgsl
1693
+ // (same body); the regression guard retargets at the new file path so
1694
+ // a future edit that re-inlines (e.g. paste-in fix-up) defeats the
1695
+ // module boundary at lint time.
1696
+ it('default-standard-pbr.wgsl imports forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, applyTBN}', () => {
1697
+ const src = readSource('default-standard-pbr.wgsl');
1698
+ expect(src).toMatch(/#import\s+forgeax_pbr::tbn::/);
1699
+ expect(src).toMatch(/decodeTangentSpaceNormalRg/);
1700
+ expect(src).toMatch(/applyTBN/);
1701
+ });
1702
+
1703
+ it('default-standard-pbr.wgsl imports forgeax_pbr::lighting_directional + lighting_punctual', () => {
1704
+ const src = readSource('default-standard-pbr.wgsl');
1705
+ expect(src).toMatch(/#import\s+forgeax_pbr::lighting_directional::/);
1706
+ expect(src).toMatch(/#import\s+forgeax_pbr::lighting_punctual::/);
1707
+ });
1708
+
1709
+ it('default-standard-pbr.wgsl no longer defines fn evalDirectional / evalPoint / evalSpot inline', () => {
1710
+ const codeOnly = stripComments(readSource('default-standard-pbr.wgsl'));
1711
+ expect(codeOnly).not.toMatch(/fn\s+evalDirectional\s*\(/);
1712
+ expect(codeOnly).not.toMatch(/fn\s+evalPoint\s*\(/);
1713
+ expect(codeOnly).not.toMatch(/fn\s+evalSpot\s*\(/);
1714
+ expect(codeOnly).not.toMatch(/fn\s+evalPunctualBody\s*\(/);
1715
+ });
1716
+
1717
+ it('built-in PBR shaders evaluate directional CSM once and reuse it for clearcoat', () => {
1718
+ for (const file of ['default-standard-pbr.wgsl', 'default-standard-pbr-skin.wgsl']) {
1719
+ const codeOnly = stripComments(readSource(file));
1720
+ expect(codeOnly.match(/evalDirectionalShadowFactor\s*\(/g)).toHaveLength(1);
1721
+ expect(codeOnly).not.toMatch(/evalDirectional\s*\(/);
1722
+ expect(codeOnly.match(/evalDirectionalNoShadow\s*\(/g)).toHaveLength(2);
1723
+ }
1724
+ });
1725
+
1726
+ it('directional CSM skips atlas projection and PCF beyond the authored shadow distance', () => {
1727
+ const codeOnly = stripComments(readSource('lighting-directional.wgsl'));
1728
+ expect(codeOnly).toMatch(
1729
+ /if\s*\(viewDepth\s*>\s*view\.splitPlanes\[count\s*-\s*1u\]\.x\)\s*\{\s*return\s+1\.0\s*;/,
1730
+ );
1731
+ });
1732
+
1733
+ it('directional CSM treats cascadeCount=0 as the fully-lit castShadow opt-out', () => {
1734
+ const codeOnly = stripComments(readSource('lighting-directional.wgsl'));
1735
+ expect(codeOnly).toMatch(
1736
+ /fn\s+evalDirectionalShadowFactor[\s\S]*?if\s*\(view\.cascadeCount\s*<\s*1\.0\s*\)\s*\{\s*return\s+1\.0\s*;/,
1737
+ );
1738
+ });
1739
+
1740
+ it('directional CSM samples the compact two-cascade 2x1 atlas without halving Y', () => {
1741
+ const codeOnly = stripComments(readSource('lighting-directional.wgsl'));
1742
+ expect(codeOnly).toMatch(
1743
+ /let\s+rows\s*:\s*u32\s*=\s*\(count\s*\+\s*columns\s*-\s*1u\)\s*\/\s*columns/,
1744
+ );
1745
+ expect(codeOnly).toMatch(/let\s+tileScale\s*=\s*_atlasTileScale\(count\)/);
1746
+ expect(codeOnly).toMatch(/let\s+uv\s*=\s*tileUv\s*\*\s*tileScale\s*\+\s*tileOrigin/);
1747
+ expect(codeOnly).not.toMatch(/tileUv\s*\*\s*inv\s*\+\s*tileOrigin/);
1748
+ });
1749
+
1750
+ it('directional PCF selects fixed 1x1, 3x3, or 5x5 paths without per-tap radius branches', () => {
1751
+ const codeOnly = stripComments(readSource('lighting-directional.wgsl'));
1752
+ expect(codeOnly).toMatch(/if\s*\(kernel\s*==\s*1u\)/);
1753
+ expect(codeOnly).toMatch(/if\s*\(kernel\s*==\s*3u\)/);
1754
+ expect(codeOnly).not.toMatch(/abs\(x\)\s*>\s*halfI/);
1755
+ expect(codeOnly).not.toMatch(/continue\s*;/);
1756
+ });
1757
+
1758
+ it('default-standard-pbr.wgsl no longer inlines the TBN basis math directly', () => {
1759
+ // The tell-tale of the pre-extraction inline TBN block: the `t0` /
1760
+ // `b0` / `n0` triple-let pattern appearing all together.
1761
+ const codeOnly = stripComments(readSource('default-standard-pbr.wgsl'));
1762
+ const tbnTriple = /let\s+n0\s*=\s*normalize\(in\.worldNormal\)/;
1763
+ expect(codeOnly).not.toMatch(tbnTriple);
1764
+ });
1765
+ });
1766
+
1767
+ describe('M5-T03 (d) -- helper modules respect their zero-binding invariant', () => {
1768
+ for (const spec of MODULES) {
1769
+ if (!spec.zeroBinding) continue;
1770
+ it(`${spec.file} declares no @group()/@binding() of its own`, () => {
1771
+ const codeOnly = stripComments(readSource(spec.file));
1772
+ expect(codeOnly).not.toMatch(/@group\(\d+\)\s*@binding\(\d+\)/);
1773
+ });
1774
+ }
1775
+ });
1776
+
1777
+ describe('M5-T03 (e) -- lighting helpers re-use the shared brdf module', () => {
1778
+ // Plan-strategy P4 consistent abstraction: GGX / Schlick / Smith helpers
1779
+ // live in forgeax_pbr::brdf -- the lighting evaluators must pull them
1780
+ // through #import, not re-implement them inline.
1781
+ it('lighting-directional.wgsl #imports forgeax_pbr::brdf', () => {
1782
+ const src = readSource('lighting-directional.wgsl');
1783
+ expect(src).toMatch(/#import\s+forgeax_pbr::brdf/);
1784
+ });
1785
+
1786
+ it('lighting-punctual.wgsl #imports forgeax_pbr::brdf', () => {
1787
+ const src = readSource('lighting-punctual.wgsl');
1788
+ expect(src).toMatch(/#import\s+forgeax_pbr::brdf/);
1789
+ });
1790
+ });
1791
+ }
1792
+
1793
+ {
1794
+ // --- from tonemap-shader.test.ts ---
1795
+ // @forgeax/engine-shader/__tests__/tonemap-shader.test.ts -
1796
+ // TS-equivalence test for the luminance Reinhard formula in tonemap.wgsl
1797
+ // and the manifest registration of the 'tonemap-reinhard-extended'
1798
+ // pipeline (feat-20260519-tonemap-reinhard-mvp / T-M2.2 + T-M2.3).
1799
+ //
1800
+ // Three concerns:
1801
+ // 1. The fragment-stage luminance Reinhard 6-sample numerical
1802
+ // ground-truth table (research F1 section 6 - "extended Reinhard
1803
+ // matches Reinhard 2002 luminance variant"). The TS port shadowed
1804
+ // below is the formula manifest.
1805
+ // 2. TonemapParams std140 layout is 16 B (exposure + whitePoint
1806
+ // + 2 padding f32 = 16 B). The shader-side struct in tonemap.wgsl
1807
+ // keeps the same byte layout.
1808
+ // 3. TONEMAP_LUMINANCE_EPSILON is the shared TS / WGSL const
1809
+ // (D-O3, 1e-5).
1810
+ //
1811
+ // The test does not invoke a GPU. The luminance Reinhard ground truth is
1812
+ // the algorithmic specification; downstream browser / dawn integration
1813
+ // tests in M3 verify the GPU readback matches this TS port within a
1814
+ // pixel epsilon.
1815
+
1816
+ // Luminance weights for Rec. 709 (research F1, section 4 - "use the same
1817
+ // luminance weights the legacy CRT phosphor code paths used"). The
1818
+ // shader-side `dot(rgb, vec3(0.2126, 0.7152, 0.0722))` matches.
1819
+ const LUMINANCE_WEIGHTS = [0.2126, 0.7152, 0.0722] as const;
1820
+
1821
+ function tonemapReinhardLuminanceTS(
1822
+ rgbIn: readonly [number, number, number],
1823
+ exposure: number,
1824
+ whitePoint: number,
1825
+ ): [number, number, number] {
1826
+ const exposed: [number, number, number] = [
1827
+ rgbIn[0] * exposure,
1828
+ rgbIn[1] * exposure,
1829
+ rgbIn[2] * exposure,
1830
+ ];
1831
+ const luma =
1832
+ exposed[0] * LUMINANCE_WEIGHTS[0] +
1833
+ exposed[1] * LUMINANCE_WEIGHTS[1] +
1834
+ exposed[2] * LUMINANCE_WEIGHTS[2];
1835
+ const lumaPrime = (luma * (1.0 + luma / (whitePoint * whitePoint))) / (1.0 + luma);
1836
+ const scale = lumaPrime / Math.max(luma, TONEMAP_LUMINANCE_EPSILON);
1837
+ return [exposed[0] * scale, exposed[1] * scale, exposed[2] * scale];
1838
+ }
1839
+
1840
+ describe('tonemap-reinhard-extended luminance formula (TS-equivalence)', () => {
1841
+ it('TONEMAP_LUMINANCE_EPSILON is the shared 1e-5 floor (D-O3)', () => {
1842
+ expect(TONEMAP_LUMINANCE_EPSILON).toBe(1e-5);
1843
+ });
1844
+
1845
+ it('sample 1: black input -> black output (epsilon floor keeps divisor finite)', () => {
1846
+ const out = tonemapReinhardLuminanceTS([0, 0, 0], 1.0, 4.0);
1847
+ expect(out[0]).toBeLessThan(1e-3);
1848
+ expect(out[1]).toBeLessThan(1e-3);
1849
+ expect(out[2]).toBeLessThan(1e-3);
1850
+ });
1851
+
1852
+ it('sample 2: pure white at L = whitePoint -> Y_prime = 1 (top of curve)', () => {
1853
+ const out = tonemapReinhardLuminanceTS([4, 4, 4], 1.0, 4.0);
1854
+ const luma = 4 * (LUMINANCE_WEIGHTS[0] + LUMINANCE_WEIGHTS[1] + LUMINANCE_WEIGHTS[2]);
1855
+ expect(luma).toBeCloseTo(4.0, 5);
1856
+ const yPrime = (luma * (1.0 + luma / 16.0)) / (1.0 + luma);
1857
+ expect(yPrime).toBeCloseTo(1.0, 5);
1858
+ expect(out[0]).toBeCloseTo(1.0, 4);
1859
+ expect(out[1]).toBeCloseTo(1.0, 4);
1860
+ expect(out[2]).toBeCloseTo(1.0, 4);
1861
+ });
1862
+
1863
+ it('sample 3: mid-gray (0.5, 0.5, 0.5) at exposure=1, Lw=4', () => {
1864
+ const out = tonemapReinhardLuminanceTS([0.5, 0.5, 0.5], 1.0, 4.0);
1865
+ expect(out[0]).toBeGreaterThan(0.0);
1866
+ expect(out[0]).toBeLessThan(0.5);
1867
+ expect(out[0]).toBeCloseTo(out[1], 5);
1868
+ expect(out[0]).toBeCloseTo(out[2], 5);
1869
+ });
1870
+
1871
+ it('sample 4: high intensity (10, 5, 2) at Lw=4 stays in [0, 1] band', () => {
1872
+ const out = tonemapReinhardLuminanceTS([10, 5, 2], 1.0, 4.0);
1873
+ expect(out[0]).toBeGreaterThan(0.0);
1874
+ expect(out[1]).toBeGreaterThan(0.0);
1875
+ expect(out[2]).toBeGreaterThan(0.0);
1876
+ // R is the brightest channel because the input is.
1877
+ expect(out[0]).toBeGreaterThan(out[1]);
1878
+ expect(out[1]).toBeGreaterThan(out[2]);
1879
+ });
1880
+
1881
+ it('sample 5: AC-05 exposure equivalence (exp=2 + L_in=halved == exp=1 + L_in)', () => {
1882
+ const a = tonemapReinhardLuminanceTS([10, 5, 2], 2.0, 4.0);
1883
+ const b = tonemapReinhardLuminanceTS([20, 10, 4], 1.0, 4.0);
1884
+ expect(a[0]).toBeCloseTo(b[0], 5);
1885
+ expect(a[1]).toBeCloseTo(b[1], 5);
1886
+ expect(a[2]).toBeCloseTo(b[2], 5);
1887
+ });
1888
+
1889
+ it('sample 6: Lw=1 + L=1 gray -> Y_prime = 1 (basic Reinhard degenerate case)', () => {
1890
+ const luma = 1.0;
1891
+ const yPrime = (luma * (1.0 + luma / 1.0)) / (1.0 + luma);
1892
+ expect(yPrime).toBeCloseTo(1.0, 5);
1893
+ const out = tonemapReinhardLuminanceTS([1, 1, 1], 1.0, 1.0);
1894
+ expect(out[0]).toBeCloseTo(1.0, 4);
1895
+ expect(out[1]).toBeCloseTo(1.0, 4);
1896
+ expect(out[2]).toBeCloseTo(1.0, 4);
1897
+ });
1898
+ });
1899
+
1900
+ describe('TonemapParams std140 layout', () => {
1901
+ it('TonemapParams stride is 16 B (4 f32 = exposure + whitePoint + 2 padding)', () => {
1902
+ // The host writes `Float32Array` of length 4 to the UBO; the shader
1903
+ // declares `struct TonemapParams { exposure: f32, whitePoint: f32,
1904
+ // _pad0: f32, _pad1: f32 }` — total = 16 B.
1905
+ const stride = 4 * 4;
1906
+ expect(stride).toBe(16);
1907
+ });
1908
+ });
1909
+ }