#sec-1
Computer Graphics · Chapter 3 · 02

第3章 交互和动画

3-2 让用户操纵图形

核心问题:用户在屏幕上的操作,怎样转换成对图形对象的操作?

#sec-2-1

本课主线:从鼠标事件到图形对象

鼠标给浏览器的是窗口位置,WebGL需要的是图形空间中的数据;如果还要选中对象,就必须建立屏幕位置与对象之间的关系。

Event→Position→Coordinate Mapping→State / Buffer→Render
Screen Position→Picking→Object ID

开场问题

如果点击Canvas左上角,WebGL中的坐标是左上角吗?为什么?

#sec-2-2

输入设备:物理设备与逻辑角色

物理设备

鼠标、键盘、触摸屏、手写板、游戏杆、空间球。

关注设备本身如何产生信号。

逻辑设备

定位、拾取、选择、键盘输入、轨迹输入。

关注应用程序获得了什么语义信息。

同一个鼠标既可以是定位器,也可以是拾取器,还可以触发菜单选择。
#sec-2-3

请求模式 vs 事件模式

请求模式

程序等待指定输入。

scanf("%d", &x);

适合程序引导用户输入。

事件模式

用户随时触发事件,程序通过回调响应。

canvas.addEventListener("click", callback);

适合交互式图形程序。

判断

飞行模拟、绘图软件、游戏控制更适合哪一种输入模式?为什么?

#sec-3-1

事件对象:程序从事件中得到什么?

事件不仅告诉我们“发生了点击”,还携带位置、按键、目标元素等测量数据。

类型

click
mousedown
keydown

目标

canvas
button
window

测量

clientX
clientY
key

canvas.addEventListener("mousedown", function(event) {
    console.log(event.clientX, event.clientY);
});
#sec-3-2

坐标系不一样:不能直接使用鼠标坐标

浏览器窗口坐标以左上角为原点(Y轴向下);WebGL 裁剪空间 (NDC) 以中心为原点,范围映射在 [-1, 1] 且 Y轴向上。

窗口坐标到WebGL NDC坐标转换示意图

坐标映射公式 (Screen → NDC)

设 Canvas 宽度为 w,高度为 h,鼠标在 Canvas 内的像素坐标为 (xw, yw):

X 轴映射: x = -1 + 2 · xw / w
Y 轴映射: y = -1 + 2 · (h - yw) / h

核心要点:Y 轴方向反转(h - yw)是完成屏幕像素到 GPU 渲染坐标转换的关键。

#sec-3-3

随堂测验 1:鼠标坐标怎样变成 WebGL 坐标?

单选60 s

如果 Canvas 不在页面左上角,想把鼠标点击位置正确转换为 WebGL 坐标,最稳妥的做法是:

A直接使用 event.clientX / canvas.width
B先用 getBoundingClientRect() 得到 Canvas 位置,再换算到 NDC
C使用 event.screenX,因为它就是 Canvas 内坐标
D只转换 x 坐标,y 坐标无需翻转
参考答案:B。clientX/clientY 是浏览器窗口坐标;应先扣除 Canvas 在页面中的位置,再映射到 WebGL 的归一化坐标。
#sec-3-4

更稳健的Canvas坐标转换

如果Canvas不在页面左上角,直接用 event.clientX / canvas.width 会产生偏移。

function pointerToNDC(event, canvas) {
    const rect = canvas.getBoundingClientRect();

    const xCanvas = (event.clientX - rect.left) * canvas.width / rect.width;
    const yCanvas = (event.clientY - rect.top) * canvas.height / rect.height;

    const x = 2 * xCanvas / canvas.width - 1;
    const y = 1 - 2 * yCanvas / canvas.height;

    return [x, y];
}

预测

如果页面左侧有一个200px宽的导航栏,直接使用 clientX 会让绘制点向哪边偏?

#sec-3-5

Demo:Pointer → GPU 坐标转换实验台

Interactive Demo · D03-03

坐标转换显微镜

实时监控三层坐标变化:浏览器窗口坐标 (clientX, clientY) → Canvas 元素内像素坐标 (xw, yw) → WebGL 归一化设备坐标 (xndc, yndc)。

思考与验证 2 min

观察任务

  1. 布局变化:改变 Canvas 在页面中的内边距与位置,观察偏移量。
  2. 公式对比:对比简化公式与使用 getBoundingClientRect() 的稳健公式。
  3. Y轴翻转:移动鼠标,观察为什么 Y 轴方向需要做 1 - 2y/h 的取反翻转。
#sec-4-1

点击怎样变成GPU中的新顶点?

坐标转换之后,得到的是WebGL可用的位置数据。接下来把它写入Buffer,并在下一次绘制中显示出来。

Click→NDC vec2→bufferSubData→index++→drawArrays
let index = 0;

canvas.addEventListener("click", function(event) {
    const [x, y] = pointerToNDC(event, canvas);
    const point = new Float32Array([x, y]);

    gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
    gl.bufferSubData(gl.ARRAY_BUFFER, 2 * 4 * index, point);
    index++;
});

function render() {
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArrays(gl.POINTS, 0, index);
    requestAnimationFrame(render);
}
#sec-4-2

交互式程序必须保存跨事件状态

一次点击只能提供一个位置。绘制矩形需要两次点击,因此程序必须记住第一次点击的位置。

第一次点击

保存P1,不立即形成矩形。

firstPoint = [x, y];
waitingSecond = true;

第二次点击

得到P2,计算另外两个顶点,上传4个顶点。

makeRectangle(firstPoint, [x, y]);
waitingSecond = false;
交互式建模的本质:事件不断到来,程序状态不断积累,绘制结果不断更新。
#sec-4-3

Demo:两次点击生成矩形

Interactive Demo · D03-04

交互式矩形建模

通过鼠标在 Canvas 上两次连续点击构建矩形:第一次点击确定矩形角点 P1(x1, y1),第二次点击确定对角点 P2(x2, y2),程序自动计算补齐其余两点并上传 GPU 渲染。

课堂思考 1 min

课堂提问

如果要在程序中支持“撤销上一个矩形(Undo)”或“删除指定矩形”,程序需要额外保存什么信息?

提示:顶点缓冲区 (VBO) 只保存渲染点,若要实现撤销/选择等对象级交互,必须在 JavaScript 端维护应用层几何数据结构 (State)。
#sec-5-1

从“点在哪里”到“点中了谁”

位置输入只能告诉程序用户点击了哪个屏幕位置;拾取要回答的是这个位置对应哪个图形对象。

Object→Transform→Rasterize→Pixel
Pixel→Object ?
图形流水线天然是前向绘制:从对象到像素容易;从像素反推对象需要额外策略。
#sec-5-2

Picking(对象拾取)的三种常见思想

前向渲染从对象绘制到像素;反向拾取要从鼠标点击的屏幕像素锁定对应图形对象。

Method 01

包围盒法 (Bounding Box)

核心思想:先用简单的包围矩形/轴对齐包围盒 (AABB) 做碰撞判定。
特点:计算速度快、易于实现,适合二维与粗粒度筛选。

Method 02

颜色 ID 法 (Color-ID Offscreen)

核心思想:在后台帧缓冲给每个对象绘制唯一编码颜色,用 gl.readPixels 读取像素值识别对象。
特点:硬件加速、像素级精确,适合复杂几何。

Method 03

射线拾取法 (Ray Casting)

核心思想:从视点经过鼠标点击点穿过屏幕生成一条三维射线,计算射线与场景网格的求交。
特点:数学完备,适合三维空间精确选择。

#sec-5-3

拾取思想判断与随堂测验

思考与讨论

课堂判断

1. 二维画图软件选中图形时,优先考虑哪种方法?(包围盒法)
2. 三维场景中选中离相机最近的模型,又更像哪种方法?(射线拾取法)

单选60 s

如果希望用户点击屏幕后,程序知道“点中了哪个对象”,下列哪种方法最符合颜色ID拾取的思想?

A只读取 event.clientX 和 event.clientY
B为每个对象赋予唯一ID颜色,离屏绘制后用 gl.readPixels 读取点击处颜色
C改变 gl.viewport,让鼠标附近区域变大
D只要对象颜色好看,就能直接判断对象身份
参考答案:B。颜色ID拾取把“对象身份”编码进离屏颜色缓存,再通过点击位置处的像素颜色反查对象。
#sec-5-4

拓展:窗口事件与触摸手势

窗口Resize

窗口大小变化时,程序需要重新考虑Canvas大小、视口和宽高比。

gl.viewport

Touch / Gesture

触摸事件可提供多个触点,进一步组合成轻扫、捏合、旋转等手势。

touchstart / touchmove
这些内容的核心仍然相同:事件携带测量数据,程序将其转换为状态变化。
#sec-6-1

AI Bug Hunt:为什么点画偏了?

现象:鼠标点击Canvas中心附近,但点总是向右下偏移。

canvas.addEventListener("click", function(event) {
    const x = -1 + 2 * event.clientX / canvas.width;
    const y = -1 + 2 * (canvas.height - event.clientY) / canvas.height;
    addPoint(x, y);
});

先自己判断

这段代码默认了什么前提?

再问AI

要求AI只指出最可能的3个错误位置。

最后验证

打印 getBoundingClientRect() 与NDC结果。

#sec-6-2

本课总结:用户操作如何进入图形流水线

用户操作→Event→Measure→Mapping→State / Buffer→Render
Screen Position→Picking→Object→Modify / Render
今天解决的问题:鼠标位置如何转成图形数据,以及屏幕位置如何进一步关联到对象。

Exit Ticket

为什么程序不能仅凭 event.clientX 直接知道“点中了哪个对象”?请用一句话回答。

#sec-6-3

第三章整体关系

第三章把前两章的静态绘制程序扩展为真正的交互式图形应用。

第1章

理解图形流水线如何从场景生成图像。

第2章

学习用数据和Shader驱动GPU绘制。

第3章

让程序随时间和用户输入持续改变状态。

Pipeline→Data + Program→Interaction + Animation