Three.js局部泛光实现:让Bloom只作用于指定对象 简介这份资源聚焦 three.js 场景中的局部泛光与局部发光实现面向已有一定 WebGL 基础、希望在工业可视化或室内场景中做出酷炫光效的前端与图形开发者。它解决的核心问题是当场景内只有部分物体需要发光、其余保持常态时如何借助 bloom 后处理精准控制辉光范围而不是让整幅画面一起泛光。资源以两个小球为示例一个发光、一个不发光逻辑清晰便于快速理解局部辉光的判定与渲染流程并结合线框模型与 glb 模型加载演示实际用法。压缩包共 467 个文件以 458 个 js 脚本为主体另含 3 个 wasm、若干 jpg/png 贴图、1 个 html 入口、1 个 json 配置与 1 个 glb 模型整体约 4.01MB结构紧凑、依赖完整。目前已有 5047 人学习下载适合想掌握局部泛光思路、并迁移到工业与室内场景中的读者参考实践。1. 场景切入为什么你的 threejs 泛光总是“糊”成一片做过三维可视化大屏的同行大概率都遇到过这个需求场景里只有某几个设备、某条管线、某个图标需要发光其他部分必须保持干净。结果一搜教程清一色让你上UnrealBloomPass整帧后处理一挂好家伙整个画面像蒙了层雾暗部被抬亮、亮部溢出原本清晰的模型边缘全糊了。这不是你参数没调好而是全屏泛光的原理决定的——它作用于整个渲染结果没法区分“谁该亮”。threejs 的局部泛光也有人叫局部辉光、局部发光要解决的就是这件事让 bloom 只落在指定对象上其余像素原样输出。关键词里的 webgl bloom、threejs局部辉光说的都是同一类诉求。这篇笔记拆的是一个可复现的实现思路适合已经会搭 threejs 基础场景、被全屏泛光坑过、想搞清楚选择性泛光到底怎么落地的从业者。下面从原理到代码一步步来中间会给出可直接抄的着色器与合成逻辑。2. 选择性泛光的原理与图层方案选型2.1 全屏泛光为什么会“污染”整个画面UnrealBloomPass的工作流是拿到渲染器的整张颜色缓冲做多次高斯模糊再按强度叠加回原图。它压根不知道什么是“对象”眼里只有像素亮度。所以只要场景里有高亮区域哪怕你只想让一个指示灯亮旁边的白色文字、浅色地面也会被卷进去。这是原理层面的限制不是调参能绕过的。要局部化核心思路只有一个把需要发光的对象单独渲染到一张纹理上对这张纹理做 bloom最后再合成回主画面。这样模糊的输入里只有目标对象自然不会污染其他像素。围绕这个思路工程上有几种选型差别主要在“怎么把目标对象挑出来”。2.2 三种主流选型对比方案做法优点代价图层法Layers用camera.layers控制先渲染发光层到 RT再渲染全场景不改材质对象增删灵活需要两次场景遍历draw call 翻倍材质替换法遍历目标对象临时换成纯色/自发光材质渲染到 RT发光颜色可控不依赖原材质要备份还原材质逻辑略重遮罩法Mask全场景渲染时输出一张 mask标记哪些像素参与 bloom一次遍历性能好需要改所有相关材质侵入性强我一般优先用图层法因为它对现有代码侵入最小对象用layers.enable()一句话就能加入发光集合删掉也方便。材质替换法适合“发光颜色和本体颜色不一致”的场景比如本体是灰色金属但你要它发蓝光。遮罩法性能最好但要求你能改材质老项目里往往不现实。下面以图层法为主线展开材质替换法在最后一章作为进阶补充。2.3 图层法的渲染管线拆解整个管线分四步理解这四步后面看代码就不会晕渲染发光层把相机图层切到发光层只渲染目标对象输出到一张WebGLRenderTarget背景透明。对 RT 做 bloom用UnrealBloomPass或自己写的高斯模糊链处理这张 RT得到一张只有光晕的纹理。渲染主场景相机图层切回默认层正常渲染全场景到屏幕或另一张 RT。合成把光晕纹理以加法混合AdditiveBlending叠加到主画面上。关键点在于第 1 步和第 3 步的图层切换必须干净否则要么发光对象在主画面里“消失”要么光晕里混进了不该有的东西。常见做法是给发光对象单独分配一个图层编号比如 1主相机默认在图层 0渲染发光层时camera.layers.set(1)渲染主场景时camera.layers.set(0)并额外enable(1)让发光对象也出现在主画面里。3. 手把手实现从 RenderTarget 到合成着色器3.1 搭建基础场景与图层分配先把骨架搭起来。这里假设你已经有一个 threejs 场景重点是图层和 RT 的初始化。import * as THREE from three; 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 { ShaderPass } from three/examples/jsm/postprocessing/ShaderPass.js; // 发光层编号0 是默认层这里用 1 作为发光专用层 const BLOOM_LAYER 1; const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 2, 8); // 主相机默认看图层 0同时开启图层 1保证发光对象在主画面里也可见 camera.layers.enable(BLOOM_LAYER); // 发光层渲染目标尺寸和画布一致带 alpha 方便后续叠加 const bloomRT new THREE.WebGLRenderTarget( window.innerWidth, window.innerHeight, { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBAFormat } );这段代码做了三件事定义发光层编号、让主相机同时能看到默认层和发光层、创建一张和屏幕等大的 RT。setPixelRatio限制到 2 是为了避免高分屏上 RT 过大拖垮帧率这是血泪经验——4K 屏上不限制光晕还没出来帧率先掉到 20。3.2 把目标对象加入发光层对象加入发光层有两种写法效果一样看个人习惯// 写法一单个对象 const glowMesh new THREE.Mesh(geometry, material); glowMesh.layers.enable(BLOOM_LAYER); // 注意是 enable 不是 set scene.add(glowMesh); // 写法二批量处理比如遍历名字带 glow 前缀的对象 scene.traverse((obj) { if (obj.isMesh obj.name.startsWith(glow_)) { obj.layers.enable(BLOOM_LAYER); } });这里有个容易翻车的点layers.enable()是“追加”layers.set()是“替换”。如果你对发光对象用了set(BLOOM_LAYER)它就从默认层被踢出去了主画面里这个对象会直接消失只剩一团光晕飘着。我见过不止一个项目栽在这排查半天以为是合成问题其实是图层设错了。批量遍历时用enable最稳。3.3 渲染发光层到 RenderTarget接下来是每帧的渲染逻辑。注意渲染顺序和清屏设置function renderBloomLayer() { // 切到发光层只渲染发光对象 camera.layers.set(BLOOM_LAYER); // 背景设为透明避免 RT 里带上场景背景色 const oldBackground scene.background; scene.background null; renderer.setClearColor(0x000000, 0); renderer.setRenderTarget(bloomRT); renderer.clear(); renderer.render(scene, camera); renderer.setRenderTarget(null); // 还原背景供主场景使用 scene.background oldBackground; }逻辑说明camera.layers.set(BLOOM_LAYER)让相机只看发光层这一步决定了 RT 里只有目标对象。scene.background null和setClearColor(0x000000, 0)是为了让 RT 背景透明否则 RT 会带上一块不透明的底色合成时会把主画面盖住。参数上setClearColor的第二个参数 0 表示 alpha 为 0这个不能省。渲染完记得把 renderTarget 设回 null否则后续主场景会画到 RT 上而不是屏幕上。3.4 用 UnrealBloomPass 处理发光层拿到只有发光对象的 RT 后就可以对它做 bloom 了。这里用EffectComposer单独跑一条链// 专门处理发光层的 composer const bloomComposer new EffectComposer(renderer, bloomRT.clone()); bloomComposer.renderToScreen false; // 不直接输出到屏幕我们要拿它的结果去合成 const bloomRenderPass new RenderPass(scene, camera); bloomComposer.addPass(bloomRenderPass); const bloomPass new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), 1.2, // strength 强度 0.6, // radius 扩散半径 0.1 // threshold 阈值 ); bloomComposer.addPass(bloomPass);参数说明strength控制光晕亮度太大整个对象会变成一团白radius控制扩散范围0.6 左右比较自然threshold是亮度阈值低于它的像素不参与 bloom因为我们的 RT 里只有发光对象这个值可以设低一点0.1 甚至 0让暗部也带点光。renderToScreen false是关键它让 composer 把结果留在内部缓冲供我们后续取用。3.5 合成着色器把光晕叠回主画面最后一步是合成。写一个简单的 ShaderPass把主场景和光晕纹理做加法混合const mixShader { uniforms: { baseTexture: { value: null }, // 主场景 bloomTexture: { value: bloomComposer.renderTarget2.texture } // 光晕 }, vertexShader: varying vec2 vUv; void main() { vUv uv; gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0); } , fragmentShader: uniform sampler2D baseTexture; uniform sampler2D bloomTexture; varying vec2 vUv; void main() { vec4 base texture2D(baseTexture, vUv); vec4 bloom texture2D(bloomTexture, vUv); // 加法混合光晕叠加到主画面上 gl_FragColor base bloom; } }; const mixPass new ShaderPass(mixShader, baseTexture); mixPass.needsSwap true; // 主 composer const finalComposer new EffectComposer(renderer); finalComposer.addPass(new RenderPass(scene, camera)); finalComposer.addPass(mixPass);着色器逻辑很直白采样主画面和光晕两张纹理相加输出。needsSwap true让 ShaderPass 在渲染后交换读写缓冲保证下一帧输入正确。注意bloomComposer.renderTarget2.texture这个引用不同 threejs 版本里 renderTarget 的命名可能略有差异如果拿不到纹理检查一下 composer 的readBuffer和writeBuffer。3.6 主循环串起来把上面的步骤按顺序放进动画循环function animate() { requestAnimationFrame(animate); // 1. 渲染发光层到 RT renderBloomLayer(); // 2. 对发光层做 bloom bloomComposer.render(); // 3. 渲染主场景并合成光晕 finalComposer.render(); } animate();顺序不能乱先出光晕再合成。如果先渲染主场景finalComposer里的 RenderPass 会把主画面覆盖掉光晕就白做了。另外bloomComposer.render()内部会自己调用 RenderPass 重新渲染一遍场景此时相机还在发光层所以第 1 步和第 2 步其实有重叠实际项目里可以把第 1 步省掉直接让 bloomComposer 的 RenderPass 去渲染发光层性能更好。这里分开写是为了让流程清晰优化版在下一章讲。4. 避坑与排查局部泛光最常见的五个翻车点4.1 发光对象在主画面里消失现象光晕出来了但对象本体不见了只剩一团模糊的光。原因对发光对象用了layers.set(BLOOM_LAYER)而不是enable导致它被移出默认层主相机渲染主场景时看不到它。解决统一用layers.enable(BLOOM_LAYER)保留对象在默认层的身份。如果确实需要set记得再enable(0)把它加回默认层。4.2 光晕里混进了背景色或无关物体现象RT 里出现了场景背景或者不该发光的模型也带上了光晕。原因渲染发光层时没清背景或者相机图层没切干净其他层的对象也被渲染进去了。解决渲染发光层前设scene.background null和setClearColor(0x000000, 0)确认camera.layers.set(BLOOM_LAYER)只保留了发光层。如果场景里有多个相机检查每个相机的图层设置。4.3 光晕边缘有黑边或锯齿现象光晕和主画面交界处有一圈暗色轮廓或者边缘毛刺明显。原因RT 的尺寸和屏幕不一致或者纹理过滤方式设成了NearestFilter放大时出现块状。解决RT 尺寸和画布保持一致过滤用LinearFilter。如果还有黑边检查合成着色器里是不是用了mix而不是加法mix会把透明区域的黑色混进来。4.4 帧率骤降draw call 翻倍现象加了局部泛光后帧率从 60 掉到 30 以下。原因场景被渲染了两遍发光层一遍、主场景一遍加上 bloom 的多级模糊开销叠加。解决发光层只放真正需要发光的对象别把整个场景都加进去bloom 的radius和分辨率适当降低能用WebGLRenderTarget的半分辨率就别用全分辨率。我一般会把 bloom 的 RT 降到屏幕的 0.5 倍肉眼几乎看不出差别性能能省一半。4.5 后处理链顺序错误导致光晕被覆盖现象光晕时有时无或者被其他后处理效果吃掉。原因finalComposer里 RenderPass 和 mixPass 的顺序不对或者 mixPass 之后又加了别的 pass 把结果覆盖了。解决确保finalComposer里 RenderPass 在最前mixPass 在最后或至少在输出到屏幕的那个 pass 之前。如果还有色调映射、抗锯齿等 pass把它们放在 mixPass 之前让光晕最后叠加。5. 进阶技巧材质替换法与性能验证5.1 材质替换法让发光颜色和本体解耦图层法有个局限光晕颜色来自对象本体的颜色。如果你想让一个灰色金属发蓝光图层法做不到。这时候用材质替换法渲染发光层前把目标对象的材质临时换成纯色自发光材质渲染完再换回来。const glowMaterial new THREE.MeshBasicMaterial({ color: 0x00aaff }); const materialCache new Map(); function swapToGlowMaterial() { scene.traverse((obj) { if (obj.isMesh obj.layers.test(new THREE.Layers().set(BLOOM_LAYER))) { materialCache.set(obj, obj.material); obj.material glowMaterial; } }); } function restoreMaterial() { materialCache.forEach((mat, obj) { obj.material mat; }); materialCache.clear(); }逻辑说明遍历场景把发光层对象的材质备份到 Map换成统一的发光材质。渲染完发光层后调用restoreMaterial还原。参数上glowMaterial的颜色就是最终光晕的颜色可以按对象类型给不同颜色比如报警设备发红光、正常设备发绿光。注意materialCache用 Map 而不是数组方便按对象查找避免还原时错位。5.2 验证光晕是否只作用于目标对象写完代码怎么确认没污染我一般用两个土办法一是把主场景的 RenderPass 临时去掉只输出光晕纹理看画面里是不是只有目标对象的光晕其他全黑。二是给发光对象加一个明显的包围盒对比开关 bloom 时包围盒外的像素有没有变化。如果包围盒外的暗部被抬亮了说明合成时混入了不该有的东西。5.3 性能验证用 renderer.info 看真实开销别凭感觉猜性能直接读renderer.infoconsole.log(draw calls:, renderer.info.render.calls); console.log(triangles:, renderer.info.render.triangles); console.log(textures:, renderer.info.memory.textures);对比开/关局部泛光时的 draw call 数如果翻倍超过 1.8 倍说明发光层里混进了太多对象该精简了。textures数量也要留意每多一个 RT 就多占一份显存移动端上尤其要控制。5.4 一个容易忽略的细节resize 时的 RT 重建窗口大小变化时RT 和 composer 都要跟着 resize否则光晕会拉伸变形window.addEventListener(resize, () { const w window.innerWidth; const h window.innerHeight; camera.aspect w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); bloomRT.setSize(w, h); bloomComposer.setSize(w, h); finalComposer.setSize(w, h); });这段代码看着简单但漏掉bloomRT.setSize是高频翻车点——composer 的尺寸变了RT 没变光晕就会错位。从那以后我每次接局部泛光需求都会先把 resize 逻辑写全再调效果省得后面返工。希望帮到你。本文还有配套的精品资源点击获取