最终效果

一、需求拆解
首先,控制器包含两个各方面:character的控制和摄像机的控制
先想清楚有哪些功能需要实现:
- 按 WASD 在球面上移动,且
移动方向是”相对于摄像机”的——按 W 就是往屏幕深处走,按 A 就是往屏幕左边走 - 按空格跳跃,可以在空中再跳一次(二段跳)
- 移动鼠标可以旋转视角,上下左右都能看,但上下俯仰角度限制(防止穿模)
- 不管走到球的哪个位置,脚都稳稳踩在球面上
把上面的描述拆成技术问题,我们会得到四个模块:
- 球面重力 — 平地上重力方向不变,球面上每个点都不一样
- 球面移动 — “前方”是摄像机决定的,要投影到球面上
- 身体对齐 — 走到球的侧面或背面时,身体要跟着倾斜
- 摄像机 — 跟随角色 + 鼠标旋转,还不能和角色的朝向互相打架
顺序很重要:先做重力,再做移动,然后处理身体对齐,最后做摄像机。因为每一步都依赖上一步的结果。
二、搭建场景
在写代码之前,先把场景节点搭好。
2.1 星球
创建一个 Node3D 作为根节点(我叫它 TestScene),然后加三个子节点:
- MeshInstance3D — 用来显示星球的样子。用
SphereMesh,半径设为 6。 - StaticBody3D — 让星球有碰撞体。在它下面放一个
CollisionShape3D,形状选SphereShape3D,半径同样设为 6,和模型的半径一致。 - DirectionalLight3D — 一个方向光,让场景亮起来。
2.2 角色
角色做成独立场景方便复用。新建一个场景,根节点选 CharacterBody3D,命名为 Player。给它加两个子节点:
- CollisionShape3D — 碰撞体。用
CapsuleShape3D(胶囊体),半径 0.3,高度 1.0。 - MeshInstance3D — 角色外观。用
CapsuleMesh,参数和碰撞体一样。在它下面还可以再挂一个MeshInstance3D(比如一个小方块当”鼻子”),用来区分角色的前后方向。
保存为 player.tscn,然后把它拖进测试场景里,放在星球上方(位置大约 (0, 6.5, 0))。
2.3 摄像机
由于摄像机也要复用,所以摄像机最好也设为独立场景。新建一个场景,根节点用 Node3D,命名为 CameraRig。在它下面放一个 Camera3D 子节点。
CameraRig 只是一个容器节点——它本身不做任何显示,只负责”站在角色的位置上”和”响应鼠标旋转”。真正的摄像机是它的子节点 Camera3D,两者的位置关系是:rig 在角色身上,摄像机在 rig 的后上方。这就实现了第三人称视角。
在编辑器中把 Camera3D 的 current 属性勾上,让它成为场景的活跃摄像机。位置设为 (0, 1.5, 3),意思是站在 rig 后上方。
保存为 camera_rig.tscn,拖进测试场景。
2.4 最终节点树
TestScene (Node3D)
├── DirectionalLight3D
├── MeshInstance3D # 星球的外观
├── StaticBody3D
│ └── CollisionShape3D # 星球的碰撞体
├── Player (CharacterBody3D)
│ ├── CollisionShape3D
│ └── MeshInstance3D # 胶囊体
│ └── MeshInstance3D # "鼻子"
└── CameraRig (Node3D)
└── Camera3D # 真正的摄像机,current=true
Player 和 CameraRig 是平级的兄弟节点,都在 TestScene 下面。它们之间没有父子关系——这点很重要,后面讲摄像机的时候会解释原因。
三、球面重力
平时做 3D 角色,重力简单得很:
velocity.y -= gravity * delta
因为地面是平的,”下”永远是世界坐标的 -Y 方向。
但现在地面是一个球。”下”不再是固定方向——站在球的顶部时,下是 -Y;走到侧面时,下变成了 XZ 方向;走到球的底部时,下甚至变成了 +Y。
思路
把”下”定义成从角色指向星球中心的方向。因为星球放在原点 (0, 0, 0),所以:
重力方向 = (角色位置 → 原点).归一化()
脚底法线 = -重力方向 # "上"就是"下"的反方向
每一帧都要重新算——因为角色在动,位置变了,重力方向也跟着变。
Godot 的 CharacterBody3D 有一个内置属性叫 up_direction。只要把每一帧的”上”写进去,is_on_floor()(判断落地)和 move_and_slide()(移动)都会自动适配,不需要自己重写任何东西。
代码
在 player.gd 里:
extends CharacterBody3D
const PLANET_CENTER := Vector3.ZERO # 星球在原点
@export var gravity := 24.0
func _physics_process(delta: float) -> void:
# 1. 这一帧的重力方向:从我指向星球中心
var gravity_direction := global_position.direction_to(PLANET_CENTER)
# 2. 脚底的"上"就是重力的反方向
var surface_up := -gravity_direction
# 3. 告诉引擎:这帧的"上"是这个方向
up_direction = surface_up
# 4. 重力加速度
velocity += gravity_direction * gravity * delta
# 5. 物理移动(自动适配 up_direction)
move_and_slide()
gravity 用 @export 声明,后面加 var,这样就能在编辑器里直接调参数,不用每次改代码。
四、球面移动
玩家按 WASD 的时候,角色应该在”球面上的前后左右”移动。但 WASD 本身只是一组数字,需要我们把它翻译成世界空间里的移动方向。
问题在于:”前”是什么?不应该是世界坐标的 +X 或 -Z,而应该是玩家在屏幕上看到的”前”。也就是摄像机正在看的方向。
但摄像机在球体外面,它的视线是斜向下的。如果直接把摄像机的朝向拿来用,角色会被”按进”球里。我们需要的是:摄像机朝向在球面上的投影——去掉斜向下的分量,只保留贴在球面上的分量。
思路
分成三步:
第一步:获取摄像机朝向,投影到球面。
-camera.basis.z 是摄像机的前方向量。然后调用 .slide(surface_up),这个方法会把向量里”沿法线方向”的那部分砍掉,剩下的就是贴在球面上的部分。
可以这样理解:把一根铅笔斜靠在桌子上,铅笔是摄像机的视线方向,桌面是你的球面局部。slide(桌面法线) 得到的就是铅笔在桌面上的影子。这就是我们想要的”贴在球面上的前方”。
第二步:用投影后的方向 + 输入,算出移动方向。
有了”贴着球面的前方”和脚底法线,叉积一下就得到”贴着球面的右方”:
右 = 前方.cross(脚底法线)
移动方向 = 右 × 水平输入 + 前 × 垂直输入
第三步:平滑加速。
如果速度瞬间从 0 跳到满速,手感会很差。用一个叫 move_toward() 的方法让当前速度逐步靠近目标速度,类似”踩油门慢慢提速”的感觉。
代码
在 _physics_process 里调用这个方法:
@export var move_speed := 5.0
@export var move_acceleration := 30.0
func handle_movement(delta: float, surface_up: Vector3) -> void:
# 获取输入(A/D = 水平,W/S = 垂直)
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
if input.length_squared() < 0.001:
return # 没按任何键,跳过
# 拿当前活跃的摄像机
var player_camera := get_viewport().get_camera_3d()
if player_camera == null:
return
# 摄像机的"前方",投影到球面上
var forward := -player_camera.global_transform.basis.z
forward = forward.slide(surface_up)
# 边缘情况:如果摄像机恰好在球的正背面,投影结果可能为零
if forward.length_squared() < 0.001:
forward = -global_transform.basis.z
forward = forward.slide(surface_up)
forward = forward.normalized()
var right := forward.cross(surface_up).normalized()
# 组合出移动方向(注意:input.y 是 W/S,对应前后)
var move_direction := (right * input.x - forward * input.y).normalized()
var target_velocity := move_direction * move_speed
# 只处理"贴着球面"的速度,法线方向的速度不管
var sideways_velocity := velocity.slide(surface_up)
sideways_velocity = sideways_velocity.move_toward(target_velocity, move_acceleration * delta)
# 拼回去:切线速度 + 法线速度
velocity = sideways_velocity + surface_up * velocity.dot(surface_up)
五、身体对齐
角色站在球顶时身体是直的,走到侧面时身体要横过来,走到球底时身体要完全倒过来。身体的”上”必须每帧重新对齐到脚底法线。
第一反应(有 bug)
自然想到:每帧取出角色当前的朝向 → 投影到球面 → 更新旋转。
# 直觉写法——但有问题
var forward := -global_transform.basis.z # 取当前朝向
forward = forward.slide(surface_up) # 投影
forward = forward.normalized()
global_transform.basis = Basis.looking_at(forward, surface_up) # 更新旋转
逻辑通顺,代码干净。但跑起来之后你会发现:角色在没有按任何键的情况下,自己在原地缓慢旋转。 不是抖动,是缓缓地、持续地往一个方向转。
为什么
在脑子里把每一帧过一遍:
- 第 1 帧:取朝向 →
slide()投影(产生极其微小的偏差,大概小数点后几位)→ 写回basis - 第 2 帧:取朝向(已经是”被微调过的”了)→
slide()再投影(又偏差一丁点)→ 写回 - 第 3 帧:取朝向(偏差已经累积了两帧)→ …
每帧的输出变成了下一帧的输入。 slide() 和 normalized() 在数学上不是精确恒等的——每次运算会有浮点数级别的误差。单独看,这点误差完全可以忽略。但当误差被反复喂回循环,它就是滚雪球。
打个比方:你把话筒对着音箱。一开始只有人耳听不到的电路噪声,但噪声被音箱放大 → 话筒收到放大后的噪声 → 再放大 → 再收 → 再放大……几轮之后就变成了尖锐的啸叫声。我们的代码也在做同样的事:把输出的微小偏差当输入再处理,偏差被反复”放大”。
根本矛盾:朝向变量同时扮演了”输入”和”输出”两个角色。 输入依赖输出,输出又变成输入,形成了闭环。
这个是 3D 游戏物理和角色控制里极其隐蔽又非常经典的一个“坑”,在Unity里,我们经常冻结轴,就是强制将旋转速度归零来对抗这个误差。
怎么修
把输入和输出拆开。拿一个独立变量 surface_heading 专门记录”我想要的朝向”。
- 初始值:世界空间的 +Z 方向
- 每帧:从这个变量出发做投影 → 把结果同时写回变量和角色的 basis
- 永远不从
global_transform.basis里读朝向
var surface_heading := Vector3.FORWARD # 独立维护,初始朝 +Z
func align_to_surface(surface_up: Vector3) -> void:
var forward := surface_heading.slide(surface_up)
if forward.length_squared() < 0.001:
forward = Vector3.FORWARD.slide(surface_up)
forward = forward.normalized()
surface_heading = forward
global_transform.basis = Basis.looking_at(forward, surface_up)
surface_heading 每帧只经过一次 slide(),不存在”自己的输出给自己当输入”的问题。循环断了。
在 _physics_process 里调用它:
func _physics_process(delta: float) -> void:
var gravity_direction := global_position.direction_to(PLANET_CENTER)
var surface_up := -gravity_direction
up_direction = surface_up
align_to_surface(surface_up) # ← 身体对齐
handle_movement(delta, surface_up) # ← 移动
handle_jump(surface_up) # ← 跳跃
velocity += gravity_direction * gravity * delta
move_and_slide()
if is_on_floor():
jumps_used = 0
这条经验的适用范围
不只是球面行走。任何时候如果你在做一个”把朝向投影到曲面”的操作,不管是球形、圆柱形、还是不规则地形——只要你的流程是”读取旋转 → 投影 → 写回旋转”,就会踩这个坑。解法都一样:输入和输出分开存,也就是解耦。
六、摄像机
摄像机要做三件事:
- 跟着角色走(角色走到球背面,摄像机也要跟过去)
- 响应鼠标——左右移动鼠标时旋转水平视角,上下移动时改变俯仰角
- 不能和角色的朝向产生之前说的那种反馈循环
6.1 为什么 CameraRig 和 Camera3D 要分开
回到场景结构:
CameraRig (Node3D) ← 只有脚本,没有显示
└── Camera3D ← 真正的摄像机,偏移 (0, 1.5, 3)
这套”支架 + 摄像机”的组合有明确分工:
- CameraRig:跟角色的位置同步,处理鼠标旋转,保持与球面对齐。你可以把它想成一个”站在角色肩膀上的透明架子”。
- Camera3D:相对于支架偏移
(0, 1.5, 3),提供了第三人称的距离感和高度。它自己不写代码,全靠父节点带着动。
如果只有一个 Camera3D 挂在角色下面,就会出现一个问题:角色的旋转会影响摄像机。角色走一步身体一晃,视角就跟着晃,画面很难看。把摄像机从角色身上拆出来,用平级的 CameraRig 代替,视角就独立了。
6.2 为什么用 _process 而不是 _physics_process
角色控制用的是 _physics_process,因为物理计算需要固定的时间步长。但摄像机用 _process 更合适——它是每帧渲染前执行,跟屏幕刷新率同步。摄像机的旋转和跟随不需要物理精度,画面流畅更优先。
6.3 鼠标旋转
我们希望的效果是:
- 鼠标左右移动 → 摄像机绕着角色水平旋转(偏航)
- 鼠标上下移动 → 摄像机抬头或低头(俯仰)
- 俯仰不能太极端——不能低头看到地底下去,也不能抬头看到自己头顶
Godot 里鼠标事件在 _unhandled_input 里接收。我们把鼠标移动量存下来,在 _process 里统一应用:
var yaw_delta := 0.0 # 这一帧的水平旋转量
var look_pitch := 0.0 # 当前的俯仰角(弧度)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
# 鼠标水平移动 → 积累偏航
yaw_delta -= event.relative.x * mouse_sensitivity
# 鼠标垂直移动 → 更新俯仰,但要限制范围
look_pitch = clampf(
look_pitch - event.relative.y * mouse_sensitivity,
deg_to_rad(min_pitch_degrees), # 最低能看多少度(比如 -25°)
deg_to_rad(max_pitch_degrees) # 最高能看多少度(比如 30°)
)
InputEventMouseMotion 的 relative 属性是这一帧鼠标移动的像素数。yaw_delta 是累加的——因为同一帧内可能有多个鼠标事件。在 _process 末尾把它清零,下帧重新开始。
最后加一个按 Esc 释放鼠标的功能,方便测试时切出去:
elif event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
6.4 球面旋转
摄像机也需要和球面对齐——不能角色站在球面上,摄像机却用世界空间的”上”来旋转。
和角色身体对齐的思路一样,摄像机也维护自己的独立朝向变量:
var camera_forward := Vector3.FORWARD
func _process(delta: float) -> void:
# 1. 跟着角色走
global_position = player.global_position
# 2. 计算脚底法线(和角色用同样的算法)
var surface_up := player.global_position.normalized()
# 3. 用自己的 camera_forward,不是角色的 basis
var forward := camera_forward.slide(surface_up).normalized()
# 4. 应用鼠标旋转:先在法线上旋转(水平),再在右方向上旋转(垂直)
forward = forward.rotated(surface_up, yaw_delta)
var right := surface_up.cross(forward).normalized()
forward = forward.rotated(right, look_pitch)
# 5. 更新朝向
global_transform.basis = Basis.looking_at(forward, surface_up)
camera_forward = forward
yaw_delta = 0.0 # 消费掉,下帧重新积累
注意第 3 步:用的是 camera_forward,不是 player.global_transform.basis.z。这和角色那边”不用自己的 basis”是同一个道理——切断输入和输出之间的循环。
6.5 角色和摄像机之间的关系
它们共享位置:camera_rig.global_position = player.global_position。
但朝向各自独立:角色有 surface_heading,摄像机有 camera_forward。谁也不读谁的 basis。
角色身体对齐时,用了 surface_heading。摄像机旋转时,用了 camera_forward。它们唯一的联系是通过”位置”——摄像机跟着角色走——但不会通过”旋转”互相影响。
6.6 完整的 camera_rig.gd
extends Node3D
@export_range(0.001, 0.01, 0.0005) var mouse_sensitivity := 0.003
@export_range(-89.0, 0.0, 1.0) var min_pitch_degrees := -25.0
@export_range(0.0, 89.0, 1.0) var max_pitch_degrees := 30.0
@onready var player: CharacterBody3D = get_node("../Player")
var yaw_delta := 0.0
var look_pitch := 0.0
var camera_forward := Vector3.FORWARD
func _ready() -> void:
global_position = player.global_position
camera_forward = -global_transform.basis.z
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _process(delta: float) -> void:
global_position = player.global_position
var surface_up := player.global_position.normalized()
var forward := camera_forward.slide(surface_up).normalized()
forward = forward.rotated(surface_up, yaw_delta)
var right := surface_up.cross(forward).normalized()
forward = forward.rotated(right, look_pitch)
global_transform.basis = Basis.looking_at(forward, surface_up)
camera_forward = forward
yaw_delta = 0.0
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
yaw_delta -= event.relative.x * mouse_sensitivity
look_pitch = clampf(
look_pitch - event.relative.y * mouse_sensitivity,
deg_to_rad(min_pitch_degrees),
deg_to_rad(max_pitch_degrees)
)
elif event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
七、跳跃
跳跃反而是最简单的。因为前面设置了 up_direction,Godot 已经知道怎么判断”站在地面上”。我们只需要把跳跃冲量加到 surface_up 方向:
@export var first_jump_speed := 9.8
@export var second_jump_speed := 7.5
var jumps_used := 0
func handle_jump(surface_up: Vector3) -> void:
if not Input.is_action_just_pressed("jump"):
return
if is_on_floor():
# 一段跳
velocity += surface_up * first_jump_speed
jumps_used = 1
elif jumps_used == 1:
# 二段跳(空中只能再跳一次)
velocity += surface_up * second_jump_speed
jumps_used = 2
二段跳力度比一段小一点(7.5 vs 9.8),这样手感有层次。jumps_used 在 is_on_floor() 时归零,所以落地之后就又能跳了。
八、完整代码总览
player.gd
extends CharacterBody3D
const PLANET_CENTER := Vector3.ZERO
@export_category("Movement")
@export var gravity := 24.0
@export var move_speed := 5.0
@export var move_acceleration := 30.0
@export var first_jump_speed := 9.8
@export var second_jump_speed := 7.5
var jumps_used := 0
var previous_turn_input := Vector2.ZERO
var surface_heading := Vector3.FORWARD
@onready var player_mesh: MeshInstance3D = $MeshInstance3D
func _physics_process(delta: float) -> void:
var gravity_direction := global_position.direction_to(PLANET_CENTER)
var surface_up := -gravity_direction
up_direction = surface_up
align_to_surface(surface_up)
handle_movement(delta, surface_up)
handle_jump(surface_up)
velocity += gravity_direction * gravity * delta
move_and_slide()
if is_on_floor():
jumps_used = 0
func align_to_surface(surface_up: Vector3) -> void:
var forward := surface_heading.slide(surface_up)
if forward.length_squared() < 0.001:
forward = Vector3.FORWARD.slide(surface_up)
forward = forward.normalized()
surface_heading = forward
global_transform.basis = Basis.looking_at(forward, surface_up)
func handle_movement(delta: float, surface_up: Vector3) -> void:
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
if input.length_squared() < 0.001:
return
var player_camera := get_viewport().get_camera_3d()
if player_camera == null:
return
var forward := -player_camera.global_transform.basis.z
forward = forward.slide(surface_up)
if forward.length_squared() < 0.001:
forward = -global_transform.basis.z
forward = forward.slide(surface_up)
forward = forward.normalized()
var right := forward.cross(surface_up).normalized()
var move_direction := (right * input.x - forward * input.y).normalized()
var target_velocity := move_direction * move_speed
var sideways_velocity := velocity.slide(surface_up)
sideways_velocity = sideways_velocity.move_toward(target_velocity, move_acceleration * delta)
velocity = sideways_velocity + surface_up * velocity.dot(surface_up)
func handle_jump(surface_up: Vector3) -> void:
if not Input.is_action_just_pressed("jump"):
return
if is_on_floor():
velocity += surface_up * first_jump_speed
jumps_used = 1
elif jumps_used == 1:
velocity += surface_up * second_jump_speed
jumps_used = 2
camera_rig.gd
(见第六章末尾)
输入映射
在项目设置 → Input Map 里添加以下动作(如果还没加的话):
| 动作名 | 按键 |
|---|---|
move_forward |
W, 上箭头 |
move_back |
S, 下箭头 |
move_left |
A, 左箭头 |
move_right |
D, 右箭头 |
jump |
空格 |
这样就完成了类似《马里奥银河》的第三人称控制器

评论区