#sec-1
Computer Graphics · Chapter 3 · 01

第3章 交互和动画

3-1 让图形动起来

核心问题:一个静态 WebGL 程序,怎样持续生成新的画面并响应用户?

#sec-2-1

本课主线:静态程序如何“活起来”?

第2章的WebGL程序通常只完成一次绘制。本课要把它改造成持续变化、可以被用户控制的图形程序。

State
程序状态
→
Update
状态更新
→
Render
重新绘制
requestAnimationFrame ↺ Event / Callback → State

课前预测

如果要让一个正方形每秒旋转60次,是否必须每一帧都重新计算并上传4个顶点?

#sec-2-2

从静态绘制开始

一个最小WebGL程序通常按照“准备数据 → 绘制一次 → 停止”的方式运行。

Canvas / Context
→
Shader Program
→
Buffer
→
Draw
→
Canvas Image
问题不是“如何再画一次”,而是:每一帧到底应该改变什么?
#sec-2-3

动画的关键:不是所有数据都要变

方案A:CPU重算顶点

  • 每一帧重新计算4个顶点
  • 每一帧重新上传Buffer
  • 对象复杂时,CPU→GPU传输会变重

方案B:更新一个参数

  • 顶点数据保持不变
  • 每一帧只更新 uTheta
  • 旋转计算交给Vertex Shader

预测

如果模型有100万个顶点,哪种方案更符合现代GPU编程思想?为什么?

#sec-2-4

随堂测验 1:动画中每帧最适合改变什么?

单选60 s

已有一个复杂模型的顶点数据保存在 GPU Buffer 中,现在只希望它持续绕原点旋转。更合理的做法是:

A每一帧重新生成全部顶点并重新上传 Buffer
B每一帧更新旋转角度的 uniform,然后重新绘制
C每一帧重新创建 Canvas 和 WebGL Context
D只调用 gl.clear,不再执行 Draw Call
参考答案:B。顶点数据可以保持在 GPU 中,跨帧变化的是程序状态 theta;通过 uniform 把状态传给 Shader,再执行绘制。
#sec-3-1

案例:单位圆上的旋转正方形

在单位圆构造正方形:随着角度 θ 改变,顶点位置发生旋转。
核心启示:角度 θ 是可以跨帧改变的程序状态。

静态单位圆与顶点坐标

θ = 30° 几何分布

动态逆时针旋转演示

rAF 循环动画 ↺
#sec-3-2

把变化量交给 Shader

顶点属性 attribute / in 描述每个顶点不同的数据;
uniform 描述一次绘制中所有顶点共享的数据。

固定数据

四个原始顶点

Buffer

变化状态

当前旋转角度

uTheta

计算位置

顶点着色器输出旋转后位置

gl_Position
#version 300 es
in vec4 aPosition;
uniform float uTheta;

void main() {
    gl_Position.x = -sin(uTheta) * aPosition.y + cos(uTheta) * aPosition.x;
    gl_Position.y =  sin(uTheta) * aPosition.x + cos(uTheta) * aPosition.y;
    gl_Position.z = 0.0;
    gl_Position.w = 1.0;
}
#sec-3-3

Uniform更新的两步

查找位置
getUniformLocation
→
每帧赋值
uniform1f
→
绘制
drawArrays
let theta = 0.0;
const thetaLoc = gl.getUniformLocation(program, "uTheta");

function render() {
    gl.clear(gl.COLOR_BUFFER_BIT);
    theta += 0.03;
    gl.uniform1f(thetaLoc, theta);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}

课堂检查

这段代码如果只调用一次 render(),会得到动画吗?为什么?

#sec-3-4

Demo:动画循环显微镜

Interactive Demo · D03-01

动画循环显微镜

观察动画循环的内部运作机制:左侧绘制旋转图形,右侧实时监控 Frame、θ 角度、Δt 帧间隔、Buffer 状态与 Uniform 更新状态。

思考与验证 2 min

观察任务

  1. 单步调试:暂停动画循环,尝试单步执行一帧。
  2. 观察 Buffer:顶点数据 VBO 在跨帧时是否发生改变?
  3. 观察状态:每一帧真正被 JavaScript 修改的是哪个变量?
  4. 原理解释:顶点位置没变,为什么 GPU 能画出不同位置?
#sec-4-1

为什么不能直接递归调用 render?

错误模型

function render() {
    update();
    draw();
    render();
}

同步递归:当前函数尚未返回,就继续调用自身。

调用栈会不断增长

render↓render↓render↓...

这不是动画循环,而是失控的函数调用。

动画循环的关键:让本次绘制结束,再把下一次绘制交还给浏览器调度。
#sec-4-2

requestAnimationFrame:把下一帧交给浏览器

它不是立即再次执行 render(),而是请求浏览器在下一次适合重绘时回调。

function render(time) {
    gl.clear(gl.COLOR_BUFFER_BIT);
    theta += 0.03;
    gl.uniform1f(thetaLoc, theta);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

    requestAnimationFrame(render);
}
requestAnimationFrame(render);
Callback→Update→Draw→Return→Next frame
#sec-4-3

Timer 和 rAF:都能动,但含义不同

方式 特点 课堂结论
setInterval(render, 16) 按时间间隔尝试调用 与浏览器绘制节奏不一定对齐
setTimeout(...) 可人为降低帧率 适合控制节奏,但不等于显示同步
requestAnimationFrame 由浏览器在下一帧前回调 动画绘制首选

排序活动

思考与排序 · 2 min

请按一帧动画从触发到渲染完成的正确执行顺序排列以下卡片:

⠿ Clear
⠿ Draw
⠿ Update State
⠿ Browser Callback
⠿ requestAnimationFrame
#sec-4-4

随堂测验 2:requestAnimationFrame 到底做了什么?

单选60 s

在动画程序中,requestAnimationFrame(render) 的作用最接近下面哪一项?

A立即同步调用 render(),形成普通递归
B创建一个新的线程,并保证每秒固定执行 60 次
C请求浏览器在下一次适合重绘时回调 render
D直接交换前后缓存,不需要再执行绘制代码
参考答案:C。它不是立即递归调用,也不保证固定帧率;它把下一帧绘制交回浏览器调度,从而形成更合适的动画循环。
#sec-4-5

双缓存:为什么要等“一整帧”准备好?

绘制和显示是解耦的。浏览器显示当前完整图像,图形系统在后台绘制下一帧。

Display

Front Buffer(前台缓存)

屏幕读取并显示的完整画面,防止未画完直接暴露导致撕裂。

Screen Display 🖥️
Drawing

Back Buffer(后台缓存)

GPU 进行光栅化的私有绘制区域,完成后触发 Swap 交换。

GPU Render Loop ⚙️
Interactive Demo · D03-02

双缓存机制显微镜

观察 Front/Back Buffer 交替 Swap 交换与帧同步机制。

核心结论:双缓存解决的是画面完整性与防撕裂(Tearing)问题,不会自动提升复杂场景的绘制速度。
#sec-5-1

从动画到交互:用户改变的是状态

正方形已经会转。现在用户点击按钮,希望它反向旋转。这个操作不是直接“改变画面”,而是改变程序状态。

Click→Event→Callback→direction→Next Render
let direction = true;

document.getElementById("DirectionButton")
  .addEventListener("click", function () {
      direction = !direction;
  });

function render() {
    theta += direction ? 0.03 : -0.03;
    gl.uniform1f(thetaLoc, theta);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    requestAnimationFrame(render);
}
#sec-5-2

不同控件,本质都是改变状态

Button

切换方向

direction = !direction

Slider

调整速度

speed = value

Keyboard

控制暂停、加速、减速

event.key
document.getElementById("speedSlider").addEventListener("input", function(event) {
    speed = Number(event.target.value);
});

window.addEventListener("keydown", function(event) {
    if (event.key === " ") paused = !paused;
});
#sec-5-3

AI协作:核验一个关于 rAF 的解释

AI回答:
“requestAnimationFrame 会创建一个新的线程,并保证程序每秒运行60次,所以它比 setInterval 更快。”

找可能正确的部分

它确实常用于平滑动画。

找明显错误

不应理解为创建新线程;也不能保证固定60fps。

如何验证

记录 time 或 performance.now(),观察帧间隔。

提示词练习

请AI解释 requestAnimationFrame,但要求它必须说明“什么由浏览器决定、什么由程序员决定”。

#sec-6-1

本课总结:动画程序的基本结构

State
theta / speed / direction
→
Update
时间或事件改变状态
→
Render
Uniform / Draw
requestAnimationFrame ↺ Event Listener → State
今天解决的问题:WebGL程序如何持续产生新画面,并让用户输入改变后续画面。

Exit Ticket

动画不是不断重新启动程序,而是不断执行:______ → ______ → ______。

用户交互不是直接改变画面,而是事件先改变:______。

#sec-6-2

下一课:用户点击了哪里?点中了谁?

下一课将把“事件改变状态”继续推进:从鼠标事件中得到的位置,如何转换成WebGL中的图形坐标?又如何判断它对应哪个对象?

Event→Position→Coordinate Mapping→GPU Data→Render / Picking