普通视图

发现新文章,点击刷新页面。
昨天以前kirito的博客

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

2025年7月24日 10:01

阴影映射

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

如何确认哪些部分的光被遮挡了呢?这个问题很简单,我们把摄像机朝向和平行光方向保持一致,进行一次渲染,得到的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。

TinyRenderer笔记4:着色器

2025年1月6日 19:58

顶点着色器和片段着色器

OpenGL 2的渲染管道可以表示如下(新版本也差不多):
TinyRenderer笔记4:着色器-21.png
在较新的OpenGL中还有其他着色器,在这个课程中只关心顶点着色器(vertex shader)和片段着色器(fragment shader)。
在上面图片中,我们无法触摸的阶段都用蓝色表示,我们的回调用橙色表示。
事实上现在的main()函数是原始的处理程序(primitive processing routine),它可以叫做顶点着色器。我们在这里没有primitive assembly的步骤,由于我们仅绘制三角形在我们的代码中,它与primitive processing合并。现在的triangle()函数是是rasterizer,对于在三角形中的每个像素都调用了了片段着色器的功能,然后执行深度检查之类的。
知道了什么是着色器就可以开始创建着色器了。

顶点着色器的主要目标是变换顶点的坐标,第二个目标是为片段着色器准备数据。
片段着色器的主要目标是确定当前像素的颜色,次要目标是我们可以通过返回true丢弃当前像素。

着色器的定义:

rust

pub trait IShader {
    /// 顶点着色器
    /// iface 第i个面, nth_vert 面的第n个顶点
    /// 返回顶点在裁剪空间的坐标(齐次坐标)
    fn vertex(&mut self, i_face: usize, nth_vert: usize) -> glm::Vec4;
    /// 片段着色器
    /// bar 当前像素在三角形中的重心坐标 color 像素颜色
    /// 返回true表示丢弃当前像素
    fn fragment(&mut self, bar: Vec3, color: &mut image::Rgba<u8>) -> bool;
}

实现一个着色器

实现一个简单的Gouraud着色器

rust

pub struct GouraudShader<'a> {
    model: &'a obj::Obj<TexturedVertex, u32>,
    varying_intensity: glm::Vec3, // 强度变化,由顶点着色器写入,由片段着色器读取
    view_port: Mat4,
    projection: Mat4,
    model_view: Mat4,
    light_dir: Vec3,
}

impl<'a> GouraudShader<'a> {
    pub fn new(
        model: &'a obj::Obj<TexturedVertex, u32>,
        model_view: Mat4,
        projection: Mat4,
        view_port: Mat4,
        light_dir: Vec3,
    )// 实现省略;
}

impl<'a> IShader for GouraudShader<'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 normal = Vec3::from_array(&vert.normal);
        let v = Vec3::from_array(&vert.position);
        let gl_v = self.view_port * self.projection * self.model_view * v.extend(1.);
        self.varying_intensity[nth_vert] = glm::dot(*normal, self.light_dir).max(0.); // 计算每个顶点的光照强度
        gl_v
    }

    fn fragment(&mut self, bar: glm::Vec3, color: &mut image::Rgba<u8>) -> bool {
        let intensity = glm::dot(self.varying_intensity, bar); // 当前像素的插值强度,重心坐标计算相对三个顶点的强度
        let x = (255. * intensity) as u8;
        *color = image::Rgba([x, x, x, 255]);
        return false; // 不丢弃任何像素
    }
}

使用这个着色器,重新实现main函数绘制模型:

rust

fn main() {
    let eye = glm::vec3(0., -1., 3.); // camera
    let center = glm::vec3(0., 0., 0.);
    let up = glm::vec3(0., 1., 0.);
    let light_dir = glm::normalize(glm::vec3(1., 1., 1.));
    let (width, height) = (800, 800);
    let mut image = ImageBuffer::<Rgba<u8>, _>::from_pixel(width, height, BLACK);
    let mut zbuffer = ImageBuffer::<Luma<u8>, _>::from_pixel(width, height, Luma([0]));

    let model = obj::load_obj::<obj::TexturedVertex, _, u32>(input).unwrap();

    let model_view = lookat(eye, center, up);

    #[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 view_port = viewport(
        width as i32 / 8,
        height as i32 / 8,
        width as i32 * 3 / 4,
        height as i32 * 3 / 4,
    );

	// 创建着色器
    let mut shader = GouraudShader::new(&model, model_view, projection, view_port, light_dir);
    // 遍历每个面
    for i in 0..model.indices.len() / 3 {
        let mut screen_coords: [glm::Vec4; 3] = [glm::Vec4::zero(); 3];
        // 遍历每个面的每个顶点
        for j in 0..3 {
            screen_coords[j] = shader.vertex(i, j);
        }
        triangle_with_shader(
            screen_coords[0],
            screen_coords[1],
            screen_coords[2],
            &mut shader,
            &mut image,
            &mut zbuffer,
        );
    }

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

在遍历每个顶点时,调用顶点着色器,在对三角行进行栅格化的时候会调用片段着色器:

rust

/// 注意现在输入的顶点坐标是齐次坐标
pub fn triangle_with_shader<
    I: GenericImage<Pixel = Rgba<u8>>,
    I2: GenericImage<Pixel = Luma<u8>>,
    S: IShader,
>(
    a_4d: glm::Vec4,
    b_4d: glm::Vec4,
    c_4d: glm::Vec4,
    shader: &mut S,
    image: &mut I,
    zbuffer: &mut I2,
) {
    let a = v4p2v3(a_4d); // a b c是齐次坐标投影到屏幕上的坐标
    let b = v4p2v3(b_4d);
    let c = v4p2v3(c_4d);
    // 确定枚举像素的边界
    let bboxmin = glm::vec2(a.x.min(b.x).min(c.x).max(0.), a.y.min(b.y).min(c.y).max(0.));
    let bboxmax = glm::vec2(
        a.x.max(b.x).max(c.x).min(image.width() as f32 - 1.),
        a.y.max(b.y).max(c.y).min(image.height() as f32 - 1.),
    );

    for px in bboxmin.x as i32..=bboxmax.x as i32 {
        for py in bboxmin.y as i32..=bboxmax.y as i32 {
            let bc_screen = barycentric(a, b, c, glm::vec3(px as f32, py as f32, 0.));
            // 留意下这里,z和w都使用齐次坐标算的
            let z = glm::dot(glm::vec3(a_4d.z, b_4d.z, c_4d.z), bc_screen);
            let w = glm::dot(glm::vec3(a_4d.w, b_4d.w, c_4d.w), bc_screen);

            let frag_depth = (z / w + 0.5) as u8;
            let frag_depth = frag_depth.min(255).max(0);

            if bc_screen.x < 0. || bc_screen.y < 0. || bc_screen.z < 0. {
                continue;
            }
            let mut color = image::Rgba([0; 4]);
            let discard = shader.fragment(bc_screen, &mut color);
            let idx = px + py * image.width() as i32;
            let zb: &mut Luma<u8> = zbuffer.get_pixel_mut(px as _, py as _);
            if zb.0[0] <= frag_depth {
                zb.0[0] = frag_depth;
                if !discard {
                    image.put_pixel(px as u32, py as u32, color);
                }
            }
        }
    }
}

最终画出来的图片如下,一个模型,一个zbuffer的灰度图:

a.png b.png
TinyRenderer笔记4:着色器-22.png TinyRenderer笔记4:着色器-23.png

这里是完整代码: 11e8d2cbfc251d694bb65ae40ec463b54636b0c0

修改片段着色器

令强度仅具有6个值:

rust

let eye = glm::vec3(1., 1., 3.); // camera
let center = glm::vec3(0., 0., 0.);
let up = glm::vec3(0., 1., 0.);
let light_dir = glm::normalize(glm::vec3(1., 1., 0.9));

fn fragment(&mut self, bar: glm::Vec3, color: &mut image::Rgba<u8>) -> bool {
	let mut intensity = glm::dot(self.varying_intensity, bar); 
	intensity = if intensity > 0.85 {
		1.
	} else if intensity > 0.6 {
		0.8
	} else if intensity > 0.45 {
		0.6
	} else if intensity > 0.3 {
		0.45
	} else if intensity > 0.15 {
		0.3
	} else {
		0.
	};

	*color = image::Rgba([(255. * intensity) as u8, (155. * intensity) as u8, 0, 255]);
	return false;
}

查看变化
TinyRenderer笔记4:着色器-24.png

纹理

现在只计算了每个点的光照,修改着色器带上纹理:

rust

varying_uv: glm::Mat3,        // 三个顶点的纹理坐标

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 normal = Vec3::from_array(&vert.normal); // 顶点法向量
        let v = Vec3::from_array(&vert.position); // 顶点位置
        let uv = Vec3::from_array(&vert.texture); // 纹理坐标
        let gl_v = self.view_port * self.projection * self.model_view * v.extend(1.);
        self.varying_intensity[nth_vert] = glm::dot(*normal, self.light_dir).max(0.); // 计算每个顶点的光照强度
        self.varying_uv.as_array_mut()[nth_vert] = uv.clone(); // 每一列是一个顶点出的纹理坐标
        gl_v
    }

    fn fragment(&mut self, bar: glm::Vec3, color: &mut image::Rgba<u8>) -> bool {
        let intensity = glm::dot(self.varying_intensity, bar); // 当前像素的插值强度,重心坐标计算相对三个顶点的强度
        let uv = self.varying_uv * bar; // 用重心坐标插值当前点的纹理坐标
        let px = self.diffuse.get_pixel(
            (uv.x * self.diffuse.width() as f32) as _,
            (uv.y * self.diffuse.height() as f32) as _,
        );
        let r = (px[0] as f32 * intensity) as u8;
        let g = (px[1] as f32 * intensity) as u8;
        let b = (px[2] as f32 * intensity) as u8;
        *color = image::Rgba([r, g, b, 255]);
        return false; // 不丢弃任何像素
    }

查看结果:
TinyRenderer笔记4:着色器-25.png
代码:81b8c2ae2d04f70036f8ef4227f9bb2ffd5a519a

几种着色方法介绍

  • 平面(Flat)着色: 每个三角形只计算一个光照
  • Gouraud着色: 每个三角形计算三个顶点的光照,使用三个顶点的光照对三角形中每个点做线性插值
  • Phong着色: 我们把三角形的每个点的法向量都插值出来,然后再计算光照
    TinyRenderer笔记4:着色器-26.png

法线贴图(Normal Mapping)

我们有纹理坐标,和这样的纹理贴图。
TinyRenderer笔记4:着色器-27.png
除了纹理,也可以把几乎任何东西存进纹理图像中。它可以是颜色、方向、温度等等。
让我们加载这个纹理:
TinyRenderer笔记4:着色器.png
如果我们将RGB值解释为xyz方向,该图像为我们提供了每个渲染像素的法向量,这样我们就不用靠三个顶点的法向量来插值法向量了。
需要注意,如果对模型进行了仿射变换,那么法向量需要做映射才能使用,上篇有讲,结论:法向量的变换矩阵为模型变换矩阵的逆转置矩阵。
也有一些表示方式能直接描述切空间中的法向量。在达布坐标系中,z向量垂直于物体,x是主曲率方向,y是它们的叉积。详细看这里

下面是使用纹理映射的例子:
从这里新建了一个Phong着色器的实现

rust

diffuse_nm: &'a ImageBuffer<Rgba<u8>, Vec<u8>>, // 法线贴图
uniform_m: Mat4,              // 模型的变换矩阵m projection*model_view
uniform_mit: Mat4,            // m的逆转置矩阵 m.inverse().transpose()

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 normal = Vec3::from_array(&vert.normal); // 顶点法向量
	let v = Vec3::from_array(&vert.position); // 顶点位置
	let uv = Vec3::from_array(&vert.texture); // 纹理坐标
	let gl_v = self.uniform_m * v.extend(1.);
	self.varying_uv.as_array_mut()[nth_vert] = uv.clone(); // 每一列是一个顶点出的纹理坐标
	gl_v
}

fn fragment(&mut self, bar: glm::Vec3, color: &mut image::Rgba<u8>) -> bool {
	let uv = self.varying_uv * bar; // 用重心坐标插值当前点的纹理坐标
	let px = self.diffuse.get_pixel(
		(uv.x * self.diffuse.width() as f32) as _,
		(uv.y * self.diffuse.height() as f32) as _,
	);
	let nm_px = self.diffuse_nm.get_pixel(
		(uv.x * self.diffuse.width() as f32) as _,
		(uv.y * self.diffuse.height() as f32) as _,
	);

	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]
	let n = self.uniform_mit * 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 intensity = glm::dot(n, l);

	let r = (px[0] as f32 * intensity) as u8;
	let g = (px[1] as f32 * intensity) as u8;
	let b = (px[2] as f32 * intensity) as u8;
	*color = image::Rgba([r, g, b, 255]);
	return false; // 不丢弃任何像素
}

需要注意的点是向量再参与齐次坐标运算时,w分量需要是0,计算后投影回3d时xyz不需要除以w分量。
这里再贴下点和向量映射回来时的区别:

rust

/// 齐次坐标系中的点投影到3d
/// 点坐标需要除以w
pub fn v4p2v3(v: glm::Vec4) -> glm::Vec3 {
    glm::vec3(v.x / v.w, v.y / v.w, v.z / v.w)
}

/// 齐次坐标系中的向量投影到3d
/// 向量坐标不需要除以w
pub fn vec4_to_3(v: glm::Vec4) -> glm::Vec3 {
    glm::vec3(v.x, v.y, v.z)
}

结果:

TinyRenderer笔记4:着色器-28.png
可以看到皮肤的起伏细节相较于只用三个顶点的法向量插值要好的多。

代码:951f42ea125a28c4f7e7aa83573d68fd43cef472

上面得到的图片比作者的亮,后来发现是全局光照进不进行矩阵变换的区别。再次修改代码

rust

// let l = self.light_dir.extend(0.); // 之前是在顶点作色器中计算光照,现在要在切空间计算
// let l = glm::normalize(vec4_to_3(l));
let l = self.light_dir; // 全局光照不进行矩阵变换
let intensity = glm::dot(n, l);

结果:
TinyRenderer笔记4:着色器-29.png

代码:64d58b1c6d0569db3db1eefaead38443d05b9ac9

高光贴图(Specular Mapping)

Phong光照模型:
Phong提议将最终照明视为三个光强度的加权总和:环境光(ambient lighting)、漫反射光(diffuse lighting)、镜面光(specular lighting)
TinyRenderer笔记4:着色器-30.png
我们前面计算的光都是漫反射光,计算了光方向向量和法线向量的余弦值。这假设了光在各个方向上均匀反射。如果是光滑的表面比如镜面,光反射范围会更小,只有反射到了我们的眼睛内我们才能看见。
TinyRenderer笔记4:着色器-31.png

对于漫反射光我们关心的是向量$n$和向量$l$的余弦值,现在我们要开始关注反射光$r$和视角方向$v$之间的夹角。
给出$n$和$l$如何求$r$:glm::normalize(n * (glm::dot(n, l) * 2.) - l)

$$ r=2n(n\cdot l)-l $$

光滑的表面在一个方向上的反射比在其他方向上的反射要多,如果我们使用余弦值的$n$次方会怎样${\cos \theta }^{n}$,所有小于1的数在进行幂运算时都会减小,这意味这余弦的$n$次方会使反射光的半径变小。不同材质的反射表现,这个信息可以存在高光贴图中。他告诉我们每个点是否有光泽。

rust

diffuse_spec: &'a ImageBuffer<Rgba<u8>, Vec<u8>>, // 高光贴图

fn fragment(&mut self, bar: glm::Vec3, color: &mut image::Rgba<u8>) -> bool {
	let uv = self.varying_uv * bar; // 用重心坐标插值当前点的纹理坐标
	let px = self.diffuse.get_pixel(
		(uv.x * self.diffuse.width() as f32) as _,
		(uv.y * self.diffuse.height() as f32) as _,
	);
	let nm_px = self.diffuse_nm.get_pixel(
		(uv.x * self.diffuse_nm.width() as f32) as _,
		(uv.y * self.diffuse_nm.height() as f32) as _,
	);
	let spec_px = self.diffuse_spec.get_pixel(
		(uv.x * self.diffuse_spec.width() as f32) as _,
		(uv.y * self.diffuse_spec.height() as f32) as _,
	);
	let spec_v = spec_px[0] as f32 / 1.; // 光泽值, 这个值越小越反射范围越大,越不光泽,越大越有光泽

	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]

	let n = self.uniform_mit * 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 = 5.; // 环境光
	let arg_diffuse = 1.; // 漫反射光
	let arg_specular = 0.6; // 镜面反射光
	let intensity = glm::dot(n, l);

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

这些参数都是可以调整的,不同的选择会给对象带来不同的外观

rust

let arg_ambient = 5.; // 环境光
let arg_diffuse = 1.; // 漫反射光
let arg_specular = 0.6; // 镜面反射光

结果如下:
TinyRenderer笔记4:着色器-32.png

代码:175d0b61c92a55d08ca9b349b77dfb245d7a1d2e

常用激活函数和损失函数

2024年5月6日 19:12

sigmod

常用激活函数和损失函数.png
定义:

$$ \sigma(x) = \frac{1}{1+e^{-x}} $$

求导:

$$ \sigma' = \sigma(x)(1-\sigma(x)) $$

softmax

作为分类问题输出层的激活函数
将各个输出节点的输出值范围映射到[0, 1],并且约束各个输出节点的输出值的和为1
常用激活函数和损失函数-1.png

定义:

$$ softmax(z_i) = \frac{e^{z_i}}{\sum ^{K}_{j=1}e^{z_j}} $$

求导:设$softmax(z_i) = p_i$

$$ \frac{\partial y_i}{\partial z_j} = \begin{cases} p_i(1-p_j) &j=i\\ -p_j\cdot p_i &j\neq i \end{cases} $$

交叉熵损失函数

softmax通常配合交叉熵损失函数使用

$$ p_i = softmax(z_i) = \frac{e^{z_i}}{\sum ^{K}_{j=1}e^{z_j}} $$

定义:$y_i$是真实样本标签值,分类问题里不是0就是1

$$ L=- \sum ^K_{i=1}y_i\log(p_i) $$

求导:

$$ \frac{\partial L}{\partial z_i} = p_i - y_i $$

求导非常简单,所以softmax和交叉熵一起用,反向传播时候计算就非常简单了

rust手写神经网络

2024年3月30日 16:43

用rust实现上篇笔记:神经网络的结构 中描述的神经网络
还在实现中。。。

训练过程

  • 确定loss函数,$f(x)$为样本的推理结果,$y$是目标结果,多个样本sum得出loss
$$ loss=\frac{1}{n}\sum^n_{i=1} (f(x_i)-y_i)^2 $$
  • 将训练样本分为n个batch,每个batch n个样本
  • 按batch进行训练,将batch中每个样本进行正向传播
  • 记录每个样本的结果,以及这个样本正向传播过程中的所有中间结果
  • 遍历完每个样本后,计算loss
  • 通过loss值对每个样本进行反向传播,过程中需要用到前面缓存的中间结果
  • 记录每个样本反向传播过得到的梯度,将所有样本的梯度求平均值,得到最终调整网络的梯度。因为loss是sum的,所以在算一个样本偏导时,其他样本为常量,求导是0,所以每个样本梯度可以独立计算。
$$ a^2 -2ab + b2 => 2a-2b => 2(a-b) => \frac{2(a-b)}{n} $$
  • 根据学习率调整网络参数
  • 将所有batch如此循环来一遍,完成一轮训练
  • 用所有训练样本计算下loss,多轮训练后得到满意的模型

神经网络的表示

要实现的是一个全连接神经网络,整个网络模型有很多层(Layer),除了输入层输出层,其余层都统称为隐藏层,对于要实现的网络可以叫全连接层。在每层里都有若干神经元,每个神经元上都保存着该神经元的偏置$b$和与上层神经元链接的每条边的权重$w$。 可以对Layer进行一定程度抽象,每一层都支持正向传播、反向传播、更新参数

rust

pub struct NeuralNetworkModel {
    pub layers: Vec<Box<dyn Layer>>,
}

pub trait Layer {
    // 正向传播
    // 返回:本层输出 & 本层中间结果
    fn forward(&mut self, input: &MatView, training: bool) -> (Mat, LayerCache);
    // 反向传播
    // grads: 后面一层传递过来的梯度
    // cache_forward: 本层正向传播时的输入和激活值,内容为forward的返回
    // 返回: 本层向前一层传递的梯度 & 本层所有梯度值
    fn backward(&mut self, grads: &MatView, cache_forward: &LayerCache) -> (Mat, LayerCache);
    // 更新权重和偏置
    // grads: 本层调整参考的梯度, 内容格式与backward返回的一致
    fn update(&mut self, learning_rate: f32, grads: &LayerCache);
}

在实际操作中,可以把一个全连接层拆为两层,即将激活函数抽出单独虚拟为一层,这样更加灵活

rust手写神经网络.png

激活函数层

先看简单的激活函数层,使用sigmod作为激活函数,激活函数层上没有任何权重和偏置,所以不需要存储任何参数:

rust

// 使用激活函数sigmod的层
pub struct SigmodLayer {}

正向传播时只需要求每个输入的sigmod值即可,sigmod的公式是:

$$ \sigma(x) = \frac{1}{1+e^{-x}} $$

rust

// 激活函数层每个神经元只有一条入边, 只是对上层的输出做一个转换, 矩阵形状n行1列
fn forward(&mut self, input: &MatView, training: bool) -> (Mat, LayerCache) {
	let out = input.map(|x| sigmod(*x));
	// 只有在训练时候才保存输出值,反向传播会用到
	let mut cache = vec![];
	if training {
		cache.push(out.clone());
	}
	(out, cache)
}

求导公式如下 详见:Sigmoid函数求导

$$ \sigma' = \sigma(x)(1-\sigma(x)) $$

有了求导公式,就可以写出sigmod层反向传播的实现:

rust

// 激活函数层反向传播, 对sigmod(x)求导即可, 每个神经元只有一条入边,返回的梯度是 n行1列
// simod(x)求导是 sigmod(x)*(1-sigmod(x))
// 每个神经元有多条出边,链式法则后要累加结果
fn backward(&mut self, grads: &MatView, cache_forward: &LayerCache) -> (Mat, LayerCache) {
	// sigmod(x)的值
	let a = cache_forward[0].view();

	// 激活函数层的输入和输出数量是相等的, 返回值长度和前一层神经元数量一致
	let mut r = Mat::from_shape_fn((a.len(), 1), |(_, _)| 0.);

	// 对每个神经元求梯度
	for (i, out) in a.iter().enumerate() {
		// 累加当前神经元每条出边的偏导
		for g in grads.rows().into_iter() {
			// 链式法则,与输入偏导相乘
			// 当前神经元为 i, 所以g也取每行第i个
			r[(i, 0)] += g[i] * (out * (1.0 - out));
		}
	}

	// 激活函数层没有任何存储任何权重和偏置,无需update
	(r, vec![])
}

没有参数,所以不需要根据梯度更新参数:

RUST

fn update(&mut self, _learning_rate: f32, _gradss: &LayerCache) {
	//不需要做任何事情
}

测试:
rust手写神经网络-1.png

正向传播:输入两个$0$,sigmod层激活值是$0.5$,输出层值为$1$
反向传播:$z=wa+b$对$w$求偏导为$a$,所以输出层每个边的偏导为$0.5$
sigmod层的偏导为 $\sum \sigma \times (1-\sigma) \times g = 0.5 \times (1-0.5) \times 0.5 = 0.125$,每个神经元只有一条出边所以不用sum

rust

let mut s = SigmodLayer::new();
let a = s.forward(&array![[0.], [0.]].view(), true);
println!("a:\n{}", a);
assert_eq!(a, array![[0.5], [0.5]]);
let g = s.backward_and_update(&array![[0.5, 0.5]].view(), 0.1);
println!("g:\n{}", g);
assert_eq!(g, array![[0.125], [0.125]])

完整代码 4157fcb

全连接层

全连接层需要存储w和b,并且特地不带激活函数:

rust

// 没有激活函数的全连接层
pub struct DenseLayerNoActive {
    // 每个神经元与上一层所有神经元边的权重, n行j列,n是本层神经元个数,j是前一层神经元个数
    pub w: Mat,
    // 每个神经元的偏置, n行1列
    pub b: Mat,
}

正向传播公式很简单,单个节点的激活值:

$$ z = w_1\times a_1 + w_2\times a_2 +\ ... + w_n*a_n + b $$

RUST

fn forward(&mut self, input: &MatView, training: bool) -> (Mat, LayerCache) {
	// 计算每个神经元激活值 w1*a1 + w2*a2 + ... + wn*an + b
	// 矩阵计算,一次算出结果, w的每行乘以输入的一列最后加b
	let r = self.w.dot(input) + &self.b;
	let mut cache = vec![];
	if training {
		cache.push(input.to_owned());
	}
	(r, cache)
}

对 $w_i$ 求偏导为$a_i$ 对$b$求偏导为$1$
反向传播:

rust

// 每个神经元有多(k)条入边返回的梯度是 n行k列
// z=w*a+b 对w求导是a, 对b求导是1
// 每个神经元看作有多条出边,链式法则后仍要累加(大多情况后一层是激活函数层,只有1条出边,但不排除其他可能)
fn backward(&mut self, grads: &MatView, cache_forward: &LayerCache) -> (Mat, LayerCache) {
	let a = cache_forward[0].view();

	let mut bias_grads = Mat::zeros(self.b.raw_dim());
	let mut w_grads = Mat::zeros(self.w.raw_dim());

	// 对每个神经元求所有w和b的偏导, 每个w的导数都是与其相乘的a, w不需要参与, 对b的偏导是1
	for (i, _) in self.w.columns().into_iter().enumerate() {
		// 累加当前神经元每条出边上的偏导, grads的每行,都是前一层某个神经元和本层连线的偏导
		for g in grads.rows().into_iter() {
			// b在这里求 链式法则相乘
			bias_grads[(i, 0)] += g[i] * 1.;
			//每个神经元上都有和前一层神经元的边, 连接w和a
			for (k, a) in a.rows().into_iter().enumerate() {
				w_grads[(i, k)] += a[0] * g[i];
			}
		}
	}

	let grads_cache = vec![bias_grads, w_grads.clone()];

	// 入边只和w有关系,不用返回偏置上的偏导
	(w_grads, grads_cache)
}

最后实现参数更新:

rust

fn update(&mut self, learning_rate: f32, grades: &LayerCache) {
	let bias_grads = grades[0].view();
	let w_grads = grades[1].view();
	// 更新偏置
	let (i, j) = (self.w.shape()[0], self.w.shape()[1]);
	for i in 0..i {
		for j in 0..j {
			self.w[(i, j)] -= learning_rate * w_grads[(i, j)];
		}
		self.b[(i, 0)] -= learning_rate * bias_grads[(i, 0)];
	}
}

测试:
rust手写神经网络-2.png

rust

let mut d = DenseLayerNoActive {
	w: array![[2., 2.], [2., 2.]],
	b: array![[0.1], [0.1]],
};

let (a, f_cache) = d.forward(&array![[0.5], [1.]].view(), true);
println!("a:\n{}", a);
assert_eq!(a, array![[3.1], [3.1]]);
let (g, b_cache) = d.backward(&array![[3.1, 3.1], [3.1, 3.1]].view(), &f_cache);
println!(
	"g:\n{}, b_cache:\ng_b:\n{}\ng_w\n{}",
	g, b_cache[0], b_cache[1]
);
assert_eq!(g, array![[3.1, 6.2], [3.1, 6.2]]);
d.update(0.1, &b_cache);
println!("d.w:\n{}\nd.b\n{}", d.w, d.b);

完整代码 4157fcb

参考

$$ C_x = \frac{(y-a)^2}{2} a = \sigma(z) \delta^L= a - y $$

Nostr账号Nip05验证方法

2023年2月4日 11:57

最近Damus App很火,写一篇教程教大家如何在App中获得紫v图标认证。

NIP05简介

NIP是Nostr改进提议Nostr Improvement ProposalNIP05描述了一种账号验证方法。可以想象成telegram或者twitter上的蓝色V标记,在Damus里是一个紫V图标,客户端展示这个图标,就说明该用户通过了NIP05验证。

该验证由客户端发起,当发现用户设置了用户名和NIP05验证地址,会发送一个https请求。

比如用户名kirito和验证地址kirito@dogdogback.com,客户端会发送如下请求:

text

https://dogdogback.com/.well-known/nostr.json?name=kirito

如果验证成功,dogdogback.com应该返回如下结果:

json

{
  "names": {
    "kirito": "2f7caa968b0ec9bacd55a07cfaf6206aab5a62387c76303c311db949dec8bc57"
  }
}

你也可以调用这个请求观察下返回: https://dogdogback.com/.well-known/nostr.json?name=kirito

客户端对比返回结果里的公钥和用户的公钥,如果一致则验证完成,Damus会在你头像后展示紫V图标。

验证方法

有两种路线:

  1. 找提供验证服务的社区项目,直接用他们的服务,这个自己找吧,缺点是可能不稳定,也不受自己控制
  2. 用自己的域名进行验证,前提是你拥有一个域名的控制权,本文主要讲解这种方式

静态文件

如果你已经有自己的网站了,并且支持https,那么直接在你的网站根目录放置一个静态文件即可。 文件名为.well-known/nostr.json,内容写上你的用户名和公钥:

text

{
  "names": {
    "kirito": "2f7caa968b0ec9bacd55a07cfaf6206aab5a62387c76303c311db949dec8bc57"
  }
}

可以写多行,为你的小伙伴也提供验证。

云函数

当然有更好的方式,而且不需要服务器和证书,下面介绍下我使用的方式:vercel云函数(你自己会搭服务或者有其他云函数也行,原理一样)

我之前写过一篇怎么用vercel云函数的文章,可以参考,当你弄好后,绑定自己的域名,就可以通过自己的域名访问云函数了,这是一个例子: https://dogdogback.com/api/list

代码如果不会写直接克隆我的仓库即可vercel-faas

具体操作:

  1. 修改代码仓库中的文件vercel.json,添加NIP05接口的重定向,这样访问路径/.well-known/nostr.json?name=xxx的请求会被交给/api/entrypoint.go文件处理:

    json

    {
    "trailingSlash": false,
    "rewrites":
    [
        {
        "source": "/api/(.*)",
        "destination": "/api/entrypoint.go"
        },
        {
        "source": "/.well-known/nostr.json",
        "destination": "/api/entrypoint.go"
        }
    ]
    }
  2. 添加处理认证请求的路由,修改/api/entrypoint.go

    go

    func registerRouter(r *gin.RouterGroup) {
        // ...
        r.GET("/.well-known/nostr.json", handler.Cors, handler.NIP05)
    }
  3. 添加认证逻辑,修改/handler/handler.go:

    go

    func NIP05(c *gin.Context) {
        name2pubkey := map[string]string{
            "kirito": "2f7caa968b0ec9bacd55a07cfaf6206aab5a62387c76303c311db949dec8bc57",
            // 可以在这里添加更多的账号,为你的朋友提供验证
            // "<name1>":"pubkey1",
            // "<name2>":"pubkey2",
        }
        user := c.Query("name")
        fmt.Println("nip05 verify request", user)
        if v, ok := name2pubkey[user]; ok {
            resp := NIP05Resp{}
            resp.Names[user] = v
            c.JSON(http.StatusOK, resp)
        }
        c.Status(http.StatusNotFound)
        return
    }

把代码push到你的仓库,vercel会自动重新部署,然后用你的域名访问看看效果,这是我的:

  1. https://dogdogback.com/.well-known/nostr.json?name=kirito
  2. https://dogdogback.com/.well-known/nostr.json?name=shishi

客户端设置

以Damus举例,编辑资料,配置你的用户名和NIP05地址,就可以看到紫V图标了

到这里就成功了,有疑问可以评论区提。

一些提示

  1. 有些webapp请求是会受到浏览器跨域策略限制,在http返回header中设置Access-Control-Allow-Origin: *,我的代码中已经处理了,如果用静态文件的方式无法处理跨域。
  2. 如何获取hex格式的公钥?在Damus中长按你自己发送的信息,选择Copy Note JSON然后粘贴到一个地方,就能看到了。
  3. vercel也是可以托管静态文件的,仓库里只留一个json文件就行。
  4. 富哥V我50

水群学习法

2022年12月8日 12:04

云玩家,全靠群友水群时候蹭经验,群友太强啦!

关于Box<dyn Trait>

问题:

这里是box<struct> -> box<dyn trait>, 还是box<struct -> dyn trait>, 如果是后者,运行时怎么拿到size的?

rust

fn thing_factory(thingtype: i32) -> Box<dyn DoThings> {
    if thingtype == 1 {
        return Box::new(MyStruct1 {});
    }
    if thingtype == 2 {
        return Box::new(MyStruct2 {});
    }
    panic!()
}

答案 box<struct> -> box<dyn trait>

土豆: Box<dyn Trait> == *mut dyn Trait == *mut T + *const metadata_of::<T as Trait>()

菜: 编译器会自动吧box<T>包装成box<T + metadate>对吧

土豆: 那是你亲手写的 as

菜: 其实return box<T> 就是 box<T> as box<dyn trait>,这样对吧

土豆: 照说是应该写出来 as _

5大郎: Rust 能有限做 implicit conversion,因为 Rust implicit conversion 是非常少的,但是这里有

对魔忍 | Han:

关于Variance

协变、逆变、不变 相关背景可以看这篇blog:Rust Subtyping and Variance

疯狂转发到我收藏夹

κόσμος: 假设你有一个 List<Dog>,Dog 是 Animal 的子型,你肯定可以传进 func(x: List<Animal>)

如果这个 func 里修改 List<Animal> 会发生什么?animals.clear(); animals.add(new Cat());

你会发现你的 List<Dog> 里出现了一个 Cat, 这就是为什么在 mutable 下不能 variant

一样的办法,把这个函数想象成另一个函数的参数。一个函数需要一个 callback: fn(Dog)->Ret,那理应是可以传一个 fn(Animal)->Ret 作为这个 callback 的, 因为这个 fn(Animal) -> Ret 一定可以处理 Dog 数据。所以 fn(Animal)->Ret < fn(Dog)->Ret, 而 Dog < Animal,所以函数参数这样的一个型构造是逆变的

nicball: 你可以把fn()→Cat当成fn()→Animal

你可以把fn(Animal)→()当成fn(Cat)→()

所以函数对返回类型协变,对参数类型逆变

或者把他当成getter setter

可以想成读的时候协变,写的时候逆变

那可变的数据结构就只能不变了

屌图

rust_iterator

TinyRenderer笔记0:画线和三角以及面剔除

2022年1月1日 20:06

这篇是自己学习tinyrenderer的笔记,不务正业系列。

tinyrenderer教程地址:https://github.com/ssloy/tinyrenderer/wiki

作者教程是用cpp实现的,我用rust来学,列一下用到的库:

  • image - 这是个图片库,充当画布,控制每个像素的颜色,生成图片
  • obj-rs - 解析obj文件的库,关于obj文件可以看Wavefront OBJ文件格式,里面存的是一些模型顶点
  • glm - 数学库,比如坐标表示,计算向量点乘、叉乘,矩阵运算

准备画布

用image库操作图片的每个像素,生成图片:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use image::{imageops::flip_vertical_in_place, ImageBuffer, Rgba};

const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]);
const RED: Rgba<u8> = Rgba([255, 0, 0, 255]);

fn main() {
    let (width, height) = (400, 400);
    let mut image = ImageBuffer::<Rgba<u8>, _>::from_pixel(width, height, BLACK);

    for x in 0..200 {
        for y in 0..200 {
            image.put_pixel(x, y, RED);
        }
    }

    flip_vertical_in_place(&mut image); // 垂直反转,因为默认坐标原点在左上角,反转后在左下角
    image.save("a.png").unwrap();
}

画线

画线比较简单,思路是对于线段ab,从a出发到b一路画点,x坐标每次移动一个像素,然后计算y坐标应该移动多少,还有一些细节直接看代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
fn line<I: GenericImage>(mut a: glm::IVec2, mut b: glm::IVec2, image: &mut I, color: I::Pixel) {
    let mut steep = false;
    if (a.x - b.x).abs() < (a.y - b.y).abs() {
        // 如果这条线是陡峭的,就交换x,y让它躺平
        swap(&mut a.x, &mut a.y);
        swap(&mut b.x, &mut b.y);
        steep = true;
    }
    if a.x > b.x {
        // make it left−to−right
        swap(&mut a, &mut b);
    }
    let dx = b.x - a.x;
    let dy = b.y - a.y;
    let derror = (dy as f64 / dx as f64).abs();
    let mut error = 0.0;
    let mut y = a.y;
    for x in a.x..=b.x {
        if steep {
            image.put_pixel(y as u32, x as u32, color);
        } else {
            image.put_pixel(x as u32, y as u32, color);
        }
        error += derror;
        if error > 0.5 {
            y += if b.y > a.y { 1 } else { -1 };
            error -= 1.0;
        }
    }
}

如果这条线很陡峭,那么x坐标移动一个像素,对应的y坐标可能要移动好几个像素,画出来不连续,所以让它躺平。

然后我们从左到右画,dy/dx算出斜率,比如斜率是0.5,那么x移动一个像素,y应该移动0.5个像素。

因为像素不能分割,所以按照四舍五入的方式,y的变化积攒到一定量再移动一个单位。

看着已经很完美了,但是里面有浮点数,可以根据简单的代换,消除浮点数:我们让derror = derror' * 2dx = 2dy,那么error > 0.5变成error > dxerror -= 1.0变成error -= 2dx,这样一来就全是整数计算:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
fn line<I: GenericImage>(mut a: glm::IVec2, mut b: glm::IVec2, image: &mut I, color: I::Pixel) {
    let mut steep = false;
    if (a.x - b.x).abs() < (a.y - b.y).abs() {
        // if the line is steep, we transpose the image
        swap(&mut a.x, &mut a.y);
        swap(&mut b.x, &mut b.y);
        steep = true;
    }
    if a.x > b.x {
        // make it left−to−right
        swap(&mut a, &mut b);
    }
    let dx = b.x - a.x;
    let dy = b.y - a.y;
    let derror = dy.abs() * 2;
    let mut error = 0;
    let mut y = a.y;
    for x in a.x..=b.x {
        if steep {
            image.put_pixel(y as u32, x as u32, color);
        } else {
            image.put_pixel(x as u32, y as u32, color);
        }
        error += derror;
        if error > dx {
            y += if b.y > a.y { 1 } else { -1 };
            error -= dx * 2;
        }
    }
}

随便画几条

能画线,就能画三角形框了,把作者提供的obj文件画出来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fn main() {
    let (width, height) = (800, 800);
    let mut image = ImageBuffer::<Rgba<u8>, _>::from_pixel(width, height, BLACK);

    let input = BufReader::new(File::open("a.obj").unwrap());
    let model: obj::Obj = obj::load_obj(input).unwrap();
    for arr in model.indices.chunks(3) {
        for i in 0..3 {
            let v0 = model.vertices.get(arr[i] as usize).unwrap().position;
            let v1 = model
                .vertices
                .get(arr[(i + 1) % 3] as usize)
                .unwrap()
                .position;
            let x0 = ((v0[0] + 1.0) * (width - 1) as f32 / 2.0) as i32;
            let y0 = ((v0[1] + 1.0) * (height - 1) as f32 / 2.0) as i32;
            let x1 = ((v1[0] + 1.0) * (width - 1) as f32 / 2.0) as i32;
            let y1 = ((v1[1] + 1.0) * (height - 1) as f32 / 2.0) as i32;
            draw::line(glm::ivec2(x0, y0), glm::ivec2(x1, y1), &mut image, WHITE);
        }
    }

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

详细代码见这里9f1f2564d8400ed296a5ee6ce9cb23e44203fd3c

填充三角形

扫描线

扫描线填充三角形的方式和这个方法的名字一样,一行一行的画直线,首先把三角形的三个点按y坐标从低到高排序,最高点和最低点连线是红色,中间点和其他两个点连线是绿色:

这样中间点就将三角形分为了上下两部分,分别画出两部分,就得到了一个完整的三角形:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
pub fn triangle<I: GenericImage>(
    mut t0: glm::Vec2,
    mut t1: glm::Vec2,
    mut t2: glm::Vec2,
    image: &mut I,
    color: I::Pixel,
) {
    if t0.y == t1.y && t0.y == t2.y {
        return;
    }

    // 按y坐标排序,从低到高 t0 t1 t2
    if t0.y > t1.y {
        swap(&mut t0, &mut t1);
    }
    if t0.y > t2.y {
        swap(&mut t0, &mut t2);
    }
    if t1.y > t2.y {
        swap(&mut t1, &mut t2);
    }

    let total_height = t2.y - t0.y;

    // 下半部分
    // y in t0.y -> t1.y
    for y in t0.y as i32..=t1.y as i32 {
        let segment_height = t1.y - t0.y + 1.;
        let alpha = (y as f32 - t0.y) as f32 / total_height as f32;
        let beta = (y as f32 - t0.y) as f32 / segment_height as f32; // be careful with divisions by zero

        let mut a = t0 + (t2 - t0) * alpha;
        let mut b = t0 + (t1 - t0) * beta;
        if a.x > b.x {
            swap(&mut a, &mut b);
        }
        for j in a.x as i32..=b.x as i32 {
            image.put_pixel(j as u32, y as u32, color);
        }
    }

    // 上半部分
    for y in t1.y as i32..=t2.y as i32 {
        let segment_height = t2.y - t1.y + 1.;
        let alpha = (y as f32 - t0.y) / total_height;
        let beta = (y as f32 - t1.y) / segment_height;
        let mut a = t0 + (t2 - t0) * alpha;
        let mut b = t1 + (t2 - t1) * beta;
        if a.x > b.x {
            swap(&mut a, &mut b);
        }
        for j in a.x as i32..=b.x as i32 {
            image.put_pixel(j as u32, y as u32, color);
        }
    }
}

将代码整理一下,同时画出两部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
pub fn triangle<I: GenericImage>(
    mut t0: glm::Vec2,
    mut t1: glm::Vec2,
    mut t2: glm::Vec2,
    image: &mut I,
    color: I::Pixel,
) {
    if t0.y == t1.y && t0.y == t2.y {
        return;
    }

    // 按y坐标排序
    if t0.y > t1.y {
        swap(&mut t0, &mut t1);
    }
    if t0.y > t2.y {
        swap(&mut t0, &mut t2);
    }
    if t1.y > t2.y {
        swap(&mut t1, &mut t2);
    }

    let total_height = t2.y - t0.y;
    // 同时画两部分
    for i in 0..=total_height as i32 {
        let second_half = if i > (t1.y - t0.y) as i32 || t1.y == t0.y {
            true
        } else {
            false
        };
        let segment_height = if second_half {
            t2.y - t1.y
        } else {
            t1.y - t0.y
        };
        let alpha = i as f32 / total_height as f32;
        let beta =
            (i as f32 - if second_half { t1.y - t0.y } else { 0. }) as f32 / segment_height as f32; // be careful with divisions by zero

        let mut a = t0 + (t2 - t0) * alpha;
        let mut b = if second_half {
            t1 + (t2 - t1) * beta
        } else {
            t0 + (t1 - t0) * beta
        };
        if a.x > b.x {
            swap(&mut a, &mut b);
        }
        for j in a.x as i32..=b.x as i32 {
            image.put_pixel(j as u32, (t0.y + i as f32) as u32, color);
        }
    }
}

详细代码见这里37bc2e71b0fe47a37da314987c69f43caf9027c1

重心坐标

扫描线是为单线程CPU编程设计的老式方法,显卡有很多核心,这种方式发挥不了显卡的威力。更好的方式是对于每个像素,直接判断其是否属于三角形内部。

首先需要了解什么是重心坐标,可以看这个文章,总结来讲:

如果点P再三角形ABC内部,必定唯一存在三个数w1,w2,w3,满足:

w1+w2+w3=1 且 w1,w2,w3非负数(负数说明不在内部)

P=w1*A+w2*B+w3*C (即P表示成A,B,C的线性组合)

则(w1,w2,w3)就称为此三角形上P点的(归一化)重心坐标。

计算的话,教程里的更好理解些,w1,w2,w3表示成(1-u-v,u,v):

$$ \begin{aligned} P &= (1-u-v)A + uB + vC \\ &= A + u(B-A) + v(C-A) \\ &= A + u\overrightarrow{AB} + v\overrightarrow{AC} \end{aligned} $$
然后再把P移过去,就得到:
$$ u\overrightarrow{AB} + v\overrightarrow{AC} + \overrightarrow{PA} = 0 $$
再把向量展开成坐标值表示:
$$ \begin{cases} u\overrightarrow{AB}_x + v\overrightarrow{AC}_x + \overrightarrow{PA}_x &= 0\\ u\overrightarrow{AB}_y + v\overrightarrow{AC}_y + \overrightarrow{PA}_y &= 0\\ \end{cases} $$
然后作者上面的式子写成了矩阵的形式:
$$ \begin{cases} \begin{bmatrix} u & v &1 \end{bmatrix} \begin{bmatrix} \overrightarrow{AB}_x \\ \overrightarrow{AC}_x \\ \overrightarrow{PA}_x \end{bmatrix} &= 0 \\ \\ \begin{bmatrix} u & v &1 \end{bmatrix} \begin{bmatrix} \overrightarrow{AB}_y \\ \overrightarrow{AC}_y \\ \overrightarrow{PA}_y \end{bmatrix} &= 0 \end{cases} $$
其实不转换成矩阵也行,这一步无所谓。观察上面的式子,如果把参与计算的数值看作三维坐标,就会变得很简单,高中知识就够了:
$$ \begin{aligned} (u,v,1)\ &*\ (x_1,x_2,x_3) &= 0\\ (u,v,1)\ &*\ (y_1,y_2,y_3) &= 0 \end{aligned} $$

这不就两个向量的点积么,复习一下:

向量$A*B=|A||B|\cos\theta$,什么时候结果为零呢:当两个向量垂直的时候结果为0

回到式子, (u,v,1)和(x1,x2,x3)、(y1,y2,y3)必须同时垂直。换句话说,给定(x1,x2,x3)、(y1,y2,y3)两向量,找到同时垂直他们的向量即可。

继续高中知识就足够了,回忆下两个向量的叉积

对两个向量做叉积能得到一个新的向量,这个向量同时垂直ab且方向与ab的位置有关。

所以,求(u,v,1)的值,只需要:

$$ (\overrightarrow{AB}_x , \overrightarrow{AC}_x , \overrightarrow{PA}_x) \times (\overrightarrow{AB}_y , \overrightarrow{AC}_y , \overrightarrow{PA}_y) $$

代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 求重心坐标
fn barycentric(a: glm::IVec2, b: glm::IVec2, c: glm::IVec2, p: glm::IVec2) -> glm::Vec3 {
    let ab = b - a;
    let ac = c - a;
    let pa = a - p;

    let u = glm::cross(
        glm::vec3(ab.x as f32, ac.x as f32, pa.x as f32),
        glm::vec3(ab.y as f32, ac.y as f32, pa.y as f32),
    );

    // 因为传入坐标都是整数,所以z小于1就意味着z是0,这种情况是因为三角形三个顶点在一条直线上,不是合法三角形
    // 这种情况返回一个负值
    if u.z.abs() < 1. {
        return glm::vec3(-1., 1., 1.);
    }

    // vec(x,y,z)/z -> (u,v,1) -> (1-u-v, u, v)
    return glm::vec3(1. - ((u.x + u.y) / u.z) as f32, u.x / u.z, u.y / u.z);
}

有了求重心坐标的函数后,只需要枚举坐标,用当前坐标与三角形顶点算重心坐标,如果重心坐标中任意一个值为负数,说明当前坐标不在三角形中,反之则说明该坐标在三角形内:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub fn triangle_with_barycentric<I: GenericImage>(
    t0: glm::IVec2,
    t1: glm::IVec2,
    t2: glm::IVec2,
    image: &mut I,
    color: I::Pixel,
) {
    let bboxmin = glm::ivec2(t0.x.min(t1.x).min(t2.x), t0.y.min(t1.y).min(t2.y));
    let bboxmax = glm::ivec2(t0.x.max(t1.x).max(t2.x), t0.y.max(t1.y).max(t2.y));
    let a = t0[1];

    for px in bboxmin.x..=bboxmax.x {
        for py in bboxmin.y..=bboxmax.y {
            let bc_screen = barycentric(t0, t1, t2, glm::ivec2(px, py));

            if bc_screen.x < 0. || bc_screen.y < 0. || bc_screen.z < 0. {
                continue;
            }
            image.put_pixel(px as u32, py as u32, color);
        }
    }
}

详细代码见这里a1d23998dccac6a3f42130e3d57f21c399fab0d7

渲染阴影以及面剔除

把作者给的obj模型的每个三角形画出来,首先将obj文件里给的坐标转换为屏幕坐标,并随机给一个颜色:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
for arr in model.indices.chunks(3) {
    let (a, b, c) = (
        model.vertices.get(arr[0] as usize).unwrap().position,
        model.vertices.get(arr[1] as usize).unwrap().position,
        model.vertices.get(arr[2] as usize).unwrap().position,
    );
    let (a, b, c) = (
        glm::vec3(a[0], a[1], a[2]),
        glm::vec3(b[0], b[1], b[2]),
        glm::vec3(c[0], c[1], c[2]),
    );
    let (sa, sb, sc) = (
        glm::ivec2(
            (((a.x + 1.) * (width - 1) as f32) / 2.) as i32,
            (((a.y + 1.) * (height - 1) as f32) / 2.) as i32,
        ),
        glm::ivec2(
            (((b.x + 1.) * (width - 1) as f32) / 2.) as i32,
            (((b.y + 1.) * (height - 1) as f32) / 2.) as i32,
        ),
        glm::ivec2(
            (((c.x + 1.) * (width - 1) as f32) / 2.) as i32,
            (((c.y + 1.) * (height - 1) as f32) / 2.) as i32,
        ),
    );

    draw::triangle_with_barycentric(
        sa,
        sb,
        sc,
        &mut image,
        Rgba([
            rand::random::<u8>() % 255,
            rand::random::<u8>() % 255,
            rand::random::<u8>() % 255,
            255,
        ]),
    );
}

这里有一个问题,模型中的每个三角形都被画出来了,正常来讲只有面对我们的三角形才应该被画出来,这就涉及到了面剔除,接下来给模型打上光照,同时进行面剔除。

首先看光照,把一束光看作一个向量,然后再求出一个三角形的面向(三角形两条边做叉积,能得到方向垂直于两条边的向量)。面向与光照方向越接近,光照强度就越大。

把面向和光照两个向量再做点积,点积如果是负的,说明该面向背对光源,如果光源方向是我们屏幕外向屏幕内,那么这个面我们是看不见的,应该被剔除。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
let n = glm::cross(c - a, b - a); // 计算三角形面向
let n = glm::normalize(n);

let intensity = glm::dot(light_dir, n);

if intensity > 0. {
    // 既是光照强度,也能当面剔除用,小于0说明背对我们
    draw::triangle_with_barycentric(
        sa,
        sb,
        sc,
        &mut image,
        Rgba([
            (255. * intensity) as u8,
            (255. * intensity) as u8,
            (255. * intensity) as u8,
            255,
        ]),
    );
}

note: 模型文件中三角形的坐标是有顺序的,一般顺时针或逆时针排列,从而方便我们计算面向

详细代码见这里990567294a9b58fb4a66a65704640ff0710bd522

Pratt Parsing - 自顶向下的算符优先级

2021年8月15日 11:09

简介

Pratt Parsing 是一种在手写递归下降解析器时处理表达式解析的好方法,通过给算符定义优先级,可以处理左递归的语法定义,写起来非常简单。

我学到这个方法是在这本书里:《Writing An Interpreter In Go》,作者用手写递归下降的方式实现了一个名为 Monky 的语言,其中解析表达式的部分就是用 Pratt Parsing 实现的。

整一个解析器无非就两种方法:手写、使用parser generator工具生成。

如果选择手写,那肯定是采用自顶向下的方式。

选择生成器,也有两个流派:自顶向下、自底向上。比如:

  • 自顶向下:ANTLR - ALL(*)
  • 自底向上:Yacc/Bison - LALR

我更喜欢年轻的ANTLR,甚至都没用过Yacc/Bison,自顶向下的方式更符合人类思维,而LALR算法生成的代码很难懂(不光生成的代码难懂,算法思想本身也难懂)。还有就是手写递归下降很流行,Go的编译器就是这么干的。

自顶向下的方式缺点是无法处理左递归,但ANTLR允许定义直接左递归,它会帮你自动改写。在《ANTRL4权威指南》14章 移除直接左递归中提到了左递归规则转换的方法,和Pratt Parsing 的方式非常相似!

Pratt Parsing 解决什么问题

写一个parser,最困难的地方可能就是解析表达式了,不同于其他语法结构,表达式涉及运算符优先级问题,并且在定义上也是递归的。

比如这样一个表达式:

1
1 + 2 * 3

乘法运算的优先级应该更高,所以parser在得到1+2的输入时,并不能直接构造一个算术表达式返回,我们希望的返回是 1+(2*3) 而不是 (1+2)*3

另外,一般语言中都支持分组,来自定义运算顺序:

1
3 * (1 + 2)

即使乘法的优先级最高,parser在读入3*之后也不能从分组中把1抢过来构造(3*1)+2,正确的返回应该是3*(1+2)

再复杂一点,函数调用也可以参与表达式运算:

1
5 * (add(2,3) + 1)

函数的参数也可以是更复杂的表达式,函数也可以嵌套:

1
add(3 * (1 + 2), add(1,2)) * 100

很明显表达式的定义是递归的,对于上面所有case,给出表达式的定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
expr:
    expr ('+'|'-'|'*'|'/') expr
    | ('-') expr
    | '(' expr ')'
    | literal;

literal:
    IDENTIFIER // 标识别符
    | integer // 整数字面量
    | '(' ( expr (',' expr)* )? ')'; //参数列表

如何在手写递归下降解释器中,正确解析这样一个表达式,就是Pratt Parsing所解决的问题。

如何做

Pratt Parsing 的基本思想是:为每一个算符定义一个优先级。算符会被分为两类,一类称为前缀prefix算符,另一类称为中缀infix算符。-5中的负号就是一个前缀算符,特别的是一个字面量或标识符也被划分为一个前缀算符,如5add,因为他们都是一元的;像1+2中的加号是中缀算符,同样比较特别的是函数调用add(1,2),函数名后面的(也被归类为中缀算符,因为函数名必须要与参数列表结合。前缀算符和后缀算符都会有关联函数,关联函数负责构造具体的子表达式,过程中会递归的调用主解析函数。通过算符优先级,来决定如何构造AST。

一个算符既可以是前缀算符,也可以是中缀算符,如何定义取决于它们在表达式中所处的位置。在定义过算符之后,最重要的事情是定义算符优先级:

1
2
3
4
5
6
7
8
9
// 优先级从低到高排列
const (
	_           int = iota
	LOWEST          // 最低的
	SUM             // + -
	PRODUCT         // * /
	PREFIX          // -1
	CALL            // 函数调用,最高
)

可以发现并没有关于分组的优先级,稍后会看到分组在解析过程中是如何巧妙处理的。

主流程

先给出解析表达式的入口方法(下文都省略词法分析器):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// pratt parsing 的主要逻辑
func (p *Parser) parseExpr(precedence int) ast.Expr {
	prefix := p.prefixParseFns[p.curToken.Type]
	if prefix == nil {
		p.noPrefixParseFnError(p.curToken.Type)
		return nil
	}
	leftExpr := prefix()

	// 如果外部算符优先级没有后面遇到的的高,会循环下去
	for !p.peekTokenIs(token.SEMICOLON) && precedence < p.peekPrecedence() {
		infix := p.infixParseFns[p.peek_Tok.Type]
		if infix == nil {
			return leftExpr
		}
		p.nextToken()
		leftExpr = infix(leftExpr) // 合并中缀表达式
	}

	return leftExpr
}
  1. 解析开始时,传入的优先级参数一定是LOWEST,第一个遇到的算符一定是前缀算符。

  2. 去预定好的map中查找该算符作为前缀算符的关联函数,就是代码中的prefix,在prefix函数中构建子表达式(prefix中可能会递归调用parseExpr函数)。

  3. 得到prefix返回的子表达式后,窥探下一个算符的优先级。

  4. 如果下个算符优先级更高,就在map中查找该算符作为中缀算符的关联函数infix

  5. 如果没有说明这不是一个中缀算符,把当前构造好的leftExpr返回。

  6. 如果有,就读入这个算符,调用infix函数构造中缀表达式(infix同样可能递归调用parseExpr)。

  7. 函数返回

算符关联函数的签名如下:

1
2
type prefixParseFn func() ast.Expr
type infixParseFn func(ast.Expr) ast.Expr

各算符作为前缀、中缀算符的关联函数如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 前缀算符关联函数
p.prefixParseFns = make(map[token.Type]prefixParseFn)
// 标识符:变量名、函数名
p.registerPrefix(token.IDENT, p.parseIdentifier)
// 整数字面量:5、10
p.registerPrefix(token.INT, p.parseIntLiteral)
// 负号:-5
p.registerPrefix(token.MINUS, p.parsePrefixExpression)
// 分组(左括号):3*(1+2)
p.registerPrefix(token.LPAREN, p.parseGroupExpr)

// 中缀算符关联函数
p.infixParseFns = make(map[token.Type]infixParseFn)
// 二元算术运算:+ - * /
p.registerInfix(token.PLUS, p.parseInfixExpr)
p.registerInfix(token.MINUS, p.parseInfixExpr)
p.registerInfix(token.ASTERISK, p.parseInfixExpr)
p.registerInfix(token.SLASH, p.parseInfixExpr)
// 函数调用(左括号): add(1,2)
p.registerInfix(token.LPAREN, p.parseCallExpr)

然后看下各算符关联函数的实现(parser在解析过程中的原则是尽可能不要推进当前算符)。

前缀算符关联函数

标识符

1
2
3
4
5
6
7
func (p *Parser) parseIdentifier() ast.Expr {
	// 注意这里不调用 nextToken, 这很重要
	return &ast.Identifier{
		Token: p.curToken,
		Value: p.curToken.Literal,
	}
}

很简单,就直接把单个token构造成AST上的Identifier节点返回。

整型字面量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// int 作为prefix的关联函数
func (p *Parser) parseIntLiteral() ast.Expr {
	lit := &ast.Int_Literal{Token: p.curToken}

	v, err := strconv.ParseInt(p.curToken.Literal, 0, 64)
	if err != nil {
		p.errs = append(p.errs, fmt.Sprintf("无法将%s转换为int", p.curToken.Literal))
		return nil
	}
	lit.Value = v
	return lit
}

也很简单,就是把源码中的字符串转换为int并构造节点。

负号

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (p *Parser) parsePrefixExpression() ast.Expr {
	expression := &ast.PrefixExpression{
		Token:    p.curToken,
		Operator: p.curToken.Literal,
	}
	p.nextToken()
	// 注意这里,是pratt parsing的关键
	// 传入优先级为 PREFIX, -3*5 , -add(1,2)  只有CALL大于此优先级
	expression.Right = p.parseExpr(PREFIX)
	return expression
}

这里开始递归了,直接递归调用parseExpr(注意传入优先级参数是PREFIX)得到负号后面跟的子表达式。PREFIX优先级很大,仅次于CALL:

1
2
-3*5 => (-3)*5
-add(1,2) => -(add(1,2))

分组

1
2
3
4
5
6
7
8
func (p *Parser) parseGroupExpr() ast.Expr {
	p.nextToken()
	exp := p.parseExpr(LOWEST)
	if !p.expectPeek(token.RPAREN) { // 吃掉 )
		return nil
	}
	return exp
}

分组的实现很简单,也很奇妙。读入(后,直接把优先级降到LOWEST,这样相当于屏蔽了前面的优先级,不管前面优先级多高,后面都看不见,思考:3*(1+2)。结束后读出)curToken,如果下个字符不是)就说明输入语法错误。

后缀算符关联函数

算术运算

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 中缀算符的关联函数 + - * /
func (p *Parser) parseInfixExpr(left ast.Expr) ast.Expr {
	expr := &ast.Infix_Expr{
		Token:    p.curToken,
		Left:     left,
		Operator: p.curToken.Literal,
	}

	precedence := p.curPrecedence()
	p.nextToken()
	expr.Right = p.parseExpr(precedence)

	return expr
}

最终构造的节点有三部分:left、op、right,读取当前算符的优先级(SUM/PRODUCT),传入parseExpr解析得到right部分,构造结束。

函数调用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (p *Parser) parseCallExpr(fn ast.Expr) ast.Expr {
	exp := &ast.Call_Expr{Token: p.curToken, Function: fn}
	exp.Arguments = p.parseCallArgs()
	return exp
}

func (p *Parser) parseCallArgs() []ast.Expr {
	args := []ast.Expr{}

	// 没有参数的情况
	if p.peekTokenIs(token.RPAREN) {
		p.nextToken()
		return args
	}
	// 解析参数
	p.nextToken()
	args = append(args, p.parseExpr(LOWEST))
	for p.peekTokenIs(token.COMMA) {
		p.nextToken()
		p.nextToken()
		args = append(args, p.parseExpr(LOWEST))
	}
	if !p.expectPeek(token.RPAREN) {
		return nil
	}
	return args
}

主要是解析参数列表,有多个参数情况下,都是逗号分割,直接循环处理,每次递归调用parseExpr传入的优先级都是LOWEST,因为每个参数都是独立的expr。

总结

整个算法的核心思想是使用优先级,来控制当前应该返回已构造的expr,还是继续解析。再贴一遍主流程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (p *Parser) parseExpr(precedence int) ast.Expr {
	prefix := p.prefixParseFns[p.curToken.Type]
	if prefix == nil {
		p.noPrefixParseFnError(p.curToken.Type)
		return nil
	}
	leftExpr := prefix()

	// 如果当前算符优先级没有后面的高,会循环下去
	for !p.peekTokenIs(token.SEMICOLON) && precedence < p.peekPrecedence() {
		infix := p.infixParseFns[p.peek_Tok.Type]
		if infix == nil {
			return leftExpr
		}
		p.nextToken()
		leftExpr = infix(leftExpr) // 合并中缀表达式
	}

	return leftExpr
}

用一个例子来演示下整个执行流程:

假设要解析1 + 2 * 5

id 剩余算符 当前算符 执行操作 说明 局部AST构造stack
0 1+2*5 parseExpr(LOWEST) 开始解析,传入最低优先级
1 +2*5 1 parserIntLiteral() 从映射中找到当前算符的prefix关联函数,执行后得到ast节点 1
2 2*5 + parseInfixExpr(left) 窥探得知+的算符优先级SUM大于LOWEST,执行+的关联函数 1+
3 2*5 + parseExpr(SUM) 在2中递归调用了解析,并且传入优先级为SUM
4 *5 2 parserIntLiteral() 解析函数发现算符还是整数,继续调用关联函数 2
5 5 * parseInfixExpr(left) 发现的优先级PRODUCT大于当前优先级SUM,调用的关联函数 2*
6 5 * parseExpr(PRODUCT) 在5中递归调用解析,并传入优先级为PRODUCT
7 5 parserIntLiteral 算符还是整数,继续调用关联函数 5
8 7->6 输入结束,7返回到6,并得到ast {5}
9 6->3 6返回到3,并得到ast {2*{5}}
10 3->0 6返回到0,并得到ast {1+{2*{5}}}

基本上是对简介中那本书中实现内容的裁剪,强烈建议读一读。

Go反射: 将切片按指定大小分块

2021年7月17日 18:13

背景

在写代码过程中,有时候需要做一些批量 查询/操作,往往会涉及将一个很大的数组或切片进行分块。

比如我们有一个存着id的数组,要根据id请求某个接口查询信息,这个接口支持批量查询,但是每次查询的数量上限是100。最好的做法是每次从数组中取最多100个id,进行批量查询,直到遍历完数组。

这个操作不复杂,可以简单的用循环实现,但是每次遇到这种场景都需要写一次代码,有点写吐了。所以就想写一个函数,可以将[]T按需求拆分成[][]T

但是go的泛型还没有来,所以只能用反射来搞了。

献祭我的周六饭后时光

成果

传入[]TT可以是任意类型,按指定大小分块,返回[][]T

例子中将[0,1,2,3,4,5,6,7,8,9]划分成了[0,1,2][3,4,5][6,7,8][9]

https://github.com/kirito41dd/xslice

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import (
	"fmt"
	"github.com/kirito41dd/xslice"
)

func main() {
	s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
	i := xslice.SplitToChunks(s, 3)
	ss := i.([][]int)
	fmt.Println(ss) // [[0 1 2] [3 4 5] [6 7 8] [9]]
}

代码实现

反射一把梭,自然离不了可爱的interface{}

欢迎复制或引包github.com/kirito41dd/xslice使用,如果以后搬砖遇到其他场景,也会继续扩充。

还是期待go泛型早点到来(那时候我rust应该已经很6了吧

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func SplitToChunks(slice interface{}, chunkSize int) interface{} {
	sliceType := reflect.TypeOf(slice)
	sliceVal := reflect.ValueOf(slice)
	length := sliceVal.Len()
	if sliceType.Kind() != reflect.Slice {
		panic("parameter must be []T")
	}
	n := 0
	if length%chunkSize > 0 {
		n = 1
	}
	SST := reflect.MakeSlice(reflect.SliceOf(sliceType), 0, length/chunkSize+n)
	st, ed := 0, 0
	for st < length {
		ed = st + chunkSize
		if ed > length {
			ed = length
		}
		SST = reflect.Append(SST, sliceVal.Slice(st, ed))
		st = ed
	}
	return SST.Interface()
}
❌
❌