← ALL ARTICLES
纹理技术11 MIN READ

视差映射

全库目录221
本文目录15

给一块砖墙平面贴上颜色和法线,斜看时砖缝仍停在原来的位置。我们让观察射线进入砖缝的高度层,找到交点,再把这一个交点交给材质采样和朝光查询。一张平面纹理可以描述颜色,也可以描述表面的局部形状。法线贴图改变着色方向,视差映射移动纹理坐标,位移映射才真正改变几何位置。三者解决的问题和产生的遮挡不同。

高度图、深度图和法线贴图

高度图(Height Map)保存表面相对基准面的高度。常见约定是白色高、黑色低。深度图在视差算法中常表示从表面顶层向内的距离,因此可能使用:

d(u,v)=1−h(u,v)d(u,v)=1-h(u,v)

资产和 Shader 必须使用同一约定。约定相反时,凸起会显示成凹陷。

法线贴图保存的是局部表面方向。它能改变漫反射和高光,却不能改变纹理坐标,也不能产生真实轮廓和几何遮挡。高度图可以离线转换成法线贴图,但法线积分回高度通常没有唯一解。

切线空间与UV尺度

UV、高度图和法线贴图都附着在表面的切线坐标系中。这里把观察方向 V\mathbf V 定义为从表面指向相机,并让切线空间的 Vz>0V_z>0。变换后:

  • Vx,VyV_x,V_y 表示视线沿纹理平面的移动方向;
  • VzV_z 表示视线离开表面的程度;
  • UV 偏移可以直接沿 VxyV_{xy} 计算。

TBN 的生成规则、镜像 UV 手性和法线贴图 Y 轴约定仍由 法线贴图、IBL、Cubemap环境采样 负责。视差和法线必须采样同一组偏移后的 UV,否则凹凸位置会错开。

沿视线寻找高度层交点

基础视差映射

把表面看成厚度为 ss 的高度层。使用一次高度采样估计视线在层内走过的距离:

Δuv=Vxymax⁡(Vz,ε)d(u,v)s\Delta \mathbf{uv}=\frac{\mathbf V_{xy}}{\max(V_z,\varepsilon)}d(u,v)s uv′=uv−Δuv\mathbf{uv}'=\mathbf{uv}-\Delta\mathbf{uv}

ss是归一化深度走完一层时对应的UV缩放,不直接等同于世界高度。若T/B为单位正交方向,一单位UV分别覆盖L_u、L_v米,物理厚度为H米,就应分别使用H/L_u、H/L_v;UV存在剪切时需要完整表面参数化的度量。下述标量heightScale假定两方向单位尺度相同。ε\varepsilon 防止掠射角除以接近零的 VzV_z。减号取决于观察向量定义和纹理坐标方向,结果反向时应先检查空间和符号。

下面的 GLSL 函数使用白色为高处的高度图,输入观察方向已经归一化到切线空间:

glsl
vec2 ParallaxUV(
    sampler2D heightMap,
    vec2 uv,
    vec3 viewDirTS,
    float heightScale)
{
    float depth = 1.0 - texture(heightMap, uv).r;
    float safeZ = max(viewDirTS.z, 0.05);
    vec2 ray = viewDirTS.xy / safeZ;
    return uv - ray * depth * heightScale;
}

这段代码只有一次高度采样,适合较浅、变化平缓的表面。高度变化大时,一次采样无法找到视线与高度场的真实交点。取一维截面,起点u=0.6,整层位移rayUV=0.2,深度场D(u)=u。一次采样得到深度0.6,于是返回u=0.48;但这个位置的表面深度是0.48,射线已经走到0.6,二者并不相交。真实交点满足t=D(0.6−0.2t),解得t=0.5、u=0.5。错误来自用起点的深度代替了沿途不断改变的深度。逐层采样正是为了重新比较这两份数据。

Offset Limiting

基础公式在掠射角会因为 1/Vz1/V_z 产生很大的偏移。Offset Limiting 省略除法,直接使用 VxyV_{xy}:

Δuv=Vxyd(u,v)s\Delta \mathbf{uv}=\mathbf V_{xy}d(u,v)s

偏移更稳定,斜视时的深度感也会减弱。它适合高度很浅、稳定性优先的材质。

陡峭视差映射

陡峭视差映射(Steep Parallax Mapping)把深度范围 [0,1][0,1] 分成多层。算法沿切线空间视线逐层移动 UV,并比较“当前射线深度”和“高度场深度”。第一次穿过表面时停止。射线从顶层向内行进,因而算法追踪的是纹理定义的局部高度层,几何剪影仍由原网格决定。

观察越接近表面,射线在 UV 平面走得越远,需要更多层:

N=lerp(Nmax,Nmin,∣Vz∣)N=lerp(N_{max},N_{min},|V_z|)

固定层数容易在正视时浪费采样,在掠射角出现台阶。动态层数把预算放到更困难的角度。

视差遮蔽映射

视差遮蔽映射(Parallax Occlusion Mapping,POM)在首次穿过后,使用穿过前后的两个样本做线性插值,得到更连续的交点。

令归一化射线深度t从0增加到1,UV平面中的射线写成 uv(t)=uv0-rayUV*t,其中 rayUV=viewDirTS.xy/max(viewDirTS.z,0.05)*uvDepthScale。深度函数D以黑色表示顶层,残差 F(t)=D(uv(t))-t。F为正时射线仍在表面上方,首次变为非正时取得交点区间。

取整数层数N,每步t=i/N。若起点D(uv0)=0,交点就是uv0,应立即返回;否则保存上一步的正残差before。当本步残差error非正时,区间内线性比例为 w=before/(before-error),在前后UV之间按w插值。分母由正残差减非正残差构成,正常区间内严格为正。这个比例来自区间内的线性残差F(w)=(1−w)before+w·error,令它为零即可得到w。比如前后残差为0.1和−0.15,零点在区间的0.4处;取两端中点则仍有−0.025的残差。插值消除了局部线性模型中的台阶,曲面和高频高度场仍有模型误差。

glsl
bool Outside(vec2 uv) { return any(lessThan(uv,vec2(0))) || any(greaterThan(uv,vec2(1))); }
struct PomHit { vec2 uv; float depth; bool valid; };
PomHit PomUV(vec2 origin, vec3 view, vec2 dx, vec2 dy) {
    if (isolatedPatch!=0 && Outside(origin)) return PomHit(origin,0.0,false);
    int layers = clamp(int(round(mix(float(maxLayers),float(minLayers),view.z))),1,128);
    vec2 ray = view.xy / max(view.z,0.05) * uvDepthScale;
    float before = textureGrad(depthMap,origin,dx,dy).r;
    if (before <= 0.0) return PomHit(origin,0.0,true);
    vec2 previous = origin;
    float previousDepth = 0.0;
    for (int i=1; i<=layers; ++i) {
        float depth = float(i)/float(layers);
        vec2 current = origin-ray*depth;
        if (isolatedPatch!=0 && Outside(current)) return PomHit(current,depth,false);
        float error = textureGrad(depthMap,current,dx,dy).r-depth;
        if (error <= 0.0) {
            float t = before/(before-error);
            return PomHit(mix(previous,current,t),mix(previousDepth,depth,t),true);
        }
        previous=current;
        previousDepth=depth;
        before=error;
    }
    return PomHit(origin-ray,1.0,true);
}

这是GLSL 4.50片元机制函数,view是已归一化、Z为正的切线空间观察方向;全局minLayers/maxLayers给层数预算,uvDepthScale是两方向尺度,depthMap采样值有限且处于[0,1]。dx/dy在一致控制流中由原始UV求得。isolatedPatch=0表示连续平铺,纹理Sampler使用Wrap;非零表示独立的[0,1]面片,越界返回valid=false。采样步跨出边界时采用保守无命中,最后一小段内的交点也可能被舍弃,减小步长可降低边缘侵蚀。有效返回值包含同一交点的UV与归一化深度:顶层为0,区间内按同一权重插值;在有限精度下未取得交叉时回退到底层1。这份接口供后续材质采样、位置重建和朝光查询共同消费。完整声明与诊断输出见pom.frag,宿主创建纹理、Sampler、参数缓冲和图形管线。

恒定深度D=0.3、起点u=0.6、rayUV=0.2时,交点t=0.3、u=0.54,与层数无关的线性插值应恢复该值。若高度信号在两次采样间快速起伏,首次采样到的区间仍可能跳过更前面的交点;增加层数降低漏检,并不提供任意高度场的严格求交保证。

UV 导数和 Mipmap

POM 会在循环中多次读取变化后的 UV。循环次数或分支在相邻像素间不一致时,隐式导数的 LOD 选择可能不稳定。可以在进入循环前保存原始 UV 的 dFdx/dFdy,再使用 textureGrad;HLSL中对应SampleGrad。保存原导数能稳定采样层级,但没有包含视差变换本身的导数,掠射变形处仍是足迹近似,需结合远处渐隐与实际画面检查。

高度图也需要 Mipmap,但普通平均会改变高低峰形状。远处通常允许减弱高度缩放或层数,把稳定性和采样成本放在细小视差之前。

交点的材质与光照输入

砖缝交点已经确定,材质和光照必须消费同一份结果。独立面片的valid=false先由材质入口丢弃;选择平面回退时,统一改用原UV、零深度并跳过高度自阴影。有效交点先用于贴图采样,再恢复光照需要的位置,朝光查询因此有了确定的起点。

材质采样沿用进入步进前保存的UV梯度:基础色、粗糙度与切线空间法线都在hit.uv用显式梯度采样。直接让这些采样隐式求导,会把不同像素的命中跳变和循环分支混入LOD选择;高度图稳定并不保证其余贴图稳定。若材质图采用uvMaterial=scale*hit.uv+offset,采样梯度也分别乘同一scale。法线图解码并归一化后,通过与步进相同的T、B、N变到着色空间;其法线用于BRDF,几何法线与高度厚度仍负责交点重建。沿用原始梯度是稳定的过滤近似,掠射处强拉伸仍需靠前文的衰减和采样预算控制。

命中位置与光照输入

点光方向需要命中位置时,可以从同一份交点恢复局部虚拟表面。设原网格位置为P0,世界单位切线为T、B,几何法线为N,一单位UV覆盖Lu、Lv米,高度层厚度H米;令duv=hit.uv-originUV,得到Phit=P0+T*(duv.x*Lu)+B*(duv.y*Lv)-N*(H*hit.depth)。它与前文两方向尺度(H/Lu,H/Lv)配套。保留步进中的未环绕UV差,Wrap只在纹理寻址时发生;先把UV夹回[0,1]再求差,会在平铺接缝产生一个纹理周期的错误位移。

例如Lu=Lv=1米、H=0.1米,交点UV相对原点移动−0.02、0,深度0.5,则虚拟位置沿T移动−0.02米并沿N向内0.05米。点光方向改用normalize(lightPosition-Phit),距离衰减也使用同一Phit,再转到TBN求高度场可见性。朝光查询的长度由下一节按这份位置确定;只改方向而保留原距离会混用两个表面。材质法线仍从命中UV读取,TBN由原表面提供,因此这是局部平面上的虚拟着色位置,光栅深度、轮廓和场景碰撞仍来自网格。邻近光源与厚层的几何差异可由这两种位置的差直接观察。

观察方向的Z小于0.05时,前文用分母下限压短了UV路径;此时Phit恢复的是这条受限路径上的虚拟交点,不再严格位于相机穿过P0的视线上。用同一Phit计算点光只能保持近似内部一致,不能消除掠射处的形状压缩。若需要严格的视线求交,应使用真实Vz并配足采样与边界处理,或在进入该角度前把高度效果渐隐到零。

高度场自阴影

观察射线求出了可见点,但这个点到光源之间也可能穿过凸起。仍采用深度向内为正的约定,设命中位置为uvHit、命中深度为tHit,光方向L从表面指向光源,且Lz>0。让q表示朝光走过的归一化垂直深度,则光线上的位置为:

uv(q)=uvHit+LxyLz uvDepthScale q,t(q)=tHit−quv(q)=uvHit+\frac{L_{xy}}{L_z}\,uvDepthScale\,q,\qquad t(q)=tHit-q

朝光查询读取PomHit中已有的UV与深度。正视时所有深度映到同一UV,因此只保存UV无法恢复这条光线的起点;POM在首次交叉时保存的深度在这里成为必要输入。

方向光的查询终点qEnd=tHit;点光则先计算从命中点到光源沿N方向的正距离zLight,以物理厚度H换成qLight=zLight/H,取qEnd=min(tHit,qLight)。光位于层内时,查询在光源处结束;光位于层外时,在离开高度层处结束。H必须为正,高度效果为零时直接走原表面光照。下述循环以q=qEnd/shadowSteps作为首个正偏移,到qEnd结束;qEnd=0直接返回可见。每步读取D(uv(q));如果t(q)>D(uv(q))+bias,光线点处在表面内部,判为遮挡。bias以归一化深度计量,用于容纳交点误差。它过大会漏掉浅凸起,过小会把起点误差判成自遮挡。Lz接近零时路径很长,可限制采样预算并渐隐该近似;Lz≤0的直射贡献按本页单面表面模型置零。

例如砖缝命中深度0.6,点光在深度0.4,qEnd只有0.2;深度0.3处的凸起位于光源之后,不应参与这次遮挡查询。若仍查到顶层q=0.6,这个凸起会错误地使砖缝变暗。光与Phit重合时方向不可归一化,按光源半径模型处理或跳过该退化光样本;位于表面背侧的光仍按单面模型没有直射贡献。

例如tHit=0.6,在q=0.2处光线深度为0.4。若采样表面D=0.3、bias=0.01,0.4>0.31,光线已经进入凸起,返回阴影;若D=0.5,则该点仍在空处,应继续步进。固定步数同样会漏过两次采样之间的薄凸起,所以这只是高度场内的硬阴影近似。远处物体投下的阴影仍由场景阴影系统提供。可以将两者的可见性相乘,再作用于直射项;环境光不能直接套用这条单方向光线的结果。

glsl
float HeightVisibility(PomHit hit, vec3 light, float qEnd, vec2 dx, vec2 dy) {
    if (!hit.valid) return 0.0;
    if (light.z<=0.0) return 0.0;
    qEnd=clamp(qEnd,0.0,hit.depth);
    if (qEnd<=0.0) return 1.0;
    vec2 ray=light.xy/max(light.z,0.05)*uvDepthScale;
    int steps=clamp(shadowSteps,1,128);
    float visibility=1.0;
    for (int i=1;i<=steps;++i) {
        float q=qEnd*float(i)/float(steps);
        vec2 uv=hit.uv+ray*q;
        if (isolatedPatch!=0 && Outside(uv)) break;
        if (hit.depth-q>textureGrad(depthMap,uv,dx,dy).r+shadowBias) {
            visibility=0.0;
            break;
        }
    }
    // Fade the finite-budget approximation to visible at grazing light angles.
    return mix(1.0,visibility,smoothstep(0.0,0.05,light.z));
}

函数接收上一节的同一份PomHit,light为归一化切线空间朝光方向;全局shadowSteps给采样预算,shadowBias为非负归一化深度偏置。qEnd由光类型和同一命中位置产生;采样足迹沿用显式梯度。独立面片越界结束查询,片外不再延展本高度场。Z在0到0.05之间时,路径除法下限限制长度,同时将阴影近似渐隐到可见,避免有限步数的长路径在掠射处跳变;这会漏掉部分真实遮挡,直射余弦仍会压低掠射贡献。独立面片越界时必须按资产边界结束查询,不能把Clamp延展出的边缘当成真实阻挡物。

完整片元入口先在一致控制流中计算导数,再连续调用两个函数:

glsl
vec2 dx=dFdx(inputUV), dy=dFdy(inputUV);
vec3 view=normalize(inputViewTS);
if (view.z<=0.0) { outputColor=vec4(inputUV,0,1); return; }
PomHit hit=PomUV(inputUV,view,dx,dy);
if (!hit.valid) discard;
float visibility=HeightVisibility(hit,normalize(lightTS),hit.depth,dx,dy);
outputColor=vec4(hit.uv,hit.depth,visibility);

这里演示方向光,故终点使用hit.depth。入口在采样和光照之前处理无命中;平面回退规则见“交点的材质与光照输入”。诊断输出的RG是交点UV,B是归一化深度,A是高度场可见性。正常材质将该可见性与场景阴影相乘后用于直射项。

点光入口先按前文恢复Phit,再计算朝光方向、距离和qEnd,最后调用HeightVisibility。这样从砖墙观察射线得到的一份交点,连续决定了材质位置、光照衰减和遮挡区间。

边界和局限

  • 平铺材质在采样时Wrap,射线位置保持连续。独立面片在步进中检查UV边界,越界时返回无命中并由材质决定丢弃或回退原表面;Clamp只有在资产明确把边缘延展为高度场时才成立,并对观察射线和自阴影使用相同延展规则。
  • 视差只改变采样位置,模型轮廓、深度缓冲和碰撞保持原几何形状。
  • 自阴影步进只检查当前高度层,采样数和偏移决定漏光与自遮挡误差。
  • 大高度、薄轮廓和近距离剪影适合几何位移或 Tessellation。
  • 掠射角需要限制偏移、增加层数或渐隐效果,避免纹理拉伸和 UV 跳变。
  • 动态循环的成本随角度和内容变化,需要在目标 GPU 上检查 Shader 指令、纹理采样和 Wave 发散。

实现与验证

  1. 使用斜光照的单个四边形,准备棋盘色、高度和法线三张可对齐纹理。
  2. 依次显示原始 UV、基础视差、陡峭视差和 POM。
  3. 固定相机后把层数设为 4、16、64,观察台阶和采样成本。
  4. 将高度缩放设为 0,确认所有纹理回到原始 UV。
  5. 旋转相机到掠射角,检查偏移方向、UV 越界和层数变化。
  6. 单独显示偏移后的 UV 与最终深度误差,定位凹凸翻转和交点错误。

一维数值推演、自阴影阻挡与漏检反例见 retained_review/pom/check.py,运行 python -B 知识库/examples/retained_review/pom/check.py。该程序只验证本文标量关系,不替代纹理采样或GPU图像检查。

CPU模型pom_line检验顶层零深度、恒定深度和线性高度场交点,运行 python -B 知识库/examples/revision_06_08/test_reference.py。GLSL片段已用glslangValidator编译为SPIR-V;该结果仅证明着色器编译通过,纹理过滤、画面与GPU成本仍为尚未验证。

相关主题

参考资料