Shadows have always been a challenge in real-time 3D rendering. Developers must find skills to display real shadows under reasonable circumstances.
Three.js has a built-in solution. Although it is not perfect, it is very convenient to use.
How does shadow work?
When you render once, Three.js will render each light that supports shadows. Those renderings will simulate what the light sees like a camera. Under these light renderings, the mesh material will be replaced by the depth mesh material MeshDepthMaterial.
Light renderings will be stored like textures, called shadow maps, and then they will be used for each material that supports receiving shadows and cast onto geometry.
Activate shadows
1. If you want to activate and use shadows, you must first set it on in the. shadowMap.enabled attribute of the renderer to allow shadow maps to be used in the scene
renderer.shadowMap.enabled = true
2. Check each object to determine whether it can cast shadows using castshadow and receive shadows using receiveshadow.
Now our scene has a sphere and a plane, and the light source has ambient light and horizontal light.
Set the sphere to cast shadows and the plane to receive shadows
sphere.castShadow = true plane.receiveShadow = true
Then use castShadow to activate shadows on the light
directionalLight.castShadow = true
Note: only directional lights, point lights, and spotlights support shadows
Optimize shadow maps
Optimize render size
We can access shadow maps in the shadow attributes of each light
console.log(directionalLight.shadow);
As you can see, the default map size is 512x512. We can set it to the nth power of 2 because it involves mip mapping. Later, it will be found that the higher the value, the clearer the details of the shadow, and the lower the value, the more blurred the shadow
directionalLight.shadow.mapSize.width = 1024 directionalLight.shadow.mapSize.height = 1024
Near and far
As mentioned above, Three.js uses a light camera for shadow map rendering. These cameras have the same properties, like near and far
To facilitate debugging, we can add a camera helper (camera assistant) to the scene. What we need to do is to add the light camera directionalLight.shadow.camera used to render shadows to the camera assistant
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera) scene.add(directionalLightCameraHelper)
We can see that the intersection of the red line is our directional light source, the square and rectangular part is near, and far is very far away, which is where the performance needs to be optimized
Next, change the near and far value of the visual range of the light camera for directional light rendering shadows
directionalLight.shadow.camera.near = 2 directionalLight.shadow.camera.far = 6
Although the shadow has not changed much, it has at least reduced some performance consumption
amplitude
By observing the above figure, after using the camera assistant, we can find that the area seen by the light camera is still too large and overflows a lot. Because we are using directional light, it uses an orthogonal camera OrthographicCamera.
Therefore, we can control which side of the camera's viewing cone can see how far through the top, right, bottom and left attributes of the orthogonal camera.
directionalLight.shadow.camera.top = 2 directionalLight.shadow.camera.right = 2 directionalLight.shadow.camera.left = -2 directionalLight.shadow.camera.bottom = -2
It can be observed that the level of detail of the current shadow is improved compared with the shadow in front of the camera without adjusting the camera
The smaller the visual range of the light camera, the more accurate the shadow is. Of course, if it is set too small, the shadow will be cropped out
// When the camera far value is too small, the shadow is cropped out directionalLight.shadow.camera.far = 3.8
vague
We can control the degree of shadow blur through the radius attribute, which will not change the distance between the light camera and the object.
directionalLight.shadow.radius = 10
Shadow mapping algorithm
There are different types of algorithms that can be applied to shadow maps
Here. Basicshadowmap - very good performance but poor quality
THREE.PCFShadowMap - poor performance but smoother edges (default)
THREE.PCFSoftShadowMap - poor performance but softer edges
THREE.VSMShadowMap - poor performance, many constraints, but can produce unexpected results.
PCF soft shadow map
// PCF soft shadow map renderer.shadowMap.type = THREE.PCFSoftShadowMap
radius does not take effect in this type
Spotlight shadows
Add spotlight
// Spotlight const spotLight = new THREE.SpotLight(0xffffff,0.4,10,Math.PI*0.3) spotLight.castShadow = true spotLight.position.set(0,2,2) scene.add(spotLight) // If you want the spotlight to look somewhere, remember to add target to the scene scene.add(spotLight.target)
Add Camera Assistant
const spotLightCameraHelper = new THREE.CameraHelper(spotLight.shadow.camera) scene.add(spotLightCameraHelper)
Mixed shadows can be observed
Optimize spotlight shadow maps
Same as optimizing directional light shadow map before.
spotLight.shadow.mapSize.width = 1024 spotLight.shadow.mapSize.height = 1024 spotLight.shadow.camera.near = 1 spotLight.shadow.camera.far = 6
But because it is a spotlight, it uses a perspective camera PerspectiveCamera Therefore, the vertical field of view angle of the camera cone can be changed through the fov attribute
spotLight.shadow.camera.fov = 30
Point light shadows
Add a point light
//Point source const pointLight = new THREE.PointLight(0xffffff,0.3) pointLight.castShadow = true pointLight.position.set(-1,1,0) scene.add(pointLight)
Add Camera Assistant
const pointLightCameraHelper = new THREE.CameraHelper(pointLight.shadow.camera) scene.add(pointLightCameraHelper)
Optimize point light shadows
pointLight.shadow.mapSize.width = 1024 pointLight.shadow.mapSize.height = 1024 pointLight.shadow.camera.near = 0.1 pointLight.shadow.camera.far = 5
The point light camera also uses a perspective camera, but it is best not to change its fov attribute of the vertical field of view of the viewing cone
Bake shadows
Baked shadows are a good alternative to Three.js shadows. We can integrate shadows into textures and apply them to materials.
Turn off the shadow map rendering of the renderer first, and then you won't see the shadows in the scene
renderer.shadowMap.enabled = false
Then we set the material texture of the plane as baked shadow map
Load texture map
// Textures const textureLoader = new THREE.TextureLoader() const bakedShadow = textureLoader.load('/textures/bakedShadow.jpg')
The plane uses the meshbasic material and applies a baked shadow texture map
const plane = new THREE.Mesh( new THREE.PlaneBufferGeometry(5, 5), new THREE.MeshBasicMaterial({map:bakedShadow}) )
This scheme is suitable for static objects, because when the position of the object changes, the shadow does not move with it.
alternative
We can also use a simpler baked shadow map and move it to keep it under the sphere.
// Load simple shadows const simpleShadow = textureLoader.load('/textures/simpleShadow.jpg')
We want to create a plane slightly higher than the floor and set its material's alpha map attribute to a simple shadow texture map,
const sphereShadow = new THREE.Mesh( new THREE.PlaneBufferGeometry(1.5,1.5), new THREE.MeshBasicMaterial({ color:0x000000, transparent:true, alphaMap:simpleShadow }) ) sphereShadow.rotation.x = - Math.PI * 0.5 sphereShadow.position.y = plane.position.y + 0.01 scene.add(sphereShadow)
Then we add animation to the sphere to make it move in a circle around the floor plane and have the effect of bouncing at the bottom of the floor
/** * Animate */ const clock = new THREE.Clock() const tick = () => { const elapsedTime = clock.getElapsedTime() //update sphere animate // Circular motion sphere.position.x = Math.sin(elapsedTime) sphere.position.z = Math.cos(elapsedTime) // Bottom bounce sphere.position.y = Math.abs(Math.sin(elapsedTime * 3)) // Update controls controls.update() // Render renderer.render(scene, camera) // Call tick again on the next frame window.requestAnimationFrame(tick) } tick()
Then set the shadow to follow the sphere for position transformation
//Update sphere shadow map position //Shadow maps follow the sphere sphereShadow.position.x = sphere.position.x sphereShadow.position.z = sphere.position.z //The shadow changes according to the height of the sphere, and the transparency of the map also changes //The higher the sphere is from the plane, the more transparent the shadow is sphereShadow.material.opacity = (1 - Math.abs(sphere.position.y)) * 0.3
source code
import './style.css' import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import * as dat from 'dat.gui' /** * Base */ // Debug const gui = new dat.GUI() // Canvas const canvas = document.querySelector('canvas.webgl') // Scene const scene = new THREE.Scene() // Textures const textureLoader = new THREE.TextureLoader() const bakedShadow = textureLoader.load('/textures/bakedShadow.jpg') const simpleShadow = textureLoader.load('/textures/simpleShadow.jpg') /** * Lights */ // Ambient light const ambientLight = new THREE.AmbientLight(0xffffff, 0.3) gui .add(ambientLight, 'intensity') .min(0) .max(1) .step(0.001) scene.add(ambientLight) // Directional light const directionalLight = new THREE.DirectionalLight(0xffffff, 0.3) directionalLight.position.set(2, 2, -1) gui .add(directionalLight, 'intensity') .min(0) .max(1) .step(0.001) gui .add(directionalLight.position, 'x') .min(-5) .max(5) .step(0.001) gui .add(directionalLight.position, 'y') .min(-5) .max(5) .step(0.001) gui .add(directionalLight.position, 'z') .min(-5) .max(5) .step(0.001) scene.add(directionalLight) directionalLight.castShadow = true directionalLight.shadow.mapSize.width = 1024 directionalLight.shadow.mapSize.height = 1024 directionalLight.shadow.camera.near = 2 directionalLight.shadow.camera.far = 6 directionalLight.shadow.camera.top = 2 directionalLight.shadow.camera.right = 2 directionalLight.shadow.camera.left = -2 directionalLight.shadow.camera.bottom = -2 directionalLight.shadow.radius = 10 // console.log(directionalLight.shadow); const directionalLightCameraHelper = new THREE.CameraHelper( directionalLight.shadow.camera ) directionalLightCameraHelper.visible = false scene.add(directionalLightCameraHelper) // Spotlight const spotLight = new THREE.SpotLight(0xffffff, 0.3, 10, Math.PI * 0.3) spotLight.castShadow = true spotLight.position.set(0, 2, 2) scene.add(spotLight) scene.add(spotLight.target) spotLight.shadow.mapSize.width = 1024 spotLight.shadow.mapSize.height = 1024 spotLight.shadow.camera.fov = 30 spotLight.shadow.camera.near = 1 spotLight.shadow.camera.far = 6 const spotLightCameraHelper = new THREE.CameraHelper(spotLight.shadow.camera) spotLightCameraHelper.visible = false scene.add(spotLightCameraHelper) //Point source const pointLight = new THREE.PointLight(0xffffff, 0.3) pointLight.castShadow = true pointLight.position.set(-1, 1, 0) pointLight.shadow.mapSize.width = 1024 pointLight.shadow.mapSize.height = 1024 pointLight.shadow.camera.near = 0.1 pointLight.shadow.camera.far = 5 scene.add(pointLight) const pointLightCameraHelper = new THREE.CameraHelper(pointLight.shadow.camera) pointLightCameraHelper.visible = false scene.add(pointLightCameraHelper) /** * Materials */ const material = new THREE.MeshStandardMaterial() material.roughness = 0.7 gui .add(material, 'metalness') .min(0) .max(1) .step(0.001) gui .add(material, 'roughness') .min(0) .max(1) .step(0.001) /** * Objects */ const sphere = new THREE.Mesh( new THREE.SphereBufferGeometry(0.5, 32, 32), material ) sphere.castShadow = true const plane = new THREE.Mesh(new THREE.PlaneBufferGeometry(5, 5), material) plane.rotation.x = -Math.PI * 0.5 plane.position.y = -0.5 plane.receiveShadow = true scene.add(sphere, plane) const sphereShadow = new THREE.Mesh( new THREE.PlaneBufferGeometry(1.5, 1.5), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, alphaMap: simpleShadow, }) ) sphereShadow.rotation.x = -Math.PI * 0.5 sphereShadow.position.y = plane.position.y + 0.01 scene.add(sphereShadow) /** * Sizes */ const sizes = { width: window.innerWidth, height: window.innerHeight, } window.addEventListener('resize', () => { // Update sizes sizes.width = window.innerWidth sizes.height = window.innerHeight // Update camera camera.aspect = sizes.width / sizes.height camera.updateProjectionMatrix() // Update renderer renderer.setSize(sizes.width, sizes.height) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) }) /** * Camera */ // Base camera const camera = new THREE.PerspectiveCamera( 75, sizes.width / sizes.height, 0.1, 100 ) camera.position.x = 1 camera.position.y = 1 camera.position.z = 2 scene.add(camera) // Controls const controls = new OrbitControls(camera, canvas) controls.enableDamping = true /** * Renderer */ const renderer = new THREE.WebGLRenderer({ canvas: canvas, }) renderer.setSize(sizes.width, sizes.height) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) renderer.shadowMap.enabled = false renderer.shadowMap.type = THREE.PCFSoftShadowMap /** * Animate */ const clock = new THREE.Clock() const tick = () => { const elapsedTime = clock.getElapsedTime() //Update sphere position //Set circular motion track sphere.position.x = Math.sin(elapsedTime) * 1.5 sphere.position.z = Math.cos(elapsedTime) * 1.5 //Set the bottom bouncing effect sphere.position.y = Math.abs(Math.sin(elapsedTime * 3)) //Update ball low shadow map position //Shadow maps follow the sphere sphereShadow.position.x = sphere.position.x sphereShadow.position.z = sphere.position.z //The shadow changes according to the height of the sphere, and the transparency of the map also changes //The higher the sphere is from the plane, the more transparent the shadow is sphereShadow.material.opacity = (1 - Math.abs(sphere.position.y)) * 0.3 // Update controls controls.update() // Render renderer.render(scene, camera) // Call tick again on the next frame window.requestAnimationFrame(tick) } tick()