@forgeax/engine-render 0.1.35 → 0.1.37
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 +28 -0
- package/dist/assembly/host-contract.d.ts +2 -2
- package/dist/assembly/host-contract.d.ts.map +1 -1
- package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -1
- package/dist/{chunk-MZ4C7EHF.mjs → chunk-3F5XYSN3.mjs} +5 -5
- package/dist/{chunk-MZ4C7EHF.mjs.map → chunk-3F5XYSN3.mjs.map} +1 -1
- package/dist/{chunk-G6HDNGY7.mjs → chunk-3O2BBKEL.mjs} +24 -230
- package/dist/chunk-3O2BBKEL.mjs.map +1 -0
- package/dist/{chunk-MXTWKZO4.mjs → chunk-ARLAEJ45.mjs} +225 -4
- package/dist/chunk-ARLAEJ45.mjs.map +1 -0
- package/dist/{chunk-3XUBWRY2.mjs → chunk-E4NJYUJ2.mjs} +4 -4
- package/dist/{chunk-3XUBWRY2.mjs.map → chunk-E4NJYUJ2.mjs.map} +1 -1
- package/dist/{chunk-LM5IE7GF.mjs → chunk-N7NVXQWI.mjs} +3 -3
- package/dist/{chunk-LM5IE7GF.mjs.map → chunk-N7NVXQWI.mjs.map} +1 -1
- package/dist/{chunk-SGPUJW3F.mjs → chunk-NMSOOXBB.mjs} +3 -3
- package/dist/{chunk-SGPUJW3F.mjs.map → chunk-NMSOOXBB.mjs.map} +1 -1
- package/dist/{chunk-LNIUIKQ7.mjs → chunk-SJIPVWNB.mjs} +79 -21
- package/dist/chunk-SJIPVWNB.mjs.map +1 -0
- package/dist/construct-renderer.mjs +141 -74
- package/dist/construct-renderer.mjs.map +1 -1
- package/dist/device/device-scope.d.ts.map +1 -1
- package/dist/features/particle-mesh-layout.d.ts +19 -0
- package/dist/features/particle-mesh-layout.d.ts.map +1 -0
- package/dist/features/types.d.ts +5 -1
- package/dist/features/types.d.ts.map +1 -1
- package/dist/gpu-driven/production-raster.d.ts +1 -1
- package/dist/gpu-driven/production-raster.d.ts.map +1 -1
- package/dist/gpu-driven/shadow-views.d.ts +1 -1
- package/dist/gpu-driven/shadow-views.d.ts.map +1 -1
- package/dist/gpu-driven/view-gpu.d.ts +1 -1
- package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
- package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
- package/dist/index.mjs +48 -36
- package/dist/index.mjs.map +1 -1
- package/dist/internal.mjs +5 -5
- package/dist/pbr-pipeline.d.ts.map +1 -1
- package/dist/publication/publisher.d.ts +1 -0
- package/dist/publication/publisher.d.ts.map +1 -1
- package/dist/record/frame.d.ts.map +1 -1
- package/dist/record/prepared-material-bindings.d.ts +1 -2
- package/dist/record/prepared-material-bindings.d.ts.map +1 -1
- package/dist/record/render-context.d.ts +2 -0
- package/dist/record/render-context.d.ts.map +1 -1
- package/dist/record/shadow-pass.d.ts +2 -2
- package/dist/record/shadow-pass.d.ts.map +1 -1
- package/dist/record/typed-frame-graph.d.ts.map +1 -1
- package/dist/recovery/render-system-candidate.d.ts +1 -8
- package/dist/recovery/render-system-candidate.d.ts.map +1 -1
- package/dist/render-contract.d.ts +2 -2
- package/dist/render-contract.d.ts.map +1 -1
- package/dist/render-system-extract-tail.d.ts.map +1 -1
- package/dist/render-system-extract.d.ts.map +1 -1
- package/dist/render-system.d.ts +2 -1
- package/dist/render-system.d.ts.map +1 -1
- package/dist/scene/render-scene.d.ts +2 -1
- package/dist/scene/render-scene.d.ts.map +1 -1
- package/dist/temporal/index.mjs +4 -4
- package/dist/typed-shadow-passes.d.ts.map +1 -1
- package/package.json +21 -21
- package/src/__tests__/device-scope-lifecycle.unit.test.ts +26 -0
- package/src/__tests__/gpu-driven-production.integration.test.ts +41 -1
- package/src/__tests__/gpu-driven-shadow-views.unit.test.ts +23 -0
- package/src/__tests__/ibl/precompute-submission.unit.test.ts +123 -0
- package/src/__tests__/ibl-residency-publication.unit.test.ts +0 -16
- package/src/__tests__/material-vertex-texture.browser.test.ts +7 -0
- package/src/__tests__/material-vertex-texture.dawn.test.ts +7 -0
- package/src/__tests__/material-vertex-texture.fixture.ts +31 -0
- package/src/__tests__/recovery-candidate-prepare.contract.test.ts +5 -0
- package/src/__tests__/renderer-factory-material-contract.unit.test.ts +106 -4
- package/src/__tests__/runtime-material-program.integration.test.ts +79 -0
- package/src/__tests__/vertex-layouts-ssot.unit.test.ts +23 -3
- package/src/assembly/host-contract.ts +2 -2
- package/src/assembly/webgpu-renderer.ts +12 -9
- package/src/assembly/webgpu-vertex-layouts.ts +2 -9
- package/src/device/device-scope.ts +3 -0
- package/src/features/particle-mesh-layout.ts +25 -0
- package/src/features/types.ts +5 -1
- package/src/gpu-driven/production-raster.ts +23 -6
- package/src/gpu-driven/shadow-views.ts +27 -2
- package/src/gpu-driven/view-gpu.ts +5 -0
- package/src/ibl/IblPipelineCache.ts +4 -3
- package/src/pbr-pipeline.ts +4 -1
- package/src/publication/__tests__/features.integration.test.ts +51 -0
- package/src/publication/__tests__/publication.integration.test.ts +59 -0
- package/src/publication/publisher.ts +56 -29
- package/src/record/__tests__/prepared-runtime-material.integration.test.ts +134 -0
- package/src/record/frame.ts +45 -6
- package/src/record/prepared-material-bindings.ts +11 -37
- package/src/record/render-context.ts +2 -0
- package/src/record/shadow-pass.ts +65 -4
- package/src/record/typed-frame-graph.ts +5 -1
- package/src/recovery/render-system-candidate.ts +1 -9
- package/src/render-contract.ts +2 -2
- package/src/render-system-extract-tail.ts +15 -6
- package/src/render-system-extract.ts +9 -2
- package/src/render-system.ts +2 -3
- package/src/scene/render-scene.ts +4 -2
- package/src/typed-shadow-passes.ts +36 -5
- package/dist/chunk-G6HDNGY7.mjs.map +0 -1
- package/dist/chunk-LNIUIKQ7.mjs.map +0 -1
- package/dist/chunk-MXTWKZO4.mjs.map +0 -1
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import { AssetRegistry } from '@forgeax/engine-assets-runtime';
|
|
3
4
|
import { type RhiDevice, RhiError } from '@forgeax/engine-rhi';
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import {
|
|
6
|
+
createMaterialProgramArtifactReceipt,
|
|
7
|
+
type MaterialShaderManifestEntry,
|
|
8
|
+
type MaterialShaderManifestVariant,
|
|
9
|
+
type ShaderCatalog,
|
|
10
|
+
ShaderRegistry,
|
|
11
|
+
type ShaderRegistryDevice,
|
|
8
12
|
} from '@forgeax/engine-shader';
|
|
9
13
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
14
|
import { deviceOptionsForAdapter } from '../assembly/device-feature-admission';
|
|
@@ -128,6 +132,104 @@ fn cloud_direct_solar_factorX() -> f32 {
|
|
|
128
132
|
});
|
|
129
133
|
});
|
|
130
134
|
|
|
135
|
+
it('selects the replacement device capability variant after a metadata-only fork', async () => {
|
|
136
|
+
const uniformReceipt = createMaterialProgramArtifactReceipt({
|
|
137
|
+
schema: [],
|
|
138
|
+
directEntry: 'vs_uniform',
|
|
139
|
+
sceneIndexEntry: 'vs_scene_uniform',
|
|
140
|
+
vertexInputs: [],
|
|
141
|
+
layoutIdentity: 'uniform',
|
|
142
|
+
});
|
|
143
|
+
const storageReceipt = createMaterialProgramArtifactReceipt({
|
|
144
|
+
schema: [],
|
|
145
|
+
directEntry: 'vs_storage',
|
|
146
|
+
sceneIndexEntry: 'vs_scene_storage',
|
|
147
|
+
vertexInputs: [],
|
|
148
|
+
layoutIdentity: 'storage',
|
|
149
|
+
});
|
|
150
|
+
const entry: MaterialShaderManifestEntry = {
|
|
151
|
+
identifier: 'game::variant',
|
|
152
|
+
sourcePath: 'variant.wgsl',
|
|
153
|
+
composedWgsl: 'fallback',
|
|
154
|
+
paramSchema: '[]',
|
|
155
|
+
uvSetCount: 3,
|
|
156
|
+
variants: [
|
|
157
|
+
{
|
|
158
|
+
definesKey: 'STORAGE_BUFFER_AVAILABLE=false',
|
|
159
|
+
defines: { STORAGE_BUFFER_AVAILABLE: false },
|
|
160
|
+
composedWgsl: 'uniform-variant',
|
|
161
|
+
receipt: uniformReceipt,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
definesKey: '',
|
|
165
|
+
defines: { STORAGE_BUFFER_AVAILABLE: true },
|
|
166
|
+
composedWgsl: 'storage-variant',
|
|
167
|
+
receipt: storageReceipt,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
};
|
|
171
|
+
const manifestUrl = URL.createObjectURL(
|
|
172
|
+
new Blob([JSON.stringify({ entries: [], materialShaders: [entry] })]),
|
|
173
|
+
);
|
|
174
|
+
const registryDevice = (): ShaderRegistryDevice => ({
|
|
175
|
+
createShaderModule: () => ({ ok: true, value: {} as never }),
|
|
176
|
+
});
|
|
177
|
+
const device = (storageBuffer: boolean): RhiDevice =>
|
|
178
|
+
({
|
|
179
|
+
caps: {
|
|
180
|
+
backendKind: 'webgpu',
|
|
181
|
+
storageBuffer,
|
|
182
|
+
rgba16floatRenderable: true,
|
|
183
|
+
samplerAliasing: true,
|
|
184
|
+
},
|
|
185
|
+
limits: {
|
|
186
|
+
maxStorageBuffersPerShaderStage: storageBuffer ? 8 : 0,
|
|
187
|
+
maxSampledTexturesPerShaderStage: 32,
|
|
188
|
+
maxTextureArrayLayers: 64,
|
|
189
|
+
maxUniformBuffersPerShaderStage: 4,
|
|
190
|
+
},
|
|
191
|
+
}) as unknown as RhiDevice;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const active = new ShaderRegistry({ device: registryDevice(), manifestUrl });
|
|
195
|
+
const activeAssets = new AssetRegistry(active);
|
|
196
|
+
await prepareMaterialShaders(device(false), () => active, activeAssets, new Map());
|
|
197
|
+
active.installMaterialArtifact('game::custom', {
|
|
198
|
+
source: 'custom-variant',
|
|
199
|
+
paramSchema: [],
|
|
200
|
+
receipt: uniformReceipt,
|
|
201
|
+
});
|
|
202
|
+
const activeArtifact = active.findMaterialArtifact('game::variant');
|
|
203
|
+
expect(activeArtifact.ok).toBe(true);
|
|
204
|
+
if (!activeArtifact.ok) return;
|
|
205
|
+
expect(activeArtifact.value.source).toBe('uniform-variant');
|
|
206
|
+
expect(activeArtifact.value.receipt).toEqual(uniformReceipt);
|
|
207
|
+
|
|
208
|
+
URL.revokeObjectURL(manifestUrl);
|
|
209
|
+
const candidate = active.forkForDevice(registryDevice());
|
|
210
|
+
const candidateAssets = new AssetRegistry(candidate);
|
|
211
|
+
const uvSetCounts = new Map<string, number>();
|
|
212
|
+
await prepareMaterialShaders(device(true), () => candidate, candidateAssets, uvSetCounts);
|
|
213
|
+
const candidateArtifact = candidate.findMaterialArtifact('game::variant');
|
|
214
|
+
expect(candidateArtifact.ok).toBe(true);
|
|
215
|
+
if (!candidateArtifact.ok) return;
|
|
216
|
+
expect(candidateArtifact.value.source).toBe('storage-variant');
|
|
217
|
+
expect(candidateArtifact.value.receipt).toEqual(storageReceipt);
|
|
218
|
+
expect(uvSetCounts.get('game::variant')).toBe(3);
|
|
219
|
+
expect(candidate.findMaterialArtifact('game::custom').ok).toBe(false);
|
|
220
|
+
|
|
221
|
+
const customArtifact = active.findMaterialArtifact('game::custom');
|
|
222
|
+
expect(customArtifact.ok).toBe(true);
|
|
223
|
+
if (!customArtifact.ok) return;
|
|
224
|
+
candidate.installMaterialArtifact('game::custom', customArtifact.value);
|
|
225
|
+
const candidateCustom = candidate.findMaterialArtifact('game::custom');
|
|
226
|
+
expect(candidateCustom.ok && candidateCustom.value.source).toBe('custom-variant');
|
|
227
|
+
expect(activeArtifact.value.source).toBe('uniform-variant');
|
|
228
|
+
} finally {
|
|
229
|
+
URL.revokeObjectURL(manifestUrl);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
131
233
|
it('requests the extended-lighting sampled-texture limit when the adapter supports it', () => {
|
|
132
234
|
const features = new Set<GPUFeatureName>();
|
|
133
235
|
expect(
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AssetRegistry, HANDLE_CUBE, RuntimeMaterialValue } from '@forgeax/engine-assets-runtime';
|
|
2
|
+
import { World } from '@forgeax/engine-ecs';
|
|
3
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
4
|
+
import { ShaderRegistry } from '@forgeax/engine-shader';
|
|
5
|
+
import type { MaterialAsset } from '@forgeax/engine-types';
|
|
6
|
+
import { expect, it } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
MATERIAL_CONTEXT,
|
|
9
|
+
materialRecordFixture,
|
|
10
|
+
} from '../../../assets-runtime/src/__tests__/fixtures/material-publication';
|
|
11
|
+
import { MeshFilter, MeshRenderer } from '../components';
|
|
12
|
+
import { extractFrames, resolveMaterialSnapshot } from '../render-system-extract';
|
|
13
|
+
|
|
14
|
+
it('keeps cooked Forward and ShadowCaster programs when World values change or a catalogue entry is replaced', () => {
|
|
15
|
+
const assets = new AssetRegistry(new ShaderRegistry({ manifestUrl: undefined }));
|
|
16
|
+
const world = new World();
|
|
17
|
+
const record = materialRecordFixture({
|
|
18
|
+
parameters: [{ name: 'roughness', type: 'f32' }],
|
|
19
|
+
values: { roughness: 0.8 },
|
|
20
|
+
passes: [
|
|
21
|
+
{ name: 'Forward', program: { module: 'game::material' } },
|
|
22
|
+
{ name: 'ShadowCaster', program: { module: 'game::shadow' } },
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
const base: MaterialAsset = { kind: 'material', ...record.resolved };
|
|
26
|
+
assets.catalog(record.guid, base).unwrap();
|
|
27
|
+
if (!record.sourceClosure || !record.parameterContract)
|
|
28
|
+
throw new Error('Missing cooked metadata');
|
|
29
|
+
assets.recordMaterialReadiness(record.guid, {
|
|
30
|
+
status: 'Ready',
|
|
31
|
+
guid: record.guid,
|
|
32
|
+
materialGuid: record.guid,
|
|
33
|
+
publicationGeneration: record.publicationGeneration,
|
|
34
|
+
specializationKey: record.specializationKey,
|
|
35
|
+
artifactDigest: record.artifactDigest,
|
|
36
|
+
sourceClosure: record.sourceClosure,
|
|
37
|
+
parameterContract: record.parameterContract,
|
|
38
|
+
record,
|
|
39
|
+
programs: record.programs,
|
|
40
|
+
});
|
|
41
|
+
const handle = world.internSharedRef('MaterialAsset', base);
|
|
42
|
+
world
|
|
43
|
+
.spawn(
|
|
44
|
+
{ component: Transform, data: {} },
|
|
45
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
46
|
+
{ component: MeshRenderer, data: { materials: [handle] } },
|
|
47
|
+
)
|
|
48
|
+
.unwrap();
|
|
49
|
+
world.update(0).unwrap();
|
|
50
|
+
const frameRead = () =>
|
|
51
|
+
extractFrames([world], 0, assets, undefined, undefined, {
|
|
52
|
+
materialContext: MATERIAL_CONTEXT,
|
|
53
|
+
cull: 'none',
|
|
54
|
+
}).renderables[0]?.materials[0];
|
|
55
|
+
const read = () =>
|
|
56
|
+
resolveMaterialSnapshot(handle, world, assets, undefined, undefined, MATERIAL_CONTEXT);
|
|
57
|
+
const before = read();
|
|
58
|
+
expect(frameRead()?.materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
59
|
+
expect(Object.keys(before.materialProgramKeys ?? {})).toEqual(['Forward', 'ShadowCaster']);
|
|
60
|
+
const content = world
|
|
61
|
+
.spawn({
|
|
62
|
+
component: RuntimeMaterialValue,
|
|
63
|
+
data: {
|
|
64
|
+
asset: handle,
|
|
65
|
+
parameter: 'roughness',
|
|
66
|
+
kind: 0,
|
|
67
|
+
value: [0.25],
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
.unwrap();
|
|
71
|
+
expect(read().roughness).toBe(0.25);
|
|
72
|
+
expect(frameRead()?.materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
73
|
+
expect(read().materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
74
|
+
assets.catalog(record.guid, { ...base, values: { roughness: 0.6 } }).unwrap();
|
|
75
|
+
expect(read().materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
76
|
+
world.despawn(content).unwrap();
|
|
77
|
+
expect(read().roughness).toBeCloseTo(0.8);
|
|
78
|
+
expect(read().materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
79
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { deriveVertexBufferLayout } from '@forgeax/engine-geometry';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import {
|
|
4
4
|
BILLBOARD_MATERIAL_INSTANCE_VERTEX_BUFFERS,
|
|
@@ -17,11 +17,31 @@ const FLOAT32_COMPONENT_COUNT = {
|
|
|
17
17
|
describe('prepared feature vertex layout SSOT', () => {
|
|
18
18
|
it('derives the mesh vertex stream from geometry layout ownership', () => {
|
|
19
19
|
const [expected] = toGpuVertexBufferLayouts(
|
|
20
|
-
deriveVertexBufferLayout(
|
|
20
|
+
deriveVertexBufferLayout({
|
|
21
|
+
position: new Float32Array(),
|
|
22
|
+
normal: new Float32Array(),
|
|
23
|
+
uv: new Float32Array(),
|
|
24
|
+
tangent: new Float32Array(),
|
|
25
|
+
uv1: new Float32Array(),
|
|
26
|
+
color: new Float32Array(),
|
|
27
|
+
}),
|
|
21
28
|
);
|
|
22
29
|
const [actual] = MESH_GEOMETRY_MATERIAL_INSTANCE_VERTEX_BUFFERS;
|
|
23
30
|
|
|
24
|
-
|
|
31
|
+
if (expected === undefined) throw new Error('Missing geometry descriptor');
|
|
32
|
+
expect(actual).toEqual({
|
|
33
|
+
...expected,
|
|
34
|
+
stepMode: 'vertex',
|
|
35
|
+
attributes: expected.attributes.map((attribute) => ({
|
|
36
|
+
...attribute,
|
|
37
|
+
shaderLocation:
|
|
38
|
+
attribute.shaderLocation === 6
|
|
39
|
+
? 15
|
|
40
|
+
: attribute.shaderLocation === 13
|
|
41
|
+
? 14
|
|
42
|
+
: attribute.shaderLocation,
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
25
45
|
});
|
|
26
46
|
|
|
27
47
|
it.each([
|
|
@@ -14,7 +14,7 @@ import type { ObservationUnavailableError, RenderError } from '../errors/render'
|
|
|
14
14
|
import type { RenderFeature, RenderFeatureDiagnostics } from '../features/types';
|
|
15
15
|
import type { GpuDrivenProductionInspection, LodOcclusionInspection } from '../inspection-types';
|
|
16
16
|
import type { MeshMaterialBindingObservation } from '../mesh-material-bindings';
|
|
17
|
-
import type { PublishedRenderFrameInput } from '../publication/contract';
|
|
17
|
+
import type { PublishedRenderFrameInput, RenderPublicationIdentity } from '../publication/contract';
|
|
18
18
|
import type { FrameObservation, FrameObservationOptions } from '../record/frame';
|
|
19
19
|
import type { CurrentGraphTarget, GraphTargetCaptureRequest } from '../record/frame-snapshot';
|
|
20
20
|
import type {
|
|
@@ -110,7 +110,7 @@ export interface RendererHostImplementation {
|
|
|
110
110
|
destroyRenderTarget(target: RenderTarget): RenderResult<void, RenderError>;
|
|
111
111
|
setProfile(profile: RenderProfile): RenderResult<void, RenderError>;
|
|
112
112
|
/** Detached world bounds for one record in the last extracted World; undefined if unavailable. */
|
|
113
|
-
bounds(world: World, entity: number): RenderSceneBounds | undefined;
|
|
113
|
+
bounds(world: World | RenderPublicationIdentity, entity: number): RenderSceneBounds | undefined;
|
|
114
114
|
inspect(): RenderInspection;
|
|
115
115
|
/**
|
|
116
116
|
* Internal bounded inspection for high-cardinality producers. The public
|
|
@@ -570,6 +570,8 @@ async function makeWebGPURenderer(
|
|
|
570
570
|
const getShader = (): ShaderCatalog => {
|
|
571
571
|
const state = candidateShaderState ?? activeShaderState;
|
|
572
572
|
if (state.shaderInstance === null) {
|
|
573
|
+
const activeCatalog = activeShaderState.shaderInstance;
|
|
574
|
+
// Reuse validated manifest CPU rows; candidate preparation selects variants.
|
|
573
575
|
// feat-20260608-create-app-param-surface-trim / M2 / D-2 q5-A:
|
|
574
576
|
// shaderManifestUrl moved to BundlerOptions (third arg). The fallback
|
|
575
577
|
// literal '/shaders/manifest.json' stays here so the LO 1.1
|
|
@@ -578,15 +580,16 @@ async function makeWebGPURenderer(
|
|
|
578
580
|
// the zero-entry opt-in: explicitly passing
|
|
579
581
|
// `bundler: { shaderManifestUrl: undefined }` retains the
|
|
580
582
|
// old "no manifest fetched" mode used by tests / camera-only worlds.
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
583
|
+
state.shaderInstance =
|
|
584
|
+
state === candidateShaderState && activeCatalog !== null
|
|
585
|
+
? activeCatalog.forkForDevice(getShaderModuleAdapter())
|
|
586
|
+
: new ShaderCatalog({
|
|
587
|
+
device: getShaderModuleAdapter(),
|
|
588
|
+
manifestUrl:
|
|
589
|
+
internals.bundler !== undefined && 'shaderManifestUrl' in internals.bundler
|
|
590
|
+
? internals.bundler.shaderManifestUrl
|
|
591
|
+
: '/shaders/manifest.json',
|
|
592
|
+
});
|
|
590
593
|
}
|
|
591
594
|
return state.shaderInstance;
|
|
592
595
|
};
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type VertexLayoutProjection,
|
|
9
9
|
} from '@forgeax/engine-geometry';
|
|
10
10
|
import type { VertexAttributeMap } from '@forgeax/engine-types';
|
|
11
|
+
import { PARTICLE_MESH_VERTEX_BUFFER } from '../features/particle-mesh-layout';
|
|
11
12
|
import { RENDER_FEATURE_VERTEX_LAYOUTS } from '../features/prepared-graphics';
|
|
12
13
|
|
|
13
14
|
type FeatureFloatVertexFormat = 'float32x2' | 'float32x3' | 'float32x4';
|
|
@@ -101,14 +102,6 @@ const MESH_GEOMETRY_MATERIAL_INSTANCE_SPEC = {
|
|
|
101
102
|
export const PREPARED_INSTANCE_VERTEX_ATTRS = buildMeshAttributeMapForUvSets(2);
|
|
102
103
|
export const PREPARED_MATERIAL_INSTANCE_VERTEX_ATTRS = SKIN_VERTEX_ATTRIBUTE_MAP;
|
|
103
104
|
|
|
104
|
-
function canonicalMeshVertexBufferLayout(): GPUVertexBufferLayout {
|
|
105
|
-
const [buffer] = toGpuVertexBufferLayouts(deriveVertexBufferLayout(DEFAULT_VERTEX_ATTRIBUTE_MAP));
|
|
106
|
-
if (buffer === undefined) {
|
|
107
|
-
throw new Error('The canonical mesh vertex layout must contain one vertex buffer');
|
|
108
|
-
}
|
|
109
|
-
return { ...buffer, stepMode: 'vertex' };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
105
|
export const POSITION_SIZE_COLOR_INSTANCE_VERTEX_BUFFERS = [
|
|
113
106
|
defineFeatureVertexBuffer(POSITION_SIZE_COLOR_INSTANCE_SPEC),
|
|
114
107
|
] satisfies readonly GPUVertexBufferLayout[];
|
|
@@ -127,7 +120,7 @@ export const TOPOLOGY_SEGMENT_INSTANCE_VERTEX_BUFFERS = [
|
|
|
127
120
|
* particle transform/material data rather than MeshAsset attributes.
|
|
128
121
|
*/
|
|
129
122
|
export const MESH_GEOMETRY_MATERIAL_INSTANCE_VERTEX_BUFFERS = [
|
|
130
|
-
|
|
123
|
+
PARTICLE_MESH_VERTEX_BUFFER,
|
|
131
124
|
defineFeatureVertexBuffer(MESH_GEOMETRY_MATERIAL_INSTANCE_SPEC),
|
|
132
125
|
] satisfies readonly GPUVertexBufferLayout[];
|
|
133
126
|
|
|
@@ -176,6 +176,7 @@ export class DeviceScope {
|
|
|
176
176
|
this.clearChildren();
|
|
177
177
|
this._clearResources();
|
|
178
178
|
this.lifecycleState = 'retired';
|
|
179
|
+
this.parent?.detachChild(this);
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
abandon(): void {
|
|
@@ -183,6 +184,7 @@ export class DeviceScope {
|
|
|
183
184
|
this.lifecycleState = 'abandoned';
|
|
184
185
|
this.clearChildren();
|
|
185
186
|
this._clearResources();
|
|
187
|
+
this.parent?.detachChild(this);
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
dispose(): void {
|
|
@@ -190,6 +192,7 @@ export class DeviceScope {
|
|
|
190
192
|
this.lifecycleState = 'disposed';
|
|
191
193
|
this.clearChildren();
|
|
192
194
|
this._clearResources();
|
|
195
|
+
this.parent?.detachChild(this);
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
async replace(
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { deriveVertexLayoutProjection } from '@forgeax/engine-geometry';
|
|
2
|
+
|
|
3
|
+
// Mesh attributes keep geometry's canonical packing. Only locations change:
|
|
4
|
+
// the particle instance occupies 4..9 and custom material inputs occupy 10..13.
|
|
5
|
+
export const PARTICLE_MESH_DEFAULTS = {
|
|
6
|
+
position: [0, 0, 0],
|
|
7
|
+
normal: [0, 0, 1],
|
|
8
|
+
uv: [0, 0],
|
|
9
|
+
tangent: [1, 0, 0, 1],
|
|
10
|
+
uv1: [0, 0],
|
|
11
|
+
color: [1, 1, 1, 1],
|
|
12
|
+
} as const;
|
|
13
|
+
export const PARTICLE_MESH_GEOMETRY = deriveVertexLayoutProjection(
|
|
14
|
+
Object.fromEntries(Object.keys(PARTICLE_MESH_DEFAULTS).map((key) => [key, new Float32Array()])),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const PARTICLE_MESH_VERTEX_BUFFER = {
|
|
18
|
+
arrayStride: PARTICLE_MESH_GEOMETRY.arrayStride,
|
|
19
|
+
stepMode: 'vertex' as const,
|
|
20
|
+
attributes: PARTICLE_MESH_GEOMETRY.attributes.map(({ key, shaderLocation, offset, format }) => ({
|
|
21
|
+
shaderLocation: key === 'color' ? 14 : key === 'uv1' ? 15 : shaderLocation,
|
|
22
|
+
offset,
|
|
23
|
+
format,
|
|
24
|
+
})),
|
|
25
|
+
};
|
package/src/features/types.ts
CHANGED
|
@@ -180,7 +180,11 @@ export interface RenderFeature<FrameData> {
|
|
|
180
180
|
* extracted frame and never during declarative planning.
|
|
181
181
|
*/
|
|
182
182
|
onFrameSubmitted?(data: FrameData, submission?: RenderFeatureSubmission): void;
|
|
183
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* Consume source-owned intents after submission; feedback must be structured-cloneable.
|
|
185
|
+
* A sealed successor may have been extracted before this acknowledgment. Ordered
|
|
186
|
+
* intent producers must identify already-submitted work instead of replaying it.
|
|
187
|
+
*/
|
|
184
188
|
onSourceFrameSubmitted?(data: FrameData, feedback: unknown): void;
|
|
185
189
|
/**
|
|
186
190
|
* Discard frame-owned producer state when graph admission or submission is
|
|
@@ -1359,6 +1359,7 @@ export interface PreparedGpuDrivenFrame {
|
|
|
1359
1359
|
readonly projectShadow?: (
|
|
1360
1360
|
graph: RenderGraphBuilder<RenderPipelineFrame>,
|
|
1361
1361
|
identity: ShadowViewIdentity,
|
|
1362
|
+
forceCompute?: boolean,
|
|
1362
1363
|
) => Result<ShadowViewProjection | undefined, RenderGraphError>;
|
|
1363
1364
|
readonly updateShadowViews?: (
|
|
1364
1365
|
views: readonly Omit<ShadowViewUpdateInput, 'sourcePlan' | 'scene'>[],
|
|
@@ -1404,7 +1405,7 @@ interface GpuDrivenResidencyValidationCache extends GpuDrivenResidencyValidation
|
|
|
1404
1405
|
|
|
1405
1406
|
interface GpuDrivenCpuValidationTelemetryCache {
|
|
1406
1407
|
readonly cacheKey: string;
|
|
1407
|
-
readonly
|
|
1408
|
+
readonly validatedSources: readonly RenderableSnapshot[];
|
|
1408
1409
|
readonly gpuOwnedDrawKeys: ReadonlySet<string>;
|
|
1409
1410
|
readonly worldKeys: readonly number[] | undefined;
|
|
1410
1411
|
readonly gpuOwnedDrawCount: number;
|
|
@@ -1420,6 +1421,17 @@ interface GpuDrivenCpuValidationTelemetryCache {
|
|
|
1420
1421
|
readonly blockedDrawItems: number;
|
|
1421
1422
|
}
|
|
1422
1423
|
|
|
1424
|
+
function sameValidatedSourceSequence(
|
|
1425
|
+
expected: readonly RenderableSnapshot[],
|
|
1426
|
+
actual: readonly { readonly source: RenderableSnapshot }[],
|
|
1427
|
+
): boolean {
|
|
1428
|
+
if (expected.length !== actual.length) return false;
|
|
1429
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
1430
|
+
if (expected[index] !== actual[index]?.source) return false;
|
|
1431
|
+
}
|
|
1432
|
+
return true;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1423
1435
|
function sameIdentitySequence<T extends object>(
|
|
1424
1436
|
expected: readonly T[],
|
|
1425
1437
|
actual: readonly T[],
|
|
@@ -2572,7 +2584,12 @@ export class GpuDrivenProduction {
|
|
|
2572
2584
|
?.inspect()
|
|
2573
2585
|
.map(
|
|
2574
2586
|
(entry) =>
|
|
2575
|
-
|
|
2587
|
+
// Cache decisions and logical update generations are frame-local
|
|
2588
|
+
// producer state. They must not feed the compiled graph key: a
|
|
2589
|
+
// graph replacement may intentionally invalidate a shadow view
|
|
2590
|
+
// once without creating a graph-rebuild feedback loop. Resource
|
|
2591
|
+
// and source-plan generations remain real graph dependencies.
|
|
2592
|
+
`${shadowViewIdentityKey(entry.identity)}:${entry.sourceRevision}:${entry.resourceGeneration}`,
|
|
2576
2593
|
)
|
|
2577
2594
|
.join('|') ?? '';
|
|
2578
2595
|
const production = this;
|
|
@@ -2689,10 +2706,10 @@ export class GpuDrivenProduction {
|
|
|
2689
2706
|
frameNumber,
|
|
2690
2707
|
),
|
|
2691
2708
|
}),
|
|
2692
|
-
projectShadow: (graph, identity) => {
|
|
2709
|
+
projectShadow: (graph, identity, forceCompute = false) => {
|
|
2693
2710
|
if (!shadowViewPool.isActive(identity) || shadowViewPool.submission(identity) === undefined)
|
|
2694
2711
|
return ok(undefined);
|
|
2695
|
-
return shadowViewPool.project(graph, identity);
|
|
2712
|
+
return shadowViewPool.project(graph, identity, forceCompute);
|
|
2696
2713
|
},
|
|
2697
2714
|
updateShadowViews: (views) =>
|
|
2698
2715
|
shadowFiltered.plan.batches.length > 0
|
|
@@ -3828,7 +3845,7 @@ export class GpuDrivenProduction {
|
|
|
3828
3845
|
cacheKey !== undefined &&
|
|
3829
3846
|
cached !== undefined &&
|
|
3830
3847
|
cached.cacheKey === cacheKey &&
|
|
3831
|
-
cached.
|
|
3848
|
+
sameValidatedSourceSequence(cached.validatedSources, validated) &&
|
|
3832
3849
|
cached.gpuOwnedDrawKeys === gpuOwnedDrawKeys &&
|
|
3833
3850
|
cached.worldKeys === worldKeys
|
|
3834
3851
|
) {
|
|
@@ -3914,7 +3931,7 @@ export class GpuDrivenProduction {
|
|
|
3914
3931
|
if (cacheKey !== undefined) {
|
|
3915
3932
|
this.cpuValidationTelemetryCache = {
|
|
3916
3933
|
cacheKey,
|
|
3917
|
-
validated,
|
|
3934
|
+
validatedSources: validated.map((row) => row.source),
|
|
3918
3935
|
gpuOwnedDrawKeys,
|
|
3919
3936
|
worldKeys,
|
|
3920
3937
|
gpuOwnedDrawCount: this.gpuOwnedDrawItems,
|
|
@@ -395,14 +395,39 @@ export class ShadowViewStatePool {
|
|
|
395
395
|
project<FrameCtx extends RenderGraphFrame>(
|
|
396
396
|
builder: RenderGraphBuilder<FrameCtx>,
|
|
397
397
|
identity: ShadowViewIdentity,
|
|
398
|
+
forceCompute = false,
|
|
398
399
|
): Result<ShadowViewProjection, RenderGraphError> {
|
|
399
400
|
const record = this.records.get(identityKey(identity));
|
|
400
401
|
if (record === undefined) return err(graphStateError(identity));
|
|
401
402
|
const cacheHit = record.cache === 'hit' && !record.invalidated;
|
|
403
|
+
// A graph replacement must carry the shadow compute passes even when the
|
|
404
|
+
// view itself is a cache hit. The graph owns the pass list, so omitting
|
|
405
|
+
// compute work while importing a replacement graph would leave its
|
|
406
|
+
// indirect arguments stale. The normal pool contract still skips work on
|
|
407
|
+
// a cache hit; graph projection opts in explicitly at that boundary.
|
|
408
|
+
const includeCompute = forceCompute || !cacheHit;
|
|
409
|
+
let forceComputePending = forceCompute;
|
|
410
|
+
let lastFrame: FrameCtx | undefined;
|
|
411
|
+
let executeForFrame = false;
|
|
412
|
+
const executeCompute = (frame: FrameCtx): boolean => {
|
|
413
|
+
if (frame !== lastFrame) {
|
|
414
|
+
lastFrame = frame;
|
|
415
|
+
const currentRecord = this.records.get(identityKey(record.identity));
|
|
416
|
+
executeForFrame =
|
|
417
|
+
forceComputePending ||
|
|
418
|
+
currentRecord === undefined ||
|
|
419
|
+
currentRecord.cache !== 'hit' ||
|
|
420
|
+
currentRecord.invalidated;
|
|
421
|
+
forceComputePending = false;
|
|
422
|
+
}
|
|
423
|
+
return executeForFrame;
|
|
424
|
+
};
|
|
402
425
|
const projected = record.view.addPasses(
|
|
403
426
|
builder,
|
|
404
427
|
shadowViewLabelPrefix(record.identity),
|
|
405
|
-
|
|
428
|
+
includeCompute,
|
|
429
|
+
undefined,
|
|
430
|
+
includeCompute ? executeCompute : undefined,
|
|
406
431
|
);
|
|
407
432
|
if (!projected.ok) return projected;
|
|
408
433
|
return ok({
|
|
@@ -411,7 +436,7 @@ export class ShadowViewStatePool {
|
|
|
411
436
|
generation: record.generation,
|
|
412
437
|
plan: record.plan,
|
|
413
438
|
view: record.view,
|
|
414
|
-
passNames:
|
|
439
|
+
passNames: includeCompute ? passNames(record.identity) : Object.freeze([]),
|
|
415
440
|
graphResources: projected.value,
|
|
416
441
|
});
|
|
417
442
|
}
|
|
@@ -982,6 +982,7 @@ export class GpuDrivenView {
|
|
|
982
982
|
labelPrefix = 'gpu-driven',
|
|
983
983
|
includeCompute = true,
|
|
984
984
|
surfaceSubmissionObservation?: () => SurfaceSubmissionCandidate | undefined,
|
|
985
|
+
executeIf?: (frame: FrameCtx) => boolean,
|
|
985
986
|
): Result<GpuDrivenViewGraphResources, RenderGraphError> {
|
|
986
987
|
const buffers = this.buffers;
|
|
987
988
|
const bindGroup = this.bindGroup;
|
|
@@ -1099,6 +1100,7 @@ export class GpuDrivenView {
|
|
|
1099
1100
|
{ resource: view.value, usage: 'uniform-read' },
|
|
1100
1101
|
{ resource: counters.value, usage: 'storage-read-write' },
|
|
1101
1102
|
],
|
|
1103
|
+
...(executeIf === undefined ? {} : { executeIf }),
|
|
1102
1104
|
encode: ({ pass }) => {
|
|
1103
1105
|
pass.setPipeline(this.resetPipeline);
|
|
1104
1106
|
pass.setBindGroup(0, bindGroup);
|
|
@@ -1116,6 +1118,7 @@ export class GpuDrivenView {
|
|
|
1116
1118
|
{ resource: counters.value, usage: 'storage-read-write' },
|
|
1117
1119
|
{ resource: visible.value, usage: 'storage-read-write' },
|
|
1118
1120
|
],
|
|
1121
|
+
...(executeIf === undefined ? {} : { executeIf }),
|
|
1119
1122
|
encode: ({ pass }) => {
|
|
1120
1123
|
pass.setPipeline(this.cullPipeline);
|
|
1121
1124
|
pass.setBindGroup(0, bindGroup);
|
|
@@ -1130,6 +1133,7 @@ export class GpuDrivenView {
|
|
|
1130
1133
|
{ resource: counters.value, usage: 'storage-read' },
|
|
1131
1134
|
{ resource: indirect.value, usage: 'storage-read-write' },
|
|
1132
1135
|
],
|
|
1136
|
+
...(executeIf === undefined ? {} : { executeIf }),
|
|
1133
1137
|
encode: ({ pass }) => {
|
|
1134
1138
|
pass.setPipeline(this.finalizePipeline);
|
|
1135
1139
|
pass.setBindGroup(0, bindGroup);
|
|
@@ -1148,6 +1152,7 @@ export class GpuDrivenView {
|
|
|
1148
1152
|
{ resource: indirect.value, usage: 'copy-src' },
|
|
1149
1153
|
{ resource: lodReadback.value, usage: 'copy-dst' },
|
|
1150
1154
|
],
|
|
1155
|
+
...(executeIf === undefined ? {} : { executeIf }),
|
|
1151
1156
|
encode: ({ encoder, resources }) => {
|
|
1152
1157
|
// A cached graph may run while an observer is awaiting mapAsync on the
|
|
1153
1158
|
// previous copy. WebGPU forbids submitting a copy into a
|
|
@@ -1031,12 +1031,13 @@ export async function runIblPrecompute(
|
|
|
1031
1031
|
pass.setVertexBuffer(0, opts.cubeVertexBuffer);
|
|
1032
1032
|
pass.draw(6, 1, face * 6, 0);
|
|
1033
1033
|
pass.end();
|
|
1034
|
+
|
|
1035
|
+
// Bound software-GPU work just as for irradiance; preserve every sample.
|
|
1036
|
+
const faceStage = await submitStage(`prefilter-mip-${mip}-face-${face}`, true);
|
|
1037
|
+
if (!faceStage.ok) return err(faceStage.error);
|
|
1034
1038
|
}
|
|
1035
1039
|
}
|
|
1036
1040
|
|
|
1037
|
-
const prefilterStage = await submitStage('prefilter', true);
|
|
1038
|
-
if (!prefilterStage.ok) return err(prefilterStage.error);
|
|
1039
|
-
|
|
1040
1041
|
// (d) brdf-lut: fullscreen triangle.
|
|
1041
1042
|
{
|
|
1042
1043
|
const pass: RhiRenderPassEncoder = encoder.beginRenderPass({
|
package/src/pbr-pipeline.ts
CHANGED
|
@@ -158,7 +158,10 @@ export function buildPbrMaterialUserRegionEntries(
|
|
|
158
158
|
let entries = derived.bglEntries.map(
|
|
159
159
|
(entry): GPUBindGroupLayoutEntry => ({
|
|
160
160
|
binding: entry.binding,
|
|
161
|
-
visibility:
|
|
161
|
+
visibility:
|
|
162
|
+
entry.texture !== undefined || entry.sampler !== undefined
|
|
163
|
+
? GPU_SHADER_STAGE_VERTEX | GPU_SHADER_STAGE_FRAGMENT
|
|
164
|
+
: entry.visibility,
|
|
162
165
|
...(entry.buffer === undefined ? {} : { buffer: entry.buffer }),
|
|
163
166
|
...(entry.sampler === undefined ? {} : { sampler: entry.sampler }),
|
|
164
167
|
...(entry.texture === undefined ? {} : { texture: entry.texture }),
|
|
@@ -120,3 +120,54 @@ it('publishes LOD GUID dependencies and rejects a missing lower-detail asset bef
|
|
|
120
120
|
candidate.discard();
|
|
121
121
|
publisher.dispose();
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
it('keeps two sealed publications and their feedback independent of later source mutation', async () => {
|
|
125
|
+
const world = new World();
|
|
126
|
+
const assets = new AssetRegistry(new ShaderRegistry({ manifestUrl: undefined }));
|
|
127
|
+
let value = 1;
|
|
128
|
+
const consumed: unknown[] = [];
|
|
129
|
+
const feature: RenderFeature<unknown> = {
|
|
130
|
+
identity: 'sealed-events',
|
|
131
|
+
extract: () => ok({ value }),
|
|
132
|
+
plan: () => ok({ resources: [], passes: [] }),
|
|
133
|
+
onSourceFrameSubmitted: (data) => consumed.push(data),
|
|
134
|
+
};
|
|
135
|
+
const publisher = createRenderPublisher(
|
|
136
|
+
world,
|
|
137
|
+
assets,
|
|
138
|
+
{ source: 'sealed', epoch: 1 },
|
|
139
|
+
undefined,
|
|
140
|
+
[feature],
|
|
141
|
+
);
|
|
142
|
+
const seal = () => {
|
|
143
|
+
const candidate = publisher.prepare(value).unwrap();
|
|
144
|
+
const packet = structuredClone(candidate.packet, {
|
|
145
|
+
transfer: renderPublicationTransfers(candidate.packet),
|
|
146
|
+
});
|
|
147
|
+
candidate.accept();
|
|
148
|
+
return packet;
|
|
149
|
+
};
|
|
150
|
+
try {
|
|
151
|
+
const first = seal();
|
|
152
|
+
value = 2;
|
|
153
|
+
const second = seal();
|
|
154
|
+
await Promise.resolve();
|
|
155
|
+
value = 99;
|
|
156
|
+
expect(second.features[0]?.data).toEqual({ value: 2 });
|
|
157
|
+
expect(publisher.prepare(3).ok).toBe(false);
|
|
158
|
+
expect(publisher.recycle(2, renderPublicationTransfers(second)).ok).toBe(false);
|
|
159
|
+
publisher.acknowledgeFeatures(1, [{ identity: feature.identity, feedback: null }]).unwrap();
|
|
160
|
+
publisher.recycle(1, renderPublicationTransfers(first)).unwrap();
|
|
161
|
+
expect(
|
|
162
|
+
publisher.acknowledgeFeatures(1, [{ identity: feature.identity, feedback: null }]).ok,
|
|
163
|
+
).toBe(false);
|
|
164
|
+
publisher.acknowledgeFeatures(2, [{ identity: feature.identity, feedback: null }]).unwrap();
|
|
165
|
+
expect(consumed).toEqual([{ value: 1 }, { value: 2 }]);
|
|
166
|
+
publisher.recycle(2, renderPublicationTransfers(second)).unwrap();
|
|
167
|
+
const third = seal();
|
|
168
|
+
expect(third.base).toBe(2);
|
|
169
|
+
expect(third.features[0]?.data).toEqual({ value: 99 });
|
|
170
|
+
} finally {
|
|
171
|
+
publisher.dispose();
|
|
172
|
+
}
|
|
173
|
+
});
|