· 4 分钟阅读 · 1649 字
宽屏相机多看了两侧:固定横向构图需要反算纵向视场角
窗口从 16:9 拉到 21:9 后,如果相机仍使用原来的纵向视场角,画面两侧会多出一截内容。角色没有移动,镜头距离也没有改变,但原先贴着屏幕右边缘的目标退到了画面内部。若这台相机承担瞄准、对话构图或固定机位演出,宽屏用户看到的空间关系已经不同。
问题来自 FOV 的轴向含义。多数透视矩阵接口接收纵向 FOV,而构图规则可能要求保持水平 FOV。两者不能按宽高比直接乘除;透视投影缩放的是半角正切。本文用独立 C# 探针固定水平 FOV 为 90°,检查 16:9、21:9 和 9:16 三种视口下同一条 45° 边缘射线的位置。探针使用 System.Numerics.Matrix4x4 生成真实投影矩阵,没有把概念图当作运行结果。

保持的是半角投影尺度
设纵向 FOV 为 v,水平 FOV 为 h,宽高比 a = width / height。对称透视视锥在近面上的半高与半宽都与半角正切成正比,因此:
tan(h / 2) = tan(v / 2) * a
h = 2 * atan(tan(v / 2) * a)
v = 2 * atan(tan(h / 2) / a)
角度本身不是线性长度。把 h 直接除以 a,在 90°、16:9 下会得到 50.625°;把这个值代回透视关系,恢复出的水平 FOV 只有 80.116009°。错误公式在小角度时可能显得接近,因为 tan(x)≈x,但这种近似没有覆盖本文的大视角与竖屏边界。
探针的关键实现位于 .tmp/daily-labs/2026-10-21-1930/CameraFov.cs。外部策略提交水平 FOV,适配层依据当前视口反算纵向 FOV,再调用只接受纵向角的矩阵接口。相机空间约定朝向负 Z。
using System.Numerics;
public static class CameraFov
{
public static double HorizontalFromVertical(double verticalRadians, double aspect)
{
ValidateFov(verticalRadians, nameof(verticalRadians));
ValidateAspect(aspect);
return 2 * Math.Atan(Math.Tan(verticalRadians / 2) * aspect);
}
public static double VerticalFromHorizontal(double horizontalRadians, double aspect)
{
ValidateFov(horizontalRadians, nameof(horizontalRadians));
ValidateAspect(aspect);
return 2 * Math.Atan(Math.Tan(horizontalRadians / 2) / aspect);
}
public static Matrix4x4 CreatePerspectiveForHorizontal(
double horizontalRadians, double aspect, double near, double far)
{
ValidateDepth(near, far);
double vertical = VerticalFromHorizontal(horizontalRadians, aspect);
float verticalFloat = (float)vertical;
float aspectFloat = (float)aspect;
float nearFloat = (float)near;
float farFloat = (float)far;
if (!float.IsFinite(verticalFloat) || verticalFloat <= 0 || verticalFloat >= MathF.PI
|| !float.IsFinite(aspectFloat) || aspectFloat <= 0
|| !float.IsFinite(nearFloat) || nearFloat <= 0
|| !float.IsFinite(farFloat) || farFloat <= nearFloat)
throw new ArgumentOutOfRangeException(nameof(aspect));
return Matrix4x4.CreatePerspectiveFieldOfView(
verticalFloat, aspectFloat, nearFloat, farFloat);
}
public static double ProjectNdcX(Vector3 cameraPoint, Matrix4x4 projection)
{
if (!IsFinite(cameraPoint) || cameraPoint.Z >= 0)
throw new ArgumentOutOfRangeException(nameof(cameraPoint));
Vector4 clip = Vector4.Transform(new Vector4(cameraPoint, 1), projection);
if (!float.IsFinite(clip.X) || !float.IsFinite(clip.W) || clip.W <= 0)
throw new ArgumentOutOfRangeException(nameof(projection));
return clip.X / clip.W;
}
private static void ValidateFov(double radians, string name)
{
if (!double.IsFinite(radians) || radians <= 0 || radians >= Math.PI)
throw new ArgumentOutOfRangeException(name);
}
private static void ValidateAspect(double aspect)
{
if (!double.IsFinite(aspect) || aspect <= 0)
throw new ArgumentOutOfRangeException(nameof(aspect));
}
private static void ValidateDepth(double near, double far)
{
if (!double.IsFinite(near) || !double.IsFinite(far)
|| near <= 0 || far <= near)
throw new ArgumentOutOfRangeException(nameof(near));
}
private static bool IsFinite(Vector3 value) =>
float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z);
}
本次双精度往返样本限定水平角 10° 至 170°、宽高比 0.25 至 4;矩阵投影只验证上述固定视口。靠近 0、π 或浮点表示极限时,还需要矩阵有限性与精度检查。输入验证与换算属于同一提交边界。FOV 必须位于 (0, π),宽高比必须为有限正数,近面为正且远面大于近面。这样矩阵创建失败时,调用者仍可保留上一份完整相机快照,而不会把新宽高比与旧投影矩阵拼成一帧不一致状态。
固定纵向角会改变横向构图
固定水平 FOV 90° 时,16:9 需要纵向 FOV 58.715507°,21:9 需要 46.397181°,9:16 则需要 121.284493°。探针把 (1,0,-1) 归一化为 45° 边缘射线,经三份 System.Numerics 投影矩阵变换后,NDC x 分别为 1、1、1.000000119。最后一个差值来自矩阵的单精度分量,测试容差为 2e-7。

失败回归把 16:9 的纵向 FOV 原样用于 21:9。此时水平 FOV 变成 105.392103°,原来的右边缘射线落在 NDC x=0.761905,意味着屏幕两侧出现额外内容。它不是分辨率提高带来的细节增加,而是投影所覆盖的方向范围已经扩大。
Program.cs 的失败回归直接保留错误策略,防止修复后只测试新公式自洽:
double baseVertical = CameraFov.VerticalFromHorizontal(Math.PI / 2, 16.0 / 9);
var wrongProjection = Matrix4x4.CreatePerspectiveFieldOfView(
(float)baseVertical, 21f / 9f, 0.1f, 1000f);
double wrongX = CameraFov.ProjectNdcX(
Vector3.Normalize(new Vector3(1, 0, -1)), wrongProjection);
if (Math.Abs(wrongX - 16.0 / 21) > 2e-7)
throw new Exception("FixedVerticalMovesEdgeInward");
当天在 .NET SDK 9.0.202、Release 配置下执行:
dotnet build "$PROBE_PROJECT/FovLab.csproj" -c Release
dotnet run --project "$PROBE_PROJECT/FovLab.csproj" -c Release --no-build
构建退出码 0,零警告、零错误;运行退出码 0,23 项通过、失败 0、跳过 0,测试程序计时 72.0023 ms。固定种子 20261021 另取 10,000 组水平 FOV 与宽高比,执行“水平转纵向再转回水平”,最大角度误差为 5.088887490341627E-14°。这组耗时只说明本次检查确实执行,不用于性能结论。
边界样本包含 1:1、32:9 与 9:32。方形视口的横纵 FOV 相等;极宽窗口需要收窄纵向角,极高窗口则把纵向角推近 180°。后者虽然在公式定义域内,仍可能产生强烈透视畸变,因此产品层通常还要给策略设置可接受范围,并在超出范围后改用裁切、镜头距离或安全区规则。
相机策略应先声明要守住哪条轴
保持水平 FOV 适合横向构图不能变化的固定机位、竞技瞄准与多设备画面一致性;保持纵向 FOV 则会让宽屏自然获得更多两侧内容。两种策略都能生成合法矩阵,差别是产品选择,不能由底层矩阵 API 的参数形式替代决定。
实现边界也应保持清楚:相机策略拥有要固定的轴和目标角,视口系统提供宽高比,投影适配层一次生成纵向 FOV 与矩阵快照。窗口尺寸变化时,这三项应按同一视口修订提交。本文只验证对称透视视锥;非对称投影、分屏裁切、物理相机传感器偏移和镜头畸变需要各自的构图合同,不能只套用一对横纵角。