TinyRenderer笔记5:阴影和矫正透视变形 - 完结
阴影映射
之前渲染的模型中,物体明暗强弱主要通过光方向和法向量计算,并没有考虑光被遮挡的场景。
如何确认哪些部分的光被遮挡了呢?这个问题很简单,我们把摄像机朝向和平行光方向保持一致,进行一次渲染,得到的zbuffer就能表示光的视角能看到的部分,看不到的部分就是阴影!然后再进行正常的渲染。
定义一个阴影着色器:
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; // 不丢弃任何像素
}
}
这个作色器会根据视线方向,将深度信息转换为颜色,深度越大(离摄像机越近)颜色越深
然后尝试渲染出图片,这次用暗黑三模型:
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,
);
}
得到结果如下:
完整代码: 3dc57eb84b1f48e8b0a40c429a712bcb604d8f00
现在得到了光的视角的zbuffer,我们叫他shadow buffer。接下来渲染模型:
需要进行两次渲染,第一次使用ShadowShader来获得shadow buffer,第二步渲染模型时,着色器会参考第一次渲染的shadow buffer。这是新的着色器:它是由上一篇中的PhongShader扩展来的,加入了计算阴影的逻辑。
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时的坐标:
let sb_p = self.uniform_m_shadow * (self.varying_tri * bar).extend(1.);
其中uniform_m_shadow这个变换矩阵就是关键。
先看下整个渲染流程:
// 渲染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是:
shadow_m * (view_port * projection * model_view).inverse().unwrap()
拆分一下非常简单,这个矩阵会作用于片段着色器中的屏幕坐标:
- 先进行变换矩阵的逆矩阵操作,将屏幕坐标转换为初始坐标
- 再把初始坐标按渲染shadow buffer时的变换矩阵操作,就得到了shadow buffer中的坐标
有了当前像素在shadow buffer中的深度信息,就能判断该点是否有阴影:
let shadow = 0.3
+ 0.7
* (if self.shadow_buffer[idx as usize] <= sb_p.z {
1.
} else {
0.
});
当前像素的深度如果大于等于shadow buffer中的深度,说明此处光线没有被遮挡。
最终效果如下
完整代码: 64e661fa487f3dbcbab9fc0c6b00cec28a3ea021
渲染出来的图像上有很多黑点,这个是 z-fighting(深度冲突)导致的,shadow buffer缓冲区精度不足以区分距离很近的面。作者给出的方法非常暴力,直接给深度加上一个偏移来改善,但并没有解决并且有副作用,这是个很复杂的问题。
if self.shadow_buffer[idx as usize] <= sb_p.z + 43.34 {
不用太关心这个值,我试了很多值效果都差不多,打印shadow buffer值和sb_p.z的值,他们的差值在个位数到一百多不等(深度范围是$[0,2000]$)。
效果如下:注意观察手上的错误变少了
注意渲染结果还是有些异常,腿上很明显有黑斑,这是因为高光贴图计算时的一个bug导致的,我们是这样计算镜面反射光强度的:
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,我们改一下代码防止这种情况:
let spec = glm::pow(r.z.max(0.), spec_v + 1.);
再看结果就更正常了
透视变形
观察下面两张图,会发现使用屏幕空间重心坐标对纹理插值会有问题:
使用屏幕空间重心坐标 | 使用裁剪空间重心坐标 |
---|---|
![]() |
![]() |
问题的原因主要是变换链的非线性。为了从齐次坐标转换为3d坐标(屏幕空间),我们除以了w分量,打破了变换的线性。因此,我们没有权利使用屏幕空间重心坐标来插值原始空间中的任何东西。
下面推导如何计算裁剪空间中的重心坐标:
属于三角形$ABC$的某个点$P$经过透视除法后变换为点$P’$ ,注意$rz+1$就是裁剪空间$w$分量
我们知道$P’$相对于三角形$A’B’C’$的重心坐标(三角形的屏幕空间坐标),这里$\alpha’\ \beta’ \ \gamma’$就是屏幕空间重心坐标$bc_screen$
我们需要找到$P$关于裁剪空间三角形$ABC$的重心坐标
重新来表示$P’$,第二个等号表示$P’$可以由$P$进行透视除法得到,第三个等号代换$A’$通过$A$进行透视除法得到
后面的等式,两边同时乘以$rP.z+1$
这个等式后面的部分就是裁剪空间的重心坐标。点P可以由三个顶点乘以重心坐标得到。
如果需要求裁剪空间的重心坐标,我们看下公式,其中只有$rP.z+1$是未知的的,点$P$的$w$分量
我们求重心坐标是为了求点$P$,现在却需要$P.w$,陷入了循环。
我们需要跳出这个循环,在(归一化的)重心坐标中,所有分量的和等于1,也就是$alpha + beta+gamma=1$
在代码中计算裁剪空间重心坐标$bc_clip$
// 屏幕空间中的重心坐标,透视除法后
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)
这就是裁剪空间重心坐标:
计算深度信息和片段着色器插值时都传入裁剪空间重心坐标即可
// 计算深度也使用裁剪空间的重心坐标插值
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。