Georeferencing 3D models for Cesium
3D models don’t usually specify where they should be in the world. The position of models in the 3D worlds of video games often changes throughout gameplay, and the same model might appear in various locations.
But sometimes, particularly when we’re modelling real world environments, we always want a model to appear in the same place. There’s only one Eiffel tower, and it doesn’t move. You could specify its position in your code at run time, but what if you wanted to send your model to someone else, to be used in a different application? It might make sense to encode the Eiffel Tower’s real world position into the model of its structure so that when it’s loaded, it will always appear on the Champ de Mars in Paris.
Here, I’ll explain how to create a glTF that, when loaded into Cesium, will appear in a specific position on the globe. Because Cesium only natively supports glTF, that’s the only format I’ll cover. However, the principles are the same for most other formats, like COLLADA, which can be uploaded to Cesium Ion and converted to glTF.
I’m assuming you know a little about the glTF format and matrix transformations- even if you’re not an expert, this tutorial will (hopefully) still make sense. If you don’t, read through the introduction to glTF in the specification document, and watch this explanation of matrix transformations first.
Coordinates in Cesium
Internally, Cesium uses Earth-Centered, Earth Fixed (ECEF) coordinates, the EPSG code for which is 4978. Here’s a summary of its features:
Origin: The center of the earth- or more accurately, the WGS84 ellipsoid.
Axis orientation: Right-handed coordinate system, with the Z-axis going from the center of the earth to the north pole. Which direction is “up” will depend on where you are on the surface of the earth. If you’re in New York, Sydney’s “up” will be upside down.
Units: Meters
Coordinates in glTFs
The glTF specification describes the coordinate system used in glTFs.
Origin: Arbitrary. All points are expressed in terms of distance from the origin, but the origin could be anywhere.
Axis orientation: Right-handed coordinate system. The specification dictates that the Y axis should point up, but in practice, this may not be the case. The Y axis will point in whichever direction the glTF’s author chose.
Units: Meters
There’s a family of coordinate systems known as local tangent plane coordinates, that are used to describe positions on a section of the Earth’s surface. We can use one of these, East, North, Up (ENU), when we’re georeferencing our glTF. In ENU, we specify a 2D plane on a tangent to the earth’s surface. One side of the plane represents eastings from the origin, its adjacent side represents northings, and the up axis points upwards, perpendicular to the the plane.
The only thing that a glTF is missing from having an ENU coordinate system is an origin- we need to know where on the earth’s surface to put the tangent plane. The Y-axis becomes up, the X-axis becomes northings, and the Z-axis gets treated as eastings.
Here’s a diagram showing both ENU and ECEF:
One tricky thing to remember when loading glTFs is that Cesium does some automatic rotation. By default, it will assume your model is Y-up, as glTFs usually are, and rotate it to Z-up, which is what Cesium uses internally (At the time of writing, Cesium’s code for this is in /Source/Scene/Model.js, if you want to have a closer look). This is fine, as long as your glTF conforms to the specification and is Y-up. The takeaway here is that you don’t have to convert your model to Z-up for Cesium, it will handle that for you.
Georeferencing a glTF by specifying a node transformation
When you load a model in Cesium, you specify a model matrix, which is used to transform from the model’s own internal coordinates (model coordinates) to the coordinates Cesium uses (world coordinates).
Here’s some code taken from the Cesium tutorial on how to load a 3D model:
var modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-75.62898254394531, 40.02804946899414, 0.0));
var model = scene.primitives.add(Cesium.Model.fromGltf({
url : '../../../../Apps/SampleData/models/GroundVehicle/GroundVehicle.glb',
modelMatrix : modelMatrix,
scale : 200.0
}));
Cesium.Transforms.eastNorthUpToFixedFrame
returns a transform matrix which will convert ENU coordinates to ECEF. The first argument of the function is the origin of the ENU coordinate system- the part that's missing from the glTF coordinate system.
If we bake this transformation into the model, then we don’t need to specify the origin of the ENU frame in our code. There’s a few ways to do that, but first, let’s cover the easiest one. The glTF specification allows for us to specify a transformation for each node by adding a matrix
property. If we wanted to transform node 0 by the identity, we could do this:
"nodes" : [
{
"mesh" : 0,
"matrix": [
1
0,
0,
0,
0,
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
1
]
}
],
Now, instead of transforming the glTF by the identity, let’s transform it by a matrix that will convert it from ENU to ECEF. We don’t have to calculate that matrix ourselves, we can use Cesium to do it for us, just like the tutorial does:
let modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-75.62898254394531, 40.02804946899414, 0.0));
console.log(modelMatrix);
link to Cesium Sandcastle demo containing the above code
After running the code above, the console shows our transformation matrix. If you’re using Sandcastle, you need to click the “Console” tab down the bottom to see it.
(0.9687088347010083, -0.1596328857440164, 0.1900540327413552, 1213872.5417953378) (0.24819990647100235, 0.6230372481913564, -0.7417690973570736, -4737669.212520408) (0, 0.7657296710688308, 0.643162553982133, 4080370.9045440303) (0, 0, 0, 1)
Add that to our glTF, and we’ve done it! Now our model matrix lives inside the gltf itself, and so we don’t need to specify the coordinates of the glTF when we load it.
"nodes" : [
{
"mesh" : 0,
"matrix": [
0.9687088347010083,
-0.1596328857440164,
0.1900540327413552,
1213872.5417953378,
0.24819990647100235,
0.6230372481913564,
-0.7417690973570736,
-4737669.212520408
0,
0.7657296710688308,
0.643162553982133,
4080370.9045440303
0,
0,
0,
1
]
}
],
Here’s the new code to load the model:
var model = scene.primitives.add(Cesium.Model.fromGltf({
url : '../../../../Apps/SampleData/models/GroundVehicle/GroundVehicle.glb',
scale : 200.0
}));
The only change is that we no longer have to specify the model matrix, since the transform is now written into the glTF.
Other methods
Why not just make the glTF use ECEF to start with instead of treating it as ENU? We could just transform each vertex by the transformation matrix we generated earlier.
The problem with this approach is that the earth is big. The Earth’s radius is 6,371,000m, so the values of coordinates on the earth’s surface will be large. Our origin from before, (-75.62898254394531, 40.02804946899414, 0.0)
, in ECEF becomes approximately (1213873, -4737669, 4080371)
.
Notice how much bigger these ECEF values are. Because of how floating point numbers work, values further away from zero are less precise. On a 3D globe, precision problems can manifest as jittering, and vertices being rendered in the wrong place. Renderers can implement workarounds, but to do that, they need a better precision version of the coordinates to work from.
For more information on addressing precision problems, I highly recommend reading “Chapter 5: Vertex Transform Precision” from 3D Engine Design for Virtual Globes by Patrick Cozzi and Kevin Ring.