一.世界地图
将整个世界切分成多个Tile,每个Tile大小相同,用二维坐标形式索引起来,如图:
(-2,2)
| (-1,2) | (0,2) | (1,2) | (2,2) |
(-2,1)
| (-1,1) | (0,1) | (1,1) | (2,1) |
(-2,0)
| (-1,0) | (0,0) | (1,0) | (2,0) |
(-2,-1)
| (-1,-1) | (0,-1) | (1,-1) | (2,-1) |
(-2,-2)
| (-1,-2) | (0,-2) | (1,-2) | (2,-2) |
中心点(0,0)位置的Tile为世界地图的中心点,例如坐标可以定位为(0,0,0),由于Tile大小相等,世界坐标与Tile就可以进行转换,可以根据玩家所在的坐标快速的定位Tile。例如,当tile长度为12000时,玩家坐标为(13000,13000) 那么可以知道玩家在索引为(1,1)的Tile上。
二.Tile实现
从顶点组织和材质两个主要方面做说明
顶点组织
Tile可以设置用多少个顶点来表达真实世界的多大单位,如用513*513个顶点来渲染出游戏世界的12000*12000面积的地表。
由于要支持LOD,既根据相机到地表的距离,提交渲染的三角形要相应的减少。我们采用顶点数目不变,通过提交不同的索引来达到减少三角形的目的。
现在的问题是,如果规划这些索引来满足这个需求。用四叉树进行分割是比较好的方式。
由于是基于Ogre实现,就可以把顶点交由Rendable对象管理,这样材质的处理和到相机的距离判断都很方便。四叉树从根节点开始每一个节点都是一个Readable对象。树的根结点用最粗糙的索引,越向下越精细。这样当玩家站在这个大Tile上时,通过相机去查看有哪些节点可以被玩家看到,然后再根据距离和节点的高度(delta值)计算lod来决定是当前节点被渲染还是它的子节点应该被渲染。被渲染的节点被加入到渲染队列,渲染时回调Rendable对象的getRenderOption函数来要求提供顶点和索引,这时节点可以在这里提供自己的索引和顶点。
注意顶点的组织有一些特殊。以下以2049*2049个顶点来表示一块Tile为例做说明
LOD 0: 2049 vertices, 32 x 65 vertex tiles (tree depth 5) vdata 0-15 [129x16]
LOD 1: 1025 vertices, 32 x 33 vertex tiles (tree depth 5) vdata 0-15 [129x16]
LOD 2: 513 vertices, 16 x 33 vertex tiles (tree depth 4) vdata 0-15 [129x16]
LOD 3: 257 vertices, 8 x 33 vertex tiles (tree depth 3) vdata 16-17 [129x2]
LOD 4: 129 vertices, 4 x 33 vertex tiles (tree depth 2) vdata 16-17 [129x2]
LOD 5: 65 vertices, 2 x 33 vertex tiles (tree depth 1) vdata 16-17 [129x2]
LOD 6: 33 vertices, 1 x 33 vertex tiles (tree depth 0) vdata 18 [33]
LOD 0 为最清晰的级别定点数最多,根结点(tree depth 0)为做粗糙的级别,顶点数最少
树的级别还有LOD的计算公式见Terrain::determineLodLevels()函数。
LOD0 行的信息表明这个LOD级别有2049*2049个顶点,节点数有32*32个,每个节点有65*65个顶点。
LOD1行的信息表明这个LOD级别是与LOD0位于同一个树深度上,只不过是以不同的索引来确定顶点数
LOD2行的信息表明这个LOD级别位于深度为4的树节点上,节点数为16*16,每个节点的顶点数为33*33
以上3个LOD级别可以共用一套顶点,位于深度为4的树节点上,每个节点上顶点数为129*129
LOD3行的信息表明这个LOD级别位于深度为3的树节点上,节点数为8*8,每个节点上的顶点数为33*33
LOD4行的信息表明这个LOD级别位于深度为2的树节点上,节点数为4*4,每个节点上的顶点数为33*33
LOD5行的信息表明这个LOD级别位于深度为1的树节点上,节点数为2*2,每个节点上的顶点数为33*33
以上三个LOD级别共用一套顶点,挂在树的深度为1的节点上
LOD6行的信息表明这个LOD级别位于树的根结点上,节点数为1,节点上顶点数为33*33
解读一下以上绿色部分让人疑惑的内容
l 为什么出现的各种数据都是2N+1?
很简单,因为整个地表都是根据四叉树分割的规则的格子,例如5*5个顶点才可以表示4*4个格子
l 为什么实际节点块的大小是129?
因为索引数据是用的16位整数,最大寻址能到129*129,下一个257*257超出了范围
l 为什么叶子节点有2个LOD级别?
计算公式(Math::Log2(mMaxBatchSize - 1.0f) - Math::Log2(mMinBatchSize - 1.0f) + 1.0f); 上例中的最大批次为65,最小批次为33。所以计算出叶子节点上的lod级别为2,最大级别65是最精细的lod,33是次级,这样做是为了配合地表高度(delta)和相机距离来计算出更多样的lod层级。
l 怎么知道哪些LOD层级/树深度可以共用一套顶点?
顶点块都是129*129的,如上例所示16*16那一深度的129*129个顶点可够它自己和下一个深度的32*32的节点使用。2*2那一个树深度的129*129个顶点可供4*4和8*8的节点使用。具体的计算公式见Terrain::distributeVertexData()
l 为什么总的顶点数要多于2049*2049?
2049^2 = 4198401 vertices
最后使用的顶点数:(16 * 129)^2 + (2 * 129)^2 + 33^2 = 4327749 vertices
因为不是简单的平面切分顶点,而是用四叉树做分割,根据总的顶点数和最大批次最小批次等参数来决定数的深度以及顶点如何共用,所以要有2049只是作为一个基数来参考,最后用的129*129的块数才决定最终的顶点数。
具体的计算方法见Terrain::distributeVertexData()
材质
由于顶点组织方式的原因,就不能在一块地表上刷贴图的方式来处理材质。Ogre中采用的是一个Tile上可以放置多层地表贴图,然后进行混合。这样的话,一个tile上可以设置N层地表贴图(3-4层)来混合出想要的地表。
l 先说说贴图是如何铺到地表上的
一般会采用256*256像素的贴图,当然也可以小点或者大点,每一层的贴图大小可以不同,这张贴图可以铺真实世界的多大也可以设置。举例来说,Tile的边长为2049,代表真实世界的12000个单位,为了简单说明,假设地表贴图有3层,每张的贴图大小都256*256,一张贴图可以铺真实世界的10个单位。上例中顶点的组织是分层次的,我们拿最粗糙的lod来举例,顶点数为33*33个顶点。那么UV坐标是这样进行安排的,顶点(1,1)处的UV坐标为(0,0) 右下角的33*33处的UV坐标为(1,1),既假设这33*33个顶点使用的是一张虚拟的大贴图铺满。这样shader在执行的时候就可以根据顶点上的坐标来定位地表贴图上的坐标了,uv是存在转换公式的 最终的uv = 12000 *顶点上uv % 10 / 10 这样就将地表平铺到了33*33个顶点代表的12000个单位的大地表上。
l 高度图的实现
高度图是size*size大小的数组,和一般的高度图做法是相似的,只是上边各级顶点的初始化和编辑时要按照比例来决定顶点数据的高度。
l 混合贴图的实现,
混合贴图是一个可以设置大小的float数值,例如设置1024*1024大小来存放tile的各层的混合数据。实现的时候一张贴图可以搞定5层地表贴图的情况,如float的第一个字节存放第二层的混合值,第二个字节存放第三层地表的混合值,第三个自己存放第四层地表的混合值,第四个字节存放第五层地表的混合值。Shader中进行颜色混合时就根据顶点的uv坐标来到混合贴图中读取数据(uv*12000 / 1024)
l 法线处理
顶点中未存放发现数据,法线是由高度通过公式计算之后存放在一张贴图数据中,然后供像素shader读取,定位方式也是通过顶点的uv坐标
l 光照贴图
光照贴图也是通过计算公式存成一张贴图,可以指定大小,通过调节大小来生成比较贴近正确的光照贴图,最后也是由像素shader通过uv坐标来定位计算
l 法线贴图与视差贴图
这个属于地表的视觉效果增强,注意这个法线贴图和上边的不是一个概念,上边的法线是用来计算光照的,这里的法线贴图和视差贴图都是美术事先做好的,是地表256*256主贴图的辅助数据,比如地表贴图是地砖,想做出高低起伏时的光照效果,那么就配上这些数据做为地表每层的第二张贴图提供给像素shader进行计算
l 顶点和像素shader代码
void main_vp(
float4 pos : POSITION,
float2 uv : TEXCOORD0,
float2 delta : TEXCOORD1,
uniform float4x4 worldMatrix,
uniform float4x4 viewProjMatrix,
uniform float2 lodMorph,
uniform float4 uvMul0,
out float4 oPos : POSITION,
out float4 oPosObj : TEXCOORD0
, out float4 oUVMisc : TEXCOORD1 // xy = uv, z = camDepth
, out float4 oUV0 : TEXCOORD2
, out float4 oUV1 : TEXCOORD3
, uniform float4 fogParams
, out float fogVal : COLOR
)
{
float4 worldPos = mul(worldMatrix, pos);
oPosObj = pos;
float toMorph = -min(0, sign(delta.y - lodMorph.y));
worldPos.y += delta.x * toMorph * lodMorph.x;
oUV0.xy = uv.xy * uvMul0.r;
oUV0.zw = uv.xy * uvMul0.g;
oUV1.xy = uv.xy * uvMul0.b;
oUV1.zw = uv.xy * uvMul0.a;
oPos = mul(viewProjMatrix, worldPos);
oUVMisc.xy = uv.xy;
fogVal = saturate((oPos.z - fogParams.y) * fogParams.w);
}
float4 expand(float4 v)
{
return v * 2 - 1;
}
float4 main_fp(
float4 position : TEXCOORD0,
float4 uvMisc : TEXCOORD1,
float4 layerUV0 : TEXCOORD2,
float4 layerUV1 : TEXCOORD3,
uniform float3 fogColour,
float fogVal : COLOR,
uniform float3 ambient,
uniform float4 lightPosObjSpace,
uniform float3 lightDiffuseColour,
uniform float3 lightSpecularColour,
uniform float3 eyePosObjSpace,
uniform float4 scaleBiasSpecular,
uniform sampler2D globalNormal : register(s0)
, uniform sampler2D lightMap : register(s1)
, uniform sampler2D blendTex0 : register(s2)
, uniform sampler2D difftex0 : register(s3)
, uniform sampler2D normtex0 : register(s4)
, uniform sampler2D difftex1 : register(s5)
, uniform sampler2D normtex1 : register(s6)
, uniform sampler2D difftex2 : register(s7)
, uniform sampler2D normtex2 : register(s8)
) : COLOR
{
float4 outputCol;
float shadow = 1.0;
float2 uv = uvMisc.xy;
outputCol = float4(0,0,0,1);
float3 normal = expand(tex2D(globalNormal, uv)).rgb;
float3 lightDir =
lightPosObjSpace.xyz - (position.xyz * lightPosObjSpace.w);
float3 eyeDir = eyePosObjSpace - position.xyz;
float3 diffuse = float3(0,0,0);
float specular = 0;
float4 blendTexVal0 = tex2D(blendTex0, uv);
float3 tangent = float3(1, 0, 0);
float3 binormal = normalize(cross(tangent, normal));
tangent = normalize(cross(normal, binormal));
float3x3 TBN = float3x3(tangent, binormal, normal);
float4 litRes, litResLayer;
float3 TSlightDir, TSeyeDir, TShalfAngle, TSnormal;
float displacement;
TSlightDir = normalize(mul(TBN, lightDir));
TSeyeDir = normalize(mul(TBN, eyeDir));
float2 uv0 = layerUV0.xy;
displacement = tex2D(normtex0, uv0).a
* scaleBiasSpecular.x + scaleBiasSpecular.y;
uv0 += TSeyeDir.xy * displacement;
TSnormal = expand(tex2D(normtex0, uv0)).rgb;
TShalfAngle = normalize(TSlightDir + TSeyeDir);
litResLayer = lit(dot(TSlightDir, TSnormal), dot(TShalfAngle, TSnormal), scaleBiasSpecular.z);
litRes = litResLayer;
float4 diffuseSpecTex0 = tex2D(difftex0, uv0);
diffuse = diffuseSpecTex0.rgb;
specular = diffuseSpecTex0.a;
float2 uv1 = layerUV0.zw;
displacement = tex2D(normtex1, uv1).a
* scaleBiasSpecular.x + scaleBiasSpecular.y;
uv1 += TSeyeDir.xy * displacement;
TSnormal = expand(tex2D(normtex1, uv1)).rgb;
TShalfAngle = normalize(TSlightDir + TSeyeDir);
litResLayer = lit(dot(TSlightDir, TSnormal), dot(TShalfAngle, TSnormal), scaleBiasSpecular.z);
litRes = lerp(litRes, litResLayer, blendTexVal0.r);
float4 diffuseSpecTex1 = tex2D(difftex1, uv1);
diffuse = lerp(diffuse, diffuseSpecTex1.rgb, blendTexVal0.r);
specular = lerp(specular, diffuseSpecTex1.a, blendTexVal0.r);
float2 uv2 = layerUV1.xy;
displacement = tex2D(normtex2, uv2).a
* scaleBiasSpecular.x + scaleBiasSpecular.y;
uv2 += TSeyeDir.xy * displacement;
TSnormal = expand(tex2D(normtex2, uv2)).rgb;
TShalfAngle = normalize(TSlightDir + TSeyeDir);
litResLayer = lit(dot(TSlightDir, TSnormal), dot(TShalfAngle, TSnormal), scaleBiasSpecular.z);
litRes = lerp(litRes, litResLayer, blendTexVal0.g);
float4 diffuseSpecTex2 = tex2D(difftex2, uv2);
diffuse = lerp(diffuse, diffuseSpecTex2.rgb, blendTexVal0.g);
specular = lerp(specular, diffuseSpecTex2.a, blendTexVal0.g);
shadow = tex2D(lightMap, uv).r;
outputCol.rgb += ambient * diffuse + litRes.y * lightDiffuseColour * diffuse * shadow;
outputCol.rgb += litRes.z * lightSpecularColour * specular * shadow;
outputCol.rgb = lerp(outputCol.rgb, fogColour, fogVal);
return outputCol;
}
顶点shader中主要根据传进来的贴图矩阵计算好每层的贴图坐标(这个数据只能有顶点shader传给像素shader),之后像素shader就根据当前的uv和每层的uv来计算混合,根据发现计算光照,再计算法线贴图和视差贴图效果,最后输出正确的颜色。
l 材质LOD
材质是有lod的,高LOD就是上述需要shader计算出的。低LOD的处理是这样的,建立一个plane对象,把高LOD材质挂到上边,用正交方式拍一张动态贴图存起来。当相机与四叉树的节点对象距离大于4000的时候(这个数字可以设置,就是ogre里材质lod参数),就直接使用这个动态贴图了,顶点上的uv坐标正好可以对到这上边来,设计的很神奇。
三.源代码介绍
1. OgreTerrain(.h .cpp)
这个是地表类的封装(代表一个Tile),这里实现了地表文件的存储和读取逻辑,地表顶点组织的计算公式,以及一些Tile相关的辅助功能
2. OgreTerrainGroup (.h .cpp)
用世界地图部分介绍的方式将Tile管理起来
3. OgreTerrainQuadTreeNode (.h .cpp)
一个OgreTerrain(Tile)关联一个OgreTerrainQuadTreeNode对象(根结点)
这个类里实现四叉树节点管理,节点上的顶点和索引分配,每个节点对象里有一个Rendable内部对象,当此节点被渲染时用来提供这上边的顶点和索引数据,另外包含Movable和SceneNode对象用来把节点放到场景中。最重要的计算lod的代码也实现在这个类的calculateCurrentLod函数里
4. OgreTerrainLayerBlendMap (.h .cpp)
这个类是对材质中描述的混合贴图的实现
5. OgreTerrainMaterialGenerator和OgreTerrainMaterialGeneratorA (.h .cpp)
这两个类是继承关系,用来根据贴图层数等参数来生成模型要使用材质(Technique, Pass, shader代码)
其中OgreTerrainMaterialGeneratorA类中的addTechnique函数比较重要,这里展示了Meterial的组成以及shader如何使用。