经纬志
首页 / luma.gl / 教程

AnimationLoop 与时间驱动

先看结论

循环负责三件事:等设备就绪、按帧调用渲染回调、停止时释放资源。 它不能替应用管理 GPU 资源,只能在正确的时机把时间与尺寸交出来。

最后验证 2026-11-03 · luma.gl 9.4 · 更新 2026-10-29 · 引用本文

构造参数

参数必需作用
device是设备实例或设备 Promise,v9 不再自动创建
onInitialize否首帧前调用一次,可返回 Promise
onRender否每帧调用,绘制写在这里
onFinalize否停止或销毁时调用,用来释放资源
onError否回调抛出异常时接收错误
autoResizeViewport否是否按像素比自动设置视口

三个回调收到同一个 AnimationProps,常用字段包括 device、width、height、aspect、 time、tick、tock。 其中 width 与 height 已经是设备像素尺寸。

标准写法

设备先建好再进循环,避免 device 为 null 时画出空帧。 资源在 onInitialize 里建,在 onFinalize 里释放, 逐帧只改缓冲区。

(async function () {
  const device = await luma.luma.createDevice({
    type: 'webgl',
    adapters: [luma.webgl2Adapter],
    createCanvasContext: {
      canvas: document.getElementById('cv'),
      useDevicePixels: Math.min(window.devicePixelRatio, 2),
      autoResize: true
    }
  });

  let model = null;
  let positions = null;

  const loop = new luma.AnimationLoop({
    device: device,

    onInitialize: function (props) {
      positions = device.createBuffer({
        data: new Float32Array(3 * 512),
        usage: luma.Buffer.VERTEX | luma.Buffer.COPY_DST
      });

      model = new luma.Model(device, {
        vs: vsSource,
        fs: fsSource,
        attributes: { positions: positions },
        bufferLayout: [
          { name: 'positions', stepMode: 'vertex', format: 'float32x3' }
        ],
        vertexCount: 512,
        topology: 'line-strip'
      });

      console.log('初始画布尺寸', props.width, props.height);
    },

    onRender: function (props) {
      const seconds = (props.time - props.startTime) / 1000;

      if (props.tick % 2 === 0) {
        const data = new Float32Array(3 * 512);
        for (let i = 0; i < 512; i++) {
          data[i * 3] = (i / 511) * 2 - 1;
          data[i * 3 + 1] = Math.sin(i * 0.05 + seconds) * 0.4;
          data[i * 3 + 2] = 0;
        }
        positions.write(data);
      }

      const pass = props.device.beginRenderPass({
        clearColor: [0.05, 0.07, 0.10, 1]
      });
      model.draw(pass);
      pass.end();
    },

    onFinalize: function () {
      model.destroy();
      positions.destroy();
    },

    onError: function (error) {
      console.error('渲染出错', error);
    }
  });

  await loop.start();
})();

start() 返回 Promise,首帧之前会等待设备就绪并执行 onInitialize。把 await loop.start() 放在 初始化流程的末尾,可以让异常落进同一个 try 块。

按需渲染

静态画面不需要每帧重画,尤其是 GIS 场景里的地图切片与三维模型。 用 setNeedsRedraw 打标记,回调里先检查标记,没有变化就直接返回。

const loop = new luma.AnimationLoop({
  device: device,
  onRender: function (props) {
    if (!loop.needsRedraw()) return;

    const pass = props.device.beginRenderPass({ clearColor: [0, 0, 0, 1] });
    model.draw(pass);
    pass.end();
  }
});

// 视角、数据或尺寸变化后
map.on('move', function () {
  updateViewMatrix();
  loop.setNeedsRedraw('camera-changed');
});

needsRedraw() 读取后会清除标记。第一帧也要主动标记一次, 否则首屏是黑的,直到有交互才出现内容。

方法作用
start()启动循环,返回 Promise
stop()停止并触发 onFinalize
destroy()停止并释放内部资源
setNeedsRedraw(reason)标记需要重绘
redraw(time)立即渲染一帧
setProps(props)运行期更新可变参数
waitForRender()等待下一帧渲染完成

注意事项

在 onRender 里创建资源

每帧 createBuffer 或 new Model, 显存与句柄会持续增长,最终表现为页面卡顿或上下文丢失。 创建集中到 onInitialize,逐帧只写数据。

用 performance.now() 替代 props.time

两者起点不同。props.time 与 props.startTime 相减 得到循环内的时间,适合驱动动画;直接拿 performance.now() 与 startTime 比较,会得到从页面加载起算的巨大数值, 正弦函数看起来像随机抖动。

停止后没释放资源

stop() 只停循环,不销毁模型与缓冲。单页应用切换路由时, 旧循环不停、资源不释放,多个 canvas 会同时占用 GPU。 在路由离开前调用 stop(),并在 onFinalize 里逐个 destroy()。

引用本文

经纬志(2026)。《AnimationLoop 与时间驱动》。https://www.jingweizhi.com/lumagl/tutorial/animation-loop.html(访问日期 2026-11-03)。