ARTICLE DETAIL

资讯详情

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

LayaAir中利用CommandBuffer实现动态描边的实践与踩坑

LayaAir中利用CommandBuffer实现动态描边的实践与踩坑 描边需求在游戏里太常见了选中单位要描边敌人受击要高亮场景交互物要提示可点击。以前我在LayaBox现在统一叫LayaAir项目里做描边第一反应就是给模型换一个双Pass的Shader一个Pass正常渲染一个Pass沿法线方向膨胀做外轮廓。小范围用没问题但一旦需要对场景里任意物体动态加描边、要控制描边优先级、要和后期效果叠加这种方案就会让我很头疼。后来项目升级到LayaAir 3.x我开始把CommandBuffer用起来思路一下清爽了很多。这篇就把我从踩坑到跑通的过程整理一下包括CommandBuffer怎么接入渲染管线、描边Shader怎么配合、以及实际项目里最需要注意的几个深坑。适合刚接触CommandBuffer的LayaAir开发者以及从Unity转过来想快速上手的同事参考。1. 为什么我用CommandBuffer做描边而不是改Shader1.1 改Shader的痛点传统做法很简单在模型材质里加一个Outline Pass顶点朝法线方向偏移背面剔除后让轮廓露出来。但实际项目里用起来会很不舒服。首先是维护成本。你以为只需要一套描边Shader就够了不对。模型可能有透明材质、带特效的材质、需要被光照的材质每一种都要在Shader里再写一遍Outline Pass。美术换了一套PBR材质程序员就要赶紧把描边Pass补上。这种改一处漏一处的情况最后往往变成一坨没人敢动的老代码。其次是动态切换问题。游戏里高亮不是固定的玩家点了A单位A要亮B要恢复原样。如果用Shader Pass就需要运行时替换材质或者给材质开关键字。替换材质会打断动态合批造成DrawCall上涨开关键字又得处理材质实例化内存和GC都有压力。单位一多表现还能忍Profiler里却已经很丑了。还有一个很关键的问题渲染顺序。描边通常希望在不透明物体之后、后处理之前叠加。但普通Shader Pass会被当成常规不透明物体跟着场景一起渲染。一旦描边物体和背景深度穿插就会出现半截描边被吃掉、半截描边颜色发灰的问题。你很难在不改渲染流程的情况下单独调它的顺序。1.2 CommandBuffer带来的好处CommandBuffer的本质是向渲染管线里指定位置插入一段“额外渲染命令”。LayaAir引擎把相机渲染流程拆成了多个节点你可以在这些节点之间追加绘制物体、切换RenderTexture、执行某个后处理效果。用CommandBuffer做描边之后有几个非常直接的改变。第一描边渲染和主材质解耦。你只需要一套描边专用材质挂在CommandBuffer里用主材质的Shader完全不用动。美术换主材质不会再影响描边效果。第二动态控制非常灵活。你可以在运行时把任意GameObject分到一个Layer让CommandBuffer只绘制这个Layer然后随时把Layer换掉。描边目标是谁、颜色是什么全都在脚本层控制不用再去碰一堆材质实例。第三渲染时机明确。CommandBuffer可以挂在相机渲染节点上比如“不透明渲染完后、后处理前”描边就不会被其他不透明物体遮挡得乱七八糟。1.3 什么项目适合这套方案我不是说所有项目都必须上CommandBuffer。如果你的需求只是“游戏里两个固定NPC要显示聊天气泡描边”那老老实实写双Pass Shader反而是成本最低的。但如果你做的是RTS、MOBA、解谜游戏或者其他包含大量可交互物体的项目描边目标经常变、描边样式还想做脉冲和变色甚至还要配合选中高亮、受击提示一起上CommandBuffer几乎是最舒服的选择。它还特别适合从Unity迁移过来的团队。Unity的CommandBuffer生态已经很成熟很多人习惯了那套思路。LayaAir的CommandBuffer虽然API不完全一样但设计逻辑接近迁移成本比我预想的低。把这套能力用上之后遇到“哪些物体要描边”这类需求我基本就不再纠结要不要改Shader了。2. 描边方案选型先想清楚你要哪种边缘2.1 背面外扩描边角色首选背面外扩描边思路是把模型背面的三角形沿法线方向推出去渲染成纯色。由于背面的三角形被正面挡住所以正常情况下只会看到露在轮廓外面的那一圈看起来就是描边。这个方案的优点是实现简单性能开销小。一个Pass解决描边渲染不需要全屏后处理。适合角色这类顶点密度足够、法线朝向比较明确的模型。缺点也明显如果模型顶点法线不连续外扩后边缘会崩如果模型做了非均匀缩放法线外扩方向会偏如果模型面数太低外扩距离稍微大一点就会出现棱角分明的多边形描边。我在实际项目里用背面外扩时一般会要求美术在模型里开启法线平滑或者顶点导入时统一处理法线。否则同一个模型近距离看描边是断的很尴尬。外扩距离不能太大通常控制在物体包围盒尺寸的0.5%到2%左右具体效果要跟分辨率一起调。2.2 深度/法线边缘检测描边场景首选另一种常见方案是把需要描边的物体单独渲染到一张RT然后对这张RT做深度边缘检测。原理和图像处理里的边缘检测很像先渲染物体的深度信息然后对深度图做横向和纵向采样如果相邻像素深度差超过阈值就认为这里是一条边。这套方案的优点是不太依赖模型拓扑场景里乱七八糟高低不平的建筑也能获得干净的轮廓。缺点是开销比背面外扩高因为多了一次RT渲染和一次全屏后处理。而且如果直接对场景深度做检测场景里所有深度跳变的地方都会描边比如地面裂缝、阴影边缘。所以需要先用CommandBuffer把“要描边的物体”单独画进一张深度RT再对这张RT检测这样其他物体的深度不参与描边目标就纯了。2.3 轮廓线几何提取描边高精度但不常用还有一类方案是从几何层面提取真正的轮廓边通过观察方向和法线方向做点积找到正好“看向边缘”的那些边然后单独绘制成线。这种效果最精细能跟随物体运动实时变化但实现成本很高需要做顶点/几何着色器层面的处理移动端兼容性也不好。游戏里大多数情况下是用不到的除非你做的是纯卡通渲染风格的手游并且团队有能力去磨这个技术。普通项目还是先考虑前面两种。2.4 三种方案对比方案实现成本性能边缘质量适用场景背面外扩低高依赖模型法线角色、NPC、道具深度边缘检测中中稳定清晰场景、建筑、地形物体几何轮廓提取高低最精确主美强需求的日系卡通渲染我自己的原则是角色用背面外扩场景大物体用深度边缘检测。两者还可以混用正好能覆盖大部分描边需求。3. CommandBuffer在LayaAir里的基础用法3.1 CommandBuffer的插入时机可以把CommandBuffer理解成相机在拍一张照片时中间有几个“检查点”你可以在检查点之间插入额外动作。LayaAir里的相机渲染节点也差不多常见的有BeforeImageEffect后处理之前、AfterOpaque不透明物体渲染完、BeforeTransparent透明物体渲染前等。命名细节不同版本会有点区别但表达的意思是一致的。做描边时我一般挂在“后处理之前”这个节点。原因很简单我想要场景先正常渲染完拿到一张包含所有不透明物体和透明物体的画面然后再把描边叠加进去。如果挂得太早描边会被后面的透明效果盖住挂得太晚可能已经在最终输出之后效果出不来。选“before image effect”或等价节点基本不会错。3.2 创建与执行基础流程CommandBuffer的常规使用流程是创建CommandBuffer、往里面追加命令、把CommandBuffer添加到相机上。接下来相机渲染的时候就会自动执行。LayaAir目前是TypeScript生态代码写起来和Unity C#很像但又不完全一样。下面是一段示范代码基于LayaAir 3.x整理具体方法名建议以当前项目的引擎d.ts为准。// 创建CommandBuffer并挂到相机上 const camera scene.getChildByName(Main Camera).getComponent(Laya.Camera); const cmd Laya.CommandBuffer.create(); const outlineRT Laya.RenderTexture.create(1024, 1024, 0, Laya.RenderFormat.R8G8B8A8); const outlineMat createOutlineMaterial(); cmd.clear(); cmd.setRenderTarget(outlineRT); cmd.clearRenderTarget(true, true, new Laya.Color(0, 0, 0, 0)); cmd.drawRenderers(Laya.Layer.Highlight, outlineMat, 0); cmd.setRenderTarget(null); cmd.blit(outlineRT, null, createCompositeMaterial()); camera.addCommandBuffer(Laya.CameraEventFlags.BeforeImageEffect, cmd);这里有一个建议CommandBuffer对象建议复用不要每帧都创建再销毁。最稳妥的做法是在初始化阶段构建一次之后只修改Layer、修改材质参数。等需要关闭描边时调用removeCommandBuffer把CommandBuffer从相机上摘掉。3.3 版本差异与注意点老项目里很多人还在用LayaAir 2.x那个阶段没有完整CommandBuffer大家喜欢用自定义后处理脚本来模拟。LayaAir 3.x开始才把这些能力集中到渲染管线层。所以如果你查旧资料看到一堆后处理脚本的写法不一定能直接搬过来。从Unity转过来的人最容易踩的坑是Unity的CommandBuffer里有DrawRendererLayaAir里要不就是叫DrawRenderers要不就是参数列表不一样Unity的Blit有很多重载LayaAir的Blit也会因为目标RT不同而表现不同。别硬背API建议先在LayaAir官方示例里跑一个最简单的CommandBuffer demo确认RT创建、清屏、绘制、Blit这四个动作能跑通再往下加描边逻辑。4. 完整实操用CommandBuffer实现角色描边4.1 准备专用Shader假设我们要做最常用的角色描边用背面外扩方案。需要准备一个描边渲染Shader核心就是顶点沿法线外扩片段输出纯色。LayaAir的Shader可以用GLSL风格来写下面是一段可参考的顶点着色器。attribute vec3 a_Position; attribute vec3 a_Normal; uniform mat4 u_MVPMatrix; uniform float u_OutlineWidth; void main() { vec3 pos a_Position normalize(a_Normal) * u_OutlineWidth; gl_Position u_MVPMatrix * vec4(pos, 1.0); }片段着色器就简单了输出一个可调的纯色。uniform vec4 u_OutlineColor; void main() { gl_FragColor u_OutlineColor; }这里的关键是描边材质必须设置成背面剔除也就是Cull Front这样才能只画模型背面外扩后的三角形。LayaAir Shader里一般通过渲染状态设置或者直接在材质设置里面把cull改成front。忘了这一步的话整个物体会被这个纯色材质包住连正面都会被刷成描边色很吓人。4.2 写CommandBuffer逻辑描边流程我拆成三步。第一步正常场景照常渲染第二步把描边物体用描边材质单独画到一张RT上第三步把RT和场景颜色合在一起输出到屏幕。脚本层面可以这样组织。class OutlineController { private cmd: Laya.CommandBuffer; private outlineRT: Laya.RenderTexture; private outlineMat: Laya.Material; private compositeMat: Laya.Material; init(camera: Laya.Camera) { this.outlineRT Laya.RenderTexture.create(1024, 1024, 0, Laya.RenderFormat.R8G8B8A8); this.outlineMat createOutlineMaterial(); this.compositeMat createCompositeMaterial(); this.cmd Laya.CommandBuffer.create(); this.cmd.clear(); this.cmd.setRenderTarget(this.outlineRT); this.cmd.clearRenderTarget(true, true, new Laya.Color(0, 0, 0, 0)); this.cmd.drawRenderers(Laya.Layer.Highlight, this.outlineMat, 0); this.cmd.setRenderTarget(null); this.cmd.blit(this.outlineRT, null, this.compositeMat); camera.addCommandBuffer(Laya.CameraEventFlags.BeforeImageEffect, this.cmd); } setTargetLayer(layer: number) { // 切换需要描边的物体层级 // 一般通过修改LayerMask实现 this.cmd.setLayerMask(layer); } destroy(camera: Laya.Camera) { camera.removeCommandBuffer(Laya.CameraEventFlags.BeforeImageEffect, this.cmd); this.outlineRT.destroy(); this.cmd.destroy(); } }注意CommandBuffer执行的时机是每帧渲染都会跑。outlineRT如果用固定分辨率要注意和屏幕分辨率匹配否则描边边缘会发虚。项目里我一般把RT分辨率设为屏幕分辨率的一半视觉差异不大性能能省不少。4.3 后处理合成描边到这一步outlineRT里已经画好了外扩后的纯色轮廓。接下来要把它合成到主画面上。合成的方式很多最简单的直接用混合把outlineRT当一张贴图全屏采样遇到纯色像素就输出描边颜色。合成Shader可以这么写。uniform sampler2D u_sceneTex; uniform sampler2D u_outlineTex; varying vec2 v_uv; void main() { vec4 sceneColor texture2D(u_sceneTex, v_uv); vec4 outlineColor texture2D(u_outlineTex, v_uv); float edge step(0.1, outlineColor.a); gl_FragColor mix(sceneColor, outlineColor, edge); }这里我习惯用alpha通道做标记因为outlineRT清屏时会清成透明外扩区域是纯色于是sceneColor和outlineColor混合后只有外扩区域会替换成描边色。但有个问题直接按alpha混合外扩区域会把原始物体也覆盖上颜色看起来像给整个物体染色而不是只有外边一圈。解决办法是不要等到合成阶段才拿到外扩结果而是在外扩RT上做一次边缘检测或者用两张RT第一张画整个物体第二张只画外扩背面然后用两张的差值得到轮廓环。实际项目里我推荐用深度边缘检测后面会细说。4.4 参数控制描边宽度和颜色不管用哪种方案描边参数都要做成可调的。宽度、颜色、透明度、脉动频率这些都是策划和美术提需求时的高频字段。单纯背面外扩宽度直接控制顶点外扩距离。写成uniform float u_OutlineWidth后脚本端每帧往上叠加sin时间就行outlineMat.setFloat(u_OutlineWidth, baseWidth pulse * Math.sin(time * speed));颜色就更好办了setVector即可。要注意的是Shader里如果开了雾效、带了混合模式颜色会和期望值有偏差。描边材质我建议设置为不透明渲染关闭雾效避免轮廓被空气透视影响。边缘检测方案里宽度对应的不是顶点位移而是后处理采样偏移。采样半径越大描边越粗。这个值受RT分辨率影响比较大。分辨率不同同样像素偏移对应的世界空间粗细不一样所以最好把宽度参数转换到UV空间uvOffset pixelWidth / rtWidth。5. 常见问题与排查技巧实录5.1 描边被其他物体遮挡这个问题很多人第一次都会遇到。场景里有个宝箱在墙壁后面但策划希望宝箱被墙挡住时仍然能看到描边提示玩家这里有东西。这时标准的“只画一遍描边”肯定不行因为深度测试会把墙壁后面那部分直接裁掉。我的解决方案是先正常渲染场景深度再用单独的RT把需要描边的物体无视场景深度渲染出来然后做边缘检测。这样一来墙壁并不会遮挡描边RT里宝箱的轮廓后处理阶段再根据需求决定是否要让描边盖在墙上面。如果不希望描边穿墙就在合成时把宝箱RT的深度和场景深度做比较只在宝箱比墙近时输出描边如果希望穿透显示就把这个比较关掉。用CommandBuffer做这件事非常自然因为它可以中途切换RenderTexture还可以只对单独一个Layer做绘制。就是注意别把场景深度搞乱了清RT、备份深度、恢复深度这些动作要谨慎。5.2 描边闪烁、断线描边闪烁通常有几个原因。第一种是深度精度问题。物体和背景离得太近深度值区分不开边缘检测阈值设太大就会出现一会儿有描边一会儿没描边。解决办法是把渲染深度RT时把设备和近裁剪面调好然后用相对深度而非绝对深度。第二种是外扩方向错了。模型有子节点节点坐标和世界坐标不一致法线外扩用了世界方向但位置用的是模型空间就会产生错位。建议在模型空间里算外扩确保缩放一致。第三种是RT分辨率低边缘锯齿明显看起来像断线。可以把RT从半分辨率提到全分辨率或者对边缘检测结果做一次双边滤波。我踩得最多的是法线问题。角色模型没开平滑法线描边在额头和鼻尖的位置会断成一截一截。让美术在建模软件里重新计算顶点法线或者导入时开启法线平滑基本上就能解决。代码里的外扩方向也尽量用顶点法线不要直接用面法线不然折角处会裂开。5.3 移动端性能分析移动端用CommandBuffer描边性能瓶颈集中在两点RT填充带宽和全屏后处理采样。RT分辨率每提升一倍带宽消耗是平方级增长。所以我的建议是RT先开半分辨率手机上跑一遍看发热不行再降。如果项目同时有好几个后处理效果比如Bloom、景深、描边尽量不要让每个效果都各开一张全屏RT考虑合并到一个CommandBuffer里。另一个优化点是只渲染真正需要描边的物体。有些项目把整个场景Layer都丢给描边CommandBuffer绘制结果把一大片地形也画进了RT开销直接爆掉。正确做法是把需要描边的对象动态加到一个独立Layer只画这些对象。每帧还要记得清理Layer否则描边目标会越攒越多。5.4 常见问题速查表现象可能原因排查思路描边不显示材质Cull设置不对检查是否设置了Cull Front描边显示在正面外扩顶点后没有只画背面切换剔除面方向描边闪烁RT分辨率低/深度冲突提高RT分辨率调整阈值描边断线模型法线不平滑重新平滑法线描边被遮挡深度测试正常单独RT忽略场景深度性能下降严重全屏后处理叠加过多降RT分辨率缩小Layer范围6. 扩展与应用范围6.1 可玩功能点击高亮、敌人受击描边CommandBuffer描边的价值不只是静态的“加个边”它可以很容易做成一组交互效果。比如玩家鼠标点中某个单位就把这个单位切到高亮Layer同时给描边材质设置渐变色单位受击时把描边颜色切换成红色并让描边宽度随受击时间脉动几帧。整个逻辑全部放在一个OutlineController里任何系统要触发描边只需要调它的setTarget和setStyle方法不用去改渲染代码。这比每个单位挂一个脚本去动态切换Shader好维护得多。尤其战斗场景里单位数量多的时候一个全局控制器管理所有描边状态代码路径非常清晰。6.2 与后期特效配合CommandBuffer描边和后期特效配合时要注意执行顺序。如果你的是卡通渲染风格可以先把描边结果输出到一张低分辨率RT再和Bloom叠加让描边边缘有一层光晕。这时候后面的模糊和提亮会覆盖一部分描边细节所以先调好混合顺序别让后处理把刚画好的轮廓又糊没了。比较实用的一个组合是先用CommandBuffer渲染描边RT再在同一个CommandBuffer里跑Blur最后blit回屏幕。这样不用开多个CommandBuffer也不会因为多次全屏采样把带宽浪费掉。6.3 我个人项目中的使用体会这套技术我在实际项目里用了大半年。一开始犯的错就是贪全想用边缘检测方案处理所有物体结果移动端帧率掉得厉害。后来拆开角色和NPC用背面外扩场景里的大型建筑用半分辨率RT边缘检测性能和效果平衡了很多。还有一个体会是别把描边做成“一口吃掉的帅效果”先做最小Demo只在单个物体上验证CommandBuffer流程然后再一层层加特效。否则渲染流程一旦出了问题根本分不清是哪一步造成的。如果你正在LayaAir项目里为描边烦恼可以按这个顺序推进先把一张RT用纯色画出一个Cube再把这张RT合成到屏幕确认CommandBuffer跑通。确认后再把Cube换成你的角色再去调法线和宽度。基础流程没问题后后面加再多的描边样式都是水到渠成的事。
返回列表