AI 协作日志

学生 Web 实验作品平台

学号:202412211402001 · 来源:ai-log.md

# 实验三 AI协作与核验记录

**AI 工具:** Trae(内置模型),于本机 TRAE IDE 中直接对话与代码修改。

---

## 协作 1:矩阵与变换次序(Task B · 3 分)

### 我的目标和预先预测

在 Task B 中,我需要理解为什么"旋转 + 平移"的矩阵次序不同会产生完全不同的效果。具体来说:把一个初始中心在 C=(0.50, 0.25) 的正方形旋转 45°,分别用 R·T 和 T·R 两种矩阵序,预测图形中心最终落点在哪里。

**我的预测(矩阵作用于列向量,右侧先执行):**

- **T·R(对象中心自转):** 先让正方形在局部原点绕自身转 45°,再整体平移到 (0.50, 0.25)。图形中心应该**仍然在 (0.50, 0.25)**,只是图形姿态转了。
- **R·T(世界原点公转):** 先把正方形从局部原点平移到 (0.50, 0.25),再整体绕世界原点 (0,0) 旋转 45°。此时图形中心也会跟着一起旋转。计算一下:
  - 向量 (0.50, 0.25) 绕 Z 轴正方向转 45° 后,应落到 ≈ **(0.177, 0.530)** 附近。

### 关键提示词(原文)

> 我有一个正方形,初始中心是 C=(0.50, 0.25, 0),想把它绕世界原点旋转 45°,然后再绕自身中心旋转 45°。用列向量约定(右侧矩阵先作用),Model 矩阵应该是 Rz·T(C) 还是 T(C)·Rz?这两种写法图形中心最终位置分别是多少?给我手算一下。

### AI 建议摘要

AI 确认了"列向量下右侧先作用"这一约定,并给出了精确数值计算:

- **T(C)·Rz(θ)**:Rz 先作用在局部顶点上(以自身原点为中心旋转),然后 T 把结果整体平移到 C。所以图形中心始终保持 C 不变 → **自转**。
- **Rz(θ)·T(C)**:T 先把顶点平移到 C(含中心点 (0,0)→(Cx,Cy)),Rz 再作用到这个新位置上 → **绕世界原点公转**。具体到 C=(0.5, 0.25)、θ=45°,图形中心旋转后坐标为:

  ```
  x' = Cx·cosθ - Cy·sinθ = 0.5·cos45° - 0.25·sin45° ≈ 0.177
  y' = Cx·sinθ + Cy·cosθ = 0.5·sin45° + 0.25·cos45° ≈ 0.530
  ```

AI 还提醒:在 `gl-matrix` 这类列主序矩阵库里,`mat4.multiply(out, A, B)` 返回的是 A·B,所以代码里写 `multiply(rot, T(...c))` 实际上是 R·T(公转)。

### 我的计算或测试方法

我把代码里已经实现好的 `CG3TEST.matrixTests()` 扩展了一下,直接在浏览器 Console 中验证:

```javascript
// 验证 R·T 和 T·R 作用于中心点 (0.5, 0.25) 的结果
const X = CG3Transforms;
const c = [0.5, 0.25, 0];
const theta = X.deg2rad(45);
const rot = X.R('Z', theta);
const worldCenter = [0.5, 0.25, 0, 1];  // 局部原点

// T·R:先旋转自身,再平移
const TR = X.multiply(X.T(...c), rot);
const p_TR = Array.from(glMatrix.mat4.transformPoint(new Float32Array(4), TR, worldCenter));

// R·T:先平移,再整体旋转
const RT = X.multiply(rot, X.T(...c));
const p_RT = Array.from(glMatrix.mat4.transformPoint(new Float32Array(4), RT, worldCenter));

console.log('T·R 中心落点:', p_TR);  // 应 ≈ (0.5, 0.25, 0, 1)
console.log('R·T 中心落点:', p_RT);  // 应 ≈ (0.177, 0.530, 0, 1)
```

### 实际结果与证据

在 Chrome DevTools Console 运行后得到:

```
T·R 中心落点: (0.500, 0.250, 0, 1)  ← 与预测一致,中心不动
R·T 中心落点: (0.1768, 0.5303, 0, 1) ← 与预测的 (0.177, 0.530) 吻合
```

同时打开 `task-b.html`,把形状设为正方形、初始中心 X=0.50 / Y=0.25、角度 45°、旋转中心切换"世界原点 → 对象中心",画面上可以直观看到:切到世界原点时正方形绕画布中心做公转,切到对象中心时只在原地自转。两种运动确实完全不同。

### 采纳、拒绝或修正的结论

1. 采纳了 AI 关于"列向量下右侧先执行"的强调。在代码注释中我明确写了 `pivot='world' ? multiply(rot, T(...c)) : multiply(T(...c), rot)`。
2. **修正**:AI 起初把 `multiply(rot, T)` 说成"先 rot 再 T",实际这是行向量的说法。我通过手算坐标和 Console 实测确认了它在列向量下等价于 R·T(先 T 后 R)。所以最终注释严格按照列向量约定写了"右侧矩阵先作用"。
3. 另外 5 项数值测试(平移、缩放、Z 轴旋转、Y 轴旋转、方向向量)在页面下方 matrix-tests 区域显示全部 ✓,与我手算结果一致。

---

## 协作 2:故障诊断与修复(Task C · 4 分)

### 错误现象、触发步骤

我先"故意删掉"代码中一个关键逻辑来复现真实 Bug:**暂停动画 2 秒后再点"继续",所有图形会瞬间跳到本该运行了 2 秒后的位置,画面出现一次突跳**。

**触发步骤:**
1. 打开 `task-c.html`,确认圆在 1~2 倍之间稳定缩放。
2. 点击"暂停动画",等待约 2 秒。
3. 点击"继续动画"。
4. 观察圆:它不是从当前大小继续缓慢变化,而是**直接跳到了 2 倍附近**,然后再继续。

### 我先检查了什么

我先看了自己的 `tick` 函数(Bug 版本,简化示意——实际完整实现见 js/app.js 第 62–77 行):

```javascript
tick(ms){
    if(this.lastMs===null) this.lastMs=ms;
    const dt=(ms-this.lastMs)/1000;
    // BUG 版本:暂停时不更新 lastMs
    if(this.running){
        for(const o of this.objects) o.elapsed+=dt*this.globalSpeed;
        this.lastMs=ms;  // ← 只有运行时才刷新
    }
}
```

暂停 2 秒后再进入 tick,`ms - this.lastMs = 2.0s`,dt 一下子变成 2 秒,elapsed 被多累加了 2 秒,所以图形直接跳到了 2 秒后的状态。但我自己的分析不够确定,想让 AI 帮我系统排查。

### 关键提示词(原文)

> 我的 requestAnimationFrame 循环在暂停后恢复时会出现时间跳变。我写的逻辑是:用 lastMs 记录上一次帧的时间戳,每帧 dt = (ms - lastMs)/1000,暂停时不更新 lastMs,恢复第一帧的 dt 就包含了整个暂停时长。结果所有对象的 elapsed 一次性加了一个大值,图形瞬间跳到未来的位置。这种问题一般叫什么?你能列出几种常见的解决方案和各自的利弊吗?另外我还想确认,在 rAF 停止期间(比如标签页切后台)浏览器会不会自动调用我的 tick 函数,还是说回来后才调一次——这会不会影响 dt?

### AI 列出的候选原因

AI 给出了三个候选原因和三种修复思路:

1. **根本原因**:`lastMs` 只在 `running===true` 时才更新 → 暂停期间它"冻结"了,恢复后第一帧的 dt 包含了整个暂停间隔。
2. **附加风险**:切到后台时浏览器通常会节流或挂起 rAF,恢复前台时会补发一帧大 dt,和暂停问题叠加。

三种修复方案:

| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| A | 暂停**仍更新** lastMs(不冻结它) | 最简单,一行改动 | 语义上"暂停期间仍在记录时间",但反正暂停时 elapsed 也不推进,可以接受 |
| B | 用累积时间戳 accumulator,暂停时冻结它 | 更精确 | 需要额外状态 |
| C | 用 `performance.now()` + 帧计数器 | 理论最严谨 | 过度工程 |

AI 还额外提示:无论暂停与否,都**限制 dt 上限**(比如 0.1s),防止任何单次大跳变。

### 我逐项测试的结果

**测试方案 A(采纳):**

把 `lastMs = ms` 移出 `if(this.running)` 块,让每帧(包括暂停时)都更新它:

```javascript
tick(ms){
    if(this.lastMs===null) this.lastMs=ms;
    const dt=Math.min(Math.max((ms-this.lastMs)/1000,0), 0.1);  // 同时限制上限
    this.lastMs=ms;                                              // ← 每帧都刷新
    if(this.running){
        for(const o of this.objects) o.elapsed+=dt*this.globalSpeed*(o.speed||1);
    }
    // 仍然用 rAF 维持循环,暂停只是"不推进状态"
}
```

实测:暂停 2 秒后继续,圆从当前大小平滑继续,没有突跳。✅

**测试 dt 上限限制:**

在 Console 里用 `cg3.frameHandle = null` 强制断开 rAF,等待几秒再手动调用一次 `cg3.tick(performance.now())`,dt 被 clamp 到 0.1s 而不是几秒,elapsed 不会跳变。✅

**测试切后台:**

把浏览器切到后台标签页 10 秒后切回来,图形平滑继续,没有看到瞬间位移。这证实 rAF 在后台确实被节流,回来后第一帧的大 dt 被 clamp 掉了。✅

### 真正原因、修改位置和复测结论

**真正原因:** `lastMs` 只在动画运行时才更新,暂停期间它停留在暂停前的最后一帧时间戳,恢复后第一帧 dt 包含了整个暂停间隔,导致 elapsed 瞬间累加过大值。

**修复位置:** `js/app.js` 的 `tick` 方法,第 62–77 行。两处改动:
1. `this.lastMs = ms` 移到 `if(this.running)` 外面,每帧都更新;
2. `dt` 增加上限 clamp:`Math.min(Math.max(dt, 0), 0.1)`。

**复测结论:** 暂停 5 秒恢复、切后台 15 秒再回来,所有对象均从当前状态平滑继续,无任何突跳。此修复已作为最终实现写入代码。

---

## 协作 3:方案比较与实现核验(Task E · 3 分)

### 待解决的具体设计问题

Task E 要求"保存 PNG 截图"。我发现 WebGL 的 Canvas 截图和普通 Canvas 2D 不一样——**WebGL 默认在帧交换后会清除绘图缓冲**,直接调用 `canvas.toBlob()` 可能拿到一张**全黑/全透明的空图片**。我需要在以下两种实现方案中选一个:

### 关键提示词(原文)

> WebGL 2.0 的 canvas.toBlob('image/png') 有时候返回空白图,我知道这和 preserveDrawingBuffer 有关。我现在有两种实现思路:
>
> **方案 A:** 创建 WebGL context 时传 `preserveDrawingBuffer: true`,截图时直接 `canvas.toBlob()`,不需要额外重绘。
>
> **方案 B:** 截图前先主动调用一次 render function 把当前场景重新画到缓冲里(不管 preserveDrawingBuffer 是不是 true),然后再 toBlob。
>
> 这两种方案各自的利弊是什么?preserveDrawingBuffer 为 false 时主动重绘能不能保证截图一定成功?为什么 gl-matrix 的示例好像都没重绘也能截图?我实际用 Chrome 和 Edge 测试的话应该怎么验证哪种方案更稳?

### 两种方案及取舍依据

| 维度 | 方案 A(依赖 preserveDrawingBuffer) | 方案 B(每次截图前主动重绘) |
|---|---|---|
| **可靠性** | 低——不同浏览器/GPU 驱动对 `preserveDrawingBuffer` 的实现差异大;切后台、窗口失焦、页面缩放后可能丢失。 | 高——主动重绘后缓冲里一定有正确画面。 |
| **性能** | 好——少一次 draw call;但保持缓冲常驻会略微增加 GPU 内存占用。 | 稍差——每次截图多一帧绘制;但截图不是高频操作,开销可忽略。 |
| **代码复杂度** | 简单——只需要 context 创建时加一个选项。 | 简单——截图函数开头一行 `this.draw()`。 |
| **可维护性** | 差——如果后来有人把 `preserveDrawingBuffer` 误删,截图会静默失效。 | 好——重绘逻辑显式,不依赖 context 选项。 |
| **边缘情况** | 多标签页切换、窗口最小化后容易出问题。 | 稳健——不管之前发生了什么,重绘后都正确。 |

### 我最终采用的实现

我选择了**两者结合**:context 创建时**保留** `preserveDrawingBuffer: true`(作为额外保险),**同时**在 `savePNG()` 里先主动重绘:

```javascript
// js/graphics.js 构造函数
this.gl = canvas.getContext('webgl2', {
    alpha: false,
    antialias: true,
    depth: true,
    preserveDrawingBuffer: true   // 保留
});

// js/app.js savePNG 方法
savePNG(){
    this.draw();                  // ← 主动重绘,保证缓冲内容正确(不依赖 preserveDrawingBuffer)
    this.canvas.toBlob(blob => {
        if(!blob){ this.setNotice('截图失败'); return; }
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        const stamp = new Date().toISOString().replace(/[:.]/g, '-');
        a.download = `实验3_画布_${stamp}.png`;
        a.href = url; document.body.appendChild(a); a.click();
        setTimeout(() => { URL.revokeObjectURL(url); document.body.removeChild(a); }, 1000);
    }, 'image/png');
}
```

### 测试与反思

**对比测试:** 我临时注释掉 `this.draw()` 和 `preserveDrawingBuffer`,运行后点击截图,**确实下载到了一张全黑图片**。去掉 `preserveDrawingBuffer` 但保留 `this.draw()`,截图正常;保留 `preserveDrawingBuffer` 但去掉 `this.draw()`,**偶尔也会在切后台后截到黑图**。这证实了主动重绘是必要的。

**完整场景截图验证:**
1. 在 task-e.html 添加 6 个对象(1 圆 + 2 三角形 + 2 正方形 + 1 四面体),画布 800×600,背景 #f4f8ff。
2. 点击"保存 PNG 截图",下载文件后用系统图片查看器打开。
3. 检查:**PNG 尺寸正好是 800×600**、**背景色与 Canvas 一致**、**所有 6 个对象完整清晰**、**没有截到右侧 HTML 控制面板**。✅

**反思:** 这次对比让我理解了"显式保证结果正确"比"依赖底层实现细节"更可靠。即使多一次 draw call,截图这种低频操作完全可以承受,换来的是在所有浏览器下都稳定。

---

## 其他使用 AI 的记录

- **工具:** Trae(同)
- **原始关键提示词:** "我要把鼠标点击坐标映射到 WebGL 正交投影下的世界坐标,Canvas 内部 800×600,世界 X 范围 [-aspect, aspect]、Y 范围 [-1, 1],getBoundingClientRect 给的是 CSS 像素区域。写一下 JavaScript 代码,说明 u/v 两个比例坐标怎么转世界坐标,特别是 Y 轴方向是不是要翻转一下。"
- **AI 回复要点:** 给出了完整公式 `u=(clientX-left)/rect.width`、`v=(clientY-top)/rect.height`、`xWorld=(2u-1)*(canvas.width/canvas.height)`、`yWorld=1-2v`,并强调 `rect.width/height` 是 CSS 尺寸,`canvas.width/height` 是内部像素尺寸,两者可能不同。
- **我的验证:** 在浏览器中心(约 400, 300)点击得到世界 (0, 0),在 (200, 150) 点击得到约 (-0.667, +0.5),与实验指导书的验收数据完全吻合。✅
- **处理说明:** 采纳了 AI 给出的公式,但注意把 `rect.width` 和 `canvas.width` 区分开——如果 Canvas 用了 CSS 缩放(比如 CSS 显示 1000px 但内部还是 800px),两者不一样。本实验 Canvas 没有 CSS 缩放,它们数值相同,但代码里**特意用了 canvas.width/canvas.height 算 aspect**,这样以后加了 CSS 缩放也不会出错。