Three.js 阴影:从"假阴影"到"阴影贴图"一次讲清
原文出处:Three.js Manual – Shadows
本文基于官方手册 Shadows 章节整理,从最便宜的假阴影讲起,再到真正的阴影贴图(shadow map)怎么调,一篇看懂。
写在前面
前面两篇我们聊了灯光和相机,场景有立体感、也能正确"看"了。但还差最后一部分——阴影。
没有阴影,物体像是飘在半空,怎么看都不真实。three.js 里做阴影有一套完整的机制,但坑也不少。这篇先介绍假阴影方案,再介绍真正的阴影贴图,并说清新手最常卡住的几个点。
一、阴影很贵
three.js 默认用shadow map(阴影贴图)做阴影。它的原理一定要记住这句话:
对于每一盏"投影"的灯,场景里所有"投射阴影"的物体,都要从"灯的角度"再画一遍。
假设你有 20 个物体、5 盏投影灯,整个场景就会被画 6 遍(5 遍从灯的角度、1 遍正式渲染)。更复杂的是,如果是点光源投影,一盏灯就要画 6 遍(立方体六个面各一遍)!
所以现实里常见做法有几种:
- 多盏灯,但只让其中一盏平行光投影;
- 用 lightmap / 环境光遮蔽贴图离线烘焙(静态、快,但后面单讲);
- 直接用假阴影(最简单)。
二、假阴影:一个圆片搞定
最省事的方案:弄一块平面,贴一张灰度"阴影"纹理,放在物体正下方。像《动物森友会》《纪念碑谷》里角色脚下的圆影就是这么干的——便宜又有效。
做法是给每个球建一个Object3D作为"底座",球和阴影都挂它下面,这样移动底座两者一起动:
constbase=newTHREE.Object3D();scene.add(base);constshadowMat=newTHREE.MeshBasicMaterial({map:shadowTexture,transparent:true,// 能透出地面depthWrite:false,// 阴影之间互不打架,不用排序});constshadowMesh=newTHREE.Mesh(shadowGeo,shadowMat);shadowMesh.position.y=0.001;// 略微抬高,避免和地面 z-fightingshadowMesh.rotation.x=Math.PI*-.5;constshadowSize=sphereRadius*4;shadowMesh.scale.set(shadowSize,shadowSize,shadowSize);base.add(shadowMesh);动画时让球上下弹跳,同时把阴影的透明度跟着调淡——球越高,影子越淡:
constyOff=Math.abs(Math.sin(time*2+ndx));sphereMesh.position.y=y+THREE.MathUtils.lerp(-2,2,yOff);shadowMesh.material.opacity=THREE.MathUtils.lerp(1,.25,yOff);两个小坑:depthWrite: false是让多个阴影互不遮挡;position.y = 0.001是防止和地面"深度冲突"闪烁(z-fighting)。
三、真正的阴影贴图
当你需要真实阴影时,走这三步:
1. 渲染器开阴影
renderer.shadowMap.enabled=true;2. 灯光开启投影
constlight=newTHREE.DirectionalLight(color,intensity);light.castShadow=true;3. 每个物体声明:投影?接收?地面只接收、不投射(反正底下看不见);立方体、球两者都要:
mesh.receiveShadow=true;// 地面mesh.castShadow=true;// 立方体/球mesh.receiveShadow=true;跑起来——咦,怎么阴影缺了一块?
四、为什么阴影"缺一块"?调阴影相机
原因:阴影贴图是从"灯的角度"渲染的,灯上挂着一台阴影相机,它框定的范围之外就不产生阴影。上面缺一块,是因为这个范围太小了。
把灯的阴影相机可视化出来就一目了然:
constcameraHelper=newTHREE.CameraHelper(light.shadow.camera);scene.add(cameraHelper);平行光用的是正交相机做阴影相机(回顾相机那篇),靠left/right/top/bottom/near/far/zoom定框。手册用一个DimensionGUIHelper同时设left/right为width、bottom/top为height,再配之前写过的MinMaxGUIHelper调near/far:
functionupdateCamera(){light.target.updateMatrixWorld();helper.update();light.shadow.camera.updateProjectionMatrix();// 改了阴影相机参数必须调这个cameraHelper.update();}updateCamera();把width、height调到约 30,阴影就完整了。
但别贪大:有人想"那我设成 100 全包住不香吗?"——结果阴影变得马赛克一样糊。因为阴影贴图本质是一张纹理,框得越大,这张纹理被拉伸得越厉害,分辨率就越低。
五、阴影贴图分辨率:mapSize
阴影贴图是张有尺寸的纹理,默认512×512。越大越清晰,但越吃内存越慢。用这两个属性调:
light.shadow.mapSize.width=1024;light.shadow.mapSize.height=1024;实战经验:阴影清晰度的两个杠杆是——① 阴影相机框尽量小(刚好罩住场景);② mapSize 尽量小但够用。另外注意每台机器的纹理上限renderer.capabilities.maxTextureSize。
六、聚光灯阴影:框由灯自己定
换成SpotLight,它的阴影相机变成透视相机。而且大部分参数你改不了——它的fov直接绑死在聚光灯的angle上,aspect由贴图尺寸自动算。所以调聚光灯阴影,本质就是调angle和penumbra(见灯光那篇)。
angle开太大,阴影贴图被摊到很大一片区域,分辨率照样崩。
七、点光源阴影:最慢的一种
PointLight向四面八方发光,它的阴影相当于6 个聚光灯阴影(立方体六个面各一遍)。所以只有near和far两个参数可调,而且整场景要画 6 遍,最慢。
常见演示是给场景套个盒子(side: THREE.BackSide渲染内侧),让阴影能投到墙和天花板上:
constcubeGeo=newTHREE.BoxGeometry(cubeSize,cubeSize,cubeSize);constcubeMat=newTHREE.MeshPhongMaterial({color:'#CCC',side:THREE.BackSide,});constmesh=newTHREE.Mesh(cubeGeo,cubeMat);mesh.receiveShadow=true;mesh.position.set(0,cubeSize/2-0.1,0);// 略低于地面,避免 z-fightingscene.add(mesh);拖动点光源位置,能看到阴影落在四周墙上。near以内不投影、far以外永远在阴影里。
总结:怎么选阴影方案?
| 方案 | 真实度 | 性能 | 适用 |
|---|---|---|---|
| 假阴影(圆片) | 低 | 极快 | 移动端、卡通风格、俯视游戏 |
| 平行光阴影贴图 | 高 | 中 | 室外主光源 |
| 聚光灯阴影贴图 | 高 | 中 | 舞台、手电 |
| 点光源阴影贴图 | 高 | 慢(6 遍) | 室内灯泡,谨慎用 |
记住四句话:① 投影前renderer.shadowMap.enabled = true+ 灯castShadow+ 物体cast/receiveShadow;② 阴影"缺一块"是阴影相机框太小;③ 框别太大、mapSize 别太高,否则糊或卡;④ 能少用几盏投影灯就少用。
延伸阅读
- Renderer.shadowMap
- LightShadow
- DirectionalLightShadow
- PointLightShadow
- 回顾:Lights(灯光) · Cameras(相机)