Cesium have released their new Moon tileset via their Cesium Ion service! This is huge as it provides high quality terrain in 3D tile format to their Cesium suite of products. They now have a Moon ellispoid in CesiumJS to compliment this new tileset.
To give this a whirl, I do what I always do, reach for the low poly bananas and see what I can come up with.
See banana-covid-checker, banana-ISS-tracker, all-planes-in-the-sky-but-bananas
I set out with the thought of just putting a banana on the moon.
The setup
I created a new Vite powered React/Typescript app. I chose Vite due to its easy Vite Cesium plugin which manages all the copying of CesiumJS files and assets to the right place when you build it. It used to be such a pain setting this up in Webpack.
I gave Resium a go as it provides a lot of the boilerplate I’d be writing anyway if I was to integrate Cesium into a react app.
The Moon
So I set up the Viewer, and the Cesium3DTileset
component with the new moon tiles.
<Cesium3DTileset
url={IonResource.fromAssetId(2684829)}
enableCollision={true}
/>
As I’m loading in the tileset from Cesium Ion, I can use the IonResource.fromAssetId()
function to load in the moon tiles.
I also need to set the ellipsoid to the moon so it doesn’t try to render the moon tiles inside the Earth’s ellipsoid.
Cesium.Ellipsoid.default = Cesium.Ellipsoid.MOON
With this all set, I fire up the app and explore the moon, it has so many more craters than I was expecting. It’s like an irregular golfball.
The Banana
I had a look at the Resium docs and added a Model
component, loaded in the GLTF and calculated a modelMatrix to position it somewhere on the surface.
A note on hosting
I host my sites on Netlify and it turns out they have a bandwidth limit on their free tier. In the past, projects that load one or two GLTF files chew through that bandwidth, but Github hosts our code for free. What is a GLTF? A coded representation of a 3D model, so I’m trying out hosting my assets in a public assets directory and then pointing the application at the raw.githubusercontent.com url to load the models from there.
So now I have a banana on the moon, I was thinking it might be cool if could move the banana to where ever I clicked. So I set up a <ScreenSpaceEventHandler>
and a <ScreenSpaceEvent>
child which is listening to left mouse clicks.
Upon a click, I use the viewer.scene.pickPosition
function to turn the clicked Cartesian2
with x
and y
coordinates, into a a Cartesian3
which includes a z
value representing the surface of the moon at the x,y
coordinate.
It got tricky
This is where it gets a bit tricky, to set the position, I needed to calculate a new modelMatrix
for the Model
and update it.
I went back to the docs and had a read, what’s the difference between the Model
component and the Entity
component with ModelGraphics
inside it.
The gist from what I can tell is, use an Entity
as default and then use a Model
when you need low level control over the model.
I know I’m listing Resium components here but they map directly to Cesium APIs so feel free to do the mental mapping if you’re not familiar with Resium
I went back and switched out the Model
for an Entity
and ModelGraphics
pair and swapped the modelMatrix
for a much easier to reason about position
value.
Now when I’m responding to the click event, I just need to take the resulting Cartesian3
from the scene.pickPosition
and create a new ConstantPositionProperty(cartesian)
and assign it to my entity’s position property. Followed by a call to viewer.scene.requerstRender()
to update the scene.
const cartesian = viewer.scene.pickPosition(clickedCartesian)
entity.position = new ConstantPositionProperty(cartesian);
viewer.scene.requestRender();
Now I have a banana appearing everywhere I click, nice one.
What if it dropped from the sky?
What if every time I clicked, a banana would fall out of the sky. That might be cool.
So I set about figuring out the steps. I think a fixed elevation would work fine. Pick a number of meters above the click point and let it drop.
To do that, I need to convert from Cartesian3
to Cartographic
so I can set the elevation to the height I want, then convert back to Cartesian3
. This seems like a bit of faffing but it works fine and I think that’s how it’s supposed to be done.
const endCartesian = viewer.scene.pickPosition(clickedCartesian);
const tempCartographic = Ellipsoid.MOON.cartesianToCartographic(endCartesian);
tempCartographic.height = tempCartographic.height + DROP_HEIGHT_M;
const startCartesian =
Ellipsoid.MOON.cartographicToCartesian(tempCartographic);
A note on Cartesians and Cartographics
Cartesians
These values represent a point in 3D space referenced from the center of the Ellipsoid (the Moon in this case) along the cartesian axes. They come in Cartesian2
(x
, y
), Cartesian3
(x
, y
, z
) and even Cartesian4
(x
, y
, z
, w
) flavours, where the w
component is used for perspective transformations and scaling.
Cartographics
These represent a point on a globe, and are specified in latitude
, longitude
and height
. Where height
is the distance in meters above the ellipsoid.
They are good for representing positions on the Earth’s surface in a human readable way.
Now I’ve got where I want the banana to start, and where I want it to end. I need to start calculating the animation.
To do this, I will use the viewer.clock.onTick
event to run the callback every time the clock ticks. I’ll then lerp
between the start position and the end position based on the currentTime - startTime
. I’ll then set the position to be the new position in the animation and request a rerender so it shows. When the animation is complete, I will remove the onTick
event listener.
const startTime = viewer.clock.currentTime;
const onTick = (clock: Cesium.Clock) => {
const currentTime = clock.currentTime;
const elapsedTime = Cesium.JulianDate.secondsDifference(
currentTime,
startTime
);
const t = elapsedTime / ANIMATION_DURATION_S;
if (t >= 1.0) {
console.log("Animation complete");
// Stop the animation
viewer.clock.onTick.removeEventListener(onTick);
entity.position = new ConstantPositionProperty(endCartesian);
viewer.scene.requestRender();
return;
}
const currentCartesian = Cesium.Cartesian3.lerp(
startCartesian,
endCartesian,
t,
new Cesium.Cartesian3()
);
entity.position = new ConstantPositionProperty(currentCartesian);
viewer.scene.requestRender();
};
viewer.clock.onTick.addEventListener(onTick)
Now I’ve got an animation of a banana falling from the sky. Something is off with it, I think it’s the linear timing of the animation, I add a simple ease in function.
Ease into it
const easeIn = (t: number) => t * t;
This is not an exact analog for gravity, even though we are on the moon, but we’ll go with it for now as it is so simple. It should provide a slow start with a speed up to a terminal velocity. Close enough.
Here’s the relevant snippet now
const currentCartesian = Cesium.Cartesian3.lerp(
startCartesian,
endCartesian,
easeIn(t),
new Cesium.Cartesian3()
);
entity.position = new ConstantPositionProperty(currentCartesian);
viewer.scene.requestRender();
The UFO
Now I have a banana appearing out of nowhere and animating to the ground upon every mouse click.
Wouldn’t it be cool if something was dropping the banana? What kind of things would even be capable of dropping a banana on the moon? Lunar Orbiter, Lunar lander, or a UFO. Time to get weird.
I look up a UFO Model on poly.pizza and push it to my assets repo.
Now I build a new Entity
and ModelGraphics
and point it at the new GLTF model. (GLB actually, trying to shave a few megs off the giant file)
I want this UFO to follow my mouse, but at a constant height that matches the height I’m dropping bananas at. If I can pull this off, I reckon it has a 50% chance of it looking like the UFO is dropping the bananas. Bingo!
So I set up my ScreenSpaceEventHandler
and ScreenSpaceEvent
components, this time listening for a mouse move event, every time the mouse moves, I want the UFO to follow. I create a bit of state using a React useState
hook and then set the location after picking the position on the ground.
<ScreenSpaceEventHandler>
<ScreenSpaceEvent
action={(action) => {
if (!cesium.viewer || !("endPosition" in action)) {
console.error("No endPosition in action");
return;
}
const locationOnMoon = cesium.viewer.scene.pickPosition(
action.endPosition
);
if (!locationOnMoon) {
console.log("No location on moon");
return;
}
setUfoCartesian(locationOnMoon);
}}
type={ScreenSpaceEventType.MOUSE_MOVE}
/>
</ScreenSpaceEventHandler>
From here, I set up a React useEffect
hook depending on the newly written state. When the ufoCartesian
state updates, the effect will run and we follow the pattern we developed before to get the start position of the banana.
useEffect(() => {
if (!cesium.viewer) {
console.log("No viewer");
return;
}
if (!entityRef.current?.cesiumElement) {
console.log("No entity position");
return;
}
const cartographic = Ellipsoid.MOON.cartesianToCartographic(ufoCartesian);
cartographic.height += DROP_HEIGHT_M;
const cartesian = Ellipsoid.MOON.cartographicToCartesian(cartographic);
entityRef.current.cesiumElement.position = new ConstantPositionProperty(
cartesian
);
cesium.viewer.scene.requestRender();
}, [cesium, ufoCartesian]);
This does a bit of null checking, converts the Cartesian3
to Cartographic
sets the height
property then converts back to Cartesian3
so it can be set as the position property and the scene rerendered.
A quick check in the browser and BINGO, the odds of it looking like a UFO dropping a banana shot up to 100%!
That’s where I’m at for now, next steps.
- Keep the bananas around when they’re dropped.
- Add some sound effects to the UFO and banana drop
Weekend Project
This was a fun weekend hack to try out the Cesium Moon tileset. It’s really cool that you can now many of the cool things we could do with Cesium on Earth, but on the moon.
I can’t want to see some of the creative apps that are built off the back of this, not to mention the usefulness in industry for planning or simulating actual moon missions. Nice work Cesium team!
You can go check it out at https://moon-bananas.madole.dev.