核心结论
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:视口左下角 xvy:视口左下角 yvw:视口宽度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 -> 00 -> 0.51 -> 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 = 0z_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 -> 右/上- 再按视口宽高拉伸成实际像素位置