@forgeax/engine-runtime 0.1.25 → 0.1.27
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__/propagate-transforms.cases.unit.test.ts +5 -1
- package/src/__tests__/propagate-transforms.dawn.test.ts +5 -1
- package/src/__tests__/record-worldid-isolation.test.ts +0 -1
- package/src/__tests__/render-system-extract.test.ts +80 -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.27",
|
|
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.27",
|
|
30
|
+
"@forgeax/engine-assets-runtime": "0.1.27",
|
|
31
|
+
"@forgeax/engine-audio-webaudio": "0.1.27",
|
|
32
|
+
"@forgeax/engine-codec": "0.1.27",
|
|
33
|
+
"@forgeax/engine-debug-draw": "0.1.27",
|
|
34
|
+
"@forgeax/engine-ecs": "0.1.27",
|
|
35
|
+
"@forgeax/engine-font": "0.1.27",
|
|
36
|
+
"@forgeax/engine-geometry": "0.1.27",
|
|
37
|
+
"@forgeax/engine-graphics-extras": "0.1.27",
|
|
38
|
+
"@forgeax/engine-image": "0.1.27",
|
|
39
|
+
"@forgeax/engine-input": "0.1.27",
|
|
40
|
+
"@forgeax/engine-math": "0.1.27",
|
|
41
|
+
"@forgeax/engine-pack": "0.1.27",
|
|
42
|
+
"@forgeax/engine-profiler": "0.1.27",
|
|
43
|
+
"@forgeax/engine-render": "0.1.27",
|
|
44
|
+
"@forgeax/engine-render-graph": "0.1.27",
|
|
45
|
+
"@forgeax/engine-rhi": "0.1.27",
|
|
46
|
+
"@forgeax/engine-rhi-null": "0.1.27",
|
|
47
|
+
"@forgeax/engine-rhi-webgpu": "0.1.27",
|
|
48
|
+
"@forgeax/engine-rhi-wgpu": "0.1.27",
|
|
49
|
+
"@forgeax/engine-scene": "0.1.27",
|
|
50
|
+
"@forgeax/engine-shader": "0.1.27",
|
|
51
|
+
"@forgeax/engine-skinning": "0.1.27",
|
|
52
|
+
"@forgeax/engine-types": "0.1.27",
|
|
53
|
+
"@forgeax/engine-vfx": "0.1.27"
|
|
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.27",
|
|
57
|
+
"@forgeax/engine-naga": "0.1.27",
|
|
58
|
+
"@forgeax/engine-state": "0.1.27",
|
|
59
|
+
"@forgeax/engine-vite-plugin-shader": "0.1.27",
|
|
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 },
|
|
@@ -105,6 +105,7 @@ import type {
|
|
|
105
105
|
} from '@forgeax/engine-types';
|
|
106
106
|
import { toShared } from '@forgeax/engine-types';
|
|
107
107
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
108
|
+
import { componentId } from '../../../ecs/src/component';
|
|
108
109
|
import { worldInternal } from '../../../ecs/src/world-internal';
|
|
109
110
|
import {
|
|
110
111
|
SPRITE_PLAYBACK_MODE_CLAMP,
|
|
@@ -489,7 +490,10 @@ type __MergedKeep =
|
|
|
489
490
|
if (archetype === undefined || record === undefined) throw new Error('child row missing');
|
|
490
491
|
const tableRow = archetype.rows[record.archetypeRow];
|
|
491
492
|
if (tableRow === undefined) throw new Error('child table row missing');
|
|
492
|
-
world[worldInternal].
|
|
493
|
+
const table = world[worldInternal].getGraph().tables[archetype.tableId];
|
|
494
|
+
const parentColumn = table?.storage.get(componentId(ChildOf))?.fields.get('parent');
|
|
495
|
+
if (parentColumn === undefined) throw new Error('ChildOf.parent column missing');
|
|
496
|
+
parentColumn.view[tableRow] = ghost as number;
|
|
493
497
|
|
|
494
498
|
const r = propagateTransforms(world);
|
|
495
499
|
expect(r.ok).toBe(false);
|
|
@@ -25,6 +25,7 @@ import { World } from '@forgeax/engine-ecs';
|
|
|
25
25
|
import { readRenderArrayView } from '@forgeax/engine-ecs/projection';
|
|
26
26
|
import { ChildOf, GlobalTransform, propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
27
27
|
import { describe, expect, it } from 'vitest';
|
|
28
|
+
import { componentId } from '../../../ecs/src/component';
|
|
28
29
|
import { worldInternal } from '../../../ecs/src/world-internal';
|
|
29
30
|
import { TileLayer, Tilemap } from '../../../render/src/components';
|
|
30
31
|
|
|
@@ -258,7 +259,10 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
|
|
|
258
259
|
if (archetype === undefined || record === undefined) throw new Error('orphan row missing');
|
|
259
260
|
const tableRow = archetype.rows[record.archetypeRow];
|
|
260
261
|
if (tableRow === undefined) throw new Error('orphan table row missing');
|
|
261
|
-
world[worldInternal].
|
|
262
|
+
const table = world[worldInternal].getGraph().tables[archetype.tableId];
|
|
263
|
+
const parentColumn = table?.storage.get(componentId(ChildOf))?.fields.get('parent');
|
|
264
|
+
if (parentColumn === undefined) throw new Error('ChildOf.parent column missing');
|
|
265
|
+
parentColumn.view[tableRow] = ghost as number;
|
|
262
266
|
|
|
263
267
|
const r = propagateTransforms(world);
|
|
264
268
|
expect(r.ok).toBe(false);
|
|
@@ -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
|
});
|
|
@@ -13,6 +13,7 @@ import { AssetGuid } from '@forgeax/engine-pack/guid';
|
|
|
13
13
|
import {
|
|
14
14
|
Camera,
|
|
15
15
|
DirectionalLight,
|
|
16
|
+
Materials,
|
|
16
17
|
MeshFilter,
|
|
17
18
|
MeshRenderer,
|
|
18
19
|
PointLight,
|
|
@@ -185,6 +186,17 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
185
186
|
const mesh = registerMesh(world);
|
|
186
187
|
const materialGuid = '019ffa97-4000-7000-8000-000000000101';
|
|
187
188
|
const cookedShaderId = 'sha256:rusted-iron-cooked-specialization';
|
|
189
|
+
const cookedShadowShaderId = 'sha256:rusted-iron-cooked-shadow-specialization';
|
|
190
|
+
const materialContext = {
|
|
191
|
+
backend: 'webgpu' as const,
|
|
192
|
+
capability: 'storage-buffer' as const,
|
|
193
|
+
pipeline: 'forward' as const,
|
|
194
|
+
geometry: 'mesh' as const,
|
|
195
|
+
pass: 'forward' as const,
|
|
196
|
+
profile: 'forgeax-material-wgsl-v1' as const,
|
|
197
|
+
toolchain: 'naga-oil' as const,
|
|
198
|
+
instrumentation: 'none' as const,
|
|
199
|
+
};
|
|
188
200
|
const material = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
|
|
189
201
|
kind: 'material',
|
|
190
202
|
passes: [
|
|
@@ -223,6 +235,26 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
223
235
|
module: 'forgeax_material::standard',
|
|
224
236
|
moduleSlots: { surface: 'game_3d::rusted_iron_surface' },
|
|
225
237
|
artifactHash: 'sha256:rusted-iron-artifact',
|
|
238
|
+
programs: [
|
|
239
|
+
{
|
|
240
|
+
context: materialContext,
|
|
241
|
+
specializationKey: cookedShaderId,
|
|
242
|
+
artifactHash: 'sha256:rusted-iron-forward-artifact',
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'shadow-caster',
|
|
248
|
+
module: 'forgeax_material::standard',
|
|
249
|
+
moduleSlots: { surface: 'game_3d::rusted_iron_surface' },
|
|
250
|
+
artifactHash: 'sha256:rusted-iron-artifact',
|
|
251
|
+
programs: [
|
|
252
|
+
{
|
|
253
|
+
context: { ...materialContext, pass: 'shadow' as const },
|
|
254
|
+
specializationKey: cookedShadowShaderId,
|
|
255
|
+
artifactHash: 'sha256:rusted-iron-shadow-artifact',
|
|
256
|
+
},
|
|
257
|
+
],
|
|
226
258
|
},
|
|
227
259
|
],
|
|
228
260
|
runtimeValues: {},
|
|
@@ -236,7 +268,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
236
268
|
)
|
|
237
269
|
.unwrap();
|
|
238
270
|
|
|
239
|
-
const frame = extractFrame(world, prepareExtractContext(world, { assets }));
|
|
271
|
+
const frame = extractFrame(world, prepareExtractContext(world, { assets, materialContext }));
|
|
240
272
|
expect(frame.renderables[0]?.material.materialShaderId).toBe(cookedShaderId);
|
|
241
273
|
const materialDispatch = frame.dispatch.filter((entry) => entry.materialHandle === material);
|
|
242
274
|
expect(materialDispatch).toEqual(
|
|
@@ -247,12 +279,58 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
247
279
|
}),
|
|
248
280
|
expect.objectContaining({
|
|
249
281
|
tags: { LightMode: 'ShadowCaster' },
|
|
250
|
-
materialShaderId:
|
|
282
|
+
materialShaderId: cookedShadowShaderId,
|
|
251
283
|
}),
|
|
252
284
|
]),
|
|
253
285
|
);
|
|
254
286
|
});
|
|
255
287
|
|
|
288
|
+
it('keeps custom Standard Surface fields in the extracted material ABI', () => {
|
|
289
|
+
const world = new World();
|
|
290
|
+
spawnCamera(world);
|
|
291
|
+
const assets = new AssetRegistry(makeMockShaderRegistry());
|
|
292
|
+
const mesh = registerMesh(world);
|
|
293
|
+
const material = world.allocSharedRef<'MaterialAsset', MaterialAsset>(
|
|
294
|
+
'MaterialAsset',
|
|
295
|
+
Materials.standard({
|
|
296
|
+
surfaceModule: 'game_3d::rusted_iron_surface',
|
|
297
|
+
parameters: [
|
|
298
|
+
{ name: 'ironColor', type: 'color' },
|
|
299
|
+
{ name: 'rustDark', type: 'color' },
|
|
300
|
+
{ name: 'rustBright', type: 'color' },
|
|
301
|
+
{ name: 'noiseScale', type: 'f32' },
|
|
302
|
+
],
|
|
303
|
+
values: {
|
|
304
|
+
ironColor: [0.4, 0.45, 0.47, 1],
|
|
305
|
+
rustDark: [0.42, 0.085, 0.018, 1],
|
|
306
|
+
rustBright: [0.95, 0.34, 0.055, 1],
|
|
307
|
+
noiseScale: 1.85,
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
world
|
|
312
|
+
.spawn(
|
|
313
|
+
{ component: Transform, data: { ...identity(), pos: [0, 0, -2] } },
|
|
314
|
+
{ component: MeshFilter, data: { assetHandle: mesh } },
|
|
315
|
+
{ component: MeshRenderer, data: { materials: [material] } },
|
|
316
|
+
)
|
|
317
|
+
.unwrap();
|
|
318
|
+
|
|
319
|
+
const frame = extractFrame(world, prepareExtractContext(world, { assets }));
|
|
320
|
+
const snapshot = frame.renderables[0]?.material;
|
|
321
|
+
const schemaNames = snapshot?.materialParamSchema?.map((entry) => entry.name) ?? [];
|
|
322
|
+
|
|
323
|
+
expect(schemaNames).toEqual(
|
|
324
|
+
expect.arrayContaining(['ironColor', 'rustDark', 'rustBright', 'noiseScale']),
|
|
325
|
+
);
|
|
326
|
+
expect(snapshot?.paramSnapshot).toMatchObject({
|
|
327
|
+
ironColor: [0.4, 0.45, 0.47].map(srgbChannelToLinear).concat(1),
|
|
328
|
+
rustDark: [0.42, 0.085, 0.018].map(srgbChannelToLinear).concat(1),
|
|
329
|
+
rustBright: [0.95, 0.34, 0.055].map(srgbChannelToLinear).concat(1),
|
|
330
|
+
noiseScale: 1.85,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
256
334
|
it('mesh-walk: RenderableSnapshot.transform.world is the resolved world mat4 (parent x child), zero per-snapshot compose', () => {
|
|
257
335
|
const world = new World();
|
|
258
336
|
world
|
|
@@ -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);
|