最近希望在unity中实现一个简单的geometry clipmap,用于飞行模拟游戏中巨大地形的渲染。但是网上冲浪之后发现资料有点少,只好一点点从头做起。那么就用本文记录一些个人比较迷惑的地方,作为我的备忘录和经验分享吧。

目前没有完全实现,但是看了下效果确实有点差,不加其他功能的话基本用不了。虽然没有做过性能测试,但是感觉很多为了效率做的细节其实没必要完全照着论文去做,有些在unity也不好实现。最近看到了这个视频:06.游戏中地形大气和云的渲染(上) | GAMES104-现代游戏引擎:从入门到实践_哔哩哔哩_bilibili,发现geometry clipmap其实最有指导意义的就是:

  • 分层LOD+相机坐标系渲染(我最开始想解决的问题)
  • 资源流式加载(原始论文其实没怎么提,不过从贴图的循环更新ToroidalOrigin能看出来)

看完视频暂时不想搞这个闭门造车了,查查virtual texture去,这样就不用自己做流式加载啦:)。

参考资料:GPU Gems 2, geometry clipmap 原始论文

算法整体框架

从论文中可以清晰得知,算法需要三个步骤:计算active region(我们希望渲染的地形范围);更新geometry clipmap(多级地形纹理/贴图);裁剪渲染范围至贴图范围,并进行渲染。

代码解读

Upsample(上采样)

算法通过对较粗糙的高度图纹理进行插值来获取分辨率更高(一级)的高度图。

不过在gpu gems提供的示例代码中,为了实现不同类型坐标的插值计算,这个算法写的比较绕,它的pixel shader是这样的:

float4 UpsamplePS(float2 p_uv : TEXCOORD0) : COLOR
{
    float residual = tex2D(ResidualSampler, p_uv*OneOverSize);  
    
    p_uv = floor(p_uv);
    float2 p_uv_div2 = p_uv/2;
    float2 lookup_tij = p_uv_div2+1; 
    float4 maskType = tex2D(LookupSampler, lookup_tij);     
          
    matrix maskMatrix[4];
    maskMatrix[0] = matrix(0, 0, 0, 0,
                           0, -1.0f/16.0f, 0, 0,
                           0, 0, 0, 0,
                           1.0f/256.0f, -9.0f/256.0f, -9.0f/256.0f, 1.0f/256.0f);
                           
    maskMatrix[1] = matrix(0, 1, 0, 0,
                           0, 9.0f/16.0f, 0, 0,
                           -1.0f/16.0f, 9.0f/16.0f, 9.0f/16.0f, -1.0f/16.0f,
                           -9.0f/256.0f, 81.0f/256.0f, 81.0f/256.0f, -9.0f/256.0f);                        
                           
    maskMatrix[2] = matrix(0, 0, 0, 0,
                           0, 9.0f/16.0f, 0, 0,
                           0, 0, 0, 0,
                           -9.0f/256.0f, 81.0f/256.0f, 81.0f/256.0f, -9.0f/256.0f);
                           
    maskMatrix[3] = matrix(0, 0, 0, 0,
                           0, -1.0f/16.0f, 0, 0,
                           0, 0, 0, 0,
                           1.0f/256.0f, -9.0f/256.0f, -9.0f/256.0f, 1.0f/256.0f);

    float2 offset = float2(dot(maskType.bgra, float4(1, 1.5, 1, 1.5)), dot(maskType.bgra, float4(1, 1, 1.5, 1.5)));
    
    float z_predicted=0;
    offset = (p_uv_div2-offset+0.5)*OneOverSize+TextureOffset;
    for(int i = 0; i < 4; i++) {
        float zrowv[4];
        for (int j = 0; j < 4; j++) {
                float2 vij    = offset+float2(i,j)*OneOverSize;
                zrowv[j]      = tex2D(CoarseLevelElevationSampler, vij);
        }
        
        vector mask = mul(maskType.bgra, maskMatrix[i]);
        vector zrow = vector(zrowv[0], zrowv[1], zrowv[2], zrowv[3]);
        zrow = floor(zrow);
        z_predicted = z_predicted+dot(zrow, mask);
    }

    
    z_predicted = floor(z_predicted);
    
    // add the residual to get the actual elevation
    float zf = z_predicted + residual;  
    
    // zf should always be an integer, since it gets packed
    //  into the integer component of the floating-point texture
    zf = floor(zf);
    
    float4 uvc = floor(float4((p_uv_div2+float2(0.5f,0)), 
                              (p_uv_div2+float2(0,0.5f))))*OneOverSize+TextureOffset.xyxy; 
            
    // look up the z_predicted value in the coarser levels  
    float zc0 = floor(tex2D(CoarseLevelElevationSampler, float4(uvc.xy, 0, 1)));
    float zc1 = floor(tex2D(CoarseLevelElevationSampler, float4(uvc.zw, 0, 1)));        
    
    float zf_zd = zf + ((zc0+zc1)/2-zf+256)/512;

    return float4(zf_zd, 0, 0, 0);
}

这个LookupSampler(texture)和maskMatrix比较魔法,在此分析一下。

我们在上采样时要从粗糙lv0的高度图(方框)插值计算出更精确一级lv1的高度图(圆圈),它们之间的关系如图所示。为了方便起见,假设我们的uv坐标是一个单位对应一像素(注意在lv0中坐标细分为0.5),那么lv1在lv0中的坐标有4种类型:

  • 和上一级重合:(0,0)
  • 在两个像素中央:(0,0.5),(0.5,0)
  • 在四个像素中央:(0.5,0.5)

那么通过权重为(-1/16, 9/16, 9/16, -1/16)的四点法插值

这四种类型的坐标的计算方式分别需要1、4、4、16个点的数据,如下图不同颜色的方框所示。

比较直观的插值公式如右侧的矩阵所示,直接对最大范围的16点数据与权重(方便起见省去了分母 /256)进行点乘即可。但是因为gpu不便处理分支语句,gpu gems里使用循环+额外的一张2*2控制纹理来实现算法,这些权重矩阵则被拆散进了maskMatrix中(如左侧所示)。比如右侧矩阵1的第一行在左侧矩阵4的第一行(这里为了和图片对应,行坐标颠倒了),而其第二行则在左侧矩阵3的第一行,第三行在左侧矩阵2的第一行......依次类推。控制纹理中则简单地记录了4个one-hot向量:

后来我发现这里好像搞反了行坐标和列坐标,不过大概意思大家能看懂就行了

  • (1,0,0,0):对应坐标类型(0,0)
  • (0,1,0,0):对应坐标类型(0,0.5)
  • (0,0,1,0):对应坐标类型(0.5, 0)
  • (0,0,0,1):对应坐标类型(0.5,0.5)

另外两个shader,ComputeNormal(法线计算)和 Render(渲染)相对比较简单,没有什么需要专门分析的地方,看gpu gems基本就行了。

分级网格

原始论文中对网格的的实现搞了很多花活,其实我不太懂,大部分情况应该都是为了节省顶点的开销,尽量复用子结构。比较重要的点应该在于网格并不是正好一级级“嵌套”起来的,层级之间存在一小片可以调整的区域,这样内层网格可以随着地图加载中心点的变化进行一点点平移,而外层则不用移动。需要注意为了防止插值造成的波浪状效果,mesh网格是“吸附”到地图坐标网格上的,也就是说纹理贴图只能进行一格格的平移,正好也对应了这个调整范围。

我在测试的时候直接奢侈一把,网格全部老老实实画出来完事,不做顶点复用。这里输出一下边缘过渡区的alpha值来展示一下分级的网格结构:

流式加载

咕了

一些改动

Occlusion(遮挡)

gpu gems提供的代码仅用法线计算光照强度(Lambertian reflectance),已经可以获得非常明显的明暗效果,让地形有了立体感;但是在起伏的山丘地形上,明显能发现怪怪的,是因为没有临近地形的遮挡效果。考虑单个平行光源(太阳)的情况,在每一个位置,我们可以根据光线方向作一条射线判断是否与地形相交来确定该位置是否会被遮挡,计算出额外的一张遮挡贴图,在最终的render里与输出相乘,类似这样就可以了:

OUTPUT OcclusionVS(appdata v)
{
    OUTPUT output;
    output.position   = float4(float2(v.vertex.x,v.vertex.y),0.0,1.0);
    output.position   = UnityObjectToClipPos(v.vertex);
    output.texcoords  = v.uv*Size;
    
    return output;
}

float4 OcclusionPS(OUTPUT input) : SV_Target
{
    float2 p_uv_div2 = floor(input.texcoords)/2;

    float2 uv = (p_uv_div2 + 0.5)*OneOverSize + TextureOffset;
    float height = coarseHeight.Sample(linear_repeat_sampler, uv).x;

    float occlusion = 1;
    for(int i = 1; i < 30; i++) {
        float dis = i / 2.0;
        float2 uv = (p_uv_div2 + dis * LightDirection + 0.5)*OneOverSize + TextureOffset;
        float h = coarseHeight.Sample(linear_repeat_sampler, uv).x;
        occlusion += clamp(sign(height + dis * ScaleFac - h), -1, 0) * 0.2;
    }
    return float4(clamp(occlusion, 0, 1), 0, 0, 0);
}

调试中遇到的问题

因为基本没怎么写过shader,中间碰到了一些问题解决了好久。一个碰到好多次的问题就是,纹理中会出现一些横竖的条纹,这个原因99%就是采样的问题。第一次是因为没有关闭纹理的mipmap,而我的贴图又是一个长方形的尺寸,导致短边上被开启了降采样,出现了好多暗纹。第二次是因为纹理使用了“点采样”,也就是不进行插值,但是在计算uv坐标的时候使用了a+b的形式,其中b是能刚好落在贴图网格上的,但是a忘了做好取整的操作,导致某些地方的采样点重合了,出现了横竖的条纹。