Build 3D Apps With React | Animated Solar System | Part 2

Dilum
Geek Culture
Published in
9 min readMay 21, 2021

--

Hello everyone. In this article, we will continue with our animated solar system application. Missed the first part? you can find it here.

Build 3D Apps with React | Animated Solar System | Part 1

So far, we have created a set of random planets and added ecliptic paths to those planets

Now it is time to animate the planets. To animate 3D objects in react three fiber, we can use useFrame hook.

useFrame hook

useFrame is a Fiber hook that you can use to update your scene on every frame of the Fiber’s render loop. When you are using the useFrame hook, however, you need to make sure it is always inside a Canvas parent. Otherwise, it will not work.

We can pass a callback function to the useFrame hook and it will be invoked once a frame. This function receives state and delta as arguments.

useFrame((state, delta) => {
//callback function
})

Delta is the time difference between the current frame and the last frame. So the delta value can be used if you want to change object properties like position or rotation gradually on each frame. You can find a good mix of useful properties inside the state variable as well. You can refer to react three fiber hooks API docs to find all available properties inside the state variable. For our application, we will use the clock property from the state.

useFrame(({ clock }) => {
const t = clock.getElapsedTime();
});

The clock property is a direct reference to the THREE.Clock instance. We can get the time difference between the first frame and the current frame using clock.getElapsedTime method. The nice thing about the getElapsedTime method is it will start the clock if the clock is not already started yet. So we don’t have to manually start the clock on the first render.

Planet animation

Now, how can we use this time value to move our planets on an elliptical path? Lucky for us all the functions we use to calculate the position on an ellipse are periodic functions! So we can just pass the time as the angle and our animation will perfectly repeat itself. To assign the new position values to our planet mesh object we need to create a reference to the mesh using React’s useRef hook. Also, we can take out the position prop from the planet mesh now because useFrame will run and update the initial position as well.

function Planet({ planet: { color, xRadius, zRadius, size } }) {
const planetRef = React.useRef();
useFrame(({ clock }) => {
const t = clock.getElapsedTime();
const x = xRadius * Math.sin(t);
const z = zRadius * Math.cos(t);
planetRef.current.position.x = x;
planetRef.current.position.z = z;
});
return (
<>
<mesh ref={planetRef}>
<sphereGeometry args={[size, 32, 32]} />
<meshStandardMaterial color={color} />
</mesh>
<Ecliptic xRadius={xRadius} zRadius={zRadius} />
</>
);
}

Yey! We have our animation. But it doesn’t seem right. Does it? We have two main issues.

  1. The formula to calculate the orbital speed of a planet is a bit more complex to be implemented in this kind of simple app. But still, we need each planet to have its own speed.
  2. The starting position of each planet should be different.

How do we solve the first issue? Well, since we use the time variable directly as the angle variable in our function we can simply multiply the time variable with some number to change the speed. Since we want each planet to have a different speed, we can add the speed to our planet config list as well.

planetData.js

.......
for (let index = 0; index < totalPlanets; index++) {
planetData.push({
id: index,
color: randomColor(),
xRadius: (index + 1.5) * 4,
zRadius: (index + 1.5) * 2,
size: random(0.5, 1),
speed: random(0.5, 0.2)
});
}
..........

App.js

function Planet({ planet: { color, xRadius, zRadius, size, speed } }) {
const planetRef = React.useRef();
useFrame(({ clock }) => {
const t = clock.getElapsedTime() * speed;
const x = xRadius * Math.sin(t);
const z = zRadius * Math.cos(t);
planetRef.current.position.x = x;
planetRef.current.position.z = z;
});
return (
<>
<mesh ref={planetRef}>
<sphereGeometry args={[size, 32, 32]} />
<meshStandardMaterial color={color} />
</mesh>
<Ecliptic xRadius={xRadius} zRadius={zRadius} />
</>
);
}

To the next issue. Why do all of our planets start at the same location? Because in the first frame, the time variable is 0 which is also our initial angle. We can add a random number to make sure that each planet’s initial position is different. So, Just like the time value, we can use any number as a random value because the functions we use to calculate the positions are periodic functions. right? Yes and no. Yes, you can use any number but if all your numbers are small, the distribution will only fill up a small portion of an eclipse. So, we have to make sure the random numbers are chosen at least between 0 and 2π. We can add that to planet config as well.

planetData.js

for (let index = 0; index < totalPlanets; index++) {
planetData.push({
id: index,
color: randomColor(),
xRadius: (index + 1.5) * 4,
zRadius: (index + 1.5) * 2,
size: random(0.5, 1),
speed: random(0.1, 0.6),
offset: random(0, Math.PI * 2),
});
}

App.js

function Planet({ planet: { color, xRadius, zRadius, size, speed, offset } }) {
const planetRef = React.useRef();
useFrame(({ clock }) => {
const t = clock.getElapsedTime() * speed + offset;
const x = xRadius * Math.sin(t);
const z = zRadius * Math.cos(t);
planetRef.current.position.x = x;
planetRef.current.position.z = z;
});
return (
<>
<mesh ref={planetRef}>
<sphereGeometry args={[size, 32, 32]} />
<meshStandardMaterial color={color} />
</mesh>
<Ecliptic xRadius={xRadius} zRadius={zRadius} />
</>
);
}

And there you have it !. An animated solar system.

Adding textures

As we all know, while revolving around the sun, planets spin around their axis as well which in fact create day and night. Let’s create that animation by rotating planets a small amount around the y-axis on each frame.

function Planet({
planet: { color, xRadius, zRadius, size, speed, offset, rotationSpeed },
}) {
const planetRef = React.useRef();
useFrame(({ clock }) => {
const t = clock.getElapsedTime() * speed + offset;
const x = xRadius * Math.sin(t);
const z = zRadius * Math.cos(t);
planetRef.current.position.x = x;
planetRef.current.position.z = z;
planetRef.current.rotation.y += rotationSpeed;
});
...
}

At this point, we won’t be able to see this rotation because our spheres have uniform color throughout the surfaces. To fix this and make our planets look more real we can add some textures. We can start by adding a texture to the sun.

Where can we find some textures for the sun and the planets? Googling “planet textures” will give you plenty of free and paid stock resources for real and fictional planet textures. All the textures used in this application are from www.solarsystemscope.com.

To add a texture to a 3D object just like in three.js we can use the map property of a material. Before we use it as the map property, we need to load our texture. For that, we can use the react three fiber hook useLoader. useLoader takes the thee.js loader instance as the first argument and the file path as the second argument.

import { Canvas, useFrame, useLoader } from "@react-three/fiber";
...
import sunTexture from "./textures/sun.jpg";
...
function Sun() {
const texture = useLoader(THREE.TextureLoader, img);
return (
<mesh>
<sphereGeometry args={[2.5, 32, 32]} />
<meshStandardMaterial map={texture} />
</mesh>
);
}

If you are following along and just added those two lines, You will get an error message on the console and the sun will not be displayed at all. The reason is when we use the useLoader hook we need to make sure somewhere above in the component tree there is a Suspense component that wraps around the subset of children one of which uses the useLoader hook. So let’s add a Suspense component inside the app component.

export default function App() {
return (
<Canvas camera={{ position: [0, 20, 25], fov: 45 }}>
<Suspense fallback={null}>
<Sun />
{planetData.map((planet) => (
<Planet planet={planet} key={planet.id} />
))}
<Lights />
<OrbitControls />
</Suspense>
</Canvas>
);
}

Now we have a texture for our sun. Let’s add textures for planets as well. To continue the tradition of our planets being random, I’ll create a list of textures and shuffle it before adding the textures to the planets.

const shuffle = (a) => {
const temp = a.slice(0);
for (let i = temp.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[temp[i], temp[j]] = [temp[j], temp[i]];
}
return temp;
};
const textures = shuffle([tx1, tx2, tx3, tx4, tx5, tx6]);const planetData = [];
const totalPlanets = 6;
for (let index = 0; index < totalPlanets; index++) {
planetData.push({
id: index,
color: randomColor(),
xRadius: (index + 1.5) * 4,
zRadius: (index + 1.5) * 2,
size: random(0.5, 1),
speed: random(0.1, 0.6),
offset: random(0, Math.PI * 2),
rotationSpeed: random(0.01, 0.03),
textureMap: textures[index]
});
}

Also, I added a star-themed background to our scene, and here is how everything looks now.

Annotations

Say we need to display a planet’s name while it is moving. How can we achieve that? Well, we can use 3D text. But rendering a lot of 3D text can affect the app performance badly and will also not look that good. The other option is creating the text inside some HTML elements and move it with the objects. To move the element with the 3D object we need to get the position of the object in 3D space and then convert it to browser width and height coordinates so that the HTML element can be positioned using absolute positioning. Sounds confusing huh!. Yes, to do this in three.js we need to do some amount of calculations. Lucky for us @react-three/Drei has a component that does all this behind the scenes !. All we have to do is import it and style our annotation a little bit.

App.js

import { OrbitControls, Html } from "@react-three/drei";
...
function Planet({
planet: {
color,
xRadius,
zRadius,
size,
speed,
offset,
rotationSpeed,
textureMap,
name
}
}) {
const planetRef = React.useRef();
const texture = useLoader(THREE.TextureLoader, textureMap);
useFrame(({ clock }) => {
const t = clock.getElapsedTime() * speed + offset;
const x = xRadius * Math.sin(t);
const z = zRadius * Math.cos(t);
planetRef.current.position.x = x;
planetRef.current.position.z = z;
planetRef.current.rotation.y += rotationSpeed;
});
return (
<>
<mesh ref={planetRef}>
<sphereGeometry args={[size, 32, 32]} />
<meshStandardMaterial map={texture} />
<Html distanceFactor={15}>
<div className="annotation">{name}</div>
</Html>
</mesh>
<Ecliptic xRadius={xRadius} zRadius={zRadius} />
</>
);
}

Styles.css

...
.annotation {
transform: translate3d(-50%, -150%, 0);
text-align: left;
background: #7676aa;
color: rgb(7, 6, 20);
padding: 16px;
border-radius: 5px;
font-size: 2rem;
}

planetData.js

for (let index = 0; index < totalPlanets; index++) {
planetData.push({
id: index,
color: randomColor(),
xRadius: (index + 1.5) * 4,
zRadius: (index + 1.5) * 2,
size: random(0.5, 1),
speed: random(0.1, 0.6),
offset: random(0, Math.PI * 2),
rotationSpeed: random(0.01, 0.03),
textureMap: textures[index],
name: (Math.random() + 1).toString(36).substring(7).toUpperCase()
});
}

We can use CSS property transform to position our annotation related to the object. By adjusting distanceFactor prop on the HTML component we can adjust the scale of our annotation. Also, we are generating a random name just for fun!

Event handlers

What if we need to give the user more information about a planet when they click on it? React three fiber components also have most of the event handlers that a normal React element has. So we can use the onClick handler prop on the planet mesh to trigger a dialogue.

App.js

export default function App() {
const [dialogueData, setDialogueData] = useState(null);
const hideDialoge = () => {
setDialogueData(null);
};
return (
<>
<Dialog hideDialoge={hideDialoge} dialogueData={dialogueData} />
<Canvas camera={{ position: [0, 20, 25], fov: 45 }}>
<Suspense fallback={null}>
<Sun />
{planetData.map((planet) => (
<Planet
planet={planet}
key={planet.id}
setDialogueData={setDialogueData}
/>
))}
<Lights />
<OrbitControls />
</Suspense>
</Canvas>
</>
);
}
...
...
function Planet({
planet: { textureMap, xRadius, zRadius, size, id },
setDialogueData,
}) {
const texture = useLoader(THREE.TextureLoader, textureMap);
return (
<>
<mesh
position={[xRadius, 0, 0]}
onClick={() => {
setDialogueData({ size, id });
}}
>
<sphereGeometry args={[size, 32, 32]} />
<meshStandardMaterial map={texture} />
<Html distanceFactor={15}>
<div className="annotation">HJHRJHR</div>
</Html>
</mesh>
<Ecliptic xRadius={xRadius} zRadius={zRadius} />
</>
);
}

Dialog.js

import React from "react";export default function Dialog({ hideDialog, dialogData }) {
if (!dialogData) {
return null;
}
const { name, gravity, orbitalPeriod, surfaceArea } = dialogData;
return (
<div className="dialog">
<div className="dialog-header">
<div className="">{name}</div>
<svg
onClick={hideDialog}
width="24px"
height="24px"
viewBox="0 0 200 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>...
</svg>
</div>
<div className="details">Gravity: {gravity} m/s²</div>
<div className="details">Orbital period: {orbitalPeriod} days</div>
<div className="details">Surface area: {surfaceArea} million km²</div>
</div>
);
}

And here is the final codesandbox for your reference.

Final thoughts

There are a lot of improvements we can do here. Instead of using spheres and textures, we can generate all our planets using custom shaders. We can add moons to planets and animate the solar flames. Or we can add some other 3D objects like asteroid belts. I might cover some of these in later tutorials. But this is it for now. Hope you enjoyed it as much as I did!

--

--

Dilum
Geek Culture

Software Engineer & Creative Coder https://dilums.com