作者:来自 vivo 互联网大前端团队- Su Ning

本文将探讨 three.js 中的阴影渲染机制,并分享一些针对性能和效果优化的实用技巧,帮助开发者在不同场景下做出最佳的权衡选择。

一、前言

在3D网页应用中,高质量的阴影渲染对于营造场景的真实感至关重要。作为广泛采用的 WebGL 框架之一,three.js 为开发者提供了多种阴影渲染选项,使得创建生动逼真的光影效果成为可能。然而,实现这些视觉上的增强往往伴随着性能开销,尤其在处理复杂场景或运行于低端设备时更为明显。因此,在确保画面质量的同时优化阴影渲染,以提升用户体验和保持流畅性,便成了一个核心挑战。本文将解析 three.js 中的阴影渲染机制,并提供一系列实用的优化策略,助力开发者在不同应用场景下达成最佳平衡。

二、数字人中使用的阴影

在开发拟我形象的过程中,恰当运用阴影可以显著增加模型的立体感与真实度。同时,在地面上添加阴影不仅能够为观察者提供空间定位的参考点,还能大大增强场景的空间层次感和沉浸体验。

图1(全局阴影)

图2(地面阴影)

接下来,我们将探讨全局阴影的优化方法以及地面阴影的具体实施方案。

三、全局阴影的优化

全局阴影的实现主要依赖于 three.js 提供的 shadowMap。只需简单几步——在 WebGLRenderer 中启用 shadowMap 功能、定义产生阴影的光源以及设定哪些物体负责投射或接收阴影——即可轻松完成设置。

若仅使用 three.js 默认配置下的阴影设置,虽然操作简便但效果通常不尽如人意。特别是在针对移动平台进行开发时,考虑到性能限制,我们有必要对 three.js 的阴影特性做进一步研究:

3.1 three.js 的阴影

在 three.js 中,阴影的类型主要有两种,分别是硬阴影(hard shadows)和软阴影(soft shadows)。硬阴影的边缘清晰,常用于模拟光源较小或光源位置靠近物体的场景;软阴影的边缘较模糊,更加接近现实中的阴影效果。这两种阴影效果是通过不同的阴影贴图(shadow map)类型实现的。以下是常见的阴影类型:

3.1.1 BasicShadowMap(硬阴影)

特性: 这是最基本的阴影类型,计算速度快,性能开销小,但效果相对简单。生成的阴影没有柔和的边缘,呈现出硬边界。

用途: 用于性能要求较高但不太关注阴影效果的场景。

图3(BasicShadowMap)

3.1.2 PCFShadowMap (Percentage-Closer Filtering)(软阴影)

特性: 默认的阴影类型,边缘相对柔和。使用了一种简单的滤波技术来使阴影边缘变得平滑。

用途: 大多数情况下推荐使用,效果较好,性能开销也可以接受。

图4(PCFShadowMap)

3.1.3 PCFSoftShadowMap(软阴影)

特性: 在 PCFShadowMap 的基础上,进一步对阴影的柔和度进行了优化,提供更柔和的阴影边缘效果,但性能开销会更大。

用途: 用于需要较高质量阴影效果的场景。

图5(PCFSoftShadowMap)

3.1.4 VSMShadowMap (Variance Shadow Map)(软阴影)

特性: 使用了方差阴影贴图算法,能够生成高质量且无锯齿的柔和阴影。相比 PCF 技术,它可以产生更加平滑的效果,并且可以避免常见的阴影采样问题。但该技术可能会产生“光晕”现象。

用途: 适用于高质量阴影场景,特别是需要柔和渐变的阴影效果。

图6(VSMShadowMap)

从上面的预览图可以看出,对于 BasicShadowMap 和 PCFShadowMap,阴影的边缘有比较多的锯齿,而对于 PCFSoftShadowMap,除了有更多的性能开销之外,人物在动的时候边缘也会有明显的闪烁的情况出现,而且边缘模糊半径过大导致阴影的效果并不明显。使用 VSMShadowMap 虽然可以得到相对好的效果,但是会出现严重的伪影问题,虽然可以通过调整 shadow 的偏置值(bias)来解决,但是过大的 bias 值会使得阴影的深度测试结果偏移过多,导致阴影被错误地渲染得过远,从而产生不自然的视觉效果。

作为一个手机上的H5页面,除了要保障基础的视觉效果,还需要优化性能以使其运行在更多的设备上,为了实现一开始向大家展示的效果同时不增加性能的开销,我们有了下面的优化思路。

3.2 优化思路

要想有一个比较好的阴影效果,首先不能是硬阴影,所以排除了 BasicShadowMap;

由于 PCFSoftShadowMap 对于性能的开销较大的同时效果提升的也不是很明显,所以也排除掉;最后由于伪影难以控制,所以我们选择了基于 PCFShadowMap进行优化。

为了得到更好的阴影边缘,可以通过提升 shadowMap 的分辨率来优化,但是分辨率的提升势必会导致性能开销变大,如何在不提升贴图分辨率的情况下提升阴影边缘的质量呢?

我们都知道在不同尺寸的屏幕相同分辨率的情况下,越小的屏幕显示效果越细腻,DirectionalLight 在生成阴影时,会使用一个正交相机(OrthographicCamera)来确定渲染阴影的区域。这个相机的四个边界(left、right、top、bottom)定义了阴影贴图的范围。通过缩小这些边界,可以将阴影贴图的像素更集中于需要渲染阴影的区域,从而提升阴影的清晰度。实际上在虚拟人的场景中,用户的主要注意力都集中在头部区域,所以只要将阴影相机聚焦在头部的区域即可,而不需要获取全局的阴影。

const bias = 1.6 // 设置一个y轴的偏置值,使得阴影相机可以正对人脸
const mainLight = new THREE.DirectionalLight(0xf2f7ff)
mainLight.intensity = 1.8
mainLight.position.set(0.3, 0.81 + bias, 2.71)
 
const target = new THREE.Object3D()
target.position.set(0, bias, 0)  // 设置灯光的照射目标
group.add(target)
mainLight.target = target
mainLight.castShadow = true
 
mainLight.shadow.radius = 2  // 设置阴影边缘的模糊半径,这个值并不是越大越好,需要根据实际场景进行微调
const { camera } = mainLight.shadow
camera.far = 5
 
// 阴影相机的默认边界为上下左右分别为5,将其缩小至各0.5
camera.top = -0.5
camera.bottom = 0.5
camera.left = -0.5
camera.right = 0.5

四、地面阴影的实现

在最开始的动图(图2)中,除了脸部的阴影,还有一个地面的阴影,很显然地面阴影不可能专门打一束光照在脚上获得,这样会使得整体的光影显得很奇怪,那么地面阴影是怎么实现的呢。

实际上这里参考了 

model-viewer (https://github.com/google/model-viewer)

的实现,地面上的阴影实际上是一个方形加上阴影贴图:

  • 创建一个正交相机,将相机的位置设置在脚下,朝向上方并有一点点倾角,获取到从地面向上看的图像;
  • 创建一个材质,并且自定义着色器渲染物体的深度信息,渲染第一步创建的相机的场景的时候将材质赋值给scene.overrideMaterial属性,这样场景中所有的物体都会使用这个材质进行渲染;
  • 再创建一个正交相机,用于模糊第一个相机获取到的图像;
  • 将模糊后的图像作为贴图,应用到地板平面上;
  • 此方案在每帧画面渲染之前都要再额外先把地面阴影的场景渲染出来,所以会增加额外的性能开销,由于地面阴影的边缘经过模糊平滑的处理,所以分辨率并不需要太高,贴图尺寸设置为64*64即可,有效的控制地面阴影带来的性能损失。

// 设置阴影渲染目标,作为阴影贴图
const size = 64
const shadowTarget = new THREE.WebGLRenderTarget(size, size)
const shadowTargetBlur = new THREE.WebGLRenderTarget(size, size)
this.shadowTarget = shadowTarget
this.shadowTargetBlur = shadowTargetBlur
 
// 调整位置
this.position.set(0, -0.05, 0)
this.rotateX(Math.PI / 2)  //旋转地板与地面平行
 
// 设置阴影相机
const camera = new THREE.OrthographicCamera(-0.75, 0.75, 0.75, -0.75, 0, 0.5)
 
// 设置地面相机的一个倾斜角度
camera.rotateX(Math.PI / 6)
camera.rotateY(Math.PI / 6)
this.add(camera)
this.camera = camera
 
// 设置视觉相机
const visionCamera = new THREE.OrthographicCamera(-0.75, 0.75, 0.75, -0.75, 0, 2)
this.add(visionCamera)
this.visionCamera = visionCamera
 
// 设置深度材质的片段着色器
this.depthMaterial.onBeforeCompile = function (shader) {
  shader.fragmentShader = shader.fragmentShader.replace(
    'gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );',
    'gl_FragColor = vec4( vec3( 0.0 ), ( 1.0 - fragCoordZ ) * opacity );'
  )
}
 
// 创建地板
const planeGeometry = new THREE.PlaneGeometry(1.5, 1.5)
const material = new THREE.MeshBasicMaterial({
  opacity: 0.3,
  transparent: true,
  map: shadowTarget.texture,
  side: THREE.DoubleSide,
  color: 0x666666
})
const plane = new THREE.Mesh(planeGeometry, material)
visionCamera.add(plane)
 
const blurPlane = new THREE.Mesh(planeGeometry)
blurPlane.visible = false
visionCamera.add(blurPlane)
 
this.plane = plane
this.blurPlane = blurPlane

五、结语

针对全局阴影和地面阴影,我们采取了不同的优化方式:

  • 通过合理选择阴影的渲染方式、优化阴影相机的视野范围以及优化阴影贴图的分辨率,可以在保证性能没有明显提升的情况下显著提升阴影的品质;
  • 通过获取底部视角的深度信息结合自定义shader来生成地面阴影,对页面的性能没有明显的损耗的同时达到一个比较好的效果。

后续也可以通过在 webview 注入机型信息,通过机型对手机的性能进行分级,调用针对性的渲染方案,可以使页面在流畅运行的前提下进一步提升画面的表现。为了实现更好的阴影效果,也可以对 three.js 的阴影相机进行扩展,实现多机位 shadowMap 等能力,在不增加太多负载的情况下进一步提升阴影的效果。


vivo互联网技术
3.4k 声望10.2k 粉丝