@forgeax/engine-render 0.1.36 → 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 +11 -0
- package/dist/assembly/host-contract.d.ts +2 -2
- package/dist/assembly/host-contract.d.ts.map +1 -1
- package/dist/{chunk-OEYKKMXG.mjs → chunk-3F5XYSN3.mjs} +5 -5
- package/dist/{chunk-OEYKKMXG.mjs.map → chunk-3F5XYSN3.mjs.map} +1 -1
- package/dist/{chunk-BZQ4CDRF.mjs → chunk-3O2BBKEL.mjs} +3 -3
- package/dist/{chunk-BZQ4CDRF.mjs.map → chunk-3O2BBKEL.mjs.map} +1 -1
- package/dist/{chunk-2OZAS3WU.mjs → chunk-ARLAEJ45.mjs} +2 -2
- package/dist/{chunk-2OZAS3WU.mjs.map → chunk-ARLAEJ45.mjs.map} +1 -1
- package/dist/{chunk-D6WSBXJ2.mjs → chunk-E4NJYUJ2.mjs} +4 -4
- package/dist/{chunk-D6WSBXJ2.mjs.map → chunk-E4NJYUJ2.mjs.map} +1 -1
- package/dist/{chunk-DRF6OULX.mjs → chunk-N7NVXQWI.mjs} +3 -3
- package/dist/{chunk-DRF6OULX.mjs.map → chunk-N7NVXQWI.mjs.map} +1 -1
- package/dist/{chunk-OB5WAFYH.mjs → chunk-NMSOOXBB.mjs} +3 -3
- package/dist/{chunk-OB5WAFYH.mjs.map → chunk-NMSOOXBB.mjs.map} +1 -1
- package/dist/{chunk-CJOV2XLO.mjs → chunk-SJIPVWNB.mjs} +6 -6
- package/dist/chunk-SJIPVWNB.mjs.map +1 -0
- package/dist/construct-renderer.mjs +17 -9
- package/dist/construct-renderer.mjs.map +1 -1
- package/dist/gpu-driven/production-raster.d.ts.map +1 -1
- package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
- package/dist/index.mjs +7 -7
- package/dist/internal.mjs +5 -5
- package/dist/render-contract.d.ts +2 -2
- package/dist/render-contract.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/package.json +21 -21
- package/src/__tests__/gpu-driven-production.integration.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/assembly/host-contract.ts +2 -2
- package/src/gpu-driven/production-raster.ts +14 -3
- package/src/ibl/IblPipelineCache.ts +4 -3
- package/src/publication/__tests__/publication.integration.test.ts +14 -0
- package/src/render-contract.ts +2 -2
- package/src/render-system.ts +2 -2
- package/src/scene/render-scene.ts +4 -2
- package/dist/chunk-CJOV2XLO.mjs.map +0 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Result } from '@forgeax/engine-rhi';
|
|
2
|
+
import { createShaderModule, RhiNullAdapter, RhiNullDevice } from '@forgeax/engine-rhi-null';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { DeviceScope } from '../../device/device-scope';
|
|
5
|
+
import {
|
|
6
|
+
createFaceUniformsBuffer,
|
|
7
|
+
createPrefilterUniformsBuffer,
|
|
8
|
+
} from '../../device/gpu-residency';
|
|
9
|
+
import {
|
|
10
|
+
createIblPipelines,
|
|
11
|
+
getOrCreateIblCache,
|
|
12
|
+
type RunIblPrecomputeOptions,
|
|
13
|
+
runIblPrecompute,
|
|
14
|
+
} from '../../ibl/IblPipelineCache';
|
|
15
|
+
|
|
16
|
+
function unwrap<T, E>(result: Result<T, E>): T {
|
|
17
|
+
if (!result.ok) throw result.error;
|
|
18
|
+
return result.value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fixture() {
|
|
22
|
+
const device = unwrap(await new RhiNullAdapter().requestDevice());
|
|
23
|
+
if (!(device instanceof RhiNullDevice)) throw new Error('Expected null backend');
|
|
24
|
+
const scope = DeviceScope.create(1, 'ibl-submission-test');
|
|
25
|
+
unwrap(await createIblPipelines(scope, device, createShaderModule));
|
|
26
|
+
const texture = unwrap(
|
|
27
|
+
device.createTexture({
|
|
28
|
+
size: { width: 4, height: 4, depthOrArrayLayers: 6 },
|
|
29
|
+
format: 'rgba16float',
|
|
30
|
+
usage: 20,
|
|
31
|
+
mipLevelCount: 1,
|
|
32
|
+
sampleCount: 1,
|
|
33
|
+
dimension: '2d',
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
const view = unwrap(device.createTextureView(texture, { dimension: 'cube' }));
|
|
37
|
+
const options: RunIblPrecomputeOptions = {
|
|
38
|
+
scope,
|
|
39
|
+
device,
|
|
40
|
+
equirectGpuTex: texture,
|
|
41
|
+
equirectView: view,
|
|
42
|
+
cubeGpuTex: texture,
|
|
43
|
+
cubeView: view,
|
|
44
|
+
cubeFaceViews: Array.from({ length: 6 }, (_, face) =>
|
|
45
|
+
unwrap(
|
|
46
|
+
device.createTextureView(texture, {
|
|
47
|
+
dimension: '2d',
|
|
48
|
+
baseArrayLayer: face,
|
|
49
|
+
arrayLayerCount: 1,
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
faceUniformsBuffer: unwrap(createFaceUniformsBuffer(device)),
|
|
54
|
+
prefilterUniformsBuffer: unwrap(createPrefilterUniformsBuffer(device)),
|
|
55
|
+
cubeVertexBuffer: unwrap(
|
|
56
|
+
device.createBuffer({ size: 432, usage: 32, mappedAtCreation: false }),
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
return { ...options, cache: getOrCreateIblCache(scope), options };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
afterEach(() => vi.restoreAllMocks());
|
|
63
|
+
|
|
64
|
+
describe('IBL precompute completion boundaries', () => {
|
|
65
|
+
it('fences each prefilter face and publishes only after the final completion', async () => {
|
|
66
|
+
const { device, scope, cache, options } = await fixture();
|
|
67
|
+
const passes: string[][] = [];
|
|
68
|
+
let offset = 0;
|
|
69
|
+
const submit = device.queue.submit.bind(device.queue);
|
|
70
|
+
vi.spyOn(device.queue, 'submit').mockImplementation((buffers) => {
|
|
71
|
+
passes.push(device.framePassNames.slice(offset));
|
|
72
|
+
offset = device.framePassNames.length;
|
|
73
|
+
return submit(buffers);
|
|
74
|
+
});
|
|
75
|
+
let release!: () => void;
|
|
76
|
+
const lastFence = new Promise<void>((resolve) => {
|
|
77
|
+
release = resolve;
|
|
78
|
+
});
|
|
79
|
+
vi.spyOn(device.queue, 'onSubmittedWorkDone').mockImplementation(async () => {
|
|
80
|
+
expect(cache.prefilterTexture).toBeUndefined();
|
|
81
|
+
expect(cache.prefilterBakeCount).toBe(0);
|
|
82
|
+
if (passes.at(-1)?.includes('ibl-brdf-lut')) await lastFence;
|
|
83
|
+
});
|
|
84
|
+
const result = runIblPrecompute(options);
|
|
85
|
+
try {
|
|
86
|
+
await vi.waitFor(() => expect(passes.at(-1)).toEqual(['ibl-brdf-lut']));
|
|
87
|
+
expect(passes.filter((batch) => batch.includes('ibl-prefilter'))).toEqual(
|
|
88
|
+
Array.from({ length: 30 }, () => ['ibl-prefilter']),
|
|
89
|
+
);
|
|
90
|
+
expect(cache.prefilterTexture).toBeUndefined();
|
|
91
|
+
expect(device.totalDrawCount).toBe(43);
|
|
92
|
+
} finally {
|
|
93
|
+
release();
|
|
94
|
+
}
|
|
95
|
+
expect(await result).toEqual({ ok: true, value: { submitted: true } });
|
|
96
|
+
expect(cache.prefilterTexture).toBeDefined();
|
|
97
|
+
expect([cache.irradianceBakeCount, cache.prefilterBakeCount, cache.brdfLutBakeCount]).toEqual([
|
|
98
|
+
1, 1, 1,
|
|
99
|
+
]);
|
|
100
|
+
scope.dispose();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it.each([
|
|
104
|
+
'fence failure',
|
|
105
|
+
'scope retirement',
|
|
106
|
+
])('stops before the next prefilter face after %s', async (failure) => {
|
|
107
|
+
const { device, scope, cache, options } = await fixture();
|
|
108
|
+
vi.spyOn(device.queue, 'onSubmittedWorkDone').mockImplementation(async () => {
|
|
109
|
+
if (!device.framePassNames.includes('ibl-prefilter')) return;
|
|
110
|
+
if (failure === 'fence failure') throw new Error('device lost');
|
|
111
|
+
scope.abandon();
|
|
112
|
+
});
|
|
113
|
+
const result = await runIblPrecompute(options);
|
|
114
|
+
expect(result).toMatchObject({ ok: false, error: { code: 'ibl-precompute-not-dispatched' } });
|
|
115
|
+
expect(device.framePassNames.filter((name) => name === 'ibl-prefilter')).toHaveLength(1);
|
|
116
|
+
expect(device.framePassNames).not.toContain('ibl-brdf-lut');
|
|
117
|
+
expect(cache.prefilterTexture).toBeUndefined();
|
|
118
|
+
expect([cache.irradianceBakeCount, cache.prefilterBakeCount, cache.brdfLutBakeCount]).toEqual([
|
|
119
|
+
0, 0, 0,
|
|
120
|
+
]);
|
|
121
|
+
scope.dispose();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -67,22 +67,6 @@ describe('IBL residency publication', () => {
|
|
|
67
67
|
expect(candidateWrites).toBeLessThan(fence);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it('fences each IBL stage before recording the next stage', () => {
|
|
71
|
-
const runStart = iblSource.indexOf('export async function runIblPrecompute(');
|
|
72
|
-
const stages = ['equirect-to-cube', 'prefilter', 'brdf-lut'];
|
|
73
|
-
let previous = runStart;
|
|
74
|
-
for (const stage of stages) {
|
|
75
|
-
const marker = iblSource.indexOf(`await submitStage('${stage}'`, previous);
|
|
76
|
-
expect(marker).toBeGreaterThan(previous);
|
|
77
|
-
previous = marker;
|
|
78
|
-
}
|
|
79
|
-
expect(iblSource.match(/await submitStage\('/g)).toHaveLength(stages.length);
|
|
80
|
-
expect(iblSource).toContain('await device.queue.onSubmittedWorkDone()');
|
|
81
|
-
expect(
|
|
82
|
-
iblSource.indexOf('cache.irradianceTexture = promoted.irradianceTexture'),
|
|
83
|
-
).toBeGreaterThan(previous);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
70
|
it('does not promote or increment counters after a failed stage', () => {
|
|
87
71
|
const runStart = iblSource.indexOf('export async function runIblPrecompute(');
|
|
88
72
|
const promotion = iblSource.indexOf(
|
|
@@ -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
|
|
@@ -1405,7 +1405,7 @@ interface GpuDrivenResidencyValidationCache extends GpuDrivenResidencyValidation
|
|
|
1405
1405
|
|
|
1406
1406
|
interface GpuDrivenCpuValidationTelemetryCache {
|
|
1407
1407
|
readonly cacheKey: string;
|
|
1408
|
-
readonly
|
|
1408
|
+
readonly validatedSources: readonly RenderableSnapshot[];
|
|
1409
1409
|
readonly gpuOwnedDrawKeys: ReadonlySet<string>;
|
|
1410
1410
|
readonly worldKeys: readonly number[] | undefined;
|
|
1411
1411
|
readonly gpuOwnedDrawCount: number;
|
|
@@ -1421,6 +1421,17 @@ interface GpuDrivenCpuValidationTelemetryCache {
|
|
|
1421
1421
|
readonly blockedDrawItems: number;
|
|
1422
1422
|
}
|
|
1423
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
|
+
|
|
1424
1435
|
function sameIdentitySequence<T extends object>(
|
|
1425
1436
|
expected: readonly T[],
|
|
1426
1437
|
actual: readonly T[],
|
|
@@ -3834,7 +3845,7 @@ export class GpuDrivenProduction {
|
|
|
3834
3845
|
cacheKey !== undefined &&
|
|
3835
3846
|
cached !== undefined &&
|
|
3836
3847
|
cached.cacheKey === cacheKey &&
|
|
3837
|
-
cached.
|
|
3848
|
+
sameValidatedSourceSequence(cached.validatedSources, validated) &&
|
|
3838
3849
|
cached.gpuOwnedDrawKeys === gpuOwnedDrawKeys &&
|
|
3839
3850
|
cached.worldKeys === worldKeys
|
|
3840
3851
|
) {
|
|
@@ -3920,7 +3931,7 @@ export class GpuDrivenProduction {
|
|
|
3920
3931
|
if (cacheKey !== undefined) {
|
|
3921
3932
|
this.cpuValidationTelemetryCache = {
|
|
3922
3933
|
cacheKey,
|
|
3923
|
-
validated,
|
|
3934
|
+
validatedSources: validated.map((row) => row.source),
|
|
3924
3935
|
gpuOwnedDrawKeys,
|
|
3925
3936
|
worldKeys,
|
|
3926
3937
|
gpuOwnedDrawCount: this.gpuOwnedDrawItems,
|
|
@@ -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({
|
|
@@ -71,6 +71,20 @@ function fixture() {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
describe('native render publication', () => {
|
|
74
|
+
it('queries detached bounds by publication identity and rejects another epoch', () => {
|
|
75
|
+
const f = fixture();
|
|
76
|
+
const identity = { source: 'publication-test', epoch: 1 };
|
|
77
|
+
expect(f.scene.bounds(identity, f.first)).toBeUndefined();
|
|
78
|
+
f.publish();
|
|
79
|
+
const bounds = f.scene.bounds(identity, f.first);
|
|
80
|
+
expect(bounds).toBeDefined();
|
|
81
|
+
expect(f.scene.bounds({ ...identity, epoch: 2 }, f.first)).toBeUndefined();
|
|
82
|
+
expect(f.scene.bounds({ source: 'other', epoch: 1 }, f.first)).toBeUndefined();
|
|
83
|
+
if (bounds !== undefined) (bounds.min as number[])[0] = -999;
|
|
84
|
+
expect(f.scene.bounds(identity, f.first)?.min[0]).not.toBe(-999);
|
|
85
|
+
f.publisher.dispose();
|
|
86
|
+
});
|
|
87
|
+
|
|
74
88
|
it('publishes a numeric baseline, observes no static delta and retains the same CPU slots', () => {
|
|
75
89
|
const f = fixture();
|
|
76
90
|
const initial = f.publish();
|
package/src/render-contract.ts
CHANGED
|
@@ -478,8 +478,8 @@ export interface Renderer {
|
|
|
478
478
|
destroyRenderTarget(target: RenderTarget): RenderResult<void, RenderError>;
|
|
479
479
|
setProfile(profile: RenderProfile): RenderResult<void, RenderError>;
|
|
480
480
|
state(): RendererState;
|
|
481
|
-
/** Detached
|
|
482
|
-
bounds(world: World, entity: number): RenderSceneBounds | undefined;
|
|
481
|
+
/** Detached bounds from an extracted World or the bound publication source; undefined if unavailable. */
|
|
482
|
+
bounds(world: World | RenderPublicationIdentity, entity: number): RenderSceneBounds | undefined;
|
|
483
483
|
inspect(): RenderInspection;
|
|
484
484
|
observe(
|
|
485
485
|
receipt: FrameReceipt,
|
package/src/render-system.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { projectCaptureScene } from './capture/scene-projection';
|
|
2
2
|
import { renderMaterialContext } from './extract/material-context';
|
|
3
|
-
import { RenderPublicationError } from './publication/contract';
|
|
3
|
+
import { RenderPublicationError, type RenderPublicationIdentity } from './publication/contract';
|
|
4
4
|
import { preparePublicationGeometry } from './publication/prepare-geometry';
|
|
5
5
|
import type { PreparedRenderPublication } from './publication/receiver';
|
|
6
6
|
import { type RenderResourceScope, renderTime } from './publication/resource-scope';
|
|
@@ -357,7 +357,7 @@ export interface RecoveryProductionEvidence {
|
|
|
357
357
|
* whose shader identity is `forgeax::default-standard-pbr`).
|
|
358
358
|
*/
|
|
359
359
|
export interface RenderSystem {
|
|
360
|
-
bounds(world: World, entity: number): RenderSceneBounds | undefined;
|
|
360
|
+
bounds(world: World | RenderPublicationIdentity, entity: number): RenderSceneBounds | undefined;
|
|
361
361
|
/** Publish one renderer-owned read-only Surface dynamic page for the next frame. */
|
|
362
362
|
setSurfaceDynamicInput(frame: SurfaceDynamicInputFrame | undefined): void;
|
|
363
363
|
/** Complete explicit SSR capability preparation before drawing a device generation. */
|
|
@@ -29,6 +29,7 @@ import type { InstanceProjectionStore } from '../instances';
|
|
|
29
29
|
import { fingerprintNumericArray, InstanceBoundsCache } from '../instances-derived-bounds';
|
|
30
30
|
import type { PointsLinesInspection } from '../points-lines/inspection';
|
|
31
31
|
import type { PointsLinesRetainedSnapshot } from '../points-lines/snapshot';
|
|
32
|
+
import type { RenderPublicationIdentity } from '../publication/contract';
|
|
32
33
|
import type { PreparedRenderPublication } from '../publication/receiver';
|
|
33
34
|
import type { RenderResourceScope } from '../publication/resource-scope';
|
|
34
35
|
import { worldEntityKey } from '../record/frame-snapshot';
|
|
@@ -2568,9 +2569,10 @@ export class PersistentRenderScene {
|
|
|
2568
2569
|
}
|
|
2569
2570
|
|
|
2570
2571
|
/** Read existing culling bounds; never expose cached mutable arrays. */
|
|
2571
|
-
bounds(world: World, entity: number): RenderSceneBounds | undefined {
|
|
2572
|
+
bounds(world: World | RenderPublicationIdentity, entity: number): RenderSceneBounds | undefined {
|
|
2572
2573
|
const entry = this.composition;
|
|
2573
|
-
const
|
|
2574
|
+
const identity = 'source' in world ? `${world.source}:${world.epoch}` : world.identity;
|
|
2575
|
+
const worldId = entry?.worlds.findIndex((source) => source.identity === identity) ?? -1;
|
|
2574
2576
|
if (entry === undefined || worldId < 0) return undefined;
|
|
2575
2577
|
const slot = entry.projection.slot(worldId, entity);
|
|
2576
2578
|
if (slot === undefined) return undefined;
|