threejs后处理实现轮廓线功能

鼠标移到光伏基站里的配电箱上,箱体亮起一圈黄边;移到旁边的汇流箱上,边就跟着换到汇流箱。这一圈边就是轮廓线,three.js 里对应的东西是后处理里的 OutlinePass。

先把一个容易混的地方分开:这圈边是”画”出来的,”鼠标在哪个箱体上”是”算”出来的。 判断鼠标指着谁用的是 Raycaster 射线拾取,把那个物体描一圈用的是 OutlinePass。两件事经常一起出现,但互不认识——OutlinePass 全程不关心鼠标,它只知道”这批物体要描边”。

这篇只讲描边那一半。

1. EffectComposer:渲染不再直接上屏

后处理的意思是:场景先不画到屏幕,而是画到一张离屏纹理上,然后一串”通道”依次在这张纹理上做操作,最后一个通道才画到屏幕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
不用后处理
renderer.render(scene, camera) ──────────────> 默认帧缓冲 ──> 屏幕

用后处理
composer.render()
RenderPass 场景画进离屏 renderTarget
| (这时候屏幕上还什么都没有)
v
OutlinePass 读这张纹理,自己再画几遍遮罩/深度/法线,
| 在全屏着色器里加上一圈描边
v
OutputPass 色调映射 + 色彩空间转换
|
v
默认帧缓冲 ──> 屏幕

要改的就一行:renderer.render(scene, camera) 换成 composer.render()。这条最容易漏——少改这一行,整条链搭得再对也白搭,画面还是原样。

“最后画到屏幕”这件事不用手动指定,EffectComposer 会把 renderToScreen 分给最后一个启用的通道。所以 OutputPass 必须放在最后。

2. OutlinePass 描的是什么

它不看几何。整个流程靠两张东西:

1
2
3
4
5
6
7
8
9
10
11
12
 选中物体的遮罩                      整个场景的深度 / 法线
(选中区域是实心的, (用来判断"这条边前面有没有
其它位置是空的) 别的东西挡着")
\ /
\ /
v v
全屏着色器:找遮罩的边缘
|
| 在这个位置比一下场景深度:
|
├─ 前面没东西挡 ──> visibleEdgeColor
└─ 前面有东西挡 ──> hiddenEdgeColor

两个结论直接从这张图来:

  • 它描的是”选中物体在屏幕上占的那块区域”的边界,所以跟这个物体有多少条棱边完全无关。一个一万面的汇流箱和一个立方体,描出来都是光滑的一圈。
  • 被挡住的那部分轮廓会单独上色,也就是 hiddenEdgeColor。这就是为什么描边可以做成”透视”效果:汇流箱被前面的配电箱挡住时,轮廓从遮挡物里面透出来。不想要这个效果,把 hiddenEdgeColor 设成接近背景的颜色就行——它是”描成另一个颜色”,不是”关掉”。

3. 那七个参数

参数 默认值 管什么
edgeStrength 3 描边的强度。越大越亮越”实”,调到 0 等于不描
edgeThickness 1 描边的粗细
edgeGlow 0 外发光。大于 0 会在硬边之外再加一层晕
pulsePeriod 0 呼吸周期。0 是不呼吸,大于 0 就按这个周期明暗脉动
visibleEdgeColor 0xffffff 没被挡住那部分轮廓的颜色
hiddenEdgeColor 0x190a05 被挡住那部分轮廓的颜色
downSampleRatio 2 描边在 1/2 分辨率下算,省性能
usePatternTexture false 用一张纹理当描边图案,不用纯色

edgeThickness 不是像素值,它是采样偏移的倍数;描边本身又在 downSampleRatio 降采样的图上算。所以别指望”设成 4 就是 4 像素粗”,它只保证”调大明显更粗”。要精确控制宽度,得改 downSampleRatio 和着色器里的偏移基数,一般没必要——悬停高亮对上视觉就行。

pulsePeriod 单独说一下:悬停高亮通常希望它是稳的,鼠标停在哪就一直是那一圈,所以设 0。要做”吸引注意力”的呼吸效果才给它一个正数,比如 3 就是 3 秒一次明暗循环。

4. 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import * as THREE from 'three'
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'

const renderer = new THREE.WebGLRenderer({ antialias: true })
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 1000)

// 1. 建管线:场景 -> 描边 -> 收尾
const composer = new EffectComposer(renderer)
composer.addPass(new RenderPass(scene, camera))

const outlinePass = new OutlinePass(new THREE.Vector2(w, h), scene, camera)
outlinePass.edgeStrength = 2
outlinePass.edgeThickness = 2
outlinePass.edgeGlow = 0 // 悬停高亮一般不要外发光
outlinePass.pulsePeriod = 0 // 0 = 不呼吸,稳住
outlinePass.visibleEdgeColor.set('#ffff00')
outlinePass.hiddenEdgeColor.set('#000000')
composer.addPass(outlinePass)

composer.addPass(new OutputPass()) // 必须最后一个

function animate() {
requestAnimationFrame(animate)
composer.render() // 不再是 renderer.render(scene, camera)
}

function resize() {
const w = canvas.clientWidth
const h = canvas.clientHeight

renderer.setSize(w, h)
composer.setSize(w, h) // 这一句会遍历所有 pass 逐个 setSize,
// 所以不用再单独调 outlinePass.setSize

camera.aspect = w / h
camera.updateProjectionMatrix()
}

// 2. 选中管理
function select(obj) {
if (!outlinePass.selectedObjects.includes(obj)) {
outlinePass.selectedObjects.push(obj)
}
}
function deselect(obj) {
const i = outlinePass.selectedObjects.indexOf(obj)
if (i > -1) outlinePass.selectedObjects.splice(i, 1)
}
function clearSelect() {
outlinePass.selectedObjects.length = 0
}

selectedObjects 就是一个普通数组,改它立刻生效,不需要通知谁。

OutputPass 的作用是把 renderer.toneMapping 和输出色彩空间在链尾补上。它从 r152 开始有,之前的做法是自己在链尾加一个 ShaderPass 挂 GammaCorrectionShader。漏了它的典型症状是整幅画面偏色——场景看着比 renderer.render 那会儿灰或者暗。

5. 接上鼠标:悬停高亮

描边和拾取合起来才是完整的悬停效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
const pickables = [] // 只放需要高亮的 mesh,不是整个场景

renderer.domElement.addEventListener('pointermove', (event) => {
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1

raycaster.setFromCamera(pointer, camera)
const hits = raycaster.intersectObjects(pickables, false)

if (hits.length > 0) {
select(hits[0].object) // hits[0] 是离相机最近的那个
} else {
clearSelect()
}
})

// 鼠标移出画布要清掉,否则会留着一圈描边
renderer.domElement.addEventListener('pointerleave', clearSelect)

四个点:

  • pointer 要转成 NDC(-1 到 1),用 getBoundingClientRect() 取画布位置而不是 innerWidth,否则画布不占满窗口时会偏。
  • pickables 传显式数组,recursive 传 false。 对整棵场景树做射线检测既慢,又会命中你看不见的东西(背景板、地面网格)。把能高亮的设备(配电箱、汇流箱这些)单独收进一个数组。
  • 放进 selectedObjects 的应该是那块 mesh 本身,也就是 hits[0].object。如果模型做了分组,别往里放父级 Group。
  • 悬停和点击选中要分开记。 如果两者都要,别共用一份数组——维护一个 hovered,一个 picked,每次变化时把两者拼起来赋给 selectedObjects。否则鼠标一移开,之前点选的东西也跟着灭了。

6. 一句话

描边和拾取是两件事:Raycaster 决定”描谁”,OutlinePass 决定”怎么描”。

真正要记住的只有三件事:描边是后处理,一次全屏 pass,跟物体有多少面无关;它描的是屏幕上的外轮廓,跟物体有多少条棱边无关;悬停高亮里那圈边和判定鼠标位置的那次射线,中间唯一的联系是”谁被放进了 selectedObjects“。

参考

threejs后处理实现轮廓线功能

https://wywzixin.github.io/threejs/outline-pass/

作者

wangyawei

发布于

2026-09-14

更新于

2026-09-14

许可协议