前言
经过上一小节,我们学会了如何使用各种类型的灯光。既然有了光,那还得有阴影,这样看起来才会更加真实。
无论使用什么引擎,阴影一直是实时3D渲染的一项挑战。需要有技巧的,以合理的性能消耗来显示更加逼真的阴影效果。
实现阴影的方法有很多种,Three.js
有一个内置的解决方案。需要注意的是,这个解决方案很方便,使用很简单,但它并不完美。
它是如何工作的
本课程不会详细说明阴影是如何在内部工作的,我们主要学习了解有关阴影的基础知识。
当Three.js
在进行渲染时,首先会对每个需要投射阴影的光源进行计算。和相机的工作原理有点类似,对光线可“见”范围里的3D对象进行渲染,在此期间,会使用MeshDepthMaterial
来替换所有的材质。
将这些渲染的结果存储为纹理贴图,并且在需要接收阴影的几何体材质上进行投影。
这里有一个平行光和聚光灯阴影的示例: https://threejs.org/examples/webgl_shadowmap_viewer.html
准备工作
在场景中创建一个球体,一个平面,再创建一个平行光和一个环境光。
我们可以在Dat.GUI
中控制这些灯光的位置和强度以及材质的金属度和粗糙度。
如何启用阴影
首先,我们需要将渲染器shadowMap
的enabled
属性设置为true
:
renderer.shadowMap.enabled = true
然后,我们需要想清楚两件事:
- 哪些对象需要计算阴影,将需要计算阴影的对象的
castShadow
属性设置为true
。
- 哪些对象需要接受阴影,将需要接受阴影的对象的
receiveShadow
属性设置为true
。
例如:场景的球体需要用于计算阴影,而平面需要接受阴影
// 球体计算阴影
sphere.castShadow = true
// 平面接受阴影
plane.receiveShadow = true
最后,我们还需要开启灯光对象的castShadow
属性。
directionalLight.castShadow = true
请注意,仅有以下三种灯光可以启用计算阴影:
- PointLight
- DirectionalLight
- SpotLight
再次提醒大家,实时阴影的计算非常消耗性能,场景中如果有大量的灯光,请务必想清楚哪些灯光需要用于计算实时阴影,而不是全部启用阴影计算。不需要进行实时阴影计算的灯光可以在3D渲染软件中将阴影的效果烘焙到贴图上。
现在我们的平面上可以看到这个球体的影子了,虽然它看起来还很粗糙。
接下来让我们学习如何改善阴影效果。
阴影优化
渲染尺寸
要知道,Three.js
里阴影的本质其实是通过计算实时生成阴影贴图。您可以使用灯光上的shadow属性访问此阴影贴图:
console.log(directionalLight.shadow)
默认情况下,阴影贴图大小仅为512x512。我们可以改变这个尺寸,不可随意设置哦,必须是2的幂:
directionalLight.shadow.mapSize.width = 1024
directionalLight.shadow.mapSize.height = 1024
当我们把阴影贴图的尺寸设置为1024后,看上去好一些了。
Near and far
Three.js
使用相机来帮助计算阴影贴图。这些相机与我们前面学到的相机具有相同的属性。比如我们必须定义相机的近视距离和远视距离。这两个参数并不会真正提高阴影的质量,但调整这两个参数有可能会修复一些看不到阴影或者阴影突然出现的错误。
为了帮助我们调试灯光对象中阴影贴图的相机,为了更方便预览近视远视两个参数的变化,我们可以使用相机辅助工具。
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)
现在,我们是不是可以直观地看到这些用于渲染阴影贴图的相机了。尝试找到适合场景的值:
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 6
光照范围Amplitude
通过相机辅助工具,可以观察到这些相机的视角过大。
因为我们使用的是平行光,所以Three.js
在为它渲染阴影贴图时使用的是正交相机。如果您还记得相机课程,我们可以通过顶部,右侧,底部和左侧属性控制相机在每一侧可以看到的距离。让我们调整减小一下这些属性:
directionalLight.shadow.camera.top = 2
directionalLight.shadow.camera.right = 2
directionalLight.shadow.camera.bottom = - 2
directionalLight.shadow.camera.left = - 2
这些值越小,阴影就会越精确。但是太小也不行,阴影会被裁剪。
现在我们把相机辅助工具隐藏起来看一看
directionalLightCameraHelper.visible = false
模糊
我们可以通过设置阴影的radius
属性从而让阴影的边缘看起来是模糊的,这将使阴影看上去更柔和。
directionalLight.shadow.radius = 10
这种模糊并没有去计算灯光和物体的距离(比如近则清晰,远则模糊),这只是一个简单的模糊,但大部分时候挺出效果的。
阴影贴图算法
three.js
中有几种内置的阴影贴图算法供我们选择:
- THREE.BasicShadowMap 性能很好,但质量很差
- THREE.PCFShadowMap 性能较差,但边缘更光滑
- THREE.PCFSoftShadowMap 性能较差,但边缘更柔软
- THREE.VSMShadowMap 更低的性能,更多的约束,可能会产生意想不到的结果
我们可以通过设置 renderer.shadowMap.type
来改变阴影贴图的算法。这个属性的默认值是 THREE.PCFShadowMap
我们可以使用 THREE.PCFSoftShadowMap
来获得更好的阴影效果。
renderer.shadowMap.type = THREE.PCFSoftShadowMap
不过启用THREE.PCFSoftShadowMap
后,shadow.radius
属性就不可用了。选择好难~
聚光灯
现在我们在场景中添加一个聚光灯Spotlight,并将castShadow
属性添加为true
。
同样的,我们也为聚光灯添加一个阴影相机的辅助工具:
// Spot light
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)
scene.add(spotLight.target)
const spotLightCameraHelper = new THREE.CameraHelper(spotLight.shadow.camera)
scene.add(spotLightCameraHelper)
如果现在场景过亮的话,可以降低其他灯光的强度:
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4)
// ...
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4)
现在两个阴影看上去是两种效果。不过就目前而言,没有太多参数能把他们调整得更和谐。
但我们至少能给他们设置一样的参数来获得相同的阴影质量。
设置相同的 shadow.mapSize
属性:
spotLight.shadow.mapSize.width = 1024
spotLight.shadow.mapSize.height = 1024
因为这是一个聚光灯SpotLight
,所以它使用的是透视相机 PerspectiveCamera
。要改变它的生效范围就必须更改fov
属性,而不是top
, right
, bottom
和 left
属性。尝试在不裁剪阴影的情况下找到尽可能小的角度:
spotLight.shadow.camera.fov = 30
同样需要改变一下 near
和 far
属性:
spotLight.shadow.camera.near = 1
spotLight.shadow.camera.far = 6
让我们再把这个相机辅助工具隐藏起来:
spotLightCameraHelper.visible = false
确实比刚才好了一点,就一点点,不仔细看可能还看不大出来:P。
点光源
现在让我们试一试最后一种支持计算阴影的灯光,点光源:
// Point light
const pointLight = new THREE.PointLight(0xffffff, 0.3)
pointLight.castShadow = true
pointLight.position.set(- 1, 1, 0)
scene.add(pointLight)
const pointLightCameraHelper = new THREE.CameraHelper(pointLight.shadow.camera)
scene.add(pointLightCameraHelper)
如果场景太亮,可以降低其他灯光强度:
const ambientLight = new THREE.AmbientLight(0xffffff, 0.3)
// ...
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.3)
// ...
const spotLight = new THREE.SpotLight(0xffffff, 0.3, 10, Math.PI * 0.3)
点光源的光线应该是向四面八方发射出去,但我们可以通过相机辅助工具只能看到一个透视相机 (和聚光灯一样),但这个相机是朝下的。这是由于在Three.js
中点光源的阴影贴图要依赖6个方向上的相机来实现-_-!。
正因为点光源会在每个方向上发光,所以Three.js
必须通过6个方向的渲染才能创建出多维数据集阴影贴图。而我们通过相机辅助工具看到的相机是最后一个方向,朝下的相机。
要同时在六个方向计算阴影,这当然会更加消耗性能,所以请尽可能避免启用点光源来计算阴影。
和其它阴影一样可以改变的属性有mapSize
,near
和far
:
pointLight.shadow.mapSize.width = 1024
pointLight.shadow.mapSize.height = 1024
pointLight.shadow.camera.near = 0.1
pointLight.shadow.camera.far = 5
让我们再把这个相机辅助工具隐藏起来:
pointLightCameraHelper.visible = false
阴影烘焙
通过前面的学习,我们知道了在Three.js
里实时计算阴影是很消耗性能的。
有另一种很好的选择是烘焙阴影。我们在上一小节中了解过灯光烘焙
,其实它和阴影烘焙
是一个意思。阴影的效果会被整合到我们应用于材料的纹理贴图上。
之前为阴影写的代码,我们可以直接在渲染器中停用它们,并不需要注释所有与阴影相关的代码行:
renderer.shadowMap.enabled = false
现在我们可以用TextureLoader
加载器加载位于 /static/textures/bakedShadow.jpg
中的阴影纹理贴图。
在创建物体和灯光之前添加下面的代码:
/**
* Textures
*/
const textureLoader = new THREE.TextureLoader()
const bakedShadow = textureLoader.load('/textures/bakedShadow.jpg')
最后,我们使用MeshBasicMaterial
来创建一个基本材质的平面即可,并不需要使用MeshStandardMaterial
:
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(5, 5),
new THREE.MeshBasicMaterial({
map: bakedShadow
})
)
现在我们应该能看到一个很漂亮很逼真的阴影效果。需要注意的是,这不是实时计算的阴影,所以当球体或灯光移动时,阴影不会随之改变。
假阴影
无论是计算阴影或者是阴影烘焙,至少都需要依赖物体和灯光的计算,只是实时和非实时的区别。
还有一种方式可以非常简单高性能的模拟出类似
阴影的效果,注意,真的只是类似。
纹理贴图是一个简单的光晕效果。白色部分将可见,黑色部分将不可见。
首先,让我们去掉原本的阴影烘焙贴图,恢复使用MeshStandardMaterial
的状态:
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(5, 5),
material
)
然后,我们可以加载位于/static/textures/bakedShadow.jpg
中的基本阴影纹理。
const simpleShadow = textureLoader.load('/textures/simpleShadow.jpg')
加载好贴图后,我们可以通过使用一个平面来创建阴影,平面是创建出来时默认是面向Z轴正方向。我们需要将其旋转90度并放置在地板上方。基础材质的颜色必须是黑色的,再将alphaMap
设置为刚才加载的阴影贴图。还需要将透明属性更改为true
,最后将平面添加到场景中:
const sphereShadow = new THREE.Mesh(
new THREE.PlaneGeometry(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(sphere, sphereShadow, plane)
好了,现在这个球体有了一个说不上很假,但也凑合能看的阴影效果。
虽然假了一点,但这种方式拥有很高的性能。并且这个阴影的位置和大小还可以根据球体的位置来动态调整,比如球体离地面越高,阴影越淡;球体离地面越近,阴影越浓。
const clock = new THREE.Clock()
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Update the sphere
sphere.position.x = Math.cos(elapsedTime) * 1.5
sphere.position.z = Math.sin(elapsedTime) * 1.5
sphere.position.y = Math.abs(Math.sin(elapsedTime * 3))
// Update the shadow
sphereShadow.position.x = sphere.position.x
sphereShadow.position.z = sphere.position.z
sphereShadow.material.opacity = (1 - sphere.position.y) * 0.3
// ...
}
tick()
如何选择阴影实现方式
在three.js
中实现阴影的三种方式(计算,烘焙,假阴影)都教给大家了,在实战中请根据项目对性能和视觉效果的需求来灵活选择,当然,这几种方式也可以结合起来使用。