背景
前不久天刀手游上线,虽说有些失望,但是抱着同行之间宜夸不宜喷的态度,其中对超大世界的RVT支持,还是非常值得深挖的。virtualtexture的概念提出好多年了,最初是SVT,但是资源规模的限制实在是太大了,应用场景不是很多。直到UE最近两个版本对RVT的支持,导致这个概念又被提出,特别是UE4.5对于移动平台支持RVT的优化。相信可能天刀手游也多多少少参考了一下对于这块的支持,特别是有个对性能和效果的大致评判。
由于Unity现在只支持了SVT,并且只在高清渲染管线支持。众所周知,Unity不是游戏引擎,是一个图形API工具(手动狗头),轮子该造还得造。本文介绍在Unity里实现完整的可在移动平台使用的超大地形RVT解决方案。
原理virtualtexture的原理比较简单,基础就是纹理映射和mipmap。各大博客网站上也有好多人写了他的原理。一图理解VT的原理:
我这里不作过多的赘述了。
大概的过程就是FeedBack取出当前所需的各位置下的mipmap等级贴图,然后填入pagetable这张表,pagetable取出信息加载对应等级贴图(RVT实时烘焙,SVT从预处理贴图提取),然后烘焙到TileTexture上。加载完后会去更新lookup贴图,把当前显示的信息存入到里边供VT渲染地形使用。在渲染地形时,通过uv去lookup贴图找出当前格子在TileTexture上的格子坐标,以及uv偏移,取出TileTexture上的Diffuse,法线,Mask等参与光照计算。
下面我一步一步从前往后梳理整个过程,以及在Unity的实现技术细节。
格子我们会根据可视范围把范围内的所有地块划分成格子,格子越小,分到固定分辨率大小的贴图清晰度就越高,当然性能也就越差。UE是固定格子,直接是以地形为单位划分的。但是对于一个超大世界来讲,这个范围是变化的,所以格子也是动态变化的。这里只交待一个概念,后面在各个部分的时候再详细讲。
FeedBackfeedback有点类似于遮挡剔除,会预先烘一个低分辨率的贴图信息,rgb分别表示格子坐标,mipmap等级。格子坐标通过世界坐标在可视区域,以及格子的大小这些参数就能算出来。mipmap等级则是通过偏导求出来的。
feed_vfVTVertFeedback(feed_attrv){feed_vfo;UNITY_SETUP_INSTANCE_ID(v);#ifdefined(UNITY_INSTANCING_ENABLED)floatpatchVertex=v.vertex.xy;float4instanceData=UNITY_ACCESS_INSTANCED_PROP(Terrain,_TerrainPatchInstanceData);floatsampleCoords=(patchVertex.xy+instanceData.xy)*instanceData.z;//(xy+float(xBase,yBase))*skipScalefloatheight=UnpackHeightmap(_TerrainHeightmapTexture.Load(int3(sampleCoords,0)));v.vertex.xz=sampleCoords*_TerrainHeightmapScale.xz;v.vertex.y=height*_TerrainHeightmapScale.y;v.texcoord=sampleCoords*_TerrainHeightmapRecipSize.zw;#endifVertexPositionInputsAttributes=GetVertexPositionInputs(v.vertex.xyz);o.pos=Attributes.positionCS;floatposWS=Attributes.positionWS.xz;o.uv=(posWS-_VTRealRect.xy)/_VTRealRect.zw;returno;}float4VTFragFeedback(feed_vfi):SV_Target{ floatpage=floor(i.uv*_VTFeedbackParam.x); floatuv=i.uv*_VTFeedbackParam.y; floatdx=ddx(uv); floatdy=ddy(uv); intmip=clamp(int(0.5*log(max(dot(dx,dx),dot(dy,dy)))+0.5+_VTFeedbackParam.w),0,_VTFeedbackParam.z); returnfloat4(page/55.0,mip/55.0,1);}
在unityurp配置渲染管线,只需要添加一个renderdata,配置好参数
然后在地形里边添加一个feedback的pass
然后在feedback相机上选择这个render
feedback的相机要保证跟场景相机参数一样,可以写个脚本复制参数,然后把这个相机挂在场景相机,transform归零就ok了。这里我把camera的enable去掉了,我自己来调用render,因为这里可以自己控制更新频率。
这里渲染的时候分辨率降低一半,如果觉得还不够可以再降一些,这里为啥不一开始就用低分辨率呢,是因为,先以一个差不多的分辨率精度比较高,再降是取最大的mipmap等级,这样会保证正确性。
float4GetMaxFeedback(floatuv,intcount){ float4col=float4(1,1,1,1); for(inty=0;ycount;y++) { for(intx=0;xcount;x++) { float4col1=texD(_MainTex,uv+float(_MainTex_TexelSize.x*x,_MainTex_TexelSize.y*y)); col=lerp(col,col1,step(col1.b,col.b)); } } returncol;}
这里之所以对分辨率要求这么高,是因为我们需要把贴图数据从rendertarget里取出到cpu来读取数据。
//发起异步回读请求m_ReadbackRequest=AsyncGPUReadback.Request(texture);
这是一个异步请求,我们可能有一个延迟,也就是说一般是这一帧使用的是上一帧的feedback。
PageTable页表是一个mipmap层级结构的表,比如说我们的格子是一个56x56的矩阵,那么mipmap为0的等级就是56x56的cell,mipmap为1的就是18x18的cell,依次类推。cell的上面除了存放mipmap等级,占据的rect,还有page的加载情况。
publicclassTableNodeCell{publicRectIntRect{get;set;}publicPagePayloadPayload{get;set;}publicintMipLevel{get;}publicTableNodeCell(intx,inty,intwidth,intheight,intmip){Rect=newRectInt(x,y,width,height);MipLevel=mip;Payload=newPagePayload();}}
这里要注意的是我们这的pagetable是个可变化的,针对移动是调整后面单独拎出来说。
上面feedback处理完得到一张feedback的贴图,上面各个像素表示需要显示的cell以及mipmap等级。我们可以通过这个信息,去触发加载TileTexture。
///summary///激活页表////summaryprivateTableNodeCellActivatePage(intx,inty,intmip){if(mipMaxMipLevel
mip0
x0
y0
x=TableSize
y=TableSize)returnnull;//找到当前页表varpage=m_PageTable[mip].Get(x,y);if(page==null){returnnull;}if(!page.Payload.IsReady){LoadPage(x,y,page);//向上找到最近的父节点while(mipMaxMipLevel!page.Payload.IsReady){mip++;page=m_PageTable[mip].Get(x,y);}}if(page.Payload.IsReady){//激活对应的平铺贴图块m_TileTexture.SetActive(page.Payload.TileIndex);page.Payload.ActiveFrame=Time.frameCount;returnpage;}returnnull;}///summary///加载页表////summaryprivatevoidLoadPage(intx,inty,TableNodeCellnode){if(node==null)return;//正在加载中,不需要重复请求if(node.Payload.LoadRequest!=null)return;//新建加载请求node.Payload.LoadRequest=m_RenderTextureJob.Request(x,y,node.MipLevel);}TileTexture
tileTexture会设置tile的size,每个size的分辨率大小,以及padding像素。
在这里我们有一个渲染队列,由于同一帧可能请求比较多,我们可以从mipmap等级高到底排序,然后控制每帧渲染数量来进行。
publicvoidUpdate(){if(m_PendingRequests.Count=0)return;//优先处理mipmap等级高的请求m_PendingRequests.Sort((x,y)={returnx.MipLevel.CompareTo(y.MipLevel);});intcount=m_Limit;while(count0m_PendingRequests.Count0){count--;//将第一个请求从等待队列移到运行队列varreq=m_PendingRequests[m_PendingRequests.Count-1];m_PendingRequests.RemoveAt(m_PendingRequests.Count-1);//开始渲染StartRenderJob?.Invoke(req);}}
在处理一个具体的tile时,由于我们需要烘焙diffuse,normal,Mask(暂时没处理),我们这里使用的MRT。
VTRTs=newRenderTexture[];VTRTs[0]=newRenderTexture(RegionSize.x*TileSizeWithPadding,RegionSize.y*TileSizeWithPadding,0);VTRTs[0].useMipMap=false;VTRTs[0].wrapMode=TextureWrapMode.Clamp;Shader.SetGlobalTexture("_VTDiffuse",VTRTs[0]);VTRTs[1]=newRenderTexture(RegionSize.x*TileSizeWithPadding,RegionSize.y*TileSizeWithPadding,0);VTRTs[1].useMipMap=false;VTRTs[1].wrapMode=TextureWrapMode.Clamp;Shader.SetGlobalTexture("_VTNormal",VTRTs[1]);
然后就转化到要把page上的某个rect,画到TileTexture的某个tile上了,这里还得考虑padding这些,涉及到非常繁琐的tileoffset的计算,这里花了我不少时间。除了渲染地形,以后如果有些需要贴到地形上的贴花也在这个过程中进行。具体的计算代码我就不贴了,感兴趣的可以下载我的demo看看。
drawtexture的shader也就是常规的blend混合,这里要注意的是,对于地形超出四层的情况,第一个四层正常渲染,第二个四层以及后面的贴花可以采用BlendOneOne的形式。
PixelOutputfrag(vf_drawTexi):SV_Target{float4blend=texD(_Blend,i.uv*_BlendTile.xy+_BlendTile.zw);#ifdefTERRAIN_SPLAT_ADDPASSclip(blend.x+blend.y+blend.z+blend.w=0.h?-1.0h:1.0h);#endiffloattransUv=i.uv*_TileOffset1.xy+_TileOffset1.zw;float4Diffuse1=texD(_Diffuse1,transUv);float4Normal1=texD(_Normal1,transUv);transUv=i.uv*_TileOffset.xy+_TileOffset.zw;float4Diffuse=texD(_Diffuse,transUv);float4Normal=texD(_Normal,transUv);transUv=i.uv*_TileOffset3.xy+_TileOffset3.zw;float4Diffuse3=texD(_Diffuse3,transUv);float4Normal3=texD(_Normal3,transUv);transUv=i.uv*_TileOffset4.xy+_TileOffset4.zw;float4Diffuse4=texD(_Diffuse4,transUv);float4Normal4=texD(_Normal4,transUv);PixelOutputo;o.col0=blend.r*Diffuse1+blend.g*Diffuse+blend.b*Diffuse3+blend.a*Diffuse4;o.col1=blend.r*Normal1+blend.g*Normal+blend.b*Normal3+blend.a*Normal4;returno;}
画完后就要通知pagetable我加载完毕,并且更新cell上的加载信息。
对于TileTexture的Tile,由于需要重复利用,可以使用LRU算法。这里本来我是抄了网上的一个LRU算反使用linkedList实现的,但是gc特别高,效率也比较低,因为他的删除是O(n)的,我重写了一个O(1)插入和删除的LRU算法,这里我也就不贴代码了,感兴趣的可以看看demo。
LookUp在pagetable每帧更新中,会将TileTexture各个Tile激活下的数据写入lookup贴图,这个lookup的尺寸跟pagetable的cell格子数一样大小,rgb分别表示,cell坐标,mipmap等级。
这里要注意几个地方,第一,Active的page只需取存在feedback上的activepage,而不需取整个TileTexture上的。第二,active的page可能会有重合的地方,这里以mipmap等级低的覆盖等级高的。由于有这两点,我起初是直接在CPU创建一张TextureD,往里填数据,后来测试发现这样会非常慢,特别是TextureD的Apply。后来参考UE的代码采用GPUInstance直接画上去,这里由于需要使用mipmap等级低的覆盖高的,可以使用画家算法,按mipmap排序。ue还使用了莫顿码,我这里直接使用InstanceData传入进去整个数据。
varmats=newMatrix4x4[drawList.Count];varpageInfos=newVector4[drawList.Count];for(inti=0;idrawList.Count;i++){floatsize=drawList[i].rect.width/TableSize;mats[i]=Matrix4x4.TRS(newVector3(drawList[i].rect.x/TableSize,drawList[i].rect.y/TableSize),Quaternion.identity,newVector3(size,size,size));pageInfos[i]=newVector4(drawList[i].drawPos.x,drawList[i].drawPos.y,drawList[i].mip/55f,0);}Graphics.SetRenderTarget(m_LookupTexture);vartempCB=newCommandBuffer();varblock=newMaterialPropertyBlock();block.SetVectorArray("_PageInfo",pageInfos);block.SetMatrixArray("_ImageMVP",mats);tempCB.DrawMeshInstanced(mQuad,0,drawLookupMat,0,mats,mats.Length,block);Graphics.ExecuteCommandBuffer(tempCB);
shader代码
UNITY_INSTANCING_BUFFER_START(InstanceProp)UNITY_DEFINE_INSTANCED_PROP(float4,_PageInfo)UNITY_DEFINE_INSTANCED_PROP(float4x4,_ImageMVP)UNITY_INSTANCING_BUFFER_END(InstanceProp)Varyingsvert(AttributesIN){ VaryingsOUT; UNITY_SETUP_INSTANCE_ID(IN); float4x4mat=UNITY_MATRIX_M; mat=UNITY_ACCESS_INSTANCED_PROP(InstanceProp,_ImageMVP); floatpos=saturate(mul(mat,IN.positionOS).xy); pos.y=1-pos.y; OUT.positionHCS=float4(.0*pos-1,0.5,1); OUT.color=UNITY_ACCESS_INSTANCED_PROP(InstanceProp,_PageInfo); returnOUT;}half4frag(VaryingsIN):SV_Target{ returnIN.color;}
这里Unity有几个坑逼的地方,首先unity调用drawMeshInstanced传入的那个矩阵他会修改里边的数据,导致我不能直接使用那个数据,因为我这里不是标准的MVP矩阵,他可能帮我转换了,我这里重新弄了一个矩阵instanceData。还有一点就是如果你在shader里不使用UNITY_MATRIX_M这个变量,unity就认为你这个不是drawInstance。所以我在shader里加了一句无意义的代码float4x4mat=UNITY_MATRIX_M;
DrawVT所有数据准备完毕后,现在就比较简单了。直接使用RVT贴图里的diffuse和Normal,然后参与光照计算就OK了。
half4GetRVTColor(VaryingsIN){floatuv=(IN.positionWS.xz-_VTRealRect.xy)/_VTRealRect.zw;floatuvInt=uv-frac(uv*_VTPageParam.x)*_VTPageParam.y; float4page=texD(_VTLookupTex,uvInt)*55;#ifdef_SHOWRVTMIPMAPreturnfloat4(clamp(1-page.b*0.1,0,1),0,0,1);#endif floatinPageOffset=frac(uv*exp(_VTPageParam.z-page.b));uv=(page.rg*(_VTTileParam.y+_VTTileParam.x*)+inPageOffset*_VTTileParam.y+_VTTileParam.x)/_VTTileParam.zw;half3albedo=texD(_VTDiffuse,uv);half3normalTS=UnpackNormalScale(texD(_VTNormal,uv),1);InputDatainputData;InitializeInputData(IN,normalTS,inputData);halfmetallic=0;halfsmoothness=0.1;halfocclusion=1;halfalpha=1;half4color=UniversalFragmentPBR(inputData,albedo,metallic,/*specular*/half3(0.0h,0.0h,0.0h),smoothness,occlusion,/*emission*/half3(0,0,0),alpha);SplatmapFinalColor(color,inputData.fogCoord);returnhalf4(color.rgb,1.0h);}动态更新
以上基本就能把地形通过rvt画出来了,但是这样有个基本限制,那就是我们移动的时候,如果是超大世界,那么这个pagetable对应的位置就有限。ue的解决办法是每个地形一套rvt,这样会造成,在地形边界可能出现四套rvt,这基本不能接受。天刀采用的是动态更新pagetable,也就是说pagetable对应的cell表示的范围会动态变化。
在changerect的时候,我们需要最大限度地复用TileTexture,不然需要重绘所有的TileTexture,这样会造成比较大的卡顿。这里对于changrect的rect的规范就比较有讲究了,我们需要fixed这个rect,让他尽可能地多复用pagetable。我们这是通过设置更新距离为整个可视距离的四分之一或者八分之一这种,然后通过这个四分之一去fixed可视范围center。
如下图,pagetable向右下角更新
mipmap为能复用这些
对于mipmap等级再高的就没法复用了。
这里对于pagetable的更新,我们可以采用clipmap的方式,存放一个offset,取数据的时候只需要叠加上这个offset即可。
动态更新的这一帧,一定要强行feedback一次,然后至少保证mipmap等级最高的那一级TileTexture加载完毕,并且更新lookup,不然会导致画面不对。当然这整个过程是有代价的,所以会造成些许卡顿。
后记
这个demo初步完成,RVT的厉害之处不在于能突破4层纹理的限制,而在于很多贴花的东西能直接贴在地形上,这样就能实现很多其他优化需求,后续可能会围绕RVT出一些实用功能。RVT在现阶段也有一些限制。比如:机型可能需要支持MRT,CPU回读RTAsyncGPUReadback.Request。在效果方面,地形精度会降低很多,并且由于加载的延迟,有时会出现模糊慢慢变清晰的情况。
源码:
百度网盘: