· 5 分钟阅读 · 2024 字

两段半透明雾叠成全遮挡:体积积分需要累计透射率

一段雾遮掉背景的 50%,后面再放一段同样的雾,背景还剩多少?若把两段 Alpha 相加再钳制,答案是零。沿视线实际执行两次衰减,背景应剩下 0.5 × 0.5 = 0.25,总遮挡比例为 75%。加法让两段有限厚度的雾提前变成完全不透明,也让同一片空气因划分格子数量不同而改变外观。

红蓝雾层沿视线依次衰减的版画概念示意,不代表实验画面

体积雾的分段积分需要保存两种量:当前区间朝相机贡献的累计颜色 C,以及穿过该区间后剩下的透射率 T。只保存一个“雾量”,无法同时回答背景还能通过多少、后段光照又被前段削弱多少。这个接口会直接影响体素切片、分块计算与最终场景合成之间交付什么数据。

雾色先在区间内积成颜色

令消光系数为 σ,单位是每米;区间沿射线的长度为 ℓ,单位是米。均匀区间的光学厚度为 τ=σℓ,透射率为 T=exp(-τ)。若区间内的源颜色 S 保持不变,则:

C = ∫[0,ℓ] σ S exp(-σs) ds = S (1-T)
Lout = C + T Lbackground

S 是这段简化介质模型的平衡源颜色,源项按 j=σS 定义;它可以承接已经算好的入射光照与散射近似,但本式没有求解多重散射。σ 为零时,C 也为零,因此这个接口不表达“零消光但仍独立发光”的介质。所有颜色处于同一线性光照域,HDR 值允许超过 1,不能在相加之前分别做色调映射。

固定输入把每段长度设为 1 米,σ 设为 ln(2),于是 T 恰为 0.5。前段取红色 S=(1,0,0),后段取蓝色 S=(0,0,1),分别得到 C前=(0.5,0,0) 与 C后=(0,0,0.5)。这里的 C 已经包含区间积分,不应在最终混合时再乘一次自身 Alpha。

后段贡献必须穿过前段

从相机向远处排列两个互不重叠的区间,先让后段输出经过前段:

Lout = C前 + T前 (C后 + T后 Lbackground)
C组合 = C前 + T前 C后
T组合 = T前 T后

红雾在前时,累计颜色为 (0.5,0,0.25);白背景再贡献 (0.25,0.25,0.25),最终为 (0.75,0.25,0.5)。交换红蓝两段后,透射率仍为 0.25,颜色却变成 (0.5,0.25,0.75)。因此,仅证明总雾量一致,并不能证明渲染顺序正确。

固定红蓝雾段的颜色与透射率组合,以及交换前后顺序的输出

研究目录 .tmp/daily-labs/2026-11-03-1930/ 的 FogSegment.cs 将这个关系作为完整的 C# 数值参考内核。Uniform 建立一个区间,FrontToBack 用于合并由调用方保证前后相邻、互不重叠且顺序正确的区间结果,Resolve 最后接入背景:

public readonly record struct Rgb(double R, double G, double B)
{
    public static Rgb operator +(Rgb a, Rgb b) =>
        new(a.R + b.R, a.G + b.G, a.B + b.B);
    public static Rgb operator *(Rgb a, double s) =>
        new(a.R * s, a.G * s, a.B * s);
    public bool Valid => double.IsFinite(R) && double.IsFinite(G) &&
        double.IsFinite(B) && R >= 0 && G >= 0 && B >= 0;
}

public readonly record struct FogSegment(Rgb C, double T)
{
    public static FogSegment Uniform(Rgb source, double extinction, double length)
    {
        if (!source.Valid || !double.IsFinite(extinction) || extinction < 0 ||
            !double.IsFinite(length) || length < 0)
            throw new ArgumentException("Invalid medium");
        double tau = extinction * length;
        // 微小光学厚度用级数避免 1-exp(-tau) 的消减误差。
        double alpha = tau < 1e-5
            ? tau * (1 - tau * (0.5 - tau / 6))
            : 1 - Math.Exp(-tau);
        return new(source * alpha, Math.Exp(-tau));
    }

    public static FogSegment FrontToBack(FogSegment front, FogSegment back)
    {
        Validate(front); Validate(back);
        var result = new FogSegment(front.C + back.C * front.T, front.T * back.T);
        Validate(result);
        return result;
    }

    public Rgb Resolve(Rgb background)
    {
        Validate(this);
        if (!background.Valid) throw new ArgumentException("Invalid background");
        var result = C + background * T;
        if (!result.Valid) throw new ArgumentException("Radiance overflow");
        return result;
    }

    private static void Validate(FogSegment s)
    {
        if (!s.C.Valid || !double.IsFinite(s.T) || s.T < 0 || s.T > 1)
            throw new ArgumentException("Invalid segment");
    }
}

极薄雾段还有一个数值陷阱:double 下 exp(-1e-18) 会舍入到 1,直接相减就丢掉颜色贡献。实现对微小 τ 使用三阶展开;固定回归确认 τ 为 1e-18 时 C 的白色通道仍为 1e-18。同一输入的 T 仍可能舍入到 1,两者只在表示精度内近似互补,不应再用舍入后的 T 强制重算 C。移植到 Shader 的 float 精度时,需要按实际格式重新验证阈值和累积误差。

可以分组计算,不能任意换序

把每段记作 (C,T),上述组合满足结合律。三个区间无论先组合前两段还是后两段,展开后都会得到 C₁ + T₁C₂ + T₁T₂C₃,总透射率都是 T₁T₂T₃。因此渲染管线可以先生成连续深度区间的局部结果,再按原深度顺序合并;区间结果无需包含最终场景颜色。

结合律没有提供交换律。并行归约若只按任务完成顺序拼接,会得到相同 T 和错误 C;区间输出必须携带或关联深度范围,由合并阶段保留顺序。背景也只能在最终 Resolve 进入一次,不能让每个分块各自混入天空,再把已经含背景的结果相加。

相同 S、σ 的均匀空气被拆成多段时,组合还应等于单次积分整段。随机回归把 σ 限制在 [0,3),两段长度各在 [0,5),线性源颜色各通道在 [0,4),验证分段前后 C 与 T 相同。这个性质约束的是恒定参数模型;实际体素中光照、阴影和密度随位置改变,调整采样步长仍会改变积分近似,不能靠组合公式消除采样误差。

两个空间重叠的局部雾体则需要更早处理。不能先把每个完整体积各积一次,再假装它们前后相邻;对同一空间区间,应先把消光系数相加、源项 j 相加,再由 S=j/σ 得到该区间的源颜色。零消光区间仍按接口的真空规则处理。这样才不会把本来同时存在的介质变成可交换前后位置的两层纸。

回归检查颜色,而不只检查 Alpha

2026-11-03 在 .NET 9 Release 下运行以下命令,Program.Main 调用 Check 验证固定输出、真空、零长度、不透明极限与微小光学厚度;Reject 检查负数、非有限输入、非法区间以及累计颜色溢出。另以固定种子 71103 运行 10,000 组结合律和均匀分段回归,四项最大误差分别断言。

cd "$WORK_DIR"
dotnet build .tmp/daily-labs/2026-11-03-1930/FogLab.csproj -c Release --no-restore
dotnet run --project .tmp/daily-labs/2026-11-03-1930/FogLab.csproj -c Release --no-build
build: exit=0, warnings=0, errors=0
run: exit=0, passed=28, failed=0, skipped=0
elapsed_ms=64.164
front_red_back_blue=(0.75,0.25,0.5)
front_blue_back_red=(0.5,0.25,0.75)
combined_T=0.25, correct_alpha=0.75, wrong_added_alpha=1
thin_tau=1e-18, thin_C_white_channel=1e-18
random_count=10000
associative_C_error=8.881784197001252e-16
associative_T_error=1.1102230246251565e-16
split_C_error=8.881784197001252e-16
split_T_error=1.6653345369377348e-16

耗时是本轮数值正确性检查的总时间,没有 GPU 性能含义。当前证据来自独立 C# 研究程序、对应测试和实际运行日志,尚未覆盖引擎的体积纹理采样、半精度存储、曝光和透明表面集成。

体积积分阶段应交付 (C,T),场景合成阶段才交付最终颜色。这个分工允许连续深度块独立求值,并让背景只注入一次;代价是调度器必须保留深度顺序,介质注入必须先解决空间重叠。若仅为消除 Alpha 相加而改成透射率相乘,却仍直接累加各段颜色,背景衰减会正确,局部雾的照明遮挡仍然错误。