Appearance
灯塔的觉醒
背景:舞台只有一盏聚光灯,三点布光要用一盏灯假装——用户需要完整的布光工具。 过程:多灯池(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 画在场景上面。拖拽结束后,从灯光的 position 和 direction 反推 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)+ 阴影参数- 多灯实例管理(
_stageLightsMap +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 测试通过