【译】在Unity 2D 酷跑游戏中-实现程序生成无线地形的另类方法
发表于2016-04-25
版权声明:原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权
你是否有想过使用Unity来创建一个无限制的酷跑游戏?这样的游戏开发将会变得非常具有挑战性,甚至对于程序老人也一样。其中,你很可能想要在你的游戏中实现程序生成地形。在这里,我们将会向你展示如何通过Unity游戏引擎来实现这一功能。
TinyWings for iOS
原理
我们将会编写一个脚本,将这个层次分成几个片段,每个片段是一个包含了Mesh并且大小固定的物件,当摄像机即将渲染一个片段时,这个Mesh将会生成并设置到目标位置。片段将不在可见释放并返回到pool中.
听起来简单?这才刚刚开始…
建立
首先,我们将需要一个带有Mesh Filter和Mesh Renderer的Prefab.我们会使用这些来渲染一个片段。
MeshRenderer and Mesh Filter prefab
现在让我们来开始编写我们的脚本,我们命名这个脚本为“MeshGen”
public class MeshGen : MonoBehaviour
{
// the length of segment (world space)
public float SegmentLength = 5;
// the segment resolution (number of horizontal points)
public int SegmentResolution = 32;
// the size of meshes in the pool
public int MeshCount = 4;
// the maximum number of visible meshes. Should be lower or equal than MeshCount
public int VisibleMeshes = 4;
// the prefab including MeshFilter and MeshRenderer
public MeshFilter SegmentPrefab;
// helper array to generate new segment without further allocations
private Vector3[] _vertexArray;
// the pool of free mesh filters
private List _freeMeshFilters = new List();
}
然后,在脚本awaken时,我们想要初始化它的字段和建立Mesh pool, 我们使用一个pool来最小化垃圾回收。
void Awake()
{
// Create vertex array helper
_vertexArray = new Vector3[SegmentResolution * 2];
// Build triangles array. For all meshes this array always will
// look the same, so I am generating it once
int iterations = _vertexArray.Length / 2 - 1;
var triangles = new int[(_vertexArray.Length - 2) * 3];
for (int i = 0; i < iterations; ++i)
{
int i2 = i * 6;
int i3 = i * 2;
triangles[i2] = i3 + 2;
triangles[i2 + 1] = i3 + 1;
triangles[i2 + 2] = i3 + 0;
triangles[i2 + 3] = i3 + 2;
triangles[i2 + 4] = i3 + 3;
triangles[i2 + 5] = i3 + 1;
}
// Create colors array. For now make it all white.
var colors = new Color32[_vertexArray.Length];
for (int i = 0; i < colors.Length; ++i)
{
colors[i] = new Color32(255, 255, 255, 255);
}
// Create game objects (with MeshFilter) instances.
// Assign vertices, triangles, deactivate and add to the pool.
for (int i = 0; i < MeshCount; ++i)
{
MeshFilter filter = Instantiate(SegmentPrefab);
Mesh mesh = filter.mesh;
mesh.Clear();
mesh.vertices = _vertexArray;
mesh.triangles = triangles;
filter.gameObject.SetActive(false);
_freeMeshFilters.Add(filter);
}
}
有一件事情需要明确说明的是三角形的顺序问题,你可能看的醉了,对于Unity的网格来说你需要定义三角形索引,这些索引是顶点索引,每1个三角形有3个顶点,这里有两种方式你可以传递这些顶点,-顺时针方向或者逆时针方向,因为大多数Unity内置的Shaders(任意Shaders)都是以逆时针的三角形顺序来渲染的。并且抛弃(Culling)顺时针三角形顺序,所以我们要准守这个规则。
这里有一个4顶点形状的例子。从上面可以看出使用了两个三角形,如果顶点像上图那样定义的(0,1,2,3的顺序)。这样做的话三角形应该被运行创建。:
· 0-2-1 (alternatives: 2-1-0 or1-2-0)
· -1-2 (alternatives: 1-2-3or 2-3-1)
高度函数
为什么我们会需要一个高度函数?这只是一个单纯的函数并且可以自由的修改来获取不同的你想要的结果。在这个例子中,我们创建了一个由两个正弦组成的函数。
// Gets the heigh of terrain at current position.
// Modify this fuction to get different terrain configuration.
private float GetHeight(float position)
{
return (Mathf.Sin(position) + 1.5f + Mathf.Sin(position * 1.75f) + 1f) / 2f;
}
生成片段函数
当我们拥有了高度函数时,我们还需要一个能够基于返回值的生成网格的函数.
// This function generates a mesh segment.
// Index is a segment index (starting with 0).
// Mesh is a mesh that this segment should be written to.
public void GenerateSegment(int index, ref Mesh mesh)
{
float startPosition = index * SegmentLength;
float step = SegmentLength / (SegmentResolution - 1);
for (int i = 0; i < SegmentResolution; ++i)
{
// get the relative x position
float xPos = step * i;
// top vertex
float yPosTop = GetHeight(startPosition + xPos); // position passed to GetHeight() must be absolute
_vertexArray[i * 2] = new Vector3(xPos, yPosTop, 0);
// bottom vertex always at y=0
_vertexArray[i * 2 + 1] = new Vector3(xPos, 0, 0);
}
mesh.vertices = _vertexArray;
// need to recalculate bounds, because mesh can disappear too early
mesh.RecalculateBounds();
}
我们计算多个顶点作为片段解决方案字段值定义的变量,并且,我们也使用了 _vertexArray.因为它已经被分配内存和不会被其他任何对象所使用。(对网格分配的数组将会复制它从而取代通过引用,这样做并不会产生任何的垃圾)。顶点的位置是相对的。但是位置通过GetHeight()运算后会变成绝对位置。
检查片段是否被摄像机看见
当片段将要被摄像机渲染时你需要检查。你可以通过这个方法来实现。
private bool IsSegmentInSight(int index)
{
Vector3 worldLeft = Camera.main.ViewportToWorldPoint(new Vector3(0, 0, 0));
Vector3 worldRight = Camera.main.ViewportToWorldPoint(new Vector3(1, 0, 0));
// check left and right segment side
float x1 = index * SegmentLength;
float x2 = x1 + SegmentLength;
return x1 <= worldRight.x && x2 >= worldLeft.x;
}
存储关于可见片段的相关数据
如果一个将被显示,我们就要使用某种方式来存储它的信息,我们会需要一个片段索引同时也需要知道MeshFilter已经使用它来绘制这个片段。之后,当这个片段不在显示之后我们就可以将它放回Pool中,我们将在MeshGen类里面创建一个辅助结构体:
private struct Segment
{
public int Index { get; set; }
public MeshFilter MeshFilter { get; set; }
}
在MeshGen类里面,会有一个私有字段
// the list of used segments
private List _usedSegments = new List();
检查片段当前是否可见
我们需要检查一个片段当前是否可见,所以我们不会使用超过一个MeshFilters来渲染单个片段
private bool IsSegmentVisible(int index)
{
return SegmentCurrentlyVisibleListIndex(index) != -1;
}
private int SegmentCurrentlyVisibleListIndex(int index)
{
for (int i = 0; i < _usedSegments.Count; ++i)
{
if (_usedSegments[i].Index == index)
{
return i;
}
}
return -1;
}
看着名为SegmentCurrentlyVisibleListIndex 可能会有点困惑,它可以通过一个给定的索引来找到对应的片段。如果找到则会在_uesdSegments的表中返回这个片段索引。
显示片段
现在,进入最重要的部分。让这个片段可见!为此,我们编写了一个EnsureSegmentVisible()的方法。这个函数需要一个片段的索引,并且在这个函数执行之后将会确保给定索引的片段为可见。
private void EnsureSegmentVisible(int index)
{
if (!IsSegmentVisible(index))
{
// get from the pool
int meshIndex = _freeMeshFilters.Count - 1;
MeshFilter filter = _freeMeshFilters[meshIndex];
_freeMeshFilters.RemoveAt(meshIndex);
// generate
Mesh mesh = filter.mesh;
GenerateSegment(index, ref mesh);
// position
filter.transform.position = new Vector3(index * SegmentLength, 0, 0);
// make visible
filter.gameObject.SetActive(true);
// register as visible segment
var segment = new Segment();
segment.Index = index;
segment.MeshFilter = filter;
_usedSegments.Add(segment);
}
}
隐藏片段
当片段不被摄像机显示之后,它应该被回收并且MeshFilter应该被归还到Pool中。我们通过EnsureSegmentNotVisible()函数来实现。和之前的函数相反
private void EnsureSegmentNotVisible(int index)
{
if (IsSegmentVisible(index))
{
int listIndex = SegmentCurrentlyVisibleListIndex(index);
Segment segment = _usedSegments[listIndex];
_usedSegments.RemoveAt(listIndex);
MeshFilter filter = segment.MeshFilter;
filter.gameObject.SetActive(false);
_freeMeshFilters.Add(filter);
}
}
把所有功能连接起来
现在,进入最酷炫的部分。Update()函数!它应该隐藏所有不在显示的片段和显示应该渲染的片段。这个顺序在这里非常的重要。因为如果不注意的话就会把MeshFilters耗尽。
void Update()
{
// get the index of visible segment by finding the center point world position
Vector3 worldCenter = Camera.main.ViewportToWorldPoint(new Vector3(0.5f, 0.5f, 0));
int currentSegment = (int) (worldCenter.x / SegmentLength);
// Test visible segments for visibility and hide those if not visible.
for (int i = 0; i < _usedSegments.Count;)
{
int segmentIndex = _usedSegments[i].Index;
if (!IsSegmentInSight(segmentIndex))
{
EnsureSegmentNotVisible(segmentIndex);
} else {
// EnsureSegmentNotVisible will remove the segment from the list
// that's why I increase the counter based on that condition
++i;
}
}
// Test neighbor segment indexes for visibility and display those if should be visible.
for (int i = currentSegment - VisibleMeshes / 2; i < currentSegment + VisibleMeshes / 2; ++i)
{
if (IsSegmentInSight(i))
{
EnsureSegmentVisible(i);
}
}
}
程序生成地形的结果
它起作用了么?让我们移动摄像机的位置来检查一下。
关于 Package
你可以在这里下载到关于这个算法的unitypackage只适用Unity5.3.1及其更新版本。你可以随意修改!如果你有什么问题,请把这些问题发布到这个帖子的评论区中。我们会非常乐意帮助你。