游戏中的数学 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)m、FOV=60°、宽高比 16/9,令 depth=-z=5m,得到 x_ndc=0.389711,再由 (x_ndc+1)·1920/2 得到 screen_x=1334.122px。图还标出 near=0.1m、far=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.2m、z=-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 层补偿。