最近工作比较忙,都好久没写文章了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
}
...... //剩余光照计算
本文地址: 基于高度场生成地形阴影/Generate Terrain ShadowMap Based on Heightfield