如何使用three.js/WebGL实现3D消防演练系统 之 事件联动与时间线复盘

序

看比赛的时候,回放总能看出不少东西。刚才还觉得这个人怎么不传球,换个角度一看,原来那边早有人堵着。坐在屏幕前指挥很容易,真到了场上,留给人想事情的时间没那么多。

工作里也差不多。事情过去以后,大家都能说上几句。要是连先发生什么、后发生什么都记不清,讨论着讨论着,就容易变成谁的记性更好。

前两篇已经做了报警定位和疏散改道。这篇把它们串起来,做演练回放。暂停以后能看,往前拖、往后拖,也应该得到同一份状态。

一、先看这几个位置

配图为 AI 生成的三维场景设计。回放需要留下的是事件;只录一段视频,没法再点一个人查他当时的位置。这里用相对演练开始的毫秒数记录时间。

js 复制代码
const history = [
  { id: 'e1', at: 1000, kind: 'alarm', device: 'SD-203', active: true },
  { id: 'e2', at: 1800, kind: 'block', edges: ['eastHall'] },
  { id: 'e3', at: 2400, kind: 'person', person: 'P-01', pos: [2, 6, 0] },
  { id: 'e4', at: 4800, kind: 'person', person: 'P-01', pos: [-5, 6, 4] },
  { id: 'e5', at: 6000, kind: 'alarm', device: 'SD-203', active: false }
];

function initialState() {
  return { alarms: {}, closed: [], people: {} };
}

function reduceEvent(state, event) {
  const next = structuredClone(state);
  if (event.kind === 'alarm') next.alarms[event.device] = event.active;
  if (event.kind === 'block') next.closed = [...event.edges];
  if (event.kind === 'person') next.people[event.person] = [...event.pos];
  return next;
}

这里只放三种事件。实际接入时按事件 ID 去重,并校验字段,再按时间和服务器序号排序。播放过程中不要修改这份记录,否则一边播放一边补数据,同一个时间点可能出现两个结果。

二、拖到某个时间,状态从哪里来

事件少就从头算,事件多就每隔一段存个快照。拖到某个时刻,找到它前面的快照,再补上中间几条事件。下面把"初始状态"和"第几条事件"一起保存,方便往回拖。

js 复制代码
function buildCheckpoints(events, stride = 50) {
  let state = initialState();
  const points = [{ at: -Infinity, index: 0, state }];
  events.forEach((event, i) => {
    state = reduceEvent(state, event);
    if ((i + 1) % stride === 0) {
      points.push({ at: event.at, index: i + 1, state });
    }
  });
  return points;
}

const checkpoints = buildCheckpoints(history);

function stateAt(time) {
  let left = 0, right = checkpoints.length - 1;
  while (left < right) {
    const mid = Math.ceil((left + right) / 2);
    if (checkpoints[mid].at <= time) left = mid;
    else right = mid - 1;
  }
  const base = checkpoints[left];
  let state = structuredClone(base.state);
  for (let i = base.index; i < history.length && history[i].at <= time; i++) {
    state = reduceEvent(state, history[i]);
  }
  return state;
}

关键在于每次都从对应快照重算,不能拿当前画面往上叠。否则从第五秒拖回第二秒,已经恢复的报警就再也亮不回来了。

三、把回放状态交给场景

三维对象只读回放结果。下面三个 Map 分别保存报警标记、通道标记和人员模型,键使用业务 ID;具体对象在模型加载时登记。

js 复制代码
const alarmObjects = new Map();
const passageObjects = new Map();
const personObjects = new Map();

function renderState(state) {
  for (const [id, object] of alarmObjects) {
    object.visible = state.alarms[id] === true;
  }
  for (const [id, object] of passageObjects) {
    object.visible = state.closed.includes(id);
  }
  for (const [id, object] of personObjects) {
    const position = state.people[id];
    object.visible = Array.isArray(position);
    if (position) object.position.fromArray(position);
  }
}

renderState(stateAt(2500));

这段同时处理了"出现"和"消失"。特别是人员位置还没采到的时候,不要让模型默认站在原点;没有记录就先隐藏,界面上保留数据缺失提示。

四、播放和暂停共用同一个时钟

动画帧只推进时间,不直接往物体身上补位移。暂停、倍速、拖动最后都调用 stateAt,才不会有三套互相对不上的状态。

js 复制代码
let frame = 0, cursor = 0;
const endTime = history.at(-1)?.at ?? 0;

function pause() {
  cancelAnimationFrame(frame);
}

function seek(time) {
  pause();
  cursor = Math.max(0, Math.min(time, endTime));
  renderState(stateAt(cursor));
}

function play(rate = 1) {
  if (!Number.isFinite(rate) || rate <= 0) return;
  pause();
  const start = performance.now(), from = cursor;
  function tick(now) {
    cursor = Math.min(endTime, from + (now - start) * rate);
    renderState(stateAt(cursor));
    if (cursor < endTime) frame = requestAnimationFrame(tick);
  }
  frame = requestAnimationFrame(tick);
}

最后来回拖几次:报警前、报警后、改道后,再回到报警前。场景应该能完整恢复,旧标记不能留下来。人员经过了哪条路线,则继续沿用上一课的节点关系核对。

消防这三篇先写到这里。把位置、路线和时间这三件事理顺,再加烟雾、音效和操作面板,会踏实很多。

**上篇:**第二课:人员疏散与动态改道