目录

TinyRenderer笔记5:阴影和矫正透视变形 - 完结

之前渲染的模型中,物体明暗强弱主要通过光方向和法向量计算,并没有考虑光被遮挡的场景。

如何确认哪些部分的光被遮挡了呢?这个问题很简单,我们把摄像机朝向和平行光方向保持一致,进行一次渲染,得到的zbuffer就能表示光的视角能看到的部分,看不到的部分就是阴影!然后再进行正常的渲染。

定义一个阴影着色器:

rust

pub struct ShadowShader<'a> {
    model: &'a obj::Obj<TexturedVertex, u32>,
    varying_tri: Mat3, // 三个顶点的屏幕坐标
    view_port: Mat4,
    projection: Mat4,
    model_view: Mat4,
}

impl<'a> IShader for ShadowShader<'a> {
    fn vertex(&mut self, i_face: usize, nth_vert: usize) -> glm::Vec4 {
        let i_vert = self.model.indices[i_face * 3 + nth_vert];
        let vert = self.model.vertices[i_vert as usize];
        let v = Vec3::from_array(&vert.position); // 顶点位置
        let gl_v = self.view_port * self.projection * self.model_view * v.extend(1.);
        self.varying_tri.as_array_mut()[nth_vert] = v4p2v3(gl_v);
        gl_v
    }

    fn fragment(&mut self, bar: glm::Vec3, color: &mut image::Rgba<u8>) -> bool {
        let p = self.varying_tri * bar; // 当前像素的插值位置
        let depth = 2000.;
        let r = (255. * p.z / depth) as u8;
        let g = (255. * p.z / depth) as u8;
        let b = (255. * p.z / depth) as u8;
        *color = image::Rgba([r, g, b, 255]); // 设置当前像素颜色为阴影颜色,深度越小颜色越潜
        return false; // 不丢弃任何像素
    }
}

这个作色器会根据视线方向,将深度信息转换为颜色,深度越大(离摄像机越近)颜色越深

然后尝试渲染出图片,这次用暗黑三模型:

rust

let light_dir = glm::normalize(glm::vec3(1., 1., 0.));
let input = BufReader::new(File::open("obj/diablo3/diablo3_pose.obj").unwrap());
let model_view_light = lookat(light_dir, center, up); // 光照方向作为摄像机方向
let projection = glm::Mat4::one(); // 使用正交投影,因为是平行光

let mut shader = ShadowShader::new(&model, model_view_light, projection, view_port);

// 正常用着色器渲染
for i in 0..model.indices.len() / 3 {
	let mut clip_coords: [glm::Vec4; 3] = [glm::Vec4::zero(); 3];
	for j in 0..3 {
		clip_coords[j] = shader.vertex(i, j);
	}
	triangle_with_shader(
		clip_coords[0],
		clip_coords[1],
		clip_coords[2],
		&mut shader,
		&mut image,
		&mut zbuffer,
	);
}

得到结果如下:
TinyRenderer笔记5:阴影和矫正透视投影插值.png

完整代码: 3dc57eb84b1f48e8b0a40c429a712bcb604d8f00

现在得到了光的视角的zbuffer,我们叫他shadow buffer。接下来渲染模型:
需要进行两次渲染,第一次使用ShadowShader来获得shadow buffer,第二步渲染模型时,着色器会参考第一次渲染的shadow buffer。这是新的着色器:它是由上一篇中的PhongShader扩展来的,加入了计算阴影的逻辑。

rust

pub struct PhongShaderWithShadow<'a> {
    model: &'a obj::Obj<TexturedVertex, u32>,
    diffuse: &'a ImageBuffer<Rgba<u8>, Vec<u8>>,
    diffuse_nm: &'a ImageBuffer<Rgba<u8>, Vec<u8>>, // 法线贴图
    diffuse_spec: &'a ImageBuffer<Rgba<u8>, Vec<u8>>, // 高光贴图
    shadow_buffer: &'a Vec<f32>,                    // 阴影缓冲区
    varying_uv: glm::Mat3,                          // 三个顶点的纹理坐标
    varying_tri: glm::Mat3,                         // 三个顶点的屏幕坐标
    uniform_m: Mat4, // 模型的变换矩阵m projection*model_view ,不带view_port,不用到屏幕坐标
    uniform_mv_it: Mat4, // uniform_m的逆转置矩阵 m.inverse().transpose()
    uniform_m_shadow: Mat4, // 用来将frame buffer中的屏幕坐标,转换为shadow buffer中的屏幕坐标
    light_dir: Vec3,
    view_port: Mat4,
    projection: Mat4,
    model_view: Mat4,
    width: u32, // 画布宽度
}
impl<'a> IShader for PhongShaderWithShadow<'a> {
    fn vertex(&mut self, i_face: usize, nth_vert: usize) -> glm::Vec4 {
        let i_vert = self.model.indices[i_face * 3 + nth_vert];
        let vert = self.model.vertices[i_vert as usize];
        let v = Vec3::from_array(&vert.position); // 顶点位置
        let uv = Vec3::from_array(&vert.texture); // 纹理坐标

        self.varying_uv.as_array_mut()[nth_vert] = uv.clone(); // 每一列是一个顶点处的纹理坐标
        let gl_v = self.view_port * self.projection * self.model_view * v.extend(1.); // 直接到屏幕坐标
        self.varying_tri.as_array_mut()[nth_vert] = v4p2v3(gl_v);
        gl_v
    }

    fn fragment(&mut self, bar: glm::Vec3, color: &mut image::Rgba<u8>) -> bool {
        // 把当前fragment的屏幕坐标转换到shadow buffer的屏幕坐标
        let sb_p = self.uniform_m_shadow * (self.varying_tri * bar).extend(1.);
        let sb_p = v4p2v3(sb_p);
        let idx = sb_p.x as u32 + (sb_p.y as u32 * self.width); // shadow buffer的下标

        // 当前点的阴影深度大于深度缓冲时,说明没有被遮挡光线
        let shadow = 0.3
            + 0.7
                * (if self.shadow_buffer[idx as usize] <= sb_p.z {
                    1.
                } else {
                    0.
                });

        // ...

        let mut n = Vec3::from_array(&[nm_px[0] as _, nm_px[1] as _, nm_px[2] as _]).clone(); // 从贴图中加载法向量
        n.as_array_mut()
            .iter_mut()
            .for_each(|v| *v = *v / 255. * 2. - 1.); // tga图像中[0,255], 转换到[-1,-1]
                                                     //println!("normal: {:?}", n);

        let n = self.uniform_mv_it * n.extend(0.); // 法线映射 注意向量转换位齐次坐标是填0
        let n = glm::normalize(vec4_to_3(n)); // 齐次坐标投影回3d 注意向量不需要除w分量

        let l = self.uniform_m * self.light_dir.extend(0.); // 映射光照方向
        let l = glm::normalize(vec4_to_3(l));

        let r = glm::normalize(n * (glm::dot(n, l) * 2.) - l); // 反射光方向

        let spec = glm::pow(r.z.max(0.), spec_v); // 我们从z轴看, dot(v,r)
        let diff = glm::dot(n, l).max(0.);

        let arg_ambient = 20.; // 环境光
        let arg_diffuse = 1.2; // 漫反射光
        let arg_specular = 0.6; // 镜面反射光
        let intensity = glm::dot(n, l);

        // 阴影参与计算
        let r = (arg_ambient + px[0] as f32 * shadow * (arg_diffuse * diff + arg_specular * spec))
            as u8;
        let g = (arg_ambient + px[1] as f32 * shadow * (arg_diffuse * diff + arg_specular * spec))
            as u8;
        let b = (arg_ambient + px[2] as f32 * shadow * (arg_diffuse * diff + arg_specular * spec))
            as u8;
        *color = image::Rgba([r, g, b, 255]);
        return false; // 不丢弃任何像素
    }
}

顶点着色器做的事情非常简单,只是保存下三个顶点的纹理坐标和屏幕坐标。

片段着色器增加了阴影计算的逻辑,第一步要把当前片段着色器处理的屏幕坐标,转换为我们渲染shadow buffer时的坐标:

rust

let sb_p = self.uniform_m_shadow * (self.varying_tri * bar).extend(1.);

其中uniform_m_shadow这个变换矩阵就是关键。

先看下整个渲染流程:

rust

// 渲染shadow buffer
let mut shader = ShadowShader::new(&model, model_view_light, projection, view_port);
for i in 0..model.indices.len() / 3 {
	let mut clip_coords: [glm::Vec4; 3] = [glm::Vec4::zero(); 3];
	for j in 0..3 {
		clip_coords[j] = shader.vertex(i, j);
	}
	triangle_with_shader_shadow(
		clip_coords[0],
		clip_coords[1],
		clip_coords[2],
		&mut shader,
		&mut image,
		&mut shadowbuffer,
	);
}
// 记录下渲染shadow buffer时的变换矩阵
let shadow_m = view_port * projection * model_view_light;
flip_verticin_place(&mut image);
image.save("a.png").unwrap();

// 渲染模型
#[rustfmt::skip]
let projection = glm::mat4(
	1., 0., 0., 0.,
	0., 1., 0., 0.,
	0., 0., 1., -1./ glm::distance(eye, center),
	0., 0., 0., 1.);
let mut shader = PhongShaderWithShadow::new(
	&model,
	&diffus,
	&diffus_nm,
	&diffus_spec,
	projection * model_view, // uniform_m
	(projection * model_view).inverse().unwrap().transpose(), // uniform_m 的逆转置矩阵,用来做法线变换
	shadow_m * (view_port * projection * model_view).inverse().unwrap(), // uniform_m_shadow
	light_dir,
	view_port,
	projection,
	model_view,
	width,
	&mut shadowbuffer, // 阴影信息
);
for i in 0..model.indices.len() / 3 {
	let mut clip_coords: [glm::Vec4; 3] = [glm::Vec4::zero(); 3];
	for j in 0..3 {
		clip_coords[j] = shader.vertex(i, j);
	}
	triangle_with_shader_shadow(
		clip_coords[0],
		clip_coords[1],
		clip_coords[2],
		&mut shader,
		&mut image,
		&mut zbuffer,
	);
}

flip_vertical_in_place(&mut image);
image.save("b.png").unwrap();

可以看到uniform_m_shadow是:

rust

shadow_m * (view_port * projection * model_view).inverse().unwrap()

拆分一下非常简单,这个矩阵会作用于片段着色器中的屏幕坐标:

  1. 先进行变换矩阵的逆矩阵操作,将屏幕坐标转换为初始坐标
  2. 再把初始坐标按渲染shadow buffer时的变换矩阵操作,就得到了shadow buffer中的坐标

有了当前像素在shadow buffer中的深度信息,就能判断该点是否有阴影:

rust

let shadow = 0.3
	+ 0.7
		* (if self.shadow_buffer[idx as usize] <= sb_p.z {
			1.
		} else {
			0.
		});

当前像素的深度如果大于等于shadow buffer中的深度,说明此处光线没有被遮挡。

最终效果如下
TinyRenderer笔记5:阴影和矫正透视投影插值-1.png

完整代码: 64e661fa487f3dbcbab9fc0c6b00cec28a3ea021

渲染出来的图像上有很多黑点,这个是 z-fighting(深度冲突)导致的,shadow buffer缓冲区精度不足以区分距离很近的面。作者给出的方法非常暴力,直接给深度加上一个偏移来改善,但并没有解决并且有副作用,这是个很复杂的问题。

rust

if self.shadow_buffer[idx as usize] <= sb_p.z + 43.34 {

不用太关心这个值,我试了很多值效果都差不多,打印shadow buffer值和sb_p.z的值,他们的差值在个位数到一百多不等(深度范围是$[0,2000]$)。
效果如下:注意观察手上的错误变少了
TinyRenderer笔记5:阴影和矫正透视变形.png

注意渲染结果还是有些异常,腿上很明显有黑斑,这是因为高光贴图计算时的一个bug导致的,我们是这样计算镜面反射光强度的:

rust

let r = glm::normalize(n * (glm::dot(n, l) * 2.) - l); // 反射光方向
let spec = glm::pow(r.z.max(0.), spec_v); // 我们从z轴看, dot(v,r) v在z轴所以就是r.z

我们用反射光方向和摄像机方向夹角来决定反光强度,并且这里求了一个spec_v次幂,本意是用来控制反射光半径(上一节提到过)。但是如果spec_v的值为零,那么任何数的0次方都是1,我们改一下代码防止这种情况:

rust

let spec = glm::pow(r.z.max(0.), spec_v + 1.);

再看结果就更正常了
TinyRenderer笔记5:阴影和矫正透视变形-3.png

观察下面两张图,会发现使用屏幕空间重心坐标对纹理插值会有问题:

使用屏幕空间重心坐标 使用裁剪空间重心坐标
TinyRenderer笔记5:阴影和矫正透视变形-1.png TinyRenderer笔记5:阴影和矫正透视变形-2.png

问题的原因主要是变换链的非线性。为了从齐次坐标转换为3d坐标(屏幕空间),我们除以了w分量,打破了变换的线性。因此,我们没有权利使用屏幕空间重心坐标来插值原始空间中的任何东西。

下面推导如何计算裁剪空间中的重心坐标:
属于三角形$ABC$的某个点$P$经过透视除法后变换为点$P’$ ,注意$rz+1$就是裁剪空间$w$分量

$$ P = \begin{bmatrix} x \\ y \\ z \end{bmatrix} \rightarrow P' = \frac{1}{rz+1} \begin{bmatrix} x \\ y \\ z \end{bmatrix} $$

我们知道$P’$相对于三角形$A’B’C’$的重心坐标(三角形的屏幕空间坐标),这里$\alpha’\ \beta’ \ \gamma’$就是屏幕空间重心坐标$bc_screen$

$$ P' = \begin{bmatrix} A' & B' & C' \end{bmatrix} \begin{bmatrix} \alpha' \\ \beta' \\ \gamma' \end{bmatrix} $$

我们需要找到$P$关于裁剪空间三角形$ABC$的重心坐标

$$ P = \begin{bmatrix} A & B & C \end{bmatrix} \begin{bmatrix} \alpha \\ \beta \\ \gamma \end{bmatrix} $$

重新来表示$P’$,第二个等号表示$P’$可以由$P$进行透视除法得到,第三个等号代换$A’$通过$A$进行透视除法得到

$$ P' = \begin{bmatrix} A' & B' & C' \end{bmatrix} \begin{bmatrix} \alpha' \\ \beta' \\ \gamma' \end{bmatrix} =P\frac{1}{rP.z+1} = \begin{bmatrix} A\frac{1}{rA.z+1} & B\frac{1}{rB.z+1} & C\frac{1}{rC.z+1} \end{bmatrix} \begin{bmatrix} \alpha' \\ \beta' \\ \gamma' \end{bmatrix} $$

后面的等式,两边同时乘以$rP.z+1$

$$ P = \begin{bmatrix} A & B & C \end{bmatrix} \begin{bmatrix} \alpha' / (rA.z+1) \\ \beta' / (rB.z+1) \\ \gamma' / (rC.z+1) \end{bmatrix} (rP.z+1) $$

这个等式后面的部分就是裁剪空间的重心坐标。点P可以由三个顶点乘以重心坐标得到。

如果需要求裁剪空间的重心坐标,我们看下公式,其中只有$rP.z+1$是未知的的,点$P$的$w$分量
我们求重心坐标是为了求点$P$,现在却需要$P.w$,陷入了循环。

我们需要跳出这个循环,在(归一化的)重心坐标中,所有分量的和等于1,也就是$alpha + beta+gamma=1$

$$ \left( \frac{\alpha'}{rA.z+1} + \frac{\beta'}{rB.z+1} +\frac{\gamma'}{rC.z+1} \right) (rP.z+1)=1 $$

在代码中计算裁剪空间重心坐标$bc_clip$

rust

// 屏幕空间中的重心坐标,透视除法后
let bc_screen = barycentric(a, b, c, glm::vec3(px as f32, py as f32, 0.));
// 裁剪空间中的中心坐标
let bc_clip = glm::vec3(
	bc_screen.x / a_4d.w,
	bc_screen.y / b_4d.w,
	bc_screen.z / c_4d.w,
);
let bc_clip = bc_clip / (bc_clip.x + bc_clip.y + bc_clip.z);

第一步屏幕重心坐标每个分量分别除以$ABC$的$w$分量
第二步是乘$rP.z+1$也就是除以(bc_clip.x + bc_clip.y + bc_clip.z)
这就是裁剪空间重心坐标:

$$ \begin{bmatrix} \alpha' / (rA.z+1) \\ \beta' / (rB.z+1) \\ \gamma' / (rC.z+1) \end{bmatrix} / \left( \frac{\alpha'}{rA.z+1} + \frac{\beta'}{rB.z+1} +\frac{\gamma'}{rC.z+1} \right) $$

计算深度信息和片段着色器插值时都传入裁剪空间重心坐标即可

rust

// 计算深度也使用裁剪空间的重心坐标插值
let frag_depth = glm::dot(glm::vec3(a_4d.z, b_4d.z, c_4d.z), bc_clip);

let idx = px + py * image.width() as i32;

if bc_screen.x < 0.
	|| bc_screen.y < 0.
	|| bc_screen.z < 0.
	|| zbuffer[idx as usize] > frag_depth
{
	continue;
}

let mut color = image::Rgba([0; 4]);
let discard = shader.fragment(bc_clip, &mut color);

if !discard {
	zbuffer[idx as usize] = frag_depth;
	image.put_pixel(px as u32, py as u32, color);
}

完整代码: e7ff99171d03a4477f0947513e64f9d0560b22ef

一些注意点:

  • 前面提到的正交投影矩阵(单位矩阵)和透视投影矩阵都是为了突出原理的简化版本,实际的投影矩阵更为复杂
  • 关于切空间法线贴图的内容只是提了一下,详细内容还是看原文 tangent-space-normal-mapping
  • 阴影映射时的z-fighting是个很复杂的问题,可以单开好多内容,有兴趣可以自己搜一下。

一些感悟:

  • 重心坐标很美妙
  • 线性代数很美妙

从2022年1月开始到2025年7月,花了两年多才学完,是真的懒得没边了~
接下来可能会去玩一下wgpu,也是rust。