3dtiled-to-3dtiles

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 volumes

The only thing that differs across everything we've built is when that mapping is evaluated, and how much of it is persisted:

evaluatepersistthat's our…
ahead of time, all of itonce, offlineto diskCLI / SDK output, .3tz/.3dtiles packages
on first request, all of itonce, on hitto cache/convert
per request, only what's askedevery requestnothing/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: implicit
cachehierarchy read up front?tile contents persisted?first requestlater / restart= today's
noneno — root only, chunks on demand (lazy)nofastre-derived; ephemeral/stream lazy
hierarchyyes — whole tree in RAMno (per-request)slower cold start, warm aftertree warm in RAM (per process)/stream eager
fullyesyes — tree + all tile GLBs to diskslow (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=full writes the whole converted output to disk (/convert — tiles can be huge, so disk), served static afterwards and surviving restart; cache=hierarchy holds only the tree in RAM (ephemeral, per process — it should fit); cache=none persists nothing (re-derived per request, with the per-URL getCtx cache 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 single bake(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 FormatDriver sketch, the shared core/ 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 live tiling=explicit routing-through-implicit-to-explicit item 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_RTC bake — exists),
  • centroidsnow a streaming option (&centroids=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 the KHR_gaussian_splatting requirement. Also available as the --centroids-only flag on the offline CLI / /convert. (A post-process form that takes an arbitrary finished splat tileset would need SPZ decode — future, same shape as bake.)
  • gltf — a generic per-tile glTF-Transform pipeline tooldecompress half IMPLEMENTED (/gltf/tileset.json?url=…&ops=decompress proxies 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 via core/gltf-decompress.js (gltf-transform + draco3dgltf + Basis WASM from three + pure-JS pngjsno sharp). Verified on a real Bing 3dv4 tile (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 as upgrade/implicit-to-explicit: tileset JSON cached once, tiles transformed on demand). Per tile: upgradeGlb→2.0 (gltf-transform needs 2.0) → io.readBinarydocument.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 / meshoptimizer decoder deps (documented, intended — it deliberately keeps the WASM out of its tree), and KTX2 decode has no library function at all — gltf-transform's ktxdecompress (#1622, v4.1+) is CLI-only and shells out to the native KTX-Software ktx binary. So our in-process path (Basis WASM from three + pure-JS pngjs) is the correct self-contained, no-native-binary choice, avoiding sharp (libvips packaging pain + 16383²/13k AVIF/WebP size bugs).
    • Compress later (eventual): draco, meshopt, quantize, ktx2 (etc1s/uastc), webp/avif, plus geometry ops simplify/weld/dedup/flatten/join/instance/palette/prune — all are glTF-Transform functions. For encode, prefer a WASM encoder (e.g. @jsquash/webp, libktx) over sharp; glTF-Transform's textureCompress({encoder}) already accepts a pluggable encoder.

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)

EndpointWhatOps / 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>][&centroids=1]preprocess + cache: converts the whole dataset once, then serves staticany format the meta-converter supports (cache=full equivalent)
/3dtiles-tools/tileset.json?tool=<t>&dir=<path>|url=<tileset>per-tile tileset transformstool=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 Tilesroot 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 extractionlocal path or http(s)://; .3tz local-only, .3dtiles local + remote via sql.js

The /gltf pipeline 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 /stream builds each tile GLB itself (no URL rewrite needed). transform=meshopt is 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=decompress is 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 native toktx/ktx binary (no in-process WASM encoder), so it stays a documented future add.

Why no built-in KTX decode dep / do we vendor the ktx CLI? No — we do not use (or vendor) glTF-Transform's ktxdecompress, which is CLI-only and shells out to the native KTX-Software ktx binary. Our decode is in-process (Basis WASM from three + pure-JS pngjs), so the server needs no native binary. Draco/meshopt decode use the documented draco3dgltf / meshoptimizer dependency registration (glTF-Transform deliberately doesn't bundle those WASM decoders).

centroids per 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 Bing td1 → 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.

ConverterDecode cost (per tile)Parallel today?Main-thread-bound partWorker-extractable?
radpure-JS column unpack (interleaved/planar) — CPU-boundparallelMap + async gzip (byte-identical)JS decode✅ decode per chunk → WorkerPool (true multicore)
sogWebP WASM decode per chunkparallelMap + async gzip (content-identical)WebP decodebiggest win (thousands of tiles); WASM runs in workers as-is
lccpure-JS DataView unpack — fastparallelMap + async gzipone ~3 M-splat tile is a serial tail✅ worker decode would split that giant tile
copcLAZ WASM (laz-perf) decode — CPU-boundparallelMap (byte-identical; ~27% faster)LAZ decode✅ per-node decode → worker pool
potreepure-JS slice (+ brotli for 2.0) — many small nodesparallelMap (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 chunk meta.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.
  • &centroids=1 (point clouds get this for free) renders splat datasets (RAD/SOG/LCC) as a POINTS tileset of splat centroids — so renderers without KHR_gaussian_splatting support 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/ and convert('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 /convert middleware.
  • Mirroring is server-side, so no browser CORS applies — but the source must serve HTTP range 206 for /copc and /potree, and must not redirect in a way that drops the Range header (e.g. some IGN LiDAR HD URLs 302 to an OVH bucket → range lost). /convert downloads in full so it tolerates that.
  • I3S remote = .slpk file 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/…#L2 returns HTML; use the raw.githubusercontent.com URL (or ?raw=1).
  • Don't mix protocols in a browser: an https:// viewer page can't fetch an http://localhost tileset (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>&centroids=1    # cached separately from the splat variant

Point-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 sourceTile geometryLazy hierarchyParallel hierarchy buildParallel tile fetchcache=hierarchy (eager)Cold start
Potree 2.0 (metadata.json)✅ range octree.bin✅ lazy implicit✅ bounded (ensureBlock2/drain Promise.all)✅ per-tile✅ drain all chunksinstant (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 implicitbounded parallel (loadChunkRoots, was sequential)per-nodedrainAll 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.jsn/a (one file)per-node➖ no-op (single file)instant
streamed-SOG (lod-meta.json)✅ per-chunk WebP➖ full tree in one JSONn/a (one file)per-tile➖ no-op (nonehierarchy)fast — nothing to defer
LCC (meta.lcc)✅ range data.bin➖ flat grid (index.bin)n/a (one file)✅ per-tile➖ no-op (nonehierarchy)fast — flat grid
Packages (.3tz/.3dtiles)✅ range / sql.js➖ passthroughn/a✅ / ➖➖ as authoredinstant

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.

FormatNative hierarchy chunking unit (per spec)Maps to 3D TilesLazy?
3D Tiles.subtree (implicit, subtreeLevels deep) · external tileset.json (explicit)— (native)
COPChierarchy 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.0hierarchy chunks in hierarchy.binfirstChunkSize + type-2 proxy nodes pointing to child chunksimplicit .subtree
Potree 1.x.hrc files — one per hierarchyStepSize band; boundary nodes reference the next .hrcimplicit .subtree
I3Snode pagesnodepages/{n}.json, fixed nodesPerPage (default 64); contiguous node-index rangesexplicit external tileset (/i3s/node/)
RADno separate index — topology is distributed in the chunks (each chunk's child_count/child_start); the .rad JSON header only lists chunk byte-rangesexplicit external tileset (/rad/subtree/)✅ (defer chunk reads; header lists all chunks)
streamed-SOGnonelod-meta.json carries the whole spatial tree in one file(whole tree at once)➖ monolithic
LCCnoneIndex.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/getCtx up front and not pay it lazily either? Largely yes. For the octree/quadtree implicit formats (Potree 2.0, COPC, Potree 1.x) getCtx no longer reads the whole hierarchy — only the root chunk/page — and each .subtree is 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:

  1. 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.
  2. Per-URL getCtx cache — 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 toolchain

Create 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.json

A 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

FormatWhat it isSpec
.3tzZIP with a @3dtilesIndex1@ binary index for random access; STORED (uncompressed) tile entrieserikdahlstrom/3tz-specification · Maxar MAXAR_content_3tz
.3dtilesSQLite DB, media(key TEXT, content BLOB); keys are relative POSIX paths3D 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.

On this page