3dtiled-to-3dtiles

Viewer (Cesium & 3DTRJS)

Running this repo day-to-day: two ports, the vanilla-viewer, and the standalone React viewer app.

Running this repo day-to-day: two ports

pnpm install    # installs the whole workspace
pnpm dev        # http://localhost:3000  (3D Tiled — the React app, + /vanilla-viewer + /docs folded in)
                # http://localhost:3001  (tile-server middleware — the only other port you need)

pnpm dev runs apps/viewer's own Vite dev server (dev:viewer) alongside the tile-server (serve:tiles) and the Fumadocs site's own Next dev server (dev:docs, port 3002 — that process still runs, it's just no longer something you navigate to directly: apps/viewer's Vite config proxies /docs and its asset/API routes through to it). apps/viewer's own build also folds in the legacy standalone vanilla-viewer.html (below) as a second page at /vanilla-viewer, so it ships in the same dist/ and needs no separate server either. See 3D Tiled below for the app itself.

vanilla-viewer.html — offline, self-contained via Vite, no React/build tooling needed

The original, pre-React reference viewer — kept as a from-scratch fallback that needs nothing but Vite (no Node backend beyond the tile-server, no component framework). All its deps (Cesium, three, 3d-tiles-renderer, @sparkjsdev/spark, the 3DGS plugin) are npm dependencies bundled by Vite — no CDN, works fully offline. Served at /vanilla-viewer by apps/viewer's own dev server/build (above), sharing that package's own package.json deps and vite.config.ts. (There used to be a second, repo-root copy of this file with its own standalone Vite config and duplicated dependencies in the root package.json — removed once apps/viewer folded vanilla-viewer.html into its own build; apps/viewer is now the only place it lives.)

pnpm uses pnpm-workspace.yaml + .npmrc (node-linker=hoisted, so the meta-converter's import('rad-to-3dtiles') resolves like npm's flat layout); npm uses the workspaces field. Either works.

vite-plugin-cesium copies Cesium's Workers/Assets/Widgets and sets CESIUM_BASE_URL; three is de-duplicated (resolve.dedupe) so SparkJS + the 3DGS plugin share one instance (replacing the old esm.sh ?external=three trick). Verified: 0 CDN requests — Cesium, the 3DTRJS globe, and Gaussian splat streaming all load from node_modules. The dev server serves sample-data/output/** too, so the built-in presets (which point at http://localhost:3000/sample-data/...) resolve against the same origin.

Additional online viewers:

viewer.html includes a 3DTilesRendererJS mode (toggle the 3DTRJS button) backed by Three.js. For Gaussian splat streaming, it uses 3D-Tiles-RendererJS-3DGS-Plugin which streams .rad/SPZ content via @sparkjsdev/spark:

npm install 3d-tiles-rendererjs-3dgs-plugin three 3d-tiles-renderer @sparkjsdev/spark
import { TilesRenderer } from '3d-tiles-renderer';
import { GaussianSplatPlugin } from '3d-tiles-rendererjs-3dgs-plugin';

const tiles = new TilesRenderer('http://localhost:3000/tileset.json');
tiles.registerPlugin(new GaussianSplatPlugin({
  renderer, scene,
  minRaycastOpacity: 0.1,
  sparkRendererOptions: { focalAdjustment: 2 },
}));
scene.add(tiles.group);

The viewer bundles this plugin (and SparkJS) as local npm deps via Vite — shared three@0.180 through resolve.dedupe, no CDN. npm run dev serves it offline.

viewer.html consolidates all controls into a right sidebar (Renderer · Basemap · Tileset & LoD · Navigation · Local frame · Ion · Snippet), including a Max SSE (px) input that drives CesiumJS maximumScreenSpaceError and 3DTRJS errorTarget (empty = auto, and the field shows what auto resolves to for the loaded tileset: auto · 2 for points, 16 for meshes, 0.5 for splats), and a Debug select (see below) that draws bounding volumes coloured by octree depth in both renderers. The preset bar includes the ◈ GSplat 1/2 reference splat tilesets from the 3DTilesRendererJS-3DGS-Plugin demo data (PLY→3D Tiles). For deeper tileset inspection (tree, bounds, per-tile stats) alongside this viewer, see 3DTiles-Inspector.

3D Tiled — standalone React viewer (apps/viewer), shadcn/ui, R3F, one push, WIP first pass

A ground-up port of viewer.html into a real React app, named 3D Tiled — the repo-root viewer.html stays as-is, unaffected; this app carries its own copy (vanilla-viewer.html, served at /vanilla-viewer) instead of depending on the legacy path. pnpm --filter 3d-tiled dev → http://localhost:3000 — this IS pnpm dev now (added to .claude/launch.json as "viewer", dev:viewer root script; see "Running this repo day-to-day" above). Stack: Vite + React 19 + TypeScript + Tailwind v4, shadcn/ui (hand-vendored src/components/ui/*, default dark theme, not a port of viewer.html's own hand-tuned palette), nuqs for the shareable URL-param slice (format/mode/cache/ prefetch/transform/tiling/renderer/preset — the address bar is a bookmarkable link to a given view; the fully-resolved tileset URL itself deliberately stays out of it, see state/urlState.ts's comment for why), Zustand for everything else (crop bbox, camera-derived readouts, status/timing log).

  • CesiumJS side (components/Viewport/CesiumViewer.tsx): plain imperative Cesium.Viewer in a useEffect, functionally identical to viewer.html's initCesium(). Resium was tried first per the original plan; dropped in favor of this — see the file's own comment.
  • 3DTRJS side (components/Viewport/ThreeDTRjsViewer.tsx): uses 3d-tiles-renderer's official /r3f export (<TilesRenderer>, <TilesPlugin>, <GlobeControls>, <EnvironmentControls>, <CameraTransition>) instead of hand-rolled three.js — a real capability of the upstream package, not previously used anywhere in this repo. GaussianSplatPlugin registers only for splat assets. drei's <TransformControls> on a box mesh is the crop-box gizmo (first pass: translate mode, approximate — see the file's CropGizmo comment). No imagery overlay on the background globe yet (a plain wireframe sphere placeholder) and no auto-fly-to-loaded-content — both documented gaps.
  • Both renderers stay mounted (CSS-hidden, not unmounted) when you switch the RENDERER pill — matches viewer.html's #cesium-container/#three-container behavior — and the last-loaded tileset URL replays into whichever one you switch to (App.tsx's url.renderer effect).

Real bugs found and fixed while building this (all upstream/tooling issues, not React-port artifacts — kept here since they'd bite anyone doing the equivalent Vite+Cesium+pnpm-monorepo setup):

  • vite-plugin-cesium's default cesiumBuildRootPath is the relative path node_modules/cesium/Build, resolved against the dev server's cwd. Works for the root app (cesium is right there) but not a nested apps/* package under pnpm's hoisting (cesium lives in the monorepo root's node_modules, not apps/viewer/node_modules) — every Cesium static asset (Widgets/widgets.css, the skybox JPGs, approximateTerrainHeights.json, …) 404'd. Worse: Vite's default SPA fallback served index.html (200, text/html) instead of a real 404, and Cesium's WebGL texture pipeline tried to decode that HTML as an image — crashing the entire render loop with an opaque InvalidStateError: the source image could not be decoded, not a per-asset failure. Fixed by pointing the plugin at cesium's actual resolved location (vite.config.ts) and adding appType: 'mpa' (same fix the root app's vite.config.mjs already documents for a related tile-server gotcha) so a real 404 surfaces instead of being masked.
  • The raw mt1.google.com "Google Hybrid" tile hotlink used as the default basemap sometimes answers with an HTML error page instead of image bytes, hitting the exact same Cesium crash independently — default basemap changed to OSM (a properly public tile service); Google stays selectable. The default is now Esri World Imagery with the World Boundaries and Places labels (src/lib/basemaps.ts): public, keyless and CORS-open, in both renderers. Cesium stacks the two as imagery layers; 3DTRjs textures its surface with the imagery and layers the labels through ImageOverlayPlugin.
  • TopBar's mount effect only auto-loaded a preset when the URL had none selected — meaning a reloaded/shared link (which always has ?preset=… from the previous session) silently never fired the actual tileset load, since the resolved URL is deliberately not persisted (see above).

Not yet done (first-pass, tracked as follow-up, not attempted to fully match viewer.html feature- for-feature in one pass): 3DTRJS auto-fly-to-content on load, 3DTRJS basemap imagery overlay, Ion asset loading on the 3DTRJS side, Cesium shadeless/brightness shading ported but not yet re-verified against baked-lighting photogrammetry content, crop-gizmo precision (approximate ENU conversion, no face-handle resize yet — translate only).


Live demo datasets — what's behind each viewer pill

This is not the same as Sample Data, which covers the repo's own bundled/downloadable sample-data/ directory (curl commands, local conversion). This section is specifically "here's what's behind each clickable pill in the live viewer" — many of the same upstream sources are shared between the two, but this section's job is the pill → source mapping.

The viewer's top-bar preset pills (COPC, Potree, SOG, LCC, GeoSplats, RAD, 3D Tiles, Packages, I3S, 3MX, Bing) each carry one or more named, clickable datasets, defined in apps/viewer/src/lib/formats.ts. The sections below document exactly what each one is and where it comes from, pulled directly from that file.

COPC ☁

Point-cloud octree, streamed live via /stream?format=copc.

PillSource
Autzenhttps://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz
Eiffelhttps://data.geopf.fr/telechargement/download/LiDARHD-NUALID/NUALHD_1-0__LAZ_LAMB93_KE_2025-06-06/LHD_FXX_0648_6863_PTS_LAMB93_IGN69.copc.laz
Texashttps://data.opengeos.org/USGS_LPC_TX_CoastalRegion_2018_A18_stratmap18-50cm-2995201a1.copc.laz

Potree 🌲

Point-cloud octree (1.x and 2.0), streamed live via /stream?format=potree.

PillSource
Lion 2.0https://potree.org/pointclouds/lion/metadata.json — potree.org's own hosted Potree-2.0 build of the lion scan
Lion Takanawa 1.xhttps://raw.githubusercontent.com/potree/potree/develop/pointclouds/lion_takanawa/cloud.js — potree/potree's own bundled example
Lion Takanawa LAZhttps://raw.githubusercontent.com/potree/potree/develop/pointclouds/lion_takanawa_laz/cloud.js — same scan, tiled-LAZ nodes
Vol Totalhttps://raw.githubusercontent.com/potree/potree/develop/pointclouds/vol_total/cloud.js — Potree's own "Sorvilier" Swiss survey (courtesy sigeom sa); label kept from the original preset name

SOG ⬢

Streamed-SOG (PlayCanvas LOD splats), streamed live via /stream?format=sog.

PillSource
Skateparkhttps://code.playcanvas.com/examples_data/example_skatepark_02/lod-meta.json — PlayCanvas's own SOG example asset
Roman Parishhttps://code.playcanvas.com/examples_data/example_roman_parish_02/lod-meta.json — PlayCanvas's own SOG example asset
Arrival jaffa3 (.lod)https://ugc.arrival.space/71585765/14ae36863868a5f74fbc29389e156bc5f18e0d7a612af78c492cd84cfa4a9c44_jaffa3_1777965648677_LOD/lod-meta.json — Arrival.Space's .lod format, which is byte-shape-identical to streamed-SOG, so the SOG adapter reads it directly

LCC ◆

XGRIDS Lixel CyberColor splats, streamed live via /stream?format=lcc.

PillSource
Big Mirrorhttps://raw.githubusercontent.com/willeastcott/assets/main/lcc/bigmirror/meta.lcc
Guan Temple (zip)https://da9i2vj1xvtoc.cloudfront.net/lcc-pub/fine_v2/Guan_Temple.zip — whole LCC file set bundled as one zip (XGRIDS' own CDN); lcc-live.js range-reads the zip entries directly

A third dataset, "PentHouse", was removed 2026-07-06: its bucket is missing the required Data.bin (confirmed index.bin's own LOD offset table references byte ranges up to ~316 MB into it, but only a much smaller environment.bin actually exists there) — every tile request 404s, and no complete public mirror was found. Re-add if a fixed/alternate host turns up.

RAD ✦

SparkJS BhattLod Gaussian-splat LoD, streamed live via /stream?format=rad.

PillSource
Coithttps://wlt-ai-cdn.art/spark-2.0/rad/coit-40m-sh1-lod.rad — William Liu's own CDN (the RAD/BhattLod format author)
Tastierhttps://wlt-ai-cdn.art/tastier_rad_500/0524c1a1-abf2-4969-ae40-9981ee836536_500k-lod.rad — same CDN
WIP PLY-RAD · Elevatorinput/PLY/Elevator-lod.rad — local sample-data only; no public source found (private/internal PLY-derived test asset)
WIP PLY-RAD · Svirnasinput/PLY/SvirnasAlyt-lod.rad — local sample-data only; no public source found

I3S 🧱

Esri Indexed 3D Scene Layer (mesh and point-cloud layers), streamed live via /stream?format=i3s.

PillSource
NYC Buildings (SceneServer)https://tiles.arcgis.com/tiles/P3ePLMYs2RVChkJx/arcgis/rest/services/Buildings_New_York_3D/SceneServer — Esri's own official, live-hosted "Buildings New York 3D" service (item 3653d502714248f8b81a34faba956bdb, owner esri_3d, built from NYC DoITT's 2014 building survey)
ArcGIS Rancho Mesh v17 (.slpk)https://www.arcgis.com/sharing/rest/content/items/e0dfde9f11054aac8e15edbb8aa1631d/data — the v17 dataset ESRI's own Scene Viewer showcases publicly (Fire Station No. 3), with known-good textures
ArcGIS Rancho Mesh v17 (SceneServer)https://tiles.arcgis.com/tiles/z2tnIkrLQ2BRzr6P/arcgis/rest/services/Rancho_Mesh_v17/SceneServer
ArcGIS Moro Bay LiDAR (.slpk) (point cloud)https://www.arcgis.com/sharing/rest/content/items/496552d059644b4892c51ad06bdba8e2/data — LEPCC-compressed positions, decoded live
ArcGIS Moro Bay LiDAR SceneServer (point cloud)https://tiles.arcgis.com/tiles/z2tnIkrLQ2BRzr6P/arcgis/rest/services/Moro_Bay_LiDAR/SceneServer
ArcGIS USGSNYC 2014 LiDAR SceneServer (point cloud)https://tiles.arcgis.com/tiles/z2tnIkrLQ2BRzr6P/arcgis/rest/services/USGSNYC_2014_LiDAR/SceneServer

See i3s-spec.md and the I3S section of CLI usage for the LEPCC decode / texture-embedding details behind these.

3D Tiles ◈ (DIRECT presets)

Already-3D-Tiles sources, loaded directly (no conversion/streaming adapter involved).

PillSource
3DT · Elevator (WL)https://raw.githubusercontent.com/WilliamLiu-1997/3DTilesRendererJS-3DGS-Plugin/main/data/Elevator-3dtiles/tileset.json
3DT · Svirnas Alyt (WL)https://raw.githubusercontent.com/WilliamLiu-1997/3DTilesRendererJS-3DGS-Plugin/main/data/SvirnasAlyt-3dtiles/tileset.json
3DT · Strasbourghttps://s3.eu-west-2.wasabisys.com/ems-sgct-photomaillage/ODACIT/EMS_PM2022/tileset.json
3DT · Clermonthttps://3d.craig.fr/datasets/Clermont/3dtiles/tileset.json
3DT · Lillehttps://webimaging.lillemetropole.fr/externe/maillage/2016_mel_10cm/3dtiles/tileset.json
3DT · Marseille (root.json)https://d3h9zulrmcj1j6.cloudfront.net/Marseille_Cesium/root.json
3DT · CesiumGS discrete-LODhttps://raw.githubusercontent.com/CesiumGS/3d-tiles-samples/main/1.0/TilesetWithDiscreteLOD/tileset.json
3DT · Google Photorealistichttps://tile.googleapis.com/v1/3dtiles/root.json?key=… (Google's public photorealistic 3D Tiles)
3DT · ESRI Portcoast (mesh)https://tiles.arcgis.com/tiles/V6ZHFr6zdgNZuVG0/arcgis/rest/services/Portcoast_industrial/3DTilesServer/tileset.json
3DT · ESRI Mantes-la-Jolie (splat)https://tiles.arcgis.com/tiles/uujCiiEZAflDbdxE/arcgis/rest/services/Collégiale_Mantes_la_Jolie_Drone_3D_Gaussian_Splat/3DTilesServer/tileset.json — ESRI's ArcGIS 3D Tiles Server publishing KHR_gaussian_splatting/SPZ splats directly, outside I3S
3DT · Agisoft Jam (Cloud)Agisoft Cloud's cloud.agisoft.com tileset, routed through this repo's /proxy endpoint (Agisoft sends no CORS headers, so a browser can't fetch it directly)

Packages 📦 (Tools mode)

.3tz/.3dtiles self-contained packages, served in-place via /3dtiles-self-contained.

PillSource
tastier .3tz (ZIP+index)sample-data/input/3DTILES-PACKAGES/tastier.3tz (bundled)
tastier .3dtiles (SQLite)sample-data/input/3DTILES-PACKAGES/tastier.3dtiles (bundled)
loaders.gl test.3tz (mesh)sample-data/input/3DTILES-PACKAGES/loadersgl-test.3tz (bundled)

GeoSplats ✨, Bing 3D 🗺, 3MX 🏛

These pseudo-format pills route to their own live adapters (/stream?format=geosplats, /bing, /threemx) but currently have no named preset assets wired into formats.ts — they're driven by a manual URL entry in the viewer instead. See maptiler-geosplats.md and the Bentley 3MX and Bing Maps 3D section of the design log for what each adapter does.

Aggregate 🧩

/aggregate mosaics: one 3D Tiles endpoint over many datasets (see Meta-tilesets). Two pills: Iconem — all formats (every dataset of the machine-local manifest, 692 of them, only where .local/aggregate-sets/iconem.json exists) and Open data (the open set: the RESOURCES.md open data, Aerometrex, flai's national LiDAR and Vexcel — 145 datasets). Where the Iconem manifest is absent, Iconem — demo (the 23 Angkor sites and Geghard — shared by Iconem for these demos, © Iconem, not open data) stands in for it. Sub-selections — Angkor only, Mes Aynak, one format, latest captures — are the picker's job, and every one of them is a URL (&only=, &format=, &latest=1 …).

The checkbox beside Terrain draws the globe at all: untick it for datasets that sit under the terrain, or to look at a cloud from below. The globe goes in place, the loaded tileset and the terrain choice are untouched. The curved: select (COPC and Aggregate) overrides the server's ellipsoid placement rule, see the meta-tilesets guide.

📂 manifest…, in the same row, loads your own set manifest from a local file, for example the Iconem share-link list on a deployment that does not have it, and adds it as a pill before Open data: see Uploading your own manifest.

A loaded mosaic gets a search box, hover cards on its markers and a dataset picker (show/hide, latest only, dates, formats) in both renderers; see Search, markers and the dataset picker. Markers are 13 px pastel discs with a dark ring, one colour for every format by default (the picker's colour control switches to per-format, per-provider or per-dataset hues). A marker goes away only when its dataset spans more than 10 % of the viewport and has tiles on screen, or fills more than 60 % of it: a cloud a kilometre away that has not loaded yet keeps its marker, so it can still be clicked to fly there. In 3DTRjs, a dataset root is not even requested until its bounds span 24 px on screen (lib/footprintGate.ts): at country scale the markers stand for the data, and the 100+ root fetches of a national mosaic do not happen. The same mask covers a dataset you hide in the picker: its tiles stop loading and leave the cache rather than being drawn invisible. In the picker's on-screen list, a server set with several sites in view (a national LiDAR set seen from orbit: 22 provinces, 113 datasets) is one row — one checkbox for the whole set, a count, and the sites folded under it; a set with a single site in view is listed as that site.

Renderer version and the Potree lab

The 3DTRjs side runs 3d-tiles-renderer 0.5.2 (was 0.4.28). The Gaussian-splat plugin still declares ^0.4.25 as its peer range, but it only uses TilesRenderer, invokeOnePlugin, invokeAllPlugins and forEachLoadedModel, all present in 0.5.2. Point clouds and meshes were verified rendering; splats behave identically on both versions under headless software GL (a dark blob), so check splats on a real GPU.

The new Potree point-rendering plugins are not in 0.5.2 yet; they live on a separate page, Potree lab (/potree-lab, ✺ potree lab in the top bar), rather than in this app. npm run dev:3dtr-master runs the whole viewer on a from-source renderer build (see that page).

Fixed: implicit point clouds disappeared in 3DTRjs at normal viewing distances. Our data is fine: the Potree/COPC root node has points, and /stream puts them in the implicit root tile's content. The problem is how 3DTRjs represents that tile.

  • SUBTREELoader turns the implicit root into a placeholder whose only content is the .subtree, and moves the real content into a cloned content child with the same geometric error.
  • Since v0.5.1, markUsedTiles skips a child of an ADD tile when "the parent already meets the error target in the child's region" (traverseFunctions.js, added in #1673).
  • With a ratio of 1, the content child is skipped exactly when it should be shown. Every /stream COPC/Potree cloud vanished until the camera got close (Geghard from 1.2 km: 2.3 px < target 4). The same applies at every subtree root and under external-tileset placeholders.

CesiumJS builds the same placeholder structure (Implicit3DTileContent.js) but its version of the skip, meetsScreenSpaceErrorEarly in Cesium3DTilesetTraversal.js, returns early when the parent hasTilesetContent || hasImplicitContent. So Cesium never skips under a placeholder.

Our workaround. ImplicitAddRefineFixPlugin (src/lib/implicitAddFix.ts, registered in the viewer and the lab) raises the content child's view error just above the target through the plugin error hook.

Upstream status.

  • Not reported as of 2026-09-17. The nearest open PR, #1749 "Make empty additive tile state consistent", does not touch the skip.
  • Background threads:
    • 3d-tiles#426 (lilleyse: traversal stops when the SSE is met "regardless of the tile's content"; give an empty root a large geometric error);
    • 3d-tiles#416 (ADD semantics);
    • 3d-tiles#776 (gkjohnson on spec vs CesiumJS);
    • 3DTRjs#1304 (empty external roots: lilleyse calls CesiumJS "incorrect" there and suggests external tilesets be unconditionally refinable);
    • 3DTRjs#670 (empty root tiles).
  • Suggested upstream fix, mirroring CesiumJS: apply the skip only when the parent has renderable content. A draft issue is in the changelog entry of 2026-09-17.

Fixed: 3DTRjs preset fly-tos were lost about half the time. The 3DTRjs scene lives inside <Canvas>, which mounts its children a few frames after the viewer. A preset pill calls load() and then flyToCity() immediately. load() is React state and survived, but flyToCity() hit a null ref and vanished, so the Angkor pills stayed on the whole globe and loaded nothing. Calls made before the scene exists (fly-to, markers) are now queued until it mounts; 4 of 4 runs fly correctly.

The same held in Cesium (a fly-to before the viewer existed was dropped). A third cause affected both renderers: App's "reload on renderer switch" effect also ran on mount, after the start-up load, and re-framed the whole tileset over the fly-to. It now runs only on a real switch.

Fly-to semantics changed in Cesium. A preset used to place the camera directly above its point, pitched 45°, so it looked height m north of the point. It now looks at the point from height m up, 45° down, as 3DTRjs does. A builder preview of 660 datasets now opens on its first dataset.

Control schemes (Controls mode): Globe (3DTRjs GlobeControls, the default), Orbit (three's OrbitControls), Environment (3DTRjs EnvironmentControls), Trackball and Fly, and Auto: Globe from far away, Orbit once the camera is below 3 km above the ground, back to Globe above 6 km. The gap keeps a zoom hovering around one height from flipping back and forth, and a switch waits for the end of a drag or a wheel zoom. Switching to Orbit puts its target under the centre of the screen; from a straight-down view it adds a 2° tilt, as Orbit's heading is undefined at the nadir. Heights are from the ground on Mapterhorn and AWS, from the ellipsoid on the other terrains. With every scheme:

  • double-click pivots on the picked point and moves halfway to it (animated);
  • middle-drag dollies toward the point under the cursor at drag start, drag down = in;
  • right-drag rotates around the point under the cursor (globe / environment), left-drag pans.

Every pick — the two above and the controls' own drag, rotate and zoom pivots — goes through one rule (lib/picking.ts): hidden objects (a dataset hidden by the picker, the inactive native/stream variant of a leaf), wireframes (the reference sphere with imagery off), transparent materials and back faces are never candidates; when both the data and the terrain are under the cursor only the data counts, so a cloud a few metres above the DEM wins over the ground behind it and data below the terrain stays reachable; when only the terrain is hit, the nearest terrain hit is used, so zooming toward bare ground works with no data around.

The camera's roll is left to the controls themselves: GlobeControls keeps the camera up on the local vertical when close to the surface and only nudges it toward north from far away, so orbiting a country keeps north at the top of the screen while a facade view keeps its horizon level. Far out, between zoom steps, the controls leave the roll alone, so a drag in circles slowly turns the view off north; that is upstream's behaviour and the viewer leaves it alone (a per-frame re-alignment was tried and dropped: it fought the drag). Terrain behind data (on by default) draws the globe and terrain first and clears the depth buffer before the data, so datasets below the DEM (a cloud not yet snapped to ground, a tunnel) stay visible; the terrain then never occludes the data.

Two things that used to read as "the controls froze until I switched them" are handled: the crop/tileset gizmo disables the controls for a drag and could leave them disabled if it unmounted mid-drag (they are re-enabled whenever no gizmo is shown), and EnvironmentControls refused a drag whose ray was near-parallel to its stale up after the camera had travelled (its up now follows the ellipsoid normal at the camera).

Loading while flying. 3DTRjs only cancels a tile that is no longer needed while it is still queued; downloads and parses already in flight run to completion, so a fast fly from a country view to a site first finished every intermediate view's imagery, terrain and point tiles. lib/staleLoads.ts drops a tile that has been downloading or parsing while unused for 12 updates, on the mosaic, its native-leaf children and the globe renderers. The imagery globe also refines to a 2 px error instead of the plugin's recommended 1 px (four times fewer tiles while moving).

The views a scripted flight passes through are also loaded coarser (lib/motionLod.ts): for the duration of a fly-to tween (double-click, a marker, a search hit — flight.active), the globe renderers' error target is multiplied by six, so the frames of the flight request only coarse imagery and terrain, and the frame after arrival requests the destination at the normal target. It never applies to the data, and never to user input (a drag, an orbit, a wheel step): an earlier version boosted from the camera's speed and made the points and the terrain visibly flicker between levels while panning. The camera tween itself does not hold any request back; what did was the parse queue. The imagery and Terrarium globes download no content: each tile's parseToMesh fetches its elevation grid, then its overlay image, then builds the mesh, and 3DTRjs runs every parse through one shared queue of five jobs sized for Draco and glTF decoding. A close view needs some 300 terrain tiles (the leaves, their ancestors and siblings), so they drained five network round-trips at a time: measured headless, the fine imagery of a 400 m view finished 8-10 s after the camera got there while each fetch took ~10 ms. Those two renderers now have their own parse queue of 32 jobs (GLOBE_PARSE_QUEUE); the data renderers keep the shared one.

How many terrain and imagery tiles a view takes

The terrain plugin's tree is a web-mercator quadtree of meshes: one 32×32-segment skirted grid (about 1,200 vertices, 2,300 triangles) per tile at every level, textured with the overlay image of that same tile at that same level. Terrain tiles and imagery tiles are therefore one to one: the counts below are both. The relief is coarser than the mesh: each mesh samples a sub-window of an elevation grid two levels up, and never finer than the DEM's last zoom (Mapterhorn: z14), so a z17 mesh displaces its 33×33 vertices from an 8×8-texel patch of a z14 grid. The fine mesh exists to carry the imagery texel for texel, which avoids atlasing many images onto one coarse mesh.

Measured steady state, camera fixed 400 m above Ghent, tilted from nadir toward north, 1440×900, 60° vertical FOV, errorTarget 1, Mapterhorn + Mapbox Satellite (.local/check-tilecount.js):

Pitch from nadirTiles drawnTiles residentLevels drawnTriangles drawnElevation grids
0° (straight down)2079z16–z1746 k33
45°2894z15–z1765 k37
70°62124z7–z17143 k67
80°58124z7–z17134 k67
85°58124z7–z17134 k67
90° (horizon centred)68127z7–z16157 k83
95° (looking up)69130z7–z16159 k86
110° (looking up)61136z7–z15141 k98
  • Drawn tiles are the ones on screen. Resident adds the ancestor chain (about three tiles per level up to the root) and siblings, kept by 3DTRjs's loadAncestors and loadSiblings (both on by default) so a level can swap in whole. Turning both off made the arrival slower, not faster.
  • Past 70° the count plateaus around 60–70: the near field shrinks as fast as the far field grows, and distant tiles are coarse (z7–z12 toward the horizon).
  • Looking up still loads terrain as long as the ground is in the frame: with a 60° vertical FOV the bottom edge of the view is at pitch − 30°, so the ground leaves the frame only past 120°. At 110° it fills the bottom strip and draws 61 tiles.
  • The "300 tiles" seen on arrival were transient: a teleport from a 300 km view walks the tree down through every level, and each level's tiles pass through the parse queue on the way, which is why the queue width mattered.

What a globe tile weighs

Measured over Bastia at 150 m (.local/check-globe2.js), 161 globe tiles resident, Mapterhorn + Mapbox Satellite:

Part of a tileEachReally heldCounted by 3DTRjs
Imagery, 512×512 RGBA1.05 MB169 MB169 MB
Elevation grid, 514×514 Float321.06 MB, shared by up to 16 tiles (41 unique here)43 MB169 MB
Mesh, 33×33 grid, about 1,200 vertices70 KB11 MB11 MB
Total223 MB350 MB
  • The mesh is not the cost. A flat quad instead of the 33×33 grid would save at most 11 MB of 223: imagery is three quarters of the real weight.
  • The over-count is the elevation grid. A tile samples a window of a grid from two levels up, and its bump and displacement maps are grid.clone()s of it: the pixels stay shared, while estimateBytesUsed charges each tile for its clone in full.
  • Why it matters. 3DTRjs stops requesting tiles once its cache is full, and every TilesRenderer shares one cache of 0.4 GiB by default. At 350 MB counted, the globe left the data 79 MB and Bastia stopped refining, so the globes now have their own cache (GLOBE_LRU_CACHE).

The planar (EPSG:3857) projection that 3DTRjs offers is for its generated image surface only (GeneratedSurfacePlugin({ shape: 'planar' })): 3D Tiles content stays in ECEF, and a true Web Mercator view would need every loaded tile reprojected per vertex, with the bounding volumes to match. Not exposed.

Debug: cells, or every tile

The Debug select has three views (Cells only is the first one without the white dataset roots):

ModeWhat is drawnHow
Cells + dataset rootsthe loaded tileset's own tiles — those declared in its tileset.json, external references not followed. On a mosaic: its quadtree cells, coloured by depth, and the roots of the datasets it references, in white.src/lib/mosaicOutlines.ts reads the tileset JSON and turns each bounding volume (region, box or sphere, through the tile's transform chain) into ECEF polylines; Cesium draws them as a PolylineCollection on the tileset's model matrix, 3DTRjs as LineSegments under the TilesRenderer group.
All tiles (bounds + depth)every tile the renderer currently holds, externals includedCesium's debugShowBoundingVolume + debugColorizeTiles; 3DTRjs's DebugTilesPlugin

The first one is the skeleton of a mosaic — Angkor: 18 group cells, 23 dataset roots — where the second draws thousands of volumes once the datasets stream in. On a plain tileset, "cells" shows its declared tree without its external children.

Point clouds

Sidebar sections. Six foldable sections, each remembering whether it was left open: Basemap & Terrain (imagery, terrain source, collision, terrain behind data, auto-zoom), Camera & Controls (controls scheme, field of view, orthographic), Tileset & LOD (error target, show asset, debug bounds, native Potree leaves), Material & Points (material mode, point size and shape, eye-dome lighting; Cesium's shadeless and brightness), and the two occasional ones that start folded, Crop & Extract and Local Frame.

Point colour (3DTRjs, in every material mode): RGB keeps the points' own colours, One colour paints them all the same, Height runs a blue-to-red ramp over a range in metres, read from the points loaded when the mode is picked or when Fit is pressed (ignoring the top and bottom 2 %). Heights are the tileset's own, before the Z offset. Height writes a replacement colour attribute (lib/pointColors.ts), 6 bytes a point while the mode is on, so it works the same on /stream tiles and native Potree leaves. Meshes keep their textures.

Material (3DTRjs): Default keeps the content's own materials; X-ray draws meshes as an additive tint that writes no depth and points additive, so a scan's interior shows through its walls and overlapping datasets read through each other — with an opacity slider (points; meshes draw at 60 % of it), and with one point colour, density alone reads as brightness; Wireframe draws mesh edges only. The original mesh material is kept and restored by Default; applied to every loaded tile, native Potree leaves included.

Imagery None (3DTRjs) draws the terrain unlit and black: a silhouette that hides what is behind it, with no hillshade competing with the data.

Points without colour are white, in streaming and in the offline converters alike, which reads well under eye-dome lighting. Sources with intensity but no RGB used to show intensity as grey; most scanners store small intensity values in a 16-bit field, so that grey came out near black.

Controls mode (3DTRjs) offers, besides Globe, Orbit and Environment, Trackball (rotation about the target at trackball speed) and Fly (W A S D move, R F up and down, arrows turn, drag to look; the speed follows the height above ground, so a city passes in a few seconds from 2 km up and a facade at walking pace). Both keep the camera level, like Orbit: the vertical is the ellipsoid normal (at the target for Trackball, at the camera for Fly), world +Y for a local tileset. Neither control has an up vector of its own, so the viewer removes the roll after each of their updates; Trackball stops 2° short of straight up or down instead of rolling over the pole, and Fly's Q E roll does nothing. Orbit's damping factor is 0.3, so it settles six times faster than three.js's 0.05. Fly keeps the frame loop running while it is active — the only mode that does — since it moves the camera from its own per-frame update.

The Material & Points section exposes, in both renderers, for any tileset that holds points (a mixed mosaic included — Open data is typed mesh but carries 113 COPC clouds):

ControlCesium3DTRjs
Point size (px)the style's pointSize; with adaptive sizing, the attenuation cap (maximumAttenuation = 2 × size)PointsMaterial.size
Adaptive point sizepointCloudShading.attenuation: size from the tile's geometric errorsizeAttenuation with a world-space size of geometric error × px size / 2 — the tile's point spacing for Potree/COPC-derived tiles, so coarse tiles close their gaps (what the potree lab's size from tile GE does), capped on screen at 8 × the px size (12–48 px)
Point shape— (squares)PointCloudEffectsPlugin.pointShape: round, square or sphere (bulged depth, so overlapping points intersect)
Eye-dome lighting, strengthpointCloudShading.eyeDomeLightingPointCloudEffectsPlugin.edlStrength: a depth pre-pass per renderer, then each point is shaded by how far it sits behind a ring of neighbours

In 3DTRjs every point cloud goes through 3DTilesRendererJS's PointCloudEffectsPlugin (vendored from master with the potree lab, see Potree lab): it swaps each tile's PointsMaterial for its PointCloudMaterial, which the native Potree leaves' PotreePlugin also extends, so the Point shape and Eye-dome lighting controls drive 3D Tiles points, /stream children and native leaves alike. Two patches on the vendored material, the first proposed upstream (PR #1780, see Potree lab), the second local and marked LOCAL in the source:

  • a maximum point size (maxPointSize, set to 8 × the px size): three has no cap, and a 20 cm point seen from 30 cm covers thousands of pixels, tens of thousands of which stall the GPU on fill rate (what "the viewer freezes when zooming close" is). Cesium has maximumAttenuation for this. The material also honours sizeAttenuation = false (fixed pixel size) now;
  • ADD-refinement sizing for tilesets that are not a Potree octree. A Potree/COPC cloud streamed as 3D Tiles refines additively: a coarse tile keeps drawing under its children, and sized by its own spacing its points would be blobs several times the size of the points around them. Potree sizes each point by the deepest active node containing it, from an octree texture only PotreePlugin fills. AddRefinePointSizePlugin (lib/addRefinePointSize.ts) does the same at tile granularity: each tile's material gets the boxes of up to 8 visible children (looking through implicit subtree roots) and the size to use inside each, down to the finest spacing visible in the child's subtree, and the vertex shader picks it per point.

Native Potree leaves (3DTRjs only, on by default, ?native=false to turn off) renders the Potree datasets of an /aggregate mosaic through 3DTRjs's PotreePlugin instead of /stream, with Potree's own adaptive point size. The point-size slider then sets PotreePlugin's point scale. The mosaic is always requested with &native=1; the checkbox switches each Potree dataset in place between its Potree reader and its /stream reader. Both stay mounted and only the chosen one loads and draws, so nothing is re-requested and flipping back is instant. See Meta-tilesets.

There is no useHQ in either renderer: Potree's high-quality splatting (a depth pass, then a depth-weighted blend of the points) exists in neither Cesium's PointCloudShading nor 3DTRjs. Cesium's attenuation + EDL is the closest it offers; 3DTRjs's PointCloudEffectsPlugin (round / sphere point shapes, EDL, adaptive size per point from the active octree nodes) is the lab's.

Terrain and snap to ground

Terrain — every open source is keyless and CORS-open; Google 3D and Cesium World Terrain need a key (apps/viewer/.env.example, or GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN on the Docker container). The list is the same in both renderers; Mapterhorn, the default, is drawn in each renderer's native form.

ModeSourceEncodingMax zHeights
WGS84the plain ellipsoid——ellipsoid (0)
Google 3DGoogle Photorealistic 3D Tilesmesh—ellipsoid
Mapterhorn, 3DTRjstiles.mapterhorn.com {z}/{x}/{y}.webpTerrarium, 512 px12 to 17, by locationsea level
Mapterhorn, CesiumRe:Earth Terrain cesium-mesh/ellipsoidquantized mesh14ellipsoid
AWS Terrain Tileselevation-tiles-prod/terrariumTerrarium, 256 px15sea level
Cesium World TerrainCesium ion asset 1quantized mesh—ellipsoid

Datum matters when data meets terrain. 3D Tiles sit in ECEF, i.e. on the WGS84 ellipsoid, while sea-level heights differ by the geoid undulation: +55 m in the Alps, about +16 m at Geghard, −100 m off India. So in 3DTRjs a correctly georeferenced dataset floats above Mapterhorn and AWS by that undulation until it is snapped to them. Mapterhorn itself serves orthometric heights (vertical datums are "not treated at the moment", mapterhorn#255). Re:Earth's mirror blends in EGM2008 and publishes ellipsoidal tiles too (mapterhorn-egm08/terrarium/ellipsoid, z14): &snap=mapterhorn-ellipsoid and Cesium's Snap still read those.

mapterhorn.com's depth depends on where you are: z12 is complete, and deeper tiles exist only where a regional DEM does. Probed on 2026-11-02, Geghard and Angkor stop at z12, Rome at z13, Tokyo at z16, Chamonix, Corsica and Paris at z17. It answers 404 elsewhere, which the terrain plugin would decode and fail on, leaving holes in the globe. TerrariumFallbackPlugin handles both cases: a 404 at or above z12 is open ocean and reads as sea level; a deeper one is cut out of the nearest ancestor that exists, so refinement carries on with the best data there. The plugin fetches even levels only, so z16 is the deepest it reads, and z13 or z15 are used through this fallback.

How each renderer draws them.

  • 3DTRjs: Terrarium sources through TerrariumMeshPlugin with the basemap draped on it (down to z16 on mapterhorn.com, where a regional DEM exists); World Terrain through QuantizedMeshPlugin.
  • Cesium: quantized mesh through CesiumTerrainProvider.fromUrl. Terrarium has no native Cesium provider, so src/lib/terrarium.ts adapts it as a CustomHeightmapTerrainProvider on a web-mercator scheme. Deeper than a source goes, or wherever it has no tile, a tile is cut out of its nearest available ancestor.

⤓ Snap to ground (Local Frame section, both renderers) moves the loaded tileset vertically onto the active terrain. On an /aggregate mosaic it instead reloads it with &snap=, which snaps each dataset separately, server-side (see Meta-tilesets). The button runs once, when you press it, and sets the tileset-level Z offset (an ENU shift along the local vertical). ↶ Undo beside it restores the offset from before the snap, or removes a mosaic's &snap=. Auto (follow the terrain), first in the section, keeps the tileset on the ground instead: the offset is re-measured when the terrain under it refines (World Terrain and Google 3D load finer tiles as you approach; raster terrains are sampled at a fixed zoom, so one measurement is final), when the terrain mode changes and when a new tileset loads — the dataset's own low bins against the terrain, so a scan of a basilica sits on its forecourt, not on its dome. Both measure against the terrain source selected in the Basemap section: Mapterhorn or AWS tiles sampled directly, World Terrain / Google 3D by a ray against the loaded geometry, the bare WGS84 ellipsoid as height 0. Clearance above ground (10 m by default) is added on top, so the dataset's ground bins land that far above the terrain and the scan lies above most of it instead of half inside it; 0 puts it exactly on the terrain. On a mosaic the same checkbox turns the server-side per-dataset snap on and off, and a snapped mosaic follows the terrain: changing it changes &snap= and reloads the mosaic in place. The server cannot read Google 3D or World Terrain, so under those it snaps to Mapterhorn on the ellipsoid, the closest DEM it has. Measured on the 660 Iconem datasets: 22 s for the first snapped mosaic (657 datasets get an offset; the other 3 have no readable top content), 0.08 s to switch to WGS84 afterwards, since each dataset's samples are cached apart from the terrain.

Where the samples come from (packages/core/dataset-ground.js, shared with the server): not from picks, but from the dataset's own top geometry.

  1. It fetches the root content: the implicit root's level-0 tile, or the first tiles under a content-less root, following external tilesets.
  2. It decodes the positions from GLB (node transforms, CESIUM_RTC), b3dm (RTC_CENTER) or pnts, with the root transform chain and glTF Y-up → Z-up (honouring gltfUpAxis).
  3. It bins them on an 8 × 8 east/north grid over the data's extent, and takes each bin's 10th-percentile height as the ground there. (It was 12 × 12; with a sparse root, many bins then held too few points for a percentile to mean anything.)

Every sample therefore sits where the dataset has points, whatever is loaded and whatever renderer shows it. Terrain is read at those same lon/lats:

  • Terrarium sources: the raster tiles directly, the same code in both renderers;
  • quantized mesh: sampleTerrain(MostDetailed) in Cesium, a raycast in 3DTRjs;
  • Google 3D: a pick on that tileset.

The Z offset is set to the 70th percentile of terrain − ground over the bins. Bins on roofs or canopy give smaller differences, noise below the ground larger ones.

Measured on Iconem's Geghard (88 of 88 bins, identical in both renderers):

TerrainZ offset
Mapterhorn ellipsoid (Terrarium)−53.5 m
Mapterhorn ellipsoid (quantized mesh)−56.2 m
AWS, sea level (earlier pick-based run)≈ −69 m

The ~16 m gap between sea-level and ellipsoidal terrain is Armenia's geoid undulation, so Geghard really floats ~55 m above the ground, most likely a vertical-datum slip in its georeference.

Why not picks. The first version picked the rendered surface on a 9 × 9 grid; it remains as the fallback for Draco- or meshopt-compressed content. It depended on what was loaded and on each renderer's pick volume:

  • over sparse points most narrow rays miss;
  • a wide one returns the highest point in the column;
  • Cesium's sampleHeight silently falls through to the globe.

The two renderers disagreed until the method changed. A dataset in a narrow gorge is still only as well matched as the terrain's resolution allows.

Tools mode datasets (implicit-to-explicit / upgrade)

The ⚙ Tools mode's demo datasets (TOOLS_DATASETS in formats.ts), used to exercise the tileset-level transforms rather than a format converter:

ToolPillSource
implicit-to-explicitUtrecht 3D (QT+b3dm→glb)https://bertt.github.io/cesium_3dtiles_samples/samples/utrecht3d/tileset.json
implicit-to-explicitDelaware 1.1 (QT+glb+metadata)https://bertt.github.io/cesium_3dtiles_samples/samples/1.1/delaware/tileset.json
implicit-to-explicitGrenoble Trees 1.1 (instanced glb)https://bertt.github.io/cesium_3dtiles_samples/samples/1.1/grenoble_trees/tileset.json
implicit-to-explicitPotree · Lion 2.0 (octree→explicit)this repo's own converted output, output/potree/lion_p2/tileset.json
implicit-to-explicitCOPC · Autzen (octree→explicit)this repo's own converted output, output/copc/autzen-3dtiles-from-copc/tileset.json
upgradeLille Pyramid (b3dm→glb)https://webimaging.lillemetropole.fr/externe/maillage/2016_mel_10cm/3dtiles/pyramid/tileset.json

These same bertt.github.io / CesiumGS sample tilesets are documented in more depth (tileset structure, implicit-tiling schemes) on the Sample Data page's "Open-Data 3D Tiles sample datasets" section.

On this page