@forgeax/engine-shader 0.1.24 → 0.1.25
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 +97 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +32 -4
- package/dist/index.mjs.map +1 -1
- package/dist/material/artifact-registry.d.ts +1 -0
- package/dist/material/artifact-registry.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/builtin-texture-sampling-contract.test.ts +10 -1
- package/src/__tests__/deferred-lighting-ssao.test.ts +4 -1
- package/src/__tests__/standard-pbr-webgl2-interface.unit.test.ts +33 -0
- package/src/__tests__/standard-physical-layer.unit.test.ts +16 -0
- package/src/__tests__/standard-surface-pass-evaluation.integration.test.ts +38 -0
- package/src/__tests__/surface-lighting-provenance.integration.test.ts +59 -0
- package/src/__tests__/surface-pass-templates.integration.test.ts +28 -0
- package/src/default-standard-pbr-skin.wgsl +26 -0
- package/src/index.ts +31 -3
- package/src/material/artifact-registry.ts +20 -1
- package/src/material/standard-physical-layer.wgsl +12 -0
- package/src/shadow_caster.wgsl +80 -6
- package/src/unlit.wgsl +12 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"artifact-registry.d.ts","sourceRoot":"","sources":["../../src/material/artifact-registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAE7D,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,IAAI,EAAE,4BAA4B,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;KAClC,CAAC;CACH;
|
|
1
|
+
{"version":3,"file":"artifact-registry.d.ts","sourceRoot":"","sources":["../../src/material/artifact-registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAE7D,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,IAAI,EAAE,4BAA4B,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,SAAS,EAAE,OAAO,GAAG,cAAc,CAAC;QAC7C,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;KAClC,CAAC;CACH;AAUD,qBAAa,wBAAwB;;IAGnC,QAAQ,CACN,QAAQ,EAAE,uBAAuB,GAChC,MAAM,CAAC,uBAAuB,EAAE,6BAA6B,CAAC;IAkCjE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS;IAIrD,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,0BAA0B,GAAG,SAAS;CAU7D"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/engine-shader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@forgeax/engine-rhi": "0.1.
|
|
26
|
-
"@forgeax/engine-types": "0.1.
|
|
25
|
+
"@forgeax/engine-rhi": "0.1.25",
|
|
26
|
+
"@forgeax/engine-types": "0.1.25",
|
|
27
27
|
"@webgpu/types": "^0.1.71"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@forgeax/engine-vite-plugin-shader": "0.1.
|
|
30
|
+
"@forgeax/engine-vite-plugin-shader": "0.1.25"
|
|
31
31
|
},
|
|
32
32
|
"forgeax": {
|
|
33
33
|
"metrics": {
|
|
@@ -49,7 +49,16 @@ describe('built-in texture coordinate records', () => {
|
|
|
49
49
|
it('keeps custom coordinate metadata distinct from texture resources', () => {
|
|
50
50
|
for (const file of shaderFiles) {
|
|
51
51
|
const text = source(file);
|
|
52
|
-
|
|
52
|
+
if (file === 'default-standard-pbr-skin.wgsl') {
|
|
53
|
+
// Standard templates are parameter-agnostic inputs. The compiler
|
|
54
|
+
// lowers the generated coordinate metadata together with the selected
|
|
55
|
+
// Surface, so the skinned template only needs to expose that seam.
|
|
56
|
+
expect(text, `${file} must delegate material facts to Surface`).toContain(
|
|
57
|
+
'evaluate_surface',
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
expect(text, `${file} must name coordinate metadata`).toMatch(/CoordinatesMetadata/);
|
|
61
|
+
}
|
|
53
62
|
expect(text, `${file} must preserve texture resources`).toMatch(/texture_2d|texture_cube/);
|
|
54
63
|
}
|
|
55
64
|
});
|
|
@@ -39,7 +39,10 @@ beforeAll(async () => {
|
|
|
39
39
|
const url = (await import(/* @vite-ignore */ urlId)) as NodeUrl;
|
|
40
40
|
const here = url.fileURLToPath(import.meta.url);
|
|
41
41
|
const srcDir = path.resolve(path.dirname(here), '..');
|
|
42
|
-
pbrSource =
|
|
42
|
+
pbrSource = [
|
|
43
|
+
fs.readFileSync(path.resolve(srcDir, 'default-standard-pbr.wgsl'), 'utf8'),
|
|
44
|
+
fs.readFileSync(path.resolve(srcDir, 'default_standard_surface.wgsl'), 'utf8'),
|
|
45
|
+
].join('\n');
|
|
43
46
|
});
|
|
44
47
|
|
|
45
48
|
function stripComments(src: string): string {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const standardPbrSource = readFileSync(
|
|
6
|
+
fileURLToPath(new URL('../default-standard-pbr.wgsl', import.meta.url)),
|
|
7
|
+
'utf8',
|
|
8
|
+
);
|
|
9
|
+
const standardPbrSkinSource = readFileSync(
|
|
10
|
+
fileURLToPath(new URL('../default-standard-pbr-skin.wgsl', import.meta.url)),
|
|
11
|
+
'utf8',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
describe('standard PBR WebGL2 interface', () => {
|
|
15
|
+
it('packs the non-transmission position and view depth into one varying', () => {
|
|
16
|
+
for (const source of [standardPbrSource, standardPbrSkinSource]) {
|
|
17
|
+
expect(source).toContain('@location(7) positionOSAndViewZ : vec4<f32>');
|
|
18
|
+
expect(source).not.toContain('@location(4) positionOS : vec3<f32>');
|
|
19
|
+
expect(source).not.toContain('@location(15) positionOS : vec3<f32>');
|
|
20
|
+
expect(source).not.toContain('@location(4) @interpolate(flat) instanceIdx : u32');
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('retains the compact transmission basis locations for adapters that advertise it', () => {
|
|
25
|
+
for (const source of [standardPbrSource, standardPbrSkinSource]) {
|
|
26
|
+
expect(source).toContain('@location(4) @interpolate(flat) transmissionBasis0 : vec4<f32>');
|
|
27
|
+
expect(source).toContain('@location(13) @interpolate(flat) transmissionBasis1 : vec4<f32>');
|
|
28
|
+
expect(source).not.toContain(
|
|
29
|
+
'@location(15) @interpolate(flat) transmissionBasis1 : vec4<f32>',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const source = readFileSync(
|
|
6
|
+
fileURLToPath(new URL('../material/standard-physical-layer.wgsl', import.meta.url)),
|
|
7
|
+
'utf8',
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
describe('standard physical layer module', () => {
|
|
11
|
+
it('is an Engine-owned layer module after Surface evaluation', () => {
|
|
12
|
+
expect(source).toContain('standard_physical_layer');
|
|
13
|
+
expect(source).toContain('evaluate_standard_physical_layer');
|
|
14
|
+
expect(source).not.toContain('moduleSlots');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
function source(name: string): string {
|
|
6
|
+
return readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function entryBody(sourceText: string, entry: string): string {
|
|
10
|
+
const start = sourceText.indexOf(`fn ${entry}`);
|
|
11
|
+
const end = sourceText.indexOf('\n@fragment', start + 1);
|
|
12
|
+
return sourceText.slice(start, end < 0 ? sourceText.length : end);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('Standard Surface pass evaluation', () => {
|
|
16
|
+
it('evaluates the selected Surface once in every rigid and skinned material pass', () => {
|
|
17
|
+
for (const name of ['default-standard-pbr.wgsl', 'default-standard-pbr-skin.wgsl']) {
|
|
18
|
+
const shader = source(name);
|
|
19
|
+
// ShadowCaster is a shared `shadow_caster.wgsl` material entry, while
|
|
20
|
+
// each Standard material template owns the forward and deferred entries.
|
|
21
|
+
for (const entry of ['fs_main', 'fs_gbuffer']) {
|
|
22
|
+
const body = entryBody(shader, entry);
|
|
23
|
+
expect(body, `${name}:${entry} must exist`).toContain(`fn ${entry}`);
|
|
24
|
+
expect(body, `${name}:${entry}`).toMatch(
|
|
25
|
+
/(?:evaluateStandardSurface|evaluate_surface|forgeax_evaluate_surface_from_fragments)\s*\(/,
|
|
26
|
+
);
|
|
27
|
+
expect(body).toContain('alphaTestSurface(surface)');
|
|
28
|
+
if (entry === 'fs_main') expect(body).toContain('surface.opacity');
|
|
29
|
+
expect(body).toContain('surface.baseColor');
|
|
30
|
+
expect(body).toContain('surface.normalWS');
|
|
31
|
+
expect(body).toContain('surface.metallic');
|
|
32
|
+
expect(body).toContain('surface.roughness');
|
|
33
|
+
expect(body).toContain('surface.emissive');
|
|
34
|
+
expect(body).toContain('surface.occlusion');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
function source(name: string): string {
|
|
6
|
+
return readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('Standard Surface lighting provenance', () => {
|
|
10
|
+
const provenance = {
|
|
11
|
+
pass: 'forward',
|
|
12
|
+
module: 'game_3d::rusted_iron_surface',
|
|
13
|
+
closure: 'forgeax_material::surface_v1',
|
|
14
|
+
artifact: 'inline-wgsl:surface-standard-v1',
|
|
15
|
+
cook: 'createShaderModule',
|
|
16
|
+
generation: 1,
|
|
17
|
+
backend: 'webgpu',
|
|
18
|
+
lane: 'direct',
|
|
19
|
+
head: 'feature-head:m3-gpu-provenance',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
it('keeps one selected Surface closure across lighting pass adapters', () => {
|
|
23
|
+
const forward = source('default-standard-pbr.wgsl');
|
|
24
|
+
const skinned = source('default-standard-pbr-skin.wgsl');
|
|
25
|
+
const shadow = source('shadow_caster.wgsl');
|
|
26
|
+
for (const shader of [forward, skinned, shadow]) {
|
|
27
|
+
expect(shader).toContain('#import forgeax_material::surface_v1');
|
|
28
|
+
expect(shader).toContain('forgeax_material::slot::surface');
|
|
29
|
+
}
|
|
30
|
+
expect(forward).toContain('fn fs_main');
|
|
31
|
+
expect(skinned).toContain('fn fs_gbuffer');
|
|
32
|
+
expect(shadow).toContain('fn fs_shadow');
|
|
33
|
+
expect(provenance.closure).toBe('forgeax_material::surface_v1');
|
|
34
|
+
expect(provenance.generation).toBeGreaterThan(0);
|
|
35
|
+
expect(provenance.head).toMatch(/^feature-head:/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('keeps direct, clustered, shadow, and controlled feature owners observable', () => {
|
|
39
|
+
const pbr = source('default-standard-pbr.wgsl');
|
|
40
|
+
expect(pbr).toContain('evalDirectional');
|
|
41
|
+
expect(pbr).toContain('evalPoint');
|
|
42
|
+
expect(pbr).toContain('evalSpot');
|
|
43
|
+
expect(pbr).toContain('fs_gbuffer');
|
|
44
|
+
expect(pbr).toContain('sampleIbl');
|
|
45
|
+
expect(pbr).toContain('evaluateStandardSurface');
|
|
46
|
+
expect(pbr).toContain('alphaClipThreshold');
|
|
47
|
+
// Volume integration is a post-lighting producer. Standard must not
|
|
48
|
+
// reconstruct or apply a private analytic fog ray in any pass.
|
|
49
|
+
expect(pbr).not.toContain('apply_fog');
|
|
50
|
+
expect(pbr).not.toContain('applySceneFog');
|
|
51
|
+
expect(pbr).not.toMatch(/forwardFallback|deferredFallback/);
|
|
52
|
+
expect(provenance.pass).toBe('forward');
|
|
53
|
+
expect(provenance.module).toBe('game_3d::rusted_iron_surface');
|
|
54
|
+
expect(provenance.artifact).toContain('inline-wgsl:');
|
|
55
|
+
expect(provenance.cook).toBe('createShaderModule');
|
|
56
|
+
expect(provenance.backend).toBe('webgpu');
|
|
57
|
+
expect(provenance.lane).toBe('direct');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
function source(name: string): string {
|
|
6
|
+
return readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('Standard Surface pass templates', () => {
|
|
10
|
+
it('imports the same Surface ABI in rigid and skinned templates', () => {
|
|
11
|
+
for (const name of ['default-standard-pbr.wgsl', 'default-standard-pbr-skin.wgsl']) {
|
|
12
|
+
const shader = source(name);
|
|
13
|
+
expect(shader).toContain('#pragma material_slot surface');
|
|
14
|
+
expect(shader).toContain('#import forgeax_material::surface_v1');
|
|
15
|
+
expect(shader).toContain('evaluate_surface');
|
|
16
|
+
expect(shader).toContain('fs_gbuffer');
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('keeps alpha clip in the shared shadow template without vertex mutation', () => {
|
|
21
|
+
const shader = source('shadow_caster.wgsl');
|
|
22
|
+
expect(shader).toContain('#pragma material_slot surface');
|
|
23
|
+
expect(shader).toContain('#import forgeax_material::surface_v1');
|
|
24
|
+
expect(shader).toContain('alphaClipThreshold');
|
|
25
|
+
expect(shader).toContain('fn fs_shadow');
|
|
26
|
+
expect(shader).not.toMatch(/\bin\.position\s*=|\bin\.positionWS\s*=/);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -961,6 +961,32 @@ fn fs_main(in : VsOut, @builtin(front_facing) frontFacing : bool) -> @location(0
|
|
|
961
961
|
return vec4<f32>(color, alpha);
|
|
962
962
|
}
|
|
963
963
|
|
|
964
|
+
// Keep the skinned Standard shader on the same multi-entry pass contract as
|
|
965
|
+
// the rigid Standard shader. Deferred draws select this entry point from the
|
|
966
|
+
// existing `forgeax::pbr-skin` artifact; lighting is performed by the deferred
|
|
967
|
+
// pass after these base surface facts are written to the shared G-buffer.
|
|
968
|
+
struct GBufferOutput {
|
|
969
|
+
@location(0) normal_roughness : vec4<f32>,
|
|
970
|
+
@location(1) albedo_metallic : vec4<f32>,
|
|
971
|
+
@location(2) emissive_ao : vec4<f32>,
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
@fragment
|
|
975
|
+
fn fs_gbuffer(in : VsOut, @builtin(front_facing) frontFacing : bool) -> GBufferOutput {
|
|
976
|
+
let surface = evaluateStandardSurface(in, frontFacing);
|
|
977
|
+
alphaTestSurface(surface);
|
|
978
|
+
let albedo = surface.baseColor;
|
|
979
|
+
let metallic = clamp(surface.metallic, 0.0, 1.0);
|
|
980
|
+
let roughness = clamp(surface.roughness, 0.04, 1.0);
|
|
981
|
+
let n = normalize(surface.normalWS);
|
|
982
|
+
|
|
983
|
+
var out : GBufferOutput;
|
|
984
|
+
out.normal_roughness = vec4<f32>(n * 0.5 + 0.5, roughness);
|
|
985
|
+
out.albedo_metallic = vec4<f32>(albedo, metallic);
|
|
986
|
+
out.emissive_ao = vec4<f32>(surface.emissive, surface.occlusion);
|
|
987
|
+
return out;
|
|
988
|
+
}
|
|
989
|
+
|
|
964
990
|
struct TemporalVsOut {
|
|
965
991
|
@builtin(position) clip : vec4<f32>,
|
|
966
992
|
@location(0) uv : vec2<f32>,
|
package/src/index.ts
CHANGED
|
@@ -107,6 +107,14 @@ export const BUILTIN_MATERIAL_MODULES = {
|
|
|
107
107
|
sprite: 'forgeax_material::sprite',
|
|
108
108
|
} as const;
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The engine-owned Standard Surface selected by a built-in Standard material.
|
|
112
|
+
* Keep this explicit in the authored pass so the build-time cooker sees the
|
|
113
|
+
* same slot contract as Materials.standard().
|
|
114
|
+
*/
|
|
115
|
+
export const DEFAULT_STANDARD_SURFACE_MODULE =
|
|
116
|
+
'forgeax_material::default_standard_surface' as const;
|
|
117
|
+
|
|
110
118
|
/** Stable build-time module id for the temporal-v1 accessor owner. */
|
|
111
119
|
export const SCENE_DATA_TEMPORAL_V1_SHADER_MODULE = 'forgeax_scene_temporal' as const;
|
|
112
120
|
|
|
@@ -114,7 +122,9 @@ export const SCENE_DATA_TEMPORAL_V1_SHADER_MODULE = 'forgeax_scene_temporal' as
|
|
|
114
122
|
* Material modules whose shader source and parameter contract are owned by
|
|
115
123
|
* the Engine. They live in the runtime ShaderRegistry, so a Pack containing
|
|
116
124
|
* one of these materials carries authored values only and does not require a
|
|
117
|
-
* project material-cook artifact.
|
|
125
|
+
* project material-cook artifact. A Standard pass with a project Surface slot
|
|
126
|
+
* is deliberately excluded: its root composition is project-owned and must
|
|
127
|
+
* arrive with a cooked material publication.
|
|
118
128
|
*/
|
|
119
129
|
export const ENGINE_MATERIAL_MODULES = [
|
|
120
130
|
'forgeax::default-standard-pbr',
|
|
@@ -143,7 +153,15 @@ export function isEngineMaterialModule(module: string): boolean {
|
|
|
143
153
|
|
|
144
154
|
export function isEngineMaterial(material: Pick<MaterialAsset, 'passes'>): boolean {
|
|
145
155
|
const passes = material.passes ?? [];
|
|
146
|
-
return
|
|
156
|
+
return (
|
|
157
|
+
passes.length > 0 &&
|
|
158
|
+
passes.every(
|
|
159
|
+
(pass) =>
|
|
160
|
+
isEngineMaterialModule(pass.program.module) &&
|
|
161
|
+
(pass.program.moduleSlots?.surface === undefined ||
|
|
162
|
+
pass.program.moduleSlots.surface === DEFAULT_STANDARD_SURFACE_MODULE),
|
|
163
|
+
)
|
|
164
|
+
);
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
export type BuiltinMaterialKind = keyof typeof BUILTIN_MATERIAL_MODULES;
|
|
@@ -175,7 +193,17 @@ const BUILTIN_VALUES: Readonly<
|
|
|
175
193
|
export function createBuiltinMaterialAsset(kind: BuiltinMaterialKind): MaterialAsset {
|
|
176
194
|
return {
|
|
177
195
|
kind: 'material',
|
|
178
|
-
passes: [
|
|
196
|
+
passes: [
|
|
197
|
+
{
|
|
198
|
+
name: 'forward',
|
|
199
|
+
program: {
|
|
200
|
+
module: BUILTIN_MATERIAL_MODULES[kind],
|
|
201
|
+
...(kind === 'standard'
|
|
202
|
+
? { moduleSlots: { surface: DEFAULT_STANDARD_SURFACE_MODULE } }
|
|
203
|
+
: {}),
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
],
|
|
179
207
|
parameters: BUILTIN_PARAMETERS[kind],
|
|
180
208
|
values: BUILTIN_VALUES[kind],
|
|
181
209
|
};
|
|
@@ -20,6 +20,7 @@ export interface MaterialArtifactConflictError {
|
|
|
20
20
|
readonly hint: string;
|
|
21
21
|
readonly detail: {
|
|
22
22
|
readonly key: string;
|
|
23
|
+
readonly dimension: 'bytes' | 'param-schema';
|
|
23
24
|
readonly existingDigest?: string;
|
|
24
25
|
readonly incomingDigest?: string;
|
|
25
26
|
};
|
|
@@ -29,6 +30,10 @@ function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
|
29
30
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function equalParamSchema(left: MaterialRuntimeArtifact, right: MaterialRuntimeArtifact): boolean {
|
|
34
|
+
return JSON.stringify(left.metadata?.paramSchema) === JSON.stringify(right.metadata?.paramSchema);
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
export class MaterialArtifactRegistry {
|
|
33
38
|
readonly #artifacts = new Map<string, MaterialRuntimeArtifact>();
|
|
34
39
|
|
|
@@ -37,13 +42,27 @@ export class MaterialArtifactRegistry {
|
|
|
37
42
|
): Result<MaterialRuntimeArtifact, MaterialArtifactConflictError> {
|
|
38
43
|
const previous = this.#artifacts.get(artifact.key);
|
|
39
44
|
if (previous !== undefined) {
|
|
40
|
-
if (equalBytes(previous.bytes, artifact.bytes))
|
|
45
|
+
if (equalBytes(previous.bytes, artifact.bytes)) {
|
|
46
|
+
if (equalParamSchema(previous, artifact)) return ok(previous);
|
|
47
|
+
return err({
|
|
48
|
+
code: 'material-artifact-conflict',
|
|
49
|
+
expected: 'one immutable parameter schema per specialization key',
|
|
50
|
+
hint: 're-cook the conflicting specialization with one canonical parameter schema',
|
|
51
|
+
detail: {
|
|
52
|
+
key: artifact.key,
|
|
53
|
+
dimension: 'param-schema',
|
|
54
|
+
...(previous.digest ? { existingDigest: previous.digest } : {}),
|
|
55
|
+
...(artifact.digest ? { incomingDigest: artifact.digest } : {}),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
41
59
|
return err({
|
|
42
60
|
code: 'material-artifact-conflict',
|
|
43
61
|
expected: 'one immutable artifact byte sequence per specialization key',
|
|
44
62
|
hint: 're-cook the conflicting specialization and publish one artifact digest',
|
|
45
63
|
detail: {
|
|
46
64
|
key: artifact.key,
|
|
65
|
+
dimension: 'bytes',
|
|
47
66
|
...(previous.digest ? { existingDigest: previous.digest } : {}),
|
|
48
67
|
...(artifact.digest ? { incomingDigest: artifact.digest } : {}),
|
|
49
68
|
},
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#define_import_path forgeax_material::standard_physical_layer
|
|
2
|
+
|
|
3
|
+
// Engine-owned physical layer facts are selected from the root StandardLayerPlan.
|
|
4
|
+
// Surface modules only produce base SurfaceData and never own this interface.
|
|
5
|
+
// The Standard template owns the facts struct; this module supplies the one
|
|
6
|
+
// physical evaluator body when the clearcoat plan is present.
|
|
7
|
+
fn evaluate_standard_physical_layer() -> StandardPhysicalLayerFacts {
|
|
8
|
+
return StandardPhysicalLayerFacts(
|
|
9
|
+
clamp(material.clearcoat, 0.0, 1.0),
|
|
10
|
+
clamp(material.clearcoatRoughness, 0.04, 1.0),
|
|
11
|
+
);
|
|
12
|
+
}
|
package/src/shadow_caster.wgsl
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
#pragma variant_axis STORAGE_BUFFER_AVAILABLE
|
|
2
2
|
#pragma variant_axis SKINNING_DISABLED
|
|
3
|
+
#pragma material_slot surface
|
|
4
|
+
#define_import_path forgeax::default-shadow-caster
|
|
5
|
+
#import forgeax_material::slot::surface::{evaluate_surface}
|
|
6
|
+
#import forgeax_material::surface_v1::{SurfaceInput, SurfaceData}
|
|
7
|
+
#import forgeax_material::parameters::{material}
|
|
3
8
|
|
|
4
9
|
// @forgeax/engine-shader shadow_caster.wgsl
|
|
5
10
|
// feat-20260520-directional-light-shadow-mapping M1c / w9 (D-9 / AC-09):
|
|
6
|
-
//
|
|
7
|
-
//
|
|
11
|
+
// depth pass for directional shadow map. The fragment stage evaluates the
|
|
12
|
+
// selected Standard Surface so opacity/alpha-clip stays identical to Forward
|
|
13
|
+
// and Deferred (depth32float is still the only render target).
|
|
8
14
|
//
|
|
9
15
|
// feat-20260613-csm-cascaded-shadow-maps M5 / w28: per-cascade
|
|
10
16
|
// lightViewProj selection. Each cascade pass writes a different
|
|
@@ -29,12 +35,25 @@
|
|
|
29
35
|
|
|
30
36
|
struct VsInput {
|
|
31
37
|
@location(0) position : vec3<f32>,
|
|
38
|
+
@location(1) normal : vec3<f32>,
|
|
39
|
+
@location(2) uv : vec2<f32>,
|
|
40
|
+
@location(3) tangent : vec4<f32>,
|
|
32
41
|
#if SKINNING_DISABLED == false
|
|
33
42
|
@location(4) skinIndex : vec4<u32>,
|
|
34
43
|
@location(5) skinWeight : vec4<f32>,
|
|
35
44
|
#endif
|
|
36
45
|
};
|
|
37
46
|
|
|
47
|
+
struct VsOut {
|
|
48
|
+
@builtin(position) clip : vec4<f32>,
|
|
49
|
+
@location(0) positionOS : vec3<f32>,
|
|
50
|
+
@location(1) positionWS : vec3<f32>,
|
|
51
|
+
@location(2) normalWS : vec3<f32>,
|
|
52
|
+
@location(3) tangentWS : vec4<f32>,
|
|
53
|
+
@location(4) surfaceUv : vec2<f32>,
|
|
54
|
+
@location(5) vertexColor : vec4<f32>,
|
|
55
|
+
};
|
|
56
|
+
|
|
38
57
|
#if SKINNING_DISABLED == false
|
|
39
58
|
#if STORAGE_BUFFER_AVAILABLE == true
|
|
40
59
|
@group(2) @binding(1) var<storage, read> palette : array<mat4x4<f32>>;
|
|
@@ -53,7 +72,7 @@ fn _cascadeLightViewProj(layer : u32) -> mat4x4<f32> {
|
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
@vertex
|
|
56
|
-
fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) ->
|
|
75
|
+
fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) -> VsOut {
|
|
57
76
|
#if SKINNING_DISABLED == false
|
|
58
77
|
let skinMatrix = palette[in.skinIndex.x] * in.skinWeight.x +
|
|
59
78
|
palette[in.skinIndex.y] * in.skinWeight.y +
|
|
@@ -63,8 +82,14 @@ fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) -> @builtin(positio
|
|
|
63
82
|
// produce world-space positions. Applying meshes[0].worldFromLocal again
|
|
64
83
|
// would double-transform a parented skinned entity.
|
|
65
84
|
let worldPos = skinMatrix * vec4<f32>(in.position, 1.0);
|
|
85
|
+
let worldNormal = normalize((skinMatrix * vec4<f32>(in.normal, 0.0)).xyz);
|
|
86
|
+
let worldTangent = normalize((skinMatrix * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
|
|
66
87
|
#else
|
|
67
|
-
let
|
|
88
|
+
let instanceLocal = instances[idx].localFromInstance;
|
|
89
|
+
let worldMatrix = meshes[0].worldFromLocal * instanceLocal;
|
|
90
|
+
let worldPos = worldMatrix * vec4<f32>(in.position, 1.0);
|
|
91
|
+
let worldNormal = normalize((worldMatrix * vec4<f32>(in.normal, 0.0)).xyz);
|
|
92
|
+
let worldTangent = normalize((worldMatrix * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
|
|
68
93
|
#endif
|
|
69
94
|
// feat-20260625-spot-light-shadow-mapping M2 / w10 (D-1): spot shadow passes
|
|
70
95
|
// set `isSpot = 1u` and write their perspective matrix into
|
|
@@ -72,8 +97,57 @@ fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) -> @builtin(positio
|
|
|
72
97
|
// `view.lightViewProj_A..D` via `index`. Routing on the discriminant keeps
|
|
73
98
|
// the spot matrix out of the directional View UBO (no same-frame contention).
|
|
74
99
|
if (shadowCasterCascade.isSpot == 1u) {
|
|
75
|
-
|
|
100
|
+
var out : VsOut;
|
|
101
|
+
out.clip = shadowCasterCascade.spotLightViewProj * worldPos;
|
|
102
|
+
out.positionOS = in.position;
|
|
103
|
+
out.positionWS = worldPos.xyz;
|
|
104
|
+
out.normalWS = worldNormal;
|
|
105
|
+
out.tangentWS = vec4<f32>(worldTangent, in.tangent.w);
|
|
106
|
+
out.surfaceUv = in.uv;
|
|
107
|
+
out.vertexColor = vec4<f32>(1.0);
|
|
108
|
+
return out;
|
|
76
109
|
}
|
|
77
110
|
let lvp = _cascadeLightViewProj(shadowCasterCascade.index);
|
|
78
|
-
|
|
111
|
+
var out : VsOut;
|
|
112
|
+
out.clip = lvp * worldPos;
|
|
113
|
+
out.positionOS = in.position;
|
|
114
|
+
out.positionWS = worldPos.xyz;
|
|
115
|
+
out.normalWS = worldNormal;
|
|
116
|
+
out.tangentWS = vec4<f32>(worldTangent, in.tangent.w);
|
|
117
|
+
out.surfaceUv = in.uv;
|
|
118
|
+
out.vertexColor = vec4<f32>(1.0);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fn evaluateShadowSurface(in : VsOut, frontFacing : bool) -> SurfaceData {
|
|
123
|
+
let viewDirectionWS = normalize(view.cameraPos - in.positionWS);
|
|
124
|
+
return evaluate_surface(SurfaceInput(
|
|
125
|
+
in.positionOS,
|
|
126
|
+
in.positionWS,
|
|
127
|
+
in.normalWS,
|
|
128
|
+
in.tangentWS,
|
|
129
|
+
viewDirectionWS,
|
|
130
|
+
in.surfaceUv,
|
|
131
|
+
in.surfaceUv,
|
|
132
|
+
in.vertexColor,
|
|
133
|
+
frontFacing,
|
|
134
|
+
));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn alphaTestShadowSurface(surface : SurfaceData) {
|
|
138
|
+
if (surface.alphaClipThreshold > 0.0 && surface.opacity <= surface.alphaClipThreshold) {
|
|
139
|
+
discard;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The depth-only path evaluates the selected Surface so alpha-clip ownership
|
|
144
|
+
// remains shared with Forward and Deferred without mutating vertex data.
|
|
145
|
+
@fragment
|
|
146
|
+
fn fs_shadow(in : VsOut, @builtin(front_facing) frontFacing : bool) {
|
|
147
|
+
alphaTestShadowSurface(evaluateShadowSurface(in, frontFacing));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@fragment
|
|
151
|
+
fn fs_main(in : VsOut, @builtin(front_facing) frontFacing : bool) {
|
|
152
|
+
alphaTestShadowSurface(evaluateShadowSurface(in, frontFacing));
|
|
79
153
|
}
|
package/src/unlit.wgsl
CHANGED
|
@@ -111,6 +111,18 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
|
|
111
111
|
return vec4<f32>(material.baseColor.rgb * texSample.rgb * vertexColor.rgb, alpha);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// Depth-only shadow variant. Keep alpha clipping identical to the color path,
|
|
115
|
+
// but return no color target because the shadow pass has a depth attachment only.
|
|
116
|
+
@fragment
|
|
117
|
+
fn fs_shadow(in : VsOut) {
|
|
118
|
+
let texSample = sampleMaterialTextureLinear(baseColorTexture, baseColorSampler, in.uv, material.baseColorTextureCoordinatesMetadata.zw);
|
|
119
|
+
let vertexColor = materialVertexColor(in);
|
|
120
|
+
let alpha = material.baseColor.a * texSample.a * vertexColor.a;
|
|
121
|
+
if (material.alphaCutoff > 0.0 && alpha < material.alphaCutoff) {
|
|
122
|
+
discard;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
114
126
|
struct TemporalVsOut {
|
|
115
127
|
@builtin(position) clip : vec4<f32>,
|
|
116
128
|
@location(0) uv : vec2<f32>,
|