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, ); } 得到结果如下: 完整代码: 3dc57eb84b1f48e8b0a40c429a712bcb604d8f00 现在得到了光的视角的zbuffer,我们叫他shadow buffer。接下来渲染模型: 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() 拆分一下非常简单,这个矩阵会作用于片段着色器中的屏幕坐标: 先进行变换矩阵的逆矩阵操作,将屏幕坐标转换为初始坐标 再把初始坐标按渲染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中的深度,说明此处光线没有被遮挡。 最终效果如下 完整代码: 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]$)。 注意渲染结果还是有些异常,腿上很明显有黑斑,这是因为高光贴图计算时的一个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.); 再看结果就更正常了 透视变形 观察下面两张图,会发现使用屏幕空间重心坐标对纹理插值会有问题: 使用屏幕空间重心坐标 使用裁剪空间重心坐标 问题的原因主要是变换链的非线性。为了从齐次坐标转换为3d坐标(屏幕空间),我们除以了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$分量 我们需要跳出这个循环,在(归一化的)重心坐标中,所有分量的和等于1,也就是$alpha + beta+gamma=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$分量 (bc_clip.x + bc_clip.y + bc_clip.z) 计算深度信息和片段着色器插值时都传入裁剪空间重心坐标即可 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。

2025/7/24
articleCard.readMore

TinyRenderer笔记4:着色器

顶点着色器和片段着色器 OpenGL 2的渲染管道可以表示如下(新版本也差不多): 在较新的OpenGL中还有其他着色器,在这个课程中只关心顶点着色器(vertex shader)和片段着色器(fragment shader)。 main()函数是原始的处理程序(primitive processing routine),它可以叫做顶点着色器。我们在这里没有primitive assembly的步骤,由于我们仅绘制三角形在我们的代码中,它与primitive processing合并。现在的triangle()函数是是rasterizer,对于在三角形中的每个像素都调用了了片段着色器的功能,然后执行深度检查之类的。 顶点着色器的主要目标是变换顶点的坐标,第二个目标是为片段着色器准备数据。 着色器的定义: 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 这里是完整代码: 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; } 查看变化 纹理 现在只计算了每个点的光照,修改着色器带上纹理: 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; // 不丢弃任何像素 } 查看结果: 代码:81b8c2ae2d04f70036f8ef4227f9bb2ffd5a519a 几种着色方法介绍 平面(Flat)着色: 每个三角形只计算一个光照 Gouraud着色: 每个三角形计算三个顶点的光照,使用三个顶点的光照对三角形中每个点做线性插值 Phong着色: 我们把三角形的每个点的法向量都插值出来,然后再计算光照 法线贴图(Normal Mapping) 我们有纹理坐标,和这样的纹理贴图。 除了纹理,也可以把几乎任何东西存进纹理图像中。它可以是颜色、方向、温度等等。 如果我们将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) } 结果: 代码: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); 结果: 代码:64d58b1c6d0569db3db1eefaead38443d05b9ac9 高光贴图(Specular Mapping) Phong光照模型: 我们前面计算的光都是漫反射光,计算了光方向向量和法线向量的余弦值。这假设了光在各个方向上均匀反射。如果是光滑的表面比如镜面,光反射范围会更小,只有反射到了我们的眼睛内我们才能看见。 对于漫反射光我们关心的是向量$n$和向量$l$的余弦值,现在我们要开始关注反射光$r$和视角方向$v$之间的夹角。 glm::normalize(n * (glm::dot(n, l) * 2.) - 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; // 镜面反射光 结果如下: 代码:175d0b61c92a55d08ca9b349b77dfb245d7a1d2e

2025/1/6
articleCard.readMore

TinyRenderer笔记3:移动摄像机

3D空间中的基底变化 在欧几里得空间中,一个坐标可以由一个原点和基底给出,考虑点$P$在坐标系$(O,i,j,k)$中拥有坐标$(x,y,z)$的含义,它意味着向量$\overrightarrow{OP}$ 可以表示为: 现在我们有另外一个坐标系$(O,i’,j’,k’)$,如何将坐标从一个坐标系转换到另一个坐标系,首先$(i,j,k)$和$(i’,j’,k’)$是三维的基底,存在一个(非简并)矩阵$M$使得: 参考下面的例子 让我们重新表示$\overrightarrow{OP}$: 现在把右边的$(i’,j’,k’)$替换成矩阵 它给出了坐标从一个坐标系到另一个坐标系的变换公式 look_at 目前我们实现的渲染器相当于使用位于Z轴上的摄像机来绘制场景。如果我们想要移动摄像机得到不同视角的画面,没问题,我们可以移动所有的场景,而不移动摄像机。 让我们这样来看待这个问题:我们想画一个场景,相机位于点$e$(眼睛)。相机应对准点$c$(中心),向量$u$(up)指向画面的上方: 这意味着我们要在坐标系$(c,x’,y’,z’)$中渲染,但是我们的模型是在$(O,x,y,z)$中给出的,这没有问题,我们只需要计算坐标的变换。这是rust代码将世界坐标转换为摄像机坐标的4x4矩阵ModelView: rust // eye 摄像机位置 center 焦点 up视角上方 pub fn lookat(eye: glm::Vec3, center: glm::Vec3, up: Vec3) -> glm::Matrix4<f32> { let z = glm::normalize(eye - center); // 向量ce let x = glm::normalize(glm::cross(up, z)); // 同时垂直于 up和z的向量 let y = glm::normalize(glm::cross(z, x)); // 注意glm中是按列存的 #[rustfmt::skip] let minv = glm::mat4( x.x, y.x, z.x, 0., x.y, y.y, z.y, 0., x.z, y.z, z.z, 0., 0., 0., 0., 1., ); #[rustfmt::skip] // 这里平移为什么是用的center? 因为把摄像机移动回去这个动作,我们并没有定义原来的摄像机位置,所以不知道位移的向量 // 但是原来的焦点可以认为是原点(0,0,0),摄像机的位移和焦点位移是一样的,所以用center的坐标来计算 // 这里如果用eye,就相当于假设原来摄像机在原点,结果也对就是看着比预想中远 let tr = glm::mat4( 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., -center.x, -center.y, -center.z, 1., ); minv * tr } 想象原点$O$平移到$c$ ,然后通过旋转$O$的坐标轴,使得坐标轴$xyz$于$x’y’z’$重合。“我们可以移动所有的场景,而不移动摄像机。” 我们用它来看物体,可以摄像机不动,对物体做上面变换的逆变换: 旋转矩阵的逆是它的转置,平移矩阵的逆也就是再吧它移回去。 rust代码中写的矩阵是这么来的: R - 右向量,对应相机坐标系x轴在世界坐标中的表示 U - 上向量,对应相机坐标系y轴在世界坐标中的表示 D - 方向向量,对应相机坐标系z轴在世界坐标中的表示 P - 相机在世界坐标系中的位置,也是平移的向量 viewport 我们在代码中写过这样的转换 rust let screen_coords_a = glm::vec3( ((a.x + 1.) * (width) as f32), ((a.y + 1.) * (height) as f32), a.z, ); 我们有一个点$a$,它属于正方形$[-1,1]*[-1,1]$,我们想要把它画成$(width,height)$尺寸的图像。 接下来要替换掉这种丑陋的方式,把所有的计算写成矩阵形式。 Viewport矩阵: 它意味着立方体$[-1,1] * [-1,1] * [-1,1]$映射到屏幕立方体$[x,x+w] * [y,y+h] * [0,d]$其中d是深度对应z轴。作者说:是的,立方体,而不是矩形,这是因为Z-Buffer的深度计算。这是Z-Buffer的分辨率。我喜欢将其等于255,因为简单地将Z-Buffer的黑白图像进行调试。,暂时还不知道这句话含义。 平移:把 $[-1,1] * [-1,1] * [-1,1]$ 平移到 $[0,2] * [0,2] * [0,2]$ 缩放: $[0,2] * [0,2] * [0,2]$ 缩放到 $[0,1] * [0,1] * [0,1]$ 缩放: $[0,1] * [0,1] * [0,1]$ 缩放到 $[0,w] * [0,h] * [0,d]$ 平移: $[0,w] * [0,h] * [0,d]$ 平移到 $[x,x+w] * [y,y+h] * [0,d]$ 最终结果就是viewport矩阵 rust fn viewport(x: i32, y: i32, w: i32, h: i32) -> glm::Matrix4<f32> { let (x, y, w, h) = (x as f32, y as f32, w as f32, h as f32); let d = 255.; #[rustfmt::skip] let m = glm::mat4( w/2., 0., 0., 0., 0., h/2., 0., 0., 0., 0., d/2., 0., x+w/2., y+h/2., d/2., 1., ); m } 坐标变换链 模型一般在他们的本地坐标系中被创建(object coordinates),他们被插入世界坐标(world coordinates)表达的场景,从一个位置转换到另一个是用矩阵Model进行的。然后,我们想在相机坐标系(eye coordinates)中表达它,这个转换叫做View。然后使用投影矩阵(Projection)对场景进行透视变形,这个矩阵将场景转换为所谓的裁剪坐标(clip coordinates)。最后,我们绘制场景,将裁剪坐标转换为屏幕坐标的矩阵称为Viewport。 如果我们从.obj文件中读取点v,那么为了在屏幕上绘制它,它将经历以下转换链: text Viewport * Projection * View * Model * v. 当我们只画一个对象时,Model不做用任何事情(我们不需要更改对象在世界中的位置),可以忽略。 rust let fin =view_port * projection * model_view; let a = v4p2v3(fin * a.extend(1.)); let b = v4p2v3(fin * b.extend(1.)); let c = v4p2v3(fin * c.extend(1.)); 暂时画出来长这样: 代码见这里e6f0a9d3fbda02776d1ca26af742e1d8f5570f5c 法向量的变换 如果我们有一个模型,并且其法向量由艺术家给出,如果我们使用该模型进行了仿射变换,那么不能简单的对其原有法向量进行相同的变换。 简单的例子就是如$p(1,0)$是模型的某个法向量,我们把模型沿y轴平移1个单位,如果也对法向量进行平移操作得到$p’(1,1)$,显然$p’$和$p$不是平行的. 具体可以看这里:知乎 结论:法向量的变换矩阵为模型变换矩阵的逆转置矩阵 $$ M'=(M^{-1})^T $$

2024/12/28
articleCard.readMore

常用激活函数和损失函数

sigmod 求导: softmax 作为分类问题输出层的激活函数 [0, 1],并且约束各个输出节点的输出值的和为1 定义: 求导:设$softmax(z_i) = p_i$ 交叉熵损失函数 softmax通常配合交叉熵损失函数使用 定义:$y_i$是真实样本标签值,分类问题里不是0就是1 求导: 求导非常简单,所以softmax和交叉熵一起用,反向传播时候计算就非常简单了

2024/5/6
articleCard.readMore

rust手写神经网络

用rust实现上篇笔记:神经网络的结构 中描述的神经网络 https://github.com/kirito41dd/Hello-NN 训练过程 确定loss函数,$f(x)$为样本的推理结果,$y$是目标结果,多个样本sum得出loss 将训练样本分为n个batch,每个batch n个样本 按batch进行训练,将batch中每个样本进行正向传播 记录每个样本的结果,以及这个样本正向传播过程中的所有中间结果 遍历完每个样本后,计算loss 通过loss值对每个样本进行反向传播,过程中需要用到前面缓存的中间结果 记录每个样本反向传播过得到的梯度,将所有样本的梯度求平均值,得到最终调整网络的梯度。因为loss是sum的,所以在算一个样本偏导时,其他样本为常量,求导是0,所以每个样本梯度可以独立计算。 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); } 在实际操作中,可以把一个全连接层拆为两层,即将激活函数抽出单独虚拟为一层,这样更加灵活 激活函数层 先看简单的激活函数层,使用sigmod作为激活函数,激活函数层上没有任何权重和偏置,所以不需要存储任何参数: rust // 使用激活函数sigmod的层 pub struct SigmodLayer {} 正向传播时只需要求每个输入的sigmod值即可,sigmod的公式是: 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函数求导 有了求导公式,就可以写出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) { //不需要做任何事情 } 测试: 正向传播:输入两个$0$,sigmod层激活值是$0.5$,输出层值为$1$ 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, } 正向传播公式很简单,单个节点的激活值: 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 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 参考 神经网络的结构 Digit Recognition with Rust and WASM - Part 1 A tiny artificial neural network rust library(每个样本都立即反向传播,相当于只能batch size都是1,不知道是故意的还是不小心的) $$ C_x = \frac{(y-a)^2}{2} a = \sigma(z) \delta^L= a - y $$

2024/3/30
articleCard.readMore

同态加密登陆

背景 在服务器日常登录过程中,一般是前端传输账号和密码到服务器,然后服务器对账号密码进行校验。但在这个过程中有两个问题,假设传输过程中信息被截获(或者打印日志然后日志泄漏等操作),可能导致密码泄漏。如果服务器数据泄漏,也会导致密码泄漏。一般来说我们服务器中存储的是密码的sha256,这样可以解决服务器数据泄漏的问题,但是传输过程中的问题没有解决(前端hash再传输是无效的,因为这样做hash后的值等效于密码)。我们希望提出一种方案,同时满足传输过程中信息任意泄漏,服务器数据任意泄漏,也可以保证密码的安全。 想法来源: https://pdos.csail.mit.edu/papers/otfvec/paper.pdf https://arxiv.org/pdf/1906.07221.pdf 同态简要 同态加密允许加密一个值,并使用加密后的值进行运算。实现同态加密有多种方式,这里简单介绍一个简要例子 选择一个自然数作为基数(这个基数需要具备一些特定的属性),将基数记为g,用 g去加密一个值,即g为底数,需要加密的值为指数。比如,我们要加密一个数值 3,取 g为 5: $$ 5^3=125 $$ 125是 3 加密过后的值,如果我们把需要加密的值E(3)乘2,我们就可以给加密后的值(125)提高两个阶数, $$ 125^2=15265=(5^3)^2=5^{2*3}=5^6 $$ 同样,我们可以通过除法来使加密值相减,例如 5−3: $$ {5^5}/{5^3}=5^{5-3}=5^2=25 $$ 我们可以看出,同态加密可以实现加、减、乘操作 方案简要 原理 $m为素数,g是m的原根之一,m数量级约 2^{256},令 h(x) = {g^{x}}\bmod{m},定义 h(x)*h(y) = g^{x\ast y}\bmod{m}$ 在登录的时候,前端需要使用两个信息,1是密码本身,2是一些现场信息,比如当前时间戳、设备信息、当前浏览器、想要登录的服务器id、后端给的保证登录不可复用token等,我们称这部分信息为前端info。后端生成数据的时候需要一个salt,这个数据可以是一个保密的随机数。 简单来说,我们需要使用三个数据。令sm=密码的sha256,sf=前端info的sha256,ss=后端salt的sha256, 当用户注册的时候,后端拿到sm和ss,计算出 $hsmss=h(sm)*h(ss)=g^{sm\ast{ss}}\bmod{m}$存入数据库,同时丢弃sm。此时后端能拿到的数据有:ss、g、m、hsmss。 当用户想要登录时,前端有sm和sf,计算出$hsmsf=h(sm)*h(sf)=g^{sm\ast{sf}}\bmod{m}$。把hsmsf和sf传入后端(实际上应该传sf的原文,因为后端需要校验时间戳等,这里为了简化使用sf) 此时后端已知hsmss、sf、hsmsf、ss,可以校验 $$ hsmss^{sf} == hsmsf^{ss}(\bmod{m}) => g^{(sm\ast ss)\ast sf}==g^{(sm\ast sf)\ast ss}(\bmod{m}) $$ 如果相等则密码正确,否则密码错误。 我们可以验证这个方案是否满足需求,假设我们在多机部署,其中一台机器后端数据库泄漏,同时某用户在登录过程中传输的所有信息也泄漏了(例如登录信息打印在日志上,日志泄漏了),此时攻击者是否可以用这些信息登录别的服务器。 为了简化问题,我们列出攻击者实际可以拿到的数据:g、m、ss、hsmss、hsmsf1、sf1(攻击者需要再次登录,被截获的登录的sf为sf1,攻击者登录为sf2)。攻击者需要做的是在不知道sm的情况下,构造出hsmsf2。 $hsmsf2=(g^{sm})^{sf2}\bmod{m}或(g^{sf2})^{sm}\bmod{m}$ 所以攻击者需要计算出$g^{sm}\bmod{m}或sm\bmod{m}$ 而 $hsmss=(g^{sm})^{ss}\bmod{m}$ 我们可以将问题再次简化 $m为素数,g是m的原根之一。h=g^{a * b}\bmod{m},t=g^{a * c}\mod{m},(a,b,c,g,m数量级都是2^{256})$ 问在已知h,b,c,g,m的情况下,是否存在非枚举的方法可以求出t. 走到这步我们很高兴的可以说这个问题是不存在有限时间内的解的,因为证明密码的强度一般是将问题转化成公认的已知困难问题,比如离散对数问题,而这个问题本身就是离散对数问题。 离散对数问题:https://zhuanlan.zhihu.com/p/523658036 代码实现思路参考: https://eprint.iacr.org/2019/458.pdf https://github.com/arnaucube/poseidon-rs 这个库已经提供了m和g的值,以及大整数运算,稍加修改即可 直接贴个m和g: text 21888242871839275222246405745257275088548364400416034343698204186575808495617 7

2024/3/14
articleCard.readMore

神经网络的结构

视频教程: 【官方双语】深度学习之神经网络的结构 Part 1 ver 2.0 【官方双语】深度学习之梯度下降法 Part 2 ver 0.9 beta 【官方双语】深度学习之反向传播算法 上/下 Part 3 ver 0.9 beta 神经网络结构 如何识别尺寸28x28=784像素的数字图片: 神经网络的变种有很多: 卷积神经网络 Convolutional neural network 擅长图像识别 image recognition 长短期记忆网络 Long short-term memory network 擅长语音识别 多层感知器 MLP(Multilayer Perceptron) 经典原版,就是下面的结构 神经网络名字来源于人类大脑,神经元是什么又是如何连接的。 激活值 Activation 隐含层 Hidden layers,例子中选择了两层隐含层,每层16个神经元,随便选的 一层的激活值是通过怎样的运算 算出下一层的激活值。可以看作是模仿生物中神经元组成的网络,某些神经元的触发,就会促使另一些神经元激发。 上层神经元如何影响下层神经元的激活值,给第二层的每个神经元与第一层所有神经元的每一条连线都附上一个权重值 Weights,这些权重都是数字而已 拿第一层的所有激活值,和每条边的权重一起,算出他们的加权和。这样算出来的值可能是任意大小,我们需要激活值都在0到1之间,可以通过某种函数来映射,比如:Sigmoid,这种函数一般叫激活函数Activation Function 所以激活值就是把上面算出来的加权值塞进sigmoid函数映射一下,但有时候即使加权和大于0,也不想把神经元点亮,比如规定加权和大于10的时候才激发,可以加上一个-10之类的数,叫做偏置 bias 。再将它送到sigmoid函数 权重表示神经元受哪些上层神经元的影响,偏执规定加权和得有多大,才能让神经元的激发有意义。除了第一层的神经元,后面每层的神经元都有偏置 所有的权重和偏执都可以看作是旋钮开关供你调整,从而带来不一样的结果。学习或者说训练的过程,就是找到该如何设置这一坨参数,才能让它正确的解决问题。 整个神经网络可以看作一个函数,例子是一个输入784个值,输出10个值的函数 梯度下降法 神经网络训练的过程,是不断调整权重和偏置这些参数,提高网络对训练数据的表现。最终我们希望这个网络可以举一反三,给它没见过的测试数据它也能正确处理。 在一开始,回完全随机的初始化所有的权重和偏置值,这个网络对于给定的训练示例,会表现的非常糟糕。 需要定义一个代价函数来告诉它,结果有多垃圾。 需要将每个垃圾输出激活值,与你想要值之间的差的平方和加起来。我们称之为单个样本上的代价(Cost 也可以叫 Loss),这个值越大,说明你的模型越找不到北。接下来要考虑所有训练样本中代价的平均值(这个值也叫 Empirical Risk 经验风险),用这个值来评价网络有多糟糕。 只告诉网络它有多糟糕是没啥用的,还得告诉他怎么改变这些权重和偏置值,才能有进步。 先不考虑13000个参数的函数,而先考虑简单的一元函数,怎么找到输入值x,使函数值最小化呢? 如果函数很简单,那么直接求斜率为0的位置就是答案,但如果函数很复杂,就不一定能写出来。对于13000个参数的网络,更加不可能。一个技巧是,先随便挑一个输入值,然后考虑向左走还是向右走,函数值才会变小。 可以找到这个地方的斜率,如果为正0就往左走,为负就往右走,每次走一小步,不停的这样,就可以找到一个局部最小值。取决于一开始的输入,最后可能落到不同的坑里,可能并不是最优解,神经网络也会遇到这样的问题。 问题变成哪个方向下山最快。和一元函数的斜率一样,多元函数也有斜率,叫做梯度,梯度指出了函数的最陡增长方向。这种算法总结下就是:随便一个输入后,先计算梯度,再按梯度负方向走一小步下山,然后循环。处理13000个输入的函数也一样,这就是梯度下降算法 想象13000个输入的情况是很复杂的,不借助空间,把梯度看着一个向量,负梯度的每一项值都告诉我们两件事,正负号告诉我们输入向量的这一项应该调大还是调小,每一项值的相对大小告诉我们改变哪个输入影响更大。 在网络中,把13000多个权重和偏置作为输入,通过训练数据,得出一个对网络糟糕程度的评分。代价函数的梯度,告诉我们如何微调权重和偏置的值,才可以让代价函数改变的最快。 当你随机初始化权重和偏置,并通过梯度下降法调整了很多次参数之后,期望的是神经网络识别数字的正确率变高,包括识别从来没见过的图片。 反向传播 神经网络学习的核心算法。为了让整个网络的代价值越来越小,我们要求的是代价函数的负梯度,它告诉你如何改变所有权重和偏置,好让代价下降的最快。反向传播算法正是用来求这个复杂到爆炸的梯度的。 理论上因为代价函数牵扯到对成千上万个训练样本的代价取平均值,所以我们调整每一步梯度下降用的权重偏置,也会基于所有的训练样本。但为了计算效率,这里可以逃课,不必每一步都非得要计算所有的训练样本。 先只关注一个训练样本“2”,会对调整权重偏置产生什么影响,假设网络现在还没训练好,所以输出层的激活值看起来都很随机,我们希望输出结果时2,我们希望对于神经元的激活值变大,其余神经元的激活值变小。并且变动的大小应该与现在值和目标值之间的差成正比,比如增加2对应神经与的激活值,就比减少8的激活值更重要,因为8的激活值已经很接近目标了。 就先来关注一下2的神经元,它的激活值是上一层所有激活值的加权和 加上一个偏置,再通过sigmoid ReLu之类的激活函数算出来的。要增大这个激活值,有三条路: 增加偏置$b$ 增加权重$w$ 改变上一层的激活值$a$(注意不能直接改激活值,只能改上上层的权重偏置) 反向传播的理念了。 再放大局面,上面只是训练单个样本“2”的影响,还要对其他所有的训练样本,同样的过一遍反向传播 记录下每个样本想怎样修改权重和偏置,在对每个参数的修改取一个平均值,不严格地说,这就是代价函数的负梯度,至少是标量的倍数: 顺便在实际操作总,如果梯度下降的每一步,都用上每一个训练样本来计算的话,那么花的时间就太长了,所以一般把训练数据打乱,分成不同组,然后按组算出下降的一步。这不是代价函数真正的梯度,然而是个不错的近似,主要是计算量会降低不少。这个技巧叫做随机梯度下降 Stochastic gradient descent 微积分原理 反向传播最重要是对代价函数求导。 先考虑最简单的网络结构,每层只有一个神经元,只有三个权重和三个偏置。我们目标是找到代价函数对这些变量有多敏感,这样就能知道怎么调整代价降低最快。 先关注最后两个神经元,激活值$a$的上标$L$表示它处在第$L$层,给定一个样本,把样本最终激活值要接近的目标叫做$y$。 一个个看: 当然这只是梯度向量$\nabla C$的一个分量: 梯度向量$\nabla C$本身由代价函数对每一个权重和每一个偏置求偏导构成。对偏置求偏导步骤也基本相同,再树状结构上把$b^{(L)}$看作变量来求即可: 回到树的末梢,来看下这个代价函数对上一层的激活值$a^{(L-1)}$的敏感度,同样的计算思路: 因为我们可以反向运用链式法则,来计算代价函数对之前的权重和偏置的敏感度。 很显然,已经有$C_0$对$a^{(L-1)}$的导数的情况下,求更上面的节点的时候比如$w^{(L-1)}$,只要简单相乘就好了: 妙蛙~ 对于复杂的结构,无非是夺标几个下标,下标是神经元在当前层的节点编号: 只是看着复杂,和每层只有一个神经元本质是一样的: 唯一改变的是,代价对$(L-1)$层激活值的导数,需要SUM,因为它有多条出边,如图上它同时影响$a^{(L)}_0$ 和$a^{(L)}_1$ 进而影响激活函数,所以得把它们加起来。 这样就完成了,只要算出来倒数第二层 代价函数对激活值的敏感度,接下来只要重复这个过程,计算喂给倒数第二层的权重和偏置就好了。这就是反向传播。 链式法则表达式给出了决定梯度每个分量的偏导,使得我们能够不断下探,最小化神经网络的代价。 ps:怎么更新,$w = w - 学习率 \times 偏导值$ ,学习率是梯度下降的步长 补充 激活函数: 疑问:ReLU好像并不能让激活值在0到1之间? 训练数据: MNIST数据库有搜集数以万计的手写数字图像

2024/1/9
articleCard.readMore

TinyRenderer笔记2:透视投影

前面渲染模型时候,没有考虑每个点的z坐标,这种方式叫做正交投影Orthographic projection,模型看起来偏胖,因为我们平时在3d世界看到的物体都是近大远小的。透视投影Perspective projection就是用近大远小的方式投影。 两种投影对比: 正交 透视 2维几何 线性变换Linear transformations 线性变换从几何直观有三个要点: 变换前是直线的,变换后依然是直线 直线比例保持不变 变换前是原点的,变换后依然是原点 说白了就是缩放、裁切和旋转, 不包括平移:可以看这个文章 平面上的线性变换都可以用一个二维矩阵计算: 仿射变换affine transformations 说简单点就是线性变换加上平移,用矩阵计算: 齐次坐标Homogeneous coordinates 把2x2的变换矩阵加上一行一列,变成3x3,并且把等待变换的向量加上一个总是1的坐标: 这样就实现了和仿射变换一样的效果!这个想法非常简单。平移在二维空间中不是线性的。所以我们将2D嵌入到3D空间中(通过简单地为第三个分量加1)。这意味着二维空间是三维空间中z=1的平面。然后我们执行一个3D线性变换,并将结果投影到我们的2D物理平面上。 将3d投射到2d只需要除以3d分量: 如果z无限逼近0代表被投影后的点在无穷远处: 被投影的点 -> 投影到平面z=?的2d坐标 (x,y,1) -> (x,y) (x,y,1/2) -> (2x,2y) (x,y,1/4) -> (4x,4y) 可以看到,随着平面的下降,投影后的点越来越远,所以当z=0时,表示的是一个向量而不是3d空间中的一个点。 3d仿射变换 2d的仿射变换可以通过吧2d嵌入3d,转换成3d中的线性变换,再投影回2d。同样的道理:3d的仿射变换,可以通过吧3d嵌入4d,转换成4d中的线性变换,在投影回3d! 使用齐次坐标: 点(x,y,z) -> (x,y,z,1),用下面的矩阵试着把它在4d空间中进行变换: 再投影回3d: 先把这个结果放一边。来看一个模拟现实中人眼将3d中一个点投影到平面上的例子: 有一个点P=(x,y,z),我们要把它投影到z=0的平面上,摄像机(也就是人的眼睛)在z轴上(0,0,c)的位置,焦点就是原点 根据初中还是高中的知识,三角形ABC和ODC是相似三角形,所以$\frac{AB}{AC}=\frac{OD}{OC}$,进而得出$\frac{x}{c-z}=\frac{x^{’}}{c}$ 所以: 回到矩阵,让r=-1/c: 这个r就是透视投影变换矩阵的关键! 总结 如果我们想用位于z轴上距离原点为c的摄像机计算一个中心投影(焦点就是原点),分三步: 将3d嵌入到4d中 在4d中进行线性变换 投影回3d rust // 4d投影到3d fn v4p2v3(v: glm::Vec4) -> glm::Vec3 { glm::vec3(v.x / v.w, v.y / v.w, v.z / v.w) } // ... let camera: glm::Vec3 = glm::vec3(0., 0., 3.); // 投影变换矩阵,注意gml初始化一行是矩阵中的一列 let projection = glm::mat4( 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., -1./camera.z, // 这里camera.z是摄像机到焦点的距离 0., 0., 0., 1.); // ... // 透视投影 let a = v4p2v3(projection * a.extend(1.)); let b = v4p2v3(projection * b.extend(1.)); let c = v4p2v3(projection * c.extend(1.)); 详细代码见这里076b31fc4ea69f00e2cee530e5e3e25445189b67

2023/4/8
articleCard.readMore

Nostr账号Nip05验证方法

最近Damus App很火,写一篇教程教大家如何在App中获得紫v图标认证。 NIP05简介 NIP是Nostr改进提议Nostr Improvement Proposal,NIP05描述了一种账号验证方法。可以想象成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图标。 验证方法 有两种路线: 找提供验证服务的社区项目,直接用他们的服务,这个自己找吧,缺点是可能不稳定,也不受自己控制 用自己的域名进行验证,前提是你拥有一个域名的控制权,本文主要讲解这种方式 静态文件 如果你已经有自己的网站了,并且支持https,那么直接在你的网站根目录放置一个静态文件即可。 文件名为.well-known/nostr.json,内容写上你的用户名和公钥: text { "names": { "kirito": "2f7caa968b0ec9bacd55a07cfaf6206aab5a62387c76303c311db949dec8bc57" } } 可以写多行,为你的小伙伴也提供验证。 云函数 当然有更好的方式,而且不需要服务器和证书,下面介绍下我使用的方式:vercel云函数(你自己会搭服务或者有其他云函数也行,原理一样) 我之前写过一篇怎么用vercel云函数的文章,可以参考,当你弄好后,绑定自己的域名,就可以通过自己的域名访问云函数了,这是一个例子: https://dogdogback.com/api/list 代码如果不会写直接克隆我的仓库即可vercel-faas。 具体操作: 修改代码仓库中的文件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" } ] } 添加处理认证请求的路由,修改/api/entrypoint.go: go func registerRouter(r *gin.RouterGroup) { // ... r.GET("/.well-known/nostr.json", handler.Cors, handler.NIP05) } 添加认证逻辑,修改/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会自动重新部署,然后用你的域名访问看看效果,这是我的: https://dogdogback.com/.well-known/nostr.json?name=kirito https://dogdogback.com/.well-known/nostr.json?name=shishi 客户端设置 以Damus举例,编辑资料,配置你的用户名和NIP05地址,就可以看到紫V图标了 到这里就成功了,有疑问可以评论区提。 一些提示 有些webapp请求是会受到浏览器跨域策略限制,在http返回header中设置Access-Control-Allow-Origin: *,我的代码中已经处理了,如果用静态文件的方式无法处理跨域。 如何获取hex格式的公钥?在Damus中长按你自己发送的信息,选择Copy Note JSON然后粘贴到一个地方,就能看到了。 vercel也是可以托管静态文件的,仓库里只留一个json文件就行。 富哥V我50

2023/2/4
articleCard.readMore

水群学习法

云玩家,全靠群友水群时候蹭经验,群友太强啦! 关于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 可以想成读的时候协变,写的时候逆变 那可变的数据结构就只能不变了 屌图

2022/12/8
articleCard.readMore