ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Three.js局部泛光实战:用遮罩纹理实现选择性Bloom

Three.js局部泛光实战:用遮罩纹理实现选择性Bloom

简介:这份资源聚焦 three.js 场景中的局部泛光与局部发光实现,面向已有一定 WebGL 基础、希望在工业可视化或室内场景中做出酷炫光效的前端与三维开发者。它解决的是场景内仅部分物体发光、其余保持常态的渲染需求,通过 bloom 后处理配合选择性发光逻辑,让发光体与普通物体在同一画面中自然共存。压缩包共 467 个文件,约 4.01MB,以 458 个 js 脚本为主体,辅以 wasm、glb 模型、jpg/png 贴图及 html 演示页,涵盖 three.js 核心模块、物理引擎与 glTF 加载相关依赖。资源内含两个小球的对比 demo,一个发光一个不发光,逻辑清晰,并附带 shadow.glb 等模型加载示例,便于理解局部辉光与线框模型结合的效果。目前已有 5047 人学习下载,适合需要快速掌握局部泛光排错思路与工程组织方式的读者参考借鉴。

1. 局部泛光不是加个 Bloom 就完事:threejs 里真正难的是“只让该亮的地方亮”

很多人在 threejs 里第一次做发光,都是直接上UnrealBloomPass,整个场景糊一层光晕,看起来挺唬人,但一放到实际项目里就翻车:UI 图标跟着发光、地面反射跟着发光、连背景贴图都泛白,最后只能把强度调到 0.1,效果约等于没有。局部泛光要解决的核心问题只有一个——把发光效果限制在指定物体或指定区域上,而不是全屏后处理一刀切。这件事在 threejs 里没有现成的一行 API,需要组合EffectComposer、RenderPass、UnrealBloomPass和SelectiveBloom思路来实现。适合谁看?做过基础 threejs 场景、想给产品加“科技感高亮”但被全屏泛光坑过的人;以及需要做数据可视化里“重点区域呼吸发光”的开发者。下面按我实际落地的顺序拆开讲。

2. 先搞懂 threejs 后处理链路:为什么全屏 Bloom 一定会溢出

2.1 EffectComposer 的渲染顺序决定了光晕范围

threejs 的后处理不是魔法,它本质上是把场景先渲染到一张离屏纹理(RenderTarget),然后一串 Pass 依次处理这张纹理,最后输出到屏幕。RenderPass负责把场景画进纹理,UnrealBloomPass负责对整张纹理做亮度提取、模糊、叠加。问题就出在这里:Bloom 拿到的是整张画面,它不知道哪个像素属于哪个物体。所以只要场景里有高亮像素,它就会发光。

常见做法是给需要发光的物体单独设置一个layers,然后用两个 RenderPass 分别渲染“发光层”和“非发光层”,但这样会带来深度冲突和遮挡错误。更稳的方案是走SelectiveBloom思路:用一张遮罩纹理告诉 Bloom “只处理这些区域”。

2.2 遮罩纹理:局部泛光的核心黑匣子

遮罩纹理(Mask Texture)是一张和屏幕等大的纹理,里面用亮度值标记哪些像素允许泛光。白色(1.0)表示参与泛光,黑色(0.0)表示不参与。生成方式有两种:一是用第二个相机只渲染发光物体到 RenderTarget,二是用材质 ID 渲染。我一般用第一种,因为可控性强,不需要改物体材质。

关键参数:RenderTarget的尺寸建议和画布一致,格式用RGBAFormat,类型用UnsignedByteType就够,不需要浮点纹理,省显存。遮罩相机的layers要单独设置,并且autoClear要设为false,否则会清掉主场景。

// 创建遮罩渲染目标 const maskTarget = new THREE.WebGLRenderTarget( window.innerWidth, window.innerHeight, { format: THREE.RGBAFormat, type: THREE.UnsignedByteType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter } ); // 遮罩相机:只渲染发光层 const maskCamera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); maskCamera.layers.set(1); // 只看到 layer 1 的物体 maskCamera.position.copy(mainCamera.position); maskCamera.quaternion.copy(mainCamera.quaternion); // 需要发光的物体设置 layer 1 glowMesh.layers.enable(1);

逻辑说明:maskCamera和主相机保持相同位置和朝向,但只渲染layer 1的物体。渲染时先清空maskTarget为黑色,再用maskCamera渲染发光物体为白色。这样得到的遮罩纹理里,只有发光物体所在像素是白色。参数上,layers.set(1)表示只启用 layer 1,主相机默认在 layer 0,互不干扰。

2.3 把遮罩接进 UnrealBloomPass 的两种改法

原生UnrealBloomPass不接受遮罩参数,所以要么改它的 shader,要么用ShaderPass自己写混合。我倾向改 shader:在UnrealBloomPass的亮度提取阶段,把遮罩纹理采样进来,用遮罩值乘以亮度。这样模糊和叠加阶段自然只处理遮罩区域。

具体做法是拿到UnrealBloomPass的materialHighPassFilter,往它的uniforms里加maskTexture,然后在 fragment shader 里加一行gl_FragColor.rgb *= texture2D(maskTexture, vUv).r;。注意vUv在 highPassFilter 里是有的,不用自己传。

// 假设 bloomPass 是 UnrealBloomPass 实例 const highPassMat = bloomPass.materialHighPassFilter; highPassMat.uniforms.maskTexture = { value: maskTarget.texture }; // 修改 fragment shader(实际项目中建议用 onBeforeCompile 或直接替换 shader) highPassMat.fragmentShader = highPassMat.fragmentShader.replace( 'gl_FragColor = vec4(texel.rgb, texel.a);', ` float mask = texture2D(maskTexture, vUv).r; gl_FragColor = vec4(texel.rgb * mask, texel.a); ` ); highPassMat.needsUpdate = true;

逻辑说明:texel是原始画面采样结果,乘以遮罩值后,非发光区域变成黑色,后续模糊和叠加就不会产生光晕。参数上,maskTexture的wrapS和wrapT建议设为ClampToEdgeWrapping,避免边缘采样出错。如果发现遮罩边缘有硬边,可以把遮罩纹理的minFilter设为LinearFilter,并在渲染遮罩时稍微模糊一下。

3. 从零搭一个可复现的局部泛光 Demo:代码、参数与调试

3.1 场景搭建与发光物体标记

先建一个最小场景:一个地面、一个普通立方体、一个需要发光的球体。球体用MeshStandardMaterial,emissive设成亮色,emissiveIntensity给到 2 以上,这样它本身亮度就高,配合遮罩就能只让它泛光。

// 场景基础 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(0, 2, 6); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); // 普通物体 const box = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0x4488ff }) ); box.position.set(-2, 0.5, 0); scene.add(box); // 发光物体 const glowBall = new THREE.Mesh( new THREE.SphereGeometry(0.8, 32, 32), new THREE.MeshStandardMaterial({ color: 0x000000, emissive: 0x00ffcc, emissiveIntensity: 3 }) ); glowBall.position.set(2, 0.8, 0); glowBall.layers.enable(1); // 标记为发光层 scene.add(glowBall); // 灯光 scene.add(new THREE.AmbientLight(0x404040)); const dirLight = new THREE.DirectionalLight(0xffffff, 1); dirLight.position.set(5, 10, 7); scene.add(dirLight);

逻辑说明:glowBall.layers.enable(1)是关键,它让球体同时属于 layer 0 和 layer 1。主相机默认渲染 layer 0,所以球体正常显示;遮罩相机只渲染 layer 1,所以遮罩里只有球体。参数上,emissiveIntensity建议 2~5,太低泛光不明显,太高会过曝。

3.2 后处理链路的完整组装

后处理链路顺序:RenderPass→UnrealBloomPass(带遮罩)→OutputPass。注意OutputPass在 threejs 较新版本里用于处理色彩空间,如果版本较老可以用ShaderPass代替。

import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; const composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); const bloomPass = new UnrealBloomPass( new THREE.Vector2(innerWidth, innerHeight), 1.5, // strength 0.4, // radius 0.85 // threshold ); composer.addPass(bloomPass); composer.addPass(new OutputPass()); // 遮罩渲染目标 const maskTarget = new THREE.WebGLRenderTarget(innerWidth, innerHeight); const maskCamera = camera.clone(); maskCamera.layers.set(1); // 每帧先渲染遮罩 function render() { renderer.setRenderTarget(maskTarget); renderer.setClearColor(0x000000, 1); renderer.clear(); renderer.render(scene, maskCamera); renderer.setRenderTarget(null); // 把遮罩传给 bloom bloomPass.materialHighPassFilter.uniforms.maskTexture.value = maskTarget.texture; composer.render(); requestAnimationFrame(render); } render();

逻辑说明:每帧先让maskCamera把发光物体画到maskTarget,清屏色为黑色。然后composer.render()走正常后处理,bloomPass在 high pass 阶段用遮罩过滤。参数上,strength控制光晕强度,radius控制扩散范围,threshold控制亮度阈值。局部泛光时threshold可以设低一点(0.6~0.8),因为遮罩已经限制了区域,不用担心误伤。

3.3 参数调试:三个必调项与观察指标

参数作用推荐范围调过头会怎样
strength光晕强度0.8~2.0过曝,发光体变成白团
radius扩散半径0.2~0.6光晕糊成一片,失去局部感
threshold亮度阈值0.6~0.9太低会让暗部也泛光,太高发光体不亮

调试时建议先把strength设为 0,确认遮罩是否正确——此时应该完全看不到光晕。然后逐步加strength,观察光晕是否只出现在球体周围。如果发现立方体也亮了,检查maskCamera.layers是否只启用了 layer 1,以及glowBall.layers.enable(1)是否生效。

4. 避坑与排查:局部泛光最常见的 5 个翻车现场

4.1 遮罩全黑或全白:layers 没设对

现象:渲染出来要么完全没有光晕,要么整个屏幕都在发光。原因:maskCamera.layers.set(1)之后,如果发光物体没有layers.enable(1),遮罩就是全黑;如果主相机也渲染了 layer 1,遮罩可能被其他物体覆盖。解决:确认发光物体执行了layers.enable(1),并且maskCamera只set(1)而不是enable(1)。set是替换,enable是追加,这里必须用set。

4.2 遮罩边缘有锯齿或硬边

现象:光晕边缘像刀切一样,或者有闪烁。原因:遮罩纹理分辨率不够,或者采样方式不对。解决:把maskTarget尺寸设为innerWidth * devicePixelRatio,并且minFilter用LinearFilter。如果还有硬边,可以在遮罩渲染后加一个小的模糊 Pass,或者把bloomPass的radius调大一点让边缘自然过渡。

4.3 透明物体遮挡导致遮罩错误

现象:发光物体前面有个透明玻璃,结果玻璃后面的发光体也被遮罩了。原因:遮罩相机渲染时,透明物体也会写入深度,导致发光体被遮挡。解决:把透明物体的layers从遮罩相机中排除,或者设置maskCamera.layers.disable(0)并确保透明物体不在 layer 1。如果透明物体需要遮挡发光,那就得用深度纹理做更复杂的处理,一般项目里直接让透明物体不参与遮罩即可。

4.4 性能下降明显:每帧多渲染一次场景

现象:加了局部泛光后帧率掉了一半。原因:每帧多了一次全场景渲染(遮罩渲染),加上 Bloom 本身的多级模糊,开销翻倍。解决:遮罩渲染不需要渲染整个场景,只渲染发光物体即可。可以把发光物体单独放在一个Group里,遮罩相机只渲染这个 Group。另外maskTarget尺寸可以减半,因为遮罩不需要高精度,innerWidth / 2就够,Bloom 采样时会自动插值。

4.5 色彩空间不一致导致光晕发灰

现象:光晕颜色和发光体颜色对不上,偏灰或偏暗。原因:maskTarget的纹理是 sRGB 还是 Linear 没统一。解决:在 threejs 里,renderer.outputColorSpace设为SRGBColorSpace后,遮罩纹理如果直接当普通纹理用,需要设置maskTarget.texture.colorSpace = THREE.NoColorSpace,因为遮罩只存亮度,不存颜色。如果发现光晕颜色不对,优先检查这里。

5. 进阶:用深度纹理做“区域泛光”和动态遮罩

局部泛光做到物体级之后,下一步往往是区域级——比如只让地面上一块圆形区域泛光,或者让光晕跟着鼠标走。这时候遮罩就不能靠 layers 了,得用深度纹理或自定义 shader 生成动态遮罩。

我一般用DepthTexture加一个后处理 Pass 来生成遮罩:在 shader 里根据世界坐标或屏幕坐标计算距离,距离小于阈值的像素设为白色,否则黑色。这样就能实现“以某点为中心的圆形泛光区域”。参数上,center用vec2传屏幕坐标,radius控制范围,softness控制边缘过渡。

// 动态遮罩 shader 片段(简化版) uniform vec2 center; uniform float radius; uniform float softness; varying vec2 vUv; void main() { float dist = distance(vUv, center); float mask = 1.0 - smoothstep(radius - softness, radius + softness, dist); gl_FragColor = vec4(vec3(mask), 1.0); }

逻辑说明:smoothstep让遮罩边缘有过渡,避免硬边。center可以每帧更新为鼠标位置或某个 3D 点投影到屏幕的坐标。这个遮罩再接到UnrealBloomPass的 high pass 阶段,就能实现“鼠标指哪哪发光”的效果。

验证方法很简单:先把radius设大,确认整个屏幕都泛光;然后缩小radius,观察光晕是否跟着center移动。如果光晕不跟着动,检查center是否每帧更新,以及vUv是否和屏幕坐标对齐。我踩过的坑是vUv在UnrealBloomPass里可能被翻转,需要1.0 - vUv.y调一下。

最后说个血泪经验:局部泛光最怕的就是“看起来能跑,一换场景就崩”。所以每次调完参数,我都会把相机拉到不同角度、不同距离各看一遍,确认遮罩没有因为深度或视角变化而失效。这个习惯帮我省了很多后悔药。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进