视差贴图、UV 偏移与 POM 命中

视差贴图、UV 偏移与 POM 命中:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能解释视差贴图怎么靠「按观察方向偏移纹理坐标」,让一面平墙在斜看时显出真实的凹凸深度和遮挡——近处的砖块凸起会挡住它后面的砖缝,比法线贴图更立体
  • 能在 Demo 里实现指定效果:斜看这面墙、调大深度让砖块凸起愈发明显、并在三档模式(关 / 基础视差 / 陡峭+POM)间切换,看出基础视差在陡处失真、陡峭+POM 的命中点更稳定
  • 能回答:视差效果为什么正对着看几乎无效、斜着看才明显?以及基础视差用在很深的高度图上为什么会断裂失真、陡峭视差是怎么修掉它的?

法线贴图只骗了光,这次连「你看到哪一点」都骗

上一章你给平墙贴了张法线图,砖缝在灯下凹下去、砖面鼓起来——可那其实只是光照上的假象:墙的几何还是一张纸。你只要把视线压低、贴着墙面斜着看过去,就会发现砖块根本没有「厚度」:该被前面砖块挡住的砖缝,照样露在外面;轮廓边缘还是齐刷刷一条直线,平得没有半点立体。

问题出在哪?法线贴图只改了「这一点该有多亮」,却没改「你斜看时该看到表面的哪一点」。真实的凹凸墙斜看时,近处凸起的砖块会挡住它后面凹陷的砖缝——这种「近的遮住远的」才是立体感的关键,而法线贴图完全没有它。

这一章要解决的就是这件事:怎么不加一个三角形,让一面平墙在斜看时不光有明暗、还显出更可信的深度和遮挡错觉。办法是再准备一张图存「每点的高低深浅」,然后按你的观察方向偏移采样的纹理坐标,让平面「假装」自己有那样的凹凸。没有它,你的墙斜看永远是张平纸;有了它,平墙也能斜看见砖块的厚度感。

高度图:一张灰度图存「表面的高低深浅」

视差贴图要用一张新图:(height map)。它和法线图一样按 UV 采样、一样不存颜色,但它每个像素只存一个灰度值,表示「这一点的表面是高是低、是深是浅」。

灰度的含义可以有两种约定,本章跟随原书把它当深度图用:白=深(这一点凹得最深)、黑=浅(接近顶面)。于是砖缝处偏白(凹下去)、砖面处偏黑(鼓在外)。后面 demo 里这张高度图不用外部贴图,而是在片段着色器里解析地算出来——给定一个 UV,直接算出该处砖块该有的深度,效果等同于采样一张真实的高度图。

视差贴图:按观察方向偏移纹理坐标

有了深度,(parallax mapping)的核心思想就一句话:渲染每个片元时,先按你的观察方向把要采样的纹理坐标偏移一点,再用偏移后的坐标去采样

为什么偏移就能造出深度?想象斜着看一面真实凹凸的墙:你的视线本该打在平面的某点 A,但表面在更靠近你的地方先凸起、把视线挡住了,于是你实际看到的是另一点 B 的纹理。视差贴图就照着这个几何关系,把采样坐标从 A 沿视方向挪到 B——平面于是「假装」自己在 B 处有那样的凸起。下面这张侧视图把这个偏移画清楚:

视差偏移:把采样点从 A 沿视方向挪到 B几何平面(着色器以为表面是平的)真实表面轮廓(高度图里存的凹凸深度)眼睛A原采样点(没修正)B真正该看到的点(凸起先挡住视线)偏移量 offset视线越斜,A 与 B 相差越大、视差越明显;正对着看时 A≈B,视差几乎无效
侧视图:视线斜射到平面,本该采样 A(红,几何交点);可表面真实轮廓在 B(绿)处先凸起挡住了视线,眼睛真正该看到的是 B 的纹理。视差偏移就是沿视方向把采样坐标从 A 挪到 B(位移量 offset),让平面斜看时显出真实的凹凸深度与遮挡——这正是法线贴图做不到的「连看到哪一点都骗」。

它和法线贴图一样工作在切线空间、一样用视方向(这两个概念上一章已经讲透,这里直接接着用):视差偏移就是把切线空间里的视方向投影到表面平面、按高度缩放,得到一段 UV 偏移量。它作用在片段着色器最前面——先偏移 UV,再拿偏移后的 UV 去采样颜色图、法线图做后续光照。关键差别在于:法线贴图只改了「这点多亮」,视差贴图还会改「你采样哪一点」,所以能造出近处挡住远处的遮挡错觉。但它没有移动顶点,物体外轮廓仍是原来的几何。

为什么基础视差不够:陡处会失真

最朴素的做法(基础视差)是:在当前 UV 处采一次高度,按这个高度一步算出偏移量,挪一下就完事。这在表面起伏平缓时够用、又快又简单。可一旦高度图很「陡」(深度大、坡很急),一步偏移就会估歪——它假设从当前点到目标点之间表面是平的,但陡坡上根本不平,偏移量算多算少都会让纹理断裂、游泳,凸起和凹陷接不上。

修法是把「一步到位」换成「一步步逼近」。(steep parallax mapping)把深度切成几层水平面,让视线从顶面沿观察方向一层层往深处探,每到一层就采样高度图比一下,直到视线钻到表面以下才停。下面用 Stepper 单步走一遍这个分层步进:

分步1 / 4

① 分层:把深度切成几层,视线沿视方向往深处走

顶面(深度 0)0123深度 1真实表面深度(高度图)入口把深度切成几层,视线沿视方向往深处走,每下一层横向偏移一点 UV
陡峭视差把深度切成等距的层,视线沿视方向一层层下探:每层采样高度图比较「层深度 vs 该处表面深度」,直到层深度首次表面深度=命中视差遮蔽映射 POM 再在命中层与上一层之间线性插值,把命中点磨得更平滑。

把表面下 0~1 的深度切成几条等距的层。视线从顶面入口沿观察方向往深处走,每往下走一层,采样坐标就横向偏移一点。

视差遮蔽映射 POM:把命中点磨平滑

陡峭视差只能停在某一层、取整层的坐标,层数少时会有「台阶感」。(Parallax Occlusion Mapping)在陡峭视差的基础上多做一步:命中层和它的上一层之间,真实交点其实在中间某处——拿这两层各自「层深度与表面深度之差」做一次线性插值,算出更接近真实交点的坐标。代价只多一点点,台阶感却被磨平,是三档里效果最好的(上面 Stepper 第 ④ 步画的就是它)。

最后还有个收尾问题:偏移后的 UV 可能被推出 [0,1] 范围,导致边缘出现拉伸或重复的假象。常见做法是偏移后 UV 越界就 discard 丢弃片元,让边缘干脆缺一块也比拉花强——这一点放到 §7 误区里细说。

动手:斜着看,让砖块凸起挡住后面

猜一猜:下面是一面贴了「砖块凹凸」的表面,你可以调倾斜角斜着去看它。先把模式调到「关」——表面是平的;切到「基础视差」或「陡峭+POM」,再把倾斜角拖大、深度拖深,你觉得砖块会怎样?正对着看(倾斜角=0)和斜着看,哪种视差效果更明显?先动手,再读下面。

这面墙在片段着色器里逐像素算出来:先解析地算出每个像素处「砖块」的高度(这就相当于一张程序化的高度图,不用任何外部贴图),再按当前模式做视差偏移、用偏移后的坐标取砖块颜色和明暗。表面正对你、切线空间是平凡的,所以视方向的 xy 分量直接就是偏移方向——把真实场景才需要的 TBN 换算留到 §6。三个控件:倾斜角(视线斜看的角度,0=正对、越大越斜);深度(砖块凹凸的深浅,0=平、越大越深);模式(关 / 基础视差 / 陡峭+POM,看三档差异):

可交互

实时演示加载中…

谜底:模式调到时,无论怎么斜看,墙都是张平纸——砖纹只是「印」上去的,没有厚度。切到基础视差、再把倾斜角和深度都拖大,砖块开始有凸起感,但盯着砖缝看会发现纹理在陡处断裂、游来游去——这就是一步偏移在陡坡上估歪的后果。切到陡峭+POM,同样的斜看和深度下,凸起干净利落、近处砖块正确挡住后面的砖缝,立体感稳稳出来。再把倾斜角拖回 0(正对着看):三档几乎看不出差别——这印证了那条「视差正看几乎无效、斜看才明显」:偏移量正比于视方向的 xy 分量,正看时 xy≈0,偏移几乎为零。

一句话对比:法线贴图 vs 视差贴图

猜一猜:同一面平墙、同一张「砖块凹凸」信息。左边用法线贴图、右边用视差贴图。斜着看过去,哪一边的砖块会真的挡住它后面的砖缝?两者骗的到底是什么不一样?

把滑块拖到中线两侧对照:法线贴图只改「这点多亮」,斜看时轮廓还是平的、砖缝照样露在外面;视差贴图连「你该看到哪一点」都改,斜看时近处凸起真的遮住后面——这就是它更立体的原因:

视差贴图
按观察方向偏移采样坐标、改「你看到哪一点」。斜着看,近处凸起的砖块产生挡住后面砖缝的错觉;但顶点和外轮廓仍然不变。
视差贴图(偏移纹理坐标 · 产生遮挡错觉)
法线贴图
只逐片元扰动法线、改「这点多亮」。凹凸全是光影假象——斜着看,轮廓还是齐刷刷一条平线,砖缝照样露在外面,没有遮挡。
法线贴图(只骗光照 · 轮廓还是平的)

代码对照:从基础视差到陡峭视差再到 POM

落到真代码,视差贴图就是在片段着色器最前面算出偏移后的 UV,再用它去采样颜色 / 法线图。下面三段从浅到深。先看基础视差:在当前 UV 采一次深度,按切线空间视方向 viewDirxy/z 一步偏移。viewDir 是上一章那个变进切线空间的视方向(指向眼睛),depthScale 控制凹凸深浅:

// 基础视差:当前 uv 采一次深度,按视方向 xy/z 一步偏移
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir) {
  float height = texture(depthMap, texCoords).r;  // 深度图:白=深
  // viewDir.xy / viewDir.z 把视方向投影到表面、按高度和缩放定偏移量
  vec2 p = viewDir.xy / viewDir.z * (height * depthScale);
  return texCoords - p;                           // 沿视方向把 uv 挪一步
}

注意那个 / viewDir.z:视线越斜,viewDir.z(朝表面外的分量)越小、偏移量越大,所以斜看视差才明显;正看时 viewDir.z≈1xy≈0,偏移几乎为零。一步偏移在陡处会估歪,于是换成陡峭视差——把深度分层、沿视方向一层层步进,直到层深度首次超过该处表面深度就停(命中):

// 陡峭视差:深度分 N 层,沿视方向逐层下探到命中层
const float numLayers = 16.0;
float layerDepth = 1.0 / numLayers;
float curLayerDepth = 0.0;
vec2 P = viewDir.xy / viewDir.z * depthScale;     // 总偏移方向 × 缩放
vec2 deltaUv = P / numLayers;                     // 每层挪一小段
vec2 curUv = texCoords;
float curDepth = texture(depthMap, curUv).r;
while (curLayerDepth < curDepth) {                // 层深度 < 表面深度就继续探
  curUv -= deltaUv;                               // 沿视方向再挪一层
  curDepth = texture(depthMap, curUv).r;
  curLayerDepth += layerDepth;
}

陡峭视差只能停在某一层,层少时有台阶感。POM 在命中层和上一层之间,用两层各自「层深度 − 表面深度」之差做线性插值,把命中点磨平滑:

// POM:命中层 curUv 与上一层 prevUv 之间线性插值精修
vec2 prevUv = curUv + deltaUv;                    // 回退到上一层
// 两层各自「层深度 - 表面深度」之差(命中后为正、命中前为负)
float afterDepth  = curDepth - curLayerDepth;
float beforeDepth = texture(depthMap, prevUv).r - (curLayerDepth - layerDepth);
float weight = afterDepth / (afterDepth - beforeDepth);
vec2 finalUv = mix(curUv, prevUv, weight);        // 按权重在两层间插值

拿到 finalUv(基础视差是 texCoords - p,陡峭是命中层 curUv,POM 是插值后的 finalUv)后,剩下的和上一章一模一样:用同一个偏移 UV 去采样颜色图、采样法线图解码、算 Blinn-Phong。颜色图偏移、法线图却仍用原 UV 会让光照和花纹错位。视差只是在所有采样之前先把 UV 挪到一个近似的命中点;POM 让这个近似更平滑,但并不改变顶点和轮廓。

容易踩的坑

小结

  • 视差贴图=渲染每个片元时,先按观察方向偏移采样的纹理坐标、再去采样,让平整几何在斜看时显出更可信的凹凸深度和遮挡错觉(近处凸起像是挡住后面),比只改光照的法线贴图更立体;它不改变顶点和外轮廓
  • 偏移量正比于切线空间视方向的 xy/z:所以视差斜看才明显、正看几乎无效(正看 xy≈0)
  • 基础视差只采一次、一步偏移,平缓表面够用;陡 / 深的高度图上会断裂游泳
  • 陡峭视差把深度分层、沿视方向逐层步进到命中层,修掉陡处失真;POM 再在命中层与上一层间线性插值,把台阶感磨平滑——三档里效果最好
  • 偏移后 UV 越界要 discard,否则边缘出现拉伸 / 重复的自遮挡瑕疵

练习

问题 1(改 Demo 代码型) 在上面那个 Demo 里,把它调成这样的目标效果:砖块凸起最立体、近处砖块的遮挡错觉最明显,且没有断裂失真。说出你把三个控件各调到什么方向,并解释为什么这组参数最能体现视差的效果。

问题 2(独立选型题 · C 型) 你要给三种表面分别选视差方案:(a) 一面起伏平缓的磨砂石膏墙;(b) 一块深沟壑的鹅卵石路面,要求斜看时石头之间有清晰遮挡错觉、不能明显失真;(c) 一个性能吃紧、但又想要鹅卵石那种最平滑遮挡错觉的手游场景。分别该选基础视差 / 陡峭视差 / 视差遮蔽映射 POM 中的哪个?为什么?

问题 3(最终 UV 一致性) 你已用 POM 得到 finalUv。片段着色器接下来要采样 diffuseMapnormalMap 并做法线贴图光照。请写出正确的两次采样,并说明为什么「颜色图用 finalUv、法线图却用原始 TexCoords」会出错;finalUv 越出 [0,1] 时,在一张孤立平面上可采取什么处理?

名词解释

本章出现的专业名词,用大白话再讲一遍。

高度贴图

一张灰度图,每个像素的明暗不是颜色,而是编码「这一点的表面高低 / 深浅」。本章按原书约定把它当深度图用:白=深(凹下去多)、黑=浅(接近顶面),所以砖缝偏白、砖面偏黑。视差贴图按这张图给的深度,沿观察方向偏移采样坐标。详见本章「高度图」一节。

视差贴图

一种在平整几何上伪造凹凸立体感的技术:渲染每个片元时,先按观察方向和该点的高度图深度,把要采样的纹理坐标偏移一点,再用偏移后的坐标采样。效果是平面斜看时显出真实的凹凸深度和遮挡——近处凸起挡住后面,比只改光照的法线贴图更立体。和法线贴图一样工作在切线空间、用视方向。详见本章「视差贴图」一节。

陡峭视差

基础视差的改进。基础视差一步偏移、陡坡上会估歪;陡峭视差把深度切成若干等距的层,让视线从顶面沿观察方向一层层往深处步进,每层采样高度图比较「当前层深度 vs 该处表面深度」,直到层深度首次超过表面深度=视线钻到表面以下=命中,命中层对应的偏移坐标就是采样点。逐层逼近比一步到位准得多。详见本章「为什么基础视差不够」一节。

视差遮蔽映射POM

Parallax Occlusion Mapping,陡峭视差的精修版。陡峭视差找到命中层后,真实交点其实落在命中层与上一层之间;POM 取这两层各自「层深度 − 表面深度」之差做线性插值,算出更接近真实交点的命中坐标,把陡峭视差的台阶感磨平滑。代价只多一次插值,是视差三档里效果最好的。详见本章「视差遮蔽映射 POM」一节。

自遮挡

斜看时近处凸起会自然挡住它后面较低的部分。视差偏移把采样坐标推出表面边界、或推到本该被遮住的区域时,若不丢弃就会露出错误拉伸 / 重复的纹理——这种瑕疵就叫自遮挡瑕疵。常见修法是偏移后 UV 越界就 discard 丢弃片元,让边缘缺一块也比拉花强。详见本章「容易踩的坑」一节。

版本、来源与运行边界

本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。

正式概念与状态责任

  • parallax mapping:在“视差贴图、UV 偏移与 POM 命中”中由fragment shader 的 UV/height-layer march负责解释其输入、受控状态和可观察结果;运行时以初始 UV、viewDir.z、每层深度/height、命中索引、最终 UV 与 discard 原因定位它的第一处变化。
  • height map:在“视差贴图、UV 偏移与 POM 命中”中由fragment shader 的 UV/height-layer march负责解释其输入、受控状态和可观察结果;运行时以初始 UV、viewDir.z、每层深度/height、命中索引、最终 UV 与 discard 原因定位它的第一处变化。
  • parallax occlusion:在“视差贴图、UV 偏移与 POM 命中”中由fragment shader 的 UV/height-layer march负责解释其输入、受控状态和可观察结果;运行时以初始 UV、viewDir.z、每层深度/height、命中索引、最终 UV 与 discard 原因定位它的第一处变化。

章专属 OpenGL 状态实验

先预测“从原 UV 分层前进,找到首次交叉并按需要做线性/POM 细化”发生后,fragment shader 的 UV/height-layer march应怎样改变tangent viewDir、height scale、layer depth、采样高度、UV offset 和命中层;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。

实验一:Context—资源—结果合同

选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。

Context · resource · observable result

视差贴图、UV 偏移与 POM 命中:状态合同

在 tangent space 沿 view direction 追踪 height map,得到可拒绝越界 UV 的命中点

验证场景

官方教程正式概念

logl-34 · 基线帧

parallax mapping固定 context、资源内容与输入事件,执行“从原 UV 分层前进,找到首次交叉并按需要做线性/POM 细化”

状态所有者fragment shader 的 UV/height-layer march
受控状态/资源tangent viewDir、height scale、layer depth、采样高度、UV offset 和命中层
触发命令从原 UV 分层前进,找到首次交叉并按需要做线性/POM 细化

冻结输入:parallax mapping

fragment shader 的 UV/height-layer march记录tangent viewDir、height scale、layer depth、采样高度、UV offset 和命中层

结果:得到可重复的初始 GL 状态与资源身份

观测:初始 UV、viewDir.z、每层深度/height、命中索引、最终 UV 与 discard 原因中的初始快照

预期:fragment shader 的 UV/height-layer march得到可复查结果,并持续满足“掠射角除法有下界;越出 [0,1] 的 UV 按材质合同 discard 或 clamp”

实验二:CPU 命令到 GPU 结果的五段轨迹

逐段执行“从原 UV 分层前进,找到首次交叉并按需要做线性/POM 细化”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“掠射角除法有下界;越出 [0,1] 的 UV 按材质合同 discard 或 clamp”。

CPU command · GL state · GPU result

视差贴图、UV 偏移与 POM 命中:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测:初始 UV、viewDir.z、每层深度/height、命中索引、最终 UV 与 discard 原因中的初始快照

不变量:掠射角除法有下界;越出 [0,1] 的 UV 按材质合同 discard 或 clamp

实验三:单故障与同输入恢复

注入“viewDir.z 接近 0 仍直接相除,UV 偏移爆炸并采到纹理另一侧”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有初始 UV、viewDir.z、每层深度/height、命中索引、最终 UV 与 discard 原因一起恢复才算修复。

Single fault · first divergence · replay

视差贴图、UV 偏移与 POM 命中:反例与恢复

故障:viewDir.z 接近 0 仍直接相除,UV 偏移爆炸并采到纹理另一侧

1. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“viewDir.z 接近 0 仍直接相除,UV 偏移爆炸并采到纹理另一侧”

3. 定位首差一致

掠射角除法有下界;越出 [0,1] 的 UV 按材质合同 discard 或 clamp

4. 清理并重放一致

初始 UV、viewDir.z、每层深度/height、命中索引、最终 UV 与 discard 原因

最小可重放检查

unit: logl-34
owner: fragment shader 的 UV/height-layer march
state_or_resource: tangent viewDir、height scale、layer depth、采样高度、UV offset 和命中层
command: 从原 UV 分层前进,找到首次交叉并按需要做线性/POM 细化
pass_invariant: 掠射角除法有下界;越出 [0,1] 的 UV 按材质合同 discard 或 clamp
single_fault: viewDir.z 接近 0 仍直接相除,UV 偏移爆炸并采到纹理另一侧
required_evidence: 初始 UV、viewDir.z、每层深度/height、命中索引、最终 UV 与 discard 原因

复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…