大体框架

最近看了Sebastian Lague的Coding Adventure-Solar System系列,视频的代码仓库地址:https://github.com/SebLague/Solar-System

我对于其中代码的架构很感兴趣,尤其是大气渲染的部分,于是下载下来研究了一番。

以下是使用mermaid制作的架构流程图,整体大概是一个上级 List<Generator> 管理了每一个天体的 Generator ,然后每一个天体都带有两个Settings,一个是 Shape Setting、另一个是 Shading Setting。前者用来生成天体的随机形状,后者用来根据前者生成的形状进行着色。

Shading Setting

Shading Setting 还拥有几个子 Setting (本质上是 ScriptableObject),里面就包含 oceanSetting 和 atmosphereSetting 。

这个 ScriptableObject 还持有一个 Compute Shader 对象,它生成的 shadingData 后面会传给 mesh 的 UV,然后发现这个 UV 本质上是四个维度的噪声,用于 Shape Setting 的形状生成。

大气渲染

这是我最关心的部分,因为经常看到各种复杂的数学公式,还有各种简化,使得我不知道如何构建一个函数——它的输入是什么,输出又是什么,参数太多,无从下手。

Sebastian Lague 的做法是,每一个大气渲染的 Shader 实际上是在做后处理,也就是在屏幕空间上做的渲染,先从屏幕的 uv 转换到实际的视野射线,然后再使用这个射线做下面的一系列操作。

预计算 Optical Depth

首先,我们需要 “optical depth” 光学深度。这部分的计算量很大,所以需要使用一个 2D Texture 来预先计算好从所有高度和角度发出的射线的 “optical depth” 。大致是,从大气的顶端开始(相对高度为1处),然后遍历每一个角度(0-180°),再把这个高度和不同的角度以某种一对一的方式映射到 uv 的 xy坐标。之后把高度降低,再遍历每一个角度,如此往复就可以得到预计算的 Texture 。注意为什么可以是 0-180°这个一维的角度范围(0-180指的是射线 垂直射入地面 到 水平射出天体的角度),而无需再带有一个由“旋转”造成的另外一个方向的角度,这是因为我们把天体的大气当做球体来处理,球体具有旋转对称性,所以不用考虑旋转造成的影响了。

在预计算的过程中,高度、角度 到 UV 的 XY 坐标的映射是这样发生的:
float y = -2 * uv.x + 1;
float x = sin(acos(y));
float dir = float2(x,y);

下面是 Desmos 中画出的 x、y 之间的关系:

image-vzgm.png

由 float y = -2 * uv.x + 1 知 y ∈ (-1,1) ,将 y 视为横坐标,x 视为纵坐标,即可发现 dir 的方向是(0-180°)

预计算得到的 Texture :

渲染大气

struct appdata {
       float4 vertex : POSITION;
       float4 uv : TEXCOORD0;
};

struct v2f {
       float4 pos : SV_POSITION;
       float2 uv : TEXCOORD0;
       float3 viewVector : TEXCOORD1;
};

v2f vert (appdata v) {
       v2f output;
       output.pos = UnityObjectToClipPos(v.vertex);
       output.uv = v.uv;
       // Camera space matches OpenGL convention where cam forward is -z. In unity forward is positive z.
       // (https://docs.unity3d.com/ScriptReference/Camera-cameraToWorldMatrix.html)
       float3 viewVector = mul(unity_CameraInvProjection, float4(v.uv.xy * 2 - 1, 0, -1));
       output.viewVector = mul(unity_CameraToWorld, float4(viewVector,0));
       return output;
}

这里的 viewVector 就是从 UV 屏幕空间逆变换得到的视线向量。

float3 rayOrigin = _WorldSpaceCameraPos;
float3 rayDir = normalize(i.viewVector);

float dstToOcean = raySphere(planetCentre, oceanRadius, rayOrigin, rayDir);

获取 dstToOcean ,也就是视线在海洋中穿越的长度。

float sceneDepthNonLinear = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
float sceneDepth = LinearEyeDepth(sceneDepthNonLinear) * length(i.viewVector);
float dstToSurface = min(sceneDepth, dstToOcean);

这里得到视线到天体表面的距离,至于为什么 海洋 和 地面 分开来算了,是因为海洋的效果实际上也是一个后处理,并没有实际的 Mesh。

float2 hitInfo = raySphere(planetCentre, atmosphereRadius, rayOrigin, rayDir);
float dstToAtmosphere = hitInfo.x;
float dstThroughAtmosphere = min(hitInfo.y, dstToSurface - dstToAtmosphere);

获取hitInfo,hitInfo.x 是视线起点到大气的距离,hitInfo.y 是视线穿越大气的长度。

if (dstThroughAtmosphere > 0) {
    const float epsilon = 0.0001;
    float3 pointInAtmosphere = rayOrigin + rayDir * (dstToAtmosphere + epsilon);  //视线进入大气的位置
    float3 light = calculateLight(pointInAtmosphere, rayDir, dstThroughAtmosphere - epsilon * 2, originalCol, i.uv);
    return float4(light, 1);
}

以下是 calculateLight 的视线细节,我没有仔细看:

float3 calculateLight(float3 rayOrigin, float3 rayDir, float rayLength, float3 originalCol, float2 uv) {
    float blueNoise = tex2Dlod(_BlueNoise, float4(squareUV(uv) * ditherScale,0,0));
    blueNoise = (blueNoise - 0.5) * ditherStrength;
    
    float3 inScatterPoint = rayOrigin;
    float stepSize = rayLength / (numInScatteringPoints - 1);
    float3 inScatteredLight = 0;
    float viewRayOpticalDepth = 0;

    for (int i = 0; i < numInScatteringPoints; i ++) {
       float sunRayLength = raySphere(planetCentre, atmosphereRadius, inScatterPoint, dirToSun).y;
       float sunRayOpticalDepth = opticalDepthBaked(inScatterPoint + dirToSun * ditherStrength, dirToSun);
       float localDensity = densityAtPoint(inScatterPoint);
       viewRayOpticalDepth = opticalDepthBaked2(rayOrigin, rayDir, stepSize * i);
       float3 transmittance = exp(-(sunRayOpticalDepth + viewRayOpticalDepth) * scatteringCoefficients);
       
       inScatteredLight += localDensity * transmittance;
       inScatterPoint += rayDir * stepSize;
    }
    inScatteredLight *= scatteringCoefficients * intensity * stepSize / planetRadius;
    inScatteredLight += blueNoise * 0.01;

    // Attenuate brightness of original col (i.e light reflected from planet surfaces)
    // This is a hacky mess, TODO: figure out a proper way to do this
    const float brightnessAdaptionStrength = 0.15;
    const float reflectedLightOutScatterStrength = 3;
    float brightnessAdaption = dot (inScatteredLight,1) * brightnessAdaptionStrength;
    float brightnessSum = viewRayOpticalDepth * intensity * reflectedLightOutScatterStrength + brightnessAdaption;
    float reflectedLightStrength = exp(-brightnessSum);
    float hdrStrength = saturate(dot(originalCol,1)/3-1);
    reflectedLightStrength = lerp(reflectedLightStrength, 1, hdrStrength);
    float3 reflectedLight = originalCol * reflectedLightStrength;

    float3 finalCol = reflectedLight + inScatteredLight;

    
    return finalCol;
}

下面这个函数就用到了之前预计算的光学深度:

float opticalDepthBaked(float3 rayOrigin, float3 rayDir) {
    float height = length(rayOrigin - planetCentre) - planetRadius;
    float height01 = saturate(height / (atmosphereRadius - planetRadius));

    float uvX = 1 - (dot(normalize(rayOrigin - planetCentre), rayDir) * .5 + .5);
    return tex2Dlod(_BakedOpticalDepth, float4(uvX, height01,0,0));
}

height01 是把入射高度映射到 0-1 之间的值,在预计算贴图中对应 Y 值。

float uvX = 1 - (dot(normalize(rayOrigin - planetCentre), rayDir) * .5 + .5);这一行我分解一下,normalize(rayOrigin - planetCentre)就是视线起点到天体中心的方向,然后与 rayDir 点乘,就是一个 0-180° 的方向,模长上就是 -1 ~ 1,然后乘以 0.5 再加上 0.5,映射到 0 ~ 1,最后再用 1 减去得到的数值,映射到 uvX ,至于为什么要用 1 减去最终数值,我也没有细想。

总结(Powered by GPT4o)

该代码结构清晰地将各个模块功能独立化,通过 Shape SettingShading Setting 管理天体的形状生成和着色。大气渲染部分的核心思路是基于屏幕空间的后处理。视线从屏幕空间反算为世界坐标后,通过光学深度(Optical Depth)的预计算贴图和多次采样进行大气散射和光照计算。

在具体实现中,代码通过预先计算的 2D 贴图加速光学深度计算,避免了实时计算中庞大的运算量。同时,基于大气球体的对称性,通过射线方向与入射点高度映射到 UV 空间,大幅简化了渲染流程。视线在大气中经过多个采样点,每次采样都会计算局部光密度、光学深度、以及光线的传输系数(Transmittance)。最终,视线的入射光、散射光、以及行进过程中衰减的表面反射光被组合,形成最终的大气颜色。

值得注意的是,这一方案不仅提升了渲染效率,还以合理的数学模型还原了大气散射和光学深度的变化。Sebastian Lague 的代码也提供了很多优化策略,例如利用蓝噪声(Blue Noise)和贴图抖动(Dithering)来减少采样伪影。

Life is a Rainmeter