上篇聊了关于GPU拾取的几种方式。GPU 拾取的多重奇幻之旅:颜色编码、G-Buffer 与光线追踪的全景探索
本篇来聊聊Cesium应用的两种拾取方式: 颜色编码, 深度缓存。
Cesium相关 pick API这里就不介绍咯, 可以直接去官方文档查阅!
总的来说, 一般在渲染引擎会提供在交互拾取时所需的 object和position。
Cesium是使用 颜色编码 拾取物体或物体上的metadata信息, 使用 深度缓存 提取当前像素上的 worldPosition。
先来看看基于颜色编码拾取物体流程: (以Primitive为例)
- 在批量处理数据时会为object或实例创建一个唯一的整数ID并转换为颜色存储到batchTable或batchTexture里(颜色编码最多 4294967295 个, 就是当前可以维护这些数量的可拾取物, 超出会报错), 且颜色编码的映射维护在上下文里的 pickObjects里(着色器里会通过 czm_batchTable_pickColor 函数获取到当前的编码ID)。
- 在 createCommands 之后, 会依据配置 ‘allowPicking’ 给当前 command添加 pickID: ‘v_pickColor’。
- 在 Scene 进行 Frame Render 的时候, 会更新 DerivedCommand, 这时候会依据当前 Command 是否具有 pickID 而决定是否 创建 输出颜色编码 的ShaderProgram 及 Command。
- 在调用 scene.pick时, 会将当前 pass 通道的其它值改为false, pick改为true,更新pickFramebuffer, 及停止其他渲染工作。
- 绑定 pickFramebuffer, 执行 pickCommand, 执行 Draw Call, 将当前颜色编码通过 out_FragColor 输出到当前FBO上。
- 以当前鼠标点为中心, 调用 readPixels 读取长宽默认为 3 的正方形上的像素值, 然后通过螺旋扫描算法 寻找当前颜色编码对应的primitive。
虽然说 颜色编码 在GPU拾取中并不是最好的方案, 但Cesium本身从Real-Time Rendering角度架构设计, 对 颜色编码 做了很多优化方案: 诸如: Scissor Test, Culling Volumn等 弥补 颜色编码在超大场景中可能频繁Draw Call带来的通信成本。
下面来聊聊基于 深度缓存 的pickPosition:
- 从render开始, Cesium就会准备 pickDepthbuffer 来接收 片元着色器 输出的 gl_FragDepth (一般会处理成 logDepth 对数深度, 默认不包含透明物体的深度值)。
- 当Scene的 pickTranslucentDepth 设置为true时, Cesium 在处理 Current Frame 中的 commands的时候, 会重新写入深度缓存, 但大致的设计流程是和 pickObject一样的(透明物体的处理涉及到 MRT, OIT, Blend 等其它渲染内容, 打算后面仔细说说)。
- 从 depthBuffer 中获取深度值, 然后进行世界坐标的转换, 可以一起仔细看看这一块代码。
/**
* @private
*/
SceneTransforms.drawingBufferToWorldCoordinates = function (
scene,
drawingBufferPosition,
depth,
result,
) {
const context = scene.context;
const uniformState = context.uniformState;
const currentFrustum = uniformState.currentFrustum;
const near = currentFrustum.x;
const far = currentFrustum.y;
// 默认是开启 logDepth 来消除 z-fighting
if (scene.frameState.useLogDepth) {
// transforming logarithmic depth of form
// log2(z + 1) / log2( far + 1);
// to perspective form
// (far - far * near / z) / (far - near)
const log2Depth = depth * uniformState.log2FarDepthFromNearPlusOne;
const depthFromNear = Math.pow(2.0, log2Depth) - 1.0;
depth = (far * (1.0 - near / (depthFromNear + near))) / (far - near);
}
// 这里得到的深度值是由 gl_FragDepth 输出的标准设备坐标,
// 所以要有完整的 x, y, z 的NDC坐标去反推世界坐标
const viewport = scene.view.passState.viewport;
const ndc = Cartesian4.clone(Cartesian4.UNIT_W, scratchNDC);
ndc.x = ((drawingBufferPosition.x - viewport.x) / viewport.width) * 2.0 - 1.0;
ndc.y =
((drawingBufferPosition.y - viewport.y) / viewport.height) * 2.0 - 1.0;
ndc.z = depth * 2.0 - 1.0;
ndc.w = 1.0;
let worldCoords;
let frustum = scene.camera.frustum;
// 正交投影的变换
if (!defined(frustum.fovy)) {
const offCenterFrustum = frustum.offCenterFrustum;
if (defined(offCenterFrustum)) {
frustum = offCenterFrustum;
}
worldCoords = scratchWorldCoords;
// 由于正交投影是线性变换,
// 只需将NDC坐标从标准设备空间转换到正交视锥体即可,得到投影坐标
worldCoords.x =
(ndc.x * (frustum.right - frustum.left) + frustum.left + frustum.right) *
0.5;
worldCoords.y =
(ndc.y * (frustum.top - frustum.bottom) + frustum.bottom + frustum.top) *
0.5;
worldCoords.z = (ndc.z * (near - far) - near - far) * 0.5;
worldCoords.w = 1.0;
// 投影坐标 乘以 视图逆矩阵 得到 世界坐标
worldCoords = Matrix4.multiplyByVector(
uniformState.inverseView,
worldCoords,
worldCoords,
);
} else {
// 如果是透视投影, 直接与视图投影逆矩阵相乘, 得到世界坐标
worldCoords = Matrix4.multiplyByVector(
uniformState.inverseViewProjection,
ndc,
scratchWorldCoords,
);
// 将世界坐标转换为标准的笛卡尔坐标,
// 由于正交投影没有透视作用, w会一直为 1
// Reverse perspective divide
const w = 1.0 / worldCoords.w;
Cartesian3.multiplyByScalar(worldCoords, w, worldCoords);
}
return Cartesian3.fromCartesian4(worldCoords, result);
};
上面是从得到深度值到输出笛卡尔世界坐标的主要流程, 我们平时使用渲染引擎不会考虑到这些, 渲染引擎默认处理流程 世界坐标 -> 本地坐标 -> 投影坐标 -> NDC标准设备坐标, pickPosition 的坐标流程逆推。
这里提到了坐标转换, 本人向提一嘴之前写的一个library, 类似于 babylon 的 gizmo, 有兴趣的话可以研究一下, 点个star
下一篇打算聊聊 MRT 或 OIT