第 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 事件的处理函数中,我们的伪代码是这样的:

csharp 复制代码
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 变量。
ini 复制代码
<!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()">
    <h1>交互的乐趣 - 响应用户输入</h1>
    <p>请在下方的画布中点击并拖动鼠标</p>
    <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) 负责大规模并行计算和渲染。

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

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

相关推荐
用户17592342150283 小时前
D3.js - 选择集方法(Selection Methods)
前端
chenjianzhong3 小时前
vite-plugin-legacy 实战解析
前端·vue.js·vite
前端赵哈哈3 小时前
Vue I18n 完整安装与使用指南
前端·vue.js·面试
秋田君3 小时前
Vue3 + VitePress 搭建部署组件库文档平台(结合 Element Plus 与 Arco Design Vue)—— 超详细图文教程
前端·vue.js·arco design
code_Bo3 小时前
前端使用snapdom报错问题
前端·javascript·vue.js
一壶浊酒..4 小时前
什么是AJAX
前端·javascript·ajax
fruge3654 小时前
从零到一:我在 Rokid Glasses 上“画”出一个远程协作系统
前端
BumBle4 小时前
高频扫码场景下的去重与接口调用方案
前端·javascript