Skip to content

Commit edbdcb6

Browse files
committed
fix: bound prepared splat runtime cache
1 parent ae36451 commit edbdcb6

2 files changed

Lines changed: 172 additions & 16 deletions

File tree

src/engine/three/splatRuntimeCache.ts

Lines changed: 116 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const RUNTIME_MAGIC = 0x53475254; // SGRT
1515
const RUNTIME_VERSION = 2;
1616
const HEADER_BYTE_LENGTH = 22 * 4;
1717
export const DEFAULT_SPLAT_BASE_LOD_MAX_SPLATS = 65536;
18+
export const DEFAULT_PREPARED_SPLAT_RUNTIME_CACHE_MAX_BYTES = 256 * 1024 * 1024;
1819

1920
export interface PreparedSplatRuntime {
2021
runtimeKey: string;
@@ -54,7 +55,10 @@ interface RuntimeRequestOptions extends RuntimeSourceOptions {
5455
const assetPromiseCache = new Map<string, Promise<GaussianSplatAsset>>();
5556
const runtimePromiseCache = new Map<string, Promise<PreparedSplatRuntime>>();
5657
const runtimeValueCache = new Map<string, PreparedSplatRuntime>();
58+
const runtimeValueSizeCache = new Map<string, number>();
5759
const idlePrewarmKeys = new Set<string>();
60+
let preparedRuntimeCacheMaxBytes = DEFAULT_PREPARED_SPLAT_RUNTIME_CACHE_MAX_BYTES;
61+
let preparedRuntimeCacheBytes = 0;
5862

5963
function normalizeRequestedMaxSplats(requestedMaxSplats: number | undefined): number {
6064
const normalized = Math.floor(requestedMaxSplats ?? 0);
@@ -119,6 +123,76 @@ function cloneViewBytes(view: Float32Array): ArrayBuffer {
119123
return bytes.buffer as ArrayBuffer;
120124
}
121125

126+
function getPreparedRuntimeByteLength(runtime: PreparedSplatRuntime): number {
127+
return runtime.centers.byteLength
128+
+ runtime.centerOpacityTextureData.byteLength
129+
+ runtime.colorTextureData.byteLength
130+
+ runtime.axisXTextureData.byteLength
131+
+ runtime.axisYTextureData.byteLength
132+
+ runtime.axisZTextureData.byteLength
133+
+ runtime.orderTemplateData.byteLength;
134+
}
135+
136+
function deletePreparedRuntime(runtimeKey: string): void {
137+
const size = runtimeValueSizeCache.get(runtimeKey);
138+
if (typeof size === 'number') {
139+
preparedRuntimeCacheBytes = Math.max(0, preparedRuntimeCacheBytes - size);
140+
runtimeValueSizeCache.delete(runtimeKey);
141+
}
142+
runtimeValueCache.delete(runtimeKey);
143+
}
144+
145+
function touchPreparedRuntime(runtimeKey: string): PreparedSplatRuntime | null {
146+
const runtime = runtimeValueCache.get(runtimeKey);
147+
if (!runtime) {
148+
return null;
149+
}
150+
151+
runtimeValueCache.delete(runtimeKey);
152+
runtimeValueCache.set(runtimeKey, runtime);
153+
return runtime;
154+
}
155+
156+
function trimPreparedRuntimeCache(): void {
157+
while (preparedRuntimeCacheBytes > preparedRuntimeCacheMaxBytes && runtimeValueCache.size > 0) {
158+
const oldestKey = runtimeValueCache.keys().next().value;
159+
if (oldestKey === undefined) {
160+
break;
161+
}
162+
deletePreparedRuntime(oldestKey);
163+
}
164+
}
165+
166+
function storePreparedRuntime(runtime: PreparedSplatRuntime): PreparedSplatRuntime {
167+
const size = getPreparedRuntimeByteLength(runtime);
168+
deletePreparedRuntime(runtime.runtimeKey);
169+
170+
if (size > preparedRuntimeCacheMaxBytes) {
171+
log.debug('Skipping prepared gaussian splat runtime cache for oversize entry', {
172+
runtimeKey: runtime.runtimeKey,
173+
size,
174+
preparedRuntimeCacheMaxBytes,
175+
});
176+
return runtime;
177+
}
178+
179+
runtimeValueCache.set(runtime.runtimeKey, runtime);
180+
runtimeValueSizeCache.set(runtime.runtimeKey, size);
181+
preparedRuntimeCacheBytes += size;
182+
trimPreparedRuntimeCache();
183+
return touchPreparedRuntime(runtime.runtimeKey) ?? runtime;
184+
}
185+
186+
function getRuntimeKeyForOptions(options: RuntimeRequestOptions): string {
187+
const requestedMaxSplats = normalizeRequestedMaxSplats(options.requestedMaxSplats);
188+
return buildRuntimeKey(
189+
options.cacheKey,
190+
options.variant,
191+
requestedMaxSplats,
192+
options.gaussianSplatSequence,
193+
);
194+
}
195+
122196
function gcd(a: number, b: number): number {
123197
let x = Math.abs(Math.floor(a));
124198
let y = Math.abs(Math.floor(b));
@@ -199,7 +273,11 @@ async function loadAsset(options: RuntimeSourceOptions): Promise<GaussianSplatAs
199273
})();
200274

201275
assetPromiseCache.set(options.cacheKey, promise);
202-
void promise.catch(() => {
276+
void promise.then(() => {
277+
if (assetPromiseCache.get(options.cacheKey) === promise) {
278+
assetPromiseCache.delete(options.cacheKey);
279+
}
280+
}, () => {
203281
if (assetPromiseCache.get(options.cacheKey) === promise) {
204282
assetPromiseCache.delete(options.cacheKey);
205283
}
@@ -504,8 +582,7 @@ async function loadRuntimeFromProjectCache(
504582
try {
505583
const buffer = await file.arrayBuffer();
506584
const runtime = deserializeRuntime(runtimeKey, buffer);
507-
runtimeValueCache.set(runtimeKey, runtime);
508-
return runtime;
585+
return storePreparedRuntime(runtime);
509586
} catch (error) {
510587
log.warn('Failed to read gaussian splat runtime cache file', {
511588
fileHash,
@@ -542,13 +619,8 @@ async function persistRuntimeToProjectCache(
542619

543620
async function ensurePreparedRuntime(options: RuntimeRequestOptions): Promise<PreparedSplatRuntime> {
544621
const requestedMaxSplats = normalizeRequestedMaxSplats(options.requestedMaxSplats);
545-
const runtimeKey = buildRuntimeKey(
546-
options.cacheKey,
547-
options.variant,
548-
requestedMaxSplats,
549-
options.gaussianSplatSequence,
550-
);
551-
const existing = runtimeValueCache.get(runtimeKey);
622+
const runtimeKey = getRuntimeKeyForOptions(options);
623+
const existing = touchPreparedRuntime(runtimeKey);
552624
if (existing) return existing;
553625

554626
const existingPromise = runtimePromiseCache.get(runtimeKey);
@@ -574,13 +646,17 @@ async function ensurePreparedRuntime(options: RuntimeRequestOptions): Promise<Pr
574646
requestedMaxSplats,
575647
normalizationBounds,
576648
);
577-
runtimeValueCache.set(runtimeKey, runtime);
649+
storePreparedRuntime(runtime);
578650
void persistRuntimeToProjectCache(options.fileHash, runtime);
579651
return runtime;
580652
})();
581653

582654
runtimePromiseCache.set(runtimeKey, promise);
583-
void promise.catch(() => {
655+
void promise.then(() => {
656+
if (runtimePromiseCache.get(runtimeKey) === promise) {
657+
runtimePromiseCache.delete(runtimeKey);
658+
}
659+
}, () => {
584660
if (runtimePromiseCache.get(runtimeKey) === promise) {
585661
runtimePromiseCache.delete(runtimeKey);
586662
}
@@ -591,10 +667,34 @@ async function ensurePreparedRuntime(options: RuntimeRequestOptions): Promise<Pr
591667
export function getPreparedSplatRuntimeSync(
592668
options: RuntimeRequestOptions,
593669
): PreparedSplatRuntime | null {
594-
const requestedMaxSplats = normalizeRequestedMaxSplats(options.requestedMaxSplats);
595-
return runtimeValueCache.get(
596-
buildRuntimeKey(options.cacheKey, options.variant, requestedMaxSplats, options.gaussianSplatSequence),
597-
) ?? null;
670+
return touchPreparedRuntime(getRuntimeKeyForOptions(options));
671+
}
672+
673+
export function clearPreparedSplatRuntimeCache(): void {
674+
assetPromiseCache.clear();
675+
runtimePromiseCache.clear();
676+
runtimeValueCache.clear();
677+
runtimeValueSizeCache.clear();
678+
idlePrewarmKeys.clear();
679+
preparedRuntimeCacheBytes = 0;
680+
}
681+
682+
export function setPreparedSplatRuntimeCacheMaxBytes(maxBytes: number): void {
683+
const normalized = Math.max(0, Math.floor(maxBytes));
684+
preparedRuntimeCacheMaxBytes = normalized;
685+
trimPreparedRuntimeCache();
686+
}
687+
688+
export function getPreparedSplatRuntimeCacheStats(): {
689+
runtimeCount: number;
690+
totalBytes: number;
691+
maxBytes: number;
692+
} {
693+
return {
694+
runtimeCount: runtimeValueCache.size,
695+
totalBytes: preparedRuntimeCacheBytes,
696+
maxBytes: preparedRuntimeCacheMaxBytes,
697+
};
598698
}
599699

600700
export async function resolvePreparedSplatRuntime(

tests/unit/splatRuntimeCache.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,60 @@ describe('splatRuntimeCache', () => {
168168
expect(runtime.centers[1]).toBeCloseTo(0);
169169
expect(runtime.centers[2]).toBeCloseTo(0);
170170
});
171+
172+
it('evicts prepared runtimes by byte budget and reloads assets after eviction', async () => {
173+
const frameA = new File([new Uint8Array([1])], 'frame0000000.ply', {
174+
type: 'application/octet-stream',
175+
});
176+
const frameB = new File([new Uint8Array([2])], 'frame0000001.ply', {
177+
type: 'application/octet-stream',
178+
});
179+
180+
loadGaussianSplatAssetMock.mockImplementation(async (file: File) => (
181+
createAsset(file, {
182+
center: file.name === frameA.name ? [1, 0, 0] : [2, 0, 0],
183+
})
184+
));
185+
186+
const {
187+
getPreparedSplatRuntimeSync,
188+
setPreparedSplatRuntimeCacheMaxBytes,
189+
waitForBasePreparedSplatRuntime,
190+
} = await import('../../src/engine/three/splatRuntimeCache');
191+
192+
setPreparedSplatRuntimeCacheMaxBytes(120);
193+
194+
await waitForBasePreparedSplatRuntime({
195+
cacheKey: 'Raw/frame0000000.ply',
196+
file: frameA,
197+
fileName: frameA.name,
198+
});
199+
expect(getPreparedSplatRuntimeSync({
200+
cacheKey: 'Raw/frame0000000.ply',
201+
file: frameA,
202+
fileName: frameA.name,
203+
variant: 'base',
204+
})).not.toBeNull();
205+
206+
await waitForBasePreparedSplatRuntime({
207+
cacheKey: 'Raw/frame0000001.ply',
208+
file: frameB,
209+
fileName: frameB.name,
210+
});
211+
212+
expect(getPreparedSplatRuntimeSync({
213+
cacheKey: 'Raw/frame0000000.ply',
214+
file: frameA,
215+
fileName: frameA.name,
216+
variant: 'base',
217+
})).toBeNull();
218+
219+
await waitForBasePreparedSplatRuntime({
220+
cacheKey: 'Raw/frame0000000.ply',
221+
file: frameA,
222+
fileName: frameA.name,
223+
});
224+
225+
expect(loadGaussianSplatAssetMock).toHaveBeenCalledTimes(3);
226+
});
171227
});

0 commit comments

Comments
 (0)