AnimationLoop 与时间驱动
循环负责三件事:等设备就绪、按帧调用渲染回调、停止时释放资源。 它不能替应用管理 GPU 资源,只能在正确的时机把时间与尺寸交出来。
构造参数
| 参数 | 必需 | 作用 |
|---|---|---|
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)。