Driving Cesium Primitives from a WebGPU Compute Result

CesiumJS does not hand a custom layer its command encoder. It owns a scene graph, a render loop and a camera, and the seam it offers is the primitive: an object you build, hand to the scene, and update between frames. That makes WebGPU interop a different problem from the MapLibre case — not “how do I draw inside their pass” but “how do I compute something and get it into their primitive before they draw”. This page is the pre-render hook that makes the two loops one, the geocentric frame both sides have to agree on, and the fence that stops a primitive being updated from a buffer the GPU has not finished writing. It is one stage of CesiumJS mapping pipeline optimization.

One loop, driven by the host Two lanes compare an independent animation loop with a pre-render hook. In the independent arrangement the compute loop and Cesium loop run on separate schedules, so the result Cesium draws was computed for a camera position one or two frames stale and varying, and both loops submit command buffers with no ordering between them. In the hooked arrangement Cesium raises pre-render, the compute pass records and submits inside that callback with the camera Cesium is about to use, and a result from two frames earlier is applied to the primitive before Cesium draws. TWO LOOPS vs ONE Independent preRender hook Own rAF loop separate schedule Submits whenever no ordering with Cesium Stale, varying camera lag then snap Cesium raises preRender the only clock Record + submit here this frame’s camera Apply a retired result two frames old, no stall postRender is one frame late by construction — the results would apply to the next draw.
Cesium owns the clock whether or not the application acknowledges it. Registering on its hook is the difference between one ordered frame and two loops competing for the same device.

Runnable reference implementation

Registering on scene.preRender makes Cesium’s loop the only loop. The compute pass runs when Cesium says a frame is starting, and its results are current for exactly that frame.

typescript
function attachComputeDriver(viewer: Cesium.Viewer, device: GPUDevice, driver: ComputeDriver) {
  // Cesium owns the clock. Never also drive requestAnimationFrame.
  viewer.scene.preRender.addEventListener((scene) => {
    const camera = scene.camera;
    // Cesium works in ECEF metres; hand the same frame to the compute pass.
    driver.setCamera(camera.positionWC, camera.directionWC);

    const encoder = device.createCommandEncoder({ label: "cesium-driver" });
    driver.recordPasses(encoder);
    device.queue.submit([encoder.finish()]);

    // The primitive reads results from TWO frames ago; never this frame's.
    const ready = driver.takeRetiredResult();
    if (ready) applyToPrimitive(driver.primitive, ready);
  });
}

The two-frame lag is the load-bearing detail, and it is what the ring of readback buffers exists for. Reading this frame’s result would mean awaiting the GPU inside Cesium’s pre-render callback, which stalls Cesium’s entire frame — the classic mistake in this integration, and one that shows as a frame rate that halves the moment the compute pass is enabled.

typescript
/** Applying a retired result to a Cesium primitive. */
function applyToPrimitive(primitive: Cesium.Primitive, result: Float64Array) {
  // Cesium expects ECEF metres in f64; the compute pass produced f32
  // residuals against an anchor, so widen and re-add here on the host.
  const attr = primitive.getGeometryInstanceAttributes("clusters");
  attr.position = Cesium.Cartesian3.pack(
    Cesium.Cartesian3.fromArray(Array.from(result)), []);
}

Parameter reference

Value Setting here Guidance
Hook scene.preRender Runs before Cesium draws, so results apply to the frame about to render. postRender is one frame late by construction.
Result lag 2 frames A ring of three readback buffers; anything less means awaiting the GPU inside Cesium’s callback.
Coordinate frame ECEF metres Cesium’s native frame. Converting on the GPU is covered under ECEF and ENU conversions.
Precision on the wire back f64 on the host Cesium’s Cartesian3 is double-precision; narrowing to f32 at the boundary reintroduces the jitter the anchor removed.
Own render loop none Cesium’s requestAnimationFrame is the only one; adding a second produces two loops racing.
Why the result is two frames old Three stages of the readback ring. In frame N the compute pass writes into slot N modulo three and the encoder is submitted with no wait. In frame N plus one that submission is still in flight and nothing reads it. In frame N plus two the submission has retired, so slot N can be mapped and read with no stall at all, and the value applied to the primitive is two frames old and entirely accurate. READBACK RING · THREE SLOTS 1 Frame N: write compute into slot N % 3 submit, do not wait 2 Frame N+1: in flight nothing reads it yet the GPU is still working 3 Frame N+2: read mapAsync resolves immediately no stall, two frames stale Three slots is the minimum that guarantees the read target has retired.
Two frames of staleness is imperceptible for cluster positions and aggregate values. Awaiting the GPU inside Cesium’s callback to avoid it costs a visible halving of the frame rate.

Two loops, and why there must be one

The single most common structural mistake here is running an independent animation loop alongside Cesium’s. It looks harmless — the compute pass has nothing to do with rendering — and it produces two specific problems.

The first is temporal mismatch. The compute pass runs on its own schedule, so the result Cesium draws was computed for a camera position between one and two frames stale, varying with how the two loops happen to interleave. During a smooth camera flight that reads as cluster markers that lag and then snap, which is the same jitter signature as a stale-camera problem in any other integration.

The second is contention. Two loops each submitting command buffers means the GPU sees interleaved work from two sources with no ordering between them, and a compute pass can land between Cesium’s own passes in a way that delays its rendering unpredictably. The frame rate becomes noisy rather than slow, which is harder to diagnose.

Registering on preRender fixes both by making Cesium’s loop the only clock. The compute work is recorded and submitted inside the callback, so it is ordered relative to Cesium’s own submission for that frame, and its inputs are the camera Cesium is about to use.

Which primitive type to drive

Cesium offers several primitive types and they differ in how cheaply an update lands, which matters when the update happens every frame.

PointPrimitiveCollection and BillboardCollection are the cheapest to update: they hold per-item position and appearance in typed arrays Cesium owns, and changing one item marks a small range dirty. For a cluster overlay whose positions change per frame, they are the right target.

A general Primitive built from geometry instances is more expensive. Its geometry is compiled into GPU buffers when the primitive is created, and while per-instance attributes can be updated cheaply, the geometry itself cannot — so anything that changes shape rather than transform needs the primitive rebuilt, which is a full recompile.

CustomShader on a Cesium3DTileset is a third route and a genuinely different one: rather than feeding Cesium new positions, it lets a compute result be sampled inside Cesium’s own shading of the tileset. For per-feature colouring driven by a GPU computation — a classification, a density, a query result — it avoids the readback entirely, because the result stays in a buffer Cesium’s shader reads.

The rule that emerges is to match the primitive to what actually changes. Positions changing per frame want a point collection; appearance changing per frame wants a custom shader; geometry changing at all wants a rebuild and should therefore change as rarely as possible.

Failure modes

  • Frame rate halves when the compute pass is enabled. The callback is awaiting a readback. Use the ring and accept a two-frame lag.
  • Cluster markers lag and snap during a camera flight. A second animation loop, or results applied from postRender.
  • Everything is in the right place at the equator and wrong at high latitude. The compute pass worked in a tangent frame while Cesium expects geocentric; the two only agree near the anchor.
  • Positions jitter at building scale. The result was narrowed to f32 at the boundary. Cesium’s Cartesian3 is f64 for a reason; widen before handing it over.
  • The primitive does not update at all. Cesium caches geometry aggressively; updating attributes requires the per-instance attribute API rather than mutating the geometry object.
What each side expects the numbers to be A table of four properties comparing what Cesium expects against what a compute pass naturally produces. Cesium expects geocentric ECEF metres while a compute pass produces residuals against an anchor. Cesium expects double precision while the GPU produces single. Cesium expects positions in its own Cartesian3 type while the GPU produces packed float arrays. Cesium expects updates through the per-instance attribute API while the natural instinct is to mutate the geometry directly. CESIUM EXPECTS · GPU PRODUCES Cesium Compute pass Frame geocentric ECEF anchor residuals Precision f64 f32 Type Cartesian3 packed f32 array Update path instance attributes Mutating the geometry object instead of the instance attributes silently does nothing.
Every row is a conversion at the boundary, and three of the four are places where doing it in the wrong direction loses precision the anchor was there to preserve. Widen first, then convert.

Keeping Cesium’s culling informed

Cesium culls aggressively against its own scene graph, and a primitive whose contents are being rewritten every frame can end up culled on stale bounds — appearing and disappearing as the camera moves in ways that look like a rendering bug and are a bookkeeping one.

The mechanism is the bounding sphere. Every primitive carries one, Cesium tests it against the frustum, and a primitive whose geometry has moved outside its declared sphere is simply not drawn. When the compute pass produces positions that vary — clusters that move as the camera zooms, particles that advect — the sphere has to be updated alongside them.

Computing it is cheap and belongs in the same pass. A parallel reduction over the output positions gives a centre and a radius for a handful of microseconds, and reading them back through the same ring as everything else costs nothing extra. Setting a deliberately generous sphere is the lazy alternative and it works, at the cost of Cesium drawing the primitive in frames where nothing of it is visible.

The related trap is Primitive.show. Toggling it is the cheap way to hide an overlay and it does not release anything, so a pipeline that creates a primitive per layer and hides the inactive ones still holds all their GPU memory. For a handful of layers that is fine; for a layer per query in an exploratory tool it is a leak with a friendly name.

Backend / Python interop note

Cesium’s tile format is 3D Tiles, and a Python service producing them has one decision that dominates this integration: which frame the tile content is expressed in.

3D Tiles content is positioned by a transform on the tile, with vertex data expressed relative to that transform — which is exactly the anchor-and-residual pattern the precision work describes, arrived at independently. A service that honours it produces tiles whose vertices are small numbers and whose transform carries the magnitude, and a compute pass can then work in the tile’s local frame with full f32 precision.

The failure to avoid is a generator that bakes absolute ECEF metres into the vertex data and leaves the transform as identity. It is easier to write, it is valid 3D Tiles, and it hands the client six-million-metre coordinates in a format that will be narrowed to f32 somewhere — producing a building model whose walls are a metre out of plumb. Checking the transform is non-identity is a one-line assertion worth making at load.