文章11
标签16
分类0
[笔记] 计算机图形学入门 - 着色

[笔记] 计算机图形学入门 - 着色

好康的:https://www.bilibili.com/video/BV1X7411F744

好玩的:https://www.mathcha.io/editor

可见性/遮挡

如果按照先远后近绘制,则因为需要先对三角形排序,时间复杂度高,且无法处理多个三角形互相重叠的情况

Z-buffering

假设z为正值(为了方便讨论,即近处z小远处z大),则除了frame buffer储存颜色信息外,还加上depth buffer(z-buffer)储存深度信息,每个像素都储存最小的z值。绘制像素时先对比z-buffer中的z值和当前正在绘制的像素的z值,如果当前深度小于之前的深度,则覆盖上色,并更新深度缓存。

image-20220625201709727

如果将z-buffer绘制为图,则深度图会呈现越靠近越黑,越远越白的样子。

如果不存在深度相同的情况,则和绘制顺序无关。如果存在深度相同的情况,则绘制顺序不同可能产生不同效果,就会出现闪烁。

透明物体需要特殊处理,z-buffer处理不了。

着色方法

着色:对不同物体应用不同材质的过程

布林-冯反射模型

注意:布林-冯模型并非完全符合物理的模型,而是一个简化的模型

一些定义

  • 着色点(shading point):在物体极小的局部表面(认为是平面)定义一个着色点

  • 法线:垂直于该极小平面的线(单位向量

  • 观测方向:从着色点看向摄像机的线(单位向量

  • 光照方向:从着色点看向光源的线(单位向量

  • 物体表面属性:颜色、光泽度(shininess)

image-20220626172510082

注:着色只考虑局部,只考虑着色点自己,不考虑其他物体的存在,所以也不考虑可能带来的阴影。shading≠shadow

漫反射

根据Lambert’s cosine law,每单位面积吸收的能量与法线和光照方向夹角的余弦值有关

image-20220626173217969

抵达到每单位面积的能量与着色点和光源(点光源)的距离有关:

如下图,不同半径的球壳的总能量是一样的,而不同半径球壳的面积不一样,所以分配到单位面积的能量也就不一样。假设半径为1球壳上的单位能量为,则半径为r的球壳上单位能量为

image-20220626173815783

而由于漫反射是向四面八方都反射光线,所以无论观测方向如何,漫反射的能量都是一样的,所以无需考虑

综上所述,可以得出漫反射光照公式:

其中是漫反射系数(颜色),是到达着色点的能量,是着色点吸收的能量

为1时表示完全不吸收,全部反射,此时表面最亮(纯白),当为0时表示完全吸收,此时表面最暗(纯黑)。可将表示为一个三通道向量(R, G, B),R、G、B都在0和1之间,即可以表示该着色点的颜色。

注:虽然有涉及一点物理的推导,但该公式并不能准确模拟真实物理的漫反射。虽然我们在这里提到”能量“,但并非真正的能量,像是观测点距离着色点的远近带来的衰减这样的情况并没有考虑进去。

高光

当观测方向接近镜面反射方向时可以看到高光。在布林-冯模型中,观测方向与镜面反射方向的接近程度可以用法线方向和半程向量(处于光照方向与观测方向的夹角中心)方向的接近程度来衡量:

image-20220626185130578

要得出半程向量的方向,只需要让光照和观测方向向量相加即可:

注:其实也可以不使用半程向量来计算接近程度,直接使用镜面反射方向和观测方向的夹角来计算,这样的模型叫做冯模型。有关布林-冯模型为什么要针对此进行改进的解答,可见learnopengl-cn上对高级光照的解释。简单来说,就是为了处理冯模型在观察向量和镜面反射向量夹角超过90度时出现的问题。

为了衡量”接近程度“,我们使用夹角的cos值来计算。但是因为cos的值下降太慢,即使已经相差很远了还是有很大的值,导致观测方向与镜面反射方向相差很远时仍然有很亮的高光,所以要为cos值加上指数来让其数值下降得更快:

image-20220626191335895

于是就可以得到高光反射光照计算公式:

其中是高光系数,p是控制高光范围的指数。布林-冯模型中一般认为高光为白色,所以一般为1,而p一般在100到200之间。

image-20220626191652998

注意:布林-冯模型是一个简化模型,其高光公式没有考虑着色点吸收的光照。

环境光

由于环境光过于复杂,因此在布林-冯模型中将环境光照看作是恒定的,为,而由于环境光来自四面八方,所以也不考虑光照方向和观察方向。

那么就可以直接得出环境光照的公式:

其中是环境光系数。由于环境光是一个常数,所以也就可以看作是一个常数的颜色,也就是在着色点本身的颜色上增减一个亮度。

注意:布林-冯模型下的环境光只是一个非常笼统的假设/拟合,其无法准确表现如凹处环境光减弱等场景。

总结

将以上三项相加即可得到布林-冯反射模型的公式:

image-20220626193047506

着色频率

Flat shading

也即对平面着色。

求三角形法线(两边叉积),对一个三角形只计算一个着色结果,三角形内部其他像素使用完全一样的着色结果。

image-20220626194529322

Grouraud shading

也即对顶点着色。

对每个顶点都求出其法线,然后对每个顶点都进行一次着色,三角形内部其他像素的着色则使用三个顶点的着色结果进行插值。

image-20220626195009791

Phong shading

也即对每个像素着色。

对每个顶点都求出其法线,然后在三角形内部的每一个像素根据顶点法线都插值出其各自的法线,然后对每个像素都进行一次着色。

image-20220626194933723

面/顶点密集时则可以使用较小的着色频率就能达到很好的效果,面/顶点较稀疏时则需要使用较大的着色频率才能达到比较好的效果:

image-20220626195609004

法线计算方法

三角形法线

用三角形两条边向量叉积即可

顶点法线

求出相邻的各个三角形面的法线,然后求平均即可。

为了排除某些极小三角形的法线的干扰,还可以求加权平均,权值为各个三角形的面积/总面积。

如下图,顶点法线向量,加权平均的话就让即可

image-20220626210855016

像素点法线

使用端点的法线进行插值,则可以得到每个像素点的法线,具体方法见后面插值部分。

实时渲染管线

  1. 输入三维空间中的点
  2. 投影至屏幕(平面)上,得到屏幕(平面)上的点
  3. 连接顶点形成屏幕(平面)上的三角形
  4. 光栅化,通过采样将三角形离散化对应到屏幕上的像素,得到采样片段
  5. 对采样片段进行深度测试分析可见性(也可以将这一步归到光栅化)
  6. 着色(指像素着色。如果是grouraud shading,那在顶点投影的时候就会发生着色)
  7. 输出图像

image-20220626213022531

这一管线有的部分是可以编程的,比如着色器编程,也即shader编程,控制顶点着色和像素着色的过程。

shader是针对每一个顶点或像素的,分顶点着色器(vertex shader)和片段/像素着色器(fragment shader/pixel shader)

一个很牛逼的shader:https://www.shadertoy.com/view/ld3Gz2

插值

三角形ABC内任意一点P坐标都可以表示为

其中当P为重心时,

当给定三角形ABC和其中一点时,则可以使用下面的公式推出α、β和γ:

则如果已知三角形ABC顶点的属性,要插值得到三角形内一点(x,y)的属性V时,也可以先求α、β、γ,然后再使用来插值。这些属性可以是位置、纹理坐标、颜色、深度、材质属性等等。

值得注意的是,在投影过程中并不能保证投影前三维坐标点和投影后二维坐标点的α、β、γ值相同。所以在插值深度等三维空间的属性时,需要用三维空间的点而不是二维空间点。为此,需要进行透视矫正插值。详情可以看作业部分。

而由于在进行透视投影时,最后一步(应用正交投影矩阵映射到标准立方体)所用的(经过视锥压缩后的)点的齐次坐标并非以1作为w分量(也就是齐次坐标追加的那个轴)的归一化齐次坐标,而是以z作为w轴的值(根据投影矩阵不同,也可能为-z,比如OpenGL)。在经过正交投影后,w分量上的值仍然为z不变。于是要得到三维空间(投影变换前)的点的深度信息,只需要取w轴信息即可;要得到投影变换后处于平面上的点的x、y、z坐标(归一化齐次坐标下的值),则需要通过齐次除法(透视除法)得到归一化齐次坐标下的x、y、z。详情可见《变换》笔记中的投影变换-透视投影-注5和注6

纹理

纹理定义

纹理是用来定义着色点的属性的。

三维物体的表面可以用一个二维的平面来表示,三维表面每一个点都可以用二维图片上的点一一对应,于是三维表面上的每一个三角形也都可以用二维图片上的三角形一一对应。这样的二维图片被称为纹理。而对于三维表面上每一个点与二维纹理上的坐标的映射,就是纹理映射。

image-20220626225148530

二维纹理上的坐标系是uv坐标系。为了方便处理,无论纹理本身是长方形还是正方形,也无论是什么分辨率,一般都将u和v的范围设定在0和1之间。对于重复使用的纹理(比如地砖),一般要求其四方连续(无缝衔接),这样的纹理被称为tileable texture,要生成这样的纹理有专门的算法(比如Wang tiles)

纹理插值和映射

插值

在对某点进行着色时,先得到其自身以及其所处三角形的顶点的三维坐标(像素位置->像素中心坐标->像素中心三维坐标)。由于知道该三角形顶点对应到纹理的uv坐标,所以可以对该三角形上任意点进行插值,得到任意点对应到纹理上的uv值。通过查询纹理上该uv值(坐标)对应的颜色,就可以得到该点的着色信息。这就是uv映射插值。

纹理放大

注:纹理上的像素点被称为纹理元素或纹素(texel),其坐标为整数。这里和下面说的的坐标不是指包括小数的坐标位置,而是只有整数的像素坐标。

如果纹理太小,而屏幕太大时,可能出现锯齿。原因是在对光栅化的点查询纹理时,可能查询到的uv位置对应到纹理上的xy坐标不是整数,并不能完美对应到只有整数的纹理元素坐标。这时候返回的纹理信息有不同的取法:

Nearest

直接查找离非整数坐标点最近的纹理元素(即对非整数坐标四舍五入取整),然后返回该纹理元素的纹理信息。

可能造成锯齿。

image-20220628020108834

Bilinear

注:采用lerp进行线性插值的公式为:,v0为起始值,v1为最终值,x为0到1之间的值,根据x不同可以得到更靠近x0或者v1的线性插值。

找到离非整数坐标点最近的四个纹理元素,可以计算得到坐标点距离四个纹理元素中心(这里中心坐标是整数)的xy距离t、s。

image-20220628020508135

于是可以先通过lerp方法取得u00、u10上的点u0,u01、u11上的点u1:

image-20220628021147784

然后再通过lerp方法取得u0、u1连线上的非整数查询点:

image-20220628021018073

最后得到非整数点(x,y)与四个相邻点的插值关系,再将四个点的属性应用进该关系就可以得到该非整数点的更平滑的插值属性了。由于做了两轮线性插值,所以这一方法被称为双线性插值。

Bicubic

取周围16个临近的纹理像素,对左上、右上、左下、右下一个4个矩形(每个都包含2*2=4个纹理像素)各自进行Bilinear采样,然后再用这四个矩形的采样值进一步进行Bilinear采样。因为最后得到的插值公式有两元三次,所以被称为双立方插值或双三次插值。

对比

image-20220628023021870

纹理缩小

如果纹理太大,而屏幕太小时,可能出现摩尔纹和锯齿。

image-20220628023247332

原因是在对光栅化的像素查询纹理时,可能该像素对应到三维几何体上覆盖了很大区域(光栅化就是在区域中采样点,相反就是点变为区域),因此也在纹理上覆盖了很大的一片面积,而如果只用三维区域的中心点来查询对应纹理的xy坐标点,那么就相当于用频率很低的采样函数来采样一个很高频很密集的信号,会造成采样失真。

如下图,右侧就相当于是用一个点来采样整个蓝色区域的纹理信息,自然会造成失真。

image-20220628024410896

Super Sampling

针对采样频率更不上信息密度的问题,可以通过增加采样频率的方法解决。和光栅化时一样,解决方案可以使用超采样,即在一个光栅化的像素中取多个采样点,分别对这些采样点找到其对应的三维坐标,再找到其对应的纹理坐标,然后进行纹理映射,最后再将这些采样点的纹理进行合并,得到该像素点的纹理。

image-20220628024956691

如图所示,效果很好但是开销极大,一般都不会用。

Mipmap

将点查询问题转化为范围查询问题。

Mipmap是快速近似正方形范围查询方法,通过提前计算每一级的纹理(每级的长宽都为上一级的一半),以达到快速查询不同大小的正方形范围的纹理,而存储量只增加了1/3。

image-20220628025927955

而要将一个光栅化后的像素近似到纹理上的一个区域,可以取该像素中心以及其周围的像素中心都映射到纹理上:

注:当然,也可以不映射像素中心以及其周围的像素中心用于计算,而是直接映射像素(正方形)本身的顶点来计算。

image-20220628170232066

根据公式得到L,也就是两个像素中心距离映射到纹理上的距离,这一距离近似等于像素边长映射到纹理上的距离,于是就可以用边长为L的正方形区域来近似该像素点映射到纹理上的区域。

而要采用的mipmap的级数可以通过公式来计算。

如果计算得到的D不为整数,而是包含小数,则可以先将其映射到两个整数级别的mipmap,分别进行纹理查询,然后这两个查询到的纹理值进行双线性插值得到最终的纹理值。这个过程进行了两次查询+一次插值,也被称为三线性插值(Trilinear)。

image-20220628171554379

缺点:可能造成过度模糊(如上图),原因是只能将像素对应纹理范围近似到正方形区域,如果纹理范围和正方形相差过大就会造成上图的现象。

各向异性过滤

也叫Ripmap。与Mipmap相比,Mipmap是预先生成了长宽各减少一半的多级纹理,各向异性过滤则是预先生成1/2长1宽、1/2长1/2宽、1长1/2宽的多级纹理。

image-20220628172156093

因此,比起mipmap只能近似到正方形,各向异性过滤可以近似到矩形。比如下图的情景,像素映射到的纹理区域更接近长方形时,各向异性过滤就可以获得比mipmap更符合的近似。

image-20220628172256286

在储存空间开销上,相比Mipmap的4/3倍开销,各向异性过滤的开销是原本的3倍。各向异性过滤的层数对性能基本没有影响,而对储存空间的占用随着总层数增加逐级收敛,最终收敛至原本的三倍。

PS.所以一般只要使用了各向异性过滤,再去调整层级数量对性能的影响就不是很大了。要不然不开节省显存,要不然就直接开最大层数。

EWA过滤

如果像素映射到的纹理区域是不规则图形,则比起用单个矩形近似,用多个椭圆来近似可以得到更符合的近似结果。代价就是需要多次查询而不是一次,开销相比各向异性更大。这种方法叫做EWA过滤。

image-20220628173007551

作业

Assignment3

首先按照惯例先修改一下框架中的错误(具体解释参考《GAMES101》作业框架问题详解和我前面《变换》那节的笔记),前面两次作业框架中齐次除法的问题现在修复了,然后就是triangle.cpp中toVector4方法不应该直接返回w=1.0f:

std::array<Vector4f, 3> Triangle::toVector4() const
{
    std::array<Vector4f, 3> res;
    std::transform(std::begin(v), std::end(v), res.begin(), [](auto& vec) { return Vector4f(vec.x(), vec.y(), vec.z(), vec.w()); });
    return res;
}

除此之外,框架给出的模型的纹理映射有点问题,有那么几个顶点的uv值是负数(uv值应该在[0,1]之间),还有就是Texture.cpp中的getColor方法计算纹理映射位置也有一点问题,坐标应该从0到width-1/height-1,框架中没有减一,导致调用Mat::at方法时assertion过不去,这些问题在Release模式下和某些版本opencv中不会报错,但是在debug下会直接报错(具体assert策略见该解答中提到的说明)

For the sake of higher performance, the index range checks are only performed in the Debug configuration

解决方法是改成release模式(顺便出图速度超级加倍),或者修改Texture::getColor:

Eigen::Vector3f getColor(float u, float v)
{
+   if (u < 0) u = 0;
+   if (u > 1) u = 1;
+   if (v < 0) v = 0;
+   if (v > 1) v = 1;
-   auto u_img = u * width;
+   auto u_img = u * (width - 1);
-   auto v_img = (1 - v) * height;
+   auto v_img = (1.f - v) * (height - 1);
    auto color = image_data.at<cv::Vec3b>(v_img, u_img);
    return Eigen::Vector3f(color[0], color[1], color[2]);
}

关于代码框架中给出的插值公式的解释,同样可以看《GAMES101》作业框架问题详解

image-20220629012323460

注意上面引用中写的z1、z2、z3在作业框架中应该使用w1、w2、w3,也就是投影到屏幕前的深度信息,因为从原始模型空间到观测空间的过程中重心关系不会发生改变(具体解释见计算机图形学中,如何推导透视校正插值?)该值是在观测空间->裁剪空间的过程中保留的

然后再按照惯例针对win+msvc修改一下cmake文件和加载模型的路径,在ide中调整程序工作目录。

接下来就可以开始写作业了。先填充main.cpp中的get_projection_matrix方法,与Assignment1和2一样,使用左手系的透视投影矩阵。

然后按照要求填充rasterize_triangle函数,这里用了前面提到的透视矫正插值,注意w分量才是原本的被化为正数的观测空间的深度。z分量在前面已经做过透视除法了,所以不需要再除一次。还有一些细节可见代码注释:

//Screen space rasterization
void rst::rasterizer::rasterize_triangle(const Triangle& t, const std::array<Eigen::Vector3f, 3>& view_pos)
{
    auto v = t.toVector4();

    // Find out the bounding box of current triangle.
    int left = floor(std::min({v[0].x(), v[1].x(), v[2].x()}));
    int right = ceil(std::max({v[0].x(), v[1].x(), v[2].x()}));
    int bottom = floor(std::min({v[0].y(), v[1].y(), v[2].y()}));
    int top = ceil(std::max({v[0].y(), v[1].y(), v[2].y()}));

    // Iterate through the pixel and find if the current pixel is inside the triangle
    for (int x = left; x <= right; x++) {
        for (int y = bottom; y <= top; y++) {
            if (!insideTriangle(x, y, t.v)) continue;

            // 后面的插值使用了透视矫正插值的方法

            auto [alpha, beta, gamma] = computeBarycentric2D(0.5f + x, 0.5f + y, t.v);
            float Z = 1.f / (alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w());
            
            // 下面所用的z并没有除以w,是因为在前面draw函数内已经对x、y、z都做过齐次除法了,这里的z已经是除过w的了
            // 按照(我拙劣的)数学推导,这里的z(x和y也是)应该是不需要做透视矫正的,不过做了也没啥所谓

            auto interpolated_z =
                    Z * (alpha * v[0].z() + beta * v[1].z() + gamma * v[2].z());

            if (interpolated_z > depth_buf[get_index(x, y)]) continue;
            depth_buf[get_index(x, y)] = interpolated_z;

            // 下面所用的属性都除以w,是因为要进行透视矫正

            auto interpolated_color = interpolate(
                    alpha / v[0].w(), beta / v[1].w(), gamma / v[2].w(),
                    t.color[0], t.color[1], t.color[2], 1.f / Z);
            auto interpolated_normal = interpolate(
                    alpha / v[0].w(), beta / v[1].w(), gamma / v[2].w(),
                    t.normal[0], t.normal[1], t.normal[2], 1.f / Z);
            auto interpolated_texcoords = interpolate(
                    alpha / v[0].w(), beta / v[1].w(), gamma / v[2].w(),
                    t.tex_coords[0], t.tex_coords[1], t.tex_coords[2], 1.f / Z);
            auto interpolated_shadingcoords = interpolate(
                    alpha / v[0].w(), beta / v[1].w(), gamma / v[2].w(),
                    view_pos[0], view_pos[1], view_pos[2], 1.f / Z);

            fragment_shader_payload payload( interpolated_color, interpolated_normal.normalized(), interpolated_texcoords, texture ? &*texture : nullptr);
            payload.view_pos = interpolated_shadingcoords;

            // Instead of passing the triangle's color directly to the frame buffer, pass the color to the shaders first to get the final color;
            auto pixel_color = fragment_shader(payload);
            set_pixel(Vector2i(x, y), pixel_color);
        }
    }
}

然后配置程序实参normal测试法线插值,就可以得到输出图片:

image-20220630152843851

然后是布林-冯模型,直接套公式:

Eigen::Vector3f phong_fragment_shader(const fragment_shader_payload& payload)
{
    Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005);
    Eigen::Vector3f kd = payload.color;
    Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937);

    auto l1 = light{{20, 20, 20}, {500, 500, 500}};
    auto l2 = light{{-20, 20, 0}, {500, 500, 500}};

    std::vector<light> lights = {l1, l2};
    Eigen::Vector3f amb_light_intensity{10, 10, 10};
    Eigen::Vector3f eye_pos{0, 0, 10};

    float p = 150;

    Eigen::Vector3f color = payload.color;
    Eigen::Vector3f point = payload.view_pos;
    Eigen::Vector3f normal = payload.normal;

    Eigen::Vector3f result_color = {0, 0, 0};
    for (auto& light : lights)
    {
        // For each light source in the code, calculate what the *ambient*, *diffuse*, and *specular*
        // components are. Then, accumulate that result on the *result_color* object.

        Eigen::Vector3f La, Ld, Ls;
        Eigen::Vector3f l = light.position - point;
        Eigen::Vector3f v = eye_pos - point;
        Eigen::Vector3f h = l.normalized() + v.normalized();
        float r2 = l.squaredNorm();
        float cos_nl = normal.dot(l) / (normal.norm() * l.norm());
        float cos_nh = normal.dot(h) / (normal.norm() * h.norm());
        La = ka.cwiseProduct(amb_light_intensity);
        Ld = kd.cwiseProduct(light.intensity / r2) * MAX(0, cos_nl);
        Ls = ks.cwiseProduct(light.intensity / r2) * pow(MAX(0, cos_nh), p);
        result_color += (La + Ld + Ls);
    }

    return result_color * 255.f;
}

配置程序实参phong得到输出图片:

image-20220630152852916

然后是加载纹理,这里框架有点问题,按照前面说的那样修改后就好了。加载其实就一句话,然后后面复制一下前面的布林-冯公式:

Eigen::Vector3f texture_fragment_shader(const fragment_shader_payload& payload)
{
	// ......
    
	if (payload.texture)
    {
        // Get the texture value at the texture coordinates of the current fragment
        return_color = payload.texture->getColor(payload.tex_coords.x(), payload.tex_coords.y());
    }
    
    // ......
}

配置参数texture输出图片:

image-20220630170445626

然后是bump mapping,按照注释依葫芦画瓢即可,感觉和法线贴图有点类似,详细关于切线空间和TBN矩阵的解释可参考LearnOpenGL CN - 法线贴图,具体细节在作业3更正公告

Eigen::Vector3f bump_fragment_shader(const fragment_shader_payload& payload)
{
    // ......

    // Implement bump mapping

    float y_len = sqrt(pow(normal.x(), 2) + pow(normal.z(), 2));
    Eigen::Vector3f tangent(normal.x() * normal.y() / y_len, y_len, normal.z() * normal.y() / y_len);
    Eigen::Vector3f bitangent = normal.cross(tangent);
    Eigen::Matrix3f TBN;
    TBN << tangent, bitangent, normal;
    float u = payload.tex_coords.x(), v = payload.tex_coords.y();
    int w = payload.texture->width, h = payload.texture->height;
    // top texel
    float dU = kh * kn * (payload.texture->getColor(u + 1.f / w, v).norm() - payload.texture->getColor(u, v).norm());
    // right texel
    float dV = kh * kn * (payload.texture->getColor(u, v + 1.f / h).norm() - payload.texture->getColor(u, v).norm());
    Eigen::Vector3f ln(-dU, -dV, 1);
    normal = (TBN * ln).normalized();

    // ......
}

配置参数bump输出图片:

image-20220630204145653

最后是displacement mapping,copy一下前面几个mapping的代码然后加上偏移的部分就好:

Eigen::Vector3f displacement_fragment_shader(const fragment_shader_payload& payload)
{
    // ......
    
	// Implement displacement mapping

    float y_len = sqrt(pow(normal.x(), 2) + pow(normal.z(), 2));
    Eigen::Vector3f tangent(normal.x() * normal.y() / y_len, y_len, normal.z() * normal.y() / y_len);
    Eigen::Vector3f bitangent = normal.cross(tangent);
    Eigen::Matrix3f TBN;
    TBN << tangent, bitangent, normal;
    float u = payload.tex_coords.x(), v = payload.tex_coords.y();
    int w = payload.texture->width, h = payload.texture->height;
    // top texel
    float dU = kh * kn * (payload.texture->getColor(u + 1.f / w, v).norm() - payload.texture->getColor(u, v).norm());
    // right texel
    float dV = kh * kn * (payload.texture->getColor(u, v + 1.f / h).norm() - payload.texture->getColor(u, v).norm());
    Eigen::Vector3f ln(-dU, -dV, 1);
    point = point + kn * normal * payload.texture->getColor(u, v).norm();
    normal = (TBN * ln).normalized();
    
    // ......
}

配置参数displacement输出图片:

image-20220630212004224