下载 App
10/16案例分享
【案例分享】正交相机的 orthoSize 是全高还是半高:一个 0.5 因子,让我在等距视角里调了两天
一句话结论
camera.orthoSize 设的是视野全高,引擎内部拿 orthoSize * 0.5 当半高用。你手算屏幕到世界坐标时漏掉这个 0.5,结果就会和 GetScreenRay 差整整 2 倍,缩放补偿也会偏掉一半、锚点漂移。
我遇到的症状
等距视角的推图游戏,鼠标滚轮缩放。需求很朴素:鼠标指着哪块地,缩放完还得指着那块地。
第一版写完,缩小时地图往左上跑,放大时往右下跑。鼠标底下那格永远在溜走。
同时还有第二个问题。我写了个手算的屏幕到世界转换,拿去跟 camera:GetScreenRay 的输出并排打印。同一个鼠标位置,两个世界坐标的每个分量都差 2.0 倍。不是差一点点,是整整一倍。
整数倍对不上,基本可以断定是某个约定搞反了,不是浮点误差。
答案就在引擎源码里
Camera.cpp 第 960 行,正交投影把视图空间坐标转成 NDC 的那一步:
float h = (1.0f / (orthoSize_ * 0.5f)) * zoom_;
h 是视图空间转 NDC 的缩放系数。反推回去:
ndcY = viewY * h = viewY / (orthoSize * 0.5)
viewY = ndcY * orthoSize * 0.5
orthoSize * 0.5 就是半高。所以 orthoSize 本身是全高——设成 20,纵向可见范围是 20 个世界单位,不是 10。
我一开始把它当半高,代码里写 viewY = ndcY * orthoSize,于是每一个结果都翻倍。这个错误不会报错、不会崩,只是你算出来的数和引擎算出来的数差一个固定倍数。
先花两句话说清 NDC
NDC 就是归一化设备坐标:把屏幕按比例压到 -1 到 1 的区间,屏幕中心是 (0, 0),左边缘 -1、右边缘 +1,上边缘 +1、下边缘 -1。
引擎内部算射线、算投影都走这套坐标,所以任何「像素转世界」的换算都得先过一遍它。像素转 NDC 就两行:
local ndcX = (screenX / W) * 2 - 1 -- 左 -1,右 +1
local ndcY = 1 - (screenY / H) * 2 -- 上 +1,下 -1
y 那行的 1 - ... 不能省。屏幕坐标原点在左上角、y 向下;NDC 原点在中心、y 向上。我漏过一次,症状是点击位置上下镜像——这个比缩放漂移好认多了。
屏幕到世界:ScreenToWorld
等距视角下最省事的做法是直接用 GetScreenRay,让引擎去做投影,你只负责让射线跟地面 y = 0 求交:
-- 屏幕像素坐标 → 世界坐标(射线与 y = 0 平面求交)
-- graphics: 引擎的 graphics 单例
-- camera: 你的 Camera 组件
local function ScreenToWorld(screenX, screenY)
-- 1) 像素 → 0..1 归一化屏幕坐标
local normalizedX = screenX / graphics.width
local normalizedY = screenY / graphics.height
-- 2) 取射线。注意 GetScreenRay 收的是 0..1,不是 NDC,也不是像素
local ray = camera:GetScreenRay(normalizedX, normalizedY)
-- 3) 与 y = 0 求交:origin.y + direction.y * t = 0
if math.abs(ray.direction.y) < 0.001 then
return nil -- 射线平行于地面,无交点
end
local t = -ray.origin.y / ray.direction.y
if t < 0 then
return nil -- 交点在相机背后
end
return Vector3(
ray.origin.x + ray.direction.x * t,
0,
ray.origin.z + ray.direction.z * t)
end
这里有个容易搞混的点:GetScreenRay 的入参是 0..1 的归一化屏幕坐标,不是 NDC 的 -1..1,也不是像素。我第一版传了 NDC,射线完全打到别处去了。
缩放到鼠标位置:补偿量为什么是「差值」
核心原理一句话:缩放前后,鼠标指向的世界坐标保持不变。
所以要做的事是——算出「同一个屏幕点,在旧 orthoSize 和新 orthoSize 下对应的世界坐标差多少」,然后把这个差补到相机位置上。
local function CalcZoomCompensation(mouseX, mouseY, oldOrthoSize, newOrthoSize)
local aspect = graphics.width / graphics.height
local ndcX = (mouseX / graphics.width) * 2 - 1
local ndcY = 1 - (mouseY / graphics.height) * 2
local deltaOrtho = oldOrthoSize - newOrthoSize
-- 相机基向量(世界空间,cameraNode 为相机所在节点)
local camRight = cameraNode.worldRight
local camUp = cameraNode.worldUp
local camForward = cameraNode.worldDirection
-- 乘以 0.5(引擎使用 orthoSize * 0.5 作为半视野)
local viewDeltaX = ndcX * aspect * deltaOrtho * 0.5
local viewDeltaY = ndcY * deltaOrtho * 0.5
-- 转换到世界空间(沿相机 right 和 up 方向)
local offsetX = camRight.x * viewDeltaX + camUp.x * viewDeltaY
local offsetY = camRight.y * viewDeltaX + camUp.y * viewDeltaY
local offsetZ = camRight.z * viewDeltaX + camUp.z * viewDeltaY
-- 射线与 y=0 平面相交的补偿
if math.abs(camForward.y) > 0.001 then
local t = -offsetY / camForward.y
offsetX = offsetX + camForward.x * t
offsetZ = offsetZ + camForward.z * t
end
return offsetX, offsetZ
end
用法:
local deltaX, deltaZ = CalcZoomCompensation(mouseX, mouseY, oldOrthoSize, newOrthoSize)
local camPos = cameraNode.position
cameraNode.position = Vector3(camPos.x + deltaX, camPos.y, camPos.z + deltaZ)
camera.orthoSize = newOrthoSize
注意 deltaOrtho 是「旧减新」。写反了,缩放时地图会朝反方向跑——症状很好认,但改之前得先想到是符号问题。
参数表
orthoSize:视野全高(世界单位)。设 20 就是纵向可见 20 个单位。
aspect:graphics.width / graphics.height,宽高比。
GetScreenRay 入参:0..1 归一化屏幕坐标(不是 NDC、不是像素)。
NDC 范围:-1..1,中心为原点,y 轴向上。
补偿公式里的 0.5:来自引擎内部 orthoSize * 0.5 的半高约定。
GetScreenRay 缓存:每次调用实时计算,改完 orthoSize 立即生效,不需要 MarkDirty。
适用边界
这是正交投影的特性,透视相机不适用——透视相机的可见范围由 FOV 和距离决定,没有 orthoSize 这个概念。
GetScreenRay 每次调用都会基于当前相机状态实时计算。改完 camera.orthoSize 立刻调用就能拿到正确结果,不用等下一帧。
可迁移的经验
看到 2 倍误差,先怀疑引擎的半高/全高约定。
整数倍的偏差几乎不可能是浮点误差或精度问题,它一定是某个约定搞反了。我这次是 orthoSize 的半高/全高,之前还遇到过坐标系 Y 轴方向、NDC 的 0..1 与 -1..1、角度制与弧度制——全是同一类问题。
排查顺序建议:
1. 先打印两套算法的输出并排对比,确认差值是不是固定倍数
2. 是固定倍数就去翻引擎源码找那个系数,不要靠试
3. 找到系数后,把它的语义写进注释——下次别人(或你自己)改到这儿能直接看懂