ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CommandBuffer高效描边:LayaAir 3D角色高亮与轮廓方案

CommandBuffer高效描边:LayaAir 3D角色高亮与轮廓方案 做3D项目的人应该都遇到过这个需求角色被选中时要亮一圈、可交互物体需要高亮、技能范围圈需要描边提示。在LayaBoxLayaAir 3D里很多人的第一反应是换材质、加个发光Shader或者直接在模型外面套一层放大的半透明模型。这些方案要么效果粗糙要么DrawCall爆炸要么和后处理管线冲突。后来我换成了CommandBuffer方案这个问题才算真正解决。这篇内容我按“方案选型 → 整体设计 → 核心代码 → 问题排查 → 实战经验”的顺序拆解代码基于LayaAir 3.0的CommandBuffer API思路通用。无论你是第一次接触CommandBuffer还是已经在项目里踩过描边的坑都能找到可以直接抄作业的部分。1. 为什么描边偏偏选了CommandBuffer方案1.1 三种常见描边方案的横向对比先聊点实在的。项目里做描边基本绕不开下面三条路。第一种是“套壳”方案也就是复制一份模型放大后反过来渲染。实现倒是简单但问题很多放大模型会导致轮廓线粗细不统一相机拉远时描边粗得离谱贴近时又细得看不见而且每个需要描边的物体都要额外多一次DrawCall几十个角色同时选中时性能直接崩。还有一个更麻烦的问题是放大后的模型经常会穿进地面、墙里描边漏出来一大截观感非常差。第二种是屏幕后处理边缘检测基于深度法线做Sobel或者Roberts算子提取轮廓。这个方案的优势是风格化统一全屏都能处理但很难精确控制“只给某个物体描边”。你要给角色A描边、不给角色B描边就得靠ID Buffer区分本质上还是多渲染一张RT复杂度并不低而且边缘检测出来的描边粗细是像素级均一的想做出“物体离相机越远描边越细”的真实效果也很费劲。第三种就是CommandBuffer方案。它相当于在渲染管线的指定环节插入一段自定义GPU命令先把需要描边的物体画到一张独立的RT上做完描边处理再和主相机画面合成。它的核心优势是不干扰主场景的正常渲染流程可以精确指定描边对象描边粗细可控还能直接叠加后处理。用一句话概括就是“在流水线旁边加一条支线单独加工一批零件最后再汇入主线”。三种方案对比我整理成了表格方便你直接判断方案实现难度描边精度性能开销适配场景套壳放大模型低低高多一次DrawCall每个物体临时演示、原型验证屏幕后处理边缘检测中中中全屏多Pass全屏风格化、卡通渲染CommandBuffer方案中高高低只画目标物体选中高亮、交互提示、技能范围1.2 CommandBuffer版本适合什么场景CommandBuffer方案最适合的场景是“需要精确控制描边对象”的地方。比如MMO里队友和怪物区分高亮、副本里可交互的物件提示、MOBA里技能范围预警、甚至编辑器里场景选中的物体描边都是它的主场。我们项目里还用它做过地面技能指示器玩家释放技能前地面会出现一个圆形范围提示这个提示既要跟着鼠标转动又不能被场景里的树、墙体遮挡得看不见。用CommandBuffer画一层独立的范围线RT再叠加回主画面效果比任何UI方案都干净。如果你只是需要“全屏黑白描边滤镜”那CommandBuffer反而绕远了直接屏幕后处理更省事。这也是我一开始强调“先判断需求再选方案”的原因。2. 整体渲染流程设计2.1 一个典型的CommandBuffer描边流程拆解CommandBuffer描边的思路并不复杂核心就四步主相机正常渲染场景到屏幕或者渲染到一张SceneRT。把需要描边的物体用“膨胀法线模板写入”的方式绘制到一张独立的OutlineRT上这张RT里只有描边轮廓颜色。再把主相机画面和OutlineRT合成为最终画面输出。听起来简单但实际落地时有几个细节直接决定效果好坏。我拆开讲。首先OutlineRT不是直接把物体画上去就行而是分成两个Pass。第一个Pass叫“写入模板”把物体正面渲染到模板缓冲把模板值标记为1这个Pass不输出任何颜色。第二个Pass叫“绘制轮廓”把物体沿法线方向外扩一点剔除正面、只画背面然后设置模板测试为“不等于1才通过”。这样外扩出来的部分凡是落在物体内部模板区域的都会被挡掉只有超出物体轮廓边缘的一圈能通过最终输出纯色的描边。为什么要剔除正面只画背面因为正面外扩之后仍然大面积覆盖在物体表面上模板挡得住内部、挡不住边缘容易出现描边跑到物体脸上的问题。背面外扩配合模板测试等于只画“轮廓外圈”这才是干净描边的关键。然后是合成。主相机画面和OutlineRT合成时最终颜色等于主画面颜色加上描边RT颜色。因为描边RT里只有边缘部分有颜色内部区域因为模板测试已经全部被剔除了所以相加不会污染物体表面。2.2 模板缓冲在描边里的作用模板缓冲是这个方案的核心很多人第一次接触描边都是死在这里。简单解释一下模板缓冲可以理解成一张“遮罩登记表”。第一次渲染时我们把物体正面的像素在表上登记为1第二次渲染时GPU每画一个像素都会先查这张表如果表里当前像素的登记值是1说明这个位置是物体内部就丢弃如果登记值不等于1说明是物体外部边缘就放行。这样处理描边就不会和物体表面产生任何颜色叠加描边永远只出现在物体外围那一圈。相比“先画放大模型再画本尊覆盖”的做法模板方案的最大优势是边缘干净、不受物体本身纹理和Alpha影响。在LayaAir里创建RenderTexture时一定要选择带深度和模板缓冲的格式。我一般用的是RenderTextureDepthFormat.DEPTHSTENCIL_24_8这是24位深度加8位模板的标准组合。如果只创建颜色格式不带模板第二步的模板测试会直接失效描边会变成一团糊在模型上的色块这是新手最容易踩的坑。2.3 渲染目标与资源配置资源准备这块重点讲三个RT尺寸、CommandBuffer生命周期、材质数量。RT尺寸建议和主相机实际分辨率保持一致。理论上可以用半分辨率来省带宽但描边本来就依赖边缘精度降分辨率会导致描边边缘出现明显锯齿后期还得加模糊处理得不偿失。如果你的项目性能压力实在大可以考虑只把OutlineRT设为三分之二分辨率然后合成时做一次双线性过滤视觉上损失可控。CommandBuffer一定要复用不要在Update里每帧new一个新的。CommandBuffer本质上是一段命令列表构建它是有开销的。正确做法是在物体进入描边状态时创建并添加命令退出描边状态时移除并销毁描边期间只需要更新矩阵、颜色这些Shader数据。材质也不需要每帧创建。模板写入材质和描边输出材质各准备一个就行多物体不同颜色通过setShaderData(u_OutlineColor, color)在运行时切换。3. 核心实现Shader与C#侧代码3.1 描边Shader的完整写法先看描边Shader。我这里把两个Pass写在一个Shader里用SubShader索引区分。第一个Pass负责写入模板第二个Pass负责外扩绘制轮廓。以LayaAir的Shader规范为例核心代码大概是这样的结构// OutlineShader.shader Shader Custom/Outline { Properties { _OutlineColor(Outline Color, Color) (1, 1, 1, 1) _OutlineWidth(Outline Width, Range(0, 0.05)) 0.01 } SubShader { // Pass 0写入模板不输出颜色 Pass { Cull Back ColorMask 0 Stencil { Ref 1 Comp Always Pass Replace } CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); return o; } fixed4 frag(v2f i) : SV_Target { return fixed4(0, 0, 0, 0); } ENDCG } // Pass 1法线外扩绘制轮廓 Pass { Cull Front Stencil { Ref 1 Comp NotEqual Pass Keep } CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; }; uniform float _OutlineWidth; uniform 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 } } }这段代码我用的是类Unity ShaderLab语法方便说明逻辑。LayaAir 3.x的Shader写法不同它用的是LayaShader自定义格式语法结构不完全一样但Pass配置、Cull、Stencil这些渲染状态是通用的对应迁移过去即可。注意Pass 0里我设置了Cull Back也就是只渲染正面。为什么因为我们拿模板标记的是物体前表面朝向相机的像素区域。Pass 1里改成Cull Front只渲染背面并且顶点在裁剪空间里沿法线方向外扩。法线外扩的量我们用的是_OutlineWidth这个值属于模型空间概念实际项目中我会在后处理里根据相机距离动态修正。还有一个细节Pass 1里模板测试通过后是Pass Keep也就是不改变模板值。如果你写成Pass Replace会把模板值覆盖掉多物体描边时会导致轮廓互相污染。这个我后面在问题排查里还会再提。3.2 合成Shader的写法描边RT画完之后接下来要把它和主相机画面合成。合成Shader相对简单核心就是采样两张图然后把描边颜色叠加到画面颜色上。// CompositeShader.shader Shader Custom/CompositeOutline { Properties { _MainTex(Scene RT, 2D) white {} _OutlineTex(Outline RT, 2D) black {} } SubShader { Pass { 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; sampler2D _OutlineTex; float4 _MainTex_ST; v2f vert(appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag(v2f i) : SV_Target { fixed4 sceneColor tex2D(_MainTex, i.uv); fixed4 outlineColor tex2D(_OutlineTex, i.uv); // 描边RT的alpha通道用来标记是否有描边 fixed3 finalColor sceneColor.rgb outlineColor.rgb * outlineColor.a; return fixed4(finalColor, 1.0); } ENDCG } } }这段合成Shader里最关键的一行是sceneColor.rgb outlineColor.rgb * outlineColor.a。为什么用alpha做开关而不是直接判断颜色值因为如果直接用颜色值判断那描边色正好是黑色时就会误判。我在描边Pass输出时把描边色写入RGB同时把alpha固定为1这样合成时只要alpha为1就代表这个像素是轮廓。如果你的项目需要在描边上叠加半透明效果也可以把描边RT的alpha改成一个可调变量比如0.8合成时描边就会带一点透明感适合做技能范围淡入淡出。3.3 C#侧CommandBuffer构建C#侧代码是实际控制渲染流程的关键。我用LayaAir 3.x的TypeScript风格写一个最小可运行示例API名称如果和你的引擎版本有差异参照引擎源码调整一下即可。// OutlineEffect.ts import { CommandBuffer, Camera, MeshRenderer, RenderTexture, Vector4, Material } from laya; export class OutlineEffect { private _cmd: CommandBuffer; private _outlineRT: RenderTexture; private _camera: Camera; // 两个材质一个写模板一个描边输出 private _maskMat: Material; private _outlineMat: Material; // 合成材质 private _compositeMat: Material; // 需要描边的物体列表 private _targets: MeshRenderer[] []; constructor(camera: Camera, screenWidth: number, screenHeight: number) { this._camera camera; // 1. 创建带深度模板缓冲的RT this._outlineRT RenderTexture.createFromPool( screenWidth, screenHeight, RenderTextureFormat.R8G8B8A8, RenderTextureDepthFormat.DEPTHSTENCIL_24_8 ); // 2. 创建材质加载Shader并设置参数 this._maskMat new Material(); // 假设MaskShader对应Shader中的Pass 0 this._maskMat.setShaderData(u_OutlineWidth, 0.0); this._outlineMat new Material(); this._outlineMat.setShaderData(u_OutlineColor, new Vector4(1.0, 1.0, 0.0, 1.0)); this._outlineMat.setShaderData(u_OutlineWidth, 0.03); this._compositeMat new Material(); this._compositeMat.setShaderData(u_SceneTex, camera.renderTarget); this._compositeMat.setShaderData(u_OutlineTex, this._outlineRT); // 3. 创建CommandBuffer并添加命令 this._cmd new CommandBuffer(); this._cmd.setRenderTarget(this._outlineRT); this._cmd.clearRenderTarget(true, true, true, new Vector4(0, 0, 0, 0)); camera.addCommandBuffer(this._cmd, CameraEventFlags.AfterImageEffect); } public addTarget(renderer: MeshRenderer): void { this._targets.push(renderer); // 每次新增物体往CommandBuffer里追加两条绘制命令 // 第一条写模板 this._cmd.drawMesh(renderer.meshFilter.sharedMesh, renderer.transform.localMatrix, this._maskMat); // 第二条描边输出 this._cmd.drawMesh(renderer.meshFilter.sharedMesh, renderer.transform.localMatrix, this._outlineMat); } public removeTarget(renderer: MeshRenderer): void { this._targets.splice(this._targets.indexOf(renderer), 1); // 实际项目中建议重建CommandBuffer这里简化为标记处理 } public destroy(): void { this._camera.removeCommandBuffer(this._cmd); this._cmd.destroy(); RenderTexture.recoverToPool(this._outlineRT); this._maskMat.destroy(); this._outlineMat.destroy(); this._compositeMat.destroy(); } }这段代码里有几个点需要重点说明。第一clearRenderTarget(true, true, true, ...)三个布尔值分别表示清颜色、清深度、清模板。模板缓冲每一次重新构建命令时都必须清一次否则上一帧的模板值会残留描边会出现“残影”或者越堆越厚的现象。第二drawMesh第二个参数传的是物体当前的世界矩阵。如果物体是动态的、会移动这里一定要保证每帧调用setShaderData更新矩阵或者用引擎的drawRender接口让引擎自动处理。第三CommandBuffer的添加时机。CameraEventFlags选择的是AfterImageEffect意味着主相机的后处理已经执行完我们再开始描边合成。如果你把时间点放在BeforeImageEffect描边也会被后处理影响可能出现描边被Bloom吃掉或者被抗锯齿磨掉的情况。这个时间点选择取决于你到底想“让描边参与后处理”还是“描边叠加在后处理之上”。3.4 多物体描边与颜色区分项目里不可能只描边一个物体几十个角色一起选中是常态。多物体描边时有两种做法。第一种是遍历所有描边目标依次执行“写模板 → 画描边”共用同一个OutlineRT。这种做法的好处是只需一张RT、一个CommandBuffer但要注意所有描边物体的轮廓颜色一样。如果你希望不同物体不同颜色就在每一条描边绘制命令之前调用setShaderData(u_OutlineColor, color)来切换颜色。第二种是多颜色同时描边需要多张OutlineRT。每个颜色的物体组各画一张RT合成时分别叠加。这样DrawCall会翻倍但对颜色区分的支持最彻底。实际项目里我一般优先用第一种因为大多数需求只是“高亮”和“不高亮”的二值判断颜色区分需求不多。还有一点是关于半透明物体的。CommandBuffer里的描边Pass我默认用了模板方案它对半透明物体是不友好的。因为模板缓冲只标记“有这个物体”但半透明物体背后还能看到后面的场景描边会把后面的轮廓也兜进去。这个问题我放在后面的问题排查部分详细讲。4. 常见问题与排查技巧实录4.1 描边被遮挡、穿透和闪烁问题描边被遮挡这件事每个做过的人应该都经历过。你辛辛苦苦把描边画出来结果角色走到墙后面描边还在墙上透出来特别出戏。这个问题本质上是“描边RT没有深度信息”。我用的方案里描边RT虽然创建了深度缓冲但主相机渲染时并不写这个深度。所以描边绘制时它不知道场景里有一堵墙在前面照常把后面的轮廓画出来。解决办法有两个思路。一个是描边RT在合成时额外采样主相机的深度纹理描边像素如果深度大于场景深度就说明被挡住了此时丢弃描边。另一个思路更简单粗暴描边本来就是为了“隔着墙也能看到目标”的设计那就没必要修。很多游戏里队友高亮就是故意穿透墙的因为玩法需要。闪烁抖动问题大多出在外扩系数和深度冲突上。法线外扩之后外扩面片有时候会和模型自身表面产生Z-fighting。我的处理办法是在顶点外扩之后把顶点沿视线方向微调一个偏移量让外扩面片的深度永远略微靠近相机一点。这个偏移量我一般取主相机nearPlane的千分之一左右具体值根据场景缩放调整。4.2 轮廓粗细不稳定近粗远细还是近细远粗这是描边方案里最容易暴露水平的问题。我的经验是不要用固定世界空间宽度而应该用屏幕空间宽度或者说“固定屏幕像素宽度”。具体做法是这样的在顶点Shader里先算好外扩顶点在裁剪空间的位置再根据裁剪空间w分量来缩放外扩量。w分量本身就和相机距离相关距离越远w越大因此外扩量除以w就能抵消远近距离的缩放影响。用这个公式float expand _OutlineWidth / clipPos.w * _ScreenParams.y;这样不管物体在屏幕里多大描边的像素宽度基本稳定。近处不会粗得吓人远处不会细得看不见。当然也要考虑美术需求。有些技能范围提示需要“近粗远细”的透视感那就不用这个修正直接用世界空间外扩即可。4.3 移动端性能与兼容性排查LayaBox的主力场景是H5和小游戏移动端性能是绕不开的话题。第一个兼容性风险是WebGL版本。模板缓冲在WebGL1里是能用的但DepthTexture、MRT这些高级特性在WebGL1下支持很差。我的建议是项目直接启用WebGL2。如果你的项目还守着WebGL1跑那这个描边方案就要谨慎评估尤其是RT格式DEPTHSTENCIL_24_8部分老设备不支持。第二个性能重点是RT带宽。一张和屏幕同分辨率的RGBA8888 RT每帧至少要清一次、画一次、读一次移动端带宽开销不小。优化路径是动态降分辨率描边RT分辨率可以跟随相机渲染比例调整在低端机上自动降为一半然后在合成时配合双线性采样。实测下来高刷屏上对帧率的改善非常明显。第三个重点是DrawCall控制。CommandBuffer里的drawMesh和普通渲染一样计算DrawCall多一个描边物体就多两次DrawCall写模板画描边。我见过有人一股脑给几百个物体全加描边帧率直接腰斩。实际操作时一定要做剔除只在视锥体内的物体才添加描边命令超出屏幕就移除。4.4 与半透明物体、后处理特效共存最常见的一个坑是角色站在半透明玻璃罩子里描边把玻璃也“描”进去了。这个问题在模板方案里确实是硬伤模板缓冲不会区分不透明和半透明只要是这个物体的渲染像素就会被标记。我的建议是如果项目里半透明交互需求很多不要用模板方案改成“双层渲染方案”描边RT里先画本尊的黑色遮罩再画外扩轮廓用遮罩的Alpha做差集来判断边缘。这样至少遮罩层是软的描边不会把半透明物体吞进去。后处理共存问题反而简单。描边合成我放在AfterImageEffectBloom、Color Grading这些后处理已经执行完描边一定在效果之上。如果你希望描边被Bloom提亮那就把CommandBuffer改到BeforeImageEffect两种效果风格完全不同看需求选择。5. 实际项目里的几条经验最后分享几条我做CommandBuffer描边时踩过的经验算是个人的碎碎念。第一CommandBuffer构建完不要随意修改。如果你发现需要频繁增删描边物体我的做法是给每个描边会话单独建一个CommandBuffer会话结束就销毁。而不是维护一个巨大的命令列表然后反复重建这样调试起来会很痛苦。第二模板值一定要记得清。我遇到过最诡异的一个问题是描边物体切换后新物体的描边区域出现奇怪的洞找了两天才发现是上一帧模板值没清干净。所有CommandBuffer构建时clearRenderTarget的模板通道必须显式清空。第三Shader里的_OutlineWidth不要在C#侧硬编码最好做成可调参数用LayaAir的setShaderData运行时更新。策划调参、美术调参都会频繁改描边粗细留个接口比每次改代码重新构建方便得多。第四如果你做的是即时战斗类游戏角色数量多、选中频繁建议给描边目标做分帧处理。每帧只更新一部分物体的描边状态而不是一帧内把所有物体的描边命令全部重建有效避免指令高峰导致的掉帧。这个方案后续还能扩展比如描边色叠加上流光效果、描边边缘做模糊柔化、描边RT配合Mask做技能扇形范围都是在现有CommandBuffer框架上继续加命令的事。先把最核心的模板描边跑通后面都是锦上添花。
返回列表