Appearance
光的七宗罪
背景:灯光系统有七宗罪——CSM 是纸糊的、轨道角度会失忆、半球光独裁、时间流转遗忘、水面西西弗斯、级联缺席、降级缺失。 过程:CascadedShadowGenerator 替换 + 轨道精度 0.05° + 半球光民主化 + stopTimeOfDay flush + 灯光菜单折叠 + 3D 指示器 + 黄昏平滑曲线 + Motion Blur/Sharpen/GlowLayer 接入。
一、七宗罪
外交官把一份审计报告摔在议会桌上。
"灯光系统有七宗罪。" 他说。
议长翻开报告。第一页写着:
S1. 级联阴影是纸糊的 —— UI 上有「阴影级联」滑块,但底层用的还是普通 ShadowGenerator。用户拖滑块,_shadowCascades 变了,序列化存了,渲染毫无变化。100% 复现。
M1. 轨道角度会失忆 —— Math.round 把 30.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.round 把 30.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.ts 的 LevelConfig 中加入了两个新字段:
typescript
interface LevelConfig {
// ... 现有字段
motionBlurEnabled: boolean;
glowEnabled: boolean;
}L0(正常)= 两者都开。L1(轻度降级)= 两者都关。L2/L3 = 关。
"预设也要更新," 她补充,"cinematic 加 motionBlur 0.3,realistic 加 motionBlur 0.2。电影和写实风格天然需要运动模糊。"
十二、收尾
所有修改完成后,外交官做了最后一次构建验证。
✓ built in 2.58s"七宗罪全部修复," 他说,"CSM 级联阴影激活、轨道精度保护、半球光民主化、时间流转记忆补全、灯光菜单折叠、位置指示器、太阳距离修正、黄昏亮度曲线、Motion Blur、Sharpen、GlowLayer。"
议长靠在椅背上:"你今天改了多少文件?"
"lighting.ts、env-bridge.ts、env-lighting.ts、env-menu.ts、renderer.ts、scene-render-levels.ts、performance.ts," 外交官数着,"七个前端文件,零个 Go 文件。"
"零个 Go 文件。" 审判长重复了一遍,"你做了这么多事,但没碰过一行后端代码。"
"灯光是纯前端的事," 外交官说,"Babylon.js 管渲染,我们管参数。Go 只负责存储。这就是联邦的分工——城邦管能力,联邦管协调。"
他站起来,合上审计报告。
"下次开会," 他说,"我们讨论 Phase 2——SSR、SSS、Reflection Probe。那是材质的领域,比灯光更复杂。"
"材质的领域……" 织工喃喃自语,"那是我的活。"
她看了一眼窗外。黄昏的天空从橙色渐变到紫色——不是骤然变黑,而是平滑过渡。
灯光系统终于学会了渐变。
本章涉及的代码变更:
lighting.ts:CascadedShadowGenerator 替换 ShadowGenerator / orbit 精度 / _ensureShadow filter 映射 / 位置指示器系统 / gizmo 实时跟随 / 太阳距离 400→1000env-bridge.ts:hemiLight.intensity 硬编码移除 / stopTimeOfDay flushenv-lighting.ts:deriveLighting 渐变过渡 + isBelowHorizon 引用修复env-menu.ts:阴影级联滑块范围 1-4→2-4renderer.ts:+RenderState 4 字段 / +GlowLayer / +Motion Blur / +Sharpen / Bloom+Glow 互斥scene-render-levels.ts:+3 个 UI 滑块 / +预设字段 / 灯光菜单折叠重构performance.ts:+LevelConfig 2 字段 / 4 档位配置