Three.js shadows

강정우·2025년 1월 6일
0

three.js

목록 보기
9/24
post-thumbnail

Shadows

castShadows = true 코드가 존재한다면, 광원은 Camera 의 frustums(절두체) 를 관리한다.

  • Frustum
    위에서 frusum 이라는 단어가 나왔는데 이는 "시야체(절두체)" 라고 번역할 수 있다.
    이는 광원이 조명을 투사하는 영역이다.

🔎 원리

showdow 는 기본적으로 Light 에 내장된 shadow Camera에 의해 계산된다.
이 shadow Camera는 광원이 바라보는 영역을 정의한다.

참고로 이 shadow Camera 가 너무 넓은 영역을 비추고 있다면 모든 역영을 계산하기 때문에 메모리를 굉장히 많이 잡아먹을 수 있다. 심지어 아무 Mesh 가 없는 곳이라도.

Shadow Map

Shadow Camera 는 장면을 렌더링하여 깊이 맵(Depth Map)을 생성한다.
이 Depth map 은 광원에서 각 픽셀까지의 거리를 저장한 텍스처이다.
이때 이 픽셀의 깊이 값이 Shadow Map에 저장된 값보다 크다면, 해당 픽셀은 그림자 내부로 간주된다.

Shadow Map은 GPU 메모리에 저장되며, 해상도(shadow.mapSize)가 메모리 사용량에 큰 영향을 미친다.

directionalLightFolder.add(data, 'shadowMapSizeWidth', [256, 512, 1024, 2048, 4096]).onChange(() => updateDirectionalLightShadowMapSize())
directionalLightFolder.add(data, 'shadowMapSizeHeight', [256, 512, 1024, 2048, 4096]).onChange(() => updateDirectionalLightShadowMapSize())

function updateDirectionalLightShadowMapSize() {
    directionalLight.shadow.mapSize.width = data.shadowMapSizeWidth
    directionalLight.shadow.mapSize.height = data.shadowMapSizeHeight
    directionalLight.shadow.map = null
}

그리고 Shadow 를 사용하려면 단드시 rederer 에 shadowMap 속성을 넣어줘야한다.

renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFShadowMap // (default)

그리고 Shadow 는 최종랜더링 때 GPU 에서 병렬로 처리된다.

Shadow Map 종류

그림자는 총 4가지 Shadow Map 이 존재한다.

  1. BasicShadowMap
    필터링되지 않은 섀도맵을 제공
    가장 빠르다
    그림자 색이 1개이다
    품질이 가장 낮다.

  2. PCFShadowMap(기본값)
    Pecentage-Closer Filtering(PCF) 알고리즘을 사용하여 섀도 맵을 필터링

  3. PCFSoftShadowMap
    Percentage-Closer Soft Shadows(PCSS) 알고리즘을 사용하여 섀도 맵을 필터

  4. VSMShadowMap
    Variance Shadow Map(VSM) 알고리즘을 사용하여 섀도 맵을 필터링
    VSMShadowMap 을 사용하면 모든 그림자 receivers 도 그림자를 cast 한다.

castShadow(), receiveShadow()

또한 그림자 드리우기, 그림자 만들기 모두 true 로 만들어줘야한다.
물론 이는 ground(e.g., plane), object(e.g., box) 와 같이 각각 그림자를 만드는지 혹은 그림자가 드리우는지 확인하여 작성하면 된다.

box.castShadow = true
ground.receiveShadow = true

Mesh 의 castShadow, receiveShadow 가 모두 true 로 설정되어있으면 이는 위 사진 처럼 다른 Mesh 표면에 artefacts 나 검은색 선이 생길 수 있다.
이는 shadow bias 속성값을 통해 조절이 가능한데 이는 기본값은 0 이고 0.1, -0.0001, 0.002 과 같은 매우 작은 값을 설정하여 조절하면 된다.

참고로 위 사진에서 볼 수 있듯, MeshBasic 과 MeshNormal 은 receiveShadow=true 를 해도 다른 Mesh 로 부터 그림자 영향을 받지 않는다.

( 광원이 아래서 위를 비추는데 바닥을 통과하여 그림자가 생성되는 모습 )
만약 gournd 를 planeGeometry 를 사용하여 만들고 여기에 castShadow 를 꺼버리면 더 이상 그림자를 만들지 않기 때문에 그냥 통화를 시켜버린다.

updateProjectionMatrix()

Shadow Camera 는 어떠한 변경이 있을 때 반드시 위 함수를 실행해줘야 계산되어 반영된다.

🌞 Lights 에 따른 Shadow

light 에 따른 광원의 기본값

PointLight 와 SpotLight 는 PerspectiveCamera frustum 를 사용하여 각각의 그림자를 계산한다.

속성기본값
fov90
aspect1
near0.5
far500

DirectionalLight 는 OrthographicCamera frustum 를 사용하여 그림자를 그린다. 얘는 직교방식을 사용하는 이유는 DirectionalLight 의 광원은 평항으로 비추기 때문이다.

속성기본값
left-5
right5
top5
bottom-5
near0.5
far500

그리고 각 광원은 cashShadow 속성을 true 로 만들어줘야한다.

directionalLight.castShadow = true
pointLight.castShadow = true
spotLight.castShadow = true

Directinoal Light

const directionalLight = new THREE.DirectionalLight(data.lightColor, Math.PI)
directionalLight.position.set(1, 1, 1)
directionalLight.castShadow = true
directionalLight.shadow.camera.near = 0
directionalLight.shadow.camera.far = 10
directionalLight.shadow.mapSize.width = data.shadowMapSizeWidth
directionalLight.shadow.mapSize.height = data.shadowMapSizeHeight
scene.add(directionalLight)

이 광원의 .cashShadow 는 위 light 에 따른 광원의 기본값 에서 적은 거 처럼 internal camera frustum 을 만들어 그림자가 어떻게 생성될 지 계산한다.

radius 옵션

showdow 를 얼마나 부드럽게 할 것인가

옵션 1.PCF(PCFShadowMap)
옵션 2.VSM(VSMShadowMap)

blurSamples 옵션

VSMShadowMap only 거의 사용되지 않음

Point Light, Spotlight

앞서 언급했듯 perspective 카메라 원리로 계산한다. 그래서 광원의 위치에 따라 그림자가 다르게 생성된다.

📸 Camera Helper

위 사진들을 보면 헬퍼의 모양이 조금 다르다는 것을 알 수 있다. 이는 light helper 가 아닌 camera helper 를 사용해서 그런데, 파란색 화살표가 top 을 뜻하고 near, far plane 을 표현하고있다. 그리고 정사각형인 이유는 light 에 따른 광원의 기본값 에서 설명했듯 directional 은 OrthographicCamera 를 기반으로 frusum 을 만들기 때문이다.

위 사진은 directional Light 에서 Orthographic 이기 때문에 모든 그림자의 길이가 동일한 것을 알 수 있다.

💻 code

import './style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import Stats from 'three/addons/libs/stats.module.js'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'

const scene = new THREE.Scene()

scene.add(new THREE.GridHelper())

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(-1, 4, 2.5)

const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.shadowMap.type = THREE.PCFShadowMap // (default)
// renderer.shadowMap.type = THREE.PCFSoftShadowMap
//renderer.shadowMap.type = THREE.BasicShadowMap
// renderer.shadowMap.type = THREE.VSMShadowMap
renderer.shadowMap.enabled = true
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)

window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight
    camera.updateProjectionMatrix()
    renderer.setSize(window.innerWidth, window.innerHeight)
})

const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true

const plane = new THREE.Mesh(new THREE.PlaneGeometry(100, 100), new THREE.MeshStandardMaterial({ color: 0xffffff }))
plane.rotation.x = -Math.PI / 2
plane.receiveShadow = true
plane.castShadow = true
scene.add(plane)

const data = {
    color: 0x00ff00,
    lightColor: 0xffffff,
    shadowMapSizeWidth: 512,
    shadowMapSizeHeight: 512,
}

const geometry = new THREE.IcosahedronGeometry(1, 1)

const meshes = [
    new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color: data.color })),
    new THREE.Mesh(geometry, new THREE.MeshNormalMaterial({ flatShading: true })),
    new THREE.Mesh(geometry, new THREE.MeshPhongMaterial({ color: data.color, flatShading: true })),
    new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: data.color, flatShading: true })),
]

meshes[0].position.set(-3, 1, 0)
meshes[1].position.set(-1, 1, 0)
meshes[2].position.set(1, 1, 0)
meshes[3].position.set(3, 1, 0)

meshes.map((m) => {
  m.castShadow = true
  m.receiveShadow = true
})

scene.add(...meshes)

const gui = new GUI()

// #region DirectionalLight

const directionalLight = new THREE.DirectionalLight(data.lightColor, Math.PI)
directionalLight.position.set(1, 1, 1)
directionalLight.castShadow = true
directionalLight.shadow.camera.near = 0
directionalLight.shadow.camera.far = 10
directionalLight.shadow.mapSize.width = data.shadowMapSizeWidth
directionalLight.shadow.mapSize.height = data.shadowMapSizeHeight
scene.add(directionalLight)

// const directionalLightHelper = new THREE.DirectionalLightHelper(directionalLight)
const directionalLightHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
directionalLightHelper.visible = false
scene.add(directionalLightHelper)

const directionalLightFolder = gui.addFolder('DirectionalLight')
directionalLightFolder.add(directionalLight, 'visible')
directionalLightFolder.addColor(data, 'lightColor').onChange(() => {
    directionalLight.color.set(data.lightColor)
})
directionalLightFolder.add(directionalLight, 'intensity', 0, Math.PI * 10)
directionalLightFolder.add(directionalLight.position, 'x', -5, 5, 0.001).onChange(() => {
    directionalLightHelper.update()
})
directionalLightFolder.add(directionalLight.position, 'y', -5, 5, 0.001).onChange(() => {
    directionalLightHelper.update()
})
directionalLightFolder.add(directionalLight.position, 'z', -5, 5, 0.001).onChange(() => {
    directionalLightHelper.update()
})
directionalLightFolder.add(directionalLightHelper, 'visible').name('Helper Visible')
directionalLightFolder.add(directionalLight.shadow.camera, 'left', -10, -1, 0.1).onChange(() => {
    directionalLight.shadow.camera.updateProjectionMatrix()
    directionalLightHelper.update()
})
directionalLightFolder.add(directionalLight.shadow.camera, 'right', 1, 10, 0.1).onChange(() => {
    directionalLight.shadow.camera.updateProjectionMatrix()
    directionalLightHelper.update()
})
directionalLightFolder.add(directionalLight.shadow.camera, 'top', 1, 10, 0.1).onChange(() => {
    directionalLight.shadow.camera.updateProjectionMatrix()
    directionalLightHelper.update()
})
directionalLightFolder.add(directionalLight.shadow.camera, 'bottom', -10, -1, 0.1).onChange(() => {
    directionalLight.shadow.camera.updateProjectionMatrix()
    directionalLightHelper.update()
})
directionalLightFolder.add(directionalLight.shadow.camera, 'near', 0, 100).onChange(() => {
    directionalLight.shadow.camera.updateProjectionMatrix()
    directionalLightHelper.update()
})
directionalLightFolder.add(directionalLight.shadow.camera, 'far', 0.1, 100).onChange(() => {
    directionalLight.shadow.camera.updateProjectionMatrix()
    directionalLightHelper.update()
})
directionalLightFolder.add(data, 'shadowMapSizeWidth', [256, 512, 1024, 2048, 4096]).onChange(() => updateDirectionalLightShadowMapSize())
directionalLightFolder.add(data, 'shadowMapSizeHeight', [256, 512, 1024, 2048, 4096]).onChange(() => updateDirectionalLightShadowMapSize())
directionalLightFolder.add(directionalLight.shadow, 'radius', 1, 10, 1).name('radius (PCF | VSM)') // PCFShadowMap or VSMShadowMap
directionalLightFolder.add(directionalLight.shadow, 'blurSamples', 1, 20, 1).name('blurSamples (VSM)') // VSMShadowMap only
directionalLightFolder.open()

function updateDirectionalLightShadowMapSize() {
    directionalLight.shadow.mapSize.width = data.shadowMapSizeWidth
    directionalLight.shadow.mapSize.height = data.shadowMapSizeHeight
    directionalLight.shadow.map = null
}

// Pointlight

const pointLight = new THREE.PointLight(data.lightColor, Math.PI)
pointLight.position.set(2, 1, 0)
pointLight.visible = false
pointLight.castShadow = true
scene.add(pointLight)

const pointLightHelper = new THREE.PointLightHelper(pointLight)
pointLightHelper.visible = false
scene.add(pointLightHelper)

const pointLightFolder = gui.addFolder('Pointlight')
pointLightFolder.add(pointLight, 'visible')
pointLightFolder.addColor(data, 'lightColor').onChange(() => {
    pointLight.color.set(data.lightColor)
})
pointLightFolder.add(pointLight, 'intensity', 0, Math.PI * 10)
pointLightFolder.add(pointLight.position, 'x', -10, 10)
pointLightFolder.add(pointLight.position, 'y', -10, 10)
pointLightFolder.add(pointLight.position, 'z', -10, 10)
pointLightFolder.add(pointLight, 'distance', 0.01, 20)
pointLightFolder.add(pointLight, 'decay', 0, 10)
pointLightFolder.add(pointLightHelper, 'visible').name('Helper Visible')
pointLightFolder.add(pointLight.shadow.camera, 'near', 0.01, 100).onChange(() => {
    pointLight.shadow.camera.updateProjectionMatrix()
    pointLightHelper.update()
})
pointLightFolder.add(pointLight.shadow.camera, 'far', 0.1, 100).onChange(() => {
    pointLight.shadow.camera.updateProjectionMatrix()
    pointLightHelper.update()
})
pointLightFolder.add(data, 'shadowMapSizeWidth', [256, 512, 1024, 2048, 4096]).onChange(() => updatePointLightShadowMapSize())
pointLightFolder.add(data, 'shadowMapSizeHeight', [256, 512, 1024, 2048, 4096]).onChange(() => updatePointLightShadowMapSize())
pointLightFolder.add(pointLight.shadow, 'radius', 1, 10, 1).name('radius (PCF | VSM)') // PCFShadowMap or VSMShadowMap
pointLightFolder.add(pointLight.shadow, 'blurSamples', 1, 20, 1).name('blurSamples (VSM)') // VSMShadowMap only
pointLightFolder.close()

function updatePointLightShadowMapSize() {
    pointLight.shadow.mapSize.width = data.shadowMapSizeWidth
    pointLight.shadow.mapSize.height = data.shadowMapSizeHeight
    pointLight.shadow.map = null
}


// Spotlight

const spotLight = new THREE.SpotLight(data.lightColor, Math.PI)
spotLight.position.set(3, 2.5, 1)
spotLight.visible = false
//spotLight.target.position.set(5, 0, -5)
spotLight.castShadow = true
scene.add(spotLight)

//const spotLightHelper = new THREE.SpotLightHelper(spotLight)
const spotLightHelper = new THREE.CameraHelper(spotLight.shadow.camera)
spotLightHelper.visible = false
scene.add(spotLightHelper)

const spotLightFolder = gui.addFolder('Spotlight')
spotLightFolder.add(spotLight, 'visible')
spotLightFolder.addColor(data, 'lightColor').onChange(() => {
    spotLight.color.set(data.lightColor)
})
spotLightFolder.add(spotLight, 'intensity', 0, Math.PI * 10)
spotLightFolder.add(spotLight.position, 'x', -10, 10).onChange(() => {
    spotLight.shadow.camera.updateProjectionMatrix()
    spotLightHelper.update()
})
spotLightFolder.add(spotLight.position, 'y', -10, 10).onChange(() => {
    spotLight.shadow.camera.updateProjectionMatrix()
    spotLightHelper.update()
})
spotLightFolder.add(spotLight.position, 'z', -10, 10).onChange(() => {
    spotLight.shadow.camera.updateProjectionMatrix()
    spotLightHelper.update()
})
spotLightFolder.add(spotLight, 'distance', 0.01, 100).onChange(() => {
    spotLight.shadow.camera.updateProjectionMatrix()
    spotLightHelper.update()
})
spotLightFolder.add(spotLight, 'decay', 0, 10).onChange(() => {
    spotLight.shadow.camera.updateProjectionMatrix()
    spotLightHelper.update()
})
spotLightFolder.add(spotLight, 'angle', 0, 1).onChange(() => {
    spotLight.shadow.camera.updateProjectionMatrix()
    spotLightHelper.update()
})
spotLightFolder.add(spotLight, 'penumbra', 0, 1, 0.001).onChange(() => {
    spotLight.shadow.camera.updateProjectionMatrix()
    spotLightHelper.update()
})
spotLightFolder.add(spotLightHelper, 'visible').name('Helper Visible')
spotLightFolder.add(spotLight.shadow.camera, 'near', 0.01, 100).onChange(() => {
    spotLight.shadow.camera.updateProjectionMatrix()
    spotLightHelper.update()
})
spotLightFolder.add(data, 'shadowMapSizeWidth', [256, 512, 1024, 2048, 4096]).onChange(() => updateSpotLightShadowMapSize())
spotLightFolder.add(data, 'shadowMapSizeHeight', [256, 512, 1024, 2048, 4096]).onChange(() => updateSpotLightShadowMapSize())
spotLightFolder.add(spotLight.shadow, 'radius', 1, 10, 1).name('radius (PCF | VSM)') // PCFShadowMap or VSMShadowMap
spotLightFolder.add(spotLight.shadow, 'blurSamples', 1, 20, 1).name('blurSamples (VSM)') // VSMShadowMap only
spotLightFolder.close()

function updateSpotLightShadowMapSize() {
    spotLight.shadow.mapSize.width = data.shadowMapSizeWidth
    spotLight.shadow.mapSize.height = data.shadowMapSizeHeight
    spotLight.shadow.map = null
}


const stats = new Stats()
document.body.appendChild(stats.dom)

const labels = document.querySelectorAll<HTMLDivElement>('.label')

let x, y
const v = new THREE.Vector3()

function animate() {
    requestAnimationFrame(animate)

    controls.update()

    for (let i = 0; i < 4; i++) {
        v.copy(meshes[i].position)
        v.project(camera)

        x = ((1 + v.x) / 2) * innerWidth - 50
        y = ((1 - v.y) / 2) * innerHeight

        labels[i].style.left = x + 'px'
        labels[i].style.top = y + 'px'
    }

    renderer.render(scene, camera)

    stats.update()
}

animate()
profile
智(지)! 德(덕)! 體(체)!

0개의 댓글