mypro334 发表于 2022-6-27 19:17

用Unity实现Parallax Mapping

使用normal map可以增加物体表面的凹凸程度,但是以一个比较小的角度观察物体表面,还是会缺乏一些细节。parallax mapping就是通过给每个像素添加上高度信息,在采样时根据高度信息和视线方向对uv进行一定的偏移,来达到模拟凹凸的效果。
首先来看没有parallax mapping,只使用normal map的效果:


由于纹理坐标是在切线空间的,我们首先需要把视线方向变换到切线空间:
      float3x3 objectToTangent = float3x3(
            v.tangent.xyz,
            cross(v.normal, v.tangent.xyz) * v.tangent.w,
            v.normal
      );
      i.tangentViewDir = mul(objectToTangent, ObjSpaceViewDir(v.vertex));
然后对uv坐标进行偏移:
      i.tangentViewDir = normalize(i.tangentViewDir);
      i.uv.xy += i.tangentViewDir.xy * _ParallaxStrength;
此时的效果如下:


接下来,我们再把纹理的高度信息加进去,让比较高的高度偏移更大,显得更凸,让比较低的高度偏移为负,显得更凹:
      float height = tex2D(_ParallaxMap, i.uv.xy).g;
      height -= 0.5;
      height *= _ParallaxStrength;
      i.uv.xy += i.tangentViewDir.xy * height;
此时效果如下:


但是这种情况下,
_ParallaxStrength参数调的过大会导致撕裂感明显:


出现的原因,本质上是我们直接拿
tangentViewDir的xy分量来作为uv的偏移量,而这个是不准的。如图:


EI为视线向量,E为没有高度时本来的采样点,但由于高度阻挡,视线向量和阻挡物相交于G,因此此时的采样点应当为H,即uvOffset为EH。EJ为视线向量在uv上的投影,即
tangentViewDir.xy,而IJ为视线向量的高度,即
tangentViewDir.z。那么,实际上真正的uvOffset,即EH为: https://www.zhihu.com/equation?tex=+EH+%3D+%5Cdfrac%7BEJ%7D%7BIJ%7D+%5Ccdot+GH+ 虽然无法直接得到GH,但可以拿原始采样点E的高度作为估计,所以我们有

i.tangentViewDir.xy /= i.tangentViewDir.z;
i.uv.xy += i.tangentViewDir.xy * height;
为了防止在极小的角度时z分量趋向于零而导致的问题,我们可以手动加上一个bias:
            i.tangentViewDir.xy /= (i.tangentViewDir.z + PARALLAX_BIAS);


由上图可知,我们是拿原始采样点的高度来估计实际视线遮挡物的高度的,因而依旧是不准确的。我们可以使用raymarching方法来进行精确估计,例如使用逐步逼近的方式,即每次前进一小步长,然后比较当前遮挡物的高度和视线的高度,如果当前视线不再被物体遮挡,则停止前进,返回上一个也就是最后一个被物体遮挡的uvOffset作为结果。由于这样实现比较麻烦,我们可以逆向思考,即每次后退一小步长,这样只要当视线被物体遮挡时,即可返回结果,如图所示:


    viewDir.xy /= (viewDir.z + PARALLAX_BIAS);
    float2 uvOffset = 0;
    float stepSize = 1.0 / PARALLAX_RAYMARCHING_STEPS;
    float2 uvDelta = viewDir * (stepSize * _ParallaxStrength);

    float stepHeight = 1;
    float surfaceHeight = GetParallaxHeight(uv);

    for (
      int i = 1;
      i < PARALLAX_RAYMARCHING_STEPS && stepHeight > surfaceHeight;
      i++
    ) {   
      uvOffset -= uvDelta;
      stepHeight -= stepSize;
      surfaceHeight = GetParallaxHeight(uv + uvOffset);
    }

    return uvOffset;


不过这种方式依旧存在一定的误差。首先是步长的选取,如果步长选的过大,在高度变化频繁的区域,可能就会错过遮挡的点;其次我们是拿若干步长后遮挡物的高度作为结果的,而它并不能精确表示视线被遮挡时的高度,如图所示:


针对这个问题,我们可以采用插值的方式解决。只需记录前一次步长遮挡物的高度和视线高度,此时视线高度是大于遮挡物高度的;而当前步长则是遮挡物高度大于视线高度,因此在前一步长到当前步长的过程中,遮挡物高度越来越高,而视线高度越来越低,在某个点它们的值必然会出现相等的情况。问题就转化为了求两条线段的交点问题,如图所示:




进而可以得到插值的系数t为 https://www.zhihu.com/equation?tex=+t+%3D+%5Cdfrac%7Ba+-+c%7D%7Ba+-+c+%2B+d+-+b%7D+ 代码实现如下:
    viewDir.xy /= (viewDir.z + PARALLAX_BIAS);
    float2 uvOffset = 0;
    float stepSize = 1.0 / PARALLAX_RAYMARCHING_STEPS;
    float2 uvDelta = viewDir * (stepSize * _ParallaxStrength);

    float stepHeight = 1;
    float surfaceHeight = GetParallaxHeight(uv);

    float2 prevUVOffset = uvOffset;
    float prevStepHeight = stepHeight;
    float prevSurfaceHeight = surfaceHeight;

    for (
      int i = 1;
      i < PARALLAX_RAYMARCHING_STEPS && stepHeight > surfaceHeight;
      i++
    ) {
         prevUVOffset = uvOffset;
      prevStepHeight = stepHeight;
      prevSurfaceHeight = surfaceHeight;

      uvOffset -= uvDelta;
      stepHeight -= stepSize;
      surfaceHeight = GetParallaxHeight(uv + uvOffset);
    }

    float prevDifference = prevStepHeight - prevSurfaceHeight;
    float difference = surfaceHeight - stepHeight;
    float t = prevDifference / (prevDifference + difference);
    uvOffset = prevUVOffset - uvDelta * t;

    return uvOffset;


不过,上面这种做法利用了一个假设,就是遮挡物的高度在一个步长区间内是线性变化的,如果遮挡物的高度变化频繁,依旧可能是有问题的。我们还可以尝试使用类似二分查找的思路来解决这个问题,即步长不是一成不变的,在到达当前步长后,每次都以一半的步长进行下去,当视线高度和遮挡物高度的大小关系发生变化时,调整步长的方向,直到一定的迭代次数之后,返回结果,如图所示:


代码实现如下:
    viewDir.xy /= (viewDir.z + PARALLAX_BIAS);
    float2 uvOffset = 0;
    float stepSize = 1.0 / PARALLAX_RAYMARCHING_STEPS;
    float2 uvDelta = viewDir * (stepSize * _ParallaxStrength);

    float stepHeight = 1;
    float surfaceHeight = GetParallaxHeight(uv);

    for (
      int i = 1;
      i < PARALLAX_RAYMARCHING_STEPS && stepHeight > surfaceHeight;
      i++
    ) {   
      uvOffset -= uvDelta;
      stepHeight -= stepSize;
      surfaceHeight = GetParallaxHeight(uv + uvOffset);
    }

    for (int i = 0; i < PARALLAX_RAYMARCHING_SEARCH_STEPS; i++) {
      uvDelta *= 0.5;
      stepSize *= 0.5;

      if (stepHeight < surfaceHeight) {
            uvOffset += uvDelta;
            stepHeight += stepSize;
      }
      else {
            uvOffset -= uvDelta;
            stepHeight -= stepSize;
      }
      surfaceHeight = GetParallaxHeight(uv + uvOffset);
    }
    return uvOffset;


如果你觉得我的文章有帮助,欢迎关注我的微信公众号Game_Develop_Forever
Reference

Parallax
视差贴图(Parallax Mapping)学习笔记
页: [1]
查看完整版本: 用Unity实现Parallax Mapping