我们已经点亮了世界,创造了一个有体积感的旋转立方体。它很漂亮,但它是一个"独裁者"------它只按照我们预设的 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
事件的处理函数中,我们的伪代码是这样的:
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 代码中。
- 移除自动旋转 :我们将删除
animate
函数中基于时间的cubeRotation += deltaTime
。旋转将完全由用户掌控。 - 设置状态变量 :在
main
函数中初始化我们上面讨论的isDragging
等变量。 - 绑定事件监听器 :为 canvas 添加
mousedown
,mouseup
,mousemove
的监听。 - 修改矩阵计算 :在
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) 负责大规模并行计算和渲染。
我们的世界不仅看得见、有光影,现在更能"摸得着"了。这为我们未来的探索铺平了道路,比如用键盘控制相机在场景中漫游,或者实现点击拾取场景中的物体。
在下一篇中,我们将回到光照的话题,完成我们之前留下的一个悬念。我们将为材质添加"高光",让物体表面能够反射出耀眼的光斑,从而区分出金属和塑料质感。