Three.js 开发中,将屏幕坐标(Screen Coordinates)转换为标准设备坐标

10 阅读1分钟

在 Three.js 或 WebGL 开发中,将屏幕坐标 转换为标准设备坐标(Normalized Device Coordinates, NDC)是一个线性映射的过程。

理解推导过程的关键在于:将一个范围(区间)映射到另一个范围。

1. 明确两个坐标系的定义

  • 屏幕坐标系 (Screen Space):

    • xx 范围:从 00(左)到 widthwidth(右)。
    • yy 范围:从 00(上)到 heightheight(下)。
    • 注意: 屏幕坐标的 yy 轴是向下的。
  • 标准设备坐标系 (NDC):

    • xx 范围:从 1-1(左)到 11(右)。
    • yy range:从 11(上)到 1-1(下)。(这是为了匹配 3D 世界坐标,向上为正)。

2. X 轴的推导过程

我们需要将 xscreen[0,width]x_{screen} \in [0, width] 映射到 xndc[1,1]x_{ndc} \in [-1, 1]

  1. 归一化 (Normalize): 先将坐标缩放到 [0,1][0, 1] 之间。

    xscreenwidth\frac{x_{screen}}{width}

  2. 放大区间: 将范围从 [0,1][0, 1] 变为 [0,2][0, 2](因为 NDC 的区间长度是 1(1)=21 - (-1) = 2)。

    xscreenwidth×2\frac{x_{screen}}{width} \times 2

  3. 平移: 将起点从 00 移动到 1-1

    xndc=(xscreenwidth×2)1x_{ndc} = (\frac{x_{screen}}{width} \times 2) - 1

这就是代码中的:mouse.x = (event.clientX / width) * 2 - 1;


3. Y 轴的推导过程

Y 轴稍微复杂一点,因为屏幕坐标的 Y 轴是向下的(0 在顶部),而 NDC 的 Y 轴是向上的(1 在顶部)。

第一步:归一化 (Normalization)

首先,我们将屏幕上的像素高度 yscreeny_{screen} 转化为一个比例(0011 之间的数)。

  • 公式:yscreenheight\frac{y_{screen}}{height}
  • 结果: 此时,屏幕最上方为 00,最下方为 11

第二步:拉伸区间 (Scaling)

NDC 的总长度是从 1-111,跨度为 22。所以我们要把刚才的比例放大 22 倍。

  • 公式:yscreenheight×2\frac{y_{screen}}{height} \times 2
  • 结果: 此时,屏幕最上方为 00,最下方为 22

第三步:平移起点 (Translation)

为了让数值围绕中心分布,从[0, 2] 区间平移到 [-1, 1],我们减去 11

  • 公式:(yscreenheight×2)1(\frac{y_{screen}}{height} \times 2) - 1
  • 结果: 此时,屏幕最上方(原 00)变成了 1-1,最下方(原 22)变成了 11

注意: 这里的映射结果是:上方 1\rightarrow -1,下方 1\rightarrow 1 。但这和 3D 世界坐标是相反的(3D 里上方应该是正数)。

第四步:符号翻转 (Inversion) —— 关键一步

为了让上方变成 11,下方变成 1-1,我们需要给整个结果取反(乘以 1-1)。

  • 公式:[(yscreenheight×2)1]- [ (\frac{y_{screen}}{height} \times 2) - 1 ]

  • 分配负号: 把负号带进去,就变成了:

    (yscreenheight×2)+1- (\frac{y_{screen}}{height} \times 2) + 1


总结公式对比

坐标点屏幕坐标 (x, y)NDC 坐标 (x, y)
左上角(0,0)(0, 0)(1,1)(-1, 1)
中心点(width/2,height/2)(width/2, height/2)(0,0)(0, 0)
右下角(width,height)(width, height)(1,1)(1, -1)

通过这个线性推导,Three.js 就能精确地知道鼠标点击在 3D 投影平面上的位置,从而通过 Raycaster(光线投射器)进行物体拾取。

mouse.x = (event.clientX / renderer.domElement.offsetWidth) * 2 - 1;
mouse.y = -(event.clientY / renderer.domElement.offsetHeight) * 2 + 1;