使用Unity Terrain API批量刷草

解读

面试官问“批量刷草”并不是让你背一句“TerrainData.SetDetailLayer”,而是想看你在真实项目里能不能把“草”当成资源+性能+工具链的一环系统解决。国内项目普遍要跑中低端安卓+iOS,草密度一上来就掉帧,策划还天天改范围、改密度、改品种;美术给的贴图不是2的幂就是带透明锯齿;热更新还要保证草不丢。所以回答必须体现:

  1. TerrainData.DetailResolutionPatchCount的底层理解;
  2. 能写编辑器批处理工具让策划一键刷;
  3. 知道GPU Instancing + Density Map才是线上性能底线;
  4. 熟悉分包、AssetBundle、热更后草的恢复逻辑。

一句话:把“刷草”答成“性能+工具+管线”才配拿U3D高薪

知识点

  • TerrainData.SetDetailLayer(int xBase, int yBase, int layer, int[,] map):唯一写入接口,必须在Undo.RecordObject包裹下使用,否则编辑器撤销栈会崩。
  • DetailResolution、DetailResolutionPerPatch、PatchCount三者的耦合关系:Resolution必须是PatchCount的整数倍,否则Unity自动对齐,导致刷出来的草错位
  • Grass Tint vs Healthy/Dry Color:移动端千万不要用Terrain自带的Tint纹理,会额外一次DrawCall;直接让美术把颜色做进Albedo。
  • GPU Instancing + Density Map:Unity 2021以后草支持DrawMeshInstancedIndirect,把Density Map做成R8贴图,采样后discard掉alpha<0.3的片,同等密度下DC从800降到2
  • 热更恢复:TerrainData不标记为Addressable,重启后草丢失;必须在RuntimeInitializeOnLoad里重新执行SetDetailLayer,数据来自ScriptableObject远程Json
  • 编辑器批处理:用TerrainTools API(Unity.TerrainTools)里的PaintContext.ReadPixels+WritePixels,可以一次读写多层DetailLayer,比逐像素Set快40倍

答案

分三步给面试官:工具、运行时、性能兜底。

  1. 编辑器批处理工具
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using UnityEngine.TerrainTools;

public static class GrassPainter
{
    [MenuItem("Tools/批量刷草")]
    static void Paint()
    {
        Terrain terrain = Selection.activeObject as Terrain;
        if (!terrain) return;
        TerrainData data = terrain.terrainData;
        Undo.RecordObject(data, "PaintGrass");

        int resolution = data.detailResolution;
        int[,] layer = new int[resolution, resolution];
        // 按策划配置的密度曲线采样
        AnimationCurve density = AssetDatabase.LoadAssetAtPath<AnimationCurve>("Assets/Config/grassDensity.asset");
        for (int y = 0; y < resolution; y++)
        {
            for (int x = 0; x < resolution; x++)
            {
                float worldX = x / (float)resolution * data.size.x;
                float worldZ = y / (float)resolution * data.size.z;
                float noise = Mathf.PerlinNoise(worldX * 0.1f, worldZ * 0.1f);
                layer[y, x] = Mathf.RoundToInt(density.Evaluate(noise) * 8);
            }
        }
        data.SetDetailLayer(0, 0, 0, layer);
        EditorUtility.SetDirty(data);
    }
}
#endif

关键点:用Undo.RecordObject保证可撤销;density曲线让策划调数值不碰代码。

  1. 运行时热更恢复
[System.Serializable]
public class GrassChunk
{
    public int xBase, yBase;
    public int[] flattened;  // 扁平化后的int[]
}

public class GrassSync : MonoBehaviour
{
    void Start()
    {
        TerrainData data = Terrain.activeTerrain.terrainData;
        string json = Addressables.LoadAssetAsync<TextAsset>("grassChunk.json").WaitForCompletion().text;
        GrassChunk chunk = JsonUtility.FromJson<GrassChunk>(json);
        int[,] map = new int[data.detailResolution, data.detailResolution];
        Buffer.BlockCopy(chunk.flattened, 0, map, 0, chunk.flattened.Length * sizeof(int));
        data.SetDetailLayer(chunk.xBase, chunk.yBase, 0, map);
    }
}

关键点Addressables保证分包;Buffer.BlockCopy把扁平数组还原为2维,比双层for快10倍。

  1. 性能兜底
  • 打开Edit→Project Settings→Graphics→Instancing Variants,把草用到的Shader加到Always Included
  • Quality Settings里把Detail Distance调到80mFade Length 20m,低端机再降50%
  • 如果草品种>4,合并贴图Atlas,用GPU Instancing一次性画,DC<=2
  • 安卓低端机用R8 Density Map替代Terrain草,Shader里discard同等密度帧率提升35%

拓展思考

  1. 大世界分块:Terrain的DetailLayer是全图内存,4096×4096分辨率直接占256MB。国内开放世界项目用Virtual Texture+自定义Sparse Clip Map,把草拆成512×512块,只加载玩家周围3×3块,内存降到1/9
  2. SRP Batch兼容性:URP下Terrain草默认走ForwardDC爆炸。可以改写成Shader GraphUnlit Master,勾选GPU Instancing,再手动加_CameraDepthTexture采样做Soft IntersectionDC压到1
  3. 风场交互:国内竞品《逆水寒》手游用Compute Shader每帧更新StructuredBuffer<float2> windOffset,草顶点Shader里采样,CPU 0开销;Unity 2022的DOTS Instancing也能直接喂Custom Buffer同样思路
  4. 策划黑盒:把密度、高度、颜色三曲线做成ScriptableObject打AssetBundle;上线后策划只换Bundle就能热更草皮无需客户端发包,国内买量包体敏感环境刚需