RealTime-Rendering16-Pick

Pick

GPU Pick

为场景中的每个对象分配一个唯一的颜色,将所有对象渲染到一个FBO中,拾取时基于鼠标指针在屏幕上的位置索引FBO中的颜色值。如果该颜色与某个对象匹配,则该对象被选中。以下是绘制到SelectionBuffer和从SelectionBuffer读取的详细步骤:

Drawing to Selection Buffer

首先需要一个单的pass将物体分配的颜色写入单独的FBO,这里需要处理几件事:

步骤 操作 详细说明
1 创建帧缓冲区 创建一个与渲染屏幕分辨率相同的帧缓冲区
2 分配唯一索引 为每个对象分配一个唯一的索引值(通常是从 1 开始的整数)
3 索引转颜色 将索引值转换为 32 位颜色值 (r, g, b, a)
4 渲染 用索引转换得到的颜色将对象绘制到帧缓冲区

Reading from Selection Buffer

步骤 操作 详细说明
1 获取鼠标位置 从屏幕获取鼠标坐标 (x, y)
2 读取像素 使用 glReadPixels() 读取鼠标位置的颜色值
3 颜色转索引 将帧缓冲区中的颜色值转换回对象索引值
4 命中检测 如果索引值非零,表示选中了对象
5 未命中检测 如果索引值为零,表示未选中任何对象

IndexToColor & ColorToIndex

1
2
3
4
5
6
7
8
// index to color
int index = ...;
int r = index & 0xFF;
int g = index >> 8 & 0xFF;
int b = index >> 16 & 0xFF;

// normalized color value: 0 ~ 1
Color color = [r/255, g/255, b/255, 1];
1
2
3
// color (r, g, b) to index
Color color = ...; // array of (r, g, b) components
int index = color[0] + (color[1] << 8) + (color[1] << 16);

glReadPixels

在opengl中读取FBO纹理附件像素值通过glReadPixels函数:

1
2
3
4
5
6
7
void glReadPixels(GLint x,
GLint y,
GLsizei width,
GLsizei height,
GLenum format,
GLenum type,
GLvoid * data);

其中xy表示像素位置, 即屏幕空间鼠标指针的位置,width和height表示要读取的像素区域大小

pipeline

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
// 渲染场景到selectionBuffer
void render(objects, camera)
{
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
gl.clearColor(0, 0, 0, 0); // 背景为黑色(索引0)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);


objects.forEach((obj, index) =>
{
const color = SelectionBuffer.encodeIndex(index + 1); // 索引从1开始
obj.renderWithColor(color, camera); // 用纯色渲染
});
gl.bindFramebuffer(gl.FRAMEBUFFER, 0);
}

// 读取鼠标位置的对象索引
read(x, y)
{
const webglY = this.height - y;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
const pixels = new Uint8Array(4);
gl.readPixels(x, webglY, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return SelectionBuffer.decodeColor(pixels);
}

CPU Pick

Construct ViewRay

射线由起点和方向向量定义。对于透视相机,起点通常是相机位置,方向是从相机所在位置指向屏幕点击位置的向量。首先需要将屏幕空间坐标转换为世界空间中的坐标,然后减去相机位置得到方向向量。

坐标转换流程

屏幕坐标(通常原点在左上角) → 归一化设备坐标(NDC,范围[-1, 1]) → 视图空间 → 世界空间

screenSpace->NDC

假设鼠标点击坐标为 (mouseX, mouseY),屏幕宽高为 viewportWidth、viewportHeight。注意Y轴方向:大多数窗口系统原点在左上角,Y向下为正,而NDC中Y向上为正,所以需要翻转Y。

1
2
3
4
5
6
7
8
// 将屏幕坐标 [0, width] 映射到 [-1, 1]
// 注意:Y轴需要翻转(屏幕Y向下,NDC Y向上)
float ndcX = (2.0f * mouseX) / viewportWidth - 1.0f;
float ndcY = 1.0f - (2.0f * mouseY) / viewportHeight; // 翻转Y轴
float ndcZ = -1.0f; // 近裁剪面(指向屏幕内)
float ndcW = 1.0f; // 齐次坐标

Vector4 ndcPos(ndcX, ndcY, ndcZ, ndcW);
NDC->worldSpace

将viewProjectionMatrix的逆矩阵乘以ndcPos,得到鼠标指针在近裁剪平面上世界空间的位置:

1
2
3
glm::mat4 invViewProj = glm::inverse(projectionMatrix * viewMatrix);
glm::vec4 rayWorld = invViewProj * ndcPos;
rayWorld /= rayWorld.w;
Construct ViewRay
1
2
glm::vec3 rayOrigin = cameraPosition;  // 或者 rayNearWorld.xyz
glm::vec3 rayDir = glm::normalize(rayWorld.xyz - rayOrigin.xyz);