Skip to content

Commit ae36451

Browse files
committed
fix: fall back to base splat runtime during export
1 parent 58f1b91 commit ae36451

2 files changed

Lines changed: 272 additions & 8 deletions

File tree

src/engine/render/RenderDispatcher.ts

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ import { buildSplatCamera, resolveOrbitCameraPose } from '../gaussian/core/Splat
3434
import { loadGaussianSplatAssetCached } from '../gaussian/loaders';
3535
import { DEFAULT_GAUSSIAN_SPLAT_SETTINGS } from '../gaussian/types';
3636
import { DEFAULT_SPLAT_EFFECTOR_SETTINGS } from '../../types/splatEffector';
37-
import { waitForBasePreparedSplatRuntime, waitForTargetPreparedSplatRuntime } from '../three/splatRuntimeCache';
37+
import {
38+
getPreparedSplatRuntimeSync,
39+
waitForBasePreparedSplatRuntime,
40+
waitForTargetPreparedSplatRuntime,
41+
} from '../three/splatRuntimeCache';
3842

3943
const log = Logger.create('RenderDispatcher');
4044
const GAUSSIAN_PLAYBACK_SORT_FREQUENCY = 6;
@@ -375,6 +379,101 @@ export class RenderDispatcher {
375379
this.exportReadyModelUrls.clear();
376380
}
377381

382+
private describeExportReadinessError(error: unknown): string {
383+
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
384+
}
385+
386+
private isRecoverableThreeSplatExportError(error: unknown): boolean {
387+
const message = this.describeExportReadinessError(error).toLowerCase();
388+
return message.includes('array buffer allocation failed') || message.includes('out of memory');
389+
}
390+
391+
private async ensureThreeSplatRuntimeReadyForExport(options: {
392+
cacheKey: string;
393+
fileHash?: string;
394+
file?: File;
395+
url?: string;
396+
fileName: string;
397+
gaussianSplatSequence?: NonNullable<Layer['source']>['gaussianSplatSequence'];
398+
preferBaseRuntime: boolean;
399+
requestedMaxSplats: number;
400+
}): Promise<void> {
401+
const {
402+
cacheKey,
403+
fileHash,
404+
file,
405+
url,
406+
fileName,
407+
gaussianSplatSequence,
408+
preferBaseRuntime,
409+
requestedMaxSplats,
410+
} = options;
411+
const variant = preferBaseRuntime ? 'base' : 'target';
412+
const threeSplatKey = `${cacheKey}|${variant}|${requestedMaxSplats}`;
413+
if (this.exportReadyThreeSplatKeys.has(threeSplatKey)) {
414+
return;
415+
}
416+
417+
const sourceOptions = {
418+
cacheKey,
419+
fileHash,
420+
file,
421+
url,
422+
fileName,
423+
gaussianSplatSequence,
424+
requestedMaxSplats,
425+
};
426+
427+
if (preferBaseRuntime) {
428+
await waitForBasePreparedSplatRuntime(sourceOptions);
429+
this.exportReadyThreeSplatKeys.add(threeSplatKey);
430+
return;
431+
}
432+
433+
try {
434+
await waitForTargetPreparedSplatRuntime(sourceOptions);
435+
this.exportReadyThreeSplatKeys.add(threeSplatKey);
436+
return;
437+
} catch (targetError) {
438+
const cachedBaseRuntime = getPreparedSplatRuntimeSync({
439+
...sourceOptions,
440+
variant: 'base',
441+
});
442+
if (cachedBaseRuntime) {
443+
log.warn('Precise export falling back to cached base Three.js splat runtime', {
444+
cacheKey,
445+
fileName,
446+
requestedMaxSplats,
447+
error: targetError,
448+
});
449+
this.exportReadyThreeSplatKeys.add(threeSplatKey);
450+
return;
451+
}
452+
453+
if (!this.isRecoverableThreeSplatExportError(targetError)) {
454+
throw targetError;
455+
}
456+
457+
try {
458+
await waitForBasePreparedSplatRuntime(sourceOptions);
459+
} catch (fallbackError) {
460+
throw new Error(
461+
`Three.js gaussian splat export fallback failed after target runtime error ` +
462+
`(${this.describeExportReadinessError(targetError)}). ` +
463+
`Fallback error: ${this.describeExportReadinessError(fallbackError)}`,
464+
);
465+
}
466+
467+
log.warn('Precise export falling back to rebuilt base Three.js splat runtime', {
468+
cacheKey,
469+
fileName,
470+
requestedMaxSplats,
471+
error: targetError,
472+
});
473+
this.exportReadyThreeSplatKeys.add(threeSplatKey);
474+
}
475+
}
476+
378477
async ensureExportLayersReady(layers: Layer[]): Promise<void> {
379478
const visibleLayers = this.collectVisibleExportLayers(layers);
380479
if (visibleLayers.length === 0) {
@@ -520,21 +619,16 @@ export class RenderDispatcher {
520619
preferBaseRuntime,
521620
requestedMaxSplats,
522621
}) => {
523-
const variant = preferBaseRuntime ? 'base' : 'target';
524-
const threeSplatKey = `${cacheKey}|${variant}|${requestedMaxSplats}`;
525-
if (this.exportReadyThreeSplatKeys.has(threeSplatKey)) {
526-
return;
527-
}
528-
await (preferBaseRuntime ? waitForBasePreparedSplatRuntime : waitForTargetPreparedSplatRuntime)({
622+
await this.ensureThreeSplatRuntimeReadyForExport({
529623
cacheKey,
530624
fileHash,
531625
file,
532626
url,
533627
fileName,
534628
gaussianSplatSequence,
629+
preferBaseRuntime,
535630
requestedMaxSplats,
536631
});
537-
this.exportReadyThreeSplatKeys.add(threeSplatKey);
538632
}),
539633
];
540634

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
getPreparedSplatRuntimeSync: vi.fn(),
5+
waitForBasePreparedSplatRuntime: vi.fn(),
6+
waitForTargetPreparedSplatRuntime: vi.fn(),
7+
loggerWarn: vi.fn(),
8+
}));
9+
10+
vi.mock('../../src/services/logger', () => ({
11+
Logger: {
12+
create: vi.fn(() => ({
13+
debug: vi.fn(),
14+
info: vi.fn(),
15+
warn: mocks.loggerWarn,
16+
error: vi.fn(),
17+
})),
18+
},
19+
}));
20+
21+
vi.mock('../../src/engine/three/splatRuntimeCache', () => ({
22+
getPreparedSplatRuntimeSync: mocks.getPreparedSplatRuntimeSync,
23+
waitForBasePreparedSplatRuntime: mocks.waitForBasePreparedSplatRuntime,
24+
waitForTargetPreparedSplatRuntime: mocks.waitForTargetPreparedSplatRuntime,
25+
}));
26+
27+
vi.mock('../../src/stores/renderTargetStore', () => ({
28+
useRenderTargetStore: {
29+
getState: () => ({}),
30+
},
31+
}));
32+
33+
vi.mock('../../src/stores/sliceStore', () => ({
34+
useSliceStore: {
35+
getState: () => ({}),
36+
},
37+
}));
38+
39+
vi.mock('../../src/stores/engineStore', () => ({
40+
useEngineStore: {
41+
getState: () => ({}),
42+
},
43+
}));
44+
45+
vi.mock('../../src/stores/timeline', () => ({
46+
useTimelineStore: {
47+
getState: () => ({
48+
playheadPosition: 0,
49+
clips: [],
50+
tracks: [],
51+
}),
52+
},
53+
}));
54+
55+
vi.mock('../../src/stores/mediaStore', () => ({
56+
DEFAULT_SCENE_CAMERA_SETTINGS: {
57+
fov: 50,
58+
near: 0.1,
59+
far: 1000,
60+
},
61+
useMediaStore: {
62+
getState: () => ({
63+
files: [],
64+
}),
65+
},
66+
}));
67+
68+
import { RenderDispatcher } from '../../src/engine/render/RenderDispatcher';
69+
70+
function createDispatcher(): RenderDispatcher {
71+
return new RenderDispatcher({
72+
getDevice: () => null,
73+
isRecovering: () => false,
74+
sampler: null,
75+
previewContext: null,
76+
targetCanvases: new Map(),
77+
compositorPipeline: null,
78+
outputPipeline: null,
79+
slicePipeline: null,
80+
textureManager: null,
81+
maskTextureManager: null,
82+
renderTargetManager: {
83+
getResolution: () => ({ width: 1920, height: 1080 }),
84+
},
85+
layerCollector: null,
86+
compositor: null,
87+
nestedCompRenderer: null,
88+
cacheManager: {} as any,
89+
exportCanvasManager: {} as any,
90+
performanceStats: {} as any,
91+
renderLoop: null,
92+
threeSceneRenderer: null,
93+
} as any);
94+
}
95+
96+
function createThreeSplatLayer() {
97+
return [
98+
{
99+
id: 'layer-splat',
100+
name: 'Hero Splat',
101+
visible: true,
102+
opacity: 1,
103+
is3D: true,
104+
sourceClipId: 'clip-splat',
105+
source: {
106+
type: 'gaussian-splat',
107+
gaussianSplatUrl: 'blob:hero-splat',
108+
gaussianSplatFileName: 'hero.ply',
109+
file: new File([new Uint8Array([1, 2, 3])], 'hero.ply', {
110+
type: 'application/octet-stream',
111+
}),
112+
gaussianSplatSettings: {
113+
render: {
114+
useNativeRenderer: false,
115+
maxSplats: 0,
116+
},
117+
},
118+
},
119+
},
120+
] as any;
121+
}
122+
123+
describe('RenderDispatcher.ensureExportLayersReady', () => {
124+
beforeEach(() => {
125+
vi.clearAllMocks();
126+
mocks.getPreparedSplatRuntimeSync.mockReturnValue(null);
127+
mocks.waitForBasePreparedSplatRuntime.mockResolvedValue({});
128+
mocks.waitForTargetPreparedSplatRuntime.mockResolvedValue({});
129+
});
130+
131+
it('falls back to a cached base three.js splat runtime when the full export runtime cannot be allocated', async () => {
132+
const dispatcher = createDispatcher();
133+
vi.spyOn(dispatcher, 'ensureThreeSceneRendererInitialized').mockResolvedValue(true);
134+
135+
mocks.waitForTargetPreparedSplatRuntime.mockRejectedValueOnce(new Error('Array buffer allocation failed'));
136+
mocks.getPreparedSplatRuntimeSync.mockReturnValueOnce({
137+
runtimeKey: 'cached-base-runtime',
138+
});
139+
140+
await expect(dispatcher.ensureExportLayersReady(createThreeSplatLayer())).resolves.toBeUndefined();
141+
142+
expect(mocks.waitForTargetPreparedSplatRuntime).toHaveBeenCalledTimes(1);
143+
expect(mocks.waitForBasePreparedSplatRuntime).not.toHaveBeenCalled();
144+
expect(mocks.loggerWarn).toHaveBeenCalledWith(
145+
'Precise export falling back to cached base Three.js splat runtime',
146+
expect.objectContaining({
147+
fileName: 'hero.ply',
148+
}),
149+
);
150+
});
151+
152+
it('rebuilds the base runtime once and caches export readiness after a recoverable target runtime failure', async () => {
153+
const dispatcher = createDispatcher();
154+
vi.spyOn(dispatcher, 'ensureThreeSceneRendererInitialized').mockResolvedValue(true);
155+
156+
mocks.waitForTargetPreparedSplatRuntime.mockRejectedValueOnce(new Error('Array buffer allocation failed'));
157+
158+
await expect(dispatcher.ensureExportLayersReady(createThreeSplatLayer())).resolves.toBeUndefined();
159+
await expect(dispatcher.ensureExportLayersReady(createThreeSplatLayer())).resolves.toBeUndefined();
160+
161+
expect(mocks.waitForTargetPreparedSplatRuntime).toHaveBeenCalledTimes(1);
162+
expect(mocks.waitForBasePreparedSplatRuntime).toHaveBeenCalledTimes(1);
163+
expect(mocks.loggerWarn).toHaveBeenCalledWith(
164+
'Precise export falling back to rebuilt base Three.js splat runtime',
165+
expect.objectContaining({
166+
fileName: 'hero.ply',
167+
}),
168+
);
169+
});
170+
});

0 commit comments

Comments
 (0)