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:
- COPC: viewer.copc.io, lidar-viewer.gishub.org
- 3D Tiles: CesiumJS sandbox, 3D-Tiles-RendererJS
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/sparkimport { 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.180throughresolve.dedupe, no CDN.npm run devserves 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 imperativeCesium.Viewerin auseEffect, functionally identical to viewer.html'sinitCesium(). 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): uses3d-tiles-renderer's official/r3fexport (<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.GaussianSplatPluginregisters only for splat assets. drei's<TransformControls>on a box mesh is the crop-box gizmo (first pass: translate mode, approximate — see the file'sCropGizmocomment). 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-containerbehavior — and the last-loaded tileset URL replays into whichever one you switch to (App.tsx'surl.renderereffect).
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 defaultcesiumBuildRootPathis the relative pathnode_modules/cesium/Build, resolved against the dev server's cwd. Works for the root app (cesium is right there) but not a nestedapps/*package under pnpm's hoisting (cesium lives in the monorepo root'snode_modules, notapps/viewer/node_modules) — every Cesium static asset (Widgets/widgets.css, the skybox JPGs,approximateTerrainHeights.json, …) 404'd. Worse: Vite's default SPA fallback servedindex.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 opaqueInvalidStateError: 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 addingappType: 'mpa'(same fix the root app'svite.config.mjsalready 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 throughImageOverlayPlugin. - 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.
| Pill | Source |
|---|---|
| Autzen | https://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz |
| Eiffel | https://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 |
| Texas | https://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.
| Pill | Source |
|---|---|
| Lion 2.0 | https://potree.org/pointclouds/lion/metadata.json — potree.org's own hosted Potree-2.0 build of the lion scan |
| Lion Takanawa 1.x | https://raw.githubusercontent.com/potree/potree/develop/pointclouds/lion_takanawa/cloud.js — potree/potree's own bundled example |
| Lion Takanawa LAZ | https://raw.githubusercontent.com/potree/potree/develop/pointclouds/lion_takanawa_laz/cloud.js — same scan, tiled-LAZ nodes |
| Vol Total | https://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.
| Pill | Source |
|---|---|
| Skatepark | https://code.playcanvas.com/examples_data/example_skatepark_02/lod-meta.json — PlayCanvas's own SOG example asset |
| Roman Parish | https://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.
| Pill | Source |
|---|---|
| Big Mirror | https://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(confirmedindex.bin's own LOD offset table references byte ranges up to ~316 MB into it, but only a much smallerenvironment.binactually 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.
| Pill | Source |
|---|---|
| Coit | https://wlt-ai-cdn.art/spark-2.0/rad/coit-40m-sh1-lod.rad — William Liu's own CDN (the RAD/BhattLod format author) |
| Tastier | https://wlt-ai-cdn.art/tastier_rad_500/0524c1a1-abf2-4969-ae40-9981ee836536_500k-lod.rad — same CDN |
| WIP PLY-RAD · Elevator | input/PLY/Elevator-lod.rad — local sample-data only; no public source found (private/internal PLY-derived test asset) |
| WIP PLY-RAD · Svirnas | input/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.
| Pill | Source |
|---|---|
| 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).
| Pill | Source |
|---|---|
| 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 · Strasbourg | https://s3.eu-west-2.wasabisys.com/ems-sgct-photomaillage/ODACIT/EMS_PM2022/tileset.json |
| 3DT · Clermont | https://3d.craig.fr/datasets/Clermont/3dtiles/tileset.json |
| 3DT · Lille | https://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-LOD | https://raw.githubusercontent.com/CesiumGS/3d-tiles-samples/main/1.0/TilesetWithDiscreteLOD/tileset.json |
| 3DT · Google Photorealistic | https://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.
| Pill | Source |
|---|---|
| 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.
SUBTREELoaderturns 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,
markUsedTilesskips a child of anADDtile 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
/streamCOPC/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.
Navigating in 3DTRjs
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 nadir | Tiles drawn | Tiles resident | Levels drawn | Triangles drawn | Elevation grids |
|---|---|---|---|---|---|
| 0° (straight down) | 20 | 79 | z16–z17 | 46 k | 33 |
| 45° | 28 | 94 | z15–z17 | 65 k | 37 |
| 70° | 62 | 124 | z7–z17 | 143 k | 67 |
| 80° | 58 | 124 | z7–z17 | 134 k | 67 |
| 85° | 58 | 124 | z7–z17 | 134 k | 67 |
| 90° (horizon centred) | 68 | 127 | z7–z16 | 157 k | 83 |
| 95° (looking up) | 69 | 130 | z7–z16 | 159 k | 86 |
| 110° (looking up) | 61 | 136 | z7–z15 | 141 k | 98 |
- 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
loadAncestorsandloadSiblings(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 tile | Each | Really held | Counted by 3DTRjs |
|---|---|---|---|
| Imagery, 512×512 RGBA | 1.05 MB | 169 MB | 169 MB |
| Elevation grid, 514×514 Float32 | 1.06 MB, shared by up to 16 tiles (41 unique here) | 43 MB | 169 MB |
| Mesh, 33×33 grid, about 1,200 vertices | 70 KB | 11 MB | 11 MB |
| Total | 223 MB | 350 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, whileestimateBytesUsedcharges each tile for its clone in full. - Why it matters. 3DTRjs stops requesting tiles once its cache is full, and every
TilesRenderershares 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):
| Mode | What is drawn | How |
|---|---|---|
| Cells + dataset roots | the 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 included | Cesium'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):
| Control | Cesium | 3DTRjs |
|---|---|---|
| Point size (px) | the style's pointSize; with adaptive sizing, the attenuation cap (maximumAttenuation = 2 × size) | PointsMaterial.size |
| Adaptive point size | pointCloudShading.attenuation: size from the tile's geometric error | sizeAttenuation 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, strength | pointCloudShading.eyeDomeLighting | PointCloudEffectsPlugin.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 hasmaximumAttenuationfor this. The material also honourssizeAttenuation = 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
PotreePluginfills.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.
| Mode | Source | Encoding | Max z | Heights |
|---|---|---|---|---|
| WGS84 | the plain ellipsoid | — | — | ellipsoid (0) |
| Google 3D | Google Photorealistic 3D Tiles | mesh | — | ellipsoid |
| Mapterhorn, 3DTRjs | tiles.mapterhorn.com {z}/{x}/{y}.webp | Terrarium, 512 px | 12 to 17, by location | sea level |
| Mapterhorn, Cesium | Re:Earth Terrain cesium-mesh/ellipsoid | quantized mesh | 14 | ellipsoid |
| AWS Terrain Tiles | elevation-tiles-prod/terrarium | Terrarium, 256 px | 15 | sea level |
| Cesium World Terrain | Cesium ion asset 1 | quantized 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
TerrariumMeshPluginwith the basemap draped on it (down to z16 on mapterhorn.com, where a regional DEM exists); World Terrain throughQuantizedMeshPlugin. - Cesium: quantized mesh through
CesiumTerrainProvider.fromUrl. Terrarium has no native Cesium provider, sosrc/lib/terrarium.tsadapts it as aCustomHeightmapTerrainProvideron 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.
- It fetches the root content: the implicit root's level-0 tile, or the first tiles under a content-less root, following external tilesets.
- 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 (honouringgltfUpAxis). - 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):
| Terrain | Z 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
sampleHeightsilently 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:
| Tool | Pill | Source |
|---|---|---|
implicit-to-explicit | Utrecht 3D (QT+b3dm→glb) | https://bertt.github.io/cesium_3dtiles_samples/samples/utrecht3d/tileset.json |
implicit-to-explicit | Delaware 1.1 (QT+glb+metadata) | https://bertt.github.io/cesium_3dtiles_samples/samples/1.1/delaware/tileset.json |
implicit-to-explicit | Grenoble Trees 1.1 (instanced glb) | https://bertt.github.io/cesium_3dtiles_samples/samples/1.1/grenoble_trees/tileset.json |
implicit-to-explicit | Potree · Lion 2.0 (octree→explicit) | this repo's own converted output, output/potree/lion_p2/tileset.json |
implicit-to-explicit | COPC · Autzen (octree→explicit) | this repo's own converted output, output/copc/autzen-3dtiles-from-copc/tileset.json |
upgrade | Lille 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.
Existing tilers (reference implementations)
Producing the tiled inputs (offline tilers): pointcloud tilers (PotreeConverter, PDAL), mesh and splat hlod converters
Bing Maps 3D (reverse-engineered)
How the live /bing adapter reverse-engineers Microsoft's undocumented tf=3dv4 tiled photogrammetry format into OGC 3D Tiles — URL scheme, td1 manifest, quadtree/subtree availability, web-mercator region math, and the tile GLB's own Draco+KTX2 content.