From 108c803a754051fcce1a25303c4b2d776c271f3f Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sun, 16 Aug 2026 10:08:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(camera):=20=E8=A7=86=E5=9B=BE=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E4=BF=AF=E4=BB=B0=E7=AC=A6=E5=8F=B7=E7=BF=BB=E8=BD=AC?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=A7=86=E8=A7=92=E4=B8=8D=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E9=BC=A0=E6=A0=87=E4=B8=8A=E4=B8=8B=E7=A7=BB=E5=8A=A8=E7=9A=84?= =?UTF-8?q?=E6=A0=B9=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视图矩阵误用正向旋转 R 代替 Rᵀ(RotX(-pitch)·RotY(-yaw)), 低头渲染成抬头、抬头渲染成低头;yaw 因 RotY 对称性侥幸正确。 新增 TestViewPitchSteep/Up/Forward 验证陡俯仰与方向映射。 --- internal/camera/camera.go | 30 +++++++------- internal/camera/pitch_test.go | 77 +++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 internal/camera/pitch_test.go diff --git a/internal/camera/camera.go b/internal/camera/camera.go index cb74bd18..ad9bcfcd 100644 --- a/internal/camera/camera.go +++ b/internal/camera/camera.go @@ -32,27 +32,27 @@ func (c *Camera) Right() [3]float64 { } // View 视图矩阵(列主序 4×4,GL 用)。 -// 推导:相机旋转 R_cam = RotY(yaw)·RotX(pitch);视图 = R_camᵀ·T(-pos)。 -// 旧实现矩阵布局错误导致非零视角下画面全灰(白屏/灰屏根因之一)。 +// 推导:相机旋转 R = RotY(yaw)·RotX(pitch)(世界→相机为 Rᵀ = RotX(-pitch)·RotY(-yaw))。 +// 视图 = Rᵀ·T(-pos)。 +// 旧实现误用正向旋转 R:俯仰角符号被翻转(低头渲染成抬头), +// 表现是「视角不跟随鼠标上下移动」(上下视角失效根因)。 func (c *Camera) View() [16]float32 { cp, sp := math.Cos(c.Pitch), math.Sin(c.Pitch) cy, sy := math.Cos(c.Yaw), math.Sin(c.Yaw) + // V = RotX(-pitch)·RotY(-yaw),按列主序存储(列 0/1/2 为相机基在世界的分量) var m [16]float32 - // 列 0 - m[0] = float32(cy) - m[1] = 0 - m[2] = float32(sy) - // 列 1 - m[4] = float32(-sy * sp) - m[5] = float32(cp) - m[6] = float32(cy * sp) - // 列 2 - m[8] = float32(-sy * cp) - m[9] = float32(-sp) - m[10] = float32(cy * cp) + m[0] = float32(cy) // col0.x + m[1] = float32(sp * sy) // col0.y + m[2] = float32(cp * sy) // col0.z + m[4] = 0 // col1.x + m[5] = float32(cp) // col1.y + m[6] = float32(-sp) // col1.z + m[8] = float32(-sy) // col2.x + m[9] = float32(sp * cy) // col2.y + m[10] = float32(cp*cy) // col2.z m[15] = 1 - // 平移:t = -R·pos + // 平移:t = -V·pos px, py, pz := float32(c.Pos[0]), float32(c.Pos[1]), float32(c.Pos[2]) m[12] = -(m[0]*px + m[4]*py + m[8]*pz) m[13] = -(m[1]*px + m[5]*py + m[9]*pz) diff --git a/internal/camera/pitch_test.go b/internal/camera/pitch_test.go new file mode 100644 index 00000000..4ee7b978 --- /dev/null +++ b/internal/camera/pitch_test.go @@ -0,0 +1,77 @@ +package camera + +import ( + "math" + "testing" +) + +// apply 用视图矩阵变换世界点(列主序语义,与 GL 一致)。 +func apply(m *[16]float32, wx, wy, wz float64) (vx, vy, vz float64) { + vx = float64(m[0])*wx + float64(m[4])*wy + float64(m[8])*wz + float64(m[12]) + vy = float64(m[1])*wx + float64(m[5])*wy + float64(m[9])*wz + float64(m[13]) + vz = float64(m[2])*wx + float64(m[6])*wy + float64(m[10])*wz + float64(m[14]) + return +} + +// TestViewPitchSteep 验证陡俯仰角(低头 60°)下: +// 相机正下方地面 → 相机前方(z<0)且屏幕下方(y<0); +// 相机前方地面 → 相机前方;远处地面 → 相机前方且低于视轴下方。 +// 旧实现俯仰符号翻转:正下方/正前方地面点被映射到相机背后(z>0), +// 低头时画面只剩天空——「视角不跟随鼠标」的根因。 +func TestViewPitchSteep(t *testing.T) { + c := New([3]float64{8.5, 66.66, 8.5}, 0, -60*math.Pi/180, 70) + m := c.View() + cases := []struct { + name string + wx, wy, wz float64 + wantBelow bool // 期望屏幕中心下方 + }{ + {"below", 8.5, 64.0, 8.5, true}, // 正下方地面 + {"ahead", 8.5, 64.0, 5.0, false}, // 前方地面 + {"far-ahead", 8.5, 64.0, 0.0, false}, // 远处地面 + } + for _, tc := range cases { + vx, vy, vz := apply(&m, tc.wx, tc.wy, tc.wz) + t.Logf("%s: view=(%.2f,%.2f,%.2f)", tc.name, vx, vy, vz) + if vz >= 0 { + t.Errorf("%s: 地面点应在相机前方 (z<0),得到 z=%.2f", tc.name, vz) + } + if tc.wantBelow && vy >= 0 { + t.Errorf("%s: 正下方地面应在屏幕下方 (y<0),得到 y=%.2f", tc.name, vy) + } + } +} + +// TestViewPitchUp 抬头 60°:远处地面应仍在相机前方;正下方(与视线夹角 150°)应在相机后方。 +func TestViewPitchUp(t *testing.T) { + c := New([3]float64{8.5, 66.66, 8.5}, 0, 60*math.Pi/180, 70) + m := c.View() + // 远处地面点(平视前方远处,y 比眼睛低) + _, _, vz := apply(&m, 8.5, 66.0, -50) + if vz >= 0 { + t.Errorf("抬头时远处地面应在相机前方 (z<0),得到 z=%.2f", vz) + } + // 正下方点:与视线方向夹角 150°>90°,应在相机后方(z>0) + _, _, vz = apply(&m, 8.5, 50, 8.5) + if vz <= 0 { + t.Errorf("抬头时正下方应在相机后方 (z>0),得到 z=%.2f", vz) + } +} + +// TestViewForward 相机前方向(Forward() 与世界原点前方一点)应映射到视图空间 -z 且 x≈0。 +func TestViewForward(t *testing.T) { + for _, tc := range []struct{ yaw, pitch float64 }{ + {0, 0}, {0, -0.6}, {0.9, 0.3}, {-2.1, 0.77}, + } { + c := New([3]float64{1, 2, 3}, tc.yaw, tc.pitch, 70) + f := c.Forward() + m := c.View() + vx, vy, vz := apply(&m, 1+f[0]*5, 2+f[1]*5, 3+f[2]*5) + if vz >= 0 { + t.Errorf("yaw=%.2f pitch=%.2f: 前方点应在相机前方,得到 z=%.2f", tc.yaw, tc.pitch, vz) + } + if math.Abs(vx) > 1e-3 || math.Abs(vy) > 1e-3 { + t.Errorf("yaw=%.2f pitch=%.2f: 前方点应位于视轴中心,得到 (%.3f,%.3f)", tc.yaw, tc.pitch, vx, vy) + } + } +}