ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

图形学拾取:屏幕坐标逆变换与射线求交实战指南

图形学拾取:屏幕坐标逆变换与射线求交实战指南 剑英陪你玩转图形学 (三)归去来图形学里有一类特别有意思的问题就是“去而复返”。第三篇我起名叫《归去来》一开始是借了陶渊明那篇赋的名字后来发现用在坐标变换上意外贴切——模型从本地空间一路走到屏幕是“去”鼠标在窗口里点一下要反查出点中的是哪个物体、哪条边、哪个三角形是“归”。这俩动作看起来是一正一反但图形学里的“归”从来不是简单地把矩阵求个逆就完事中间藏着透视除法、深度约定、视口映射这些细节任何一个环节出错你点到的位置就会偏到十万八千里外。这一篇我打算把“去”与“归”这条链完整拆开来讲顺便带大家做一个屏幕点拾取的小实验一个旋转的彩色立方体鼠标点击哪个三角形哪个三角形就被高亮。这个场景听起来简单但把渲染管线、矩阵逆变换、射线与三角形求交全串起来了非常适合修完图形学实验一、刚把OpenGL环境跑通的同学继续往前推一步。当然如果你已经在CSDN或GitHub上被各种新旧代码折磨过这篇文章也能帮你把这些碎片拼回一张完整的地图。1. 图形学里的“去”与“回”到底指什么1.1 渲染管线本质上是一条单行道我读大学那会儿图形学实验一基本都是一个模板搭好OpenGL窗口创建一个三角形或立方体然后看着坐标写在代码里模型转一转完事。CSDN上这类代码一抓一大把但你有没有想过屏幕上那个三角形它的顶点坐标到底经历了什么一个简单的三角网格模型建模时顶点存储的通常是以物体自身中心为原点的局部坐标。渲染时要把它摆进一个更大的世界场景里就得乘上模型矩阵为了让相机决定“从哪个角度去看”又要乘上视图矩阵为了让近大远小、把视锥压缩成一个立方体还得乘上投影矩阵。乘完之后得到的是裁剪空间齐次坐标继续做透视除法也就是把 x、y、z 都除以 w得到范围在 [-1,1] 的 NDC 坐标。最后通过视口变换映射到窗口的像素坐标。我把这个过程叫作“去”。值得强调的是这里每一步矩阵乘法都更像是在“加工坐标”而不是简单的移动位置。模型矩阵可能包含平移、旋转、缩放视图矩阵把世界坐标换成以相机为原点的观察坐标投影矩阵则决定了哪些物体在视野内、哪些被裁剪掉。四个空间一环扣一环最后你才能在屏幕上看到一个正常不拉伸的三角形。这条链路的最大特点是它设计成了“正向流水线”。不是每个顶点都能一路走到屏幕上的——如果在裁剪空间超出了 w 的范围顶点会被丢弃或裁剪掉。这正是渲染管线的基本逻辑把数不尽的三角形丢进去最后只保留看得见的部分。可一旦你想做鼠标拾取、编辑器里的拖拽手柄、或者把像素坐标换算成世界坐标去做后期特效你就要逆着这条单向的流水线往回走这就是“归”。1.2 为什么要逆着渲染管线走“归”并不是为了好玩它直接对应一大类交互功能。最常见的场景就是鼠标拾取游戏里你点了屏幕上的一个敌人程序怎么知道点中了谁一个简单的做法是把每条三角形的世界坐标重新投影一遍比较哪个三角形离鼠标点最近但这个做法在复杂场景里效率很低工程上通常用射线拾取从相机出发穿过鼠标点击的那个屏幕位置生成一条射线然后去和场景中的几何体求交。生成这条射线的过程就要把鼠标的屏幕坐标逆变换回世界坐标。此外很多编辑器功能也依赖“回”。比如你想在三维场景里用鼠标拖动一个物体沿着地面移动就需要把鼠标所在屏幕坐标投射到地面上的一条射线计算射线与地面的交点。再比如阴影贴图、UV 反投影、把深度缓冲还原成三维坐标做屏幕空间效果全都建立在逆变换的基础之上。所以你在图形学实验里忽略“归”短期内似乎也能把画面画得挺好看但一旦涉及交互、涉及用户输入你就不得不把这条链彻底弄明白。这也是为什么我把第三篇的名字起成“归去来”真正想玩转图形学不能只会在正向管线里推着顶点往前走还得学会在需要的时候优雅地走回来。2. 核心矩阵拆解从本地坐标到像素坐标2.1 四个坐标空间之间的换算关系在动手写代码之前我建议先把四个空间和它们之间的对应关系刻在脑子里。图形学里天天念叨的模型空间、世界空间、观察空间、裁剪空间再往后接 NDC 和屏幕空间它们的核心作用可以浓缩成下面这张表空间代表坐标系说明关键矩阵/操作模型空间物体自身坐标顶点在建模软件里的原始位置模型矩阵 M世界空间场景统一坐标所有物体摆在一起的位置M 的作用结果观察空间以相机为原点相机看向 -Z 方向视图矩阵 V裁剪空间齐次坐标将视锥压成 [-w, w]投影矩阵 PNDC归一化设备坐标坐标系范围 [-1,1]透视除法 x/w, y/w, z/w屏幕空间窗口像素坐标从窗口左上角或左下角开始计数视口变换正变换的完整公式是clip P * V * M * localPos然后透视除法得到 NDC再做视口变换得到窗口坐标。每一次变换都是左乘一个矩阵这也就意味着如果你想从屏幕坐标回到世界坐标理论上只需要把每一步都反过来先做视口逆变换再做透视除法的逆操作然后左乘(P*V)的逆矩阵就能得到一个世界坐标点。但这里有个非常容易踩坑的地方透视除法本身不是线性变换它把坐标除以了 w。你从屏幕坐标反推 NDC 时只知道 x、y并不知道原来的 w 是多少。这也是很多刚入门的人做逆变换时得到一堆 NaN 的原因。后面我会说清楚怎么绕开这个坑。2.2 为什么矩阵可以“去”也可以“归”线性代数里一个方阵如果行列式不为零就存在逆矩阵。从几何直觉上讲矩阵表示的是对一个向量做缩放、旋转、错切这类线性变换而逆矩阵就是把这个变换“撤销”掉。旋转矩阵的逆就是反着转同样的角度缩放矩阵的逆就是把缩放系数取倒数平移矩阵的逆就是减去平移量。模型矩阵、视图矩阵、投影矩阵通常都是可逆的尤其是投影矩阵虽然它把视锥压成了长方体但它并没有丢掉原始的深度信息所以逆投影矩阵可以把一个 NDC 坐标还原回观察空间坐标。这也是为什么把(P*V)求逆是一个可行的做法。你可以把矩阵逆变换想象成一个“存档与读档”的过程正向渲染时每个顶点都从本地一路变换到屏幕相当于走了一条路逆变换就是为了拿到一个世界坐标或观察坐标你要从这个路的尽头一步步倒着走回去而逆矩阵就是每一步的“回程票”。只要中途没有发生不可逆的退化例如矩阵行列式为零、或者你把深度信息完全丢弃了回程就一定能走通。真正工程上需要留心的是各种坐标约定的差异。OpenGL 的 NDC 深度范围是 [-1,1]DirectX 的是 [0,1]窗口原点一个在左下角一个在左上角鼠标 API 返回的 y 坐标通常是向下增长的。这些差异叠加起来“归”的就不仅仅是数学还有一堆坐标系习惯。我见过无数代码数学算得挺对结果忘记把鼠标 y 轴翻一下拾取就偏到了地底下。2.3 一个经典的“回不去”例子法线变换“归去来”这个概念还能延伸到法线变换上。很多人不知道如果模型矩阵里包含了非均匀缩放那么顶点坐标可以乘模型矩阵来变换但法线不能直接乘模型矩阵。道理很简单法线本质上不是一个普通的位置向量它代表的是物体表面的方向。当一个表面沿 x 轴拉伸两倍时它的法线并不会简单地跟着拉伸否则法线就不再垂直于表面了。正确的做法是用模型矩阵的逆转置矩阵(M^-1)^T来变换法线。也就是说你要先求模型矩阵的逆再转置才能让法线在变换后依然垂直于表面。这算是“归去来”的一个隐藏关卡位置向量在“去”的时候乘 M法线在“去”的时候却要乘一个由 M 的逆派生的矩阵。我在帮学生调实验一作业时经常看到有人给物体加了缩放之后光照颜色变得很奇怪查来查去才发现是法线没走“逆转置”这条路。这也提醒我们图形学里很多看似对称的数学关系实际用起来都有一层容易忽略的条件。3. 实操做一个鼠标点选三角形的小实验3.1 实验目标与前置准备这个实验的目标是在之前画好的旋转立方体场景里用鼠标点击屏幕点击到的三角形会被高亮成白色其余三角形正常显示颜色。为了聚焦于“归去来”这个主题我把场景保持在最简单的层次一个立方体一个透视相机鼠标点击拾取。前置条件不复杂一个能跑的 OpenGL 环境加上 GLM 数学库。GLM 提供了glm::unProject可以直接完成部分逆变换但为了把原理讲透我会先展示手动实现的方式这样你也能在 shader 里写出类似逻辑。我假设你已经能画出一个带 MVPP 矩阵的立方体了。如果你还在实验一阶段建一个顶点缓冲对象用 Element Buffer 画 12 个三角形立方体 6 个面每个面两个三角形再挂一个简单的颜色 shader就可以了。下面我从正向链路的代码开始把整个拾取流程串起来。3.2 正向链路把立方体的每个顶点送到屏幕上虽然你已经会画立方体了但为了后面对照我还是要先明确一下正向变换里每个矩阵的作用。透视相机一般这么设置glm::mat4 view glm::lookAt(cameraPos, cameraTarget, cameraUp); glm::mat4 proj glm::perspective( glm::radians(45.0f), // 纵向视角 (float)width / height, // 宽高比 0.1f, // 近裁剪面 100.0f // 远裁剪面 ); glm::mat4 model glm::mat4(1.0f);顶点着色器里的核心代码就是#version 330 core layout (location 0) in vec3 aPos; uniform mat4 model; uniform mat4 view; uniform mat4 proj; void main() { gl_Position proj * view * model * vec4(aPos, 1.0); }这段代码太常见了以至于很多人忽略了它背后做的一连串坐标搬家aPos 是模型空间坐标乘 model 变成世界坐标乘 view 变成观察空间坐标乘 proj 变成裁剪空间齐次坐标然后固定管线自动做透视除法得到 NDC 坐标。最后窗口系统根据视口设置把它映射到屏幕上的像素位置。我在这里想提醒一个细节裁剪空间坐标要求-w x,y,z w而w在透视投影下等于-viewZ观察空间下的深度值。也就是说离相机越远的点w 越大裁剪空间的坐标范围也越“宽容”这正是透视投影压缩视锥的方式。理解这一点后面做屏幕拾取时你才能明白为什么不能简单地在 NDC 里直接线性映射。3.3 反向链路从鼠标位置还原出世界空间射线现在进入重头戏。当鼠标点击窗口的某个像素位置(mouseX, mouseY)时我们需要构造一条从相机出发、穿过该像素的射线。标准的做法分两步第一步把屏幕坐标还原成 NDC 坐标第二步用逆 VP 矩阵把 NDC 坐标还原成世界坐标。先把鼠标坐标转换成 NDC。大多数鼠标 API 返回的屏幕坐标原点在窗口左上角y 轴向下而 OpenGL 的 NDC 原点在窗口中心y 轴向上。所以float ndcX (2.0f * mouseX) / width - 1.0f; float ndcY 1.0f - (2.0f * mouseY) / height;这里width和height是窗口的像素尺寸注意要用实际的 framebuffer size很多人在高DPI 屏幕上因为这个踩过坑后面我还会提。现在我们得到了鼠标在近裁剪面上的 NDC 点。要生成射线我们还需要远裁剪面上的另一个点或者说我们需要两个点来定义射线方向。常见的做法是取 NDC 的 z 等于 -1近面和 1远面。glm::vec4 ndcNear(ndcX, ndcY, -1.0f, 1.0f); glm::vec4 ndcFar(ndcX, ndcY, 1.0f, 1.0f);然后把这两个点从 NDC 逆变换回世界坐标。这里要特别注意向量要回到裁剪空间再除以 w才能得到正确的三维坐标。因为 NDC 坐标实际上就是裁剪空间坐标除以 w 之后的结果所以我们把ndcNear乘上逆 VP 矩阵得到的是一个裁剪空间的齐次点还要再除以它自己的 w 分量才真正回到观察空间或世界空间的坐标。如果你用glm::unProject这个库已经帮你处理好了这些细节glm::vec3 worldNear glm::unProject( glm::vec3(mouseX, height - mouseY, 0.0f), view, proj, glm::vec4(0.0f, 0.0f, width, height) ); glm::vec3 worldFar glm::unProject( glm::vec3(mouseX, height - mouseY, 1.0f), view, proj, glm::vec4(0.0f, 0.0f, width, height) ); glm::vec3 rayDir glm::normalize(worldFar - worldNear); glm::vec3 rayOrigin worldNear;注意glm::unProject里我传入的鼠标 y 坐标是height - mouseY因为 GLM 默认视口原点在左下角而我们的鼠标坐标原点在左上角。如果你搞反了射线会在垂直方向上完全反向。这也是使用第三方数学库时最容易忽略的约定问题。如果你想不依赖 GLM 的 unProject自己写也很简单核心逻辑就是一个手动 unprojectglm::vec3 manualUnProject(glm::vec3 winPos, const glm::mat4 view, const glm::mat4 proj, const glm::vec4 viewport) { glm::mat4 invVP glm::inverse(proj * view); glm::vec4 ndc; ndc.x (2.0f * (winPos.x - viewport.x)) / viewport.z - 1.0f; ndc.y (2.0f * (winPos.y - viewport.y)) / viewport.w - 1.0f; ndc.z 2.0f * winPos.z - 1.0f; ndc.w 1.0f; glm::vec4 clip invVP * ndc; return glm::vec3(clip) / clip.w; }这个手动版本对理解原理非常友好先做视口逆变换得到 NDC再做逆 VP 矩阵变换得到裁剪空间齐次坐标最后除以 w。代码里的clip.w就是那个关键步骤去掉它你就等着接 NaN 吧。3.4 射线与三角形求交Möller–Trumbore 算法有了射线接下来就是几何求交。场景里的立方体本质上是一堆三角形所以我们要遍历所有 36 个顶点组成的 12 个三角形判断射线是否穿过某个三角形。判断方法最常用的是 Möller–Trumbore 算法它基于重心坐标一次就能判出交点效率很高。bool rayTriangleIntersect(const glm::vec3 ro, const glm::vec3 rd, const glm::vec3 v0, const glm::vec3 v1, const glm::vec3 v2, float t, float u, float v) { const float eps 1e-8f; glm::vec3 e1 v1 - v0; glm::vec3 e2 v2 - v0; glm::vec3 p glm::cross(rd, e2); float det glm::dot(e1, p); if (fabs(det) eps) return false; // 射线与三角形平行 float invDet 1.0f / det; glm::vec3 s ro - v0; u glm::dot(s, p) * invDet; if (u 0.0f || u 1.0f) return false; glm::vec3 q glm::cross(s, e1); v glm::dot(rd, q) * invDet; if (v 0.0f || u v 1.0f) return false; t glm::dot(e2, q) * invDet; return t eps; }算法的内部逻辑就是解一个线性方程组ro t * rd v0 u * e1 v * e2。t表示射线从起点出发走了多远u和v是三角形上的重心坐标只要u 0、v 0、uv 1交点就在三角形内部。我第一次看这个算法时觉得它像黑魔法后来发现它不过是在用混合积重写克莱姆法则数学底子够的话很容易推导出来。需要注意一点做拾取时要记得把射线的远近范围限制在t 0和t 远裁剪面距离之间否则你可能会捡到相机背后的三角形或者捡到远到根本看不见的三角形。在实际项目中这里还会配合各种加速结构比如 BVH、八叉树但对一个立方体来说暴力遍历完全够用。3.5 组装让鼠标点击变成高亮反馈拾取流程的最后一步是把整条链组装起来。当鼠标点击时执行以下步骤根据鼠标像素坐标用逆 VP 矩阵生成近点和远点的世界坐标得到射线原点和方向。遍历所有三角形用 Möller–Trumbore 算法求交。在所有相交结果中选取t值最小的那个三角形也就是离相机最近的它就是用户点中的目标。把该三角形的索引记录到 uniforms 里渲染时判断当前三角形是否是要高亮的目标如果是就用白色否则用原色。渲染高亮也很简单把三角形索引数组上传为一个 uniform int 数组或者直接在 CPU 侧修改要绘制的颜色再更新顶点缓冲。对立方体这种小规模模型CPU 修改是完全没有压力的。我在自己的实验环境里跑了一遍完整流程整体效果就是旋转立方体时鼠标点哪个面哪个面就闪白。看起来简简单单但它背后同时用到了正向渲染的全部矩阵知识和逆向推理能力。我觉得这是对“图形学实验一画个立方体”最有价值的延伸之一。4. 常见问题与排查技巧实录4.1 逆矩阵得到的结果全是 NaN 或无穷大这是我见过最多的问题几乎每个第一次写逆变换的同学都会撞上。原因通常有几种一是投影矩阵或视图矩阵不可逆比如你把近裁剪面设成了 0或者某个缩放分量设成了 0二是你用了glm::inverse的结果但忘了除以 w 分量三是你把 NDC 坐标的 z 直接设成了深度缓冲里的值但这个值在某些平台上是 [0,1]在另一些格式下是 [-1,1]算出来自然不对。排查方法我建议先写一个最小测试把一个已知的世界坐标点用 MVP 矩阵正变换到屏幕坐标再立刻用逆 VP 矩阵变换回来看能不能还原。如果还原不了把中间每一步的数值打印出来通常一眼就能看出是哪一步出了问题。这个测试的做法本身也很有价值相当于给你的坐标变换代码做了一次单元测试。4.2 射线方向反了鼠标点上面拾取到下面这类问题九成是坐标系的 y 轴没处理好。窗口坐标系的 y 轴是向下的而 OpenGL 的 NDC y 轴是向上的。如果你直接把鼠标的mouseY当成 NDC y射线就会上下颠倒。用glm::unProject时记得传入viewportHeight - mouseY用手动 unproject 时ndc.y 1.0f - 2.0f * mouseY / height。我以前在一篇文章里看到把鼠标坐标直接喂给glm::unProject代码跑起来也没报错但结果永远差半截这就是典型的“看文档不仔细”。GLM 的视口参数用的是左下角原点和常见的窗口事件坐标不一样一定要翻一下。4.3 拾取精度差点小物体非常吃力立方体足够大所以这个实验里不会遇到明显的精度问题。但如果你把同样的思路挪到实际项目里点一个小按钮或者细长的电线杆射线拾取就会暴露出一个核心问题像素误差被放大了。屏幕上一个像素可能对应世界空间里很细的一条锥远距离下误差非常显著。对策有几个方向用更高分辨率的深度缓冲区做 GPU 拾取用面积更大的包围盒做预判或者使用“射线与包围盒求交优先命中了再和你关心的三角形求交”的层级策略。工程上还会额外给三角形一个很小的厚度容差避免因为浮点计算误差导致点在三角形边缘时捡不到。我在做编辑器工具时经常给拾取射线加一个很小的锥角半径让范围内的物体都可以被选中这种“宽容拾取”能极大改善操作手感。4.4 视口大小和实际帧缓冲大小不一致高 DPI 屏、窗口缩放、以及显卡驱动对缩放的处理都会导致逻辑窗口大小和实际渲染的像素数量不一致。如果你用窗口大小做逆变换但渲染目标实际有 2 倍或 3 倍的像素那么你的 NDC 坐标就会整体偏移。这个问题在 Windows 上特别常见因为系统级缩放默认是 125% 或 150%。解决办法是每次渲染时向 OpenGL 查询实际的帧缓冲大小也就是glGetRenderbufferParameteriv或glfwGetFramebufferSize而不是用glfwGetWindowSize。如果你用了 Qt、或者 OF 框架也要特别注意区分“逻辑尺寸”和“物理像素”。我在这个坑上浪费过一下午排查到最后发现不是算法错了是尺寸来源错了。4.5 OpenGL 和 DirectX 的 NDC 差异如果你是两边都写的选手这里有一个高频事故点OpenGL 的 NDC 深度范围是 [-1,1]DirectX 是 [0,1]所以同一套逆变换代码从 OpenGL 搬到 DirectX 时z 的映射必须改。很多跨平台渲染代码库都会为此做一层包装把深度范围统一成 [0,1] 或者记录一个 convention 标记。此外OpenGL 的 NDC y 轴向上DirectX 的 y 轴也向上只是深度方向不同但很多 UI 框架传入屏幕坐标时都是 y 向下所以无论在哪个 API 里你都要在接入底层渲染 API 之前把 y 轴翻转一次。把这些约定搞清楚才能保证你的“归”在任何一个渲染后端上都能走通。5. 从实验一到实际项目几点个人体会5.1 图形学实验不是“调参游戏”我知道很多人在做实验一时都是照着教程把代码敲一遍看到立方体动了就觉得自己会了。其实“画面能跑”和“理解原理”之间隔着一条巨大的鸿沟。我的建议是每做完一个实验就给自己出一个逆向题目比如这个立方体旋转时你能不能通过鼠标点击让某个角变红这种“逆着渲染管线想一次”的训练比单纯调形状、调颜色要有价值得多。我在线下带实验课时判断学生是否真的懂矩阵从来不问他矩阵怎么乘而是给他一个屏幕坐标让他手工算出去射线方向。能算出来的说明他脑子里真有一张坐标空间地图算不出来的往往正变换背得再熟只需要换个方向问就露馅了。图形学这门课说白了就是训练你在不同坐标系之间自由切换的能力。5.2 一个逆向思考的练习如果你想把“归去来”玩得更深我建议你试试这几个变体练习不用glm::unProject自己写逆变换函数并用精确到小数的坐标验证结果。把透视投影改成正交投影看看射线拾取哪里会有不同。做一个“地面网格拾取”射线和 y0 的平面求交让鼠标点哪一个小球就移动到哪。这个练习能帮你熟悉“射线与平面求交”的套路编辑器里拖拽物体的常用方案。试着把 NDC 坐标还原成观察空间坐标而不是世界空间坐标看看有什么区别。这些都是同一套数学思想的延伸。我自己当年练完这些之后再看图形学论文里的各种反投影技巧感觉明显顺畅多了。5.3 “归去来”不止是拾取最后再聊一个更大的视角图像渲染本身就是一个“去”的过程而很多高级效果都在“归”上。比如延迟渲染里的 G-Buffer就是把各种几何信息写入纹理后续光照计算时再从纹理里读取还原出世界坐标屏幕空间反射也是从深度缓冲中重建像素位置再做射线步进。你可以把整个渲染管线想象成一列单向行驶的火车但聪明的工程师会时不时下车从铁轨的另一端走回去拾起有用的信息再回来。所以这篇文章里讲的屏幕拾取虽然看起来只是一个小交互功能但它其实给你打开了一扇门理解逆变换就是理解很多现代渲染技术的起点。往后的阴影贴图、SSAO、体积光、GI 里到处都有“去而复归”的影子。我把这套东西彻底想明白其实已经很晚了是在工作后被一个实时拾取 bug 逼着重新翻开线性代数教材才补上的课。如果你现在还在做图形学实验一、二那正好趁课程压力不大把这个“归去来”的小实验写一遍后面省下的时间绝对值得。
返回列表