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