Three.js智慧园区大屏:光照、自发光与Bloom泛光实战
发布时间:2026/9/28 17:19:52来源:尧图网络
最近在做智慧园区的大屏可视化项目模型和数据都已经就位但整个场景一跑起来总觉得“平”——白天没有阳光的层次晚上没有灯光的温度。捣鼓了几天光照与自发光相关的效果之后我决定把这部分内容整理成系列教程的第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.5far设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、颜色调成纯白以为越亮越有科技感结果画面过曝得没法看。自发光控制的是“色”和“量”不是单纯加亮度。我常用的一套参数参考材质对象coloremissiveemissiveIntensity说明钢结构立柱0x8888880xff55000.8橙色微光模拟金属反光玻璃幕墙0xaaaaaa0x1a6eff1.2蓝色自发光像内透照明LED点阵屏0xcccccc0xff00662.0高亮发光配合Bloom效果最佳普通路面0x6666660x0000000不发光保持正常受光实际代码是这样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.0Bloom强度没变效果反而更有质感。3.2 后处理Bloom从EffectComposer到UnrealBloomPassBloom的本质是“把高亮区域挑出来、模糊它、再加回原画面上”让亮的地方像在发光而不是生硬的亮色色块。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。之后我没仔细看一直在调intensityintensity调再高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以下设置ACESFilmicToneMappingBloom全屏过曝threshold过低、strength过高先压emissiveIntensity到2.0以下再降strengthBloom完全没有效果threshold高于材质实际亮度材质emissiveIntensity提到2.0以上threshold调到0.6~0.85试扫描线方向歪斜模型旋转过、用了局部坐标改用世界坐标计算scanCoord或调整模型rotation扫描线闪烁跳动depthWrite开启、半透明排序问题depthWrite: false使用AdditiveBlending自发光物体被阴影压暗对MeshStandardMaterial用阴影却不看emissiveemissive不受阴影影响检查重叠的MeshLambert/Phong材质或半透明叠加5.3 我的调试顺序心得这一章涉及的东西比较杂我每次给新场景配光照和Bloom都会按一个固定的顺序来先不挂任何后处理把场景的正常渲染调好看再给关键模型加emissive自发光最后才上Bloom。后处理是最后一步不是第一步。否则你会陷入一个怪圈画面太黑就调BloomBloom一变太亮又压曝光压完曝光又觉得暗完全失去判断依据。还有一个个人习惯调完参数之后一定把相机绕着场景转一圈看画面整体而不是只盯着某个发光模型。Bloom的强度会被相邻物体的亮暗影响同一个strength值近看一个发光的LED屏和远看半条街的LED屏效果完全不一样。我在项目里就把strength从0.9一路调到0.5最后定在0.6才既保留了灯光氛围又没让整条街道糊成一团。另外一个容易被忽略的技巧调Bloom前先把场景里所有光的强度整体压低一档再调。因为Bloom提取的是“相对亮度差”场景特别亮的时候地面的亮度已经接近threshold了发光物体一动整个画面的泛光范围会跟着晃。先把底光压低发光体单独提上去发光与不发光之间的亮度差拉大Bloom出来的效果才干净。最后分享一个关于扫描器的小技巧这个扫描效果用来做“检测”“勘探”“扫描动画”的展示非常合适但别把所有模型都用同一个扫描shader。建筑主体用扫描线加菲涅尔轮廓就够LED屏那些本身在发光的物体就别再叠扫描线了两道亮光叠在一起最后画面会白得什么都看不见。效果是“点”出来的不是“堆”出来的这是我在这一章里最想强调的一点。
网站建设高端定制企业官网