Skip to content

光影之协奏

背景:环境预设切换时,天空变了,但后处理管线(曝光/色调映射)还在用上一曲的乐谱。 过程applyEnvPreset 同步调用 setRenderState 推送曝光/色调映射,后处理与环境预设形成完整"光场"。


环境系统把天穹立了起来,物理系统让裙摆能飞,多相机让视角能舞——但 Riku 总觉得,联邦还缺一层东西。

他站在屏幕前,看着同一个场景反复切换:正午、夕阳、夜景、阴天。天空颜色在变,太阳位置在变,灯光方向也在变。但不知为何,有些时刻看起来,有些时刻看起来

正午的天空明明很亮,画面却发灰。夜景的星空已经点亮,地面却亮得像下午。夕阳的橙红铺满了天穹,可模型的边缘没有那层金边辉光。

Riku 把问题抛给 Jieling。

"光是对的,影是错的;天是对的,地是错的。"

这不是某一个城邦的 bug。这是两个城邦——环境系统与后处理管线——没有统一指挥。


史官的第一份奏报:两个独奏者

在 Babylon 的世界里,后处理是一串挂在 DefaultRenderingPipeline 上的效果:泛光、景深、暗角、抗锯齿。它们各有各的参数,但需要一个统一的指挥——RenderState

另一边,环境系统 EnvState 里有天空、太阳、地面、水、粒子。ENV_PRESETS 预设表已经把天空色、太阳角、光照强度、曝光和色调映射都打包好了。

问题就出在这里:applyEnvPreset 最初只调用了 setLightState() 来更新灯光,把天空色推导成方向光和半球光,却没有去碰管线里的曝光和色调映射

于是黄昏的天空配上了正午的曝光,夜景的暗角没有跟上,正午的泛光阈值却按夜景来算。

光与影各弹各的,联邦听起来像一个没合过排的乐团。


追查:指挥棒在哪

Riku 打开 scene.ts 里初始化管线的代码:

typescript
export const pipeline = new DefaultRenderingPipeline("default", true, scene, [scene.activeCamera!]);
pipeline.samples = 1;
pipeline.fxaaEnabled = false;
pipeline.bloomEnabled = false;
pipeline.imageProcessingEnabled = true;

管线的默认状态很保守:泛光关、FXAA 关、图像处理开。它就像一支把乐器都收在盒子里的乐团,等待 RenderState 来逐个发令。

setRenderState 是"人类手动调节"的接口。用户点开菜单,拖动曝光滑块,打开暗角开关——这些操作都被正确地反映到管线上。然而,当用户从"正午"切到"夜景"时,环境系统却忘了告诉管线:曝光该降了,色调映射该从 ACES 切到 Neutral 了。

Riku 在 env-lighting.ts 里找到 ENV_PRESETS

typescript
noon: {
    label: "正午",
    skyColorTop: [0.53, 0.71, 0.91],
    skyColorBot: [0.3, 0.5, 0.8],
    sunAngle: 75,
    exposure: 1.0,
    toneMapping: 1,  // ACES
    ...deriveLighting([0.53, 0.71, 0.91], 75),
},
night: {
    label: "夜景",
    skyColorTop: [0.05, 0.05, 0.15],
    skyColorBot: [0.02, 0.02, 0.08],
    sunAngle: -15,
    exposure: 0.4,
    toneMapping: 4,  // Neutral
    ...deriveLighting([0.05, 0.05, 0.15], -15),
},

曝光和色调映射明明就坐在每个预设里,却没人去念它们。城邦又犯了老毛病:每个城邦都自带完整配置,但联邦只读了一半。


根因:半截子指挥

applyEnvPreset 的核心流程是"先断联、再设置、最后恢复":

typescript
export function applyEnvPreset(name: string): boolean {
    const preset = ENV_PRESETS[name];
    if (!preset) return false;

    const wasLinked = envAutoLink;
    envAutoLink = false; // 防止自动推导互相覆盖

    envSunAngle = preset.sunAngle;
    setLightState({ /* ... */ });

    // 后处理部分——原本缺失!

    envAutoLink = wasLinked;
    return true;
}

setLightState 修改了方向光和半球光,却没动 imageProcessing.exposureimageProcessing.toneMappingType。于是环境系统拼完了天空,后处理乐队却还拿着上一曲的乐谱。

更隐蔽的是水面。WaterMaterial 的反射依赖环境纹理,但程序天空是一个自绘渐变球体——WaterMaterial 看不到它,只能看到普通网格,水面反射出的是"世界本身"而不是天空。为了让水面有波纹,Riku 为它加了 Perlin 噪声凹凸纹理。但没有正确的曝光和色调映射,水再真,也只是"一面亮度错误的镜子"。


修复:完整的指挥

修复并不复杂:让 applyEnvPreset 在设置完光照之后,也调用 setRenderState 把预设的曝光和色调映射推送进管线。

typescript
setLightState({
    dirColor: preset.dirDiffuse,
    dirX: preset.dirDirection[0],
    dirY: preset.dirDirection[1],
    dirZ: preset.dirDirection[2],
    dirIntensity: preset.dirIntensity,
    hemiIntensity: preset.hemiIntensity,
});
setRenderState({
    exposure: preset.exposure,
    toneMapping: preset.toneMapping,
});

这两行把后处理乐队拉进了环境 preset 的指挥下。正午用 ACES 的高动态、夜景用 Neutral 的柔和收敛、夕阳用 Reinhard 来压住那团橙红——每个天空都有了自己的影调。

同步地,太阳盘光晕也做了调整:太阳越高光晕越收敛,越低越铺张。天空与后处理共同完成一个完整的"光场"。


验证

Riku 逐个切换预设,验证光影是否同步:

预设天空后处理结果
正午明亮蓝ACES,高曝光高光不发白 ✅
夕阳橙红低垂Reinhard,中曝光金边辉光正确 ✅
夜景深蓝星空Neutral,低曝光暗部层次分明 ✅

水面反射亮度与天空一致,不再像"发光的水银"。暗角与夜景曝光形成完整氛围。

ENV_PRESETS 的断言从 4 个同步到 7 个,223 个测试全部通过。


尾注

修复完成后,Jieling 在场景菜单里看到了一个新的层级:「后处理」。它下面有泛光、抗锯齿、边缘高亮、景深、暗角。每一个都连接着 RenderState 的一个字段,而 RenderState 又通过 applyEnvPreset 与环境预设绑定。

她终于不用手动调三个面板来换一个天空了。选「正午」,就是正午的完整光场;选「夜景」,就是夜景的完整影调。

Riku 在提交信息里写:

光影之协奏:让环境预设同时指挥后处理管线。

窗外是黄昏。屏幕上的夕阳也是。两个黄昏,一个在外面,一个在 DefaultRenderingPipeline 里。


教训:光与影要一起指挥,否则各弹各的。