@forgeax/engine-runtime 0.1.25 → 0.1.26
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/package.json +30 -30
- package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +10 -6
- package/src/__tests__/fixtures/material-programs/center-right.wgsl +15 -0
- package/src/__tests__/fixtures/material-programs/left.wgsl +9 -0
- package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +4 -1
- package/src/__tests__/loader-extract-graceful.test.ts +7 -6
- package/src/__tests__/loader-registry.cases.unit.test.ts +8 -15
- package/src/__tests__/material-programs.browser.test.ts +145 -0
- package/src/__tests__/materials.unit.test.ts +2 -2
- package/src/__tests__/pipeline-cache-keying.unit.test.ts +12 -0
- package/src/__tests__/pipeline-spec.test.ts +14 -0
- package/src/__tests__/record-worldid-isolation.test.ts +0 -1
- package/src/__tests__/render-system-extract.test.ts +33 -2
- package/src/__tests__/surface-standard-pipeline.browser.test.ts +8 -5
- package/src/__tests__/weapon-spirit-material.browser.test.ts +249 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/engine-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -26,37 +26,37 @@
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@forgeax/engine-animation": "0.1.
|
|
30
|
-
"@forgeax/engine-assets-runtime": "0.1.
|
|
31
|
-
"@forgeax/engine-audio-webaudio": "0.1.
|
|
32
|
-
"@forgeax/engine-codec": "0.1.
|
|
33
|
-
"@forgeax/engine-debug-draw": "0.1.
|
|
34
|
-
"@forgeax/engine-ecs": "0.1.
|
|
35
|
-
"@forgeax/engine-font": "0.1.
|
|
36
|
-
"@forgeax/engine-geometry": "0.1.
|
|
37
|
-
"@forgeax/engine-graphics-extras": "0.1.
|
|
38
|
-
"@forgeax/engine-image": "0.1.
|
|
39
|
-
"@forgeax/engine-input": "0.1.
|
|
40
|
-
"@forgeax/engine-math": "0.1.
|
|
41
|
-
"@forgeax/engine-pack": "0.1.
|
|
42
|
-
"@forgeax/engine-profiler": "0.1.
|
|
43
|
-
"@forgeax/engine-render": "0.1.
|
|
44
|
-
"@forgeax/engine-render-graph": "0.1.
|
|
45
|
-
"@forgeax/engine-rhi": "0.1.
|
|
46
|
-
"@forgeax/engine-rhi-null": "0.1.
|
|
47
|
-
"@forgeax/engine-rhi-webgpu": "0.1.
|
|
48
|
-
"@forgeax/engine-rhi-wgpu": "0.1.
|
|
49
|
-
"@forgeax/engine-scene": "0.1.
|
|
50
|
-
"@forgeax/engine-shader": "0.1.
|
|
51
|
-
"@forgeax/engine-skinning": "0.1.
|
|
52
|
-
"@forgeax/engine-types": "0.1.
|
|
53
|
-
"@forgeax/engine-vfx": "0.1.
|
|
29
|
+
"@forgeax/engine-animation": "0.1.26",
|
|
30
|
+
"@forgeax/engine-assets-runtime": "0.1.26",
|
|
31
|
+
"@forgeax/engine-audio-webaudio": "0.1.26",
|
|
32
|
+
"@forgeax/engine-codec": "0.1.26",
|
|
33
|
+
"@forgeax/engine-debug-draw": "0.1.26",
|
|
34
|
+
"@forgeax/engine-ecs": "0.1.26",
|
|
35
|
+
"@forgeax/engine-font": "0.1.26",
|
|
36
|
+
"@forgeax/engine-geometry": "0.1.26",
|
|
37
|
+
"@forgeax/engine-graphics-extras": "0.1.26",
|
|
38
|
+
"@forgeax/engine-image": "0.1.26",
|
|
39
|
+
"@forgeax/engine-input": "0.1.26",
|
|
40
|
+
"@forgeax/engine-math": "0.1.26",
|
|
41
|
+
"@forgeax/engine-pack": "0.1.26",
|
|
42
|
+
"@forgeax/engine-profiler": "0.1.26",
|
|
43
|
+
"@forgeax/engine-render": "0.1.26",
|
|
44
|
+
"@forgeax/engine-render-graph": "0.1.26",
|
|
45
|
+
"@forgeax/engine-rhi": "0.1.26",
|
|
46
|
+
"@forgeax/engine-rhi-null": "0.1.26",
|
|
47
|
+
"@forgeax/engine-rhi-webgpu": "0.1.26",
|
|
48
|
+
"@forgeax/engine-rhi-wgpu": "0.1.26",
|
|
49
|
+
"@forgeax/engine-scene": "0.1.26",
|
|
50
|
+
"@forgeax/engine-shader": "0.1.26",
|
|
51
|
+
"@forgeax/engine-skinning": "0.1.26",
|
|
52
|
+
"@forgeax/engine-types": "0.1.26",
|
|
53
|
+
"@forgeax/engine-vfx": "0.1.26"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@forgeax/engine-import": "0.1.
|
|
57
|
-
"@forgeax/engine-naga": "0.1.
|
|
58
|
-
"@forgeax/engine-state": "0.1.
|
|
59
|
-
"@forgeax/engine-vite-plugin-shader": "0.1.
|
|
56
|
+
"@forgeax/engine-import": "0.1.26",
|
|
57
|
+
"@forgeax/engine-naga": "0.1.26",
|
|
58
|
+
"@forgeax/engine-state": "0.1.26",
|
|
59
|
+
"@forgeax/engine-vite-plugin-shader": "0.1.26",
|
|
60
60
|
"@types/node": "^20.14.0"
|
|
61
61
|
},
|
|
62
62
|
"forgeax": {
|
|
@@ -5,7 +5,9 @@ import { describe, expect, it } from 'vitest';
|
|
|
5
5
|
type CookedRecord = {
|
|
6
6
|
readonly specializationKey: string;
|
|
7
7
|
readonly artifactDigest: string;
|
|
8
|
-
readonly
|
|
8
|
+
readonly programs: readonly {
|
|
9
|
+
readonly artifact: { readonly digest: string; readonly bytes: readonly number[] };
|
|
10
|
+
}[];
|
|
9
11
|
readonly resolved?: {
|
|
10
12
|
readonly values?: unknown;
|
|
11
13
|
};
|
|
@@ -54,9 +56,9 @@ describe('custom-shader cooked MaterialAsset fixture', () => {
|
|
|
54
56
|
const cookedRecords = [root?.payload.cooked, derived?.payload.cooked];
|
|
55
57
|
for (const cooked of cookedRecords) {
|
|
56
58
|
expect(cooked).toBeDefined();
|
|
57
|
-
expect(cooked?.artifact?.digest).toMatch(/^sha256:/);
|
|
58
|
-
expect(cooked?.
|
|
59
|
-
expect(cooked?.receipt?.schemaVersion).toBe('material-cook/
|
|
59
|
+
expect(cooked?.programs?.[0]?.artifact?.digest).toMatch(/^sha256:/);
|
|
60
|
+
expect(cooked?.artifactDigest).toMatch(/^sha256:/);
|
|
61
|
+
expect(cooked?.receipt?.schemaVersion).toBe('material-cook/4');
|
|
60
62
|
expect(cooked?.receipt?.identity?.layoutIdentity).toMatch(/^sha256-/);
|
|
61
63
|
expect(cooked?.receipt?.identity?.programIdentity).toMatch(/^sha256:/);
|
|
62
64
|
expect(cooked?.receipt?.identity?.pipelineIdentity).toMatch(/^sha256:/);
|
|
@@ -67,7 +69,9 @@ describe('custom-shader cooked MaterialAsset fixture', () => {
|
|
|
67
69
|
root?.payload.cooked?.specializationKey,
|
|
68
70
|
);
|
|
69
71
|
expect(derived?.payload.cooked?.artifactDigest).toBe(root?.payload.cooked?.artifactDigest);
|
|
70
|
-
expect(derived?.payload.cooked?.artifact?.bytes).toEqual(
|
|
72
|
+
expect(derived?.payload.cooked?.programs?.[0]?.artifact?.bytes).toEqual(
|
|
73
|
+
root?.payload.cooked?.programs?.[0]?.artifact?.bytes,
|
|
74
|
+
);
|
|
71
75
|
expect(root?.payload.cooked?.resolved?.values).toMatchObject({
|
|
72
76
|
baseColor: [0.95, 0.45, 0.2, 1],
|
|
73
77
|
time: 0,
|
|
@@ -82,7 +86,7 @@ describe('custom-shader cooked MaterialAsset fixture', () => {
|
|
|
82
86
|
|
|
83
87
|
it('keeps cooked layout identity and authored coordinate transforms', () => {
|
|
84
88
|
const cooked = root?.payload.cooked;
|
|
85
|
-
expect(cooked?.receipt?.schemaVersion).toBe('material-cook/
|
|
89
|
+
expect(cooked?.receipt?.schemaVersion).toBe('material-cook/4');
|
|
86
90
|
expect(cooked?.receipt?.identity?.layoutIdentity).toMatch(/^sha256-/);
|
|
87
91
|
expect(cooked?.receipt?.identity?.programIdentity).toMatch(/^sha256:/);
|
|
88
92
|
expect(cooked?.receipt?.identity?.pipelineIdentity).toMatch(/^sha256:/);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#define_import_path regression::center_right
|
|
2
|
+
#import forgeax_material::parameters::{material}
|
|
3
|
+
struct Input { @location(0) position: vec3<f32> }
|
|
4
|
+
@vertex fn vs_main(input: Input) -> @builtin(position) vec4<f32> {
|
|
5
|
+
return vec4<f32>(input.position.xy * 0.4, 0.5, 1.0);
|
|
6
|
+
}
|
|
7
|
+
@vertex fn vs_right(input: Input) -> @builtin(position) vec4<f32> {
|
|
8
|
+
return vec4<f32>(input.position.xy * 0.4 + vec2<f32>(0.6, 0.0), 0.5, 1.0);
|
|
9
|
+
}
|
|
10
|
+
@fragment fn fs_main() -> @location(0) vec4<f32> {
|
|
11
|
+
return vec4<f32>(0.0, material.strength, 0.0, 1.0);
|
|
12
|
+
}
|
|
13
|
+
@fragment fn fs_blue() -> @location(0) vec4<f32> {
|
|
14
|
+
return vec4<f32>(0.0, 0.0, material.strength, 1.0);
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#define_import_path regression::left
|
|
2
|
+
#import forgeax_material::parameters::{material}
|
|
3
|
+
struct Input { @location(0) position: vec3<f32> }
|
|
4
|
+
@vertex fn vs_main(input: Input) -> @builtin(position) vec4<f32> {
|
|
5
|
+
return vec4<f32>(input.position.xy * 0.4 + vec2<f32>(-0.6, 0.0), 0.5, 1.0);
|
|
6
|
+
}
|
|
7
|
+
@fragment fn fs_main() -> @location(0) vec4<f32> {
|
|
8
|
+
return vec4<f32>(material.strength, 0.0, 0.0, 1.0);
|
|
9
|
+
}
|
|
@@ -45,6 +45,9 @@ function matrices(x: number, count = 1): Float32Array {
|
|
|
45
45
|
return result;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Coverage instrumentation plus a contended heavy runner can make the
|
|
49
|
+
// 20,000-instance recovery case exceed the normal one-minute budget even
|
|
50
|
+
// though the operation remains bounded and completes quickly uninstrumented.
|
|
48
51
|
it.each([
|
|
49
52
|
1, 129, 20000,
|
|
50
53
|
])('renders %i World instances after replacing and recovering the Renderer', async (count) => {
|
|
@@ -158,4 +161,4 @@ it.each([
|
|
|
158
161
|
expect(retained[(count - 1) * 16 + 12]).toBe(finalX);
|
|
159
162
|
expect(b.inspect().instanceCollections[0]?.count).toBe(count);
|
|
160
163
|
await b.dispose();
|
|
161
|
-
},
|
|
164
|
+
}, 120_000);
|
|
@@ -99,24 +99,25 @@ describe('loader-extract graceful handoff (M4 w24)', () => {
|
|
|
99
99
|
expect('baseColorTexture' in pv).toBe(false);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
it('(3) shader NOT registered (R-4 cross-worktree) -> graceful
|
|
103
|
-
// No paramSchema lookup available
|
|
104
|
-
//
|
|
102
|
+
it('(3) shader NOT registered (R-4 cross-worktree) -> graceful structured texture fallback', () => {
|
|
103
|
+
// No paramSchema lookup is available, so the loader cannot classify bare
|
|
104
|
+
// integers as texture refs. Explicit structured texture values remain
|
|
105
|
+
// resolvable across worktrees without guessing at scalar fields.
|
|
105
106
|
const ctx = makeCtx({
|
|
106
107
|
shaderTextureFieldNames: () => undefined,
|
|
107
108
|
});
|
|
108
109
|
const out = materialLoader.load(
|
|
109
110
|
{
|
|
110
111
|
passes: [{ program: { module: 'forgeax::user-defined' } }],
|
|
111
|
-
values: { customTexture: 0 },
|
|
112
|
+
values: { customTexture: { texture: 0 } },
|
|
112
113
|
},
|
|
113
114
|
['tex-guid'],
|
|
114
115
|
ctx,
|
|
115
116
|
);
|
|
116
117
|
expect(out).toMatchObject({ kind: 'material' });
|
|
117
118
|
const pv = (out as { values: Record<string, unknown> }).values;
|
|
118
|
-
//
|
|
119
|
-
expect(pv.customTexture).
|
|
119
|
+
// Explicit structured refs resolve to the enclosing refs[] GUID.
|
|
120
|
+
expect(pv.customTexture).toEqual({ texture: 'tex-guid' });
|
|
120
121
|
});
|
|
121
122
|
|
|
122
123
|
it('(4) shader registered + scalar paramValue with refs[] index in range -> NOT misclassified', () => {
|
|
@@ -546,29 +546,22 @@ type __MergedKeep =
|
|
|
546
546
|
expect(out).toMatchObject({ kind: 'material', parentGuid: 'parent-guid' });
|
|
547
547
|
});
|
|
548
548
|
|
|
549
|
-
it('materialLoader resolves heightTexture
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
// from getMaterialShaderTextureFieldNames), the loader falls back to
|
|
554
|
-
// a graceful "try every int paramValue in [0, refs.length)" walk and
|
|
555
|
-
// resolves heightTexture (any field name) to the refs[] entry.
|
|
556
|
-
//
|
|
557
|
-
// feat-20260614 M8 (D-19): the embedded sub-asset ref is stored as its
|
|
558
|
-
// GUID string verbatim (dash-form) -- the ECS/render side resolves the
|
|
559
|
-
// GUID -> column handle at use time via world.allocSharedRef; the loader
|
|
560
|
-
// no longer mints a numeric handle (resolveRefSync is deleted). Mirrors
|
|
561
|
-
// the sibling `parentGuid` contract (refs index -> GUID string).
|
|
549
|
+
it('materialLoader resolves an explicit heightTexture ref index to its GUID (M4 / w22, D-19)', () => {
|
|
550
|
+
// Texture references use the explicit `{ texture: refsIndex }` wire
|
|
551
|
+
// shape. This remains safe when the shader is not registered because
|
|
552
|
+
// the loader does not guess whether a bare integer is a scalar.
|
|
562
553
|
const out = materialLoader.load(
|
|
563
554
|
{
|
|
564
555
|
passes: [{ shader: 'test' }],
|
|
565
|
-
values: { heightTexture: 0 },
|
|
556
|
+
values: { heightTexture: { texture: 0 } },
|
|
566
557
|
},
|
|
567
558
|
['height-guid'],
|
|
568
559
|
mockCtx(),
|
|
569
560
|
);
|
|
570
561
|
expect(out).toMatchObject({ kind: 'material' });
|
|
571
|
-
expect((out as Record<string, unknown>)?.values?.heightTexture).
|
|
562
|
+
expect((out as Record<string, unknown>)?.values?.heightTexture).toEqual({
|
|
563
|
+
texture: 'height-guid',
|
|
564
|
+
});
|
|
572
565
|
});
|
|
573
566
|
|
|
574
567
|
it('materialLoader returns normally when values has no heightTexture field', () => {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
|
|
2
|
+
import { World } from '@forgeax/engine-ecs';
|
|
3
|
+
import { Camera, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
|
|
4
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
5
|
+
import type { MaterialAsset } from '@forgeax/engine-types';
|
|
6
|
+
import { expect, it } from 'vitest';
|
|
7
|
+
import { page } from 'vitest/browser';
|
|
8
|
+
import { constructRuntimeRendererHost } from '../renderer-host';
|
|
9
|
+
import { drawPublished } from './draw-published';
|
|
10
|
+
|
|
11
|
+
it.each([
|
|
12
|
+
false,
|
|
13
|
+
true,
|
|
14
|
+
])('draws every selected material Pass with mixed blending=%s', async (mixed) => {
|
|
15
|
+
if (!navigator.gpu) throw new Error('WebGPU is required');
|
|
16
|
+
const canvas = document.createElement('canvas');
|
|
17
|
+
canvas.width = 384;
|
|
18
|
+
canvas.height = 256;
|
|
19
|
+
document.body.append(canvas);
|
|
20
|
+
const requestAdapter = navigator.gpu.requestAdapter.bind(navigator.gpu);
|
|
21
|
+
const validation: string[] = [];
|
|
22
|
+
const entries: string[] = [];
|
|
23
|
+
const devices: GPUDevice[] = [];
|
|
24
|
+
navigator.gpu.requestAdapter = async (options) => {
|
|
25
|
+
const adapter = await requestAdapter(options);
|
|
26
|
+
if (adapter === null) return adapter;
|
|
27
|
+
const requestDevice = adapter.requestDevice.bind(adapter);
|
|
28
|
+
adapter.requestDevice = async (descriptor) => {
|
|
29
|
+
const device = await requestDevice(descriptor);
|
|
30
|
+
devices.push(device);
|
|
31
|
+
device.addEventListener('uncapturederror', (event) => validation.push(event.error.message));
|
|
32
|
+
const record = (descriptor: GPURenderPipelineDescriptor) =>
|
|
33
|
+
entries.push(`${descriptor.vertex.entryPoint}/${descriptor.fragment?.entryPoint}`);
|
|
34
|
+
const create = device.createRenderPipeline.bind(device);
|
|
35
|
+
device.createRenderPipeline = (descriptor) => {
|
|
36
|
+
record(descriptor);
|
|
37
|
+
return create(descriptor);
|
|
38
|
+
};
|
|
39
|
+
const createAsync = device.createRenderPipelineAsync.bind(device);
|
|
40
|
+
device.createRenderPipelineAsync = async (descriptor) => {
|
|
41
|
+
record(descriptor);
|
|
42
|
+
return createAsync(descriptor);
|
|
43
|
+
};
|
|
44
|
+
return device;
|
|
45
|
+
};
|
|
46
|
+
return adapter;
|
|
47
|
+
};
|
|
48
|
+
const host = await constructRuntimeRendererHost(
|
|
49
|
+
canvas,
|
|
50
|
+
{},
|
|
51
|
+
{ shaderManifestUrl: '/shaders/manifest.json' },
|
|
52
|
+
);
|
|
53
|
+
if (!host.ok) {
|
|
54
|
+
navigator.gpu.requestAdapter = requestAdapter;
|
|
55
|
+
canvas.remove();
|
|
56
|
+
throw host.error;
|
|
57
|
+
}
|
|
58
|
+
const errors: string[] = [];
|
|
59
|
+
const unsubscribe = host.value.renderer.subscribe((event) => {
|
|
60
|
+
if (event.kind === 'error') errors.push(event.error.message);
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
const assets = host.value.assets;
|
|
64
|
+
assets.configurePackIndex(
|
|
65
|
+
mixed ? '/__material-programs-mixed/pack-index.json' : '/__material-programs/pack-index.json',
|
|
66
|
+
);
|
|
67
|
+
const material = (
|
|
68
|
+
await assets.loadByGuid<MaterialAsset>(
|
|
69
|
+
assets.parseGuid('8f50ae65-6e0a-40b4-8949-b47006edab90'),
|
|
70
|
+
)
|
|
71
|
+
).unwrap();
|
|
72
|
+
const projection = assets.getMaterialProjectionForPayload(material);
|
|
73
|
+
expect(
|
|
74
|
+
new Set(
|
|
75
|
+
projection?.passes.flatMap((pass) =>
|
|
76
|
+
pass.programs.map((program) => program.specializationKey),
|
|
77
|
+
),
|
|
78
|
+
).size,
|
|
79
|
+
).toBe(2);
|
|
80
|
+
const world = new World();
|
|
81
|
+
const handle = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', material);
|
|
82
|
+
world
|
|
83
|
+
.spawn(
|
|
84
|
+
{ component: Transform, data: { pos: [0, 0, 3], scale: [1, 1, 1], quat: [0, 0, 0, 1] } },
|
|
85
|
+
{
|
|
86
|
+
component: Camera,
|
|
87
|
+
data: {
|
|
88
|
+
fov: Math.PI / 4,
|
|
89
|
+
aspect: 1.5,
|
|
90
|
+
near: 0.1,
|
|
91
|
+
far: 100,
|
|
92
|
+
clearColor: [0.02, 0.02, 0.02, 1],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
.unwrap();
|
|
97
|
+
world
|
|
98
|
+
.spawn(
|
|
99
|
+
{ component: Transform, data: { pos: [0, 0, 0], scale: [1, 1, 1], quat: [0, 0, 0, 1] } },
|
|
100
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
101
|
+
{ component: MeshRenderer, data: { materials: [handle] } },
|
|
102
|
+
)
|
|
103
|
+
.unwrap();
|
|
104
|
+
for (let frame = 0; frame < 40; frame++) {
|
|
105
|
+
const receipt = drawPublished(host.value.renderer, world);
|
|
106
|
+
if (!receipt.ok) throw receipt.error;
|
|
107
|
+
const completed = await receipt.value.completed;
|
|
108
|
+
if (!completed.ok) throw completed.error;
|
|
109
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
110
|
+
}
|
|
111
|
+
await Promise.all(devices.map((device) => device.queue.onSubmittedWorkDone()));
|
|
112
|
+
const shot = await page.elementLocator(canvas).screenshot({ base64: true });
|
|
113
|
+
const bytes = Uint8Array.from(atob(typeof shot === 'string' ? shot : shot.base64), (char) =>
|
|
114
|
+
char.charCodeAt(0),
|
|
115
|
+
);
|
|
116
|
+
const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
117
|
+
const surface = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
118
|
+
const ctx = surface.getContext('2d');
|
|
119
|
+
if (ctx === null) throw new Error('pixel readback unavailable');
|
|
120
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
121
|
+
bitmap.close();
|
|
122
|
+
const pixels = ctx.getImageData(0, 0, surface.width, surface.height);
|
|
123
|
+
for (const [fraction, channel] of [
|
|
124
|
+
[0.2, 0],
|
|
125
|
+
[0.5, 1],
|
|
126
|
+
[0.8, 2],
|
|
127
|
+
] as const) {
|
|
128
|
+
const offset =
|
|
129
|
+
(Math.floor(surface.height * 0.5) * surface.width + Math.floor(surface.width * fraction)) *
|
|
130
|
+
4;
|
|
131
|
+
const color = [...pixels.data.slice(offset, offset + 3)];
|
|
132
|
+
expect(color[channel], `Pass color at ${fraction}: ${color}`).toBeGreaterThan(200);
|
|
133
|
+
expect(color.filter((_, index) => index !== channel).every((value) => value < 40)).toBe(true);
|
|
134
|
+
}
|
|
135
|
+
expect(entries).toContain('vs_right/fs_blue');
|
|
136
|
+
expect(validation).toEqual([]);
|
|
137
|
+
expect(errors).toEqual([]);
|
|
138
|
+
} finally {
|
|
139
|
+
unsubscribe();
|
|
140
|
+
host.value.renderer.dispose();
|
|
141
|
+
for (const device of devices) device.destroy();
|
|
142
|
+
navigator.gpu.requestAdapter = requestAdapter;
|
|
143
|
+
canvas.remove();
|
|
144
|
+
}
|
|
145
|
+
}, 60_000);
|
|
@@ -80,10 +80,10 @@ describe('Materials.standard multi-pass (w14)', () => {
|
|
|
80
80
|
expect(shadowPass).toBeDefined();
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it('shadow-caster pass uses
|
|
83
|
+
it('shadow-caster pass uses the canonical depth-only shader', () => {
|
|
84
84
|
const mat = Materials.standard({ baseColor: [0.5, 0.5, 0.5, 1] });
|
|
85
85
|
const shadowPass = mat.passes?.find((p) => p.name === ('shadow-caster' as PassKind));
|
|
86
|
-
expect(shadowPass?.program.module).toBe('
|
|
86
|
+
expect(shadowPass?.program.module).toBe('forgeax::default-shadow-caster');
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
it('castShadow=false suppresses the shadow-caster pass', () => {
|
|
@@ -53,6 +53,18 @@ function specOf(
|
|
|
53
53
|
describe('cacheKeyOf passKind dimension', () => {
|
|
54
54
|
const SHADER_ID = 'forgeax::default-unlit';
|
|
55
55
|
|
|
56
|
+
it('separates authored entry selections within one published module', () => {
|
|
57
|
+
const base = specOf('custom::two-entries', false);
|
|
58
|
+
const first = {
|
|
59
|
+
...base,
|
|
60
|
+
shader: { ...base.shader, vertexEntry: 'vs_main', fragmentEntry: 'fs_main' },
|
|
61
|
+
};
|
|
62
|
+
const second = { ...base, shader: { ...first.shader, fragmentEntry: 'fs_readability' } };
|
|
63
|
+
const shadow = { ...base, shader: { ...first.shader, vertexEntry: 'vs_shadow' } };
|
|
64
|
+
expect(new Set([first, second, shadow].map(cacheKeyOf)).size).toBe(3);
|
|
65
|
+
expect(cacheKeyOf({ ...first })).toBe(cacheKeyOf(first));
|
|
66
|
+
});
|
|
67
|
+
|
|
56
68
|
it('same shaderId + entries produce different keys for forward vs shadow-caster', () => {
|
|
57
69
|
const forwardKey = cacheKeyOf(specOf(SHADER_ID, false, 'forward'));
|
|
58
70
|
const shadowKey = cacheKeyOf(specOf(SHADER_ID, false, 'shadow-caster'));
|
|
@@ -490,6 +490,20 @@ describe('buildPipelineDescriptor (M2-T3-TEST)', () => {
|
|
|
490
490
|
};
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
it('uses the entry selection carried by the pipeline spec', () => {
|
|
494
|
+
const base = makeForwardSpec();
|
|
495
|
+
const spec: PipelineSpec = {
|
|
496
|
+
...base,
|
|
497
|
+
shader: { ...base.shader, vertexEntry: 'vs_custom', fragmentEntry: 'fs_readability' },
|
|
498
|
+
};
|
|
499
|
+
const descriptor = buildPipelineDescriptor(spec, {
|
|
500
|
+
vertex: mockVertexModule,
|
|
501
|
+
fragment: mockFragmentModule,
|
|
502
|
+
});
|
|
503
|
+
expect(descriptor.vertex.entryPoint).toBe('vs_custom');
|
|
504
|
+
expect(descriptor.fragment?.entryPoint).toBe('fs_readability');
|
|
505
|
+
});
|
|
506
|
+
|
|
493
507
|
function makeShadowCasterSpec(sampleCount: 1 | 4 = 1): PipelineSpec {
|
|
494
508
|
return {
|
|
495
509
|
shader: { id: 'forgeax::shadow-caster', passKind: 'shadow-caster', variantSet: undefined },
|
|
@@ -103,7 +103,6 @@ const SPRITE_FILE = fileURLToPath(
|
|
|
103
103
|
describe('production-path shared instance bind-group resolver', () => {
|
|
104
104
|
it('sprite identity and sprite-instance paths use the shared resolver', () => {
|
|
105
105
|
const src = readFileSync(SPRITE_FILE, 'utf8');
|
|
106
|
-
expect(src).toContain('const identityInstBg = resolveGeometryInstancesBindGroup(');
|
|
107
106
|
expect(src).toContain('const spriteInstBg = resolveGeometryInstancesBindGroup(');
|
|
108
107
|
expect(src).toContain('const spriteInstancesBg = resolveGeometryInstancesBindGroup(');
|
|
109
108
|
});
|
|
@@ -185,6 +185,17 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
185
185
|
const mesh = registerMesh(world);
|
|
186
186
|
const materialGuid = '019ffa97-4000-7000-8000-000000000101';
|
|
187
187
|
const cookedShaderId = 'sha256:rusted-iron-cooked-specialization';
|
|
188
|
+
const cookedShadowShaderId = 'sha256:rusted-iron-cooked-shadow-specialization';
|
|
189
|
+
const materialContext = {
|
|
190
|
+
backend: 'webgpu' as const,
|
|
191
|
+
capability: 'storage-buffer' as const,
|
|
192
|
+
pipeline: 'forward' as const,
|
|
193
|
+
geometry: 'mesh' as const,
|
|
194
|
+
pass: 'forward' as const,
|
|
195
|
+
profile: 'forgeax-material-wgsl-v1' as const,
|
|
196
|
+
toolchain: 'naga-oil' as const,
|
|
197
|
+
instrumentation: 'none' as const,
|
|
198
|
+
};
|
|
188
199
|
const material = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
|
|
189
200
|
kind: 'material',
|
|
190
201
|
passes: [
|
|
@@ -223,6 +234,26 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
223
234
|
module: 'forgeax_material::standard',
|
|
224
235
|
moduleSlots: { surface: 'game_3d::rusted_iron_surface' },
|
|
225
236
|
artifactHash: 'sha256:rusted-iron-artifact',
|
|
237
|
+
programs: [
|
|
238
|
+
{
|
|
239
|
+
context: materialContext,
|
|
240
|
+
specializationKey: cookedShaderId,
|
|
241
|
+
artifactHash: 'sha256:rusted-iron-forward-artifact',
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: 'shadow-caster',
|
|
247
|
+
module: 'forgeax_material::standard',
|
|
248
|
+
moduleSlots: { surface: 'game_3d::rusted_iron_surface' },
|
|
249
|
+
artifactHash: 'sha256:rusted-iron-artifact',
|
|
250
|
+
programs: [
|
|
251
|
+
{
|
|
252
|
+
context: { ...materialContext, pass: 'shadow' as const },
|
|
253
|
+
specializationKey: cookedShadowShaderId,
|
|
254
|
+
artifactHash: 'sha256:rusted-iron-shadow-artifact',
|
|
255
|
+
},
|
|
256
|
+
],
|
|
226
257
|
},
|
|
227
258
|
],
|
|
228
259
|
runtimeValues: {},
|
|
@@ -236,7 +267,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
236
267
|
)
|
|
237
268
|
.unwrap();
|
|
238
269
|
|
|
239
|
-
const frame = extractFrame(world, prepareExtractContext(world, { assets }));
|
|
270
|
+
const frame = extractFrame(world, prepareExtractContext(world, { assets, materialContext }));
|
|
240
271
|
expect(frame.renderables[0]?.material.materialShaderId).toBe(cookedShaderId);
|
|
241
272
|
const materialDispatch = frame.dispatch.filter((entry) => entry.materialHandle === material);
|
|
242
273
|
expect(materialDispatch).toEqual(
|
|
@@ -247,7 +278,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
247
278
|
}),
|
|
248
279
|
expect.objectContaining({
|
|
249
280
|
tags: { LightMode: 'ShadowCaster' },
|
|
250
|
-
materialShaderId:
|
|
281
|
+
materialShaderId: cookedShadowShaderId,
|
|
251
282
|
}),
|
|
252
283
|
]),
|
|
253
284
|
);
|
|
@@ -233,11 +233,14 @@ describe('Standard Surface runtime Browser WebGPU provenance', () => {
|
|
|
233
233
|
const projection = assets.getMaterialProjectionForPayload(material);
|
|
234
234
|
if (surfaceCase.id.startsWith('custom-')) {
|
|
235
235
|
expect(projection?.specializationKey).toBeTruthy();
|
|
236
|
-
|
|
237
|
-
projection
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
).
|
|
236
|
+
const programKeys =
|
|
237
|
+
projection?.passes.flatMap((pass) =>
|
|
238
|
+
pass.programs.map((program) => program.specializationKey),
|
|
239
|
+
) ?? [];
|
|
240
|
+
expect(programKeys.length).toBeGreaterThan(0);
|
|
241
|
+
for (const specializationKey of new Set(programKeys)) {
|
|
242
|
+
expect(assets.getMaterialArtifact(specializationKey)).toBeDefined();
|
|
243
|
+
}
|
|
241
244
|
}
|
|
242
245
|
materials.push(material);
|
|
243
246
|
publications.push({ surfaceCase, row, publication, material });
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
|
|
2
|
+
import { World } from '@forgeax/engine-ecs';
|
|
3
|
+
import {
|
|
4
|
+
Camera,
|
|
5
|
+
DirectionalLight,
|
|
6
|
+
Materials,
|
|
7
|
+
MeshFilter,
|
|
8
|
+
MeshRenderer,
|
|
9
|
+
type Renderer,
|
|
10
|
+
} from '@forgeax/engine-render';
|
|
11
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
12
|
+
import type { MaterialAsset } from '@forgeax/engine-types';
|
|
13
|
+
import { expect, it } from 'vitest';
|
|
14
|
+
import { page } from 'vitest/browser';
|
|
15
|
+
import { constructRuntimeRendererHost } from '../renderer-host';
|
|
16
|
+
import { drawPublished } from './draw-published';
|
|
17
|
+
|
|
18
|
+
async function readPresentedPixels(canvas: HTMLCanvasElement) {
|
|
19
|
+
const shot = await page.elementLocator(canvas).screenshot({ base64: true });
|
|
20
|
+
const base64 = typeof shot === 'string' ? shot : shot.base64;
|
|
21
|
+
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
|
22
|
+
const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
23
|
+
const surface = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
24
|
+
const context = surface.getContext('2d');
|
|
25
|
+
if (context === null) throw new Error('2D readback is required for pixel assertions');
|
|
26
|
+
context.drawImage(bitmap, 0, 0);
|
|
27
|
+
bitmap.close();
|
|
28
|
+
return context.getImageData(0, 0, surface.width, surface.height);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
false,
|
|
33
|
+
true,
|
|
34
|
+
])('renders the ai-weapon-spirit child Toon through real WebGPU (Engine shadow: %s)', async (engineShadow) => {
|
|
35
|
+
if (!navigator.gpu) throw new Error('WebGPU is required for the material regression');
|
|
36
|
+
const canvas = document.createElement('canvas');
|
|
37
|
+
canvas.width = 384;
|
|
38
|
+
canvas.height = 256;
|
|
39
|
+
document.body.append(canvas);
|
|
40
|
+
const originalRequestAdapter = navigator.gpu.requestAdapter.bind(navigator.gpu);
|
|
41
|
+
const validationErrors: string[] = [];
|
|
42
|
+
const pipelineEntries: string[] = [];
|
|
43
|
+
const pipelineLabels: string[] = [];
|
|
44
|
+
const shaderSources = new WeakMap<GPUShaderModule, string>();
|
|
45
|
+
const shadowPipelineSources: string[] = [];
|
|
46
|
+
const devices: GPUDevice[] = [];
|
|
47
|
+
navigator.gpu.requestAdapter = async (options) => {
|
|
48
|
+
const adapter = await originalRequestAdapter(options);
|
|
49
|
+
if (adapter === null) return adapter;
|
|
50
|
+
const requestDevice = adapter.requestDevice.bind(adapter);
|
|
51
|
+
adapter.requestDevice = async (descriptor) => {
|
|
52
|
+
const device = await requestDevice(descriptor);
|
|
53
|
+
devices.push(device);
|
|
54
|
+
device.addEventListener('uncapturederror', (event) =>
|
|
55
|
+
validationErrors.push(event.error.message),
|
|
56
|
+
);
|
|
57
|
+
const createShaderModule = device.createShaderModule.bind(device);
|
|
58
|
+
device.createShaderModule = (descriptor) => {
|
|
59
|
+
const module = createShaderModule(descriptor);
|
|
60
|
+
shaderSources.set(module, descriptor.code);
|
|
61
|
+
return module;
|
|
62
|
+
};
|
|
63
|
+
const observeShadow = (descriptor: GPURenderPipelineDescriptor) => {
|
|
64
|
+
if (
|
|
65
|
+
descriptor.depthStencil?.format === 'depth32float' &&
|
|
66
|
+
Array.from(descriptor.fragment?.targets ?? []).length === 0
|
|
67
|
+
) {
|
|
68
|
+
const source = shaderSources.get(descriptor.vertex.module);
|
|
69
|
+
if (source !== undefined) shadowPipelineSources.push(source);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const createPipeline = device.createRenderPipeline.bind(device);
|
|
73
|
+
device.createRenderPipeline = (descriptor) => {
|
|
74
|
+
observeShadow(descriptor);
|
|
75
|
+
pipelineLabels.push(descriptor.label ?? '<unlabelled>');
|
|
76
|
+
pipelineEntries.push(descriptor.vertex.entryPoint ?? '<implicit>');
|
|
77
|
+
return createPipeline(descriptor);
|
|
78
|
+
};
|
|
79
|
+
const createPipelineAsync = device.createRenderPipelineAsync.bind(device);
|
|
80
|
+
device.createRenderPipelineAsync = async (descriptor) => {
|
|
81
|
+
observeShadow(descriptor);
|
|
82
|
+
pipelineLabels.push(descriptor.label ?? '<unlabelled>');
|
|
83
|
+
pipelineEntries.push(descriptor.vertex.entryPoint ?? '<implicit>');
|
|
84
|
+
return createPipelineAsync(descriptor);
|
|
85
|
+
};
|
|
86
|
+
return device;
|
|
87
|
+
};
|
|
88
|
+
return adapter;
|
|
89
|
+
};
|
|
90
|
+
let renderer: Renderer | undefined;
|
|
91
|
+
let unsubscribe: (() => void) | undefined;
|
|
92
|
+
try {
|
|
93
|
+
const host = await constructRuntimeRendererHost(
|
|
94
|
+
canvas,
|
|
95
|
+
{},
|
|
96
|
+
{ shaderManifestUrl: '/shaders/manifest.json' },
|
|
97
|
+
);
|
|
98
|
+
if (!host.ok) throw host.error;
|
|
99
|
+
renderer = host.value.renderer;
|
|
100
|
+
const errors: string[] = [];
|
|
101
|
+
unsubscribe = renderer.subscribe((event) => {
|
|
102
|
+
if (event.kind === 'error') errors.push(event.error.message);
|
|
103
|
+
});
|
|
104
|
+
const assets = host.value.assets;
|
|
105
|
+
assets.configurePackIndex(
|
|
106
|
+
engineShadow
|
|
107
|
+
? '/__weapon-spirit-engine-shadow/pack-index.json'
|
|
108
|
+
: '/__weapon-spirit/pack-index.json',
|
|
109
|
+
);
|
|
110
|
+
const child = await assets.loadByGuid<MaterialAsset>(
|
|
111
|
+
assets.parseGuid('4846fa5b-8f80-57c0-9cdc-e345102fdb6c'),
|
|
112
|
+
);
|
|
113
|
+
if (!child.ok) throw child.error;
|
|
114
|
+
const readiness = assets.getMaterialReadiness('4846fa5b-8f80-57c0-9cdc-e345102fdb6b');
|
|
115
|
+
expect(
|
|
116
|
+
readiness,
|
|
117
|
+
`root material must publish one complete Ready generation: ${JSON.stringify(readiness)}`,
|
|
118
|
+
).toMatchObject({ status: 'Ready' });
|
|
119
|
+
const projection = assets.getMaterialProjectionForPayload(child.value);
|
|
120
|
+
const shadowProgram = projection?.passes.find((pass) => pass.name === 'ShadowCaster')
|
|
121
|
+
?.programs[0];
|
|
122
|
+
if (shadowProgram === undefined) throw new Error('published shadow program missing');
|
|
123
|
+
const shadowArtifact = assets.getMaterialArtifact(shadowProgram.specializationKey);
|
|
124
|
+
if (shadowArtifact === undefined) throw new Error('installed shadow artifact missing');
|
|
125
|
+
const expectedShadowSource = new TextDecoder().decode(shadowArtifact.bytes);
|
|
126
|
+
expect(projection?.specializationKey).toBeTruthy();
|
|
127
|
+
expect(child.value.values).toMatchObject({
|
|
128
|
+
emissionStrength: 0,
|
|
129
|
+
pigmentStrength: 0,
|
|
130
|
+
surfaceMetallic: 0,
|
|
131
|
+
sideShade: 0.84,
|
|
132
|
+
});
|
|
133
|
+
const world = new World();
|
|
134
|
+
const toon = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', child.value);
|
|
135
|
+
const standard = world.allocSharedRef<'MaterialAsset', MaterialAsset>(
|
|
136
|
+
'MaterialAsset',
|
|
137
|
+
Materials.standard({ baseColor: [0.6, 0.6, 0.6, 1] }),
|
|
138
|
+
);
|
|
139
|
+
const transform = (
|
|
140
|
+
pos: readonly [number, number, number],
|
|
141
|
+
scale: readonly [number, number, number] = [1, 1, 1],
|
|
142
|
+
) => ({ pos, scale, quat: [0, 0, 0, 1] as const });
|
|
143
|
+
world
|
|
144
|
+
.spawn(
|
|
145
|
+
{ component: Transform, data: transform([0, 8, 18]) },
|
|
146
|
+
{
|
|
147
|
+
component: Camera,
|
|
148
|
+
data: {
|
|
149
|
+
fov: Math.PI / 4,
|
|
150
|
+
aspect: 1.5,
|
|
151
|
+
near: 0.1,
|
|
152
|
+
far: 100,
|
|
153
|
+
clearColor: [0.02, 0.02, 0.03, 1],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
.unwrap();
|
|
158
|
+
const light = world
|
|
159
|
+
.spawn({
|
|
160
|
+
component: DirectionalLight,
|
|
161
|
+
data: {
|
|
162
|
+
direction: [0.25, -1, -0.45],
|
|
163
|
+
color: [1, 1, 1],
|
|
164
|
+
intensity: 1,
|
|
165
|
+
castShadow: true,
|
|
166
|
+
mapSize: 512,
|
|
167
|
+
shadowDistance: 50,
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
.unwrap();
|
|
171
|
+
world
|
|
172
|
+
.spawn(
|
|
173
|
+
{ component: Transform, data: transform([0, 5, 5], [10, 0.1, 10]) },
|
|
174
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
175
|
+
{ component: MeshRenderer, data: { materials: [standard] } },
|
|
176
|
+
)
|
|
177
|
+
.unwrap();
|
|
178
|
+
for (const [index, material] of [toon, standard, toon].entries()) {
|
|
179
|
+
world
|
|
180
|
+
.spawn(
|
|
181
|
+
{ component: Transform, data: transform([(index - 1) * 2, 6, 5]) },
|
|
182
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
183
|
+
{ component: MeshRenderer, data: { materials: [material] } },
|
|
184
|
+
)
|
|
185
|
+
.unwrap();
|
|
186
|
+
}
|
|
187
|
+
const drawFrames = async (count: number) => {
|
|
188
|
+
for (let frame = 0; frame < count; frame += 1) {
|
|
189
|
+
const receipt = drawPublished(host.value.renderer, world);
|
|
190
|
+
if (!receipt.ok) throw receipt.error;
|
|
191
|
+
const completed = await receipt.value.completed;
|
|
192
|
+
if (!completed.ok) throw completed.error;
|
|
193
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
194
|
+
}
|
|
195
|
+
await Promise.all(devices.map((device) => device.queue.onSubmittedWorkDone()));
|
|
196
|
+
};
|
|
197
|
+
await drawFrames(60);
|
|
198
|
+
const shadowed = await readPresentedPixels(canvas);
|
|
199
|
+
world.set(light, DirectionalLight, { castShadow: false }).unwrap();
|
|
200
|
+
expect(world.get(light, DirectionalLight).unwrap().castShadow).toBe(false);
|
|
201
|
+
await drawFrames(10);
|
|
202
|
+
expect(renderer.inspect().perFramePassNames).not.toContain('shadowCascade0');
|
|
203
|
+
const unshadowed = await readPresentedPixels(canvas);
|
|
204
|
+
expect([unshadowed.width, unshadowed.height]).toEqual([shadowed.width, shadowed.height]);
|
|
205
|
+
for (const [startX, endX] of [
|
|
206
|
+
[0.29, 0.45],
|
|
207
|
+
[0.55, 0.71],
|
|
208
|
+
] as const) {
|
|
209
|
+
let toonPixels = 0;
|
|
210
|
+
let shadowPixels = 0;
|
|
211
|
+
for (let y = 0; y < shadowed.height; y += 1) {
|
|
212
|
+
for (let x = Math.floor(shadowed.width * startX); x < shadowed.width * endX; x += 1) {
|
|
213
|
+
const offset = (y * shadowed.width + x) * 4;
|
|
214
|
+
const red = shadowed.data[offset] ?? 0;
|
|
215
|
+
const green = shadowed.data[offset + 1] ?? 0;
|
|
216
|
+
const blue = shadowed.data[offset + 2] ?? 0;
|
|
217
|
+
if (green > red + 10 && blue > red + 5) toonPixels += 1;
|
|
218
|
+
// Fixed camera receiver regions under the two Toon cubes exclude
|
|
219
|
+
// the Standard cube's shadow, so its success cannot mask either caster.
|
|
220
|
+
if (
|
|
221
|
+
y > shadowed.height * 0.75 &&
|
|
222
|
+
y < shadowed.height * 0.85 &&
|
|
223
|
+
(unshadowed.data[offset] ?? 0) > red + 20
|
|
224
|
+
)
|
|
225
|
+
shadowPixels += 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
expect(toonPixels, `visible Toon pixels in region ${startX}`).toBeGreaterThan(30);
|
|
229
|
+
expect(shadowPixels, `shadow contribution in region ${startX}`).toBeGreaterThan(10);
|
|
230
|
+
}
|
|
231
|
+
await Promise.all(devices.map((device) => device.queue.onSubmittedWorkDone()));
|
|
232
|
+
expect(validationErrors, 'raw WebGPU validation messages').toEqual([]);
|
|
233
|
+
expect(errors, 'renderer errors').toEqual([]);
|
|
234
|
+
expect(
|
|
235
|
+
pipelineEntries,
|
|
236
|
+
`the authored shadow vertex entry must reach a real PSO: ${JSON.stringify(pipelineLabels)}`,
|
|
237
|
+
).toContain(engineShadow ? 'vs_main' : 'vs_shadow');
|
|
238
|
+
expect(
|
|
239
|
+
shadowPipelineSources,
|
|
240
|
+
'the published shadow program must reach a depth-only PSO',
|
|
241
|
+
).toContain(expectedShadowSource);
|
|
242
|
+
} finally {
|
|
243
|
+
navigator.gpu.requestAdapter = originalRequestAdapter;
|
|
244
|
+
unsubscribe?.();
|
|
245
|
+
renderer?.dispose();
|
|
246
|
+
for (const device of devices) device.destroy();
|
|
247
|
+
canvas.remove();
|
|
248
|
+
}
|
|
249
|
+
}, 60_000);
|