简介:一套完整的Unity3D选中物体描边特效工程包,覆盖描边颜色随时间变化、宽度动态缩放、Ctrl键追加多选、重复点击取消及点击空白处统一清除等交互。实现基于模板纹理与模糊后处理:先对选中物体纯色渲染生成模板,经模糊外扩得到模糊纹理,再依据像素是否位于模板内部输出原色或描边色,并以透明度控制边缘过渡,适合有渲染基础、想掌握后处理描边原理的开发者。工程共16536个文件,含3425个C#脚本、材质、着色器、预制体、模型、PNG贴图及DLL等,压缩包约94.03MB,可直接在Unity中打开研究。已有1583人学习,提供从原理到交互的完整实现,有助于快速理解模板测试与模糊后处理在选中特效中的应用。
1. 选型思考:描边特效的方案对比与取舍
在做3D交互项目时,“选中物体要给个视觉反馈”几乎是绕不开的需求。不管是编辑器工具、工业展示、还是游戏里的拾取系统,描边特效都是最常见的做法之一。我这次用的是Unity3D,目标很简单:鼠标点击任意物体,给它加上一圈清晰醒目的描边,点击另一个物体时,上一个描边消失,新物体亮起来。
在动手前我先梳理了主流的描边实现路线,这里也给各位一个参考。如果没有做过类似功能,直接按我的选型逻辑走就行。
1.1 四种常见描边方案横向对比
法线外扩(双Pass描边):核心思路是把物体渲染两遍,第一遍正常绘制,第二遍把顶点沿法线方向向外推出一段距离,只画背面,这样边缘自然形成一圈外扩轮廓。这是目前最普遍、性能也最稳的方案,适合绝大多数静态或动态物体。
屏幕空间边缘检测:用后处理的方式在屏幕纹理上找深度或法线不连续的区域,然后叠加颜色。好处是通用性强,不用改模型,缺点是处理透明物体容易漏边,而且像素级检测在低分辨率下会有锯齿。
顶点色/贴图预烘焙轮廓:美术在建模阶段就画好轮廓线,运行时直接采样显示。效果最可控,但需要美术配合,无法对运行时动态生成的物体即时响应。
几何着色器生成轮廓:在GPU阶段根据三角形邻接关系动态生成轮廓边。效果最精准,但移动端支持不理想,且实现复杂度高,调试成本大。
1.2 为什么我选择法线外扩
对比下来,法线外扩方案的性价比最合适。它不需要后处理相机,不依赖屏幕分辨率,对单个物体的轮廓控制非常直接,改动面也小——只要给目标物体多挂一个材质,C#侧做状态管理就行。
我在项目里还额外验证了一个关键点:可交互物体的数量不大,且都是标准MeshRenderer。如果场景有上千个物体同时需要描边,那就要考虑GPU Instancing或者合并渲染,否则Draw Call会吃紧。这个问题后面第4部分细说。
2. 整体设计:从“点击”到“亮边”的链路拆解
这个功能看起来简单,但真正写代码时如果链路不清,很容易出现“描边消不掉”“点击空白处描边残留”“多个物体同时高亮”这类问题。所以先把链路拆清楚再写代码。
2.1 完整交互链路
鼠标点击触发射线检测,命中的物体进入“待选中”状态;此时立刻把上一个选中物体的描边关闭,再给新物体开启描边。整个流程可以拆成四步:射线检测 → 状态重置 → 描边启用 → 数据记录。
说白了就是“先关旧、再开新”。很多新人会踩的坑是只记得开新描边,忘了关旧的,结果场景里越点越亮。
2.2 渲染通道的分离设计
这里有一个容易忽略的点:描边材质最好不要污染原始材质。我见过有人直接在Renderer.material上改Shader,结果退出交互后物体材质被永久替换,再想恢复就得保存一份原始引用。正确做法是单独维护一份描边材质实例,需要时临时替换,不需要时换回原材质。
我采用了字典结构来管理:Dictionary<Renderer, Material> originalMaterialMap,记录哪些Renderer被替换过,点击新物体时先恢复旧Renderer的材质,再处理新物体。这个方案对单体Renderer和带子物体的多Renderer模型都适用。
2.3 交互层的可复用性
为了让这个功能不局限于单一项目,我把“选中检测”和“描边表现”拆成了两个模块。检测模块只负责输出“当前选中的Renderer列表”,表现模块负责描边的添加与移除。这样以后想换成高亮、外发光,甚至播放一段动画,都不需要动射线检测的代码。
3. 实操过程:Shader编写与C#脚本实现
重头戏来了。我直接贴出可以跑的完整代码,并逐一解释关键行的作用。如果你是新手,建议先把Shader原样抄进工程,跑通效果后再去调参。
3.1 描边Shader完整实现
Shader "Custom/Outline" { Properties { _OutlineColor ("Outline Color", Color) = (1, 1, 0, 1) _OutlineWidth ("Outline Width", Range(0, 0.1)) = 0.01 } SubShader { // 第一遍:正常渲染物体本来的样子 Pass { Tags { "RenderType"="Opaque" } Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { return tex2D(_MainTex, i.uv); } ENDCG } // 第二遍:沿法线外扩,只画背面形成描边 Pass { Cull Front CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; }; float _OutlineWidth; fixed4 _OutlineColor; v2f vert (appdata v) { v2f o; // 重点:把顶点沿法线方向推开 float3 worldNormal = UnityObjectToWorldNormal(v.normal); float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; worldPos += worldNormal * _OutlineWidth; o.vertex = UnityWorldToClipPos(worldPos); return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG } } }这个Shader的核心就是第二遍Pass里的worldPos += worldNormal * _OutlineWidth这一行。它的作用是让所有顶点沿着法线方向撑大一圈,然后只渲染正面被剔除后的背面区域,也就是模型边缘露出来的外壳。
3.2 参数调优心得
_OutlineWidth的单位是“世界空间下的距离”,实际使用时注意别给太小或太大。我在测试场景里相机距离物体3米左右,宽度设为0.02效果比较合适;如果镜头拉远到10米,视觉上描边会变细,这时需要动态调整宽度。
更精细的做法是把宽度从世界空间转到屏幕空间:在Shader里用distance(_WorldSpaceCameraPos, worldPos)计算距离,再乘一个缩放系数,保证任何镜头距离下描边粗细一致。这个优化我建议放到V1.1再做,先把基础流程跑通更重要。
3.3 C#侧:射线检测与描边管理
下面是控制脚本的核心部分。我把它拆成两个文件:OutlineManager.cs负责描边状态管理,SelectionController.cs负责射线检测。
using System.Collections.Generic; using UnityEngine; public class OutlineManager : MonoBehaviour { private Dictionary<Renderer, Material> originalMaterials = new Dictionary<Renderer, Material>(); private Renderer currentTarget; public void SetOutline(GameObject target, Color color, float width) { ClearOutline(); if (target == null) return; Renderer[] renderers = target.GetComponentsInChildren<Renderer>(); if (renderers.Length == 0) return; currentTarget = renderers[0]; // 为每个Renderer创建描边材质实例,并替换当前材质 foreach (Renderer renderer in renderers) { if (!originalMaterials.ContainsKey(renderer)) { originalMaterials[renderer] = renderer.sharedMaterial; } Material outlineMat = new Material(Shader.Find("Custom/Outline")); outlineMat.SetColor("_OutlineColor", color); outlineMat.SetFloat("_OutlineWidth", width); renderer.material = outlineMat; } } public void ClearOutline() { if (currentTarget == null) return; Renderer[] renderers = currentTarget.GetComponentsInChildren<Renderer>(); foreach (Renderer renderer in renderers) { if (originalMaterials.TryGetValue(renderer, out Material original)) { renderer.material = original; originalMaterials.Remove(renderer); } } currentTarget = null; } }using UnityEngine; public class SelectionController : MonoBehaviour { [SerializeField] private OutlineManager outlineManager; [SerializeField] private Color outlineColor = new Color(1f, 0.8f, 0.2f); [SerializeField] private float outlineWidth = 0.02f; private void Update() { if (Input.GetMouseButtonDown(0)) { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit)) { outlineManager.SetOutline(hit.collider.gameObject, outlineColor, outlineWidth); } else { outlineManager.ClearOutline(); // 点击空白区域清除描边 } } } }3.4 这段代码的设计细节
注意ClearOutline里我用GetComponentsInChildren处理了带子物体的模型。实测中一个角色模型往往有多个MeshRenderer,如果只处理第一个,会出现“身体亮了但手臂没亮”的尴尬情况。用GetComponentsInChildren一步到位,省心很多。
还有一个容易被忽略的细节:恢复材质时我用的是originalMaterials字典里存的sharedMaterial,而不是material。因为renderer.material会自动实例化一份新材质,每次都调用会造成内存泄漏,必须用sharedMaterial做原始备份。这样项目跑久了内存也不会膨胀。
4. 实操中的常见问题与排查技巧
写代码五分钟,调试两小时。这个功能看着简单,实际跑起来会遇到各种幺蛾子。我把踩过的坑整理出来,多数是Unity开发新手绕不过去的坎。
4.1 描边被其他物体遮挡或穿透
最典型的表现是:描边出来了,但被场景里的墙或地面挡住,或者反过来,描边穿透了墙体显示在另一面。原因在于描边Pass的深度测试和主Pass一致,但顶点外扩后深度值发生了变化。
我当时的处理方式:给描边物体单独设一个Layer,让主相机忽略这个Layer,再增加一个专门渲染描边的相机叠加画面。这样描边永远不会受场景遮挡逻辑干扰,视觉上永远浮在物体表面之上。
如果不想增加相机,也可以用Offset指令调整深度偏移,但效果不如独立相机可控。
4.2 透明物体描边后出现黑色块
这个坑是我在调试一个玻璃材质模型时发现的。透明物体的渲染队列是Transparent,它需要关闭深度写入才能正确混合。我的描边Shader第二遍Pass默认是ZWrite On,结果透明物体描边后会出现黑色轮廓块。
解决办法是在描边Pass里显式设置ZWrite Off,并调整渲染队列:
Tags { "Queue"="Transparent" "RenderType"="Transparent" } ZWrite Off Blend SrcAlpha OneMinusSrcAlpha透明物体的描边方案,建议单独做一份Shader变体,不要和Opaque混在一起。
4.3 多个Renderer导致Draw Call暴涨
刚才说了,每个Renderer都需要替换材质,如果一个模型有10个子物体,一次选中就产生10个新材质实例,性能直接拉垮。我排查时用Profiler看了一眼,Draw Call从50直接跳到70。
解决思路有两个方向:一是给所有子物体合并成一个材质,用Texture Atlas替代多材质;二是用MaterialPropertyBlock避免实例化材质,只修改块里的颜色和宽度参数,这样GPU实例化能跑满。
MaterialPropertyBlock block = new MaterialPropertyBlock(); renderer.GetPropertyBlock(block); block.SetColor("_OutlineColor", color); block.SetFloat("_OutlineWidth", width); renderer.SetPropertyBlock(block);用PropertyBlock的好处是描边材质可以实现成共享材质,不需要每个Renderer单独new一份,Draw Call能压下来不少。
4.4 模型边缘有破面或裂缝
这个问题出现在低模或UV接缝明显的模型上。法线外扩本质上依赖模型法线数据,如果模型的法线本身存在硬边或者贴图接缝,外扩后的描边会呈现断裂、锯齿或者穿插现象。
我当时的临时方案是换一个更平滑的法线数据来源,比如从相邻三角形插值法线,但工程量大。更实际的做法是在模型导入设置里把Normals模式改成Calculate,如果模型里已经包含了错误的法线,Unity会自动重新计算。
如果是运行时动态生成的网格,生成时就要调用mesh.RecalculateNormals(),确保法线平滑。
4.5 问题排查速查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 描边被遮挡 | 深度测试冲突 | 独立相机+独立Layer,或调整Offset |
| 描边断断续续 | 法线数据异常 | 导入设置里重新计算法线,或调用RecalculateNormals |
| 透明物体描边发黑 | 没有关闭深度写入 | 设置ZWrite Off,调整Queue为Transparent |
| Draw Call暴涨 | 每帧实例化过多材质 | 使用MaterialPropertyBlock或合并Renderer |
| 点击空白处描边残留 | 没有处理空射线 | 在射线未命中时调用ClearOutline |
| 描边粗细随镜头变化 | 世界空间固定宽度 | 改成屏幕空间宽度,乘以距离系数 |
5. 一个容易被忽视的扩展方向
描边特效做完之后,我的下一版需求变成了“选中高亮时闪烁提醒”。这个需求的实现方式其实非常简单——不需要改动Shader和C#核心逻辑,只需要在Update里动态修改描边材质的颜色Alpha值。
float alpha = Mathf.PingPong(Time.time * 3f, 1f); outlineMat.SetColor("_OutlineColor", new Color(1f, 0.8f, 0.2f, alpha));这个思路可以继续扩展到“描边呼吸效果”“按下时变粗再变细”等更细腻的反馈。用Shader控制外扩宽度,用C#控制时间曲线,就能组合出各种交互反馈。
我做这个功能最大的体会是:描边本身是一个很轻量的功能,但把它接入现有项目的交互体系后,会牵扯到材质管理、相机渲染、性能优化、状态维护等多个环节。把这些环节想明白,写起来就是一马平川。各位如果按我这套代码跑通了,后续再遇到类似需求,比如“选中发光”“选中泛光”,核心思路都通用。
本文还有配套的精品资源,点击获取