@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.
Files changed (42) hide show
  1. package/README.md +11 -0
  2. package/dist/assembly/host-contract.d.ts +2 -2
  3. package/dist/assembly/host-contract.d.ts.map +1 -1
  4. package/dist/{chunk-OEYKKMXG.mjs → chunk-3F5XYSN3.mjs} +5 -5
  5. package/dist/{chunk-OEYKKMXG.mjs.map → chunk-3F5XYSN3.mjs.map} +1 -1
  6. package/dist/{chunk-BZQ4CDRF.mjs → chunk-3O2BBKEL.mjs} +3 -3
  7. package/dist/{chunk-BZQ4CDRF.mjs.map → chunk-3O2BBKEL.mjs.map} +1 -1
  8. package/dist/{chunk-2OZAS3WU.mjs → chunk-ARLAEJ45.mjs} +2 -2
  9. package/dist/{chunk-2OZAS3WU.mjs.map → chunk-ARLAEJ45.mjs.map} +1 -1
  10. package/dist/{chunk-D6WSBXJ2.mjs → chunk-E4NJYUJ2.mjs} +4 -4
  11. package/dist/{chunk-D6WSBXJ2.mjs.map → chunk-E4NJYUJ2.mjs.map} +1 -1
  12. package/dist/{chunk-DRF6OULX.mjs → chunk-N7NVXQWI.mjs} +3 -3
  13. package/dist/{chunk-DRF6OULX.mjs.map → chunk-N7NVXQWI.mjs.map} +1 -1
  14. package/dist/{chunk-OB5WAFYH.mjs → chunk-NMSOOXBB.mjs} +3 -3
  15. package/dist/{chunk-OB5WAFYH.mjs.map → chunk-NMSOOXBB.mjs.map} +1 -1
  16. package/dist/{chunk-CJOV2XLO.mjs → chunk-SJIPVWNB.mjs} +6 -6
  17. package/dist/chunk-SJIPVWNB.mjs.map +1 -0
  18. package/dist/construct-renderer.mjs +17 -9
  19. package/dist/construct-renderer.mjs.map +1 -1
  20. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  21. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  22. package/dist/index.mjs +7 -7
  23. package/dist/internal.mjs +5 -5
  24. package/dist/render-contract.d.ts +2 -2
  25. package/dist/render-contract.d.ts.map +1 -1
  26. package/dist/render-system.d.ts +2 -1
  27. package/dist/render-system.d.ts.map +1 -1
  28. package/dist/scene/render-scene.d.ts +2 -1
  29. package/dist/scene/render-scene.d.ts.map +1 -1
  30. package/dist/temporal/index.mjs +4 -4
  31. package/package.json +21 -21
  32. package/src/__tests__/gpu-driven-production.integration.test.ts +23 -0
  33. package/src/__tests__/ibl/precompute-submission.unit.test.ts +123 -0
  34. package/src/__tests__/ibl-residency-publication.unit.test.ts +0 -16
  35. package/src/assembly/host-contract.ts +2 -2
  36. package/src/gpu-driven/production-raster.ts +14 -3
  37. package/src/ibl/IblPipelineCache.ts +4 -3
  38. package/src/publication/__tests__/publication.integration.test.ts +14 -0
  39. package/src/render-contract.ts +2 -2
  40. package/src/render-system.ts +2 -2
  41. package/src/scene/render-scene.ts +4 -2
  42. 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 validated: readonly { readonly source: RenderableSnapshot }[];
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.validated === validated &&
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();
@@ -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 world bounds for one record in the last extracted World; undefined if unavailable. */
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,
@@ -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 worldId = entry?.worlds.indexOf(world) ?? -1;
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;