[{"content":"基础理论 均匀网格划分 把整个场景 AABB 均匀划分成 Nx × Ny × Nz 的三维网格，每个 cell 里存放与其相交的 primitive（如三角形）列表\n但是实际场景中Mesh很容易是按簇分布的，此时如果击中左上角这个cell，会导致大量的相交计算\n最容易的解决办法是提高grid的分辨率\n四叉树/八叉树 这种方法是从顶向下划分，Root表示整个场景，先切4刀，检查每个区域是否Mesh数量小于阈值了，如果不小于，继续划分\n如下图中区域1就需要继续划分，而234就不需要了\n区域1中Mesh还是很多，继续划分\n继续划分新的区域1\n上述流程假定一个情况：每个三角形都能刚好划分到不同的区域，但实际上一个三角形可能分布在不同的区域内，所以每个涉及的区域都需要存储这个三角形，如下图\n松散八叉树 扩大子节点的范围，这样可以保证这个结点是完全包含五角星\n假设一条射线从上到下进行击中判断，正好落在左上角和右上角两个结点的中心偏右一点，那左图（如果只在左边存储五角星）只有右边结点会加入检测队列，导致五角星漏掉了，那五角星就会被误判，右边就能正常把左边结点加入判断流程\n线性八叉树 TODO:具体使用流程还不太清除\nMorton Code 将多维数据映射到一维，同时保留数据点的局部性\n空间中相近的坐标，编码后的数值也相近\n下图展示计算一个XYZ坐标的莫顿编码\n如果把空间中XYZ从0-10都编码并排序后连起来会发现，一维编码表示的空间坐标，在3D空间中有了局部连续性\nBSP树 递归的用一个超平面去划分场景为两半，划分可以是任意的\n继续对AB两个区域进行划分\n与四叉树类似，当一个区域内的Mesh数量小于阈值后停止分割\nK-d Tree 是BSP树的一种特殊形式，横平竖直地进行分割，而不是随意分割\nBVH 上述方法都是自顶向下对空间进行划分，看Mesh落在哪个区域，而BVH是从底向上，合并多个Mesh为一个结点，直到合并场景中所有结点\n一个关键区别是：BVH运行划分重叠，所以mesh可以安心得只落在一个结点，不会出现一个Mesh落在两个子空间，需要额外处理的情况\n构建BVH的关键在于，如果划分左右区间使得左右两个结点的AABB重叠最小\nBVH在GPU是bottomUp来构建的\n这里注意：以前理解的 BVH构建是自底向上是不全面的，分CPU和GPU两种情况，如下图文字所示\n下面是TopDown的流程，Root表示所有三角形组成的AABB，每次都把大的AABB分成两个小的AABB，每个AABB表示一部分三角形组成的AABB\nTopDown流程的问题是，怎么去划分呢？\n可以选择沿着一条坐标轴进行，或者某个方向\n划分可以使用空间的中心或者Mesh的中心\n按照空间来划分流程如下，首先选择最长轴，找到终点，分析每个Mesh的中心在这个中点的左侧还是右侧，从而划分两个部分，计算两部分的AABB，这样就划分好了\n如何与BVH进行求交运算呢，用一个递归代码就能完成\n1 2 3 4 5 6 7 8 9 10 11 HitInfo TraverseBVH(Node* node, Ray ray) { if (!IntersectAABB(ray, node-\u0026gt;aabb)) return HitInfo::miss(); // 剪枝 if (node-\u0026gt;isLeaf) { return IntersectTriangles(ray, node-\u0026gt;triangles); // 真正的遍历求交 } else { HitInfo hitLeft = TraverseBVH(node-\u0026gt;left, ray); HitInfo hitRight = TraverseBVH(node-\u0026gt;right, ray); return hitLeft.hitDistance \u0026lt; hitRight.hitDistance ? hitLeft : hitRight; } } 从下图可以理解坐标中点划分并不是一种很好的方式（左图），AABB重叠了，右图是比较好的方式\nBVH的SAH（表面积启发式的结点划分方法） 定义数学模型：\n对一个Mesh求交的代价为t(x)，那一批Mesh不分区情况下的总代价为： 如果将Mesh分为两区，光线击中某个区域的概率为p(x),此时的代价为（ttrav是表示遍历树状结构的代价）： 结合BVH来看，AB分别表示划分后两个AABB的表面积，C是父结点的表面积，ab表示划分后各个区域的Mesh数量 从理论上理解：SAH目的是减少求交次数，首先Mesh越多，求交次数就越多，那Mesh所在的区域被击中的概率越大，进入区域去遍历这些Mesh的概率就越大，AABB的表面积越大，选择当前区域的概率越大，所以这两个因素相乘就是代价\n八叉树实践 总体上就是递归构建，基本思路不难，估计难得是适配实际情况\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 #pragma once #include \u0026#34;collision.h\u0026#34; /* 八叉树(目前这个代码跑不了，只是伪代码)： 构建: 把所有物体分配给Root，如果Root的物体数量大于MAX_LEAF_OBJECT_COUNT，就创建它的8个孩子，并且把所有物体按照位置分配给孩子（如果没法精确分配给某个孩子，就还是存在Root上），再遍历8个孩子，如果某个孩子物体数量大于MAX_LEAF_OBJECT_COUNT，继续递归构建 添加：判断物体是否包含在Root，包含的话，就判断8个孩子是否完美包含它，如果包含，就递归给孩子去添加，如果都不包含。就存储在当前Root 更新物体位置：判断是否还在同一个结点，如果不在就删除后重写插入，或者有一些论文去查询邻近结点 松散八叉树： 八叉树的用途：（加速遍历Mesh的判断，如果对某个结点的判断失败了，那这个结点以下的Mesh的判断可以完全跳过） 视锥裁剪：用视锥体和Root进行相交判断，如果AABB完全在视锥外，直接剔除Root（剪枝），如果视锥完全包含当前AABB，直接添加当前Root下所有Mesh，如果只是相交，递归检查8个孩子，递归到孩子结点，如果还相交，就添加到可见物体数组 */ namespace GameEngine { #define MAX_LEAF_OBJECT_COUNT 4 template\u0026lt;class T\u0026gt; class OctreeNode { public: OctreeNode(BoundingBox\u0026amp; Box) { AABB = Box; } std::vector\u0026lt;T\u0026gt; Objects; BoundingBox AABB; std::array\u0026lt;OctreeNode\u0026lt;T\u0026gt;*, 8\u0026gt; children{nullptr}; bool IsStrictContain(T\u0026amp; Object) { return AABB.IsContains(Object.box); } }; template\u0026lt;class T\u0026gt; class Octree { public: Octree(std::vector\u0026lt;T\u0026gt;\u0026amp; Objects, BoundingBox\u0026amp; Box) { Root = std::make_shared\u0026lt;OctreeNode\u0026lt;T\u0026gt;\u0026gt;(Box); CreateChildNode(Root); GenOctree(Root, Objects); } void CreateChildNode(std::shared_ptr\u0026lt;OctreeNode\u0026lt;T\u0026gt;\u0026gt; Root) { // TODO: 八个子空间生成，规划每个子空间的范围 // Root-\u0026gt;children[0] = new OctreeNode({ xxxx}); } void GenOctree(std::shared_ptr\u0026lt;OctreeNode\u0026lt;T\u0026gt;\u0026gt; Root,std::vector\u0026lt;T\u0026gt; \u0026amp;Objects) { // 1. 每个物体划分到当前Root的8个子空间或Root空间中 for (T\u0026amp; obj : Objects) { bool isPushed = false; for (OctreeNode\u0026lt;T\u0026gt;* CurSpace : Root-\u0026gt;children) { if(CurSpace-\u0026gt;AABB.IsContains(obj.aabb)) { CurSpace-\u0026gt;Objects.push_back(obj); isPushed = true; } } if (!isPushed)\t// 没有一个子空间能够完全包住物体，放在父空间中 { Root-\u0026gt;Objects.push_back(obj); } isPushed = false; } // 2. 判断子空间是否需要继续划分 for (OctreeNode\u0026lt;T\u0026gt;* CurSpace : Root-\u0026gt;children) { if (CurSpace-\u0026gt;Objects.size() \u0026gt; MAX_LEAF_OBJECT_COUNT) { CreateChildNode(CurSpace); GenOctree(CurSpace,CurSpace-\u0026gt;Objects); CurSpace-\u0026gt;Objects.clear(); // TODO:如果有物体存在Root上，这行代码就把他删了。。。。 } } } void AddObject(T\u0026amp; Object) { AddObjectInner(Object, Root); } bool AddObjectInner(T\u0026amp; Object, std::shared_ptr\u0026lt;OctreeNode\u0026lt;T\u0026gt;\u0026gt; Root) { if (!Root) return false; if (!Root-\u0026gt;AABB.IsContains(Object.aabb)) { return false; } bool isHandled = false; for (OctreeNode\u0026lt;T\u0026gt;* CurSpace : Root-\u0026gt;children) { if (CurSpace \u0026amp;\u0026amp; CurSpace-\u0026gt;AABB.IsContains(obj.aabb)) { // 说明子结点插入失败,交给当前结点处理 isHandled = AddObjectInner(object, CurSpace); } } if (!isHandled) { Root-\u0026gt;Objects.push_back(object); } return true; } private: std::shared_ptr\u0026lt;OctreeNode\u0026lt;T\u0026gt;\u0026gt; Root = nullptr; }; } BVH + SAH实践(TODO) CPU（自顶向下） CPU上构建BVH主要是TopDown的，SAH用来划分区间\n为了减少SAH的计算量，在最长轴上均匀分成若干桶，来减少计算,最终划分只会出现在桶内\n另外插入新结点时插入到哪个叶子结点也是根据SAH，如下图插入L的Cost = 新增的11结点的表面积 + 沿路向上的父结点表面积变化的和\n1 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 57 58 #pragma once #include \u0026#34;collision.h\u0026#34; /* BVH:（TODO：目前是伪代码，SAH未完成） 构建方式： 计算所有Mesh的AABB在哪个轴上最宽，在这个轴上对Mesh进行排序,根据某种划分算法把Mesh数组分成两部分，分别递归构建子树，直到叶子结点 添加Mesh：首先插入一定发生在叶子结点，依据SAH，判断依哪一个节点的插入会有“最小表面积”的变化，具体计算公式看Blog */ namespace GameEngine { template\u0026lt;typename T\u0026gt; class BVHNode { public: BVHNode* left = nullptr; BVHNode* right = nullptr; BVHNode* parent = nullptr; std::vector\u0026lt;T\u0026gt; objects; BoundingBox AABB; }; template\u0026lt;typename T\u0026gt; class BVH { public: BVHNode\u0026lt;T\u0026gt;* BuildBVHInner(std::vector\u0026lt;T\u0026gt;\u0026amp;objects, BVHNode\u0026lt;T\u0026gt;* Root) { // 1. 计算Root的AABB for (auto\u0026amp; object : objects) { Root-\u0026gt;AABB.Merge(object.aabb); } // 创建Root if(!Root) Root = new BVHNode\u0026lt;T\u0026gt;(); if (objects.size() \u0026lt;= 1) { Root-\u0026gt;objects = objects; return Root; } // 2. 利用SAH算法，对Root进行划分 int lefeMaxIndex = SAHSplit(Root, objects,int left, int right); // 3. 递归构建左右子树 BVHNode\u0026lt;T\u0026gt;* left = BuildBVHInner(objects.begin() + left, objects.begin() + lefeMaxIndex, Root); BVHNode\u0026lt;T\u0026gt;* right = BuildBVHInner(objects.begin() + left + lefeMaxIndex, objects.begin() + right, Root); Root-\u0026gt;left = left; Root-\u0026gt;right = right; left-\u0026gt;parent = right-\u0026gt;parent = Root; return Root; } // SAH算法，返回划分后left的最大Index int SAHSplit(BVHNode\u0026lt;T\u0026gt;* node, std::vector\u0026lt;T\u0026gt;\u0026amp; objects, int left, int right) { } BVHNode\u0026lt;T\u0026gt;* Root = nullptr; }; } GPU(自底向上，线性BVH) 以二维坐标和三维坐标为例，对于每一个点计算其对应的莫顿码，然后利用莫顿码对其进行排序，再将排序后的点按照顺序连接起来就会得到如下图所示的Z形曲线z-order curve\n构建莫顿码就是把多维数组成的坐标的每个维度交错排布\n比如二维平面上[0-3]范围内的点，全部编码为莫顿编码后，排序连线如下\n主要观察一点，用最高位0或1就可以把区域划分为上下两部分！\n只看第二位，又可以左右划分，这样就划分 成了四个区域\n所以莫顿编码 第一位为1，第二位为0的区域一定在左上角！\n对于三角形图元，编码它的方式为 xyzmin表示整个场景的最小点，lwh是场景的宽度\n按这种方式计算后P就代表这个三角形在AABB盒中的位置，接下来对P进行莫顿编码\n构建BVH时，按照上边方法生成所有图元的编码，然后进行基数排序\n这里基数排序回忆一下\n排序完成后，再按照最长公共前缀来进行切分\n切分就是按照当前位是0还是1（其实不是看一位，而是最长公共前缀，比如前几个都是00），同样是0的说明在空间上是接近的\n这种方式仍然是自顶向下的，下面介绍从底向上的GPU的构建办法\n首先第一点：一个二叉树，叶子结点有N个时，非叶子结点一定有N-1个\n","date":"2026-02-20T20:02:28+08:00","image":"https://sdpyy1.github.io/image-20260220203337299.png","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E7%A9%BA%E9%97%B4%E5%8A%A0%E9%80%9F%E7%BB%93%E6%9E%84/","title":"游戏引擎开发实践（空间加速结构）"},{"content":" FXAA（fast approximate antialiasing） 的思路是检测图像的边界，只在边界处进行滤波，达到抗锯齿的效果，确实锯齿效果就是发生在边界\n这种思路被称为形变抗锯齿，SMAA也是这个类型\nFXAA只需要一个Pass，是一种快速抗锯齿\nFXAA 的流程：\n边缘检测：比较当前像素上下左右四个方向上的亮度差，过大才进行抗锯齿操作 确定混合系数：计算当前像素与周围一圈像素的平均亮度（考虑距离，斜角距离更大）与当前像素亮度的差作为依据来确定混合系数 确定锯齿方向，判断垂直和水平四个方向上的变化，取最大变化的方向 在锯齿方向上进行左右搜素直到锯齿边界，离得越近的边界贡献应该越大 用上边的混合系数和第四步的边界距离来指导最终的混合系数，得到采样时的偏移量（在采样时偏移像素中心，就相当于混合其他像素了～） 边缘检测 计算当前处理的像素点和周围像素点的亮度对比值，FXAA 通过确定水平和垂直方向上像素点的亮度差，来计算对比值。当对比度值较大时，我们认为需要进行抗锯齿处理。\n实现比较容易，计算上下左右中间五个像素，求亮度，之后取最大值和最小值，如果差距过小，就当作不是边界，直接返回原数据\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 //////////////////////////////////////////// 1. 求亮度差 //////////////////////////////////////////// vec2 texCoords = (vec2(invocID) + 0.5f) / vec2(textureSize); float up = RGBtoLuminance(texture(sampler2D(historyTexture, SAMPLER[0]), texCoords + stepUV * Kernel_Map[1]).xyz); float down = RGBtoLuminance(texture(sampler2D(historyTexture, SAMPLER[0]), texCoords + stepUV * Kernel_Map[7]).xyz); float left = RGBtoLuminance(texture(sampler2D(historyTexture, SAMPLER[0]), texCoords + stepUV * Kernel_Map[3]).xyz); float right = RGBtoLuminance(texture(sampler2D(historyTexture, SAMPLER[0]), texCoords + stepUV * Kernel_Map[5]).xyz); float center = RGBtoLuminance(texture(sampler2D(historyTexture, SAMPLER[0]), texCoords).xyz); float maxLum = max(center, max(max(up, down), max(left, right))); float minLum = min(center, min(min(up, down), min(left, right))); float Contrast = maxLum - minLum; if(Contrast \u0026lt; max(EDGE_THRESHOLD_MIN, maxLum * EDGE_THRESHOLD_MAX)) { vec4 outcolor = texture(sampler2D(historyTexture, SAMPLER[0]), texCoords); return; } imageStore(out_texture, invocID, vec4(1,0,0,1)); 看看效果，可以看到大部分点都认为是不需要进行处理的地方\n基于亮度的混合系数计算 一方面考虑亮度差，另一方面考虑距离（对角线上的像素距离中心像素远一些）\n1 2 3 4 5 6 7 8 9 10 //////////////////////////////////////////// 2. 计算基于亮度的混合系数计算 //////////////////////////////////////////// float Filter = 2.0 * (N + E + S + W) + NE + NW + SE + SW; Filter = Filter / 12.0; Filter = abs(Filter - M); Filter = saturate(Filter / Contrast); float PixelBlend = smoothstep(0.0, 1.0, Filter); PixelBlend = PixelBlend * PixelBlend; 计算混合方向与混合 锯齿的方向不一定一样，通过亮度差异来计算锯齿方向\n计算完偏移方向后，最终采样点就往锯齿方向偏移，偏移量就是上一步获得的权重\n1 2 3 4 5 6 7 8 9 10 //////////////////////////////////////////// 3. 计算锯齿方向并混合 //////////////////////////////////////////// float Vertical = abs(N + S - 2 * M) * 2+ abs(NE + SE - 2 * E) + abs(NW + SW - 2 * W); float Horizontal = abs(E + W - 2 * M) * 2 + abs(NE + NW - 2 * N) + abs(SE + SW - 2 * S); bool IsHorizontal = Vertical \u0026gt; Horizontal; // 垂直方向上亮度变化大，说明锯齿是水平的 vec2 PixelStep = IsHorizontal ? vec2(0, stepUV.y) : vec2(stepUV.x, 0); float Positive = abs((IsHorizontal ? N : E) - M); float Negative = abs((IsHorizontal ? S : W) - M); if(Positive \u0026lt; Negative) PixelStep = -PixelStep; // PixelStep往亮度大方向移动 vec4 outcolor = texture(sampler2D(historyTexture, SAMPLER[0]), texCoords + PixelStep * PixelBlend); 更准确的混合系数 上述简化的方法是有问题的：\n如下图1是要进行抗锯齿的图片\n图2是上述方法的结果，在边界上很多像素结果都是一致的，因为在9宫格范围内他们的表现一致，但实际上考虑到这个锯齿是斜向的，所以越靠右的像素（第二排应该越靠近黑色去采样，而不是白色）\n图三效果就好了\n计算混合时，在锯齿方向上左右采样，直到找到锯齿的边界\n看左右两边到达锯齿边界的距离远近，像素的混合系数应该更趋向于离得更近的锯齿边界的颜色\n锯齿的边界其实可以理解为上下像素差距不大的地方，如上图的左右边界， 他们要么是全红了，要么是全白了，\n所以离全红更近，就偏向红，否则偏向白色\n如下图点离右侧锯齿边界更近，那它应该更黑一点（假设三角形内部是黑色）\n","date":"2026-01-25T16:21:06+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5fxaa-todo/","title":"游戏引擎开发实践（FXAA   TODO）"},{"content":"渲染架构 RHI RHI相关的资源全部被封装位RHIResource\n1 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 enum RHIResourceType : uint32_t { // 基础资源 RHI_BUFFER = 0, RHI_TEXTURE, RHI_TEXTURE_VIEW, RHI_SAMPLER, RHI_SHADER, // 光追 RHI_SHADER_BINDING_TABLE, RHI_TOP_LEVEL_ACCELERATION_STRUCTURE, RHI_BOTTOM_LEVEL_ACCELERATION_STRUCTURE, // 资源描述符 RHI_ROOT_SIGNATURE, RHI_DESCRIPTOR_SET, // 渲染Pass RHI_RENDER_PASS, RHI_GRAPHICS_PIPELINE, RHI_COMPUTE_PIPELINE, RHI_RAY_TRACING_PIPELINE, // 底层相关 RHI_QUEUE, RHI_SURFACE, RHI_SWAPCHAIN, RHI_COMMAND_POOL, RHI_COMMAND_CONTEXT, RHI_COMMAND_CONTEXT_IMMEDIATE, // 同步 RHI_FENCE, RHI_SEMAPHORE, RHI_RESOURCE_TYPE_MAX_CNT,\t// }; RHIResource基类\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class RHIResource { public: RHIResource() = delete; RHIResource(RHIResourceType resourceType) : resourceType(resourceType) {}; virtual ~RHIResource() {}; inline RHIResourceType GetType() { return resourceType; } virtual void* RawHandle() { return nullptr; };\t// 底层资源的裸指针，仅debug时使用 void printRawHandle(); private: RHIResourceType resourceType; uint32_t lastUseTick = 0; virtual void Destroy() {}; friend class DynamicRHI; }; 每个RHI资源都拥有自己的类来管理\n1 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 class RHITexture : public RHIResource { public: RHITexture(const RHITextureInfo\u0026amp; info): RHIResource(RHI_TEXTURE), info(info){} Extent3D MipExtent(uint32_t mipLevel); inline const TextureSubresourceRange\u0026amp; GetDefaultSubresourceRange() const { return defaultRange; } inline const TextureSubresourceLayers\u0026amp; GetDefaultSubresourceLayers() const { return defaultLayers; } inline const RHITextureInfo\u0026amp; GetInfo() const { return info; } protected: RHITextureInfo info; TextureSubresourceRange defaultRange = {}; TextureSubresourceLayers defaultLayers = {}; }; class RHIBuffer : public RHIResource { public: RHIBuffer(const RHIBufferInfo\u0026amp; info) : RHIResource(RHI_BUFFER) , info(info) { } virtual void* Map() = 0; virtual void UnMap() = 0; inline const RHIBufferInfo\u0026amp; GetInfo() const { return info; } protected: RHIBufferInfo info; }; DynamicRHI\n负责不需要Context的RHI命令，比如创建纹理、Buffer、Fence等\n负责API Context的创建\n1 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 class DynamicRHI { private: static DynamicRHIRef s_DynamicRHI; public: static DynamicRHIRef Init(RHIConfig config); static DynamicRHIRef Get(){return s_DynamicRHI;} virtual void Tick(); // 更新资源计数，清理无引用且长时间未使用资源 virtual void InitImGui(GLFWwindow* window) = 0; virtual RHIDescriptorSetRef GetImGuiTextId(RHITextureViewRef textureView) = 0; virtual void Destroy(); virtual RHIComputePipelineRef CreateComputePipeline(const RHIComputePipelineInfo\u0026amp; info) = 0; virtual RHITopLevelAccelerationStructureRef CreateTopLevelAccelerationStructure(const RHITopLevelAccelerationStructureInfo\u0026amp; info) = 0; virtual RHIShaderBindingTableRef CreateShaderBindingTable(const RHIShaderBindingTableInfo\u0026amp; info) = 0; virtual RHIRayTracingPipelineRef CreateRayTracingPipeline(const RHIRayTracingPipelineInfo\u0026amp; info) = 0; virtual RHIBottomLevelAccelerationStructureRef CreateBottomLevelAccelerationStructure(const RHIBottomLevelAccelerationStructureInfo\u0026amp; info) = 0; virtual RHIQueueRef GetQueue(const RHIQueueInfo\u0026amp; info) = 0; virtual RHISurfaceRef CreateSurface(GLFWwindow* window) = 0; virtual RHISwapchainRef CreateSwapChain(const RHISwapchainInfo\u0026amp; info) = 0; virtual RHICommandPoolRef CreateCommandPool(const RHICommandPoolInfo\u0026amp; info) = 0; virtual RHICommandContextRef CreateCommandContext(RHICommandPoolRef pool) = 0; virtual RHITextureRef CreateTexture(const RHITextureInfo\u0026amp; info) = 0; virtual RHITextureViewRef CreateTextureView(const RHITextureViewInfo\u0026amp; info) = 0; virtual RHISamplerRef CreateSampler(const RHISamplerInfo\u0026amp; info) = 0; virtual RHIShaderRef CreateShader(const RHIShaderInfo\u0026amp; info) = 0; virtual RHIBufferRef CreateBuffer(const RHIBufferInfo\u0026amp; info) = 0; virtual RHIGraphicsPipelineRef CreateGraphicsPipeline(const RHIGraphicsPipelineInfo\u0026amp; info) = 0; virtual RHIRenderPassRef CreateRenderPass(const RHIRenderPassInfo\u0026amp; info) = 0; virtual RHIRootSignatureRef CreateRootSignature(const RHIRootSignatureInfo\u0026amp; info) = 0; // 同步 virtual RHIFenceRef CreateFence(bool signaled) = 0; virtual RHISemaphoreRef CreateSemaphore() = 0; virtual RHICommandListImmediateRef GetImmediateCommandList() = 0; void RegisterResource(RHIResourceRef resource) { resourceMap[resource-\u0026gt;GetType()].push_back(resource); } RHIConfig\u0026amp; GetConfig() { return m_Config; } bool isEnableRayTracing() { return m_Config.enableRayTracing; } protected: DynamicRHI(const RHIConfig\u0026amp; config) : m_Config(config) {}; std::array\u0026lt;std::vector\u0026lt;RHIResourceRef\u0026gt;, RHI_RESOURCE_TYPE_MAX_CNT\u0026gt; resourceMap; RHIConfig m_Config; }; 另外他还维护一个resourceMap（每种资源一个Vector），每帧Tick\n1 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 void DynamicRHI::Tick() { for (auto\u0026amp; resources : resourceMap) { bool needClean = false; for (RHIResourceRef\u0026amp; resource : resources) { if (resource) { if (resource.use_count() == 1) resource-\u0026gt;lastUseTick++; else resource-\u0026gt;lastUseTick = 0; if (resource-\u0026gt;lastUseTick \u0026gt; 6) //析构资源6帧后销毁 { needClean = true; //if(resource-\u0026gt;GetType() != RHI_RENDER_PASS) // std::cout \u0026lt;\u0026lt; \u0026#34;RHI resource [\u0026#34; \u0026lt;\u0026lt; resource.get() \u0026lt;\u0026lt; \u0026#34;] of type [\u0026#34; \u0026lt;\u0026lt; resource-\u0026gt;GetType() \u0026lt;\u0026lt; \u0026#34;] destroied\u0026#34; \u0026lt;\u0026lt; std::endl; resource-\u0026gt;Destroy(); resource = nullptr; } } } // 删除空指针 if (needClean) { resources.erase(std::remove_if(resources.begin(), resources.end(), [](RHIResourceRef x) { return x == nullptr; }), resources.end()); } } } RHICommandContext，用于定义需要Commandbuffer的命令，内部持有RHICommandBuffer对象\n1 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 class RHICommandContext : public RHIResource { public: RHICommandContext(RHICommandPoolRef pool): RHIResource(RHI_COMMAND_CONTEXT), pool(pool){} virtual void BeginCommand() = 0; virtual void EndCommand() = 0; virtual void Execute(RHIFenceRef waitFence, RHISemaphoreRef waitSemaphore, RHISemaphoreRef signalSemaphore) = 0; virtual void TextureBarrier(const RHITextureBarrier\u0026amp; barrier) = 0; virtual void BufferBarrier(const RHIBufferBarrier\u0026amp; barrier) = 0; virtual void BeginRenderPass(RHIRenderPassRef renderPass) = 0; //也可以运行时FindOrCreate相应的renderpass和framebuffer等，很多东西可以做中心化的查找表统一管理状态 virtual void CopyTexture(RHITextureRef src, TextureSubresourceLayers srcSubresource, RHITextureRef dst, TextureSubresourceLayers dstSubresource) = 0; virtual void GenerateMips(RHITextureRef src) = 0; virtual void SetViewport(Offset2D min, Offset2D max) = 0; virtual void SetScissor(Offset2D min, Offset2D max) = 0; virtual void SetDepthBias(float constantBias, float slopeBias, float clampBias) = 0; virtual void SetLineWidth(float width) = 0; virtual void SetGraphicsPipeline(RHIGraphicsPipelineRef graphicsPipeline) = 0; virtual void SetComputePipeline(RHIComputePipelineRef computePipeline) = 0; virtual void PushLabel(const std::string\u0026amp; name, Color3 color = { 1.0f, 1.0f, 1.0f }) = 0; virtual void PopLabel() = 0; virtual void SetRayTracingPipeline(RHIRayTracingPipelineRef rayTracingPipeline) = 0; virtual void PushConstants(void* data, uint16_t size, ShaderFrequency frequency) = 0; virtual void BindDescriptorSet(RHIDescriptorSetRef descriptor, uint32_t set) = 0; virtual void BindVertexBuffer(RHIBufferRef vertexBuffer, uint32_t streamIndex, uint32_t offset) = 0; virtual void BindIndexBuffer(RHIBufferRef indexBuffer, uint32_t offset) = 0; virtual void Dispatch(uint32_t groupCountX, uint32_t groupCountY, uint32_t groupCountZ) = 0; virtual void DispatchIndirect(RHIBufferRef argumentBuffer, uint32_t argumentOffset) = 0; virtual void TraceRays(uint32_t groupCountX, uint32_t groupCountY, uint32_t groupCountZ) = 0; virtual void Draw(uint32_t vertexCount, uint32_t instanceCount, uint32_t firstVertex, uint32_t firstInstance) = 0; virtual void DrawIndexed(uint32_t indexCount, uint32_t instanceCount, uint32_t firstIndex, uint32_t vertexOffset, uint32_t firstInstance) = 0; virtual void DrawIndirect(RHIBufferRef argumentBuffer, uint32_t offset, uint32_t drawCount) = 0; virtual void DrawIndexedIndirect(RHIBufferRef argumentBuffer, uint32_t offset, uint32_t drawCount) = 0; virtual void EndRenderPass() = 0; virtual std::vector\u0026lt;RHIGPUTimeInfo\u0026gt; GetGPUTime() = 0; virtual void ImGuiRenderDrawData() = 0; protected: RHICommandPoolRef pool; }; VulkanRHICommandContextImmediate负责临时命令执行和提交\n1 2 3 4 5 6 7 8 9 10 11 class RHICommandContextImmediate : public RHIResource { public: RHICommandContextImmediate(): RHIResource(RHI_COMMAND_CONTEXT_IMMEDIATE){} virtual void Flush() = 0; virtual void GenerateMips(RHITextureRef src) = 0; virtual void TextureBarrier(const RHITextureBarrier\u0026amp; barrier) = 0; virtual void CopyBufferToTexture(RHIBufferRef src, uint64_t srcOffset, RHITextureRef dst, TextureSubresourceLayers dstSubresource) = 0; virtual void ImGuiUploadFonts() = 0; }; RHICommandList负责为上层（RDG）提供服务\n1 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 57 58 59 class RHICommandList { public: RHICommandList(const CommandListInfo\u0026amp; info) : info(info) {} void BeginCommand(); void EndCommand(); void TextureBarrier(const RHITextureBarrier\u0026amp; barrier); void BeginRenderPass(RHIRenderPassRef renderPass); void CopyTexture(RHITextureRef src, TextureSubresourceLayers srcSubresource, RHITextureRef dst, TextureSubresourceLayers dstSubresource); void GenerateMips(RHITextureRef src); void EndRenderPass(); void BufferBarrier(const RHIBufferBarrier\u0026amp; barrier); void Execute(RHIFenceRef fence = nullptr, RHISemaphoreRef waitSemaphore = nullptr, RHISemaphoreRef signalSemaphore = nullptr); void SetViewport(Offset2D min, Offset2D max); void SetScissor(Offset2D min, Offset2D max); void SetDepthBias(float constantBias, float slopeBias, float clampBias); void PushLabel(const std::string\u0026amp; name, Color3 color = { 1.0f, 1.0f, 1.0f }); void PopLabel(); void SetLineWidth(float width); void SetGraphicsPipeline(RHIGraphicsPipelineRef graphicsPipeline); void SetRayTracingPipeline(RHIRayTracingPipelineRef rayTracingPipeline); void SetComputePipeline(RHIComputePipelineRef computePipeline); void PushConstants(void* data, uint16_t size, ShaderFrequency frequency); void BindDescriptorSet(RHIDescriptorSetRef descriptor, uint32_t set = 0); void BindVertexBuffer(RHIBufferRef vertexBuffer, uint32_t streamIndex = 0, uint32_t offset = 0); void BindIndexBuffer(RHIBufferRef indexBuffer, uint32_t offset = 0); void Dispatch(uint32_t groupCountX, uint32_t groupCountY, uint32_t groupCountZ); void DispatchIndirect(RHIBufferRef argumentBuffer, uint32_t argumentOffset = 0); void TraceRays(uint32_t groupCountX, uint32_t groupCountY, uint32_t groupCountZ); void Draw(uint32_t vertexCount, uint32_t instanceCount = 1, uint32_t firstVertex = 0, uint32_t firstInstance = 0); void DrawIndexed(uint32_t indexCount, uint32_t instanceCount = 1, uint32_t firstIndex = 0, uint32_t vertexOffset = 0, uint32_t firstInstance = 0); void DrawIndirect(RHIBufferRef argumentBuffer, uint32_t offset, uint32_t drawCount); void DrawIndexedIndirect(RHIBufferRef argumentBuffer, uint32_t offset, uint32_t drawCount); void ImGuiRenderDrawData(); public: std::vector\u0026lt;RHIGPUTimeInfo\u0026gt; GetGPUTime(); uint32_t GetDrawCallCount() { return drawCallCount; } void ClearDrawCallCount() { drawCallCount = 0; } protected: inline void AddCommand(RHICommand* command) { commands.push_back(command); } private: std::vector\u0026lt;RHICommand*\u0026gt; commands; CommandListInfo info; uint32_t drawCallCount = 0; }; 他的各个函数其实和RHICommandContext是一致的，但是实现上是可以直接执行底层函数，也可以缓存\n1 2 3 4 5 6 void RHICommandList::BeginCommand() { COMMANDLIST_DEBUG_OUTPUT(); if (info.byPass) info.context-\u0026gt;BeginCommand(); else ADD_COMMAND(BeginCommand); } 命令的缓存是通过ADD_COMMAND\n1 2 3 4 5 6 7 #define ADD_COMMAND(commandName, ...) do { \\ RHICommand* command = new RHICommand##commandName(__VA_ARGS__); \\ AddCommand(command); \\ } while (0) // commands的指针存在一个Vector，最终执行依次执行即可 std::vector\u0026lt;RHICommandImmediate*\u0026gt; commands; 所以说每个命令都是一个类\n1 2 3 4 5 struct RHICommandBeginCommand : public RHICommand { RHICommandBeginCommand() {} virtual void Execute(RHICommandContextRef context) override final; }; 执行CommandList\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void RHICommandList::Execute(RHIFenceRef fence, RHISemaphoreRef waitSemaphore, RHISemaphoreRef signalSemaphore) { if (!info.byPass) { // LOG_DEBUG(\u0026#34;Recording command list in delay mode.\u0026#34;); for (int32_t i = 0; i \u0026lt; commands.size(); i++) { commands[i]-\u0026gt;Execute(info.context); delete commands[i]; } commands.clear(); } info.context-\u0026gt;Execute(fence, waitSemaphore, signalSemaphore); } RDG RDFNode: 分为资源Node和PassNode\n资源Node分为BufferNode和TextureNode，PassNode有 图形、计算、光追、Copy、呈现五种\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 enum RDGPassNodeType { RDG_PASS_NODE_TYPE_RENDER = 0, RDG_PASS_NODE_TYPE_COMPUTE, RDG_PASS_NODE_TYPE_RAY_TRACING, RDG_PASS_NODE_TYPE_PRESENT, RDG_PASS_NODE_TYPE_COPY, RDG_PASS_NODE_TYPE_MAX_ENUM, // }; enum RDGResourceNodeType { RDG_RESOURCE_NODE_TYPE_TEXTURE = 0, RDG_RESOURCE_NODE_TYPE_BUFFER, RDG_RESOURCE_NODE_TYPE_MAX_ENUM, // }; // PassNode需要的Context typedef struct RDGPassContext { RHICommandListRef command; RDGBuilder* builder; std::array\u0026lt;RHIDescriptorSetRef, MAX_DESCRIPTOR_SETS\u0026gt; descriptors; uint32_t passIndex[3] = { 0, 0, 0 }; } RDGPassContext; // Node基类 class RDGNode : public DependencyGraph::Node { public: RDGNode(std::string name) : name(name) { } const std::string\u0026amp; Name() { return name; } private: std::string name; }; typedef RDGNode* RDGNodeRef; // 资源节点///////////////////////////////////////////////////////////////////////////////////// class RDGResourceNode : public RDGNode { public: RDGResourceNode(std::string name, RDGResourceNodeType nodeType) : RDGNode(name) , nodeType(nodeType) { } inline bool IsImported() { return isImported; } RDGResourceNodeType NodeType() { return nodeType; } protected: RDGResourceNodeType nodeType; bool isImported = false; }; typedef RDGResourceNode* RDGResourceNodeRef; class RDGTextureNode : public RDGResourceNode { public: RDGTextureNode(std::string name): RDGResourceNode(name, RDG_RESOURCE_NODE_TYPE_TEXTURE){} void ForEachPass(const std::function\u0026lt;void(RDGTextureEdgeRef, class RDGPassNode*)\u0026gt;\u0026amp; func); RDGTextureHandle GetHandle() { return RDGTextureHandle(ID()); } const RHITextureInfo\u0026amp; GetInfo() { return info; } RHITextureRef GetRHITexture() { return texture; } private: RHITextureInfo info; RHIResourceState initState; // 从池中/外部引用时的最初状态 RHITextureRef texture; // 执行时分配和绑定，会动态更新，在最后一个依赖pass完成后返回资源池 friend class RDGTextureBuilder; friend class RDGBuilder; }; typedef RDGTextureNode* RDGTextureNodeRef; class RDGBufferNode : public RDGResourceNode { public: RDGBufferNode(std::string name): RDGResourceNode(name, RDG_RESOURCE_NODE_TYPE_BUFFER){} void ForEachPass(const std::function\u0026lt;void(RDGBufferEdgeRef, class RDGPassNode*)\u0026gt;\u0026amp; func); RDGBufferHandle GetHandle() { return RDGBufferHandle(ID()); } const RHIBufferInfo\u0026amp; GetInfo() { return info; } private: RHIBufferInfo info; RHIResourceState initState; // 从池中/外部引用时的最初状态 RHIBufferRef buffer; // 执行时分配和绑定，会动态更新，在最后一个依赖pass完成后返回资源池 friend class RDGBufferBuilder; friend class RDGBuilder; }; typedef RDGBufferNode* RDGBufferNodeRef; // pass节点///////////////////////////////////////////////////////////////////////////////////// class RDGPassNode : public RDGNode { public: RDGPassNode(std::string name, RDGPassNodeType nodeType) : RDGNode(name) , nodeType(nodeType) { } inline bool Before(RDGPassNode* other) { return ID() \u0026lt; other-\u0026gt;ID(); } // 假定所有pass的添加顺序就是执行顺序 inline bool After(RDGPassNode* other) { return ID() \u0026gt; other-\u0026gt;ID(); } void ForEachTexture(const std::function\u0026lt;void(RDGTextureEdgeRef, RDGTextureNodeRef)\u0026gt;\u0026amp; func); void ForEachBuffer(const std::function\u0026lt;void(RDGBufferEdgeRef, RDGBufferNodeRef)\u0026gt;\u0026amp; func); Color3 GetLabelColor() { return LabelColor; } void SetLabelColor(Color3 color) { LabelColor = color; } RDGPassNodeType NodeType() { return nodeType; } protected: RDGPassNodeType nodeType; bool isCulled = false; Color3 LabelColor = { 1.0f, 1.0f, 1.0f }; RHIRootSignatureRef rootSignature; std::array\u0026lt;RHIDescriptorSetRef, MAX_DESCRIPTOR_SETS\u0026gt; descriptorSets; std::vector\u0026lt;RHITextureViewRef\u0026gt; pooledViews; // 动态分配的池化资源，执行完毕后返回资源池 std::vector\u0026lt;std::pair\u0026lt;RHIDescriptorSetRef, uint32_t\u0026gt;\u0026gt; pooledDescriptorSets; friend class RDGBuilder; }; typedef RDGPassNode* RDGPassNodeRef; class RDGRenderPassNode : public RDGPassNode { public: RDGRenderPassNode(std::string name) : RDGPassNode(name, RDG_PASS_NODE_TYPE_RENDER) { } RDGRenderPassHandle GetHandle() { return RDGRenderPassHandle(ID()); } private: uint32_t passIndex[3] = { 0, 0, 0 }; RDGPassExecuteFunc execute; friend class RDGRenderPassBuilder; friend class RDGBuilder; }; typedef RDGRenderPassNode* RDGRenderPassNodeRef; class RDGComputePassNode : public RDGPassNode { public: RDGComputePassNode(std::string name) : RDGPassNode(name, RDG_PASS_NODE_TYPE_COMPUTE) { } RDGComputePassHandle GetHandle() { return RDGComputePassHandle(ID()); } private: uint32_t passIndex[3] = { 0, 0, 0 }; RDGPassExecuteFunc execute; friend class RDGComputePassBuilder; friend class RDGBuilder; }; typedef RDGComputePassNode* RDGComputePassNodeRef; class RDGRayTracingPassNode : public RDGPassNode { public: RDGRayTracingPassNode(std::string name) : RDGPassNode(name, RDG_PASS_NODE_TYPE_RAY_TRACING) { } RDGRayTracingPassHandle GetHandle() { return RDGRayTracingPassHandle(ID()); } private: uint32_t passIndex[3] = { 0, 0, 0 }; RDGPassExecuteFunc execute; friend class RDGRayTracingPassBuilder; friend class RDGBuilder; }; typedef RDGRayTracingPassNode* RDGRayTracingPassNodeRef; class RDGPresentPassNode : public RDGPassNode { public: RDGPresentPassNode(std::string name) : RDGPassNode(name, RDG_PASS_NODE_TYPE_PRESENT) { } RDGPresentPassHandle GetHandle() { return RDGPresentPassHandle(ID()); } private: friend class RDGPresentPassBuilder; friend class RDGBuilder; }; typedef RDGPresentPassNode* RDGPresentPassNodeRef; class RDGCopyPassNode : public RDGPassNode { public: RDGCopyPassNode(std::string name) : RDGPassNode(name, RDG_PASS_NODE_TYPE_COPY) { } RDGCopyPassHandle GetHandle() { return RDGCopyPassHandle(ID()); } private: bool generateMip = false; friend class RDGCopyPassBuilder; friend class RDGBuilder; }; typedef RDGCopyPassNode* RDGCopyPassNodeRef; RDGEdge 定义Pass如何访问某个资源,分为TextureEdge和BufferEdge\n边用于定义一个Pass如何访问一个资源，并且标识了资源在资源描述符集的位置（Set Binding），另外需要知道Pass访问这个资源需要这个资源处于哪种状态\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 enum RDGEdgeType { RDG_EDGE_TYPE_TEXTURE = 0, RDG_EDGE_TYPE_BUFFER, RDG_EDGE_TYPE_MAX_ENUM, // }; class RDGEdge : public DependencyGraph::Edge { public: RDGEdge(RDGEdgeType edgeType, RHIResourceState state = RESOURCE_STATE_UNDEFINED): state(state), edgeType(edgeType){} virtual bool IsOutput() { return false; } RDGEdgeType EdgeType() { return edgeType; } RHIResourceState state; // 在对应的pass处要求的状态（若作为pass输入，pass不应在内部改变状态） protected: RDGEdgeType edgeType; }; typedef RDGEdge* RDGEdgeRef; class RDGTextureEdge : public RDGEdge { public: RDGTextureEdge() : RDGEdge(RDG_EDGE_TYPE_TEXTURE) { } TextureSubresourceRange subresource = {}; TextureSubresourceLayers subresourceLayer = {}; // 单层mip，多层layer // 下面这些每种标记都对应一种用法 bool asColor = false; // 颜色缓冲标记 bool asDepthStencil = false; // 深度缓冲标记 bool asShaderRead = false; bool asShaderReadWrite = false; bool asOutputRead = false; bool asOutputReadWrite = false; bool asPresent = false; bool asTransferSrc = false; bool asTransferDst = false; virtual bool IsOutput() override { return asOutputRead || asOutputReadWrite; } uint32_t set = 0; uint32_t binding = 0; uint32_t index = 0; ResourceType type = RESOURCE_TYPE_TEXTURE; TextureViewType viewType = VIEW_TYPE_2D; AttachmentLoadOp loadOp = ATTACHMENT_LOAD_OP_DONT_CARE; AttachmentStoreOp\tstoreOp = ATTACHMENT_STORE_OP_DONT_CARE; Color4\tclearColor = { 0.0f, 0.0f, 0.0f, 0.0f }; float\tclearDepth = 1.0f; uint32_t\tclearStencil = 0; }; class RDGBufferEdge : public RDGEdge { public: RDGBufferEdge() : RDGEdge(RDG_EDGE_TYPE_BUFFER) { } uint32_t offset = 0; uint32_t size = 0; bool asShaderRead = false; // Shader会读取 bool asShaderReadWrite = false; bool asOutputRead = false; bool asOutputReadWrite = false; bool asOutputIndirectDraw = false; virtual bool IsOutput() override { return asOutputRead || asOutputReadWrite || asOutputIndirectDraw; } uint32_t set = 0; // 描述符使用 uint32_t binding = 0; uint32_t index = 0; ResourceType type = RESOURCE_TYPE_UNIFORM_BUFFER; }; typedef RDGBufferEdge* RDGBufferEdgeRef; RDGResoruceHandle 给每个Node一个ID\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 using NodeID = DependencyGraph::NodeID; class RDGResoruceHandle { public: RDGResoruceHandle(NodeID id) : id(id) {} bool operator\u0026lt; (const RDGResoruceHandle\u0026amp; other) const noexcept { return id \u0026lt; other.id; } bool operator== (const RDGResoruceHandle\u0026amp; other) const noexcept { return (id == other.id); } bool operator!= (const RDGResoruceHandle\u0026amp; other) const noexcept { return !operator==(other); } inline NodeID ID() { return id; } protected: NodeID id = UINT32_MAX; }; class RDGPassHandle : public RDGResoruceHandle { public: RDGPassHandle(NodeID id) : RDGResoruceHandle(id) {}; }; class RDGRenderPassHandle : public RDGPassHandle { public: RDGRenderPassHandle(NodeID id) : RDGPassHandle(id) {}; }; class RDGComputePassHandle : public RDGPassHandle { public: RDGComputePassHandle(NodeID id) : RDGPassHandle(id) {}; }; class RDGRayTracingPassHandle : public RDGPassHandle { public: RDGRayTracingPassHandle(NodeID id) : RDGPassHandle(id) {}; }; class RDGPresentPassHandle : public RDGPassHandle { public: RDGPresentPassHandle(NodeID id) : RDGPassHandle(id) {}; }; class RDGCopyPassHandle : public RDGPassHandle { public: RDGCopyPassHandle(NodeID id) : RDGPassHandle(id) {}; }; class RDGTextureHandle : public RDGResoruceHandle { public: RDGTextureHandle(NodeID id) : RDGResoruceHandle(id) {}; }; class RDGBufferHandle : public RDGResoruceHandle { public: RDGBufferHandle(NodeID id) : RDGResoruceHandle(id) {}; }; RDGPool 负责资源的复用\n被池化的资源有：Buffer、Texture、ImageView、DescriptorSet\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 class RDGBufferPool { public: struct PooledBuffer { RHIBufferRef buffer; RHIResourceState state; }; struct Key { Key(const RHIBufferInfo\u0026amp; info) : memoryUsage(info.memoryUsage) , type(info.type) , creationFlag(info.creationFlag) { } MemoryUsage memoryUsage; ResourceType type; BufferCreationFlags creationFlag; friend bool operator== (const Key\u0026amp; a, const Key\u0026amp; b) { return a.memoryUsage == b.memoryUsage \u0026amp;\u0026amp; a.type == b.type \u0026amp;\u0026amp; a.creationFlag == b.creationFlag; } struct Hash { size_t operator()(const Key\u0026amp; a) const { return MurmurHash64A(\u0026amp;a, sizeof(Key), 0); } }; }; PooledBuffer Allocate(const RHIBufferInfo\u0026amp; info); void Release(const PooledBuffer\u0026amp; pooledBuffer); inline uint32_t PooledSize() { return pooledSize; } inline uint32_t AllocatedSize() { return allocatedSize; } void Clear() { pooledBuffers.clear(); pooledSize = 0; } static std::shared_ptr\u0026lt;RDGBufferPool\u0026gt; Get() { static std::shared_ptr\u0026lt;RDGBufferPool\u0026gt; pool; if (pool == nullptr) pool = std::make_shared\u0026lt;RDGBufferPool\u0026gt;(); return pool; } private: std::unordered_map\u0026lt;Key, std::list\u0026lt;PooledBuffer\u0026gt;, Key::Hash\u0026gt; pooledBuffers; // Key一样的Buffer也有一个List（双向链表） uint32_t pooledSize = 0; uint32_t allocatedSize = 0; }; class RDGTexturePool { public: struct PooledTexture { RHITextureRef texture; RHIResourceState state; }; struct Key { Key(const RHITextureInfo\u0026amp; info) : info(info) { } RHITextureInfo info; friend bool operator== (const Key\u0026amp; a, const Key\u0026amp; b) { return a.info == b.info; } struct Hash { size_t operator()(const Key\u0026amp; a) const { return MurmurHash64A(\u0026amp;a, sizeof(Key), 0); } }; }; PooledTexture Allocate(const RHITextureInfo\u0026amp; info); void Release(const PooledTexture\u0026amp; pooledTexture); inline uint32_t PooledSize() { return pooledSize; } inline uint32_t AllocatedSize() { return allocatedSize; } void Clear() { pooledTextures.clear(); pooledSize = 0; } static std::shared_ptr\u0026lt;RDGTexturePool\u0026gt; Get() { static std::shared_ptr\u0026lt;RDGTexturePool\u0026gt; pool; if (pool == nullptr) pool = std::make_shared\u0026lt;RDGTexturePool\u0026gt;(); return pool; } private: std::unordered_map\u0026lt;Key, std::list\u0026lt;PooledTexture\u0026gt;, Key::Hash\u0026gt; pooledTextures; // 每个Key对应一个List而不是一个Texture uint32_t pooledSize = 0; uint32_t allocatedSize = 0; }; class RDGTextureViewPool { public: struct PooledTextureView { RHITextureViewRef textureView; }; struct Key { Key(const RHITextureViewInfo\u0026amp; info) : info(info) { } RHITextureViewInfo info; friend bool operator== (const Key\u0026amp; a, const Key\u0026amp; b) { return a.info == b.info; } struct Hash { size_t operator()(const Key\u0026amp; a) const { return MurmurHash64A(\u0026amp;a.info, sizeof(RHITextureViewInfo), 0); } }; }; PooledTextureView Allocate(const RHITextureViewInfo\u0026amp; info); void Release(const PooledTextureView\u0026amp; pooledTextureView); inline uint32_t PooledSize() { return pooledSize; } inline uint32_t AllocatedSize() { return allocatedSize; } void Clear() { pooledTextureViews.clear(); pooledSize = 0; } static std::shared_ptr\u0026lt;RDGTextureViewPool\u0026gt; Get() { static std::shared_ptr\u0026lt;RDGTextureViewPool\u0026gt; pool; if (pool == nullptr) pool = std::make_shared\u0026lt;RDGTextureViewPool\u0026gt;(); return pool; } private: std::unordered_map\u0026lt;Key, std::list\u0026lt;PooledTextureView\u0026gt;, Key::Hash\u0026gt; pooledTextureViews; uint32_t pooledSize = 0; uint32_t allocatedSize = 0; }; class RDGDescriptorSetPool { public: struct PooledDescriptor { RHIDescriptorSetRef descriptor; }; struct Key // 根签名和Set一致就可以复用 { Key(const RHIRootSignatureInfo\u0026amp; info, uint32_t set) : entries(info.GetEntries()) , set(set) { } std::vector\u0026lt;ShaderResourceEntry\u0026gt; entries; uint32_t set; friend bool operator== (const Key\u0026amp; a, const Key\u0026amp; b) { return a.entries == b.entries \u0026amp;\u0026amp; a.set == b.set; } struct HashEntries { size_t operator()(std::vector\u0026lt;ShaderResourceEntry\u0026gt; entries) const { return MurmurHash64A(entries.data(), entries.size() * sizeof(ShaderResourceEntry), 0); } }; struct Hash { size_t operator()(const Key\u0026amp; a) const { return std::hash\u0026lt;uint32_t\u0026gt;()(a.set) ^ (HashEntries()(a.entries) \u0026lt;\u0026lt; 1); } }; }; PooledDescriptor Allocate(const RHIRootSignatureRef\u0026amp; rootSignature, uint32_t set); void Release(const PooledDescriptor\u0026amp; pooledDescriptor, const RHIRootSignatureRef\u0026amp; rootSignature, uint32_t set); inline uint32_t PooledSize() { return pooledSize; } inline uint32_t AllocatedSize() { return allocatedSize; } void Clear() { pooledDescriptors.clear(); pooledSize = 0; } static std::shared_ptr\u0026lt;RDGDescriptorSetPool\u0026gt; Get(uint32_t index) // 描述符池需要FRAMES_IN_FLIGHT每帧一个，不然下一帧修改可能影响上一帧还未完成的渲染！！！ { static std::shared_ptr\u0026lt;RDGDescriptorSetPool\u0026gt; pool[3]; if (pool[index] == nullptr) pool[index] = std::make_shared\u0026lt;RDGDescriptorSetPool\u0026gt;(); return pool[index]; } private: std::unordered_map\u0026lt;Key, std::list\u0026lt;PooledDescriptor\u0026gt;, Key::Hash\u0026gt; pooledDescriptors; uint32_t pooledSize = 0; uint32_t allocatedSize = 0; }; RDGBuilder 负责构建图\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 #pragma once #include \u0026#34;DependencyGraph.h\u0026#34; #include \u0026#34;RDGHandle.h\u0026#34; #include \u0026#34;RDGNode.h\u0026#34; #include \u0026#34;Hazel/Renderer/RHI/RHICommandList.h\u0026#34; #include \u0026lt;cstdint\u0026gt; #include \u0026lt;memory\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;vector\u0026gt; // #define RDG_DEBUG /* RDGNode分为PassNode(RenderPass, ComputePass, RayTracingPass, CopyPass, PresentPass)和ResourceNode(TextureNode, BufferNode) RDGEdge用于存储 资源-\u0026gt;Pass的View，用于在Pass时创建对应的view */ namespace GameEngine { // name-\u0026gt;node class RDGBlackBoard { public: RDGPassNodeRef Pass(std::string name); RDGBufferNodeRef Buffer(std::string name); RDGTextureNodeRef Texture(std::string name); #ifdef RDG_DEBUG std::string PassName(RDGPassNodeRef pass); std::string BufferName(RDGBufferNodeRef buffer); std::string TextureName(RDGTextureNodeRef texture); #endif void AddPass(RDGPassNodeRef pass); void AddBuffer(RDGBufferNodeRef buffer); void AddTexture(RDGTextureNodeRef texture); void Clear() { passes.clear(); buffers.clear(); textures.clear(); } private: std::unordered_map\u0026lt;std::string, RDGPassNodeRef\u0026gt; passes; std::unordered_map\u0026lt;std::string, RDGBufferNodeRef\u0026gt; buffers; std::unordered_map\u0026lt;std::string, RDGTextureNodeRef\u0026gt; textures; #ifdef RDG_DEBUG std::unordered_map\u0026lt;RDGPassNodeRef,std::string\u0026gt; passeNames; std::unordered_map\u0026lt;RDGTextureNodeRef,std::string\u0026gt; textureNames; std::unordered_map\u0026lt;RDGBufferNodeRef,std::string\u0026gt; bufferNames; #endif }; class RDGBuilder { public: RDGBuilder() = default; RDGBuilder(RHICommandListRef command): command(command){} // 给一个commandList ~RDGBuilder() {}; RDGTextureBuilder CreateTexture(std::string name); RDGBufferBuilder CreateBuffer(std::string name); RDGRenderPassBuilder CreateRenderPass(std::string name); // 假定所有pass的添加顺序就是执行顺序，方便处理排序和依赖关系等 RDGComputePassBuilder CreateComputePass(std::string name); RDGRayTracingPassBuilder CreateRayTracingPass(std::string name); RDGPresentPassBuilder CreatePresentPass(std::string name); RDGCopyPassBuilder CreateCopyPass(std::string name); RDGTextureHandle GetTexture(std::string name); RDGBufferHandle GetBuffer(std::string name); RDGRenderPassHandle GetRenderPass(std::string name) { return GetPass\u0026lt;RDGRenderPassNodeRef, RDGRenderPassHandle\u0026gt;(name); } RDGComputePassHandle GetComputePass(std::string name) { return GetPass\u0026lt;RDGComputePassNodeRef, RDGComputePassHandle\u0026gt;(name); } RDGRayTracingPassHandle GetRayTracingPass(std::string name) { return GetPass\u0026lt;RDGRayTracingPassNodeRef, RDGRayTracingPassHandle\u0026gt;(name); } RDGPresentPassHandle GetPresentPass(std::string name) { return GetPass\u0026lt;RDGPresentPassNodeRef, RDGPresentPassHandle\u0026gt;(name); } RDGCopyPassHandle GetCopyPass(std::string name) { return GetPass\u0026lt;RDGCopyPassNodeRef, RDGCopyPassHandle\u0026gt;(name); } RHITextureRef GetRHITexture(std::string name) { return blackBoard.Texture(name)-\u0026gt;GetRHITexture(); } DependencyGraphRef GetGraph() { return graph; } void Execute(); private: void CreateInputBarriers(RDGPassNodeRef pass); void CreateOutputBarriers(RDGPassNodeRef pass); void PrepareDescriptorSet(RDGPassNodeRef pass); void PrepareRenderTarget(RDGRenderPassNodeRef pass, RHIRenderPassInfo\u0026amp; renderPassInfo); void ReleaseResource(RDGPassNodeRef pass); void ExecutePass(RDGRenderPassNodeRef pass); void ExecutePass(RDGComputePassNodeRef pass); void ExecutePass(RDGRayTracingPassNodeRef pass); void ExecutePass(RDGPresentPassNodeRef pass); void ExecutePass(RDGCopyPassNodeRef pass); template\u0026lt;typename Type, typename Handle\u0026gt; Handle GetPass(std::string name) { auto node = blackBoard.Pass(name); if (node == nullptr) { LOG_TRACE(\u0026#34;Unable to find RDG resource, please check name!\u0026#34;); return Handle(UINT32_MAX); } return dynamic_cast\u0026lt;Type\u0026gt;(node)-\u0026gt;GetHandle(); } // Resolve 创建真正的资源 RHITextureRef Resolve(RDGTextureNodeRef textureNode); RHIBufferRef Resolve(RDGBufferNodeRef bufferNode); void Release(RDGTextureNodeRef textureNode, RHIResourceState state); void Release(RDGBufferNodeRef bufferNode, RHIResourceState state); RHIResourceState PreviousState(RDGTextureNodeRef textureNode, RDGPassNodeRef passNode, TextureSubresourceRange subresource = {}, bool output = false); // 获取当前pass（在执行顺序上）的资源的前序状态 RHIResourceState PreviousState(RDGBufferNodeRef bufferNode, RDGPassNodeRef passNode, uint32_t offset = 0, uint32_t size = 0, bool output = false); // output用于标记是相对于输入还是输出资源 bool IsLastUsedPass(RDGTextureNodeRef textureNode, RDGPassNodeRef passNode, bool output = false); bool IsLastUsedPass(RDGBufferNodeRef bufferNode, RDGPassNodeRef passNode, bool output = false); std::vector\u0026lt;RDGPassNodeRef\u0026gt; passes; // 创建的全部pass，按照创建顺序执行 DependencyGraphRef graph = std::make_shared\u0026lt;DependencyGraph\u0026gt;(); RDGBlackBoard blackBoard; RHICommandListRef command; }; class RDGTextureBuilder { public: RDGTextureBuilder(RDGBuilder* builder, RDGTextureNodeRef texture) : builder(builder) , texture(texture) { }; RDGTextureBuilder\u0026amp; Import(RHITextureRef texture, RHIResourceState initState); RDGTextureBuilder\u0026amp; Exetent(Extent3D extent); RDGTextureBuilder\u0026amp; Format(RHIFormat format); RDGTextureBuilder\u0026amp; MemoryUsage(MemoryUsage memoryUsage); RDGTextureBuilder\u0026amp; AllowReadWrite(); RDGTextureBuilder\u0026amp; AllowRenderTarget(); RDGTextureBuilder\u0026amp; AllowDepthStencil(); RDGTextureBuilder\u0026amp; MipLevels(uint32_t mipLevels); RDGTextureBuilder\u0026amp; ArrayLayers(uint32_t arrayLayers); RDGTextureBuilder\u0026amp; CubeMap(); RDGTextureHandle Finish() { return texture-\u0026gt;GetHandle(); } private: RDGBuilder* builder; RDGTextureNodeRef texture; }; class RDGBufferBuilder { public: RDGBufferBuilder(RDGBuilder* builder, RDGBufferNodeRef buffer) : builder(builder) , buffer(buffer) { }; RDGBufferBuilder\u0026amp; Import(RHIBufferRef buffer, RHIResourceState initState); RDGBufferBuilder\u0026amp; Size(uint32_t size); RDGBufferBuilder\u0026amp; MemoryUsage(MemoryUsage memoryUsage); RDGBufferBuilder\u0026amp; AllowVertexBuffer(); RDGBufferBuilder\u0026amp; AllowIndexBuffer(); RDGBufferBuilder\u0026amp; AllowReadWrite(); RDGBufferBuilder\u0026amp; AllowRead(); RDGBufferHandle Finish() { return buffer-\u0026gt;GetHandle(); } private: RDGBuilder* builder; RDGBufferNodeRef buffer; }; class RDGRenderPassBuilder { public: RDGRenderPassBuilder(RDGBuilder* builder, RDGRenderPassNodeRef pass) : builder(builder) , pass(pass) , graph(builder-\u0026gt;GetGraph()) { }; RDGRenderPassBuilder\u0026amp; PassIndex(uint32_t x = 0, uint32_t y = 0, uint32_t z = 0); // 给一个index设置函数方便给Execute传参 RDGRenderPassBuilder\u0026amp; RootSignature(RHIRootSignatureRef rootSignature); // 若提供根签名未提供描述符，使用池化创建 RDGRenderPassBuilder\u0026amp; DescriptorSet(uint32_t set, RHIDescriptorSetRef descriptorSet); // 若提供了描述符，直接用相应的描述符 // 创建边 默认View都是2D，如果需要cubeView，需要手动指定 RDGRenderPassBuilder\u0026amp; Read(uint32_t set, uint32_t binding, uint32_t index, RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); RDGRenderPassBuilder\u0026amp; Read(uint32_t set, uint32_t binding, uint32_t index, RDGTextureHandle texture, TextureViewType viewType = VIEW_TYPE_2D, TextureSubresourceRange subresource = {}); RDGRenderPassBuilder\u0026amp; ReadWrite(uint32_t set, uint32_t binding, uint32_t index, RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); // 好像和read也没什么区别？ RDGRenderPassBuilder\u0026amp; ReadWrite(uint32_t set, uint32_t binding, uint32_t index, RDGTextureHandle texture, TextureViewType viewType = VIEW_TYPE_2D, TextureSubresourceRange subresource = {}); RDGRenderPassBuilder\u0026amp; LabelColor(Color3 color); RDGRenderPassBuilder\u0026amp; Color(uint32_t binding, RDGTextureHandle texture, AttachmentLoadOp load = ATTACHMENT_LOAD_OP_DONT_CARE, AttachmentStoreOp store = ATTACHMENT_STORE_OP_DONT_CARE, Color4 clearColor = { 0.0f, 0.0f, 0.0f, 0.0f }, TextureSubresourceRange subresource = {}); RDGRenderPassBuilder\u0026amp; DepthStencil(RDGTextureHandle texture, AttachmentLoadOp load = ATTACHMENT_LOAD_OP_DONT_CARE, AttachmentStoreOp store = ATTACHMENT_STORE_OP_DONT_CARE, float clearDepth = 1.0f, uint32_t clearStencil = 0, TextureSubresourceRange subresource = {}); // 标识后续还会使用的资源，会用PassNode-\u0026gt;ResourceNode RDGRenderPassBuilder\u0026amp; OutputRead(RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); // 在执行完Pass后作为输出，自动屏障，可能还会在其他地方使用 RDGRenderPassBuilder\u0026amp; OutputRead(RDGTextureHandle texture, TextureSubresourceRange subresource = {}); RDGRenderPassBuilder\u0026amp; OutputReadWrite(RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); RDGRenderPassBuilder\u0026amp; OutputReadWrite(RDGTextureHandle texture, TextureSubresourceRange subresource = {}); RDGRenderPassBuilder\u0026amp; Execute(const RDGPassExecuteFunc\u0026amp; execute); RDGRenderPassHandle Finish() { return pass-\u0026gt;GetHandle(); } private: RDGBuilder* builder; RDGRenderPassNodeRef pass; DependencyGraphRef graph; }; class RDGComputePassBuilder { public: RDGComputePassBuilder(RDGBuilder* builder, RDGComputePassNodeRef pass) : builder(builder) , pass(pass) , graph(builder-\u0026gt;GetGraph()) { }; RDGComputePassBuilder\u0026amp; PassIndex(uint32_t x = 0, uint32_t y = 0, uint32_t z = 0); // 给一个index设置函数方便给Execute传参 RDGComputePassBuilder\u0026amp; RootSignature(RHIRootSignatureRef rootSignature); // 若提供根签名未提供描述符，使用池化创建 RDGComputePassBuilder\u0026amp; DescriptorSet(uint32_t set, RHIDescriptorSetRef descriptorSet); // 若提供了描述符，直接用相应的描述符 RDGComputePassBuilder\u0026amp; Read(uint32_t set, uint32_t binding, uint32_t index, RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); RDGComputePassBuilder\u0026amp; Read(uint32_t set, uint32_t binding, uint32_t index, RDGTextureHandle texture, TextureViewType viewType = VIEW_TYPE_2D, TextureSubresourceRange subresource = {}); RDGComputePassBuilder\u0026amp; ReadWrite(uint32_t set, uint32_t binding, uint32_t index, RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); // 好像和read也没什么区别？ RDGComputePassBuilder\u0026amp; ReadWrite(uint32_t set, uint32_t binding, uint32_t index, RDGTextureHandle texture, TextureViewType viewType = VIEW_TYPE_2D, TextureSubresourceRange subresource = {}); RDGComputePassBuilder\u0026amp; OutputRead(RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); // 在执行完Pass后作为输出，自动屏障，可能还会在其他地方使用 RDGComputePassBuilder\u0026amp; OutputRead(RDGTextureHandle texture, TextureSubresourceRange subresource = {}); RDGComputePassBuilder\u0026amp; OutputReadWrite(RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); RDGComputePassBuilder\u0026amp; OutputReadWrite(RDGTextureHandle texture, TextureSubresourceRange subresource = {}); RDGComputePassBuilder\u0026amp; OutputIndirectDraw(RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); RDGComputePassBuilder\u0026amp; Execute(const RDGPassExecuteFunc\u0026amp; execute); RDGComputePassHandle Finish() { return pass-\u0026gt;GetHandle(); } private: RDGBuilder* builder; RDGComputePassNodeRef pass; DependencyGraphRef graph; }; class RDGRayTracingPassBuilder { public: RDGRayTracingPassBuilder(RDGBuilder* builder, RDGRayTracingPassNodeRef pass) : builder(builder) , pass(pass) , graph(builder-\u0026gt;GetGraph()) { }; RDGRayTracingPassBuilder\u0026amp; PassIndex(uint32_t x = 0, uint32_t y = 0, uint32_t z = 0); // 给一个index设置函数方便给Execute传参 RDGRayTracingPassBuilder\u0026amp; RootSignature(RHIRootSignatureRef rootSignature); // 若提供根签名未提供描述符，使用池化创建 RDGRayTracingPassBuilder\u0026amp; DescriptorSet(uint32_t set, RHIDescriptorSetRef descriptorSet); // 若提供了描述符，直接用相应的描述符 RDGRayTracingPassBuilder\u0026amp; Read(uint32_t set, uint32_t binding, uint32_t index, RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); RDGRayTracingPassBuilder\u0026amp; Read(uint32_t set, uint32_t binding, uint32_t index, RDGTextureHandle texture, TextureViewType viewType = VIEW_TYPE_2D, TextureSubresourceRange subresource = {}); RDGRayTracingPassBuilder\u0026amp; ReadWrite(uint32_t set, uint32_t binding, uint32_t index, RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); // 好像和read也没什么区别？ RDGRayTracingPassBuilder\u0026amp; ReadWrite(uint32_t set, uint32_t binding, uint32_t index, RDGTextureHandle texture, TextureViewType viewType = VIEW_TYPE_2D, TextureSubresourceRange subresource = {}); RDGRayTracingPassBuilder\u0026amp; OutputRead(RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); // 在执行完Pass后作为输出，自动屏障，可能还会在其他地方使用 RDGRayTracingPassBuilder\u0026amp; OutputRead(RDGTextureHandle texture, TextureSubresourceRange subresource = {}); RDGRayTracingPassBuilder\u0026amp; OutputReadWrite(RDGBufferHandle buffer, uint32_t offset = 0, uint32_t size = 0); RDGRayTracingPassBuilder\u0026amp; OutputReadWrite(RDGTextureHandle texture, TextureSubresourceRange subresource = {}); RDGRayTracingPassBuilder\u0026amp; Execute(const RDGPassExecuteFunc\u0026amp; execute); RDGRayTracingPassHandle Finish() { return pass-\u0026gt;GetHandle(); } private: RDGBuilder* builder; RDGRayTracingPassNodeRef pass; DependencyGraphRef graph; }; class RDGPresentPassBuilder { public: RDGPresentPassBuilder(RDGBuilder* builder, RDGPresentPassNodeRef pass) : builder(builder) , pass(pass) , graph(builder-\u0026gt;GetGraph()) { }; RDGPresentPassHandle Finish() { return pass-\u0026gt;GetHandle(); } RDGPresentPassBuilder\u0026amp; Texture(RDGTextureHandle texture, TextureSubresourceLayers subresource = {}); RDGPresentPassBuilder\u0026amp; PresentTexture(RDGTextureHandle texture); private: RDGBuilder* builder; RDGPresentPassNodeRef pass; DependencyGraphRef graph; }; class RDGCopyPassBuilder { public: RDGCopyPassBuilder(RDGBuilder* builder, RDGCopyPassNodeRef pass) : builder(builder) , pass(pass) , graph(builder-\u0026gt;GetGraph()) { }; RDGCopyPassHandle Finish() { return pass-\u0026gt;GetHandle(); } RDGCopyPassBuilder\u0026amp; From(RDGTextureHandle texture, TextureSubresourceLayers subresource = {}); RDGCopyPassBuilder\u0026amp; To(RDGTextureHandle texture, TextureSubresourceLayers subresource = {}); RDGCopyPassBuilder\u0026amp; GenerateMips(); RDGCopyPassBuilder\u0026amp; OutputRead(RDGTextureHandle texture, TextureSubresourceLayers subresource = {}); RDGCopyPassBuilder\u0026amp; OutputReadWrite(RDGTextureHandle texture, TextureSubresourceLayers subresource = {}); private: RDGBuilder* builder; RDGCopyPassNodeRef pass; DependencyGraphRef graph; }; } RDG下定义一个Pass的流程\n创建/获取 资源节点Handler 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 // 创建资源 RDGTextureBuilder RDGBuilder::CreateTexture(std::string name) { RDGTextureNodeRef textureNode = graph-\u0026gt;CreateNode\u0026lt;RDGTextureNode\u0026gt;(name); blackBoard.AddTexture(textureNode); return RDGTextureBuilder(this, textureNode); } RDGBufferBuilder RDGBuilder::CreateBuffer(std::string name) { RDGBufferNodeRef bufferNode = graph-\u0026gt;CreateNode\u0026lt;RDGBufferNode\u0026gt;(name); blackBoard.AddBuffer(bufferNode); return RDGBufferBuilder(this, bufferNode); } // 获取资源 RDGTextureHandle RDGBuilder::GetTexture(std::string name) { auto node = blackBoard.Texture(name); if (node == nullptr) { return RDGTextureHandle(UINT32_MAX); } return node-\u0026gt;GetHandle(); } RDGBufferHandle RDGBuilder::GetBuffer(std::string name) { auto node = blackBoard.Buffer(name); if (node == nullptr) { return RDGBufferHandle(UINT32_MAX); } return node-\u0026gt;GetHandle(); } 创建Pass结点，并挂载需要的资源 Read资源：资源 -\u0026gt; Pass\nReadWrite资源： Pass-\u0026gt; 资源\n1 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 // 创建图形PassNode RDGRenderPassBuilder RDGBuilder::CreateRenderPass(std::string name) { RDGRenderPassNodeRef passNode = graph-\u0026gt;CreateNode\u0026lt;RDGRenderPassNode\u0026gt;(name); blackBoard.AddPass(passNode); passes.push_back(passNode); return RDGRenderPassBuilder(this, passNode); } // 绑定只读资源（此时需要指定 资源描述符位置、 viewType 和 subresource） RDGComputePassBuilder\u0026amp; RDGComputePassBuilder::Read(uint32_t set, uint32_t binding, uint32_t index, RDGTextureHandle texture, TextureViewType viewType, TextureSubresourceRange subresource) { RDGTextureEdgeRef edge = graph-\u0026gt;CreateEdge\u0026lt;RDGTextureEdge\u0026gt;(); edge-\u0026gt;state = RESOURCE_STATE_SHADER_RESOURCE; // 因为是Read，所以指定为SRV edge-\u0026gt;subresource = subresource; edge-\u0026gt;asShaderRead = true; edge-\u0026gt;set = set; edge-\u0026gt;binding = binding; edge-\u0026gt;index = index; edge-\u0026gt;type = RESOURCE_TYPE_TEXTURE; edge-\u0026gt;viewType = viewType; graph-\u0026gt;Link(graph-\u0026gt;GetNode(texture.ID()), pass, edge); return *this; } // 绑定读写资源 RDGComputePassBuilder\u0026amp; RDGComputePassBuilder::ReadWrite(uint32_t set, uint32_t binding, uint32_t index, RDGBufferHandle buffer, uint32_t offset, uint32_t size) { RDGBufferEdgeRef edge = graph-\u0026gt;CreateEdge\u0026lt;RDGBufferEdge\u0026gt;(); edge-\u0026gt;state = RESOURCE_STATE_UNORDERED_ACCESS; edge-\u0026gt;offset = offset; edge-\u0026gt;size = size; edge-\u0026gt;asShaderReadWrite = true; edge-\u0026gt;set = set; edge-\u0026gt;binding = binding; edge-\u0026gt;index = index; edge-\u0026gt;type = RESOURCE_TYPE_RW_BUFFER; graph-\u0026gt;Link(pass, graph-\u0026gt;GetNode(buffer.ID()), edge); return *this; } 定义Pass执行流程 RDGPassContext 1 2 3 4 5 6 7 typedef std::function\u0026lt;void(RDGPassContext)\u0026gt; RDGPassExecuteFunc; RDGComputePassBuilder\u0026amp; RDGComputePassBuilder::Execute(const RDGPassExecuteFunc\u0026amp; execute) { pass-\u0026gt;execute = execute; return *this; } 也可以指定Output资源，用于在Pass执行完成后，修改资源的状态，这有助于一个Pass有很多个PassNode时（比如Hiz）在Pass内部资源屏障 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 RDGComputePassBuilder\u0026amp; RDGComputePassBuilder::OutputRead(RDGTextureHandle texture, TextureSubresourceRange subresource) { RDGTextureEdgeRef edge = graph-\u0026gt;CreateEdge\u0026lt;RDGTextureEdge\u0026gt;(); edge-\u0026gt;state = RESOURCE_STATE_SHADER_RESOURCE; edge-\u0026gt;subresource = subresource; edge-\u0026gt;asOutputReadWrite = true; edge-\u0026gt;type = RESOURCE_TYPE_TEXTURE; graph-\u0026gt;Link(pass, graph-\u0026gt;GetNode(texture.ID()), edge); return *this; } RDGComputePassBuilder\u0026amp; RDGComputePassBuilder::OutputReadWrite(RDGBufferHandle buffer, uint32_t offset, uint32_t size) { RDGBufferEdgeRef edge = graph-\u0026gt;CreateEdge\u0026lt;RDGBufferEdge\u0026gt;(); edge-\u0026gt;state = RESOURCE_STATE_UNORDERED_ACCESS; edge-\u0026gt;offset = offset; edge-\u0026gt;size = size; edge-\u0026gt;asOutputReadWrite = true; edge-\u0026gt;type = RESOURCE_TYPE_RW_BUFFER; graph-\u0026gt;Link(pass, graph-\u0026gt;GetNode(buffer.ID()), edge); return *this; } RDG执行 1 2 3 4 5 RDGBuilder rdgBuilder = RDGBuilder(CurCommandList); // 传入CommandList // 构建图结构 for (auto\u0026amp; pass : passes) { if (pass) pass-\u0026gt;Build(rdgBuilder); } // 执行RDG rdgBuilder.Execute(); RDG执行内部流程\n遍历PassNode，根据类型，执行Pass对应的执行流程\n1 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 57 58 59 60 61 62 void RDGBuilder::Execute() { std::string currentPrefix; for (size_t i = 0; i \u0026lt; passes.size(); ++i) { auto\u0026amp; pass = passes[i]; if (!pass || pass-\u0026gt;isCulled) continue; std::string name = pass-\u0026gt;Name(); size_t pos = name.find(\u0026#39;_\u0026#39;); std::string prefix = (pos != std::string::npos) ? name.substr(0, pos) : name; if (!currentPrefix.empty() \u0026amp;\u0026amp; currentPrefix != prefix) { command-\u0026gt;PopLabel(); currentPrefix.clear(); } if (currentPrefix.empty()) { command-\u0026gt;PushLabel(prefix, pass-\u0026gt;GetLabelColor()); currentPrefix = prefix; } #ifdef RDG_DEBUG LOG_INFO(\u0026#34;RDG Pass Begin: [{}]\u0026#34;, name); #endif // 执行 pass switch (pass-\u0026gt;NodeType()) { case RDG_PASS_NODE_TYPE_RENDER: ExecutePass(dynamic_cast\u0026lt;RDGRenderPassNodeRef\u0026gt;(pass)); break; case RDG_PASS_NODE_TYPE_COMPUTE: ExecutePass(dynamic_cast\u0026lt;RDGComputePassNodeRef\u0026gt;(pass)); break; case RDG_PASS_NODE_TYPE_RAY_TRACING: ExecutePass(dynamic_cast\u0026lt;RDGRayTracingPassNodeRef\u0026gt;(pass)); break; case RDG_PASS_NODE_TYPE_PRESENT: ExecutePass(dynamic_cast\u0026lt;RDGPresentPassNodeRef\u0026gt;(pass)); break; case RDG_PASS_NODE_TYPE_COPY: ExecutePass(dynamic_cast\u0026lt;RDGCopyPassNodeRef\u0026gt;(pass)); break; default: LOG_ERROR(\u0026#34;Unsupported RDG pass type!\u0026#34;); } #ifdef RDG_DEBUG LOG_INFO(\u0026#34;RDG Pass End: [{}]\u0026#34;, name); #endif // 检查下一个 pass 前缀，如果变化或者是最后一个 pass，pop size_t nextIndex = i + 1; std::string nextPrefix; while (nextIndex \u0026lt; passes.size() \u0026amp;\u0026amp; (!passes[nextIndex] || passes[nextIndex]-\u0026gt;isCulled)) { ++nextIndex; } if (nextIndex \u0026lt; passes.size()) { std::string nextName = passes[nextIndex]-\u0026gt;Name(); size_t nextPos = nextName.find(\u0026#39;_\u0026#39;); nextPrefix = (nextPos != std::string::npos) ? nextName.substr(0, nextPos) : nextName; } if (nextIndex == passes.size() || nextPrefix != prefix) { command-\u0026gt;PopLabel(); currentPrefix.clear(); } } for (auto\u0026amp; pass : passes) // 释放池化资源 { //ReleaseResource(pass); for (auto\u0026amp; descriptor : pass-\u0026gt;pooledDescriptorSets) { RDGDescriptorSetPool::Get(APP_FRAMEINDEX)-\u0026gt;Release({ descriptor.first }, pass-\u0026gt;rootSignature, descriptor.second); } } } 图形渲染Pass内部流程\n创建和更新资源描述符集 创建RenderPass、FrameBuffer（pooled） 根据DAG自动进行输入屏障 BeginRenderPass 执行Pass 根据DAG自动进行输入屏障 回收Pass使用的资源，用于资源复用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void RDGBuilder::ExecutePass(RDGRenderPassNodeRef pass) { PrepareDescriptorSet(pass); RHIRenderPassInfo renderPassInfo = {}; PrepareRenderTarget(pass, renderPassInfo); RHIRenderPassRef renderPass = APP_DYNAMICRHI-\u0026gt;CreateRenderPass(renderPassInfo); // 根据图创建RenderPass和FranmeBuffer，因为有pool其实无所谓把RenderPass和FrameBuffer放在一起创建 CreateInputBarriers(pass); command-\u0026gt;BeginRenderPass(renderPass); // Begin的是RenderPass，把实际的FrameBuffer当作参数传递进去了 RDGPassContext context; context.command = command; context.builder = this; context.descriptors = pass-\u0026gt;descriptorSets; context.passIndex[0] = pass-\u0026gt;passIndex[0]; context.passIndex[1] = pass-\u0026gt;passIndex[1]; context.passIndex[2] = pass-\u0026gt;passIndex[2]; pass-\u0026gt;execute(context); command-\u0026gt;EndRenderPass(); CreateOutputBarriers(pass); ReleaseResource(pass); } 自动创建屏障的过程：\n遍历一个Texture的所有Pass，如果这个Pass在当前Pass之前，就根据逻辑找这个Pass与这个纹理如何交互的，就得到了Pass\n从这里看目前的流程还是比较简单了，因为Pass都是一条线的顺序\n多线程渲染 设计是：Tick一开始会先让渲染线程渲染上一帧的数据，之后主线程同步执行下一帧的收集\nRenderCommandQueue用于在主线程收集命令\n1 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 #pragma once #include \u0026lt;cstdint\u0026gt; #include \u0026lt;vector\u0026gt; namespace GameEngine { struct RenderCommandDebugInfo { const char* file; int line; const char* function; }; class RenderCommandQueue { public: typedef void(*RenderCommandFn)(void*); RenderCommandQueue(); ~RenderCommandQueue(); void* Allocate(RenderCommandFn func, uint32_t size); // 收集命令 std::vector\u0026lt;RenderCommandDebugInfo\u0026gt; m_DebugInfos; // 调试信息 void Execute(); private: uint8_t* m_CommandBuffer; uint8_t* m_CommandBufferPtr; uint32_t m_CommandCount = 0; }; } // 分配一个10mb的Buffer RenderCommandQueue::RenderCommandQueue() { m_CommandBuffer = new uint8_t[10 * 1024 * 1024]; // 10mb buffer m_CommandBufferPtr = m_CommandBuffer; memset(m_CommandBuffer, 0, 10 * 1024 * 1024); // 填充0 } Submit的流程\n传入一段Lambda表达式或一个函数指针，Submit内部会统一封装为一个参数为void*的lambda,内部会调用原函数，并进行销毁 在Queue中分配内存，存储统一封装好的lambda的指针 + 指针大小记录 + 真正的调用函数内容（即需要的void*） 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 // 暴漏的接口 template\u0026lt;typename FuncT\u0026gt; static void Submit(FuncT\u0026amp;\u0026amp; func, const char* file = nullptr, int line = 0, const char* function = nullptr) { auto\u0026amp; queue = GetRenderCommandQueue(); // 有两个CommandQueue交替工作，一个在主线程收集指令，一个在渲染线程执行指令，每帧都会交替一次 #ifdef RTDEBUG queue.m_DebugInfos.push_back({ file, line, function }); #endif // 把传入的函数指针或lambda统一封装为一个参数为void*的lambda，这里lambda用于转换void*指针到真正的对象指针，然后执行它 auto renderCmd = [](void* ptr) { auto pFunc = (FuncT*)ptr; (*pFunc)(); pFunc-\u0026gt;~FuncT(); }; auto storageBuffer = queue.Allocate(renderCmd, sizeof(FuncT)); new (storageBuffer) FuncT(std::forward\u0026lt;FuncT\u0026gt;(func)); // placement New 最近刚学的 原地创建对象，不分配新内存 } using RenderCommandFn = void(*)(void*); // Submit可以传递一个lambda表达式，也可以是一个函数的地址，但必须都是无参的，为了统一 // Submit([]() { //\tcout \u0026lt;\u0026lt; \u0026#34;hello world\u0026#34; \u0026lt;\u0026lt; endl; // }); //\tSubmit(\u0026amp;test); void* RenderCommandQueue::Allocate(RenderCommandFn fn, uint32_t size) { *(RenderCommandFn*)m_CommandBufferPtr = fn; m_CommandBufferPtr += sizeof(RenderCommandFn); *(uint32_t*)m_CommandBufferPtr = size; m_CommandBufferPtr += sizeof(uint32_t); void* memory = m_CommandBufferPtr; m_CommandBufferPtr += size; m_CommandCount++; return memory; } Queue执行流程：就是按照上边封装数据的流程读取数据，然后依次执行\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void RenderCommandQueue::Execute() { uint8_t* buffer = m_CommandBuffer; for (uint32_t i = 0; i \u0026lt; m_CommandCount; i++) { RenderCommandFn function = *(RenderCommandFn*)buffer; buffer += sizeof(RenderCommandFn); uint32_t size = *(uint32_t*)buffer; buffer += sizeof(uint32_t); // 执行命令 function(buffer); buffer += size; } m_CommandBufferPtr = m_CommandBuffer; m_CommandCount = 0; } 以上架构就完成了Queue如何收集指令和执行指令\n下面还要看渲染线程和主线程如何同步\n渲染线程封装，使用状态机模型 Idle Busy Kick三个状态流转，通过win的线程同步API来进行控制\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 // 简单封装一个线程对象，Dispathc用于开启并执行一个线程，Join表示阻塞等待该线程结束 class Thread { public: Thread(const std::string\u0026amp; name); // 线程执行 template\u0026lt;typename Fn, typename... Args\u0026gt; void Dispatch(Fn\u0026amp;\u0026amp; func, Args\u0026amp;\u0026amp;... args) { m_Thread = std::thread(func, std::forward\u0026lt;Args\u0026gt;(args)...); // 这行会立即新线程执行传入的函数 SetName(m_Name); LOG_INFO(\u0026#34;Thread [{0}] Dispatch and Run!\u0026#34;, m_Name); } void SetName(const std::string\u0026amp; name); // 阻塞等待线程结束 void Join(); std::thread::id GetID() const; private: std::string m_Name; std::thread m_Thread; }; enum class ThreadingPolicy { // MultiThreaded will create a Render Thread None = 0, SingleThreaded, MultiThreaded }; // 渲染线程的封装 class RenderThread { public: enum class State { Idle = 0, Busy, Kick }; public: RenderThread(ThreadingPolicy coreThreadingPolicy); ~RenderThread(); void Run(); bool IsRunning() const { return m_IsRunning; } void Terminate(); void Wait(State waitForState); void WaitAndSet(State waitForState, State setToState); void Set(State setToState); void NextFrame(); void BlockUntilRenderComplete(); void Kick(); void Pump(); static uint32_t RT_GetFrameIndex(); static bool IsCurrentThreadRT(); private: RenderThreadData* m_Data; ThreadingPolicy m_ThreadingPolicy; Thread m_RenderThread; bool m_IsRunning = false; std::atomic\u0026lt;uint32_t\u0026gt; m_AppThreadFrame = 0; }; 渲染线程内部\n创建流程\n1 2 3 4 5 6 7 8 9 10 11 RenderThread::RenderThread(ThreadingPolicy coreThreadingPolicy) : m_RenderThread(\u0026#34;Render Thread\u0026#34;), m_ThreadingPolicy(coreThreadingPolicy) { m_Data = new RenderThreadData(); if (m_ThreadingPolicy == ThreadingPolicy::MultiThreaded) { InitializeCriticalSection(\u0026amp;m_Data-\u0026gt;m_CriticalSection); // 创建临界区 InitializeConditionVariable(\u0026amp;m_Data-\u0026gt;m_ConditionVariable); // 创建条件变量配套 } } 渲染线程运行：渲染线程While等待Kick信号，收到信号后，转为Busy状态，进一步就是拿Queue的执行，执行完成后设置为Idle状态\n1 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 void RenderThread::Run() { m_IsRunning = true; if (m_ThreadingPolicy == ThreadingPolicy::MultiThreaded) { m_RenderThread.Dispatch(RenderManager::RenderThreadFunc, this); } s_RenderThreadID = m_RenderThread.GetID(); } // 挂在渲染线程的函数 void RenderManager::RenderThreadFunc(RenderThread* renderThread) { while (renderThread-\u0026gt;IsRunning()) { WaitAndRender(renderThread); } } // 具体渲染线程的函数 void RenderManager::WaitAndRender(RenderThread* renderThread) { // Wait for kick, then set render thread to busy { // 渲染线程循环等待Kick信号，收到信号后，设置为Busy信号并开始工作 renderThread-\u0026gt;WaitAndSet(RenderThread::State::Kick, RenderThread::State::Busy); } // 工作就是把缓存命令全部执行 s_CommandQueue[GetRenderQueueIndex()]-\u0026gt;Execute(); // Rendering has completed, set state to idle renderThread-\u0026gt;Set(RenderThread::State::Idle); } 信号流转\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 void RenderThread::WaitAndSet(State waitForState, State setToState) { if (m_ThreadingPolicy == ThreadingPolicy::SingleThreaded) return; EnterCriticalSection(\u0026amp;m_Data-\u0026gt;m_CriticalSection); while (m_Data-\u0026gt;m_State != waitForState) { SleepConditionVariableCS(\u0026amp;m_Data-\u0026gt;m_ConditionVariable, \u0026amp;m_Data-\u0026gt;m_CriticalSection, INFINITE); } m_Data-\u0026gt;m_State = setToState; WakeAllConditionVariable(\u0026amp;m_Data-\u0026gt;m_ConditionVariable); LeaveCriticalSection(\u0026amp;m_Data-\u0026gt;m_CriticalSection); } 来捋一下主线程的工作\n主线程等待渲染线程执行完毕 主线程交换Queue 主线程通知渲染线程开始工作（渲染上一帧） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 1. 主线程等待渲染线程执行完毕 void RenderThread::BlockUntilRenderComplete() { if (m_ThreadingPolicy == ThreadingPolicy::SingleThreaded) return; Wait(State::Idle); } // 2. 主线程交换Queue void RenderManager::SwapRenderCommandQueue() { s_RenderCommandQueueSubmissionIndex = (s_RenderCommandQueueSubmissionIndex + 1) % s_RenderCommandQueueCount; } // 3. 主线程通知渲染线程开始工作（渲染上一帧） void RenderThread::Kick() { if (m_ThreadingPolicy == ThreadingPolicy::MultiThreaded) { Set(State::Kick); } } 主线程和渲染线程都需要访问临界区资源，主线程为了把状态设置为Kick，等待Idle状态，渲染线程等待Kick信号，转为Busy状态，执行完成后转为Idle状态\n两个线程对于状态的修改通过进入和离开临界区代码来互斥，它会自动加锁和解锁\n1 2 EnterCriticalSection(\u0026amp;m_Data-\u0026gt;m_CriticalSection); LeaveCriticalSection(\u0026amp;m_Data-\u0026gt;m_CriticalSection); 条件变量用于防止CPU轮询，比如一个线程进入临界区后发现当前状态不是想要的状态，于是退出，还是在外部写一个while来轮询\n有了条件变量的话，发现状态不对，就会让线程进入休眠，并且释放锁，当满足条件时，重新上锁开始切换状态\n1 2 SleepConditionVariableCS(\u0026amp;m_CondVar, \u0026amp;m_CriticalSection, INFINITE); WakeAllConditionVariable(\u0026amp;m_CondVar); 所以条件变量的目的是让上锁的线程临时释放锁，等满足条件后，重新再获得锁\n渲染Tick执行\n1 2 3 4 5 6 7 8 9 10 11 12 // 在渲染最后执行 CurCommandList中已经收集了当前帧的所有渲染指令，这个提交到渲染线程后，会在下一帧的开始处执行这些函数 RENDER_SUBMIT([this]() { //LOG_INFO(\u0026#34;渲染第{}帧\u0026#34;, APP_FRAMEINDEX_RT); auto\u0026amp; CurResource = m_PerFrameBaseResources[APP_FRAMEINDEX_RT]; CurResource.fence-\u0026gt;Wait(); // 先等待这个飞行帧上一帧渲染结束 m_SwapChain-\u0026gt;GetNewFrame(nullptr, CurResource.startSemaphore); RHICommandListRef CurCommandList = CurResource.commandList; CurCommandList-\u0026gt;Execute(CurResource.fence, CurResource.startSemaphore, CurResource.finishSemaphore); m_GPUTimeInfos[APP_FRAMEINDEX_RT] = CurCommandList-\u0026gt;GetGPUTime(); m_SwapChain-\u0026gt;Present(CurResource.finishSemaphore); m_RenderThread.NextFrame(); }); 渲染资源管理 全局资源描述符布局\n创建一个Set=0的根签名，填充常用的各种资源类型，其中一部分绑定点是bindless的 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 // 创建一个全局的资源描述符集来挂载各种全局资源 RHIRootSignatureInfo info = {}; // set binding count frequency type info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_POSITION, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_NORMAL, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_TANGENT, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_TEXCOORD, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_COLOR, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_BONE_INDEX, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_BONE_WEIGHT, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_ANIMATION, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_INDEX, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_SAMPLER, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_SAMPLER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_TEXTURE_1D, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_TEXTURE }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_TEXTURE_1D_ARRAY, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_TEXTURE }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_TEXTURE_2D, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_TEXTURE }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_TEXTURE_2D_ARRAY, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_TEXTURE }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_TEXTURE_CUBE, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_TEXTURE }); // 就是得设置Texture，设置Cube不对 info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_BINDLESS_TEXTURE_3D, MAX_BINDLESS_RESOURCE_SIZE, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_TEXTURE }); // 下面这些需要手动去绑定buffer和preFrame资源描述符 info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_MESHINSTANCEINFO, 1, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_MATERIALINFO, 1, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_MESHINFO, 1, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_SETTING, 1, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_CAMERA, 2, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_LIGHTINFO, 1, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_GIZMO, 1, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RW_BUFFER }); if (RENDER_ENABLE_RAY_TRACING) { info.AddEntry({ 0, GLORBAL_RESOURCE_BINDING_TLAS, 1, SHADER_FREQUENCY_ALL, RESOURCE_TYPE_RAY_TRACING }); } m_GlobalResourcePreFrameRootSignature = APP_DYNAMICRHI-\u0026gt;CreateRootSignature(info); for (auto\u0026amp; resource : m_PerFrameGlobalResources) resource.descriptorSet = m_GlobalResourcePreFrameRootSignature-\u0026gt;CreateDescriptorSet(0); 提前创建好一些资源，绑定到这个资源描述符集中 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 // 挂载默认全局资源 for (auto\u0026amp; resource : m_PerFrameGlobalResources) { // camera { // 0号位是当前摄像机， 1号位存储Scene自带的默认摄像机 RHIDescriptorUpdateInfo cameraUpdateInfo = {}; cameraUpdateInfo.resourceType = RESOURCE_TYPE_RW_BUFFER; cameraUpdateInfo.buffer = resource.activeCameraDataBuffer.GetRHIBuffer(); cameraUpdateInfo.index = 0; cameraUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_CAMERA; resource.descriptorSet-\u0026gt;UpdateDescriptor(cameraUpdateInfo); cameraUpdateInfo.buffer = resource.defalutCameraDataBuffer.GetRHIBuffer(); cameraUpdateInfo.index = 1; resource.descriptorSet-\u0026gt;UpdateDescriptor(cameraUpdateInfo); } // Setting { RHIDescriptorUpdateInfo settingUpdateInfo = {}; settingUpdateInfo.resourceType = RESOURCE_TYPE_RW_BUFFER; settingUpdateInfo.buffer = m_MultiFrameGlobalResources.globalSettingInfoBuffer.GetRHIBuffer(); settingUpdateInfo.index = 0; settingUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_SETTING; resource.descriptorSet-\u0026gt;UpdateDescriptor(settingUpdateInfo); } // meshInstanceInfo { RHIDescriptorUpdateInfo meshInfoUpdateInfo = {}; meshInfoUpdateInfo.resourceType = RESOURCE_TYPE_RW_BUFFER; meshInfoUpdateInfo.buffer = m_MultiFrameGlobalResources.meshInfoBuffer.GetRHIBuffer(); meshInfoUpdateInfo.index = 0; meshInfoUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_MESHINSTANCEINFO; resource.descriptorSet-\u0026gt;UpdateDescriptor(meshInfoUpdateInfo); } // materialInfo { RHIDescriptorUpdateInfo materialInfoUpdateInfo = {}; materialInfoUpdateInfo.resourceType = RESOURCE_TYPE_RW_BUFFER; materialInfoUpdateInfo.buffer = m_MultiFrameGlobalResources.materialBuffer.GetRHIBuffer(); materialInfoUpdateInfo.index = 0; materialInfoUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_MATERIALINFO; resource.descriptorSet-\u0026gt;UpdateDescriptor(materialInfoUpdateInfo); } // MeshInfo { RHIDescriptorUpdateInfo vertexInfoUpdateInfo = {}; vertexInfoUpdateInfo.resourceType = RESOURCE_TYPE_RW_BUFFER; vertexInfoUpdateInfo.buffer = m_MultiFrameGlobalResources.vertexBuffer.GetRHIBuffer(); vertexInfoUpdateInfo.index = 0; vertexInfoUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_MESHINFO; resource.descriptorSet-\u0026gt;UpdateDescriptor(vertexInfoUpdateInfo); } // sampler { RHIDescriptorUpdateInfo samplerUpdateInfo = {}; samplerUpdateInfo.resourceType = RESOURCE_TYPE_SAMPLER; samplerUpdateInfo.sampler = m_MultiFrameGlobalResources.samplers[0]-\u0026gt;sampler; samplerUpdateInfo.index = 0; samplerUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_BINDLESS_SAMPLER; resource.descriptorSet-\u0026gt;UpdateDescriptor(samplerUpdateInfo); samplerUpdateInfo.sampler = m_MultiFrameGlobalResources.samplers[1]-\u0026gt;sampler; samplerUpdateInfo.index = 1; samplerUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_BINDLESS_SAMPLER; resource.descriptorSet-\u0026gt;UpdateDescriptor(samplerUpdateInfo); samplerUpdateInfo.sampler = m_MultiFrameGlobalResources.samplers[2]-\u0026gt;sampler; samplerUpdateInfo.index = 2; samplerUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_BINDLESS_SAMPLER; resource.descriptorSet-\u0026gt;UpdateDescriptor(samplerUpdateInfo); samplerUpdateInfo.sampler = m_MultiFrameGlobalResources.samplers[3]-\u0026gt;sampler; samplerUpdateInfo.index = 3; samplerUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_BINDLESS_SAMPLER; resource.descriptorSet-\u0026gt;UpdateDescriptor(samplerUpdateInfo); } // lightInfo { RHIDescriptorUpdateInfo lightUpdateInfo = {}; lightUpdateInfo.resourceType = RESOURCE_TYPE_RW_BUFFER; lightUpdateInfo.buffer = resource.lightInfoBuffer.GetRHIBuffer(); lightUpdateInfo.index = 0; lightUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_LIGHTINFO; resource.descriptorSet-\u0026gt;UpdateDescriptor(lightUpdateInfo); } // gizmoDrawData { RHIDescriptorUpdateInfo gizmoUpdateInfo = {}; gizmoUpdateInfo.resourceType = RESOURCE_TYPE_RW_BUFFER; gizmoUpdateInfo.buffer = resource.gizmoBuffer.GetRHIBuffer(); gizmoUpdateInfo.index = 0; gizmoUpdateInfo.binding = GLORBAL_RESOURCE_BINDING_GIZMO; resource.descriptorSet-\u0026gt;UpdateDescriptor(gizmoUpdateInfo); } } 在RDG Build时绑定这个布局到Set=0 1 command-\u0026gt;BindDescriptorSet(RENDER_RESOURCEMANAGER-\u0026gt;GetGlobalResourcePerFrameDescriptorSet(), 0); Bindless资源管理\n申请一个对应bindless槽位内的ID，并绑定资源描述符集的对应binding的对应index\n1 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 uint32_t RenderResourceManager::AllocateBindlessID(const BindlessResourceInfo\u0026amp; resoruceInfo, BindlessSlot slot) { // 给这个资源分配一个ID uint32_t index = m_BindlessIDAlloctor[slot].Allocate(); // 构建更新信息 RHIDescriptorUpdateInfo updateInfo = {}; updateInfo.binding = BindlessSlotToPerFrameBinding(slot); updateInfo.index = index; // bindless数组的index updateInfo.resourceType = resoruceInfo.resourceType; updateInfo.buffer = resoruceInfo.buffer; updateInfo.textureView = resoruceInfo.textureView; updateInfo.sampler = resoruceInfo.sampler; updateInfo.bufferOffset = resoruceInfo.bufferOffset; updateInfo.bufferRange = resoruceInfo.bufferRange; // 实时更新会因为一些问题报错，暂时不更新本帧，只更新其他帧 for (size_t i = 0; i \u0026lt; m_PerFrameGlobalResources.size(); ++i) { if (i == APP_FRAMEINDEX) { auto\u0026amp; resource = m_PerFrameGlobalResources[i]; resource.isNeedUpdate = true; resource.updateInfos.push_back(updateInfo); } else { m_PerFrameGlobalResources[i].descriptorSet-\u0026gt;UpdateDescriptor(updateInfo); } } return index; } Mesh渲染架构（Bindless + 间接渲染 ） 所有的Mesh相关数据，全部由Bindless管理\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 enum BindlessSlot { BINDLESS_SLOT_POSITION = 0, BINDLESS_SLOT_NORMAL, BINDLESS_SLOT_TANGENT, BINDLESS_SLOT_TEXCOORD, BINDLESS_SLOT_COLOR, BINDLESS_SLOT_BONE_INDEX, BINDLESS_SLOT_BONE_WEIGHT, BINDLESS_SLOT_ANIMATION, BINDLESS_SLOT_INDEX, BINDLESS_SLOT_SAMPLER, BINDLESS_SLOT_TEXTURE_1D, BINDLESS_SLOT_TEXTURE_1D_ARRAY, BINDLESS_SLOT_TEXTURE_2D, BINDLESS_SLOT_TEXTURE_2D_ARRAY, BINDLESS_SLOT_TEXTURE_CUBE, BINDLESS_SLOT_TEXTURE_3D, BINDLESS_SLOT_MAX_ENUM, // }; 加载模型时，上传对应数据到对应槽位，并范围ID值，另外有一个Buffer专门存储所有顶点数据的各项（位置、法线、颜色）的ID，组成一个MeshInfo，也作为一个ArrayBuffer上传\n1 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 void VertexBuffer::SetBufferData(void* data, uint32_t size, RHIBufferRef\u0026amp; buffer, uint32_t\u0026amp; id, uint32_t slot) { if (size == 0) return; if (!buffer || buffer-\u0026gt;GetInfo().size \u0026lt; size) // 创建buffer { RHIBufferInfo rHIBufferInfo; rHIBufferInfo.type = RESOURCE_TYPE_RW_BUFFER | RESOURCE_TYPE_VERTEX_BUFFER; rHIBufferInfo.size = size; rHIBufferInfo.memoryUsage = MEMORY_USAGE_CPU_TO_GPU; rHIBufferInfo.creationFlag = BUFFER_CREATION_PERSISTENT_MAP; buffer = APP_DYNAMICRHI-\u0026gt;CreateBuffer(rHIBufferInfo); if (id != 0) RENDER_RESOURCEMANAGER-\u0026gt;ReleaseBindlessID(id, (BindlessSlot)slot); BindlessResourceInfo bindlessResourceInfo; bindlessResourceInfo.buffer = buffer; bindlessResourceInfo.resourceType = RESOURCE_TYPE_RW_BUFFER; bindlessResourceInfo.bufferOffset = 0; bindlessResourceInfo.bufferRange = size; id = RENDER_RESOURCEMANAGER-\u0026gt;AllocateBindlessID(bindlessResourceInfo, (BindlessSlot)slot); } memcpy(buffer-\u0026gt;Map(), data, size); RENDER_RESOURCEMANAGER-\u0026gt;SetMeshInfo(vertexInfo, vertexID); } 另外每个Mesh实例也会有一个ID，用于存储这个实例对应的MeshInfo的ID以及材质的ID，也作为ArrayBuffer上传\n所以每个实例在GPU层只是一些底层资源的ID集合（再加一个Model矩阵）\n1 2 3 4 5 6 7 8 9 // Mesh的实例信息 typedef struct MeshInstanceInfo { glm::mat4 modelMatrix; glm::mat4 prevModelMatrix; uint32_t animationID; //TODO:动画索引 uint32_t materialID; uint32_t vertexID; uint32_t indexID; }MeshInstanceInfo; 渲染时,DrawCall指定起始实例为对应的实例ID，在Shader内就能拿到这个ID，然后去全局资源中拿到关于这个实例的所有信息\n1 2 3 4 5 6 7 8 9 for (auto\u0026amp; batch : meshBatch) { // 收集这个PSO的绘制命令到m_IndirectCommands（最终一并上传） RHIIndirectCommand meshDrawCommand; meshDrawCommand.firstInstance = batch.instanceID; // 在Shader中通过这个拿到实例ID meshDrawCommand.vertexCount = batch.indexCount; meshDrawCommand.instanceCount = 1; meshDrawCommand.firstVertex = 0; m_IndirectCommands.push_back(meshDrawCommand); } 这样设计下配合间接渲染，可以一口气上传所有实例的DrawCall，因为中间并不涉及顶点缓冲的切换\n可以做到Pipeline一致的所有Mesh只需要一个DrawCall\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void MeshPassProcessor::Draw(RHICommandListRef command) { for (auto\u0026amp; drawCommand : m_MeshDrawCommands) { auto [w, h] = APP_WINDOWSIZE; command-\u0026gt;SetGraphicsPipeline(drawCommand.pipeline); if (drawCommand.meshCommandRange.size \u0026gt; 0) { command-\u0026gt;DrawIndirect( m_MeshIndirectDrawDataBuffer[APP_FRAMEINDEX]-\u0026gt;GetRHIBuffer(), 4 * sizeof(uint32_t) + drawCommand.meshCommandRange.begin * sizeof(RHIIndirectCommand), drawCommand.meshCommandRange.size); } } } 序列化 定义各种序列化规则 以及一些好用的宏\ncereal库的使用就是定义好一个类或结构体的序列化和反序列化如何进行即可\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 #pragma once #include \u0026lt;cereal/access.hpp\u0026gt; #include \u0026lt;cereal/types/utility.hpp\u0026gt; #include \u0026lt;cereal/types/string.hpp\u0026gt; #include \u0026lt;cereal/types/polymorphic.hpp\u0026gt; #include \u0026lt;cereal/types/vector.hpp\u0026gt; #include \u0026lt;cereal/types/map.hpp\u0026gt; #include \u0026lt;cereal/types/list.hpp\u0026gt; #include \u0026lt;cereal/types/array.hpp\u0026gt; #include \u0026lt;cereal/types/queue.hpp\u0026gt; #include \u0026lt;cereal/archives/json.hpp\u0026gt; #include \u0026lt;cereal/archives/binary.hpp\u0026gt; #include \u0026lt;Hazel/Renderer/RHI/RHIBase.h\u0026gt; namespace cereal { // 指定各种类型的序列化规则 template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, GameEngine::Extent2D\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;width\u0026#34;, e.width), cereal::make_nvp(\u0026#34;height\u0026#34;, e.height)); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, GameEngine::Extent3D\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;width\u0026#34;, e.width), cereal::make_nvp(\u0026#34;height\u0026#34;, e.height), cereal::make_nvp(\u0026#34;depth\u0026#34;, e.depth)); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, glm::vec3\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;x\u0026#34;, e.x), cereal::make_nvp(\u0026#34;y\u0026#34;, e.y), cereal::make_nvp(\u0026#34;z\u0026#34;, e.z)); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, glm::vec2\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;x\u0026#34;, e.x), cereal::make_nvp(\u0026#34;y\u0026#34;, e.y)); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, glm::vec4\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;x\u0026#34;, e.x), cereal::make_nvp(\u0026#34;y\u0026#34;, e.y), cereal::make_nvp(\u0026#34;z\u0026#34;, e.z),cereal::make_nvp(\u0026#34;w\u0026#34;, e.w)); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, glm::ivec4\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;x\u0026#34;, e.x), cereal::make_nvp(\u0026#34;y\u0026#34;, e.y), cereal::make_nvp(\u0026#34;z\u0026#34;, e.z),cereal::make_nvp(\u0026#34;w\u0026#34;, e.w)); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, glm::uvec3\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;x\u0026#34;, e.x), cereal::make_nvp(\u0026#34;y\u0026#34;, e.y), cereal::make_nvp(\u0026#34;z\u0026#34;, e.z)); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, glm::ivec3\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;x\u0026#34;, e.x), cereal::make_nvp(\u0026#34;y\u0026#34;, e.y), cereal::make_nvp(\u0026#34;z\u0026#34;, e.z)); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, glm::quat\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;x\u0026#34;, e.x), cereal::make_nvp(\u0026#34;y\u0026#34;, e.y), cereal::make_nvp(\u0026#34;z\u0026#34;, e.z), cereal::make_nvp(\u0026#34;w\u0026#34;, e.w)); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, std::filesystem::path\u0026amp; e) { ar(cereal::make_nvp(\u0026#34;path\u0026#34;, e.string())); } template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar, glm::mat4\u0026amp; m) { ar( cereal::make_nvp(\u0026#34;c0\u0026#34;, m[0]), cereal::make_nvp(\u0026#34;c1\u0026#34;, m[1]), cereal::make_nvp(\u0026#34;c2\u0026#34;, m[2]), cereal::make_nvp(\u0026#34;c3\u0026#34;, m[3]) ); } #define BeginSerailize \\ friend class cereal::access; \\ template\u0026lt;class Archive\u0026gt; \\ void serialize(Archive\u0026amp; ar) \\ { #define SerializeBaseClass(className) \\ try { \\ ar(cereal::make_nvp(#className, cereal::base_class\u0026lt;className\u0026gt;(this))); \\ } catch (const std::exception\u0026amp; e) { \\ LOG_WARN(\u0026#34;{}: failed to serialize base class \u0026#39;{}\u0026#39;, reason: {}\u0026#34;, __FUNCTION__, #className, e.what()); \\ } #define SerailizeEntry(entry) \\ try { \\ ar(cereal::make_nvp(#entry, entry)); \\ } catch (const std::exception\u0026amp; e) { \\ LOG_WARN(\u0026#34;{}: failed to serialize entry \u0026#39;{}\u0026#39;, reason: {}\u0026#34;, __FUNCTION__, #entry, e.what()); \\ } #define SerailizeComponent(COMPONENT_TYPE) \\ if (HasComponent\u0026lt;COMPONENT_TYPE\u0026gt;()) { \\ ar(cereal::make_nvp(\u0026#34;Has\u0026#34; #COMPONENT_TYPE, true)); \\ ar(cereal::make_nvp(#COMPONENT_TYPE, GetComponentConst\u0026lt;COMPONENT_TYPE\u0026gt;())); \\ } else { \\ ar(cereal::make_nvp(\u0026#34;Has\u0026#34; #COMPONENT_TYPE, false)); \\ } #define DeserializeComponent(COMPONENT_TYPE) \\ do { \\ bool hasComponent_##COMPONENT_TYPE = false; \\ try { \\ ar(cereal::make_nvp(\u0026#34;Has\u0026#34; #COMPONENT_TYPE, hasComponent_##COMPONENT_TYPE)); \\ } catch (const cereal::Exception\u0026amp;) { \\ hasComponent_##COMPONENT_TYPE = false; \\ } \\ \\ if (hasComponent_##COMPONENT_TYPE) { \\ AddComponent\u0026lt;COMPONENT_TYPE\u0026gt;(); \\ try { \\ ar(cereal::make_nvp(#COMPONENT_TYPE, GetComponent\u0026lt;COMPONENT_TYPE\u0026gt;())); \\ } catch (const cereal::Exception\u0026amp;) { \\ LOG_INFO(\u0026#34;Old file missing data for {} component, using default values\u0026#34;, #COMPONENT_TYPE); \\ } \\ } \\ } while(0) #define BeginIfSave if constexpr (Archive::is_saving::value) { #define EndIfSave } #define BeginIfLoad if constexpr (Archive::is_loading::value) { #define EndIfLoad } #define SerailizeAssetEntry(entry) \\ ar(cereal::make_nvp(#entry, entry));\t\\ if(entry) entry-\u0026gt;OnLoadAsset(); #define EndSerailize } } #define SerailizeAssetParent ar(cereal::base_class\u0026lt;Asset\u0026gt;(this)); 比如Model,每个被序列化的对象都必须有序列化规则，否则就会报错，而且还不好找是哪个不存在导致的报错。。。\n1 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 class Model : public Asset { public: Model() = default; Model(std::string path, ModelSpec m_ModelSpec); void LoadFromFile(std::string path); virtual std::string GetAssetTypeName() override { return \u0026#34;Asset_Model\u0026#34;; } virtual AssetType GetAssetType() override { return ASSET_TYPE_MODEL; } virtual void OnLoadAsset() override; virtual void OnSaveAsset() override; bool hasBone() {return findBone;} std::vector\u0026lt;SubmeshData\u0026gt;\u0026amp; GetSubmeshes() { return submeshes; } SubmeshData\u0026amp; GetSubmeshData(uint32_t index) { return submeshes[index]; } MeshRef GetSubMesh(uint32_t index) { return submeshes[index].mesh; } std::vector\u0026lt;MaterialRef\u0026gt;\u0026amp; GetMaterials() { return materials; } MaterialRef GetMaterial(uint32_t index) { return materials[index]; } std::string GetPath() { return path; } VertexBufferRef GetVertexBuffer(int index){return submeshes[index].vertexBuffer;} IndexBufferRef GetIndexBuffer(int index){return submeshes[index].indexBuffer;} private: void ProcessNode(aiNode* node, const aiScene* scene, std::vector\u0026lt;aiMesh*\u0026gt;\u0026amp; processMeshes); void ProcessMesh(aiMesh* mesh, const aiScene* scene, int index); void ExtractBoneWeights(Mesh* submesh, aiMesh* mesh, const aiScene* scene); std::shared_ptr\u0026lt;Texture\u0026gt; Model::LoadMaterialTexture(std::string texturePath); private: std::string path; ModelSpec m_ModelSpec; uint64_t totalIndex = 0; uint64_t totalVertex = 0; uint32_t totalClusterCnt = 0; uint32_t totalClusterMaxMip = 0; std::vector\u0026lt;SubmeshData\u0026gt; submeshes; std::vector\u0026lt;MaterialRef\u0026gt; materials; std::unordered_map\u0026lt;std::string, TextureRef\u0026gt; textureMap; bool findBone = false; private: BeginSerailize SerailizeAssetParent SerailizeEntry(path) SerailizeEntry(m_ModelSpec) SerailizeEntry(totalIndex) SerailizeEntry(totalVertex) SerailizeEntry(totalClusterCnt) SerailizeEntry(totalClusterMaxMip) SerailizeEntry(submeshes) SerailizeEntry(materials) EndSerailize }; 如果序列化和反序列化操作不一致，比如要上传到GPU 用写好的BeginIfLoad BeginIfSave 来区分这是在加载还是在保存\n1 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 struct SubmeshData { std::shared_ptr\u0026lt;Mesh\u0026gt; mesh; VertexBufferRef vertexBuffer; IndexBufferRef indexBuffer; RHIBottomLevelAccelerationStructureRef blas; BeginSerailize SerailizeEntry(mesh) BeginIfLoad //加载时需要把顶点数据上传到GPU LOG_TRACE(\u0026#34; - Vertex Count: {}\u0026#34;, mesh-\u0026gt;position.size()); LOG_TRACE(\u0026#34; - Index Count: {}\u0026#34;, mesh-\u0026gt;index.size()); // 上传到GPU vertexBuffer = std::make_shared\u0026lt;VertexBuffer\u0026gt;(); vertexBuffer-\u0026gt;SetPosition(mesh-\u0026gt;position); vertexBuffer-\u0026gt;SetNormal(mesh-\u0026gt;normal); vertexBuffer-\u0026gt;SetTangent(mesh-\u0026gt;tangent); vertexBuffer-\u0026gt;SetTexCoord(mesh-\u0026gt;texCoord); vertexBuffer-\u0026gt;SetColor(mesh-\u0026gt;color); vertexBuffer-\u0026gt;SetBoneIndex(mesh-\u0026gt;boneIndex); vertexBuffer-\u0026gt;SetBoneWeight(mesh-\u0026gt;boneWeight); vertexBuffer-\u0026gt;SetBoundingBox(mesh-\u0026gt;box); const MeshInfo\u0026amp; vi = vertexBuffer-\u0026gt;vertexInfo; LOG_TRACE(\u0026#34; - Vertex Buffer Info:\u0026#34;); LOG_TRACE(\u0026#34; positionID: {}\u0026#34;, vi.positionID); LOG_TRACE(\u0026#34; normalID: {}\u0026#34;, vi.normalID); LOG_TRACE(\u0026#34; tangentID: {}\u0026#34;, vi.tangentID); LOG_TRACE(\u0026#34; texCoordID: {}\u0026#34;, vi.texCoordID); LOG_TRACE(\u0026#34; colorID: {}\u0026#34;, vi.colorID); LOG_TRACE(\u0026#34; boneIndexID: {}\u0026#34;, vi.boneIndexID); LOG_TRACE(\u0026#34; boneWeightID: {}\u0026#34;, vi.boneWeightID); indexBuffer = std::make_shared\u0026lt;IndexBuffer\u0026gt;(); indexBuffer-\u0026gt;SetIndex(mesh-\u0026gt;index); LOG_TRACE(\u0026#34; IndexBufferID: {}\u0026#34;, indexBuffer-\u0026gt;indexID); // RayTracing if (RENDER_ENABLE_RAY_TRACING) { RHIBottomLevelAccelerationStructureInfo blasInfo = {}; blasInfo.vertexBuffer = vertexBuffer-\u0026gt;positionBuffer; blasInfo.indexBuffer = indexBuffer-\u0026gt;buffer; blasInfo.triangleCount = mesh-\u0026gt;TriangleNum(); blasInfo.vertexStride = sizeof(glm::vec3); blasInfo.indexOffset = 0; blasInfo.vertexOffset = 0; blas = APP_DYNAMICRHI-\u0026gt;CreateBottomLevelAccelerationStructure(blasInfo); } EndIfLoad EndSerailize }; 使用时比较麻烦的一点是如果一个类成员是第三方库，那就需要很多的trick来解决这个问题，比如Entity底层用的entt，那就很麻烦\n序列化场景时就需要手动处理逻辑，而不能直接序列化Entity\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 template\u0026lt;class Archive\u0026gt; void serialize(Archive\u0026amp; ar) { if constexpr (Archive::is_saving::value) { ar(cereal::make_nvp(\u0026#34;EntityCount\u0026#34;, m_EntityIDMap.size())); for (auto\u0026amp; [uuid, entity] : m_EntityIDMap) { ar(cereal::make_nvp(\u0026#34;Entity\u0026#34;, entity)); } } else { m_EntityIDMap.clear(); size_t entityCount = 0; ar(cereal::make_nvp(\u0026#34;EntityCount\u0026#34;, entityCount)); for (size_t i = 0; i \u0026lt; entityCount; ++i) { Entity entity; ar(cereal::make_nvp(\u0026#34;Entity\u0026#34;, entity)); m_EntityIDMap[entity.GetUUID()] = entity; } } } 基础功能 摄像机系统 分为两种模式 FPS和轨迹球\n1 2 3 4 enum class CameraMode { NONE, FLYCAM, ARCBALL }; Tick函数，主要就是设计两种模式下如何响应鼠标移动和键盘移动来更新 Position 和 YawPitch（间接更新朝向）\n另外m_IsCapturing设计是为了防止两次操作方向时鼠标位置不一致导致的屏幕突然旋转（感觉可以设计的更简洁。。。TODO）\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 void EditorCamera::OnUpdate(const Timestep ts) { bool isMouseInViewport = m_IsMouseInViewport; bool isButtonPressed = Input::IsMouseButtonDown(MouseButton::Right) || Input::IsMouseButtonDown(MouseButton::Middle) || (Input::IsMouseButtonDown(MouseButton::Left) \u0026amp;\u0026amp; Input::IsKeyDown(KeyCode::LeftAlt)); // 鼠标开始控制的第一帧，初始化Delta资源 if (isButtonPressed \u0026amp;\u0026amp; isMouseInViewport \u0026amp;\u0026amp; !m_IsCapturing) { m_IsCapturing = true; m_InitialMousePosition = { Input::GetMouseX(), Input::GetMouseY() }; m_YawDelta = 0.0f; m_PitchDelta = 0.0f; m_PositionDelta = glm::vec3(0.0f); } // 结束鼠标控制 if (!isButtonPressed) m_IsCapturing = false; if (!m_IsCapturing) { EnableMouse(); }else { // 鼠标控制中的一帧 const glm::vec2 mouse{ Input::GetMouseX(), Input::GetMouseY() }; const glm::vec2 delta = (mouse - m_InitialMousePosition) * 0.002f; if (Input::IsMouseButtonDown(MouseButton::Right) \u0026amp;\u0026amp; !Input::IsKeyDown(KeyCode::LeftAlt)) { m_CameraMode = CameraMode::FLYCAM; DisableMouse(); const float yawSign = GetUpDirection().y \u0026lt; 0 ? -1.0f : 1.0f; const float speed = GetCameraSpeed(); // 位置调整 Position if (Input::IsKeyDown(KeyCode::Q)) m_PositionDelta -= ts.GetMilliseconds() * speed * glm::vec3{ 0.f, yawSign, 0.f }; if (Input::IsKeyDown(KeyCode::E)) m_PositionDelta += ts.GetMilliseconds() * speed * glm::vec3{ 0.f, yawSign, 0.f }; if (Input::IsKeyDown(KeyCode::S)) m_PositionDelta -= ts.GetMilliseconds() * speed * m_Direction; if (Input::IsKeyDown(KeyCode::W)) m_PositionDelta += ts.GetMilliseconds() * speed * m_Direction; if (Input::IsKeyDown(KeyCode::A)) m_PositionDelta -= ts.GetMilliseconds() * speed * m_RightDirection; if (Input::IsKeyDown(KeyCode::D)) m_PositionDelta += ts.GetMilliseconds() * speed * m_RightDirection; // 旋转调整Direction constexpr float maxRate{ 0.12f }; // Yaw * yawSign是为了相机倒置的时候，偏航角和鼠标的移动还是一致的 m_YawDelta += glm::clamp(yawSign * delta.x * RotationSpeed(), -maxRate, maxRate); m_PitchDelta += glm::clamp(delta.y * RotationSpeed(), -maxRate, maxRate); m_RightDirection = glm::cross(m_Direction, glm::vec3{ 0.f, yawSign, 0.f }); // 重新调整焦点 const float distance = glm::distance(m_FocalPoint, m_Position); m_FocalPoint = m_Position + GetForwardDirection() * distance; m_Distance = distance; } else if (Input::IsKeyDown(KeyCode::LeftAlt)) { m_CameraMode = CameraMode::ARCBALL; if (Input::IsMouseButtonDown(MouseButton::Middle)) { DisableMouse(); MousePan(delta); } else if (Input::IsMouseButtonDown(MouseButton::Left)) { DisableMouse(); MouseRotate(delta); } else if (Input::IsMouseButtonDown(MouseButton::Right)) { DisableMouse(); MouseZoom((delta.x + delta.y) * 0.1f); } else EnableMouse(); } else { EnableMouse(); } m_InitialMousePosition = mouse; m_Position += m_PositionDelta; m_Yaw += m_YawDelta; m_Pitch += m_PitchDelta; if (m_CameraMode == CameraMode::ARCBALL) m_Position = CalculatePosition(); UpdateCameraView(); } } 好像别的基础功能也没啥需要专门记录的了。。。。\n","date":"2026-01-09T17:16:11+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E5%9F%BA%E7%A1%80%E6%9E%B6%E6%9E%84/","title":"游戏引擎开发实践（基础架构）"},{"content":"\n随机变量X大于t的概率可以用随机变量X的均值和方差进行估计\n这个是单边切比雪夫不等式，它给出的是概率上限，也就是X大于t的概率不会大于这个\nVSM 软阴影需要在shadowMap上多次采样才行，采样点越多越软，但是并不能提前模糊光源深度图，模糊深度图后只是改变了比较的深度值，最终采样结果还是0-1，而VSM的思想就是通过概率论的公式直接估计一片区域内ShadowMap的深度比着色点深度更大的概率，只需要采样深度图一次即可（不过多了一个对深度图进行模糊的Pass）\n流程很简单\n操作简单，但是效果非常好。但是！漏光了！头盔左侧\n如果直接用的话问题还是很大的\n网上对漏光的解释是当我算C的阴影时，应该按照B的深度来估计，但是A也参与了，AB深度差距大，方差大，估算不准确\n大概理解一下就是一片区域进行采样的filter时，这片区域的深度变化非常大，不能称之为类似一个正态分布的情况，这就会导致你用切比雪夫不等式估算不准确。\n实际测试下来就是发现深度变化大的地方就容易漏光\n解决漏光可以使用ESVM，用指数函数$e^{kd}$对深度做变换，将多峰分布拉成单峰，压缩方差\n改完之后确实有效果\n但是并不能完全消除阴影，有些视角下还是有漏光的\n","date":"2025-12-31T21:34:50+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E5%88%87%E6%AF%94%E9%9B%AA%E5%A4%AB%E4%B8%8D%E7%AD%89%E5%BC%8F%E7%9A%84%E5%BA%94%E7%94%A8/","title":"游戏引擎开发实践（切比雪夫不等式的应用）"},{"content":"降噪算法 高斯滤波器 很简单就是拿一个高斯滤波核遍历一遍像素\n高斯公式回顾一下\n双边滤波 这张图是高斯滤波的结果，它任何地方都会被均等的糊掉\n下面是双边滤波器的公式 (i,j)(k,l)两个点的之间的权重计算，前半部分就是高斯分布，后半部分（也是一个高斯分布，颜色越相近，越接近最高点 ）表示：如果两个点颜色差距越大，权重越小\n这个式子可以拆成两个指数函数的乘积，第二项如果颜色一致就=1，颜色不一致程度越大，越小，这样就达到了根据颜色差异调整权重的能力\n这个算法的问题是分不清是边界还是噪声\n联合双边滤波 高斯滤波的标准：距离\n双边滤波的标准：距离+颜色差异\n联合双边滤波思想就是用更多的指标，比如GBuffer那些信息\n比如量化深度差异，法线差异来指导权重，这里并没有说具体公式\n大滤波器优化 如果滤波核很大，如何进行优化\n拆分2个Pass 水平一遍，竖直一遍，经典\n再配合groupshared，减少ComputeShader的采样次数\n原理上来讲二维高斯就可以拆成两个一维高斯的乘积，卷积写成二重积分后，对于高斯滤波核来说可以拆成内部x积分完成后，再y积分\n但是复杂的滤波核不适用\n小波变换卷积\\空洞卷积 À-Trous wavelet 思路也是多个Pass进行卷积，比如5x5的核，每次都是5x5但是采样点的间距变了，变成$2^i$\n复杂度从n2变成 卷积核的平方 * 卷积次数\n处理过亮像素 噪声中很亮的点如果做滤波，会变成一片亮\n亮点探测:\n处理亮点\nSVGF （Spatiotemporal Variance-Guided Filter）时空方差引导滤波 SVGF就是融合了上边介绍的这些技术点，从时间和空间上都进行了滤波\n3个因素引导的联合双边滤波\n深度 它的深度指数衰减不仅仅是考虑空间深度，还会考虑法线投影上的深度大小（分母），这样建模是为了表达虽然空间上深度差异较大，但是xxxx(TODO:其实没懂为什么这样)\n法线 两个法线的点乘来控制，并用指数控制衰减速度\n另外一点不要用法线贴图的数据，直接用模型原始法线\nLuminance 颜色差异会被噪声干扰，比如一个点在阴影里，但是因为噪声，它变得特别亮，解决办法就是利用方差来指导，方差信息会先计算一次空间上的方差，再时域累积，再空间滤波\nSVGF解决不掉的问题是，场景没有变化也就是MotionVector没有数据，但是阴影一直在移动，这时候的阴影就会有噪声\n再后来出现了A-SVGF,处理场景突变的延迟问题\nRAE 利用神经网络来滤波，目前没兴趣\nSVGF实践 参考文刀秋二大佬的https://zhuanlan.zhihu.com/p/28288053，与https://zhuanlan.zhihu.com/p/699706592\nDemodulate Albedo 第一步要存储SPP=1的情况下，直接光照和间接光照的结果，并且在存储时要/albedo来保存，过滤过后再乘回来，文刀秋二的解释是（纹理的细节并不会因为Filter的强度过于大而丢失掉）目前不太理解，先做出来再说。 另外这样做的前提应该是当前场景是Diffuse的\n这张（右侧）表示直接光照/albedo\n这张（右侧）表示间接光照/albedo\nReconstruction Filter 先实现空洞滤波\n1 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 #version 450 core #include \u0026#34;../common/common.glsl\u0026#34; #include \u0026#34;../common/math.glsl\u0026#34; #ifdef COMPUTE_SHADER layout(set = 1, binding = 0, rgba32f) uniform image2D OUT_COLOR; layout(set = 1, binding = 1, rgba32f) uniform image2D IN_COLOR; layout(set = 1,binding = 2, rgba32f) uniform image2D velocityTexture; layout(push_constant) uniform Uniforms { int curPassIndex; // 当前 Atrous pass 索引 }; layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; void main(){ ivec2 invocID = ivec2(gl_GlobalInvocationID.xy); ivec2 imgSize = ivec2(imageSize(OUT_COLOR)); if (invocID.x \u0026gt;= imgSize.x || invocID.y \u0026gt;= imgSize.y) return; vec3 colorSum = vec3(0.0); float weightSum = 0.0; // 5x5 Atrous 核心 做了空洞滤波，相当于64*64的卷积核 int curStep = 1 \u0026lt;\u0026lt; curPassIndex; for (int dy = -2; dy \u0026lt;= 2; ++dy) { for (int dx = -2; dx \u0026lt;= 2; ++dx) { ivec2 samplePos = invocID + ivec2(dx, dy) * curStep; // 边界检查 samplePos = clamp(samplePos, ivec2(0), imgSize - 1); vec3 sampleColor = imageLoad(IN_COLOR, samplePos).xyz; // Atrous 权重，这里先用 1.0，之后可以换成高斯或 bilateral 权重 float weight = 1.0; colorSum += sampleColor * weight; weightSum += weight; } } vec3 result = colorSum / weightSum; imageStore(OUT_COLOR, invocID, vec4(result, 1.0)); } #endif 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 #include \u0026#34;hzpch.h\u0026#34; #include \u0026#34;SVGFPass.h\u0026#34; #include \u0026#34;Hazel/Core/Application.h\u0026#34; #include \u0026#34;Hazel/Scene/SceneManager.h\u0026#34; #include \u0026#34;Hazel/Renderer/RenderResource/RenderResourceManager.h\u0026#34; #include \u0026#34;Hazel/Renderer/RenderResource/PipelineCache.h\u0026#34; #include \u0026lt;Hazel/Renderer/RenderResource/Shader.h\u0026gt; namespace GameEngine { void SVGFPass::Init() { m_Shader = std::make_shared\u0026lt;Shader\u0026gt;(\u0026#34;postprocess/SVGF\u0026#34;, SHADER_FREQUENCY_COMPUTE)-\u0026gt;GetRHIShader(); RHIRootSignatureInfo info = {}; info.AddEntryFromReflect(m_Shader); info.AddEntry(RENDER_RESOURCEMANAGER-\u0026gt;GetGlobalResourcePreFrameRootSignature()-\u0026gt;GetInfo()) .AddPushConstant({4,SHADER_FREQUENCY_COMPUTE }); m_RootSignature = APP_DYNAMICRHI-\u0026gt;CreateRootSignature(info); RHIComputePipelineInfo pipelineInfo = {}; pipelineInfo.computeShader = m_Shader; pipelineInfo.rootSignature = m_RootSignature; m_Pipeline = APP_DYNAMICRHI-\u0026gt;CreateComputePipeline(pipelineInfo); } void SVGFPass::Build(RDGBuilder\u0026amp; builder) { auto\u0026amp; [w, h] = APP_WINDOWSIZE; RDGTextureHandle pathTracingDirectRes = builder.GetTexture(\u0026#34;PathTracingdirectRes\u0026#34;); if (pathTracingDirectRes.ID() == UINT32_MAX) { return; } RDGTextureHandle pathTracingIndirectRes = builder.GetTexture(\u0026#34;PathTracingIndirectRes\u0026#34;); RDGTextureHandle velocity = builder.GetTexture(\u0026#34;GBufferVelocity\u0026#34;); RDGBufferHandle exposureData = builder.GetBuffer(\u0026#34;ExposureData\u0026#34;); RDGTextureHandle directFilterRes = builder.CreateTexture(\u0026#34;PathTracing_SVGF_directFilterRes\u0026#34;) .Exetent({ w, h ,1 }) .Format(FORMAT_R32G32B32A32_SFLOAT) .AllowRenderTarget() .AllowReadWrite() .Finish(); RDGTextureHandle forPinpongTexture = builder.CreateTexture(\u0026#34;PathTracing_SVGF_forPinpongTexture\u0026#34;) .Exetent({ w, h ,1 }) .Format(FORMAT_R32G32B32A32_SFLOAT) .AllowRenderTarget() .AllowReadWrite() .Finish(); RDGTextureHandle inDirectFilterRes = builder.CreateTexture(\u0026#34;PathTracing_SVGF_inDirectFilterRes\u0026#34;) .Exetent({ w, h ,1 }) .Format(FORMAT_R32G32B32A32_SFLOAT) .AllowRenderTarget() .AllowReadWrite() .Finish(); // 直接光 for (int i = 0; i \u0026lt; 5; i++) { builder.CreateComputePass(GetName() + \u0026#34;_DirectRes\u0026#34; + std::to_string(i)) .RootSignature(m_RootSignature) .PassIndex(i) .ReadWrite(1, 0, 0, i == 0 ? directFilterRes : i % 2 == 1 ? forPinpongTexture : directFilterRes) .ReadWrite(1, 1, 0, i == 0 ? pathTracingDirectRes : i % 2 == 1 ? directFilterRes : forPinpongTexture) .ReadWrite(1, 2, 0, velocity) .Execute([\u0026amp;](RDGPassContext context) { auto\u0026amp; [w, h] = APP_WINDOWSIZE; RHICommandListRef command = context.command; command-\u0026gt;SetComputePipeline(m_Pipeline); command-\u0026gt;BindDescriptorSet(context.descriptors[1], 1); uint32_t curIndex = context.passIndex[0]; command-\u0026gt;PushConstants(\u0026amp;curIndex, sizeof(uint32_t), SHADER_FREQUENCY_COMPUTE); command-\u0026gt;BindDescriptorSet(RENDER_RESOURCEMANAGER-\u0026gt;GetGlobalResourcePerFrameDescriptorSet(), 0); command-\u0026gt;Dispatch((w + 15) / 16, (h + 15) / 16, 1); }) .Finish(); } // 间接光 for (int i = 0; i \u0026lt; 5; i++) { builder.CreateComputePass(GetName() + \u0026#34;_DirectRes\u0026#34; + std::to_string(i)) .RootSignature(m_RootSignature) .PassIndex(i) .ReadWrite(1, 0, 0, i == 0 ? inDirectFilterRes : i % 2 == 1 ? forPinpongTexture : inDirectFilterRes) .ReadWrite(1, 1, 0, i == 0 ? pathTracingIndirectRes : i % 2 == 1 ? inDirectFilterRes : forPinpongTexture) .ReadWrite(1, 2, 0, velocity) .Execute([\u0026amp;](RDGPassContext context) { auto\u0026amp; [w, h] = APP_WINDOWSIZE; RHICommandListRef command = context.command; command-\u0026gt;SetComputePipeline(m_Pipeline); command-\u0026gt;BindDescriptorSet(context.descriptors[1], 1); uint32_t curIndex = context.passIndex[0]; command-\u0026gt;PushConstants(\u0026amp;curIndex, sizeof(uint32_t), SHADER_FREQUENCY_COMPUTE); command-\u0026gt;BindDescriptorSet(RENDER_RESOURCEMANAGER-\u0026gt;GetGlobalResourcePerFrameDescriptorSet(), 0); command-\u0026gt;Dispatch((w + 15) / 16, (h + 15) / 16, 1); }) .Finish(); } } } 直接光模糊后结果\n间接光会有闪烁的情况\n滤波公式，每次滤波时都是一个联合双边滤波\n具体的联合双边滤波有三部分组成\n先加入法线贡献\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 5x5 Atrous 核心 做了空洞滤波，相当于64*64的卷积核 uint curStep = 1 \u0026lt;\u0026lt; curPassIndex; for (int dy = -2; dy \u0026lt;= 2; ++dy) { for (int dx = -2; dx \u0026lt;= 2; ++dx) { ivec2 samplePos = invocID + ivec2(dx, dy) * int(curStep); // 边界检查 samplePos = clamp(samplePos, ivec2(0), imgSize - 1); vec3 sampleColor = imageLoad(IN_COLOR, samplePos).xyz; // 高斯 float gaussWeight = gaussKernel5x5[dx + 2 + (dy + 2) * 5]; // 法线 vec3 sampleNormal = imageLoad(NORMAL, samplePos).xyz; float normalWeight = pow(max(dot(normal, sampleNormal), 0.0), 128.0); float weight = gaussWeight*normalWeight; colorSum += sampleColor * weight; weightSum += weight; } } 明显还是有问题的，因为小正方形上面和地面法线都朝上，所以贡献偏大了，阴影跑到了正方形上边\n需要引入颜色差异来解决这个问题，毕竟阴影里的颜色和阴影外的最大区别就是颜色，很容易区分，另外还需要注意颜色差异是噪声造成的还是场景造成的（方差越大，越不应该作为权重评判的指标）\n分子表示亮度差，差异越大权重越小，但差异过大也可能是噪声导致的，所以差异大并不一定表示颜色边界\n所以引入方差来表示颜色的变化，方差越大，权重越大。就是越不考虑颜色差异的影响\n这里的方差是指时间维度的方差，这样的话就得用MotionVector来找上一帧的位置，也就可以理解为如果一个像素位置颜色一直在变化，那它就很有可能只是噪声，就不应该用颜色差异来知道滤波，所以这一项权重就会变大\n先根据一阶矩和二阶矩计算出方差\n时间上的方差结果只用于第一次滤波，后续4次滤波用一个公式来更新方差值\n","date":"2025-12-26T15:26:32+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E9%99%8D%E5%99%AA%E7%AE%97%E6%B3%95-svgf/","title":"游戏引擎开发实践（降噪算法 SVGF）"},{"content":" 未完结\n因为直接看了第7章，第4章还没看，需要先回去看一下4.3.1的BRDF、BTDF、BSSRDF\nBXDF框架 BSDF BRDF 很熟悉了，只定义了反射如何进行\nHemispherical–Directional Reflectance（半球–方向反射率） 从BRDF延申出的一个概念，把光照设置为常量1，这时候的渲染方程就变成了这个东西\n它的意义是不关心光照，不关心各个方向的Radiance（均匀分布的光照），只关心材质本身往W0方向的Radiance\n它可以用来检查物理守恒，物理正确的材质必须满足：\nhemispherical–hemispherical reflectance（半球–半球反射率） 描述了一个面在所有入射方向上平均收到光照后，向所有方向反射的平均能量比例\n这不就是把上一个半球–方向反射率求了一个半球积分后的平均么\n上述两个东西，感觉是用在表达材质的反射效果的一个指标，并不是渲染需要的东西，写论文用的东西？\nBTDF T 表示transmittance ，BTDF用于描述透射的分布\n这里特别提到BTDF并没有reciprocity 的特性，就是不能反着来\nBTDF和BRDF被统称为BSDF，因为他们都可以表达为\n最终BSDF作用到渲染中为（注意这里绝对值符号是PBRT自己加的，他们意思是并不会修改模型的法线朝向模型外侧，可能有他们的考虑）\n整了半天4.3.1 原来并没有介绍具体的BSDF模型，只是引入了概念\nBSSRDF 今天属于是理清楚了 scattering是各种各样的改变光方向的行为的统称，并不是额外的效果。。。\n折射是透射的一种，透射是更上层的概念，透射就是进入某种介质的行为。。。\nbidirectional scattering surface reflectance distribution function (BSSRDF) 描述的是此表面反射的理论框架\n参数多了一个Pi，也就是Pi对着色点P0的贡献比例，比BSDF多一个维度，BSDF只考虑当前着色点\n看这张图能看出光先进入物体内部，不断散射后从着色点穿出后到达摄像机\n计算过程需要遍历每一个Pi，BSSDF定义这个Pi点的贡献值\n内层积分定义一个Pi点的渲染贡献，外层积分累积所有Pi点\n下图展示了(a) 漫反射 (b)高光反射 (c) 镜面反射（接近）（d）逆反射的Lobe\n另外也理解了各项同性和异性， 同性就是指如上图的摄像机位置，如果绕着法线进行旋转，得到的结果应该是一致的\n基础理论 为可见光的波长（量级为米级）远小于渲染场景中物体的尺寸（毫米到米级），因此波动相关的现象通常不会在渲染图像中体现.\n但要深入理解光线撞击表面时的行为，就需要借助波动光学的理论，而且在几何光学的模拟框架中融入波动光学的结论，已经成为计算机图形学的常用设计模式。\n当光线照射到物体表面时，会激发材料原子外层的电子，使其发生快速振荡；振荡的电荷会产生次级电场振荡，这些次级振荡会发生相长干涉和相消干涉，这就是原子反射光线的核心机制。而干涉的具体表现，则由原子的种类和原子间的结合方式决定。\n材料种类 电介质（Dielectrics） 这是一大类电绝缘体，包括玻璃、水、矿物油、空气等气态、液态、固态物质。这类材料中，电子与原子的结合非常牢固，不会自由移动。\n导体（Conductors） 包括金属、合金以及类金属（如石墨）。这类材料的原子晶格中存在自由电子，入射电磁波激发的电子振荡可以让电子在较大范围内移动；同时，电子在晶格中移动时会以热量的形式耗散部分入射能量，导致光线在深入材料的过程中被快速吸收。\n关键特性：光线通常在材料表面0.1 米的深度内被完全吸收，只有极薄的金属薄膜才能透射可观的光线。\n半导体（Semiconductors） 如硅、锗，兼具电介质和导体的特性。例如硅在可见光波段表现出金属的特性，但在红外波段是透明的，因此可用于红外相机的光学元件。\nPBRT后面介绍了IOR（折射率）的原理就是光速与在真空中的光速的比值，两种介质的折射率差值越大，分界面的反射效果越强。\n反射定律 这张图解释了反射是如何计算的，分解成平行于法线和垂直于法线的两个向量后很容易计算\n折射定律 经过一些推导最终折射的向量可以通过下面这个公式计算\n折射需要有一个注意的地方：从折射率高进入折射率低（从水中看向空气）时，入射角度过大会导致折射算出来sin大于1了，此时发生的是全内反射，没有折射了\n菲涅耳方程 上述的折射和反射是会同时发生的，对于导体来说，折射会迅速消失。\n菲涅耳方程用来计算反射光的比例\n这块东西解释了入射波可以分解为垂直（垂直于入射向量和法线组成的平面）和平行两个，单独进行建模。菲涅尔方程描述了给定已知振幅的入射波时，反射波振幅与入射波振幅之间的关系。（这些内容没必要特别细看）\n电介质、导体和半导体均遵循相同的菲涅尔方程。\n对于电介质，就按照上边的公式计算\n对于导体，折射率之比变成用复数表示，然后各种解释，不太理解，看这里最终eta是一个复数，而不是简单的float比值，这种东西就不细看了\n常见BXDF 完美模型 Diffuse Reflection（完美漫反射） 介绍了最简单的Lambertian model，完全均匀反射率的漫反射表面使用的模型\n下图解释了为什么要除以/Pi。 因为这样才能保证使总反射能量=R（反射率，用RGB就是albedo）\nConductor BRDF（完美反射） 导体的BRDF依据两个观点：\n镜面反射定律为每条光线分配特定的反射方向，菲涅耳方程则确定反射光的分布范围。 任何残余光线都会折射进入导体，并被迅速吸收转化为热能 所以光滑导体只考虑BRDF、采样只会采样反射方向、PDF=1\n非常简单，对W0有贡献的点只有反射方向，所以不需要算积分了，F()是菲涅尔方程\n计算积分时的采样函数不需要什么工具，而是直接找反射方向，然后用菲涅尔公式计算反射比值，返回对这个方向的采样结果，另外PDF直接取1即可\nDielectric BSDF（完美反射+完美折射） 不像导体，这里就必须考虑反射和折射了\n设置采样方向的方法是根据入射光角度，计算菲涅耳方程的反射比例，然后去一个随机数来概率选择BRDF还是BTDF\n等比例选择BRDF和BTDF 根据反射比例来选择BRDF和BTDF，效果明显变好了，因为少了很多低贡献的采样 采样方式代码，会根据概率选择BRDF和BTDF\n此时的PDF为\n如果选择反射，和导体的采样方向一样\n如果选择折射\nThin Dielectric BSDF（薄电介质，比如玻璃（跳过）） 先理解薄电介质的物理背景（为什么需要单独建模）：\n普通电介质（DielectricBxDF）只模拟 “单界面”（比如空气 - 玻璃），但实际的薄玻璃有两个平行界面（空气→玻璃→空气）：\n光线入射到第一层界面：一部分反射，一部分透射进入玻璃； 透射光到达第二层界面：一部分透射出去，一部分反射回玻璃内部； 反射回的光又会在第一层界面反射 / 透射…… 这个过程无限递归（如图 9.16）； 薄电介质的核心假设：玻璃厚度极薄，光线的空间偏移可忽略，只需计算 “所有内部反射的总贡献”。 这个模型主要就是：多层内部反射的总反射率可以用几何级数求和简化（避免追踪无限次反射）\n具体就先不看了！\nNon-Symmetric Scattering and Refraction（跳过） BTDF不能和BRDF一样反转使用，反转后必须修正系数才能保证物理正确。\n这就不看了，看了一时半会也用不着\n粗糙模型 微平面理论的关键洞见在于：无需对每个微平面进行显式几何建模，而是通过统计方法描述其集体行为\n微面反射模型需考虑的三个重要几何效应。\n(a)遮蔽效应：目标微面因被其他微面遮挡而不可见。\n(b)阴影效应：类似地，光线无法抵达该微面。\n(c)反射间效应：光线在到达观察者前会在微面间发生多次反射。\n用法线分布来描述微表面，这里提到要让一小块微平面dA上方的面积投影下面积等于dA的面积\nGGX法线分布\n还有一种Beckmann法线分布\nGGX的有点是拖尾，它的尾巴比较长，如下图橙色尾巴很长。而beckmannNDF在90°时已经衰减为非常接近0。如果把高出定义为高光，那backmannNDF高光周围会迅速衰减。而GGX会缓慢衰减，形成光晕的效果\n后面介绍的过于理论了，感觉对目前帮助不大，先能进入行业再说，否则也没什么意义\n","date":"2025-12-25T14:10:44+08:00","image":"https://sdpyy1.github.io/image-20260102161357849.png","permalink":"https://sdpyy1.github.io/p/pbrtreflection-models/","title":"PBRT（Reflection Models）"},{"content":"蒙特卡洛积分 开头一句话就解释了为什么渲染要用蒙特卡洛积分\n虽然梯形积分法或高斯求积等标准数值积分技术在求解低维光滑积分时效果显著，但对于渲染中常见的高维不连续积分，其收敛速度却明显不足。蒙特卡洛积分技术为此提供了解决方案，通过随机采样评估积分，其收敛速度与被积函数的维度无关\n概率论Review 读一遍，关键是回顾一下PMF/PDF CDF都是什么\n随机变量\np(X)表达概率，称为PMF\n这个是真忘了\n对于连续随机变量，PMF取值始终是0，所以另外引入了PDF（概率密度函数）来表达连续性随机变量的概率情况\n计算PDF的理论方法就是，在随机域上对PDF进行积分的结果得=1，反退出来的PDF函数是什么\n另外PDF是累积分布函数的导数\nPDF作用就很好理解了，他表示当前取值的概率密度，计算ab范围内的概率，就进行积分就是一段区域内的概率\n期望，X的期望就可以通过 积分X*PDF来计算\nThe Monte Carlo Estimator 蒙特卡洛公式的期望就等于原始需要计算的积分\n蒙特卡洛公式，代码流程就是从PDF表示的分布中随机采样，计算积分值 / pdf值，多个采样点求和后除以采样数\n可以看出一般形式的蒙特卡洛公式的期望仍然等于要求的积分\nError in Monte Carlo Estimators 蒙特卡洛积分的误差 使用方差与收敛时间评估蒙特卡洛积分\n回顾方差的含义就是函数与其期望值的预期平方偏差\n经典公式\n多个独立随机变量的方差可以直接求和得到总的方差\n蒙特卡洛积分相当于多个独立的随机变量的和，他的方差相当于每个随机变量的方差的和，因为另外因为蒙特卡洛积分要除以/N（样本数量），所以蒙特卡洛积分的方差会随着样本数量的增加而线性递减。\n下面这段话是想说明蒙特卡洛算法的收敛速度不受维度影响，因此成为高维积分唯一实用的数值积分算法，TODO: 其实不是特别理解在说什么\n使用方差线性递减这一特性，使用方差与运行实践来评估一个蒙特卡洛，方差和运行时间越小越好\n并非所有积分估计量的期望值都等于积分本身。这类估计量被称为有偏估计量\n虽然有偏，但随着样本数 N→∞N \\to \\inftyN→∞，误差会趋近 0，这类估计器称为一致（consistent）估计器\n在积分估计中，有偏估计器虽然期望值不等于真实值，但可能方差更小、收敛更快，而且一致估计器随着样本数增加仍能得到正确结果；在 Monte Carlo 渲染中，绝大多数估计器是无偏的，光子映射是一个例外。\nMSE（均方误差）：估计值与真实值之间的平方误差的期望（方差是估计值与估计值比较，均方误差是估计值与实际值比较）\n对于无偏估计，那MSE就等于方差，否则即为方差与估计量偏差平方之和。\n最后这点东西介绍，需要明白 蒙特卡洛积分本身也是一个随机变量（因为他通过采样多个独立的随机变量来进行的），所以可以求蒙特卡洛的方差和均方误差，求法就是用样本进行估计，下边两幅图就是如何通过样本估计蒙特卡洛的方差和均方误差\n这里提到的应该就是用样本估计随机变量的方差（也就是如何量化一个蒙特卡洛积分的方差）\n利用样本估计的方差，是实际方差的估计值，所以本身也有方差\n另外提到了MSE如何估计\nImproving Efficiency 提升效率 Stratified Sampling 分层采样 简单理解就是把积分域划分为多个区域，每个区域按照自己的PDF去进行蒙特卡洛积分\n把随机采样改成了在每个子区域随机采样，\n下图i代表第i个积分区域，j代表第j个样本\n随机采样结果\n分层采样结果，对比能看出噪声更小了\n分层采样的缺点与维度有关，维度太多分层也变得复杂\nImportance Sampling 重要性采样 重要性抽样是一种强大的方差缩减技术，其原理基于蒙特卡洛估计量的特性\n原理：若样本取自与被积函数函数形式相似的分布，则收敛速度更快。\n如果被积函数是上图，PDF用下图，那最终得到的蒙特卡洛的方差要比随机采样好很多\nPDF中间高，说明在这些点采样的概率高，在采样时也就更逼近真实的数据，很好理解的效果好\nMultiple Importance Sampling 多重重要性采样 在面对被积函数是多个函数相乘（比如渲染方程就是BRDF Li cos 三项)的情况下的优化方案\n首先举个例子，假如有两个PDF，刚好对应两个f(x)，如果我们使用PDFa作为采样的PDF时，最终样本都会只剩下fb(x)，因为PDFa与fa(x)更好成比例\n此时蒙特卡洛积分的方差完全由fb(x)的方差决定，但他的方差可能是很高的，另外这是不可控的，因为被积函数是不能变动的。所以单纯依赖被积函数某一项的形状设计PDF，是不可靠的\nMIS就是用来解决这个问题的\nMIS的采样方案是\n也就是用光源相关的PDF采样一次，用BRDF相关的PDF采样一次，并乘以各自的权重。\n总结成公式就是\n那权重如何计算呢（MIS 的权重来自一个要求：在保持无偏的前提下，让方差尽可能小。）PBRT 默认使用的是 Balance Heuristic。\n抛开每种PDF生成的样本数量不谈，权重就是 PDF/所有PDF\n更进一步，乘个幂次\n紧接着提到单样本模型（Single-Sample MIS），其实就是上边的n都=1，就是每个PDF都只采样一次，方差就很低了\nMIS Compensation 多重重要性采样补偿 比如一个渲染场景，光源项PDF与BRDF的PDF两个PDF进行多重重要性采样\n这个东西必须学习完MIS并且实践后才能感悟他在干什么，这里先简单理解一下\n此时的蒙特卡洛估计的MIS\nBRDF在高光方向大量采样，但是这里的光源=0，最终采样样本=0\n光源方向采样也因为权重被削弱\nMIS虽然考虑到了不同的PDF的配合，但是如果一个PDF大量覆盖了积分接近0的区域，方差并不是最优\nBSDF PDF 在“根本没有光”的方向仍然给了概率，总之就是在MIS的情况下，仍然会让积分区域接近0的位置分布大量的采样\n一种MIS补偿方案是锐化概率分布，就是说在概率很小的地方，直接让他概率为0，不进行采样\n低于阈值的概率 → 直接砍掉 剩下的概率 → 重新归一化 结果：PDF 更“尖锐”（sharpened） Russian Roulette 跳过贡献度较小的样本，提升蒙特卡洛性能，同时保证无偏\nC通常是0，q是一个[0-1]均匀分布的随机变量，有1-q的概率继续计算，有q的概率停止\n可以看出来这样做并没有改变蒙特卡洛积分的无偏性\nSplitting 同样需要结合具体场景来学习，这里先简单过一下\n对于一个二重积分\n每次采样都需要重新计算x样本和y样本\n每次都要重新采 x\n如果 x 很贵（比如一次主光线 / 路径前缀）\nSplitting：核心思想\nx 很贵，y 很便宜，那就：\n先采一次 x 对这个 x，多采几个 y Sampling Using the Inversion Method 逆变换法采样 已知PDF情况下，解决如何按照这个PDF进行采样的方法：反演变换法 从CDF来进行采样\n一句话解释思路就是：1. 根据$\\xi$ 得到一个[0-1]直接的随机数，然后让 CDF函数 = 这个值，反向求出X\nCDF图像的斜率就是PDF值，斜率越大，说明这个采样点的概率越大，同时斜率越大的区域在Y坐标被均匀采样的概率越大，总结结果就是用一个均匀分布采样结果为 PDF越大的区域越容易被采样\n离散情况 利用均匀采样配合权重来按照权重进行采样，最终结果是按照权重来提高概率，算法本质是均匀采样\n需要前边的公式，$\\xi$ 是[0-1]之间均匀分布的随机变量， X是离散的随机变量，这个公式展示了如何从$\\xi$去随机选择哪个X作为样本。 也就是说用均匀分布的随机变量来采样X\n这一操作在CDF图像上就是$\\xi$ 取值作为Y轴位置，向右看对齐到哪一列就去Xi\n这个东西的作用是在一个权重数组中，通过均匀分布来选择其中一个 最终结果不是均匀采样的，而是按照权重大小来进行的， 但是过程本质利用的是均匀采样\n这个代码最终返回一个权重索引，满足\n连续情况 利用PDF对应的CDF来进行\n​\t从PDF采样的步骤：\n计算CDF\n还是利用均匀分布产生的随机数\n构建等式$\\xi$ = CDF(X)，反过来就算出来X是什么了 Transforming between Distributions 把上一章讲的逆变换法推演到任意情况，随机变量X的信息都知道，现在利用X对随机变量Y=f(X）进行抽样的方法\n感觉目前不会遇到这样的问题，先跳过，不然学了也是忘\n","date":"2025-12-22T13:17:37+08:00","permalink":"https://sdpyy1.github.io/p/pbrtmonte-carlo-integration/","title":"PBRT（Monte Carlo Integration）"},{"content":" 上图展示了Mesh被另外一个摄像机剔除（剩下了AABB盒）、摄像机视锥被灯光影响的分簇结果\n间接绘制 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 void VulkanRHICommandContext::DrawIndirect(RHIBufferRef argumentBuffer, uint32_t offset, uint32_t drawCount) { /* *\t绘制指令参数： //indexed draw VKAPI_ATTR void VKAPI_CALL vkCmdDrawIndexedIndirect( VkCommandBuffer commandBuffer, VkBuffer buffer, // 存储绘制参数的Buffer VkDeviceSize offset, // Buffer起始偏移 uint32_t drawCount, // 绘制的次数 uint32_t stride); // Buffer中每个绘制指令的大小 //non indexed draw VKAPI_ATTR void VKAPI_CALL vkCmdDrawIndirect( // 参数与vkCmdDrawIndirect一致 VkCommandBuffer commandBuffer, VkBuffer buffer, VkDeviceSize offset, uint32_t drawCount, uint32_t stride); 绘制Buffer参数：这个Buffer中存储的参数实际就是直接调用vkCmdDrawIndexed、vkCmdDraw需要的参数 //indexed struct VkDrawIndexedIndirectCommand { uint32_t indexCount;\t// 索引数量 uint32_t instanceCount;\t// 实例数量 uint32_t firstIndex;\t// 索引偏移 int32_t vertexOffset;\t// 顶点偏移 uint32_t firstInstance;\t// 实例偏移 }; //non indexed typedef struct VkDrawIndirectCommand { uint32_t vertexCount; // 顶点数量 uint32_t instanceCount; // 实例数量 uint32_t firstVertex; // 顶点偏移 uint32_t firstInstance; // 实例偏移 } VkDrawIndirectCommand; */ vkCmdDrawIndirect(handle, CAST\u0026lt;VulkanRHIBuffer\u0026gt;(argumentBuffer)-\u0026gt;GetHandle(), offset, drawCount, sizeof(RHIIndirectCommand)); } 在支持Multi Draw时，一次API可以提交多个IndirectCommand绘制指令，drawCount参数为执行指令的数量，这样在多个Mesh共享同一个VBO和IBO的情况下，可以做到画多种Mesh只需要一次API提交。\n这样使用的前提是Draw的context是一致的，中间不能切换（如果使用bindless的话，需要弄清楚怎么把Id传递进去）\n另外一点，这个属于新特性，老的设备不支持Multi Draw\n图片来自：https://zhuanlan.zhihu.com/p/362994106\nMesh剔除 基本思路就是使用ComputerShader提前剔除不需要渲染的DrawCall，创建Buffer-\u0026gt;准备好剔除前的数据-\u0026gt;上传到GPU-\u0026gt;用HiZ对这些instance剔除 -\u0026gt;写buffer-\u0026gt;传给api去绘制\n这里的Hiz是用的上一帧的Hiz，毕竟还没处理好Mesh信息，这一帧还没有深度信息\n是否支持实例化？\n增加实例化后，每个RHIIndirectCommand的instanceCount将不再是1，也就是说一个DrawCallBuffer需要包含多个实例。 但剔除并不是连续操作，比如实例100、101、102、103合并到一个DrawCallBuffer后，如果101被剔除。剩下100、102。DrawCallBuffer很好改（实例数量-1，起始实例仍然是100），但是因为101被剔除，那顶点着色器就无法连续操作。必须添加额外信息Buffer来支持。让顶点着色器知道自己处理的是哪个实例！总之，考虑到实例化会大大增加流程的复杂性。不考虑合并实例。\n// TODO:DrawIndirectCountKHR 可以间接填写Buffer的数量，这样的话，可以在剔除时把没有被剔除的Buffer紧密排布，并且更新BufferCount,这样的好处就是剔除后的DrawCall不会像老方法一样，生成一条空的DrawCall\n最终架构：\nCPU端：每个Pass收集自己需要的MeshBatch，将MeshBatch按照PSO进行分类。相同PSO的MeshBatch最终被放在同一个MeshDrawCommand，通过一次DrawCall全部上传，同时把所有对应的间接渲染指令组成一个Buffer，一并上传至GPU（并且携带InstanceCount和CullingType用于GPU执行不同的剔除流程） 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 enum CullingType :uint32_t { CULLING_TYPE_BASE, CULLING_TYPE_DIRECTIONLIGHT_SHADOW, CULLING_TYPE_POINTLIGHT_SHADOW, CULLING_TYPE_MAX_CNT }; /* 为了方便剔除时拿到详细信息，上传的Buffer不能只包含绘制指令,还需要记录实例数量,如果后续需要更多信息，可以扩展这个结构体 */ struct MeshIndirectDrawData { uint32_t instanceCount; CullingType passType; uint32_t _padding[2]; std::array\u0026lt;RHIIndirectCommand, MAX_PER_FRAME_INSTANCE_SIZE\u0026gt; indirectCommands; }; /* 最终定义一次DrawCall需要的信息 1. 使用的PSO 2. 间接绘制Buffer的Batch（PSO一致的绘制指令可以一次性全部上传） */ struct MeshDrawCommand { RHIGraphicsPipelineRef pipeline; IndexRange meshCommandRange = { 0, 0 }; }; class MeshPassProcessor { public: void Init(CullingType PassType); void Process(const std::vector\u0026lt;MeshBatch\u0026gt;\u0026amp; drawBatches); void Draw(RHICommandListRef command); void AddBatch(const MeshBatch\u0026amp; batch) { m_MeshBatches.push_back(batch); } void OnBuildDrawCommands(RHIGraphicsPipelineRef pipeline, std::vector\u0026lt;MeshBatch\u0026gt;\u0026amp; meshBatch); uint32_t GetDrawCommandCount() { return m_MeshBatches.size(); } RHIBufferRef GetMeshIndirectDrawDataBuffer() { return m_MeshIndirectDrawDataBuffer[APP_FRAMEINDEX]-\u0026gt;GetRHIBuffer(); } protected: virtual void MeshPassProcessor::AddMeshBatch(const MeshBatch\u0026amp; batch) = 0; virtual RHIGraphicsPipelineRef OnCreatePipeline(const DrawPipelineState\u0026amp; first) = 0; private: void MapMeshBatches(MeshBatch\u0026amp; batch); private: std::vector\u0026lt;MeshBatch\u0026gt; m_MeshBatches; // 收集当前Pass需要的batch CullingType m_PassType; std::array\u0026lt;std::shared_ptr\u0026lt;RenderBuffer\u0026lt;MeshIndirectDrawData\u0026gt;\u0026gt;, FRAMES_IN_FLIGHT\u0026gt; m_MeshIndirectDrawDataBuffer; std::map\u0026lt;DrawPipelineState, std::vector\u0026lt;MeshBatch\u0026gt;\u0026gt; m_MeshBatchMap; std::vector\u0026lt;MeshDrawCommand\u0026gt; m_MeshDrawCommands; // 存储这个是为了Draw的时候遍历 std::vector\u0026lt;RHIIndirectCommand\u0026gt; m_IndirectCommands; // 存储这个是为了把Commands一口气上传 }; using MeshPassProcessorRef = std::shared_ptr\u0026lt;MeshPassProcessor\u0026gt;; 渲染时，通过MeshDrawCommand中提前记录的偏移，直接指定Buffer的offset和DrawCount(此时的Buffer已经被剔除了，被提出的渲染指令实例数量被设置为0) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void MeshPassProcessor::Draw(RHICommandListRef command) { for (auto\u0026amp; drawCommand : m_MeshDrawCommands) { auto [w, h] = APP_WINDOWSIZE; command-\u0026gt;SetGraphicsPipeline(drawCommand.pipeline); if (drawCommand.meshCommandRange.size \u0026gt; 0) { command-\u0026gt;DrawIndirect( m_MeshIndirectDrawDataBuffer[APP_FRAMEINDEX]-\u0026gt;GetRHIBuffer(), 4 * sizeof(uint32_t) + drawCommand.meshCommandRange.begin * sizeof(RHIIndirectCommand), drawCommand.meshCommandRange.size); } } } GPU端：通过 gl_GlobalInvocationID.x获取实例ID，通过gl_GlobalInvocationID.y获取剔除类型，将不可见的实例buffer的实例数量设置为0\n1 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 #version 450 core #include \u0026#34;../common/common.glsl\u0026#34; #include \u0026#34;../common/constant.glsl\u0026#34; #include \u0026#34;../common/intersection.glsl\u0026#34; #ifdef COMPUTE_SHADER const uint MESH_PASS_TYPE_BASE = 0u; const uint MESH_PASS_TYPE_DIRECTIONLIGHT_SHADOW = 1u; const uint MESH_PASS_TYPE_POINTLIGHT_SHADOW = 2u; #define LOCAL_X 32 // instance #define LOCAL_Y 1 // MeshPassType #define LOCAL_Z 1 layout(set = 1,binding = 0) buffer drawbuffer{ uint instanceCount; uint passType; uint _padding[2]; RHIIndirectCommand buffers[MAX_PER_FRAME_INSTANCE_SIZE]; } ALL_CULLING_BUFFERS[4]; layout(local_size_x = LOCAL_X, local_size_y = LOCAL_Y, local_size_z = LOCAL_Z) in; void main() { uint threadInstanceId = gl_GlobalInvocationID.x; uint passTypeId = gl_GlobalInvocationID.y; if(threadInstanceId \u0026gt;= ALL_CULLING_BUFFERS[passTypeId].instanceCount){ return; } uint instanceId = ALL_CULLING_BUFFERS[passTypeId].buffers[threadInstanceId].firstInstance; // 摄像机剔除 if(ALL_CULLING_BUFFERS[passTypeId].passType == MESH_PASS_TYPE_BASE){ }else if(ALL_CULLING_BUFFERS[passTypeId].passType == MESH_PASS_TYPE_DIRECTIONLIGHT_SHADOW){ // 定向光剔除 ALL_CULLING_BUFFERS[passTypeId].buffers[threadInstanceId].instanceCount = 0u; }else if(ALL_CULLING_BUFFERS[passTypeId].passType == MESH_PASS_TYPE_POINTLIGHT_SHADOW){ // 点光剔除 } } #endif 剩下的工作就是各种求交测试，判断可见性了\n相交判断 摄像机的范围定义为视锥Frustum，一个平面定义为$ax + by + cz + d = 0$,每个平面可以用glm::vec4来表示，摄像机的视锥范围就可以用6个平面代替，根据平面方程的性质，(a,b,c)就是这个平面的法线\n获取视锥平面 首先需要定义视锥，视锥体由六个面组成\n使用union来方便给Shader进行统一架构，因为GPU剔除时并不需要知道当前判断的是哪个平面的相交，只需要一次遍历6个面即可\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 struct Frustum { union { struct { glm::vec4 planeRight; glm::vec4 planeLeft; glm::vec4 planeTop; glm::vec4 planeBottom; glm::vec4 planeNear; glm::vec4 planeFar; }; glm::vec4 planes[6]; }; }; 下面工作就是如何计算当前摄像机的6个平面了\n如何构造一个平面？\n法线量 + 一个点 定义一个平面 三个点确定一个平面（其实通过三个点构造两个向量，对两个向量叉乘即可得到法向量，这样也就转化成了第一种情况） 对于Far和Near两个平面，法向量: 摄像机的forward向量，点: 用camera.position + forward * (near或者far的距离)\n对与上下左右四个平面，摄像机的位置始终经过四个平面，点就确定了。法向量需要叉乘来实现，具体步骤就是先计算远平面的4个交点的坐标，与摄像机位置构成两个向量叉乘后得到法向量\n求Far平面坐标用Fov来求\n上面的方法从理论上很好理解，但是下面的方法是直接从viewPorj矩阵中计算出来6个平面的vec4\n下面这种方案是在寻找对空间中点的约束，来构造平面方程。 约束条件就是 视锥平面上任何一点（世界坐标系下）进行VP变换后，要在NDC范围的边界上，从而构建一个等式求解的\n首先VP矩阵的意义就是把世界坐标下的点$\\vec{P}=(x,y,z,1)$转移到NDC空间，经过变换后的点表示为$\\vec{P_{clip}}=(x_c, y_c, z_c, w_c)$\n以右裁剪面为例。$(x_c = w_c)$是NDC最大支持的右边界（这个本质就是透视除法后，xc=1是最大边界）。\n再来看VP矩阵 * 一个列向量（世界空间顶点位置），计算过程就是（注意这里的取值是按照列主序进行的）\n把计算过程带入$(x_c = w_c)$得到\n这也就是说世界空间点P与VP矩阵满足上边这个关系的点都在视锥的右面。把这个约束展开\n也就得到了平面方程（本质就是空间中的点满足这个约束的点集组成的平面，这种方法就是在寻找这种约束）\n1 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 57 58 59 60 61 62 63 64 65 66 67 Frustum CreateFrustumFromMatrix(const glm::mat4\u0026amp; VP) { Frustum frustum; // 右平面 frustum.planeRight = glm::vec4( VP[0][3] - VP[0][0], VP[1][3] - VP[1][0], VP[2][3] - VP[2][0], VP[3][3] - VP[3][0] ); // 左平面 frustum.planeLeft = glm::vec4( VP[0][3] + VP[0][0], VP[1][3] + VP[1][0], VP[2][3] + VP[2][0], VP[3][3] + VP[3][0] ); // 上平面 frustum.planeTop = glm::vec4( VP[0][3] - VP[0][1], VP[1][3] - VP[1][1], VP[2][3] - VP[2][1], VP[3][3] - VP[3][1] ); // 下平面 frustum.planeBottom = glm::vec4( VP[0][3] + VP[0][1], VP[1][3] + VP[1][1], VP[2][3] + VP[2][1], VP[3][3] + VP[3][1] ); // 远平面 frustum.planeFar = glm::vec4( VP[0][3] - VP[0][2], VP[1][3] - VP[1][2], VP[2][3] - VP[2][2], VP[3][3] - VP[3][2] ); // 近平面 frustum.planeNear = glm::vec4( VP[0][3] + VP[0][2], VP[1][3] + VP[1][2], VP[2][3] + VP[2][2], VP[3][3] + VP[3][2] ); // 归一化 auto normalizePlane = [](glm::vec4\u0026amp; p) { float len = glm::length(glm::vec3(p)); p /= len; }; normalizePlane(frustum.planeRight); normalizePlane(frustum.planeLeft); normalizePlane(frustum.planeTop); normalizePlane(frustum.planeBottom); normalizePlane(frustum.planeNear); normalizePlane(frustum.planeFar); return frustum; } 上边第二个方案相比第一个方案计算量就少很多了\n视锥与AABB相交 计算AABB的8个角点，是否都在视锥体外部\n判断思路就是，如果8个角点都在面的同一侧，那么整个AABB盒都在面的一侧（与面没有相交）\n判断如果AABB盒在6个面的同一侧，那他肯定就没有相交\n首先注意，不能反过来求是否有角点在视锥体内部，来判断相交，原因如下图，D的角点都在外部，但是他在视锥体内部\n如何判断一个点在平面的哪一侧呢？\n在下图场景中，OA与法线的cos即可判断，大于0不就是与法线在同一侧呗，或者直接把点带入平面方程，如果结果大于0，那他就在法向量的同侧\n如果8个点在任意一个视锥面的外侧，那这个AABB盒就在视锥体外侧\n同样，以上方法是逻辑上很清晰的代码，但是实践中为了效率，有更好的方法\nSAT 分离轴定理 对于两个凸几何体 A 和 B，若存在一条直线（轴），使得 A 和 B 在这条轴上的投影互不重叠，则称这条轴为「分离轴」，此时 A 和 B 不相交；若不存在这样的分离轴，则 A 和 B 相交（或包含）。也就是说只要找到一条轴能分离，那两个物体肯定不相交\n对于很规则的形状，只需要依次在每条边的垂直线做投影即可\n注意不适用于凹多面体\n在当前场景下，视锥的分离轴就只有6个（6个平面的法向量）\n所有AABB盒与平面的相交判断，集中在平面的法向量这条分离轴上。十分精炼的代码还是需要一步步理解\n首先平面在分离轴上如何定义？\n因为分离轴是平面的法线，所以在分离轴上来看，平面是一个点。\n那如何表达平面在分离轴的位置呢？（也就是说得先定义一个分离轴的原点） 把原点作为分离轴的原点 （在写这个的时候忽略了一个点，法线肯定是过原点来定义的，所以分离轴肯定是过原点的，另外分离轴的空间位置是没有意义的，因为投影后结果都是一样的，所以放在原点也比较合理）\n这时平面在分离轴的坐标就是(-d) (拿一个简单的平面画图推一下就能看出来)\n到这里 分离轴代表的1D坐标系已经有了。\n下一步理解AABB如何投影到这个坐标系上\nAABB的每个点都可以定义为从000到点位置的向量，然后把他投影到了分离轴上，下一步就是求AABB在分离轴上的范围\n到这里就理解了最大偏移，也理解了项目代码中那个蜜汁操作的含义\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 /* 当前Frustum的6个平面的法向量都指向内部 这里的相交判断利用的是SAT（分离轴定理），平面的法向量方向作为分离轴，这时，整个平面在分离轴上就只是一个点， */ bool FrustumIntersectBox(Frustum frustum, BoundingBox box) { vec3 center = (box.maxBound + box.minBound) * 0.5; vec3 extent = (box.maxBound - box.minBound) * 0.5; for (int i = 0; i \u0026lt; 6; i++) { vec4 plane = frustum.planes[i]; // 这段蜜汁代码需要结合记录的博客来看，真的不理解清除，这代码写的简直是顶级防御性编程 vec3 absN = abs(plane.xyz); float radius = dot(absN, extent); // AABB中心到AABB的所有投影位置的最大距离 float distance = dot(plane.xyz, center) + plane.w; // -plane.w是平面的投影 if (distance \u0026lt; -radius) return false; } return true; } 点光源剔除 点光源的范围是一个球，只需要做一个球和AABB的相交判断\n1 2 3 4 5 6 7 8 9 10 11 bool SphereIntersectBox(BoundingSphere sphere, BoundingBox box) { vec3 closestPoint = clamp( sphere.center, box.minBound, box.maxBound ); vec3 delta = closestPoint - sphere.center; float distSq = dot(delta, delta); return distSq \u0026lt;= sphere.radius * sphere.radius; } 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 #version 450 core #include \u0026#34;../common/common.glsl\u0026#34; #include \u0026#34;../common/constant.glsl\u0026#34; #include \u0026#34;../common/intersection.glsl\u0026#34; #ifdef COMPUTE_SHADER const uint MESH_PASS_TYPE_BASE = 0u; const uint MESH_PASS_TYPE_DIRECTIONLIGHT_SHADOW = 1u; const uint MESH_PASS_TYPE_POINTLIGHT_SHADOW = 2u; #include \u0026#34;../common/gizmo.glsl\u0026#34; #define LOCAL_X 32 // instance #define LOCAL_Y 1 // MeshPassType #define LOCAL_Z 1 layout(set = 1, binding = 0) buffer drawbuffer{ uint instanceCount; uint passType; uint _padding[2]; RHIIndirectCommand buffers[MAX_PER_FRAME_INSTANCE_SIZE]; } ALL_CULLING_BUFFERS[4]; layout(local_size_x = LOCAL_X, local_size_y = LOCAL_Y, local_size_z = LOCAL_Z) in; void main() { uint threadInstanceId = gl_GlobalInvocationID.x; uint passTypeId = gl_GlobalInvocationID.y; if(threadInstanceId \u0026gt;= ALL_CULLING_BUFFERS[passTypeId].instanceCount){ return; } uint instanceId = ALL_CULLING_BUFFERS[passTypeId].buffers[threadInstanceId].firstInstance; mat4 modelMatrix = GetModelMatrix(instanceId); BoundingBox aabb = GetOriginBoundingBox(instanceId); aabb = BoundingBoxTransform(aabb,modelMatrix); // 摄像机剔除 TODO：用前一帧的HIZ进行遮挡剔除？ if(ALL_CULLING_BUFFERS[passTypeId].passType == MESH_PASS_TYPE_BASE){ Camera camera = GetCamera(); if(GetRenderSetting().renderBoundingBox == 1){ AddGizmoBoundingBox(aabb, vec4(1,0,0,1)); } bool isVisiable = FrustumIntersectBox(camera.frustum, aabb); if(!isVisiable){ ALL_CULLING_BUFFERS[passTypeId].buffers[threadInstanceId].instanceCount = 0u; } }else if(ALL_CULLING_BUFFERS[passTypeId].passType == MESH_PASS_TYPE_DIRECTIONLIGHT_SHADOW){ DirectionLight light = GetDirectionLight(); bool isVisiable = FrustumIntersectBox(light.frustum[0], aabb); // TODO: 现在只考虑一个级联的剔除（导致现在只有CSM=0距离内有阴影），也就是后三个级联的剔除用的第一个视锥，这是错的！！！解决需要想办法把当前是处理第几个级联的信息传递进来~现在架构不够灵活，还不太好传递呢~ if(!isVisiable){ ALL_CULLING_BUFFERS[passTypeId].buffers[threadInstanceId].instanceCount = 0u; } }else if(ALL_CULLING_BUFFERS[passTypeId].passType == MESH_PASS_TYPE_POINTLIGHT_SHADOW){ // 点光剔除 BoundingSphere sphere = GetPointLight(0).sphere; bool isVisiable = SphereIntersectBox(sphere, aabb); if(!isVisiable){ ALL_CULLING_BUFFERS[passTypeId].buffers[threadInstanceId].instanceCount = 0u; } } } #endif 目前的剔除完成了最简单的版本，每个Pass都会进行自己需要的Mesh的剔除，在间接渲染时拿着被修改的IndirectDrawBuffer去渲染\nCluster Based Lighting 把摄像机视锥分簇，每个簇记录会影响它的光源信息，计算光照时，先判断着色点属于哪个簇，只遍历影响它的光源\n数据结构 目前灯光存储依靠两个结构\n1 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 // 获取灯光信息的地方 struct LightInfo { uint32_t directionLightCount = 0; uint32_t pointLightCount = 0; uint32_t spotLightCount = 0; uint32_t _padding0; DirectionLight dirLights; PointLight pointLights[MAX_POINT_LIGHT_SIZE]; SpotLight spotLights[MAX_SPOT_LIGHT_SIZE]; }; // 所有的光源信息统一存储在 layout(set = 0, binding = GLORBAL_RESOURCE_BINDING_LIGHTINFO) readonly buffer LightInfoBuffer { LightInfo data; } LIGHTINFO; LightInfo GetLightInfo() { return LIGHTINFO.data; } DirectionLight GetDirectionLight() { return LIGHTINFO.data.dirLights; } PointLight GetPointLight(uint index) { return LIGHTINFO.data.pointLights[index]; } SpotLight GetSpotLight(uint index) { return LIGHTINFO.data.spotLights[index]; } uint GetPointLightCount() { return LIGHTINFO.data.pointLightCount; } uint GetSpotLightCount() { return LIGHTINFO.data.spotLightCount; } 参考下图进行架构簇，每个簇存储起始位置和数量，所有簇的灯光id信息统一存储在lightAssignBuffer中，在簇Buffer中只存储起始索引和数量\n簇的划分 先存Forward方向进行深度划分，可以采样平均切分或者对数切分\n在写Shader时疏忽了一个很早之前的知识点，线性深度和非线性深度。\n在当前场景下，如果在near-far之间定义深度，是线性深度，但是如果想通过逆变换把屏幕空间坐标转回世界坐标，必须提供非线性深度，但是直接把自己自定义的深度转为非线性后配合当前UV来计算是错的，因为深度与XY的计算是有联系的，自定义深度破坏了这种联系\n1 2 float minZ = (far - near) / LIGHT_CLUSTER_DEPTH * float(globalID.z) + near; float maxZ = (far - near) / LIGHT_CLUSTER_DEPTH * float(globalID.z + 1) + near; 所以采用射线来定义方向，再用深度来计算最终的世界位置深度\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 /* 传入自定义深度（线性深度[near-far]） */ vec3 SceenToWorldCustomDepth(vec2 uv, float viewZ, Camera camera) { vec2 ndcXY = uv * 2.0 - 1.0; vec4 ndcPos = vec4(ndcXY, 0.0, 1.0); vec4 viewPos = camera.invProj * ndcPos; viewPos /= viewPos.w; vec3 viewDir = normalize(viewPos.xyz); float t = viewZ / (-viewDir.z); vec3 finalViewPos = viewDir * t; vec4 worldPos = camera.invView * vec4(finalViewPos, 1.0); return worldPos.xyz; } 哎这种写起来太麻烦，重新看了一眼博客，它的做法是直接拿near和Far进行映射，然后再切分，也就是转为世界坐标的点都在Near和Far上，然后调整按比例调整\n最终版本，还捎带实现了多摄像机系统，为了方便调试\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 #version 450 core #include \u0026#34;../common/common.glsl\u0026#34; #include \u0026#34;../common/constant.glsl\u0026#34; #include \u0026#34;../common/intersection.glsl\u0026#34; #include \u0026#34;../common/math.glsl\u0026#34; #include \u0026#34;../common/gizmo.glsl\u0026#34; #ifdef COMPUTE_SHADER layout(set = 1, binding = 0, rg32ui) uniform uimage2DArray u_Clusters; // FORMAT_R32G32_UINT layout(set = 1, binding = 1) buffer lightIDs{ uint lightID[]; }u_lightIDs; #define THREAD_SIZE_X 8 #define THREAD_SIZE_Y 8 #define THREAD_SIZE_Z 1 layout (local_size_x = THREAD_SIZE_X, local_size_y = THREAD_SIZE_Y, local_size_z = THREAD_SIZE_Z) in; void main() { uvec3 globalID = gl_GlobalInvocationID; Camera camera; if(GetRenderSetting().ClusterLightFrustum == 1){ camera = GetDefaultCamera(); }else{ camera = GetCamera(); } camera.InverseViewProj = inverse(camera.projNoJetter * camera.view); // 后续都用的projNoJetter float w = camera.width; float h = camera.height; uint clusterX = uint((w + LIGHT_CLUSTER_GRID_SIZE - 1) / LIGHT_CLUSTER_GRID_SIZE); uint clusterY = uint((h + LIGHT_CLUSTER_GRID_SIZE - 1) / LIGHT_CLUSTER_GRID_SIZE); uint clusterZ = LIGHT_CLUSTER_DEPTH; uint clusterCount = clusterX * clusterY * clusterZ; if (globalID.x \u0026gt;= clusterX || globalID.y \u0026gt;= clusterY || globalID.z \u0026gt;= clusterZ) { return; } //////////////////////////////////////// 计算每个簇的世界坐标（先计算NDC坐标下8个顶点位置，转到世界空间即可） /////////////////////////////////////////// float ndcMinx = float(globalID.x) / float(clusterX); float ndcMiny = float(globalID.y) / float(clusterY); float ndcMaxx = (float(globalID.x) + 1) / float(clusterX); float ndcMaxy = (float(globalID.y) + 1) / float(clusterY); // 线性划分 float minZ = float(globalID.z) / float(clusterZ); float maxZ = float((globalID.z + 1)) / float(clusterZ); // 一长条的簇 vec3 p0 = SceenToWorld(vec2(ndcMinx, ndcMiny), 0, camera); vec3 p1 = SceenToWorld(vec2(ndcMinx, ndcMiny), 1, camera); vec3 p2 = SceenToWorld(vec2(ndcMinx, ndcMaxy), 0, camera); vec3 p3 = SceenToWorld(vec2(ndcMinx, ndcMaxy), 1, camera); vec3 p4 = SceenToWorld(vec2(ndcMaxx, ndcMiny), 0, camera); vec3 p5 = SceenToWorld(vec2(ndcMaxx, ndcMiny), 1, camera); vec3 p6 = SceenToWorld(vec2(ndcMaxx, ndcMaxy), 0, camera); vec3 p7 = SceenToWorld(vec2(ndcMaxx, ndcMaxy), 1, camera); // 在World下切分深度 vec3 clusterP0 = p0 + minZ * (p1-p0); vec3 clusterP1 = p0 + maxZ * (p1-p0); vec3 clusterP2 = p2 + minZ * (p3-p2); vec3 clusterP3 = p2 + maxZ * (p3-p2); vec3 clusterP4 = p4 + minZ * (p5-p4); vec3 clusterP5 = p4 + maxZ * (p5-p4); vec3 clusterP6 = p6 + minZ * (p7-p6); vec3 clusterP7 = p6 + maxZ * (p7-p6); vec3 clusterCenter = (clusterP0 + clusterP1 + clusterP2 + clusterP3 + clusterP4 + clusterP5 + clusterP6 + clusterP7) / 8; // 构建视锥 Frustum frustum; frustum.planes[0] = calculatePlane(clusterP0, clusterP2,clusterP4, clusterCenter); // 近平面 frustum.planes[1] = calculatePlane(clusterP5, clusterP7, clusterP1, clusterCenter); // 远平面 frustum.planes[2] = calculatePlane(clusterP0, clusterP1, clusterP2, clusterCenter); // 左平面 frustum.planes[3] = calculatePlane(clusterP4, clusterP6,clusterP5, clusterCenter); // 右平面 frustum.planes[4] = calculatePlane(clusterP0, clusterP4, clusterP5, clusterCenter); // 下平面 frustum.planes[5] = calculatePlane(clusterP2, clusterP3, clusterP6, clusterCenter); // 上平面 //////////////////////////////////////// 相加测试与记录数据 /////////////////////////////////////////// uint lightIDs[MAX_LIGHTS_PER_CLUSTER]; uint lightCount = 0; for(int i = 0; i \u0026lt; GetPointLightCount(); i++){ BoundingSphere sphere = GetPointLight(i).sphere; bool isVisiable = FrustumIntersectSphere(frustum, sphere); if(isVisiable){ lightIDs[lightCount++] = i; if(GetRenderSetting().ClusterLightFrustum == 1){ // debug受影响的视锥 DrawFrustumEdges(clusterP0, clusterP2,clusterP6, clusterP4, clusterP1, clusterP3, clusterP7, clusterP5, vec4(1, 0, 0, 1.0)); } } } // 记录数据 uint startOffset = atomicAdd(LIGHTINFO.data.clusterAtomicOffset,lightCount); // 它返回的是操作前的数据 for(uint i = 0; i \u0026lt; lightCount; i++){ u_lightIDs.lightID[startOffset + i] = lightIDs[i]; } imageStore(u_Clusters, ivec3(globalID), uvec4(uvec2(lightCount, startOffset), 0, 0)); } #endif 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 ivec3 GetClusterIndex(vec3 worldPos,Camera camera) { vec3 viewPos = (camera.view * vec4(worldPos, 1.0)).xyz; float zView = -viewPos.z; if (zView \u0026lt;= camera.Near) zView = camera.Near; if (zView \u0026gt;= camera.Far) zView = camera.Far; vec4 clipPos = camera.proj * vec4(viewPos, 1.0); vec3 ndcPos = clipPos.xyz / clipPos.w; // [-1, 1] vec2 screenUV = ndcPos.xy * 0.5 + 0.5; // [0, 1] vec2 pixelPos = screenUV * vec2(camera.width, camera.height); uint clusterX = uint((camera.width + LIGHT_CLUSTER_GRID_SIZE - 1) / LIGHT_CLUSTER_GRID_SIZE); uint clusterY = uint((camera.height + LIGHT_CLUSTER_GRID_SIZE - 1) / LIGHT_CLUSTER_GRID_SIZE); uint clusterZ = LIGHT_CLUSTER_DEPTH; uint x = uint(pixelPos.x / LIGHT_CLUSTER_GRID_SIZE); uint y = uint(pixelPos.y / LIGHT_CLUSTER_GRID_SIZE); x = clamp(x, 0u, clusterX - 1); y = clamp(y, 0u, clusterY - 1); float zNorm = (zView - camera.Near) / (camera.Far - camera.Near); uint z = uint(zNorm * float(clusterZ)); z = clamp(z, 0u, clusterZ - 1); return ivec3(x, y, z); } 这张图展示了Mesh剔除和灯光分簇\n","date":"2025-12-16T13:30:40+08:00","image":"https://sdpyy1.github.io/image-20251221171023893.png","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5gpu-driven/","title":"游戏引擎开发实践（GPU Driven）"},{"content":" 内容参考https://www.cnblogs.com/timlly/p/14588598.html、https://www.cnblogs.com/timlly/p/14327537.html#25-ue%E7%9A%84%E5%A4%9A%E7%BA%BF%E7%A8%8B%E6%B8%B2%E6%9F%93\n内容总结:\n游戏线程的组件如何与渲染线程的Proxy进行交互 从proxy到RHI层命令之间的转换流程 多线程渲染 这块东西，如果想深入看代码的话，需要先学习UE的多线程架构，TaskGraph什么的，目前还没看。游戏线程-\u0026gt;渲染线程-\u0026gt;RHI线程 （这个分工我的渲染器已经简单实现了一个版本，不过RHI没有单独开线程，放在渲染线程之后执行 https://github.com/sdpyy1/GameEngine-dev/tree/mutliThread）\nemmm 看到后边发现，上边说的是错的， 我实现的实际上只是RHI线程，渲染线程其实还是主线程执行的\n游戏线程的对象通常做逻辑更新，在内存中有一份持久的数据，为了避免游戏线程和渲染线程产生竞争条件，会在渲染线程额外存储一份内存拷贝，并且使用的是另外的类型，以下是UE比较常见的类型映射关系（游戏线程对象以U开头，渲染线程以F开头）\n单独划分出RHI线程，让渲染线程更稳定了。 不理解。。\n游戏线程和渲染线程的交互 先弄清楚游戏组件向SceneProxy传递数据的机制，下面内容主要是如何把组件信息传递给渲染线程，如果把组件修改信息传递给渲染线程\n三个基础概念：\n​\t- UPrimitiveComponent：图元组件，是所有可渲染或拥有物理模拟的物体父类。是CPU层裁剪的最小粒度单位\n​\t- FScene：是UWorld在渲染模块的代表。只有加入到FScene的物体才会被渲染器感知到。渲染线程拥有FScene的所有状态（游戏线程不可直接修改）。\n​\t- FPrimitiveSceneProxy：图元场景代理，是UPrimitiveComponent在渲染器的代表，镜像了UPrimitiveComponent在渲染线程的状态。\n下面也就是把这个组件传入FScene的过程，就是在渲染器的FScene中创建一个代理对象，并把收集到的信息传递给渲染线程\n1 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 // 输入一个UPrimitiveComponent组件，把他加到渲染器可感知的FScene中 void FScene::AddPrimitive(UPrimitiveComponent* Primitive) {\t// If the bulk reregister flag is set, add / remove will be handled in bulk by the FStaticMeshComponentBulkReregisterContext if (Primitive-\u0026gt;bBulkReregister) { return; } BatchAddPrimitivesInternal(MakeArrayView(\u0026amp;Primitive, 1)); } void FScene::BatchAddPrimitivesInternal(TArrayView\u0026lt;T*\u0026gt; InPrimitives){ ... for (T* Primitive : InPrimitives){ FPrimitiveSceneProxy* PrimitiveSceneProxy = nullptr; ... // 创建代理 PrimitiveSceneProxy = Primitive-\u0026gt;GetPrimitiveComponentInterface()-\u0026gt;CreateSceneProxy(); ... } // 把批量创建信息传递到渲染线程 ENQUEUE_RENDER_COMMAND（内部的lambda会放在渲染线程中执行） ... ENQUEUE_RENDER_COMMAND(AddPrimitiveCommand)( [this, CreateCommands = MoveTemp(CreateCommands)](FRHICommandListBase\u0026amp; RHICmdList) { for (const FCreateCommand\u0026amp; Command : CreateCommands) { FScopeCycleCounter Context(Command.PrimitiveSceneProxy-\u0026gt;GetStatId()); Command.PrimitiveSceneProxy-\u0026gt;SetTransform(RHICmdList, Command.RenderMatrix, Command.WorldBounds, Command.LocalBounds, Command.AttachmentRootPosition); Command.PrimitiveSceneProxy-\u0026gt;CreateRenderThreadResources(RHICmdList); // 在渲染线程中将SceneInfo加入到场景中. AddPrimitiveSceneInfo_RenderThread(Command.PrimitiveSceneInfo, Command.PreviousTransform); } }); } 下面看看创建代理对象的过程\n看这个情况，是每个组件都有自己创建场景代理的流程\n随便翻一翻他们干了什么\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 聚光组件 FLightSceneProxy* USpotLightComponent::CreateSceneProxy() const { if (IsSpotLightSupported(this)) { return new FSpotLightSceneProxy(this); } return nullptr; } // skyLight组件 FSkyLightSceneProxy* USkyLightComponent::CreateSceneProxy() const { if (ProcessedSkyTexture || IsRealTimeCaptureEnabled()) { return new FSkyLightSceneProxy(this); } return NULL; } // staticMesh组件比较复杂，不过翻到最后也是进入了\tauto* Proxy = ::new FStaticMeshSceneProxy(this, false); 他们都进入了各自的复杂的构造函数链，一直翻到最顶层\n比如灯光代理\n1 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 FLightSceneProxy::FLightSceneProxy(const ULightComponent* InLightComponent) : LightComponent(InLightComponent) , SceneInterface(InLightComponent-\u0026gt;GetScene()) , IndirectLightingScale(InLightComponent-\u0026gt;IndirectLightingIntensity) , VolumetricScatteringIntensity(FMath::Max(InLightComponent-\u0026gt;VolumetricScatteringIntensity, 0.0f)) , ShadowResolutionScale(InLightComponent-\u0026gt;ShadowResolutionScale) , ShadowBias(InLightComponent-\u0026gt;ShadowBias) , ShadowSlopeBias(InLightComponent-\u0026gt;ShadowSlopeBias) , ShadowSharpen(InLightComponent-\u0026gt;ShadowSharpen) , ContactShadowLength(InLightComponent-\u0026gt;ContactShadowLength) , ContactShadowCastingIntensity(InLightComponent-\u0026gt;ContactShadowCastingIntensity) , ContactShadowNonCastingIntensity(InLightComponent-\u0026gt;ContactShadowNonCastingIntensity) , SpecularScale(InLightComponent-\u0026gt;SpecularScale) , DiffuseScale(InLightComponent-\u0026gt;DiffuseScale) , LightGuid(InLightComponent-\u0026gt;LightGuid) , RayStartOffsetDepthScale(InLightComponent-\u0026gt;RayStartOffsetDepthScale) , IESTexture(0) , bContactShadowLengthInWS(InLightComponent-\u0026gt;ContactShadowLengthInWS ? true : false) , bMovable(InLightComponent-\u0026gt;IsMovable()) , bStaticLighting(InLightComponent-\u0026gt;HasStaticLighting()) , bStaticShadowing(InLightComponent-\u0026gt;HasStaticShadowing()) , bCastDynamicShadow(InLightComponent-\u0026gt;CastShadows\u0026amp;\u0026amp; InLightComponent-\u0026gt;CastDynamicShadows) , bCastStaticShadow(InLightComponent-\u0026gt;CastShadows\u0026amp;\u0026amp; InLightComponent-\u0026gt;CastStaticShadows) , bCastTranslucentShadows(InLightComponent-\u0026gt;CastTranslucentShadows) , bTransmission(InLightComponent-\u0026gt;bTransmission \u0026amp;\u0026amp; bCastDynamicShadow \u0026amp;\u0026amp; !bStaticShadowing) , bCastVolumetricShadow(InLightComponent-\u0026gt;bCastVolumetricShadow) , bCastHairStrandsDeepShadow(InLightComponent-\u0026gt;bCastDeepShadow) , bCastShadowsFromCinematicObjectsOnly(InLightComponent-\u0026gt;bCastShadowsFromCinematicObjectsOnly) , bForceCachedShadowsForMovablePrimitives(InLightComponent-\u0026gt;bForceCachedShadowsForMovablePrimitives) , CastRaytracedShadow(InLightComponent-\u0026gt;CastShadows == 0 ? (TEnumAsByte\u0026lt;ECastRayTracedShadow::Type\u0026gt;) ECastRayTracedShadow::Disabled : InLightComponent-\u0026gt;CastRaytracedShadow) , bAffectReflection(InLightComponent-\u0026gt;bAffectReflection) , bAffectGlobalIllumination(InLightComponent-\u0026gt;bAffectGlobalIllumination) , bAffectTranslucentLighting(InLightComponent-\u0026gt;bAffectTranslucentLighting) , bUsedAsAtmosphereSunLight(InLightComponent-\u0026gt;IsUsedAsAtmosphereSunLight()) , bUseRayTracedDistanceFieldShadows(InLightComponent-\u0026gt;bUseRayTracedDistanceFieldShadows) , bUseVirtualShadowMaps(false)\t// See below , bCastModulatedShadows(false) , bUseWholeSceneCSMForMovableObjects(false) , bSelected(InLightComponent-\u0026gt;IsSelected() || InLightComponent-\u0026gt;IsOwnerSelected()) , bAllowMegaLights(InLightComponent-\u0026gt;bAllowMegaLights) , MegaLightsShadowMethod(InLightComponent-\u0026gt;MegaLightsShadowMethod) , LightFunctionAtlasLightIndex(0) , AtmosphereSunLightIndex(InLightComponent-\u0026gt;GetAtmosphereSunLightIndex()) , AtmosphereSunDiskColorScale(InLightComponent-\u0026gt;GetAtmosphereSunDiskColorScale()) , LightType(InLightComponent-\u0026gt;GetLightType()) , LightingChannelMask(GetLightingChannelMaskForStruct(InLightComponent-\u0026gt;LightingChannels)) , StatId(InLightComponent-\u0026gt;GetStatID(true)) , ComponentName(InLightComponent-\u0026gt;GetFName()) , LevelName(InLightComponent-\u0026gt;GetOwner() ? InLightComponent-\u0026gt;GetOwner()-\u0026gt;GetLevel()-\u0026gt;GetOutermost()-\u0026gt;GetFName() : NAME_None) , FarShadowDistance(0) , FarShadowCascadeCount(0) , ShadowAmount(1.0f) , SamplesPerPixel(1) , DeepShadowLayerDistribution(InLightComponent-\u0026gt;DeepShadowLayerDistribution) , IESAtlasId(~0u) staticMesh代理，这些图元相关的对象，最上层都是FPrimitiveSceneProxy::FPrimitiveSceneProxy\n1 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 FPrimitiveSceneProxy::FPrimitiveSceneProxy(const FPrimitiveSceneProxyDesc\u0026amp; InProxyDesc, FName InResourceName) : #if !(UE_BUILD_SHIPPING || UE_BUILD_TEST) WireframeColor(FLinearColor::White) ,\t#endif CustomPrimitiveData(InProxyDesc.GetCustomPrimitiveData()) ,\tTranslucencySortPriority(FMath::Clamp(InProxyDesc.TranslucencySortPriority, SHRT_MIN, SHRT_MAX)) ,\tTranslucencySortDistanceOffset(InProxyDesc.TranslucencySortDistanceOffset) ,\tMobility(InProxyDesc.Mobility) ,\tLightmapType(InProxyDesc.LightmapType) ,\tStatId() ,\tDrawInGame(InProxyDesc.IsVisible()) ,\tDrawInEditor(InProxyDesc.IsVisibleEditor()) ,\tbReceivesDecals(InProxyDesc.bReceivesDecals) ,\tbVirtualTextureMainPassDrawAlways(true) ,\tbVirtualTextureMainPassDrawNever(false) ,\tbOnlyOwnerSee(InProxyDesc.bOnlyOwnerSee) ,\tbOwnerNoSee(InProxyDesc.bOwnerNoSee) ,\tbParentSelected(InProxyDesc.ShouldRenderSelected()) ,\tbIndividuallySelected(InProxyDesc.IsComponentIndividuallySelected()) ,\tbLevelInstanceEditingState(InProxyDesc.GetLevelInstanceEditingState()) ,\tbHovered(false) ,\tbUseViewOwnerDepthPriorityGroup(InProxyDesc.bUseViewOwnerDepthPriorityGroup) ,\tStaticDepthPriorityGroup((uint8)InProxyDesc.GetStaticDepthPriorityGroup()) ,\tViewOwnerDepthPriorityGroup(InProxyDesc.ViewOwnerDepthPriorityGroup) ,\tbStaticLighting(InProxyDesc.HasStaticLighting()) ,\tbVisibleInReflectionCaptures(InProxyDesc.bVisibleInReflectionCaptures) ,\tbVisibleInRealTimeSkyCaptures(InProxyDesc.bVisibleInRealTimeSkyCaptures) ,\tbVisibleInRayTracing(InProxyDesc.bVisibleInRayTracing) ,\tbRenderInDepthPass(InProxyDesc.bRenderInDepthPass) ,\tbRenderInMainPass(InProxyDesc.bRenderInMainPass) ,\tbForceHidden(false) ,\tbCollisionEnabled(InProxyDesc.IsCollisionEnabled()) ,\tbTreatAsBackgroundForOcclusion(InProxyDesc.bTreatAsBackgroundForOcclusion) ,\tbSupportsParallelGDME(true) ,\tbSinglePassGDME(false) ,\tbVisibleInLumenScene(false) ,\tbOpaqueOrMasked(true) ,\tbCanSkipRedundantTransformUpdates(true) ,\tbGoodCandidateForCachedShadowmap(true) ,\tbNeedsUnbuiltPreviewLighting(!InProxyDesc.IsPrecomputedLightingValid()) ,\tbHasValidSettingsForStaticLighting(InProxyDesc.HasValidSettingsForStaticLighting()) ,\tbWillEverBeLit(true) ... 基本上来看，主要工作是把组件信息手动填充到proxy中，来构造proxy。拷贝数据之后，游戏线程修改的是PrimitiveComponent的数据，而渲染线程修改或访问的是PrimitiveSceneProxy的数据，彼此不干扰，避免了临界区和锁的同步，也保证了线程安全。\n但在创建完之后，PrimitiveComponent是如何向PrimitiveSceneProxy更新数据的呢？\n这里涉及到UActorComponent是所有组件的父类。在UActorComponent 有一些标志来说明组件是否被被修改\n1 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 class UActorComponent : public UObject, public IInterface_AssetUserData { GENERATED_BODY() // 下面四个虚函数用于管理后边的四个uint8的状态信息，子类可以重新他们的逻辑，在这里只修改脏读状态 virtual void DoDeferredRenderUpdates_Concurrent() { (......) if(bRenderStateDirty) { RecreateRenderState_Concurrent(); } else { if(bRenderTransformDirty) { SendRenderTransform_Concurrent(); } if(bRenderDynamicDataDirty) { SendRenderDynamicData_Concurrent(); } } } virtual void CreateRenderState_Concurrent(FRegisterComponentContext* Context) { bRenderStateCreated = true; bRenderStateDirty = false; bRenderTransformDirty = false; bRenderDynamicDataDirty = false; } virtual void SendRenderTransform_Concurrent() { bRenderTransformDirty = false; } virtual void SendRenderDynamicData_Concurrent() { bRenderDynamicDataDirty = false; } ... /** Is this component in need of its whole state being sent to the renderer? */ uint8 bRenderStateDirty:1; /** Is this component\u0026#39;s transform in need of sending to the renderer? */ uint8 bRenderTransformDirty:1; /** Is this component\u0026#39;s dynamic data in need of sending to the renderer? */ uint8 bRenderDynamicDataDirty:1; /** Is this component\u0026#39;s instanced data in need of sending to the renderer? */ uint8 bRenderInstancesDirty:1;\t... } 比如灯光组件的更新\n1 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 // 灯光组件更新变换矩阵 void ULightComponent::SendRenderTransform_Concurrent() { // Update the scene info\u0026#39;s transform for this light. // 也能看出UE的设计框架是任何地方都可以访问这些全局Context GetWorld()-\u0026gt;Scene-\u0026gt;UpdateLightTransform(this); Super::SendRenderTransform_Concurrent(); } // update内部可以看出首先Scene指的就是Fscene他是渲染器感知的场景，其次，更新时传入的是proxy，这也就实现了组件更新的传递 // 另外说明SceneProxy是在这个组件内部存好指针的 void FScene::UpdateLightTransform(ULightComponent* Light) { UpdateLightInternal(Light-\u0026gt;SceneProxy, FUpdateLightTransformParameters{.LightToWorld = Light-\u0026gt;GetComponentTransform().ToMatrixNoScale(), .Position = Light-\u0026gt;GetLightPosition()}); } // 再内部，把更新信息传递到渲染线程 template \u0026lt;typename UpdatePayloadType\u0026gt; void FScene::UpdateLightInternal(FLightSceneProxy* LightSceneProxy, UpdatePayloadType\u0026amp;\u0026amp; InUpdatePayload) { if (LightSceneProxy) { FLightSceneInfo* LightSceneInfo = LightSceneProxy-\u0026gt;GetLightSceneInfo(); if (LightSceneInfo-\u0026gt;bVisible) { ENQUEUE_RENDER_COMMAND(UpdateLightTransform)( [this, LightSceneInfo, UpdatePayload = MoveTemp(InUpdatePayload)] (FRHICommandListBase\u0026amp;) mutable { FScopeCycleCounter Context(LightSceneInfo-\u0026gt;Proxy-\u0026gt;GetStatId()); SceneLightInfoUpdates-\u0026gt;Enqueue(LightSceneInfo, MoveTemp(UpdatePayload)); }); } } } 渲染流程 上一节拿捏了Proxy的创建，现在渲染器就是要收集Proxy，然后布拉布拉变成RHICommandList😄\n大致可以总结为：模型数据准备 → Scene 收集 → 可见性剔除 → DrawCall 收集 → Pass 提交 → RHI 转换 → GPU 执行\nMesh数据—→FMeshBatch—→MeshDrawCommand——\u0026gt;Pass submit → RHI translate → GPU\n顶点工厂 定义了顶点数据从CPU-\u0026gt;GPU的框架\n先过一些需要的类\nFStaticMeshDataType 定义了顶点属性\n1 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 struct FStaticMeshDataType { /** The stream to read the vertex position from. */ FVertexStreamComponent PositionComponent; // 每一项底层都是一个RHIBuffer /** The streams to read the tangent basis from. */ FVertexStreamComponent TangentBasisComponents[2]; /** The streams to read the texture coordinates from. */ TArray\u0026lt;FVertexStreamComponent, TFixedAllocator\u0026lt;MAX_STATIC_TEXCOORDS / 2\u0026gt; \u0026gt; TextureCoordinates; /** The stream to read the shadow map texture coordinates from. */ FVertexStreamComponent LightMapCoordinateComponent; /** The stream to read the vertex color from. */ FVertexStreamComponent ColorComponent; FRHIShaderResourceView* PositionComponentSRV = nullptr; FRHIShaderResourceView* TangentsSRV = nullptr; /** A SRV to manually bind and load TextureCoordinates in the vertex shader. */ FRHIShaderResourceView* TextureCoordinatesSRV = nullptr; /** A SRV to manually bind and load Colors in the vertex shader. */ FRHIShaderResourceView* ColorComponentsSRV = nullptr; uint32 ColorIndexMask = ~0u; int8 LightMapCoordinateIndex = -1; uint8 NumTexCoords = 0; uint8 LODLightmapDataIndex = 0; }; // 翻开一个StreamComponent，基本就看懂了，所以StreamComponent是存储了具体的Buffer的 struct FVertexStreamComponent { /** The vertex buffer to stream data from. If null, no data can be read from this stream. */ const FVertexBuffer* VertexBuffer = nullptr; /** The offset to the start of the vertex buffer fetch. */ uint32 StreamOffset = 0; /** The offset of the data, relative to the beginning of each element in the vertex buffer. */ uint8 Offset = 0; /** The stride of the data. */ uint8 Stride = 0; /** The type of the data read from this stream. */ TEnumAsByte\u0026lt;EVertexElementType\u0026gt; Type = VET_None; EVertexStreamUsage VertexStreamUsage = EVertexStreamUsage::Default; } //FVertexBuffer 底层就是RHI层的Buffer class FVertexBuffer : public FRenderResource { public: FBufferRHIRef VertexBufferRHI; }; using FBufferRHIRef = TRefCountPtr\u0026lt;FRHIBuffer\u0026gt;; FVertexStream也是类似FVertexStreamComponent，底层封装了一个FVertexBuffer\n1 2 3 4 5 6 7 8 9 10 11 /** * Information needed to set a vertex stream. */ struct FVertexStream { const FVertexBuffer* VertexBuffer = nullptr; uint32 Offset = 0; uint16 Stride = 0; EVertexStreamUsage VertexStreamUsage = EVertexStreamUsage::Default; uint8 Padding = 0; }; FVertexElement 描述 StreamIndex对应的Stream 与 AttributeIndex的对应关系 Attribute就是在Vulkan中是VkVertexInputAttributeDescription的location， 进一步再对应Shader就是 layout (location = 0) in vec3 inPos;\n“从哪个 Vertex Buffer（StreamIndex），在什么偏移和格式下，读数据，送到 Vertex Shader 的哪个输入槽（AttributeIndex）。”\n1 2 3 4 5 6 7 8 9 10 11 12 13 struct FVertexElement { uint8 StreamIndex; uint8 Offset; TEnumAsByte\u0026lt;EVertexElementType\u0026gt; Type; uint8 AttributeIndex; uint16 Stride; /** * Whether to use instance index or vertex index to consume the element. * eg if bUseInstanceIndex is 0, the element will be repeated for every instance. */ uint16 bUseInstanceIndex; } 在VertexFactory的FVertexFactory::InitDeclaration中，就是拿一批FVertexElement去初始化一个FVertexDeclarationRHIRef\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 拿一堆Element去创建Declaration void FVertexFactory::InitDeclaration(const FVertexDeclarationElementList\u0026amp; Elements, EVertexInputStreamType StreamType) { if (StreamType == EVertexInputStreamType::PositionOnly) { PositionDeclaration = PipelineStateCache::GetOrCreateVertexDeclaration(Elements); } else if (StreamType == EVertexInputStreamType::PositionAndNormalOnly) { PositionAndNormalDeclaration = PipelineStateCache::GetOrCreateVertexDeclaration(Elements); } else // (StreamType == EVertexInputStreamType::Default) { // Create the vertex declaration for rendering the factory normally. Declaration = PipelineStateCache::GetOrCreateVertexDeclaration(Elements); } } 上面提到的DeclarationFVertexDeclarationRHIRef PositionDeclaration，在UE也被抽象成了RHI资源，这粒度太细了。这个东西就是用来在创建PSO时使用的\n1 2 3 4 5 6 7 8 9 typedef TArray\u0026lt;struct FVertexElement,TFixedAllocator\u0026lt;MaxVertexElementCount\u0026gt; \u0026gt; FVertexDeclarationElementList; class FRHIVertexDeclaration : public FRHIResource { public: FRHIVertexDeclaration() : FRHIResource(RRT_VertexDeclaration) {} virtual bool GetInitializer(FVertexDeclarationElementList\u0026amp; Init) { return false; } virtual uint32 GetPrecachePSOHash() const { return 0; } }; 回过头再看顶点工厂内部存储了 Stream（数据从哪来，VertexBuffer） 以及 对应的RHI资源FVertexDeclarationRHIRef\n1 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 /** * Encapsulates a vertex data source which can be linked into a vertex shader. */ class FVertexFactory : public FRenderResource { public: /** * Information needed to set a vertex stream. */ struct FVertexStream { const FVertexBuffer* VertexBuffer = nullptr; uint32 Offset = 0; uint16 Stride = 0; EVertexStreamUsage VertexStreamUsage = EVertexStreamUsage::Default; uint8 Padding = 0; friend bool operator==(const FVertexStream\u0026amp; A,const FVertexStream\u0026amp; B) { return A.VertexBuffer == B.VertexBuffer \u0026amp;\u0026amp; A.Stride == B.Stride \u0026amp;\u0026amp; A.Offset == B.Offset \u0026amp;\u0026amp; A.VertexStreamUsage == B.VertexStreamUsage; } FVertexStream() { } }; typedef TArray\u0026lt;FVertexStream, TInlineAllocator\u0026lt;8\u0026gt; \u0026gt; FVertexStreamList; /** The vertex streams used to render the factory. */ FVertexStreamList Streams; private: /** The position only vertex stream used to render the factory during depth only passes. */ TArray\u0026lt;FVertexStream,TInlineAllocator\u0026lt;2\u0026gt; \u0026gt; PositionStream; TArray\u0026lt;FVertexStream, TInlineAllocator\u0026lt;3\u0026gt; \u0026gt; PositionAndNormalStream; /** The RHI vertex declaration used to render the factory normally. */ FVertexDeclarationRHIRef Declaration; /** The RHI vertex declaration used to render the factory during depth only passes. */ FVertexDeclarationRHIRef PositionDeclaration; FVertexDeclarationRHIRef PositionAndNormalDeclaration; }; 再看FLocalVertexFactory 他继承VertexFactory，定义一个最基础的VertexFactory\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /** * A vertex factory which simply transforms explicit vertex attributes from local to world space. */ class FLocalVertexFactory : public FVertexFactory { FDataType Data; // 这个东西就是来自上边提到的FStaticMeshDataType } struct FDataType : public FStaticMeshDataType { FVertexStreamComponent PreSkinPositionComponent; FRHIShaderResourceView* PreSkinPositionComponentSRV = nullptr; #if WITH_EDITORONLY_DATA const class UStaticMesh* StaticMesh = nullptr; bool bIsCoarseProxy = false; #endif }; FLocalVertexFactory 内主要的方法\n1 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 void FLocalVertexFactory::InitRHI(FRHICommandListBase\u0026amp; RHICmdList) { SCOPED_LOADTIMER(FLocalVertexFactory_InitRHI); // We create different streams based on feature level check(HasValidFeatureLevel()); // VertexFactory needs to be able to support max possible shader platform and feature level // in case if we switch feature level at runtime. const bool bCanUseGPUScene = UseGPUScene(GMaxRHIShaderPlatform, GetFeatureLevel()); const bool bUseManualVertexFetch = SupportsManualVertexFetch(GetFeatureLevel()); // If the vertex buffer containing position is not the same vertex buffer containing the rest of the data, // then initialize PositionStream and PositionDeclaration. if (Data.PositionComponent.VertexBuffer != Data.TangentBasisComponents[0].VertexBuffer) { auto AddDeclaration = [this](EVertexInputStreamType InputStreamType, bool bAddNormal) { FVertexDeclarationElementList StreamElements; StreamElements.Add(AccessStreamComponent(Data.PositionComponent, 0, InputStreamType)); bAddNormal = bAddNormal \u0026amp;\u0026amp; Data.TangentBasisComponents[1].VertexBuffer != NULL; if (bAddNormal) { StreamElements.Add(AccessStreamComponent(Data.TangentBasisComponents[1], 2, InputStreamType)); } AddPrimitiveIdStreamElement(InputStreamType, StreamElements, 1, 1); // 在这里就执行了前边提到的InitDeclaration InitDeclaration(StreamElements, InputStreamType); }; AddDeclaration(EVertexInputStreamType::PositionOnly, false); AddDeclaration(EVertexInputStreamType::PositionAndNormalOnly, true); } FVertexDeclarationElementList Elements; GetVertexElements(GetFeatureLevel(), EVertexInputStreamType::Default, bUseManualVertexFetch, Data, Elements, Streams, ColorStreamIndex); AddPrimitiveIdStreamElement(EVertexInputStreamType::Default, Elements, 13, 13); check(Streams.Num() \u0026gt; 0); InitDeclaration(Elements); check(IsValidRef(GetDeclaration())); const int32 DefaultBaseVertexIndex = 0; const int32 DefaultPreSkinBaseVertexIndex = 0; if (RHISupportsManualVertexFetch(GMaxRHIShaderPlatform) || bCanUseGPUScene) { SCOPED_LOADTIMER(FLocalVertexFactory_InitRHI_CreateLocalVFUniformBuffer); UniformBuffer = CreateLocalVFUniformBuffer(this, Data.LODLightmapDataIndex, nullptr, DefaultBaseVertexIndex, DefaultPreSkinBaseVertexIndex); } FLocalVertexFactoryLooseParameters LooseParameters; LooseParameters.FrameNumber = -1; LooseParameters.GPUSkinPassThroughPositionBuffer = GNullVertexBuffer.VertexBufferSRV; LooseParameters.GPUSkinPassThroughPreviousPositionBuffer = GNullVertexBuffer.VertexBufferSRV; LooseParameters.GPUSkinPassThroughPreSkinnedTangentBuffer = GNullVertexBuffer.VertexBufferSRV; LooseParametersUniformBuffer = TUniformBufferRef\u0026lt;FLocalVertexFactoryLooseParameters\u0026gt;::CreateUniformBufferImmediate(LooseParameters, UniformBuffer_MultiFrame); check(IsValidRef(GetDeclaration())); } 总结来看，顶点工厂的粒度是Mesh级别的，也就是说，顶点数据不一致 就得新创建一个顶点工厂\n总之 顶点工厂就是一个模型在渲染器的代表\n从FPrimitiveSceneProxy到FMeshBatch FMeshBatch是本节才接触的新概念，它它包含了绘制Pass所需的所有信息，解耦了网格Pass和FPrimitiveSceneProxy，所以FPrimitiveSceneProxy并不知道会被哪些Pass绘制。\n首先看看FMeshBatchElement，网格批次元素, 存储了FMeshBatch单个网格所需的数据\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 struct FMeshBatchElement { // 存储实例的信息比如变换矩阵、材质信息，这里是RHIBuffer，说明他是GPU端的handle FRHIUniformBuffer* PrimitiveUniformBuffer; // 网格的UniformBuffer在CPU侧的数据. const TUniformBuffer\u0026lt;FPrimitiveUniformShaderParameters\u0026gt;* PrimitiveUniformBufferResource; /** Uniform buffer containing the \u0026#34;loose\u0026#34; parameters that aren\u0026#39;t wrapped in other uniform buffers. Those parameters can be unique per mesh batch, e.g. view dependent. */ FUniformBufferRHIRef LooseParametersUniformBuffer; // 索引缓冲 const FIndexBuffer* IndexBuffer; // 实例数量 uint32 NumInstances; uint32 BaseVertexIndex; uint32 MinVertexIndex; uint32 MaxVertexIndex; int32 UserIndex; float MinScreenSize; float MaxScreenSize; ... }; 再看FMeshBatch，这里注释可以看出来，如果顶点缓冲和材质是一致的就再一个Batch中（所以自己实现的话，同一个SubMesh+一个材质，就可以定义一个Batch）\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 /** * A batch of mesh elements, all with the same material and vertex buffer */ struct FMeshBatch { TArray\u0026lt;FMeshBatchElement,TInlineAllocator\u0026lt;1\u0026gt; \u0026gt; Elements; // 包含的FMeshBatchElement /** Vertex factory for rendering, required. */ const FVertexFactory* VertexFactory; // 顶点工厂 /** Material proxy for rendering, required. */ const FMaterialRenderProxy* MaterialRenderProxy; // 材质信息 ... }; 一个FMeshBatch拥有一组FMeshBatchElement、一个顶点工厂和一个材质实例，同一个FMeshBatch的所有FMeshBatchElement共享着相同的材质和顶点缓冲（可可被视为Vertex Factory）。但通常情况（大多数情况）下，FMeshBatch只会有一个FMeshBatchElement。\n在进入渲染函数后，会执行可见性测试和剔除，以便剔除被遮挡和被隐藏的物体，在此阶段的末期会调用GatherDynamicMeshElements收集当前场景所有的FPrimitiveSceneProxy\n1 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 void FDynamicMeshElementContext::GatherDynamicMeshElementsForPrimitive(FPrimitiveSceneInfo* Primitive, uint8 ViewMask) { SCOPED_NAMED_EVENT(DynamicPrimitive, FColor::Magenta); TArray\u0026lt;int32, TInlineAllocator\u0026lt;4\u0026gt;\u0026gt; MeshBatchCountBefore; MeshBatchCountBefore.SetNumUninitialized(Views.Num()); for (int32 ViewIndex = 0; ViewIndex \u0026lt; Views.Num(); ViewIndex++) { MeshBatchCountBefore[ViewIndex] = MeshCollector.GetMeshBatchCount(ViewIndex); } MeshCollector.SetPrimitive(Primitive-\u0026gt;Proxy, Primitive-\u0026gt;DefaultDynamicHitProxyId); // If Custom Render Passes aren\u0026#39;t in use, there will be only one group, which is the common case. if (ViewFamilyGroups.Num() == 1 || Primitive-\u0026gt;Proxy-\u0026gt;SinglePassGDME()) { Primitive-\u0026gt;Proxy-\u0026gt;GetDynamicMeshElements(FirstViewFamily.AllViews, FirstViewFamily, ViewMask, MeshCollector); // 关键是这里 Proxy自己有方法封装出一个MeshElement } else { for (FViewFamilyGroup\u0026amp; Group : ViewFamilyGroups) { if (uint8 MaskedViewMask = ViewMask \u0026amp; Group.ViewSubsetMask) { Primitive-\u0026gt;Proxy-\u0026gt;GetDynamicMeshElements(FirstViewFamily.AllViews, *Group.Family, MaskedViewMask, MeshCollector); } } } for (int32 ViewIndex = 0; ViewIndex \u0026lt; Views.Num(); ViewIndex++) { FViewInfo\u0026amp; View = *Views[ViewIndex]; if (ViewMask \u0026amp; (1 \u0026lt;\u0026lt; ViewIndex)) { FDynamicPrimitive\u0026amp; DynamicPrimitive = DynamicPrimitives.Emplace_GetRef(); DynamicPrimitive.PrimitiveIndex = Primitive-\u0026gt;GetIndex(); DynamicPrimitive.ViewIndex = ViewIndex; DynamicPrimitive.StartElementIndex = MeshBatchCountBefore[ViewIndex]; DynamicPrimitive.EndElementIndex = MeshCollector.GetMeshBatchCount(ViewIndex); } } } 这里边proxy会进行GetDynamicMeshElements(),他是每个proxy子类自己实现的方法\n1 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 57 58 59 60 61 62 63 64 65 66 67 virtual void GetDynamicMeshElements(const TArray\u0026lt;const FSceneView*\u0026gt;\u0026amp; Views, const FSceneViewFamily\u0026amp; ViewFamily, uint32 VisibilityMap, class FMeshElementCollector\u0026amp; Collector) const {} // 骨骼mesh的实现 void FSkeletalMeshSceneProxy::GetDynamicMeshElements(const TArray\u0026lt;const FSceneView*\u0026gt;\u0026amp; Views, const FSceneViewFamily\u0026amp; ViewFamily, uint32 VisibilityMap, FMeshElementCollector\u0026amp; Collector) const { QUICK_SCOPE_CYCLE_COUNTER(STAT_FSkeletalMeshSceneProxy_GetMeshElements); GetMeshElementsConditionallySelectable(Views, ViewFamily, true, VisibilityMap, Collector); } void FSkeletalMeshSceneProxy::GetMeshElementsConditionallySelectable(const TArray\u0026lt;const FSceneView*\u0026gt;\u0026amp; Views, const FSceneViewFamily\u0026amp; ViewFamily, bool bInSelectable, uint32 VisibilityMap, FMeshElementCollector\u0026amp; Collector) const { if (!MeshObject) { return; } TRACE_CPUPROFILER_EVENT_SCOPE(SkeletalMesh); const FEngineShowFlags\u0026amp; EngineShowFlags = ViewFamily.EngineShowFlags; const int32 FirstLODIdx = SkeletalMeshRenderData-\u0026gt;GetFirstValidLODIdx(FMath::Max(SkeletalMeshRenderData-\u0026gt;PendingFirstLODIdx, SkeletalMeshRenderData-\u0026gt;CurrentFirstLODIdx)); if (FirstLODIdx == INDEX_NONE) { #if DO_CHECK UE_LOG(LogSkeletalMesh, Warning, TEXT(\u0026#34;Skeletal mesh %s has no valid LODs for rendering.\u0026#34;), *GetResourceName().ToString()); #endif } else { const int32 LODIndex = MeshObject-\u0026gt;GetLOD(); check(LODIndex \u0026lt; SkeletalMeshRenderData-\u0026gt;LODRenderData.Num()); const FSkeletalMeshLODRenderData\u0026amp; LODData = SkeletalMeshRenderData-\u0026gt;LODRenderData[LODIndex]; if (LODSections.Num() \u0026gt; 0 \u0026amp;\u0026amp; LODIndex \u0026gt;= FirstLODIdx) { check(SkeletalMeshRenderData-\u0026gt;LODRenderData[LODIndex].GetNumVertices() \u0026gt; 0); const FLODSectionElements\u0026amp; LODSection = LODSections[LODIndex]; check(LODSection.SectionElements.Num() == LODData.RenderSections.Num()); for (FSkeletalMeshSectionIter Iter(LODIndex, *MeshObject, LODData, LODSection); Iter; ++Iter) { const FSkelMeshRenderSection\u0026amp; Section = Iter.GetSection(); const int32 SectionIndex = Iter.GetSectionElementIndex(); const FSectionElementInfo\u0026amp; SectionElementInfo = Iter.GetSectionElementInfo(); bool bSectionSelected = false; #if WITH_EDITORONLY_DATA // TODO: This is not threadsafe! A render command should be used to propagate SelectedEditorSection to the scene proxy. if (MeshObject-\u0026gt;SelectedEditorMaterial != INDEX_NONE) { bSectionSelected = (MeshObject-\u0026gt;SelectedEditorMaterial == SectionElementInfo.UseMaterialIndex); } else { bSectionSelected = (MeshObject-\u0026gt;SelectedEditorSection == SectionIndex); } #endif // If hidden skip the draw if (MeshObject-\u0026gt;IsMaterialHidden(LODIndex, SectionElementInfo.UseMaterialIndex) || Section.bDisabled) { continue; } GetDynamicElementsSection(Views, ViewFamily, VisibilityMap, LODData, LODIndex, SectionIndex, bSectionSelected, SectionElementInfo, bInSelectable, Collector); } } } # } 代码比较复杂，总之，渲染开始后会收集所有需要渲染的FPrimitiveSceneProxy组成FMeshBatch\n从FMeshBatch到FMeshDrawCommand 紧接着进入SetupMeshPass来创建FMeshPassProcessor,这里关注到一个点，各种需要Mesh信息的Pass都会在这里统一并行处理，而不是等Pass自己渲染时才处理\nUE事先罗列了所有可能需要绘制的Pass，在SetupMeshPass阶段对需要用到的Pass并行化地生成DrawCommand\n1 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 void FSceneRenderer::SetupMeshPass(FViewInfo\u0026amp; View, FExclusiveDepthStencil::Type BasePassDepthStencilAccess, FViewCommands\u0026amp; ViewCommands, FInstanceCullingManager\u0026amp; InstanceCullingManager) { SCOPE_CYCLE_COUNTER(STAT_SetupMeshPass); const EShadingPath ShadingPath = GetFeatureLevelShadingPath(Scene-\u0026gt;GetFeatureLevel()); // 遍历所有的EMeshPass定义的pass for (int32 PassIndex = 0; PassIndex \u0026lt; EMeshPass::Num; PassIndex++) { const EMeshPass::Type PassType = (EMeshPass::Type)PassIndex; // 每个MeshPass创建一个FMeshPassProcessor FMeshPassProcessor* MeshPassProcessor = FPassProcessorManager::CreateMeshPassProcessor(ShadingPath, PassType, Scene-\u0026gt;GetFeatureLevel(), Scene, \u0026amp;View, nullptr); FParallelMeshDrawCommandPass\u0026amp; Pass = View.ParallelMeshDrawCommandPasses[PassIndex]; TArray\u0026lt;int32, TInlineAllocator\u0026lt;2\u0026gt; \u0026gt; ViewIds; ViewIds.Add(View.GPUSceneViewId); // Only apply instancing for ISR to main view passes const bool bIsMainViewPass = PassType != EMeshPass::Num \u0026amp;\u0026amp; (FPassProcessorManager::GetPassFlags(ShadingPath, PassType) \u0026amp; EMeshPassFlags::MainView) != EMeshPassFlags::None; FName PassName(GetMeshPassName(PassType)); // 并行地处理可见Pass的处理任务，创建此Pass的所有绘制命令 Pass.DispatchPassSetup( Scene, View, FInstanceCullingContext(PassName, ShaderPlatform, \u0026amp;InstanceCullingManager, ViewIds, bAllowInstanceOcclusionCulling ? View.PrevViewInfo.HZB : nullptr, InstanceCullingMode, CullingFlags), PassType, BasePassDepthStencilAccess, MeshPassProcessor, View.DynamicMeshElements, \u0026amp;View.DynamicMeshElementsPassRelevance, View.NumVisibleDynamicMeshElements[PassType], ViewCommands.DynamicMeshCommandBuildRequests[PassType], ViewCommands.DynamicMeshCommandBuildFlags[PassType], ViewCommands.NumDynamicMeshCommandBuildRequestElements[PassType], ViewCommands.MeshCommands[PassIndex]); } } } 这里展示一下所有的MeshPassEnum\n1 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 // 罗列所有需要Mesh数据的Pass，比如深度Pass namespace EMeshPass { enum Type : uint8 { DepthPass, SecondStageDepthPass, BasePass, AnisotropyPass, SkyPass, SingleLayerWaterPass, SingleLayerWaterDepthPrepass, CSMShadowDepth, VSMShadowDepth, OnePassPointLightShadowDepth, Distortion, Velocity, TranslucentVelocity, TranslucencyStandard, TranslucencyStandardModulate, TranslucencyAfterDOF, TranslucencyAfterDOFModulate, TranslucencyAfterMotionBlur, TranslucencyHoldout, /** A standalone pass to render all translucency for holdout, inferring the background visibility*/ TranslucencyAll, /** Drawing all translucency, regardless of separate or standard. Used when drawing translucency outside of the main renderer, eg FRendererModule::DrawTile. */ LightmapDensity, DebugViewMode, /** Any of EDebugViewShaderMode */ CustomDepth, MobileBasePassCSM, /** Mobile base pass with CSM shading enabled */ VirtualTexture, LumenCardCapture, LumenCardNanite, LumenTranslucencyRadianceCacheMark, LumenFrontLayerTranslucencyGBuffer, DitheredLODFadingOutMaskPass, /** A mini depth pass used to mark pixels with dithered LOD fading out. Currently only used by ray tracing shadows. */ NaniteMeshPass, MeshDecal_DBuffer, MeshDecal_SceneColorAndGBuffer, MeshDecal_SceneColorAndGBufferNoNormal, MeshDecal_SceneColor, MeshDecal_AmbientOcclusion, WaterInfoTextureDepthPass, WaterInfoTexturePass, #if WITH_EDITOR HitProxy, HitProxyOpaqueOnly, EditorLevelInstance, EditorSelection, #endif Num, NumBits = 6, }; } 看看DispatchPassSetup分发时干了什么\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 void FParallelMeshDrawCommandPass::DispatchPassSetup( FScene* Scene, const FViewInfo\u0026amp; View, FInstanceCullingContext\u0026amp;\u0026amp; InstanceCullingContext, EMeshPass::Type PassType, FExclusiveDepthStencil::Type BasePassDepthStencilAccess, FMeshPassProcessor* MeshPassProcessor, const TArray\u0026lt;FMeshBatchAndRelevance, SceneRenderingAllocator\u0026gt;\u0026amp; DynamicMeshElements, const TArray\u0026lt;FMeshPassMask, SceneRenderingAllocator\u0026gt;* DynamicMeshElementsPassRelevance, int32 NumDynamicMeshElements, TArray\u0026lt;const FStaticMeshBatch*, SceneRenderingAllocator\u0026gt;\u0026amp; InOutDynamicMeshCommandBuildRequests, TArray\u0026lt;EMeshDrawCommandCullingPayloadFlags, SceneRenderingAllocator\u0026gt; InOutDynamicMeshCommandBuildFlags, int32 NumDynamicMeshCommandBuildRequestElements, FMeshCommandOneFrameArray\u0026amp; InOutMeshDrawCommands, FMeshPassProcessor* MobileBasePassCSMMeshPassProcessor, FMeshCommandOneFrameArray* InOutMobileBasePassCSMMeshDrawCommands ) { TRACE_CPUPROFILER_EVENT_SCOPE(ParallelMdcDispatchPassSetup); check(!TaskEventRef.IsValid() \u0026amp;\u0026amp; MeshPassProcessor != nullptr \u0026amp;\u0026amp; TaskContext.PrimitiveIdBufferData == nullptr); check((PassType == EMeshPass::Num) == (DynamicMeshElementsPassRelevance == nullptr)); MaxNumDraws = InOutMeshDrawCommands.Num() + NumDynamicMeshElements + NumDynamicMeshCommandBuildRequestElements; // 设置TaskContext的数据，收集生成MeshCommand所需的数据 TaskContext.MeshPassProcessor = MeshPassProcessor; TaskContext.MobileBasePassCSMMeshPassProcessor = MobileBasePassCSMMeshPassProcessor; TaskContext.DynamicMeshElements = \u0026amp;DynamicMeshElements; TaskContext.DynamicMeshElementsPassRelevance = DynamicMeshElementsPassRelevance; TaskContext.View = \u0026amp;View; TaskContext.Scene = Scene; TaskContext.ShadingPath = GetFeatureLevelShadingPath(View.GetFeatureLevel()); TaskContext.ShaderPlatform = Scene-\u0026gt;GetShaderPlatform(); TaskContext.PassType = PassType; TaskContext.bUseGPUScene = UseGPUScene(GMaxRHIShaderPlatform, View.GetFeatureLevel()); TaskContext.bDynamicInstancing = IsDynamicInstancingEnabled(View.GetFeatureLevel()); TaskContext.bReverseCulling = View.bReverseCulling; TaskContext.bRenderSceneTwoSided = View.bRenderSceneTwoSided; TaskContext.BasePassDepthStencilAccess = BasePassDepthStencilAccess; TaskContext.DefaultBasePassDepthStencilAccess = Scene-\u0026gt;DefaultBasePassDepthStencilAccess; TaskContext.NumDynamicMeshElements = NumDynamicMeshElements; TaskContext.NumDynamicMeshCommandBuildRequestElements = NumDynamicMeshCommandBuildRequestElements; // Only apply instancing for ISR to main view passes const bool bIsMainViewPass = PassType != EMeshPass::Num \u0026amp;\u0026amp; (FPassProcessorManager::GetPassFlags(TaskContext.ShadingPath, TaskContext.PassType) \u0026amp; EMeshPassFlags::MainView) != EMeshPassFlags::None; // GPUCULL_TODO: Note the InstanceFactor is ignored by the GPU-Scene supported instances, but is used for legacy primitives. TaskContext.InstanceFactor = (bIsMainViewPass \u0026amp;\u0026amp; View.IsInstancedStereoPass()) ? 2 : 1; TaskContext.InstanceCullingContext = MoveTemp(InstanceCullingContext); // 设置基于view的透明排序键 TaskContext.TranslucencyPass = ETranslucencyPass::TPT_MAX; TaskContext.TranslucentSortPolicy = View.TranslucentSortPolicy; TaskContext.TranslucentSortAxis = View.TranslucentSortAxis; TaskContext.ViewOrigin = View.ViewMatrices.GetViewOrigin(); TaskContext.ViewMatrix = View.ViewMatrices.GetViewMatrix(); TaskContext.PrimitiveBounds = \u0026amp;Scene-\u0026gt;PrimitiveBounds; switch (PassType) { case EMeshPass::TranslucencyStandard: TaskContext.TranslucencyPass\t= ETranslucencyPass::TPT_TranslucencyStandard; break; case EMeshPass::TranslucencyStandardModulate: TaskContext.TranslucencyPass\t= ETranslucencyPass::TPT_TranslucencyStandardModulate; break; case EMeshPass::TranslucencyAfterDOF: TaskContext.TranslucencyPass\t= ETranslucencyPass::TPT_TranslucencyAfterDOF; break; case EMeshPass::TranslucencyAfterDOFModulate: TaskContext.TranslucencyPass\t= ETranslucencyPass::TPT_TranslucencyAfterDOFModulate; break; case EMeshPass::TranslucencyAfterMotionBlur: TaskContext.TranslucencyPass\t= ETranslucencyPass::TPT_TranslucencyAfterMotionBlur; break; case EMeshPass::TranslucencyHoldout: TaskContext.TranslucencyPass\t= ETranslucencyPass::TPT_TranslucencyHoldout; break; case EMeshPass::TranslucencyAll: TaskContext.TranslucencyPass\t= ETranslucencyPass::TPT_AllTranslucency; break; } Swap(TaskContext.MeshDrawCommands, InOutMeshDrawCommands); Swap(TaskContext.DynamicMeshCommandBuildRequests, InOutDynamicMeshCommandBuildRequests); Swap(TaskContext.DynamicMeshCommandBuildFlags, InOutDynamicMeshCommandBuildFlags); if (TaskContext.ShadingPath == EShadingPath::Mobile \u0026amp;\u0026amp; TaskContext.PassType == EMeshPass::BasePass) { Swap(TaskContext.MobileBasePassCSMMeshDrawCommands, *InOutMobileBasePassCSMMeshDrawCommands); } else { check(MobileBasePassCSMMeshPassProcessor == nullptr \u0026amp;\u0026amp; InOutMobileBasePassCSMMeshDrawCommands == nullptr); } // 真正去分发任务 if (MaxNumDraws \u0026gt; 0) { // Preallocate resources on rendering thread based on MaxNumDraws. TaskContext.PrimitiveIdBufferDataSize = TaskContext.InstanceFactor * MaxNumDraws * sizeof(int32); TaskContext.PrimitiveIdBufferData = FMemory::Malloc(TaskContext.PrimitiveIdBufferDataSize); #if DO_GUARD_SLOW FMemory::Memzero(TaskContext.PrimitiveIdBufferData, TaskContext.PrimitiveIdBufferDataSize); #endif // DO_GUARD_SLOW TaskContext.MeshDrawCommands.Reserve(MaxNumDraws); TaskContext.TempVisibleMeshDrawCommands.Reserve(MaxNumDraws); const bool bExecuteInParallel = FApp::ShouldUseThreadingForPerformance() \u0026amp;\u0026amp; CVarMeshDrawCommandsParallelPassSetup.GetValueOnRenderThread() \u0026gt; 0 \u0026amp;\u0026amp; GIsThreadedRendering; // Rendering thread is required to safely use rendering resources in parallel. // 如果是并行方式, 便创建并行任务实例并加入TaskGraph系统执行. if (bExecuteInParallel) { if (IsOnDemandShaderCreationEnabled()) { TaskEventRef = TGraphTask\u0026lt;FMeshDrawCommandPassSetupTask\u0026gt;::CreateTask().ConstructAndDispatchWhenReady(TaskContext); } else { FGraphEventArray DependentGraphEvents; DependentGraphEvents.Add(TGraphTask\u0026lt;FMeshDrawCommandPassSetupTask\u0026gt;::CreateTask().ConstructAndDispatchWhenReady(TaskContext)); TaskEventRef = TGraphTask\u0026lt;FMeshDrawCommandInitResourcesTask\u0026gt;::CreateTask(\u0026amp;DependentGraphEvents).ConstructAndDispatchWhenReady(TaskContext); } } else { QUICK_SCOPE_CYCLE_COUNTER(STAT_MeshPassSetupImmediate); FMeshDrawCommandPassSetupTask Task(TaskContext); Task.AnyThreadTask(); if (!IsOnDemandShaderCreationEnabled()) { FMeshDrawCommandInitResourcesTask DependentTask(TaskContext); DependentTask.AnyThreadTask(); } } // This work needs to be deferred until at least BuildRenderingCommands (to ensure the DynamicPrimitiveCollector is uploaded), so we use the async mechanism either way auto FinalizeInstanceCullingSetup = [this, Scene](FInstanceCullingContext\u0026amp; InstanceCullingContext) { WaitForMeshPassSetupTask(); #if DO_CHECK for (const FVisibleMeshDrawCommand\u0026amp; VisibleMeshDrawCommand : TaskContext.MeshDrawCommands) { if (VisibleMeshDrawCommand.PrimitiveIdInfo.bIsDynamicPrimitive) { uint32 PrimitiveIndex = VisibleMeshDrawCommand.PrimitiveIdInfo.DrawPrimitiveId \u0026amp; ~GPrimIDDynamicFlag; TaskContext.View-\u0026gt;DynamicPrimitiveCollector.CheckPrimitiveProcessed(PrimitiveIndex, Scene-\u0026gt;GPUScene); } } #endif InstanceCullingContext.SetDynamicPrimitiveInstanceOffsets(TaskContext.View-\u0026gt;DynamicPrimitiveCollector.GetInstanceSceneDataOffset(), TaskContext.View-\u0026gt;DynamicPrimitiveCollector.NumInstances()); }; TaskContext.InstanceCullingContext.BeginAsyncSetup(FinalizeInstanceCullingSetup); } } 上边设置的context会被放在FMeshDrawCommandPassSetupTask中，并执行createTask, 所以Task是这个流程中的关键\n1 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 // 并行设置网格绘制指令的任务. 包含动态网格绘制命令的生成, 排序, 合并等. class FMeshDrawCommandPassSetupTask { public: // context封装到这里了 FMeshDrawCommandPassSetupTask(FMeshDrawCommandPassSetupTaskContext\u0026amp; InContext) : Context(InContext) { } ... // 生成动态和静态网格绘制指令(通过MeshPassProcessor将FMeshBatch转换成MeshDrawCommand). GenerateDynamicMeshDrawCommands( Context.View, Context.ShadingPath, Context.PassType, Context.MeshPassProcessor, *Context.DynamicMeshElements, Context.DynamicMeshElementsPassRelevance, Context.NumDynamicMeshElements, Context.DynamicMeshCommandBuildRequests, Context.DynamicMeshCommandBuildFlags, Context.NumDynamicMeshCommandBuildRequestElements, Context.MeshDrawCommands, Context.MeshDrawCommandStorage, Context.MinimalPipelineStatePassSet, Context.NeedsShaderInitialisation // 开始处理生成的MeshDrawCommands if (Context.MeshDrawCommands.Num() \u0026gt; 0) { // 用view相关的数据更新网格排序键. 排序键的类型是FMeshDrawCommandSortKey, 包含了BasePass和透明的键值, 其中透明物体的排序以其到摄像机的距离为依据. 点进去看发现他处理了距离信息并且把他写入SortKey，在下边才真正开始排序 UpdateTranslucentMeshSortKeys( Context.TranslucentSortPolicy, Context.TranslucentSortAxis, Context.ViewOrigin, Context.ViewMatrix, *Context.PrimitiveBounds, Context.TranslucencyPass, bInverseSorting, Context.MeshDrawCommands ); // 执行MeshDrawCommand的排序, FCompareFMeshDrawCommands首先以FMeshDrawCommandSortKey作为排序依据, 其次再用StateBucketId. { QUICK_SCOPE_CYCLE_COUNTER(STAT_SortVisibleMeshDrawCommands); Context.MeshDrawCommands.Sort(FCompareFMeshDrawCommands()); } } ... } Context.MeshDrawCommands.Sort(FCompareFMeshDrawCommands()); Sort就是一个基础的方法，传入的是一个比较器\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 struct FCompareFMeshDrawCommands { FORCEINLINE bool operator() (const FVisibleMeshDrawCommand\u0026amp; A, const FVisibleMeshDrawCommand\u0026amp; B) const { // First order by a sort key. if (A.SortKey != B.SortKey) { return A.SortKey \u0026lt; B.SortKey; } // Next order by instancing bucket. if (A.StateBucketId != B.StateBucketId) { return A.StateBucketId \u0026lt; B.StateBucketId; } return false; } }; 所以FMeshDrawCommands的排序就是主要依赖SortKey的。 下面看一下这个SortKey的设计。 他是一个union，也就是说有三种排序模式只能三选一，越靠前的数据优先级越高\n1 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 /** FVisibleMeshDrawCommand sort key. */ class FMeshDrawCommandSortKey { public: union { uint64 PackedData; struct { uint64 VertexShaderHash\t: 16; // Order by vertex shader\u0026#39;s hash. uint64 PixelShaderHash\t: 32; // Order by pixel shader\u0026#39;s hash. uint64 Background\t: 1; uint64 Masked\t: 15; // First order by masked. } BasePass; struct { uint64 MeshIdInPrimitive\t: 16; // Order meshes belonging to the same primitive by a stable id. uint64 Distance\t: 32; // Order by distance. uint64 Priority\t: 16; // First order by priority. } Translucent; struct { uint64 VertexShaderHash : 32;\t// Order by vertex shader\u0026#39;s hash. uint64 PixelShaderHash : 32;\t// First order by pixel shader\u0026#39;s hash. } Generic; }; FORCEINLINE bool operator!=(FMeshDrawCommandSortKey B) const { return PackedData != B.PackedData; } FORCEINLINE bool operator\u0026lt;(FMeshDrawCommandSortKey B) const { return PackedData \u0026lt; B.PackedData; } static RENDERER_API const FMeshDrawCommandSortKey Default; }; 下面是这些key的计算\n这里需要弄清楚这个union的工作机制\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 union { uint64_t PackedData; struct { uint64_t VertexShaderHash : 16; uint64_t PixelShaderHash : 32; uint64_t Background : 1; uint64_t Masked : 15; } BasePass; } test; test.PackedData = 0; test.BasePass.VertexShaderHash = 0x1234; std::cout \u0026lt;\u0026lt; \u0026#34;PackedData = 0x\u0026#34; \u0026lt;\u0026lt; std::hex \u0026lt;\u0026lt; std::setfill(\u0026#39;0\u0026#39;) \u0026lt;\u0026lt; std::setw(16) \u0026lt;\u0026lt; test.PackedData \u0026lt;\u0026lt; std::endl; // 这个例子打印出来是PackedData = 0x0000000000001234 所以越上边的内容，其实是越低优先级 Masked优先级最高 上边例子也说明了，最终把这些字段都填充后，直接比较两个SortKey的大小就可以进行排序了~\n接下来看看什么是FMeshDrawCommand，从注释能开出来，到RHI层之上最后一层就是FMeshDrawCommand\n1 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 /** * FMeshDrawCommand fully describes a mesh pass draw call, captured just above the RHI. FMeshDrawCommand should contain only data needed to draw. For InitViews payloads, use FVisibleMeshDrawCommand. * FMeshDrawCommands are cached at Primitive AddToScene time for vertex factories that support it (no per-frame or per-view shader binding changes). * Dynamic Instancing operates at the FMeshDrawCommand level for robustness. Adding per-command shader bindings will reduce the efficiency of Dynamic Instancing, but rendering will always be correct. * Any resources referenced by a command must be kept alive for the lifetime of the command. FMeshDrawCommand is not responsible for lifetime management of resources. For uniform buffers referenced by cached FMeshDrawCommand\u0026#39;s, RHIUpdateUniformBuffer makes it possible to access per-frame data in the shader without changing bindings. */ class FMeshDrawCommand { public: // 资源信息 Shader、顶点、索引信息 FMeshDrawShaderBindings ShaderBindings; FVertexInputStreamArray VertexStreams; FRHIBuffer* IndexBuffer; // PSO信息 FGraphicsMinimalPipelineStateId CachedPipelineId; // DrawCall用的信息 uint32 FirstIndex; uint32 NumPrimitives; uint32 NumInstances; // 包含两种模式，直接绘制或者间接绘制，间接绘制就是包含一个间接绘制Buffer union { struct { uint32 BaseVertexIndex; uint32 NumVertices; } VertexParams; struct { FRHIBuffer* Buffer; uint32 Offset; } IndirectArgs; }; ... }; 下面接着看FMeshPassProcessor 它就是把FMeshBatch转换成FMeshDrawCommands的工具\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 /** * Base class of mesh processors, whose job is to transform FMeshBatch draw descriptions received from scene proxy implementations into FMeshDrawCommands ready for the RHI command list */ class FMeshPassProcessor : public IPSOCollector { public: EMeshPass::Type MeshPassType; const FScene* RESTRICT Scene; ERHIFeatureLevel::Type FeatureLevel; const FSceneView* ViewIfDynamicMeshCommand; FMeshPassDrawListContext* DrawListContext; UE_DEPRECATED(5.4, \u0026#34;Use the below Ctor instead which provides either EMeshPass::Type or the name of the mesh pass directly.\u0026#34;) FMeshPassProcessor(const FScene* InScene, ERHIFeatureLevel::Type InFeatureLevel, const FSceneView* InViewIfDynamicMeshCommand, FMeshPassDrawListContext* InDrawListContext) : FMeshPassProcessor(EMeshPass::Num, InScene, InFeatureLevel, InViewIfDynamicMeshCommand, InDrawListContext) { } RENDERER_API FMeshPassProcessor(EMeshPass::Type InMeshPassType, const FScene* InScene, ERHIFeatureLevel::Type InFeatureLevel, const FSceneView* InViewIfDynamicMeshCommand, FMeshPassDrawListContext* InDrawListContext); RENDERER_API FMeshPassProcessor(const TCHAR* InMeshPassName, const FScene* InScene, ERHIFeatureLevel::Type InFeatureLevel, const FSceneView* InViewIfDynamicMeshCommand, FMeshPassDrawListContext* InDrawListContext); virtual ~FMeshPassProcessor() {} void SetDrawListContext(FMeshPassDrawListContext* InDrawListContext) { DrawListContext = InDrawListContext; } // FMeshPassProcessor interface // Add a FMeshBatch to the pass 是否处理一个MeshBatch是由子类实现的，也就是各个MeshPass自己定义自己需要哪些MeshBatch virtual void AddMeshBatch(const FMeshBatch\u0026amp; RESTRICT MeshBatch, uint64 BatchElementMask, const FPrimitiveSceneProxy* RESTRICT PrimitiveSceneProxy, int32 StaticMeshId = -1) = 0; // By default no PSOs collected virtual void CollectPSOInitializers(const FSceneTexturesConfig\u0026amp; SceneTexturesConfig, const FMaterial\u0026amp; Material, const FPSOPrecacheVertexFactoryData\u0026amp; VertexFactoryData, const FPSOPrecacheParams\u0026amp; PreCacheParams, TArray\u0026lt;FPSOPrecacheData\u0026gt;\u0026amp; PSOInitializers) override {} static FORCEINLINE_DEBUGGABLE ERasterizerCullMode InverseCullMode(ERasterizerCullMode CullMode) { return CullMode == CM_None ? CM_None : (CullMode == CM_CCW ? CM_CW : CM_CCW); } struct FMeshDrawingPolicyOverrideSettings { EDrawingPolicyOverrideFlags\tMeshOverrideFlags = EDrawingPolicyOverrideFlags::None; EPrimitiveType\tMeshPrimitiveType = PT_TriangleList; }; RENDERER_API static FMeshDrawingPolicyOverrideSettings ComputeMeshOverrideSettings(const FPSOPrecacheParams\u0026amp; PrecachePSOParams); RENDERER_API static FMeshDrawingPolicyOverrideSettings ComputeMeshOverrideSettings(const FMeshBatch\u0026amp; Mesh); RENDERER_API static ERasterizerFillMode ComputeMeshFillMode(const FMaterial\u0026amp; InMaterialResource, const FMeshDrawingPolicyOverrideSettings\u0026amp; InOverrideSettings); RENDERER_API static ERasterizerCullMode ComputeMeshCullMode(const FMaterial\u0026amp; InMaterialResource, const FMeshDrawingPolicyOverrideSettings\u0026amp; InOverrideSettings); template\u0026lt;typename PassShadersType, typename ShaderElementDataType\u0026gt; void BuildMeshDrawCommands( const FMeshBatch\u0026amp; RESTRICT MeshBatch, uint64 BatchElementMask, const FPrimitiveSceneProxy* RESTRICT PrimitiveSceneProxy, const FMaterialRenderProxy\u0026amp; RESTRICT MaterialRenderProxy, const FMaterial\u0026amp; RESTRICT MaterialResource, const FMeshPassProcessorRenderState\u0026amp; RESTRICT DrawRenderState, const PassShadersType\u0026amp; PassShaders, ERasterizerFillMode MeshFillMode, ERasterizerCullMode MeshCullMode, FMeshDrawCommandSortKey SortKey, EMeshPassFeatures MeshPassFeatures, const ShaderElementDataType\u0026amp; ShaderElementData); template\u0026lt;typename PassShadersType\u0026gt; void AddGraphicsPipelineStateInitializer( const FPSOPrecacheVertexFactoryData\u0026amp; VertexFactoryData, const FMaterial\u0026amp; RESTRICT MaterialResource, const FMeshPassProcessorRenderState\u0026amp; RESTRICT DrawRenderState, const FGraphicsPipelineRenderTargetsInfo\u0026amp; RESTRICT RenderTargetsInfo, const PassShadersType\u0026amp; PassShaders, ERasterizerFillMode MeshFillMode, ERasterizerCullMode MeshCullMode, EPrimitiveType PrimitiveType, EMeshPassFeatures MeshPassFeatures, bool bRequired, TArray\u0026lt;FPSOPrecacheData\u0026gt;\u0026amp; PSOInitializers); template\u0026lt;typename PassShadersType\u0026gt; static void AddGraphicsPipelineStateInitializer( const FPSOPrecacheVertexFactoryData\u0026amp; VertexFactoryData, const FMaterial\u0026amp; RESTRICT MaterialResource, const FMeshPassProcessorRenderState\u0026amp; RESTRICT DrawRenderState, const FGraphicsPipelineRenderTargetsInfo\u0026amp; RESTRICT RenderTargetsInfo, const PassShadersType\u0026amp; PassShaders, ERasterizerFillMode MeshFillMode, ERasterizerCullMode MeshCullMode, EPrimitiveType PrimitiveType, EMeshPassFeatures MeshPassFeatures, ESubpassHint SubpassHint, uint8 SubpassIndex, bool bRequired, int32 PSOCollectorIndex, TArray\u0026lt;FPSOPrecacheData\u0026gt;\u0026amp; PSOInitializers); protected: RENDERER_API FMeshDrawCommandPrimitiveIdInfo GetDrawCommandPrimitiveId( const FPrimitiveSceneInfo* RESTRICT PrimitiveSceneInfo, const FMeshBatchElement\u0026amp; BatchElement) const; RENDERER_API bool ShouldSkipMeshDrawCommand( const FMeshBatch\u0026amp; RESTRICT MeshBatch, const FPrimitiveSceneProxy* RESTRICT PrimitiveSceneProxy ) const; RENDERER_API bool PipelineVariableRateShadingEnabled() const; RENDERER_API bool HardwareVariableRateShadingSupportedByScene() const; }; 由此可见，FMeshPassProcessor的主要作用是：\nPass过滤。将该Pass无关的MeshBatch给过滤掉，比如深度Pass过滤掉透明物体。(通过子类重载AddBatch) 选择绘制命令所需的Shader及渲染状态（深度、模板、混合状态、光栅化状态等）。 收集绘制命令涉及的Shader资源绑定。 Pass的Uniform Buffer，如ViewUniformBuffer、DepthPassUniformBuffer。 顶点工厂绑定（顶点数据和索引）。 材质绑定。 Pass的与绘制指令相关的绑定。 收集Draw Call相关的参数。 从FMeshDrawCommand到RHICommandList FMeshBatch转换成FMeshDrawCommand后，每个Pass都对应了一个FMeshPassProcessor，每个FMeshPassProcessor保存了该Pass需要绘制的所有FMeshDrawCommand，以便渲染器在合适的时间触发并渲染。\n也就是说，在前边流程，已经把每个Pass需要的FMeshDrawCommand准备好了，可以进行每个Pass的绘制流程了。\n1 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 // 传入单个MeshDrawCommand， bool FMeshDrawCommand::SubmitDraw( const FMeshDrawCommand\u0026amp; RESTRICT MeshDrawCommand, const FGraphicsMinimalPipelineStateSet\u0026amp; GraphicsMinimalPipelineStateSet, const FMeshDrawCommandSceneArgs\u0026amp; SceneArgs, uint32 InstanceFactor, FRHICommandList\u0026amp; RHICmdList, FMeshDrawCommandStateCache\u0026amp; RESTRICT StateCache) { #if WANTS_DRAW_MESH_EVENTS RHI_BREADCRUMB_EVENT_CONDITIONAL(RHICmdList, GShowMaterialDrawEvents != 0, \u0026#34;%s %s (%u instances)\u0026#34; , MeshDrawCommand.DebugData.MaterialRenderProxy-\u0026gt;GetMaterialName() , MeshDrawCommand.DebugData.ResourceName , MeshDrawCommand.NumInstances * InstanceFactor ); #endif bool bAllowSkipDrawCommand = true; if (SubmitDrawBegin(MeshDrawCommand, GraphicsMinimalPipelineStateSet, SceneArgs, InstanceFactor, RHICmdList, StateCache, bAllowSkipDrawCommand)) { // 在这里边就是RHICmdList.DrawIndexedPrimitive 这种RHI指令了~ SubmitDrawEnd(MeshDrawCommand, SceneArgs, InstanceFactor, RHICmdList); return true; } return false; } // 这就进入了CommandList中 RHICmdList.DrawIndexedPrimitive( MeshDrawCommand.IndexBuffer, MeshDrawCommand.VertexParams.BaseVertexIndex, 0, MeshDrawCommand.VertexParams.NumVertices, MeshDrawCommand.FirstIndex, MeshDrawCommand.NumPrimitives, MeshDrawCommand.NumInstances * InstanceFactor ); 后续RHICommandList-\u0026gt;具体API，这个我的仓库已经写了一版，这里就不解析了\n","date":"2025-12-13T12:29:39+08:00","permalink":"https://sdpyy1.github.io/p/ue%E6%BA%90%E7%A0%81%E5%AD%A6%E4%B9%A0%E6%B8%B2%E6%9F%93%E6%9C%BA%E5%88%B6/","title":"UE源码学习（渲染机制）"},{"content":" 总体流程\n探针收集光照信息：探针向四面八方发射256条光线，计算击中点的光照并存储 （此处可以额外计算击中点的间接光照，来模拟无限弹射的全局光照效果），另外还要存储hitT用来评估可见性 混合光照信息：每个探针分配6*6的像素，每个像素代表一个法线方向，通过蒙特卡洛积分来估计法线方向上的Irrandiance（其实和IBL思路一致），并进行时域加权混合，来获得更多的样本？ 混合距离信息：每个探针分配 14*14的像素，计算每个方向上HitT的期望和平方的期望，时域混合（这两部都采用了八面体映射，另外这两步涉及到采样边界的问题，在像素外扩充一圈，用特定规则填充后存储）类似，保证在一个探针内采样时，不会采样到别的探针数据 光照Pass使用探针：根据着色点位置找到周围8个探针，从8个探针中获取对应法线位置的数据进行加权平均，有3种权重系数 如果probe离着色点较远，降低probe的权重（三线性插值系数） 如果着色点到probe的方向与表面法线的夹角过大，降低probe的权重（方向系数） 如果着色点与probe之间有较大的概率存在遮挡物，降低probe的权重（切比雪夫系数） DDGI Probe DDGIVolume 把探针组组成一个包围盒，定义探针数量和间距，以及DDGI的各种参数，这里实现了一个新的探针组件来表达它，并实现了他的可视化\nProbeTrace 这个阶段利用光追，计算并保存每个探针发射四面八方的光线的Radiance 信息（在每个击中点进行一次光照计算）\n获取volume的设置，采用y-up方向，作为Image2Darray的layer，所以每层的探针数量为probeCount.x * probeCount.z 1 2 3 4 5 6 DDGISetting ddgiSetting = RENDER_RESOURCEMANAGER-\u0026gt;GetGlobalSettingInfo().ddgiSetting; glm::uvec3 probeCount = ddgiSetting.probeCount; uint32_t raysPerProbe = ddgiSetting.raysPerProbe; uint32_t probeCountPreLayer = probeCount.x * probeCount.z; uint32_t volumeLayerCount = probeCount.y; // y-up 执行光追TraceRays(raysPerProbe，probeCountPreLayer，volumeLayerCount) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // VolumeTrace { auto\u0026amp; build = builder.CreateRayTracingPass(GetName() + \u0026#34;_VolumeTrace\u0026#34;) .PassIndex(raysPerProbe, probeCountPreLayer, volumeLayerCount) .RootSignature(m_VolumeTraceRootSignature) .ReadWrite(1, 0, 0, rayTexture, VIEW_TYPE_2D_ARRAY, { TEXTURE_ASPECT_COLOR ,0,1,0,volumeLayerCount }) .Read(1, 1, 0, dirShadowMap, VIEW_TYPE_2D_ARRAY, { TEXTURE_ASPECT_DEPTH ,0,1,0,4 }) .Read(1, 3, 0, skyBox, VIEW_TYPE_CUBE, { TEXTURE_ASPECT_COLOR ,0,1,0,6 }) .Read(1, 4, 0, irrandiance, VIEW_TYPE_2D_ARRAY, { TEXTURE_ASPECT_COLOR,0,1,0,volumeLayerCount }) .Read(1, 5, 0, distance, VIEW_TYPE_2D_ARRAY, { TEXTURE_ASPECT_COLOR,0,1,0,volumeLayerCount }) .Execute([\u0026amp;](RDGPassContext context) { RHICommandListRef command = context.command; command-\u0026gt;SetRayTracingPipeline(m_VolumeTracePipeline); command-\u0026gt;BindDescriptorSet(RENDER_RESOURCEMANAGER-\u0026gt;GetGlobalResourcePerFrameDescriptorSet(), 0); command-\u0026gt;BindDescriptorSet(context.descriptors[1], 1); command-\u0026gt;TraceRays(context.passIndex[0], context.passIndex[1], context.passIndex[2]); }); LightInfo\u0026amp; lightInfo = LightCollector::GetLightInfo(); if (lightInfo.pointLightCount \u0026gt; 0) { RDGTextureHandle pointShadowMap = builder.GetTexture(\u0026#34;Point Shadow Color[0]\u0026#34;); build.Read(1, 2, 0, pointShadowMap, VIEW_TYPE_CUBE, { TEXTURE_ASPECT_COLOR,0,1,0,6 }); } } 光追Shader\nRayGen中需要为每个探针的每条光线的击中点计算光照和阴影，并且混合历史信息（计算击中点的周围8个探针历史信息的irrandiance，作为击中点的间接光照（只计算漫反射部分））\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 struct Payload { vec3 albedo; float roughness; vec3 worldPosition; float metallic; vec3 normal; float hitT; }; void main(){ uint rayIndex = gl_LaunchIDEXT.x; uint probePlaneIndex = gl_LaunchIDEXT.y; uint planeIndex = gl_LaunchIDEXT.z; uint probeCountPrePlane = DDGIGetProbesPerPlane(volume.probeCount); // x * z（y-up） uint probeIndex = (planeIndex * probeCountPrePlane) + probePlaneIndex; uvec3 probeCoords = DDGIGetProbeCoords(probeIndex,volume); // 探针在探针网格的坐标 vec3 probeWorldPosition = DDGIGetProbeWorldPosition(probeCoords, volume); vec3 rayDirection = normalize(RTXGISphericalFibonacci(rayIndex,volume.raysPerProbe)); // 生成光线方向 // 最终每根光线的计算结果都需要存储， Texture2DArray(x:rayIndex, y:probeIndexInLayer, z:layerIndex) uvec3 outputCoords = DDGIGetRayDataTexelCoords(rayIndex,probeIndex,volume); // 启动射线 traceRayEXT(TLAS, // acceleration structure gl_RayFlagsOpaqueEXT, // rayFlags 控制光线的行为，比如是否忽略背面、是否启用 any-hit、是否可用 conservative tracing 等 0xFF, // cullMask 0xFF → 匹配所有实例 0, // sbtRecordOffset 索引到 Shader Binding Table (SBT) 的起始记录位置 1, // sbtRecordStride SBT 中每条记录的间隔（单位是记录数，不是字节） 0, // missIndex 当光线没有击中任何几何体时，使用 SBT 中 miss shader 的索引 probeWorldPosition.xyz, // ray origin 0, // ray min range rayDirection.xyz, // ray direction MAX_RAY_TRACING_DISTANCE, // TODO:这个应该换成DDGI自己的参数设置 0 // payload (location = 0) payload的位置 ); // Miss，返回天空的Radiance if(payload.hitT == -1.f){ imageStore(out_RAYDATA, ivec3(outputCoords), vec4(payload.albedo, 1e27f)); return; } // 击中背面，参考RTXGI的做法 // Make the hit distance negative to mark a backface hit for blending, probe relocation, and probe classification. // Shorten the hit distance on a backface hit by 80% to decrease the influence of the probe during irradiance sampling. if(payload.hitKind == gl_HitKindBackFacingTriangleEXT){ imageStore(out_RAYDATA, ivec3(outputCoords), vec4(vec3(0), -payload.hitT * 0.2)); return; } ///////////////////////////////////////////// // Directional Light TODO: 点光源和聚光 这里也是通过TraceRays来判断阴影的 ///////////////////////////////////////////// vec3 brdf = (payload.albedo / PI); vec3 lighting = vec3(0.f); DirectionLight dirLight = GetDirectionLight(); // 硬件在找到第一个 hit 后立即停止 | 跳过ClosestHitShader，直接返回rayGen | 所有模型都被视为不透明 const uint rayFlags =gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsSkipClosestHitShaderEXT | gl_RayFlagsOpaqueEXT; // 发射光线计算遮挡 traceRayEXT(TLAS, rayFlags, 0xFF, 0, 1, 1, payload.worldPosition, 0, -dirLight.direction, MAX_RAY_TRACING_DISTANCE, 1 ); float shadowScale = 1.0; if(shadowPayload.hitT != -1.f){ shadowScale = 0.0; } vec3 lightDirection = -normalize(dirLight.direction); float nol = max(dot(payload.normal, lightDirection), 0.f); vec3 dirLighting = nol * dirLight.radiance * shadowScale; lighting += dirLighting; vec3 diffuse = lighting * brdf; ///////////////////////////////////////////// // Indirection Light ///////////////////////////////////////////// vec3 irradiance = vec3(0); float volumeBlendWeight = DDGIGetVolumeBlendWeight(payload.worldPosition, volume); if (volumeBlendWeight \u0026gt; 0){ irradiance = DDGIGetIrrandianceByWorldPosition( payload.worldPosition, payload.normal, volume, ddgi_Irrandiance,ddgi_Distance); } // Perfectly diffuse reflectors don\u0026#39;t exist in the real world. // Limit the BRDF albedo to a maximum value to account for the energy loss at each bounce. float maxAlbedo = 0.9f; vec3 radiance = diffuse + ((min(payload.albedo, vec3(maxAlbedo, maxAlbedo, maxAlbedo)) / PI) * irradiance * volumeBlendWeight); // 最终存储rayData radiance(3) + hitT(1) imageStore(out_RAYDATA, ivec3(outputCoords), vec4(Saturate(radiance), payload.hitT)); } closestHit需要获取击中点的模型信息\n1 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 #ifdef RAYCLOSEST_HIT_SHADER layout(location = 0) rayPayloadInEXT Payload payload; hitAttributeEXT vec2 attribs; void main() { uint instanceID = gl_InstanceCustomIndexEXT; // 在构建TLAS时，给每个实例分配的ID uint primitiveID = gl_PrimitiveID; // 击中的三角形索引 vec3 barycentrics = vec3(1.0 - attribs.x - attribs.y, attribs.x, attribs.y); // 收集MeshInfo mat4 model = GetModelMatrix(instanceID); uvec3 triangleIndex = GetTriangleIndex(instanceID, primitiveID); vec4 position = GetTrianglePosition(instanceID,triangleIndex,barycentrics); vec3 meshNormal = GetTriangleMeshNormal(instanceID,triangleIndex,barycentrics); vec4 tangent = GetTriangleTangent(instanceID,triangleIndex,barycentrics); vec3 worldNormal = GetWorldNormal(meshNormal,model); vec4 worldTangent = GetWorldTangent(tangent,model); vec2 texCoord = GetTriangleTexCoord(instanceID, triangleIndex, barycentrics); vec4 worldPos = model * position; MaterialInfo material = GetMaterialInfo(instanceID); vec4 albedo = GetDiffuse(material,texCoord); vec3 normal = GetNormal(material, texCoord, worldNormal, worldTangent); vec4 emission = GetEmission(material,texCoord); albedo += emission; float roughness = GetRoughness(material,texCoord); float metallic = GetMetallic(material, texCoord); payload.worldPosition = worldPos.xyz; payload.normal = normal; payload.roughness = roughness; payload.metallic = metallic; payload.albedo = albedo.rgb; payload.hitT = gl_HitTEXT; payload.hitKind = gl_HitKindEXT; } #endif Miss处理未击中的情况，需要两个，一个处理天空，一个处理阴影判断标记（因为MissShader只支持一个Payload，好像只有RayGen可以定义多个PayLoad，其他都只能用于处理一个PayLoad）\n1 2 3 4 5 6 7 8 9 10 11 #ifdef RAYMISS_SHADER layout(location = 0) rayPayloadInEXT Payload payload; // rayPayloadInEXT 注意是InEXT layout(set = 1, binding = 3) uniform textureCube skyCube; void main() { vec3 rayDir = normalize(gl_WorldRayDirectionEXT); payload.albedo = texture(samplerCube(skyCube,SAMPLER[0]),rayDir).rgb; payload.hitT = -1; } #endif 1 2 3 4 5 6 7 8 #ifdef RAYMISS_SHADER layout(location = 1) rayPayloadInEXT ShadowPayLoad shadowPayload; void main() { shadowPayload.hitT = -1; } #endif RayTrace结束后，获得了每个探针的每条光线携带的Radiance信息\n另外还会在着色点采样它周围8个探针的光照数据（就是上一帧的记录，也是该着色点的间接光照）这样做相当于得到了采样点的间接光照\nProbeBlend 根据RayData来更新探针信息\nIrrandianceBlend 探针理论上需要存储每个方向上的半球积分来得到该位置的Irrandiance，但是存储所有方向不太可能，DDGI采用6*6的像素存储一个探针36个方向上的Irrandiance，使用时进行插值即可，另外使用八面体映射，遍历每个探针，保存一个6 * 6区域，每个区域都被映射到八面体上的一个方向，再把这个方向挪到球面，遍历所有光线与该方向的权重进行混合\n注意Texture2DArray的采样的uvw的w不是0-1,用第几层就传递几，这点疏忽了给我整麻了\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // dispatch 需要处理网格中的每一个探针，所以传入的是probeCount.x, probeCount.y, probeCount.z { // Irrandiance Blend builder.CreateComputePass(GetName() + \u0026#34;_ProbeIrrandianceBlend\u0026#34;) .ReadWrite(1, 0, 0, irrandiance, VIEW_TYPE_2D_ARRAY, { TEXTURE_ASPECT_COLOR ,0,1,0,volumeLayerCount }) .ReadWrite(1, 1, 0, rayTexture, VIEW_TYPE_2D_ARRAY, { TEXTURE_ASPECT_COLOR ,0,1,0,volumeLayerCount }) .RootSignature(m_ProbeIrrandianceBlendRootSignature) .PassIndex(probeCount.x, probeCount.z, probeCount.y) .Execute([\u0026amp;](RDGPassContext context) { RHICommandListRef command = context.command; command-\u0026gt;SetComputePipeline(m_ProbeIrrandianceBlendPipeline); command-\u0026gt;BindDescriptorSet(RENDER_RESOURCEMANAGER-\u0026gt;GetGlobalResourcePerFrameDescriptorSet(), 0); command-\u0026gt;BindDescriptorSet(context.descriptors[1], 1); command-\u0026gt;Dispatch(context.passIndex[0], context.passIndex[1], context.passIndex[2]); }) .Finish(); } 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 layout(set = 1, rgba32f, binding = 0) uniform image2DArray o_Texture; layout(set = 1, rgba32f, binding = 1) uniform image2DArray IN_RayData; // 每个探针需要8*8的区域，所以这里线程组设置就是8*8（内部6*6是数据，外部一圈是填充，为了保证双线性插值的正确性） layout(local_size_x = DDGI_PROBE_NUM_TEXELS_IRRANDIANCE, local_size_y = DDGI_PROBE_NUM_TEXELS_IRRANDIANCE, local_size_z = 1) in; void main(){ // 索引探针在RayData的位置 DDGISetting volume = GetDDGISetting(); uvec3 groupID = gl_WorkGroupID; // 对于dispatch的ID uvec3 invocationID = gl_GlobalInvocationID; // 相对于全局的调用ID 用于最后存储像素位置 uvec3 LocalInvocationID = gl_LocalInvocationID; // 相对于组内的调用ID 用于区别边界和内部 // 用LocalInvocationID是不是0，0或者 7，7 马上就能判断是不是边界 bool isBorderTexel = (LocalInvocationID.x == 0 || LocalInvocationID.x == (DDGI_PROBE_NUM_TEXELS_IRRANDIANCE_INTERIOR + 1)) || (LocalInvocationID.y == 0 || LocalInvocationID.y == (DDGI_PROBE_NUM_TEXELS_IRRANDIANCE_INTERIOR + 1)); // uint probeIndex = (groupID.y * DDGIGetProbesPerPlane(volume.probeCount)) + groupID.z * volume.probeCount.x + groupID.x ; // Early out: no probe maps to this thread 感觉不会出现这种情况 uint numProbes = (volume.probeCount.x * volume.probeCount.y * volume.probeCount.z); if (probeIndex \u0026gt;= numProbes || probeIndex \u0026lt; 0) return; vec4 result = vec4(0.0); // 首先处理内部6*6 if(!isBorderTexel){ // 对于6*6区域的一个像素，对应到探针的一个方向上，需要计算这个方向上的半球积分来存储Irrandiance // 1. 获取这个像素对应的方向 uvec3 threadCoords = uvec3(groupID.x * DDGI_PROBE_NUM_TEXELS_IRRANDIANCE_INTERIOR, groupID.y * DDGI_PROBE_NUM_TEXELS_IRRANDIANCE_INTERIOR, invocationID.z) + LocalInvocationID - uvec3(1, 1, 0); // 计算出抛开边界后当前线程是的坐标（没有边界填充的2DArray坐标） // 把6*6区域当作一个纹理，下面获得当前线程处理的位置的UV坐标[-1,1] vec2 probeOctantUV = DDGIGetNormalizedOctahedralCoordinates(uvec2(threadCoords.xy), DDGI_PROBE_NUM_TEXELS_IRRANDIANCE_INTERIOR); // 获取这个UV对应到八面体上的方向 vec3 probeRayDirection = DDGIGetOctahedralDirection(probeOctantUV); // 遍历当前探针的所有光线，为当前八面体上的方向计算irrandiance for (uint rayIndex = 0; rayIndex \u0026lt; volume.raysPerProbe; rayIndex++){ // 获取ray的方向，RTXGISphericalFibonacci是可以复现的所以不需要提前存储 vec3 rayDirection = normalize(RTXGISphericalFibonacci(rayIndex,volume.raysPerProbe)); // TODO: 优化点，因为每个探针计算这个都是一模一样的 // 当前要存储的方向与这跟光线的cos，两个方向夹角越小，权重越大 float weight = max(0.0, dot(probeRayDirection, rayDirection)); // 解析RayData中的信息 uvec3 rayDataTexCoords = DDGIGetRayDataTexelCoords(rayIndex, probeIndex, volume); vec4 storedData = imageLoad(IN_RayData, ivec3(rayDataTexCoords)); vec3 probeRayRadiance = storedData.xyz; float probeRayDistance = storedData.w; if(probeRayDistance \u0026lt; 0){ // 击中的是背面 continue; } result += vec4(probeRayRadiance * weight, weight); } // 到这里 用vec4存储了所有光线的加权和，并且w分量存储了权重和，方便计算 //////////////////////////////工程化修正///////////////////////////////////////////// float epsilon = float(volume.raysPerProbe); epsilon *= 1e-9f; result.rgb *= 1.f / (1.f * max(result.a, epsilon)); // 时域加权混合 vec4 history = imageLoad(o_Texture,ivec3(gl_GlobalInvocationID)); vec3 delta = (result.rgb - history.rgb); float hysteresis = 0.97; // TODO：混合系数是参数 if (dot(history, history) == 0) hysteresis = 0.f; float probeIrradianceEncodingGamma = 5.0f; // TODO：编码Gamma是参数 result.rgb = pow(result.rgb, vec3(1.f / probeIrradianceEncodingGamma)); vec3 irradianceSample = result.rgb; float probeIrradianceThreshold = 0.25f; // TODO：阈值是参数 if (RTXGIMaxComponent(history.rgb - result.rgb) \u0026gt; probeIrradianceThreshold) { // Lower the hysteresis when a large lighting change is detected hysteresis = max(0.f, hysteresis - 0.75f); } float probeBrightnessThreshold = 0.10f; // TODO：阈值是参数 if (RGBtoLuminance(delta) \u0026gt; probeBrightnessThreshold) { // Clamp the maximum per-update change in irradiance when a large brightness change is detected delta *= 0.25f; } const float c_threshold = 1.f / 1024.f; vec3 lerpDelta = (1.f - hysteresis) * delta; if (RTXGIMaxComponent(result.rgb) \u0026lt; RTXGIMaxComponent(history.rgb)) { lerpDelta = min(max(vec3(c_threshold), abs(lerpDelta)), abs(delta)) * sign(lerpDelta); } result = vec4(history.rgb + lerpDelta, 1.f); imageStore(o_Texture, ivec3(gl_GlobalInvocationID), result); /////////////////////////////////////////////////////////////////////////// // 边界处理 else{ // 边界 // AllMemoryBarrierWithGroupSync(); 这里有一个同步点 bool isCornerTexel = (LocalInvocationID.x == 0 || LocalInvocationID.x == (DDGI_PROBE_NUM_TEXELS_IRRANDIANCE - 1)) \u0026amp;\u0026amp; (LocalInvocationID.y == 0 || LocalInvocationID.y == (DDGI_PROBE_NUM_TEXELS_IRRANDIANCE - 1)); bool isRowTexel = (LocalInvocationID.x \u0026gt; 0 \u0026amp;\u0026amp; LocalInvocationID.x \u0026lt; (DDGI_PROBE_NUM_TEXELS_IRRANDIANCE - 1)); uvec3 copyCoordinates = uvec3(groupID.x * DDGI_PROBE_NUM_TEXELS_IRRANDIANCE, groupID.y * DDGI_PROBE_NUM_TEXELS_IRRANDIANCE, invocationID.z); if(isCornerTexel) { copyCoordinates.x += LocalInvocationID.x \u0026gt; 0 ? 1 : DDGI_PROBE_NUM_TEXELS_IRRANDIANCE_INTERIOR; copyCoordinates.y += LocalInvocationID.y \u0026gt; 0 ? 1 : DDGI_PROBE_NUM_TEXELS_IRRANDIANCE_INTERIOR; } else if(isRowTexel) { copyCoordinates.x += (DDGI_PROBE_NUM_TEXELS_IRRANDIANCE - 1) - LocalInvocationID.x; copyCoordinates.y += LocalInvocationID.y + ((LocalInvocationID.y \u0026gt; 0) ? -1 : 1); } else // Column Texel { copyCoordinates.x += LocalInvocationID.x + ((LocalInvocationID.x \u0026gt; 0) ? -1 : 1); copyCoordinates.y += (DDGI_PROBE_NUM_TEXELS_IRRANDIANCE - 1) - LocalInvocationID.y; } imageStore(o_Texture, ivec3(gl_GlobalInvocationID), imageLoad(o_Texture, ivec3(copyCoordinates))); } } DistanceBlend 对于distance，只有一些地方不一样\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 #version 450 core #include \u0026#34;../common/common.glsl\u0026#34; #include \u0026#34;../common/DDGI.glsl\u0026#34; layout(set = 1, rgba32f, binding = 0) uniform image2DArray o_Texture; layout(set = 1,rgba32f, binding = 1) uniform image2DArray IN_RayData; #ifdef COMPUTE_SHADER layout(local_size_x = DDGI_PROBE_NUM_TEXELS_DISTANCE, local_size_y = DDGI_PROBE_NUM_TEXELS_DISTANCE, local_size_z = 1) in; void main(){ DDGISetting volume = GetDDGISetting(); uvec3 groupID = gl_WorkGroupID; // 对于dispatch的ID uvec3 invocationID = gl_GlobalInvocationID; // 相对于全局的调用ID uvec3 LocalInvocationID = gl_LocalInvocationID; // 相对于组内的调用ID bool isBorderTexel = (LocalInvocationID.x == 0 || LocalInvocationID.x == (DDGI_PROBE_NUM_TEXELS_DISTANCE_INTERIOR + 1)) || (LocalInvocationID.y == 0 || LocalInvocationID.y == (DDGI_PROBE_NUM_TEXELS_DISTANCE_INTERIOR + 1)); uint probeIndex = (groupID.y * DDGIGetProbesPerPlane(volume.probeCount)) + groupID.z * volume.probeCount.x + groupID.x ; vec4 result = vec4(0.0); if(!isBorderTexel){ uvec3 threadCoords = uvec3(groupID.x * DDGI_PROBE_NUM_TEXELS_DISTANCE_INTERIOR, groupID.y * DDGI_PROBE_NUM_TEXELS_DISTANCE_INTERIOR, invocationID.z) + LocalInvocationID - uvec3(1, 1, 0); vec2 probeOctantUV = DDGIGetNormalizedOctahedralCoordinates(uvec2(threadCoords.xy), DDGI_PROBE_NUM_TEXELS_DISTANCE_INTERIOR); vec3 probeRayDirection = DDGIGetOctahedralDirection(probeOctantUV); // 遍历当前探针的所有光线 for (uint rayIndex = 0; rayIndex \u0026lt; volume.raysPerProbe; rayIndex++){ vec3 rayDirection = normalize(RTXGISphericalFibonacci(rayIndex, volume.raysPerProbe)); float weight = max(0.f, dot(probeRayDirection, rayDirection)); float probeDistanceExponent = 50.f; // TODO:参数 // Increase or decrease the filtered distance value\u0026#39;s \u0026#34;sharpness\u0026#34; weight = pow(weight, probeDistanceExponent); uvec3 rayDataTexCoords = DDGIGetRayDataTexelCoords(rayIndex, probeIndex, volume); // 光线最大能射到probe间距的1.5倍 float probeMaxRayDistance = length(volume.gridStep) * 1.5f; vec4 storedData = imageLoad(IN_RayData, ivec3(rayDataTexCoords)); float probeRayDistance = storedData.w; probeRayDistance = min(abs(probeRayDistance),probeMaxRayDistance); result += vec4(probeRayDistance * weight, (probeRayDistance * probeRayDistance) * weight, 0.f, weight); } //////////////////////////////工程化修正///////////////////////////////////////////// float epsilon = float(volume.raysPerProbe); epsilon *= 1e-9f; result.rgb *= 1.f / (1.f * max(result.a, epsilon)); // 时域加权混合 vec4 history = imageLoad(o_Texture,ivec3(gl_GlobalInvocationID)); float hysteresis = 0.97; // TODO：混合系数是参数 if (dot(history, history) == 0) hysteresis = 0.f; result = vec4(Lerp(result.rg, history.rg, hysteresis), 0.f, 1.f); //////////////////////////////工程化修正///////////////////////////////////////////// imageStore(o_Texture, ivec3(gl_GlobalInvocationID), result); return; }else{ // 边界 memoryBarrier(); // 所有全局内存类型的屏障 memoryBarrierShared(); // 对 shared 内存屏障 barrier(); //这里有一个同步点 bool isCornerTexel = (LocalInvocationID.x == 0 || LocalInvocationID.x == (DDGI_PROBE_NUM_TEXELS_DISTANCE - 1)) \u0026amp;\u0026amp; (LocalInvocationID.y == 0 || LocalInvocationID.y == (DDGI_PROBE_NUM_TEXELS_IRRANDIANCE - 1)); bool isRowTexel = (LocalInvocationID.x \u0026gt; 0 \u0026amp;\u0026amp; LocalInvocationID.x \u0026lt; (DDGI_PROBE_NUM_TEXELS_DISTANCE - 1)); uvec3 copyCoordinates = uvec3(groupID.x * DDGI_PROBE_NUM_TEXELS_DISTANCE, groupID.y * DDGI_PROBE_NUM_TEXELS_DISTANCE, invocationID.z); if(isCornerTexel) { copyCoordinates.x += LocalInvocationID.x \u0026gt; 0 ? 1 : DDGI_PROBE_NUM_TEXELS_DISTANCE_INTERIOR; copyCoordinates.y += LocalInvocationID.y \u0026gt; 0 ? 1 : DDGI_PROBE_NUM_TEXELS_DISTANCE_INTERIOR; } else if(isRowTexel) { copyCoordinates.x += (DDGI_PROBE_NUM_TEXELS_DISTANCE - 1) - LocalInvocationID.x; copyCoordinates.y += LocalInvocationID.y + ((LocalInvocationID.y \u0026gt; 0) ? -1 : 1); } else // Column Texel { copyCoordinates.x += LocalInvocationID.x + ((LocalInvocationID.x \u0026gt; 0) ? -1 : 1); copyCoordinates.y += (DDGI_PROBE_NUM_TEXELS_DISTANCE - 1) - LocalInvocationID.y; } imageStore(o_Texture, ivec3(gl_GlobalInvocationID), imageLoad(o_Texture, ivec3(copyCoordinates))); } } #endif IndirectLight 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 /* 根据一个世界坐标，计算从探针中获取的Irrandiance */ vec3 DDGIGetIrrandianceByWorldPosition(vec3 worldPosition, vec3 direction, DDGISetting volume, texture2DArray IrrdianceTexture,texture2DArray distanceTexture){ // 如果WorldPosition不在DDGIvolume内，直接返回 // if(!DDGICheckPostion(worldPosition,volume)){ // return vec3(0.f, 0.f, 0.f); // } vec3 sumIrradiance = vec3(0.0f); float sumWeight = 0.0f; vec3 irradiance = vec3(0.f, 0.f, 0.f); float accumulatedWeights = 0.f; // 得到离worldPosition最近的探针坐标 ivec3 baseProbeCoords = DDGIGetBaseProbeGridCoords(worldPosition, volume); vec3 baseProbeWorldPosition = DDGIGetProbeWorldPosition(baseProbeCoords,volume); vec3 alpha = clamp(((worldPosition - baseProbeWorldPosition) / volume.gridStep), vec3(0.f, 0.f, 0.f), vec3(1.f, 1.f, 1.f)); for(int i = 0; i \u0026lt; 8; i++){ float weight = 1.0; // 使用魔法获得一个探针😄 ivec3 offset = ivec3(i, i \u0026gt;\u0026gt; 1, i \u0026gt;\u0026gt; 2) \u0026amp; ivec3(1); ivec3 probeGridCoord = clamp(baseProbeCoords + offset, ivec3(0), ivec3(volume.probeCount) - ivec3(1)); uint probeIndex = (probeGridCoord.y * DDGIGetProbesPerPlane(volume.probeCount)) + probeGridCoord.z * volume.probeCount.x + probeGridCoord.x; vec3 probePos = DDGIGetProbeWorldPosition(probeGridCoord, volume); // 方向系数 { vec3 directionToProbe = normalize(probePos - worldPosition); weight *= Square(max(0.0001, (dot(directionToProbe, direction) + 1.0) * 0.5)) + 0.2; } //切比雪夫系数 { vec3 probeToPoint = worldPosition - probePos; vec3 dir = normalize(-probeToPoint); float dist = length(probeToPoint); vec2 octantCoords = DDGIGetOctahedralCoordinates(dir); vec3 probeTextureUV = DDGIGetProbeUV(int(probeIndex), octantCoords, int(DDGI_PROBE_NUM_TEXELS_DISTANCE_INTERIOR), volume); vec2 temp = texture(sampler2DArray(distanceTexture,SAMPLER[0]),probeTextureUV).rg; // 采样距离纹理 float mean = temp.x; float variance = abs(Square(temp.x) - temp.y); float chebyshev = variance / (variance + Square(max(dist - mean, 0.0))); chebyshev = max(Pow3(chebyshev), 0.0); //以切比雪夫系数三次方作为权重 weight *= (dist \u0026lt;= mean) ? 1.0 : chebyshev; } //避免计算精度问题 { weight = max(0.000001, weight); const float crushThreshold = 0.2f; if (weight \u0026lt; crushThreshold) weight *= weight * weight * (1.0f / Square(crushThreshold)); } //三线性插值系数 { vec3 trilinear = mix(1.0 - alpha, alpha, offset); weight *= trilinear.x * trilinear.y * trilinear.z; } //采样，累计光照 { vec3 irradianceDir = direction; vec2 octantCoords = DDGIGetOctahedralCoordinates(direction); vec3 probeTextureUV = DDGIGetProbeUV(int(probeIndex), octantCoords, int(DDGI_PROBE_NUM_TEXELS_IRRANDIANCE_INTERIOR), volume); vec3 probeIrradiance = texture(sampler2DArray(IrrdianceTexture,SAMPLER[0]),probeTextureUV).rgb; sumIrradiance += weight * probeIrradiance; sumWeight += weight; } } vec3 netIrradiance = sumIrradiance / sumWeight; return 2 * PI * netIrradiance; } 下图显示只渲染间接光\nMath 首先回顾Irrandiance是单位半球上各个方向的Radiance的积分（×cos）\n均匀分布的蒙特卡洛积分来近似上边这个积分式\nDDGI存储一个Irrandiance时，并不是/N，而是/余弦权重之和，目的是减少方差\n他的期望是N/2,对比蒙特卡洛积分还需要*2\n总结 第一次实现比较复杂的全局光照效果，还是太逞能，总想抄RTXGI的各种实践Tick，实际根本理解不了。甚至还想着用Y-Up来构建Imager2DArray，导致逻辑十分混乱。大量时间用在了无用的调试Bug上。\n还有一些DDGI的内容没有实现\nRelocationVolumeProbes：读取一个探针交中背面的次数，如果大于阈值，说明Probe在墙里边，就朝着交中背面的方向偏移，或者交中正面的距离太短了，也进行偏移 ClassifyVolumeProbes：Classify主要是为了给probe进行分类，对于一些卡在墙中或者不在视野内的probe则不进行计算，以节省开销，当然，更重要的是，这个还能避免计算墙外probe，一定程度上减少漏光 总体来看第一次搞大动作比较失败~\n","date":"2025-12-07T20:36:06+08:00","image":"https://sdpyy1.github.io/image-20251211235847350.png","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5ddgi/","title":"游戏引擎开发实践（DDGI）"},{"content":" 总体来看TAA思想比较简单易懂，实现思路更多是实践性的经验总结\nTAA TAA (Temporal Anti-Aliasing) 综合历史帧的数据来实现抗锯齿，这样会将每个像素点的多次采样均摊到多个帧中，相对的开销要小得多。在 MSAA 中，我们在一帧中，在每个像素中放置了多个次像素采样点。在 TAA 中，我们实现的方式，就是在每帧采样时，将采样的点进行偏移，实现抖动 (jitter)。\n静态场景 在静态场景下，上一帧与本帧的颜色信息很好获得，只需要取相同位置进行采样，然后进行一个插值即可，然后用jitter进行抖动\n1 2 3 float3 currColor = currBuffer.Load(pos); float3 historyColor = historyBuffer.Load(pos); return lerp(historyColor, currColor, 0.05f); 这里的取混合系数为0.05，意味着最新的一帧渲染结果，只对最终结果产生了5%的贡献。\n将累计的过程展开来看的话，可以看出当前帧 TAA 后的结果，是包含了所有的历史帧结果的，说明这种方式是合理的：\n在 TAA 中，我们实现的方式，就是在每帧采样时，将采样的点进行偏移，实现抖动 (jitter)。采样点抖动的偏移，是和 MSAA 的次像素采样点放置是相同的，都需要使用低差异的采样序列，来实现更好的抗锯齿效果。TAA 中都会直接使用 Halton 序列，采样的点位置如下所示：\n1 2 3 // 先将Halton序列的值转化为 -0.5~0.5范围的偏移；再除以屏幕长度，得到UV下的偏移值；最后乘以2，是转化到裁剪空间中的偏移值 ProjectionMatrix.m02 += (HaltonSequence[Index].x - 0.5f) / ScreenWidth * 2; ProjectionMatrix.m12 += (HaltonSequence[Index].y - 0.5f) / ScreenHeight * 2; 动态物体 要对历史数据进行混合，就要能够还原出当前物体在屏幕中投影的位置。为了能够精确地记录物体在屏幕空间中的移动，我们使用 Motion Vector 贴图来记录物体在屏幕空间中的变化距离，表示当前帧和上一帧中，物体在屏幕空间投影坐标的变化值\n记录上一帧的投影矩阵和上一帧的模型位置，在本帧使用上一帧的透视变换逆变换就能把模型还原到上一帧的视角下，在上一帧视角下对比两帧模型的位置来拿到Motion Vector（这种有点麻烦，实战不用这种方案）\n使用 MotionVector\n已经知道模型的运动变化了，在本帧中复原模型到上一帧的位置，算出对应的屏幕坐标\n1 2 3 4 5 6 // 减去抖动坐标值，得到当前实际的像素中心UV值 uv -= _Jitter; // 减去Motion值，算出上帧的投影坐标 float2 uvLast = uv - motionVectorBuffer.Sample(point, uv); //使用双线性模式采样 float3 historyColor = historyBuffer.Sample(linear, uvLast); 这样使用也是有问题的。比如 一扇门，本来模型在门后，现在向右位移模型出现，但是按照MontionVector去采样上一帧的颜色时，其实采样到了门上。\n因此为了得到更加平滑的数据，可以在当前像素点周围判断深度，取距离镜头最近的点位置，来采样 Motion Vector 的值，这样可以减弱遮挡错误的影响。\n优化手法：使用 lerp 代替 if-else，可以在 GPU 并行计算中避免分支，提高效率\n1 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 float2 GetClosestFragment(float2 uv) { float2 k = _CameraDepthTexture_TexelSize.xy; //在上下左右四个点 const float4 neighborhood = float4( SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, clamp(uv - k)), SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, clamp(uv + float2(k.x, -k.y))), SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, clamp(uv + float2(-k.x, k.y))), SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, clamp(uv + k)) ); // 获取离相机最近的点 #if defined(UNITY_REVERSED_Z) #define COMPARE_DEPTH(a, b) step(b, a) #else #define COMPARE_DEPTH(a, b) step(a, b) #endif // 获取离相机最近的点，这里使用 lerp 是避免在shader中写分支判断 float3 result = float3(0.0, 0.0, SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, uv)); result = lerp(result, float3(-1.0, -1.0, neighborhood.x), COMPARE_DEPTH(neighborhood.x, result.z)); result = lerp(result, float3( 1.0, -1.0, neighborhood.y), COMPARE_DEPTH(neighborhood.y, result.z)); result = lerp(result, float3(-1.0, 1.0, neighborhood.z), COMPARE_DEPTH(neighborhood.z, result.z)); result = lerp(result, float3( 1.0, 1.0, neighborhood.w), COMPARE_DEPTH(neighborhood.w, result.z)); return (uv + result.xy * k); } //在周围像素中，寻找离相机最近的点 float2 closest = GetClosestFragment(uv); //使用周围最近点，得到Velocity值，来计算上帧投影位置 float2 uvLast = uv - motionVectorBuffer.Sample(point, closest); //... 另外对应历史点的采样，可以使用 Catmull–Rom 采样进行进一步优化，而不是使用默认的双线性采样\n因为使用了双线性采样，所以得到的值会混合周围像素的颜色，造成结果略微模糊。如果想要使得到的历史结果质量更好，也可以使用一些特殊的过滤方式进行处理。比如UE4中使用 Catmull–Rom 的方式进行锐化过滤。Catmull-Rom方式的采样，会在目标点周围进行 5 次采样，然后根据相应权重进行过滤混合，额外的开销也非常大。\n历史结果处理 由于像素抖动，模型变化，渲染光照变化导致渲染结果发生变化时，会导致历史帧得到的像素值失效，就会产生 鬼影/ghosting 和 闪烁 /flicking 问题。\n为了缓解鬼影和闪烁的问题，我们还要对采样的历史帧和当前帧数据进行对比，将历史帧数据 clamp/截断 在合理的范围内。要确定当前帧目标像素的亮度范围，就需要读取当前帧数据目标像素周围 5 个或者 9 个像素点的颜色范围：\n简单的做法就是直接进行 clamp：就是先在当前位置像素领域内判断颜色范围，之后采样历史点时把历史点的颜色clamp到这个范围内，防止它颜色变化太大\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 float3 AABBMin, AABBMax; AABBMax = AABBMin = RGBToYCoCg(Color); // 取得YCoCg色彩空间下，Clip的范围 for(int k = 0; k \u0026lt; 9; k++) { float3 C = RGBToYCoCg(_MainTex.Sample(sampler_PointClamp, uv, kOffsets3x3[k])); AABBMin = min(AABBMin, C); AABBMax = max(AABBMax, C); } // 需要 Clip处理的历史数据 float3 HistoryYCoCg = RGBToYCoCg(HistoryColor); // 简单地进行Clmap float3 ResultYCoCg = clmap(History, AABBMin, AABBMax); //还原到RGB色彩空间，得到最终结果 HistoryColor.rgb = YCoCgToRGB(ResultYCoCg)); 另外一种做法是进行 clip，clip的效果会更好，计算量也会相对较大二者的差别可从下图看出：\nClip和Clamp的区别，clamp简单粗暴地把颜色固定到范围角点上，clip则效果更好，首先获取范围内颜色的中间值，然后从历史点颜色 发送射线到中间值，取射线与AABB盒交点位置的颜色值\n混合结果 1 2 3 // 与上帧相比移动距离越远，就越倾向于使用当前的像素的值 blendFactor = saturate(0.05 + length(motion) * 100); return lerp(historyColor, currColor, blendFactor); 因为我们用到了很多双线性采样，会使得得到的结果有些模糊，因此我们根据情况选择是否对结果进行一次简单的锐化。\n实战 MotionVector 这块逻辑很清晰。 因为模型在世界的位置只取决于模型实例的Model变换。 而模型在屏幕像素的位置取决于投影矩阵和摄像机矩阵。 所以 每个模型都需要额外存储一个glm::mat4来存储上一帧的模型变换，摄像机也需要额外存储上一帧的投影矩阵（不过这个一般不会变），已经上一帧的View矩阵\n在GBufferPass时，获取模型上一帧的Model矩阵，在FragShader中，根据像素存储插值出来的本帧Position和上帧Position进行计算，得到MotionVector\n1 2 3 4 5 6 7 8 9 vec2 CalculateVelocity(vec4 pos, vec4 prevPos){ vec4 curNDC = CAMERAINFO.data.proj * CAMERAINFO.data.view * pos; curNDC /= curNDC.w; vec2 curUV = curNDC.xy * 0.5 + 0.5; vec4 prevNDC = CAMERAINFO.data.prevProj * CAMERAINFO.data.prevView * prevPos; prevNDC /= prevNDC.w; vec2 prevUV = prevNDC.xy * 0.5 + 0.5; return vec2(curUV.xy - prevUV.xy); // MotionVector存储的是当前像素与上一帧中该位置信息所在的像素的偏移增量 } 混合历史信息 在TAApass，读取当前ViewPort，根据MotionVector把像素值偏移到上一帧位置，读取历史信息，混合历史信息和当前信息\n1 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 57 58 59 60 61 62 #version 450 core #include \u0026#34;../common/common.glsl\u0026#34; #ifdef COMPUTE_SHADER layout(set = 1,binding = 0,rgba32f) uniform image2D out_texture; layout(set = 1,binding = 1) uniform texture2D velocityTexture; layout(set = 1,binding = 2) uniform texture2D historyTexture; layout(set = 1,binding = 3) uniform texture2D curTexture; layout(set = 1,binding = 4) uniform texture2D depthTexture; vec2 GetClosestFragment(vec2 uv) { vec2 k = 1.0 / vec2(imageSize(out_texture)); float depth0 = texture(sampler2D(depthTexture, SAMPLER[0]), clamp(uv - k, 0.0, 1.0)).r; float depth1 = texture(sampler2D(depthTexture, SAMPLER[0]), clamp(uv + vec2(k.x, -k.y), 0.0, 1.0)).r; float depth2 = texture(sampler2D(depthTexture, SAMPLER[0]), clamp(uv + vec2(-k.x, k.y), 0.0, 1.0)).r; float depth3 = texture(sampler2D(depthTexture, SAMPLER[0]), clamp(uv + k, 0.0, 1.0)).r; vec4 neighborhood = vec4(depth0, depth1, depth2, depth3); #define COMPARE_DEPTH(a, b) step(a, b) vec3 result = vec3(0.0, 0.0, texture(sampler2D(depthTexture, SAMPLER[0]), uv).r); result = mix(result, vec3(-1.0, -1.0, neighborhood.x), COMPARE_DEPTH(neighborhood.x, result.z)); result = mix(result, vec3( 1.0, -1.0, neighborhood.y), COMPARE_DEPTH(neighborhood.y, result.z)); result = mix(result, vec3(-1.0, 1.0, neighborhood.z), COMPARE_DEPTH(neighborhood.z, result.z)); result = mix(result, vec3( 1.0, 1.0, neighborhood.w), COMPARE_DEPTH(neighborhood.w, result.z)); #undef COMPARE_DEPTH return uv + result.xy * k; } layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; void main() { // TODO: 此处的重投影逻辑可能在别的地方一会用到，应该写成单独的一个pass,把重投影后的UV写在一个纹理中 ivec2 imgSize = ivec2(imageSize(out_texture)); ivec2 invocID = ivec2(gl_GlobalInvocationID.xy); if (invocID.x \u0026gt;= imgSize.x || invocID.y \u0026gt;= imgSize.y) return; vec2 texCoords = (vec2(invocID) + 0.5f) / vec2(imgSize); vec2 ClosestUv = GetClosestFragment(texCoords); vec2 velocity = texture(sampler2D(velocityTexture,SAMPLER[0]), ClosestUv).rg; // 用采样点周围离摄像头最近的深度的UV来采样速度值 vec2 historyTexCoords = texCoords - velocity; bool valid = historyTexCoords.x \u0026gt; 0.0f ? true : false; float blend = 0.95f; if(!valid) blend = 1.0f; vec3 historyColor = texture(sampler2D(historyTexture,SAMPLER[0]), historyTexCoords).rgb; vec3 curColor = texture(sampler2D(curTexture,SAMPLER[0]), texCoords).rgb; vec3 mixColor = mix(curColor,historyColor, blend); imageStore(out_texture, invocID, vec4(mixColor, 1.0)); } #endif 当然目前只是简单混合了颜色，渲染结果能看出来TAA的效果，但是移动物体或者摄像机时，鬼影十分严重\n处理拖影 对比当前数据和历史数据，把历史数据clamp到合理范围内\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 #version 450 core #include \u0026#34;../common/common.glsl\u0026#34; #include \u0026#34;../common/TAA.glsl\u0026#34; #include \u0026#34;../common/math.glsl\u0026#34; #ifdef COMPUTE_SHADER layout(set = 1,binding = 0,rgba32f) uniform image2D out_texture; layout(set = 1,binding = 1) uniform texture2D velocityTexture; layout(set = 1,binding = 2) uniform texture2D historyTexture; layout(set = 1,binding = 3) uniform texture2D curTexture; layout(set = 1,binding = 4) uniform texture2D depthTexture; layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; void main() { ivec2 invocID = ivec2(gl_GlobalInvocationID.xy); ivec2 imgSize = ivec2(imageSize(out_texture)); if (invocID.x \u0026gt;= imgSize.x || invocID.y \u0026gt;= imgSize.y) return; if(GetPostprocessSetting().TaaSetting.enable == 0){ vec2 texCoords = (vec2(invocID) + 0.5f) / vec2(imgSize); vec3 curColor = texture(sampler2D(curTexture,SAMPLER[0]), texCoords).rgb; imageStore(out_texture, invocID, vec4(curColor, 1.0)); return; } vec2 texelSize = 1.0 / vec2(imgSize); vec2 baseTexCoords = (vec2(invocID) + 0.5f) / vec2(imgSize); vec2 jitterUV = GetPostprocessSetting().TaaSetting.UVjetter * texelSize; vec2 texCoordsWithJitter = baseTexCoords + jitterUV; vec2 ClosestUv = GetClosestFragment(baseTexCoords, texelSize, depthTexture); vec2 velocity = texture(sampler2D(velocityTexture,SAMPLER[0]), ClosestUv).rg; vec2 historyTexCoords = baseTexCoords - velocity; // 采样当前帧（带抖动）、历史帧（无抖动）颜色 vec3 historyColor = texture(sampler2D(historyTexture,SAMPLER[0]), historyTexCoords).rgb; vec3 curColor = texture(sampler2D(curTexture,SAMPLER[0]), texCoordsWithJitter).rgb; const vec2 kOffsets3x3[9] = { vec2(-1.0, -1.0), vec2( 0.0, -1.0), vec2( 1.0, -1.0), vec2(-1.0, 0.0), vec2( 0.0, 0.0), vec2( 1.0, 0.0), vec2(-1.0, 1.0), vec2( 0.0, 1.0), vec2( 1.0, 1.0) }; // fix 拖影：3x3邻域使用带抖动的UV，且钳位边界 vec3 AABBMin = RGBToYCoCg(curColor); vec3 AABBMax = AABBMin; for(int k = 0; k \u0026lt; 9; k++) { vec2 uvOffset = kOffsets3x3[k] * texelSize; vec2 neighborUV = texCoordsWithJitter + uvOffset; neighborUV = clamp(neighborUV, 0.0, 1.0); vec3 C = RGBToYCoCg(texture(sampler2D(curTexture, SAMPLER[0]), neighborUV).rgb); AABBMin = min(AABBMin, C); AABBMax = max(AABBMax, C); } // Clip处理核心逻辑（无问题） vec3 HistoryYCoCg = RGBToYCoCg(historyColor); vec3 Filtered = (AABBMin + AABBMax) * 0.5f; vec3 RayOrigin = HistoryYCoCg; vec3 RayDir = Filtered - RayOrigin; float epsilon = 1.0 / 65536.0; RayDir = mix(vec3(epsilon), RayDir, greaterThan(abs(RayDir), vec3(epsilon))); vec3 InvRayDir = 1.0 / RayDir; vec3 MinIntersect = (AABBMin - RayOrigin) * InvRayDir; vec3 MaxIntersect = (AABBMax - RayOrigin) * InvRayDir; vec3 EnterIntersect = min(MinIntersect, MaxIntersect); float ClipBlend = max(EnterIntersect.x, max(EnterIntersect.y, EnterIntersect.z)); ClipBlend = clamp(ClipBlend, 0.0, 1.0); vec3 ResultYCoCg = mix(HistoryYCoCg, Filtered, ClipBlend); historyColor = YCoCgToRGB(ResultYCoCg); float motionLength = length(velocity); float blendFactor = Saturate(0.05 + motionLength * 1000.0); bool valid = (historyTexCoords.x \u0026gt;= 0.0 \u0026amp;\u0026amp; historyTexCoords.x \u0026lt;= 1.0) \u0026amp;\u0026amp; (historyTexCoords.y \u0026gt;= 0.0 \u0026amp;\u0026amp; historyTexCoords.y \u0026lt;= 1.0); if (!valid) {blendFactor = 1.0;} if (GetPostprocessSetting().TaaSetting.shaper == 1) { float strength = GetPostprocessSetting().TaaSetting.shaperStrength; vec2 topUV = clamp(texCoordsWithJitter + vec2(0.0, -texelSize.y), 0.0, 1.0); vec2 leftUV = clamp(texCoordsWithJitter + vec2(-texelSize.x, 0.0), 0.0, 1.0); vec2 rightUV = clamp(texCoordsWithJitter + vec2(texelSize.x, 0.0), 0.0, 1.0); vec2 bottomUV = clamp(texCoordsWithJitter + vec2(0.0, texelSize.y), 0.0, 1.0); vec3 topColor = RGBToYCoCg(texture(sampler2D(curTexture, SAMPLER[0]), topUV).rgb); vec3 leftColor = RGBToYCoCg(texture(sampler2D(curTexture, SAMPLER[0]), leftUV).rgb); vec3 centerColor = RGBToYCoCg(curColor); vec3 rightColor = RGBToYCoCg(texture(sampler2D(curTexture, SAMPLER[0]), rightUV).rgb); vec3 bottomColor = RGBToYCoCg(texture(sampler2D(curTexture, SAMPLER[0]), bottomUV).rgb); vec3 sharpenSum = vec3(0.0); sharpenSum += (-1.0 * strength) * topColor; sharpenSum += (-1.0 * strength) * leftColor; sharpenSum += (5.0 * strength) * centerColor; sharpenSum += (-1.0 * strength) * rightColor; sharpenSum += (-1.0 * strength) * bottomColor; vec3 sharpenedColor = YCoCgToRGB(sharpenSum); sharpenedColor = max(sharpenedColor, vec3(0.0)); curColor = sharpenedColor; } // HDR需要先转为LDRmix后再恢复， 如果不这样，目前测试发现会出现一个黑点，然后移动后越来越大 historyColor = ToneMapping(max(historyColor, 0.0f)); curColor = ToneMapping(max(curColor, 0.0f)); vec3 mixColor = mix(historyColor, curColor, blendFactor); mixColor = InverseToneMapping(max(mixColor, 0.0f)); imageStore(out_texture, invocID, vec4(mixColor, 1.0)); } #endif ","date":"2025-12-04T18:58:15+08:00","image":"https://sdpyy1.github.io/image-20251207191240613.png","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E6%8A%97%E9%94%AF%E9%BD%BF-taa/","title":"游戏引擎开发实践（抗锯齿 TAA）"},{"content":"Vulkan接入光追管线 参考https://nvpro-samples.github.io/vk_raytracing_tutorial_KHR 、 https://docs.vulkan.org/tutorial/latest/courses/18_Ray_tracing/02_Acceleration_structures.html\n加速结构 在 Vulkan 中，用户创建的加速结构分为两个部分：底层加速结构（bottom level acceleration structure，BLAS） 和顶层加速结构（top level acceleration structure，TLAS）。\nBLAS (Bottom-Level Acceleration Structure) 包含：各网格单元（三角形、顶点、索引）的实际几何数据。注：法线、UV及其他属性未存储于BLAS本身——需在着色器中单独访问。\n每个Mesh对应一个BLAS\nTLAS (Top-Level Acceleration Structure) 包含：对BLAS结构的引用及其世界空间变换（位置、旋转、缩放）\n整个场景一个TLAS\n示例：TLAS包含如下实例：“位于（1,0,1）位置的茶壶BLAS，旋转角度为45°，比例为2.0”\n两层结构目的：TLAS存储BLAS的实例化信息，每个Mesh的BLAS只需要存储一次\nRT过程 Ray Generation 射线与TLAS相交以确定可能被击中的实例 对于每个潜在命中，射线均经过转换并针对相应的BLAS进行测试 最近交点确定命中点与材质 Shader Binding Table (SBT) 着色器绑定表（SBT）是“蓝图”，它告诉光线追踪器在不同类型的光线交点上执行哪些着色器。与光栅化中着色器按顺序绑定不同，光线追踪需要所有着色器同时可用，因为光线可以随时击中任何表面。\nRay Generation Shaders: Entry point for each pixel’s primary ray Miss Shaders: Executed when rays don’t hit any geometry (background/sky) Hit Shaders: Executed when rays intersect with geometry (material shading) Callable Shaders: Optional shaders that can be invoked from other shaders 也就是一个Shader池，Hit或者Miss时都可以从池中拿不同的Shader来执行\n为什么需要SBT：在光线追踪中，单条光线可能击中场景中的任何物体，不同的物体可能需要不同的着色器（例如，不同的材质、透明度效果）\n一些概念：\nShader Handles: 显然需要给池子中每个Shader一个唯一标识\nSBT Regions：不同类型的Shader存储在不同的Regions\nInstance Association：TLAS实例可通过hitGroupId字段指定所使用的命中着色器。\nData Attachment:着色器可附加自定义数据（材质属性、实例数据）至着色器句柄，应该就是给Shader\nShader shader写起来思路很清晰，设计的很好\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 #version 460 #extension GL_EXT_ray_tracing : enable #extension GL_ARB_separate_shader_objects : enable #extension GL_GOOGLE_include_directive : enable #include \u0026#34;../common/common.glsl\u0026#34; // RayPayLoad是自定义的，定义layout时需要前缀rayPayloadEXT，任何需要的 Hit/ Miss/ ClosestHit 等 shader 会写回 payload struct Payload { vec3 color; }; #ifdef RAYGEN_SHADER /* RayGenShader目的很简单，他是GPU与CPU交换的中间，负责定义payLoad，负责计算Ray的生成，就是从摄像机射向各个像素中心，整体来看就是一个工作组是二维，处理一张Image的ComputerShader */ // layout(set = 0, binding = 0) uniform accelerationStructureEXT topLevelAS; 这个已经在全局资源中 layout(set = 1, binding = 0, rgba32f) uniform image2D OUT_COLOR; // 定义数据 layout(location = 0) rayPayloadEXT Payload payload ; // 必须有rayPayloadEXT前缀 void main() { // 相当于一个自动的二维dipatch // gl_LaunchIDEXT.xy; 线程ID // gl_LaunchSizeEXT; 总线程数 // 1. 计算屏幕坐标，并转到NDC坐标 const vec2 pixelCenter = vec2(gl_LaunchIDEXT.xy) + vec2(0.5); // 移动到像素中心 const vec2 inUV = pixelCenter / vec2(gl_LaunchSizeEXT.xy); // UV坐标 vec2 ndc = inUV * 2.0 - 1.0; // 屏幕坐标转NDC[-1,1] // 2. Ray参数 vec3 origin = CAMERAINFO.data.position; // 相机位置 vec4 target = CAMERAINFO.data.invProj * vec4(ndc.x, ndc.y, 0, 1) ; // 射线终点， 设置在像素NDC坐标，深度最近的位置 并转到View空间 target /= target.w; // 透视除法 target = CAMERAINFO.data.invView * target; vec3 direction = normalize(target.xyz - origin.xyz); payload.color = vec3(0.0); traceRayEXT(TLAS, // acceleration structure gl_RayFlagsOpaqueEXT, // rayFlags 控制光线的行为，比如是否忽略背面、是否启用 any-hit、是否可用 conservative tracing 等 0xFF, // cullMask 0xFF → 匹配所有实例 0, // sbtRecordOffset 索引到 Shader Binding Table (SBT) 的起始记录位置 1, // sbtRecordStride SBT 中每条记录的间隔（单位是记录数，不是字节） 0, // missIndex 当光线没有击中任何几何体时，使用 SBT 中 miss shader 的索引 origin.xyz, // ray origin MIN_RAY_TRACING_DISTANCE, // ray min range direction.xyz, // ray direction MAX_RAY_TRACING_DISTANCE, // ray max range 0 // payload (location = 0) payload的位置 ); vec4 outColor = vec4(payload.color, 1.0); imageStore(OUT_COLOR, ivec2(gl_LaunchIDEXT.xy), outColor); } #endif #ifdef RAYCLOSEST_HIT_SHADER /* Hit是具体到Mesh的某一个三角形以及击中点，并且会给hitAttributeEXT来表示重心坐标，需要手动插值来计算击中点的信息 gl_WorldRayTmaxEXT 可以获得击中的时间 t */ layout(location = 0) rayPayloadInEXT Payload payload; // rayPayloadInEXT 注意是InEXT // Hit shader 可以访问击中的 geometry 信息 hitAttributeEXT vec2 attribs; // 用来求重心坐标，表示击中点对于击中三角形的三个顶点的权重 void main() { uint instanceID = gl_InstanceCustomIndexEXT; // 在构建TLAS时，给每个实例分配的ID uint primitiveID = gl_PrimitiveID; // 击中的三角形索引 vec3 barycentrics = vec3(1.0 - attribs.x - attribs.y, attribs.x, attribs.y); // 插值需要手动进行 // 需要根据实例ID和三角形索引去Bindless找对应三个顶点的信息，再根据barycentrics进行插值 // 根据插值后的结果计算着色信息 MaterialInfo material = GetMaterialInfo(instanceID); // 返回颜色 payload.color = material.diffuse.xyz; // 或者继续递归 } #endif #ifdef RAYMISS_SHADER /* MissShader是当Ray没有击中任何几何体时，会调用MissShader，MissShader可以返回一个颜色，或者继续递归 */ layout(location = 0) rayPayloadInEXT Payload payload; // rayPayloadInEXT 注意是InEXT void main() { payload.color = vec3(0.6, 0.8, 1.0); // 也可以采样天空盒纹理 } #endif ","date":"2025-12-02T16:22:18+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5vulkan%E6%8E%A5%E5%85%A5%E5%85%89%E7%BA%BF%E8%BF%BD%E8%B8%AA%E5%85%89%E7%BA%BF/","title":"游戏引擎开发实践（Vulkan接入光线追踪光线）"},{"content":"Cluster Based Lighting 这里只介绍原理，实现篇放在游戏引擎开发实践（GPU-Driven）中\n将相机视锥体分为若干簇，并为仅为每个簇分配若干有效的光源，可以避免大量无效的光照计算\n在着色阶段的流程则比较简单。首先根据像素坐标计算像素所属 Cluster，然后遍历该 Cluster 的 “有效光源” 列表，逐一计算光照\n分簇 ComputerShader中每个线程一个簇，XY上分簇，Z上进行切分，每个簇就对应XYZ\n所以执行时就是 command-\u0026gt;Dispatch( 1, 1, LIGHT_CLUSTER_DEPTH); LIGHT_CLUSTER_DEPTH就是Z坐标划分次数\n在Shader中进行每个Group的在XY上进行划分。\n1 2 3 4 5 6 7 8 #define THREAD_SIZE_X LIGHT_CLUSTER_WIDTH #define THREAD_SIZE_Y LIGHT_CLUSTER_HEIGHT #define THREAD_SIZE_Z 1 layout (local_size_x = THREAD_SIZE_X, local_size_y = THREAD_SIZE_Y, local_size_z = THREAD_SIZE_Z) in; void main(){ uvec3 gID = gl_GlobalInvocationID.xyz; // 获得的就是每个簇的索引 } 接下来要找每个簇对应的视锥体范围的角点坐标\n根据上边的信息可以屏幕空间上每个簇的角点UV，下面顺序是 屏幕UV-\u0026gt;NDC-\u0026gt;View，最终目的是拿到每个簇在View下的每个角点的坐标（转到世界坐标计算也行）\n求交光照剔除 在每个簇中遍历场景中的所有光源范围，如果光源范围覆盖到了这个簇的位置，就把这个光源的索引信息存储到这个簇的信息中，具体怎么存就看具体光源是怎么管理的了\n求交可以简单的判断簇的角点在不在光源位置内，或者用簇做一个包围盒，用包围盒与光源的球形包围盒进行求交运算来判断该光源是否会影响当前簇\n","date":"2025-11-24T11:14:58+08:00","permalink":"https://sdpyy1.github.io/p/cluster-based-lighting/","title":"Cluster Based Lighting"},{"content":"Pass TODO ClipmapPass: 是一个基于光线追踪（Ray Tracing）的体素化（Voxelization）渲染通道，它的核心任务是高效地为体素化全局光照（VXGI）系统构建和更新一个动态的、多层次的体素场景表示\nClusterLightingPass:光照剔除 （2025.11.24 拿捏原理、待整合）\nDDGIPass：一点进度没有（2025.12.12 青春版完结，但是没做一些工程化的内容，比如ReLocation）\nExposurePass：自动曝光（2025.12.14 完成）\nFXAA、TAA：抗锯齿得来点进度啊（2025.12.7 TAA完成）\nGizmoPass：画在屏幕上的Debug信息不急(虽然不急但是很有意思，提前完成了 2025.11.30 完成)\nGPUCullingPass：GPU-Driven管线(Working\u0026hellip;\u0026hellip;.)（2025.12.18日完成视锥、阴影的Mesh剔除工作， 遮挡剔除还没写）\nPathTracingPass：啊，把这东西搬到实时管线么。。。，可能只是研究API怎么用（因为是分帧更新，所以不影响实时渲染管线的工作，2025.12.13 搭建PathTracing完成，但是应该是还有问题，有些像素会过曝）\nReprojectionPass：这个好像就是TAA用的motionVector的实现，待研究（2025.12.7 这东西因为除了TAA别的地方也会用到，所以单独拿出来了，我先自代码还是在TAA代码中）\nReSTIRDIPass：高效地计算场景中的直接光照（Direct Illumination），代替光照pass？\nSSSR:更牛逼的SSR? 在 SSR 基础上引入 随机采样（Stochastic Sampling）和 时空域滤波（Temporal + Spatial Filtering），以低采样率实现高质量软反射\nSurface CachePass：优化模型的Pass？\nSVGFPass：在不模糊图像细节（如边缘、纹理）的前提下，高效地去除蒙特卡洛渲染中的噪声，应该是配合上边的ReSTIRDIPass\nVolumetricFogPass：一直还没做过\n","date":"2025-11-24T10:49:27+08:00","permalink":"https://sdpyy1.github.io/p/%E8%A6%81%E5%AD%A6%E7%9A%84%E5%A4%AA%E5%A4%9A/","title":"要学的太多"},{"content":" Bindless（Unbounded）无绑定或者无界绑定，是指不通过传统图形 API 的形如 glBindXXX 函数而直接将 Buffer\\Texture 的 GPU 虚拟地址存储在 Bindless Buffer 中，在 Shader 中通过索引 Bindless 而直接访问 Texture\\Buffer 数据的技术。Bindless 也是现代渲染技术如 GPU Driven Pipeline 不可或缺的基础组成部分\nBindless可以减少DrawCall\nVulkan创建DescriptorLayout时需要通过VkDescriptorSetLayoutBinding来表示每个绑定点的信息\n1 2 3 4 5 6 7 typedef struct VkDescriptorSetLayoutBinding { uint32_t binding; // 绑定点编号（从 0 开始） VkDescriptorType descriptorType; // 描述符类型 uint32_t descriptorCount; // 描述符数量（数组大小） VkShaderStageFlags stageFlags; // 着色器可见性阶段 const VkSampler* pImmutableSamplers; // immutable 采样器（可选） } VkDescriptorSetLayoutBinding; descriptorCount指定binding的资源描述符数量，也就是这个Binding可以存放多个相同类型的资源。在更新DescriptorSet时\n1 2 3 4 5 6 7 8 VkWriteDescriptorSet writeDescriptorSet = {}; writeDescriptorSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; writeDescriptorSet.dstSet = yourDescriptorSet; writeDescriptorSet.dstBinding = 0; writeDescriptorSet.dstArrayElement = 2; // 对应layout的descriptorCount的索引，在Shader中 用[descriptorCount]来设置一个资源数组 writeDescriptorSet.descriptorCount = 1; writeDescriptorSet.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; writeDescriptorSet.pImageInfo = \u0026amp;imageInfo; 所以在创建布局时，可以指定一个binding位置很大的descriptorCount，在Update时根据索引来更新，并且在Shader中写可以方便索引（当然需要传递索引到Shader）\n这种静态的方式带来的问题是无法根据实际情况来决定资源数量，造成复杂的资源驻留管理以及 Descriptor 空间浪费。在从越大佬的文章中，这种方式被称为“有限”Bindless\n真bindless其实就是靠Vulkan自己添加的新特性，支持Shader指定数组时不需要填入数量\n","date":"2025-11-21T13:33:36+08:00","permalink":"https://sdpyy1.github.io/p/bindless/","title":"Bindless"},{"content":"Motion Blur 思路是给Ray定义一个时间，表示光线的行进时间，判断光线与模型求交时，先用Ray的持续时间来计算模型在当前时间的位置，在新的位置上计算求交，因为发送的射线的时间是随机设置的，所有他们与球求交的是空间上不一致的，所以移动的物体就会出现模糊，本质也就是渲染时，不同像素收集到的是不同时间段的模型颜色，真实相机拍摄运动物体时，快门从打开到关闭的这段时间（比如 1/100 秒）内，物体一直在移动，传感器会记录下这段时间内物体在不同位置的所有光线信息，所以这种近似属于Hack\nBounding Volume Hierarchies BVH_node 可以继承 : public hittable，与 sphere 的继承一样。 相机遍历hittable_list时，会执行BVH_node本身的hit函数，函数内就有树形检索逻辑，加快击中判定\n构建BVH的流程，是一个递归函数，用start和end表示要处理的objects范围，递归时，设置为原来的一半作为子节点\n1 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 bvh_node(std::vector\u0026lt;shared_ptr\u0026lt;hittable\u0026gt;\u0026gt;\u0026amp; objects, size_t start, size_t end) { // 随机选择一个轴方向 int axis = random_int(0,2); // 排序器 auto comparator = (axis == 0) ? box_x_compare : (axis == 1) ? box_y_compare : box_z_compare; size_t object_span = end - start; if (object_span == 1) { // 只有一个物体 left = right = objects[start]; } else if (object_span == 2) { // 有两个物体 left = objects[start]; right = objects[start+1]; } else { // 在某个轴方向对objects进行排序 std::sort(std::begin(objects) + start, std::begin(objects) + end, comparator); // 划分为两半 auto mid = start + object_span/2; // 递归构建子节点 left = make_shared\u0026lt;bvh_node\u0026gt;(objects, start, mid); right = make_shared\u0026lt;bvh_node\u0026gt;(objects, mid, end); } // 更新包围盒 bbox = aabb(left-\u0026gt;bounding_box(), right-\u0026gt;bounding_box()); } 进一步优化，每次都选择最长的轴方向进行划分，因为物体在最长轴上的离散度最高，这样划分包围盒的重叠最少\n1 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 bvh_node(std::vector\u0026lt;shared_ptr\u0026lt;hittable\u0026gt;\u0026gt;\u0026amp; objects, size_t start, size_t end) { // Build the bounding box of the span of source objects. bbox = aabb::empty; for (size_t object_index=start; object_index \u0026lt; end; object_index++) bbox = aabb(bbox, objects[object_index]-\u0026gt;bounding_box()); int axis = bbox.longest_axis(); // 排序器 auto comparator = (axis == 0) ? box_x_compare : (axis == 1) ? box_y_compare : box_z_compare; size_t object_span = end - start; if (object_span == 1) { // 只有一个物体 left = right = objects[start]; } else if (object_span == 2) { // 有两个物体 left = objects[start]; right = objects[start+1]; } else { // 在某个轴方向对objects进行排序 std::sort(std::begin(objects) + start, std::begin(objects) + end, comparator); // 划分为两半 auto mid = start + object_span/2; // 递归构建子节点 left = make_shared\u0026lt;bvh_node\u0026gt;(objects, start, mid); right = make_shared\u0026lt;bvh_node\u0026gt;(objects, mid, end); } bbox = aabb(left-\u0026gt;bounding_box(), right-\u0026gt;bounding_box()); } 纹理 生成球的UV 做法是3D坐标转为球面坐标，再转为UV坐标\n3D坐标与球面坐标的转换\n四边形 一个点Q+两个方向UV来定义一个四边形\n求交运算 找到四边形对应的平面 光线与平面相交 确定交点在四边形内 四边形所在平面方程 定义平面：\n法向量：\n已知平面的法向量n，另外四边形的uv向量与法线n垂直（也就是说说u*v（叉乘）后的方向就是法线方向，这样就求出了ABC）\n进一步，点Q落在平面上（已经知道ABC就是法向量的xyz，这样就可以通过带入法线和Q求解D，就求解了平面的方程）\n光线与平面求交 点位置可以表示为光线P+td\n求解D时，是用法线和点位置求解的，上一步已经求出D是多少了。现在反过来就可以求t\n现在点位置用Ray表示后，求解D的方程表示为：\n求解t得到光线相交于平面的时间\n判定点是否在四边形内部 判断前需要在2D平面有一个基准坐标系，来表达任何点在平面上的位置\n直接用Q点表示原点，UV向量来表示坐标系（不平行即可）\n此时平面上任意一点可以表达为（$\\alpha,\\beta$）\n突然用公式求解出（$\\alpha,\\beta$）的值\n推导流程\n求解出$（\\alpha,\\beta）$后，按下图判断点是否在四边形内部\n发光材质 给材质一个发光函数，根据击中的位置，返回一个发光颜色。\n1 2 3 4 5 6 7 8 9 10 11 class material { public: virtual ~material() = default; virtual color emitted(double u, double v, const point3\u0026amp; p) const { return color(0,0,0); } virtual bool scatter(const ray\u0026amp; r_in, const hit_record\u0026amp; rec, color\u0026amp; attenuation, ray\u0026amp; scattered) const { return false; } }; 击中物体计算颜色时，首先进行自发光判断，再进行scatter递归\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 color ray_color(const ray\u0026amp; r, int depth, const hittable\u0026amp; world) const { // If we\u0026#39;ve exceeded the ray bounce limit, no more light is gathered. if (depth \u0026lt;= 0) return color(0,0,0); hit_record rec; // If the ray hits nothing, return the background color. if (!world.hit(r, interval(0.001, infinity), rec)) return background; ray scattered; color attenuation; color color_from_emission = rec.mat-\u0026gt;emitted(rec.u, rec.v, rec.p); if (!rec.mat-\u0026gt;scatter(r, rec, attenuation, scattered)) return color_from_emission; color color_from_scatter = attenuation * ray_color(scattered, depth-1, world); return color_from_emission + color_from_scatter; } 平移和旋转 这里介绍的平移旋转并不是对模型进行平移旋转，而是模拟光线与平移后的模型击中来计算着色，也就是说移动光线，达到平移物品的目的\n平移变换 (Translation) 平移是最简单的变换。\n目标：我们想让一个物体沿着向量 offset 移动。\n方法\n：\n变换光线：在判断光线 r 是否与平移后的物体相交时，我们先将光线 反向平移 offset 个单位，得到一条新的光线 offset_r。 求交：用这条 offset_r 光线与原始位置的物体进行相交测试。 逆变换交点：如果 offset_r 与原始物体相交于点 p，那么这个交点在世界空间中就位于 p + offset 的位置。我们将计算出的交点 rec.p 进行正向平移，得到最终的交点。 旋转变换 (Rotation) 旋转比平移复杂，因为它涉及到三角函数，并且还需要变换光线的方向向量和表面法向量。\n目标：我们想让一个物体绕 Y 轴旋转 θ 角。\n方法\n：\n变换光线：在判断光线 r 是否与旋转后的物体相交时，我们先将光线的原点和方向都进行 反向旋转 -θ（即顺时针旋转 θ）。\n求交：用这条变换后的光线与原始朝向的物体进行相交测试。\n逆变换交点和法向量\n：\n如果相交，得到的交点 p 和法向量 normal 是在物体的局部空间中的。 我们需要将它们正向旋转 θ（逆时针旋转 θ），变换回世界空间，作为最终的结果。 ","date":"2025-11-15T12:22:52+08:00","image":"https://sdpyy1.github.io/image-20251122210353992.png","permalink":"https://sdpyy1.github.io/p/raytracingthenextweek/","title":"RayTracingTheNextWeek"},{"content":" 初次简单了解一下UE的代码长什么样子，参考视频https://www.bilibili.com/video/BV1VK411x7bH?spm_id_from=333.788.videopod.sections\u0026amp;vd_source=9df9034e2f1978b1018f5b387ec3eacd\n向下 FRHIResource /** The base type of RHI resources. */\n1 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 const ERHIResourceType ResourceType; // 说明了那些被认为是RHI资源 enum ERHIResourceType : uint8 { RRT_None, RRT_SamplerState, RRT_RasterizerState, RRT_DepthStencilState, RRT_BlendState, RRT_VertexDeclaration, RRT_VertexShader, RRT_MeshShader, RRT_AmplificationShader, RRT_PixelShader, RRT_GeometryShader, RRT_RayTracingShader, RRT_ComputeShader, RRT_GraphicsPipelineState, RRT_ComputePipelineState, RRT_RayTracingPipelineState, RRT_BoundShaderState, RRT_UniformBufferLayout, RRT_UniformBuffer, RRT_Buffer, RRT_Texture, // @todo: texture type unification - remove these RRT_Texture2D, RRT_Texture2DArray, RRT_Texture3D, RRT_TextureCube, // @todo: texture type unification - remove these RRT_TextureReference, RRT_TimestampCalibrationQuery, RRT_GPUFence, RRT_RenderQuery, RRT_RenderQueryPool, RRT_Viewport, RRT_UnorderedAccessView, RRT_ShaderResourceView, RRT_RayTracingAccelerationStructure, RRT_RayTracingShaderBindingTable, RRT_StagingBuffer, RRT_CustomPresent, RRT_ShaderLibrary, RRT_PipelineBinaryLibrary, RRT_ShaderBundle, RRT_WorkGraphShader, RRT_WorkGraphPipelineState, RRT_StreamSourceSlot, RRT_ResourceCollection, RRT_Num }; ERHIResourceType 枚举包含的资源类型可归纳为以下大类：\n渲染状态资源（管线状态配置类） 着色器及相关资源（各类着色器程序与绑定布局类） 缓冲与数据存储资源（数据载体及布局描述类） 纹理及纹理访问资源（图像数据及访问接口类） 光线追踪专用资源（光线追踪加速与绑定类） 查询与同步资源（GPU/CPU 交互及数据查询类） 其他辅助资源（视口、自定义呈现、资源集合等） 这里的每个实现都用子类实现\n1 2 3 4 5 6 7 8 9 class FRHIUniformBuffer : public FRHIResource #if ENABLE_RHI_VALIDATION , public RHIValidation::FUniformBufferResource #endif { public: FRHIUniformBuffer() = delete; ... } 再进一步就到了具体API的子类\n1 2 3 4 5 6 7 class FVulkanUniformBuffer : public FRHIUniformBuffer { public: FVulkanUniformBuffer(FVulkanDevice\u0026amp; Device, const FRHIUniformBufferLayout* InLayout, const void* Contents, EUniformBufferUsage InUsage, EUniformBufferValidation Validation); virtual ~FVulkanUniformBuffer(); ... } 其他资源都一样的继承思路\n这套架构已经简单实现\nFDynamicRHI 类似创建操作(上下文无关操作)是在FDynamicRHI，上下文有关的操作（也就是在固定生命周期内执行的操作）是在IRHICommandContext\n一些图形渲染场景的操作API都定义在这里，创建shader、更新纹理等等\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 /** The interface which is implemented by the dynamically bound RHI. */ class FDynamicRHI { public: using FRHICalcTextureSizeResult = ::FRHICalcTextureSizeResult; /** Declare a virtual destructor, so the dynamic RHI can be deleted without knowing its type. */ RHI_API virtual ~FDynamicRHI(); /** Initializes the RHI; separate from IDynamicRHIModule::CreateRHI so that GDynamicRHI is set when it is called. */ virtual void Init() = 0; /** Called after the RHI is initialized; before the render thread is started. */ virtual void PostInit() {} /** Shutdown the RHI; handle shutdown and resource destruction before the RHI\u0026#39;s actual destructor is called (so that all resources of the RHI are still available for shutdown). */ virtual void Shutdown() = 0; virtual const TCHAR* GetName() = 0; virtual ERHIInterfaceType GetInterfaceType() const { return ERHIInterfaceType::Hidden; } virtual FDynamicRHI* GetNonValidationRHI() { return this; } /** Called after PostInit to initialize the pixel format info, which is needed for some commands default implementations */ void InitPixelFormatInfo(const TArray\u0026lt;uint32\u0026gt;\u0026amp; PixelFormatBlockBytesIn) { PixelFormatBlockBytes = PixelFormatBlockBytesIn; } /////// RHI Methods RHI_API virtual void RHIEndFrame_RenderThread(FRHICommandListImmediate\u0026amp; RHICmdList); struct FRHIEndFrameArgs { // Increments once per call to RHIEndFrame uint32 FrameNumber; #if WITH_RHI_BREADCRUMBS const TRHIPipelineArray\u0026lt;FRHIBreadcrumbNode*\u0026gt;\u0026amp; GPUBreadcrumbs; #endif }; virtual void RHIEndFrame(const FRHIEndFrameArgs\u0026amp; Args) = 0; // FlushType: Thread safe virtual FSamplerStateRHIRef RHICreateSamplerState(const FSamplerStateInitializerRHI\u0026amp; Initializer) = 0; // FlushType: Thread safe virtual FRasterizerStateRHIRef RHICreateRasterizerState(const FRasterizerStateInitializerRHI\u0026amp; Initializer) = 0; // FlushType: Thread safe virtual FDepthStencilStateRHIRef RHICreateDepthStencilState(const FDepthStencilStateInitializerRHI\u0026amp; Initializer) = 0; // FlushType: Thread safe virtual FBlendStateRHIRef RHICreateBlendState(const FBlendStateInitializerRHI\u0026amp; Initializer) = 0; // FlushType: Wait RHI Thread virtual FVertexDeclarationRHIRef RHICreateVertexDeclaration(const FVertexDeclarationElementList\u0026amp; Elements) = 0; // FlushType: Wait RHI Thread virtual FPixelShaderRHIRef RHICreatePixelShader(TArrayView\u0026lt;const uint8\u0026gt; Code, const FSHAHash\u0026amp; Hash) = 0; // FlushType: Wait RHI Thread virtual FVertexShaderRHIRef RHICreateVertexShader(TArrayView\u0026lt;const uint8\u0026gt; Code, const FSHAHash\u0026amp; Hash) = 0; // FlushType: Wait RHI Thread virtual FGeometryShaderRHIRef RHICreateGeometryShader(TArrayView\u0026lt;const uint8\u0026gt; Code, const FSHAHash\u0026amp; Hash) = 0; // FlushType: Wait RHI Thread virtual FMeshShaderRHIRef RHICreateMeshShader(TArrayView\u0026lt;const uint8\u0026gt; Code, const FSHAHash\u0026amp; Hash) ... } 封装了渲染所需的核心资源创建与管理逻辑，包括：\n缓冲（顶点缓冲、索引缓冲、常量缓冲等）的创建 / 更新 / 销毁； 纹理（2D 纹理、立方体贴图等）的加载 / 格式转换 / 内存管理； 渲染管线状态（着色器、混合状态、深度测试等）的配置与绑定； 绘制命令（Draw Call）的提交与执行。 IRHICommandContext 定义上下文相关的操作，比如RHIDispatchComputeShader，这明显需要在Pass中间调用。RHIDrawPrimitive``RHIDrawPrimitiveIndirect``RHIDrawIndexedIndirect这些，内部都是虚函数，由具体平台实现\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 /** The interface RHI command context. Sometimes the RHI handles these. On platforms that can processes command lists in parallel, it is a separate object. */ class IRHICommandContext : public IRHIComputeContext { public: virtual ~IRHICommandContext() { } virtual ERHIPipeline GetPipeline() const override { return ERHIPipeline::Graphics; } virtual void RHIDispatchComputeShader(uint32 ThreadGroupCountX, uint32 ThreadGroupCountY, uint32 ThreadGroupCountZ) = 0; ... } 进入API层，类内部都会有一个用于定义当前上下文的对象，比如FVulkanCommandListContext,在执行上边那些命令时，会使用Manager来获得当前激活的commandBuffer来记录命令\n1 2 3 4 5 6 7 class FVulkanCommandListContext : public IRHICommandContext{ ... private: ... FVulkanCommandBufferManager* CommandBufferManager; ... } 当创建一个IRHICommandContext时，在Vulkan中就是创建一个新的CommandBuffer\n1 2 3 // Create CommandBufferManager, contain all active buffers CommandBufferManager = new FVulkanCommandBufferManager(InDevice, this); CommandBufferManager-\u0026gt;Init(this); 向上 FRHICommand UE的命令是用链表串起来的\n1 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 struct FRHICommandBase { FRHICommandBase* Next = nullptr; // 命令是链表连起来的 virtual void ExecuteAndDestruct(FRHICommandListBase\u0026amp; CmdList) = 0; // 具体执行方法 }; // 封装了lambda来执行命令 template \u0026lt;typename RHICmdListType, typename LAMBDA\u0026gt; struct TRHILambdaCommand final : public FRHICommandBase { LAMBDA Lambda; #if CPUPROFILERTRACE_ENABLED const TCHAR* Name; #endif TRHILambdaCommand(LAMBDA\u0026amp;\u0026amp; InLambda, const TCHAR* InName) : Lambda(Forward\u0026lt;LAMBDA\u0026gt;(InLambda)) #if CPUPROFILERTRACE_ENABLED , Name(InName) #endif {} void ExecuteAndDestruct(FRHICommandListBase\u0026amp; CmdList) override final { TRACE_CPUPROFILER_EVENT_SCOPE_TEXT_ON_CHANNEL(Name, RHICommandsChannel); Lambda(*static_cast\u0026lt;RHICmdListType*\u0026gt;(\u0026amp;CmdList)); Lambda.~LAMBDA(); } }; FRHICommand继承Base后只添加了一个函数 （调用命令）\n1 2 3 4 5 6 7 8 9 10 11 12 13 template\u0026lt;typename TCmd, typename NameType = FUnnamedRhiCommand\u0026gt; struct FRHICommand : public FRHICommandBase { void ExecuteAndDestruct(FRHICommandListBase\u0026amp; CmdList) override final { LLM_SCOPE_BYNAME(TEXT(\u0026#34;RHIMisc/CommandList/ExecuteAndDestruct\u0026#34;)); TRACE_CPUPROFILER_EVENT_SCOPE_ON_CHANNEL_STR(NameType::TStr(), RHICommandsChannel); TCmd* ThisCmd = static_cast\u0026lt;TCmd*\u0026gt;(this); ThisCmd-\u0026gt;Execute(CmdList); // 调用命令 ThisCmd-\u0026gt;~TCmd(); // 释放命令 } }; 再下一层就是各种具体命令 比如FRHICommandSetShaderParameters、FRHICommandSetViewport等等，每个具体命令都有一个Execute方法\n1 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 void FRHICommandDrawPrimitive::Execute(FRHICommandListBase\u0026amp; CmdList) { RHISTAT(DrawPrimitive); INTERNAL_DECORATOR(RHIDrawPrimitive)(BaseVertexIndex, NumPrimitives, NumInstances); } void FRHICommandDrawIndexedPrimitive::Execute(FRHICommandListBase\u0026amp; CmdList) { RHISTAT(DrawIndexedPrimitive); INTERNAL_DECORATOR(RHIDrawIndexedPrimitive)(IndexBuffer, BaseVertexIndex, FirstInstance, NumVertices, StartIndex, NumPrimitives, NumInstances); } void FRHICommandSetBlendFactor::Execute(FRHICommandListBase\u0026amp; CmdList) { RHISTAT(SetBlendFactor); INTERNAL_DECORATOR(RHISetBlendFactor)(BlendFactor); } void FRHICommandSetStreamSource::Execute(FRHICommandListBase\u0026amp; CmdList) { RHISTAT(SetStreamSource); INTERNAL_DECORATOR(RHISetStreamSource)(StreamIndex, VertexBuffer, Offset); } void FRHICommandSetViewport::Execute(FRHICommandListBase\u0026amp; CmdList) { RHISTAT(SetViewport); INTERNAL_DECORATOR(RHISetViewport)(MinX, MinY, MinZ, MaxX, MaxY, MaxZ); } 再下面就是各个API具体的实现了\nFRHICommandList 它内部的函数也是这种API，根据情况Bypass()选择直接调用API还是把命令缓存起来\n1 2 3 4 5 6 7 8 9 10 FORCEINLINE_DEBUGGABLE void DrawPrimitive(uint32 BaseVertexIndex, uint32 NumPrimitives, uint32 NumInstances) { //check(IsOutsideRenderPass()); if (Bypass()) { GetContext().RHIDrawPrimitive(BaseVertexIndex, NumPrimitives, NumInstances); return; } ALLOC_COMMAND(FRHICommandDrawPrimitive)(BaseVertexIndex, NumPrimitives, NumInstances); } 主要看两个指针\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class FRHICommandListBase{ protected: FRHICommandBase* Root = nullptr; // 命令链表的起始 FRHICommandBase** CommandLink = nullptr; // 命令结束的位置，用于添加新命令 } // 新建一个命令的流程 FORCEINLINE_DEBUGGABLE void* AllocCommand(int32 AllocSize, int32 Alignment) { checkSlow(!IsExecuting()); checkfSlow(!Bypass(), TEXT(\u0026#34;Invalid attempt to record commands in bypass mode.\u0026#34;)); FRHICommandBase* Result = (FRHICommandBase*) MemManager.Alloc(AllocSize, Alignment); // 内存中申请空间 ++NumCommands; *CommandLink = Result; CommandLink = \u0026amp;Result-\u0026gt;Next; // 其实就是链表添加一个尾结点的操作 return Result; } Base下一层FRHIComputeCommandList\n1 class FRHIComputeCommandList : public FRHICommandListBase{} 再下一层才是FRHICommandList。听了UE的RHI介绍视频，我觉得这样设计是因为渲染Queue可以进行渲染命令也可以是计算命令（我现在自己的项目的ComputeShader都是放在渲染Queue中进行的）。是有包含关系的。\n1 class FRHICommandList : public FRHIComputeCommandList 再下一层是立即模式\n1 class FRHICommandListImmediate : public FRHICommandList 再进到API层面还有封装\nMesh的DrawCall流程 数据流转：\nFPrimitiveSceneProxy-\u0026gt;FMeshBatch-\u0026gt;FMeshDrawCommand-\u0026gt;RHICommandList-\u0026gt;GPU\nFMeshDrawCommand 存储渲染一个Mesh的具体信息，粒度是DrawCall级别的了，上层的收集工作目的就是生成各种FMeshDrawCommand，FMeshDrawCommand进一步才转化成RHI层指令（CommandList）\n1 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 class FMeshDrawCommand { public: /** * Resource bindings */ FMeshDrawShaderBindings ShaderBindings; // 着色器顶点输入信息 FVertexInputStreamArray VertexStreams; // 顶点数据 FRHIBuffer* IndexBuffer; // 索引数据 /** * PSO */ FGraphicsMinimalPipelineStateId CachedPipelineId; // PSO /** * Draw command parameters 三角形相关信息 */ uint32 FirstIndex; uint32 NumPrimitives; uint32 NumInstances; // 实例数量，这也就是说DrawCommand对应的是一个SubMesh的所有实例 /** Submits commands to the RHI Commandlist to draw the MeshDrawCommand. */ /** 提交这个DrawCall到CommandList **/ static bool SubmitDraw( const FMeshDrawCommand\u0026amp; RESTRICT MeshDrawCommand, const FGraphicsMinimalPipelineStateSet\u0026amp; GraphicsMinimalPipelineStateSet, const FMeshDrawCommandSceneArgs\u0026amp; SceneArgs, uint32 InstanceFactor, FRHICommandList\u0026amp; CommandList, class FMeshDrawCommandStateCache\u0026amp; RESTRICT StateCache); ..... } SubmitDraw主要是一个Begin和一个End组成\n1 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 57 58 59 60 61 // SubmitDraw的实现 bool FMeshDrawCommand::SubmitDraw( const FMeshDrawCommand\u0026amp; RESTRICT MeshDrawCommand, const FGraphicsMinimalPipelineStateSet\u0026amp; GraphicsMinimalPipelineStateSet, const FMeshDrawCommandSceneArgs\u0026amp; SceneArgs, uint32 InstanceFactor, FRHICommandList\u0026amp; RHICmdList, FMeshDrawCommandStateCache\u0026amp; RESTRICT StateCache) { #if WANTS_DRAW_MESH_EVENTS RHI_BREADCRUMB_EVENT_CONDITIONAL(RHICmdList, GShowMaterialDrawEvents != 0, \u0026#34;%s %s (%u instances)\u0026#34; , MeshDrawCommand.DebugData.MaterialRenderProxy-\u0026gt;GetMaterialName() , MeshDrawCommand.DebugData.ResourceName , MeshDrawCommand.NumInstances * InstanceFactor ); #endif bool bAllowSkipDrawCommand = true; if (SubmitDrawBegin(MeshDrawCommand, GraphicsMinimalPipelineStateSet, SceneArgs, InstanceFactor, RHICmdList, StateCache, bAllowSkipDrawCommand)) { SubmitDrawEnd(MeshDrawCommand, SceneArgs, InstanceFactor, RHICmdList); return true; } return false; } // Begin 调用RHI设置各种参数 bool FMeshDrawCommand::SubmitDrawBegin( const FMeshDrawCommand\u0026amp; RESTRICT MeshDrawCommand, const FGraphicsMinimalPipelineStateSet\u0026amp; GraphicsMinimalPipelineStateSet, const FMeshDrawCommandSceneArgs\u0026amp; SceneArgs, uint32 InstanceFactor, FRHICommandList\u0026amp; RHICmdList, FMeshDrawCommandStateCache\u0026amp; RESTRICT StateCache, bool bAllowSkipDrawCommand) { // 1. PipeLineState的设置，下到RHI层 RHICmdList.SetGraphicsPipelineState(PipelineState, Initializer.BoundShaderState, StencilRef, bApplyAdditionalState); ..... } // End中主要就是调用RHI的DrawIndexedPrimitive（上层是把对应的Command存储的RHICommandList而已） void FMeshDrawCommand::SubmitDrawEnd(const FMeshDrawCommand\u0026amp; MeshDrawCommand, const FMeshDrawCommandSceneArgs\u0026amp; SceneArgs, uint32 InstanceFactor, FRHICommandList\u0026amp; RHICmdList) { ..... if (MeshDrawCommand.IndexBuffer) { if (MeshDrawCommand.NumPrimitives \u0026gt; 0 \u0026amp;\u0026amp; !bDoOverrideArgs) { RHICmdList.DrawIndexedPrimitive( MeshDrawCommand.IndexBuffer, MeshDrawCommand.VertexParams.BaseVertexIndex, 0, MeshDrawCommand.VertexParams.NumVertices, MeshDrawCommand.FirstIndex, MeshDrawCommand.NumPrimitives, MeshDrawCommand.NumInstances * InstanceFactor ); } ..... } FMeshBatch 比如相同材质、相同xxx的一组模型放在一个Batch，一个Batch有一组FMeshBatchElement，FmeshBatch由FSceneRenderer的Gatherxxx这个函数来收集，这个函数有一个参数是\nMeshDrawCommand里没有材质信息，材质信息来自MeshBatch\nFMeshPassProcessor 上层收集组装成FMeshBatch，通过FMeshPassProcessor的BuildMeshDrawCommands来构建FMeshDrawCommand（有历史原因，4.22之前是没有FMeshDrawCommand的）\n1 2 3 4 5 6 7 // 代码里这个For可以看出来，Batch的每个element都变成了一个FMeshDrawCommand for (int32 BatchElementIndex = 0; BatchElementIndex \u0026lt; NumElements; BatchElementIndex++) { if ((1ull \u0026lt;\u0026lt; BatchElementIndex) \u0026amp; BatchElementMask) { const FMeshBatchElement\u0026amp; BatchElement = MeshBatch.Elements[BatchElementIndex]; FMeshDrawCommand\u0026amp; MeshDrawCommand = DrawListContext-\u0026gt;AddCommand(SharedMeshDrawCommand, NumElements); RDG FRDGResource RDG层面也封装了一层Resource，其内部就是FRHIResource\n1 2 3 4 5 6 7 8 /** Generic graph resource. */ class FRDGResource { ..... private: FRHIResource* ResourceRHI = nullptr; ..... }; FRDGTexture继承来自FRDGResource。但是在RDG层的Resource不能自己创建，必须统一经过RDGBuilder来创建\n1 2 3 4 5 6 7 8 /** A render graph resource with an allocation lifetime tracked by the graph. May have child resources which reference it (e.g. views). */ class FRDGViewableResource : public FRDGResource { /** Render graph tracked Texture. */ class FRDGTexture final : public FRDGViewableResource {....} FRDGPass 把一个Pass的渲染指令进行存储\nFRDGBuilder 比如贴图的创建需要RDGBuilder的CreateTexture来创建\n1 RENDERCORE_API FRDGTextureRef CreateTexture(const FRDGTextureDesc\u0026amp; Desc, const TCHAR* Name, ERDGTextureFlags Flags = ERDGTextureFlags::None); 对于已经存在的资源，也需要通过RDGBuilder进行注册，生命周期不经过RDG管理\n1 2 3 4 ** Registers a external pooled render target texture to be tracked by the render graph. The name of the registered RDG texture is pulled from the pooled render target. */ RENDERCORE_API FRDGTextureRef RegisterExternalTexture( const TRefCountPtr\u0026lt;IPooledRenderTarget\u0026gt;\u0026amp; ExternalPooledTexture, ERDGTextureFlags Flags = ERDGTextureFlags::None); 一个Pass需要输出的资源需要经过Extraction来输出\n1 2 3 4 5 6 /** Queues a pooled render target extraction to happen at the end of graph execution. For graph-created textures, this extends * the lifetime of the GPU resource until execution, at which point the pointer is filled. If specified, the texture is transitioned * to the AccessFinal state, or kDefaultAccessFinal otherwise. */ RENDERCORE_API void QueueTextureExtraction(FRDGTextureRef Texture, TRefCountPtr\u0026lt;IPooledRenderTarget\u0026gt;* OutPooledTexturePtr, ERDGResourceExtractionFlags Flags = ERDGResourceExtractionFlags::None); RENDERCORE_API void QueueTextureExtraction(FRDGTextureRef Texture, TRefCountPtr\u0026lt;IPooledRenderTarget\u0026gt;* OutPooledTexturePtr, ERHIAccess AccessFinal, ERDGResourceExtractionFlags Flags = ERDGResourceExtractionFlags::None); 上面三种情况就定义了资源在一个Pass的创建、输入、输出设置\n添加一个Pass到RDG，ExecuteLambda就是存储具体的渲染流程\n1 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 template \u0026lt;typename ParameterStructType, typename ExecuteLambdaType\u0026gt; FRDGPassRef AddPass(FRDGEventName\u0026amp;\u0026amp; Name, const ParameterStructType* ParameterStruct, ERDGPassFlags Flags, ExecuteLambdaType\u0026amp;\u0026amp; ExecuteLambda){ // 创建一个Pass对象 FRDGDispatchPass* Pass = Allocators.Root.AllocNoDestruct\u0026lt;DispatchPassType\u0026gt;( Forward\u0026lt;FRDGEventName\u0026amp;\u0026amp;\u0026gt;(Name), ParametersMetadata, ParameterStruct, OverridePassFlags(NameString, Flags), Forward\u0026lt;LaunchLambdaType\u0026amp;\u0026amp;\u0026gt;(LaunchLambda)); IF_RDG_ENABLE_DEBUG(ClobberPassOutputs(Pass)); // 存储Pass数组 FRDGPassRegistry Passes; Passes.Insert(Pass); // 设置Pass的资源\tSetupParameterPass(Pass); } // 收集Passzi FRDGPass* FRDGBuilder::SetupParameterPass(FRDGPass* Pass) { IF_RDG_ENABLE_DEBUG(UserValidation.ValidateAddPass(Pass, AuxiliaryPasses.IsActive())); CSV_SCOPED_TIMING_STAT_EXCLUSIVE_CONDITIONAL(RDGBuilder_SetupPass, GRDGVerboseCSVStats != 0); #if RDG_EVENTS TOptional\u0026lt;TRDGEventScopeGuard\u0026lt;FRDGScope_RHI\u0026gt;\u0026gt; PassNameScope; if (ScopeState.ScopeMode == ERDGScopeMode::AllEventsAndPassNames) { FRDGEventName Name = Pass-\u0026gt;GetEventName(); PassNameScope.Emplace(*this, ERDGScopeFlags::None, FRHIBreadcrumbData(__FILE__, __LINE__, TStatId(), NAME_None), MoveTemp(Name)); } #endif SetupPassInternals(Pass); if (ParallelSetup.bEnabled) { MarkResourcesAsProduced(Pass); AsyncSetupQueue.Push(FAsyncSetupOp::SetupPassResources(Pass)); } else { SetupPassResources(Pass); } SetupAuxiliaryPasses(Pass); return Pass; } ","date":"2025-11-13T20:22:40+08:00","image":"https://sdpyy1.github.io/image-20251121130145117.png","permalink":"https://sdpyy1.github.io/p/%E5%88%9D%E8%AF%86ue%E6%BA%90%E7%A0%81rhi%E6%B8%B2%E6%9F%93%E6%9C%BA%E5%88%B6rdg/","title":"初识UE源码（RHI、渲染机制、RDG）"},{"content":"Ray-Sphere Intersection 显式：“谁等于谁的函数”。 隐式：“变量们满足某个共同等式” 球公式用向量形式表达为： $$ (\\mathbf{C} - \\mathbf{P}) \\cdot (\\mathbf{C} - \\mathbf{P}) = (C_x - x)^2 + (C_y - y)^2 + (C_z - z)^2 $$ 也就是 $$ (\\mathbf{C} - \\mathbf{P}) \\cdot (\\mathbf{C} - \\mathbf{P}) = r^2 $$ 给一个点P(t)，如果与圆相加，说明这个公式有解 $$ (\\mathbf{C} - \\mathbf{P}(t)) \\cdot (\\mathbf{C} - \\mathbf{P}(t)) = r^2 $$P(t)可以用Ray来表示，Ray = Q + td\n经过一系列变换，最后用求根公式可以直接算出t的取值，或者用判别式直接获取解的个数\n1 2 3 4 5 6 7 8 9 10 11 // 判断r是否与这个球体相交 bool hit_sphere(const point3\u0026amp; center, double radius, const ray\u0026amp; r) { auto a = dot(r.direction(),r.direction()); auto b = -2*dot(r.direction(),center-r.origin()); auto c = dot(center-r.origin(),center-r.origin()) - radius * radius; auto discriminant = b*b - 4 *a *c; return discriminant \u0026gt; 0; } 1 2 3 if(hit_sphere(point3(0,0,-1),0.2,r)){ return color(1,0,0); } 1 2 3 4 5 6 7 8 9 10 11 12 13 // 返回t double hit_sphere(const point3\u0026amp; center, double radius, const ray\u0026amp; r) { vec3 oc = center - r.origin(); auto a = dot(r.direction(), r.direction()); auto b = -2.0 * dot(r.direction(), oc); auto c = dot(oc, oc) - radius*radius; auto discriminant = b*b - 4*a*c; if (discriminant \u0026lt; 0) { return -1.0; } else { return (-b - std::sqrt(discriminant) ) / (2.0*a); } } Simplifying Code 求根公式中，把原始b带入发现可以化简\n抗锯齿 像素中心击中物体就是一种颜色，没有击中就是另一种颜色，没有过度，锯齿就会出现。（对一个像素进行着色，一条光线只取一个点的颜色，但是一个像素对应世界位置的一片颜色不一定是一样的，而是连续的）\n初始采样点为像素中心，每个像素多次调用git_ray(),他会在一个像素中随机进行采样\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 vec3 sample_square() const { // Returns the vector to a random point in the [-.5,-.5]-[+.5,+.5] unit square. return vec3(random_double() - 0.5, random_double() - 0.5, 0); } ray get_ray(int i, int j) const { // Construct a camera ray originating from the origin and directed at randomly sampled // point around the pixel location i, j. auto offset = sample_square(); auto pixel_sample = pixel00_loc + ((i + offset.x()) * pixel_delta_u) + ((j + offset.y()) * pixel_delta_v); auto ray_origin = center; auto ray_direction = pixel_sample - ray_origin; return ray(ray_origin, ray_direction); } 漫反射材质 hit后，不是直接返回颜色，而是继续发射一根随机半球方向的光线继续进行光线追踪，击中物体后累加，另外需要设置递归深度防止无限递归\n1 2 3 4 if (world.hit(r, interval(0, infinity), rec)) { vec3 direction = random_on_hemisphere(rec.normal); return 0.5 * ray_color(ray(rec.p, direction), depth-1, world); } Fixing Shadow Acne 如果光线起点恰好位于表面正下方（浮点数的舍入误差），它可能会再次与该表面相交。\n镜面反射 v+2B就是静面反射向量，B可以通过dot(v,n)*n来求\n1 2 3 inline vec3 reflect(const vec3\u0026amp; v, const vec3\u0026amp; n) { return v - 2*dot(v,n)*n; } RT实现这种间接光照太容易了\n电介质材质（折射） 当从折射率高的一边看向折射率低的一边，如果入射角度过大，用反射定律计算出来的折射光线角度超过了90°，这时候就应该只进行反射，而不进行折射。（教程有折射相关的内容，本质就是把反射替换成折射来进行二次弹射）\nDefocus Blur 景深/散焦模糊 成像平面的距离改用焦距来定义，所以在成像平面位置的渲染才是清晰的\n1 2 3 4 5 // 根据fov和Near计算视口宽高 auto theta = degrees_to_radians(vfov); auto h = std::tan(theta/2); auto viewport_height = 2 * h * focus_dist; auto viewport_width = viewport_height * (double(image_width)/image_height); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 构建光线时，先在像素内部随机采样，获得一个摄像终点，但是射线起点不再是相机的位置，而是以相机位置为中心的一个圆盘（可调整半径）上的任意一点作为摄像起点 ray get_ray(int i, int j) const { // Construct a camera ray originating from the defocus disk and directed at a randomly // sampled point around the pixel location i, j. auto offset = sample_square(); auto pixel_sample = pixel00_loc + ((i + offset.x()) * pixel_delta_u) + ((j + offset.y()) * pixel_delta_v); auto ray_origin = (defocus_angle \u0026lt;= 0) ? center : defocus_disk_sample(); auto ray_direction = pixel_sample - ray_origin; return ray(ray_origin, ray_direction); } 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 57 58 59 60 int main() { hittable_list world; auto ground_material = make_shared\u0026lt;lambertian\u0026gt;(color(0.5, 0.5, 0.5)); world.add(make_shared\u0026lt;sphere\u0026gt;(point3(0,-1000,0), 1000, ground_material)); for (int a = -11; a \u0026lt; 11; a++) { for (int b = -11; b \u0026lt; 11; b++) { auto choose_mat = random_double(); point3 center(a + 0.9*random_double(), 0.2, b + 0.9*random_double()); if ((center - point3(4, 0.2, 0)).length() \u0026gt; 0.9) { shared_ptr\u0026lt;material\u0026gt; sphere_material; if (choose_mat \u0026lt; 0.8) { // diffuse auto albedo = color::random() * color::random(); sphere_material = make_shared\u0026lt;lambertian\u0026gt;(albedo); world.add(make_shared\u0026lt;sphere\u0026gt;(center, 0.2, sphere_material)); } else if (choose_mat \u0026lt; 0.95) { // metal auto albedo = color::random(0.5, 1); auto fuzz = random_double(0, 0.5); sphere_material = make_shared\u0026lt;metal\u0026gt;(albedo, fuzz); world.add(make_shared\u0026lt;sphere\u0026gt;(center, 0.2, sphere_material)); } else { // glass sphere_material = make_shared\u0026lt;dielectric\u0026gt;(1.5); world.add(make_shared\u0026lt;sphere\u0026gt;(center, 0.2, sphere_material)); } } } } auto material1 = make_shared\u0026lt;dielectric\u0026gt;(1.5); world.add(make_shared\u0026lt;sphere\u0026gt;(point3(0, 1, 0), 1.0, material1)); auto material2 = make_shared\u0026lt;lambertian\u0026gt;(color(0.4, 0.2, 0.1)); world.add(make_shared\u0026lt;sphere\u0026gt;(point3(-4, 1, 0), 1.0, material2)); auto material3 = make_shared\u0026lt;metal\u0026gt;(color(0.7, 0.6, 0.5), 0.0); world.add(make_shared\u0026lt;sphere\u0026gt;(point3(4, 1, 0), 1.0, material3)); camera cam; cam.aspect_ratio = 16.0 / 9.0; cam.image_width = 1200; cam.samples_per_pixel = 500; cam.max_depth = 50; cam.vfov = 20; cam.lookfrom = point3(13,2,3); cam.lookat = point3(0,0,0); cam.vup = vec3(0,1,0); cam.defocus_angle = 0.6; cam.focus_dist = 10.0; cam.render(world); } 每个像素采样500次，并且深度为50的结果\n","date":"2025-11-12T13:26:10+08:00","image":"https://sdpyy1.github.io/image-20251114165052053.png","permalink":"https://sdpyy1.github.io/p/raytracingoneweek/","title":"RayTracingOneWeek"},{"content":"API差异图 2025.12.16 https://zhuanlan.zhihu.com/p/582237962 全局光照浅析\n现实中的反射大部分情况下并不是mirror reflections也不是diffuse reflections，而是介于两者之间，这种反射叫做glossy reflections，对应的材质也叫做glossy 。 这下把这些名词都对应上了~ Caustics焦散在图形学中的定义是指光线经过高光物体的反射或折射，然后弹射到漫反射表面，再弹射到眼睛的效果，如上图中书下面阴影中高亮的部分。Caustics很难实现，即便在离线渲染中使用path tracing也很难实现，通常会借助Photon Mapping光子映射技术来实现Caustics。 第一次接触这个词！\n下图可以看到在书下面的阴影中也有很亮的地方，叫做焦散\n下面是几种离线渲染的全局光照方案\nWhitted-Style Ray Tracing\n从视点发射光线。 光线碰撞到物体表面，如果表面材质是diffuse材质则光线停止传播，否则沿着反射方向继续传播。 每个碰撞点都需要发射一条shadow ray做遮挡查询。 shading point需要累加所有碰撞点的Irradiance，作为最终的光照结果。 Whitted-Style Ray Tracing算法无法实现右边的glossy reflection效果，因为光线是按照镜面反射的方式传播的，真实世界大部分物体都不是mirror materail。\n原来 Mirror和Glossy是要区分的。另外Games101讲的Whitted-Style Ray Tracing原来就是丐版的pathTracing，因为只进行镜面反射，所以没法实现glossy的反射\n因为漫反射不递归，所以漫反射全局光照效果肯定是没有的\nDistributed Ray Tracing\n基本思路和Whitted-Style Ray Tracing一样，只是为了解决漫反射光线传播，在每一个碰撞点上的半球空间都会发射多条光线来模拟漫反射。\n算法的缺点非常明显就是计算量爆炸，这个算法即便是离线渲染也是不能接受的，不过这个算法应该是最朴素也是效果最好的光线追踪算法，说不定以后硬件性能爆炸，这个算法也有回春的一天。\nPath Tracing\n这个算法的最大问题是光线命中光源的概率很低，因此会有很多噪点。\n通过加大光源面积和投射光线的数量可以缓解这个问题。另外可以把每个碰撞点的光照分为直接光照和间接光照，直接光照从光源中进行采样以保证命中概率，并且直接光照会做shadow ray，而间接光照还是使用上面的算法，这样噪点仅存在于间接光照的部分\nBidirectional Path Tracing\n这里能看出PathTracing的缺点：如果场景主要是间接光照照亮的，那PathTracing效果就会很差\n有的时候光源的环境很复杂，使用上面的Path Tracing依然会获得很低的光源命中率，如下图：\n现实中这种情形，照亮场景的应该是间接光照，而间接光照的命中率又很低，所以上面的算法不太适用这种场景\n双向路径跟踪算法（Bidirectional Path Tracing，BDPT）在路径跟踪算法的基础之上，额外从光源出发创建光路，再连接从照相机追溯的光路和从光源出发的光路上的点，创建多条光路，并根据光线从这些光路传播的概率，合并各个光路传递的辐射亮度，即进行所谓的多重重要性抽样（multiple importance sampling），计算光线最后进入照相机生成图像的光照结果，如下图：\n光子映射Photon Mapping\nPath Tracing算法最大的问题就是光路命中光源的概率太低，即便是使用大量的SPP也会产生很多高频的噪点。另外在光线传播的过程中光路都是随机生成的，Caustics这种需要先弹射到镜面然后再弹射到漫反射物体的命中概率就更低了，因此Path Tracing很难模拟Caustics效果。\n光子映射的基本算法分成两个Pass。\nPass1-光源从随机方向发射光子，并在一切表面上弹射，如果弹射的表面是diffuse材质则记录光子信息包括光子的POS，光子的能量POWER，光子的入射方向Dir，可以把这些光子理解成为次级光源，使用俄罗斯轮盘算法结束光子的递归弹射，这样可以保证能量守恒。这些光子信息与视点无关，所以可以保存起来供后续pass使用，这也是空间换时间的算法。 Pass2-从相机射出的光线，打到非diffuse材质表面进行反射或折射，打到diffuse材质表面则根据收集半径检索附近的光子进行间接光照计算。 下面是实时渲染的全局光照解决方案\nLight Probe(Irradiance Volume)\nlight probe就是在空间中摆放很多的probe，这些probe可以当成次级光源，在离线的时候将irradiance保存到probe中，通常使用球鞋函数来缓存光照信息。\n这样空间中的每个点都可以找到离它最近的四个probe，这四个probe可以构成一个四面体，然后计算四面体的重心位置，通过重心位置和坐标点进行差值来计算最终的光照信息，如下图：\nDDGI其实就是Irradiance Volume的实时版本，它利用实时光线追踪来计算probe信息，而Irradiance Volume是离线计算的，从这点上来说DDGI是完全支持动态光源的算法，但是它依赖可以做实时光线追踪的硬件或者是使用Ray Marching。另外DDGI使用了切比雪夫不等式来预测probe是否被遮挡，减缓漏光问题。DDGI的核心是优化Irradiance volume的更新\n漏光的原因是采样到了实际上被遮挡的探针，DDGI使用切比雪夫不等式原来就是用来预测Probe是否被遮挡\n下面还介绍了LPV、VXGI，这个等实现时再看\nGIBS\nGIBS与VXGI不同之处是VXGI将场景的物体表面离散成了voxel，而GIBS将场景表面离散成了surfel。描述一个surfel的基本数据结构是位置，法线和半径，这就定义了一个2D的圆形表面\nPRT\nPRT就是将光照方程进行拆解，然后用球谐函数记录每个部分，利用球谐函数的性质还原光照方程，因为light transport部分包含场景的遮挡关系且这部分是预计算的所以PRT只支持静态场景动态光源。\nSSGI\nssgi是基于屏幕空间的全局光照算法，利用深度信息进行Ray Marching，效率不如SDF，但是不需要额外的存储空间，因为是基于屏幕空间的光照算法，所以缺少屏幕外的光照信息。\nLumen\nue5的lumen主要是实现了一套软光线追踪算法，其中涉及的内容几乎包括了上面所有介绍的实时全局光照算法，会单独写一篇文章介绍，这里就不详细介绍了。\n总结一下实时全局光照算法，基本分为三个步骤：\n离散化场景空间或物体表面，probe，voxel，surfel，均匀网格，还有一个lumen的mesh card后面介绍。 计算Irradiance cache。 利用Irradiance cache还原光照方程。 另外 有一个东西是 SDF实现的raymarch不太了解\n2025.11.21 丛越 https://zhuanlan.zhihu.com/p/73016473\n看了人家的管线设计才发现自己费那么多劲琢磨架构是多么的无知，当前阶段还是多输入少输出吧\n原来Buffer还能这么复用（在不同时段通过别名资源重用内存）\nGPU驱动管线（下一步学习计划！）\nVulkan的内存模型 Vulkan 的内存分为 Host 和 Device 两大类，下图可以看到哪些是Device可见的，哪些是Host可见的\n并行 Command 尽管从 API 层面来看 Command 是依次录制的，但 GPU 在执行时是将 Queue 中的指令同时分发给 GPU 多核中的 Pipeline 执行，这就是 GPU 的并行计算机制。而这种并行，是不会保证执行的顺序，所以对于提交到 GPU 执行的 Command，需要了解以下几个事实：\nCommand 在 Command Buffer 中顺序并不代表在 GPU 内部执行的顺序。 提交到同一 Command Queue 中不同 Command Buffer 的顺序并不代表在 GPU 内部执行的顺序。 不同 Command Queue 的提交顺序不代表在 GPU 内部执行的顺序。 Pipeline Barrier TODO： https://zhuanlan.zhihu.com/p/100162469\n下面图片的每个块表示一个Pipeline Stage\n2025.11.11 SaeruHikari GBuffer压缩 Gbuffer是可以压缩的(https://zhuanlan.zhihu.com/p/95824400)。\nGBuffer会带来显卡的带宽压力, 所以现在的游戏一般会把GBuffer压缩到2~3张。比如把法线压缩到2通道并在使用时进行Decode, 或者不保存PosW到GBuffer, 而是在使用时使用深度重建。可以参照顽皮狗在Sig16分享的方案:\n不同的BRDF方案 可以学习并实践一下不同的BRDF方案，而不是就还是Lambert + Cook-Torrance\nDiffuse\n首先看漫反射部分, 目前比较流行的公式有这些可选:\nLambert Burley(Disney)[sig2012] Renormalized Disney Diffuse[sig2014] PBR diffuse for GGX_Smith[GDC 2017] MultiScattering Diffuse BRDF[sig2018] 在五个模型都尝试一遍过后选择了RenormalizedDisneyDiffuse。\n这里特别要提一句的是MultiScattering版本的diffuse BRDF, 在今年的sig也有对它实现的补充以及改进分享。但是实测之后发现效果并不好, 因为多散射对diffuse的影响实在太小, 这个模型并不能带来太多可见的改善。\n而Frostbite在14年sig[3]提出的Renormalized Disney Diffuse是带来观感改善较大的一个:\nMoving Frostbite To PBRwww.ea.com/frostbite/news/moving-frostbite-to-pb\n它主要对Burley模型进行了改进, 根据视角入射角对Diffuse进行补正来维持能量守恒, 在视角移动时产生的视觉效果更加细腻(当然光源太复杂就没那么明显了\u0026hellip;)。\nSpecular\n接下来来到BRDF里最重要, 组装起来也最好玩的Specular BRDF项:\n(Cook-Torrance)\nD(h): 法线分布函数, 描述微面元法线分布的概率, 具有正确朝向，能够将来自l的光反射到v的表面点的相对于表面面积的浓度。 F(l,h) : 菲涅尔方程, 描述不同的表面角下表面所反射的光线所占的比率。 G(l,v,h) : 几何函数/几何遮蔽，描述微平面自成阴影的属性，即m = h的未被遮蔽的表面点的百分比。 **分母 4(n·l)(n·v）：**校正因子，作为微观几何的局部空间和整个宏观表面的局部空间之间变换的微平面量的校正。 这里不再细致解释, 列出常用的模型, 这些模型在UE4几乎都有实现, 可以到BRDF.ush去参考(抄):\nSpecular D:\nBlinn-Phong[1977] GGX/GTR[2007/2012] (GGX(GTR2)作为GTR的特例) 以及各种各向异性版本\u0026hellip; SpecularF\nCook-Torrance[1982] Schlick[1994] (一般选用这个, 便宜且曲线非常贴紧参考值) SpecularG\nCook-Torrance[1982] Smith[1967] (被拓展为l, v的联合遮蔽函数, 具有多种变体) TAA 一直没管过引擎的走样问题。。。待学习完善\nRGD https://zhuanlan.zhihu.com/p/97022384 这里有RGD的实现思路，学习RGD可以参考\n反射 https://link.zhihu.com/?target=https%3A//github.com/rttrorg/rttr ， SaeruHikari用的这个反射\n多线程渲染? 博客提到的多线程是指多个queue，和我现在实现不一样啊\n2025.11.12 匿名资源、临时资源 这个东西我的项目有进行规整，所有资源都做了保存 （应该：无名未注册的资源会被保持在一个池内, 并由系统进行自动化的复用。）\nRDG 蓝色表示PassNode, 白色圆角表示ResourceNode, 实际资源存储在Container中。\n纹理、Buffer、Pass创建都是走的RDG\nRDG需要处理多帧资源比如TAA\n","date":"2025-11-11T13:43:46+08:00","permalink":"https://sdpyy1.github.io/p/%E5%8D%9A%E5%AE%A2%E6%97%A5%E8%AE%B0%E8%AE%B0%E5%BD%95%E7%9C%8B%E5%A4%A7%E4%BD%AC%E5%8D%9A%E5%AE%A2%E7%9A%84%E4%B8%80%E4%BA%9B%E6%80%9D%E8%80%83/","title":"博客日记（记录看大佬博客的一些思考）"},{"content":" 这里涉及的整套结构已经废弃，转为研究更轻薄的RHI层，放弃过度封装！\n资源描述符管理日志 目标：在创建Pass时，设置好Input的绑定资源，渲染时无需再设置\n每个shader每个Set一个资源描述符，读取Shader时自动创建（问题1：一个不一定够） Resize问题 （Resize后图片被销毁）（解决1：在渲染时判断是否被销毁，重新获取） 一个Pass需要多个描述符（绑定图片的不同layer/mip层的VkImageView）（解决1：创建Pass时，提交额外需要的资源描述符数量）（Hazel的解决方案：额外使用的资源描述符集被定义为材质，每次DrawCall切换材质）（Dark的解决方案: pushDescriptorSetKHR渲染时实时创建并推送，这样很方便但是不好整合） 弃坑，整个架构都放弃了\n","date":"2025-11-10T11:03:46+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E8%B5%84%E6%BA%90%E6%8F%8F%E8%BF%B0%E7%AC%A6%E7%AE%A1%E7%90%86/","title":"游戏引擎开发实践（资源描述符管理）"},{"content":"下采样 为什么要下采样 要想光晕扩的足够大，第一件事情就是扩大模糊的范围。一种非常简单的思路就是死命加大滤波盒的尺寸，使用一个巨大的 kernal 对纹理进行模糊。但是性能上肯定是吃不消，单 pass 的纹理采样次数是 N^2 而双 pass 是 N+N\n此外还有一个问题，在处理高分辨率纹理时你需要等比地增加滤波盒的尺寸，才能形成同等大小的模糊。比如在 1000x1000 分辨率下用 250 像素的 kernal，模糊的结果占 1/4 屏幕，当分辨率增加到 2000x2000 的时候，要使用 500 像素的 kernal 才能达到同样的效果\n回到模糊的问题，模糊滤波的本质是查询 kernal 范围内的所有像素并加权平均，即范围查询问题。在计算机图形学中实现快速范围查询，通常会请到老朋友 Mipmap 出场。Mipmap 将图像大小依次折半形成金字塔，mip[i] 中的单个像素代表了 mip[i-1] 中的 2x2 像素块均值，也代表 mip[i-2] 中的 4x4 像素块均值：\n为什么不直接平均，而是用高斯模糊 为什么不直接用平均，而是用高斯模糊。 下图为直接使用mipmap，进行下采样\n下图为高斯模糊，明显更加圆滑，直接MipMap还是方形的\n所以进行下采样时，使用高斯模糊，而不是普通的平均。 这只是第一步\n直接将最高层级 mip 叠加到图像上虽然能够产生足够大的光晕扩散，但是发光物的中心区域不够明亮。此外，发光物和泛光之间没有过度而是直接跳变\n亮度跳变导致的\n为了实现发光物和最高层 mip 之间的过渡，我们需要叠加所有的 mip 层级到原图上。因为 mip[i] 是基于 mip[i-1] 进行计算的，相邻层级之间相对连续则不会产生跳变\n上采样 引擎实践 ","date":"2025-11-10T10:09:12+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E9%AB%98%E8%B4%A8%E9%87%8Fbloom/","title":"游戏引擎开发实践（高质量Bloom）"},{"content":"\n其实OpenGL渲染器已经完成的单次散射的大气渲染，但是对原理理解不透彻，这次准备更完善一点，并且实现整套的多重散射大气渲染，参考UE的实现，以及几篇论文\nPrecomputed Atmospheric Scattering Eric Bruneton, Fabrice Neyret在2008年发表的论文，在2017年有了一个示例代码\nTransmittance 透射率 基本概念：透射是可乘的\n对于 三点共线 $p, q, r$（顺序为 $p \\to q \\to r$） 从 p 到 r 的透射率 = 从 p 到 q 的透射 × 从 q 到 r 的透射 $$ T(p,r) = T(p,q) \\cdot T(q,r) $$ 这是光线传输的乘法性质，因为大气衰减是指数衰减，连续段可以相乘。 处理大气边界\n对于点对 $p, q$ 将从 $p$ 出发到 $q$ 的射线延长，找到它 与大气上下边界的最近交点 $i$ 透射可以表示为： $$ T(p,q) = \\frac{T(p,i)}{T(q,i)} $$ 特殊情况：如果 $[p,q]$ 与地面相交 → 透射为 0（光被阻挡） 直观理解： 你只需要知道从大气内部某点到大气边界的透射，再通过除法就能得到任意点对的透射。 透射率的预计算 离地心的距离 r 和天顶角的余弦$\\mu = \\cos\\theta$这两个参数，就可以描述从任意点出发沿着任意方向到大气层的路径上的传输衰减。\n所以预计算的是摄像机在任意高度，不同抬头角度看向大气层的边界的投射率\n// TODO: 知乎https://zhuanlan.zhihu.com/p/595576594上对坐标到纹理的重映射方法与论文不一样\n单次散射 光路即使没有经过太阳，太阳也对这条光路有贡献，因为本来不应该经过视线的光由于空气粒子的反弹进入了视线，形成光路\n光线在空气中主要会发生两种物理现象：散射和透射。\n散射：散射是指一束光和大气层中的微粒发生碰撞之后，众多粒子各奔东西四散而逃的物理现象。我们把逃逸到视线方向进而产生颜色贡献的光照能量记作 ：S\n透射（Transmittance）描述的是光在介质中穿行所造成的能量衰减，记作 ：$I = T(I_0)$\n所以单次散射的流程：\nT1透射 （衰减） S点散射 T2透射 （衰减） 事实上在大气层内太阳光可以被看做是平行光，因此需要对视线方向做积分来重建无数条光路的总和：\n散射理论 解决散射系数和相位函数是什么\n在大气渲染中也是类似的，我们通过 “散射系数” 和 “相位函数” 来描述光线散射的现象。散射系数$\\sigma$ 描述了光线和空气分子发生一次反弹之后逃逸到各个方向的（失去的）能量 总和\n剩下的能量并非完全进入我们的视线，而是会四下逃窜。因此相位函数 $phase(\\theta)$ 表述了反弹后剩下的能量有多少能够逃到指定的方向上。相位函数和 BRDF 一样在定义域上积分等于 1.0，这保证了能量守恒：\n理解就是：1. 散射系数决定有多少能量会进行散射 2. 相位函数决定这些散射的能量有多少会沿着$\\theta$方向进行散射（注意散射的能量才是渲染用的，而不是剩下的）\n将散射系数和相位函数简单相乘就能得到在某点发生一次散射之后，逃逸到某个方向上的能量大小\n$S = \\sigma \\cdot \\text{phase}(\\theta)$\n大气层并非完全均匀，大气分子的密度随着高度的增加而减少。越少的分子数目意味着发生散射的概率越低。此外大气对红、黄、蓝三种波长的光有着不同的散射量。因此散射系数通常是波长和高度的函数\n$\\sigma(\\lambda, h)$\n对于空气这种介质，它的密度通常随着海拔的增高而降低。密度的高低反应在数值上就是散射概率的减少，散射概率的减少对应着散射后剩余能量的增加。因此可以用海平面（Y=0）处的散射系数和海平面 h 处的高度密度衰减函数来描述任意高度的散射系数\n$\\sigma(\\lambda, h) = \\sigma(\\lambda, 0)\\rho(h)$\n单次散射预计算 这篇论文使用的是4D表，属于是把计算结果全部打表了\n单次散射函数依赖 4 个参数： $$ (r, \\mu, \\mu_s, \\nu) $$ $r$：观察点到地心的距离 $\\mu = \\cos(\\theta_v)$：视线方向与地心方向余弦 $\\mu_s = \\cos(\\theta_s)$：太阳方向与地心方向余弦 $\\nu = \\cos(\\theta_{vs})$：视线方向与太阳方向余弦 多重散射 二次散射的计算需要一次散射，三次需要二次，所以一层一层往上算来得到多重散射效果。\n太阳光可能散射两次才散射到ViewDir\n沿着ViewDir上某一点的计算不仅要计算来自太阳的散射，而是一个球面积分（也就是说任意方向都可能散射到这个点），对于球面上任何一点的贡献计算又是一层积分运算（球面积分的每一个采样方向，都要逐步进行 raymarch 到大气层边缘。如下图最右边蓝色线条）。所有2次多重散射就需要3重积分来求解\n设：\n$L^{(1)}(p, \\omega)$：点 p 向方向 ω 的单次散射光。 $L^{(2)}(p, \\omega)$：点 p 向方向 ω 的二次散射光。 $L^{(3)}(p, \\omega)$：点 p 向方向 ω 的三次散射光。 那么： $$ L^{(2)}(p, \\omega) = \\int_{q, \\omega'} L^{(1)}(q, \\omega') \\cdot f_s(\\omega', \\omega) $$ 以此类推： $$ L^{(n)}(p, \\omega) = \\int_{q, \\omega'} L^{(n-1)}(q, \\omega') \\cdot f_s(\\omega', \\omega) $$ 所以：\n计算第 n 阶散射，必须先有第 n−1 阶的结果。\n有点懂，但没完全懂\u0026hellip;.\nA Scalable and Production Ready Sky and Atmosphere Rendering Technique 2020年Epic发表介绍大气渲染的论文\n多重散射的优化 这篇论文主要对多重散射需要一层一层计算迭代来实现的情况做了简化。作者通过做出一些对结果影响不大的简化假设，大幅度地降低了Multiple Scattering计算的消耗，并且使用数值方法计算无穷阶Scattering之和变为可能。\n论文提到的Sky-View LUT就是最终渲染结果存储的纹理\nTODO: 在靠近地平线部分大气散射会变得高频，因此UV的映射不是线性，而是靠近地平线部分会分配更多像素\n大气透视查找表 除了天空，远处被大气覆盖的物体也要受到大气的影响。远处的物体对相机的贡献主要来自于两点，物体自身的颜色乘以着色点到相机的 transmittance，以及相机到着色点这段路径中间受到的大气散射。对应下图两条不同颜色的路径：\n分割Camera Frustum（和Cluster Rendering中的分割一样），计算每个格子到Camera方向的In-Scattering和Transmittance，保存在Volume Texture中； 在Volume Texture的z轴方向上根据Transmittance累加In-Scattering，使得每一个单元格保存的是该单元格到Camera的Luminance； 在Opaque渲染之后，做一次Post Processing，采样上述的Volume Texture，对场景中的物体添加Aerial Perspective； Transparent物体渲染时在VS中采样Volume Texture添加Aerial Perspective。 引擎实践 Transmittance Lut 用ComputerShader预计算一张2D表，记录每个高度，每个天顶角到大气外圈的透射率。\n流程是把UV坐标映射到X：不同天顶角余弦值（-1，1）,Y：不同高度（0，1）用得到的数据计算透射率后保存\n1 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 #version 450 core #ifdef COMPUTE_SHADER #define LOCAL_SIZE 8 #include \u0026#34;include/SkyCommon.glslh\u0026#34; layout(rgba32f, binding = 0) uniform writeonly image2D transmittanceLut; layout(local_size_x = LOCAL_SIZE, local_size_y = LOCAL_SIZE, local_size_z = 1) in; void main() { ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy); AtmosphereParameter Atmosphere; // TODO: AtmosphereUniform Atmosphere.PlanetRadius = 6360000.0; Atmosphere.AtmosphereHeight = 60000.0; Atmosphere.RayleighScatteringScalarHeight = 8000.0; Atmosphere.MieScatteringScalarHeight = 1200.0; Atmosphere.OzoneLevelCenterHeight = 25000.0; Atmosphere.OzoneLevelWidth = 15000.0; float bottomRadius = Atmosphere.PlanetRadius; float topRadius = Atmosphere.PlanetRadius + Atmosphere.AtmosphereHeight; ivec2 lutSize = imageSize(transmittanceLut); float viewHeight; float viewZenithCosAngle; vec2 uv = vec2(texelCoord) / vec2(lutSize); float r = 0.0; UvToTransmittanceLutParams(bottomRadius, topRadius, uv, viewZenithCosAngle, r); float sin_theta = sqrt(1.0 - viewZenithCosAngle * viewZenithCosAngle); vec3 CameraPosition = vec3(0.0, r, 0.0); vec3 viewDir = vec3(sin_theta, viewZenithCosAngle, 0); float dis = RayIntersectSphere(vec3(0,0,0), topRadius, CameraPosition, viewDir); vec3 hitPoint = CameraPosition + viewDir * dis; vec3 color = Transmittance(Atmosphere, CameraPosition, hitPoint); imageStore(transmittanceLut, texelCoord, vec4(color, 1.0)); } #endif 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // UV转LUT参数（高度、天顶角的余弦值） void UvToTransmittanceLutParams(float bottomRadius, float topRadius, vec2 uv, out float mu, out float r) { float x_mu = uv.x; float x_r = uv.y; float H = sqrt(max(0.0f, topRadius * topRadius - bottomRadius * bottomRadius)); float rho = H * x_r; r = sqrt(max(0.0f, rho * rho + bottomRadius * bottomRadius)); float d_min = topRadius - r; float d_max = rho + H; float d = d_min + x_mu * (d_max - d_min); mu = d == 0.0f ? 1.0f : (H * H - rho * rho - d * d) / (2.0f * r * d); mu = clamp(mu, -1.0f, 1.0f); } 最终生成的lut如下，在Vulkan中，0,0在右上角，所以和参考博客生成的lutY轴反转了\nSkyView Lut 用一张2D（球面坐标）纹理存储不同ViewDir下的大气渲染结果\n1 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 57 58 #version 450 core #ifdef COMPUTE_SHADER #define LOCAL_SIZE 8 #include \u0026#34;include/SkyCommon.glslh\u0026#34; // 二维 UV 坐标当作球面坐标 (θ, φ) 映射到单位球方向向量。 一个技巧：用2D球面来存储3D的方向向量了 vec3 UVToViewDir(vec2 uv) { float theta = (1.0 - uv.y) * PI; float phi = (uv.x * 2 - 1) * PI; float x = sin(theta) * cos(phi); float z = sin(theta) * sin(phi); float y = cos(theta); return vec3(x, y, z); } layout(rgba32f, binding = 0) uniform writeonly image2D SkyViewLut; layout(binding = 1) uniform sampler2D u_TransmittanceLut; layout(binding = 2) uniform sampler2D u_MultiScatteringLut; layout(std140, set = 0, binding = 3) uniform SceneData { DirectionalLight DirectionalLights; float EnvironmentMapIntensity; } u_Scene; layout(set = 0,binding = 4) uniform CameraDataUniform { mat4 view; mat4 proj; mat4 viewProj; float width; float height; float Near; float Far; vec3 CameraPosition; } u_CameraData; layout(local_size_x = LOCAL_SIZE, local_size_y = LOCAL_SIZE, local_size_z = 1) in; void main() { ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy); AtmosphereParameter Atmosphere = BuildAtmosphereParameter(); ivec2 lutSize = imageSize(SkyViewLut); vec2 uv = vec2(texelCoord) / vec2(lutSize); vec3 viewDir = UVToViewDir(uv); vec3 lightDir = normalize(-u_Scene.DirectionalLights.Direction); vec3 cameraPosition = normalize(u_CameraData.CameraPosition); float h = cameraPosition.y - Atmosphere.SeaLevel + Atmosphere.PlanetRadius; vec3 eyePos = vec3(0, h, 0); vec3 color = GetSkyView(Atmosphere, eyePos, viewDir, lightDir, -1.0f,u_TransmittanceLut, u_MultiScatteringLut); imageStore(SkyViewLut, texelCoord, vec4(color, 1.0)); } #endif 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 vec3 GetSkyView( in AtmosphereParameter param, vec3 eyePos, vec3 viewDir, vec3 lightDir, float maxDis, sampler2D _transmittanceLut, sampler2D _multiScatteringLut) { const int N_SAMPLE = 32; vec3 color = vec3(0, 0, 0); // 光线和大气层, 星球求交 float dis = RayIntersectSphere(vec3(0,0,0), param.PlanetRadius + param.AtmosphereHeight, eyePos, viewDir); float d = RayIntersectSphere(vec3(0,0,0), param.PlanetRadius, eyePos, viewDir); if(dis \u0026lt; 0) return color; if(d \u0026gt; 0) dis = min(dis, d); if(maxDis \u0026gt; 0) dis = min(dis, maxDis); // 带最长距离 maxDis 限制, 方便 aerial perspective lut 部分复用代码 float ds = dis / float(N_SAMPLE); vec3 p = eyePos + (viewDir * ds) * 0.5; vec3 sunLuminance = param.SunLightColor * param.SunLightIntensity; vec3 opticalDepth = vec3(0, 0, 0); for(int i=0; i\u0026lt;N_SAMPLE; i++) { // 积累沿途的湮灭系数 float h = length(p) - param.PlanetRadius; vec3 extinction = RayleighCoefficient(param, h) + MieCoefficient(param, h) + // scattering OzoneAbsorption(param, h) + MieAbsorption(param, h); // absorption opticalDepth += extinction * ds; vec3 t1 = TransmittanceToAtmosphere(param, p, lightDir, _transmittanceLut); vec3 s = Scattering(param, p, lightDir, viewDir); vec3 t2 = exp(-opticalDepth); // 单次散射 vec3 inScattering = t1 * s * t2 * ds * sunLuminance; color += inScattering; // 多重散射 vec3 multiScattering = GetMultiScattering(param, p, lightDir, _multiScatteringLut); color += multiScattering * t2 * ds * sunLuminance; p += viewDir * ds; } return color; } ","date":"2025-11-07T18:08:43+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E5%A4%A7%E6%B0%94%E6%B8%B2%E6%9F%93/","title":"游戏引擎开发实践（大气渲染）"},{"content":" 主要讲了欧拉积分、碰撞检测方法、碰撞结算方法 物理世界的对象:\nStatic Dynamic Trigger(参与GamePlay但是与物理世界无关) Kinematic（动力学Actor） Actor Shapes 欧拉积分 黎曼积分定义为： $$ \\int_a^b f(t) \\, dt = \\lim_{n\\to\\infty} \\sum_{i=0}^{n-1} f(t_i^*) \\, \\Delta t $$ 其中：\n区间 $[a,b]$ 被划分为很多小区间； 每个小区间长度为 $\\Delta t = \\frac{b-a}{n}$； $f(t_i^*)$ 是函数在区间上的一个采样值； 积分就是“采样 × 宽度”的极限 显式欧拉法 = 左端点采样（Left Riemann Sum） 隐式欧拉法 = 右端点采样（Right Riemann Sum）\n显式欧拉积分 用当前状态的参数来近似 $\\Delta T$ ，比如$t_0时速度为5，那就假设$$\\Delta T$内都是5来模拟积分。 显式欧拉法 = 用左端点采样的黎曼积分近似。\n这样求积分的问题是：力不守恒，用因为时间段内力不是恒定的。 如下图所示，如果总是以某一点的力的方向来求解，那物体总是朝着切线方向位移，来不及改变力的方向（因为在$\\Delta T$时间内力是恒定的）\n隐式欧拉方法 用未来的状态来计算$\\Delta T$时间内的数据 。隐式欧拉法 = 用右侧端点采样的黎曼积分近似。\n显然能量衰减了，我在$\\Delta T$时间内又不是一直这么小\n半隐式欧拉法 有点像Hack\u0026hellip;..\n这种方法数学上很稳定 $\\Delta T$取0.05就能很好模拟圆周运动了\n刚体运动学 这块课程很杂，应该需要用到什么学什么，这里先听课\n碰撞检测 一般分两步：\nAABB检测 计算碰撞点 Broad Phase BVH检测\n排序检测\nNarrow Phase 用球的半径来检测\n闵可夫斯基和（Minkowski Sum） 两个点集内全部互相加\n红色三角形表示Minkowski Sum的结果\n再定义减法\n最妙的来了，如果红色图像包了原点，那就碰撞了\n如何判断这个多边形过原点呢？\n分离轴原理(SAT) 碰撞处理 处理碰撞后分离\nHack 约束求解 需要再看\nScene Query 查询碰撞点、查询Sweep、查询Overlap\nraycast查询碰撞点：比如子弹\nSweep\nOverlap：比如爆炸范围overlap了那些Actor\n优化 模拟优化\nCCD：连续碰撞检测，离散检测可能会穿过薄面\n确定性模拟：不同的机器计算结果应该一致，比如显卡、移动端等差别\n","date":"2025-11-04T11:04:19+08:00","image":"https://sdpyy1.github.io/%E6%88%AA%E5%B1%8F2025-11-04-11.15.42.png","permalink":"https://sdpyy1.github.io/p/games104%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E%E7%9A%84%E5%9F%BA%E7%A1%80%E7%90%86%E8%AE%BA/","title":"Games104：物理引擎的基础理论"},{"content":" 这里涉及的整套结构已经废弃，转为研究更轻薄的RHI层，放弃过度封装！\n本文主要讲解GameEngine项目Vulkan封装，在渲染层只需要调用RHI层接口即可\n总览 所有底层对象创建都需要使用xxxSpecification来说明创建需求，调用RHI接口函数，创建底层Vulkan对象\nImage 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 enum class ImageFormat { None = 0, RED8UN, RED8UI, ... // Defaults Depth = DEPTH32F, }; // 图片用途 enum class ImageUsage { None = 0, Texture, Attachment, Storage, HostRead }; // 图片说明书 struct ImageSpecification { std::string DebugName; ImageFormat Format = ImageFormat::RGBA; // 图片格式 ImageUsage Usage = ImageUsage::Texture; // 用途 bool Transfer = false; // 是否需要转移 uint32_t Width = 1; uint32_t Height = 1; uint32_t Mips = 1; // mip多少层，在Vulkan中也需要自己指定 uint32_t Layers = 1; // Layer，图层通常用于立方体纹理（6 个面）、数组纹理（多个图像的集合）等场景。 bool CreateSampler = true; }; 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 class Image : public RendererResource { public: virtual ~Image() = default; virtual void Resize(const uint32_t width, const uint32_t height) = 0; virtual void Invalidate() = 0; virtual void Release() = 0; virtual uint32_t GetWidth() const = 0; virtual uint32_t GetHeight() const = 0; virtual glm::uvec2 GetSize() const = 0; virtual bool HasMips() const = 0; virtual float GetAspectRatio() const = 0; virtual ImageSpecification\u0026amp; GetSpecification() = 0; virtual const ImageSpecification\u0026amp; GetSpecification() const = 0; virtual Buffer GetBuffer() const = 0; virtual Buffer\u0026amp; GetBuffer() = 0; virtual uint64_t GetGPUMemoryUsage() const = 0; virtual void CreatePerLayerImageViews() = 0; virtual uint64_t GetHash() const = 0; virtual void SetData(Buffer buffer) = 0; virtual void CopyToHostBuffer(Buffer\u0026amp; buffer) const = 0; }; class Image2D : public Image { public: static Ref\u0026lt;Image2D\u0026gt; Create(const ImageSpecification\u0026amp; specification, Buffer buffer = Buffer()); virtual void Resize(const glm::uvec2\u0026amp; size) = 0; virtual bool IsValid() const = 0; }; 到VulkanImage2D后，创建过程\n处理图片用途\n声明图像的所有用途（需用按位或组合），类型为 VkImageUsageFlags，常见取值：\nVK_IMAGE_USAGE_SAMPLED_BIT：可被采样器访问（作为纹理）。 VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT：作为颜色附着（渲染目标）。 VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT：作为深度 / 模板附着。 VK_IMAGE_USAGE_TRANSFER_SRC_BIT/VK_IMAGE_USAGE_TRANSFER_DST_BIT：作为复制操作的源 / 目标。 VK_IMAGE_USAGE_STORAGE_BIT：作为存储图像（Compute Shader 读写）。 用途需与后续操作匹配，否则会触发验证层错误。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 VkImageUsageFlags usage = VK_IMAGE_USAGE_SAMPLED_BIT; if (m_Specification.Usage == ImageUsage::Attachment) { if (Utils::IsDepthFormat(m_Specification.Format)) usage |= VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT; else usage |= VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; } if (m_Specification.Transfer || m_Specification.Usage == ImageUsage::Texture) { usage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; } if (m_Specification.Usage == ImageUsage::Storage) { usage |= VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; } VkImageCreateInfo\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 VkImageCreateInfo imageCreateInfo = {}; imageCreateInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; imageCreateInfo.imageType = VK_IMAGE_TYPE_2D; imageCreateInfo.format = vulkanFormat; imageCreateInfo.extent.width = m_Specification.Width; imageCreateInfo.extent.height = m_Specification.Height; imageCreateInfo.extent.depth = 1; imageCreateInfo.mipLevels = m_Specification.Mips; imageCreateInfo.arrayLayers = m_Specification.Layers; imageCreateInfo.samples = VK_SAMPLE_COUNT_1_BIT; imageCreateInfo.tiling = m_Specification.Usage == ImageUsage::HostRead ? VK_IMAGE_TILING_LINEAR : VK_IMAGE_TILING_OPTIMAL; // 内存布局 imageCreateInfo.usage = usage; m_Info.MemoryAlloc = allocator.AllocateImage(imageCreateInfo, memoryUsage, m_Info.Image, \u0026amp;m_GPUAllocationSize); s_ImageReferences[m_Info.Image] = this; VKUtils::SetDebugUtilsObjectName(device, VK_OBJECT_TYPE_IMAGE, m_Specification.DebugName, m_Info.Image); VkImageViewCreateInfo（默认创建的ImageView是包含Image所有（所有mip，所有layer）的ImageView）\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 VkImageViewCreateInfo imageViewCreateInfo = {}; imageViewCreateInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; imageViewCreateInfo.viewType = m_Specification.Layers \u0026gt; 1 ? VK_IMAGE_VIEW_TYPE_2D_ARRAY : VK_IMAGE_VIEW_TYPE_2D; imageViewCreateInfo.format = vulkanFormat; imageViewCreateInfo.flags = 0; imageViewCreateInfo.subresourceRange = {}; imageViewCreateInfo.subresourceRange.aspectMask = aspectMask; imageViewCreateInfo.subresourceRange.baseMipLevel = 0; imageViewCreateInfo.subresourceRange.levelCount = m_Specification.Mips; imageViewCreateInfo.subresourceRange.baseArrayLayer = 0; imageViewCreateInfo.subresourceRange.layerCount = m_Specification.Layers; imageViewCreateInfo.image = m_Info.Image; VK_CHECK_RESULT(vkCreateImageView(device, \u0026amp;imageViewCreateInfo, nullptr, \u0026amp;m_Info.ImageView)); VKUtils::SetDebugUtilsObjectName(device, VK_OBJECT_TYPE_IMAGE_VIEW, fmt::format(\u0026#34;{} default image view\u0026#34;, m_Specification.DebugName), m_Info.ImageView); 采样器\n1 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 if (m_Specification.CreateSampler) { VkSamplerCreateInfo samplerCreateInfo = {}; samplerCreateInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; samplerCreateInfo.maxAnisotropy = 1.0f; if (Utils::IsIntegerBased(m_Specification.Format)) { samplerCreateInfo.magFilter = VK_FILTER_NEAREST; samplerCreateInfo.minFilter = VK_FILTER_NEAREST; samplerCreateInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; } else { samplerCreateInfo.magFilter = VK_FILTER_LINEAR; samplerCreateInfo.minFilter = VK_FILTER_LINEAR; samplerCreateInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; } samplerCreateInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; samplerCreateInfo.addressModeV = samplerCreateInfo.addressModeU; samplerCreateInfo.addressModeW = samplerCreateInfo.addressModeU; samplerCreateInfo.mipLodBias = 0.0f; samplerCreateInfo.minLod = 0.0f; samplerCreateInfo.maxLod = 100.0f; samplerCreateInfo.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE; m_Info.Sampler = Vulkan::CreateSampler(samplerCreateInfo); VKUtils::SetDebugUtilsObjectName(device, VK_OBJECT_TYPE_SAMPLER, fmt::format(\u0026#34;{} default sampler\u0026#34;, m_Specification.DebugName), m_Info.Sampler); } 设置图片默认布局 Storage和HostRead才会设置，其他图片都还是无布局状态，不过RenderPass设置可以设置附件的开始和结束布局\n1 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 if (m_Specification.Usage == ImageUsage::Storage) // General { // Transition image to GENERAL layout VkCommandBuffer commandBuffer = VulkanContext::GetCurrentDevice()-\u0026gt;GetCommandBuffer(true); VkImageSubresourceRange subresourceRange = {}; subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; subresourceRange.baseMipLevel = 0; subresourceRange.levelCount = m_Specification.Mips; subresourceRange.layerCount = m_Specification.Layers; Utils::InsertImageMemoryBarrier(commandBuffer, m_Info.Image, 0, 0, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, subresourceRange); VulkanContext::GetCurrentDevice()-\u0026gt;FlushCommandBuffer(commandBuffer); } else if (m_Specification.Usage == ImageUsage::HostRead) // VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL { // Transition image to TRANSFER_DST layout VkCommandBuffer commandBuffer = VulkanContext::GetCurrentDevice()-\u0026gt;GetCommandBuffer(true); VkImageSubresourceRange subresourceRange = {}; subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; subresourceRange.baseMipLevel = 0; subresourceRange.levelCount = m_Specification.Mips; subresourceRange.layerCount = m_Specification.Layers; Utils::InsertImageMemoryBarrier(commandBuffer, m_Info.Image, 0, 0, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, subresourceRange); VulkanContext::GetCurrentDevice()-\u0026gt;FlushCommandBuffer(commandBuffer); // 会把命令提交并等待完成 } 资源描述符信息封装（用默认的构造写一个资源描述符）\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 void VulkanImage2D::UpdateDescriptor() { if (m_Specification.Format == ImageFormat::DEPTH24STENCIL8 || m_Specification.Format == ImageFormat::DEPTH32F || m_Specification.Format == ImageFormat::DEPTH32FSTENCIL8UINT) { m_DescriptorImageInfo.imageLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; } else if (m_Specification.Usage == ImageUsage::Storage) { m_DescriptorImageInfo.imageLayout = VK_IMAGE_LAYOUT_GENERAL; } else if (m_Specification.Usage == ImageUsage::HostRead) { m_DescriptorImageInfo.imageLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; } else { m_DescriptorImageInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; } m_DescriptorImageInfo.imageView = m_Info.ImageView; m_DescriptorImageInfo.sampler = m_Info.Sampler; } 另外Image类需要提供创建不同Mip不同Layer的ImageView的功能，用于不同的场景，比如Mip用于使用ComputerShader创建HZB，Layer用于CSM级联阴影，用一个Image的不同Layer表示不同的级联\nTexture Texture就是把Image再封装一层，用于纹理采样，另外封装了2D纹理和Cube纹理，后续还可以封装3D纹理，用于体积云等等\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 struct TextureSpecification { ImageFormat Format = ImageFormat::RGBA; uint32_t Width = 1; uint32_t Height = 1; TextureWrap SamplerWrap = TextureWrap::Repeat; TextureFilter SamplerFilter = TextureFilter::Linear; bool GenerateMips = true; bool Storage = false; bool StoreLocally = false; std::string DebugName; }; class Texture : public RendererResource { public: virtual ~Texture() {} virtual void Bind(uint32_t slot = 0) const = 0; virtual ImageFormat GetFormat() const = 0; virtual uint32_t GetWidth() const = 0; virtual uint32_t GetHeight() const = 0; virtual glm::uvec2 GetSize() const = 0; virtual uint32_t GetMipLevelCount() const = 0; virtual std::pair\u0026lt;uint32_t, uint32_t\u0026gt; GetMipSize(uint32_t mip) const = 0; virtual uint64_t GetHash() const = 0; virtual TextureType GetType() const = 0; }; class Texture2D : public Texture { public: static Ref\u0026lt;Texture2D\u0026gt; Create(const TextureSpecification\u0026amp; specification); static Ref\u0026lt;Texture2D\u0026gt; Create(const TextureSpecification\u0026amp; specification, const std::filesystem::path\u0026amp; filepath); static Ref\u0026lt;Texture2D\u0026gt; Create(const TextureSpecification\u0026amp; specification, Buffer imageData); virtual void CreateFromFile(const TextureSpecification\u0026amp; specification, const std::filesystem::path\u0026amp; filepath) = 0; virtual void CreateFromBuffer(const TextureSpecification\u0026amp; specification, Buffer data = Buffer()) = 0; virtual void ReplaceFromFile(const TextureSpecification\u0026amp; specification, const std::filesystem::path\u0026amp; filepath) = 0; virtual void Resize(const glm::uvec2\u0026amp; size) = 0; virtual void Resize(const uint32_t width, const uint32_t height) = 0; virtual Ref\u0026lt;Image2D\u0026gt; GetImage() const = 0; virtual void Lock() = 0; virtual void Unlock() = 0; virtual Buffer GetWriteableBuffer() = 0; virtual bool Loaded() const = 0; virtual const std::filesystem::path\u0026amp; GetPath() const = 0; virtual TextureType GetType() const override { return TextureType::Texture2D; } static AssetType GetStaticType() { return AssetType::Texture; } virtual AssetType GetAssetType() const override { return GetStaticType(); } }; class TextureCube : public Texture { public: static Ref\u0026lt;TextureCube\u0026gt; Create(const TextureSpecification\u0026amp; specification, Buffer imageData = Buffer()); virtual TextureType GetType() const override { return TextureType::TextureCube; } virtual void GenerateMips(bool readonly = false) = 0; virtual void GenerateMips(Ref\u0026lt;RenderCommandBuffer\u0026gt; renderCommandBuffer,bool readonly = false) = 0; static AssetType GetStaticType() { return AssetType::EnvMap; } virtual AssetType GetAssetType() const override { return GetStaticType(); } }; 创建底层Image对象\n1 2 3 4 5 6 7 8 ImageSpecification imageSpec; imageSpec.Format = m_Specification.Format; imageSpec.Width = m_Specification.Width; imageSpec.Height = m_Specification.Height; imageSpec.Mips = specification.GenerateMips ? GetMipLevelCount() : 1; imageSpec.DebugName = specification.DebugName; imageSpec.CreateSampler = false; m_Image = Image2D::Create(imageSpec); 从文件中读取数据\n1 m_ImageData = TextureImporter::ToBufferFromFile(filepath, m_Specification.Format, m_Specification.Width, m_Specification.Height); 数据写入Image对象（先把数据放在临时缓冲区，再转移到图片），生成MipMap后，转为VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL布局\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 void VulkanTexture2D::SetData(Buffer buffer) { auto device = VulkanContext::GetCurrentDevice(); Ref\u0026lt;VulkanImage2D\u0026gt; image = m_Image.As\u0026lt;VulkanImage2D\u0026gt;(); auto\u0026amp; info = image-\u0026gt;GetImageInfo(); VkDeviceSize size = m_ImageData.Size; VkMemoryAllocateInfo memAllocInfo{}; memAllocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; VulkanAllocator allocator(\u0026#34;Texture2D\u0026#34;); // Create staging buffer VkBufferCreateInfo bufferCreateInfo{}; bufferCreateInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; bufferCreateInfo.size = size; bufferCreateInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; bufferCreateInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; VkBuffer stagingBuffer; VmaAllocation stagingBufferAllocation = allocator.AllocateBuffer(bufferCreateInfo, VMA_MEMORY_USAGE_CPU_TO_GPU, stagingBuffer); // Copy data to staging buffer uint8_t* destData = allocator.MapMemory\u0026lt;uint8_t\u0026gt;(stagingBufferAllocation); HZ_CORE_ASSERT(m_ImageData.Data); memcpy(destData, m_ImageData.Data, size); allocator.UnmapMemory(stagingBufferAllocation); VkCommandBuffer copyCmd = device-\u0026gt;GetCommandBuffer(true); // Image memory barriers for the texture image // The sub resource range describes the regions of the image that will be transitioned using the memory barriers below VkImageSubresourceRange subresourceRange = {}; // Image only contains color data subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; // Start at first mip level subresourceRange.baseMipLevel = 0; subresourceRange.levelCount = 1; subresourceRange.layerCount = 1; // Transition the texture image layout to transfer target, so we can safely copy our buffer data to it. VkImageMemoryBarrier imageMemoryBarrier{}; imageMemoryBarrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; imageMemoryBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; imageMemoryBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; imageMemoryBarrier.image = info.Image; imageMemoryBarrier.subresourceRange = subresourceRange; imageMemoryBarrier.srcAccessMask = 0; imageMemoryBarrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; imageMemoryBarrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; imageMemoryBarrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; // Insert a memory dependency at the proper pipeline stages that will execute the image layout transition // Source pipeline stage is host write/read execution (VK_PIPELINE_STAGE_HOST_BIT) // Destination pipeline stage is copy command execution (VK_PIPELINE_STAGE_TRANSFER_BIT) vkCmdPipelineBarrier( copyCmd, VK_PIPELINE_STAGE_HOST_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, \u0026amp;imageMemoryBarrier); VkBufferImageCopy bufferCopyRegion = {}; bufferCopyRegion.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; bufferCopyRegion.imageSubresource.mipLevel = 0; bufferCopyRegion.imageSubresource.baseArrayLayer = 0; bufferCopyRegion.imageSubresource.layerCount = 1; bufferCopyRegion.imageExtent.width = m_Specification.Width; bufferCopyRegion.imageExtent.height = m_Specification.Height; bufferCopyRegion.imageExtent.depth = 1; bufferCopyRegion.bufferOffset = 0; // Copy mip levels from staging buffer vkCmdCopyBufferToImage( copyCmd, stagingBuffer, info.Image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, \u0026amp;bufferCopyRegion); uint32_t mipCount = m_Specification.GenerateMips ? GetMipLevelCount() : 1; if (mipCount \u0026gt; 1) // Mips to generate { Utils::InsertImageMemoryBarrier(copyCmd, info.Image, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, subresourceRange); } else { Utils::InsertImageMemoryBarrier(copyCmd, info.Image, VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, image-\u0026gt;GetDescriptorInfoVulkan().imageLayout, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, subresourceRange); } device-\u0026gt;FlushCommandBuffer(copyCmd); // Clean up staging resources allocator.DestroyBuffer(stagingBuffer, stagingBufferAllocation); if (m_Specification.GenerateMips \u0026amp;\u0026amp; mipCount \u0026gt; 1) GenerateMips(); } 对于Cube纹理，就是imageCreateInfo.arrayLayers = 6; // cubeMap本质是一个图片数组，arrayLayers来表示6个面，其他类似\n","date":"2025-11-02T20:46:56+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5vulkan%E6%9E%B6%E6%9E%84%E7%AF%87image/","title":"游戏引擎开发实践（Vulkan架构篇：Image）"},{"content":" 讲了 GUI架构、Asset的序列化和反序列化、Redo undo实现、反射等技术\n反射 为什么需要反射 我定义一个定向光组件，刚开始有方向和强度，我想在GUI实时修改，就需要在imgui添加两个按钮来调整，但是定向光的属性越来越多，我并不希望每次都要修改imgui界面来修改，这种就可以让imgui拿到定向光的反射来自动生成\nC++实现 Piccolo 借助 Clang AST，相当于直接复用了编译器的语法分析，通过在需要反射的类和属性上做标记，让clang AST分析后就会带上这个标记，从而知道了这些信息。 再下一步就是生成对应的反射类（就是自动生成的一些cpp文件，这些文件就包含了GetSet方法来设置原本被反射的类的信息\n这节内容主要是反射了解一下\n","date":"2025-10-29T14:42:31+08:00","permalink":"https://sdpyy1.github.io/p/games104%E5%BC%95%E6%93%8E%E5%B7%A5%E5%85%B7%E9%93%BE/","title":"Games104：引擎工具链"},{"content":" 这里主要说出了三角形渲染，还有其他的渲染方式，主要是了解各种优化渲染技巧\n天空盒 \u0026hellip;\n光场渲染 Radiance 捕获与计算摄影 Radiance（辐射亮度）可以在： 不同位置 不同方向 不同时间 不同光照条件 下进行捕获。 计算摄影领域研究如何从这些捕获的数据中提取有用结果，例如高动态范围成像（HDR）、光照重建等。 ​\n基于图像的物体表示 Lumigraph 和 光场渲染（Light-field Rendering）： 通过一组离散观察点捕获物体。 给定一个新视角，通过在已存储视图之间插值生成新视图。 类似于全息摄影（Holography）：用二维视图数组表示物体。 优点： 能够表示任意复杂表面与光照。 显示速率几乎恒定，与物体几何复杂度无关。 缺点： 数据量大，需要存储大量视图。 Sprite和图层 Sprite（精灵图）：\n屏幕上可移动的图像元素，例如鼠标光标、游戏角色。 不一定是矩形，支持透明像素。 简单sprite：每个像素直接映射到屏幕像素。 动画sprite：通过显示不同的sprite序列生成动画。 通用sprite：\n将纹理渲染到始终面向观众的多边形（通常是四边形）上。 支持大小调整和拉伸。 Alpha通道提供透明度和边缘抗锯齿。 可包含深度信息，使sprite有三维位置。 场景可以看作是一系列sprite层：\n典型例子：二维序列帧动画（cel animation）。 每层都有深度信息，用于确定前后关系。 渲染顺序：\n画家算法（Painter’s Algorithm）：从后向前渲染，无需z-buffer。 相机移动时： 物体变大 → 可使用相同sprite或mipmap处理。 前景与背景的相对覆盖可通过修改sprite层位置调整。 广告牌技术 基于观察方向来修改纹理矩形朝向的技术被称为广告牌技术（billboarding），这个矩形被称为广告牌（billboard）\n这就是通过两个向量构建正交基的办法：首先，计算 right 向量： $$ \\mathbf{r} = \\mathbf{u} \\times \\mathbf{n} $$ 然后，用 right 向量 和法线重新计算一个正交的 up 向量： $$ \\mathbf{u}' = \\mathbf{n} \\times \\mathbf{r} $$ 最终得到一个标准正交基： $$ \\{\\mathbf{r}, \\mathbf{u}', \\mathbf{n}\\} $$ 这样 $\\mathbf{r}$、$\\mathbf{u}\u0026rsquo;$ 和 $\\mathbf{n}$ 三个向量两两垂直。\n有了这些初步的准备，剩下的主要任务就是决定用什么表面法线和up向量来定义广告牌的方向。构建正交基 + 平移 + 缩放的操作，其核心目的就是生成模型变换矩阵（Model Matrix），也就是把广告牌从局部模型空间变换到世界空间的矩阵。\n屏幕对齐（screen-aligned）的广告牌 法线向量： $$ \\mathbf{n} = -\\mathbf{v}_n $$ $\\mathbf{v}_n$ 是视平面法线，指向远离观察点。 法线指向观察者。 Up 向量： $$ \\mathbf{u} = \\text{camera up vector} $$ 定义了广告牌在视平面上的上方向。 Right 向量： $$ \\mathbf{r} = \\mathbf{u} \\times \\mathbf{n} $$ 由 up 和法线叉乘得到，完成正交基。 旋转矩阵： $$ R = [\\mathbf{r} \\;\\; \\mathbf{u} \\;\\; \\mathbf{n}] $$ 面向世界（world oriented）的广告牌 两种不同对齐方式的广告牌的俯视图。根据不同的对齐方法，五块广告牌的朝向也不同。\n特性 屏幕对齐（Screen-aligned） 面向视点（Viewpoint-oriented） 法线方向 固定指向视平面 指向观察者 扭曲情况 对所有sprite相同，远处可能被拉伸 遵循真实透视，投影形变正确 使用场景 小sprite、文本注释、地图标记 粒子效果、火焰、烟雾、爆炸等 渲染复杂度 低 高，需要每帧计算法线 Wang [1839, 1840]详细介绍了微软飞行模拟器产品中所使用的云建模和渲染技术。每片云都由5到400块广告牌组成，但是只需要16种不同的基本sprite纹理即可实现各种各样的云，因为这些基本的sprite纹理可以使用非均匀缩放和旋转来进行修改，从而组合形成各种各样类型的云。还会根据距离云中心的距离来修改该点的透明度，从而模拟云的形成和消散。为了节省处理时间，远处的云都被渲染为一组环绕场景的8个全景纹理，类似于天空盒。\n轴向广告牌 最后一种常见类型的广告牌被称为轴向广告牌（axial billboarding）。在这个方案中，被纹理化的物体通常并不会直接面对观察者。相反，它可以围绕一些固定的世界空间轴进行旋转，并在这个范围内尽可能多地面向观察者。\n当观察者在场景中移动时，灌木广告牌会发生旋转以面向前方。在这个例子中，灌木从南面被照亮，因此不断变化的视野会使得整体着色随着旋转而发生变化。\n广告牌类型 法线朝向 Up 向量 特点 示例 屏幕对齐（Screen-aligned） 固定视平面法线 相机 up 固定旋转矩阵，适合小sprite/文本 HUD、标记 面向视点（Viewpoint-oriented） 指向观察者 尽量对齐世界 up 可保持透视形变 粒子效果、火焰、爆炸 轴向（Axial） 尽量面向观察者，但绕固定轴 世界 up 保持直立，适合圆柱形对称物体 远景树木、光柱、激光束 Impostor Impostor 是一个广告牌，通过渲染复杂物体到纹理上，然后将纹理映射到广告牌上，替代原始几何体。\n在左边，通过视锥体从侧面观察物体，从而创建了一个impostor。观察方向朝向物体的中心点\\mathbf{c}，使用这个相机设置渲染一副图像，并将其用作impostor纹理。如右侧所示，将imposter纹理应用于一个四边形上。impostor的中心等于原始物体的中心，法线（从imposter中心发出）直接指向视点。\n位移技术 通过图像编码几何信息：不仅是颜色，也包括高度、深度、法线\n优势：\n减少几何复杂度 与传统impostor或sprite相比，渲染更真实 应用场景：\n远景人群、粒子系统、点云 复杂表面细节建模（纹理化模拟几何） 粒子系统 等学习GPU粒子时再记录\n定义：由独立微小物体组成的集合，通过算法控制运动和生命周期（创建、移动、修改、删除）。\n应用：火、烟、爆炸、水流、星系等自然现象和特效。\n渲染：\n每个粒子可以是单像素、轨迹线段或四边形广告牌（sprite）。 圆形粒子可只考虑位置，不必考虑旋转方向。 可以用几何着色器生成，也可用顶点着色器生成，后者更高效。 纹理：可使用颜色、法线、法线贴图等。\n点渲染 概念：使用点（point）作为基本图元来表示物体表面，然后通过高斯滤波填补点之间的空隙。 历史： 1985 年，Levoy 和 Whitted 首次提出。 约 15 年后再次兴起，原因： 计算能力提升，支持交互速率渲染； 激光扫描仪等设备提供高密度点云数据（RGB-D、LIDAR、Kinect、iPhone TrueDepth、Tango、自动驾驶激光雷达等）。 点云数据：每个点通常包含位置、颜色/强度，有时还有分类信息（建筑、路面等）。 体素 体素化输入来源 点云：扫描设备生成的任意位置点。 多边形网格：通过 GPU 加速体素化。 医学影像：通过切片堆叠生成体素。 图像集合：可通过视觉外壳（visual hull）或轮廓切割生成体素。 常见体素化方法 六正交视图体素化（Karabassi et al.）： 从六个方向（上下左右前后）渲染深度。 标记不可见体素为内部体素。 优点：可识别内部体素；缺点：漏掉六视图不可见特征。 视觉外壳体素化（Loop et al.）： 基于相机捕获的轮廓图像生成体素。 只在可见像素位置生成体素，适合人体重建。 切片渲染体素化（Eisemann \u0026amp; Decoret）： 使用 32bit 渲染目标存储多层体素（slicemap）。 优点：可在 GPU 上一次渲染多层。 局限：只生成表面体素，无法识别内部体素。 现代 GPU 体素化： 计算着色器 + 图像加载/存储操作： 支持随机读写纹理。 可进行保守光栅化，记录与体素重叠的三角形。 可直接构建 SVO（Sparse Voxel Octree）： 自上而下体素化非空节点。 自下而上 mipmap 填充结构。 动态场景处理： 渐进式更新体素（progressive voxelization）。 使用深度缓冲清除/设置体素。 体素类型 实体体素：内部体素和外部体素完全分离。 26-分离体素：内部体素不与外部体素共享面、边或顶点。 6-分离体素：内部体素与外部体素共享边角（更保守的表面表示）。 存储方式 体素数据通常存储在三维数组或三维纹理中。 每个体素可用 bit 或更高精度数据表示状态（内部/外部、密度、颜色、法线等）。 渲染方法 直接立方体渲染： 将每个体素绘制为立方体。 相邻立方体共享面可剔除，减少多边形数量。 可使用快速贪心算法进一步合并小面片。 表面提取（Surface Extraction）： Marching Cubes： 每个体素的 8 个角点确定表面穿过位置。 通过查表生成三角形网格。 可插值顶点位置以获得平滑表面。 水平集（Level Set）： 体素存储到表面的距离（正内部，负外部）。 可直接进行光线追踪或调整网格顶点位置。 稀疏体素光线投射： 针对稀疏体素数据直接进行光线投射。 GPU 高效实现，支持交互式帧率。 锥形追踪（Cone Tracing）： 类似 mipmap 的采样方法。 支持软阴影、景深、可变法线滤波。 利用体素规则性进行区域采样。 优化与数据结构 八叉树（Octree）： 优点：易构建，支持稀疏数据。 缺点：树遍历开销大，动态变化不便。 VDB 树 / 索引表： 提高 GPU 光线追踪性能。 支持动态体素变化。 可分块流式加载，处理大规模场景。 ","date":"2025-10-29T13:51:52+08:00","image":"https://sdpyy1.github.io/202308191853823.png","permalink":"https://sdpyy1.github.io/p/beyond-polygons-%E8%B6%85%E8%B6%8A%E5%A4%9A%E8%BE%B9%E5%BD%A2/","title":"Beyond Polygons 超越多边形"},{"content":"概念 vkCmdDispatch分配的是线程组的总数，Shader中的layout定义一个线程组内部多少个线程\nworkgroup定义了计算负载处理构型，是GPU必须处理的工作项。workgroup是一个三维模型，每个维度的大小通过vkCmdDIspatch(commandBuffer, groupSize.x, groupSize.y, groupSize.z)指定。 invocation代表workgroup中的一个计算单元，每个计算单元运行相同的compute shader。 workgroup中的invocations并行运行，它们的维度由compute shader中layout(local_size_x=a, local_size_y=b, local_size_y=c)指定。同一个workgroup中的invocations可以访问共享内存。 案例 1 vkCmdDispatch(cmdBuffer, storageImage.width / 16, storageImage.height / 16, 1); 这就代表了我需要$storageImage.width / 16 storageImage.width / 16 * 1$个线程组。(本质是计算了图片能分成多少份1616的区域)\n通常这样写，是假设每个线程组负责处理图像中 16×16 大小的区域\n对应的Shader中写的是\n1 layout (local_size_x = 16, local_size_y = 16) in; 定义了每个线程组包含 16×16 = 256 个线程.这样每个线程刚好处理图像中 globalPos 对应的一个像素，整个任务通过线程组和线程的二维分布，实现了对图像的并行遍历。\n在Shader中计算当前线程属于哪个像素的坐标\n1 2 3 4 // 1. 计算当前线程对应的全局像素坐标（x, y） uvec2 groupID = gl_WorkGroupID.xy; // 线程组在全局的索引 uvec2 localID = gl_LocalInvocationID.xy; // 线程在组内的索引 ivec2 globalPos = ivec2(groupID * 16 + localID); // 转换为整数坐标 在Shader中获取某个像素值的方法是\n1 gvec4 imageLoad(gimage2D image, ivec2 coord); 参数 1：要读取的图像（即 inputImage）。 参数 2：像素的整数坐标（ivec2，表示 (x, y) 位置，原点通常在图像左上角）。 返回值：像素的颜色值（gvec4，包含 r, g, b, a 四个通道，范围通常为 [0.0, 1.0] 或 [0, 255]，取决于图像格式）。 线程组内部可以使用共享数据，在Shader中用shared修饰的变量，但是需要使用同步函数保证所有线程先写入，才能使用\n1 2 3 4 5 6 7 8 9 10 shared vec4 cache[16][16]; // 每个线程写自己的位置 cache[localID.x][localID.y] = someValue; // 等待同组线程全部写完 barrier(); // GLSL 内置同步函数 // 之后可以安全读取其他线程写的值 vec4 v = cache[otherX][otherY]; 同步 ComputePass并不需要创建RenderPass，也就是说没有定义资源的依赖关系，所以需要手动设置内存屏障保证后续Pass读取到的是新的结果\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 比如这张图片在compute中处理，后续参与采样，需要一个内存屏障在两个Pass之间 // Image memory barrier to make sure that compute shader writes are finished before sampling from the texture VkImageMemoryBarrier imageMemoryBarrier = {}; imageMemoryBarrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; // We won\u0026#39;t be changing the layout of the image imageMemoryBarrier.oldLayout = VK_IMAGE_LAYOUT_GENERAL; imageMemoryBarrier.newLayout = VK_IMAGE_LAYOUT_GENERAL; imageMemoryBarrier.image = storageImage.image; imageMemoryBarrier.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; imageMemoryBarrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; imageMemoryBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; imageMemoryBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; imageMemoryBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; vkCmdPipelineBarrier( cmdBuffer, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // compute结束 VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, // 片段着色器开始 VK_FLAGS_NONE, 0, nullptr, 0, nullptr, 1, \u0026amp;imageMemoryBarrier); vkCmdBeginRenderPass(cmdBuffer, \u0026amp;renderPassBeginInfo, VK_SUBPASS_CONTENTS_INLINE); ","date":"2025-10-29T12:17:56+08:00","permalink":"https://sdpyy1.github.io/p/computershader%E5%AD%A6%E4%B9%A0/","title":"ComputerShader学习"},{"content":"图像处理 这里介绍的就是什么是后处理，以及用三角形代替四边形的方法，这个我在OpenGL渲染器已经在使用了\n紧接着介绍图像处理的高斯滤波器是一种常用的滤波核，其形状是著名的钟形曲线：\n$$ \\operatorname{Gaussian}(x)=\\left(\\frac{1}{\\sigma \\sqrt{2 \\pi}}\\right) e^{-\\frac{r^{2}}{2 \\sigma^{2}}} \\tag{12.1} $$ 将（b）中的权重乘上（c）中的权重，可以得到与（a）相同的权重，这表明两个滤波器实际上是等效的，因此是可分离的。使用（a）对一个像素进行滤波需要25个样本，而单独使用（b）和（c）对一个像素进行滤波分别只要5个样本，总共10个样本，这大大降低了计算量。\n边界像素怎么取？在 GPU 后处理里，一般有以下几种方式来“处理边界外的像素”：\n策略 名称 含义 视觉效果 Clamp to Edge（边界钳制） 最常用 超出范围的采样坐标自动钳制到最近的边界像素 ✅ 边缘平滑、不会产生黑框 Repeat（平铺） 重复采样 超出范围部分从另一侧重头取样 🚫 不适合模糊，会出现重复图案 Mirror（镜像） 对称反射 超出边界部分以边界为轴镜像反射 ✅ 比 clamp 更平滑，但略贵 Constant / Black（填充0） 超出范围直接返回黑色或常量 🚫 会产生黑边、亮度损失 因为Shader中采样本身就可以不在像素中心，他可以自动插值，所以3*3的滤波核不需要采样9次，而是把采样点放在两个像素之间。通过调整样本的权重，以及样本采样的位置达到原来滤波核的效果。（比如采样点偏向权重高的一边，那自然插值就会分配更高的权重）\n下面讲了：\n圆盘滤波器能产生漂亮的虚化，但代价高；可用复数技巧实现高效近似。 ComputerShader做这种操作效果更好（线程组共享内容，随意写入） “移动平均（Moving Average）”技巧：对每行（或列）第一个像素计算完整滤波和；后续像素只需：加上进入核的新样本；减去离开核的旧样本；每个像素的模糊结果都能在 O(1) 时间更新。 下采样：先通过原图生成一张分辨率更低的图片，在低分辨率下进行滤波，再放大回去，效果类似，速度更快 双边滤波 图像处理中，双边滤波指的是 滤波权重同时考虑两个“方面”：\n方面 解释 空间距离（Spatial） 像素离中心的物理距离：越近权重越大（普通高斯模糊的做法） 颜色差异 / 强度差异（Range / Intensity） 像素颜色或亮度差异：越相近权重越大，越不同权重越小（边缘保留） 使用像素着色器进行图像处理操作。其中左上角为原始图像，在其基础上进行了各种处理。右上角为高斯差分运算（Gaussian difference operation），左下角为边缘检测，右下角为边缘检测与原始图像混合后的合成图像。\n乒乓缓冲区（后处理Pass的资源管理方式） 使用 两个离屏缓冲区（Framebuffer / Texture） 交替作为输入和输出。 流程示例： Pass 输入 输出 第1个Pass 缓冲区 A 缓冲区 B 第2个Pass 缓冲区 B 缓冲区 A 第3个Pass 缓冲区 A 缓冲区 B 每个 Pass 都可以使用前一个 Pass 的输出作为输入，同时写入另一个缓冲区。 优点：不需要为每个中间结果创建新的纹理，节省 GPU 内存和管理成本。 重投影技术 利用上一帧已经渲染的数据来渲染本帧\n反向重投影 会计算当前帧（t）和前一帧（t−1）的顶点位置。使用顶点着色中的z和w分量，像素着色器可以为t和t - 1时刻计算插值z/w，如果两帧之间的距离足够近，那么可以在前一帧的颜色缓冲中来对位置$\\mathbf{p}_{i}^{t-1}$的颜色进行双线性查找，并使用这个颜色值来代替当前帧的颜色值，而不是重新计算一个新的着色值。\n淡绿色的位置上一帧也是可见的，所以可以重用，暗绿色上一帧不可见，所以需要计算\n为了获得更好的质量，还可以使用一个运行时平均滤波器（running-average filter）[1264, 1556]，它会逐步淘汰旧的着色值。它特别适用于空间抗锯齿（spatial antialiasing）、软阴影和全局光照。这个滤波器的描述如下：\n$$ \\mathbf{c}_{f}\\left(\\mathbf{p}^{t}\\right)=\\alpha \\mathbf{c}\\left(\\mathbf{p}^{t}\\right)+(1-\\alpha) \\mathbf{c}\\left(\\mathbf{p}^{t-1}\\right) \\tag{12.2} $$其中$\\mathbf{c}\\left(\\mathbf{p}^{t}\\right)$是点$\\mathbf{p}^{t}$上的新着色值，$\\mathbf{c}\\left(\\mathbf{p}^{t-1}\\right)$是前一帧中的重投影颜色，$\\mathbf{c}_{f}\\left(\\mathbf{p}^{t}\\right) $是应用滤波器之后的最终颜色。Nehab等人在某些用例中使用$α = 3/5$，但是他建议根据具体的渲染内容来尝试使用不同的值。\n正向重投影 正向重投影则是从第t - 1帧的像素开始，并将它们投影到第t帧中，因此不需要进行两次顶点着色。这意味着来自第t−1帧的像素会被分散到第t帧中，而反向重投影方法则会收集从第t−1帧到第t帧的像素值。反向重投影方法还需要处理那些变得可见的遮挡区域，通常会通过一些启发式的空洞填充方法来完成，即使用周围像素的信息来推断出缺失区域的值。Yu等人[1952]使用正向重投影方法，以一种廉价的方式来计算景深效果。Didyk等人[350]基于运动向量（motion vector），在第t−1帧中自适应地生成网格来避免空洞，而不是使用经典的空洞填充方法。这个网格是通过深度测试来进行渲染的，然后会将其投影到第t帧中，这意味着遮挡问题和折叠问题，是作为带深度测试的自适应三角形网格光栅化的一部分来进行处理的。Didyk等人将他们的方法从左眼重投影到右眼，从而为虚拟现实生成一对立体图像，这两个图像之间的相关性通常会很高。后来，Didyk等人[351]提出了一种感知驱动的方法（perceptually motivated method）来执行时域采样，例如：将帧率从40 Hz增加到120 Hz。\n镜头光晕和泛光 光学现象\n光晕（Halo） 由镜头晶体结构的径向纤维引起。 外缘红色，内部紫色，环绕光源。 尺寸恒定，与光源距离无关。 绒毛状光环（Ciliary corona） 由透镜密度波动造成。 表现为从光源向外辐射的射线。 其他次要效果 光圈叶片 → 多边形图案 玻璃凹槽 → 条状光线 CCD溢出 → 泛光效果 这些统称为 炫光效果（glare effect） 数字化实现\n(1) 基本思路\n将光源或高亮像素分离出来（bright-pass filter）。 对这些像素进行模糊处理（通常高斯模糊，也可以是径向或尖峰形状）。 将模糊后的结果叠加回原图。 可以下采样以降低计算量（½ 到 1/8 分辨率），再上采样回原图。 (2) 镜头光晕纹理（Lens Flare Texture）\n使用纹理（例如一系列小正方块）模拟光晕效果。 光源远离屏幕中心 → 光晕小且透明 光源靠近屏幕中心 → 光晕大且不透明 可以结合 屏幕空间可见性 调整亮度（遮挡检测）。 (3) 条纹（Star streaks）\n使用 steerable filter 在给定方向上累加像素值。 通常结合乒乓缓冲区和下采样，实现高效渲染。 常用于光源周围的线性光条或星状光晕。 (4) 泛光（Bloom）\n只保留过亮区域，暗部变黑 → bright-pass。 对 bright-pass 图像进行模糊（高斯或锐化型）。 模糊结果叠加回原图（加法混合）。 可结合 HDR 渲染：先 tone-mapping，再叠加 bloom。 (5) 优化技巧\n低分辨率渲染：减少模糊计算量，扩大相对核尺寸。 多级下采样：可产生更广泛的模糊效果。 遮挡采样：如 Maughan 的 GPU 遮挡采样，调整光晕亮度。 累积历史帧：对动画物体形成条纹光晕或持久感。 典型渲染流程（后处理管线）\n场景渲染 → 得到 HDR 场景缓冲。 Bright-pass 提取高亮像素。 模糊处理 可以径向模糊（halo） 可以方向模糊（star streaks） 使用低分辨率或乒乓缓冲区优化 光晕纹理叠加 根据光源位置、遮挡信息调整大小与亮度 合成 bloom 或 lens flare 结果加到原图 tone mapping \u0026amp; HDR → LDR 景深 累积缓冲区方法（Accumulate Buffer）\n通过移动镜头位置（保持焦点固定），多次渲染图像并累积平均。 焦点附近的像素保持清晰，远近场模糊。 可以收敛到物理正确的ground-truth景深图像。 缺点：渲染成本高，不适合实时应用。 分层渲染：\n焦点层：清晰聚焦。\n远场层：模糊处理。\n近场层：模糊处理。\n通过改变裁剪平面和模糊处理，将层组合，形成景深。\n缺点：物体跨越多层时会产生突变；模糊不随距离连续变化。\n焦点外像素被扩散到弥散圆内。\n散射（Scatter）方法：\n将像素颜色散射到弥散圆覆盖的邻域像素。 GPU上直接散射困难 → 可用Sprite渲染或可分离滤波。 聚集（Gather / Backward Mapping）方法：\n对每个像素，采样其弥散圆内的邻域像素颜色并聚合。 可使用深度信息来调整模糊半径，效率高且可预测。 现代算法通常结合前景/背景分层 + 可分离滤波 + 聚集方法。\n散射（scatter）操作是获取像素的着色值，并将其扩散到邻近区域的像素上，例如通过渲染一个圆形的sprite。在聚集操作中，相邻区域的值被采样并用于影响某一个像素。GPU的像素着色器经过优化，可以通过纹理采样来执行聚集操作。\n近场与远场模糊处理\n近场： 独立图像渲染并模糊。 使用alpha通道控制混合，避免清晰边缘被模糊。 远场： 使用弥散圆半径插值焦点图像。 组合： 根据弥散圆半径插值焦点、远场图像。 使用近场alpha覆盖合成，形成自然的景深。 运动模糊 本质：运动模糊是由于物体或相机在快门打开时间内移动，导致图像在时间上被平均，从而产生模糊。 电影中的运动模糊： 电影每秒24帧（FPS），快门通常在1/40s-1/60s。 每一帧画面本身已经有运动模糊，所以即使24 FPS，画面也看起来平滑。 快门时间越短（如1/500s），会产生“hyperkinetic”效果，看起来非常快、清晰。 实时图形中的问题： FPS 低于一定值时，运动显得跳跃。 高速物体在没有运动模糊时出现“时间锯齿”（temporal aliasing）。 多帧累积（Frame Accumulation）\n思路：在一段时间内累积若干帧并求平均。 问题： 会降低帧率（每帧需渲染多次）。 快速移动物体容易出现鬼影。 优化： 滑动累积（sliding accumulation）：只渲染新帧，减去最早帧，累加当前帧。 b. 速度缓冲区（Velocity Buffer / Motion Vector）\n每个像素记录当前帧与上一帧在屏幕空间的位置差（速度向量）。 用法： 渲染当前帧图像。 根据速度缓冲区进行像素方向上的模糊。 可在GPU中通过滤波、瓦片处理加速（tile-based max velocity）。 优点： 只需渲染一帧。 可处理复杂场景的物体运动。 支持遮挡处理（前景物体不被背景模糊覆盖）。 几何方法（Geometry-based Motion Blur）\n对快速移动物体生成拉伸的几何（如剑挥动）。 缺点： 计算复杂。 需要对每个物体处理纹理和高光。 结合景深与运动模糊\n通过对速度向量和弥散圆结合，统一处理景深模糊和运动模糊。 可使用随机采样、交错采样、快速重建等技术减少采样噪声。 ","date":"2025-10-28T11:59:27+08:00","image":"https://sdpyy1.github.io/202307131302667.png","permalink":"https://sdpyy1.github.io/p/image-space-effects-%E5%9B%BE%E5%83%8F%E7%A9%BA%E9%97%B4%E7%89%B9%E6%95%88/","title":"Image Space Effects 图像空间特效"},{"content":" 在OpenGL渲染器中已经实现了硬阴影以及PCF，PCSS，在这里再引入级联阴影\n级联阴影 传统阴影做法与问题 截图来自我的OpenGL延迟渲染器\n1 2 float orthoSize = 10.0f; glm::mat4 lightProjection = glm::ortho(-orthoSize, orthoSize, -orthoSize, orthoSize, near_plane, far_plane); 这行代码决定了从光源位置渲染阴影贴图的正交投影矩阵，他决定了阴影的覆盖范围，当设置为10时，硬阴影以及PCSS效果如下\n当设置为100时，硬阴影与PCSS效果如下,可以看到硬阴影锯齿变得严重起来，软阴影也糊成一片了\n最后设置为1000,阴影以及完全失效了，因为覆盖区域太大了，整个模型也就占几个像素，导致模型边缘区域都是一样的判断结果，另外bias也失效了\n所以使用大场景时，阴影需要覆盖很大的区域，导致一大片位置只在阴影贴图中占一小部分，会导致近处模型阴影明显出问题\n级联阴影的原理 首先对摄像机坐标系下的视锥体分割，分割为不同深度的分段 在光源的视角下，生成每个视锥体分段的“包围盒”，这个包围盒是生成阴影贴图时正交投影的重要参考 视锥体分段 目的是把相机的视锥体（camera frustum）沿深度方向划分成多个“级联”\n常用方法叫PSSM\nds表示阴影贴图的像素长度 dp屏幕像素长度 dp/ds可以理解为阴影的锯齿误差 为了使整个画面的阴影看起来质量一致，不因到相机的距离而发生明显质量变化， 应该让dp/ds 是一个常数（dp/ds：表示“一个阴影贴图像素在屏幕上看起来有多大”） 由dp/dy = n/z，得出dp = ndy/z φ和θ分别表示曲面法线与屏幕和阴影贴图平面之间的角度。由dz/cosθ = dy/cosφ，得出dy = dzcosφ/cosθ 所以dp = ndzcosφ/zcosθ，dp/ds = ndzcosφ/zdscosθ 看看这个式子： $$ \\frac{dp}{ds} = \\frac{n dz \\cos\\phi}{z \\, ds \\cos\\theta} $$ 它告诉你阴影质量受哪些因素影响：\n因素 影响 含义 n / z ↓ 随距离增大而减小 物体越远，阴影在屏幕上越小 → 锯齿更明显 cosφ / cosθ 与法线角度相关 面倾斜时投影面积不同，阴影更“拉伸” dz/ds 光源深度变化与贴图分辨率关系 光源投影范围越大（dz 大），每个像素越模糊 → dp/ds 越大，阴影越锯齿 最后如果用对数拆分方案，可以推导出 其中Z是视锥体分段处的值,对应上图中的Ci，n是近平面距离，f是远平面距离，N是视锥体分割的个数，一般是1~4个\n如果使用均匀拆分方案 而PSSM方法是他们的折中，用系数λ来加权平均， 1 2 3 4 5 6 7 8 9 10 11 12 13 14 float nd = camera-\u0026gt;near; float fd = camera-\u0026gt;far; float lambda = 0.75; float ratio = fd / nd; frustums[0].near(nd); for(int i = 1 ; i \u0026lt; 分段个数 ; i++) { float si = i / (float)分段个数; float t_near = lambda * (nd * powf(ratio, si)) + (1 - lambda) * (nd + (fd - nd) * si); float far = near * 1.005f;//略微增加重合，避免断裂 frustums[i].near(near); frustums[i-1].far(far); } frustums[分段个数-1].far(fd); 到这里就拿到了每个视锥体切片的Near和Far是多少，就是上图中的C0，C1\u0026hellip;\n光源空间中正交投影 先计算出摄像机视锥体分块在世界空间中的坐标（就是计算了近平面和远平面组成的立方体的8个顶点的世界坐标） 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 glm::vec3 viewPos = camera-\u0026gt;Position; glm::vec3 viewDir = camera-\u0026gt;Front; glm::vec3 up(0.0f, 1.0f, 0.0f); glm::vec3 right = glm::cross(viewDir, up); for(int i = 0 ; i \u0026lt; 分块个数 ; i++) { Frustum\u0026amp; frustum = m_frustums[i]; glm::vec3 fc = viewPos + viewDir * frustum.far(); // 当前的Near glm::vec3 nc = viewPos + viewDir * frustum.near(); // 当前的Far right = glm::normalize(right); // 相机的右方向 up = glm::normalize(glm::cross(right, viewDir)); // 计算正交基的上方向 // 计算当前分片的近平面和远平面宽高的一半 float near_height = tan(frustum.fov() / 2.0f) * frustum.near(); float near_width = near_height * frustum.ratio(); float far_height = tan(frustum.fov() / 2.0f) * frustum.far(); float far_width = far_height * frustum.ratio(); //记录视锥8个顶点 frustum.m_points[0] = nc - up * near_height - right * near_width; frustum.m_points[1] = nc + up * near_height - right * near_width; frustum.m_points[2] = nc + up * near_height + right * near_width; frustum.m_points[3] = nc - up * near_height + right * near_width; frustum.m_points[4] = fc - up * far_height - right * far_width; frustum.m_points[5] = fc + up * far_height - right * far_width; frustum.m_points[6] = fc + up * far_height + right * far_width; frustum.m_points[7] = fc - up * far_height + right * far_width; } 利用分块的各顶点坐标，计算摄像机视锥体分段的“包围盒”，从而计算出分块对应的正交投影矩阵（最终结果就是每个级联块都有了自己的变换矩阵，我想把模型放在哪个级联块中，就乘以他的变换矩阵） 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 57 58 glm::mat4 lightProjMat; for(int i = 0 ; i \u0026lt; 分块个数 ; i++) { //1. 找出光空间中八个顶点的最大最小z值（光源视角下z的范围） Frustum\u0026amp; frustum = m_frustums[i]; glm::vec3 max(-1000.0f, -1000.0f, 0.0f); glm::vec3 min(1000.0f, 1000.0f, 0.0f); glm::vec4 transf = lightViewMat * glm::vec4(frustum.m_points[0], 1.0f); min.z = transf.z; max.z = transf.z; for(int j = 1 ; j \u0026lt; 8 ; j++) { transf = lightViewMat * glm::vec4(frustum.m_points[j], 1.0f); if(transf.z \u0026gt; max.z) { max.z = transf.z; } if(transf.z \u0026lt; min.z) { min.z = transf.z; } } //1.1 扩展光视锥体的大小，使其包括所有的遮挡物 for(int j=0; j\u0026lt;场景中物体包围球个数; j++) { transf = lightViewMat * vec4f(objBSphereCenter[j], 1.0f); if(transf.z + objBSphereRadius[j] \u0026gt; max.z) { max.z = transf.z + objBSphereRadius[j]; } } //2. 设光空间的正交投影矩阵为ortho，他的x,y∈[-1,1]，z∈[-tmax.z,-tmin.z] //使用x,y∈[-1,1]，是因为每个分块的投影矩阵都可以使用单位x,y缩放平移后获得 //使用z∈[-tmax.z,-tmin.z]，是因为摄像机空间指向负Z方向，而glm::ortho传入的是近平面和远平面指向正Z方向 glm::mat4 ortho = glm::ortho(-1.0f, 1.0f, -1.0f, 1.0f, -tmax.z, -tmin.z); //2.1 在光空间中，找出视锥体切片各顶点的x、y的标准设备坐标范围。因为我们设的投影矩阵的x、y都是标准设备坐标，我们需要求出x、y的变化范围，以便对ortho进行缩放平移 glm::mat4 lightVP = ortho * lightViewMat; for(int j = 0 ; j \u0026lt; 8 ; j++) { transf = lightVP * glm::vec4(frustum.m_points[j], 1.0f); transf.x /= transf.w; transf.y /= transf.w; if(transf.x \u0026gt; max.x) { max.x = transf.x; } if(transf.x \u0026lt; min.x) { min.x = transf.x; } if(transf.y \u0026gt; max.y) { max.y = transf.y; } if(transf.y \u0026lt; min.y) { min.y = transf.y; } } //2.2 根据正交投影矩阵的公式，设置缩放平移量(计算过程在后面) glm::vec2 scale(2.0f / (max.x - min.x), 2.0f / (max.y - min.y)); glm::vec2 offset(-0.5f * (max.x + min.x) * scale.x, -0.5f * (max.y + min.y) * scale.y); glm::mat4 crop = glm::mat4(1.0); //2.3 设置缩放平移的变换矩阵 crop[0][0] = scale.x; crop[1][1] = scale.y; crop[0][3] = offset.x; crop[1][3] = offset.y; crop = glm::transpose(crop);//注意glm按列储存，实际矩阵要转置 //2.4 计算出光空间中的正交投影矩阵 lightProjMat = crop * ortho; //保存光空间的投影矩阵 projection_matrices[i] = lightProjMat; //保存世界坐标到光空间变换的矩阵 crop_matrices[i] = lightProjMat * lightViewMat; } 计算出摄像机视锥体分块的远平面在摄像机空间中投影后的位置，并把他变换到[0.0，1.0]。这么做的目的是为了在中判断某个片段属于哪个分块 1 2 3 4 5 for(int i = 0 ; i \u0026lt; 分块个数 ; i++) { far_bounds[i] =0.5f*((-1.0f * frustums[i].far() * projMat[2][2] + projMat[3][2])/ frustums[i].far())+0.5f; } ","date":"2025-10-25T20:49:55+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E9%98%B4%E5%BD%B1%E7%AF%87/","title":"游戏引擎开发实践（阴影篇）"},{"content":" 这节主要讲了动画在实践时需要的一些技术\n动画混合 在一个Clip内部，使用KeyFrame之间进行插值\nClip之间进行插值，就涉及到如何设置两个Clip的权重，这里静态到走路可以用速度进行插值\nClip之间插值取哪一的KeyFrame进行插值呢？\n混合空间 1D/2D混合空间 一维混合就是类似从走到跑的混合，只需要一个速度变量就可以进行权重插值\n二维混合空间就需要x,y两个参数来确定很多的动画的插值\nDelaunay Triangulation（德劳内三角剖分）：解决众多动画中那些动画参与插值（并不是所有动画都需要参与）\n二维插值的问题\n假设你有 2D Blend Space，点 (x, y) 表示某个动作（如走、跑、斜向走）。 当角色的当前参数 (px, py) 落在这些点之间时，需要根据它周围的动画点计算混合权重。 问题是：怎么快速找到 (px, py) 所在的区域，并确定参与混合的动画点？ 三角剖分的思路\n把二维平面上的所有动画点用三角形连起来（Delaunay Triangulation），满足： 没有点在任意三角形的外接圆内（Delaunay性质）。 三角形形状尽量接近等边三角形，避免“瘦长三角形”，这样插值更稳定。 这样每个三角形的顶点就是 参与插值的动画点。 Skeleton Masked Blending 用来解决两个动画分别作用于不同的骨骼的情况（一个动画控制上半身，一个动画控制下半身）\nAdditive Blending Blending做完后还可以再加一个修饰\n动作状态机（ASM） 动画混合树 IK 这块东西在Games105详细记录过了\n面部动画 就是用很多的表情单元进行混合\n直接混合也不行\n真实存AU存的是一个表情相对于正常面部的便宜\n动画重定向 ","date":"2025-10-25T15:45:35+08:00","image":"https://sdpyy1.github.io/%E6%88%AA%E5%B1%8F2025-10-20-15.21.05.png","permalink":"https://sdpyy1.github.io/p/games104%E9%AB%98%E7%BA%A7%E5%8A%A8%E7%94%BB%E6%8A%80%E6%9C%AF%E5%8A%A8%E7%94%BB%E6%A0%91ik%E5%92%8C%E8%A1%A8%E6%83%85%E5%8A%A8%E7%94%BB/","title":"Games104：高级动画技术：动画树、IK和表情动画"},{"content":"渲染过程最终计算的是radiance，到目前为止，我们一直在使用反射方程（reflectance equation）来对其进行计算：\n$$ L_{o}(\\mathbf{p}, \\mathbf{v})=\\int_{\\mathbf{l} \\in \\Omega} f(\\mathbf{l}, \\mathbf{v}) L_{i}(\\mathbf{p}, \\mathbf{l})(\\mathbf{n} \\cdot \\mathbf{l})^{+} d \\mathbf{l} \\tag{11.1} $$其中$L_{o}(\\mathbf{p}, \\mathbf{v})$是表面位置$\\mathbf{p}$在观察方向$\\mathbf{v}$上的出射radiance；$\\Omega$是表面位置$\\mathbf{p}$的上半球范围；$f(\\mathbf{l}, \\mathbf{v})$是观察方向$\\mathbf{v}$和当前光线入射方向$\\mathbf{l}$上的BRDF；$L_{i}(\\mathbf{p}, \\mathbf{l})$是从光线方向$\\mathbf{l}$到达表面位置$\\mathbf{p}$的入射radiance；$(\\mathbf{n} \\cdot \\mathbf{l})^{+}$是光线方向$\\mathbf{l}$和表面法线$\\mathbf{n}$之间的点积，并将负数结果clamp到0，即将来自表面下方的光线过滤掉。\n通用全局光照算法 通用 = 适用于所有类型的光照交互，不局限于特定材质或光源类型。\n它强调的是算法完整求解渲染方程，可以模拟现实世界中各种光线传输现象，而不是仅仅处理直接光或特定反射类型。\n辐射度 目标：模拟漫反射表面间的光线反弹，尤其是面光源产生的软阴影。 假设：所有间接光来自漫反射表面（不适合镜面或高光材质）。 基本思想：光在环境中弹射，直到达到稳定平衡，每个表面可以视为光源。 光传输路径：通常记作 $LD*E$（光源 $L$ → 漫反射 $D$ → 眼睛 $E$）。 算法实现\n面片划分\n场景表面分割为若干面片（patch），每个面片计算一个平均辐射度值。（就是把Mesh分成一个一个片来计算） 面片大小不必与原始三角形网格一致，但要足够小以捕捉阴影和光照细节。 辐射度方程 $$ B_{i} = B_i^e + \\rho_{\\mathrm{ss}} \\sum_j F_{ij} B_j $$ $B_i$：面片 $i$ 的辐射度 $B_i^e$：面片自身发出的辐射度 $\\rho_{\\mathrm{ss}}$：次表面反照率 $F_{ij}$：形状因子（form factor），表示面片 $i$ 发出的光有多少到达面片 $j$ 通俗理解 自己发光的部分 → $B_i^e$ 面片本身是光源时才有 其他面片反射来的光 → $\\rho_{\\mathrm{ss}} \\sum_j F_{ij} B_j$ 房间里其他面片反射的光经过漫反射照到面片 $i$ 总和 → $B_i$ 面片 $i$ 的最终辐射度就是“自己发光 + 别人反射的光” 形状因子（描述一个面片受到另一个面片的影响程度）\n$$ F_{ij} = \\frac{1}{A_i} \\int_{A_i} \\int_{A_j} V(i,j) \\frac{\\cos \\theta_i \\cos \\theta_j}{\\pi d_{ij}^2} \\, da_i da_j $$ $A_i$：面片面积 $V(i,j)$：可见性函数（1 = 可见，0 = 被遮挡） $\\theta_i, \\theta_j$：法线与连线的夹角 $d_{ij}$：两面片中心距离 求解方法\n形状因子计算完成后，所有面片方程组成一个线性系统（一个由线性方程组成的数学模型） 解线性系统得到每个面片的辐射度 限制与应用\n扩展性差，面片数量大时矩阵求解开销高 不适合高光或镜面材质 现代实时系统借鉴思想：预计算形状因子，然后在运行时进行光线传播 辐射度算法是一种基于有限元的全局光照方法，适合漫反射环境，通过面片划分、形状因子计算和线性系统求解，模拟间接光照。虽然计算量大，但其理论基础和预计算思想对实时渲染设计仍有重要参考价值。\n光线追踪 这块倒是原理比较熟悉了\n环境光遮蔽 上一小节中所介绍的通用全局光照算法，它们的计算成本都很高。虽然它们可以产生各种复杂的效果，但是生成一幅图像往往需要好几个小时。我们将首先介绍一些最简单的，但是在视觉上很有说服力的方法，并在本章节逐步探索实时替代方案，逐步构建更加复杂的效果。\nAO的话也比较熟悉了，跳过。在GameEngine真正实现时，再回来看\n漫反射全局光照 这部分针对漫反射的全局光照的各种技术进行介绍\n表面预照明（静态场景，固定法线） 静态场景烘培\n对于一个法线已知的Lambertian表面，其irradiance可以预先计算出来。在运行过程中，将这个值乘以实际的表面颜色（例如纹理颜色），从而获得反射的radiance。根据表面颜色的确切形式，可能还需要额外除以\\pi 来确保能量守恒。\n除了限制最为严格的硬件平台之外，如今已经很少使用预计算irradiance的方法了。因为根据定义，irradiance是针对给定的法线方向进行计算的，这意味着我们无法对物体的表面法线进行修改，我们无法使用法线映射来提供高频的表面细节。这也意味着只能对平面进行预计算irradiance。如果我们需要在动态几何物体上使用烘焙光照，我们就需要其他的方法来存储这些光照信息。这些限制条件促使人们寻找一种方法，来存储带有方向分量的预计算光照。\n定向表面预照明（静态场景，法线可变） 1️⃣ 为什么需要定向表面预照明\n对 Lambertian 漫反射表面，简单的环境光照（irradiance）只存储一个标量就够了，但如果要： 使用 法线贴图（normal map）提高细节 给动态几何提供间接光 就需要存储 irradiance 随表面法线变化 的信息，也就是方向性信息。 2️⃣ 常用表示方法\n球谐函数（Spherical Harmonics, SH） 可以存储完整的半球或球面光照函数。 优点：插值简单，支持动态法线。 缺点：系数多（3阶SH每通道9个系数），可能有振铃现象（振铃现象就是在用有限阶球谐函数近似光照时，高频细节无法精确表达，在边界或高对比区域出现的伪波纹或亮暗误差）。 优化变体： Chen 的方法：将主要方向光用一个方向+颜色存储，其余用二阶SH编码 → 18个系数，降低存储。 H-basis：仅对半球编码，可用更少系数（6个）获得三阶SH精度。 半球基（AHD / Half-angle Basis） 《半条命2》使用：每个样本 3 颜色通道，总共 9 个系数。 简单，成本低，效果可接受。 切线空间高斯 / 环境波瓣 Crytek 和《教团：1886》方法： 存储 平均光线方向 + 颜色 + 混合因子。 用球面高斯波瓣（cos^n 权重）表示光照分布。 优点：高质量、可做漫反射和镜面高光卷积。 缺点：存储和计算成本高。 环境立方 / 环境骰子 用几个方向的余弦波瓣表示光照（cos² 或 cos⁴）。 优点：存储少（6-12个方向），局部支持好，重建质量接近二阶SH。 已应用在《半条命2》《使命召唤》《孤岛惊魂3》等游戏。 预计算传输 PRT（静态场景，但光源可变） 虽然上述的预计算光照看起来很惊艳，但是它本质上还是静态的。\n如果我们假设场景中的几何物体没有发生变化，只有光照发生了变化，那么我们可以对光线与模型的相互作用进行预计算。物体之间的影响（例如相互反射或者次表面散射），可以预先进行一定程度的分析，并将结果存储下来，而不需要对实际的radiance进行操作。接收入射光线，并将其转换为整个场景的radiance分布，这个函数被称为传输函数（transfer function）。这样的方法被称为预计算传输（precomputed transfer）或者预计算radiance传输（precomputed radiance transfer，PRT）。\n简单讲就是对于每种光源都可以提前对他单独进行预计算，最终场景光照可以由这些单独的光源预计算结果进行累加即可\n使用预计算radiance传输的渲染示例。会预先计算三个显示器的完整光照传输，分别获得一个归一化的“单位”响应。由于光线传输的线性叠加特点，这些单独的解可以分别乘以显示器的颜色（本例中是粉色、黄色和蓝色），从而获得最终的光照效果。\n上述过程可以写出如下数学形式：\n$$ L(\\mathbf{p})=\\sum_{i} L_{i}(\\mathbf{p}) \\mathbf{w}_{i} \\tag{11.36} $$其中$L(\\mathbf{p})$是点$\\mathbf{p}$的最终radiance；$L_{i}(\\mathbf{p})$是来自显示器$i$的预计算单位（归一化）贡献；$\\mathbf{w}_{i}$是该显示器的当前亮度。这个方程在数学意义上定义了一个向量空间（vector space），$L_i$是这个空间中的基向量。任何可能的光照效果，都可以通过这些光源贡献的线性组合来生成。\n存储方法 光照信息存储的目的\n无论是完全预计算的光照（例如光照贴图）还是只预计算部分传输信息（PRT等），都需要将结果数据存储在 GPU友好的形式。 存储的形式必须便于 运行时高效访问，以便着色器能快速获取光照信息。 光照贴图（Lightmap）\n定义：存储预计算光照的纹理。术语上可以涵盖irradiance贴图或其他光照存储。\n特点：\nGPU上使用纹理机制进行访问，通常经过双线性过滤。 有些表示方法（如AHD）是非线性的，插值后需要归一化。 光照贴图通常 不使用 mipmap，因为贴图分辨率已经很低，每个纹素覆盖面积大（约 20×20 cm）。 参数化要求：\n模型网格需要唯一的参数化（unique parameterization），每个三角形都有自己的纹理区域。 网格通常被分成 chart 或 shell，然后打包到同一纹理中。 需要注意 chart 之间不重叠，并保证过滤占用空间独立，以防颜色溢出。 接缝问题：\n因为 chart 独立展开，纹素边界可能不连续。 可以通过手动放置接缝、后处理或者约束优化来减小可见瑕疵。 光照信息被烘焙到一个场景中，将光照贴图应用到物体表面上从而实现光照。光照贴图使用了一个唯一的参数化。场景会被划分成多个元素，这些元素被展开并打包成一个共同的纹理。例如：右图左下角的小块对应了地面，它展示了两个立方体的阴影。\n顶点光照存储\n预计算光照也可以存储在顶点上，但： 光照质量依赖网格细分程度。 网格过粗会导致光照表现不准确，过细会增加计算成本。 在现代GPU上，顶点之间传递大量光照参数会降低效率，因此很少使用。 体积光照存储（Irradiance Volume / Light Probe）\n概念：\n在场景中预计算空间中多个点的光照信息。 可用于动态物体，也可用于静态物体。也就是说光照信息分布在场景中，而不是物体上，即使新物体来了也能直接用 存储方式：\n原始方法：每个样本点存储小纹理。 现代方法：存储在三维纹理或其他体积结构中，可用GPU加速过滤。 常用表示方法：\n二阶或三阶 球谐函数（SH） 球面高斯函数 环境立方体贴图（cube map） 采样方法：\n可用最近探针插值，或者基于四面体网格/点云+重心坐标的插值（图11.30、11.31）。 精度依赖探针密度和网格结构。 注意问题：\n光照体素可能跨越不同光照特征的表面，需要处理边界问题。 体积存储比光照贴图占用更多内存（立方关系）。 Unity引擎使用了一个四面体网格，来从一组探针中插值出光照信息。\n对于静态和动态的几何物体，通常会使用不同的光照存储方法。例如：静态物体可以使用光照贴图，而动态物体则可以从体积结构中获得光照信息。虽然这样做很流行，但是这种方案可能会导致不同类型的几何物体之间产生不一致的外观表现。其中一些差异可以通过正则化（regularization）来消除，即在这些表示方法中对光照信息进行平均。\n动态漫反射全局光照 预计算光照的局限性\n优点：能产生高质量的光照效果。 缺点： 需要离线烘焙，耗时长（可能几个小时甚至更多）。 烘焙-调整-再烘焙循环非常耗费时间，影响工作效率。 不适用于动态几何或用户生成的场景，因为场景会在运行时变化。 为了适应场景动态变化，需要即时计算间接光照（无需长时间预计算），主要方法包括：\n即时辐射度（Instant Radiosity）\n思路：从光源向场景投射光线，每个被光线照到的点放置一个 虚拟点光源（VPL, Virtual Point Light） 来模拟间接照明。 实现：Tabellion \u0026amp; Lamorlette（《怪物史莱克2》） 对场景表面做一次直接光照 Pass 并存储纹理。 渲染时使用这些缓存数据生成一次弹射的间接光照。 优点：一次弹射即可产生合理效果。 缺点：仍然是部分离线，需要一定计算。 反射阴影贴图（Reflective Shadow Maps, RSM）\n基于光源视角渲染的阴影贴图，不仅存储深度，还存储 法线、反照率、直接光照 等信息。 纹素视作虚拟点光源，在渲染时生成单次弹射的间接光照。 优化： 不使用全部像素作为点光源，而是用 重要性驱动选择子集。 可在屏幕空间 splat 虚拟光源，而不是每个着色点都选取纹素。 缺点： 无法提供间接光照的遮挡信息（近似效果）。 间接光源数量不足时，会出现闪烁。 数量太多则性能挑战大。 其他改进\n双抛物面阴影贴图：逐步添加间接光源，保证每帧只用少量阴影贴图。 点场景表示 + 不完美阴影贴图（imperfect shadow maps）：小贴图 + 后处理过滤，实现间接光照遮挡效果。 游戏应用示例： 《Dust 514》使用多纹理图层收集间接光照。 UE 风筝 Demo 用类似方法处理地形间接光照。 光照传播体积（LPV） 场景被离散成一个规则的三维网格，每个单元格内都会维护一个穿过它的定向radiance分布，他使用二阶球谐函数来处理这些信息\nLPV 的基本流程\n注入光照（Injection）： 将直接光照注入与表面接触的体素（volume cell）中。 注入值是表面反射光（根据材质颜色调节）。 可以使用 反射阴影贴图（RSM） 或其他方法找到这些体素。 光照传播（Propagation）： 每个体素分析邻居体素的 radiance 分布，更新自身 radiance。 每次迭代光照只传播一个体素的距离，所以需要多次迭代才能覆盖更大空间。 优点：每个体素都有完整 radiance 场，任意 BRDF 都可用。 级联体积（Cascaded LPV）： 为了覆盖更大空间并节省内存，使用嵌套、逐渐变大的体素层级。 光照注入和传播在每个层级独立进行，查找光照时选用最细可用层级。 遮挡信息（Occlusion）： 初始 LPV 不考虑间接光遮挡。 后续改进引入 RSM 深度信息 或 相机深度缓冲，为体素添加遮挡近似。 体素化全局光照（Voxel Cone Tracing, VXGI） 体素化场景：将场景几何体用稀疏体素八叉树（Sparse Voxel Octree, SVO）表示，每个体素存储反射的光照信息（Radiance）。\n光照注入：\n使用反射阴影贴图（RSM）或直接光照信息注入到最低层体素。 再通过层次传播向上累积，形成整个体素空间的光照场。 圆锥追踪近似射线积分：\n为了避免追踪大量射线，使用 圆锥（Cone）近似 来取代理想射线。 沿圆锥轴线在八叉树上进行层次查找，并根据体素覆盖率调节光照衰减。 多个圆锥组合可以模拟漫反射间接光。 性能优化\n稀疏八叉树访问开销大： 查找叶子节点需要多次内存访问，GPU 上容易导致 warp 停滞。 级联三维纹理替代八叉树： 类似 级联光照传播体积（LPV），每层覆盖更大的空间。 只需一次纹理查找即可获得多层级信息。 屏幕空间过滤： 每像素只追踪一个圆锥，通过屏幕空间滤波得到最终漫反射响应。 VXGI 的优点\n可支持 动态全局光照（直接光 + 间接光）。 允许 动态物体和光源。 能处理 复杂遮挡和半透明间接光（虽然近似）。 体素锥形追踪使用一系列体素树中的过滤查找，来对一个精确的锥形追踪进行近似。左图显示的是三维轨迹的二维模拟。右图展示了体素化几何的分层表示，从左到右每一列所展示的体素树，其层次越来越粗糙。在右图每一行中，展示了用于为给定样本提供覆盖率的层次结构节点。选择合适的级别进行使用，从而使得较粗级别节点的大小大于当前查找的大小，较细级别节点的大小小于当前查找的大小。会使用一个类似于三线性滤波的过程，来在这两个选定的级别之间进行插值。\n镜面全局光照 漫反射 VS 光泽材质的不同需求\n特性 漫反射 光泽/镜面材质 光照分布 宽余弦波瓣（整个半球） 狭窄镜面波瓣（小立体角） 光照采样 半球内积分 只需有限方向光线 实时渲染要求 低频、粗略表示即可 需高频、精确方向信息 漫反射光照可以用二阶球谐函数、LPV 或 VXGI 等低频方法表示。 光泽材质要求高频 radiance 表示，否则镜面反射高光会出现明显瑕疵。 局部环境贴图（反射探针） 最早将环境贴图与空间中特定点相绑定的游戏之一是《半条命2》，在他们的系统中，会由艺术家首先在整个场景中放置采样位置。在预处理阶段中，会在每个位置上渲染一个立方体贴图。在进行高光计算的时候，物体会使用最近位置上的结果来作为入射radiance的表示。相邻的物体可能会使用不同的环境贴图，这将会导致视觉效果的不匹配，但是艺术家可以手动调整立方体贴图所覆盖的范围。\n常规环境贴图（EM）假设环境在“无限远”，即每个表面点采样反射方向时，环境似乎以这个点为中心。\n对于小物体或者贴图正好从物体中心生成，结果几乎精确；但大多数物体：\nEM 是在某个中心点生成的（reflection probe） 高光表面离中心点越远 → 反射方向采样的结果就偏离真实情况 → 高光不真实 视差矫正：\n假设入射光来自一个有限大小的球体或盒子（reflection proxy），而不是无限远。\n用户可以定义半径/大小。 代理物体可以是球、盒子等形状，包围渲染到 EM 中的几何。 计算校正方向：\n对于某个表面点 $\\mathbf{p}$： 计算反射方向 $\\mathbf{r}$（传统方式） 视作从表面点沿 $\\mathbf{r}$ 发射一条射线，与反射代理相交 计算交点 $\\mathbf{x}$ 新方向 $\\mathbf{r}\u0026rsquo; = \\mathbf{x} - \\mathbf{c}$，其中 $\\mathbf{c}$ 是环境贴图的中心 使用 $\\mathbf{r}\u0026rsquo;$ 去 EM 采样，而不是直接使用 $\\mathbf{r}$ 左边是常规的环境映射，它使用蓝色圆圈进行表示（它也可以是任何表示形式，例如立方体贴图）。左图中的效果是通过使用反射观察方向$\\mathbf{r}$访问环境贴图来确定的。仅仅使用这个方向作为参数，蓝色圆圈EM会被视为半径无限大且遥远的。对于黑色圆表面上的任何点，EM好像都以该点为中心。右图中，我们希望EM能够把周围的黑色房间表示为本地的，而不是无限远的。蓝色圆圈EM是在房间的中心处生成的。要像访问房间一样访问这个EM，会从位置$\\mathbf{p}$处，沿着反射观察方向发射一根反射光线，这个光线会在着色器中与一个简单的代理物体（房间周围的红色框）相交。这个交点与EM的中心形成一个新的方向$\\mathbf{r}^{\\prime}$，然后会像常规的环境映射一样，使用这个方向$\\mathbf{r}^{\\prime}$来访问EM。通过求解$\\mathbf{r}^{\\prime}$，这个过程会将EM视为具有一个实际的物理形状，即图中的红框。这个红色代理框的假设会在房间的左下角和右下角失效，因为代理形状与实际房间的几何形状并不匹配。\n视差矫正从上图就很好理解，我反射采样点是R方向，但是物体并不在立方体贴图中间，如果按左图采样的方向是错的（他是假设着色点在立方体中心来采样的），按右图来看，他重新获取采样方向R\u0026rsquo;，才是正确的采样结果\n多探针组合\n当一个区域被多个探针覆盖时，需要一种规则来组合它们： 优先级法：用户可以为探针设置优先级，高优先级探针覆盖低优先级探针 平滑插值法：在相邻探针之间做线性/权重插值，保证过渡自然 问题：简单的组合规则无法完全消除瑕疵，尤其是在高光或抛光材质上，容易出现反射拉伸或不自然。 反射代理形状问题\n反射代理（reflection proxy）通常是简化的几何形状（球、盒子、平面），很少能完全匹配真实场景几何。 结果： 反射拉伸：高光表面上，反射看起来被拉伸或不真实 BRDF不完全正确：渲染到环境贴图的物体可能在不同位置看起来不一样 漏光（light leaking）：光线可能穿过代理，导致一些遮挡丢失 缓解方法： 定向遮蔽（directional occlusion） 预计算漫反射光照：将环境贴图反射值除以平均漫反射光照，只保留高频成分，再在着色时乘回漫反射 深度辅助方法\nSzirmay-Kalos：每个探针存储深度贴图，查找时做光线追踪 → 精度更高，但成本大 McGuire：用探针深度缓冲追踪光线，如果主探针信息不足，选择备用探针继续追踪 → 提升可靠性和精度 预过滤环境贴图问题\n对光泽材质（glossy BRDF）： EM通常是预过滤的，每个 mipmap 层表示不同卷积半径的入射辐射 问题：视差校正会让反射方向在代理形状上的分布变化，使得卷积略不准确 Pesce 和 Iwanicki 分析了这些问题并提出了潜在解决方案 反射代理形状的灵活性\n代理不必是闭合或凸形，可以是： 平面矩形 简单盒子 球形代理 复杂代理能更精确表示周围几何，但会增加计算开销 反射探针的实时更新 传统方法：局部反射探针一般在离线阶段渲染和预过滤，生成高质量立方体贴图。 问题： 开放世界游戏中，时间变化、动态几何体和大量环境探针使得离线渲染不现实 存储大量环境贴图会消耗磁盘空间 解决方案： 一部分探针在加载阶段预渲染 其他探针在进入相机视野时动态渲染 低帧率更新：动态物体的反射不需要每帧都更新\n可以定义每帧更新的探针数量 使用启发式方法决定更新顺序，例如：探针到相机的距离、上次更新时间等 拆分立方体贴图渲染：一个立方体贴图的6个面可以分布到6帧渲染，降低每帧开销\n高质量卷积通常需要多次采样，不适合高帧率实时更新\n解决方法：\n重要性采样（Colbert \u0026amp; Krivanek）：用少量样本（约64）进行近似滤波 mipmap层级采样：根据启发式选择每个样本的 mipmap 层 基函数滤波（Manson \u0026amp; Sloan）：先简单下采样+滤波，再组合 mipmap 构建最终贴图 纹理压缩：\nHDR 探针可压缩为 BC6H 半精度浮点格式 减少显存占用和带宽消耗 G-buffer辅助渲染：\n离线生成场景的 G-buffer 运行时只需计算光照和卷积 可以在预生成 G-buffer 上渲染动态几何体，降低 CPU 开销 基于体素的方法 体素锥形追踪，无论是使用稀疏八叉树进行存储[307]，还是其级联版本（章节11.5.7）[1190]，同样可以用于渲染高光效果\n对于光泽材质而言，使用锥形追踪的效率要高得多。在镜面光照的情况下，BRDF的波瓣会很狭窄，只需要考虑一个来自较小立体角的radiance，因此我们不再需要同时追踪多个圆锥区域，在大多数情况下，一个着色点只需要追踪一个圆锥就足够了。只有较为粗糙材质上的高光效果，才可能需要追踪多个圆锥，但是又因为这样的反射效果十分模糊，在这种情况下，我们只需要使用局部反射探针即可，根本不需要执行锥形追踪。\n就是Voxel Cone Tracing来进行着色，因为glossy表面的lobe很狭窄，相比于漫反射效率要高得多\n平面反射 方法 A：反射场景副本 对每个反射的物体创建一个副本 将副本沿平面镜像变换到反射位置 渲染副本物体生成反射图像 光源也需进行镜像处理： 光源位置和方向也要镜像 将生成的图像用作反射贴图或者直接绘制到反射表面 方法 B：反射观察者 不改变场景中的物体，而是反射观察者（摄像机）位置和观察方向 通过修改投影矩阵，实现从镜面后的视角渲染场景 优点：不需要复制场景物体 关键点：仍需镜像光源位置/方向 屏幕空间方法 这里讲的就是SSR，以及SSR的优化策略\n统一方法 到目前为止我们所介绍的方法，它们可以组合成一个能够渲染漂亮图像的完整系统。然而，这些系统交错在一起，缺乏路径追踪的优雅性和概念简洁性。渲染方程的不同方面都会以不同的方式进行处理，在每个方面都做出了不同程度的妥协。尽管最终的生成图像看起来很逼真，但是在很多情况下，这些方法依然会失败，导致视错觉的中断。由于上述的这些原因，实时路径追踪一直是研究工作的重点。\n混合渲染管线（Hybrid Rendering Pipeline）：更加合理、但不那么纯粹的方法是，使用路径跟踪方法来处理光栅化渲染框架内难以实现的效果。我们对相机可见的三角形进行光栅化，但是在计算反射效果的时候，我们不再依赖近似的反射代理或者不完整的屏幕空间信息，而是通过路径追踪来计算。我们不再尝试模拟具有模糊效果的面光源阴影，而是直接通过向光源追踪光线并计算正确的遮挡信息。\n为了生成高质量的图像，可能需要对每个像素追踪成百上千条光线。即使是使用最优的BVH、最高效的树遍历算法和最快速的GPU，目前也只能在最简单的场景中实时做到这一点，而在稍微复杂一点的场景中则根本无法实现。在可用的性能限制下，我们所生成的图像会具有非常多的噪点，根本无法用于显示。然而幸运的是，这些充满噪声的图像可以使用降噪算法来进行处理，从而产生基本无噪声的图像，如图11.42和图24.2所示。最近在实时光追降噪领域取得了令人印象深刻的进展，并且开发出了一些算法，可以在每像素仅追踪一根光线的情况下（1spp），创建视觉上接近高质量的、路径追踪生成的图像 [95, 200, 247, 1124, 1563]。\n","date":"2025-10-25T13:59:45+08:00","image":"https://sdpyy1.github.io/202307031150341.png","permalink":"https://sdpyy1.github.io/p/global-illumination-%E5%85%A8%E5%B1%80%E5%85%89%E7%85%A7/","title":"Global Illumination 全局光照"},{"content":" 内容包括：1. 将点光源拓展到面光源，并且介绍了一些在Lambertian表面或Glossy表面的实现方案 2.环境光照针对高光和漫反射的模拟的各种实现方案 3. 引入SH预计算的概念\n局部光照 (Local Illumination) 局部光照模型只考虑光源直接照射到表面点的光照贡献，忽略：\n来自其他物体表面反射的间接光（Global Illumination 的一部分） 场景中可能存在的全局光能量传播效应（如颜色反弹、光子映射、环境光照等） 也就是说，它计算的 $L_o$ 只来自： $$ L_o(\\mathbf{p}, \\mathbf{v}) = \\sum_{\\text{光源 } i} \\int_{\\omega_{L_i}} f_r(\\mathbf{p}, \\mathbf{l}, \\mathbf{v}) L_i(\\mathbf{p}, \\mathbf{l}) (\\mathbf{n}\\cdot\\mathbf{l})^+ d\\mathbf{l} $$ 积分域 $\\omega_{L_i}$ = 光源 i 在半球上所占立体角 如果光源面积很小 → 可以近似为点光源 只计算直接光照 → 计算量低，适合实时渲染 面光源 这一节主要是对一些面光源的积分运算在diffuse和glossy表面的实时渲染近似方案\n球体使用了GGX BRDF来进行渲染，从左到右，球体材质的表面粗糙度递增。最右侧图像和最左侧图像是一样的，只是将其垂直翻转了过来。请注意，在低粗糙度的材质上，由大圆盘灯引起的高光和着色效果，与较小光源在高粗糙度材质上所引起的高光效果，在视觉上面十分相似。\n这张图意思是使用点光源，并且调整粗糙度后，可以用来近似面光源的镜面效果。\nLambertian表面 对于Lambertian表面这种特殊情况，直接使用点光源来表示面光源是很精确的。对于这样的表面，其出射的radiance与irradiance成正比：\n$$ L_{o}(\\mathbf{v})=\\frac{\\rho_{\\mathrm{ss}}}{\\pi} E \\tag{10.2} $$ 也就是说对于Lambertian表面只要接受到的irrandance一致，那最终的radiance就是一样的。所以可以直接用点光源代替面光源（只是在Lambertian表面）\n向量irradiance 这里说的一堆东西是一种用点光源代替面光源近似计算的实现方案，使用它的方法求出来的净辐照度来代替对整个面光源的积分运算\n向量irradiance（vector irradiance）的概念对于当面光源存在时，理解irradiance的行为十分有用。向量irradiance的概念由Gershun [526]提出，他将其称之为光源向量（light vector），Arvo [73]将这个概念进一步推广。利用向量irradiance，可以将任意大小和任意形状的面光源，精确地转换为点光源或者方向光。\n这里的向量irradiance计算过程\n$$ \\mathbf{e}(\\mathbf{p})=\\int_{\\mathbf{l} \\in \\Theta} L_{i}(\\mathbf{p}, \\mathbf{l}) \\mathbf{l} d \\mathbf{l} \\tag{10.4} $$ 左图：点$\\mathbf{p}$被各种具有形状、大小、radiance分布的光源所包围，其中黄色的明亮程度代表了光源发射出的radiance数量。以点$\\mathbf{p}$为起点的橙色箭头代表了向量，它指向任何存在入射radiance的方向，每个向量的长度，等于来自该方向上的radiance乘以箭头所覆盖的无限小的立体角。原则上应当存在无穷多个箭头。右图：向量irradiance（橙色大箭头）是左图中所有橙色向量的总和。向量irradiance可以用来计算任意平面在点$\\mathbf{p}$处的净irradiance。\n其中$\\mathbf{n}$是平面的表面法线。通过平面的净irradiance，是指流经平面“正面”（表面法线$\\mathbf{n}$所指向的方向）和流经平面“背面”的irradiance之差。虽然说净irradiance本身对于着色计算来说没有什么用处，但是如果说没有任何radiance被发射通过平面的“背面”的话（换句话说，对于所分析的光线分布，光线方向$\\mathbf{l}$和表面法线$\\mathbf{n}$之间的夹角都不会超过$90^{\\circ}$，所有的入射光线都来自于着色点的正半球方向），即$E(\\mathbf{p},-\\mathbf{n})=0$，那么此时有：\n$$ E(\\mathbf{p}, \\mathbf{n})=\\mathbf{n} \\cdot \\mathbf{e}(\\mathbf{p}) \\tag{10.6} $$ 如果入射radiance $L_i$与波长无关的假设不成立的话，那么在一般情况下，我们就不能再定义单个向量irradiance $\\mathbf{e}$了。然而，彩色光通常在所有点上都具有相同的相对光谱分布，这意味着我们可以将$L_i$分解为颜色$\\mathbf{c}^{\\prime}$，以及与波长无关的radiance分布$L_i^{\\prime}$。在这种情况下，我们可以计算$L_i^{\\prime}$的向量irradiance $\\mathbf{e}$，并对方程10.6进行一些扩展，将$\\mathbf{n} \\cdot \\mathbf{e}$乘上颜色$\\mathbf{c}^{\\prime}$。这样做的结果与计算方向光irradiance的方程相同，只是做了以下替换：\n$$ \\begin{aligned} \\mathbf{l}_{c} \u0026 =\\frac{\\mathbf{e}(\\mathbf{p})}{\\|\\mathbf{e}(\\mathbf{p})\\|}, \\\\ \\mathbf{c}_{\\text {light }} \u0026 =\\mathbf{c}^{\\prime} \\frac{\\|\\mathbf{e}(\\mathbf{p})\\|}{\\pi} .\\end{aligned} \\tag{10.7} $$到此为止，我们已经可以将任意形状和任意大小的面光源转换为方向光，同时不会引入任何误差。\n对于一些简单情况，用于求取向量irradiance的方程10.4可以求出解析解。例如：想象现在有一个以$\\mathbf{p}_l$为中心、半径为$r_l$的球形光源。球面上的每一点都会向各个方向发出具有恒定radiance $L_l$的光线。对于这种光源，将方程10.4和方程10.7联立，可以获得如下结果：\n$$ \\begin{aligned} \\mathbf{l}_{c} \u0026 =\\frac{\\mathbf{p}_{l}-\\mathbf{p}}{\\left\\|\\mathbf{p}_{l}-\\mathbf{p}\\right\\|}, \\\\ \\mathbf{c}_{\\text {light }} \u0026 =\\frac{r_{l}^{2}}{\\left\\|\\mathbf{p}_{l}-\\mathbf{p}\\right\\|^{2}} L_{l} .\\end{aligned} \\tag{10.8} $$上述方程，与具有$\\mathbf{c}{\\text {light }{0}}=L_{l}, r_{0}=r_{l}$和标准距离平方反比衰减函数的泛光灯（章节5.2.2）完全相同。可以对这个衰减函数进行一些修正，使得光线从球体表面才开始发生衰减，并在光源的最大影响距离处衰减到0，有关这些调整的更多细节，详见章节5.2.2。\nGlossy表面 与 Lambertian 表面（仅依赖入射辐照度总量）不同，非 Lambertian 表面（如光泽、高光表面）的出射亮度，不仅依赖入射光总量，还依赖入射光的方向分布—— 面光源在这类表面的核心视觉效果是 “与光源形状相似、边缘随粗糙度模糊的高光”\n在实时渲染中，大多数对面光源光照效果的实用近似，都是基于了这样的一个想法：为每个着色点都寻找一个等效的精确光源，从而模拟非无穷小光源的效果。这种方法经常被用于实时渲染中，以解决各种各样的问题\n粗糙度修正 核心逻辑：找到面光源入射辐照度的 “有效圆锥体” 和 BRDF 镜面波瓣的 “有效圆锥体”，用两个圆锥体的立体角之和，修正材质的粗糙度参数，让镜面波瓣 “变宽”，模拟面光源的柔和高光。 典型实现：Karis 将该思路应用于 GGX BRDF，修改粗糙度参数$(\\alpha_g\u0026rsquo; = \\left(\\alpha_{g}+\\frac{r_{l}}{2\\left|\\mathbf{p}_{l}-\\mathbf{p}\\right|}\\right)^{\\mp})$（限制在 0-1 之间）。 优缺点：计算成本低，对中等光泽表面效果好；但对镜面级光滑表面失效（无法模拟锐利高光），且微表面 BRDF 的宽衰减特性会导致效果失真。 代表性点技术 核心逻辑：基于 “积分中值定理”，用面光源上对表面能量贡献最大的一个 “代表性点”，替代整个面光源（将面光源简化为一个点光源），类似蒙特卡洛积分的重要性采样思想。 关键操作： 选择代表性点：优先选 “与反射光线夹角最小” 或 “距离反射光线最近” 的点（Karis 改进后更高效）； 能量修正：通过简单公式缩放光线强度，保证能量守恒。 优缺点：推导简单、适用于多种光源形状；但会导致粗糙表面的高光偏 “尖锐”（与真实值有偏差），需额外优化。 扩展优化：Iwanicki 和 Pesce 通过拟合数值积分结果，引入软阈值、缩放因子等参数；de Carpentier 针对微表面 BRDF，改用 “最大化$(\\mathbf{n} \\cdot \\mathbf{h})$（法线与半向量点积）” 选择代表性点，优化掠射角下的高光形状。 线性变换余弦（LTC）法：高精度通用解决方案 核心逻辑：用 “3×3 矩阵变换的余弦波瓣”（LTC）近似任意 BRDF 的镜面波瓣，通过矩阵对余弦波瓣进行缩放、拉伸、旋转，适配不同 BRDF 和光源形状；再利用逆矩阵变换积分定义域，将复杂积分转化为简单余弦波瓣的积分（可复用成熟算法）。 关键优势： 通用性强：适用于任意纹理多边形面光源（卡片、圆盘、管状等）和一般 BRDF； 准确性高：离线构建 BRDF 参数（粗糙度、入射角）的查找表，实时通过查找表快速求解。 缺点：计算成本高于前两种方法，但准确性更优。 一般光源形状的适配的处理 光源形状 适用场景 关键处理方式 球形光源 基础简化光源 粗糙度修正、代表性点技术（核心适配对象） 管状（胶囊）光源 模拟荧光灯管 Lambertian 表面可用 Picott 的封闭积分解；非 Lambertian 表面用代表性点技术叠加点光源 平面光源（卡片 / 圆盘 / 多边形） 柔光箱、反光板、发光面板等 - 简单近似：Drobot 的代表性点方法（找积分最大值附近的点）；- 精确解析：Arvo 的辐照度张量 + 轮廓积分（实时成本高）；- 实用优化：Lecocq 的 O (1) 轮廓积分近似 环境光照 在实践中，通常我们会认为直接光具有较高的radiance和相对较小的立体角，而间接光往往会以中等或者较低的radiance，来覆盖半球方向上的其余部分。基于这种划分方式和理由，我们可以将二者分开进行处理。\n恒定环境光 核心假设：入射辐射度 $L_A$ 不随方向变化（全方向恒定），是最基础的环境光照模型。\n对不同表面的贡献\nLambertian（漫反射）表面 特点：出射辐射度贡献恒定，与表面法线 $\\mathbf{n}$ 和观察方向 $\\mathbf{v}$ 无关。 公式： $$ L_o(\\mathbf{v}) = \\rho_{\\mathrm{ss}} L_A $$ 简化逻辑： 漫反射的 BRDF 为 $\\frac{\\rho_{\\mathrm{ss}}}{\\pi}$，半球积分为 $\\int_{\\Omega} (\\mathbf{n} \\cdot \\mathbf{l}) d\\mathbf{l} = \\pi$，因此最终 $\\frac{\\rho_{\\mathrm{ss}}}{\\pi} \\cdot L_A \\cdot \\pi = \\rho_{\\mathrm{ss}} L_A$。 任意 BRDF 表面 特点：贡献依赖定向反照率 $R(\\mathbf{v})$。 定向反照率定义： $$ R(\\mathbf{v}) = \\int_{\\Omega} f(\\mathbf{l}, \\mathbf{v}) (\\mathbf{n} \\cdot \\mathbf{l}) \\, d\\mathbf{l} $$ 出射辐射度公式： $$ L_o(\\mathbf{v}) = L_A \\cdot R(\\mathbf{v}) $$ 老版本简化（近似方案） 假设：定向反照率 $R(\\mathbf{v})$ 为恒定值（称为环境颜色 $\\mathbf{c}_{\\mathrm{amb}}$）。 公式： $$ L_o(\\mathbf{v}) = \\mathbf{c}_{\\mathrm{amb}} \\cdot L_A $$ 球面函数和半球函数 把环境光照 $L_A$ 视为一个常数，这意味着：\n每个方向来的光强都是相同的。\n但真实世界中并非如此。一个点从不同方向接收到的光强往往不同，例如：\n左边可能是红墙 → 红色光； 右边可能是绿树 → 绿色光； 上方可能被遮挡 → 几乎没有光。 因此，要描述这种“方向依赖的入射辐射度（radiance）”，我们需要一个定义在**方向空间（sphere）**上的函数。\n我们把所有方向的集合记为： $$ S = \\{\\omega \\in \\mathbb{R}^3 : ||\\omega|| = 1\\} $$ 于是，一个方向依赖的入射辐射度可以写为： $$ L_i(\\omega) : S \\rightarrow \\mathbb{R}^+ $$ 它表示从方向 $\\omega$ 入射的 radiance。\n这个函数定义在球面上（而非位置上），因此叫球面函数（spherical function）。\n对于一个 Lambertian 表面，其出射辐射度与入射辐射度的关系是： $$ L_o = \\frac{\\rho}{\\pi} \\int_{H(\\mathbf{n})} L_i(\\omega_i) (\\mathbf{n} \\cdot \\omega_i) d\\omega_i $$ 其中：\n$H(\\mathbf{n})$：法线方向上的半球； $(\\mathbf{n} \\cdot \\omega_i)$：余弦项； 这个积分就是radiance 与余弦波瓣的卷积。 因此，为了实时渲染，我们往往预计算： $$ E(\\mathbf{n}) = \\int_{H(\\mathbf{n})} L_i(\\omega_i) (\\mathbf{n} \\cdot \\omega_i) d\\omega_i $$ 并把它称为 irradiance function，它是球面函数的卷积结果。预计算每个法线半球的irradiance。\n球面函数是定义在球面上的连续函数，不可能直接存储。所以我们要找一种“基底”，用有限个系数表示它。这就类似于在平面上用傅立叶级数展开周期函数。\n例如： $f(\\omega) = \\sum_{l,m} c_{lm} Y_{lm}(\\omega)$， 其中 $Y_{lm}$ 是球谐函数（Spherical Harmonics）。\n这类基底称为：\n球面基底（Spherical Base） 包括球谐函数、Haar 小波基、径向基、环境高光基底（AHD）等。 投影（Projection）： 把函数 $L_i(\\omega)$ 投影到基底空间，求出系数： $$ c_{lm} = \\int_S L_i(\\omega) Y_{lm}(\\omega) d\\omega $$ 重建（Reconstruction）： 通过这些系数恢复函数： $$ \\tilde{L_i}(\\omega) = \\sum_{l,m} c_{lm} Y_{lm}(\\omega) $$ 总结一下：一个定义在球面上的函数 $f(\\omega)$，可以用一组球面基函数 $Y_l^m(\\omega)$ 的线性组合来近似表示。 你只需要计算保存这些线性组合的系数，在任何方向上（角度）都可以通过这些系数 + 基函数重建出近似值。\n球面基底的简单表示形式 表格（采样点）表示球面函数 方法：直接在球面上选若干方向 $\\omega_i$，存储每个方向上的函数值 $f(\\omega_i)$。 重建：当需要某个方向 $\\omega$ 的值时，找到附近的样本点，用插值（如双线性或三线性）求出近似值。 优点： 非常直观，易于理解和实现； 表征能力强，只要采样足够密集，几乎可以精确还原原函数； 函数相加/相乘简单：直接对对应采样点的值相加或相乘。 缺点： 样本少时重建误差大； 旋转不变性差（函数旋转后，插值结果可能不准确，导致闪烁或脉动瑕疵）； 高频函数需要大量样本，存储和计算量大； 卷积等操作复杂，计算成本和样本数量成正比。 环境立方体（Ambient Cube, AC）\n思想：用 6 个值（立方体六个面）表示球面函数。 公式： $$ F_{AC}(\\mathbf{d}) = \\mathbf{d} \\cdot \\operatorname{sel}_+(\\mathbf{c}_+, \\mathbf{c}_-, \\mathbf{d}) $$ $\\mathbf{d}$ 是方向向量； $\\mathbf{c}+$ 和 $\\mathbf{c}-$ 是立方体六个面的值； $\\operatorname{sel}_+$ 根据方向的正负性选择对应面。 特点： 只需三个波瓣参与计算（x、y、z方向）； 在软件重建上可能比 GPU 双线性过滤更快； 精度低，但实现简单； 可以和球谐 SH 互相转换（Sloan 提供方法）。 环境骰子（Ambient Dice, AD）：\n基底由二十面体顶点方向上的平方和四次余弦波瓣组成； 需要存储 12 个值中的 6 个来重建； 重建质量比环境立方体高，但逻辑稍复杂。 球面基底 基函数的一个例子。在这个例子中，输入值为0-5之间的一个数，函数会返回0-1之间一个值，左侧的图展示了这样一个函数。中间的图展示了一组基函数（每种颜色都代表了一个不同的基函数）。右图则展示了使用基函数来对目标函数的近似，通过将每个基函数乘以一个权重并将它们相加来完成这个近似。右图中的每个基函数都按照各自的权重进行了缩放，图中的黑色线条代表了基函数求和之后的结果，这是对原始函数的近似结果；图中灰色线条代表了原始函数，用于和近似函数进行比较。\n球面径向基函数（SRBF） 定义：一类沿轴旋转对称的函数，只依赖于 方向之间的夹角。 输入参数 = 方向 $\\mathbf{v}$ 与波瓣方向 $\\mathbf{d}$ 的夹角。 构建方法： 将球面覆盖上若干固定方向的“波瓣”（lobe）； 每个波瓣有大小/尖锐度参数； 函数值 = 所有波瓣在该方向上的值加权求和。 优点： 可以获得比双线性插值更高质量的重建； 可通过固定波瓣方向，简化投影（只需拟合权重）。 球面高斯（SG, Spherical Gaussian） 定义：一种常用的 SRBF，类似 von Mises-Fisher 分布： $$ G(\\mathbf{v}, \\mathbf{d}, \\lambda) = e^{\\lambda(\\mathbf{v} \\cdot \\mathbf{d}-1)} $$ $\\mathbf{v}$：计算方向 $\\mathbf{d}$：波瓣主方向 $\\lambda$：尖锐度/扩散参数 球面函数表示： $$ F_G(\\mathbf{v}) = \\sum_k w_k G(\\mathbf{v}, \\mathbf{d}_k, \\lambda_k) $$ 通过拟合 $w_k$ 来最小化重建误差。 如果波瓣方向和扩散参数固定，投影成基就简单 → 线性最小二乘法。 如果方向/扩散参数可变 → 非线性优化，难度大。 球谐函数（spherical harmonic，SH） 球谐函数（spherical harmonic，SH）是球面上的一组正交的基函数。一组基函数的正交集（orthogonal set）具有如下特点：任意两个不同基函数之间的内积（inner product）为零。内积是一个类似于点积的概念。两个向量之间的内积就是它们的点积，即各个分量对应相乘再相加的结果。我们可以类似地推导出两个函数的内积的定义，即将这两个函数相乘再进行积分，其数学表达如下：\n$$ \\left\\langle f_{i}(\\mathbf{n}), f_{j}(\\mathbf{n})\\right\\rangle \\equiv \\int_{\\mathbf{n} \\in \\Theta} f_{i}(\\mathbf{n}) f_{j}(\\mathbf{n}) d \\mathbf{n} $$ 下面说的是为了说明什么是标准正交基，以及他的优势对比普通基底\n标准正交集（orthonormal set）也是一个正交集，其附加条件是该集合中的任意一个函数与自身的内积都为1。更加正式的表述方式为，一组函数${f_j()}$是标准正交的条件是：\n$$ \\left\\langle f_{i}(), f_{j}()\\right\\rangle=\\left\\{\\begin{array}{ll}0, \u0026 \\text { where } i \\neq j, \\\\ 1, \u0026 \\text { where } i=j .\\end{array}\\right. $$ 展示了一个类似于图10.18的例子，不同之处在于，其中的基函数都是标准正交的。请注意，图10.20中的所展示的标准正交基函数都是互不重叠的，这个条件对于非负函数的标准正交集合是十分必要的，因为任何的重叠都意味着内积非零。而对于那些在部分范围内为负的基函数，则可以发生重叠，它们仍然可以形成标准正交集。这种重叠通常会得到更好的近似结果，因为它允许使用更加平滑的基底。而那些互不重叠的基函数，往往会导致近似结果的不连续性。\n标准正交基的优点在于，想要找到最接近目标函数的近似值十分简单。为了完成投影操作，每个基函数的权重系数都是目标函数$f_{\\text {target }}() $与相应基函数的内积：\n$$ \\begin{array}{c}k_{j}=\\left\\langle f_{\\text {target }}(), f_{j}()\\right\\rangle, \\\\[2mm] f_{\\text {target }}() \\approx \\sum_{j=1}^{n} k_{j} f_{j}() .\\end{array} \\tag{10.23} $$ SH的基\nSH 的特点\n旋转不变性： 基函数在旋转下有解析变换公式，旋转投影函数方便。 计算成本低： 基函数是 $x, y, z$ 坐标的多项式。 全局支持： 类似球面高斯，每个基函数影响球面上所有方向。 频带排列： 第 0 频带：常数函数 第 1 频带：线性函数 第 2 频带：二次函数 … → 低频分量变化慢，高频分量变化快 光照积分等低频函数可以用少量系数表示（比如 irradiance）。 积分和乘积解析： 两个函数在球面上的积分可以转化为它们系数的点积 → 高效计算光照积分。 其他球面表示方法 除了 SH，还有：\n方法 特点 用途 线性变换余弦（LTC） 近似 BRDF 高效积分 球面小波（spherical wavelet） 空间局部性 + 频率局部性 高频函数压缩 球面分段常数基函数 球面划分为常数区域 环境光照表示 双聚类近似（biclustering） 矩阵分解方法 环境光照压缩 半球基底 为什么要用半球基底\n上节讲的 SH、球面高斯等基底都是定义在 整个球面 上。 但是很多光照相关函数 天然只定义在半球（例如 BRDF、入射辐照度、到达表面的光照），另一半球（向下）是零。 如果用全球基底来表示半球函数，会 浪费一半的表示能力。 因此研究者提出了 直接在半球域上构造的基底。 AHD基底 最简单的半球表示方法：\nA：恒定环境光 H：高光方向光 D：入射光集中的方向 存储：\n8 个参数（2 个角度 + 6 个 RGB 颜色） 投影：\n非线性 → 通常先投影到 SH，再用最优方向确定余弦波瓣方向 → 最小二乘求权重 用途：\n表面 irrandiance 存储\n游戏应用：《雷神之锤3》《使命召唤》\n半条命2基底（Radiosity Normal Mapping） 用 3 个 互相垂直的基向量表示半球函数 基向量在切线空间中： $$ \\mathbf{m}_0, \\mathbf{m}_1, \\mathbf{m}_2 $$ 重建公式： $$ E(\\mathbf{n}) = \\frac{\\sum_{k=0}^{2} \\max(\\mathbf{m}_k \\cdot \\mathbf{n}, 0)^2 E_k}{\\sum_{k=0}^{2} \\max(\\mathbf{m}_k \\cdot \\mathbf{n}, 0)^2} \\quad \\text{(10.25)} $$ 优化后可简化为线性加权形式： $$ E(\\mathbf{n}) = \\sum_{k=0}^{2} d_k E_k \\quad \\text{(10.27)} $$ 特点： 存储少、计算快 可与法线贴图结合 实际效果优于低阶 SH 半球谐波 / H-Basis 将 SH 特化到半球域：\n半球谐波（HSH）：类似 SH，但只定义在半球 可以使用 Zernike 多项式（单位圆盘上的正交函数）来表示半球函数 H-Basis（Habel）：\n混合部分球谐函数 + HSH 正交 计算效率较高 可以对系数向量进行矩阵运算完成旋转 环境映射 将一个球面函数记录在一个或者多个图像中的做法，被称为环境映射（environment mapping）\n经纬度映射 就是把环境光贴图表达为经纬度采样，计算要采样方向的经纬度，去对应位置读取\n起源：1976年，Blinn 和 Newell 实现了第一个环境映射算法。\n原理：\n把球面上的环境信息“展开”到二维纹理。\n类似地球上的经纬度系统：\n$\\phi$（经度） → $[-π, π]$ $\\rho$（纬度） → $[0, π]$ 将反射向量 $\\mathbf{r} = (r_x, r_y, r_z)$ 转换为球坐标： $$ \\rho = \\arccos(r_z), \\quad \\phi = \\text{atan2}(r_y, r_x) \\quad \\text{(10.30)} $$ 用 $(\\rho, \\phi)$ 来访问纹理，获取环境颜色。\n注意：\n纬度-经度映射 ≠ Mercator 投影： 纬度-经度映射保持纬度线之间的距离恒定。 Mercator 投影在两极会极度拉伸。 球面映射 球面映射把环境光照记录在一个圆形纹理图像中，就像在一个完全反射的球体上看到环境一样。这个纹理也称为光照探针（light probe），因为它捕捉了球体位置处的光照。\n计算流程\n固定观察向量 $\\mathbf{v} = (0,0,1)$： 球面贴图是在一个固定视角下拍摄或者生成的，反映了这个方向的环境信息。\n求半角向量 $\\mathbf{n}$： 反射观察向量 $\\mathbf{r}$ 和原始观察向量 $\\mathbf{v}$ 的和： $$ \\mathbf{n} = \\frac{(r_x, r_y, r_z + 1)}{\\sqrt{r_x^2 + r_y^2 + (r_z + 1)^2}} $$ 映射到纹理坐标 $(u,v)$： $$ u = \\frac{r_x}{2 m} + 0.5, \\quad v = \\frac{r_y}{2 m} + 0.5 $$ 这里 $m = \\sqrt{r_x^2 + r_y^2 + (r_z + 1)^2}$ 将 $[-1,1]$ 范围映射到 $[0,1]$ 纹理查找：\n使用 $(u,v)$ 在球面贴图中查找对应的入射辐射 $L_i(\\mathbf{r})$ 立方体映射 将环境投影到以相机位置为中心的立方体六个面上，每个面90° FOV。\n六张正方形纹理分别对应立方体的 +X、-X、+Y、-Y、+Z、-Z。\n访问纹理时，直接用方向向量 $\\mathbf{r}$ 指向哪个面，再根据面内坐标取值即可。 这个在LearnOpenGL中实现过\n基于图像的高光照明 这里讲的就是LearnOpenGL的IBL实现，高光就是反射的一个范围内都能接收到\n环境映射最初是作为一种镜面渲染技术发展起来的，但是也可以将它扩展到光泽（glossy）反射中。当用于模拟无限远处光源的镜面效果时，这样的环境贴图也被称为高光探针（specular light probe）。（这里的镜面反射就是只有反射方向的颜色，光泽反射就是反射的一个范围内都能接收到）\n先解释一下lobe是什么，lobe说的都是BRDF的lobe\n对于一个 给定的入射方向 $\\mathbf{l}$，BRDF $f_r(\\mathbf{l}, \\mathbf{v})$ 会定义出 所有出射方向 $\\mathbf{v}$ 上的反射强度。\n**波瓣（lobe）**就是指这个方向空间中 反射值显著非零的区域。\n理想镜面 → 波瓣无限尖锐，只在反射方向上非零。 光泽表面 → 波瓣稍宽，附近方向也有非零反射。 漫反射 → 波瓣非常宽，几乎整个半球都有非零反射。 下图展示镜面和高光的区别\n径向对称的镜面波瓣：反射方向在lobe中心\n径向对称的镜面波瓣是一种渲染的假设，但会存在地平线裁剪问题\n对于上边这种情况，观察球的表面时lobe会被地平线裁剪，这时候经径向对称的镜面波瓣假设是错的，会让表面高光过亮\n环境贴图 + mipmap 模拟粗糙度\nHeidrich 和 Seidel 方法：用一个环境贴图来模拟表面模糊反射。 粗糙度的处理： 环境立方体贴图生成多级 mipmap。 低级别 mipmap：高分辨率，对应光滑表面（高光尖锐）。 高级别 mipmap：低分辨率，对应粗糙表面（高光模糊）。 渲染时，通过 反射向量索引贴图 并选择对应 mipmap 层，实现不同粗糙度的反射。 图10.36显示了不同粗糙度波瓣对应的 mipmap 层级及渲染结果。\n卷积环境贴图 想要生成预过滤的环境贴图意味着，要对与方向$\\mathbf{v}$相关的每个纹素上，计算环境radiance与镜面波瓣$D$的积分：\n$$ \\int_{\\Omega} D(\\mathbf{l}, \\mathbf{v}) L_{i}(\\mathbf{l}) d \\mathbf{l}. $$这个积分是一个球面卷积（spherical convolution），由于其中的$L_i$只能通过环境贴图的表格形式获得，因此这个积分通常无法解析求解，只能通过数值方法进行求解。一种流行的数值方法是采用蒙特卡罗方法：\n$$ \\int_{\\Omega} D(\\mathbf{l}, \\mathbf{v}) L_{i}(\\mathbf{l}) d \\mathbf{l} \\approx \\lim _{N \\rightarrow \\infty} \\frac{1}{N} \\sum_{k=1}^{N} \\frac{D\\left(\\mathbf{l}_{k}, \\mathbf{v}\\right) L_{i}\\left(\\mathbf{l}_{k}\\right)}{p\\left(\\mathbf{l}_{k}, \\mathbf{v}\\right)} \\tag{10.36} $$ 蒙特卡罗积分\n通过随机采样球面方向近似积分： $$ \\int_\\Omega D(\\mathbf{l}, \\mathbf{v}) L_i(\\mathbf{l}) \\, d\\mathbf{l} \\approx \\frac{1}{N} \\sum_{k=1}^{N} \\frac{D(\\mathbf{l}_k, \\mathbf{v}) L_i(\\mathbf{l}_k)}{p(\\mathbf{l}_k, \\mathbf{v})} $$ $\\mathbf{l}_k$ 是球面样本方向 $p(\\mathbf{l}_k, \\mathbf{v})$ 是采样概率密度 问题：\n高光波瓣很尖，很多样本 $D(\\mathbf{l}_k, \\mathbf{v})\\approx 0$ → 浪费计算 第一级 mipmap（高分辨率，低粗糙度）尤其慢 重要性采样（Importance Sampling）\n通过选择采样概率 $p(\\mathbf{l}_k, \\mathbf{v})$ 更贴近波瓣形状 减少无效样本，降低方差 可以结合 环境贴图的 radiance 分布 和镜面波瓣进一步优化 适合 离线渲染 或生成 ground-truth 数据 用锥形 / 区域采样降低方差\n不用单点采样，而是用 锥形区域 样本代替一个方向 可以对应 mipmap 层级进行近似 会引入偏差，但显著降低样本数，甚至可以 GPU 实时计算 示例技术： McGuire 等人：实时近似卷积，通过混合不同 mipmap 层级来重建波瓣 Hensley 等人：使用 SAT（前缀和）加速积分 Kautz / Manson \u0026amp; Sloan：B 样条滤波生成 mipmap，提高精度与速度 缺点：\n忽略了 shadowing-masking 和半向量菲涅尔。 仅适合粗略镜面近似或 Phong 模型。 会产生误差，如地平线裁剪或掠射角下波瓣尖锐变化 split-sum 改进：将镜面环境光积分拆分为两个部分： 环境光卷积：波瓣 $D$ 与环境光 $L_i$ 的积分，依赖于反射方向和粗糙度，可预计算到立方体贴图 mipmap。 半球定向反射率 $R_\\text{spec}$：包含 BRDF 权重（菲涅尔、shadowing-masking），依赖于视角、粗糙度，可预计算到二维或三维查找表。 优势： 对任意微表面 BRDF 都适用，不只是 Phong。 运行时快速查表即可获得高光结果。 注意： 假设波瓣径向对称（仍会有误差）。 可以通过轻微调整查询方向来补偿半球裁剪误差。 不对称和各向异性波瓣 传统预过滤环境贴图的假设\n早期方法（如 10.5.1）假设镜面波瓣是径向对称，仅依赖于反射向量。 这种方法在 Phong 或简单各向同性 BRDF 下有效。 波瓣的径向对称意味着： 波瓣围绕反射向量旋转时，形状不变。 高光大小只由粗糙度决定，与光线方向旋转无关。 微表面 BRDF 的复杂性\n微表面 BRDF（如 GGX）是围绕半向量 $\\mathbf{h}=(\\mathbf{l}+\\mathbf{v})/|\\mathbf{l}+\\mathbf{v}|$ 定义的。 半向量依赖入射方向 $\\mathbf{l}$，环境光下 $\\mathbf{l}$ 并不唯一。 因此即便各向同性，也无法满足反射向量径向对称。 直接将微表面 BRDF 转换为径向对称波瓣会引入较大误差（高光形状偏离真实情况，尤其在掠射视角）。 图10.38 显示 GGX 原生波瓣（红） vs. 径向对称近似波瓣（绿），差异明显。 改进方法\nKautz \u0026amp; McCool：\n使用单个最佳样本拟合 BRDF，而不是恒定修正因子。 对多个样本平均，近似半向量模型的拉伸高光。 引入能量修正因子，补偿径向对称近似的能量损失。 Iwanicki \u0026amp; Pesce：\n对 GGX BRDF 做环境立方体贴图近似，利用 Nelder-Mead 最小化优化样本位置。 可以结合 GPU 各向异性过滤加速采样。 Revie / McAuley：\n在延迟渲染和毛皮渲染中，调整单个样本位置以匹配复杂 BRDF 峰值。 McAllister / Lafortune BRDF：\n利用 Lafortune BRDF（多个广义 Phong 波瓣叠加）表达复杂材质。 依然可以用传统预过滤环境贴图和 mipmap 编码不同 Phong 指数。 irradiance环境映射 上一小节中我们讨论了如何使用预过滤环境贴图来实现光泽反射，这些贴图同样也可以用于实现漫反射\n计算irradiance环境贴图的过程。在原始环境纹理（本例中是一个立方体贴图）中对表面法线周围的余弦加权半球进行采样，并对采样结果进行求和，从而获得与视图无关的irradiance。图中绿色的方块代表了立方体贴图的横截面，红色的虚线代表了纹素之间的边界。这里使用的是一个立方体贴图，但实际上任何环境表示都是可以使用的。\n球谐irradiance 漫反射光照低频：irradiance 随表面法线变化平滑，适合用低阶 SH 表示。\n低阶 SH 足够：前 9 个系数（每个 RGB 通道）就能表示环境光，误差约 1%；间接光照甚至只需 4 个系数（12 个浮点数）。\n卷积方法高效：\n从 radiance 贴图计算 irradiance 实际上是与 clamped 余弦函数卷积。\n卷积在 SH 空间中可通过系数相乘快速完成： $$ k_{E j} = k_{\\cos^+ j}' \\, k_{L j} $$ $k_{L j}$ 是 radiance 的 SH 系数，$k_{\\cos^+ j}\u0026rsquo;$ 是 clamped 余弦函数系数的缩放\n紧凑：比立方体贴图或抛物线贴图节省存储。\n高效：渲染时可通过多项式计算重建 irradiance，而无需纹理采样。\n动态光源支持：\n新光源的 SH 系数可以累加到已有 SH 系数中，无需重算整个贴图。 点光源、圆盘光源、球光源等均有解析 SH 表达式，可快速叠加。 其他方法 方法 特点 优缺点 半球光照（hemisphere lighting） 使用天空颜色 $L_{sky}$ 和地面颜色 $L_{ground}$ 两种均匀radiance表示上半球和下半球 简单、存储少、计算快；精度低 环境立方体（ambient cube） 将半球光照扩展为6个方向的立方体 较SH更直观，可用于快速计算；存储略大 球面高斯函数 对irradiance使用旋转对称的高斯函数表示 平滑，适合低频光照 H-basis 只能表示一个半球方向 有局限，法线向下时不可用 SH（球谐函数） 低频光照的紧凑表示，可做卷积、动态光源累加 高频光照不足，振铃现象存在 ","date":"2025-10-22T14:19:43+08:00","image":"https://sdpyy1.github.io/202306151248940.png","permalink":"https://sdpyy1.github.io/p/local-illumination-%E5%B1%80%E9%83%A8%E5%85%89%E7%85%A7/","title":"Local Illumination 局部光照"},{"content":" 废弃\nStaticMesh收集 首先从Scene的Entity找到包含StaticMesh组件的Entity，第一步剔除不显示的Mesh\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 void Scene::CollectRenderableEntities(Ref\u0026lt;SceneRender\u0026gt; sceneRender) { // 收集StaticMesh auto allEntityOwnMesh = GetAllEntitiesWith\u0026lt;StaticMeshComponent\u0026gt;(); for (auto entity : allEntityOwnMesh) { auto\u0026amp; staticMeshComponent = allEntityOwnMesh.get\u0026lt;StaticMeshComponent\u0026gt;(entity); if (!staticMeshComponent.Visible) continue; Ref\u0026lt;MeshSource\u0026gt; mesh = AssetManager::GetAsset\u0026lt;MeshSource\u0026gt;(staticMeshComponent.StaticMesh); if (mesh == nullptr) continue; Entity e = Entity(entity, this); glm::mat4 transform = GetWorldSpaceTransformMatrix(e); sceneRender-\u0026gt;SubmitStaticMesh(mesh, transform); } } 另外因为层级结构，需要递归处理每个Mesh的模型变换矩阵\n1 2 3 4 5 6 7 8 9 10 glm::mat4 Scene::GetWorldSpaceTransformMatrix(Entity entity) { glm::mat4 transform(1.0f); Entity parent = GetEntityByUUID(entity.GetParentUUID()); if (parent) transform = GetWorldSpaceTransformMatrix(parent); return transform * entity.Transform().GetTransform(); } 最后注册这个Mesh，携带它的模型变换。\n注册Mesh到StaticDrawList 遍历每个SubMesh\n计算他的全局变换矩阵 获取MeshKey（用于实例化，这样做自动把一样的SubMesh自动放在了一起），这里Key就表示同一个SubMesh同一个材质，就属于实例化的一个 缓存对应的变换矩阵 增加对应DrawCommand的实例数量，最终每一个MeshKey对应的DrawCommand都会变成一个DrawCall 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 struct StaticDrawCommand { Ref\u0026lt;MeshSource\u0026gt; MeshSource; uint32_t SubmeshIndex; Ref\u0026lt;MaterialAsset\u0026gt; MaterialAsset; uint32_t InstanceCount = 0; }; void SceneRender::SubmitStaticMesh(Ref\u0026lt;MeshSource\u0026gt; meshSource, const glm::mat4\u0026amp; transform) { const auto\u0026amp; submeshData = meshSource-\u0026gt;GetSubmeshes(); for(uint32_t submeshIndex = 0; submeshIndex \u0026lt;submeshData.size(); submeshIndex++){ glm::mat4 submeshTransform = transform * submeshData[submeshIndex].Transform; // subMesh的全局变换 AssetHandle materialHandle = meshSource-\u0026gt;GetMaterialHandle(submeshData[submeshIndex].MaterialIndex); Ref\u0026lt;MaterialAsset\u0026gt; material = AssetManager::GetAsset\u0026lt;MaterialAsset\u0026gt;(materialHandle);// subMesh的材质索引; HZ_CORE_VERIFY(material); MeshKey meshKey = { meshSource-\u0026gt;Handle, materialHandle, submeshIndex, false }; // 缓存变换矩阵 auto\u0026amp; transformStorage = m_MeshTransformMap[meshKey].Transforms.emplace_back(); // 对于每一种MeshKey，都存储多个Transforms矩阵，表示多个实例 transformStorage.MRow[0] = { submeshTransform[0][0], submeshTransform[1][0], submeshTransform[2][0], submeshTransform[3][0] }; transformStorage.MRow[1] = { submeshTransform[0][1], submeshTransform[1][1], submeshTransform[2][1], submeshTransform[3][1] }; transformStorage.MRow[2] = { submeshTransform[0][2], submeshTransform[1][2], submeshTransform[2][2], submeshTransform[3][2] }; // 缓存绘制命令 auto\u0026amp; destDrawList = m_StaticMeshDrawList; auto\u0026amp; dc = destDrawList[meshKey]; dc.MeshSource = meshSource; dc.SubmeshIndex = submeshIndex; dc.MaterialAsset = material; dc.InstanceCount++; } }; 缓存所有DrawCommand的变换矩阵 注册完成后，处理模型变换信息，这里使用的是顶点缓冲区来存储模型变换信息，下面代码作用就是把每个要渲染的SubMesh的变换矩阵按顺序存在一个顶点缓冲区，等待渲染时绑定\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 收集所有参与渲染的Mesh的变换矩阵存储在m_SubmeshTransformBuffers { uint32_t frameIndex = Renderer::GetCurrentFrameIndex(); uint32_t offset = 0; for (auto\u0026amp; [key, transformData] : m_MeshTransformMap) { transformData.TransformOffset = offset * sizeof(TransformVertexData); for (const auto\u0026amp; transform : transformData.Transforms) { m_SubmeshTransformBuffers[frameIndex].Data[offset] = transform; offset++; } } // 在这里所有的变换矩阵已经上传到GPU（RT线程） m_SubmeshTransformBuffers[frameIndex].Buffer-\u0026gt;SetData(m_SubmeshTransformBuffers[frameIndex].Data, offset * sizeof(TransformVertexData)); } 下面要渲染时就按顺序渲染\n1 2 3 4 5 for (auto\u0026amp; [meshKey, drawCommand] : m_StaticMeshDrawList) { const auto\u0026amp; transformData = m_MeshTransformMap.at(meshKey); // 其中已经记录好了每个DrawCall对应的变换矩阵偏移 Renderer::RenderStaticMeshWithMaterial(m_CommandBuffer, m_GeoPipeline,drawCommand.MeshSource, drawCommand.SubmeshIndex, drawCommand.MaterialAsset-\u0026gt;GetMaterial(), m_SubmeshTransformBuffers[frameIndex].Buffer, transformData.TransformOffset, drawCommand.InstanceCount); } 实例化渲染 绑定整个Mesh的顶点数据 绑定所有变换矩阵的缓冲区也作为顶点数据，并设置正确的偏移量 绑定索引 材质相关 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 void VulkanRenderer::RenderStaticMeshWithMaterial(Ref\u0026lt;RenderCommandBuffer\u0026gt; renderCommandBuffer, Ref\u0026lt;Pipeline\u0026gt; pipeline, Ref\u0026lt;MeshSource\u0026gt; meshSource, uint32_t submeshIndex, Ref\u0026lt;Material\u0026gt; material, Ref\u0026lt;VertexBuffer\u0026gt; transformBuffer, uint32_t transformOffset, uint32_t instanceCount) { HZ_CORE_ASSERT(meshSource); HZ_CORE_ASSERT(material); Ref\u0026lt;VulkanMaterial\u0026gt; vulkanMaterial = material.As\u0026lt;VulkanMaterial\u0026gt;(); Renderer::Submit([renderCommandBuffer, pipeline,meshSource, submeshIndex, vulkanMaterial, transformBuffer, transformOffset, instanceCount]() mutable { uint32_t frameIndex = Renderer::RT_GetCurrentFrameIndex(); VkCommandBuffer commandBuffer = renderCommandBuffer.As\u0026lt;VulkanRenderCommandBuffer\u0026gt;()-\u0026gt;GetActiveCommandBuffer(); Ref\u0026lt;VulkanVertexBuffer\u0026gt; meshVertBuffer = meshSource-\u0026gt;GetVertexBuffer().As\u0026lt;VulkanVertexBuffer\u0026gt;(); VkBuffer vkMeshVertBuffer = meshVertBuffer-\u0026gt;GetVulkanBuffer(); VkDeviceSize vertexOffsets[1] = { 0 }; vkCmdBindVertexBuffers(commandBuffer, 0, 1, \u0026amp;vkMeshVertBuffer, vertexOffsets); // 把整个Mesh的顶点都绑定 Ref\u0026lt;VulkanVertexBuffer\u0026gt; vulkanTransformBuffer = transformBuffer.As\u0026lt;VulkanVertexBuffer\u0026gt;(); VkBuffer vkTransformBuffer = vulkanTransformBuffer-\u0026gt;GetVulkanBuffer(); VkDeviceSize instanceOffsets[1] = { transformOffset }; vkCmdBindVertexBuffers(commandBuffer, 1, 1, \u0026amp;vkTransformBuffer, instanceOffsets); // 第二个顶点缓冲区绑定当前SubMesh的变换矩阵数据 auto vulkanMeshIB = Ref\u0026lt;VulkanIndexBuffer\u0026gt;(meshSource-\u0026gt;GetIndexBuffer()); VkBuffer ibBuffer = vulkanMeshIB-\u0026gt;GetVulkanBuffer(); vkCmdBindIndexBuffer(commandBuffer, ibBuffer, 0, VK_INDEX_TYPE_UINT32); // 索引缓冲区全绑定 // 每个材质绑定自己的 Set=1 VkDescriptorSet matSet = vulkanMaterial-\u0026gt;GetDescriptorSets()[frameIndex]; vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline.As\u0026lt;VulkanPipeline\u0026gt;()-\u0026gt;GetVulkanPipelineLayout(), 1, // Set=1 1, \u0026amp;matSet, 0, nullptr); const auto\u0026amp; submeshes = meshSource-\u0026gt;GetSubmeshes(); const auto\u0026amp; submesh = submeshes[submeshIndex]; vkCmdDrawIndexed(commandBuffer, submesh.IndexCount/*索引数量*/, instanceCount/*实例数量*/, submesh.BaseIndex/*索引缓冲区的偏移*/, submesh.BaseVertex/*顶点偏移*/, 0/*实例化ID开始的编号*/); }); } 另外要注意实例的变换矩阵的切换时机要在Pipeline创建时设置好,下面可以看到实例顶点布局的inputRate是设置的VK_VERTEX_INPUT_RATE_INSTANCE，这样他就会每个实例切换一次\n1 2 3 4 5 6 7 8 9 10 11 12 13 VkVertexInputBindingDescription\u0026amp; vertexInputBinding = vertexInputBindingDescriptions.emplace_back(); vertexInputBinding.binding = 0; vertexInputBinding.stride = vertexLayout.GetStride(); vertexInputBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; if (instanceLayout.GetElementCount()) { VkVertexInputBindingDescription\u0026amp; instanceInputBinding = vertexInputBindingDescriptions.emplace_back(); instanceInputBinding.binding = 1; instanceInputBinding.stride = instanceLayout.GetStride(); instanceInputBinding.inputRate = VK_VERTEX_INPUT_RATE_INSTANCE; } Shader 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 #version 450 core #ifdef VERTEX_SHADER layout(binding = 0) uniform UniformBufferObject { mat4 view; mat4 proj; float width; float height; float Near; float Far; } ubo; layout(location = 0) in vec3 inPosition; layout(location = 1) in vec3 inNormal; layout(location = 2) in vec3 inTangent; layout(location = 3) in vec3 inBinormal; layout(location = 4) in vec2 inTexCoord; layout(location = 5) in vec4 a_MRow0; layout(location = 6) in vec4 a_MRow1; layout(location = 7) in vec4 a_MRow2; layout(location = 0) out vec3 fragWorldPos; layout(location = 1) out vec3 fragWorldNormal; layout(location = 2) out vec3 fragWorldTangent; layout(location = 3) out vec3 fragWorldBinormal; layout(location = 4) out vec2 fragTexCoord; void main() { mat4 transform = mat4( vec4(a_MRow0.x, a_MRow1.x, a_MRow2.x, 0.0), vec4(a_MRow0.y, a_MRow1.y, a_MRow2.y, 0.0), vec4(a_MRow0.z, a_MRow1.z, a_MRow2.z, 0.0), vec4(a_MRow0.w, a_MRow1.w, a_MRow2.w, 1.0) ); vec4 worldPos = transform * vec4(inPosition, 1.0); fragWorldPos = worldPos.xyz; mat3 modelRot = mat3(transform); fragWorldNormal = normalize(modelRot * inNormal); fragWorldTangent = normalize(modelRot * inTangent); fragWorldBinormal = normalize(modelRot * inBinormal); fragTexCoord = inTexCoord; gl_Position = ubo.proj * ubo.view * worldPos; } #endif RenderDoc可以看到，只有一个DrawCall\n","date":"2025-10-21T20:11:16+08:00","image":"https://sdpyy1.github.io/image-20251022010105969.png","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E5%AE%9E%E4%BE%8B%E5%8C%96%E6%B8%B2%E6%9F%93/","title":"游戏引擎开发实践（实例化渲染）"},{"content":"骨骼构建 骨骼信息读取 骨骼数据包含\n骨骼名称 每个骨骼的父骨骼索引 每个骨骼相对于父骨骼的变换（平移、旋转、缩放） 整个骨架的变换矩阵 1 2 3 4 5 6 7 8 9 std::vector\u0026lt;std::string\u0026gt; m_BoneNames; std::vector\u0026lt;uint32_t\u0026gt; m_ParentBoneIndices; // rest pose of skeleton. All in bone-local space (i.e. translation/rotation/scale relative to parent) std::vector\u0026lt;glm::vec3\u0026gt; m_BoneTranslations; std::vector\u0026lt;glm::quat\u0026gt; m_BoneRotations; std::vector\u0026lt;glm::vec3\u0026gt; m_BoneScales; // The skeleton itself can have a transform // Notably this happens if the whole \u0026#34;armature\u0026#34; is rotated or scaled in DCC tool glm::mat4 m_Transform; 另外骨骼信息应该独属于Mesh，应该用unique_ptr包裹指针\n第一步直接把所以SubMesh的骨骼信息提取到Vector，这一步只是把Name提取出来了\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void BoneHierarchy::ExtractBones() { // Note: ASSIMP does not appear to support import of digital content files that contain _only_ an armature/skeleton and no mesh. for (uint32_t meshIndex = 0; meshIndex \u0026lt; m_Scene-\u0026gt;mNumMeshes; ++meshIndex) { const aiMesh* mesh = m_Scene-\u0026gt;mMeshes[meshIndex]; for (uint32_t boneIndex = 0; boneIndex \u0026lt; mesh-\u0026gt;mNumBones; ++boneIndex) { m_Bones.emplace(mesh-\u0026gt;mBones[boneIndex]-\u0026gt;mName.C_Str()); } } // Extract also any nodes that are animated (but don\u0026#39;t have any skin bound to them) for (uint32_t animationIndex = 0; animationIndex \u0026lt; m_Scene-\u0026gt;mNumAnimations; ++animationIndex) { const aiAnimation* animation = m_Scene-\u0026gt;mAnimations[animationIndex]; for (uint32_t channelIndex = 0; channelIndex \u0026lt; animation-\u0026gt;mNumChannels; ++channelIndex) { const aiNodeAnim* nodeAnim = animation-\u0026gt;mChannels[channelIndex]; m_Bones.emplace(nodeAnim-\u0026gt;mNodeName.C_Str()); } } } 第二步一套代码从assimp中读取了骨骼信息\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 void BoneHierarchy::TraverseNode(aiNode* node, Skeleton* skeleton, const glm::mat4\u0026amp; parentTransform) { if (m_Bones.find(node-\u0026gt;mName.C_Str()) != m_Bones.end()) { skeleton-\u0026gt;SetTransform(parentTransform); aiNode* parent = node-\u0026gt;mParent; while (parent) { parent-\u0026gt;mTransformation = aiMatrix4x4(); parent = parent-\u0026gt;mParent; } TraverseBone(node, skeleton, Skeleton::NullIndex); } else { auto transform = parentTransform * Utils::Mat4FromAIMatrix4x4(node-\u0026gt;mTransformation); for (uint32_t nodeIndex = 0; nodeIndex \u0026lt; node-\u0026gt;mNumChildren; ++nodeIndex) { TraverseNode(node-\u0026gt;mChildren[nodeIndex], skeleton, transform); } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void BoneHierarchy::TraverseBone(aiNode* node, Skeleton* skeleton, uint32_t parentIndex) { uint32_t boneIndex = skeleton-\u0026gt;AddBone(node-\u0026gt;mName.C_Str(), parentIndex, Utils::Mat4FromAIMatrix4x4(node-\u0026gt;mTransformation)); for (uint32_t nodeIndex = 0; nodeIndex \u0026lt; node-\u0026gt;mNumChildren; ++nodeIndex) { if (m_Bones.find(node-\u0026gt;mChildren[nodeIndex]-\u0026gt;mName.C_Str()) != m_Bones.end()) { TraverseBone(node-\u0026gt;mChildren[nodeIndex], skeleton, boneIndex); } else { // do not traverse any further. // It is not supported to have a non-bone and then more bones below it. } } } 蒙皮权重处理 首先定义骨骼权重信息\n1 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 struct BoneInfluence { uint32_t BoneInfoIndices[4] = { 0, 0, 0, 0 }; // 影响当前顶点的骨骼索引（最多4个） float Weights[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; // 对应骨骼的影响权重（最多4个） void AddBoneData(uint32_t boneInfoIndex, float weight) { if (weight \u0026lt; 0.0f || weight \u0026gt; 1.0f) { HZ_CORE_WARN(\u0026#34;Vertex bone weight is out of range. We will clamp it to [0, 1] (BoneID={0}, Weight={1})\u0026#34;, boneInfoIndex, weight); weight = std::clamp(weight, 0.0f, 1.0f); } if (weight \u0026gt; 0.0f) { for (size_t i = 0; i \u0026lt; 4; i++) { if (Weights[i] == 0.0f) { BoneInfoIndices[i] = boneInfoIndex; Weights[i] = weight; return; } } // Note: when importing from assimp we are passing aiProcess_LimitBoneWeights which automatically keeps only the top N (where N defaults to 4) // bone weights (and normalizes the sum to 1), which is exactly what we want. // So, we should never get here. HZ_CORE_WARN(\u0026#34;Vertex has more than four bones affecting it, extra bone influences will be discarded (BoneID={0}, Weight={1})\u0026#34;, boneInfoIndex, weight); } } void NormalizeWeights() { float sumWeights = 0.0f; for (size_t i = 0; i \u0026lt; 4; i++) { sumWeights += Weights[i]; } if (sumWeights \u0026gt; 0.0f) { for (size_t i = 0; i \u0026lt; 4; i++) { Weights[i] /= sumWeights; } } } }; 另外还要骨骼信息\n1 2 3 4 5 6 7 8 struct BoneInfo { glm::mat4 InverseBindPose; uint32_t BoneIndex; BoneInfo(const glm::mat4\u0026amp; inverseBindPose, uint32_t boneIndex) : InverseBindPose(inverseBindPose), BoneIndex(boneIndex) { } }; 下来遍历每个SubMesh的骨骼信息来处理每个顶点的权重信息\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 // skinning weights meshSource-\u0026gt;m_Skeleton = AssimpAnimationImporter::ImportSkeleton(scene); if (meshSource-\u0026gt;HasSkeleton()) { HZ_CORE_INFO_TAG(\u0026#34;Mesh\u0026#34;, \u0026#34;开始处理骨骼信息\u0026#34;); meshSource-\u0026gt;m_BoneInfluences.resize(meshSource-\u0026gt;m_Vertices.size()); for (uint32_t m = 0; m \u0026lt; scene-\u0026gt;mNumMeshes; m++) { aiMesh* mesh = scene-\u0026gt;mMeshes[m]; Submesh\u0026amp; submesh = meshSource-\u0026gt;m_Submeshes[m]; if (mesh-\u0026gt;mNumBones \u0026gt; 0) { submesh.IsRigged = true; for (uint32_t i = 0; i \u0026lt; mesh-\u0026gt;mNumBones; i++) { aiBone* bone = mesh-\u0026gt;mBones[i]; bool hasNonZeroWeight = false; for (size_t j = 0; j \u0026lt; bone-\u0026gt;mNumWeights; j++) { if (bone-\u0026gt;mWeights[j].mWeight \u0026gt; 0.000001f) { hasNonZeroWeight = true; break; } } if (!hasNonZeroWeight) continue; // Find bone in skeleton uint32_t boneIndex = meshSource-\u0026gt;m_Skeleton-\u0026gt;GetBoneIndex(bone-\u0026gt;mName.C_Str()); if (boneIndex == Skeleton::NullIndex) { HZ_CORE_ERROR_TAG(\u0026#34;Animation\u0026#34;, \u0026#34;Could not find mesh bone \u0026#39;{}\u0026#39; in skeleton!\u0026#34;, bone-\u0026gt;mName.C_Str()); } uint32_t boneInfoIndex = ~0; for (size_t j = 0; j \u0026lt; meshSource-\u0026gt;m_BoneInfo.size(); ++j) { if (meshSource-\u0026gt;m_BoneInfo[j].BoneIndex == boneIndex) { boneInfoIndex = static_cast\u0026lt;uint32_t\u0026gt;(j); break; } } if (boneInfoIndex == ~0) { boneInfoIndex = static_cast\u0026lt;uint32_t\u0026gt;(meshSource-\u0026gt;m_BoneInfo.size()); const auto\u0026amp; boneInfo = meshSource-\u0026gt;m_BoneInfo.emplace_back(Utils::Mat4FromAIMatrix4x4(bone-\u0026gt;mOffsetMatrix), boneIndex); HZ_CORE_INFO_TAG(\u0026#34;Mesh\u0026#34;, \u0026#34;BoneInfo for bone \u0026#39;{0}\u0026#39;\u0026#34;, bone-\u0026gt;mName.C_Str()); HZ_CORE_INFO_TAG(\u0026#34;Mesh\u0026#34;, \u0026#34; SubMeshIndex = {0}\u0026#34;, m); HZ_CORE_INFO_TAG(\u0026#34;Mesh\u0026#34;, \u0026#34; BoneIndex = {0}\u0026#34;, boneIndex); glm::vec3 translation; glm::quat rotationQuat; glm::vec3 scale; Math::DecomposeTransform(boneInfo.InverseBindPose, translation, rotationQuat, scale); glm::vec3 rotation = glm::degrees(glm::eulerAngles(rotationQuat)); HZ_CORE_INFO_TAG(\u0026#34;Mesh\u0026#34;, \u0026#34; Inverse Bind Pose = {\u0026#34;); HZ_CORE_INFO(\u0026#34; translation: ({0:8.4f}, {1:8.4f}, {2:8.4f})\u0026#34;, translation.x, translation.y, translation.z); HZ_CORE_INFO(\u0026#34; rotation: ({0:8.4f}, {1:8.4f}, {2:8.4f})\u0026#34;, rotation.x, rotation.y, rotation.z); HZ_CORE_INFO(\u0026#34; scale: ({0:8.4f}, {1:8.4f}, {2:8.4f})\u0026#34;, scale.x, scale.y, scale.z); HZ_CORE_INFO(\u0026#34; }\u0026#34;); } for (size_t j = 0; j \u0026lt; bone-\u0026gt;mNumWeights; j++) { int VertexID = submesh.BaseVertex + bone-\u0026gt;mWeights[j].mVertexId; float Weight = bone-\u0026gt;mWeights[j].mWeight; meshSource-\u0026gt;m_BoneInfluences[VertexID].AddBoneData(boneInfoIndex, Weight); } } } } for (auto\u0026amp; boneInfluence : meshSource-\u0026gt;m_BoneInfluences) { boneInfluence.NormalizeWeights(); } } 至此为MeshSource导入了骨骼信息，封装为\n1 2 std::vector\u0026lt;BoneInfluence\u0026gt; m_BoneInfluences; // 每个顶点对应的骨骼影响数据 std::vector\u0026lt;BoneInfo\u0026gt; m_BoneInfo; // 骨骼信息 动画构建 GLTF的动画结构 采样器:定义了有几个关键帧，以及每个关键帧对应的骨骼运动信息 通道：指明一根骨骼由哪个采样器控制，以及控制方式（平移、旋转、缩放） 使用游戏引擎制作动画时的关键帧是统一设置了所有骨骼的变换信息，GLTF对每个骨骼单独控制，节省了很多空间，因为可能两个关键帧之间某些骨骼并没有变换，但是统一存储还得存一遍。下面是一个开火动画的例子\n1 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 \u0026#34;animations\u0026#34; : [ { \u0026#34;name\u0026#34; : \u0026#34;Fire\u0026#34;, // 开火动画的例子 \u0026#34;samplers\u0026#34; : [ { \u0026#34;input\u0026#34; : 7, // 存储的是所有关键帧的时间点（7只是访问accessors 数组的索引） \u0026#34;interpolation\u0026#34; : \u0026#34;LINEAR\u0026#34;, // 采用线性插值 \u0026#34;output\u0026#34; : 8 // 与input里一一对应的关键帧的数据 }, { \u0026#34;input\u0026#34; : 7, \u0026#34;interpolation\u0026#34; : \u0026#34;LINEAR\u0026#34;, \u0026#34;output\u0026#34; : 9 } ]， \u0026#34;channels\u0026#34; : [ { \u0026#34;sampler\u0026#34; : 0, // 指定一个采样器 \u0026#34;target\u0026#34; : { \u0026#34;node\u0026#34; : 5, // 指定这个采样器作用哪个骨骼 \u0026#34;path\u0026#34; : \u0026#34;translation\u0026#34; // 作用的方式 } }, { \u0026#34;sampler\u0026#34; : 1, \u0026#34;target\u0026#34; : { \u0026#34;node\u0026#34; : 5, \u0026#34;path\u0026#34; : \u0026#34;rotation\u0026#34; } } ] } ] 动画数据本质就是存储每个关键帧的骨骼变换数据，GLTF这样存储是一种优化手段\nGLTF动画数据导入 定义关键帧\n1 2 3 4 5 6 template\u0026lt;typename T\u0026gt; struct KeyFrame { float FrameTime; T Value; KeyFrame(const float frameTime, const T\u0026amp; value) : FrameTime(frameTime), Value(value) {} }; 定义Channel结构,对于一组关键帧，存储一根骨骼每个关键帧的变换数据，以及骨骼的Index（捕捉了 “单根骨骼的完整动画轨迹”）\n1 2 3 4 5 6 7 struct Channel { std::vector\u0026lt;KeyFrame\u0026lt;glm::vec3\u0026gt;\u0026gt; Translations; std::vector\u0026lt;KeyFrame\u0026lt;glm::quat\u0026gt;\u0026gt; Rotations; std::vector\u0026lt;KeyFrame\u0026lt;glm::vec3\u0026gt;\u0026gt; Scales; uint32_t Index; }; 下面给出如何封装所有骨骼的Channel\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 static std::vector\u0026lt;Channel\u0026gt; ImportChannels(aiAnimation* anim, const Skeleton\u0026amp; skeleton, const bool isMaskedRootMotion, const glm::vec3\u0026amp; rootTranslationMask, float rootRotationMask) { std::vector\u0026lt;Channel\u0026gt; channels; std::unordered_map\u0026lt;std::string_view, uint32_t\u0026gt; boneIndices; std::unordered_set\u0026lt;uint32_t\u0026gt; rootBoneIndices; for (uint32_t i = 0; i \u0026lt; skeleton.GetNumBones(); ++i) { boneIndices.emplace(skeleton.GetBoneName(i), i + 1); // 0 is reserved for root motion channel boneIndices are base=1 if (skeleton.GetParentBoneIndex(i) == Skeleton::NullIndex) rootBoneIndices.emplace(i + 1); } std::map\u0026lt;uint32_t, aiNodeAnim*\u0026gt; validChannels; for (uint32_t channelIndex = 0; channelIndex \u0026lt; anim-\u0026gt;mNumChannels; ++channelIndex) { aiNodeAnim* nodeAnim = anim-\u0026gt;mChannels[channelIndex]; auto it = boneIndices.find(nodeAnim-\u0026gt;mNodeName.C_Str()); if (it != boneIndices.end()) { validChannels.emplace(it-\u0026gt;second, nodeAnim); // validChannels.first is base=1, .second is node pointer } } channels.resize(skeleton.GetNumBones() + 1); // channels is base=1 // channels don\u0026#39;t necessarily have first frame at time zero. // We can just generate a dummy key frame, but that can end up looking a bit odd (in particular, // looping animations appear to pause for a split second each time they loop and encounter the // dummy key frame. // So instead, we clip the animation time horizon. double firstFrameDelta = DBL_MAX; double animationDuration = anim-\u0026gt;mDuration; for (uint32_t boneIndex = 1; boneIndex \u0026lt; channels.size(); ++boneIndex) { if (auto validChannel = validChannels.find(boneIndex); validChannel != validChannels.end()) { auto nodeAnim = validChannel-\u0026gt;second; if (nodeAnim-\u0026gt;mNumPositionKeys \u0026gt; 0) firstFrameDelta = std::min(firstFrameDelta, nodeAnim-\u0026gt;mPositionKeys[0].mTime); if (nodeAnim-\u0026gt;mNumRotationKeys \u0026gt; 0) firstFrameDelta = std::min(firstFrameDelta, nodeAnim-\u0026gt;mRotationKeys[0].mTime); if (nodeAnim-\u0026gt;mNumScalingKeys \u0026gt; 0) firstFrameDelta = std::min(firstFrameDelta, nodeAnim-\u0026gt;mScalingKeys[0].mTime); } } anim-\u0026gt;mDuration -= firstFrameDelta; // The rest of the code assumes non-zero animation duration. // Enforce that here. if (anim-\u0026gt;mDuration \u0026lt;= 0.0) { anim-\u0026gt;mDuration = 1.0; } for (uint32_t boneIndex = 1; boneIndex \u0026lt; channels.size(); ++boneIndex) { Channel\u0026amp; channel = channels[boneIndex]; channel.Index = boneIndex; if (auto validChannel = validChannels.find(boneIndex); validChannel != validChannels.end()) { auto nodeAnim = validChannel-\u0026gt;second; channel.Translations.reserve(nodeAnim-\u0026gt;mNumPositionKeys + 2); // +2 because worst case we insert two more keys channel.Rotations.reserve(nodeAnim-\u0026gt;mNumRotationKeys + 2); channel.Scales.reserve(nodeAnim-\u0026gt;mNumScalingKeys + 2); // Note: There is no need to check for duplicate keys (i.e. multiple keys all at same frame time) // because Assimp throws these out for us for (uint32_t keyIndex = 0; keyIndex \u0026lt; nodeAnim-\u0026gt;mNumPositionKeys; ++keyIndex) { aiVectorKey key = nodeAnim-\u0026gt;mPositionKeys[keyIndex]; float frameTime = std::clamp(static_cast\u0026lt;float\u0026gt;((key.mTime - firstFrameDelta) / anim-\u0026gt;mDuration), 0.0f, 1.0f); if ((keyIndex == 0) \u0026amp;\u0026amp; (frameTime \u0026gt; 0.0f)) { channels[boneIndex].Translations.emplace_back(0.0f, glm::vec3{ static_cast\u0026lt;float\u0026gt;(key.mValue.x), static_cast\u0026lt;float\u0026gt;(key.mValue.y), static_cast\u0026lt;float\u0026gt;(key.mValue.z) }); } channel.Translations.emplace_back(frameTime, glm::vec3{ static_cast\u0026lt;float\u0026gt;(key.mValue.x), static_cast\u0026lt;float\u0026gt;(key.mValue.y), static_cast\u0026lt;float\u0026gt;(key.mValue.z) }); } if (channel.Translations.empty()) { HZ_CORE_WARN_TAG(\u0026#34;Animation\u0026#34;, \u0026#34;No translation track found for bone \u0026#39;{}\u0026#39;\u0026#34;, skeleton.GetBoneName(boneIndex - 1)); channel.Translations = { {0.0f, glm::vec3{0.0f}}, {1.0f, glm::vec3{0.0f}} }; } else if (channel.Translations.back().FrameTime \u0026lt; 1.0f) { channel.Translations.emplace_back(1.0f, channel.Translations.back().Value); } for (uint32_t keyIndex = 0; keyIndex \u0026lt; nodeAnim-\u0026gt;mNumRotationKeys; ++keyIndex) { aiQuatKey key = nodeAnim-\u0026gt;mRotationKeys[keyIndex]; float frameTime = std::clamp(static_cast\u0026lt;float\u0026gt;((key.mTime - firstFrameDelta) / anim-\u0026gt;mDuration), 0.0f, 1.0f); // WARNING: constructor parameter order for a quat is still WXYZ even if you have defined GLM_FORCE_QUAT_DATA_XYZW if ((keyIndex == 0) \u0026amp;\u0026amp; (frameTime \u0026gt; 0.0f)) { channel.Rotations.emplace_back(0.0f, glm::quat{ static_cast\u0026lt;float\u0026gt;(key.mValue.w), static_cast\u0026lt;float\u0026gt;(key.mValue.x), static_cast\u0026lt;float\u0026gt;(key.mValue.y), static_cast\u0026lt;float\u0026gt;(key.mValue.z) }); } channel.Rotations.emplace_back(frameTime, glm::quat{ static_cast\u0026lt;float\u0026gt;(key.mValue.w), static_cast\u0026lt;float\u0026gt;(key.mValue.x), static_cast\u0026lt;float\u0026gt;(key.mValue.y), static_cast\u0026lt;float\u0026gt;(key.mValue.z) }); HZ_CORE_ASSERT(fabs(glm::length(channels[boneIndex].Rotations.back().Value) - 1.0f) \u0026lt; 0.00001f); // check rotations are normalized (I think assimp ensures this, but not 100% sure) } if (channel.Rotations.empty()) { HZ_CORE_WARN_TAG(\u0026#34;Animation\u0026#34;, \u0026#34;No rotation track found for bone \u0026#39;{}\u0026#39;\u0026#34;, skeleton.GetBoneName(boneIndex - 1)); channel.Rotations = { {0.0f, glm::quat{1.0f, 0.0f, 0.0f, 0.0f}}, {1.0f, glm::quat{1.0f, 0.0f, 0.0f, 0.0f}} }; } else if (channel.Rotations.back().FrameTime \u0026lt; 1.0f) { channel.Rotations.emplace_back(1.0f, channel.Rotations.back().Value); } for (uint32_t keyIndex = 0; keyIndex \u0026lt; nodeAnim-\u0026gt;mNumScalingKeys; ++keyIndex) { aiVectorKey key = nodeAnim-\u0026gt;mScalingKeys[keyIndex]; float frameTime = std::clamp(static_cast\u0026lt;float\u0026gt;((key.mTime - firstFrameDelta) / anim-\u0026gt;mDuration), 0.0f, 1.0f); if (keyIndex == 0 \u0026amp;\u0026amp; frameTime \u0026gt; 0.0f) { channel.Scales.emplace_back(0.0f, glm::vec3{ static_cast\u0026lt;float\u0026gt;(key.mValue.x), static_cast\u0026lt;float\u0026gt;(key.mValue.y), static_cast\u0026lt;float\u0026gt;(key.mValue.z) }); } channel.Scales.emplace_back(frameTime, glm::vec3{ static_cast\u0026lt;float\u0026gt;(key.mValue.x), static_cast\u0026lt;float\u0026gt;(key.mValue.y), static_cast\u0026lt;float\u0026gt;(key.mValue.z) }); } if (channel.Scales.empty()) { HZ_CORE_WARN_TAG(\u0026#34;Animation\u0026#34;, \u0026#34;No scale track found for bone \u0026#39;{}\u0026#39;\u0026#34;, skeleton.GetBoneName(boneIndex - 1)); channel.Scales = { {0.0f, glm::vec3{1.0f}}, {1.0f, glm::vec3{1.0f}} }; } else if (channel.Scales.back().FrameTime \u0026lt; 1.0f) { channel.Scales.emplace_back(1.0f, channels[boneIndex].Scales.back().Value); } } else { HZ_CORE_WARN_TAG(\u0026#34;Animation\u0026#34;, \u0026#34;No animation tracks found for bone \u0026#39;{}\u0026#39;\u0026#34;, skeleton.GetBoneName(boneIndex - 1)); auto translation = skeleton.GetBoneTranslations().at(boneIndex - 1); auto rotation = skeleton.GetBoneRotations().at(boneIndex - 1); auto scale = skeleton.GetBoneScales().at(boneIndex - 1); channel.Translations = { {0.0f, translation}, {1.0f, translation} }; channel.Rotations = { {0.0f, rotation}, {1.0f, rotation} }; channel.Scales = { {0.0f, scale}, {1.0f, scale} }; } } // Create root motion channel. // If isMaskedRootMotion is true, then root motion channel is created by filtering components of the first channel. // Otherwise root motion channel is copied as-is from the first channel. // // Root motion is then removed from all \u0026#34;root\u0026#34; channels (so it doesn\u0026#39;t get applied twice) HZ_CORE_ASSERT(!rootBoneIndices.empty()); // Can\u0026#39;t see how this would ever be false! HZ_CORE_ASSERT(rootBoneIndices.find(1) != rootBoneIndices.end()); // First bone must be a root! Channel\u0026amp; root = channels[0]; root.Index = 0; if (isMaskedRootMotion) { for (auto\u0026amp; translation : channels[1].Translations) { root.Translations.emplace_back(translation.FrameTime, translation.Value * rootTranslationMask); translation.Value *= (glm::vec3(1.0f) - rootTranslationMask); translation.Value += root.Translations.front().Value; } for (auto\u0026amp; rotation : channels[1].Rotations) { if (rootRotationMask \u0026gt; 0.0f) { auto angleY = Utils::AngleAroundYAxis(rotation.Value); root.Rotations.emplace_back(rotation.FrameTime, glm::quat{ glm::cos(angleY * 0.5f), glm::vec3{0.0f, 1.0f, 0.0f} *glm::sin(angleY * 0.5f) }); rotation.Value = glm::conjugate(glm::quat(glm::cos(angleY * 0.5f), glm::vec3{ 0.0f, 1.0f, 0.0f } *glm::sin(angleY * 0.5f))) * rotation.Value; rotation.Value *= root.Rotations.front().Value; } else { root.Rotations.emplace_back(rotation.FrameTime, glm::quat{ 1.0f, 0.0f, 0.0f, 0.0f }); } } } else { root.Translations = channels[1].Translations; root.Rotations = channels[1].Rotations; channels[1].Translations = { {0.0f, root.Translations.front().Value}, {1.0f, root.Translations.front().Value} }; channels[1].Rotations = { {0.0f, root.Rotations.front().Value}, {1.0f, root.Rotations.front().Value} }; } root.Scales = { {0.0f, glm::vec3{1.0f}}, {1.0f, glm::vec3{1.0f}} }; // It is possible that there is more than one \u0026#34;root\u0026#34; bone in the asset. // We need to remove the root motion from all of them (otherwise those bones will move twice as fast when root motion is applied) for (const auto rootBoneIndex : rootBoneIndices) { // we already removed root motion from the first bone, above if (rootBoneIndex != 1) { for (auto\u0026amp; translation : channels[rootBoneIndex].Translations) { // sample root channel at this translation\u0026#39;s frametime for (size_t rootFrame = 0; rootFrame \u0026lt; root.Translations.size() - 1; ++rootFrame) { if (root.Translations[rootFrame + 1].FrameTime \u0026gt;= translation.FrameTime) { const float alpha = (translation.FrameTime - root.Translations[rootFrame].FrameTime) / (root.Translations[rootFrame + 1].FrameTime - root.Translations[rootFrame].FrameTime); translation.Value -= glm::mix(root.Translations[rootFrame].Value, root.Translations[rootFrame + 1].Value, alpha); translation.Value += root.Translations.front().Value; break; } } } for (auto\u0026amp; rotation : channels[rootBoneIndex].Rotations) { // sample root channel at the this rotation\u0026#39;s frametime for (size_t rootFrame = 0; rootFrame \u0026lt; root.Rotations.size() - 1; ++rootFrame) { if (root.Rotations[rootFrame + 1].FrameTime \u0026gt;= rotation.FrameTime) { const float alpha = (rotation.FrameTime - root.Rotations[rootFrame].FrameTime) / (root.Rotations[rootFrame + 1].FrameTime - root.Rotations[rootFrame].FrameTime); rotation.Value = glm::normalize(glm::conjugate(glm::slerp(root.Rotations[rootFrame].Value, root.Rotations[rootFrame + 1].Value, alpha)) * rotation.Value); rotation.Value *= root.Rotations.front().Value; break; } } } } } return channels; } 压缩算法（如 ACL）要求所有骨骼的关键帧数量和时间戳严格对齐，否则无法批量处理。但是GLTF导入的骨骼关键帧是不同的采样器得到的，不一定一致，所以需要统一化\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 void SanitizeChannels(std::vector\u0026lt;Channel\u0026gt;\u0026amp; channels) { uint32_t maxNumFrames = 2; // The rest of the code requires each channel to have at least 2 frames for (const auto\u0026amp; channel : channels) { maxNumFrames = std::max(maxNumFrames, static_cast\u0026lt;uint32_t\u0026gt;(channel.Translations.size())); maxNumFrames = std::max(maxNumFrames, static_cast\u0026lt;uint32_t\u0026gt;(channel.Rotations.size())); maxNumFrames = std::max(maxNumFrames, static_cast\u0026lt;uint32_t\u0026gt;(channel.Scales.size())); } float frameInterval = 1.0f / (maxNumFrames - 1); // loop over all channels and change them so they all have maxNumFrames frames. // add new frames where necessary by interpolating between existing frames. for (auto\u0026amp; channel : channels) { Channel newChannel; uint32_t translationIndex = 1; newChannel.Translations.reserve(maxNumFrames); newChannel.Translations.emplace_back(channel.Translations[0]); for (uint32_t i = 1; i \u0026lt; maxNumFrames - 1; ++i) { float frameTime = i * frameInterval; while ((translationIndex \u0026lt; channel.Translations.size()) \u0026amp;\u0026amp; (channel.Translations[translationIndex].FrameTime \u0026lt; frameTime)) { ++translationIndex; } const float t = (frameTime - channel.Translations[translationIndex - 1].FrameTime) / (channel.Translations[translationIndex].FrameTime - channel.Translations[translationIndex - 1].FrameTime); newChannel.Translations.emplace_back(frameTime, glm::mix(channel.Translations[translationIndex - 1].Value, channel.Translations[translationIndex].Value, t)); } newChannel.Translations.emplace_back(channel.Translations.back()); uint32_t rotationIndex = 1; newChannel.Rotations.reserve(maxNumFrames); newChannel.Rotations.emplace_back(channel.Rotations[0]); for (uint32_t i = 1; i \u0026lt; maxNumFrames - 1; ++i) { float frameTime = i * frameInterval; while ((rotationIndex \u0026lt; channel.Rotations.size()) \u0026amp;\u0026amp; (channel.Rotations[rotationIndex].FrameTime \u0026lt; frameTime)) { ++rotationIndex; } const float t = (frameTime - channel.Rotations[rotationIndex - 1].FrameTime) / (channel.Rotations[rotationIndex].FrameTime - channel.Rotations[rotationIndex - 1].FrameTime); newChannel.Rotations.emplace_back(frameTime, glm::slerp(channel.Rotations[rotationIndex - 1].Value, channel.Rotations[rotationIndex].Value, t)); } newChannel.Rotations.emplace_back(channel.Rotations.back()); uint32_t scaleIndex = 1; newChannel.Scales.reserve(maxNumFrames); newChannel.Scales.emplace_back(channel.Scales[0]); for (uint32_t i = 1; i \u0026lt; maxNumFrames - 1; ++i) { float frameTime = i * frameInterval; while ((scaleIndex \u0026lt; channel.Scales.size()) \u0026amp;\u0026amp; (channel.Scales[scaleIndex].FrameTime \u0026lt; frameTime)) { ++scaleIndex; } const float t = (frameTime - channel.Scales[scaleIndex - 1].FrameTime) / (channel.Scales[scaleIndex].FrameTime - channel.Scales[scaleIndex - 1].FrameTime); newChannel.Scales.emplace_back(frameTime, glm::mix(channel.Scales[scaleIndex - 1].Value, channel.Scales[scaleIndex].Value, t)); } newChannel.Scales.emplace_back(channel.Scales.back()); channel.Translations = std::move(newChannel.Translations); channel.Rotations = std::move(newChannel.Rotations); channel.Scales = std::move(newChannel.Scales); } } 进一步使用acl压缩动画数据\n1 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 acl::error_result CompressChannels(const std::vector\u0026lt;Channel\u0026gt;\u0026amp; channels, const float fps, const Skeleton\u0026amp; skeleton, acl::compressed_tracks*\u0026amp; outCompressedTracks) { acl::iallocator\u0026amp; allocator = Utils::GetAnimationAllocator(); uint32_t numTracks = static_cast\u0026lt;uint32_t\u0026gt;(channels.size()); uint32_t numSamples = static_cast\u0026lt;uint32_t\u0026gt;(channels[0].Translations.size()); acl::track_array_qvvf rawTrackList(allocator, numTracks); for (uint32_t i = 0; i \u0026lt; numTracks; ++i) { acl::track_desc_transformf desc; desc.output_index = i; desc.parent_index = (i == 0) ? acl::k_invalid_track_index : skeleton.GetParentBoneIndex(i - 1) + 1; // 0 is root motion channel, 1..numBones are in the skeleton desc.precision = 0.0001f; desc.shell_distance = 3.0f; acl::track_qvvf rawTrack = acl::track_qvvf::make_reserve(desc, allocator, numSamples, fps); for (uint32_t j = 0; j \u0026lt; numSamples; ++j) { const auto\u0026amp; translation = channels[i].Translations[j].Value; const auto\u0026amp; rotation = channels[i].Rotations[j].Value; const auto\u0026amp; scale = channels[i].Scales[j].Value; rawTrack[j].rotation = rtm::quat_set(rotation.x, rotation.y, rotation.z, rotation.w); rawTrack[j].translation = rtm::vector_set(translation.x, translation.y, translation.z); rawTrack[j].scale = rtm::vector_set(scale.x, scale.y, scale.z); } rawTrackList[i] = std::move(rawTrack); } acl::pre_process_settings_t preProcessSettings; preProcessSettings.actions = acl::pre_process_actions::recommended; preProcessSettings.precision_policy = acl::pre_process_precision_policy::lossy; acl::qvvf_transform_error_metric error_metric; preProcessSettings.error_metric = \u0026amp;error_metric; acl::error_result result = acl::pre_process_track_list(allocator, preProcessSettings, rawTrackList); if (result.any()) { return result; } acl::compression_settings compressSettings = acl::get_default_compression_settings(); compressSettings.error_metric = \u0026amp;error_metric; acl::output_stats stats{ acl::stat_logging::none }; result = acl::compress_track_list(allocator, rawTrackList, compressSettings, outCompressedTracks, stats); return result; } ACL动画压缩 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 acl::error_result CompressChannels(const std::vector\u0026lt;Channel\u0026gt;\u0026amp; channels, const float fps, const Skeleton\u0026amp; skeleton, acl::compressed_tracks*\u0026amp; outCompressedTracks) { acl::iallocator\u0026amp; allocator = Utils::GetAnimationAllocator(); uint32_t numTracks = static_cast\u0026lt;uint32_t\u0026gt;(channels.size()); uint32_t numSamples = static_cast\u0026lt;uint32_t\u0026gt;(channels[0].Translations.size()); acl::track_array_qvvf rawTrackList(allocator, numTracks); for (uint32_t i = 0; i \u0026lt; numTracks; ++i) { acl::track_desc_transformf desc; desc.output_index = i; desc.parent_index = (i == 0) ? acl::k_invalid_track_index : skeleton.GetParentBoneIndex(i - 1) + 1; // 0 is root motion channel, 1..numBones are in the skeleton desc.precision = 0.0001f; desc.shell_distance = 3.0f; acl::track_qvvf rawTrack = acl::track_qvvf::make_reserve(desc, allocator, numSamples, fps); for (uint32_t j = 0; j \u0026lt; numSamples; ++j) { const auto\u0026amp; translation = channels[i].Translations[j].Value; const auto\u0026amp; rotation = channels[i].Rotations[j].Value; const auto\u0026amp; scale = channels[i].Scales[j].Value; rawTrack[j].rotation = rtm::quat_set(rotation.x, rotation.y, rotation.z, rotation.w); rawTrack[j].translation = rtm::vector_set(translation.x, translation.y, translation.z); rawTrack[j].scale = rtm::vector_set(scale.x, scale.y, scale.z); } rawTrackList[i] = std::move(rawTrack); } acl::pre_process_settings_t preProcessSettings; preProcessSettings.actions = acl::pre_process_actions::recommended; preProcessSettings.precision_policy = acl::pre_process_precision_policy::lossy; acl::qvvf_transform_error_metric error_metric; preProcessSettings.error_metric = \u0026amp;error_metric; acl::error_result result = acl::pre_process_track_list(allocator, preProcessSettings, rawTrackList); if (result.any()) { return result; } acl::compression_settings compressSettings = acl::get_default_compression_settings(); compressSettings.error_metric = \u0026amp;error_metric; acl::output_stats stats{ acl::stat_logging::none }; result = acl::compress_track_list(allocator, rawTrackList, compressSettings, outCompressedTracks, stats); return result; } 到这里模型的骨骼信息和动画信息都封装好了\n渲染动画 动画相关组件\n1 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 struct DynamicMeshComponent { AssetHandle MeshSource = 0; }; struct SubmeshComponent { AssetHandle Mesh; std::vector\u0026lt;UUID\u0026gt; BoneEntityIds; uint32_t SubmeshIndex = 0; bool Visible = true; SubmeshComponent() = default; SubmeshComponent(const SubmeshComponent\u0026amp; other) : Mesh(other.Mesh), BoneEntityIds(other.BoneEntityIds), SubmeshIndex(other.SubmeshIndex), Visible(other.Visible) { } SubmeshComponent(AssetHandle mesh, uint32_t submeshIndex = 0) : Mesh(mesh), SubmeshIndex(submeshIndex) { } }; struct AnimationComponent { AssetHandle Mesh; const Animation* CurrentAnimation; float CurrentTime = 0.0f; bool IsLooping = true; Pose CurrentPose; std::vector\u0026lt;UUID\u0026gt; BoneEntityIds; AnimationComponent() = default; AnimationComponent(AssetHandle mesh):Mesh(mesh) { } }; 收集动画Mesh\n定义骨骼UBO，同实例化渲染的设计，通过MeshKey来区分不一样的DrawCall，所有Submesh只要材质一样就会被实例化调用，另外用StorageBufferSet平铺骨骼变换矩阵\n1 2 3 4 5 6 7 8 using BoneTransforms = std::array\u0026lt;glm::mat4, 100\u0026gt;; // Note: 100 == MAX_BONES from the shaders struct BoneTransformsMapData { std::vector\u0026lt;BoneTransforms\u0026gt; BoneTransformsData; uint32_t BoneTransformsBaseIndex = 0; }; std::map\u0026lt;MeshKey, BoneTransformsMapData\u0026gt; m_MeshBoneTransformsMap; Ref\u0026lt;StorageBufferSet\u0026gt; m_SBSBoneTransforms; Shader中，通过a_BoneIndices传递该顶点绑定的骨骼，a_BoneWeights传递权重。这些信息在Mesh导入时已经被封装。通过顶点缓冲区导入\n1 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 57 58 59 60 61 62 63 64 #version 450 core #ifdef VERTEX_SHADER layout(binding = 0) uniform UniformBufferObject { mat4 view; mat4 proj; float width; float height; float Near; float Far; } ubo; const int MAX_BONES = 100; const int MAX_ANIMATED_MESHES = 1024; layout(location = 0) in vec3 inPosition; layout(location = 1) in vec3 inNormal; layout(location = 2) in vec3 inTangent; layout(location = 3) in vec3 inBinormal; layout(location = 4) in vec2 inTexCoord; layout(location = 5) in vec4 a_MRow0; layout(location = 6) in vec4 a_MRow1; layout(location = 7) in vec4 a_MRow2; // Bone influences layout(location = 8) in ivec4 a_BoneIndices; layout(location = 9) in vec4 a_BoneWeights; layout (std140, set = 0, binding = 1) readonly buffer BoneTransforms { mat4 BoneTransforms[MAX_BONES * MAX_ANIMATED_MESHES]; } r_BoneTransforms; layout(push_constant) uniform BoneTransformIndex { uint Base; } u_BoneTransformIndex; layout(location = 0) out vec3 fragWorldPos; layout(location = 1) out vec3 fragWorldNormal; layout(location = 2) out vec3 fragWorldTangent; layout(location = 3) out vec3 fragWorldBinormal; layout(location = 4) out vec2 fragTexCoord; void main() { mat4 transform = mat4( vec4(a_MRow0.x, a_MRow1.x, a_MRow2.x, 0.0), vec4(a_MRow0.y, a_MRow1.y, a_MRow2.y, 0.0), vec4(a_MRow0.z, a_MRow1.z, a_MRow2.z, 0.0), vec4(a_MRow0.w, a_MRow1.w, a_MRow2.w, 1.0) ); mat4 boneTransform = r_BoneTransforms.BoneTransforms[(u_BoneTransformIndex.Base + gl_InstanceIndex) * MAX_BONES + a_BoneIndices[0]] * a_BoneWeights[0]; boneTransform += r_BoneTransforms.BoneTransforms[(u_BoneTransformIndex.Base + gl_InstanceIndex) * MAX_BONES + a_BoneIndices[1]] * a_BoneWeights[1]; boneTransform += r_BoneTransforms.BoneTransforms[(u_BoneTransformIndex.Base + gl_InstanceIndex) * MAX_BONES + a_BoneIndices[2]] * a_BoneWeights[2]; boneTransform += r_BoneTransforms.BoneTransforms[(u_BoneTransformIndex.Base + gl_InstanceIndex) * MAX_BONES + a_BoneIndices[3]] * a_BoneWeights[3]; vec4 worldPos = transform * boneTransform * vec4(inPosition, 1.0); fragWorldPos = worldPos.xyz; mat3 modelRot = mat3(transform); mat3 boneRot = mat3(boneTransform); fragWorldNormal = normalize(modelRot * boneRot * inNormal); fragWorldTangent = normalize(modelRot * boneRot * inTangent); fragWorldBinormal = normalize(modelRot * boneRot * inBinormal); fragTexCoord = inTexCoord; gl_Position = ubo.proj * ubo.view * worldPos; } #endif ","date":"2025-10-20T20:17:44+08:00","permalink":"https://sdpyy1.github.io/p/%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5%E5%8A%A8%E7%94%BB%E7%AF%87/","title":"游戏引擎开发实践（动画篇）"},{"content":"蒙皮动画 这里讲的就是科普了一下\n坐标系\n实际存储的数据是关节数据\nRoot关节一般存在脚底，为了方便计算移速、跳跃高度。髋关节是第一个子关节\nT-pose和A-pose：\nA-pose 下，肩关节旋转角度为 0°，肘部微弯 15°，手掌自然朝向大腿关节，旋转轴处于中性位置，绑定师可以更精确地绘制顶点权重，控制手臂摆动和肌肉变形，提高工作效率和质量。T-pose 中，肩部会受到挤压，当手臂放下时，可能会出现肌肉穿模等问题。A-pose 则能避免这种情况，其腋下、胯部等容易穿帮的区域自然展开，为后续的动画制作提供了更好的基础，减少了调整和修正的工作量。\n3D空间的旋转 这块东西在Games105数学原理博客部分已总结\n欧拉角 分别沿着三个轴旋转，旋转矩阵可以合并成一个\n缺点：\n顺序依赖 万向锁，直接用公式也能解释，把y轴旋转带入后，整个旋转公式只剩下x轴旋转部分了，z轴的都没了 欧拉角的插值有问题\n四元数 在二维平面上可以用复数代表旋转。另外旋转的叠加可以直接通过Product运算算出\n拓展到3D旋转\n欧拉角转四元数 用四元数旋转一个顶点 总结后：直接表达成旋转矩阵如下\n用四元数表达从U旋转到V 可以通过下面的公式计算出对应的旋转四元数是多少\n四元数表达给定轴的旋转 蒙皮动画 骨骼信息 旋转，大部分都是以旋转为主\n平移\n平移还是有用的，比如站立和蹲下是时Root和髋关节就发生了平移\n放缩\n所以Joint Pose就是，其中旋转矩阵可以通过四元数转化而来（注意这个变换不需要透视除法）\n另外骨骼信息存储的都在局部坐标系，也就是说骨骼信息存储的实际是它相对于父骨骼的变换信息，而不是相对于整个模型。\n如果使用局部坐标系：\n只需更新父骨骼的变换，就能自动影响子骨骼。 方便做层级动画，比如抬手动作只需要改手臂局部变换，身体会自动跟随。 如果存储在模型坐标系：\n每个骨骼的世界变换必须单独存储或重复计算。 当父骨骼旋转时，需要手动更新所有子骨骼的世界位置，计算复杂且容易出错。 动画混合（比如走路 + 挥手）通常对局部骨骼变换进行插值或加权，而不是对世界位置插值：\n局部空间插值保持层次一致性，不会出现骨骼分离或错位。 世界空间插值容易导致骨骼错位或姿势破坏，因为子骨骼的位置依赖父骨骼。 蒙皮矩阵 现在已经理解了骨骼信息如何存储，下面介绍骨骼信息如何作用到顶点上\n下图说明即使骨骼移动，顶点与骨骼的相对位置并不会变化\nBind Pose：默认姿态用于绑定骨骼和顶点\n下面三个参数分别是某个顶点：\n绑定姿态下的模型空间位置 绑定姿态下的局部空间位置 绑定姿态下的骨骼在模型空间的位置 顶点相对于骨骼的位置在任何时候都不会变，并且等于骨骼在模型空间位置的逆矩阵 x 顶点在模型空间中的位置\n顶点的位置 = BindPose下顶点位置先转到骨骼的局部坐标系再跟着骨骼运动后的位置\n在存储骨骼信息时，需要存储Inverse BindPose Matrix，它是把变换矩阵一路从父节点传递下来，得到当前骨骼的模型坐标系下的变换信息，然后求逆。\nInverse BindPose Matrix的作用是把一个在模型坐标系下的顶点坐标（bindpose下的）转移到这根骨骼上，所以不管任何时候，转移后得到的结果都应该是一样的\n把顶点信息（bindpose下）转移到骨骼后，再乘以现在的骨骼变换矩阵，就得到了顶点的现在的位置（实际位置），这样也就得到了一帧动画下这个顶点的位置\n蒙皮变换 总结下：在骨骼蒙皮动画中，每个顶点在 Bind Pose（绑定姿态） 下的模型空间位置，通过 Skinning Matrix（即 $M_i \\cdot B_i^{-1}$ 的加权和）进行变换后，就得到了该顶点在当前动画帧下的最终渲染位置。 $$ v_\\text{final} = \\sum_i w_i \\, (M_i \\cdot B_i^{-1}) \\, v_\\text{bind} $$ $v_\\text{bind}$ —— 顶点在绑定姿态下的模型空间坐标 $B_i^{-1}$ —— 骨骼绑定姿态的逆矩阵（Inverse Bind Pose） $M_i$ —— 当前帧骨骼的变换矩阵 $w_i$ —— 顶点对骨骼的权重 另外再乘上Model变换矩阵，就转移到世界坐标系，所以传递给GPU的变换信息应该是下面这个整体的变换公式\n多个骨骼影响的加权平均应该放在模型坐标系\n动画的插值 Games105（2）已经详细记录\n四元数的NLearp插值的角速度不同\n解决上述问题的办法\n动画的Runtime Pipeline 计算运行时间 计算插值 计算骨骼信息 计算蒙皮矩阵 更新顶点信息 动画压缩 5S的动画数据要求2GB存储空间\n骨骼信息大量变化的都是旋转\n","date":"2025-10-20T15:00:44+08:00","image":"https://sdpyy1.github.io/%E6%88%AA%E5%B1%8F2025-10-20-15.21.05.png","permalink":"https://sdpyy1.github.io/p/games104%E6%B8%B8%E6%88%8F%E5%BC%95%E6%93%8E%E7%9A%84%E5%8A%A8%E7%94%BB%E6%8A%80%E6%9C%AF%E5%9F%BA%E7%A1%80/","title":"Games104：游戏引擎的动画技术基础"},{"content":"概括 理解了渲染方程设计理由 分清楚了表面反射（镜面反射）和局部次表面反射（漫反射） 知道了常用的BRDF，以及漫反射BRDF项还有很多更好的模型 光的物理学 光的本质 在物理光学中，光被视为一种电磁横波，即由电场和磁场的振荡组成； 电场与磁场： 相互垂直； 都垂直于光的传播方向； 振幅比值恒定； 这种波的传播速度称为相位速度（phase velocity），在真空中就是光速 c ≈ 3×10⁵ km/s。 光的波长决定颜色： 可见光波长范围约为 400–700 nm； 单色光（monochromatic light）：只有一个波长（如理想化的正弦波）； 多色光（polychromatic light）：包含多个波长，是现实中的常见情况； 例如蜘蛛丝约为 1 µm，约等于 2–3 个波长大小。 光的能量与电场强度 光波携带能量； 能量流强度（irradiance, $E$）与电场振幅的平方成正比； 渲染中关注的是时间平均能量流，即平均 irradiance； 因为电场比磁场对物质的影响更大，渲染只关注电场部分。 波的叠加与干涉 光波可以线性叠加； 不同相位的叠加导致不同结果： 相长干涉（constructive interference）： 相位一致 → 叠加强； irradiance 增为 $n^2$ 倍； 相消干涉（destructive interference）： 相位相反 → 互相抵消； irradiance 变为 0； 不相干叠加（incoherent addition）： 相位随机； irradiance 线性叠加为 $nE_1$，即“1+1=2”。 这些现象并不违反能量守恒，因为： 在空间不同位置上会交替出现相长与相消； 总能量守恒。 n个具有相同频率、相同偏振和相同振幅的单色光，它们以三种不同的方式进行叠加。从左到右分别是：相长干涉，相消干涉和不相干叠加。每种情况的下面都展示了对应组合波的振幅和irradiance\n光与物质的相互作用 当物体中的电荷振荡时会发出电磁波（即发光）； 能量形式如热能、电能被转化为光能； 渲染中将其视为光源； 当光照射到物质上： 电场驱动物质内电荷振荡； 振荡电荷再次发出光 → 散射（scattering）； 散射出的光与原光频率相同（除荧光、磷光等少数情况）； 一个分子会向各方向散射，但多数光保持接近原传播方向； 散射效率依赖于波长； 实际中光与分子群体相互作用，可能产生相干干涉效应； 这些多分子散射的情况是之后章节的重点。 粒子 这里讲的是瑞丽散射和米氏散射，不同的散射情况与粒子大小与光的波长的比较有关，我在OpenGL渲染器的大气渲染原理部分已经记录过了\n介质 光在均匀介质（homogeneous medium）中进行传播是另一种重要的情况，均匀介质是指一个充满均匀分布的相同分子的空间。这里所说的均匀分布，并不是指分子间距要像晶体一样完全规则，如果液体和非晶体是纯净物（所有的分子都是相同的），并且没有间隙或者气泡的话，也可以认为它们在光学上是均匀的。\n在均匀介质中，光的传播核心特点是散射波的相消干涉：\n分子散射产生的次级波会整齐排列，除原始传播方向外，所有其他方向的散射波会因相位相反相互抵消（相消干涉）；\n原始波与所有单分子散射波叠加后，最终波形与原始波基本一致，仅相位速度和（部分情况下的）振幅发生改变；\n相消干涉完全抑制了散射现象，因此均匀介质不会表现出明显的散射效果。\n四个装有不同吸收性质液体的小容器。从左到右分别是：纯净水，石榴汁，茶，咖啡\n非均匀介质可简化为 “嵌有散射粒子的均匀介质”，其光学行为的核心是 “相消干涉的破坏”：\n从左到右分别是：水，加了几滴牛奶的水，加了大约10%牛奶的水，全脂牛奶，乳白色玻璃。大多数牛奶的散射粒子都要比可见光的波长大，因此它的散射主要是无色的，在中间的图像上有明显的淡蓝色。在最右侧图像中，乳白色玻璃中的散射粒子要比可见光的波长小，因此蓝光的散射强度要比红光强；同时由于明暗背景的分割，透射光在左边更加明显，而散射光则在右边更加明显。\n介质对光的影响本质是散射与吸收的组合效应：均匀介质通过相消干涉抑制散射，其光学性质由复折射率（n+iκ）描述；非均匀介质因结构无序破坏相消干涉，散射成为主要特征，且散射 / 吸收的效果均与尺度、粒子尺寸、波长密切相关。这一规律是理解自然现象（如天空蓝、水的蓝色、牛奶的白色）和渲染技术中介质模拟的基础。\n散射和吸收现象都与尺度有关。在小场景中不产生任何明显散射的介质，在较大的尺度上也可能会有相当明显的散射现象。例如：当在房间内观察一杯水时，光在空气中的散射与在水中的吸收是几乎不可见的；然而在一个较大尺度的环境中，这两种效果可能都会十分显著，如图9.7所示。\n左图中：在超过几米的范围内，水会对光产生强烈的吸收作用，尤其是红光，因此整体看起来会很蓝。右图中：光在没有严重空气污染或者雾的情况下，也会在数英里尺度的空气中发生散射。\n具有不同吸收作用和散射作用组合的液体容器。\n表面 从光学角度来看，物体的表面（surface）是一个二维界面，它分隔了具有不同折射率的空间体积。在一般的渲染情况下，由空气组成的外部空间，其折射率约为1.003，为简单起见，通常假设空气的折射率为1；而内部空间的折射率则取决于构成物体的物质。\n一个光波撞击一个平面，平面两侧物质的折射率分别为n_1 和n_2。左侧图展示了入射波的侧视图，这个入射波从左上角进入，红色的强度代表了波的相位。表面下方的波间距与(n_1/n_2)成正比，本例中为0.5。相位沿着表面排列，因此间距的变化会弯曲（折射）透射波的方向。图中三角形的构造展示了Snell定律的推导过程。为了清晰起见，右上方的图显示了反射波的情况，它与入射波具有相同的波间距，因此其方向与表面法线具有相同的夹角。右下方展示了入射波、透射波和反射波方向的矢量图。\n最终结果如图9.9所示。反射波和入射波的方向，与表面法线之间具有相同的夹角$\\theta_i$；透射波的方向会以$\\theta_t$的角度进行弯曲（折射），它与$\\theta_{i}$的关系如下：\n$$ \\sin \\left(\\theta_{t}\\right)=\\frac{n_{1}}{n_{2}} \\sin \\left(\\theta_{i}\\right). \\tag{9.1} $$ 上边主要描述了在光在表面发生的反射与折射，下面描述的是微表面模型\n左侧展示了两个表面的照片，右侧使用示意图的形式，展示了它们所对应的微观几何结构。上方的表面拥有略微粗糙的微观几何形状，入射光线击中了表面上不同的点，每个点的法线方向略有差异，并在一个狭窄的锥形方向上被反射，其宏观效果是具有轻微模糊的反射。下方表面具有更加粗糙的微观几何形状，入射光线照射到的表面点，具有明显不同的法线方向，反射光线会以一个较宽的锥体进行扩散，从而导致宏观的反射效果变得更加模糊。\n在渲染中，我们并不会对微观几何形状进行明确的建模，而是会以一种统计的方式对其进行处理，将该表面视为一个具有微观结构法线的随机分布。因此，我们将表面建模为，会在一个连续方向上（译者注：一定的立体角范围内），对光线进行反射和折射。其中这个连续方向的宽度（锥形范围的大小，或者是立体角的大小），以及反射细节和折射细节的模糊程度，取决于微观几何法线的统计方差，即表面微尺度的粗糙度（roughness），如图9.13所示。\n次表面散射 进入物体内部的折射光线，会继续与内部物质发生相互作用。前面我们提到，金属具有较高的吸收率和较高的反射率，即金属表面会反射大部分的入射光线，进入金属内部的折射光线也会被迅速吸收；相比之下，非金属物体则会表现出广泛的散射行为和吸收行为\n这种次表面散射（subsurface scatter）的光线，会以相对于入射点的不同距离从表面射出，这个进出距离（entry-exit distances）的分布取决于材料中散射粒子的密度和性质，这些距离与着色尺度（像素的大小，以及着色样本之间的距离）之间的关系是很重要的。如果这个进出距离比着色尺度要小，那么可以假定它们为零，这样我们就可以将次表面散射与表面反射整合到同一个局部着色模型中，即某个着色点上的出射光线，只依赖于该点的入射光线。由于次表面散射与表面反射具有明显不同的外观表现，因此可以很容易地将它们划分为单独的着色选项，使用镜面项（specular term）控制表面反射现象，使用漫反射项（diffuse term）控制局部次表面散射（local subsurface scattering）现象。\n就是说镜面项是表面反射结果，漫反射项是次表面反射的结果（前提是光进入表面的距离很小）。光线进出的距离很长（＞着色尺度）—— 比如牛奶、蜡烛、玉石、厚玻璃这些材料，光在内部跑了毫米级甚至厘米级距离才出来（比如用手电筒照牛奶杯，对面会透出光，还会有颜色渐变）；这种 “远距离的次表面散射”，就不能再用简单的漫反射项模拟了，需要专门的次表面散射模型（比如 SSS 材质、BSSRDF 模型），因为它的出射光不仅和入射点有关，还和周围区域的入射光有关（比如玉石的 “通透感”“温润感”，就是这种长距离 SSS 的效果）。\n值得注意的是，局部次表面散射技术和全局次表面散射技术模拟的是完全相同的物理现象。每种情况下的最佳选择（即到底使用哪种模型和技术），不仅取决于材质的属性，还取决于观察的尺度。例如：当渲染一个孩子在玩塑料玩具的场景时，我们很可能需要使用全局次表面散射技术来准确地渲染孩子的皮肤，而对于塑料玩具而言，可能一个局部的漫反射着色模型就足够了。这是因为皮肤中的散射距离，要比在塑料中的散射距离大得多，但是如果相机足够远的话，皮肤的散射距离也可能会小于一个像素，此时局部的着色模型对于儿童和玩具而言都是十分准确的。相反，在一个极端的特写镜头中，塑料也可能会表现出明显的非局部次表面散射现象，这时候就需要全局次表面散射技术来对玩具进行准确地渲染。\n在观察尺度大于次表面散射尺度时，用漫反射来控制局部sss即可，但是观察尺度很小时，局部sss就不够了，需要全局sss（也就是漫反射不能只考虑当前像素，光进入物体距离大于一个像素）\n相机 在渲染的时候，我们会计算从表面着色点到相机位置的radiance。这模拟了一个简化了的成像系统，例如胶片相机、数码相机或者人眼。\n成像设备中都有一个传感器表面，由许多小单元组成（像素级别）： 人眼 → 视杆细胞、视锥细胞； 数码相机 → 光电二极管（photodiode）； 胶片 → 感光颗粒； 这些传感器测量的是打到它们表面的irradiance（照度）； 即单位面积上接收到的能量流强度。 问题：irradiance 本身不能直接形成图像 单个传感器只能测到“有多少光打在它上面”， 但它不区分光来自哪个方向。 如果不加控制（例如完全裸露的感光平面）， 所有方向的光都会混在一起，导致图像失焦或模糊。\n光圈与透镜的作用 为了让传感器只响应特定方向的光， 成像系统会加上以下结构：\n不透光外壳：阻挡多余方向的光； 小孔（aperture, 光圈）：只允许部分方向的光进入； 透镜（lens）：将来自场景某点的光聚焦到传感器的对应像素。 这样，每个传感器只接收来自：\n一小块场景区域； 一小组入射方向。 换句话说，每个传感器对光具有了方向性响应（directionally specific）。\nirradiance → radiance 的关系 传感器测量的irradiance，其实是： $$ E = \\int L(x, \\omega_i) \\cos\\theta_i\\, d\\omega_i $$ ——即所有入射方向上radiance的加权平均；\n但由于相机光圈和镜头只允许一小范围的方向入射， 实际上每个像素感受到的就是该方向上的平均radiance。\nThe BRDF 最终，基于物理的渲染可以归结为沿着一组观察射线，计算进入相机的radiance。对于一个给定的观察射线，我们需要计算的是$L_{i}(\\mathbf{c},-\\mathbf{v})$，其中$\\mathbf{c}$是相机的位置，$-\\mathbf{v}$是沿着观察射线的方向。\n不考虑介质的影响，认为进入相机的radiance，与其观察方向上离开最近物体表面的radiance是相等的\n所以着色计算就转到了计算物体表面p向方向v的radiance。\n不考虑透明物体和全局次表面散射的情况；我们所关注的是局部反射现象，它将照射到当前着色点的光线，重新发射回外部，这些现象包括表面反射以及局部次表面散射，它们只依赖于入射光方向$\\mathbf{l}$和指向外部的观察方向$\\mathbf{v}$。\n双向反射分布函数（bidirectional reflectance distribution function，BRDF）描述了着色点表面的局部反射系数，记为$f(\\mathbf{l}, \\mathbf{v})$。\n光线的入射方向和出射方向各有两个自由度。\n一种常用的参数化表示包括两个角度：\n仰角 $$\\theta$$：相对于表面法线 \\(\\mathbf{n}\\) 方位角 $$\\phi$$：相对于表面法线 \\(\\mathbf{n}\\) 的水平旋转 在一般情况下，BRDF 是一个包含 四个标量变量 的函数：\n两个描述入射方向 ($$\\theta_i, \\phi_i$$) 两个描述出射方向 ($$\\theta_o, \\phi_o$$) 各向同性 (Isotropic) BRDF 是一个重要特例：\n当光线入射方向和出射方向围绕表面法线 $$\\mathbf{n}$$ 旋转时，BRDF 保持不变，只依赖于入射和出射方向之间的相对角度。 对各向同性 BRDF，只需要 三个标量变量： 入射方向仰角 $$\\theta_i$$ 出射方向仰角 $$\\theta_o$$ 入射与出射方向的相对方位角 $$\\phi = \\phi_o - \\phi_i$$ 换句话说，如果将一个具有均匀各向同性材质的物体放在转盘上旋转，在给定光线和相机条件下，物体表面在所有旋转角度上看起来都是相同的。\n由于忽略了荧光（fluorescence）和磷光（phosphorescence）等现象，我们可以假设，给定波长的入射光会以相同波长被反射出来。 反射光的光量会根据波长变化，可以用两种方式模拟： 将波长视为 BRDF 的额外输入变量； 将 BRDF 视为返回光谱分布的值。 在实时渲染中，通常使用第二种方法，实时渲染器一般将光谱分布表示为一个RGB三元组，这意味着BRDF会返回一个RGB值\n为了计算着色点的出射光线 $$L_{o}(\\mathbf{p}, \\mathbf{v})$$，我们将 BRDF 项合并到反射方程（reflectance equation）中： $$ L_{o}(\\mathbf{p}, \\mathbf{v}) = \\int_{\\mathbf{l} \\in \\Omega} f(\\mathbf{l}, \\mathbf{v})\\, L_{i}(\\mathbf{p}, \\mathbf{l})\\, (\\mathbf{n} \\cdot \\mathbf{l})\\, d \\mathbf{l} \\tag{9.3} $$ 公式说明： 积分下标 $$\\mathbf{l} \\in \\Omega$$ 表示对以着色点 $$\\mathbf{p}$$ 为球心、表面法线 $$\\mathbf{n}$$ 为方向的单位半球上所有入射光方向 $$\\mathbf{l}$$ 积分； 入射光方向 $$\\mathbf{l}$$ 在半球范围内连续扫描，并非单一光线； $$d\\mathbf{l}$$ 表示入射方向 $$\\mathbf{l}$$ 周围的单位立体角微分； 出射 radiance 等于入射 radiance 乘以 BRDF 再乘以 $$\\mathbf{n} \\cdot \\mathbf{l}$$ 点积的积分。 $Li(\\mathbf{p}, \\mathbf{l})$是 入射 radiance，表示从方向 $\\mathbf{l}$ 到达点 $\\mathbf{p}$ 的光强度 在第一行中，左侧展示了一个Lambertian BRDF（一个简单半球）；中间展示了在Lambertian项中添加了Blinn-Phong高光的结果；右侧展示了Cook-Torrance BRDF [285, 1779]，值得注意的是，镜面高光并不是最强的反射方向。在第二行中，左侧是Ward各向异性模型的特写，在这种情况下，反射方向是一个倾斜的镜面波瓣；中间展示了Hapke/Lommel-Seeliger“月面（lunar surface）”BRDF [664]，它具有强烈的后反射；右侧展示了 Lommel-Seeliger散射，尘埃表面会将光线散射到掠射角上（grazing angle）。\n表面反射的BRDF模型 这部分LearnOpenGL的PBR模型部分已经记录，就是NDF项如何建模使用\n次表面散射的BRDF模型 这里知道了BRDF的漫反射部分（也就是这本书中介绍的局部次表面散射部分）除了最简单的Lambertian漫反射模型，还有很多更复杂的模型，但是实时渲染用Lambertian足够了\n对于 Lambertian 漫反射，BRDF 定义为： $$ f_r(\\mathbf{l}, \\mathbf{v}) = \\frac{\\rho}{\\pi} $$ 其中：\n$\\mathbf{l}$ = 入射光方向 $\\mathbf{v}$ = 出射方向（视线方向） $\\rho$ = 漫反射反射率（albedo），范围 [0,1] $\\pi$ = 正则化因子，保证能量守恒 布料的BRDF模型 布料材质特点 微观几何结构复杂：纤维、丝线、编织结构等 表面光学特性特殊： 各向异性镜面高光 粗糙散射（次表面散射导致边缘亮） 观察角依赖的颜色变化（多色纤维引起） 高频空间变化显著（纹理、老化、瑕疵、皱褶等） 结论：默认通用BRDF（Lambert + GGX）无法准确模拟这些特性。\n布料BRDF的三大类 经验模型（Empirical） 根据观察设计，非物理正确 示例： 《神秘海域2》漫反射BRDF + rim/inner/lambert项 《神秘海域4》使用 wrap lighting 近似次表面散射 迪士尼的 sheen 项模拟粗糙散射 微表面模型（Microfacet / Micro-surface） 使用 NDF 描述纤维表面微结构 示例： Ashikhmin 逆高斯 NDF 模拟天鹅绒 《教团：1886》使用改进微表面BRDF + 天鹅绒 NDF Imageworks 提供可附加光泽项的逆NDF 微圆柱体模型（Micro-cylinder / Fiber-based） 假设表面由大量一维纤维/线段组成 示例： Kajiya-Kay / Banks 模型 《神秘海域4》用于丝绸、高光纤维 Dreamworks 和 Sadeghi 的模型增加了可控参数和masking-shadowing 可借鉴头发BSDF（PxrSurface、Marschner模型） 布料材质光学特性特殊，通用BRDF适用性有限\n不同布料类型（天鹅绒、丝绸、棉花、羊毛等）需要不同BRDF或BSDF来模拟\n模型选择依据材质：\n经验模型 → 视觉效果优先、易控制 微表面模型 → 模拟高光、粗糙散射 微圆柱体模型 → 精确描述纤维方向性、masking-shadowing 波动光学的BRDF模型 这。。了解即可\n几何光学的假设与局限 几何光学把光当作射线，适用于表面特征要么非常小（\u0026lt; 波长），要么非常大（\u0026gt; 100 倍波长）的情况。 现实世界表面在1~100倍波长的尺度上具有不规则性，这部分被称为纳米几何（nanogeometry）。 纳米几何对反射率的影响无法用几何光学模拟，需要使用波动光学。 衍射（Diffraction） 纳米几何会导致光发生衍射，Huygens-Fresnel原理解释了光如何在表面点作为新球面波源传播并相干干涉。 表面高度的不规则性决定了镜面反射与衍射光的分布： 较大尺度的不规则性 → 小扩散角度 较小尺度的不规则性 → 大扩散角度 周期性纳米结构（如CD、DVD、昆虫翅膀）可产生显著的彩虹色衍射。 最近的研究表明，衍射在许多普通材质中也存在，只是传统渲染模型未考虑。 薄膜干涉（Thin-film interference） 发生在厚度接近光波长的薄膜上，例如油渍、肥皂泡。 原理：薄膜顶部反射光与底部反射光的光路差导致相长/相消干涉。 产生的效果： 高光随观察角度变化，出现彩虹色或微妙色偏。 相干长度决定了干涉是否可见： 可见光的相干长度约 1 微米，因此厚度超过 1 微米的薄膜通常不会产生可见干涉。 实时渲染中： 可以用查表或近似方法高效实现一阶、二阶光路干涉。 已经被许多现代渲染器（RenderMan、Imageworks）采用，增强材质真实感。 总结 几何光学适用于宏观微表面，但无法处理纳米尺度的光学效应。 波动光学补充了几何光学的不足： 衍射：纳米几何导致光在各方向散射和干涉。 薄膜干涉：薄层材质产生随角度变化的彩虹高光或微妙色偏。 在渲染中，这些效应虽然属于“细节”，但对真实感有显著提升，现代材质模型和渲染器已开始支持这些效果。 分层材质 概念：现实中的材质通常由多层组成，例如灰尘、水、冰、雪、涂层或生物材料。 透明涂层（Clear Coat）： 最常见且视觉重要的分层案例。 透明光滑层覆盖在基底材质上，如木材上涂清漆。 二次反射效果显著，尤其在金属基底上。 可以有颜色（通过Beer-Lambert定律模拟光吸收）。 支持逐层法线，但实时渲染中较少用。 分层材质模型实例： 迪士尼Principled Shader、虚幻引擎、PxrSurface材质、Dreamworks/Imageworks。 Weidlich \u0026amp; Wilkie模型：多层叠加，追踪单次反射/折射，计算成本低，适合实时。 Jakob等框架：支持层间多次反射，精确但不适合实时渲染。 游戏《使命召唤：无限战争》：可叠加任意层，支持折射、散射、层间吸收及逐层法线，实时渲染复杂材质。 材质混合（Material Blending） 概念：将多种材质的BRDF参数结合在一起。 方法： 使用蒙版纹理控制不同材质混合区域。 可在运行时或预烘焙完成。 可混合法线贴图，通过高度贴图或细节法线叠加实现。 应用： 展示动态伤害效果 用户自定义装备/服装 增加角色与环境视觉多样性 性能优化： 对蒙版边界材质，优先混合BRDF参数而非计算两次着色。 材质过滤（Material Filtering） 概念：纹理在GPU上通过mipmap和滤波进行采样，但假设参数与最终颜色线性，非线性参数可能产生瑕疵。 主要问题： 法线贴图和粗糙度贴图的线性mipmap可能导致高光闪烁或亮度异常。 解决方法： 法线分布过滤： 理想方法：对NDF进行平均，而非对法线或粗糙度单独平均。 Toksvig方法：通过平均法线长度修正粗糙度（公式9.76），适合Blinn-Phong/Beckmann NDF。 LEAN/CLEAN/LEADR映射：基于法线协方差矩阵，处理各向同性或各向异性。 方差映射： 预计算法线方差存储到纹理中，用于修正粗糙度，兼顾GPU滤波。 可用于多法线叠加（如细节法线）。 高频表面和闪闪发光材质： 闪烁现象由每像素覆盖少量凸起引起。 实时渲染方法： Wang \u0026amp; Bowles：产生视觉闪光效果（雪花等）。 Zirr \u0026amp; Kaplanyan：模拟多尺度法线分布，空间和时间上稳定。 ","date":"2025-10-20T13:29:19+08:00","image":"https://sdpyy1.github.io/20230611105147.png","permalink":"https://sdpyy1.github.io/p/physically-based-shading2-%E5%9F%BA%E4%BA%8E%E7%89%A9%E7%90%86%E7%9A%84%E7%9D%80%E8%89%B2/","title":"Physically Based Shading(2) 基于物理的着色"},{"content":"Light and Color 光与颜色 这一章节除了辐射度量学，toneMapping、颜色分级我感觉会用到，其他更多是科普内容，比如为什么用RGB就能代替可见光\n光量 任何基于物理的渲染方法，其第一步都是以一种精确的方式，来对光进行量化（quantify）。\n辐射度量学 在不同的课程中接触了这个东西几次，每次都有不一样的理解😝，下图展示了光的波长范围与人可见光的范围\n各个辐射量（radiometric quantity）的存在是为了对电磁辐射的各个方面进行测量和度量，例如：总能量、功率（随时间变化的能量）以及相对于面积、方向或者二者的功率密度等\n在辐射度量学中，最基本的单位是辐射通量（radiant flux）Φ，辐射通量是指辐射能量随时间的流动变化，又叫做功率（power），其单位为瓦特(watts，W)。==理解为做功的功率即可== 辐照度（irradiance）是辐射通量相对于面积的密度，即$ d \\Phi / d A$。irradiance是相对于一个面积来进行定义的，这个面积可能是空间中的一个假想区域，但是在渲染中一般都是物体的表面。irradiance的单位是瓦特每平方米（$W/m^2$）。==理解为功率在单位面积上的大小==， 辐射强度（radiant intensity），即辐射通量相对于方向的密度，更准确地说，是相对于立体角的密度$（d \\Phi / d \\omega）$。它的单位是瓦特每立体弧度$（W/sr）$ 最后，辐射度（radiance）L是对单条光线中电磁辐射的度量。更精确地说，它是辐射通量相对于面积和立体角的密度$（d^{2} \\Phi / d A d \\omega）$。这里的面积位于垂直于光线的平面上，如果想要在其他方向上对表面施加辐射，则必须使用余弦因子进行校正。==进一步把irradiance划分到单位立体角== 立体角可以理解为立体空间的一个立体方向截取一个单位球体的面片面积\nradiance是传感器（例如眼睛或者相机）所直接测量的对象，因此它对渲染而言至关重要。计算着色方程的目的就是沿着给定的光线，计算从着色点到相机的radiance；沿着这条光线计算出来的结果L。radiance的公制单位是瓦特每平方米每立体弧度$（W/m^2sr）$\n环境中的radiance可以被认为是五个变量（或者六个变量，将波长考虑在内）的函数，它被称为辐射分布（radiance distribution）；其中有三个变量指定了位置，另外两个变量指定了方向，这个分布函数描述了在空间中任何地方传播的任何光线。根据上面的描述，我们可以这样来理解渲染过程：将眼睛和屏幕定义为一个点和一组方向（例如从眼睛出发，穿过每个像素的光线），然后使用这个函数，在这组方向上对眼睛所在的位置进行评估。\n在着色方程中，radiance通常会以$L_{o}(\\mathbf{x}, \\mathbf{d})$或者\n$$L_{i}(\\mathbf{x}, \\mathbf{d})$$的形式出现，它代表了从点x发出，或者进入点x的radiance具体是多少。方向向量$\\mathbf{d}$表示了光线的方向。radiance的一个重要特性是，在忽略了雾等大气效应影响的前提下，radiance不会受到传播距离的影响。换句话说，无论一个表面与相机的距离有多远，它们都将具有相同的radiance。当距离相机越远时，这个表面所覆盖的像素就越少，但是来自表面上每个像素的radiance是恒定的。\n多数光波中都包含了许多不同波长的单色光，这通常可以被可视化为一个光谱功率分布（spectral power distribution，SPD），它是一个展示了光线能量如何在不同波长之间分布的图片。（三种不同光波的光谱功率分布（SPD）。第一行是一个绿色激光的SPD，它有一个非常狭窄的光谱分布，其波形类似于图9.1中的简单正弦波。第二行的SPD是由相同的绿色激光，外加两个额外的激光所组成的，分别是红色激光和蓝色激光。将这些激光的波长和相对强度转换到RGB激光投影显示器上，会显示出中性白色。第三行的SPD是标准的D65光源，这是一个典型的中性白色参考值，旨在代表室外的自然光照，类似第三行的SPD，其能量连续地分布在可见光谱上，是典型的自然光。）\n==在下图中人眼对于第二行和第三行的感知是一样的，这就是为什么只用三个数字就可以精确地表示任何颜色的原因==\n光度学 辐射度量学仅仅对物理量进行了研究，它完全没有考虑人眼的感知。与此相关的一个领域被称为光度学（photometry），它与辐射度量学类似，不同之处在于，它会根据人眼的敏感度，对辐射度量学中的一切事物进行加权处理。通过乘以CIE光度曲线（CIE photometric curve），辐射度量学中的计算结果可以被转换为相应的光度单位。CIE光度曲线是一条以555纳米为中心的钟形曲线，它代表了人眼对各种波长光线的响应程度。\n这个转换曲线与测量单位，是光度学理论和辐射度量学理论之间的唯一区别。每个辐射物理量都有一个对应的光度学物理量\nLuminance通常用来描述平面的亮度。例如：高动态范围（high dynamic range，HDR）电视屏幕的峰值亮度通常在500到1000尼特（nit）之间。相比之下，晴朗天空的亮度大约为8000尼特，60瓦的电灯泡约为12万尼特，地平线上的太阳约为60万尼特。==所以尼特相当于Radiance==\n色度学 人眼对于颜色的感知与光线的SPD（光谱功率分布，上上一节提到）密切相关。同时我们还知道了，二者之间并不是简单的一一对应关系。第二行和第三行所展示的SPD完全不同，但给人的感知却是完全相同的。色度学（colorimetry）研究的就是SPD和颜色感知之间的关系。\n人眼可以分辨大约1000万种不同的颜色。对于颜色感知，人眼的视网膜上有着三种不同类型的视锥感受器（细胞），每种感受器对于不同波长的光线都有不同的反应。其他动物的眼睛则有着不同数量的颜色感受器，有些动物的眼睛甚至多达15个。因此，对于一个给定的SPD，我们的大脑只会从这些感受器中接收到三种不同的信号，这就是为什么只用三个数字就可以精确地表示任何颜色的原因\n书中描述了一组实验来说明人类如何用科学方法量化 “颜色感知”。从人眼的生理特性出发，通过实验建立标准，最终用简单的数值（比如 XYZ、xy）精准描述任何颜色，解决了 “不同光线（SPD）可能看起来一样” 的问题。 用 XYZ 表达任何颜色的核心原理，不管光线的光谱多复杂，只要编码（XYZ）相同，人眼感知到的颜色就完全一致。\nCIE 1931 色度图（图 8.8）是一张「人类可见颜色的 “地图”」—— 把所有我们肉眼能分辨的颜色，都精准定位在一张二维图上，核心作用是直观展示 “颜色的位置、范围和关系”，方便标准化对比和应用（比如屏幕、打印机的颜色匹配）。\n图上的黑色轮廓线，就是 “人类可见颜色的全部范围“。 白色三角形：代表某类设备的「色域范围」。三角形的三个顶点，对应设备能发出的 “三种基础色”（比如屏幕的红、绿、蓝像素，打印机的青、品红、黄墨水）； 三角形内部的所有颜色，都是这台设备能精准还原的颜色； 三角形外部、但在黑色轮廓内的颜色，这台设备 “显示 / 打印不出来”（会被近似替代）； 黑点：代表「标准白光」 色度图仅仅描述了一个平面，它仅包含了颜色的色调信息，想要完整地描述一个颜色，还需要第三个维度Y，即亮度luminance。这三个坐标在一起，定义了所谓的xyY坐标系。色度图对于理解颜色在渲染中的使用，以及理解渲染系统中的限制而言非常重要。电视或者计算机显示器会通过使用R、G、B颜色值来呈现颜色，每个颜色通道都会控制一个显示原色（display primary），这个原色会发出具有特定SPD的光线。三种原色都会按其各自的颜色值进行缩放，这些颜色值被叠加在一起，从而生成一个被观众所感知的单一SPD。\n在图形渲染中有几个值得关注的RGB色域空间，每个空间都由R、G、B三原色和一个白点进行定义。为了对它们进行比较，我们将使用一种不同类型的色度图，它被称为CIE 1976 UCS （uniform chromaticity scale）色度图。它是CIELUV颜色空间的一部分，CIE将CIELUV颜色空间（以及另一个颜色空间CIELAB）作为XYZ颜色空间的替代方案\n上边这些东西总结起来用于解释为什么RGB的vec3就可以用来渲染颜色，因为不管光线的波长组合多复杂（也就是 SPD 多不一样），只要它们给人眼的颜色感知相同，就都能用同一组 RGB 数值代替。\n使用RGB颜色进行渲染 严格来说，RGB颜色值代表的是感知量，而不是真实的物理量，使用RGB颜色值来进行基于物理的渲染，在技术上来说是一个分类错误。正确的方法应当是在光谱物理量上执行所有的渲染计算，然后通过密集采样或者是投影到适当的基底上，并在最后将其转换为用于屏幕输出的RGB颜色值。\n展示了一种用于激光投影仪屏幕的材料，它在激光投影仪光线波长的窄带处，具有较高的反射率，而在其他大多数波长上的反射率都较低。这使得这个屏幕表面会反射投影仪所发出的大部分光线，同时会吸收来自其他光源的大部分光线。在这种情况下，RGB渲染器将会产生严重的渲染错误。==一个极端例子展示RGB会错误渲染的场景==\n然而，对于大多数渲染系统而言，尤其是那些并不是用于预测模拟的交互式应用程序而言，RGB渲染的效果得出奇的好。即使是动画电影的离线渲染中，也只是在最近才开始使用光谱渲染，而且只是少数情况，大部分离线渲染仍然使用的是RGB渲染。\n从场景到屏幕 对于一个给定的虚拟场景，基于物理的渲染有着这样一个目标，即计算场景中可能存在的真实radiance。然而计算完成之后，渲染的工作还远未完成，我们仍然需要确定最终的结果，即显示器帧缓冲中的像素值。在本小节中，我们将讨论与此有关的一些因素。\nHDR显示器通常会使用Rec. 2020和Rec. 2100标准。其中Rec. 2020定义了一个具有更宽色域的颜色空间，如图8.12所示，并且它与Rec. 709和sRGB色彩空间具有相同的白点（D65）。\n虽然峰值亮度（peak luminance）和色域规格（gamut specification）对于编码而言十分重要，但是对于实际的显示设备而言，它们多少有些不切实际（aspirational）。因此，HDR显示器会在内部执行从标准规格到实际显示功能的色调映射（tone mapping）与色域映射（gamut mapping）。这个映射过程也可能会受到应用程序所传递过来的原始数据的影响，这些原始数据可能会指明实际的动态范围和色域范围\n色调映射 toneMapping 在伽马矫正时，即将线性的radiance值，转换为用于显示设备的非线性编码值的过程。显示编码所使用的函数是显示器光电转换函数（electrical optical transfer function，EOTF）的逆函数，它确保了输入的线性值与显示器发出的线性radiance相匹配。在我们前面的讨论中，忽略了发生在渲染和显示编码之间的一个重要步骤，下面我们将对这个步骤进行介绍和讨论。\n这个步骤叫做色调映射（tone mapping）或者色调再现（tone reproduction），它是指将场景的radiance转换为显示器radiance的过程。在这个步骤中所应用的转换函数称为端到端转换函数（end-to-end transfer function）或者场景到屏幕转换函数（scene-to-screen transform）。图像状态（image state）的概念是理解色调映射的关键[1602]，有两种基本的图像状态：场景参考（scene-referred）图像是根据场景中的radiance进行定义的，显示参考（display-referred）图像是根据显示器的radiance进行定义的。图像状态与编码无关，在这两种状态下的图像，都可以进行线性编码或者非线性编码。图8.13展示了图像状态、色调映射和显示编码是如何在成像管线中进行组合的，这个成像管线用于将最初渲染生成的颜色值，转换为最终用于显示的颜色值。\n同一个场景使用了四种不同的色调变换。这些结果的差异主要体现在图中的绿色圆圈区域，因为那里的场景像素值特别高。左上角：直接裁剪（同时使用了sRGB OETF）；右上角：Reinhard方法[1478]；左下角：Duiker方法[392]；右下角：寒霜方法（保持色相的版本）[497]。Reinhard，Duiker和寒霜的变换，都可以保留因裁剪而丢失的高光信息。然而，Reinhard曲线倾向于降低图像中较暗部分的饱和度[628, 629]，而Duiker变换则增加了较暗区域的饱和度，后者有时会被认为是更好的特性[630]。通过一些特殊设计，寒霜变换保持了饱和度和色相，避免了强烈的色相偏移，请仔细观察其他三张图片左下角的圆圈区域。\n曝光 动态曝光：用上述方法（对数平均 / 直方图 / 光照强度）实时计算每帧曝光，适配场景光照变化（比如从室内到室外，曝光自动降低，避免过曝）； 静态曝光：多数游戏的选择 —— 美术人员根据场景固定光照（比如室内、室外、夜景）手动设置曝光值，避免动态曝光的意外波动（比如突然出现极亮像素导致画面骤暗）； 核心原则：无论哪种策略，曝光都是 “线性缩放”—— 只改变亮度的整体范围，不破坏线性空间的计算规则（后续色调映射才做非线性变换）。 曝光的本质是「场景亮度到显示亮度的 “预处理缩放”」，核心是找到一个稳定、合理的缩放因子。核心目标让场景的关键细节（中间调、灰卡对应的亮度）落在显示器的敏感区间，同时尽可能保留高光和阴影细节，符合人眼感知。\n颜色分级 这个内容动手实践一下应该就好懂了\n在章节8.2.2中，我们提到了择优图像再现（preferred image reproduction）的概念，即生成在某种意义上看起来比原始场景更好的图像。它通常会涉及到对图像颜色的创造性处理，这个过程被称为颜色分级（color grading），或者调色、校色等，它们的含义实际上都是一样的。\n数字颜色分级已经在电影工业中使用了一段时间，早期的例子包括电影《逃狱三王（O Brother, Where Art Thou？）》（2000）和《天使爱美丽（Amelie）》（2001）。颜色分级通常是通过交互式的操作，来对场景图像的颜色进行调整，直到实现想要的创意“外观”，然后再将相同的操作序列，重新应用到一个镜头或者一个序列中的所有图像上。颜色分级技术从电影传播到游戏领域中，现在在游戏中被广泛应用[392, 424, 756, 856, 1222]。\nSelan [1601]展示了如何将来自一个颜色分级，或者图像编辑应用的任意颜色转换，“烘焙”到一个三维颜色查找表（LUT）中。通过将RGB颜色值作为xyz坐标输入，来从这个表中快速查找对应的新颜色；这种方式可以用于从颜色到颜色的任何映射，不过这个过程会受到LUT分辨率的限制。Selan的烘焙过程从一个相同LUT开始，这个LUT会将每个输入的颜色，映射到相同的输出颜色，然后再将其“切片”从而创建一个二维图像。然后将这个切片的LUT图像加载到一个颜色分级应用程序中，并对其应用定义目标创意外观所需要的操作。需要注意的是，只能对LUT应用颜色操作，需要避免模糊等空间操作。然后将编辑好的LUT保存下来，“打包”到一个三维GPU纹理中，并在渲染的过程中进行使用，从而动态地对渲染像素应用相同的颜色转换。Iwanicki [806]提出了一种聪明的方法，当在LUT中存储颜色变换的时候，可以使用最小化的最小二乘法，来减少采样误差。\n在后来的出版物中，Selan [806]对两种执行颜色分级的方法进行了区分。第一种方法是对显示参考的图像数据进行颜色分级操作。第二种方法通过显示变换，对结果进行预览，然后再对场景参考的数据进行颜色分级操作。虽然显示参考的颜色分级方法更加容易操作和实现，但是对场景参考的数据进行颜色分级操作，可以生成更高保真度（fidelity）的结果。\n当实时应用程序首次使用颜色分级技术时，显示参考方法占据了主导地位[756, 856]。然而，场景参考方法由于其更高的视觉质量，而获得了更多的关注[198, 497, 672]，如图8.16所示。对场景参考的图像数据应用颜色分级操作，还可以将色调映射曲线烘焙到颜色分级LUT中[672]，从而节省一些计算量，就像在游戏《神秘海域4》中所做的那样[198]。\n游戏《神秘海域4》中的场景画面。最上面的截图没有使用颜色分级，下面两个截图各自应用了一个颜色分级操作。为了便于说明，我们选择了一个极端的颜色分级操作（将原始图像乘以一个高饱和度的青色）。在左下角的截图中，将颜色分级应用在了显示参考图像上（即色调映射之后）；而在右下角的截图中，将颜色分级应用在了场景参考图像上（即色调映射之前）\n在进行LUT查找之前，还必须将场景参考的数据重新映射到$[0,1]$范围中[1601]。在寒霜引擎[497]中，使用了感知量化曲线OETF来实现这个目的，尽管可以使用一些更加简单的曲线。Duiker [392]使用了一个对数曲线，而Hable [635]则建议使用一次或者两次的平方根运算来实现。\n","date":"2025-10-20T11:41:15+08:00","image":"https://sdpyy1.github.io/202306132029525.png","permalink":"https://sdpyy1.github.io/p/physically-based-shading-1-%E5%85%89%E4%B8%8E%E9%A2%9C%E8%89%B2/","title":"Physically Based Shading (1) 光与颜色"},{"content":"前向运动学 单链 每个关节的局部坐标系与全局坐标系重合\n父关节的旋转会让子关节也跟着旋转 子关节的朝向就是夫关节朝向乘以自身的旋转 只旋转R0时,Q1在Q0的局部坐标系下的坐标并不会变一直是l0,如果想求Q1的世界坐标,就是父关节的世界坐标加朝向*l0 这一套东西就是用传播来更新,比如Q4的局部坐标系的某一个点,可以算出他在Q3的局部坐标系的位置,Q3的位置又能计算出他在Q2的位置,最终得到这个点在Q0的位置,最终再换算到世界坐标系,就得到了Q4局部坐标系下某一点在全局坐标系下的位置 这块比较好理解 BVH文件,用欧拉角表示旋转 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 HIERARCHY ROOT RootJoint // 主结点 { OFFSET 0.000000 0.000000 0.000000 CHANNELS 6 Xposition Yposition Zposition Xrotation Yrotation Zrotation // 主节点的位置和旋转信息 JOINT lHip // 主关节内的子关节 { OFFSET 0.100000 -0.051395 0.000000 // 子关节相对于主关节局部坐标系的偏移量 CHANNELS 3 Xrotation Yrotation Zrotation // 欧拉角表示的旋转 JOINT lKnee // 子关节的子关节 一层一层嵌套 { OFFSET 0.000000 -0.410000 0.000000 CHANNELS 3 Xrotation Yrotation Zrotation JOINT lAnkle { OFFSET 0.000000 -0.390000 0.000000 CHANNELS 3 Xrotation Yrotation Zrotation JOINT lToeJoint { OFFSET 0.000000 -0.050000 0.130000 CHANNELS 3 Xrotation Yrotation Zrotation End Site { OFFSET 0.010000 0.002000 0.060000 } } } } } 一行MOTION信息按照上边给的关节顺序和通道数给出一帧的旋转信息\n1 1.471305 0.917570 2.607916 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 45.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 -45.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 前向运动学作业 1. 解析BVH文件 BVH存储的是每个关节相对于父关节的旋转(欧拉角)和位置偏移\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 def part1_calculate_T_pose(bvh_file_path): \u0026#34;\u0026#34;\u0026#34; 输入： bvh 文件路径 输出: joint_name: List[str]，字符串列表，包含着所有关节的名字 joint_parent: List[int]，整数列表，包含着所有关节的父关节的索引,根节点的父关节索引为-1 joint_offset: np.ndarray，形状为(M, 3)的numpy数组，包含着所有关节的偏移量 \u0026#34;\u0026#34;\u0026#34; joint_name = [] joint_parent = [] joint_offset = [] parent_stack = [] # 用于跟踪当前关节的父级层次 current_parent = -1 # 根节点的父节点索引为-1 # 读取BVH文件内容 with open(bvh_file_path, \u0026#39;r\u0026#39;) as f: lines = [line.strip() for line in f if line.strip()] # 定位层次结构部分（HIERARCHY到MOTION之间的内容） hierarchy_start = None hierarchy_end = None for i, line in enumerate(lines): if line.upper() == \u0026#39;HIERARCHY\u0026#39;: hierarchy_start = i + 1 elif line.upper() == \u0026#39;MOTION\u0026#39; and hierarchy_start is not None: hierarchy_end = i break if hierarchy_start is None or hierarchy_end is None: raise ValueError(\u0026#34;BVH文件格式错误，未找到层次结构或运动数据部分\u0026#34;) # 解析层次结构 for line in lines[hierarchy_start:hierarchy_end]: tokens = line.split() if not tokens: continue if tokens[0].upper() == \u0026#39;ROOT\u0026#39;: # 处理根节点 joint_name.append(tokens[1]) joint_parent.append(-1) parent_stack.append(len(joint_name) - 1) current_parent = len(joint_name) - 1 elif tokens[0].upper() == \u0026#39;JOINT\u0026#39;: # 处理子关节 joint_name.append(tokens[1]) joint_parent.append(current_parent) parent_stack.append(len(joint_name) - 1) current_parent = len(joint_name) - 1 elif tokens[0].upper() == \u0026#39;OFFSET\u0026#39;: # 处理偏移量（T姿态下的位置偏移） offset = np.array([float(tokens[1]), float(tokens[2]), float(tokens[3])]) joint_offset.append(offset) elif tokens[0].upper() == \u0026#39;END\u0026#39; and tokens[1].upper() == \u0026#39;SITE\u0026#39;: # 处理末端节点 end_joint_name = f\u0026#34;{joint_name[current_parent]}_end\u0026#34; joint_name.append(end_joint_name) joint_parent.append(current_parent) parent_stack.append(len(joint_name) - 1) current_parent = len(joint_name) - 1 elif tokens[0] == \u0026#39;}\u0026#39;: # 层级结束，返回上一级父节点 if parent_stack: parent_stack.pop() current_parent = parent_stack[-1] if parent_stack else -1 # 转换为numpy数组 joint_offset = np.array(joint_offset) return joint_name, joint_parent, joint_offset 2. 前向运动学计算 读入BVH文件中每一帧的动作数据, 计算每个关节在世界坐标系下的旋转和位置。\n1 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 def part2_forward_kinematics(joint_name, joint_parent, joint_offset, motion_data, frame_id): \u0026#34;\u0026#34;\u0026#34;请填写以下内容 输入: part1 获得的关节名字，父节点列表，偏移量列表 motion_data: np.ndarray，形状为(N,X)的numpy数组，其中N为帧数，X为Channel数 frame_id: int，需要返回的帧的索引 输出: joint_positions: np.ndarray，形状为(M, 3)的numpy数组，包含着所有关节的全局位置 joint_orientations: np.ndarray，形状为(M, 4)的numpy数组，包含着所有关节的全局旋转(四元数) Tips: 1. joint_orientations的四元数顺序为(x, y, z, w) 2. from_euler时注意使用大写的XYZ \u0026#34;\u0026#34;\u0026#34; bone_num = len(joint_name) # 记录所有关节的全局位置 joint_positions = np.empty((bone_num, 3)) # 记录所有关节的全局旋转(四元数) joint_orientations = np.empty((bone_num, 4)) # 一帧的所有旋转数据和主关节的位置信息 motion_data_oneFrame = motion_data[frame_id] # 主关节的位置信息 joint_positions[0] = motion_data_oneFrame[:3] # 主关节的旋转(从欧拉角转为四元数) joint_orientations[0] = R.from_euler(\u0026#39;XYZ\u0026#39;, motion_data_oneFrame[3:6], degrees=True).as_quat() frame_count = 1 for i in range(1, bone_num): # 当前关节的父关节id p = joint_parent[i] cur_rotate = None if joint_name[i].endswith(\u0026#39;_end\u0026#39;): # _end表示一条关节链的终点位置 cur_rotate = R.from_euler(\u0026#39;XYZ\u0026#39;, [0., 0., 0.], degrees=True) else: cur_rotate = R.from_euler(\u0026#39;XYZ\u0026#39;, motion_data_oneFrame[3 + frame_count * 3: 6 + frame_count * 3], degrees=True) frame_count += 1 # 父关节的旋转信息 p_orient = R.from_quat(joint_orientations[p]) # 当前位置的旋转 = 父关节的旋转 * 当前关节的旋转 joint_orientations[i] = (p_orient * cur_rotate).as_quat() # 当前位置 = 父关节的全局位置 + 父关节的旋转 * 当前关节在父关节旋转方向上的偏移 joint_positions[i] = joint_positions[p] + np.dot(R.from_quat(joint_orientations[p]).as_matrix(), joint_offset[i]) return joint_positions, joint_orientations 3. 运动重定向 读入一个A-Pose的文件, 将A-pose的bvh重定向到T-pose上 意思是 骨骼信息来自T-pose,但是一帧的旋转信息来自A-pose的文件. retarget_motion_data = part3_retarget_func(T_pose_bvh_path, A_pose_bvh_path)用来把A-pose给出的旋转信息重定向到T-pose的骨骼上. 处理办法就是遍历到肩膀肘手腕时要额外处理旋转信息\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 def part3_retarget_func(T_pose_bvh_path, A_pose_bvh_path): \u0026#34;\u0026#34;\u0026#34; 将 A-pose的bvh重定向到T-pose上 输入: 两个bvh文件的路径 输出: motion_data: np.ndarray，形状为(N,X)的numpy数组，其中N为帧数，X为Channel数。retarget后的运动数据 Tips: 两个bvh的joint name顺序可能不一致哦( as_euler时也需要大写的XYZ \u0026#34;\u0026#34;\u0026#34; def index_bone_to_channel(index, flag): if flag == \u0026#39;t\u0026#39;: end_bone_index = end_bone_index_t else: end_bone_index = end_bone_index_a for i in range(len(end_bone_index)): if end_bone_index[i] \u0026gt; index: return index - i return index - len(end_bone_index) def get_t2a_offset(bone_name): l_bone = [\u0026#39;lShoulder\u0026#39;, \u0026#39;lElbow\u0026#39;, \u0026#39;lWrist\u0026#39;] r_bone = [\u0026#39;rShoulder\u0026#39;, \u0026#39;rElbow\u0026#39;, \u0026#39;rWrist\u0026#39;] if bone_name in l_bone: return R.from_euler(\u0026#39;XYZ\u0026#39;, [0., 0., 45.], degrees=True) if bone_name in r_bone: return R.from_euler(\u0026#39;XYZ\u0026#39;, [0., 0., -45.], degrees=True) return R.from_euler(\u0026#39;XYZ\u0026#39;, [0., 0., 0.], degrees=True) motion_data = load_motion_data(A_pose_bvh_path) t_name, t_parent, t_offset = part1_calculate_T_pose(T_pose_bvh_path) a_name, a_parent, a_offset = part1_calculate_T_pose(A_pose_bvh_path) end_bone_index_t = [] for i in range(len(t_name)): if t_name[i].endswith(\u0026#39;_end\u0026#39;): end_bone_index_t.append(i) end_bone_index_a = [] for i in range(len(a_name)): if a_name[i].endswith(\u0026#39;_end\u0026#39;): end_bone_index_a.append(i) for m_i in range(len(motion_data)): frame = motion_data[m_i] cur_frame = np.empty(frame.shape[0]) cur_frame[:3] = frame[:3] for t_i in range(len(t_name)): cur_bone = t_name[t_i] a_i = a_name.index(t_name[t_i]) if cur_bone.endswith(\u0026#39;_end\u0026#39;): continue channel_t_i = index_bone_to_channel(t_i, \u0026#39;t\u0026#39;) channel_a_i = index_bone_to_channel(a_i, \u0026#39;a\u0026#39;) # retarget local_rotation = frame[3 + channel_a_i * 3: 6 + channel_a_i * 3] if cur_bone in [\u0026#39;lShoulder\u0026#39;, \u0026#39;lElbow\u0026#39;, \u0026#39;lWrist\u0026#39;, \u0026#39;rShoulder\u0026#39;, \u0026#39;rElbow\u0026#39;, \u0026#39;rWrist\u0026#39;]: p_bone_name = t_name[t_parent[t_i]] Q_pi = get_t2a_offset(p_bone_name) Q_i = get_t2a_offset(cur_bone) local_rotation = (Q_pi * R.from_euler(\u0026#39;XYZ\u0026#39;, local_rotation, degrees=True) * Q_i.inv()).as_euler(\u0026#39;XYZ\u0026#39;, degrees=True) cur_frame[3 + channel_t_i * 3: 6 + channel_t_i * 3] = local_rotation motion_data[m_i] = cur_frame return motion_data 逆向运动学 已知运动结果求每一个关节的旋转 IK问题可以有解、无解、多解 双关节问题 下图两个虚线圆的交点就是关节1应该去的位置，到达后旋转关节1，肯定能让关节2到达指定位置 另外一种思路： 移动关节1，让0-2的距离= 0到x的距离 再旋转关节0即可 在三维下绕着0x为轴旋转即可 多关节最优化问题 只考虑末端点到达指定位置的问题，建模成 通过各个关节的旋转，得到末端点位置的这个函数 求解问题就是：就是找到各关节的旋转使得目标点位置-公式求解的末端点位置=0 这个方程有没有根 等价的可以转化为一个优化问题，优化问题取极值 求解问题到这里就变成了函数求极值的问题 优化问题下降方法 1. 循环坐标下降法 CCD 认为参数的坐标轴方向为下降方向，不断交换坐标轴。 其实就是每次只更新一个参数（一个旋转角度） CCD放在IK来解释就是每次只旋转一个关节 下图就是只旋转3，让3-4朝向正确（也就是寻找了x离目标位置最近的位置） 下一步旋转关节2 依此类推 之后再回到4关节进行循环 2. 梯度下降（雅可比转置法） CCD没有考虑到目标函数的性质，直接找负梯度方向就可以更快的下降 也就是说每个参数的更新方向都是下降最快的方向 参数的更新公式 红色的这一项是控制梯度更新是往正方向还是负方向（因为下降过头还得回来） 前边梯度的倒数构成的矩阵也可以叫做雅可比矩阵（f对每个参数求导的结果组成的矩阵） 下降梯度法也可以就雅可比转置法。 对于IK问题，他输出是一个点，是三维的输出（x,y,z）所以雅可比矩阵应该是三维的 雅可比矩阵计算方法 调库！！！ 有限差分（用前向运动学运动依次来获得数据填充矩阵，看不懂。。。。） 几何方法 （针对单自由度关节）这里应该是当一个关节旋转一个很小的角度时，把罗德里格斯旋转公式求出目标点，取极限求出来的就是雅可比矩阵的一项 （针对ball 3自由度的关节）很难理解的，选择不理解，知道是可以的就行 3. 高斯-牛顿法 最优化学过。。忘了，这里选择不回忆 4. 雅可比逆方法 。。。听个响 听个。。 角色的IK 更复杂的优化问题。 每个目标点都对应上一节一个优化目标，最后相加。还需要加一个正则项，约束各个关节的旋转 逆向运动学作业 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 def load_motion_data(bvh_file_path): \u0026#34;\u0026#34;\u0026#34;part2 辅助函数，读取bvh文件\u0026#34;\u0026#34;\u0026#34; with open(bvh_file_path, \u0026#39;r\u0026#39;) as f: lines = f.readlines() for i in range(len(lines)): if lines[i].startswith(\u0026#39;Frame Time\u0026#39;): break motion_data = [] for line in lines[i + 1:]: data = [float(x) for x in line.split()] if len(data) == 0: break motion_data.append(np.array(data).reshape(1, -1)) motion_data = np.concatenate(motion_data, axis=0) return motion_data def rotation_matrix(a, b): a = a / np.linalg.norm(a) b = b / np.linalg.norm(b) n = np.cross(a, b) # 旋转矩阵是正交矩阵，矩阵的每一行每一列的模，都为1；并且任意两个列向量或者任意两个行向量都是正交的。 # n=n/np.linalg.norm(n) # 计算夹角 cos_theta = np.dot(a, b) sin_theta = np.linalg.norm(n) theta = np.arctan2(sin_theta, cos_theta) # 构造旋转矩阵 c = np.cos(theta) s = np.sin(theta) v = 1 - c rotation_matrix = np.array([[n[0] * n[0] * v + c, n[0] * n[1] * v - n[2] * s, n[0] * n[2] * v + n[1] * s], [n[0] * n[1] * v + n[2] * s, n[1] * n[1] * v + c, n[1] * n[2] * v - n[0] * s], [n[0] * n[2] * v - n[1] * s, n[1] * n[2] * v + n[0] * s, n[2] * n[2] * v + c]]) return rotation_matrix def inv_safe(data): # return R.from_quat(data).inv() if np.allclose(data, [0, 0, 0, 0]): return np.eye(3) else: return np.linalg.inv(R.from_quat(data).as_matrix()) def from_quat_safe(data): # return R.from_quat(data) if np.allclose(data, [0, 0, 0, 0]): return np.eye(3) else: return R.from_quat(data).as_matrix() def part1_inverse_kinematics(meta_data, joint_positions, joint_orientations, target_pose): \u0026#34;\u0026#34;\u0026#34; 完成函数，计算逆运动学 输入: meta_data: 为了方便，将一些固定信息进行了打包，见上面的meta_data类 joint_positions: 当前的关节位置，是一个numpy数组，shape为(M, 3)，M为关节数 joint_orientations: 当前的关节朝向，是一个numpy数组，shape为(M, 4)，M为关节数 target_pose: 目标位置，是一个numpy数组，shape为(3,) 输出: 经过IK后的姿态 joint_positions: 计算得到的关节位置，是一个numpy数组，shape为(M, 3)，M为关节数 joint_orientations: 计算得到的关节朝向，是一个numpy数组，shape为(M, 4)，M为关节数 \u0026#34;\u0026#34;\u0026#34; path, path_name, path1, path2 = meta_data.get_path_from_root_to_end() parent_idx = meta_data.joint_parent # local_rotation是用于最后计算不在链上的节点 no_caled_orientation = copy.deepcopy(joint_orientations) local_rotation = [ R.from_matrix(inv_safe(joint_orientations[parent_idx[i]]) * from_quat_safe(joint_orientations[i])).as_quat() for i in range(len(joint_orientations))] local_rotation[0] = R.from_matrix(from_quat_safe(joint_orientations[0])).as_quat() local_position = [joint_positions[i] - joint_positions[parent_idx[i]] for i in range(len(joint_orientations))] local_position[0] = joint_positions[0] path_end_id = path1[0] ## lWrist_end 就是手掌 只是加了end不叫hand而已 for k in range(0, 300): # k：循环次数 # 正向的，path1是从手到root之前 for idx in range(0, len(path1)): # idx：路径上的第几个节点了，第0个是手，最后一个是root path_joint_id = path1[idx] vec_to_end = joint_positions[path_end_id] - joint_positions[path_joint_id] vec_to_target = target_pose - joint_positions[path_joint_id] # 获取end-\u0026gt;target的旋转矩阵 # debug # rot_matrix=rotation_matrix(np.array([1,0,0]),np.array([1,1,0])) rot_matrix = rotation_matrix(vec_to_end, vec_to_target) # 计算前的朝向。这个朝向实际上是累乘到父节点的 initial_orientation = from_quat_safe(joint_orientations[path_joint_id]) # 旋转矩阵，格式换算 rot_matrix_R = R.from_matrix(rot_matrix).as_matrix() # 计算后的朝向 calculated_orientation = rot_matrix_R.dot(initial_orientation) # 写回结果列表 joint_orientations[path_joint_id] = R.from_matrix(calculated_orientation).as_quat() # 子节点的朝向也会有所变化 # idx-1 就是当前节点的下一个更接近尾端的节点，一直向前迭代到1 for i in range(idx - 1, 0, -1): path_joint_id = path1[i] # 遍历路径后的节点,都乘上旋转 joint_orientations[path_joint_id] = R.from_matrix( rot_matrix_R.dot(from_quat_safe(joint_orientations[path_joint_id]))).as_quat() path_joint_id = path1[idx] # 修改子节点的位置 for i in range(idx - 1, -1, -1): # path_joint_id=path1[i] # 节点id next_joint_id = path1[i] # 指向下个节点的向量 vec_to_next = joint_positions[next_joint_id] - joint_positions[path_joint_id] # 左乘，改变向量 calculated_vec_to_next_dir = rot_matrix.dot(vec_to_next) # 防止长度不对 calculated_vec_to_next = calculated_vec_to_next_dir / np.linalg.norm( calculated_vec_to_next_dir) * np.linalg.norm(vec_to_next) # 还原回去 joint_positions[next_joint_id] = calculated_vec_to_next + joint_positions[path_joint_id] # path2是从脚到root，所以要倒着 # debug # for idx in range(len(path2)-1,len(path2)-3,-1): # len(path2)-1 --\u0026gt; 0 for idx in range(len(path2) - 1, 0, -1): # len(path2)-1 --\u0026gt; 0 path_joint_id = path2[idx] parient_joint_id = max(parent_idx[path_joint_id], 0) vec_to_end = joint_positions[path_end_id] - joint_positions[path_joint_id] vec_to_target = target_pose - joint_positions[path_joint_id] # 获取end-\u0026gt;target的旋转矩阵 # debug # rot_matrix=rotation_matrix(np.array([0.72,0.35,0]),np.array([0.5,0.35,0])) # rot_matrix=np.linalg.inv(rot_matrix) rot_matrix = rotation_matrix(vec_to_end, vec_to_target) # 计算前的朝向。注意path2是反方向的，要改父节点才行 initial_orientation = from_quat_safe(joint_orientations[path_joint_id]) # 旋转矩阵，格式换算 rot_matrix_R = R.from_matrix(rot_matrix).as_matrix() # 计算后的朝向 calculated_orientation = rot_matrix_R.dot(initial_orientation) # 写回结果列表 joint_orientations[path_joint_id] = R.from_matrix(calculated_orientation).as_quat() # 其他节点的朝向也会有所变化 for i in range(idx + 1, len(path2)): path_joint_id = path2[i] joint_orientations[path_joint_id] = R.from_matrix( rot_matrix_R.dot(from_quat_safe(joint_orientations[path_joint_id]))).as_quat() # idx-1 就是当前节点的下一个更接近尾端的节点，一直向前迭代到1 for i in range(len(path1) - 1, 0, -1): path_joint_id = path1[i] # 遍历路径后的节点,都乘上旋转 joint_orientations[path_joint_id] = R.from_matrix( rot_matrix_R.dot(from_quat_safe(joint_orientations[path_joint_id]))).as_quat() path_joint_id = path2[max(idx - 1, 0)] # 修改父节点，或者说更靠近手的那些节点的位置 # path2上的 for i in range(idx, len(path2)): # path_joint_id=path1[i] # 节点id prev_joint_id = path2[i] # 指向上一个节点的向量 vec_to_next = joint_positions[prev_joint_id] - joint_positions[path_joint_id] # 左乘，改变向量 calculated_vec_to_next_dir = rot_matrix.dot(vec_to_next) # 防止长度不对 calculated_vec_to_next = calculated_vec_to_next_dir / np.linalg.norm( calculated_vec_to_next_dir) * np.linalg.norm(vec_to_next) # 还原回去 joint_positions[prev_joint_id] = joint_positions[path_joint_id] + calculated_vec_to_next # path1上的 for i in range(len(path1) - 1, -1, -1): # path_joint_id=path1[i] # 节点id prev_joint_id = path1[i] # 指向上一个节点的向量 vec_to_next = joint_positions[prev_joint_id] - joint_positions[path_joint_id] # 左乘，改变向量 calculated_vec_to_next_dir = rot_matrix.dot(vec_to_next) # 防止长度不对 calculated_vec_to_next = calculated_vec_to_next_dir / np.linalg.norm( calculated_vec_to_next_dir) * np.linalg.norm(vec_to_next) # 还原回去 joint_positions[prev_joint_id] = calculated_vec_to_next + joint_positions[path_joint_id] # debug # rot_matrix=rotation_matrix(np.array([1,0,0]),np.array([1,0,1])) # joint_orientations[0]=R.from_matrix(rot_matrix).as_quat() # joint_orientations[1]=R.from_matrix(rot_matrix).as_quat() joint_orientations[path_end_id] = joint_orientations[path1[1]] cur_dis = np.linalg.norm(joint_positions[path_end_id] - target_pose) if cur_dis \u0026lt; 0.01: break print(\u0026#34;距离\u0026#34;, cur_dis, \u0026#34;迭代了\u0026#34;, k, \u0026#34;次\u0026#34;) # 更新不在链上的节点 for k in range(len(joint_orientations)): if k in path: pass elif k == 0: # 要单独处理，不然跟节点的-1就会变成从最后一个节点开始算 pass else: # 先获取局部旋转 # 这里如果直接存的就是矩阵就会有问题？ local_rot_matrix = R.from_quat(local_rotation[k]).as_matrix() # 再获取我们已经计算了的父节点的旋转 parent_rot_matrix = from_quat_safe(joint_orientations[parent_idx[k]]) # 乘起来 # re=local_rot_matrix.dot(parent_rot_matrix) re = parent_rot_matrix.dot(local_rot_matrix) joint_orientations[k] = R.from_matrix(re).as_quat() # 父节点没旋转的时候是： initial_o = from_quat_safe(no_caled_orientation[parent_idx[k]]) # 父节点的旋转*delta_orientation=子节点旋转 # 反求delta_orientation delta_orientation = np.dot(re, np.linalg.inv(initial_o)) # 父节点的位置加原本基础上的旋转 joint_positions[k] = joint_positions[parent_idx[k]] + delta_orientation.dot(local_position[k]) return joint_positions, joint_orientations 关键帧插值 给出一系列离散的值后，寻找一个f(x),首先满足已有的点都在f(x)上，另外要能计算其他的未给出的x的f(x) 梯度函数 也可以是离当前点最近的点的值作为f(x) 线性插值函数 直接用直线连接已知点 线性插值的平滑性 多项式插值 Polynomial Interpolation 计算参数就是把已知点带进去 然后用矩阵求解。 这里就能看出需要多少个采样点 把矩阵求逆就可以求出参数a Runge\u0026rsquo;s phenomenon：当使用等距节点对某些光滑函数进行高次多项式插值时，随着插值多项式次数的增加，多项式在区间端点附近会出现剧烈的振荡，导致插值误差不仅不减小，反而会急剧增大，最终完全偏离原函数。 下图靠近两边的点中间插值出现了非常巨大的震荡（原因是接近1时，x的高次方会变得很大，如果还是使用等距插值就会出问题） 样条插值 Spline Interpolation 只在单独几个采样点上用低阶多项式插值 常用的是Cubic Splines （三次样条插值） 计算时只需要相邻的两个采样点计算依次多项式参数。 如果总采样点是N+1，那一共有N段，每段都是一个三次多项式。每个多项式4个未知参数。所以一共有4N个未知参数。 2个采样点信息没办法求出4个未知数。样条采样引入了别的规则。\n两段之间的采样点的在两个多项式的导数相同 二阶导也相同 对整条曲线的边缘点的一阶二阶导数一些额外限制 三次埃尔米特样条 Cubic Hermite Splines Cubic Splines问题是只要移动一个点，都可能发生整条曲线的变化，求解也比较昂贵\nCubic Hermite Splines的做法是除了给出两个采样点外，还需要额外告诉两个点的导数\n总结就是 4个方程4个未知数 矩阵的逆都一步到位了，一次矩阵乘法就直接求解4个位置系数 还可以把多项式直接写出矩阵形式 旋转的插值 旋转的四种表示方法：\n旋转矩阵 欧拉角 轴角 四元数 下面两种旋转表达旋转速度插值不是恒定的 用四元数的slerp来恒定速度（但线性插值并单位化四元数来插值不恒定）\n","date":"2025-10-19T14:25:28+08:00","image":"https://sdpyy1.github.io/9cd6da17016c4d8bb0a71ba5e8f44d0c.png","permalink":"https://sdpyy1.github.io/p/games105-%E8%AE%A1%E7%AE%97%E6%9C%BA%E8%A7%92%E8%89%B2%E5%8A%A8%E7%94%BB%E5%9F%BA%E7%A1%80%E5%89%8D%E5%90%91%E8%BF%90%E5%8A%A8%E5%AD%A6%E9%80%86%E5%90%91%E8%BF%90%E5%8A%A8%E5%AD%A6%E5%85%B3%E9%94%AE%E5%B8%A7%E5%8A%A8%E7%94%BB%E4%B8%8E%E6%8F%92%E5%80%BC/","title":"GAMES105 计算机角色动画基础（前向运动学、逆向运动学、关键帧动画与插值）"},{"content":"线性代数 向量 点乘 Dot Product 叉乘 Cross Product 叉乘作用:寻找同时与AB都垂直的方向(AB平面的法线方向)\n向量旋转(叉乘应用) 理论上在ab两个向量的角平分面上任何一个方向都可以作为旋转轴来进行旋转 这时候找ab叉乘方向作为轴进行旋转,旋转最少 如何旋转一个向量 例如绕着u为轴进行旋转 可以把旋转拆分成两部分 v方向是 UcrossA的方向(其实就是圆盘的切线方向,也就是说叉乘可以求切线方向),t方向是UcrossV(与切线垂直) 从俯视图来看 这个推导结果就是Rodrigues\u0026rsquo;s rotation formula(罗德里格斯旋转公式) 正交基 正交基的要求 一个向量可以用正交基的线性组合来表示 两个向量的点乘可以展开 最后一项为0了 叉乘也可以,去掉叉乘自己=0的项,化简后 矩阵 一些会用到的特殊矩阵(单位/对角/对称/反对称) 转置 运算 一些运算工具 叉乘计算可以用矩阵来表示,这里的a用一个反对称阵来表示了 把叉乘计算转为矩阵运算后,旋转一个向量的罗德里格斯旋转公式可以表示为Ra,这里的R就是空间中沿着某个单位轴u,旋转的旋转矩阵 正交矩阵 定义:矩阵的每一列都是互相正交的向量 正交阵的逆=正交阵的转置 正交阵转置*正交阵 = 单位阵 矩阵的行列式 三×四列矩阵的行列式怎么算😄 行列式的性质 叉乘同样可以用行列式来计算\n特征值 比较重要的结论是,3x3的正交阵肯定有一个特征值是1或-1 刚体变换 Rigid Transformation 缩放 相当于把向量乘以一个对角阵,每一行表示缩放比例 平移 平移可以直接加 旋转 旋转矩阵有一些性质.旋转矩阵是正交阵,行列式一定是+1,刚性变化,不会改变向量的模长 旋转的组合,毕竟是左乘肯定得反着写 沿着坐标轴旋转的旋转矩阵 分别沿着3个轴旋转结果,与沿着某个计算出来的轴旋转可以得到一样的结果 旋转矩阵肯定有一个特征值为1的特征值. RU=1*U(特征值的概念),这里的特征向量U在左乘R后保持不变,可以理解为U方向就是旋转轴 旋转轴和旋转角度可以通过各种线性代数公式推导出来.R-1=RT所以左右同乘以RT.得到某个反对称阵 * 对称轴U = 0. 反对称阵可以写成叉乘 叉乘=0表示两个向量共线 所以说这个反对称阵对应的叉乘的u\u0026rsquo;单位化后就是对称轴.谁想出来的😄\n坐标转换 全局和模型坐标系的转换\n3D旋转 旋转的插值不能线性插值.下面这种插值是错的 得出结论,用旋转矩阵来表达旋转时的缺点 欧拉角（Euler Angles）来表示旋转 欧拉角（Euler Angles）是 3D 旋转中最直观的表示方式（通过绕三个正交轴的依次旋转角度定义） 但它存在一个致命缺陷 ——万向锁（Gimbal Lock，也称 Gimbal Lock） 万向锁把中间变换设置为90°时,另外两个旋转变换都只会在同一个方向上进行旋转,这就是万向锁. https://www.bilibili.com/video/BV1Nr4y1j7kn/?spm_id_from=333.337.search-card.all.click\u0026amp;vd_source=9df9034e2f1978b1018f5b387ec3eacd解释的很好,其实调整第一个轴,结果却在最后一个轴上旋转的原因是: 调整第一个轴本来就是在第一个轴上旋转,但是调整后又经过第二个轴的旋转,把他带到了第三个轴的旋转上看上去就像是在第三个轴旋转.\n欧拉角的优缺点: 旋转轴和旋转角度来表示旋转 前边说过了 优缺点: 四元数 Quaternions 复数与二维旋转 复数表示为\n复数用向量来表示就是 复平面:x轴为实轴,y轴为虚轴 复数的乘法: 可以看出复数的可以写出一个矩阵和一个向量的结果.矩阵代表z1,向量代表z2. 也就是说z1乘以z2等价于给z2左乘一个变换矩阵\n把表示z1的这个变换矩阵做一个变形 从下图可以看出根号下平方和表示这个复数的模长 并且 带入刚才的矩阵 左边部分是一个缩放矩阵,右边刚好是2D旋转公式. 所以说一个复数可以看作是先旋转角度再缩放的变换矩阵\n所以 我们要对一个向量旋转时,先把他看成一个复数 给他左乘一个复数就能达到旋转效果 或者可以理解为2D旋转公式 中的这个旋转矩阵是可以写出复数形式的\n三维旋转 轴角式:绕着某个旋转轴进行旋转 欧拉角:就是上边讲的那个,用它的缺点就是有万向锁,四元数来解决它 在轴角表示法中,确定一个旋转需要一个旋转轴(3个自由度)旋转角度(1个自由度),四个自由度来表示\n旋转分解:把向量v分解为平行于旋转轴和垂直于旋转轴的两个分量 分解过程有具体公式实现 下面就只需要分别讨论两个分量的旋转 对于垂直分量,就是在底面投影形成的一个圆形上的旋转,w可以通过叉乘获得 在底盘上可以把旋转结果分解到垂直的两个方向上 对于平行分量来说,它并没有旋转,所以 最终组合两个分量并化简 额,其实就是上边讲的Rodrigues旋转公式(⊙﹏⊙)\n四元数定义 四元数的性质: 四元数可以写出一个向量 可以用四元数来表示向量和标量 用向量表示后,乘法就表示为 四元数没有交换律,有结合律 单位四元数的逆=共轭 类似于单位复数可以组成复平面的一个圆,单位四元数也可以组成4D空间的球壳 对于任何一个单位四元数都可以写出下面这个形式 它和轴角表示的u和斯塔有相同的信息量.这样的对应可得 一个轴角表示可以转化为一个四元数表示\n如果把一个需要被旋转的向量表示为纯四元数(标量=0),那么旋转q作用到它的计算方式为 四元数可以旋转叠加 四元数的插值 在球壳上才是合法的单位四元数,所以线性插值的qt是不合法的 所以每次插值都需要进行单位化,但是插值速度是不恒定的 为了实现常数速度的插值,需要用slerp 其中a和b可以推导出来 四元数的优点 ","date":"2025-10-19T14:24:24+08:00","image":"https://sdpyy1.github.io/697bf32a43404daeadb64e6c91c121f0.png","permalink":"https://sdpyy1.github.io/p/games105-%E8%AE%A1%E7%AE%97%E6%9C%BA%E8%A7%92%E8%89%B2%E5%8A%A8%E7%94%BB%E5%9F%BA%E7%A1%80%E6%95%B0%E5%AD%A6%E5%9F%BA%E7%A1%80/","title":"GAMES105 计算机角色动画基础（数学基础）"},{"content":"大气渲染 渲染方法分析 光如何与Participating Media Particles（包括小粒子（气体分子），大粒子（气溶胶、水滴、烟雾）） 交互？\n吸收 外散射 自发光 内散射 M点为着色点，那从P点看向M点时，需要计算两个值（1）Transmittance(透光度):有多少光能透过来(2) Scattering：粒子会把来自各个方向的光散射到观察方向 真实大气物理 太阳光由不同波长的光组成，人眼把这些波长光的组合定义成了白色而已 大气中包含两种粒子：气体分子一般小于这些光的波长，其他的气溶胶大于这些波长 有两种完全不同的散射模型来末说这两种粒子的散射 瑞利散射 Rayleigh Scattering （针对气体分子） 散射特点：对波长敏感，短波长（蓝光）更容易被散射，所以天空是蓝色。 各种波长光散射形状差不多，但是强度不同，各个方向上散射大致相同 具体的模型公式有两部分，前半部分是各种系数、后半部分是模型形状（花生形状）。公式表达含义是在在散射方向 θ上的散射光强，它与光的波长、海拔高度有关 直观理解天空为什么是蓝色，太阳直射时，大量的蓝光被散射开来，红光散射比较弱。太阳落山时，大量的蓝光散射到了太空和地面，红光的散射效果体现了出来 米氏散射 Mie scattering （针对大分子） 米氏散射对波长依赖弱 → 散射几乎不偏色，所以雾和云看起来白色或灰色 所有波长的光一视同仁，但是不同的散射方向能力有巨大差异 奇怪的形状。 光的吸收 这两种气体会吸收光，但模型过于复杂，在渲染中假设他们均匀分布 单次散射和多次散射 单次散射只考虑太阳光到一个粒子后散射到摄像机 多次散射就是考虑一个粒子散射到另一个粒子这样不停散射最终到达摄像机 主要看山的背面，多次散射才能表达出这种感觉 大气渲染实践 瑞利散射和米氏散射建模 参考别的论文，他们是用h = 0时求出的解作为基准，用一个高度衰减函数进行高度衰减。函数都有两部分，一部分求解散射系数一部分求解相函数 瑞利散射：\n1 2 3 4 5 6 7 8 9 10 11 12 vec3 RayleighCoefficient(in AtmosphereParameter param, float h) { const vec3 sigma = vec3(5.802, 13.558, 33.1) * 1e-6; float H_R = param.RayleighScatteringScalarHeight; float rho_h = exp(-(h / H_R)); return sigma * rho_h; } float RayleighPhase(in AtmosphereParameter param, float cos_theta) { return (3.0 / (16.0 * 3.14159265)) * (1.0 + cos_theta * cos_theta); } 米氏散射多一个g，用来控制米氏散射的形状\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 vec3 MieCoefficient(AtmosphereParameter param, float h) { const vec3 sigma = vec3(3.996e-6); float H_M = param.MieScatteringScalarHeight; float rho_h = exp(-(h / H_M)); return sigma * rho_h; } float MiePhase(AtmosphereParameter param, float cos_theta) { float g = param.MieAnisotropy; float a = 3.0 / (8.0 * 3.14159265); float b = (1.0 - g * g) / (2.0 + g * g); float c = 1.0 + cos_theta * cos_theta; float d = pow(1.0 + g * g - 2.0 * g * cos_theta, 1.5); return a * b * (c / d); } 散射结果，将两种散射结果相加，得到太阳光被一个粒子散射到摄像机的三色比例\n1 2 3 4 5 6 7 8 9 10 vec3 Scattering(AtmosphereParameter param, vec3 p, vec3 lightDir, vec3 viewDir) { float cos_theta = dot(lightDir, viewDir); float h = length(p) - param.PlanetRadius; vec3 rayleigh = RayleighCoefficient(param, h) * RayleighPhase(param, cos_theta); vec3 mie = MieCoefficient(param, h) * MiePhase(param, cos_theta); return rayleigh + mie; } 吸收建模 瑞利散射没有吸收，米氏有吸收，另外大气中的臭氧也有吸收效应\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 vec3 MieAbsorption(AtmosphereParameter param, float h) { const vec3 sigma = vec3(4.4e-6); float H_M = param.MieScatteringScalarHeight; float rho_h = exp(-(h / H_M)); return sigma * rho_h; } vec3 OzoneAbsorption(AtmosphereParameter param, float h) { const vec3 sigma_lambda = vec3(0.650, 1.881, 0.085) * 1e-6; float center = param.OzoneLevelCenterHeight; float width = param.OzoneLevelWidth; float rho = max(0.0, 1.0 - abs(h - center) / width); return sigma_lambda * rho; } 透光率 透光率与经过的路径和吸收有关，由于每个点的吸收不同，所以采样处理 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 vec3 Transmittance(in AtmosphereParameter param, vec3 p1, vec3 p2) { const int N_SAMPLE = 32; vec3 dir = normalize(p2 - p1); float distance = length(p2 - p1); float ds = distance / float(N_SAMPLE); vec3 sum = vec3(0.0); vec3 p = p1 + dir * ds * 0.5; for(int i = 0; i \u0026lt; N_SAMPLE; i++) { float h = length(p) - param.PlanetRadius; vec3 scattering = RayleighCoefficient(param, h) + MieCoefficient(param, h); vec3 absorption = OzoneAbsorption(param, h) + MieAbsorption(param, h); vec3 extinction = scattering + absorption; sum += extinction * ds; p += dir * ds; } // 计算透光率 return exp(-sum); } 单次散射 首先要有一个光线与球体求交的计算\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 float RayIntersectSphere(vec3 center, float radius, vec3 rayStart, vec3 rayDir) { float OS = length(center - rayStart); float SH = dot(center - rayStart, rayDir); float OH = sqrt(OS * OS - SH * SH); // 射线未击中球体 if(OH \u0026gt; radius) return -1.0; float PH = sqrt(radius * radius - OH * OH); // 使用最小正距离 float t1 = SH - PH; float t2 = SH + PH; float t = (t1 \u0026lt; 0.0) ? t2 : t1; return t; } 对于单次散射而言，从摄像机位置RM获得每一个采样点，每一个采样点进行光照计算，最后汇总 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 vec3 singleScatterSkyColor(vec3 camPosInPlanet, AtmosphereParameter param, vec3 viewDir){ vec3 lightDir = normalize(-lightPos); int N_SAMPLE = 16; float dstToPlanet = RayIntersectSphere(vec3(0,0,0), param.PlanetRadius, camPosInPlanet, viewDir); float dstToAtmosphere = RayIntersectSphere(vec3(0,0,0), param.PlanetRadius + param.AtmosphereHeight, camPosInPlanet, viewDir); // 还没实现别的渲染，纯黑的，先不判断 // if(dstToPlanet \u0026gt; 0.0 || dstToAtmosphere \u0026lt; 0.0){ // return vec3(0); // } float stepSize = dstToAtmosphere / float(N_SAMPLE); vec3 testPoint = camPosInPlanet; vec3 color = vec3(0); for (int i =0; i\u0026lt;N_SAMPLE; i++) { // 太阳方向rayMarching的距离 float disToLight = RayIntersectSphere(vec3(0,0,0), param.PlanetRadius + param.AtmosphereHeight, testPoint, lightDir); // 太阳到采样点之间的透光率 vec3 tramsmittanceLightToTest = Transmittance(param,testPoint + lightDir * disToLight,testPoint); // 散射光比例 vec3 scattering = Scattering(param, testPoint, lightDir, viewDir); // 摄像机到采样点之间的透光度 vec3 tramsmittanceTestToCam = Transmittance(param,testPoint,camPosInPlanet); // 采样点的光照计算。// TODO：需要把太阳光放大很多倍才有比较亮的效果（因为ToneMapping压黑的） vec3 inScattering = tramsmittanceLightToTest * scattering * tramsmittanceTestToCam * stepSize * lightColor * 4; testPoint += viewDir * stepSize; color += inScattering; } float cosAngle = dot(viewDir, lightDir); if(cosAngle \u0026gt; cos(0.02)) { // 视线指向太阳圆盘 color += Transmittance(param, lightPos + vec3(0,param.PlanetRadius + param.AtmosphereHeight,0), camPosInPlanet) * lightColor; } return color; } 透射率预计算 对于T2，因为每次都是固定在一个方向上进行透射率累加，所以一边计算一边缓存即可，不用每次都从头开始计算透射率，主要来看T1的预计算（因为每次计算太阳光到采样点的透射率都是一个全新的方向，不能利用之前的数据） 地球是圆的，大气层具有对称性，所以左右晃动不会影响透射率 也就是说，对于某一高度的着色点，不同方向上的透射率只和天顶角有关。高度和天顶角两个参数就能确定透射率。（待实现，可参考https://zhuanlan.zhihu.com/p/595576594）\n展示 这里还有一点点遮挡关系没有出来，不过不是重点 多重散射 就像渲染方程，除了来自光源的直接光照+散射，场景中其他方向的粒子也会散射，也会对着色点有贡献，所以要积分获得整个球面上所有方向上的贡献之和，同时还有大量预计算这就做到了多重散射。这块的实现暂时放弃，同样可参考https://zhuanlan.zhihu.com/p/595576594 ","date":"2025-10-19T14:20:40+08:00","image":"https://sdpyy1.github.io/025e7da8dd62453095a4739f7c3d15ed.png","permalink":"https://sdpyy1.github.io/p/openglrender%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%953%E5%90%8E%E5%A4%84%E7%90%86pass%E5%A4%A7%E6%B0%94%E6%B8%B2%E6%9F%93/","title":"OpenGLRender开发记录（3）：后处理Pass（大气渲染）"},{"content":"已实现功能 除了上次实现IBL之外，项目目前新增了imGUI的渲染，更方便地进行调试 可以随意切换IBL贴图、模型控制、灯光控制灯。 并且添加了一个PBR材质的地板。下一步就是实现阴影\n阴影 shadowMap 这个东西很简单，直接实现了，不讲原理\nPCF 取周围一圈的像素求平均，让阴影更软\nPCSS ① Blocker Search 阶段（寻找遮挡者） 目的：估算遮挡物与被遮挡物之间的距离 → 用于计算 penumbra（阴影模糊程度）\n步骤： 从当前 fragment 的 light space 坐标，投影到 shadow map 中：projCoords.xy\n以该位置为中心，在 shadow map 中进行 小范围采样（通常 3x3 或 5x5）：\n收集所有 比当前 fragment 深度更小的样本（说明它们挡住了光）\n累加这些“遮挡者”的深度值\n记录 blocker 数量\n若存在 blocker：\n计算平均 blocker 深度 avgBlockerDepth\n② Penumbra Size 计算阶段（决定模糊程度） 目的：用当前 fragment 深度 与 avgBlockerDepth 的距离估算光源发散导致的阴影模糊程度\n③ Filtering 阶段（模糊阴影边缘） 目的：根据 penumbra 大小，用可变范围 PCF 模糊阴影边缘\n所以PCSS可以叫自适应PCF\n实现 shadowMap 初始化一个FBO用于shadowMap的渲染，创建一张纹理存储深度结果\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 void ShadowPass::init() { glGenFramebuffers(1, \u0026amp;shadowFBO); // 创建深度纹理 glGenTextures(1, \u0026amp;shadowMap); glBindTexture(GL_TEXTURE_2D, shadowMap); glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, scene.width, scene.height, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); // attach 深度纹理到FBO glBindFramebuffer(GL_FRAMEBUFFER, shadowFBO); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, shadowMap, 0); glDrawBuffer(GL_NONE); glReadBuffer(GL_NONE); glBindFramebuffer(GL_FRAMEBUFFER, 0); isInit = true; } 下一步就是在光源视角下渲染，首先得找到摄像机的位置，其实就是MVP矩阵的VP用光源而不是用摄像机，下面是对于平行光的shadowMap渲染\n1 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 void ShadowPass::render() { if (!isInit){ std::cout \u0026lt;\u0026lt; \u0026#34;shadowPass init\u0026#34; \u0026lt;\u0026lt; std::endl; return; } glViewport(0, 0, scene.width, scene.height); glBindFramebuffer(GL_FRAMEBUFFER, shadowFBO); glClear(GL_DEPTH_BUFFER_BIT); // glCullFace(GL_FRONT); // 可选，防止 Peter-panning shadowShader.bind(); glm::mat4 lightProjection, lightView; float orthoSize = 10.0f; float near_plane = 0.1f; float far_plane = 100.0f; // 你可以再根据场景大小动态调整 // 平行光使用正交投影 lightProjection = glm::ortho(-orthoSize, orthoSize, -orthoSize, orthoSize, near_plane, far_plane); // TODO：只实现了平行光，他的position存储的是方向，而不是位置，所以要取反 lightView = glm::lookAt(-scene.lights[0]-\u0026gt;position * 10.0f, glm::vec3(0.0f), glm::vec3(0.0, 1.0, 0.0)); lightSpaceMatrix = lightProjection * lightView; shadowShader.setMat4(\u0026#34;lightSpaceMatrix\u0026#34;, lightSpaceMatrix); // 渲染所有模型（只写深度） for (auto\u0026amp; model : scene.models) { glm::mat4 modelMatrix = model.getModelMatrix(); shadowShader.setMat4(\u0026#34;model\u0026#34;, modelMatrix); model.draw(shadowShader); } shadowShader.unBind(); // glCullFace(GL_BACK); glBindFramebuffer(GL_FRAMEBUFFER, 0); glViewport(0, 0, scene.width, scene.height); } 顶点着色器，物体要乘以lightSpaceMatrix来转到光源视角下\n1 2 3 4 5 6 7 8 9 #version 330 core layout (location = 0) in vec3 aPos; uniform mat4 model; uniform mat4 lightSpaceMatrix; void main() { gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0); } 片段着色器不需要执行操作\n1 2 3 4 #version 330 core void main() { // 空着就行，只写深度 } 下一步将生成的shadowMap传递到lightPass来参与光照计算，另外需要把lightMatrix也传递过去，用于把模型距离值转移到视角下来进行比较\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 float ShadowCalculation(vec3 fragPosWorld, vec3 normal) { vec4 fragPosLightSpace = lightSpaceMatrix * vec4(fragPosWorld, 1.0); vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; projCoords = projCoords * 0.5 + 0.5; // [-1,1] → [0,1] // 从深度贴图采样 float closestDepth = texture(shadowMap, projCoords.xy).r; float currentDepth = projCoords.z; // 简单 bias 防止 shadow acne // float bias = max(0.005 * (1.0 - dot(normal, normalize(lightPos))), 0.001); // 超出边界不产生阴影 if (projCoords.z \u0026gt; 1.0) return 0.0; // 进行一次比较 return (currentDepth) \u0026gt; closestDepth ? 1.0 : 0.0; } 有了计算阴影的函数后，只需要在计算光照的最后*（1-shadow）\n1 vec3 Lo = (kD * albedo / PI + specular) * radiance * NdotL * (1-ShadowCalculation(WorldPos, N)); 结果如下，经典的自阴影现象，主要原因就是shadowMap的分辨率不足，一些不在同一高度的位置被记录了相同高度，在主摄像机渲染时，某个位置在shadowMap存储的高度比自己本来还要高，就会被认为是阴影（但是这种情况下很好分辨光源摄像机的覆盖范围，方便调试🙂） 当我把shadowMap的分辨率提高后，自然就消失了，但这肯定不是最优 通常的做法是加一个自偏移，也就是说shadowMap存的高度和我用来比较的高度差异不超过bias，就认为没有阴影\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 float ShadowCalculation(vec3 fragPosWorld, vec3 normal) { vec4 fragPosLightSpace = lightSpaceMatrix * vec4(fragPosWorld, 1.0); vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; projCoords = projCoords * 0.5 + 0.5; // [-1,1] → [0,1] // 从深度贴图采样 float closestDepth = texture(shadowMap, projCoords.xy).r; float currentDepth = projCoords.z; // 简单 bias 防止 shadow acne float bias = max(0.005 * (1.0 - dot(normal, normalize(lightPos))), 0.001); // 超出边界不产生阴影 if (projCoords.z \u0026gt; 1.0) return 0.0; // 进行一次比较 return (currentDepth - bias) \u0026gt; closestDepth ? 1.0 : 0.0; } PCF 阴影问题解决了，下面就是提升效果，当前的阴影是硬阴影，锯齿很严重 PCF思路就是取周围像素的shadow来取平均，柔化阴影边界\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // --- PCF --- float shadow = 0.0; ivec2 texSize = textureSize(shadowMap, 0); vec2 texelSize = 1.0 /vec2(texSize); int range = 10; // 5x5 int samples = 0; for (int x = -range; x \u0026lt;= range; ++x) { for (int y = -range; y \u0026lt;= range; ++y) { float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; shadow += (currentDepth - bias \u0026gt; pcfDepth) ? 1.0 : 0.0; samples++; } } shadow /= float(samples); return shadow; PCSS 三步走，一些参数我已经提取出去当uniform，可以控制第一步的搜索半径、第二步的半影大小、以及控制一下最大的滤波核大小\n1 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 float avgBlockerDepth = 0.0; int blockers = 0; ivec2 texSize = textureSize(shadowMap, 0); vec2 texelSize = 1.0 / vec2(texSize); int searchRadius = int(PCSSBlockerSearchRadius); for (int x = -searchRadius; x \u0026lt;= searchRadius; ++x) { for (int y = -searchRadius; y \u0026lt;= searchRadius; ++y) { float sampleDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; if (sampleDepth \u0026lt; currentDepth - bias) { avgBlockerDepth += sampleDepth; blockers++; } } } if (blockers == 0) return 0.0; avgBlockerDepth /= blockers; float penumbra = (currentDepth - avgBlockerDepth) * PCSSScale; int kernel = int(clamp(penumbra * float(texSize.x), 1.0, PCSSKernelMax)); float shadow = 0.0; for (int x = -kernel; x \u0026lt;= kernel; ++x) { for (int y = -kernel; y \u0026lt;= kernel; ++y) { float sampleDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; shadow += (currentDepth - bias \u0026gt; sampleDepth) ? 1.0 : 0.0; } } shadow /= float((2 * kernel + 1) * (2 * kernel + 1)); return shadow; 阴影 三种阴影可以写在一起\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 float ShadowCalculation(vec3 fragPosWorld, vec3 normal) { vec4 fragPosLightSpace = lightSpaceMatrix * vec4(fragPosWorld, 1.0); vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; projCoords = projCoords * 0.5 + 0.5; if (projCoords.z \u0026gt; 1.0) return 0.0; float closestDepth = texture(shadowMap, projCoords.xy).r; float currentDepth = projCoords.z; float bias = max(0.005 * (1.0 - dot(normal, normalize(lightPos))), 0.001); // shadow type switching if (shadowType == 0) { return 0.0; // no shadow } else if (shadowType == 1) { return (currentDepth - bias \u0026gt; closestDepth) ? 1.0 : 0.0; // hard shadow } else if (shadowType == 2) { // --- PCF --- float shadow = 0.0; ivec2 texSize = textureSize(shadowMap, 0); vec2 texelSize = 1.0 /vec2(texSize); int range = pcfScope; int samples = 0; for (int x = -range; x \u0026lt;= range; ++x) { for (int y = -range; y \u0026lt;= range; ++y) { float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; shadow += (currentDepth - bias \u0026gt; pcfDepth) ? 1.0 : 0.0; samples++; } } shadow /= float(samples); return shadow; } else if (shadowType == 3) { float avgBlockerDepth = 0.0; int blockers = 0; ivec2 texSize = textureSize(shadowMap, 0); vec2 texelSize = 1.0 / vec2(texSize); int searchRadius = int(PCSSBlockerSearchRadius); for (int x = -searchRadius; x \u0026lt;= searchRadius; ++x) { for (int y = -searchRadius; y \u0026lt;= searchRadius; ++y) { float sampleDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; if (sampleDepth \u0026lt; currentDepth - bias) { avgBlockerDepth += sampleDepth; blockers++; } } } if (blockers == 0) return 0.0; avgBlockerDepth /= blockers; float penumbra = (currentDepth - avgBlockerDepth) * PCSSScale; int kernel = int(clamp(penumbra * float(texSize.x), 1.0, PCSSKernelMax)); float shadow = 0.0; for (int x = -kernel; x \u0026lt;= kernel; ++x) { for (int y = -kernel; y \u0026lt;= kernel; ++y) { float sampleDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; shadow += (currentDepth - bias \u0026gt; sampleDepth) ? 1.0 : 0.0; } } shadow /= float((2 * kernel + 1) * (2 * kernel + 1)); return shadow; } return 0.0; } ","date":"2025-10-19T14:19:37+08:00","image":"https://sdpyy1.github.io/976b5ef841fd4cda9621c4812dd3dba4.png","permalink":"https://sdpyy1.github.io/p/openglrender%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%952%E9%98%B4%E5%BD%B1shadowmappcfpcss/","title":"OpenGLRender开发记录（2）：阴影（shadowMap，PCF，PCSS）"},{"content":"已实现功能 前边基础架构部分就不专门写了。这里展示一下已有的功能 延迟渲染管线 G-Buffer的可视化调试 PBR材质直接光照渲染 下面开始研究IBL在OpenGL中的实现 理论准备 基于图像的光照(Image based lighting, IBL)将周围环境整体视为一个大光源。IBL 通常使用立方体贴图的每个像素视为光源\n在IBL中，不只有直接光照对着色点有贡献，而是四面八方的环境光都有贡献。之前总觉得为什么渲染方程中有积分，为什么在shader中没见积分运算，原来是它只是一个理想状态，要计算所有方向的光照是很困难的，给定任何方向向量 wi，我们需要一些方法来获取这个方向上场景的辐射度，并且需要实时计算积分。可以使用蒙特卡洛采样来近似积分值，但IBL并不是这样。说实话听YLQ讲，越听越是迷糊。\n下面看看IBL是如何避免积分运算的\n避免积分运算 首先渲染方程的形式如下，已经被分成了漫反射和镜面反射两部分 进一步把+号拆开，可以拆成两个积分\n漫反射部分 首先来看漫反射部分，这里用的BRDF是Lambertian模型，它是一个常数，所以可以直接提出去，分子代表颜色 另外kd的计算，基本原理就是先算出漫反射的比例，再进一步去除金属度的影响（金属没有漫反射），为什么这样设计，我查AI应该是迪士尼的论文提出的。 kd与光线方向有关系（因为F的计算需要wi），所以不能移出积分，做一个近似操作，本来F需要wi和半程向量来计算，改为用摄像机观察方向w0和法线方向来近似，这样就可以挪出去了 其中 （F0表示垂直入射时的反射率）\n最终\n积分内部就只有光源和cos了。 到现在对于不同的法线方向n，就可以预计算一个积分值。 也就是说这张贴图存储的是不同法线方向上的积分值 这样分析半天，其实从理论上想，也应该这样，我不管从什么方向上看（即不同的w0，irrandance是方向无关的）当我观察一个点时，环境贴图对他的贡献都是它法线为中心形成的半球上的光对他的贡献之和（因为是漫反射）\n因此我们可以预计算这个积分值，得到一个 cubemap，称为 irradiance map。积分方法就是蒙特卡洛积分，我们可以简单的在半球面上均匀采样。下面给出learnOpenGL的采样方案 首先改为球面坐标系 用黎曼积分来近似 注意 实际场景中 = 多个局部环境（多个“局部场景”） 在全局场景下： 每个区域（Probe volume）都有自己的局部环境 map； 然后通过探针插值（或者 voxel GI）做出空间连续的光照过渡。 比如室内室外的map肯定得不一样才对，即使法线方向一致。 这就是为什么要用探针\n镜面反射部分 这部分比较夸张，与w0、法线方向、以及BRDF中各种参数有关，即使一个方向向量用球面坐标系，也有9个因素。所以不能直接预计算。 首先基本思路是蒙特卡罗积分，但是当前采样方法实时太慢 Epic提出了很好的解决方案：分割求和近似法（split sum approximation）\n首先思路是把积分中的光照项提出来 如果想求解提出来的这一项Lc(wo)是什么，就需要进行蒙特卡洛积分运算，运算通过法线分布函数进行采样化简，化简过程如下（本质是把法线分布函数的PDF求出来带进去，进行分子分母化简，得到最终结果） 进一步近似，F对结果影响不大，直接去掉了 再近似，把w0和法线方向都近似成R(反射方向)，这样预计算就与观察无关了（把摄像机方向、法线方向全部换成反射方向R来计算），这样处理会让掠射角处理出问题 总结来说就是利用了各种近似手段把这个积分拆成了两部分，第一部分放在坐标，剩余放在右边，通过各种化简近似出第一部分是什么，然后放回原式 第一部分的预计算其实就是在以反射方向为中心，整个半球的光照进行积分的预计算，但是需要通过粗糙度来进行mipmap，因为各种近似的前提是法线分布函数，他是由粗糙度参与控制的，越粗糙的表面，越要用level更高的mipmap 第一部分通过在法线分布函数上进行采样蒙特卡洛积分进行预计算 下面看第二部分 ，他的参数包括w0,n,F0(通过金属度和albedo决定)和粗糙度。 F0是常数，看看怎么挪出去 又是一个拆分，把积分拆成两项（这里就是拆加法，没有近似），然后把F0挪出去，得到关于scale和bias两项的计算 这两项用法线分布函数进行蒙特卡洛积分，可以抵消很多项。拆掉F0后，积分结果只和cos和粗糙度了，用一个2D纹理的两个通道存储结果即可。 这个预计算部分叫做LUT，这个 LUT 是由 BRDF 决定的，所以确定的 BRDF 就有确定的 LUT。\nOpenGL实现 HDR与cubemap 在 PBR 渲染管线中考虑高动态范围(High Dynamic Range, HDR)的场景光照非常重要。由于 PBR 的大部分输入基于实际物理属性和测量，因此为入射光值找到其物理等效值是很重要的 第一步：加载HDR图片，存储为纹理\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #include \u0026#34;stb_image.h\u0026#34; stbi_set_flip_vertically_on_load(true); int width, height, nrComponents; float *data = stbi_loadf(\u0026#34;newport_loft.hdr\u0026#34;, \u0026amp;width, \u0026amp;height, \u0026amp;nrComponents, 0); unsigned int hdrTexture; if (data) { glGenTextures(1, \u0026amp;hdrTexture); glBindTexture(GL_TEXTURE_2D, hdrTexture); // 这里必须用32F，有些HDR图片太亮了 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB32F, width, height, 0, GL_RGB, GL_FLOAT, data); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); stbi_image_free(data); } else { std::cout \u0026lt;\u0026lt; \u0026#34;Failed to load HDR image.\u0026#34; \u0026lt;\u0026lt; std::endl; } 第二步：从纹理到立方体贴图 顶点着色器传递世界坐标\n1 2 3 4 5 6 7 8 9 10 11 12 13 #version 330 core layout (location = 0) in vec3 aPos; out vec3 localPos; uniform mat4 projection; uniform mat4 view; void main() { localPos = aPos; gl_Position = projection * view * vec4(localPos, 1.0); } 片段着色器，在HDR图上进行采样\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #version 330 core out vec4 FragColor; in vec3 localPos; uniform sampler2D equirectangularMap; const vec2 invAtan = vec2(0.1591, 0.3183); vec2 SampleSphericalMap(vec3 v) { vec2 uv = vec2(atan(v.z, v.x), asin(v.y)); uv *= invAtan; uv += 0.5; return uv; } void main() { vec2 uv = SampleSphericalMap(normalize(localPos)); // make sure to normalize localPos vec3 color = texture(equirectangularMap, uv).rgb; FragColor = vec4(color, 1.0); } 要将等距柱状投影图转换为立方体贴图，我们需要渲染一个（单位）立方体，并从内部将等距柱状图投影到立方体的每个面，先创建一张立方体贴图\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 unsigned int envCubemap; glGenTextures(1, \u0026amp;envCubemap); glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap); for (unsigned int i = 0; i \u0026lt; 6; ++i) { // note that we store each face with 16 bit floating point values glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 512, 512, 0, GL_RGB, GL_FLOAT, nullptr); } glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 对同一个立方体渲染六次，每次面对立方体的一个面，并用帧缓冲对象记录其结果\n1 2 3 4 5 6 7 8 unsigned int captureFBO, captureRBO; glGenFramebuffers(1, \u0026amp;captureFBO); glGenRenderbuffers(1, \u0026amp;captureRBO); glBindFramebuffer(GL_FRAMEBUFFER, captureFBO); glBindRenderbuffer(GL_RENDERBUFFER, captureRBO); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512); glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, captureRBO); 下面正式进入转换\n1 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 // 让摄像机对准6个轴方向 glm::mat4 captureProjection = glm::perspective(glm::radians(90.0f), 1.0f, 0.1f, 10.0f); glm::mat4 captureViews[] = { glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(-1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 1.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, -1.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 0.0f, 1.0f), glm::vec3(0.0f, -1.0f, 0.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 0.0f, -1.0f), glm::vec3(0.0f, -1.0f, 0.0f)) }; // convert HDR equirectangular environment map to cubemap equivalent equirectangularToCubemapShader.use(); equirectangularToCubemapShader.setInt(\u0026#34;equirectangularMap\u0026#34;, 0); equirectangularToCubemapShader.setMat4(\u0026#34;projection\u0026#34;, captureProjection); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, hdrTexture); glViewport(0, 0, 512, 512); // don\u0026#39;t forget to configure the viewport to the capture dimensions. glBindFramebuffer(GL_FRAMEBUFFER, captureFBO); for (unsigned int i = 0; i \u0026lt; 6; ++i) { equirectangularToCubemapShader.setMat4(\u0026#34;view\u0026#34;, captureViews[i]); // 把这 6 次渲染结果写入到立方体贴图的六个面上 glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, envCubemap, 0); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); renderCube(); // renders a 1x1 cube } glBindFramebuffer(GL_FRAMEBUFFER, 0); 还可以用这个立方体贴图来渲染天空盒 shader部分\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #version 330 core layout (location = 0) in vec3 aPos; uniform mat4 projection; uniform mat4 view; out vec3 localPos; void main() { localPos = aPos; mat4 rotView = mat4(mat3(view)); // remove translation from the view matrix vec4 clipPos = projection * rotView * vec4(localPos, 1.0); gl_Position = clipPos.xyww; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #version 330 core out vec4 FragColor; in vec3 localPos; uniform samplerCube environmentMap; void main() { vec3 envColor = texture(environmentMap, localPos).rgb; envColor = envColor / (envColor + vec3(1.0)); envColor = pow(envColor, vec3(1.0/2.2)); FragColor = vec4(envColor, 1.0); } 代码部分\n1 2 3 4 5 6 7 8 9 // 天空盒 glDepthFunc(GL_LEQUAL); skyboxShader.use(); skyboxShader.setMat4(\u0026#34;projection\u0026#34;, scene.camera-\u0026gt;getProjectionMatrix()); skyboxShader.setMat4(\u0026#34;view\u0026#34;, scene.camera-\u0026gt;getViewMatrix()); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap); scene.renderCube(); glDepthFunc(GL_LESS); 到这里就得到了立方体贴图和天空盒渲染\nirradance Map 预计算对于每个方向来说对半球进行积分的结果，当计算好后，每次需要漫反射，就可以通过法线方向得到漫反射值\n1 vec3 irradiance = texture(irradianceMap, N); 这一步的做法与生成cubemap的做法一致，只是片段着色器不一致。由于辐照度图对所有周围的辐射值取了平均值，因此它丢失了大部分高频细节，所以我们可以以较低的分辨率（32x32）存储，我可以把它渲染成天空盒来看看，基本没有场景信息了，所以不需要高分辨率 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 GLuint preComputer::computeIrradianceMap(GLuint envCubemap) { Shader irradianceMapShader = Shader(\u0026#34;shader/irradianceMap.vert\u0026#34;,\u0026#34;shader/irradianceMap.frag\u0026#34;); const glm::mat4 captureProjection = glm::perspective(glm::radians(90.0f), 1.0f, 0.1f, 10.0f); const glm::mat4 captureViews[] = { glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(-1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f), glm::vec3(0.0f, -1.0f, 0.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f), glm::vec3(0.0f, -1.0f, 0.0f)) }; GLuint FrameBuffer; GLuint RenderBuffer; glGenFramebuffers(1, \u0026amp;FrameBuffer); glGenRenderbuffers(1, \u0026amp;RenderBuffer); unsigned int irradianceMap; glGenTextures(1, \u0026amp;irradianceMap); glBindTexture(GL_TEXTURE_CUBE_MAP, irradianceMap); for (unsigned int i = 0; i \u0026lt; 6; ++i) { glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 32, 32, 0, GL_RGB, GL_FLOAT, nullptr); } glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glBindFramebuffer(GL_FRAMEBUFFER, FrameBuffer); glBindRenderbuffer(GL_RENDERBUFFER, RenderBuffer); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 32, 32); irradianceMapShader.use(); irradianceMapShader.setInt(\u0026#34;environmentMap\u0026#34;, 0); irradianceMapShader.setMat4(\u0026#34;projection\u0026#34;, captureProjection); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap); glViewport(0, 0, 32, 32); // don\u0026#39;t forget to configure the viewport to the capture dimensions. glBindFramebuffer(GL_FRAMEBUFFER, FrameBuffer); for (unsigned int i = 0; i \u0026lt; 6; ++i) { irradianceMapShader.setMat4(\u0026#34;view\u0026#34;, captureViews[i]); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, irradianceMap, 0); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); scene.renderCube(); } glBindFramebuffer(GL_FRAMEBUFFER, 0); } 具体的shader\n1 2 3 4 5 6 7 8 9 10 11 12 13 #version 330 core layout (location = 0) in vec3 aPos; out vec3 WorldPos; uniform mat4 projection; uniform mat4 view; void main() { WorldPos = aPos; gl_Position = projection * view * vec4(WorldPos, 1.0); } 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 #version 330 core out vec4 FragColor; in vec3 WorldPos; uniform samplerCube environmentMap; const float PI = 3.14159265359; void main() { vec3 N = normalize(WorldPos); vec3 irradiance = vec3(0.0); // tangent space calculation from origin point vec3 up = vec3(0.0, 1.0, 0.0); vec3 right = normalize(cross(up, N)); up = normalize(cross(N, right)); float sampleDelta = 0.025; float nrSamples = 0.0; for(float phi = 0.0; phi \u0026lt; 2.0 * PI; phi += sampleDelta) { for(float theta = 0.0; theta \u0026lt; 0.5 * PI; theta += sampleDelta) { // spherical to cartesian (in tangent space) vec3 tangentSample = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); // tangent space to world vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N; irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta); nrSamples++; } } irradiance = PI * irradiance * (1.0 / float(nrSamples)); FragColor = vec4(irradiance, 1.0); } prefilterMap 先创建一张cubemap\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 unsigned int prefilterMap; glGenTextures(1, \u0026amp;prefilterMap); glBindTexture(GL_TEXTURE_CUBE_MAP, prefilterMap); for (unsigned int i = 0; i \u0026lt; 6; ++i) { glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 128, 128, 0, GL_RGB, GL_FLOAT, nullptr); } glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glGenerateMipmap(GL_TEXTURE_CUBE_MAP); 需要的shader，渲染了5层的mipmap，使用法线分布函数进行重要性采样，预计算每个方向上的积分值\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 #version 330 core out vec4 FragColor; in vec3 WorldPos; uniform samplerCube environmentMap; // 当前贴图的粗糙度 uniform float roughness; const float PI = 3.14159265359; // ---------------------------------------------------------------------------- float DistributionGGX(vec3 N, vec3 H, float roughness) { float a = roughness*roughness; float a2 = a*a; float NdotH = max(dot(N, H), 0.0); float NdotH2 = NdotH*NdotH; float nom = a2; float denom = (NdotH2 * (a2 - 1.0) + 1.0); denom = PI * denom * denom; return nom / denom; } // ---------------------------------------------------------------------------- // http://holger.dammertz.org/stuff/notes_HammersleyOnHemisphere.html // efficient VanDerCorpus calculation. float RadicalInverse_VdC(uint bits) { bits = (bits \u0026lt;\u0026lt; 16u) | (bits \u0026gt;\u0026gt; 16u); bits = ((bits \u0026amp; 0x55555555u) \u0026lt;\u0026lt; 1u) | ((bits \u0026amp; 0xAAAAAAAAu) \u0026gt;\u0026gt; 1u); bits = ((bits \u0026amp; 0x33333333u) \u0026lt;\u0026lt; 2u) | ((bits \u0026amp; 0xCCCCCCCCu) \u0026gt;\u0026gt; 2u); bits = ((bits \u0026amp; 0x0F0F0F0Fu) \u0026lt;\u0026lt; 4u) | ((bits \u0026amp; 0xF0F0F0F0u) \u0026gt;\u0026gt; 4u); bits = ((bits \u0026amp; 0x00FF00FFu) \u0026lt;\u0026lt; 8u) | ((bits \u0026amp; 0xFF00FF00u) \u0026gt;\u0026gt; 8u); return float(bits) * 2.3283064365386963e-10; // / 0x100000000 } // 低差异序列 vec2 Hammersley(uint i, uint N) { return vec2(float(i)/float(N), RadicalInverse_VdC(i)); } // ---------------------------------------------------------------------------- // GGX法线分布函数下的重要性采样 vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness) { float a = roughness*roughness; float phi = 2.0 * PI * Xi.x; float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a*a - 1.0) * Xi.y)); float sinTheta = sqrt(1.0 - cosTheta*cosTheta); // from spherical coordinates to cartesian coordinates - halfway vector vec3 H; H.x = cos(phi) * sinTheta; H.y = sin(phi) * sinTheta; H.z = cosTheta; // from tangent-space H vector to world-space sample vector vec3 up = abs(N.z) \u0026lt; 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); vec3 tangent = normalize(cross(up, N)); vec3 bitangent = cross(N, tangent); vec3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z; return normalize(sampleVec); } // ---------------------------------------------------------------------------- void main() { vec3 N = normalize(WorldPos); // 以像素位置为法线方向（其实是在做 cube map 采样方向） vec3 R = N; // 反射向量设为法线 vec3 V = R; // 视线向量也设为法线（这是预计算贴图，所以默认观察方向和反射方向一致） const uint SAMPLE_COUNT = 1024u; // importance sample 的数量 vec3 prefilteredColor = vec3(0.0); float totalWeight = 0.0; for(uint i = 0u; i \u0026lt; SAMPLE_COUNT; ++i) { vec2 Xi = Hammersley(i, SAMPLE_COUNT); // 第 i 个低差异采样点 vec3 H = ImportanceSampleGGX(Xi, N, roughness); // 半程向量 H vec3 L = normalize(2.0 * dot(V, H) * H - V); // 入射光方向 L = reflect(-V, H) float NdotL = max(dot(N, L), 0.0); if(NdotL \u0026gt; 0.0) { // 计算重要性采样的 pdf float D = DistributionGGX(N, H, roughness); float NdotH = max(dot(N, H), 0.0); float HdotV = max(dot(H, V), 0.0); float pdf = D * NdotH / (4.0 * HdotV) + 0.0001; // 环境贴图的信息 float resolution = 512.0; // 原始 cube map 每个面宽度 float saTexel = 4.0 * PI / (6.0 * resolution * resolution); // 每个 texel 的立体角大小 float saSample = 1.0 / (float(SAMPLE_COUNT) * pdf + 0.0001); // 当前 sample 对应的立体角 // 根据 sample 的“模糊度”选择 mipmap 层级 float mipLevel = roughness == 0.0 ? 0.0 : 0.5 * log2(saSample / saTexel); // 对 cube map 进行 LOD 采样（选定 mip level） prefilteredColor += textureLod(environmentMap, L, mipLevel).rgb * NdotL; totalWeight += NdotL; } } prefilteredColor = prefilteredColor / totalWeight; // 归一化亮度 FragColor = vec4(prefilteredColor, 1.0); // 输出颜色 } 渲染代码\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 GLuint preComputer::computePrefilterMap(GLuint envCubemap) { const glm::mat4 captureProjection = glm::perspective(glm::radians(90.0f), 1.0f, 0.1f, 10.0f); const glm::mat4 captureViews[] = { glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(-1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f), glm::vec3(0.0f, -1.0f, 0.0f)), glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f), glm::vec3(0.0f, -1.0f, 0.0f)) }; glEnable(GL_TEXTURE_CUBE_MAP_SEAMLESS); Shader prefilterShader = Shader(\u0026#34;shader/prefiltermap.vert\u0026#34;,\u0026#34;shader/prefiltermap.frag\u0026#34;); GLuint FrameBuffer; GLuint RenderBuffer; glGenFramebuffers(1, \u0026amp;FrameBuffer); glGenRenderbuffers(1, \u0026amp;RenderBuffer); unsigned int prefilterMap; glGenTextures(1, \u0026amp;prefilterMap); glBindTexture(GL_TEXTURE_CUBE_MAP, prefilterMap); for (unsigned int i = 0; i \u0026lt; 6; ++i) { glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 128, 128, 0, GL_RGB, GL_FLOAT, nullptr); } glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glGenerateMipmap(GL_TEXTURE_CUBE_MAP); prefilterShader.use(); prefilterShader.setInt(\u0026#34;environmentMap\u0026#34;, 0); prefilterShader.setMat4(\u0026#34;projection\u0026#34;, captureProjection); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap); glBindFramebuffer(GL_FRAMEBUFFER, FrameBuffer); unsigned int maxMipLevels = 5; // 单独生成5张map，对应不同的粗糙度，来组成mipmap for (unsigned int mip = 0; mip \u0026lt; maxMipLevels; ++mip) { // reisze framebuffer according to mip-level size. unsigned int mipWidth = 128 * std::pow(0.5, mip); unsigned int mipHeight = 128 * std::pow(0.5, mip); glBindRenderbuffer(GL_RENDERBUFFER, RenderBuffer); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mipWidth, mipHeight); glViewport(0, 0, mipWidth, mipHeight); float roughness = (float)mip / (float)(maxMipLevels - 1); prefilterShader.setFloat(\u0026#34;roughness\u0026#34;, roughness); for (unsigned int i = 0; i \u0026lt; 6; ++i) { prefilterShader.setMat4(\u0026#34;view\u0026#34;, captureViews[i]); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, prefilterMap, mip); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); scene.renderCube(); } } glBindFramebuffer(GL_FRAMEBUFFER, 0); return prefilterMap; } 用prefilterMap渲染天空盒的结果 LUT 现在渲染方程就剩最后一部分积分的预计算了 通过一系列变换变成了 这个式子只和观察方向与法线的夹角以及粗糙都有关，所以用一张2D贴图存储，采样计算积分仍然使用GGX重要性采样\n1 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 vec2 IntegrateBRDF(float NdotV, float roughness) { vec3 V; V.x = sqrt(1.0 - NdotV*NdotV); V.y = 0.0; V.z = NdotV; float A = 0.0; float B = 0.0; vec3 N = vec3(0.0, 0.0, 1.0); const uint SAMPLE_COUNT = 1024u; for(uint i = 0u; i \u0026lt; SAMPLE_COUNT; ++i) { vec2 Xi = Hammersley(i, SAMPLE_COUNT); vec3 H = ImportanceSampleGGX(Xi, N, roughness); vec3 L = normalize(2.0 * dot(V, H) * H - V); float NdotL = max(L.z, 0.0); float NdotH = max(H.z, 0.0); float VdotH = max(dot(V, H), 0.0); if(NdotL \u0026gt; 0.0) { float G = GeometrySmith(N, V, L, roughness); float G_Vis = (G * VdotH) / (NdotH * NdotV); float Fc = pow(1.0 - VdotH, 5.0); A += (1.0 - Fc) * G_Vis; B += Fc * G_Vis; } } A /= float(SAMPLE_COUNT); B /= float(SAMPLE_COUNT); return vec2(A, B); } // ---------------------------------------------------------------------------- void main() { vec2 integratedBRDF = IntegrateBRDF(TexCoords.x, TexCoords.y); FragColor = integratedBRDF; } 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 GLuint preComputer::computeLutMap(GLuint envCubemap) { Shader lutShader = Shader(\u0026#34;shader/lut.vert\u0026#34;,\u0026#34;shader/lut.frag\u0026#34;); GLuint brdfLUTTexture; glGenTextures(1, \u0026amp;brdfLUTTexture); GLuint FrameBuffer; GLuint RenderBuffer; glGenFramebuffers(1, \u0026amp;FrameBuffer); glGenRenderbuffers(1, \u0026amp;RenderBuffer); // pre-allocate enough memory for the LUT texture. glBindTexture(GL_TEXTURE_2D, brdfLUTTexture); glTexImage2D(GL_TEXTURE_2D, 0, GL_RG16F, 512, 512, 0, GL_RG, GL_FLOAT, 0); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glBindFramebuffer(GL_FRAMEBUFFER, FrameBuffer); glBindRenderbuffer(GL_RENDERBUFFER, RenderBuffer); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, brdfLUTTexture, 0); glViewport(0, 0, 512, 512); lutShader.use(); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); scene.renderQuad(); glBindFramebuffer(GL_FRAMEBUFFER, 0); return brdfLUTTexture; } 整合 到这里就以及有了所有预计算贴图，下面就是把这些贴图整合到PBR渲染管线，下面给出一个支持一个光源的IBL+PBR渲染Shader\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 #version 330 core out vec4 FragColor; in vec3 T; in vec3 B; in vec3 N; in vec3 WorldPos; in vec2 TexCoords; uniform vec3 camPos; uniform vec3 lightPos; uniform vec3 lightColor; // IBL uniform samplerCube irradianceMap; uniform samplerCube prefilterMap; uniform sampler2D lutMap; // Material maps uniform sampler2D texture_albedo; uniform sampler2D texture_normal; uniform sampler2D texture_metallic; uniform sampler2D texture_roughness; uniform sampler2D texture_ao; uniform sampler2D texture_emission; // material parameters //uniform vec3 albedo; //uniform float metallic; //uniform float roughness; //uniform float ao; const float PI = 3.14159265359; // Convert normal map to world space vec3 getNormalFromMap() { vec3 n = normalize(N); vec3 t = normalize(T - dot(T, n) * n); vec3 b = normalize(cross(n, t)); mat3 TBN = mat3(t, b, n); vec3 normalTS = texture(texture_normal, TexCoords).rgb; normalTS = normalTS * 2.0 - 1.0; return normalize(TBN * normalTS); } // GGX NDF float DistributionGGX(vec3 N, vec3 H, float roughness) { float a = roughness*roughness; float a2 = a*a; float NdotH = max(dot(N, H), 0.0); float NdotH2 = NdotH*NdotH; float nom = a2; float denom = (NdotH2 * (a2 - 1.0) + 1.0); denom = PI * denom * denom; return nom / denom; } // Geometry: Schlick-GGX float GeometrySchlickGGX(float NdotV, float roughness) { float r = (roughness + 1.0); float k = (r*r) / 8.0; float nom = NdotV; float denom = NdotV * (1.0 - k) + k; return nom / denom; } float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) { float NdotV = max(dot(N, V), 0.0); float NdotL = max(dot(N, L), 0.0); float ggx2 = GeometrySchlickGGX(NdotV, roughness); float ggx1 = GeometrySchlickGGX(NdotL, roughness); return ggx1 * ggx2; } // Fresnel: Schlick Approximation vec3 fresnelSchlick(float cosTheta, vec3 F0) { return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); } vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) { return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); } void main() { // 纹理采样 vec3 albedo = pow(texture(texture_albedo, TexCoords).rgb, vec3(2.2)); float metallic = texture(texture_metallic, TexCoords).r; float roughness = texture(texture_roughness, TexCoords).r; float ao = texture(texture_ao, TexCoords).r; vec3 emission = pow(texture(texture_emission, TexCoords).rgb, vec3(2.2)); // gamma correct // vec3 emission = vec3(0,0,0); // 参数准备 vec3 N = getNormalFromMap(); // 法线 vec3 V = normalize(camPos - WorldPos); // 视线方向 vec3 R = reflect(-V, N); // 反射方向 vec3 F0 = mix(vec3(0.04), albedo, metallic); // 菲涅尔反射 F0 vec3 L = normalize(lightPos - WorldPos); // 光源方向 vec3 H = normalize(V + L); // 半程向量 // 光照部分 float NDF = DistributionGGX(N, H, roughness); float G = GeometrySmith(N, V, L, roughness); vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); vec3 nominator = NDF * G * F; float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.001; vec3 specular = nominator / denominator; vec3 kS = F; vec3 kD = (1.0 - kS) * (1.0 - metallic); float NdotL = max(dot(N, L), 0.0); vec3 radiance = lightColor; vec3 Lo = (kD * albedo / PI + specular) * radiance * NdotL; // IBL ambient const float MAX_REFLECTION_LOD = 4.0; vec3 prefilteredColor = textureLod(prefilterMap, R, roughness * MAX_REFLECTION_LOD).rgb; F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); vec2 lut = texture(lutMap, vec2(max(dot(N, V), 0.0), roughness)).rg; vec3 specularIBL = prefilteredColor * (F * lut.x + lut.y); vec3 irradiance = texture(irradianceMap, N).rgb; vec3 diffuse = irradiance * albedo / PI; // learnOpenGL中并没有 /PI，实践下来除以效果更好 vec3 ambient = (kD * diffuse + specularIBL) * ao; // 光照相加 vec3 color = ambient + Lo + emission; // toneMapping color = color / (color + vec3(1.0)); // 伽马矫正 color = pow(color, vec3(1.0 / 2.2)); FragColor = vec4(color, 1.0); } 效果展示 切换为ACES Filmic Tone Mapping 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 vec3 RRTAndODTFit(vec3 v) { vec3 a = v * (v + 0.0245786) - 0.000090537; vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081; return a / b; } vec3 ACESFilmToneMapping(vec3 color) { // 适当的曝光缩放，可以视为手动曝光调整（可调参数） color *= 0.6; // ACES tone mapping 曲线 color = RRTAndODTFit(color); // Clamp 到 [0, 1] return clamp(color, 0.0, 1.0); } ","date":"2025-10-19T14:13:50+08:00","image":"https://sdpyy1.github.io/bbcca62cc5b24dc29f8a6396d10b8572.png","permalink":"https://sdpyy1.github.io/p/openglrender%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%951%E5%9F%BA%E4%BA%8E%E5%9B%BE%E5%83%8F%E7%9A%84%E5%85%89%E7%85%A7ibl/","title":"OpenGLRender开发记录（1）：基于图像的光照（IBL）"},{"content":"作业介绍 首先看一下整体渲染流程\n绘制光源 绘制shadowMap diffuse、depth、normal、shadow、worldPos五个GBuffer信息 最后利用上边的信息来真正进行渲染 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 // Draw light light.meshRender.mesh.transform.translate = light.entity.lightPos; light.meshRender.draw(this.camera, null, updatedParamters); // Shadow pass gl.bindFramebuffer(gl.FRAMEBUFFER, light.entity.fbo); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); for (let i = 0; i \u0026lt; this.shadowMeshes.length; i++) { this.shadowMeshes[i].draw(this.camera, light.entity.fbo, updatedParamters); // this.shadowMeshes[i].draw(this.camera); } // return; // Buffer pass gl.bindFramebuffer(gl.FRAMEBUFFER, this.camera.fbo); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); for (let i = 0; i \u0026lt; this.bufferMeshes.length; i++) { this.bufferMeshes[i].draw(this.camera, this.camera.fbo, updatedParamters); // this.bufferMeshes[i].draw(this.camera); } // return // Camera pass for (let i = 0; i \u0026lt; this.meshes.length; i++) { this.meshes[i].draw(this.camera, null, updatedParamters); } 直接光照 EvalDiffuse(wi, wo, uv) 通过入射方向、出射方向、漫反射率纹理图uv坐标来计算BRDF的返回值，因为是计算diffuse的BRDF，所以它是一个常数 分母π保证了反射光的总能量不超过入射光能量\n1 2 3 4 5 6 7 vec3 EvalDiffuse(vec3 wi, vec3 wo, vec2 uv) { vec3 albedo = GetGBufferDiffuse(uv); vec3 normal = GetGBufferNormalWorld(uv); // 这里把cos项和BRDF项放在一起了 float cosTheta = max(0,dot(normal,wi)); return albedo * cosTheta * INV_PI; } 这样就得到了渲染方程中的BRDF项\nEvalDirectionalLight(vec2 uv) 这里需要计算一个着色点的直接光照项（需要通过shadowMap来考虑阴影）\n1 2 3 4 vec3 EvalDirectionalLight(vec2 uv) { vec3 Le = GetGBufferuShadow(uv) * uLightRadiance; return Le; } 其中GetGBufferuShadow方法返回的就是Visbility项，它在上一轮渲染中存储了每个像素的可见性信息\n1 2 3 4 float GetGBufferuShadow(vec2 uv) { float visibility = texture2D(uGShadow, uv).x; return visibility; } 最后修改main函数，它还做了伽马矫正\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void main() { float s = InitRand(gl_FragCoord.xy); vec3 L = vec3(0.0); vec3 color = vec3(0.0); vec3 worldPos = vPosWorld.xyz; vec2 screenUV = GetScreenCoordinate(vPosWorld.xyz); vec3 wi = normalize(uLightDir); vec3 wo = normalize(uCameraPos - worldPos); // 直接光照 L = EvalDiffuse(wi, wo, screenUV) * EvalDirectionalLight(screenUV); // gamma矫正 color = pow(clamp(L, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2)); gl_FragColor = vec4(vec3(color.rgb), 1.0); } 我的电脑是mac，根据网上只有mac电脑会出下如下问题，根据视角移动会不断变换出错的位置 我们先把可见性设置为1，避免阴影生成，看看是什么情况 举个例子：假设地板深度值为0.5001，上方模型为0.5002。在16位深度缓冲区中，两者可能被四舍五入为相同值（如0.500），导致GPU随机选择显示其中一个67。此时地板可能因浮点舍入误差被误判为更小Z值，从而错误覆盖模型。\nNDC坐标下的Z值并不是线性的，越靠近近平面的地方，精度越高，远的地方精度越低，参考下图 所以说当我把摄像机离得特别近时，这种问题慢慢就得到了缓解，因为接近近平面精度比较高 所以说这种问题的一种解决思路就是调整远近平面的距离，越小，精度高的范围越大，出现z-fighting可能性越低\n1 2 3 // Add camera // const camera = new THREE.PerspectiveCamera(75, gl.canvas.clientWidth / gl.canvas.clientHeight, 1e-3, 1000); const camera = new THREE.PerspectiveCamera(75, gl.canvas.clientWidth / gl.canvas.clientHeight, 5e-2, 1e2); 为了验证这一说法的正确性，我们拉远镜头，就看出来这种问题又出现了，说明远处z值精度小 不过这种解决方案只适合玩具，调整NF参数属于是把整个项目的东西都改了，还有一种解决思路就是在有这种重叠放置的地方进行手动偏移\n间接光照 bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) 这一步需要实现linear RayMarch，通过一条光线，求出击中点的坐标 基本思路就是从着色点沿着光照方向每次向前一小步，查看这一点对应的深度值，并于深度缓冲中的深度值做比较，如果这一点深度值比深度缓冲中深度值大了，说明他已经处于可见物体的内部\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) { const int totalStepTimes = 60; const float threshold = 0.0001; float step = 0.05; vec3 stepDir = normalize(dir) * step; vec3 curPos = ori; for(int i = 0; i \u0026lt; totalStepTimes; i++) { vec2 screenUV = GetScreenCoordinate(curPos); float rayDepth = GetDepth(curPos); float gBufferDepth = GetGBufferDepth(screenUV); // 已经在可见物体内部 if(rayDepth \u0026gt; gBufferDepth + threshold){ hitPos = curPos; return true; } curPos += stepDir; } } 现在已经有方法对光线求交了，下面就来实现间接光照\n1 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 void main() { float s = InitRand(gl_FragCoord.xy); vec3 worldPos = vPosWorld.xyz; vec2 screenUV = GetScreenCoordinate(vPosWorld.xyz); vec3 wi = normalize(uLightDir); vec3 wo = normalize(uCameraPos - worldPos); vec3 normal = GetGBufferNormalWorld(screenUV); // 着色点的直接光照 vec3 L = vec3(0.0); L = EvalDiffuse(wi, wo, screenUV) * EvalDirectionalLight(screenUV); // 着色点的间接光照 vec3 L_ind = vec3(0.0); for(int i = 0; i \u0026lt; SAMPLE_NUM; i++){ float pdf; vec3 localDir = SampleHemisphereCos(s, pdf); vec3 b1, b2; // 通过空间法线得到两个切线，从而建立切线空间坐标系 LocalBasis(normal, b1, b2); // 通过BTN矩阵将局部坐标系转为世界坐标系 vec3 dir = normalize(mat3(b1, b2, normal) * localDir); vec3 hit_pos = vec3(0.0); // 向采样方向发出光线 if(RayMarch(worldPos, dir, hit_pos)){ vec2 hitScreenUV = GetScreenCoordinate(hit_pos); // 通过蒙特卡洛积分来近似渲染方程的积分值 L_ind += EvalDiffuse(dir, wo, screenUV) / pdf * EvalDiffuse(wi, dir, hitScreenUV) * EvalDirectionalLight(hitScreenUV); } } L_ind /= float(SAMPLE_NUM); L = L + L_ind; vec3 color = pow(clamp(L, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2)); gl_FragColor = vec4(vec3(color.rgb), 1.0); } 方块场景 洞穴场景渲染 只渲染间接光 所以SSR我理解也是在做光线追踪，但只利用了屏幕空间的信息，也就是真正被渲染的位置的信息，被覆盖掉的着色点是不会考虑的，这当然要比直接做光线追踪快很多。课程后边有实时光线渲染，目前还不知道如何实现的。\n目前从方块场景能看出一些问题 按照目前的一次弹射渲染逻辑，理论上阴影的颜色就应该是黑色（因为即使有击中点，这一点计算出来的直接光照也必然是黑色），而现在有点漏光了 说明它判断击中点时，判断了B4位置的颜色，而实际上，它直接穿过了模型，打到了模型的正面，说明步长太大了。参考其他博客的解决思路\nhttps://zhuanlan.zhihu.com/p/668194020 代码的思路跟前面的差不多，每一次步进时，判断下一步位置的深度与gBuffer的深度的关系，如果下一步的位置在gBuffer的前面（nextDepth\u0026lt;gDepth），则可以步进。如果下一步的深度没有gBuffer的深，就判断一下深度相差多少，有没有给定的阈值大。如果比阈值大，那么就直接返回 false ，否则，这个时候就可以执行SSR了。先让当前位置步进一个step，返回给 hitPos ，然后返回真。\n1 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 bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) { const float EPS = 1e-2; const int totalStepTimes = 20; const float threshold = 0.1; bool result = false, firstIn = false; float step = 0.8; vec3 curPos = ori; vec3 nextPos; for(int i = 0; i \u0026lt; totalStepTimes; i++) { nextPos = curPos+dir*step; vec2 uvScreen = GetScreenCoordinate(curPos); if(any(bvec4(lessThan(uvScreen, vec2(0.0)), greaterThan(uvScreen, vec2(1.0))))) break; if(GetDepth(nextPos) \u0026lt; GetGBufferDepth(GetScreenCoordinate(nextPos))){ curPos += dir * step; if(firstIn) step *= 0.5; continue; } firstIn = true; if(step \u0026lt; EPS){ float s1 = GetGBufferDepth(GetScreenCoordinate(curPos)) - GetDepth(curPos) + EPS; float s2 = GetDepth(nextPos) - GetGBufferDepth(GetScreenCoordinate(nextPos)) + EPS; if(s1 \u0026lt; threshold \u0026amp;\u0026amp; s2 \u0026lt; threshold){ hitPos = curPos + 2.0 * dir * step * s1 / (s1 + s2); result = true; } break; } if(firstIn) step *= 0.5; } return result; } 使用大佬的优化算法，帧数一下就起来了\n下面再介绍一下光线求交的加速方法，就是为深度图设置MipMap，上一层存入下一层4个像素的最小深度，也就是离屏幕更近的距离，如果一条光线在上一层中比最小距离更近，那它肯定不会在下一层中相交于物体，这样就可以省去很多if\n具体的代码需要涉及到OpenGL如何生成这样的纹理，或者可能根本不支持这种mipmap，毕竟默认的mipmap并不是存最小值，而是平均值，可能需要自己写入缓存然后保存FBO，然后把它当作一张纹理来使用。我就不写了，目前阶段以学习理论为主\n","date":"2025-10-19T14:05:15+08:00","image":"https://sdpyy1.github.io/8a13212a6c85436f925ee5f06d474564.png","permalink":"https://sdpyy1.github.io/p/games202-%E9%AB%98%E8%B4%A8%E9%87%8F%E5%AE%9E%E6%97%B6%E6%B8%B2%E6%9F%93assignment-3/","title":"GAMES202 高质量实时渲染（Assignment 3）"},{"content":"作业介绍 物体在不同光照下的表现不同，PRT(Precomputed Radiance Transfer) 是一个计算物体在不同光照下表现的方法。光线在一个环境中，会经历反射，折射，散射，甚至还会物体的内部进行散射。为了模拟具有真实感的渲染结果，传统的Path Tracing 方法需要考虑来自各个方向的光线、所有可能的传播形式并且收敛速度极慢。PRT 通过一种预计算方法，该方法在离线渲染的 Path Tracing 工具链中预计算 lighting 以及 light transport 并将它们用球谐函数拟合后储存，这样就将时间开销转移到了离线中。最后通过使用这些预计算好的数据，我们可以轻松达到实时渲染严苛的时间要求，同时渲染结果可以呈现出全局光照的效果。\nPRT 方法存在的限制包括： • 不能计算随机动态场景的全局光照 • 场景中物体不可变动\n本次作业的工作主要分为两个部分：cpp 端的离线预计算部分以及在 WebGL框架上使用预计算数据部分\nPRT课上最终得出的结论是对渲染方程的计算，可以先把光照和其余部分分别计算球谐展开后系数相乘（针对BRDF是diffuse的情况），所以我们只需要针对光照算球谐展开的系数，然后针对其余部分算一个球谐展开的系数，传递给顶点着色器后相乘就是顶点的着色\n环境光贴图预计算 要做的就是把L(wi)项用球谐函数表示，因为球谐函数都一样，不一样的只有系数，所以只需要预计算出系数，系数求法如下，针对球谐函数的任何一项求他的系数都是算一个积分 根据作业提示，需要完成函数，输入为天空盒的6个面图片\n1 2 3 4 std::vector\u0026lt;Eigen::Array3f\u0026gt; PrecomputeCubemapSH(const std::vector\u0026lt;std::unique_ptr\u0026lt;float[]\u0026gt;\u0026gt; \u0026amp;images, const int \u0026amp;width, const int \u0026amp;height, const int \u0026amp;channel) { 下面这一步是把6张贴图每个像素的方向向量都存起来了\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 std::vector\u0026lt;Eigen::Vector3f\u0026gt; cubemapDirs; cubemapDirs.reserve(6 * width * height); for (int i = 0; i \u0026lt; 6; i++) { Eigen::Vector3f faceDirX = cubemapFaceDirections[i][0]; Eigen::Vector3f faceDirY = cubemapFaceDirections[i][1]; Eigen::Vector3f faceDirZ = cubemapFaceDirections[i][2]; for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { float u = 2 * ((x + 0.5) / width) - 1; float v = 2 * ((y + 0.5) / height) - 1; Eigen::Vector3f dir = (faceDirX * u + faceDirY * v + faceDirZ).normalized(); cubemapDirs.push_back(dir); } } } 接着对系数数组初始化\n1 2 3 4 5 // 表示球谐系数的个数 constexpr int SHNum = (SHOrder + 1) * (SHOrder + 1); std::vector\u0026lt;Eigen::Array3f\u0026gt; SHCoeffiecents(SHNum); for (int i = 0; i \u0026lt; SHNum; i++) SHCoeffiecents[i] = Eigen::Array3f(0); 最后遍历每个方向向量进行计算系数\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 for (int i = 0; i \u0026lt; 6; i++) { for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { // TODO: here you need to compute light sh of each face of cubemap of each pixel // TODO: 此处你需要计算每个像素下cubemap某个面的球谐系数 ![请添加图片描述](f30ab4af6a6c4b1d9ba4c82953bd31d1.png) Eigen::Vector3f dir = cubemapDirs[i * width * height + y * width + x]; int index = (y * width + x) * channel; Eigen::Array3f Le(images[i][index + 0], images[i][index + 1], images[i][index + 2]); } } } 计算方法就是遍历每一个像素，通过黎曼积分的方法来说，每个像素点都对每个球谐函数的系数有贡献\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 for (int i = 0; i \u0026lt; 6; i++) { for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { // TODO: here you need to compute light sh of each face of cubemap of each pixel // TODO: 此处你需要计算每个像素下cubemap某个面的球谐系数 Eigen::Vector3f dir = cubemapDirs[i * width * height + y * width + x]; int index = (y * width + x) * channel; // 当前像素的RGB值 Eigen::Array3f Le(images[i][index + 0], images[i][index + 1], images[i][index + 2]); // 计算当前像素的面积 float delta_wi = CalcArea(x, y, width, height); Eigen::Vector3d _dir(Eigen::Vector3d(dir[0], dir[1], dir[2]).normalized());//这里dir要变成Eigen::Vector3d类型 // 计算当前像素点对每个基函数系数的黎曼积分求法的贡献 for(int l = 0;l \u0026lt; SHNum; l++){ for(int m = -l; m \u0026lt;= l; m++){ SHCoeffiecents[sh::GetIndex(l,m)] += Le * sh::EvalSH(l,m,_dir)*delta_wi; } } } } } 对于作业提到的伽马矫正，可以参考我之前的博客伽马矫正\n传输项的预计算 对于漫反射传输项来说，分为 unshadowed, shadowed, interreflection 三 种情况，我们将分别计算这三种情况的漫反射传输球谐系数。\nDiffuse unshadowed 这种情况下渲染方程的BRDF项为常数，此时渲染方程为 Li项已经处理掉了，就剩下max()项了 作业中只需要写出transport部分在给定一个方向时的值\n1 2 3 4 5 6 if (m_Type == Type::Unshadowed) { // TODO: here you need to calculate unshadowed transport term of a given direction // TODO: 此处你需要计算给定方向下的unshadowed传输项球谐函数值 return 0; } 这里就只剩一个点乘和max了\n1 2 3 4 5 6 7 float dot_product = wi.dot(n); if (m_Type == Type::Unshadowed) { // TODO: here you need to calculate unshadowed transport term of a given direction // TODO: 此处你需要计算给定方向下的unshadowed传输项球谐函数值 return dot_product \u0026gt; 0 ? dot_product : 0; } Indoor的数据\n1 2 3 4 5 6 7 8 9 0.518558 0.510921 0.498186 -0.0139227 -0.0198673 -0.0233177 -0.0229861 -0.0361469 -0.0237983 0.0263383 0.0681837 0.0585552 -0.0508792 -0.0607283 -0.0570984 0.0515054 0.035726 0.0207611 0.0147266 0.0112063 -0.026747 0.00411617 0.0257427 0.0428588 0.0642155 0.0399902 0.0190308 每一行代表一个基函数的参数，可以理解为把原光照函数投影到某一个基函数后的RGB分量分别为多少\nDiffuse shadowed 相对于unshadowed，就多出来一项Visibility\n1 2 3 4 5 6 7 // 从顶点位置发射一条光线，与场景相交说明被遮挡了 if(dot_product \u0026gt; 0.0f \u0026amp;\u0026amp; !scene-\u0026gt;rayIntersect(Ray3f(v, wi.normalized()))) { return dot_product; }else{ return 0.0f; } 当定义好函数后调用了\n1 auto shCoeff = sh::ProjectFunction(SHOrder, shFunc, m_SampleCount); 这行代码根据SH的阶数、被展开的函数、采样数来得到展开后SH的系数 到这里 光照项和转移项都分别计算了它们的SH展开的系数并存储在txt文件中（通过跑该程序）\n1 2 3 4 5 6 7905 0.213508 0.153329 0.206834 -0.0845127 -0.060769 0.144391 0.0588847 -0.0591535 -0.0178413 0.219123 0.147477 0.190788 -0.134417 -0.0519802 0.135719 0.0171978 -0.101493 0.0164001 0.206635 0.160885 0.201914 -0.0758193 -0.0472086 0.167402 0.0471802 -0.052247 -0.00859244 0.185821 0.153003 0.162114 -0.119748 -0.0931989 0.143295 0.00899377 -0.0984071 -0.0184536 0.206635 0.160885 0.201914 -0.0758193 -0.0472086 0.167402 0.0471802 -0.052247 -0.00859244 每一行代表一个顶点的球谐展开系数。因为T部分不仅与入射方向有关，也与顶点的具体位置有关，所以每固定一个顶点，球谐展开系数都是不一样的\nDiffuse Inter-reflection(bonus) 这里就需要考虑光线的多次弹射，渲染方程变成 计算一个顶点的系数时，不仅考虑到来自环境光的光照，还考虑来自别的地方弹射过来的光的影响，仿照光线追踪的写法，从着色点射出采样光线，若击中物体，则把光线反过来求出它对着色点的贡献（如果递归的写就可以求出击中物体的值，递归到最后一层就是本身着色点的值）\n1 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 57 std::unique_ptr\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; computeInterreflectionSH(Eigen::MatrixXf* directTSHCoeffs, const Point3f\u0026amp; pos, const Normal3f\u0026amp; normal, const Scene* scene, int bounces) { std::unique_ptr\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; coeffs(new std::vector\u0026lt;double\u0026gt;()); coeffs-\u0026gt;assign(SHCoeffLength, 0.0); if (bounces \u0026gt; m_Bounce) return coeffs; const int sample_side = static_cast\u0026lt;int\u0026gt;(floor(sqrt(m_SampleCount))); std::random_device rd; std::mt19937 gen(rd()); std::uniform_real_distribution\u0026lt;\u0026gt; rng(0.0, 1.0); for (int t = 0; t \u0026lt; sample_side; t++) { for (int p = 0; p \u0026lt; sample_side; p++) { double alpha = (t + rng(gen)) / sample_side; double beta = (p + rng(gen)) / sample_side; double phi = 2.0 * M_PI * beta; double theta = acos(2.0 * alpha - 1.0); //这边模仿ProjectFunction函数写 Eigen::Array3d d = sh::ToVector(phi, theta); const auto wi = Vector3f(d.x(), d.y(), d.z()); double H = wi.normalized().dot(normal); Intersection its; if (H \u0026gt; 0.0 \u0026amp;\u0026amp; scene-\u0026gt;rayIntersect(Ray3f(pos, wi.normalized()), its)) { MatrixXf normals = its.mesh-\u0026gt;getVertexNormals(); Point3f idx = its.tri_index; Point3f hitPos = its.p; Vector3f bary = its.bary; Normal3f hitNormal = Normal3f(normals.col(idx.x()).normalized() * bary.x() + normals.col(idx.y()).normalized() * bary.y() + normals.col(idx.z()).normalized() * bary.z()) .normalized(); auto nextBouncesCoeffs = computeInterreflectionSH(directTSHCoeffs, hitPos, hitNormal, scene, bounces + 1); for (int i = 0; i \u0026lt; SHCoeffLength; i++) { auto interpolateSH = (directTSHCoeffs-\u0026gt;col(idx.x()).coeffRef(i) * bary.x() + directTSHCoeffs-\u0026gt;col(idx.y()).coeffRef(i) * bary.y() + directTSHCoeffs-\u0026gt;col(idx.z()).coeffRef(i) * bary.z()); (*coeffs)[i] += (interpolateSH + (*nextBouncesCoeffs)[i]) * H; } } } } for (unsigned int i = 0; i \u0026lt; coeffs-\u0026gt;size(); i++) { (*coeffs)[i] /= sample_side * sample_side; } return coeffs; } 1 2 3 4 5 6 7 8 9 10 11 for (int i = 0; i \u0026lt; mesh-\u0026gt;getVertexCount(); i++) { const Point3f\u0026amp; v = mesh-\u0026gt;getVertexPositions().col(i); const Normal3f\u0026amp; n = mesh-\u0026gt;getVertexNormals().col(i).normalized(); auto indirectCoeffs = computeInterreflectionSH(\u0026amp;m_TransportSHCoeffs, v, n, scene, 1); for (int j = 0; j \u0026lt; SHCoeffLength; j++) { m_TransportSHCoeffs.col(i).coeffRef(j) += (*indirectCoeffs)[j]; } std::cout \u0026lt;\u0026lt; \u0026#34;computing interreflection light sh coeffs, current vertex idx: \u0026#34; \u0026lt;\u0026lt; i \u0026lt;\u0026lt; \u0026#34; total vertex idx: \u0026#34; \u0026lt;\u0026lt; mesh-\u0026gt;getVertexCount() \u0026lt;\u0026lt; std::endl; } 实时球谐光照计算 这里我不展示如何跑通代码，只展示主要的逻辑点。跑通代码可以参考博客：https://zhuanlan.zhihu.com/p/596050050\n对于预计算数据使用就是在顶点着色器中，要求一个顶点的着色，就要把光照项的每一个系数与T项对应的系数相乘后相加即可\n从下面代码可以看出，三个颜色通道单独计算\n1 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 //prtVertex.glsl attribute vec3 aVertexPosition; attribute vec3 aNormalPosition; attribute mat3 aPrecomputeLT; uniform mat4 uModelMatrix; uniform mat4 uViewMatrix; uniform mat4 uProjectionMatrix; uniform mat3 uPrecomputeL[3]; varying highp vec3 vNormal; varying highp mat3 vPrecomputeLT; varying highp vec3 vColor; float L_dot_LT(mat3 PrecomputeL, mat3 PrecomputeLT) { vec3 L_0 = PrecomputeL[0]; vec3 L_1 = PrecomputeL[1]; vec3 L_2 = PrecomputeL[2]; vec3 LT_0 = PrecomputeLT[0]; vec3 LT_1 = PrecomputeLT[1]; vec3 LT_2 = PrecomputeLT[2]; return dot(L_0, LT_0) + dot(L_1, LT_1) + dot(L_2, LT_2); } void main(void) { // 无实际作用，避免aNormalPosition被优化后产生警告 vNormal = (uModelMatrix * vec4(aNormalPosition, 0.0)).xyz; for(int i = 0; i \u0026lt; 3; i++) { vColor[i] = L_dot_LT(aPrecomputeLT, uPrecomputeL[i]); } gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertexPosition, 1.0); } 以R通道举例 一行（顶点的系数）乘一列（环境光贴图系数的R通道）结果作为着色点的R通道值\n至于还有一个作业要做旋转。 我的理解是如果环境光贴图进行了旋转，其实修改的就只是环境光贴图的球谐展开的系数，其他的不会变，而且因为球谐函数的特性，很容易就能求旋转后的系数。先理解了就行\n","date":"2025-10-19T14:04:46+08:00","image":"https://sdpyy1.github.io/1e99f72904054ad5bbfe172340ccd69e.png","permalink":"https://sdpyy1.github.io/p/games202-%E9%AB%98%E8%B4%A8%E9%87%8F%E5%AE%9E%E6%97%B6%E6%B8%B2%E6%9F%93assignment-2/","title":"GAMES202 高质量实时渲染（Assignment 2）"},{"content":"Homework1 shadow Map 首先需要完成MVP矩阵的构造，在这里的mvp用来表示如果一个模型在shadowmap视角下的位置\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 CalcLightMVP(translate, scale) { let lightMVP = mat4.create(); let modelMatrix = mat4.create(); let viewMatrix = mat4.create(); let projectionMatrix = mat4.create(); // Model transform mat4.translate(modelMatrix,modelMatrix,translate); mat4.scale(modelMatrix,modelMatrix,scale); // View transform mat4.lookAt(viewMatrix, this.lightPos, this.focalPoint, this.lightUp); // Projection transform mat4.ortho(projectionMatrix, -100,100,-100,100,0.1,400); // 实测far要得400可以覆盖到整个平面 mat4.multiply(lightMVP, projectionMatrix, viewMatrix); mat4.multiply(lightMVP, lightMVP, modelMatrix); return lightMVP; } 他在顶点着色器中使用，目的是传递每个顶点位置在光源视角下坐标 vPositionFromLight = uLightMVP * vec4(aVertexPosition, 1.0); 在片段着色器中，先实现比较的函数\n1 2 3 4 5 6 7 8 9 10 11 12 float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){ // shadowmap中存储的深度 float lightDepth = unpack(texture2D(shadowMap, shadowCoord.xy)); // 着色点深度 float shadowDepth = shadowCoord.z; float visibility = 1.0; // 被挡住了 if (shadowDepth \u0026gt; lightDepth) { visibility = 0.0; } return visibility; } 在主函数中，需要先对光源坐标处理一下，因为它是经过透视投影处理后的NDC坐标，而shadowMap上取值其实需要的是UV坐标（0，1）之间，所以需要先转到[0,1]之间 ⚠️：透视除法在透视投影时才需要，我看别的博客都写了，其实是不需要的\n1 2 3 4 5 float visibility = 1.0; // 透视投影时才需要 // vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w; vec3 shadowCoord = (vPositionFromLight.xyz+1.0)/2.0; visibility = useShadowMap(uShadowMap, vec4(shadowCoord, 1.0)); 居然没出现自阴影问题，我们手动创建一个，首先光源位置修改在engine.js中lightPos\n1 2 3 4 5 6 7 // Add lights // light - is open shadow map == true let lightPos = [0, 90, 80]; let focalPoint = [0, 0, 0]; let lightUp = [0, 1, 0] const directionLight = new DirectionalLight(5000, [1, 1, 1], lightPos, focalPoint, lightUp, true, renderer.gl); renderer.addLight(directionLight); 把光源变斜一点，就会发现场景从远到近逐渐出现自阴影现象 y = 40 y=30 y=20 y=10这时候整个地板都出错了 加一个自偏移，就解决了\n1 2 3 if (shadowDepth \u0026gt; lightDepth + 0.01) { visibility = 0.0; } 下面是偏移量改为0.05的效果，效果就太差了，偏移量应该根据光照的角度动态调整 走样的情况，下面就用PCF来解决～ PCF(Percentage Closer Filter) 简单理解就是不只判断shadowmap的一个位置，而是一圈位置的平均。 作业中提供了两种采样，它的作用就是减少计算量，没必要真的一个一个便利来取均值，偏移记录在了vec2 poissonDisk[NUM_SAMPLES]; 下面是我的实现\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 float PCF(sampler2D shadowMap, vec4 coords) { poissonDiskSamples(coords.xy); // 采样数 float numSamples = 0.0; // 没有遮挡的采样数 float numUnBlock = 0.0; // 过滤核大小 float filterSize = 5.0; float mapSize = 2048.0; // 过滤核范围 float filterRange = filterSize / mapSize; for(int i = 0;i\u0026lt;NUM_SAMPLES;i++){ vec2 samplexCoor = coords.xy + poissonDisk[i] * filterRange; // 采样时可能会越界 if(samplexCoor.x \u0026gt; 0.0 \u0026amp;\u0026amp; samplexCoor.x \u0026lt; 1.0 \u0026amp;\u0026amp; samplexCoor.y \u0026gt; 0.0 \u0026amp;\u0026amp; samplexCoor.y \u0026lt; 1.0) { numSamples++; if(useShadowMap(shadowMap,vec4(samplexCoor,coords.z,1.0)) == 1.0) { numUnBlock++; } } } return numUnBlock/numSamples; } 锯齿位置的变化（过滤核大小为5） 当改为20时 改为100时，出现了大量噪点 PCSS(Percentage Closer Soft Shadow) 用PCF来做软阴影，一句话来说就是动态修改过滤核的尺寸，达到不同的因子区域不同的软硬程度\n第一步需要在一定范围内搜索深度比着色点进的点，从而得到一个平均深度\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) { int numSamples = 0; float sumDepth = 0.0; float searchSize = 15.0; float mapSize = 2048.0; float searchRange = searchSize / mapSize; for( int i = 0; i \u0026lt; BLOCKER_SEARCH_NUM_SAMPLES; i ++ ) { vec2 sampleCoor = uv + poissonDisk[i] * searchRange; // 采样时可能会越界 if(sampleCoor.x \u0026gt; 0.0 \u0026amp;\u0026amp; sampleCoor.x \u0026lt; 1.0 \u0026amp;\u0026amp; sampleCoor.y \u0026gt; 0.0 \u0026amp;\u0026amp; sampleCoor.y \u0026lt; 1.0) { float depth = unpack(texture2D(shadowMap, sampleCoor)); if(depth \u0026lt; zReceiver) { sumDepth += depth; numSamples++; } } } if(numSamples \u0026gt; 0) { return sumDepth / float(numSamples); } else { return zReceiver; } } 下来就计算半影尺寸并把它作为过滤核尺寸来进行PCF\n1 2 3 4 5 6 7 8 9 10 11 12 float PCSS(sampler2D shadowMap, vec4 coords){ uniformDiskSamples(coords.xy); // STEP 1: avgblocker depth float avgBlockerDepth = findBlocker(shadowMap, coords.xy, coords.z); // STEP 2: penumbra size // 假设光源尺寸为50 float Wlight = 50.0; float penumbraSize = (coords.z - avgBlockerDepth) * Wlight / avgBlockerDepth; // STEP 3: filtering // 把半影尺寸当做过滤核大小 return PCF(shadowMap, coords,penumbraSize); } 光源尺寸越大，阴影软硬区分程度越大，因为计算出的过滤核尺寸区分度越大 Wlight = 50 Wlight = 100 设置过小时，反而看不出什么效果 ","date":"2025-10-19T14:03:49+08:00","image":"https://sdpyy1.github.io/531daca67f0242c6abba44c1e5a88603.png","permalink":"https://sdpyy1.github.io/p/games202-%E9%AB%98%E8%B4%A8%E9%87%8F%E5%AE%9E%E6%97%B6%E6%B8%B2%E6%9F%93assignment-1/","title":"GAMES202 高质量实时渲染（Assignment 1）"},{"content":"RTX RTX实际做的事： 一个像素用一个样本来采样SPP\n考虑了一次弹射，第一次primary Ray其实没有必要（从摄像机到着色点），只需要做一遍光栅化就可以实现一整个屏幕的primaryRay。所以一个SPP只需要考虑3条光线 降噪 时间滤波 1SPP噪声非常大。实时光线追踪在算法上并没有区别，主要原因是硬件的突破，让光线追踪达到了实时水平。所以实时光线追踪最关键的技术是降噪！ 从这幅图可以看出，降噪的效果非常好。降噪的方法有很多，但是应用到实时的降噪技术很少 实时降噪使用的是时间上的滤波。当前帧需要前一帧已经滤波。假设运动是连续的。有一个vector叫做motion vector，记录每个点上一帧的位置 也就是当前帧用的spp，使用了上一帧的spp，上一帧的spp也用了上上帧的spp，所以作用到当前帧的spp，远大于1。 下面首先介绍一个概念，G-buffer，这东西就是延迟渲染用的 Back projection 求一个像素的内容，在上一帧中的位置 首先求一个点的世界坐标：\n可以提前存储在G-buffer 通过MVP+viewport的逆变换变回世界坐标 我们是知道每个世界坐标是如何变换的，所以通过逆变换来找到这个坐标上一帧的坐标，再把世界坐标转为屏幕坐标即可 得到上一帧对应像素的像素值，进行线性插值，通常a取0.1-0.2，也就是80%以上都是上一帧得到的 下面对比一下groundtruth和降噪结果 有一些该暗的地方仍然是亮的。 基于时间的滤波是有问题的，有一下几点：\n第一帧/切换场景 当前帧的物体在上一帧不存在\n当前帧的物体在上一帧被遮挡了（G-buffer中没有存储） 会出现脱尾 解决思路：\n混合当前帧和上一帧数据之前，把上一帧的结果拉到当前帧周围 检测要不要用上一帧的数据 检测上一帧位置是否为同一个物体 优化混合系数 增大空间滤波 但是这样处理噪声又出现了 滤波实现 空间滤波 滤波操作为低通滤波，保留低频信息，移除高频信息\n高斯滤波 双边滤波（考虑边界） 高斯滤波会把图像均匀地糊掉，边界也会模糊 ，公式的第二项表示如果两边颜色差值很大，贡献就会变小 联合双边滤波 使用G-buffer来更好地指导滤波 用深度、颜色、法向量来指导滤波 如何实现大的滤波核呢？先水平再垂直，效率从\nProgressively Growing Sizes\nRTRT滤波解决方案 SVGF 3个factors指导的滤波\n深度 - 法线 颜色差异 RAE 工业界问题及解法 TAA 时间上的抗锯齿 每一帧采样一个像素的四个角之一，剩余三个角用之前的数据。这样就相当于MSAA了（静止场景）\nMSAA vs SSAA SSAA就是高分辨率渲染再变小 MSAA分辨率不变，在一个像素中采样多次，甚至可以在临近像素复用\nSMAA 超级分辨率 延迟渲染 节省shading的时间\n","date":"2025-10-19T14:02:08+08:00","image":"https://sdpyy1.github.io/39f6ae2e6aa04864b9165387c73282ae.png","permalink":"https://sdpyy1.github.io/p/games202-games202-%E9%AB%98%E8%B4%A8%E9%87%8F%E5%AE%9E%E6%97%B6%E6%B8%B2%E6%9F%93real-time-ray-tracing/","title":"GAMES202 GAMES202 高质量实时渲染（Real-Time Ray-Tracing）"},{"content":"Micorfacet BRDF F项：Fresnel term Fresnel项（Fresnel term）用于描述光在界面上反射的强度随入射角变化的现象。 对于绝缘体材质 对于导电材质 Snell\u0026rsquo;s Law（斯涅尔定律）描述的是光在两种介质交界面上传播时的折射行为 如果分为RGB三个通道，三个通道有自己的折射率。 Rs：垂直偏振（s-polarized）反射率 Rp：平行偏振（p-polarized）反射率 因为自然光通常是非偏振光（即含有相等的 s 和 p 偏振分量），所以实际使用时我们取两者的平均来得到总反射率：两者通过平均得到Fresnel平均反射率。 R0是垂直入射下的反射率，可以通过两种介质的折射率获得，他是一个三通道。进一步可以用Schlick\u0026rsquo;s Approximation来近似出θ角度下的反射率 G项：shadowing-masking term（geometry term） 为了解决微表面的自遮挡问题 下图左边表示光线被遮挡（shadowing），右图是视线被遮挡（masking） 如果没有G项，当视角和法线接近90°时，BRDF分母会接近0，所以BRDF会变得巨大，导致球体一圈变成白色 常用的G项为The Smith shadowing-masking term 把shadowing和masking两种情况分开考虑 他是基于NDF的值来考虑G项的值的 D项：Normal Distribution Function（NDF） 微表面上看法线方向并不是统一的 当法线分布更多在半程向量时，反射光强度更大\nglossy和diffuse的表面法线分布情况，glossy的表面分布比较集中 有不同的模型来描述法线分布\nBenckmann、GGX Beckmann NDF 两个系数来控制分布。阿尔法控制表面的粗糙程度。sita控制半程向量和法向量的夹角 圆心是着色点，红色是微观法线。黑线是宏观着色点法线。他们的夹角就是sita，把红线延长到上面的一条线上。用tansita来定义分布 所以说Beckmann NDF是定义在tanθ上的高斯分布\nGGX NDF 它的尾巴比较长，如下图橙色尾巴很长。而beckmannNDF在90°时已经衰减为非常接近0。如果把高出定义为高光，那backmannNDF高光周围会迅速衰减。而GGX会缓慢衰减，形成光晕的效果 下图可以看出backmann高光周围比较尖锐。GGX比较平滑 所以说GGX的长尾可以带来自然过渡的效果。\nGGX的拓展 定义参数gamma,调整尾巴衰减速度 Multiple Bounces 现在已经学习了BRDF的FGD三项的构成。但是这样渲染出来还有问题。随着roughness的增加，模型逐渐变暗了。下面一行是在做能量损失实验，说明roughness越大，能量损失越多了 这种能量损失体现在，越粗糙的表面法线分布更容易被挡住 所以要考虑多次bounces，有相关的论文，但是对于实时渲染太慢了 所以现在有了基于经验补全的方式，kulla-Conty Approximation 首先根据渲染方程，假设输入的L=1，计算输出的能量是多少 所以1-E(u0)就是损失的能量（E与观察方向有关系，所以不同的视角下损失能量是不一样的），最后补上这部分能量。 具体怎么算出来就不看了。最终损失能量依赖于观察方向和粗糙度两个变量。所以直接预计算打表即可。 补充前后对比 LTC(Shading Microfacet Models using Linearly Transformed Cosines) LTC 最初是由 Tobias Ritschel 等人在论文《LTC - A Fast and Efficient Approximation of Fresnel Terms and Distribution of Normal Vectors for Real-Time Rendering》提出的，它被设计为一种方法来 近似反射分布函数（BRDF） Fresnel项 和 法线分布函数（例如 GGX）是微面模型的关键组成部分，这些函数非常复杂，计算成本较高。LTC 使用线性时间编码（Linear Time Coding）来近似这些复杂的分布，使得实时渲染时可以快速计算这些项。 综上LTC是一种对BRDF项的预计算\n首先，它是有限制的。主要是在GGX的法线分布下、没有阴影、多边形光源 主要思想是任何BRDF lobe 都可以通过一个变换转化为一个余弦函数 实际实现比较复杂，课程没提到\nDisney’s Principled BRDF 首先介绍了为什么有了微表面BRDF还需要Disney的BRDF 它被称为principled的BRDF，可以通过调整参数来达到想要的效果，所以他是艺术家友好的 介绍一下里边的名词\nsubsurface（次表面反射）：光线进入表面后并不会直接反射回去，而是穿透表面并在内部多次散射，最终可能以不同的方向从表面射出。 metallic（金属性） specular（镜面） specularTint（镜面颜色）：specularTint 是一个与 镜面反射（Specular Reflection）相关的参数，用来控制反射光的颜色如何与 材质本身的颜色混合。也就是说镜面反射光不是单纯的白色，而且受表面材质颜色的影响 anisotropic（各向异性）：述材料或表面在不同方向上具有不同物理特性的术语 sheen（沿着球的表面有绒毛雾化效果） clearcoat（添加一层镀膜） Non-Photorealistic Rendering(NPR) Outline Rendering 描边 先来看看有哪些Outline，B表示自己的边界，S表示两个面交界处形成的边界，C是折痕，M是材质边界 知道了边界，下来就需要描边，可以通过Shading或者图像处理来完成。首先来介绍Shading方法，哪些像素点是S边上呢，根据观察，就是法线与观察方向=之间夹角接近垂直的像素点。可以设置夹角的阈值，来改变边的粗细 另外就是外扩法，之前learnOpenGL里有 另外看一下从图像上来处理 Color blocks 色块效果 直接把渲染结果量化，就是连续值变成离散 Strokes Surface Stylization 素描效果 ","date":"2025-10-19T14:01:31+08:00","image":"https://sdpyy1.github.io/a798b632f6bc41f9b3e68453c8445fb7.png","permalink":"https://sdpyy1.github.io/p/games202-%E9%AB%98%E8%B4%A8%E9%87%8F%E5%AE%9E%E6%97%B6%E6%B8%B2%E6%9F%93real-time-physically-based-materials/","title":"GAMES202 高质量实时渲染（Real-Time Physically Based Materials）"},{"content":" 简单理解就是已经被照亮的点作为光源再去照亮别的点 在3D空间的全局光照 Reflective Shadow Maps（RSM） 用非直接光照照亮点p需要什么：\n那些点被光源直接照亮（从shadowMap中得到） 其他点对点p的贡献是什么 shadowMap中每个纹素就是一个面光源（因为shadowMap描述的就是那些地方被直接照到），紧接着要求p点的间接光照其实就是求shadowMap每个纹素代表的面光源对p点的贡献 把纹素作为面光源来求渲染方程 现在问题就是从纹素反射出来的Radiance如何计算\nBRDF项在当前场景中认为是diffuse的 BRDF可以认为是出射的Radiance与入射的irradiance的比例，所以L可以算出 这样写的好处是带入渲染方程后把dA项消掉了，这样就不需要patch的面积大小了，公式就变成了 有几个问题： V项没了，因为计算量太大，要计算对于每个着色点从任何一个纹素看向着色点是否被遮挡 方程下边变成4次方，是论文作者对距离衰减做了平方衰减处理，课程中说这是错的，应该是平方，如果说错了直播吃键盘😄（看到第9节课，老师真吃键盘了） 有一些纹素一定不会对着色点有贡献 1. 可见性 2. 方向 3. 距离 这个问题就引出了这篇论文的假设，如何知道那些纹素离着色点比较近，论文中认为在shaowMap上比较近，那么世界坐标下就离的比较近（大胆的假设） RSM需要存储的东西 RSM在工业界通常用在手电筒 RSM的优点：\n易于实现 缺点： 直接光源有多少就得有多少张shadowMap 可见行没法做 很多的假设 采样率和质量的tradeoff Light Propagation Volumes (LPV) 关键点：Radiance沿直线传播时不会发生变化z 关键做法：把整个场景体积进行切割，成为一个个体素（类比纹素） 下图红色箭头就是间接光照的来源，就是求黄色点接收到的红色radiance的计算 步骤：\n那些点接收到了直接光照\n把这些点放在场景到的一个网格中\n在网格中传播radiance 通过RSM得到一系列虚拟光源 对场景划分3D网格（可以使用3D纹理，定义每个UV是3D空间的哪个网格） 计算一个格子中向任何方向上的radiance是多少，并用SH压缩，LPV需要存储场景中每个体素（voxel）的辐射度分布，直接存储全方向的辐射度会导致内存爆炸。通过SH投影（通常用2阶或3阶），可将6D的光照函数压缩为少量系数（如9个或16个），极大降低内存需求。 这一步我理解就是把每个次级光源都归为了每个网格的属性，这里记录了每个网格向各个方向的Radiance 一个格子的radiance向上下左右方向进行传播，这一步结束后每个格子的radiance就都记录好了 对于一个着色点来说，就可以直接使用当前格子接受到的Radiance来计算间接光照了 但是有问题，墙的一边不可能照亮墙的另一边，那把一堵墙放在同一个网格中，那计算着色时墙两边的Radiance都会被考虑，就会出现漏光 这样就会出现漏光现象，这就要考虑网格划分粒度了 Voxel Global Illumination (VXGI) 把整个场景网格化，想象成MC用方块搭起来的场景，并做了层级处理，比如上一层的一个格子在下一次划分为8个格子，最终建立起一颗树 第一步：用RSM的方法找到直接光照的网格，并记录每个网格接收到的光源的入射方向和法线，并更新到各个层级，高层级整合低层级的光源入射方向和法线 开始着色： 对于glossy的着色点，光源到达后会反射为一个圆锥的范围 从着色点发射锥体，沿锥体轴线步进采样。看场景中那些体素在椎体范围内，那它就对该着色点有贡献（这是利用光路可逆的思想，反过来这些碰到的体素就会通过椎体射到着色点） 对于diffuse的着色点 在屏幕空间的全局光照 屏幕空间：只使用屏幕信息，对图像进行后期处理\nScreen Space Ambient Occlusion（SSAO） 一种全局光照的近似 key diea 1：\n不知道间接光照，假设为一个常数 但不是所有方向都能接收到，会被别的物体挡住显然AO的环境光更好 经典从渲染方程中解释 把V项拆出去了，拆出去的（蓝色）项就像当于把四面八方的可见行进行了平均，剩下的（橙色）项中L项在SSAO中已经假设为常数，另外还假设BRDF是diffuse的，所以全是常数了 其中cossita还没解释 用cos项把球面上的积分转为圆面上的积分（把立体角的面积投影到底面圆上进行积分） 数学结束，SSAO就是把间接光当常数、BRDF也是diffuse的 理论分析结束，现在问题就是如何在屏幕空间求一个着色点四面八方看哪些地方被挡住了 SSAO假设任何一个着色点在周围半径为R的球中进行采样，每个点与对应像素的zbuffer进行比较，如果大，说明这个点被挡住了就是红色 但是上图有一个点判断错了 用一整个球来采样还有问题，墙体内部的点不需要考虑，只需要半球采样即可 采样越多越准确 AO做法是先低采样得到AO 然后进行模糊 SSAO的一个问题是会出现假遮挡现象，因为在摄像机视角下，看着被遮挡了，但实际上两者距离是很远的 进一步有技术叫HBAO，只考虑一定范围内的遮挡\nScreen Space Directional Occlusion(SSDO) 是对SSAO的提升，SSAO考虑间接光照是是一样的，但是通过RSM，我们是可以求出间接光照的\n下图看出AO只是简单的变暗，而DO考虑的反射物的颜色，让阴影变蓝了\n与SSAO想法相反，DO认为如果打出去的光线没有物体挡住，那它才应该没有间接光照的贡献，只有直接光照的贡献，反而打到的物体才会反射回来光 实际做法也和SSAO一样，半球面采样，使用Zbuffer来判断采样点是否被挡住，下图ABD点都得到是被遮挡了 考虑ABD的间接光照对C点的贡献 DO也有假遮挡问题，A点没有被挡住，但是从视角上看是被挡住了，所以被认为对C点有间接光照贡献 只能做小范围的全局光照 Screen Space Reflection（SSR） 在屏幕空间中做光线追踪 现在在屏幕空间已经有了红框的东西，现在SSR就只需要加上白框的东西 SSR的思路 Linear Raymarch 目标是找到光线与场景的交点\n选定步长，每走一步检查深度 还有加速方法，首先对zbuffer生成mipmap 但与常规mipmap不相同，上一层级的一个像素是下一层级4个像素的最小值，而不是平均值 存最小值的目的就是，如果一个光线与上层不相交，那下层更不用考虑了，因为下层离得更远 算法为首先走一步，每交点，就胆子大一点就在上一层一口气走两步，还没交点走4步，有交点了再慢慢走 SSR也有问题，因为屏幕空间只有最前面的数据，所以藏在背后的但是反射出来应该能看见的物体就渲染不出来了 ","date":"2025-10-19T14:00:35+08:00","image":"https://sdpyy1.github.io/aa2c09da489f4288808aa9af1f4fc213.png","permalink":"https://sdpyy1.github.io/p/games202-%E9%AB%98%E8%B4%A8%E9%87%8F%E5%AE%9E%E6%97%B6%E6%B8%B2%E6%9F%93real-time-global-illumination/","title":"GAMES202 高质量实时渲染（Real-Time Global Illumination）"},{"content":"Shading from Environment Lighting 使用环境光计算shading的操作叫做IBL(Image based lighting) ，光照不是来自光源，而是环境贴图 下面在不考虑阴影的情况下来看渲染方程，接受来自整个半球面的光来进行渲染 之前的做法是使用蒙特卡洛积分来估计积分，但是太慢了放在实时渲染中（采样表现并不好，（但是随着发展形势逐渐变化了） ，这里就讲避免采样的方法） 根据上图，联系上节课如何对visibility拆分的情况，这里的光照项也可以提出去 这种操作反应到贴图上就是对贴图进行了过滤，在渲染之前先得到滤波核处理过的贴图 这种操作相当于把多次采样近似为先滤波后的镜面反射方向的一次采样 到这里对于光照项避免了采样，下来来看BRDF项避免采样的方法，如今已经有更好的方法，这里只是学思想，主要思想就是我们想直接打表来预存每个变量下的积分值，但是维度太高了，这里在分析如何降维 微表面模型的BRDF的方程 这里主要考虑菲涅耳项和法线分布项的近似 F项 G项 看这两幅图，说明如果我们想预计算BRDF函数，实际上只需要3个变量，一张三维的表，但是三维还是太麻烦了 F项还可以进一步简化，这样写之后把R0拆出来了，因为它基本是一个常数 这样等式右边的连个积分都可写成一个二维表来进行预计算 在这张2d纹理任何一点的值，就是BRDF积分出来的结果，直接渲染方程都不用积分了 下图可以看见这种简化方法得到的结果与真实采样得到的结果十分相似，但计算量小了很多 这种方法叫做split sum，它是unreal引擎PBR🐮b的基础～（没有采样就没有噪点） 我感觉实时渲染目前了解的很多都是如何把计算进行简化的同时偏差并不大！\nshadow from environment lighting 可以把环境光贴图当作很多光源，每个光源都需要shadowMap，这是很困难的 Spherical Harmonics (SH) Spherical Harmonics (SH)（球面谐函数）是一类在球面坐标系中定义的特殊函数 l决定了波动的“频率”或阶数 m 控制了波动的方向性，特别是如何沿着经度 这个东西就是用来把一个二维函数展开成SH的线性组合（类似一维的傅里叶变换） 每一项前边的系数用下式来计算（两个函数乘起来求积分），这种求系数叫做投影，同样通过这些系数也可以恢复原来的函数\n说了这么多东西，想表达的就是计算时BRDF是低频时，就可以把它比做低通滤波器，所以不管光照项算出来多高频，最终都会被抵消，所以并不需要高频。所以可以用球谐函数前三阶来近似光照项 总结一下上边这些东西。球谐函数的作用是把一个函数展开成从低频到高频的基函数组的线性组合，渲染方程中如果BRDF是diffuse的，那它就是个低通滤波，即使光照是高频的，它的高频信号计算后也会消失，所以直接用球谐函数前几阶来代替它即可。 上面这些东西还只是shading，还没有解决shadow\nPrecomputed Radiance Transfer (PRT) 在实时渲染下，人们更愿意把渲染方程写成下面，PRT假设光照会变，别的不会变的情况 上图是考虑下图这一点的数据 这三张图就代表了在这一点任何方向的lighting、visibility、brdf。直接对应相乘再相加就是这一点的shading，但是每个像素都这么大的计算了，显然效率太低了 这三项都可以表示为球面函数，那直接把上边三张图对应像素值相乘就可以了 PRT的基本思想就是把渲染方程看成下面的两部分 lighting和light transport PRT的做法就是：（它假设场景所有东西都不变，就光照可以改变）\n把lighting进行球谐函数展开，在预计算时就可以算好，因为每个像素的lighting部分都是来自同一张环境光贴图所有点位的值 transport部分也可以预计算球谐函数展开 如果BRDF是diffuse的，那可以把它当作一个常数（即任何方向都均匀反射），下图先提出BRDF的常数，然后把光照项换成求和式，之后交换了求和与积分的顺序（在CG中可以认为任何时候求和和积分都可以交换顺序） 到这里发现积分内的含义就和之前求球谐函数展开后各项系数的方程是长一样的，所以可以把后边剩下的项先变成球谐函数，然后i不同就是不同项的系数，这样这个积分项就变成了一个离散的一维数组 最终渲染方程变成了两个东西的点乘 但是预计算后边这一部分就说明整个场景的物体是不能动的。另外球谐函数的一个优点是旋转后可以很快算出系数的变化，所以支持光照的旋转。 最终渲染方程只需要一个light的数组和一个transport的数组来计算 另外还有glossy的物体没有处理。 diffuse的BRDF可以当作一个常数，而glossy的BRDF与摄像机的位置o也有关系，不同的摄像机位置o得出的T数组是不一样的 因为现在最终的结果与摄像机视角o有关了，进一步把T(o)也进行球谐函数展开 最终light项仍然是一个数组，而transport项变成了二维的\nSH选择不同的阶的时间复杂度 ","date":"2025-10-19T13:59:44+08:00","image":"https://sdpyy1.github.io/787b8e5bfbd6453bad3b4faab67d12d9.png","permalink":"https://sdpyy1.github.io/p/games202-%E9%AB%98%E8%B4%A8%E9%87%8F%E5%AE%9E%E6%97%B6%E6%B8%B2%E6%9F%93real-time-environment-mapping/","title":"GAMES202 高质量实时渲染（Real-Time Environment Mapping）"},{"content":"Shadow Mapping 这块东西在我OpenGL学习笔记中有详细介绍\nhttps://blog.csdn.net/lzh804121985/article/details/147256140?spm=1001.2014.3001.5502\nshadowMapping的问题 阴影痤疮(Shadow Acne)、或者叫自阴影 出现该问题的原因可以看下图，光源视角下的一个像素内高度认为是同一个，下一个像素的位置，会被认为比前一个Z值更远，所以平坦的地板会交替出现高低不平的地方，这样就会出现阴影 简单解决就是认为z插值在一个范围内就不算在阴影中，虽然能解决这个问题，但是引入了新的问题，阴影发生了偏移。如果bias设置的过大，在靠近脚的地方，就不会出现阴影。在工业界称为“彼得潘效应\u0026quot;(Peter Panning) 对于这种不接触的阴影，解决方法是shadowmapping中不仅存最小深度的表面，还存储第二小深度的表面 另外在learnOpenGL提到，在渲染深度贴图时，使用正面剔除，让深度贴图存储的是物体的内面来解决彼得潘问题。 因为我们只需要深度贴图的深度值，对于实体物体无论我们用它们的正面还是背面都没问题。使用背面深度不会有错误，因为阴影在物体内部有错误我们也看不见。\n另外阴影还有走样问题，毕竟阴影计算就是一个像素一个像素来做的 shadow mapping背后的数学 在实时渲染中，经常会用到一些不等式，但人们更多关注的是不等式相等时成立的条件，也就是说，不等式在什么条件下近似相等。实时渲染中，经常用到的一个约等如下： 满足下列两个条件之一时，实时渲染领域认为他们近似相等： 当g(x)满足：\ng(x)积分域足够小 g(x)足够smooth，g(x)值变化不大，比较平坦 下面来到渲染方程 根据上边的不等式，就可以把最后一项（V）提出来 这样变换后，渲染方程就变成了正常做shading之后，再乘以一个表示可见不可见的项 这时的G(x)就是渲染方程的被积函数\nPCF（Percentage Closer Filtering） 以点P为例，并不只找shadowmap中对应的一个像素，而是找一圈的像素，与这些像素都进行比较，最后将比较结果进行平均 比较结果就是一个全是1，0的矩阵，最终得到的数就不是非0即1的数了 PCSS（Percentage closer soft shadows） PCF它原本是用来解决阴影的走样问题的，后来人们又发现它可以用来生成软阴影，就是PCSS 首先来回顾一下软阴影和硬阴影 到这里就可以理解软阴影在阴影不同位置有不同的软硬程度就可以通过PCF的滤波核的大小来控制，越大越模糊，越小越硬 w(半影)与光源大小、物体位置之间呈现相似三角形的关系，w就可以用来表示软硬程度。半影举例越大，滤波核尺寸就应该设计的更大，让阴影更软\n总结PCSS的算法流程：1. 在一个范围搜索遮挡物，并计算平均深度。2. 用平均深度求出半影距离3. 在对应的滤波核尺寸下进行PCF 上边的步骤1需要选取一个合适的范围，下图就展示了如何选取一个范围来进行遮挡物搜素，假设了ShadowMap在近平面，用光源的大小来决定搜素范围 在PCSS第一步和第三步中，为了计算一个像素点的着色，需要考虑多个纹素，从而导致速度慢。如果采用稀疏采样，又会有噪点产生 VSSM（Variance Soft Shadow Mapping） 针对PCSS效率问题，提出VSSM\n优化步骤3 PCF和PCSS的 PC（percentage closer）的理解就是在深度纹理的一片区域内，有多少比例的纹素比着色点深度小 这就相当于看多少人比我考试分数高，就需要遍历所有同学的成绩 VSSM的解决方案就是用成绩分布（正态分布）来估计（⚠️VSSM并没有用正态分布，这里这样说只是方便理解，后边提到切比雪夫不等式就好理解了） 定义一个正态分布需要均值和方差，这个可以快速计算 均值可以通过mipmap（因为上一级map到下一级map就是把相邻的正方形进行平均，到最后就整合为了一个平均值）、SAT（是一种用于高效计算矩形区域内元素总和的数据结构）来求，方差则是用一个概率论公式来求 得到正态分布分布后，求小于着色点深度的纹素百分比，其实就是阴影部分的线下面积 下来VASS还对这种计算进行了简化。直接使用不等式来近似 只要给定均值、方差、和要比较的位置t，就有下面的不等式成立 ，在渲染中就直接用方差、期望、t来估计红色部分的面积，然后马上就能得出剩余部分的面积（t必须大于中间线才好使） 至此，对PCSS步骤三的优化结束，在生成shadowMap时，为了计算方差，还需要额外生成一张平方shadowMap，总体来说甚至都不需要进行loop就可以知道一片区域有多少纹素深度小于着色点深度 优化步骤1 为了设置合理大小的过滤核，对一个区域内遮挡物的深度进行平均，需要遍历纹素，效率并不高\n例如下图，如果着色点的深度为7，那么这些纹素的深度只有蓝色的是遮挡物，就需要平均他们，而红色不参与计算 对蓝色部分和红色部分求均值后，满足下式 这里求N1/N就可以直接用刚才步骤三的不等式（用方差、均值、t来估计大于t的比例），之后1减去它就是N2/N。\n但我们还不知道红色部分的均值，在VSSM中直接假设红色部分均值就是t（大胆的假设，假设的依据是阴影位置大概率是个平面） VSSM的效果 另外课程还提到目前PCSS是压过VSSM的，因为人们对噪声的容忍度越来越高，在图像层面进行降噪手段变强了\nSAT（Summed Area Tables） SAT是给一片区域立刻得到它的均值的一种数据结构\nSAT是对原数据的预处理得到的数据，在一维场景下，每个index记录都是前缀和（提前记录好），现在要求中间某一段的和，就直接拿出SAT在范围查询边界（左边是边界-1）的数据相减就行了 在二维情况下蓝色的范围查询，可以转化为左上角都是(0,0)的框加减操作 VSSM的问题 VSSM中作了很多假设，有一个就是假设一片区域纹素是符合正态分布的 例如右图深度只有3个峰值，不符合正态分布 下图就是说本来只有一小部分没遮住，但是假设为正态分布变成了很大一部分，就会让阴影边白 有一些地方变白了 Moment Shadow Mapping 为了解决VSSM分布描述不准确的问题，提出MSM，使用更高阶矩来描述分布 大概意思就是用数据的高阶来更好的拟合PCF的效果，下图表明用4阶矩下的分布就很好的达到了PCF的效果 缺点很明显空间很大 Distance Field Soft Shadows 首先看一下Distance functions是什么,它描述了空间中任意一点到物体表面的最小距离，离得越远值越小，表示到图上就是变白 在任何一个点上，以它记录的值为半径画圆，都不可能碰到物体，如果做光线追踪，那可以直接移动到圆的表面上去，这就是SFD的一个应用 下来用它来生成软阴影是它的另外一个阴影 用SDF可以大概描述多大的范围被物体挡住了，圆圈内为安全距离，不会有物体挡住，用这个圈的大小来定义visbility的大小，从而实现软阴影 在不同的位置找圆做切线，最小的切线就是需要的安全距离 求角度是一个acrsin的计算，比较慢，人们用一个替代来简化 其中根据不同的k值来达到不同的效果 显而易见的优缺点 空间换时间，但这是不考虑生成距离场的时间 ","date":"2025-10-19T13:58:35+08:00","image":"https://sdpyy1.github.io/7d064c8a32bd4ca48f6dc45c082cf7c4.png","permalink":"https://sdpyy1.github.io/p/games202-%E9%AB%98%E8%B4%A8%E9%87%8F%E5%AE%9E%E6%97%B6%E6%B8%B2%E6%9F%93real-time-shadows/","title":"GAMES202 高质量实时渲染（Real Time Shadows）"},{"content":"微平面模型 所有的PBR技术都基于微平面理论。这项理论认为，达到微观尺度之后任何平面都可以用被称为微平面(Microfacets)的细小镜面来进行描绘。根据平面粗糙程度的不同，这些细小镜面的取向排列可以相当不一致： 下图展示了ROUGH和SMOOTH的微平面 我们假设一个粗糙度(Roughness)参数，然后用统计学的方法来估计微平面的粗糙程度。我们可以基于这个粗糙程度来计算众多微平面中朝着半程向量方向的比例。微平面的朝向方向与半程向量的方向越是一致，镜面反射的效果就越是强烈越是锐利。通过使用一个介于0到1之间的粗糙度参数，我们就能概略地估算微平面的取向情况了 为了遵守能量守恒定律，我们需要对漫反射光和镜面反射光做出明确的区分。当一束光线碰撞到一个表面的时候，它就会分离成一个折射部分和一个反射部分。 反射部分就是镜面光，折射部分被吸收进而转为漫反射\n对于金属表面，折射光会被直接吸收而不会散开，也就是没有了漫反射，因此它们两者在PBR渲染管线中被区别处理。\n1 2 float kS = calculateSpecularComponent(...); // 反射/镜面 部分 float kD = 1.0 - ks; // 折射/漫反射 部分 反射率方程 基于物理的渲染所坚定遵循的是一种被称为反射率方程(The Reflectance Equation)的渲染方程的特化版本。要正确地理解PBR，很重要的一点就是要首先透彻地理解反射率方程： $$ L_0(p, \\omega_{\\text{o}}) = \\int_{\\Omega} f_{r}(p, \\omega_{\\text{i}}, \\omega_{\\text{o}}) L_{r}(p, \\omega_{\\text{i}}) n \\cdot \\omega_{\\text{i}} \\, d\\omega_{\\text{i}} $$一些物理量，在Games101已经接触过\n辐射通量Φ：光源输出的能量，以瓦特为单位 辐射强度：单位球面上，一个光源向每单位立体角所投射的辐射通量 $I = \\frac{d\\Phi}{d\\omega}$ 辐射率:一个拥有辐射强度Φ的光源在单位面积A，单位立体角ω上的辐射出的总能量.$L = \\frac{d^2 \\Phi}{dA d\\omega \\cos \\theta}$ 这里的$cos\\theta$为入射光与平面法线的夹角余弦，也就是说入射光越斜，平面接受到的光能量越弱\n事实上，当涉及到辐射率时，我们通常关心的是所有投射到点p上的光线的总和，而这个和就称为辐射照度或者辐照度(Irradiance)。\n再回到渲染方程 $$ L_0(p, \\omega_{\\text{o}}) = \\int_{\\Omega} f_{r}(p, \\omega_{\\text{i}}, \\omega_{\\text{o}}) L_{r}(p, \\omega_{\\text{i}}) n \\cdot \\omega_{\\text{i}} \\, d\\omega_{\\text{i}} $$$\\omega_{\\text{i}}$就表示半球面的一个立体角，然后在整个半球面进行积分，所以$L_o(p,\\omega_o)$就代表从$\\omega_o$方向上观察，光线投射到p点后反射出来的irradiance\nBRDF 现在这个方程就剩下$f_{r}(p, \\omega_{\\text{i}}, \\omega_{\\text{o}})$未解释了。BRDF，或者说双向反射分布函数，它接受入射（光）方向ωi，出射（观察）方向ωo\nBRDF基于我们之前所探讨过的微平面理论来近似的求得材质的反射与折射属性。几乎所有实时渲染管线使用的都是一种被称为Cook-Torrance BRDF模型。\nCook-Torrance BRDF兼有漫反射和镜面反射两个部分： $f_r = k_df_{lambert}+k_sf_{cook-torrance}$\n其中$f_{lambert} = c/\\pi$，c表示表面颜色。除以π是为了对漫反射光进行标准化\n另外镜面反射为： 字母D，F与G分别代表着一种类型的函数。三个函数分别为法线分布函数(Normal Distribution Function)，菲涅尔方程(Fresnel Rquation)和几何函数(Geometry Function)\n以上的每一种函数都是用来估算相应的物理参数的，而且你会发现用来实现相应物理机制的每种函数都有不止一种形式。它们有的非常真实，有的则性能高效。\n法线分布函数：从统计学上近似地表示了与某些（半程）向量h取向一致的微平面的比率 几何函数：几何函数从统计学上近似的求得了微平面间相互遮蔽的比率，这种相互遮蔽会损耗光线的能量。 菲涅尔方程：被反射的光线对比光线被折射的部分所占的比率，这个比率会随着我们观察的角度不同而不同。当光线碰撞到一个表面的时候，菲涅尔方程会根据观察角度告诉我们被反射的光线所占的百分比 菲涅尔方程是一个相当复杂的方程式，不过幸运的是菲涅尔方程可以用Fresnel-Schlick近似法求得近似解： 我觉得了解这么多足够继续看Games202课程了～\n","date":"2025-10-19T13:53:52+08:00","image":"https://sdpyy1.github.io/f9c17e0cf6ff46a7a63a8f8199cf48b0.png","permalink":"https://sdpyy1.github.io/p/opengl%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0pbr/","title":"OpenGL学习笔记（PBR）"},{"content":"延迟着色法 我们现在一直使用的光照方式叫做正向渲染(Forward Rendering)或者正向着色法(Forward Shading) 最简单理解就是片段着色器要完成所有物体的所有可见部分的光照计算，然而大部分着色器的输出都会被之后的输出覆盖。\n延迟着色法(Deferred Shading)，或者说是延迟渲染(Deferred Rendering)，为了解决上述问题而诞生了\n下图使用了1874个点光源，这对于正向渲染几乎是不可能的 延迟着色法基于我们延迟(Defer)或推迟(Postpone)大部分计算量非常大的渲染(像是光照)到后期进行处理的想法。 它包含两个处理阶段：\n几何处理：渲染场景一次，获取各种几何信息，并存储在一系列叫G缓冲的纹理中。比如位置、颜色、法向量、镜面值 光照处理阶段：利用G缓冲的数据来计算光照 这样做一些被覆盖的物体就不会计算光照 G缓冲 对于每一个片段我们需要储存的数据有：一个位置向量、一个法向量，一个颜色向量，一个镜面强度值。 创建这样一个FBO\n1 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 GLuint gBuffer; glGenFramebuffers(1, \u0026amp;gBuffer); glBindFramebuffer(GL_FRAMEBUFFER, gBuffer); GLuint gPosition, gNormal, gColorSpec; // - 位置颜色缓冲 glGenTextures(1, \u0026amp;gPosition); glBindTexture(GL_TEXTURE_2D, gPosition); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, gPosition, 0 // - 法线颜色缓冲 glGenTextures(1, \u0026amp;gNormal); glBindTexture(GL_TEXTURE_2D, gNormal); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, gNormal, 0); // - 颜色 + 镜面颜色缓冲 glGenTextures(1, \u0026amp;gAlbedoSpec); glBindTexture(GL_TEXTURE_2D, gAlbedoSpec); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT2, GL_TEXTURE_2D, gAlbedoSpec, 0); // - 告诉OpenGL我们将要使用(帧缓冲的)哪种颜色附件来进行渲染 GLuint attachments[3] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 }; glDrawBuffers(3, attachments); // 之后同样添加渲染缓冲对象(Render Buffer Object)为深度缓冲(Depth Buffer)，并检查完整性 [...] 在顶点着色器中写入缓冲，这里的layout是为了指明使用的是哪一个颜色缓冲\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #version 330 core layout (location = 0) out vec3 gPosition; layout (location = 1) out vec3 gNormal; layout (location = 2) out vec4 gAlbedoSpec; in vec2 TexCoords; in vec3 FragPos; in vec3 Normal; uniform sampler2D texture_diffuse1; uniform sampler2D texture_specular1; void main() { // 存储第一个G缓冲纹理中的片段位置向量 gPosition = FragPos; // 同样存储对每个逐片段法线到G缓冲中 gNormal = normalize(Normal); // 和漫反射对每个逐片段颜色 gAlbedoSpec.rgb = texture(texture_diffuse1, TexCoords).rgb; // 存储镜面强度到gAlbedoSpec的alpha分量 gAlbedoSpec.a = texture(texture_specular1, TexCoords).r; } 延迟光照处理阶段 由于所有的G缓冲纹理都代表的是最终变换的片段值，我们只需要对每一个像素执行一次昂贵的光照运算就行了。这使得延迟光照非常高效，特别是在需要调用大量重型片段着色器的复杂场景中。\n1 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 #version 330 core out vec4 FragColor; in vec2 TexCoords; uniform sampler2D gPosition; uniform sampler2D gNormal; uniform sampler2D gAlbedoSpec; struct Light { vec3 Position; vec3 Color; }; const int NR_LIGHTS = 32; uniform Light lights[NR_LIGHTS]; uniform vec3 viewPos; void main() { // 从G缓冲中获取数据 vec3 FragPos = texture(gPosition, TexCoords).rgb; vec3 Normal = texture(gNormal, TexCoords).rgb; vec3 Albedo = texture(gAlbedoSpec, TexCoords).rgb; float Specular = texture(gAlbedoSpec, TexCoords).a; // 然后和往常一样地计算光照 vec3 lighting = Albedo * 0.1; // 硬编码环境光照分量 vec3 viewDir = normalize(viewPos - FragPos); for(int i = 0; i \u0026lt; NR_LIGHTS; ++i) { // 漫反射 vec3 lightDir = normalize(lights[i].Position - FragPos); vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Albedo * lights[i].Color; lighting += diffuse; } FragColor = vec4(lighting, 1.0); } 结合延迟渲染与正向渲染 延迟渲染因为只保存了最前边的数据，就没办法进行混合了（需要玻璃背后的颜色进行mix）。为了展示这是如何工作的，我们将会使用正向渲染器渲染光源为一个小立方体，因为光照立方体会需要一个特殊的着色器(会输出一个光照颜色)。\n在延迟渲染之后渲染所有立方体，所有的立方体就渲染到了最前边 我们需要做的就是首先复制出在几何渲染阶段中储存的深度信息，并输出到默认的帧缓冲的深度缓冲，\n1 2 3 4 5 6 7 8 glBindFramebuffer(GL_READ_FRAMEBUFFER, gBuffer); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); // 写入到默认帧缓冲 glBlitFramebuffer( 0, 0, SCR_WIDTH, SCR_HEIGHT, 0, 0, SCR_WIDTH, SCR_HEIGHT, GL_DEPTH_BUFFER_BIT, GL_NEAREST ); glBindFramebuffer(GL_FRAMEBUFFER, 0); // 现在像之前一样渲染光立方体 [...] 在这里我们复制整个读帧缓冲的深度缓冲信息到默认帧缓冲的深度缓冲，对于颜色缓冲和模板缓冲我们也可以这样处理。现在如果我们接下来再渲染光立方体，场景里的几何体将会看起来很真实了，而不只是简单地粘贴立方体到2D方形之上： 现在渲染正方体时会考虑目前像素的深度值，所以会有部分像素无法通过深度测试，所以效果比较真实 更多的光源 真正让大量光源成为可能的是我们能够对延迟渲染管线引用的一个非常棒的优化：光体积(Light Volumes) 由于大部分光源都使用了某种形式的衰减(Attenuation)，我们可以用它来计算光源能够到达的半径。我们接下来只需要对那些在一个或多个光体积内的片段进行繁重的光照运算就行了。这可以给我们省下来很可观的计算量，因为我们现在只在需要的情况下计算光照。\n在渲染光源时先考虑半径来丢弃一些光源的贡献\n1 2 3 4 5 6 7 8 9 10 for(int i = 0; i \u0026lt; NR_LIGHTS; ++i) { // 计算光源和该片段间距离 float distance = length(lights[i].Position - FragPos); if(distance \u0026lt; lights[i].Radius) { // 执行大开销光照 [...] } } GPU和GLSL并不擅长优化循环和分支。这一缺陷的原因是GPU中着色器的运行是高度并行的，大部分的架构要求对于一个大的线程集合，GPU需要对它运行完全一样的着色器代码从而获得高效率。这通常意味着一个着色器运行时总是执行一个if语句所有的分支从而保证着色器运行都是一样的，这使得我们之前的半径检测优化完全变得无用，我们仍然在对所有光源计算光照！\n使用光体积更好的方法是渲染一个实际的球体，并根据光体积的半径缩放。这些球的中心放置在光源的位置，由于它是根据光体积半径缩放的，这个球体正好覆盖了光的可视体积。这就是我们的技巧：我们使用大体相同的延迟片段着色器来渲染球体。因为球体产生了完全匹配于受影响像素的着色器调用，我们只渲染了受影响的像素而跳过其它的像素。下面这幅图展示了这一技巧：\n屏幕空间环境光遮蔽 SSAO 在现实中，光线会以任意方向散射，它的强度是会一直改变的，所以间接被照到的那部分场景也应该有变化的强度，而不是一成不变的环境光。环境光遮蔽（Anbient Occlusion）是其中一种解决方案。\n它的原理是通过将褶皱、孔洞和非常靠近的墙面变暗的方法近似模拟出间接光照。这些区域很大程度上是被周围的几何体遮蔽的，光线会很难流失，所以这些地方看起来会更暗一些。 环境光遮蔽这一技术会带来很大的性能开销，因为它还需要考虑周围的几何体。我们可以对空间中每一点发射大量光线来确定其遮蔽量，但是这在实时运算中会很快变成大问题。\n孤岛危机中有了新的技术叫屏幕空间环境光遮蔽（Screen-space Ambient Occlusion, SSAO）技术。这一技术使用了屏幕空间场景的深度而不是真实的几何体数据来确定遮蔽量。这一做法相对于真正的环境光遮蔽不但速度快，而且还能获得很好的效果，使得它成为近似实时环境光遮蔽的标准。\nSSAO背后的原理：每一个片段，我们都会根据周边深度值计算一个遮蔽因子(Occlusion Factor)。这个遮蔽因子之后会被用来减少或者抵消片段的环境光照分量。遮蔽因子是通过采集片段周围球型核心(Kernel)的多个深度样本，并 灰色样本就是深度高于片段深度的样本，灰色越多，片段获得的环境光照也就越少。\n渲染效果的质量和精度与我们采样样本的数量有直接关系 Crytek公司开发的SSAO技术会产生一种特殊的视觉风格。因为使用的采样核心是一个球体，它导致平整的墙面也会显得灰蒙蒙的，因为核心中一半的样本都会在墙这个几何体上。下面这幅图展示了孤岛危机的SSAO，它清晰地展示了这种灰蒙蒙的感觉 由于这个原因，我们将不会使用球体的采样核心，而使用一个沿着表面法向量的半球体采样核心。\n接着上面的延迟渲染，在g缓冲中额外添加一层表示深度的缓冲，注意要先转为线性\n1 2 // 储存线性深度到gPositionDepth的alpha分量 gPositionDepth.a = LinearizeDepth(gl_FragCoord.z); 紧接着需要沿着表面法线方向生成样本\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 std::uniform_real_distribution\u0026lt;GLfloat\u0026gt; randomFloats(0.0, 1.0); // 随机浮点数，范围0.0 - 1.0 std::default_random_engine generator; std::vector\u0026lt;glm::vec3\u0026gt; ssaoKernel; for (GLuint i = 0; i \u0026lt; 64; ++i) { glm::vec3 sample( randomFloats(generator) * 2.0 - 1.0, randomFloats(generator) * 2.0 - 1.0, randomFloats(generator) ); sample = glm::normalize(sample); sample *= randomFloats(generator); GLfloat scale = GLfloat(i) / 64.0; ssaoKernel.push_back(sample); } 我们在切线空间中以-1.0到1.0为范围变换x和y方向，并以0.0和1.0为范围变换样本的z方向(如果以-1.0到1.0为范围，取样核心就变成球型了)。由于采样核心将会沿着表面法线对齐，所得的样本矢量将会在半球里。\n每个核心样本将会被用来偏移观察空间片段位置从而采样周围的几何体。我们在教程开始的时候看到，如果没有变化采样核心，我们将需要大量的样本来获得真实的结果。通过引入一个随机的转动到采样核心中，我们可以很大程度上减少这一数量。\n之后把这些东西放在SSAO着色器中执行SSAO算法 现在的效果仍然看起来不是很完美，由于重复的噪声纹理再图中清晰可见。为了创建一个光滑的环境遮蔽结果，我们需要模糊环境遮蔽纹理。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #version 330 core in vec2 TexCoords; out float fragColor; uniform sampler2D ssaoInput; void main() { vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 0)); float result = 0.0; for (int x = -2; x \u0026lt; 2; ++x) { for (int y = -2; y \u0026lt; 2; ++y) { vec2 offset = vec2(float(x), float(y)) * texelSize; result += texture(ssaoInput, TexCoords + offset).r; } } fragColor = result / (4.0 * 4.0); } 最近几篇OpenGL学习笔记更多用于了解技术，不做实现，更多实践放在Games202中～\n","date":"2025-10-19T13:53:03+08:00","image":"https://sdpyy1.github.io/5d72ce7b8a844d10a11dbfa6f72f7733.png","permalink":"https://sdpyy1.github.io/p/opengl%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%E5%BB%B6%E8%BF%9F%E7%9D%80%E8%89%B2%E6%B3%95ssao/","title":"OpenGL学习笔记（延迟着色法、SSAO）"},{"content":"HDR 一句话理解就是不要让颜色限制到0-1.先拓展到很大的范围，最后再通过某种映射回到0-1，这样不同颜色的区分度就会变大。 大量片段的颜色值都非常接近1.0，在很大一个区域内每一个亮的片段都有相同的白色。这损失了很多的细节，使场景看起来非常假。 一个更好的方案是让颜色暂时超过1.0，然后将其转换至0.0到1.0的区间内，从而防止损失细节。\n显示器被限制为只能显示值为0.0到1.0间的颜色，但是在光照方程中却没有这个限制。通过使片段的颜色超过1.0，我们有了一个更大的颜色范围，这也被称作HDR(High Dynamic Range, 高动态范围)\nHDR渲染和其很相似，我们允许用更大范围的颜色值渲染从而获取大范围的黑暗与明亮的场景细节，最后将所有HDR值转换成在[0.0, 1.0]范围的LDR(Low Dynamic Range,低动态范围)。转换HDR值到LDR值得过程叫做色调映射(Tone Mapping)\n在OpenGL中通过设置帧缓冲的颜色分量的不同格式来让颜色值不限制在0-1之间。 默认的帧缓冲默认一个颜色分量只占用8位(bits)。当使用一个使用32位每颜色分量的浮点帧缓冲时(使用GL_RGB32F 或者GL_RGBA32F)，我们需要四倍的内存来存储这些颜色。所以除非你需要一个非常高的精确度，32位不是必须的，使用GLRGB16F就足够了。\n当存储好颜色后，不能直接将这个FBO渲染到屏幕上，因为又会被限制在0-1之间，需要先进行色调映射，最简单的色调映射算法是Reinhard色调映射，它涉及到分散整个HDR颜色值到LDR颜色值上，所有的值都有对应。\n泛光 让发光的东西更像在发光的技术。\n这种光流，或发光效果，是通过一种叫做泛光(Bloom)的后期处理效果来实现的。泛光使场景中所有明亮的区域都具有类似发光的效果 我们来一步一步解释这个处理过程。我们在场景中渲染一个带有4个立方体形式不同颜色的明亮的光源。带有颜色的发光立方体的亮度在1.5到15.0之间。如果我们将其渲染至HDR颜色缓冲，场景看起来会是这样的：\n我们得到这个HDR颜色缓冲纹理，提取所有超出一定亮度的fragment。这样我们就会获得一个只有fragment超过了一定阈限的颜色区域： 我们将这个超过一定亮度阈限的纹理进行模糊处理。泛光效果的强度很大程度上是由模糊过滤器的范围和强度决定的。 最终的被模糊化的纹理就是我们用来获得发出光晕效果的东西。这个已模糊的纹理要添加到原来的HDR场景纹理之上。由于模糊滤镜的作用，明亮的区域在宽度和高度上都得到了扩展，因此场景中的明亮区域看起来会发光或流光。 一个FBO可以挂在两个颜色缓冲附件\n1 2 layout (location = 0) out vec4 FragColor; layout (location = 1) out vec4 BrightColor; 输出颜色时通过不同条件写入到不同的颜色缓冲中\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #version 330 core layout (location = 0) out vec4 FragColor; layout (location = 1) out vec4 BrightColor; [...] void main() { [...] // first do normal lighting calculations and output results FragColor = vec4(lighting, 1.0f); // Check whether fragment output is higher than threshold, if so output as brightness color float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722)); if(brightness \u0026gt; 1.0) BrightColor = vec4(FragColor.rgb, 1.0); } 有了两个颜色缓冲，我们就有了一个正常场景的图像和一个提取出的亮区的图像。这些都在一个渲染步骤中完成。\n高斯模糊 介绍一个高级点的模糊处理器 高斯模糊基于高斯曲线，高斯曲线通常被描述为一个钟形曲线，中间的值达到最大化，随着距离的增加，两边的值不断减少。高斯曲线在数学上有不同的形式，但是通常是这样的形状 其实就是滤波核的权重不一样了～ 我们可以从二维高斯曲线方程中获得权重。然而这个过程有个问题，就是很快会消耗极大的性能。以一个32×32的模糊kernel为例，我们必须对每个fragment从一个纹理中采样1024次！\n幸运的是，高斯方程有个非常巧妙的特性，它允许我们把二维方程分解为两个更小的方程：一个描述水平权重，另一个描述垂直权重。我们首先用水平权重在整个纹理上进行水平模糊，然后在经改变的纹理上进行垂直模糊。利用这个特性，结果是一样的，但是可以节省难以置信的性能，因为我们现在只需做32+32次采样，不再是1024了！这叫做两步高斯模糊。 水平模糊一次、垂直模糊一次，就避免了每个像素单独处理的麻烦 把两个纹理混合 其实就是两张纹理相加～\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #version 330 core out vec4 FragColor; in vec2 TexCoords; uniform sampler2D scene; uniform sampler2D bloomBlur; uniform float exposure; void main() { const float gamma = 2.2; vec3 hdrColor = texture(scene, TexCoords).rgb; vec3 bloomColor = texture(bloomBlur, TexCoords).rgb; hdrColor += bloomColor; // additive blending // tone mapping vec3 result = vec3(1.0) - exp(-hdrColor * exposure); // also gamma correct while we\u0026#39;re at it result = pow(result, vec3(1.0 / gamma)); FragColor = vec4(result, 1.0f); } ","date":"2025-10-19T13:52:28+08:00","image":"https://sdpyy1.github.io/3f35480e680a4948bb8b80386c384e22.png","permalink":"https://sdpyy1.github.io/p/opengl%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0hdr%E6%B3%9B%E5%85%89/","title":"OpenGL学习笔记（HDR、泛光）"},{"content":"法线贴图 用普通的纹理看起来是平的，通过修改每个像素的法线，得到更好的效果。这种每个fragment使用各自的法线，替代一个面上所有fragment使用同一个法线的技术叫做法线贴图（normal mapping）或凹凸贴图（bump mapping）。应用到砖墙上，效果像这样： 这张法线贴图颜色偏蓝，是因为砖头表面的法线是（0，0，1），缝隙偏绿是因为缝隙的法线为(0,1,0)\n1 2 3 4 5 6 7 8 9 10 11 12 uniform sampler2D normalMap; void main() { // 从法线贴图范围[0,1]获取法线 normal = texture(normalMap, fs_in.TexCoords).rgb; // 将法线向量转换为范围[-1,1] normal = normalize(normal * 2.0 - 1.0); [...] // 像往常那样处理光照 } 但是这样就有问题了，如果你修改了物体的朝向，那法线贴图的数据将无法再起作用，因为它记录的是绝对值，然而物体的移动也会伴随着法线的移动 切线空间 切线空间是位于三角形表面之上的空间：法线就是正z方向，切线是x，副切线为y。切线空间是相对于单个三角形的局部坐标系，所以三角形无论如何旋转，正z方向都是法线方向。但X轴（切线）和Y轴（副切线）的方向可能因纹理坐标或模型定义而变化 通过左乘一个TBN矩阵，让法线从切线空间转到全局空间中 N是法线方向，T是切线方向，B是副切线方向。 切线和副切线与UV方向对齐\n我们可以用一个三角形的顶点和纹理坐标（因为纹理坐标和切线向量在同一空间中）计算出切线和副切线你就已经部分地达到目的了，用切线空间的切线左乘这个TBN矩阵，就得到了可以直接使用的切线。\n视差贴图 它对根据储存在纹理中的几何信息对顶点进行位移或偏移。一种实现的方式是比如有1000个顶点，根据纹理中的数据对平面特定区域的顶点的高度进行位移。这样的每个纹理像素包含了高度值纹理叫做高度贴图。一张简单的砖块表面的高度贴图如下所示： 高度贴图中存储的是每个点的高度值，每个顶点取高度贴图的一个位置值来进行偏移，从而实现凹凸不平的效果，但是显著增加了计算量（每个顶点都得实实在在进行位移） 涉茶贴图不需要额外的顶点数据来表达深度，它像法线贴图一样采用一种聪明的手段欺骗用户的眼睛。视差贴图背后的思想是修改UV坐标使一个fragment的表面看起来比实际的更高或者更低。为了理解它如何工作，看看下面砖块表面的图片： 红线代表高度贴图代表的高度，当视线在看A点时，实际上B点的高度会把A点挡住，所以使用点B的纹理而不是点A。\n计算方法是：取A点的高度旋转到观察方向，再投影到平面上，就取投影终点位置的UV坐标B当作A点的UV坐标 这个技巧在大多数时候都没问题，但点B是粗略估算得到的。当表面的高度变化很快的时候，看起来就不会真实，因为向量P¯最终不会和B接近，就像下图这样 视差贴图的另一个问题是，当表面被任意旋转以后很难指出从P¯获取哪一个坐标，在切线空间中完成更好\n","date":"2025-10-19T13:51:43+08:00","image":"https://sdpyy1.github.io/b242fb1f71cf4c9ba9bab2585fce9b47.png","permalink":"https://sdpyy1.github.io/p/opengl%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%E6%B3%95%E7%BA%BF%E8%B4%B4%E5%9B%BE%E8%A7%86%E5%B7%AE%E8%B4%B4%E5%9B%BE/","title":"OpenGL学习笔记（法线贴图、视差贴图）"},{"content":"Blinn-Phong PhongShading不仅对真实光照有很好的近似，而且性能也很高。但是它的镜面反射会在一些情况下出现问题，特别是物体反光度很低时，会导致大片（粗糙的）高光区域。下面这张图展示了当p为1.0时地板会出现的效果： 上图高光区域的边缘有明显的断层，出现这个问题的原因是观察向量和反射向量间的夹角不能大于90度。如果点积的结果为负数，镜面光分量会变为0.0。所以90°的地方明显的断层 下图就是这个角度大于90°的情况 1977年，James F. Blinn在风氏着色模型上加以拓展，引入了Blinn-Phong着色模型。Blinn-Phong模型与风氏模型非常相似，但是它对镜面光模型的处理上有一些不同，让我们能够解决之前提到的问题。Blinn-Phong模型不再依赖于反射向量，而是采用了所谓的半程向量(Halfway Vector)，即光线与视线夹角一半方向上的一个单位向量。当半程向量与法线向量越接近时，镜面光分量就越大。 用光线与实现夹角的半程向量与法线夹角来表示镜面光强度。当视线正好与（现在不需要的）反射向量对齐时，半程向量就会与法线完美契合。所以当观察者视线越接近于原本反射光线的方向时，镜面高光就会越强。现在，不论观察者向哪个方向看，半程向量与表面法线之间的夹角都不会超过90度（除非光源在表面以下）。\nBlinn-Phong与风氏模型唯一的区别就是，Blinn-Phong测量的是法线与半程向量之间的夹角，而风氏模型测量的是观察方向与反射向量间的夹角。 除此之外还有一个小区别，就是半程向量与表面法线的夹角通常会小于观察与反射向量的夹角。所以，如果你想获得和风氏着色类似的效果，就必须在使用Blinn-Phong模型时将镜面反光度设置更高一点。通常我们会选择风氏着色时反光度分量的2到4倍。 Blinn-Phong着色的一个附加好处是，它比Phong着色性能更高，因为我们不必计算更加复杂的反射向量了。\nGamma矫正 Gamma 设备输出亮度 = 输入电压的Gamma次幂 第一行表示人眼感知色阶，第二行是物理的色阶。 人眼对暗色的分辨能力远超亮色，那么在有限的计算机颜色中（256个色阶），亮色和暗色均匀分布的话，那亮色部分就会精度过剩而暗色部分就会精度不足。如何解决这个问题？进行 Gamma 矫正。\n人的视觉系统对光照强度的反应并不是线性的，这意味着一个线性变化的光强，人眼感知起来却并非如此。\ny=x的电线表示Gamma为1的理想状态，当我们输出一个值为(0.5,0.0,0.0)的颜色时，显示器输出的实际颜色为(0.218,0.0,0.0) 就是0.5的2.2次幂。如果我们把设置的颜色翻倍变为（1，0，0）实际上显示器的输出翻了4.5倍不止。\n这块我感觉他解释的很乱，不如直接说人眼的gamma是1/2.2，显示器的gamma是2.2，刚好抵消了\n为什么看起来\u0026quot;正常\u0026quot;： 人眼恰好需要约4.5倍的物理亮度变化才能感知\u0026quot;2倍亮度\u0026quot; 显示器的Gamma 2.2 ≈ 补偿了人眼的非线性感知 最终达到\u0026quot;所见即所得\u0026quot;的效果\n因为颜色是根据显示器的输出配置的，所以线性空间中的所有中间(照明)计算在物理上都是不正确的。随着更多先进的照明算法的引入，这一点变得更加明显，如下图所示： 如果没有适当地纠正这个显示器伽马，照明看起来是错误的，艺术家将很难获得逼真和好看的结果。解决方案正是应用伽马校正。\nGamma矫正 Gamma校正(Gamma Correction)的思路是在最终的颜色输出到显示器之前先将Gamma的倒数作用到颜色上。就是让一个颜色变得更亮之后输出到显示器又会变暗，刚好抵消了~\n我们来看另一个例子。还是那个暗红色(0.5,0.0,0.0)。在将颜色显示到显示器之前，我们先对颜色应用Gamma校正曲线。线性的颜色显示在显示器上相当于降低了2.2 次幂的亮度，所以倒数就是1/2.2次幂。Gamma校正后的暗红色就会成为(0.73,0.0,0.0)。校正后的颜色接着被发送给显示器，最终显示出来的颜色是(0.5,0.0,0.0)。你会发现使用了Gamma校正，显示器最终会显示出我们在应用中设置的那种线性的颜色。\n基于gamma2.2的颜色空间叫做sRGB颜色空间。每个显示器的gamma曲线都有所不同，但是gamma2.2在大多数显示器上表现都不错。\n实现方法 使用OpenGL内建的sRGB帧缓冲。 开启即可 1 glEnable(GL_FRAMEBUFFER_SRGB); 不开矫正效果 开启矫正 明显亮了很多，这也就是在抵消显示器让颜色变暗的情况。 使用这种方法要注意，他是输出到屏幕之前进行的矫正，如果你提前进行了矫正，那后续操作就全错了。\n自己在像素着色器中进行gamma校正w。 1 2 3 4 5 6 7 8 void main() { // 在线性空间做炫酷的光照效果 [...] // 应用伽马矫正 float gamma = 2.2; fragColor.rgb = pow(fragColor.rgb, vec3(1.0/gamma)); } sRGB纹理 显示器显示颜色时使用了gamma曲线（约2.2），这意味着：你看到的颜色 ≠ 内存中的数值 典型错误场景： 当你在渲染管线中： ① 加载sRGB纹理（已经gamma校正） ② 进行光照计算（应该在线性空间） ③ 输出到显示器（又自动gamma校正） → 实际发生了两次gamma校正 → 画面过亮\n也就是说sRGB纹理它已经被gamma矫正过了，渲染完成后不需要再进行gamma矫正。\n所以可以先转为线性颜色计算后，再统一进行gamma矫正 为了转为线性的颜色，可以使用特定的纹理创建函数\n1 glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image); 这个函数会自动把sRGB纹理转为线性空间，正常进行计算后统一进行gamma矫正并输出\n衰减 伽马校正的另一个不同之处在于光照衰减。在真实的物理世界中，光衰减与光源距离的平方成反比。\n1 float attenuation = 1.0 / (distance * distance); 然而当我们使用这个衰减公式时，衰减效果总是过于强烈 如果我们使用这个方程，而且不进行gamma校正，显示在监视器上的衰减方程实际上将变成$(1.0/distance^2)^{2.2}$,所以进行了强烈的衰减。\n总而言之，gamma校正使你可以在线性空间中进行操作。因为线性空间更符合物理世界，大多数物理公式现在都可以获得较好效果，比如真实的光的衰减。你的光照越真实，使用gamma校正获得漂亮的效果就越容易。这也正是为什么当引进gamma校正时，建议只去调整光照参数的原因。\n阴影 shadow mapping 这个在我的tinyrenderer中实现过。这里额外提到了把摄像机角度生成的zbuffer存储在纹理中。我们管储存在纹理中的所有这些深度值，叫做深度贴图（depth map）或阴影贴图。 首先创建一个有三个正方形的场景\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 int main(){ // 初始化窗口 GLFWwindow * window = InitWindowAndFunc(); stbi_set_flip_vertically_on_load(true); // 启用深度测试 glEnable(GL_DEPTH_TEST); // shader Shader shader(\u0026#34;./shader/base.vert\u0026#34;, \u0026#34;./shader/base.frag\u0026#34;); // 纹理 unsigned int planeDiffuse = loadTexture(\u0026#34;./assets/diffuse.png\u0026#34;); unsigned int cubeDiffuse = loadTexture(\u0026#34;./assets/container.jpg\u0026#34;); // 地板 float planeVertices[] = { // positions // normals // texcoords 25.0f, -0.5f, 25.0f, 0.0f, 1.0f, 0.0f, 25.0f, 0.0f, -25.0f, -0.5f, 25.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, -25.0f, -0.5f, -25.0f, 0.0f, 1.0f, 0.0f, 0.0f, 25.0f, 25.0f, -0.5f, 25.0f, 0.0f, 1.0f, 0.0f, 25.0f, 0.0f, -25.0f, -0.5f, -25.0f, 0.0f, 1.0f, 0.0f, 0.0f, 25.0f, 25.0f, -0.5f, -25.0f, 0.0f, 1.0f, 0.0f, 25.0f, 25.0f }; // plane VAO unsigned int planeVBO,planeVAO; glGenVertexArrays(1, \u0026amp;planeVAO); glGenBuffers(1, \u0026amp;planeVBO); glBindVertexArray(planeVAO); glBindBuffer(GL_ARRAY_BUFFER, planeVBO); glBufferData(GL_ARRAY_BUFFER, sizeof(planeVertices), planeVertices, GL_STATIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float))); glEnableVertexAttribArray(2); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float))); glBindVertexArray(0); while (!glfwWindowShouldClose(window)) { // 清理窗口 glClearColor(0.05f, 0.05f, 0.05f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glm::mat4 projection = glm::perspective(glm::radians(45.0f), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 1000.0f); glm::mat4 view = camera.GetViewMatrix();; shader.use(); glBindVertexArray(planeVAO); shader.setMat4(\u0026#34;projection\u0026#34;, projection); shader.setMat4(\u0026#34;view\u0026#34;, view); shader.setMat4(\u0026#34;model\u0026#34;, glm::mat4(1.0)); shader.setInt(\u0026#34;diffuse_texture1\u0026#34;, 0); glBindTexture(GL_TEXTURE_2D, planeDiffuse); glDrawArrays(GL_TRIANGLES, 0, 6); glBindTexture(GL_TEXTURE_2D, cubeDiffuse); // cubes glm::mat4 model = glm::mat4(1.0f); model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(0.0f, 1.5f, 0.0)); model = glm::scale(model, glm::vec3(0.5f)); shader.setMat4(\u0026#34;model\u0026#34;, model); renderCube(); model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(2.0f, 0.0f, 1.0)); model = glm::scale(model, glm::vec3(0.5f)); shader.setMat4(\u0026#34;model\u0026#34;, model); renderCube(); model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(-1.0f, 0.0f, 2.0)); model = glm::rotate(model, glm::radians(60.0f), glm::normalize(glm::vec3(1.0, 0.0, 1.0))); model = glm::scale(model, glm::vec3(0.25)); shader.setMat4(\u0026#34;model\u0026#34;, model); renderCube(); // 事件处理 glfwPollEvents(); // 双缓冲 glfwSwapBuffers(window); processFrameTimeForMove(); processInput(window); } glfwTerminate(); return 0; }; void renderCube() { // initialize (if necessary) if (cubeVAO == 0) { float vertices[] = { // back face -1.0f, -1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, // bottom-left 1.0f, 1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f, // top-right 1.0f, -1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 0.0f, // bottom-right 1.0f, 1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f, // top-right -1.0f, -1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, // bottom-left -1.0f, 1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 1.0f, // top-left // front face -1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // bottom-left 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, // bottom-right 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, // top-right 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, // top-right -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, // top-left -1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // bottom-left // left face -1.0f, 1.0f, 1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f, // top-right -1.0f, 1.0f, -1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // top-left -1.0f, -1.0f, -1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f, // bottom-left -1.0f, -1.0f, -1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f, // bottom-left -1.0f, -1.0f, 1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 0.0f, // bottom-right -1.0f, 1.0f, 1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f, // top-right // right face 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, // top-left 1.0f, -1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, // bottom-right 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // top-right 1.0f, -1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, // bottom-right 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, // top-left 1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, // bottom-left // bottom face -1.0f, -1.0f, -1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f, // top-right 1.0f, -1.0f, -1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 1.0f, // top-left 1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f, // bottom-left 1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f, // bottom-left -1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, // bottom-right -1.0f, -1.0f, -1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f, // top-right // top face -1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, // top-left 1.0f, 1.0f , 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // bottom-right 1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, // top-right 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // bottom-right -1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, // top-left -1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f // bottom-left }; glGenVertexArrays(1, \u0026amp;cubeVAO); glGenBuffers(1, \u0026amp;cubeVBO); // fill buffer glBindBuffer(GL_ARRAY_BUFFER, cubeVBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // link vertex attributes glBindVertexArray(cubeVAO); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float))); glEnableVertexAttribArray(2); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float))); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0); } // render Cube glBindVertexArray(cubeVAO); glDrawArrays(GL_TRIANGLES, 0, 36); glBindVertexArray(0); } 下来就是生成阴影贴图了\n第一步需要使用自定义帧缓冲生成深度贴图\n1 2 GLuint depthMapFBO; glGenFramebuffers(1, \u0026amp;depthMapFBO); 下面生成一张空纹理图，因为我们只关心深度值，我们要把纹理格式指定为GL_DEPTH_COMPONENT\n1 2 3 4 5 6 7 8 9 GLuint depthMap; glGenTextures(1, \u0026amp;depthMap); glBindTexture(GL_TEXTURE_2D, depthMap); glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); 把纹理图挂载到自定义帧缓冲中 然而，不包含颜色缓冲的帧缓冲对象是不完整的，所以我们需要显式告诉OpenGL我们不适用任何颜色数据进行渲染。我们通过将调用glDrawBuffer和glReadBuffer把读和绘制缓冲设置为GL_NONE来做这件事。\n1 2 3 4 5 glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0); glDrawBuffer(GL_NONE); glReadBuffer(GL_NONE); glBindFramebuffer(GL_FRAMEBUFFER, 0); 这里光源视角下设置的是平行光，所以直接用正交投影即可\n1 2 GLfloat near_plane = 1.0f, far_plane = 7.5f; glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane); 下面为了渲染是从光源角度的渲染，所以要修改视图矩阵\n1 glm::mat4 lightView = glm::lookAt(glm::vec3(-2.0f, 4.0f, -1.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f)); 两者结合就得到了一个光空间的变换矩阵，他能将每个世界空间坐标转变为光源视角下的坐标\n用于渲染的shader非常简单\n1 2 3 4 5 6 7 8 9 10 #version 330 core layout (location = 0) in vec3 position; uniform mat4 lightSpaceMatrix; uniform mat4 model; void main() { gl_Position = lightSpaceMatrix * model * vec4(position, 1.0f); } 片段着色器什么都不用干，但是它默认就会进行深度测试，会存储深度缓冲\n1 2 3 4 5 6 #version 330 core void main() { // gl_FragDepth = gl_FragCoord.z; } 在自定义缓冲下进行渲染后，就存入了深度缓存的信息\n1 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 // 切换到光源视角下 glm::mat4 lightProjection, lightView; glm::mat4 lightSpaceMatrix; float near_plane = 1.0f, far_plane = 7.5f; lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane); lightView = glm::lookAt(lightPos, glm::vec3(0.0f), glm::vec3(0.0, 1.0, 0.0)); lightSpaceMatrix = lightProjection * lightView; depthShader.use(); depthShader.setMat4(\u0026#34;lightSpaceMatrix\u0026#34;, lightSpaceMatrix); glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT); glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); glClear(GL_DEPTH_BUFFER_BIT); // cubes glm::mat4 model = glm::mat4(1.0f); model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(0.0f, 1.5f, 0.0)); model = glm::scale(model, glm::vec3(0.5f)); depthShader.setMat4(\u0026#34;model\u0026#34;, model); renderCube(); model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(2.0f, 0.0f, 1.0)); model = glm::scale(model, glm::vec3(0.5f)); depthShader.setMat4(\u0026#34;model\u0026#34;, model); renderCube(); model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(-1.0f, 0.0f, 2.0)); model = glm::rotate(model, glm::radians(60.0f), glm::normalize(glm::vec3(1.0, 0.0, 1.0))); model = glm::scale(model, glm::vec3(0.25)); depthShader.setMat4(\u0026#34;model\u0026#34;, model); renderCube(); glBindFramebuffer(GL_FRAMEBUFFER, 0); 渲染阴影 首先在顶点着色器，我们要记录每个顶点在光源视角下的位置FragPosLightSpace\n1 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 #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; layout (location = 2) in vec2 aTexCoords; out vec2 TexCoords; out VS_OUT { vec3 FragPos; vec3 Normal; vec2 TexCoords; vec4 FragPosLightSpace; } vs_out; uniform mat4 projection; uniform mat4 view; uniform mat4 model; uniform mat4 lightSpaceMatrix; void main() { // 世界坐标 vs_out.FragPos = vec3(model * vec4(aPos, 1.0)); vs_out.Normal = transpose(inverse(mat3(model))) * aNormal; vs_out.TexCoords = aTexCoords; // 在光源视角下的裁剪空间坐标 vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0); gl_Position = projection * view * model * vec4(aPos, 1.0); } 在片段着色器中，通过是否在阴影中来修改是否需要高光和漫反射光，如果在阴影中，则设置为0\n1 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 #version 330 core out vec4 FragColor; in VS_OUT { vec3 FragPos; vec3 Normal; vec2 TexCoords; vec4 FragPosLightSpace; } fs_in; uniform sampler2D diffuseTexture; uniform sampler2D shadowMap; uniform vec3 lightPos; uniform vec3 viewPos; float ShadowCalculation(vec4 fragPosLightSpace) { [...] } void main() { vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb; vec3 normal = normalize(fs_in.Normal); vec3 lightColor = vec3(1.0); // Ambient vec3 ambient = 0.15 * color; // Diffuse vec3 lightDir = normalize(lightPos - fs_in.FragPos); float diff = max(dot(lightDir, normal), 0.0); vec3 diffuse = diff * lightColor; // Specular vec3 viewDir = normalize(viewPos - fs_in.FragPos); vec3 reflectDir = reflect(-lightDir, normal); float spec = 0.0; vec3 halfwayDir = normalize(lightDir + viewDir); spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0); vec3 specular = spec * lightColor; // 计算阴影 float shadow = ShadowCalculation(fs_in.FragPosLightSpace); vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color; FragColor = vec4(lighting, 1.0f); } 计算方法如下 首先根据像素位置对应记录的光源坐标下的NDC坐标（注意要手动进行透视除法,切换到[0,1]范围。在深度贴图中用NDC坐标当作UV坐标去采样，得到该点最近的深度值，然后与该点的深度值进行比较\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 float ShadowCalculation(vec4 fragPosLightSpace) { // 手动进行透视除法，转为NDC坐标 vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; // 变换到[0,1]的范围 projCoords = projCoords * 0.5 + 0.5; // 取得最近点的深度(使用[0,1]范围下的fragPosLight当坐标) float closestDepth = texture(shadowMap, projCoords.xy).r; // 取得当前片段在光源视角下的深度 float currentDepth = projCoords.z; // 检查当前片段是否在阴影中 大离的近 float shadow = currentDepth \u0026gt; closestDepth ? 1.0 : 0.0; return shadow; } 最终渲染出来为 改进阴影贴图 目前的渲染是有问题的 地板四边形渲染出一大块交替黑线。这种阴影贴图的不真实感叫做阴影失真(Shadow Acne)，下图解释了成因：\n阴影贴图受限于分辨率，在离光源很远的情况下，多个片段在深度贴图的同一个位置进行了采样。 我们可以用一个叫做阴影偏移（shadow bias）的技巧来解决这个问题，我们简单的对表面的深度（或深度贴图）应用一个偏移量，这样片段就不会被错误地认为在表面之下了。 1 2 float bias = 0.005; float shadow = currentDepth - bias \u0026gt; closestDepth ? 1.0 : 0.0; 还有更好的方法是自适应的偏移量\n1 float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005); 但是当偏移量过大时，也会出现悬浮效果。这个阴影失真叫做悬浮(Peter Panning)，因为物体看起来轻轻悬浮在表面之上 下边这幅是正常的，很明显能感觉到上图的错误 我们可以使用一个叫技巧解决大部分的Peter panning问题：当渲染深度贴图时候使用正面剔除（front face culling）你也许记得在面剔除教程中OpenGL默认是背面剔除。我们要告诉OpenGL我们要剔除正面。\n因为我们只需要深度贴图的深度值，对于实体物体无论我们用它们的正面还是背面都没问题。使用背面深度不会有错误，因为阴影在物体内部有错误我们也看不见。\n1 2 3 glCullFace(GL_FRONT); RenderSceneToDepthMap(); glCullFace(GL_BACK); // 不要忘记设回原先的culling face 另外还有一个问题是光锥不可见的区域一律被任务处于阴影中了 也就是说有些纹理坐标超出了深度贴图的范围，我们可以修改贴图的环绕参数，将超出部分的深度值全部设置为1，这样，贴图外永远都不在阴影中\n1 2 3 4 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); GLfloat borderColor[] = { 1.0, 1.0, 1.0, 1.0 }; glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor); 但这样处理只解决了一块阴影\n仍有一部分是黑暗区域。那里的坐标超出了光的正交视锥的远平面。你可以看到这片黑色区域总是出现在光源视锥的极远处。\n当一个点比光的远平面还要远时，它的投影坐标的z坐标大于1.0。这种情况下，GL_CLAMP_TO_BORDER环绕方式不起作用，因为我们把坐标的z元素和深度贴图的值进行了对比；它总是为大于1.0的z返回true。\n直接强制让z值大于1的位置（在远平面外的点）设置不在阴影中 PCF 目前的阴影表面仍有锯齿 因为深度贴图有一个固定的分辨率，多个片段对应于一个纹理像素。结果就是多个片段会从深度贴图的同一个深度值进行采样，这几个片段便得到的是同一个阴影，这就会产生锯齿边。\n你可以通过增加深度贴图的分辨率的方式来降低锯齿块，也可以尝试尽可能的让光的视锥接近场景。\n另一个（并不完整的）解决方案叫做PCF（percentage-closer filtering），这是一种多个不同过滤方式的组合，它产生柔和阴影，使它们出现更少的锯齿块和硬边。核心思想是从深度贴图中多次采样，每一次采样的纹理坐标都稍有不同。每个独立的样本可能在也可能不再阴影中。所有的次生结果接着结合在一起，进行平均化，我们就得到了柔和阴影。\n1 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 float ShadowCalculation(vec4 fragPosLightSpace) { // perform perspective divide vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; // Transform to [0,1] range projCoords = projCoords * 0.5 + 0.5; // Get closest depth value from light\u0026#39;s perspective (using [0,1] range fragPosLight as coords) float closestDepth = texture(shadowMap, projCoords.xy).r; // Get depth of current fragment from light\u0026#39;s perspective float currentDepth = projCoords.z; // Calculate bias (based on depth map resolution and slope) vec3 normal = normalize(fs_in.Normal); vec3 lightDir = normalize(lightPos - fs_in.FragPos); float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005); // Check whether current frag pos is in shadow // float shadow = currentDepth - bias \u0026gt; closestDepth ? 1.0 : 0.0; // PCF float shadow = 0.0; vec2 texelSize = 1.0 / textureSize(shadowMap, 0); for(int x = -1; x \u0026lt;= 1; ++x) { for(int y = -1; y \u0026lt;= 1; ++y) { float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; shadow += currentDepth - bias \u0026gt; pcfDepth ? 1.0 : 0.0; } } shadow /= 9.0; // Keep the shadow at 0.0 when outside the far_plane region of the light\u0026#39;s frustum. if(projCoords.z \u0026gt; 1.0) shadow = 0.0; return shadow; } 正交 vs 投影 在渲染深度贴图的时候，正交(Orthographic)和投影(Projection)矩阵之间有所不同。正交投影矩阵并不会将场景用透视图进行变形，所有视线/光线都是平行的，这使它对于定向光来说是个很好的投影矩阵。然而透视投影矩阵，会将所有顶点根据透视关系进行变形，结果因此而不同。下图展示了两种投影方式所产生的不同阴影区域： 如果使用的是透视投影，在使用深度贴图时，需要先将深度转为线性深度\n1 2 3 4 5 float LinearizeDepth(float depth) { float z = depth * 2.0 - 1.0; // Back to NDC return (2.0 * near_plane * far_plane) / (far_plane + near_plane - z * (far_plane - near_plane)); } 后续对于阴影的优化放在Games202课程作业一中进行~\n另外第二章点阴影就是在一个向四面八方发射光的点光源的阴影渲染，实现就是参考天空盒类似的6个面的shadowmap来实现\n","date":"2025-10-19T13:50:26+08:00","image":"https://sdpyy1.github.io/e876017653874542a3d6dcca727f0a04.png","permalink":"https://sdpyy1.github.io/p/opengl%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0blinn-phong%E4%BC%BD%E9%A9%AC%E7%9F%AB%E6%AD%A3%E9%98%B4%E5%BD%B1/","title":"OpenGL学习笔记（Blinn Phong、伽马矫正、阴影）"},{"content":"简介 在学习完Games101，以及手撸一个软光栅之后，来学习一下OpenGL~ OpenGL常被视作提供图形操作函数的API，但其本质是由Khronos制定的规范（Specification），仅严格定义函数行为及输出标准，具体实现（如底层优化、硬件适配）由开发者（如显卡厂商）自行完成。\n核心模式与立即渲染模式 OpenGL早期采用立即渲染模式（固定管线），简化了图形绘制但效率低且控制受限。随着版本迭代，OpenGL 3.2起废弃此模式，转向‌核心模式‌，强制使用现代函数并移除旧特性。核心模式虽需深入理解图形编程（如手动管理渲染流程），但显著提升了灵活性与性能，同时迫使开发者掌握底层细节，牺牲易用性换取更高效的硬件控制能力。\n状态机 OpenGL自身是一个巨大的状态机(State Machine)：一系列的变量描述OpenGL此刻应当如何运行。OpenGL的状态通常被称为OpenGL上下文(Context)。我们通常使用如下途径去更改OpenGL状态：设置选项，操作缓冲。最后，我们使用当前OpenGL上下文来渲染。下面是一个例子\n1 2 3 4 // 绑定opengl当前状态的vao GL_CALL(glBindVertexArray(vao)); // 指定下一次属性设置在vao中的位置 GL_CALL(glEnableVertexAttribArray(posAttrib)); 当使用OpenGL的时候，我们会遇到一些状态设置函数(State-changing Function)，这类函数将会改变上下文。以及状态使用函数(State-using Function)，这类函数会根据当前OpenGL的状态执行一些操作。只要你记住OpenGL本质上是个大状态机，就能更容易理解它的大部分特性。\n对象 在OpenGL中一个对象是指一些选项的集合，它代表OpenGL状态的一个子集。这块的解释还看不明白，等学习后再补充\nGLFW和GLAD GLFW是一个专门针对OpenGL的C语言库，它提供了一些渲染物体所需的最低限度的接口。它允许用户创建OpenGL上下文、定义窗口参数以及处理用户输入，对我们来说这就够了。如果没有 GLFW 这样的库，开发者需要自己针对不同的操作系统编写复杂的代码来创建窗口和处理用户输入，这将大大增加开发的难度和工作量。使用 GLFW 可以简化这些底层的操作，让开发者能够更加专注于 OpenGL 的图形渲染部分，提高开发效率\n由于 OpenGL 驱动版本众多，不同的显卡厂商和操作系统对 OpenGL 函数的实现可能会有所不同，而且 OpenGL 中的大多数函数的位置在编译时无法确定，需要在运行时查询。GLAD 的主要作用就是管理 OpenGL 的函数指针，它会根据当前的系统和显卡环境，动态地加载正确的 OpenGL 函数地址，并将这些函数地址保存在函数指针中，以便开发者在程序运行时能够正确地调用 OpenGL 函数。这样可以确保程序在不同的硬件和操作系统环境下都能正常运行，提高了程序的兼容性。\n至于GLFW和glad的安装这里不会提及\nHello OpenGL ⚠️两个头文件的导入顺序。GLAD的头文件包含了正确的OpenGL头文件（例如GL/gl.h），所以需要在其它依赖于OpenGL的头文件之前包含GLAD。\n1 2 3 4 5 6 7 8 9 10 11 #include \u0026lt;glad/glad.h\u0026gt; #include \u0026lt;GLFW/glfw3.h\u0026gt; int main() { glfwInit(); // 对GLFW的配置 版本号、次版本号、选择核心模式 glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); return 0; } 进一步利用GLFW进行窗口创建，并将OpenGL上下文设置到这个窗口上\n1 2 3 4 5 6 7 GLFWwindow* window = glfwCreateWindow(800, 600, \u0026#34;LearnOpenGL\u0026#34;, NULL, NULL); if (window == nullptr){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to create GLFW window\u0026#34; \u0026lt;\u0026lt; std::endl; glfwTerminate(); return -1; } glfwMakeContextCurrent(window); 下一步就是用glad加载函数指针\n1 2 3 4 5 if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { std::cout \u0026lt;\u0026lt; \u0026#34;Failed to initialize GLAD\u0026#34; \u0026lt;\u0026lt; std::endl; return -1; } 紧接着需要提供渲染的窗口大小，这个就是进行视口变换时的依据\n1 2 // 前两个参数控制窗口左下角的位置。第三个和第四个参数控制渲染窗口的宽度和高度（像素） glViewport(0, 0, 800, 600); 下面介绍回调函数\n1 2 3 4 5 6 7 8 void framebuffer_size_callback(GLFWwindow* window, int width, int height){ // 当窗口大小变化时，重新调整视口 glViewport(0, 0, width, height); } --- // 绑定窗口变化事件到framebuffer_size_callback这个函数 glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); 最终就是渲染循环了，毕竟不只是渲染一幅图片\n1 2 3 4 5 6 7 while(!glfwWindowShouldClose(window)) { // 双缓冲 glfwSwapBuffers(window); // 事件处理 glfwPollEvents(); } 双缓冲(Double Buffer) 应用程序使用单缓冲绘图时可能会存在图像闪烁的问题。 这是因为生成的图像不是一下子被绘制出来的，而是按照从左到右，由上而下逐像素地绘制而成的。最终图像不是在瞬间显示给用户，而是通过一步一步生成的，这会导致渲染的结果很不真实。为了规避这些问题，我们应用双缓冲渲染窗口应用程序。前缓冲保存着最终输出的图像，它会在屏幕上显示；而所有的的渲染指令都会在后缓冲上绘制。当所有的渲染指令执行完毕后，我们交换(Swap)前缓冲和后缓冲，这样图像就立即呈显出来，之前提到的不真实感就消除了。\n最后一件事，窗口关闭的处理\n1 2 glfwTerminate(); return 0; 渲染操作都是写在while循环中的，现在简单写一下清屏操作，并通过glClearColor设置清屏后显示的颜色\n1 2 glClearColor(0.2f, 0.3f, 0.3f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); 当前的完整代码如下\n1 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 在这里插入代码片// // Created by 刘卓昊 on 2025/4/3. // #include \u0026lt;glad/glad.h\u0026gt; #include \u0026lt;GLFW/glfw3.h\u0026gt; #include \u0026lt;iostream\u0026gt; void framebuffer_size_callback(GLFWwindow* window, int width, int height){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to create GLFW window\u0026#34; \u0026lt;\u0026lt; std::endl; glViewport(0, 0, width, height); } int main() { glfwInit(); // 对GLFW的配置 版本号、次版本号、选择核心模式 glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); GLFWwindow* window = glfwCreateWindow(800, 600, \u0026#34;LearnOpenGL\u0026#34;, NULL, NULL); if (window == nullptr){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to create GLFW window\u0026#34; \u0026lt;\u0026lt; std::endl; glfwTerminate(); return -1; } glfwMakeContextCurrent(window); if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to initialize GLAD\u0026#34; \u0026lt;\u0026lt; std::endl; return -1; } // 前两个参数控制窗口左下角的位置。第三个和第四个参数控制渲染窗口的宽度和高度（像素） glViewport(0, 0, 800, 600); glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); while(!glfwWindowShouldClose(window)) { // 双缓冲 glfwSwapBuffers(window); // 事件处理 glfwPollEvents(); glClearColor(0.2f, 0.3f, 0.3f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); } glfwTerminate(); return 0; } 运行结果如下图 Triangle 三角形 首先了解三个概念 顶点数组对象：Vertex Array Object，VAO 顶点缓冲对象：Vertex Buffer Object，VBO 元素缓冲对象：Element Buffer Object，EBO 或 索引缓冲对象 Index Buffer Object，IBO\n顶点缓冲对象 VBO OpenGL不是简单地把所有的3D坐标变换为屏幕上的2D像素；OpenGL仅当3D坐标在3个轴（x、y和z）上-1.0到1.0的范围内时才处理它。所有在这个范围内的坐标叫做标准化设备坐标(Normalized Device Coordinates)，此范围内的坐标最终显示在屏幕上（在这个范围以外的坐标则不会显示）。 这里为了简单，没有设置深度\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 float vertices[] = { -0.5f, -0.5f, 0.0f, 0.5f, -0.5f, 0.0f, 0.0f, 0.5f, 0.0f }; 我们通过顶点缓冲对象(Vertex Buffer Objects, VBO)管理这个内存，它会在GPU内存（通常被称为显存）中储存大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上，而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢，所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后，顶点着色器几乎能立即访问顶点，这是个非常快的过程。 顶点缓冲对象是第一个接触的OpenGL对象，需要有一个独一无二的ID，通过glGenBuffers函数来生成一个带有缓冲ID的VBO对象。 ```cpp // 就是unsigned int GLuint VBO; // 此函数会为VBO分配一个未使用的ID（例如 1, 2 等）但此时‌并未实际创建缓冲对象‌，仅预留了标识符 glGenBuffers(1, \u0026amp;VBO); // 绑定到OpenGL上下文中，此时GPU才会真正分配内存 glBindBuffer(GL_ARRAY_BUFFER, VBO); 从此刻起，我们对GL_ARRAY_BUFFER上的操作都属于对当前绑定的VBO进行操作。 glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。第一个参数是目标缓冲的类型：顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位)；用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。至于第四个参数有三种情况：\nGL_STATIC_DRAW ：数据不会或几乎不会改变。 GL_DYNAMIC_DRAW：数据会被改变很多。 GL_STREAM_DRAW ：数据每次绘制时都会改变。 1 glBufferData(GL_ARRAY_BUFFER, sizeof(vertices),vertices,GL_STATIC_DRAW); 现在我们已经把顶点数据储存在显卡的内存中，用VBO这个顶点缓冲对象管理。下面我们会创建一个顶点着色器和片段着色器来真正处理这些数据。现在我们开始着手创建它们吧。\n先简单来进行硬编码写一个简单的顶点着色器\n1 2 3 4 5 6 const char * vertexShaderSource = \u0026#34;#version 330 core\\n\u0026#34; \u0026#34;layout (location=0) in vec3 aPos;\\n\u0026#34; \u0026#34;void main()\\n\u0026#34; \u0026#34;{\\n\u0026#34; \u0026#34; gl_Position = vec4(aPos.x,aPos.y,aPos.z,1.0);\\n\u0026#34; \u0026#34;}\\0\u0026#34;; 为了运行glsl，需要在运行时动态编译源代码。需要先创建着色器对象，并编译\n1 2 3 4 5 6 // 创建着色器 GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER); // 源码添加在shader上 glShaderSource(vertexShader,1,\u0026amp;vertexShaderSource, nullptr); // 编译源码 glCompileShader(vertexShader); 如果想检查运行编译是否正确，以及报错信息也是比较麻烦\n1 2 3 4 5 6 7 8 int success; char infoLog[512]; glGetShaderiv(vertexShader, GL_COMPILE_STATUS, \u0026amp;success); if(!success) { glGetShaderInfoLog(vertexShader, 512, nullptr, infoLog); std::cout \u0026lt;\u0026lt; \u0026#34;ERROR::SHADER::VERTEX::COMPILATION_FAILED\\n\u0026#34; \u0026lt;\u0026lt; infoLog \u0026lt;\u0026lt; std::endl; } 片段着色器操作类似，主要处理光栅化后每个像素的着色\n1 2 3 4 5 6 7 8 9 10 const char * fragmentShaderSource = \u0026#34;#version 330 core\\n\u0026#34; \u0026#34;out vec4 FragColor;\\n\u0026#34; \u0026#34;\\n\u0026#34; \u0026#34;void main()\\n\u0026#34; \u0026#34;{\\n\u0026#34; \u0026#34; FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\\n\u0026#34; \u0026#34;} \u0026#34;; unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fragmentShader, 1, \u0026amp;fragmentShaderSource, NULL); glCompileShader(fragmentShader); 最终还需要链接成一个程序\n1 2 3 4 5 unsigned int shaderProgram; shaderProgram = glCreateProgram(); glAttachShader(shaderProgram, vertexShader); glAttachShader(shaderProgram, fragmentShader); glLinkProgram(shaderProgram); 检查报错信息\n1 2 3 4 glGetProgramiv(shaderProgram, GL_LINK_STATUS, \u0026amp;success); if(!success) { glGetProgramInfoLog(shaderProgram, 512, nullptr, infoLog); } 最后还需要use这个程序\n1 glUseProgram(shaderProgram); 顶点数组对象 VAO 顶点着色器允许我们指定任何以顶点属性为形式的输入。这使其具有很强的灵活性的同时，它还的确意味着我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以，我们必须在渲染前指定OpenGL该如何解释顶点数据。顶点数据的解析方式如下 我们需要告诉OpenGL如何解析顶点数据，比如紧密的字节序列的一段代表一个顶点的数据，但数据中可能不止顶点的位置，可能还有顶点的颜色之类的，需要告诉OpenGL一个顶点数据有多大，顶点位置的偏移量是多少（因为不一定一开始就是位置信息）\n1 2 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); 上边代码中的0就是之前写顶点着色器代码中的(location=0) 如果把这些状态配置存储在一个对象中，通过绑定他来启动该状态，这就是VAO。 顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定。VAO中存储的内容如下 VAO创建过程与VBO类似\n1 2 3 4 5 GLuint VAO; glGenVertexArrays(1, \u0026amp;VAO); glBindVertexArray(VAO); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); 当创建并绑定好VAO和VBO后就可以进行渲染了 ⚠️ 渲染函数要写到While循环里\n1 2 // while中 glDrawArrays(GL_TRIANGLES, 0, 3); 元素缓冲对象 EBO/ 索引缓冲对象 IEO 其实就是保存顶点的顺序，比如如果VAO中是这样的数据\n1 2 3 4 5 6 7 8 9 10 float vertices[] = { // 第一个三角形 0.5f, 0.5f, 0.0f, // 右上角 0.5f, -0.5f, 0.0f, // 右下角 -0.5f, 0.5f, 0.0f, // 左上角 // 第二个三角形 0.5f, -0.5f, 0.0f, // 右下角 -0.5f, -0.5f, 0.0f, // 左下角 -0.5f, 0.5f, 0.0f // 左上角 }; 我们指定了右下角和左上角两次！一个矩形只有4个而不是6个顶点，这样就产生50%的额外开销。EBO是一个缓冲区，就像一个顶点缓冲区对象一样，它存储 OpenGL 用来决定要绘制哪些顶点的索引。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 float vertices[] = { 0.5f, 0.5f, 0.0f, // 右上角 0.5f, -0.5f, 0.0f, // 右下角 -0.5f, -0.5f, 0.0f, // 左下角 -0.5f, 0.5f, 0.0f // 左上角 }; unsigned int indices[] = { // 注意索引从0开始! // 此例的索引(0,1,2,3)就是顶点数组vertices的下标， // 这样可以由下标代表顶点组合成矩形 0, 1, 3, // 第一个三角形 1, 2, 3 // 第二个三角形 }; EBO创建和绑定方法与VBO基本一致\n1 2 3 4 GLuint EBO; glGenBuffers(1, \u0026amp;EBO); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); glDrawElements绘制的时候就不是直接绘制了，而是使用EBO获取索引来绘制\n1 glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); **另外可以使用VAO中的数据来保存EBO。在绑定VAO时，绑定的最后一个元素缓冲区对象存储为VAO的元素缓冲区对象。然后，绑定到VAO也会自动绑定该EBO。**这步操作是自动进行的，但需要先绑定VAO。 如果想绘制线框模型可以用以下代码来决定是线框模型还是填充模型\n1 2 glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 着色器 在Hello Triangle教程中提到，着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。从基本意义上来说，着色器只是一种把输入转化为输出的程序。着色器也是一种非常独立的程序，因为它们之间不能相互通信；它们之间唯一的沟通只有通过输入和输出。\nGLSL 着色器是使用一种叫GLSL的类C语言写成的。GLSL是为图形计算量身定制的，它包含一些针对向量和矩阵操作的有用特性。 着色器的开头总是要声明版本，接着是输入和输出变量、uniform和main函数。每个着色器的入口点都是main函数，在这个函数中我们处理所有的输入变量，并将结果输出到输出变量中。 一个典型的着色器结构如下\n1 2 3 4 5 6 7 8 9 10 11 12 13 #version version_number in type in_variable_name; in type in_variable_name; out type out_variable_name; uniform type uniform_name; void main() { // 处理输入并进行一些图形操作 ... // 输出处理过的结果到输出变量 out_variable_name = weird_stuff_we_processed; } 当我们特别谈论到顶点着色器的时候，每个输入变量也叫顶点属性(Vertex Attribute)，我们能声明的顶点属性是有上限的，它一般由硬件来决定。OpenGL确保至少有16个包含4分量的顶点属性可用\n数据类型 基础数据类型有：int、float、double、uint、bool 另外还有向量 向量可以有一些有趣的重组操作\n1 2 3 4 vec2 someVec; vec4 differentVec = someVec.xyxx; vec3 anotherVec = differentVec.zyw; vec4 otherVec = someVec.xxxx + anotherVec.yxzy; 输入输出 GLSL定义了in和out关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出，只要一个输出变量与下一个着色器阶段的输入匹配，它就会传递下去。在顶点着色器和片段着色器有一些不同。 顶点着色器的输入特殊在，它从顶点数据中直接接收输入。为了定义顶点数据该如何管理，我们使用location这一元数据指定输入变量，这样我们才可以在CPU上配置顶点属性。例如layout (location = 0)，除此之外也可以这样设置\n1 2 3 4 // 根据glsl的参数输入顺序来获取vao中数据定义的顺序 GLint posAttrib = glGetAttribLocation(program, \u0026#34;aPos\u0026#34;); GLint colorAttrib = glGetAttribLocation(program, \u0026#34;aColor\u0026#34;); GLint uvAttrib = glGetAttribLocation(program, \u0026#34;aUV\u0026#34;); 这样设置的情况下，glsl就不要写location了\n1 2 3 4 5 6 const char* vertexShaderSource = \u0026#34;#version 330 core\\n\u0026#34; // \u0026#34;layout (location = 0) in vec3 aPos;\\n\u0026#34; // 输入位置1的3维坐标 \u0026#34;in vec3 aPos;\\n\u0026#34; // 输入位置1的3维坐标(不指定位置版本，就按从头开始) // \u0026#34;layout (location = 1) in vec3 aColor;\\n\u0026#34; // 输入位置2的颜色数据 // \u0026#34;in vec3 aColor;\\n\u0026#34; // 输入位置2的颜色数据(不指定位置版本，就是从上一个变量输入完成之后的3个数据)‘ \u0026#34;in vec2 aUV;\\n\u0026#34; 在片段着色器中，需要一个vec4颜色的输出变量，因为他的目的就是生成最终的颜色。如果你在片段着色器没有定义输出颜色，OpenGL会把你的物体渲染为黑色（或白色）。所以，如果我们打算从一个着色器向另一个着色器发送数据，我们必须在发送方着色器中声明一个输出，在接收方着色器中声明一个类似的输入。当类型和名字都一样的时候，OpenGL就会把两个变量链接到一起，它们之间就能发送数据了（这是在链接程序对象时完成的），下面是一个例子，vertexColor变量就是从顶点着色器跑到片段着色器的\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 顶点 #version 330 core layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为0 out vec4 vertexColor; // 为片段着色器指定一个颜色输出 void main() { gl_Position = vec4(aPos, 1.0); // 注意我们如何把一个vec3作为vec4的构造器的参数 vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 把输出变量设置为暗红色 } //片段 #version 330 core out vec4 FragColor; in vec4 vertexColor; // 从顶点着色器传来的输入变量（名称相同、类型相同） void main() { FragColor = vertexColor; } Uniform uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的，而且它可以被着色器程序的任意着色器在任意阶段访问。第二，无论你把uniform值设置成什么，uniform会一直保存它们的数据，直到它们被重置或更新。\n1 2 3 4 5 6 7 8 9 #version 330 core`在这里插入代码片` out vec4 FragColor; uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量 void main() { FragColor = ourColor; } 如果你声明了一个uniform却在GLSL代码中没用过，编译器会静默移除这个变量，导致最后编译出的版本中并不会包含它，这可能导致几个非常麻烦的错误，记住这点！\n这个uniform现在还是空的；我们还没有给它添加任何数据，所以下面我们就做这件事。我们首先需要找到着色器中uniform属性的索引/位置值。当我们得到uniform的索引/位置值后，我们就可以更新它的值了。调用glUseProgram来设置uniform的值\n1 2 3 4 5 float timeValue = glfwGetTime(); float greenValue = (sin(timeValue) / 2.0f) + 0.5f; int vertexColorLocation = glGetUniformLocation(shaderProgram, \u0026#34;ourColor\u0026#34;); glUseProgram(shaderProgram); glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f); 最后提一点，如果在VBO中不止位置属性，还有颜色属性如下图 可以这样设置\n1 2 3 4 5 6 // 位置属性 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); // 颜色属性 glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float))); glEnableVertexAttribArray(1); 把原本的代码写在这里记录一下\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 // // Created by 刘卓昊 on 2025/4/3. // #include \u0026lt;glad/glad.h\u0026gt; #include \u0026lt;GLFW/glfw3.h\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;valarray\u0026gt; void framebuffer_size_callback(GLFWwindow* window, int width, int height){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to create GLFW window\u0026#34; \u0026lt;\u0026lt; std::endl; glViewport(0, 0, width, height); } GLFWwindow * InitWindowAndFunc(){ glfwInit(); // 对GLFW的配置 版本号、次版本号、选择核心模式 glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); GLFWwindow* window = glfwCreateWindow(800, 600, \u0026#34;LearnOpenGL\u0026#34;, nullptr, nullptr); if (window == nullptr){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to create GLFW window\u0026#34; \u0026lt;\u0026lt; std::endl; glfwTerminate(); return nullptr; } glfwMakeContextCurrent(window); if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to initialize GLAD\u0026#34; \u0026lt;\u0026lt; std::endl; return nullptr; } // 前两个参数控制窗口左下角的位置。第三个和第四个参数控制渲染窗口的宽度和高度（像素） glViewport(0, 0, 800, 600); glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); return window; } int main() { float vertices[] = { 0.5f, 0.5f, 0.0f, // 右上角 0.5f, -0.5f, 0.0f, // 右下角 -0.5f, -0.5f, 0.0f, // 左下角 -0.5f, 0.5f, 0.0f // 左上角 }; unsigned int indices[] = { // 注意索引从0开始! // 此例的索引(0,1,2,3)就是顶点数组vertices的下标， // 这样可以由下标代表顶点组合成矩形 0, 1, 3, // 第一个三角形 1, 2, 3 // 第二个三角形 }; GLFWwindow * window = InitWindowAndFunc(); // 创建一个ID GLuint VBO; // 此函数会为VBO分配一个未使用的ID（例如 1, 2 等），但此时‌并未实际创建缓冲对象‌，仅预留了标识符 glGenBuffers(1, \u0026amp;VBO); // 绑定到OpenGL上下文中，此时GPU才会真正分配内存 glBindBuffer(GL_ARRAY_BUFFER, VBO); //glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型：顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位)；用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。 glBufferData(GL_ARRAY_BUFFER, sizeof(vertices),vertices,GL_STATIC_DRAW); // 顶点着色器 const char * vertexShaderSource = \u0026#34;#version 330 core\\n\u0026#34; \u0026#34;layout (location=0) in vec3 aPos;\\n\u0026#34; \u0026#34;void main()\\n\u0026#34; \u0026#34;{\\n\u0026#34; \u0026#34; gl_Position = vec4(aPos.x,aPos.y,aPos.z,1.0);\\n\u0026#34; \u0026#34;}\\0\u0026#34;; // 创建着色器 GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER); // 源码添加在shader上 glShaderSource(vertexShader,1,\u0026amp;vertexShaderSource, nullptr); // 编译源码 glCompileShader(vertexShader); int success; char infoLog[512]; glGetShaderiv(vertexShader, GL_COMPILE_STATUS, \u0026amp;success); if(!success) { glGetShaderInfoLog(vertexShader, 512, nullptr, infoLog); std::cout \u0026lt;\u0026lt; \u0026#34;ERROR::SHADER::VERTEX::COMPILATION_FAILED\\n\u0026#34; \u0026lt;\u0026lt; infoLog \u0026lt;\u0026lt; std::endl; } const char * fragmentShaderSource = \u0026#34;#version 330 core\\n\u0026#34; \u0026#34;out vec4 FragColor;\\n\u0026#34; \u0026#34;uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量\\n\u0026#34; \u0026#34;\\n\u0026#34; \u0026#34;void main()\\n\u0026#34; \u0026#34;{\\n\u0026#34; \u0026#34; FragColor = ourColor;\\n\u0026#34; \u0026#34;} \u0026#34;; unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fragmentShader, 1, \u0026amp;fragmentShaderSource, NULL); glCompileShader(fragmentShader); // 整合成一个程序 unsigned int shaderProgram; shaderProgram = glCreateProgram(); glAttachShader(shaderProgram, vertexShader); glAttachShader(shaderProgram, fragmentShader); glLinkProgram(shaderProgram); // 链接阶段报错处理 glGetProgramiv(shaderProgram, GL_LINK_STATUS, \u0026amp;success); if(!success) { glGetProgramInfoLog(shaderProgram, 512, nullptr, infoLog); } // 设置使用 glUseProgram(shaderProgram); // VAO GLuint VAO; glGenVertexArrays(1, \u0026amp;VAO); glBindVertexArray(VAO); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); // EBO GLuint EBO; glGenBuffers(1, \u0026amp;EBO); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); // glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); while(!glfwWindowShouldClose(window)) { // 双缓冲 glfwSwapBuffers(window); // 事件处理 glfwPollEvents(); glClearColor(0.2f, 0.3f, 0.3f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); // 更新uniform颜色 float timeValue = glfwGetTime(); float greenValue = sin(timeValue) / 2.0f + 0.5f; int vertexColorLocation = glGetUniformLocation(shaderProgram, \u0026#34;ourColor\u0026#34;); glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f); // 通过绑定好的VAO和VBO和EBO画三角形 glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); } glfwTerminate(); return 0; } 下来给出一个封装的shader类\n1 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 // // Created by Administrator on 2025/4/4. // #ifndef OPENGL_SHADER_H #define OPENGL_SHADER_H #include \u0026lt;glad/glad.h\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;fstream\u0026gt; #include \u0026lt;sstream\u0026gt; #include \u0026lt;iostream\u0026gt; class Shader { public: GLuint ID; Shader(const char*vertexPath, const char*fragmentPath); void use(); // uniform工具函数 void setBool(const std::string \u0026amp;name, bool value) const; void setInt(const std::string \u0026amp;name, int value) const; void setFloat(const std::string \u0026amp;name, float value) const; void setVec4f(const std::string \u0026amp;name, float v0, float v1, float v2, float v3) const; }; #endif //OPENGL_SHADER_H 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 // // Created by Administrator on 2025/4/4. // #include \u0026#34;Shader.h\u0026#34; Shader::Shader(const char *vertexPath, const char *fragmentPath) { // 1. 从文件路径中获取顶点/片段着色器 std::string vertexCode; std::string fragmentCode; std::ifstream vShaderFile; std::ifstream fShaderFile; // 保证ifstream对象可以抛出异常： vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit); fShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit); try{ // 打开文件 vShaderFile.open(vertexPath); fShaderFile.open(fragmentPath); std::stringstream vShaderStream, fShaderStream; // 读取文件的缓冲内容到数据流中 vShaderStream \u0026lt;\u0026lt; vShaderFile.rdbuf(); fShaderStream \u0026lt;\u0026lt; fShaderFile.rdbuf(); // 关闭文件处理器 vShaderFile.close(); fShaderFile.close(); // 转换数据流到string vertexCode = vShaderStream.str(); fragmentCode = fShaderStream.str(); }catch(std::ifstream::failure e){ std::cout \u0026lt;\u0026lt; \u0026#34;ERROR::SHADER::FILE_NOT_READ\u0026#34; \u0026lt;\u0026lt; std::endl; } const char* vShaderCode = vertexCode.c_str(); const char* fShaderCode = fragmentCode.c_str(); // 2. 编译着色器 unsigned int vertex, fragment; int success; char infoLog[512]; // 顶点着色器 vertex = glCreateShader(GL_VERTEX_SHADER); glShaderSource(vertex, 1, \u0026amp;vShaderCode, NULL); glCompileShader(vertex); // 打印编译错误（如果有的话） glGetShaderiv(vertex, GL_COMPILE_STATUS, \u0026amp;success); if(!success) { glGetShaderInfoLog(vertex, 512, NULL, infoLog); std::cout \u0026lt;\u0026lt; \u0026#34;ERROR::SHADER::VERTEX::COMPILATION_FAILED\\n\u0026#34; \u0026lt;\u0026lt; infoLog \u0026lt;\u0026lt; std::endl; }; // 片段着色器 fragment = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fragment, 1, \u0026amp;fShaderCode, NULL); glCompileShader(fragment); // 打印编译错误（如果有的话） glGetShaderiv(fragment, GL_COMPILE_STATUS, \u0026amp;success); if(!success) { glGetShaderInfoLog(fragment, 512, NULL, infoLog); std::cout \u0026lt;\u0026lt; \u0026#34;ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\\n\u0026#34; \u0026lt;\u0026lt; infoLog \u0026lt;\u0026lt; std::endl; } // 着色器程序 ID = glCreateProgram(); glAttachShader(ID, vertex); glAttachShader(ID, fragment); glLinkProgram(ID); // 打印连接错误（如果有的话） glGetProgramiv(ID, GL_LINK_STATUS, \u0026amp;success); if(!success) { glGetProgramInfoLog(ID, 512, NULL, infoLog); std::cout \u0026lt;\u0026lt; \u0026#34;ERROR::SHADER::PROGRAM::LINKING_FAILED\\n\u0026#34; \u0026lt;\u0026lt; infoLog \u0026lt;\u0026lt; std::endl; } // 删除着色器，它们已经链接到我们的程序中了，已经不再需要了 glDeleteShader(vertex); glDeleteShader(fragment); } void Shader::use() { glUseProgram(ID); } void Shader::setBool(const std::string \u0026amp;name, bool value) const { glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value); } void Shader::setInt(const std::string \u0026amp;name, int value) const { glUniform1i(glGetUniformLocation(ID, name.c_str()), value); } void Shader::setFloat(const std::string \u0026amp;name, float value) const { glUniform1f(glGetUniformLocation(ID, name.c_str()), value); } void Shader::setVec4f(const std::string \u0026amp;name, float v0, float v1, float v2, float v3) const { glUniform4f(glGetUniformLocation(ID, name.c_str()), v0, v1, v2, v3); } 纹理 首先需要给每个顶点一个纹理坐标\n1 2 3 4 5 float texCoords[] = { 0.0f, 0.0f, // 左下角 1.0f, 0.0f, // 右下角 0.5f, 1.0f // 上中 }; 纹理坐标的范围通常是从(0, 0)到(1, 1)，当我们设置成别的区域时，OpenGL通过参数调整有不同的表达方式 可以对每个坐标轴的行为进行单独控制\n1 2 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT); 纹理过滤 首先介绍一个概念：纹理像素，打开一张图片的每个像素就是纹理像素。OpenGL以一个顶点设置的纹理坐标数据去查找纹理图像上的像素，然后进行采样提取纹理像素的颜色。当我需要渲染到一片很大的光栅时，每个像素点插值出来的纹理坐标没法直接对应纹理图片的一个像素（即纹理像素），比如纹理坐标为(0.1,0.1)时，如果原纹理图片是10x10的，这就表示第一行第一列的那个纹理像素的颜色作为该像素点的颜色（当然真实情况是0+0.5才是一个纹理像素的中心，这里只是便于理解），但是如果纹理坐标插值出来是(0.11,0.11)那就没法直接对应了，就需要特殊处理了。 OpenGL也有对于纹理过滤(Texture Filtering)的选项。纹理过滤有很多个选项，但是现在我们只讨论最重要的两种：GL_NEAREST和GL_LINEAR。\nGL_NEAREST（也叫邻近过滤，Nearest Neighbor Filtering）是OpenGL默认的纹理过滤方式，即选择离纹理坐标最接近的纹理像素的颜色 GL_LINEAR（也叫线性过滤，(Bi)linear Filtering）它会基于纹理坐标附近的纹理像素，计算出一个插值，近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近，那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色 Mipmap 多级渐远纹理 有些物体会很远，但其纹理会拥有与近处物体同样高的分辨率。比如一个物体光栅化后只占了2x2的像素，但他的纹理图片有10x10，它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉，更不用说对它们使用高分辨率纹理浪费内存的问题了。 OpenGL使用一种叫做多级渐远纹理(Mipmap)的概念来解决这个问题，它简单来说就是一系列的纹理图像，后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单：距观察者的距离超过一定的阈值，OpenGL会使用不同的多级渐远纹理，即最适合物体的距离的那个。由于距离远，解析度不高也不会被用户注意到。同时，多级渐远纹理另一加分之处是它的性能非常好。让我们看一下多级渐远纹理是什么样子的 离摄像机距离较远的物体采样时，在小纹理上采样效果更好。对Mipmap的使用也有多种过滤方式 最后一种参数额外介绍一下GL_LINEAR_MIPMAP_LINEAR（三线性过滤），在最接近的两个mipmap上进行线性插值，最后混合结果，计算量时最大的\n1 2 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 第一行是对渲染物体小于纹理图片时，使用Mipmap，第二个是当渲染物体大于纹理图片时的设置\n实际使用方式 stb_image.h是Sean Barrett的一个非常流行的单头文件图像加载库，下载源代码后使用方式如下\n1 2 #define STB_IMAGE_IMPLEMENTATION #include \u0026#34;stb_image.h\u0026#34; 首先加载一个纹理图片。这个函数首先接受一个图像文件的位置作为输入。接下来它需要三个int作为它的第二、第三和第四个参数，stb_image.h将会用图像的宽度、高度和颜色通道的个数填充这三个变量。我们之后生成纹理的时候会用到的图像的宽度和高度的。\n1 2 int width, height, nrChannels; unsigned char *data = stbi_load(\u0026#34;./assert/container.jpg\u0026#34;, \u0026amp;width, \u0026amp;height, \u0026amp;nrChannels, 0); 对纹理的管理与之前那些Object类似，也得创建id\n1 2 GLuint texture; glGenTextures(1, \u0026amp;texture); 紧接着也需要进行绑定，这样之后对纹理的设置都是设置绑定的纹理\n1 glBindTexture(GL_TEXTURE_2D, texture); 下来就是把载入的图片生成纹理了，上边的绑定就是为了下边设置时不需要再考虑是给谁设置了\n1 2 3 4 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data); glGenerateMipmap(GL_TEXTURE_2D); // 释放掉图片数据，因为已经转移好了 stbi_image_free(data); 下来设置一下纹理的环绕和过滤方式\n1 2 3 4 5 // 为当前绑定的纹理对象设置环绕、过滤方式 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 这样就设置好纹理了，当然顶点数据还要额外存储纹理坐标，不然每个像素去纹理图哪个位置找还不知道，在VBO中存储顶点位置、颜色、纹理坐标，同时需要告诉VAO\n1 2 3 4 5 6 7 float vertices[] = { // ---- 位置 ---- ---- 颜色 ---- - 纹理坐标 - 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上 0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下 -0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下 -0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上 }; 告诉VAO，纹理数据是如何存储的，即每个顶点数据站8个字节，纹理数据的偏移为6\n1 2 glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float))); glEnableVertexAttribArray(2); 同时需要在顶点着色器中接收参数，接收了每个顶点的纹理坐标，并输出到片段着色器中\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aColor; layout (location = 2) in vec2 aTexCoord; out vec3 ourColor; out vec2 TexCoord; void main() { gl_Position = vec4(aPos, 1.0); ourColor = aColor; TexCoord = aTexCoord; } 片段着色器接收插值后的纹理坐标和纹理图片（uniform ） texture是本身就有的函数，根据输入的纹理图片和纹理坐标，根据之前对纹理图片的设置进行采样\n1 2 3 4 5 6 7 8 9 10 11 12 #version 330 core out vec4 FragColor; in vec3 ourColor; in vec2 TexCoord; uniform sampler2D ourTexture; void main() { FragColor = texture(ourTexture, TexCoord); } 现在只剩下在调用glDrawElements之前绑定纹理了，它会自动把纹理赋值给片段着色器的采样器\n1 2 3 glBindTexture(GL_TEXTURE_2D, texture); glBindVertexArray(VAO); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); 如果你的纹理代码不能正常工作或者显示是全黑，请继续阅读，并一直跟进我们的代码到最后的例子，它是应该能够工作的。在一些驱动中，必须要对每个采样器uniform都附加上纹理单元才可以，这个会在下面介绍。\n纹理单元 我们通过uniform设置的纹理，但是没有在代码中给他赋值呀。使用glUniform1i，我们可以给纹理采样器分配一个位置值，这样的话我们能够在一个片段着色器中设置多个纹理。一个纹理的默认纹理单元是0，它是默认的激活纹理单元，所以教程前面部分我们没有分配一个位置值。纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器，我们可以一次绑定多个纹理，只要我们首先激活对应的纹理单元。\n1 2 glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元 glBindTexture(GL_TEXTURE_2D, texture); 激活纹理单元之后，绑定的纹理会绑定到激活的纹理单元。OpenGL至少保证有16个纹理单元供你使用，也就是说你可以激活从GL_TEXTURE0到GL_TEXTRUE15。\n就这块东西可以理解为一个像素点的颜色不一定只来自于一张纹理图，比如普通贴图、法线贴图、高光贴图共同配合才能完成一个很好的效果，具体例子可以看我之前的tinyrenderer相关博客的例子。https://blog.csdn.net/lzh804121985/article/details/146939272?spm=1001.2014.3001.5502（直接拉到最后看各种图片）\n当设置两个不同的纹理图时，要修改片段着色器的内容，这里就是混合两张纹理图的内容\n1 2 3 4 5 6 7 uniform sampler2D texture1; uniform sampler2D texture2; void main() { FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2); } 因为这里定义的其实是两个采样器，而不是两个纹理，我觉得修改名字比较直观\n1 2 3 4 5 6 7 uniform sampler2D sampler1; uniform sampler2D sampler2; void main() { FragColor = mix(texture(sampler1, TexCoord), texture(sampler2, TexCoord), 0.2); }} 最后就需要指定每个sample对应的是哪个纹理单元了\n1 2 3 4 // 设置 Uniform（目的是给片段着色器中定义的两个采样器，告诉他们分别对应的是哪个纹理单元） // glUniform1i(glGetUniformLocation(ourShader.ID, \u0026#34;texture1\u0026#34;), 0); // 手动设置 ourShader.setInt(\u0026#34;sampler1\u0026#34;, 0); ourShader.setInt(\u0026#34;sampler2\u0026#34;, 1); 完整代码如下\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 // // Created by 刘卓昊 on 2025/4/3. // #include \u0026lt;glad/glad.h\u0026gt; #include \u0026lt;GLFW/glfw3.h\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026#34;Shader.h\u0026#34; #define STB_IMAGE_IMPLEMENTATION #include \u0026lt;stb_image.h\u0026gt; #define GL_CALL(x) \\ do { \\ x; \\ GLenum error = glGetError(); \\ if (error != GL_NO_ERROR) { \\ std::cerr \u0026lt;\u0026lt; \u0026#34;OpenGL error: \u0026#34; \u0026lt;\u0026lt; error \u0026lt;\u0026lt; \u0026#34; at \u0026#34; \u0026lt;\u0026lt; __FILE__ \u0026lt;\u0026lt; \u0026#34;:\u0026#34; \u0026lt;\u0026lt; __LINE__ \u0026lt;\u0026lt; std::endl; \\ } \\ } while (0) void framebuffer_size_callback(GLFWwindow* window, int width, int height) { GL_CALL(glViewport(0, 0, width, height)); } GLFWwindow * InitWindowAndFunc() { glfwInit(); // 对GLFW的配置 版本号、次版本号、选择核心模式 glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); GLFWwindow* window = glfwCreateWindow(800, 600, \u0026#34;LearnOpenGL\u0026#34;, nullptr, nullptr); if (window == nullptr) { std::cout \u0026lt;\u0026lt; \u0026#34;Failed to create GLFW window\u0026#34; \u0026lt;\u0026lt; std::endl; glfwTerminate(); return nullptr; } glfwMakeContextCurrent(window); if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { std::cout \u0026lt;\u0026lt; \u0026#34;Failed to initialize GLAD\u0026#34; \u0026lt;\u0026lt; std::endl; return nullptr; } // 前两个参数控制窗口左下角的位置。第三个和第四个参数控制渲染窗口的宽度和高度（像素） GL_CALL(glViewport(0, 0, 800, 600)); glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); return window; } int main() { float vertices[] = { // ---- 位置 ---- ---- 颜色 ---- - 纹理坐标 - 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上 0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下 -0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下 -0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上 }; unsigned int indices[] = { // 注意索引从0开始! // 此例的索引(0,1,2,3)就是顶点数组vertices的下标， // 这样可以由下标代表顶点组合成矩形 0, 1, 3, // 第一个三角形 1, 2, 3 // 第二个三角形 }; GLFWwindow * window = InitWindowAndFunc(); // shader Shader ourShader(\u0026#34;./shader/shader.vs\u0026#34;, \u0026#34;./shader/shader.fs\u0026#34;); // 创建Object的ID GLuint VBO, VAO, EBO; GL_CALL(glGenVertexArrays(1, \u0026amp;VAO)); GL_CALL(glGenBuffers(1, \u0026amp;VBO)); GL_CALL(glGenBuffers(1, \u0026amp;EBO)); GL_CALL(glBindVertexArray(VAO)); // 绑定到OpenGL上下文中，此时GPU才会真正分配内存 GL_CALL(glBindBuffer(GL_ARRAY_BUFFER, VBO)); //glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型：顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位)；用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。 GL_CALL(glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW)); // EBO GL_CALL(glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO)); GL_CALL(glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW)); // VAO GL_CALL(glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0)); GL_CALL(glEnableVertexAttribArray(0)); GL_CALL(glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float)))); GL_CALL(glEnableVertexAttribArray(1)); GL_CALL(glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)))); GL_CALL(glEnableVertexAttribArray(2)); // 纹理部分 GLuint texture1, texture2; // 让加载的图片y轴反转 stbi_set_flip_vertically_on_load(true); // 纹理单元 0 GL_CALL(glActiveTexture(GL_TEXTURE0)); int width, height, nrChannels; GL_CALL(glGenTextures(1, \u0026amp;texture1)); GL_CALL(glBindTexture(GL_TEXTURE_2D, texture1)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)); unsigned char *data1 = stbi_load(\u0026#34;./assets/container.jpg\u0026#34;, \u0026amp;width, \u0026amp;height, \u0026amp;nrChannels, 0); if (!data1){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to load texture\u0026#34; \u0026lt;\u0026lt; std::endl; } GL_CALL(glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data1)); GL_CALL(glGenerateMipmap(GL_TEXTURE_2D)); stbi_image_free(data1); // 纹理单元 1 GL_CALL(glActiveTexture(GL_TEXTURE1)); GL_CALL(glGenTextures(1, \u0026amp;texture2)); GL_CALL(glBindTexture(GL_TEXTURE_2D, texture2)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)); unsigned char *data2 = stbi_load(\u0026#34;./assets/awesomeface.png\u0026#34;, \u0026amp;width, \u0026amp;height, \u0026amp;nrChannels, 0); if (!data2){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to load texture\u0026#34; \u0026lt;\u0026lt; std::endl; } GL_CALL(glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data2)); GL_CALL(glGenerateMipmap(GL_TEXTURE_2D)); stbi_image_free(data2); ourShader.use(); // 设置 Uniform（目的是给片段着色器中定义的两个采样器，告诉他们分别对应的是哪个纹理单元） GL_CALL(ourShader.setInt(\u0026#34;sampler1\u0026#34;, 0)); GL_CALL(ourShader.setInt(\u0026#34;sampler2\u0026#34;, 1)); while (!glfwWindowShouldClose(window)) { // 清理窗口 GL_CALL(glClearColor(0.2f, 0.3f, 0.3f, 1.0f)); GL_CALL(glClear(GL_COLOR_BUFFER_BIT)); // 绑定纹理 GL_CALL(glActiveTexture(GL_TEXTURE0)); GL_CALL(glBindTexture(GL_TEXTURE_2D, texture1)); GL_CALL(glActiveTexture(GL_TEXTURE1)); GL_CALL(glBindTexture(GL_TEXTURE_2D, texture2)); // 激活着色器 GL_CALL(ourShader.use()); // 绑定VAO GL_CALL(glBindVertexArray(VAO)); // 绘制 GL_CALL(glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)); // 事件处理 glfwPollEvents(); // 双缓冲 glfwSwapBuffers(window); // 解绑 GL_CALL(glBindVertexArray(0)); GL_CALL(glUseProgram(0)); } glfwTerminate(); return 0; } 用Clion的一定要注意，要修改debug文件夹下的着色器代码才能生效。。。 最终图片 坐标系统 GLM是OpenGL Mathematics的缩写，它是一个只有头文件的库，也就是说我们只需包含对应的头文件就行了，不用链接和编译。GLM可以在它们的网站上下载。把头文件的根目录复制到你的includes文件夹，然后你就可以使用这个库了。 OpenGL希望在每次顶点着色器运行后，我们可见的所有顶点都为标准化设备坐标(Normalized Device Coordinate, NDC)。也就是说，每个顶点的x，y，z坐标都应该在-1.0到1.0之间，超出这个坐标范围的顶点都将不可见。也就是说OpenGL顶点着色器执行完后，会对不在NDC范围内的点进行剔除。\ngames101学过很多了，MVP+透视除法+视口变换，让一个三维物体转为二维坐标 局部空间(Local Space，或者称为物体空间(Object Space)) 世界空间(World Space) 观察空间(View Space，或者称为视觉空间(Eye Space)) 裁剪空间(Clip Space) 屏幕空间(Screen Space) 对于局部空间、世界空间、观察空间这里就不做解释了\n裁剪空间 在一个顶点着色器运行的最后，OpenGL期望所有的坐标都能落在一个特定的范围内，且任何在这个范围之外的点都应该被裁剪掉(Clipped)。被裁剪掉的坐标就会被忽略，所以剩下的坐标就将变为屏幕上可见的片段。这也就是裁剪空间(Clip Space)名字的由来。\n如果只是图元(Primitive)，例如三角形，的一部分超出了裁剪体积(Clipping Volume)，则OpenGL会重新构建这个三角形为一个或多个三角形让其能够适合这个裁剪范围。 例如下图 一旦所有顶点被变换到裁剪空间，最终的操作——透视除法(Perspective Division)将会执行，在这个过程中我们将位置向量的x，y，z分量分别除以向量的齐次w分量。透视除法是将4D裁剪空间坐标变换为3D标准化设备坐标的过程。这一步会在每一个顶点着色器运行的最后被自动执行。 在OpenGL中只需要处理MVP矩阵，裁剪、透视除法和视口变换会自动处理。 来直接开干把！\n模型变换矩阵，通过将顶点坐标乘以这个模型矩阵，我们将该顶点坐标变换到世界坐标。我们的平面看起来就是在地板上，代表全局世界里的平面。 1 2 glm::mat4 model; model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f)); 视图变换矩阵 1 2 3 glm::mat4 view; // 注意，我们将矩阵向我们要进行移动场景的反方向移动。 view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f)); 透视投影矩阵 1 2 glm::mat4 projection; projection = glm::perspective(glm::radians(45.0f), screenWidth / screenHeight, 0.1f, 100.0f); 把这些矩阵传入顶点着色器\n1 2 3 4 5 6 7 8 9 10 11 12 13 #version 330 core layout (location = 0) in vec3 aPos; ... uniform mat4 model; uniform mat4 view; uniform mat4 projection; void main() { // 注意乘法要从右向左读 gl_Position = projection * view * model * vec4(aPos, 1.0); ... } 整体如下\n1 2 3 4 5 6 7 8 9 10 11 // 构建MVP矩阵 auto model = glm::mat4(1.0f); auto view = glm::mat4(1.0f); auto projection = glm::mat4(1.0f); model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f)); view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f)); projection = glm::perspective(glm::radians(45.0f), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f); ourShader.setMat4(\u0026#34;model\u0026#34;, model); ourShader.setMat4(\u0026#34;view\u0026#34;, view); ourShader.setMat4(\u0026#34;projection\u0026#34;, projection); setMat4是新添加的方法\n1 2 3 4 5 6 7 8 void Shader::setMat4(const std::string \u0026amp;name, const glm::mat4 \u0026amp;mat) const { glUniformMatrix4fv( glGetUniformLocation(ID, name.c_str()), // 获取Uniform位置 1, // 上传矩阵数量 GL_FALSE, // 是否转置（GLM默认列主序，无需转置） glm::value_ptr(mat) // 使用GLM提供的指针获取方法 ); } 现在图片躺下了 后边这个立方体就不做了，就是顶点都写进去，然后设置好VAO，之后在循环中修改MVP矩阵的M矩阵，他就转起来了，但目前还没有考虑Z-buffer。**GLFW会自动为你生成这样一个缓冲）**我们想要确定OpenGL真的执行了深度测试，首先我们要告诉OpenGL我们想要启用深度测试；它默认是关闭的。我们可以通过glEnable函数来开启深度测试。glEnable和glDisable函数允许我们启用或禁用某个OpenGL功能\n1 2 3 4 5 // 开启Z-buffer glEnable(GL_DEPTH_TEST); // 当模型发生变化时，需要在循环中清楚之前缓存的zbuffer glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 摄像机 OpenGL本身没有摄像机(Camera)的概念，但我们可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机，产生一种我们在移动的感觉，而不是场景在移动。在上一节中我们通过把所有物体向后平移3格，来模拟摄像机在(0,0,3)的位置上。\n要定义一个摄像机，我们需要它在世界空间中的位置、观察的方向、一个指向它右侧的向量以及一个指向它上方的向量。实际上创建了一个三个单位轴相互垂直的、以摄像机的位置为原点的坐标系。 首先来指定一个摄像机位置\n1 glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f); 摄像机的方向就指向场景的原点即可，相减就得到了摄像机的指向方向，但是取反转方向，也就是是指向+Z的，与摄像机的朝向相反\n1 2 glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f); glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget); 之后还需要一个右向量作为x方向，可以通过上向量与指向方向叉乘得到\n1 2 3 4 // 上方向 glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f); // 叉乘得右方向，表示x轴的正方向 glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection)); 最后y方向就可以通过上和右的叉乘得到\n1 glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight); 使用上面得到的x\\y\\z向量可以构建lookAt矩阵，可以用这个矩阵乘以任何向量来将其变换到摄像机坐标空间 其中R是右向量，U是上向量，D是方向向量,P是摄像机位置向量,可以直接通过GLM生成这个矩阵\n1 2 3 4 glm::mat4 view; view = glm::lookAt(glm::vec3(0.0f, 0.0f, 3.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f)); glm::LookAt函数需要一个位置、目标和上向量 如果我们在View矩阵构建时使用时间，就会让整个场景开始旋转\n1 2 3 4 float camX = sin(glfwGetTime()) * radius; float camZ = cos(glfwGetTime()) * radius; glm::mat4 view; view = glm::lookAt(glm::vec3(camX, 0.0, camZ), glm::vec3(0.0, 0.0, 0.0), glm::vec3(0.0, 1.0, 0.0)); 自由移动 1 2 3 4 glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f); glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f); glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f); view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp); 通过上边参数控制，就用这些vec3来控制lookAt矩阵的生成了，下来就用监听来绑定按键修改参数，就是改变Pos来实现的\n1 2 3 4 5 6 7 8 9 10 11 12 void processInput(GLFWwindow *window) { float cameraSpeed = 0.05f; // adjust accordingly if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS) cameraPos += cameraSpeed * cameraFront; if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS) cameraPos -= cameraSpeed * cameraFront; if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS) cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed; if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS) cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed; } 这里设置一个固定的移动速度是有bug的，因为每一帧处理一次移动，如果硬件比较好的条件下，帧数高，速度就会快一点。图形程序和游戏通常会跟踪一个时间差(Deltatime)变量，它储存了渲染上一帧所用的时间。我们把所有速度都去乘以deltaTime值\n1 2 3 4 5 6 7 8 9 10 float deltaTime = 0.0f; // 当前帧与上一帧的时间差 float lastFrame = 0.0f; // 上一帧的时间 float currentFrame = glfwGetTime(); deltaTime = currentFrame - lastFrame; lastFrame = currentFrame; void processInput(GLFWwindow *window) { float cameraSpeed = 2.5f * deltaTime; ... } 视角移动 为了能够改变视角，我们需要根据鼠标的输入改变cameraFront向量。这里需要一点三角学的知识\n欧拉角：俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll)，每个欧拉角都有一个值来表示，把三个角结合起来我们就能够计算3D空间中任何的旋转向量了。 假设我们现在在XZ屏幕，往Y偏移就是俯仰角 假设俯仰角为pitch\n1 2 3 direction.y = sin(glm::radians(pitch)); // 注意我们先把角度转为弧度 direction.x = cos(glm::radians(pitch)); direction.z = cos(glm::radians(pitch)); 对偏航角的处理一个道理 通过俯仰角的计算已经知道了斜边长是cos(pitch)，一结合，就得如果已知俯仰角和偏航角，就可以知道(x,y,z)坐标了\n1 2 3 direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw)); // 译注：direction代表摄像机的前轴(Front)，这个前轴是和本文第一幅图片的第二个摄像机的方向向量是相反的 direction.y = sin(glm::radians(pitch)); direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw)); 偏航角和俯仰角是通过鼠标（或手柄）移动获得的，水平的移动影响偏航角，竖直的移动影响俯仰角。它的原理就是，储存上一帧鼠标的位置，在当前帧中我们当前计算鼠标位置与上一帧的位置相差多少。如果水平/竖直差别越大那么俯仰角或偏航角就改变越大，也就是摄像机需要移动更多的距离。 首先让光标消失\n1 glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED); 之后注册一个鼠标移动监听的回调\n1 2 void mouse_callback(GLFWwindow* window, double xpos, double ypos); glfwSetCursorPosCallback(window, mouse_callback); 我们必须在程序存储上一帧鼠标的位置，再看这一帧的变化，计算出角度进而计算出摄像机位置，修改LookAt矩阵\n1 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 void mouse_callback(GLFWwindow* window, double xpos, double ypos) { if(firstMouse) { lastX = xpos; lastY = ypos; firstMouse = false; } float xoffset = xpos - lastX; float yoffset = lastY - ypos; lastX = xpos; lastY = ypos; float sensitivity = 0.05; xoffset *= sensitivity; yoffset *= sensitivity; yaw += xoffset; pitch += yoffset; if(pitch \u0026gt; 89.0f) pitch = 89.0f; if(pitch \u0026lt; -89.0f) pitch = -89.0f; glm::vec3 front; front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch)); front.y = sin(glm::radians(pitch)); front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch)); cameraFront = glm::normalize(front); } 另外场景的放大缩小可以通过控制透视矩阵的fov来控制 最后提供一个封装好的摄像机类\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 #ifndef CAMERA_H #define CAMERA_H #include \u0026lt;glad/glad.h\u0026gt; #include \u0026lt;glm/glm.hpp\u0026gt; #include \u0026lt;glm/gtc/matrix_transform.hpp\u0026gt; // Defines several possible options for camera movement. Used as abstraction to stay away from window-system specific input methods enum Camera_Movement { FORWARD, BACKWARD, LEFT, RIGHT }; // Default camera values const float YAW = -90.0f; const float PITCH = 0.0f; const float SPEED = 2.5f; const float SENSITIVITY = 0.1f; const float ZOOM = 45.0f; // An abstract camera class that processes input and calculates the corresponding Euler Angles, Vectors and Matrices for use in OpenGL class Camera { public: // camera Attributes glm::vec3 Position; glm::vec3 Front; glm::vec3 Up; glm::vec3 Right; glm::vec3 WorldUp; // euler Angles float Yaw; float Pitch; // camera options float MovementSpeed; float MouseSensitivity; float Zoom; // constructor with vectors Camera(glm::vec3 position = glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f), float yaw = YAW, float pitch = PITCH) : Front(glm::vec3(0.0f, 0.0f, -1.0f)), MovementSpeed(SPEED), MouseSensitivity(SENSITIVITY), Zoom(ZOOM) { Position = position; WorldUp = up; Yaw = yaw; Pitch = pitch; updateCameraVectors(); } // constructor with scalar values Camera(float posX, float posY, float posZ, float upX, float upY, float upZ, float yaw, float pitch) : Front(glm::vec3(0.0f, 0.0f, -1.0f)), MovementSpeed(SPEED), MouseSensitivity(SENSITIVITY), Zoom(ZOOM) { Position = glm::vec3(posX, posY, posZ); WorldUp = glm::vec3(upX, upY, upZ); Yaw = yaw; Pitch = pitch; updateCameraVectors(); } // returns the view matrix calculated using Euler Angles and the LookAt Matrix glm::mat4 GetViewMatrix() { return glm::lookAt(Position, Position + Front, Up); } // processes input received from any keyboard-like input system. Accepts input parameter in the form of camera defined ENUM (to abstract it from windowing systems) void ProcessKeyboard(Camera_Movement direction, float deltaTime) { float velocity = MovementSpeed * deltaTime; if (direction == FORWARD) Position += Front * velocity; if (direction == BACKWARD) Position -= Front * velocity; if (direction == LEFT) Position -= Right * velocity; if (direction == RIGHT) Position += Right * velocity; } // processes input received from a mouse input system. Expects the offset value in both the x and y direction. void ProcessMouseMovement(float xoffset, float yoffset, GLboolean constrainPitch = true) { xoffset *= MouseSensitivity; yoffset *= MouseSensitivity; Yaw += xoffset; Pitch += yoffset; // make sure that when pitch is out of bounds, screen doesn\u0026#39;t get flipped if (constrainPitch) { if (Pitch \u0026gt; 89.0f) Pitch = 89.0f; if (Pitch \u0026lt; -89.0f) Pitch = -89.0f; } // update Front, Right and Up Vectors using the updated Euler angles updateCameraVectors(); } // processes input received from a mouse scroll-wheel event. Only requires input on the vertical wheel-axis void ProcessMouseScroll(float yoffset) { Zoom -= (float)yoffset; if (Zoom \u0026lt; 1.0f) Zoom = 1.0f; if (Zoom \u0026gt; 45.0f) Zoom = 45.0f; } private: // calculates the front vector from the Camera\u0026#39;s (updated) Euler Angles void updateCameraVectors() { // calculate the new Front vector glm::vec3 front; front.x = cos(glm::radians(Yaw)) * cos(glm::radians(Pitch)); front.y = sin(glm::radians(Pitch)); front.z = sin(glm::radians(Yaw)) * cos(glm::radians(Pitch)); Front = glm::normalize(front); // also re-calculate the Right and Up vector Right = glm::normalize(glm::cross(Front, WorldUp)); // normalize the vectors, because their length gets closer to 0 the more you look up or down which results in slower movement. Up = glm::normalize(glm::cross(Right, Front)); } }; #endif 最终能运行的整体代码\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 // // Created by 刘卓昊 on 2025/4/3. // #include \u0026lt;glad/glad.h\u0026gt; #include \u0026lt;GLFW/glfw3.h\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026#34;Shader.h\u0026#34; #define STB_IMAGE_IMPLEMENTATION #include \u0026lt;stb_image.h\u0026gt; #include \u0026lt;glm/glm.hpp\u0026gt; #include \u0026lt;glm/gtc/matrix_transform.hpp\u0026gt; #include \u0026lt;glm/gtc/type_ptr.hpp\u0026gt; #include \u0026#34;camera.h\u0026#34; #define GL_CALL(x) \\ do { \\ x; \\ GLenum error = glGetError(); \\ if (error != GL_NO_ERROR) { \\ std::cerr \u0026lt;\u0026lt; \u0026#34;OpenGL error: \u0026#34; \u0026lt;\u0026lt; error \u0026lt;\u0026lt; \u0026#34; at \u0026#34; \u0026lt;\u0026lt; __FILE__ \u0026lt;\u0026lt; \u0026#34;:\u0026#34; \u0026lt;\u0026lt; __LINE__ \u0026lt;\u0026lt; std::endl; \\ } \\ } while (0) void framebuffer_size_callback(GLFWwindow* window, int width, int height); void mouse_callback(GLFWwindow* window, double xpos, double ypos); void scroll_callback(GLFWwindow* window, double xoffset, double yoffset); void processInput(GLFWwindow *window); const unsigned int SCR_WIDTH = 800; const unsigned int SCR_HEIGHT = 600; // camera Camera camera(glm::vec3(0.0f, 0.0f, 3.0f)); float lastX = SCR_WIDTH / 2.0f; float lastY = SCR_HEIGHT / 2.0f; bool firstMouse = true; // timing float deltaTime = 0.0f;\t// time between current frame and last frame float lastFrame = 0.0f; GLFWwindow * InitWindowAndFunc() { glfwInit(); // 对GLFW的配置 版本号、次版本号、选择核心模式 glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); GLFWwindow* window = glfwCreateWindow(800, 600, \u0026#34;LearnOpenGL\u0026#34;, nullptr, nullptr); if (window == nullptr) { std::cout \u0026lt;\u0026lt; \u0026#34;Failed to create GLFW window\u0026#34; \u0026lt;\u0026lt; std::endl; glfwTerminate(); return nullptr; } glfwMakeContextCurrent(window); if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { std::cout \u0026lt;\u0026lt; \u0026#34;Failed to initialize GLAD\u0026#34; \u0026lt;\u0026lt; std::endl; return nullptr; } // 前两个参数控制窗口左下角的位置。第三个和第四个参数控制渲染窗口的宽度和高度（像素） GL_CALL(glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT)); glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); glfwSetCursorPosCallback(window, mouse_callback); glfwSetScrollCallback(window, scroll_callback); glfwSetScrollCallback(window, scroll_callback); return window; } int main() { float vertices[] = { // ---- 位置 ---- ---- 颜色 ---- - 纹理坐标 - 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上 0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下 -0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下 -0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上 }; unsigned int indices[] = { // 注意索引从0开始! // 此例的索引(0,1,2,3)就是顶点数组vertices的下标， // 这样可以由下标代表顶点组合成矩形 0, 1, 3, // 第一个三角形 1, 2, 3 // 第二个三角形 }; GLFWwindow * window = InitWindowAndFunc(); // shader Shader ourShader(\u0026#34;./shader/shader.vs\u0026#34;, \u0026#34;./shader/shader.fs\u0026#34;); // 创建Object的ID GLuint VBO, VAO, EBO; GL_CALL(glGenVertexArrays(1, \u0026amp;VAO)); GL_CALL(glGenBuffers(1, \u0026amp;VBO)); GL_CALL(glGenBuffers(1, \u0026amp;EBO)); GL_CALL(glBindVertexArray(VAO)); // 绑定到OpenGL上下文中，此时GPU才会真正分配内存 GL_CALL(glBindBuffer(GL_ARRAY_BUFFER, VBO)); //glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型：顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位)；用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。 GL_CALL(glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW)); // EBO GL_CALL(glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO)); GL_CALL(glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW)); // VAO GL_CALL(glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0)); GL_CALL(glEnableVertexAttribArray(0)); GL_CALL(glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float)))); GL_CALL(glEnableVertexAttribArray(1)); GL_CALL(glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)))); GL_CALL(glEnableVertexAttribArray(2)); // 纹理部分 GLuint texture1, texture2; // 让加载的图片y轴反转 stbi_set_flip_vertically_on_load(true); // 纹理单元 0 GL_CALL(glActiveTexture(GL_TEXTURE0)); int width, height, nrChannels; GL_CALL(glGenTextures(1, \u0026amp;texture1)); GL_CALL(glBindTexture(GL_TEXTURE_2D, texture1)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)); unsigned char *data1 = stbi_load(\u0026#34;./assets/container.jpg\u0026#34;, \u0026amp;width, \u0026amp;height, \u0026amp;nrChannels, 0); if (!data1){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to load texture\u0026#34; \u0026lt;\u0026lt; std::endl; } GL_CALL(glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data1)); GL_CALL(glGenerateMipmap(GL_TEXTURE_2D)); stbi_image_free(data1); // 纹理单元 1 GL_CALL(glActiveTexture(GL_TEXTURE1)); GL_CALL(glGenTextures(1, \u0026amp;texture2)); GL_CALL(glBindTexture(GL_TEXTURE_2D, texture2)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)); GL_CALL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)); unsigned char *data2 = stbi_load(\u0026#34;./assets/awesomeface.png\u0026#34;, \u0026amp;width, \u0026amp;height, \u0026amp;nrChannels, 0); if (!data2){ std::cout \u0026lt;\u0026lt; \u0026#34;Failed to load texture\u0026#34; \u0026lt;\u0026lt; std::endl; } GL_CALL(glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data2)); GL_CALL(glGenerateMipmap(GL_TEXTURE_2D)); stbi_image_free(data2); ourShader.use(); // 设置 Uniform（目的是给片段着色器中定义的两个采样器，告诉他们分别对应的是哪个纹理单元） GL_CALL(ourShader.setInt(\u0026#34;sampler1\u0026#34;, 0)); GL_CALL(ourShader.setInt(\u0026#34;sampler2\u0026#34;, 1)); // 光标消失 glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED); while (!glfwWindowShouldClose(window)) { float currentFrame = static_cast\u0026lt;float\u0026gt;(glfwGetTime()); deltaTime = currentFrame - lastFrame; lastFrame = currentFrame; processInput(window); // 清理窗口 GL_CALL(glClearColor(0.2f, 0.3f, 0.3f, 1.0f)); GL_CALL(glClear(GL_COLOR_BUFFER_BIT)); // 绑定纹理 GL_CALL(glActiveTexture(GL_TEXTURE0)); GL_CALL(glBindTexture(GL_TEXTURE_2D, texture1)); GL_CALL(glActiveTexture(GL_TEXTURE1)); GL_CALL(glBindTexture(GL_TEXTURE_2D, texture2)); // 激活着色器 GL_CALL(ourShader.use()); // 构建MVP矩阵 auto model = glm::mat4(1.0f); auto view = glm::mat4(1.0f); auto projection = glm::mat4(1.0f); model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f)); view = camera.GetViewMatrix(); projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f); ourShader.setMat4(\u0026#34;model\u0026#34;, model); ourShader.setMat4(\u0026#34;view\u0026#34;, view); ourShader.setMat4(\u0026#34;projection\u0026#34;, projection); // 绑定VAO GL_CALL(glBindVertexArray(VAO)); // 绘制 GL_CALL(glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)); // 事件处理 glfwPollEvents(); // 双缓冲 glfwSwapBuffers(window); // 解绑 GL_CALL(glBindVertexArray(0)); GL_CALL(glUseProgram(0)); } glfwTerminate(); return 0; } // process all input: query GLFW whether relevant keys are pressed/released this frame and react accordingly // --------------------------------------------------------------------------------------------------------- void processInput(GLFWwindow *window) { if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) glfwSetWindowShouldClose(window, true); if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS) camera.ProcessKeyboard(FORWARD, deltaTime); if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS) camera.ProcessKeyboard(BACKWARD, deltaTime); if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS) camera.ProcessKeyboard(LEFT, deltaTime); if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS) camera.ProcessKeyboard(RIGHT, deltaTime); } // glfw: whenever the window size changed (by OS or user resize) this callback function executes // --------------------------------------------------------------------------------------------- void framebuffer_size_callback(GLFWwindow* window, int width, int height) { // make sure the viewport matches the new window dimensions; note that width and // height will be significantly larger than specified on retina displays. glViewport(0, 0, width, height); } // glfw: whenever the mouse moves, this callback is called // ------------------------------------------------------- void mouse_callback(GLFWwindow* window, double xposIn, double yposIn) { float xpos = static_cast\u0026lt;float\u0026gt;(xposIn); float ypos = static_cast\u0026lt;float\u0026gt;(yposIn); if (firstMouse) { lastX = xpos; lastY = ypos; firstMouse = false; } float xoffset = xpos - lastX; float yoffset = lastY - ypos; // reversed since y-coordinates go from bottom to top lastX = xpos; lastY = ypos; camera.ProcessMouseMovement(xoffset, yoffset); } // glfw: whenever the mouse scroll wheel scrolls, this callback is called // ---------------------------------------------------------------------- void scroll_callback(GLFWwindow* window, double xoffset, double yoffset) { camera.ProcessMouseScroll(static_cast\u0026lt;float\u0026gt;(yoffset)); } ","date":"2025-10-19T13:48:28+08:00","image":"https://sdpyy1.github.io/3779db9d1ff84e66b075f0dd6d1edb90.png","permalink":"https://sdpyy1.github.io/p/opengl%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%E7%AE%80%E4%BB%8B%E4%B8%89%E8%A7%92%E5%BD%A2%E7%9D%80%E8%89%B2%E5%99%A8%E7%BA%B9%E7%90%86%E5%9D%90%E6%A0%87%E7%B3%BB%E7%BB%9F%E6%91%84%E5%83%8F%E6%9C%BA/","title":"OpenGL学习笔记（简介、三角形、着色器、纹理、坐标系统、摄像机）"},{"content":"Lesson 5: Gouraud shading 本节课实现了 Gouraud shading，对三角形每个顶点用法线求光照，进一步插值到内部像素，相比于phong，phong是要对每个内部像素都需要计算光照，这里简单处理就是默认为255，255，255的白光，计算顶点法线和光照方向的夹角（注意单位化和钝角处理)，得到cos直接乘以光照，就得到了每个顶点的变暗比例，之后再插值到各个像素，关键代码如下\n1 2 3 4 5 6 7 8 9 void Triangle::setShadingColor(Eigen::Vector3f lightDir) { lightDir.normalize(); Vector3f basecolor = Vector3f(255,255,255); for (int i = 0; i \u0026lt; 3; ++i) { // 确保法线已归一化，并限制点积结果非负 float intensity = std::max(0.0f, normal[i].normalized().dot(lightDir)); color[i] = basecolor * intensity; } } 因为我的MVP变换和课程设置不一致，所以角度不一样，下面展示 lightDir(1,-1,1)情况下的渲染 完整代码在（Gouraud-shading分支）：https://github.com/sdpyy1/CppLearn/tree/Gouraud-shading\nLesson 6: Shaders for the software renderer phongShading 首先是进行了PhongShading，思路就是插值出每个像素的法向量、每个像素对应的原来的位置（即着色点的位置，需要在顶点着色器中提前存储）以及光源的位置、摄像机的位置来计算高光、漫反射以及加上环境光,主要代码如下（自己实现注意颜色值的变化，防止过曝）\n1 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 Eigen::Vector3f TGAColorToVector3f(const TGAColor\u0026amp; color) { float r = static_cast\u0026lt;float\u0026gt;(color.bgra[2]) / 255.0f; float g = static_cast\u0026lt;float\u0026gt;(color.bgra[1]) / 255.0f; float b = static_cast\u0026lt;float\u0026gt;(color.bgra[0]) / 255.0f; return Eigen::Vector3f(r, g, b); } // 将Eigen::Vector3f转换为TGAColor的函数 TGAColor Vector3fToTGAColor(const Eigen::Vector3f\u0026amp; vectorColor) { auto clamp = [](float v) { return std::max(0.0f, std::min(1.0f, v)); }; unsigned char r = static_cast\u0026lt;unsigned char\u0026gt;(clamp(vectorColor.x()) * 255.0f); unsigned char g = static_cast\u0026lt;unsigned char\u0026gt;(clamp(vectorColor.y()) * 255.0f); unsigned char b = static_cast\u0026lt;unsigned char\u0026gt;(clamp(vectorColor.z()) * 255.0f); return TGAColor(r, g, b); } // blinnPhongShading TGAColor blinnPhongShading(const TGAColor \u0026amp; kdColor,const Vector3f \u0026amp; point,const Vector3f \u0026amp; normal) { Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005); Eigen::Vector3f kd = TGAColorToVector3f(kdColor); Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937); // 环境光强度 Eigen::Vector3f amb_light_intensity{10, 10, 10}; // 高光的指数，越大对角度越敏感 float p = 150; // 计算点到光源的向量 Eigen::Vector3f light_vec = lightDir - point; // 计算点到光源的距离 float r = light_vec.norm(); // 归一化从点到光源的向量 Eigen::Vector3f light_dir = light_vec.normalized(); // 漫反射 Eigen::Vector3f diffuse = kd.cwiseProduct(lightIntensity / (r * r)) * std::max(0.0f, normal.dot(light_dir)); // 高光反射 // 计算从表面点到观察者的向量 Eigen::Vector3f view_dir = (viewDir - point).normalized(); // 计算半程向量 Eigen::Vector3f halfVector = (light_dir + view_dir).normalized(); Eigen::Vector3f specular = ks.cwiseProduct(lightIntensity / (r * r)) * std::pow(std::max(0.0f, normal.dot(halfVector)), p); Eigen::Vector3f all = diffuse + specular + ka.cwiseProduct(amb_light_intensity); return Vector3fToTGAColor(all); } 法线贴图 法线贴图是通过使用 RGB 纹理的红色、绿色和蓝色分量来存储法线的 XYZ 分量，但是存储在法线贴图中的法线是模型坐标系下的法线坐标，如果模型在空间中发生了旋转，那法线位置就会发生变化，所以如果要用法线贴图，需要对法线变换到global坐标系下才行。另外不能简单地给法线左乘一个模型变换矩阵，解释及正确做法如下（也不是一定不行，有几种模型变换是不行滴） 下面使用法线贴图作为color得到的图片 下面使用法线贴图作为phongshading的法线来渲染光照，注意贴图范围是[-1，1]，而颜色通道是[0,1]，也就是拿到的材质颜色是[0,1]的，需要先转成[-1,1]才可以使用，关键代码如下\n1 2 // 法线来自法线贴图 Eigen::Vector3f barycentricNorm = TGAColorToVector3f(nm.getColor(texU,texV))*2-Vector3f{1,1,1}; 对比直接使用插值的法线和使用贴图法线的效果区别，区别还是很明显的，使用法线贴图并没有增加模型的结构，但精细程度大大提升了 Specular mapping 高光贴图 在之前的设置中镜面反射系数是自己设置的，使用高光贴图就是用来获取这个参数\n1 Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937); 修改为从高光贴图中获得\n1 2 // 高光系数来自高光贴图 TGAColor specKd = spec.getColor(texU,texV); 最终效果如下，变化看不太出来了，主要是一些奇怪的高光点消失了 tangent space normal mapping ‌切线空间法线贴图 将法线方向存储在‌切线空间‌（Tangent Space）中，而非模型或世界空间。这种技术可以显著提升表面凹凸感，且对模型变形（如动画）更友好，首先解释一下切线空间 这个东西就是把法线存储在切线空间坐标下，如果需要法线就在着色器中，通过 ‌TBN矩阵‌（Tangent-Bitangent-Normal Matrix）将法线从切线空间转换到世界空间\n为什么有了法线贴图，还需要切线法线贴图呢？\n普通法线贴图与特定的模型紧密绑定的，当模型的形状、朝向或者拓扑结构发生变化时，法线贴图可能就不再适用。切线空间是每个顶点局部的一个坐标系，它由切线（Tangent）、副切线（Bitangent）和法线（Normal）三个向量定义。切线空间法线贴图中的法向量是相对于每个顶点的切线空间而言的。这种表示方式使得法线贴图具有更好的可移植性，同一个切线空间法线贴图可以应用到不同形状和朝向的模型上，只要每个模型的切线空间能够正确计算，就可以得到正确的光照效果。 切线空间法线贴图能够很好地支持模型变形。因为切线空间是随着顶点一起变形的，当模型发生变形时，切线空间也会相应地改变，而切线空间法线贴图中的法向量在新的切线空间中仍然能够正确表示表面的细节。所以，即使模型在动画过程中发生了变形，光照效果依然能够保持正确。 如果不对切线法线贴图进行转换，直接使用，效果如下哈哈哈 获取到切线法线贴图后，需要左乘一个TBN矩阵，转为空间法线才能使用，第一列是三角形的切线，第二列是副切线，第三列是法线 具体转化函数如下\n1 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 // 从切线法线转为法线 Eigen::Vector3f getNormalFromTangent(const Triangle\u0026amp; triangle, const Eigen::Vector3f\u0026amp; tangentSpaceNormal, const Eigen::Vector3f\u0026amp; barycentricNorm) { // 计算切线 Eigen::Vector3f edge1 = triangle.globalCoords[1].head\u0026lt;3\u0026gt;() - triangle.globalCoords[0].head\u0026lt;3\u0026gt;(); Eigen::Vector3f edge2 = triangle.globalCoords[2].head\u0026lt;3\u0026gt;() - triangle.globalCoords[0].head\u0026lt;3\u0026gt;(); Eigen::Vector2f deltaUV1 = triangle.texCoords[1] - triangle.texCoords[0]; Eigen::Vector2f deltaUV2 = triangle.texCoords[2] - triangle.texCoords[0]; float f = 1.0f / (deltaUV1.x() * deltaUV2.y() - deltaUV2.x() * deltaUV1.y()); Eigen::Vector3f tangent; tangent.x() = f * (deltaUV2.y() * edge1.x() - deltaUV1.y() * edge2.x()); tangent.y() = f * (deltaUV2.y() * edge1.y() - deltaUV1.y() * edge2.y()); tangent.z() = f * (deltaUV2.y() * edge1.z() - deltaUV1.y() * edge2.z()); tangent.normalize(); // 计算副切线 Eigen::Vector3f bitangent; bitangent.x() = f * (-deltaUV2.x() * edge1.x() + deltaUV1.x() * edge2.x()); bitangent.y() = f * (-deltaUV2.x() * edge1.y() + deltaUV1.x() * edge2.y()); bitangent.z() = f * (-deltaUV2.x() * edge1.z() + deltaUV1.x() * edge2.z()); bitangent.normalize(); // 构建 TBN 矩阵 Eigen::Matrix3f TBN; TBN.col(0) = tangent; TBN.col(1) = bitangent; TBN.col(2) = barycentricNorm; // 将切向法线转换到世界空间 return TBN * tangentSpaceNormal; } 最终用转化后的切线作为像素的切线得到的图片如下\nLesson 7: Shadow mapping 简单理解就是把光源当作摄像机，重新渲染一遍场景，但只需要记录每个点离光源最近的点，也就是哪些点能被光源照到，在正式渲染时，判断一个点颜色时，先去刚才记录的光照缓存中查看该点是否能被光照到，来决定是否进行渲染 完成的做法是对于一个像素，先去找它原本在空间中的位置，紧接着做MVP变换和视口变换变到以光源为摄像机下的对应像素块的位置，判断该位置深度是否需要阴影，不需要阴影才去正常进行光照，我的简单做法是如果在阴影，就让光照最终结果*0.2,从而变暗，这里这样做只是为了效果好看，不过这个光照模型本身就是不准确的，我的做法并不特别准确，但是理解原理即可 整体实现代码如下，其他未展示文件可以在仓库中找到\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 #include \u0026#34;thirdParty/tgaimage.h\u0026#34; #include \u0026#34;model.h\u0026#34; #include \u0026lt;vector\u0026gt; #include \u0026lt;cmath\u0026gt; #include \u0026#34;iostream\u0026#34; using namespace std; constexpr static int width = 1000; constexpr static int height = 1000; float angleX = 0.0f; float angleY = 0.0f; float angleZ = 0.0f; float tx = 0.0f; float ty = 0.0f; float tz = 0.0f; float sx = 1.0f; float sy = 1.0f; float sz = 1.0f; Eigen::Vector3f eye_pos{0,0,3}; Eigen::Vector3f eye_dir(0.0f, 0.0f, -1.0f); Eigen::Vector3f up(0.0f, 1.0f, 0.0f); float fovY = 45.0f; float aspectRatio = 1.0f; float near = 0.1f; float far = 100.0f; Eigen::Vector3f lightDir{1,1,0}; Eigen::Vector3f lightIntensity{5,5,5}; // 计算三角形面积，可能返回负数，表示背对屏幕 float signed_triangle_area(float ax, float ay, float bx, float by, float cx, float cy) { return .5f*((by-ay)*(bx+ax) + (cy-by)*(cx+bx) + (ay-cy)*(ax+cx)); } Eigen::Vector3f TGAColorToVector3f(const TGAColor\u0026amp; color) { float r = static_cast\u0026lt;float\u0026gt;(color.bgra[2]) / 255.0f; float g = static_cast\u0026lt;float\u0026gt;(color.bgra[1]) / 255.0f; float b = static_cast\u0026lt;float\u0026gt;(color.bgra[0]) / 255.0f; return Eigen::Vector3f(r, g, b); } // 将Eigen::Vector3f转换为TGAColor的函数 TGAColor Vector3fToTGAColor(const Eigen::Vector3f\u0026amp; vectorColor) { auto clamp = [](float v) { return std::max(0.0f, std::min(1.0f, v)); }; unsigned char r = static_cast\u0026lt;unsigned char\u0026gt;(clamp(vectorColor.x()) * 255.0f); unsigned char g = static_cast\u0026lt;unsigned char\u0026gt;(clamp(vectorColor.y()) * 255.0f); unsigned char b = static_cast\u0026lt;unsigned char\u0026gt;(clamp(vectorColor.z()) * 255.0f); return TGAColor(r, g, b); } // blinnPhongShading TGAColor blinnPhongShading(const TGAColor \u0026amp; textureColor, const Vector3f \u0026amp; point, const Vector3f \u0026amp; normal,TGAColor specKd,bool isShadow) { // 环境光系数 Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005); // 漫反射系数（来自材质贴图） Eigen::Vector3f kd = TGAColorToVector3f(textureColor); // 高光系数（来自高光贴图） Eigen::Vector3f ks = TGAColorToVector3f(specKd); Eigen::Vector3f amb_light_intensity{10, 10, 10}; // 环境光强度 // 高光的指数，越大对角度越敏感 float p = 150; // 计算点到光源的向量 Eigen::Vector3f light_vec = lightDir - point; // 计算点到光源的距离 float r = light_vec.norm(); // 归一化从点到光源的向量 Eigen::Vector3f light_dir = light_vec.normalized(); // 漫反射 Eigen::Vector3f diffuse = kd.cwiseProduct(lightIntensity / (r * r)) * std::max(0.0f, normal.dot(light_dir)); // 高光反射 // 计算从表面点到观察者的向量 Eigen::Vector3f view_dir = (eye_pos - point).normalized(); // 计算半程向量 Eigen::Vector3f halfVector = (light_dir + view_dir).normalized(); Eigen::Vector3f specular = ks.cwiseProduct(lightIntensity / (r * r)) * std::pow(std::max(0.0f, normal.dot(halfVector)), p); Eigen::Vector3f all = diffuse + specular + ka.cwiseProduct(amb_light_intensity); if (isShadow){ return Vector3fToTGAColor(all*0.2); } return Vector3fToTGAColor(all); } // 插值函数 Eigen::Vector3f interpolate(const Eigen::Vector3f\u0026amp; v0, const Eigen::Vector3f\u0026amp; v1, const Eigen::Vector3f\u0026amp; v2, double alpha, double beta, double gamma) { return alpha * v0 + beta * v1 + gamma * v2; } float interpolate(float v0, float v1, float v2, float alpha, float beta, float gamma) { return alpha * v0 + beta * v1 + gamma * v2; } // 从切线法线转为法线 Eigen::Vector3f getNormalFromTangent(const Triangle\u0026amp; triangle, const Eigen::Vector3f\u0026amp; tangentSpaceNormal, const Eigen::Vector3f\u0026amp; barycentricNorm) { // 计算切线 Eigen::Vector3f edge1 = triangle.globalCoords[1].head\u0026lt;3\u0026gt;() - triangle.globalCoords[0].head\u0026lt;3\u0026gt;(); Eigen::Vector3f edge2 = triangle.globalCoords[2].head\u0026lt;3\u0026gt;() - triangle.globalCoords[0].head\u0026lt;3\u0026gt;(); Eigen::Vector2f deltaUV1 = triangle.texCoords[1] - triangle.texCoords[0]; Eigen::Vector2f deltaUV2 = triangle.texCoords[2] - triangle.texCoords[0]; float f = 1.0f / (deltaUV1.x() * deltaUV2.y() - deltaUV2.x() * deltaUV1.y()); Eigen::Vector3f tangent; tangent.x() = f * (deltaUV2.y() * edge1.x() - deltaUV1.y() * edge2.x()); tangent.y() = f * (deltaUV2.y() * edge1.y() - deltaUV1.y() * edge2.y()); tangent.z() = f * (deltaUV2.y() * edge1.z() - deltaUV1.y() * edge2.z()); tangent.normalize(); // 计算副切线 Eigen::Vector3f bitangent; bitangent.x() = f * (-deltaUV2.x() * edge1.x() + deltaUV1.x() * edge2.x()); bitangent.y() = f * (-deltaUV2.x() * edge1.y() + deltaUV1.x() * edge2.y()); bitangent.z() = f * (-deltaUV2.x() * edge1.z() + deltaUV1.x() * edge2.z()); bitangent.normalize(); // 构建 TBN 矩阵 Eigen::Matrix3f TBN; TBN.col(0) = tangent; TBN.col(1) = bitangent; TBN.col(2) = barycentricNorm; // 将切向法线转换到世界空间 return TBN * tangentSpaceNormal; } void shadow(Triangle \u0026amp;triangle,std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt; *shadowBuffer){ float ax = triangle.screenCoords[0].x(); float ay = triangle.screenCoords[0].y(); float bx = triangle.screenCoords[1].x(); float by = triangle.screenCoords[1].y(); float cx = triangle.screenCoords[2].x(); float cy = triangle.screenCoords[2].y(); int bbminx = std::floor(std::min(std::min(ax, bx), cx)); int bbminy = std::ceil(std::min(std::min(ay, by), cy)); int bbmaxx = std::floor(std::max(std::max(ax, bx), cx)); int bbmaxy = std::ceil(std::max(std::max(ay, by), cy)); float total_area = signed_triangle_area(ax, ay, bx, by, cx, cy); for (int x = bbminx; x \u0026lt;= bbmaxx; x++) { for (int y = bbminy; y \u0026lt;= bbmaxy; y++) { // 虽然可以把整个三角形直接剔除，但是我希望只是把屏幕外的像素剔除 if (x \u0026lt; 0 || x \u0026gt;= width || y \u0026lt; 0 || y \u0026gt;= height) { continue; } float alpha = signed_triangle_area(x, y, bx, by, cx, cy) / total_area; float beta = signed_triangle_area(x, y, cx, cy, ax, ay) / total_area; float gamma = signed_triangle_area(x, y, ax, ay, bx, by) / total_area; if (alpha \u0026lt; 0 || beta \u0026lt; 0 || gamma \u0026lt; 0) continue; // 说明当前像素不在三角形内部 float barycentricZ = interpolate(triangle.screenCoords[0].z(), triangle.screenCoords[1].z(),triangle.screenCoords[2].z(), alpha, beta, gamma); if (shadowBuffer-\u0026gt;at(x).at(y) \u0026lt; barycentricZ) { shadowBuffer-\u0026gt;at(x).at(y) = barycentricZ; } } } } // 绘制一个三角形 void drawTriangle(Triangle \u0026amp;triangle, TGAImage \u0026amp;framebuffer, std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt; *zBuffer, std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt; *shadowBuffer,Texture \u0026amp;texture, Texture \u0026amp;nm, Texture \u0026amp;spec, Texture \u0026amp;nm_tangent,Eigen::Matrix4f mvpForShadow) { float ax = triangle.screenCoords[0].x(); float ay = triangle.screenCoords[0].y(); float bx = triangle.screenCoords[1].x(); float by = triangle.screenCoords[1].y(); float cx = triangle.screenCoords[2].x(); float cy = triangle.screenCoords[2].y(); int bbminx = std::floor(std::min(std::min(ax, bx), cx)); int bbminy = std::ceil(std::min(std::min(ay, by), cy)); int bbmaxx = std::floor(std::max(std::max(ax, bx), cx)); int bbmaxy = std::ceil(std::max(std::max(ay, by), cy)); // 如果面积为负数，背对屏幕，被裁剪 float total_area = signed_triangle_area(ax, ay, bx, by, cx, cy); if (total_area \u0026lt; 1) return; #pragma omp parallel for for (int x = bbminx; x \u0026lt;= bbmaxx; x++) { for (int y = bbminy; y \u0026lt;= bbmaxy; y++) { // 虽然可以把整个三角形直接剔除，但是我希望只是把屏幕外的像素剔除 if (x \u0026lt; 0 || x \u0026gt;= width || y \u0026lt; 0 || y \u0026gt;= height) { continue; } float alpha = signed_triangle_area(x, y, bx, by, cx, cy) / total_area; float beta = signed_triangle_area(x, y, cx, cy, ax, ay) / total_area; float gamma = signed_triangle_area(x, y, ax, ay, bx, by) / total_area; if (alpha \u0026lt; 0 || beta \u0026lt; 0 || gamma \u0026lt; 0) continue; // 说明当前像素不在三角形内部 float barycentricZ = interpolate(triangle.screenCoords[0].z(), triangle.screenCoords[1].z(),triangle.screenCoords[2].z(), alpha, beta, gamma); Eigen::Vector3f barycentricGlobalCoord = interpolate(triangle.globalCoords[0].head\u0026lt;3\u0026gt;(),triangle.globalCoords[1].head\u0026lt;3\u0026gt;(),triangle.globalCoords[2].head\u0026lt;3\u0026gt;(), alpha, beta,gamma); float texU = interpolate(triangle.texCoords[0].x(), triangle.texCoords[1].x(),triangle.texCoords[2].x(), alpha, beta, gamma); float texV = interpolate(triangle.texCoords[0].y(), triangle.texCoords[1].y(),triangle.texCoords[2].y(), alpha, beta, gamma); TGAColor texColor = texture.getColor(texU, texV); Eigen::Vector3f barycentricNorm = interpolate(triangle.normal[0], triangle.normal[1],triangle.normal[2], alpha, beta, gamma); // 法线来自法线贴图 // Eigen::Vector3f barycentricNorm = TGAColorToVector3f(nm.getColor(texU,texV))*2-Vector3f{1,1,1}; // 切线法线贴图 // Eigen::Vector3f barycentricNmTangent =TGAColorToVector3f(nm_tangent.getColor(texU, texV)) * 2 - Vector3f{1, 1, 1}; // barycentricNmTangent = getNormalFromTangent(triangle, barycentricNmTangent, barycentricNorm); // 高光系数来自高光贴图 TGAColor specKd = spec.getColor(texU, texV); // zbuffer中缓存的渲染物体距离小于当前渲染物体的距离时，才覆盖渲染 if (zBuffer-\u0026gt;at(x).at(y) \u0026lt; barycentricZ) { zBuffer-\u0026gt;at(x).at(y) = barycentricZ; // 阴影处理 // 1. 找到该像素对应物体在原空间的位置 Eigen::Vector4f locationInShaowBuffer = mvpForShadow * (barycentricGlobalCoord.homogeneous()); locationInShaowBuffer.x() /= locationInShaowBuffer.w(); locationInShaowBuffer.y() /= locationInShaowBuffer.w(); locationInShaowBuffer.z() /= locationInShaowBuffer.w(); locationInShaowBuffer.x() = 0.5*width*(locationInShaowBuffer.x()+1); locationInShaowBuffer.y() = 0.5*height*(locationInShaowBuffer.y()+1); bool isShadow = false; // 在阴影中 if (locationInShaowBuffer.z() \u0026lt; shadowBuffer-\u0026gt;at(locationInShaowBuffer.x()).at(locationInShaowBuffer.y())){ isShadow = true; } // 直接使用贴图 // framebuffer.set(x,y, texture.getColor(texU,texV)); // 使用phongshading光照模型 framebuffer.set(x,y, blinnPhongShading(texColor,barycentricGlobalCoord,barycentricNorm,specKd,isShadow)); // 直接使用法线贴图 // framebuffer.set(x,y, nm.getColor(texU,texV)); // 使用法线贴图的法线配合phongshading // framebuffer.set(x,y, blinnPhongShading(texColor,barycentricGlobalCoord,barycentricNorm,specKd)); // 使用切线法线贴图配合phongshaing // framebuffer.set(x, y,blinnPhongShading(texColor, barycentricGlobalCoord, barycentricNmTangent, specKd)); } } } } int main() { Model model(\u0026#34;./obj/diablo3_pose/diablo3_pose.obj\u0026#34;, \u0026#34;./obj/diablo3_pose/diablo3_pose_diffuse.tga\u0026#34;); TGAImage framebuffer(width, height, TGAImage::RGB); // 定义一个zBuffer,并设置全部数据为最小负数 auto *zBuffer = new std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt;(width, std::vector\u0026lt;float\u0026gt;(height,std::numeric_limits\u0026lt;float\u0026gt;::lowest())); // 用于shadow auto *shadowBuffer = new std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt;(width, std::vector\u0026lt;float\u0026gt;(height,std::numeric_limits\u0026lt;float\u0026gt;::lowest())); // 获取法线贴图 Texture nm(\u0026#34;./obj/diablo3_pose/diablo3_pose_nm.tga\u0026#34;); Texture spec(\u0026#34;./obj/diablo3_pose/diablo3_pose_spec.tga\u0026#34;); Texture nm_tangent(\u0026#34;./obj/diablo3_pose/diablo3_pose_nm_tangent.tga\u0026#34;); // 首先先从光源位置渲染，来赋值shadowBuffer model.setModelTransformation(angleX, angleY, angleZ, tx, ty, tz, sx, sy, sz); model.setViewTransformation(lightDir*4, eye_dir, up); model.setProjectionTransformation(fovY, aspectRatio, near, far); // 获取所有变换矩阵 Eigen::Matrix4f mvpForShadow = model.getMVP(); for (Triangle triangle: model.triangleList) { // 坐标投影 triangle.setScreenCoords(mvpForShadow, width, height); // 绘制三角形 shadow(triangle, shadowBuffer); } // 转回正常视角，进行渲染 model.setModelTransformation(angleX, angleY, angleZ, tx, ty, tz, sx, sy, sz); model.setViewTransformation(eye_pos, eye_dir, up); model.setProjectionTransformation(fovY, aspectRatio, near, far); // 获取所有变换矩阵 Eigen::Matrix4f mvp = model.getMVP(); // 遍历obj文件中的每个三角形 for (Triangle triangle: model.triangleList) { // 坐标投影 triangle.setScreenCoords(mvp, width, height); // 摄像机空间点转光源空间点的矩阵 Eigen::Matrix4f viewToLightTrans = mvpForShadow * (mvp.inverse()); // 绘制三角形 drawTriangle(triangle, framebuffer, zBuffer,shadowBuffer, model.texture, nm, spec, nm_tangent,mvpForShadow); } framebuffer.write_tga_file(\u0026#34;framebuffer.tga\u0026#34;); delete (zBuffer); delete (shadowBuffer); return 0; } 最终呈现效果如下，很合理哈，因为右手并未被挡住，所以还是亮的 下图通过过曝来展示哪些像素被阴影化了，还是蛮合理的～ 在使用普通的材质贴图下效果比较好 对于出现的不合理的黑色纹路解释来自别的博客：\n原文链接：https://blog.csdn.net/qq_42987967/article/details/125011820 上述照片脖子部位下面有很明显的z-fighting痕迹，主要是因为浮点数精度不足导致的冲突(即当前的点可能索引到附近的点了，而附近的点的shadowbuffer值比该点大造成误判)，毕竟连续的像素位置很接近，则有的像素大于shadow map，有的则小于shadow map，从而产生了离散的显示。此处采用一个解决z-fighting问题的暴力解法，即对于每一个像素投影光源的z轴值都进行一个偏移，只要在该偏移位置不存在连续像素即可展示不错的画面。\n我需要将最终比较的z值加一个很小的偏移量，瞬间这些黑色条纹就消失了～\n1 2 3 if (locationInShadowBuffer.z()+0.005 \u0026lt; shadowBuffer-\u0026gt;at(locationInShadowBuffer.x()).at(locationInShadowBuffer.y())){ isShadow = true; } ai给出的解释：这种现象叫做自阴影\n深度图的精度限制‌：Shadow Mapping 需要从光源视角生成深度图（Shadow Map）。由于深度图的分辨率有限，物体表面在光源视角下的离散采样可能导致实际连续的几何表面在深度图中被“阶梯化”。当从摄像机视角比较深度时，同一表面的不同片段可能被错误判定为处于阴影中。 浮点数精度误差‌：深度值存储为浮点数，计算时可能因精度不足导致比较错误。例如，光源视角和摄像机视角的深度计算存在微小差异，导致同一位置被误判为“低于”深度图记录的深度，从而触发阴影。 ","date":"2025-10-19T13:42:48+08:00","image":"https://sdpyy1.github.io/23afe9a8003a44a788f6448c182b6b68.png","permalink":"https://sdpyy1.github.io/p/tinyrender%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95-5-7/","title":"TinyRender开发记录 5-7"},{"content":"项目介绍 Tiny Renderer or how OpenGL works: software rendering in 500 lines of code 项目地址：https://github.com/ssloy/tinyrenderer In this series of articles, I want to show how OpenGL works by writing its clone (a much simplified one). Surprisingly enough, I often meet people who cannot overcome the initial hurdle of learning OpenGL / DirectX. Thus, I have prepared a short series of lectures, after which my students show quite good renderers. So, the task is formulated as follows: using no third-party libraries (especially graphic ones), get something like this picture:\n所以学习最终目标是不使用第三方代码，得到下面这种图，建议学完games101后来复习，不过过程很详细，作为入门也是不错的 环境搭建 虽然项目旨在不使用第三方库，但提供了图片读取、保存、设置像素点颜色的代码 tagimage.h\n1 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 #pragma once #include \u0026lt;cstdint\u0026gt; #include \u0026lt;fstream\u0026gt; #include \u0026lt;vector\u0026gt; #pragma pack(push,1) struct TGAHeader { std::uint8_t idlength{}; std::uint8_t colormaptype{}; std::uint8_t datatypecode{}; std::uint16_t colormaporigin{}; std::uint16_t colormaplength{}; std::uint8_t colormapdepth{}; std::uint16_t x_origin{}; std::uint16_t y_origin{}; std::uint16_t width{}; std::uint16_t height{}; std::uint8_t bitsperpixel{}; std::uint8_t imagedescriptor{}; }; #pragma pack(pop) struct TGAColor { std::uint8_t bgra[4] = {0,0,0,0}; std::uint8_t bytespp = {0}; TGAColor() = default; TGAColor(const std::uint8_t R, const std::uint8_t G, const std::uint8_t B, const std::uint8_t A=255) : bgra{B,G,R,A}, bytespp(4) { } TGAColor(const std::uint8_t *p, const std::uint8_t bpp) : bytespp(bpp) { for (int i=bpp; i--; bgra[i] = p[i]); } std::uint8_t\u0026amp; operator[](const int i) { return bgra[i]; } }; struct TGAImage { enum Format { GRAYSCALE=1, RGB=3, RGBA=4 }; TGAImage() = default; TGAImage(const int w, const int h, const int bpp); bool read_tga_file(const std::string filename); bool write_tga_file(const std::string filename, const bool vflip=true, const bool rle=true) const; void flip_horizontally(); void flip_vertically(); TGAColor get(const int x, const int y) const; void set(const int x, const int y, const TGAColor \u0026amp;c); int width() const; int height() const; private: bool load_rle_data(std::ifstream \u0026amp;in); bool unload_rle_data(std::ofstream \u0026amp;out) const; int w = 0; int h = 0; int bpp = 0; std::vector\u0026lt;std::uint8_t\u0026gt; data = {}; }; tgaimage.cpp\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 #include \u0026lt;iostream\u0026gt; #include \u0026lt;cstring\u0026gt; #include \u0026#34;tgaimage.h\u0026#34; TGAImage::TGAImage(const int w, const int h, const int bpp) : w(w), h(h), bpp(bpp), data(w*h*bpp, 0) {} bool TGAImage::read_tga_file(const std::string filename) { std::ifstream in; in.open (filename, std::ios::binary); if (!in.is_open()) { std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t open file \u0026#34; \u0026lt;\u0026lt; filename \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; in.close(); return false; } TGAHeader header; in.read(reinterpret_cast\u0026lt;char *\u0026gt;(\u0026amp;header), sizeof(header)); if (!in.good()) { in.close(); std::cerr \u0026lt;\u0026lt; \u0026#34;an error occured while reading the header\\n\u0026#34;; return false; } w = header.width; h = header.height; bpp = header.bitsperpixel\u0026gt;\u0026gt;3; if (w\u0026lt;=0 || h\u0026lt;=0 || (bpp!=GRAYSCALE \u0026amp;\u0026amp; bpp!=RGB \u0026amp;\u0026amp; bpp!=RGBA)) { in.close(); std::cerr \u0026lt;\u0026lt; \u0026#34;bad bpp (or width/height) value\\n\u0026#34;; return false; } size_t nbytes = bpp*w*h; data = std::vector\u0026lt;std::uint8_t\u0026gt;(nbytes, 0); if (3==header.datatypecode || 2==header.datatypecode) { in.read(reinterpret_cast\u0026lt;char *\u0026gt;(data.data()), nbytes); if (!in.good()) { in.close(); std::cerr \u0026lt;\u0026lt; \u0026#34;an error occured while reading the data\\n\u0026#34;; return false; } } else if (10==header.datatypecode||11==header.datatypecode) { if (!load_rle_data(in)) { in.close(); std::cerr \u0026lt;\u0026lt; \u0026#34;an error occured while reading the data\\n\u0026#34;; return false; } } else { in.close(); std::cerr \u0026lt;\u0026lt; \u0026#34;unknown file format \u0026#34; \u0026lt;\u0026lt; (int)header.datatypecode \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; return false; } if (!(header.imagedescriptor \u0026amp; 0x20)) flip_vertically(); if (header.imagedescriptor \u0026amp; 0x10) flip_horizontally(); std::cerr \u0026lt;\u0026lt; w \u0026lt;\u0026lt; \u0026#34;x\u0026#34; \u0026lt;\u0026lt; h \u0026lt;\u0026lt; \u0026#34;/\u0026#34; \u0026lt;\u0026lt; bpp*8 \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; in.close(); return true; } bool TGAImage::load_rle_data(std::ifstream \u0026amp;in) { size_t pixelcount = w*h; size_t currentpixel = 0; size_t currentbyte = 0; TGAColor colorbuffer; do { std::uint8_t chunkheader = 0; chunkheader = in.get(); if (!in.good()) { std::cerr \u0026lt;\u0026lt; \u0026#34;an error occured while reading the data\\n\u0026#34;; return false; } if (chunkheader\u0026lt;128) { chunkheader++; for (int i=0; i\u0026lt;chunkheader; i++) { in.read(reinterpret_cast\u0026lt;char *\u0026gt;(colorbuffer.bgra), bpp); if (!in.good()) { std::cerr \u0026lt;\u0026lt; \u0026#34;an error occured while reading the header\\n\u0026#34;; return false; } for (int t=0; t\u0026lt;bpp; t++) data[currentbyte++] = colorbuffer.bgra[t]; currentpixel++; if (currentpixel\u0026gt;pixelcount) { std::cerr \u0026lt;\u0026lt; \u0026#34;Too many pixels read\\n\u0026#34;; return false; } } } else { chunkheader -= 127; in.read(reinterpret_cast\u0026lt;char *\u0026gt;(colorbuffer.bgra), bpp); if (!in.good()) { std::cerr \u0026lt;\u0026lt; \u0026#34;an error occured while reading the header\\n\u0026#34;; return false; } for (int i=0; i\u0026lt;chunkheader; i++) { for (int t=0; t\u0026lt;bpp; t++) data[currentbyte++] = colorbuffer.bgra[t]; currentpixel++; if (currentpixel\u0026gt;pixelcount) { std::cerr \u0026lt;\u0026lt; \u0026#34;Too many pixels read\\n\u0026#34;; return false; } } } } while (currentpixel \u0026lt; pixelcount); return true; } bool TGAImage::write_tga_file(const std::string filename, const bool vflip, const bool rle) const { constexpr std::uint8_t developer_area_ref[4] = {0, 0, 0, 0}; constexpr std::uint8_t extension_area_ref[4] = {0, 0, 0, 0}; constexpr std::uint8_t footer[18] = {\u0026#39;T\u0026#39;,\u0026#39;R\u0026#39;,\u0026#39;U\u0026#39;,\u0026#39;E\u0026#39;,\u0026#39;V\u0026#39;,\u0026#39;I\u0026#39;,\u0026#39;S\u0026#39;,\u0026#39;I\u0026#39;,\u0026#39;O\u0026#39;,\u0026#39;N\u0026#39;,\u0026#39;-\u0026#39;,\u0026#39;X\u0026#39;,\u0026#39;F\u0026#39;,\u0026#39;I\u0026#39;,\u0026#39;L\u0026#39;,\u0026#39;E\u0026#39;,\u0026#39;.\u0026#39;,\u0026#39;\\0\u0026#39;}; std::ofstream out; out.open (filename, std::ios::binary); if (!out.is_open()) { std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t open file \u0026#34; \u0026lt;\u0026lt; filename \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; out.close(); return false; } TGAHeader header; header.bitsperpixel = bpp\u0026lt;\u0026lt;3; header.width = w; header.height = h; header.datatypecode = (bpp==GRAYSCALE?(rle?11:3):(rle?10:2)); header.imagedescriptor = vflip ? 0x00 : 0x20; // top-left or bottom-left origin out.write(reinterpret_cast\u0026lt;const char *\u0026gt;(\u0026amp;header), sizeof(header)); if (!out.good()) { out.close(); std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t dump the tga file\\n\u0026#34;; return false; } if (!rle) { out.write(reinterpret_cast\u0026lt;const char *\u0026gt;(data.data()), w*h*bpp); if (!out.good()) { std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t unload raw data\\n\u0026#34;; out.close(); return false; } } else if (!unload_rle_data(out)) { out.close(); std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t unload rle data\\n\u0026#34;; return false; } out.write(reinterpret_cast\u0026lt;const char *\u0026gt;(developer_area_ref), sizeof(developer_area_ref)); if (!out.good()) { std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t dump the tga file\\n\u0026#34;; out.close(); return false; } out.write(reinterpret_cast\u0026lt;const char *\u0026gt;(extension_area_ref), sizeof(extension_area_ref)); if (!out.good()) { std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t dump the tga file\\n\u0026#34;; out.close(); return false; } out.write(reinterpret_cast\u0026lt;const char *\u0026gt;(footer), sizeof(footer)); if (!out.good()) { std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t dump the tga file\\n\u0026#34;; out.close(); return false; } out.close(); return true; } // TODO: it is not necessary to break a raw chunk for two equal pixels (for the matter of the resulting size) bool TGAImage::unload_rle_data(std::ofstream \u0026amp;out) const { const std::uint8_t max_chunk_length = 128; size_t npixels = w*h; size_t curpix = 0; while (curpix\u0026lt;npixels) { size_t chunkstart = curpix*bpp; size_t curbyte = curpix*bpp; std::uint8_t run_length = 1; bool raw = true; while (curpix+run_length\u0026lt;npixels \u0026amp;\u0026amp; run_length\u0026lt;max_chunk_length) { bool succ_eq = true; for (int t=0; succ_eq \u0026amp;\u0026amp; t\u0026lt;bpp; t++) succ_eq = (data[curbyte+t]==data[curbyte+t+bpp]); curbyte += bpp; if (1==run_length) raw = !succ_eq; if (raw \u0026amp;\u0026amp; succ_eq) { run_length--; break; } if (!raw \u0026amp;\u0026amp; !succ_eq) break; run_length++; } curpix += run_length; out.put(raw?run_length-1:run_length+127); if (!out.good()) { std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t dump the tga file\\n\u0026#34;; return false; } out.write(reinterpret_cast\u0026lt;const char *\u0026gt;(data.data()+chunkstart), (raw?run_length*bpp:bpp)); if (!out.good()) { std::cerr \u0026lt;\u0026lt; \u0026#34;can\u0026#39;t dump the tga file\\n\u0026#34;; return false; } } return true; } TGAColor TGAImage::get(const int x, const int y) const { if (!data.size() || x\u0026lt;0 || y\u0026lt;0 || x\u0026gt;=w || y\u0026gt;=h) return {}; return TGAColor(data.data()+(x+y*w)*bpp, bpp); } void TGAImage::set(int x, int y, const TGAColor \u0026amp;c) { if (!data.size() || x\u0026lt;0 || y\u0026lt;0 || x\u0026gt;=w || y\u0026gt;=h) return; memcpy(data.data()+(x+y*w)*bpp, c.bgra, bpp); } void TGAImage::flip_horizontally() { int half = w\u0026gt;\u0026gt;1; for (int i=0; i\u0026lt;half; i++) for (int j=0; j\u0026lt;h; j++) for (int b=0; b\u0026lt;bpp; b++) std::swap(data[(i+j*w)*bpp+b], data[(w-1-i+j*w)*bpp+b]); } void TGAImage::flip_vertically() { int half = h\u0026gt;\u0026gt;1; for (int i=0; i\u0026lt;w; i++) for (int j=0; j\u0026lt;half; j++) for (int b=0; b\u0026lt;bpp; b++) std::swap(data[(i+j*w)*bpp+b], data[(i+(h-1-j)*w)*bpp+b]); } int TGAImage::width() const { return w; } int TGAImage::height() const { return h; } 写一个测试来确保环境正常\n1 2 3 4 5 6 7 8 9 10 #include \u0026#34;tgaimage.h\u0026#34; const TGAColor white = TGAColor(255, 255, 255, 255); const TGAColor red = TGAColor(255, 0, 0, 255); int main(int argc, char** argv) { TGAImage image(100, 100, TGAImage::RGB); image.set(52, 41, red); image.flip_vertically(); // 垂直方向翻转图片，反转y坐标，作者解释是希望图片的原点在左下角，但很多库原点都在左上角 image.write_tga_file(\u0026#34;output.tga\u0026#34;); return 0; } 运行后图片如下 Lesson 1: Bresenham’s Line Drawing Algorithm（画线算法） 首先进行初始化，在图上标记三个位置\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #include \u0026#34;tgaimage.h\u0026#34; constexpr TGAColor white = {255, 255, 255, 255}; // attention, BGRA order constexpr TGAColor green = { 0, 255, 0, 255}; constexpr TGAColor red = { 255, 0, 0, 255}; constexpr TGAColor blue = {255, 128, 64, 255}; constexpr TGAColor yellow = { 0, 200, 255, 255}; int main(int argc, char** argv) { constexpr int width = 64; constexpr int height = 64; TGAImage framebuffer(width, height, TGAImage::RGB); int ax = 7, ay = 3; int bx = 12, by = 37; int cx = 62, cy = 53; framebuffer.set(ax, ay, white); framebuffer.set(bx, by, white); framebuffer.set(cx, cy, white); framebuffer.write_tga_file(\u0026#34;framebuffer.tga\u0026#34;); return 0; } 得到结果如下 首先来学习下如何在像素上画一条线 第一次尝试：想象用参数t来表示的一个在$x_a和x_b$之间的点$(x(t),y(t))$ $$ \\begin{cases} x(t) = a_x + t \\cdot (b_x - a_x) \\\\ y(t) = a_y + t \\cdot (b_y - a_y) \\end{cases} $$ 如果我们变换一下形式就会发现，这就是插值的公式 $$ \\begin{cases} x(t) = (1-t) \\cdot a_x + t \\cdot b_x \\\\ y(t) = (1-t) \\cdot a_y + t \\cdot b_y \\end{cases} $$ 下面来尝试一下通过控制t来绘制这条直线\n1 2 3 4 5 6 7 void drawLine_first(int x1,int y1,int x2,int y2,TGAImage \u0026amp;img,TGAColor color){ for(float t = 0;t\u0026lt;=1;t+=0.02){ int x = std::round(x1 + t * (x2-x1)); // round会进行四舍五入 int y = std::round(y1 + t * (y2-y1)); img.set(x,y,color); } } 考虑左下角的a点和右上角的c点，如果我从a向c绘制一次，再从c向a绘制一次，结果如下 图中能明显看出问题，一是在x上有缝隙，二是不同的绘制方向结果是不同的， 第三另外t步长的设置也不容易控制 接下来看第二种尝试，代码的改变写在了注释中\n1 2 3 4 5 6 7 8 void drawLine_Second(int ax, int ay, int bx, int by, TGAImage \u0026amp;img, TGAColor color){ for (int x = ax ; x\u0026lt;= bx; x++) { // 不再以t控制，而是以x的进行进行控制，保证了水平方向上不会有空隙 // 如果不加强制转换，当分子分母都是整数时，计算结果的小数部分会被截断 float t = (x-ax)/static_cast\u0026lt;float\u0026gt;(bx-ax); // 变换了形式，表示出当x移动一格时，t是多少， int y = std::round( ay + (by-ay)*t ); img.set(x, y, color); } } 这里碰到了Cpp的static_cast，顺便学习一下是什么 隐式转换（Implicit Conversion）：编译器或解释器‌自动完成‌的类型转换，无需程序员显式指定。 显式转换（Explicit Conversion）：程序员‌主动指定‌的类型转换，通常通过语法或函数强制实现。 static_cast是 C++ 中一种显式类型转换操作符，‌用于在编译时进行类型转换，‌适用场景‌：明确的、安全的类型转换（如基本类型转换、向上转换、void* 转换），不用c语言风格的强制转换是为了规避风险。 从下图看到问题2，3已经解决了，原本有的问题1空隙也没有了，但是出现了新的很大的空隙，甚至一条线直接消失了 线消失比较好解决，原因就是从右上角向左下角画线，if就进不去\n1 2 3 4 5 6 7 8 9 10 11 12 void drawLine_Second(int ax, int ay, int bx, int by, TGAImage \u0026amp;img, TGAColor color){ if (ax\u0026gt;bx) { // make it left−to−right std::swap(ax, bx); std::swap(ay, by); } for (int x = ax ; x\u0026lt;= bx; x++) { // 不再以t控制，而是以x的进行进行控制，保证了水平方向上不会有空隙 // 如果不加强制转换，当分子分母都是整数时，计算结果的小数部分会被截断 float t = (x-ax)/static_cast\u0026lt;float\u0026gt;(bx-ax); // 变换了形式，表示出当x移动一格时，t是多少， int y = std::round( ay + (by-ay)*t ); img.set(x, y, color); } } 下面就要解决a-\u0026gt;b这么大空隙了，这个问题就是斜率大的线段的采样不足，因为x只走了几步就到了，也就只会画出几个点 接下来进行第三次尝试，解决思路就是如果斜率太大，就从y进行for，而不是x，教程中的解决思路十分巧妙，如果斜率太大，就交换x坐标和y坐标，同时绘制时绘制坐标变成$(y,x)$\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 最终版本 void drawLine(int ax, int ay, int bx, int by, TGAImage \u0026amp;framebuffer, TGAColor color) { bool steep = std::abs(ax-bx) \u0026lt; std::abs(ay-by); if (steep) { // if the drawLine is steep, we transpose the image std::swap(ax, ay); std::swap(bx, by); } if (ax\u0026gt;bx) { // make it left−to−right std::swap(ax, bx); std::swap(ay, by); } int y = ay; int ierror = 0; for (int x=ax; x\u0026lt;=bx; x++) { if (steep) // if transposed, de−transpose framebuffer.set(y, x, color); else framebuffer.set(x, y, color); ierror += 2 * std::abs(by-ay); y += (by \u0026gt; ay ? 1 : -1) * (ierror \u0026gt; bx - ax); ierror -= 2 * (bx-ax) * (ierror \u0026gt; bx - ax); } } 至此就完成了比较好的效果的直线绘制，第四次尝试是如何优化算法的运行速度，这里就不说了，直接上他最终的优化代码作为后续使用，但是走样（锯齿）问题没有解决，这里不详细说，后边课程肯定会涉及到。\nLesson 2: Triangle rasterization 三角形光栅化 本节课目的是画一个实心的三角形（上节课只画了边） 首先提供一个画线框三角形的代码\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 constexpr int width = 128; constexpr int height = 128; // 绘制一个三角形 void drawTriangle(int ax, int ay, int bx, int by, int cx, int cy, TGAImage \u0026amp;framebuffer, TGAColor color) { drawLine(ax, ay, bx, by, framebuffer, color); drawLine(bx, by, cx, cy, framebuffer, color); drawLine(cx, cy, ax, ay, framebuffer, color); } // 绘制三个三角形进行测试 int main(int argc, char** argv) { TGAImage framebuffer(width, height, TGAImage::RGB); drawTriangle( 7, 45, 35, 100, 45, 60, framebuffer, red); drawTriangle(120, 35, 90, 5, 45, 110, framebuffer, white); drawTriangle(115, 83, 80, 90, 85, 120, framebuffer, green); framebuffer.write_tga_file(\u0026#34;framebuffer.tga\u0026#34;); return 0; } 填充三角形需要做的事情：\n它应该简单快捷 它应该是对称的 —— 输出不应该依赖于传递给函数的顶点顺序 如果两个三角形共享两个顶点，由于光栅化舍入误差，它们之间不应该有间隙 Scanline rendering 线性扫描 这块不是很想实现，因为学过games101后，已经知道更好的方法是什么了😂，这里参考别的博客看看原理是啥吧。\nhttps://blog.csdn.net/qq_42987967/article/details/124831459\n思路就是先对顶点y坐标进行排序，并从中间顶点水平切一刀，这样扫描时比例变化是正常的不会突然反向，交点A沿t0到t2的主斜边移动，B沿t0到t1的侧边移动，移动过程中填充内部的像素 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 void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage \u0026amp;image, TGAColor color) { // sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!) if (t0.y\u0026gt;t1.y) std::swap(t0, t1); if (t0.y\u0026gt;t2.y) std::swap(t0, t2); if (t1.y\u0026gt;t2.y) std::swap(t1, t2); int total_height = t2.y-t0.y; for (int y=t0.y; y\u0026lt;=t1.y; y++) { int segment_height = t1.y-t0.y+1; float alpha = (float)(y-t0.y)/total_height; float beta = (float)(y-t0.y)/segment_height; // be careful with divisions by zero Vec2i A = t0 + (t2-t0)*alpha; Vec2i B = t0 + (t1-t0)*beta; if (A.x\u0026gt;B.x) std::swap(A, B); for (int j=A.x; j\u0026lt;=B.x; j++) { image.set(j, y, color); // attention, due to int casts t0.y+i != A.y } } for (int y=t1.y; y\u0026lt;=t2.y; y++) { int segment_height = t2.y-t1.y+1; float alpha = (float)(y-t0.y)/total_height; float beta = (float)(y-t1.y)/segment_height; // be careful with divisions by zero Vec2i A = t0 + (t2-t0)*alpha; Vec2i B = t1 + (t2-t1)*beta; if (A.x\u0026gt;B.x) std::swap(A, B); for (int j=A.x; j\u0026lt;=B.x; j++) { image.set(j, y, color); // attention, due to int casts t0.y+i != A.y } } } Modern rasterization approach 现代栅格化方法 基本思路就是用包围盒围住三角形，减少需要遍历的三角形数量，之后遍历盒子中每个像素，判断是否在三角形内部，伪代码如下\n1 2 3 4 5 6 7 8 triangle(vec2 points[3]) { vec2 bbox[2] = find_bounding_box(points); for (each pixel in the bounding box) { if (inside(points, pixel)) { put_pixel(pixel); } } } 首先是包围盒的建立，其实就找三个顶点的最大值和最小值，就能画出一个围住三角形最小的矩形\n1 2 3 4 int bbminx = std::min(std::min(ax, bx), cx); // bounding box for the triangle int bbminy = std::min(std::min(ay, by), cy); // defined by its top left and bottom right corners int bbmaxx = std::max(std::max(ax, bx), cx); int bbmaxy = std::max(std::max(ay, by), cy); 之后就是剔除不在三角形内部的像素，在games101提供的方法是用像素坐标叉乘三条边顺序组成的向量，如果叉乘结果都在一个方向，那这个像素点就在三角形内部，在这个教程中并不是这样做的，它是利用重心坐标，计算出一个点对于这个三角形的重心坐标$(\\alpha,\\beta,\\gamma)$，只要有一个是负数，就表示不再三角形内，那就用他这种方法吧。重心坐标反映的是划分为三个小三角形的面积比，如果点在三角形外，那面积算出来就成负数了。 首先提供一个算三角形面积的函数\n1 2 3 double signed_triangle_area(int ax, int ay, int bx, int by, int cx, int cy) { return .5*((by-ay)*(bx+ax) + (cy-by)*(cx+bx) + (ay-cy)*(ax+cx)); } 下来就可以用面积比来求重心坐标了\n1 2 3 4 5 6 7 8 9 10 11 #pragma omp parallel for for (int x=bbminx; x\u0026lt;=bbmaxx; x++) { for (int y=bbminy; y\u0026lt;=bbmaxy; y++) { double alpha = signed_triangle_area(x, y, bx, by, cx, cy) / total_area; double beta = signed_triangle_area(x, y, cx, cy, ax, ay) / total_area; double gamma = signed_triangle_area(x, y, ax, ay, bx, by) / total_area; if (alpha\u0026lt;0 || beta\u0026lt;0 || gamma\u0026lt;0) continue; // negative barycentric coordinate =\u0026gt; the pixel is outside the triangle framebuffer.set(x, y, color); } } } #pragma omp parallel for是OpenMP中的一个指令，用于并行化for循环。OpenMP是一种并行编程模型，可以在支持OpenMP的编译器上使用 最终的三角形绘制如下\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 绘制一个三角形 void drawTriangle(int ax, int ay, int bx, int by, int cx, int cy, TGAImage \u0026amp;framebuffer, TGAColor color) { int bbminx = std::min(std::min(ax, bx), cx); // bounding box for the triangle int bbminy = std::min(std::min(ay, by), cy); // defined by its top left and bottom right corners int bbmaxx = std::max(std::max(ax, bx), cx); int bbmaxy = std::max(std::max(ay, by), cy); double total_area = signed_triangle_area(ax, ay, bx, by, cx, cy); #pragma omp parallel for for (int x=bbminx; x\u0026lt;=bbmaxx; x++) { for (int y=bbminy; y\u0026lt;=bbmaxy; y++) { double alpha = signed_triangle_area(x, y, bx, by, cx, cy) / total_area; double beta = signed_triangle_area(x, y, cx, cy, ax, ay) / total_area; double gamma = signed_triangle_area(x, y, ax, ay, bx, by) / total_area; if (alpha\u0026lt;0 || beta\u0026lt;0 || gamma\u0026lt;0) continue; // negative barycentric coordinate =\u0026gt; the pixel is outside the triangle framebuffer.set(x, y, color); } } } back-face culling 背面剔除 一般来说法线背对相机或光线方向的平面可认为是没用的，可以不用绘制将其剔除以减少运算量。原理是如果正面的三角形都是顺时针，那背面的都是逆时针，另外一种方法是计算三角形法向量与摄像机的点乘，小于0说明它是背对的。 在第2课中使用的是计算三角形的面积，下面这个代码是带符号的，所以负的面积说明三角形是背对的。\n1 2 3 double signed_triangle_area(int ax, int ay, int bx, int by, int cx, int cy) { return .5*((by-ay)*(bx+ax) + (cy-by)*(cx+bx) + (ay-cy)*(ax+cx)); } 在这里引入向量和模型导入的函数 vector.h\n生成下图，可以看出来把所有三角形全画出来，脸部轮廓都不见了 修改三角形绘制函数，如果计算出来的三角形面积是负数，就直接不绘制了，这里设置小于1，是把面积太小的三角形直接省略了\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void drawTriangle(int ax, int ay, int bx, int by, int cx, int cy, TGAImage \u0026amp;framebuffer, TGAColor color) { int bbminx = std::min(std::min(ax, bx), cx); int bbminy = std::min(std::min(ay, by), cy); int bbmaxx = std::max(std::max(ax, bx), cx); int bbmaxy = std::max(std::max(ay, by), cy); double total_area = signed_triangle_area(ax, ay, bx, by, cx, cy); if (total_area\u0026lt;1) return; #pragma omp parallel for for (int x=bbminx; x\u0026lt;=bbmaxx; x++) { for (int y=bbminy; y\u0026lt;=bbmaxy; y++) { double alpha = signed_triangle_area(x, y, bx, by, cx, cy) / total_area; double beta = signed_triangle_area(x, y, cx, cy, ax, ay) / total_area; double gamma = signed_triangle_area(x, y, ax, ay, bx, by) / total_area; if (alpha\u0026lt;0 || beta\u0026lt;0 || gamma\u0026lt;0) continue; framebuffer.set(x, y, color); } } } 修改后明显可以看出脸部轮廓出来了\n使用2k分辨率，效果更好了 Lesson 3: Hidden faces removal (z buffer) 首先介绍一下代码变动 模型获取使用开源库\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 // // Created by LEI XU on 4/28/19. // // // This loader is created by Robert Smith. // https://github.com/Bly7/OBJ-Loader // Use the MIT license. #ifndef RASTERIZER_OBJ_LOADER_H #define RASTERIZER_OBJ_LOADER_H #pragma once #include \u0026lt;iostream\u0026gt; #include \u0026lt;vector\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;fstream\u0026gt; #include \u0026lt;math.h\u0026gt; // Print progress to console while loading (large models) #define OBJL_CONSOLE_OUTPUT // Namespace: OBJL // // Description: The namespace that holds eveyrthing that //\tis needed and used for the OBJ Model Loader namespace objl { // Structure: Vector2 // // Description: A 2D Vector that Holds Positional Data struct Vector2 { // Default Constructor Vector2() { X = 0.0f; Y = 0.0f; } // Variable Set Constructor Vector2(float X_, float Y_) { X = X_; Y = Y_; } // Bool Equals Operator Overload bool operator==(const Vector2\u0026amp; other) const { return (this-\u0026gt;X == other.X \u0026amp;\u0026amp; this-\u0026gt;Y == other.Y); } // Bool Not Equals Operator Overload bool operator!=(const Vector2\u0026amp; other) const { return !(this-\u0026gt;X == other.X \u0026amp;\u0026amp; this-\u0026gt;Y == other.Y); } // Addition Operator Overload Vector2 operator+(const Vector2\u0026amp; right) const { return Vector2(this-\u0026gt;X + right.X, this-\u0026gt;Y + right.Y); } // Subtraction Operator Overload Vector2 operator-(const Vector2\u0026amp; right) const { return Vector2(this-\u0026gt;X - right.X, this-\u0026gt;Y - right.Y); } // Float Multiplication Operator Overload Vector2 operator*(const float\u0026amp; other) const { return Vector2(this-\u0026gt;X *other, this-\u0026gt;Y * other); } // Positional Variables float X; float Y; }; // Structure: Vector3 // // Description: A 3D Vector that Holds Positional Data struct Vector3 { // Default Constructor Vector3() { X = 0.0f; Y = 0.0f; Z = 0.0f; } // Variable Set Constructor Vector3(float X_, float Y_, float Z_) { X = X_; Y = Y_; Z = Z_; } // Bool Equals Operator Overload bool operator==(const Vector3\u0026amp; other) const { return (this-\u0026gt;X == other.X \u0026amp;\u0026amp; this-\u0026gt;Y == other.Y \u0026amp;\u0026amp; this-\u0026gt;Z == other.Z); } // Bool Not Equals Operator Overload bool operator!=(const Vector3\u0026amp; other) const { return !(this-\u0026gt;X == other.X \u0026amp;\u0026amp; this-\u0026gt;Y == other.Y \u0026amp;\u0026amp; this-\u0026gt;Z == other.Z); } // Addition Operator Overload Vector3 operator+(const Vector3\u0026amp; right) const { return Vector3(this-\u0026gt;X + right.X, this-\u0026gt;Y + right.Y, this-\u0026gt;Z + right.Z); } // Subtraction Operator Overload Vector3 operator-(const Vector3\u0026amp; right) const { return Vector3(this-\u0026gt;X - right.X, this-\u0026gt;Y - right.Y, this-\u0026gt;Z - right.Z); } // Float Multiplication Operator Overload Vector3 operator*(const float\u0026amp; other) const { return Vector3(this-\u0026gt;X * other, this-\u0026gt;Y * other, this-\u0026gt;Z * other); } // Float Division Operator Overload Vector3 operator/(const float\u0026amp; other) const { return Vector3(this-\u0026gt;X / other, this-\u0026gt;Y / other, this-\u0026gt;Z / other); } // Positional Variables float X; float Y; float Z; }; // Structure: Vertex // // Description: Model Vertex object that holds //\ta Position, Normal, and Texture Coordinate struct Vertex { // Position Vector Vector3 Position; // Normal Vector Vector3 Normal; // Texture Coordinate Vector Vector2 TextureCoordinate; }; struct Material { Material() { name; Ns = 0.0f; Ni = 0.0f; d = 0.0f; illum = 0; } // Material Name std::string name; // Ambient Color Vector3 Ka; // Diffuse Color Vector3 Kd; // Specular Color Vector3 Ks; // Specular Exponent float Ns; // Optical Density float Ni; // Dissolve float d; // Illumination int illum; // Ambient Texture Map std::string map_Ka; // Diffuse Texture Map std::string map_Kd; // Specular Texture Map std::string map_Ks; // Specular Hightlight Map std::string map_Ns; // Alpha Texture Map std::string map_d; // Bump Map std::string map_bump; }; // Structure: Mesh // // Description: A Simple Mesh Object that holds //\ta name, a vertex list, and an index list struct Mesh { // Default Constructor Mesh() { } // Variable Set Constructor Mesh(std::vector\u0026lt;Vertex\u0026gt;\u0026amp; _Vertices, std::vector\u0026lt;unsigned int\u0026gt;\u0026amp; _Indices) { Vertices = _Vertices; Indices = _Indices; } // Mesh Name std::string MeshName; // Vertex List std::vector\u0026lt;Vertex\u0026gt; Vertices; // Index List std::vector\u0026lt;unsigned int\u0026gt; Indices; // Material Material MeshMaterial; }; // Namespace: Math // // Description: The namespace that holds all of the math //\tfunctions need for OBJL namespace math { // Vector3 Cross Product Vector3 CrossV3(const Vector3 a, const Vector3 b) { return Vector3(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); } // Vector3 Magnitude Calculation float MagnitudeV3(const Vector3 in) { return (sqrtf(powf(in.X, 2) + powf(in.Y, 2) + powf(in.Z, 2))); } // Vector3 DotProduct float DotV3(const Vector3 a, const Vector3 b) { return (a.X * b.X) + (a.Y * b.Y) + (a.Z * b.Z); } // Angle between 2 Vector3 Objects float AngleBetweenV3(const Vector3 a, const Vector3 b) { float angle = DotV3(a, b); angle /= (MagnitudeV3(a) * MagnitudeV3(b)); return angle = acosf(angle); } // Projection Calculation of a onto b Vector3 ProjV3(const Vector3 a, const Vector3 b) { Vector3 bn = b / MagnitudeV3(b); return bn * DotV3(a, bn); } } // Namespace: Algorithm // // Description: The namespace that holds all of the // Algorithms needed for OBJL namespace algorithm { // Vector3 Multiplication Opertor Overload Vector3 operator*(const float\u0026amp; left, const Vector3\u0026amp; right) { return Vector3(right.X * left, right.Y * left, right.Z * left); } // A test to see if P1 is on the same side as P2 of a line segment ab bool SameSide(Vector3 p1, Vector3 p2, Vector3 a, Vector3 b) { Vector3 cp1 = math::CrossV3(b - a, p1 - a); Vector3 cp2 = math::CrossV3(b - a, p2 - a); if (math::DotV3(cp1, cp2) \u0026gt;= 0) return true; else return false; } // Generate a cross produect normal for a triangle Vector3 GenTriNormal(Vector3 t1, Vector3 t2, Vector3 t3) { Vector3 u = t2 - t1; Vector3 v = t3 - t1; Vector3 normal = math::CrossV3(u,v); return normal; } // Check to see if a Vector3 Point is within a 3 Vector3 Triangle bool inTriangle(Vector3 point, Vector3 tri1, Vector3 tri2, Vector3 tri3) { // Test to see if it is within an infinite prism that the triangle outlines. bool within_tri_prisim = SameSide(point, tri1, tri2, tri3) \u0026amp;\u0026amp; SameSide(point, tri2, tri1, tri3) \u0026amp;\u0026amp; SameSide(point, tri3, tri1, tri2); // If it isn\u0026#39;t it will never be on the triangle if (!within_tri_prisim) return false; // Calulate Triangle\u0026#39;s Normal Vector3 n = GenTriNormal(tri1, tri2, tri3); // Project the point onto this normal Vector3 proj = math::ProjV3(point, n); // If the distance from the triangle to the point is 0 //\tit lies on the triangle if (math::MagnitudeV3(proj) == 0) return true; else return false; } // Split a String into a string array at a given token inline void split(const std::string \u0026amp;in, std::vector\u0026lt;std::string\u0026gt; \u0026amp;out, std::string token) { out.clear(); std::string temp; for (int i = 0; i \u0026lt; int(in.size()); i++) { std::string test = in.substr(i, token.size()); if (test == token) { if (!temp.empty()) { out.push_back(temp); temp.clear(); i += (int)token.size() - 1; } else { out.push_back(\u0026#34;\u0026#34;); } } else if (i + token.size() \u0026gt;= in.size()) { temp += in.substr(i, token.size()); out.push_back(temp); break; } else { temp += in[i]; } } } // Get tail of string after first token and possibly following spaces inline std::string tail(const std::string \u0026amp;in) { size_t token_start = in.find_first_not_of(\u0026#34; \\t\u0026#34;); size_t space_start = in.find_first_of(\u0026#34; \\t\u0026#34;, token_start); size_t tail_start = in.find_first_not_of(\u0026#34; \\t\u0026#34;, space_start); size_t tail_end = in.find_last_not_of(\u0026#34; \\t\u0026#34;); if (tail_start != std::string::npos \u0026amp;\u0026amp; tail_end != std::string::npos) { return in.substr(tail_start, tail_end - tail_start + 1); } else if (tail_start != std::string::npos) { return in.substr(tail_start); } return \u0026#34;\u0026#34;; } // Get first token of string inline std::string firstToken(const std::string \u0026amp;in) { if (!in.empty()) { size_t token_start = in.find_first_not_of(\u0026#34; \\t\u0026#34;); size_t token_end = in.find_first_of(\u0026#34; \\t\u0026#34;, token_start); if (token_start != std::string::npos \u0026amp;\u0026amp; token_end != std::string::npos) { return in.substr(token_start, token_end - token_start); } else if (token_start != std::string::npos) { return in.substr(token_start); } } return \u0026#34;\u0026#34;; } // Get element at given index position template \u0026lt;class T\u0026gt; inline const T \u0026amp; getElement(const std::vector\u0026lt;T\u0026gt; \u0026amp;elements, std::string \u0026amp;index) { int idx = std::stoi(index); if (idx \u0026lt; 0) idx = int(elements.size()) + idx; else idx--; return elements[idx]; } } // Class: Loader // // Description: The OBJ Model Loader class Loader { public: // Default Constructor Loader() { } ~Loader() { LoadedMeshes.clear(); } // Load a file into the loader // // If file is loaded return true // // If the file is unable to be found // or unable to be loaded return false bool LoadFile(std::string Path) { // If the file is not an .obj file return false if (Path.substr(Path.size() - 4, 4) != \u0026#34;.obj\u0026#34;) return false; std::ifstream file(Path); if (!file.is_open()) return false; LoadedMeshes.clear(); LoadedVertices.clear(); LoadedIndices.clear(); std::vector\u0026lt;Vector3\u0026gt; Positions; std::vector\u0026lt;Vector2\u0026gt; TCoords; std::vector\u0026lt;Vector3\u0026gt; Normals; std::vector\u0026lt;Vertex\u0026gt; Vertices; std::vector\u0026lt;unsigned int\u0026gt; Indices; std::vector\u0026lt;std::string\u0026gt; MeshMatNames; bool listening = false; std::string meshname; Mesh tempMesh; #ifdef OBJL_CONSOLE_OUTPUT const unsigned int outputEveryNth = 1000; unsigned int outputIndicator = outputEveryNth; #endif std::string curline; while (std::getline(file, curline)) { #ifdef OBJL_CONSOLE_OUTPUT if ((outputIndicator = ((outputIndicator + 1) % outputEveryNth)) == 1) { if (!meshname.empty()) { std::cout \u0026lt;\u0026lt; \u0026#34;\\r- \u0026#34; \u0026lt;\u0026lt; meshname \u0026lt;\u0026lt; \u0026#34;\\t| vertices \u0026gt; \u0026#34; \u0026lt;\u0026lt; Positions.size() \u0026lt;\u0026lt; \u0026#34;\\t| texcoords \u0026gt; \u0026#34; \u0026lt;\u0026lt; TCoords.size() \u0026lt;\u0026lt; \u0026#34;\\t| normals \u0026gt; \u0026#34; \u0026lt;\u0026lt; Normals.size() \u0026lt;\u0026lt; \u0026#34;\\t| triangles \u0026gt; \u0026#34; \u0026lt;\u0026lt; (Vertices.size() / 3) \u0026lt;\u0026lt; (!MeshMatNames.empty() ? \u0026#34;\\t| material: \u0026#34; + MeshMatNames.back() : \u0026#34;\u0026#34;); } } #endif // Generate a Mesh Object or Prepare for an object to be created if (algorithm::firstToken(curline) == \u0026#34;o\u0026#34; || algorithm::firstToken(curline) == \u0026#34;g\u0026#34; || curline[0] == \u0026#39;g\u0026#39;) { if (!listening) { listening = true; if (algorithm::firstToken(curline) == \u0026#34;o\u0026#34; || algorithm::firstToken(curline) == \u0026#34;g\u0026#34;) { meshname = algorithm::tail(curline); } else { meshname = \u0026#34;unnamed\u0026#34;; } } else { // Generate the mesh to put into the array if (!Indices.empty() \u0026amp;\u0026amp; !Vertices.empty()) { // Create Mesh tempMesh = Mesh(Vertices, Indices); tempMesh.MeshName = meshname; // Insert Mesh LoadedMeshes.push_back(tempMesh); // Cleanup Vertices.clear(); Indices.clear(); meshname.clear(); meshname = algorithm::tail(curline); } else { if (algorithm::firstToken(curline) == \u0026#34;o\u0026#34; || algorithm::firstToken(curline) == \u0026#34;g\u0026#34;) { meshname = algorithm::tail(curline); } else { meshname = \u0026#34;unnamed\u0026#34;; } } } #ifdef OBJL_CONSOLE_OUTPUT std::cout \u0026lt;\u0026lt; std::endl; outputIndicator = 0; #endif } // Generate a Vertex Position if (algorithm::firstToken(curline) == \u0026#34;v\u0026#34;) { std::vector\u0026lt;std::string\u0026gt; spos; Vector3 vpos; algorithm::split(algorithm::tail(curline), spos, \u0026#34; \u0026#34;); vpos.X = std::stof(spos[0]); vpos.Y = std::stof(spos[1]); vpos.Z = std::stof(spos[2]); Positions.push_back(vpos); } // Generate a Vertex Texture Coordinate if (algorithm::firstToken(curline) == \u0026#34;vt\u0026#34;) { std::vector\u0026lt;std::string\u0026gt; stex; Vector2 vtex; algorithm::split(algorithm::tail(curline), stex, \u0026#34; \u0026#34;); vtex.X = std::stof(stex[0]); vtex.Y = std::stof(stex[1]); TCoords.push_back(vtex); } // Generate a Vertex Normal; if (algorithm::firstToken(curline) == \u0026#34;vn\u0026#34;) { std::vector\u0026lt;std::string\u0026gt; snor; Vector3 vnor; algorithm::split(algorithm::tail(curline), snor, \u0026#34; \u0026#34;); vnor.X = std::stof(snor[0]); vnor.Y = std::stof(snor[1]); vnor.Z = std::stof(snor[2]); Normals.push_back(vnor); } // Generate a Face (vertices \u0026amp; indices) if (algorithm::firstToken(curline) == \u0026#34;f\u0026#34;) { // Generate the vertices std::vector\u0026lt;Vertex\u0026gt; vVerts; GenVerticesFromRawOBJ(vVerts, Positions, TCoords, Normals, curline); // Add Vertices for (int i = 0; i \u0026lt; int(vVerts.size()); i++) { Vertices.push_back(vVerts[i]); LoadedVertices.push_back(vVerts[i]); } std::vector\u0026lt;unsigned int\u0026gt; iIndices; VertexTriangluation(iIndices, vVerts); // Add Indices for (int i = 0; i \u0026lt; int(iIndices.size()); i++) { unsigned int indnum = (unsigned int)((Vertices.size()) - vVerts.size()) + iIndices[i]; Indices.push_back(indnum); indnum = (unsigned int)((LoadedVertices.size()) - vVerts.size()) + iIndices[i]; LoadedIndices.push_back(indnum); } } // Get Mesh Material Name if (algorithm::firstToken(curline) == \u0026#34;usemtl\u0026#34;) { MeshMatNames.push_back(algorithm::tail(curline)); // Create new Mesh, if Material changes within a group if (!Indices.empty() \u0026amp;\u0026amp; !Vertices.empty()) { // Create Mesh tempMesh = Mesh(Vertices, Indices); tempMesh.MeshName = meshname; int i = 2; while(1) { tempMesh.MeshName = meshname + \u0026#34;_\u0026#34; + std::to_string(i); for (auto \u0026amp;m : LoadedMeshes) if (m.MeshName == tempMesh.MeshName) continue; break; } // Insert Mesh LoadedMeshes.push_back(tempMesh); // Cleanup Vertices.clear(); Indices.clear(); } #ifdef OBJL_CONSOLE_OUTPUT outputIndicator = 0; #endif } // Load Materials if (algorithm::firstToken(curline) == \u0026#34;mtllib\u0026#34;) { // Generate LoadedMaterial // Generate a path to the material file std::vector\u0026lt;std::string\u0026gt; temp; algorithm::split(Path, temp, \u0026#34;/\u0026#34;); std::string pathtomat = \u0026#34;\u0026#34;; if (temp.size() != 1) { for (int i = 0; i \u0026lt; temp.size() - 1; i++) { pathtomat += temp[i] + \u0026#34;/\u0026#34;; } } pathtomat += algorithm::tail(curline); #ifdef OBJL_CONSOLE_OUTPUT std::cout \u0026lt;\u0026lt; std::endl \u0026lt;\u0026lt; \u0026#34;- find materials in: \u0026#34; \u0026lt;\u0026lt; pathtomat \u0026lt;\u0026lt; std::endl; #endif // Load Materials LoadMaterials(pathtomat); } } #ifdef OBJL_CONSOLE_OUTPUT std::cout \u0026lt;\u0026lt; std::endl; #endif // Deal with last mesh if (!Indices.empty() \u0026amp;\u0026amp; !Vertices.empty()) { // Create Mesh tempMesh = Mesh(Vertices, Indices); tempMesh.MeshName = meshname; // Insert Mesh LoadedMeshes.push_back(tempMesh); } file.close(); // Set Materials for each Mesh for (int i = 0; i \u0026lt; MeshMatNames.size(); i++) { std::string matname = MeshMatNames[i]; // Find corresponding material name in loaded materials // when found copy material variables into mesh material for (int j = 0; j \u0026lt; LoadedMaterials.size(); j++) { if (LoadedMaterials[j].name == matname) { LoadedMeshes[i].MeshMaterial = LoadedMaterials[j]; break; } } } if (LoadedMeshes.empty() \u0026amp;\u0026amp; LoadedVertices.empty() \u0026amp;\u0026amp; LoadedIndices.empty()) { return false; } else { return true; } } // Loaded Mesh Objects std::vector\u0026lt;Mesh\u0026gt; LoadedMeshes; // Loaded Vertex Objects std::vector\u0026lt;Vertex\u0026gt; LoadedVertices; // Loaded Index Positions std::vector\u0026lt;unsigned int\u0026gt; LoadedIndices; // Loaded Material Objects std::vector\u0026lt;Material\u0026gt; LoadedMaterials; private: // Generate vertices from a list of positions, //\ttcoords, normals and a face line void GenVerticesFromRawOBJ(std::vector\u0026lt;Vertex\u0026gt;\u0026amp; oVerts, const std::vector\u0026lt;Vector3\u0026gt;\u0026amp; iPositions, const std::vector\u0026lt;Vector2\u0026gt;\u0026amp; iTCoords, const std::vector\u0026lt;Vector3\u0026gt;\u0026amp; iNormals, std::string icurline) { std::vector\u0026lt;std::string\u0026gt; sface, svert; Vertex vVert; algorithm::split(algorithm::tail(icurline), sface, \u0026#34; \u0026#34;); bool noNormal = false; // For every given vertex do this for (int i = 0; i \u0026lt; int(sface.size()); i++) { // See What type the vertex is. int vtype; algorithm::split(sface[i], svert, \u0026#34;/\u0026#34;); // Check for just position - v1 if (svert.size() == 1) { // Only position vtype = 1; } // Check for position \u0026amp; texture - v1/vt1 if (svert.size() == 2) { // Position \u0026amp; Texture vtype = 2; } // Check for Position, Texture and Normal - v1/vt1/vn1 // or if Position and Normal - v1//vn1 if (svert.size() == 3) { if (svert[1] != \u0026#34;\u0026#34;) { // Position, Texture, and Normal vtype = 4; } else { // Position \u0026amp; Normal vtype = 3; } } // Calculate and store the vertex switch (vtype) { case 1: // P { vVert.Position = algorithm::getElement(iPositions, svert[0]); vVert.TextureCoordinate = Vector2(0, 0); noNormal = true; oVerts.push_back(vVert); break; } case 2: // P/T { vVert.Position = algorithm::getElement(iPositions, svert[0]); vVert.TextureCoordinate = algorithm::getElement(iTCoords, svert[1]); noNormal = true; oVerts.push_back(vVert); break; } case 3: // P//N { vVert.Position = algorithm::getElement(iPositions, svert[0]); vVert.TextureCoordinate = Vector2(0, 0); vVert.Normal = algorithm::getElement(iNormals, svert[2]); oVerts.push_back(vVert); break; } case 4: // P/T/N { vVert.Position = algorithm::getElement(iPositions, svert[0]); vVert.TextureCoordinate = algorithm::getElement(iTCoords, svert[1]); vVert.Normal = algorithm::getElement(iNormals, svert[2]); oVerts.push_back(vVert); break; } default: { break; } } } // take care of missing normals // these may not be truly acurate but it is the // best they get for not compiling a mesh with normals if (noNormal) { Vector3 A = oVerts[0].Position - oVerts[1].Position; Vector3 B = oVerts[2].Position - oVerts[1].Position; Vector3 normal = math::CrossV3(A, B); for (int i = 0; i \u0026lt; int(oVerts.size()); i++) { oVerts[i].Normal = normal; } } } // Triangulate a list of vertices into a face by printing //\tinducies corresponding with triangles within it void VertexTriangluation(std::vector\u0026lt;unsigned int\u0026gt;\u0026amp; oIndices, const std::vector\u0026lt;Vertex\u0026gt;\u0026amp; iVerts) { // If there are 2 or less verts, // no triangle can be created, // so exit if (iVerts.size() \u0026lt; 3) { return; } // If it is a triangle no need to calculate it if (iVerts.size() == 3) { oIndices.push_back(0); oIndices.push_back(1); oIndices.push_back(2); return; } // Create a list of vertices std::vector\u0026lt;Vertex\u0026gt; tVerts = iVerts; while (true) { // For every vertex for (int i = 0; i \u0026lt; int(tVerts.size()); i++) { // pPrev = the previous vertex in the list Vertex pPrev; if (i == 0) { pPrev = tVerts[tVerts.size() - 1]; } else { pPrev = tVerts[i - 1]; } // pCur = the current vertex; Vertex pCur = tVerts[i]; // pNext = the next vertex in the list Vertex pNext; if (i == tVerts.size() - 1) { pNext = tVerts[0]; } else { pNext = tVerts[i + 1]; } // Check to see if there are only 3 verts left // if so this is the last triangle if (tVerts.size() == 3) { // Create a triangle from pCur, pPrev, pNext for (int j = 0; j \u0026lt; int(tVerts.size()); j++) { if (iVerts[j].Position == pCur.Position) oIndices.push_back(j); if (iVerts[j].Position == pPrev.Position) oIndices.push_back(j); if (iVerts[j].Position == pNext.Position) oIndices.push_back(j); } tVerts.clear(); break; } if (tVerts.size() == 4) { // Create a triangle from pCur, pPrev, pNext for (int j = 0; j \u0026lt; int(iVerts.size()); j++) { if (iVerts[j].Position == pCur.Position) oIndices.push_back(j); if (iVerts[j].Position == pPrev.Position) oIndices.push_back(j); if (iVerts[j].Position == pNext.Position) oIndices.push_back(j); } Vector3 tempVec; for (int j = 0; j \u0026lt; int(tVerts.size()); j++) { if (tVerts[j].Position != pCur.Position \u0026amp;\u0026amp; tVerts[j].Position != pPrev.Position \u0026amp;\u0026amp; tVerts[j].Position != pNext.Position) { tempVec = tVerts[j].Position; break; } } // Create a triangle from pCur, pPrev, pNext for (int j = 0; j \u0026lt; int(iVerts.size()); j++) { if (iVerts[j].Position == pPrev.Position) oIndices.push_back(j); if (iVerts[j].Position == pNext.Position) oIndices.push_back(j); if (iVerts[j].Position == tempVec) oIndices.push_back(j); } tVerts.clear(); break; } // If Vertex is not an interior vertex float angle = math::AngleBetweenV3(pPrev.Position - pCur.Position, pNext.Position - pCur.Position) * (180 / 3.14159265359); if (angle \u0026lt;= 0 \u0026amp;\u0026amp; angle \u0026gt;= 180) continue; // If any vertices are within this triangle bool inTri = false; for (int j = 0; j \u0026lt; int(iVerts.size()); j++) { if (algorithm::inTriangle(iVerts[j].Position, pPrev.Position, pCur.Position, pNext.Position) \u0026amp;\u0026amp; iVerts[j].Position != pPrev.Position \u0026amp;\u0026amp; iVerts[j].Position != pCur.Position \u0026amp;\u0026amp; iVerts[j].Position != pNext.Position) { inTri = true; break; } } if (inTri) continue; // Create a triangle from pCur, pPrev, pNext for (int j = 0; j \u0026lt; int(iVerts.size()); j++) { if (iVerts[j].Position == pCur.Position) oIndices.push_back(j); if (iVerts[j].Position == pPrev.Position) oIndices.push_back(j); if (iVerts[j].Position == pNext.Position) oIndices.push_back(j); } // Delete pCur from the list for (int j = 0; j \u0026lt; int(tVerts.size()); j++) { if (tVerts[j].Position == pCur.Position) { tVerts.erase(tVerts.begin() + j); break; } } // reset i to the start // -1 since loop will add 1 to it i = -1; } // if no triangles were created if (oIndices.size() == 0) break; // if no more vertices if (tVerts.size() == 0) break; } } // Load Materials from .mtl file bool LoadMaterials(std::string path) { // If the file is not a material file return false if (path.substr(path.size() - 4, path.size()) != \u0026#34;.mtl\u0026#34;) return false; std::ifstream file(path); // If the file is not found return false if (!file.is_open()) return false; Material tempMaterial; bool listening = false; // Go through each line looking for material variables std::string curline; while (std::getline(file, curline)) { // new material and material name if (algorithm::firstToken(curline) == \u0026#34;newmtl\u0026#34;) { if (!listening) { listening = true; if (curline.size() \u0026gt; 7) { tempMaterial.name = algorithm::tail(curline); } else { tempMaterial.name = \u0026#34;none\u0026#34;; } } else { // Generate the material // Push Back loaded Material LoadedMaterials.push_back(tempMaterial); // Clear Loaded Material tempMaterial = Material(); if (curline.size() \u0026gt; 7) { tempMaterial.name = algorithm::tail(curline); } else { tempMaterial.name = \u0026#34;none\u0026#34;; } } } // Ambient Color if (algorithm::firstToken(curline) == \u0026#34;Ka\u0026#34;) { std::vector\u0026lt;std::string\u0026gt; temp; algorithm::split(algorithm::tail(curline), temp, \u0026#34; \u0026#34;); if (temp.size() != 3) continue; tempMaterial.Ka.X = std::stof(temp[0]); tempMaterial.Ka.Y = std::stof(temp[1]); tempMaterial.Ka.Z = std::stof(temp[2]); } // Diffuse Color if (algorithm::firstToken(curline) == \u0026#34;Kd\u0026#34;) { std::vector\u0026lt;std::string\u0026gt; temp; algorithm::split(algorithm::tail(curline), temp, \u0026#34; \u0026#34;); if (temp.size() != 3) continue; tempMaterial.Kd.X = std::stof(temp[0]); tempMaterial.Kd.Y = std::stof(temp[1]); tempMaterial.Kd.Z = std::stof(temp[2]); } // Specular Color if (algorithm::firstToken(curline) == \u0026#34;Ks\u0026#34;) { std::vector\u0026lt;std::string\u0026gt; temp; algorithm::split(algorithm::tail(curline), temp, \u0026#34; \u0026#34;); if (temp.size() != 3) continue; tempMaterial.Ks.X = std::stof(temp[0]); tempMaterial.Ks.Y = std::stof(temp[1]); tempMaterial.Ks.Z = std::stof(temp[2]); } // Specular Exponent if (algorithm::firstToken(curline) == \u0026#34;Ns\u0026#34;) { tempMaterial.Ns = std::stof(algorithm::tail(curline)); } // Optical Density if (algorithm::firstToken(curline) == \u0026#34;Ni\u0026#34;) { tempMaterial.Ni = std::stof(algorithm::tail(curline)); } // Dissolve if (algorithm::firstToken(curline) == \u0026#34;d\u0026#34;) { tempMaterial.d = std::stof(algorithm::tail(curline)); } // Illumination if (algorithm::firstToken(curline) == \u0026#34;illum\u0026#34;) { tempMaterial.illum = std::stoi(algorithm::tail(curline)); } // Ambient Texture Map if (algorithm::firstToken(curline) == \u0026#34;map_Ka\u0026#34;) { tempMaterial.map_Ka = algorithm::tail(curline); } // Diffuse Texture Map if (algorithm::firstToken(curline) == \u0026#34;map_Kd\u0026#34;) { tempMaterial.map_Kd = algorithm::tail(curline); } // Specular Texture Map if (algorithm::firstToken(curline) == \u0026#34;map_Ks\u0026#34;) { tempMaterial.map_Ks = algorithm::tail(curline); } // Specular Hightlight Map if (algorithm::firstToken(curline) == \u0026#34;map_Ns\u0026#34;) { tempMaterial.map_Ns = algorithm::tail(curline); } // Alpha Texture Map if (algorithm::firstToken(curline) == \u0026#34;map_d\u0026#34;) { tempMaterial.map_d = algorithm::tail(curline); } // Bump Map if (algorithm::firstToken(curline) == \u0026#34;map_Bump\u0026#34; || algorithm::firstToken(curline) == \u0026#34;map_bump\u0026#34; || algorithm::firstToken(curline) == \u0026#34;bump\u0026#34;) { tempMaterial.map_bump = algorithm::tail(curline); } } // Deal with last material // Push Back loaded Material LoadedMaterials.push_back(tempMaterial); // Test to see if anything was loaded // If not return false if (LoadedMaterials.empty()) return false; // If so return true else return true; } }; } #endif //RASTERIZER_OBJ_LOADER_H 线性代数使用Eigen库\n三角形类\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #ifndef TINYRENDERER_TRIANGLE_H #define TINYRENDERER_TRIANGLE_H #include \u0026lt;Eigen/Eigen\u0026gt; using namespace Eigen; class Triangle{ public: Eigen::Vector4f globalCoords[3]; Eigen::Vector3f color[3]; Eigen::Vector2f texCoords[3]; Eigen::Vector3f normal[3]; Eigen::Vector3f screenCoords[3]; Triangle(); void setGlobalCoords(int ind, Eigen::Vector4f ver); void setNormal(int ind, Eigen::Vector3f n); void setTexCoord(int ind,Eigen::Vector2f uv); void setScreenCoord(int ind,int width,int height); }; #endif //TINYRENDERER_TRIANGLE_H 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 #include \u0026#34;Triangle.h\u0026#34; Triangle::Triangle() { globalCoords[0] \u0026lt;\u0026lt; 0,0,0,1; globalCoords[1] \u0026lt;\u0026lt; 0,0,0,1; globalCoords[2] \u0026lt;\u0026lt; 0,0,0,1; color[0] \u0026lt;\u0026lt; 0.0, 0.0, 0.0; color[1] \u0026lt;\u0026lt; 0.0, 0.0, 0.0; color[2] \u0026lt;\u0026lt; 0.0, 0.0, 0.0; texCoords[0] \u0026lt;\u0026lt; 0.0, 0.0; texCoords[1] \u0026lt;\u0026lt; 0.0, 0.0; texCoords[2] \u0026lt;\u0026lt; 0.0, 0.0; } void Triangle::setGlobalCoords(int ind, Vector4f ver){ globalCoords[ind] = ver; } void Triangle::setNormal(int ind, Vector3f n){ normal[ind] = n; } void Triangle::setTexCoord(int ind, Vector2f uv) { texCoords[ind] = uv; } // 简单实现正交投影 Vector3f world2screen(Vector4f globalCoord,int width,int height) { return Vector3f(int((globalCoord.x()+1.)*width/2.+.5), int((globalCoord.y()+1.)*height/2.+.5), globalCoord.z()); } void Triangle::setScreenCoord(int ind,int width,int height) { screenCoords[ind] = world2screen(this-\u0026gt;globalCoords[ind],width,height); } 模型类\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #ifndef __MODEL_H__ #define __MODEL_H__ #include \u0026lt;vector\u0026gt; #include \u0026lt;Eigen/Eigen\u0026gt; #include \u0026#34;Triangle.h\u0026#34; class Model { private: public: explicit Model(const char *filename); ~Model(); std::vector\u0026lt;Triangle\u0026gt; triangleList; }; #endif //__MODEL_H__ 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 #include \u0026#34;model.h\u0026#34; #include \u0026#34;thirdParty/OBJ_Loader.h\u0026#34; // 加载模型就直接用教程的代码了！ Model::Model(const char *filename) { objl::Loader Loader; Loader.LoadFile(filename); std::cout \u0026lt;\u0026lt; \u0026#34;?\u0026#34;; for (const auto \u0026amp;mesh: Loader.LoadedMeshes){ for(int i=0;i\u0026lt;mesh.Vertices.size();i+=3) { Triangle * t = new Triangle; for(int j=0;j\u0026lt;3;j++) { t-\u0026gt;setGlobalCoords(j, Vector4f(mesh.Vertices[i + j].Position.X, mesh.Vertices[i + j].Position.Y,mesh.Vertices[i + j].Position.Z, 1.0)); t-\u0026gt;setNormal(j,Vector3f(mesh.Vertices[i+j].Normal.X,mesh.Vertices[i+j].Normal.Y,mesh.Vertices[i+j].Normal.Z)); t-\u0026gt;setTexCoord(j,Vector2f(mesh.Vertices[i+j].TextureCoordinate.X, mesh.Vertices[i+j].TextureCoordinate.Y)); } this-\u0026gt;triangleList.push_back(*t); } } } Model::~Model() { } 已经告诉了使用zbuffer，其实就是把处于后边的物体就不需要渲染了，在背面剔除后，删掉了不需要处理的三角形，但是在像素层面，位于前面的颜色应该把后边的颜色挡住 首先来定义一个zbuffer，我没有使用教程中的一位数组表示，因为我不希望代码理解起来过于复杂\n1 2 3 // 定义一个zbuffer，并设置为无穷小 std::unique_ptr\u0026lt;std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt;\u0026gt; zBuffer = std::make_unique\u0026lt;std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt;\u0026gt;(width, std::vector\u0026lt;float\u0026gt;(height)); auto * zBuffer = new std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt;(width, std::vector\u0026lt;float\u0026gt;(std::numeric_limits\u0026lt;float\u0026gt;::lowest())); 在这种情况下，绘制像素前要先判断当前想要绘制的颜色是否被挡住，至于是大于号还是小于号，看如何定义，当前模型是z越大离屏幕越近\n1 2 3 4 5 6 7 8 9 10 double alpha = signed_triangle_area(x, y, bx, by, cx, cy) / total_area; double beta = signed_triangle_area(x, y, cx, cy, ax, ay) / total_area; double gamma = signed_triangle_area(x, y, ax, ay, bx, by) / total_area; if (alpha\u0026lt;0 || beta\u0026lt;0 || gamma\u0026lt;0) continue; // 说明当前像素不在三角形内部 float barycentricZ = alpha*triangle.screenCoords[0].z() + beta*triangle.screenCoords[1].z() + gamma*triangle.screenCoords[2].z(); // zbuffer中缓存的渲染物体距离小于当前渲染物体的距离时，才覆盖渲染 if (x\u0026lt;width \u0026amp;\u0026amp; y \u0026lt; height \u0026amp;\u0026amp; zBuffer-\u0026gt;at(x).at(y) \u0026lt; barycentricZ){ zBuffer-\u0026gt;at(x).at(y) = barycentricZ; framebuffer.set(x,y,color); } 当前的整体代码如下\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 #include \u0026#34;thirdParty/tgaimage.h\u0026#34; #include \u0026#34;model.h\u0026#34; #include \u0026lt;vector\u0026gt; #include \u0026lt;cmath\u0026gt; #include \u0026lt;iostream\u0026gt; constexpr TGAColor white = {255, 255, 255, 255}; constexpr TGAColor green = { 0, 255, 0, 255}; constexpr TGAColor red = { 255, 0, 0, 255}; constexpr TGAColor blue = {255, 128, 64, 255}; constexpr TGAColor yellow = { 0, 200, 255, 255}; constexpr static int width = 2560; constexpr static int height = 1920; // 画线尝试1 void drawLine_first(int ax, int ay, int bx, int by, TGAImage \u0026amp;img, TGAColor color){ for(float t = 0;t\u0026lt;=1;t+=0.02){ int x = std::round(ax + t * (bx - ax)); // round会进行四舍五入 int y = std::round(ay + t * (by - ay)); img.set(x,y,color); } } // 画线尝试2 void drawLine_second(int ax, int ay, int bx, int by, TGAImage \u0026amp;img, TGAColor color){ if (ax\u0026gt;bx) { // make it left−to−right std::swap(ax, bx); std::swap(ay, by); } for (int x = ax ; x\u0026lt;= bx; x++) { // 不再以t控制，而是以x的进行进行控制，保证了水平方向上不会有空隙 // 如果不加强制转换，当分子分母都是整数时，计算结果的小数部分会被截断 float t = (x-ax)/static_cast\u0026lt;float\u0026gt;(bx-ax); // 变换了形式，表示出当x移动一格时，t是多少， int y = std::round( ay + (by-ay)*t ); img.set(x, y, color); } } // 画线尝试3 void drawLine_third(int ax, int ay, int bx, int by, TGAImage \u0026amp;img, TGAColor color){ bool steep = std::abs(ax-bx) \u0026lt; std::abs(ay-by); if (steep) { // if the drawLine is steep, we transpose the image std::swap(ax, ay); std::swap(bx, by); } if (ax\u0026gt;bx) { // make it left−to−right std::swap(ax, bx); std::swap(ay, by); } for (int x = ax ; x\u0026lt;= bx; x++) { // 不再以t控制，而是以x的进行进行控制，保证了水平方向上不会有空隙 // 如果不加强制转换，当分子分母都是整数时，计算结果的小数部分会被截断 float t = (x-ax)/static_cast\u0026lt;float\u0026gt;(bx-ax); // 变换了形式，表示出当x移动一格时，t是多少， int y = std::round( ay + (by-ay)*t ); if (steep) // if transposed, de−transpose img.set(y, x, color); else img.set(x, y, color); } } // 最终版本 对计算进行了优化 void drawLine(int ax, int ay, int bx, int by, TGAImage \u0026amp;framebuffer, TGAColor color) { bool steep = std::abs(ax-bx) \u0026lt; std::abs(ay-by); if (steep) { // if the drawLine is steep, we transpose the image std::swap(ax, ay); std::swap(bx, by); } if (ax\u0026gt;bx) { // make it left−to−right std::swap(ax, bx); std::swap(ay, by); } int y = ay; int ierror = 0; for (int x=ax; x\u0026lt;=bx; x++) { if (steep) // if transposed, de−transpose framebuffer.set(y, x, color); else framebuffer.set(x, y, color); ierror += 2 * std::abs(by-ay); y += (by \u0026gt; ay ? 1 : -1) * (ierror \u0026gt; bx - ax); ierror -= 2 * (bx-ax) * (ierror \u0026gt; bx - ax); } } // 三角形面积，可能返回负数，表示背对屏幕 double signed_triangle_area(int ax, int ay, int bx, int by, int cx, int cy) { return .5*((by-ay)*(bx+ax) + (cy-by)*(cx+bx) + (ay-cy)*(ax+cx)); } // 绘制一个三角形 void drawTriangle(Triangle triangle, TGAImage \u0026amp;framebuffer, std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt; * zBuffer,TGAColor color) { float ax = triangle.screenCoords[0].x(); float ay = triangle.screenCoords[0].y(); float bx = triangle.screenCoords[1].x(); float by = triangle.screenCoords[1].y(); float cx = triangle.screenCoords[2].x(); float cy = triangle.screenCoords[2].y(); float bbminx = std::min(std::min(ax, bx), cx); float bbminy = std::min(std::min(ay, by), cy); float bbmaxx = std::max(std::max(ax, bx), cx); float bbmaxy = std::max(std::max(ay, by), cy); // 如果面积为负数，背对屏幕，被裁剪 double total_area = signed_triangle_area(ax, ay, bx, by, cx, cy); if (total_area\u0026lt;1) return; #pragma omp parallel for for (int x=bbminx; x\u0026lt;=bbmaxx; x++) { for (int y=bbminy; y\u0026lt;=bbmaxy; y++) { double alpha = signed_triangle_area(x, y, bx, by, cx, cy) / total_area; double beta = signed_triangle_area(x, y, cx, cy, ax, ay) / total_area; double gamma = signed_triangle_area(x, y, ax, ay, bx, by) / total_area; if (alpha\u0026lt;0 || beta\u0026lt;0 || gamma\u0026lt;0) continue; // 说明当前像素不在三角形内部 float barycentricZ = alpha*triangle.screenCoords[0].z() + beta*triangle.screenCoords[1].z() + gamma*triangle.screenCoords[2].z(); // zbuffer中缓存的渲染物体距离小于当前渲染物体的距离时，才覆盖渲染 if (x\u0026lt;width \u0026amp;\u0026amp; y \u0026lt; height \u0026amp;\u0026amp; zBuffer-\u0026gt;at(x).at(y) \u0026lt; barycentricZ){ zBuffer-\u0026gt;at(x).at(y) = barycentricZ; framebuffer.set(x,y,color); } } } } int main() { auto * model = new Model(\u0026#34;./obj/african_head/african_head.obj\u0026#34;); TGAImage framebuffer(width, height, TGAImage::RGB); // 定义一个zBuffer,并设置全部数据为最小负数 auto * zBuffer = new std::vector\u0026lt;std::vector\u0026lt;float\u0026gt;\u0026gt;(width, std::vector\u0026lt;float\u0026gt;(height,std::numeric_limits\u0026lt;float\u0026gt;::lowest())); // 遍历obj文件中的每个三角形 for (Triangle triangle : model-\u0026gt;triangleList) { // 将当前三角形的三个顶点都投影到屏幕 for (int i = 0; i \u0026lt; 3; ++i) triangle.setScreenCoord(i,width,height); // 绘制三角形 drawTriangle(triangle, framebuffer, zBuffer, TGAColor(rand()%255, rand()%255, rand()%255, 255)); } // framebuffer.flip_vertically(); framebuffer.write_tga_file(\u0026#34;framebuffer.tga\u0026#34;); return 0; } 下一步就是把材质贴上去，也就是设置颜色时不再使用随机颜色，而是根据三个顶点的纹理坐标进行插值，获得一个像素点的纹理坐标，从纹理图片对应位置获取颜色来设置 首先得有一个承载材质的类，我这里使用的是TGAImage来读取图片,并把材质类作为Model的成员\n1 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 #ifndef TINYRENDERER_TEXTURE_H #define TINYRENDERER_TEXTURE_H #include \u0026lt;Eigen/Eigen\u0026gt; #include \u0026#34;thirdParty/tgaimage.h\u0026#34; class Texture{ private: TGAImage texture; public: Texture(const std::string\u0026amp; name) { texture.read_tga_file(\u0026#34;\u0026#34;); width = texture.width(); height = texture.height(); } int width, height; TGAColor getColor(float u, float v) { auto u_img = u * width; auto v_img = (1 - v) * height; TGAColor color = texture.get(v_img, u_img); return color; } }; #endif //TINYRENDERER_TEXTURE_H 在main中首先对uv坐标进行插值，之后在设置像素颜色时，通过插值的uv坐标，到uv图中找对应位置的颜色\n1 2 3 4 5 6 7 float texU = alpha*triangle.texCoords[0].x() + beta*triangle.texCoords[1].x() + gamma*triangle.texCoords[2].x(); float texV = alpha*triangle.texCoords[0].y() + beta*triangle.texCoords[1].y() + gamma*triangle.texCoords[2].y(); // zbuffer中缓存的渲染物体距离小于当前渲染物体的距离时，才覆盖渲染 if (x\u0026lt;width \u0026amp;\u0026amp; y \u0026lt; height \u0026amp;\u0026amp; zBuffer-\u0026gt;at(x).at(y) \u0026lt; barycentricZ){ zBuffer-\u0026gt;at(x).at(y) = barycentricZ; framebuffer.set(x,y,texture.getColor(texU,texV)); } 生成效果如下图所示 到目前为止我自己的实现可以在github的分支结点中找到：https://github.com/sdpyy1/CppLearn/tree/56841b79fe7c74bce1d9210f1a42e2a3ca019768/tinyrenderer\nLesson 4: Perspective projection 这里我不希望只完成他课程的简单情况，我直接把MVP矩阵+视口变换全部封装了，详情可查看我的仓库，下边是主要代码\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 #include \u0026#34;model.h\u0026#34; #include \u0026#34;thirdParty/OBJ_Loader.h\u0026#34; Model::Model(const char * objFileName,const char * texFileName) : texture(texFileName){ objl::Loader Loader; Loader.LoadFile(objFileName); this-\u0026gt;modelMatrix = Eigen::Matrix4f::Identity(); this-\u0026gt;viewMatrix = Eigen::Matrix4f::Identity(); this-\u0026gt;projectionMatrix = Eigen::Matrix4f::Identity(); for (const auto \u0026amp;mesh: Loader.LoadedMeshes){ for(int i=0;i\u0026lt;mesh.Vertices.size();i+=3) { Triangle t; for(int j=0;j\u0026lt;3;j++) { // 此处设置每个三角形的属性 t.setGlobalCoord(j, Vector4f(mesh.Vertices[i + j].Position.X, mesh.Vertices[i + j].Position.Y, mesh.Vertices[i + j].Position.Z, 1.0)); t.setNormal(j,Vector3f(mesh.Vertices[i+j].Normal.X,mesh.Vertices[i+j].Normal.Y,mesh.Vertices[i+j].Normal.Z)); t.setTexCoord(j,Vector2f(mesh.Vertices[i+j].TextureCoordinate.X, mesh.Vertices[i+j].TextureCoordinate.Y)); Matrix4f mvp = projectionMatrix * viewMatrix * modelMatrix; } this-\u0026gt;triangleList.push_back(t); } } } // 将角度转换为弧度 constexpr float deg2rad(float degrees) { return degrees * M_PI / 180.0f; } // 生成绕 x, y, z 轴旋转的变换矩阵 Eigen::Matrix4f rotation(float angleX, float angleY, float angleZ) { // 分别计算绕 x, y, z 轴旋转的矩阵 Eigen::Matrix4f rotationX = Eigen::Matrix4f::Identity(); float radX = deg2rad(angleX); rotationX(1, 1) = std::cos(radX); rotationX(1, 2) = -std::sin(radX); rotationX(2, 1) = std::sin(radX); rotationX(2, 2) = std::cos(radX); Eigen::Matrix4f rotationY = Eigen::Matrix4f::Identity(); float radY = deg2rad(angleY); rotationY(0, 0) = std::cos(radY); rotationY(0, 2) = std::sin(radY); rotationY(2, 0) = -std::sin(radY); rotationY(2, 2) = std::cos(radY); Eigen::Matrix4f rotationZ = Eigen::Matrix4f::Identity(); float radZ = deg2rad(angleZ); rotationZ(0, 0) = std::cos(radZ); rotationZ(0, 1) = -std::sin(radZ); rotationZ(1, 0) = std::sin(radZ); rotationZ(1, 1) = std::cos(radZ); // 组合三个旋转矩阵，这里假设旋转顺序为 Z -\u0026gt; Y -\u0026gt; X Eigen::Matrix4f modelMatrix = rotationX * rotationY * rotationZ; return modelMatrix; } // 生成平移变换矩阵 Eigen::Matrix4f translation(float tx, float ty, float tz) { Eigen::Matrix4f translationMatrix = Eigen::Matrix4f::Identity(); translationMatrix(0, 3) = tx; translationMatrix(1, 3) = ty; translationMatrix(2, 3) = tz; return translationMatrix; } // 生成缩放变换矩阵 Eigen::Matrix4f scaling(float sx, float sy, float sz) { Eigen::Matrix4f scalingMatrix = Eigen::Matrix4f::Identity(); scalingMatrix(0, 0) = sx; scalingMatrix(1, 1) = sy; scalingMatrix(2, 2) = sz; return scalingMatrix; } // 视图变换矩阵 Eigen::Matrix4f get_view_matrix(Eigen::Vector3f eye_pos, Eigen::Vector3f target, Eigen::Vector3f up) { // TODO:还没理解怎么换的 // 观察方向 Vector3f z = (eye_pos - target).normalized(); // 叉乘得右方向 Vector3f r = z.cross(up).normalized(); // 叉乘得上方向 Vector3f u = z.cross(r).normalized(); Eigen::Matrix4f translate; translate \u0026lt;\u0026lt; r.x(),r.y(),r.z(),-r.dot(eye_pos), u.x(),u.y(),u.z(),-u.dot(eye_pos), -z.x(),-z.y(),-z.z(),z.dot(eye_pos), 0,0,0,1; // 效果是将摄像机作为原点情况下各个点的坐标 return translate; } Eigen::Matrix4f get_projection_matrix(float eye_fov, float aspect_ratio, float n, float f) { Eigen::Matrix4f projection = Eigen::Matrix4f::Identity(); float t = -tan((eye_fov/360)*M_PI)*(abs(n)); //top float r = t/aspect_ratio; Eigen::Matrix4f Mp;//透视矩阵 Mp \u0026lt;\u0026lt; n, 0, 0, 0, 0, n, 0, 0, 0, 0, n+f, -n*f, 0, 0, 1, 0; Eigen::Matrix4f Mo_tran;//平移矩阵 Mo_tran \u0026lt;\u0026lt; 1, 0, 0, 0, 0, 1, 0, 0, //b=-t; 0, 0, 1, -(n+f)/2 , 0, 0, 0, 1; Eigen::Matrix4f Mo_scale;//缩放矩阵 Mo_scale \u0026lt;\u0026lt; 1/r, 0, 0, 0, 0, 1/t, 0, 0, 0, 0, 2/(n-f), 0, 0, 0, 0, 1; projection = (Mo_scale*Mo_tran)* Mp;//投影矩阵 //这里一定要注意顺序，先透视再正交;正交里面先平移再缩放；否则做出来会是一条直线！ return projection; } void Model::setModelTransformation(float angleX, float angleY, float angleZ, float tx, float ty, float tz, float sx, float sy, float sz){ if (triangleList.empty()){ std::cout \u0026lt;\u0026lt; \u0026#34;模型未导入！\u0026#34;\u0026lt;\u0026lt;std::endl; return; } Eigen::Matrix4f rotationMatrix = rotation(angleX, angleY, angleZ); Eigen::Matrix4f translationMatrix = translation(tx, ty, tz); Eigen::Matrix4f scalingMatrix = scaling(sx, sy, sz); // 按缩放 -\u0026gt; 旋转 -\u0026gt; 平移的顺序组合变换矩阵 modelMatrix = translationMatrix * rotationMatrix * scalingMatrix; } // 应用视图变换的函数 void Model::setViewTransformation(Eigen::Vector3f eye_pos, Eigen::Vector3f target, Eigen::Vector3f up) { viewMatrix = get_view_matrix(eye_pos,target,up); } // 应用透视变换的函数 void Model::setProjectionTransformation(float fovY, float aspectRatio, float near, float far) { projectionMatrix = get_projection_matrix(fovY, aspectRatio, near, far); } Matrix4f Model::getMVP(){ return projectionMatrix * viewMatrix * modelMatrix; } Model::~Model() { } ","date":"2025-10-19T13:37:45+08:00","image":"https://sdpyy1.github.io/015e0f415d1c493db111695862b55bfe.png","permalink":"https://sdpyy1.github.io/p/tinyrender%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95-1-4/","title":"TinyRender开发记录 1-4"},{"content":"作业介绍 本次作业只需要实现渲染方程，首先需要理解渲染公式 其次需要知道利用蒙特卡洛公式求积分的方法 求渲染公式的积分部分需要在半球面采样，为了提高准确度，换元为在光源面积上采样 在作业中也给出了伪代码 代码实现 代码参考https://zhuanlan.zhihu.com/p/606074595\n注释写的很清楚了\n1 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 57 58 59 60 61 62 63 64 65 66 Vector3f Scene::castRay(const Ray\u0026amp; ray, int depth) const { Vector3f hitColor = this-\u0026gt;backgroundColor; // 获取光线ray与场景中物体的交点 Intersection shade_point_inter = Scene::intersect(ray); // 如果有交点 if (shade_point_inter.happened){ // 交点坐标 Vector3f p = shade_point_inter.coords; // 从摄像机到点P的方向 Vector3f wo = ray.direction; // p点的法线 Vector3f N = shade_point_inter.normal; Vector3f L_dir(0), L_indir(0); //光照的采样 获取位置和概率密度函数 Intersection light_point_inter; float pdf_light; sampleLight(light_point_inter, pdf_light); //Get x,ws,NN,emit from inter // 光照采样点的坐标 Vector3f x = light_point_inter.coords; // 从点P到光照采样点的方向 Vector3f ws = normalize(x-p); // 光照采样点的法线 Vector3f NN = light_point_inter.normal; // ？ Vector3f emit = light_point_inter.emit; // 点p到光照采样点的距离 float distance_pTox = (x - p).norm(); //创建从p-\u0026gt;采样点的光照 Vector3f p_deviation = (dotProduct(ray.direction, N) \u0026lt; 0) ? p + N * EPSILON : p - N * EPSILON ; Ray ray_pTox(p_deviation, ws); //判断是否中间有别的物体挡住了 Intersection blocked_point_inter = Scene::intersect(ray_pTox); // 如果没有被挡住就算一次L if (abs(distance_pTox - blocked_point_inter.distance \u0026lt; 0.01 )) { L_dir = emit * shade_point_inter.m-\u0026gt;eval(wo, ws, N) * dotProduct(ws, N) * dotProduct(-ws, NN) / (distance_pTox * distance_pTox * pdf_light); } // 俄罗斯轮盘赌来停止递归 float ksi = get_random_float(); if (ksi \u0026lt; RussianRoulette) { // 采样获得wi方向来进一步递归 Vector3f wi = normalize(shade_point_inter.m-\u0026gt;sample(wo, N)); // 创建对应的光线 Ray ray_pTowi(p_deviation, wi); //光线碰到了不发光的物体，需要递归处理 Intersection bounce_point_inter = Scene::intersect(ray_pTowi); // 如果光线碰到了物体就需要递归处理了 if (bounce_point_inter.happened \u0026amp;\u0026amp; !bounce_point_inter.m-\u0026gt;hasEmission()) { float pdf = shade_point_inter.m-\u0026gt;pdf(wo, wi, N); if(pdf\u0026gt; EPSILON) L_indir = castRay(ray_pTowi, depth + 1) * shade_point_inter.m-\u0026gt;eval(wo, wi, N) * dotProduct(wi, N) / (pdf *RussianRoulette); } } // 结合直接光照和间接光照获得该点的颜色值 hitColor = shade_point_inter.m-\u0026gt;getEmission() + L_dir + L_indir; } return hitColor; } ","date":"2025-10-19T13:32:46+08:00","image":"https://sdpyy1.github.io/4360789f62274e29b3aabdfd7a0a1f6a.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-assignment7/","title":"GAMES101 现代计算机图形学入门 Assignment7"},{"content":"作业介绍 在上次作业中，每一次计算光线交点时，都需要与场景中所有的物体进行求交运行，这显然是不合理的，这次作业用BVH划分后进行加速 预处理 需要根据上次作业的内容以及这次作业对代码结构的调整，写入eye ray计算方法和三角形与光线求交，下面给出具体代码 void Renderer::Render(const Scene\u0026amp; scene)中，光线被定义成了一个类，填入参数\n1 2 3 4 5 6 7 8 9 10 for (uint32_t i = 0; i \u0026lt; scene.width; ++i) { // generate primary ray direction float x = (2 * (i + 0.5) / (float)scene.width - 1) * imageAspectRatio * scale; float y = (1 - 2 * (j + 0.5) / (float)scene.height) * scale; Vector3f dir = Vector3f(x, y, -1); // Don\u0026#39;t forget to normalize this direction! dir = normalize(dir); Ray ray(eye_pos, dir); framebuffer[m++] = scene.castRay(ray, 0); } inline Intersection Triangle::getIntersection(Ray ray)中，交点定义为了一个类，填入参数即可\n1 2 3 4 5 6 7 // TODO find ray triangle intersection inter.normal = normal; inter.coords = ray(t_tmp); inter.distance = t_tmp; inter.happened = true; inter.m = m; inter.obj = this; Bounds3::IntersectP 这里就需要完成光线与包围盒是否相交的操作，我这里代码并没用后两个参数，AABB的好处就是平面法向量很简单，一个点和一个法向量确定一个平面，再联立光线参数方程，就可以解出来t（这个方法比较垃圾，下面有更好的方法）\n1 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 inline bool Bounds3::IntersectP(const Ray\u0026amp; ray, const Vector3f\u0026amp; invDir, const std::array\u0026lt;int, 3\u0026gt;\u0026amp; dirIsNeg) const { // invDir: ray direction(x,y,z), invDir=(1.0/x,1.0/y,1.0/z), use this because Multiply is faster that Division // dirIsNeg: ray direction(x,y,z), dirIsNeg=[int(x\u0026gt;0),int(y\u0026gt;0),int(z\u0026gt;0)], use this to simplify your logic // TODO test if ray bound intersects // xyz方向的法向量 Vector3f nX(1,0,0); Vector3f nY(0,1,0); Vector3f nZ(0,0,1); // x方向两个t float tx1 = dotProduct(pMin-ray.origin,nX)/ dotProduct(ray.direction,nX); float tx2 = dotProduct(pMax-ray.origin,nX)/ dotProduct(ray.direction,nX); // y方向两个t float ty1 = dotProduct(pMin-ray.origin,nY)/ dotProduct(ray.direction,nY); float ty2 = dotProduct(pMax-ray.origin,nY)/ dotProduct(ray.direction,nY); // z方向两个t float tz1 = dotProduct(pMin-ray.origin,nZ)/ dotProduct(ray.direction,nZ); float tz2 = dotProduct(pMax-ray.origin,nZ)/ dotProduct(ray.direction,nZ); // 保证1\u0026lt;2 if (tx1 \u0026gt; tx2){ float temp = tx1; tx1 = tx2; tx2 = temp; } if (ty1 \u0026gt; ty2){ float temp = ty1; ty1 = ty2; ty2 = temp; } if (tz1 \u0026gt; tz2){ float temp = tz1; tz1 = tz2; tz2 = temp; } float maxEnter = std::max(tx1,std::max(ty1,tz1)); float minExit = std::min(tx2,std::min(ty2,tz2)); return minExit \u0026gt; 0 \u0026amp;\u0026amp; maxEnter \u0026lt;= minExit; } 看了别人的解答，我这个确实比较垃，直接根据x轴上光线起点与对面的距离再除以x方向上的光的速度，就可以得到t了（速度就是光线的方向向量的x分量）\nhttps://zhuanlan.zhihu.com/p/545253020\n1 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 inline bool Bounds3::IntersectP(const Ray\u0026amp; ray, const Vector3f\u0026amp; invDir, const std::array\u0026lt;int, 3\u0026gt;\u0026amp; dirIsNeg) const { // invDir: ray direction(x,y,z), invDir=(1.0/x,1.0/y,1.0/z), use this because Multiply is faster that Division // 计算进入x,y,z截面的最早和最晚时间 float t_min_x = (pMin.x - ray.origin.x) * invDir[0]; float t_min_y = (pMin.y - ray.origin.y) * invDir[1]; float t_min_z = (pMin.z - ray.origin.z) * invDir[2]; float t_max_x = (pMax.x - ray.origin.x) * invDir[0]; float t_max_y = (pMax.y - ray.origin.y) * invDir[1]; float t_max_z = (pMax.z - ray.origin.z) * invDir[2]; // dirIsNeg: ray direction(x,y,z), dirIsNeg=[int(x\u0026gt;0),int(y\u0026gt;0),int(z\u0026gt;0)], use this to simplify your logic // 如果方向是负的，就交换最早和最晚时间 if (!dirIsNeg[0]) { float t = t_min_x; t_min_x = t_max_x; t_max_x = t; } if (!dirIsNeg[1]) { float t = t_min_y; t_min_y = t_max_y; t_max_y = t; } if (!dirIsNeg[2]) { float t = t_min_z; t_min_z = t_max_z; t_max_z = t; } // 小小取其大，大大取其小 float t_enter = std::max(t_min_x, std::max(t_min_y, t_min_z)); float t_exit = std::min(t_max_x, std::min(t_max_y, t_max_z)); // TODO test if ray bound intersects // 检测包围盒是否存在 if (t_exit\u0026gt;=0\u0026amp;\u0026amp;t_enter\u0026lt;t_exit) { return true; } else { return false; } } BVHAccel::getIntersection BVH树构建好后，通过树上的遍历，找到离光源最近的物体的交点，代码其实就是二叉树的遍历\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 Intersection BVHAccel::getIntersection(BVHBuildNode* node, const Ray\u0026amp; ray) const { // TODO Traverse the BVH to find intersection std::array\u0026lt;int,3\u0026gt; dirIsNeg; dirIsNeg[0] = (ray.direction[0]\u0026gt;0); dirIsNeg[1] = (ray.direction[1]\u0026gt;0); dirIsNeg[2] = (ray.direction[2]\u0026gt;0); Intersection inter; if (!node-\u0026gt;bounds.IntersectP(ray, ray.direction_inv, dirIsNeg)){ return inter; } if(node-\u0026gt;left == nullptr \u0026amp;\u0026amp; node-\u0026gt;right == nullptr){ return node-\u0026gt;object-\u0026gt;getIntersection(ray); } Intersection leftInter = getIntersection(node-\u0026gt;left, ray); Intersection rightInter = getIntersection(node-\u0026gt;right, ray); // 返回离光源最近的 return leftInter.distance \u0026lt; rightInter.distance ? leftInter : rightInter; } 最终运行代码得到如图结果 其他代码 BVH树的构造 BVH在分叉中点选取时，参考的是物体包围盒质心（Centroid）在质心包围盒（Centroid Bounds）最大延伸轴上的排序位置\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 // 构建BVH树 BVHBuildNode* BVHAccel::recursiveBuild(std::vector\u0026lt;Object*\u0026gt; objects) { BVHBuildNode* node = new BVHBuildNode(); // Compute bounds of all primitives in BVH node Bounds3 bounds; // 获取当前层所有物体的包围盒 for (int i = 0; i \u0026lt; objects.size(); ++i) bounds = Union(bounds, objects[i]-\u0026gt;getBounds()); // 只有一个物体了，就设置叶子节点 if (objects.size() == 1) { // Create leaf _BVHBuildNode_ node-\u0026gt;bounds = objects[0]-\u0026gt;getBounds(); node-\u0026gt;object = objects[0]; node-\u0026gt;left = nullptr; node-\u0026gt;right = nullptr; return node; } // 有两个物体，就设置左右两个叶子节点 else if (objects.size() == 2) { node-\u0026gt;left = recursiveBuild(std::vector{objects[0]}); node-\u0026gt;right = recursiveBuild(std::vector{objects[1]}); node-\u0026gt;bounds = Union(node-\u0026gt;left-\u0026gt;bounds, node-\u0026gt;right-\u0026gt;bounds); return node; } // 正常情况就找中间的物体，然后分成左右两部分，再进行递归 else { Bounds3 centroidBounds; for (int i = 0; i \u0026lt; objects.size(); ++i) centroidBounds = Union(centroidBounds, objects[i]-\u0026gt;getBounds().Centroid()); int dim = centroidBounds.maxExtent(); switch (dim) { case 0: std::sort(objects.begin(), objects.end(), [](auto f1, auto f2) { return f1-\u0026gt;getBounds().Centroid().x \u0026lt; f2-\u0026gt;getBounds().Centroid().x; }); break; case 1: std::sort(objects.begin(), objects.end(), [](auto f1, auto f2) { return f1-\u0026gt;getBounds().Centroid().y \u0026lt; f2-\u0026gt;getBounds().Centroid().y; }); break; case 2: std::sort(objects.begin(), objects.end(), [](auto f1, auto f2) { return f1-\u0026gt;getBounds().Centroid().z \u0026lt; f2-\u0026gt;getBounds().Centroid().z; }); break; } auto beginning = objects.begin(); auto middling = objects.begin() + (objects.size() / 2); auto ending = objects.end(); auto leftshapes = std::vector\u0026lt;Object*\u0026gt;(beginning, middling); auto rightshapes = std::vector\u0026lt;Object*\u0026gt;(middling, ending); assert(objects.size() == (leftshapes.size() + rightshapes.size())); node-\u0026gt;left = recursiveBuild(leftshapes); node-\u0026gt;right = recursiveBuild(rightshapes); node-\u0026gt;bounds = Union(node-\u0026gt;left-\u0026gt;bounds, node-\u0026gt;right-\u0026gt;bounds); } return node; } ","date":"2025-10-19T13:24:41+08:00","image":"https://sdpyy1.github.io/7685e3c05ceb40d686c8997f9b6dab10.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-assignment6/","title":"GAMES101 现代计算机图形学入门 Assignment6"},{"content":"作业介绍 eye ray（计算像素坐标实际对应的空间坐标） 这里首先需要回顾一下光栅化过程中的各种space转换\nModel Space 模型自身的局部坐标系，类如.obj文件中定义的顶点位置，并不会随着在空间中移动而修改 World Space 全局坐标系，所有物体在此空间中进行定位和交互 View Space 以摄像机为原点的坐标系，看向-Z方向 Clip Space 经投影矩阵（Projection Matrix）变换后的标准化坐标空间（范围[-1,1]） Screen Space 经过视口转换映射到屏幕上 Raster Space 用于表示最终渲染图像中每个像素的位置‌ 遍历像素是在Raster Space中进行的，第一步要计算摄像机看向每个像素时，实际上看向的空间坐标。（在这里意识到之前学习的一个误区，经过视口变化后，xy坐标范围从[-1,1]变化为[0,width/heigth]，而z坐标并不是变成从0开始，然后向-z变化，而是主要看最大深度和最小深度是如何设置的）\nx和y都加0.5移动到像素中心 $x+0.5/width，y+0.5/height$ 缩小到[0,1] 下一步 $2 * ( i+0.5 )/width - 1$,扩大2倍左移一个单位，这样就移动到了[-1,1] （由于屏幕原点在左上角，y越往下越大，但是在NDC空间，y越往下越小，所以要反转y坐标） 因为原来屏幕不一定是正方形，需要给x*宽高比（此时已经在NDC坐标下） 最后将NDC坐标转换为摄像机视角下的坐标（给了fov，就是给了znear的位置），给x、y乘以tan(deg2rad(scene.fov * 0.5f)），这样就得到了当眼睛看向一个像素时，它实际上在znear上的位置 确实没有必要继续逆转换了，因为znear上的坐标在透视投影过程中并不会变化 给出这部分的完整代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 for (int j = 0; j \u0026lt; scene.height; ++j) { for (int i = 0; i \u0026lt; scene.width; ++i) { // generate primary ray direction float x; float y; // TODO: Find the x and y positions of the current pixel to get the direction // vector that passes through it. // Also, don\u0026#39;t forget to multiply both of them with the variable *scale*, and // x (horizontal) variable with the *imageAspectRatio* // 映射到[-1,1] x = 2 * (i + 0.5) / scene.width - 1; y = 1 - 2 * (j + 0.5) / scene.height; // 找到对应znear的坐标 x = x * scale * imageAspectRatio; y = y * scale; Vector3f dir = Vector3f(x, y, -1); // Don\u0026#39;t forget to normalize this direction! normalize(dir); framebuffer[m++] = castRay(eye_pos, dir, scene, 0); } UpdateProgress(j / (float)scene.height); } rayTriangleIntersect 三角形与光线求交 这里比较容易，因为有现成的公式，如果有交点，那这个交点可以用重心坐标表示，所以可以得出图中的等式，直接代码实现即可，最后记得判断结果的合理性（t要大于0，重心坐标三个分量都要大于0，等式已经限定了三者相加为1） 完整代码\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 bool rayTriangleIntersect(const Vector3f\u0026amp; v0, const Vector3f\u0026amp; v1, const Vector3f\u0026amp; v2, const Vector3f\u0026amp; orig, const Vector3f\u0026amp; dir, float\u0026amp; tnear, float\u0026amp; u, float\u0026amp; v) { Vector3f e1 = v1-v0; Vector3f e2 = v2-v0; Vector3f s = orig - v0; Vector3f s1 = crossProduct(dir,e2); Vector3f s2 = crossProduct(s,e1); Vector3f tmp(dotProduct(s2,e2), dotProduct(s1,s), dotProduct(s2,dir)); Vector3f ans = tmp/ dotProduct(s1,e1); tnear = ans.x; u = ans.y; v = ans.z; if (tnear \u0026gt; 0 \u0026amp;\u0026amp; u\u0026gt;=0 \u0026amp;\u0026amp; v\u0026gt;=0 \u0026amp;\u0026amp; 1-u-v\u0026gt;0){ return true; } return false; } 上边两步做完后，作业就结束了，输出的图如下 其他代码 Object及其派生 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 class Object { public: Object() : materialType(DIFFUSE_AND_GLOSSY) , ior(1.3) , Kd(0.8) , Ks(0.2) , diffuseColor(0.2) , specularExponent(25) {} virtual ~Object() = default; // 光线与Object是否有交点 virtual bool intersect(const Vector3f\u0026amp;, const Vector3f\u0026amp;, float\u0026amp;, uint32_t\u0026amp;, Vector2f\u0026amp;) const = 0; // 负责计算交点处的表面属性 virtual void getSurfaceProperties(const Vector3f\u0026amp;, const Vector3f\u0026amp;, const uint32_t\u0026amp;, const Vector2f\u0026amp;, Vector3f\u0026amp;, Vector2f\u0026amp;) const = 0; // 返回Object的漫反射颜色 virtual Vector3f evalDiffuseColor(const Vector2f\u0026amp;) const { return diffuseColor; } // material properties MaterialType materialType; // 折射率 float ior; // 漫反射和高光反射系数 float Kd, Ks; // 漫反射颜色 Vector3f diffuseColor; // 高光指数（Phong模型参数），控制高光区域的大小和锐利程度（值越大，高光越集中）‌ float specularExponent; }; Object有两个实现类Sphere和MeshTriangle 首先看看 Sphere\n1 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 class Sphere : public Object { public: Sphere(const Vector3f\u0026amp; c, const float\u0026amp; r) : center(c) , radius(r) , radius2(r * r) {} // 实现求交 就是光线的方程与球的隐式方程联立求解 bool intersect(const Vector3f\u0026amp; orig, const Vector3f\u0026amp; dir, float\u0026amp; tnear, uint32_t\u0026amp;, Vector2f\u0026amp;) const override { // analytic solution Vector3f L = orig - center; float a = dotProduct(dir, dir); float b = 2 * dotProduct(dir, L); float c = dotProduct(L, L) - radius2; float t0, t1; if (!solveQuadratic(a, b, c, t0, t1)) return false; if (t0 \u0026lt; 0) t0 = t1; if (t0 \u0026lt; 0) return false; tnear = t0; return true; } // 返回的是法线 void getSurfaceProperties(const Vector3f\u0026amp; P, const Vector3f\u0026amp;, const uint32_t\u0026amp;, const Vector2f\u0026amp;, Vector3f\u0026amp; N, Vector2f\u0026amp;) const override { // 球表面一点的法线就是这一点与圆心连接的线 N = normalize(P - center); } Vector3f center; float radius, radius2; }; 其中求交问题参考下图，就是联立求解，solveQuadratic用来求二次方程的根 紧接着看一下MeshTriangle\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 class MeshTriangle : public Object { public: // 参数为顶点数组、索引数组、三角形数量、对应的纹理坐标 MeshTriangle(const Vector3f* verts, const uint32_t* vertsIndex, const uint32_t\u0026amp; numTris, const Vector2f* st) { uint32_t maxIndex = 0; for (uint32_t i = 0; i \u0026lt; numTris * 3; ++i) if (vertsIndex[i] \u0026gt; maxIndex) maxIndex = vertsIndex[i]; maxIndex += 1; vertices = std::unique_ptr\u0026lt;Vector3f[]\u0026gt;(new Vector3f[maxIndex]); memcpy(vertices.get(), verts, sizeof(Vector3f) * maxIndex); vertexIndex = std::unique_ptr\u0026lt;uint32_t[]\u0026gt;(new uint32_t[numTris * 3]); memcpy(vertexIndex.get(), vertsIndex, sizeof(uint32_t) * numTris * 3); numTriangles = numTris; stCoordinates = std::unique_ptr\u0026lt;Vector2f[]\u0026gt;(new Vector2f[maxIndex]); memcpy(stCoordinates.get(), st, sizeof(Vector2f) * maxIndex); } // 求交 就是上边实现的功能 bool intersect(const Vector3f\u0026amp; orig, const Vector3f\u0026amp; dir, float\u0026amp; tnear, uint32_t\u0026amp; index, Vector2f\u0026amp; uv) const override { bool intersect = false; for (uint32_t k = 0; k \u0026lt; numTriangles; ++k) { const Vector3f\u0026amp; v0 = vertices[vertexIndex[k * 3]]; const Vector3f\u0026amp; v1 = vertices[vertexIndex[k * 3 + 1]]; const Vector3f\u0026amp; v2 = vertices[vertexIndex[k * 3 + 2]]; float t, u, v; if (rayTriangleIntersect(v0, v1, v2, orig, dir, t, u, v) \u0026amp;\u0026amp; t \u0026lt; tnear) { tnear = t; uv.x = u; uv.y = v; index = k; intersect |= true; } } return intersect; } void getSurfaceProperties(const Vector3f\u0026amp;, const Vector3f\u0026amp;, const uint32_t\u0026amp; index, const Vector2f\u0026amp; uv, Vector3f\u0026amp; N, Vector2f\u0026amp; st) const override { const Vector3f\u0026amp; v0 = vertices[vertexIndex[index * 3]]; const Vector3f\u0026amp; v1 = vertices[vertexIndex[index * 3 + 1]]; const Vector3f\u0026amp; v2 = vertices[vertexIndex[index * 3 + 2]]; Vector3f e0 = normalize(v1 - v0); Vector3f e1 = normalize(v2 - v1); // 直接用了顶点法线作为内部的法线 N = normalize(crossProduct(e0, e1)); const Vector2f\u0026amp; st0 = stCoordinates[vertexIndex[index * 3]]; const Vector2f\u0026amp; st1 = stCoordinates[vertexIndex[index * 3 + 1]]; const Vector2f\u0026amp; st2 = stCoordinates[vertexIndex[index * 3 + 2]]; // 插值出来的纹理坐标 st = st0 * (1 - uv.x - uv.y) + st1 * uv.x + st2 * uv.y; } // 生成一个缩放后的棋盘格纹理，颜色在橙色和黄色之间交替 Vector3f evalDiffuseColor(const Vector2f\u0026amp; st) const override { float scale = 5; float pattern = (fmodf(st.x * scale, 1) \u0026gt; 0.5) ^ (fmodf(st.y * scale, 1) \u0026gt; 0.5); return lerp(Vector3f(0.815, 0.235, 0.031), Vector3f(0.937, 0.937, 0.231), pattern); } std::unique_ptr\u0026lt;Vector3f[]\u0026gt; vertices; uint32_t numTriangles; std::unique_ptr\u0026lt;uint32_t[]\u0026gt; vertexIndex; std::unique_ptr\u0026lt;Vector2f[]\u0026gt; stCoordinates; }; Renerer.cpp 这里就是实现整体渲染逻辑的地方\ntrace 求交运行的地方，从代码可以看出每个像素都需要和场景中的每个物体进行求交，效率较低，下一次作业就是加速这快东西\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 std::optional\u0026lt;hit_payload\u0026gt; trace( const Vector3f \u0026amp;orig, const Vector3f \u0026amp;dir, const std::vector\u0026lt;std::unique_ptr\u0026lt;Object\u0026gt; \u0026gt; \u0026amp;objects) { float tNear = kInfinity; std::optional\u0026lt;hit_payload\u0026gt; payload; // 用场景中所有的物体与光线进行求交 for (const auto \u0026amp; object : objects) { float tNearK = kInfinity; uint32_t indexK; Vector2f uvK; if (object-\u0026gt;intersect(orig, dir, tNearK, indexK, uvK) \u0026amp;\u0026amp; tNearK \u0026lt; tNear) { payload.emplace(); payload-\u0026gt;hit_obj = object.get(); payload-\u0026gt;tNear = tNearK; payload-\u0026gt;index = indexK; payload-\u0026gt;uv = uvK; tNear = tNearK; } } return payload; } castRay 光线追踪的整体逻辑，递归函数很好理解，出口也很明显，具体逻辑都写注释了\n1 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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 Vector3f castRay( const Vector3f \u0026amp;orig, const Vector3f \u0026amp;dir, const Scene\u0026amp; scene, int depth) { // 递归出口 if (depth \u0026gt; scene.maxDepth) { return Vector3f(0.0,0.0,0.0); } Vector3f hitColor = scene.backgroundColor; if (auto payload = trace(orig, dir, scene.get_objects()); payload) { Vector3f hitPoint = orig + dir * payload-\u0026gt;tNear; Vector3f N; // normal Vector2f st; // st coordinates // 获取该点位的法线和纹理坐标 payload-\u0026gt;hit_obj-\u0026gt;getSurfaceProperties(hitPoint, dir, payload-\u0026gt;index, payload-\u0026gt;uv, N, st); // 根据材质不同进行不同的处理 switch (payload-\u0026gt;hit_obj-\u0026gt;materialType) { // 反射+折射 case REFLECTION_AND_REFRACTION: { // 获得反射方向 Vector3f reflectionDirection = normalize(reflect(dir, N)); // 获得折射方向 Vector3f refractionDirection = normalize(refract(dir, N, payload-\u0026gt;hit_obj-\u0026gt;ior)); // 当光线击中物体表面后，反射光线的起点如果直接使用 hitPoint（命中点），由于浮点数精度限制，新光线可能会误判为与同一物体再次相交（即“自交”），导致渲染错误（如表面黑斑或无限递归）。 根据反射方向与表面法线 N 的关系，将起点沿法线方向 ‌轻微偏移‌，使其略微离开原始表面，避免自交。 Vector3f reflectionRayOrig = (dotProduct(reflectionDirection, N) \u0026lt; 0) ? hitPoint - N * scene.epsilon : hitPoint + N * scene.epsilon; Vector3f refractionRayOrig = (dotProduct(refractionDirection, N) \u0026lt; 0) ? hitPoint - N * scene.epsilon : hitPoint + N * scene.epsilon; // 递归计算这次反射和折射后映射到该位置的颜色 Vector3f reflectionColor = castRay(reflectionRayOrig, reflectionDirection, scene, depth + 1); Vector3f refractionColor = castRay(refractionRayOrig, refractionDirection, scene, depth + 1); // 菲涅尔效应（Fresnel Effect）动态混合反射和折射颜色。菲涅尔效应表明，光线与表面交互时，反射和折射的比例取决于入射角：当光线 ‌垂直入射‌（入射角接近 0°）时，反射比例 kr 较小，折射占主导。 float kr = fresnel(dir, N, payload-\u0026gt;hit_obj-\u0026gt;ior); hitColor = reflectionColor * kr + refractionColor * (1 - kr); break; } // 只有反射 case REFLECTION: { float kr = fresnel(dir, N, payload-\u0026gt;hit_obj-\u0026gt;ior); Vector3f reflectionDirection = reflect(dir, N); Vector3f reflectionRayOrig = (dotProduct(reflectionDirection, N) \u0026lt; 0) ? hitPoint + N * scene.epsilon : hitPoint - N * scene.epsilon; hitColor = castRay(reflectionRayOrig, reflectionDirection, scene, depth + 1) * kr; break; } // BPhong光照模型了进行默认，这里重点要看一下阴影是如何产生的 default: { // [comment] // We use the Phong illumation model int the default case. The phong model // is composed of a diffuse and a specular reflection component. // [/comment] Vector3f lightAmt = 0, specularColor = 0; Vector3f shadowPointOrig = (dotProduct(dir, N) \u0026lt; 0) ? hitPoint + N * scene.epsilon : hitPoint - N * scene.epsilon; // [comment] // Loop over all lights in the scene and sum their contribution up // We also apply the lambert cosine law // [/comment] for (auto\u0026amp; light : scene.get_lights()) { Vector3f lightDir = light-\u0026gt;position - hitPoint; // square of the distance between hitPoint and the light float lightDistance2 = dotProduct(lightDir, lightDir); lightDir = normalize(lightDir); float LdotN = std::max(0.f, dotProduct(lightDir, N)); // is the point in shadow, and is the nearest occluding object closer to the object than the light itself? // 在一个只考虑光照模型的hitpoint上，以这个点为起始点，向光源方向发出光线，查看是否被遮挡，如果遮挡物距离比光影距离近，则生成阴影 auto shadow_res = trace(shadowPointOrig, lightDir, scene.get_objects()); bool inShadow = shadow_res \u0026amp;\u0026amp; (shadow_res-\u0026gt;tNear * shadow_res-\u0026gt;tNear \u0026lt; lightDistance2); // 如果被遮挡，则漫反射贡献将变为0 lightAmt += inShadow ? 0 : light-\u0026gt;intensity * LdotN; Vector3f reflectionDirection = reflect(-lightDir, N); specularColor += powf(std::max(0.f, -dotProduct(reflectionDirection, dir)), payload-\u0026gt;hit_obj-\u0026gt;specularExponent) * light-\u0026gt;intensity; } hitColor = lightAmt * payload-\u0026gt;hit_obj-\u0026gt;evalDiffuseColor(st) * payload-\u0026gt;hit_obj-\u0026gt;Kd + specularColor * payload-\u0026gt;hit_obj-\u0026gt;Ks; break; } } } return hitColor; } ","date":"2025-10-19T13:24:12+08:00","image":"https://sdpyy1.github.io/596150ce910e433694d4bb76ebe253f6.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-assignment5/","title":"GAMES101 现代计算机图形学入门 Assignment5"},{"content":"作业介绍 本期作业将在新的代码上完成，就是绘制四个控制点表示的贝塞尔曲线，本次作业的原理很简单，另外我目前并不想把时间花费到写出实现代码上，所以本次代码来自网络，会标明出处 cv::Point2f recursive_bezier(const std::vectorcv::Point2f \u0026amp;control_points, float t) 参考https://blog.csdn.net/ycrsw/article/details/124117190?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522409267016115dce86afca8873f30f1ba%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D\u0026amp;request_id=409267016115dce86afca8873f30f1ba\u0026amp;biz_id=0\u0026amp;utm_medium=distribute.pc_search_result.none-task-blog-2alltop_positive~default-1-124117190-null-null.142^v102^pc_search_result_base4\u0026amp;utm_term=games101%E4%BD%9C%E4%B8%9A4\u0026amp;spm=1018.2226.3001.4187\n该方法用于返回确定t时，需要绘制的点的坐标\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 bool recursive_bezier(const std::vector\u0026lt;cv::Point2f\u0026gt; \u0026amp;control_points, std::vector\u0026lt;cv::Point2f\u0026gt; \u0026amp;points1, std::vector\u0026lt;cv::Point2f\u0026gt; \u0026amp;points2, float t, cv::Mat \u0026amp;window){ int size; size = points1.size(); if(size == 1){ window.at\u0026lt;cv::Vec3b\u0026gt;(points1[0].y, points1[0].x)[1] = 255;//设置该点的绿通道为255 points2 = control_points; points1.clear();//结束计算后记得初始化容器 return true; } for(int i = 0; i \u0026lt; size - 1; i++) points2.push_back((1 - t) * points1[i] + t * points1[i + 1]); points1.clear(); return false; } void bezier(const std::vectorcv::Point2f \u0026amp;control_points, cv::Mat \u0026amp;window) 当t不断变化下，点也不断变化，从而绘制出曲线\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void bezier(const std::vector\u0026lt;cv::Point2f\u0026gt; \u0026amp;control_points, cv::Mat \u0026amp;window) { // TODO: Iterate through all t = 0 to t = 1 with small steps, and call de Casteljau\u0026#39;s // recursive Bezier algorithm. int size = control_points.size(); std::vector\u0026lt;cv::Point2f\u0026gt; points1 = control_points, points2; bool flag = true, bflag; for (double t = 0.0; t \u0026lt;= 1.0; t += 0.001) { bflag = false; while(!bflag){ if(flag) bflag = recursive_bezier(control_points, points1, points2, t, window); else bflag = recursive_bezier(control_points, points2, points1, t, window); flag = !flag; } } } 本次作业主要懂贝塞尔曲线的原理，以及递归代码如何写即可\n","date":"2025-10-19T13:23:42+08:00","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-assignment4/","title":"GAMES101 现代计算机图形学入门 Assignment4"},{"content":"作业描述 上节课已经对光栅化的操作有了了解，这节课直接引入了模型和纹理，其实本质就是引入了很多的顶点，操作其实没什么变化\n和上次一样，只不过要插值更多的东西 copy以前的投影矩阵即可 实现Blinn-Phong的光照模型，本质就是对每个像素的颜色值进行处理，把插值出来的颜色作为了漫反射的系数，另外还需要考虑高光反射和环境光 把材质搬进来，漫反射系数不再使用插值算法，而是由材质提供 Bump mapping 待学习 法向量、纹理颜色插值 通过重心坐标进行插值,每个像素点的重心坐标计算公式如下，$A_A$是面积 重心坐标计算代码如下\n1 2 3 4 5 6 static std::tuple\u0026lt;float, float, float\u0026gt; computeBarycentric2D(float x, float y, const Vector4f* v){ float c1 = (x*(v[1].y() - v[2].y()) + (v[2].x() - v[1].x())*y + v[1].x()*v[2].y() - v[2].x()*v[1].y()) / (v[0].x()*(v[1].y() - v[2].y()) + (v[2].x() - v[1].x())*v[0].y() + v[1].x()*v[2].y() - v[2].x()*v[1].y()); float c2 = (x*(v[2].y() - v[0].y()) + (v[0].x() - v[2].x())*y + v[2].x()*v[0].y() - v[0].x()*v[2].y()) / (v[1].x()*(v[2].y() - v[0].y()) + (v[0].x() - v[2].x())*v[1].y() + v[2].x()*v[0].y() - v[0].x()*v[2].y()); float c3 = (x*(v[0].y() - v[1].y()) + (v[1].x() - v[0].x())*y + v[0].x()*v[1].y() - v[1].x()*v[0].y()) / (v[2].x()*(v[0].y() - v[1].y()) + (v[1].x() - v[0].x())*v[2].y() + v[0].x()*v[1].y() - v[1].x()*v[0].y()); return {c1,c2,c3}; } 插值算法使用如下\n1 2 3 4 static Eigen::Vector3f interpolate(float alpha, float beta, float gamma, const Eigen::Vector3f\u0026amp; vert1, const Eigen::Vector3f\u0026amp; vert2, const Eigen::Vector3f\u0026amp; vert3, float weight) { return (alpha * vert1 + beta * vert2 + gamma * vert3) / weight; } 具体的使用如下\n1 2 3 4 5 6 7 8 //color interpolate auto interpolated_color = interpolate(alpha,beta,gamma,t.color[0],t.color[1],t.color[2],1); //normal vector interpolate auto interpolated_normal = interpolate(alpha,beta,gamma,t.normal[0],t.normal[1],t.normal[2],1).normalized(); //texture coordinates interpolate auto interpolated_texcoords = interpolate(alpha,beta,gamma,t.tex_coords[0],t.tex_coords[1],t.tex_coords[2],1); //shading point coordinates interpolate 这个东西是为了记录每个像素点是在哪个方向上观察他的，同于计算光照 auto interpolated_shadingcoords = interpolate(alpha,beta,gamma,view_pos[0],view_pos[1],view_pos[2],1); Blinn-Phong的光照模型 听过课肯定都懂了，直接写进代码即可 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 Eigen::Vector3f phong_fragment_shader(const fragment_shader_payload\u0026amp; payload) { // ka kd ks 是三种光的系数 Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005); Eigen::Vector3f kd = payload.color; Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937); // l1 l2表示有两个光源，里边的内容是位置和强度 auto l1 = light{{20, 20, 20}, {500, 500, 500}}; auto l2 = light{{-20, 20, 0}, {500, 500, 500}}; std::vector\u0026lt;light\u0026gt; lights = {l1, l2}; // 环境光强度 Eigen::Vector3f amb_light_intensity{10, 10, 10}; Eigen::Vector3f eye_pos{0, 0, 10}; // 高光的指数，越大对角度越敏感 float p = 150; Eigen::Vector3f color = payload.color; Eigen::Vector3f point = payload.view_pos; Eigen::Vector3f normal = payload.normal; Eigen::Vector3f result_color = {0, 0, 0}; for (auto\u0026amp; light : lights) { // 计算点到光源的向量 Eigen::Vector3f light_vec = light.position - point; // 计算点到光源的距离 float r = light_vec.norm(); // 归一化从点到光源的向量 Eigen::Vector3f light_dir = light_vec.normalized(); // 漫反射 Eigen::Vector3f diffuse = kd.cwiseProduct(light.intensity / (r * r)) * std::max(0.0f, normal.dot(light_dir)); // 高光反射 // 计算从表面点到观察者的向量 Eigen::Vector3f view_dir = (eye_pos - point).normalized(); // 计算半程向量 Eigen::Vector3f halfVector = (light_dir + view_dir).normalized(); Eigen::Vector3f specular = ks.cwiseProduct(light.intensity / (r * r)) * std::pow(std::max(0.0f, normal.dot(halfVector)), p); result_color += (diffuse + specular + ka.cwiseProduct(amb_light_intensity)); } return result_color * 255.f; } 这里边环境光是对于每个光源自带的么，为什么每个光源都要计算一次，而不是全局计算一次，有无懂哥？\n纹理颜色视为公式中的 kd 根据插值出来的纹理坐标为每个像素提供对应在纹理图片中的颜色，与上一步区别就是漫反射系数换成了纹理颜色\n1 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 57 58 Eigen::Vector3f texture_fragment_shader(const fragment_shader_payload\u0026amp; payload) { Eigen::Vector3f return_color = {0, 0, 0}; if (payload.texture) { // TODO: Get the texture value at the texture coordinates of the current fragment return_color = payload.texture-\u0026gt;getColor(payload.tex_coords.x(),payload.tex_coords.y()); } Eigen::Vector3f texture_color; texture_color \u0026lt;\u0026lt; return_color.x(), return_color.y(), return_color.z(); Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005); Eigen::Vector3f kd = texture_color / 255.f; Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937); auto l1 = light{{20, 20, 20}, {500, 500, 500}}; auto l2 = light{{-20, 20, 0}, {500, 500, 500}}; std::vector\u0026lt;light\u0026gt; lights = {l1, l2}; Eigen::Vector3f amb_light_intensity{10, 10, 10}; Eigen::Vector3f eye_pos{0, 0, 10}; float p = 150; Eigen::Vector3f color = texture_color; Eigen::Vector3f point = payload.view_pos; Eigen::Vector3f normal = payload.normal; Eigen::Vector3f result_color = {0, 0, 0}; for (auto\u0026amp; light : lights) { // TODO: For each light source in the code, calculate what the *ambient*, *diffuse*, and *specular* // components are. Then, accumulate that result on the *result_color* object. // 计算点到光源的向量 Eigen::Vector3f light_vec = light.position - point; // 计算点到光源的距离 float r = light_vec.norm(); // 归一化从点到光源的向量 Eigen::Vector3f light_dir = light_vec.normalized(); // 漫反射 Eigen::Vector3f diffuse = kd.cwiseProduct(light.intensity / (r * r)) * std::max(0.0f, normal.dot(light_dir)); // 高光反射 // 计算从表面点到观察者的向量 Eigen::Vector3f view_dir = (eye_pos - point).normalized(); // 计算半程向量 Eigen::Vector3f halfVector = (light_dir + view_dir).normalized(); Eigen::Vector3f specular = ks.cwiseProduct(light.intensity / (r * r)) * std::pow(std::max(0.0f, normal.dot(halfVector)), p); result_color += (diffuse + specular + ka.cwiseProduct(amb_light_intensity)); } return result_color * 255.f; } Bump mapping 源码给了一个示例来参考 通过扰动表面法线方向‌（而非实际修改几何形状）来制造光照上的明暗变化 按照上述代码对传进来的像素的法线进行处理，最后的颜色就是法线值\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 float x = normal.x(); float y = normal.y(); float z = normal.z(); Eigen::Vector3f t{ x * y / std::sqrt(x * x + z * z), std::sqrt(x * x + z * z), z*y / std::sqrt(x * x + z * z) }; Eigen::Vector3f b = normal.cross(t); Eigen::Matrix3f TBN; TBN \u0026lt;\u0026lt; t.x(), b.x(), normal.x(), t.y(), b.y(), normal.y(), t.z(), b.z(), normal.z(); float u = payload.tex_coords.x(); float v = payload.tex_coords.y(); float w = payload.texture-\u0026gt;width; float h = payload.texture-\u0026gt;height; float dU = kh * kn * (payload.texture-\u0026gt;getColor(u + 1 / w , v).norm() - payload.texture-\u0026gt;getColor(u, v).norm()); float dV = kh * kn * (payload.texture-\u0026gt;getColor(u, v + 1 / h).norm() - payload.texture-\u0026gt;getColor(u, v).norm()); Eigen::Vector3f ln{-dU, -dV, 1}; Eigen::Vector3f result_color = (TBN * ln).normalized(); 完整代码如下\n1 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 Eigen::Vector3f bump_fragment_shader(const fragment_shader_payload\u0026amp; payload) { Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005); Eigen::Vector3f kd = payload.color; Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937); auto l1 = light{{20, 20, 20}, {500, 500, 500}}; auto l2 = light{{-20, 20, 0}, {500, 500, 500}}; std::vector\u0026lt;light\u0026gt; lights = {l1, l2}; Eigen::Vector3f amb_light_intensity{10, 10, 10}; Eigen::Vector3f eye_pos{0, 0, 10}; float p = 150; Eigen::Vector3f color = payload.color; Eigen::Vector3f point = payload.view_pos; Eigen::Vector3f normal = payload.normal; float kh = 0.2, kn = 0.1; // 这个地方是为了后面用局部坐标系，让n始终朝向001，课上讲过 float x = normal.x(); float y = normal.y(); float z = normal.z(); Eigen::Vector3f t{ x * y / std::sqrt(x * x + z * z), std::sqrt(x * x + z * z), z*y / std::sqrt(x * x + z * z) }; Eigen::Vector3f b = normal.cross(t); Eigen::Matrix3f TBN; TBN \u0026lt;\u0026lt; t.x(), b.x(), normal.x(), t.y(), b.y(), normal.y(), t.z(), b.z(), normal.z(); float u = payload.tex_coords.x(); float v = payload.tex_coords.y(); float w = payload.texture-\u0026gt;width; float h = payload.texture-\u0026gt;height; float dU = kh * kn * (payload.texture-\u0026gt;getColor(u + 1 / w , v).norm() - payload.texture-\u0026gt;getColor(u, v).norm()); float dV = kh * kn * (payload.texture-\u0026gt;getColor(u, v + 1 / h).norm() - payload.texture-\u0026gt;getColor(u, v).norm()); Eigen::Vector3f ln{-dU, -dV, 1}; Eigen::Vector3f result_color = (TBN * ln).normalized(); return result_color * 255.f; } displacement mapping 这种应用层面的东西目前不是很想深究，不过它比bump mapping多的就是它是真的会修改三角形位置\n1 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 57 Eigen::Vector3f displacement_fragment_shader(const fragment_shader_payload\u0026amp; payload) { Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005); Eigen::Vector3f kd = payload.color; Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937); auto l1 = light{{20, 20, 20}, {500, 500, 500}}; auto l2 = light{{-20, 20, 0}, {500, 500, 500}}; std::vector\u0026lt;light\u0026gt; lights = {l1, l2}; Eigen::Vector3f amb_light_intensity{10, 10, 10}; Eigen::Vector3f eye_pos{0, 0, 10}; float p = 150; Eigen::Vector3f color = payload.color; Eigen::Vector3f point = payload.view_pos; Eigen::Vector3f normal = payload.normal; float kh = 0.2, kn = 0.1; float x = normal.x(); float y = normal.y(); float z = normal.z(); Eigen::Vector3f t{ x * y / std::sqrt(x * x + z * z), std::sqrt(x * x + z * z), z*y / std::sqrt(x * x + z * z) }; Eigen::Vector3f b = normal.cross(t); Eigen::Matrix3f TBN; TBN \u0026lt;\u0026lt; t.x(), b.x(), normal.x(), t.y(), b.y(), normal.y(), t.z(), b.z(), normal.z(); float u = payload.tex_coords.x(); float v = payload.tex_coords.y(); float w = payload.texture-\u0026gt;width; float h = payload.texture-\u0026gt;height; float dU = kh * kn * (payload.texture-\u0026gt;getColor(u + 1 / w , v).norm() - payload.texture-\u0026gt;getColor(u, v).norm()); float dV = kh * kn * (payload.texture-\u0026gt;getColor(u, v + 1 / h).norm() - payload.texture-\u0026gt;getColor(u, v).norm()); Eigen::Vector3f ln{-dU, -dV, 1}; //与凹凸贴图的区别就在于这句话 point += (kn * normal * payload.texture-\u0026gt;getColor(u , v).norm()); normal = (TBN * ln).normalized(); Eigen::Vector3f result_color = {0, 0, 0}; for (auto\u0026amp; light : lights) { Eigen::Vector3f l = (light.position - point).normalized(); // 光 Eigen::Vector3f v = (eye_pos - point).normalized();\t// 眼 Eigen::Vector3f h = (l + v).normalized(); // 半程向量 double r_2 = (light.position - point).dot(light.position - point); Eigen::Vector3f Ld = kd.cwiseProduct(light.intensity / r_2) * std::max(0.0f, normal.dot(l)); //cwiseProduct()函数允许Matrix直接进行点对点乘法,而不用转换至Array。 Eigen::Vector3f Ls = ks.cwiseProduct(light.intensity / r_2) * std::pow(std::max(0.0f, normal.dot(h)), p); result_color += (Ld + Ls); } Eigen::Vector3f La = ka.cwiseProduct(amb_light_intensity); result_color += La; return result_color * 255.f; } ","date":"2025-10-19T13:22:54+08:00","image":"https://sdpyy1.github.io/6218a369afe94437bb81dfb0420e1b39.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-assignment3/","title":"GAMES101 现代计算机图形学入门 Assignment3"},{"content":"作业介绍 上节课通过MVP+视口变换把三角形三个顶点从空间坐标转移到了屏幕上的坐标，并绘制三角形，这节课将通过光栅化技术，构造实心的三角形 视口变换？ 说是上节课实现了这个，但是任务中并没有，源码中已经实现了视口变换，这里就看一下它如何实现的\n1 2 3 4 5 6 7 //Viewport transformation for (auto \u0026amp; vert : v) { vert.x() = 0.5*width*(vert.x()+1.0); vert.y() = 0.5*height*(vert.y()+1.0); vert.z() = vert.z() * f1 + f2; } 以x举例，NDC坐标下x的范围为[-1,1]，首先+1，将范围固定到[0,2],下一步乘0.5乘width,就固定到了[0,width]的范围，也就是屏幕的范围。y坐标同理（y坐标可能需要反转，因为屏幕坐标y可能是向下走的）。 下面看对z坐标的变化。这里我其实不是理解在干什么，下边是deepseek的解释： 视口变换的 ‌z坐标处理‌ 通常是将归一化设备坐标（NDC）的z值映射到深度缓冲的合法范围（如[0,1]）。然而，在你的代码中，f1和f2的设置（f1 = 24.95, f2 = 25.05）表明z坐标被映射到了‌非标准的线性深度范围‌，而非常规的[0,1]\n1 2 3 float f1 = (50 - 0.1) / 2.0; float f2 = (50 + 0.1) / 2.0; vert.z() = vert.z() * f1 + f2; 源码分析 这里只需要看一下rst::rasterizer::draw(pos_buf_id pos_buffer, ind_buf_id ind_buffer, col_buf_id col_buffer, Primitive type)这个函数干了什么\n1 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 void rst::rasterizer::draw(pos_buf_id pos_buffer, ind_buf_id ind_buffer, col_buf_id col_buffer, Primitive type) { auto\u0026amp; buf = pos_buf[pos_buffer.pos_id]; auto\u0026amp; ind = ind_buf[ind_buffer.ind_id]; auto\u0026amp; col = col_buf[col_buffer.col_id]; float f1 = (50 - 0.1) / 2.0; float f2 = (50 + 0.1) / 2.0; // MVP矩阵 Eigen::Matrix4f mvp = projection * view * model; // 对每个顶点进行MVP+视口变换+设置颜色 for (auto\u0026amp; i : ind) { // 定义一个三角形，设置它的属性，此时设置的已经是在屏幕上的三角形 Triangle t; Eigen::Vector4f v[] = { mvp * to_vec4(buf[i[0]], 1.0f), mvp * to_vec4(buf[i[1]], 1.0f), mvp * to_vec4(buf[i[2]], 1.0f) }; //Homogeneous division for (auto\u0026amp; vec : v) { vec /= vec.w(); } //Viewport transformation for (auto \u0026amp; vert : v) { vert.x() = 0.5*width*(vert.x()+1.0); vert.y() = 0.5*height*(vert.y()+1.0); vert.z() = vert.z() * f1 + f2; } for (int i = 0; i \u0026lt; 3; ++i) { t.setVertex(i, v[i].head\u0026lt;3\u0026gt;()); t.setVertex(i, v[i].head\u0026lt;3\u0026gt;()); t.setVertex(i, v[i].head\u0026lt;3\u0026gt;()); } auto col_x = col[i[0]]; auto col_y = col[i[1]]; auto col_z = col[i[2]]; t.setColor(0, col_x[0], col_x[1], col_x[2]); t.setColor(1, col_y[0], col_y[1], col_y[2]); t.setColor(2, col_z[0], col_z[1], col_z[2]); // 这里就是要实现的函数，传入的就是三角形 rasterize_triangle(t); } } 光栅化 整体流程就是计算这个三角形需要涉及到哪些像素点，并对这些像素点设置属性，最后所有需要处理的像素点信息都存到了一个buffer中，调Opencv相关功能把它画出来，这里需要干的事就是给每个像素需要的东西计算出来\nbounding box 画一个三角形没必要遍历所有像素点来找属于该三角形的像素点，只需要线框选一个范围，在这个范围内进行遍历即可，最简单的办法就是找三个坐标最左最右最上最下四个标记点，框选即可，具体做法如下（取值时还会向外扩展一个像素，这是为了‌确保覆盖所有可能被三角形覆盖的像素，因为浮点数本来就是不准确的）\n1 2 3 4 int minX = std::floor((std::min({t.v[0].x(),t.v[1].x(),t.v[2].x()}))-0.5); int maxX = std::ceil(std::max({t.v[0].x(),t.v[1].x(),t.v[2].x()})+0.5); int minY = std::floor(std::min({t.v[0].y(),t.v[1].y(),t.v[2].y()})-0.5); int maxY = std::ceil(std::max({t.v[0].y(),t.v[1].y(),t.v[2].y()})+0.5); 如何判定像素点在三角形内部 通过上一步已经确定了三角形的大概范围，下一步就是需要判断具体哪些像素确实需要处理，实现思路主要是通过叉乘计算，连接三角形顶点和像素点形成三个向量，用这三个向量分别叉乘三角形的三条边，如果得到的向量方向相同，说明像素点在三角形内部。具体实现如下，写的比较繁琐，但是好理解\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 static bool insideTriangle(int x, int y, const Vector3f* _v) { // TODO : Implement this function to check if the point (x, y) is inside the triangle represented by _v[0], _v[1], _v[2] // 设置三条边对应的向量 Eigen::Vector3i triangleEdgeVector1(_v[1].x()-_v[0].x(),_v[1].y()-_v[0].y(),0); Eigen::Vector3i triangleEdgeVector2(_v[2].x()-_v[1].x(),_v[2].y()-_v[1].y(),0); Eigen::Vector3i triangleEdgeVector3(_v[0].x()-_v[2].x(),_v[0].y()-_v[2].y(),0); Eigen::Vector3i p1(x-_v[0].x(),y-_v[0].y(),0); Eigen::Vector3i p2(x-_v[1].x(),y-_v[1].y(),0); Eigen::Vector3i p3(x-_v[2].x(),y-_v[2].y(),0); bool flag1 = triangleEdgeVector1.cross(p1).z() \u0026gt; 0; bool flag2 = triangleEdgeVector2.cross(p2).z() \u0026gt; 0; bool flag3 = triangleEdgeVector3.cross(p3).z() \u0026gt;0; if(flag1 == flag2 \u0026amp;\u0026amp; flag2 == flag3) return true; return false; } 像素点处理 经过上一步，已经确定了哪些像素点在三角形内部，下来就是对这些像素点进行处理，首先确定需要处理哪些东西\nz坐标（因为从摄像机看，进处的物体会挡住远处的物体，所以只需要渲染离屏幕更近的颜色，实现就是用z-buffer技术，其实本质就是缓存每个像素点当前最近的距离，如果遍历的像素点比buffer还近就替换他） 颜色 颜色处理 如果你看到下节课，颜色是通过三个顶点颜色进行插值获得的，这节作业还没用到，看源码，每个三角形的三个顶点都被设置成了同一个颜色，所以这里只需要把颜色设置为顶点颜色即可\nZ坐标处理 按照学习的内容，z坐标应该为三个顶点z坐标插值得到，但是这里并不是，并且算法也是直接给出，并不是课程内容。z坐标计算如下，computeBarycentric2D用来获取当前像素点的重心坐标，下节课会讲到的。至于z坐标的处理方式，用到一个透视校正 为什么需要透视校正而不是直接插值，我目前还不懂，问的ai？‌ 在透视投影中，物体在近大远小的效果下，其深度（z 值）的分布是 ‌非线性的‌。若直接在屏幕空间线性插值深度，会导致以下问题：\n远处物体的深度插值过于密集，近处过于稀疏。 深度测试（Z-test）不准确，产生渲染错误（如 Z-fighting）。 所以这里就直接调用了，不研究了\n1 2 3 4 auto[alpha, beta, gamma] = computeBarycentric2D(i, j, t.v); float w_reciprocal = 1.0/(alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w()); float z_interpolated = alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w(); z_interpolated *= w_reciprocal; 完整代码 ⚠️：z-buffer缓存记得处理\n1 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 void rst::rasterizer::rasterize_triangle(const Triangle\u0026amp; t) { // 三个顶点的齐次坐标 auto v = t.toVector4(); // iterate through the pixel and find if the current pixel is inside the triangle 取边界时要向外扩展一个像素 int minX = std::floor((std::min({t.v[0].x(),t.v[1].x(),t.v[2].x()}))-0.5); int maxX = std::ceil(std::max({t.v[0].x(),t.v[1].x(),t.v[2].x()})+0.5); int minY = std::floor(std::min({t.v[0].y(),t.v[1].y(),t.v[2].y()})-0.5); int maxY = std::ceil(std::max({t.v[0].y(),t.v[1].y(),t.v[2].y()})+0.5); for(int i = minX; i\u0026lt;=maxX;i++){ for(int j = minY;j\u0026lt;=maxY;j++){ // 如果划分到三角形内 if(insideTriangle(i,j,t.v)){ // If so, use the following code to get the interpolated z value. auto[alpha, beta, gamma] = computeBarycentric2D(i, j, t.v); float w_reciprocal = 1.0/(alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w()); float z_interpolated = alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w(); z_interpolated *= w_reciprocal; if(z_interpolated \u0026lt; depth_buf[get_index(i,j)]){ // TODO : set the current pixel (use the set_pixel function) to the color of the triangle (use getColor function) if it should be painted. set_pixel(Vector3f(i,j,z_interpolated),t.getColor()); depth_buf[get_index(i,j)] = z_interpolated; } } } } } ","date":"2025-10-19T13:22:24+08:00","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-assignment2/","title":"GAMES101 现代计算机图形学入门 Assignment2"},{"content":"作业介绍 给出三个点的空间坐标，通过MVP矩阵将点投影到平面上，并绘制出三角形 MVP变换 Model Transformation（模型变换）：将物体从局部坐标系（Local Space）转换到世界坐标系（World Space），通过以下操作实现：\n平移（Translate）‌：调整物体在世界空间中的位置； 旋转（Rotate）‌：改变物体的朝向； 缩放（Scale）‌：控制物体的大小‌ View Transformation（视图变换）： 将摄像机移动原点‌；形成以摄像机为中心的视角 ‌调整摄像机朝向‌：默认摄像机看向-Z轴方向，Y轴为垂直向上方向； ‌同步变换物体‌：保持物体与摄像机的相对位置关系‌\nProjection Transformation（投影变换）： 将观察空间中的三维物体投影到二维裁剪空间（Clip Space），分为两种类型：\n‌正交投影（Orthographic Projection）‌\n保持物体平行性，无近大远小效果。‌ 透视投影（Perspective Projection）‌\n模拟人眼视觉效果，产生近大远小的深度感。 作业代码逻辑解析 main函数分析\n1 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 57 58 59 60 int main(int argc, const char** argv) { // 旋转角度 float angle = 0; bool command_line = false; std::string filename = \u0026#34;output.png\u0026#34;; if (argc \u0026gt;= 3) { command_line = true; angle = std::stof(argv[2]); // -r by default if (argc == 4) { filename = std::string(argv[3]); } else return 0; } rst::rasterizer r(700, 700); // 摄像机位置 Eigen::Vector3f eye_pos = {0, 0, 5}; // 三角形的三个顶点 std::vector\u0026lt;Eigen::Vector3f\u0026gt; pos{{2, 0, -2}, {0, 2, -2}, {-2, 0, -2}}; // 顶点顺序 std::vector\u0026lt;Eigen::Vector3i\u0026gt; ind{{0, 1, 2}}; // 顶点坐标和顺序加载到光栅器 auto pos_id = r.load_positions(pos); auto ind_id = r.load_indices(ind); int key = 0; int frame_count = 0; if (command_line) { r.clear(rst::Buffers::Color | rst::Buffers::Depth); // 设置MVP三个矩阵 r.set_model(get_model_matrix(angle)); r.set_view(get_view_matrix(eye_pos)); r.set_projection(get_projection_matrix(45, 1, 0.1, 50)); // 画出图形 r.draw(pos_id, ind_id, rst::Primitive::Triangle); cv::Mat image(700, 700, CV_32FC3, r.frame_buffer().data()); image.convertTo(image, CV_8UC3, 1.0f); cv::imwrite(filename, image); return 0; } while (key != 27) { r.clear(rst::Buffers::Color | rst::Buffers::Depth); r.set_model(get_model_matrix(angle)); r.set_view(get_view_matrix(eye_pos)); r.set_projection(get_projection_matrix(45, 1, 0.1, 50)); r.draw(pos_id, ind_id, rst::Primitive::Triangle); cv::Mat image(700, 700, CV_32FC3, r.frame_buffer().data()); image.convertTo(image, CV_8UC3, 1.0f); cv::imshow(\u0026#34;image\u0026#34;, image); key = cv::waitKey(10); std::cout \u0026lt;\u0026lt; \u0026#34;frame count: \u0026#34; \u0026lt;\u0026lt; frame_count++ \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; if (key == \u0026#39;a\u0026#39;) { angle += 10; } else if (key == \u0026#39;d\u0026#39;) { angle -= 10; } } return 0; } 这次作业主要就是实现MVP矩阵，一部分代码就是把MVP矩阵装进光栅化器中。所以别的代码已经没必要看了，在下次作业中再细看\n模型变换：get_model_matrix(float rotation_angle) 实现model变换，可以进行缩放、平移、旋转，作业这里只需要进行绕z轴的旋转，在上次作业中，上一篇博客已经提到了如何进行三位物体的旋转，绕各个轴都有对应的变换矩阵，直接左乘到坐标向量即可。\n1 2 3 4 5 6 7 Eigen::Matrix4f get_model_matrix(float rotation_angle) { Eigen::Matrix4f model = Eigen::Matrix4f::Identity(); Eigen::Matrix4f rotationMatrix; rotationMatrix \u0026lt;\u0026lt; cos(rotation_angle/180* MY_PI),-sin(rotation_angle/180* MY_PI),0,0, sin(rotation_angle/180*MY_PI),cos(rotation_angle/180* MY_PI),0,0,0,0,1,0,0,0,0,1; return rotationMatrix; } 视图变换：get_view_matrix(Eigen::Vector3f eye_pos) 本次作业并没有让完成这部分，已经被写好了，这里写分析一下代码，视图变换主要作用就是如何把摄像机放在世界远点来进行观察，main函数中已经定义了Eigen::Vector3f eye_pos = {0, 0, 5};\n1 2 3 4 5 6 7 8 9 Eigen::Matrix4f get_view_matrix(Eigen::Vector3f eye_pos) { Eigen::Matrix4f view = Eigen::Matrix4f::Identity(); Eigen::Matrix4f translate; translate \u0026lt;\u0026lt; 1, 0, 0, -eye_pos[0], 0, 1, 0, -eye_pos[1], 0, 0, 1, -eye_pos[2], 0, 0, 0, 1; view = translate * view; return view; } 其中可以看出视图变换的矩阵为，说明只进行了平移操作，摄像机的向上方向和看的方向都为默认。为其他所以物体的坐标都左乘这个矩阵，就实现了所有物体以摄像机为原点的位置坐标\n投影变换：get_projection_matrix(float eye_fov, float aspect_ratio, float，zNear, float zFar) 本节图片来自https://zhuanlan.zhihu.com/p/620496517\n在给定的参数描述下，就可以确定整个需要投影的空间位置，如下图所示，目标就是把这个立方体区域转换到NDC坐标 第一步的目标是将该区域变为长方体区域，在进行转换时，离得远的物体就会相应的变小 从+x向-x方向看结构如下图，压缩比例应该为n/z,所以 y = ny/z,x同理 设计一个变换矩阵 左乘后同时除以z，根据齐次坐标的性质，仍表示同一个坐标，这样每个坐标的x，y都处理好了，就剩下z未处理，其中包含a,b,c,d4个未知数 z坐标获得与上次作业旋转公式推导的方法相似，取几个在变化过程中不会变z的坐标来求解 近平面上（$x^1,y^1,n,1$）与中心点（$0,0,n,1$），远平面（$x^2,y^2,f,1$）和中心点$0,0,f,1$），通过这些点，以及全部近面和远面中心点z不会变这些信息，可以算出a,b,c,d如何取值，最终得到的变换矩阵为 但是并没有结束，此时以及得到如下图所示的情况，下一步还需要把空间移动到NDC，就是将长方体变为坐标值在 $[-1,1]^3$ 的立方体。（从这里开始，其实就是正交投影的过程） 以上图坐标为例，平移的方向和距离用t表示很好理解，就是把整个空间的中心挪到坐标原点 $$ t = \\left( -\\frac{l + r}{2},\\ -\\frac{b + t}{2},\\ -\\frac{n + f}{2} \\right) $$ 下一步就是把所有坐标都进行单位化，就是把长方体的变长都变成1，对坐标进行统一缩小，缩小矩阵如下 结合上边的平移后得到正交投影的变化矩阵 结合透视和正交的矩阵就可以得到最终的投影矩阵\n代码实现 代码来自https://blog.csdn.net/qq_41265365/article/details/124229095，懒得算了，就是把三个矩阵合并成了一个写的\n1 2 3 4 5 6 7 8 9 10 11 12 13 Eigen::Matrix4f get_projection_matrix(float eye_fov, float aspect_ratio, float zNear, float zFar) { Eigen::Matrix4f Mpersp; float fovY = eye_fov*MY_PI/180.0; float cota = 1.f/tanf(fovY/2); float zD = zNear-zFar; Mpersp \u0026lt;\u0026lt; -cota/aspect_ratio, 0, 0, 0, 0, -cota, 0, 0, 0, 0, (zNear+zFar)/zD, -2*zNear*zFar/zD, 0, 0, 1, 0; return Mpersp; } ","date":"2025-10-19T13:21:54+08:00","image":"https://sdpyy1.github.io/594b42fc6200482991a2c402f81c52d1.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-assignment1/","title":"GAMES101 现代计算机图形学入门 Assignment1"},{"content":"Assignment0 前边一大半主要是安装虚拟机保证运行一致，这里就跳过了，到实际的问题：点旋转和平移 使用Eigen表示向量 点$P=(2,1)$ 表示为 Eigen::Vector3f point(2, 1, 1);，最后一位是用于齐次坐标，为1时表示是一个点\n点旋转矩阵 **二维点旋转矩阵**比较简单，推导主要是通过设A、B、C、D四个变量，找两个特殊点求解 三维点旋转矩阵可分解为分别绕三个轴旋转 注意：上述提到的公式都是按照逆时针旋转的矩阵，顺时针相当于逆时针旋转$-θ$，本题只涉及二维点旋转\n平移 平移可以简单通过加法实现，但是如果想统一格式，用一个矩阵表示点的所有变换操作，就需要用到齐次坐标，利用齐次坐标表示转换矩阵，可以把旋转缩放平移同时塞到一个矩阵中。下图为只进行平移操作的齐次转换矩阵 旋转矩阵的齐次坐标表达为： 平移 (1,2)，只需要把tx换为1，ty换为2，得到平移矩阵的齐次坐标表达为： 旋转和平移组合 得到最终旋转+平移的矩阵为： 代码实现 std::sin和std::cos的参数为弧度制，用角度/180*Π得到。例如cos45°可以表达为std::cos(45.0/180.0*acos(-1)),其中acos(-1)就等于Π\n1 2 3 4 5 6 7 8 9 10 // 作业0实现 std::cout \u0026lt;\u0026lt; \u0026#34;作业0实现\u0026#34; \u0026lt;\u0026lt; std::endl; Eigen::Vector3f point(2, 1, 1); Eigen::Matrix3f rotate; std::cout \u0026lt;\u0026lt; \u0026#34;转换矩阵\u0026#34; \u0026lt;\u0026lt; std::endl; rotate \u0026lt;\u0026lt; std::cos(45.0/180.0*acos(-1)),-std::sin(45.0/180.0*acos(-1)),1,std::sin(45.0/180.0* acos(-1)),std::cos(45.0/180.0*acos(-1)),2,0,0,1; std::cout \u0026lt;\u0026lt; rotate \u0026lt;\u0026lt; std::endl; Eigen::Vector3f res = rotate*point; std::cout \u0026lt;\u0026lt; \u0026#34;结果\u0026#34; \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; res \u0026lt;\u0026lt; std::endl; ","date":"2025-10-19T13:14:45+08:00","image":"https://sdpyy1.github.io/2703bfe2cee44ac4b97c54c3d8b35851.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-assignment0/","title":"GAMES101 现代计算机图形学入门 Assignment0"},{"content":"Cameras 小孔成像 小孔成像是可以拍照的，但是没有景深效果\n视场 Field of View (FOV) 传感器的高度与传感器和小孔之间的距离决定FOV的大小 通常都是固定传感器尺寸，控制焦距来控制FOV 不同焦距下的视场 曝光 Exposure 决定曝光的变量 在摄像机中决定曝光的变量 第一行是光圈、第二行是快门速度、第三行是IOS设置 IOS的介绍 光圈，N越大直径越小 快门 因为快门打开需要时间，那高速运动物体就会出问题\n镜头 理想情况 焦距物距像距的关系 景深就是像距没有落到感光元件上 这节主要是科普摄像机的东西\n光场 Light Field 人眼前放一块幕布，投影这个图形，对人来说看到的是一样的 全光函数 用全光函数表示人能看到的东西 从简单来看 引入颜色 引入时间，变为电影 引入位置。全息电影 用七个维度衡量看到的所有东西，光场从这里开始\n从任意一个位置看向任何一个位置，都可以从光场中提取光的信息 并不需要知道光场内是什么物体，只需要光场的信息，在盒子表面任何一点任何一个位置的信息 颜色 Color ","date":"2025-10-19T12:49:13+08:00","image":"https://sdpyy1.github.io/05414f77c7904ed5851265148aa16f58.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-camera-lenses-light-fields-color-and-perception/","title":"GAMES101 现代计算机图形学入门 Camera Lenses Light Fields Color and Perception"},{"content":"一些科普 Keyframe Animator Physical Simulation 利用物理公式来模拟 质点弹簧系统 Mass Spring Rope 下面开始描述这个系统 胡克定律描述弹簧或弹性体所受的回复力 F 与形变量 x 成正比， $k_s$是劲度系数，但是弹簧本身也有长度 考虑弹簧原始长度 ，$\\frac{b - a}{|b - a|}$这个向量用来表示方向，但问题是这样写，弹簧一旦拉开就不会不停的运动下去（动能和势能相互转化），所以还需要引入摩擦力 引入摩擦力之前先引入一些符号表示位置、速度、加速度 给物体一个相反与速度方向的力，但还有问题，这样会导致所有的运动都停下来，假设AB两个物体同步向右走，他俩都有速度，但相对之间没有速度，所以应该没摩擦力，但是用下面的公式，只要物体有速度，就会受到摩擦力而停止 我们在逐渐完善弹簧系统，摩擦力应该跟着物体AB的相对运动而产生，下图用两个物体速度差来表示相对速度，另外还需要考虑这个速度差是沿着弹簧方向才行，所以点乘$\\frac{b - a}{|b - a|}$来投影到ba方向 下面看一些弹簧结构 模型的行为是由结构连接来决定的，下面看如果一块布这样设计，很明显是不合理的，图中展示了2种原因（布可以抵抗切变力但这个结构不行，布会对抗对折） 先来改进切变，加入蓝色弹簧来抵抗 再加另外一个方向，但不能解决第二个问题 最终版 粒子系统 运动学 Forward Kinematics 已知角度求P的位置 逆运动学Inverse Kinematics 已知P的位置求角度 Rigging Motion Capture ![请添\n第二次课 cont. Single Particle Simulation 首先学习单粒子运动，假设粒子的运动由一个速度向量场决定，速度向量场的速度由位置和时间决定 要计算粒子在某个时间的位置，需要解下图所示的常微分方程 用欧拉公式来解微分方程，用上一步的数据来计算下一步的位置 欧拉方程的误差与步长大小有关 欧拉方程的不稳定性，在一个螺旋速度场中，本来应该旋转，但是不管步长选的多小都一定会飞出去。第二幅图的运动模拟也不合理。 如何解决呢，有人提出了中点法，先用欧拉算出a点，但不用a点，取中点b，考虑b点的速度，应用b点的速度回去重新算欧拉 进一步，有人用中点思想，算两次欧拉方法，不是回原点重新算，而是去中点算，如果算出来和第一次算出来差不多就不算了，说明步长够小了 还有隐式的欧拉方法 Runge-Kutta Families Position-Based / Verlet Integration 刚体（不会发生形变）模拟 流体模拟 Fluid Simulation 这门课的动画模拟更多都是在科普。\n","date":"2025-10-19T12:48:02+08:00","image":"https://sdpyy1.github.io/49ba0bbdd1d24a848de86f532c675a7e.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-animation/simulation/","title":"GAMES101 现代计算机图形学入门 Animation/Simulation"},{"content":"Shadow Mapping 如果使用光栅化，对一个像素shading时考虑光源，考虑摄影机位置，但是没有考虑模型其他位置对该位置的影响，因为在光源与该像素点之间有别的东西挡住，那光源就会被挡住，形成阴影。shading解决不了阴影问题 该算法为图像空间算法。生成阴影并不需要模型的几何位置信息，当前该算法还是存在走样现象 该算法的关键思想是：一个点不在阴影的前提是摄像机能看见这个点，并且光源能看见这个点 算法步骤：\nRender from Light：把光源当作摄像机，做一遍光栅化，从而得到光源会看到什么东西，只需要记录看到点的深度\n从设定好的摄像机位置去真正的渲染场景得到摄像机视角的深度Buffer，如下图橙线下的点两次都看见了，而红线上的点只有光源嫩看见，摄像机是看不见的\n举一个实际的例子 上图左上角有一个点光源，下图则展示了从点光源看向模型的样子 当然这一步只需要记录每个点的深度 最终再回到摄像机的视角，绿色就是摄像机和光源都能看见的位置，不是绿色的位置就是阴影的位置 shadow maps的问题 首先解释一下硬软阴影区别，看两幅图就能理解\n硬阴影 ： 软阴影： 产生软阴影是因为光源具有体积，导致，有的地方完全看不到光源（本影, Umbra）， 有的地方能看到一部分光源（半影，Penumbra）。所以阴影的边缘会有过渡的情况，从而产生软阴影，就像上图中太阳与地球的示意一样（全日食与半日食）。 Ray Tracing Why Ray Tracing 为什么需要光线追踪？\n光栅化不能很好地处理全局效果，如软阴影（光栅化可通过Shadow Mapping实现硬阴影（老师说目前也有技术实现软），由于缺乏全局信息，需要多次计算，如上一节提到的把光源当作摄像机先计算一遍深度缓存）。另外是当光多次弹射时，光栅化不好处理 光栅化很块，但是质量低 光线追踪很准确，光栅化主要用于实时，光线追踪用于离线 图形学对光线的假设 光线沿直线传播 光线和光线不会碰撞 光线从光源出发经过各种反射到达人眼（光路可逆，光线追踪利用的就是光路的可逆性） Ray Casting 光线投射 如下图所示，摄像机连出一根线到屏幕的一个像素点，点到达物体表面时，该点也能连接到光源，就可以计算出这一点的光的强度来进行着色 具体例子如下图 从眼睛发出线打到一个像素，紧接着达到场景中的某个位置上（eye ray），只考虑与场景最近的一个交点，所以后边的虚线就不考虑了（在作业5中可知，生成eve ray的流程：屏幕是Raster Space（光栅空间），为了找到摄像机到每个栅格真正代表的空间坐标，需要将Raster Space还原成NDC space再进一步还原成） 紧接着连接一条线到光源位置（shadow ray），有了法线、入射方向、观察方向，就可以利用光照模型来计算着色了（例如 Blinn Phong model） Recursive Ray Tracing（Whitted风格） 上一节的模型仍然只考虑了光线只反射一次的情况，还是从上一节的图开始，假设eye ray打到的是一个玻璃球，可能会发生折射和反射，每一个弹射点都进行着色计算（都连接到光源），每个着色都会被加到这个像素点的着色上去。 里边具体的技术点会在后边讲到\nRay-Surface Intersection （隐式几何与光线求交） 首先用数学定义一下光线：光线被定义为由起点和方向的向量，点光源的描述如下，这样定义的目的是将光线与物体求交点的问题转化为求t值的问题 从简单的开始，光线如何与球求交点，其实就是点P在球面上，也在光线上，联立即可 最终可以解出来t的大小，t取小的一个，表示第一个交点 Ray Intersection With Triangle Mesh（显示几何与光线求交） 由上边内容可知，给出一个隐式的几何，求光线与几何的交点，就是联立后求t的值，那显式的表达呢？如下图，如何判断光线与mesh有交点呢？一个一个三角形求光线与三角形的交点么？太慢了 虽然太慢了，但是还是要先学习一下怎么求一个光线与三角形的交点。 基本思路就是先找出光线与三角形所在平面的交点，之后判断这个交点是不是在三角形内部。 首先来看一下平面如何定义，一个向量和一个点就可以定义一个平面 如何判断一个点P是否在平面上呢？只要下图两点形成的向量与平面法向量垂直即可 所以又回到了上一节的联立，点P在平面上，也在光线上，进行联立求解，最后得到光线与平面的交点，再判断点是否在三角形内，就可以得到光线与三角形的交点。 Möller Trumbore Algorithm 那有没有方法直接判断呢？如果光线与三角形有交点，那这个交点就可以用重心坐标表示，直接建立如下等式，用克莱默法则求解方程组就可以直接求出来（三个未知数，三个方程，所以是可以解出来的）\nAccelerating Ray-Surface Intersection（加速求交方法） 现在已经知道了光线与三角形求交，那如何判断光线与mesh的交点呢。一个一个三角形遍历太慢了。如下图三角形实在太多了。这就引入了Accelerating Ray-Surface Intersection 先介绍一个概念 Bounding Volumes，如果光线都打不到包围盒，更打不到mesh了 想象一下一个box相对的两个面是一个无限大的平面（称为对面），box就是三个对面的交集，通常使用的是Axis-Aligned Bounding Box (AABB) (轴对⻬包围盒)，这个东西就是这个盒子的边都是和坐标轴对齐的，简化计算。 下面就来看下光线与包围盒的求交问题（与包围盒有交点，才去考虑与包围盒内部的Mesh求交） 先从二维平面情况下看, $x_0$和$x_1$是一个对面，$y_0$和$y_1$是一个对面 从$x_0$和$x_1$对面上看可以求出两个交点 从$y_0$和$y_1$对面上看也可以求出两个交点 求交集后，就求出光线进入和出去盒子的时间\n从三维情况下开，3组对面的时间t进行求交集，当进入时间小于离开时间，就是有交点 另外，用上诉算法算出来的t可能是负数，用下图进行处理，总结来说，进入小于退出，退出大于0即可 最后解释一下为什么要用AABB盒，光线与平面求交点是有公式的，如下图General。对准坐标轴后计算更容易，下图为计算量对比。 Uniform Spatial Partitions (Grids) 均匀网格 通过上节的加速算法，我们已经知道如果把物体包在盒子里，先判断与盒子求交，再考虑与盒子内物体求交，但采用包围盒并不一定会提升性能，例如下面两种情况\n整个场景只有一个极其复杂的单一人物模型，那么只对这一个物体做包围盒的话，相当于对效率没有任何提升 整个场景充斥着大量的细小模型，如草，花之类的，每个模型可能只有很少的面，如果此时对每个物体求包围盒，得到的包围盒数量会相当之多，对于光线追踪效率来说效率提升有限。 所以还要对包围盒进行进一步的处理 首先来进行预处理 4. 找到包围盒 5. 画出格子（盒子内部再分成很多的格子） 6. 标记有物体的格子 预处理完成后，光线从一侧打进来，只会处理被标记的格子，如果有标记，就算一下是否有交点，这就避免了和包围盒中所有的物体求交，这种方法主要就是解决了，包围盒中有多个物体，只要光线打到了具体一个范围时，才判断是否有交点。 Uniform Grids表现比较好的场景如下 但分布特别不均匀的场景，就不适合用上述方法\nSpatial Partitions 不均匀的网格 在物体比较少地方没必要用统一大小的格子，那如何进行场景划分呢，有如下3中方式 第一种Oct-Tree，也就是八叉树，每次将空间分为8个相等的部分，再递归的对子空间进行划分。因为图中是2维例子，所以只划分了4部分。当划分的子空间足够小或是空间中三角形面的数量很少的时候会停止划分。这种方法的显著缺点是，随着维度的上升划分的空间数量会呈指数级增长。\n第二种KD-Tree，其每次将空间划分为两部分，且划分依次沿着 x − a x i s ， y − a x i s ， z − a x i s x-axis，y-axis，z-axisx−axis，y−axis，z−axis （保持最规整的空间区域），如图中所示，第一次横着将2维空间分为上下，第二次再竖着将上下两个子空间分别划分为左右部分，依次递归划分，终止条件与八叉树类似。\n第三种BSP-Tree，其与KD-Tree类似，唯一不同的是划分不再沿着固定一轴，可以任意方向划分，缺点自然是划分的空间没有规则性，求交困难。在与轴对齐更好计算的场景下不适用\n本节知识主要以KD-Tree来建立 给定一个场景，先建立KD-Tree，做好加速结构，再进行光线求交。 下面从预处理开始介绍，竖直先来一刀 水平再来一刀，蓝绿都需要做，这里演示只砍绿色 再来两刀 明白了KD-Tree的流程之后，介绍一下KD-Tree的数据结构 实际的三角形object只存在叶子结点上 下一步介绍光线来了之后如何与KD-Tree交互，如下图的这条光线 首先判断与最大的盒子是否有交点，如下图所示是有的 紧接着判断A结点的子结点1是否有交点，此时发现确实有交点，1是叶子结点，那光线就必须和这个盒子里的物体进行计算求交 之后又发现与右边也有交点，那么就需要继续看子结点 子结点2也有交点，是叶子结点，那就得和2中所有物体求交，后续流程一样 Bounding Volume Hierarchy（BVH） KD-Tree并不完美，缺点是判断包围盒与三角面的是否相交较难，因此划分的过程不是那么想象的简单，其次同一个三角面可能被不同的包围盒同时占有，这两个不同包围盒内的叶节点会同时存储这一个三角形面。 BVH不再通过场景进行划分，而是通过物体来划分，得到了广泛应用。 首先把一个包围盒内的三角形组织成两部分，并对两部分三角形重新求包围盒，如下图 之后重复操作，划分到一个叶子结点只有比如5个三角形就停止，这样一个三角形只会出现在一个包围盒里。避免了KD-Tree一个三角形可能出现在不同的叶子结点里的问题 那如何进行结点划分呢？首先向KD-Tree学习，每次选一个x、y、z其中一个轴进行划分。技巧：每次都找最长的轴进行划分，例如场景在x轴上是一个长条，就利用x轴划分。如何分成两半呢（如何保证划分后两部分三角形数量差不多呢）？通过三角形的重心坐标，在x轴上排序（其实不用排序，有更好的找中位数算法叫快速旋转算法，可以在O(n)时间内找到中位数），就知道中间那个三角形是哪个了，这样就进行了划分。 下来介绍光线与BVH求交的过程，用代码演示 划分空间vs划分物体 到这里，求交的内容就结束了\nBasic Radiometry（辐射度量学） 在之前实现 Blinn-Phong模型时，光照强度I设置为10，但是10是什么呢？\nwhited-style光线追踪只考虑了光滑面的镜面反射与折射，并没有对漫反射的光线进行追踪，而是直接返回当前着色点颜色 在计算光源直接照射的贡献时，使用了Blinn-Phong模型，而Blinn-Phong模型本身就是一个不准确的经验模型，使用的这种模型的whited-style光线追踪自身自然也是不正确的 为此，更好的渲染模型路径追踪出现了，而在这之前，我们必须掌握一些辐射度量学的知识，它是对光照的一套测量系统和单位，能够准确的描述光线的物理性质。辐射度量学是物理上准确定义光照的方法。首先来引入一些概念\n引入定义 Radiant Energy and Flux (Power) 辐射能量和辐射通量/功率 辐射能量就是光源射出来的电磁能量，单位为焦耳 （功） Radiant flux(power) 辐射能量的基础之上除以时间，也就是单位时间的能量（功率、流明） 在计算机图形学来看，主要关注一下三个东西 Radiant Intensity 辐射强度 从一个点光源发射的每单位立体角下的功率，光源向四面八方辐射能量，在单位立体角上的功率就是辐射强度 那什么是立体角呢？ 首先2维下的角度就是弧度/半径，3维空间中立体角就是一块面积/半径的平方，立体角就是用来描述空间中角有多大，一个球体总的立体角就是4pi 下图描述了单位立体角。在微分下，形成的一个很小的面积的立体角，是一个微分立体角dw，计算公式如下 最终可以得到 辐射强度 = 辐射功率/4pi Irradiance 每单位照射面积dA所接收到的power/flux 光源能量在距离上有r平方的衰减，可以用irradiance来解释，远处单位面积更大，按r平方衰减，Intensity并没有变化，单位角也没有变化，变化的是单位面积，进而影响了Irradiance\nRadiance Radiance是 单位立体角、单位面积上的power Incident Radiance是到达表面的irradiance 在 per 立体角下 Exiting Radiance 是 开表面辐射强度在per单位面积上 最后看一下Irradiance和Radiance的关系\nIrradiance是单位面积上的总能量 Radiance是总能量在单位立体角上的大小，就是在Irradiance上增加了方向性（radiance在方向上积分就是Irradiance） BRDF Bidirectional Reflectance Distribute Function 双向反射分布函数，在介绍完上边概念的定义后，可以这样理解光线的反射，一个微分面积元在接受到一定方向上的亮度后，再向不同方向把能量辐射出去，所谓BRDF就是描述一个从不同方向入射之后，反射光线分布情况的函数\n下图为BRDF的方程，只需要两个参数入射光方向 ωi，反射光方向 ωr，函数值为反射光的radiance与入射光的irradiance的比值 摄像机所在方向上的反射光，是由来自不同方向入射光线的irradiance经过反射得到的，不同方向上的入射光线的irradiance的贡献由BRDF函数决定 但是入射光的radiance不仅仅来自光源，也可能是其他物体的反射光恰好反射到了该点，这是一个递归的过程 进一步，如果这个点本身就会发光，需要额外加上这部分，最终的渲染方程为下图 如何理解渲染方程呢？ 从反射方程来看，如果有多个光源，就把这些radiance进行累加 如果有一个面光源，对这个面对应的立体角进行积分 那么更进一步再在场景当中加入其它物体，使得物体之间发生光线交互之后是什么情况呢。如下图所示，可以把其它物体同样考虑成面光源，对其所占立体角进行积分即可，只不过对其它物体的立体角积分不像是面光源所有入射方向都有radiance，物体的立体角可能只有个别几个方向有入射的radiance（即多次物体间光线反射之后才恰好照射到着色点），其它方向没有，但本质上都可以视作是面光源。 最终整理方程，只有入射和反射的radiance不知道，其他项都是知道的 简化形式 经过一系列变化后得到，L是所要求的反射光，E是自己的发光，K是一个算子 最终可以得到 最终最终整理成如下形式，E是自身发光，KE是光源反射一次结果（光源直接照射），Blinn-phong模型就只考虑到这一层，K2E是弹射一次的光照\u0026hellip;.. 下图展示了处理弹射次数变多时的变化，场景越来越亮并且逐渐收敛 这一部分就结束了，主要就是从辐射度量学角度来计算光照。直接光照和弹射后的光照混合就形成了全局光照\nMonte carlo Path Tracing 蒙特卡洛路径追踪 Monte Carlo Integration 蒙特卡洛积分 蒙特卡洛是用来解一个原函数不好解析的定积分计算 它的基本思想就是通过随机采样获得很多f(x)来进行估计 更一般情况下，概率函数取任意一个，用下边公式就可以求一个定积分 Path Tracing 路径追踪 之前已经学习了Whitted-Style Ray Tracing，从摄像机射出光线遇到物体进行镜面反射或折射，每次弹射都与光源连线，但这里面有一些不符合物理的情况 问题一： Whitted-Style假设反射时完美的，没有任何模糊或扩散，但是对于Glossy表面，现实中光线会在一个范围内散射，而不是单一方向的镜面反射 问题二：光的漫反射应该真正的向四面八方反射开，例如下边右图柱子被墙壁的红色光反射到而呈现了红色 所以最终得到Whitted-Style Ray Tracing是错的 用蒙特卡洛积分解渲染方程 考虑下图场景 解渲染方程，就是求在半球上的积分，所以可以用蒙特卡洛来解 被积函数如下，在立体角上积分，整体为半球 选择概率密度函数如下 最终要求的就是 ，在不同的立体角上进行采样，最终利用蒙特卡洛公式来估计积分值 用算法描述就是要求p点向wo方向的辐射，首先通过概率密度函数随机选择N个样本，对于每个选中的方向wi，从p点连接到wi形成一条光线，如果这个方向打到了光源，就用公式进行累加 （这里的描述只考虑直接光照），如果没打到就是采样结果为0 上边已经解决了直接光照问题，下来再解决全局光照，p点在wi方向上并没有看到光源，但是看到了Q点，这时候把P点当作摄像机，Q点当作要处理的点，对Q点进行上边介绍的处理，就能算出Q点对P点的贡献 全局光照的算法描述如下，注意红色位置 到这里还是有问题 问题一： 点越打越多，比如采样10次，如果10次采样都打到了物体上，那每个采样点又会采样10次 问题一的解决就是只采样一次 用N=1来进行积分就叫做Path Tracing，虽然在一个点位只会采样一次，但是整体路径可以进行多次，也就是从像素出发的线不只一条，但接触到物体时只采样一次 问题二：没有设置递归出口，可是真实情况光就是会弹射无数次，引入俄罗斯轮盘赌来解决，以一定的概率停止递归\n根据期望，这种概率性做法最终的期望还是Lo 最终最终的算法步骤如下： 下面看一下用这个算法生成的图，算法是正确的，但是像素采样点设置很少时（low SPP）效果并不好，在High SPP下速度又太慢 Sampling the Light 光源采样 需要的采样数与光源大小有关系，如果射出的采样光没有打到光源，贡献就是0 解决效率问题思想就是在点P不再四面八方采样，而是在光源上进行采样，现在存在的问题蒙特卡洛积分的采样概率密度函数是在P点半球面的采样，需要把公式转换成在光源上采样的形式 从数学上说就是dw的积分改为dA积分，需要先研究dw和dA的关系，就是用两者的关系进行换元 最终重写渲染方程，就是把在立体角上的采样换成了在光源面积上采样 到这还有一个小小问题 如果光源被挡住呢？ 点光源路径追踪不好处理，建议改成小面积的面光源\n最后来感受一下Path Tracing的强大 如何渲染一张图：要么光栅化要么光线追踪\n","date":"2025-10-19T12:43:12+08:00","image":"https://sdpyy1.github.io/a6d366b54c3a45b38beb9e550a1ea53b.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-ray-tracing/","title":"GAMES101 现代计算机图形学入门 Ray Tracing"},{"content":"Implicit and explicit Implicit 是并不会告诉任何点的信息，只会告诉该曲面上所有点满足的关系 $$ f(x,y,z)=0 $$ 判断所给出的隐式方程描述的是一个怎样的形状十分困难 可以十分容易的判断出一点与曲面的关系 example for Implicit 代数方法描述 Constructive Solid Geometry（CSG 通过基本几何的运算定义新的几何） Signed Distance Functions（空间任何点到物体表面的最短距离来描述，用正负表示在物体外还是内） 在2D情况，看下图就很好理解了 图片来自https://blog.csdn.net/CODE_RabbitV/article/details/140288664\n当两个距离函数进行融合后后，找到所有值为0的位置，就是融合后的物体表面\n分形 explicit 所有曲面的点被直接给出，或者可以通过映射关系直接得到 例如： $$ f(u,v) = ((2+cosu)cosv,(2+cosu)sinv,sinu) $$ 把uv都遍历一遍就可以拿到所有的(x,y,z)\n因根据需要选择一种表达方式\nExampl For Explicit Point Cloud（点云）全是点组成的模型 Polygon Mesh（多边形面） 计算机中用三角形面表示的文件 .obj文件 Curves 曲线 贝塞尔曲线（Bézier Curves） 给定一系列控制点，画出曲线，下图是实际例子 de Casteljau实现 考虑3个控制点（quadratic Bezier） 给定一个比例t，把两个线段都根据t截开\n得到的两个点连起来再执行相同操作 最后只有一个点了，就确定了曲线一个点的位置 把所有的t都找一遍，就画出了曲线，见下图，想象一下绿色和蓝色的线跟着t的变化一直变化，就会画出蓝色的线 可以用表达式表达出每个点，用t去截线段，其实就是做一个插值计算 最终总结，给定n+1个控制点，比例t的情况下，可以得到n阶的贝塞尔曲线 贝塞尔曲线的一些性质：\n必定经过起始与终止控制点 必定经与起始与终止线段相切 具有仿射变换性质，可以通过移动控制点移动整条曲线 凸包性质，曲线一定不会超出所有控制点构成的多边形范围 什么是凸包（convex hull）？想象墙上有许多图钉，我们需要用一根橡皮筋把这些图钉围住，于是我们开始会让橡皮筋尽可能的撑大，然后松手放开橡皮筋，它所形成的多边形即为凸包，如图中蓝色曲线所示 贝塞尔曲线在控制点过多时，不好控制，如下图所示 于是就有了Piecewise Bézier Curves，取一部分点做，然后结合起来，如下图 贝塞尔曲线的连续性 C0连续，就是控制点重合 C1连续就是达到了曲面光滑，实现方法看下图红线 Surfaces 曲面 贝塞尔曲面 通过贝塞尔曲线获得贝塞尔曲面的方法： 如下图已经画出了四条贝塞尔曲线，在t取某一值下生成的4个点，把这4个点作为控制点再画一条曲线，这样t变化时，这条线就绘制成了一个面 Mesh Operation Mesh subdivision 曲面细分 引入更多的三角形， Loop Subdivision 增多三角形数量，如下如 调整细分后新的三角形顶点处理，更新一条边的中心点，如下图的白点，对这个点的处理，通过上下左右四个顶点加权平均（这个权重是具体的算法，这里不做解释） 旧的顶点呢，如下图白点就是一个旧顶点，更新算法包括自己本来的位置，以及周围顶点进行加权平均 Catmull-Clark Subdivision 一般的模型不一定全是三角形，就没法用loop subdivision，Catmull-Clark可用于一般情况，如下图有正方形也有三角形 先定义两个表达，非四边形面和奇异点（度不等于 4的点） 如上图框所示，每条边和每个面都取钟中点，然后把这些点连起来，如下图所示 连接后，奇异点变多了，非四边形都消失了，此时奇异点是4个，但是如果继续细分，奇异点数目并不会继续增加，如下图还是4个 与loop一样，调整完后需要对顶点位置进行处理，处理方法如下 下图为两种方法对比 Mesh Simplification 曲面简化 当模型距离很远时，模型的细节就没必要表现出来\nCollapsing An Edge 边坍缩 那哪些边可以进行坍缩，如果度量呢？下图进行了度量，首先5个顶点进行平均效果并不好， 用2次误差方法来实现：即坍缩之后蓝色新顶点所在的位置与原来各个平面的垂直距离之和。\n为模型每条边赋值，其值为坍缩这条边之后，代替两个老顶点的新顶点所能得到的最小二次误差度量 选取权值最小的边做坍缩，新顶点位置为原来计算得出使得二次误差最小的位置 坍缩完之后，与之相连其他的边的位置会改动，更新这些边的权值 重复上述步骤，直到到达终止条件 一些坍缩的实例 几何部分就这些东西了～ ","date":"2025-10-19T12:37:17+08:00","image":"https://sdpyy1.github.io/3b0a2bb693ef4b20a6ca0414d87ae566.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-geometry/","title":"GAMES101 现代计算机图形学入门 Geometry"},{"content":"什么是shading？ shading 负责计算物体表面每个采样点的颜色，具体考虑光照、材质属性、观察角度等因素，生成具有真实感的视觉效果（如漫反射、镜面高光、环境光等）‌，之前的光栅化将是几何图元（如三角形）转换为屏幕上的像素，确定哪些像素被图元覆盖，是几何层面的处理\nBlinn-Phong Reflectance Model(A Simple Shading Model) Blinn-Phong 反射模型（Blinn-Phong Reflectance Model）是一种用于计算机图形学中模拟物体表面光照效果的着色模型.该模型主要由**环境光(Ambient)、漫反射(Diffuse)和高光反射(Specular)**三部分组成。 定义一些基本向量，默认为单位向量：\nViewer Direction ：观察方向,用v\t表示 Surface normal：法线方向，使用n表示 Light direction：光线方向，用l表示 Diffuse Reflection（漫反射） 是投射在粗糙表面上的光向各个方向反射的现象。当一束平行的入射光线射到粗糙的表面时，表面会把光线向着四面八方反射，所以入射线虽然互相平行，由于各点的法线方向不一致，造成反射光线向不同的方向无规则地反射，这种反射称之为“漫反射”或“漫射”。正是因为反射是完全随机的，因此可以认为漫反射光在任何反射方向上的分布都是一样的。 漫反射强度影响因素：\n入射光线与法线的夹角 lambert's cosine law: 当夹角为θ时，能量要乘cosθ = l.dot(v)(单位向量) 入射光线自身的强度 入射光自身的强度与距离有关 结合两个影响因素，得到漫反射能量公式 $$L_d = k_d (I / r^2) \\max(0, \\mathbf{n} \\cdot \\mathbf{l})$$ 其中$k_d$表示反射系数指光（入射光）投向物体时，其表面反射光的强度与入射光的强度之比值，受入射光的投射角度、强度、波长、物体表面材料的性质以及反射光的测量角度等因素影响。一般来讲，在颜色系列中，黑色的反射系数较小，为0.03，白色的反射系数较大，为0.8 最后，漫反射与观察方向v没有关系，漫反射能量会均匀反射到各个方向上去。\nSpecular highlights(高光/镜面反射) 若物体表面很光滑，光线会被反射，如图所示R为镜面反射方向，观察方向v与反射方向R接近时，能看见反射光，反射光强度与R和v的夹角有关 R和v的夹角 等价于l和v的half vector与法线方向n之间的夹角，通过向量相加的平行四边形法则获取到half vector，再单位化 最终得到镜面反射能量公式 $$ \\begin{align*} L_s \u0026= k_s (I / r^2) \\max(0, \\cos\\alpha)^p \\\\ \u0026= k_s (I / r^2) \\max(0, \\mathbf{n} \\cdot \\mathbf{h})^p \\end{align*} $$ 其中$k_s$为镜面反射系数，通常取白色的系数\n因为镜面反射对角度很敏感，所以设置参数p来控制夹角的影响 Ambient Term（环境光） 环境光不依赖任何项，任何方向射入，任何方向观察都不影响环境光的强度（该模型的简化，精确计算需要用到全局光照技术） 最后，展示结合效果 Shading Frequencies（着色频率） 3个几何形状完全相同的球，为什么着色结果各不相同？计算作色的频率不同，球1一个平面取一个法线，做一次着色，球3每个像素都求法线后都做一次着色 shade each triangle(flat shading)：每个三角形求一个法线（边向量叉积），对整个三角形进行相同的shading，当三角形面很多，效果也不一定差，反而计算量少 shade each vertex（Gouraud shading）：三角形的三个顶点都求法线，三个顶点进行shading，三角形内部用插值填充 shade each pixel(Phong shading)：每个像素都求法线，每个像素都进行shading 如何求顶点的法线？利用顶点关联的面的法线进行加权平均\nGraphics（Real time Rendering）Pipeline Pipeline：从场景到图的流程 ⚠️：Shading根据Shading Frequencies（着色频率）的不同发生在不同阶段，如果是每个顶点进行着色，就发生在顶点处理阶段，如果是每个像素shading么就在光栅化之后，下面图也解释的shader编程中的vertexShader和fragmentShader GPU模型 Texture Mapping 考虑球和地板，不同的位置有不同的颜色，每个位置都有自己的漫反射系数，引入纹理映射对每一个点定义不同的属性 模型上任意一个三角形的顶点，都能在纹理图找到对应的顶点。 至于如何一一对应，是美工的任务。 纹理图通过UV坐标来进行定位（UV取值范围都是0-1之间），这样每个三角形的顶点都对应一个UV坐标 如果我已经知道每个三角形顶点对于的UV坐标，那如何知道三角形内部每个点的UV坐标呢？ （插值解决）\n如何插值？ 为什么要用插值？因为我们希望处理完顶点后，三角形内部进行平滑的过度 首先引入重心坐标 Barycentric Coordinates 重心坐标可以表示为顶点的线性组合（如下图所示），三角形内任意一点的坐标(x,y)，找到满足$\\alpha+\\beta+\\gamma=1$ 和 (x, y) = $\\alpha A + \\beta B + \\gamma C$的（$\\alpha$,$\\beta$,$\\gamma$）, 就可以用（$\\alpha$,$\\beta$,$\\gamma$）表示该点的重心坐标 重心坐标（$\\alpha$,$\\beta$,$\\gamma$）可以通过面积比求出，如下图 由此可得三角形的重心的重心坐标为(1/3,1/3,1/3), 除此之外，也有直接的公式求出任意一点的重心坐标，如下图 其实重心坐标显示出的内容就是该点对于三角形三个顶点的权重应该是多少。（$\\alpha$,$\\beta$,$\\gamma$）就分别是A、B、C顶点的权重。 ⚠️：投影后重心坐标会变化\nTexture Magnification（纹理图太小） A pixel on a texture - a texel(纹理元素、纹素) 场景：纹理图片22，但需要把它渲染到44的屏幕，那在渲染中间像素时，他的UV坐标无法对应texel，所以需要处理，下图为三种处理方式 Nearest 落在哪个texel附近，就选择哪个texel Bilinear Interpolation 综合考虑周围4个texel的值，水平方向得到两个插值，再垂直方向再做一次插值，所以叫双线性插值，插值函数就是 $$lerp(x,v_0,v_1) = v_0 + x(v_1 - v_0)$$ Bicubic 更复杂的插值，取周围16的texel\nTexture Magnification（纹理图太大） 例如纹理图被重复使用用来帖地板 如果只进行简单操作，得到结果为 问题的原因：在远处一个Pixel就覆盖了多个texel，下图大框是Pixel，在远处，一个像素就覆盖了多个纹理，很大范围只采样一次，就会导致信息丢失，导致走样。最简单解决思路就是Supersampling，采样点增多，获得的信息就多，但计算量太大 Mipmap 为一张纹理图生成多个低分辨率纹理图，每次分辨率减半，将4个相邻像素点求均值合为一个像素点。从存储容量上看，根据等比数列求和，存储只增加了$1/3$ 那在映射时应该选择哪一Level 作为纹理呢？利用屏幕相邻的像素点的距离估计纹理的大小，如图红色像素距离上和右两个像素的距离反应到纹理图上的距离变大的程度来选择，取最大\n但通过$D = log_2L$计算的是一个连续值，并不一定直接表面使用第几层，如何处理？ 使用三线性插值，例如D=1.8，就在第1层和第2层分别做一次双线性插值，最后根据D的大小，对两次插值结果再插值一次\n使用该技术得到结果，如下图所示，远处仍然比较模糊\n各向异性过滤 Mipmap所规定的区域查询 必须是正方形，而纹理映射中可不仅仅只有正方形，如下图所示，某些像素对应的纹理图位置并不是正方形，针对这种情况，有时候需要水平方向的高Level，有时候需要竖直方向上的高Level，因此也启发了各向异性过滤，生成各种方向上压缩的材质，最终存储容量收敛到原来的3倍 看下图一个像素点在纹理上对应的并不是一个正方形区域，使用Mipmap就不合适了 纹理的应用 Environment Map 纹理不一定非得一副图片，可以用纹理描述环境光效果 Bump Mapping 凹凸贴图 欺骗人眼，看着像是用模型做出来的凹凸效果，其实只是材质的原因。在贴图上定义每个点的高度进而改变法向量，这就回影响shading，因为法线参与了color生成过程。\nDisplacement Mapping 位移贴图 真的去移动了顶点，Bump会露馅，因为最外圈显然是一个完整的球。Bump Maps是逻辑上的高度改变，它实则并没有改变内部的模型，是假的改变；而Displacement Map则是物理上的高度改变。它真正改变了模型。二者的区别就在此处，可以通过物体阴影的边缘发现这点 3维纹理 ","date":"2025-10-19T00:57:01+08:00","image":"https://sdpyy1.github.io/8794c0d329d24aca97c68998d2945da5.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-shading/","title":"GAMES101 现代计算机图形学入门 Shading"},{"content":"线性代数复习 向量 首先介绍一下向量，有长度有方向，起始位置不固定 向量归一化，就是获得向量方向上的单位向量，后续课程各种操作都是在单位向量上进行 向量加法 点乘和叉乘 向量点乘，在后续课程中用来计算cosθ，如果两个单位向量点乘结果就是cosθ 在坐标系中的使用，就是对应坐标相乘再相加 点乘可以用来投影 向量叉乘 在坐标系中的使用，有公式 在图形中的使用是可以判断一个点是否在三角形内部，在光栅化时用来判断一个像素是否需要被渲染 矩阵 矩阵就不说了，比较容易\nTransformation（变换） Model Transformation 模型变换可以做缩放、旋转、平移 通过矩阵计算实现的缩放 旋转 笔记保存 games101课程光栅化之前的笔记，发现还是直接写博客方便，后续用CSDN完成笔记，前边部分先贴在这里，以后再补充 这是之前遗留的笔记，放在这里保存 ","date":"2025-10-19T00:47:26+08:00","image":"https://sdpyy1.github.io/3d23bd7f8fdc4adfa5ef11d68f2d98fd.png","permalink":"https://sdpyy1.github.io/p/games101-%E7%8E%B0%E4%BB%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9B%BE%E5%BD%A2%E5%AD%A6%E5%85%A5%E9%97%A8-transformation-rasterization/","title":"GAMES101 现代计算机图形学入门 Transformation \u0026 Rasterization"}]