// Canvas
const canvas = document.querySelector("canvas.webgl")
// Sizes
const sizes = {
width: 800,
height: 600,
}
// Scene
const scene = new THREE.Scene()
// Object
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1)
const cubeMaterial = new THREE.MeshBasicMaterial({
color: "#ff0000",
})
const cubeMesh = new THREE.Mesh(cubeGeometry, cubeMaterial)
scene.add(cubeMesh)
// Camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height)
camera.position.z = 3
scene.add(camera)
// Renderer
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
})
renderer.setSize(sizes.width, sizes.height)
renderer.render(scene, camera)
Three.js documentation is great.
Ultimately we'll end up with a red cube that well be looking at straight-on.
Transformations
There are 4 properties to transform objects:
position
rotation
scale
quaternion
These properties will be compiled in matrices.
Rotation
rotation inherits from the Euler object which works in Math.PI. It will rotate around the provided axis in X * Math.Pi.
Axis order is also important for rotation.
Quaternion
This is a representation of rotation that is more helpful.
Creating groups
You can create a new group with new THREE.Group().
From there, we can add multiple objects to a group.
Animations
We need to call a function to animate the scene.
You can think of it as "stop motion".
import "./style.css"
import * as THREE from "three"
import gsap from "gsap"
/**
* Base
*/
// Canvas
const canvas = document.querySelector("canvas.webgl")
// Scene
const scene = new THREE.Scene()
/**
* Base
*/
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 })
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
/**
* Sizes
*/
const sizes = {
width: 800,
height: 600,
}
/**
* Camera
*/
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height)
camera.position.z = 3
scene.add(camera)
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
})
renderer.setSize(sizes.width, sizes.height)
/**
* Animate
*/
gsap.to(mesh.position, { duration: 1, delay: 1, x: 2 })
const tick = () => {
// Render
renderer.render(scene, camera)
// Call tick again on the next frame
window.requestAnimationFrame(tick)
}
tick()
There were also examples playing around using the THREE clock elapsed time with sinuisoidal waves.
If you want more control, the GSAP can help to create things such as tweens (a Greensock library).
Cameras
Camera: The Camera class is what we call an abstract class. You're not supposed to use it directly, but you can inherit from it to have access to common properties and methods. Some of the following classes inherit from the Camera class.
ArrayCamera: The ArrayCamera is used to render your scene multiple times by using multiple cameras. Each camera will render a specific area of the canvas. You can imagine this looking like old school console multiplayer games where we had to share a split-screen.
StereoCamera: The StereoCamera is used to render the scene through two cameras that mimic the eyes in order to create what we call a parallax effect that will lure your brain into thinking that there is depth. You must have the adequate equipment like a VR headset or red and blue glasses to see the result.
CubeCamera: The CubeCamera is used to get a render facing each direction (forward, backward, leftward, rightward, upward, and downward) to create a render of the surrounding. You can use it to create an environment map for reflection or a shadow map. We'll talk about those later.
OrthographicCamera: The OrthographicCamera is used to create orthographic renders of your scene without perspective. It's useful if you make an RTS game like Age of Empire. Elements will have the same size on the screen regardless of their distance from the camera.
PerspectiveCamera: The PerspectiveCamera is the one we already used and simulated a real-life camera with perspective.
We are going to focus on the OrthographicCamera and the PerspectiveCamera in the course.
// Camera
const camera = new THREE.PerspectiveCamera(
75, // in degress for ield of view
sizes.width / sizes.height,
0.1, // near param
100 // far param
)
// const aspectRatio = sizes.width / sizes.height
// const camera = new THREE.OrthographicCamera(- 1 * aspectRatio, 1 * aspectRatio, 1, - 1, 0.1, 100)
camera.position.z = 3
scene.add(camera)
Be aware for near and far params ended up with z fighting bug when using the PerspectiveCamera.
When using the OrthographicCamera camera you pass a left, right, top, bottom, near, and far param. Be sure to calculate the aspect ratio. It can be great for effects.
We can also add controls to move the camera:
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"
// Cursor
const cursor = {
x: 0,
y: 0,
}
window.addEventListener("mousemove", (event) => {
cursor.x = event.clientX / sizes.width - 0.5
cursor.y = -(event.clientY / sizes.height - 0.5)
})
// Controls
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true
// Animate
const clock = new THREE.Clock()
const tick = () => {
const elapsedTime = clock.getElapsedTime()
// Update camera - this is the examble before using OrbitControl
// camera.position.x = Math.sin(cursor.x * Math.PI * 2) * 3
// camera.position.z = Math.cos(cursor.x * Math.PI * 2) * 3
// camera.position.y = cursor.y * 5
// Update controls
controls.update()
// Render
renderer.render(scene, camera)
// Call tick again on the next frame
window.requestAnimationFrame(tick)
}
tick()
Fullscreen and resizing
This lesson is to make things immersive and take up all available space.
To handle the resize, we can listen to the resize event.
const resizeCallback = () => {
// Update sizes
sizes.width = window.innerWidth
sizes.height = window.innerHeight
// Update camera
camera.aspect = sizes.width / sizes.height
camera.updateProjectionMatrix()
renderer.setSize(sizes.width, sizes.height)
}
window.addEventListener("resize", resizeCallback)
// be sure to remove as well
To handle the blurry render or "stairs" effect, we can use the antialias param. This happens because we are on a screen with a pixel ratio greater than 1.
If you want to see how many "triangles" are made on the surface of a geometry, you can pass wireframe to be true to the MeshBasicMaterial:
const geometry = new THREE.BoxGeometry(1, 1, 1, 2, 2, 2)
const material = new THREE.MeshBasicMaterial({ wireframe: true })
Creating your own triangle
const positionsArray = new Float32Array(9)
positionsArray[0] = 0
positionsArray[1] = 0
positionsArray[2] = 0
positionsArray[3] = 0
positionsArray[4] = 1
positionsArray[5] = 0
positionsArray[6] = 1
positionsArray[7] = 0
positionsArray[8] = 0
// alternative, you could inline it as the argument.
// Convert it to a BufferAttribute
const positionsAttribute = new THREE.BufferAttribute(positionsArray, 3)
const geometry = new THREE.BufferGeometry()
geometry.setAttribute("position", positionsAttribute)
Debug UI
The debug UI helps you to test things out live.
First we need to add the npm dependecy dat.gui with npm install dat.gui
import * as dat from "dat.gui"
import gasp from "gasp"
const gui = new dat.GUI()
const parameters = {
color: "#ff0000",
spin: () => {
gsap.to(mesh.rotation, { y: mesh.rotation.y + 10, duration: 1 })
},
}
// Adding elements
gui.add(mesh.position, "y")
// gui.add(mesh.position, 'y', - 3, 3, 0.01) - this example specifies properties min, max and precision
// gui.add(mesh.position, 'y').min(- 3).max(3).step(0.01).name('elevation') - here is an alternative use of it
gui.add(mesh, "visible")
gui.add(material, "wireframe")
gui.addColor(parameters, "color").onChange(() => {
material.color.set(parameters.color)
})
There are different types of elements you can add to that panel:
Range — for numbers with minimum and maximum value
Color — for colors with various formats
Text — for simple texts
Checkbox — for booleans (true or false)
Select — for a choice from a list of values
Button — to trigger functions
Folder — to organize your panel if you have too many elements
As for other tips:
You can press h to toggle the hide.
You can close the panel by clicking the toggle.
You can change the width by drag and drop or provide a default width (400 was the example).
Textures
Color (or albedo): The albedo texture is the most simple one. It'll only take the pixels of the texture and apply them to the geometry.
Alpha: The alpha texture is a grayscale image where white will be visible, and black won't.
Height (or displacement): The height texture is a grayscale image that will move the vertices to create some relief. You'll need to add subdivision if you want to see it.
Normal: The normal texture will add small details. It won't move the vertices, but it will lure the light into thinking that the face is oriented differently. Normal textures are very useful to add details with good performance because you don't need to subdivide the geometry.
Ambient occlusion: The ambient occlusion texture is a grayscale image that will fake shadow in the surface's crevices. While it's not physically accurate, it certainly helps to create contrast.
Metalness: The metalness texture is a grayscale image that will specify which part is metallic (white) and non-metallic (black). This information will help to create reflection.
Roughness: The roughness is a grayscale image that comes with metalness, and that will specify which part is rough (white) and which part is smooth (black). This information will help to dissipate the light. A carpet is very rugged, and you won't see the light reflection on it, while the water's surface is very smooth, and you can see the light reflecting on it. Here, the wood is uniform because there is a clear coat on it.
PBR
Those textures (especially the metalness and the roughness) follow what we call PBR principles. PBR stands for Physically Based Rendering. It regroups many techniques that tend to follow real-life directions to get realistic results.
While there are many other techniques, PBR is becoming the standard for realistic renders, and many software, engines, and libraries are using it.
const image = new Image()
const texture = new THREE.Texture(image)
image.addEventListener("load", () => {
texture.needsUpdate = true
})
image.src = "/textures/door/color.jpg"
// To see the texture on the cube, replace the color property by map and use the texture as value
const material = new THREE.MeshBasicMaterial({ map: texture })
If you have multiple images to load and want to mutualize the events like being notified when all the images are loaded, you can use a LoadingManager.
const loadingManager = new THREE.LoadingManager()
loadingManager.onStart = () => {
console.log("loading started")
}
loadingManager.onLoad = () => {
console.log("loading finished")
}
loadingManager.onProgress = () => {
console.log("loading progressing")
}
loadingManager.onError = () => {
console.log("loading error")
}
const textureLoader = new THREE.TextureLoader(loadingManager)
// Start loading what you need
const colorTexture = textureLoader.load("/textures/door/color.jpg")
const alphaTexture = textureLoader.load("/textures/door/alpha.jpg")
const heightTexture = textureLoader.load("/textures/door/height.jpg")
const normalTexture = textureLoader.load("/textures/door/normal.jpg")
const ambientOcclusionTexture = textureLoader.load(
"/textures/door/ambientOcclusion.jpg"
)
const metalnessTexture = textureLoader.load("/textures/door/metalness.jpg")
const roughnessTexture = textureLoader.load("/textures/door/roughness.jpg")
// In use
const material = new THREE.MeshBasicMaterial({ map: colorTexture })
Transforming the texture
const colorTexture = textureLoader.load("/textures/door/color.jpg")
colorTexture.repeat.x = 2
colorTexture.repeat.y = 3
// for x and y respectively to repeat the texture
colorTexture.wrapS = THREE.RepeatWrapping
colorTexture.wrapT = THREE.RepeatWrapping
// alternativing the direction
colorTexture.wrapS = THREE.MirroredRepeatWrapping
colorTexture.wrapT = THREE.MirroredRepeatWrapping
// offsetting the texture
colorTexture.offset.x = 0.5
colorTexture.offset.y = 0.5
// rotating the texture
colorTexture.rotation = Math.PI * 0.25
// changing the rotation from 0, 0 (assuming offset and repeat are removed)
colorTexture.rotation = Math.PI * 0.25
colorTexture.center.x = 0.5
colorTexture.center.y = 0.5
Minification filter
The minification filter happens when the pixels of texture are smaller than the pixels of the render. In other words, the texture is too big for the surface, it covers.
You can change the minification filter of the texture using the minFilter property.
The default is THREE.LinearMipmapLinearFilter. If you are not satisfied with how your texture looks, you should try the other filters.
If use a texture like a small checkerboard, then the artefacts you see are are called moiré patterns and you usually want to avoid them.
Magnification filter
The magnification filter works just like the minification filter, but when the pixels of the texture are bigger than the render's pixels. In other words, the texture too small for the surface it covers.
You can change the magnification filter of the texture using the magFilter property.
There are only two possible values:
THREE.NearestFilter
THREE.LinearFilter
Only use the mipmaps for the minFilter property. If you are using the THREE.NearestFilter, you don't need the mipmaps, and you can deactivate them with colorTexture.generateMipmaps = false:
Each pixel of the textures you are using will have to be stored on the GPU regardless of the image's weight. And like your hard drive, the GPU has storage limitations. It's even worse because the automatically generated mipmapping increases the number of pixels that have to be store.
Try to reduce the size of your images as much as possible.
If you remember what we said about the mipmapping, Three.js will produce a half smaller version of the texture repeatedly until it gets a 1x1 texture. Because of that, your texture width and height must be a power of 2. That is mandatory so that Three.js can divide the size of the texture by 2.
Some examples: 512x512, 1024x1024 or 512x2048
512, 1024 and 2048 can be divided by 2 until it reaches 1.
If you are using a texture with a width or height different than a power of 2 value, Three.js will try to stretch it to the closest power of 2 number, which can have visually poor results, and you'll also get a warning in the console.
Where to find textures
Unfortunately, it's always hard to find the perfect textures. There are many websites, but the textures aren't always right, and you may have to pay.
It's probably a good idea to start by searching on the web. Here are some websites I frequently end up on.
poliigon.com
3dtextures.me
arroway-textures.ch
Materials
Materials are used to put a color on each visible pixel of the geometries.
The algorithms that decide on the color of each pixel are written in programs called shaders. Writing shaders is one of the most challenging parts of WebGL and Three.js, but don't worry; Three.js has many built-in materials with pre-made shaders.
This section will just focus on materials for now.
The color property on the material will apply a unifor color. This can be combined with the map property of the texture:
// Ways to apply the color
material.color = new THREE.Color("#ff0000")
material.color = new THREE.Color("#f00")
material.color = new THREE.Color("red")
material.color = new THREE.Color("rgb(255, 0, 0)")
material.color = new THREE.Color(0xff0000)
We can use the wireframe property to show the triangles that compose the geometry with material.wireframe = true.
There are some other properties explained:
// opacity
material.transparent = true
material.opacity = 0.5
// texture transparency
material.alphaMap = doorAlphaTexture
// `side` property lets you decide which side of a face is visible (by default is front side)
material.side = THREE.DoubleSide
MeshNormalMaterial
// The MeshNormalMaterial displays a nice purple, blueish, greenish color
// You can use Normals for many things like calculating how to illuminate the face or how the environment should reflect or refract on the geometries' surface.
const material = new THREE.MeshNormalMaterial()
// new property we can use - normals won't be interpolated between the vertices
material.flatShading = true
MeshMatcapMaterial
MeshMatcapMaterial is a fantastic material because of how great it can look while being very performant.
const material = new THREE.MeshMatcapMaterial()
material.matcap = matcapTexture
The meshes will appear illuminated, but it's just a texture that looks like it.
The only problem is that the illusion is the same regardless of the camera orientation. Also, you cannot update the lights because there are none.
MeshDepthMaterial
The MeshDepthMaterial will simply color the geometry in white if it's close to the camera's near value and in black if it's close to the far value of the camera.
The aoMap property (literally "ambient occlusion map") will add shadows where the texture is dark. For it to work, you must add what we call a second set of UV (the coordinates that help position the textures on the geometries).
sphere.geometry.setAttribute(
"uv2",
new THREE.BufferAttribute(sphere.geometry.attributes.uv.array, 2)
)
plane.geometry.setAttribute(
"uv2",
new THREE.BufferAttribute(plane.geometry.attributes.uv.array, 2)
)
torus.geometry.setAttribute(
"uv2",
new THREE.BufferAttribute(torus.geometry.attributes.uv.array, 2)
)
material.aoMap = doorAmbientOcclusionTexture
material.aoMapIntensity = 1
3D Text
Three.js already supports 3D text geometries with the TextGeometry class. The problem is that you must specify a font, and this font must be in a particular json format called typeface.
// Computer the bounding box
textGeometry.computeBoundingBox()
// We can now center it like so (also subtracting bevel or 0.02 to be precise)
textGeometry.translate(
-(textGeometry.boundingBox.max.x - 0.02) * 0.5, // Subtract bevel size
-(textGeometry.boundingBox.max.y - 0.02) * 0.5, // Subtract bevel size
-(textGeometry.boundingBox.max.z - 0.03) * 0.5 // Subtract bevel thickness
)
// Or just do it without being ridiculous
textGeometry.center()