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.

Vue Composition API control plane and the WebGPU data plane, joined only by the useRenderLoop rAF tick On the left, Vue's reactive graph is the control plane: declarative layer props flow into ref and reactive camera state, then into a computed projection extent, then into a watch with flush set to post that diffs intent. It decides what the scene contains but never carries the bytes. A thin dashed intent arrow tells the shallowRef-held WebGPU context what to render. On the right the data plane holds a GPUDevice, its GPUQueue, a 16-byte uniform buffer, and a large storage buffer of attributes — all held in shallowRef so Vue never proxies the handles. The only path that actually moves bytes is the useRenderLoop requestAnimationFrame tick, which reads the latest snapshot and performs one queue.writeBuffer and one submit per painted frame. Vue Composition API — control plane layer props (LayerDesc[]) ref / reactive — camera, zoom computed — projection extent watch(flush: 'post')diffs intent → add / remove deltas Decides what the scene contains — never carries the bytes WebGPU context · shallowRef — data plane GPUDevice GPUQueue uniform bufferstruct · 16 B storage bufferattributes · MB Handles never proxied · survive every re-render intent — what to render useRenderLoop · requestAnimationFrame one writeBuffer · one submit per frame latest snapshot queue.writeBuffer the only path that moves bytes

Prerequisites

Before applying these patterns, you should be comfortable with:

  1. WebGPU device acquisition — the navigator.gpu.requestAdapter() / requestDevice() handshake and its lifecycle, covered in initializing WebGPU devices for GIS workloads. This page assumes a GPUDevice already exists and is owned by a composable.
  2. 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.
  3. Buffer alignment rules — typed-array packing must respect WGSL’s memory alignment for spatial data buffers, especially the 16-byte rule for vec3/vec4 and the 256-byte rule for dynamic uniform offsets.
  4. Vue 3.4+ — the Composition API, the distinction between ref/reactive (deep proxies) and shallowRef/shallowReactive (one-level tracking), computed, watch/watchEffect with explicit flush timing, and the onMounted/onUnmounted/onBeforeUnmount lifecycle hooks.
  5. 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().
  6. 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.

ts
// 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.

ts
// 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.

ts
// 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.

wgsl
// 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.

ts
// 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.

ts
// 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 a Proxy get on every element access. During a writeBuffer copy of a few million points, that is millions of trap invocations per frame. shallowRef to 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.writeBuffer copies 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 GPUBindGroup every 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); dispatch Math.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

  • GPUValidationError on writeBuffer — 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 assert data.byteLength + offset <= buffer.size. Wrap suspect regions in device.pushErrorScope('validation') / popErrorScope() during development to surface the exact call.
  • Reactivity stall during camera drag — frame time spikes whenever the user pans. Cause: a GPUBuffer or coordinate array bound to ref/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 to shallowRef and keeping the array non-reactive.
  • OperationError on mapAsyncmapAsync was called on a buffer still in use by an unsubmitted or in-flight command. Cause: mapping a per-frame uniform instead of using queue.writeBuffer, or remapping before onSubmittedWorkDone() resolved. Fix: route small frequent writes through the queue; for staged uploads, await the previous frame’s work-done fence before remapping.
  • Device lostdevice.lost resolves (GPU reset, driver crash, tab backgrounded). Every shallowRef-held handle is now invalid. Fix: await device.value.lost, mark the context dead, and re-run init(); never keep submitting into orphaned handles. If reacquisition fails, engage fallback routing.
  • Orphaned render loop after unmount — a removed <SpatialMap> keeps submitting commands. Cause: requestAnimationFrame started in setup() without an onUnmounted cancelAnimationFrame. Fix: own the loop in useRenderLoop so teardown is guaranteed, and destroy() 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:

python
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

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.

Up: Framework Integration & Backend Synchronization