Check out the latest code on GitHub or in CodeSandbox.
You can create 3D graphics with React! This is how I built a custom three.js geometry to use in react-three-fiber. Hopefully documenting my process can help someone get started* with 3D programming.
*I’m assuming that you have some basic knowledge of three.js and React. I’m no expert; that’s all I had when I figured this stuff out.
Also, I hope you like math.
With limited opportunities to hang out with friends, D&D has been a great outlet for me in the COVID-19 era. It’s healthy escapism in the form of collaborative storytelling with a bunch of improv thrown in. So it’s not hard to see why it’s surged in popularity recently.
Two D&D web apps that have become indispensable to my group are Roll20 and D&D Beyond. Respectively, they make it easy to keep track of game state (enemy positions, players’ turn order) and character state (hit points, inventory, spells known). Using traditional character sheets, it’s frustrating to rifle through stacks of paper and erase/cross things out to update all of that information by hand.
Part of the joy of playing D&D is rolling dice that affect the outcomes of characters’ actions. These talismans determine whether you can persuade the lizard people to fight the frog people to create a distraction while you reclaim an ancient castle that’s been taken over by cultists. Or whether you can kill a boss while doing a cool backflip.
Rolling dice is simple enough to manage when everyone is sitting around a table in a room together. Unfortunately, in a virtual environment, some of that tactile magic is lost. Both Roll20 and D&D Beyond attempt to make up for the absence of real dice with 3D dice rollers. ✨
It’s ridiculously cool that this D&D website rolls dice right on top of your character sheet. pic.twitter.com/Vcev156TL8
— Chris Coyier (@chriscoyier) September 23, 2020
Is it possible to make my own 3D dice roller?
As a web developer, I use each of these 3D dice rollers and think, “This is amazing! I wonder how its code works though.”
And also… “I wonder how hard it would be to recreate it.”
I knew it was possible! Even if it proved to be really challenging, I also knew it’d still be a valuable experience. Reverse-engineering software is one of the best ways to learn new technologies.
Learning awesome tools
There are two tools in particular that I wanted to practice using:
react-three-fiber (r3f) is a wrapper around three.js, giving us a way to create three.js scenes declaratively with React.
Why is it a good idea to use three.js and React together? To quote Corrine Toracchio’s talk from Next.js Conf 2020:
Building dynamic scene graphs declaratively with re-usable components makes dealing with three.js easier and brings order and sanity to your codebase.
This makes it much simpler to create 3D objects and deal with their relationships in code—even complex stuff like physics!
r3f’s ecosystem also includes some nifty helper libraries which would come in handy for a dice roller:
drei is a library of helpers and abstractions for using three.js with JSX, such as
use-cannon is a wrapper around cannon.js which allows you to simulate physics in r3f scenes using React hooks.
How do I make a dice roller with r3f?
- Create React components to represent each type of die.
- Render those components on a canvas as necessary, depending on what dice a user wants to roll.
- Roll the dice in a physics simulation, and be able to obtain the result of the roll.
The very first step—making the dice—was both easier and harder than I expected.
Why did I have to make a D10 from scratch?
A standard set of D&D dice consists of seven dice: a D4, a D6, a D8, two D10s—one numbered 0-9 and another numbered 00-90, whose values are added to obtain random numbers from 1-100—a D12, and a D20. Every die’s shape is a convex polyhedron with congruent faces, which means that each of its marked numbers is equally likely to be rolled.
Except for the D10, these convex polyhedra are regular, which means that all of their faces’ angles and sides are equal, and the same number of faces meet at each vertex. These shapes are better known as Platonic solids:
Thanks to three.js providing geometric definitions for each of these shapes, I could make these dice out of the box. However, there is no Platonic solid with ten faces. So the D10 is a shape called a pentagonal trapezohedron. It has ten vertices where 3 faces meet, and two vertices where 5 faces meet.
I could have just created the model for this thing in Blender (…if I learned Blender first). But three.js is perfectly capable of stitching together a couple of triangles in the browser. It couldn’t be that hard.
To start, I forked this CodeSandbox from the documentation of use-cannon. This scene consists of a cube plowing through a hundred little spheres in a box. Conceptually, it’s not too different from rolling dice.
Setting the scene
This is what the 3D scene to showcase a die looks like—minus lights, the floor, and some walls.
Describing the scene in a declarative way with JSX makes a lot of sense:
Create a canvas.
Inside that canvas, create a physics world with gravity pulling in the negative z direction.
Instantiate a die in the physics world.
- Initial position is slightly above the floor, so that the die doesn’t clip through it.
- Initial rotation is set so that when the die hits the ground, it falls to one side instead of remaining uncannily balanced. The physics are somewhat convincing, but they’re definitely not real.
Render the canvas at a specified root element.
…Why a D8 though? Isn’t this a blog post about a D10?
Creating a D8
A D8 is an excellent base from which to create a D10. The two shapes are actually pretty similar! Each consists of a top and a bottom vertex, which both connect to a ring of vertices between them.
For the octahedron, the middle layer of vertices is a square. So four of its faces are formed by connecting the middle layer to the top vertex, and the other four faces are formed by connecting the middle layer to the bottom vertex.
Likewise, the pentagonal trapezohedron has five faces on top, and five faces on bottom. Its middle layer of vertices is roughly a pentagon. More on that later.
Because Platonic solids are exported by three.js and drei, this is all I needed to make a D8 component that obeys the laws of physics:
A couple of things to note:
Even though drei gives us an
<Octahedron />component, the vanilla three.js
octahedronGeometryhas to be created for physics to work. That geometry is passed into
useConvexPolyhedron, which returns a ref that’s aware of physical properties like shape and mass. Passing this ref as a prop to the
<Octahedron />component is what links the object on the canvas to the physics world.
useConvexPolyhedronalso returns an API object whose methods allow you to apply transformations and interactions to your shape.
I added an
onClickhandler for two reasons:
- To verify that physics work. I.e., collisions, rotations.
- To inspect different sides of the shape. Kicking it around seemed more fun than orbiting a camera around it.
In r3f, any three.js object can be expressed as a JSX component.
<meshNormalMaterial />is a material that colors each face in a mesh according to the direction of its normal vector.
Here’s the D8 in action:
Manually creating a D8
To create a D10, first my goal was to create an octahedron (D8) component using
PolyhedronGeometry and drei’s
<Polyhedron /> that behaved identically to the one above, just to make sure I have a good grasp on r3f. Because the shapes have similar structures, I could then turn the shape into a pentagonal trapezohedron (D10) by passing different parameters to the
What parameters are necessary?
- A array of vertices
- A array of faces
For this part, I relied heavily on Three.js Fundamentals’ articles on custom geometry and custom buffer geometry. Those article raised some important considerations:
BufferGeometry, the array of vertices is a one-dimensional array of x, y, and z coordinates. So for a list of points [p0, p1, …, pn], the vertex array will look like [x0, y0, z0, x1, y1, z1, …, xn, yn, zn].
Each face is defined as a group of three vertices.
When defining a face, the vertex order matters: they must be specified in an order that is counter-clockwise when the triangle is facing the camera. This ensures that its normal vector points outward from the shape’s exterior, because of the right-hand rule.
How do I draw an octahedron?
Note that I numbered the vertices of the D8 above counterclockwise, starting from the left, for no reason other than that’s how I labeled the shape when I drew it.
The first face contains vertices 0, 2, and 3 in that order so that we know the face is front-facing.
Here’s that same octahedron, made using the same code from the previous snippet—but with
PolyhedronGeometry and explicitly defined vertices and faces following the labels in the diagram, instead of
I grouped each both the vertex and face arrays in threes and called
Array.prototype.flat() afterward for readability.
As you can see, this D8 acts exactly the same as the previous one.
Converting a D8 to a D10
Remember how I mentioned that the middle layer of vertices in a pentagonal trapezohedron is roughly a pentagon?
If those vertices did form a pentagon, then the shape would be a similar but different one called a pentagonal bipyramid, which has triangular faces.
In contrast, each face of a pentagonal trapezohedron is a quadrilateral that kind of looks like a kite. 🪁
Even though each face has four sides, its
faces array will still be a list of triangular faces. Instead of 10 quadrilaterals, the array will represent 20 triangles.
Another thing about buffer geometries: no matter whether a face is a triangle, quadrilateral, or some other shape, faces are always defined as triangles because three points uniquely identify a plane. Any face that consists of a shape with more than three vertices is really just multiple triangles which lie on the same plane. Also, GPUs are really efficient at rendering triangles in 3D graphics.
So what exactly is that middle vertex layer?
You can visualize a pentagonal trapezohedron’s middle layer of vertices in one of two ways:
Two pentagons, one above and one below, rotated 1/10th of a turn apart from each other.
A zigzaggy decagon whose vertices alternate going up and down.
The latter model shows the way to create this middle vertex layer: Trace out a circle, and place ten equally spaced vertices along this circle, alternating the height of each vertex between high and low.
Here’s how Anton created the D10’s middle vertex layer with a
One important difference between his implementation and mine is the order in which vertices are created. Anton created vertices counterclockwise, starting from the right ((1, 0)). Because I wanted to match the vertex order of the D8 above, I created the D10’s middle layer of vertices counterclockwise, starting from the left ((-1, 0)).
To draw a circle counterclockwise around the origin from (-1, 0), you can use the following parametric equation:
- x = -cos b
- y = -sin b
For 0 ≤ b ≤ 2π: x starts at -1, goes to 1, then goes back to -1; y starts at 0, goes to -1, then to 1, then back to 0.
When we take the z component into account, we have our zigzaggy decagon:
By modifying the vertex and face arrays again, the octahedron from the last snippet can be turned into a pentagonal trapezohedron. The code below produces a kickable D10—the very first demo above!
I’m sure there’s a one-line
for loop that will generate the same
that’s beyond the scope of this blog post I didn’t feel like it writing out the vertex order by hand is a little clearer.
To see the above code in action, scroll back to the beginning or check out the scene on CodeSandbox!
Thank you for sticking it out through this humongous blog post. I hope you learned something new. 😊
The next challenge is to add a number texture to the die. Stay tuned for part 2!
In the meantime…
Can this post or the dice roller be made more accessible? Are there any illustrations/demos that would make this material easier to understand? Am I causing huge performance issues? Did I mess up some geometric concepts/terminology?
If you have an answer to any of these questions, please hit me up on Twitter!