Skip to content

灯塔的觉醒

背景:舞台只有一盏聚光灯,三点布光要用一盏灯假装——用户需要完整的布光工具。 过程:多灯池(spot/point/directional)+ 每灯独立阴影 + 3D Gizmo 拖拽 + 6 套预设 + TWEEN 过渡 + EnvBridge 状态驱动。


一、一盏灯的孤独

舞台灯光管理员坐在角落里,面前只有一盏灯。

"你只有一盏灯?" 外交官问。

"一盏聚光灯," 管理员说,"从头顶打下来,照着舞台中央。启用、关闭、调强度——就这些。"

"没有点光源?没有平行光?"

"没有。"

"没有阴影?"

"没有。"

外交官沉默了一会儿:"那用户怎么打三点布光?"

管理员摊手:"用一盏灯假装三点。先把灯挪到 45° 侧上方当主光,再手动改参数假装补光——但只有一盏灯,做不到。"


二、灯光的分身术

"我们需要多盏灯," 外交官说,"而且每盏灯要有不同的类型。"

他在白板上画了三种灯光:

聚光灯(SpotLight)—— 锥形光束,适合打角色轮廓、舞台焦点。

点光源(PointLight)—— 全向发光,适合室内补光、暖色氛围。

平行光(DirectionalLight)—— 无限远平行光线,适合模拟日光。

"用户选一个预设——'角色肖像'——系统自动创建三盏灯:主光、补光、轮廓光。每盏灯独立控制位置、强度、颜色、锥角。"

管理员皱眉:"但现有的代码只有一个 stageLight 变量,一个 SpotLight 实例。"

"所以要重构," 外交官说,"把单灯改成灯池。"

他在白板上画了一个 Map:

_stageLights: Map<string, StageLightEntry>
  'light-1' → { state, light: SpotLight }
  'light-2' → { state, light: PointLight }
  'light-3' → { state, light: DirectionalLight }

"每盏灯有唯一 ID,有独立的状态,有独立的阴影生成器。"


三、阴影的诞生

"光有了," 织工说,"但没有影子。"

"阴影是灯光的承诺," 外交官说,"没有影子的光是不完整的。"

他解释了 Babylon.js 的阴影机制:

"ShadowGenerator 需要一个光源和一组投射者。我们有三种灯光,其中聚光灯和平行光可以投射阴影——点光源不行,因为它是全向的。"

"所以每盏灯要自己的阴影生成器?"

"是的。每盏灯的阴影独立——分辨率、类型(硬/软/PCF)、偏移量都可以单独调。"

管理员问:"那阴影投射者呢?模型和道具都要投射阴影吗?"

"都要," 外交官说,"每当模型注册表更新时,重新扫描所有 mesh,加入阴影投射者列表。"

他在白板上画了流程:

模型加载完成
  → rebuildShadowCasters()
    → 遍历 modelRegistry + propRegistry
    → 每个 Mesh 加入 ShadowGenerator
    → 每个 Mesh 设为 receiveShadows

四、3D 拖拽

"最后一个问题," 织工说,"位置控制。现在是三个滑块——水平角度、仰角、距离。调一盏灯要滑三个滑块,调三盏灯要滑九个。"

"所以需要 3D 拖拽," 外交官说,"用户直接在场景里拖灯。"

他引入了 Babylon.js 的 TransformGizmo

"Gizmo 有两种——PositionGizmo 是三色坐标轴(红=X,绿=Y,蓝=Z),拖轴移动位置。RotationGizmo 是三个圆环,拖环旋转方向。"

"但灯光不是普通节点," 管理员说,"SpotLight 的方向是 direction 属性,不是 rotation。"

"所以我们用 UtilityLayerRenderer 创建一个独立的渲染层,把 Gizmo 画在场景上面。拖拽结束后,从灯光的 positiondirection 反推 StageLightState 的轨道参数。"

他在白板上画了交互流程:

用户点击"拖拽定位"按钮
  → attachLightGizmo(id)
    → 创建 PositionGizmo + RotationGizmo
    → 绑定到灯光节点
    → onDragEnd → 同步 StageLightState
  → 场景里出现三色坐标轴 + 旋转环
  → 用户拖拽
  → 释放时自动保存位置
  → 点击"退出拖拽" → detachLightGizmo()

五、灯光预设

"最后," 外交官说,"我们需要预设。"

他在白板上列了六个场景:

预设灯光配置
角色肖像主光 45° 暖色 + 补光对侧冷色 + 轮廓光背后白色
道具产品顶部柔光 + 侧方补光
舞台戏剧单盏强聚光,高对比,带阴影
舞蹈表演三色聚光 120° 间隔
自然日光平行光模拟太阳,带阴影
夜间场景低强度平行光月光 + 暖色点光源

"用户点一个芯片——'角色肖像'——系统自动创建三盏灯,位置、强度、颜色全部设好。"

"但如果用户已经手动调好了灯呢?" 管理员问。

"好问题," 外交官说,"所以不能暴力清空重建。要复用——比较当前灯数和预设灯数,不足则补齐,过多则删除,参数用 TWEEN 动画平滑过渡。"

他在白板上画了过渡曲线:

用户点击预设
  → cancelAllLightingTweens()  // 清除旧动画
  → 比较当前灯光 vs 预设灯光
    → 数量不足 → addStageLight()
    → 数量过多 → removeStageLight()
    → 参数不同 → tweenValue(from, to, 500ms)

"500 毫秒,ease-out quad。灯光从旧位置'流'到新位置,不是'闪现'。"

织工点头:"如果用户在 500 毫秒内连点两个预设呢?"

"第一次点击时 cancelAllLightingTweens() 清除所有正在运行的 requestAnimationFrame 句柄。同一时间只有一个 TWEEN 集群拥有控制权。"


六、状态驱动

"还有一个架构问题," 外交官说,"灯光预设的状态应该存在哪里?"

"存在 StageLightState 里?" 管理员猜。

"不行。StageLightState 是每盏灯的参数,预设名是全局状态。如果存在 StageLightState 里,每盏灯都要记同一个预设名——冗余。"

"那存在 LightState(方向光/半球光状态)里?"

"也不对。灯光预设管的是舞台灯光,不是环境光。"

外交官画了一张状态流图:

EnvState.lightingPresetName  ←  用户点击预设芯片

EnvBridge.setEnvState({ lightingPresetName: 'character-portrait' })

EnvBridge 检测到 lightingPresetName 变化

applyLightingPresetFromEnv('character-portrait')

灯光池复用 + TWEEN 过渡

"预设名存在 EnvState 里,由 EnvBridge 监测变化。这样序列化自动兼容——旧场景没有这个字段,默认 null(自定义状态)。"

"这就是状态驱动," 审判长从后排开口,"不是命令驱动。"


七、尾声

灯光管理员站起身,面前不再是孤零零的一盏灯。

现在他有整个灯池——六种预设、多盏独立灯光、阴影投射、3D 拖拽、平滑过渡。

"以前我只有一盏灯," 他说,"现在我有一整套布光系统。"

外交官收起白板:"这就是今天的进步——从参数面板到创作工具。灯光不再是一组滑块,而是一套完整的布光方案。"

他顿了顿。

"而且,当你点下'角色肖像'那个芯片时,三盏灯同时亮起——500 毫秒内从黑暗流向光明。那一刻你会觉得,代码也可以是美的。"


对应技术变更:

  • StageLightState 扩展为多类型(spot/point/directional)+ 阴影参数
  • 多灯实例管理(_stageLights Map + addStageLight/removeStageLight
  • 每灯独立 ShadowGenerator(硬/软/PCF + 分辨率 + 偏移)
  • 3D Gizmo(PositionGizmo + RotationGizmo + UtilityLayerRenderer)
  • 6 套内置灯光预设(lighting-presets.ts
  • TWEEN 平滑过渡(ease-out quad,500ms 位置 / 300ms 强度颜色)
  • _cancelAllLightingTweens() 竞态安全
  • EnvState.lightingPresetName 状态驱动 + EnvBridge 协调
  • preset-chip UI 交互
  • TypeScript 零新增错误,360/363 测试通过