- 设计一个通用的网格Job框架。
- 定义单独的网格流和生成器。
- 禁用对 native container 访问的限制。
- 在 XZ 平面上创建一个四边形网格。
- 生成四边形行而不是单个四边形。
这是关于程序网格的系列教程中的第二篇。上一篇教程介绍了高级 Mesh API。这次我们将使用该 API 制作一个 Burst Job,该Job生成一个由多个四边形组成的方形网格。
本教程使用 Unity 2020.3.18f1 制作。
1 程序网格Job框架
方形网格只是我们可以生成的许多程序网格之一。因此,我们将首先设计一个支持通用方法的框架,而不是直接从方形网格Job开始。这将有点类似于我们在伪随机噪声系列中使用的通用方法,但有一些不同之处。
1.1 通用顶点
我们要做的第一件事是定义一个通用的 Vertex 结构类型来保存顶点数据。让我们将其资产文件放在 Scripts / Procedural Meshes 子文件夹中。
Vertex 的内容与 AdvancedSingleStreamProceduralMesh.Vertex 相同,只是我们不会为最小化它的大小而烦恼,因此为所有内容赋予适当的浮点类型。
using Unity.Mathematics;
public struct Vertex {
public float3 position, normal;
public float4 tangent;
public float2 texCoord0;
}
在 Pseudorandom Noise 系列中,我们将所有与噪声相关的类型放在一个类中,并使用部分类将代码拆分为多个文件。这次我们将使用不同的方法:自定义命名空间,我们将其命名为 ProceduralMeshes。
要使类型成为命名空间的一部分,必须在具有适当名称的命名空间块内定义它,就好像它嵌套在类块内一样。对顶点执行此操作。
using Unity.Mathematics;
namespace ProceduralMeshes {
public struct Vertex {
public float3 position, normal;
public float4 tangent;
public float2 texCoord0;
}
}
我们也将把所有其他 ProceduralMeshes 类型的资源放在 Procedural Meshes 文件夹中。
1.2 网格流
为了存储网格数据,我们需要定义顶点和索引缓冲区,并以适当的格式复制相关数据。我们将通过引入 ProceduralMeshes.IMeshStreams 接口来隔离此代码,而不是为每个Job显式定义此代码。它将负责设置顶点和索引缓冲区,隐藏有多少流的详细信息以及确切的数据格式是什么。
using Unity.Mathematics;
using UnityEngine;
namespace ProceduralMeshes {
public interface IMeshStreams { }
}
它的首要职责是初始化网格数据。我们将为此定义一个 Setup 方法,将网格数据作为参数,以及所需的顶点数和索引数。
void Setup(Mesh.MeshData data, int vertexCount, int indexCount);
它还负责将顶点复制到网格的顶点缓冲区,无论流的数量和数据格式如何。为此,我们将使用 SetVertex 方法,并将顶点索引和数据设置为参数。
void SetVertex(int index, Vertex data);
我们对索引缓冲区也要这样做。由于使用三角形而不是单个索引更方便,我们将定义一个SetTriangle方法,将三角形索引和int3顶点索引三合一作为参数。
void SetTriangle(int index, int3 triangle);
该接口最直接的实现是单流方法。我们将这种类型命名为 SingleStream,它必须是一个结构才能处理 Burst Job。我们还将在 ProceduralMeshes.Streams 嵌套命名空间中对流实现进行分组。我还将他们的资产放在 Scripts / Procedural Meshes / Streams 子文件夹中。
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.Rendering;
namespace ProceduralMeshes.Streams {
public struct SingleStream : IMeshStreams {}
}
添加Setup方法,用它来定义网格的缓冲区,就像我们在AdvancedSingleStreamProceduralMesh中做的那样,只是我们将在所有地方使用32位浮点。同时立即设置单个子网格,还不用担心它的边界。
public void Setup (Mesh.MeshData meshData, int vertexCount, int indexCount) {
var descriptor = new NativeArray<VertexAttributeDescriptor>(
4, Allocator.Temp, NativeArrayOptions.UninitializedMemory
);
descriptor[0] = new VertexAttributeDescriptor(dimension: 3);
descriptor[1] = new VertexAttributeDescriptor(
VertexAttribute.Normal, dimension: 3
);
descriptor[2] = new VertexAttributeDescriptor(
VertexAttribute.Tangent, dimension: 4
);
descriptor[3] = new VertexAttributeDescriptor(
VertexAttribute.TexCoord0, dimension: 2
);
meshData.SetVertexBufferParams(vertexCount, descriptor);
descriptor.Dispose();
meshData.SetIndexBufferParams(indexCount, IndexFormat.UInt32);
meshData.subMeshCount = 1;
meshData.SetSubMesh(0, new SubMeshDescriptor(0, indexCount));
}
为了在单个流中存储顶点数据,引入一个私有的嵌套Stream0类型。它与顶点完全匹配,只是在这里我们应该确保字段的顺序是固定的,将StructLayout(LayoutKind.Sequential)属性附加到它。用它来为这个流定义一个本地数组字段,并在Setup的最后检索它。
[StructLayout(LayoutKind.Sequential)]
struct Stream0 {
public float3 position, normal;
public float4 tangent;
public float2 texCoord0;
}
NativeArray<Stream0> stream0;
public void Setup (Mesh.MeshData meshData, int vertexCount, int indexCount) {
…
stream0 = meshData.GetVertexData<Stream0>();
}
SetVertex的实现包括将顶点数据复制到一个Stream0值,并将其存储在流中的适当索引处。
public void SetVertex (int index, Vertex vertex) => stream0[index] = new Stream0 {
position = vertex.position,
normal = vertex.normal,
tangent = vertex.tangent,
texCoord0 = vertex.texCoord0
};
我们对SetVertex的实现是微不足道的,但它可能要复杂得多,例如,如果我们决定将部分数据存储为16位值,需要进行转换。在这种情况下,Burst可能决定只包含一次SetVertex代码,并在每次顶点被设置时插入一条调用指令–方法调用。这种方法很慢,而且无法进行积极的代码优化。因此,我们将指示Burst总是在线插入整个代码,而不是去调用。这是通过给方法附加MethodImpl属性来实现的,MethodImplOptions.AggressiveInlining是其参数。这些类型是System.Runtime.CompilerServices命名空间的一部分。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetVertex (int index, Vertex vertex) => stream0[index] = new Stream0 {
…
};
我们不应该总是建议对Burst代码进行积极的内联吗?
作为经验法则,你确实可以一直这样做,但大多数情况下没有必要。小的方法会被自动内联,那些只使用一个的方法也会被内联。为了确定这一点,请检查由Burst生成的代码,看看是否存在不需要的调用指令。
在我们的具体案例中,我们将在每个四边形中调用SetVertex四次。如果我们在SetVertex中加入从浮点转换为半数的代码,那么Burst很可能不会内联这个方法。在这一点上,SetVertex不需要这个属性,但是我把它作为一个示范。
最后,我们可以直接将三角形数据复制到索引缓冲区,将索引数据重新解释为int3三角形数据。将本地数组存储在Setup末尾的一个字段中,并在SetTriangle中执行复制。
NativeArray<int3> triangles;
public void Setup (Mesh.MeshData meshData, int vertexCount, int indexCount) {
…
stream0 = meshData.GetVertexData<Stream0>();
triangles = meshData.GetIndexData<int>().Reinterpret<int3>(4);
}
…
public void SetTriangle (int index, int3 triangle) => triangles[index] = triangle;
1.3 网格生成器
我们还将为负责生成网格的那部分代码引入一个接口,将其命名为ProceduralMeshes.IMeshGenerator。它定义了被Job执行的代码,所以它需要一个带有索引参数的Execute方法。我们也给它第二个参数,用于存储流。这必须是一个通用参数,被限制为一个实现IMeshStreams的结构。我们不需要让整个接口都是通用的,我们可以只限制在Execute方法上
using UnityEngine;
namespace ProceduralMeshes {
public interface IMeshGenerator {
void Execute<S> (int i, S streams) where S : struct, IMeshStreams;
}
}
我们需要知道被生成的网格的顶点数量,生成器可以通过VertexCount getter属性来提供。我们可以通过写int VertexCount { get; }将其添加到接口中。同时包括一个索引计数的getter属性。
int VertexCount { get; }
int IndexCount { get; }
除此之外,在调度Job时也必须知道Job的长度。添加一个JobLength getter属性来提供这个信息。
int JobLength { get; }
为了生成我们的方形网格,我们必须通过定义ProceduralMeshes.Generators.SquareGrid结构类型来实现这个接口,再一次在一个嵌套的命名空间和独立的子文件夹中。
using Unity.Mathematics;
using UnityEngine;
using static Unity.Mathematics.math;
namespace ProceduralMeshes.Generators {
public struct SquareGrid : IMeshGenerator {}
}
我们现在还不会生成网格,重点是先完成框架。所以现在只提供一个最小的实现,生成一个空网格,什么都不做。
public int VertexCount => 0;
public int IndexCount => 0;
public int JobLength => 0;
public void Execute<S> (int z, S streams) where S : struct, IMeshStreams {}
1.4 Mesh Job
下一步是定义一个Burst Job来生成网格,为此我们引入了ProceduralMeshes.MeshJob类型。这是一个通用的IJobFor结构,带有IMeshGenerator和IMeshStreams的类型参数。
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
namespace ProceduralMeshes {
[BurstCompile(FloatPrecision.Standard, FloatMode.Fast, CompileSynchronously = true)]
public struct MeshJob<G, S> : IJobFor
where G : struct, IMeshGenerator
where S : struct, IMeshStreams {}
}
给它的生成器和流的私有字段。它的Execute方法只是将调用转发给生成器,将索引和流都传给它。
G generator;
S streams;
public void Execute (int i) => generator.Execute(i, streams);
因为我们只在生成网格时向流写入,而不从流中读取,所以我们可以给流附加WriteOnly属性。这将间接地将只写状态应用到IMeshStreams实现所包含的本地数组上。
[WriteOnly]
S streams;
就像我们在伪随机噪声系列中所做的那样,我们也给这个Job提供了自己的公共静态ScheduleParallel方法,用于创建和调度这个Job,并返回其Job句柄。它需要网格数据和Job时间作为参数。在这种情况下,我们必须在调度前调用Job流的Setup,将网格数据与我们从Job生成器中获取的顶点和索引计数一起传递给它。
public static JobHandle ScheduleParallel (
Mesh.MeshData meshData, JobHandle dependency
) {
var job = new MeshJob<G, S>();
job.streams.Setup(
meshData, job.generator.VertexCount, job.generator.IndexCount
);
return job.ScheduleParallel(job.generator.JobLength, 1, dependency);
}
1.5 程序网格组件
为了尝试我们的框架,我们将创建一个ProceduralMesh组件类型,它将设置其MeshFilter组件的网格,就像之前教程中的组件一样。这个类型并不是框架本身的一部分,所以我们将把它的资产放在Scripts文件夹中。另外,由于它不是我们命名空间的一部分,我们必须将它们全部导入。
using ProceduralMeshes;
using ProceduralMeshes.Generators;
using ProceduralMeshes.Streams;
using UnityEngine;
using UnityEngine.Rendering;
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class ProceduralMesh : MonoBehaviour {}
这一次我们将在Awake方法中创建Mesh对象,生成Mesh,并将其分配给MeshFilter。我们把生成Mesh的代码放在一个单独的GenerateMesh方法中,并通过一个字段来跟踪Mesh的情况。
Mesh mesh;
void Awake () {
mesh = new Mesh {
name = "Procedural Mesh"
};
GenerateMesh();
GetComponent<MeshFilter>().mesh = mesh;
}
void GenerateMesh () {}
生成Mesh包括分配可写的Mesh数据,然后为其调度并立即完成一个MeshJob–使用我们的SquareGrid和SingleStream类型–然后应用于Mesh。
void GenerateMesh () {
Mesh.MeshDataArray meshDataArray = Mesh.AllocateWritableMeshData(1);
Mesh.MeshData meshData = meshDataArray[0];
MeshJob<SquareGrid, SingleStream>.ScheduleParallel(
meshData, default
).Complete();
Mesh.ApplyAndDisposeWritableMeshData(meshDataArray, mesh);
}
现在创建一个程序网格游戏对象,无论是在新场景中,还是替换上一教程中现有的四边形生成游戏对象
1.6 生成四边形
在这一点上,当进入播放模式时,会产生一个空网格。
我们得到一个空网格,因为我们的Job还没有做任何事情。目前该Job根本没有安排,因为它的长度为零。我们通过让 SquareGrid.JobLength 返回 1 来激活Job。
public int JobLength => 1;
这导致我们的Job被安排,但是当进入播放模式时,我们现在得到一个无效的操作异常,抱怨说两个容器可能是同一个东西。这指的是SingleStream的两个本地数组。Unity抱怨说它们可能是别名,这意味着本地数组可能代表重叠的数据。原因是所有的网格数据都是一个未被管理的内存块。我们的工作试图同时访问这些数据的两个部分–顶点部分和三角形索引部分,Unity不允许这样做,因为它可能产生错误的结果。
一般来说,Unity 的安全检查是有效的,应该注意,但在这种情况下,我们确定顶点和索引数据永远不会重叠。因此,我们将通过将 Unity.Collections.LowLevel.Unsafe 命名空间中的 NativeDisableContainerSafetyRestriction 属性附加到两个原生数组字段来禁用安全性。
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Mathematics;
…
namespace ProceduralMeshes.Streams {
public struct SingleStream : IMeshStreams {
…
[NativeDisableContainerSafetyRestriction]
NativeArray<Stream0> stream0;
[NativeDisableContainerSafetyRestriction]
NativeArray<int3> triangles;
…
}
}
为了测试我们的框架,我们将让SquareGrid暂时只生成一个四边形,与我们在上一个教程中生成的四边形完全一样。所以它的顶点数量必须变成四个。
public int VertexCount => 4;
在执行中,首先创建一个通用顶点值,并设置它的法线和切线向量,这对于所有顶点都是相同的。由于所有值都初始化为零,我们只需设置非零分量就足够了。因此,法线 Z 分量变为 -1,切线 XW 分量变为 1 和 -1。
public void Execute (int i, S streams) {
var vertex = new Vertex();
vertex.normal.z = -1f;
vertex.tangent.xw = float2(1f, -1f);
}
xw 的分配是如何工作的?
这是一个swizzle操作,它允许我们按照我们想要的顺序对向量的一个子集进行赋值。swizzle操作也可以用来提取组件的子集,不管我们喜欢什么顺序,甚至是重复的。例如,我们也可以访问.yx、.zy、.xx、.xy、.zxxy等等,而不是.xy。数学类型通过属性实现这些。
然后我们可以设置第一个顶点,索引为零。我们忽略传递给 Execute 方法的索引,因为我们将一次性生成整个四边形。我们可以这样做,因为我们禁用了native arrays的安全限制。
var vertex = new Vertex();
vertex.normal.z = -1f;
vertex.tangent.xw = float2(1f, -1f);
streams.SetVertex(0, vertex);
我们不需要 NativeDisableParallelForRestriction 属性来写入任何索引吗?
是的,但是NativeDisableContainerSafetyRestriction属性禁用了所有限制,所以我们不需要同时应用NativeDisableParallelForRestriction。
通过调整位置和纹理坐标并设置其他三个顶点来完成这个四边形。
streams.SetVertex(0, vertex);
vertex.position = right();
vertex.texCoord0 = float2(1f, 0f);
streams.SetVertex(1, vertex);
vertex.position = up();
vertex.texCoord0 = float2(0f, 1f);
streams.SetVertex(2, vertex);
vertex.position = float3(1f, 1f, 0f);
vertex.texCoord0 = 1f;
streams.SetVertex(3, vertex);
我们还需要两个三角形,因此将索引计数设置为 6。
public int IndexCount => 6;
然后在Execute的末尾设置两个三角形。
public void Execute (int i, S streams) {
…
streams.SetTriangle(0, int3(0, 2, 1));
streams.SetTriangle(1, int3(1, 2, 3));
}
这应该产生一个四边形,但相反,我们甚至在Job被安排之前就得到一个参数异常。这发生在SingleStream.Setup中设置子网格的时候。当我们调用SetSubMesh时,它立即验证了三角形的指数并重新计算了边界。这几乎可以保证会失败,因为这时Job还没有运行,所以索引缓冲区包含了任意的数据。我们必须提供MeshUpdateFlags来表明SetSubMesh不应该对这些数据做任何事情。在之前的教程中我们已经使用了DontRecalculateBounds。这一次我们也必须使用DontValidateIndices。我们用二进制的OR |操作符来合并这两个标志。
meshData.SetSubMesh(
0, new SubMeshDescriptor(0, indexCount),
MeshUpdateFlags.DontRecalculateBounds |
MeshUpdateFlags.DontValidateIndices
);
1.7 Bounds
我们的Mesh唯一缺少的是有效的边界。生成器应该提供这些边界,所以在IMeshGenerator接口上添加一个属性来获取这些边界。
Bounds Bounds { get; }
然后将实现添加到 SquareGrid。
public Bounds Bounds => new Bounds(new Vector3(0.5f, 0.5f), new Vector3(1f, 1f));
要设置边界,在MeshJob.ScheduleParallel中添加一个Mesh的参数。我把它作为第一个参数。这样我们就可以在创建Job后立即设置Mesh的边界。
public static JobHandle ScheduleParallel (
Mesh mesh, Mesh.MeshData meshData, JobHandle dependency
) {
var job = new MeshJob<G, S>();
mesh.bounds = job.generator.Bounds;
…
}
在ProceduralMesh.GenerateMesh中传递网格。
MeshJob<SquareGrid, SingleStream>.ScheduleParallel(
mesh, meshData, default
).Complete();
我们还应该设置子网格的边界。为了实现这一点,我们将把边界作为第二个参数添加到IMeshStreams.Setup中。
void Setup(
Mesh.MeshData meshData, Bounds bounds, int vertexCount, int indexCount
);
调整SingleStream.Setup,使其设置子网格的边界和顶点计数。
public void Setup (
Mesh.MeshData meshData, Bounds bounds, int vertexCount, int indexCount
) {
…
meshData.SetSubMesh(
0, new SubMeshDescriptor(0, indexCount) {
bounds = bounds,
vertexCount = vertexCount
},
MeshUpdateFlags.DontRecalculateBounds |
MeshUpdateFlags.DontValidateIndices
);
…
}
最后,在MeshJob.ScheduleParallel中设置流的时候,要包括边界。我们可以将边界存储在一个变量中,或者直接使用Mesh边界赋值表达式的结果作为Setup的一个参数。我采用了后者来演示这个用法。
public static JobHandle ScheduleParallel (
Mesh mesh, Mesh.MeshData meshData, JobHandle dependency
) {
var job = new MeshJob<G, S>();
//mesh.bounds = job.generator.Bounds;
job.streams.Setup(
meshData,
mesh.bounds = job.generator.Bounds,
job.generator.VertexCount,
job.generator.IndexCount
);
return job.ScheduleParallel(job.generator.JobLength, 1, dependency);
}
1.8 16 位索引
在之前的教程中,我们将三角形的索引从32位减少到16位,因为这样可以将索引缓冲区的大小减半。让我们对我们的框架也做同样的事情。一个方便的方法是在ProceduralMeshes.Streams命名空间中定义一个TriangleUInt16类型。它是一个包含三个ushort值的连续结构。给它一个从int3到TriangleUInt16的隐式转换操作符。
using System.Runtime.InteropServices;
using Unity.Mathematics;
namespace ProceduralMeshes.Streams {
[StructLayout(LayoutKind.Sequential)]
public struct TriangleUInt16 {
public ushort a, b, c;
public static implicit operator TriangleUInt16 (int3 t) => new TriangleUInt16 {
a = (ushort)t.x,
b = (ushort)t.y,
c = (ushort)t.z
};
}
}
现在我们可以通过改变三角形索引元素类型和索引缓冲区格式,将SingleStream切换到16位索引。
[NativeDisableContainerSafetyRestriction]
NativeArray<TriangleUInt16> triangles;
public void Setup (
Mesh.MeshData meshData, Bounds bounds, int vertexCount, int indexCount
) {
…
meshData.SetIndexBufferParams(indexCount, IndexFormat.UInt16);
…
triangles = meshData.GetIndexData<ushort>().Reinterpret<TriangleUInt16>(2);
}
1.9 多重顶点流(Multiple Vertex Streams)
作为一个不同的IMeshStreams实现的例子,让我们包括一个多流的方法,如AdvancedMultiStreamProceduralMesh。复制SingleStream并将其重命名为MultiStream。用四个流来代替它的单个顶点属性的单流。
public struct MultiStream : IMeshStreams {
//[StructLayout(LayoutKind.Sequential)]
//struct Stream0 {
// …
//}
//[NativeDisableContainerSafetyRestriction]
//NativeArray<Stream0> stream0;
[NativeDisableContainerSafetyRestriction]
NativeArray<float3> stream0, stream1;
[NativeDisableContainerSafetyRestriction]
NativeArray<float4> stream2;
[NativeDisableContainerSafetyRestriction]
NativeArray<float2> stream3;
…
public void Setup (
Mesh.MeshData meshData, Bounds bounds, int vertexCount, int indexCount
) {
…
descriptor[1] = new VertexAttributeDescriptor(
VertexAttribute.Normal, dimension: 3, stream: 1
);
descriptor[2] = new VertexAttributeDescriptor(
VertexAttribute.Tangent, dimension: 4, stream: 2
);
descriptor[3] = new VertexAttributeDescriptor(
VertexAttribute.TexCoord0, dimension: 2, stream: 3
);
…
stream0 = meshData.GetVertexData<float3>();
stream1 = meshData.GetVertexData<float3>(1);
stream2 = meshData.GetVertexData<float4>(2);
stream3 = meshData.GetVertexData<float2>(3);
triangles = meshData.GetIndexData<ushort>().Reinterpret<TriangleUInt16>(2);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetVertex (int index, Vertex vertex) {
stream0[index] = vertex.position;
stream1[index] = vertex.normal;
stream2[index] = vertex.tangent;
stream3[index] = vertex.texCoord0;
}
…
}
现在可以通过在ProceduralMesh.GenerateMesh中用MultiStream代替SingleStream来切换到多流方法。
MeshJob<SquareGrid, MultiStream>.ScheduleParallel(
mesh, meshData, default
).Complete();
请注意,生成器代码只知道通用顶点的情况。它完全不知道顶点数据是如何被存储的。甚至有可能只存储部分数据,例如省略法线和切线。Burst会优化掉不需要的代码。
2 四边形网格
现在我们有了一个功能框架,我们继续生成一个由多个四边形组成的网格,将它们放置在一起,形成一个规则的正方形网格。与单个四边形相比,这样的网格本身并没有任何好处,但它可以作为更复杂的网格的基础,而不是完全的平面。在本教程中,我们将把自己限制在简单的网格上。
2.1 网格分辨率
我们将调整我们的代码,使其能够产生一个R×R方格的网格,其中R代表网格的分辨率。网格的分辨率是一个一般的概念,为此我们可以给IMeshGenerator添加一个属性。在这种情况下,该属性应该是可设置的,我们通过在其块中加入set;来强制执行。
int Resolution { get; set; }
我们可以在SquareGrid中实现这个属性,包括同样的一行代码,只是添加了公共访问修改器。这就产生了一个微不足道的自动属性,其中隐含了一个属性所使用的字段。
public int Resolution { get; set; }
顶点数、索引数和工作长度现在取决于分辨率。四边形的数量等于分辨率的平方,所以必须全部乘以这个。
public int VertexCount => 4 * Resolution * Resolution ;
public int IndexCount => 6 * Resolution * Resolution;
public int JobLength => Resolution * Resolution;
在MeshJob.ScheduleParallel中添加一个分辨率参数,并在创建Job后立即使用它来设置生成器的分辨率。
public static JobHandle ScheduleParallel (
Mesh mesh, Mesh.MeshData meshData, int resolution, JobHandle dependency
) {
var job = new MeshJob<G, S>();
job.generator.Resolution = resolution;
…
}
然后给ProceduralMesh添加一个分辨率滑块,在生成网格的时候使用。最小应该是1,最大我用10。
[SerializeField, Range(1, 10)]
int resolution = 1;
…
void GenerateMesh () {
…
MeshJob<SquareGrid, MultiStream>.ScheduleParallel(
mesh, meshData, resolution, default
).Complete();
…
}
为了支持在游戏模式下改变分辨率时重新生成网格,我们必须做一些改变。我们这次的做法是加入一个更新方法,生成网格,然后关闭组件。这样Update就不会在每一帧都被无谓地调用。我们在一个新的OnValidate方法中启用该组件。这意味着我们不再需要在 Awake中生成网格。
void Awake () {
…
//GenerateMesh();
GetComponent<MeshFilter>().mesh = mesh;
}
void OnValidate () => enabled = true;
void Update () {
GenerateMesh();
enabled = false;
}
2.2 生成所有的四边形
为了确保我们为所有四边形设置数据,我们将在Execute开始时确定正确的顶点和三角形索引。传递给Execute的工作索引代表四边形索引。所以它的第一个顶点索引是四边形的,第一个三角形索引是双倍的。
public void Execute (int i, S streams) {
int vi = 4 * i, ti = 2 * i;
…
}
我们通过在第一个顶点上增加一个偏移量来找到其他的顶点指数。为了清楚起见,我包括了零偏移,尽管它并不影响代码。
streams.SetVertex(vi + 0, vertex);
vertex.position = right();
vertex.texCoord0 = float2(1f, 0f);
streams.SetVertex(vi + 1, vertex);
vertex.position = up();
vertex.texCoord0 = float2(0f, 1f);
streams.SetVertex(vi + 2, vertex);
vertex.position = float3(1f, 1f, 0f);
vertex.texCoord0 = 1f;
streams.SetVertex(vi + 3, vertex);
三角形的情况也是如此。在这种情况下,我们还必须将第一个顶点索引添加到定义三角形的相对顶点索引中,以保持它们的相对性。
streams.SetTriangle(ti + 0, vi + int3(0, 2, 1));
streams.SetTriangle(ti + 1, vi + int3(1, 2, 3));
我们还必须确定四边形的位置偏移,相对于它们的左下角。我们通过用四边形索引的整数除以分辨率来找到Y偏移。然后通过用四边形索引减去Y乘以分辨率来找到X偏移。
int vi = 4 * i, ti = 2 * i;
int y = i / Resolution;
int x = i - Resolution * y;
我们可以在一个float4值中定义四边形所需的所有四个坐标,包含X、X+1、Y和Y+1。但我们最初只加0.9,以便在四边形之间留出可见的间隙。
int y = i / Resolution;
int x = i - Resolution * y;
var coordinates = float4(x, x + 0.9f, y, y + 0.9f);
我们可以通过对坐标进行swizzle操作来正确设置位置,为每个位置选择合适的两个坐标。
vertex.position.xy = coordinates.xz;
streams.SetVertex(vi + 0, vertex);
vertex.position.xy = coordinates.yz;
vertex.texCoord0 = float2(1f, 0f);
streams.SetVertex(vi + 1, vertex);
vertex.position.xy = coordinates.xw;
vertex.texCoord0 = float2(0f, 1f);
streams.SetVertex(vi + 2, vertex);
vertex.position.xy = coordinates.yw;
vertex.texCoord0 = 1f;
streams.SetVertex(vi + 3, vertex);
2.3 一个面
网格通常用于平面,所以让我们调整我们的网格,使其位于XZ平面内。首先,将y重命名为z,同时关闭四边形之间的间隙。
int z = i / Resolution;
int x = i - Resolution * z;
var coordinates = float4(x, x + 1f, z, z + 1f);
我们通过分配给顶点位置的XZ分量而不是XY分量来改变网格的方向。
vertex.position.xz = coordinates.xz;
streams.SetVertex(vi + 0, vertex);
vertex.position.xz = coordinates.yz;
vertex.texCoord0 = float2(1f, 0f);
streams.SetVertex(vi + 1, vertex);
vertex.position.xz = coordinates.xw;
vertex.texCoord0 = float2(0f, 1f);
streams.SetVertex(vi + 2, vertex);
vertex.position.xz = coordinates.yw;
我们还必须改变法线向量,使其指向上方。
vertex.normal.y = 1f;
如果平面以原点为中心,并且有一个固定的尺寸,无论其分辨率如何,这也很方便。我们可以通过将所有坐标除以分辨率,然后减去1/2来实现这一点。
var coordinates = float4(x, x + 1f, z, z + 1f) / Resolution - 0.5f;
调整边界以匹配。
public Bounds Bounds => new Bounds(Vector3.zero, new Vector3(1f, 0f, 1f));
2.4 生成四边形行
我们的工作目前是孤立地生成网格的每一个象限。创建一个四边形并不费力,但顶点数据不能被矢量化。因此,所有的东西都必须按四边形计算,Unity的Job框架增加了额外的开销。我们可以通过在一次调用Execute的过程中合并生成多个四边形来提高效率。最有意义的是将单行的所有四边形一起生成。这将使Job的长度与分辨率相等,不再是平方。
public int JobLength => Resolution;
我们将让每次调用Execute来处理沿X轴的一整行四边形。因此,工作索引将代表该行的Z偏移,而不是四边形索引。让我们相应地重命名它。另外,该行的第一个四边形索引因此等于分辨率乘以Z。
public void Execute (int z, S streams) {
int vi = 4 * Resolution * z, ti = 2 * Resolution * z;
//int z = i / Resolution;
…
}
现在,我们没有使用固定的X偏移量,而是为整个行引入了一个循环,它包围了填充流的代码。
//int x = i - Resolution * z;
for (int x = 0; x < Resolution; x++) {
var coordinates = float4(x, x + 1f, z, z + 1f) / Resolution - 0.5f;
…
streams.SetTriangle(ti + 0, vi + int3(0, 2, 1));
streams.SetTriangle(ti + 1, vi + int3(1, 2, 3));
}
为了设置正确的四边形,在循环的每一次迭代之后,我们必须将顶点指数增加4,将三角形指数增加2。
for (int x = 0; x < Resolution; x++, vi += 4, ti += 2) { … }
最后,Burst可以检测到循环内从未改变的代码,并自动将其从循环中拉出。然而,它不会分割向量,所以我们可以通过手动将坐标向量分割成独立的X和Z对来优化一下。Z坐标的计算是恒定的,因此会被拉出循环。
//var coordinates = float4(x, x + 1f, z, z + 1f) / Resolution - 0.5f;
var xCoordinates = float2(x, x + 1f) / Resolution - 0.5f;
var zCoordinates = float2(z, z + 1f) / Resolution - 0.5f;
//vertex.position.xz = coordinates.xz;
vertex.position.x = xCoordinates.x;
vertex.position.z = zCoordinates.x;
streams.SetVertex(vi + 0, vertex);
//vertex.position.xz = coordinates.yz;
vertex.position.x = xCoordinates.y;
vertex.texCoord0 = float2(1f, 0f);
streams.SetVertex(vi + 1, vertex);
//vertex.position.xz = coordinates.xw;
vertex.position.x = xCoordinates.x;
vertex.position.z = zCoordinates.y;
vertex.texCoord0 = float2(0f, 1f);
streams.SetVertex(vi + 2, vertex);
//vertex.position.xz = coordinates.yw;
vertex.position.x = xCoordinates.y;
vertex.texCoord0 = 1f;
streams.SetVertex(vi + 3, vertex);