简介:这是一份面向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 { "RenderType"="Opaque" "Queue"="Geometry" } 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都挂了OutlineTarget,A在前B在后。B被A挡住的那部分在屏幕上没有描边,看起来“描边不完整”。
原因:在最初的版本里,Extract Shader用默认的ZTest LEqual,B的像素在mask渲染时被A的深度卡掉了,导致mask缺失了被遮挡区域。
解决:把Extract Shader改成ZTest Always,让所有描边物体的像素无条件写入mask。这样互相遮挡的物体都能在mask里亮起来,描边也就能画在完整轮廓上。代价是描边会“透视”出来,选中反馈反而更清晰,一般不需要担心。
坑2:URP工程里完全没反应
现象:把包导入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管理RT,Reuse同一尺寸的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项目的通用高亮模块。我自己后来再拿到类似的后处理资源,第一件事就是查清楚它的渲染管线兼容性,再决定要不要接进当前项目——这个习惯帮我避开了好多次“导入即黑屏”的翻车。希望帮到你。
本文还有配套的精品资源,点击获取