Vue Wrapper Patterns for Spatial Components
A Vue spatial component has to reconcile two clocks that do not agree. Vue’s reactivity graph decides when effects run, based on dependency tracking through Proxy traps; WebGPU expects work to be encoded and submitted exactly once per frame, aligned to requestAnimationFrame. Bind a GPUBuffer, a device handle, or a five-million-element coordinate array directly to reactive() and every property access pays a proxy get tax, every mutation schedules a re-render, and a camera drag at 120 Hz turns into a reactivity storm that stalls the main thread and thrashes GPU memory. This page establishes a deterministic wrapper boundary so that Vue orchestrates what the spatial scene should contain while raw GPU bytes stay completely outside the reactive system. It is a core building block of Framework Integration & Backend Synchronization, where component state must mirror real-time backend streams without dropping frames.
The governing principle mirrors the one used on the React state hydration side: the Composition API is the control plane, the GPU context is the data plane. Composables decide intent; they never carry the bytes.
Prerequisites
Before applying these patterns, you should be comfortable with:
- WebGPU device acquisition — the
navigator.gpu.requestAdapter()/requestDevice()handshake and its lifecycle, covered in initializing WebGPU devices for GIS workloads. This page assumes aGPUDevicealready exists and is owned by a composable. - The compute and render paths — see WebGPU compute vs render pipeline fundamentals. Spatial wrappers usually drive both: a compute pass to cluster or cull, then a render pass to draw.
- Buffer alignment rules — typed-array packing must respect WGSL’s memory alignment for spatial data buffers, especially the 16-byte rule for
vec3/vec4and the 256-byte rule for dynamic uniform offsets. - Vue 3.4+ — the Composition API, the distinction between
ref/reactive(deep proxies) andshallowRef/shallowReactive(one-level tracking),computed,watch/watchEffectwith explicit flush timing, and theonMounted/onUnmounted/onBeforeUnmountlifecycle hooks. - Browsers — Chrome/Edge 113+ or any Chromium with WebGPU enabled. For environments without it, the wrapper must degrade gracefully through browser support and fallback routing rather than throwing during
setup(). - Data assumptions — spatial state reaching the GPU is expressible as flat typed arrays (
Float32Array,Uint32Array). Reactive objects describing layers and camera are fine; the payloads they reference must be packed before they touch a buffer.
Reactivity Boundaries: API Reference
There is no single rule for “make it reactive.” The correct wrapper picks a reactivity primitive per value based on whether Vue needs to track it, how often it changes, and whether it is a GPU handle that must never be proxied. Choosing wrong is the most common cause of frame-time spikes during interaction.
| Value | Primitive | Why | Trap to avoid |
|---|---|---|---|
GPUDevice, GPUQueue, GPUBuffer, GPUTexture |
shallowRef |
Stored as opaque handles; Vue must hold the reference but never walk into it | ref()/reactive() deep-proxies the handle, breaking identity checks and adding get overhead |
| Active layer id set | shallowReactive(new Set()) or ref<Set> |
One-level membership changes drive declarative diffing | Deep reactivity on a Set of thousands of feature ids |
| Camera, zoom, projection inputs | ref / small reactive |
Cheap scalars Vue should track to trigger uniform writes | None — these are genuinely reactive |
| Derived extent / projection matrix | computed |
Recompute only when camera deps change; memoized | Recomputing inside the render loop every frame |
| Large coordinate / attribute arrays | plain variable held by shallowRef to the buffer, array kept non-reactive |
The bytes belong to the data plane | Wrapping a 40 MB Float32Array in reactive() |
| Per-frame write side effects | watch(src, fn, { flush: 'post' }) |
Run after DOM flush, before the next paint | flush: 'sync' firing mid-mutation; pre racing layout |
The single most important entry: device handles and buffers live in shallowRef, never ref. shallowRef tracks reassignment of .value but does not proxy the object it points to, so device.value.queue is the raw GPUQueue and not a reactive wrapper around it. For the precise semantics of shallow versus deep reactivity, the authoritative reference is the Vue Reactivity Core API.
Implementation Walkthrough
1. Own the context outside the reactivity graph
A useSpatialContext composable initializes the adapter, device, and queue, and exposes only a controlled surface. The handles sit in shallowRef so component code can react to whether a device exists without paying to proxy it. readonly prevents callers from reassigning the handles from outside.
// useSpatialContext.ts
import { shallowRef, readonly } from 'vue';
export function useSpatialContext() {
const device = shallowRef<GPUDevice | null>(null);
const queue = shallowRef<GPUQueue | null>(null);
async function init() {
if (!navigator.gpu) throw new Error('WebGPU unavailable — engage fallback routing');
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('No WebGPU adapter');
const dev = await adapter.requestDevice();
// Assign once; shallowRef tracks the reassignment, not the handle internals.
device.value = dev;
queue.value = dev.queue;
}
// Direct, non-blocking path for small frequent payloads (camera, zoom, time).
function syncToGPU(buffer: GPUBuffer, offset: number, data: ArrayBuffer) {
// offset and byteLength must both be multiples of 4 (WebGPU validation).
queue.value?.writeBuffer(buffer, offset, data);
}
return { device: readonly(device), queue: readonly(queue), init, syncToGPU };
}
The reason syncToGPU exists at all is to keep queue.writeBuffer out of component code, where a reactive coordinate array could otherwise be passed straight in and trigger proxy interception on every element read during the copy.
2. Drive a deterministic frame from the Composition API
Vue’s lifecycle hooks must bracket the render loop, not be the render loop. useRenderLoop abstracts requestAnimationFrame into a single tick and tears it down on unmount so a removed map view never leaves an orphaned loop submitting commands to a dead device.
// useRenderLoop.ts
import { ref, onMounted, onUnmounted } from 'vue';
export function useRenderLoop(onFrame: (dtMs: number) => void) {
const rafId = ref<number | null>(null);
let last = 0;
function tick(now: number) {
onFrame(now - last);
last = now;
rafId.value = requestAnimationFrame(tick);
}
onMounted(() => {
last = performance.now();
rafId.value = requestAnimationFrame(tick);
});
onUnmounted(() => {
if (rafId.value !== null) cancelAnimationFrame(rafId.value);
});
return { isRunning: () => rafId.value !== null };
}
The onFrame callback is where the frame is encoded: hydrate dirty uniforms, encode compute and render passes, and call queue.submit() exactly once. Keeping submission single-shot per frame is what makes GPU/CPU synchronization predictable.
3. Diff declarative layers into incremental passes
Spatial components expose layers as declarative props — a list of layer descriptors the user toggles and reorders. Recreating pipelines on every prop change recompiles shaders and stalls. A useLayerRegistry keeps a shallow set of active layer ids; a watch diffs incoming props against it and emits only the add/remove deltas, so the engine runs an incremental compute pass instead of rebuilding the world.
// useLayerRegistry.ts
import { shallowReactive, watch, type Ref } from 'vue';
interface LayerDesc { id: string; visible: boolean }
export function useLayerRegistry(
props: Ref<LayerDesc[]>,
onAdd: (id: string) => void, // allocate GPUBuffer/GPUTexture for the layer
onRemove: (id: string) => void, // destroy() that layer's resources
) {
const active = shallowReactive(new Set<string>());
watch(props, (next) => {
const wanted = new Set(next.filter(l => l.visible).map(l => l.id));
for (const id of wanted) if (!active.has(id)) { active.add(id); onAdd(id); }
for (const id of [...active]) if (!wanted.has(id)) { active.delete(id); onRemove(id); }
}, { deep: true, flush: 'post' });
return { active };
}
This is the same declarative-to-imperative compilation used for deck.gl layer integration with WebGPU, where layer props become compute passes without redundant context recreation. The wrapper’s job is to make onAdd/onRemove the only place GPU resources are created or destroyed.
4. Cache bind groups and uniforms across frames
Compute shaders for spatial work — point clustering, raster resampling, geodesic distance fields, vector-field interpolation — need a stable bind-group layout and tight uniform packing. WGSL enforces alignment that the JavaScript-side buffer descriptor must mirror exactly; the struct below is ordered so it sums to a single 16-byte uniform block with no explicit padding.
// spatial_cluster.wgsl
// Field order is chosen so the struct sums to exactly 16 bytes
// (the minimum uniform-buffer block size). No explicit padding required.
struct Uniforms {
grid_resolution: vec2<f32>, // offset 0, size 8, align 8
cell_size: f32, // offset 8, size 4, align 4
point_count: u32, // offset 12, size 4, align 4
}; // total 16 bytes, aligned to 16
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<storage, read> input_points: array<vec2<f32>>;
@group(0) @binding(2) var<storage, read_write> cluster_indices: array<u32>;
@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let idx = gid.x;
if (idx >= uniforms.point_count) { return; }
let pos = input_points[idx];
let grid_x = u32(floor(pos.x / uniforms.cell_size));
let grid_y = u32(floor(pos.y / uniforms.cell_size));
cluster_indices[idx] = grid_y * u32(uniforms.grid_resolution.x) + grid_x;
}
A GPUBindGroup is expensive to create relative to a frame budget, so the wrapper caches it and invalidates only when a bound resource changes — a resized storage buffer, a new texture atlas, or a layer add/remove. A computed is the wrong tool here (it would rebuild the bind group on any tracked read); instead, key the cache on the buffer handles themselves and rebuild only when those identities change.
// useBindGroupCache.ts — rebuild only when bound resources change identity
import { shallowRef } from 'vue';
export function useBindGroupCache(device: GPUDevice, layout: GPUBindGroupLayout) {
const cache = shallowRef<GPUBindGroup | null>(null);
let key: GPUBuffer[] = [];
function get(uniforms: GPUBuffer, points: GPUBuffer, out: GPUBuffer) {
const next = [uniforms, points, out];
const stale = next.some((b, i) => b !== key[i]);
if (stale || !cache.value) {
cache.value = device.createBindGroup({
layout,
entries: [
{ binding: 0, resource: { buffer: uniforms } },
{ binding: 1, resource: { buffer: points } },
{ binding: 2, resource: { buffer: out } },
],
});
key = next;
}
return cache.value;
}
return { get };
}
5. Pace backend streams to the frame, not the socket
Spatial payloads from Python backends arrive over WebSockets or HTTP/2 as compact binary (msgpack, or raw Float32Array chunks). Decode off the main thread in a Web Worker, hand the typed array back through a transferable, and write the freshest snapshot once per frame inside onFrame. Gating by a monotonic generation counter discards late frames that would otherwise overwrite newer state.
// inside useRenderLoop's onFrame
let appliedGen = -1;
function onFrame(_dt: number) {
const snap = stream.latest(); // { gen: number, packed: Float32Array }
if (snap && snap.gen > appliedGen) {
queue.value!.writeBuffer(pointBuffer, 0, snap.packed); // dirty-range upload
appliedGen = snap.gen;
}
// encode compute + render here, then submit exactly once:
queue.value!.submit([encoder.finish()]);
}
For binary tile ingest at scale, the staging-buffer path documented in CesiumJS mapping pipeline optimization carries these chunks into GPU memory without a JSON parse step.
Memory and Performance Implications
- VRAM footprint. Per-frame uniforms are trivial (the struct above is 16 bytes). Cost lives in attribute buffers: a 5M-point cloud at
vec2<f32>is 40 MB, doubled to 80 MB if double-buffered. Reserve double-buffering for state that genuinely changes every frame; stream large geometry into one buffer and mutate only the dirty range. - Proxy overhead. A deep
reactive()around a coordinate array adds aProxygeton every element access. During awriteBuffercopy of a few million points, that is millions of trap invocations per frame.shallowRefto the buffer, with the array kept non-reactive, removes the tax entirely — this is the central performance reason for the wrapper boundary. - Transfer cost.
queue.writeBuffercopies into a driver-managed staging area, so a 16-byte uniform write is effectively free while a 40 MB whole-buffer rewrite per frame saturates the PCIe bus. Upload dirty-rectangle slices keyed to changed feature ids instead. - Bind-group churn. Recreating a
GPUBindGroupevery frame shows up as steady CPU time in the profiler. The cache in step 4 collapses it to near-zero on steady-state frames; rebuilds happen only on layer or buffer-identity changes. - Workgroup sizing. The clustering kernel uses
@workgroup_size(256); dispatchMath.ceil(pointCount / 256)workgroups. 64 is the safe cross-vendor default (NVIDIA warp 32, AMD/Intel wavefront 64); 256 raises occupancy for large point sets but tune only after profiling with timestamp queries. - Frame budget. Target the full hydrate → compute → render chain under 16 ms (60 Hz) or 8 ms (120 Hz). Uniform hydration should consume well under 1 ms; if it does not, a reactive value has leaked into the render path.
Failure Modes and Diagnostics
GPUValidationErroronwriteBuffer— offset or size is not a multiple of 4, or the write exceeds the buffer’s allocated size. Cause: a typed-array layout that ignored alignment, or a buffer sized for the wrong vector type. Fix: recheck packing against the memory alignment rules and assertdata.byteLength + offset <= buffer.size. Wrap suspect regions indevice.pushErrorScope('validation')/popErrorScope()during development to surface the exact call.- Reactivity stall during camera drag — frame time spikes whenever the user pans. Cause: a
GPUBufferor coordinate array bound toref/reactive, so each access hits a proxy trap and each mutation schedules a re-render. Detect with the Vue DevTools timeline (renders firing on pointermove); fix by moving the handle toshallowRefand keeping the array non-reactive. OperationErroronmapAsync—mapAsyncwas called on a buffer still in use by an unsubmitted or in-flight command. Cause: mapping a per-frame uniform instead of usingqueue.writeBuffer, or remapping beforeonSubmittedWorkDone()resolved. Fix: route small frequent writes through the queue; for staged uploads, await the previous frame’s work-done fence before remapping.- Device lost —
device.lostresolves (GPU reset, driver crash, tab backgrounded). EveryshallowRef-held handle is now invalid. Fix:await device.value.lost, mark the context dead, and re-runinit(); never keep submitting into orphaned handles. If reacquisition fails, engage fallback routing. - Orphaned render loop after unmount — a removed
<SpatialMap>keeps submitting commands. Cause:requestAnimationFramestarted insetup()without anonUnmountedcancelAnimationFrame. Fix: own the loop inuseRenderLoopso teardown is guaranteed, anddestroy()every buffer and texture in the same hook. - Out-of-order backend frames — late stream frames overwrite newer state. Fix: stamp each hydration with a monotonic generation counter, discard any frame older than the last applied one, and double-buffer so the GPU never reads the buffer being written.
Backend Synchronization
Real-time streams from a Python backend must be paced to the hydration cycle, not the network. Pack tightly on the backend so the bytes map straight onto the GPU buffer layout with no client-side reshaping — the same packing the React state hydration path relies on:
import struct
# One point = lon(f32) + lat(f32). Matches vec2<f32> on the GPU side,
# so the client copies the payload into the storage buffer with no reshape.
POINT_FORMAT = "<2f"
def pack_points(points):
return b"".join(struct.pack(POINT_FORMAT, lon, lat) for lon, lat in points)
On the client, decode in a Web Worker, transfer the typed array back, and let onFrame apply only the freshest generation. Teardown is non-negotiable: an unmounting view must call buffer.destroy() and device.destroy() so GPU memory does not accumulate across a long-running session such as a kiosk dashboard.
Where to Go Next
- React state hydration for GPU contexts — the same control-plane/data-plane split expressed with refs and
useSyncExternalStore. - deck.gl layer integration with WebGPU — binding wrapped buffers into custom layer render passes.
- CesiumJS mapping pipeline optimization — tile streaming and LOD hydration without a JSON parse step.
Conclusion
A Vue spatial wrapper succeeds by keeping the Composition API on the control plane and the GPU context entirely off the reactivity graph. Hold the device, queue, and buffers in shallowRef; derive camera-driven values with computed; diff declarative layer props into incremental passes; cache bind groups on buffer identity; submit once per frame; and gate backend streams by generation counter. Built this way, a GIS dashboard or large mapping surface holds sub-16 ms frames under heavy spatial load — and Vue never re-renders to move a single byte.