Three.js-GPU拾取

解决什么问题

Three.js中常用的处理用户交互的方式,是RayCaster,这是CPU拾取方案。

但是如果遇到下面2个场景,常规RayCaster的方案就不够用了:

  • 场景中的物体数量非常多/模型面数很多,RayCaster发射的每一根射线要判断的三角形数量巨大

  • 在无用户交互接入的情况下,我们想判断下场景中当前哪些物体是可见的

这2种场景都需要巨大的计算量,CPU会不够看,此时,就需要借助GPU的计算能力了。

原理

我们先思考下:GPU处理的是什么?是像素

因此基于GPU的处理,一定是围绕像素进行的。而像素能携带的信息,最常用的就是颜色RGB值

因此我们构建一个离屏拾取层(_pickingScene),里面的物体和最终需要渲染的物体一一对应,并且给离屏场景中每一个物体,设置一个唯一的颜色值,那么我们只需要判断最终渲染的2D离屏图片的像素中,携带有哪些颜色,就可以知道当前哪些对象被渲染出来了。

这和分层Canvas处理交互是一样的道理。

所以我们要做的就很简单了:

  • 给每个物体设置一个唯一的颜色值(一般是基于该对象的唯一标记,比如索引ID,来生成颜色值)

  • 将物体通过离屏渲染,渲染到内存中的纹理上

  • 读取离屏纹理上的像素的颜色值,映射到对象

注意渲染和最后的采样,针对不同的需求,方式会有所不同:

用户交互

如果是用于判断用户的鼠标交互触发了那个对象,那么渲染和采样时,只需要对用户鼠标当前位置的一个像素进行采样即可,这种场景基本没有性能问题。

全屏判断

如果是针对非用户交互的全屏判断,比如判断场景中当前哪些物体是可见的,这种情况,就需要渲染整个屏幕的内容(一次),然后针对屏幕内容,根据预设的采样范围大小(比如按照20*20像素的矩形),进行多次采样。

注意:获取像素的颜色值,这是耗费的CPU,不是GPU,因此要控制这个采样的操作数量,操作次数太多了,也是会卡住的。

这里有很大的优化空间,比如可以先获取模型的包围盒,然后算出一个涵盖所有模型的包围盒,只针对这一个或者有限的几个包围盒范围做采样。

遇到的问题

构建离屏层对象

这个其实不是问题,只是作为前置环节,这里做个记录:

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
/**
* 给顶点指定颜色
* @param geometry
* @param color
*/
function applyVertexColors(geometry: BufferGeometry, color: Color) {
const position = geometry.attributes.position;
const colors = [];

for (let i = 0; i < position.count; i++) {
colors.push(color.r, color.g, color.b);
}

geometry.setAttribute('color', new Float32BufferAttribute(colors, 3));
}

const pickingMaterial = new MeshBasicMaterial({
vertexColors: true
});
const color = new Color();
// 构建拾取用到的对象,注意将id作为取数据的标记
let maxIndex = 0;
this._displayingObjects.forEach((obj: Mesh, i) => {
this._idToName[i + 1] = obj.name;
maxIndex = i;

// 获取geometry
const geometry = obj.geometry.clone();

// 通过模型在数组中的下标id来设置顶点颜色(十六进制颜色)
// 为了和空白区域进行区分,将纯黑色(0)定义为空白区域,因此下标做了+1处理,即从1开始
applyVertexColors(geometry, color.setHex(i + 1));

const mesh = new Mesh(geometry, pickingMaterial);
mesh.name = obj.name;

this._pickingScene.add(mesh);

this._pickingingObjects[i] = mesh;
});

离屏对象和非离屏对象的Transform同步问题

因为我们构建了一个离屏层,那么就需要保证离屏层里面的对象和非离屏层的对象,其Transform信息(position、rotation、scale)一定是相同的。这就需要我们对其做一个同步操作:

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
/**
* 同步需要展示的物体和用于拾取的物体的位置信息
* 需要在修改了场景中模型的位置信息后调用
*/
public syncTransform() {
// 构建拾取用到的对象,注意将id作为取数据的标记
let maxIndex = 0;
this._displayingObjects.forEach((obj: Mesh, i) => {
const pickingObject = this._pickingingObjects[i];

if (!pickingObject || pickingObject.name !== obj.name) {
throw new Error(`获取picking对象失败`);
}

const worldPosition = new Vector3();
obj.getWorldPosition(worldPosition);

const worldScale = new Vector3();
obj.getWorldScale(worldScale);

const worldQuaternion = new Quaternion();
obj.getWorldQuaternion(worldQuaternion);

// rotation
const euler = new Euler();
euler.setFromQuaternion(worldQuaternion);

// 必须设置transform属性
pickingObject.position.set(
worldPosition.x,
worldPosition.y,
worldPosition.z
);
// mesh.scale.set(worldScale.x, worldScale.y, worldScale.z);
// TODO
const scale = 0.03;
pickingObject.scale.set(scale, scale, scale);
pickingObject.rotation.set(euler.x, euler.y, euler.z, euler.order);
});
}

DPR问题

这里有一个必须先要理清楚的内容,就是DPR:devicePixelRatio,设备像素比。

先弄清楚几个概念:

DP = Device Pixels = 设备物理像素/分辨率

DIP = Device-Independent Pixel = 设备独立像素、CSS像素、逻辑像素

DPR = DP / DIP

以我的显示器为例:

DP = 3840 x 2160

我设置了150%缩放,因此DPR = 1.5

那么可以计算得出DIP = DP / DPR = (3840 x 2160) / 1.5 = 2560 x 1440

这里要注意:

  • 离屏渲染,最终渲染的是一个纹理,也就是图片,一般我们会将其设置为物理像素,即DP

  • 用户的鼠标交互,获取的坐标点,是逻辑像素,即DIP

因此我们在采样的时候,需要将用户的鼠标位置,乘以DPR,以获取物理像素位置,再进行纹理采样。

设置离屏纹理的大小,将其设置为物理像素(TODO:如果设置为逻辑像素会怎么样?):

1
2
3
4
this._pickingTexture = new WebGLRenderTarget(
dom.clientWidth * window.devicePixelRatio,
dom.clientHeight * window.devicePixelRatio
);

获取用户的鼠标位置信息:

1
2
3
4
canvas.addEventListener('mousemove', (event: MouseEvent) => {
// 用户的鼠标位置,这是逻辑像素/CSS像素
console.log(event.x, event.y);
});

采样时对鼠标坐标进行的处理:

1
2
3
4
5
6
7
8
9
10
11
12
const pixelBuffer = new Uint8Array(4);
this._renderer.readRenderTargetPixels(
this._pickingTexture,
x * window.devicePixelRatio,
// // 这里的y坐标是窗口坐标,是和像素坐标系反过来的,上大下小
// (误)注意:this._renderer.domElement.height已经是物理分辨率了,不需要乘以DPR
//this._renderer.domElement.height - y * window.devicePixelRatio,
this._pickingTexture.height - y * window.devicePixelRatio,
1,
1,
pixelBuffer
);

(待确认)Y坐标反向问题

注意:这个想法只是我根据现象进行的猜测,不一定准确。

离屏渲染的纹理,其Y坐标从上到下,是从大到小的;这和常规的CSS里的坐标系的Y坐标是相反的,因此采样时,需要对Y坐标做一个反向处理。

1
const pixelY = this._renderer.domElement.height - y * window.devicePixelRatio,

像素颜色还原为模型id

因为像素颜色是将模型id作为十六进制颜色值来设置的,通过readRenderTargetPixels()取得像素信息后,得到的实际上是范围为255的number类型颜色值,因此反向将像素颜色转为模型id时,需要用到位运算:

1
const id = (pixelBuffer[0] << 16) | (pixelBuffer[1] << 8) | pixelBuffer[2];

R左移16位,R左移8位,B不用左移;然后进行位或运算。

比如以红色为例:

1、我们获取的pixelBuffer是(255, 0, 0)

R = 1111 1111

G = 0000 0000

B = 0000 0000

2、左移操作后:

R = 1111 1111 0000 0000 0000 0000

G = 0000 0000 0000 0000

B = 0000 0000

3、位或操作后:

1111 1111 0000 0000 0000 0000

4、转为十进制:

16,711,680

因此红色模型,其id就是16,711,680。

基于RGB,我们理论上能标记的模型数量是16,777,215个,即FFFFFF的10进制范围。

当模型数量比较少的时候,我们如果将离屏层打印出来,可以看到基本都是蓝色的,因为1-255对应的是RGB中的B范围。

toneMapping导致的颜色RGB范围异常问题

WebGLRenderer默认的色调映射(renderer.toneMapping = THREE.NoToneMapping)下,RGB值的范围是0-255,但是如果我们设置了toneMapping为其他值,RGB的范围就可能会发生变化(比如设置为THREE.LinearToneMapping,则RGB的上限就只有153了)。

这会导致我们通过像素的颜色还原模型id时,会和模型id对应不上。因此需要做如下处理:

1、离屏处理开始前,先去除toneMapping:

1
2
3
// 设置renderer.toneMapping会导致颜色值上限从255变成其他值,进而导致将颜色还原为id出错,因此这里需要将其设置为NoToneMapping
this._rendererToneMapping = this._renderer.toneMapping;
this._renderer.toneMapping = NoToneMapping;

2、当离屏处理结束后,恢复toneMapping:

1
2
3
4
5
6
7
/**
* 重置renderTarget,不然不会渲染最终的画面
*/
public resetRenderer() {
this._renderer.setRenderTarget(null);
this._renderer.toneMapping = this._rendererToneMapping;
}

调试技巧

离屏层默认对我们是不可见的,如果出现问题,很难调试,因此我们可以考虑在需要的时候,将离屏层渲染出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 离屏渲染
if (getURLParam('showTexture')) {
this._renderer.setRenderTarget(null);
} else {
this._renderer.setRenderTarget(this._pickingTexture);
}

this._renderer.render(this._pickingScene, this._camera);

// 中断程序,这样就可以看到离屏层内容了
if (getURLParam('showTexture')) {
debugger;
}

看到的效果类似这样(为了便于区分,将车顶和侧后方的玻璃手动设置成了红色):
texture1