-
Notifications
You must be signed in to change notification settings - Fork 1
Architecture
copcesium follows the three-layer pipeline the COPC-visualization brief recommends: a Data Layer that parses and decodes, a Cache & Scheduler on the main thread that decides what to draw and keeps memory bounded, and a Render Layer that pushes GPU draw commands. This page traces how a single octree node travels through those layers.

Everything expensive happens off the main thread. src/worker/worker.ts runs inside a pool of Workers (src/worker/WorkerPool.ts) and, per node:
-
Fetches point data with
Copc.loadPointDataView(url, copc, node)fromcopc.js— a single HTTP Range Request for that node's byte span. -
Decodes LAZ via
laz-perfWASM. The WASM module is compiled once per Worker and reused — recompiling per node is exactly what the pool exists to avoid. -
Reprojects each point: source CRS →
EPSG:4326lon/lat (proj4) → ECEF, using WGS84 ellipsoid math done by hand (the Worker never constructs a Cesium object). -
Colors each point: RGB if present (with 16-bit → 8-bit auto-scaling), else an ASPRS classification color table, else a flat gray. Raw
Intensity,Classification, and a header-range-normalized elevation also ship alongside the baked color — that's what letscolorModeswitch modes as a GPU uniform update instead of a re-decode (see Styling in the README).
convertNode() (src/worker/worker.ts) does this end to end. The result — { positions: Float32Array, origin: [number, number, number], colors: Uint8Array, intensities, classifications, elevations, pointCount, maxIntensity } — is transferred (not copied) back to the main thread via the message's transfer list. Positions leave the Worker as Float32 offsets from the node's own origin (its first point's ECEF), with that origin carried separately at double precision — see Relative-to-eye precision below for why.
Each Worker pays a one-time cost to compile the laz-perf WASM. A pool of concurrency (default 5) Workers amortizes that across the whole session and decodes up to 5 nodes in parallel without blocking the UI.
CopcDataSource owns the scheduling. Two listeners drive it:
-
scene.preRender(every frame) runs_updateVisibility()— a cheap pass that only re-tests already-selected nodes against the live frustum and togglesshow. No BFS, no allocation, no loading. This catches camera changes between the throttled LoD passes. - The expensive LoD re-selection (
_updateLoD()) is throttled todebounceMs(default 100 ms) onpreRender, and also fired immediately oncamera.moveEndso the final, resting view always gets its full refinement.
src/lod/selectNodes.ts walks the octree from the root, expanding the node with the highest screen-space error first using a max-heap. A node is subdivided further when its SSE exceeds sseThreshold and it has children with data — but every node the traversal visits along the way is selected, not just the ones it stops at. A COPC octree stores each point in exactly one node, so a node's points are not a coarse copy of its children's; they're distinct points interleaved through the same volume, and the cloud rendered is the union of the root down through the current cut. Selecting only the frontier would silently drop everything held above it. Nodes outside the frustum are dropped with their whole subtree, and a node with zero points is never selected (though it's still expanded so its populated children are reached). The walk stops once either maxVisibleNodes or maxPoints (the render set's total point count across selected nodes, since node point counts vary widely across a hierarchy) is hit, so the budget cutoff keeps the most visually important nodes rather than whatever a FIFO order reached first — and since a parent is always popped before its children are pushed, the cutoff can only trim depth off the bottom, never leave a descendant selected without the ancestors it sits on top of.
A hierarchy that spans multiple pages doesn't need every page loaded up front: a child key found in pages but not yet in nodes triggers onPageNeeded, and CopcDataSource loads that sub-page lazily, merges it into the live node map, and re-runs selection — so zooming into a deep region reveals more detail as its page arrives instead of stopping wherever the root page's depth ran out.
When the selection changes, copcesium shows the new set before hiding the old. A deselected node is only hidden once its replacement — anything in the new selection that covers the same volume, at any depth (a descendant on subdivision or an ancestor on merge, matched via octree containment, not just a direct parent/child) — is cached and actually shown. Until then the old node stays visible and pinned. Matching at any depth (not just one level) matters because a single LoD pass routinely moves the cut two or more levels at once — one mouse-wheel notch is worth roughly 1–1.5 levels of screen-space error, and debounceMs collapses several notches into one pass. A LoD transition therefore never leaves a gap where neither the old nor the new detail level is on screen.
src/cache/NodeCache.ts is an LRU keyed by node. maxCacheNodes (default 150) caps retained nodes; the least-recently-used unselected node is torn down (its GPU primitive removed) when the cap is exceeded. Currently-selected nodes are pinned and never evicted out from under the renderer.
CopcDataSource also keeps its own small LRU of computed Cesium.BoundingSpheres, separate from NodeCache, capped well above maxCacheNodes (5000 entries): every candidate selectNodes() touches while walking the hierarchy gets a sphere computed, not just the currently-loaded/visible set, so this cache sees a much larger working set per LoD pass. Unlike node data, a bounding sphere is pure arithmetic with no I/O or GPU work, so it needs no pinning — evicting one just means it's recomputed on the next call.
src/renderer/PointCloudPrimitive.ts is a hand-rolled Cesium primitive built on the low-level DrawCommand / VertexArray API rather than Cesium.Primitive (which allocates a JS object per point). Each node uploads its buffers as a single GPU vertex array and draws PrimitiveType.POINTS.
ECEF coordinates are large (millions of meters), and GPU floats are 32-bit — rendering them directly produces jitter. Rather than splitting each point into a Cesium-style high/low Float32 pair, precision lives in the node's origin: the Worker already emits positions as Float32 offsets from the node's first point (in ECEF meters, well within Float32 precision at a single node's extent), and that origin is carried separately at double precision. PointCloudPrimitive bakes the origin straight into the primitive's model matrix (Cesium.Matrix4.fromTranslation), and the vertex shader reconstructs an eye-relative position directly from the node-relative offset and czm_encodedCameraPositionMCHigh/MCLow, feeding czm_modelViewProjectionRelativeToEye. No EncodedCartesian3.encode split runs on the main thread at all — halving the Worker→main transfer (12 vs. 24 bytes/point) along with it.
Once uploaded, the CPU-side TypedArrays are released. GPU init is lazy (first update() call, when frameState.context is available) and failure-tolerant: a GPU error excludes just that node instead of aborting Cesium's frame loop.
- Camera moves →
moveEnd/preRenderfires. -
selectNodes()returns the key set for the current view. - New keys not in cache and not already pending are dispatched to the
WorkerPool; any still-pending key that dropped out of the new selection is cancelled instead, freeing its worker slot for the current selection. - A Worker fetches (Range Request), decodes (laz-perf), reprojects (proj4→ECEF), colors, and transfers
NodeRenderDataback. - Main thread builds a
PointCloudPrimitive, adds it to the scene, and caches it. - Reconciliation shows the new node and hides the now-covered old one; the LRU evicts anything over budget.
| File | Layer | Role |
|---|---|---|
src/CopcDataSource.ts |
Scheduler | Public API, listeners, LoD reconciliation, load dispatch |
src/copc/hierarchy.ts, node.ts, key.ts
|
Data | COPC hierarchy load, octree key parsing/math |
src/worker/worker.ts |
Data | Fetch + decode + reproject + color |
src/worker/WorkerPool.ts |
Data | Reused Worker pool, request/response protocol |
src/lod/selectNodes.ts |
Scheduler | SSE max-heap octree traversal |
src/lod/screenSpaceError.ts, boundingVolume.ts
|
Scheduler | SSE math, frustum culling, bounding spheres |
src/cache/NodeCache.ts |
Scheduler | LRU + pin |
src/crs/detectCrs.ts, projections.ts, epsgTable.ts, project.ts
|
Data | CRS auto-detection, proj4 fallback table |
src/renderer/PointCloudPrimitive.ts, shaders.ts
|
Render | DrawCommand primitive, origin-based RTE precision, shaders |