From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
This tutorial is inspired by Claudio Guglieri’s new personal website that features a collection of playful 3D scenes. What we’ll do today is to explore the “Don’t” scene that is composed of rotating mirrors:
We’ll be using Three.js with react-three-fiber v5, drei and use-cannon and we’ll assume that you have some basic knowledge on how to set up a scene and work with Three.js.
Since real-time reflections would be extremely performance-heave, we’ll employ a few neat tricks!
All these libraries are part of Poimandres, a collection of libraries for creative coding. Follow Poimandres on Twitter to get the latest updates:
Drawing sharp text in 3D Space
To make our text look as sharp as possible, we use drei’s Text component, which is a wrapper around Troika Three Text. This library allows us to draw any webfont using signed distance fields and antialiasing:
import { Text } from '@react-three/drei'
function Title() {
return <Text material-toneMapped={false}>My Title</Text>
}
The `material-toneMapped={false}` tells three.js to ignore our material when doing tone mapping. Since react-three-fiber v5 uses sRGB by default, our text would otherwise be more grey than white.
Mirrors
The mirrors are simple Box objects positioned in 3D Space by loading the positions from a JSON file. We use `useResource` to store a reference to the materials and re-use them in the single Mirror components, meaning we will only instance the materials once.
To make the mirrors pop out of the black backdrop, we added a thin film effect by David Lenaerts.
import { useResource } from 'react-three-fiber'
function Mirrors({ envMap }) {
const sideMaterial = useResource();
const reflectionMaterial = useResource();
const [thinFilmFresnelMap] = useState(new ThinFilmFresnelMap());
return (
<>
<meshLambertMaterial ref={sideMaterial} map={thinFilmFresnelMap} color={0xaaaaaa} />
<meshLambertMaterial ref={reflectionMaterial} map={thinFilmFresnelMap} envMap={envMap} />
{mirrorsData.mirrors.map((mirror, index) => (
<Mirror
key={`mirror-${index}`}
{...mirror}
sideMaterial={sideMaterial.current}
reflectionMaterial={reflectionMaterial.current}
/>
))}
</>
);
}
For the single mirrors, we assigned a material to each face by setting the material prop as an array with 6 values (a material for each of the 6 faces of the Box geometry):
function Mirror({ sideMaterial, reflectionMaterial, args, ...props }) {
const ref = useRef()
useFrame(() => {
ref.current.rotation.y += 0.001
ref.current.rotation.z += 0.01
})
return (
<Box {...props}
ref={ref}
args={args}
material={[
sideMaterial,
sideMaterial,
sideMaterial,
sideMaterial,
reflectionMaterial,
reflectionMaterial
]}
/>
)
}
The mirrors are rotated each frame on the y and z axis to create interesting movements in the reflected image.
Reflections
As you noticed, we are using an envMap property on our mirror materials. The envMap is used to show reflections on metallic objects. But how can we create one for our scene?
Enter cubeCamera, a Three.js object that creates 6 perspective cameras and makes a cube texture out of them:
// 1. we create a CubeRenderTarget
const [renderTarget] = useState(new THREE.WebGLCubeRenderTarget(1024))
// 2. we get a reference to our cubeCamera
const cubeCamera = useRef()
// 3. we update the camera each frame
useFrame(({ gl, scene }) => {
cubeCamera.current.update(gl, scene)
})
return (
<cubeCamera
layers={[11]}
name="cubeCamera"
ref={cubeCamera}
position={[0, 0, 0]}
// i. notice how the renderTarget is passed as a constructor argument of the cubeCamera object
args={[0.1, 100, renderTarget]}
/>
)
In this basic example, we setup cubeCamera that helps us bring the sky reflections on our physical material.
Right now, our scene doesn’t really have much else than the mirrors, so we use a magic trick to create interesting reflections:
function TitleCopies({ layers }) {
const vertices = useMemo(() => {
const y = new THREE.IcosahedronGeometry(8)
return y.vertices
}, [])
return <group name="titleCopies">{vertices.map((vertex,i) => <Title name={"titleCopy-" + i} position={vertex} layers={layers} />)}</group>
}
We create an IcosahedronGeometry (20 faces) and use its vertices to create copies of our title, so that our cubeCamera has something to look at. To make sure the text is always visible, we also make it rotate to look at the center of the scene, where our camera is positioned.
Since we don’t want the fake text copies to be visible in the main scene, but only in the reflections, we use the layers system of Three.js.
By assigning layer 11 to our cubeCamera, only objects that share the same layer would be visible to it. This is what our cubeCamera is going to see (and thus what we are going to get on the mirrors).
Fun fact: Claudio was kind enough to show us that he also used the same technique to make the reflections more interesting.
Finishing touches
To finish it up, we added a simple mouse interaction that really helps selling the reflections on the mirrors. We wrapped our whole scene in a <group> and animated it using the mouse position:
import { useFrame } from "react-three-fiber";
function Scene() {
const group = useRef();
const rotationEuler = new THREE.Euler(0, 0, 0);
const rotationQuaternion = new THREE.Quaternion(0, 0, 0, 0);
const { viewport } = useThree();
useFrame(({ mouse }) => {
const x = (mouse.x * viewport.width) / 100;
const y = (mouse.y * viewport.height) / 100;
rotationEuler.set(y, x, 0);
rotationQuaternion.setFromEuler(rotationEuler);
group.current.quaternion.slerp(rotationQuaternion, 0.1);
});
return <group ref={group}>...</group>;
}
We create the Euler and Quaternion objects outside of the useFrame loop, since object creation on every frame would hinder performance.
To make a smooth rotation, we first set the rotation angle from mouse x and y, then slerp (which sounds funny but actually means spherical linear interpolation) the group’s quaternion to our new quaternion.
Bonus Points: Cannon!
Our second variation on this theme involves some simple physics simulation using use-cannon, another library in the react-three-fiber’s ecosystem.
For this scene, we setup a wall of cubes that use the same materials setup of our mirrors:
import { useBox } from '@react-three/cannon'
function Mirror({ envMap, fresnel, ...props }) {
const [ref, api] = useBox(() => props)
return (
<Box ref={ref} args={props.args}
onClick={() => api.applyImpulse([0, 0, -50], [0, 0, 0])}
receiveShadow castShadow material={[...]}
/>
)
}
The useBox hook from use-cannon creates a physical box that is then bound to the Box mesh using the given ref, meaning that any change in position of the physical box will also be applied to our mesh.
We also added two physical planes, one for the floor and one for the back wall. Then we only render the floor with a ShadowMaterial:
import { usePlane } from '@react-three/cannon'
function PhysicalWalls(props) {
// ground
usePlane(() => ({ ...props }))
// back wall
usePlane(() => ({ position: [0, 0, -20] }))
return (
<Plane args={[1000, 1000]} {...props} receiveShadow>
<shadowMaterial transparent opacity={0.2} />
</Plane>
)
}
To make everything magically work, we wrap it in the <Physics> provider:
import { Physics } from '@react-three/cannon'
<Physics gravity={[0, -10, 0]} >
<Mirrors envMap={renderTarget.texture} />
<PhysicalWalls rotation={[-Math.PI/2, 0, 0]} position={[0, -2, 0]}/>
</Physics>
Here is a simplified version of the physical scene we used:
And here we go with some DESTRUCTION:
And just so you know… Panna, Olga and Pedro are the names of Gianmarco’s bunny (Panna) and Marco’s cats (Olga and Pedro) 🙂