最近工作比较忙,都好久没写文章了o(︶︿︶)o 唉
这篇补去年的工作中做的一个小功能:地形烘焙阴影图

一、简介

大部分游戏引擎中,地形作为一个特殊对象,一般基于传统高度图实现
部分引擎可能会实现体素化地形,体素化地形不在我们的讨论范围内
本文所介绍的高度场生成阴影依赖传统高度图,因此不适用于体素化地形

我们实现了一种方案,可以大幅度减少地形参与渲染阴影渲染的开销
对于阴影渲染Pass,地形每次采样高度图计算顶点偏移的GPU消耗不小,尤其是超大地形的情况下
使用我们的方案只有在地形范围内的物体会多一次阴影贴图采样,相较于VS阶段的开销,PS阶段采样和计算可以忽略不计了

优点是在大世界方案中更节省CPU时间,缺点是阴影没有传统阴影贴图的精度高
但是在大世界模式中,这一点精度误差可以通过阴影偏移来保证视觉效果贴近

在我们的方案中,地形高度图默认为R16格式,与Unreal和Unity中地形使用的格式相同,可以快速移植

GPT4生产图真的很好用(逃)

二、原理

本方案实现来源为《荒野大镖客》在SIGGRAPH2019中分享的一篇PPT

在这页PPT中,R星的团队描述了一种静态地形阴影的存储方案:

使用一张R16G16F的浮点格式纹理来保存计算地形阴影所需的最小数据

  • R通道:保存地形射线检测的相对地形的高度
  • G通道:保存地形射线检测到碰撞的长度

阴影烘焙

通过ComputeShader并行计算高度场,我们认为初始点为Pos,偏移后计算的点为TracePos,步进值为Step

  • 对于地形上的每个点,添加初始偏移量后开始迭代
  • 计算TracePos到光源的直线路径上(逆方向光光源方向)是否有任何其他高点阻挡光线
  • 如果路径上有更高的点挡住了光线,该点就处于阴影中,返回True,记录碰撞点,TracePos增加Step
  • 如果路径上没有更高的点挡住了光线,该点就处于阴影外,返回False,Step减少,TracePos减少Step
  • 迭代次数满足条件,迭代结束
  • 记录当前坐标下地形最终的HitPos和RayLength,转换为相对地形水平面的高度储存

Trace的算法可以参考高度场使用光线追踪算法来Trace,也可以自己使用简化的步进算法,我就不展开一一说明了,网上可以搜到很多相关的算法。

(懒得自己画图了,让GPT给我画了一张,看起来还算正确,就是文字都不对,想看代码的直接看最下面吧)

软阴影计算

由于没有通过传统渲染流程渲染地形,因此PCF等计算软阴影的方案在此处走不通,需要通过G通道保存的射线长度来计算地形的软阴影。我们通过在光照计算的时候将方向光视为锥形光来模拟软阴影。

此时我们已知的信息有:

  • 地形的坐标信息 Pos,缩放信息 Scale
  • 当前计算的像素的世界坐标 PosWorld
  • 太阳光的锥角 θ,由此可知三角函数值 cosθ sinθ tanθ

示意图如下:

我们根据上面的信息可以推算出半影区间范围的长度length,与当前点相对碰撞点的高度可以计算出软阴影的比例。示意图中标明了两种情况,分别是物体的点在Trace的点之上(阴影范围外,也需要软阴影计算)和Trace的点之下(阴影范围内,需要计算是全阴影还是在软阴影范围内)

这里我就不推导详细的公式了,需要的可以看下面的代码实现。
(才不是因为我想偷个懒,哼(@ ̄ー ̄@))

(下面是来自GPT4看似高级但是没啥用的示意图,就图个好看了)

至此原理部分就讲完了,接下来直接上代码

三、代码实现

高度场碰撞计算:

// 获取高度图上地形相对高度
float GetHeight(in float2 worldPosition)
{
    float2 position = (worldPosition - TerrainPosition.xz) / TerrainScale.xz;
    float sampleReuslt = HeightMap[uint2(position)];
    return sampleReuslt;
}

// 判定射线是否相交
bool RayTriangleIntersect(float3 v0, float3 v1, float3 v2, float3 orig, float3 dir, out float t_near)
{
    float3 s1 = cross(dir, v2 - v0);
    float3 s2 = cross(orig - v0, v1 - v0);
    const float coeff = 1.0f / dot(s1, v1 - v0);
    const float t = coeff * dot(s2, v2 - v0);
    const float b1 = coeff * dot(s1, orig - v0);
    const float b2 = coeff * dot(s2, dir);
    if (t >= 0 && b1 >= 0 && b2 >= 0 && (1 - b1 - b2) >= 0)
    {
        t_near = t;
        return true;
    }

    return false;
};

// 计算当前坐标以及高度是都有碰撞
bool QueryHitByRayAndHeightmap(in float3 tracePosition, in float3 traceDirection, in float4 terrainSize, out float4 hitPosition)
{
    float3 rayStep = traceDirection;
    float3 rayStartPosition = tracePosition;

    float3 rayLastPosition = rayStartPosition;
    float height = GetHeight(rayLastPosition.xz);

    float2 iterateCountXZ = abs(LightDirection.w) / abs(rayStep.xz);
    float iterateCount = min(iterateCountXZ.x, iterateCountXZ.y);

    while ((rayLastPosition.y - height) > EPSILON)
    {
        rayStartPosition = rayLastPosition;
        rayLastPosition += rayStep * iterateCount;

        if (RectContians(terrainSize, rayLastPosition.xz))
        {
            height = GetHeight(rayLastPosition.xz);
        }
        else
        {
            return false;
        }
    }

    if (all(rayStartPosition == rayLastPosition))
    {
        return false;
    }

    float3 colPos;
    if (rayLastPosition.y <= height)
    {
        float3 index = rayStartPosition;
        rayStep = (rayLastPosition - rayStartPosition) * rcp(LoopIterateCount);

        for (int i = 0; i <= LoopIterateCount; ++i)
        {
            index = rayStartPosition + float(i) * rayStep;
            height = GetHeight(index.xz);

            if (index.y < height)
            {
                colPos = index - rayStep;
                break;
            }
            colPos = index;
        }

        colPos = (colPos + index) * 0.5f;
    }

    // use ColPos calculate triangle
    float2 xzFloor = floor(colPos.xz);
    float2 xzCeil = ceil(colPos.xz);

    float4 xPos = float4(xzFloor.xx + float2(-1.0f, 0.0f), xzCeil.xx + float2(0.0f, 1.0f));
    float4 zPos = float4(xzFloor.yy + float2(-1.0f, 0.0f), xzCeil.yy + float2(0.0f, 1.0f));

    // 生成三角形面所需顶点用于碰撞计算
    float3 arcPos[16];
    [unroll] for (int j = 0; j < 4; j++)
    {
        [unroll] for (int i = 0; i < 4; i++)
        {
            // generate position
            arcPos[j * 4 + i] = float3(xPos[i], GetHeight(float2(xPos[i], zPos[j])), zPos[j]);
        }
    }

    float tFirst = 0;
    float tSecond = 0;
    bool isInFirst = false;
    bool isInSecond = false;

    for (int i = 0; i <= 8; i += 4)
    {
        for (int j = 0; j < 3; ++j)
        {
            int index = i + j;

            isInFirst = RayTriangleIntersect(arcPos[index], arcPos[index + 4], arcPos[index + 5], tracePosition, traceDirection, tFirst);
            isInSecond = RayTriangleIntersect(arcPos[index], arcPos[index + 5], arcPos[index + 1], tracePosition, traceDirection, tSecond);

            if (isInFirst || isInSecond)
            {
                float3 collisionPoint = tracePosition + traceDirection * (isInFirst ? tFirst : tSecond);
                float distance = distance(tracePosition, collisionPoint);
                hitPosition = float4(collisionPoint, distance);
                return true;
            }
        }
    }

    if (!isInFirst && !isInSecond && abs(colPos.x) >= EPSILON && abs(colPos.z) >= EPSILON)
    {
        float distance = distance(tracePosition, colPos);
        hitPosition = float4(colPos, distance);
        return true;
    }

    return false;
}

阴影烘焙迭代:

[numthreads(8, 8, 1)]
void GenerateShadow(uint2 dispatchThreadId : SV_DispatchThreadID)
{
    // because LightDirection.w is below zero
    uint strip = uint(abs(LightDirection.w));
    uint2 imagePosition = ImageStartOffset + dispatchThreadId;
    uint2 imagePositionStrip = (ImageStartOffset + dispatchThreadId) * strip;

    if (any(imagePositionStrip > TerrainPosition.ww))
        return;

    // 过滤部分完全无碰撞的光方向
    if (all(abs(LightDirection.xz) <= EPSILON))
    {
        ShadowMap[imagePosition] = float4(-TerrainScale.w, 0.0f, 0.0f, 0.0f);
        return;
    }

    float2 worldPositionXZ = float2(imagePositionStrip) * TerrainScale.xz;
    float terrainHeight = (UnpackHeight(HeightMap[imagePositionStrip]) - 0.5f) * TerrainScale.w;
    float4 worldPosition = float4(TerrainPosition.xyz, 0.0f) + float4(worldPositionXZ.x, terrainHeight, worldPositionXZ.y, 0.0f);

    float step = TerrainScale.w / 2.0f;
    float4 hitPosition = float4(0.0f, 0.0f, 0.0f, 0.0f);
    float3 traceDirection = normalize(LightDirection.xyz);
    float3 tracePosition = worldPosition.xyz + float3(0.0f, StartOffset, 0.0f); // ensure iterator should execute
    float4 terrainSize = TerrainPosition.xzxz + float4(0, 0, TerrainPosition.w, TerrainPosition.w) * TerrainScale.xzxz;

    for (int i = 0; i < IterateCount; i++)
    {
        float4 hitPositionLocal = float4(0.0f, 0.0f, 0.0f, 0.0f);
        [flatten] if (QueryHitByRayAndHeightmap(tracePosition, traceDirection, terrainSize, hitPositionLocal))
        {
            hitPosition = hitPositionLocal;
            tracePosition.y += step;
        }
        else
        {
            step /= 2.0f;
            tracePosition.y -= step;
        }
    }

    // x -> height reletive to terrain position Y, y -> ray length
    float deltaHeight = tracePosition.y - worldPosition.y - StartOffset;
    float relativeHeight =
        hitPosition.w < EPSILON ? (worldPosition.y - TerrainPosition.y) : (tracePosition.y - TerrainPosition.y - StartOffset);
    ShadowMap[imagePosition] = float4(relativeHeight, hitPosition.w, 0.0f, 0.0f);
}

光照阴影计算

...... // 常规阴影光照计算

// ShadowMapGaeaRegion: xyz -> pos, w -> bias
// ShadowMapGaeaParams: xyz -> scale, w -> heightmap
// ShadowMapGaeaSoftParams: x -> cos, y -> sin, z -> tan
float2 minRegion = ShadowMapGaeaRegion.xz;
float2 maxRegion = ShadowMapGaeaRegion.xz + ShadowMapGaeaParams.ww * ShadowMapGaeaParams.xz;
// 跳过不在地形内的像素
if (all(input.position_world.xz >= minRegion) && all(input.position_world.xz <= maxRegion))
{
	// 计算UV采样阴影图
	float2 deltaPosition = (input.position_world.xz - ShadowMapGaeaRegion.xz) / ShadowMapGaeaParams.xz;
	float2 sampleReuslt = ShadowMapGaea.SampleLevel(ShadowGaeaSampler, deltaPosition / ShadowMapGaeaParams.ww, 0).xy;
	float shadowGaeaBiasHeight = input.position_world.y - ShadowMapGaeaRegion.y + ShadowMapGaeaRegion.w;
	// 点是否在计算相交的高度之上
	bool pointAboveSample = shadowGaeaBiasHeight > sampleReuslt.x;
#if	SHADOW_MAP_ENABLE_SOFT
	// 当前高度和相对高度差
	float softDelta = (shadowGaeaBiasHeight - sampleReuslt.x) / ShadowMapGaeaParams.y;
	float sinAngle = ShadowMapGaeaSoftParams.y;
	float costan = ShadowMapGaeaSoftParams.x * ShadowMapGaeaSoftParams.z;
	// 产生软阴影的高度
	float length = pointAboveSample ? sinAngle + costan : sinAngle - costan;
	// 半影区间长度
	float softLength = (sampleReuslt.y * ShadowMapGaeaSoftParams.z) / length;
	// 计算相对地形高度差和半影长度的比例
	float softRatio = softDelta / max(softLength, 1.0f);
	shadow = min(shadow, clamp(softRatio, -0.5f, 0.5f) + 0.5f);
#else
	float terrainShadow = pointAboveSample ? 0.0f : 1.0f;
	shadow = min(shadow, terrainShadow);
#endif
}

...... //剩余光照计算
说点什么
支持Markdown语法
好耶,沙发还空着ヾ(≧▽≦*)o
Loading...