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.
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.
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.
/** 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. |
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
f32at the boundary. Cesium’sCartesian3isf64for 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.
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.
Related
- CesiumJS mapping pipeline optimization — the topic this page belongs to.
- Syncing Cesium 3D Tiles with WebGPU compute buffers — the buffer hand-off in detail.
- Converting ECEF and ENU frames in compute shaders — the frame both sides have to agree on.
- MapLibre and Mapbox WebGPU interop — the same problem where the host does share its pass.
- Frame profiling with timestamp queries — confirming the compute pass is not what costs the frame.