Architecture: the materialization continuum (cache = none · hierarchy · full)
Every converter — offline or live — is the same mapping:
HLOD mapping
Every converter — offline or live — is the same mapping:
input format 3D Tiles
───────────── ─────────
hierarchy (octree pages / nodepages) ↔ tile tree (implicit subtrees / explicit children)
node data (points / mesh / splats) ↔ tile content (glTF + KHR_gaussian_splatting)
CRS + bounds ↔ root transform + bounding volumesThe only thing that differs across everything we've built is when that mapping is evaluated, and how much of it is persisted:
| evaluate | persist | that's our… | |
|---|---|---|---|
| ahead of time, all of it | once, offline | to disk | CLI / SDK output, .3tz/.3dtiles packages |
| on first request, all of it | once, on hit | to cache | /convert |
| per request, only what's asked | every request | nothing | /stream + adapters |
One axis: what the middleware caches
How much the middleware caches.
cache = none | hierarchy | full # default: none
prefetch = <N levels> # background-warm N levels below root (cache=none) — default: 1
tiling = implicit | explicit # octree/quadtree only — default: implicitcache | hierarchy read up front? | tile contents persisted? | first request | later / restart | = today's |
|---|---|---|---|---|---|
| none | no — root only, chunks on demand (lazy) | no | fast | re-derived; ephemeral | /stream lazy |
| hierarchy | yes — whole tree in RAM | no (per-request) | slower cold start, warm after | tree warm in RAM (per process) | /stream eager |
| full | yes | yes — tree + all tile GLBs to disk | slow (convert all) | served static; survives restart | /convert |
?cache=hierarchy reads literally as "cache the hierarchy, not the tiles." cache=none = fully
dynamic; cache=full = convert-and-store. The parameter name carries the concept, so nobody has to
memorise what "live" vs "cached" means.
Where each persists (explicit):
cache=fullwrites the whole converted output to disk (/convert— tiles can be huge, so disk), served static afterwards and surviving restart;cache=hierarchyholds only the tree in RAM (ephemeral, per process — it should fit);cache=nonepersists nothing (re-derived per request, with the per-URLgetCtxcache warming as you go).
The same spectrum has three well-known names elsewhere, if it helps: DB materialized-table → materialized-view → view; Next.js SSG → ISR → SSR; GIS/TiTiler pre-rendered tile cache → cache-on-demand → dynamic tiling.
There is no "prebuilt" mode. For an offline-built tileset, point the viewer directly at the output
tileset.json(static file serving). The continuum above is only about what the converter middleware does when it's the one producing 3D Tiles from a non-3D-Tiles source.
Push vs pull (why offline converters stay as they are)
Offline conversion pushes: it walks the input format's hierarchy top-down, building the tree along the way and emitting every tile. The live stream adapters pull: they expose the tileset root, then respond to each 3D Tiles request by extracting from the input format exactly the hierarchy/tile content needed for that response — working the format the other way around. Same mapping, opposite direction.
The offline converters are conserved as-is: they work and walk the format hierarchy correctly. The harmonization below targets the live path.
bake(driver)is possible but deferred. Because push and pull are the same mapping in opposite directions, a singlebake(driver)could in principle drive offline conversion from the very same per-format drivers the live path uses (walk root → children → content, write every tile + the tree to disk) — collapsing the two codebases into one. We are not doing this yet: the offline converters are battle-tested and have format-specific niceties (centroids, merging, per-format GE tuning) that a premature unification would risk. It's noted here as the natural endgame, to be taken per-format only when there's a concrete reason.
Harmonization plan, endpoint rework, and phasing — the design discussion for how the live path got harmonized (the
FormatDriversketch, the sharedcore/services, the endpoint consolidation, and the chosen Phases 1–3 rollout) has moved into the Design log as a single continuous narrative, since it's a historical record of a completed migration rather than an ongoing architectural concern. The livetiling=explicitrouting-through-implicit-to-explicititem that was still open there is tracked in TODO / Future work.
Factory / composability ideas (future)
Several one-shot transforms are natural /3dtiles-tools commands that chain together rather than
being baked into each converter:
implicit-to-explicit(exists),upgrade(b3dm/pnts→glb, glTF 1.0→2.0,CESIUM_RTCbake — exists),centroids— now a streaming option (¢roids=1): for tiled-splat formats (SOG, LCC, RAD) each tile is emitted as a glTF POINTS GLB of splat centres instead of full gaussians (cheap preview), and the tileset drops theKHR_gaussian_splattingrequirement. Also available as the--centroids-onlyflag on the offline CLI //convert. (A post-process form that takes an arbitrary finished splat tileset would need SPZ decode — future, same shape asbake.)gltf— a generic per-tile glTF-Transform pipeline tool — decompress half IMPLEMENTED (/gltf/tileset.json?url=…&ops=decompressproxies an explicit tileset;/gltf/tile.glb?url=…&ops=…transforms one tile).ops=decompress|draco|meshopt|ktx. Decodes Draco + EXT_meshopt geometry and KTX2→PNG textures viacore/gltf-decompress.js(gltf-transform + draco3dgltf + Basis WASM fromthree+ pure-JSpngjs— nosharp). Verified on a real Bing3dv4tile (Draco+KTX2 → plain GLB, both extensions stripped). Encode ops below are next. Rather than one hard-coded texture step, this exposes glTF-Transform (gltf-transform.dev) ops as a chainable/3dtiles-tools?tool=gltf&ops=…that runs on each mesh tile's GLB on the fly (same streaming model asupgrade/implicit-to-explicit: tileset JSON cached once, tiles transformed on demand). Per tile:upgradeGlb→2.0 (gltf-transform needs 2.0) →io.readBinary→document.transform(...ops)→io.writeBinary. This is the gltf-pipeline #665 / glTF-Transform #591 / #1622 "apply a transform to every tile" pattern.- Decompress first (DONE): Draco decode + EXT_meshopt decode + KTX2/Basis→PNG. Note
glTF-Transform does not decode these for you: Draco/meshopt require registering the
draco3dgltf/meshoptimizerdecoder deps (documented, intended — it deliberately keeps the WASM out of its tree), and KTX2 decode has no library function at all — gltf-transform'sktxdecompress(#1622, v4.1+) is CLI-only and shells out to the native KTX-Softwarektxbinary. So our in-process path (Basis WASM fromthree+ pure-JSpngjs) is the correct self-contained, no-native-binary choice, avoidingsharp(libvips packaging pain + 16383²/13k AVIF/WebP size bugs). - Compress later (eventual):
draco,meshopt,quantize,ktx2(etc1s/uastc),webp/avif, plus geometry opssimplify/weld/dedup/flatten/join/instance/palette/prune— all are glTF-Transform functions. For encode, prefer a WASM encoder (e.g.@jsquash/webp, libktx) oversharp; glTF-Transform'stextureCompress({encoder})already accepts a pluggable encoder.
- Decompress first (DONE): Draco decode + EXT_meshopt decode + KTX2/Basis→PNG. Note
glTF-Transform does not decode these for you: Draco/meshopt require registering the
Composability goal: source → convert → [centroids] → [gltf: draco/ktx2 decode|encode, simplify…] → [implicit-to-explicit] → [upgrade] → 3D Tiles, each stage a tool you can chain.
Live tools & endpoints — how to call (current)
| Endpoint | What | Ops / params (current) |
|---|---|---|
/stream/tileset.json?url=<SRC>&format=<fmt>&transform=<op> (rides along on the tileset's own /stream/tile/… content URIs — no separate flag per tile request) | per-tile transform applied to every streamed tile (covers implicit COPC/Potree the /gltf proxy can't reach, since /stream builds each GLB itself) | transform=meshopt (encode — EXT_meshopt, works on POINTS → point tiles ~⅓ size) · transform=draco (encode, mesh tiles only, no-op on POINTS) · transform=ktx (encode ETC1S) · transform=ktx-uastc (encode UASTC, higher quality/larger) · transform=decompress (decode draco+meshopt+KTX2, +&tex=jpg|png picks the KTX2 decode output) |
/convert/tileset.json?url=<SRC>[&format=<fmt>][¢roids=1] | preprocess + cache: converts the whole dataset once, then serves static | any format the meta-converter supports (cache=full equivalent) |
/3dtiles-tools/tileset.json?tool=<t>&dir=<path>|url=<tileset> | per-tile tileset transforms | tool=implicit-to-explicit · tool=upgrade (b3dm/pnts→glb, glTF 1.0→2.0, CESIUM_RTC bake) |
/gltf/tileset.json?url=<tileset>&ops=<ops>[&tex=jpg|png] (+ /gltf/tile.glb?url=<glb>&ops=) | generic per-tile glTF-Transform pipeline (proxies an explicit tileset, recurses external refs) | ops=decompress (= draco,meshopt,ktx) · or any of draco · meshopt · ktx (decode); KTX2 decodes to tex=jpg (default, fast/small) or png |
/bing/tileset.json?root=<quadkey>&g=<genid>&maxLevel=<L>[&decompress=1] | Bing Maps 3D (tf=3dv4) → 3D Tiles | root quadkey, g genid (default 15340), maxLevel, decompress |
/3dtiles-self-contained/tileset.json?url=<path|URL.3tz|.3dtiles> | serve a .3tz/.3dtiles package in-place, no extraction | local path or http(s)://; .3tz local-only, .3dtiles local + remote via sql.js |
The
/gltfpipeline runs per-tile only on explicit tilesets (concrete tile URIs — 3MX/Bing/RAD/I3S outputs and external mesh 3D Tiles); it recurses external-tileset refs. Implicit COPC/Potree octree templates ({level}-{x}-{y}) can't be URL-rewritten per tile — so for those use/stream?transform=, which applies the op as/streambuilds each tile GLB itself (no URL rewrite needed).transform=meshoptis the standout: it compresses POINTS (where Draco no-ops, having no indices), shrinking COPC/Potree point tiles to ~⅓ over the wire (decoded transparently by Cesium/3DTilesRendererJS).transform=decompressis the reverse — mainly for ingesting existing remote 3D Tiles into consumers that can't read Draco/KTX2/CESIUM_RTC(e.g. blender BLOSM). KTX2 encode still needs a nativetoktx/ktxbinary (no in-process WASM encoder), so it stays a documented future add.Why no built-in KTX decode dep / do we vendor the
ktxCLI? No — we do not use (or vendor) glTF-Transform'sktxdecompress, which is CLI-only and shells out to the native KTX-Softwarektxbinary. Our decode is in-process (Basis WASM fromthree+ pure-JSpngjs), so the server needs no native binary. Draco/meshopt decode use the documenteddraco3dgltf/meshoptimizerdependency registration (glTF-Transform deliberately doesn't bundle those WASM decoders).
centroidsper format — works for SOG / LCC / RAD (the tiled-splat formats). Verified: the tileset omits the splat extension and each tile is a POINTS GLB (SOG 724 B vs 1160 B splat for the same run; LCC, RAD likewise). For RAD the flag rides on the absolute/stream/rad/subtree/…+/stream/tile/…URIs the lazy fragments emit. COPC/Potree are point clouds already.
Encode ops, ingestion decompress, and other forward-looking items for this pipeline have moved to TODO / Future work's active list (encode-ops remainder,
rtc_center/ decompress for ingestion, browser-native bbox crop, 3MX siblings, server-side per-cache progress, Draco+KTX2 decode speed). The Bingtd1→ explicit lazy fragments work described in an earlier draft of this section is done — see the dedicated Bing Maps 3D → 3D Tiles section in the design log. The fastest JS point-cloud tiler idea is tracked in Candidate source formats's research note.
Phasing (Phases 1–3, chosen scope) and the harmonization rollout are documented as a completed historical migration in the Design log rather than here — offline converters remain conserved as described above; only the live (pull) path was harmonised.
Parallelism (per converter)
Per-tile work — decode → SPZ/GLB encode → gzip → write — is independent per tile, so it is
embarrassingly parallel. The shared core (packages/core) provides the two tiers: async gzip
(zlib.gzip on the libuv threadpool) + parallelMap (bounded-concurrency = cores−1, no workers), and
a WorkerPool (worker_threads) for decode-heavy formats. All five operational converters now use
tier 1 (parallelMap + async gzip/buildPointsGlb), each verified to produce output equivalent to
its prior serial version. The WorkerPool tier (true multicore decode) is not yet wired — it's the
remaining headroom for the WASM/CPU-bound decoders.
| Converter | Decode cost (per tile) | Parallel today? | Main-thread-bound part | Worker-extractable? |
|---|---|---|---|---|
| rad | pure-JS column unpack (interleaved/planar) — CPU-bound | ✅ parallelMap + async gzip (byte-identical) | JS decode | ✅ decode per chunk → WorkerPool (true multicore) |
| sog | WebP WASM decode per chunk | ✅ parallelMap + async gzip (content-identical) | WebP decode | ✅ biggest win (thousands of tiles); WASM runs in workers as-is |
| lcc | pure-JS DataView unpack — fast | ✅ parallelMap + async gzip | one ~3 M-splat tile is a serial tail | ✅ worker decode would split that giant tile |
| copc | LAZ WASM (laz-perf) decode — CPU-bound | ✅ parallelMap (byte-identical; ~27% faster) | LAZ decode | ✅ per-node decode → worker pool |
| potree | pure-JS slice (+ brotli for 2.0) — many small nodes | ✅ parallelMap (byte-identical) | per-node decode + brotli | ✅ many small nodes → ideal worker-pool fan-out |
| i3s (draft) | pure-JS geometry decode | ❌ (draft; serial) | — | ✅ later |
Measured (cores−1 lanes): copc autzen 23.0→16.9 s, potree lion 6.2→5.0 s — modest now (gzip/decode
still on the main thread via the libuv pool); the WorkerPool tier is where multicore decode lands.
Short answer to "can main-thread work move to workers?" Yes — for every converter. The pure-JS
decoders (rad, lcc, potree) gain the most (they're single-core-bound today → true multicore via
WorkerPool); the WASM decoders (sog, copc) are already fast per-op but still serialized on the main
thread, so a worker pool parallelizes them across cores. core.WorkerPool is the shared mechanism;
each converter just needs its tile loop ported (lcc is the worked example).
/convert — live any-format → 3D Tiles (implemented)
The same server also exposes a generic endpoint that works for every format the meta-converter supports (potree, sog/streamed-SOG, rad, lcc, copc, i3s).
The current implementation (offline conversion, cached, served live)
/convert is a pragmatic stand-in that gets every format renderable today, before each
per-format range adapter exists (so far COPC, Potree, streamed-SOG, LCC, and RAD all do — see
"Streaming roadmap — per-format range adapters").
Where the range adapters are per-tile, /convert is per-dataset: on the
first request it resolves the source to a local dataset (downloading remote inputs), runs the
in-process convert() once (output cached on disk under the OS temp dir, keyed by
sha1(format|src)), then serves the static 3D Tiles output. So it converts the whole hierarchy
and all tile content up front, then streams from that cache — not range-streamed, but genuinely
on-demand and format-agnostic. Subsequent requests (and restarts) hit the warm cache instantly.
npm run serve:tiles
# GET /convert/tileset.json?url=<SRC>[&format=<fmt>] → tileset (content URIs rewritten absolute)
# GET /convert/<key>/<path> → static tile / subtree / sub-tileset file<SRC>may be a remote URL or a local path (file or dataset dir). Remote directory formats are mirrored with the sibling files each converter reads — Potree 2.0 (metadata.json+hierarchy.bin+octree.bin), LCC (meta.lcc+index.bin/data.bin), SOG (lod-meta.json+ every chunkmeta.json+ its WebP textures). Single-file formats (COPC/RAD/SLPK) download the one file.&format=is optional — auto-detected from the path/extension (pass it for remote directory formats where the index filename isn't decisive, or to override).&tiling=implicit|explicit(COPC & Potree) chooses the tree encoding — implicit (subtree files) or an explicit nested tree. CLI:--tiling=…; API:opts.tiling. Defaults: COPC implicit, Potree explicit. Same flag works for the offline converters.¢roids=1(point clouds get this for free) renders splat datasets (RAD/SOG/LCC) as a POINTS tileset of splat centroids — so renderers withoutKHR_gaussian_splattingsupport can still display the scene. See Centroids-only below (same page).- The served root tileset has its relative
uris rewritten to absolute/convert/<key>/…so content resolves regardless of how the viewer bases the query-string URL; nested sub-tilesets are served at their real paths so their own relative URIs already resolve. - For big COPC, prefer
/stream?format=copc(true range streaming) over/convert(downloads the whole.laz).
Remote source notes / limits:
- The same remote support is shared by the CLI and JS API (
packages/convert/fetch-source.js):3dtiled https://host/scene/metadata.json out/andconvert('https://…', out)download the source (+ the sibling files the format needs) to a temp dir, then convert. Local path or remote URL works identically across CLI, JS API, and the/convertmiddleware. - Mirroring is server-side, so no browser CORS applies — but the source must serve HTTP range
206 for
/copcand/potree, and must not redirect in a way that drops theRangeheader (e.g. some IGN LiDAR HD URLs 302 to an OVH bucket → range lost)./convertdownloads in full so it tolerates that. - I3S remote =
.slpkfile only; live REST SceneServer services (gzipped node-page tree) aren't mirrored yet — extract the.slpk/ mirror the service locally and pass the directory. - Use a raw file URL, not a hosting page — GitHub
…/blob/…#L2returns HTML; use theraw.githubusercontent.comURL (or?raw=1). - Don't mix protocols in a browser: an
https://viewer page can't fetch anhttp://localhosttileset (mixed-content block). Serve the viewer over the same scheme as the tile-server.
viewer.html ships pills for each: 🛰 Potree / RAD / SOG / LCC / COPC →3DT (live).
Centroids-only: splats as point clouds
Gaussian-splat formats (RAD / SOG / LCC) normally emit KHR_gaussian_splatting GLB tiles, which
many 3D-Tiles clients can't render. --centroids-only (CLI) / ?centroids=1 (middleware) instead
emits a plain glTF POINTS tileset of the splat centroids — POSITION + COLOR_0 only, dropping
scale / rotation / gaussian opacity — placed in the exact same frame as the splat tiles. So any
point-cloud-capable renderer (CesiumJS, 3DTilesRendererJS, cesium-for-unreal, …) can display a
tiled-splat scene as points.
# offline conversion
3dtiled scene.rad out/ --centroids-only # POINTS tileset instead of splats
# live middleware
GET /convert/tileset.json?url=<SRC>¢roids=1 # cached separately from the splat variantPoint-cloud inputs (COPC/Potree) are already POINTS, so the flag is a no-op for them. The output is a
standard POINTS tileset with no KHR_gaussian_splatting extension advertised.
/stream lazy-loading status (geometry vs hierarchy)
Two independent questions for a streaming adapter: is geometry fetched per-tile on demand (vs. converting everything up front), and is the hierarchy/tree itself built on demand (vs. reading the whole index/hierarchy at cold load)? Geometry-on-demand is universal here; hierarchy-on-demand is the hard part, and matters for huge clouds where reading the entire hierarchy before the first tile is the bottleneck.
Tiles vs hierarchy — (1) tile geometry is fetched on demand for every format (universal).
(2) the hierarchy is the hard part. Each live converter exposes the cache axis (none=lazy,
hierarchy=eager-drain, full=/convert). The columns below: how tile geometry is fetched, the
lazy hierarchy strategy, whether the hierarchy build is parallel (bounded HTTP range/page fetches —
the lever for massive multi-page datasets), whether tile fetches are parallel range, and the
measured cold start.
/stream source | Tile geometry | Lazy hierarchy | Parallel hierarchy build | Parallel tile fetch | cache=hierarchy (eager) | Cold start |
|---|---|---|---|---|---|---|
Potree 2.0 (metadata.json) | ✅ range octree.bin | ✅ lazy implicit | ✅ bounded (ensureBlock2/drain Promise.all) | ✅ per-tile | ✅ drain all chunks | instant (lazy==eager byte-identical, 1624 nodes) |
COPC (.copc.laz) | ✅ range node block | ✅ lazy implicit | ✅ bounded (ensureForSubtree/drainAll) | ✅ per-tile | ✅ drain all pages (shared ctx) | instant (+bg prefetch) |
Potree 1.x .hrc (cloud.js) | ✅ per-node fetch | ✅ lazy implicit | ✅ bounded parallel (loadChunkRoots, was sequential) | per-node | ✅ drainAll whole .hrc | ~0.2 s (302 M-pt; was ~80 s) |
RAD (.rad) | ✅ range chunk | ✅ lazy explicit (4-lvl frags) | ✅ bounded parallel per BFS level (was sequential) | ✅ per-chunk | ✅ full explicit tree (1 parallel pass) | 0.015 s (50 M; was 9.6 s) |
I3S (3dSceneLayer/.slpk) | ✅ per-node geometry | ✅ lazy explicit (4-lvl frags) | ✅ bounded parallel page-load per level (was recursive-serial) | per-node | ✅ all node-pages (parallel batches) | ~0.2 s (lazy & eager both 5882/5882) |
Potree 1.4 inline (cloud.js) | ✅ per-node | ➖ tree inline in cloud.js | n/a (one file) | per-node | ➖ no-op (single file) | instant |
streamed-SOG (lod-meta.json) | ✅ per-chunk WebP | ➖ full tree in one JSON | n/a (one file) | per-tile | ➖ no-op (none≡hierarchy) | fast — nothing to defer |
LCC (meta.lcc) | ✅ range data.bin | ➖ flat grid (index.bin) | n/a (one file) | ✅ per-tile | ➖ no-op (none≡hierarchy) | fast — flat grid |
Packages (.3tz/.3dtiles) | ✅ range / sql.js | ➖ passthrough | n/a | ✅ / ➖ | ➖ as authored | instant |
Legend: ✅ done · ➖ not applicable / single-index (nothing to parallelize or defer) · "lazy implicit" =
tiny implicit root, each .subtree built on demand from only the chunk(s) it needs · "lazy explicit" =
root fragment of N levels, boundary children are external-tileset refs fetched as the camera refines in.
Parallel hierarchy build (the massive-multi-page lever): the eager/drain paths always used
Promise.all; the lazy fragment builders are now bounded-parallel too — RAD loads each BFS level
concurrently, I3S loads each node-page level concurrently (then builds the fragment synchronously, no
seen race), and Potree 1.x loads each .hrc band concurrently (loadChunkRoots). All capped at
min(cpus−1, 16) via core/parallel.js parallelMap so a deep multi-page fragment can't burst
hundreds of simultaneous range requests. Parallel tile fetch is inherent — the renderer requests
many tiles at once and Node serves them concurrently; a single tile is one range read + decode.
All lazy paths verified to reach the same node set as eager after parallelization: Potree 2.0 byte-identical (1624 nodes), RAD 765/765 reachable, I3S 5882/5882 content tiles.
Multi-page hierarchies: the native chunking unit per format (and why we prefetch)
Lazy hierarchy is only possible when the format itself chunks its hierarchy into independently
fetchable pieces. This is the same idea everywhere, with different names — and 3D Tiles has both output
forms of it: .subtree files (implicit) and external tileset.json references (explicit). The
job of each adapter is to map the source format's chunking unit onto one of those two 3D Tiles forms.
| Format | Native hierarchy chunking unit (per spec) | Maps to 3D Tiles | Lazy? |
|---|---|---|---|
| 3D Tiles | .subtree (implicit, subtreeLevels deep) · external tileset.json (explicit) | — (native) | ✅ |
| COPC | hierarchy pages — EPT-style; root page in the COPC info, child pages referenced by offset/size (COPC is "COG for point clouds"; pages are its analog of COG overviews) | implicit .subtree | ✅ |
| Potree 2.0 | hierarchy chunks in hierarchy.bin — firstChunkSize + type-2 proxy nodes pointing to child chunks | implicit .subtree | ✅ |
| Potree 1.x | .hrc files — one per hierarchyStepSize band; boundary nodes reference the next .hrc | implicit .subtree | ✅ |
| I3S | node pages — nodepages/{n}.json, fixed nodesPerPage (default 64); contiguous node-index ranges | explicit external tileset (/i3s/node/) | ✅ |
| RAD | no separate index — topology is distributed in the chunks (each chunk's child_count/child_start); the .rad JSON header only lists chunk byte-ranges | explicit external tileset (/rad/subtree/) | ✅ (defer chunk reads; header lists all chunks) |
| streamed-SOG | none — lod-meta.json carries the whole spatial tree in one file | (whole tree at once) | ➖ monolithic |
| LCC | none — Index.bin is a flat per-Unit array (Total Units = fileSize / indexDataSize), no page structure | (whole grid at once) | ➖ monolithic |
So yes — COPC pages, I3S node pages, Potree chunks/.hrc are all the same multi-page concept as
3D Tiles subtrees/external-tilesets, which is exactly why those formats support true lazy hierarchy.
SOG and LCC do not (rechecked against the specs): their index is a single monolithic file with no
sub-pages, so there is no portion to defer — cache=hierarchy ≡ cache=none for them (both fetch+parse
the one index; "no-op" means the eager knob has nothing extra to do). For a very large SOG/LCC that
single index is genuinely not free to parse — but the format provides no paging to exploit, so the
honest answer is "out of our hands until the format chunks its index." (RAD is the in-between case: its
header lists every chunk, which is O(chunks) and unavoidable, but the expensive topology+bounds are read
lazily per 4-level fragment.)
Why prefetch. With a chunked hierarchy, cache=none reads only the page(s) a request needs, so the
first request into a new region pays a fetch. prefetch=N warms the next N levels in the background so
the renderer usually finds them already resident — combining a lazy cold start with eager-like
refinement. It is meaningful only where there are pages to warm: COPC, Potree 2.0, Potree 1.x
(octree-lazy). For RAD/I3S the 4-level fragment is itself a prefetch unit. SOG/LCC have no pages → n/a.
Can we avoid reading availability/
getCtxup front and not pay it lazily either? Largely yes. For the octree/quadtree implicit formats (Potree 2.0, COPC, Potree 1.x)getCtxno longer reads the whole hierarchy — only the root chunk/page — and each.subtreeis generated from just the chunk(s) that block needs, so the up-front cost is gone and the per-subtree cost is bounded (a few range reads, not the whole index). For the explicit formats (RAD, I3S) we emit N-level fragments whose boundary children are external-tileset refs, so the same bound applies. The only remaining strictly-eager cases are SOG/LCC, where the entire index is a single small file you must read anyway — there is no sub-index to defer, so laziness buys nothing.
Why lazy cold-starts but slightly slower subsequent tiles — and how to get both. Eager fills all availability in RAM once, so every later
.subtree/child lookup is a pure in-memory hit. Lazy defers that, so the first request into a new region pays a chunk/page fetch. Two mitigations, both kept in the middleware so you get fast cold start and warm refinement:
- Background prefetch — after the root tileset is served, COPC fires a fire-and-forget
ensureForSubtree(0,0,0,0); the first levels are usually warm before the renderer asks.- Per-URL
getCtxcache — once a chunk/page is loaded it stays resident, so a region is fetched at most once; repeat passes are in-memory.Both eager and lazy implementations are retained rather than replaced: implicit formats keep the eager full-hierarchy reader behind the scenes for the offline converter, and I3S exposes
&lazy=1(lazy explicit fragments) vs. its default parallel-eager full tree — so you can pick minimal cold start or a complete in-RAM tree per request.
Note — huge clouds need lazy hierarchy, not just lazy geometry. Building the full tree up front doesn't scale: a 100 M-splat RAD or a 302 M-point Potree 1.7 cloud otherwise reads its entire hierarchy/index before showing anything (measured ~80 s for the latter, 9.6 s for a 50 M-splat RAD). Lazy hierarchy collapses both to well under a second.
Octree key convention
The implicit level-x-y-z tile coordinates are derived from each node's bounding-box
position relative to the root box — not from the node-name bit-decomposition. Potree's
own naming uses bit0→Z, bit1→Y, bit2→X (opposite of the 3D Tiles OCTREE convention of
bit0→X, bit1→Y, bit2→Z). Using the box position is convention-agnostic and self-validating
(verified: 0 box mismatches on all tested datasets). The same coordsOfBox() derivation is
used in both the offline converters and the live stream adapters.
/3dtiles-self-contained — serve .3tz / .3dtiles packages in-place
# Open a .3tz package directly in any 3D Tiles viewer without extracting it:
GET http://localhost:3001/3dtiles-self-contained/tileset.json?url=/abs/path/to/scene.3tz
GET http://localhost:3001/3dtiles-self-contained/tiles/0/0/0.glb?url=/abs/path/to/scene.3tz?url= also takes a local filesystem path (resolved relative to the tile-server's own CWD, not
fetched over HTTP) alongside http(s):// — unique among the adapters here, which all fetch() and so
need an absolute URL. Working example against this repo's bundled sample package:
GET http://localhost:3001/3dtiles-self-contained/tiles/node_7.glb?url=sample-data%2Finput%2F3DTILES-PACKAGES%2Ftastier.3dtiles&format=3dtiles-pkg.3tz is a ZIP file with a @3dtilesIndex1@ binary index that maps every tile path to its offset
in the ZIP, enabling O(1) random-access reads (no full-file scan). This project parses the index on
first open, then serves each tile with a single fs.read() at the stored offset — equivalent to
npx 3d-tiles-tools serve but in-process, with CORS open and no additional CLI.
.3dtiles is an SQLite database (key TEXT, content BLOB). Requires better-sqlite3:
pnpm add -w better-sqlite3 # needs C++ build toolchainCreate packages — this repo ships a dependency-free packer (scripts/pack-3dtiles.mjs, ZIP+STORED
for .3tz, sql.js for .3dtiles), or use the official 3d-tiles-tools:
# In-repo packer (no external deps): writes <out>.3tz AND <out>.3dtiles
node scripts/pack-3dtiles.mjs sample-data/output/rad/tastier500k-3dtiles-from-rad sample-data/input/3DTILES-PACKAGES/tastier
# Official tool (CesiumGS) — `convert` replaces the old packageCreate/databaseToTileset commands:
npx 3d-tiles-tools convert -i ./tileset/ -o ./scene.3tz # ZIP package
npx 3d-tiles-tools convert -i ./tileset/ -o ./scene.3dtiles # SQLite package
# --inputTilesetJsonFileName <name> when the top-level JSON isn't called tileset.json
# input/output may be a dir, a tileset.json, a .3tz, a .3dtiles, or a .zip containing tileset.jsonA ready-made sample is built into sample-data/input/3DTILES-PACKAGES/tastier.{3tz,3dtiles} (from the
tastier RAD splat tileset) — open it via GET /3dtiles-self-contained/tileset.json?url=sample-data/input/3DTILES-PACKAGES/tastier.3tz.
Packaging-format specifications
| Format | What it is | Spec |
|---|---|---|
.3tz | ZIP with a @3dtilesIndex1@ binary index for random access; STORED (uncompressed) tile entries | erikdahlstrom/3tz-specification · Maxar MAXAR_content_3tz |
.3dtiles | SQLite DB, media(key TEXT, content BLOB); keys are relative POSIX paths | 3D Tiles package format proposal — CesiumGS/3d-tiles#727 (PDF spec 1.0.0) |
Tooling & discussion: 3d-tiles-tools convert ·
extended package server — CesiumGS/3d-tiles-tools#86.
Local notes: 3tz-3dtiles-packages.md.