基于Unity的植被刷工具

最近接手的一个需求是给美术同学提供一个刷植被工具,类似Unity地形的Paint Details.
之所以没有选择Unity自己的地形工具,是因为策划需求中需要动态让植被消失和显示,另外大批量草的优化自己控制相对好处理一些,当然还有Unity地形广受诟病的性能问题.

这里把实现过程做个简单的记录.
内容包括:编辑器开发,植被shader, 生成优化,显隐

先上结果

编辑器长这样:

unity 刷子 unity在模型上刷植被_shader


刷完草后的效果:

unity 刷子 unity在模型上刷植被_shader_02

unity 刷子 unity在模型上刷植被_unity 刷子_03


动态显示消失:

unity 刷子 unity在模型上刷植被_shader_04

编辑器部分:
编辑器部分逻辑比较简单,掌握一些基础的EditorAPI,然后根据自己设计的面板去罗列代码就可以。

面板核心代码:

public class PaintDetailsEW : EditorWindow
{
	//打开窗口
	[MenuItem("Window/Paint Details %g")]
	static void Open()
	{
	    var window = (PaintDetailsEW) EditorWindow.GetWindowWithRect(typeof(PaintDetailsEW), new Rect(0, 0, 386,320), false, "Paint Detail");
	    window.Show();
	    Enable = true;       
	}
	void OnInspectorUpdate()
    {
        Repaint();
    }

    void OnGUI()
    {
        CurrentSelect = Selection.activeTransform;
        
        GUILayout.Space(20);

        GUILayout.BeginHorizontal();
        GUILayout.FlexibleSpace();
        GUILayout.BeginVertical("box", GUILayout.Width(347));
        GUILayout.BeginHorizontal();
        GUILayout.Label("Add Assets", GUILayout.Width(125));
        
        AddObject = (GameObject)EditorGUILayout.ObjectField("", AddObject, typeof(GameObject), true, GUILayout.Width(160));
        if (GUILayout.Button("+", GUILayout.Width(40)))
        {
            for (int i = 0; i < 6; i++)
            { 
                if (Plants[i] == null)
                {
                    Plants[i] = AddObject;
                    break;
                }
            }
        }
        
        GUILayout.EndHorizontal();
        GUILayout.EndVertical();
        GUILayout.FlexibleSpace();
        GUILayout.EndHorizontal();

        for (int i = 0; i < 6; i++)
        {
            if (Plants[i] != null)
                TexObjects[i] = AssetPreview.GetAssetPreview(Plants[i]) as Texture;
            else TexObjects[i] = null;
        }
        
        GUILayout.BeginHorizontal();
        GUILayout.FlexibleSpace();
        GUILayout.BeginVertical("box", GUILayout.Width(347));
        PlantSelect = GUILayout.SelectionGrid(PlantSelect, TexObjects, 6, "gridlist", GUILayout.Width(330), GUILayout.Height(55));

        GUILayout.BeginHorizontal();

        for (int i = 0; i < 6; i++)
        {
            if (GUILayout.Button("—", GUILayout.Width(52)))
            {
                Plants[i] = null;
            }
        }

        GUILayout.EndHorizontal();

        GUILayout.EndVertical();
        GUILayout.FlexibleSpace();
        GUILayout.EndHorizontal();

        GUILayout.BeginHorizontal();
        GUILayout.FlexibleSpace();
        GUILayout.BeginVertical("box", GUILayout.Width(347));
        GUILayout.BeginHorizontal();
        GUILayout.Label("Setting", GUILayout.Width(145));        
        GUILayout.EndHorizontal();
        brushSize = (int)EditorGUILayout.Slider("Brush Size", brushSize, 1, 36);
        scaleRandom = EditorGUILayout.Slider("Scale Random(+/-)", scaleRandom, 0.05f, 1f);
        density = (int)EditorGUILayout.Slider("Density", density, 1,10);
        GUILayout.EndVertical();
        GUILayout.FlexibleSpace();
        GUILayout.EndHorizontal();

        GUILayout.BeginHorizontal();
        GUILayout.FlexibleSpace();
        GUILayout.BeginVertical(GUILayout.Width(347));

        string btnText = IsHome ? "二级地图" : "领地地图";
        if (GUILayout.Button(btnText))
        {
            IsHome = btnText.Equals("领地地图");
            Editor = !IsHome;
        }

        ......
    }
}

刷草核心代码:

[CustomEditor(typeof(PaintDetails))]
public class PaintDetailsExtends : Editor
{
	//Scene面板回调函数
    void OnSceneGUI()
    {
        if (PaintDetailsEW.Enable)
        {
            Planting();
        }
    }
	
	void Planting()
    {
    	//使用射线取地面交点
    	Event e = Event.current;                        
        RaycastHit raycastHit = new RaycastHit();
        Ray terrain = HandleUtility.GUIPointToWorldRay(e.mousePosition);
        if (Physics.Raycast(terrain, out raycastHit, Mathf.Infinity, layerMask))
        {  
        	//根据鼠标划过位置和编辑器面板设置的密度等参数实例化植被 并打上标记
        }
	}
}
[RequireComponent(typeof(MeshCollider))]
public class PaintDetails : MonoBehaviour
{
}

增 删

unity 刷子 unity在模型上刷植被_ide_05


unity 刷子 unity在模型上刷植被_shader_06

刷完后点击保存数据,将数据存入本地,这里选择使用的是protobuff

unity 刷子 unity在模型上刷植被_ide_07

着色器部分:
顶点函数:

//草的风吹顶点动画
	o.worldPos = mul(unity_ObjectToWorld,v.vertex);				
	float3 windVector = UnpackNormal(tex2Dlod(_WindVector , float4(o.worldPos.xz*0.01+_TimeX*_WindSpeed,0,0)));
	o.wind = windVector;//用于模拟风浪反光
	float3 windDir = float3(windVector.x,0.0,windVector.y);
	float3 windDir1 = lerp(windDir,0,1-v.color.r);
	o.vertex = UnityWorldToClipPos(o.worldPos.xyz+float3(windDir1.x,0,windDir1.z)*_WindPower);

//采样地表颜色 用于草根与地表的混合
	fixed4 screenPos = ComputeScreenPos (o.vertex);
	fixed2 suv = screenPos.xy/screenPos.w;
	o.grabCol = tex2Dlod(_CameraColorTexture,float4(suv,0,0));
//环境光
	o.ambient = _Color.rgb * glstate_lightmodel_ambient + _LightColor0 * 0.125;

片元函数:

fixed4 color = tex2D(_MainTex, i.uv);
	clip(color.a - _Cutout);

	color.rgb *= i.ambient;
	color.rgb *=2;
	UNITY_LIGHT_ATTENUATION(atten,   i, i.worldPos);
	fixed3 shadowColor = lerp(fixed3(1,1,1), lerp(fixed3(1,1,1), _ShadowColor.rgb, 1 - atten), _ShadowColor.w);

	float3 Windcol = saturate(i.wind.x*i.wind.y*_LightColor0*shadowColor);
	color.rgb += Windcol*color.rgb*;
					
	color.rgb = lerp(i.grabCol.rgb,color.rgb, saturate(i.color.r));
	color.rgb *= shadowColor;

着色器部分可优化空间还很大,比如用高度取代顶点色,做LOD分级,高配保留效果,中配取消风吹顶点动画+阴影,低配取消动画+阴影+地表拾色等。

草的加载:
前期把数据存入本地时,分成了若干个1023的组,便于在这里使用unity提供的DrawMeshInstanced 进行草的加载渲染.
关于GPUInstancing 网上的资料也比较多了,优点很明显,在渲染大数量对象时,效率很高,同时可以省下大量DC,内部也做了很多性能的优化. 缺点是目前仍然有15%左右的低配机型(opengl es2.0)不支持.我们的方案是收集这些设备做好宏定义,在不支持的机型上就直接让草地隐藏掉了.

GPUInstancing代码比较简单,主要是前期要把数据准备好,参数:mesh,mat,和一个矩阵队列,矩阵队列里包含每个对象的位置,缩放和旋转

var data = mGrassDatas[i];
if (data.draw)
{
    for (int j = 0; j < data.grassMatrixs.Count; j++)
    {
        Graphics.DrawMeshInstanced(data.mesh, 0, data.mat, data.grassMatrixs[j]);
    }
}

这里只做了第一步,后续会利用Culling Group实现分区剔除逻辑,大幅度减少GPUInstancing循环绘制的植被数量.
Culling Group的空间划分,内部实现的效率很高,他需要你先注册好包围球大小和位置,然后他会动态把进入视野的区域索引队列通过回调函数返回.
仅在返回的区域中去绘制,就可以让每次绘制的数量大幅减少. 这里要配合之前的数据,在编辑阶段保存数据的时候就要考虑到包围盒的划分. 根据地图的大小,可以让包围盒的注册数量尽量少,毕竟Culling Group判断包围盒是否在视空间内也是有一定消耗的, 但是往往项目做到后来,优化的重灾区都在渲染上面,每一帧CPU等待GPU的比例很高,所以在CPU做裁剪节省GPU的消耗,还是利大于弊的.

显隐:
显隐部分主要操作的还是本地数据,由于我们之前功能开发中已经将地面分好了逻辑格,所以我只要将草的数据根据逻辑格做好映射就可以了,当移动建筑时动态的获取建筑所占的逻辑格索引,通过索引取得植被范围,然后操作数组就可以了. 数组要提前申请好,避免产生GC.

后续优化:
第一版结束,能做的优化其实还有很多,比如把每个草的预制体做大一些,让他包含原来的两份或三份,这样批次还能近一步减少,只在绿色地表上刷草的话,是否可以取消对地表的拾色混合,改为把贴图底部涂成地表的颜色,这样就可以节省一次采样,还有alphatest的性能问题等等.