mypro334 发表于 2022-8-5 20:41

【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, $"间隔: {interval}m", 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("围墙生成器", EditorStyles.boldLabel);
            layerMask = EditorGUILayout.LayerField("目标检测Layer", layerMask);
            targetPrefab = EditorGUILayout.ObjectField("围墙预制体", targetPrefab, typeof(GameObject), false) as GameObject;
            instanceParent = EditorGUILayout.ObjectField("实例生成父对象", instanceParent, typeof(Transform), true) as Transform;
            if (targetPrefab == null)
            {
                EditorGUILayout.HelpBox("请选择目标预制体", MessageType.Error);
            }
            else
            {
                instanceType = (InstanceType)EditorGUILayout.EnumPopup("实例化类型", instanceType);
                alignAxis = (AlignAxis)EditorGUILayout.EnumPopup("对齐方向轴", alignAxis);
                onXZplane = EditorGUILayout.Toggle("XZ面对齐", onXZplane);
                EditorGUILayout.HelpBox("暂时仅支持Right轴的xz面对齐方式", MessageType.Warning);
                if (!onXZplane)
                {
                  using (new EditorGUILayout.HorizontalScope())
                  {
                        lockX = EditorGUILayout.Toggle("锁定X轴旋转", lockX);
                        lockY = EditorGUILayout.Toggle("锁定Y轴旋转", lockY);
                        lockZ = EditorGUILayout.Toggle("锁定Z轴旋转", lockZ);
                  }
                }
                genrationScale = EditorGUILayout.Slider("生成缩放", genrationScale, 0, 10);
                if (GUILayout.Button("自动调整间隔大小"))
                {
                  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("未找到模型MeshFilter,自动修正间隔失败...");
                  }
                }
                interval = EditorGUILayout.Slider("实例间隔", interval, 0.01f, 100);
                intervalAccuracy = EditorGUILayout.FloatField("实例间隔修改精度", intervalAccuracy);
                EditorGUILayout.HelpBox("开始:A;结束:S;撤销:D;Alt + 鼠标滚轮可以微调生成间距", 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,功能逻辑和本文的逻辑别无二致,这个玩具有点好玩(雾)。

FeastSC 发表于 2022-8-5 20:45

如果只是尝试着做做没问题。
如果是真正做项目的话,Houdini更适合做这种工作。
如果不想用Houdini,Unity的procedural world开发套件也更完善一些。而且你想怎么修改都可以直接改他的源代码。
这几搞一套那可是个大工程,项目都做完了你的工具都可能没做完......

zt3ff3n 发表于 2022-8-5 20:52

我是隐约猜到了应该让Houdini做这些,但是小公司的工作流就是“手工业”,没办法,摊手.jpg

c0d3n4m 发表于 2022-8-5 20:58

Houdini确实一开始会比较麻烦。但是从长远来说对小工作室是比较划算的选择。你一开始做自己的工具,可能会写得比较快,觉得这样效率更高。但是当美术工作过程中给你提越来越多的需求,你就会招架不住了。

unityloverz 发表于 2022-8-5 21:00

非常感谢分享经验,看来我真得要学习Hounidi了。

DomDomm 发表于 2022-8-5 21:01

根据我的经验,以后比依然会出现的需求。
第一、游戏场景不会是个大平板,极有可能是有起伏地面的。哪怕最初的设计说好了是,以后也完全有可能改,毕竟这种事对于提需求的人来说不过就是动动嘴,并且他们对于一件事难易程度的判断,大概率是通过写文档的长度来判断的。对于他们来说,这种修改落实在纸面上绝对不超过二十个汉字。
如果加上地块的起伏,就涉及到Terrain系统了。你是不是要根据Terrain的数据来安排你的摆放物?
那你要处理的问题包括不限于:根据地形处理你摆放物的高度,随机生成物体的旋转缩放、处理是否按照地形的法线来改变摆放物的方向。更高级一点的就是根据高度和坡度来分别制定摆放物的规则,以及以摆放物为中心按照一定规则去摆放其他物体(比如在石碓旁边一般都有草,道路两旁一般都有树)。
先不说你有没有能力搞出这么复杂的一套东西,哪怕你真的花了几个月搞出来了。一跑版本发现Terrain效率太低没法上真机,要转回Mesh的方法。你又要进行大量的修改才能保证之前写过的东西能继续使用。
我知道,一般来说做工具的初期,都是有明确的实现目标,目标定好了以后实现功能是程序员觉得理所应当的流程。因此遇到种种“非难”,你大可以说这些要求之前没提过,本来我写的工具也不是干这些的。
但是没用,说到底工具是辅助开发而不是反过来。对于决策来说可能这些需求只不过就是扩展你现在工具,你的“过激”反应只会让对方觉得你消极怠工或者能力不足。说实话让他们理解扩展和推到重来是两个概念很难,更不要说即便他们能理解,也只会认为那是你的问题,和他们提的需求无关。
我敢预言。大概率你自己闷头写了几个月,最终还是不得不回头去研究Houdini。所以作为一个过来人,我才建议你最好从一开始就从Houdini开始入手,能免去后续很多很多麻烦。
页: [1]
查看完整版本: 【UnityTools】地编工具——围墙生成(1)