5.2 透视与正交投影
同一个场景,换一个投影矩阵,出来的画面可以完全不同。站内的 GPU 剔除与可见性系统 讨论的是拿到视锥之后怎么高效地筛掉不可见物体;本节回到视锥本身是怎么被矩阵「定义」出来的——透视与正交这两个投影矩阵的逐项差别,以及它对深度与覆盖的直接后果。
两个投影矩阵的逐项推导
透视投影把视锥(截头四棱锥)映射到立方体,正交投影把长方体映射到立方体。两者都是 4x4 矩阵,差别集中在第三行与第四行:
透视(f = 1/tan(fovY/2),a = 宽高比):
[ f/a 0 0 0 ]
[ 0 f 0 0 ]
[ 0 0 (far+near)/(near-far) 2*far*near/(near-far) ]
[ 0 0 -1 0 ]
正交(视景体 x 属于 [l,r],y 属于 [b,t],z 属于 [n,f]):
[ 2/(r-l) 0 0 -(r+l)/(r-l) ]
[ 0 2/(t-b) 0 -(t+b)/(t-b) ]
[ 0 0 -2/(f-n) -(f+n)/(f-n) ]
[ 0 0 0 1 ]
第三行负责把视轴的深度压进 [-1, 1](OpenGL 约定)。透视的第三行含 z 的线性项与常数项,正交的第三行只是线性缩放——这一点在 5.3 节会变成「深度精度分布」的差异。
除以 w 与 w 恒为 1
两个矩阵最本质的区别在最后一行。透视的最后一行是 (0, 0, -1, 0),于是 w = -z_v —— 它随顶点深度变化;正交的最后一行是 (0, 0, 0, 1),于是 w 恒等于 1。
这一行决定了后续一切。透视必须做「除以 w」:NDC = 裁剪坐标 / w,近处的顶点 w 小、被放大,远处的 w 大、被缩小,于是平行线在画面上收敛于灭点。正交根本不做除法:NDC 就是裁剪坐标,世界里的平行线在画面上依然平行,等长的线段在任何深度都投影成等长。
脚本取世界点 (0, 6, 0)(与相机等高、在正前方):它沿视轴的深度是 23.67,实测透视的 w = 1551456(Q16.16,即 23.67),正交的 w = 65536(恰好 1.0)。
同一场景,两种投影
把《烛影山谷》分别用两个矩阵渲染,得到两帧(out/cg_raster_05_2_persp.png 与 _ortho.png):
canvas : 320x240 ortho box x[-18,18] y[-14,14]
persp : covered=43731 (569‰) culled=290 dropped= 0 depth[min/med/max]=60516/63164/65335 span=73‰
hist‰=[0, 0, 0, 0, 0, 0, 0, 0, 0, 1000]
ortho : covered=20692 (269‰) culled=296 dropped= 0 depth[min/med/max]=2134/25253/47168 span=687‰
hist‰=[76, 175, 126, 174, 222, 108, 97, 22, 0, 0]
w(轴点) : persp=1551456 ortho=65536
覆盖率差 : 300‰
| 量 | 透视 | 正交 |
|---|---|---|
| 覆盖像素 | 43731(569‰) | 20692(269‰) |
| 背面剔除 | 290 | 296 |
| 深度 min/med/max | 60516 / 63164 / 65335 | 2134 / 25253 / 47168 |
| 深度量程占用 | 73‰ | 687‰ |
| 深度直方图 | 全落在最高十分位 | 铺满 0–80% 区间 |
最刺眼的一行是深度量程:透视下整个场景的深度都挤在 [60516, 65335],只占满量程的 73‰;正交下则铺开在 [2134, 47168],占了 687‰。原因是透视的深度对世界距离是非线性的,而《烛影山谷》恰好都落在 6 到 64 这个「远端」区间里,非线性把它全推到了量程顶端。覆盖率也差了一倍多(569‰ 对 269‰):透视放大了近处的球体,正交则对所有深度一视同仁。
为什么正交投影在工程制图里仍然有用
正交投影丢掉了「近大远小」,这在渲染里通常是缺点,但在工程里是刚需:尺寸可以直接在图上量取(画出来的 3 cm 就是 3 cm),平行关系保持(投影后的直角还是直角),轴测图、建筑立面图、机械装配图都靠这个性质。UI 与 2D 精灵同样用正交——没有透视就没有不必要的变形;方向光的阴影贴图也用正交,因为平行光的视锥本来就是一个方盒,用透视反而会引入畸变。
代码
S 层脚本(复用卷一的 build_scene,同一场景渲染两次):
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_05_2_perspective_ortho.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_05_2_perspective_ortho.py
"""5.2 透视与正交投影:用同一份 scene_spec.json 的同一场景,
分别经透视投影与正交投影各渲染一帧,并量化两者的差别。
关键差别:透视矩阵最后一行是 (0,0,-1,0),w = -z_view 随深度变化,
片元要「除以 w」;正交矩阵最后一行是 (0,0,0,1),w 恒为 1,不做透视除法。
产物:
out/cg_raster_05_2_persp.png / out/cg_raster_05_2_ortho.png 两帧成片
out/cg_raster_05_2_persp_depth.png / out/cg_raster_05_2_ortho_depth.png 深度图
out/cg_raster_05_2_depth_hist.csv 两种投影的深度直方图
out/cg_raster_05_2_compare.json 覆盖率 / 深度分布对比
"""
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
from render_raster import build_scene # noqa: E402 复用卷一的场景装配,避免重复
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
DEPTH_MAX = np.iinfo(np.int64).max
# 正交投影的视景体(视图空间,x 右 / y 上 / -z 前),全定点 Q16
ORTHO_L, ORTHO_R = -18 * ONE, 18 * ONE
ORTHO_B, ORTHO_T = -14 * ONE, 14 * ONE
def mat_ortho(l, r, b, t, n, f):
"""OpenGL 约定正交投影:z 由 [n, f] 线性映射到 [-1, 1],全定点。
最后一行 (0,0,0,ONE):w 恒为 1,故不需要透视除法。
"""
rl = r - l
tb = t - b
fn = f - n
return (
cglib.fdiv(2 * ONE, rl), 0, 0, -cglib.fdiv(r + l, rl),
0, cglib.fdiv(2 * ONE, tb), 0, -cglib.fdiv(t + b, tb),
0, 0, -cglib.fdiv(2 * ONE, fn), -cglib.fdiv(f + n, fn),
0, 0, 0, ONE,
)
def render(spec, mvp, verts, tris, vcolors):
"""按给定 MVP 投影并光栅化,返回 (color, depth, covered, culled, dropped)。"""
width, height = spec["width"], spec["height"]
screen = []
for v in verts:
p = cglib.project(mvp, v)
if p is None:
screen.append(None)
continue
sx, sy = cglib.to_screen(p[0], p[1], width, height)
screen.append((sx, sy, p[2]))
rtris, dropped = [], 0
for a, b, c in tris:
sa, sb, sc = screen[a], screen[b], screen[c]
if sa is None or sb is None or sc is None:
dropped += 1
continue
rtris.append(
(
sa[0], sa[1], sa[2], vcolors[a][0], vcolors[a][1], vcolors[a][2],
sb[0], sb[1], sb[2], vcolors[b][0], vcolors[b][1], vcolors[b][2],
sc[0], sc[1], sc[2], vcolors[c][0], vcolors[c][1], vcolors[c][2],
)
)
color, depth, covered, culled = cglib.rasterize(rtris, width, height, spec["backgroundQ8"])
return color, depth, covered, culled, dropped
def depth_hist(depth, covered, bins=10):
"""覆盖像素的归一化深度直方图(每档千分比)。"""
vals = depth[covered]
if vals.size == 0:
return [0] * bins
edges = np.linspace(0, ONE + 1, bins + 1)
hist = [0] * bins
for i in range(bins):
lo, hi = edges[i], edges[i + 1]
hist[i] = int(((vals >= lo) & (vals < hi)).sum())
total = int(vals.size)
return [round(h * 1000 / total) for h in hist]
def main():
spec = cglib.load_spec()
width, height = spec["width"], spec["height"]
cam = spec["camera"]
aspect = cglib.fdiv(cglib.f_from_int(width), cglib.f_from_int(height))
view = cglib.mat_look_at(tuple(cam["eyeQ16"]), tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
persp = cglib.mat_perspective(cam["tanHalfFovYQ16"], aspect, cam["nearQ16"], cam["farQ16"])
ortho = mat_ortho(ORTHO_L, ORTHO_R, ORTHO_B, ORTHO_T, cam["nearQ16"], cam["farQ16"])
verts, tris, vcolors = build_scene(spec)
out = os.path.join(ROOT, "out")
result = {}
for name, proj in (("persp", persp), ("ortho", ortho)):
mvp = cglib.mat_mul(proj, view)
color, depth, covered, culled, dropped = render(spec, mvp, verts, tris, vcolors)
cglib.save_png(os.path.join(out, f"cg_raster_05_2_{name}.png"), color)
cglib.save_gray_png(os.path.join(out, f"cg_raster_05_2_{name}_depth.png"), depth)
vals = depth[covered]
dmin = int(vals.min()) if vals.size else 0
dmax = int(vals.max()) if vals.size else 0
result[name] = {
"covered": int(covered.sum()),
"coverage_permille": int(covered.sum()) * 1000 // (width * height),
"culled": culled,
"dropped": dropped,
"depth_min": dmin if vals.size else None,
"depth_median": int(np.median(vals)) if vals.size else None,
"depth_max": dmax if vals.size else None,
"depth_span_permille": (dmax - dmin) * 1000 // ONE,
"depth_hist_permille": depth_hist(depth, covered),
}
# 透视 vs 正交的深度分布对比(世界距离尺度)
# 透视:depth 非线性,近处密集;正交:depth 随 -z_view 线性。
# w 的实测:取相机轴上一个点,透视 w = 距离,正交 w = 1。
axis_pt = (0, cam["eyeQ16"][1], 0)
w_persp = cglib.project(cglib.mat_mul(persp, view), axis_pt)
w_ortho = cglib.project(cglib.mat_mul(ortho, view), axis_pt)
compare = {
"canvas": [width, height],
"ortho_box": {"l": ORTHO_L, "r": ORTHO_R, "b": ORTHO_B, "t": ORTHO_T},
"perspective": result["persp"],
"orthographic": result["ortho"],
"coverage_delta_permille": result["persp"]["coverage_permille"] - result["ortho"]["coverage_permille"],
"w_on_axis_point": {
"perspective_w": None if w_persp is None else w_persp[3],
"orthographic_w": None if w_ortho is None else w_ortho[3],
},
}
with open(os.path.join(out, "cg_raster_05_2_compare.json"), "w", encoding="utf-8") as fh:
json.dump(compare, fh, ensure_ascii=False, indent=2)
bins = [f"{i*10}-{(i+1)*10}" for i in range(10)]
cglib.write_csv(
os.path.join(out, "cg_raster_05_2_depth_hist.csv"),
["depth_bin_pct"] + bins,
[
["perspective"] + result["persp"]["depth_hist_permille"],
["orthographic"] + result["ortho"]["depth_hist_permille"],
],
)
print(f"canvas : {width}x{height} ortho box x[{ORTHO_L//ONE},{ORTHO_R//ONE}] y[{ORTHO_B//ONE},{ORTHO_T//ONE}]")
for name in ("persp", "ortho"):
r = result[name]
print(
f"{name:<6}: covered={r['covered']:>5} ({r['coverage_permille']}‰) "
f"culled={r['culled']:>3} dropped={r['dropped']:>3} "
f"depth[min/med/max]={r['depth_min']}/{r['depth_median']}/{r['depth_max']} "
f"span={r['depth_span_permille']}‰"
)
print(f" hist‰={r['depth_hist_permille']}")
print(f"w(轴点) : persp={compare['w_on_axis_point']['perspective_w']} "
f"ortho={compare['w_on_axis_point']['orthographic_w']}")
print(f"覆盖率差 : {compare['coverage_delta_permille']}‰")
return 0
if __name__ == "__main__":
sys.exit(main())
P 层核心断言(完整文件 core/test/cg_raster_05_2_perspective_ortho.test.ts):
// snippet: 5.2 P 层核心断言
test("正交投影:所有顶点的裁剪空间 w 恒为 ONE(不做透视除法)", () => {
const { ws } = render(matMul(ortho, view));
for (const w of ws) assert.equal(w, ONE);
});
test("透视投影:w = -z_view 随深度变化", () => {
const { ws } = render(matMul(persp, view));
assert.ok(new Set(ws.map(String)).size > 1);
});
test("两帧覆盖像素数与 Python 侧 S 层逐位相同", () => {
assert.equal(render(matMul(persp, view)).covered, 43731);
assert.equal(render(matMul(ortho, view)).covered, 20692);
});
node --test 'core/test/cg_raster_05_2_perspective_ortho.test.ts'
ℹ tests 5 ℹ pass 5 ℹ fail 0
阅读导航:上一节:5.1 针孔相机模型 · 下一节:5.3 视锥与近远平面 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。