接口简介
介绍完基本方案和原理,也许你会发现很多东西都是概念上的和思路上的体现,那么我们再来过一下实际工程中用到的接口和参数,也许能有更加直观的感受。接口参数主要分为5个部分:系统基本参数配置,模型资源的输入,密度信息的输入,高度信息的输入,以及一些其他附加项。
首先是 基本参数`
var param = new RuntimeRenderParam()
{
mRadius = 100f, //视距半径
mThreshold = 5f, //摄像机移动多少距离会触发刷新视距内草海缓存
mTexWindWave = "wind_Wave", //风噪纹理
mTexNoise = "noise", //全局噪声贴图
mGrassAssetPath = new string[] { "RawGrassMeshFileName" }, //草海中草的资源路径
mGrassDensity = new int[] { 25 }, //草对应的密度信息
mStrategy = BuildVegeStrategy.GeneratedLOD, //如何处理草资源
};
//下面创建了一个用于渲染草海的Render实例
mVegeRender = new RuntimeRender(probe, Camera.main, densMgr, heightMgr, assetProvider, param, 0);
可以看到为了创建Render实例,我们在构造时传了 RuntimeRenderParam
结构体用于包装复数个入参,详细可以参考代码中的注释,其中在模拟风动时我使用了一张全局风噪贴图用于消除正弦函数的“人造”感;此外还用了一张全局噪声纹理来营造植被间位置和朝向的微小差异。后面3条和草的Mesh有关,简单说这几个参数决定了当前Render能渲染出的草海基本模型长什么样,最高密度是多少,多株模型间如何合并成一个密度单元。处理自定义的构造体外,我们还传入了我们设想中的视场中心点对象 probe 用于判断视场何时向何处移动;同时也需要把摄像机传入其中以便采集到其管理的一些参数(如变换矩阵);最后是三个陌生的对象:densMgr、heightMgr 和 assetProvider,其实很好理解,它们分别代表了之前提到的另外3个接口部分:密度信息的输入,高度信息的输入 和 模型资源的输入。
再来看看 密度信息接口`
这部分有2层嵌套,底层是面向原始数据的存取和Chunk级别的索引:
public interface IDensityDB where T : IComparable
{
//已知索引和偏移,找到并构建出QuadTreeDenisty结构体
QuadTreeDensity GetQuadTreeById(int aId, Vector2 aOffset);
//同上,但是返回的不是四叉树,而是二维数组对象 Array2DDensity
Array2DDensity GetArray2DById(int aId, Vector2 aOffset);
//编辑时使用,从普通纹理中解码出密度,再编码为四叉树 QuadTreeDensity
QuadTreeDensity GetQuadTreeFromTex(Texture2D aTex, Vector2 aOffset, int aId);
}
正如前文所述,密度图可以简单的表示为一张二维数组,也可以编码成四叉树格式。如果用树,其原始数据可以是已经序列化好的C#对象,也可以直接从一张密度纹理上构建起来,分布对于不同的接口。
原始数据层之上则是之前我们提到的9宫格地块管理器,负责提供更加精细的搜索和修改密度服务:
public class GlobalDensityMgr where T : IComparable
{
//对一块由Min和Max圈定的世界范围内的空间进行搜索,返回其上所有关联的密度信息
public void SearchRange(Vector2 aMin, Vector2 aMax, ref T[] aD, ref Vector2[] aP){...}
//将一小块被修改的密度信息回写到原始数据结构的对应位置中
public void InsertRange(Vector2 aMin, Vector2 aMax, T[] aDensityArr){...}
}
其中类型变量 T
表示的是密度,由于四叉树构建的需要,T需要是能够互相比较的类型,一般项目使用int型即可,但若有特殊需求,也可对T进行拓展。而InsertRange方法存在的意义是为了方便玩家与草海的交互。比如塞尔达-旷野之息中的烧草和割草都需要实施的修改地面密度信息,而我们的多层数据结构又决定了必须将这些修改信息及时同步到底层的数据区块上,以免玩家在切出切入游戏场景后,原来对草地造成的变动因为缓存的更替而消失。
还有是 高度信息接口`
与密度信息类似,高度也是分为两个部分管理:底层数据接口(通过接口定义),以及上层对高度信息的管理器。
public interface IHeightDB
{
//通过地块上的锚点(索引点)获取对应地块的高度图
UnityEngine.Object GetHeightmapByPosition(Vector2Int aPos, Type aType);
//获取高度图的Size信息
Vector3 GetSizeOfTerrain(Vector2Int aPos);
}
管理器的内容比较复杂,本身不提供接口,只是维护好一张可以在shader中随时取用的环绕在角色周围的高度图。这里有必要补充一点细节:我们使用密度单位在尺寸上是1平方米,它上面的分布着的每一株草在GPU顶点渲染过程中都需要知道确切的世界空间高度。这个高度值显然同合并后的草模型没啥关系,没法附带到模型顶点数据中,而且也不方便通过 Instance buffer 带入(数据量太大),所以合理的路径是采样高度图。如果所有的草都在一个独立地块上,那么只需要传入这个地块对应的高度纹理即可,但是如果待渲染的草分布在周围4个拼接地块上,就需要想办法分批渲染或者合并高度图了。
当然有的项目地形系统可能会用到Clipmap或者Virtual Texture等技术,那么就能很方便的在GPU内虚拟一张世界范围内的超大高度纹理,会大大简化我们的工作量。
下面还有的是 模型资源输入接口`
public enum BuildVegeStrategy
{
DefinedLOD = 0, //由美术合并模型资源
GeneratedLOD = 1, //由程序随机合并美术单株资源
}
public interface IVegeCommonAssets
{
//加载指定路径下的模型草资源
Object LoadVegeMeshWithPathSync(string aPath);
//程序合并一套模型草资源到一个密度单位上,使用给的的LOD级别
Object BuildVegeMesh(string[] aPrototypePath, int[] aNumberPerSquare, BuildVegeStrategy aStrategy, int aLOD = 0);
}
这里所谓和合并草资源也是仁者见仁,理论上效果最好的应该是美术脑海中的那个,但是很多时候需要大量的“多样性”,那么设计一套合理的方式去随机合并多株不同模型的草也是一种不赖的选择。
说来草的Mesh合并过程中也是有不少值得推敲的细节,这里主要是说如何安排好每个顶点上的额外数据:我们知道一般Mesh除了 vertices
和 normals
外还会附带几组 uvs
,为了能正确在GPU中展开这些被合并了的单株草Mesh,必须合理利用这多出的几组额外uv纹理。
Vector3[] vertices = new Vector3[verticesSize]; //origin vertex
int[] subMesh = new int[subMeshSize]; //origin mesh index(triangles)
Vector3[] normals = new Vector3[normalsSize]; //will use [0,1,0]
Vector2[] uv1s = new Vector2[uv1sSize]; //origin uv1
Vector2[] uv2s = new Vector2[uv2sSize]; //position:x,z
Vector2[] uv3s = new Vector2[uv3sSize]; //position:lod + density
其实我这边处理得比较简单,只使用了额外的uv2和uv3用来存放单株草在1平米见方的空间中的相对便宜
最后则是一些 其他附加项`
可以想象,为了实现丰富的草海交互功能,我们还需要提供诸如
- 角色压草
- 草随风动,风向可调
- 烧草
- 割草
等等基本需求,这些实现起来并不困难,大多是在shader中通过代码控制草Mesh的变化或顶点色的lerp,有时间我会和大家详细聊聊。