[{"content":"注意，本文也存在未能找出原因的反常结果。\n用多少顶点来绘制一个三角形效率最高 我们知道一个三角形就三个顶点，而且我们也知道一个网格的顶点数量越少其渲染所需时间越少。那么对于一个三角形来说，用三个顶点来绘制，效率就一定最高吗？\n初步分析 如果对软光栅化有基本的了解，那么应该知道，一个三角形在光栅化之前，需要基于其NDC空间的坐标确定一个AABB。然后我们再对这个AABB所包含的全部像素进行逐像素遍历，并基于Top-Left规则判断像素是否属于三角形内部(我们默认考虑的是标准光栅化，这里不考虑保守光栅化)。\n那么很自然的，对任意的一个如下图所示的三角形$\\triangle ABC$，\n我们可以用一条水平线段$DC$将三角形分为两个$\\triangle ADC$与$\\triangle DBC$，\n很显然，这两个分割后的三角形的包围盒要比原始的三角形的包围盒小不少(可以剩下不少需要遍历的像素)。那么渲染这样分割后的三角形是否会比一整个的三角形更快呢？当然，我们也需要考虑到光栅化之前的顶点操作：只要post-transform cache起作用了(像这里我们绘制时提供索引的buffer)，那么在顶点提交环节实际上也就多计算了一个顶点而已。\n于是，我们考虑绘制1000个如下三角形到一个8192x8192的纹理中(用Size=5的正交相机，并用PCG Hash实现固定的伪随机位置)，\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const float k_triangleSize = 0.5f; { m_SingleTriangle = new Mesh(); m_SingleTriangle.vertices = new Vector3[] { new Vector3(-k_triangleSize, -k_triangleSize, 0), new Vector3(k_triangleSize, k_triangleSize, 0), new Vector3(k_triangleSize, 0, 0) }; m_SingleTriangle.triangles = new int[] { 0, 1, 2 }; m_SingleTriangle.RecalculateBounds(); } { m_SplitTriangle = new Mesh(); m_SplitTriangle.vertices = new Vector3[] { new Vector3(-k_triangleSize, -k_triangleSize, 0), new Vector3(0, 0, 0), new Vector3(k_triangleSize, 0, 0), new Vector3(k_triangleSize, k_triangleSize, 0), }; m_SplitTriangle.triangles = new int[] { 0, 1, 2, 1, 3, 2 }; } 考虑到用Profiler工具抓取单帧的结果误差会很大，所以我们就直接用unity的CustomSampler来采集绘制开销。在AMD RX6600下，我们采集了100000帧的绘制开销(运行30秒后再采集，保证GPU处于预热状态)，结果如下，\n可以看到用分割后的三角形渲染比用完整的三角形渲染，确实能更快一些。\n光栅化流程的加速 我们上面测试的三角形在分割后，其包围盒面积是完整的$75%$，如果硬件光栅化的过程完全与我们最开提到的软光栅化的流程一样，我们这种“优化”应该能有很显著的性能提升才对。但是实际上提升的并不明显(可能开销就减少了10us左右)。\n原因在于，硬件光栅化的过程比我们上面说的软光栅化的过程要复杂多了(实际上工业级的软光栅化也是如此)。比如[1]Laine,Samuli,et al (2011)中设计的软光栅化管线\n就是由多个用于分配的\u0026quot;层级\u0026quot;组成的。因此，以上图所示的管线为例，我们实际做的不过是减少了Bin Rasterizer与Coarse Rasterizer的少量工作，自然没有特别大的性能提升。事实上，上面的软光栅化管线即使面对当年的硬件光栅化管线，也有不少的差距，更别说现在的硬件了。\n我们用同样的方式在Nvidia的RTX4070下测试可得\n可以看到完整三角形的绘制比分割后的三角形开销要少太多了，这表明N卡硬件光栅化的低、中层级的分配与剔除阶段的性能极为优秀。不过，为什么分割后的渲染开销居然是完整的2倍还不止，甚至(似乎)比RX6600的还差？当然，unity的CustomSampler得到的可能并不是完整的绘制开销，因此我们用Nsight分别抓帧来对比一下(从unity中采集的结果来看，N卡的渲染开销还是比较稳定的，所以我们抓的单帧还是有一定参考性的)。\n我们可以看到分离后渲染的Warp利用率波动很大，且存在利用率很低的情况。我们用Nsight的Trace Analysis View来查看可能的原因，\n可以看到分割的三角形的光栅化相关的吞吐量都小于完整的三角形的，但VRAM吞吐量却相对较高。注意到分离的三角形的Timeline中，VRAM与L2带宽存在两个(更准确点是四个)明显的峰值，因此有可能是因为我们渲染目标的尺寸太大了(8192x8192)，导致ROP单元在输出颜色与深度时出现瓶颈。仔细一想，我在配置这些三角形的时候是由近及远的，那么如果反着分配一下呢(由远及近)？\n可以看到，尽管吞吐量和利用率有所提升，但由远及近的渲染排序导致 overdraw 过于严重，进而大幅增加了开销(光栅化的吞吐量更低了)。但是这里就又引出了一个奇怪的点，为什么这时候并未出现带宽上的瓶颈期呢？这一点暂时猜测为：因分离的三角形的重合边的early z效率低下导致的。\nReferences [1]High-Performance Software Rasterization on GPUs\n光栅化的硬件深度与PS中的硬件深度 VFX中我们经常会有在PS中用NDC坐标获得屏幕坐标或是深度的需求，那么一个很自然的问题就产生了：PS中计算得到的深度，如果在PS中输出至SV_Depth，会和直接通过硬件光栅化流程输出至Z-Buffer的效果完全等效吗？\n要验证上面这一点，我们只需两个Pass：\n第一个Pass：我们利用NDC坐标向SV_Depth写入PS中的硬件深度 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 CGINCLUDE #include \u0026#34;UnityCG.cginc\u0026#34; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; float4 vert : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.vert = o.vertex; return o; } ENDCG Pass { CGPROGRAM #pragma vertex vert #pragma fragment fragDepth float fragDepth (v2f i) : SV_Depth { return i.vert.z / i.vert.w;//PS中的硬件深度 } ENDCG } 第二个Pass：则利用ZTest Equal来对比深度即可 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Pass { ZTest Equal ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag fixed4 frag (v2f i) : SV_Target { return 1; } ENDCG } 如果深度相等，那么对指定的网格，其渲染得到的结果应该是全白的。但事实并非如此，\n如果我们把渲染目标的尺寸设为32x32的，然后从GPU中回读这两个深度的位模式(以下我们取一段片段)\n硬件深度 00000000 00000000 00000000 3E250C66 3E233F3F 3E217219 3E1FA4F2 3E1DD7CC 3E1C0AA5 3E1A3D7F 3E187059 3E16A332 3E14D60C 3E1308E5 3E113BBF 3E0F6E98 00000000 00000000 00000000 PS中的硬件深度 00000000 00000000 00000000 3E250C66 3E233F40 3E21721A 3E1FA4F2 3E1DD7CB 3E1C0AA6 3E1A3D7F 3E187059 3E16A333 3E14D60D 3E1308E6 3E113BC0 3E0F6E99 00000000 00000000 00000000 可以看到确实有深度存在非常微小的差异：相差个0x00000001。我没法保证这样的误差完全是由以下原因造成的，不过应该是造成这种误差的主要来源。我们设$v_a, v_b$是顶点$a$与$b$的NDC坐标，那么对线段$ab$中的一点$c$，其硬件深度应该由顶点$a$与$b$的深度插值得到，即 $$ d_{HW}=lerp\\left(\\frac{v_a.z}{v_a.w},\\frac{v_b.z}{v_b.w},t\\right), $$ 而在PS中，需要经过插值(默认的)再进行透视除法，即 $$ d_{PS}=\\frac{lerp(v_a,v_b,t).z}{lerp(v_a,v_b,t).w}, $$ 数学上来说这两个公式是一样的，但是浮点数因其精度问题，确实会因此产生极小的误差。\n三角形的定向与索引顺序 我们知道，三角形的索引顺序决定了一个三角形的\u0026quot;正面\u0026quot;，那么一个自然的想法就是：如果一个三角形只绘制背面，然后我们在GS中手动调整顺序，使其\u0026quot;反向\u0026quot;(也就是将$1,2,3$改为$1,3,2$)，那么这样获得的三角形与正常只绘制正面的三角形应该是一样的。于是我们就按照这样的思路实现两个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 CGINCLUDE #include \u0026#34;UnityCG.cginc\u0026#34; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); return o; } struct v2g { float4 vertex : SV_POSITION; }; struct g2f { float4 vertex : SV_POSITION; }; v2g vertBack(appdata v) { v2g o; o.vertex = UnityObjectToClipPos(v.vertex); return o; } [maxvertexcount(3)] void geom(triangle v2g IN[3], inout TriangleStream\u0026lt;g2f\u0026gt; OUT) { g2f o; o.vertex = IN[0].vertex; OUT.Append(o); o.vertex = IN[2].vertex; OUT.Append(o); o.vertex = IN[1].vertex; OUT.Append(o); OUT.RestartStrip(); } fixed4 frag (v2f i) : SV_Target { return 0; } ENDCG Pass { Cull Back ColorMask 0 CGPROGRAM #pragma vertex vert #pragma fragment frag ENDCG } Pass { Cull Front ColorMask 0 CGPROGRAM #pragma vertex vertBack #pragma geometry geom #pragma fragment frag ENDCG } 然后我们再用一个Pass来比较深度：如果是\u0026quot;天空盒\u0026quot;则返回黑色，如果是相同的则返回蓝色，否则，返回白色。在N卡中，我们用于渲染的网格的区域总是蓝色的，也就是说我们的假设是成立的。但是在AMD RX6600下，事情就变得有趣起来了\nYour browser doesn't support HTML5 video. Here is a link to the video instead. 这难不成是渲染界的宇称不守恒?\n","date":"2025-09-20T00:00:00Z","permalink":"https://RicciFloOow.github.io.com/p/%E6%9D%82%E8%AE%B0%E6%B8%B2%E6%9F%93%E4%B8%AD%E4%B8%80%E4%BA%9B%E5%8F%8D%E7%9B%B4%E8%A7%89%E7%9A%84%E7%BB%93%E6%9E%9C/","title":"杂记：渲染中一些反直觉的结果"},{"content":"描边是很多NPR渲染中必不可少的一部分，主流的方案大致有三种：\n多Pass绘制背面描边 后处理——基于深度、法线的边缘检测 基于网格的共享边计算倒角边(Silhouette Edge)来绘制\u0026quot;线\u0026quot;(实际绘制的是quad) 当然，需要注意的是，我们基于法线与视线方向的内积得到的像是描边的效果应该是边缘光(Rim Lighting)，而不是描边(当然，作为fallback倒是可以的)。此外，如果是2D的描边，一般更多会用SDF来实现。\n项目地址: RicciFloOow/Outline-Analysis: 三种主流描边方案\n多Pass描边 实现原理 多Pass描边的原理就是将网格\u0026quot;外扩\u0026quot;，然后计算\u0026quot;外扩\u0026quot;后的渲染结果与正常渲染结果的差集，该差集即为描边区域。由于NPR渲染的对象通常是前向渲染的，所以多Pass描边的方案并不会对渲染管线造成很大的影响。\n对于球面、立方体这类简单的几何体，\u0026ldquo;外扩\u0026quot;似乎就是整体放大一些。不过我们只要考虑稍微复杂一点的几何体，比如环面，就能发现单纯的将比例放大是不合理的。因此，我们应该考虑几何体表面局部的信息，再结合\u0026quot;向外\u0026rdquo;(这种带方向的属性)，很自然的能联想到该用法线来实现\u0026quot;外扩\u0026quot;。至于实现差集，我们通过绘制\u0026quot;外扩\u0026quot;后的背面(剔除正面)，并绘制正常的正面(默认剔除背面)，自然就能利用ZTest得到两者的差集(注意，除非特殊情况，否则我们不需要管这个两个Pass的顺序)。\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 SubShader { Pass { Cull Back //正常渲染, 省略 } Pass { Cull Front//实现差集的核心 CGPROGRAM #pragma vertex vertOutline #pragma fragment fragOutline #include \u0026#34;UnityCG.cginc\u0026#34; struct v2fBase { float4 vertex : SV_POSITION; }; struct appdataBase { float4 vertex : POSITION; float3 normal : NORMAL; }; float _OutlineWidth; half4 _OutlineColor; v2fBase vertBase (appdataBase v) { float4 wPos = mul(unity_ObjectToWorld, float4(v.vertex.xyz + v.normal * _OutlineWidth, 1.0)); v2fBase o; o.vertex = mul(UNITY_MATRIX_VP, wPos); return o; } fixed4 fragOutline (v2fBase i) : SV_Target { return _OutlineColor; } ENDCG } } 但很显然，上面的方案在几何体非等比例拉伸的情况下就会出现明显失真了，于是我们自然的会去考虑在世界空间下外扩，即VS内的改为\n1 2 3 4 5 6 7 8 9 float4 wPos = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0)); float3x3 tW2OMat = (float3x3)transpose(unity_WorldToObject); float3 normal = mul(tW2OMat, v.normal);//转到世界空间 // wPos.xyz += normal * _OutlineWidth; // v2fBase o; o.vertex = mul(UNITY_MATRIX_VP, wPos); return o; 但是这样做仍然存在问题：在透视相机下，相同的的描边宽度(\u0026ldquo;外扩\u0026quot;距离)会出现近处粗而远处细的问题。那么解决这种问题的办法就是在裁剪空间中\u0026quot;外扩\u0026rdquo;。\n1 2 3 4 5 6 7 8 9 10 11 12 v2fBase o; float4 wPos = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0)); float4 clipPos = mul(UNITY_MATRIX_VP, wPos); o.vertex = clipPos; float3x3 tW2OMat = (float3x3)transpose(unity_WorldToObject); float3 normal = mul((float3x3)UNITY_MATRIX_VP, mul(tW2OMat, v.normal)); // float2 nOffset = normalize(normal.xy); nOffset.x *= (_ScreenParams.y / _ScreenParams.x); float2 offset = nOffset * _OutlineWidth / _ScreenParams.y * 2.0; o.vertex.xy += offset * o.vertex.w; // return o; 于是我们可以得到\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 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 public struct GeoPoint : IEquatable\u0026lt;GeoPoint\u0026gt; { public Vector3 Pos; private const float K_ToLerance = 1e-5f; private const float K_SqrToLerance = K_ToLerance * K_ToLerance; public GeoPoint(Vector3 pos) { Pos = pos; } public bool Equals(GeoPoint other) { return (Pos - other.Pos).magnitude \u0026lt; K_SqrToLerance; } public override bool Equals(object obj) { if (obj is GeoPoint) { return Equals((GeoPoint)obj); } return false; } public override int GetHashCode() { int x = Mathf.RoundToInt(Pos.x / K_ToLerance); int y = Mathf.RoundToInt(Pos.y / K_ToLerance); int z = Mathf.RoundToInt(Pos.z / K_ToLerance); return x.GetHashCode() ^ y.GetHashCode() \u0026lt;\u0026lt; 2 ^ z.GetHashCode() \u0026gt;\u0026gt; 2; } } public struct GeoEdge : IEquatable\u0026lt;GeoEdge\u0026gt; { public GeoPoint v0; public GeoPoint v1; public GeoEdge(GeoPoint v0, GeoPoint v1) { this.v0 = v0; this.v1 = v1; } public bool Equals(GeoEdge other) { return (v0.Equals(other.v1) \u0026amp;\u0026amp; v1.Equals(other.v0)) || (v0.Equals(other.v0) \u0026amp;\u0026amp; v1.Equals(other.v1)); } public override int GetHashCode() { return v0.GetHashCode() ^ v1.GetHashCode(); } } 然后我们就可以很容易得到平均法线了\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 public static Vector3[] GetAvgNormal(Vector3[] vertices, Vector3[] ns) { Vector3[] normals = new Vector3[vertices.Length]; // Dictionary\u0026lt;GeoPoint, List\u0026lt;int\u0026gt;\u0026gt; vertTriDict = new Dictionary\u0026lt;GeoPoint, List\u0026lt;int\u0026gt;\u0026gt;(); for (int i = 0; i \u0026lt; vertices.Length; i++) { GeoPoint geoPoint = new GeoPoint(vertices[i]); if (!vertTriDict.ContainsKey(geoPoint)) { vertTriDict[geoPoint] = new List\u0026lt;int\u0026gt;(); } vertTriDict[geoPoint].Add(i); } // foreach (var vt in vertTriDict) { Vector3 sumNormal = Vector3.zero; List\u0026lt;int\u0026gt; indices = vt.Value; foreach (int index in indices) { //注意，这里加权(比如基于三角形面积)反而结果会不好 sumNormal += ns[index]; } sumNormal = sumNormal.normalized;//TODO: 更好的归一化 foreach (int index in indices) { normals[index] = sumNormal; } } // return normals; } 其中传入的参数ns是基于顶点重建的法线(两个边的方向按顺序叉乘得到)。我们用这样的法线传入VS中\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 float3 normal = _FixedNormalBuffer[vertID]; v2fBase o; float4 wPos = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0)); float4 clipPos = mul(UNITY_MATRIX_VP, wPos); o.vertex = clipPos; float3x3 tW2OMat = (float3x3)transpose(unity_WorldToObject); normal = mul((float3x3)UNITY_MATRIX_VP, mul(tW2OMat, normal)); // float2 nOffset = normalize(fixedNormal.xy); nOffset.x *= (_ScreenParams.y / _ScreenParams.x); float2 offset = nOffset * _OutlineWidth * / _ScreenParams.y * 2.0; o.vertex.xy += offset * o.vertex.w; o.uv = v.uv; return o; 其中uint vertID : SV_VertexID，可得\n我们用模之屋上IcePaper提供的芙露德莉斯模型来看一下是否使用平均法线的区别(参数都相同的情况下)\n不难发现，细节上存在明显的区别。\n需要注意的是，对于Skinned Mesh，其顶点坐标、法线以及切线都是受骨骼动画影响的，又由于自带的法线与切线通常是必要的，因此我们一般用两种方案来处理平均法线：\n和我们项目里用的类似，使用StructuredBuffer或是GraphicsBuffer，然后自行实现GPU蒙皮并计算实时的平均法线方向。个人推荐这种，因为这种方案灵活性更高，对其他效果支持也更方便。 另一种则用的是类似法线贴图的方法，我们计算出平均法线后，利用自带的法线、切线、副法线构成的切空间得到其坐标，然后存到顶点色或是别的UV通道里。在VS里利用蒙皮后的法线与切线重建切空间，重新算出平均法线的方向。 方案局限性 性能差 尽管Early Z似乎能在一定程度上减少多Pass描边的开销，但是仍会有大量无效的片元在光栅化阶段生成。而且通常描边的PS是不复杂的，很多可能人用的和我们演示的一样，就返回预设的描边颜色，没有别的采样。因此我们几乎可以认为，这是100%overdraw的。从性能优化上来说，这是一个非常糟糕的方案。\nBlend Shape描边可能的异常 Blend Shape的原理是通过对少量顶点(组)进行插值，从而实现网格连续变化的过程。所以通常Blend Shape只记录那些发生变化的顶点的位置信息，有些做的好一点的也会记录法线信息，但一般都不会再记录切线信息，因此不论我们用哪种方式记录修复的平均法线，都会因为信息的不全而产生失真，从而带来可能的错误描边。\n单面边缘描边无效 我们先来看铃芽之旅中的一幕，可以看到铃芽的校服袖子处是存在描边的\n而下图(素材来源[2])框中的袖子的边界却并没有描边\n粗略的分析，似乎是因为边界处法线仍然是垂直表面的而不是垂直于法线朝\u0026quot;外\u0026quot;的。那么我们通过下面的方式修改平均法线，使其在边缘处朝\u0026quot;外\u0026quot;\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 private static void AddEdgeTriangle(ref Dictionary\u0026lt;GeoEdge, List\u0026lt;int\u0026gt;\u0026gt; edgeCounter, GeoPoint p0, GeoPoint p1, int tri) { var edge = new GeoEdge(p0, p1); if (edgeCounter.ContainsKey(edge)) { edgeCounter[edge].Add(tri); } else { edgeCounter.Add(edge, new List\u0026lt;int\u0026gt;() { tri }); } } /// \u0026lt;summary\u0026gt; /// 获得边界上的点: 如果一条边不被两个三角形共享，那么其顶点就是边界上的点 /// \u0026lt;/summary\u0026gt; /// \u0026lt;param name=\u0026#34;triangles\u0026#34;\u0026gt;\u0026lt;/param\u0026gt; /// \u0026lt;param name=\u0026#34;vertices\u0026#34;\u0026gt;\u0026lt;/param\u0026gt; /// \u0026lt;returns\u0026gt;\u0026lt;/returns\u0026gt; private static Dictionary\u0026lt;GeoPoint, List\u0026lt;GeoEdge\u0026gt;\u0026gt; GetBoundaryGeoPoints(int[] triangles, Vector3[] vertices, out Dictionary\u0026lt;GeoEdge, List\u0026lt;int\u0026gt;\u0026gt; edgeCounter) { edgeCounter = new Dictionary\u0026lt;GeoEdge, List\u0026lt;int\u0026gt;\u0026gt;();//list中记录三角形索引 // int trianglesCount = triangles.Length / 3; for (int i = 0; i \u0026lt; trianglesCount; i++) { int v0 = triangles[i * 3]; int v1 = triangles[i * 3 + 1]; int v2 = triangles[i * 3 + 2]; // var p0 = new GeoPoint(vertices[v0]); var p1 = new GeoPoint(vertices[v1]); var p2 = new GeoPoint(vertices[v2]); // AddEdgeTriangle(ref edgeCounter, p0, p1, i); AddEdgeTriangle(ref edgeCounter, p1, p2, i); AddEdgeTriangle(ref edgeCounter, p2, p0, i); } // var boundaryPoints = new Dictionary\u0026lt;GeoPoint, List\u0026lt;GeoEdge\u0026gt;\u0026gt;(); // foreach (var edge in edgeCounter) { if (edge.Value.Count == 1) { if (boundaryPoints.ContainsKey(edge.Key.v0)) { boundaryPoints[edge.Key.v0].Add(edge.Key); } else { List\u0026lt;GeoEdge\u0026gt; edges = new List\u0026lt;GeoEdge\u0026gt;(); edges.Add(edge.Key); boundaryPoints.Add(edge.Key.v0, edges); } // if (boundaryPoints.ContainsKey(edge.Key.v1)) { boundaryPoints[edge.Key.v1].Add(edge.Key); } else { List\u0026lt;GeoEdge\u0026gt; edges = new List\u0026lt;GeoEdge\u0026gt;(); edges.Add(edge.Key); boundaryPoints.Add(edge.Key.v1, edges); } } } // return boundaryPoints; } public static Vector3[] GetBoundaryFixedAvgNormal(int[] triangles, Vector3[] vertices, Vector3[] ns) { var boundaryPoints = GetBoundaryGeoPoints(triangles, vertices, out Dictionary\u0026lt;GeoEdge, List\u0026lt;int\u0026gt;\u0026gt; edgeCounter); // Vector3[] normals = new Vector3[vertices.Length]; // Dictionary\u0026lt;GeoPoint, List\u0026lt;int\u0026gt;\u0026gt; vertTriDict = new Dictionary\u0026lt;GeoPoint, List\u0026lt;int\u0026gt;\u0026gt;(); for (int i = 0; i \u0026lt; vertices.Length; i++) { GeoPoint geoPoint = new GeoPoint(vertices[i]); if (!vertTriDict.ContainsKey(geoPoint)) { vertTriDict[geoPoint] = new List\u0026lt;int\u0026gt;(); } vertTriDict[geoPoint].Add(i); } // foreach (var vt in vertTriDict) { Vector3 sumNormal = Vector3.zero; List\u0026lt;int\u0026gt; indices = vt.Value; // if (boundaryPoints.ContainsKey(vt.Key)) { //需要计算边界点 var edges = boundaryPoints[vt.Key]; //注意, 边的方向叉乘包含该边的三角形的法线, 就是垂直该边且指向外侧的的方向 //我们只需累加这样的方向 foreach (var edge in edges) { int triIndex = edgeCounter[edge][0];//这里肯定有且只有一个三角形符合 Vector3 triNormal = ns[triangles[triIndex * 3]]; Vector3 edgeDir = (edge.v1.Pos - edge.v0.Pos).normalized; // sumNormal += Vector3.Cross(edgeDir, triNormal); } } else { foreach (int index in indices) { sumNormal += ns[index]; } } // foreach (int index in indices) { normals[index] = sumNormal.normalized; } } // return normals; } 我们用Quad实验一下，看看修正完的\u0026quot;法线\u0026quot;数据是否正确\n不难看出，确实是垂直于三角形的法线朝\u0026quot;外\u0026quot;的。但很可惜，用这样的法线去给Quad描边，仍然是没有描边效果的。其根本原因是多Pass描边是靠正反两面的差异得到的结果，这在只有单面时就失效了。\n那么多Pass描边下，一般是怎么解决这个问题的呢？我看不少游戏的解决方案就是在纹理中加描边。\n复杂网格描边失效 同样是用芙露德莉斯，我们把描边换为白色，然后调到特殊的视角可以看到\n究其原因是这里网格靠的太近了，导致外扩的面穿模了\n透明物体描边效果差 并不是说透明物体就无法用多Pass描边来实现描边了，像崩铁的头发的描边就是正常的。不过这种透明本身也需要Stencil Buffer加多Pass来实现，对于复杂一些的物体就很容易产生问题了。\n后处理描边 后处理描边一般分两大类，一类是基于边缘检测实现的对场景的描边，一类是对Mask提取边缘并描边。\n边缘检测 游戏中的边缘检测一般是利用深度图或是法线图(法线通道)，对纹素的邻域检测梯度，来识别物体轮廓。其中最常见的就是用Sobel算子来实现边缘检测。以下面的\u0026quot;法线图\u0026quot;为例，\n由于Sobel算子需要采样像素的3x3邻域，因此我们宁可浪费一些线程也要用group shared memory来减少重复采样。\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 groupshared float gsSFCols[100];//10x10 //we need to sample 10x10 block\u0026#39;s pixels for a 8x8 block\u0026#39;s neighbor //for (8, 8, 1) thread groups, we might encounter scenarios where certain threads require sampling up to 4 times or more, //or sampling schemes with lower L2 cache hit rates are used. //in contrast, we adopt a thread group of (10, 10, 1) configuration, //trading marginal wavefront inefficiency to reduce overheads from sampling (synchronization required for the final thread group) and branch divergence. [numthreads(10, 10, 1)] void SobelFilterEdgeDetectKernel (uint3 id : SV_GroupID, uint gindex : SV_GroupIndex, uint3 gid : SV_GroupThreadID) { int2 coord = clamp(gid.xy + id.xy * 8 - 1, 0, _InputTexSize - 1); int2 oCoord = clamp(gid.xy + id.xy * 8, 0, _InputTexSize - 1); gsSFCols[gindex] = dot(InputTex[coord].xyz, float3(0.222, 0.707, 0.071)); GroupMemoryBarrierWithGroupSync(); // if (all(gid.xy \u0026lt; 8)) { float TLCol = GetSobelFilterNeighborCol(gid.xy, int2(-1, 1)); float TCCol = GetSobelFilterNeighborCol(gid.xy, int2(0, 1)); float TRCol = GetSobelFilterNeighborCol(gid.xy, int2(1, 1)); float MLCol = GetSobelFilterNeighborCol(gid.xy, int2(-1, 0)); float MRCol = GetSobelFilterNeighborCol(gid.xy, int2(1, 0)); float BLCol = GetSobelFilterNeighborCol(gid.xy, int2(-1, -1)); float BCCol = GetSobelFilterNeighborCol(gid.xy, int2(0, -1)); float BRCol = GetSobelFilterNeighborCol(gid.xy, int2(1, -1)); // float G_x = -TLCol + TRCol - 2 * MLCol + 2 * MRCol - BLCol + BRCol; float G_y = -TLCol - 2 * TCCol - TRCol + BLCol + 2 * BCCol + BRCol; //RW_OutputGradientTex[oCoord] = float2(G_x, G_y); RW_OutputTex[oCoord] = float4(length(float2(G_x, G_y)).xxx, 1); } } 当然，需要注意CPU端分配的线程组大小\n1 2 3 cs.GetKernelThreadGroupSizes(kernelIndex, out uint x, out uint y, out uint z); //... cmd.DispatchCompute(cs, kernelIndex, Mathf.CeilToInt(TexWidth / ((float)x - 2)), Mathf.CeilToInt(TexHeight / ((float)y - 2)), 1); 于是我们可以得到\nNOTE\n我们知道，一个Warp通常是包含32个线程的(我们还是以N卡为主)，那么100个线程实际上需要4个Warp来执行，且会存在一个Warp中的线程利用率极低。而这100个线程组，实际需要后续L1缓存访问的也只有64%，剩余36个线程也处于等待状态，因此我不能保证我们这里设定的线程组的大小就是最优解。想要获得最优解需要对不同的硬件以及不同的线程组大小实测才行。 我个人分析边缘的时候更喜欢用利用下面这种更小的邻域(用于其他效果分析时)，我们用Gather()类方法来加速，\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 [numthreads(8, 8, 1)] void NearestNeighborNormalEdgeDetectKernel (uint3 id : SV_DispatchThreadID) { float2 uv = (clamp(id.xy, 0, (uint2)_TexSize.xy) + 0.5) * _TexSize.zw; //note that using 3 times Gather() is a bit faster than 4 times SampleLevel(), //gather order: https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/gather4--sm5---asm- //0, 1 x, y //3, 2 w, z float4 nr = InputTex.GatherRed(sampler_PointClamp, uv); float4 ng = InputTex.GatherGreen(sampler_PointClamp, uv); float4 nb = InputTex.GatherBlue(sampler_PointClamp, uv); // float3 nR = normalize(cross(float3(-0.5, nr.x - nr.z, 0.5), float3(0.5, nr.y - nr.w, 0.5))); float3 nG = normalize(cross(float3(-0.5, ng.x - ng.z, 0.5), float3(0.5, ng.y - ng.w, 0.5))); float3 nB = normalize(cross(float3(-0.5, nb.x - nb.z, 0.5), float3(0.5, nb.y - nb.w, 0.5))); // float a = max(1 - nR.y, max(1 - nG.y, 1 - nB.y)); RW_OutputTex[id.xy] = float4(a.xxx, 1); } 将结果增强10倍可得\n可以看到后者的边缘更细，并且边缘更容易出现(对比左起1/5处的墙间的边缘线)。\nMask描边 很多时候，我们可能只需要对Mask的外边缘描边，而不需要内部描边，并且还可能需要描边保留Mask中绘制了的区域的值的信息，以用于查找最终绘制的颜色。像Need For Speed: Unbound里，车辆加速时的外描边效果就可以通过这种方式来实现。以下面的Mask为例，\n我们首先利用Mask找出边缘(注意, 我们的Mask的y也存了深度信息，为了处理重叠时该用哪一个的情况)\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [numthreads(8, 8, 1)] void PostProcessEffectOutlineDetectKernel (uint3 id : SV_DispatchThreadID) { float2 uv = saturate((_FullScreenTexSize.zw * (id.xy + 0.5))); // float2 col = _EffectOutlineMask_RT.SampleLevel(sampler_PointClamp, uv, 0); float4 nCol = _EffectOutlineMask_RT.GatherRed(sampler_LinearClamp, uv, 0); float4 nDepth = _EffectOutlineMask_RT.GatherGreen(sampler_LinearClamp, uv, 0); float2 nMaxCol = float2(nCol.x, nDepth.x); nMaxCol = lerp(nMaxCol, float2(nCol.y, nDepth.y), step(nMaxCol.y, nDepth.y)); nMaxCol = lerp(nMaxCol, float2(nCol.z, nDepth.z), step(nMaxCol.y, nDepth.z)); nMaxCol = lerp(nMaxCol, float2(nCol.w, nDepth.w), step(nMaxCol.y, nDepth.w)); float3 nColEqualChecker = nCol.xyz - nCol.yzw; RW_EffectOutlineZone_RT[id.xy] = dot(nColEqualChecker, nColEqualChecker) \u0026gt; 0 ? nMaxCol : float2(0, col.y);//a fast way to check if x,y,z,w components are equal(while, GatherCmpRed() may be faster to check) } 然后就用和Gaussian模糊类似的水平与垂直的两个Kernel将描边信息外扩\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 #define HALF_THICKSIZE 3 groupshared float2 gsCol[64 + 2 * HALF_THICKSIZE]; [numthreads(64, 1, 1)] void PostProcessEffectOutlineThickerHorizontalKernel (uint3 id : SV_DispatchThreadID, uint gIndex : SV_GroupIndex) { float2 uv = saturate((_FullScreenTexSize.zw * (id.xy + 0.5))); gsCol[gIndex + HALF_THICKSIZE] = _EffectOutlineZone_RT.SampleLevel(sampler_LinearClamp, uv, 0); if (gIndex \u0026lt; HALF_THICKSIZE) { gsCol[gIndex] = _EffectOutlineZone_RT.SampleLevel(sampler_LinearClamp, saturate((_FullScreenTexSize.zw * (id.xy - float2(HALF_THICKSIZE, 0) + 0.5))), 0); } if (gIndex \u0026gt;= 64 - HALF_THICKSIZE) { gsCol[gIndex + HALF_THICKSIZE * 2] = _EffectOutlineZone_RT.SampleLevel(sampler_LinearClamp, saturate((_FullScreenTexSize.zw * (id.xy + float2(HALF_THICKSIZE, 0) + 0.5))), 0); } // GroupMemoryBarrierWithGroupSync(); float2 nMaxCol = 0; for (int i = -HALF_THICKSIZE; i \u0026lt;= HALF_THICKSIZE; i++) { float2 tNCol = gsCol[HALF_THICKSIZE + i + gIndex]; nMaxCol = tNCol.x \u0026gt; 0 ? lerp(nMaxCol, tNCol, step(nMaxCol.y, tNCol.y)) : nMaxCol; } RW_EffectOutlineTemp_RT[id.xy] = nMaxCol; } [numthreads(1, 64, 1)] void PostProcessEffectOutlineThickerVerticalKernel (uint3 id : SV_DispatchThreadID, uint gIndex : SV_GroupIndex) { float2 uv = saturate((_FullScreenTexSize.zw * (id.xy + 0.5))); gsCol[gIndex + HALF_THICKSIZE] = _EffectOutlineTemp_RT.SampleLevel(sampler_LinearClamp, uv, 0); if (gIndex \u0026lt; HALF_THICKSIZE) { gsCol[gIndex] = _EffectOutlineTemp_RT.SampleLevel(sampler_LinearClamp, saturate((_FullScreenTexSize.zw * (id.xy - float2(0, HALF_THICKSIZE) + 0.5))), 0); } if (gIndex \u0026gt;= 64 - HALF_THICKSIZE) { gsCol[gIndex + HALF_THICKSIZE * 2] = _EffectOutlineTemp_RT.SampleLevel(sampler_LinearClamp, saturate((_FullScreenTexSize.zw * (id.xy + float2(0, HALF_THICKSIZE) + 0.5))), 0); } // GroupMemoryBarrierWithGroupSync(); float2 nMaxCol = 0; for (int i = -HALF_THICKSIZE; i \u0026lt;= HALF_THICKSIZE; i++) { float2 tNCol = gsCol[HALF_THICKSIZE + i + gIndex]; nMaxCol = tNCol.x \u0026gt; 0 ? lerp(nMaxCol, tNCol, step(nMaxCol.y, tNCol.y)) : nMaxCol; } // float2 col = _EffectOutlineMask_RT.SampleLevel(sampler_PointClamp, uv, 0); RW_EffectOutlineZone_RT[id.xy] = nMaxCol.x \u0026gt; 0 ? nMaxCol : float2(0, col.y); } 可以看到得到的描边区域还是比较均匀的。\n方案局限性 不像其他两种方案，屏幕空间的描边很难自由控制局部描边宽度，因此我们通常只能将其用于场景的风格化绘制，或是一些VFX，有时也可以用于UI上的VFX。\n共享边描边 我忘了最初是在哪里看到这种方案的，印象中说是米哈游在崩三的技术分享中提出的。但说实话，就我自己玩崩三以及米家的游戏经历来看，应该都用的是多(双)Pass描边——都不用抓帧，崩三里相机不止一次\u0026quot;穿模\u0026quot;看到背面的渲染结果了(不知道为什么他们不用这个来做)。所谓共享边本质上就是两个相邻三角形共用的那条边(注意，这里是几何上的——顶点位置一样的)，对于网格边界，那些没有相邻的边也将被我们考虑进去。因此，虽然我们叫共享边描边，本质上是找出网格的所有边，只是其中需要利用到共享边的对应的两个三角形的信息罢了。\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 [System.Serializable] public struct OutlineEdge { /// \u0026lt;summary\u0026gt; /// 共享的三角形的数量, 正常就1或2, 因此实际上我们可以和下面的宽度数据压缩为一个, 减少一些带宽 /// \u0026lt;/summary\u0026gt; public int SharedTriCount; public float EdgeBaseWidth; //我们按照如下顺序记录顶点索引 //1, 3 //0, 2 public int V0Index; public int V1Index; public int V2Index; public int V3Index; public OutlineEdge(int sharedTriCount, int v0Index, int v1Index, int v2Index, int v3Index, float edgeBaseWidth = 1) { SharedTriCount = sharedTriCount; EdgeBaseWidth = edgeBaseWidth; V0Index = v0Index; V1Index = v1Index; V2Index = v2Index; V3Index = v3Index; } } 计算共享边的方法也很简单\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 private static void AddEdgeTriangle(ref Dictionary\u0026lt;GeoEdge, List\u0026lt;Vector2Int\u0026gt;\u0026gt; edgeCounter, GeoPoint p0, GeoPoint p1, int tri, int offset) { var edge = new GeoEdge(p0, p1); if (edgeCounter.ContainsKey(edge)) { edgeCounter[edge].Add(new Vector2Int(tri, offset)); } else { edgeCounter.Add(edge, new List\u0026lt;Vector2Int\u0026gt;() { new Vector2Int(tri, offset) }); } } private static void GetVerticesIndices(Vector2Int info, int[] triangles, out int v0, out int v1, out int v2, out int v3) { int triBaseIndex = info.x * 3; // int[] tris = new int[] { triangles[triBaseIndex], triangles[triBaseIndex + 1], triangles[triBaseIndex + 2] }; // v0 = tris[(info.y + 2) % 3]; v1 = tris[info.y]; v2 = tris[(info.y + 1) % 3]; v3 = tris[(info.y + 2) % 3]; } private static void GetVerticesIndices(Vector2Int t0Info, Vector2Int t1Info, int[] triangles, out int v0, out int v1, out int v2, out int v3) { int triBaseIndex = t0Info.x * 3; // int[] tris = new int[] { triangles[triBaseIndex], triangles[triBaseIndex + 1], triangles[triBaseIndex + 2] }; // v0 = tris[(t0Info.y + 2) % 3]; v1 = tris[t0Info.y]; v2 = tris[(t0Info.y + 1) % 3]; v3 = triangles[t1Info.x * 3 + ((2 + t1Info.y) % 3)]; } public static OutlineEdge[] GetOutlineEdges(int[] triangles, Vector3[] vertices) { var edgeCounter = new Dictionary\u0026lt;GeoEdge, List\u0026lt;Vector2Int\u0026gt;\u0026gt;();//list中记录三角形索引以及当前边的offset // int trianglesCount = triangles.Length / 3; for (int i = 0; i \u0026lt; trianglesCount; i++) { int v0 = triangles[i * 3]; int v1 = triangles[i * 3 + 1]; int v2 = triangles[i * 3 + 2]; // var p0 = new GeoPoint(vertices[v0]); var p1 = new GeoPoint(vertices[v1]); var p2 = new GeoPoint(vertices[v2]); // AddEdgeTriangle(ref edgeCounter, p0, p1, i, 0); AddEdgeTriangle(ref edgeCounter, p1, p2, i, 1); AddEdgeTriangle(ref edgeCounter, p2, p0, i, 2); } // var outlineEdges = new List\u0026lt;OutlineEdge\u0026gt;(); // foreach (var ec in edgeCounter) { int v0, v1, v2, v3; var tris = ec.Value; if (tris.Count \u0026lt; 2) { if (tris.Count == 1) { Vector2Int tInfo = tris[0]; GetVerticesIndices(tInfo, triangles, out v0, out v1, out v2, out v3); outlineEdges.Add(new OutlineEdge(1, v0, v1, v2, v3)); } //如果小于1, 显然是异常的, 直接无视这样的边 } else { Vector2Int t0Info = tris[0]; Vector2Int t1Info = tris[1]; GetVerticesIndices(t0Info, t1Info, triangles, out v0, out v1, out v2, out v3); outlineEdges.Add(new OutlineEdge(2, v0, v1, v2, v3)); } } // return outlineEdges.ToArray(); } 绘制描边 我记得当时看到的教程用的是geometry shader来绘制边，那时候还不懂，现在我肯定是不会去用geometry shader了。我们在CS里分配需要绘制的边，并计算clip space下的坐标，直接给绘制时的VS用。\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 #define DEPTH_BIAS 0.0001 struct OutlineEdge { int SharedTriCount; float EdgeBaseWidth; int V0Index; int V1Index; int V2Index; int V3Index; }; uint _MeshEdgeCount; uint _VerticesBufferStride; uint _VerticesBufferOffset; float _OutlineWidth; float _OutlineFarDis; float _OutlineNearDis; float4x4 _ObjToWorldMatrix; ByteAddressBuffer _BakedVerticesBuffer; //use SkinnedMeshRenderer.GetVertexBuffer() to get the buffer StructuredBuffer\u0026lt;OutlineEdge\u0026gt; _MeshOutlineDataBuffer; RWStructuredBuffer\u0026lt;uint\u0026gt; RW_EdgeArgBuffer; //note that we should calculate vertices here but not in vs (only need calculate 4 vertex in cs, but need to calculate 6 in vs) RWStructuredBuffer\u0026lt;float4\u0026gt; RW_ClipSpaceEdgeVerticesBuffer; [numthreads(1, 1, 1)] void InitEdgeArgBufferKernel(uint3 id : SV_DispatchThreadID) { RW_EdgeArgBuffer[0] = 6; //2 triangle pre instance (3 * 2) RW_EdgeArgBuffer[1] = 0; //to draw rect count ----Value that we need to count---- RW_EdgeArgBuffer[2] = 0; //start vertex location RW_EdgeArgBuffer[3] = 0; //start instance location RW_EdgeArgBuffer[4] = 0; //default value for glsl } //ref: https://atyuwen.github.io/posts/antialiased-line/ void AddRectToOutlineSequence(float4 p0, float4 p1, float3 cwp, float baseWidth) { if (p0.w \u0026gt; p1.w) { float4 temp = p0; p0 = p1; p1 = temp; } if (p0.w \u0026lt; _ProjectionParams.y)//clamp to frustum { float ratio = (_ProjectionParams.y - p0.w) / (p1.w - p0.w); p0 = lerp(p0, p1, ratio); } float2 a = p0.xy / p0.w; //sspos float2 b = p1.xy / p1.w; // float disFactor = smoothstep(_OutlineFarDis, _OutlineNearDis, length(_WorldSpaceCameraPos - cwp)); float2 c = normalize(float2(a.y - b.y, b.x - a.x)) * (_ScreenParams.zw - 1.0) * _OutlineWidth * baseWidth * disFactor; //add bias to avoid Z-fighting (this cannot completely eliminate the problem) float4 v0 = float4(p0.xy + c * p0.w, p0.zw + float2(0, DEPTH_BIAS)); float4 v1 = float4(p0.xy - c * p0.w, p0.zw + float2(0, DEPTH_BIAS)); float4 v2 = float4(p1.xy + c * p1.w, p1.zw + float2(0, DEPTH_BIAS)); float4 v3 = float4(p1.xy - c * p1.w, p1.zw + float2(0, DEPTH_BIAS)); // uint instanceID; InterlockedAdd(RW_EdgeArgBuffer[1], 1, instanceID); // RW_ClipSpaceEdgeVerticesBuffer[instanceID * 6] = v0; RW_ClipSpaceEdgeVerticesBuffer[instanceID * 6 + 1] = v2; RW_ClipSpaceEdgeVerticesBuffer[instanceID * 6 + 2] = v1; RW_ClipSpaceEdgeVerticesBuffer[instanceID * 6 + 3] = v1; RW_ClipSpaceEdgeVerticesBuffer[instanceID * 6 + 4] = v2; RW_ClipSpaceEdgeVerticesBuffer[instanceID * 6 + 5] = v3; } float4 ObjToHClip(float3 p) { return mul(UNITY_MATRIX_VP, mul(_ObjToWorldMatrix, float4(p, 1))); } float4 ObjToWorld(float3 p) { return mul(_ObjToWorldMatrix, float4(p, 1)); } float4 WorldToHClip(float4 p) { return mul(UNITY_MATRIX_VP, p); } float3 LoadVertexData(uint index) { uint byteAddress = index * _VerticesBufferStride + _VerticesBufferOffset; //note that, for newly version of DirectXShaderCompiler, we can load types other than uint directly //ref: https://github.com/microsoft/DirectXShaderCompiler/wiki/ByteAddressBuffer-Load-Store-Additions uint3 uPos = _BakedVerticesBuffer.Load3(byteAddress); return asfloat(uPos); } //TODO: use group shared memory as intermediate buffer to add edges [numthreads(64, 1, 1)] void DispatchSilhouetteEdges(uint3 id : SV_DispatchThreadID) { if (id.x \u0026gt;= _MeshEdgeCount) { return; } OutlineEdge edge = _MeshOutlineDataBuffer[id.x]; // if (edge.SharedTriCount == 1) { //the edge is border of mesh, hence should be outline float3 v1 = LoadVertexData(edge.V1Index); float3 v2 = LoadVertexData(edge.V2Index); float4 p1 = ObjToWorld(v1); float4 p2 = ObjToWorld(v2); // AddRectToOutlineSequence(WorldToHClip(p1), WorldToHClip(p2), 0.5 * (p1 + p2).xyz, edge.EdgeBaseWidth); } else { // float3 v0 = LoadVertexData(edge.V0Index); float3 v1 = LoadVertexData(edge.V1Index); float3 v2 = LoadVertexData(edge.V2Index); float3 v3 = LoadVertexData(edge.V3Index); // float4 p0 = ObjToWorld(v0); float4 p1 = ObjToWorld(v1); float4 p2 = ObjToWorld(v2); float4 p3 = ObjToWorld(v3); // float3 t0 = (p0 + p1 + p2).xyz / 3; float3 t1 = (p1 + p2 + p3).xyz / 3; float3 viewDir0 = normalize(t0 - _WorldSpaceCameraPos); float3 viewDir1 = normalize(t1 - _WorldSpaceCameraPos); // float3 n0 = cross(normalize((p1 - p0).xyz), normalize((p2 - p0).xyz));//normalize for safe float3 n1 = cross(normalize((p3 - p1).xyz), normalize((p2 - p1).xyz)); // float c0 = dot(viewDir0, n0); float c1 = dot(viewDir1, n1); if (sign(c0) != sign(c1))//silhouette edge { //need to be outline AddRectToOutlineSequence(WorldToHClip(p1), WorldToHClip(p2), 0.5 * (p1 + p2).xyz, edge.EdgeBaseWidth); } } } 理论上这应该是共享边绘制的最佳方案了。当然，以上方案正如我注释的那样，还可以用group shared memory作为中间buffer进一步的减少InterlockedAdd的同步开销。\n下面我们来说一下为什么不用geometry shader。以DX的API为例，我们需要知道其绘制命令主要有Draw()与DrawIndexed()两大类区别(不考虑indirect以及instanced)，这两个接口的最大区别就在于DrawIndexed()会使用(指定的)index buffer而不是顺序处理，这让其真正的使用上了post-transform cache(有时也被称为vertex cache，即，顶点缓存——虽然可能有歧义，但我更喜欢这种叫法)这一特性。这个特性的作用是可以复用VS中处理完的结果(当然，这是少量可以复用)，举个例子就是一个Quad由两个三角形6个顶点组成。在用Draw()绘制的时候我们必须提供6个顶点的数据，而用DrawIndexed()则只会计算4个顶点，公共边的两个顶点会直接从缓存中读取。需要注意的是，这和MeshTopology没有任何关系，在unity中，MeshTopology用Quad与用Triangle的区别只在于你自己传入的用于VS中读取的index buffer的带宽变小了，而并不会启用顶点缓存(API文档中也说了，绝大多数GPU中，Quad都是模拟的)。启用顶点缓存时的帧率(unity中启用顶点缓存靠的是需要传入一个索引的GraphicsBuffer的那些接口)，差不多是未启用的1.5倍，而仅仅是将MeshTopology的Quad改为Triangle并修改VS中读取顺序的方案，只将帧率提高了1帧左右(33ms提升至34ms)，这与我们用Triangle自行手动模拟Quad的方案是一样的。\n很显然，geometry shader会\u0026quot;破坏\u0026quot;顶点缓存，更别说其他的问题了。这里也不得不吐槽一嘴，我最早接触到geometry shader的时候应该就是绘制草场用geometry shader，我一直不明白，为什么不在传入的模型的顶点中做动画？偏要用geometry shader来实现呢？附一张抓的Horizon Zero Dawn的一帧，用的就是DrawIndexedInstanced()+模型(可以明确只有VS+PS)\n未来与展望 关于描边，还有几个未来需要考虑如何更好的实现的内容，算是TODO List，也留些参考\n透明物体的描边 透明玻璃杯是最常见的需要描边的透明物体\n环境对描边色的影响 References [1]IcePaper 鸣潮-芙露德莉斯\n[2]CombatGirls_RifleCharacterPack\n[3]铃芽之旅\n[4]Draw Anti-aliased Lines with Geometry Shader\n[5]你的名字\n[6]Jen 113407548\n","date":"2025-09-13T00:00:00Z","image":"https://RicciFloOow.github.io.com/p/%E8%AF%A6%E8%B0%88%E9%A3%8E%E6%A0%BC%E5%8C%96%E6%8F%8F%E8%BE%B9/Imgs/SharedEdgeOutline_RST_hu_2e7ca4871289e12a.png","permalink":"https://RicciFloOow.github.io.com/p/%E8%AF%A6%E8%B0%88%E9%A3%8E%E6%A0%BC%E5%8C%96%E6%8F%8F%E8%BE%B9/","title":"详谈风格化描边"},{"content":"DDGI是一个核心基于硬件光追的\u0026quot;体素化\u0026quot;的实时GI方案(主要对Diffuse Illumination)，同时也是RTXGI的核心技术。这里我们基于DDGI的论文与演讲文档[1]与[2]实现了DDGI的主要流程，当然，在完成了我们自己的DDGI主要流程后，也参考了[3]进行了改进与优化。\n为什么选择DDGI？\n与[2]最后所设想的Stream GI一样，唯有\u0026quot;体素化\u0026quot;的(\u0026ldquo;网格化\u0026quot;的)方案才能更好的接入未来基于服务器的辅助渲染技术。 动态的、\u0026ldquo;体素化\u0026quot;的，这很适合箱庭+即时(或实时)PCG的游戏的高品质GI方案。 NOTE\n本项目是基于Unity 2022.3.+版本的，因此存在很多功能是不能用的，比如RTS的Keywords，基于ArgBuffer的线程分配的RTS等等。 项目地址: RicciFloOow/Unity-DDGI: Global Illumination Based On DDGI In Unity\nDDGI流程 我们先来看未优化前我们与官方的DDGI的流程的异同\nNOTE\n注意，有些步骤的实际执行顺序可能与图示的并不一致，因为这些并不影响主要逻辑。 可以看到，我们处理Probes的状态的方法以及对高阶反弹的处理与官方版的有很大的区别。项目里我们基于官方版的修改了高阶反弹的方案，不过，这也导致了一些其它问题。\n官方的DDGI 作为实打实踩过坑的人，如果有人看到了这篇文档，还是希望能先下载官方的DDGI看看他们的实际流程，少走点弯路。不过需要注意的是，官方的DDGI项目的部署也有坑。\n官方的DDGI就是[3]NVIDIAGameWorks/RTXGI-DDGI: RTX Global Illumination (RTXGI)，其中的QuickStart给出的git的地址是错误的：指向的是完整的RTXGI的项目(文档中给的是https://github.com/NVIDIAGameWorks/RTXGI.git但实际是https://github.com/NVIDIAGameWorks/RTXGI-DDGI.git)。 另一个问题就是克隆下来的项目可能是不完整的，我试了几次，都是RTXGI-DDGI\\external\\agilitysdk中缺失几乎整个sdk(好像只有个version文件)，需要自行下载Agility SDK并拷贝至该文件夹下。 其余的只需要按照QuickStart中的步骤做就行了。\nProbe状态机 我们先来看Probe的状态逻辑。DDGI相对于传统的光照探针方案的一大优点就在于DDGI可以利用光追的结果\u0026quot;自动化\u0026quot;的“优化”自身的位置并判断自身是否处于物体\u0026quot;内部\u0026quot;从而无效。那么该如何判断是否处于物体内部？一个自然的想法便是基于射线命中时是命中的正面还是反面来判断。\n官方的SDK里的提供的方案和我们上面说的大致类似，也是基于正反面，然后基于距离(反面的取负值)计算权重估计是否需要活跃。\n但是这样做其实是存在问题的，比如在绝大多数情况下网格间的重合是不可避免的，这可能会导致一个探针有更多的射线先碰到重合的网格的正面，从而导致对当前Probe状态的误判。而且DDGI是一个允许离线烘焙的技术，在仅基于正反面的基础上来判断探针是否处于\u0026quot;墙内\u0026rdquo;，在我看来是远远不够的。我们的思路是利用场景中\u0026quot;绝对\u0026quot;不会在\u0026quot;墙内\u0026quot;的对象来辅助判断：比如游戏中的玩家相机位置，一些点光源的位置，这些对象从游戏设计上就不会处于\u0026quot;墙内\u0026rdquo;。\n因此，\n我们的第一步是Probe均匀向外发射射线，然后在命中点处发射指向前面提到的用于辅助判断的对象的位置的射线(注意，不需要全部，只需要几个有特征，比较重要的即可)，如果Miss了，那么向Directional Light的方向发射足够长的射线看看是否碰撞。之后统计是否有足够的无碰撞的射线来判断Probe是否有效。 仅靠一次反弹通常是不够的，所以我们后面的步骤就是将Probe的活跃信息向周围\u0026quot;扩散\u0026quot;：一个活跃的Probe均匀向外发射射线，在命中点处发射指向另一个Probe的射线，然后统计是否有足够的无碰撞的二次反射射线。重复以上操作将活跃信息\u0026quot;扩散\u0026quot;至全部网格。 不过需要注意的是，上述的\u0026quot;扩散\u0026quot;过程其实和计算SDF是相似的，因此我们参考了JFA加速的方案，逐步增加扩散的步长(当然，会有一定的上限，过大了可能通常是无效的)，而不是仅靠\u0026quot;扩散\u0026quot;至相邻Probe来实现。不过这个过程还是与SDF有一定的区别，因此存在极端情况使得扩散的结果并不好，比如下图这种左下角有个光源，探针在右下角的情况(蓝色区域是墙)。\n此外需要注意的是，这种检测需要发射的射线还是不少的，由于Unity 2022.3.+版本还不支持用ArgBuffer来分配RTS的线程组(当然，DXR是支持的)，因此需要手动分配到多帧来执行(手动分步模拟异步，RTS是同步的)。如果不手动分配到多帧执行，只要Probes Grid一大，就会可能导致单帧渲染超时，从而系统强杀进程使得应用闪退，这一点可以用项目中的Assets/DynamicDiffuseGI/Demo/DemoScene/DemoProbe2FlattenOct.unity场景来测试。\n尽管RTS中是没法同步组的(group shared memory只支持CS)，理论上我们还是可以利用Warp来加速的(对Probe的有效射线的统计)——只要线程组分配合理\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 //...validation check logic... //note that our ValidationRaysCount is a multiple of 64, hence we can use wave intrinsics uint tValidRayCount = isValid ? 1 : 0; uint warpFirstProbeIndex = WaveReadLaneFirst(probeIndex); bool areSameProbe = WaveActiveAllTrue(warpFirstProbeIndex == probeIndex); // if (areSameProbe)//for safe { uint waveValidSum = WaveActiveSum(tValidRayCount); if (WaveIsFirstLane()) { InterlockedAdd(RW_ProbeValidationIntermediateBuffer[warpFirstProbeIndex], waveValidSum); } } else { if (isValid) { InterlockedAdd(RW_ProbeValidationIntermediateBuffer[probeIndex], 1); } } 不难发现这种方案的准确性会更高，但是代价是开销会更大，因此如果场景发生了改变，理论上应该只重新检测场景变化的部分的包围盒所影响的Probes。\n当然，官方除了处理了\u0026quot;墙内\u0026quot;的Probe的状态以外，也对那些Probe所\u0026quot;影响的范围内\u0026quot;没有任何物体的Probe进行了处理——不活跃的(没必要浪费性能)，像下图中红色描边的探针就是官方的Cornell盒场景中不活跃的。我们在后面的改进版中也基于此想法修改了这类Probes的状态。\n高阶反弹 [4]提到DDGI的辐照度约为光线二次及以后的高阶(多次)反弹的漫反射项的总和，因此我们记录的irradiance就应该基于此来计算。在本项目里，我们实现了两种高阶反弹的方案，第一种是在Probe网格内基于8-邻域或是26-邻域(我们用26-邻域)采样并传递，第二种则是DDGI官方的那种，在Probe Tracing的同时就采样命中点的邻近Probes来实现传递。我们项目里的Demo场景DemoProbe2FlattenOct用的还是第一种方案，DemoCornellBox场景则在后面的优化中改用了方案二。\n方案一相对于方案二的唯一的优点就只有可以更清晰的看到多次反弹的分解过程\n而且方案一在网格间传递的过程中是没法获得albedo的，只能用之前的irradiance来模拟，这实际上会使得误差更大。而方案二则确实是光线二次及以后的高阶反弹的漫反射项的和。\nProbe Tracing 我们的Probe Tracing与官方的最大不同点在于我们的depth与irradiance的Tracing是分离的。那么为什么(一定)要分离呢？\n首先我们的Probe在Tracing时的精度是不同，我们期望在相机附近的Probes在Tracing时，单个纹素使用的射线数量更多(可以看下文我们关于射线分布的设计)，这样我们的结果会相对来说更快的收敛且精确(高精度的16rpp，低精度的4rpp)。但是这样会造成一个问题，就是depth通常要比irradiance用的纹理大，如果为了满足depth的精度，那么irradiance会有很多相对来说\u0026quot;浪费\u0026quot;的(\u0026ldquo;重复\u0026quot;的)Tracing了。而且depth严格来说只需发射一个射线，而irradiance则需要发射两个射线，且还需要额外的Buffer或是纹理的采样(法线肯定得采样的)。因此，我们将这两种分开来Tracing了。\nGlossy Illumination 至于Glossy Illumination，我们是按照[2]中的方案来实现的，用bilateral blur downsample来得到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 float3 GetRangeKernel(float3 diff, float sigmaInv) { return exp(-diff * diff * sigmaInv);//here sigmaInv = 1 / (2 * sigma * sigma) } [numthreads(8, 8, 1)] void DX12BilateralMipmapKernel (uint3 id : SV_DispatchThreadID, uint gindex : SV_GroupIndex) { uint laneCount = WaveGetLaneCount(); uint waveIndex = gindex / laneCount; // int2 coord = clamp(id.xy, 0, _InputTexSize - 1); float3 col = InputTexture.Load(uint3(coord, 0)).xyz; // float3 waveColMax = WaveActiveMax(col); float3 waveColMin = WaveActiveMin(col); if (WaveIsFirstLane()) { gsWaveColMax[waveIndex] = waveColMax; gsWaveColMin[waveIndex] = waveColMin; } // gsCols[gindex] = col; GroupMemoryBarrierWithGroupSync(); if (gindex == 0) { float3 gColMax = gsWaveColMax[0]; float3 gColMin = gsWaveColMin[0]; // for (uint i = 1; i \u0026lt; 64 / laneCount; i++) { gColMax = max(gColMax, gsWaveColMax[i]); gColMin = min(gColMin, gsWaveColMin[i]); } // gsColMode = (gsWaveColMax[0] + gsWaveColMin[0]) * 0.5; } GroupMemoryBarrierWithGroupSync(); // if ((gindex \u0026amp; 0x9) == 0)//x,y % 2 == 0 { float3 c1 = gsCols[gindex + 0x01]; float3 c2 = gsCols[gindex + 0x08]; float3 c3 = gsCols[gindex + 0x09]; // //TODO: user controlled sigma float3 w0 = GetRangeKernel(gsColMode - col, 18); float3 w1 = GetRangeKernel(gsColMode - c1, 18); float3 w2 = GetRangeKernel(gsColMode - c2, 18); float3 w3 = GetRangeKernel(gsColMode - c3, 18); // col = (w0 * col + w1 * c1 + w2 * c2 + w3 * c3); col /= max(1e-5, w0 + w1 + w2 + w3); // RW_OutputTexMipmap1[id.xy / 2] = float4(col, 1); gsCols[gindex] = col; } GroupMemoryBarrierWithGroupSync(); if ((gindex \u0026amp; 0x1B) == 0)//x,y % 4 == 0 { float3 c1 = gsCols[gindex + 0x02]; float3 c2 = gsCols[gindex + 0x10]; float3 c3 = gsCols[gindex + 0x12]; // float3 w0 = GetRangeKernel(gsColMode - col, 18); float3 w1 = GetRangeKernel(gsColMode - c1, 18); float3 w2 = GetRangeKernel(gsColMode - c2, 18); float3 w3 = GetRangeKernel(gsColMode - c3, 18); // col = (w0 * col + w1 * c1 + w2 * c2 + w3 * c3); col /= max(1e-5, w0 + w1 + w2 + w3); // RW_OutputTexMipmap2[id.xy / 4] = float4(col, 1); gsCols[gindex] = col; } GroupMemoryBarrierWithGroupSync(); if (gindex == 0) { float3 c1 = gsCols[gindex + 0x04]; float3 c2 = gsCols[gindex + 0x20]; float3 c3 = gsCols[gindex + 0x24]; // float3 w0 = GetRangeKernel(gsColMode - col, 18); float3 w1 = GetRangeKernel(gsColMode - c1, 18); float3 w2 = GetRangeKernel(gsColMode - c2, 18); float3 w3 = GetRangeKernel(gsColMode - c3, 18); // col = (w0 * col + w1 * c1 + w2 * c2 + w3 * c3); col /= max(1e-5, w0 + w1 + w2 + w3); // RW_OutputTexMipmap3[id.xy / 8] = float4(col, 1); } } 可以看到细节还是有所保留的。\nNOTE\nGI中的漫反射部分也是和DI的高阶反弹一样，通过采样命中点的邻近Probes。 射线与分布 以下是我们的渲染结果\nDirect Light Indirect Light Full Result (Including Glossy Illumination) 不难发现我们的结果有明显的网格感，这是因为我们的Probes的分步是网格分布——太均匀了(场景也过于简单)，如果基于场景自行Relocate效果会好一些(实际上，如果将官方的示例中的Cornell盒的天空盒改成白色的，也能看到一定的网格感)。但是如果Relocate了，那么又可能会导致Chebyshev计算的权重的\u0026quot;异常\u0026quot;加剧，像官方示例中的Cornell盒就有明显的异常分界带\nOfficial Demo: Cornell Box's Indirect Light 射线的方向 射线方向也算是一个有点小坑的地方。DDGI推荐使用Spherical Fibonacci Grids计算射线方向，并用[5]中的八面体展开映射$\\varphi:\\text{dir}\\mapsto\\text{coord}$来储存Probes发出的射线的结果(irradiance和depth)。很显然，对一个$n\\times n$的纹理，如果我们仅生成$n\\times n$条射线$(n\u0026gt;2)$，是一定存在像素不是任何生成的射线方向在$\\varphi$下的像——即$\\varphi$不是满射的情况的。自然的，对于$n\\times n$的纹理，我们是否可以通过生成$m\\times m$条射线$(m\u0026gt;n)$使得基于任意的初始方向生成的这些射线方向在$\\varphi$下是满的呢？数学上应该可以算出这样的$m$的下界，不过我们这里没必要去计算，因为即便$m=n+2$，仍然可能不是满的。总的来说，就是我们需要生成更多的射线才可能覆盖满我们目标存储的纹理。\n我们在场景Assets/DynamicDiffuseGI/Demo/DemoScene/DemoProbe2FlattenOct.unity中采用的方案就是最直接的利用尽可能多的射线来覆盖目标纹理的方法：\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 float4 probeIrradiances[1024]; float3 probeDepths[1024]; for (i = 0; i \u0026lt; 1024; i++) { probeIrradiances[i] = 0; probeDepths[i] = 0; } //基于我们生成的足够多的射线来Tracing for (i = 0; i \u0026lt; raysCount; i++) { float3 rayDir = _RayGenBuffer[i]; RayDesc ray; ray.Origin = probePos; ray.Direction = rayDir; ray.TMin = 0.001; ray.TMax = 1000; ProbeRayPayload payload; payload.color = 0; payload.depth = 0; payload.bounceTimes = 0; TraceRay(_SceneAccelStruct, RAY_FLAG_CULL_BACK_FACING_TRIANGLES, 0xFF, 0, 1, 0, ray, payload); float2 localUV = ProbeDir2OctUV(rayDir); uint irradianceLocalIndex = GetLocalArrayIndex(localUV, _IrradianceRealResolution); uint depthLocalIndex = GetLocalArrayIndex(localUV, _DepthRealResolution); //写入到指定临时buffer中 probeIrradiances[irradianceLocalIndex] += float4(payload.color, 1); probeDepths[depthLocalIndex] += float3(payload.depth, payload.depth * payload.depth, 1); } // uint3 irradianceLocalTexBaseCoord = uint3(probeIndex.x * (_IrradianceRealResolution + 2), probeIndex.z * (_IrradianceRealResolution + 2), probeIndex.y); for (i = 0; i \u0026lt; _IrradianceRealResolution + 2; i++) { for (j = 0; j \u0026lt; _IrradianceRealResolution + 2; j++) { //在这里逐像素写入irradiance } } uint3 depthLocalTexBaseCoord = uint3(probeIndex.x * (_DepthRealResolution + 2), probeIndex.z * (_DepthRealResolution + 2), probeIndex.y); for (i = 0; i \u0026lt; _DepthRealResolution + 2; i++) { for (j = 0; j \u0026lt; _DepthRealResolution + 2; j++) { //在这里逐像素写入depth } } 但显然，上面这种方案的性能是极差的。因此一种自然的改进方案就是利用一个中间的纹理(当然实际是多个)来记录单个射线的结果，然后再在CS中来将结果映射到我们实际临时纹理中(一般用group shared memory，不过这限制了目标纹理的分辨率)，之后再输出到指定的Texture2DArray中。这个其实就是官方所使用的方案。\n那么有没有办法可以不用中间纹理？自然是有的，只需要我们保证我们分配的方向能保证覆盖全部纹素，并且每个纹素能对应我们期望数量的射线方向，那么我们就可以直接基于纹素坐标去获取射线方向，从而省去了中间纹理这一步骤。这个射线方向分配方案就存在一个坑。\n对$n\\times n$的纹理，我们期望每个纹素都有$k$条射线。于是我最初的方案是，我们生成$m\\times m$条射线，然后利用$\\varphi$将射线对应的纹素坐标计算出来，把这个射线添加到该纹素对应的一个容器里，等全部映射完了再检查一下容器里的射线是否足够k条，如果不够则通过插值或是其他的随机方案来生成足够的射线。不过，这个方案在实际项目中是完全不能用的。我花了不少时间才定位到Chebyshev权重异常的原因，就是这个射线分配方案：即使在混合了非常多的帧的结果后，一些在理应呈现出对称的结果的地方仍然是明显非对称的。\n优化后的是SphericalFibonacciRay.cs中所使用的方案：我们之前的方案给出的条件太苛刻了，因为八面体展开映射下的纹素之间在球面上的投影差异本身就不小，如果我们非常严格的基于纹素对应的方向来生成不足的射线，得到的结果将总是很不均匀的。因此我们考虑将射线同时记录进其所属的纹素的$3\\times3$邻域内。并且即便当前纹素内的射线不够，我们也不再生成别的射线了，而是重复使用容器内的射线。不过这个方案在cpu端实现起来开销巨大(我本来还打算考虑使用射线与纹素方向的内积值为权重来排序)，因此最终我们还是实现了一个GPU版的(见RandomRayGenerator.compute)。\n射线长度 射线越短，通常来说开销越小。我们先来看一下官方的Demo中的深度图的数据\nNsight Frame Debug 可以看到里面存的距离居然不到0.4，这其中一个原因是这个Demo中的Cornell Box本身就比较小，另一个就是他们在写入深度的时候限制了距离：基于Probe的间距(官方是取了1.5倍)。我们在项目了直接令射线长度是Probe的间距的1.733倍，这是因为我们的depth与irradiance的检测是分离的。不过这也就要求了我们必须对全部Miss的Probe的状态做处理。\n总结 说实话，做DDGI之前我对其还是抱有很大期望的，但是实际做出来后(包括官方的Cornell Box的Demo)，还是有那么些失望的——没有达到我的预期。更重要的是，本来我期望DDGI能对高频变化的场景能有很好的响应(当然，官方本身的逻辑下是无法做到很及时的响应的)，不过这一点实测还是无法实现的。\nReferences [1]Dynamic Diffuse Global Illumination with Ray-Traced Irradiance Fields\n[2]DDGI with Ray-Traced Irradiance Fields Presentation\n[3]NVIDIAGameWorks/RTXGI-DDGI: RTX Global Illumination (RTXGI)\n[4]Dynamic Diffuse Global Illumination Resampling\n[5]Survey of Efficient Representations for Independent Unit Vectors (JCGT)\n","date":"2025-09-05T00:00:00Z","image":"https://RicciFloOow.github.io.com/p/unity%E4%B8%AD%E4%B8%80%E4%B8%AAddgi%E7%9A%84%E5%AE%9E%E7%8E%B0/Img/DDGI_Rst_Indirect_hu_e4c2da8c2a5f2f83.png","permalink":"https://RicciFloOow.github.io.com/p/unity%E4%B8%AD%E4%B8%80%E4%B8%AAddgi%E7%9A%84%E5%AE%9E%E7%8E%B0/","title":"Unity中一个DDGI的实现"},{"content":"硬件光追(HWRT)，不管用不用的上，学还是得学的，毕竟现在出个SoC只要带了光追单元都要吹上一吹(甭管好不好用)。\n我们这里只考虑Unity中硬件光追的使用，并且是在builtin管线下的使用。因为硬件光追只与所用的Unity版本以及硬件(更准确的是显卡、系统、驱动以及图形API)有关，与所用的管线无关：比如早些年的版本是不支持A卡的，但是较新版本的unity是支持A卡硬件光追的。我们这里用的是Unity 2022.3.+(LTS)的版本：因为2023版还没有LTS版的，Unity6又太新了，里面bug还是有不少的，不便于学习。\nIMPORTANT\n在开始前，首先需要确认你的显卡是否支持Unity的硬件光追：先把项目所用的图形API更改为DX12，然后看SystemInfo.supportsRayTracing的值是否为true。当然，还有一种办法是查看所用的Unity版本对应的HDRP的文档：Getting started with ray tracing | High Definition RP | 14.0.11 里面就有支持的显卡型号。当然，如果你用的是A卡，则需要注意后文中会提到的关于A卡的硬件光追\u0026quot;Bug\u0026quot;。 项目里实现了基于硬件光追的以下功能：\nUnity的renderer的path tracer Instance的path tracer 基于PBD的布料模拟(用HWRT做碰撞检测) NOTE\n需要注意，现在Unity的API文档都是默认定位到Unity6下的文档，因此如果点开HWRT相关的API后想通过点击左上角的version来切换到2022.3.+版本的API，是会提示不支持的。因为2022.3.+版本中HWRT相关的API都是UnityEngine.Experimental.Rendering类下的，而Unity6里则被更改到了UnityEngine.Rendering里。 项目地址: RicciFloOow/Unity-Builtin-Ray-Tracing: unity built-in 管线下的DXR使用示例(仅适用于2022.3.+版本)\n1. Renderer的光追 下文中涉及到DXR的内容，建议配合官方文档[1]DirectX Raytracing (DXR) Functional Spec | DirectX-Specs一起食用。\nUnity的Renderer有很多子类，常见的Mesh Renderer, Skinned Mesh Renderer, Particle System Renderer, Trail Renderer等等，我们这里就只考虑最简单也是最常用的Mesh Renderer。\nRenderer的硬件光追由以下内容组成：\nCPU端： 用于执行RTS(Ray Tracing Shader)的Command Buffer的脚本 (可选)控制指定Mesh Renderer是否用于HWRT以及配置其RayTracingSubMeshFlags的脚本 GPU端： Ray Tracing Shader(.raytrace文件)：实现Ray Generation Shader, Miss Shader以及Callable Shader Hit Groups Shader(.shader文件)：可以实现Closest Hit Shader, Any Hit Shader, Intersection Shader(注意，并不是都要实现，一般就实现Closest Hit Shader) 1.1 Ray Tracing Shader 在Ray Tracing Shader中，我们需要先定义#pragma max_recursion_depth n，这里的n是用户定义的TraceRay()最大的迭代深度，这个值必须得大于等于实际迭代的深度，否则可能会导致软件闪退。当然，这个n也是有上限的。\n1.1.1 Ray Generation Shader Ray Generation Shader通常有如下格式：\n1 2 3 4 5 6 7 8 9 [shader(\u0026#34;raygeneration\u0026#34;)] void YourRayGenShaderName() { 配置RayDesc // TraceRay(...) // 处理输出的RayPayload } 如果自行实现过软光追的话(特别是用CS实现过的话)，配置RayDesc这一步就非常容易理解了。假设我们要把光追的结果输出到一张$m\\times n$大小的Render Texture上，并且在Dispatch()当前Ray Generation Shader时设置的threads grid为(m, n, 1)，那么可以利用DispatchRaysIndex().xy来获得当前的像素点的坐标(像素空间中的)，然后用DispatchRaysDimensions().xy来获得threads grid的width与height的大小(也就是m与n)，以此将像素坐标转到屏幕空间坐标。然后我们就可以利用当前相机的配置，将近裁剪面(或是远裁剪面)中当前顶点的屏幕空间坐标转为世界空间坐标(这一步只要做过(与深度相关的)屏幕空间后处理的话应该都知道怎么做，方法也很多)。从而我们有了当前像素点对应的射线的方向(世界空间中)。\nRayDesc是由微软定义的结构，包含射线的\n原点(Origin)：通常用相机的坐标。 方向(Direction)：我们上面计算的当前像素点对应的射线的方向。 起点距离(TMin)：在Ray Generation Shader中的初始射线，一般用相机的近裁剪面距离。在后续的通过反射或是折射生成的射线中，一般为0。 终点距离(TMax)：在Ray Generation Shader中的初始射线，一般用相机的远裁剪面距离。 NOTE\n对给定的RayDesc，实际用于检测的是从Origin + TMin * Direction到Origin + TMax * Direction的直线段。 在执行TraceRay()前，我们还需要传入一个由用户自定义的结构RayPayload(并不一定要取这个名，不过后文为了方便我们都用这个结构体名)，用于在不同的shader之间传递必要的光追结果信息。\nNOTE\n需要注意的是，在Unity中我们定义自己的RayPayload结构时，并不需要按照DXR官方文档中的语法规范来定义，而只需要和定义一般的结构体一样来定义即可。 TraceRay()是将指定射线作用于TraceRay control flow的方法：指定的射线会在RAS(RaytracingAccelerationStructure)中搜索是否有相交的三角形或是Instance Primitive，如果没有则返回通过Miss Shader后的结果，而如果有，则按照一定规则执行Hit Group中的那些Shader。需要注意的是，TraceRay()并不是只能在Ray Generation Shader里调用，它甚至可以在Miss Shader里调。因此要实现一个Path Tracer，我们可以有两种方案：\n最常见的方案就是在Hit Group中的那些Shader中(一般是在Closest Hit Shader中)，基于相交检测的结果，产生一个(或多个)新的射线并调用TraceRay()。 另一种方案便是在Ray Generation Shader中循环调用TraceRay()，直到返回的RayPayload达成结束循环的条件或是循环次数达到上限了。当然，在Hit Group中的那些Shader中我们就不再调用TraceRay()。这种方案的好处是max_recursion_depth可以是1(Ray Generation Shader的深度为0)。需要注意的是，这种方案适用于BRDF，如果是BSDF这种往往就不太合适了。 TraceRay()的参数如下：\nRaytracingAccelerationStructure accelerationStructure：通过Command Buffer或RTS分别调用SetRayTracingAccelerationStructure()或SetAccelerationStructure()来设置所用的加速结构。 uint Ray flags：通常我们用0(也即0x00)，也就是RAY_FLAG_NONE，更多的可见Flags Per Ray。 uint InstanceInclusionMask：通常用0xFF。 uint RayContributionToHitGroupIndex：我们通常用0。 uint MultiplierForGeometryContributionToShaderIndex：我们通常用1，详见Hit group table indexing。 uint MissShaderIndex：一个RTS里可以有多个Miss Shader(只需要名字不同即可)，传入当前需要的索引即可(按照顺序数或是查看对应RTS的Inspector窗口下的索引值)。 RayDesc Ray：产生的射线。 RayPayload payload：我们自定义的结构体。 只要我们在配置RayDesc时就对目标像素点做了一些抖动，那么我们只需要把TraceRay()输出的payload中的颜色信息(处理完后的)与历史帧的结果混合一下再输出即可(就是TAA)。\n1.1.2 Miss Shader 粗略的来说Miss Shader就是当前射线未能检测到三角形或是其它实例时，亦或是未能通过当前给的条件时，作为最终执行的Shader。因此我们可以将Miss Shader作为天空盒来使用：程序化的，或是采样Cube Map等等。\n需要注意的是，Miss Shader有如下格式\n1 2 3 4 5 [shader(\u0026#34;miss\u0026#34;)] void YourMissShaderName(inout RayPayload rayPayload : SV_RayPayload) { 处理rayPayload } 其中RayPayload就是我们前面提到的自定义的结构体，必须和其他相关联的Shader中用的一致。\n1.2 Hit Groups Shader 这类Shader在Unity中，也是通过创建最常见的.shader格式文件来实现的，无非是光追Pass的格式与内容会和光栅化的Pass的不同。也就是说一个这类Shader有大致如下的格式(下面示例中我们仅考虑Closest Hit 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 Shader \u0026#34;Shader名称\u0026#34; { Properties { 需要用到的材质属性 } SubShader { Tags { \u0026#34;RenderType\u0026#34;=\u0026#34;Opaque\u0026#34; } Pass { 光栅化pass(具体内容省略) } Pass { Name \u0026#34;HWRayTracing\u0026#34; Tags{ \u0026#34;LightMode\u0026#34; = \u0026#34;HWRayTracing\u0026#34; } HLSLPROGRAM #pragma raytracing HitShader #include 必要的.cginc文件(或.hlsl文件) 用到的材质属性 [shader(\u0026#34;closesthit\u0026#34;)] void ClosestHit(inout RayPayload rayPayload : SV_RayPayload, AttributeData attributeData : SV_IntersectionAttributes) { 可以利用attributeData，对rayPayload处理 } ENDHLSL } } } NOTE\n需要知道的是，光栅化的Pass对于光追管线来说是完全没有用处的，其存在的意义仅是为了Editor下方便查看。 与光栅化的Pass不同的是，光栅化的Pass的Name我们不写也是可以的，但是光追用的Pass就必须要写Name。同时，这里取的Name是用于C#脚本中CommandBuffer.SetRayTracingShaderPass(RayTracingShader rts, string passName)中的passName这个参数的(必须要调这个方法)。因此，如果多个不同的Hit Groups Shader要用于同一个光追流中，那么这些Name也是需要一样的。当然，这个具体的名字是可以自己随便取的。\n#pragma raytracing HitShader是表明这一个用于光追的Pass，其中HitShader也是可以自行取名的。\n一般来说，我们都是需要#include一些必要的.cginc文件(或.hlsl文件)，因为如果需要获得命中点的法线、uv等信息(我们这里考虑的是Renderer的光追，因此命中的是三角形)，那么我们就需要利用命中点的重心坐标以及三角形的三个顶点的法线、uv等信息来获得该点的法线、uv等信息。这样的话，我们就需要用到Unity提供的(帮我们封装好的)光追获取网格中的三角形顶点的这类信息的方法：UnityRayTracingFetchVertexAttribute3()等等。这些方法存在于include文件UnityRaytracingMeshUtils.cginc中。\nAttributeData也是一个允许用户自行取名并定义的结构体(详见Intersection Attributes Structure)，但是与RayPayload的不同的是，这个结构体有更多的限制：\n对于三角形网格，其结构必须是：\n1 2 3 4 struct AttributeData { float2 barycentrics; }; 因为用的是内置的Intersection方法(不重写Intersection Shader)。\n对于Instance光追中可能存在的Intersection Shader，我们可以定义不同结构的AttributeData，但是其结构大小不能超过D3D12_RAYTRACING_MAX_ATTRIBUTE_SIZE_IN_BYTES，也就是32 bytes(2个float4的大小)。\nWARNING\n用A卡学习的时候需要注意，项目中如果有多个相同Name的Hit Groups Shader，运行时有可能会造成Unity Editor闪退(当然，需要SetRayTracingShaderPass()中用了这个Name)。至少我用的RX6600是必然闪退(100%)，并且用HDRP开启Path Tracing也会直接闪退。只有仅存在一个给定Name的Hit Groups Shader时，才能正常运行(这大大限制了A卡上光追的实用性)。 1.3 执行RTS的Command Buffer 执行Ray Tracing Shader的Command Buffer一般如下：\n1 2 3 4 5 6 cmd.SetRayTracingShaderPass(rts, passName); // cmd.SetRenderTarget(rt);//光追结果输出到rt(一个render texture)上 配置其它属性 // cmd.DispatchRays(rts, rayGenShaderName, rt.width, rt.height, 1, 目标相机); 其中SetRayTracingShaderPass()必须要比DispatchRays()先调用。\n1.4 RayTracingAccelerationStructure RayTracingAccelerationStructure的构造方法需要用到RayTracingAccelerationStructure.RASSettings，无参的默认ManagementMode是手动的而非自动的，而Renderer的光追一般用自动的(Instance的必须用手动的)：\n1 2 RayTracingAccelerationStructure.RASSettings settings = new RayTracingAccelerationStructure.RASSettings(RayTracingAccelerationStructure.ManagementMode.Automatic, RayTracingAccelerationStructure.RayTracingModeMask.Everything, ~0); RayTracingAccelerationStructure accStruct = new RayTracingAccelerationStructure(settings); 因此对于Mesh Renderer(更准确点的是所有Renderer)，只要其rayTracingMode不是Off的(这是Renderer的属性，Inspector窗口下没显示的需要脚本修改)，在Build的时候都会自动添加，无需使用AddInstance()再逐个添加(只有ManagementMode是手动的时候才需要自行添加，RayTracingSubMeshFlags也才有意义)。\n对于静态的场景(相机可动，场景Renderer不可动)，我们仅需要在实例化RayTracingAccelerationStructure后(如果有手动添加的，则在手动添加后)执行accStruct.Build()即可。但是如果这个场景的Transform是动态的，比如受骨骼影响的Skinned Mesh Renderer，那么我们只需要在Update()里执行accStruct.Build()。当然，如果存在手动添加的对象，那么需要对手动添加的逐个调用accStruct.UpdateInstanceTransform()。\n1.5 Renderer的光追结果 2. Instance的光追 Instance的光追大体上与Renderer的光追类似，不过有几个必须注意的点：\nManagementMode必须是手动的。 Hit Groups Shader中需要有Intersection Shader。 如果Instance仍是一般的三角形且无其他特殊需求，那么应该用Renderer来绘制：Using intersection shaders instead of the build-in ray-triangle intersection is less efficient but offers far more flexibilit. 我们可以用Intersection Shader配合SDF来绘制一些几何体的实例(当然，如果是一些简单几何体可以用更快的检测方案：见[2]Geometric Tools)。像我们在项目中就做的更简单了，直接绘制的是给出的AABB对应的长方体。\nIntersection Shader在Hit Groups 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 Shader \u0026#34;Shader名称\u0026#34; { Properties { 需要用到的材质属性 } SubShader { Pass { Name \u0026#34;HWRayTracing\u0026#34; Tags{ \u0026#34;LightMode\u0026#34; = \u0026#34;HWRayTracing\u0026#34; } HLSLPROGRAM #pragma raytracing HitShader #include 必要的.cginc文件(或.hlsl文件) 用到的材质属性 [shader(\u0026#34;intersection\u0026#34;)] void Intersection() { 利用PrimitiveIndex()获得索引，来访问指定的Buffer以获取当前AABB对应的实例信息 if (达成了检测条件) { ReportHit(...); } } [shader(\u0026#34;closesthit\u0026#34;)] void ClosestHit(inout RayPayload rayPayload : SV_RayPayload, AttributeData attributeData : SV_IntersectionAttributes) { 可以利用attributeData，对rayPayload处理 } ENDHLSL } } } PrimitiveIndex()是当前AABB在GraphicsBuffer存的数组中的索引，我们可以用这个索引访问其他含有当前AABB所包围的几何体的信息的Buffer。然后我们可以利用得到的几何体数据与当前射线的信息来判断是否相交。需要注意的是，自定义的Instance Shader中的碰撞检测虽然并没有显式的约束，但是由于在加速结构中搜索用的是Ray-AABB检测，因此我们在Instance Shader中所能做的碰撞检测中的一方只能是射线或点(如果想做Sphere-AABB碰撞检测，那么需要传入时增大AABB，这会破坏BVH带来的加速效果)。\n我们用ReportHit(float THit, uint HitKind, attr_t Attributes)来提交当前检测的结果，当然，如果提交的THit不在RayTMin()与 RayTCurrent()之间，那么会提交失败并返回false。\nCPU端的脚本与Renderer的大差不差，除了在手动给RayTracingAccelerationStructure添加实例时，我们需要传入使用类似上面给出的Shader的创建的材质。除了RayTracingAccelerationStructure通过SetRayTracingAccelerationStructure()设置后，是全部都可以访问的。其他的诸如Buffer，Texture等，在通过SetRayTracingBufferParam()等方法设置后仅能在给定的RTS内访问，其他的想要访问要么用SetGlobalBuffer()来设置全局可访问的，要么用Material.SetBuffer()来设置(像我们项目里这样)。\nInstance的光追结果(黄金屋)：\nAABB：40000 Bounce：8 分辨率：1920X1080 Ray/Pixel：1 RTX4070：210FPS(HWRT), 40FPS(自行实现的BVH+GPGPU软光追) NOTE\n用RX6600在相同条件下运行相同场景：34FPS(HWRT)，14FPS(自行实现的BVH+GPGPU软光追)，10.7FPS(用Unity自带的24个顶点的长方体的Renderer的光追，而RTX4070则是130+FPS) 3. 基于PBD的布料模拟 我们这里就不详细展开PBD的原理了，阅读原论文[3]Müller, Matthias, et al (2007)以及整理的教程[4]Bender, Jan, et al (2017)就很容易理解其原理。我们的重点放在HWRT能在这其中起到什么样的作用。不论是PBD，XPBD还是Small Steps，其一般的约束的实现的原理都大差不差(我是指在GPU中实现并行的原理)：与骨骼对网格顶点的作用原理一致，对给定的约束，我们只需记录\n该约束下每个顶点相关的其他顶点的索引以及该约束可能需要记录的一些系数的初始值构成的结构体：需要基于网格的顶点顺序按序添加到一个数组中。 每个顶点有多少个上述的相关的结构体，以及其在数组中的起始索引：通常这两个值可以被压缩至一个int里。 需要注意的是碰撞也是一大类约束，是可以细分为更具体的约束的，比如Self Collisions、Environment Collisions等。项目里我们就不考虑Self Collisions了，因为以现在给出的接口我们要做也只能做GPGPU的Self Collisions，而没法做HWRT的。至于Environment Collisions，有两种方案来做碰撞检测(约束是分开做的)：\n射线-网格(三角形)碰撞检测。 顶点-Instance(简单几何体)碰撞检测。 3.1 碰撞检测 3.1.1 射线-网格碰撞检测 这个检测方法是我们项目里所使用的检测方案。大致原理就是基于射线(更准确的应该说是线段)与环境的网格做相交检测来实现碰撞检测。在场景以及布料的运动速度较低的情况下，这种碰撞结果有着相对更高的准确性(相较于简单的几何体，网格的“精度”更高)。\n首先，射线的原点(这里也应该就是射线的起点)就是给定的顶点。但是射线的方向就值得我们稍微思考一下了。一种很自然的想法是取当前顶点的速度方向为射线方向。但是实际使用后会发现这样的效果并不好，这是因为在给定时刻，顶点的速度可以认为就只是包含了单个点的信息，而如果需要包含“邻域”的信息，那么应该选择使用该点的法线更为合适(用法线还能解决顶点速度值过小的情况下的方向选择问题)。还需要考虑到一个事实就是：有些约束算法并不一定能让检测到碰撞的顶点“立刻被弹开”(比如可能当前顶点速度太快)，导致顶点“错误”的进入了网格“内部”。那么为了能检测到是否在内部，我们可以用两个方向相反的射线来检测：如果在“内部”，那么存在一条射线的方向与命中的点的法线的内积是大于0的(当然，这并不是我们最终用于判断顶点是否在内部的办法)。\n以上我们只是大概的判断了是否会碰撞的情况，因为还有一个关键点没有确认：碰撞点(更准确的应该是射线(段)的长度)。显然，如果射线的长度很长，那么很容易就检测到碰撞，因此我们需要布料有“厚度”，如果当前顶点到碰撞点的切平面的距离小于等于布料的厚度(顶点在外部的时候)那么我们才认为顶点发生了碰撞。不过，即使是我们给布料有了“厚度”，也不代表射线的长度就可以任意长了。我们考虑下图这个非常简单的情况，\n假设$OP$是不可伸缩的一段布料，那么当$O$点固定时$P_1$将是$OP$运动过程中与墙壁的真实碰撞点。显然，以$P$点法线方向发出的射线与墙壁的交点$P_2$只能接近$P_1$而无法达到(在未发生碰撞的过程中)。因此我们必须得给射线长度一个上限，否则在一些情况下其预估的碰撞点和“真实”的碰撞点会差别很大(因此也就没必要通过顶点速度来限定射线长度了，因为通常射线长度上限不会很长)。\n有了碰撞点之后，我们再去判断顶点是否在模型内部(现在是考虑了“厚度”的影响后的)，只需考虑碰撞点的法线与顶点到碰撞点的向量的内积是否大于0即可。\n3.1.2 顶点-Instance碰撞检测 基于上面这种方案，我们可以发现碰撞点的预估是非常重要且依赖与射线长度的：这就限制了环境与布料的运动速度，应用性不那么强。那么一个自然的想法是：我们直接利用SDF来判断顶点是否在简单几何体的内部，并获得法线。这可以利用Instance的光追来实现。\n3.2 碰撞约束 一般来说环境碰撞约束可以用[4]Bender, Jan, et al (2017)里介绍的(我们改写了一下)： $$ C(\\mathbf{x})=\\mathbf{n}\\cdot\\mathbf{x}^{\\prime}-thickness, $$ 其中$\\mathbf{x}^{\\prime}$是碰撞点$\\mathbf{p}$到顶点$\\mathbf{x}$的向量，$\\mathbf{n}$是碰撞点的法线。显然$\\mathbf{n}\\cdot\\mathbf{p}$是一个常量(如果环境是静态的话)，那么这个式子就可以转为参考的那个约束条件了。不过，这个约束的实际使用效果并没有多么好(可能是我们碰撞检测预估的点并不那么理想造成的)，因此在项目里我们用的并不是约束，而是简单的碰撞模型：环境的质量无穷大，导致顶点碰撞后速度方向被“反射”，值的大小有一定的损失(如果环境是静态的，那么损失的多一些效果会好一点。如果环境是动态的，损失的少一点会好一些)。\n3.3 模拟效果 我们的布料模拟效果如下：\n$\\Delta t = 0.001$， Solver Iterator Count：32 4. 项目的使用说明 考虑到项目大小以及资源来源(虽说用的也是Unity提供的)，我就不上传Demo与素材了，需要的可以按照下面的步骤配置。\n4.1 Renderer的光追 默认只需在在相机下添加UniRTCamera组件(至于Mesh Renderer的材质可以用Assets/UniBuiltinHWRT/Shader/UniPBR中的Shader)，其中SimpleRT在Assets/UniBuiltinHWRT/Shader/RTS中，SkyboxTex则只需一张纹理格式为RGB HDR Compressed BC6H的Cube Map即可。\n如果将UniRTCamera中的光追加速结构的ManagementMode改为手动的，那么只需给对应的Mesh Renderer下添加UniRTRenderer组件即可\n此外，如果需要创建新的Hit Groups Shader的话，可以在编辑器的文件窗口下右键Create/UniHWRT/RT Shader/New Unity Renderer RTS来创建新的Shader文件。\n4.2 Instance的光追 与Renderer的类似，用的RTS也是一样的\n只是长方体需要对目标对象下添加InstanceRTRenderer组件(因为是大量的几何实例，所以一般是脚本生成这些对象并配置材质参数的)\n4.3 基于PBD的布料模拟 布料模拟会稍微麻烦一些。我们需要离线生成一些必要的数据，在此之前，需要保证我们目标的SkinnedMeshRenderer中的网格代表的就是需要模拟的布料，如果需要模拟的只是其Submesh中的一个的话，可以添加SkinnedSubMeshSplitter组件，\n并按照变量名配置，分离出对应的子网格，然后再用Unity的FBX Export插件把新的对象导出至本地。然后我们需要在目标的SkinnedMeshRenderer下添加PBDClothMeshConvertor组件，并点击转换按钮(我没有优化里面的算法，因此对于复杂的网格，转换的时间需要不少)\n就会在网格对应的.fbx或.obj文件同目录下生成转换完的文件。选中该文件，点击编辑Fixed顶点按钮，\n我们就能打开一个编辑窗口\n点击网格上的顶点，我们可以修改其顶点权重(全改完后需要点击应该更改才能保存)，\n这里我们一般要么用0要么用1：\n1表示该顶点完全由骨骼控制(如果骨骼不动，就等效于一般网格的固定点了)， 0表示该顶点完全由PBD模拟结果控制。 首先，我们需要在布料对应的SkinnedMeshRenderer下添加PBDClothRenderer组件，其中RelatedSkinnedMesh就是该SkinnedMeshRenderer。\n然后在相机上添加PBDClothSimCamera，其中TargetPBDCloth就是上面添加的PBDClothRenderer。\n对环境碰撞物，除了要使用用于碰撞检测的材质外，还需要添加PBDClothCollider组件(如果ManagementMode是手动的)。\nNOTE\n需要注意的是，我们默认布料的scale都是$(1,1,1)$(不论是local的来说lossy的)，如果不是，那么会影响离线计算的边的BaseLength的值。如果比例是等比的不同，那么可以手动修改PBDClothSimuCS.compute中的代码，乘一个系数即可。 References [1]DirectX Raytracing (DXR) Functional Spec | DirectX-Specs\n[2]Geometric Tools\n[3]Müller, Matthias, et al. \u0026ldquo;Position based dynamics.\u0026rdquo; Journal of Visual Communication and Image Representation 18.2 (2007): 109-118.\n[4]Bender, Jan, Matthias Müller, and Miles Macklin. \u0026ldquo;A survey on position based dynamics, 2017.\u0026rdquo; Proceedings of the European Association for Computer Graphics: Tutorials (2017): 1-31.\n","date":"2025-03-12T00:00:00Z","image":"https://RicciFloOow.github.io.com/p/unity-built-in-ray-tracing/Imgs/PT_InstanceSample_hu_eec2105c44f80695.png","permalink":"https://RicciFloOow.github.io.com/p/unity-built-in-ray-tracing/","title":"Unity Built-In Ray Tracing"}]