@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.
- package/LICENSE +202 -0
- package/README.md +99 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/ShaderRegistry.d.ts +208 -0
- package/dist/ShaderRegistry.d.ts.map +1 -0
- package/dist/__tests__/atmosphere-background.test.d.ts +2 -0
- package/dist/__tests__/atmosphere-background.test.d.ts.map +1 -0
- package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts +2 -0
- package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts.map +1 -0
- package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts +2 -0
- package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts.map +1 -0
- package/dist/__tests__/atmosphere-preetham-red.test.d.ts +2 -0
- package/dist/__tests__/atmosphere-preetham-red.test.d.ts.map +1 -0
- package/dist/__tests__/atmosphere-preetham.test.d.ts +2 -0
- package/dist/__tests__/atmosphere-preetham.test.d.ts.map +1 -0
- package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts +2 -0
- package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts.map +1 -0
- package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts +2 -0
- package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts.map +1 -0
- package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts +2 -0
- package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts.map +1 -0
- package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts +2 -0
- package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts.map +1 -0
- package/dist/__tests__/deferred-lighting-ssao.test.d.ts +2 -0
- package/dist/__tests__/deferred-lighting-ssao.test.d.ts.map +1 -0
- package/dist/__tests__/fog-oracle.unit.test.d.ts +2 -0
- package/dist/__tests__/fog-oracle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts +2 -0
- package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts.map +1 -0
- package/dist/__tests__/fog-producer-matrix.integration.test.d.ts +2 -0
- package/dist/__tests__/fog-producer-matrix.integration.test.d.ts.map +1 -0
- package/dist/__tests__/hdrp-ssao-compile.test.d.ts +2 -0
- package/dist/__tests__/hdrp-ssao-compile.test.d.ts.map +1 -0
- package/dist/__tests__/ibl-irradiance.unit.test.d.ts +2 -0
- package/dist/__tests__/ibl-irradiance.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ibl-sampling.unit.test.d.ts +2 -0
- package/dist/__tests__/ibl-sampling.unit.test.d.ts.map +1 -0
- package/dist/__tests__/lighting-punctual.unit.test.d.ts +2 -0
- package/dist/__tests__/lighting-punctual.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-artifact-dedup.unit.test.d.ts +2 -0
- package/dist/__tests__/material-artifact-dedup.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-builtins.unit.test.d.ts +2 -0
- package/dist/__tests__/material-builtins.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/material-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-derived-builtins.integration.test.d.ts +2 -0
- package/dist/__tests__/material-derived-builtins.integration.test.d.ts.map +1 -0
- package/dist/__tests__/public-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/public-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scene-temporal.unit.test.d.ts +2 -0
- package/dist/__tests__/scene-temporal.unit.test.d.ts.map +1 -0
- package/dist/__tests__/shader-registry-transaction.unit.test.d.ts +2 -0
- package/dist/__tests__/shader-registry-transaction.unit.test.d.ts.map +1 -0
- package/dist/__tests__/shader.unit.test.d.ts +2 -0
- package/dist/__tests__/shader.unit.test.d.ts.map +1 -0
- package/dist/__tests__/sprite-lit-shader.test.d.ts +2 -0
- package/dist/__tests__/sprite-lit-shader.test.d.ts.map +1 -0
- package/dist/__tests__/sprite-variants.unit.test.d.ts +2 -0
- package/dist/__tests__/sprite-variants.unit.test.d.ts.map +1 -0
- package/dist/__tests__/taa-resolve.unit.test.d.ts +2 -0
- package/dist/__tests__/taa-resolve.unit.test.d.ts.map +1 -0
- package/dist/__tests__/transparent-pbr.unit.test.d.ts +2 -0
- package/dist/__tests__/transparent-pbr.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vertex-color-variant.unit.test.d.ts +2 -0
- package/dist/__tests__/vertex-color-variant.unit.test.d.ts.map +1 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +560 -0
- package/dist/index.mjs.map +1 -0
- package/dist/material/artifact-registry.d.ts +31 -0
- package/dist/material/artifact-registry.d.ts.map +1 -0
- package/dist/material/artifact-types.d.ts +13 -0
- package/dist/material/artifact-types.d.ts.map +1 -0
- package/dist/material-schemas.d.ts +8 -0
- package/dist/material-schemas.d.ts.map +1 -0
- package/dist/register-default-sprite-lit.d.ts +40 -0
- package/dist/register-default-sprite-lit.d.ts.map +1 -0
- package/dist/register-default-standard-pbr-skin.d.ts +34 -0
- package/dist/register-default-standard-pbr-skin.d.ts.map +1 -0
- package/dist/tonemap.d.ts +13 -0
- package/dist/tonemap.d.ts.map +1 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/ShaderRegistry.ts +519 -0
- package/src/__tests__/atmosphere-background.test.ts +33 -0
- package/src/__tests__/atmosphere-cubemap-orientation.test.ts +68 -0
- package/src/__tests__/atmosphere-preetham-numeric.test.ts +122 -0
- package/src/__tests__/atmosphere-preetham-red.test.ts +52 -0
- package/src/__tests__/atmosphere-preetham.test.ts +132 -0
- package/src/__tests__/atmosphere-three-r184-reference.test.ts +122 -0
- package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +31 -0
- package/src/__tests__/builtin-texture-sampling-contract.test.ts +56 -0
- package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +19 -0
- package/src/__tests__/deferred-lighting-ssao.test.ts +114 -0
- package/src/__tests__/fog-oracle.unit.test.ts +132 -0
- package/src/__tests__/fog-orthographic-ray.unit.test.ts +62 -0
- package/src/__tests__/fog-producer-matrix.integration.test.ts +77 -0
- package/src/__tests__/hdrp-ssao-compile.test.ts +185 -0
- package/src/__tests__/ibl-irradiance.unit.test.ts +43 -0
- package/src/__tests__/ibl-sampling.unit.test.ts +40 -0
- package/src/__tests__/lighting-punctual.unit.test.ts +66 -0
- package/src/__tests__/manifest.fixture.json +22 -0
- package/src/__tests__/material-artifact-dedup.unit.test.ts +21 -0
- package/src/__tests__/material-builtins.unit.test.ts +111 -0
- package/src/__tests__/material-contract.unit.test.ts +79 -0
- package/src/__tests__/material-derived-builtins.integration.test.ts +30 -0
- package/src/__tests__/public-surface.unit.test.ts +9 -0
- package/src/__tests__/scene-temporal.unit.test.ts +32 -0
- package/src/__tests__/shader-registry-transaction.unit.test.ts +116 -0
- package/src/__tests__/shader.unit.test.ts +1909 -0
- package/src/__tests__/sprite-lit-shader.test.ts +240 -0
- package/src/__tests__/sprite-variants.unit.test.ts +209 -0
- package/src/__tests__/taa-resolve.unit.test.ts +32 -0
- package/src/__tests__/transparent-pbr.unit.test.ts +21 -0
- package/src/__tests__/vertex-color-variant.unit.test.ts +52 -0
- package/src/atmosphere-background.wgsl +80 -0
- package/src/atmosphere-cubemap.wgsl +72 -0
- package/src/atmosphere-ibl.wgsl +127 -0
- package/src/atmosphere-preetham.wgsl +214 -0
- package/src/bloom-blur.wgsl +89 -0
- package/src/bloom-bright.wgsl +50 -0
- package/src/bloom-composite.wgsl +54 -0
- package/src/brdf.wgsl +25 -0
- package/src/common.wgsl +523 -0
- package/src/default-standard-pbr-skin.wgsl +538 -0
- package/src/default-standard-pbr.wgsl +794 -0
- package/src/errors.ts +169 -0
- package/src/fog.wgsl +27 -0
- package/src/fxaa.wgsl +259 -0
- package/src/hdrp-cluster-forward.wgsl +186 -0
- package/src/hdrp-deferred-lighting.wgsl +69 -0
- package/src/hdrp-ssao.wgsl +174 -0
- package/src/ibl-brdf-lut.wgsl +92 -0
- package/src/ibl-equirect-to-cube.wgsl +56 -0
- package/src/ibl-irradiance.wgsl +89 -0
- package/src/ibl-prefilter.wgsl +101 -0
- package/src/ibl-sampling.wgsl +58 -0
- package/src/ibl-shared.wgsl +118 -0
- package/src/index.ts +166 -0
- package/src/lighting-directional.wgsl +471 -0
- package/src/lighting-punctual.wgsl +274 -0
- package/src/material/artifact-registry.ts +78 -0
- package/src/material/artifact-types.ts +26 -0
- package/src/material-schemas.ts +48 -0
- package/src/msdf-text.wgsl +208 -0
- package/src/pbr-temporal.wgsl +73 -0
- package/src/register-default-sprite-lit.ts +87 -0
- package/src/register-default-standard-pbr-skin.ts +74 -0
- package/src/shadow-pcf.wgsl +120 -0
- package/src/shadow_caster.wgsl +54 -0
- package/src/skybox.wgsl +75 -0
- package/src/sprite-lit.wgsl +307 -0
- package/src/sprite.wgsl +365 -0
- package/src/taa-resolve.wgsl +129 -0
- package/src/tbn.wgsl +54 -0
- package/src/tonemap.ts +13 -0
- package/src/tonemap.wgsl +177 -0
- package/src/types.ts +59 -0
- 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
|
+
}
|