three.js learning notes - Shadows

Keywords: Javascript Front-end Three.js

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()

Posted by kaiquej on Fri, 03 Dec 2021 10:12:18 -0800