Skip to content

Architecture Notes

Working notes on the layer/view split, overlay state model, and per-frame protocol. Where Design Philosophy explains what and why, this page explains how.

Overlays and view-local presentation

Galavi has three distinct kinds of state, only one of which is portable:

  1. Shared scene State — physical space, layers, camera, exploration. The document. Round-trips through getState() / setState(). Two peers rendering the same dataset agree on this.

  2. View-scoped runtime state — the BaseView instance fields: GPU pipelines, bound canvas, controls, dirty flags. Local to the process; never serialized.

  3. View-local presentation state — overlay visibility, marker shape, scalebar styling, label position. Owned by the overlay instance. Not in State. Mutated at runtime via Galavi.view(id).setOverlayOptions(type, opts).

The third tier is the easy one to get wrong. Overlays sit on top of a view and they read shared State in onRender(state) (e.g. MarkerOverlay projects state.exploration.camera.target to screen space). That makes them sound like pure projections — and on the read side they are. But on the write side they own UI state that has no business being in the scene document:

  • Two clients sharing the same State may legitimately disagree on whether the scale bar is showing.
  • A "marker visible" toggle on view A does not, and should not, propagate to view B over the network.
  • Restoring a saved scene should not reset the user's overlay preferences.

So: overlays read State, but their own presentation options live next to the overlay, not in State. The runtime channel is BaseOverlay.setOptions(opts), exposed on the public API as:

ts
galavi.view("main").setOverlayOptions("marker", { visible: true });

ViewConfig.overlays provides the initial values; setOverlayOptions mutates them at runtime. Neither path touches State, so no commit / notify cycle runs — setOverlayOptions calls requestRender() directly.

What goes where

ConcernLives in
Camera position / targetState.exploration.camera
LOD level / modeState.exploration.lod
Layer order, opacity, contrastState.layers[i]
Which overlays exist on a viewViewConfig.overlays (construction)
Whether the marker is visible nowoverlay options (via setOptions)
Marker shape / precisionoverlay options (via setOptions)
GPU pipelines, bind groupsBaseView instance

If a future feature wants to share overlay state across peers (e.g. an "annotation" overlay where the dots themselves are the data), that data should become a real layer or a real field of State — it should not be smuggled in as overlay options.

Layers and views: data/render split

Design Philosophy says layers are pure data and views own GPU residency. This is how that split is actually wired.

Layer side (data only)

A BaseLayer instance never holds a GPUDevice, a GPUBuffer, or a TilePool. It exposes:

  • getGeometry(), getShader(), getParams(), getStorageData() — what to bind for this layer's pipeline.
  • getTileSpec(): TileSpec | null — for tiled image layers, the residency descriptor (tileSize, gridCells, format, bytesPerTexel, maxPoolSize). Pure constructor-time + class constants; never depends on the current data identity.
  • planTiles(target, effectiveScale, options): TileFramePlan | null — per frame, returns the desired TilePlan (one tile per grid cell), the TileLoader to resolve each tile, and an optional in-bounds filter.
  • geometryVersion, colormapVersion, dataVersion — monotonic counters read by the view's per-layer renderer to decide what to invalidate.
  • attach(requestRender) / detach() — owner-injected render-request channel, used by async loaders (data fetch, surface load) to ask for a repaint without re-running commit.

View side (all the GPU)

Each view has a private ImagePipeline that maintains one LayerRenderer per layer in its layer list. The renderer owns:

  • the GPURenderPipeline and bind groups,
  • the params/model/storage GPUBuffers,
  • the colormap GPUTexture,
  • for tiled layers, the entire TileManager + TilePool (texture, slot ring, pending-tiles queue, per-frame commit/pump).

Per-frame protocol:

  1. host calls pipeline.markDirty() whenever the view's layer set changes,
  2. pipeline.sync(layers) reconciles renderers (rebuild on geometry/blending drift; soft-reset tile residency on dataVersion drift; in-place LUT rewrite on colormap drift),
  3. pipeline.updateTiles(layers, effectiveScale, localTarget, opts) asks each layer for its TileFramePlan and commits to that renderer's TileManager,
  4. pipeline.writeFrameUniforms(layers, beforeWrite?) writes per-layer params + model uniforms,
  5. pipeline.draw(pass, layers) emits sorted, visible draw calls.

Reuse over rebuild

The pool's structure is fixed by getTileSpec(), which depends only on constructor-time fields and class constants. Source / selection / sliceIndex changes never need a different pool shape. So:

  • dataVersion drift → tileManager.reset() only. Pool texture, pipeline, bind groups, colormap texture, params/model buffers all survive.
  • geometryVersion or blending drift → full renderer rebuild. (Tiled layers in current code don't bump geometryVersion; this path is for surfaces, points, vectors, …)
  • colormapVersion drift → writeTexture into the existing colormap LUT.

Why integer version counters, not booleans or callbacks

geometryVersion, colormapVersion, dataVersion are monotonic ints, not isDirty booleans or onChange callbacks. The reason is per-consumer cache invalidation: the same layer can be cached by multiple LayerRenderers (one per view), and each renderer needs to decide independently whether its cache is stale.

  • A boolean flag breaks for >1 consumer: whichever renderer reads-and-clears it first hides the change from the others.
  • A push-style callback would force the layer to hold references to its consumers, re-introducing the layer → view coupling the pure-data refactor removed. Push is right for one-shot events ("a tile finished uploading, repaint" — that's requestRender); pull-with-version is right for cache invalidation.
  • An int counter + a per-consumer lastSeen* field has no clear/reset step at all; the comparison itself is the read.

Rule of thumb:

  • Per-consumer cache invalidation → integer version.
  • One-shot "something happened, please react" → injected callback.
  • Boolean flags → only when the flag is the data (e.g. visible, isReady).

One layer in many views

A single BaseLayer instance can appear in multiple views' layer lists. The explorer renders the same volume layer simultaneously in three slice views, a volume view, and a navigator. This is supported, not banned.

Why it's safe:

  • JS is single-threaded; views render sequentially within a frame. There is no concurrent mutation.
  • After the pure-data refactor, every piece of GPU state lives on the view-side LayerRenderer. Two views over the same layer get independent TilePools, bind groups, params/model buffers, and active pyramid levels. They cannot interfere at the GPU level.
  • The per-frame sequence inside one view — syncupdateTileswriteFrameUniformsdraw — runs to completion before the next view's sequence starts. Layer-level scratch fields used during planning (currentLevel, params.viewport) are read back into that view's params buffer before the next view touches them.

What you must not do:

  • Don't read layer-level scratch fields (getCurrentLevel(), params.viewport) from outside the per-view plan→draw window and treat them as "where is layer X drawing?" They are last-writer-wins across views and reflect whichever view ran most recently. If you need that information for a UX surface, query the view, not the layer.

Released under the GPL-3.0 License.