@forgeax/engine-runtime 0.1.7 → 0.1.19
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 +45 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/helpers/standard-material-manifest.d.ts +12 -0
- package/dist/__tests__/helpers/standard-material-manifest.d.ts.map +1 -0
- package/dist/__tests__/render-environment-consumer.integration.test.d.ts +2 -0
- package/dist/__tests__/render-environment-consumer.integration.test.d.ts.map +1 -0
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
- package/dist/__tests__/render-feature-prepared-graphics.fixture.d.ts.map +1 -1
- package/dist/__tests__/renderer-host-fallback.unit.test.d.ts +2 -0
- package/dist/__tests__/renderer-host-fallback.unit.test.d.ts.map +1 -0
- package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts +2 -0
- package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts.map +1 -0
- package/dist/backend-selection.d.ts.map +1 -1
- package/dist/index.mjs +20 -1
- package/dist/index.mjs.map +1 -1
- package/dist/renderer-host.d.ts +2 -1
- package/dist/renderer-host.d.ts.map +1 -1
- package/dist/renderer-host.mjs +21 -2
- package/dist/renderer-host.mjs.map +1 -1
- package/package.json +30 -30
- package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +12 -11
- package/src/__tests__/errors.unit.test.ts +36 -0
- package/src/__tests__/extract-record-no-hardcoded-texture-fields.test.ts +4 -5
- package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +461 -18
- package/src/__tests__/geometry.unit.test.ts +3 -1
- package/src/__tests__/helpers/standard-material-manifest.ts +45 -0
- package/src/__tests__/material-texture-uv-scale.unit.test.ts +8 -14
- package/src/__tests__/materials.unit.test.ts +2 -0
- package/src/__tests__/pbr-pipeline.unit.test.ts +8 -8
- package/src/__tests__/pipeline-cache-keying.unit.test.ts +6 -4
- package/src/__tests__/pipeline.unit.test.ts +5 -2
- package/src/__tests__/render-environment-consumer.integration.test.ts +33 -0
- package/src/__tests__/render-error-exhaustive.test-d.ts +57 -0
- package/src/__tests__/render-feature-prepared-graphics.fixture.ts +10 -0
- package/src/__tests__/render-system-mega.test.ts +3 -1
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +3 -1
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +3 -1
- package/src/__tests__/render-system-record.test.ts +8 -6
- package/src/__tests__/renderer-host-fallback.unit.test.ts +112 -0
- package/src/__tests__/renderer-lifecycle.integration.test.ts +10 -0
- package/src/__tests__/renderer-surface.unit.test.ts +66 -0
- package/src/__tests__/shadow-csm-cascade-loadop.test.ts +5 -1
- package/src/__tests__/shadow-csm-tile-consistency.test.ts +5 -1
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +383 -0
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +7 -7
- package/src/__tests__/ssao-passes.test.ts +28 -12
- package/src/__tests__/systems.unit.test.ts +48 -31
- package/src/backend-selection.ts +4 -0
- package/src/renderer-host.ts +30 -3
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// Real Dawn regression for the static/skinned shadow pipeline transition.
|
|
2
|
+
//
|
|
3
|
+
// The static shadow path uses the canonical all-true variant key (`''`) while
|
|
4
|
+
// the skinned path uses an explicit negative skinning axis. Keep both entries
|
|
5
|
+
// in one production render so the pipeline layout and group-2 bind group must
|
|
6
|
+
// change at the same command-recording boundary.
|
|
7
|
+
|
|
8
|
+
import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
|
|
9
|
+
import { World } from '@forgeax/engine-ecs';
|
|
10
|
+
import type { Renderer } from '@forgeax/engine-render';
|
|
11
|
+
import { Camera, DirectionalLight, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
|
|
12
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
13
|
+
import { Skin } from '@forgeax/engine-skinning';
|
|
14
|
+
import type { Handle, MaterialAsset, MeshAsset, SkeletonAsset } from '@forgeax/engine-types';
|
|
15
|
+
import { describe, expect, it } from 'vitest';
|
|
16
|
+
import { constructRuntimeRendererHost } from '../renderer-host';
|
|
17
|
+
import { drawPublished } from './draw-published';
|
|
18
|
+
|
|
19
|
+
const WIDTH = 384;
|
|
20
|
+
const HEIGHT = 256;
|
|
21
|
+
const BYTES_PER_ROW = Math.ceil((WIDTH * 4) / 256) * 256;
|
|
22
|
+
const TEXTURE_USAGE_COPY_SRC = 0x01;
|
|
23
|
+
const TEXTURE_USAGE_RENDER_ATTACHMENT = 0x10;
|
|
24
|
+
const BUFFER_USAGE_MAP_READ = 0x0001;
|
|
25
|
+
const BUFFER_USAGE_COPY_DST = 0x0008;
|
|
26
|
+
const MAP_MODE_READ = 0x0001;
|
|
27
|
+
|
|
28
|
+
const ENGINE_MANIFEST = await (async () => {
|
|
29
|
+
const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
|
|
30
|
+
return buildEngineShaderManifest();
|
|
31
|
+
})();
|
|
32
|
+
const ENGINE_MANIFEST_URL = `data:application/json,${encodeURIComponent(
|
|
33
|
+
JSON.stringify(ENGINE_MANIFEST),
|
|
34
|
+
)}`;
|
|
35
|
+
|
|
36
|
+
type MixedCapture = {
|
|
37
|
+
readonly pixels: Uint8Array;
|
|
38
|
+
readonly errors: readonly string[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function staticMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
|
|
42
|
+
return world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
|
|
43
|
+
kind: 'material',
|
|
44
|
+
passes: [
|
|
45
|
+
{
|
|
46
|
+
name: 'Forward',
|
|
47
|
+
program: { module: 'forgeax::default-standard-pbr' },
|
|
48
|
+
renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'ShadowCaster',
|
|
52
|
+
program: { module: 'forgeax::default-shadow-caster' },
|
|
53
|
+
renderState: {
|
|
54
|
+
tags: { LightMode: 'ShadowCaster' },
|
|
55
|
+
passKind: 'shadow-caster',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
values: {
|
|
60
|
+
baseColor: [0.8, 0.35, 0.15, 1],
|
|
61
|
+
metallic: 0.1,
|
|
62
|
+
roughness: 0.55,
|
|
63
|
+
emissive: [0, 0, 0],
|
|
64
|
+
emissiveIntensity: 0,
|
|
65
|
+
occlusionStrength: 1,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function skinnedMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
|
|
71
|
+
return world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
|
|
72
|
+
kind: 'material',
|
|
73
|
+
passes: [
|
|
74
|
+
{
|
|
75
|
+
name: 'Forward',
|
|
76
|
+
program: { module: 'forgeax::pbr-skin' },
|
|
77
|
+
renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'ShadowCaster',
|
|
81
|
+
program: { module: 'forgeax::default-shadow-caster' },
|
|
82
|
+
renderState: {
|
|
83
|
+
tags: { LightMode: 'ShadowCaster' },
|
|
84
|
+
passKind: 'shadow-caster',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
values: {
|
|
89
|
+
baseColor: [0.15, 0.45, 0.9, 1],
|
|
90
|
+
metallic: 0.1,
|
|
91
|
+
roughness: 0.55,
|
|
92
|
+
emissive: [0, 0, 0],
|
|
93
|
+
emissiveIntensity: 0,
|
|
94
|
+
occlusionStrength: 1,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function floorMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
|
|
100
|
+
return world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
|
|
101
|
+
kind: 'material',
|
|
102
|
+
passes: [
|
|
103
|
+
{
|
|
104
|
+
name: 'Forward',
|
|
105
|
+
program: { module: 'forgeax::default-standard-pbr' },
|
|
106
|
+
renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
values: {
|
|
110
|
+
baseColor: [0.75, 0.75, 0.75, 1],
|
|
111
|
+
metallic: 0,
|
|
112
|
+
roughness: 0.9,
|
|
113
|
+
emissive: [0, 0, 0],
|
|
114
|
+
emissiveIntensity: 0,
|
|
115
|
+
occlusionStrength: 1,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function skinnedMesh(world: World): Handle<'MeshAsset', 'shared'> {
|
|
121
|
+
// Canonical interleaved order: position, normal, uv, tangent, skinIndex,
|
|
122
|
+
// skinWeight (72 bytes / vertex). All vertices use joint zero.
|
|
123
|
+
const vertices = new Float32Array([
|
|
124
|
+
-0.75, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0.75, 0, 0, 0, 0, 1, 1, 0, 1, 0,
|
|
125
|
+
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1.2, 0, 0, 0, 1, 0.5, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0,
|
|
126
|
+
]);
|
|
127
|
+
return world.allocSharedRef<'MeshAsset', MeshAsset>('MeshAsset', {
|
|
128
|
+
kind: 'mesh',
|
|
129
|
+
vertices,
|
|
130
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
131
|
+
attributes: {
|
|
132
|
+
position: new Float32Array([-0.75, 0, 0, 0.75, 0, 0, 0, 1.2, 0]),
|
|
133
|
+
normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]),
|
|
134
|
+
uv: new Float32Array([0, 0, 1, 0, 0.5, 1]),
|
|
135
|
+
tangent: new Float32Array([1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1]),
|
|
136
|
+
skinIndex: new Uint16Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
|
137
|
+
skinWeight: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]),
|
|
138
|
+
},
|
|
139
|
+
aabb: new Float32Array([-0.75, 0, 0, 0.75, 1.2, 0]),
|
|
140
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
141
|
+
submeshes: [
|
|
142
|
+
{
|
|
143
|
+
indexOffset: 0,
|
|
144
|
+
indexCount: 3,
|
|
145
|
+
vertexCount: 3,
|
|
146
|
+
materialSlot: 0,
|
|
147
|
+
topology: 'triangle-list',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function skeleton(world: World): Handle<'SkeletonAsset', 'shared'> {
|
|
154
|
+
const inverseBindMatrices = new Float32Array(16);
|
|
155
|
+
inverseBindMatrices[0] = 1;
|
|
156
|
+
inverseBindMatrices[5] = 1;
|
|
157
|
+
inverseBindMatrices[10] = 1;
|
|
158
|
+
inverseBindMatrices[15] = 1;
|
|
159
|
+
return world.allocSharedRef<'SkeletonAsset', SkeletonAsset>('SkeletonAsset', {
|
|
160
|
+
kind: 'skeleton',
|
|
161
|
+
inverseBindMatrices,
|
|
162
|
+
jointCount: 1,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function identityTransform(pos: readonly [number, number, number] = [0, 0, 0]) {
|
|
167
|
+
return { pos, quat: [0, 0, 0, 1] as const, scale: [1, 1, 1] as const };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function spawnMixedScene(world: World, castShadow: boolean): void {
|
|
171
|
+
const staticMat = staticMaterial(world);
|
|
172
|
+
const skinMat = skinnedMaterial(world);
|
|
173
|
+
const floorMat = floorMaterial(world);
|
|
174
|
+
const skinMesh = skinnedMesh(world);
|
|
175
|
+
const skinSkeleton = skeleton(world);
|
|
176
|
+
|
|
177
|
+
world.spawn(
|
|
178
|
+
{ component: Transform, data: { ...identityTransform([0, 5, 5]), scale: [10, 0.1, 10] } },
|
|
179
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
180
|
+
{ component: MeshRenderer, data: { materials: [floorMat] } },
|
|
181
|
+
);
|
|
182
|
+
world.spawn(
|
|
183
|
+
{ component: Transform, data: identityTransform([0, 8, 18]) },
|
|
184
|
+
{
|
|
185
|
+
component: Camera,
|
|
186
|
+
data: {
|
|
187
|
+
fov: (45 * Math.PI) / 180,
|
|
188
|
+
aspect: WIDTH / HEIGHT,
|
|
189
|
+
near: 0.1,
|
|
190
|
+
far: 100,
|
|
191
|
+
clearColor: [0.02, 0.02, 0.03, 1],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
world.spawn({
|
|
196
|
+
component: DirectionalLight,
|
|
197
|
+
data: {
|
|
198
|
+
direction: [0.25, -1, -0.45],
|
|
199
|
+
color: [1, 1, 1],
|
|
200
|
+
intensity: 1,
|
|
201
|
+
castShadow,
|
|
202
|
+
mapSize: 1024,
|
|
203
|
+
depthBias: 0.005,
|
|
204
|
+
normalBias: 0.05,
|
|
205
|
+
shadowDistance: 50,
|
|
206
|
+
pcfKernelSize: 3,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const spawnStaticCaster = (x: number): void => {
|
|
211
|
+
world.spawn(
|
|
212
|
+
{ component: Transform, data: { ...identityTransform([x, 7, 5]), scale: [0.8, 0.8, 0.8] } },
|
|
213
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
214
|
+
{ component: MeshRenderer, data: { materials: [staticMat] } },
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
// Entity order is intentional: static A -> skinned B -> static C.
|
|
218
|
+
spawnStaticCaster(-3);
|
|
219
|
+
const joint = world
|
|
220
|
+
.spawn({
|
|
221
|
+
component: Transform,
|
|
222
|
+
data: identityTransform([0, 7, 5]),
|
|
223
|
+
})
|
|
224
|
+
.unwrap();
|
|
225
|
+
world.spawn(
|
|
226
|
+
{ component: Transform, data: identityTransform() },
|
|
227
|
+
{ component: MeshFilter, data: { assetHandle: skinMesh } },
|
|
228
|
+
{ component: MeshRenderer, data: { materials: [skinMat] } },
|
|
229
|
+
{
|
|
230
|
+
component: Skin,
|
|
231
|
+
data: {
|
|
232
|
+
skeleton: skinSkeleton,
|
|
233
|
+
joints: new Uint32Array([joint as unknown as number]),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
spawnStaticCaster(3);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function readPixels(device: GPUDevice, texture: GPUTexture): Promise<Uint8Array> {
|
|
241
|
+
const buffer = device.createBuffer({
|
|
242
|
+
size: BYTES_PER_ROW * HEIGHT,
|
|
243
|
+
usage: BUFFER_USAGE_MAP_READ | BUFFER_USAGE_COPY_DST,
|
|
244
|
+
});
|
|
245
|
+
const encoder = device.createCommandEncoder();
|
|
246
|
+
encoder.copyTextureToBuffer(
|
|
247
|
+
{ texture },
|
|
248
|
+
{ buffer, bytesPerRow: BYTES_PER_ROW, rowsPerImage: HEIGHT },
|
|
249
|
+
{ width: WIDTH, height: HEIGHT, depthOrArrayLayers: 1 },
|
|
250
|
+
);
|
|
251
|
+
device.queue.submit([encoder.finish()]);
|
|
252
|
+
await buffer.mapAsync(MAP_MODE_READ);
|
|
253
|
+
const pixels = new Uint8Array(buffer.getMappedRange().slice(0));
|
|
254
|
+
buffer.unmap();
|
|
255
|
+
buffer.destroy();
|
|
256
|
+
return pixels;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function renderMixed(castShadow: boolean): Promise<MixedCapture> {
|
|
260
|
+
let device: GPUDevice | undefined;
|
|
261
|
+
let target: GPUTexture | undefined;
|
|
262
|
+
const originalRequestAdapter = globalThis.navigator.gpu.requestAdapter.bind(
|
|
263
|
+
globalThis.navigator.gpu,
|
|
264
|
+
);
|
|
265
|
+
globalThis.navigator.gpu.requestAdapter = async (options) => {
|
|
266
|
+
const adapter = await originalRequestAdapter(options);
|
|
267
|
+
if (adapter === null) return adapter;
|
|
268
|
+
const originalRequestDevice = adapter.requestDevice.bind(adapter);
|
|
269
|
+
adapter.requestDevice = async (descriptor) => {
|
|
270
|
+
const created = await originalRequestDevice(descriptor);
|
|
271
|
+
device ??= created;
|
|
272
|
+
return created;
|
|
273
|
+
};
|
|
274
|
+
return adapter;
|
|
275
|
+
};
|
|
276
|
+
const canvas = {
|
|
277
|
+
width: WIDTH,
|
|
278
|
+
height: HEIGHT,
|
|
279
|
+
getContext(kind: string): unknown {
|
|
280
|
+
if (kind !== 'webgpu') return null;
|
|
281
|
+
return {
|
|
282
|
+
configure(descriptor: { device: GPUDevice; format?: GPUTextureFormat }) {
|
|
283
|
+
target ??= descriptor.device.createTexture({
|
|
284
|
+
size: { width: WIDTH, height: HEIGHT, depthOrArrayLayers: 1 },
|
|
285
|
+
format: descriptor.format ?? 'rgba8unorm',
|
|
286
|
+
usage: TEXTURE_USAGE_RENDER_ATTACHMENT | TEXTURE_USAGE_COPY_SRC,
|
|
287
|
+
viewFormats: ['rgba8unorm-srgb'],
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
unconfigure() {},
|
|
291
|
+
getCurrentTexture(): GPUTexture {
|
|
292
|
+
if (target === undefined) throw new Error('render target requested before configure');
|
|
293
|
+
return target;
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
addEventListener() {},
|
|
298
|
+
removeEventListener() {},
|
|
299
|
+
} as unknown as HTMLCanvasElement;
|
|
300
|
+
|
|
301
|
+
let renderer: Renderer | undefined;
|
|
302
|
+
let unsubscribe: (() => void) | undefined;
|
|
303
|
+
const errors: string[] = [];
|
|
304
|
+
try {
|
|
305
|
+
const host = await constructRuntimeRendererHost(
|
|
306
|
+
canvas,
|
|
307
|
+
{},
|
|
308
|
+
{
|
|
309
|
+
shaderManifestUrl: ENGINE_MANIFEST_URL,
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
if (!host.ok) throw host.error;
|
|
313
|
+
renderer = host.value.renderer;
|
|
314
|
+
unsubscribe = renderer.subscribe((event) => {
|
|
315
|
+
if (event.kind === 'error') errors.push(event.error.code);
|
|
316
|
+
});
|
|
317
|
+
const world = new World();
|
|
318
|
+
spawnMixedScene(world, castShadow);
|
|
319
|
+
// The first frame warms the static and skinned PSOs; the second frame is
|
|
320
|
+
// the asserted mixed command stream after both cache entries exist.
|
|
321
|
+
for (let frame = 0; frame < 2; frame += 1) {
|
|
322
|
+
const receipt = drawPublished(renderer, world);
|
|
323
|
+
expect(receipt.ok, `mixed ${castShadow ? 'shadow' : 'baseline'} draw`).toBe(true);
|
|
324
|
+
if (!receipt.ok) throw receipt.error;
|
|
325
|
+
const completed = await receipt.value.completed;
|
|
326
|
+
expect(completed.ok, 'Queue::submit completion').toBe(true);
|
|
327
|
+
if (!completed.ok) throw completed.error;
|
|
328
|
+
}
|
|
329
|
+
if (device === undefined || target === undefined)
|
|
330
|
+
throw new Error('Dawn target not initialized');
|
|
331
|
+
await device.queue.onSubmittedWorkDone();
|
|
332
|
+
return { pixels: await readPixels(device, target), errors };
|
|
333
|
+
} finally {
|
|
334
|
+
globalThis.navigator.gpu.requestAdapter = originalRequestAdapter;
|
|
335
|
+
unsubscribe?.();
|
|
336
|
+
renderer?.dispose();
|
|
337
|
+
target?.destroy();
|
|
338
|
+
device?.destroy();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function regionDiff(a: Uint8Array, b: Uint8Array, x0: number, x1: number): number {
|
|
343
|
+
let changed = 0;
|
|
344
|
+
for (let y = Math.floor(HEIGHT * 0.32); y < HEIGHT; y += 1) {
|
|
345
|
+
for (let x = x0; x < x1; x += 1) {
|
|
346
|
+
const index = y * BYTES_PER_ROW + x * 4;
|
|
347
|
+
if (
|
|
348
|
+
Math.abs((a[index] ?? 0) - (b[index] ?? 0)) > 2 ||
|
|
349
|
+
Math.abs((a[index + 1] ?? 0) - (b[index + 1] ?? 0)) > 2 ||
|
|
350
|
+
Math.abs((a[index + 2] ?? 0) - (b[index + 2] ?? 0)) > 2
|
|
351
|
+
) {
|
|
352
|
+
changed += 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return changed;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
describe('mixed static/skinned shadow pipeline (Dawn)', () => {
|
|
360
|
+
it('submits static → skinned → static casters with zero validation errors', async () => {
|
|
361
|
+
if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') {
|
|
362
|
+
throw new Error('dawn-node navigator.gpu not injected');
|
|
363
|
+
}
|
|
364
|
+
const shadow = await renderMixed(true);
|
|
365
|
+
const baseline = await renderMixed(false);
|
|
366
|
+
expect(shadow.errors).toEqual([]);
|
|
367
|
+
expect(baseline.errors).toEqual([]);
|
|
368
|
+
expect(shadow.pixels.length).toBeGreaterThan(0);
|
|
369
|
+
expect(baseline.pixels.length).toBe(shadow.pixels.length);
|
|
370
|
+
|
|
371
|
+
// Each caster owns a separate x-region. Comparing against the identical
|
|
372
|
+
// no-shadow scene proves the depth pass contributed in all three regions,
|
|
373
|
+
// rather than merely proving that a color frame was non-black.
|
|
374
|
+
const regions = [
|
|
375
|
+
[0, Math.floor(WIDTH / 3)],
|
|
376
|
+
[Math.floor(WIDTH / 3), Math.floor((2 * WIDTH) / 3)],
|
|
377
|
+
[Math.floor((2 * WIDTH) / 3), WIDTH],
|
|
378
|
+
] as const;
|
|
379
|
+
for (const [x0, x1] of regions) {
|
|
380
|
+
expect(regionDiff(shadow.pixels, baseline.pixels, x0, x1)).toBeGreaterThan(0);
|
|
381
|
+
}
|
|
382
|
+
}, 120000);
|
|
383
|
+
});
|
|
@@ -118,24 +118,24 @@ describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
|
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
describe('material BGL congruence (pbr-material-merged / unlit-material)', () => {
|
|
121
|
-
it('pbr-material-user-region entries are
|
|
121
|
+
it('pbr-material-user-region entries are 17 (PBR layout reused by sprite & sprite-lit)', () => {
|
|
122
122
|
// sprite + sprite-lit use the default standard-PBR user-region
|
|
123
|
-
// schema (
|
|
123
|
+
// schema (20 value fields + 8 texture fields produce 17 BGL entries).
|
|
124
124
|
// Both share the same userRegion shape and therefore the same BGL.
|
|
125
125
|
const base = buildPbrMaterialUserRegionEntries();
|
|
126
|
-
expect(base.length).toBe(
|
|
126
|
+
expect(base.length).toBe(17);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
it('pbr-material-merged stays
|
|
129
|
+
it('pbr-material-merged stays 26 entries (sprite/sprite-lit do not change material BGL shape)', () => {
|
|
130
130
|
const merged = buildBindGroupLayoutDescriptor(makeSpec('forgeax::sprite-lit'), {
|
|
131
131
|
kind: 'pbr-material-merged',
|
|
132
132
|
});
|
|
133
|
-
expect(merged.entries.length).toBe(
|
|
134
|
-
// mergeSkylightIntoMaterialBgl +
|
|
133
|
+
expect(merged.entries.length).toBe(26);
|
|
134
|
+
// mergeSkylightIntoMaterialBgl + transmission injection must stay
|
|
135
135
|
// append-only against the 7-entry PBR base.
|
|
136
136
|
const base = buildPbrMaterialUserRegionEntries();
|
|
137
137
|
const afterSky = mergeSkylightIntoMaterialBgl(base);
|
|
138
|
-
const expected = [...afterSky, ...appendInjection(afterSky, '
|
|
138
|
+
const expected = [...afterSky, ...appendInjection(afterSky, 'transmission')];
|
|
139
139
|
expect(merged.entries).toEqual(expected);
|
|
140
140
|
});
|
|
141
141
|
});
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { RenderGraph } from '@forgeax/engine-render-graph';
|
|
9
9
|
import type { RhiDevice } from '@forgeax/engine-rhi';
|
|
10
10
|
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { DeviceScope } from '../../../render/src/device/device-scope';
|
|
12
|
+
import type { BloomPersistentBundle } from '../../../render/src/record/render-context';
|
|
11
13
|
import {
|
|
12
14
|
recordBloomBlurHPass,
|
|
13
15
|
recordBloomBlurVPass,
|
|
@@ -680,6 +682,31 @@ function makePostProcessRecordCtx(): {
|
|
|
680
682
|
queue: { writeBuffer: vi.fn(() => ({ ok: true, value: undefined })) },
|
|
681
683
|
};
|
|
682
684
|
const sampler = { label: 'postprocess-sampler' };
|
|
685
|
+
const bloomScope = DeviceScope.create(1, 'runtime-ssao-bloom-fixture');
|
|
686
|
+
const ownBloomHandle = (
|
|
687
|
+
kind: 'pipeline' | 'binding' | 'post-effect' | 'buffer',
|
|
688
|
+
label: string,
|
|
689
|
+
) => {
|
|
690
|
+
const handle = { label };
|
|
691
|
+
bloomScope._adopt(kind, handle, () => undefined);
|
|
692
|
+
return handle;
|
|
693
|
+
};
|
|
694
|
+
const bloomResources = {
|
|
695
|
+
scope: bloomScope,
|
|
696
|
+
generation: bloomScope.generation,
|
|
697
|
+
bloomBrightPipeline: ownBloomHandle('pipeline', 'bloom-bright-pipeline'),
|
|
698
|
+
bloomBlurHPipeline: ownBloomHandle('pipeline', 'bloom-blur-h-pipeline'),
|
|
699
|
+
bloomBlurVPipeline: ownBloomHandle('pipeline', 'bloom-blur-v-pipeline'),
|
|
700
|
+
bloomCompositePipeline: ownBloomHandle('pipeline', 'bloom-composite-pipeline'),
|
|
701
|
+
bloomBrightBindGroupLayout: ownBloomHandle('binding', 'bloom-bright-bgl'),
|
|
702
|
+
bloomBlurBindGroupLayout: ownBloomHandle('binding', 'bloom-blur-bgl'),
|
|
703
|
+
bloomCompositeBindGroupLayout: ownBloomHandle('binding', 'bloom-composite-bgl'),
|
|
704
|
+
bloomSampler: ownBloomHandle('post-effect', 'bloom-sampler'),
|
|
705
|
+
bloomBrightParamsBuffer: ownBloomHandle('buffer', 'bloom-bright-params'),
|
|
706
|
+
bloomBlurHParamsBuffer: ownBloomHandle('buffer', 'bloom-blur-h-params'),
|
|
707
|
+
bloomBlurVParamsBuffer: ownBloomHandle('buffer', 'bloom-blur-v-params'),
|
|
708
|
+
bloomCompositeParamsBuffer: ownBloomHandle('buffer', 'bloom-composite-params'),
|
|
709
|
+
} as unknown as BloomPersistentBundle;
|
|
683
710
|
const ctx = {
|
|
684
711
|
runtime: {
|
|
685
712
|
device: { ...device, caps: { storageBuffer: true } },
|
|
@@ -702,23 +729,12 @@ function makePostProcessRecordCtx(): {
|
|
|
702
729
|
skyboxBindGroupLayout: { label: 'skybox-bgl' },
|
|
703
730
|
skyboxSampler: sampler,
|
|
704
731
|
skyboxRotationBuffer: { label: 'skybox-rotation' },
|
|
705
|
-
bloomBrightPipeline: { label: 'bloom-bright-pipeline' },
|
|
706
|
-
bloomBrightBindGroupLayout: { label: 'bloom-bright-bgl' },
|
|
707
|
-
bloomBrightParamsBuffer: { label: 'bloom-bright-params' },
|
|
708
|
-
bloomBlurHPipeline: { label: 'bloom-blur-h-pipeline' },
|
|
709
|
-
bloomBlurVPipeline: { label: 'bloom-blur-v-pipeline' },
|
|
710
|
-
bloomBlurBindGroupLayout: { label: 'bloom-blur-bgl' },
|
|
711
|
-
bloomBlurHParamsBuffer: { label: 'bloom-blur-h-params' },
|
|
712
|
-
bloomBlurVParamsBuffer: { label: 'bloom-blur-v-params' },
|
|
713
|
-
bloomCompositePipeline: { label: 'bloom-composite-pipeline' },
|
|
714
|
-
bloomCompositeBindGroupLayout: { label: 'bloom-composite-bgl' },
|
|
715
|
-
bloomCompositeParamsBuffer: { label: 'bloom-composite-params' },
|
|
716
|
-
bloomSampler: sampler,
|
|
717
732
|
fxaaPipeline: { label: 'fxaa-pipeline' },
|
|
718
733
|
fxaaBindGroupLayout: { label: 'fxaa-bgl' },
|
|
719
734
|
fxaaSampler: sampler,
|
|
720
735
|
},
|
|
721
736
|
},
|
|
737
|
+
bloomResources,
|
|
722
738
|
frameState: { postProcessBgCache: new WeakMap() },
|
|
723
739
|
bindGroupCounts: { createBindGroup: 0, keys: [] },
|
|
724
740
|
camera: {
|