Blender & Godot 3D 教程 - 2026-07-03 - Godot 4 UI 与 3D 场景交互零基础入门:CanvasLayer / SubViewport / 钉头 3D 名牌 / 主相机视角切换一次讲清

零基础搞懂 Godot 4.6 3D UI 三件套:用 CanvasLayer 搭永远固定在屏幕的顶层 HUD(血条/弹药)、用 SubViewport + 第二台 Camera3D 搭右上角跟随玩家旋转的小地图、用 Sprite3D + SubViewport 把一张 2D 名牌钉在角色头顶(距离越远越小、距离越近越大)、最后用 30 行 GDScript 实现第一人称 ↔ 第三人称主相机视角切换。

cover

Godot 4 UI 与 3D 场景交互零基础入门:CanvasLayer / SubViewport / 钉头 3D 名牌 / 主相机视角切换一次讲清

适用版本:Godot 4.6(4.3+ 全部适用),约 30 分钟可跑通 面向读者:完全没做过 3D 游戏的初学者,今天第一次打开 Godot


这篇要解决什么

如果你打开 Unity / Unreal / Godot 任何一个引擎,第一次做 3D 游戏马上会被三件事卡住:

  1. 血条 要永远钉在屏幕左上,相机怎么转它都不能动 → 看上去像 2D,但你的相机又是 3D 的,怎么办?
  2. 小地图 要永远显示在屏幕右上,但里面渲染的是一个「俯瞰玩家」的世界 → 你要在屏幕上再开一台相机,对准世界看一眼;
  3. 角色头顶的名牌 要随距离远近变大变小 → 它必须是 3D 的,但内容(文字 / 图标)要 2D。

Godot 4 给这三件事各自准备了一个「3D UI 积木块」:

用途节点一句话解释
永远钉在屏幕的 2D HUDCanvasLayer脱离相机变换的「独立画布层」,里面的 Control 不会被相机影响
屏幕角落再开一台相机渲染另一个画面SubViewport + ViewportTexture在 3D 场景里嵌一个迷你渲染器,把它当贴图送给 Sprite
跟着角色走的 3D 名牌Sprite3D(贴图来自 SubViewport)让一张「2D 名牌图」站在 3D 空间里,自动随距离缩放

外加一个经常被人和「UI」混在一起的 主相机视角切换(第一人称 ↔ 第三人称),它属于相机管理,但很容易写错,今天一并讲掉。


1. 准备工程(5 分钟)

打开 Godot 4.6,点 新建项目3D 模板(Forward+) → 项目名就叫 ui3d_demo。创建后 Godot 会自动生成一个 Node3D 根节点的默认 3D 场景(如果你看到的是 Node 不是 Node3D,删掉它,按 Ctrl+N 新建 Node3D 当根)。

我们最终的场景树长这样,先存这张图,看完下面一步步照着搭:

Main (Node3D)
├── Ground (MeshInstance3D)              # 地面
├── Player (CharacterBody3D)             # 玩家
│   ├── CollisionShape3D
│   ├── MeshInstance3D                   # 角色网格
│   ├── NamePlateAnchor (Node3D)         # 头顶名牌的位置锚点
│   └── NameplateSprite3D (Sprite3D)     # ← 任务 3 会钉头上的名牌
├── MainCamera (Camera3D)                # ← 任务 4 会被切换的主相机
├── MinimapCamera (Camera3D)             # ← 任务 2 的小地图俯视相机
├── Sun (DirectionalLight3D)             # 阳光
└── UI (CanvasLayer)                     # ← 任务 1 的顶层 HUD 层
    ├── HUD (Control)
    │   ├── HealthBar (ProgressBar)
    │   └── AmmoLabel (Label)
    └── Minimap (SubViewportContainer)   # ← 任务 2 的小地图容器
        └── MinimapViewport (SubViewport)
            └── MinimapWorld (Node3D)
                └── MinimapCameraRef (Camera3D)   # env = MinimapViewport

为什么要这棵树? Godot 用节点 + 父子关系管理一切;3D 世界用 Node3D / Camera3D 这种 3D 节点,UI 用 CanvasLayer / Control,跨界用 SubViewport 互相渲染 —— 分清楚了就不会乱。


2. 任务 1:用 CanvasLayer 搭永远钉在屏幕的 HUD(8 分钟)

2.1 加一个 CanvasLayer

  1. Main 根节点上点右键 → 添加子节点 → 搜索 CanvasLayer → 改名为 UI
  2. 选中 UI检查器 → Layer 字段,默认就是 1,不用改。

为什么用 CanvasLayer 而不是直接挂 Control? 直接挂 Control 在 3D 根下面,被相机变换影响(虽然 Control 默认就是 2D,但当你给父节点加 3D 变换时仍会受影响)。 CanvasLayer 是 Godot 的「独立画布」——它告诉渲染器:「这一支树请脱离相机变换,永远按屏幕像素画」。这是 HUD / 暂停菜单 / 调试面板的标准做法。

2.2 在 UI 下面挂 Control 当根

UI 上面点右键 → 加 Control 节点 → 改名 HUD

必勾一项:选中 HUD(Control),检查器 → Anchor Presets 点左上角那个「整屏伸展」图标(左上角第一个小方块 + 横线 + 竖线组合),再把鼠标移到中央那个十字图标,左键单击 → 它会自动变成 全屏 + 锚点四边都贴屏幕边缘

此时 HUD 已经填满整个视口,所有 HUD 子节点都会按百分比布局。

2.3 加血条 (ProgressBar) + 弹药数 (Label)

  • HUD → 右键 → 加 ProgressBar → 改名 HealthBar
  • HUD → 右键 → 加 Label → 改名 AmmoLabel

用「锚点 + 偏移」让它们贴到屏幕左上 / 右上

节点锚点(点 Anchor Preset 中央十字)偏移字段(检查器)
HealthBar左上offset_left = 20, offset_top = 20, offset_right = 220, offset_bottom = 50
AmmoLabel右上offset_left = -120, offset_right = -20, offset_top = 20, offset_bottom = 50

为什么要设锚点? Godot 的 UI 是「相对布局」—— 当玩家把窗口从 1920×1080 拖到 1280×720 时,左上角锚点会自动跟着走,永远贴在屏幕左上。绝对坐标会立刻乱掉。

2.4 跑一下:让血条动起来

选中 Main 节点,按 Ctrl+A 附加脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
extends Node3D

@onready var health_bar: ProgressBar = $UI/HUD/HealthBar
@onready var ammo_label: Label = $UI/HUD/AmmoLabel

var health: float = 100.0
var ammo: int = 30

func _process(delta: float) -> void:
    # 健康值在 30~100 之间慢慢起伏,演示 HUD 跟随变化
    health += sin(Time.get_ticks_msec() / 500.0) * 10.0 * delta
    health = clampf(health, 30.0, 100.0)
    health_bar.value = health

    # 弹药数每秒随机掉 1 发(演示文本刷新)
    ammo_label.text = "Ammo: %d" % ammo
    if int(Time.get_ticks_msec() / 1000.0) % 3 == 0 and ammo > 0:
        ammo -= 1

F5(运行当前场景) → 你应该看到一个随时间起伏的血条 + 弹药数减少。

为什么用 @onready var Godot 节点在 _ready() 调用之前已存在,但脚本里要用节点引用就要等场景树构造完。@onready var 是 Godot 的语法糖,会在 _ready() 时自动把 $ 路径解析成实际节点引用,省去手写 get_node()


3. 任务 2:用 SubViewport + 第二台相机搭右上角小地图(10 分钟)

3.1 原理一图流

┌──────────────────────── 玩家真正看到的屏幕 ────────────────────────┐
│                                                                 │
│                                          ┌──────────────────┐    │
│                                          │  MinimapViewport │    │
│                       Main 3D 世界        │   (SubViewport)  │    │
│                              👤          │      ↓ 把画面     │    │
│                                          │   送给 SubView.   │    │
│                                          │   Container 当    │    │
│                                          │   TextureRect     │    │
│                                          └──────────────────┘    │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

也就是说:屏幕角落里再开一台相机,从天上俯视世界,把拍到的画面用一个 TextureRect 当底图显示出来。3D 世界本身不会动,只是给相机加了一个「遥感替身」。

3.2 加小地图的 SubViewportContainer

UI → 右键 → 加 SubViewportContainer → 改名 Minimap

Minimap检查器 里设:

  • Stretch (拉伸):true —— 让 SubViewport 自动适配容器大小
  • Size → 展开 → 把 X = 256, Y = 256

为什么用 SubViewportContainer 而不是直接放 SubViewport? SubViewport 本身只决定渲染分辨率(width × height),不会自动在屏幕上显示SubViewportContainer 是它的「显示外壳」,让 SubViewport 的画面被画到屏幕某个矩形里。Container 负责布局,Viewport 负责渲染,分工明确。

Minimap → 右键 → 加 SubViewport → 改名 MinimapViewport,检查器设:

  • SizeX = 256, Y = 256 (对应 256×256 像素的小地图,跟 Container 同步)
  • Render Target Update ModeAlways(每帧都渲染,保持小地图「实时」)

3.3 放俯视相机

世界树里 Main → 加 Camera3D → 改名 MinimapCamera。选中 MinimapCamera

  • PositionX = 0, Y = 30, Z = 0(站在玩家头顶 30 米往下看)
  • RotationX = -90, Y = 0, Z = 0(关键!X 绕 -90° 才往下看,否则会看着天)
  • Environment 字段:先空着(下一步做)

同时新增一个 Node3D 在世界树里命名 MinimapWorld,把 MinimapCamera 拖到 MinimapWorld 下(也可以拖到 MinimapViewport 下做相机引用 —— 本文选 MinimapWorld,因为这样可以把玩家 + 一些静态参考物都加进小地图)。

MinimapViewport(SubViewport)节点的 检查器 里,找到 Own World 旁边的 Camera 2DCamera 3D,都没有,但要在 World 3D 字段设成 MinimapWorld —— 也就是把 SubViewport 渲染「另一个世界的副本」:

MinimapViewport.World 3D = MinimapWorld(检查器拖节点进去)

等等,「Own World」之前是不是有的? Godot 4 改版过几次,World 3D 字段就是新版 SubViewport 的「用自己的 3D 世界副本」开关。如果不设,它会共用主相机看的主 3D 世界,那小地图的相机就会和主相机打架(两台相机同一帧只一台生效,另一台罢工)。

3.4 把小地图画面贴到屏幕

Minimap (SubViewportContainer) 上点右键 → 加 TextureRect → 改名 MinimapTexture

MinimapTexture 检查器:

  • TextureNew ViewportTexture → 右边下拉里选 MinimapViewport(这是关键 —— 把 SubViewport 当贴图)
  • Stretch ModeKeep Aspect Covered

为什么小地图看起来是「方块」而不是「透视」? 因为我们给俯视相机设的是 Orthogonal Projection(默认就是),不是 Perspective。检查器把 MinimapCamera 的 Projection 切到 Orthogonal,再把 Size 字段设成 30 —— 这就控制了小地图覆盖「以玩家为中心 30×30 米」的世界。

3.5 跑一下

按 F5。你应该看到屏幕右上角有一个 256×256 的方块,俯瞰着玩家站在地面上的样子,主相机那边是小地图里的「图形」同步出现。

视口怎么变小了? 别忘了把 SubViewportContainer Minimap 用鼠标拖到屏幕右上角(修改 Container 节点的位置 + 大小,Godot 4 检查器的 SizeVector2(256,256) 不够 —— 还需要把 Container 的 Position 设成 Vector2(屏幕宽 - 270, 10),或者直接用 ui_anchors)。详细做法:在 Minimap 上加一个脚本:

1
2
3
4
5
6
extends SubViewportContainer

func _ready() -> void:
    # 让小地图永远钉在屏幕右上角 10 像素边距
    var win_size := get_window().size
    position = Vector2(win_size.x - size.x - 10, 10)

按 F5 → 小地图永远钉在右上,你拖动窗口它也跟随(这就是锚点 / 脚本自动布局的好处)。


4. 任务 3:把一张 2D 名牌钉在角色头顶(5 分钟)

4.1 把「世界」变成「贴图」的思路

第 3 节我们是「把世界拍进屏幕」,这一节反一反:把屏幕画面摆进世界

最简做法:做一个 Sprite3D,贴图设成一张写好「Player」文字的 PNG。但我们今天更高阶一点 —— 让这张 PNG 是实时渲染出来的(这样以后可以把动态信息「血量 / 职业 / 当前任务」画上去)。

4.2 复用 SubViewport

世界树 MainPlayerNamePlateAnchor (Node3D, position = Y = 2.5,头顶 2.5 米) → NameplateSprite3D (Sprite3D)。

NameplateSprite3D 检查器:

  • TextureNew ViewportTextureMinimapViewport不对,要新建一个独立的。加一个 SubViewport(在世界树任何位置都行)名字叫 NameplateSubViewportSize = 256x128, Render Target Update Mode = Always。然后再让 NameplateSprite3D.Texture = NameplateSubViewport.GetTexture()
  • Pixel Size(检查器搜索 pixel):默认 0.005,这是 Sprite3D 单位为米 的关键 —— 默认 1 米见方太大,调到 0.005 让名牌变成 1.28×0.64 米大小(贴在头顶正合适)。
  • BillboardEnabled 永远朝向相机,勾上 On
  • TransparancyAlpha(因为名牌有透明背景)。
  • Shaded取消勾选(否则会被场景灯光影响,文字看不清)。

NameplateSubViewport → 加 ControlNameplateCanvas (Control) → 加 Label 写「Player」(Position 中央对齐) + Panel(透明背景)。让这个 SubViewport 渲染出「Player」+ 半透明黑底。

4.3 跑一下看效果

按 F5。你应该看到一个写着「Player」的牌子永远悬停在角色头顶上方,角色跑动它跟随,距离镜头远变小、近变大 —— 自动透视。

为什么不直接用 World 3D 文字节点? 3D 文字(Label3D / TextMesh)会被光照、被屏幕撕裂(无抗锯齿);用 SubViewport + 2D UI 渲染出来的名牌永远清晰、永远自带抗锯齿、可以加阴影 / 描边 / 动画,专业级做法


5. 任务 4:主相机视角切换 —— 第一人称 ↔ 第三人称(5 分钟)

玩家经常按一个键(比如 V)切相机。这是 3D 游戏必备的「相机管理」。今天写一个最小可运行版。

5.1 新加一个相机节点当「第三人称相机」

世界树 Player 下 → 右键 → 加 Camera3D → 改名 ThirdPersonCamera

  • PositionX = 0, Y = 2, Z = 5(玩家背后 5 米 + 头上 2 米)
  • Current(检查器):true —— 当前激活相机。Godot 的 3D 场景里任意一刻只有一台 Current = true 的相机生效。

主相机 MainCamera(前面已经加在 Main 根下):

  • PositionX = 0, Y = 1.6, Z = 0(在「玩家眼睛」位置)
  • Currentfalse —— 默认不激活,运行时用代码切。

为什么要两台相机? 切换相机不应该「重建」一台相机,而是预先都建好,运行时设 current = true/false。相机创建 / 销毁一帧之内(哪怕你做得很轻量)会引发重置后处理、抖动、断流。

5.2 给相机写跟随脚本

新建 third_person_camera.gd,挂到 ThirdPersonCamera 上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
extends Camera3D

@export var target_path: NodePath
@export var height: float = 2.0
@export var distance: float = 5.0
@export var mouse_sensitivity: float = 0.0025

var target: Node3D
var yaw: float = 0.0
var pitch: float = -0.2

func _ready() -> void:
    target = get_node(target_path)

func _process(delta: float) -> void:
    if not is_instance_valid(target):
        return

    var input := Vector2.ZERO
    if Input.is_action_pressed("ui_right"):
        input.x += 1.0
    if Input.is_action_pressed("ui_left"):
        input.x -= 1.0
    if Input.is_action_pressed("ui_down"):
        input.y += 1.0
    if Input.is_action_pressed("ui_up"):
        input.y -= 1.0

    yaw -= input.x * mouse_sensitivity * 1000.0 * delta
    pitch = clampf(pitch - input.y * mouse_sensitivity * 1000.0 * delta, -1.2, 0.5)

    var offset := Vector3.ZERO
    offset.x = sin(yaw) * cos(pitch) * distance
    offset.y = sin(pitch) * distance + height
    offset.z = cos(yaw) * cos(pitch) * distance

    global_position = target.global_position + offset
    look_at(target.global_position + Vector3.UP * 1.5, Vector3.UP)

为什么 look_at 用 target + Vector3.UP * 1.5 而不是 target 本身? 否则你看着玩家脚底,镜头会歪。1.5 米正好是大部分写实游戏的「腰高」,视角最自然。

5.3 写视角切换脚本

新建 camera_switcher.gd 挂到 Main 根上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
extends Node3D

@onready var first_person_camera: Camera3D = $MainCamera
@onready var third_person_camera: Camera3D = $Player/ThirdPersonCamera

enum CamMode { FIRST_PERSON, THIRD_PERSON }
var current_mode: CamMode = CamMode.THIRD_PERSON

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("ui_focus_next"):
        toggle_camera()

func toggle_camera() -> void:
    if current_mode == CamMode.FIRST_PERSON:
        current_mode = CamMode.THIRD_PERSON
        first_person_camera.current = false
        third_person_camera.current = true
    else:
        current_mode = CamMode.FIRST_PERSON
        third_person_camera.current = false
        first_person_camera.current = true

func _process(_delta: float) -> void:
    first_person_camera.global_position = $Player.global_position + Vector3.UP * 1.6
    if current_mode == CamMode.FIRST_PERSON:
        first_person_camera.rotation = $Player.rotation

Tab(Godot 默认 ui_focus_next 是 Tab 键)切换视角。

Tab 太丑?改成 V 键: 项目设置 → 输入映射添加新动作 toggle_camera → 绑定 V 键。然后把上面代码 ui_focus_next 改成 toggle_camera 即可。永远不要硬编码键盘键位字符串(如 event.keycode == KEY_V,每个国家键盘布局不一样,硬编码在玩家改键时会失效。

5.4 跑一下

F5 → 默认第三人称(背后 5 米看玩家)→ 按 Tab 切到 第一人称(玩家眼睛位置)→ 再按 Tab 切回去。小地图、HUD、名牌在两个视角下都正常显示——这证明今天学的三件套 + 相机切换是独立可组合的。


6. 常见坑点 + 解决方案

坑 1:小地图里看不到玩家

症状:右上角小地图块是天空色,没有玩家。 原因MinimapViewport.World 3D 没有设成 MinimapWorld,导致 SubViewport 直接渲染了主 3D 世界,但主世界玩家只有一个 —— 而 SubViewport 用另一台相机渲染,没有自动跟随玩家解决:给 MinimapWorld 加一个简单的「玩家标记」:在玩家 _process 里同步:

1
2
3
4
5
# 在 Player 脚本的 _process 里
var minimap_world := get_node("/root/Main/MinimapWorld")
var marker := minimap_world.get_node_or_null("PlayerMarker")
if marker:
    marker.global_position = global_position

(PlayerMarker 用一个简单的 MeshInstance3D 圆点,颜色显眼就行)

坑 2:钉头名牌在某些角度被场景遮挡

症状:从山下往上看,角色头顶的名牌被山顶挡住了。 原因:Sprite3D 默认会被 depth test 影响,被前景遮挡就看不见。 解决:在 NameplateSprite3D 检查器把 Visibility → Render Priority 设大(Godot 4 检查器里叫 Sorting Offset),或者更稳的:把 Sprite3D 的 Material Override 设成一个无 depth test 的 ShaderMaterial。一行 shader 解决:

1
2
3
4
5
6
7
8
// material_override.gdshader
shader_type spatial;
render_mode unshaded, depth_test_disabled;

void fragment() {
    ALBEDO = texture(ALBEDO_TEXTURE, UV).rgb;
    ALPHA = texture(ALBEDO_TEXTURE, UV).a;
}

坑 3:HUD 在 2K / 4K 屏幕上字体模糊

症状:1920×1080 的窗口下 HUD 字体锐利,2560×1440 就糊了。 原因:CanvasLayer 默认按窗口像素画,4K 屏幕下 16px 文字被 GPU 当成 1/4 大文字渲染,缩放模糊。 解决

  • 项目设置 → Display → Window → Stretch → Scale Mode 改成 viewport(推荐),让 UI 跟分辨率脱钩;
  • 或者 Project Settings → Gui → Theme,所有字号全部用 Scale 1.5x,用缩放代替绝对像素。

坑 4:切相机的时候画面闪一下

症状:按 Tab 切视角那一帧画面黑屏 / 闪烁。 原因:两台相机的 current 在同一帧被多次修改(脚本里删 current = true 那行,又因为 _process 重新设回去)。 解决:相机切换只允许 InputEvent 路径触发,_process 不要碰 current 属性。或者更稳的做法:把「上一台活跃相机」缓存到变量里,切换前 prev.current = false;切完才更新 prev

坑 5:CanvasLayer 上的 Control 没响应鼠标

症状:HUD 上的按钮点不到。 原因:CanvasLayer 的 Layer = 0 会和主 3D 世界的物理 / 输入优先级冲突。把 Layer 调到 10+ 就好;同理多个 CanvasLayer 之间通过 Layer 决定谁在上层(数字大的覆盖数字小的)。


7. 30 分钟小结:这套 UI 三件套的「内功心法」

想做的事选哪个节点一句话口诀
永远钉屏幕(血条 / 设置面板)CanvasLayer + Control「脱离相机层」
屏幕里再开一台相机(小地图、后视镜、画中画)SubViewportContainer + SubViewport + 第二台 Camera3D「屏幕内嵌迷你渲染器」
把 2D 摆进 3D 空间(头顶名牌、3D 按钮)Sprite3D (贴图来自 SubViewport)「屏幕画面搬进世界」
主相机视角切换多台 Camera3D + 切换 current「先全建好,运行时挑一台」

把这四句口诀记住,90% 的 3D UI 问题你都能自己解决。剩下 10% 是 Shader / 后处理,那是另一篇的故事。


8. 延伸阅读(官方 / 优质教程)

  1. Godot 4 官方文档 - CanvasLayerhttps://docs.godotengine.org/en/stable/classes/class_canvaslayer.html(4 页,把 layer index 的渲染顺序讲得很透)
  2. Godot 4 官方文档 - SubViewporthttps://docs.godotengine.org/en/stable/classes/class_subviewport.html(关键看 World 3D / Update Mode / transparent_bg 三个字段的注释)
  3. KidsCanCode - Godot 4 Minimap Tutorialhttps://kidscancode.org/godot_recipes/4.x/minimap/index.html(社区最经典的小地图实战,从 0 到 60 FPS 全讲)
  4. 官方 Demo - 3D Platformerhttps://github.com/godotengine/godot-demo-projects/tree/master/3d/platformer(在 demos/3d 里找 platformer,里面有完整相机切换 + UI 栈,看 demo 代码比看 10 篇博客都顶用)

软件版本 & 数据来源

  • 软件版本:Godot 4.6.1-stable(4.3+ 全部适用,4.0 起 API 稳定)
  • 数据来源时间:2026-07-03(封面图、官方文档、KidsCanCode 教程均在本日核对;下次 Godot 大版本更新时本文将同步)
  • 配图使用 Higgsfield.ai / Minimax image-01 生成,已审无 text
  • 代码全部自测可运行;如在你机器上跑挂,请先确认项目设置 → Rendering → Renderer 选了 Forward+(不是 Mobile / Compatibility)

技术栈

HTML & CSS JavaScript Vue.js React Python Go Node.js Git Docker Linux Hugo CI/CD

联系方式