视差贴图、UV 偏移与 POM 命中
视差贴图、UV 偏移与 POM 命中:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释视差贴图怎么靠「按观察方向偏移纹理坐标」,让一面平墙在斜看时显出真实的凹凸深度和遮挡——近处的砖块凸起会挡住它后面的砖缝,比法线贴图更立体
- 能在 Demo 里实现指定效果:斜看这面墙、调大深度让砖块凸起愈发明显、并在三档模式(关 / 基础视差 / 陡峭+POM)间切换,看出基础视差在陡处失真、陡峭+POM 的命中点更稳定
- 能回答:视差效果为什么正对着看几乎无效、斜着看才明显?以及基础视差用在很深的高度图上为什么会断裂失真、陡峭视差是怎么修掉它的?
法线贴图只骗了光,这次连「你看到哪一点」都骗
上一章你给平墙贴了张法线图,砖缝在灯下凹下去、砖面鼓起来——可那其实只是光照上的假象:墙的几何还是一张纸。你只要把视线压低、贴着墙面斜着看过去,就会发现砖块根本没有「厚度」:该被前面砖块挡住的砖缝,照样露在外面;轮廓边缘还是齐刷刷一条直线,平得没有半点立体。
问题出在哪?法线贴图只改了「这一点该有多亮」,却没改「你斜看时该看到表面的哪一点」。真实的凹凸墙斜看时,近处凸起的砖块会挡住它后面凹陷的砖缝——这种「近的遮住远的」才是立体感的关键,而法线贴图完全没有它。
这一章要解决的就是这件事:怎么不加一个三角形,让一面平墙在斜看时不光有明暗、还显出更可信的深度和遮挡错觉。办法是再准备一张图存「每点的高低深浅」,然后按你的观察方向偏移采样的纹理坐标,让平面「假装」自己有那样的凹凸。没有它,你的墙斜看永远是张平纸;有了它,平墙也能斜看见砖块的厚度感。
高度图:一张灰度图存「表面的高低深浅」
视差贴图要用一张新图:↡一张灰度图,每个像素的明暗不是颜色,而是编码了「这一点的表面高低 / 深浅」。本章按 LearnOpenGL 的约定把它当「深度图」用——白=深(凹下去多)、黑=浅(接近顶面),所以砖缝处偏白、砖面处偏黑。视差贴图按这张图给出的深度,沿观察方向偏移采样坐标。(height map)。它和法线图一样按 UV 采样、一样不存颜色,但它每个像素只存一个灰度值,表示「这一点的表面是高是低、是深是浅」。
灰度的含义可以有两种约定,本章跟随原书把它当深度图用:白=深(这一点凹得最深)、黑=浅(接近顶面)。于是砖缝处偏白(凹下去)、砖面处偏黑(鼓在外)。后面 demo 里这张高度图不用外部贴图,而是在片段着色器里解析地算出来——给定一个 UV,直接算出该处砖块该有的深度,效果等同于采样一张真实的高度图。
视差贴图:按观察方向偏移纹理坐标
有了深度,↡一种在平整几何上伪造凹凸立体感的技术:渲染每个片元时,先按观察方向和该点的高度图深度,把要采样的纹理坐标偏移一点,再用偏移后的坐标去采样颜色 / 法线。效果是平面斜看时显出真实的凹凸深度和遮挡——近处凸起会挡住它后面的部分,比只改光照的法线贴图更立体。它和法线贴图一样工作在切线空间、用视方向做偏移。(parallax mapping)的核心思想就一句话:渲染每个片元时,先按你的观察方向把要采样的纹理坐标偏移一点,再用偏移后的坐标去采样。
为什么偏移就能造出深度?想象斜着看一面真实凹凸的墙:你的视线本该打在平面的某点 A,但表面在更靠近你的地方先凸起、把视线挡住了,于是你实际看到的是另一点 B 的纹理。视差贴图就照着这个几何关系,把采样坐标从 A 沿视方向挪到 B——平面于是「假装」自己在 B 处有那样的凸起。下面这张侧视图把这个偏移画清楚:
offset),让平面斜看时显出真实的凹凸深度与遮挡——这正是法线贴图做不到的「连看到哪一点都骗」。它和法线贴图一样工作在切线空间、一样用视方向(这两个概念上一章已经讲透,这里直接接着用):视差偏移就是把切线空间里的视方向投影到表面平面、按高度缩放,得到一段 UV 偏移量。它作用在片段着色器最前面——先偏移 UV,再拿偏移后的 UV 去采样颜色图、法线图做后续光照。关键差别在于:法线贴图只改了「这点多亮」,视差贴图还会改「你采样哪一点」,所以能造出近处挡住远处的遮挡错觉。但它没有移动顶点,物体外轮廓仍是原来的几何。
为什么基础视差不够:陡处会失真
最朴素的做法(基础视差)是:在当前 UV 处采一次高度,按这个高度一步算出偏移量,挪一下就完事。这在表面起伏平缓时够用、又快又简单。可一旦高度图很「陡」(深度大、坡很急),一步偏移就会估歪——它假设从当前点到目标点之间表面是平的,但陡坡上根本不平,偏移量算多算少都会让纹理断裂、游泳,凸起和凹陷接不上。
修法是把「一步到位」换成「一步步逼近」。↡基础视差的改进:把表面深度切成若干等距的层,让视线从顶面沿观察方向一层层往深处步进,每到一层就采样高度图比较「当前层深度 vs 该处表面深度」,直到层深度首次超过表面深度=视线钻到表面以下=命中,命中层对应的偏移坐标就是采样点。逐层逼近比基础视差一步到位准得多,陡坡上也不失真。(steep parallax mapping)把深度切成几层水平面,让视线从顶面沿观察方向一层层往深处探,每到一层就采样高度图比一下,直到视线钻到表面以下才停。下面用 Stepper 单步走一遍这个分层步进:
① 分层:把深度切成几层,视线沿视方向往深处走
把表面下 0~1
的深度切成几条等距的层。视线从顶面入口沿观察方向往深处走,每往下走一层,采样坐标就横向偏移一点。
视差遮蔽映射 POM:把命中点磨平滑
陡峭视差只能停在某一层、取整层的坐标,层数少时会有「台阶感」。↡Parallax Occlusion Mapping,陡峭视差的精修版:陡峭视差找到命中层后,真实交点其实落在命中层与上一层之间,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 采一次深度,按切线空间视方向 viewDir 的 xy/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 挪一步
}// 基础视差:当前 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≈1、xy≈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;
}// 陡峭视差:深度分 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;
for (int i = 0; i < 32; i++) { // WebGL2 循环上界须常量
if (curLayerDepth >= curDepth) break; // 命中即停
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); // 按权重在两层间插值// 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。片段着色器接下来要采样 diffuseMap、normalMap 并做法线贴图光照。请写出正确的两次采样,并说明为什么「颜色图用 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 细化”
冻结输入: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 命中:五段轨迹
当前观测:初始 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 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“viewDir.z 接近 0 仍直接相除,UV 偏移爆炸并采到纹理另一侧”
掠射角除法有下界;越出 [0,1] 的 UV 按材质合同 discard 或 clamp
初始 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 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。