最近在做智慧园区的大屏可视化项目,模型和数据都已经就位,但整个场景一跑起来总觉得“平”——白天没有阳光的层次,晚上没有灯光的温度。捣鼓了几天光照与自发光相关的效果之后,我决定把这部分内容整理成系列教程的第4章:从场景配置入手,把光照、材质自发光、Bloom泛光特效,再到一个带感十足的线框扫描器,一次讲透。这篇文章适合正在做三维可视化、WebGIS大屏、数字孪生项目的开发者,也适合想搞懂Three.js光照和后处理但不想啃数学公式的同学。
文中所有代码和思路都来自我实际项目里跑通并调过细节的方案,你不用照抄,但照着配置基本能复现同样效果。这一章的核心关键词很明确:光照、自发光、Bloom、线框扫描器、场景配置。我会把每个环节的“为什么这么做”讲清楚,而不是只丢一段能跑的代码。
1. 场景配置:先把“底子”打对,再谈光影
1.1 渲染器与色彩空间,别让颜色“白调”了
很多刚入门的朋友把灯光和材质参数调来调去,屏幕上的画面却总是不对劲:要么整体发灰,要么灯光一亮就过曝。这种情况八成不是参数问题,而是渲染器和色彩空间的底子没打好。
我统一的场景初始化配置是这样:
const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2;关键点有两个。第一,outputColorSpace = SRGBColorSpace。这是个很容易被忽略的配置,颜色从线性空间转到显示空间,是照着sRGB标准做的。如果你不设置,很多颜色看起来会偏暗或者偏灰,尤其你后面要做Bloom这种基于亮度的后处理,色彩空间不对,亮度阈值全都失真。
第二,ACESFilmicToneMapping。这是个电影级色调映射曲线,可以把高光压得更柔和,阴影保留更多细节。亮度超过1的部分不会被一刀切掉,而是被“按曲线压缩”到可显示范围。Bloom要正常工作,就需要场景里有超过1的HDR亮度值,这个色调映射才hold得住。说得直白一点,toneMapping是给画面“收个尾”、让它不刺眼,Bloom是在toneMapping之前先把那些超亮的区域挑出来做泛光,两个不能搞混。
提示:如果你的Three.js版本比较新(r152+),用EffectComposer做后处理时,记得在最后加一个
OutputPass,它负责最终的色彩空间转换和toneMapping。不然你开了toneMapping,画面却依然发灰,排查半天发现是后处理把流程截断了。
1.2 基础光照搭配:不是灯越多越好
场景光照的配置我走过一段弯路:一开始为了画面亮,拼命往场景里塞光源,结果模型质感全没了,金属不金属、玻璃不玻璃,像一块被灯烤化的塑料。后来才总结出这套组合拳:定向光当主角,半球光当环境,环境光只用来补一个基础底。
// 主光源:定向光 const dirLight = new THREE.DirectionalLight(0xffffff, 2.5); dirLight.position.set(120, 180, 60); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 500; dirLight.shadow.camera.left = -80; dirLight.shadow.camera.right = 80; dirLight.shadow.camera.top = 80; dirLight.shadow.camera.bottom = -80; scene.add(dirLight); // 环境补光 const ambient = new THREE.AmbientLight(0x404060, 0.6); scene.add(ambient); // 半球光 const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x3a4a63, 0.8); scene.add(hemiLight);为什么用三种光?把定向光理解为太阳或者主路灯,它负责制造明确的明暗对比和阴影,让物体有立体感。半球光模拟的是天空和地面的环境反射:天空蓝一点、地面暗一点,整体气氛就出来了。环境光只给一个很低的亮度,防止背光面死黑,但加太多会让画面发灰,所以强度控制在0.5~0.8之间比较合适。
有个容易踩坑的地方:DirectionalLight的position并不代表“光源位置”,它只代表方向。光源在无穷远处,物体收到的光方向都是一致的。所以position.set(120, 180, 60)的真实含义是“光从(120, 180, 60)这个方向照过来”,跟光离物体多远无关。想做清晨和傍晚的光线变化,直接转这个方向向量就行。
2. 光的方向、强度与动态光照:把“灯”调明白
2.1 定向光的阴影细节,决定了画面“真不真”
上面代码里shadow.camera.left/right/top/bottom这四项很容易被忽略,但它们很关键。DirectionalLight的阴影本质是一个正交相机从光源方向拍一张深度图,正交相机的视景体如果太小,投影范围不够覆盖场景,就会出现阴影边缘突然消失了、或者阴影外头还有一大块黑斑这种诡异情况。
我一般习惯把正交相机范围设置成场景包围盒的1.2倍左右。比如当前园区模型长宽约120米,我就把left/right/top/bottom都设到±80。近裁面和远裁面也要算一下:near设0.5,far设500,足够覆盖整个园区,又不至于让深度精度浪费在不必要的地方。阴影贴图尺寸2048在PC和移动端都能接受,如果模型特别精细需要更锐利的阴影,可以拉到4096,但手机上要慎用。
阴影效果想要“柔”,关键在PCFSoftShadowMap。这个类型会用多次采样来软化阴影边缘,效果属于性价比最高的那一档。VSM之类的方案虽然看起来更平滑,但对场景动态变化的支持比较麻烦,大屏项目里我基本不用。
2.2 动态光照:让场景具备“时间感”
前阵子项目里要对接城市级三维场景,我顺手把底图的动态日照方向同步到了Three.js场景里,让小场景里的光影跟着真实时间变化。你如果在做Cesium和Three.js叠加的项目,思路一样的:Cesium提供太阳方向,你每帧把那个方向转成Three.js的方向光方向即可。
如果只是单独跑可视化大屏,也可以在内部维护一套24小时光照循环。我做店铺亮化效果时就是这么干的:
const dirColor = new THREE.Color(); const hemiColor = new THREE.Color(); function updateDayNight(timeOfDay) { // timeOfDay: 0 ~ 24 const t = timeOfDay / 24; // 白天暖白到夜晚深蓝的过渡 dirColor.setHSL(0.58, 0.6, 0.45 + t * 0.15); dirLight.color.copy(dirColor); dirLight.intensity = Math.sin(t * Math.PI) > 0.15 ? Math.sin(t * Math.PI) * 4 : 0.15; // 半球光亮度跟随 hemiLight.intensity = 0.3 + Math.sin(t * Math.PI) * 0.5; }这里用三角函数模拟日出日落,中午最亮,晚上压低但不会完全归零,不然场景“死黑一片”什么都没了。注意时间参数timeOfDay我是按0~24小时传进来,你也可以直接用时钟累计秒数去驱动。
一个我之前走过的坑:动态光每一帧都在调方向和强度,千万不要在循环里new THREE.Color()或者new THREE.Vector3(),这样会瞬间产生大量临时对象,触发垃圾回收导致帧率抖动。正确做法是像上面这样在外面先创建好临时变量,循环里只调用copy()方法。
动态光照的性能开销其实不大,它不开阴影时就是改几个uniform的事。真正的性能大头在“动态阴影”——如果你让定向光的方向每帧变化,阴影贴图就得每帧重绘,场景面数一大开销直接翻倍。我的建议很实在:大屏项目尽量只让强度变、方向慢慢绕,阴影区域不跟着剧烈跑,否则保持静态阴影。
3. 自发光与Bloom:让“亮”得有层次,而不是傻亮
3.1 材质自发光:emissive、emissiveIntensity的正确用法
自发光在Three.js材质体系里非常简单,但它经常被误用:有人把emissiveIntensity调到5.0、颜色调成纯白,以为越亮越有科技感,结果画面过曝得没法看。自发光控制的是“色”和“量”,不是单纯加亮度。
我常用的一套参数参考:
| 材质对象 | color | emissive | emissiveIntensity | 说明 |
|---|---|---|---|---|
| 钢结构立柱 | 0x888888 | 0xff5500 | 0.8 | 橙色微光,模拟金属反光 |
| 玻璃幕墙 | 0xaaaaaa | 0x1a6eff | 1.2 | 蓝色自发光,像内透照明 |
| LED点阵屏 | 0xcccccc | 0xff0066 | 2.0 | 高亮发光,配合Bloom效果最佳 |
| 普通路面 | 0x666666 | 0x000000 | 0 | 不发光,保持正常受光 |
实际代码是这样:
const ledMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.2, roughness: 0.4, emissive: new THREE.Color(0xff0066), emissiveIntensity: 2.0 });要注意两个容易被理解错的地方。第一,emissive不会受到阴影影响,它本质上是在PBR计算完成之后,直接把颜色叠加到材质上。发光区域不会把光“打”到旁边的物体上,不会产生真实的光照反弹。你如果想要发光物体照亮周围,还是得老老实实加PointLight或SpotLight。第二,emissiveIntensity只是数值,它和Bloom联动时,决定了发光区域能否被Bloom“提取”出来。
我之前就做过一次蠢事:把路灯的emissiveIntensity调到4.0,想着泛光效果更强,结果画面中心一片惨白,连路灯轮廓都看不见了。后来把数值压回2.0,Bloom强度没变,效果反而更有质感。
3.2 后处理Bloom:从EffectComposer到UnrealBloomPass
Bloom的本质是“把高亮区域挑出来、模糊它、再加回原画面上”,让亮的地方像在发光,而不是生硬的亮色色块。Three.js里最省事的方式就是用UnrealBloomPass,属于Unreal引擎那套思路的移植版,效果比较可控。
基本的后处理链路:
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js'; const composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); const bloomPass = new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), 0.6, // strength 0.4, // radius 0.85 // threshold ); composer.addPass(bloomPass); composer.addPass(new OutputPass()); // 渲染循环里不要用 renderer.render() composer.render();三个核心参数,我这里给你一套我的调参思路:
- threshold(阈值):决定哪些像素“有资格”发光。0.85的意思就是,HDR亮度值达到0.85以上的部分才会被提取出来做泛光。这个值调低了,整个画面都会泛光,像雾霾天;调高了,只有特别亮的自发光物体才会亮起来。我一般从0.85起步试验。
- strength(强度):决定泛光的扩散程度。0.6是个比较安全的起步值,想要强烈的科技感可以拉到0.9,但再高的空间不大,不然画面就“软”了,建筑的棱角全被光晕抹平。
- radius(半径):决定泛光的传播距离。0.4~0.6比较适合建筑级别的模型,太小了光晕在物体边缘就“断开”了,太大了光能漫到地面上去。
网上很多教程让你“多试几次”,但试之前最好先理解一个因果关系:材质自发光决定“谁发光”,Bloom决定“发光怎么扩散”。如果画面过曝,优先去压emissiveIntensity,而不是猛降Bloom的strength,因为压缩后处理参数其实是掩盖了你材质把关不严的问题。如果在压低emissiveIntensity之后Bloom效果变弱,那是正常的,再回调一点Bloom参数即可。
提示:如果你用了composer,渲染循环里必须调用
composer.render()。还有,OutputPass只在你需要toneMapping和色彩空间转换的时候加,它是流程的最后一步。很多老教程还在用renderer.toneMapping直接配composer,在r152以后效果是错的。
3.3 发光材质“没反应”的三个排查入口
我在群里看过不少人问:emissiveIntensity明明调到3了,Bloom却一点反应没有。排查逻辑一般分三步:
第一步,看材质是否真的用了emissive。如果你给模型挂的是MeshBasicMaterial,它压根没有emissive属性。第二步,看Bloom的threshold是不是设太高了。你调低threshold到0.6试试,如果画面立刻泛光,说明不是Bloom坏了,是你发光物本身的亮度不够。第三步,看composer有没有覆盖原本的renderer.render,如果后处理和普通渲染同时执行,画面的下半帧叠加会让亮度不可控。
4. 线框扫描器实战:从无到有做一条“扫光线”
4.1 方案对比:ShaderMaterial vs MeshBasicMaterial+贴图
线框扫描器这个效果,我在以前的项目里实现过两次。第一次图省事:用MeshBasicMaterial加一张渐变贴图,把贴图的uv坐标沿着模型移动,做出“扫光”效果。好处是代码短,缺点也非常明显:贴图扫描是“平面式”的,它只能沿着UV展开的方向走,模型稍微复杂一点扫描线就会扭曲变形,而且很不均匀。
后来改用ShaderMaterial,扫描线直接在模型世界坐标里计算,相当于每个顶点都知道自己在哪里、扫描线到了哪里,完全不受UV展开影响。性能上也更好,一个几万面的模型跑起来毫无压力。最终我选定了ShaderMaterial方案,因为它更接近“真的扫描”而不是“贴图贴上去的假扫”。
4.2 核心着色器代码与实战调参
先看顶点着色器,它的任务很简单:把模型顶点的位置和法线传给片元着色器,并计算世界空间坐标:
// vertex varying vec3 vLocalPos; varying vec3 vWorldPos; varying vec3 vNormal; void main() { vLocalPos = position; vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }片元着色器是核心,它做三件事:第一,根据模型局部坐标的y计算一个“扫描位置”;第二,根据法线和视线方向计算菲涅尔边缘高光,用来做“线框”质感;第三,把两者合成输出:
// fragment uniform float uTime; uniform float uScanSpeed; uniform vec3 uScanColor; uniform float uLineWidth; uniform float uFresnelPower; varying vec3 vLocalPos; varying vec3 vWorldPos; varying vec3 vNormal; void main() { // 用局部Y坐标做扫描位置 float scanCoord = fract(vLocalPos.y * uScanSpeed + uTime * 0.3); // 构造一条带状的扫描线:中间亮、两边渐隐 float lineMask = 1.0 - smoothstep(0.0, uLineWidth, min(scanCoord, 1.0 - scanCoord)); // 菲涅尔边缘光:视线与法线夹角越大越亮,模拟轮廓线 vec3 viewDir = normalize(cameraPosition - vWorldPos); float fresnel = pow(1.0 - max(dot(normalize(vNormal), viewDir), 0.0), uFresnelPower); vec3 finalColor = uScanColor * (lineMask * 1.5 + fresnel * 1.0); float alpha = clamp(lineMask + fresnel * 0.5, 0.0, 1.0); gl_FragColor = vec4(finalColor, alpha); }ShaderMaterial使用时的几个关键配置:
const scanMat = new THREE.ShaderMaterial({ vertexShader: vertShader, fragmentShader: fragShader, uniforms: { uTime: { value: 0 }, uScanSpeed: { value: 1.5 }, uLineWidth: { value: 0.08 }, uScanColor: { value: new THREE.Color(0x00e5ff) }, uFresnelPower: { value: 2.5 } }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide });解释几个参数:
uScanSpeed:扫描线的移动速度,我习惯用1.2~2.0。太快像抽搐,太慢像静止。uLineWidth:线带宽度,0.05~0.12之间比较合适。太宽了线带和一个光柱没区别,没有“扫”的感觉。uFresnelPower:控制边缘发光的范围。2.0~3.0给的是较窄的高亮轮廓;如果设成1.0,几乎整个物体轮廓都在发光,适合科技感的“能量罩”效果。AdditiveBlending:叠加混合模式,让扫描线在暗色背景上更亮、更有穿透感。配合后面上Bloom,效果会很足。depthWrite: false:扫描线是一种半透明效果,关闭深度写入能避免半透明物体之间的排序问题,画面不会出现扫描线消失或遮挡错乱。
片元着色器里用的cameraPosition和normalMatrix是Three.js自动注入的内置uniform和变量,不用手动声明也能用,这是ShaderMaterial比较方便的地方。如果你在代码里看到这段没声明却直接用了,不是笔误,是Three.js已经帮你把东西塞进去了。
// 渲染循环里更新uTime scanMat.uniforms.uTime.value += delta;这里有个容易搞错的方向问题:我用vLocalPos的y做扫描坐标,意味着扫描线是沿着模型局部坐标系的Y轴移动。如果你的模型从建模软件里导入后本身旋转过90度,扫出来的线就会是斜着扫或者横着扫。最简单的修复方案有两个:一是把模型的rotation调整好再挂材质,二是在顶点着色器里把vLocalPos统一变换到世界空间再计算:
vec3 worldLocal = (modelMatrix * vec4(position, 1.0)).xyz; float scanCoord = fract(worldLocal.y * uScanSpeed + uTime * 0.3);用哪个方案取决于你的模型场景——如果你的模型没有额外旋转,直接用局部坐标就好,性能更轻;如果模型经过复杂变换,优先用世界坐标,省去每帧去同步旋转矩阵的麻烦。我自己一般直接改用世界坐标。
4.3 与自发光、Bloom联动:做一个“亮化扫描”组合特效
单看扫描线只是动的线,一旦把扫描区域“点亮”,和Bloom一配合,效果就立刻上了一个台阶——像是扫描到哪、哪里就被“激活”了一样。
思路很简单:扫描线经过的区域,给它额外叠加一层自发光颜色。扫描线ShaderMaterial的输出本身就带亮度,叠加混合模式会把亮度直接加到画面的颜色上,这已经相当于“自发光”了;Bloom会把这个亮度当作高光提取出来,自然就发光了。如果你嫌能量感不够强,也可以在扫描线经过时把模型本身的MeshStandardMaterial的emissiveIntensity临时提一下,比如:
// 每帧检测扫描线的位置,计算该模型是否处于扫描区间 const scanWorldY = (uTime * 0.3) % 1; if (scanWorldY > 0.8) { targetMat.emissiveIntensity += 0.8; } else { targetMat.emissiveIntensity = baseIntensity; }这个做法要谨慎,每帧去改材质的uniform会造成一些CPU开销,但对单个模型来说完全可以接受。更优雅的做法是在shader里加一个uniform,把扫描线位置传入材质着色器里,直接在材质内完成发光叠加,效果更平滑。
坦白讲,线框扫描器最需要调的不是代码,是“速度感”和“距离感”。速度感靠uTime的速率系数;距离感靠透明度和颜色明度——远处的扫描线要淡一点,近了再实一点。如果整个场景的相机高度很夸张,比如从高空俯拍整个园区,扫描线就得做大半径、低透明度,不然一条特别细的亮线在高空俯视图里几乎不可见。
5. 踩坑记录与排查清单
5.1 定向光被“关闭”的真实复盘
有一次我调项目的光照,场景里定向光明明加了、强度和方向都对,但画面就是没有阳光感。排查了半小时,最后发现之前做“白天/黑夜切换”的功能时,初始化里执行了一个light.setAll(false)的批处理,把场景里所有光源的enabled属性一次性设为了false。之后我没仔细看,一直在调intensity,intensity调再高,enabled为false的光源根本不会参与计算,自然一点效果都没有。
这个问题在项目的“origin 定向光照已关闭”场景里特别容易复现:初始化光照状态时,默认把定向光关闭了,而你后来要动态开启却发现怎么都点不亮。遇到这种情况先检查光源的light.enabled,而不是急着调强度。修复也很简单,初始化时显式把开启的光源单独拉出来:
dirLight.enabled = true; hemiLight.enabled = true;更稳妥的做法是,做动态模式切换时永远不去改enabled,只用intensity把它压低到0.1这种几乎不可见的程度,保留光源在场景里的“可见性状态”,这样切换模式不会出现光源“假死”的诡异bug。
5.2 常见问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 定向光不亮,怎么调都没反应 | light.enabled为false或intensity为0 | 检查enabled属性,然后用intensity控制亮度而非关灯 |
| 画面整体发灰 | 环境光强度过高或toneMapping未设置 | 压制AmbientLight到0.5以下,设置ACESFilmicToneMapping |
| Bloom全屏过曝 | threshold过低、strength过高 | 先压emissiveIntensity到2.0以下,再降strength |
| Bloom完全没有效果 | threshold高于材质实际亮度 | 材质emissiveIntensity提到2.0以上,threshold调到0.6~0.85试 |
| 扫描线方向歪斜 | 模型旋转过、用了局部坐标 | 改用世界坐标计算scanCoord,或调整模型rotation |
| 扫描线闪烁跳动 | depthWrite开启、半透明排序问题 | depthWrite: false,使用AdditiveBlending |
| 自发光物体被阴影压暗 | 对MeshStandardMaterial用阴影却不看emissive | emissive不受阴影影响,检查重叠的MeshLambert/Phong材质或半透明叠加 |
5.3 我的调试顺序心得
这一章涉及的东西比较杂,我每次给新场景配光照和Bloom,都会按一个固定的顺序来:先不挂任何后处理,把场景的正常渲染调好看;再给关键模型加emissive自发光;最后才上Bloom。后处理是最后一步,不是第一步。否则你会陷入一个怪圈:画面太黑就调Bloom,Bloom一变太亮又压曝光,压完曝光又觉得暗,完全失去判断依据。
还有一个个人习惯:调完参数之后,一定把相机绕着场景转一圈看画面整体,而不是只盯着某个发光模型。Bloom的强度会被相邻物体的亮暗影响,同一个strength值,近看一个发光的LED屏和远看半条街的LED屏,效果完全不一样。我在项目里就把strength从0.9一路调到0.5,最后定在0.6,才既保留了灯光氛围又没让整条街道糊成一团。
另外一个容易被忽略的技巧:调Bloom前,先把场景里所有光的强度整体压低一档再调。因为Bloom提取的是“相对亮度差”,场景特别亮的时候,地面的亮度已经接近threshold了,发光物体一动,整个画面的泛光范围会跟着晃。先把底光压低,发光体单独提上去,发光与不发光之间的亮度差拉大,Bloom出来的效果才干净。
最后分享一个关于扫描器的小技巧:这个扫描效果用来做“检测”“勘探”“扫描动画”的展示非常合适,但别把所有模型都用同一个扫描shader。建筑主体用扫描线加菲涅尔轮廓就够,LED屏那些本身在发光的物体就别再叠扫描线了,两道亮光叠在一起,最后画面会白得什么都看不见。效果是“点”出来的,不是“堆”出来的,这是我在这一章里最想强调的一点。