5.2 透视与正交投影

逐项推导透视与正交两个投影矩阵:透视最后一行 (0,0,-1,0) 让 w=-z_view 随深度变化、片元要除以 w,正交最后一行 (0,0,0,1) 让 w 恒为 1、不做透视除法。用同一场景分别渲染两帧并量化覆盖率与深度分布差别,说明正交为何在工程制图里仍然有用。

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‰)
背面剔除290296
深度 min/med/max60516 / 63164 / 653352134 / 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 视锥与近远平面 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图