
做大地图项目最烦的事之一就是砍树。早期我写过一套基于 Animator 的砍伐动画做法是给每棵树挂一个动画控制器砍中之后随机播倒下动画。树简单还好地图一旦铺开几百棵光初始化 Animator 和动画状态机就把内存吃掉了Draw Call 更感人。后来我换成顶点动画 Shader把“倾倒”这件事完全交给 GPU 顶点阶段去算CPU 这边只需要传一根射线命中的位置和方向。这篇文章就把这套方案从数学原理、Shader 完整代码、C# 驱动到大地图合批优化拆开讲清楚。如果你也在做可破坏植被、开放世界砍树或者只是想了解顶点动画在实际项目里的落地姿势下面的内容可以直接抄。1. 传统方案为什么扛不住Animator、骨骼和物理的账单先说结论砍树并不是一个多复杂的动作但它发生的场景是“大地图”这就让很多常规做法变得不划算。1.1 大地图场景里传统方案的账本最常见的砍树做法是 Animator。每棵树挂一个 AnimatorController里面至少有一个 Idle 和 一个 Fall 状态C# 端通过SetTrigger触发。Unity 里每个 Animator 都会占用 AnimatorController 资源、创建内部状态机数据几百个 Animator 同时存在内存和 CPU 开销都明显往上涨。如果树还要随机播放不同角度的倒下动画你还得准备多个动画 Clip或者用 Blend Tree 做混合资产数量翻倍。第二种常见做法是物理。给树干加 Rigidbody、铰链或者用AddForce做木头砸地效果。说实话物理砸树那一瞬间确实爽但大地图里的树往往有几千棵给每棵树挂物理组件是自杀式做法光碰撞检测和休眠唤醒的调度量就能把主线程拖慢。而且树倒下经常会压到地形、散射体、角色脚下相关修正逻辑越写越多最后变成一个大泥潭。还有一批人会直接用它FX 特效加模型替换。就是砍的瞬间播放一个特效然后把树模型替换成一个“倒下状态”的静态模型。这个方案确实省但问题也很明显砍树过程完全没有过渡视觉上很生硬而且你需要准备至少两套树模型资产内存翻倍。在大地图里内存往往比 CPU 还紧张。1.2 顶点动画能做到什么做不到什么顶点动画 Shader 的思路很干脆不改变模型数据的存储也不创建任何动画资源只在渲染的最终阶段由 GPU 在顶点着色器里把顶点的位置“算”到新的位置。树的倒下令 CPU 不参与逐帧控制GPU 只需要每棵树多算几十条指令而已。它能做到的是一棵树的倾倒、弯曲、回弹、被吹走全部由顶点位移完成不产生额外内存不新建模型不需要动画状态机适合大量重复的植被只要材质和网格共享就具备合批和实例化基础可以在移动端跑只要控制好指令数。它做不到的很明确不会自动覆盖精确的碰撞体积物理碰撞还得另想办法无法像骨骼动画那样做局部“皮肤”收缩比如树皮裂开这种细节顶点位移不会自动更新法线阴影也需要特殊处理。1.3 适用场景与预先判断标准我推荐这套方案的条件是树的模型面数不高单树 500 到 2000 个三角形砍伐是间歇性的不是全场所有树同时倒大地图复用同一批树资源目标是移动端或者中低端 PCCPU 预算有限。如果你做的是剧情向作品里“主角单独砍一棵千年古树”动画师手 K 一个倒下动画反而更简单更可控。这个判断可以帮你省掉后面至少一星期的调优时间。2. 砍树变换的数学断点旋转、径向衰减和顶点色权重Shader 写起来不难难的在于让树倒得“像真的”。这里面有几个关键数学点需要先理清楚。2.1 断口位置与旋转轴的选择砍树本质上是让树绕着一个“断口”倒下去。顶点可动的范围不是整棵树而是以断口为中心的一个扇形区域。断口高度的概念很重要如果断口设在地面上 0.1 米处树就表现为连根拔起如果断口设在 1.2 米处树就表现为被砍断倒下。在实际项目里我通常在模型空间约定一个局部原点即树的根部。Shader 里暴露一个_CutHeight参数表示断口高度。每个顶点相对于断口的相对坐标为float3 pivotLocal float3(0, _CutHeight, 0); float3 localDir v.vertex.xyz - pivotLocal;旋转轴的选择则要根据“倒向哪个方向”来定。树的初始方向是世界 Y 轴倒下的目标方向是_FallDir。我们需要构造一个垂直于这两个方向的轴让树绕着这个轴旋转过去。这个轴就是横轴的叉积float3 axis cross(float3(0,1,0), normalize(_FallDir));叉积方向决定了旋转的正负具体表现为当_FallDir指向正 X 轴时axis指向负 Z 轴正向旋转会把树顶拉向正 X正好是倒下方向。这一点实操时别搞反了我早期就把叉积顺序写反树直接朝相反方向倒下。2.2 从整体旋转到自然弯曲的复合变化一个完整的砍树过程最好包含两种位移整体旋转让树绕断口轴旋转一个角度表现“主干倒下”局部弯曲树冠比树干更柔韧越靠顶部偏移量越大表现“树叶被甩出去”的弧线。整体旋转用一段 Rodrigues 旋转公式实现。它的优点是任何绕轴旋转都能直接写成顶点变换不需要构造旋转矩阵float3 RotateAroundPivot(float3 pos, float3 pivot, float3 axis, float angle) { float3 dir pos - pivot; float cosA cos(angle); float sinA sin(angle); float3 nAxis normalize(axis); return pivot dir * cosA cross(nAxis, dir) * sinA nAxis * dot(nAxis, dir) * (1.0 - cosA); }对于任意顶点先绕断口旋转一个角度然后在世界空间里沿_FallDir加一个随高度增大的偏移。这样树倒下时不只是一个刚体翻转树干底部微微弯曲树冠甩在身后视觉上就柔和很多。2.3 径向衰减和顶点色让影响范围更可控如果整棵树所有顶点在倒下时都动树干最底部的顶点也会跟着位移看起来就像整棵树被平移吹走。为了解决这个问题需要径向衰减因子和一个权重系数。径向衰减以断口位置为圆心以树的水平投影距离为半径float dist length(v.vertex.xz - pivotLocal.xz); float falloff smoothstep(_Radius, _Radius - _Radius * _EdgeSoftness, dist);这个falloff会让离断口越远或者离树中心越远的顶点位移越小。顶点色在这里的作用更大。我在建模阶段就要求美术把顶点色 R 通道当成“摆动权重”树干底部涂黑0树冠涂白1中间渐变。这样 Shader 不需要知道哪部分是树干哪部分是树冠直接把v.color.r乘到旋转角度上即可。影响半径和顶点色搭配树的倒下就不会出现破模型或者穿模感。3. Shader 完整实现Unlit 版可跑通的砍树顶点动画理论说清楚之后下面是可以直接放进 Unity 里跑的 Shader 骨架。这个版本是内置渲染管线下的 Unlit Shader支持实例化、双面渲染、AlphaTest。核心逻辑全部在顶点阶段。3.1 顶点阶段核心代码Shader Custom/TreeCutShader { Properties { _MainTex (Diffuse, 2D) white {} _CutHeight (Cut Height, Float) 0.5 _TreeHeight (Tree Height, Float) 5.0 _FallDir (Fall Direction, Vector) (1,0,0,0) _MaxAngle (Max Fall Angle, Range(0, 120)) 90 _FallAmount (Fall Progress, Range(0,1)) 0 _BendStrength (Bend Strength, Range(0,2)) 0.6 _Radius (Influence Radius, Float) 2.0 _EdgeSoftness (Edge Softness, Range(0.01,0.9)) 0.3 } SubShader { Tags { QueueAlphaTest RenderTypeTransparentCutout } Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.5 #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; float _CutHeight; float _TreeHeight; float4 _FallDir; float _MaxAngle; float _FallAmount; float _BendStrength; float _Radius; float _EdgeSoftness; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; float3 RotateAroundPivot(float3 pos, float3 pivot, float3 axis, float angle) { float3 dir pos - pivot; float cosA cos(angle); float sinA sin(angle); float3 nAxis normalize(axis); return pivot dir * cosA cross(nAxis, dir) * sinA nAxis * dot(nAxis, dir) * (1.0 - cosA); } v2f vert (appdata v) { v2f o; // 1. 计算径向衰减和顶点色权重 float3 pivotLocal float3(0, _CutHeight, 0); float dist length(v.vertex.xz - pivotLocal.xz); float falloff smoothstep(_Radius, _Radius - _Radius * _EdgeSoftness, dist); float heightW saturate(v.vertex.y / (_TreeHeight 0.0001)); float cutW v.color.r * heightW * falloff; // 2. 转到世界空间 float3 pivotWorld mul(unity_ObjectToWorld, float4(pivotLocal, 1)).xyz; float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; float3 bentPos worldPos; // 3. 只有权重足够大时做顶点位移 if (cutW 0.001) { float3 fallDirWorld normalize(_FallDir.xyz); float3 axis cross(float3(0, 1, 0), fallDirWorld); float angle radians(_MaxAngle) * _FallAmount * cutW; bentPos RotateAroundPivot(worldPos, pivotWorld, axis, angle); // 4. 叠加树冠甩动 float3 bendOffset fallDirWorld * sin(angle) * _BendStrength * heightW * falloff; bentPos bendOffset; } // 5. 权重混合避免边界突变 float3 finalPos lerp(worldPos, bentPos, cutW); o.vertex UnityWorldToClipPos(finalPos); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 c tex2D(_MainTex, i.uv); clip(c.a - 0.5); return c; } ENDCG } } }这段代码我故意保留了if (cutW 0.001)这个分支为的是让大部分静止的顶点不进入旋转计算移动端能省一点指令。实际上 GPU 分支在大量顶点下不会太完美但树这种低面数模型上问题是可控的。3.2 阴影投射 Pass 不能漏很多人在 Unlit Shader 里跑通渲染就以为完工了结果树倒下的瞬间阴影还是原来笔直的树影子特别出戏。原因是阴影投射走的是ShadowCasterPass默认模块里的阴影顶点函数根本没做位移。解决办法是把上面那段位移逻辑抽成一个函数文件比如TreeCutHelper.cginc然后在渲染 Pass 和 ShadowCaster Pass 里都调用同一份函数。这样树倒下时阴影也会跟着倒不会出现悬浮阴影的滑稽画面。3.3 让效果自然的三个细节第一个细节是进度曲线。直接线性把_FallAmount从 1 拉到 0树看起来像机器人缺少“被砍断后先是顿一下然后开始加速倒”的节奏。我习惯在 C# 端控制进度用SmoothStep加轻微回弹而不是在 Shader 里硬调。第二个细节是Cull Off。树叶通常是双面片不开双面渲染从背面看会漏。树倒下后摄像机经常会转到树叶背面必须开Cull Off。第三个细节是AlphaTest裁剪阈值。树叶贴图如果做 Alpha 裁剪clip(c.a - 0.5)的阈值要跟美术确认。高了叶子边缘发黑低了会出现半透明残影。移动端尽量别用带透明混合的叶子AlphaTest 虽然脏一点但是便宜。4. C# 驱动与批量砍伐一千棵树也能维持低开销Shader 只负责最后那一帧的顶点变换真正的状态控制、交互检测、参数下发都在 C# 里。这一节讲整个驱动链路。4.1 交互检测与参数归一化砍树的第一步是把点击位置和方向告诉 Shader。方向建议用从镜头到树的投影方向这样玩家砍树时树会自然朝远离镜头的一侧倒符合直觉。public void Cut(Vector3 fallDirection) { _renderer.GetPropertyBlock(_block); _block.SetFloat(_FallAmount, 1.0f); _block.SetVector(_FallDir, fallDirection); _block.SetFloat(_CutHeight, 0.5f); _block.SetFloat(_TreeHeight, 5.0f); _block.SetFloat(_MaxAngle, 90.0f); _block.SetFloat(_Radius, 2.0f); _renderer.SetPropertyBlock(_block); StartCoroutine(RunFallAnimation()); }射线检测时注意判断hit.normal的方向并不总是合适因为它可能是地形法线不是树的倒向。我一般直接把Camera.main.transform.forward投影到地面再归一化作为fallDirection简单稳定。4.2 利用 MaterialPropertyBlock 避免材质实例爆炸千万不要为树动态创建材质那会瞬间崩掉合批。正确姿势是用MaterialPropertyBlock。它的意义是同一个材质实例可以被多个 Renderer 共用PropertyBlock只覆盖个别属性而不会触发材质克隆。代码如下_block new MaterialPropertyBlock(); _renderer.GetPropertyBlock(_block); _block.SetFloat(_FallAmount, amount); _renderer.SetPropertyBlock(_block);这里有个重要的性能细节一个材质如果已经有很多棵树在用就会共享同一批 Shader 变体。当某棵树被砍了它独享一个 PropertyBlock其他树不受影响。从渲染管线的角度看绝大多数静止树仍然可以走动态合批或者 GPU Instancing只有被砍的那棵树从批次里摘出去这对大地图是最关键的优化。4.3 可恢复的伪物理曲线砍树不一定砍完就结束。很多玩法的树过一段时间会重新长出来或者倒下过程可能被反派用意念回弹。我的做法是让_FallAmount从 1 到 0 的过程用一段曲子float t 0f; while (t 1f) { t Time.deltaTime / fallDuration; // 基础S曲线 回弹 float progress Mathf.SmoothStep(0f, 1f, t); float amount 1f - progress; amount Mathf.Sin(t * Mathf.PI * 4f) * (1f - t) * 0.06f; SetFallAmount(Mathf.Clamp01(amount)); yield return null; }这样树倒下时会有轻微二次弹跳视觉上更接近被砍后树干自身的反弹而不会像读秒器一样匀速倒地。5. 大地图落地合批、剔除和实例化之间的取舍Shader 本身跑通只是第一步放到大地图里才会遇到真正的性能挑战。这一节说几个必须做的取舍。5.1 合批为何失效以及“热点树”策略Unity 的动态合批要求被合批的物体共享同一个材质并且顶点数总量不能超过上限。MaterialPropertyBlock的使用会直接破坏这个条件因为 SkinnedMeshRenderer 和 MeshRenderer 在检测合批时会把有 PropertyBlock 的物体单独排出去。如果你为每一棵树都创建 PropertyBlock等于把所有树都踢出了合批Draw Call 会从 1 涨到几百。这一点我吃过亏。我的方案是“静动态树分离”静止状态的树绝对不碰 PropertyBlock保持默认材质尽可能进入动态合批或者利用 GPU Instancing。被砍中的树临时生成一个 PropertyBlock从合批名单中摘除由砍伐管理器单独跟踪动画结束后把 PropertyBlock 清掉让树回到默认材质池重新参与合批。这样同一时刻只有极少量的树处于“热点状态”Draw Call 基本可控。再配合一个简单的上限判断比如最多同时允许 16 棵树在动画中超过就强制完成最老的一棵性能就不会失控。5.2 视锥剔除和包围盒问题顶点动画改了顶点的位置但 CPU 侧做视锥剔除用的是 Renderer 的包围盒这个包围盒不会跟着顶点动画变化。默认情况下Bounds是模型原始状态的最大范围。当树倒下 90 度时它会横躺在地面上原始包围盒完全罩不住于是玩家会看到树在摄像机的某个角度瞬间消失。解决方法是主动把Bounds放大到树倒下后的最大范围Mesh mesh GetComponentMeshFilter().sharedMesh; Bounds b mesh.bounds; b.Expand(_TreeHeight); GetComponentRenderer().localBounds b;代价是树在横躺状态下也会被判定为可视多了一些绘制消耗。如果树特别多可以考虑把树的包围盒缩小一点然后靠后期 LOD 做远距离优化而不是一味放大。5.3 移动端指令数控制与变体管理这套 Shader 在移动端不算重但不能不做控制。每个顶点至少需要一次叉积、一次 Rodrigues 旋转、一次 smoothstep、若干次 lerp在 Mali 和 Adreno 上都有压力。如果你的树是 1000 面每棵树的顶点在 500 到 1000 之间一帧只有一棵树在动画完全可以忽略。要避免的是把同一棵树复制几千次并且所有树都设置了动画参数这样即使不动也会进入分支计算。我最后把默认静止树的材质切换到一个没有顶点动画的普通树 Shader 上靠远处 LOD 或者 MeshRenderer 切换来避免不必要的计算。变体方面ShadowCaster、Instancing 和 Fog 都会预制出大量组合。建议最终打包前用 Shader Variant Collection 只记录实际用到的变体尤其移动端。别让美术随便加特性一个自动化开关就能救命。6. 踩坑实录法线、阴影、误触发和包体优化最后总结一下我在项目里实际踩过的坑每一个都消耗过半天到一天的时间。6.1 砍完树阴影还在的奇怪问题前面提到过ShadowCaster Pass 要复用同样的顶点位移。这里补充一个具体表现树已经横倒在地上但阴影还挂在原来的位置上笔直地投射。玩家视角很明显一眼假。如果用 UnityEngine 自带 ShadowCaster Pass需要把o.pos从UnityClipSpaceShadowCasterPos更新成自己位移后的裁剪坐标并且阴影偏移要重新计算否则边缘会疯狂闪烁。6.2 法线不更新导致的“平面感”Unlit Shader 对法线不敏感但如果你用的是自带 Diffuse 光照模型顶点旋转后法线没有跟着转光照的方向就会是错的树的一面会突然黑掉像一块平板。补救方式是在顶点阶段把法线也乘上旋转矩阵float3 rotatedNormal mul((float3x3)unity_ObjectToWorld, v.normal); rotatedNormal RotateAroundPivot(rotatedNormal, float3(0,0,0), axis, angle); v.normal mul((float3x3)unity_WorldToObject, rotatedNormal);注意这里的变换过程把法线转到世界空间旋转再转回对象空间交给后面管线继续用。如果不用这个树叶在转圈时笑起来很怪。6.3 误砍误伤的过滤大地图场景树通常会有碰撞体。射线检测Physics.Raycast会命中的是 Collider 而不是 Renderer。如果你只做了一个 Collider 组件可能把“地面”也当成树。我的过滤规则是目标物体必须有TreeCut脚本检测hit.collider.CompareTag(Tree)如果树周围还有石头、灌木加一层距离判断树干中心到射线命中点的水平距离大于阈值就忽略。这套过滤在大地图里能避免非常多莫名其妙的 bug比如弦切到空气树却倒了。实际是射线穿过了树冠的 Collider 而不是树干。6.4 顶点色没有用高度也能凑合最后分享一个小技巧。不是所有美术都会给树刷顶点色尤其外包模型。如果没有顶点色通道可以只用高度来模拟权重树冠权重高、树干底部权重低。代码里把v.color.r换成heightW即可float cutW heightW * heightW * falloff;加平方是为了让树干底部的位移更少更贴近视觉习惯。树顶的摇晃幅度会比默认大不少。以后美术有时间补顶点色再切成颜色通道版本Shader 不动效果还能进一步提升。我自己的项目最终就是这么落地的大地图铺了两千多棵树同一时刻最多十几棵被砍帧数压力几乎可以忽略内存也比 Animator 那版省了一个数量级。顶点动画 Shader 的坑主要集中在阴影、法线和包围盒上把它们提前处理好剩下的就是调一根舒服的惯性曲线了。