@forgeax/engine-runtime 0.1.20 → 0.1.21
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 +102 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
- package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
- package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
- package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
- package/dist/createRenderer.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +30 -30
- package/src/__tests__/active-camera.unit.test.ts +6 -8
- package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
- package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
- package/src/__tests__/components.test-d.ts +10 -3
- package/src/__tests__/components.unit.test.ts +13 -7
- package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
- package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
- package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
- package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
- package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
- package/src/__tests__/errors.unit.test.ts +51 -20
- package/src/__tests__/extract-frames-merge.test.ts +48 -40
- package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
- package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
- package/src/__tests__/geometry.unit.test.ts +7 -10
- package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +2 -1
- package/src/__tests__/light-snapshot.test-d.ts +8 -0
- package/src/__tests__/lights-preservation.fixup.test.ts +127 -0
- package/src/__tests__/lights.unit.test.ts +75 -2235
- package/src/__tests__/pbr-pipeline.unit.test.ts +6 -1
- package/src/__tests__/pipeline.unit.test.ts +48 -202
- package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
- package/src/__tests__/point-light-shadow.unit.test.ts +61 -4
- package/src/__tests__/render-error-exhaustive.test-d.ts +63 -9
- package/src/__tests__/render-system-mega.test.ts +30 -41
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +4 -1
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +4 -1
- package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
- package/src/__tests__/renderer-recover.unit.test.ts +8 -0
- package/src/__tests__/renderer-surface.unit.test.ts +229 -3
- package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
- package/src/__tests__/shadow-csm-component.test.ts +31 -24
- package/src/__tests__/shadow-csm-extract.test.ts +156 -47
- package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
- package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
- package/src/__tests__/shadow-csm-ubo.test.ts +41 -45
- package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +85 -20
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +3 -1
- package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
- package/src/__tests__/ssao-bgl.test.ts +10 -16
- package/src/__tests__/ssao-passes.test.ts +19 -2
- package/src/__tests__/systems.unit.test.ts +44 -31
- package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
- package/src/createRenderer.ts +1 -0
- package/src/index.ts +1 -2
|
@@ -10,7 +10,21 @@
|
|
|
10
10
|
// This file is scoped to the 'browser' vitest project (file-naming convention
|
|
11
11
|
// *.browser.test.ts); the 'dawn' and 'unit' projects exclude it.
|
|
12
12
|
|
|
13
|
+
import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
|
|
14
|
+
import { World } from '@forgeax/engine-ecs';
|
|
15
|
+
import {
|
|
16
|
+
Camera,
|
|
17
|
+
DirectionalLight,
|
|
18
|
+
Materials,
|
|
19
|
+
MeshFilter,
|
|
20
|
+
MeshRenderer,
|
|
21
|
+
PointLight,
|
|
22
|
+
PointLightShadow,
|
|
23
|
+
} from '@forgeax/engine-render';
|
|
24
|
+
import { propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
13
25
|
import { describe, expect, it } from 'vitest';
|
|
26
|
+
import { page } from 'vitest/browser';
|
|
27
|
+
import { constructRuntimeRendererHost } from '../renderer-host';
|
|
14
28
|
|
|
15
29
|
// WebGPU bitmask constants per spec (avoids needing @webgpu/types globals
|
|
16
30
|
// in the runtime tsconfig). Values are stable across implementations.
|
|
@@ -192,87 +206,198 @@ describe('M0 cube_array comparison sampler (browser)', () => {
|
|
|
192
206
|
});
|
|
193
207
|
});
|
|
194
208
|
|
|
195
|
-
// feat-20260612-point-light-shadows-urp-hdrp
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// Round-2 status (feat-20260612 Round-2 commit-B + commit-C): the BGL
|
|
203
|
-
// hookup landed -- POINT_SHADOW_AVAILABLE is registered at all 3
|
|
204
|
-
// vite-plugin-shader define sites, the runtime PBR view BGL emits
|
|
205
|
-
// always-on bindings 5 (cube_array depth atlas) + 6 (shadowParams UBO 64 B),
|
|
206
|
-
// and recordPointShadowPass walks validated entries with real drawIndexed
|
|
207
|
-
// calls + per-face VP write to viewUniformBuffer.lightSpaceMatrix. The
|
|
208
|
-
// shadowParams UBO is written per frame in record-stage with
|
|
209
|
-
// (near, far, 1/(far-near), 0) per shadow-casting snapshot.
|
|
210
|
-
//
|
|
211
|
-
// What remains for this `it.skip` to flip to `it`: the full
|
|
212
|
-
// createRenderer + canvas + readPixels + scene scaffolding (mirror of
|
|
213
|
-
// `light-casters-9-light.browser.test.ts` shape: spawnCamera + spawn
|
|
214
|
-
// occluder cube + spawn ground plane + spawnPointAt + spawnPointShadow
|
|
215
|
-
// + driving FRAMES_PER_SCENE rAF ticks + sampleBlockAverage at occluded
|
|
216
|
-
// vs free sample sites). The wiring is unblocked at the engine layer; the
|
|
217
|
-
// scope of writing the test fixture itself + tuning the lighting setup so
|
|
218
|
-
// occluded < non-occluded reliably under chromium headless presentation
|
|
219
|
-
// timing exceeds Round-2 fix-up budget. Tracked as a sub-followup
|
|
220
|
-
// (feat-followup-point-shadow-browser-readback) per implement-decisions.md.
|
|
221
|
-
describe('URP point shadow pixel readback (browser, T-M3-6 / AC-14)', () => {
|
|
222
|
-
it.skip('occluded fragment darker than non-occluded -- BGL hookup landed in Round-2; full createRenderer + readPixels scaffolding deferred', () => {
|
|
223
|
-
// Pseudocode for the assertion the runnable test will make once the
|
|
224
|
-
// hookup lands:
|
|
225
|
-
//
|
|
226
|
-
// const renderer = createRenderer(canvas, { ... });
|
|
227
|
-
// const world = new World();
|
|
228
|
-
// world.spawn({ component: Transform, data: {...} },
|
|
229
|
-
// { component: PointLight, data: {...} },
|
|
230
|
-
// { component: PointLightShadow, data: {} });
|
|
231
|
-
// // ... ground plane + occluder ...
|
|
232
|
-
// renderer.draw(world);
|
|
233
|
-
// const px = await readback(canvas, occludedX, occludedY);
|
|
234
|
-
// const py = await readback(canvas, freeX, freeY);
|
|
235
|
-
// expect(px.r + px.g + px.b).toBeLessThan(py.r + py.g + py.b);
|
|
236
|
-
// expect(py.r + py.g + py.b).toBeGreaterThan(0); // non-black frame
|
|
237
|
-
});
|
|
238
|
-
});
|
|
209
|
+
// feat-20260612-point-light-shadows-urp-hdrp M5 / AC-24 focused consumer.
|
|
210
|
+
// This is a real Standard createRenderer path rather than a cube-array-only
|
|
211
|
+
// primitive: the same caster/receiver scene is rendered with and without the
|
|
212
|
+
// PointLightShadow component, then the canvas pixels and renderer pass facts
|
|
213
|
+
// are checked together.
|
|
214
|
+
const browserReady = typeof navigator !== 'undefined' && navigator.gpu !== undefined;
|
|
215
|
+
const REAL_CANVAS_SIZE = 96;
|
|
239
216
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
217
|
+
function spawnPointShadowScene(world: World, withShadow: boolean): void {
|
|
218
|
+
const material = world.allocSharedRef(
|
|
219
|
+
'MaterialAsset',
|
|
220
|
+
Materials.standard({
|
|
221
|
+
baseColor: [0.78, 0.26, 0.08, 1],
|
|
222
|
+
metallic: 0,
|
|
223
|
+
roughness: 0.45,
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
world
|
|
227
|
+
.spawn(
|
|
228
|
+
{ component: Transform, data: { pos: [0, 0, 0], scale: [1.3, 1.3, 1.3] } },
|
|
229
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
230
|
+
{ component: MeshRenderer, data: { materials: [material] } },
|
|
231
|
+
)
|
|
232
|
+
.unwrap();
|
|
233
|
+
world
|
|
234
|
+
.spawn(
|
|
235
|
+
{ component: Transform, data: { pos: [0, -1.65, 0], scale: [3.5, 0.15, 3.5] } },
|
|
236
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
237
|
+
{ component: MeshRenderer, data: { materials: [material] } },
|
|
238
|
+
)
|
|
239
|
+
.unwrap();
|
|
240
|
+
world
|
|
241
|
+
.spawn(
|
|
242
|
+
{ component: Transform, data: { pos: [0, 0, 6] } },
|
|
243
|
+
{ component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 50 } },
|
|
244
|
+
)
|
|
245
|
+
.unwrap();
|
|
246
|
+
world
|
|
247
|
+
.spawn({
|
|
248
|
+
component: DirectionalLight,
|
|
249
|
+
data: { direction: [-0.4, -0.8, -1], intensity: 0.35, castShadow: false },
|
|
250
|
+
})
|
|
251
|
+
.unwrap();
|
|
252
|
+
const point = [1.8, 2.6, 2.2] as const;
|
|
253
|
+
world
|
|
254
|
+
.spawn(
|
|
255
|
+
{ component: Transform, data: { pos: point } },
|
|
256
|
+
{ component: PointLight, data: { color: [1, 0.72, 0.4], intensity: 22, range: 12 } },
|
|
257
|
+
...(withShadow
|
|
258
|
+
? [{ component: PointLightShadow, data: { mapSize: 256, nearPlane: 0.1, farPlane: 25 } }]
|
|
259
|
+
: []),
|
|
260
|
+
)
|
|
261
|
+
.unwrap();
|
|
262
|
+
|
|
263
|
+
// The standalone renderer host does not install scenePlugin for this
|
|
264
|
+
// fixture. Publish world-space matrices before extract so the pixel proof
|
|
265
|
+
// exercises the actual caster/receiver scene instead of the clear colour.
|
|
266
|
+
propagateTransforms(world).unwrap();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function waitForPresentedPixels(canvas: HTMLCanvasElement): Promise<Uint8Array> {
|
|
270
|
+
let latest = new Uint8Array(REAL_CANVAS_SIZE * REAL_CANVAS_SIZE * 4);
|
|
271
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
272
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
273
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
274
|
+
// CDP-backed element screenshots read the compositor surface directly;
|
|
275
|
+
// createImageBitmap(canvas) can race swap-chain presentation in headless
|
|
276
|
+
// Chromium and return an all-zero opaque surface.
|
|
277
|
+
const shot = await page.elementLocator(canvas).screenshot({ base64: true, save: false });
|
|
278
|
+
const b64 = typeof shot === 'string' ? shot : shot.base64;
|
|
279
|
+
const binary = atob(b64);
|
|
280
|
+
const encoded = new Uint8Array(binary.length);
|
|
281
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
282
|
+
encoded[index] = binary.charCodeAt(index);
|
|
283
|
+
}
|
|
284
|
+
const bitmap = await createImageBitmap(new Blob([encoded], { type: 'image/png' }));
|
|
285
|
+
const surface = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
286
|
+
const context = surface.getContext('2d', { willReadFrequently: true });
|
|
287
|
+
if (context === null) {
|
|
288
|
+
bitmap.close();
|
|
289
|
+
throw new Error('point-shadow browser test: canvas readback context unavailable');
|
|
290
|
+
}
|
|
291
|
+
context.drawImage(bitmap, 0, 0);
|
|
292
|
+
const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
293
|
+
bitmap.close();
|
|
294
|
+
latest = new Uint8Array(image.data);
|
|
295
|
+
let nonBlack = false;
|
|
296
|
+
for (let index = 0; index < latest.length; index += 4) {
|
|
297
|
+
if ((latest[index] ?? 0) + (latest[index + 1] ?? 0) + (latest[index + 2] ?? 0) > 0) {
|
|
298
|
+
nonBlack = true;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (nonBlack) return latest;
|
|
303
|
+
}
|
|
304
|
+
return latest;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function pixelLuma(pixels: Uint8Array): number {
|
|
308
|
+
let sum = 0;
|
|
309
|
+
for (let index = 0; index < pixels.length; index += 4) {
|
|
310
|
+
sum += (pixels[index] ?? 0) + (pixels[index + 1] ?? 0) + (pixels[index + 2] ?? 0);
|
|
311
|
+
}
|
|
312
|
+
return sum / (pixels.length / 4);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function pixelDifference(a: Uint8Array, b: Uint8Array): number {
|
|
316
|
+
let difference = 0;
|
|
317
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 4) {
|
|
318
|
+
difference += Math.abs((a[index] ?? 0) - (b[index] ?? 0));
|
|
319
|
+
difference += Math.abs((a[index + 1] ?? 0) - (b[index + 1] ?? 0));
|
|
320
|
+
difference += Math.abs((a[index + 2] ?? 0) - (b[index + 2] ?? 0));
|
|
321
|
+
}
|
|
322
|
+
return difference;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
it.skipIf(!browserReady)(
|
|
326
|
+
'Standard point-shadow scene differs from its no-shadow baseline',
|
|
327
|
+
async () => {
|
|
328
|
+
const canvas = document.createElement('canvas');
|
|
329
|
+
canvas.width = REAL_CANVAS_SIZE;
|
|
330
|
+
canvas.height = REAL_CANVAS_SIZE;
|
|
331
|
+
canvas.style.width = `${REAL_CANVAS_SIZE}px`;
|
|
332
|
+
canvas.style.height = `${REAL_CANVAS_SIZE}px`;
|
|
333
|
+
document.body.appendChild(canvas);
|
|
334
|
+
let renderer: import('@forgeax/engine-render').Renderer | undefined;
|
|
335
|
+
try {
|
|
336
|
+
const host = await constructRuntimeRendererHost(
|
|
337
|
+
canvas,
|
|
338
|
+
{},
|
|
339
|
+
{ shaderManifestUrl: '/shaders/manifest.json' },
|
|
340
|
+
);
|
|
341
|
+
if (!host.ok) throw host.error;
|
|
342
|
+
renderer = host.value.renderer;
|
|
343
|
+
const errors: string[] = [];
|
|
344
|
+
const unsubscribe = renderer.subscribe((event) => {
|
|
345
|
+
if (event.kind === 'error') errors.push(event.error.code);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const shadowWorld = new World();
|
|
349
|
+
spawnPointShadowScene(shadowWorld, true);
|
|
350
|
+
const shadowAttachment = renderer.attach(shadowWorld);
|
|
351
|
+
expect(shadowAttachment.ok).toBe(true);
|
|
352
|
+
if (!shadowAttachment.ok) throw shadowAttachment.error;
|
|
353
|
+
for (let frame = 0; frame < 6; frame += 1) {
|
|
354
|
+
shadowWorld.update(1 / 60).unwrap();
|
|
355
|
+
const drawn = renderer.draw({
|
|
356
|
+
leases: [shadowAttachment.value],
|
|
357
|
+
camera: { lease: shadowAttachment.value },
|
|
358
|
+
environment: { lease: shadowAttachment.value },
|
|
359
|
+
});
|
|
360
|
+
expect(drawn.ok).toBe(true);
|
|
361
|
+
if (!drawn.ok) throw drawn.error;
|
|
362
|
+
}
|
|
363
|
+
const shadowPixels = await waitForPresentedPixels(canvas);
|
|
364
|
+
const shadowInspection = renderer.inspect();
|
|
365
|
+
expect(shadowInspection.pointShadow?.status).toBe('ready');
|
|
366
|
+
expect(shadowInspection.pointShadow?.requested).toBe(1);
|
|
367
|
+
expect(shadowInspection.pointShadow?.admitted).toBe(1);
|
|
368
|
+
expect(
|
|
369
|
+
shadowInspection.perFramePassNames.filter((name) => name.startsWith('point-shadow-')),
|
|
370
|
+
).toHaveLength(6);
|
|
371
|
+
|
|
372
|
+
const noShadowWorld = new World();
|
|
373
|
+
spawnPointShadowScene(noShadowWorld, false);
|
|
374
|
+
const noShadowAttachment = renderer.attach(noShadowWorld);
|
|
375
|
+
expect(noShadowAttachment.ok).toBe(true);
|
|
376
|
+
if (!noShadowAttachment.ok) throw noShadowAttachment.error;
|
|
377
|
+
for (let frame = 0; frame < 6; frame += 1) {
|
|
378
|
+
noShadowWorld.update(1 / 60).unwrap();
|
|
379
|
+
const drawn = renderer.draw({
|
|
380
|
+
leases: [noShadowAttachment.value],
|
|
381
|
+
camera: { lease: noShadowAttachment.value },
|
|
382
|
+
environment: { lease: noShadowAttachment.value },
|
|
383
|
+
});
|
|
384
|
+
expect(drawn.ok).toBe(true);
|
|
385
|
+
if (!drawn.ok) throw drawn.error;
|
|
386
|
+
}
|
|
387
|
+
const noShadowPixels = await waitForPresentedPixels(canvas);
|
|
388
|
+
const noShadowInspection = renderer.inspect();
|
|
389
|
+
expect(noShadowInspection.pointShadow?.status).toBe('inactive');
|
|
390
|
+
expect(
|
|
391
|
+
noShadowInspection.perFramePassNames.filter((name) => name.startsWith('point-shadow-')),
|
|
392
|
+
).toHaveLength(0);
|
|
393
|
+
expect(pixelLuma(shadowPixels)).toBeGreaterThan(0);
|
|
394
|
+
expect(pixelLuma(noShadowPixels)).toBeGreaterThan(0);
|
|
395
|
+
expect(pixelDifference(shadowPixels, noShadowPixels)).toBeGreaterThan(0);
|
|
396
|
+
expect(errors).toEqual([]);
|
|
397
|
+
unsubscribe();
|
|
398
|
+
} finally {
|
|
399
|
+
if (renderer !== undefined) await renderer.dispose();
|
|
400
|
+
canvas.remove();
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
);
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
BYTES_PER_LIGHT_SLOT,
|
|
23
23
|
LIGHTSLOT_LAYOUT,
|
|
24
24
|
packLightSlot,
|
|
25
|
+
SPOT_LIGHT_PROJECTOR_FLAG,
|
|
25
26
|
} from '../../../render/src/light-buffer-layout';
|
|
26
27
|
import { buildPointShadowMatrices } from '../../../render/src/render-system-extract';
|
|
27
28
|
import {
|
|
@@ -164,8 +165,8 @@ function spawnValidatedPointShadow(
|
|
|
164
165
|
expect(r.error).toBeInstanceOf(ShadowInvalidConfigError);
|
|
165
166
|
const detail = (r.error as unknown as ShadowInvalidConfigError).detail;
|
|
166
167
|
expect(detail.field).toBe('mapSize');
|
|
167
|
-
expect(detail.
|
|
168
|
-
expect(detail.
|
|
168
|
+
expect(detail.actual).toBe(0);
|
|
169
|
+
expect(detail.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1 });
|
|
169
170
|
// .expected and .hint carry actionable strings
|
|
170
171
|
expect((r.error as unknown as ShadowInvalidConfigError).expected).toContain('mapSize');
|
|
171
172
|
expect((r.error as unknown as ShadowInvalidConfigError).hint).toContain('mapSize');
|
|
@@ -190,8 +191,8 @@ function spawnValidatedPointShadow(
|
|
|
190
191
|
expect(r.error).toBeInstanceOf(ShadowInvalidConfigError);
|
|
191
192
|
const detail = (r.error as unknown as ShadowInvalidConfigError).detail;
|
|
192
193
|
expect(detail.field).toBe('farPlane');
|
|
193
|
-
expect(detail.
|
|
194
|
-
expect(detail.
|
|
194
|
+
expect(detail.actual).toBe(0.5);
|
|
195
|
+
expect(detail.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1.0 });
|
|
195
196
|
}
|
|
196
197
|
});
|
|
197
198
|
|
|
@@ -609,6 +610,62 @@ function spawnValidatedPointShadow(
|
|
|
609
610
|
expect(packed[15]).toBeCloseTo(3, 5);
|
|
610
611
|
});
|
|
611
612
|
|
|
613
|
+
it('keeps authored spot shadow intensity and PCF width in the cluster lanes', () => {
|
|
614
|
+
const spotSnap = {
|
|
615
|
+
kind: 'spot' as const,
|
|
616
|
+
entity: 0,
|
|
617
|
+
position: vec3.create(0, 0, 0),
|
|
618
|
+
direction: vec3.create(0, -1, 0),
|
|
619
|
+
color: vec3.create(1, 1, 1),
|
|
620
|
+
intensity: 1,
|
|
621
|
+
invRangeSquared: 0,
|
|
622
|
+
cosInner: 0.95,
|
|
623
|
+
cosOuter: 0.85,
|
|
624
|
+
castShadow: true,
|
|
625
|
+
lightViewProj: undefined,
|
|
626
|
+
mapSize: 2048,
|
|
627
|
+
nearPlane: 0.1,
|
|
628
|
+
farPlane: 50,
|
|
629
|
+
shadowAtlasTile: 2,
|
|
630
|
+
shadowIntensity: 0.35,
|
|
631
|
+
pcfKernelSize: 5,
|
|
632
|
+
};
|
|
633
|
+
const packed = packLightSlot(spotSnap);
|
|
634
|
+
const i32 = new Int32Array(packed.buffer);
|
|
635
|
+
expect(i32[13]).toBe(2);
|
|
636
|
+
expect(packed[14]).toBeCloseTo(0.35, 5);
|
|
637
|
+
expect(packed[15]).toBeCloseTo(5, 5);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('marks the accepted projector without losing a shadow tile', () => {
|
|
641
|
+
const spotSnap = {
|
|
642
|
+
kind: 'spot' as const,
|
|
643
|
+
entity: 7,
|
|
644
|
+
position: vec3.create(0, 2, 0),
|
|
645
|
+
direction: vec3.create(0, -1, 0),
|
|
646
|
+
color: vec3.create(1, 1, 1),
|
|
647
|
+
intensity: 1,
|
|
648
|
+
invRangeSquared: 0.04,
|
|
649
|
+
cosInner: 0.95,
|
|
650
|
+
cosOuter: 0.85,
|
|
651
|
+
castShadow: true,
|
|
652
|
+
lightViewProj: new Float32Array(16),
|
|
653
|
+
mapSize: 512,
|
|
654
|
+
nearPlane: 0.1,
|
|
655
|
+
farPlane: 25,
|
|
656
|
+
shadowAtlasTile: 2,
|
|
657
|
+
projectorAsset: { kind: 'texture' } as never,
|
|
658
|
+
};
|
|
659
|
+
const packed = packLightSlot(spotSnap, undefined, true);
|
|
660
|
+
const encoded = new Int32Array(packed.buffer)[13] ?? 0;
|
|
661
|
+
expect(encoded & SPOT_LIGHT_PROJECTOR_FLAG).toBe(SPOT_LIGHT_PROJECTOR_FLAG);
|
|
662
|
+
expect(encoded & (SPOT_LIGHT_PROJECTOR_FLAG - 1)).toBe(2);
|
|
663
|
+
|
|
664
|
+
const projectorOnly = packLightSlot({ ...spotSnap, shadowAtlasTile: -1 }, undefined, true);
|
|
665
|
+
const projectorOnlyEncoded = new Int32Array(projectorOnly.buffer)[13] ?? 0;
|
|
666
|
+
expect(projectorOnlyEncoded).toBe(SPOT_LIGHT_PROJECTOR_FLAG);
|
|
667
|
+
});
|
|
668
|
+
|
|
612
669
|
it('AC-05: LIGHTSLOT_LAYOUT byteSize unchanged at 64 (no-regression for standard)', () => {
|
|
613
670
|
// The pad-lane re-purposing must not change the total LightSlot size:
|
|
614
671
|
// the std430 vec4 stride is preserved; only the field semantics within
|
|
@@ -40,6 +40,8 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
|
|
|
40
40
|
return code;
|
|
41
41
|
case 'taa-unavailable':
|
|
42
42
|
return code;
|
|
43
|
+
case 'standard-profile-invalid':
|
|
44
|
+
return code;
|
|
43
45
|
case 'frame-receipt-stale':
|
|
44
46
|
return code;
|
|
45
47
|
case 'renderer-contract-failed':
|
|
@@ -64,11 +66,23 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
|
|
|
64
66
|
return code;
|
|
65
67
|
case 'equirect-projection-failed':
|
|
66
68
|
return code;
|
|
67
|
-
case '
|
|
69
|
+
case 'standard-light-budget-exceeded':
|
|
70
|
+
return code;
|
|
71
|
+
case 'standard-cluster-index-overflow':
|
|
72
|
+
return code;
|
|
73
|
+
case 'standard-cluster-transport-unavailable':
|
|
74
|
+
return code;
|
|
75
|
+
case 'render-target-descriptor-invalid':
|
|
76
|
+
return code;
|
|
77
|
+
case 'render-target-capability-missing':
|
|
68
78
|
return code;
|
|
69
|
-
case '
|
|
79
|
+
case 'render-target-state-invalid':
|
|
70
80
|
return code;
|
|
71
|
-
case '
|
|
81
|
+
case 'render-target-operation-failed':
|
|
82
|
+
return code;
|
|
83
|
+
case 'reflection-probe-budget-exceeded':
|
|
84
|
+
return code;
|
|
85
|
+
case 'render-intent-invalid':
|
|
72
86
|
return code;
|
|
73
87
|
case 'point-shadow-atlas-uninitialized':
|
|
74
88
|
return code;
|
|
@@ -186,6 +200,10 @@ function narrowRenderError(err: RenderError): void {
|
|
|
186
200
|
void err.detail.operation;
|
|
187
201
|
void err.detail.causes;
|
|
188
202
|
break;
|
|
203
|
+
case 'standard-profile-invalid':
|
|
204
|
+
void err.detail.field;
|
|
205
|
+
void err.detail.actual;
|
|
206
|
+
break;
|
|
189
207
|
case 'frame-receipt-stale':
|
|
190
208
|
void err.detail.frameId;
|
|
191
209
|
void err.detail.receiptGeneration;
|
|
@@ -210,7 +228,9 @@ function narrowRenderError(err: RenderError): void {
|
|
|
210
228
|
break;
|
|
211
229
|
case 'shadow-invalid-config':
|
|
212
230
|
void err.detail.field; // string
|
|
213
|
-
void err.detail.
|
|
231
|
+
void err.detail.actual; // number
|
|
232
|
+
void err.detail.bound;
|
|
233
|
+
void err.detail.reason; // string
|
|
214
234
|
break;
|
|
215
235
|
case 'environment-source-conflict':
|
|
216
236
|
void err.detail.owners;
|
|
@@ -237,17 +257,51 @@ function narrowRenderError(err: RenderError): void {
|
|
|
237
257
|
case 'equirect-projection-failed':
|
|
238
258
|
void err.detail.handle; // number
|
|
239
259
|
break;
|
|
240
|
-
case '
|
|
260
|
+
case 'standard-light-budget-exceeded':
|
|
241
261
|
void err.detail.actual; // number
|
|
242
262
|
void err.detail.budget; // number
|
|
243
263
|
break;
|
|
244
|
-
case '
|
|
264
|
+
case 'standard-cluster-index-overflow':
|
|
245
265
|
void err.detail.actual; // number
|
|
246
266
|
void err.detail.capacity; // number
|
|
247
267
|
break;
|
|
248
|
-
case '
|
|
249
|
-
void err.detail.
|
|
250
|
-
void err.detail.
|
|
268
|
+
case 'standard-cluster-transport-unavailable':
|
|
269
|
+
void err.detail.requested; // number
|
|
270
|
+
void err.detail.admitted; // 0
|
|
271
|
+
break;
|
|
272
|
+
case 'render-target-descriptor-invalid':
|
|
273
|
+
void err.detail.field;
|
|
274
|
+
void err.detail.value;
|
|
275
|
+
void err.detail.expected;
|
|
276
|
+
break;
|
|
277
|
+
case 'render-target-capability-missing':
|
|
278
|
+
void err.detail.operation;
|
|
279
|
+
void err.detail.requested;
|
|
280
|
+
void err.detail.capability;
|
|
281
|
+
void err.detail.actual;
|
|
282
|
+
break;
|
|
283
|
+
case 'render-target-state-invalid':
|
|
284
|
+
void err.detail.operation;
|
|
285
|
+
void err.detail.reason;
|
|
286
|
+
void err.detail.state;
|
|
287
|
+
void err.detail.generation;
|
|
288
|
+
break;
|
|
289
|
+
case 'render-target-operation-failed':
|
|
290
|
+
void err.detail.operation;
|
|
291
|
+
void err.detail.stage;
|
|
292
|
+
void err.detail.generation;
|
|
293
|
+
void err.detail.cause;
|
|
294
|
+
void err.detail.recovery;
|
|
295
|
+
break;
|
|
296
|
+
case 'reflection-probe-budget-exceeded':
|
|
297
|
+
void err.detail.actual;
|
|
298
|
+
void err.detail.budget;
|
|
299
|
+
break;
|
|
300
|
+
case 'render-intent-invalid':
|
|
301
|
+
void err.detail.component;
|
|
302
|
+
void err.detail.field;
|
|
303
|
+
void err.detail.value;
|
|
304
|
+
void err.detail.allowed;
|
|
251
305
|
break;
|
|
252
306
|
case 'point-shadow-atlas-uninitialized':
|
|
253
307
|
// No detail on this class.
|
|
@@ -436,10 +436,11 @@ function drawPublished(renderer: RendererType, world: WorldType) {
|
|
|
436
436
|
|
|
437
437
|
// DirectionalLight with merged shadow fields.
|
|
438
438
|
expect(errors).toHaveLength(0);
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
expect(log.
|
|
439
|
+
// Standard Cluster owns the shadow, G-buffer, lighting, forward, and
|
|
440
|
+
// output topology stages. Keep this structural rather than freezing a
|
|
441
|
+
// pass-count literal that changes when a named stage is split.
|
|
442
|
+
expect(log.beginRenderPassCount).toBeGreaterThan(log.queueSubmitCount);
|
|
443
|
+
expect(log.setPipelineCount).toBeGreaterThan(0);
|
|
443
444
|
expect(log.drawIndexedCount).toBe(1);
|
|
444
445
|
expect(log.encoderFinishCount).toBe(1); // one typed graph command encoder
|
|
445
446
|
expect(log.queueSubmitCount).toBe(1); // one submission for the frame graph
|
|
@@ -592,9 +593,13 @@ function drawPublished(renderer: RendererType, world: WorldType) {
|
|
|
592
593
|
|
|
593
594
|
// Soft path: no error fired (D-Q7 mirroring; LO §1.1 minimum semantic).
|
|
594
595
|
expect(errors).toHaveLength(0);
|
|
595
|
-
// Empty geometry does not remove graph-owned
|
|
596
|
+
// Empty geometry does not remove graph-owned attachment and output passes.
|
|
597
|
+
// With no DirectionalLight, exact-zero topology omits the directional
|
|
598
|
+
// shadow target and pass while retaining the spot/point attachments.
|
|
599
|
+
// The graph-owned final output transform is recorded after the frame
|
|
600
|
+
// attachment pass even when the scene has no geometry.
|
|
596
601
|
expect(log.beginRenderPassCount).toBe(12);
|
|
597
|
-
expect(log.queueSubmitCount).toBe(8); //
|
|
602
|
+
expect(log.queueSubmitCount).toBe(8); // spot + 6 cube faces + frame
|
|
598
603
|
// No geometry submitted.
|
|
599
604
|
expect(log.drawIndexedCount).toBe(0);
|
|
600
605
|
});
|
|
@@ -703,7 +708,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
|
|
|
703
708
|
Object.assign(log, makeLog());
|
|
704
709
|
drawPublished(renderer, world as WorldType);
|
|
705
710
|
|
|
706
|
-
expect(errors.some((e) => e.code === 'render-system-multi-camera')).toBe(
|
|
711
|
+
expect(errors.some((e) => e.code === 'render-system-multi-camera')).toBe(false);
|
|
707
712
|
// First archetype hit still rendered.
|
|
708
713
|
expect(log.drawIndexedCount).toBeGreaterThanOrEqual(1);
|
|
709
714
|
});
|
|
@@ -822,45 +827,28 @@ function drawPublished(renderer: RendererType, world: WorldType) {
|
|
|
822
827
|
// registration, the clear pass still runs (so visual debugging shows
|
|
823
828
|
// the cleared canvas rather than a stale frame).
|
|
824
829
|
// The invalid entity is skipped, while graph-owned attachment passes are
|
|
825
|
-
// still recorded after the
|
|
830
|
+
// still recorded after the spot and six cube-face attachment passes.
|
|
831
|
+
// The graph-owned final output transform still runs after an invalid
|
|
832
|
+
// renderable is skipped, preserving a visible cleared frame.
|
|
826
833
|
expect(log.beginRenderPassCount).toBe(12);
|
|
827
|
-
expect(log.queueSubmitCount).toBe(8); //
|
|
834
|
+
expect(log.queueSubmitCount).toBe(8); // spot + 6 cube faces + frame
|
|
828
835
|
});
|
|
829
836
|
|
|
830
837
|
it('internal exception fires webgpu-runtime-error with .detail = { error: string } and skips frame', async () => {
|
|
831
|
-
// Inject a
|
|
832
|
-
//
|
|
838
|
+
// Inject a raw command-encoder failure only after renderer construction
|
|
839
|
+
// has completed. RHI device creation intentionally leaves this raw
|
|
840
|
+
// boundary synchronous, so RenderSystem's outer try/catch must surface
|
|
841
|
+
// the failure as webgpu-runtime-error instead of a ready-time failure.
|
|
833
842
|
const log = makeLog();
|
|
834
843
|
const { device } = makeMockGPUDevice(log);
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
// instance fallback storage buffer seed (feat-20260513-instanced-
|
|
844
|
-
// mesh M3 T-M3-2) + 1 for the skin-palette-identity seed
|
|
845
|
-
// (feat-20260611 R2 / M8 / w28 IS-14, 255 identity mat4 = 16320 B)
|
|
846
|
-
// + 1 for the built-in tonemap params UBO defaultValue seed at register
|
|
847
|
-
// (feat-20260621 M-A3 / D-5: postProcess.register('forgeax::tonemap')
|
|
848
|
-
// eager-writes its 16 B defaultValue during ready).
|
|
849
|
-
// feat-20260625-spot-light-shadow-mapping w25 (scope-amend webkit-
|
|
850
|
-
// fallback): the boot-time spot lightViewProj UBO 256 B zero-init seed
|
|
851
|
-
// is GONE — the spot matrices folded into the View UBO tail (no standalone
|
|
852
|
-
// buffer to zero-init), so the boot writeBuffer count dropped 14 -> 13.
|
|
853
|
-
// HANDLE_QUAD joined the builtin upload loop in
|
|
854
|
-
// feat-20260520-2d-sprite-layer-mvp post-merge fix; HANDLE_SPHERE
|
|
855
|
-
// (id=4) joined in feat-20260529-fxaa-runtime-toggle;
|
|
856
|
-
// HANDLE_NINESLICE_QUAD (id=5) joined in feat-20260527-sprite-
|
|
857
|
-
// nineslice M2 / w12.
|
|
858
|
-
// Only fail after ready completes (call 14+) so the failure
|
|
859
|
-
// surfaces in RenderSystem rather than the ready Promise.
|
|
860
|
-
if (writeCallCount > 13) {
|
|
861
|
-
throw new Error('mock: writeBuffer NaN payload');
|
|
862
|
-
}
|
|
863
|
-
return originalWrite.call(baseQueue, ...args);
|
|
844
|
+
const raw = device as {
|
|
845
|
+
createCommandEncoder: (...args: unknown[]) => unknown;
|
|
846
|
+
};
|
|
847
|
+
const originalCreateCommandEncoder = raw.createCommandEncoder;
|
|
848
|
+
let failRecord = false;
|
|
849
|
+
raw.createCommandEncoder = (...args: unknown[]) => {
|
|
850
|
+
if (failRecord) throw new Error('mock: createCommandEncoder failure');
|
|
851
|
+
return originalCreateCommandEncoder(...args);
|
|
864
852
|
};
|
|
865
853
|
vi.stubGlobal('navigator', { ...baseNavigator, gpu: makeMockGPU(device) });
|
|
866
854
|
const engine = await importEngine();
|
|
@@ -868,6 +856,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
|
|
|
868
856
|
const renderer = (
|
|
869
857
|
await engine.createRenderer(canvas, {}, { shaderManifestUrl: buildManifestDataUrl() })
|
|
870
858
|
).unwrap();
|
|
859
|
+
failRecord = true;
|
|
871
860
|
|
|
872
861
|
const { World } = await importEcs();
|
|
873
862
|
const C = await importComponents();
|
|
@@ -907,7 +896,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
|
|
|
907
896
|
| undefined;
|
|
908
897
|
expect(runtimeDetail?.error).toBeDefined();
|
|
909
898
|
expect(typeof runtimeDetail?.error?.message).toBe('string');
|
|
910
|
-
expect(runtimeDetail?.error?.message).toContain('
|
|
899
|
+
expect(runtimeDetail?.error?.message).toContain('createCommandEncoder');
|
|
911
900
|
});
|
|
912
901
|
});
|
|
913
902
|
|
|
@@ -116,6 +116,7 @@ function makeMockGPUDevice(spies: DeviceSpies, maxTextureDimension2D = 8192): {
|
|
|
116
116
|
limits: { maxTextureDimension2D },
|
|
117
117
|
queue: {
|
|
118
118
|
submit: () => undefined,
|
|
119
|
+
onSubmittedWorkDone: async () => undefined,
|
|
119
120
|
writeBuffer: () => undefined,
|
|
120
121
|
writeTexture: () => undefined,
|
|
121
122
|
},
|
|
@@ -486,7 +487,9 @@ describe('record: per-submesh PBR material BG textureView (bug-20260610 D2 regre
|
|
|
486
487
|
const { renderer } = await setupRenderer(spies);
|
|
487
488
|
const errors: string[] = [];
|
|
488
489
|
renderer.subscribe((event) => {
|
|
489
|
-
if (event.kind === 'error')
|
|
490
|
+
if (event.kind === 'error') {
|
|
491
|
+
errors.push(event.error.code);
|
|
492
|
+
}
|
|
490
493
|
});
|
|
491
494
|
|
|
492
495
|
const world = await spawnPbrMultiMaterialScene();
|