Web特效010—坐标系:网页上的一个点,为什么到了 WebGL 里就变了?

0 阅读27分钟

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.clientXevent.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)表达顶点位置,水平和垂直范围大致都是 -11

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 转成 1y 则需要翻转方向,因为 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 里的某个交互对象。它们处在不同的坐标体系里,所以不能看到 clientXclientY 就直接拿来当作元素内部坐标使用。

这一节先从 DOM 开始,看看鼠标事件到底提供了哪些坐标,以及怎样把视口坐标转换成元素自己的局部坐标。这个过程看起来只是做一次减法,却是网页拖拽、点击命中、Canvas 交互和 WebGL 鼠标拾取的共同起点。

1. 鼠标事件给你的,首先是“视口里的位置”

当用户移动鼠标、点击页面或触摸屏幕时,浏览器会创建一个事件对象,并把指针的位置放进去:

const panel = document.querySelector('.panel');

panel.addEventListener('pointerdown', (event) => {
  console.log(event.clientX, event.clientY);
});

这里的 clientXclientY 表示指针相对于浏览器视口左上角的位置。视口可以理解为“当前用户看到的那块网页区域”,它的左上角是 (0, 0),向右是 x 正方向,向下是 y 正方向。

需要注意的是,clientXclientY 并不是相对于某个 DOM 元素的坐标。即使事件监听器写在 .panel 上,它们仍然描述的是指针在视口中的位置。

视口左上角 (0, 0)
        ↓
        └── 指针位置 (clientX, clientY)

页面滚动时,元素可能已经位于文档中的更深位置,但 clientXclientY 仍然以当前视口为参考。这正是它适合描述“用户当前看到哪里”,却不能直接表示“点击了元素内部哪里”的原因。

2. getBoundingClientRect():先找到元素在视口中的边界

要把视口坐标转换成元素内部坐标,需要先知道元素左上角在视口中的位置。DOM 提供的 getBoundingClientRect() 就是用来获取这个信息的:

const rect = panel.getBoundingClientRect();

console.log({
  left: rect.left,
  top: rect.top,
  width: rect.width,
  height: rect.height,
});

返回的 rect.leftrect.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,则还要进一步换算成以中心为原点、范围通常为 -11 的 NDC 坐标。

所以,鼠标事件并没有直接告诉你“点击了哪个图形”。浏览器只提供了一个基于视口的观测结果,剩下的坐标转换和命中判断,要由我们根据当前渲染系统继续完成。

这也是网页特效中经常出现“鼠标和图形对不上”的根源:事件坐标、元素坐标、Canvas 像素坐标和 WebGL 坐标没有处在同一个空间里。

三、Canvas 坐标:CSS 尺寸、画布像素与设备像素比如何换算

DOM 坐标解决的是“用户点到了元素里的什么位置”,但如果这个元素是 Canvas,问题还没有结束。Canvas 在网页中通常同时拥有两套尺寸:CSS 决定它在页面上显示多大,HTML 属性 widthheight 决定它内部实际有多少个像素。

这两套尺寸刚好相等时,坐标换算很简单;一旦使用了响应式布局,或者遇到高清屏幕,显示尺寸和内部像素尺寸就可能不同。如果直接把鼠标坐标当成 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 内部像素

上一节得到的 xy,只是指针相对于 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.widthcanvas.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 绘制中,顶点坐标通常写在 -11 的范围内:

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.5pixelY + 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 方向大致落在 -11 的范围内。此时它还不是像素坐标,还要经过视口变换:

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_modelu_viewu_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.xbox.y 必须与 point.xpoint.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.xparticle.ymouse.xmouse.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[重新绘制下一帧]

整篇文章可以归纳为四个检查问题:

  1. 这个坐标的原点在哪里?
  2. x、y 方向分别朝哪里增长?
  3. 它的单位是 CSS 像素、Canvas 像素还是归一化数值?
  4. 它下一步要被哪个坐标空间使用?

只要这四个问题能回答清楚,很多“鼠标和图形对不上”“高清屏幕上位置偏移”“WebGL 图形跑出画布”的问题,就不再是凭感觉调数字,而是可以沿着坐标链逐步定位。

网页特效的交互,本质上不是让鼠标直接控制图形,而是把鼠标所在的空间转换成图形所在的空间。

从 DOM 的视口坐标,到 Canvas 的内部像素,再到 WebGL 的 NDC、世界空间和模型空间,坐标转换就是连接用户输入与 GPU 画面的那座桥。

七、关于八荒启

八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。

我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。

八荒启,专为交互动画而生
让复杂事物可探索、可操作、可反馈

如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。

  • 官方网站:bahuangqi.com
  • 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
  • 定制服务:可通过官网提交需求或联系人工客服进行评估

感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。