We'd love your input: Coordinate reference systems and local coordinates in CesiumJS

Hi everyone,

The CesiumJS team is exploring how we might better support different coordinate reference systems (CRSs), vertical datums, and local coordinate systems, and we’d like to better understand your needs and current workflows.

There are several existing feature requests that touch different parts of this problem:

Rather than looking at these only as individual features, we’re interested in understanding the broader workflows behind them.

Some examples of capabilities we have in mind include:

  • Reprojecting data for 2D rendering

  • Working with different vertical reference datums, such as Mean Sea Level or a regional height datum rather than height relative to the WGS84 ellipsoid

  • Displaying coordinates and measurements in a particular CRS, even when the underlying scene uses CesiumJS’s global coordinate system

  • Working directly with local/project coordinates, such as for surveying, CAD, BIM, or other engineering data where the data’s own coordinate system may be meaningful to users

  • Combining datasets from different coordinate systems

These are examples, not a proposed scope or API, each of which could ultimately require very different solutions.

We also want to be transparent that we’re in the exploration stage and have not committed to implementing these capabilities in CesiumJS. Before deciding, we want to better understand the problems you are actually trying to solve so we can balance feature requests with the overall maintainability and scope of CesiumJS

If you work with CRSs, vertical datums, or local coordinate systems in a CesiumJS application, we’d especially like to hear:

What is your use case? What coordinate systems or datums are you working with, and what do you need to accomplish?

Where do you encounter friction in you workflow? Is the challenge getting data into CesiumJS, rendering it correctly, working with coordinates at runtime, presenting coordinates or measurements to users, or something else?

How are you solving it today? Are you preprocessing data, using PROJ or another geospatial library, converting through a backend service, maintaining your own transforms, or using another visualization tool?

How important is better support in CesiumJS? Is this mostly a convenience, a significant source of complexity, or something currently blocking a workflow?

We’re also interested in cases that don’t fit neatly into the examples above. Part of the goal here is to understand whether there are common workflows we could support more cohesively, rather than addressing each coordinate-system problem independently.

Thanks!

We’ve run into this frequently on engineering and infrastructure projects.

Our biggest challenges aren’t reprojecting global datasets, but integrating survey, CAD, BIM, point cloud, geotechnical and terrain data that often arrive in different horizontal and vertical reference systems. We’ve ended up building our own CRS transformation framework, including geoid-based height corrections to support project-specific vertical datums.

The main friction points are:

  • Vertical datums are by far the biggest challenge. Horizontal transformations are relatively straightforward with tools like PROJ, but converting between ellipsoidal heights and project/local height datums adds a lot of complexity.

  • Local engineering coordinate systems are common in CAD, BIM and survey data, and users often want coordinates, measurements and asset locations reported in those systems rather than WGS84.

  • Combining datasets from multiple suppliers frequently requires custom transformation and validation workflows before data can be visualised confidently.

Today we solve this with a combination of client-side processing, using PROJ and geoid tif files, and pre-processing data using FME and Python.

For us, better support in CesiumJS would be highly valuable. The biggest wins would be:

  • Native support for vertical datums and geoid models.
  • First-class handling of local/project coordinate systems.
  • Easier coordinate display and measurement in user-defined CRSs.
  • A consistent framework for managing multiple coordinate systems within a scene.

Adding a use case, since it lines up closely with a couple of items on your list.

Use case: we’re building a web-diffused LOD300 3D digital twin of a dense urban area, sourced from BIM/CAD, natively in a projected CRS (CC44 / Lambert Conformal Conic, EPSG:3944) with a national vertical datum(NGF-IGN69 EPSG:5720) . For glTF export we reduce coordinates by a fixed false-origin offset to keep vertex magnitudes small (float32 precision into blender), then re-apply the true offset at the tileset transform level.

1. On-the-fly display conversion, independent of the render CRS
The “displaying coordinates and measurements in a particular CRS” item is exactly what we need — and we’d stress it doesn’t require touching how CesiumJS itself computes and renders (WGS84/ECEF stays the render CRS). What’s missing is a two-way conversion layer at the UI/interaction level: pick a point on the globe → report it in an arbitrary target CRS (our local projected system), and conversely, enter/query a position in that CRS → place/highlight it on the globe. For engineering and planning audiences working under a local projected-CRS mandate, lat/long or ECEF isn’t the coordinate system they think in, and right now we maintain our own PROJ pipeline and specific API for the vertical datum (Reframe from swistopo in Switzerland) just for this display layer.

2. Projected data draped on the globe without visible warping, at cm precision
This is the harder one, and we see two genuinely different strategies depending on data type, not one:

  • Large continuous data (terrain/ortho): distortion from a projected CRS isn’t constant across a large footprint, so this needs true per-vertex reprojection into geographic/ECEF. No way around it.
  • Discrete, individually-small objects (buildings): for a conformal projection like Lambert/CC44, distortion at the scale of a single building footprint is effectively a pure similarity transform (uniform scale + rotation + translation), not a warp. A single local transform matrix per object/tile is enough — vertex reprojection is unnecessary and wasteful at that scale. We rely on this distinction constantly, and it would help a lot if CesiumJS’s tooling made it an explicit, supported pattern rather than something every team derives on its own.

3. Precision on export/import, tied to point 2
Concretely: our source geometry lives in projected coordinates, locally reduced by an XY shift to protect float32 vertex precision during glTF export. What we don’t have yet is a clean, standard path to carry that shift plus the true CRS transform through to CesiumJS placement without precision loss — keep the mesh geometry small-magnitude/float32-safe, and apply the full-precision georeferencing (double precision) only at the transform-matrix level, conceptually close to a per-tile transform in 3D Tiles, but currently something we hand-roll rather than something the ecosystem standardizes around.

4. What the ideal export tooling would look like
What we’d actually want, end to end: at export time, from Blender/BIM/CAD, attach the CRS directly to each GLB as machine-readable metadata — a horizontal EPSG code and a vertical datum EPSG code, plus the true local origin (easting/northing/height) that GLB was reduced from — written into a glTF extension a tiling tool can read. From there, either the tiler computes the ECEF placement transform itself (projection + geoid/vertical datum handling) and writes it per tile into tileset.json, or we compute it ourselves and just need a standard, documented place to declare it so a generic tiler can consume it directly rather than requiring a bespoke pipeline. Right now that whole chain — CRS in Blender → transform math → tileset.json — is a project-specific script. Standardizing just the declaration (“this GLB is georeferenced in EPSG:X horizontal / EPSG:Y vertical, true origin at E/N/H”) would let tooling do the rest, instead of every team reimplementing PROJ + geoid handling from scratch.

Happy to go into more detail on our pipeline if useful.

Hi, thanks for opening this thread — we have a slightly unusual workflow that touches CRS support from an angle I don’t see mentioned above.

Our use case

We render a custom globe built from glTF (.glb) models rather than 3D Tiles or the built-in terrain/imagery pipeline. Geometry is generated server-side by our own C++ backend (much faster than doing it in JS, and gives us full control over LOD and tiling strategy), packaged as .glb, and streamed to CesiumJS which loads them as Cesium.Model instances.

We ship many .glb files, some of them very large — a single .glb typically holds one primitive with hundreds of thousands of triangles spanning the entire globe. The .glb path gives us the performance we need for this volume: binary vertex buffers, one draw call per file, no per-frame JS work. We drive per-vertex attributes and per-frame animation (movement, appearance changes) entirely through CustomShader uniforms, so the geometry itself is uploaded once and only the uniforms change frame to frame. We even render labels through .glb — the text is baked into textures on the C++ side and drawn as textured quads,
which turned out to be significantly faster than any DOM/canvas-based labeling approach we tried.

Each .glb is stored in identity model matrix. Today, our vertices carry world-space Cartesian x/y/z, and our custom vertex shader converts back to lat/lon/alt (via asin(normal.y) / atan(normal.x, normal.z)) before applying the projection. Ideally we’d store lat/lon/alt directly in the vertex buffers to skip that conversion entirely, but we haven’t tried yet because we’re not sure the CesiumJS Model pipeline (bounding volume computation, culling, picking, LOD) would accept vertices in a non-Cartesian coordinate space — the “identity model matrix” contract seems to assume Cartesian. If anyone from the Cesium team
can confirm whether the Model pipeline would tolerate arbitrary vertex semantics as long as the vertex shader emits proper clip-space positions, that would already unlock a nice optimization for us. The projection itself is then applied entirely on the GPU inside a custom vertex shader we inject via CustomShader. This lets us switch projection instantly (3D sphere / 2D / Columbus View / custom LCC / Web Mercator) without re-uploading or re-tessellating any geometry — the GPU re-projects every frame.

In production we use a spherical Lambert Conformal Conic projection (LCC with +lat_1=10 +lat_2=50 +lat_0=30 +lon_0=20, forced spherical with +a=+b=sphereRadius so it matches our shader’s spherical LCC formula exactly). Cesium’s CPU-side Proj4Projection handles the projection for the GeoJSON entities and the entity layer, while the GPU shader handles the same projection for our custom glTF geometry — both share the same sphere radius via a uniform so the two rendering paths stay pixel-aligned.

We took this route because 3D Tiles didn’t fit our constraints: we needed direct control over the vertex format, we didn’t want to bake geometry per-projection, and the server-side generation gives us much better throughput than doing tessellation in the browser.

The friction

The projection math has to run in the vertex shader, so the shader needs the current projection’s parameters as uniforms. Today, CesiumJS exposes very little about the active MapProjection to shaders: for GeographicProjection and WebMercatorProjection, everything is hardcoded in the automatic uniforms and GLSL functions; for anything else, there’s no API for a MapProjection implementation to publish its parameters to the shader side.

We ended up precomputing the projection constants JS-side (for our Lambert case: the spherical LCC exponent N, scale factor F, K0, false easting/northing…) and uploading them as CustomShader uniforms every time we build a shader. It works, but it’s tightly coupled — our CustomShader factory has to know about every projection type we support and how to unpack it into vec4-ish uniforms. Adding a projection means editing both the JS uniform packer AND the GLSL shader in lockstep. And every consumer has to reimplement the projection math in GLSL from scratch — no shared reference.

Related upstream work

We contributed the custom projection classes themselves in Add support of Proj4JS & custom map projections by mickae1 · Pull Request #13471 · CesiumGS/cesium · GitHub (Proj4Projection, CustomProjection, Matrix4Projection — adapted from @likangning93’s PR #7502). That PR already adds five GLSL automatic uniforms (czm_mapProjectionType, czm_projectionParams, czm_projectionOffsets, czm_projectionEllipsoidParams, czm_projectionEllipsoidParams2) that publish the active projection’s parameters to the shader side — exactly the kind of API we need for our workflow.

What would help us most

A stable, documented contract by which a MapProjection publishes its parameters to shaders, so any custom projection (ours or a third-party’s) can be applied on the GPU without every consumer inventing their own uniform packing scheme. The uniforms in #13471 are a good starting point but they’re currently sized for a small set of projections. Something like MapProjection.getShaderUniforms(): { [name: string]:
UniformValue } that the CustomShader pipeline picks up automatically, plus a companion GLSL include (czm_projection.glsl or similar) with reference implementations of project(lat, lon, altitude) for the built-in projections (including LCC), so users doing GPU-side projection don’t have to reimplement Snyder from scratch.

Priority

For us this is significant complexity, not blocking — we’ve built around it. But every new projection we want to support means duplicated work on both the JS and GLSL sides. A first-class API would let us delete a fair amount of glue code and would probably encourage other users to move projection work to the GPU where it belongs for large scenes.

Happy to elaborate on any of this or share code snippets if useful.

This post was drafted with the help of Claude to synthesize the work we’ve done on this workflow.