3dtiled-to-3dtiles

3dtiled Supported Formats

Set of Node.js SDK/CLI, standalone-packaged, converters: and middleware on-the-fly HLOD conversion (hierarchy/tile content)

Introduction: The intent (online, range-streamed)

This started as a set of Node.js SDK/CLI, standalone-packaged, converters: take a tiled / LoD source format — HLOD Hierarchical Level of Detail, tiled point clouds, meshes, or 3D Gaussian splats (COPC, Potree, I3S, stream-SOG, LCC, RAD, 3MX, GeoSplats, …) — and turn it into OGC 3D Tiles 1.1 (standard mesh primitives, TRIANGLES, POINTS, splats via KHR_gaussian_splatting + KHR_gaussian_splatting_compression_spz_2 GLB tiles). The unifying idea behind every converter is the same: translate each of the tree-hierarchy and tile-geometry as-is, since each source format is already some spatial tree of pre-tiled, LoD'd content — so the converter just maps that tree onto a 3D Tiles HLOD tree, implicit (octree/quadtree) or explicit (arbitrary), and re-encodes node content into one shared output. The decode differs per format; the output is uniform.

It has since grown into an on-the-fly streaming middleware (packages/tile-server, the offline converters' live counterpart): point it at a <format> source URL and it serves a 3D Tiles tileset live, so any 3D-Tiles-compatible client — browser/JS viewers, CesiumJS or its native plugins (Unreal, Unity, Nvidia Omniverse, Open-3d-Engine), GIS software (QGIS, ESRI etc), anything that speaks the OGC standard — can consume any of these tiled formats without a pre-conversion step, with tile-hierarchy/content caching and prefetching tunable per use case (cache=none|hierarchy|all). A viewer (vite-served React app) based on CesiumJS and 3DTilesRendererJS is the reference client, and doubles as a bbox + geometric-error-aware crop/extract tool (/extract/*) for pulling a sub-region, with user-controlled Geometric Error target precision, of any of these formats back out as LAS/COPC/GLB/SPZ/SOG/PLY.

Towards a TiTiler for 3D

A TiTiler equivalent for 3D formats: middleware that reads cloud-native, internally tiled 3D formats and streams OGC 3D Tiles 1.1 on demand — transcribing the source octree/hierarchy into a 3D Tiles tree and converting raw node geometry to glTF/GLB, served over HTTP Range so nothing is re-encoded or re-sorted. Like TiTiler for COG: zero re-tiling, zero copy.

The intent: fully online, fetching only the required portions of the underlying format hierarchy to build the 3D Tiles JSON hierarchy, then — when GLB tiles are requested — fetching via range requests the corresponding {copc, stream-sog, lcc, potree, etc} contiguous memory block just to build the requested tile.

The end state is the same model generalised to every internally-tiled format (streamed-SOG chunk runs, LCC unit LOD runs, Potree octree-node byte ranges, …): a per-format "range adapter" mapping a requested 3D-Tiles key → the source byte range to fetch and transcode.

Supported Input Format

The format name below links to its canonical spec/source (multiple links where more than one canonical source exists); formats with no public spec (reverse-engineered/undocumented) link to nothing.

Output — 3D Tiles

FormatHierarchy / subdivisionNode (leaf) contentRefineIntermediate nodesMetadata locationHTTP range
3D Tiles (output)explicit or implicit; box/region/sphere volumesglTF/GLB w/ KHR_gaussian_splatting+_spz_2 (splats) or POINTS/TRIANGLESREPLACE/ADDoptional contenttileset.json (+ .3tz)

Tiled point clouds → 3D Tiles

FormatHierarchy / subdivisionNode (leaf) contentRefineIntermediate nodesMetadata locationHTTP range
COPC (Cloud Optimized Pointcloud) · spec repooctree (EPT-style)LAZ-compressed LAS pointsADDoctree nodes (accumulate)COPC VLR + hierarchy pages✅ designed for range reads
Potree (v1 and v2) · 2.0 formatoctree (named r/r0..r7)points: position int32×3·scale+offset + rgb (2.0 octree.bin / 1.x per-node .bin)ADDoctree nodes (accumulate)2.0 metadata.json+hierarchy.bin (22 B/node) · 1.x cloud.js+.hrc✅ (2.0 single octree.bin; 1.x per-node files)

Generic tiled formats → 3D Tiles

Formats that carry (or wrap) a general node/mesh hierarchy rather than a point- or splat-specific one.

FormatHierarchy / subdivisionNode (leaf) contentRefineIntermediate nodesMetadata locationHTTP range
3D Tiles packages .3tz/.3dtiles3tz spec · .3dtiles proposal #727passthrough — whatever the wrapped tileset.json declares (explicit or implicit)the wrapped tileset's own tile content, unchanged(wrapped tileset's own)(wrapped tileset's own).3tz: ZIP + @3dtilesIndex1@ binary index (path→offset) · .3dtiles: SQLite key TEXT, content BLOBN/A — O(1) offset read / SQLite key lookup, not source-file byte-ranges
I3S (Esri, OGC Community Standard)node tree (always explicit — no implicit/template scheme), OBB/MBS LoD (SLPK or REST)nodes/geometry/textures; DefaultGeometrySchema = PerAttributeArray (pos f32×3 · normal f32×3 · uv0 f32×2 · color u8×4, sequential) or DracoREPLACEnode pages (nodepages/*.json)3dSceneLayer.json + node pages✅ REST/range
3MX (Bentley/Acute3D)master Scene.3mx JSON → 3MXBO binary node treeOpenCTM (MG1/LZMA) mesh + JPEG baseColor → transcoded to glTFREPLACE3MXBO node-tree levelsScene.3mx + per-node .3mxb➖ per-file GET (lazy external-tileset fragments per .3mxb)

Bing Maps

FormatHierarchy / subdivisionNode (leaf) contentRefineIntermediate nodesMetadata locationHTTP range
Bing Maps 3D (no public spec — undocumented, reverse-engineered)quadtree of quadkeys (availability by HTTP probe)tf=3dv4 GLB — Draco geometry + KTX2 textures, ECEF baked into the node matrixREPLACEquadkey levelsmanifest by quadkey/genid➖ per-file GET

Tiled Gaussian-splat sources → 3D Tiles

FormatHierarchy / subdivisionNode (leaf) contentRefineIntermediate nodesMetadata locationHTTP range
SOG (streamed) (PlayCanvas) · SOGbinary spatial tree (AABB split)unbundled SOG chunk: WebP textures → DataTable (pos/quat-w-first/log-scale/SH-DC/logit-opacity)REPLACEempty grouping nodesno geo metadata, lod-meta.json + per-chunk meta.json✅ per-file
LCC (XGRIDS) · whitepapergrid of Units (cellX×Y) × per-cell LODsData.bin fixed 32 B/splat: pos f32×3, color rgba8, scale u16×3, rot 10-10-10-2 (+SH Shcoef.bin 64 B)REPLACEper-cell coarse LODsgeoref'ed, meta.lcc JSON + Index.binIndex.bin offsets into Data.bin
RAD (SparkJS) · ext-splatsLoD merge tree, variable fanout (Tiny-LoD base β≈1.75; Bhatt-LoD unspecified, our converter observes ~1.5 in practice)RADC chunk: per-splat columns — f16/oct88r8 interleaved, ln_0r8/r8_delta planar, f32_lebytes byte-transposedREPLACEdownsampled merged splatsno geo metadata, root RAD0 JSON + per-chunk RADC JSON✅ chunked .radc
GeoSplats (MapTiler)octree voxel grids (grid_1/8/64/512) × 8 progressive LODsper-octant SOGS (means_l/u, quats, scales, sh0 WebP) or octant_<n>.plyREPLACE (LOD subset)coarser-LOD octantsgeoref'ed,model.json (georef) + per-sub-model metadata.json (voxel_grids + entries TOC)✅ per-file / range

3D Tiles → 3D Tiles transcodes

The input is already 3D Tiles — these are tileset-level transforms (the live/streaming-flavored counterparts to 3d-tiles-tools). Served live by the /3dtiles-tools endpoint (packages/tile-server/3dtiles-tools-live.js): tileset JSON is rewritten once and cached; each tile is converted on demand (like /stream).

tool=implicit-to-explicit — read a tileset with implicitTiling (OCTREE/QUADTREE) + .subtree files, walk the availability bitstreams, and emit an explicit nested tree (derived boxes via the same octreeSubCube/cubeToBox core helpers as the octree converters). Per-tile .b3dm/.pnts content is converted to glb on the fly; .glb is proxied as-is. Works on local dirs and remote URLs.

tool=upgrade — legacy → 3D Tiles 1.1 with per-tile content conversion (packages/3dtiles-tools/upgrade.js):

  • JSON-level: asset.version1.1, legacy content.urluri (older 1.0/0.0 tilesets).
  • b3dmglb: a b3dm is a header + feature/batch tables + an embedded GLB — extracted by slicing to the GLB (zero-copy where possible).
  • pntsglb: parsed and re-encoded to a glTF POINTS GLB (native fast builder; POSITION / POSITION_QUANTIZED, RGBA/RGB/RGB565/CONSTANT_RGBA, RTC_CENTER → node translation).
  • glTF 1.0 → 2.0: if the embedded GLB is version 1 (legacy pg2b3dm/older exporters), it is upgraded to glTF 2.0 via gltf-pipeline (processGlb) so Three.js/3DTRjs can load it (CesiumJS reads glTF 1.0 natively, Three.js does not).
  • CESIUM_RTC baking: because we serve a plain GLB (bypassing each renderer's native b3dm/RTC handling), the b3dm's CESIUM_RTC.center (an ECEF offset) is folded into a wrapper node translation and the extension stripped. The center is pre-rotated by the inverse glTF up-axis (RX−90°: (x,y,z)→(x,z,−y)) so the renderer's Y-up→Z-up RX+90° restores the true ECEF position — otherwise the ~6378 km-magnitude center is itself rotated 90° and the geometry lands thousands of km away (correct bounding boxes, empty tiles). The small relative mesh vertices still get RX+90°, as in native b3dm.

i3dm/cmpt content and full PBR material rebuilds are deferred to 3d-tiles-tools (upgrade --targetVersion 1.1).

viewer.html's "⚙ Tools" mode groups a third operation, packages, alongside these two for UX — but it isn't a /3dtiles-tools?tool=… value at all: it routes to the separate /3dtiles-self-contained endpoint (a .3tz/.3dtiles package → a standard streamed 3D Tiles tileset — see "/3dtiles-self-contained — serve .3tz / .3dtiles packages in-place" in the architecture doc), not a tileset-level transcode. The grouping is "pick an operation to demo," not an architectural claim that packages lives under /3dtiles-tools.

Doable but low value (noted, not planned):

  • Point-budget / downsample proxy — serve fewer/decimated tiles to weak clients. Marginal: a capable renderer (CesiumJS / 3DTilesRendererJS) already loads only what its SSE budget needs.
  • Mesh → points — emit only vertices of a mesh tileset. Niche (loses the surface; render-time can approximate), listed for completeness.

For full offline pipelines (merge, mergeJson, upgrade, b3dmToGlb/pnts/i3dm/cmpt → glTF), use 3d-tiles-tools — the canonical CesiumGS toolbox.

On this page