Skip to content

Commit ca1b622

Browse files
authored
feat: refactor to share chunk data across multiple layers/viewports (#531)
### Summary This is picking up where #514 left off, basically just that PR rebased on `main`. This PR introduces a `ChunkStore`/`ChunkStoreView` architecture to enable efficient chunk sharing across multiple viewports. Currently, `ChunkManagerSource` conflates two responsibilities: managing a canonical chunk data store (for a given source) and determining viewport-specific chunk visibility. This design prevents resource sharing when multiple viewports render overlapping portions of the same data source. The implementation splits `ChunkManagerSource` into two complementary classes: - `ChunkStore`: Manages the canonical data store and global state for all chunks for a single source by aggregating view state across associated `ChunkStoreView` objects - `ChunkStoreView`: Represents a layer/viewport-specific "view" into that store, calculating LOD, visibility, and prefetch requirements based on its camera and slice position Multiple layers (including in separate viewports) can now share the same `ChunkStore` while each maintains its own `ChunkStoreView`. The `ChunkStore` aggregates chunk requirements across all views: chunks are kept loaded if any view needs them, and disposed when no views reference them. Priority for loading is determined by taking the minimum priority across all views. `ChunkStoreView` maintains an efficient sparse `Map<Chunk, ChunkViewState>` rather than tracking all chunks. View state aggregation happens synchronously during the update cycle, with `maybeForgetChunk()` called inline to clean up no-longer-visible chunks within a view. `chunkManager.update()` is now called *after* rendering, as chunk view states are updated as the runtime iterates over viewports to render. This performs the view state aggregation and ultimately chunk queueing and discarding. ### Related Issue For architecture diagrams and class relationships, see the [multiple viewports tech spec](https://docs.google.com/document/d/1OwGL7KW34hxFAENyOm2WiQ71E58XGu9Y7jKkJlyRx84/edit?tab=t.0). This is the final PR of the multi-viewport implementation plan (see tech spec): - #407: WebGL infrastructure - #419: Viewport class - #451: Viewport events - #506: Idetik API integration - #531 (this PR): `ChunkManagerSource` refactor ### Tests & Checks - Existing chunk management tests updated to use new `ChunkStore`/`ChunkStoreView` API - All tests pass - Existing examples work without modification - Added a second `ChunkedImageLayer` to the multi-viewport example and confirmed it behaves as expected, with chunk memory sharing confirmed (according to stats.js) - Validated that chunks are properly disposed when no views need them (memory goes back down)
1 parent 2b90b5c commit ca1b622

14 files changed

Lines changed: 903 additions & 721 deletions

File tree

packages/core/examples/chunk_streaming/chunk_info_overlay.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,26 @@ export class ChunkInfoOverlay {
1818

1919
public update(idetik: Idetik, _timestamp?: DOMHighResTimeStamp): void {
2020
if (this.textDiv_.style.display === "none") return;
21-
const chunkManagerSource = this.imageLayer_.chunkManagerSource;
22-
if (!chunkManagerSource) {
23-
this.textDiv_.textContent = "No chunk manager source";
21+
const chunkStore = this.imageLayer_.chunkStoreView?.store;
22+
const chunkStoreView = this.imageLayer_.chunkStoreView;
23+
if (!chunkStore || !chunkStoreView) {
24+
this.textDiv_.textContent = "No chunk store";
2425
return;
2526
}
2627

27-
const chunksAtCurrentTime = chunkManagerSource.getChunksAtCurrentTime();
28+
const chunksAtCurrentTime = chunkStore.getChunksAtTime(
29+
chunkStore.getTimeIndex(this.imageLayer_.sliceCoords)
30+
);
2831
if (!chunksAtCurrentTime) {
2932
this.textDiv_.textContent = "No chunks available";
3033
return;
3134
}
3235

3336
const chunkDetails: string[] = [];
34-
const currentLOD = chunkManagerSource.currentLOD;
35-
const renderedChunks = chunkManagerSource.getChunks();
37+
const currentLOD = chunkStoreView.currentLOD;
38+
const renderedChunks = chunkStoreView.getChunksToRender(
39+
this.imageLayer_.sliceCoords
40+
);
3641
const totalChunks = chunksAtCurrentTime.length;
3742

3843
let loadedChunks = 0;
@@ -41,7 +46,7 @@ export class ChunkInfoOverlay {
4146
visible: number;
4247
rendered: number;
4348
prefetched: number;
44-
}[] = Array.from({ length: chunkManagerSource.lodCount }, () => ({
49+
}[] = Array.from({ length: chunkStore.lodCount }, () => ({
4550
visible: 0,
4651
rendered: 0,
4752
prefetched: 0,

packages/core/examples/multi_viewport/index.html

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@
1818
height: 100vh;
1919
}
2020

21+
#viewport-left {
22+
flex: 1;
23+
border: 3px solid black;
24+
}
25+
26+
#right-panel {
27+
flex: 1;
28+
display: flex;
29+
flex-direction: column;
30+
}
31+
2132
.viewport {
2233
flex: 1;
2334
border: 3px solid black;
@@ -39,7 +50,10 @@
3950
<body>
4051
<div id="container">
4152
<div class="viewport" id="viewport-left"></div>
42-
<div class="viewport" id="viewport-right"></div>
53+
<div id="right-panel">
54+
<div class="viewport" id="viewport-top-right"></div>
55+
<div class="viewport" id="viewport-bottom-right"></div>
56+
</div>
4357
</div>
4458
<canvas id="canvas"></canvas>
4559
<script type="module" src="./main.ts"></script>

packages/core/examples/multi_viewport/main.ts

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,29 @@ const zMin = z.translate;
2626
const zMax = z.translate + z.scale * z.shape - z.scale;
2727
const zRange = { min: zMin, max: zMax };
2828

29+
// shared source between viewports
2930
const source = new OmeZarrImageSource(url);
30-
const sliceCoords = { t: 400, z: 200, c: 0 };
31-
const camera2D = new OrthographicCamera(left, right, top, bottom);
32-
const imageLayer = new ChunkedImageLayer({
31+
32+
const camera3D = new PerspectiveCamera();
33+
const volumeLayer = new VolumeLayer();
34+
35+
const sliceCoords1 = { t: 400, z: 200, c: 0 };
36+
const camera2D1 = new OrthographicCamera(left, right, top, bottom);
37+
const imageLayer1 = new ChunkedImageLayer({
3338
source,
34-
sliceCoords,
39+
sliceCoords: sliceCoords1,
3540
policy: createExplorationPolicy(),
3641
channelProps: [{ contrastLimits: [0, 200] }],
3742
});
3843

39-
// TODO: the reason this example works is that the volume viewport uses a perspective camera
40-
// otherwise the ChunkManager update causes interference between the two viewports
41-
// this example can be updated to include multiple views of the same volume once we have
42-
// better handling of multiple viewports using the same source
43-
const camera3D = new PerspectiveCamera();
44+
const sliceCoords2 = { t: 400, z: 300, c: 0 };
45+
const camera2D2 = new OrthographicCamera(left, right, top, bottom);
46+
const imageLayer2 = new ChunkedImageLayer({
47+
source,
48+
sliceCoords: sliceCoords2,
49+
policy: createExplorationPolicy(),
50+
channelProps: [{ contrastLimits: [0, 200] }],
51+
});
4452

4553
new Idetik({
4654
canvas: document.querySelector<HTMLCanvasElement>("#canvas")!,
@@ -50,25 +58,50 @@ new Idetik({
5058
element: document.querySelector<HTMLDivElement>("#viewport-left")!,
5159
camera: camera3D,
5260
cameraControls: new OrbitControls(camera3D, { radius: 3 }),
53-
layers: [new VolumeLayer()],
61+
layers: [volumeLayer],
62+
},
63+
{
64+
id: "slice1",
65+
element: document.querySelector<HTMLDivElement>("#viewport-top-right")!,
66+
camera: camera2D1,
67+
cameraControls: new PanZoomControls(camera2D1),
68+
layers: [imageLayer1],
5469
},
5570
{
56-
id: "slice",
57-
element: document.querySelector<HTMLDivElement>("#viewport-right")!,
58-
camera: camera2D,
59-
cameraControls: new PanZoomControls(camera2D),
60-
layers: [imageLayer],
71+
id: "slice2",
72+
element: document.querySelector<HTMLDivElement>(
73+
"#viewport-bottom-right"
74+
)!,
75+
camera: camera2D2,
76+
cameraControls: new PanZoomControls(camera2D2),
77+
layers: [imageLayer2],
6178
},
6279
],
6380
showStats: true,
6481
}).start();
6582

6683
const gui = new GUI({ width: 300 });
84+
85+
const topRightViewportFolder = gui.addFolder("Top Right Viewport (Slice 1)");
86+
addDimensionSlider({
87+
gui: topRightViewportFolder,
88+
sliceCoords: sliceCoords1,
89+
dimensionName: "z",
90+
minValue: zRange.min,
91+
maxValue: zRange.max,
92+
stepValue: z.scale,
93+
});
94+
topRightViewportFolder.open();
95+
96+
const bottomRightViewportFolder = gui.addFolder(
97+
"Bottom Right Viewport (Slice 2)"
98+
);
6799
addDimensionSlider({
68-
gui,
69-
sliceCoords,
100+
gui: bottomRightViewportFolder,
101+
sliceCoords: sliceCoords2,
70102
dimensionName: "z",
71103
minValue: zRange.min,
72104
maxValue: zRange.max,
73105
stepValue: z.scale,
74106
});
107+
bottomRightViewportFolder.open();
Lines changed: 146 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,181 @@
1-
import { ChunkSource, SliceCoordinates } from "../data/chunk";
2-
import { OrthographicCamera } from "../objects/cameras/orthographic_camera";
1+
import { Logger } from "../utilities/logger";
2+
import { Chunk, ChunkSource } from "../data/chunk";
33
import { ChunkQueue } from "../data/chunk_queue";
4-
import { ChunkManagerSource } from "./chunk_manager_source";
4+
import { ChunkStore } from "./chunk_store";
5+
import { ChunkStoreView } from "./chunk_store_view";
56
import { ImageSourcePolicy } from "./image_source_policy";
67

78
export class ChunkManager {
8-
private readonly sources_ = new Map<ChunkSource, ChunkManagerSource>();
9-
private readonly pendingSources_ = new Map<
10-
ChunkSource,
11-
Promise<ChunkManagerSource>
12-
>();
9+
private readonly stores_ = new Map<ChunkSource, ChunkStore>();
10+
private readonly pendingStores_ = new Map<ChunkSource, Promise<ChunkStore>>();
11+
private readonly views_ = new Map<ChunkStore, ChunkStoreView[]>();
1312
private readonly queue_ = new ChunkQueue();
1413

15-
public async addSource(
14+
public async addView(
1615
source: ChunkSource,
17-
sliceCoords: SliceCoordinates,
1816
policy: ImageSourcePolicy
19-
) {
17+
): Promise<ChunkStoreView> {
18+
const store = await this.addSource(source);
19+
const view = new ChunkStoreView(store, policy);
20+
this.views_.set(store, (this.views_.get(store) ?? []).concat(view));
21+
return view;
22+
}
23+
24+
public removeView(view: ChunkStoreView): void {
25+
const store = view.store;
26+
const source = this.getSourceForStore(store);
27+
28+
const views = this.views_.get(store);
29+
if (!views) {
30+
throw new Error("Cannot remove view: store not managed by ChunkManager");
31+
}
32+
33+
const index = views.indexOf(view);
34+
if (index === -1) {
35+
throw new Error(
36+
`Cannot remove view: view not found in store's view list (source: ${source})`
37+
);
38+
}
39+
40+
const affectedChunks = Array.from(view.chunkViewStates.keys());
41+
views.splice(index, 1);
42+
43+
for (const chunk of affectedChunks) {
44+
this.aggregateChunkViewStates(chunk, store);
45+
}
46+
47+
if (views.length === 0) {
48+
this.stores_.delete(source);
49+
this.views_.delete(store);
50+
}
51+
}
52+
53+
private async addSource(source: ChunkSource): Promise<ChunkStore> {
2054
const existingOrPending =
21-
this.sources_.get(source) ?? this.pendingSources_.get(source);
55+
this.stores_.get(source) ?? this.pendingStores_.get(source);
2256
if (existingOrPending) {
2357
return existingOrPending;
2458
}
2559

26-
const initializeSource = async () => {
60+
const initializeStore = async () => {
2761
const loader = await source.open();
28-
const chunkManagerSource = new ChunkManagerSource(
29-
loader,
30-
sliceCoords,
31-
policy
32-
);
33-
this.sources_.set(source, chunkManagerSource);
34-
this.pendingSources_.delete(source);
35-
return chunkManagerSource;
62+
const store = new ChunkStore(loader);
63+
this.stores_.set(source, store);
64+
this.pendingStores_.delete(source);
65+
return store;
3666
};
3767

38-
const pending = initializeSource();
39-
this.pendingSources_.set(source, pending);
68+
const pending = initializeStore();
69+
this.pendingStores_.set(source, pending);
4070
return pending;
4171
}
4272

43-
public update(camera: OrthographicCamera, bufferWidth: number) {
44-
if (this.sources_.size === 0) return;
45-
46-
if (camera.type !== "OrthographicCamera") {
47-
throw new Error(
48-
"ChunkManager currently supports only orthographic cameras. " +
49-
"Update the implementation before using a perspective camera."
50-
);
73+
private getSourceForStore(store: ChunkStore): ChunkSource {
74+
for (const [source, s] of this.stores_) {
75+
if (s === store) {
76+
return source;
77+
}
5178
}
79+
throw new Error("Source not found for the given store.");
80+
}
5281

53-
const viewBounds2D = camera.getWorldViewRect();
54-
const virtualWidth = Math.abs(viewBounds2D.max[0] - viewBounds2D.min[0]);
55-
const virtualUnitsPerScreenPixel = virtualWidth / bufferWidth;
56-
const lodFactor = Math.log2(1 / virtualUnitsPerScreenPixel);
82+
public update() {
83+
for (const [_, store] of this.stores_) {
84+
const updatedChunks = this.updateAndCollectChunkChanges(store);
5785

58-
for (const [_, source] of this.sources_) {
59-
const updatedChunks = source.updateAndCollectChunkChanges(
60-
lodFactor,
61-
viewBounds2D
62-
);
6386
for (const chunk of updatedChunks) {
6487
if (chunk.priority === null) {
6588
this.queue_.cancel(chunk);
6689
} else if (chunk.state === "queued") {
6790
this.queue_.enqueue(chunk, (signal) =>
68-
source.loadChunkData(chunk, signal)
91+
store.loadChunkData(chunk, signal)
6992
);
7093
}
7194
}
7295
}
7396

7497
this.queue_.flush();
7598
}
99+
100+
private updateAndCollectChunkChanges(store: ChunkStore): Set<Chunk> {
101+
const views = this.views_.get(store);
102+
if (!views) return new Set<Chunk>();
103+
const affectedChunks = new Set<Chunk>();
104+
for (const view of views) {
105+
for (const [chunk, _viewState] of view.chunkViewStates) {
106+
affectedChunks.add(chunk);
107+
}
108+
}
109+
110+
for (const chunk of affectedChunks) {
111+
this.aggregateChunkViewStates(chunk, store);
112+
}
113+
114+
return affectedChunks;
115+
}
116+
117+
private aggregateChunkViewStates(chunk: Chunk, store: ChunkStore): void {
118+
const views = this.views_.get(store);
119+
if (!views) return;
120+
let anyVisible = false;
121+
let anyPrefetch = false;
122+
let minPriority: number | null = null;
123+
let orderKeyForMinPriority: number | null = null;
124+
125+
for (const view of views) {
126+
const viewState = view.chunkViewStates.get(chunk);
127+
if (!viewState) continue;
128+
129+
if (viewState.visible) anyVisible = true;
130+
if (viewState.prefetch) anyPrefetch = true;
131+
132+
if (viewState.priority !== null) {
133+
if (minPriority === null || viewState.priority < minPriority) {
134+
minPriority = viewState.priority;
135+
orderKeyForMinPriority = viewState.orderKey;
136+
}
137+
}
138+
139+
if (
140+
!viewState.visible &&
141+
!viewState.prefetch &&
142+
viewState.priority === null
143+
) {
144+
view.maybeForgetChunk(chunk);
145+
}
146+
}
147+
148+
chunk.visible = anyVisible;
149+
chunk.prefetch = anyPrefetch;
150+
chunk.priority = minPriority;
151+
chunk.orderKey = orderKeyForMinPriority;
152+
153+
const shouldEnqueueChunk =
154+
chunk.priority !== null && chunk.state === "unloaded";
155+
if (shouldEnqueueChunk) {
156+
chunk.state = "queued";
157+
return;
158+
}
159+
160+
const shouldCancelQueuedChunk =
161+
chunk.priority === null && chunk.state === "queued";
162+
if (shouldCancelQueuedChunk) {
163+
chunk.state = "unloaded";
164+
return;
165+
}
166+
167+
const shouldDisposeChunk =
168+
chunk.state === "loaded" && !chunk.visible && !chunk.prefetch;
169+
if (shouldDisposeChunk) {
170+
chunk.data = undefined;
171+
chunk.state = "unloaded";
172+
chunk.priority = null;
173+
chunk.orderKey = null;
174+
chunk.prefetch = false;
175+
Logger.debug(
176+
"ChunkManager",
177+
`Disposing chunk ${JSON.stringify(chunk.chunkIndex)} in LOD ${chunk.lod}`
178+
);
179+
}
180+
}
76181
}

0 commit comments

Comments
 (0)