@forgeax/engine-runtime 0.1.7 → 0.1.20
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__/helpers/tilemap-assets.d.ts.map +1 -1
- package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
- package/dist/__tests__/material-texture-dimension-mismatch.unit.test.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/__tests__/texture-dimensions.browser.test.d.ts +2 -0
- package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
- package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
- package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
- package/dist/backend-selection.d.ts.map +1 -1
- package/dist/createRenderer.d.ts.map +1 -1
- package/dist/index.mjs +21 -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__/asset-registry.recursive.spec.ts +12 -18
- package/src/__tests__/asset.unit.test.ts +28 -25
- package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
- package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
- package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +2 -3
- package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +12 -11
- package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
- package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
- package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
- package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
- package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
- package/src/__tests__/derive-bgl-integration.test.ts +11 -0
- package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
- package/src/__tests__/errors.unit.test.ts +56 -0
- package/src/__tests__/extract-record-no-hardcoded-texture-fields.test.ts +4 -5
- package/src/__tests__/four-path-compression.integration.test.ts +2 -2
- package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +461 -18
- package/src/__tests__/geometry.unit.test.ts +3 -1
- package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
- package/src/__tests__/helpers/standard-material-manifest.ts +45 -0
- package/src/__tests__/helpers/tilemap-assets.ts +3 -4
- package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +4 -2
- package/src/__tests__/lights.unit.test.ts +38 -17
- package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
- package/src/__tests__/loader-name-survival.test.ts +2 -3
- package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
- package/src/__tests__/material-texture-uv-scale.unit.test.ts +18 -16
- package/src/__tests__/materials.unit.test.ts +2 -0
- package/src/__tests__/mip-gate.unit.test.ts +11 -8
- package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
- package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
- package/src/__tests__/pbr-pipeline.unit.test.ts +31 -12
- package/src/__tests__/pipeline-cache-keying.unit.test.ts +6 -4
- package/src/__tests__/pipeline.unit.test.ts +23 -21
- package/src/__tests__/point-light-shadow.unit.test.ts +6 -5
- package/src/__tests__/render-environment-consumer.integration.test.ts +33 -0
- package/src/__tests__/render-error-exhaustive.test-d.ts +86 -0
- package/src/__tests__/render-feature-prepared-graphics.fixture.ts +10 -0
- package/src/__tests__/render-system-extract.test.ts +4 -6
- package/src/__tests__/render-system-mega.test.ts +3 -1
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +7 -7
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -4
- 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__/shadow-fields-observable.dawn.test.ts +56 -39
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +385 -0
- package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -9
- package/src/__tests__/ssao-passes.test.ts +28 -12
- package/src/__tests__/systems.unit.test.ts +63 -33
- package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
- package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
- package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
- package/src/backend-selection.ts +4 -0
- package/src/createRenderer.ts +3 -0
- package/src/renderer-host.ts +30 -3
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// Dawn integration tests for FullscreenPostProcessPass:
|
|
5
5
|
// (a) AC-06: the typed fullscreen pass samples input texture and writes to target,
|
|
6
6
|
// producing visible pixel output (non-black readback).
|
|
7
|
-
// (b) AC-09: FXAA OFF/ON dual-pass
|
|
8
|
-
//
|
|
7
|
+
// (b) AC-09: FXAA OFF/ON dual-pass readback with independent byte and
|
|
8
|
+
// affected-pixel diagnostics.
|
|
9
9
|
// (c) R-COLORSPACE falsify: a variant that writes the swap-chain through the
|
|
10
10
|
// srgb view confirms the dual-pass comparison has discriminability
|
|
11
11
|
// (the sRGB-pass variant produces different bytes).
|
|
@@ -22,10 +22,19 @@ import type { Renderer } from '@forgeax/engine-render';
|
|
|
22
22
|
import {
|
|
23
23
|
ANTIALIAS_FXAA,
|
|
24
24
|
ANTIALIAS_NONE,
|
|
25
|
+
ANTIALIAS_TAA,
|
|
25
26
|
Camera,
|
|
27
|
+
Materials,
|
|
26
28
|
MeshFilter,
|
|
27
29
|
MeshRenderer,
|
|
30
|
+
MotionBlur,
|
|
28
31
|
} from '@forgeax/engine-render';
|
|
32
|
+
import { validateGraphTargetCaptureReadback } from '@forgeax/engine-render/internal';
|
|
33
|
+
import type {
|
|
34
|
+
RendererHostAssembly,
|
|
35
|
+
RendererLegacyHostAdapter,
|
|
36
|
+
} from '@forgeax/engine-render/internal/construct-renderer';
|
|
37
|
+
import type { Buffer } from '@forgeax/engine-rhi';
|
|
29
38
|
import { Transform } from '@forgeax/engine-scene';
|
|
30
39
|
import { describe, expect, it } from 'vitest';
|
|
31
40
|
import { constructRuntimeRendererHost } from '../renderer-host';
|
|
@@ -42,6 +51,13 @@ const TEXTURE_USAGE_RENDER_ATTACHMENT = 0x10;
|
|
|
42
51
|
const BUFFER_USAGE_MAP_READ = 0x0001;
|
|
43
52
|
const BUFFER_USAGE_COPY_DST = 0x0008;
|
|
44
53
|
const MAP_MODE_READ = 0x0001;
|
|
54
|
+
const BUFFER_USAGE_MAP_READ_RHI = 0x0001;
|
|
55
|
+
const BUFFER_USAGE_COPY_DST_RHI = 0x0008;
|
|
56
|
+
type DebugDrawHost = RendererHostAssembly['debugDrawHost'] &
|
|
57
|
+
Pick<
|
|
58
|
+
RendererLegacyHostAdapter,
|
|
59
|
+
'getCurrentGraphTarget' | 'requestGraphTargetCapture' | 'onError'
|
|
60
|
+
>;
|
|
45
61
|
|
|
46
62
|
const ENGINE_MANIFEST = await (async () => {
|
|
47
63
|
const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
|
|
@@ -75,6 +91,140 @@ async function doReadPixels(device: GPUDevice, renderTarget: GPUTexture): Promis
|
|
|
75
91
|
return bytes;
|
|
76
92
|
}
|
|
77
93
|
|
|
94
|
+
function pixelHash(pixels: Uint8Array): string {
|
|
95
|
+
let hash = 2166136261;
|
|
96
|
+
for (const byte of pixels) hash = Math.imul(hash ^ byte, 16777619);
|
|
97
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pixelDiffSummary(none: Uint8Array, fxaa: Uint8Array): string {
|
|
101
|
+
const channelDiffs = [0, 0, 0, 0];
|
|
102
|
+
const channelDelta = [0, 0, 0, 0];
|
|
103
|
+
let differingPixels = 0;
|
|
104
|
+
for (let offset = 0; offset < none.length; offset += 4) {
|
|
105
|
+
let pixelDiffers = false;
|
|
106
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
107
|
+
const delta = Math.abs((none[offset + channel] ?? 0) - (fxaa[offset + channel] ?? 0));
|
|
108
|
+
if (delta !== 0) {
|
|
109
|
+
pixelDiffers = true;
|
|
110
|
+
channelDiffs[channel] = (channelDiffs[channel] ?? 0) + 1;
|
|
111
|
+
channelDelta[channel] = (channelDelta[channel] ?? 0) + delta;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (pixelDiffers) differingPixels += 1;
|
|
115
|
+
}
|
|
116
|
+
const centerOffset = ((HEIGHT >> 1) * WIDTH + (WIDTH >> 1)) * 4;
|
|
117
|
+
const channels = (pixels: Uint8Array, offset: number) =>
|
|
118
|
+
Array.from(pixels.subarray(offset, offset + 4));
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
noneHash: pixelHash(none),
|
|
121
|
+
fxaaHash: pixelHash(fxaa),
|
|
122
|
+
differingPixels,
|
|
123
|
+
channelDiffs,
|
|
124
|
+
channelDelta,
|
|
125
|
+
center: { none: channels(none, centerOffset), fxaa: channels(fxaa, centerOffset) },
|
|
126
|
+
background: { none: channels(none, 4), fxaa: channels(fxaa, 4) },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function decodeFloat16(lo: number, hi: number): number {
|
|
131
|
+
const bits = lo | (hi << 8);
|
|
132
|
+
const sign = (bits & 0x8000) === 0 ? 1 : -1;
|
|
133
|
+
const exponent = (bits >>> 10) & 0x1f;
|
|
134
|
+
const fraction = bits & 0x03ff;
|
|
135
|
+
if (exponent === 0) return sign * 2 ** -14 * (fraction / 1024);
|
|
136
|
+
if (exponent === 0x1f) return fraction === 0 ? sign * Infinity : Number.NaN;
|
|
137
|
+
return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function prepareOutputTransformCapture(host: DebugDrawHost):
|
|
141
|
+
| {
|
|
142
|
+
readonly buffer: Buffer;
|
|
143
|
+
readonly bytesPerRow: number;
|
|
144
|
+
readonly width: number;
|
|
145
|
+
readonly height: number;
|
|
146
|
+
}
|
|
147
|
+
| undefined {
|
|
148
|
+
const bytesPerRow = Math.ceil((WIDTH * 8) / 256) * 256;
|
|
149
|
+
const buffer = host.device.createBuffer({
|
|
150
|
+
size: bytesPerRow * HEIGHT,
|
|
151
|
+
usage: BUFFER_USAGE_MAP_READ_RHI | BUFFER_USAGE_COPY_DST_RHI,
|
|
152
|
+
});
|
|
153
|
+
if (!buffer.ok) return undefined;
|
|
154
|
+
const sentinel = new Uint8Array(16).fill(0xa5);
|
|
155
|
+
const initialized = host.device.queue.writeBuffer(buffer.value, 0, sentinel);
|
|
156
|
+
if (!initialized.ok) return undefined;
|
|
157
|
+
host.requestGraphTargetCapture({
|
|
158
|
+
name: 'standard-output-color',
|
|
159
|
+
buffer: buffer.value,
|
|
160
|
+
bytesPerRow,
|
|
161
|
+
width: WIDTH,
|
|
162
|
+
height: HEIGHT,
|
|
163
|
+
expected: {
|
|
164
|
+
format: 'rgba16float',
|
|
165
|
+
width: WIDTH,
|
|
166
|
+
height: HEIGHT,
|
|
167
|
+
usage: TEXTURE_USAGE_COPY_SRC,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
return { buffer: buffer.value, bytesPerRow, width: WIDTH, height: HEIGHT };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readOutputTransformCapture(
|
|
174
|
+
host: DebugDrawHost,
|
|
175
|
+
capture: ReturnType<typeof prepareOutputTransformCapture>,
|
|
176
|
+
receiptFrameId: number,
|
|
177
|
+
): Promise<
|
|
178
|
+
| {
|
|
179
|
+
readonly graphFrameId: number;
|
|
180
|
+
readonly graphGeneration: number;
|
|
181
|
+
readonly targetName: string;
|
|
182
|
+
readonly textureIdentity: number;
|
|
183
|
+
readonly receiptFrameId: number;
|
|
184
|
+
readonly format: string;
|
|
185
|
+
readonly size: { readonly width: number; readonly height: number };
|
|
186
|
+
readonly usage: number;
|
|
187
|
+
readonly center: readonly number[];
|
|
188
|
+
readonly captureWritten: boolean;
|
|
189
|
+
}
|
|
190
|
+
| undefined
|
|
191
|
+
> {
|
|
192
|
+
const target = host.getCurrentGraphTarget('standard-output-color');
|
|
193
|
+
if (target === undefined || capture === undefined) return undefined;
|
|
194
|
+
await host.device.queue.onSubmittedWorkDone();
|
|
195
|
+
const mapped = await capture.buffer.mapAsync(MAP_MODE_READ);
|
|
196
|
+
if (!mapped.ok) return undefined;
|
|
197
|
+
const range = mapped.value.getMappedRange();
|
|
198
|
+
if (!range.ok) return undefined;
|
|
199
|
+
const bytes = new Uint8Array(range.value.slice(0));
|
|
200
|
+
mapped.value.unmap();
|
|
201
|
+
host.device.destroyBuffer(capture.buffer);
|
|
202
|
+
const { width, height } = capture;
|
|
203
|
+
const { bytesPerRow } = capture;
|
|
204
|
+
const validation = validateGraphTargetCaptureReadback({
|
|
205
|
+
bytes,
|
|
206
|
+
expectedByteLength: bytesPerRow * height,
|
|
207
|
+
sentinel: new Uint8Array(16).fill(0xa5),
|
|
208
|
+
});
|
|
209
|
+
if (!validation.ok) throw new Error(`graph-target-capture:${validation.code}`);
|
|
210
|
+
const centerOffset = (height >> 1) * bytesPerRow + (width >> 1) * 8;
|
|
211
|
+
return {
|
|
212
|
+
graphFrameId: target.frameId,
|
|
213
|
+
graphGeneration: target.graphGeneration,
|
|
214
|
+
targetName: target.name,
|
|
215
|
+
textureIdentity: target.textureIdentity,
|
|
216
|
+
receiptFrameId,
|
|
217
|
+
format: target.descriptor.format,
|
|
218
|
+
size: { width, height },
|
|
219
|
+
usage: target.descriptor.usage,
|
|
220
|
+
center: [0, 1, 2, 3].map((channel) => {
|
|
221
|
+
const offset = centerOffset + channel * 2;
|
|
222
|
+
return decodeFloat16(bytes[offset] ?? 0, bytes[offset + 1] ?? 0);
|
|
223
|
+
}),
|
|
224
|
+
captureWritten: bytes.some((byte, index) => index < 16 && byte !== 0xa5),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
78
228
|
function spawnCubeScene(world: World, antialias: number): void {
|
|
79
229
|
world.spawn(
|
|
80
230
|
{
|
|
@@ -111,6 +261,73 @@ function spawnCubeScene(world: World, antialias: number): void {
|
|
|
111
261
|
);
|
|
112
262
|
}
|
|
113
263
|
|
|
264
|
+
function spawnTemporalCubeScene(world: World) {
|
|
265
|
+
const material = world.allocSharedRef('MaterialAsset', Materials.unlit([0.9, 0.3, 0.25, 1]));
|
|
266
|
+
const cube = world
|
|
267
|
+
.spawn(
|
|
268
|
+
{
|
|
269
|
+
component: Transform,
|
|
270
|
+
data: {
|
|
271
|
+
pos: [0, 0, 0],
|
|
272
|
+
quat: [0, 0, 0, 1],
|
|
273
|
+
scale: [1, 1, 1],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
277
|
+
{ component: MeshRenderer, data: { materials: [material] } },
|
|
278
|
+
)
|
|
279
|
+
.unwrap();
|
|
280
|
+
world
|
|
281
|
+
.spawn(
|
|
282
|
+
{
|
|
283
|
+
component: Transform,
|
|
284
|
+
data: {
|
|
285
|
+
pos: [0, 0, 4],
|
|
286
|
+
quat: [0, 0, 0, 1],
|
|
287
|
+
scale: [1, 1, 1],
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
component: Camera,
|
|
292
|
+
data: {
|
|
293
|
+
fov: Math.PI / 3,
|
|
294
|
+
aspect: WIDTH / HEIGHT,
|
|
295
|
+
near: 0.1,
|
|
296
|
+
far: 100,
|
|
297
|
+
antialias: ANTIALIAS_TAA,
|
|
298
|
+
clearColor: [0, 0, 0, 1],
|
|
299
|
+
} as Record<string, unknown> as never,
|
|
300
|
+
},
|
|
301
|
+
{ component: MotionBlur, data: { shutterAngle: 180, maxRadiusPixels: 32, sampleCount: 8 } },
|
|
302
|
+
)
|
|
303
|
+
.unwrap();
|
|
304
|
+
return cube;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function spawnLinearClearScene(world: World, antialias: number, linear: number): void {
|
|
308
|
+
world.spawn(
|
|
309
|
+
{
|
|
310
|
+
component: Transform,
|
|
311
|
+
data: {
|
|
312
|
+
pos: [0, 0, 3],
|
|
313
|
+
quat: [0, 0, 0, 1],
|
|
314
|
+
scale: [1, 1, 1],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
component: Camera,
|
|
319
|
+
data: {
|
|
320
|
+
fov: Math.PI / 4,
|
|
321
|
+
aspect: 1,
|
|
322
|
+
near: 0.1,
|
|
323
|
+
far: 100,
|
|
324
|
+
antialias,
|
|
325
|
+
clearColor: [linear, linear, linear, 1],
|
|
326
|
+
} as Record<string, unknown> as never,
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
114
331
|
/**
|
|
115
332
|
* Build a renderer with a mock canvas + offscreen render target, following the
|
|
116
333
|
* fxaa-pixel-diff.dawn.test.ts pattern. Returns the renderer, captured device,
|
|
@@ -120,6 +337,7 @@ async function setupRenderer(): Promise<{
|
|
|
120
337
|
renderer: Renderer;
|
|
121
338
|
device: GPUDevice;
|
|
122
339
|
renderTarget: GPUTexture;
|
|
340
|
+
debugDrawHost: DebugDrawHost;
|
|
123
341
|
}> {
|
|
124
342
|
if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') {
|
|
125
343
|
throw new Error('dawn-node navigator.gpu not injected; vitest.setup-webgpu.ts regressed');
|
|
@@ -193,7 +411,8 @@ async function setupRenderer(): Promise<{
|
|
|
193
411
|
// Create render target eagerly after capturing the shared device.
|
|
194
412
|
const rt = getOrCreateRt(device);
|
|
195
413
|
|
|
196
|
-
|
|
414
|
+
const debugDrawHost = host.value.debugDrawHost as DebugDrawHost;
|
|
415
|
+
return { renderer, device, renderTarget: rt, debugDrawHost };
|
|
197
416
|
}
|
|
198
417
|
|
|
199
418
|
describe('feat-20260604 M2 w10: FullscreenPostProcessPass dawn tests', () => {
|
|
@@ -233,15 +452,18 @@ describe('feat-20260604 M2 w10: FullscreenPostProcessPass dawn tests', () => {
|
|
|
233
452
|
expect(r + g + b, 'center pixel must not be black — cube should render').toBeGreaterThan(0);
|
|
234
453
|
});
|
|
235
454
|
|
|
236
|
-
it('AC-09: FXAA OFF/ON dual-pass
|
|
237
|
-
// AC-09
|
|
238
|
-
//
|
|
239
|
-
//
|
|
455
|
+
it('AC-09: FXAA OFF/ON dual-pass preserves paired readback diagnostics', async () => {
|
|
456
|
+
// AC-09 keeps the paired readback and surface-path evidence. The old
|
|
457
|
+
// repair-local byte/pixel epsilon gate was dimensionally invalid: RGB byte
|
|
458
|
+
// differences cannot be compared with a pixel count. Keep both quantities
|
|
459
|
+
// as independent diagnostics while the fixture owns visual acceptance.
|
|
240
460
|
//
|
|
241
461
|
// NOTE: There is no reference PNG. The assertion is that two distinct
|
|
242
462
|
// render passes produce measurable differences (proving FXAA is active)
|
|
243
463
|
// while both produce visible geometry (proving nothing is broken).
|
|
244
|
-
const { renderer, device, renderTarget } = await setupRenderer();
|
|
464
|
+
const { renderer, device, renderTarget, debugDrawHost } = await setupRenderer();
|
|
465
|
+
const renderErrors: unknown[] = [];
|
|
466
|
+
const removeErrorListener = debugDrawHost.onError((error) => renderErrors.push(error));
|
|
245
467
|
|
|
246
468
|
// Pass 1: antialias='none' baseline.
|
|
247
469
|
const worldNone = new World();
|
|
@@ -256,8 +478,14 @@ describe('feat-20260604 M2 w10: FullscreenPostProcessPass dawn tests', () => {
|
|
|
256
478
|
environment: { lease: attachmentNone.value },
|
|
257
479
|
});
|
|
258
480
|
expect(drawnNone.ok).toBe(true);
|
|
481
|
+
if (!drawnNone.ok) throw drawnNone.error;
|
|
259
482
|
await device.queue.onSubmittedWorkDone();
|
|
483
|
+
const standardOutputNone = {
|
|
484
|
+
receiptFrameId: drawnNone.value.frameId,
|
|
485
|
+
target: 'not-present-on-no-fxaa-fast-path',
|
|
486
|
+
} as const;
|
|
260
487
|
const pixelsNone = await doReadPixels(device, renderTarget);
|
|
488
|
+
const inspectionNone = renderer.inspect();
|
|
261
489
|
|
|
262
490
|
// Pass 2: antialias='fxaa'.
|
|
263
491
|
const worldFxaa = new World();
|
|
@@ -266,14 +494,38 @@ describe('feat-20260604 M2 w10: FullscreenPostProcessPass dawn tests', () => {
|
|
|
266
494
|
expect(attachmentFxaa.ok).toBe(true);
|
|
267
495
|
if (!attachmentFxaa.ok) throw attachmentFxaa.error;
|
|
268
496
|
worldFxaa.update(1 / 60).unwrap();
|
|
497
|
+
const outputCapture = prepareOutputTransformCapture(debugDrawHost);
|
|
269
498
|
const drawnFxaa = renderer.draw({
|
|
270
499
|
leases: [attachmentFxaa.value],
|
|
271
500
|
camera: { lease: attachmentFxaa.value },
|
|
272
501
|
environment: { lease: attachmentFxaa.value },
|
|
273
502
|
});
|
|
274
|
-
expect(
|
|
503
|
+
expect(
|
|
504
|
+
drawnFxaa.ok,
|
|
505
|
+
drawnFxaa.ok
|
|
506
|
+
? undefined
|
|
507
|
+
: `FXAA draw failed: ${JSON.stringify({ error: drawnFxaa.error, renderErrors })}`,
|
|
508
|
+
).toBe(true);
|
|
509
|
+
if (!drawnFxaa.ok) throw drawnFxaa.error;
|
|
275
510
|
await device.queue.onSubmittedWorkDone();
|
|
511
|
+
const standardOutputFxaa = await readOutputTransformCapture(
|
|
512
|
+
debugDrawHost,
|
|
513
|
+
outputCapture,
|
|
514
|
+
drawnFxaa.value.frameId,
|
|
515
|
+
);
|
|
276
516
|
const pixelsFxaa = await doReadPixels(device, renderTarget);
|
|
517
|
+
const inspectionFxaa = renderer.inspect();
|
|
518
|
+
expect(standardOutputFxaa).toEqual(
|
|
519
|
+
expect.objectContaining({
|
|
520
|
+
graphFrameId: drawnFxaa.value.frameId - 1,
|
|
521
|
+
graphGeneration: expect.any(Number),
|
|
522
|
+
textureIdentity: expect.any(Number),
|
|
523
|
+
receiptFrameId: drawnFxaa.value.frameId,
|
|
524
|
+
format: 'rgba16float',
|
|
525
|
+
size: { width: WIDTH, height: HEIGHT },
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
expect(standardOutputFxaa?.captureWritten).toBe(true);
|
|
277
529
|
|
|
278
530
|
expect(pixelsNone.length).toBe(pixelsFxaa.length);
|
|
279
531
|
expect(pixelsNone.length).toBeGreaterThan(0);
|
|
@@ -297,18 +549,99 @@ describe('feat-20260604 M2 w10: FullscreenPostProcessPass dawn tests', () => {
|
|
|
297
549
|
}
|
|
298
550
|
expect(nonBlackFxaa, 'fxaa pass must render at least one non-black pixel').toBeGreaterThan(0);
|
|
299
551
|
|
|
300
|
-
|
|
301
|
-
let
|
|
302
|
-
for (let i = 0; i < pixelsNone.length; i
|
|
303
|
-
|
|
552
|
+
let rgbByteDiffCount = 0;
|
|
553
|
+
let affectedPixels = 0;
|
|
554
|
+
for (let i = 0; i < pixelsNone.length; i += 4) {
|
|
555
|
+
let pixelAffected = false;
|
|
556
|
+
for (let channel = 0; channel < 3; channel++) {
|
|
557
|
+
if (pixelsNone[i + channel] !== pixelsFxaa[i + channel]) {
|
|
558
|
+
rgbByteDiffCount++;
|
|
559
|
+
pixelAffected = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (pixelAffected) affectedPixels++;
|
|
304
563
|
}
|
|
305
564
|
const totalPixels = WIDTH * HEIGHT;
|
|
306
|
-
const
|
|
307
|
-
const
|
|
565
|
+
const affectedPixelRatio = affectedPixels / totalPixels;
|
|
566
|
+
const diagnostics = {
|
|
567
|
+
rgbByteDiffCount,
|
|
568
|
+
affectedPixels,
|
|
569
|
+
affectedPixelRatio,
|
|
570
|
+
pixelDiff: pixelDiffSummary(pixelsNone, pixelsFxaa),
|
|
571
|
+
standardOutputNone,
|
|
572
|
+
standardOutputFxaa,
|
|
573
|
+
};
|
|
574
|
+
// biome-ignore lint/suspicious/noConsole: AC-09 retains a bounded portability diagnostic for paired readback evidence.
|
|
575
|
+
console.info(`AC-09 paired readback diagnostics: ${JSON.stringify(diagnostics)}`);
|
|
308
576
|
expect(
|
|
309
|
-
|
|
310
|
-
`
|
|
311
|
-
|
|
577
|
+
rgbByteDiffCount,
|
|
578
|
+
`paired readback must expose the existing FXAA difference; diagnostics=${JSON.stringify(diagnostics)}; ` +
|
|
579
|
+
`noneSurface=${JSON.stringify({
|
|
580
|
+
displayEncoded: inspectionNone.displayEncoded,
|
|
581
|
+
intermediateFormat: inspectionNone.intermediateFormat,
|
|
582
|
+
surfaceStorage: inspectionNone.surfaceStorage,
|
|
583
|
+
surfaceDisplay: inspectionNone.surfaceDisplay,
|
|
584
|
+
endpoint: inspectionNone.endpoint,
|
|
585
|
+
capability: inspectionNone.capability,
|
|
586
|
+
error: inspectionNone.error,
|
|
587
|
+
})}; fxaaSurface=${JSON.stringify({
|
|
588
|
+
displayEncoded: inspectionFxaa.displayEncoded,
|
|
589
|
+
intermediateFormat: inspectionFxaa.intermediateFormat,
|
|
590
|
+
surfaceStorage: inspectionFxaa.surfaceStorage,
|
|
591
|
+
surfaceDisplay: inspectionFxaa.surfaceDisplay,
|
|
592
|
+
endpoint: inspectionFxaa.endpoint,
|
|
593
|
+
capability: inspectionFxaa.capability,
|
|
594
|
+
error: inspectionFxaa.error,
|
|
595
|
+
standardOutputNone,
|
|
596
|
+
standardOutputFxaa,
|
|
597
|
+
})}`,
|
|
598
|
+
).toBeGreaterThan(0);
|
|
599
|
+
removeErrorListener();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('keeps TAA + MotionBlur visible on the first no-yield Dawn draw', async () => {
|
|
603
|
+
const { renderer, device, renderTarget } = await setupRenderer();
|
|
604
|
+
const world = new World();
|
|
605
|
+
const cube = spawnTemporalCubeScene(world);
|
|
606
|
+
const attachment = renderer.attach(world);
|
|
607
|
+
expect(attachment.ok).toBe(true);
|
|
608
|
+
if (!attachment.ok) throw attachment.error;
|
|
609
|
+
|
|
610
|
+
// Do not await between draws: temporal fullscreen PSOs must be seeded by
|
|
611
|
+
// renderer initialization, not by an accidental event-loop yield.
|
|
612
|
+
for (let frame = 0; frame < 2; frame += 1) {
|
|
613
|
+
world.set(cube, Transform, { pos: [Math.sin(frame * 0.08) * 0.2, 0, 0] }).unwrap();
|
|
614
|
+
world.update(1 / 60).unwrap();
|
|
615
|
+
const drawn = renderer.draw({
|
|
616
|
+
leases: [attachment.value],
|
|
617
|
+
camera: { lease: attachment.value },
|
|
618
|
+
environment: { lease: attachment.value },
|
|
619
|
+
});
|
|
620
|
+
expect(drawn.ok).toBe(true);
|
|
621
|
+
}
|
|
622
|
+
await device.queue.onSubmittedWorkDone();
|
|
623
|
+
const pixels = await doReadPixels(device, renderTarget);
|
|
624
|
+
let nonBlack = 0;
|
|
625
|
+
for (let index = 0; index < pixels.length; index += 4) {
|
|
626
|
+
const red = pixels[index + 0] ?? 0;
|
|
627
|
+
const green = pixels[index + 1] ?? 0;
|
|
628
|
+
const blue = pixels[index + 2] ?? 0;
|
|
629
|
+
if (red + green + blue > 0) nonBlack += 1;
|
|
630
|
+
}
|
|
631
|
+
expect(nonBlack, 'TAA + MotionBlur must write the declared output target').toBeGreaterThan(0);
|
|
632
|
+
|
|
633
|
+
const inspection = renderer.inspect();
|
|
634
|
+
expect(inspection.perFramePassNames).toEqual(
|
|
635
|
+
expect.arrayContaining([
|
|
636
|
+
'standard-scene-data',
|
|
637
|
+
'taa-resolve',
|
|
638
|
+
'motion-blur',
|
|
639
|
+
'output-transform',
|
|
640
|
+
]),
|
|
641
|
+
);
|
|
642
|
+
expect(inspection.temporal).toMatchObject({ status: 'stable', historyValid: true });
|
|
643
|
+
expect(inspection.motionBlur).toMatchObject({ status: 'active', historyWrites: 0 });
|
|
644
|
+
await renderer.dispose();
|
|
312
645
|
});
|
|
313
646
|
|
|
314
647
|
it('R-COLORSPACE falsify: writing through srgb view changes pixel output (discriminability guard)', () => {
|
|
@@ -333,4 +666,114 @@ describe('feat-20260604 M2 w10: FullscreenPostProcessPass dawn tests', () => {
|
|
|
333
666
|
// The sRGB-encoded value should be larger (gamma curve brightens).
|
|
334
667
|
expect(srgbGammaOutput).toBeGreaterThan(nonSrgbOutput);
|
|
335
668
|
});
|
|
669
|
+
|
|
670
|
+
it('records the GPU half-cell/raw-unorm divergence without software quantization', async () => {
|
|
671
|
+
const { renderer, device, renderTarget, debugDrawHost } = await setupRenderer();
|
|
672
|
+
const linearInputs = [0.49989, 0.49992] as const;
|
|
673
|
+
const directPixels: Uint8Array[] = [];
|
|
674
|
+
const fxaaPixels: Uint8Array[] = [];
|
|
675
|
+
const fxaaCaptures: Array<NonNullable<Awaited<ReturnType<typeof readOutputTransformCapture>>>> =
|
|
676
|
+
[];
|
|
677
|
+
|
|
678
|
+
for (const linear of linearInputs) {
|
|
679
|
+
const directWorld = new World();
|
|
680
|
+
spawnLinearClearScene(directWorld, ANTIALIAS_NONE, linear);
|
|
681
|
+
const directAttachment = renderer.attach(directWorld);
|
|
682
|
+
expect(directAttachment.ok).toBe(true);
|
|
683
|
+
if (!directAttachment.ok) throw directAttachment.error;
|
|
684
|
+
directWorld.update(1 / 60).unwrap();
|
|
685
|
+
const directDraw = renderer.draw({
|
|
686
|
+
leases: [directAttachment.value],
|
|
687
|
+
camera: { lease: directAttachment.value },
|
|
688
|
+
environment: { lease: directAttachment.value },
|
|
689
|
+
});
|
|
690
|
+
expect(directDraw.ok).toBe(true);
|
|
691
|
+
await device.queue.onSubmittedWorkDone();
|
|
692
|
+
directPixels.push(await doReadPixels(device, renderTarget));
|
|
693
|
+
|
|
694
|
+
const fxaaWorld = new World();
|
|
695
|
+
spawnLinearClearScene(fxaaWorld, ANTIALIAS_FXAA, linear);
|
|
696
|
+
const fxaaAttachment = renderer.attach(fxaaWorld);
|
|
697
|
+
expect(fxaaAttachment.ok).toBe(true);
|
|
698
|
+
if (!fxaaAttachment.ok) throw fxaaAttachment.error;
|
|
699
|
+
fxaaWorld.update(1 / 60).unwrap();
|
|
700
|
+
const capture = prepareOutputTransformCapture(debugDrawHost);
|
|
701
|
+
const fxaaDraw = renderer.draw({
|
|
702
|
+
leases: [fxaaAttachment.value],
|
|
703
|
+
camera: { lease: fxaaAttachment.value },
|
|
704
|
+
environment: { lease: fxaaAttachment.value },
|
|
705
|
+
});
|
|
706
|
+
expect(fxaaDraw.ok).toBe(true);
|
|
707
|
+
if (!fxaaDraw.ok) throw fxaaDraw.error;
|
|
708
|
+
await device.queue.onSubmittedWorkDone();
|
|
709
|
+
const output = await readOutputTransformCapture(
|
|
710
|
+
debugDrawHost,
|
|
711
|
+
capture,
|
|
712
|
+
fxaaDraw.value.frameId,
|
|
713
|
+
);
|
|
714
|
+
expect(output?.captureWritten).toBe(true);
|
|
715
|
+
expect(output?.center.every(Number.isFinite)).toBe(true);
|
|
716
|
+
if (output === undefined) continue;
|
|
717
|
+
fxaaCaptures.push(output);
|
|
718
|
+
fxaaPixels.push(await doReadPixels(device, renderTarget));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const centerOffset = ((HEIGHT >> 1) * WIDTH + (WIDTH >> 1)) * 4;
|
|
722
|
+
const rawCenters = [
|
|
723
|
+
...directPixels.map((pixels) => pixels[centerOffset] ?? Number.NaN),
|
|
724
|
+
...fxaaPixels.map((pixels) => pixels[centerOffset] ?? Number.NaN),
|
|
725
|
+
];
|
|
726
|
+
expect(rawCenters.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)).toBe(
|
|
727
|
+
true,
|
|
728
|
+
);
|
|
729
|
+
expect(fxaaCaptures).toHaveLength(linearInputs.length);
|
|
730
|
+
for (const output of fxaaCaptures) {
|
|
731
|
+
expect(output).toMatchObject({
|
|
732
|
+
targetName: 'standard-output-color',
|
|
733
|
+
format: 'rgba16float',
|
|
734
|
+
size: { width: WIDTH, height: HEIGHT },
|
|
735
|
+
captureWritten: true,
|
|
736
|
+
});
|
|
737
|
+
expect(output.graphFrameId).toBeGreaterThan(0);
|
|
738
|
+
expect(output.receiptFrameId).toBeGreaterThan(0);
|
|
739
|
+
expect(output.graphGeneration).toBeGreaterThan(0);
|
|
740
|
+
expect(output.textureIdentity).toBeGreaterThan(0);
|
|
741
|
+
expect(output.usage & TEXTURE_USAGE_COPY_SRC).toBe(TEXTURE_USAGE_COPY_SRC);
|
|
742
|
+
expect(output.center.every(Number.isFinite)).toBe(true);
|
|
743
|
+
}
|
|
744
|
+
expect(fxaaCaptures[0]?.center).toEqual(fxaaCaptures[1]?.center);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('keeps Output Transform boundary inputs finite on the GPU', async () => {
|
|
748
|
+
const { renderer, device, debugDrawHost } = await setupRenderer();
|
|
749
|
+
const linearInputs = [0, 2 ** -24, 2 ** -14, 1] as const;
|
|
750
|
+
const outputs: number[] = [];
|
|
751
|
+
|
|
752
|
+
for (const linear of linearInputs) {
|
|
753
|
+
const world = new World();
|
|
754
|
+
spawnLinearClearScene(world, ANTIALIAS_FXAA, linear);
|
|
755
|
+
const attachment = renderer.attach(world);
|
|
756
|
+
expect(attachment.ok).toBe(true);
|
|
757
|
+
if (!attachment.ok) throw attachment.error;
|
|
758
|
+
world.update(1 / 60).unwrap();
|
|
759
|
+
const capture = prepareOutputTransformCapture(debugDrawHost);
|
|
760
|
+
const drawn = renderer.draw({
|
|
761
|
+
leases: [attachment.value],
|
|
762
|
+
camera: { lease: attachment.value },
|
|
763
|
+
environment: { lease: attachment.value },
|
|
764
|
+
});
|
|
765
|
+
expect(drawn.ok).toBe(true);
|
|
766
|
+
if (!drawn.ok) throw drawn.error;
|
|
767
|
+
await device.queue.onSubmittedWorkDone();
|
|
768
|
+
const output = await readOutputTransformCapture(debugDrawHost, capture, drawn.value.frameId);
|
|
769
|
+
expect(output?.captureWritten).toBe(true);
|
|
770
|
+
expect(output?.center.every(Number.isFinite)).toBe(true);
|
|
771
|
+
outputs.push(output?.center[0] ?? Number.NaN);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
expect(outputs[0]).toBe(0);
|
|
775
|
+
expect(outputs[3]).toBeGreaterThan(0.99);
|
|
776
|
+
expect(outputs[3]).toBeLessThanOrEqual(1);
|
|
777
|
+
expect(outputs.every(Number.isFinite)).toBe(true);
|
|
778
|
+
});
|
|
336
779
|
});
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
import type { ExtractedFrame } from '../../../render/src/render-system-extract';
|
|
65
65
|
import { extractFrame, prepareExtractContext } from '../../../render/src/render-system-extract';
|
|
66
66
|
import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
67
|
+
import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
|
|
67
68
|
import { drawWithOwners } from './renderer-test-utils';
|
|
68
69
|
|
|
69
70
|
type RendererErrorObservation = {
|
|
@@ -2135,7 +2136,8 @@ function drawPublished(renderer: RendererType, world: WorldType) {
|
|
|
2135
2136
|
sourcePath: `${identifier}.wgsl`,
|
|
2136
2137
|
composedWgsl: '/* stub */',
|
|
2137
2138
|
paramSchema: '[]',
|
|
2138
|
-
variants:
|
|
2139
|
+
variants:
|
|
2140
|
+
identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
|
|
2139
2141
|
});
|
|
2140
2142
|
const manifest = {
|
|
2141
2143
|
schemaVersion: '1.0.0',
|
|
@@ -144,12 +144,11 @@ function configuredStore(probe: CowProbe): GpuResidencyCache {
|
|
|
144
144
|
function texturePod(w = 2, h = 2): TextureAsset {
|
|
145
145
|
return {
|
|
146
146
|
kind: 'texture',
|
|
147
|
-
width: w,
|
|
148
|
-
height: h,
|
|
147
|
+
shape: { viewDimension: '2d', extent: { width: w, height: h } },
|
|
149
148
|
format: 'rgba8unorm-srgb',
|
|
150
149
|
data: new Uint8Array(w * h * 4).fill(188),
|
|
151
150
|
colorSpace: 'srgb',
|
|
152
|
-
|
|
151
|
+
mips: { kind: 'none' },
|
|
153
152
|
};
|
|
154
153
|
}
|
|
155
154
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface StandardMaterialShaderVariant {
|
|
2
|
+
readonly definesKey: string;
|
|
3
|
+
readonly defines: {
|
|
4
|
+
readonly CLUSTER_FORWARD_AVAILABLE: boolean;
|
|
5
|
+
readonly STORAGE_BUFFER_AVAILABLE: boolean;
|
|
6
|
+
readonly TRANSMISSION_AVAILABLE: boolean;
|
|
7
|
+
readonly VERTEX_COLOR_AVAILABLE: boolean;
|
|
8
|
+
};
|
|
9
|
+
readonly composedWgsl: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function variantKey(defines: StandardMaterialShaderVariant['defines']): string {
|
|
13
|
+
const entries = Object.entries(defines).sort(([left], [right]) =>
|
|
14
|
+
left < right ? -1 : left > right ? 1 : 0,
|
|
15
|
+
);
|
|
16
|
+
return entries.every(([, value]) => value)
|
|
17
|
+
? ''
|
|
18
|
+
: entries.map(([key, value]) => `${key}=${value}`).join('+');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function standardMaterialShaderVariants(
|
|
22
|
+
composedWgsl = '/* stub */',
|
|
23
|
+
): readonly StandardMaterialShaderVariant[] {
|
|
24
|
+
const variants: StandardMaterialShaderVariant[] = [];
|
|
25
|
+
for (const cluster of [false, true]) {
|
|
26
|
+
for (const storage of [false, true]) {
|
|
27
|
+
for (const transmission of [false, true]) {
|
|
28
|
+
for (const vertexColor of [false, true]) {
|
|
29
|
+
const defines = {
|
|
30
|
+
CLUSTER_FORWARD_AVAILABLE: cluster,
|
|
31
|
+
STORAGE_BUFFER_AVAILABLE: storage,
|
|
32
|
+
TRANSMISSION_AVAILABLE: transmission,
|
|
33
|
+
VERTEX_COLOR_AVAILABLE: vertexColor,
|
|
34
|
+
} as const;
|
|
35
|
+
variants.push({
|
|
36
|
+
definesKey: variantKey(defines),
|
|
37
|
+
defines,
|
|
38
|
+
composedWgsl,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return variants;
|
|
45
|
+
}
|
|
@@ -6,12 +6,11 @@ export const TEST_ATLAS_GUID = 'test/atlas';
|
|
|
6
6
|
export function makeTestTexture(seed = 0): TextureAsset {
|
|
7
7
|
return {
|
|
8
8
|
kind: 'texture',
|
|
9
|
-
width: 32,
|
|
10
|
-
|
|
11
|
-
format: 'rgba8unorm',
|
|
9
|
+
shape: { viewDimension: '2d', extent: { width: 32, height: 32 } },
|
|
10
|
+
format: 'rgba8unorm-srgb',
|
|
12
11
|
data: new Uint8Array(32 * 32 * 4).fill(seed),
|
|
13
12
|
colorSpace: 'srgb',
|
|
14
|
-
|
|
13
|
+
mips: { kind: 'none' },
|
|
15
14
|
};
|
|
16
15
|
}
|
|
17
16
|
|