光照与材质
光照模型(Lighting Model)
面试题:经典光照模型(Phong)由哪几部分组成?
冯氏光照模型(Phong)把物体表面颜色拆成三部分叠加:
- 环境光(Ambient):模拟全局间接光,给所有表面一个基础亮度,与方向无关。
ambient = 环境光强 × 材质色。 - 漫反射(Diffuse):粗糙表面对光的均匀散射,亮度取决于光线方向 L 与法线 N 的夹角:
diffuse = max(dot(N, L), 0) × 光强 × 材质色(兰伯特余弦定律)。 - 镜面反射(Specular):光滑表面的高光,取决于反射方向 R 与视线方向 V 的夹角:
specular = pow(max(dot(R, V), 0), shininess)。
Phong vs Blinn-Phong:Blinn-Phong 用「半程向量 H = normalize(L + V)」与法线点乘代替 R·V,计算更快、高光更自然,是实时渲染常用近似。
Three.js 光源类型
| 光源 | 说明 | 是否产生阴影 |
|---|---|---|
AmbientLight | 环境光,均匀照亮,无方向 | 否 |
DirectionalLight | 平行光(如太阳),方向一致 | 是 |
PointLight | 点光源(如灯泡),向四周发散,有衰减 | 是 |
SpotLight | 聚光灯,锥形范围 | 是 |
HemisphereLight | 半球光,模拟天空/地面环境色 | 否 |
RectAreaLight | 矩形区域光,模拟窗户/灯管 | 否 |
材质对比
面试题:Three.js 常见材质有什么区别?性能如何排序?
| 材质 | 光照 | 特点 | 性能 |
|---|---|---|---|
MeshBasicMaterial | 不受光 | 纯色/纹理,恒定亮度 | 最快 |
MeshLambertMaterial | 漫反射 | 逐顶点计算光照,无高光 | 快 |
MeshPhongMaterial | Phong | 逐片元计算,有高光 | 中 |
MeshStandardMaterial | PBR | 基于物理,metalness/roughness | 较慢 |
MeshPhysicalMaterial | PBR 增强 | 额外支持清漆、透射、镜面等 | 最慢 |
选型原则:不需要光照(如 UI、辉光)用 Basic;要写实用 Standard/Physical;性能吃紧且要求不高用 Lambert/Phong。
PBR(基于物理的渲染)
面试题:什么是 PBR?为什么比 Phong 更真实?
- PBR(Physically Based Rendering)用物理参数而非经验参数描述材质,核心是 金属度(metalness) 和 粗糙度(roughness)。
- 遵循能量守恒(反射的光不会超过入射光),在不同光照环境下都保持一致、可信的表现。
- 通常配合 环境贴图(Environment Map)/ IBL(基于图像的光照) 来提供真实的环境反射,金属材质尤其依赖它。
- 常见贴图:albedo(基础色)、normal(法线)、metalnessMap、roughnessMap、aoMap(环境光遮蔽)、emissive(自发光)。
阴影机制与性能陷阱
面试题:实时阴影(Shadow Mapping)的底层原理是什么?为什么开启阴影会导致严重的性能下降?
1. 底层原理:Shadow Mapping 算法
Three.js 底层默认使用 Shadow Mapping(阴影贴图) 算法来实现实时阴影。这个算法的核心在于“两次渲染”机制:
- 第一阶段(深度图渲染 - Depth Pass):
- 将相机的视角“瞬移”到光源的位置(这就是为什么代码里要配置
light.shadow.camera)。 - 以光源的视角将整个场景渲染一遍。但这次渲染不计算颜色,而是只计算离光源最近的物体的深度值(Z值),并将这些值记录到一张名为阴影贴图 (Shadow Map) 的离屏纹理中。
- 将相机的视角“瞬移”到光源的位置(这就是为什么代码里要配置
- 第二阶段(正式渲染 - Color Pass):
- 将相机切回真实的玩家视角,正常渲染场景。
- 在片元着色器(Fragment Shader)计算每个像素时,将该像素的坐标反向转换回“光源坐标系”。
- 关键比对:将算出的深度值,与刚才阴影贴图(Shadow Map)里记录的该位置的深度值进行比对。
- 如果当前像素的深度 大于 贴图里的深度,说明在光源和该像素之间有其他物体挡着了,该像素就在阴影中(压暗颜色);否则就在光照下。
2. 为什么开启阴影极度消耗性能?
一旦面试官问到性能,你必须从渲染管线的角度指出开启阴影的“致命开销”:
- Draw Call 数量直接翻倍(甚至翻 N 倍):
- 因为 Shadow Mapping 需要“两次渲染”,如果你的场景有 1000 个 Mesh,正常渲染是 1000 次 Draw Call。一旦开启了阴影(
castShadow = true),系统必须先以光源视角再画一遍这 1000 个物体。总 Draw Call 会瞬间飙升到 2000 次! - 如果场景里有 3 个投射阴影的光源,那就要以 3 个光源视角各画一遍深度图,总 Draw Call 会变成 4000 次!这在移动端浏览器上是灾难性的。
- 因为 Shadow Mapping 需要“两次渲染”,如果你的场景有 1000 个 Mesh,正常渲染是 1000 次 Draw Call。一旦开启了阴影(
- GPU 显存占用与采样带宽激增:
- 阴影贴图本质上是一张极高分辨率的纹理(为了让阴影边缘不出现马赛克,通常需要将
light.shadow.mapSize设为 2048x2048 甚至 4096x4096)。这不仅生吃巨大的显存,而且在第二阶段渲染时,片元着色器必须高频去采样这张庞大的贴图,给 GPU 带宽带来极大压力。
- 阴影贴图本质上是一张极高分辨率的纹理(为了让阴影边缘不出现马赛克,通常需要将
- 软阴影计算(PCF / PCSS)开销:
- 原生的 Shadow Map 边缘是硬锯齿状的。为了实现柔和的软阴影(Soft Shadow),Three.js 默认会在着色器里进行多次周边像素采样模糊(PCF)。采样的点数越多,GPU 的运算负担越重。
3. 在 Three.js 中的基础配置
// 1. 渲染器全局开启阴影计算,并可设置软阴影类型 (如 PCFSoftShadowMap)
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// 2. 光源开启投射
light.castShadow = true;
// 关键优化:限制光源阴影相机的视野范围,越紧凑阴影越清晰且省性能
light.shadow.camera.near = 0.5;
light.shadow.camera.far = 50;
// 3. 物体参与阴影投射和接收
mesh.castShadow = true; // 这个物体会挡住光,投下阴影
ground.receiveShadow = true; // 这个地面会显示别人投下来的阴影
常见问题:
- 阴影表面出现诡异的条纹斑点(Shadow Acne):因为浮点数精度误差导致物体错误地遮挡了自己。解决办法是微调
light.shadow.bias = -0.0001(给深度加一点偏移)。- 优化方案:在静态场景(如建筑漫游)中,坚决不用实时阴影!必须在 Blender 里把阴影烘焙(Bake)死在贴图上,前端直接当成普通纹理加载,零性能损耗。
纹理与采样原理
- UV 坐标:把 2D 纹理映射到 3D 表面的坐标系统(范围 0~1)。U 代表水平方向,V 代表垂直方向。
1. Mipmap(多级渐远纹理)
面试题:如果把一张 4K 的高清贴图贴在一个距离相机非常远、在屏幕上只有 10x10 像素的物体上,会发生什么?
- 问题(摩尔纹与闪烁):如果没有 Mipmap,GPU 在渲染那 10x10 个像素时,只能在 4K 贴图里极其“跳跃”地随机抽取 100 个像素点。当你稍微移动相机,抽取的像素就会剧烈变化,导致远处的纹理疯狂闪烁,出现密集的网格状波纹(摩尔纹 / Moiré pattern)。
- Mipmap 的解法:在加载纹理时,GPU 会提前生成一系列逐渐缩小的图(比如 2048、1024、512...直到 1x1)。当物体离相机远时,GPU 会自动选用那张小分辨率的贴图进行采样。
- 代价:Mipmap 会额外多占用约 33% 的显存,但换来了极大的远景抗锯齿提升和采样性能优化(采样小图更快)。Three.js 默认开启。
2. 纹理过滤(Texture Filtering)
当纹理被放大(贴近相机)或缩小(远离相机)时,GPU 应该如何决定屏幕像素的颜色?这由 magFilter(放大过滤)和 minFilter(缩小过滤)决定。
- NearestFilter(最近邻过滤):
- 原理:简单粗暴,直接选取离目标位置最近的那一个像素的颜色。
- 表现:当相机贴近物体时,能看到明显的马赛克/大方块。
- 应用场景:《我的世界 (Minecraft)》风格的体素游戏、2D 像素风游戏。在这种场景下,必须手动将材质的
magFilter设为THREE.NearestFilter,否则像素边缘会被模糊掉。
- LinearFilter(双线性插值过滤):
- 原理:提取目标位置周围的 4 个像素,按距离权重混合出一个平滑的颜色。
- 表现:当相机贴近时,马赛克消失,取而代之的是平滑的模糊感。
- 应用场景:绝大多数写实 3D 游戏和 Web3D 项目的标准配置(Three.js 默认值)。
3. GPU 纹理压缩(KTX2 / Basis)
面试题:我把贴图存成了 100KB 的 JPG 格式,为什么一加载到场景里,显存就爆了?
- 传统格式的陷阱:PNG 或 JPG 是为了网络传输和硬盘存储而设计的压缩算法(类似 ZIP)。GPU 硬件在渲染时是不认识 JPG 的。浏览器必须在 CPU 中把这 100KB 的 JPG 解码成最原始的 RGBA 像素矩阵(位图)后,再推送到 GPU 显存里。一张 2048x2048 的图,无论 JPG 有多小,解压到显存中必定占用
2048 * 2048 * 4 Bytes = 16MB显存! - 硬件级纹理压缩(Block Compression):KTX2 / Basis 格式采用的是 GPU 原生支持的块压缩算法(如 ASTC, ETC, DXT)。这种格式的贴图在下载后,不需要在 CPU 解码,直接以压缩状态塞进 GPU 显存。GPU 在渲染采样时可以通过硬件电路瞬间解压读取。
- 优势:显存占用和 GPU 带宽消耗通常能降低到原来的 1/4 到 1/8,彻底解决 WebGL 在移动端浏览器经常遇到的 OOM(内存溢出)崩溃问题。