AI 协作日志

学生 Web 实验作品平台

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

# 实验三 AI协作记录

> 教师参考代码包不包含虚构的AI协作过程。学生提交时需填写真实工具、提示词、独立测试与结论。

## AI协作1:矩阵和变换次序

- 我自己的预测:
  在Task B中,对于一个初始中心在C=(0.5, 0.25)的正方形,我预测:
  - `Rz(theta) · T(C)`(先平移再旋转):正方形会绕世界原点公转,因为顶点先被移到C点,再整体绕原点旋转,所以轨迹是一个以原点为圆心的圆。
  - `T(C) · Rz(theta)`(先旋转再平移):正方形会在原地自转,因为顶点先绕局部原点旋转,然后再整体平移到C点,所以中心位置不变,只是自身旋转。
  列向量规则下,右侧的矩阵先作用,所以乘法次序很重要。

- AI工具与原始提示词:
  "我在做WebGL矩阵变换实验,有个问题想请教:对于列向量来说,M = T * R 和 M = R * T 有什么区别?假设T是平移矩阵,R是旋转矩阵,一个初始中心在(0.5, 0.25)的正方形,分别用这两种组合,图形会怎么运动?请用'右侧矩阵先作用'的规则解释。"

- AI的建议:
  AI解释了列向量下矩阵乘法的作用顺序:对于 `P' = M · P`,M中**右侧**的矩阵先作用于P。
  - `T · R`:先R(旋转),再T(平移)→ 顶点先绕原点旋转,然后被平移到目标位置。结果是图形在目标位置原地自转。
  - `R · T`:先T(平移),再R(旋转)→ 顶点先被移到目标位置,然后整体绕世界原点旋转。结果是图形绕世界原点公转。
  AI还举了数值例子:取theta=90°,点P=(1,0,0,1)在C=(0.5,0.25,0)的正方形上的局部点(0.5+1, 0.25+0)即(1.5, 0.25),经过R·T后会变成(-0.25, 1.5),确实是绕原点转了90°。

- 本人计算、运行与证据:
  我手动计算了一个测试点验证:设正方形右下角局部坐标为(0.105, -0.105),中心C=(0.5, 0.25),theta=90°。
  - R·T(公转):先T得(0.605, 0.145),再Rz(90°)得(-0.145, 0.605)——点确实绕原点旋转了。
  - T·R(自转):先Rz(90°)得(0.105, 0.105),再T得(0.605, 0.355)——中心还是(0.5, 0.25),但点的相对位置变了,说明是自转。
  运行Task B页面,选择"正方形"+"旋转",分别切换"世界原点"和"对象自身中心"两种pivot模式:
  - world模式:正方形围绕画布中心做圆周运动,位置不断变化。
  - self模式:正方形在原地旋转,中心位置保持不动。
  两者视觉差异非常明显,验证了我的预测和AI的解释。

- 采纳或修正结论:
  完全采纳AI的解释。矩阵乘法次序在列向量系统中至关重要:右侧矩阵先作用。`R·T` 是先平移到位置再绕世界原点旋转(公转),`T·R` 是先绕局部原点旋转再平移到位置(自转)。代码中 `multiply(R_mat, T_mat)` 对应 `R·T`(公转),`multiply(T_mat, R_mat)` 对应 `T·R`(自转),与运行现象一致。

## AI协作2:实际故障排查

- 错误现象与重现步骤:
  在Task C实现暂停功能时,我发现一个问题:点击"暂停动画"后画面确实停止了,但等待几秒再点击"继续动画",图形会突然跳变到一个新的位置,而不是从暂停的位置继续运动。就好像暂停期间时间一直在后台偷偷流逝一样。
  重现步骤:
  1. 打开task-c.html,等待圆形缩放几圈
  2. 点击"暂停动画",默数3秒
  3. 点击"继续动画",观察圆形大小——它不是从暂停时的大小继续,而是跳到了一个完全不同的缩放状态

- 我先检查了什么:
  我先检查了暂停按钮的事件处理,确认 `this.running` 标志确实被正确切换了。然后检查了tick函数中的dt计算:

  ```javascript
  let dt = (ms - this.lastMs) / 1000.0;
  this.lastMs = ms;
  if(this.running && this.globalSpeed>0){
    for(const obj of this.objects){
      obj.elapsed += dt * this.globalSpeed * obj.speed;
    }
  }
  ```

  乍一看逻辑没问题:running为false时就不累加elapsed。但问题可能出在`this.lastMs`上——暂停期间lastMs没有更新,恢复后的第一帧dt会包含整个暂停的时长!虽然这一帧的elapsed不会累加(因为running刚变true的那一帧...等等,不对,点击继续后running变成true,然后下一帧tick进来时,lastMs还是暂停前的值,dt会非常大,但此时running已经是true了,所以会把暂停期间的全部时间一下子加上去!)

- AI工具与原始提示词:
  "我用requestAnimationFrame做WebGL动画,有暂停功能。暂停时设置running=false,然后在tick里判断如果running就累加elapsed。但是暂停几秒后恢复,动画会突然跳一下,好像暂停期间的时间都被加上了。我的dt是用(ms - lastMs)/1000算的,lastMs每次tick都更新。这是怎么回事?怎么修复?"

- AI建议的候选原因与测试:
  AI列出了几个可能的原因:
  1. **lastMs在暂停期间没有更新**:恢复后的第一帧,dt = 当前时间 - 暂停前的时间,包含了整个暂停时长。如果恢复时running已经是true,这一帧的巨大dt会被全部加到elapsed上,造成突跳。
  2. **多个rAF循环同时运行**:如果每次启动都调用requestAnimationFrame,可能有多个循环在跑。
  3. **暂停时lastMs置null**:有些实现暂停时把lastMs设为null,恢复后第一帧dt=0,但需要特殊处理。

  建议的修复方案:在暂停状态下,虽然不累加elapsed,但**仍然要更新lastMs**。这样恢复后的第一帧dt就只是正常的帧间隔,不会包含暂停时间。或者,在从暂停切换到运行的那一帧,重置lastMs为当前ms,跳过那一帧的dt累加。

- 实测结果、修复与复测:
  我先验证了AI的第一个猜测:在tick函数中加入console.log(dt),暂停3秒后恢复,果然看到dt约等于3.0秒,证实了问题。

  我采用的修复方案:无论是否暂停,每次tick都更新lastMs。因为即使暂停,rAF仍在运行(画面还在重绘),只是elapsed不增加。

  修改后的代码:
  ```javascript
  tick(ms){
    if(this.lastMs===null)this.lastMs=ms;
    let dt = (ms - this.lastMs) / 1000.0;
    this.lastMs = ms;  // 无论是否暂停都更新lastMs
    const MAX_DT = 0.1;
    if(dt > MAX_DT) dt = MAX_DT;  // 额外保护,防止后台标签切换导致大跳
    if(this.running && this.globalSpeed>0){
      for(const obj of this.objects){
        obj.elapsed += dt * this.globalSpeed * obj.speed;
      }
    }
    this.draw();
    // ...
  }
  ```

  另外我还加了MAX_DT限制,防止切换浏览器标签页后dt过大导致跳变。

  复测结果:暂停5秒后恢复,动画从暂停位置平滑继续,没有突跳。多次暂停/继续测试均正常。

  (补充:我还额外验证了——如果把lastMs的更新放到if(running)里面,问题就会复现,进一步确认了根因。)

## AI协作3:设计方案比较

- 我考虑的两种实现:
  在Task E中实现"保存PNG截图"功能时,我考虑了两种方案:

  **方案A:直接读取Canvas像素(toDataURL / toBlob)**
  - 思路:直接调用 `canvas.toBlob(callback, 'image/png')` 导出当前画面
  - 优点:代码简单,API原生支持
  - 担心:WebGL的默认行为是绘制后丢弃颜色缓冲(双缓冲机制),直接toBlob可能拿到黑色/空白画面

  **方案B:先readPixels再手动组装PNG**
  - 思路:用 `gl.readPixels()` 读取像素数据到ArrayBuffer,然后用JavaScript手动生成PNG文件
  - 优点:不依赖preserveDrawingBuffer,理论上更可靠
  - 缺点:代码复杂,需要自己实现PNG编码(或引入第三方库),性能可能更差

- AI工具与原始提示词:
  "我需要把WebGL画布保存为PNG图片。查资料说WebGL有双缓冲,直接canvas.toDataURL可能拿到黑屏。有两种方案:1)启用preserveDrawingBuffer后用toBlob;2)用gl.readPixels读像素再自己生成PNG。哪种更好?各自有什么坑?preserveDrawingBuffer会影响性能吗?"

- AI对比、取舍与原因:
  AI的分析总结:
  - **方案A(preserveDrawingBuffer + toBlob)**:
    - 优点:代码极简,浏览器原生处理PNG编码,效率高
    - 缺点:需要在getContext时设置`preserveDrawingBuffer: true`;可能轻微影响性能(禁用了某些缓冲优化),但对2D/简单3D场景几乎可忽略
    - 注意事项:toBlob是异步的;截图前最好主动draw一次确保画面最新
  
  - **方案B(readPixels + 手动PNG)**:
    - 优点:不需要preserveDrawingBuffer;更底层可控
    - 缺点:需要自己实现PNG编码(deflate压缩、CRC校验等),代码量巨大且容易出错;readPixels读到的是上下颠倒的(WebGL坐标原点在左下角),需要翻转;性能比原生toBlob差
  
  - **推荐**:对于学生实验这种场景,方案A完胜。代码简洁、正确性高、性能影响可忽略。只需要确保getContext时加上`preserveDrawingBuffer: true`,并且在截图前调用一次draw确保缓冲内容最新。

  AI还提醒了一个细节:readPixels读到的像素数据是BGRA顺序且上下翻转的,如果用方案B需要额外处理。

- 实际实现及核验:
  我采纳了方案A。检查graphics.js中发现已经设置了`preserveDrawingBuffer: true`:
  ```javascript
  this.gl=canvas.getContext('webgl2', {alpha:false, antialias:true, depth:true, preserveDrawingBuffer:true});
  ```

  我的实现代码:
  ```javascript
  savePNG(){
    this.draw(); // 强制重绘保证缓冲是最新画面
    const canvas = this.canvas;
    canvas.toBlob((blob)=>{
      if(!blob){
        this.setNotice("截图失败,blob为空");
        return;
      }
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      const now = new Date().getTime();
      a.href = url;
      a.download = `实验3_画布_${now}.png`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      this.setNotice("PNG截图已触发下载");
    },'image/png');
  }
  ```

  核验结果:
  1. 在Task E页面添加6个对象(4种形状都有)
  2. 点击"保存截图",浏览器下载PNG文件
  3. 打开PNG检查:尺寸800×600与Canvas内部尺寸一致;图形清晰、颜色正确、背景色正确;圆形没有被拉伸(宽高比正确)
  4. 修改画布为960×540后再次截图,PNG尺寸正确更新为960×540
  5. 清屏后截图,得到纯背景色图片,符合预期

  方案A在本实验中工作完美,代码简洁且可靠。AI的建议正确,preserveDrawingBuffer带来的性能影响在这种简单场景下完全感知不到。