1. 项目概述从“黑盒”到“画笔”几何着色器的艺术解放如果你在Unity里做过一些风格化渲染或者尝试过一些非写实的特效可能会觉得标准的Shader管线有点“束手束脚”。顶点着色器动一动点片元着色器涂一涂颜色中间的过程像个黑盒子。今天要聊的Unity几何着色器就是打开这个黑盒子的钥匙它能让你在GPU的几何处理阶段“无中生有”或“改头换面”直接生成新的顶点和三角面。这听起来有点抽象但它的一个绝佳应用就是实时生成草图风格的轮廓线——不是贴图不是后期效果而是实实在在、根据模型几何实时计算生成的线条。为什么需要这个想象一下你的游戏角色在移动标准卡通描边要么依赖法线外扩容易在复杂结构处断裂要么是屏幕后处理无法区分物体前后层次。而用几何着色器你可以直接读取模型的每个三角面在它的基础上生成代表“草图笔触”的新的线条几何体。这就像给了你一支在3D空间里直接作画的笔控制力是前所未有的。最近不少独立游戏和艺术化Demo里那种灵动、手绘感强烈的轮廓背后很可能就有它的功劳。这个教程适合谁如果你已经熟悉Unity基本的Shader编写至少写过Unlit Shader了解顶点/片元着色器结构对模型空间、裁剪空间这些概念不陌生并且对“用代码创造视觉效果”充满好奇那么几何着色器将为你打开一扇新的大门。它不算是日常开发的高频工具但绝对是让你从“Shader使用者”迈向“Shader创作者”的关键一步。接下来我会带你从原理到实战完整实现一个基于几何着色器的动态草图轮廓效果过程中会穿插大量我踩过的坑和总结的优化技巧。2. 核心原理与管线定位几何着色器如何工作在深入代码之前我们必须搞清楚几何着色器在渲染管线中的位置和工作机制。这决定了我们能做什么、不能做什么以及如何高效地去做。2.1 渲染管线中的“雕塑家”标准的可编程渲染管线简化流程是顶点着色器 - 曲面细分着色器 - 几何着色器 - 片元着色器。几何着色器是可选的它紧接在顶点着色器或曲面细分着色器之后。它的输入不是一个顶点而是一个完整的图元Primitive。在大多数情况下我们处理的是三角形图元也就是一次输入三个顶点已经经过了顶点着色器的变换。它的核心能力是销毁、修改或创建新的图元。你可以输入一个三角形然后不输出任何图元销毁它。输出一个修改过的三角形比如进行一些顶点位移。输出完全不同的图元比如将这一个三角形拆分成多个三角形或者输出代表轮廓的线段Line Strip或点Point。对于我们的“草图”效果核心思路就是第三种。我们将每个输入的三角形在其三条边上生成新的、细长的四边形两个三角形来模拟笔触线条。这就是“无中生有”——几何着色器输出的顶点数可以远大于输入。2.2 性能考量与适用场景这是最关键的部分直接决定你是否应该在项目中使用它。几何着色器有一个显著特点它会导致GPU的流水线动态分支增多且输出数据量可变对性能不友好尤其是在低端移动设备上。所以它的典型应用场景是GPU粒子系统一个点图元输入生成一个面向摄像机的广告牌四边形。毛发/草地渲染在网格表面生成毛发线段。程序化几何生成如我们的草图轮廓线或者将简单网格细分为复杂网格但通常曲面细分着色器更合适。阴影体积生成。一个重要警告不要试图用几何着色器做大规模模型细分或替代整个高模它的性能开销会非常大。它最适合用于“增量式”的、每个输入图元只生成少量新图元的特效。注意在Unity的URP/HDRP中对几何着色器的支持与内置渲染管线不同需要编写特定的[Geometry]Pass并且一些语法和内置变量可能有差异。本教程以内置渲染管线为例原理相通迁移时需注意API变化。3. 实战构建草图轮廓几何着色器理论说得再多不如动手写一行代码。我们目标是创建一个Shader为任何模型添加可自定义的草图式轮廓边。3.1 创建基础Shader与几何着色器函数首先在Unity中创建一个新的Standard Surface Shader或者Unlit Shader然后我们将其改造成包含几何着色器的版本。几何着色器在HLSL中通过特定的函数结构定义。Shader Custom/SketchOutlineGeometry { Properties { _MainTex (Albedo (RGB), 2D) white {} _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 // 轮廓线属性 _OutlineColor (Outline Color, Color) (0, 0, 0, 1) _OutlineWidth (Outline Width, Range(0, 0.1)) 0.05 _OutlineNoise (Outline Noise, Range(0, 1)) 0.2 // 用于模拟手绘抖动 } SubShader { Tags { RenderTypeOpaque } LOD 200 // 第一个Pass渲染原本的模型 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 4.0 // 几何着色器需要至少3.04.0更保险 ... // 标准的表面着色器代码 ENDCG // 第二个Pass专门用于渲染轮廓线使用几何着色器 Pass { Tags { LightMode ForwardBase } Cull Off // 关闭裁剪因为轮廓线可能在任何方向 ZWrite Off // 通常轮廓线写在模型之上不写入深度避免Z-fighting Blend SrcAlpha OneMinusSrcAlpha // 如果需要半透明轮廓 CGPROGRAM #pragma vertex vert #pragma geometry geom #pragma fragment frag #pragma target 4.0 #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2g { float4 vertex : SV_POSITION; float3 normal : NORMAL; float4 worldPos : TEXCOORD0; }; struct g2f { float4 vertex : SV_POSITION; float4 color : COLOR; }; float4 _OutlineColor; float _OutlineWidth; float _OutlineNoise; // 顶点着色器准备数据给几何着色器 v2g vert (appdata v) { v2g o; o.vertex v.vertex; o.normal v.normal; o.worldPos mul(unity_ObjectToWorld, v.vertex); // 转换到世界空间计算 return o; }上面是Properties和第一个渲染模型本身的Pass。重点在第二个Pass它包含了#pragma geometry geom声明以及连接顶点着色器(vert)和片元着色器(frag)的中间结构体v2g和g2f。3.2 几何着色器核心从三角形到轮廓线接下来是重头戏几何着色器函数geom。它的输入是三角形图元triangle输出是线段流LineStream。我们这里选择生成三角形边的线段这是一种常见且效果不错的轮廓线生成方式。// 几何着色器每个三角形输入生成其三条边的轮廓线段 [maxvertexcount(6)] // 关键声明最大输出顶点数。我们每条边生成2个顶点3条边最多6个顶点。 void geom(triangle v2g input[3], inout LineStreamg2f lineStream) { // 计算三角形的面法线用于后续的宽度扩展方向 float3 edge1 input[1].worldPos.xyz - input[0].worldPos.xyz; float3 edge2 input[2].worldPos.xyz - input[0].worldPos.xyz; float3 faceNormal normalize(cross(edge1, edge2)); g2f o; // 为每条边生成线段 for (int i 0; i 3; i) { int j (i 1) % 3; // 下一个顶点索引 // 获取边的两个顶点 float3 worldPosA input[i].worldPos.xyz; float3 worldPosB input[j].worldPos.xyz; // 计算边的方向 float3 edgeDir normalize(worldPosB - worldPosA); // 计算此边应扩展的方向垂直于边和面法线 float3 expandDir normalize(cross(faceNormal, edgeDir)); // 模拟手绘抖动给扩展方向一个随机扰动 float noise (frac(sin(dot(worldPosA, float3(12.9898, 78.233, 45.543))) * 43758.5453) * 2 - 1) * _OutlineNoise; expandDir faceNormal * noise; // 生成线段的两个顶点向两侧扩展形成宽度 o.color _OutlineColor; // 顶点A向外扩展 o.vertex mul(UNITY_MATRIX_VP, float4(worldPosA expandDir * _OutlineWidth, 1.0)); lineStream.Append(o); // 顶点B向外扩展 o.vertex mul(UNITY_MATRIX_VP, float4(worldPosB expandDir * _OutlineWidth, 1.0)); lineStream.Append(o); // 结束当前线段开始下一条 lineStream.RestartStrip(); } }代码解读与关键点[maxvertexcount(6)]这是几何着色器的生命线。你必须准确估算并声明本函数可能输出的最大顶点数量。声明过小会导致顶点被截断渲染错误声明过大会浪费性能。这里我们为每个三角形的3条边各生成一条2个顶点的线段所以是6。triangle v2g input[3]这表示输入是一个三角形图元包含3个v2g结构的数据。LineStreamg2f我们输出一个线段流。你也可以用TriangleStream输出三角形流。轮廓线生成算法核心是expandDir的计算。我们取边的方向(edgeDir)和面的法线(faceNormal)的叉积得到一个垂直于该边且大致垂直于视图方向如果面法线朝向相机的向量。沿着这个方向将顶点“挤”出去就形成了轮廓宽度。手绘抖动通过一个简单的伪随机函数sin和dot对expandDir施加一个基于顶点位置的扰动让轮廓线宽度有细微的不规则变化模仿手绘草图的不精确感。_OutlineNoise控制扰动强度。lineStream.Append和RestartStripAppend将顶点添加到输出流RestartStrip结束当前线段或三角带并开始新的一个。3.3 片元着色器与最终整合几何着色器输出的顶点会进入片元着色器这里很简单就是输出我们设置的颜色。// 片元着色器非常简单直接输出颜色 fixed4 frag (g2f i) : SV_Target { return i.color; } ENDCG } } FallBack Diffuse }至此一个基础的、基于几何着色器的草图轮廓Shader就完成了。将它赋给一个模型调整_OutlineWidth和_OutlineColor你就能看到模型边缘出现了实时的、由几何体构成的轮廓线。4. 优化、问题排查与高级技巧直接使用上面的基础版本你可能会立刻遇到一些问题。下面是我在实际项目中总结的优化方案和避坑指南。4.1 性能优化与质量控制控制[maxvertexcount]: 这是最重要的性能开关。在保证效果的前提下尽可能设小。例如如果你只生成背向相机的边轮廓那么每个三角形最多可能只生成一条边2个顶点可以将数量设为2。这需要更复杂的背面剔除逻辑。基于距离的宽度衰减: 在geom函数中可以根据顶点到相机的距离length(_WorldSpaceCameraPos - worldPosA)来动态减小_OutlineWidth。这样远处模型的轮廓线不会过粗而显得突兀也能减少重叠和视觉混乱。屏幕空间宽度: 更高级的做法是将_OutlineWidth定义为屏幕空间像素值。这需要在几何着色器中将世界空间顶点转换到裁剪空间后使用ComputeScreenPos和_ScreenParams来对顶点进行像素级别的偏移。这能保证轮廓线在任何分辨率下视觉粗细一致。float4 clipPosA mul(UNITY_MATRIX_VP, float4(worldPosA, 1.0)); float2 screenPosA ComputeScreenPos(clipPosA).xy * _ScreenParams.xy; // 对screenPosA进行像素偏移再转换回裁剪空间...避免内部线条: 当前的实现会渲染所有三角形的所有边导致模型内部也有很多线条。一个改进方案是使用顶点法线。在geom函数中检查共享同一条边的两个三角形的面法线或顶点法线的朝向。如果它们都朝向相机或都背向相机则这条边是内部边应该被剔除。这需要访问相邻三角形的信息实现起来更复杂通常需要预处理或在CPU端准备数据。4.2 常见问题与解决方案实录问题1轮廓线闪烁Z-fighting现象轮廓线和模型表面交替出现快速闪烁。原因轮廓线几何体和模型表面处于几乎相同的深度值深度测试精度不足。解决方案ZWrite Off如我们代码所示关闭轮廓线Pass的深度写入。让轮廓线永远画在模型之上。深度偏移Depth Bias在Pass中使用Offset -1, -1指令强制让轮廓线的深度值比实际更靠前更靠近相机这是一个更稳健的图形学技巧。轻微外推在计算expandDir时除了宽度方向再添加一个微小的、沿面法线朝向相机的偏移量让轮廓线几何体物理上稍微离开模型表面。问题2轮廓线在硬边处断裂或不连续现象在模型硬边如立方体边缘处轮廓线没有连接成光滑的角而是断开的。原因我们是以三角形为单位生成边的。共享一条边的两个三角形会各自生成一条轮廓线但它们的顶点扩展方向expandDir是基于各自的面法线计算的如果两个面法线不同硬边扩展方向就不同导致两条线段在端点处不重合。解决方案这是此类算法固有的挑战。有几种思路接受这种风格对于草图效果轻微的断裂有时反而增加了手绘感。使用顶点法线替代面法线在vert阶段将顶点法线传递给v2g在geom中使用(input[i].normal input[j].normal)/2来计算边的扩展方向。这会使轮廓线在硬边处平滑过渡但可能丢失一些锐利感。后处理融合这是一个“作弊”但有效的方法。先以较粗的宽度渲染轮廓线到一张RT然后进行一次高斯模糊最后再与场景合成。模糊可以弥合细小的断裂。问题3移动设备上性能急剧下降现象在手机上帧率很低。原因几何着色器本身开销大且我们为每个三角形都生成了6个顶点。解决方案LOD细节层次为模型设置LOD Group低LOD级别的模型使用更简单的Shader甚至关闭轮廓线。基于距离的裁剪在几何着色器开头计算三角形中心到相机的距离。如果超过阈值直接return不输出任何几何体。降采样渲染考虑将轮廓线渲染到一个较低分辨率如一半分辨率的RenderTexture再上采样合成这对线条状的效果质量损失不大但能显著减少填充率开销。4.3 风格化扩展让草图更“草”基础轮廓线有了如何让它更像手绘草图线条粗细变化不要用固定的_OutlineWidth。可以根据边的长度、或者用一个基于世界坐标的噪声图采样结果来动态调整每条边的宽度。长的边粗一点短的边细一点。颜色与透明度变化在frag函数中不要直接返回固定颜色。可以结合屏幕空间坐标、时间变量让颜色有细微的深浅或色相变化模拟铅笔或水彩的笔触感。float pulse sin(_Time.y * 5 i.vertex.x * 0.01) * 0.1 0.9; return _OutlineColor * float4(1,1,1, pulse); // 让透明度随时间脉动内部素描线Hatching这更具挑战性。你可以在几何着色器中不仅生成轮廓边还可以基于模型表面的光照强度在vert阶段计算好在暗部区域生成一些代表阴影的、短而随机的内部线段。这需要更复杂的逻辑来控制线段的密度和方向。几何着色器是一把锋利的双刃剑。它给了你底层几何的创造权但同时也要求你对GPU管线和性能有深刻的理解。从这个小而美的“草图轮廓”项目开始你可以逐渐尝试更复杂的几何变换比如将网格爆炸成碎片、实时生成轨迹网格等。记住始终用[maxvertexcount]约束你的创造力并在真机上严格测试性能。当你看到自己用代码生成的几何体在屏幕上流畅舞动时那种成就感是使用现成组件无法比拟的。