【UnityTools】地编工具——围墙生成(1)
前言搜了一下unity地编相关,河流、道路工具倒是有人说,或者用宇宙无敌的【RamSpline】插件,但是其他的东西好像寥寥无几。
也许是我对地编的工作流理解有问题吧,也可能是没人在Unity里面做地编,别人都是用DCC程序化生成呢。
权当是闲得无聊,分享一个没什么技术含量的玩具。
模型资源来自:RPG Poly Pack - Lite | 3D Landscapes | Unity Asset Store
一、 工具效果
模型生成
一个玩具而已,可以在SceneView点击鼠标生成线段,然后在线段上生成想要的模型。
在构建某些重复性场景时可能对地编友好(大概)。
这个工具长这样:
平平无奇小工具
二、 完整代码
没什么说头,直接贴代码,感觉也没什么值得学习的东西。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
namespace MyUnityTool
{
public class BuildWallsWindow : EditorWindow
{
private enum InstanceType
{
Clone,
Prefab
}
private enum AlignAxis
{
Right,
Left,
//Forward,
//Back,
//Up,
//Down
}
private class GenerationRecord
{
public List<GameObject> instances = new List<GameObject>();
public Vector3 lastPos;
public void Clear()
{
instances.Clear();
}
public void AddRange(IEnumerable<GameObject> instances)
{
this.instances.AddRange(instances);
}
}
private Transform instanceParent;
private List<GameObject> stamps;
private Stack<GenerationRecord> records;
private GameObject targetPrefab;
private GUIStyle textStyle;
private KeyCode startKey = KeyCode.A, endKey = KeyCode.S;
private KeyCode cancelKey = KeyCode.D;
private AlignAxis alignAxis = AlignAxis.Right;
private InstanceType instanceType = InstanceType.Prefab;
private Vector3[] recordPoints;
private Vector3 lastPoint;
private RaycastHit hit;
private LayerMask layerMask = 0;
private int m_layerMask => 1 << layerMask;
private float interval = 5;
private float intervalAccuracy = 0.1f;
private float genrationScale = 1;
private int oldCount = 0, desireCount = 0;
private bool startStamp;
private bool onXZplane = true;
private bool lockX, lockY, lockZ;
public static void OpenWindow()
{
EditorWindow.GetWindow<BuildWallsWindow>().Show();
}
private void OnEnable()
{
SceneView.duringSceneGui += OnSceneView;
recordPoints = new Vector3;
stamps = new List<GameObject>();
records = new Stack<GenerationRecord>();
textStyle = new GUIStyle();
textStyle.normal.textColor = Color.black;
textStyle.fontStyle = FontStyle.Bold;
textStyle.fontSize = 20;
}
private void OnDisable()
{
SceneView.duringSceneGui -= OnSceneView;
ResetStamps();
records.Clear();
}
private void OnSceneView(SceneView sceneView)
{
SceneView.RepaintAll();
if (targetPrefab != null)
{
var mousPos = Event.current.mousePosition;
var ray = HandleUtility.GUIPointToWorldRay(mousPos);
if (Event.current.type == EventType.KeyDown && Event.current.keyCode == startKey)
{
if (Physics.Raycast(ray, out hit, float.MaxValue, m_layerMask))
{
recordPoints = hit.point;
startStamp = true;
}
}
if (Event.current.type == EventType.KeyDown && Event.current.keyCode == endKey)
{
ResetStamps();
startStamp = false;
}
if (Event.current.type == EventType.KeyDown && Event.current.keyCode == cancelKey)
{
UndoInstance();
ResetStamps();
}
if (Event.current.type == EventType.ScrollWheel)
{
if (Event.current.alt)
{
interval += Event.current.delta.y > 0 ? intervalAccuracy : -intervalAccuracy;
Event.current.Use();
}
}
if (startStamp)
{
if (Physics.Raycast(ray, out hit, float.MaxValue, m_layerMask))
{
recordPoints = hit.point;
var forward = recordPoints - recordPoints;
var rotationAxis = forward;
if (onXZplane)
{
rotationAxis.y = 0;//钳制xz面进行旋转
}
var length = forward.magnitude;
var quaternion = Quaternion.FromToRotation(GetAlignAxis(alignAxis), rotationAxis);
if (!onXZplane)
{
quaternion = LockRotation(quaternion);
}
desireCount = (int)(length / interval);
while (oldCount < desireCount)
{
GameObject stamp;
if (instanceType == InstanceType.Prefab)
{
stamp = (GameObject)PrefabUtility.InstantiatePrefab(targetPrefab);
}
else
{
stamp = (GameObject)GameObject.Instantiate(targetPrefab);
}
stamp.transform.localScale = Vector3.one * genrationScale;
stamp.hideFlags = HideFlags.HideAndDontSave;
stamps.Add(stamp);
oldCount++;
if (oldCount == desireCount)
{
break;
}
}
while (oldCount > desireCount)
{
var stamp = stamps;
stamps.Remove(stamp);
GameObject.DestroyImmediate(stamp);
oldCount--;
if (oldCount == desireCount)
{
break;
}
}
for (int i = 0; i < stamps.Count; i++)
{
var stamp = stamps;
stamp.transform.rotation = quaternion;
stamp.transform.position = recordPoints + forward.normalized * interval * (i + 0.5f);
}
lastPoint = recordPoints + forward.normalized * interval * (stamps.Count);
}
if (Event.current.type == EventType.MouseDown && Event.current.button == 0)
{
GenInstances(recordPoints);
recordPoints = lastPoint;
}
DrawHandles();
}
}
}
private void DrawHandles()
{
Handles.color = Color.red;
Handles.DrawWireCube(recordPoints, Vector3.one);
Handles.DrawWireCube(recordPoints, Vector3.one);
Handles.color = Color.green;
Handles.DrawLine(recordPoints, recordPoints);
Handles.DrawWireDisc(lastPoint, Vector3.up, interval);
Handles.Label(recordPoints, $&#34;间隔: {interval}m&#34;, textStyle);
Handles.color = Color.blue;
Handles.DrawLine(lastPoint, recordPoints);
}
private Quaternion LockRotation(Quaternion quaternion)
{
if (lockX)
{
quaternion.x = 0;
}
if (lockY)
{
quaternion.y = 0;
}
if (lockZ)
{
quaternion.z = 0;
}
return quaternion.normalized;
}
private void GenInstances(Vector3 lastPos)
{
foreach (var stamp in stamps)
{
stamp.hideFlags = HideFlags.None;
stamp.transform.parent = instanceParent;
}
var record = new GenerationRecord();
record.AddRange(stamps);
record.lastPos = lastPos;
records.Push(record);
ResetStamps(false);
}
private void OnGUI()
{
EditorGUILayout.LabelField(&#34;围墙生成器&#34;, EditorStyles.boldLabel);
layerMask = EditorGUILayout.LayerField(&#34;目标检测Layer&#34;, layerMask);
targetPrefab = EditorGUILayout.ObjectField(&#34;围墙预制体&#34;, targetPrefab, typeof(GameObject), false) as GameObject;
instanceParent = EditorGUILayout.ObjectField(&#34;实例生成父对象&#34;, instanceParent, typeof(Transform), true) as Transform;
if (targetPrefab == null)
{
EditorGUILayout.HelpBox(&#34;请选择目标预制体&#34;, MessageType.Error);
}
else
{
instanceType = (InstanceType)EditorGUILayout.EnumPopup(&#34;实例化类型&#34;, instanceType);
alignAxis = (AlignAxis)EditorGUILayout.EnumPopup(&#34;对齐方向轴&#34;, alignAxis);
onXZplane = EditorGUILayout.Toggle(&#34;XZ面对齐&#34;, onXZplane);
EditorGUILayout.HelpBox(&#34;暂时仅支持Right轴的xz面对齐方式&#34;, MessageType.Warning);
if (!onXZplane)
{
using (new EditorGUILayout.HorizontalScope())
{
lockX = EditorGUILayout.Toggle(&#34;锁定X轴旋转&#34;, lockX);
lockY = EditorGUILayout.Toggle(&#34;锁定Y轴旋转&#34;, lockY);
lockZ = EditorGUILayout.Toggle(&#34;锁定Z轴旋转&#34;, lockZ);
}
}
genrationScale = EditorGUILayout.Slider(&#34;生成缩放&#34;, genrationScale, 0, 10);
if (GUILayout.Button(&#34;自动调整间隔大小&#34;))
{
var meshFilter = targetPrefab.GetComponent<MeshFilter>();
if (meshFilter == null)
{
meshFilter = targetPrefab.GetComponentInChildren<MeshFilter>();
}
if (meshFilter != null)
{
var mesh = meshFilter.sharedMesh;
var size = mesh.bounds.size;
interval = size.x * meshFilter.transform.lossyScale.x * genrationScale;
}
else
{
Debug.LogError(&#34;未找到模型MeshFilter,自动修正间隔失败...&#34;);
}
}
interval = EditorGUILayout.Slider(&#34;实例间隔&#34;, interval, 0.01f, 100);
intervalAccuracy = EditorGUILayout.FloatField(&#34;实例间隔修改精度&#34;, intervalAccuracy);
EditorGUILayout.HelpBox(&#34;开始:A;结束:S;撤销:D;Alt + 鼠标滚轮可以微调生成间距&#34;, MessageType.Info);
}
}
private void UndoInstance()
{
if (records.Count > 0)
{
var record = records.Pop();
if (record != null)
{
foreach (var ins in record.instances)
{
DestroyImmediate(ins);
}
recordPoints = record.lastPos;
recordPoints = record.lastPos;
record.Clear();
}
}
}
private void ResetStamps(bool destroyStamps = true)
{
if (destroyStamps)
{
foreach (var stamp in stamps)
{
DestroyImmediate(stamp);
}
}
oldCount = desireCount = 0;
stamps.Clear();
}
private Vector3 GetAlignAxis(AlignAxis alignAxis)
{
switch (alignAxis)
{
case AlignAxis.Right:
return Vector3.right;
case AlignAxis.Left:
return Vector3.left;
//case AlignAxis.Forward:
// return Vector3.forward;
//case AlignAxis.Back:
// return Vector3.back;
//case AlignAxis.Up:
// return Vector3.up;
//case AlignAxis.Down:
// return Vector3.down;
}
return Vector3.zero;
}
}
}
三、 算法细节
1. 撤销功能
只能说只要是个工具,就必须支持一定的容错操作,那么撤销功能必不可少。为了实现这个功能,我们额外定义了一个“操作记录”类与“历史栈”:
操作记录
记录操作的栈
每次撤销时,就是从栈里面出栈一个历史记录,然后重置线段起点、终点,销毁已经实例化的游戏对象罢了。
历史记录出栈,还原已记录信息
2. 沿轴分布
如何把模型沿着确定的线段轴对齐分布?我们得确定他的旋转与位置坐标。
旋转的处理,核心是Quaternion.FromToRotation方法。
一般的,假设我们的线段方向是(1,0,1),想把模型的x正轴对齐线段,那么这个旋转量就是:
var quaternion = Quaternion.FromToRotation(Vector3.right, new Vector3(1, 0, 1));
为什么使用【Vector3.right】进行旋转量计算而非具体的某个【GameObject.transform.right】?
因为我们可以认为模型的沿轴旋转是在【模型空间】而非【世界空间】,自然是使用【Vector3.right】常量就行。
为什么要钳制在xz面进行旋转?
因为我们的默认是在xz面进行建筑生成,那么物体的旋转轴自然是y轴才符合常识。如果想进行多维度扩展,可以自行修改旋转量。然后把他赋值给transform.rotation就行啦。
stamp就是某个具体的GameObject
为了支持对齐模型的不同轴向,我们使用GetAlignAxis来确定具体的轴, 他长这样:
暂时只实现了x正负轴的对齐,实际上其他轴的对齐方式也是同理
而位置信息的计算,是这样一句话:
stamp.transform.position = recordPoints + forward.normalized * interval * (i + 0.5f);
其中,【recordPoints】是线段起点,【forward】是沿轴方向,【interval】是两个模型之间的间隔,【i】是模型数组的下标。
由于默认模型网格的原点是在网格的中心位置,所以有一个0.5f的偏移。
至此,旋转+位置的计算实现了模型沿轴分布。
感觉还蛮好玩的
3. 间隔计算
如何确定模型之间的间隔?以对齐x轴为例的话,只要知道模型的x轴向的宽度就可以了。有没有什么办法自动计算呢?自然是有的。
自动计算间隔代码
注意这一句话:
interval = size.x * meshFilter.transform.lossyScale.x * genrationScale;
我们是读取了模型的【MeshFilter】上的【Mesh】信息,获取 【mesh.bounds.size】。
由于我们是沿x轴对齐,自然是取size.x,再考虑到模型可能存在缩放什么的,把缩放系数乘上去,就是最终的interval啦。
注意Mesh信息的“Bounds Size”
然而,这样计算出来的【interval】过于“精确”,会导致模型紧挨着一起。为了灵活调整,我们支持【interval】的“缩放”。
根据鼠标滚轮微调interval,一般的,intervalAccuracy = 0.1
微调interval
4. 预览对象与实例对象
有些时候,我们想实例化一个“预制体”,而不是“克隆体”,实现的API是这两个:
两种API
克隆对象与保持预制体连接的对象
在我们拉线时,实际上已经生成了游戏对象,只是修改【HideFlags】隐藏了他们的某些信息:
stamp.hideFlags = HideFlags.HideAndDontSave;
在确定生成时,只是进行了还原。
stamp.hideFlags = HideFlags.None;
5. 其他细节
每帧刷新SceneView:SceneView.RepaintAll();
屏幕坐标转世界射线:
var mousPos = Event.current.mousePosition;
var ray = HandleUtility.GUIPointToWorldRay(mousPos);
注册/注销SceneView更新:
SceneView.duringSceneGui += OnSceneView;
SceneView.duringSceneGui -= OnSceneView;
什么?你说你不会写UnityEditor扩展? 请:
Unity3d Editor 编辑器扩展功能详解(1) 目录索引
小结
实际上,对于某些“沙盒建造”类的GamePlay,功能逻辑和本文的逻辑别无二致,这个玩具有点好玩(雾)。 如果只是尝试着做做没问题。
如果是真正做项目的话,Houdini更适合做这种工作。
如果不想用Houdini,Unity的procedural world开发套件也更完善一些。而且你想怎么修改都可以直接改他的源代码。
这几搞一套那可是个大工程,项目都做完了你的工具都可能没做完...... 我是隐约猜到了应该让Houdini做这些,但是小公司的工作流就是“手工业”,没办法,摊手.jpg Houdini确实一开始会比较麻烦。但是从长远来说对小工作室是比较划算的选择。你一开始做自己的工具,可能会写得比较快,觉得这样效率更高。但是当美术工作过程中给你提越来越多的需求,你就会招架不住了。 非常感谢分享经验,看来我真得要学习Hounidi了。 根据我的经验,以后比依然会出现的需求。
第一、游戏场景不会是个大平板,极有可能是有起伏地面的。哪怕最初的设计说好了是,以后也完全有可能改,毕竟这种事对于提需求的人来说不过就是动动嘴,并且他们对于一件事难易程度的判断,大概率是通过写文档的长度来判断的。对于他们来说,这种修改落实在纸面上绝对不超过二十个汉字。
如果加上地块的起伏,就涉及到Terrain系统了。你是不是要根据Terrain的数据来安排你的摆放物?
那你要处理的问题包括不限于:根据地形处理你摆放物的高度,随机生成物体的旋转缩放、处理是否按照地形的法线来改变摆放物的方向。更高级一点的就是根据高度和坡度来分别制定摆放物的规则,以及以摆放物为中心按照一定规则去摆放其他物体(比如在石碓旁边一般都有草,道路两旁一般都有树)。
先不说你有没有能力搞出这么复杂的一套东西,哪怕你真的花了几个月搞出来了。一跑版本发现Terrain效率太低没法上真机,要转回Mesh的方法。你又要进行大量的修改才能保证之前写过的东西能继续使用。
我知道,一般来说做工具的初期,都是有明确的实现目标,目标定好了以后实现功能是程序员觉得理所应当的流程。因此遇到种种“非难”,你大可以说这些要求之前没提过,本来我写的工具也不是干这些的。
但是没用,说到底工具是辅助开发而不是反过来。对于决策来说可能这些需求只不过就是扩展你现在工具,你的“过激”反应只会让对方觉得你消极怠工或者能力不足。说实话让他们理解扩展和推到重来是两个概念很难,更不要说即便他们能理解,也只会认为那是你的问题,和他们提的需求无关。
我敢预言。大概率你自己闷头写了几个月,最终还是不得不回头去研究Houdini。所以作为一个过来人,我才建议你最好从一开始就从Houdini开始入手,能免去后续很多很多麻烦。
页:
[1]