Skip to content

光的七宗罪

背景:灯光系统有七宗罪——CSM 是纸糊的、轨道角度会失忆、半球光独裁、时间流转遗忘、水面西西弗斯、级联缺席、降级缺失。 过程:CascadedShadowGenerator 替换 + 轨道精度 0.05° + 半球光民主化 + stopTimeOfDay flush + 灯光菜单折叠 + 3D 指示器 + 黄昏平滑曲线 + Motion Blur/Sharpen/GlowLayer 接入。


一、七宗罪

外交官把一份审计报告摔在议会桌上。

"灯光系统有七宗罪。" 他说。

议长翻开报告。第一页写着:

S1. 级联阴影是纸糊的 —— UI 上有「阴影级联」滑块,但底层用的还是普通 ShadowGenerator。用户拖滑块,_shadowCascades 变了,序列化存了,渲染毫无变化。100% 复现。

M1. 轨道角度会失忆 —— Math.round30.4° 变成 30°,多轮往返后角度整型化。

M5. 半球光的独裁 —— _applyEnvStateFacade 硬编码 hemiLight.intensity = 0.5,预设动画设的 hemiIntensity 被覆盖。

N2. 时间流转的遗忘 —— _timeOfDayTick 直接调 _applyEnvStateFacade,跳过持久化。停止时间流转后,sunAngle 漂在内存里,后端读到的是旧值。

S2(已修). 冗余的 setSkipLightAutoSave(false) —— 已在上轮删除。

还有两个新发现:

N1. 水面的西西弗斯 —— setEnvState 每次都调 _applyEnvStateFacade,后者对水面是"按 state 现值 create/dispose"。幸好 createWater 是幂等的,否则预设动画每帧都会销毁+重建水面。

L1. 级联重建的缺席 —— shadowCascades 变化不触发 _ensureShadow,依赖 S1 修复。

议长读完,抬头:"你打算怎么修?"

"从最严重的开始。" 外交官说,"S1。"


二、纸灯笼的真相

"级联阴影," 外交官在白板上画了一个大大的 ShadowGenerator,"它的真名是 CascadedShadowGenerator。但我们在 434 行写的是 new ShadowGenerator(_shadowResolution, dirLight)。"

"为什么不直接用 CSM?" 审判长问。

"因为代码里根本没导入 CascadedShadowGenerator。" 外交官说,"就像你在一个城邦里建了道路,但忘了修桥——路修到河边就断了。"

织工举手:"但 CascadedShadowGenerator 的构造函数第三个参数不是 numCascades,是 usefulFloatFirst。"

所有人都看向她。

"我查了 Babylon.js 的源码," 她说,"构造函数签名是 constructor(mapSize, light, usefulFloatFirst?, camera?, useRedTextureType?)numCascades 是通过 setter 设置的,默认 4。"

外交官点头:"所以正确用法是:先构造,再 gen.numCascades = _shadowCascades。"

他在白板上写下修改方案:

// 旧:纸灯笼
const gen = new ShadowGenerator(_shadowResolution, dirLight);

// 新:真正的级联
const gen = new CascadedShadowGenerator(_shadowResolution, dirLight);
gen.numCascades = _shadowCascades;

"但还有一个问题," 织工补充,"CSM 只支持 FILTER_NONE / FILTER_PCF / FILTER_PCSS。旧代码里 useBlurExponentialShadowMap 对应的是 FILTER_EXPONENTIALSHADOWMAP——这在 CSM 中不支持,会触发 Logger.Error 并回退到 FILTER_NONE。"

"所以," 外交官说,"旧代码里 shadowType !== 'hard' 时设 useBlurExponentialShadowMap = true,在 CSM 下等于什么都没做。"

他在白板上写下正确的 filter 映射:

if (_shadowType === 'pcf') {
    gen.usePercentageCloserFiltering = true;
} else if (_shadowType === 'soft') {
    gen.useContactHardeningShadow = true;
}

审判长问:"UI 滑块的范围呢?"

"CSM 的 numCascades 最小值是 2," 织工说,"但 UI 滑块是 1-4。1 是无效值。需要改为 2-4。"

"好," 议长说,"S1 完结。M1 呢?"


三、小数点的尊严

"M1 很简单," 外交官说,"Math.round30.4° 变成 30°。改成 Math.round(x * 10) / 10,保留一位小数。往返漂移上限从 1° 降到 0.05°。"

他在白板上写下:

// 旧:整型化
orbitAzimuth: Math.round(angle * 180 / Math.PI),

// 新:保留一位小数
orbitAzimuth: Math.round(angle * 180 / Math.PI * 10) / 10,

"三行修改。" 他说,"但意义重大——它保护了用户每一次轨道调整的精度。"


四、半球光的独裁

"M5 是定时炸弹," 外交官说,"_applyEnvStateFacade 第 74 行硬编码 hemiLight.intensity = 0.5。当前因调用顺序侥幸不冲突——setEnvState 先执行,setLightState 后执行,后者覆盖前者。但这是巧合,不是契约。"

"怎么修?" 审判长问。

"去掉硬编码,改为 getLightState().hemiIntensity——从灯光状态中读取当前值。"

// 旧:独裁
hemiLight.intensity = 0.5;

// 新:民主
hemiLight.intensity = getLightState().hemiIntensity;

"但有个微妙之处," 织工说,"在预设动画中,setEnvState 先于 setLightState 执行。_applyEnvStateFacade 中读到的 hemiIntensity 是旧值,然后 setLightState 才设新值。间隔一帧,视觉无感知——但这是时序依赖,不是错误。"

外交官点头:"我们接受这个时序依赖,因为它在所有路径上都一致。"


五、时间流转的遗忘

"N2 是记忆缺陷," 外交官说,"_timeOfDayTick 每帧改 envState.sunAngle,但只调 _applyEnvStateFacade,不调 setEnvState。后者有 500ms 防抖写后端,前者直接跳过。结果:时间流转期间 sunAngle 在内存里漂,后端读到的还是旧值。"

"但 setEnvState 的防抖本来就是优化——每帧写后端太浪费。" 审判长说。

"所以修复不是每帧写," 外交官说,"而是在 stopTimeOfDay 里加一次 flush。"

typescript
export function stopTimeOfDay(): void {
    _timeOfDayActive = false;
    _unregisterTimeOfDay();
    _unregisterTimeOfDay = null;
    // 持久化当前 sunAngle 到后端
    SetEnvState(envState).catch(() => {});
}

"一句话。" 他说,"但补上了记忆的最后一块拼图。"


六、灯光的折叠

"灯光菜单太长了," 议长说,"基础设置、参数、阴影、位置——四张卡片堆在一起,用户要滚很久。"

"折叠。" 外交官说。

他把「参数」卡片改为 addCollapsible,默认收起。把「阴影」卡片改为 addCollapsible + headerToggle,开关在标题栏右侧。把「位置(轨道)」改为 addCollapsible,默认展开。

"还有一个问题," 织工说,"启用开关占了一整行,名称输入框也占了一整行。这两个可以合并——把灯光名称作为折叠面板的标题,启用开关放在标题栏右侧。"

"这样," 外交官在白板上画了一个新的布局:

[💡 聚光灯 1  [toggle]]  ← 标题 + 启用开关
  ├─ 类型 [聚光灯]
  ├─ 强度 [----●----]
  └─ 颜色 [R G B]

[📐 参数  ▾]              ← 折叠
[☁ 阴影  [toggle] ▾]      ← 折叠 + headerToggle
[🎯 位置(轨道)  ▾]      ← 展开
  ├─ 水平角度 [----●----]
  ├─ 仰角 [----●----]
  ├─ 距离 [----●----]
  └─ 🖐 拖拽定位

"漂亮。" 审判长说。


七、白色圆球

"但灯光在场景里没有位置标识," 用户说,"我看不到灯在哪里。"

"加一个白色球体," 外交官说,"启用时在灯光位置创建,禁用/删除时移除。聚光灯额外显示方向线。"

织工实现了 _createIndicator_updateIndicator

typescript
function _createIndicator(): Mesh {
    const mesh = MeshBuilder.CreateSphere('lightIndicator', { diameter: 0.5 }, _scene);
    const mat = new StandardMaterial('lightIndicatorMat', _scene);
    mat.emissiveColor = new Color3(1, 1, 1);
    mat.disableLighting = true;
    mesh.material = mat;
    return mesh;
}

"自发光材质," 她说,"不受光照影响,在任何环境下都可见。"

"但有个问题," 外交官说,"gizmo 的 onDragEndObservable 只在释放鼠标时触发。拖拽过程中球体不跟随。用户会觉得灯光没动。"

"加 onDragObservable。" 织工说。

typescript
_posGizmo.onDragObservable.add(() => {
    if (entry.indicator) {
        entry.indicator.position.copyFrom(entry.light.position);
    }
    if (entry.dirLine) {
        entry.dirLine.position.copyFrom(entry.light.position);
    }
});

"拖拽中实时更新,释放时同步 state。" 她说。


八、太阳的距离

"太阳太近了," 用户说。

外交官查了一下:SUN_DISC_DISTANCE = 400,直径 30。视角约 4.3°——真实太阳是 0.5°。

"改成 1000。" 他说。

视角降到 1.7°,更接近真实太阳,但仍然足够大以便用户看到。


九、黄昏的颜色

"黄昏太暗了," 用户说,"太阳还没落山,场景就已经跟夜晚一样黑了。"

外交官查看 deriveLighting

typescript
const isBelowHorizon = sunAngle <= 0;
const dirIntensity = isBelowHorizon ? 0 : Math.max(L * 1.2, 0.15);

"问题在这里," 他说,"sunAngle <= 0 时方向光直接归零。没有渐变过渡。"

织工修复了亮度曲线:

typescript
// 旧:阶梯式
const isBelowHorizon = sunAngle <= 0;
const dirIntensity = isBelowHorizon ? 0 : Math.max(L * 1.2, 0.15);

// 新:渐变式
const horizonFade = Math.max(0, Math.min(1, (sunAngle + 5) / 10));
const dirIntensity = horizonFade * Math.max(L * 1.2, 0.15);
const hemiIntensity = sunAngle <= 0
    ? Math.min(0.8, 0.3 + L * 0.5)
    : Math.min(1, 0.6 + (1 - dirIntensity) * 0.4);

"0° 到 10° 之间平滑衰减,-5° 以下完全关闭。" 她说,"半球光在夜晚根据天空亮度补偿——不是固定 0.3,而是 0.3 + L * 0.5。"

"但你删了 isBelowHorizon 变量," 审判长发现,"第 55 行还在引用它。"

"哦。" 织工说,用 sunAngle <= 0 替换了所有引用。


十、渲染的进化

灯光修复完毕后,外交官提出了一个新的议题。

"我们的后处理管线有 10 类效果," 他说,"但 Motion Blur、Sharpen、GlowLayer 完全没接入。Babylon.js 的 DefaultRenderingPipeline 本来就支持这些——我们只需要接上。"

"先做什么?" 审判长问。

"Quick Wins。" 外交官说,"Motion Blur 对舞蹈类高频动作有极强的视觉补偿。Sharpen 消除低分辨率模糊感。GlowLayer 让发光物体自动产生辉光。三个功能,改动模式完全一致:加 RenderState 字段、加 _applyRenderState 映射、加 UI 滑块、加性能降级。"

织工在白板上画了架构图:

RenderState 接口
├── 已有: bloom / fxaa / msaa / outline / dof / vignette / ca / grain
├── 新增: motionBlurEnabled / motionBlurAmount
├── 新增: sharpenAmount
└── 新增: glowEnabled / glowIntensity

"Bloom 已经有了," 她说,"Motion Blur 和 Sharpen 直接接入 pipeline。GlowLayer 是独立系统——不在 pipeline 里,需要单独管理。"

"但 GlowLayer 和 Bloom 视觉上非常相似," 审判长警告,"如果两者同时开启且强度较高,可能导致白出。"

"加互斥逻辑," 外交官说,"Bloom weight > 0.5 时,自动降低 GlowLayer intensity。"

typescript
const adjustedGlow = bloomW > 0.5
    ? targetGlow * (1 - (bloomW - 0.5))
    : targetGlow;

"一行联动。" 他说,"但防止了最糟糕的情况。"


十一、降级的阶梯

"性能降级也要同步," 织工说,"L1 关闭 Motion Blur 和 Glow,L2/L3 全部关闭。"

她在 performance.tsLevelConfig 中加入了两个新字段:

typescript
interface LevelConfig {
    // ... 现有字段
    motionBlurEnabled: boolean;
    glowEnabled: boolean;
}

L0(正常)= 两者都开。L1(轻度降级)= 两者都关。L2/L3 = 关。

"预设也要更新," 她补充,"cinematicmotionBlur 0.3realisticmotionBlur 0.2。电影和写实风格天然需要运动模糊。"


十二、收尾

所有修改完成后,外交官做了最后一次构建验证。

✓ built in 2.58s

"七宗罪全部修复," 他说,"CSM 级联阴影激活、轨道精度保护、半球光民主化、时间流转记忆补全、灯光菜单折叠、位置指示器、太阳距离修正、黄昏亮度曲线、Motion Blur、Sharpen、GlowLayer。"

议长靠在椅背上:"你今天改了多少文件?"

"lighting.tsenv-bridge.tsenv-lighting.tsenv-menu.tsrenderer.tsscene-render-levels.tsperformance.ts," 外交官数着,"七个前端文件,零个 Go 文件。"

"零个 Go 文件。" 审判长重复了一遍,"你做了这么多事,但没碰过一行后端代码。"

"灯光是纯前端的事," 外交官说,"Babylon.js 管渲染,我们管参数。Go 只负责存储。这就是联邦的分工——城邦管能力,联邦管协调。"

他站起来,合上审计报告。

"下次开会," 他说,"我们讨论 Phase 2——SSR、SSS、Reflection Probe。那是材质的领域,比灯光更复杂。"

"材质的领域……" 织工喃喃自语,"那是我的活。"

她看了一眼窗外。黄昏的天空从橙色渐变到紫色——不是骤然变黑,而是平滑过渡。

灯光系统终于学会了渐变。


本章涉及的代码变更

  • lighting.ts:CascadedShadowGenerator 替换 ShadowGenerator / orbit 精度 / _ensureShadow filter 映射 / 位置指示器系统 / gizmo 实时跟随 / 太阳距离 400→1000
  • env-bridge.ts:hemiLight.intensity 硬编码移除 / stopTimeOfDay flush
  • env-lighting.ts:deriveLighting 渐变过渡 + isBelowHorizon 引用修复
  • env-menu.ts:阴影级联滑块范围 1-4→2-4
  • renderer.ts:+RenderState 4 字段 / +GlowLayer / +Motion Blur / +Sharpen / Bloom+Glow 互斥
  • scene-render-levels.ts:+3 个 UI 滑块 / +预设字段 / 灯光菜单折叠重构
  • performance.ts:+LevelConfig 2 字段 / 4 档位配置