@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,122 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
type Vec3 = readonly [number, number, number];
|
|
4
|
+
type PerezFits = readonly [
|
|
5
|
+
readonly [number, number],
|
|
6
|
+
readonly [number, number],
|
|
7
|
+
readonly [number, number],
|
|
8
|
+
readonly [number, number],
|
|
9
|
+
readonly [number, number],
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const PAPER_FITS: Readonly<Record<'Y' | 'x' | 'y', PerezFits>> = {
|
|
13
|
+
Y: [
|
|
14
|
+
[0.1787, -1.463],
|
|
15
|
+
[-0.3554, 0.4275],
|
|
16
|
+
[-0.0227, 5.3251],
|
|
17
|
+
[0.1206, -2.5771],
|
|
18
|
+
[-0.067, 0.3703],
|
|
19
|
+
],
|
|
20
|
+
x: [
|
|
21
|
+
[-0.0193, -0.2592],
|
|
22
|
+
[-0.0665, 0.0008],
|
|
23
|
+
[-0.0004, 0.2125],
|
|
24
|
+
[-0.0641, -0.8989],
|
|
25
|
+
[-0.0033, 0.0452],
|
|
26
|
+
],
|
|
27
|
+
y: [
|
|
28
|
+
[-0.0167, -0.2608],
|
|
29
|
+
[-0.095, 0.0092],
|
|
30
|
+
[-0.0079, 0.2102],
|
|
31
|
+
[-0.0441, -1.6537],
|
|
32
|
+
[-0.0109, 0.0529],
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function normalize(value: Vec3): Vec3 {
|
|
37
|
+
const length = Math.hypot(...value);
|
|
38
|
+
return [value[0] / length, value[1] / length, value[2] / length];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function zenithYxy(sunTheta: number, turbidity: number): Vec3 {
|
|
42
|
+
const theta2 = sunTheta * sunTheta;
|
|
43
|
+
const theta3 = theta2 * sunTheta;
|
|
44
|
+
const turbidity2 = turbidity * turbidity;
|
|
45
|
+
const chi = (4 / 9 - turbidity / 120) * (Math.PI - 2 * sunTheta);
|
|
46
|
+
return [
|
|
47
|
+
(4.0453 * turbidity - 4.971) * Math.tan(chi) - 0.2155 * turbidity + 2.4192,
|
|
48
|
+
(0.00165 * theta3 - 0.00374 * theta2 + 0.00208 * sunTheta) * turbidity2 +
|
|
49
|
+
(-0.02902 * theta3 + 0.06377 * theta2 - 0.03202 * sunTheta + 0.00394) * turbidity +
|
|
50
|
+
(0.11693 * theta3 - 0.21196 * theta2 + 0.06052 * sunTheta + 0.25885),
|
|
51
|
+
(0.00275 * theta3 - 0.0061 * theta2 + 0.00316 * sunTheta) * turbidity2 +
|
|
52
|
+
(-0.04214 * theta3 + 0.0897 * theta2 - 0.04153 * sunTheta + 0.00515) * turbidity +
|
|
53
|
+
(0.15346 * theta3 - 0.26756 * theta2 + 0.06669 * sunTheta + 0.26688),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function perez(theta: number, gamma: number, fits: PerezFits, turbidity: number): number {
|
|
58
|
+
const [a, b, c, d, e] = fits.map(([slope, intercept]) => slope * turbidity + intercept);
|
|
59
|
+
const safeCos = Math.max(Math.cos(theta), 0.01);
|
|
60
|
+
return Math.max(
|
|
61
|
+
(1 + (a ?? 0) * Math.exp((b ?? 0) / safeCos)) *
|
|
62
|
+
(1 + (c ?? 0) * Math.exp((d ?? 0) * gamma) + (e ?? 0) * Math.cos(gamma) ** 2),
|
|
63
|
+
1e-5,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function evaluatePaperYxy(viewInput: Vec3, sunInput: Vec3, turbidity: number): Vec3 {
|
|
68
|
+
const view = normalize([viewInput[0], Math.max(viewInput[1], 0), viewInput[2]]);
|
|
69
|
+
const sun = normalize(sunInput);
|
|
70
|
+
const theta = Math.acos(Math.max(0, Math.min(1, view[1])));
|
|
71
|
+
const sunTheta = Math.acos(Math.max(0, Math.min(1, sun[1])));
|
|
72
|
+
const gamma = Math.acos(
|
|
73
|
+
Math.max(-1, Math.min(1, view[0] * sun[0] + view[1] * sun[1] + view[2] * sun[2])),
|
|
74
|
+
);
|
|
75
|
+
const zenith = zenithYxy(sunTheta, turbidity);
|
|
76
|
+
return (['Y', 'x', 'y'] as const).map((channel, index) => {
|
|
77
|
+
const relative =
|
|
78
|
+
perez(theta, gamma, PAPER_FITS[channel], turbidity) /
|
|
79
|
+
perez(0, sunTheta, PAPER_FITS[channel], turbidity);
|
|
80
|
+
return (zenith[index] ?? 0) * relative;
|
|
81
|
+
}) as unknown as Vec3;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function yxyToLinearSrgb([Y, x, y]: Vec3): Vec3 {
|
|
85
|
+
const safeY = Math.max(y, 1e-5);
|
|
86
|
+
const X = (Y * x) / safeY;
|
|
87
|
+
const Z = (Y * Math.max(1 - x - y, 0)) / safeY;
|
|
88
|
+
return [
|
|
89
|
+
3.2406 * X - 1.5372 * Y - 0.4986 * Z,
|
|
90
|
+
-0.9689 * X + 1.8758 * Y + 0.0415 * Z,
|
|
91
|
+
0.0557 * X - 0.204 * Y + 1.057 * Z,
|
|
92
|
+
].map((channel) => Math.min(Math.max(channel, 0), 65504)) as unknown as Vec3;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('Preetham 1999 independent numeric oracle', () => {
|
|
96
|
+
it.each([
|
|
97
|
+
['morning zenith', [0, 1, 0], [-0.45, 0.22, -0.865], 3.2, [3.225319, 0.260094, 0.274286]],
|
|
98
|
+
[
|
|
99
|
+
'morning horizon',
|
|
100
|
+
[0.1, 0, -0.995],
|
|
101
|
+
[-0.45, 0.22, -0.865],
|
|
102
|
+
3.2,
|
|
103
|
+
[11.048136, 0.378332, 0.377748],
|
|
104
|
+
],
|
|
105
|
+
['noon zenith', [0, 1, 0], [0.1, 0.97, -0.22], 2.1, [9.491382, 0.263007, 0.270884]],
|
|
106
|
+
[
|
|
107
|
+
'evening circumsolar',
|
|
108
|
+
[0.5, 0.15, -0.85],
|
|
109
|
+
[0.5, 0.15, -0.85],
|
|
110
|
+
4.2,
|
|
111
|
+
[19.689314, 0.412721, 0.425093],
|
|
112
|
+
],
|
|
113
|
+
] as const)('%s matches the pinned Y/x/y reference', (_name, view, sun, turbidity, expected) => {
|
|
114
|
+
const actual = evaluatePaperYxy(view, sun, turbidity);
|
|
115
|
+
expect(actual[0]).toBeCloseTo(expected[0], 5);
|
|
116
|
+
expect(actual[1]).toBeCloseTo(expected[1], 5);
|
|
117
|
+
expect(actual[2]).toBeCloseTo(expected[2], 5);
|
|
118
|
+
expect(
|
|
119
|
+
yxyToLinearSrgb(actual).every((channel) => Number.isFinite(channel) && channel <= 65504),
|
|
120
|
+
).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
interface NodeFs {
|
|
4
|
+
existsSync(path: string): boolean;
|
|
5
|
+
readFileSync(path: string, encoding: string): string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface NodePath {
|
|
9
|
+
dirname(path: string): string;
|
|
10
|
+
resolve(...parts: string[]): string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface NodeUrl {
|
|
14
|
+
fileURLToPath(url: string): string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let source = '';
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
const fs = (await import(/* @vite-ignore */ 'node:fs')) as unknown as NodeFs;
|
|
21
|
+
const path = (await import(/* @vite-ignore */ 'node:path')) as unknown as NodePath;
|
|
22
|
+
const url = (await import(/* @vite-ignore */ 'node:url')) as unknown as NodeUrl;
|
|
23
|
+
const shaderPath = path.resolve(
|
|
24
|
+
path.dirname(url.fileURLToPath(import.meta.url)),
|
|
25
|
+
'..',
|
|
26
|
+
'atmosphere-preetham.wgsl',
|
|
27
|
+
);
|
|
28
|
+
expect(fs.existsSync(shaderPath)).toBe(true);
|
|
29
|
+
source = fs.readFileSync(shaderPath, 'utf8');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('Preetham artifact contract (red gate)', () => {
|
|
33
|
+
it('pins one algorithm revision and independent Y/x/y coefficient tables', () => {
|
|
34
|
+
expect(source).toContain('PREETHAM_ALGORITHM_REVISION');
|
|
35
|
+
expect(source).toContain('PREETHAM_PEREZ_Y_COEFFICIENTS');
|
|
36
|
+
expect(source).toContain('PREETHAM_PEREZ_X_COEFFICIENTS');
|
|
37
|
+
expect(source).toContain('PREETHAM_PEREZ_SMALL_Y_COEFFICIENTS');
|
|
38
|
+
expect(source).toContain('1999');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('owns Yxy to linear-sRGB conversion and below-horizon clamp', () => {
|
|
42
|
+
expect(source).toContain('preetham_yxy_to_linear_srgb');
|
|
43
|
+
expect(source).toContain('preetham_sky_radiance');
|
|
44
|
+
expect(source).toMatch(/max\([^\n]*\.y[^\n]*0\.0/);
|
|
45
|
+
expect(source).toContain('finite');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('does not create a second evaluator or inject a disc into the cube', () => {
|
|
49
|
+
expect(source.match(/fn\s+preetham_sky_radiance\b/g) ?? []).toHaveLength(1);
|
|
50
|
+
expect(source).not.toContain('sun_disc');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
interface NodeFs {
|
|
4
|
+
readFileSync(path: string, encoding: string): string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface NodePath {
|
|
8
|
+
dirname(path: string): string;
|
|
9
|
+
resolve(...parts: string[]): string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface NodeUrl {
|
|
13
|
+
fileURLToPath(url: string): string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CompilerModule {
|
|
17
|
+
compileShader(
|
|
18
|
+
source: string,
|
|
19
|
+
options: {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly imports?: Readonly<Record<string, string>>;
|
|
22
|
+
},
|
|
23
|
+
): Promise<{ readonly ok: boolean; readonly error?: { readonly code: string } }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let source = '';
|
|
27
|
+
let producerSource = '';
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
const fs = (await import(/* @vite-ignore */ 'node:fs')) as unknown as NodeFs;
|
|
31
|
+
const path = (await import(/* @vite-ignore */ 'node:path')) as unknown as NodePath;
|
|
32
|
+
const url = (await import(/* @vite-ignore */ 'node:url')) as unknown as NodeUrl;
|
|
33
|
+
source = fs.readFileSync(
|
|
34
|
+
path.resolve(
|
|
35
|
+
path.dirname(url.fileURLToPath(import.meta.url)),
|
|
36
|
+
'..',
|
|
37
|
+
'atmosphere-preetham.wgsl',
|
|
38
|
+
),
|
|
39
|
+
'utf8',
|
|
40
|
+
);
|
|
41
|
+
producerSource = fs.readFileSync(
|
|
42
|
+
path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', 'atmosphere-cubemap.wgsl'),
|
|
43
|
+
'utf8',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function finite(value: number): boolean {
|
|
48
|
+
return Number.isFinite(value) && value >= 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function yxyToLinearSrgb(Y: number, x: number, y: number): [number, number, number] {
|
|
52
|
+
const safeY = Math.max(y, 1e-5);
|
|
53
|
+
const X = (Y * x) / safeY;
|
|
54
|
+
const Z = (Y * Math.max(1 - x - y, 0)) / safeY;
|
|
55
|
+
return [
|
|
56
|
+
Math.max(3.2406 * X - 1.5372 * Y - 0.4986 * Z, 0),
|
|
57
|
+
Math.max(-0.9689 * X + 1.8758 * Y + 0.0415 * Z, 0),
|
|
58
|
+
Math.max(0.0557 * X - 0.204 * Y + 1.057 * Z, 0),
|
|
59
|
+
].map((channel) => Math.min(channel, 65504)) as [number, number, number];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('Preetham analytic sky artifact', () => {
|
|
63
|
+
it('is one pinned build-time evaluator with a separate background disc', () => {
|
|
64
|
+
expect(source.match(/fn\s+preetham_sky_radiance\b/g) ?? []).toHaveLength(1);
|
|
65
|
+
expect(source).toContain('PREETHAM_ALGORITHM_REVISION');
|
|
66
|
+
expect(source).toContain('PREETHAM_PEREZ_Y_COEFFICIENTS');
|
|
67
|
+
expect(source).toContain('PREETHAM_PEREZ_X_COEFFICIENTS');
|
|
68
|
+
expect(source).toContain('PREETHAM_PEREZ_SMALL_Y_COEFFICIENTS');
|
|
69
|
+
expect(source).not.toContain('const PREETHAM_PEREZ_COEFFICIENTS');
|
|
70
|
+
expect(source).toContain('preetham_zenith_yxy');
|
|
71
|
+
expect(source).toContain('preetham_relative_perez');
|
|
72
|
+
expect(source).toContain('preetham_yxy_to_linear_srgb');
|
|
73
|
+
expect(source).toContain('preetham_finite_guard');
|
|
74
|
+
expect(source).toContain('let daylightWeight = smoothstep(-0.12, 0.02, sun.y)');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps the Y/x/y component order and luminance in the XYZ conversion', () => {
|
|
78
|
+
expect(source).toMatch(/let\s+Y\s*=\s*max\(yxy\.x/);
|
|
79
|
+
expect(source).toMatch(/let\s+x\s*=\s*clamp\(yxy\.y/);
|
|
80
|
+
expect(source).toMatch(/let\s+y\s*=\s*clamp\(yxy\.z/);
|
|
81
|
+
expect(yxyToLinearSrgb(1, 1 / 3, 1 / 3)).toEqual(
|
|
82
|
+
expect.arrayContaining([
|
|
83
|
+
expect.closeTo(1.2048, 4),
|
|
84
|
+
expect.closeTo(0.9484, 4),
|
|
85
|
+
expect.closeTo(0.9087, 4),
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
const low = yxyToLinearSrgb(0.5, 0.28, 0.3);
|
|
89
|
+
const high = yxyToLinearSrgb(1, 0.28, 0.3);
|
|
90
|
+
expect(high[0] / low[0]).toBeCloseTo(2, 5);
|
|
91
|
+
expect(high[1] / low[1]).toBeCloseTo(2, 5);
|
|
92
|
+
expect(high[2] / low[2]).toBeCloseTo(2, 5);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('passes the build-time Naga validation path', async () => {
|
|
96
|
+
const compiler = (await import(
|
|
97
|
+
/* @vite-ignore */ new URL('../../../shader-compiler/dist/index.mjs', import.meta.url).href
|
|
98
|
+
)) as unknown as CompilerModule;
|
|
99
|
+
const result = await compiler.compileShader(source, {
|
|
100
|
+
id: 'forgeax_environment::preetham',
|
|
101
|
+
});
|
|
102
|
+
expect(result.ok).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('validates the sole cubemap producer caller through composition', async () => {
|
|
106
|
+
const compiler = (await import(
|
|
107
|
+
/* @vite-ignore */ new URL('../../../shader-compiler/dist/index.mjs', import.meta.url).href
|
|
108
|
+
)) as unknown as CompilerModule;
|
|
109
|
+
const result = await compiler.compileShader(producerSource, {
|
|
110
|
+
id: 'forgeax_environment::cubemap',
|
|
111
|
+
imports: { 'forgeax_environment::preetham': source },
|
|
112
|
+
});
|
|
113
|
+
expect(result.ok).toBe(true);
|
|
114
|
+
expect(producerSource.match(/preetham_sky_radiance\s*\(/g) ?? []).toHaveLength(1);
|
|
115
|
+
expect(producerSource).not.toMatch(/-input\.direction\.y/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it.each([
|
|
119
|
+
['horizon', 4.2, 0.3, 0.32],
|
|
120
|
+
['zenith', 2.1, 0.25, 0.27],
|
|
121
|
+
['extreme-low-lux', 0, 0.25, 0.27],
|
|
122
|
+
])('%s Yxy probe remains finite', (_name, Y, x, y) => {
|
|
123
|
+
expect(yxyToLinearSrgb(Y, x, y).every(finite)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('scales sky radiance with illuminance without changing direct-light ownership', () => {
|
|
127
|
+
const low = yxyToLinearSrgb(2, 0.28, 0.3);
|
|
128
|
+
const high = yxyToLinearSrgb(4, 0.28, 0.3);
|
|
129
|
+
expect(high[1] / low[1]).toBeCloseTo(2, 5);
|
|
130
|
+
expect(source).not.toContain('sun_disc');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
type Vec3 = readonly [number, number, number];
|
|
4
|
+
|
|
5
|
+
const THREE_R184_SOURCE = 'three.js@r184/examples/jsm/objects/Sky.js';
|
|
6
|
+
const RAYLEIGH = 1.1;
|
|
7
|
+
const MIE_COEFFICIENT = 0.006;
|
|
8
|
+
const MIE_DIRECTIONAL_G = 0.78;
|
|
9
|
+
const TOTAL_RAYLEIGH: Vec3 = [5.804542996261093e-6, 1.3562911419845635e-5, 3.0265902468824876e-5];
|
|
10
|
+
const MIE_CONST: Vec3 = [1.8399918514433978e14, 2.7798023919660528e14, 4.0790479543861094e14];
|
|
11
|
+
|
|
12
|
+
const PROFILES = {
|
|
13
|
+
morning: { sun: [-0.45, 0.22, -0.865] as Vec3, turbidity: 3.2 },
|
|
14
|
+
noon: { sun: [0.1, 0.97, -0.22] as Vec3, turbidity: 2.1 },
|
|
15
|
+
evening: { sun: [0.5, 0.15, -0.85] as Vec3, turbidity: 4.2 },
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
function normalize(value: Vec3): Vec3 {
|
|
19
|
+
const length = Math.hypot(...value);
|
|
20
|
+
return [value[0] / length, value[1] / length, value[2] / length];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function dot(left: Vec3, right: Vec3): number {
|
|
24
|
+
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function threeR184Sky(
|
|
28
|
+
directionInput: Vec3,
|
|
29
|
+
sunInput: Vec3,
|
|
30
|
+
turbidity: number,
|
|
31
|
+
showSunDisc: boolean,
|
|
32
|
+
): Vec3 {
|
|
33
|
+
const direction = normalize(directionInput);
|
|
34
|
+
const sun = normalize(sunInput);
|
|
35
|
+
const sunZenithCos = Math.max(-1, Math.min(1, sun[1]));
|
|
36
|
+
const sunEnergy =
|
|
37
|
+
1000 * Math.max(0, 1 - Math.exp(-((1.6110731556870734 - Math.acos(sunZenithCos)) / 1.5)));
|
|
38
|
+
const sunFade = 1 - Math.max(0, Math.min(1, 1 - Math.exp(sun[1] / 450_000)));
|
|
39
|
+
const rayleighCoefficient = RAYLEIGH - (1 - sunFade);
|
|
40
|
+
const betaR = TOTAL_RAYLEIGH.map((value) => value * rayleighCoefficient) as unknown as Vec3;
|
|
41
|
+
// biome-ignore lint/suspicious/noApproximativeNumericConstant: Three r184 pins 0.434 exactly.
|
|
42
|
+
const mieScale = 0.434 * (0.2 * turbidity * 1e-17) * MIE_COEFFICIENT;
|
|
43
|
+
const betaM = MIE_CONST.map((value) => value * mieScale) as unknown as Vec3;
|
|
44
|
+
const zenithAngle = Math.acos(Math.max(0, direction[1]));
|
|
45
|
+
const inverse =
|
|
46
|
+
1 / (Math.cos(zenithAngle) + 0.15 * (93.885 - (zenithAngle * 180) / Math.PI) ** -1.253);
|
|
47
|
+
const sR = 8_400 * inverse;
|
|
48
|
+
const sM = 1_250 * inverse;
|
|
49
|
+
const cosTheta = dot(direction, sun);
|
|
50
|
+
const rayleighPhase = 0.05968310365946075 * (1 + (cosTheta * 0.5 + 0.5) ** 2);
|
|
51
|
+
const g2 = MIE_DIRECTIONAL_G ** 2;
|
|
52
|
+
const miePhase =
|
|
53
|
+
0.07957747154594767 * ((1 - g2) / (1 - 2 * MIE_DIRECTIONAL_G * cosTheta + g2) ** 1.5);
|
|
54
|
+
const lowSunMix = Math.max(0, Math.min(1, (1 - sun[1]) ** 5));
|
|
55
|
+
const colorOffset: Vec3 = [0, 0.0003, 0.00075];
|
|
56
|
+
|
|
57
|
+
return ([0, 1, 2] as const).map((channel) => {
|
|
58
|
+
const extinction = Math.exp(-(betaR[channel] * sR + betaM[channel] * sM));
|
|
59
|
+
const scatter =
|
|
60
|
+
sunEnergy *
|
|
61
|
+
((betaR[channel] * rayleighPhase + betaM[channel] * miePhase) /
|
|
62
|
+
(betaR[channel] + betaM[channel]));
|
|
63
|
+
let lin = Math.max(scatter * (1 - extinction), 0) ** 1.5;
|
|
64
|
+
lin *= 1 - lowSunMix + lowSunMix * Math.max(scatter * extinction, 0) ** 0.5;
|
|
65
|
+
const disc =
|
|
66
|
+
showSunDisc && cosTheta >= 0.9999566769464484 ? sunEnergy * 19_000 * extinction : 0;
|
|
67
|
+
return (lin + 0.1 * extinction + disc) * 0.04 + colorOffset[channel];
|
|
68
|
+
}) as unknown as Vec3;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('Three.js r184 same-parameter model reference', () => {
|
|
72
|
+
it('pins natural daylight probes independently from the ForgeaX evaluator', () => {
|
|
73
|
+
const probes = Object.entries(PROFILES).map(([time, profile]) => {
|
|
74
|
+
const horizon = normalize([profile.sun[0], 0.08, profile.sun[2]]);
|
|
75
|
+
return {
|
|
76
|
+
time,
|
|
77
|
+
zenith: threeR184Sky([0, 1, 0], profile.sun, profile.turbidity, false),
|
|
78
|
+
horizon: threeR184Sky(horizon, profile.sun, profile.turbidity, false),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
const expected = [
|
|
82
|
+
{
|
|
83
|
+
time: 'morning',
|
|
84
|
+
zenith: [0.030492, 0.102424, 0.308226],
|
|
85
|
+
horizon: [45.588088, 44.78799, 25.700271],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
time: 'noon',
|
|
89
|
+
zenith: [1.520243, 3.631492, 7.981302],
|
|
90
|
+
horizon: [0.917458, 2.878871, 7.094558],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
time: 'evening',
|
|
94
|
+
zenith: [0.019634, 0.063679, 0.19124],
|
|
95
|
+
horizon: [81.28711, 73.286304, 34.37594],
|
|
96
|
+
},
|
|
97
|
+
] as const;
|
|
98
|
+
expect(THREE_R184_SOURCE).toBe('three.js@r184/examples/jsm/objects/Sky.js');
|
|
99
|
+
for (const [index, probe] of probes.entries()) {
|
|
100
|
+
const fixture = expected[index];
|
|
101
|
+
expect(probe.time).toBe(fixture?.time);
|
|
102
|
+
for (const channel of [0, 1, 2] as const) {
|
|
103
|
+
expect(probe.zenith[channel]).toBeCloseTo(fixture?.zenith[channel] ?? Number.NaN, 5);
|
|
104
|
+
expect(probe.horizon[channel]).toBeCloseTo(fixture?.horizon[channel] ?? Number.NaN, 5);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
expect(probes[0]?.horizon[0]).toBeGreaterThan(
|
|
108
|
+
probes[0]?.horizon[2] ?? Number.POSITIVE_INFINITY,
|
|
109
|
+
);
|
|
110
|
+
expect(probes[1]?.zenith[2]).toBeGreaterThan(probes[1]?.zenith[0] ?? Number.POSITIVE_INFINITY);
|
|
111
|
+
expect(probes[2]?.horizon[0]).toBeGreaterThan(
|
|
112
|
+
probes[2]?.horizon[2] ?? Number.POSITIVE_INFINITY,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('keeps the display Sun separable from the environment reference', () => {
|
|
117
|
+
const profile = PROFILES.morning;
|
|
118
|
+
const hidden = threeR184Sky(profile.sun, profile.sun, profile.turbidity, false);
|
|
119
|
+
const visible = threeR184Sky(profile.sun, profile.sun, profile.turbidity, true);
|
|
120
|
+
expect(visible[0]).toBeGreaterThan(hidden[0] * 100);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const shaderRoot = resolve(import.meta.dirname, '..');
|
|
6
|
+
const tonemap = readFileSync(resolve(shaderRoot, 'tonemap.wgsl'), 'utf8');
|
|
7
|
+
const bloom = readFileSync(resolve(shaderRoot, 'bloom-blur.wgsl'), 'utf8');
|
|
8
|
+
const fxaa = readFileSync(resolve(shaderRoot, 'fxaa.wgsl'), 'utf8');
|
|
9
|
+
|
|
10
|
+
describe('post shader color-domain contract', () => {
|
|
11
|
+
it('marks tone as the only HDR-to-LDR transition', () => {
|
|
12
|
+
expect(tonemap).toContain('linearHdrColorDomain');
|
|
13
|
+
expect(tonemap).toContain('linearLdrColorDomain');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('keeps bloom and FXAA in linear domains', () => {
|
|
17
|
+
expect(bloom).toContain('linearHdrColorDomain');
|
|
18
|
+
expect(fxaa).toContain('linearLdrColorDomain');
|
|
19
|
+
expect(fxaa).toContain('linearToSrgbOetf');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('does not blend into an encoded destination', () => {
|
|
23
|
+
expect(`${tonemap}\n${bloom}\n${fxaa}`).not.toContain('encodedDestinationBlend');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('preserves source alpha through the output pass', () => {
|
|
27
|
+
expect(tonemap).toContain('let source = textureSample(hdr, samp, in.uv);');
|
|
28
|
+
expect(tonemap).toContain('return vec4<f32>(mapped, source.a);');
|
|
29
|
+
expect(tonemap).not.toContain('return vec4<f32>(mapped, 1.0);');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const shaderFiles = [
|
|
6
|
+
'default-standard-pbr.wgsl',
|
|
7
|
+
'default-standard-pbr-skin.wgsl',
|
|
8
|
+
'unlit.wgsl',
|
|
9
|
+
'sprite.wgsl',
|
|
10
|
+
'sprite-lit.wgsl',
|
|
11
|
+
'msdf-text.wgsl',
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
function source(file: string): string {
|
|
15
|
+
return readFileSync(fileURLToPath(new URL(`../${file}`, import.meta.url)), 'utf8');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('built-in texture coordinate records', () => {
|
|
19
|
+
it('does not declare hand-written texture coordinate members', () => {
|
|
20
|
+
for (const file of shaderFiles) {
|
|
21
|
+
const text = source(file);
|
|
22
|
+
expect(text, `${file} has fixed UV scale members`).not.toMatch(/UvScale\b/);
|
|
23
|
+
expect(text, `${file} has fixed coordinate offsets`).not.toMatch(
|
|
24
|
+
/TEXTURE_COORDINATE_OFFSET|textureScaleOffset/,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('routes every built-in material texture sample through the shared scale-aware helper', () => {
|
|
30
|
+
for (const file of shaderFiles) {
|
|
31
|
+
const text = source(file);
|
|
32
|
+
expect(text, `${file} imports the material sampling helper`).toContain(
|
|
33
|
+
'sampleMaterialTexture',
|
|
34
|
+
);
|
|
35
|
+
expect(text, `${file} has no direct material textureSample bypass`).not.toMatch(
|
|
36
|
+
/textureSample\((?:baseColor|metallicRoughness|normal|emissive|occlusion)Texture/,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('documents the falsification boundary: replacing a non-unit scale with identity reaches padding', () => {
|
|
42
|
+
const scale: readonly [number, number] = [2085 / 2088, 1573 / 1576];
|
|
43
|
+
const [logicalX, logicalY] = scale;
|
|
44
|
+
const sabotagedEdge: readonly [number, number] = [1, 1];
|
|
45
|
+
expect(logicalX).toBeLessThan(sabotagedEdge[0]);
|
|
46
|
+
expect(logicalY).toBeLessThan(sabotagedEdge[1]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('keeps custom coordinate metadata distinct from texture resources', () => {
|
|
50
|
+
for (const file of shaderFiles) {
|
|
51
|
+
const text = source(file);
|
|
52
|
+
expect(text, `${file} must name coordinate metadata`).toMatch(/CoordinatesMetadata/);
|
|
53
|
+
expect(text, `${file} must preserve texture resources`).toMatch(/texture_2d|texture_cube/);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const shader = readFileSync(
|
|
6
|
+
fileURLToPath(new URL('../default-standard-pbr.wgsl', import.meta.url)),
|
|
7
|
+
'utf8',
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
describe('standard PBR MASK alpha contract', () => {
|
|
11
|
+
it('multiplies factor alpha by sampled texture alpha before discard', () => {
|
|
12
|
+
expect(shader).toContain('alphaTest(material.baseColor.a * baseSample.a * vertexColor.a)');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('matches Three r184 alphaTest by discarding values equal to or below cutoff', () => {
|
|
16
|
+
expect(shader).toContain('alpha <= material.alphaCutoff');
|
|
17
|
+
expect(shader).not.toContain('alpha < material.alphaCutoff');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @forgeax/engine-shader/__tests__/deferred-lighting-ssao.test.ts -
|
|
2
|
+
// Structural unit test for deferred lighting ambient SSAO synthesis (M2 / w11).
|
|
3
|
+
// feat-20260612-hdrp-ssao.
|
|
4
|
+
//
|
|
5
|
+
// plan-strategy D-7: ambient *= mix(1.0, ssaoFactor * bakedAO, intensity).
|
|
6
|
+
//
|
|
7
|
+
// This test reads the deferred lighting entry point in default-standard-pbr.wgsl
|
|
8
|
+
// and asserts:
|
|
9
|
+
// (a) ambient calculation contains mix(1.0, ...) with SSAO factor contribution
|
|
10
|
+
// (b) bakedAO is consumed in the ambient term (material.occlusionStrength)
|
|
11
|
+
// (c) SSAO texture read is optional (not required when SSAO absent)
|
|
12
|
+
// (d) Ambient formula = (kD * irradiance * albedo + specularIbl) * skylight.intensity * ao
|
|
13
|
+
// multiplied by SSAO blend when active
|
|
14
|
+
//
|
|
15
|
+
// RED before w12 (SSAO blend line not yet added to default-standard-pbr.wgsl).
|
|
16
|
+
// GREEN after w12 inserts the synthesis line.
|
|
17
|
+
|
|
18
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
19
|
+
|
|
20
|
+
interface NodeFs {
|
|
21
|
+
readFileSync: (p: string, enc: string) => string;
|
|
22
|
+
}
|
|
23
|
+
interface NodePath {
|
|
24
|
+
resolve: (...parts: string[]) => string;
|
|
25
|
+
dirname: (p: string) => string;
|
|
26
|
+
}
|
|
27
|
+
interface NodeUrl {
|
|
28
|
+
fileURLToPath: (u: string) => string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let pbrSource!: string;
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
const fsId = 'node:fs';
|
|
35
|
+
const pathId = 'node:path';
|
|
36
|
+
const urlId = 'node:url';
|
|
37
|
+
const fs = (await import(/* @vite-ignore */ fsId)) as NodeFs;
|
|
38
|
+
const path = (await import(/* @vite-ignore */ pathId)) as NodePath;
|
|
39
|
+
const url = (await import(/* @vite-ignore */ urlId)) as NodeUrl;
|
|
40
|
+
const here = url.fileURLToPath(import.meta.url);
|
|
41
|
+
const srcDir = path.resolve(path.dirname(here), '..');
|
|
42
|
+
pbrSource = fs.readFileSync(path.resolve(srcDir, 'default-standard-pbr.wgsl'), 'utf8');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function stripComments(src: string): string {
|
|
46
|
+
return src
|
|
47
|
+
.split(/\r?\n/)
|
|
48
|
+
.map((line: string) => line.replace(/\/\/.*$/, ''))
|
|
49
|
+
.join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('deferred lighting ambient SSAO synthesis (M2 / w11)', () => {
|
|
53
|
+
it('(a) ambient calculation uses occlusionStrength * bakedAO texture sample', () => {
|
|
54
|
+
// The ambient expression starts from kD * irradiance before clearcoat and
|
|
55
|
+
// skylight terms are applied; ao comes from material.occlusionStrength.
|
|
56
|
+
// M7/w32 changed `let` -> `var` so the SSAO blend line `ambient *= mix(...)` can mutate it.
|
|
57
|
+
const codeOnly = stripComments(pbrSource);
|
|
58
|
+
expect(codeOnly).toMatch(/var\s+ambient\s*=\s*\(*\s*kD\s*\*\s*irradiance/);
|
|
59
|
+
expect(codeOnly).toMatch(/material\.occlusionStrength/);
|
|
60
|
+
expect(codeOnly).toMatch(/skylight\.intensity/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('(b) SSAO blend formula ambient *= mix(1.0, ...) is present', () => {
|
|
64
|
+
// After w12, the source must contain the synthesis line:
|
|
65
|
+
// ambient *= mix(1.0, ssaoFactor * bakedAO, intensity);
|
|
66
|
+
// or equivalent form.
|
|
67
|
+
const codeOnly = stripComments(pbrSource);
|
|
68
|
+
// Check for the mix(1.0, ...) pattern near or modifying the ambient variable.
|
|
69
|
+
expect(codeOnly).toMatch(/ambient\s*\*=\s*mix\(\s*1\.0\s*,\s*\w+/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('(c) SSAO factor is multiplied by ao (baked occlusion from texture)', () => {
|
|
73
|
+
// Verify the relationship: ssaoFactor * ao appears in the ambient modulation.
|
|
74
|
+
const codeOnly = stripComments(pbrSource);
|
|
75
|
+
// The formula is: ambient *= mix(1.0, ssaoFactor * ao, ssaoIntensity)
|
|
76
|
+
expect(codeOnly).toMatch(/ssaoFactor\s*\*\s*ao/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('(d) no crash path when SSAO texture absent (optional bind group)', () => {
|
|
80
|
+
// Verify the SSAO texture declaration is optional or conditional.
|
|
81
|
+
// When SSAO is not wired, the shader falls back to bakedAO-only.
|
|
82
|
+
// In the source: the ambient *= line should be conditional or use a
|
|
83
|
+
// default value when ssaoBlurred is not bound.
|
|
84
|
+
//
|
|
85
|
+
// For M2: the ambient line lives under CLUSTER_FORWARD_AVAILABLE ifdef
|
|
86
|
+
// or is guarded by a conditional. We verify that the SSAO-related
|
|
87
|
+
// variables (ssaoFactor, ssaoIntensity, ssaoBlurred) are used in
|
|
88
|
+
// a way that doesn't crash without binding.
|
|
89
|
+
const codeOnly = stripComments(pbrSource);
|
|
90
|
+
// The SSAO blend must appear near the ambient calculation in fs_main.
|
|
91
|
+
// Verify the fs_main function contains both the ambient calculation
|
|
92
|
+
// and the SSAO blend.
|
|
93
|
+
const ambientArea = codeOnly.slice(
|
|
94
|
+
codeOnly.indexOf('var ambient ='),
|
|
95
|
+
codeOnly.indexOf('var color = ambient'),
|
|
96
|
+
);
|
|
97
|
+
if (ambientArea.length > 0) {
|
|
98
|
+
// In the area between ambient calculation and color assignment,
|
|
99
|
+
// the SSAO blend should be present.
|
|
100
|
+
expect(ambientArea).toMatch(/ambient\s*\*=/);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('(e) verify the exact formula: ambient *= mix(1.0, ssaoFactor * bakedAO, intensity)', () => {
|
|
105
|
+
// Numerical verification: when ssaoFactor=0.5, bakedAO=0.8, intensity=1.0:
|
|
106
|
+
// mix(1.0, 0.5*0.8, 1.0) = mix(1.0, 0.4, 1.0) = 0.4
|
|
107
|
+
// ambient *= 0.4 means the ambient term is reduced by 60%.
|
|
108
|
+
//
|
|
109
|
+
// We assert the source has the semantic shape of this formula.
|
|
110
|
+
const codeOnly = stripComments(pbrSource);
|
|
111
|
+
// Look for the exact pattern: ambient *= mix(1.0, <var> * <var>, <var>)
|
|
112
|
+
expect(codeOnly).toMatch(/ambient\s*\*=\s*mix\(\s*1\.0\s*,\s*\w+\s*\*\s*\w+\s*,\s*\w+\s*\)/);
|
|
113
|
+
});
|
|
114
|
+
});
|