GPU自动处理专题3-NDC怎么映射成屏幕像素坐标(视口变换)

核心结论

  • NDC 变成屏幕像素坐标,本质上就是做一次视口变换Viewport Transform
  • 在 WebGL / OpenGL 语境里,NDC 范围是:
text 复制代码
x ∈ [-1, 1]
y ∈ [-1, 1]
z ∈ [-1, 1]
  • GPU 会把这个标准范围,线性映射到你设置的视口矩形:
js 复制代码
gl.viewport(x, y, width, height);
  • 映射后得到的就是窗口里的屏幕坐标,更准确说是窗口坐标

先把整个链路串起来

一个顶点从 gl_Position 到屏幕,大致是这样:

text 复制代码
gl_Position(裁剪空间)
-> 透视除法
-> NDC
-> 视口变换
-> 屏幕像素坐标

如果写成公式:

text 复制代码
ndc = clip.xyz / clip.w
screen = viewportTransform(ndc)

先理解视口是什么

viewport 可以理解成:

  • "我最终要把 NDC 这块标准区域,画到屏幕上的哪个矩形区域里"

例如:

js 复制代码
gl.viewport(0, 0, canvas.width, canvas.height);

意思是:

  • 把 NDC 的整个标准区域
  • 映射到画布左下角开始、宽高为 canvas.width x canvas.height 的区域

如果画布是:

text 复制代码
800 x 600

那视口就是:

  • 左下角起点 (0, 0)
  • 800
  • 600

NDC 到屏幕坐标的核心公式

假设视口是:

text 复制代码
viewport = (vx, vy, vw, vh)

其中:

  • vx:视口左下角 x
  • vy:视口左下角 y
  • vw:视口宽度
  • vh:视口高度

那么从 NDC 到屏幕坐标的映射公式是:

text 复制代码
screenX = vx + (x_ndc + 1) * vw / 2
screenY = vy + (y_ndc + 1) * vh / 2
screenZ =      (z_ndc + 1) / 2

这里先重点看 x,y

为什么公式是这样

因为 NDC 的范围是:

text 复制代码
[-1, 1]

而屏幕像素范围是:

text 复制代码
[vx, vx + vw]
[vy, vy + vh]

所以本质上是做两步:

第一步:把 [-1, 1] 变成 [0, 1] 公式:

text 复制代码
t = (ndc + 1) / 2

例如:

  • -1 -> 0
  • 0 -> 0.5
  • 1 -> 1

第二步:把 [0, 1] 放大到视口范围 公式:

text 复制代码
screen = viewportStart + t * viewportSize

合并起来就是:

text 复制代码
screenX = vx + (x_ndc + 1) * vw / 2
screenY = vy + (y_ndc + 1) * vh / 2

这就是完整来源。

用 x 坐标单独看最清楚

假设:

text 复制代码
vx = 0
vw = 800

那么:

text 复制代码
screenX = (x_ndc + 1) * 800 / 2

代几个值:

  • x_ndc = -1
text 复制代码
screenX = 0
  • x_ndc = 0
text 复制代码
screenX = 400
  • x_ndc = 1
text 复制代码
screenX = 800

很直观:

  • NDC 最左边映射到屏幕最左边
  • NDC 中心映射到屏幕中心
  • NDC 最右边映射到屏幕最右边

y 坐标也是同样道理

假设:

text 复制代码
vy = 0
vh = 600

那么:

text 复制代码
screenY = (y_ndc + 1) * 600 / 2

代几个值:

  • y_ndc = -1
text 复制代码
screenY = 0
  • y_ndc = 0
text 复制代码
screenY = 300
  • y_ndc = 1
text 复制代码
screenY = 600

也就是:

  • NDC 底部映射到视口底部
  • NDC 中间映射到视口中间
  • NDC 顶部映射到视口顶部

注意 WebGL 的窗口坐标原点在左下

这是很重要的一个点。

在 WebGL / OpenGL 的视口变换里:

  • (0, 0) 在视口的左下角

所以刚才的公式得到的 screenY,是以左下为原点的窗口坐标。

但你在浏览器 DOM、Canvas 2D、鼠标事件里,经常接触的是:

  • 左上角为原点

所以如果你要和 DOM 坐标对应,通常还需要再转一次:

text 复制代码
canvasY_topLeft = canvasHeight - screenY

这一步不是 GPU 的视口变换本身,而是你在和浏览器坐标系统对接时常常要做的转换。

完整数值例子

假设画布大小:

text 复制代码
800 x 600

并且:

js 复制代码
gl.viewport(0, 0, 800, 600);

某个顶点的 NDC 是:

text 复制代码
(0.5, -0.5, 0.0)

那么:

text 复制代码
screenX = 0 + (0.5 + 1) * 800 / 2
        = 1.5 * 400
        = 600

screenY = 0 + (-0.5 + 1) * 600 / 2
        = 0.5 * 300
        = 150

所以它对应的窗口坐标是:

text 复制代码
(600, 150)

含义是:

  • 横向在右边
  • 纵向在偏下的位置

如果你想转成浏览器常见的左上原点坐标:

text 复制代码
domY = 600 - 150 = 450

所以在 DOM 语义里,它大概是:

text 复制代码
(600, 450)

再看几个边界点

还是假设:

text 复制代码
viewport = (0, 0, 800, 600)

点 1

text 复制代码
NDC = (-1, -1)

得到:

text 复制代码
screen = (0, 0)

也就是左下角。

点 2

text 复制代码
NDC = (1, -1)

得到:

text 复制代码
screen = (800, 0)

也就是右下角。

点 3

text 复制代码
NDC = (-1, 1)

得到:

text 复制代码
screen = (0, 600)

也就是左上角。

点 4

text 复制代码
NDC = (1, 1)

得到:

text 复制代码
screen = (800, 600)

也就是右上角。

点 5

text 复制代码
NDC = (0, 0)

得到:

text 复制代码
screen = (400, 300)

也就是正中心。

z 是怎么映射的

z 也会做类似映射:

text 复制代码
screenZ = (z_ndc + 1) / 2

因为在 WebGL 的 NDC 中:

text 复制代码
z ∈ [-1, 1]

映射后变成:

text 复制代码
depth ∈ [0, 1]

这个值通常会写入深度缓冲,用来做深度测试。

例如:

  • z_ndc = -1 -> depth = 0
  • z_ndc = 1 -> depth = 1

所以:

  • NDC 的 z 是标准化深度
  • 映射后是深度缓冲更常用的范围

为什么是线性映射

因为 NDC 已经是标准化之后的空间了。 透视效果在 Clip -> NDC除以 w那一步已经体现过了。

也就是说:

  • Clip -> NDC:包含透视效果,非线性关键步骤
  • NDC -> Screen:只是简单线性缩放和平移

这是一个特别重要的分层理解。

很多人会误以为:

  • 屏幕坐标阶段还在做透视

其实不是。 真正的透视核心已经在前一步完成了。

从图形上怎么理解

你可以把 NDC 想成一个标准正方形:

text 复制代码
(-1,1)      (1,1)
   +----------+
   |          |
   |    •     |
   |          |
   +----------+
(-1,-1)     (1,-1)

屏幕则是一个像素矩形,比如 800 x 600

text 复制代码
(0,600)       (800,600)
   +-------------+
   |             |
   |      •      |
   |             |
   +-------------+
(0,0)         (800,0)

视口变换做的事就是:

  • 把左边这个标准方块
  • 拉伸到右边这个具体像素矩形里

如果视口不是整个画布呢

如果你设置:

js 复制代码
gl.viewport(100, 50, 400, 300);

那么 NDC 就不再映射到整个画布,而是映射到:

  • 左下角 (100, 50)
  • 400
  • 300

此时公式仍然一样:

text 复制代码
screenX = 100 + (x_ndc + 1) * 400 / 2
screenY =  50 + (y_ndc + 1) * 300 / 2

比如 NDC = (0, 0)

text 复制代码
screenX = 100 + 200 = 300
screenY =  50 + 150 = 200

说明 NDC 中心会映射到这个小视口的中心,而不是整个画布中心。

这就是为什么一个画布里可以分多个视口显示不同画面。

为什么这一步对光栅化很重要

GPU 只有知道三角形的顶点在屏幕上的像素位置,才能继续:

  • 判断三角形覆盖了哪些像素
  • 生成哪些片元
  • 对片元做插值
  • 执行片元着色器

所以:

  • NDC 还是抽象空间
  • Screen Coordinates 才开始真正贴近像素网格

视口变换其实就是"进入光栅化前的最后定位步骤"。

和鼠标坐标对照时怎么处理

浏览器鼠标事件通常给你的是:

  • 左上角为原点
  • 向下 y 增大

比如:

js 复制代码
canvas.addEventListener('click', (e) => {
  const x = e.offsetX;
  const y = e.offsetY;
});

而 WebGL 的窗口坐标通常更接近:

  • 左下角为原点

所以如果你拿屏幕像素坐标去和 WebGL 的结果对照,常常要做:

js 复制代码
const glY = canvas.height - e.offsetY;

如果再进一步要转成 NDC,则通常用:

js 复制代码
const xNdc = (x / canvas.width) * 2 - 1;
const yNdc = 1 - (y / canvas.height) * 2;

这是刚才公式的反推。

反过来,从屏幕像素坐标转回 NDC

如果你已经有屏幕坐标,想反算 NDC:

text 复制代码
x_ndc = 2 * (screenX - vx) / vw - 1
y_ndc = 2 * (screenY - vy) / vh - 1

如果视口就是整个画布:

text 复制代码
x_ndc = 2 * screenX / width - 1
y_ndc = 2 * screenY / height - 1

如果你的输入 y 是浏览器左上原点坐标,常见写法会变成:

js 复制代码
const xNdc = (mouseX / canvas.width) * 2 - 1;
const yNdc = 1 - (mouseY / canvas.height) * 2;

这个在拾取、射线投射里特别常见。

初学者最容易混淆的点

  • NDC 不是像素坐标,它只是标准范围坐标。
  • NDC -> 屏幕 只是线性映射,不再负责透视。
  • WebGL 视口坐标原点是左下,不是浏览器常见的左上。
  • gl.viewport() 会直接影响 NDC 最终落到屏幕的哪里。
  • 深度值也会做映射,z_ndc 最终通常进入 [0, 1] 深度范围。

一句话总结

  • NDC 映射到屏幕像素坐标,就是把 [-1,1] 范围内的标准坐标,按照 gl.viewport() 指定的矩形区域,做一次线性缩放和平移。

最关键公式

text 复制代码
screenX = vx + (x_ndc + 1) * vw / 2
screenY = vy + (y_ndc + 1) * vh / 2
screenZ =      (z_ndc + 1) / 2

最短记忆版

  • -1 -> 左/下
  • 0 -> 中间
  • 1 -> 右/上
  • 再按视口宽高拉伸成实际像素位置

NDC模棱两可的可以看看这篇文章

后续讲讲光栅化是什么

相关推荐
Appthing2 小时前
在 TanStack Start 中使用 VitePWA 的正确配置
javascript
前端炒粉3 小时前
手写promise
java·前端·javascript
mONESY3 小时前
Node.js fs 文件系统模块 四种读写方案全解析
javascript·后端
Larcher3 小时前
从回调地狱到优雅异步:Node.js 文件操作进化史
javascript·后端·架构
熊猫钓鱼>_>4 小时前
Electron:当 Web 技术统治桌面
大数据·前端·javascript·人工智能·架构·electron·agent
江华森4 小时前
Python 实现高德地图找房(三):地图可视化与高德 JS API
开发语言·javascript·python
谭光志4 小时前
AI 是怎么操作浏览器的——browser use 实现原理
前端·javascript·ai编程
用户3074596982075 小时前
GatewayWorker 从零到一完整指南
javascript·php
铁皮饭盒5 小时前
Bun.redis真正的强项
javascript