Architecture - Jangmyun/copcesium GitHub Wiki
Architecture
copcesium follows the three-layer pipeline the COPC-visualization brief recommends: a Data Layer that fetches, 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.
┌─────────────────────────────────────────────┐
camera moves → │ CopcDataSource (main thread) │
│ • preRender → cheap frustum visibility │
│ • moveEnd → LoD re-selection │
└───────────────┬─────────────────────────────┘
│ selectNodes() picks keys
│ (+ prefetch candidates, needed hierarchy pages)
▼
┌───────────────────────────────────────────────────────┐
│ Cache & Scheduler (main thread) │
│ • NodeCache (LRU + pin, node- and byte-bounded) │
│ • pendingKeys de-dup, stale-load cancellation │
│ • RangeFetcher: merged HTTP Range Requests, │
│ bounded concurrency, retry with backoff │
└───────────────┬───────────────────────────────────────┘
│ NodeConversionPayload (compressed bytes — transferred)
▼
┌───────────────────────────────────────────────────────┐
│ Data Layer (WorkerPool × concurrency) │
│ • laz-perf WASM decode │
│ • proj4 → lon/lat → ECEF │
│ • node-relative Float32 offsets + Float64 origin │
│ • baked RGB colour + raw intensity/class/elevation │
└───────────────┬───────────────────────────────────────┘
│ NodeRenderData — transferred
▼
┌───────────────────────────────────────────────────────┐
│ Render Layer (main thread → GPU) │
│ • PointCloudPrimitive │
│ • origin in the model matrix (+ live heightOffset) │
│ • DrawCommand + VertexArray, POINTS │
│ • colour mode / filter / size / opacity as uniforms │
└───────────────────────────────────────────────────────┘
Layer 0 — Metadata (load())
Before any node is streamed, src/copc/hierarchy.ts (loadCopcHierarchy()):
- Probes for Range Request support — a
Range: bytes=0-0request that must come back206 Partial Content. A server that ignoresRangeand returns the whole file as200 OKfails here with a clear error instead of a confusing parse failure deep insideCopc.create(). - Reads the file size from that response's
Content-Range(falling back to aHEADrequest'sContent-Lengthwhen CORS hidesContent-Range), forstats.fileBytes. - Reads the COPC header/VLRs and the root hierarchy page via
Copc.create()/Copc.loadHierarchyPage(), using a status-checked, byte-counting getter (src/copc/TransferCounter.ts) rather thancopc's default one, which never checks the HTTP status.
The root page may not cover the whole octree: deeper subtrees can live in hierarchy sub-pages. Their entry points are kept, and loaded lazily later (see Hierarchy sub-pages).
CRS detection (src/crs/detectCrs.ts) then runs on the file's WKT — see Coordinate Systems.
Layer 1 — Data Layer
Fetch (main thread, RangeFetcher)
Each node's compressed bytes are fetched on the main thread, before the decode is handed to a worker. src/copc/RangeFetcher.ts:
- Merges adjacent ranges.
_updateLoD()dispatches every newly-selected node in one synchronous pass, so requests queued in the same microtask tick are exactly the sibling nodes a COPC writer lays out contiguously. Contiguous ranges are merged into one HTTP Range Request (capped at 512 KiB per group, so one slow response can't hold back an unbounded batch), and the response is sliced back apart. - Bounds in-flight requests to
maxConcurrentRequests(which defaults to the worker pool's size). - Insists on
206. Any other status, including a200from a server that ignoredRange, is an error rather than bytes to decode. - Retries what a retry can fix.
5xxand network-level failures are retried up to 3 times with cancellable exponential backoff (250 ms, 500 ms). A4xx(expired pre-signed URL, bad range) fails immediately. - Can be cancelled. Cancelling every member of a merged group aborts the underlying fetch.
Decode (Web Worker)
src/worker/worker.ts runs inside a pool of Workers (src/worker/WorkerPool.ts) and, per node:
- 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. The WASM binary is embedded in the Worker as base64 at build time, so nothing is fetched at runtime. - Reprojects each point: source CRS →
EPSG:4326lon/lat (proj4) → ECEF, using WGS84 ellipsoid math done by hand (the Worker never constructs a Cesium object). Height isz * zFactor + geoidOffset. - Packs positions as node-relative
Float32offsets from the node's origin (its first point's ECEF), with the double-precision origin returned separately. This is 12 bytes/point instead of 24, and precision lives in the origin (see Precision). - Bakes a default colour: RGB if present (with 16-bit → 8-bit auto-scaling), else the ASPRS classification palette, else flat grey.
- Ships the raw attributes too —
Intensity(Uint16),Classification(Uint8), and elevation normalized over the header's Z range (Uint16) — so every styling change can be a uniform update on the GPU instead of a re-decode.
The result (NodeRenderData) is transferred (not copied) back to the main thread via the message's transfer list.
Why a Worker pool and not one Worker
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. The pool can also be created once and passed to several load() calls (fourth argument) so reloads don't re-spawn workers.
WorkerPool is defensive: a task with no reply within 30 s is rejected and its slot freed, and a Worker that throws is terminated and replaced (up to 10 replacements).
Layer 2 — Cache & Scheduler (main thread)
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 octree walk, 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.
Camera position, direction and up are always read in world coordinates (positionWC/directionWC/upWC), so culling and screen-space error stay correct after camera.lookAt() changes the camera's reference frame.
Node selection
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 at maxVisibleNodes or maxPoints, whichever is hit first, 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.
Prefetching
With prefetchFrustumFactor > 1 (default 1.8), selection also tests nodes against a widened frustum — the near-plane width and height scaled by that factor. A populated node inside the widened frustum but outside the real one is reported as a prefetch candidate: it uses the same SSE rule (so it's fetched at roughly the level of detail it will be drawn at), but it is never shown. The walk keeps going past the visible budget only to find these, up to maxPrefetchNodes (default 50) per pass.
Prefetch never gets in the way of what's on screen:
- A candidate is only dispatched once the visible loads have claimed their slots, and only while fewer loads are pending than the pool's
concurrency. - A prefetched node gets a cache recency bump, not a pin — pinning up to
maxVisibleNodes + maxPrefetchNodeskeys could leave nothing evictable. - A pending load is not cancelled just because it is outside the real frustum, as long as it is still a prefetch candidate.
Hierarchy sub-pages
When the walk reaches a child whose subtree lives in a not-yet-loaded hierarchy sub-page, it reports that page instead of treating the child as a leaf. CopcDataSource loads the page in the background, merges its nodes into the live node map (so maxDepth grows), and re-runs selection. This is what lets large, multi-page files keep subdividing past the depth the root page covered.
A failing page is retried at most 3 times with backoff (1 s, then 2 s); a failure more than 60 s after the previous one counts as a new incident. A permanent 4xx gives up on the first attempt. Giving up is permanent for that subtree for the session, and is logged once as an error.
Cancelling stale loads
Each pass, any still-pending load whose key is neither in the new selection nor a prefetch candidate is cancelled — its Range Request aborted, or its queued worker task dropped — so a worker slot isn't spent decoding something the camera has already left. Cancellation surfaces as an AbortError, which is expected and not logged.
No flash of empty space
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 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. Candidates are bucketed by depth once per pass, so a deselected node skips same-depth keys outright. A LoD transition therefore never leaves a gap where neither the old nor the new detail level is on screen.
Bounded memory
src/cache/NodeCache.ts is an LRU keyed by node. It evicts the least-recently-used unselected node (removing its GPU primitive) whenever either limit is exceeded:
maxCacheNodes(default 150) — node count.maxCacheBytes(unset by default) — estimated bytes, atpointCount × 21per node (positions 12 B + colour 4 B + intensity 2 B + classification 1 B + elevation 2 B).
Currently-shown nodes are pinned and never evicted out from under the renderer. Pinning also refreshes their recency, so eviction is least-recently-selected at debounceMs granularity — without that, eviction would be FIFO by load time and would throw away exactly the shallow ancestors a zoom-out needs back.
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.
Layer 3 — Render Layer (GPU)
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 five attribute buffers (position, colour, intensity, classification, elevation) as a single vertex array and draws PrimitiveType.POINTS.
Precision
ECEF coordinates are large (millions of meters), and GPU floats are 32-bit — rendering them directly produces jitter. Instead of splitting every coordinate into a high/low pair, positions arrive as small Float32 offsets from the node origin, and the double-precision origin is carried in the draw command's model matrix. The vertex shader reconstructs eye-relative coordinates from the offset and Cesium's encoded camera position (czm_encodedCameraPositionMCHigh/Low) and projects with czm_modelViewProjectionRelativeToEye. Offsets stay within a node's extent, so a Float32 holds them to sub-millimeter precision.
heightOffset is applied the same way: it shifts the model matrix along the node's local "up", so changing it moves every node on the next frame without touching geometry.
Styling in the shader
Every style setting is a uniform read through a shared style object, so changing it is a uniform update on the next frame — no refetch, no re-decode, no cache invalidation:
colorModepicks between the baked colour, an intensity ramp overintensityRange, the classification palette, or an elevation ramp.classificationFilteris a 256-bit allow-list; filtered points are moved outside clip space in the vertex shader (they still occupy GPU memory).pointSizeMode: 'attenuated'computesgl_PointSize = clamp(attenuationFactor / sqrt(distance_m), minPixelSize, maxPixelSize);'fixed'usespixelSize.opacity < 1switches the command toPass.TRANSLUCENTwith alpha blending and no depth writes (no per-point depth sort).
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. Once uploaded, the CPU-side TypedArrays are released.
Measuring it: stats
dataSource.stats reports what the pipeline actually spent: fileBytes, transferredBytes and requestCount across every request path (the probe, header, hierarchy pages, and node data — a merged fetch counts once), pendingNodes, and fetch/decode/upload timings (count, plus p50/p95 over the most recent 256 nodes). upload is timed on the first frame a node is actually drawn, so upload.count trails decode.count by the nodes that were decoded but never shown. Each stage is also emitted as a performance.measure (copcesium fetch / decode / upload) for the DevTools Performance timeline.
The full round-trip
- Camera moves →
moveEnd/preRenderfires. selectNodes()returns the key set for the current view, plus prefetch candidates and any hierarchy pages it needs.- Needed pages start loading. Pending loads that are no longer wanted are cancelled.
- New keys not in cache and not already pending are dispatched — visible ones first, then prefetch candidates while slots remain.
RangeFetchermerges sibling ranges into Range Requests; each node's bytes are transferred to a Worker.- The Worker decodes (laz-perf), reprojects (proj4 → ECEF), packs offsets + attributes, and transfers
NodeRenderDataback. - The main thread builds a
PointCloudPrimitive, adds it to the scene (shown only if still selected), and caches it. - Reconciliation shows the new node and hides the now-covered old one; the LRU evicts anything over budget.
Source map
| File | Layer | Role |
|---|---|---|
src/CopcDataSource.ts |
Scheduler | Public API, listeners, LoD reconciliation, load/page dispatch, stats |
src/copc/hierarchy.ts |
Metadata | Range-support probe, header + root hierarchy page load |
src/copc/node.ts, key.ts |
Data | Octree key parsing, child/ancestor math, depth bucketing |
src/copc/RangeFetcher.ts |
Data | Merged, bounded, retrying Range Requests for node data |
src/copc/TransferCounter.ts |
Data | Byte/request accounting and the status-checked counting getter |
src/worker/worker.ts |
Data | Decode + reproject + pack (Worker side) |
src/worker/WorkerPool.ts, messages.ts |
Data | Reused Worker pool, request/response protocol, cancel/timeout |
src/lod/selectNodes.ts |
Scheduler | SSE max-heap octree traversal, budgets, prefetch candidates |
src/lod/screenSpaceError.ts, boundingVolume.ts |
Scheduler | SSE math, (expanded) frustum culling, bounding spheres |
src/cache/NodeCache.ts |
Scheduler | LRU + pin, node- and byte-bounded |
src/crs/detectCrs.ts, projections.ts, epsgTable.ts, project.ts |
Data | CRS auto-detection, offline EPSG fallback table, main-thread projector |
src/loader/loadNode.ts |
Render | Builds a PointCloudPrimitive from NodeRenderData |
src/renderer/PointCloudPrimitive.ts, shaders.ts |
Render | DrawCommand primitive, model-matrix origin, styling shaders |
src/style/classificationColors.ts |
Render | ASPRS palette shared by the Worker fallback and the shader |