@forgeax/engine-runtime 0.1.27 → 0.1.29
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 +98 -0
- package/dist/collect-scene-asset.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +480 -15
- package/dist/index.mjs.map +1 -1
- package/dist/scene-utils/mount-override-fold.d.ts +3 -1
- package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
- package/package.json +37 -30
- package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
- package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
- package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
- package/src/__tests__/asset-registry-mounts.test.ts +174 -734
- package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
- package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
- package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
- package/src/__tests__/collect-scene-asset.test.ts +96 -113
- package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
- package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
- package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
- package/src/__tests__/collect-transient-skip.test.ts +20 -27
- package/src/__tests__/components.test-d.ts +17 -1
- package/src/__tests__/components.unit.test.ts +75 -27
- package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
- package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
- package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
- package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
- package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
- package/src/__tests__/errors.unit.test.ts +13 -0
- package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
- package/src/__tests__/feature-depth-material.fixture.ts +473 -0
- package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
- package/src/__tests__/geometry.unit.test.ts +42 -43
- package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
- package/src/__tests__/handle-migration.test.ts +0 -1
- package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
- package/src/__tests__/instances.test-d.ts +13 -52
- package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
- package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
- package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
- package/src/__tests__/material-publication.browser.test.ts +29 -0
- package/src/__tests__/material-publication.commands.ts +23 -0
- package/src/__tests__/material-publication.dawn.test.ts +24 -0
- package/src/__tests__/material-publication.fixture.ts +349 -0
- package/src/__tests__/material-publication.server.ts +109 -0
- package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
- package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
- package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
- package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
- package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
- package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
- package/src/__tests__/record-worldid-isolation.test.ts +47 -28
- package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
- package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
- package/src/__tests__/render-system-extract.test.ts +61 -0
- package/src/__tests__/render-system-extract.unit.test.ts +21 -7
- package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
- package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
- package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
- package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
- package/src/__tests__/render-system-mega.test.ts +50 -62
- package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
- package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
- package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
- package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
- package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
- package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
- package/src/__tests__/roundtrip-primitive.test.ts +24 -23
- package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
- package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
- package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
- package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
- package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
- package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
- package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
- package/src/__tests__/serialize-scene-asset.test.ts +36 -50
- package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
- package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
- package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
- package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
- package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
- package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
- package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
- package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
- package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
- package/src/__tests__/transient-fields-collect.test.ts +4 -4
- package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
- package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
- package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
- package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
- package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
- package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
- package/src/__tests__/wave1-dynamic-geometry.browser.test.ts +326 -0
- package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
- package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
- package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
- package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
- package/src/__tests__/world-scene-despawn.test.ts +8 -8
- package/src/__tests__/world-scene-instantiate.test.ts +23 -29
- package/src/__tests__/world-scene-nested.test.ts +90 -163
- package/src/__tests__/world-scene-override.test.ts +17 -16
- package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
- package/src/collect-scene-asset.ts +637 -19
- package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
- package/src/index.ts +1 -0
- package/src/scene-utils/mount-override-fold.ts +5 -14
- package/src/__tests__/node-shims.d.ts +0 -31
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { createBoxGeometry, createPlaneGeometry } from '@forgeax/engine-geometry';
|
|
3
|
+
import { AssetGuid } from '@forgeax/engine-pack';
|
|
4
|
+
import {
|
|
5
|
+
Camera,
|
|
6
|
+
DirectionalLight,
|
|
7
|
+
MeshFilter,
|
|
8
|
+
MeshRenderer,
|
|
9
|
+
PointLight,
|
|
10
|
+
PointLightShadow,
|
|
11
|
+
Skylight,
|
|
12
|
+
SpotLight,
|
|
13
|
+
} from '@forgeax/engine-render';
|
|
14
|
+
import { attachRecorder, buildFrameModel, decodeTape, openReplay } from '@forgeax/engine-rhi-debug';
|
|
15
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
16
|
+
import { createBuiltinMaterialAsset } from '@forgeax/engine-shader';
|
|
17
|
+
import { ok, type ParticleEffectAsset, type Result } from '@forgeax/engine-types';
|
|
18
|
+
import { ParticleEffectPlayer } from '@forgeax/engine-vfx';
|
|
19
|
+
import type { ParticleCodeModuleSet } from '@forgeax/engine-vfx-compiler';
|
|
20
|
+
import { createVfxRuntimeHost } from '@forgeax/engine-vfx-render';
|
|
21
|
+
import { expect } from 'vitest';
|
|
22
|
+
import { loadBackendPack } from '../backend-selection';
|
|
23
|
+
import { constructRuntimeRendererHost } from '../renderer-host';
|
|
24
|
+
|
|
25
|
+
export type CookMeshLightingFixture = (
|
|
26
|
+
source: unknown,
|
|
27
|
+
modules: Readonly<Record<string, ParticleCodeModuleSet>>,
|
|
28
|
+
) => Promise<Result<{ readonly asset: ParticleEffectAsset }, unknown>>;
|
|
29
|
+
|
|
30
|
+
function inspect(value: unknown): string {
|
|
31
|
+
return JSON.stringify(value, (_key, item) =>
|
|
32
|
+
item instanceof Error ? { ...item, message: item.message } : item,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One real-device acceptance path shared by native Dawn and browser WebGPU. */
|
|
37
|
+
export async function verifyVfxMeshLighting(options: {
|
|
38
|
+
readonly shaderManifestUrl: string;
|
|
39
|
+
readonly cook: CookMeshLightingFixture;
|
|
40
|
+
readonly tapePaths?: Readonly<Record<string, string | undefined>>;
|
|
41
|
+
readonly saveTape?: (path: string, bytes: Uint8Array) => void;
|
|
42
|
+
}): Promise<void> {
|
|
43
|
+
const env = options.tapePaths ?? {};
|
|
44
|
+
const cookParticleCodeEffect = options.cook;
|
|
45
|
+
const world = new World();
|
|
46
|
+
const focal = 1 / Math.tan(Math.PI / 8);
|
|
47
|
+
const viewProjection = new Float32Array([
|
|
48
|
+
focal,
|
|
49
|
+
0,
|
|
50
|
+
0,
|
|
51
|
+
0,
|
|
52
|
+
0,
|
|
53
|
+
focal,
|
|
54
|
+
0,
|
|
55
|
+
0,
|
|
56
|
+
0,
|
|
57
|
+
0,
|
|
58
|
+
-10 / 9.9,
|
|
59
|
+
-1,
|
|
60
|
+
0,
|
|
61
|
+
0,
|
|
62
|
+
29 / 9.9,
|
|
63
|
+
3,
|
|
64
|
+
]);
|
|
65
|
+
const vfx = createVfxRuntimeHost({
|
|
66
|
+
camera: {
|
|
67
|
+
read: () => ({
|
|
68
|
+
position: new Float32Array([0, 0, 3]),
|
|
69
|
+
right: new Float32Array([1, 0, 0]),
|
|
70
|
+
up: new Float32Array([0, 1, 0]),
|
|
71
|
+
viewProjection,
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
let device: GPUDevice | undefined;
|
|
76
|
+
let target: GPUTexture | undefined;
|
|
77
|
+
const errors: unknown[] = [];
|
|
78
|
+
const pack = await loadBackendPack({});
|
|
79
|
+
if (!pack.ok) throw pack.error;
|
|
80
|
+
if (pack.value.createShaderModule === undefined) throw new Error('No shader factory');
|
|
81
|
+
const recording = attachRecorder({
|
|
82
|
+
...pack.value,
|
|
83
|
+
createShaderModule: pack.value.createShaderModule,
|
|
84
|
+
});
|
|
85
|
+
if (!recording.ok) throw recording.error;
|
|
86
|
+
const recorder = recording.value;
|
|
87
|
+
const capturedWork: unknown[] = [];
|
|
88
|
+
const canvas = {
|
|
89
|
+
width: 64,
|
|
90
|
+
height: 64,
|
|
91
|
+
getContext: () => ({
|
|
92
|
+
configure(descriptor: GPUCanvasConfiguration) {
|
|
93
|
+
device = descriptor.device;
|
|
94
|
+
device.addEventListener('uncapturederror', (event) => errors.push(event.error.message));
|
|
95
|
+
target = device.createTexture({
|
|
96
|
+
size: [64, 64],
|
|
97
|
+
format: descriptor.format,
|
|
98
|
+
viewFormats: [descriptor.format === 'rgba8unorm' ? 'rgba8unorm-srgb' : 'bgra8unorm-srgb'],
|
|
99
|
+
usage: 0x10 | 0x01,
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
unconfigure() {},
|
|
103
|
+
getCurrentTexture: () => target,
|
|
104
|
+
}),
|
|
105
|
+
addEventListener() {},
|
|
106
|
+
removeEventListener() {},
|
|
107
|
+
};
|
|
108
|
+
const constructed = await constructRuntimeRendererHost(
|
|
109
|
+
canvas,
|
|
110
|
+
{
|
|
111
|
+
features: [vfx.feature],
|
|
112
|
+
rhi: recorder.backend.rhi,
|
|
113
|
+
rhiInstrumentation: {
|
|
114
|
+
resolveSurfaceDevice(device) {
|
|
115
|
+
const unwrapped = recorder.backend.unwrapDeviceForSurface(device);
|
|
116
|
+
if (!unwrapped.ok) throw unwrapped.error;
|
|
117
|
+
return ok(unwrapped.value);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
shaderManifestUrl: options.shaderManifestUrl,
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
if (!constructed.ok) throw constructed.error;
|
|
126
|
+
const { renderer, assets } = constructed.value;
|
|
127
|
+
renderer.subscribe((event) => {
|
|
128
|
+
if (event.kind === 'error') errors.push(event.error);
|
|
129
|
+
});
|
|
130
|
+
let vfxAttached = false;
|
|
131
|
+
try {
|
|
132
|
+
world
|
|
133
|
+
.spawn(
|
|
134
|
+
{ component: Transform, data: { pos: [0, 0, 3], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
135
|
+
{ component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 10 } },
|
|
136
|
+
)
|
|
137
|
+
.unwrap();
|
|
138
|
+
const light = world
|
|
139
|
+
.spawn({
|
|
140
|
+
component: DirectionalLight,
|
|
141
|
+
data: {
|
|
142
|
+
direction: [-1, 0, -1],
|
|
143
|
+
color: [1, 1, 1],
|
|
144
|
+
intensity: 3,
|
|
145
|
+
castShadow: true,
|
|
146
|
+
cascadeCount: 1,
|
|
147
|
+
mapSize: 512,
|
|
148
|
+
shadowDistance: 6,
|
|
149
|
+
depthBias: 0.001,
|
|
150
|
+
normalBias: 0.001,
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
.unwrap();
|
|
154
|
+
const material = createBuiltinMaterialAsset('standard');
|
|
155
|
+
const mesh = createBoxGeometry(0.5, 0.5, 0.5).unwrap();
|
|
156
|
+
const materialGuid = AssetGuid.random();
|
|
157
|
+
const meshGuid = AssetGuid.random();
|
|
158
|
+
const materialAdded = assets.catalog(materialGuid, {
|
|
159
|
+
...material,
|
|
160
|
+
values: { ...material.values, baseColor: [0.3, 0.3, 0.3, 1], metallic: 0, roughness: 1 },
|
|
161
|
+
});
|
|
162
|
+
if (!materialAdded.ok) throw materialAdded.error;
|
|
163
|
+
const meshAdded = assets.catalog(meshGuid, mesh);
|
|
164
|
+
if (!meshAdded.ok) throw meshAdded.error;
|
|
165
|
+
const attached = renderer.attach(world);
|
|
166
|
+
if (!attached.ok) throw attached.error;
|
|
167
|
+
const lease = attached.value;
|
|
168
|
+
const attachedVfx = await vfx.attachWorld({ world, assets });
|
|
169
|
+
if (!attachedVfx.ok) throw attachedVfx.error;
|
|
170
|
+
vfxAttached = true;
|
|
171
|
+
const frame = { leases: [lease], camera: { lease }, environment: { lease } };
|
|
172
|
+
const pixel = async () => {
|
|
173
|
+
if (device === undefined || target === undefined) throw new Error('No GPU surface');
|
|
174
|
+
const buffer = device.createBuffer({ size: 256, usage: 0x01 | 0x08 });
|
|
175
|
+
try {
|
|
176
|
+
const encoder = device.createCommandEncoder();
|
|
177
|
+
encoder.copyTextureToBuffer(
|
|
178
|
+
{ texture: target, origin: [32, 32] },
|
|
179
|
+
{ buffer, bytesPerRow: 256 },
|
|
180
|
+
[1, 1],
|
|
181
|
+
);
|
|
182
|
+
device.queue.submit([encoder.finish()]);
|
|
183
|
+
await buffer.mapAsync(1);
|
|
184
|
+
const result = [...new Uint8Array(buffer.getMappedRange()).slice(0, 4)];
|
|
185
|
+
buffer.unmap();
|
|
186
|
+
return result;
|
|
187
|
+
} finally {
|
|
188
|
+
buffer.destroy();
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
let pointShadowCaptureStage = 0;
|
|
192
|
+
const sample = async (
|
|
193
|
+
lighting: 'standard' | 'unlit',
|
|
194
|
+
receiveShadows: boolean,
|
|
195
|
+
intensity: number,
|
|
196
|
+
caster?: { readonly enabled: boolean; readonly position: readonly [number, number, number] },
|
|
197
|
+
surface?: {
|
|
198
|
+
readonly ordinary?: boolean;
|
|
199
|
+
readonly roughness: number;
|
|
200
|
+
readonly metallic: number;
|
|
201
|
+
},
|
|
202
|
+
) => {
|
|
203
|
+
world.set(light, DirectionalLight, { intensity }).unwrap();
|
|
204
|
+
const surfaceMaterial = {
|
|
205
|
+
...material,
|
|
206
|
+
values: {
|
|
207
|
+
...material.values,
|
|
208
|
+
baseColor: [0.3, 0.3, 0.3, 1],
|
|
209
|
+
metallic: surface?.metallic ?? 0,
|
|
210
|
+
roughness: surface?.roughness ?? 1,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
const surfaceGuid = surface === undefined ? materialGuid : AssetGuid.random();
|
|
214
|
+
if (surface !== undefined) {
|
|
215
|
+
const added = assets.catalog(surfaceGuid, surfaceMaterial);
|
|
216
|
+
if (!added.ok) throw added.error;
|
|
217
|
+
}
|
|
218
|
+
const cooked = await cookParticleCodeEffect(
|
|
219
|
+
{
|
|
220
|
+
schemaVersion: 3,
|
|
221
|
+
emitters: [
|
|
222
|
+
// Its ordinary raster precedes the off-camera caster in authored
|
|
223
|
+
// order. It must not delay the independent caster's GPU projection.
|
|
224
|
+
...(caster?.enabled
|
|
225
|
+
? [
|
|
226
|
+
{
|
|
227
|
+
id: 'visible-prefix',
|
|
228
|
+
capacity: 1,
|
|
229
|
+
backend: { required: 'gpu' as const },
|
|
230
|
+
space: 'world' as const,
|
|
231
|
+
bounds: { kind: 'sphere' as const, center: [0.7, 0, 0], radius: 0.5 },
|
|
232
|
+
schedule: { rate: 0, bursts: [{ time: 0, count: 1 }] },
|
|
233
|
+
program: { module: 'visible.wgsl' },
|
|
234
|
+
renderers: [
|
|
235
|
+
{
|
|
236
|
+
kind: 'mesh' as const,
|
|
237
|
+
mesh: AssetGuid.format(meshGuid),
|
|
238
|
+
material: AssetGuid.format(materialGuid),
|
|
239
|
+
lighting: 'unlit' as const,
|
|
240
|
+
castShadows: false,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
]
|
|
245
|
+
: []),
|
|
246
|
+
{
|
|
247
|
+
id: 'mesh',
|
|
248
|
+
capacity: 1,
|
|
249
|
+
backend: { required: 'gpu' },
|
|
250
|
+
space: 'world',
|
|
251
|
+
bounds: {
|
|
252
|
+
kind: 'sphere',
|
|
253
|
+
center: caster?.position ?? [0, 0, 0],
|
|
254
|
+
radius: caster === undefined ? 4 : 0.5,
|
|
255
|
+
},
|
|
256
|
+
schedule: { rate: 0, bursts: [{ time: 0, count: 1 }] },
|
|
257
|
+
program: { module: 'mesh.wgsl' },
|
|
258
|
+
renderers: [
|
|
259
|
+
{
|
|
260
|
+
kind: 'mesh',
|
|
261
|
+
mesh: AssetGuid.format(meshGuid),
|
|
262
|
+
material: AssetGuid.format(surfaceGuid),
|
|
263
|
+
lighting,
|
|
264
|
+
receiveShadows,
|
|
265
|
+
castShadows: caster?.enabled ?? false,
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
'visible.wgsl': {
|
|
273
|
+
entry: `
|
|
274
|
+
#import forgeax_vfx::prelude::{VfxParticle, VfxSpawnContext, VfxUpdateContext}
|
|
275
|
+
fn vfx_spawn(ctx: VfxSpawnContext, particle: ptr<function, VfxParticle>) {
|
|
276
|
+
(*particle).position = vec3<f32>(0.7, 0.0, 0.0);
|
|
277
|
+
(*particle).color = vec4<f32>(1.0);
|
|
278
|
+
(*particle).mesh_orientation = vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
|
279
|
+
(*particle).mesh_scale = vec3<f32>(0.25);
|
|
280
|
+
(*particle).lifetime = 100.0;
|
|
281
|
+
}
|
|
282
|
+
fn vfx_update(ctx: VfxUpdateContext, particle: ptr<function, VfxParticle>) {}`,
|
|
283
|
+
},
|
|
284
|
+
'mesh.wgsl': {
|
|
285
|
+
entry: `
|
|
286
|
+
#import forgeax_vfx::prelude::{VfxParticle, VfxSpawnContext, VfxUpdateContext}
|
|
287
|
+
fn vfx_spawn(ctx: VfxSpawnContext, particle: ptr<function, VfxParticle>) {
|
|
288
|
+
(*particle).position = vec3<f32>(${(caster?.position ?? [0, 0, 0]).map((value) => `${value.toFixed(2)}`).join(', ')});
|
|
289
|
+
(*particle).color = vec4<f32>(1.0);
|
|
290
|
+
(*particle).mesh_orientation = vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
|
291
|
+
(*particle).mesh_scale = vec3<f32>(1.0);
|
|
292
|
+
(*particle).lifetime = 100.0;
|
|
293
|
+
}
|
|
294
|
+
fn vfx_update(ctx: VfxUpdateContext, particle: ptr<function, VfxParticle>) {}`,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
if (!cooked.ok) throw cooked.error;
|
|
299
|
+
const player =
|
|
300
|
+
surface?.ordinary === true
|
|
301
|
+
? world
|
|
302
|
+
.spawn(
|
|
303
|
+
{
|
|
304
|
+
component: Transform,
|
|
305
|
+
data: { pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
component: MeshFilter,
|
|
309
|
+
data: { assetHandle: world.allocSharedRef('MeshAsset', mesh) },
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
component: MeshRenderer,
|
|
313
|
+
data: { materials: [world.allocSharedRef('MaterialAsset', surfaceMaterial)] },
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
.unwrap()
|
|
317
|
+
: world
|
|
318
|
+
.spawn({
|
|
319
|
+
component: ParticleEffectPlayer,
|
|
320
|
+
data: {
|
|
321
|
+
effect: world.allocSharedRef('ParticleEffectAsset', cooked.value.asset),
|
|
322
|
+
playing: true,
|
|
323
|
+
seed: 1,
|
|
324
|
+
timeScale: 1,
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
.unwrap();
|
|
328
|
+
try {
|
|
329
|
+
for (let index = 0; index < 8; index++) {
|
|
330
|
+
world.update(1 / 60).unwrap();
|
|
331
|
+
const submitted = renderer.draw(frame);
|
|
332
|
+
if (!submitted.ok) throw new Error(inspect({ error: submitted.error, events: errors }));
|
|
333
|
+
const observed = await renderer.observe(submitted.value, { include: ['draws'] });
|
|
334
|
+
if (!observed.ok) throw observed.error;
|
|
335
|
+
}
|
|
336
|
+
expect(errors).toEqual([]);
|
|
337
|
+
if (caster?.enabled) {
|
|
338
|
+
expect(
|
|
339
|
+
vfx
|
|
340
|
+
.inspect(world)
|
|
341
|
+
?.players.flatMap((player) => player.emitters)
|
|
342
|
+
.map((emitter) => emitter.cameraVisible),
|
|
343
|
+
).toEqual([true, false]);
|
|
344
|
+
const names = renderer.inspect().perFramePassNames;
|
|
345
|
+
const projectIndices = names.flatMap((name, index) =>
|
|
346
|
+
name.endsWith('.project') ? [index] : [],
|
|
347
|
+
);
|
|
348
|
+
expect(projectIndices).toHaveLength(2);
|
|
349
|
+
for (const projectIndex of projectIndices)
|
|
350
|
+
expect(projectIndex, names.join('\n')).toBeLessThan(names.indexOf('shadowCascade0'));
|
|
351
|
+
}
|
|
352
|
+
if (
|
|
353
|
+
surface?.ordinary === true ||
|
|
354
|
+
(env.FORGEAX_POINT_SHADOW_TAPE !== undefined && pointShadowCaptureStage === 2) ||
|
|
355
|
+
(caster?.enabled &&
|
|
356
|
+
intensity === 0 &&
|
|
357
|
+
(env.FORGEAX_VFX_CAST_TAPE !== undefined ||
|
|
358
|
+
env.FORGEAX_POINT_SHADOW_TAPE !== undefined)) ||
|
|
359
|
+
(lighting === 'standard' &&
|
|
360
|
+
receiveShadows &&
|
|
361
|
+
intensity === 3 &&
|
|
362
|
+
capturedWork.length === 0)
|
|
363
|
+
) {
|
|
364
|
+
const capture = recorder.captureFrame();
|
|
365
|
+
const before = await recorder.frameBoundary();
|
|
366
|
+
if (!before.ok) throw before.error;
|
|
367
|
+
world.update(1 / 60).unwrap();
|
|
368
|
+
const draw = renderer.draw(frame);
|
|
369
|
+
if (!draw.ok) throw draw.error;
|
|
370
|
+
const observed = await renderer.observe(draw.value, { include: ['draws'] });
|
|
371
|
+
if (!observed.ok) throw observed.error;
|
|
372
|
+
const after = await recorder.frameBoundary();
|
|
373
|
+
if (!after.ok) throw after.error;
|
|
374
|
+
const encoded = await capture;
|
|
375
|
+
if (!encoded.ok) throw encoded.error;
|
|
376
|
+
if (env.FORGEAX_VFX_LIGHTING_TAPE !== undefined) {
|
|
377
|
+
options.saveTape?.(env.FORGEAX_VFX_LIGHTING_TAPE, encoded.value.bytes);
|
|
378
|
+
}
|
|
379
|
+
if (env.FORGEAX_POINT_SHADOW_TAPE !== undefined && pointShadowCaptureStage === 2) {
|
|
380
|
+
options.saveTape?.(env.FORGEAX_POINT_SHADOW_TAPE, encoded.value.bytes);
|
|
381
|
+
}
|
|
382
|
+
if (
|
|
383
|
+
env.FORGEAX_POINT_SHADOW_TAPE !== undefined &&
|
|
384
|
+
caster?.enabled === true &&
|
|
385
|
+
lighting === 'standard' &&
|
|
386
|
+
receiveShadows &&
|
|
387
|
+
intensity === 0 &&
|
|
388
|
+
surface === undefined
|
|
389
|
+
) {
|
|
390
|
+
options.saveTape?.(env.FORGEAX_POINT_SHADOW_TAPE, encoded.value.bytes);
|
|
391
|
+
}
|
|
392
|
+
if (caster?.enabled && env.FORGEAX_VFX_CAST_TAPE !== undefined) {
|
|
393
|
+
options.saveTape?.(env.FORGEAX_VFX_CAST_TAPE, encoded.value.bytes);
|
|
394
|
+
}
|
|
395
|
+
const decoded = decodeTape(encoded.value.bytes);
|
|
396
|
+
if (!decoded.ok) throw decoded.error;
|
|
397
|
+
capturedWork.push(
|
|
398
|
+
...buildFrameModel(decoded.value).works.map((work) => ({
|
|
399
|
+
index: work.workIndex,
|
|
400
|
+
kind: work.kind,
|
|
401
|
+
pipeline: work.pipeline?.shaders?.map((shader) => shader.entryPoint),
|
|
402
|
+
bindings: work.bindings.map((binding) => [binding.groupIndex, binding.binding]),
|
|
403
|
+
})),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const sampled = await pixel();
|
|
407
|
+
if (surface?.ordinary && sampled[3] === 0)
|
|
408
|
+
throw new Error(inspect({ work: capturedWork, renderer: renderer.inspect() }));
|
|
409
|
+
return sampled;
|
|
410
|
+
} finally {
|
|
411
|
+
world.despawn(player).unwrap();
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
const lit = await sample('standard', true, 3);
|
|
415
|
+
const dark = await sample('standard', true, 0);
|
|
416
|
+
const unlit = await sample('unlit', true, 0);
|
|
417
|
+
const unlitWithLight = await sample('unlit', false, 3);
|
|
418
|
+
expect(
|
|
419
|
+
lit[0],
|
|
420
|
+
JSON.stringify({ lit, dark, unlit, unlitWithLight, capturedWork }),
|
|
421
|
+
).toBeGreaterThan((dark[0] ?? 0) + 30);
|
|
422
|
+
expect(unlit[0]).toBeGreaterThan((dark[0] ?? 0) + 30);
|
|
423
|
+
expect(unlitWithLight).toEqual(unlit);
|
|
424
|
+
const spawnMeshCaster = (position: readonly [number, number, number], geometry = mesh) =>
|
|
425
|
+
world
|
|
426
|
+
.spawn(
|
|
427
|
+
{ component: Transform, data: { pos: position, quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
428
|
+
{
|
|
429
|
+
component: MeshFilter,
|
|
430
|
+
data: { assetHandle: world.allocSharedRef('MeshAsset', geometry) },
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
component: MeshRenderer,
|
|
434
|
+
data: {
|
|
435
|
+
materials: [
|
|
436
|
+
world.allocSharedRef('MaterialAsset', {
|
|
437
|
+
...material,
|
|
438
|
+
passes: [
|
|
439
|
+
...(material.passes ?? []),
|
|
440
|
+
{
|
|
441
|
+
name: 'ShadowCaster',
|
|
442
|
+
program: { module: 'forgeax::default-shadow-caster' },
|
|
443
|
+
renderState: {
|
|
444
|
+
tags: { LightMode: 'ShadowCaster' },
|
|
445
|
+
passKind: 'shadow-caster',
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
}),
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
.unwrap();
|
|
455
|
+
const occluder = spawnMeshCaster([1, 0, 1.25]);
|
|
456
|
+
const shadowed = await sample('standard', true, 3);
|
|
457
|
+
const noReceive = await sample('standard', false, 3);
|
|
458
|
+
expect(noReceive[0], JSON.stringify({ lit, shadowed, noReceive })).toBeGreaterThan(
|
|
459
|
+
(shadowed[0] ?? 0) + 30,
|
|
460
|
+
);
|
|
461
|
+
expect(noReceive).toEqual(lit);
|
|
462
|
+
world.despawn(occluder).unwrap();
|
|
463
|
+
expect(await sample('standard', true, 3)).toEqual(lit);
|
|
464
|
+
// Reverse the caster witness: a GPU Mesh must receive the shared point
|
|
465
|
+
// shadow, respect receiveShadows, and consume live authored bias values.
|
|
466
|
+
const receivingPoint = world
|
|
467
|
+
.spawn(
|
|
468
|
+
{
|
|
469
|
+
component: Transform,
|
|
470
|
+
data: { pos: [2.4, 0, 2.65], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
|
|
471
|
+
},
|
|
472
|
+
{ component: PointLight, data: { color: [1, 1, 1], intensity: 40, range: 10 } },
|
|
473
|
+
{
|
|
474
|
+
component: PointLightShadow,
|
|
475
|
+
data: {
|
|
476
|
+
mapSize: 512,
|
|
477
|
+
nearPlane: 0.1,
|
|
478
|
+
farPlane: 10,
|
|
479
|
+
depthBias: 0.001,
|
|
480
|
+
normalBias: 0.001,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
.unwrap();
|
|
485
|
+
pointShadowCaptureStage = 1;
|
|
486
|
+
const pointLit = await sample('standard', true, 0);
|
|
487
|
+
const receivingOccluder = spawnMeshCaster([1.2, 0, 1.45]);
|
|
488
|
+
pointShadowCaptureStage = 2;
|
|
489
|
+
const pointShadow = await sample('standard', true, 0);
|
|
490
|
+
const pointNoReceive = await sample('standard', false, 0);
|
|
491
|
+
expect(pointLit[0], JSON.stringify({ pointLit, pointShadow, pointNoReceive })).toBeGreaterThan(
|
|
492
|
+
(pointShadow[0] ?? 0) + 30,
|
|
493
|
+
);
|
|
494
|
+
expect(pointNoReceive).toEqual(pointLit);
|
|
495
|
+
world.set(receivingPoint, PointLightShadow, { depthBias: 1000, normalBias: 0 }).unwrap();
|
|
496
|
+
expect(
|
|
497
|
+
await sample('standard', true, 0),
|
|
498
|
+
'Authored point depth bias must reach the GPU receiver',
|
|
499
|
+
).toEqual(pointLit);
|
|
500
|
+
world.set(receivingPoint, PointLightShadow, { depthBias: 0.001, normalBias: 1 }).unwrap();
|
|
501
|
+
expect(
|
|
502
|
+
await sample('standard', true, 0),
|
|
503
|
+
'Point slope bias must not be swapped with the depth floor',
|
|
504
|
+
).toEqual(pointLit);
|
|
505
|
+
world.set(receivingPoint, PointLightShadow, { depthBias: 0.001, normalBias: 0.001 }).unwrap();
|
|
506
|
+
expect(await sample('standard', true, 0)).toEqual(pointShadow);
|
|
507
|
+
world.despawn(receivingOccluder).unwrap();
|
|
508
|
+
expect(await sample('standard', true, 0)).toEqual(pointLit);
|
|
509
|
+
world.set(receivingPoint, PointLight, { intensity: 0 }).unwrap();
|
|
510
|
+
expect(await sample('standard', true, 0)).toEqual(dark);
|
|
511
|
+
world.despawn(receivingPoint).unwrap();
|
|
512
|
+
// A directional HDR panorama falsifies the constant-white fallback and
|
|
513
|
+
// exercises the renderer-owned diffuse, specular mip, and BRDF LUT path.
|
|
514
|
+
const environmentBytes = new Uint8Array(16 * 8 * 8);
|
|
515
|
+
const environmentView = new DataView(environmentBytes.buffer);
|
|
516
|
+
for (let y = 0; y < 8; y++)
|
|
517
|
+
for (let x = 0; x < 16; x++) {
|
|
518
|
+
const offset = (y * 16 + x) * 8;
|
|
519
|
+
environmentView.setUint16(offset, x < 8 ? 0x4000 : 0x2800, true);
|
|
520
|
+
environmentView.setUint16(offset + 2, 0x2800, true);
|
|
521
|
+
environmentView.setUint16(offset + 4, x < 8 ? 0x2800 : 0x4000, true);
|
|
522
|
+
environmentView.setUint16(offset + 6, 0x3c00, true);
|
|
523
|
+
}
|
|
524
|
+
const sky = world
|
|
525
|
+
.spawn({
|
|
526
|
+
component: Skylight,
|
|
527
|
+
data: {
|
|
528
|
+
equirect: world.allocSharedRef('EquirectAsset', {
|
|
529
|
+
kind: 'equirect',
|
|
530
|
+
width: 16,
|
|
531
|
+
height: 8,
|
|
532
|
+
format: 'rgba16float',
|
|
533
|
+
colorSpace: 'linear',
|
|
534
|
+
data: environmentBytes,
|
|
535
|
+
}),
|
|
536
|
+
intensity: 0.5,
|
|
537
|
+
},
|
|
538
|
+
})
|
|
539
|
+
.unwrap();
|
|
540
|
+
const environmentSamples: number[][] = [];
|
|
541
|
+
for (const metallic of [0, 1])
|
|
542
|
+
for (const roughness of [0.05, 1]) {
|
|
543
|
+
const surface = { metallic, roughness };
|
|
544
|
+
const ordinary = await sample('standard', true, 0, undefined, {
|
|
545
|
+
...surface,
|
|
546
|
+
ordinary: true,
|
|
547
|
+
});
|
|
548
|
+
const particle = await sample('standard', true, 0, undefined, surface);
|
|
549
|
+
for (let channel = 0; channel < 3; channel++)
|
|
550
|
+
expect(
|
|
551
|
+
Math.abs((particle[channel] ?? 0) - (ordinary[channel] ?? 0)),
|
|
552
|
+
JSON.stringify({ surface, particle, ordinary }),
|
|
553
|
+
).toBeLessThanOrEqual(2);
|
|
554
|
+
expect(Math.max(...particle.slice(0, 3))).toBeGreaterThan(30);
|
|
555
|
+
environmentSamples.push(particle);
|
|
556
|
+
}
|
|
557
|
+
expect(environmentSamples[2]).not.toEqual(environmentSamples[3]);
|
|
558
|
+
const environmentLit = await sample('standard', true, 0);
|
|
559
|
+
world.set(sky, Skylight, { rotation: [0, 1, 0, 0] }).unwrap();
|
|
560
|
+
const environmentRotated = await sample('standard', true, 0);
|
|
561
|
+
expect(
|
|
562
|
+
Math.max(
|
|
563
|
+
...environmentLit
|
|
564
|
+
.slice(0, 3)
|
|
565
|
+
.map((value, index) => Math.abs(value - (environmentRotated[index] ?? 0))),
|
|
566
|
+
),
|
|
567
|
+
).toBeGreaterThan(30);
|
|
568
|
+
expect(await sample('standard', false, 0)).toEqual(environmentRotated);
|
|
569
|
+
expect(await sample('unlit', true, 0)).toEqual(unlit);
|
|
570
|
+
world.set(sky, Skylight, { intensity: 0 }).unwrap();
|
|
571
|
+
expect(await sample('standard', true, 0)).toEqual(dark);
|
|
572
|
+
world.despawn(sky).unwrap();
|
|
573
|
+
expect(await sample('standard', true, 0)).toEqual(dark);
|
|
574
|
+
const receiver = world
|
|
575
|
+
.spawn(
|
|
576
|
+
{ component: Transform, data: { pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
577
|
+
{ component: MeshFilter, data: { assetHandle: world.allocSharedRef('MeshAsset', mesh) } },
|
|
578
|
+
{
|
|
579
|
+
component: MeshRenderer,
|
|
580
|
+
data: {
|
|
581
|
+
materials: [
|
|
582
|
+
world.allocSharedRef('MaterialAsset', {
|
|
583
|
+
...material,
|
|
584
|
+
passes: [
|
|
585
|
+
{
|
|
586
|
+
name: 'Forward',
|
|
587
|
+
program: { module: 'forgeax::default-standard-pbr' },
|
|
588
|
+
renderState: { tags: { LightMode: 'Forward' } },
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
}),
|
|
592
|
+
],
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
)
|
|
596
|
+
.unwrap();
|
|
597
|
+
const casterPosition = [1.2, 0, 1.45] as const;
|
|
598
|
+
const clearReceiver = await sample('unlit', false, 3, {
|
|
599
|
+
enabled: false,
|
|
600
|
+
position: casterPosition,
|
|
601
|
+
});
|
|
602
|
+
const particleShadow = await sample('unlit', false, 3, {
|
|
603
|
+
enabled: true,
|
|
604
|
+
position: casterPosition,
|
|
605
|
+
});
|
|
606
|
+
expect(clearReceiver[0], JSON.stringify({ clearReceiver, particleShadow })).toBeGreaterThan(
|
|
607
|
+
(particleShadow[0] ?? 0) + 30,
|
|
608
|
+
);
|
|
609
|
+
expect(await sample('unlit', false, 3, { enabled: false, position: casterPosition })).toEqual(
|
|
610
|
+
clearReceiver,
|
|
611
|
+
);
|
|
612
|
+
for (const [kind, sideX, sideY] of [
|
|
613
|
+
['point', 1, 0],
|
|
614
|
+
['point', -1, 0],
|
|
615
|
+
['point', 0, 1],
|
|
616
|
+
['point', 0, -1],
|
|
617
|
+
['spot', 1, 0],
|
|
618
|
+
] as const) {
|
|
619
|
+
const localCaster = [sideX * 1.2, sideY * 1.2, 1.45] as const;
|
|
620
|
+
const transform = {
|
|
621
|
+
component: Transform,
|
|
622
|
+
data: {
|
|
623
|
+
pos: [sideX * 2.4, sideY * 2.4, 2.65] as const,
|
|
624
|
+
quat: [0, 0, 0, 1] as const,
|
|
625
|
+
scale: [1, 1, 1] as const,
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
const localLight =
|
|
629
|
+
kind === 'point'
|
|
630
|
+
? world
|
|
631
|
+
.spawn(
|
|
632
|
+
transform,
|
|
633
|
+
{ component: PointLight, data: { color: [1, 1, 1], intensity: 40, range: 10 } },
|
|
634
|
+
{
|
|
635
|
+
component: PointLightShadow,
|
|
636
|
+
data: {
|
|
637
|
+
mapSize: 512,
|
|
638
|
+
nearPlane: 0.1,
|
|
639
|
+
farPlane: 10,
|
|
640
|
+
depthBias: 0.001,
|
|
641
|
+
normalBias: 0.001,
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
)
|
|
645
|
+
.unwrap()
|
|
646
|
+
: world
|
|
647
|
+
.spawn(transform, {
|
|
648
|
+
component: SpotLight,
|
|
649
|
+
data: {
|
|
650
|
+
color: [1, 1, 1],
|
|
651
|
+
intensity: 40,
|
|
652
|
+
range: 10,
|
|
653
|
+
direction: [-sideX, -sideY, -1],
|
|
654
|
+
innerConeDeg: 20,
|
|
655
|
+
outerConeDeg: 35,
|
|
656
|
+
castShadow: true,
|
|
657
|
+
mapSize: 512,
|
|
658
|
+
nearPlane: 0.1,
|
|
659
|
+
farPlane: 10,
|
|
660
|
+
depthBias: 0.001,
|
|
661
|
+
normalBias: 0.001,
|
|
662
|
+
},
|
|
663
|
+
})
|
|
664
|
+
.unwrap();
|
|
665
|
+
const clear = await sample('unlit', false, 0, { enabled: false, position: localCaster });
|
|
666
|
+
const shadow = await sample('unlit', false, 0, { enabled: true, position: localCaster });
|
|
667
|
+
expect(clear[0], JSON.stringify({ kind, sideX, sideY, clear, shadow })).toBeGreaterThan(
|
|
668
|
+
(shadow[0] ?? 0) + 30,
|
|
669
|
+
);
|
|
670
|
+
expect(await sample('unlit', false, 0, { enabled: false, position: localCaster })).toEqual(
|
|
671
|
+
clear,
|
|
672
|
+
);
|
|
673
|
+
const meshCaster = spawnMeshCaster(localCaster, createPlaneGeometry(0.5, 0.5).unwrap());
|
|
674
|
+
const ordinaryShadow = await sample('unlit', false, 0, {
|
|
675
|
+
enabled: false,
|
|
676
|
+
position: localCaster,
|
|
677
|
+
});
|
|
678
|
+
expect(
|
|
679
|
+
clear[0],
|
|
680
|
+
JSON.stringify({ kind, sideX, sideY, clear, ordinaryShadow }),
|
|
681
|
+
).toBeGreaterThan((ordinaryShadow[0] ?? 0) + 30);
|
|
682
|
+
world.set(meshCaster, Transform, { quat: [0, 1, 0, 0] }).unwrap();
|
|
683
|
+
expect(await sample('unlit', false, 0, { enabled: false, position: localCaster })).toEqual(
|
|
684
|
+
clear,
|
|
685
|
+
);
|
|
686
|
+
world.despawn(meshCaster).unwrap();
|
|
687
|
+
expect(await sample('unlit', false, 0, { enabled: false, position: localCaster })).toEqual(
|
|
688
|
+
clear,
|
|
689
|
+
);
|
|
690
|
+
world.despawn(localLight).unwrap();
|
|
691
|
+
}
|
|
692
|
+
// Current scene depth is produced after shadows. This simulation alternates
|
|
693
|
+
// its caster each FixedUpdate; the light view must consume the last submitted
|
|
694
|
+
// projection, not rerun a tick or pretend current depth exists before opaque.
|
|
695
|
+
world.set(light, DirectionalLight, { intensity: 3 }).unwrap();
|
|
696
|
+
world.update(1 / 60).unwrap();
|
|
697
|
+
const present = async () => {
|
|
698
|
+
const submitted = renderer.draw(frame);
|
|
699
|
+
if (!submitted.ok) throw submitted.error;
|
|
700
|
+
const observed = await renderer.observe(submitted.value, { include: ['draws'] });
|
|
701
|
+
if (!observed.ok) throw observed.error;
|
|
702
|
+
return pixel();
|
|
703
|
+
};
|
|
704
|
+
const depthClear = await present();
|
|
705
|
+
const depthCook = await cookParticleCodeEffect(
|
|
706
|
+
{
|
|
707
|
+
schemaVersion: 3,
|
|
708
|
+
emitters: [
|
|
709
|
+
{
|
|
710
|
+
id: 'depth-latency',
|
|
711
|
+
capacity: 1,
|
|
712
|
+
backend: { required: 'gpu' },
|
|
713
|
+
space: 'world',
|
|
714
|
+
bounds: { kind: 'sphere', center: [1.2, 1, 1.45], radius: 1.5 },
|
|
715
|
+
schedule: { rate: 0, bursts: [{ time: 0, count: 1 }] },
|
|
716
|
+
program: { module: 'depth-latency.wgsl' },
|
|
717
|
+
renderers: [
|
|
718
|
+
{
|
|
719
|
+
kind: 'mesh',
|
|
720
|
+
mesh: AssetGuid.format(meshGuid),
|
|
721
|
+
material: AssetGuid.format(materialGuid),
|
|
722
|
+
lighting: 'unlit',
|
|
723
|
+
castShadows: true,
|
|
724
|
+
},
|
|
725
|
+
],
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
'depth-latency.wgsl': {
|
|
731
|
+
entry: `
|
|
732
|
+
#import forgeax_vfx::prelude::{VfxParticle, VfxSpawnContext, VfxUpdateContext}
|
|
733
|
+
#import forgeax_vfx::data::scene_depth
|
|
734
|
+
fn vfx_spawn(ctx: VfxSpawnContext, particle: ptr<function, VfxParticle>) {
|
|
735
|
+
(*particle).position = vec3<f32>(1.2, 2.0, 1.45);
|
|
736
|
+
(*particle).color = vec4<f32>(1.0);
|
|
737
|
+
(*particle).mesh_orientation = vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
|
738
|
+
(*particle).mesh_scale = vec3<f32>(1.0);
|
|
739
|
+
(*particle).lifetime = 100.0;
|
|
740
|
+
}
|
|
741
|
+
fn vfx_update(ctx: VfxUpdateContext, particle: ptr<function, VfxParticle>) {
|
|
742
|
+
let depth = textureLoad(forgeax_vfx_scene_depth, vec2<i32>(32, 32), 0);
|
|
743
|
+
let casts = depth < 0.99 && (ctx.tick % 2u) == 0u;
|
|
744
|
+
(*particle).position = vec3<f32>(1.2, select(2.0, 0.0, casts), 1.45);
|
|
745
|
+
}`,
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
);
|
|
749
|
+
if (!depthCook.ok) throw depthCook.error;
|
|
750
|
+
const depthPlayer = world
|
|
751
|
+
.spawn({
|
|
752
|
+
component: ParticleEffectPlayer,
|
|
753
|
+
data: {
|
|
754
|
+
effect: world.allocSharedRef('ParticleEffectAsset', depthCook.value.asset),
|
|
755
|
+
playing: true,
|
|
756
|
+
seed: 1,
|
|
757
|
+
timeScale: 1,
|
|
758
|
+
},
|
|
759
|
+
})
|
|
760
|
+
.unwrap();
|
|
761
|
+
const phase = () =>
|
|
762
|
+
vfx.inspect(world)?.players.find((entry) => entry.player === depthPlayer)?.lastCommitted
|
|
763
|
+
?.phaseTick;
|
|
764
|
+
world.update(1 / 60).unwrap();
|
|
765
|
+
let firstDepthFrame: number[] = [];
|
|
766
|
+
for (let attempt = 0; attempt < 32; attempt++) {
|
|
767
|
+
firstDepthFrame = await present();
|
|
768
|
+
if (phase() === 0) break;
|
|
769
|
+
}
|
|
770
|
+
expect(phase()).toBe(0);
|
|
771
|
+
expect(firstDepthFrame).toEqual(depthClear);
|
|
772
|
+
const depthShadow = await present();
|
|
773
|
+
expect(depthClear[0], JSON.stringify({ depthClear, depthShadow })).toBeGreaterThan(
|
|
774
|
+
(depthShadow[0] ?? 0) + 30,
|
|
775
|
+
);
|
|
776
|
+
expect(phase()).toBe(0);
|
|
777
|
+
expect(await present()).toEqual(depthShadow);
|
|
778
|
+
expect(phase()).toBe(0);
|
|
779
|
+
|
|
780
|
+
const capture = recorder.captureFrame();
|
|
781
|
+
const before = await recorder.frameBoundary();
|
|
782
|
+
if (!before.ok) throw before.error;
|
|
783
|
+
world.update(1 / 60).unwrap();
|
|
784
|
+
const changedDepthFrame = await present();
|
|
785
|
+
const after = await recorder.frameBoundary();
|
|
786
|
+
if (!after.ok) throw after.error;
|
|
787
|
+
const captured = await capture;
|
|
788
|
+
if (!captured.ok) throw captured.error;
|
|
789
|
+
if (env.FORGEAX_VFX_DEPTH_LATENCY_TAPE !== undefined)
|
|
790
|
+
options.saveTape?.(env.FORGEAX_VFX_DEPTH_LATENCY_TAPE, captured.value.bytes);
|
|
791
|
+
const decoded = decodeTape(captured.value.bytes);
|
|
792
|
+
if (!decoded.ok) throw decoded.error;
|
|
793
|
+
const depthModel = buildFrameModel(decoded.value);
|
|
794
|
+
expect(
|
|
795
|
+
changedDepthFrame,
|
|
796
|
+
JSON.stringify({ phase: phase(), passes: renderer.inspect().perFramePassNames }),
|
|
797
|
+
).toEqual(depthShadow);
|
|
798
|
+
expect(phase()).toBe(1);
|
|
799
|
+
const depthUpdate = depthModel.works.find((work) =>
|
|
800
|
+
work.pipeline?.shaders.some((shader) => shader.entryPoint === 'forgeax_vfx_update_main'),
|
|
801
|
+
);
|
|
802
|
+
const shadowWork = depthModel.works.find((work) => work.kind === 'drawIndexedIndirect');
|
|
803
|
+
expect(depthUpdate).toBeDefined();
|
|
804
|
+
expect(shadowWork).toBeDefined();
|
|
805
|
+
expect(depthUpdate?.workIndex).toBeGreaterThan(shadowWork?.workIndex ?? Infinity);
|
|
806
|
+
expect(
|
|
807
|
+
depthUpdate?.bindings.some((binding) => binding.groupIndex === 0 && binding.binding === 13),
|
|
808
|
+
).toBe(true);
|
|
809
|
+
expect(await present()).toEqual(depthClear);
|
|
810
|
+
expect(phase()).toBe(1);
|
|
811
|
+
world.update(1 / 60).unwrap();
|
|
812
|
+
expect(await present()).toEqual(depthClear);
|
|
813
|
+
expect(phase()).toBe(2);
|
|
814
|
+
expect(await present()).toEqual(depthShadow);
|
|
815
|
+
// Catch-up may submit several ordered ticks together. Early shadows still
|
|
816
|
+
// use the previous projection; the following draw sees only the final tick.
|
|
817
|
+
world.update(1 / 60).unwrap();
|
|
818
|
+
world.update(1 / 60).unwrap();
|
|
819
|
+
world.update(1 / 60).unwrap();
|
|
820
|
+
expect(await present()).toEqual(depthShadow);
|
|
821
|
+
expect(phase()).toBe(5);
|
|
822
|
+
expect(await present()).toEqual(depthClear);
|
|
823
|
+
expect(phase()).toBe(5);
|
|
824
|
+
world.despawn(depthPlayer).unwrap();
|
|
825
|
+
world.update(1 / 60).unwrap();
|
|
826
|
+
expect(await present()).toEqual(depthClear);
|
|
827
|
+
world.despawn(receiver).unwrap();
|
|
828
|
+
const replayAdapter = await pack.value.rhi.requestAdapter();
|
|
829
|
+
if (!replayAdapter.ok) throw replayAdapter.error;
|
|
830
|
+
const replaySampledTextureLimit = replayAdapter.value.limits.maxSampledTexturesPerShaderStage;
|
|
831
|
+
const replayDevice = await replayAdapter.value.requestDevice(
|
|
832
|
+
Number.isFinite(replaySampledTextureLimit) && replaySampledTextureLimit > 0
|
|
833
|
+
? {
|
|
834
|
+
requiredLimits: {
|
|
835
|
+
// Keep the replay admission request within the actual adapter
|
|
836
|
+
// capability. The lighting/depth assertions below still run on
|
|
837
|
+
// the fresh device; this only avoids rejecting low-limit
|
|
838
|
+
// WebGPU adapters before replay can begin.
|
|
839
|
+
maxSampledTexturesPerShaderStage: Math.min(20, replaySampledTextureLimit),
|
|
840
|
+
},
|
|
841
|
+
}
|
|
842
|
+
: undefined,
|
|
843
|
+
);
|
|
844
|
+
if (!replayDevice.ok) throw replayDevice.error;
|
|
845
|
+
// Native lifetime cleanup stays in this Dawn fixture, outside Render/VFX.
|
|
846
|
+
const replayRaw = pack.value._internal_getRawDevice?.(replayDevice.value) as
|
|
847
|
+
| GPUDevice
|
|
848
|
+
| undefined;
|
|
849
|
+
if (replayRaw === undefined) throw new Error('Missing native replay device');
|
|
850
|
+
try {
|
|
851
|
+
const replay = await openReplay(decoded.value, {
|
|
852
|
+
device: replayDevice.value,
|
|
853
|
+
createShaderModule: pack.value.createShaderModule,
|
|
854
|
+
});
|
|
855
|
+
if (!replay.ok) throw replay.error;
|
|
856
|
+
let disposalError: unknown;
|
|
857
|
+
try {
|
|
858
|
+
const lastWork = depthModel.works.at(-1);
|
|
859
|
+
if (lastWork === undefined) throw new Error('Missing captured output work');
|
|
860
|
+
const inspection = await replay.value.inspectWork(lastWork.workIndex, ['pixels']);
|
|
861
|
+
if (!inspection.ok) throw inspection.error;
|
|
862
|
+
const attachment = inspection.value.attachment;
|
|
863
|
+
if (attachment === undefined) throw new Error('Missing replay pixels');
|
|
864
|
+
expect(attachment.width).toBe(64);
|
|
865
|
+
expect(attachment.height).toBe(64);
|
|
866
|
+
const offset = (32 * 64 + 32) * 4;
|
|
867
|
+
expect([...attachment.bytes.slice(offset, offset + 4)]).toEqual(changedDepthFrame);
|
|
868
|
+
} finally {
|
|
869
|
+
const disposed = await replay.value.dispose();
|
|
870
|
+
if (!disposed.ok) disposalError = disposed.error;
|
|
871
|
+
}
|
|
872
|
+
if (disposalError !== undefined) throw disposalError;
|
|
873
|
+
} finally {
|
|
874
|
+
replayRaw.destroy();
|
|
875
|
+
}
|
|
876
|
+
expect(errors).toEqual([]);
|
|
877
|
+
} finally {
|
|
878
|
+
if (vfxAttached) await vfx.detachWorld({ world });
|
|
879
|
+
renderer.dispose();
|
|
880
|
+
target?.destroy();
|
|
881
|
+
await recorder.dispose();
|
|
882
|
+
}
|
|
883
|
+
}
|