From 6d10cfeb06978b9bc80bfe28b110af6e4c08505d Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Thu, 19 Mar 2026 00:06:30 -0700 Subject: [PATCH 01/38] messy start on a data manager --- src/data_manager/DataManager.ts | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/data_manager/DataManager.ts diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts new file mode 100644 index 000000000..3575d3a7d --- /dev/null +++ b/src/data_manager/DataManager.ts @@ -0,0 +1,84 @@ +import { DataTexture } from "three"; + +import { PriorityQueue } from "./PriorityQueue.js"; + +export const enum ChunkPriorityClass { + RECENT = 0, + PREFETCH = 1, + VISIBLE = 2, + LENGTH, +} + +export type ChunkPriority = { + class: ChunkPriorityClass; + score: number; +}; + +/** Returns true iff `a > b` */ +const compareChunkPriority = (a: ChunkPriority, b: ChunkPriority) => + a.class !== b.class ? a.class > b.class : a.score > b.score; + +export const enum ChunkState { + QUEUED = "queued", + LOADING = "loading", + WORKER = "worker", + MEMORY = "memory", + DEVICE = "device", +} + +type ChunkData = + | { state: ChunkState.QUEUED | ChunkState.LOADING | ChunkState.WORKER } + | { state: ChunkState.MEMORY; memory: ArrayBuffer } + | { state: ChunkState.DEVICE; texture: DataTexture }; + +type ChunkEntry = ChunkData & { + subscriberPriorities: [number, ChunkPriority][]; + priority: ChunkPriority; +}; + +// export interface DataSubscriber {} + +export type ChunkId = { + sourceId: number; + multiscaleIndex: number; + tczyx: [number, number, number, number, number]; +}; + +const chunkIdToString = (id: ChunkId): string => `${id.sourceId}:${id.multiscaleIndex}:${id.tczyx.join(",")}`; + +const stringToChunkId = (id: string): ChunkId => { + const [sourceId, multiscaleIndex, tczyx] = id.split(":"); + const [t, c, z, y, x] = tczyx.split(","); + return { + sourceId: parseInt(sourceId), + multiscaleIndex: parseInt(multiscaleIndex), + tczyx: [parseInt(t), parseInt(c), parseInt(z), parseInt(y), parseInt(x)], + }; +}; + +export class DataManager { + chunks: Map; + loadQueue: PriorityQueue; + + constructor() { + this.chunks = new Map(); + this.loadQueue = new PriorityQueue(compareChunkPriority); + } + + requestChunk(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority): ArrayBuffer | DataTexture | undefined { + const chunkIdString = chunkIdToString(chunkId); + const chunkEntry = this.chunks.get(chunkIdString); + + if (chunkEntry === undefined) { + this.chunks.set(chunkIdString, { + state: ChunkState.QUEUED, + subscriberPriorities: [[subscriberId, priority]], + priority, + }); + this.loadQueue.insert(priority, chunkIdString); + return undefined; + } + + return undefined; + } +} From 226689b394767106a50f226f70e2aa75b6e56a6e Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Thu, 19 Mar 2026 00:54:42 -0700 Subject: [PATCH 02/38] move types into new `types` file --- src/data_manager/DataManager.ts | 83 ++++++++------------------------- src/data_manager/types.ts | 55 ++++++++++++++++++++++ 2 files changed, 74 insertions(+), 64 deletions(-) create mode 100644 src/data_manager/types.ts diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 3575d3a7d..40245d886 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -1,61 +1,7 @@ -import { DataTexture } from "three"; - +import type { ChunkId, ChunkPriority, ChunkEntry } from "./types.js"; +import { chunkIdToString, ChunkState, compareChunkPriority } from "./types.js"; import { PriorityQueue } from "./PriorityQueue.js"; -export const enum ChunkPriorityClass { - RECENT = 0, - PREFETCH = 1, - VISIBLE = 2, - LENGTH, -} - -export type ChunkPriority = { - class: ChunkPriorityClass; - score: number; -}; - -/** Returns true iff `a > b` */ -const compareChunkPriority = (a: ChunkPriority, b: ChunkPriority) => - a.class !== b.class ? a.class > b.class : a.score > b.score; - -export const enum ChunkState { - QUEUED = "queued", - LOADING = "loading", - WORKER = "worker", - MEMORY = "memory", - DEVICE = "device", -} - -type ChunkData = - | { state: ChunkState.QUEUED | ChunkState.LOADING | ChunkState.WORKER } - | { state: ChunkState.MEMORY; memory: ArrayBuffer } - | { state: ChunkState.DEVICE; texture: DataTexture }; - -type ChunkEntry = ChunkData & { - subscriberPriorities: [number, ChunkPriority][]; - priority: ChunkPriority; -}; - -// export interface DataSubscriber {} - -export type ChunkId = { - sourceId: number; - multiscaleIndex: number; - tczyx: [number, number, number, number, number]; -}; - -const chunkIdToString = (id: ChunkId): string => `${id.sourceId}:${id.multiscaleIndex}:${id.tczyx.join(",")}`; - -const stringToChunkId = (id: string): ChunkId => { - const [sourceId, multiscaleIndex, tczyx] = id.split(":"); - const [t, c, z, y, x] = tczyx.split(","); - return { - sourceId: parseInt(sourceId), - multiscaleIndex: parseInt(multiscaleIndex), - tczyx: [parseInt(t), parseInt(c), parseInt(z), parseInt(y), parseInt(x)], - }; -}; - export class DataManager { chunks: Map; loadQueue: PriorityQueue; @@ -65,20 +11,29 @@ export class DataManager { this.loadQueue = new PriorityQueue(compareChunkPriority); } - requestChunk(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority): ArrayBuffer | DataTexture | undefined { + requestChunk(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority) { const chunkIdString = chunkIdToString(chunkId); - const chunkEntry = this.chunks.get(chunkIdString); + let chunkEntry = this.chunks.get(chunkIdString); if (chunkEntry === undefined) { - this.chunks.set(chunkIdString, { + chunkEntry = { state: ChunkState.QUEUED, subscriberPriorities: [[subscriberId, priority]], priority, - }); - this.loadQueue.insert(priority, chunkIdString); - return undefined; + }; + this.chunks.set(chunkIdString, chunkEntry); + this.loadQueue.insert(chunkIdString, priority); + } else { + if (compareChunkPriority(priority, chunkEntry.priority)) { + chunkEntry.priority = priority; + } + + const subscriberPriorityIndex = chunkEntry.subscriberPriorities.findIndex(([id]) => id === subscriberId); + if (subscriberPriorityIndex === -1) { + chunkEntry.subscriberPriorities.push([subscriberId, priority]); + } else { + chunkEntry.subscriberPriorities[subscriberPriorityIndex][1] = priority; + } } - - return undefined; } } diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts new file mode 100644 index 000000000..dd8098690 --- /dev/null +++ b/src/data_manager/types.ts @@ -0,0 +1,55 @@ +import { DataTexture } from "three"; + +export const enum ChunkPriorityClass { + RECENT = 0, + PREFETCH = 1, + VISIBLE = 2, + LENGTH, +} + +export type ChunkPriority = { + class: ChunkPriorityClass; + score: number; +}; + +/** Returns true iff `a > b` */ +export const compareChunkPriority = (a: ChunkPriority, b: ChunkPriority) => + a.class !== b.class ? a.class > b.class : a.score > b.score; + +export const enum ChunkState { + QUEUED = "queued", + LOADING = "loading", + WORKER = "worker", + MEMORY = "memory", + DEVICE = "device", +} + +type ChunkData = + | { state: ChunkState.QUEUED | ChunkState.LOADING | ChunkState.WORKER } + | { state: ChunkState.MEMORY; memory: ArrayBuffer } + | { state: ChunkState.DEVICE; texture: DataTexture }; + +export type ChunkEntry = ChunkData & { + subscriberPriorities: [number, ChunkPriority][]; + priority: ChunkPriority; +}; + +// export interface DataSubscriber {} + +export type ChunkId = { + sourceId: number; + multiscaleIndex: number; + tczyx: [number, number, number, number, number]; +}; + +export const chunkIdToString = (id: ChunkId): string => `${id.sourceId}:${id.multiscaleIndex}:${id.tczyx.join(",")}`; + +export const stringToChunkId = (id: string): ChunkId => { + const [sourceId, multiscaleIndex, tczyx] = id.split(":"); + const [t, c, z, y, x] = tczyx.split(","); + return { + sourceId: parseInt(sourceId), + multiscaleIndex: parseInt(multiscaleIndex), + tczyx: [parseInt(t), parseInt(c), parseInt(z), parseInt(y), parseInt(x)], + }; +}; From 731b900938d6e59587666e7df8fa928b5072ba42 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Thu, 19 Mar 2026 10:30:25 -0700 Subject: [PATCH 03/38] more `DataManager` bones --- src/data_manager/DataManager.ts | 101 +++++++++++++++++++++++++++++--- src/data_manager/types.ts | 29 ++++++++- 2 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 40245d886..b070d5f17 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -1,14 +1,101 @@ -import type { ChunkId, ChunkPriority, ChunkEntry } from "./types.js"; -import { chunkIdToString, ChunkState, compareChunkPriority } from "./types.js"; +import type { TypedArray } from "three"; + +import type { ChunkId, ChunkPriority, ChunkEntry, DataManagerLimits } from "./types.js"; +import { + chunkIdToString, + ChunkState, + chunkPriorityGreater, + chunkPriorityLess, + MIN_CHUNK_PRIORITY, + validateDataManagerLimits, + DEFAULT_DATA_MANAGER_LIMITS, + ChunkPriorityClass, + stringToChunkId, +} from "./types.js"; import { PriorityQueue } from "./PriorityQueue.js"; +import { CreateLoaderOptions } from "../loaders/index.js"; + +type SourceId = number; + +interface SourceContext { + createSource: (path: string | string[], options?: Omit) => Promise; + requestChunk: (id: ChunkId) => void; + abortChunk: (id: ChunkId) => void; + onChunkLoad: (id: ChunkId, data: TypedArray) => void; +} + +type ChunkQueue = PriorityQueue; + +class ChunkQueues { + load: ChunkQueue = new PriorityQueue(chunkPriorityGreater); + evict: ChunkQueue = new PriorityQueue(chunkPriorityLess); + deviceLoad: ChunkQueue = new PriorityQueue(chunkPriorityGreater); + deviceEvict: ChunkQueue = new PriorityQueue(chunkPriorityLess); + + update(key: string, entry: ChunkEntry) { + const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { + return chunkPriorityGreater(priority, prevPriority) ? priority : prevPriority; + }, MIN_CHUNK_PRIORITY); + + if (nextPriority.class === entry.priority.class && nextPriority.score === entry.priority.score) { + return; + } + + entry.priority = nextPriority; + + switch (entry.state) { + case ChunkState.QUEUED: + case ChunkState.LOADING: + case ChunkState.WORKER: + case ChunkState.MEMORY: + case ChunkState.DEVICE: + } + } +} export class DataManager { - chunks: Map; - loadQueue: PriorityQueue; + private chunks: Map; + private loadQueue: PriorityQueue; + private requestCount: number; + private context: SourceContext; + + public limits: DataManagerLimits; - constructor() { + constructor(context: SourceContext, limits?: DataManagerLimits) { + this.limits = { + ...DEFAULT_DATA_MANAGER_LIMITS, + ...(limits ? validateDataManagerLimits(limits) : {}), + }; + this.context = context; this.chunks = new Map(); - this.loadQueue = new PriorityQueue(compareChunkPriority); + this.loadQueue = new PriorityQueue(chunkPriorityGreater); + this.requestCount = 0; + } + + private trySubmitOneRequest(): boolean { + const nextRequest = this.loadQueue.peek(); + if (nextRequest === undefined) { + return false; + } + + const [priority, key] = nextRequest; + const { concurrentRequests, concurrentPrefetches } = this.limits; + const requestLimit = + priority.class === ChunkPriorityClass.VISIBLE + ? concurrentRequests + : priority.class === ChunkPriorityClass.PREFETCH + ? concurrentPrefetches + : 0; + + if (this.requestCount >= requestLimit) { + return false; + } + + this.loadQueue.pop(); + this.context.requestChunk(stringToChunkId(key)); + this.requestCount++; + + return true; } requestChunk(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority) { @@ -24,7 +111,7 @@ export class DataManager { this.chunks.set(chunkIdString, chunkEntry); this.loadQueue.insert(chunkIdString, priority); } else { - if (compareChunkPriority(priority, chunkEntry.priority)) { + if (chunkPriorityGreater(priority, chunkEntry.priority)) { chunkEntry.priority = priority; } diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index dd8098690..dddf645c0 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -1,5 +1,27 @@ import { DataTexture } from "three"; +export type DataManagerLimits = { + size: number; + deviceSize: number; + concurrentRequests: number; + concurrentPrefetches: number; +}; + +const ONE_GIGABYTE = 1024 * 1024 * 1024; + +export const DEFAULT_DATA_MANAGER_LIMITS: DataManagerLimits = { + size: ONE_GIGABYTE, + deviceSize: ONE_GIGABYTE, + concurrentRequests: 10, + concurrentPrefetches: 4, +}; + +export const validateDataManagerLimits = (limits: DataManagerLimits): DataManagerLimits => ({ + ...limits, + deviceSize: Math.min(limits.size, limits.deviceSize), + concurrentPrefetches: Math.min(limits.concurrentRequests, limits.concurrentPrefetches), +}); + export const enum ChunkPriorityClass { RECENT = 0, PREFETCH = 1, @@ -12,9 +34,12 @@ export type ChunkPriority = { score: number; }; +export const MIN_CHUNK_PRIORITY: ChunkPriority = { class: ChunkPriorityClass.RECENT, score: 0 }; /** Returns true iff `a > b` */ -export const compareChunkPriority = (a: ChunkPriority, b: ChunkPriority) => +export const chunkPriorityGreater = (a: ChunkPriority, b: ChunkPriority) => a.class !== b.class ? a.class > b.class : a.score > b.score; +export const chunkPriorityLess = (a: ChunkPriority, b: ChunkPriority) => + a.class !== b.class ? a.class < b.class : a.score < b.score; export const enum ChunkState { QUEUED = "queued", @@ -34,8 +59,6 @@ export type ChunkEntry = ChunkData & { priority: ChunkPriority; }; -// export interface DataSubscriber {} - export type ChunkId = { sourceId: number; multiscaleIndex: number; From c133f640a81951e262034853c579c564fb05ec51 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sat, 21 Mar 2026 09:22:57 -0700 Subject: [PATCH 04/38] more work on `DataManager` --- src/data_manager/DataManager.ts | 120 ++++++++++++++++++-------------- src/data_manager/types.ts | 49 +++++++++---- 2 files changed, 102 insertions(+), 67 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index b070d5f17..465cf2a9d 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -1,6 +1,6 @@ import type { TypedArray } from "three"; -import type { ChunkId, ChunkPriority, ChunkEntry, DataManagerLimits } from "./types.js"; +import type { ChunkId, ChunkPriority, ChunkEntry, DataManagerLimits, LocalChunkId } from "./types.js"; import { chunkIdToString, ChunkState, @@ -9,19 +9,14 @@ import { MIN_CHUNK_PRIORITY, validateDataManagerLimits, DEFAULT_DATA_MANAGER_LIMITS, - ChunkPriorityClass, stringToChunkId, + limitForPriority, + ChunkPriorityLevel, } from "./types.js"; import { PriorityQueue } from "./PriorityQueue.js"; -import { CreateLoaderOptions } from "../loaders/index.js"; -type SourceId = number; - -interface SourceContext { - createSource: (path: string | string[], options?: Omit) => Promise; - requestChunk: (id: ChunkId) => void; - abortChunk: (id: ChunkId) => void; - onChunkLoad: (id: ChunkId, data: TypedArray) => void; +export interface IChunkSource { + getChunk(id: LocalChunkId, abortSignal?: AbortSignal): Promise; } type ChunkQueue = PriorityQueue; @@ -31,96 +26,113 @@ class ChunkQueues { evict: ChunkQueue = new PriorityQueue(chunkPriorityLess); deviceLoad: ChunkQueue = new PriorityQueue(chunkPriorityGreater); deviceEvict: ChunkQueue = new PriorityQueue(chunkPriorityLess); + recentCounter = 0; + /** Resolves a chunk's overall priority based on all requests for it, then updates its queue position. */ update(key: string, entry: ChunkEntry) { const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { return chunkPriorityGreater(priority, prevPriority) ? priority : prevPriority; }, MIN_CHUNK_PRIORITY); - if (nextPriority.class === entry.priority.class && nextPriority.score === entry.priority.score) { + if (nextPriority.level === ChunkPriorityLevel.RECENT) { + nextPriority.score = this.recentCounter; + this.recentCounter += 1; + } else if (nextPriority.level === entry.priority.level && nextPriority.score === entry.priority.score) { return; } entry.priority = nextPriority; - switch (entry.state) { + switch (entry.data.state) { case ChunkState.QUEUED: - case ChunkState.LOADING: - case ChunkState.WORKER: + this.load.insert(key, nextPriority); + break; case ChunkState.MEMORY: + this.deviceLoad.insert(key, nextPriority); + this.evict.insert(key, nextPriority); + break; case ChunkState.DEVICE: + this.deviceEvict.insert(key, nextPriority); + break; + case ChunkState.LOADING: + case ChunkState.WORKER: } } } export class DataManager { - private chunks: Map; - private loadQueue: PriorityQueue; - private requestCount: number; - private context: SourceContext; + private chunks = new Map(); + private queues = new ChunkQueues(); + private sources: IChunkSource[] = []; + private requests = new Map(); + private memorySize = 0; + private deviceSize = 0; public limits: DataManagerLimits; - constructor(context: SourceContext, limits?: DataManagerLimits) { - this.limits = { + constructor(limits?: Partial) { + this.limits = validateDataManagerLimits({ ...DEFAULT_DATA_MANAGER_LIMITS, - ...(limits ? validateDataManagerLimits(limits) : {}), - }; - this.context = context; - this.chunks = new Map(); - this.loadQueue = new PriorityQueue(chunkPriorityGreater); - this.requestCount = 0; + ...(limits ?? {}), + }); } - private trySubmitOneRequest(): boolean { - const nextRequest = this.loadQueue.peek(); - if (nextRequest === undefined) { - return false; - } + private updateDeviceData() {} - const [priority, key] = nextRequest; - const { concurrentRequests, concurrentPrefetches } = this.limits; - const requestLimit = - priority.class === ChunkPriorityClass.VISIBLE - ? concurrentRequests - : priority.class === ChunkPriorityClass.PREFETCH - ? concurrentPrefetches - : 0; - - if (this.requestCount >= requestLimit) { - return false; - } + private onChunkLoad(id: ChunkId, data: TypedArray) { + // If this just freed up our request quota, make sure to fill it back up + this.requests.delete(chunkIdToString(id)); + this.submitRequests(); + } - this.loadQueue.pop(); - this.context.requestChunk(stringToChunkId(key)); - this.requestCount++; + submitRequests() { + let nextRequest = this.queues.load.peek(); - return true; + while (nextRequest !== undefined && this.requests.size < limitForPriority(this.limits, nextRequest[0])) { + this.queues.load.pop(); + + const key = nextRequest[1]; + const id = stringToChunkId(key); + const source = this.sources[id.sourceId]; + const abortController = new AbortController(); + this.requests.set(key, abortController); + + source + .getChunk(id, abortController.signal) + .then((data) => this.onChunkLoad(id, data)) + .catch(() => { + this.chunks.delete(key); + this.requests.delete(key); + }); + + nextRequest = this.queues.load.peek(); + } } + /** + * + */ requestChunk(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority) { const chunkIdString = chunkIdToString(chunkId); let chunkEntry = this.chunks.get(chunkIdString); if (chunkEntry === undefined) { chunkEntry = { - state: ChunkState.QUEUED, + data: { state: ChunkState.QUEUED }, subscriberPriorities: [[subscriberId, priority]], - priority, + priority: MIN_CHUNK_PRIORITY, }; this.chunks.set(chunkIdString, chunkEntry); - this.loadQueue.insert(chunkIdString, priority); + this.queues.load.insert(chunkIdString, priority); } else { - if (chunkPriorityGreater(priority, chunkEntry.priority)) { - chunkEntry.priority = priority; - } - const subscriberPriorityIndex = chunkEntry.subscriberPriorities.findIndex(([id]) => id === subscriberId); if (subscriberPriorityIndex === -1) { chunkEntry.subscriberPriorities.push([subscriberId, priority]); } else { chunkEntry.subscriberPriorities[subscriberPriorityIndex][1] = priority; } + + this.queues.update(chunkIdString, chunkEntry); } } } diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index dddf645c0..858bd0ccb 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -1,4 +1,4 @@ -import { DataTexture } from "three"; +import type { DataTexture, TypedArray } from "three"; export type DataManagerLimits = { size: number; @@ -22,24 +22,44 @@ export const validateDataManagerLimits = (limits: DataManagerLimits): DataManage concurrentPrefetches: Math.min(limits.concurrentRequests, limits.concurrentPrefetches), }); -export const enum ChunkPriorityClass { +export const enum ChunkPriorityLevel { + /** + * Chunks that were recently loaded but which aren't currently needed. + * + * A chunk is automatically assigned a priority at this level when one or more subscribers request them (at the + * `VISIBLE` or `PREFETCH` level), their data is loaded, and then all requests are removed. Subscribers shouldn't use + * this level explicitly when making requests -- if the chunk isn't already cached, the request will be ignored! + */ RECENT = 0, + /** + * Chunks that aren't currently visible, but that are expected to become necessary in the future. + * + * Besides being loaded after `VISIBLE` chunks, chunks prioritized at this level may be subject to lower request + * quotas and/or memory limits, depending on the `DataManager`'s configuration. + */ PREFETCH = 1, + /** Chunks that are currently needed for rendering. */ VISIBLE = 2, - LENGTH, } export type ChunkPriority = { - class: ChunkPriorityClass; + level: ChunkPriorityLevel; score: number; }; -export const MIN_CHUNK_PRIORITY: ChunkPriority = { class: ChunkPriorityClass.RECENT, score: 0 }; +export const limitForPriority = (limits: DataManagerLimits, { level }: ChunkPriority) => + level === ChunkPriorityLevel.VISIBLE + ? limits.concurrentRequests + : level === ChunkPriorityLevel.PREFETCH + ? limits.concurrentPrefetches + : 0; + +export const MIN_CHUNK_PRIORITY: ChunkPriority = { level: ChunkPriorityLevel.RECENT, score: 0 }; /** Returns true iff `a > b` */ export const chunkPriorityGreater = (a: ChunkPriority, b: ChunkPriority) => - a.class !== b.class ? a.class > b.class : a.score > b.score; + a.level !== b.level ? a.level > b.level : a.score > b.score; export const chunkPriorityLess = (a: ChunkPriority, b: ChunkPriority) => - a.class !== b.class ? a.class < b.class : a.score < b.score; + a.level !== b.level ? a.level < b.level : a.score < b.score; export const enum ChunkState { QUEUED = "queued", @@ -49,22 +69,25 @@ export const enum ChunkState { DEVICE = "device", } -type ChunkData = - | { state: ChunkState.QUEUED | ChunkState.LOADING | ChunkState.WORKER } - | { state: ChunkState.MEMORY; memory: ArrayBuffer } +export type ChunkData = + | { state: ChunkState.QUEUED | ChunkState.WORKER } + | { state: ChunkState.LOADING; controller: AbortController } + | { state: ChunkState.MEMORY; memory: TypedArray } | { state: ChunkState.DEVICE; texture: DataTexture }; -export type ChunkEntry = ChunkData & { +export type ChunkEntry = { + data: ChunkData; subscriberPriorities: [number, ChunkPriority][]; priority: ChunkPriority; }; -export type ChunkId = { - sourceId: number; +export type LocalChunkId = { multiscaleIndex: number; tczyx: [number, number, number, number, number]; }; +export type ChunkId = LocalChunkId & { sourceId: number }; + export const chunkIdToString = (id: ChunkId): string => `${id.sourceId}:${id.multiscaleIndex}:${id.tczyx.join(",")}`; export const stringToChunkId = (id: string): ChunkId => { From 011a26e0617327f4f943d75b2d6c895eb6d053bf Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sat, 21 Mar 2026 13:04:12 -0700 Subject: [PATCH 05/38] more docs & utils for types module --- src/data_manager/types.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index 858bd0ccb..8a7950a22 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -3,6 +3,7 @@ import type { DataTexture, TypedArray } from "three"; export type DataManagerLimits = { size: number; deviceSize: number; + devicePrefetchSize: number; concurrentRequests: number; concurrentPrefetches: number; }; @@ -12,6 +13,7 @@ const ONE_GIGABYTE = 1024 * 1024 * 1024; export const DEFAULT_DATA_MANAGER_LIMITS: DataManagerLimits = { size: ONE_GIGABYTE, deviceSize: ONE_GIGABYTE, + devicePrefetchSize: ONE_GIGABYTE / 2, concurrentRequests: 10, concurrentPrefetches: 4, }; @@ -19,6 +21,7 @@ export const DEFAULT_DATA_MANAGER_LIMITS: DataManagerLimits = { export const validateDataManagerLimits = (limits: DataManagerLimits): DataManagerLimits => ({ ...limits, deviceSize: Math.min(limits.size, limits.deviceSize), + devicePrefetchSize: Math.min(limits.size, limits.deviceSize, limits.devicePrefetchSize), concurrentPrefetches: Math.min(limits.concurrentRequests, limits.concurrentPrefetches), }); @@ -47,26 +50,39 @@ export type ChunkPriority = { score: number; }; -export const limitForPriority = (limits: DataManagerLimits, { level }: ChunkPriority) => +export const requestLimitForPriority = (limits: DataManagerLimits, { level }: ChunkPriority): number => level === ChunkPriorityLevel.VISIBLE ? limits.concurrentRequests : level === ChunkPriorityLevel.PREFETCH ? limits.concurrentPrefetches + : 1; + +export const deviceSizeLimitForPriority = (limits: DataManagerLimits, { level }: ChunkPriority): number => + level === ChunkPriorityLevel.VISIBLE + ? limits.deviceSize + : level === ChunkPriorityLevel.PREFETCH + ? limits.devicePrefetchSize : 0; export const MIN_CHUNK_PRIORITY: ChunkPriority = { level: ChunkPriorityLevel.RECENT, score: 0 }; /** Returns true iff `a > b` */ export const chunkPriorityGreater = (a: ChunkPriority, b: ChunkPriority) => a.level !== b.level ? a.level > b.level : a.score > b.score; +/** Returns true iff `a < b` */ export const chunkPriorityLess = (a: ChunkPriority, b: ChunkPriority) => a.level !== b.level ? a.level < b.level : a.score < b.score; export const enum ChunkState { + /** Chunk is queued to be loaded. */ QUEUED = "queued", + /** Chunk is currently loading. */ LOADING = "loading", - WORKER = "worker", + /** Chunk is cached in memory. */ MEMORY = "memory", + /** Chunk is cached in memory *and* uploaded to the GPU. */ DEVICE = "device", + /** Chunk has been temporarily handed off to a worker. Currently unused. */ + WORKER = "worker", } export type ChunkData = From 1fa14c3a9694487cf276dc8b3b937d9496963ce4 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sat, 21 Mar 2026 13:37:45 -0700 Subject: [PATCH 06/38] most of the way to managing device uploads --- src/data_manager/DataManager.ts | 217 +++++++++++++++++++++++++------- src/data_manager/types.ts | 4 +- 2 files changed, 173 insertions(+), 48 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 465cf2a9d..fa75f90d7 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -1,4 +1,4 @@ -import type { TypedArray } from "three"; +import type { Data3DTexture, TypedArray } from "three"; import type { ChunkId, ChunkPriority, ChunkEntry, DataManagerLimits, LocalChunkId } from "./types.js"; import { @@ -10,15 +10,32 @@ import { validateDataManagerLimits, DEFAULT_DATA_MANAGER_LIMITS, stringToChunkId, - limitForPriority, + requestLimitForPriority, ChunkPriorityLevel, + deviceSizeLimitForPriority, } from "./types.js"; import { PriorityQueue } from "./PriorityQueue.js"; -export interface IChunkSource { - getChunk(id: LocalChunkId, abortSignal?: AbortSignal): Promise; +export interface ChunkSource { + getChunk(id: LocalChunkId, signal?: AbortSignal): Promise; } +export interface DataSubscriber { + dataSubscriberId?: number; +} + +const swapRemove = (arr: T[], index: number) => { + if (index < 0) { + return; + } + + const { length } = arr; + const replace = arr.pop(); + if (replace !== undefined && index < length) { + arr[index] = replace; + } +}; + type ChunkQueue = PriorityQueue; class ChunkQueues { @@ -26,47 +43,16 @@ class ChunkQueues { evict: ChunkQueue = new PriorityQueue(chunkPriorityLess); deviceLoad: ChunkQueue = new PriorityQueue(chunkPriorityGreater); deviceEvict: ChunkQueue = new PriorityQueue(chunkPriorityLess); - recentCounter = 0; - - /** Resolves a chunk's overall priority based on all requests for it, then updates its queue position. */ - update(key: string, entry: ChunkEntry) { - const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { - return chunkPriorityGreater(priority, prevPriority) ? priority : prevPriority; - }, MIN_CHUNK_PRIORITY); - - if (nextPriority.level === ChunkPriorityLevel.RECENT) { - nextPriority.score = this.recentCounter; - this.recentCounter += 1; - } else if (nextPriority.level === entry.priority.level && nextPriority.score === entry.priority.score) { - return; - } - - entry.priority = nextPriority; - - switch (entry.data.state) { - case ChunkState.QUEUED: - this.load.insert(key, nextPriority); - break; - case ChunkState.MEMORY: - this.deviceLoad.insert(key, nextPriority); - this.evict.insert(key, nextPriority); - break; - case ChunkState.DEVICE: - this.deviceEvict.insert(key, nextPriority); - break; - case ChunkState.LOADING: - case ChunkState.WORKER: - } - } } export class DataManager { private chunks = new Map(); private queues = new ChunkQueues(); - private sources: IChunkSource[] = []; + private sources: ChunkSource[] = []; private requests = new Map(); private memorySize = 0; private deviceSize = 0; + private recentCounter = 0; public limits: DataManagerLimits; @@ -77,21 +63,60 @@ export class DataManager { }); } - private updateDeviceData() {} + /** + * Inserts a chunk into the appropriate queue, or updates its queue position. + * + * Assumes that the chunk is not in any queues that don't match its state. + */ + private updateChunkInQueue(key: string, entry: ChunkEntry) { + switch (entry.data.state) { + case ChunkState.QUEUED: + this.queues.load.insert(key, entry.priority); + break; + case ChunkState.MEMORY: + this.queues.deviceLoad.insert(key, entry.priority); + this.queues.evict.insert(key, entry.priority); + break; + case ChunkState.DEVICE: + this.queues.deviceEvict.insert(key, entry.priority); + break; + case ChunkState.LOADING: + if (entry.priority.level === ChunkPriorityLevel.RECENT) { + // TODO cancel request + } + break; + case ChunkState.WORKER: + } + } - private onChunkLoad(id: ChunkId, data: TypedArray) { - // If this just freed up our request quota, make sure to fill it back up - this.requests.delete(chunkIdToString(id)); - this.submitRequests(); + /** Resolves a chunk's overall priority based on all requests for it, then updates its queue position. */ + private updateChunkPriority(key: string, entry: ChunkEntry) { + const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { + return chunkPriorityGreater(priority, prevPriority) ? priority : prevPriority; + }, MIN_CHUNK_PRIORITY); + + if (nextPriority.level === ChunkPriorityLevel.RECENT) { + nextPriority.score = this.recentCounter; + this.recentCounter += 1; + } + + if (nextPriority.level !== entry.priority.level || nextPriority.score !== entry.priority.score) { + this.updateChunkInQueue(key, entry); + } } submitRequests() { let nextRequest = this.queues.load.peek(); - while (nextRequest !== undefined && this.requests.size < limitForPriority(this.limits, nextRequest[0])) { + while (nextRequest !== undefined && this.requests.size < requestLimitForPriority(this.limits, nextRequest[0])) { this.queues.load.pop(); - const key = nextRequest[1]; + const [priority, key] = nextRequest; + if (priority.level === ChunkPriorityLevel.RECENT) { + // Ignore requests at the `RECENT` level. That's supposed to be for chunks that are already loaded! + continue; + } + const id = stringToChunkId(key); const source = this.sources[id.sourceId]; const abortController = new AbortController(); @@ -109,10 +134,108 @@ export class DataManager { } } + private updateDeviceData() { + // STEP 1: pull eligible chunks out of the `deviceLoad` queue + const loads: [string, ChunkEntry][] = []; + // Sorry eslint! This one's just easier if we loop unconditionally and break when done. + // eslint-disable-next-line no-constant-condition + while (true) { + const nextLoad = this.queues.deviceLoad.peek(); + if (nextLoad === undefined) { + // all out of queued device loads + break; + } + const [loadPriority, loadKey] = nextLoad; + + const loadEntry = this.chunks.get(loadKey); + // type safety requires these next two `if`s, but neither should be true unless this class has a bug + if (loadEntry === undefined) { + console.warn(`chunk ${loadKey} queued for device upload without data`); + this.queues.deviceLoad.pop(); + this.queues.evict.remove(loadKey); + continue; + } + if (loadEntry.data.state !== ChunkState.MEMORY) { + console.warn( + `chunk ${loadKey} queued for device upload in invalid state (expected "memory", found ${loadEntry.data.state})` + ); + this.queues.deviceLoad.pop(); + this.queues.evict.remove(loadKey); + this.updateChunkInQueue(loadKey, loadEntry); + continue; + } + + const chunkSize = loadEntry.data.memory.byteLength; + const nextEvictPriority = this.queues.deviceEvict.peek()?.[0] ?? MIN_CHUNK_PRIORITY; + if ( + // queue this chunk if there's space for it... + !(this.deviceSize + chunkSize < deviceSizeLimitForPriority(this.limits, loadPriority)) && + // ...or if its priority is greater than the lowest-priority chunk that's already on the GPU. + !(loadPriority.level !== ChunkPriorityLevel.RECENT && chunkPriorityGreater(loadPriority, nextEvictPriority)) + ) { + // otherwise, we're done + break; + } + + this.queues.deviceLoad.pop(); + this.queues.evict.remove(loadKey); + this.queues.deviceEvict.insert(loadKey, loadPriority); + this.deviceSize += chunkSize; + loads.push([loadKey, loadEntry]); + } + + // STEP 2: evict chunks that have been pushed off the GPU, keeping hold of their textures in case they're reusable + const freedTextures: Data3DTexture[] = []; + let nextEvict = this.queues.deviceEvict.peek(); + while (nextEvict !== undefined && this.deviceSize >= deviceSizeLimitForPriority(this.limits, nextEvict[0])) { + const [evictPriority, evictKey] = nextEvict; + this.queues.deviceEvict.pop(); + this.queues.deviceLoad.insert(evictKey, evictPriority); + this.queues.evict.insert(evictKey, evictPriority); + + const evictEntry = this.chunks.get(evictKey); + if (evictEntry !== undefined) { + if (evictEntry.data.state === ChunkState.DEVICE) { + // this chunk was previously on the GPU; demote to `MEMORY` and save its texture + const memory = evictEntry.data.texture.image.data; + this.deviceSize -= memory.byteLength; + freedTextures.push(evictEntry.data.texture); + evictEntry.data = { state: ChunkState.MEMORY, memory }; + } else if (evictEntry.data.state === ChunkState.MEMORY) { + // this chunk was promoted in the previous step; put it back + this.deviceSize -= evictEntry.data.memory.byteLength; + const index = loads.findIndex(([_, entry]) => entry === evictEntry); + swapRemove(loads, index); + } else { + console.warn( + `chunk ${evictKey} queued for device evict in invalid state (expected "device" or "memory", found ${evictEntry.data.state})` + ); + } + } else { + console.warn(`chunk ${evictKey} queued for device evict without data`); + } + + nextEvict = this.queues.deviceEvict.peek(); + } + + // STEP 3: now we can actually set up textures + for (const [loadKey, loadEntry] of loads) { + // TODO + } + + freedTextures.forEach((tex) => tex.dispose()); + } + + private onChunkLoad(id: ChunkId, data: TypedArray) { + // If this just freed up our request quota, make sure to fill it back up + this.requests.delete(chunkIdToString(id)); + this.submitRequests(); + } + /** * */ - requestChunk(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority) { + queueChunkRequest(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority) { const chunkIdString = chunkIdToString(chunkId); let chunkEntry = this.chunks.get(chunkIdString); @@ -132,7 +255,9 @@ export class DataManager { chunkEntry.subscriberPriorities[subscriberPriorityIndex][1] = priority; } - this.queues.update(chunkIdString, chunkEntry); + this.updateChunkPriority(chunkIdString, chunkEntry); } } + + removeChunkRequest(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority) {} } diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index 8a7950a22..336c78eb9 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -1,4 +1,4 @@ -import type { DataTexture, TypedArray } from "three"; +import { Data3DTexture, TypedArray } from "three"; export type DataManagerLimits = { size: number; @@ -89,7 +89,7 @@ export type ChunkData = | { state: ChunkState.QUEUED | ChunkState.WORKER } | { state: ChunkState.LOADING; controller: AbortController } | { state: ChunkState.MEMORY; memory: TypedArray } - | { state: ChunkState.DEVICE; texture: DataTexture }; + | { state: ChunkState.DEVICE; texture: Data3DTexture }; export type ChunkEntry = { data: ChunkData; From e5fadecfbcb5ffbffd49cbc2cd7743695174876c Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 13:13:25 -0700 Subject: [PATCH 07/38] polish off device upload and request submission --- src/data_manager/DataManager.ts | 106 +++++++++++++++++++++++++++----- src/data_manager/types.ts | 13 ++-- 2 files changed, 97 insertions(+), 22 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index fa75f90d7..497f6e227 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -1,4 +1,17 @@ -import type { Data3DTexture, TypedArray } from "three"; +import { + ByteType, + Data3DTexture, + FloatType, + IntType, + PixelFormatGPU, + RedFormat, + RedIntegerFormat, + ShortType, + TypedArray, + UnsignedByteType, + UnsignedIntType, + UnsignedShortType, +} from "three"; import type { ChunkId, ChunkPriority, ChunkEntry, DataManagerLimits, LocalChunkId } from "./types.js"; import { @@ -15,8 +28,11 @@ import { deviceSizeLimitForPriority, } from "./types.js"; import { PriorityQueue } from "./PriorityQueue.js"; +import { VolumeDims } from "../VolumeDims.js"; +import { NumberType } from "../types.js"; export interface ChunkSource { + getDims(): VolumeDims[]; getChunk(id: LocalChunkId, signal?: AbortSignal): Promise; } @@ -36,12 +52,27 @@ const swapRemove = (arr: T[], index: number) => { } }; +// TODO this should go in a utils module somewhere +const dataTypeToTextureProperties: { [ty in Exclude]: [number, number, PixelFormatGPU] } = { + int8: [ByteType, RedIntegerFormat, "R8I"], + int16: [ShortType, RedIntegerFormat, "R16I"], + int32: [IntType, RedIntegerFormat, "R32I"], + uint8: [UnsignedByteType, RedIntegerFormat, "R8UI"], + uint16: [UnsignedShortType, RedIntegerFormat, "R16UI"], + uint32: [UnsignedIntType, RedIntegerFormat, "R32UI"], + float32: [FloatType, RedFormat, "R32F"], +}; + type ChunkQueue = PriorityQueue; class ChunkQueues { + /** Chunks waiting to be loaded */ load: ChunkQueue = new PriorityQueue(chunkPriorityGreater); + /** Chunks in memory that may be evicted */ evict: ChunkQueue = new PriorityQueue(chunkPriorityLess); + /** Chunks in memory waiting to be uploaded to the GPU */ deviceLoad: ChunkQueue = new PriorityQueue(chunkPriorityGreater); + /** Chunks on the GPU that may be evicted */ deviceEvict: ChunkQueue = new PriorityQueue(chunkPriorityLess); } @@ -105,6 +136,13 @@ export class DataManager { } } + /** + * Submits queued chunk requests. + * + * Request queueing and submission happen in separate steps to allow all requests to be queued and sorted into + * priority order before submitting. If requests were submitted immediately upon being added to the queue, the first + * requests in a batch would submit in the order they were queued, not priority order. + */ submitRequests() { let nextRequest = this.queues.load.peek(); @@ -114,11 +152,18 @@ export class DataManager { const [priority, key] = nextRequest; if (priority.level === ChunkPriorityLevel.RECENT) { // Ignore requests at the `RECENT` level. That's supposed to be for chunks that are already loaded! + this.chunks.delete(key); + continue; + } + + const chunkEntry = this.chunks.get(key); + if (chunkEntry === undefined) { continue; } + chunkEntry.data = { state: ChunkState.LOADING }; const id = stringToChunkId(key); - const source = this.sources[id.sourceId]; + const source = this.sources[id.source]; const abortController = new AbortController(); this.requests.set(key, abortController); @@ -134,6 +179,7 @@ export class DataManager { } } + /** Resolves which chunks should be uploaded to the GPU, and which should be evicted. */ private updateDeviceData() { // STEP 1: pull eligible chunks out of the `deviceLoad` queue const loads: [string, ChunkEntry][] = []; @@ -150,13 +196,13 @@ export class DataManager { const loadEntry = this.chunks.get(loadKey); // type safety requires these next two `if`s, but neither should be true unless this class has a bug if (loadEntry === undefined) { - console.warn(`chunk ${loadKey} queued for device upload without data`); + console.error(`chunk ${loadKey} queued for device upload without data`); this.queues.deviceLoad.pop(); this.queues.evict.remove(loadKey); continue; } if (loadEntry.data.state !== ChunkState.MEMORY) { - console.warn( + console.error( `chunk ${loadKey} queued for device upload in invalid state (expected "memory", found ${loadEntry.data.state})` ); this.queues.deviceLoad.pop(); @@ -169,8 +215,9 @@ export class DataManager { const nextEvictPriority = this.queues.deviceEvict.peek()?.[0] ?? MIN_CHUNK_PRIORITY; if ( // queue this chunk if there's space for it... - !(this.deviceSize + chunkSize < deviceSizeLimitForPriority(this.limits, loadPriority)) && + !(this.deviceSize + chunkSize <= deviceSizeLimitForPriority(this.limits, loadPriority)) && // ...or if its priority is greater than the lowest-priority chunk that's already on the GPU. + // (this implies that at least one chunk will be evicted from the GPU in the next step) !(loadPriority.level !== ChunkPriorityLevel.RECENT && chunkPriorityGreater(loadPriority, nextEvictPriority)) ) { // otherwise, we're done @@ -184,8 +231,7 @@ export class DataManager { loads.push([loadKey, loadEntry]); } - // STEP 2: evict chunks that have been pushed off the GPU, keeping hold of their textures in case they're reusable - const freedTextures: Data3DTexture[] = []; + // STEP 2: evict chunks that have been pushed off the GPU let nextEvict = this.queues.deviceEvict.peek(); while (nextEvict !== undefined && this.deviceSize >= deviceSizeLimitForPriority(this.limits, nextEvict[0])) { const [evictPriority, evictKey] = nextEvict; @@ -196,10 +242,10 @@ export class DataManager { const evictEntry = this.chunks.get(evictKey); if (evictEntry !== undefined) { if (evictEntry.data.state === ChunkState.DEVICE) { - // this chunk was previously on the GPU; demote to `MEMORY` and save its texture + // this chunk was previously on the GPU; demote to `MEMORY` and destroy its texture const memory = evictEntry.data.texture.image.data; this.deviceSize -= memory.byteLength; - freedTextures.push(evictEntry.data.texture); + evictEntry.data.texture.dispose(); evictEntry.data = { state: ChunkState.MEMORY, memory }; } else if (evictEntry.data.state === ChunkState.MEMORY) { // this chunk was promoted in the previous step; put it back @@ -207,23 +253,51 @@ export class DataManager { const index = loads.findIndex(([_, entry]) => entry === evictEntry); swapRemove(loads, index); } else { - console.warn( + // again, if either of the following errors are ever printed, there's a bug somewhere in this file + console.error( `chunk ${evictKey} queued for device evict in invalid state (expected "device" or "memory", found ${evictEntry.data.state})` ); } } else { - console.warn(`chunk ${evictKey} queued for device evict without data`); + console.error(`chunk ${evictKey} queued for device evict without data`); } nextEvict = this.queues.deviceEvict.peek(); } - // STEP 3: now we can actually set up textures + // STEP 3: create textures for newly-promoted chunks for (const [loadKey, loadEntry] of loads) { - // TODO - } + const loadId = stringToChunkId(loadKey); + const source = this.sources[loadId.source]; + if (source === undefined) { + console.error(`chunk ${loadKey} queued for device upload with invalid source (id ${loadId.source})`); + this.queues.deviceEvict.remove(loadKey); + this.chunks.delete(loadKey); + continue; + } - freedTextures.forEach((tex) => tex.dispose()); + const multiscaleDims = source.getDims()[loadId.multiscale]; + if (multiscaleDims === undefined) { + console.error( + `chunk ${loadKey} queued for device upload with invalid multiscale index (source id ${loadId.source}, multiscale index ${loadId.multiscale})` + ); + this.queues.deviceEvict.remove(loadKey); + this.chunks.delete(loadKey); + continue; + } + const [_t, _c, sizeZ, sizeY, sizeX] = multiscaleDims.spacing; + const data = (loadEntry.data as { memory: TypedArray }).memory.buffer as ArrayBuffer; + const dtype = multiscaleDims.dataType; + const [texType, texFormat, texInternalFormat] = dataTypeToTextureProperties[dtype]; + + const texture = new Data3DTexture(data, sizeX, sizeY, sizeZ); + texture.type = texType; + texture.format = texFormat; + texture.internalFormat = texInternalFormat; + texture.needsUpdate = true; + + loadEntry.data = { state: ChunkState.DEVICE, texture }; + } } private onChunkLoad(id: ChunkId, data: TypedArray) { @@ -233,7 +307,9 @@ export class DataManager { } /** + * Queues a request for a chunk with the given `chunkId` at priority level `priority`. * + * Chunk requests are not guaranteed to submit until the next call to `submitRequests`. */ queueChunkRequest(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority) { const chunkIdString = chunkIdToString(chunkId); diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index 336c78eb9..2a5bf619a 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -86,8 +86,7 @@ export const enum ChunkState { } export type ChunkData = - | { state: ChunkState.QUEUED | ChunkState.WORKER } - | { state: ChunkState.LOADING; controller: AbortController } + | { state: ChunkState.QUEUED | ChunkState.WORKER | ChunkState.LOADING } | { state: ChunkState.MEMORY; memory: TypedArray } | { state: ChunkState.DEVICE; texture: Data3DTexture }; @@ -98,20 +97,20 @@ export type ChunkEntry = { }; export type LocalChunkId = { - multiscaleIndex: number; + multiscale: number; tczyx: [number, number, number, number, number]; }; -export type ChunkId = LocalChunkId & { sourceId: number }; +export type ChunkId = LocalChunkId & { source: number }; -export const chunkIdToString = (id: ChunkId): string => `${id.sourceId}:${id.multiscaleIndex}:${id.tczyx.join(",")}`; +export const chunkIdToString = (id: ChunkId): string => `${id.source}:${id.multiscale}:${id.tczyx.join(",")}`; export const stringToChunkId = (id: string): ChunkId => { const [sourceId, multiscaleIndex, tczyx] = id.split(":"); const [t, c, z, y, x] = tczyx.split(","); return { - sourceId: parseInt(sourceId), - multiscaleIndex: parseInt(multiscaleIndex), + source: parseInt(sourceId), + multiscale: parseInt(multiscaleIndex), tczyx: [parseInt(t), parseInt(c), parseInt(z), parseInt(y), parseInt(x)], }; }; From 680d6aa704dd7e94f98d2a0070ab7347fbaec892 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 17:50:37 -0700 Subject: [PATCH 08/38] add some docs --- src/data_manager/DataManager.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 497f6e227..5971d38bf 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -77,12 +77,21 @@ class ChunkQueues { } export class DataManager { + /** Data and current state for every chunk of data tracked and managed by this class. */ private chunks = new Map(); private queues = new ChunkQueues(); private sources: ChunkSource[] = []; + /** + * Information about each in-flight request. + * + * This could be stored in `chunks`, but requests don't necessarily correspond one-to-one with chunks. + */ private requests = new Map(); + /** The amount of chunk data currently cached in memory, in bytes */ private memorySize = 0; + /** The amount of GPU texture memory managed by this class, in bytes */ private deviceSize = 0; + /** A counter for assigning chunks a priority at the `RECENT` level. */ private recentCounter = 0; public limits: DataManagerLimits; From 17ff843ddab36814a6770f2cc21c0633c23e2c67 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 20:56:35 -0700 Subject: [PATCH 09/38] start on setting up data subscriber infrastructure --- src/data_manager/DataManager.ts | 159 ++++++++++++++++++++++++-------- src/data_manager/types.ts | 2 +- 2 files changed, 120 insertions(+), 41 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 5971d38bf..25444b2a2 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -31,13 +31,16 @@ import { PriorityQueue } from "./PriorityQueue.js"; import { VolumeDims } from "../VolumeDims.js"; import { NumberType } from "../types.js"; -export interface ChunkSource { +export interface IChunkSource { getDims(): VolumeDims[]; getChunk(id: LocalChunkId, signal?: AbortSignal): Promise; } -export interface DataSubscriber { - dataSubscriberId?: number; +export interface IDataSubscriber { + // eslint-disable-next-line @typescript-eslint/naming-convention + _subscriberId?: number; + onChunkLoaded?: (id: ChunkId) => void; + onChunkOnDevice?: (id: ChunkId) => void; } const swapRemove = (arr: T[], index: number) => { @@ -53,7 +56,7 @@ const swapRemove = (arr: T[], index: number) => { }; // TODO this should go in a utils module somewhere -const dataTypeToTextureProperties: { [ty in Exclude]: [number, number, PixelFormatGPU] } = { +const dataTypeToTextureProperties: { [T in Exclude]: [number, number, PixelFormatGPU] } = { int8: [ByteType, RedIntegerFormat, "R8I"], int16: [ShortType, RedIntegerFormat, "R16I"], int32: [IntType, RedIntegerFormat, "R32I"], @@ -63,6 +66,17 @@ const dataTypeToTextureProperties: { [ty in Exclude]: [nu float32: [FloatType, RedFormat, "R32F"], }; +const dataTypeToByteLength: { [T in NumberType]: number } = { + int8: 1, + int16: 2, + int32: 4, + uint8: 1, + uint16: 2, + uint32: 4, + float32: 4, + float64: 8, +}; + type ChunkQueue = PriorityQueue; class ChunkQueues { @@ -80,7 +94,7 @@ export class DataManager { /** Data and current state for every chunk of data tracked and managed by this class. */ private chunks = new Map(); private queues = new ChunkQueues(); - private sources: ChunkSource[] = []; + private sources: IChunkSource[] = []; /** * Information about each in-flight request. * @@ -93,6 +107,8 @@ export class DataManager { private deviceSize = 0; /** A counter for assigning chunks a priority at the `RECENT` level. */ private recentCounter = 0; + /** A counter for assigning subscriber IDs. */ + private subscriberCount = 0; public limits: DataManagerLimits; @@ -103,6 +119,15 @@ export class DataManager { }); } + /** Gets the id of data subscriber `subscriber`, assigning it one if it doesn't have one. */ + private getIdForSubscriber(subscriber: IDataSubscriber): number { + if (subscriber._subscriberId === undefined) { + subscriber._subscriberId = this.subscriberCount; + this.subscriberCount += 1; + } + return subscriber._subscriberId; + } + /** * Inserts a chunk into the appropriate queue, or updates its queue position. * @@ -122,7 +147,7 @@ export class DataManager { break; case ChunkState.LOADING: if (entry.priority.level === ChunkPriorityLevel.RECENT) { - // TODO cancel request + // TODO cancel request here } break; case ChunkState.WORKER: @@ -145,6 +170,34 @@ export class DataManager { } } + private getChunkSpatialDims(chunkId: ChunkId): { x: number; y: number; z: number; dataType: NumberType } | undefined { + const source = this.sources[chunkId.source]; + if (source === undefined) { + return undefined; + } + + const multiscale = source.getDims()[chunkId.multiscale]; + if (multiscale === undefined) { + return undefined; + } + + // TODO fix config so we don't need this + // see @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars + // eslint-disable-next-line + const [_t, _c, z, y, x] = multiscale.spacing; + const { dataType } = multiscale; + return { x, y, z, dataType }; + } + + private estimateChunkSize(chunkId: ChunkId): number { + const dims = this.getChunkSpatialDims(chunkId); + if (dims === undefined) { + return 0; + } + const { x, y, z, dataType } = dims; + return x * y * z * dataTypeToByteLength[dataType]; + } + /** * Submits queued chunk requests. * @@ -153,38 +206,53 @@ export class DataManager { * requests in a batch would submit in the order they were queued, not priority order. */ submitRequests() { - let nextRequest = this.queues.load.peek(); - - while (nextRequest !== undefined && this.requests.size < requestLimitForPriority(this.limits, nextRequest[0])) { + const nextRequest = this.queues.load.peek(); + if (nextRequest === undefined) { + return; + } + let [requestPriority, requestKey] = nextRequest; + let requestId = stringToChunkId(requestKey); + const nextEvictPriority = this.queues.evict.peek()?.[0] ?? MIN_CHUNK_PRIORITY; + + while ( + // keep submitting requests while concurrency is available and... + this.requests.size < requestLimitForPriority(this.limits, requestPriority) && + // ...either space will be available or this chunk has higher priority than an already cached chunk + (this.memorySize + this.estimateChunkSize(requestId) < this.limits.size || + chunkPriorityGreater(requestPriority, nextEvictPriority)) + ) { this.queues.load.pop(); - const [priority, key] = nextRequest; - if (priority.level === ChunkPriorityLevel.RECENT) { + if (requestPriority.level === ChunkPriorityLevel.RECENT) { // Ignore requests at the `RECENT` level. That's supposed to be for chunks that are already loaded! - this.chunks.delete(key); + this.chunks.delete(requestKey); continue; } - const chunkEntry = this.chunks.get(key); + const chunkEntry = this.chunks.get(requestKey); if (chunkEntry === undefined) { continue; } chunkEntry.data = { state: ChunkState.LOADING }; - const id = stringToChunkId(key); - const source = this.sources[id.source]; + const source = this.sources[requestId.source]; const abortController = new AbortController(); - this.requests.set(key, abortController); + this.requests.set(requestKey, abortController); source - .getChunk(id, abortController.signal) - .then((data) => this.onChunkLoad(id, data)) + .getChunk(requestId, abortController.signal) + .then((data) => this.onChunkLoad(requestId, data)) .catch(() => { - this.chunks.delete(key); - this.requests.delete(key); + this.chunks.delete(requestKey); + this.requests.delete(requestKey); }); - nextRequest = this.queues.load.peek(); + const nextRequest = this.queues.load.peek(); + if (nextRequest === undefined) { + return; + } + [requestPriority, requestKey] = nextRequest; + requestId = stringToChunkId(requestKey); } } @@ -277,29 +345,22 @@ export class DataManager { // STEP 3: create textures for newly-promoted chunks for (const [loadKey, loadEntry] of loads) { const loadId = stringToChunkId(loadKey); - const source = this.sources[loadId.source]; - if (source === undefined) { - console.error(`chunk ${loadKey} queued for device upload with invalid source (id ${loadId.source})`); - this.queues.deviceEvict.remove(loadKey); - this.chunks.delete(loadKey); - continue; - } + const dims = this.getChunkSpatialDims(loadId); - const multiscaleDims = source.getDims()[loadId.multiscale]; - if (multiscaleDims === undefined) { + if (dims === undefined) { console.error( - `chunk ${loadKey} queued for device upload with invalid multiscale index (source id ${loadId.source}, multiscale index ${loadId.multiscale})` + `chunk ${loadKey} queued for device upload with invalid source id or multiscale index (id ${loadId.source}, multiscale index ${loadId.multiscale})` ); this.queues.deviceEvict.remove(loadKey); this.chunks.delete(loadKey); continue; } - const [_t, _c, sizeZ, sizeY, sizeX] = multiscaleDims.spacing; + + const { x, y, z, dataType } = dims; + const [texType, texFormat, texInternalFormat] = dataTypeToTextureProperties[dataType]; const data = (loadEntry.data as { memory: TypedArray }).memory.buffer as ArrayBuffer; - const dtype = multiscaleDims.dataType; - const [texType, texFormat, texInternalFormat] = dataTypeToTextureProperties[dtype]; - const texture = new Data3DTexture(data, sizeX, sizeY, sizeZ); + const texture = new Data3DTexture(data, x, y, z); texture.type = texType; texture.format = texFormat; texture.internalFormat = texInternalFormat; @@ -316,11 +377,13 @@ export class DataManager { } /** - * Queues a request for a chunk with the given `chunkId` at priority level `priority`. + * Declares that subscriber `subscriber` requires chunk `chunkId` at priority level `priority`. * - * Chunk requests are not guaranteed to submit until the next call to `submitRequests`. + * If the chunk is not already in memory, this will queue the chunk to be loaded. Chunk load requests are not + * guaranteed to submit until the next call to `submitRequests`. */ - queueChunkRequest(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority) { + queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority) { + const subscriberId = this.getIdForSubscriber(subscriber); const chunkIdString = chunkIdToString(chunkId); let chunkEntry = this.chunks.get(chunkIdString); @@ -328,7 +391,7 @@ export class DataManager { chunkEntry = { data: { state: ChunkState.QUEUED }, subscriberPriorities: [[subscriberId, priority]], - priority: MIN_CHUNK_PRIORITY, + priority: priority, }; this.chunks.set(chunkIdString, chunkEntry); this.queues.load.insert(chunkIdString, priority); @@ -344,5 +407,21 @@ export class DataManager { } } - removeChunkRequest(subscriberId: number, chunkId: ChunkId, priority: ChunkPriority) {} + /** Declares that subscriber `subscriber` no longer requires chunk `chunkId`. */ + removeChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId) { + const subscriberId = this.getIdForSubscriber(subscriber); + const chunkKey = chunkIdToString(chunkId); + const chunkEntry = this.chunks.get(chunkKey); + if (chunkEntry === undefined) { + return; + } + + const index = chunkEntry.subscriberPriorities.findIndex(([id]) => id === subscriberId); + if (index < 0) { + return; + } + + swapRemove(chunkEntry.subscriberPriorities, index); + this.updateChunkPriority(chunkKey, chunkEntry); + } } diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index 2a5bf619a..a697b4599 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -81,7 +81,7 @@ export const enum ChunkState { MEMORY = "memory", /** Chunk is cached in memory *and* uploaded to the GPU. */ DEVICE = "device", - /** Chunk has been temporarily handed off to a worker. Currently unused. */ + /** Chunk has been temporarily handed off to a worker. Unused, for now. */ WORKER = "worker", } From 2deb251b52ffe20d62c2cd0141f53e0e5d351857 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 21:44:51 -0700 Subject: [PATCH 10/38] at last, flesh out `onChunkLoad` --- src/data_manager/DataManager.ts | 91 ++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 8 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 25444b2a2..e8467b9b8 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -166,6 +166,7 @@ export class DataManager { } if (nextPriority.level !== entry.priority.level || nextPriority.score !== entry.priority.score) { + entry.priority = { ...nextPriority }; this.updateChunkInQueue(key, entry); } } @@ -370,9 +371,83 @@ export class DataManager { } } - private onChunkLoad(id: ChunkId, data: TypedArray) { + /** Evicts cached data to stay under the size limit. */ + private evictCacheData() { + while (this.memorySize > this.limits.size) { + const nextEvict = this.queues.evict.pop(); + if (nextEvict === undefined) { + // should never get here (checked above that the chunk isn't larger than the entire cache) + break; + } + const evictKey = nextEvict[1]; + + this.queues.deviceLoad.remove(evictKey); + const evictEntry = this.chunks.get(evictKey); + // yup... below `if` makes the type system happy, but if they ever evaluate to `true` that's a bug in this class. + if (evictEntry === undefined) { + console.error(`chunk ${evictKey} queued for eviction without data`); + continue; + } + + switch (evictEntry.data.state) { + case ChunkState.MEMORY: + // If we find any state other than this one, it's a bug! + this.memorySize -= evictEntry.data.memory.byteLength; + break; + case ChunkState.DEVICE: + console.error(`chunk ${evictKey} queued for eviction while in the "device" state`); + this.deviceSize -= evictEntry.data.texture.image.data.byteLength; + this.memorySize -= evictEntry.data.texture.image.data.byteLength; + evictEntry.data.texture.dispose(); + this.queues.deviceEvict.remove(evictKey); + // TODO if subscribers get "chunk removed from GPU" events, one should go here + break; + case ChunkState.LOADING: + console.error(`chunk ${evictKey} queued for eviction while in the "loading" state`); + // TODO cancel request + break; + default: + // TODO `WORKER` state may be weird to manage here + console.error( + `chunk ${evictKey} queued for eviction in an invalid state (expected "memory", found ${evictEntry.data.state})` + ); + } + + this.chunks.delete(evictKey); + } + } + + private onChunkLoad(id: ChunkId, memory: TypedArray) { + const key = chunkIdToString(id); + this.requests.delete(key); + if (memory.byteLength > this.limits.size) { + console.error(`received chunk ${key} which is larger than the cache limit`); + return; + } + const data = { state: ChunkState.MEMORY as const, memory }; + + let chunkEntry = this.chunks.get(key); + if (chunkEntry === undefined) { + console.error(`received chunk ${key} without data manager entry`); + chunkEntry = { + data, + subscriberPriorities: [], + priority: { level: ChunkPriorityLevel.RECENT, score: this.recentCounter }, + }; + this.chunks.set(key, chunkEntry); + this.recentCounter += 1; + } else { + chunkEntry.data = data; + } + + this.queues.deviceLoad.insert(key, chunkEntry.priority); + this.queues.evict.insert(key, chunkEntry.priority); + + this.memorySize += memory.byteLength; + + this.updateDeviceData(); + this.evictCacheData(); // If this just freed up our request quota, make sure to fill it back up - this.requests.delete(chunkIdToString(id)); this.submitRequests(); } @@ -380,20 +455,20 @@ export class DataManager { * Declares that subscriber `subscriber` requires chunk `chunkId` at priority level `priority`. * * If the chunk is not already in memory, this will queue the chunk to be loaded. Chunk load requests are not - * guaranteed to submit until the next call to `submitRequests`. + * submitted until the next call to `submitRequests`. */ queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority) { const subscriberId = this.getIdForSubscriber(subscriber); const chunkIdString = chunkIdToString(chunkId); - let chunkEntry = this.chunks.get(chunkIdString); + const chunkEntry = this.chunks.get(chunkIdString); if (chunkEntry === undefined) { - chunkEntry = { + const newChunkEntry: ChunkEntry = { data: { state: ChunkState.QUEUED }, - subscriberPriorities: [[subscriberId, priority]], - priority: priority, + subscriberPriorities: [[subscriberId, { ...priority }]], + priority: { ...priority }, }; - this.chunks.set(chunkIdString, chunkEntry); + this.chunks.set(chunkIdString, newChunkEntry); this.queues.load.insert(chunkIdString, priority); } else { const subscriberPriorityIndex = chunkEntry.subscriberPriorities.findIndex(([id]) => id === subscriberId); From b8bd33497b81568587cb4073c6927817bc189848 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 22:20:11 -0700 Subject: [PATCH 11/38] docs for `DataManagerLimits` --- src/data_manager/types.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index a697b4599..e964eff00 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -11,10 +11,29 @@ export type DataManagerLimits = { const ONE_GIGABYTE = 1024 * 1024 * 1024; export const DEFAULT_DATA_MANAGER_LIMITS: DataManagerLimits = { + /** The maximum amount of data that can be kept in memory, in bytes. */ size: ONE_GIGABYTE, + /** + * The maximum amount of data that can be kept on the GPU, in bytes. + * + * Cannot be set higher than `size`. + */ deviceSize: ONE_GIGABYTE, + /** + * The maximum amount of data that can be kept on the GPU if at least some GPU data has the `PREFETCH` priority. + * + * Setting this lower than `deviceSize` keeps the data manager from prematurely filling up GPU memory with prefetched + * data that may never be used. Cannot be set higher than `deviceSize`. + */ devicePrefetchSize: ONE_GIGABYTE / 2, + /** The maximum number of concurrent requests. */ concurrentRequests: 10, + /** + * The maximum number of concurrent requests if at least one request has the `PREFETCH` priority. + * + * Setting this lower than `concurrentRequests` ensures that some concurrency always remains available for requests + * with the `VISIBLE` priority. Cannot be set higher than `concurrentRequests`. + */ concurrentPrefetches: 4, }; From 5a05395597433a0a969c7878ac2ec2a4bf73d05b Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 22:24:09 -0700 Subject: [PATCH 12/38] set up data subscribers, adding sources, etc --- src/data_manager/DataManager.ts | 101 +++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index e8467b9b8..aef91b199 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -39,10 +39,17 @@ export interface IChunkSource { export interface IDataSubscriber { // eslint-disable-next-line @typescript-eslint/naming-convention _subscriberId?: number; - onChunkLoaded?: (id: ChunkId) => void; - onChunkOnDevice?: (id: ChunkId) => void; + onChunkLoaded?: (id: ChunkId, data: TypedArray) => void; + onChunkOnGpu?: (id: ChunkId, texture: Data3DTexture) => void; + // TODO events for when chunks are evicted? } +// TODO move to types file? along with above interfaces...? +type SourceEntry = { + source: IChunkSource; + subscribers: IDataSubscriber[]; +}; + const swapRemove = (arr: T[], index: number) => { if (index < 0) { return; @@ -94,7 +101,7 @@ export class DataManager { /** Data and current state for every chunk of data tracked and managed by this class. */ private chunks = new Map(); private queues = new ChunkQueues(); - private sources: IChunkSource[] = []; + private sources: SourceEntry[] = []; /** * Information about each in-flight request. * @@ -172,12 +179,12 @@ export class DataManager { } private getChunkSpatialDims(chunkId: ChunkId): { x: number; y: number; z: number; dataType: NumberType } | undefined { - const source = this.sources[chunkId.source]; - if (source === undefined) { + const sourceEntry = this.sources[chunkId.source]; + if (sourceEntry === undefined) { return undefined; } - const multiscale = source.getDims()[chunkId.multiscale]; + const multiscale = sourceEntry.source.getDims()[chunkId.multiscale]; if (multiscale === undefined) { return undefined; } @@ -236,11 +243,16 @@ export class DataManager { } chunkEntry.data = { state: ChunkState.LOADING }; - const source = this.sources[requestId.source]; + const sourceEntry = this.sources[requestId.source]; + if (sourceEntry === undefined) { + console.error(`chunk ${requestKey} queued for load with invalid source id ${requestId.source}`); + this.chunks.delete(requestKey); + continue; + } const abortController = new AbortController(); this.requests.set(requestKey, abortController); - source + sourceEntry.source .getChunk(requestId, abortController.signal) .then((data) => this.onChunkLoad(requestId, data)) .catch(() => { @@ -367,6 +379,8 @@ export class DataManager { texture.internalFormat = texInternalFormat; texture.needsUpdate = true; + this.sources[loadId.source].subscribers.forEach((s) => s.onChunkOnGpu?.(loadId, texture)); + loadEntry.data = { state: ChunkState.DEVICE, texture }; } } @@ -424,6 +438,11 @@ export class DataManager { console.error(`received chunk ${key} which is larger than the cache limit`); return; } + const sourceEntry = this.sources[id.source]; + if (sourceEntry === undefined) { + console.error(`received chunk ${key} with invalid source id ${id.source}`); + return; + } const data = { state: ChunkState.MEMORY as const, memory }; let chunkEntry = this.chunks.get(key); @@ -444,6 +463,7 @@ export class DataManager { this.queues.evict.insert(key, chunkEntry.priority); this.memorySize += memory.byteLength; + sourceEntry.subscribers.forEach((s) => s.onChunkLoaded?.(id, memory)); this.updateDeviceData(); this.evictCacheData(); @@ -499,4 +519,69 @@ export class DataManager { swapRemove(chunkEntry.subscriberPriorities, index); this.updateChunkPriority(chunkKey, chunkEntry); } + + /** Get a chunk's data buffer, if it is in memory. */ + getChunkBuffer(chunkId: ChunkId): TypedArray | undefined { + const key = chunkIdToString(chunkId); + const entry = this.chunks.get(key); + if (entry === undefined) { + return undefined; + } + + if (entry.data.state === ChunkState.MEMORY) { + return entry.data.memory; + } else if (entry.data.state === ChunkState.DEVICE) { + // TODO this is wrong -- may just return a `UInt8Array` regardless of underlying data type? + return entry.data.texture.image.data; + } + + return undefined; + } + + /** Get a chunk's texture, if it is on the GPU. */ + getChunkTexture(chunkId: ChunkId): Data3DTexture | undefined { + const key = chunkIdToString(chunkId); + const entry = this.chunks.get(key); + if (entry?.data.state === ChunkState.DEVICE) { + return entry.data.texture; + } + return undefined; + } + + /** + * Add a chunk source to this data manager. + * + * Returns the ID that will be used to identify this source in chunk requests and event subscriptions. + */ + addSource(source: IChunkSource): number { + const id = this.sources.length; + this.sources.push({ source, subscribers: [] }); + return id; + } + + /** Subscribes `subscriber` to data events from the source with id `sourceId` */ + subscribeToSource(subscriber: IDataSubscriber, sourceId: number): boolean { + const sourceEntry = this.sources[sourceId]; + if (sourceEntry === undefined) { + return false; + } + + if (sourceEntry.subscribers.findIndex((s) => s === subscriber) === -1) { + sourceEntry.subscribers.push(subscriber); + } + + return true; + } + + /** Unsubscribes `subscriber` from data events from the source with id `sourceId` */ + unsubscribeFromSource(subscriber: IDataSubscriber, sourceId: number): boolean { + const sourceEntry = this.sources[sourceId]; + if (sourceEntry === undefined) { + return false; + } + + const subscriberIndex = sourceEntry.subscribers.findIndex((s) => s === subscriber); + swapRemove(sourceEntry.subscribers, subscriberIndex); + return true; + } } From 5a9466b62d6458d55610f029f5d5ba56b140b76c Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 22:47:26 -0700 Subject: [PATCH 13/38] make some exports default --- src/data_manager/DataManager.ts | 4 ++-- src/data_manager/priority_queue.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index aef91b199..8116636c8 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -27,7 +27,7 @@ import { ChunkPriorityLevel, deviceSizeLimitForPriority, } from "./types.js"; -import { PriorityQueue } from "./PriorityQueue.js"; +import PriorityQueue from "./PriorityQueue.js"; import { VolumeDims } from "../VolumeDims.js"; import { NumberType } from "../types.js"; @@ -97,7 +97,7 @@ class ChunkQueues { deviceEvict: ChunkQueue = new PriorityQueue(chunkPriorityLess); } -export class DataManager { +export default class DataManager { /** Data and current state for every chunk of data tracked and managed by this class. */ private chunks = new Map(); private queues = new ChunkQueues(); diff --git a/src/data_manager/priority_queue.ts b/src/data_manager/priority_queue.ts index e512dc702..d59429759 100644 --- a/src/data_manager/priority_queue.ts +++ b/src/data_manager/priority_queue.ts @@ -9,7 +9,7 @@ const entryToTuple = (entry?: Entry): [P, V] | undefined => entry && const parentIndexOf = (i: number) => Math.ceil(i / 2) - 1; const childIndexOf = (i: number) => i * 2 + 1; -export class PriorityQueue { +export default class PriorityQueue { private keys: Map> = new Map(); private heap: Entry[] = []; From bedc74e941ff91fc4b11d98ed033d850d505d717 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 23:21:58 -0700 Subject: [PATCH 14/38] turns out `VolumeDims.spacing` doesn't mean what I thought it did --- src/data_manager/DataManager.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 8116636c8..0102a1d85 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -31,8 +31,14 @@ import PriorityQueue from "./PriorityQueue.js"; import { VolumeDims } from "../VolumeDims.js"; import { NumberType } from "../types.js"; +// TODO not modifying original `VolumeDims` for compatibility, but at some point this will either need to be +// incorporated into `VolumeDims` or replaced with an entirely new type +export type ExtVolumeDims = VolumeDims & { chunkShape: [number, number, number, number, number] }; + export interface IChunkSource { - getDims(): VolumeDims[]; + getDims(): ExtVolumeDims[]; + // TODO this is three's `TypedArray`. Is that a good one to rely on for this fairly important interface? + // What does the current loader interface use? getChunk(id: LocalChunkId, signal?: AbortSignal): Promise; } @@ -192,7 +198,7 @@ export default class DataManager { // TODO fix config so we don't need this // see @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars // eslint-disable-next-line - const [_t, _c, z, y, x] = multiscale.spacing; + const [_t, _c, z, y, x] = multiscale.chunkShape; const { dataType } = multiscale; return { x, y, z, dataType }; } From 3cdb4f71ab6997ba1597d7a05d0a375aa24c0ac5 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 23:44:15 -0700 Subject: [PATCH 15/38] use proper internal `TypedArray` type; store dtypes --- src/data_manager/DataManager.ts | 34 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 0102a1d85..5349a2b8c 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -7,13 +7,12 @@ import { RedFormat, RedIntegerFormat, ShortType, - TypedArray, UnsignedByteType, UnsignedIntType, UnsignedShortType, } from "three"; -import type { ChunkId, ChunkPriority, ChunkEntry, DataManagerLimits, LocalChunkId } from "./types.js"; +import type { ChunkId, ChunkPriority, ChunkEntry, DataManagerLimits, LocalChunkId, Chunk } from "./types.js"; import { chunkIdToString, ChunkState, @@ -29,7 +28,7 @@ import { } from "./types.js"; import PriorityQueue from "./PriorityQueue.js"; import { VolumeDims } from "../VolumeDims.js"; -import { NumberType } from "../types.js"; +import { NumberType, TypedArray } from "../types.js"; // TODO not modifying original `VolumeDims` for compatibility, but at some point this will either need to be // incorporated into `VolumeDims` or replaced with an entirely new type @@ -37,15 +36,13 @@ export type ExtVolumeDims = VolumeDims & { chunkShape: [number, number, number, export interface IChunkSource { getDims(): ExtVolumeDims[]; - // TODO this is three's `TypedArray`. Is that a good one to rely on for this fairly important interface? - // What does the current loader interface use? - getChunk(id: LocalChunkId, signal?: AbortSignal): Promise; + getChunk(id: LocalChunkId, signal?: AbortSignal): Promise>; } export interface IDataSubscriber { // eslint-disable-next-line @typescript-eslint/naming-convention _subscriberId?: number; - onChunkLoaded?: (id: ChunkId, data: TypedArray) => void; + onChunkLoaded?: (id: ChunkId, chunk: Chunk) => void; onChunkOnGpu?: (id: ChunkId, texture: Data3DTexture) => void; // TODO events for when chunks are evicted? } @@ -68,7 +65,7 @@ const swapRemove = (arr: T[], index: number) => { } }; -// TODO this should go in a utils module somewhere +// TODO these maps should go in a utils module somewhere const dataTypeToTextureProperties: { [T in Exclude]: [number, number, PixelFormatGPU] } = { int8: [ByteType, RedIntegerFormat, "R8I"], int16: [ShortType, RedIntegerFormat, "R16I"], @@ -342,7 +339,10 @@ export default class DataManager { const memory = evictEntry.data.texture.image.data; this.deviceSize -= memory.byteLength; evictEntry.data.texture.dispose(); - evictEntry.data = { state: ChunkState.MEMORY, memory }; + const { dtype } = evictEntry.data; + // TODO verify: is this still the same typed array? or do we have to recreate it? + // See also `getChunkBuffer` + evictEntry.data = { state: ChunkState.MEMORY, memory, dtype }; } else if (evictEntry.data.state === ChunkState.MEMORY) { // this chunk was promoted in the previous step; put it back this.deviceSize -= evictEntry.data.memory.byteLength; @@ -377,7 +377,7 @@ export default class DataManager { const { x, y, z, dataType } = dims; const [texType, texFormat, texInternalFormat] = dataTypeToTextureProperties[dataType]; - const data = (loadEntry.data as { memory: TypedArray }).memory.buffer as ArrayBuffer; + const data = (loadEntry.data as { memory: TypedArray }).memory.buffer as ArrayBuffer; const texture = new Data3DTexture(data, x, y, z); texture.type = texType; @@ -387,7 +387,7 @@ export default class DataManager { this.sources[loadId.source].subscribers.forEach((s) => s.onChunkOnGpu?.(loadId, texture)); - loadEntry.data = { state: ChunkState.DEVICE, texture }; + loadEntry.data = { state: ChunkState.DEVICE, texture, dtype: dataType }; } } @@ -437,8 +437,9 @@ export default class DataManager { } } - private onChunkLoad(id: ChunkId, memory: TypedArray) { + private onChunkLoad(id: ChunkId, chunk: Chunk) { const key = chunkIdToString(id); + const { data: memory, dtype } = chunk; this.requests.delete(key); if (memory.byteLength > this.limits.size) { console.error(`received chunk ${key} which is larger than the cache limit`); @@ -449,7 +450,7 @@ export default class DataManager { console.error(`received chunk ${key} with invalid source id ${id.source}`); return; } - const data = { state: ChunkState.MEMORY as const, memory }; + const data = { state: ChunkState.MEMORY as const, memory, dtype }; let chunkEntry = this.chunks.get(key); if (chunkEntry === undefined) { @@ -469,7 +470,7 @@ export default class DataManager { this.queues.evict.insert(key, chunkEntry.priority); this.memorySize += memory.byteLength; - sourceEntry.subscribers.forEach((s) => s.onChunkLoaded?.(id, memory)); + sourceEntry.subscribers.forEach((s) => s.onChunkLoaded?.(id, chunk)); this.updateDeviceData(); this.evictCacheData(); @@ -527,7 +528,7 @@ export default class DataManager { } /** Get a chunk's data buffer, if it is in memory. */ - getChunkBuffer(chunkId: ChunkId): TypedArray | undefined { + getChunkBuffer(chunkId: ChunkId): TypedArray | undefined { const key = chunkIdToString(chunkId); const entry = this.chunks.get(key); if (entry === undefined) { @@ -538,7 +539,8 @@ export default class DataManager { return entry.data.memory; } else if (entry.data.state === ChunkState.DEVICE) { // TODO this is wrong -- may just return a `UInt8Array` regardless of underlying data type? - return entry.data.texture.image.data; + // return entry.data.texture.image.data; + return undefined; } return undefined; From 32a605042effd76440f594ec5fb4fc833fa3245e Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 23:46:27 -0700 Subject: [PATCH 16/38] forgot types with last commit --- src/data_manager/types.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index e964eff00..33f7dfbc2 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -1,4 +1,5 @@ -import { Data3DTexture, TypedArray } from "three"; +import type { Data3DTexture } from "three"; +import { NumberType, TypedArray } from "../types"; export type DataManagerLimits = { size: number; @@ -106,8 +107,8 @@ export const enum ChunkState { export type ChunkData = | { state: ChunkState.QUEUED | ChunkState.WORKER | ChunkState.LOADING } - | { state: ChunkState.MEMORY; memory: TypedArray } - | { state: ChunkState.DEVICE; texture: Data3DTexture }; + | { state: ChunkState.MEMORY; memory: TypedArray; dtype: NumberType } + | { state: ChunkState.DEVICE; texture: Data3DTexture; dtype: NumberType }; export type ChunkEntry = { data: ChunkData; @@ -133,3 +134,8 @@ export const stringToChunkId = (id: string): ChunkId => { tczyx: [parseInt(t), parseInt(c), parseInt(z), parseInt(y), parseInt(x)], }; }; + +export type Chunk = { + data: TypedArray; + dtype: T; +}; From 6f0bf11ddcbf50c447d2d49206fad53bb3e59e25 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sun, 22 Mar 2026 23:56:20 -0700 Subject: [PATCH 17/38] add a method to get source dims --- src/data_manager/DataManager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 5349a2b8c..33d401eca 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -567,6 +567,11 @@ export default class DataManager { return id; } + /** Returns the dimensions of the given source. */ + getSourceDims(sourceId: number): ExtVolumeDims[] | undefined { + return this.sources[sourceId]?.source.getDims(); + } + /** Subscribes `subscriber` to data events from the source with id `sourceId` */ subscribeToSource(subscriber: IDataSubscriber, sourceId: number): boolean { const sourceEntry = this.sources[sourceId]; From 01542d42b99bb8a4a871d05210e07a6d73091027 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Fri, 27 Mar 2026 15:02:59 -0700 Subject: [PATCH 18/38] convert priority min from constant to function --- src/data_manager/DataManager.ts | 10 +++++----- src/data_manager/types.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 33d401eca..3019a9f48 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -18,7 +18,7 @@ import { ChunkState, chunkPriorityGreater, chunkPriorityLess, - MIN_CHUNK_PRIORITY, + getMinChunkPriority, validateDataManagerLimits, DEFAULT_DATA_MANAGER_LIMITS, stringToChunkId, @@ -65,7 +65,7 @@ const swapRemove = (arr: T[], index: number) => { } }; -// TODO these maps should go in a utils module somewhere +// TODO these maps should go in a utils module somewhere (src/types.ts?) const dataTypeToTextureProperties: { [T in Exclude]: [number, number, PixelFormatGPU] } = { int8: [ByteType, RedIntegerFormat, "R8I"], int16: [ShortType, RedIntegerFormat, "R16I"], @@ -168,7 +168,7 @@ export default class DataManager { private updateChunkPriority(key: string, entry: ChunkEntry) { const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { return chunkPriorityGreater(priority, prevPriority) ? priority : prevPriority; - }, MIN_CHUNK_PRIORITY); + }, getMinChunkPriority()); if (nextPriority.level === ChunkPriorityLevel.RECENT) { nextPriority.score = this.recentCounter; @@ -223,7 +223,7 @@ export default class DataManager { } let [requestPriority, requestKey] = nextRequest; let requestId = stringToChunkId(requestKey); - const nextEvictPriority = this.queues.evict.peek()?.[0] ?? MIN_CHUNK_PRIORITY; + const nextEvictPriority = this.queues.evict.peek()?.[0] ?? getMinChunkPriority(); while ( // keep submitting requests while concurrency is available and... @@ -305,7 +305,7 @@ export default class DataManager { } const chunkSize = loadEntry.data.memory.byteLength; - const nextEvictPriority = this.queues.deviceEvict.peek()?.[0] ?? MIN_CHUNK_PRIORITY; + const nextEvictPriority = this.queues.deviceEvict.peek()?.[0] ?? getMinChunkPriority(); if ( // queue this chunk if there's space for it... !(this.deviceSize + chunkSize <= deviceSizeLimitForPriority(this.limits, loadPriority)) && diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index 33f7dfbc2..1ac64bac5 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -84,7 +84,7 @@ export const deviceSizeLimitForPriority = (limits: DataManagerLimits, { level }: ? limits.devicePrefetchSize : 0; -export const MIN_CHUNK_PRIORITY: ChunkPriority = { level: ChunkPriorityLevel.RECENT, score: 0 }; +export const getMinChunkPriority = (): ChunkPriority => ({ level: ChunkPriorityLevel.RECENT, score: 0 }); /** Returns true iff `a > b` */ export const chunkPriorityGreater = (a: ChunkPriority, b: ChunkPriority) => a.level !== b.level ? a.level > b.level : a.score > b.score; From a2566590af8f66304623c4165528eadf4cbdf9ee Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Fri, 27 Mar 2026 17:02:50 -0700 Subject: [PATCH 19/38] use new numeric comparison funcs in data manager --- src/data_manager/DataManager.ts | 18 +++++++++--------- src/data_manager/types.ts | 12 ++++++------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 3019a9f48..d5dede00a 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -16,8 +16,8 @@ import type { ChunkId, ChunkPriority, ChunkEntry, DataManagerLimits, LocalChunkI import { chunkIdToString, ChunkState, - chunkPriorityGreater, - chunkPriorityLess, + comparePriority, + reverseComparePriority, getMinChunkPriority, validateDataManagerLimits, DEFAULT_DATA_MANAGER_LIMITS, @@ -91,13 +91,13 @@ type ChunkQueue = PriorityQueue; class ChunkQueues { /** Chunks waiting to be loaded */ - load: ChunkQueue = new PriorityQueue(chunkPriorityGreater); + load: ChunkQueue = new PriorityQueue(comparePriority); /** Chunks in memory that may be evicted */ - evict: ChunkQueue = new PriorityQueue(chunkPriorityLess); + evict: ChunkQueue = new PriorityQueue(reverseComparePriority); /** Chunks in memory waiting to be uploaded to the GPU */ - deviceLoad: ChunkQueue = new PriorityQueue(chunkPriorityGreater); + deviceLoad: ChunkQueue = new PriorityQueue(comparePriority); /** Chunks on the GPU that may be evicted */ - deviceEvict: ChunkQueue = new PriorityQueue(chunkPriorityLess); + deviceEvict: ChunkQueue = new PriorityQueue(reverseComparePriority); } export default class DataManager { @@ -167,7 +167,7 @@ export default class DataManager { /** Resolves a chunk's overall priority based on all requests for it, then updates its queue position. */ private updateChunkPriority(key: string, entry: ChunkEntry) { const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { - return chunkPriorityGreater(priority, prevPriority) ? priority : prevPriority; + return comparePriority(priority, prevPriority) < 0 ? priority : prevPriority; }, getMinChunkPriority()); if (nextPriority.level === ChunkPriorityLevel.RECENT) { @@ -230,7 +230,7 @@ export default class DataManager { this.requests.size < requestLimitForPriority(this.limits, requestPriority) && // ...either space will be available or this chunk has higher priority than an already cached chunk (this.memorySize + this.estimateChunkSize(requestId) < this.limits.size || - chunkPriorityGreater(requestPriority, nextEvictPriority)) + comparePriority(requestPriority, nextEvictPriority) < 0) ) { this.queues.load.pop(); @@ -311,7 +311,7 @@ export default class DataManager { !(this.deviceSize + chunkSize <= deviceSizeLimitForPriority(this.limits, loadPriority)) && // ...or if its priority is greater than the lowest-priority chunk that's already on the GPU. // (this implies that at least one chunk will be evicted from the GPU in the next step) - !(loadPriority.level !== ChunkPriorityLevel.RECENT && chunkPriorityGreater(loadPriority, nextEvictPriority)) + !(loadPriority.level !== ChunkPriorityLevel.RECENT && comparePriority(loadPriority, nextEvictPriority) < 0) ) { // otherwise, we're done break; diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index 1ac64bac5..40164b5b4 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -85,12 +85,12 @@ export const deviceSizeLimitForPriority = (limits: DataManagerLimits, { level }: : 0; export const getMinChunkPriority = (): ChunkPriority => ({ level: ChunkPriorityLevel.RECENT, score: 0 }); -/** Returns true iff `a > b` */ -export const chunkPriorityGreater = (a: ChunkPriority, b: ChunkPriority) => - a.level !== b.level ? a.level > b.level : a.score > b.score; -/** Returns true iff `a < b` */ -export const chunkPriorityLess = (a: ChunkPriority, b: ChunkPriority) => - a.level !== b.level ? a.level < b.level : a.score < b.score; +/** Result is less than `0` if `a > b`, greater than `0` if `a < b`, or `0` if `a === b` */ +export const comparePriority = (a: ChunkPriority, b: ChunkPriority): number => + a.level !== b.level ? b.level - a.level : b.score - a.score; +/** Result is greater than `0` if `a > b`, less than `0` if `a < b`, or `0` if `a === b` */ +export const reverseComparePriority = (a: ChunkPriority, b: ChunkPriority): number => + a.level !== b.level ? a.level - b.level : a.score - b.score; export const enum ChunkState { /** Chunk is queued to be loaded. */ From c96607470cf4c475a05e3539db03673407e31123 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Fri, 27 Mar 2026 17:30:35 -0700 Subject: [PATCH 20/38] send all update methods through new `update` method --- src/data_manager/DataManager.ts | 158 ++++++++++++++++++-------------- 1 file changed, 89 insertions(+), 69 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index d5dede00a..e3a743dc2 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -210,69 +210,10 @@ export default class DataManager { } /** - * Submits queued chunk requests. + * Resolves which chunks should be uploaded to the GPU, and which should be evicted from it. * - * Request queueing and submission happen in separate steps to allow all requests to be queued and sorted into - * priority order before submitting. If requests were submitted immediately upon being added to the queue, the first - * requests in a batch would submit in the order they were queued, not priority order. + * In other words, this function drives the `deviceLoad` and `deviceEvict` queues. */ - submitRequests() { - const nextRequest = this.queues.load.peek(); - if (nextRequest === undefined) { - return; - } - let [requestPriority, requestKey] = nextRequest; - let requestId = stringToChunkId(requestKey); - const nextEvictPriority = this.queues.evict.peek()?.[0] ?? getMinChunkPriority(); - - while ( - // keep submitting requests while concurrency is available and... - this.requests.size < requestLimitForPriority(this.limits, requestPriority) && - // ...either space will be available or this chunk has higher priority than an already cached chunk - (this.memorySize + this.estimateChunkSize(requestId) < this.limits.size || - comparePriority(requestPriority, nextEvictPriority) < 0) - ) { - this.queues.load.pop(); - - if (requestPriority.level === ChunkPriorityLevel.RECENT) { - // Ignore requests at the `RECENT` level. That's supposed to be for chunks that are already loaded! - this.chunks.delete(requestKey); - continue; - } - - const chunkEntry = this.chunks.get(requestKey); - if (chunkEntry === undefined) { - continue; - } - chunkEntry.data = { state: ChunkState.LOADING }; - - const sourceEntry = this.sources[requestId.source]; - if (sourceEntry === undefined) { - console.error(`chunk ${requestKey} queued for load with invalid source id ${requestId.source}`); - this.chunks.delete(requestKey); - continue; - } - const abortController = new AbortController(); - this.requests.set(requestKey, abortController); - - sourceEntry.source - .getChunk(requestId, abortController.signal) - .then((data) => this.onChunkLoad(requestId, data)) - .catch(() => { - this.chunks.delete(requestKey); - this.requests.delete(requestKey); - }); - - const nextRequest = this.queues.load.peek(); - if (nextRequest === undefined) { - return; - } - [requestPriority, requestKey] = nextRequest; - requestId = stringToChunkId(requestKey); - } - } - - /** Resolves which chunks should be uploaded to the GPU, and which should be evicted. */ private updateDeviceData() { // STEP 1: pull eligible chunks out of the `deviceLoad` queue const loads: [string, ChunkEntry][] = []; @@ -391,8 +332,12 @@ export default class DataManager { } } - /** Evicts cached data to stay under the size limit. */ - private evictCacheData() { + /** + * Evicts data cached in memory to stay under the size limit. + * + * In other words, this function drives the `evict` queue. + */ + private evictCachedData() { while (this.memorySize > this.limits.size) { const nextEvict = this.queues.evict.pop(); if (nextEvict === undefined) { @@ -437,6 +382,80 @@ export default class DataManager { } } + /** + * Submits requests for chunks that are queued to load. + * + * In other words, this function drives the `load` queue. + */ + private submitRequests() { + const nextRequest = this.queues.load.peek(); + if (nextRequest === undefined) { + return; + } + let [requestPriority, requestKey] = nextRequest; + let requestId = stringToChunkId(requestKey); + const nextEvictPriority = this.queues.evict.peek()?.[0] ?? getMinChunkPriority(); + + while ( + // keep submitting requests while concurrency is available and... + this.requests.size < requestLimitForPriority(this.limits, requestPriority) && + // ...either space will be available or this chunk has higher priority than an already cached chunk + (this.memorySize + this.estimateChunkSize(requestId) < this.limits.size || + comparePriority(requestPriority, nextEvictPriority) < 0) + ) { + this.queues.load.pop(); + + if (requestPriority.level === ChunkPriorityLevel.RECENT) { + // Ignore requests at the `RECENT` level. That's supposed to be for chunks that are already loaded! + this.chunks.delete(requestKey); + continue; + } + + const chunkEntry = this.chunks.get(requestKey); + if (chunkEntry === undefined) { + continue; + } + chunkEntry.data = { state: ChunkState.LOADING }; + + const sourceEntry = this.sources[requestId.source]; + if (sourceEntry === undefined) { + console.error(`chunk ${requestKey} queued for load with invalid source id ${requestId.source}`); + this.chunks.delete(requestKey); + continue; + } + const abortController = new AbortController(); + this.requests.set(requestKey, abortController); + + sourceEntry.source + .getChunk(requestId, abortController.signal) + .then((data) => this.onChunkLoad(requestId, data)) + .catch(() => { + this.chunks.delete(requestKey); + this.requests.delete(requestKey); + }); + + const nextRequest = this.queues.load.peek(); + if (nextRequest === undefined) { + return; + } + [requestPriority, requestKey] = nextRequest; + requestId = stringToChunkId(requestKey); + } + } + + /** + * Drives progress on all queued requests, including creating textures, evicting data, and submitting requests. + * + * Request queueing and submission happen in separate steps to allow all requests to be queued and sorted into + * priority order before submitting. If requests were submitted immediately upon being added to the queue, the first + * requests in a batch would submit in the order they were queued, not priority order. + */ + update() { + this.updateDeviceData(); + this.evictCachedData(); + this.submitRequests(); + } + private onChunkLoad(id: ChunkId, chunk: Chunk) { const key = chunkIdToString(id); const { data: memory, dtype } = chunk; @@ -472,17 +491,14 @@ export default class DataManager { this.memorySize += memory.byteLength; sourceEntry.subscribers.forEach((s) => s.onChunkLoaded?.(id, chunk)); - this.updateDeviceData(); - this.evictCacheData(); - // If this just freed up our request quota, make sure to fill it back up - this.submitRequests(); + this.update(); } /** * Declares that subscriber `subscriber` requires chunk `chunkId` at priority level `priority`. * * If the chunk is not already in memory, this will queue the chunk to be loaded. Chunk load requests are not - * submitted until the next call to `submitRequests`. + * submitted until the next call to `update`. */ queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority) { const subscriberId = this.getIdForSubscriber(subscriber); @@ -509,7 +525,11 @@ export default class DataManager { } } - /** Declares that subscriber `subscriber` no longer requires chunk `chunkId`. */ + /** + * Declares that subscriber `subscriber` no longer requires chunk `chunkId`. + * + * The `DataManager` will not respond to this change until the next call to `update`. + */ removeChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId) { const subscriberId = this.getIdForSubscriber(subscriber); const chunkKey = chunkIdToString(chunkId); From 1aafd928936427c5cfff65d635d88fd68c2af17d Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sat, 18 Apr 2026 14:07:30 -0700 Subject: [PATCH 21/38] fix lint & type errors --- src/data_manager/DataManager.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index e3a743dc2..a072ce42b 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -217,8 +217,7 @@ export default class DataManager { private updateDeviceData() { // STEP 1: pull eligible chunks out of the `deviceLoad` queue const loads: [string, ChunkEntry][] = []; - // Sorry eslint! This one's just easier if we loop unconditionally and break when done. - // eslint-disable-next-line no-constant-condition + // This one's easier if we just loop unconditionally and `break` when done. while (true) { const nextLoad = this.queues.deviceLoad.peek(); if (nextLoad === undefined) { @@ -278,7 +277,7 @@ export default class DataManager { if (evictEntry.data.state === ChunkState.DEVICE) { // this chunk was previously on the GPU; demote to `MEMORY` and destroy its texture const memory = evictEntry.data.texture.image.data; - this.deviceSize -= memory.byteLength; + this.deviceSize -= memory?.byteLength ?? 0; evictEntry.data.texture.dispose(); const { dtype } = evictEntry.data; // TODO verify: is this still the same typed array? or do we have to recreate it? @@ -318,7 +317,7 @@ export default class DataManager { const { x, y, z, dataType } = dims; const [texType, texFormat, texInternalFormat] = dataTypeToTextureProperties[dataType]; - const data = (loadEntry.data as { memory: TypedArray }).memory.buffer as ArrayBuffer; + const data = (loadEntry.data as { memory: TypedArray }).memory; const texture = new Data3DTexture(data, x, y, z); texture.type = texType; @@ -361,8 +360,8 @@ export default class DataManager { break; case ChunkState.DEVICE: console.error(`chunk ${evictKey} queued for eviction while in the "device" state`); - this.deviceSize -= evictEntry.data.texture.image.data.byteLength; - this.memorySize -= evictEntry.data.texture.image.data.byteLength; + this.deviceSize -= evictEntry.data.texture.image.data?.byteLength ?? 0; + this.memorySize -= evictEntry.data.texture.image.data?.byteLength ?? 0; evictEntry.data.texture.dispose(); this.queues.deviceEvict.remove(evictKey); // TODO if subscribers get "chunk removed from GPU" events, one should go here From 8cd96ca0aa54684c8e5f92a5228237a60fea3337 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sat, 18 Apr 2026 14:07:50 -0700 Subject: [PATCH 22/38] fix test --- src/test/priority_queue.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/priority_queue.test.ts b/src/test/priority_queue.test.ts index cafa0ce11..fe3dee4aa 100644 --- a/src/test/priority_queue.test.ts +++ b/src/test/priority_queue.test.ts @@ -1,4 +1,4 @@ -import { PriorityQueue } from "../data_manager/priority_queue.js"; +import PriorityQueue from "../data_manager/priority_queue.js"; const ENTRY_COUNT = 100; From 6a7927fe023b65e842066a319a75321e36398628 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sat, 18 Apr 2026 14:11:34 -0700 Subject: [PATCH 23/38] fix import format --- src/data_manager/types.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index 40164b5b4..bbe56b066 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -1,5 +1,5 @@ import type { Data3DTexture } from "three"; -import { NumberType, TypedArray } from "../types"; +import type { NumberType, TypedArray } from "../types.ts"; export type DataManagerLimits = { size: number; @@ -74,15 +74,15 @@ export const requestLimitForPriority = (limits: DataManagerLimits, { level }: Ch level === ChunkPriorityLevel.VISIBLE ? limits.concurrentRequests : level === ChunkPriorityLevel.PREFETCH - ? limits.concurrentPrefetches - : 1; + ? limits.concurrentPrefetches + : 1; export const deviceSizeLimitForPriority = (limits: DataManagerLimits, { level }: ChunkPriority): number => level === ChunkPriorityLevel.VISIBLE ? limits.deviceSize : level === ChunkPriorityLevel.PREFETCH - ? limits.devicePrefetchSize - : 0; + ? limits.devicePrefetchSize + : 0; export const getMinChunkPriority = (): ChunkPriority => ({ level: ChunkPriorityLevel.RECENT, score: 0 }); /** Result is less than `0` if `a > b`, greater than `0` if `a < b`, or `0` if `a === b` */ From 25cc91e3028376416b7e9b1210bdeb952b209eb9 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sat, 18 Apr 2026 14:24:08 -0700 Subject: [PATCH 24/38] fix some array buffer type errors --- src/data_manager/DataManager.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index a072ce42b..97df6c9b7 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -28,7 +28,7 @@ import { } from "./types.js"; import PriorityQueue from "./PriorityQueue.js"; import { VolumeDims } from "../VolumeDims.js"; -import { NumberType, TypedArray } from "../types.js"; +import { ARRAY_CONSTRUCTORS, type NumberType, type TypedArray } from "../types.js"; // TODO not modifying original `VolumeDims` for compatibility, but at some point this will either need to be // incorporated into `VolumeDims` or replaced with an entirely new type @@ -276,12 +276,11 @@ export default class DataManager { if (evictEntry !== undefined) { if (evictEntry.data.state === ChunkState.DEVICE) { // this chunk was previously on the GPU; demote to `MEMORY` and destroy its texture - const memory = evictEntry.data.texture.image.data; - this.deviceSize -= memory?.byteLength ?? 0; + const array = evictEntry.data.texture.image.data as TypedArray | null; + const memory = array ?? new ARRAY_CONSTRUCTORS[evictEntry.data.dtype](0); + this.deviceSize -= memory?.byteLength; evictEntry.data.texture.dispose(); const { dtype } = evictEntry.data; - // TODO verify: is this still the same typed array? or do we have to recreate it? - // See also `getChunkBuffer` evictEntry.data = { state: ChunkState.MEMORY, memory, dtype }; } else if (evictEntry.data.state === ChunkState.MEMORY) { // this chunk was promoted in the previous step; put it back @@ -557,9 +556,7 @@ export default class DataManager { if (entry.data.state === ChunkState.MEMORY) { return entry.data.memory; } else if (entry.data.state === ChunkState.DEVICE) { - // TODO this is wrong -- may just return a `UInt8Array` regardless of underlying data type? - // return entry.data.texture.image.data; - return undefined; + return (entry.data.texture.image.data as TypedArray | null) ?? undefined; } return undefined; From 1ae33dbf0131d3a5310c0019e084fe9e4c107ed2 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Thu, 20 Aug 2026 15:47:56 -0700 Subject: [PATCH 25/38] use a symbol! --- src/data_manager/DataManager.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 97df6c9b7..4ad898a4d 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -30,6 +30,8 @@ import PriorityQueue from "./PriorityQueue.js"; import { VolumeDims } from "../VolumeDims.js"; import { ARRAY_CONSTRUCTORS, type NumberType, type TypedArray } from "../types.js"; +const SUBSCRIBER_ID = Symbol("DataManager.subscriberId"); + // TODO not modifying original `VolumeDims` for compatibility, but at some point this will either need to be // incorporated into `VolumeDims` or replaced with an entirely new type export type ExtVolumeDims = VolumeDims & { chunkShape: [number, number, number, number, number] }; @@ -40,8 +42,7 @@ export interface IChunkSource { } export interface IDataSubscriber { - // eslint-disable-next-line @typescript-eslint/naming-convention - _subscriberId?: number; + [SUBSCRIBER_ID]?: number; onChunkLoaded?: (id: ChunkId, chunk: Chunk) => void; onChunkOnGpu?: (id: ChunkId, texture: Data3DTexture) => void; // TODO events for when chunks are evicted? @@ -131,11 +132,11 @@ export default class DataManager { /** Gets the id of data subscriber `subscriber`, assigning it one if it doesn't have one. */ private getIdForSubscriber(subscriber: IDataSubscriber): number { - if (subscriber._subscriberId === undefined) { - subscriber._subscriberId = this.subscriberCount; + if (subscriber[SUBSCRIBER_ID] === undefined) { + subscriber[SUBSCRIBER_ID] = this.subscriberCount; this.subscriberCount += 1; } - return subscriber._subscriberId; + return subscriber[SUBSCRIBER_ID]; } /** From bc06399ca50962583ff37390c3773c2e9ff68465 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Thu, 20 Aug 2026 17:06:21 -0700 Subject: [PATCH 26/38] just use chunk ids as map keys --- src/data_manager/DataManager.ts | 80 +++++++++++++++------------------ 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 4ad898a4d..45fca60eb 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -21,7 +21,6 @@ import { getMinChunkPriority, validateDataManagerLimits, DEFAULT_DATA_MANAGER_LIMITS, - stringToChunkId, requestLimitForPriority, ChunkPriorityLevel, deviceSizeLimitForPriority, @@ -88,7 +87,7 @@ const dataTypeToByteLength: { [T in NumberType]: number } = { float64: 8, }; -type ChunkQueue = PriorityQueue; +type ChunkQueue = PriorityQueue; class ChunkQueues { /** Chunks waiting to be loaded */ @@ -103,7 +102,7 @@ class ChunkQueues { export default class DataManager { /** Data and current state for every chunk of data tracked and managed by this class. */ - private chunks = new Map(); + private chunks = new Map(); private queues = new ChunkQueues(); private sources: SourceEntry[] = []; /** @@ -111,7 +110,7 @@ export default class DataManager { * * This could be stored in `chunks`, but requests don't necessarily correspond one-to-one with chunks. */ - private requests = new Map(); + private requests = new Map(); /** The amount of chunk data currently cached in memory, in bytes */ private memorySize = 0; /** The amount of GPU texture memory managed by this class, in bytes */ @@ -144,7 +143,7 @@ export default class DataManager { * * Assumes that the chunk is not in any queues that don't match its state. */ - private updateChunkInQueue(key: string, entry: ChunkEntry) { + private updateChunkInQueue(key: ChunkId, entry: ChunkEntry) { switch (entry.data.state) { case ChunkState.QUEUED: this.queues.load.insert(key, entry.priority); @@ -166,7 +165,7 @@ export default class DataManager { } /** Resolves a chunk's overall priority based on all requests for it, then updates its queue position. */ - private updateChunkPriority(key: string, entry: ChunkEntry) { + private updateChunkPriority(key: ChunkId, entry: ChunkEntry) { const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { return comparePriority(priority, prevPriority) < 0 ? priority : prevPriority; }, getMinChunkPriority()); @@ -217,7 +216,7 @@ export default class DataManager { */ private updateDeviceData() { // STEP 1: pull eligible chunks out of the `deviceLoad` queue - const loads: [string, ChunkEntry][] = []; + const loads: [ChunkId, ChunkEntry][] = []; // This one's easier if we just loop unconditionally and `break` when done. while (true) { const nextLoad = this.queues.deviceLoad.peek(); @@ -302,16 +301,16 @@ export default class DataManager { } // STEP 3: create textures for newly-promoted chunks - for (const [loadKey, loadEntry] of loads) { - const loadId = stringToChunkId(loadKey); + for (const [loadId, loadEntry] of loads) { const dims = this.getChunkSpatialDims(loadId); if (dims === undefined) { + const loadIdString = chunkIdToString(loadId); console.error( - `chunk ${loadKey} queued for device upload with invalid source id or multiscale index (id ${loadId.source}, multiscale index ${loadId.multiscale})` + `chunk ${loadIdString} queued for device upload with invalid source id or multiscale index (id ${loadId.source}, multiscale index ${loadId.multiscale})` ); - this.queues.deviceEvict.remove(loadKey); - this.chunks.delete(loadKey); + this.queues.deviceEvict.remove(loadId); + this.chunks.delete(loadId); continue; } @@ -391,8 +390,7 @@ export default class DataManager { if (nextRequest === undefined) { return; } - let [requestPriority, requestKey] = nextRequest; - let requestId = stringToChunkId(requestKey); + let [requestPriority, requestId] = nextRequest; const nextEvictPriority = this.queues.evict.peek()?.[0] ?? getMinChunkPriority(); while ( @@ -406,11 +404,11 @@ export default class DataManager { if (requestPriority.level === ChunkPriorityLevel.RECENT) { // Ignore requests at the `RECENT` level. That's supposed to be for chunks that are already loaded! - this.chunks.delete(requestKey); + this.chunks.delete(requestId); continue; } - const chunkEntry = this.chunks.get(requestKey); + const chunkEntry = this.chunks.get(requestId); if (chunkEntry === undefined) { continue; } @@ -418,27 +416,26 @@ export default class DataManager { const sourceEntry = this.sources[requestId.source]; if (sourceEntry === undefined) { - console.error(`chunk ${requestKey} queued for load with invalid source id ${requestId.source}`); - this.chunks.delete(requestKey); + console.error(`chunk ${requestId} queued for load with invalid source id ${requestId.source}`); + this.chunks.delete(requestId); continue; } const abortController = new AbortController(); - this.requests.set(requestKey, abortController); + this.requests.set(requestId, abortController); sourceEntry.source .getChunk(requestId, abortController.signal) .then((data) => this.onChunkLoad(requestId, data)) .catch(() => { - this.chunks.delete(requestKey); - this.requests.delete(requestKey); + this.chunks.delete(requestId); + this.requests.delete(requestId); }); const nextRequest = this.queues.load.peek(); if (nextRequest === undefined) { return; } - [requestPriority, requestKey] = nextRequest; - requestId = stringToChunkId(requestKey); + [requestPriority, requestId] = nextRequest; } } @@ -456,36 +453,35 @@ export default class DataManager { } private onChunkLoad(id: ChunkId, chunk: Chunk) { - const key = chunkIdToString(id); const { data: memory, dtype } = chunk; - this.requests.delete(key); + this.requests.delete(id); if (memory.byteLength > this.limits.size) { - console.error(`received chunk ${key} which is larger than the cache limit`); + console.error(`received chunk ${chunkIdToString(id)} which is larger than the cache limit`); return; } const sourceEntry = this.sources[id.source]; if (sourceEntry === undefined) { - console.error(`received chunk ${key} with invalid source id ${id.source}`); + console.error(`received chunk ${chunkIdToString(id)} with invalid source id ${id.source}`); return; } const data = { state: ChunkState.MEMORY as const, memory, dtype }; - let chunkEntry = this.chunks.get(key); + let chunkEntry = this.chunks.get(id); if (chunkEntry === undefined) { - console.error(`received chunk ${key} without data manager entry`); + console.error(`received chunk ${chunkIdToString(id)} without data manager entry`); chunkEntry = { data, subscriberPriorities: [], priority: { level: ChunkPriorityLevel.RECENT, score: this.recentCounter }, }; - this.chunks.set(key, chunkEntry); + this.chunks.set(id, chunkEntry); this.recentCounter += 1; } else { chunkEntry.data = data; } - this.queues.deviceLoad.insert(key, chunkEntry.priority); - this.queues.evict.insert(key, chunkEntry.priority); + this.queues.deviceLoad.insert(id, chunkEntry.priority); + this.queues.evict.insert(id, chunkEntry.priority); this.memorySize += memory.byteLength; sourceEntry.subscribers.forEach((s) => s.onChunkLoaded?.(id, chunk)); @@ -501,8 +497,7 @@ export default class DataManager { */ queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority) { const subscriberId = this.getIdForSubscriber(subscriber); - const chunkIdString = chunkIdToString(chunkId); - const chunkEntry = this.chunks.get(chunkIdString); + const chunkEntry = this.chunks.get(chunkId); if (chunkEntry === undefined) { const newChunkEntry: ChunkEntry = { @@ -510,8 +505,8 @@ export default class DataManager { subscriberPriorities: [[subscriberId, { ...priority }]], priority: { ...priority }, }; - this.chunks.set(chunkIdString, newChunkEntry); - this.queues.load.insert(chunkIdString, priority); + this.chunks.set(chunkId, newChunkEntry); + this.queues.load.insert(chunkId, priority); } else { const subscriberPriorityIndex = chunkEntry.subscriberPriorities.findIndex(([id]) => id === subscriberId); if (subscriberPriorityIndex === -1) { @@ -520,7 +515,7 @@ export default class DataManager { chunkEntry.subscriberPriorities[subscriberPriorityIndex][1] = priority; } - this.updateChunkPriority(chunkIdString, chunkEntry); + this.updateChunkPriority(chunkId, chunkEntry); } } @@ -531,8 +526,7 @@ export default class DataManager { */ removeChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId) { const subscriberId = this.getIdForSubscriber(subscriber); - const chunkKey = chunkIdToString(chunkId); - const chunkEntry = this.chunks.get(chunkKey); + const chunkEntry = this.chunks.get(chunkId); if (chunkEntry === undefined) { return; } @@ -543,13 +537,12 @@ export default class DataManager { } swapRemove(chunkEntry.subscriberPriorities, index); - this.updateChunkPriority(chunkKey, chunkEntry); + this.updateChunkPriority(chunkId, chunkEntry); } /** Get a chunk's data buffer, if it is in memory. */ getChunkBuffer(chunkId: ChunkId): TypedArray | undefined { - const key = chunkIdToString(chunkId); - const entry = this.chunks.get(key); + const entry = this.chunks.get(chunkId); if (entry === undefined) { return undefined; } @@ -565,8 +558,7 @@ export default class DataManager { /** Get a chunk's texture, if it is on the GPU. */ getChunkTexture(chunkId: ChunkId): Data3DTexture | undefined { - const key = chunkIdToString(chunkId); - const entry = this.chunks.get(key); + const entry = this.chunks.get(chunkId); if (entry?.data.state === ChunkState.DEVICE) { return entry.data.texture; } From 6577144ea085919b61843eb2831b055b1c789569 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Wed, 26 Aug 2026 10:57:36 -0700 Subject: [PATCH 27/38] Revert "just use chunk ids as map keys" This reverts commit 125e1d4942bec2c1346fde088cf233c166359bd1. --- src/data_manager/DataManager.ts | 80 ++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 45fca60eb..4ad898a4d 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -21,6 +21,7 @@ import { getMinChunkPriority, validateDataManagerLimits, DEFAULT_DATA_MANAGER_LIMITS, + stringToChunkId, requestLimitForPriority, ChunkPriorityLevel, deviceSizeLimitForPriority, @@ -87,7 +88,7 @@ const dataTypeToByteLength: { [T in NumberType]: number } = { float64: 8, }; -type ChunkQueue = PriorityQueue; +type ChunkQueue = PriorityQueue; class ChunkQueues { /** Chunks waiting to be loaded */ @@ -102,7 +103,7 @@ class ChunkQueues { export default class DataManager { /** Data and current state for every chunk of data tracked and managed by this class. */ - private chunks = new Map(); + private chunks = new Map(); private queues = new ChunkQueues(); private sources: SourceEntry[] = []; /** @@ -110,7 +111,7 @@ export default class DataManager { * * This could be stored in `chunks`, but requests don't necessarily correspond one-to-one with chunks. */ - private requests = new Map(); + private requests = new Map(); /** The amount of chunk data currently cached in memory, in bytes */ private memorySize = 0; /** The amount of GPU texture memory managed by this class, in bytes */ @@ -143,7 +144,7 @@ export default class DataManager { * * Assumes that the chunk is not in any queues that don't match its state. */ - private updateChunkInQueue(key: ChunkId, entry: ChunkEntry) { + private updateChunkInQueue(key: string, entry: ChunkEntry) { switch (entry.data.state) { case ChunkState.QUEUED: this.queues.load.insert(key, entry.priority); @@ -165,7 +166,7 @@ export default class DataManager { } /** Resolves a chunk's overall priority based on all requests for it, then updates its queue position. */ - private updateChunkPriority(key: ChunkId, entry: ChunkEntry) { + private updateChunkPriority(key: string, entry: ChunkEntry) { const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { return comparePriority(priority, prevPriority) < 0 ? priority : prevPriority; }, getMinChunkPriority()); @@ -216,7 +217,7 @@ export default class DataManager { */ private updateDeviceData() { // STEP 1: pull eligible chunks out of the `deviceLoad` queue - const loads: [ChunkId, ChunkEntry][] = []; + const loads: [string, ChunkEntry][] = []; // This one's easier if we just loop unconditionally and `break` when done. while (true) { const nextLoad = this.queues.deviceLoad.peek(); @@ -301,16 +302,16 @@ export default class DataManager { } // STEP 3: create textures for newly-promoted chunks - for (const [loadId, loadEntry] of loads) { + for (const [loadKey, loadEntry] of loads) { + const loadId = stringToChunkId(loadKey); const dims = this.getChunkSpatialDims(loadId); if (dims === undefined) { - const loadIdString = chunkIdToString(loadId); console.error( - `chunk ${loadIdString} queued for device upload with invalid source id or multiscale index (id ${loadId.source}, multiscale index ${loadId.multiscale})` + `chunk ${loadKey} queued for device upload with invalid source id or multiscale index (id ${loadId.source}, multiscale index ${loadId.multiscale})` ); - this.queues.deviceEvict.remove(loadId); - this.chunks.delete(loadId); + this.queues.deviceEvict.remove(loadKey); + this.chunks.delete(loadKey); continue; } @@ -390,7 +391,8 @@ export default class DataManager { if (nextRequest === undefined) { return; } - let [requestPriority, requestId] = nextRequest; + let [requestPriority, requestKey] = nextRequest; + let requestId = stringToChunkId(requestKey); const nextEvictPriority = this.queues.evict.peek()?.[0] ?? getMinChunkPriority(); while ( @@ -404,11 +406,11 @@ export default class DataManager { if (requestPriority.level === ChunkPriorityLevel.RECENT) { // Ignore requests at the `RECENT` level. That's supposed to be for chunks that are already loaded! - this.chunks.delete(requestId); + this.chunks.delete(requestKey); continue; } - const chunkEntry = this.chunks.get(requestId); + const chunkEntry = this.chunks.get(requestKey); if (chunkEntry === undefined) { continue; } @@ -416,26 +418,27 @@ export default class DataManager { const sourceEntry = this.sources[requestId.source]; if (sourceEntry === undefined) { - console.error(`chunk ${requestId} queued for load with invalid source id ${requestId.source}`); - this.chunks.delete(requestId); + console.error(`chunk ${requestKey} queued for load with invalid source id ${requestId.source}`); + this.chunks.delete(requestKey); continue; } const abortController = new AbortController(); - this.requests.set(requestId, abortController); + this.requests.set(requestKey, abortController); sourceEntry.source .getChunk(requestId, abortController.signal) .then((data) => this.onChunkLoad(requestId, data)) .catch(() => { - this.chunks.delete(requestId); - this.requests.delete(requestId); + this.chunks.delete(requestKey); + this.requests.delete(requestKey); }); const nextRequest = this.queues.load.peek(); if (nextRequest === undefined) { return; } - [requestPriority, requestId] = nextRequest; + [requestPriority, requestKey] = nextRequest; + requestId = stringToChunkId(requestKey); } } @@ -453,35 +456,36 @@ export default class DataManager { } private onChunkLoad(id: ChunkId, chunk: Chunk) { + const key = chunkIdToString(id); const { data: memory, dtype } = chunk; - this.requests.delete(id); + this.requests.delete(key); if (memory.byteLength > this.limits.size) { - console.error(`received chunk ${chunkIdToString(id)} which is larger than the cache limit`); + console.error(`received chunk ${key} which is larger than the cache limit`); return; } const sourceEntry = this.sources[id.source]; if (sourceEntry === undefined) { - console.error(`received chunk ${chunkIdToString(id)} with invalid source id ${id.source}`); + console.error(`received chunk ${key} with invalid source id ${id.source}`); return; } const data = { state: ChunkState.MEMORY as const, memory, dtype }; - let chunkEntry = this.chunks.get(id); + let chunkEntry = this.chunks.get(key); if (chunkEntry === undefined) { - console.error(`received chunk ${chunkIdToString(id)} without data manager entry`); + console.error(`received chunk ${key} without data manager entry`); chunkEntry = { data, subscriberPriorities: [], priority: { level: ChunkPriorityLevel.RECENT, score: this.recentCounter }, }; - this.chunks.set(id, chunkEntry); + this.chunks.set(key, chunkEntry); this.recentCounter += 1; } else { chunkEntry.data = data; } - this.queues.deviceLoad.insert(id, chunkEntry.priority); - this.queues.evict.insert(id, chunkEntry.priority); + this.queues.deviceLoad.insert(key, chunkEntry.priority); + this.queues.evict.insert(key, chunkEntry.priority); this.memorySize += memory.byteLength; sourceEntry.subscribers.forEach((s) => s.onChunkLoaded?.(id, chunk)); @@ -497,7 +501,8 @@ export default class DataManager { */ queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority) { const subscriberId = this.getIdForSubscriber(subscriber); - const chunkEntry = this.chunks.get(chunkId); + const chunkIdString = chunkIdToString(chunkId); + const chunkEntry = this.chunks.get(chunkIdString); if (chunkEntry === undefined) { const newChunkEntry: ChunkEntry = { @@ -505,8 +510,8 @@ export default class DataManager { subscriberPriorities: [[subscriberId, { ...priority }]], priority: { ...priority }, }; - this.chunks.set(chunkId, newChunkEntry); - this.queues.load.insert(chunkId, priority); + this.chunks.set(chunkIdString, newChunkEntry); + this.queues.load.insert(chunkIdString, priority); } else { const subscriberPriorityIndex = chunkEntry.subscriberPriorities.findIndex(([id]) => id === subscriberId); if (subscriberPriorityIndex === -1) { @@ -515,7 +520,7 @@ export default class DataManager { chunkEntry.subscriberPriorities[subscriberPriorityIndex][1] = priority; } - this.updateChunkPriority(chunkId, chunkEntry); + this.updateChunkPriority(chunkIdString, chunkEntry); } } @@ -526,7 +531,8 @@ export default class DataManager { */ removeChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId) { const subscriberId = this.getIdForSubscriber(subscriber); - const chunkEntry = this.chunks.get(chunkId); + const chunkKey = chunkIdToString(chunkId); + const chunkEntry = this.chunks.get(chunkKey); if (chunkEntry === undefined) { return; } @@ -537,12 +543,13 @@ export default class DataManager { } swapRemove(chunkEntry.subscriberPriorities, index); - this.updateChunkPriority(chunkId, chunkEntry); + this.updateChunkPriority(chunkKey, chunkEntry); } /** Get a chunk's data buffer, if it is in memory. */ getChunkBuffer(chunkId: ChunkId): TypedArray | undefined { - const entry = this.chunks.get(chunkId); + const key = chunkIdToString(chunkId); + const entry = this.chunks.get(key); if (entry === undefined) { return undefined; } @@ -558,7 +565,8 @@ export default class DataManager { /** Get a chunk's texture, if it is on the GPU. */ getChunkTexture(chunkId: ChunkId): Data3DTexture | undefined { - const entry = this.chunks.get(chunkId); + const key = chunkIdToString(chunkId); + const entry = this.chunks.get(key); if (entry?.data.state === ChunkState.DEVICE) { return entry.data.texture; } From bbea5f4c75496fe84d4ae3a7db4fd3d9e7439f9b Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Wed, 26 Aug 2026 11:00:34 -0700 Subject: [PATCH 28/38] fix datamanager closure references --- src/data_manager/DataManager.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 4ad898a4d..d8651e426 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -425,9 +425,11 @@ export default class DataManager { const abortController = new AbortController(); this.requests.set(requestKey, abortController); + // Make a stable by-reference copy of `requestId` for the closures below + const requestedId = requestId; sourceEntry.source - .getChunk(requestId, abortController.signal) - .then((data) => this.onChunkLoad(requestId, data)) + .getChunk(requestedId, abortController.signal) + .then((data) => this.onChunkLoad(requestedId, data)) .catch(() => { this.chunks.delete(requestKey); this.requests.delete(requestKey); From 748a02015107cecd24a408ac3e0c68fae869b256 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Fri, 11 Sep 2026 10:07:55 -0700 Subject: [PATCH 29/38] use proper types in three map --- src/data_manager/DataManager.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index d8651e426..7e218f85e 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -3,10 +3,12 @@ import { Data3DTexture, FloatType, IntType, + type PixelFormat, PixelFormatGPU, RedFormat, RedIntegerFormat, ShortType, + type TextureDataType, UnsignedByteType, UnsignedIntType, UnsignedShortType, @@ -67,7 +69,9 @@ const swapRemove = (arr: T[], index: number) => { }; // TODO these maps should go in a utils module somewhere (src/types.ts?) -const dataTypeToTextureProperties: { [T in Exclude]: [number, number, PixelFormatGPU] } = { +const dataTypeToTextureProperties: { + [T in Exclude]: [TextureDataType, PixelFormat, PixelFormatGPU]; +} = { int8: [ByteType, RedIntegerFormat, "R8I"], int16: [ShortType, RedIntegerFormat, "R16I"], int32: [IntType, RedIntegerFormat, "R32I"], From 377dad53d3fd11368ca93fae1979c5816273d898 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Thu, 24 Sep 2026 18:53:38 -0700 Subject: [PATCH 30/38] abstract away GPU interface with `DeviceInterface` --- src/data_manager/DataManager.ts | 116 +++++++++++++------------------- src/data_manager/types.ts | 17 +++-- 2 files changed, 56 insertions(+), 77 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 7e218f85e..d8b638db4 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -1,20 +1,12 @@ -import { - ByteType, - Data3DTexture, - FloatType, - IntType, - type PixelFormat, - PixelFormatGPU, - RedFormat, - RedIntegerFormat, - ShortType, - type TextureDataType, - UnsignedByteType, - UnsignedIntType, - UnsignedShortType, -} from "three"; - -import type { ChunkId, ChunkPriority, ChunkEntry, DataManagerLimits, LocalChunkId, Chunk } from "./types.js"; +import type { + ChunkId, + ChunkPriority, + ChunkEntry, + DataManagerLimits, + LocalChunkId, + Chunk, + DeviceInterface, +} from "./types.js"; import { chunkIdToString, ChunkState, @@ -28,9 +20,9 @@ import { ChunkPriorityLevel, deviceSizeLimitForPriority, } from "./types.js"; +import type { NumberType, TypedArray } from "../types.js"; import PriorityQueue from "./PriorityQueue.js"; import { VolumeDims } from "../VolumeDims.js"; -import { ARRAY_CONSTRUCTORS, type NumberType, type TypedArray } from "../types.js"; const SUBSCRIBER_ID = Symbol("DataManager.subscriberId"); @@ -43,17 +35,17 @@ export interface IChunkSource { getChunk(id: LocalChunkId, signal?: AbortSignal): Promise>; } -export interface IDataSubscriber { +export interface IDataSubscriber { [SUBSCRIBER_ID]?: number; onChunkLoaded?: (id: ChunkId, chunk: Chunk) => void; - onChunkOnGpu?: (id: ChunkId, texture: Data3DTexture) => void; + onChunkOnGpu?: (id: ChunkId, texture: Tex) => void; // TODO events for when chunks are evicted? } // TODO move to types file? along with above interfaces...? -type SourceEntry = { +type SourceEntry = { source: IChunkSource; - subscribers: IDataSubscriber[]; + subscribers: IDataSubscriber[]; }; const swapRemove = (arr: T[], index: number) => { @@ -68,19 +60,6 @@ const swapRemove = (arr: T[], index: number) => { } }; -// TODO these maps should go in a utils module somewhere (src/types.ts?) -const dataTypeToTextureProperties: { - [T in Exclude]: [TextureDataType, PixelFormat, PixelFormatGPU]; -} = { - int8: [ByteType, RedIntegerFormat, "R8I"], - int16: [ShortType, RedIntegerFormat, "R16I"], - int32: [IntType, RedIntegerFormat, "R32I"], - uint8: [UnsignedByteType, RedIntegerFormat, "R8UI"], - uint16: [UnsignedShortType, RedIntegerFormat, "R16UI"], - uint32: [UnsignedIntType, RedIntegerFormat, "R32UI"], - float32: [FloatType, RedFormat, "R32F"], -}; - const dataTypeToByteLength: { [T in NumberType]: number } = { int8: 1, int16: 2, @@ -105,11 +84,11 @@ class ChunkQueues { deviceEvict: ChunkQueue = new PriorityQueue(reverseComparePriority); } -export default class DataManager { +export default class DataManager { /** Data and current state for every chunk of data tracked and managed by this class. */ - private chunks = new Map(); + private chunks = new Map>(); private queues = new ChunkQueues(); - private sources: SourceEntry[] = []; + private sources: SourceEntry[] = []; /** * Information about each in-flight request. * @@ -127,7 +106,10 @@ export default class DataManager { public limits: DataManagerLimits; - constructor(limits?: Partial) { + constructor( + private deviceInterface: DeviceInterface, + limits?: Partial + ) { this.limits = validateDataManagerLimits({ ...DEFAULT_DATA_MANAGER_LIMITS, ...(limits ?? {}), @@ -135,7 +117,7 @@ export default class DataManager { } /** Gets the id of data subscriber `subscriber`, assigning it one if it doesn't have one. */ - private getIdForSubscriber(subscriber: IDataSubscriber): number { + private getIdForSubscriber(subscriber: IDataSubscriber): number { if (subscriber[SUBSCRIBER_ID] === undefined) { subscriber[SUBSCRIBER_ID] = this.subscriberCount; this.subscriberCount += 1; @@ -148,7 +130,7 @@ export default class DataManager { * * Assumes that the chunk is not in any queues that don't match its state. */ - private updateChunkInQueue(key: string, entry: ChunkEntry) { + private updateChunkInQueue(key: string, entry: ChunkEntry) { switch (entry.data.state) { case ChunkState.QUEUED: this.queues.load.insert(key, entry.priority); @@ -170,7 +152,7 @@ export default class DataManager { } /** Resolves a chunk's overall priority based on all requests for it, then updates its queue position. */ - private updateChunkPriority(key: string, entry: ChunkEntry) { + private updateChunkPriority(key: string, entry: ChunkEntry) { const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { return comparePriority(priority, prevPriority) < 0 ? priority : prevPriority; }, getMinChunkPriority()); @@ -219,9 +201,9 @@ export default class DataManager { * * In other words, this function drives the `deviceLoad` and `deviceEvict` queues. */ - private updateDeviceData() { + private updateDeviceData(deviceHandle: Dev) { // STEP 1: pull eligible chunks out of the `deviceLoad` queue - const loads: [string, ChunkEntry][] = []; + const loads: [string, ChunkEntry][] = []; // This one's easier if we just loop unconditionally and `break` when done. while (true) { const nextLoad = this.queues.deviceLoad.peek(); @@ -281,11 +263,9 @@ export default class DataManager { if (evictEntry !== undefined) { if (evictEntry.data.state === ChunkState.DEVICE) { // this chunk was previously on the GPU; demote to `MEMORY` and destroy its texture - const array = evictEntry.data.texture.image.data as TypedArray | null; - const memory = array ?? new ARRAY_CONSTRUCTORS[evictEntry.data.dtype](0); + const { memory, dtype } = evictEntry.data; this.deviceSize -= memory?.byteLength; - evictEntry.data.texture.dispose(); - const { dtype } = evictEntry.data; + this.deviceInterface.destroyTexture(evictEntry.data.texture); evictEntry.data = { state: ChunkState.MEMORY, memory, dtype }; } else if (evictEntry.data.state === ChunkState.MEMORY) { // this chunk was promoted in the previous step; put it back @@ -320,18 +300,12 @@ export default class DataManager { } const { x, y, z, dataType } = dims; - const [texType, texFormat, texInternalFormat] = dataTypeToTextureProperties[dataType]; - const data = (loadEntry.data as { memory: TypedArray }).memory; - - const texture = new Data3DTexture(data, x, y, z); - texture.type = texType; - texture.format = texFormat; - texture.internalFormat = texInternalFormat; - texture.needsUpdate = true; + const { memory } = loadEntry.data as { memory: TypedArray }; + const texture = this.deviceInterface.createTexture(memory, dataType, [x, y, z], deviceHandle); this.sources[loadId.source].subscribers.forEach((s) => s.onChunkOnGpu?.(loadId, texture)); - loadEntry.data = { state: ChunkState.DEVICE, texture, dtype: dataType }; + loadEntry.data = { state: ChunkState.DEVICE, memory, texture, dtype: dataType }; } } @@ -364,9 +338,9 @@ export default class DataManager { break; case ChunkState.DEVICE: console.error(`chunk ${evictKey} queued for eviction while in the "device" state`); - this.deviceSize -= evictEntry.data.texture.image.data?.byteLength ?? 0; - this.memorySize -= evictEntry.data.texture.image.data?.byteLength ?? 0; - evictEntry.data.texture.dispose(); + this.deviceSize -= evictEntry.data.memory.byteLength; + this.memorySize -= evictEntry.data.memory.byteLength; + this.deviceInterface.destroyTexture(evictEntry.data.texture); this.queues.deviceEvict.remove(evictKey); // TODO if subscribers get "chunk removed from GPU" events, one should go here break; @@ -455,8 +429,10 @@ export default class DataManager { * priority order before submitting. If requests were submitted immediately upon being added to the queue, the first * requests in a batch would submit in the order they were queued, not priority order. */ - update() { - this.updateDeviceData(); + update(deviceHandle?: Dev) { + if (this.deviceInterface.isDeviceHandle(deviceHandle)) { + this.updateDeviceData(deviceHandle); + } this.evictCachedData(); this.submitRequests(); } @@ -505,13 +481,13 @@ export default class DataManager { * If the chunk is not already in memory, this will queue the chunk to be loaded. Chunk load requests are not * submitted until the next call to `update`. */ - queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority) { + queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority) { const subscriberId = this.getIdForSubscriber(subscriber); const chunkIdString = chunkIdToString(chunkId); const chunkEntry = this.chunks.get(chunkIdString); if (chunkEntry === undefined) { - const newChunkEntry: ChunkEntry = { + const newChunkEntry: ChunkEntry = { data: { state: ChunkState.QUEUED }, subscriberPriorities: [[subscriberId, { ...priority }]], priority: { ...priority }, @@ -535,7 +511,7 @@ export default class DataManager { * * The `DataManager` will not respond to this change until the next call to `update`. */ - removeChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId) { + removeChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId) { const subscriberId = this.getIdForSubscriber(subscriber); const chunkKey = chunkIdToString(chunkId); const chunkEntry = this.chunks.get(chunkKey); @@ -560,17 +536,15 @@ export default class DataManager { return undefined; } - if (entry.data.state === ChunkState.MEMORY) { + if (entry.data.state === ChunkState.MEMORY || entry.data.state === ChunkState.DEVICE) { return entry.data.memory; - } else if (entry.data.state === ChunkState.DEVICE) { - return (entry.data.texture.image.data as TypedArray | null) ?? undefined; } return undefined; } /** Get a chunk's texture, if it is on the GPU. */ - getChunkTexture(chunkId: ChunkId): Data3DTexture | undefined { + getChunkTexture(chunkId: ChunkId): Tex | undefined { const key = chunkIdToString(chunkId); const entry = this.chunks.get(key); if (entry?.data.state === ChunkState.DEVICE) { @@ -596,7 +570,7 @@ export default class DataManager { } /** Subscribes `subscriber` to data events from the source with id `sourceId` */ - subscribeToSource(subscriber: IDataSubscriber, sourceId: number): boolean { + subscribeToSource(subscriber: IDataSubscriber, sourceId: number): boolean { const sourceEntry = this.sources[sourceId]; if (sourceEntry === undefined) { return false; @@ -610,7 +584,7 @@ export default class DataManager { } /** Unsubscribes `subscriber` from data events from the source with id `sourceId` */ - unsubscribeFromSource(subscriber: IDataSubscriber, sourceId: number): boolean { + unsubscribeFromSource(subscriber: IDataSubscriber, sourceId: number): boolean { const sourceEntry = this.sources[sourceId]; if (sourceEntry === undefined) { return false; diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index bbe56b066..cffcbe8d6 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -1,4 +1,3 @@ -import type { Data3DTexture } from "three"; import type { NumberType, TypedArray } from "../types.ts"; export type DataManagerLimits = { @@ -45,6 +44,12 @@ export const validateDataManagerLimits = (limits: DataManagerLimits): DataManage concurrentPrefetches: Math.min(limits.concurrentRequests, limits.concurrentPrefetches), }); +export interface DeviceInterface { + isDeviceHandle(val: unknown): val is Dev; + createTexture(data: TypedArray, dtype: NumberType, size: [number, number, number], deviceHandle: Dev): Tex; + destroyTexture(tex: Tex): void; +} + export const enum ChunkPriorityLevel { /** * Chunks that were recently loaded but which aren't currently needed. @@ -105,13 +110,13 @@ export const enum ChunkState { WORKER = "worker", } -export type ChunkData = +export type ChunkData = | { state: ChunkState.QUEUED | ChunkState.WORKER | ChunkState.LOADING } - | { state: ChunkState.MEMORY; memory: TypedArray; dtype: NumberType } - | { state: ChunkState.DEVICE; texture: Data3DTexture; dtype: NumberType }; + | { state: ChunkState.MEMORY; memory: TypedArray; dtype: NumberType } + | { state: ChunkState.DEVICE; memory: TypedArray; dtype: NumberType; texture: Tex }; -export type ChunkEntry = { - data: ChunkData; +export type ChunkEntry = { + data: ChunkData; subscriberPriorities: [number, ChunkPriority][]; priority: ChunkPriority; }; From 317a7e4b6355925440d1db82622dfc8d07a0d540 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Thu, 24 Sep 2026 18:57:41 -0700 Subject: [PATCH 31/38] add `finishUpdate` --- src/data_manager/DataManager.ts | 2 ++ src/data_manager/types.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index d8b638db4..8b0c70f8c 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -307,6 +307,8 @@ export default class DataManager { loadEntry.data = { state: ChunkState.DEVICE, memory, texture, dtype: dataType }; } + + this.deviceInterface.finishUpdate(); } /** diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index cffcbe8d6..e2c69c5b5 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -48,6 +48,7 @@ export interface DeviceInterface { isDeviceHandle(val: unknown): val is Dev; createTexture(data: TypedArray, dtype: NumberType, size: [number, number, number], deviceHandle: Dev): Tex; destroyTexture(tex: Tex): void; + finishUpdate(): void; } export const enum ChunkPriorityLevel { From 826e2f5228cde74b1d11742d0232e2efceb347a9 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Thu, 24 Sep 2026 19:19:37 -0700 Subject: [PATCH 32/38] add distinct priorities for memory and device --- src/data_manager/DataManager.ts | 60 +++++++++++++++++++++------------ src/data_manager/types.ts | 11 ++++-- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 8b0c70f8c..b88ba7ad6 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -133,17 +133,17 @@ export default class DataManager { private updateChunkInQueue(key: string, entry: ChunkEntry) { switch (entry.data.state) { case ChunkState.QUEUED: - this.queues.load.insert(key, entry.priority); + this.queues.load.insert(key, entry.memoryPriority); break; case ChunkState.MEMORY: - this.queues.deviceLoad.insert(key, entry.priority); - this.queues.evict.insert(key, entry.priority); + this.queues.deviceLoad.insert(key, entry.devicePriority); + this.queues.evict.insert(key, entry.memoryPriority); break; case ChunkState.DEVICE: - this.queues.deviceEvict.insert(key, entry.priority); + this.queues.deviceEvict.insert(key, entry.devicePriority); break; case ChunkState.LOADING: - if (entry.priority.level === ChunkPriorityLevel.RECENT) { + if (entry.memoryPriority.level === ChunkPriorityLevel.RECENT) { // TODO cancel request here } break; @@ -153,17 +153,29 @@ export default class DataManager { /** Resolves a chunk's overall priority based on all requests for it, then updates its queue position. */ private updateChunkPriority(key: string, entry: ChunkEntry) { - const nextPriority = entry.subscriberPriorities.reduce((prevPriority, [_, priority]) => { - return comparePriority(priority, prevPriority) < 0 ? priority : prevPriority; - }, getMinChunkPriority()); - - if (nextPriority.level === ChunkPriorityLevel.RECENT) { - nextPriority.score = this.recentCounter; + const [nextMemory, nextDevice] = entry.subscriberPriorities.reduce( + ([prevMemory, prevDevice], { priority, device }) => { + const m = comparePriority(priority, prevMemory) < 0 ? priority : prevMemory; + const d = device && comparePriority(priority, prevDevice) < 0 ? priority : prevDevice; + return [m, d]; + }, + [getMinChunkPriority(), getMinChunkPriority()] + ); + + if (nextDevice.level === ChunkPriorityLevel.RECENT) { + nextMemory.score = this.recentCounter; + if (nextMemory.level === ChunkPriorityLevel.RECENT) { + nextMemory.score = this.recentCounter; + } this.recentCounter += 1; } - if (nextPriority.level !== entry.priority.level || nextPriority.score !== entry.priority.score) { - entry.priority = { ...nextPriority }; + if ( + comparePriority(nextMemory, entry.memoryPriority) !== 0 || + comparePriority(nextDevice, entry.devicePriority) !== 0 + ) { + entry.memoryPriority = { ...nextMemory }; + entry.devicePriority = { ...nextDevice }; this.updateChunkInQueue(key, entry); } } @@ -460,7 +472,8 @@ export default class DataManager { chunkEntry = { data, subscriberPriorities: [], - priority: { level: ChunkPriorityLevel.RECENT, score: this.recentCounter }, + memoryPriority: { level: ChunkPriorityLevel.RECENT, score: this.recentCounter }, + devicePriority: { level: ChunkPriorityLevel.RECENT, score: this.recentCounter }, }; this.chunks.set(key, chunkEntry); this.recentCounter += 1; @@ -468,8 +481,8 @@ export default class DataManager { chunkEntry.data = data; } - this.queues.deviceLoad.insert(key, chunkEntry.priority); - this.queues.evict.insert(key, chunkEntry.priority); + this.queues.deviceLoad.insert(key, chunkEntry.memoryPriority); + this.queues.evict.insert(key, chunkEntry.memoryPriority); this.memorySize += memory.byteLength; sourceEntry.subscribers.forEach((s) => s.onChunkLoaded?.(id, chunk)); @@ -483,7 +496,7 @@ export default class DataManager { * If the chunk is not already in memory, this will queue the chunk to be loaded. Chunk load requests are not * submitted until the next call to `update`. */ - queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority) { + queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority, device: boolean) { const subscriberId = this.getIdForSubscriber(subscriber); const chunkIdString = chunkIdToString(chunkId); const chunkEntry = this.chunks.get(chunkIdString); @@ -491,15 +504,18 @@ export default class DataManager { if (chunkEntry === undefined) { const newChunkEntry: ChunkEntry = { data: { state: ChunkState.QUEUED }, - subscriberPriorities: [[subscriberId, { ...priority }]], - priority: { ...priority }, + subscriberPriorities: [{ subscriberId, priority: { ...priority }, device }], + memoryPriority: { ...priority }, + devicePriority: device ? { ...priority } : getMinChunkPriority(), }; this.chunks.set(chunkIdString, newChunkEntry); this.queues.load.insert(chunkIdString, priority); } else { - const subscriberPriorityIndex = chunkEntry.subscriberPriorities.findIndex(([id]) => id === subscriberId); + const subscriberPriorityIndex = chunkEntry.subscriberPriorities.findIndex( + (entry) => entry.subscriberId === subscriberId + ); if (subscriberPriorityIndex === -1) { - chunkEntry.subscriberPriorities.push([subscriberId, priority]); + chunkEntry.subscriberPriorities.push({ subscriberId, priority: { ...priority }, device }); } else { chunkEntry.subscriberPriorities[subscriberPriorityIndex][1] = priority; } @@ -521,7 +537,7 @@ export default class DataManager { return; } - const index = chunkEntry.subscriberPriorities.findIndex(([id]) => id === subscriberId); + const index = chunkEntry.subscriberPriorities.findIndex((entry) => entry.subscriberId === subscriberId); if (index < 0) { return; } diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index e2c69c5b5..e45f8c327 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -116,10 +116,17 @@ export type ChunkData = | { state: ChunkState.MEMORY; memory: TypedArray; dtype: NumberType } | { state: ChunkState.DEVICE; memory: TypedArray; dtype: NumberType; texture: Tex }; +export type SubscriberPriority = { + subscriberId: number; + priority: ChunkPriority; + device: boolean; +}; + export type ChunkEntry = { data: ChunkData; - subscriberPriorities: [number, ChunkPriority][]; - priority: ChunkPriority; + subscriberPriorities: SubscriberPriority[]; + memoryPriority: ChunkPriority; + devicePriority: ChunkPriority; }; export type LocalChunkId = { From 55aee78288f18ec3abeba7334a117e123c32b098 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Thu, 24 Sep 2026 19:23:58 -0700 Subject: [PATCH 33/38] clean up chunk priority calculation a bit --- src/data_manager/DataManager.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index b88ba7ad6..1bc6badb3 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -153,20 +153,19 @@ export default class DataManager { /** Resolves a chunk's overall priority based on all requests for it, then updates its queue position. */ private updateChunkPriority(key: string, entry: ChunkEntry) { - const [nextMemory, nextDevice] = entry.subscriberPriorities.reduce( - ([prevMemory, prevDevice], { priority, device }) => { - const m = comparePriority(priority, prevMemory) < 0 ? priority : prevMemory; - const d = device && comparePriority(priority, prevDevice) < 0 ? priority : prevDevice; - return [m, d]; - }, - [getMinChunkPriority(), getMinChunkPriority()] - ); - - if (nextDevice.level === ChunkPriorityLevel.RECENT) { - nextMemory.score = this.recentCounter; - if (nextMemory.level === ChunkPriorityLevel.RECENT) { - nextMemory.score = this.recentCounter; + let nextMemory = getMinChunkPriority(); + let nextDevice = getMinChunkPriority(); + entry.subscriberPriorities.forEach(({ priority, device }) => { + if (comparePriority(priority, nextMemory) < 0) { + nextMemory = priority; + } + if (device && comparePriority(priority, nextDevice) < 0) { + nextDevice = priority; } + }); + + if (nextMemory.level === ChunkPriorityLevel.RECENT) { + nextMemory.score = this.recentCounter; this.recentCounter += 1; } From 36ab2d2e1e8ca523a906b786897d5f702c7e50d6 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Sat, 26 Sep 2026 00:08:05 -0700 Subject: [PATCH 34/38] account for edge chunks in dim calculations --- src/data_manager/DataManager.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/DataManager.ts index 1bc6badb3..890eac094 100644 --- a/src/data_manager/DataManager.ts +++ b/src/data_manager/DataManager.ts @@ -179,7 +179,7 @@ export default class DataManager { } } - private getChunkSpatialDims(chunkId: ChunkId): { x: number; y: number; z: number; dataType: NumberType } | undefined { + private getChunkDims(chunkId: ChunkId): { x: number; y: number; z: number; dataType: NumberType } | undefined { const sourceEntry = this.sources[chunkId.source]; if (sourceEntry === undefined) { return undefined; @@ -190,16 +190,20 @@ export default class DataManager { return undefined; } - // TODO fix config so we don't need this - // see @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars - // eslint-disable-next-line - const [_t, _c, z, y, x] = multiscale.chunkShape; - const { dataType } = multiscale; + const { shape, chunkShape, dataType } = multiscale; + const [, , iz, iy, ix] = chunkId.tczyx; + const [, , cz, cy, cx] = chunkShape; + const [, , sz, sy, sx] = shape; + // Chunks that abut the max edge of the volume in any dimension may be smaller in that dimension. + // e.g. for a volume with chunk size 10 & volume size 35 in x, chunks at x = 3 have size 5 in x. + const z = Math.min(cz, sz - cz * iz); + const y = Math.min(cy, sy - cy * iy); + const x = Math.min(cx, sx - cx * ix); return { x, y, z, dataType }; } private estimateChunkSize(chunkId: ChunkId): number { - const dims = this.getChunkSpatialDims(chunkId); + const dims = this.getChunkDims(chunkId); if (dims === undefined) { return 0; } @@ -299,7 +303,7 @@ export default class DataManager { // STEP 3: create textures for newly-promoted chunks for (const [loadKey, loadEntry] of loads) { const loadId = stringToChunkId(loadKey); - const dims = this.getChunkSpatialDims(loadId); + const dims = this.getChunkDims(loadId); if (dims === undefined) { console.error( From 78e71f3fe6b15bdffcbfa101131a37631a0d609e Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Mon, 28 Sep 2026 08:35:49 -0700 Subject: [PATCH 35/38] some docs updates --- src/data_manager/types.ts | 65 +++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/src/data_manager/types.ts b/src/data_manager/types.ts index e45f8c327..39125fcd7 100644 --- a/src/data_manager/types.ts +++ b/src/data_manager/types.ts @@ -1,42 +1,44 @@ import type { NumberType, TypedArray } from "../types.ts"; +/** Settings for the maximum resources a `DataManager` may use (memory, request concurrency, etc.) */ export type DataManagerLimits = { - size: number; - deviceSize: number; - devicePrefetchSize: number; - concurrentRequests: number; - concurrentPrefetches: number; -}; - -const ONE_GIGABYTE = 1024 * 1024 * 1024; - -export const DEFAULT_DATA_MANAGER_LIMITS: DataManagerLimits = { /** The maximum amount of data that can be kept in memory, in bytes. */ - size: ONE_GIGABYTE, + size: number; /** * The maximum amount of data that can be kept on the GPU, in bytes. * * Cannot be set higher than `size`. */ - deviceSize: ONE_GIGABYTE, + deviceSize: number; /** * The maximum amount of data that can be kept on the GPU if at least some GPU data has the `PREFETCH` priority. * * Setting this lower than `deviceSize` keeps the data manager from prematurely filling up GPU memory with prefetched * data that may never be used. Cannot be set higher than `deviceSize`. */ - devicePrefetchSize: ONE_GIGABYTE / 2, + devicePrefetchSize: number; /** The maximum number of concurrent requests. */ - concurrentRequests: 10, + concurrentRequests: number; /** * The maximum number of concurrent requests if at least one request has the `PREFETCH` priority. * * Setting this lower than `concurrentRequests` ensures that some concurrency always remains available for requests * with the `VISIBLE` priority. Cannot be set higher than `concurrentRequests`. */ + concurrentPrefetches: number; +}; + +const ONE_GIGABYTE = 1024 * 1024 * 1024; + +export const DEFAULT_DATA_MANAGER_LIMITS: DataManagerLimits = { + size: ONE_GIGABYTE, + deviceSize: ONE_GIGABYTE, + devicePrefetchSize: ONE_GIGABYTE / 2, + concurrentRequests: 10, concurrentPrefetches: 4, }; +/** Enforces validity requirements on `DataManagerLimits`, e.g. `deviceSize` must not be greater than `size`. */ export const validateDataManagerLimits = (limits: DataManagerLimits): DataManagerLimits => ({ ...limits, deviceSize: Math.min(limits.size, limits.deviceSize), @@ -44,33 +46,47 @@ export const validateDataManagerLimits = (limits: DataManagerLimits): DataManage concurrentPrefetches: Math.min(limits.concurrentRequests, limits.concurrentPrefetches), }); +/** Abstraction over the graphics API or library used by `DataManager` to move chunks to/from the GPU. */ export interface DeviceInterface { + /** + * Type predicate for this interface's *device handle*, for APIs (like WebGPU) that require explicit access to a + * shared resource to perform GPU operations. + */ isDeviceHandle(val: unknown): val is Dev; + /** Create a texture with the given `data`, `dtype`, and `size` using `deviceHandle`. */ createTexture(data: TypedArray, dtype: NumberType, size: [number, number, number], deviceHandle: Dev): Tex; + /** Destroy a texture and clean up its GPU resources. */ destroyTexture(tex: Tex): void; + /** Called when `DataManager` is done with an update cycle, in case the interface has state to clean up. */ finishUpdate(): void; } +/** Reasons that a chunk is tracked by `DataManager`, in increasing order of priority. */ export const enum ChunkPriorityLevel { /** - * Chunks that were recently loaded but which aren't currently needed. + * The chunk was recently loaded but is not currently needed. * - * A chunk is automatically assigned a priority at this level when one or more subscribers request them (at the - * `VISIBLE` or `PREFETCH` level), their data is loaded, and then all requests are removed. Subscribers shouldn't use - * this level explicitly when making requests -- if the chunk isn't already cached, the request will be ignored! + * A chunk is automatically assigned a priority at this level when one or more subscribers request it (at the + * `VISIBLE` or `PREFETCH` level), its data is loaded, and then all requests are removed. Subscribers shouldn't use + * this level explicitly when making requests: if the chunk isn't already cached, the request will be ignored! */ RECENT = 0, /** - * Chunks that aren't currently visible, but that are expected to become necessary in the future. + * The chunk isn't currently visible, but is expected to become necessary in the future. * * Besides being loaded after `VISIBLE` chunks, chunks prioritized at this level may be subject to lower request * quotas and/or memory limits, depending on the `DataManager`'s configuration. */ PREFETCH = 1, - /** Chunks that are currently needed for rendering. */ + /** The chunk is currently needed for rendering. */ VISIBLE = 2, } +/** + * The priority at which a chunk is required. + * + * Chunks are prioritized by their `ChunkPriorityLevel`, then within their level by a numeric score. + */ export type ChunkPriority = { level: ChunkPriorityLevel; score: number; @@ -122,10 +138,19 @@ export type SubscriberPriority = { device: boolean; }; +/** `DataManager`-internal type containing full state of a single chunk. */ export type ChunkEntry = { + /** The current lifecycle state of a chunk, and the data associated with it. */ data: ChunkData; + /** A record of every request for this chunk. */ subscriberPriorities: SubscriberPriority[]; + /** Priority at which the chunk is required in memory. Derived from `subscriberPriorities` on request add/remove. */ memoryPriority: ChunkPriority; + /** + * Priority at which the chunk is required in GPU memory. Derived from `subscriberPriorities` on request add/remove. + * + * Must never be greater than `memoryPriority`. + */ devicePriority: ChunkPriority; }; From f7207a1fa50b5d5391bc8c7e5ffb4a88938daa11 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Tue, 6 Oct 2026 15:57:52 -0700 Subject: [PATCH 36/38] rename to use underscores --- src/data_manager/{DataManager.ts => data_manager.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/data_manager/{DataManager.ts => data_manager.ts} (100%) diff --git a/src/data_manager/DataManager.ts b/src/data_manager/data_manager.ts similarity index 100% rename from src/data_manager/DataManager.ts rename to src/data_manager/data_manager.ts From e0f7041249deff6374a4896c7a35dc5599661e2d Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Tue, 6 Oct 2026 16:08:47 -0700 Subject: [PATCH 37/38] fix import --- src/data_manager/data_manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data_manager/data_manager.ts b/src/data_manager/data_manager.ts index 890eac094..4232e495d 100644 --- a/src/data_manager/data_manager.ts +++ b/src/data_manager/data_manager.ts @@ -21,7 +21,7 @@ import { deviceSizeLimitForPriority, } from "./types.js"; import type { NumberType, TypedArray } from "../types.js"; -import PriorityQueue from "./PriorityQueue.js"; +import PriorityQueue from "./priority_queue.js"; import { VolumeDims } from "../VolumeDims.js"; const SUBSCRIBER_ID = Symbol("DataManager.subscriberId"); From 4f5d8684016fd057c0afe20c96163ae87116a028 Mon Sep 17 00:00:00 2001 From: Cameron Fraser Date: Wed, 7 Oct 2026 13:57:47 -0700 Subject: [PATCH 38/38] rename: `queueChunkRequest` -> `addChunkRequest` --- src/data_manager/data_manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data_manager/data_manager.ts b/src/data_manager/data_manager.ts index 4232e495d..5ce886aa1 100644 --- a/src/data_manager/data_manager.ts +++ b/src/data_manager/data_manager.ts @@ -499,7 +499,7 @@ export default class DataManager { * If the chunk is not already in memory, this will queue the chunk to be loaded. Chunk load requests are not * submitted until the next call to `update`. */ - queueChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority, device: boolean) { + addChunkRequest(subscriber: IDataSubscriber, chunkId: ChunkId, priority: ChunkPriority, device: boolean) { const subscriberId = this.getIdForSubscriber(subscriber); const chunkIdString = chunkIdToString(chunkId); const chunkEntry = this.chunks.get(chunkIdString);