Skip to content

ADR-168: 动态追光:舞台灯跟随角色/骨骼

状态: 已实现(Phase A-D 完成,E 远期搁置) 日期: 2026-07-22(立项)→ 2026-07-23(Phase A-D 落地) 关联: ADR-152(真实光锥)、ADR-159(lighting 拆分)、ADR-167(场景动作库/多角色)

1. 问题陈述

当前舞台灯(StageLightState)采用纯静态世界坐标定位:

  • posX/Y/Z — 灯的绝对位置
  • targetX/Y/Z — 光束瞄准的绝对坐标(默认原点)
  • orbit* — 绕世界原点公转

缺陷

场景表现
单角色偏移到 (2, 0, 3)灯还照 (0,0,0),角色半身在暗区
多角色分散站位无法同时兼顾,只有一盏灯"恰好"照到某角色
VMD 动作幅度大(跳跃/位移)角色跑出光锥范围,舞台效果崩塌
相机 VMD 运镜镜头切到角色侧面,灯光角度不配合,平面感

根因:灯光系统没有「目标绑定」概念——不感知角色存在,不追踪骨骼位置,不随时间更新。

2. 设计目标

#目标验收标准
G1灯光可绑定到角色(模型根节点)或指定骨骼SpotLight 的 target 逐帧跟随绑定目标
G2多灯可分别绑定不同角色2 角色 + 2 灯 → 各照各的
G3未绑定时行为不变(向后兼容)旧场景文件加载后灯光表现一致
G4跟随有平滑延迟(非硬切)快速移动时光束有 0.1-0.3s 的追踪惯性
G5支持「偏移量」微调绑定后仍可 offset 调整瞄准点(如瞄头不瞄脚)
G6性能:N 灯 × M 角色 无显著帧率影响8 灯 × 4 角色 < 0.1ms/帧

3. 方案设计

3.1 状态扩展

typescript
// lighting.ts — StageLightState 新增字段
interface StageLightState {
    // ... 现有字段 ...

    /** 跟随目标(null = 静态模式,向后兼容) */
    followTarget: FollowTarget | null;
}

interface FollowTarget {
    /** 绑定的模型 ID(modelRegistry key) */
    modelId: string;
    /** 骨骼名(null = 模型根节点 / 重心) */
    boneName: string | null;
    /** 相对骨骼的局部偏移(世界单位) */
    offset: [number, number, number];
    /** 追踪平滑系数(0-1,越大越快;0 = 瞬移) */
    smoothing: number; // default 0.15
    /** 灯自身是否也跟随移动(true = 灯位置相对目标保持轨道;false = 只转 target) */
    moveWithTarget: boolean; // default false
}

3.2 逐帧更新管线

在渲染循环(onAfterAnimationsObservable)中注册 追光 tick(骨骼动画求值后再更新灯位置,避免延迟一帧):

typescript
function _tickFollowLights(): void {
    for (const [id, entry] of lightingState.stageLights) {
        const ft = entry.state.followTarget;
        if (!ft) continue;

        const model = lightingState.modelRegistry?.get(ft.modelId);
        if (!model) continue; // 模型已卸载 → 灯回退静态

        // 1. 求目标世界坐标
        const worldPos = _resolveFollowWorldPos(model, ft);

        // 2. 平滑插值 target
        const current = lightingState.tmpTarget;
        current.copyFrom(entry.light instanceof SpotLight
            ? entry.light.position.add(entry.light.direction.scale(entry.state.orbitDistance))
            : new Vector3(entry.state.targetX, entry.state.targetY, entry.state.targetZ));
        Vector3.LerpToRef(current, worldPos, ft.smoothing, lightingState.tmpTarget);

        // 3. 写回
        entry.state.targetX = lightingState.tmpTarget.x;
        entry.state.targetY = lightingState.tmpTarget.y;
        entry.state.targetZ = lightingState.tmpTarget.z;
        _applyTargetToLight(entry);

        // 4. moveWithTarget:灯位置 = 目标 + 轨道偏移
        if (ft.moveWithTarget) {
            _applyOrbitRelativeTo(entry, lightingState.tmpTarget);
        }
    }
}

3.3 目标解析策略

boneName解析方式
null模型根 mesh 的 getAbsolutePosition() + Y 偏移(重心 ≈ 模型高度 × 0.5)
'頭' / 'center'skeleton.bones.find(name)getAbsolutePosition()
自定义同上,找不到时 fallback 到根节点 + logWarn

3.4 UI 交互

在灯光编辑卡片(scene-stage-lights.ts)新增「跟随目标」区块:

[跟随目标]  ○ 无(静态)  ○ 角色 1  ○ 角色 2  ...
[骨骼]      [下拉:根节点 / 頭 / 上半身 / ...]
[偏移 Y]    [滑块 -2 ~ 5]
[平滑度]    [滑块 0.01 ~ 0.5]
[灯随动]    [开关]

3.5 序列化兼容

  • followTarget: null 时序列化省略该字段 → 旧文件无此字段 → 反序列化默认 null → 静态模式。
  • 加载时若 modelId 对应的模型不存在(场景部分加载),灯保持静态直到模型注册后自动绑定。

3.6 性能考量

措施说明
仅遍历 followTarget !== null 的灯静态灯零开销
复用 lightingState.tmpTarget / tmpDir零 GC 分配
骨骼查找缓存Map<modelId+boneName, Bone> 缓存,模型卸载时清除
频率可降若帧率敏感,可隔帧更新(每 2 帧 tick 一次)

4. 备选方案(已否决)

方案否决理由
Babylon light.parent = mesh灯成为 mesh 子节点后无法独立控制 target 方向;多灯共享 parent 时无法分别瞄准
每帧硬设 target(无平滑)60fps 下 VMD 动作本身已平滑,但用户手动移动模型时会"弹跳";且无法表达"灯光师手动追踪"的惯性手感
全局自动追光(所有灯自动照最近角色)剥夺用户控制权;多灯场景下行为不可预测

5. 实施分期

Phase内容估时
A — 核心追踪FollowTarget 类型 + _tickFollowLights + 根节点跟随 + 序列化2-3h
B — 骨骼绑定骨骼选择 UI + 骨骼缓存 + offset1-2h
C — 灯随动moveWithTarget + 轨道相对计算1h
D — 多灯编排预设中保存 followTarget + 预设切换时平滑过渡追踪目标1h
E — 智能补光(远期)无绑定灯自动填充暗区 / 根据相机角度微调光锥朝向待评估

6. 风险

风险缓解
模型卸载后灯"失焦"检测 modelRegistry 无对应 key → 回退静态 + toast 提示
骨骼名跨模型不一致(中日英)提供 boneName 模糊匹配 + 用户手选覆盖
高频 tick 与 VMD 求值竞争追光 tick 注册在 onAfterAnimationsObservable(骨骼更新后)
光锥/阴影跟随延迟光锥 transform 已在 coneUpdateHandle 每帧同步;阴影 ShadowGenerator 自动跟随 light.position

7. 验收清单

  • [x] 单角色绑定后,播放位移动作 VMD,光锥始终覆盖角色(个人灯 + 舞台灯追光双路径)
  • [x] 双角色 + 双灯分别绑定,互不干扰(个人灯独立 _entries Map;舞台灯各自 followTarget
  • [x] 旧场景文件(无 followTarget / personalLight)加载后灯光表现不变(默认 null / 默认值)
  • [x] 模型卸载后灯回退静态,无报错(detachPersonalLight + modelRegistry.get 守卫)
  • [ ] 8 灯 × 4 角色场景帧率无明显下降(< 0.1ms 追光开销)— 待性能基准测试
  • [x] 预设切换时个人灯参数平滑过渡(intensity/color 300ms tween)

8. 实现记录(2026-07-23)

8.1 双路径架构

实际实现采用个人灯 + 舞台灯追光双路径,而非 ADR 原始设计的单一 followTarget 方案:

路径文件职责
个人灯(Personal Light)lighting-follow.ts每个 actor 自动获得一盏 SpotLight,跟随腰骨/根节点;用户可调亮度/颜色/锥角/高度/光锥/跟随骨骼
舞台灯追光(Stage Light Follow)lighting-follow.tstickStageLightFollow()已有舞台灯通过 followTarget 字段绑定角色/骨骼,target 逐帧平滑追踪

8.2 已完成 Phase

Phase状态说明
A — 核心追踪个人灯逐帧 tick(onAfterAnimationsObservable)+ 平滑插值 + 骨骼解析
B — 骨骼绑定个人灯菜单骨骼下拉(model-detail.ts)+ 舞台灯跟随目标卡片(scene-stage-lights.ts
C — 灯随动个人灯天然随动;舞台灯 moveWithTarget 开关
D — 多灯编排4 个内置预设各配 personalLight 联动参数;applyLightingPresetFromEnv 末尾 tween 过渡
E — 智能补光❌ 搁置远期,不在本轮范围

8.3 序列化

  • 个人灯SceneFile.models[].personalLight(差异落盘,仅存与 DEFAULT_PERSONAL_LIGHT 不同的字段)
  • 舞台灯追光StageLightState.followTargetstageLights[] 整体序列化(null 时省略)
  • 反序列化 Phase 2 中调用 restorePersonalLights() 恢复个人灯参数

8.4 测试覆盖

src/__tests__/scene/lighting-follow.test.ts(8 tests):

  • DEFAULT_PERSONAL_LIGHT 默认值完整性(含 boneName)
  • StageLightState.followTarget 向后兼容(默认 null)
  • getAllPersonalLights / restorePersonalLights 边界
  • tickStageLightFollow 无 followTarget / ghost model 静默跳过

8.5 已知限制

项目说明
性能基准8灯×4角色 < 0.1ms 尚未实测
骨骼缓存当前每帧 WAIST_CANDIDATES.find(),未做 Map<modelId+boneName, Bone> 缓存
舞台灯 offset UIfollowTarget.offset 暂无独立滑块,需通过代码设置