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()):

  1. Probes for Range Request support — a Range: bytes=0-0 request that must come back 206 Partial Content. A server that ignores Range and returns the whole file as 200 OK fails here with a clear error instead of a confusing parse failure deep inside Copc.create().
  2. Reads the file size from that response's Content-Range (falling back to a HEAD request's Content-Length when CORS hides Content-Range), for stats.fileBytes.
  3. 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 than copc'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 a 200 from a server that ignored Range, is an error rather than bytes to decode.
  • Retries what a retry can fix. 5xx and network-level failures are retried up to 3 times with cancellable exponential backoff (250 ms, 500 ms). A 4xx (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:

  1. Decodes LAZ via laz-perf WASM. 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.
  2. Reprojects each point: source CRS → EPSG:4326 lon/lat (proj4) → ECEF, using WGS84 ellipsoid math done by hand (the Worker never constructs a Cesium object). Height is z * zFactor + geoidOffset.
  3. Packs positions as node-relative Float32 offsets 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).
  4. Bakes a default colour: RGB if present (with 16-bit → 8-bit auto-scaling), else the ASPRS classification palette, else flat grey.
  5. 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 toggles show. No octree walk, no allocation, no loading. This catches camera changes between the throttled LoD passes.
  • The expensive LoD re-selection (_updateLoD()) is throttled to debounceMs (default 100 ms) on preRender, and also fired immediately on camera.moveEnd so 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 + maxPrefetchNodes keys 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, at pointCount × 21 per 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:

  • colorMode picks between the baked colour, an intensity ramp over intensityRange, the classification palette, or an elevation ramp.
  • classificationFilter is a 256-bit allow-list; filtered points are moved outside clip space in the vertex shader (they still occupy GPU memory).
  • pointSizeMode: 'attenuated' computes gl_PointSize = clamp(attenuationFactor / sqrt(distance_m), minPixelSize, maxPixelSize); 'fixed' uses pixelSize.
  • opacity < 1 switches the command to Pass.TRANSLUCENT with 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

  1. Camera moves → moveEnd/preRender fires.
  2. selectNodes() returns the key set for the current view, plus prefetch candidates and any hierarchy pages it needs.
  3. Needed pages start loading. Pending loads that are no longer wanted are cancelled.
  4. New keys not in cache and not already pending are dispatched — visible ones first, then prefetch candidates while slots remain.
  5. RangeFetcher merges sibling ranges into Range Requests; each node's bytes are transferred to a Worker.
  6. The Worker decodes (laz-perf), reprojects (proj4 → ECEF), packs offsets + attributes, and transfers NodeRenderData back.
  7. The main thread builds a PointCloudPrimitive, adds it to the scene (shown only if still selected), and caches it.
  8. 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