我们已经点亮了世界,创造了一个有体积感的旋转立方体。它很漂亮,但它是一个"独裁者"------它只按照我们预设的 cubeRotation += deltaTime
指令来行动。用户,作为这个世界名义上的"主人",却没有任何发言权。
是时候改变这一切了。今天,我们将学习如何监听用户的操作(比如鼠标点击和拖动),并将这些操作转化为 3D 场景中的变化。我们将赋予用户"上帝之手",让他们能够随意拨弄我们的立方体。
沟通的桥梁:JavaScript 事件监听器
我们的 WebGL-Canvas 元素,本质上就是一个普通的 HTML 元素。这意味着,我们可以使用所有标准的 Web API 来监听发生在它身上的事件。
实现"点击并拖动来旋转"这个经典交互,我们需要三个核心事件:
mousedown
:当用户在 canvas 上按下鼠标按键时触发。我们用它来标记"开始拖动"。mouseup
:当用户松开鼠标按键时触发。我们用它来标记"结束拖动"。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 代码中。
-
移除自动旋转 :我们将删除
animate
函数中基于时间的cubeRotation += deltaTime
。旋转将完全由用户掌控。 -
设置状态变量 :在
main
函数中初始化我们上面讨论的isDragging
等变量。 -
绑定事件监听器 :为 canvas 添加
mousedown
,mouseup
,mousemove
的监听。 -
修改矩阵计算 :在
<!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()">drawScene
函数中,模型矩阵的旋转部分,不再使用时间变量,而是使用我们通过鼠标交互更新的currentRotation
变量。交互的乐趣 - 响应用户输入
请在下方的画布中点击并拖动鼠标
<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>
总结与展望
现在,运行代码,你的立方体应该会静静地等待着。但当你用鼠标在它身上按下并拖动时,它会立刻响应你的操作,跟随你的鼠标轨迹进行旋转。我们成功地建立了一条从用户到 3D 世界的沟通渠道!
今天,我们完成了非常重要的一步:
- 学会了使用 JavaScript 事件监听器 来捕捉用户输入。
- 理解了通过状态变量来管理和累积交互状态的重要性。
- 掌握了将 2D 屏幕输入映射为 3D 空间变换的基本思路。
- 再次印证了 CPU 和 GPU 的职责分离:CPU (JS) 负责逻辑和交互,GPU (GLSL) 负责大规模并行计算和渲染。
我们的世界不仅看得见、有光影,现在更能"摸得着"了。这为我们未来的探索铺平了道路,比如用键盘控制相机在场景中漫游,或者实现点击拾取场景中的物体。
在下一篇中,我们将回到光照的话题,完成我们之前留下的一个悬念。我们将为材质添加"高光",让物体表面能够反射出耀眼的光斑,从而区分出金属和塑料质感。