第 7 篇:交互的乐趣 - 响应用户输入

我们已经点亮了世界,创造了一个有体积感的旋转立方体。它很漂亮,但它是一个"独裁者"------它只按照我们预设的 cubeRotation += deltaTime 指令来行动。用户,作为这个世界名义上的"主人",却没有任何发言权。

是时候改变这一切了。今天,我们将学习如何监听用户的操作(比如鼠标点击和拖动),并将这些操作转化为 3D 场景中的变化。我们将赋予用户"上帝之手",让他们能够随意拨弄我们的立方体。

沟通的桥梁:JavaScript 事件监听器

我们的 WebGL-Canvas 元素,本质上就是一个普通的 HTML 元素。这意味着,我们可以使用所有标准的 Web API 来监听发生在它身上的事件。

实现"点击并拖动来旋转"这个经典交互,我们需要三个核心事件:

  1. mousedown:当用户在 canvas 上按下鼠标按键时触发。我们用它来标记"开始拖动"。
  2. mouseup:当用户松开鼠标按键时触发。我们用它来标记"结束拖动"。
  3. mousemove:当用户在 canvas 上移动鼠标时触发。这是最关键的事件,我们将在这里计算鼠标的移动,并将其转化为旋转。
记住状态:交互中的变量

计算机不会像人一样"记得"鼠标是不是正按着。我们必须用变量来明确地告诉它当前的状态。对于拖动旋转,我们需要几个"记忆"变量:

  • isDragging = false;:一个布尔值("开关"),用来记录鼠标当前是否处于按下并拖动的状态。
  • previousMousePosition = { x: 0, y: 0 };:一个对象,用来存储上一次 mousemove 事件触发时鼠标的位置。没有"上一次"的位置,我们就无法计算出鼠标移动的距离和方向
  • currentRotation = { x: 0, y: 0 };:一个对象,用来累积旋转的角度。用户的每次拖动,都会在之前旋转的基础上继续增加或减少角度。
将 2D 鼠标移动映射到 3D 旋转

这是最核心的逻辑。当用户在 2D 屏幕上移动鼠标时,我们如何让 3D 物体做出符合直觉的反应?

一个简单而有效的方法是:

  • 鼠标的水平移动 (deltaX) 映射为 围绕 Y 轴(垂直轴)的旋转。想象一下用手左右拨动一个地球仪。
  • 鼠标的垂直移动 (deltaY) 映射为 围绕 X 轴(水平轴)的旋转。想象一下用手上下拨动一个地球仪。

mousemove 事件的处理函数中,我们的伪代码是这样的:

复制代码
function onMouseMove(event) {
  if (isDragging is false) {
    return; // 如果没按着鼠标,就什么都不做
  }

  // 1. 计算鼠标移动了多少
  const deltaX = event.clientX - previousMousePosition.x;
  const deltaY = event.clientY - previousMousePosition.y;

  // 2. 将移动距离累加到总旋转角度上
  //    (乘以一个小的系数,让旋转速度更合适)
  currentRotation.y += deltaX * 0.01;
  currentRotation.x += deltaY * 0.01;

  // 3. 更新"上一次"的位置,为下次移动做准备
  previousMousePosition.x = event.clientX;
  previousMousePosition.y = event.clientY;
}
着色器:完全不用动!

这是最美妙的部分。我们的顶点着色器和片元着色器一行代码都不需要修改

为什么?因为光照、投影、相机这些逻辑都没有变。我们改变的,仅仅是每一帧发送给 u_mvpMatrix 这个 uniform数据。交互完全是 CPU (JavaScript) 侧的逻辑,GPU (GLSL) 只负责接收最终指令并忠实地执行渲染。

JavaScript:主导一切

所有的改动都将发生在我们的 JavaScript 代码中。

  1. 移除自动旋转 :我们将删除 animate 函数中基于时间的 cubeRotation += deltaTime。旋转将完全由用户掌控。

  2. 设置状态变量 :在 main 函数中初始化我们上面讨论的 isDragging 等变量。

  3. 绑定事件监听器 :为 canvas 添加 mousedown, mouseup, mousemove 的监听。

  4. 修改矩阵计算 :在 drawScene 函数中,模型矩阵的旋转部分,不再使用时间变量,而是使用我们通过鼠标交互更新的 currentRotation 变量。

    <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>WebGL 教程 9:交互的乐趣</title> <style> body { background-color: #222; color: #eee; text-align: center; } canvas { background-color: #000; border: 1px solid #555; cursor: grab; } canvas:active { cursor: grabbing; } </style> </head> <body onload="main()">

    交互的乐趣 - 响应用户输入

    请在下方的画布中点击并拖动鼠标

    <canvas id="webgl-canvas" width="600" height="600"></canvas>
    复制代码
     <!-- Shader 代码与上一篇 (基础光照) 完全相同,无需修改 -->
     <script id="vertex-shader" type="x-shader/x-vertex">
         attribute vec4 a_position;
         attribute vec3 a_normal;
         uniform mat4 u_mvpMatrix;
         uniform mat4 u_normalMatrix;
         varying vec3 v_normal;
         void main() {
             gl_Position = u_mvpMatrix * a_position;
             v_normal = (u_normalMatrix * vec4(a_normal, 0.0)).xyz;
         }
     </script>
     <script id="fragment-shader" type="x-shader/x-fragment">
         precision mediump float;
         varying vec3 v_normal;
         uniform vec3 u_lightDirection;
         uniform vec3 u_lightColor;
         uniform vec3 u_ambientLight;
         void main() {
             vec3 normal = normalize(v_normal);
             float light_factor = max(dot(normal, -normalize(u_lightDirection)), 0.0);
             vec3 diffuse = u_lightColor * light_factor;
             vec3 finalColor = u_ambientLight + diffuse;
             gl_FragColor = vec4(finalColor, 1.0);
         }
     </script>
     
     <script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"></script>
    
     <script>
         function main() {
             const canvas = document.getElementById('webgl-canvas');
             const gl = canvas.getContext('webgl');
             if (!gl) { alert('WebGL not supported!'); return; }
    
             const program = createProgram(gl, 
                 document.getElementById('vertex-shader').text,
                 document.getElementById('fragment-shader').text);
    
             const locations = { /* ... locations ... */ };
              // (Code is the same as before, omitted for brevity)
             locations.position=gl.getAttribLocation(program,"a_position"); locations.normal=gl.getAttribLocation(program,"a_normal"); locations.mvpMatrix=gl.getUniformLocation(program,"u_mvpMatrix"); locations.normalMatrix=gl.getUniformLocation(program,"u_normalMatrix"); locations.lightDirection=gl.getUniformLocation(program,"u_lightDirection"); locations.lightColor=gl.getUniformLocation(program,"u_lightColor"); locations.ambientLight=gl.getUniformLocation(program,"u_ambientLight");
    
             const buffer = initBuffers(gl);
    
             // --- 新增: 交互状态变量 ---
             let isDragging = false;
             let previousMousePosition = { x: 0, y: 0 };
             let rotation = { x: 0.5, y: -0.5 }; // 初始旋转
    
             // --- 新增: 事件监听 ---
             canvas.addEventListener('mousedown', (e) => {
                 isDragging = true;
                 previousMousePosition.x = e.clientX;
                 previousMousePosition.y = e.clientY;
             });
             canvas.addEventListener('mouseup', () => {
                 isDragging = false;
             });
             canvas.addEventListener('mousemove', (e) => {
                 if (!isDragging) return;
                 const deltaX = e.clientX - previousMousePosition.x;
                 const deltaY = e.clientY - previousMousePosition.y;
    
                 rotation.y += deltaX * 0.01;
                 rotation.x += deltaY * 0.01;
    
                 previousMousePosition.x = e.clientX;
                 previousMousePosition.y = e.clientY;
             });
    
             gl.useProgram(program);
             gl.enable(gl.DEPTH_TEST);
             gl.depthFunc(gl.LEQUAL);
    
             function animate() {
                 // 不再需要基于时间的旋转
                 drawScene(gl, locations, buffer, rotation);
                 requestAnimationFrame(animate);
             }
             requestAnimationFrame(animate);
         }
         
         // drawScene 函数现在接收 rotation 对象,而不是单个旋转值
         function drawScene(gl, locations, buffers, rotation) {
             gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
             gl.clearColor(0.1, 0.1, 0.1, 1.0);
             gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    
             const projectionMatrix = mat4.create();
             mat4.perspective(projectionMatrix, 45 * Math.PI / 180, gl.canvas.clientWidth / gl.canvas.clientHeight, 0.1, 100.0);
    
             const viewMatrix = mat4.create();
             mat4.lookAt(viewMatrix, [0, 0, 5], [0, 0, 0], [0, 1, 0]);
    
             const modelMatrix = mat4.create();
             // 使用来自用户输入的旋转
             mat4.rotate(modelMatrix, modelMatrix, rotation.x, [1, 0, 0]); // 绕 X 轴
             mat4.rotate(modelMatrix, modelMatrix, rotation.y, [0, 1, 0]); // 绕 Y 轴
             
             const mvpMatrix = mat4.create();
             mat4.multiply(mvpMatrix, projectionMatrix, viewMatrix);
             mat4.multiply(mvpMatrix, mvpMatrix, modelMatrix);
             
             const normalMatrix = mat4.create();
             mat4.invert(normalMatrix, modelMatrix);
             mat4.transpose(normalMatrix, normalMatrix);
    
             gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
             gl.vertexAttribPointer(locations.position, 3, gl.FLOAT, false, 0, 0);
             gl.enableVertexAttribArray(locations.position);
             
             gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);
             gl.vertexAttribPointer(locations.normal, 3, gl.FLOAT, false, 0, 0);
             gl.enableVertexAttribArray(locations.normal);
             
             gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.indices);
    
             gl.uniformMatrix4fv(locations.mvpMatrix, false, mvpMatrix);
             gl.uniformMatrix4fv(locations.normalMatrix, false, normalMatrix);
             gl.uniform3fv(locations.lightDirection, [0.5, 0.7, 1.0]);
             gl.uniform3fv(locations.lightColor, [1.0, 1.0, 1.0]);
             gl.uniform3fv(locations.ambientLight, [0.2, 0.2, 0.2]);
    
             gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);
         }
    
         function initBuffers(gl) {
             // 为每个面定义独立的顶点,这样可以有正确的法向量
             const positions = [
                 // 前面
                 -1, -1,  1,
                  1, -1,  1,
                  1,  1,  1,
                 -1,  1,  1,
                 
                 // 后面
                 -1, -1, -1,
                 -1,  1, -1,
                  1,  1, -1,
                  1, -1, -1,
                 
                 // 顶面
                 -1,  1, -1,
                 -1,  1,  1,
                  1,  1,  1,
                  1,  1, -1,
                 
                 // 底面
                 -1, -1, -1,
                  1, -1, -1,
                  1, -1,  1,
                 -1, -1,  1,
                 
                 // 右面
                  1, -1, -1,
                  1,  1, -1,
                  1,  1,  1,
                  1, -1,  1,
                 
                 // 左面
                 -1, -1, -1,
                 -1, -1,  1,
                 -1,  1,  1,
                 -1,  1, -1,
             ];
    
             const positionBuffer = gl.createBuffer();
             gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
             gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
    
             const normalData = [
                 [0, 0, 1],   // 前面
                 [0, 0, -1],  // 后面
                 [0, 1, 0],   // 顶面
                 [0, -1, 0],  // 底面
                 [1, 0, 0],   // 右面
                 [-1, 0, 0],  // 左面
             ];
             let normals = [];
             for(let i = 0; i < 6; i++) {
                 for(let j = 0; j < 4; j++) {
                     normals.push(...normalData[i]);
                 }
             }
             const normalBuffer = gl.createBuffer();
             gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
             gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(normals), gl.STATIC_DRAW);
    
             const indices = new Uint16Array([
                 0,  1,  2,    0,  2,  3,    // 前面
                 4,  5,  6,    4,  6,  7,    // 后面
                 8,  9,  10,   8,  10, 11,   // 顶面
                 12, 13, 14,   12, 14, 15,   // 底面
                 16, 17, 18,   16, 18, 19,   // 右面
                 20, 21, 22,   20, 22, 23    // 左面
             ]);
             const indexBuffer = gl.createBuffer();
             gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
             gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
    
             return {
                 position: positionBuffer,
                 normal: normalBuffer,
                 indices: indexBuffer
             };
         }
         function createProgram(gl, vs, fs) { /* ... same as previous ... */ function c(t,s){const h=gl.createShader(t);gl.shaderSource(h,s);gl.compileShader(h);if(!gl.getShaderParameter(h,gl.COMPILE_STATUS)){console.error(gl.getShaderInfoLog(h));gl.deleteShader(h);return null}return h}const p=gl.createProgram();gl.attachShader(p,c(gl.VERTEX_SHADER,vs));gl.attachShader(p,c(gl.FRAGMENT_SHADER,fs));gl.linkProgram(p);if(!gl.getProgramParameter(p,gl.LINK_STATUS)){console.error(gl.getProgramInfoLog(p));gl.deleteProgram(p);return null}return p; }
     </script>
    </body> </html>
总结与展望

现在,运行代码,你的立方体应该会静静地等待着。但当你用鼠标在它身上按下并拖动时,它会立刻响应你的操作,跟随你的鼠标轨迹进行旋转。我们成功地建立了一条从用户到 3D 世界的沟通渠道!

今天,我们完成了非常重要的一步:

  • 学会了使用 JavaScript 事件监听器 来捕捉用户输入。
  • 理解了通过状态变量来管理和累积交互状态的重要性。
  • 掌握了将 2D 屏幕输入映射为 3D 空间变换的基本思路。
  • 再次印证了 CPU 和 GPU 的职责分离:CPU (JS) 负责逻辑和交互,GPU (GLSL) 负责大规模并行计算和渲染。

我们的世界不仅看得见、有光影,现在更能"摸得着"了。这为我们未来的探索铺平了道路,比如用键盘控制相机在场景中漫游,或者实现点击拾取场景中的物体。

在下一篇中,我们将回到光照的话题,完成我们之前留下的一个悬念。我们将为材质添加"高光",让物体表面能够反射出耀眼的光斑,从而区分出金属和塑料质感。

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax