本文属于系列「游戏中的数学:从坐标到可同步运动」第 14 / 15 篇

游戏中的数学 14:世界点怎样变成屏幕点

两个方向

血条跟随、世界标记和鼠标拾取看似是 UI 与输入问题,底层却共享同一对变换:世界点投到屏幕,屏幕点反投影成世界射线。实验严格使用右手相机空间:+X 向右、+Y 向上、前方为 -Z,并明确拒绝相机后方以及近远裁剪范围外的点。

// $WORK_DIR/.tmp/game-math-lab/Program.cs
// GameMathLab.Project
static Vector2 Project(
    Vector3 p, double width, double height,
    double fov, double aspect,
    double near = .1, double far = 100)
{
    var depth = -p.Z;
    if (depth < near || depth > far)
        return new(float.NaN, float.NaN);

    var tan = Math.Tan(DegToRad(fov / 2));
    var nx = p.X / (depth * tan * aspect);
    var ny = p.Y / (depth * tan);
    return new(
        (float)((nx + 1) * .5 * width),
        (float)((1 - ny) * .5 * height));
}

相机层拥有视图参数,UI 只接收像素位置和可见性;拾取层接收归一化射线。任何一层都不应把屏幕误差回写成第 13 章的物理位置。

引擎接入还要把矩阵乘法顺序、前向轴、NDC 深度范围与屏幕原点成组写进边界。只改 Y 翻转通常能让中心点“看起来对”,却会让斜向拾取和裁剪在平台切换后悄悄偏离。

透视除法

先用 View 矩阵把世界点减去固定相机位置 (3,-2,5)m,再令正深度 depth=-z_camera。横向归一坐标除以 depth·tan(fov/2)·aspect,纵向除以 depth·tan(fov/2);随后把 [-1,1] 映射到 1920×1080 像素,并翻转屏幕 Y。

FOV 使用弧度参与三角函数,60 度只在入口转换一次。宽高比只作用于横轴;若同时改动纵轴,超宽屏会二次缩放。视口变换最后执行,所以分辨率变化不会改变世界射线的方向定义。

// Program.cs · GameMathLab.Unproject
static Vector3 Unproject(
    Vector2 s, double width, double height,
    double fov, double aspect)
{
    var nx = s.X / width * 2 - 1;
    var ny = 1 - s.Y / height * 2;
    var tan = Math.Tan(DegToRad(fov / 2));

    return Vector3.Normalize(new(
        (float)(nx * tan * aspect),
        (float)(ny * tan),
        -1));
}

透视投影相似三角形与固定数值推导

图中不是处理流程,而是可复算的相似三角形:固定相机点 P=(2,1,-5)mFOV=60°、宽高比 16/9,令 depth=-z=5m,得到 x_ndc=0.389711,再由 (x_ndc+1)·1920/2 得到 screen_x=1334.122px。图还标出 near=0.1mfar=100m-Z 前方。

射线回代

反投影只恢复方向,不能从单个屏幕点恢复原深度。因此验证方法不是直接比较两个三维点,而是把原点投影到反投影射线上,计算点到射线距离,再将最近点投回屏幕比较像素。

这是一个可观测性选择:点到射线误差用米,屏幕回代误差用像素。前者检查三维几何,后者检查视口映射。只看其中一个可能掩盖错误,例如投影与反投影使用了同一种错误约定而互相抵消。

// Program.cs · Chapter14 回代主链
var screen = Project(p, width, height, fov, aspect);
var ray = Unproject(screen, width, height, fov, aspect);

var along = Vector3.Dot(p, ray);
var closest = ray * along;
var rayError = Vector3.Distance(p, closest);
var screen2 = Project(closest, width, height, fov, aspect);

maxRay = Math.Max(maxRay, rayError);
maxPixel = Math.Max(
    maxPixel,
    Vector2.Distance(screen, screen2));

if (i % 100 == 0)
    depthErrors.Add(new(depth, rayError));

深度与反投影点到射线残差曲线

视锥样本

固定种子先在相机空间生成 10,000 个视锥内点:深度 0.2..90.2mz=-depth,横纵位置限制在当前深度半视锥的 90% 内;再通过相机位置构造世界点,并用 View 平移变回相机空间。正常路径必须保持亚像素,失败路径分别拒绝后方、近裁剪内与远裁剪外的点。

// Program.cs · Chapter14 输入与断言
const double width = 1920, height = 1080;
const double fov = 60, aspect = width / height;
const double near = .1, far = 100;
var cameraPosition = new Vector3(3, -2, 5);
var view = Matrix4x4.CreateTranslation(-cameraPosition);
var random = new Random(1414);

for (var i = 0; i < 10_000; i++)
{
    var depth = .2 + random.NextDouble() * 90;
    var halfY = depth * Math.Tan(DegToRad(fov / 2));
    var cameraPoint = new Vector3(
        (float)((random.NextDouble() * 1.8 - .9) * halfY * aspect),
        (float)((random.NextDouble() * 1.8 - .9) * halfY),
        (float)-depth);
    var worldPoint = cameraPoint + cameraPosition;
    var p = Vector3.Transform(worldPoint, view);
    // 执行上一段回代主链
}

Check(maxPixel < 1e-3,
    "project-unproject ray roundtrip stays subpixel");
Check(double.IsNaN(Project(new Vector3(0, 0, 1),
        width, height, fov, aspect, near, far).X),
    "behind-camera point is rejected");
Check(double.IsNaN(Project(new Vector3(0, 0, -.05f),
        width, height, fov, aspect, near, far).X),
    "near-clipped point is rejected");
Check(double.IsNaN(Project(new Vector3(0, 0, -101),
        width, height, fov, aspect, near, far).X),
    "far-clipped point is rejected");

失败时序是:后方点或裁剪外点进入透视除法 → 得到貌似合法的屏幕坐标 → UI 错误显示或拾取不可见物体。提前返回 NaN 让可见性判断显式发生,不能依赖偶然的视口裁剪。

近远裁剪面已进入同一标量函数,闭区间 [0.1,100]m 与投影共享相机快照。接入引擎矩阵时仍需核对 NDC 深度范围和边界开闭,否则 CPU 标签与 GPU 光栅化会出现一帧分歧。

固定运行

进程退出码为 0,15 章全通过。本章最大屏幕回代误差为 0.000137329px,最大点到射线误差为 2.47955×10⁻⁵m;它们是正确性残差,不是渲染性能数据。

cd "$WORK_DIR"
/usr/bin/time -p dotnet run \
  --project .tmp/game-math-lab/GameMathLab.csproj \
  -- --out .tmp/game-math-lab/out
CH14 PASS samples=10000 max_pixel_error=1.373E-004px max_ray_error=2.480E-005m behind=rejected near=rejected far=rejected
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
exit_code=0
{
  "samples": 10000,
  "width_px": 1920,
  "height_px": 1080,
  "fov_deg": 60,
  "near_m": 0.1,
  "far_m": 100,
  "camera_position": { "x": 3, "y": -2, "z": 5 },
  "max_pixel_error": 0.0001373291015625,
  "max_point_to_ray_error_m": 2.47955322265625E-05,
  "behind_camera": "rejected",
  "near_clip": "rejected",
  "far_clip": "rejected"
}

显示边界

手写标量公式便于复算,也把约定暴露得很清楚;完整引擎应复用同一 View/Projection 矩阵,避免 CPU UI 与 GPU 渲染产生两套 FOV 或 Y 方向。若进入超宽屏、反向 Z 或抖动投影,再把这些参数纳入同一相机快照。

下一章会让世界坐标跨越多个分区并经过网络量化。相机仍消费重构后的局部坐标,屏幕射线仍由本章合同生成;大世界精度与网络插值不能靠 UI 层补偿。