Unity 3D : 贝塞尔道路网格生成
最近涉及到一个较大的场景,考虑到场景仅为演出效果,为节省资源打算使用tile贴图完成地面主要材质,道路则自动生成适用于贴片材质的网格。
目录
引言
最近的一个项目,涉及到一个较大的场景,考虑到场景仅为演出效果,为节省资源打算使用tile贴图完成地面主要材质,道路则自动生成适用于贴片材质的网格。
贝塞尔路径的展现与编辑
首先建立一个简单的类结构,用于存储每个曲线段。
// 单个曲线段的数据结构
[Serializable]
public class CurveSegment
{
public Vector3 point;
public Vector3 controlPoint1;//控制点1
public Vector3 controlPoint2;//控制点2
public bool isBezier = true; // 是否是贝塞尔曲线
}
在这里解释一下该结构的含义
每一个贝塞尔曲线只存储当前线段的结束点。 起始点由前一个相邻的曲线决定,这样规划的好处是不必在同步两个点上花费心思,同时也可以自动适应删除的逻辑(起始点始终是前面的线段)
不过注意,我希望能够混合存储直线/贝塞尔曲线,因此添加了名为isBezier
的标识变量,用于区分线段的类型。
为了管理这些曲线段,建立一个Mono
类。
我的计划是生成简单的
单向道路
,不存在交叉路口,因此只需要一个List
即可管理这些曲线段结构。
//我们希望在编辑器模式就可以执行Unity事件函数
[ExecuteInEditMode]
public class MultiSegmentBezierCurve : MonoBehaviour
{
public List<CurveSegment> segments = new(); // 曲线结构数组
private void Start()
{
//默认存在一个结构作为根
AddSegment();
}
// 添加新的曲线段
public void AddSegment()
{
//如果结构为空,则根的位置与GameObject的位置相同。
//否则自动衔接上一个曲线的位置
var lastPoint = segments.Count > 0 ? segments[^1].point : transform.position;
var newSegment = new CurveSegment
{
point = lastPoint + Vector3.right * 2,
controlPoint1 = lastPoint + Vector3.right * 0.5f,
};
newSegment.controlPoint2 = newSegment.point - Vector3.right * 0.5f;
segments.Add(newSegment);
}
// 删除最后一段曲线
public void RemoveSegment()
{
if (segments.Count > 1)
{
segments.RemoveAt(segments.Count - 1);
}
PointUpdated();
}
}
在Editor中绘制样条线
首先为了能在Editor
中画出线段内容。在Assets根目录建立Editor
文件夹。
作为先行步骤,我们先完成绘制逻辑:
//表明目标类是MultiSegmentBezierCurve
[CustomEditor(typeof(MultiSegmentBezierCurve))]
public class MultiSegmentBezierCurveEditor : UnityEditor.Editor
{
private void OnSceneGUI()
{
var curve = (MultiSegmentBezierCurve)target; //获取绑定的Mono
if (curve.segments.Count == 1)return; //如果当前只有根,则直接退出
//逐个循环每个区线段
for (var index = 0; index < curve.segments.Count; index++)
{
var isLast = index == curve.segments.Count - 1; //标记当前是否是末尾元素
CurveSegment lastSegment;
CurveSegment nextSegment;
if(index == 0) //跳过根
continue;
if (isLast) //如果是最后一段曲线,则进行如下处理
{
lastSegment = curve.segments[index - 1];
nextSegment = null;
}
else //一般情况
{
lastSegment = curve.segments[index - 1];
nextSegment = curve.segments[index + 1];
}
var segment = curve.segments[index];
//Draw Segment
var lastPoint = lastSegment.point;
var thisPoint = segment.point;
var cp1 = segment.controlPoint1;
var cp2 = segment.controlPoint2;
if (segment.isBezier) //绘制贝塞尔
{
Handles.color = Color.blue; //当前画笔颜色改为蓝色
Handles.DrawLine(lastPoint, cp1, 4); //绘制控制点1的直线
Handles.DrawLine(thisPoint, cp2, 4); //绘制控制点2的直线
Handles.color = Color.green;//画笔颜色改为绿色
Handles.DrawBezier(lastPoint, thisPoint, cp1, cp2, Color.green, null, 8f);// 使用内置函数绘制贝塞尔
}
else //绘制直线
{
Handles.color = Color.green;
Handles.DrawLine(lastPoint, thisPoint,4f); //绘制直线
}
}
}
}
此时,可以通过Inspector
编辑List
容器属性,手动添加贝塞尔曲线数据,提前查看效果,确定是否正常工作
你可能注意到,更新数据时画面并不会实时更新,这是由于OnSceneGUI并不是每时每刻都会被触发。
在Editor中绘制可操控的Handles
继续编辑MultiSegmentBezierCurveEditor
,实现显示自定义的交互标志(或称之为Handles
)并处理相应事件:
private void OnSceneGUI()
{
var curve = (MultiSegmentBezierCurve)target; //获取绑定的Mono
if (curve.segments.Count == 1)return; //如果当前只有根,则直接退出
//逐个循环每个区线段
for (var index = 0; index < curve.segments.Count; index++)
{
//Draw Segment
...见上文代码...
//Draw Handles
//首先绘制一个红色圆圈,用于显示point的位置
Handles.color = Color.red;
Handles.DrawWireDisc(segment.point, Vector3.up, 1f);
var controlPoint1 = segment.controlPoint1;
var controlPoint2 = segment.controlPoint2;
//在绘制可交互的Handles之前,使用BeginChangeCheck
//提醒Unity开始监听以下的Handles是否正在与用户交互
EditorGUI.BeginChangeCheck();
//在point的位置上,绘制一个位置Handles(一个小的,可操控的三维坐标)
var point = Handles.PositionHandle(segment.point, Quaternion.identity);
if (segment.isBezier)
{
//如果是贝塞尔,则绘制控制柄 颜色为蓝色
Handles.color = Color.blue;
//在controlPoint1的位置上,绘制一个滑块Handles,Handles.SphereHandleCap表明它的形状是一个球体
//这个滑块所处平面是 Vector3.up, Vector3.forward所形成平面(也就是主观上的地面),它只能在此平面移动
controlPoint1 = Handles.Slider2D(cp1, Vector3.up, Vector3.forward, Vector3.right, 0.4f, Handles.SphereHandleCap,Vector2.zero);
controlPoint2 = Handles.Slider2D(cp2, Vector3.up, Vector3.forward, Vector3.right, 0.4f, Handles.SphereHandleCap,Vector2.zero);
}
//如果Handles正在被用户交互,则EndChangeCheck返回true,否则返回false。
//在这里,如果用户没有与任何Handles交互,则跳过下面的代码
if (!EditorGUI.EndChangeCheck()) continue;
//使用RecordObject记录当前的操作。
//记录操作后,用户可以在操作后使用撤销恢复本次所做的更改
//这是官方用法,无需担心性能问题。
Undo.RecordObject(target, "Changed Position");
//请记住:当用户修改Handles后,相应的Handles会返回修改后的数值
//我们进行恒等判断,区分用户到底修改了哪个Handles
//用户每一帧只可能修改一个Handles(因为鼠标只有一个)因此使用elif分支判断即可
if (segment.point != point)
{
//用户更新了点 则应同时移动两个段的控制柄
var diff = point - segment.point;
segment.point = point;
segment.controlPoint2 += diff;
if (!isLast || isLoop)
nextSegment.controlPoint1 += diff;
}
else if (segment.controlPoint1 != controlPoint1)
{
//用户更新了控制柄1 则也应当一并更新上一个段的控制柄2
segment.controlPoint1 = controlPoint1;
lastSegment.controlPoint2 = lastSegment.point + (lastSegment.point - controlPoint1);
}
else if (segment.controlPoint2 != controlPoint2)
{
//用户更新了控制柄2 则应更新下一个段的控制柄1
segment.controlPoint2 = controlPoint2;
if(!isLast || isLoop)
nextSegment.controlPoint1 = point + (point - controlPoint2);
}
}
}
该逻辑较多,提取的重点如下:
在上面代码中,请着重注意:
绘制了两种Handles:
- 在point的位置上绘制
PositionHandle
(表现为可移动的小坐标轴)- 在控制点1/2的位置上绘制
Slider2D
滑块(表现为只能在固定平面移动的实心球体)实现了撤销功能:使用 Undo.RecordObject(Unity文档) 记录操作至Unity操作栈,用户可以按需撤销。
实现了Handles事件逻辑:使用 BeginChangeCheck/EndChangeCheck(Unity文档) 包围Handles所在的代码块,并在
EndChangeCheck
时检查用户是否正在修改Handles实现响应逻辑实现了控制点1/2的自动对称,跟随父级移动的逻辑
在Inspector中添加自定义按钮
接下来,为了能够更快捷的对曲线进行增删改的操作,可以考虑在Inspector窗口中添加交互按钮。
继续编辑MultiSegmentBezierCurveEditor
,重载OnInspectorGUI
函数,添加一个工具函数:
public override void OnInspectorGUI()
{
DrawDefaultInspector();
var curve = (MultiSegmentBezierCurve)target;
//检测按钮是否被按下,只需要检查其返回值即可
if (GUILayout.Button("添加曲线段"))
curve.AddSegment();
if (GUILayout.Button("删除最后一段曲线"))
curve.RemoveSegment();
//显示一个Label标签
GUILayout.Label("线段类型");
//为每个线段添加一个按钮,用于切换线段类型
for (var index = 1; index < curve.segments.Count; index++)
{
var segment = curve.segments[index];
CurveSegment nextSegment;
CurveSegment lastSegment;
lastSegment = curve.segments[index - 1];
nextSegment = index == curve.segments.Count - 1 ? null : curve.segments[index + 1];
if (GUILayout.Button("切换为" + (segment.isBezier ? "直线" : "贝塞尔") + "类型"))
{
segment.isBezier = !segment.isBezier;
if (segment.isBezier) //切换回贝塞尔,只需要更新本曲线的控制柄位置即可
{
if (lastSegment != null)
segment.controlPoint1 = lastSegment.point + (lastSegment.point - lastSegment.controlPoint2);
if (nextSegment != null)
segment.controlPoint2 = segment.point + (segment.point - nextSegment.controlPoint1);
}
else //切换到直线 稍微复杂,将前后段的贝塞尔控制柄自动对其到本直线
{
var step = (segment.point - lastSegment.point) / 4;
segment.controlPoint1 = lastSegment.point + step;
segment.controlPoint2 = segment.point - step;
if (nextSegment != null)
{ // 更新下一个段的控制柄1
DoUpdateCurveSegment(index + 1);
}
{ // 更新上一个段的控制柄2
DoUpdateCurveSegment(index - 1);
}
}
//注意:在这里通知Unity对视窗进行重绘,即触发OnSceneGUI
//否则更改无法被实时显示出来
SceneView.RepaintAll();
}
}
}
//工具函数,更新控制柄的位置使对齐前后曲线的控制柄
private void DoUpdateCurveSegment(int index)
{
var curve = (MultiSegmentBezierCurve)target;
var segment = curve.segments[index];
if (!segment.isBezier)return;
CurveSegment nextSegment;
CurveSegment lastSegment;
if (index == 0)
{
if(curve.segments.Count <= 1)return;
lastSegment = null;
nextSegment = curve.segments[1];
}
else
{
lastSegment = curve.segments[index - 1];
nextSegment = index == curve.segments.Count - 1 ? null : curve.segments[index + 1];
}
if (lastSegment != null)
{
segment.controlPoint1 = lastSegment.point + (lastSegment.point - lastSegment.controlPoint2);
}
if (nextSegment != null)
{
segment.controlPoint2 = segment.point + (segment.point - nextSegment.controlPoint1);
}
}
此效果仅堪堪够用,细节未考虑周全,不过你应该注意如下要点:
- 如果你在代码中修改了某个会影响画面表现的数据,则必须调用
SceneView.RepaintAll
通知Unity更新画面内容,因为内容的更新实际位于OnSceneGUI
事件中,而OnSceneGUI
并不是每时刻被调用。- 处理
GUILayout
中的控件事件只需检测其返回值
由点路径生成网格
在生成网格之前,我们需要知道该路径的描点。由于画图逻辑是自动绘制的,因此我们需要手动的逐一采样路径点。
曲线路径采样
首先要解决的是对贝塞尔采样,可以从其计算方式入手:
之后设定一个采样率resolution
作为参数即可,对于直线采样,则只需关注该直线的起始点与末尾点,整个路径的采样函数如下:
[SerializeField] private int resolution = 10;//采样率
public void PointUpdated()
{
float length = 0; //记录此路径的长度
var list = new List<Vector3>(); //采样后的点List
var lastPoint = Vector3.zero;
//逐一遍历所有线段
for (var i = 0; i < segments.Count; i++)
{
CurveSegment prevSegment;
if (i == 0) //如果是第一个
{
list.Add(segments[0].point); //添加点
lastPoint = segments[0].point;
continue; //直接进入下一段线段
}
else
prevSegment = segments[i - 1]; //更新prevSegment
var segment = segments[i];
if (segment.isBezier)
{
//对贝塞尔采样 采样精度为resolution
for (var step = 1; step <= resolution; step++)
{
var t = step / (float)resolution;
var point = Mathf.Pow(1 - t, 3) * prevSegment.point +
3 * Mathf.Pow(1 - t, 2) * t * segment.controlPoint1 +
3 * (1 - t) * Mathf.Pow(t, 2) * segment.controlPoint2 +
Mathf.Pow(t, 3) * segment.point;
list.Add(point);
length += (point - lastPoint).magnitude;//取得此点到上一个点的距离,并累加到路径长度
lastPoint = point;
}
}
else
{
//对直线则直接取两点
// list.Add(prevSegment.point);//无需添加直线开始点,因为开始点已经被加入
var len = (segment.point - prevSegment.point).magnitude;//量直线长度,并累加到路径长度
length += len;
list.Add(segment.point); //添加直线结束点
lastPoint = segment.point;
}
}
//采样完毕,此时可以根据list开始进行生成网格
}
由路径生成网格
*为避免混淆,我将路径采样得到的点称为路径点。*
Unity有两种方式 (官方文档)生成网格:
一种是传统的API:通过直接设置Mesh的vertices
/triangles
/uv
等属性(或SetVertices
/SetTriangles
/SetUVs
等)
一种是“高级”API(官方称如此):通过SetVertexBufferParams
/SetVertexBufferData
/SetSubMesh
等方法设置顶点各项属性,顶点的各个属性分量被集成到同一个数组中。
“高级”API省略了部分数据验证,因此执行效率更高,但你应确保数据的准确性。下面我将使用两种方式生成网格
网格生成逻辑阐述:
从路径点生成网格顶点
生成顶点的逻辑如下图所示。
首先寻找切线
位置(前进方向),之后根据切线
向左右延伸固定宽度,由此确定生成的顶点位置
实际上图中的“切线
”并不准确,我们的为了取得“切线
”的方向,只需要将上一段的矢量方向与下一段的矢量方向相加后取方向即可。
对于端点只需取
a
,b
之中存在的一种方向
之后取这条“切线
”的垂直方向,向这个垂直方向延伸一定的正负距离即可确定顶点位置。
由网格顶点生成面
生成面的逻辑如下图所示:
注意组成面的顶点顺序要保持一致,Unity使用
缠绕顺序
(winding order,即顶点的顺序)确定面朝向。因此需始终保持顺时针的方向组成面。Unity文档 mesh-index-data
由图中可以看出,最佳的方式是每个Path点
负责生成两个面,最后一个Path点
不生成面。
生成网格(传统API)
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float width = 1.0f) //输入路径点列表
{
//存储顶点数据:一共有path.Count * 2顶点待生成
var verts = new Vector3[path.Count * 2];
//存储三角形网格数据:一共有(path.Count - 1) * 2个三角形,每个三角形占用3个元素位置
var tris = new int[(path.Count - 1) * 2 * 3];
//记录当前已生成的进度
var vertIndex = 0;
var triIndex = 0;
//前后线段的方向
var fromDir = Vector2.zero;
var toDir = Vector2.zero;
for (var i = 0; i < path.Count; i++)
{
//路径前进方向:即“切线”
var forward = Vector2.zero;
//如果当前顶点并不是最后一个,即包含下一段,则加上下一段的方向
if (i < path.Count - 1)
{
var a = path[i + 1] - path[i];
//抛弃y轴数据,只取xz平面上的路径切线
toDir = new Vector2(a.x, a.z).normalized;
forward += toDir;
}
//如果当前顶点并不是第一个,即包含上一段,则加上上一段的方向
if (i > 0)
{
var a = path[i] - path[i - 1];
//抛弃y轴数据,只取xz平面上的路径切线
fromDir = new Vector2(a.x, a.z).normalized;
forward += fromDir;
}
//将“切线”归一化
forward.Normalize();
//取“切线”的垂直方向,长度为width
var left = new Vector2(-forward.y, forward.x) * width;
//======设置顶点位置=======
var pathPoint = new Vector2(path[i].x, path[i].z);
//将路径左移作为顶点
verts[vertIndex + 0] = new Vector3(pathPoint.x + left.x,path[i].y,pathPoint.y + left.y);
//将路径右移作为顶点
verts[vertIndex + 1] = new Vector3(pathPoint.x - left.x,path[i].y,pathPoint.y - left.y);
//======设置三角形数据=======
if (i < path.Count - 1)
{
//三角形之一
tris[triIndex + 0] = vertIndex + 2; //这个顶点在下一个path中生成
tris[triIndex + 1] = vertIndex + 1;
tris[triIndex + 2] = vertIndex + 0;
//三角形之二
tris[triIndex + 3] = vertIndex + 1;
tris[triIndex + 4] = vertIndex + 2;
tris[triIndex + 5] = vertIndex + 3;
}
//生成了两个顶点
vertIndex += 2;
//生成了两个三角形,占用了2*3=6个tris元素位置
triIndex += 2 * 3;
}
var mesh = new Mesh
{
vertices = verts, //赋值顶点数据
triangles = tris, //赋值三角形
};
return mesh;
}
注意:
- 在计算“切线”
forward
时,抛弃了Y轴数据,我们只需要在xz平面(地平面)上获取垂直于它的方向。triangles
数组的数据排列方式比较微妙,每三个为一组,将它想象成一个二维数组,triangles[triIndex]
表示访问第triIndex
个三角形。
生成网格(高级API)
private static readonly VertexAttributeDescriptor[] VertexAttributes = {
//表示顶点属性包含Position位置信息,数据格式是Float32(默认为此),包含3个分量xyz(默认为此)
new(VertexAttribute.Position,VertexAttributeFormat.Float32,3),
//(未来可以在此处添加更多信息,例如UV,Normal等数据)
};
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float width = 1.0f)
{
//表示当前有path.Count * 2个顶点
var vertexCount = path.Count * 2;
//每一个buffer长度为3(根据顶点属性,只包含一个Position属性,Position需要3个分量表示xyz)
const int bufferLength = 3;
//顶点属性buffer总长度
var vertexAttributeBufferLength = vertexCount * bufferLength;
//顶点属性buffer
var vertexAttributeBuffer = new float[vertexAttributeBufferLength];
//包含(path.Count - 1) * 2个三角形
var tris = new int[(path.Count - 1) * 2 * 3];
var vertIndex = 0;
var triIndex = 0;
//前后线段的方向
var fromDir = Vector2.zero;
var toDir = Vector2.zero;
for (var i = 0; i < path.Count; i++)
{
//标记本顶点属性的开始位置
var start = i * bufferLength * 2;
//方向
var forward = Vector2.zero;
if (i < path.Count - 1)
{
var a = path[i + 1] - path[i];
toDir = new Vector2(a.x, a.z).normalized;
forward += toDir;
}
if (i > 0)
{
// fromDir = toDir;
var a = path[i] - path[i - 1];
fromDir = new Vector2(a.x, a.z).normalized;
forward += fromDir;
}
forward.Normalize();
var left = new Vector2(-forward.y, forward.x) * width;
//======设置顶点位置=======
var pathPoint = new Vector2(path[i].x, path[i].z);
for (var j = 0; j < 2; j++)
{
var isLeftSidePoint = j == 0;
//offset用于自动的定位左右端点
var offset = bufferLength * j;
if (isLeftSidePoint)
{
// 顶点数据 左端点(加上left偏移)
//[start + offset]表示当前是第start + offset个顶点的数据位置
//[start + offset + 0]表示当前是第start + offset个顶点的首个属性
//首个属性具体含义由代码开头VertexAttributeDescriptor的定义可知:
//前三个数据是Position数据,因此[start + offset + 0]是x分量,[start + offset + 1]是y分量...以此类推
vertexAttributeBuffer[start + offset + 0] = pathPoint.x + left.x ;
vertexAttributeBuffer[start + offset + 1] = path[i].y;
vertexAttributeBuffer[start + offset + 2] = pathPoint.y + left.y ;
}
else
{
// 顶点数据 右端点(减去left偏移)
vertexAttributeBuffer[start + offset + 0] = pathPoint.x - (left.x );
vertexAttributeBuffer[start + offset + 1] = path[i].y;
vertexAttributeBuffer[start + offset + 2] = pathPoint.y - (left.y );
}
}
//======设置三角形数据=======
if (i < path.Count - 1)
{
//左侧三角形
tris[triIndex + 0] = vertIndex + 2; //这个顶点在下一个path中生成
tris[triIndex + 1] = vertIndex + 1;
tris[triIndex + 2] = vertIndex + 0;
//右侧三角形
tris[triIndex + 3] = vertIndex + 1;
tris[triIndex + 4] = vertIndex + 2; //这个顶点在下一个path中生成
tris[triIndex + 5] = vertIndex + 3; //这个顶点在下一个path中生成
}
vertIndex += 2;
triIndex += 6;
}
//将顶点缓冲区写入Mesh
var mesh = new Mesh{ name = "PathMesh" };
//固定范式
mesh.SetVertexBufferParams(vertexCount, VertexAttributes);
mesh.SetVertexBufferData(vertexAttributeBuffer, 0, 0, vertexAttributeBufferLength);
//将顶点索引写入索引缓冲区
var indexCount = tris.Length;
mesh.SetIndexBufferParams(indexCount, IndexFormat.UInt32);
mesh.SetIndexBufferData(tris, 0, 0, indexCount);
//每个Mesh至少包含一个SubMesh
mesh.subMeshCount = 1;
var subMeshDescriptor = new SubMeshDescriptor(0, indexCount);
mesh.SetSubMesh(0, subMeshDescriptor);
//计算Bounds信息
mesh.RecalculateBounds();
return mesh;
}
可以看到最大的差异在于设置顶点的位置信息代码变得冗长。
在高级API中,需要手动的设置顶点属性的每个分量,顶点属性可以包含多种数据,上面代码中只包含位置信息,则需要设置x,y,z的数据,在下文将添加UV信息,则需要设置x,y,z,uv.x,uv.y信息,以此类推。高级API的顶点数据更加紧凑,无需额外开辟UVs数据数组,后文会有所体现。
自动更新网格
首先在Mono
中,添加一个pathWidth
属性,并更新先前的采样路径点函数PointUpdated
,在函数末尾调用上文的网格生成函数CreatePathMesh
:
//添加一个道路宽度属性
[SerializeField] private float pathWidth = 1f;
public void PointUpdated(){
//...
...其他代码...
//采样完毕,此时可以开始进行生成网格
//需要一个MeshRenderer
//考虑为这个Mono类添加特性[RequireComponent(typeof(MeshFilter))],以便Unity自动的为你添加这个组件
//并在Start事件中保存MeshFilter的变量,这样不必每次都GetComponent
GetComponent<MeshFilter>().mesh = CreatePathMesh(list,pathWidth);
}
注意:如果你决定在Start中保存MeshFilter的引用,请留意在
ExecuteInEditMode
特性修饰下Awake和Start
仅在脚本赋给物体的时候被调用,更新脚本不会触发Awake
和Start
为了能够在发生改动时自动更新生成网格,在MultiSegmentBezierCurveEditor
的OnSceneGUI
,OnInspectorGUI
方法中适时调用PointUpdated
:
//在for循环中,EndChangeCheck之后的末尾调用PointUpdated
private void OnSceneGUI(){
var curve = (MultiSegmentBezierCurve)target;
...其他代码...
for (var index = 0; index < curve.segments.Count; index++){
...其他代码...
if (!EditorGUI.EndChangeCheck()) continue;
...其他代码...
//在这里调用PointUpdated
curve.PointUpdated();
}
}
//在按钮点击事件的末尾调用PointUpdated
public override void OnInspectorGUI(){
...
if (GUILayout.Button("切换为" + (segment.isBezier ? "直线" : "贝塞尔") + "类型")){
...
//在这里调用PointUpdated
curve.PointUpdated();
}
}
如果没有问题,你应该可以看到出现紫色的网格体
此时你会发现在Inspector
中修改pathWidth
不会实时更新网格,可以在Mono
中实现OnValidate
方法,当属性修改时调用PointUpdated
private void OnValidate()
{
PointUpdated();
}
UV生成
为了能够让Tiles贴图在竖直方向(y轴)上延伸,且让左端点的UV.X
为0,由端点的UV.X
为1,UV
的分布如下:
明确一点:网格的分布并不是均匀的,因此需要在UV划分上加入对距离的考量。
之后只需添加对UV
数据处理的代码即可:
传统API-对UV数据的处理
为节省篇幅,这里只列出与上文代码不同的地方
//在参数中添加一个totalLength用于标定总体长度
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLength,float width = 1.0f) //输入路径点列表
{
...原始代码...
//声明一个UV数组存储uv,长度为verts数组的长度,与verts数组一一对应
var uvs = new Vector2[verts.Length];
//添加一个记录当前路径长度的数组
var currentLength = 0f;
//在for循环中
for (var i = 0; i < path.Count; i++)
{
...原始代码...
//添加对Uv的数据处理
if (i == 0)
{
//端点开头设置固定值
uvs[vertIndex + 0] = new Vector2( 0,0);
uvs[vertIndex + 1] = new Vector2(0,1);
}
else
{
//更新当前长度
currentLength += (path[i] - path[i - 1]).magnitude;
//根据长度判断进度
var u = currentLength / totalLength;
//设置uv数据
uvs[vertIndex + 0] = new Vector2( u,0);
uvs[vertIndex + 1] = new Vector2(u,1);
}
}
var mesh = new Mesh
{
vertices = verts, //赋值顶点数据
triangles = tris, //赋值三角形
uvs = uvs, //*赋值uv
};
return mesh;
}
高级API-对UV数据的处理
再次强调:相较于传统API,高级API可直接为顶点分配UV,位置,法向等信息。而传统API需要通过
mesh.uvs
/mesh.normals
的方式分配。
//首先要更新VertexAttributeDescriptor,添加UV数据
private static readonly VertexAttributeDescriptor[] VertexAttributes = {
new(VertexAttribute.Position,VertexAttributeFormat.Float32,3),
//默认uv使用第一个纹理坐标TexCoord0,其包含2个分量(x和y)
new(VertexAttribute.TexCoord0, VertexAttributeFormat.Float32, 2),
};
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLength,float width = 1.0f)
{
//需要更改的地方:因为添加了两个分量(uv坐标的x和y),因此bufferLength需要增加2
const int bufferLength = 3 + 2;
..原始代码...
//添加一个说明型变量 增加可读性
var isFirstPathPoint = i == 0;
for (var j = 0; j < 2; j++){
...原始代码...
//设置uv数据
if (isFirstPathPoint)
{
//为什么是+4?因为0 1 2对应着顶点的position坐标x y z,则3 4则对应着uv的x y
//这里是将uv的y设定为0
vertexAttributeBuffer[start + offset + 4] = 0;
vertexAttributeBuffer[start + offset + 4] = 0;
}
else
{
var u = currentLength / totalLen;
vertexAttributeBuffer[start + offset + 4] = u;
vertexAttributeBuffer[start + offset + 4] = u;
}
//根据左右顶点,将uv的x设置为0或者1
vertexAttributeBuffer[start + offset + 3] = isLeftSidePoint ? 0 : 1;
}
...原始代码...
}
效果检验
在开始之前需要添加
MeshRenderer
组件,可以使用[RequireComponent(typeof(MeshRenderer))]
特性修饰Mono
类
你可以使用一个简单的shader
检验uv分布是否符合预期
Shader "Unlit/UVDisplay"{
Properties{ }
SubShader{
Tags { "RenderType"="Opaque" }
LOD 100
Pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
v2f_img vert (const appdata_base v){
v2f_img o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}
fixed4 frag (v2f_img i) : SV_Target{
return lerp(lerp(i.uv.y,fixed4(1,0,0,1),step(0.495,abs(i.uv.y - 0.5)))
,fixed4(1,0,0,1),step(0.49 ,abs(i.uv.x - 0.5)));
}
ENDCG
}
}
}
UV的01边界处将由红色描边,内容由uv.y
填充,预期结果应为
网格斜角处理(Miter join)
现在有一个问题,当使用多段直线弯折时,会出现拐角处路面不平行的问题:
由于每个路径点左右延伸的宽度是固定的,对于发生倾斜的线段,固定的宽度会导致收缩。
为了解决这个问题,我们需要延长位于拐角处的宽度,根据弯折角度确定宽度延伸倍率。
要计算这个倍率,可以从几何的角度出发:
三个圆半径相同,蓝色为直径,其中黑色线段为切线,灰色线段为圆心连线,求红色线段的长度。
那么我们应该从如下角度思考
解决方法
在生成网格时计算这个倍率,使用传统API和高级API的计算方式是一样的:
在left
变量声明之后对left
进行倍率缩放:
//添加一个参数miterJoin :我们需不需要处理斜角?
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLength,float width = 1.0f,bool miterJoin = false)
{
...此前的代码完全一致
forward.Normalize();
var left = new Vector2(-forward.y, forward.x) * width;
//在left变量声明之后,添加额外的计算方式
if (miterJoin && fromDir != Vector2.zero && toDir != Vector2.zero)
{
var fromLeft = new Vector2(-fromDir.y, fromDir.x);
var toLeft = new Vector2(-toDir.y, toDir.x);
var angle = Vector2.Angle(fromLeft, toLeft) / 2;
if (angle != 0) //如果发生弯折,则乘上新的倍率
left *= 1 / Mathf.Cos(angle * Mathf.Deg2Rad);
}
//其余代码一致...
}
对于高锐度的斜角,不适用于
Tiles
贴图,因为必然会出现接缝,或严重拉伸的问题
Tiles自适应
由于UV
是单纯的从0到1,所以会导致当道路延长时,贴图跟随道路伸展或压缩,这不符合预期。
先前我们计算了totalLength
用于计算uv,现在可以基于totalLength
计算材质的Y向缩放倍率进而实现所谓的“自适应”,
例如在计算采样点的PointUpdated
方法中:
//添加一个倍率值属性
[SerializeField] [Range(0.01f,1)] private float tiling = 0.5f;
public void PointUpdated()
{
...其余代码...
//在更新网格之前或之后
_meshFilter.mesh = CreatePathMesh(list,length,isLoop,pathWidth,miterJoin);
if(_meshRenderer.sharedMaterial)
_meshRenderer.sharedMaterial.mainTextureScale = new Vector2(1, length * tiling);
}
仅对于包含
_MainTex_ST
输入的Shader
有效,你可以为shader
添加_MainTex ("Texture", 2D)
属性和float2 _MainTex_ST
变量用于读取倍率
Loop路径循环
现在只差最后一步,只需要将头尾链接即可。但要注意:起始段顶点已分配了UV
,不可被再次覆盖,因此我们应在起始段使用两个新的顶点完全覆盖它的位置,在这个新的顶点上使用新的UV
数据。
在开始之前…
在Mono
脚本中,添加一个属性isLoop
用于标识此路径是否是循环路径。更新PointUpdated
让其对根线段进行采样。
public bool isLoop;
public void PointUpdated()
{
float length = 0;
var list = new List<Vector3>();
var lastPoint = Vector3.zero;
for (var i = 0; i < segments.Count; i++)
{
CurveSegment prevSegment;
if (i == 0)
{
if (isLoop)//开启了循环
{
//前一个线段为末尾线段
prevSegment = segments[^1];
//上一个点是末尾线段的点,用于测量距离
lastPoint = prevSegment.point;
}
else //未开启循环
{
list.Add(segments[0].point); //直接添加一个点,作为起始点
lastPoint = segments[0].point;
continue; //跳过根线段的网格生成
}
}
else
{
prevSegment = segments[i - 1];
}
//...之后的内容不变,依旧是采样路径...
var segment = segments[i];
}
}
有必要提醒:
PointUpdated
中list.Add
用于添加采样点,且仅在采样一次之后才能使用Add
,换句话说list.Add
只能添加末尾的点,因此开启Loop后不能直接调用Add
,而未开启Loop时则需要直接调用Add
(相当于根线段直接采样完毕成为一个点)
接下来修改MultiSegmentBezierCurveEditor
。
在旧的OnSceneGUI
中,我们直接跳过了对根线段的绘制,现在我们希望对于Loop
路径,可以显示初始点完整的controlPoint1/2
,因此对其修改如下:
private void OnSceneGUI()
{
var curve = (MultiSegmentBezierCurve)target;
if (curve.segments.Count == 1)return;
var isLoop = curve.isLoop;
//先进行特化处理 如果不是循环路径
if (!isLoop)//那么我们只绘制根的Point Handle,用于允许移动
{
var firstSegment = curve.segments[0];
Handles.color = Color.red;
EditorGUI.BeginChangeCheck();
var firstPoint = Handles.PositionHandle(firstSegment.point,Quaternion.identity);
if (EditorGUI.EndChangeCheck())//移动了point 则进行更新
{
var diff = firstPoint - firstSegment.point;
firstSegment.point = firstPoint;
if (curve.segments.Count > 1)
curve.segments[1].controlPoint1 += diff;//即使controlPoint1是不可见的,依然需要更新
curve.PointUpdated();
}
}
//下面进行逐一处理
for (var index = 0; index < curve.segments.Count; index++)
{
var isLast = index == curve.segments.Count - 1;
CurveSegment lastSegment;
CurveSegment nextSegment;
if (isLoop)//是loop
{
if (index == 0)
{ //对于loop,第一个的上一个就是最后一个
lastSegment = curve.segments[^1];
nextSegment = curve.segments[1];
}
else if(isLast)
{
lastSegment = curve.segments[index - 1];
//对于loop,最后一个的下一个就是第一个
nextSegment = curve.segments[0];
}
else
{
lastSegment = curve.segments[index - 1];
nextSegment = curve.segments[index + 1];
}
}
else
{
if(index == 0) //不是loop,直接跳过对根的绘制,因为我们已经处理过了
continue;
if (isLast)
{
lastSegment = curve.segments[index - 1];
nextSegment = null;//不是loop,末尾的下一个就是null
}
else
{
lastSegment = curve.segments[index - 1];
nextSegment = curve.segments[index + 1];
}
}
//...下面的剩余代码相同...
var segment = curve.segments[index];
//Draw Segment
}
}
主要是针对
lastSegment
,nextSegment
的处理,因为loop是循环的。
如果你使用了OnInspectorGUI
的方法,请注意一并处理,OnInspectorGUI
的情况更复杂,因为涉及到传递index
而非传递引用,因此最便捷的方式是添加变量lastSegmentIndex
,nextSegmentIndex
,把index
进行独立管理。
限于篇幅原因,且考虑到修改起来并不困难,因此在此处略过OnInspectorGUI
的修改内容。
Loop网格生成
最关键的步骤在于创建一个位置与起始位置重叠,但UV
数据相反的顶点。
为了便于拓展, 将使用List
类型代替原先的数组。
篇幅至此已略长,我将只给出高级API的网格生成解决方案,至于使用传统API还请读者手动修正相关内容。
为了能在任意时机复制当前的顶点,我们需要一个函数DuplicateVertexes
,用于复制顶点,我们称之为D逻辑
。
默认自动建立的顶点的逻辑将被提取为CreateVertexes
函数,用于执行默认的建立顶点逻辑,我们称之为C逻辑
。
二者的区别是:
- D逻辑
会与先前的顶点组成三角形,不会与在其之后生成的顶点组成三角形。
- C逻辑
既会连接之前的顶点也会连接之后的顶点,若先前的顶点已经与D逻辑
的顶点相连,则C逻辑
将只会连接之后的顶点。
图中为了区分,特意将两者的顶点间隙扩大,实际上两种顶点是完全重叠的。
两者都是CreatePathMesh
的局部函数,因此它们可以使用CreatePathMesh
的局部变量。
首先是CreateVertex
函数:
void CreateVertex(int i, Vector2 left, float leftUVy, float rightUVy)
{
var pathPoint = new Vector2(path[i].x, path[i].z);
for (var j = 0; j < 2; j++)
{
var isLeftSidePoint = j == 0;
var offset = bufferLength * j;
if (isLeftSidePoint)
{
// 顶点数据 左端点
vertexAttributeBuffer[start + offset + 0] = pathPoint.x + left.x ;
vertexAttributeBuffer[start + offset + 1] = path[i].y;
vertexAttributeBuffer[start + offset + 2] = pathPoint.y + left.y ;
//uv.y
vertexAttributeBuffer[start + offset + 4] = leftUVy;
}
else
{
// 顶点数据 右端点
vertexAttributeBuffer[start + offset + 0] = pathPoint.x - (left.x );
vertexAttributeBuffer[start + offset + 1] = path[i].y;
vertexAttributeBuffer[start + offset + 2] = pathPoint.y - (left.y );
//uv.y
vertexAttributeBuffer[start + offset + 4] = rightUVy;
}
//uv.x
vertexAttributeBuffer[start + offset + 3] = isLeftSidePoint ? 0 : 1;
}
}
可以看出它仅仅是将原始的逻辑提取为函数。
参数中leftUVy
和rightUVy
分别指定了左顶点和右顶点的UV.y
的值,UV.x
的值将会自动生成,其值始终是往左为0,往右为1
接下来是DuplicateVertexes
函数:
void DuplicateVertexes(int i,Vector2 left,float leftUVy = 0f,float rightUVy = 0f)
{
//扩充2个顶点的容量
vertexAttributeBuffer.AddRange(new float[bufferLength * 2]);
CreateVertex(i, left, leftUVy, rightUVy);
//自动递增相关的标志
vertIndex += 2;
vertexCount += 2;
start += bufferLength * 2;
vertexAttributeBufferLength += 2 * bufferLength;
}
从功能描述中可以看出其包含了CreateVertex
的功能,因此从实现逻辑上它本身就是CreateVertex的一层封装。
三角形连接采用的是相对寻址,在DuplicateVertexes
中修改相关的偏移量便可实现类似于“鸠占鹊巢”的效果,抢占三角形位置,达到预期目的。
再次强调:由于这些都是局部函数,因此可以共用父函数的局部变量,例如
start
,vertIndex
,vertexCount
等
之后,重写CreatePathMesh
,在末尾加入前文的两个局部函数,同时添加针对loop的逻辑。
为节省篇幅,我删去了与文章最初所给出的一致的注释
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLen,bool isLoop = false,float width = 1.0f,bool miterJoin = false)
{
var vertexCount = path.Count * 2;
const int bufferLength = 3 + 2;
var vertexAttributeBufferLength = vertexCount * bufferLength;
var vertexAttributeBuffer = new List<float>(new float[vertexAttributeBufferLength]);
//注意:根据是否开启了loop循环决定三角形个数
//默认有(path.Count - 1) * 2个三角形,开启循环后,将添加两个三角形,即path.Count * 2个三角形
var tris = new int[(path.Count - (isLoop ? 0 : 1)) * 2 * 3];
var vertIndex = 0;
var triIndex = 0;
var start = 0;
var currentLength = 0f;
var fromDir = Vector2.zero;
var toDir = Vector2.zero;
for (var i = 0; i < path.Count; i++)
{
if (i != 0)
{
currentLength += (path[i] - path[i - 1]).magnitude;
}
var forward = Vector2.zero;
if (i < path.Count - 1)
{
var a = path[i + 1] - path[i];
toDir = new Vector2(a.x, a.z).normalized;
forward += toDir;
}else if (isLoop)
{
//如果开启了loop 则可以计算最后一段曲线的toDir
var a = path[0] - path[i];
toDir = new Vector2(a.x, a.z).normalized;
forward += toDir;
}
if (i > 0)
{
// fromDir = toDir;
var a = path[i] - path[i - 1];
fromDir = new Vector2(a.x, a.z).normalized;
forward += fromDir;
}else if (isLoop)
{
//如果开启了loop 则可以计算第一段曲线的fromDir
var a = path[0] - path[^1];
fromDir = new Vector2(a.x, a.z).normalized;
forward += fromDir;
}
forward.Normalize();
var left = new Vector2(-forward.y, forward.x) * width;
//斜角处理
if (miterJoin && fromDir != Vector2.zero && toDir != Vector2.zero)
{
var fromLeft = new Vector2(-fromDir.y, fromDir.x);
var toLeft = new Vector2(-toDir.y, toDir.x);
var angle = Vector2.Angle(fromLeft, toLeft) / 2 * Mathf.Deg2Rad;
if (angle != 0)
{
left *= 1 / Mathf.Cos(angle);
}
}
//若是起始点,且开启了Loop,则复制当前顶点
//(因为是最先生成的顶点,因此顶点的编号为0,1,在之后会用到此编号)
if (i == 0 && isLoop)
DuplicateVertexes(i, left,1f,1f);
var isFirstPathPoint = i == 0;
var uvy =isFirstPathPoint ? 0 : currentLength / totalLen;
//调用默认建立顶点的逻辑
CreateVertex(i, left,uvy,uvy);
//开始创建三角形
if (i < path.Count - 1)
{
//左侧三角形
tris[triIndex + 0] = vertIndex + 2; //这个顶点在下一个path中生成
tris[triIndex + 1] = vertIndex + 1;
tris[triIndex + 2] = vertIndex + 0;
//右侧三角形
tris[triIndex + 3] = vertIndex + 1;
tris[triIndex + 4] = vertIndex + 2; //这个顶点在下一个path中生成
tris[triIndex + 5] = vertIndex + 3; //这个顶点在下一个path中生成
}
else if (isLoop) //如果开启了Loop,则在末尾生成三角形
{
//与前半部分if分支内比较:
//将原本位于下一个path中生成的顶点,
//替换为最初使用D逻辑复制的顶点编号(0,1)
tris[triIndex + 0] = 0;
tris[triIndex + 1] = vertIndex + 1;
tris[triIndex + 2] = vertIndex + 0;
tris[triIndex + 3] = vertIndex + 1;
tris[triIndex + 4] = 0;
tris[triIndex + 5] = 1;
}
vertIndex += 2;
triIndex += 6;
start += bufferLength * 2;
}
//与先前一致,此处不在赘述
var mesh = new Mesh{name = "PathMesh"};
mesh.SetVertexBufferParams(vertexCount, VertexAttributes);
mesh.SetVertexBufferData(vertexAttributeBuffer, 0, 0, vertexAttributeBufferLength);
var indexCount = tris.Length;
mesh.SetIndexBufferParams(indexCount, IndexFormat.UInt32);
mesh.SetIndexBufferData(tris, 0, 0, indexCount);
mesh.subMeshCount = 1;
var subMeshDescriptor = new SubMeshDescriptor(0, indexCount);
mesh.SetSubMesh(0, subMeshDescriptor);
mesh.RecalculateBounds();
return mesh;
...CreateVertex的定义放在这里...
...DuplicateVertexes的定义放在这里...
}
注意:使用D逻辑复制的起始路径点顶点将会预留到末尾路径点生成顶点后使用。
三角形连接采用的是相对寻址(vertIndex
+ 偏移个数),这是D逻辑与C逻辑能够发挥作用的基础。
若无差错,则此时便可实现单向UV
的Loop路径
如果在D逻辑
中为顶点添加恒定偏移,便能看到接缝位置:
高锐度斜角UV纠正
如之前提到的,对于高锐度的斜角,不适用于Tiles
贴图,因为必然会出现接缝,或严重拉伸的问题,但是某种意义上接缝要比拉伸好的多,因此在这里我给出一个纠正UV
的方案,以接缝来代替拉伸问题,可供参考。
考虑一种极端情况:
黑色为实际UV坐标,红色为预期正确的UV
坐标
由于UV是基于长度比例totalLength
计算的,因此可以通过这点计算偏移Δ
:
得出正确的坐标后,使用前文的DuplicateVertexes
/CreateVertex
分离交界处的UV
,便能做到类似于裁切UV的效果。
代码如下:
//添加参数maxAngleThreshold 用于指定最大的角度,超过此角度则进行UV分离
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLen,bool isLoop = false,float width = 1.0f,bool miterJoin = false,float maxAngleThreshold = 45f)
{
...默认代码...
for (var i = 0; i < path.Count; i++)
{
//添加一个标记变量,防止CreateVertex被调用多次
var vertCreated = false;
...默认代码...
//在斜角处理过程中,添加角度判断逻辑
if (miterJoin && fromDir != Vector2.zero && toDir != Vector2.zero)
{
var fromLeft = new Vector2(-fromDir.y, fromDir.x);
var toLeft = new Vector2(-toDir.y, toDir.x);
//注意:此处改为有符号的角度
var angle = Vector2.SignedAngle(fromLeft, toLeft) / 2 * Mathf.Deg2Rad;
if (angle != 0)
{
left *= 1 / Mathf.Cos(angle);
//斜角UV纠正
if (Mathf.Abs(angle) > maxAngleThreshold * Mathf.Deg2Rad)
{
var u = currentLength / totalLen;
var offset = left.magnitude * Mathf.Sin(angle) / totalLen;
DuplicateVertexes(i,left,u-offset,u+offset);
CreateVertex(i,left,u+offset,u-offset);
vertCreated = true; //接管了create 无需再次create
}
}
...默认代码...
//将CreateVertex使用!vertCreated.if包围
if (!vertCreated)
{
var isFirstPathPoint = i == 0;
var uvy =isFirstPathPoint ? 0 : currentLength / totalLen;
CreateVertex(i, left,uvy,uvy);
}
...剩余代码一致...
}
}
}
虽然存在明显接缝,但基于长度比例的UV计算对于特征明显的部分可做到接近同步传送效果。
至此本文完结,任何想法或建议,欢迎在评论区留言探讨。如果文章中有任何疏漏或不准确之处,恳请指正。
更多推荐
所有评论(0)