Web特效010—坐标系:网页上的一个点,为什么到了 WebGL 里就变了?
在网页上,我们经常把鼠标坐标、元素位置和 Canvas 坐标混在一起使用:鼠标明明点在图形上,命中检测却偏了一截;画布明明设置成了 800 像素,WebGL 中的顶点却不能直接写成 800;同一个点经过 CSS 缩放、设备像素比和 GPU 变换之后,为什么会出现在完全不同的位置?这些问题的根源,往往不是公式太难,而是页面中同时存在多套坐标系。本文将从一个网页上的普通点出发,依次认识 DOM 坐标、Canvas 像素坐标、Canvas 2D 坐标、WebGL 标准化设备坐标以及裁剪空间,解释它们各自的原点、方向、范围和单位,并通过坐标转换公式说明一个点如何从鼠标事件进入画布,再进入 GPU 渲染管线。理解这些坐标系之间的转换关系后,后续无论是做粒子跟随、鼠标交互、三维模型拾取,还是制作水流、电流等网页特效,都能知道坐标到底在哪一步发生了变化。
一、先别急着画:网页里为什么同时存在这么多套坐标系
1. 同一个“点”,在不同系统里可能有不同答案
做网页交互动画时,我们经常会遇到一个看起来很奇怪的问题:鼠标明明点在圆上,程序却判断没有点中;画布在页面上显示得很大,WebGL 顶点却不能直接写成屏幕像素;一个元素向右移动了 100 像素,在另一个绘制系统里却完全不是同样的数值。
问题通常不在鼠标事件或绘图 API 本身,而在于我们把不同坐标系中的数字混用了。
用户点击的位置:浏览器窗口坐标
元素所在的位置:页面布局坐标
Canvas 内部的位置:画布像素坐标
WebGL 顶点的位置:标准化设备坐标
GPU 处理的位置:裁剪空间与其他中间空间
它们描述的可能是屏幕上同一个位置,但原点、方向、范围和单位并不相同。就像同一栋楼可以用“距离市中心 5 公里”“位于地图横坐标 300”“房间号 502”来描述,每一种说法都有效,只是参考系不同。
坐标不是一个固定的数字,而是“数字 + 所属坐标系”的组合。
如果不先说清楚这个数字属于哪套坐标系,后面的计算即使公式完全正确,结果也可能错位。
2. 网页交互为什么特别容易遇到坐标错位
普通 DOM 页面已经替我们处理了大量布局工作。浏览器知道元素在页面中的位置,也能把鼠标事件分发给对应的元素。但 Canvas 和 WebGL 通常是一块“自己负责绘制的区域”,浏览器不会自动知道画布里面的某个圆、粒子或三角形是什么。
例如,鼠标事件提供的坐标来自页面或视口,而 Canvas 绘制使用的是画布内部坐标:
canvas.addEventListener("pointermove", (event) => {
console.log(event.clientX, event.clientY);
// 这是相对于浏览器视口的坐标
});
如果 Canvas 被 CSS 缩放过,或者页面滚动过,event.clientX 和 event.clientY 就不能直接拿来当作 Canvas 内部的绘制坐标。必须先减去画布在视口中的位置,再根据显示尺寸和内部像素尺寸进行比例换算。
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
这段代码只完成了第一步:把视口坐标变成 Canvas 显示区域内的 CSS 坐标。后面还可能需要考虑设备像素比、Canvas 内部尺寸和 WebGL 坐标范围。
3. 一次绘制为什么要经过好几套空间
在 Canvas 2D 中,我们常常可以直接使用像素坐标:左上角是 (0, 0),向右是 x 增大,向下是 y 增大。这个坐标系和网页布局比较接近,所以入门时感觉很直观。
但 WebGL 面向的是 GPU 渲染管线,它通常使用标准化设备坐标(NDC)表达顶点位置,水平和垂直范围大致都是 -1 到 1:
Canvas 2D:
左上角附近是 (0, 0)
向右、向下增加
WebGL NDC:
中心附近是 (0, 0)
向右增加 x
向上增加 y
范围通常是 -1 到 1
所以,一个屏幕宽度为 800 的画布,不能直接把 x = 400 写成 WebGL 顶点坐标。需要先把像素坐标映射到 NDC:
const ndcX = (pixelX / canvas.width) * 2 - 1;
const ndcY = 1 - (pixelY / canvas.height) * 2;
这里的 x 映射把左边的 0 转成 -1,把右边的 canvas.width 转成 1;y 则需要翻转方向,因为 Canvas 像素坐标通常向下为正,而 WebGL 的 NDC 通常向上为正。
4. 坐标转换不是多余步骤,而是让不同系统能够对话
把不同坐标系区分开,并不意味着网页特效必须变得复杂。恰恰相反,一旦知道每个坐标属于哪套空间,就能把转换写成清晰、可复用的函数。
flowchart TD
A[鼠标或触摸坐标] --> B[视口坐标]
B --> C[Canvas 显示区域坐标]
C --> D[Canvas 内部像素坐标]
D --> E[WebGL NDC 坐标]
E --> F[顶点着色器中的空间变换]
F --> G[最终屏幕位置]
例如,粒子跟随鼠标时,粒子系统可以统一使用自己的世界坐标;只有在接收鼠标输入或提交 GPU 绘制时,才分别执行“输入坐标转世界坐标”和“世界坐标转渲染坐标”。这样,业务数据不会被某一种绘图 API 绑死。
后面的内容会分别展开 DOM、Canvas 和 WebGL 中最重要的坐标系。现在先记住一个原则:看到任何位置数字时,第一反应应该是问一句——它相对于谁?原点在哪里?向哪个方向增长?单位是什么?
二、DOM 和鼠标坐标:用户点到的地方,浏览器是怎么告诉你的
上一节我们说到,网页里的“一个点”并不只有一种坐标。用户点击屏幕时,浏览器首先拿到的是鼠标事件坐标;而我们真正想操作的元素,可能是一个按钮、一张图片,也可能是 Canvas 里的某个交互对象。它们处在不同的坐标体系里,所以不能看到 clientX、clientY 就直接拿来当作元素内部坐标使用。
这一节先从 DOM 开始,看看鼠标事件到底提供了哪些坐标,以及怎样把视口坐标转换成元素自己的局部坐标。这个过程看起来只是做一次减法,却是网页拖拽、点击命中、Canvas 交互和 WebGL 鼠标拾取的共同起点。
1. 鼠标事件给你的,首先是“视口里的位置”
当用户移动鼠标、点击页面或触摸屏幕时,浏览器会创建一个事件对象,并把指针的位置放进去:
const panel = document.querySelector('.panel');
panel.addEventListener('pointerdown', (event) => {
console.log(event.clientX, event.clientY);
});
这里的 clientX 和 clientY 表示指针相对于浏览器视口左上角的位置。视口可以理解为“当前用户看到的那块网页区域”,它的左上角是 (0, 0),向右是 x 正方向,向下是 y 正方向。
需要注意的是,clientX、clientY 并不是相对于某个 DOM 元素的坐标。即使事件监听器写在 .panel 上,它们仍然描述的是指针在视口中的位置。
视口左上角 (0, 0)
↓
└── 指针位置 (clientX, clientY)
页面滚动时,元素可能已经位于文档中的更深位置,但 clientX、clientY 仍然以当前视口为参考。这正是它适合描述“用户当前看到哪里”,却不能直接表示“点击了元素内部哪里”的原因。
2. getBoundingClientRect():先找到元素在视口中的边界
要把视口坐标转换成元素内部坐标,需要先知道元素左上角在视口中的位置。DOM 提供的 getBoundingClientRect() 就是用来获取这个信息的:
const rect = panel.getBoundingClientRect();
console.log({
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
});
返回的 rect.left 和 rect.top,分别表示元素左边和上边距离视口左上角的距离。于是,指针相对于元素左上角的位置可以这样计算:
panel.addEventListener('pointerdown', (event) => {
const rect = panel.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
console.log('元素内部坐标:', x, y);
});
这一步的本质是:把两个都使用“视口左上角”的位置相减,消掉共同的参考点。
元素内部坐标 = 鼠标视口坐标 − 元素左上角的视口坐标
如果元素左上角位于视口中的 (200, 100),鼠标位于 (260, 145),那么鼠标在元素内部就是 (60, 45)。无论元素整体位于页面什么位置,内部坐标都从元素自己的左上角重新开始计算。
3. 页面滚动和 CSS 变换,为什么不能靠猜
getBoundingClientRect() 返回的是元素当前相对于视口的矩形,因此它会随着页面滚动、窗口尺寸变化和 CSS 变换而变化。不要把元素初始位置写死,也不要只在页面加载时读取一次:
function getPointerPosition(element, event) {
const rect = element.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
panel.addEventListener('pointermove', (event) => {
const point = getPointerPosition(panel, event);
panel.textContent = `${point.x.toFixed(0)}, ${point.y.toFixed(0)}`;
});
如果元素经过了 transform: scale()、旋转或复杂的矩阵变换,简单的“坐标相减”只能得到元素布局矩形中的位置,不能自动还原到变换前的局部空间。这时还要结合 CSS 变换矩阵,或者使用更适合的坐标转换方案。
不过对于普通的矩形面板、按钮和未旋转的 Canvas,这个公式已经足够可靠,也是绝大多数网页交互的第一步。
4. 从 DOM 坐标继续往下:交互对象需要自己的空间
把鼠标转换成元素内部坐标后,我们才真正拥有了一个可以交给业务逻辑的点:
const point = getPointerPosition(panel, event);
if (point.x >= 0 && point.x <= panel.clientWidth &&
point.y >= 0 && point.y <= panel.clientHeight) {
console.log('指针位于元素内部');
}
对于 DOM 元素,这个点通常用来判断按钮区域、拖拽范围或卡片命中;对于 Canvas,它还要继续从 CSS 显示坐标换算成画布像素坐标;对于 WebGL,则还要进一步换算成以中心为原点、范围通常为 -1 到 1 的 NDC 坐标。
所以,鼠标事件并没有直接告诉你“点击了哪个图形”。浏览器只提供了一个基于视口的观测结果,剩下的坐标转换和命中判断,要由我们根据当前渲染系统继续完成。
这也是网页特效中经常出现“鼠标和图形对不上”的根源:事件坐标、元素坐标、Canvas 像素坐标和 WebGL 坐标没有处在同一个空间里。
三、Canvas 坐标:CSS 尺寸、画布像素与设备像素比如何换算
DOM 坐标解决的是“用户点到了元素里的什么位置”,但如果这个元素是 Canvas,问题还没有结束。Canvas 在网页中通常同时拥有两套尺寸:CSS 决定它在页面上显示多大,HTML 属性 width 和 height 决定它内部实际有多少个像素。
这两套尺寸刚好相等时,坐标换算很简单;一旦使用了响应式布局,或者遇到高清屏幕,显示尺寸和内部像素尺寸就可能不同。如果直接把鼠标坐标当成 Canvas 像素坐标,绘制位置就会出现偏移,线条和文字也可能变得模糊。
1. Canvas 为什么有两套尺寸
先看一个最常见的 Canvas:
<canvas id="canvas" width="800" height="400"></canvas>
这里的 width="800" 和 height="400" 是 Canvas 的内部绘图缓冲区尺寸,表示它内部有 800 × 400 个像素。它也可以通过 CSS 设置显示尺寸:
canvas {
width: 400px;
height: 200px;
}
此时,Canvas 内部仍然是 800 × 400 像素,但页面上只显示为 400 × 200 CSS 像素。浏览器会把内部画面缩放后显示出来:
内部绘图尺寸:800 × 400 像素
页面显示尺寸:400 × 200 CSS 像素
缩放比例:2
如果代码执行 ctx.fillRect(400, 200, 20, 20),它使用的是内部像素坐标。这个矩形最终会显示在画布的中心附近,而不是页面中的 (400, 200) CSS 位置。
CSS 尺寸决定“画布在页面上看起来多大”,HTML 属性尺寸决定“画布内部实际画多少像素”。
2. 鼠标位置要先换成 Canvas 内部像素
上一节得到的 x、y,只是指针相对于 Canvas 显示区域左上角的 CSS 坐标。要把它换成内部像素坐标,还要分别乘上两个方向的缩放比例:
const canvas = document.querySelector('#canvas');
canvas.addEventListener('pointerdown', (event) => {
const rect = canvas.getBoundingClientRect();
const cssX = event.clientX - rect.left;
const cssY = event.clientY - rect.top;
const pixelX = cssX * canvas.width / rect.width;
const pixelY = cssY * canvas.height / rect.height;
console.log('Canvas 内部像素坐标:', pixelX, pixelY);
});
这里不能简单地写成 event.clientX - rect.left,因为 rect.width 可能和 canvas.width 不一样。正确公式是:
Canvas 像素 x = Canvas 内 CSS 坐标 x × 内部宽度 / 显示宽度
Canvas 像素 y = Canvas 内 CSS 坐标 y × 内部高度 / 显示高度
3. 设备像素比:同样大小的屏幕,为什么清晰度不同
现代屏幕通常存在设备像素比(Device Pixel Ratio,简称 DPR)。例如,某个元素在 CSS 中显示为 400 × 200,但设备像素比为 2 时,浏览器可能需要用 800 × 400 个物理像素来呈现它,才能保持足够清晰。
可以用 window.devicePixelRatio 读取当前设备像素比:
console.log(window.devicePixelRatio);
通常的高清 Canvas 初始化方式是:让内部像素尺寸等于 CSS 尺寸乘以 DPR,同时把绘图坐标系缩放回 CSS 尺寸:
function resizeCanvas(canvas, context) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const canvas = document.querySelector('#canvas');
const ctx = canvas.getContext('2d');
resizeCanvas(canvas, ctx);
window.addEventListener('resize', () => {
resizeCanvas(canvas, ctx);
});
这样做之后,Canvas 内部拥有更高的像素密度,但代码中的 ctx.fillRect(100, 50, 20, 20) 仍然可以理解为“在 CSS 坐标 (100, 50) 处绘制”。
4. 高清绘制和鼠标换算要保持同一套约定
如果绘图上下文已经通过 setTransform(dpr, 0, 0, dpr, 0, 0) 缩放,那么绘图 API 使用的是 CSS 坐标,鼠标位置也应该使用 CSS 坐标,而不是再次乘上 DPR:
function getCanvasPoint(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
canvas.addEventListener('pointermove', (event) => {
const point = getCanvasPoint(canvas, event);
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
ctx.beginPath();
ctx.arc(point.x, point.y, 12, 0, Math.PI * 2);
ctx.fillStyle = '#16a6b6';
ctx.fill();
});
另一种做法是不调用 setTransform(),而是始终使用内部像素坐标。那就必须把鼠标位置乘以 canvas.width / rect.width 和 canvas.height / rect.height。两种方式都可以,关键是不能一部分代码使用 CSS 坐标,另一部分代码使用内部像素坐标。
flowchart TD
A[鼠标 clientX / clientY] --> B[减去 Canvas 的 rect.left / rect.top]
B --> C[得到 Canvas 显示区域内的 CSS 坐标]
C --> D{绘图上下文是否按 DPR 缩放}
D -->|是| E[直接用 CSS 坐标绘制]
D -->|否| F[按内部尺寸与显示尺寸的比例换算]
E --> G[高清 Canvas 绘制]
F --> G
所以,Canvas 坐标问题可以归结为三个数字之间的关系:页面上显示了多少 CSS 像素,Canvas 内部存了多少绘图像素,当前设备的 DPR 是多少。只要在初始化、鼠标输入和绘制这三个环节使用同一套坐标约定,画面清晰度和交互位置就能同时保持正确。
四、WebGL 坐标:为什么顶点不能直接写成屏幕上的像素值
Canvas 2D 可以直接写 (200, 100) 这样的像素坐标,但 WebGL 的顶点着色器通常不能这样理解它。WebGL 顶点进入渲染管线时,首先要使用裁剪空间坐标;经过透视除法之后,才会得到标准化设备坐标(Normalized Device Coordinates,简称 NDC)。只有到了视口变换阶段,NDC 才会映射成 Canvas 上的像素位置。
这意味着,WebGL 中的 x = 200 并不是“屏幕横坐标 200 像素”。它已经远远超出了默认裁剪范围,顶点很可能会被裁剪掉。要让一个顶点出现在屏幕上的指定位置,必须先把像素坐标转换到 WebGL 使用的坐标空间。
1. WebGL 默认认的是中心原点和 -1 到 1
在最简单的 WebGL 绘制中,顶点坐标通常写在 -1 到 1 的范围内:
const vertices = new Float32Array([
-1, -1,
1, -1,
0, 1,
]);
这三个点组成一个覆盖较大区域的三角形。这里的坐标不是 Canvas 像素坐标,而是裁剪空间中的齐次坐标分量。与 Canvas 2D 的区别很明显:
| 坐标系统 | 原点 | x 方向 | y 方向 | 常见范围 |
|---|---|---|---|---|
| Canvas 2D | 左上角 | 向右增大 | 向下增大 | 像素范围 |
| WebGL NDC | 中心 | 向右增大 | 向上增大 | -1 到 1 |
所以,Canvas 中的左上角不是 WebGL 中的 (0, 0)。在没有额外矩阵变换的情况下,WebGL 的 (0, 0) 位于画布中心附近。
2. 像素坐标怎样映射到 NDC
假设 Canvas 内部像素尺寸是 width × height,某个像素点是 (pixelX, pixelY),可以按比例把它换算成 NDC:
function pixelToNDC(pixelX, pixelY, width, height) {
return {
x: pixelX / width * 2 - 1,
y: 1 - pixelY / height * 2,
};
}
const point = pixelToNDC(200, 100, canvas.width, canvas.height);
console.log(point);
横坐标把像素区间 [0, width] 映射到 NDC 区间 [-1, 1]:
ndcX = pixelX / canvas.width × 2 − 1
纵坐标还要反转,因为 Canvas 像素坐标向下增大,而 WebGL NDC 通常向上增大:
ndcY = 1 − pixelY / canvas.height × 2
顶部像素对应 ndcY = 1,底部像素对应 ndcY = -1。实际项目中,如果要定位到某个像素的中心,还可以使用 pixelX + 0.5 和 pixelY + 0.5,避免把坐标理解成像素边缘。
3. 顶点为什么会被裁剪
WebGL 的裁剪阶段会检查顶点是否位于可见范围附近。如果顶点经过顶点着色器处理后,裁剪空间坐标超出允许范围,相关图元可能被裁掉:
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
此时,如果 JavaScript 传入 [200, 100],它不是“位于画布中间偏右的位置”,而是一个远远超出裁剪范围的坐标。浏览器不会自动把它当作像素值,也不会自动帮我们除以画布宽高。
也可以把像素尺寸作为 Uniform 传入顶点着色器,在 GPU 中完成转换:
attribute vec2 a_pixelPosition;
uniform vec2 u_resolution;
void main() {
vec2 zeroToOne = a_pixelPosition / u_resolution;
vec2 clipSpace = zeroToOne * 2.0 - 1.0;
gl_Position = vec4(clipSpace * vec2(1.0, -1.0), 0.0, 1.0);
}
这种写法把 JavaScript 中的像素顶点转换成裁剪空间坐标,最后翻转 y 方向。实际工程中,把分辨率作为 Uniform 传给 Shader,通常比在 JavaScript 中预先改写所有顶点更灵活。
4. 视口变换负责把 NDC 送到画布
完成裁剪和透视除法后,GPU 还要通过视口变换,把 [-1, 1] 范围的 NDC 映射到 Canvas 的实际绘制区域:
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
如果视口宽度为 W、高度为 H,NDC 到像素的大致映射关系可以写成:
pixelX = (ndcX + 1) × W / 2
pixelY = (1 − ndcY) × H / 2
正向映射和反向映射是同一件事的两个方向:
flowchart TD
A[Canvas 像素坐标] --> B[按宽高归一化]
B --> C[映射到 -1 到 1]
C --> D[翻转 y 方向]
D --> E[WebGL NDC / 裁剪空间]
E --> F[裁剪与光栅化]
F --> G[视口变换]
G --> H[Canvas 绘制缓冲区像素]
还要注意,canvas.width 是 HTML Canvas 的内部宽度,而 gl.drawingBufferWidth 是 WebGL 实际绘制缓冲区的宽度。高 DPR、CSS 缩放或上下文配置变化时,两者不一定完全相同。进行精确换算时,应优先使用当前 WebGL 绘制缓冲区的实际尺寸。
因此,WebGL 并不是不能使用像素坐标,而是像素坐标必须经过明确的空间转换。只要把“像素 → NDC → 裁剪 → 视口 → 像素”这条链路理顺,WebGL 依然可以精确地在屏幕指定位置绘制图形,只是这份换算责任不会由 API 自动替你完成。
五、从局部空间到屏幕空间:一个点怎样经过矩阵一路变换
前面我们已经知道,WebGL 不能直接把屏幕像素当成顶点坐标。更完整地说,一个顶点通常也不会一直停留在它最初的坐标系里,而是会经过多个空间:模型自己的局部空间、场景中的世界空间、相机看到的观察空间、用于裁剪的裁剪空间,最后才变成屏幕像素。
这些变化并不是靠一堆手写的加减法完成的,而是由矩阵统一描述。矩阵可以把平移、旋转、缩放、相机观察和投影压缩成一条清晰的变换链,让同一个顶点在不同空间之间稳定地移动。
1. 局部空间:顶点最初属于自己的模型
假设我们要绘制一架纸飞机、一个按钮图标或一个粒子,它的顶点最初只描述自己在模型内部的形状:
局部空间(Local Space)
原点:模型自己的原点
单位:模型内部约定的单位
用途:描述模型的几何形状
例如,一个三角形模型可能使用下面三个顶点:
const localVertices = [
[-0.5, -0.5, 0.0],
[ 0.5, -0.5, 0.0],
[ 0.0, 0.5, 0.0],
];
这些坐标只说明三角形长什么样,并没有说明它应该出现在页面的左边还是右边。我们可以把同一个模型复制很多次,每次使用不同的平移、旋转和缩放矩阵,把它们放到场景的不同位置。
这一步得到的是世界空间坐标:
世界坐标 = 模型矩阵 × 局部坐标
在数学上,顶点通常会补上一个齐次坐标分量,写成 vec4(x, y, z, 1.0)。最后这个 1.0 让矩阵可以对顶点执行平移;方向向量则通常使用 w = 0.0,避免被平移影响。
2. 世界空间和观察空间:相机也需要一套坐标
世界空间描述“物体在整个场景中的位置”,但 GPU 还需要知道相机在哪里、朝向哪里。观察矩阵(View Matrix)负责把世界空间转换成相机视角下的观察空间:
观察坐标 = 观察矩阵 × 世界坐标
可以把观察矩阵理解成“把整个世界反向移动,让相机回到原点”。相机向右移动时,观察矩阵会让场景看起来向左移动;相机旋转时,场景也会产生相反方向的旋转。
因此,模型、相机和投影可以合并成一条常见的变换链:
裁剪坐标 = 投影矩阵 × 观察矩阵 × 模型矩阵 × 局部坐标
在顶点着色器中,通常会写成:
attribute vec3 a_position;
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
void main() {
vec4 localPosition = vec4(a_position, 1.0);
gl_Position =
u_projection *
u_view *
u_model *
localPosition;
}
矩阵的书写顺序不能随意调换,因为矩阵乘法通常不满足交换律。这里的含义是:先用模型矩阵放置物体,再用观察矩阵转换到相机空间,最后用投影矩阵转换到裁剪空间。
3. 投影矩阵:把三维世界压进可裁剪的范围
投影矩阵负责把观察空间中的物体映射到裁剪空间。常见的投影方式有两种:正交投影和透视投影。
正交投影不会因为物体远近而改变大小,适合 2D 场景、UI、地图和一些平面技术图:
远处的物体不会因为距离变远而缩小
平行线仍然保持平行
透视投影更接近人眼看到的效果,远处物体会变小,适合 3D 场景:
近处物体更大
远处物体更小
视野通常由视场角、宽高比、近裁剪面和远裁剪面决定
投影矩阵输出的是裁剪空间中的齐次坐标。随后,GPU 会执行透视除法:
NDC.x = clip.x / clip.w
NDC.y = clip.y / clip.w
NDC.z = clip.z / clip.w
这一步正是透视效果出现的关键。不同深度的顶点拥有不同的 w,除以 w 后,远处物体的屏幕尺寸就会相对变小。
4. NDC 到屏幕空间:最后一次映射
经过透视除法后,顶点进入标准化设备坐标(NDC)。在常见的 WebGL 约定中,x、y 方向大致落在 -1 到 1 的范围内。此时它还不是像素坐标,还要经过视口变换:
flowchart TD
A[局部空间<br/>模型自己的顶点] --> B[模型矩阵]
B --> C[世界空间<br/>物体在场景中的位置]
C --> D[观察矩阵]
D --> E[观察空间<br/>相机看到的场景]
E --> F[投影矩阵]
F --> G[裁剪空间
等待裁剪]
G --> H[透视除法
除以 w]
H --> I[NDC
范围约为 -1 到 1]
I --> J[视口变换]
J --> K[屏幕空间
Canvas 绘制缓冲区像素]
假设视口左下角是 (0, 0),宽度为 W),高度为 H`,则可以近似写成:
screenX = (ndcX + 1) × W / 2
screenY = (ndcY + 1) × H / 2
这里要特别注意,WebGL 的视口坐标通常以左下角为原点,而 DOM 和 Canvas 的鼠标坐标通常以左上角为原点。如果要把 WebGL 屏幕位置和鼠标事件坐标对应起来,常见的 y 方向转换是:
const domY = canvas.clientHeight - webglY;
在高 DPR 场景中,还要区分 CSS 像素和绘制缓冲区像素:
const scaleX = gl.drawingBufferWidth / canvas.clientWidth;
const scaleY = gl.drawingBufferHeight / canvas.clientHeight;
const bufferX = domX * scaleX;
const bufferY = (canvas.clientHeight - domY) * scaleY;
于是,一个顶点真正走过的路径可以概括为:
局部坐标决定模型形状,模型矩阵决定它放在哪里,观察矩阵决定相机怎么看,投影矩阵决定它怎样进入屏幕,视口变换最终把它落成像素。
这就是矩阵在 WebGL 中的价值:它把不同坐标空间之间的关系组织起来。以后看到一组看似神秘的 u_model、u_view 和 u_projection,可以把它们理解成同一件事的三个阶段——摆放物体、摆放相机、决定成像方式。
六、坐标转换真正用在哪里:从鼠标命中到网页特效交互
前面几节一直在做坐标转换,看起来像是在处理一堆数字,但它最终服务的并不是数学本身,而是网页和用户之间的互动。用户点击一个按钮、拖动一个图形、移动鼠标让粒子跟随,背后都需要先回答同一个问题:用户输入的这个点,怎样转换成当前渲染系统能够理解的位置?
坐标转换一旦建立起来,DOM、Canvas 2D 和 WebGL 就可以共享同一套输入。浏览器负责提供事件位置,我们负责把它逐层转换到元素、画布、世界或模型空间,再交给命中检测和动画逻辑处理。
1. 鼠标命中:先把输入点变成对象能理解的坐标
最简单的命中检测,是判断鼠标是否落在一个矩形区域内。第一步是把事件坐标转换成元素内部坐标,第二步才是判断范围:
function getLocalPoint(element, event) {
const rect = element.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
function isInsideRect(point, rect) {
return point.x >= rect.x &&
point.x <= rect.x + rect.width &&
point.y >= rect.y &&
point.y <= rect.y + rect.height;
}
panel.addEventListener('pointermove', (event) => {
const point = getLocalPoint(panel, event);
panel.classList.toggle('is-hovered', isInsideRect(point, {
x: 0,
y: 0,
width: panel.clientWidth,
height: panel.clientHeight,
}));
});
圆形命中检测则需要比较距离:
function isInsideCircle(point, circle) {
const dx = point.x - circle.x;
const dy = point.y - circle.y;
return dx * dx + dy * dy <= circle.radius * circle.radius;
}
这里的关键不是公式有多复杂,而是参与计算的点和圆必须属于同一个坐标空间。鼠标点是 Canvas 内部坐标,圆心也必须使用 Canvas 内部坐标;不能拿视口坐标去和元素内部的圆心比较。
2. 拖拽交互:记录偏移量,而不是让对象跳到鼠标下面
拖拽时常见的“跳动”问题,也和坐标空间有关。如果按下鼠标后直接把对象左上角设置为鼠标坐标,鼠标就会瞬间吸附到对象角落。更自然的做法是记录按下时鼠标与对象之间的偏移量:
let dragging = false;
let offsetX = 0;
let offsetY = 0;
panel.addEventListener('pointerdown', (event) => {
const point = getLocalPoint(panel, event);
dragging = true;
offsetX = point.x - box.x;
offsetY = point.y - box.y;
panel.setPointerCapture(event.pointerId);
});
panel.addEventListener('pointermove', (event) => {
if (!dragging) return;
const point = getLocalPoint(panel, event);
box.x = point.x - offsetX;
box.y = point.y - offsetY;
render();
});
panel.addEventListener('pointerup', (event) => {
dragging = false;
panel.releasePointerCapture(event.pointerId);
});
其中 box.x、box.y 必须与 point.x、point.y 使用同一套元素内部坐标。这样无论用户从图形的中心还是边缘开始拖动,图形都能保持与指针的相对位置。
3. Canvas 特效:鼠标位置怎样驱动粒子和光晕
在 Canvas 特效中,鼠标坐标通常不会直接决定某一个 DOM 元素,而是会作为粒子系统的输入参数。例如,粒子可以根据自己与鼠标的距离产生排斥、吸引或发光效果:
const mouse = {
x: 0,
y: 0,
active: false,
};
canvas.addEventListener('pointermove', (event) => {
const rect = canvas.getBoundingClientRect();
mouse.x = event.clientX - rect.left;
mouse.y = event.clientY - rect.top;
mouse.active = true;
});
canvas.addEventListener('pointerleave', () => {
mouse.active = false;
});
function updateParticle(particle) {
if (!mouse.active) return;
const dx = particle.x - mouse.x;
const dy = particle.y - mouse.y;
const distance = Math.hypot(dx, dy);
const radius = 120;
if (distance > 0 && distance < radius) {
const force = (radius - distance) / radius;
particle.vx += dx / distance * force * 0.8;
particle.vy += dy / distance * force * 0.8;
}
}
这个例子中,particle.x、particle.y 和 mouse.x、mouse.y 都使用 Canvas 显示坐标。即使 Canvas 为了高清显示而把内部缓冲区放大了,只要绘图上下文已经按 DPR 缩放,动画逻辑仍然可以使用 CSS 尺寸对应的坐标,代码会更直观。
4. WebGL 交互:把鼠标反算回 NDC 或世界空间
WebGL 的鼠标交互通常需要反向转换。浏览器给出的是左上角原点的 DOM 坐标,而 WebGL 常用的是中心原点、范围约为 -1 到 1 的 NDC:
function pointerToNDC(canvas, event) {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return {
x: x / rect.width * 2 - 1,
y: 1 - y / rect.height * 2,
};
}
canvas.addEventListener('pointermove', (event) => {
const ndc = pointerToNDC(canvas, event);
gl.uniform2f(pointerUniform, ndc.x, ndc.y);
});
如果特效中的交互平面位于世界空间,而不是 NDC,还需要使用投影矩阵和观察矩阵的逆矩阵,把鼠标射线反算回场景:
DOM 坐标
→ Canvas 局部坐标
→ NDC
→ 裁剪空间中的射线
→ 观察空间或世界空间
→ 与交互平面求交
这类反变换常用于 3D 场景中的鼠标拾取、粒子吸附、平面拖拽和模型交互。它比 2D 命中检测复杂,但核心原则完全一样:先明确输入点属于哪个空间,再通过对应的变换进入目标空间。
5. 把整篇文章压缩成一条交互链
坐标转换真正重要的地方,在于它让不同系统之间能够稳定地对话:
flowchart TD
A[鼠标或触摸事件] --> B[视口坐标]
B --> C[DOM 元素内部坐标]
C --> D{目标渲染系统}
D -->|DOM| E[按钮命中或拖拽判断]
D -->|Canvas 2D| F[Canvas 坐标与粒子逻辑]
D -->|WebGL| G[NDC 或世界空间]
E --> H[更新交互状态]
F --> H
G --> H
H --> I[重新绘制下一帧]
整篇文章可以归纳为四个检查问题:
- 这个坐标的原点在哪里?
- x、y 方向分别朝哪里增长?
- 它的单位是 CSS 像素、Canvas 像素还是归一化数值?
- 它下一步要被哪个坐标空间使用?
只要这四个问题能回答清楚,很多“鼠标和图形对不上”“高清屏幕上位置偏移”“WebGL 图形跑出画布”的问题,就不再是凭感觉调数字,而是可以沿着坐标链逐步定位。
网页特效的交互,本质上不是让鼠标直接控制图形,而是把鼠标所在的空间转换成图形所在的空间。
从 DOM 的视口坐标,到 Canvas 的内部像素,再到 WebGL 的 NDC、世界空间和模型空间,坐标转换就是连接用户输入与 GPU 画面的那座桥。
七、关于八荒启
八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。
我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。
八荒启,专为交互动画而生
让复杂事物可探索、可操作、可反馈
如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。
- 官方网站:bahuangqi.com
- 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
- 定制服务:可通过官网提交需求或联系人工客服进行评估
感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。