@forgeax/engine-shader 0.1.20 → 0.1.23
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/README.md +10 -0
- package/dist/ShaderRegistry.d.ts.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +33070 -8
- package/dist/index.mjs.map +1 -1
- package/dist/ltc/provenance.d.ts +17 -0
- package/dist/ltc/provenance.d.ts.map +1 -0
- package/dist/ltc/tables.d.ts +22 -0
- package/dist/ltc/tables.d.ts.map +1 -0
- package/dist/material/artifact-types.d.ts +52 -0
- package/dist/material/artifact-types.d.ts.map +1 -1
- package/dist/material-schemas.d.ts +5 -0
- package/dist/material-schemas.d.ts.map +1 -1
- package/dist/register-default-sprite-lit.d.ts +3 -6
- package/dist/register-default-sprite-lit.d.ts.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ShaderRegistry.ts +65 -0
- package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -4
- package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +7 -0
- package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +138 -30
- package/src/__tests__/direct-light-layout.unit.test.ts +40 -0
- package/src/__tests__/ibl-irradiance.unit.test.ts +7 -4
- package/src/__tests__/ibl-sampling.unit.test.ts +3 -4
- package/src/__tests__/lighting-point-spot-volume.unit.test.ts +16 -2
- package/src/__tests__/lighting-punctual.unit.test.ts +61 -8
- package/src/__tests__/lighting-spot-volume-attenuation.unit.test.ts +1 -1
- package/src/__tests__/ltc-provenance.unit.test.ts +33 -0
- package/src/__tests__/manifest.fixture.json +33 -0
- package/src/__tests__/material-contract.unit.test.ts +61 -1
- package/src/__tests__/material-derived-builtins.integration.test.ts +27 -0
- package/src/__tests__/probe-lighting-composition.unit.test.ts +38 -0
- package/src/__tests__/rect-area-brdf.unit.test.ts +36 -0
- package/src/__tests__/reflection-probe-sampling.unit.test.ts +45 -0
- package/src/__tests__/scene-temporal.unit.test.ts +20 -0
- package/src/__tests__/shader.unit.test.ts +40 -15
- package/src/__tests__/shadow-pcss.unit.test.ts +131 -0
- package/src/__tests__/spot-cookie-composition.unit.test.ts +57 -0
- package/src/__tests__/spot-modifier-composition.unit.test.ts +19 -0
- package/src/__tests__/sprite-lit-shader.test.ts +24 -11
- package/src/__tests__/sprite-variants.unit.test.ts +5 -1
- package/src/__tests__/transmission-thickness-scale.unit.test.ts +7 -1
- package/src/__tests__/transparent-pbr.unit.test.ts +7 -0
- package/src/brdf.wgsl +115 -1
- package/src/common.wgsl +95 -91
- package/src/default-standard-pbr-skin.wgsl +108 -92
- package/src/default-standard-pbr.wgsl +189 -191
- package/src/fxaa.wgsl +27 -6
- package/src/ibl-irradiance.wgsl +4 -1
- package/src/ibl-sampling.wgsl +56 -2
- package/src/index.ts +23 -0
- package/src/lighting-directional.wgsl +161 -132
- package/src/lighting-probe.wgsl +51 -0
- package/src/lighting-punctual.wgsl +69 -29
- package/src/lighting-rect-area.wgsl +162 -0
- package/src/lighting-spot-modifiers.wgsl +93 -0
- package/src/ltc/provenance.ts +19 -0
- package/src/ltc/tables.ts +2831 -0
- package/src/material/artifact-types.ts +137 -0
- package/src/material-schemas.ts +12 -0
- package/src/register-default-sprite-lit.ts +3 -6
- package/src/scene-temporal.wgsl +14 -8
- package/src/shadow-pcf.wgsl +44 -1
- package/src/sprite-lit.wgsl +37 -53
- package/src/sprite.wgsl +1 -1
- package/src/standard-cluster.wgsl +274 -0
- package/src/tonemap.wgsl +8 -3
- package/src/types.ts +2 -1
- package/src/volume/volume-inject.wgsl +48 -38
- package/src/volume/volume-integrate.wgsl +47 -42
- package/dist/.tsbuildinfo +0 -1
- package/src/hdrp-cluster-forward.wgsl +0 -224
|
@@ -27,15 +27,18 @@ beforeAll(async () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
describe('IBL irradiance payload contract', () => {
|
|
30
|
-
it('records that convolution
|
|
31
|
-
expect(irradianceSource).toContain('
|
|
30
|
+
it('records that convolution stores Lambert-normalized E over pi', () => {
|
|
31
|
+
expect(irradianceSource).toContain('IRRADIANCE_PAYLOAD_E_OVER_PI');
|
|
32
32
|
expect(irradianceSource).toMatch(/irradiance\s*=\s*PI\s*\*\s*irradiance/);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
it('keeps the constant-environment analytic oracle finite and explicit', () => {
|
|
36
36
|
const environment = 0.72;
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// The hemisphere integral is E=pi*L for a constant environment. The
|
|
38
|
+
// convolution shader's PI*sampleAverage stores E/pi=L directly, which is
|
|
39
|
+
// the Lambert diffuse radiance consumed by the material shader.
|
|
40
|
+
const convolvedPayload = environment;
|
|
41
|
+
const diffuseRadiance = convolvedPayload;
|
|
39
42
|
|
|
40
43
|
expect(diffuseRadiance).toBeCloseTo(environment, 12);
|
|
41
44
|
expect(Number.isFinite(diffuseRadiance)).toBe(true);
|
|
@@ -29,12 +29,11 @@ beforeAll(async () => {
|
|
|
29
29
|
describe('IBL diffuse sampling payload contract', () => {
|
|
30
30
|
it('names the sampled payload as E over pi at the canonical sampling owner', () => {
|
|
31
31
|
expect(samplingSource).toContain('irradianceEOverPi');
|
|
32
|
-
expect(samplingSource).toMatch(/irradianceEOverPi\s
|
|
32
|
+
expect(samplingSource).toMatch(/return\s+irradianceEOverPi\s*;/);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
it('does not divide the
|
|
35
|
+
it('does not divide the already Lambert-normalized diffuse payload again', () => {
|
|
36
36
|
const diffuseBody = samplingSource.match(/fn sampleIblDiffuse\([\s\S]*?\n\}/)?.[0] ?? '';
|
|
37
|
-
expect(diffuseBody.
|
|
38
|
-
expect(diffuseBody).not.toMatch(/irradianceEOverPi\s*\/\s*PI[\s\S]*\/\s*PI/);
|
|
37
|
+
expect(diffuseBody).not.toMatch(/\/\s*PI/);
|
|
39
38
|
});
|
|
40
39
|
});
|
|
@@ -24,8 +24,22 @@ describe('shared punctual optics for volumetric fog', () => {
|
|
|
24
24
|
expect(inject).toContain('evalVolumeSpot');
|
|
25
25
|
expect(integrate).toContain('evalVolumePoint');
|
|
26
26
|
expect(integrate).toContain('evalVolumeSpot');
|
|
27
|
-
expect(inject).toContain('
|
|
28
|
-
expect(inject).toContain('
|
|
27
|
+
expect(inject).toContain('light_data');
|
|
28
|
+
expect(inject).toContain('cluster_uniform');
|
|
29
|
+
expect(inject).not.toContain('pointLightsBuffer');
|
|
30
|
+
expect(inject).not.toContain('spotLightsBuffer');
|
|
31
|
+
expect(integrate).toContain('light_data');
|
|
32
|
+
expect(integrate).toContain('cluster_uniform');
|
|
33
|
+
expect(integrate).not.toContain('pointLightsBuffer');
|
|
34
|
+
expect(integrate).not.toContain('spotLightsBuffer');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps volume View aligned with the directional filter carrier', () => {
|
|
38
|
+
expect(inject).toContain('directionalShadowFilter : vec4<f32>');
|
|
39
|
+
expect(inject).toContain('normalBias : f32,\n directionalShadowFilter');
|
|
40
|
+
expect(inject).not.toContain('view.pcfKernelSize');
|
|
41
|
+
expect(inject).toContain('filter_profile >= 4u');
|
|
42
|
+
expect(inject).toContain('stable PCF3 receiver');
|
|
29
43
|
});
|
|
30
44
|
|
|
31
45
|
it('keeps the punctual volume path finite and monotonic at range boundaries', () => {
|
|
@@ -4,11 +4,16 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
|
|
6
6
|
const shaderPath = join(dirname(fileURLToPath(import.meta.url)), '../lighting-punctual.wgsl');
|
|
7
|
-
const
|
|
7
|
+
const standardClusterShaderPath = join(
|
|
8
8
|
dirname(fileURLToPath(import.meta.url)),
|
|
9
|
-
'../
|
|
9
|
+
'../standard-cluster.wgsl',
|
|
10
10
|
);
|
|
11
11
|
const pbrShaderPath = join(dirname(fileURLToPath(import.meta.url)), '../default-standard-pbr.wgsl');
|
|
12
|
+
const brdfShaderPath = join(dirname(fileURLToPath(import.meta.url)), '../brdf.wgsl');
|
|
13
|
+
const directionalShaderPath = join(
|
|
14
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
15
|
+
'../lighting-directional.wgsl',
|
|
16
|
+
);
|
|
12
17
|
|
|
13
18
|
describe('direct punctual lighting shader contract', () => {
|
|
14
19
|
it('exposes independent point and spot paths with explicit range and cone inputs', async () => {
|
|
@@ -16,6 +21,8 @@ describe('direct punctual lighting shader contract', () => {
|
|
|
16
21
|
|
|
17
22
|
expect(source).toContain('fn evalPoint(');
|
|
18
23
|
expect(source).toContain('fn evalSpot(');
|
|
24
|
+
expect(source).toContain('fn evalPointFlat(');
|
|
25
|
+
expect(source).toContain('fn evalSpotFlat(');
|
|
19
26
|
expect(source).toContain('invRangeSquared');
|
|
20
27
|
expect(source).toContain('cosInner');
|
|
21
28
|
expect(source).toContain('cosOuter');
|
|
@@ -36,10 +43,30 @@ describe('direct punctual lighting shader contract', () => {
|
|
|
36
43
|
expect(source).not.toContain('normalize(lightDir)');
|
|
37
44
|
});
|
|
38
45
|
|
|
46
|
+
it('shares Three r184 direct-PBR Fresnel and multiscatter ownership', async () => {
|
|
47
|
+
const [source, brdf, directional] = await Promise.all([
|
|
48
|
+
readFile(shaderPath, 'utf8'),
|
|
49
|
+
readFile(brdfShaderPath, 'utf8'),
|
|
50
|
+
readFile(directionalShaderPath, 'utf8'),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
expect(brdf).toContain('exp2((-5.55473 * vDotH - 6.98316) * vDotH)');
|
|
54
|
+
expect(brdf).toContain('fn threeR184DirectMultiScatter(');
|
|
55
|
+
expect(brdf).toContain('energyLoss * favg * favg + vec3<f32>(1e-6)');
|
|
56
|
+
expect(brdf).not.toContain('energyLoss * favg + vec3<f32>(1e-6)');
|
|
57
|
+
expect(source).toContain('threeR184DirectMultiScatter(roughness, nDotV, nDotL, F0)');
|
|
58
|
+
expect(source).toContain('let diffuse = (1.0 - metallic) * baseColor / 3.14159265;');
|
|
59
|
+
expect(source).toContain('return factor * factor / dSquared;');
|
|
60
|
+
expect(source).not.toContain('let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);');
|
|
61
|
+
expect(directional).toContain('threeR184DirectMultiScatter');
|
|
62
|
+
expect(directional).not.toContain('THREE_R184_DFG_LUT');
|
|
63
|
+
});
|
|
64
|
+
|
|
39
65
|
it('keeps cluster punctual evaluation in the shared lighting owner', async () => {
|
|
40
|
-
const source = await readFile(
|
|
66
|
+
const source = await readFile(standardClusterShaderPath, 'utf8');
|
|
41
67
|
|
|
42
|
-
expect(source).toContain('
|
|
68
|
+
expect(source).toContain('DirectLightSlot');
|
|
69
|
+
expect(source).toContain('light.metadata.x');
|
|
43
70
|
expect(source).toContain('evalPoint');
|
|
44
71
|
expect(source).toContain('evalSpot');
|
|
45
72
|
expect(source).toContain('evalSpotShadowed');
|
|
@@ -49,18 +76,44 @@ describe('direct punctual lighting shader contract', () => {
|
|
|
49
76
|
});
|
|
50
77
|
|
|
51
78
|
it('decodes HDRP spot cone lanes in the shared evaluator order', async () => {
|
|
52
|
-
const source = await readFile(
|
|
79
|
+
const source = await readFile(standardClusterShaderPath, 'utf8');
|
|
53
80
|
|
|
54
|
-
expect(source).toContain('light.
|
|
81
|
+
expect(source).toContain('light.colorTimesIntensity.w, light.direction.w, light.position.w');
|
|
55
82
|
expect(source).not.toContain('light.direction.w, light.color.w, light.position.w');
|
|
56
83
|
});
|
|
57
84
|
|
|
85
|
+
it('keeps the unshadowed spot sentinel out of the projector flag branch', async () => {
|
|
86
|
+
const source = await readFile(standardClusterShaderPath, 'utf8');
|
|
87
|
+
|
|
88
|
+
expect(source).toContain('if (encoded_tile >= 0 && (encoded_tile & PROJECTOR_FLAG) != 0)');
|
|
89
|
+
expect(source).toContain(
|
|
90
|
+
'sampleStandardSpotProjector(view.spotLightViewProj[tile], world_pos, light.metadata)',
|
|
91
|
+
);
|
|
92
|
+
expect(source).toContain(
|
|
93
|
+
'textureSampleLevel(cookieTexture, spotModifierSampler, uv, metadata.w, 0.0).rgb',
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
58
97
|
it('passes precomputed base and clearcoat roughness/F0 facts to cluster lights', async () => {
|
|
59
98
|
const source = await readFile(pbrShaderPath, 'utf8');
|
|
60
99
|
|
|
61
|
-
expect(source).
|
|
62
|
-
|
|
100
|
+
expect(source).toMatch(
|
|
101
|
+
/evaluateStandardClusterLights\(\s*in\.ndc\.xyz,\s*in\.viewZ,\s*in\.worldPos,\s*n,\s*v,\s*diffuseAlbedo,\s*metallic,\s*a,\s*f0,\s*false,?\s*\)/u,
|
|
63
102
|
);
|
|
64
103
|
expect(source).toContain('coatAlpha, vec3<f32>(0.04)');
|
|
65
104
|
});
|
|
105
|
+
|
|
106
|
+
it('requires one shared Directional PCSS orchestration owner', async () => {
|
|
107
|
+
const source = await readFile(directionalShaderPath, 'utf8');
|
|
108
|
+
|
|
109
|
+
expect(source).toContain('PCSS_MEDIUM_RAW_TAPS');
|
|
110
|
+
expect(source).toContain('PCSS_MEDIUM_COMPARE_TAPS');
|
|
111
|
+
expect(source).toContain('PCSS_HIGH_RAW_TAPS');
|
|
112
|
+
expect(source).toContain('PCSS_HIGH_COMPARE_TAPS');
|
|
113
|
+
expect(source).toContain('fn _samplePcssForCascade');
|
|
114
|
+
expect(source).toContain('if (blockerCount == 0u)');
|
|
115
|
+
expect(source).toContain('shadow_load_raw_depth');
|
|
116
|
+
expect(source).toContain('shadow_sample_compare');
|
|
117
|
+
expect(source).not.toMatch(/frameIndex|frame_index|taaJitter|taa_jitter|jitter/);
|
|
118
|
+
});
|
|
66
119
|
});
|
|
@@ -14,7 +14,7 @@ describe('volume Spot attenuation precision', () => {
|
|
|
14
14
|
expect(volumeInject).toContain('spot_shadow_visibility_at');
|
|
15
15
|
expect(volumeIntegrate).toContain('evalSpotAttenuation');
|
|
16
16
|
expect(volumeIntegrate).toContain('spot_attenuation_at');
|
|
17
|
-
expect(volumeIntegrate).toContain('light.
|
|
17
|
+
expect(volumeIntegrate).toContain('light.position.w');
|
|
18
18
|
// The paired Point+Spot owner applies visibility only to the Spot term;
|
|
19
19
|
// the Point radiance remains independent of the spot shadow. Keep the
|
|
20
20
|
// assertion at the semantic call boundary so WGSL formatting changes do
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
generateLtcTables,
|
|
4
|
+
hashLtcTable,
|
|
5
|
+
LTC_SOURCE_PROVENANCE,
|
|
6
|
+
LTC_TABLE_HASHES,
|
|
7
|
+
LTC_TABLE_HEIGHT,
|
|
8
|
+
LTC_TABLE_WIDTH,
|
|
9
|
+
LTC_TABLES,
|
|
10
|
+
} from '../ltc/tables';
|
|
11
|
+
|
|
12
|
+
describe('LTC provenance and tracked data contract', () => {
|
|
13
|
+
it('pins the fixed Three.js reference and two 64 by 64 rgba16float tables', () => {
|
|
14
|
+
expect(LTC_SOURCE_PROVENANCE.source).toBe('Three.js');
|
|
15
|
+
expect(LTC_SOURCE_PROVENANCE.revision).toBe('r184');
|
|
16
|
+
expect(LTC_SOURCE_PROVENANCE.generator).toContain('deterministic');
|
|
17
|
+
expect({ width: LTC_TABLE_WIDTH, height: LTC_TABLE_HEIGHT }).toEqual({ width: 64, height: 64 });
|
|
18
|
+
expect(LTC_TABLES.lambert.byteLength).toBe(64 * 64 * 4 * 2);
|
|
19
|
+
expect(LTC_TABLES.ggx.byteLength).toBe(64 * 64 * 4 * 2);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('keeps generation deterministic and hashes the tracked outputs', () => {
|
|
23
|
+
const first = generateLtcTables();
|
|
24
|
+
const second = generateLtcTables();
|
|
25
|
+
|
|
26
|
+
expect(first).toEqual(second);
|
|
27
|
+
expect(first).toEqual(LTC_TABLES);
|
|
28
|
+
expect(hashLtcTable(first.lambert)).toBe(LTC_TABLE_HASHES.lambert);
|
|
29
|
+
expect(hashLtcTable(first.ggx)).toBe(LTC_TABLE_HASHES.ggx);
|
|
30
|
+
expect(LTC_TABLE_HASHES.lambert).toMatch(/^[a-f0-9]{64}$/);
|
|
31
|
+
expect(LTC_TABLE_HASHES.ggx).toMatch(/^[a-f0-9]{64}$/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -18,5 +18,38 @@
|
|
|
18
18
|
"glsl": null,
|
|
19
19
|
"bindings": "[]"
|
|
20
20
|
}
|
|
21
|
+
],
|
|
22
|
+
"materialShaders": [
|
|
23
|
+
{
|
|
24
|
+
"identifier": "forgeax::default-standard-pbr",
|
|
25
|
+
"sourcePath": "default-standard-pbr.wgsl",
|
|
26
|
+
"composedWgsl": "@vertex fn vs_main() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }",
|
|
27
|
+
"paramSchema": "[]",
|
|
28
|
+
"variants": [],
|
|
29
|
+
"directEntry": "vs_main",
|
|
30
|
+
"sceneIndexEntry": "vs_scene_index",
|
|
31
|
+
"materialRow": { "byteLength": 384, "fields": ["baseColor", "alphaCutoff"] },
|
|
32
|
+
"resourceSlots": [],
|
|
33
|
+
"uvSets": [],
|
|
34
|
+
"vertexInputs": [
|
|
35
|
+
{ "semantic": "position", "location": 0, "format": "float32x3" },
|
|
36
|
+
{ "semantic": "normal", "location": 1, "format": "float32x3" },
|
|
37
|
+
{ "semantic": "uv", "location": 2, "format": "float32x2" },
|
|
38
|
+
{ "semantic": "tangent", "location": 3, "format": "float32x4" }
|
|
39
|
+
],
|
|
40
|
+
"alphaMask": { "cutoff": "alphaCutoff", "source": "baseColor.a" },
|
|
41
|
+
"reflection": {
|
|
42
|
+
"layoutIdentity": "standard-pbr/material-row-v1",
|
|
43
|
+
"resourceSlots": [],
|
|
44
|
+
"vertexInputs": [
|
|
45
|
+
{ "semantic": "position", "location": 0, "format": "float32x3" },
|
|
46
|
+
{ "semantic": "normal", "location": 1, "format": "float32x3" },
|
|
47
|
+
{ "semantic": "uv", "location": 2, "format": "float32x2" },
|
|
48
|
+
{ "semantic": "tangent", "location": 3, "format": "float32x4" }
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"receiptIdentity": "standard-pbr/material-row-v1",
|
|
52
|
+
"generation": 1
|
|
53
|
+
}
|
|
21
54
|
]
|
|
22
55
|
}
|
|
@@ -102,7 +102,11 @@ describe('standard PBR transmission material contract', () => {
|
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
it('keeps the seven transmission inputs in the reflected UBO and coordinate contract', {
|
|
105
|
-
|
|
105
|
+
// This test intentionally invokes the full engine shader manifest
|
|
106
|
+
// producer. Keep its budget local to the producer-backed contract so a
|
|
107
|
+
// concurrent coverage child cannot turn host CPU contention into a false
|
|
108
|
+
// product failure.
|
|
109
|
+
timeout: 60_000,
|
|
106
110
|
}, async () => {
|
|
107
111
|
const derived = derive(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
|
|
108
112
|
const transmissionNames = [
|
|
@@ -178,4 +182,60 @@ describe('standard PBR transmission material contract', () => {
|
|
|
178
182
|
/@group\(1\)\s*@binding\(16\)\s*var thicknessTexture/,
|
|
179
183
|
);
|
|
180
184
|
});
|
|
185
|
+
|
|
186
|
+
it('publishes one receipt for direct and scene-index consumers', {
|
|
187
|
+
timeout: 20_000,
|
|
188
|
+
}, async () => {
|
|
189
|
+
const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
|
|
190
|
+
const manifest = await buildEngineShaderManifest();
|
|
191
|
+
const standard = manifest.materialShaders.find(
|
|
192
|
+
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
193
|
+
);
|
|
194
|
+
const skin = manifest.materialShaders.find((entry) => entry.identifier === 'forgeax::pbr-skin');
|
|
195
|
+
expect(standard).toBeDefined();
|
|
196
|
+
expect(skin).toBeDefined();
|
|
197
|
+
expect(standard).toMatchObject({
|
|
198
|
+
directEntry: expect.any(String),
|
|
199
|
+
sceneIndexEntry: expect.any(String),
|
|
200
|
+
materialRow: expect.any(Object),
|
|
201
|
+
resourceSlots: expect.any(Array),
|
|
202
|
+
uvSets: expect.any(Array),
|
|
203
|
+
vertexInputs: expect.any(Array),
|
|
204
|
+
alphaMask: expect.any(Object),
|
|
205
|
+
reflection: expect.any(Object),
|
|
206
|
+
});
|
|
207
|
+
expect(skin?.receiptIdentity).toBe(standard?.receiptIdentity);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('composes the scene-index entry into the same validated PBR artifact as the direct entry', {
|
|
211
|
+
timeout: 30_000,
|
|
212
|
+
}, async () => {
|
|
213
|
+
const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
|
|
214
|
+
const manifest = await buildEngineShaderManifest();
|
|
215
|
+
const standard = manifest.materialShaders.find(
|
|
216
|
+
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
217
|
+
);
|
|
218
|
+
expect(standard).toBeDefined();
|
|
219
|
+
if (standard === undefined) return;
|
|
220
|
+
expect(standard.composedWgsl).toMatch(/@vertex\s+fn\s+vs_main\s*\(/);
|
|
221
|
+
expect(standard.composedWgsl).toMatch(/@vertex\s+fn\s+vs_scene_index\s*\(/);
|
|
222
|
+
expect(standard.composedWgsl).toMatch(/@fragment\s+fn\s+fs_main\s*\(/);
|
|
223
|
+
expect(standard.sceneIndexEntry).toBe('vs_scene_index');
|
|
224
|
+
expect(standard.reflection?.layoutIdentity).toBe(standard.receiptIdentity);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('rejects a receipt that loses a required prepared ABI field', {
|
|
228
|
+
timeout: 30_000,
|
|
229
|
+
}, async () => {
|
|
230
|
+
const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
|
|
231
|
+
const manifest = await buildEngineShaderManifest();
|
|
232
|
+
const standard = manifest.materialShaders.find(
|
|
233
|
+
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
234
|
+
);
|
|
235
|
+
expect(standard).toBeDefined();
|
|
236
|
+
if (standard === undefined) return;
|
|
237
|
+
const malformed = { ...standard } as Record<string, unknown>;
|
|
238
|
+
delete malformed.reflection;
|
|
239
|
+
expect(malformed).not.toHaveProperty('reflection');
|
|
240
|
+
});
|
|
181
241
|
});
|
|
@@ -1,8 +1,35 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { derive } from '@forgeax/engine-types';
|
|
2
4
|
import { describe, expect, it } from 'vitest';
|
|
3
5
|
import { DEFAULT_STANDARD_PBR_PARAM_SCHEMA } from '../material-schemas.js';
|
|
4
6
|
|
|
5
7
|
describe('material derived built-in integration contract', () => {
|
|
8
|
+
it('routes every Standard lit consumer through the single Cluster accessor', () => {
|
|
9
|
+
const sources = [
|
|
10
|
+
readFileSync(resolve(import.meta.dirname, '../default-standard-pbr.wgsl'), 'utf8'),
|
|
11
|
+
readFileSync(resolve(import.meta.dirname, '../default-standard-pbr-skin.wgsl'), 'utf8'),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
for (const source of sources) {
|
|
15
|
+
expect(source).toContain('evaluateStandardClusterLights');
|
|
16
|
+
expect(source).not.toContain('evaluate_cluster_lights');
|
|
17
|
+
expect(source).not.toContain('forgeax_hdrp');
|
|
18
|
+
expect(source).not.toContain('forgeax_urp');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sprite = readFileSync(resolve(import.meta.dirname, '../sprite-lit.wgsl'), 'utf8');
|
|
22
|
+
expect(sprite).toContain('evaluateStandardClusterLights');
|
|
23
|
+
expect(sprite).toContain('CLUSTER_FORWARD_AVAILABLE');
|
|
24
|
+
expect(sprite).toContain('ndc, viewZ, worldPos');
|
|
25
|
+
expect(sprite).not.toContain('forgeax_hdrp');
|
|
26
|
+
expect(sprite).not.toContain('forgeax_urp');
|
|
27
|
+
|
|
28
|
+
for (const source of [...sources, sprite]) {
|
|
29
|
+
expect(source).not.toMatch(/in\.clip\.xy\s*\/\s*in\.clip\.w/);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
6
33
|
it('uses the same derived identity for standard and skinned standard schemas', () => {
|
|
7
34
|
const standard = derive(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
|
|
8
35
|
const skinned = derive(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
|
|
@@ -0,0 +1,38 @@
|
|
|
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 probe = readFileSync(resolve(shaderRoot, 'lighting-probe.wgsl'), 'utf8');
|
|
7
|
+
const standard = readFileSync(resolve(shaderRoot, 'default-standard-pbr.wgsl'), 'utf8');
|
|
8
|
+
const skin = readFileSync(resolve(shaderRoot, 'default-standard-pbr-skin.wgsl'), 'utf8');
|
|
9
|
+
|
|
10
|
+
describe('Probe diffuse shader composition', () => {
|
|
11
|
+
it('evaluates one SH9 record and derives Sky as the residual', () => {
|
|
12
|
+
expect(probe).toContain('array<vec4<f32>, 9>');
|
|
13
|
+
expect(probe).toContain('sh9');
|
|
14
|
+
expect(probe).toContain('1.0 - localBlendFraction');
|
|
15
|
+
expect(probe).toContain('max(');
|
|
16
|
+
expect(standard).toContain('#ifdef PROBE_BLEND_AVAILABLE');
|
|
17
|
+
expect(standard).toContain('@group(3) @binding(1) var<storage, read> probeBlendRecords');
|
|
18
|
+
expect(standard).not.toContain('@group(2) @binding(1) var<storage, read> probeBlendRecords');
|
|
19
|
+
expect(standard).toContain('evaluateProbeDiffuse(probeShPreblend');
|
|
20
|
+
expect(skin).toContain('evaluateProbeDiffuse(probeShPreblend');
|
|
21
|
+
expect(skin).toContain('@group(3) @binding(1) var<storage, read> probeBlendRecords');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('keeps Sky out of probe admission and preserves the existing specular owner', () => {
|
|
25
|
+
expect(probe).not.toContain('q_sky');
|
|
26
|
+
expect(probe).not.toContain('for (var probe');
|
|
27
|
+
expect(standard).toContain('sampleIblSpecular');
|
|
28
|
+
expect(skin).toContain('sampleIblSpecular');
|
|
29
|
+
expect(standard).not.toContain('sampleProbeSpecular');
|
|
30
|
+
expect(skin).not.toContain('sampleProbeSpecular');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('suppresses probe diffuse for metal while retaining Skylight IBL inputs', () => {
|
|
34
|
+
expect(probe).toContain('(1.0 - metallic)');
|
|
35
|
+
expect(standard).toContain('sampleIblDiffuse');
|
|
36
|
+
expect(skin).toContain('sampleIblDiffuse');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
describe('RectAreaLight LTC BRDF shader contract', () => {
|
|
5
|
+
it('provides one-sided Lambert and GGX LTC polygon evaluators', async () => {
|
|
6
|
+
const source = await readFile(new URL('../lighting-rect-area.wgsl', import.meta.url), 'utf8');
|
|
7
|
+
|
|
8
|
+
expect(source).toContain('fn evalRectAreaLtcLambert(');
|
|
9
|
+
expect(source).toContain('fn evalRectAreaLtcGgx(');
|
|
10
|
+
expect(source).toContain('fn ltcUv(');
|
|
11
|
+
expect(source).toContain('fn ltcEdgeVectorFormFactor(');
|
|
12
|
+
expect(source).toContain('fn ltcClippedSphereFormFactor(');
|
|
13
|
+
expect(source).toContain('fn ltcEvaluate(');
|
|
14
|
+
expect(source).toContain('ltcLambertTexture');
|
|
15
|
+
expect(source).toContain('ltcGgxTexture');
|
|
16
|
+
expect(source).not.toContain('LTC_LAMBERT');
|
|
17
|
+
expect(source).not.toContain('LTC_GGX');
|
|
18
|
+
expect(source).toContain('let rect0 = lightPos - halfX - halfY;');
|
|
19
|
+
expect(source).toContain(
|
|
20
|
+
'let transform = mInv * transpose(mat3x3<f32>(tangent, bitangent, n));',
|
|
21
|
+
);
|
|
22
|
+
expect(source).toContain('sqrt(1.0 - nDotV)');
|
|
23
|
+
expect(source).toContain('let diffuse = baseColor * (1.0 - metallic);');
|
|
24
|
+
expect(source).not.toContain('viewWeight');
|
|
25
|
+
expect(source).not.toContain('rectAreaSolidAngle');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does not route Rect through punctual inverse-square or shadow sampling', async () => {
|
|
29
|
+
const source = await readFile(new URL('../lighting-rect-area.wgsl', import.meta.url), 'utf8');
|
|
30
|
+
|
|
31
|
+
expect(source).not.toContain('/ distanceSquared');
|
|
32
|
+
expect(source).not.toContain('shadowAtlas');
|
|
33
|
+
expect(source).not.toContain('evalPoint(');
|
|
34
|
+
expect(source).not.toContain('evalSpot(');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const samplingSource = readFileSync(new URL('../ibl-sampling.wgsl', import.meta.url), 'utf8');
|
|
5
|
+
const standardSource = readFileSync(
|
|
6
|
+
new URL('../default-standard-pbr.wgsl', import.meta.url),
|
|
7
|
+
'utf8',
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
describe('Standard reflection probe sampling contract', () => {
|
|
11
|
+
it('keeps global IBL helpers and adds a bounded local probe sampling helper', () => {
|
|
12
|
+
expect(samplingSource).toMatch(/fn\s+sampleIblDiffuse\s*\(/);
|
|
13
|
+
expect(samplingSource).toMatch(/fn\s+sampleIblSpecular\s*\(/);
|
|
14
|
+
expect(samplingSource).toMatch(/fn\s+sampleReflectionProbeSpecular\s*\(/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('uses box projection and explicit Skylight fallback in the Standard shader', () => {
|
|
18
|
+
expect(samplingSource).toMatch(/box_project/);
|
|
19
|
+
expect(standardSource).toMatch(/sampleReflectionProbeSpecular/);
|
|
20
|
+
expect(standardSource).toContain('skylight.intensity < 0.0');
|
|
21
|
+
expect(standardSource).toMatch(/sampleIblSpecular/);
|
|
22
|
+
expect(standardSource).toMatch(/skylight/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('projects only the environment specular lobe for c=1 without removing diffuse light', () => {
|
|
26
|
+
expect(standardSource).toContain(
|
|
27
|
+
'var reflectionFallback = specularIbl * (vec3<f32>(1.0) - coatF);',
|
|
28
|
+
);
|
|
29
|
+
expect(standardSource).toContain('kD * irradiance * diffuseAlbedo');
|
|
30
|
+
expect(standardSource).toContain(
|
|
31
|
+
'output.reflectionFallback = vec4<f32>(reflectionFallback, 1.0);',
|
|
32
|
+
);
|
|
33
|
+
expect(standardSource).not.toContain('output.reflectionFallback = vec4<f32>(ambient, 1.0);');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('keeps probe clearcoat on the selected probe source', () => {
|
|
37
|
+
expect(standardSource).toMatch(
|
|
38
|
+
/if \(skylight\.intensity < 0\.0\) \{[\s\S]*?clearcoatIbl = sampleReflectionProbeSpecular\(/,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps zero-intensity probes distinguishable from an absent Skylight', () => {
|
|
43
|
+
expect(standardSource).toContain('max(-skylight.intensity - 1.0, 0.0) * ao');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -20,6 +20,25 @@ describe('scene temporal varyings', () => {
|
|
|
20
20
|
expect(source).toContain('packed.z < 0.0');
|
|
21
21
|
expect(source).toContain('SCENE_DATA_TEMPORAL_V1_CLEAR');
|
|
22
22
|
expect(source).toContain('rgba16float');
|
|
23
|
+
expect(source).toContain('fn sceneViewZ');
|
|
24
|
+
expect(source).toContain('orthographicViewZ = -(temporalProjection.x');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('uses projection depth rather than Euclidean distance for cluster view Z', () => {
|
|
28
|
+
const sceneViewZ = (
|
|
29
|
+
clip: { readonly z: number; readonly w: number },
|
|
30
|
+
temporalProjection: { readonly x: number; readonly y: number; readonly z: number },
|
|
31
|
+
) => {
|
|
32
|
+
const ndcDepth = clip.z / Math.max(Math.abs(clip.w), 1e-6);
|
|
33
|
+
const orthographicViewZ = -(
|
|
34
|
+
temporalProjection.x +
|
|
35
|
+
ndcDepth * (temporalProjection.y - temporalProjection.x)
|
|
36
|
+
);
|
|
37
|
+
return temporalProjection.z >= 0.5 ? orthographicViewZ : -clip.w;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
expect(sceneViewZ({ z: 0.5, w: 1 }, { x: 1, y: 11, z: 1 })).toBe(-6);
|
|
41
|
+
expect(sceneViewZ({ z: 3, w: 7 }, { x: 1, y: 11, z: 0 })).toBe(-7);
|
|
23
42
|
});
|
|
24
43
|
|
|
25
44
|
it.each(localTemporalShaders)('%s perspective-interpolates both clip authorities', (file) => {
|
|
@@ -39,6 +58,7 @@ describe('scene temporal varyings', () => {
|
|
|
39
58
|
|
|
40
59
|
it.each(pbrTemporalConsumers)('%s imports the shared PBR temporal authority', (file) => {
|
|
41
60
|
const source = readFileSync(resolve(import.meta.dirname, '..', file), 'utf8');
|
|
61
|
+
expect(source).toContain('#import forgeax_scene_temporal::{sceneViewZ}');
|
|
42
62
|
expect(source).toContain('#import forgeax_pbr::temporal::{projectPbrSceneTemporal}');
|
|
43
63
|
expect(source).toMatch(/@interpolate\(perspective\) currentClip : vec4<f32>/);
|
|
44
64
|
expect(source).toMatch(/@interpolate\(perspective\) previousClip : vec4<f32>/);
|