最终效果

2026-07-19-22-58-46 (1).gif


一、需求拆解

首先,控制器包含两个各方面:character的控制和摄像机的控制

先想清楚有哪些功能需要实现:

  • 按 WASD 在球面上移动,且
    移动方向是”相对于摄像机”的——按 W 就是往屏幕深处走,按 A 就是往屏幕左边走
  • 按空格跳跃,可以在空中再跳一次(二段跳)
  • 移动鼠标可以旋转视角,上下左右都能看,但上下俯仰角度限制(防止穿模)
  • 不管走到球的哪个位置,脚都稳稳踩在球面上

把上面的描述拆成技术问题,我们会得到四个模块:

  1. 球面重力 — 平地上重力方向不变,球面上每个点都不一样
  2. 球面移动 — “前方”是摄像机决定的,要投影到球面上
  3. 身体对齐 — 走到球的侧面或背面时,身体要跟着倾斜
  4. 摄像机 — 跟随角色 + 鼠标旋转,还不能和角色的朝向互相打架

顺序很重要:先做重力,再做移动,然后处理身体对齐,最后做摄像机。因为每一步都依赖上一步的结果。


二、搭建场景

在写代码之前,先把场景节点搭好。

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 的后上方。这就实现了第三人称视角。

在编辑器中把 Camera3Dcurrent 属性勾上,让它成为场景的活跃摄像机。位置设为 (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

这条经验的适用范围

不只是球面行走。任何时候如果你在做一个”把朝向投影到曲面”的操作,不管是球形、圆柱形、还是不规则地形——只要你的流程是”读取旋转 → 投影 → 写回旋转”,就会踩这个坑。解法都一样:输入和输出分开存,也就是解耦。


六、摄像机

摄像机要做三件事:

  1. 跟着角色走(角色走到球背面,摄像机也要跟过去)
  2. 响应鼠标——左右移动鼠标时旋转水平视角,上下移动时改变俯仰角
  3. 不能和角色的朝向产生之前说的那种反馈循环

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°)
        )

InputEventMouseMotionrelative 属性是这一帧鼠标移动的像素数。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_usedis_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 空格

这样就完成了类似《马里奥银河》的第三人称控制器