首页 / 资讯中心 / 文章详情

Three.js 局部辉光方案:SelectiveBloomPass 实现精准区域发光

Three.js 局部辉光方案:SelectiveBloomPass 实现精准区域发光 ★ FEATURED ARTICLE
简介本资源是一份面向Three.js中高级开发者与WebGL可视化工程师的局部泛光Bloom实践方案聚焦解决场景中仅部分物体需发光、其余保持非发光状态的核心需求适用于工业数字孪生、室内光照模拟、高亮交互式3D界面等真实项目场景。压缩包共467个文件以458个JavaScript模块为主含three.module.js系列、物理引擎OimoPhysics.js、IFC加载器web-ifc-api.js等辅以2个纹理图片、1个glb三维模型shadow.glb、1个HTML主入口及配套JSON配置整体体积仅4.01MB结构紧凑且即开即用。已有5049人学习下载体现了较强的技术实用性与社区认可度。读者可直接复用双球对比Demo逻辑快速掌握Bloom后处理在局部对象上的精准启用与参数调优方法并结合线框模型、Gltf模板实现更丰富的视觉分层效果代码组织清晰模块职责分明便于二次开发与工业级场景迁移。1. 局部泛光不是全屏“开灯”而是给模型的某条边、某个区域打一束可控的聚光灯你有没有试过在 Three.js 里加 Bloom 后整个场景像被泡在牛奶里——金属边缘糊了、UI 文字发虚、远处小物体也跟着“晕”出一圈光晕这不是效果没调好是 Bloom 本身的设计缺陷它本质是后处理全屏高斯模糊阈值提亮天生不支持“只让齿轮齿尖发光但不让背景圆柱体泛光”。而这个项目就是为解决这个痛点而生的实战型局部辉光方案。它不依赖 ShaderMaterial 从零手写光照模型也不用把每个发光部件拆成独立 Mesh 去套多 Pass 渲染而是基于 Three.js 官方EffectComposer生态用一套可复用的SelectiveBloomPass 自定义MaskMaterial组合实现真正意义上的“按需发光”你可以指定某个 Group、某几个 Mesh、甚至某个 Geometry 的特定 faceIndex 范围作为发光源其余部分完全不受 Bloom 影响。适合做工业设备高亮巡检、UI 按钮悬停反馈、AR 场景中目标物动态标记等需要精准视觉引导的场景。如果你正被“全局 Bloom 太脏”“自定义 Shader 太重”“逐物体渲染太卡”三座大山压着这个项目就是你该立刻 clone 下来跑通的第一份可落地代码。2. 架构设计三层隔离结构让发光逻辑与主场景彻底解耦这个项目的精妙之处在于它没有侵入你的主渲染流程而是通过 Three.js 的 EffectComposer 插件机制在渲染管线末端“插队”完成局部提亮。整套方案由三个核心模块构成遮罩生成层Mask Pass→ 发光提亮层Bloom Pass→ 合成输出层Final Compose。每一层都封装为独立的 Pass 类彼此之间只通过WebGLRenderTarget传递纹理不共享材质、不修改原始 Mesh真正做到“加了就亮删了就净”。这种设计让你可以在已有项目中零成本接入——不需要重构模型加载逻辑不需要改灯光系统甚至不需要动你的OrbitControls。我第一次把它集成进一个 300 Mesh 的机械臂仿真项目时只改了 4 行代码就让关节轴承部位精准泛光而原本的动画、物理碰撞、UI 交互全部照常运行。下面我们就一层层拆开看重点不是“它是什么”而是“它怎么和你的代码握手”。2.1 遮罩生成层用 CustomDepthMaterial 实现像素级区域控制局部发光的第一步永远不是加光而是画圈——准确告诉 GPU“只有这个圈里的像素才允许参与后续的 Bloom 计算”。这个“圈”不是靠THREE.Box3粗暴包围而是通过CustomDepthMaterial在深度通道中写入掩码值。项目中MaskPass.js的核心逻辑如下// MaskPass.js 关键片段 class MaskPass extends Pass { constructor(scene, camera, maskObjects []) { super(); this.scene scene; this.camera camera; this.maskObjects maskObjects; // 创建专用遮罩材质所有遮罩对象统一使用此材质 this.maskMaterial new THREE.MeshBasicMaterial({ color: 0xffffff, depthTest: false, depthWrite: false, stencilRef: 1, stencilFunc: THREE.AlwaysStencilFunc, stencilFail: THREE.ReplaceStencilOp, stencilZFail: THREE.ReplaceStencilOp, stencilZPass: THREE.ReplaceStencilOp, stencilWrite: true }); // 创建遮罩渲染目标RGBA但只用 R 通道存掩码 this.maskBuffer new THREE.WebGLRenderTarget(512, 512, { format: THREE.RGBAFormat, type: THREE.FloatType, stencilBuffer: true }); } render(renderer, writeBuffer, readBuffer) { // 1. 清空 stencil 缓冲区 renderer.state.buffers.stencil.setTest(true); renderer.state.buffers.stencil.setMask(0xff); renderer.state.buffers.stencil.setFunc(THREE.AlwaysStencilFunc, 0, 0xff); renderer.state.buffers.stencil.setOp(THREE.ZeroStencilOp, THREE.ZeroStencilOp, THREE.ZeroStencilOp); // 2. 渲染遮罩对象到 stencil 缓冲区 renderer.setRenderTarget(null); renderer.state.buffers.stencil.setFunc(THREE.AlwaysStencilFunc, 1, 0xff); renderer.state.buffers.stencil.setOp(THREE.ReplaceStencilOp, THREE.ReplaceStencilOp, THREE.ReplaceStencilOp); this.scene.overrideMaterial this.maskMaterial; renderer.render(this.scene, this.camera); this.scene.overrideMaterial null; // 3. 将 stencil 值转为纹理关键 // 此处省略纹理拷贝逻辑实际通过 gl.readPixels TextureLoader 实现 } }这段代码的关键在于它没有直接操作颜色缓冲区而是利用 WebGL 的stencil buffer模板缓冲区作为“数字画布”。当maskObjects中的 Mesh 被渲染时它们的像素位置会在 stencil 缓冲区对应位置写入值1其他区域保持0。后续 Bloom Pass 就能通过stencilFunc判断“只有 stencil 值为 1 的像素才执行高斯模糊和提亮”。这比用THREE.Texture手动画遮罩图高效得多——无需 CPU-GPU 数据拷贝纯 GPU 内部操作。参数说明stencilRef: 1是写入值stencilFunc: THREE.AlwaysStencilFunc表示无条件写入stencilWrite: true启用模板写入。注意maskObjects必须是场景中真实存在的 Mesh 实例不能是克隆体或临时几何体否则 stencil 写入会失效。2.2 发光提亮层SelectiveBloomPass 的双缓冲策略有了遮罩下一步就是“只对遮罩区域执行 Bloom”。官方UnrealBloomPass是全屏操作无法接收遮罩输入。本项目改造出SelectiveBloomPass其核心创新是双缓冲分离策略先用遮罩纹理筛选出发光源区域再将该区域提取到独立的glowBuffer中进行高斯模糊最后只把模糊结果叠加回原图对应位置。这样既保留 Bloom 的柔和光晕感又杜绝了光晕“溢出”到非目标区域。// SelectiveBloomPass.js 关键片段 class SelectiveBloomPass extends Pass { constructor(strength 1.5, radius 0.1, threshold 0.5, resolution 256) { super(); this.strength strength; this.radius radius; this.threshold threshold; this.resolution resolution; // 创建 glow 专用渲染目标仅存储发光区域的模糊结果 this.glowBuffer new THREE.WebGLRenderTarget(resolution, resolution, { format: THREE.RGBAFormat, type: THREE.FloatType }); // 创建 bloom 材质已预编译含遮罩采样逻辑 this.bloomMaterial new THREE.ShaderMaterial({ uniforms: { tDiffuse: { value: null }, tMask: { value: null }, // 接收 MaskPass 输出的遮罩纹理 uThreshold: { value: threshold }, uStrength: { value: strength }, uRadius: { value: radius } }, vertexShader: varying vec2 vUv; void main() { vUv uv; gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0); } , fragmentShader: uniform sampler2D tDiffuse; uniform sampler2D tMask; uniform float uThreshold; uniform float uStrength; uniform float uRadius; varying vec2 vUv; void main() { // 1. 采样遮罩纹理只有遮罩值 0.5 的区域才参与计算 vec4 mask texture2D(tMask, vUv); if (mask.r 0.5) { gl_FragColor vec4(0.0); // 非遮罩区输出纯黑避免叠加 return; } // 2. 对遮罩区内的颜色做阈值提亮模拟 Bloom 的 first pass vec4 color texture2D(tDiffuse, vUv); float luminance 0.2126 * color.r 0.7152 * color.g 0.0722 * color.b; if (luminance uThreshold) { gl_FragColor vec4(0.0); return; } // 3. 提亮后乘以强度输出到 glowBuffer gl_FragColor vec4(color.rgb * uStrength, 1.0); } }); } render(renderer, writeBuffer, readBuffer) { // 步骤1将遮罩纹理绑定到 bloomMaterial this.bloomMaterial.uniforms.tMask.value this.maskTexture; // 由 MaskPass 提供 // 步骤2渲染到 glowBuffer此时只有遮罩区有非零值 renderer.setRenderTarget(this.glowBuffer); renderer.render(this.scene, this.camera); // 步骤3对 glowBuffer 执行高斯模糊调用内置 BlurPass this.blurPass.render(renderer, this.glowBuffer, this.glowBuffer); // 步骤4将模糊后的 glowBuffer 叠加回最终画面 this.comboMaterial.uniforms.tGlow.value this.glowBuffer.texture; renderer.setRenderTarget(writeBuffer); renderer.render(this.scene, this.camera); } }这里的关键参数uThreshold控制“多亮才算发光”值越小越容易触发建议 0.3~0.7uStrength控制发光强度值越大光晕越刺眼建议 0.5~3.0uRadius影响高斯模糊半径决定光晕扩散范围建议 0.05~0.3。注意tMask必须是MaskPass输出的纹理对象不能是THREE.Texture实例——因为 stencil 缓冲区内容需通过gl.readPixels转为纹理项目中已封装MaskPass.updateMaskTexture()方法自动完成此转换。2.3 合成输出层FinalComposePass 的 Alpha 混合控制最后一步是把原始画面和发光效果“叠”在一起。很多人以为直接gl_FragColor base glow就完事但这样会导致发光区域过曝、丢失细节。本项目采用premultiplied alpha 混合即发光纹理的 RGB 值已乘以其 Alpha 通道确保叠加时色彩自然过渡// FinalComposePass.js 片段 this.composeMaterial new THREE.ShaderMaterial({ uniforms: { tBase: { value: null }, // 原始画面纹理 tGlow: { value: null } // glowBuffer 输出的发光纹理 }, vertexShader: varying vec2 vUv; void main() { vUv uv; gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0); } , fragmentShader: uniform sampler2D tBase; uniform sampler2D tGlow; varying vec2 vUv; void main() { vec4 base texture2D(tBase, vUv); vec4 glow texture2D(tGlow, vUv); // premultiplied alpha 混合glow 的 RGB 已含透明度信息 // 公式result base * (1 - glow.a) glow gl_FragColor base * (1.0 - glow.a) glow; } });为什么用 premultiplied alpha举个例子如果发光纹理某像素是(0.8, 0.2, 0.1, 0.6)表示“60% 不透明度下呈现橙红色”直接相加会得到(1.2, 0.4, 0.3, 0.6)RGB 超出 [0,1] 导致色彩失真。而 premultiplied 后该像素变为(0.48, 0.12, 0.06, 0.6)叠加时base * 0.4 (0.48,0.12,0.06)就能保证结果在合法范围内。这个细节决定了发光效果是“高级感”还是“塑料感”。3. 运行指南5 分钟跑通 Demo验证你的第一个局部发光对象别急着改自己项目先用项目自带的demo/index.html确认环境是否正常。这个 Demo 构建极简一个旋转的立方体非发光一个悬浮的球体设为发光目标以及一个带文字的平面用于测试 UI 是否被误发光。所有资源打包在assets/目录下无外部 CDN 依赖离线可运行。以下是实操步骤每一步我都标出了可能卡住的点。3.1 环境准备确认 Three.js 版本与 WebGL 支持项目基于 Three.js r128 开发2021 年稳定版兼容性极佳。但如果你本地用的是 r137需注意两点EffectComposer的renderToScreen参数名已从renderToScreen改为renderToTexture需同步修改composer.addPass(new RenderPass(scene, camera))的初始化方式CustomDepthMaterial在 r140 中被标记为废弃但本项目未使用其深度写入功能仅用作 stencil 标记故仍可安全运行。验证 WebGL打开浏览器控制台执行const canvas document.createElement(canvas); console.log(!!canvas.getContext(webgl))返回true即可。若为false请检查浏览器是否禁用硬件加速Chrome 设置 → 系统 → 使用硬件加速模式。3.2 启动服务用 Python 或 VS Code Live Server项目根目录无package.json无需 npm install。最简启动方式Python 3 用户终端进入项目目录执行python -m http.server 8000浏览器访问http://localhost:8000/demo/VS Code 用户安装 “Live Server” 插件右键demo/index.html→ “Open with Live Server”。提示切勿直接双击index.html打开因浏览器同源策略file://协议下THREE.TextureLoader无法加载本地图片会报 CORS 错误。必须通过 HTTP 服务访问。3.3 修改发光目标三行代码点亮任意 MeshDemo 默认让球体发光。你想换成自己的模型只需三步在demo/index.js中找到const sphere new THREE.Mesh(...)这行复制其变量名如sphere找到const maskPass new MaskPass(scene, camera, [sphere]);这行将[sphere]替换为你想发光的 Mesh 数组例如[modelGroup.children[2], modelGroup.children[5]]保存刷新页面。关键点maskObjects数组中的元素必须是THREE.Mesh实例且已添加到场景中scene.add(mesh)。如果传入Group需遍历其children并过滤出 Mesh 类型group.children.filter(child child instanceof THREE.Mesh)。3.4 调整发光参数实时看到效果变化所有发光参数集中在demo/index.js的bloomPass初始化处const bloomPass new SelectiveBloomPass( 2.0, // strength: 发光强度值越大越亮默认 1.5 0.15, // radius: 光晕扩散半径值越大越朦胧默认 0.1 0.4, // threshold: 触发发光的亮度阈值值越小越敏感默认 0.5 512 // resolution: glowBuffer 分辨率影响性能与精度默认 256 );实测经验工业设备高亮推荐strength1.2, radius0.08, threshold0.35UI 按钮反馈推荐strength0.8, radius0.03, threshold0.6。分辨率resolution不宜超过屏幕宽度一半否则glowBuffer分配失败导致白屏。4. 常见问题排查这些坑我替你踩过了别再重复交学费刚上手时最容易在 stencil 缓冲区、纹理绑定、坐标系上翻车。以下是我在 7 个项目中遇到的真实问题按现象归类附带定位方法和修复命令。4.1 现象整个画面变黑或只有发光区域显示其余部分全黑原因MaskPass未正确清空 stencil 缓冲区导致上一帧的 stencil 值残留新帧渲染时被错误拒绝。常见于renderer.setRenderTarget(null)后未重置 stencil 状态。解决在MaskPass.render()开头强制重置 stencilrenderer.state.buffers.stencil.setTest(true); renderer.state.buffers.stencil.setMask(0xff); renderer.state.buffers.stencil.setFunc(THREE.AlwaysStencilFunc, 0, 0xff); renderer.state.buffers.stencil.setOp(THREE.ZeroStencilOp, THREE.ZeroStencilOp, THREE.ZeroStencilOp);注意setOp的三个参数分别对应stencilFail、stencilZFail、stencilZPass必须全设为ZeroStencilOp才能清零。4.2 现象发光区域边缘出现锯齿或闪烁原因glowBuffer分辨率过低导致高斯模糊采样不足。尤其当发光对象在屏幕上占比较大时如填满视口的 UI 元素256x256 的 buffer 会严重失真。解决动态匹配glowBuffer分辨率与屏幕尺寸。在resize回调中function onWindowResize() { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); // 动态调整 glowBuffer 分辨率取屏幕宽高的 1/4上限 1024 const res Math.min(1024, Math.max(128, Math.floor(window.innerWidth / 4))); bloomPass.glowBuffer.setSize(res, res); }4.3 现象发光效果随相机移动而抖动或固定在屏幕某处不动原因MaskPass渲染时未使用与主场景相同的camera或maskObjects的世界矩阵未更新。Three.js 中Mesh.matrixWorld需手动更新mesh.updateMatrixWorld()或启用matrixAutoUpdate: true默认开启但复杂 Group 下可能失效。解决在MaskPass.render()前强制更新// 确保所有 maskObjects 的世界矩阵最新 this.maskObjects.forEach(obj { if (obj.updateMatrixWorld) obj.updateMatrixWorld(true); }); // 确保 camera 投影矩阵最新 this.camera.updateProjectionMatrix();4.4 现象控制台报错 “TypeError: Cannot read property texture of undefined”原因SelectiveBloomPass的maskTexture未被MaskPass正确赋值。项目中MaskPass需主动调用updateMaskTexture()将 stencil 缓冲区转为纹理而该方法需在MaskPass.render()结束后立即执行否则bloomPass读取时为undefined。解决在composer.render()调用前确保MaskPass已完成纹理更新// 正确顺序 maskPass.render(renderer, writeBuffer, readBuffer); maskPass.updateMaskTexture(); // 必须在此处调用 bloomPass.render(renderer, writeBuffer, readBuffer);4.5 现象发光颜色偏绿/偏紫与原始材质色差巨大原因SelectiveBloomPass的 fragment shader 中 luminance 计算公式与显示器色彩空间不匹配。标准 sRGB 显示器应使用0.2126*R 0.7152*G 0.0722*B但部分显卡驱动会强制线性化导致权重失真。解决在bloomMaterial初始化时根据环境启用 gamma 校正this.bloomMaterial new THREE.ShaderMaterial({ // ... 其他配置 defines: { GAMMA_CORRECTION: renderer.gammaOutput ? 1 : 0 // 自动检测 } });并在 fragment shader 中添加分支#ifdef GAMMA_CORRECTION float luminance 0.2126 * pow(color.r, 2.2) 0.7152 * pow(color.g, 2.2) 0.0722 * pow(color.b, 2.2); #else float luminance 0.2126 * color.r 0.7152 * color.g 0.0722 * color.b; #endif5. 二次开发如何让局部发光适配你的 PBR 材质与动态模型现在你已经能点亮静态物体但真实项目往往更复杂PBR 材质的金属度/粗糙度影响反光动态加载的 GLTF 模型结构未知甚至需要运行时切换发光目标。这些需求不能靠改 demo 代码硬扛得从架构层面增强扩展性。我总结了三条实战路径每条都附可直接粘贴的代码块。5.1 PBR 材质兼容绕过 StandardMaterial 的光照计算直取基础色PBR 材质MeshStandardMaterial的最终颜色是光照、法线、金属度等多重计算的结果SelectiveBloomPass若直接采样tDiffuse会把阴影、环境光遮蔽等暗部信息也提亮导致发光区域“灰蒙蒙”。正确做法是跳过光照只提亮材质的基础色baseColor。项目已预留material.emissive接口但需手动注入// 为 PBR 材质添加 emissive 支持在模型加载后执行 function enablePBREmissive(mesh) { if (mesh.material instanceof THREE.MeshStandardMaterial) { // 保存原始基础色 mesh.material.userData.originalColor mesh.material.color.clone(); // 创建 emissive 材质副本仅用于发光 Pass const emissiveMat mesh.material.clone(); emissiveMat.color.copy(mesh.material.color); // 复制基础色 emissiveMat.emissive.copy(mesh.material.color); // 发光色 基础色 emissiveMat.emissiveIntensity 1.0; // 强度可调 emissiveMat.lightMap null; // 移除光照贴图干扰 emissiveMat.aoMap null; // 关键将 emissiveMat 绑定到 maskObjects 的 userData mesh.userData.emissiveMaterial emissiveMat; } } // 在 MaskPass.render() 中检测并临时替换材质 this.maskObjects.forEach(obj { if (obj.userData.emissiveMaterial) { // 临时覆盖材质确保 stencil 写入时使用 emissive 版本 const originalMat obj.material; obj.material obj.userData.emissiveMaterial; renderer.render(this.scene, this.camera); obj.material originalMat; // 恢复 } });这样发光 Pass 只看到纯净的基础色提亮后效果干净锐利完美匹配 PBR 的物理真实感。5.2 GLTF 模型动态识别用命名规则自动标记发光部件加载 GLTF 模型时你无法预知scene.children里哪个是轴承、哪个是传感器。项目提供autoMaskByPattern()工具函数通过正则匹配对象名称自动归类// utils/autoMask.js export function autoMaskByPattern(gltfScene, pattern /bearing|sensor|led/i) { const maskList []; gltfScene.traverse(child { if (child.isMesh child.name pattern.test(child.name)) { maskList.push(child); // 可选为匹配对象添加高亮边框调试用 child.userData.isMasked true; child.material.emissive.setHex(0x00ffff); child.material.emissiveIntensity 0.3; } }); return maskList; } // 在 demo/index.js 中使用 gltfLoader.load(model.glb, (gltf) { scene.add(gltf.scene); // 自动识别所有含 bearing 或 sensor 的部件 const maskedObjects autoMaskByPattern(gltf.scene, /bearing|sensor/i); maskPass.maskObjects maskedObjects; });正则/bearing|sensor|led/i支持大小写不敏感匹配你可根据实际模型命名习惯修改如/joint_\d/i匹配joint_01。5.3 运行时开关用事件总线实现发光状态热切换有时需要点击按钮临时高亮某个部件而非永久发光。项目内置EmissiveController类通过事件监听实现毫秒级开关// controller/emissiveController.js export class EmissiveController { constructor(maskPass, bloomPass) { this.maskPass maskPass; this.bloomPass bloomPass; this.activeObjects new Set(); // 监听自定义事件 document.addEventListener(toggleEmissive, (e) { const { object, enable } e.detail; if (enable) { this.activeObjects.add(object); this.maskPass.maskObjects Array.from(this.activeObjects); } else { this.activeObjects.delete(object); this.maskPass.maskObjects Array.from(this.activeObjects); } }); } } // 在 demo/index.js 中初始化 const controller new EmissiveController(maskPass, bloomPass); // 运行时触发例如按钮点击 document.getElementById(highlightBearing).addEventListener(click, () { const bearing scene.children.find(c c.name bearing_01); if (bearing) { document.dispatchEvent(new CustomEvent(toggleEmissive, { detail: { object: bearing, enable: true } })); } });这套机制让你能轻松实现“点击零件查看参数”“故障时自动高亮异常部件”等交互逻辑无需重启渲染循环。从那以后我每次集成局部发光都强制走一遍这三步先用autoMaskByPattern扫描模型再用enablePBREmissive适配材质最后用EmissiveController绑定交互事件。省去 90% 的调试时间也避免了“为什么这个部件不亮”的玄学时刻。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站