Navara is a 3D globe map engine: reusable GIS logic lives in a Rust/WASM core, and drawing is delegated to a swappable CG-rendering library. @navaramap/three is the Three.js-based binding (currently the only one; more rendering engines are planned — avoid wording that fixes Navara to Three.js in prose). Its public API is ThreeView plus a declarative Source/Layer/Descriptor model.
Packages
| Package | What it provides | When you need it |
|---|---|---|
@navaramap/three |
ThreeView (default export), Color, geodetic math utils, MeshDesc/EffectDesc/LightDesc base classes, handle types, built-in attribution UI (view.attribution, on by default) |
Always |
@navaramap/three-default-plugin |
DefaultPlugin, DefaultDescriptions (registers ~40 built-in descriptors) |
Almost always |
@navaramap/three-default-descs |
Individual descriptor classes/types (BoxMeshDesc, SSREffectDesc, SunLightDesc, …) |
Typed addMesh<T>/addEffect<T> calls, or manual registration without DefaultPlugin |
@navaramap/three-plugins |
PersonViewPlugin, OverlayPlugin, CesiumIonPlugin, TileJsonPlugin |
Per feature |
@navaramap/three-api |
Standalone GIS math (no view) | Pure geometry computation |
Most apps need only the first two.
Install every package your code imports from, explicitly. @navaramap/three-default-plugin depends on @navaramap/three-default-descs, but importing descriptor types from the latter with only the plugin installed fails to resolve under pnpm (transitive deps are not importable). Peer deps to install alongside: three, postprocessing.
The canonical setup order (invariant)
import ThreeView from "@navaramap/three";
import {
DefaultPlugin,
type DefaultDescriptions,
} from "@navaramap/three-default-plugin";
const view = new ThreeView<DefaultDescriptions>({ shadow: true }); // 1. construct
const defaultPlugin = new DefaultPlugin();
view.addPlugin(defaultPlugin); // 2. add ALL plugins — before init, or it throws
await view.init(); // 3. async init (WASM + workers + pipeline)
defaultPlugin.addDefaultPhotorealScene(); // 4. optional photoreal sky/sun/AA bundle
view.setCamera({
lng: 139.77,
lat: 35.68,
height: 10000,
heading: 0,
pitch: -30,
roll: 0,
});
const src = view.addSource({
type: "raster-tile",
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
maxZoom: 18,
});
view.addLayer({ type: "raster", source: src }); // 5. sources/layers/effects after init
When DefaultPlugin is used, parameterize the view as new ThreeView<DefaultDescriptions> so descriptor keys are typed.
Start from a recipe when building a scene
For "make it look good" goals, use the proven compositions in references/recipes.md (e.g. the photoreal base scene: DefaultPlugin + photoreal scene + Re:Earth quantized-mesh terrain + EOX satellite imagery + attribution) instead of assembling pieces from scratch.
Four capability tiers — pick the lowest that solves the problem
- Declarative API — add sources and layers with plain config objects. Covers basemaps, terrain, vector data, 3D Tiles. → references/declarative-api.md
- Imperative API — per-feature evaluation (
FeatureEvaluator), picking, terrain sampling, geodetic/ECEF math. → references/imperative-api.md - Plugins — reusable purpose-built features (photoreal scene, first-person walk, DOM overlays, attribution), and how to write your own. → references/plugins.md
- Custom descriptors — your own meshes/effects/lights, with access to the render pipeline, depth buffer, and normal/G-buffer (MRT). → references/custom-desc.md
Critical gotchas (apply everywhere)
addPlugin()afterinit()throws. All plugininit()s run in parallel duringview.init().- Updates are partial merges:
Layer.update(),Source.update()and mesh/effect/light handle.update()all merge into the current config — omitted materials and omitted fields within a material are preserved (verified:layer.update({ point: { color } })keepssize/clampToGround). Note:Layer.update()'s JSDoc says "the entire configuration is replaced" — that text is outdated; trust the merge behavior shown in its own@example. - Vector materials consume only their native geometry unless
geometryTypesopts in more.point/billboard/textdefault to["point"],polylineto["line"]— polygon data with only apolylinematerial renders nothing. Opt in with e.g.polyline: { geometryTypes: ["line", "polygon"] }(polygon boundary rings incl. holes render as closed polylines) orpoint: { geometryTypes: ["point", "line", "polygon"] }(one point per vertex, ring-closing duplicate skipped). The array replaces the default, so include the native category to keep it. Boundary polylines are at ring base height only — extruded-polygon edges remainpolygon.outline's job. Two caveats: (1)geometryTypesis construction-time config —layer.update()applies a new value only to tiles loaded afterwards (resident tiles keep their old geometry, and toggling back never removes already-built derivations); set it at layer creation or delete/re-add the layer. (2) On tiled paths derivation walks tile-clipped rings; boundary polylines drop clip-introduced edges automatically (draped and non-draped are both clean), but vector-tile-derived points can still appear at clip-introduced vertices near tile edges. - One source can back several layers. Point many
addLayer({ type: "vector", source })calls at the same vector-tile source to style sublayers separately (sourceLayers: ["water"],["roads"], …) or to stack materials on the same sublayer (apolygonfill layer plus apolylineoutline layer oversourceLayers: ["buildings"]). Each tile is fetched and decoded once no matter how many layers match; every matching layer gets its own features, batch-id space andlayer.update()/delete()handle. Layers with nosourceLayersmatch every MVT sublayer. Stacking a wider symbol under a narrower one (an outline) is the common use: add the wider layer first, since render order is add order. Two stacked point layers needdeclutter: falseon both — screen-space decluttering is on by default, so the sprites at identical positions suppress each other and the underlay silently vanishes. Non-clamped stacked polylines also need a smallheightdifference or they z-fight. - Sources are reference-counted:
source.delete()returnsfalsewhile any layer still references it. Updating a source resets and reloads every referencing layer. - Inline GeoJSON
datadoes not reload viasource.update()(verified 2026-08: readouts driven from the same code ran, but the rendered features never changed; URL-based sources do reload). Re-pointing the existing layer at a fresh source also failed to render. The working pattern for dynamic inline data is rebuild both:layer.delete(); source.delete(); source = view.addSource({ type: "geojson", data }); layer = view.addLayer({...}). For measurement-style dynamic overlays (a handful of markers + a line), mesh descs are the simpler tool: add/deleteSphereMeshDescmarkers and aTubeMeshDescarc via handles — seeexample/pages/examples/api/measure-geodesic. - Labels and sprites face the camera unless you opt out, and each feature can differ. Three options on
text/billboard/point. The facing option (textFacing/billboardFacing/pointFacing) picks the plane:"upright"(default) stands the quad up,"flat"lays it on the globe surface around the anchor, wrapped to the curvature (so a pixel-sized label at globe scale stays on the surface instead of lifting off at its ends), so it reads as painted on the surface.rotateWithCamera(defaulttrue) picks whether it follows the camera:truekeeps it facing the viewer (a screen-aligned billboard, or for"flat"a yaw that keeps text reading left-to-right),falsefreezes it in the anchor's east-north-up frame.rotation(degrees, clockwise from the front, default0) then spins it inside that plane about its anchor —centerpicks where inside the quad the pivot sits. All three are also per-feature from a feature evaluator, returned asfacing/rotateWithCamera/rotation, so one batch can hold labels at eight different bearings in a single draw call. Evaluated values win over the material —layer.update()re-emitsfeatureUpdated, so a registered evaluator re-runs and reapplies its values after any material change. To let the material govern a feature again, have the evaluator return the material value for it (an evaluator that simply stops returning a property leaves the last value in place). Gotchas:upright+rotateWithCamera: falseis a real signboard — invisible edge-on from directly above and mirrored from behind (its face points south, so a north-looking camera reads it), andbackfaceCulling: true(defaultfalse) hides it from behind instead. The same option trims a large flat quad at the limb, since its wrapped-over parts face away; for"flat"keepoffsetDepth: true(the default) or the label z-fights the surface it lies on; and declutter always measures a screen-aligned box, so it over-estimates a flat or edge-on quad's footprint (hides slightly more than needed, never less). - Non-draped vector polylines are lit; draped ones are unlit.
clampToGround: falsepolylines render through a lit shader (material.lights = !isTexturized), so with zero lights they draw black — invisible on a dark basemap and a classic "the line is missing" trap. Draped (clampToGround: true) lines bake into tile textures and ignore lights. Fix: addview.addLight({ ambient: { intensity: 1 } })(needs DefaultPlugin) to show the line at its plain color. - Polyline
widthis screen px, but clamped bymaxWidthin world meters (default 1000). From a whole-globe camera 1 px ≈ 11 km, so the default clamp makes any line sub-pixel — passmaxWidth(e.g.100_000) for high-altitude views. Draped polylines additionally vanish entirely at globe-scale zoom (bake resolution) regardless ofmaxWidth(verified at camera height ≈ 9,000 km; they render fine at ≈ 2,000 km) — use non-draped lines for global arcs. - WASM-backed math throws before
view.init().degreeToRadian,EllipsoidGeodesic,geodeticToVector3, … call intonavara_wasm_api, whichview.init()initializes — a module-top-level call fails with e.g. "Cannot read properties of undefined (reading 'angleToRadian')". Plain lat/lng/height constants are fine at module top level (they're just degree numbers); call the WASM-backed functions on them only after init. - Layer render order = add order (e.g. add terrain before the raster basemap draped on it).
elevationHeatmap: omittinglogBoundarybreaks software-GL backends.Math.log(mat.logBoundary)(navara_threemesh/tile/index.ts) turns the missing value into a NaN uniform, and the branch-free ternary inelevation_pars_fragment.glslevaluates the log path even withlogarithmicfalse. ANGLE Metal flushes the NaN (renders fine); SwiftShader — i.e. headless Playwright, the screenshot pipeline, CI — propagates it and every pixel renders one constant color (verified 2026-07 on both backends with identical code). Until the engine guards that computation, passlogBoundaryexplicitly when headless captures matter. The material itself is unlit and needs no terrain layer or lights;globe.elevationColormapsupplies the colors.- Deferred lighting recipe: set
view.lit = falseso materials output albedo while the lit pipeline keeps writing normals and the shadow buffer, then re-light in a late post pass fromgetNormalTexture()+getShadowTexture(). A per-mesh/materiallit: trueopts something out, and the shadow buffer's G channel is the per-pixel mask for it — transparent unlit materials cannot be masked this way (they are already blended into the albedo). Togglinglitrecompiles, so it is a config switch, not a per-frame control. Worked effect:example/pages/debug/buffers/run.ts; option semantics: docsthree/api/threeview-properties#lit. aerialPerspective.irradiance: trueIS a deferred lighting pass — always pair it withview.lit = false.addDefaultPhotorealScene({ deferredLighting: true })sets both. It re-lights the G-buffer from the precomputed atmosphere through the normal buffer, so the defaultview.lit = trueapplies lighting twice, forward pass plus atmosphere (exposure then depends on the basemap: about10on dark satellite imagery, about3on bright tiles, andToneMappingMode.NEUTRALkeeps tiles even; docsthree_default_descs/effect-desc/tone-mapping-effect-desc#choosing-the-exposure). That path is Lambertian, so cascaded shadows and the sun's specular are gone (cloud shadows survive).- Enable shadows and specular in that lighting with the effect's
shadow/specularoptions:addDefaultPhotorealScene({ deferredLighting: true, shadow: true, specular: true })at setup, oraerialPerspective.update({ aerialPerspective: { shadow: true } })at any time, withfalsereleasing the shadow G-buffer again. Tuning is flatshadow*keys on the same config, not a nested object, and they survive the term being off. Both terms needirradiance: trueplususeNormalBuffer, and neither reaches transparent surfaces orlit: truemeshes, so close-ups that need shadowed glass or water keepirradiance: falseand the defaultlit. Option list: docsthree_default_descs/effect-desc/aerial-perspective-effect-desc. Worked page:example/pages/debug/deferred-lighting. aerialPerspective.specularhas no roughness or reflectance option — both are per-pixel material properties read from the normal buffer. glTF and most mesh types leave roughness at1, which spreads the term into a faint sheen instead of a highlight, so the layer has to ask for one:model: { metalness: 0.5, roughness: 0.2 }. Reflectance below0.01is skipped, so terrain, polylines, sprites and text stay matte. The terrain watermask sets its own(0.02, 0.4)for open ocean, and lowering that roughness collapses the glitter into a hard dot.- Only materials that run three's lighting chunks write the shadow buffer. Terrain/raster tiles fall back to
MeshBasicMaterialwithout vertex normals (quantized-mesh+requestVertexNormals: true), a hillshade material oruseNormal: true, and a basic material never writes a shadow term. Sprites, text, points,LineMaterialand customShaderMaterialwrite the "no data" value on purpose. - The shadow buffer holds PCF output, not penumbra data.
renderer.shadowMap.typeis fixed toPCFShadowMap, so softness issun.shadowRadiuswidening a 3x3 kernel and reads as stipple past ~2. With no blocker distance in the buffer, a post filter can only smooth the edge at one radius. SnowMeshDesc/RainMeshDescmaxHeightis an opacity fade, not a spawn ceiling. Every frame the material opacity is multiplied bymax(1 - cameraHeight / maxHeight, 0), so with the defaultmaxHeight: 3000a camera at or above 3 km renders zero visible snow while everything still runs — a silent empty screenshot. Particles also spawn in anareaWidthxareaHeightbox around the camera andsizeis world-space (sizeAttenuation), so flakes only read at their nominal size when the camera is inside the weather, not looking at it from above.sun.shadowFardefaults to 50 km, spreading the 4 cascades so wide that person- or car-scale shadows never resolve. For character/street-level scenes useshadowFar: ~1000withshadowLambda: 1(fully logarithmic split) — that is what makes a walking character cast a visible shadow (example/pages/examples/plugin/person-view).- Never reconstruct a G-buffer normal with
xy * 2 - 1— it is octahedral; decode withunpackVec2ToNormalfromNORMAL_PACKING_SHADER. The wrong reconstruction looks plausible and costs a long debugging session. Known inconsistency to watch for:instancedSprite.frag.glsl/sdfText.frag.glsldefine a LOCALxy*0.5+0.5packer that does not match. - Allocating any optional G-buffer silently re-routes meshes into the MRT pass (so their own normal/shadow/mask is written instead of the terrain's showing through). Expect the first
addEffectthat needs a buffer to recompile and to shift where meshes render. Self-shadow acne (striping) on large mesh faces in the shadow buffer is tamed withsun: { shadowNormalBias: ~3 }. - A custom effect should inherit scene config, not re-declare it —
ctx.findLight("sun")/findEffect/findMeshresolve active descs by registered key, so a lighting effect reads the sun's real intensity andview.atmosphere.sunDirectioninstead of growing its own options. - Toggling terrain on/off: delete/re-add the layer.
terrainLayer.update({ terrain: { show: false } })has no visible effect (verified 2026-07);layer.delete()drops the surface to the flat ellipsoid and draped raster layers re-drape automatically. - Handle events vs desc events: mesh/light/effect handles (
BaseHandle) only emitdeleted— desc-specific events (load/error/animationReadyon GLTF, instanced-GLTF and splat descs) live on the desc, so subscribe viahandle.ref.on("load", ...). - Camera events: listen to
move, notmovestart. User interaction and the camera movement methods emit events, but instantaneous ones (setCamera,lookAt,rotateAroundAxis,flyTowithduration: 0) finish inside one frame and emit onlymove+moveend— nomovestart, since there is no movement to open. Continuous movement (gestures, their inertia, an animatedflyTo) emits the fullmovestart->move... ->moveend. Each event fires at most once per frame, but an instantaneous change emitsmoveandmoveendin the same frame, so a handler bound to both runs twice for onesetCamera— keep it idempotent. Follow mode emits nothing: whilecameraFollow/cameraFreeLookis active the camera is repositioned every frame with no status pushed, so pollpositionGeographiconpreRenderinstead of listening formove. - Never write to
view.camera.rawfrustum fields (fovetc.) — the engine overwrites them and Rust-side culling desyncs. Use theview.camera.fov/near/farsetters. - Units: every public lat/lng API takes degrees —
setCamera/flyTo,geodetic, terrain sampling,geodeticToVector3/vector3ToGeodetic,EllipsoidGeodesic(headings in degrees too). Meshpositionis ECEF meters. Three.js-level angles stay radians (Eulerrotationfields,rotateAroundAxis/rotateAround— that's whatdegreeToRadianis still for); batch IDs are 24-bit. - Ground placement needs
sampleTerrainMostDetailed: the synchronoussampleTerrainHeightreads only render-resident tiles, so from a distant camera it returns a coarse-LOD height (tens of meters off) —await view.sampleTerrainMostDetailed(source, positions)(source = the registered terrain source's handle or id, always explicit) fetches the source's max-LOD tiles regardless of the camera. See references/imperative-api.md. - Placing a mesh geographically: use
geodetic—{ lng, lat, height, heading, pitch, roll, scale }in degrees and metres, the same convention assetCamera. It builds a West-Up-North (Y-up, +Z north) frame, which matches glTF's front=+Z / up=+Y convention, so an unmodified glTF asset needs noRx(+90°)up-axis correction.headingis the compass bearing the asset's front faces, andpitch/rollalso cover in-frame flips — a Y-down splat capture is upright withpitch: 180instead of a separaterotationoffset. AddheightReference: "terrain"to makeheightrelative to terrain (a live subscription that re-clamps as tiles stream — for a one-shot placement,sampleTerrainMostDetailedis cheaper).position/rotation/scalestay available as offsets inside the frame, andgeodetic,matrixandmatrixWorldshare one placement slot — setting any two of them throwsConflictingTransformError. - Low-level placement: raw
position/rotation/scaleare earth-centered ECEF metres, so a barepositionwon't sit upright at a lng/lat. SetmatrixWorldto a tangent-frame matrix when you need a framegeodeticdoesn't build —eastNorthUpToFixedFrame(ENU, Z-up),northEastDownToFixedFrame(NED),northUpEastToFixedFrame(NUE, Y-up but +X north),northWestUpToFixedFrame(NWU),westUpNorthToFixedFrame(WUN, whatgeodeticuses). All are exported from@navaramap/three. Note the Z-up frames need anRx(+90°)correction for glTF assets; WUN does not. See references/imperative-api.md. - Init-only options cannot change after
init():shadow,maxSse,segments,useNormal. - Optional G-buffers: declare, never assume. They exist only while an active effect lists them in
static requiredBuffers, so read them through theViewContext/MRTPassEffectDescgetters (undefinedwhen disabled) and re-fetch every frame — a config change rebuilds the attachments. Never hardcode an attachment index; they are packed and shift. Switching the set reallocates attachments and recompiles shaders, so add an effect once and tune viahandle.update()rather than add/remove per frame. Only MRT-pass fragments write the G-buffer at all — a mesh sitting in the opaque/transparent scene contributes nothing to it. - Fixed buffer needs go in
static requiredBuffers, config-dependent ones in agetRequiredBuffers()override. The view reads the instance method (its default returns the static), and an override replaces the static completely, so never declare both. It is read beforeonCreate, so derive it from the constructor config, and emitctx.emit("gbufferRequirementsChanged")when anupdate()changes the result. - Effect compatibility:
hideUnderground: falseandlogarithmicDepthBufferbreak some effect descriptors — test, and prefer defaults. picking: true(constructor, default on) is required for thefeatureClick(click or touch tap) andfeatureHover/featureEnter/featureLeave(pointermove) events and pickable meshes. Hover picking activates only while a hover-family listener is registered.- API stability tiers:
ThreeView= Tier 0 (stable).Plugin+ViewContext= Tier 1 (may break between minor versions). Keep app code on Tier 0 where possible. - Runtime assets (atmosphere/cloud/noise/water) bundle automatically — each file is referenced via a static
new URL(..., import.meta.url), so Vite/webpack emit them into the app build; no copying or config needed. To self-host instead, passatmosphereAssetsUrl(Atmosphere) /assetsUrl(Clouds) pointing at a directory that keeps the original filenames (transmittance.exr,local_weather.png, …); both default toundefined= bundled assets. There are no directory URL constants (appending filenames to a directory can't survive bundler hashing) — only per-file constants exist (ATMOSPHERE_TRANSMITTANCE_URL,CLOUD_LOCAL_WEATHER_URL,STBN_URL,WATER_NORMAL_URL,STARS_ASSETS_URL, …).
Lighting — when it's needed and which light to add
Lighting only affects surfaces that carry normals. Anything without normals is shaded uniformly (fullbright) and needs no light. But once normals are present, no light means the surface renders black. Add at least one light when you have:
- meshes / GLTF models that ship vertex normals
quantized-meshterrain loaded withrequestVertexNormals: trueraster-demterrain rendered with ahillshadematerial- the bare globe when the view was constructed with
useNormal: true(init-only) — needed for the sun to shade the globe when no terrain or hillshade layer supplies normals
Built-in lights — register via DefaultPlugin, then view.addLight<T>({ ... }):
| Descriptor | Key | Use |
|---|---|---|
AmbientLightDesc |
ambient |
flat fill — raise overall brightness or light everything uniformly; no direction, no shadows |
LightProbeDesc |
lightProbe |
pseudo-IBL from spherical-harmonics coefficients (e.g. a baked night ambient) |
SkyLightProbeDesc |
skyLightProbe |
dynamic sky ambient that follows the sun through the atmosphere system |
SunLightDesc |
sun |
directional sun; its direction is derived from atmosphere.date, and it casts CSM shadows |
addDefaultPhotorealScene() already adds a sun + skyLightProbe — start there for realistic scenes and add ambient/lightProbe to taste.
A sun light also works standalone, without addDefaultPhotorealScene(): its direction still follows atmosphere.date, and its color is computed from the atmosphere transmittance texture, which loads on demand (verified 2026-08 shading quantized-mesh terrain with only view.addLight({ sun: {} })). Note view.toneMappingExposure has no visible effect in such a scene — tone mapping is inactive without the photoreal effect stack, so don't reach for exposure to fix brightness there; use the light's intensity.
atmosphere.date is local-time-sensitive. It's a plain JS Date, so new Date("2024-06-21T12:00:00") is read in the device's timezone — the sun lands in a different place per machine. Pin a global instant with an explicit UTC string (new Date("2024-06-21T12:00:00Z")). To hold the same time-of-day or sun elevation while flying the camera to another country, don't recompute by hand — use view.atmosphere.setDateFromCameraAt({ lng }) (keeps local solar time) or setElevationFromCameraAt({ lat, lng }) (keeps sun elevation).
Where to verify — never guess API details
Primary reference: the docs site — https://navara.world/docs/ (Japanese under /ja/). Sections: /three/ (core API: sources, layers, materials, camera, events), /three_default_descs/ (every built-in mesh/effect/light Descriptor and its options), /three_default_plugin/, /three_plugins/.
- Do not guess material or config property names — this skill shows patterns, not exhaustive option lists. Verify exact fields against the docs site, or the TypeScript definitions in
node_modules/@navaramap/*(.d.ts). - Working inside the Navara repository? The docs source is
docs/src/content/docs/and runnable examples areweb/navara_three/example/pages/— reference paths in this skill starting withexample/pages/refer to that examples directory.