基于Unity的植被刷工具
最近接手的一个需求是给美术同学提供一个刷植被工具,类似Unity地形的Paint Details.
之所以没有选择Unity自己的地形工具,是因为策划需求中需要动态让植被消失和显示,另外大批量草的优化自己控制相对好处理一些,当然还有Unity地形广受诟病的性能问题.
这里把实现过程做个简单的记录.
内容包括:编辑器开发,植被shader, 生成优化,显隐
先上结果
编辑器长这样:
刷完草后的效果:
动态显示消失:
编辑器部分:
编辑器部分逻辑比较简单,掌握一些基础的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
{
}
增 删
刷完后点击保存数据,将数据存入本地,这里选择使用的是protobuff
着色器部分:
顶点函数:
//草的风吹顶点动画
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的性能问题等等.