前言

在上一篇笔记中,我们学习了三维模型如何经过 MVP 矩阵,从模型空间一路变换到裁剪空间。 但裁剪空间并不是最终显示在屏幕上的位置。 这一节,我们将继续探索 GPU 如何把裁剪空间中的顶点转换到屏幕坐标,并介绍进入 Rasterization(光栅化)之前必须理解的几个概念:

  • NDC
  • Viewport Transform
  • Depth Buffer

NDC:标准化设备坐标

现在我们知道,裁剪坐标中的顶点坐标是四维齐次坐标,接下来怎么将其转换为屏幕上的二维坐标呢?

$$
(x_c,y_c,z_c,w_c)
$$

答案是透视除法(Perspective Divide),GPU会对每个顶点进行下面的透视除法计算:

$$
x_{ndc}=\frac{x_c}{w_c}
$$

$$
y_{ndc}=\frac{y_c}{w_c}
$$

$$
z_{ndc}=\frac{z_c}{w_c}
$$

经过透视除法之后,四维齐次坐标重新变回了三维坐标:

$$
(x_{ndc},y_{ndc},z_{ndc})
$$

这个坐标就是 NDC(Normalized Device Coordinates,标准化设备坐标)

在 OpenGL / WebGL 中,可见范围会被统一到:

$$
[-1,1]\times[-1,1]\times[-1,1]
$$

也就是说,无论相机使用怎样的 FOV、Aspect、Near 和 Far,最终都会被转换到一个统一的标准坐标范围。

至此,从观察空间到 NDC 的过程可以总结为:

View Space → Projection Matrix → Clip Space → Clipping → Perspective Divide → NDC

接下来,只需要再把 NDC 映射到实际的视口,就能得到最终的屏幕坐标。

Viewport Transform:视口变换

有了 NDC 坐标后,我们终于来到了最后一步——Viewport Transform(视口变换)

这一步相比之前就简单多了。

NDC 已经把不同相机、不同视锥体产生的坐标统一到了一个标准范围。视口变换要做的,就是把 NDC 中 X、Y 方向的 [-1,1] 映射到实际的视口范围。

假设视口的宽高分别为 $width$ 和 $height$,那么:

$$
x_{screen}=\frac{x_{ndc}+1}{2}\times width
$$

$$
y_{screen}=\frac{y_{ndc}+1}{2}\times height
$$

例如一个大小为 1920 × 1080 的视口,NDC 的中心:

$$
(0,0)
$$

最终会被映射到:

$$
(960,540)
$$

至此,一个最初定义在模型空间中的三维顶点,经过一系列坐标变换,终于找到了自己在二维屏幕上的位置。

整个过程可以总结为:

Model Space → World Space → View Space → Clip Space → NDC → Screen Space

NDC 坐标中的 Z 有什么用?

细心的朋友们可能已经发现了一个问题:在上面的视口变换中,我们只使用了 NDC 中的 X 和 Y 坐标,并没有用到 Z。

那么,NDC 中的 Z 坐标去哪了?

实际上,Z 坐标非常重要。

虽然屏幕最终是二维的,我们只需要 X、Y 来确定一个点在屏幕上的位置,但三维场景中可能存在多个物体投影到同一个屏幕位置。

例如在一张人像照片中,画面可能同时存在前景、人物和背景。它们之间具有明确的遮挡关系:前景会遮挡人物,人物又会遮挡身后的背景。

如果只保留 X、Y 坐标,这些物体投影到二维屏幕后,我们就无法知道谁在前、谁在后。

而 NDC 中的 Z 坐标保留了顶点经过投影后的深度信息。在后续渲染过程中,这些深度信息会被写入 Depth Buffer(深度缓冲区),并通过 Depth Test(深度测试) 判断物体之间的遮挡关系。

简单来说:

  • X、Y 决定物体出现在屏幕的哪里;
  • Z 决定同一个位置上,谁应该被看到。

所以,虽然 Z 没有直接参与二维屏幕位置的计算,但它仍然是三维场景能够正确呈现前后遮挡关系的关键。