Three.js-GPU拾取
解决什么问题
Three.js中常用的处理用户交互的方式,是RayCaster,这是CPU拾取方案。
但是如果遇到下面2个场景,常规RayCaster的方案就不够用了:
-
场景中的物体数量非常多/模型面数很多,RayCaster发射的每一根射线要判断的三角形数量巨大
-
在无用户交互接入的情况下,我们想判断下场景中当前哪些物体是可见的
这2种场景都需要巨大的计算量,CPU会不够看,此时,就需要借助GPU的计算能力了。
原理
我们先思考下:GPU处理的是什么?是像素。
因此基于GPU的处理,一定是围绕像素进行的。而像素能携带的信息,最常用的就是颜色RGB值。
因此我们构建一个离屏拾取层(_pickingScene),里面的物体和最终需要渲染的物体一一对应,并且给离屏场景中每一个物体,设置一个唯一的颜色值,那么我们只需要判断最终渲染的2D离屏图片的像素中,携带有哪些颜色,就可以知道当前哪些对象被渲染出来了。
这和分层Canvas处理交互是一样的道理。
所以我们要做的就很简单了:
-
给每个物体设置一个唯一的颜色值(一般是基于该对象的唯一标记,比如索引ID,来生成颜色值)
-
将物体通过离屏渲染,渲染到内存中的纹理上
-
读取离屏纹理上的像素的颜色值,映射到对象
注意渲染和最后的采样,针对不同的需求,方式会有所不同:
用户交互
如果是用于判断用户的鼠标交互触发了那个对象,那么渲染和采样时,只需要对用户鼠标当前位置的一个像素进行采样即可,这种场景基本没有性能问题。
全屏判断
如果是针对非用户交互的全屏判断,比如判断场景中当前哪些物体是可见的,这种情况,就需要渲染整个屏幕的内容(一次),然后针对屏幕内容,根据预设的采样范围大小(比如按照20*20像素的矩形),进行多次采样。
注意:获取像素的颜色值,这是耗费的CPU,不是GPU,因此要控制这个采样的操作数量,操作次数太多了,也是会卡住的。
这里有很大的优化空间,比如可以先获取模型的包围盒,然后算出一个涵盖所有模型的包围盒,只针对这一个或者有限的几个包围盒范围做采样。
遇到的问题
构建离屏层对象
这个其实不是问题,只是作为前置环节,这里做个记录:
/**
* 给顶点指定颜色
* @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)一定是相同的。这就需要我们对其做一个同步操作:
/**
* 同步需要展示的物体和用于拾取的物体的位置信息
* 需要在修改了场景中模型的位置信息后调用
*/
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:如果设置为逻辑像素会怎么样?):
this._pickingTexture = new WebGLRenderTarget(
dom.clientWidth * window.devicePixelRatio,
dom.clientHeight * window.devicePixelRatio
);
获取用户的鼠标位置信息:
canvas.addEventListener('mousemove', (event: MouseEvent) => {
// 用户的鼠标位置,这是逻辑像素/CSS像素
console.log(event.x, event.y);
});
采样时对鼠标坐标进行的处理:
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坐标做一个反向处理。
const pixelY = this._renderer.domElement.height - y * window.devicePixelRatio,
像素颜色还原为模型id
因为像素颜色是将模型id作为十六进制颜色值来设置的,通过readRenderTargetPixels()取得像素信息后,得到的实际上是范围为255的number类型颜色值,因此反向将像素颜色转为模型id时,需要用到位运算:
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:
// 设置renderer.toneMapping会导致颜色值上限从255变成其他值,进而导致将颜色还原为id出错,因此这里需要将其设置为NoToneMapping
this._rendererToneMapping = this._renderer.toneMapping;
this._renderer.toneMapping = NoToneMapping;
2、当离屏处理结束后,恢复toneMapping:
/**
* 重置renderTarget,不然不会渲染最终的画面
*/
public resetRenderer() {
this._renderer.setRenderTarget(null);
this._renderer.toneMapping = this._rendererToneMapping;
}
调试技巧
离屏层默认对我们是不可见的,如果出现问题,很难调试,因此我们可以考虑在需要的时候,将离屏层渲染出来:
// 离屏渲染
if (getURLParam('showTexture')) {
this._renderer.setRenderTarget(null);
} else {
this._renderer.setRenderTarget(this._pickingTexture);
}
this._renderer.render(this._pickingScene, this._camera);
// 中断程序,这样就可以看到离屏层内容了
if (getURLParam('showTexture')) {
debugger;
}
看到的效果类似这样(为了便于区分,将车顶和侧后方的玻璃手动设置成了红色):
