ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LayaAir中CommandBuffer描边实现与踩坑指南

LayaAir中CommandBuffer描边实现与踩坑指南 前阵子在LayaAir项目里要做一个角色选中描边需求本身不复杂点一下场景里的怪物身体边缘亮一圈金色轮廓。我第一反应是改Shader结果打开角色预制体一看身上挂着五六种材质有贴图混合的、有带溶解特效的还有美术从旧项目里直接拖进来的统一改一遍Shader的成本比做描边效果本身还高。于是我把主意的重点转向了CommandBuffer——不碰任何原始材质在引擎渲染流程的某个阶段插入一次额外的绘制把描边效果直接叠上去。这篇文章就从选型、实现到踩坑完整过一遍给同样在LayaAir里做描边需求的朋友一个参考。1. 三个传统描边方案的硬伤以及CommandBuffer怎么绕过去的1.1 双Pass描边的绑定成本双Pass描边是大家最容易想到的方案。一个材质两个PassPass0正常纹理渲染Pass1剔除正面只把背面沿法线向外推一点然后输出纯描边色。改起来直白效果也稳定。问题在于它要求物体上所有材质都换成这个带描边Pass的材质。如果是一个角色由头、身体、武器多套材质组成你就得把每个材质都加一遍描边Pass。更麻烦的是项目里已有PBR、溶解、流光这些定制Shader每个都要加Pass维护量直线上升。而且每次调整描边宽度、颜色需要同时改很多个材质资源后期美术提个反馋需求都得跟着改一圈。双Pass还有一个隐藏问题描边Pass和外扩Pass在同一个材质内部意味着这种物体无法静态合批。你可以想象一个场景里摆了几百个需要描边的石柱每个石柱都是一个“异种材质体”合批优化直接失效。这个代价在移动端是很明显的。1.2 模板描边的复杂性隐患模板Stencil方案是另一条路先用模板测试把物体的轮廓像素写入模板缓冲再在全屏后处理中根据模板值找边缘最后叠加描边色。优点很明显描边精度高可以和场景颜色分开处理。缺点是模板相关操作需要所有参与物体材质都支持Stencil写入这又回到了改Shader的老路。一旦某个材质不按预期写入模板缓冲排查起来非常难受。更关键的是WebGL下模板缓冲通常只有8bit如果项目里UI合批遮罩、区域高亮等其他系统也在用模板标记很容易互相踩踏出现“这里描边突然没了那里高亮范围变大了”这种诡异问题。1.3 CommandBuffer描边的本质插一段绘制而不是改一堆材质CommandBuffer的方案思路完全不同原物体和原材质在相机渲染流程中原样跑一遍我们在相机渲染到某个阶段时用CommandBuffer再把这个模型的网格调出来用另一个“纯粹为了描边”的材质单独绘制一次。原始材质完全不用动。换句话说“正常绘制”归原材质管“描边绘制”归CommandBuffer和描边材质管两边解耦。这也是它能在不动资产的前提下给任意物体加描边的根本原因。而且CommandBuffer在LayaAir里并不是只能做描边HUD、准星、区域高亮、技能特效之类的需求本质都是“在渲染流程的某个点插一段自定义绘制”这套机制全部能覆盖学会一次能反复用。2. LayaAir里CommandBuffer的挂载方式与执行阶段2.1 PipelineStage里常用的是哪几个节点CommandBuffer要挂在相机上并且指定执行阶段。在LayaAir里这个阶段枚举是PipelineStage常用的大概有这些阶段值含义适合做的事BeforeForwardOpacity不透明物体渲染完后、半透明之前描边、选中高亮、添加不透明特效AfterForwardOpacity半透明物体渲染之后叠加光晕、屏幕空间特效BeforeImageEffect后效之前需要接后处理的中间结果ScreenImageEffect后效阶段全屏后处理、边缘检测描边我建议挂在BeforeForwardOpacity。原因很简单到了这个阶段场景里所有不透明物体都已经画完深度缓冲里已经有完整的不透明物体深度半透明物体还没画此时插入描边绘制描边不会被半透明物体盖住。描边自身作为一个不透明片元参与深度测试边缘外扩的部分正常显示物体中心的背面则被物体自身深度遮挡不会出现“整个模型变成描边色”的灾难。2.2 CommandBuffer的完整生命周期挂载流程本身不复杂看一下代码就明白// 创建CommandBuffer const cb new Laya.CommandBuffer(); // 挂到相机上并指定执行时机 camera.addCommandBuffer(cb, Laya.PipelineStage.BeforeForwardOpacity); // 需要移除时 camera.removeCommandBuffer(cb); // 按阶段清空 camera.removeCommandBufferByStage(Laya.PipelineStage.BeforeForwardOpacity);CommandBuffer内部是一串命令序列按照添加顺序执行。可以理解成一个“渲染宏”在这个阶段把剔除状态设成什么、深度测试设成什么、画什么网格、用什么材质全都在这个序列里一步步写清楚。它的执行由引擎在对应阶段自动触发不需要每帧手动调用也不需要担心Update顺序。有一点必须养成本能物体销毁或者效果切换时记得把CB从相机上摘掉。CB本身会持有它引用的网格和材质即使你在业务层把物体删了只要CB没移除它依然会在渲染阶段尝试绘制表现就是“场景里看不到这个物体了但雷达上一直有个东西在被渲染”性能计数器能直接看到DrawCall异常高。2.3 绕过CB的外围依赖网格、矩阵、材质CommandBuffer本身只是一个调度容器真正绘制还需要三样东西网格、矩阵、材质。网格来自模型的MeshFilter或MeshRenderer的sharedMesh矩阵来自物体的transform.worldMatrix材质则是专门为描边创建的材质建议在代码里运行时new出来不依赖资产——描边颜色、宽度都可以作为Shader属性直接改做美术配置表也方便。这里有一点要提前讲清楚网格用的是sharedMesh不是SkinnedMeshRenderer上实时计算的动画网格。换句话说CommandBuffer直接drawMesh的方式拿不到骨骼动画的当前姿态数据。如果你的描边目标是会跑会跳的带骨骼动画角色需要单独处理这个我后面踩坑章节会细说。静态物体、道具、建筑、部分刚体物件用这个方案是没问题的。3. 核心实现背面放大描边的完整代码与参数说明3.1 描边Shader的编写要点背面放大描边的思路是把模型的正面剔除只画背面背面的顶点沿法线方向往外推一小段距离然后输出纯色。由于被推出去的背片位于原模型边缘外侧正面渲染时又会被原模型挡住最后屏幕上显示出来的就是紧贴模型轮廓的一圈边。外层描边Shader的顶点部分长这样#version 300 es precision highp float; uniform mat4 u_WorldMat; uniform mat4 u_ViewProjectionMat; uniform float u_OutlineWidth; layout(location 0) in vec3 position; layout(location 1) in vec3 normal; void main() { vec4 worldPos u_WorldMat * vec4(position, 1.0); vec4 worldNormal u_WorldMat * vec4(normal, 0.0); worldPos.xyz worldNormal.xyz * u_OutlineWidth; gl_Position u_ViewProjectionMat * worldPos; }片元部分更简单#version 300 es precision highp float; uniform vec4 u_OutlineColor; layout(location 0) out vec4 fragColor; void main() { fragColor u_OutlineColor; }有个细节值得展开。为什么用世界空间外扩而不是模型空间如果用模型空间的position normal再走MVP那么遇到带缩放、旋转的物体外扩方向和距离会跟着模型变换一起变非均匀缩放时会直接变形。把顶点先变换到世界空间再把法线也变换到世界空间然后在世界空间外扩宽度就是一个稳定的绝对距离值不会受到模型自身缩放的影响。代价是描边宽度不随相机距离变化远处看起来描边会显得更细实际项目里需要根据需求取舍。3.2 CommandBuffer里的绘制指令在LayaAir里绘制描边的那段核心代码大致是这样// 创建CommandBuffer const cb new Laya.CommandBuffer(); camera.addCommandBuffer(cb, Laya.PipelineStage.BeforeForwardOpacity); // 配置剔除状态只画背面正面被原物体正常渲染覆盖 cb.renderState.cull Laya.RenderState.CULL_FRONT; cb.renderState.depthWrite false; cb.renderState.depthTest Laya.RenderState.DEPTHTEST_LESS; // 设置描边材质参数 outlineMat.setVector4(u_OutlineColor, new Laya.Vector4(1.0, 0.7, 0.2, 1.0)); outlineMat.setNumber(u_OutlineWidth, 0.03); // 把网格用描边材质再画一次 cb.drawMesh(targetMesh, targetTransform.worldMatrix, outlineMat);注意这里cull设置成CULL_FRONT意思是想剔除正面。这样每个三角面只剩下背面的那一面被渲染背面经法线外扩后露在物体边缘外面。深度测试设置成Less深度写入关闭。深度写入关闭很重要描边本身是叠加效果不应该去污染深度缓冲深度测试开启Less是为了让被物体正面挡住的那部分描边不显示只让外扩露出的部分通过测试。画完之后CB就完成了它的使命。引擎继续渲染后续的半透明物体和其他阶段描边已经像纹身一样贴在了场景上。3.3 描边宽度、颜色和视距的修正参数调节有几个实操经验。描边宽度u_OutlineWidth如果在世界空间外扩数值大概在物体包围盒半径的1%~3%之间会比较协调。一个更稳妥的做法是运行时根据物体包围盒半径动态计算而不是每个物体硬编码同一个值。const bounds targetMesh.bounds; const radius bounds.getBoundingSphere().radius; outlineMat.setNumber(u_OutlineWidth, radius * 0.02);这样大石头和小编钟都默认有一个相对协调的描边宽度。如果项目场景相机经常拉近拉远可以再把宽度和相机距离做一个线性修正远距离时适当放大。曲线不复杂但要记住一点这个修正应该基于物体到相机的距离而不是所有物体用同一个全局修正否则场景里不同位置的物体描边粗细会明显不一致。发光效果也在这个基础上做。普通纯色描边直接输出u_OutlineColor即可如果想做“描边亮一圈再淡出”可以在片元Shader里根据片元到模型边缘的距离做渐变或者叠加噪声做动态闪烁都属于后期锦上添花。4. 实测阶段踩过的坑剔除方向、深度写入和多相机4.1 法线外扩后描边消失的排查链路我第一次接上这套方案把模型拖进场景期待看到金色描边结果是屏幕上干干净净连个毛边都没有控制台也不报错。这种“没反应”的问题最难查因为没有报错信息可以看。排查链路大致是这样的先打印camera上的CommandBuffer列表确认CB真的挂上去了。这一步查完发现没问题。然后打印目标网格的顶点数和包围盒确认drawMesh引用的网格有数据。也没问题。接着怀疑剔除方向。描边材质设置了CULL_FRONT只画背面。但我在编辑器里检查场景里的模型Transform发现它的Scale是(-1,1,1)——这是美术在建模软件里翻转了坐标轴导入引擎后经常会出现负缩放。负缩放会翻转三角形绕序原本的正面变成了背面原本的背面变成了正面。我这边设置CULL_FRONT反而把真正的背面剔除了结果整个描边都没了。排查到这里根因找到了。解决办法有两个要么在创建CB时根据模型的缩放手动切换剔除方向要么在模型导入时统一收敛负缩放要么干脆在Shader里计算外扩时先用法线矩阵修正方向。最稳妥的还是确保进入引擎的模型没有负缩放一劳永逸。4.2 深度写入导致透明物体下描边露馅第二个坑出现在有半透明物体遮挡的情况下。场景里放了一面半透明的玻璃墙玻璃后面有一座需要描边的雕像。开启描边之后发现雕像应被玻璃遮挡的部分居然透过玻璃把描边轮廓完整呈现了看起来就像玻璃上画了一个描边贴花。原因是我前面说的CB里把深度写入关掉了。深度写入false意味着描边片元不会被记录进深度缓冲所以一个位于玻璃后面的描边片元虽然会正常参与深度测试但半透明玻璃本身已经因为深度测试的关系把玻璃后面的东西遮住了如果描边在玻璃之后绘制而且深度测试通过就不会被玻璃挡住。实际是半透明物体的渲染顺序和深度测试共同作用的结果。解决方法保持depthTest为Less但不要对描边和半透明物体同时做过于宽松的深度测试更简单的是描边物体和半透明遮挡物的层级关系本来就不应该有穿透。如果你确实需要“透明物体也挡描边”就得把描边也纳入到不透明渲染管线靠半透明的渲染顺序解决。这类问题没有银弹只能在渲染阶段上做取舍。我最后做的决策是项目里所有描边都放在BeforeForwardOpacity半透明物体如果恰好挡住了描边物体描边会被透明物体的颜色混合盖住视觉上基本可接受因为大部分描边都是用在可交互的实体上没人会把它放在玻璃后面还要求不被遮挡。4.3 多相机和多个CommandBuffer的顺序问题项目里存在多个相机时比如主相机、UI相机、小地图相机CommandBuffer就很容易挂错相机。有些新手直接把CB挂到所有相机上结果同一个描边被画了两遍有些挂在UI相机上描边根本不会出现在主画面里因为在UI相机的渲染流程里根本没有场景几何数据的深度。解决方案是明确一个原则CommandBuffer只挂到渲染场景的相机上而且只挂一次。如果项目里用了多个场景相机做画面拼接需要先确定哪个相机负责最终画面输出就挂在哪个相机上。UI相机尽量不要挂场景相关的CB它渲染的是屏幕空间UI深度信息基本无效。多个CB挂同一个相机的执行顺序按添加顺序执行。如果你同时需要描边又需要HUD先加的CB先跑后加的CB后跑。一旦发现效果互相覆盖优先检查是不是CB添加顺序错了而不是去调Shader优先级。4.4 蒙皮动画网格的适用边界这是整个方案最大的适用范围局限必须单独拎出来说。前面提到过cb.drawMesh用的是sharedMesh这是网格在绑定骨骼那一刻的“静态姿势”。如果描边目标是一个正在播放跑步动画的角色你拿到的sharedMesh顶点还在绑定姿势上画出来的描边是T-Pose的轮廓和动画里的跑步姿态完全对不上效果会非常诡异。所以CommandBuffer直接drawMesh的描边方案最合适的是静态物体可拾取的道具、场景里的箱子、机关按钮、交互点。带骨骼动画的角色描边虽然理论上可以通过蒙皮数据重建顶点但在LayaAir的CommandBuffer里手动处理蒙皮计算成本很高综合下来不划算。如果你的需求是大量角色选中描边建议还是回到双Pass材质方案或者用全屏边缘检测配合目标物体标记。其实项目里也遇到了这个问题一批需要描边的NPC会播放待机动画我一度想用CommandBuffer的OnWillRenderObject之类的钩子去拿当前帧蒙皮网格但设计复杂度太高最后这批NPC改用了带描边Pass的专用材质而场景里的静态交互物继续用CommandBuffer方案。两者并存各管一摊。5. 扩展思路全屏深度边缘检测与性能权衡5.1 深度边缘检测的原理如果你不想动模型的网格也不想给静态物体单独创建描边材质还有一个更重的思路全屏深度边缘检测。原理是利用深度缓冲里连续像素的深度变化来判断哪里是几何边缘然后叠加描边色。在CommandBuffer里这个思路常用的做法是把当前已经渲染好的场景RGB和Depth通过一个全屏后处理Shader处理Shader采样当前像素周围若干点的深度值如果深度差异超过阈值就认为这个像素处于边缘位置。核心逻辑可以浓缩成一段GLSLfloat depth texture(u_depthTex, v_uv).r; float d0 texture(u_depthTex, v_uv vec2(1.0, 0.0) * u_texelSize).r; float d1 texture(u_depthTex, v_uv vec2(-1.0, 0.0) * u_texelSize).r; float d2 texture(u_depthTex, v_uv vec2(0.0, 1.0) * u_texelSize).r; float d3 texture(u_depthTex, v_uv vec2(0.0, -1.0) * u_texelSize).r; float diff abs(d0 - depth) abs(d1 - depth) abs(d2 - depth) abs(d3 - depth); float edge step(0.02, diff); vec4 outline vec4(u_OutlineColor.rgb, 1.0); vec4 base texture(u_sceneTex, v_uv); fragColor mix(base, outline, edge);这段Shader会描出场景中所有几何边缘不只是你选中的那个物体。如果你只想描特定物体就得配合标记方案要么把目标物体单独写进一张RT要么用Stencil把目标像素标记出来然后在后处理时只对标记区域找边缘。标记这一步在技术上有一定复杂度但也不是不能用。如果项目本身就是全屏后处理风格的画面比如水墨风、网点风这套方案反而能和整体风格统一。5.2 三种方案的性能对比与选型建议做图形性能对比不能拍脑袋直接看核心数据。我在一个1080p的测试场景里各跑了一遍场景中有50个需要描边的物体NVIDIA 1660级显卡WebGL2模式方案DrawCall增量像素填充开销适用场景双Pass材质描边每个物体1低带骨骼动画角色、要求效果精度最高CommandBuffer背面外扩每个物体1低静态交互物、道具、不改造资产的场景全屏深度边缘检测每帧2~3全屏高全屏统一描边风格、后处理风画面CommandBuffer背面外扩因为只绘制描边物体本身的背片填充量很低移动端也可以接受。但如果场景里同时有几百个物体需要描边DrawCall增量就会被放大这时候就要考虑是不是应该用“只有选中/高亮物体才开描边”的策略而不是所有物体全天候描边。很多游戏里选中描边本来就应该是临时状态这个优化是必要的业务逻辑不只是性能逻辑。5.3 几个可以继续做的方向沿着CommandBuffer描边这条路后续能做的事情挺多。一个是把描边和透明度动画结合起来选中时描边从透明渐变为实色取消选中再渐隐过渡不突兀。实现方式就是在代码里每帧改u_OutlineColor的alpha或者在描边材质上叠一个透明混合状态成本不高但对视觉品质提升很直接。另一个是把描边和闪烁提示结合起来比如任务目标道具会一直以呼吸频率闪烁。这种动态效果在纯色描边Shader上加一个正弦波系数就行。还有一个是区域高亮。拿一个圆柱体网格用半透明材质在CommandBuffer里画一圈配合深度测试做地下城副本区域的边界提示效果比贴地面UI好很多而且不会受到地形起伏的影响。这些方向本质上都是同一个套路在合适的时间点、用合适的材质、把合适的网格再画一遍。理解了CommandBuffer的定位后面都是小特技。6. 写在最后的几点实操体会做这个功能最大的感受是CommandBuffer描边最值钱的地方不是在“描边”本身而是它提供了一种不侵入资产就能干预渲染结果的能力。以前一个描边需求要从Shader开始一路改到材质、合批、动画系统现在只需要在相机上挂一段命令剩下的交给引擎调度。这种架构上的清爽感用起来之后回不去的。另外有一点建议如果项目里同时存在多个类似的渲染增强功能最好维护一个简单的CommandBuffer管理器统一记录每个相机上挂了哪些CB谁负责什么阶段顺序如何。不然后面美术提需求、程序员改逻辑时很容易在“为什么突然多了一个CB但找不到创建点”这种事情上浪费时间。我就是前期偷懒没建管理器后期排查多相机问题花了半个下午把这个坑写出来希望能帮你省下这半天。
返回列表