☰
Three.js局部泛光实战:Layers与UnrealBloomPass实现选择性发光
2026/10/14 4:12:29 网站建设 项目流程

简介:这份资源聚焦 three.js 场景中的局部泛光与局部发光实现,面向已有一定 WebGL 基础、希望在工业可视化或室内场景中做出酷炫光效的前端与图形开发者。它解决的是场景内仅部分物体发光、其余保持常态的渲染需求,通过 bloom 后处理配合线框模型,可清晰呈现两个小球一明一暗的对比逻辑,并附带模型加载示例。压缩包共 467 个文件,约 4.01MB,以 458 个 js 脚本为核心,辅以 three.js 相关模块、wasm 物理与 IFC 解析文件、glb 模型、jpg/png 贴图及 html 演示页,结构完整便于直接运行调试。目前已有 5047 人学习下载。读者可从中掌握局部辉光的实现思路、后处理通道配置与模型加载结合方式,并借鉴其目录组织快速迁移到自身项目,适合需要落地发光特效的实战参考。

1. 局部泛光不是全局 Bloom:一次把 threejs 局部发光讲透

做三维可视化大屏时,最容易被产品经理一句话打回重做的需求就是「让这个设备亮起来,但别把整个场景搞糊」。全局 Bloom 一开,画面里所有高亮像素全在发光,金属边框、白色文字、天空盒统统糊成一团,性能还掉得厉害。threejs 的局部泛光(Selective Bloom)就是解决这个问题的:只让指定物体发光,其余部分保持干净锐利。它适合做数据大屏里的告警设备、数字孪生里的高亮管线、产品展示里的发光 Logo,也适合任何需要「视觉焦点」但不想牺牲整体清晰度的场景。核心思路不复杂——用两层渲染把发光物体单独抠出来做模糊叠加,但真正落地时,图层管理、渲染顺序、色调映射这三处最容易翻车。下面按「先跑通最小闭环,再调参数,最后避坑」的顺序拆开讲。

2. 用 EffectComposer 搭出局部泛光的最小闭环

2.1 为什么必须走后期管线而不是给材质加 emissive

很多人第一反应是给材质加emissive颜色,觉得够亮就会「发光」。但emissive只是让物体自身颜色变亮,它不会向周围像素扩散光晕,本质还是「亮色块」而不是「光」。真正的泛光需要把高亮区域提取出来、做高斯模糊、再叠加回原图,这是屏幕空间的后处理操作,必须走EffectComposer这条后期管线。

threejs 官方 examples 里提供了UnrealBloomPass,它做的是全局泛光。局部泛光的做法是:准备两个渲染层,一层放需要发光的物体,一层放其余物体;先用UnrealBloomPass对发光层做泛光,再和基础层合成。关键 API 是Layers,它允许相机和物体分别设置所属层,渲染时按层过滤。

选型上,UnrealBloomPass比手写 Shader 更稳,因为它的阈值、强度、半径三个参数已经覆盖了绝大多数调参需求,而且和EffectComposer的RenderPass配合成熟。自己写泛光 Shader 当然更灵活,但调试成本高,除非你要做非高斯核的特殊光效,否则没必要。

2.2 最小可运行代码:两层渲染 + 图层过滤

下面这段代码是局部泛光的最小闭环,跑通它你就能看到「只有红色立方体发光,地面和背景保持清晰」的效果。

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'; import { CopyShader } from 'three/examples/jsm/shaders/CopyShader.js'; // 场景与相机 const scene = new THREE.Scene(); scene.background = new THREE.Color(0x111111); const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 1000); camera.position.set(4, 3, 6); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); renderer.toneMapping = THREE.ACESFilmicToneMapping; // 色调映射影响泛光观感 document.body.appendChild(renderer.domElement); // 图层约定:0 是基础层,1 是发光层 const BLOOM_LAYER = 1; // 地面(基础层,不发光) const ground = new THREE.Mesh( new THREE.PlaneGeometry(20, 20), new THREE.MeshStandardMaterial({ color: 0x333333 }) ); ground.rotation.x = -Math.PI / 2; ground.layers.set(0); scene.add(ground); // 发光物体:红色立方体 const glowCube = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0xff2222, emissive: 0xff0000, emissiveIntensity: 2 }) ); glowCube.position.y = 1; glowCube.layers.set(BLOOM_LAYER); // 关键:把它放到发光层 scene.add(glowCube); // 普通物体:蓝色球体,不发光 const normalSphere = new THREE.Mesh( new THREE.SphereGeometry(0.8, 32, 32), new THREE.MeshStandardMaterial({ color: 0x2266ff }) ); normalSphere.position.set(2, 0.8, 0); normalSphere.layers.set(0); scene.add(normalSphere); // 灯光 scene.add(new THREE.AmbientLight(0xffffff, 0.4)); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(5, 8, 5); scene.add(dirLight); // 后期合成器 const composer = new EffectComposer(renderer); // 第一遍:只渲染发光层,用于提取泛光源 const bloomLayer = new THREE.Layers(); bloomLayer.set(BLOOM_LAYER); const renderPassBloom = new RenderPass(scene, camera); renderPassBloom.camera.layers.set(BLOOM_LAYER); // 相机只看发光层 const bloomPass = new UnrealBloomPass( new THREE.Vector2(innerWidth, innerHeight), 1.5, // strength 强度 0.6, // radius 半径 0.1 // threshold 阈值 ); // 第二遍:渲染完整场景(所有层) const renderPassBase = new RenderPass(scene, camera); renderPassBase.camera.layers.enableAll(); // 合成:先基础层,再叠加泛光层 composer.addPass(renderPassBase); composer.addPass(renderPassBloom); composer.addPass(bloomPass); // 最终输出 const copyPass = new ShaderPass(CopyShader); composer.addPass(copyPass); function animate() { requestAnimationFrame(animate); glowCube.rotation.y += 0.01; composer.render(); } animate();

这段代码的逻辑分三层理解。第一层是图层划分:glowCube.layers.set(1)把它单独放到第 1 层,地面和球体留在第 0 层。第二层是双 RenderPass:renderPassBloom的相机只启用第 1 层,所以它渲染出来的画面里只有发光物体,其余是黑的;renderPassBase的相机启用所有层,渲染完整场景。第三层是叠加顺序:composer先执行基础渲染,再执行泛光渲染,UnrealBloomPass对泛光渲染的结果做模糊,最后CopyShader把结果输出到屏幕。

参数说明:strength控制光晕亮度,1.5 是中等偏强;radius控制光晕扩散范围,0.6 适合中小物体;threshold是亮度阈值,0.1 表示只要比背景亮一点就参与泛光,调高到 0.8 以上只有极亮区域才发光。这三个参数没有标准答案,取决于你的场景色调和显示器,建议先固定 strength,再调 radius,最后用 threshold 收边。

2.3 渲染顺序与 clearColor 的两个隐藏开关

上面代码能跑,但有两个细节不处理会出现「泛光物体边缘发黑」或「背景被覆盖」的问题。

第一个是renderPassBloom的clearColor。默认RenderPass会用渲染器的 clearColor 清屏,如果渲染器背景是深色,泛光层渲染出来的黑色背景会被UnrealBloomPass当成「暗部」忽略,这没问题;但如果你的场景背景是浅色,泛光层渲染出的浅色背景会被误判为高亮,导致整个画面泛光。解决办法是给renderPassBloom单独设置clearColor为纯黑:

renderPassBloom.clearColor = new THREE.Color(0x000000); renderPassBloom.clearAlpha = 0;

第二个是composer.renderTarget的格式。默认EffectComposer使用HalfFloatType,这对 HDR 泛光更友好,但部分移动端 GPU 不支持。如果发现泛光在手机上失效或颜色异常,可以显式指定:

const renderTarget = new THREE.WebGLRenderTarget(innerWidth, innerHeight, { type: THREE.HalfFloatType, format: THREE.RGBAFormat }); const composer = new EffectComposer(renderer, renderTarget);

这两个开关在官方示例里不会强调,但实际项目里一旦背景不是纯黑,十有八九会踩到。

3. 局部发光的三种实现路线与选型对比

3.1 路线一:Layers + UnrealBloomPass(推荐)

这是上一章跑通的方案,优点是代码量少、和 threejs 官方后期管线无缝集成、参数直观。缺点是UnrealBloomPass是全屏模糊,即使只有一个小物体发光,它也会对整个屏幕做多次高斯模糊,性能开销和发光物体数量无关,只和分辨率有关。在 1080p 下,一个UnrealBloomPass大约占 2~3ms,4K 下会到 8~10ms。如果你的场景里发光物体很少但分辨率很高,这个方案性价比最高。

适用场景:数据大屏、数字孪生、产品展示,发光物体数量在 1~20 个之间,分辨率不超过 2K。

3.2 路线二:自定义 Shader 做局部阈值提取

如果你需要更精细的控制,比如「只让物体边缘发光」或「发光颜色随距离渐变」,可以在UnrealBloomPass之前插入一个自定义ShaderPass,用物体的世界坐标或 UV 做遮罩。核心思路是:在泛光层渲染时,把物体的 ID 或遮罩信息写入 alpha 通道,然后在 Shader 里根据 alpha 决定是否参与泛光。

// 自定义阈值 Shader 片段 uniform sampler2D tDiffuse; uniform vec3 glowColor; varying vec2 vUv; void main() { vec4 texel = texture2D(tDiffuse, vUv); // 只保留 alpha > 0.5 的区域参与泛光 float mask = step(0.5, texel.a); vec3 bloomSource = texel.rgb * mask; gl_FragColor = vec4(bloomSource, 1.0); }

这个方案的代价是你需要自己管理遮罩的写入和读取,而且UnrealBloomPass仍然会对全屏做模糊,只是模糊的输入被裁剪了。性能上没有本质提升,但视觉控制更细。适合做「发光边缘描边」或「呼吸灯渐变」这类效果。

3.3 路线三:多 Pass 分层渲染 + 手动合成

当发光物体数量超过 50 个,或者你需要每个物体独立控制泛光强度时,UnrealBloomPass的单阈值就不够用了。这时候可以把发光物体按强度分组,每组渲染一次泛光,最后手动叠加。代价是渲染次数翻倍,性能开销线性增长。

// 伪代码:按强度分组渲染 const groups = { low: [], mid: [], high: [] }; glowObjects.forEach(obj => { if (obj.userData.glowLevel === 'high') groups.high.push(obj); else if (obj.userData.glowLevel === 'mid') groups.mid.push(obj); else groups.low.push(obj); }); // 每组单独设置 bloomPass 参数并渲染 function renderGroup(group, strength) { bloomPass.strength = strength; group.forEach(obj => obj.layers.set(BLOOM_LAYER)); composer.render(); group.forEach(obj => obj.layers.set(0)); }

这个方案适合「告警等级用不同亮度区分」的场景,比如红色告警最强、黄色次之、蓝色最弱。但要注意,每次composer.render()都会清屏,所以你需要把结果渲染到不同的WebGLRenderTarget,最后用ShaderPass叠加。代码复杂度高,除非需求明确,否则不建议一上来就用。

3.4 三条路线的参数与性能对照

路线发光物体数量1080p 开销控制粒度代码复杂度
Layers + UnrealBloomPass1~202~3ms中低
自定义 Shader 阈值1~503~5ms高中
多 Pass 分层合成50+6~15ms极高高

选型建议:先用路线一跑通,确认视觉方向;如果产品要求「每个物体亮度不同」,再上路线三;路线二适合做特殊光效,不是通用方案。

4. 局部泛光避坑:五个让画面翻车的参数与写法

4.1 坑一:发光物体边缘出现黑边

现象:发光立方体周围有一圈黑色轮廓,像是被描了黑边。

原因:renderPassBloom渲染时,发光物体之外的区域是纯黑,UnrealBloomPass对黑色区域做模糊后,黑色会向发光区域扩散,叠加到基础层上就形成了黑边。本质是泛光层的背景色和基础层不匹配。

解决:给renderPassBloom设置clearColor为透明黑,并在合成时用加法混合而不是普通叠加。或者更简单——把bloomPass.threshold调高到 0.2 以上,让黑色区域不参与泛光计算。

renderPassBloom.clearColor = new THREE.Color(0x000000); renderPassBloom.clearAlpha = 0; bloomPass.threshold = 0.2; // 过滤掉暗部噪声

4.2 坑二:色调映射导致泛光颜色发灰

现象:设置了toneMapping = ACESFilmicToneMapping后,泛光颜色变得灰暗,红色发光变成暗红。

原因:色调映射是在最终输出阶段做的,但UnrealBloomPass在色调映射之前执行。如果泛光强度不够,经过 ACES 压缩后高光会被压暗。这是 HDR 管线的正常行为,不是 bug。

解决:把bloomPass.strength提高 30%~50%,或者在UnrealBloomPass之后、色调映射之前插入一个GammaCorrectionShader。更稳妥的做法是让发光材质的emissiveIntensity大于 1,给泛光留足亮度余量。

4.3 坑三:相机 layers 设置后看不到普通物体

现象:调用了camera.layers.set(BLOOM_LAYER)之后,场景里只剩发光物体,地面和球体全消失了。

原因:camera.layers.set(n)是「只渲染第 n 层」,不是「额外启用第 n 层」。如果你在基础渲染的相机上也调了set,它就只能看到那一层。

解决:基础渲染的相机用camera.layers.enableAll(),泛光渲染的相机才用camera.layers.set(BLOOM_LAYER)。两个 RenderPass 必须用不同的相机实例,或者至少在不同阶段切换 layers 状态。

4.4 坑四:移动端泛光失效或闪屏

现象:桌面端正常,手机上泛光区域变成纯白或直接黑屏。

原因:移动端 GPU 对HalfFloatType渲染目标支持不一致,部分设备回退到UnsignedByteType,导致 HDR 数据被截断。另外,移动端屏幕像素比高,UnrealBloomPass的分辨率参数如果没按devicePixelRatio缩放,会触发显存溢出。

解决:检测设备能力,移动端降级为UnsignedByteType并降低泛光分辨率。

const isMobile = /Mobi|Android/i.test(navigator.userAgent); const bloomResolution = new THREE.Vector2( innerWidth * (isMobile ? 0.5 : 1), innerHeight * (isMobile ? 0.5 : 1) ); const bloomPass = new UnrealBloomPass(bloomResolution, 1.2, 0.5, 0.2);

4.5 坑五:发光物体被其他物体遮挡时仍然发光

现象:发光立方体被一堵墙挡住,但墙后面仍然透出光晕。

原因:泛光层渲染时,相机只看到发光层,墙不在这一层,所以泛光层里立方体是「裸露」的,没有被遮挡。叠加到基础层后,光晕就穿透了墙。

解决:这是局部泛光的固有局限。如果必须处理遮挡,需要把遮挡物也加入泛光层,但这样遮挡物也会参与泛光。折中方案是用深度测试:在泛光层渲染时启用深度缓冲,让遮挡物写入深度但不写入颜色。实现上可以给遮挡物设置colorWrite = false并加入泛光层,这样它只挡光不发光。

// 遮挡物:写入深度,不写颜色 wall.material.colorWrite = false; wall.layers.enable(BLOOM_LAYER); // 同时存在于两层

5. 把局部泛光做成可复用的发光管理器

5.1 封装思路:注册制 + 动态开关

实际项目里,发光物体不是固定的,告警来了要亮、告警走了要灭。如果每次都手动改layers和重建composer,代码会散落各处。我一般会封装一个GlowManager,用注册制管理发光物体,对外只暴露register、unregister、setGlow三个方法。

class GlowManager { constructor(composer, bloomPass, bloomLayerIndex = 1) { this.composer = composer; this.bloomPass = bloomPass; this.bloomLayerIndex = bloomLayerIndex; this.glowObjects = new Set(); } register(object3D, intensity = 1) { object3D.userData.glowIntensity = intensity; object3D.layers.enable(this.bloomLayerIndex); this.glowObjects.add(object3D); } unregister(object3D) { object3D.layers.disable(this.bloomLayerIndex); this.glowObjects.delete(object3D); } setGlow(object3D, enabled) { if (enabled) { object3D.layers.enable(this.bloomLayerIndex); } else { object3D.layers.disable(this.bloomLayerIndex); } } // 根据当前发光物体数量动态调整泛光强度 update() { const count = this.glowObjects.size; this.bloomPass.strength = count > 0 ? 1.2 + Math.min(count * 0.05, 0.8) : 0; } }

这个管理器的关键点是update方法:发光物体越多,泛光强度略微上调,避免多个物体同时发光时整体偏暗。Math.min(count * 0.05, 0.8)是经验公式,上限 0.8 防止过曝。你可以根据场景色调调整系数。

5.2 验证方法:用三个测试场景确认边界

封装完之后,别急着接业务,先用三个场景验证:

第一个是单物体开关测试:注册一个立方体,反复setGlow(true/false),观察泛光是否干净地出现和消失,有没有残影。残影通常是因为composer的渲染目标没有清空,检查renderPassBloom.clear是否为 true。

第二个是多物体叠加测试:同时注册 10 个发光物体,观察泛光强度是否过曝。如果画面发白,把update里的系数调低,或者给每个物体设置不同的emissiveIntensity。

第三个是遮挡测试:把一个不发光物体放在发光物体前面,确认光晕不会穿透。如果穿透,按 4.5 的方法给遮挡物加colorWrite = false并启用泛光层。

5.3 一个具体技巧:用 emissiveIntensity 做呼吸灯

局部泛光最实用的进阶技巧是配合emissiveIntensity做呼吸灯效果。不要直接改bloomPass.strength,因为那会影响所有发光物体;改单个物体的emissiveIntensity更精准。

// 在 animate 循环里 const time = performance.now() * 0.001; glowCube.material.emissiveIntensity = 1.5 + Math.sin(time * 2) * 0.8;

Math.sin(time * 2)控制呼吸频率,* 0.8控制振幅,1.5是基准亮度。这样立方体会在 0.7 到 2.3 之间平滑变化,泛光强度随之波动,但其他发光物体不受影响。注意emissiveIntensity低于 1 时泛光可能不明显,建议基准值设在 1.2 以上。

我自己的习惯是:先把bloomPass的strength固定在一个中间值,然后所有动态效果都通过emissiveIntensity做,这样后期调参时只需要动一个地方。另外,emissiveIntensity的变化不要超过 3,否则在 ACES 色调映射下容易过曝成白色,失去颜色信息。如果发现颜色被冲淡,把toneMappingExposure从默认的 1 降到 0.8 左右,给高光留出余量。

这套方案我在几个大屏项目里反复用过,最深的教训是:局部泛光的难点不在 Shader,而在图层管理和渲染顺序。先把Layers和RenderPass的关系理清楚,再调参数,能省掉大量「为什么发光不对」的排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询