简介这是一份面向Unity开发者的描边效果Outline Effect学习与应用资源仿照Unity Highlighting System设计可满足角色高亮、UI元素突出、场景物件边界强调等常见视觉需求。压缩包共包含582个文件整体仅4.9MB核心文件包括Shader着色器、C#脚本、材质、Unity场景及贴图png/exr其中大量info、meta、asset等文件是Unity工程的元数据与资源索引配合使用即可完整还原描边效果的实现流程。当前已有173人学习下载。通过研读并导入该工程开发者能够理解基于法线或深度信息检测边缘的描边原理掌握Shader中描边宽度、颜色、透明度等参数的调节方法也能借助已有脚本与材质快速集成到自己的项目中用于游戏角色选中、交互提示或场景美化提升整体视觉表现力。1. 仿Highlighting描边这次不用再靠“加个边框”糊弄选中反馈做3D交互类项目的人十有八九遇到过这个场景模型选中了随手加个线框或者半透明罩子结果物体和背景颜色一接近描边直接被吞掉玩家根本看不出选中了谁。这份仿Highlighting的描边效果资源走的是另一条技术路线——用额外相机把目标物体渲染成纯色mask再在屏幕后处理阶段做像素级边缘检测。选择这种方案的原因很直接描边宽度是屏幕像素级的模型复杂度、纹理细节、相机距离都不影响最终效果多个物体重叠也不会出现错位。这篇解析会把目录结构、核心Shader、C#控制脚本逐段拆开再给出导入步骤和常见坑。适合Unity项目里要做“选中即高亮”交互反馈的开发者。2. 技术选型与项目结构mask渲染 屏幕空间边缘检测2.1 从“仿Highlighting”反推技术路线不碰模型只碰像素这类描边效果在Unity社区里能见度很高围绕它也有不少仿写版本。核心思路很统一先用一个额外相机把需要描边的物体渲染到一张RenderTexture上得到一张纯色mask。这张mask里被选中的物体区域是亮色其余区域是透明或黑色。随后在屏幕后处理阶段对mask做边缘检测检测到的边缘像素替换成指定描边色非边缘像素原样输出。整套流程不依赖模型拓扑也不依赖贴图细节所以一个复杂的雕像和一个Cube的描边逻辑完全一致。技术选型上这份资源走的是Unity内置渲染管线的老路子C# 负责控制相机管理、RT生命周期、描边目标注册。ShaderLab 负责渲染一道Shader做mask提取一道做边缘检测与混合。OnRenderImage 作为后处理挂接入口。这套方案的优点和缺点都很明显。优点是实现直白整个效果链路只靠十几行C#加两个Shader就能跑通调试时可以对着帧缓冲一步步看出问题出在哪。缺点是它绕过了SRP在URP工程里不能直接用需要额外做一层适配。第四章会专门讲这个坑。2.2 目录结构逐层拆解Scripts、Shaders、Editor各管什么展开这份rar文件里面不是散装脚本而是一个带完整Assets和ProjectSettings的标准Unity项目目录。因为自带Demo场景导入后不需要额外搭场景直接打开就能看效果。先看整体结构Assets/ ├── OutlineEffect/ │ ├── Scripts/ │ │ ├── OutlineManager.cs │ │ ├── OutlineTarget.cs │ │ └── Editor/ │ │ └── OutlineTargetEditor.cs │ ├── Shaders/ │ │ ├── OutlineExtract.shader │ │ └── OutlineComposite.shader │ ├── Scenes/ │ │ └── Demo.unity │ ├── Materials/ │ │ └── OutlineMaskMat.mat │ └── README.md └── ProjectSettings/ ├── ProjectSettings.asset └── EditorBuildSettings.asset每个文件职责分得很清没有一类文件是身兼多职的Scripts/OutlineManager.cs是总控。挂在主相机上负责创建RT、调度mask相机渲染、调用Composite Shader做边缘检测以及把最终结果写回屏幕。Scripts/OutlineTarget.cs挂在每个需要描边的物体上管理自身是否被注册进描边列表。Shaders/OutlineExtract.shader只做一件事把物体渲染成纯色。它接收一个颜色参数不管原材质长什么样输出一律是纯色。Shaders/OutlineComposite.shader做屏幕空间边缘检测主体是四邻域梯度运算。它采样mask的周围像素算出变化量变化量大的地方输出描边色。Materials/OutlineMaskMat.mat是Extract Shader的默认材质实例主要给编辑器里预览mask用运行时由脚本new材质不依赖这个资产。Editor/OutlineTargetEditor.cs是Inspector扩展加了个预览按钮方便在不进Play模式的情况下直接看描边效果。这个分包方式建议保留。后面集成到自己项目时整个OutlineEffect目录拖进Assets就行不用把脚本一个个挑出来放错位置。2.3 数据流从场景物体到最终屏幕像素的完整路径把Unity的渲染流程切到“描边模式”来看这一帧发生了什么。假设场景里有个Cube挂上了OutlineTarget且注册成功数据流是固定的五步主相机照常渲染所有物体得到正常画面src。程序在OnRenderImage入口里先把专用相机outlineCamera的targetTexture指向maskRT并把cullingMask设成只渲染“Outline”这一层。调用outlineCamera.Render()。此时所有挂在Outline层上的物体被逐个渲染到maskRT。因为它们使用的都是OutlineExtract材质最终mask里选中区域是纯白背景是纯黑。Composite Shader被设为活动材质Graphics.Blit(src, dest, compositeMat)执行。片元着色器对mask做多次采样计算梯度找到边缘。边缘像素混合描边色非边缘像素直接取src原色最终输出到dest也就是屏幕画面。这套“先产生mask再边缘检测”的链路和直接给模型做顶点外扩描边最大的区别在于mask是像素级的模型面数再多最后的轮廓线依然是平滑的。多个要描边的物体重叠时mask亮区叠加边缘检测只关心亮暗交界处不会出现外扩方案的“错位重影”。3. 核心模块拆解mask生成、边缘检测与动态注册3.1 OutlineExtract.shader任意模型一律拍平成一张纯色mask这个Shader是整套方案的地基。它本身不画任何细节只输出一种颜色。直接看代码Shader Hidden/OutlineExtract { Properties { _MaskColor (Mask Color, Color) (1, 1, 1, 0) } SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { Cull Off ZTest Always // 多个描边物体互相遮挡时mask依然全亮 ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; fixed4 _MaskColor; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { // 固定输出mask色不贴图、不光照、不受雾影响 return fixed4(_MaskColor.rgb, 1.0); } ENDCG } } }有人会问为什么mask渲染也要写Shader直接用一个白色标准材质不行不行。标准材质会受光照、阴影、雾效影响渲染出来的不是纯色边缘检测的阈值就没法稳定设置。这里为了杜绝所有不可控因素片元输出写死。三个状态位也刻意做了处理。Cull Off保证模型背面也能写入摄像头碰到物体内部时不会黑半边ZTest Always让所有描边物体的像素无条件写入mask——两个描边物体一前一后时后面的不会被前面的深度测试卡掉mask始终覆盖整个亮区ZWrite Off避免mask写入深度缓冲干扰后续渲染。实际改这份资源时Extract Shader基本不用动真要动的是URP环境下把CGPROGRAM换成HLSLPROGRAM并引入对应SRP库。3.2 OutlineComposite.shader用Sobel找梯度边缘像素自动“发亮”mask生成之后重头戏在Composite这一步。这个Shader既要算边缘又要做结果混合。核心片段如下Shader Hidden/OutlineComposite { Properties { _MainTex (Scene, 2D) white {} _MaskTex (Mask, 2D) black {} _OutlineColor (Outline Color, Color) (1, 1, 0, 1) _Threshold (Threshold, Range(0, 1)) 0.3 _OutlineWidth (Outline Width, Range(0.5, 4)) 1.0 } SubShader { Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; sampler2D _MaskTex; float4 _MaskTex_TexelSize; fixed4 _OutlineColor; float _Threshold; float _OutlineWidth; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata_img v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.texcoord; return o; } fixed4 frag (v2f i) : SV_Target { // 用像素尺寸缩放采样半径实现“宽度N个像素” float2 texel _MaskTex_TexelSize.xy * _OutlineWidth; float4 center tex2D(_MaskTex, i.uv); float4 up tex2D(_MaskTex, i.uv float2(0, texel.y)); float4 down tex2D(_MaskTex, i.uv - float2(0, texel.y)); float4 left tex2D(_MaskTex, i.uv - float2(texel.x, 0)); float4 right tex2D(_MaskTex, i.uv float2(texel.x, 0)); // 四邻域差之和近似梯度大于阈值触发描边 float gradient length(center - up) length(center - down) length(center - left) length(center - right); fixed4 scene tex2D(_MainTex, i.uv); if (gradient _Threshold) { return fixed4(_OutlineColor.rgb, 1.0); } return scene; } ENDCG } } }这里几个细节值得停下来讲。_MaskTex_TexelSize是Unity自动注入的表示mask贴图单个像素在UV坐标系里的宽高。用它乘上_OutlineWidth采样半径就变成了“N个像素”。这个参数直接控制描边的视觉宽度但它不是模型空间的距离而是屏幕像素宽度——分辨率越高同样的数值在屏幕上显得越细。gradient的计算用的是四邻域差绝对值之和等效于一个简化版Sobel算子。之所以不写完整的三乘三Sobel卷积核是因为这里不需要判断边缘方向只需要知道“周围变化大不大”。少做几次乘加运算移动端帧率能省一点是一点。_Threshold需要对着实际项目调。mask颜色越纯这个值越好设这也是Extract Shader必须输出固定颜色的原因。要是输出带光照的灰色渐变gradient对任何阈值都会有误判。这套逻辑处理不了的边缘场景是目标物体自带镂空或凹陷时镂空区域的边界也会被当成轮廓。比如一个栅格模型描边会把每个格子的内边界都勾出来。这在大多数拾取高亮场景里是可接受的因为用户看到的确实是物体的每一个可见轮廓如果只想描最外圈就得在Composite里额外利用深度纹理做方向判断Demo里保留的是全轮廓方案。3.3 OutlineManager.cs 与 OutlineTarget.cs注册、渲染、释放C#端一共两个核心类。先看OutlineTarget挂在需要描边的物体上using UnityEngine; [RequireComponent(typeof(Renderer))] public class OutlineTarget : MonoBehaviour { public Color outlineColor Color.yellow; [Range(0.5f, 4f)] public float outlineWidth 1f; [Range(0f, 1f)] public float threshold 0.3f; [SerializeField] private int targetLayer 9; private int originalLayer; void Awake() { originalLayer gameObject.layer; } public void SetOutlineActive(bool active) { gameObject.layer active ? targetLayer : originalLayer; } }这个类的核心动作是SetOutlineActive把物体切到指定层。因为mask相机只渲染这一层“切层”就等于“注册进描边列表”。这种设计比维护一个动态List要稳你永远不会遇到“物体已销毁但列表里还留着引用”的空引用问题。targetLayer默认9因为0到8是引擎保留层9通常空闲。如果你的项目里9已经被占用改成一个空闲的自定义层即可。编辑器的实时预览由OutlineTargetEditor接管运行时动态开关注射直接调SetOutlineActive就行代码不掺和。再看OutlineManager挂在主相机上using UnityEngine; [RequireComponent(typeof(Camera))] public class OutlineManager : MonoBehaviour { public Camera outlineCamera; public Shader extractShader; public Shader compositeShader; public int outlineLayer 9; public Color outlineColor Color.yellow; public float outlineWidth 1f; public float threshold 0.3f; private Material extractMat; private Material compositeMat; private RenderTexture maskRT; void Start() { extractMat new Material(extractShader); compositeMat new Material(compositeShader); outlineCamera.cullingMask 1 outlineLayer; outlineCamera.clearFlags CameraClearFlags.SolidColor; outlineCamera.backgroundColor Color.clear; outlineCamera.enabled false; // 不进主渲染链路 } void OnRenderImage(RenderTexture src, RenderTexture dest) { EnsureMaskRT(); outlineCamera.targetTexture maskRT; outlineCamera.Render(); compositeMat.SetTexture(_MaskTex, maskRT); compositeMat.SetColor(_OutlineColor, outlineColor); compositeMat.SetFloat(_OutlineWidth, outlineWidth); compositeMat.SetFloat(_Threshold, threshold); Graphics.Blit(src, dest, compositeMat); maskRT.DiscardContents(); } private void EnsureMaskRT() { if (maskRT null || maskRT.width ! Screen.width || maskRT.height ! Screen.height) { if (maskRT ! null) maskRT.Release(); maskRT new RenderTexture(Screen.width, Screen.height, 0, RenderTextureFormat.ARGB32); maskRT.name OutlineMaskRT; } } void OnDestroy() { if (maskRT ! null) { maskRT.Release(); maskRT null; } if (extractMat ! null) DestroyImmediate(extractMat); if (compositeMat ! null) DestroyImmediate(compositeMat); } }这段代码把第二章的数据流变成了真实执行的动作。EnsureMaskRT放在OnRenderImage开头而不是Start是为了应对编辑器里拖动窗口导致分辨率变化的情况——RT尺寸不匹配时自动重建。outlineCamera.enabled false这句非常重要如果忘了副相机就会在主流程里把自己渲染到屏幕把画面刷成一个纯色。OnDestroy里的释放逻辑是保命用的。编辑器里反复Play-Stop的人应该深有体会RT和材质不释放显存会被一点点吃满。这套代码里maskRT直接按屏幕全尺寸创建最直观移动端项目可以改成Screen.width / 2做降采样配合Composite里的双线性过滤能省不少填充率。3.4 为什么不直接用“法线外扩”方案看到这里熟悉描边实现的人会问还有更省的法线外扩做法给物体做两个Pass第二Pass把顶点沿法线推开一段距离再输出描边色不是更省一个相机确实法线外扩性能更优实现更短。但它的短板同样明显模型必须是封闭的开口模型外扩后会破面相机靠近物体时轮廓会明显膨胀看起来像物体“长胖了”。而mask加边缘检测的方案描边宽度是屏幕像素级的跟相机距离无关放大缩小都不变。这份资源选择用帧率换稳定性适合拾取高亮、编辑器辅助线这类需要视觉可预测的场景。如果你的项目里全是封闭模型、相机距离固定换成外扩法完全没问题否则建议保留这套方案。4. 导入配置与避坑记录跑通Demo再谈改造4.1 环境要求与导入步骤这份资源对Unity版本不算苛刻2018到2020左右的编辑器都能直接打开再往后的版本只要不是URP工程也都能跑。导入方式有两种一是用Unity Hub直接“打开现有项目”选中解压后的目录二是只把Assets/OutlineEffect复制到已有工程的Assets下。第二种方式更适合你手里已经有业务工程、只想把描边能力嵌进去的情况。如果选第二种记得先确认Tag and Layers里第9层是否空闲。包自带的ProjectSettings里已经预置了一层名为Outline的Layer所以在Demo场景里不用手动配一旦搬到新工程就需要在Edit - Project Settings - Tags and Layers里把第9层命名成Outline或者在OutlineManager里改outlineLayer字段指向其他空闲层。4.2 验证“描边是否真的由边缘检测产生”跑通后建议做两个快速验证确认你对这套方案的理解没有偏差。第一个验证把outlineCamera的cullingMask清零再Play描边立即消失但场景正常。这说明描边确实来自mask渲染而不是Shader里悄悄给模型加了轮廓。第二个验证把_Threshold调大到0.9再Play描边会变成零星的噪点而且噪点只出现在物体轮廓附近。阈值越高边缘越难触发这是正常的。如果你观察到噪点出现在物体内部大块区域说明mask被某些非目标物体污染了优先检查layer配置。4.3 避坑记录五个反复出现的坑坑1两个描边物体相互遮挡时后面的描边不见了现象物体A和物体B都挂了OutlineTargetA在前B在后。B被A挡住的那部分在屏幕上没有描边看起来“描边不完整”。原因在最初的版本里Extract Shader用默认的ZTest LEqualB的像素在mask渲染时被A的深度卡掉了导致mask缺失了被遮挡区域。解决把Extract Shader改成ZTest Always让所有描边物体的像素无条件写入mask。这样互相遮挡的物体都能在mask里亮起来描边也就能画在完整轮廓上。代价是描边会“透视”出来选中反馈反而更清晰一般不需要担心。坑2URP工程里完全没反应现象把包导入URP工程打开场景Play时没有描边也没有报错。原因URP接管了渲染流程OnRenderImage这个回调在SRP下是死代码。不是脚本没挂是引擎根本不再调用它。解决两条路。一是把渲染逻辑挪到RenderPipelineManager.endCameraRendering事件里用CommandBuffer提交Blit二是直接把Composite Shader升级成URP的HLSL版本。如果项目还处于“刚启用URP但还没大规模换Shader”的阶段优先选事件方案Shader不用动。坑3整个屏幕刷成mask颜色现象Play之后屏幕变成一片纯色正常画面完全消失。原因outlineCamera.enabled没有设成false副相机在正常渲染流程里也执行了一遍它的画面覆盖了主相机的输出。解决把专用mask相机的enabled关掉只在OnRenderImage里手动调Render()。这样不会出现双写画面也不会出现相机重复渲染的警告。坑4移动端高分辨率下描边有锯齿现象真机上把_OutlineWidth调到1以下边缘出现一格一格的锯齿。原因maskRT全分辨率时边缘检测在像素边界突然过渡最终blit到显示分辨率时又做了一次线性采样硬边就被放大成了锯齿。解决将maskRT降采样到屏幕尺寸的一半让双线性过滤自然柔化边缘。代价是_OutlineWidth为0.5时细描边会变淡属于正常取舍。坑5反复进出Play模式后编辑器变卡现象反复Play-Stop几十次编辑器帧率下降GPU显存占用稳步上升。原因每次Start都new了一个RenderTexture和两份材质但释放逻辑没有走全。特别是当OutlineManager挂载的物体被禁用或销毁时OnDestroy不一定会及时执行。解决用RenderTexture.GetTemporary管理RTReuse同一尺寸的RT减少反复分配同时在OnDestroy里做完整清理。排查时先看Profiler的Memory模块确认是不是RT和Material在持续累积。上面这五个坑前两个属于方案的技术边界后三个属于工程落地细节。这类后处理方案肉眼看到的现象和真实原因之间隔着好几层渲染状态没有排查思路就只能瞎试。把这几条记在心里能省掉大半天调试时间。提示如果你在别的机器上打开Demo发现描边颜色和截图不一样先别怀疑Shader坏了检查OutlineManager上暴露的outlineColor字段全局颜色在这里改。5. 二次开发把单相机描边改造成可复用高亮组件5.1 改造步骤与验证方法这份资源自带的Demo是一次性用法真要落到自己项目里建议做一次小重构把“先注册目标再统一渲染”走成组件级接口。第一步抽出描边配置。把color、width、threshold从单独的OutlineTarget改成一个ScriptableObject资产这样同一组参数可以在多个物体间复用美术改颜色只改一份配置不用逐个物体去点Inspector。第二步给OutlineManager增加一个Register方法把切Layer这类底层操作包成业务接口public void Register(OutlineTarget target, bool active) { target.SetOutlineActive(active); }改完之后逻辑层只知道“注册/注销”完全不关心Layer和RT的事情。后面哪怕把渲染底层换成URP RenderFeature接口面都不需要变。第三步搭一个验证场景一个与背景同色的立方体比如白墙边放白色箱子加描边确认轮廓清晰再放两个相邻物体同时注册描边确认相交处不出现错位把相机拉远拉近确认描边宽度不随距离变化最后在Editor下反复进出Play模式确认帧率不掉、显存不涨。这套改造做完这个包就不再是“一个Demo效果”而是可以嵌进任何Unity项目的通用高亮模块。我自己后来再拿到类似的后处理资源第一件事就是查清楚它的渲染管线兼容性再决定要不要接进当前项目——这个习惯帮我避开了好多次“导入即黑屏”的翻车。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?