GameTech/Godot 4.7 互动教程
目录示例游戏
第四部分 · 3D 实战 · 第 15 章

3D 基础:空间、相机、光照与材质

从 2D 跨到 3D 要补的几块基础:右手坐标系和 -Z 前方、Transform3D 与 Basis、欧拉角和四元数、相机投影、光源和阴影、PBR 材质、WorldEnvironment,以及 Compatibility 渲染器在 3D 上能做什么、不能做什么。

第四部分用一个小型第一人称射击游戏(FPS)把 3D 的主要知识串起来。本章先不讲玩法,只讲"把一个 3D 场景搭起来并且看着顺眼"需要的东西:坐标怎么表示、物体怎么旋转、相机怎么把三维投到屏幕上、光和材质怎么配合、环境怎么调。接下来两章再分别讲第一人称控制器和战斗系统。

下面就是本部分的示例。它运行在 Compatibility 渲染器上(Web 导出只能用这个),整个竞技场由代码搭建,贴图是用 Python 生成的,没有任何外部美术素材。点击画面后鼠标会被锁定,按 Esc 释放。本章只显示和画面有关的参数。

先别急着打,把太阳阴影关掉再打开,同时看右边"实时数据"里的绘制调用:关掉阴影后,从大约 160 次降到 90 次左右。阴影要从光源的角度把场景再画一遍(这里是两级级联,所以是两遍),这是 3D 里最常见的性能开销之一。

坐标系:Y 朝上,-Z 是前方

Godot 的 3D 世界用右手坐标系:伸出右手,拇指指向 +X(右),食指指向 +Y(上),中指自然弯向你自己,就是 +Z。也就是说,+Z 指向屏幕外、朝着观察者,而"往前走"是往 -Z 走。Godot 的相机、灯光、look_at() 都约定 -Z 是"前方"。

软件坐标系上方前方
Godot右手+Y-Z
Unity左手+Y+Z
Blender右手+Z-Y
glTF 文件格式右手+Y+Z(模型"正面"朝 +Z)

最后一行是个常见坑:从 Blender 导出的 glTF 模型,正面通常朝 +Z,导入 Godot 后"脸朝后"。要么在导入时转 180°,要么用 look_at(target, Vector3.UP, true)(第三个参数 use_model_front 表示按 +Z 为前方来对准)。本例的敌人是用方块拼的,面罩放在 -Z 一侧,所以不用处理。

Node3D 和 Transform3D

2D 里的 Node2D 对应 3D 里的 Node3D,所有 3D 节点都继承它。它的位置、旋转、缩放合在一起是一个 Transform3D,由两部分组成:

  • basis(Basis):一个 3×3 矩阵。它的三列 basis.x、basis.y、basis.z 分别是物体自己的 X、Y、Z 轴在父坐标系里指向哪里。旋转和缩放都存在这里。
  • origin(Vector3):位置,就是 position。

把一个"局部坐标"的点变成父坐标系里的点,公式是 p' = basis × p + origin,代码里直接写 transform * p。这一点在 FPS 里随处可见:玩家的输入是"相对自己"的(前、后、左、右),乘上身体的 basis 就变成了世界方向;枪口在相机的局部坐标里,乘上相机的变换就得到世界坐标。

snippets/transform_basics.gd第 7–18 行Transform3D 的两个部分,以及局部 ↔ 世界坐标的换算在工程中查看 ↗
func transform_parts() -> void:
	# Transform3D = basis(3×3 矩阵:旋转 + 缩放)+ origin(位置)
	var t := global_transform
	var right := t.basis.x        # 物体自己的 +X(右)在世界里指向哪里
	var up := t.basis.y           # 自己的 +Y(上)
	var forward := -t.basis.z     # Godot 约定 -Z 是"前方"
	var pos := t.origin           # 等于 global_position
	# 把局部坐标转成世界坐标:先乘 basis(旋转、缩放),再加 origin(平移)
	var muzzle_local := Vector3(0.2, -0.1, -0.5)
	var muzzle_world := t * muzzle_local          # 等价于 t.basis * muzzle_local + t.origin
	var back_to_local := t.affine_inverse() * muzzle_world
	print(right, up, forward, pos, back_to_local)

下面这个小部件是一个用 canvas 手写的线框渲染器。拖动三个欧拉角滑块,立方体按 Godot 的方式旋转;彩色的三根短轴就是 basis.x(红)、basis.y(绿)、basis.z(蓝),下方显示的 3×3 矩阵的三列正是它们的坐标。注意蓝色那根是 +Z,物体的"前方"是它的反方向,用虚线标出来了。

欧拉角:顺序很重要

rotation(弧度)和 rotation_degrees(度)是欧拉角:用绕 X、Y、Z 三根轴各转多少度来描述朝向。直观,但有两个问题。

第一,旋转顺序不同,结果不同。Godot 默认按 YXZ 的顺序组合:先绕 Y 轴转(偏航 yaw,左右看),再绕 X 轴转(俯仰 pitch,上下看),最后绕 Z 轴转(翻滚 roll)。这个顺序是专门为"站在地上的角色和相机"选的:先决定面朝哪个方向,再在这个方向上抬头低头,最符合直觉。在小部件里把顺序切到 XYZ,用同样的三个角度,立方体会停在不同的位置。

第二,万向节锁(gimbal lock)。把 X 转到 ±90°(正上方或正下方),这时 Y 轴和 Z 轴的旋转效果重合了:拖 Y 和拖 Z 都是让物体绕同一根轴打转,丢掉了一个自由度。在小部件里点演示万向节锁可以亲眼看到。FPS 相机通过把俯仰角限制在 ±88° 来避开这个点(下一章的 max_pitch)。

snippets/transform_basics.gd第 23–31 行欧拉角的几种写法在工程中查看 ↗
func euler_examples() -> void:
	# rotation 是欧拉角(弧度),rotation_degrees 是同样的值换成度。
	# Godot 默认的旋转顺序是 YXZ:先绕 Y(偏航),再绕 X(俯仰),最后绕 Z(翻滚)。
	rotation_degrees = Vector3(-30, 45, 0)        # 抬头 30°,向左转 45°
	var b := Basis.from_euler(Vector3(deg_to_rad(-30), deg_to_rad(45), 0.0))   # 同样的朝向
	print(b.get_euler())                          # 再从 Basis 读回欧拉角
	# 相对当前朝向再转一点:rotate_y 绕父节点的 Y 轴,rotate_object_local 绕自己的轴
	rotate_y(deg_to_rad(10))
	rotate_object_local(Vector3.RIGHT, deg_to_rad(5))

四元数:做插值时用它

四元数(Quaternion)用四个数表示一个旋转,没有万向节锁,两个朝向之间可以用 slerp() 走最短的弧线平滑过渡。对欧拉角直接做 lerp 可能"绕远路"(比如从 350° 到 10°,欧拉角插值会反着转 340°)。日常写代码不需要理解四元数的数学,记住用法即可:要在两个朝向之间平滑过渡时,把 Basis 转成 Quaternion,slerp 完再转回来。

snippets/transform_basics.gd第 36–41 行用四元数在两个朝向之间平滑过渡在工程中查看 ↗
## 在两个朝向之间平滑过渡:转成四元数做球面插值(slerp),不会出现万向节锁,
## 走的也是最短的弧线。直接对欧拉角做 lerp 可能会"绕远路"。
func turn_towards(target_basis: Basis, weight: float) -> void:
	var from := Quaternion(basis.orthonormalized())
	var to := Quaternion(target_basis.orthonormalized())
	basis = Basis(from.slerp(to, weight))
只绕一根轴转的时候用不着四元数。示例里敌人转身只改 rotation.y,用 lerp_angle() 就够了:它会自动处理 359° 到 1° 这种跨越边界的情况,走最短的方向。

look_at:让 -Z 对准目标

look_at(目标位置, 上方向) 会重新设置 basis,让节点的 -Z 指向目标。第二个参数告诉它"哪边是上",不能和视线方向平行(直直往上看时要换一个上方向,示例的弹孔代码就处理了这种情况)。如果只想让角色水平转身而不是"仰头",把目标的高度换成自己的高度再 look_at。

snippets/transform_basics.gd第 46–52 行look_at 的两种常见用法在工程中查看 ↗
func face_target(target: Node3D) -> void:
	# look_at 让自己的 -Z 指向目标;第二个参数是"上"方向,不能和视线平行
	look_at(target.global_position, Vector3.UP)
	# 只想水平转身(不抬头低头)时,把目标的高度换成自己的高度
	var flat := Vector3(target.global_position.x, global_position.y, target.global_position.z)
	if not flat.is_equal_approx(global_position):
		look_at(flat, Vector3.UP)

示例中的敌人没有直接用 look_at,而是用 atan2(-dir.x, -dir.z) 算出"让 -Z 对准某方向需要的偏航角",再用 lerp_angle 平滑地转过去。这是 look_at 的平滑版本,代码在第 17 章的敌人移动部分。

一个 Node3D 的 basis 是单位矩阵(没有旋转)。它"面朝"世界坐标的哪个方向?

  1. +X
  2. +Z
  3. -Z
  4. +Y

Godot 约定 -Z 是前方。没有旋转时,物体的 -Z 和世界的 -Z 重合,所以面朝世界的 -Z。代码里常写 -global_basis.z 来取"前方"。

相机:透视、FOV 和裁剪面

Camera3D 把三维场景投影到屏幕上。透视投影(默认)近大远小,所有能看到的东西落在一个四棱台(视锥,frustum)里。它由三个数决定:

  • fov:视野角。Godot 默认 keep_aspect = KEEP_HEIGHT,所以 fov 指垂直方向的角度,水平视野由窗口宽高比算出来:tan(水平/2) = tan(fov/2) × 宽/高。16:9 下 fov 75° 对应水平约 107°。
  • near:近裁剪面,比它更近的东西不画。第一人称的枪离眼睛只有几十厘米,所以示例里设成 0.03 米。
  • far:远裁剪面,比它更远的东西不画。

一个点离视线中心偏了 θ 角,在屏幕上离中心的距离是 tan(θ) / tan(fov/2) × 半屏高。第 17 章的准星会用这个公式把"散布角"换算成像素。

正交投影没有近大远小,画面里一米永远是同样多的像素,size 是画面能看到的高度(米)。常用于等距视角的策略游戏、小地图、2.5D 横版游戏。

snippets/transform_basics.gd第 57–70 行相机的常用设置和屏幕 ↔ 世界坐标换算在工程中查看 ↗
func setup_camera(cam: Camera3D) -> void:
	cam.projection = Camera3D.PROJECTION_PERSPECTIVE
	cam.fov = 75.0                 # 垂直视野角(keep_aspect 默认是 KEEP_HEIGHT)
	cam.near = 0.05                # 近裁剪面:比它近的东西不画
	cam.far = 250.0                # 远裁剪面:near 和 far 的比值越大,深度精度越差
	# 正交相机:没有近大远小,size 是画面的高度(米),常用于等距视角和小地图
	var top_down := Camera3D.new()
	top_down.projection = Camera3D.PROJECTION_ORTHOGONAL
	top_down.size = 40.0
	# 屏幕坐标 ↔ 世界坐标:鼠标拾取、在 3D 物体头顶显示 2D 血条都要用到
	var screen_pos := cam.unproject_position(Vector3(0, 1, -5))
	var ray_dir := cam.project_ray_normal(screen_pos)
	print(screen_pos, ray_dir)
	top_down.free()
near 不要设得太小、far 不要设得太大。深度缓冲的精度主要分配在靠近 near 的地方,far / near 的比值越大,远处的面越容易互相"打架"闪烁(z-fighting)。本例 near = 0.03、far = 250,场景只有 40 米,没有问题;如果做大地图,先把 near 调大到 0.1 左右。
拖示例里的视野角 FOV:40° 像望远镜,远处的敌人很大但视野很窄;110° 能看到很宽的范围,但画面边缘明显被拉伸。大多数 PC 上的 FPS 默认在 70°–90°(垂直)之间。

光源与阴影

Godot 有三种实时光源:

节点特点示例里的用途
DirectionalLight3D平行光,只有方向没有位置,照亮整个场景太阳(投阴影)+ 一盏反方向的冷色补光(不投阴影)
OmniLight3D点光源,向四周发光,有范围 omni_range平台四角的灯泡、枪口火光
SpotLight3D聚光灯,一个圆锥(本例没用)手电筒、舞台灯

平行光的方向由旋转决定,光沿着自己的 -Z 方向照射。阴影用的是阴影贴图:从光源的角度把场景渲染一遍深度,再比较每个像素离光源的距离。平行光照的范围是无限的,所以要用级联阴影(PSSM):离相机近的地方用一张高精度的贴图,远的地方用低精度的。几个关键参数:

  • directional_shadow_mode:级联数。2 级比 4 级少画一半,Web 上推荐。
  • directional_shadow_max_distance:阴影只画到这么远。距离越短,同样分辨率下阴影越清晰。示例场景只有 40 米,设成 40。
  • shadow_bias / shadow_normal_bias:偏移量。太小会出现条纹状的"阴影痤疮"(shadow acne),太大阴影会和物体脱开(漏光)。
world/world.gd第 34–56 行太阳和补光在工程中查看 ↗
func _make_lights() -> void:
	# 太阳:DirectionalLight3D 只有方向,没有位置。旋转决定光从哪里来(光沿自身 -Z 方向照射)
	sun = DirectionalLight3D.new()
	sun.name = "Sun"
	sun.rotation_degrees = Vector3(-38, 32, 0)
	sun.light_color = Color(1.0, 0.86, 0.68)
	sun.light_energy = 1.25
	sun.shadow_enabled = shadows
	sun.shadow_blur = 1.5
	sun.shadow_bias = 0.05
	sun.shadow_normal_bias = 1.6
	sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_2_SPLITS   # 2 级级联,比 4 级省一半绘制
	sun.directional_shadow_max_distance = 40.0     # 阴影只画到 40 米,距离越短阴影越清晰
	add_child(sun)
	# 补光:从反方向打一盏弱的冷色光,不投阴影,让背光面不至于死黑
	var fill := DirectionalLight3D.new()
	fill.name = "Fill"
	fill.rotation_degrees = Vector3(-30, -150, 0)
	fill.light_color = Color(0.55, 0.68, 1.0)
	fill.light_energy = 0.35
	fill.light_specular = 0.0
	fill.sky_mode = DirectionalLight3D.SKY_MODE_LIGHT_ONLY   # 只照亮场景,不在天空里画出第二个太阳
	add_child(fill)
只靠一盏太阳光,背光面会是一片死黑(或者被环境光染成一片蓝)。从反方向加一盏弱的、偏冷色的平行光作补光,并关掉它的阴影和高光,花费很小,但能让背光面有层次。注意补光默认也会在天空里画出"第二个太阳",示例里用 sky_mode = SKY_MODE_LIGHT_ONLY 关掉了。

材质:StandardMaterial3D 与 PBR

Godot 的标准材质基于 PBR(基于物理的渲染):不直接指定"高光有多亮",而是描述表面的物理性质,由光照模型去算。最重要的四项:

属性含义示例里的取值
albedo底色(颜色或贴图),表面在白光下反射出的颜色地面、墙、箱子都用生成的贴图;集装箱用同一张金属贴图乘不同的 albedo_color
metallic金属度,0 = 非金属(塑料、木头、水泥),1 = 金属。金属的反射带有自身颜色水泥 0,钢梁 0.85
roughness粗糙度,0 = 镜面,1 = 完全漫反射。决定高光是一个小亮点还是一大片朦胧水泥 0.9,钢梁 0.38
normal_texture法线贴图,不增加三角形,只改变光照的方向,让平面"看起来"有凹凸墙面用一张由噪声算出来的法线贴图

示例里的方块大小各不相同,如果用普通 UV,贴图会被拉伸。解决办法是三平面映射(triplanar):按顶点在三个方向上的坐标分别取贴图,再按法线方向混合。打开 uv1_world_triplanar 后用的是世界坐标,所有方块的贴图连成一片,像同一块水泥浇出来的。箱子用的是物体空间的三平面映射,每个面正好一张贴图。

world/level.gd第 21–85 行地面、墙、金属、箱子的材质在工程中查看 ↗
## PBR 材质:albedo(底色/贴图)、metallic(金属度)、roughness(粗糙度)、normal(法线贴图)。
## 用三平面映射(triplanar)后,不管方块多大,贴图都按世界坐标平铺,不会被拉伸。
func _make_materials() -> void:
	var floor_mat := StandardMaterial3D.new()
	floor_mat.albedo_texture = load(TEX + "floor.png")
	floor_mat.roughness = 0.85
	floor_mat.uv1_triplanar = true
	floor_mat.uv1_world_triplanar = true
	floor_mat.uv1_scale = Vector3.ONE * 0.5          # 贴图一张覆盖 2m×2m
	mats.floor = floor_mat

	var wall_mat := StandardMaterial3D.new()
	wall_mat.albedo_texture = load(TEX + "wall.png")
	wall_mat.normal_enabled = true
	wall_mat.normal_texture = load(TEX + "wall_normal.png")
	wall_mat.normal_scale = 1.4
	wall_mat.roughness = 0.9
	wall_mat.uv1_triplanar = true
	wall_mat.uv1_world_triplanar = true
	wall_mat.uv1_scale = Vector3.ONE * 0.25          # 一张覆盖 4m
	mats.wall = wall_mat
	# 墙裙:同一张贴图乘上青绿色,下半截墙像刷过漆
	var paint: StandardMaterial3D = wall_mat.duplicate()
	paint.albedo_color = Color(0.36, 0.62, 0.64)
	paint.roughness = 0.7
	mats.paint = paint

	var metal := StandardMaterial3D.new()
	metal.albedo_texture = load(TEX + "metal.png")
	metal.metallic = 0.85
	metal.roughness = 0.38
	metal.uv1_triplanar = true
	metal.uv1_world_triplanar = true
	mats.metal = metal

	# 集装箱:同一张金属贴图,用 albedo_color 染成不同颜色(贴图颜色 × albedo_color)
	for tint: Array in [["teal", Color(0.35, 0.75, 0.78)], ["orange", Color(1.0, 0.55, 0.28)], ["red", Color(0.85, 0.32, 0.3)]]:
		var m: StandardMaterial3D = metal.duplicate()
		m.albedo_color = tint[1]
		m.metallic = 0.55
		m.roughness = 0.5
		mats[tint[0]] = m

	var hazard := StandardMaterial3D.new()
	hazard.albedo_texture = load(TEX + "hazard.png")
	hazard.roughness = 0.6
	hazard.uv1_triplanar = true
	hazard.uv1_world_triplanar = true
	hazard.uv1_scale = Vector3.ONE * 1.5
	mats.hazard = hazard

	var crate := StandardMaterial3D.new()
	crate.albedo_texture = load(TEX + "crate.png")
	crate.roughness = 0.8
	crate.uv1_triplanar = true                        # 物体空间三平面:每个面正好一张贴图
	crate.uv1_scale = Vector3.ONE / 1.2
	crate.uv1_offset = Vector3.ONE * 0.5
	mats.crate = crate

	var lamp := StandardMaterial3D.new()
	lamp.albedo_color = Color(1.0, 0.8, 0.5)
	lamp.emission_enabled = true                      # 自发光 + glow:灯泡周围会有一圈光晕
	lamp.emission = Color(1.0, 0.7, 0.4)
	lamp.emission_energy_multiplier = 4.0
	mats.lamp = lamp
贴图来自工程里的 textures/make_textures.py:用 NumPy 生成可平铺的噪声,再画上网格线、板缝、箱子框和警示条纹,法线贴图由高度图的梯度算出。全部贴图加起来不到 450 KB,Web 版的 .pck 只有 650 KB 左右。

WorldEnvironment:天空、环境光、色调映射、雾

场景里放一个 WorldEnvironment 节点,给它一个 Environment 资源,就能统一设置"整个世界"的效果:

  • 背景和天空:ProceduralSkyMaterial 用几个颜色画出渐变天空,太阳的位置自动取自平行光。
  • 环境光:没有被灯直接照到的地方有多亮。取自天空时,朝上的面偏天空色,朝下的面偏地面色,比单一颜色自然。
  • 色调映射(tonemap):光照计算的结果是 HDR,亮度可以远超 1。色调映射把它压回屏幕能显示的范围,高光不会一片死白。ACES 对比度高、偏电影感;AgX 更柔和、颜色更"真"。
  • 雾:远处的物体逐渐融进雾色,制造纵深感,也能掩盖远裁剪面。
  • glow:亮度超过阈值的像素向外扩散出光晕。示例里的灯泡、敌人的面罩、枪口火光都靠它发光。
world/world.gd第 61–96 行用代码搭的 Environment(和在检查器里设置效果一样)在工程中查看 ↗
func _make_environment() -> void:
	var sky_mat := ProceduralSkyMaterial.new()
	sky_mat.sky_top_color = Color(0.24, 0.42, 0.7)
	sky_mat.sky_horizon_color = Color(0.86, 0.76, 0.66)
	sky_mat.ground_horizon_color = Color(0.86, 0.76, 0.66)
	sky_mat.ground_bottom_color = Color(0.25, 0.23, 0.22)
	var sky := Sky.new()
	sky.sky_material = sky_mat

	env = Environment.new()
	env.background_mode = Environment.BG_SKY
	env.sky = sky
	# 环境光取自天空:朝上的面偏蓝,朝下的面偏暗,比单一颜色的环境光自然得多
	env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
	env.ambient_light_sky_contribution = 0.5        # 一半取自天空,一半取 ambient_light_color
	env.ambient_light_color = Color(0.6, 0.52, 0.45)
	env.ambient_light_energy = 0.45
	env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
	# 色调映射:把 HDR 的亮度压到屏幕能显示的 0~1,高光不会一片死白
	env.tonemap_mode = Environment.TONE_MAPPER_ACES
	env.tonemap_exposure = 0.9
	# 雾:指数雾,远处的物体逐渐融进雾色,增加纵深感
	env.fog_enabled = fog_density > 0.0
	env.fog_mode = Environment.FOG_MODE_EXPONENTIAL
	env.fog_density = fog_density
	env.fog_light_color = Color(0.78, 0.72, 0.66)
	env.fog_sun_scatter = 0.25
	env.fog_aerial_perspective = 0.25
	env.fog_sky_affect = 0.15
	# glow:亮度超过阈值的像素向外扩散(Compatibility 下是简化版实现)
	env.glow_enabled = true
	env.glow_intensity = 0.7
	env.glow_hdr_threshold = 1.0
	var we := WorldEnvironment.new()
	we.environment = env
	add_child(we)
把雾密度从 0.006 拖到 0.05:远处的墙几乎看不见了,场景变得像沙尘天。再拖到 0:对比度和饱和度明显升高,但远近没有层次。雾的密度一般要比你的第一感觉小得多,示例的默认值是反复对比后选的。

用 CSG 快速搭原型

CSG(构造实体几何)节点可以对方块、圆柱、球体做加、减、交运算。比如一面墙减去一个方块再减去一个横放的圆柱,就挖出了一个拱门。它很适合在编辑器里快速"白盒"关卡,验证玩法后再换成美术做的模型。

world/level.gd第 176–211 行一面墙减去门洞和圆顶 = 拱门;再把结果烘焙成静态碰撞体在工程中查看 ↗
## 带拱门的隔墙:CSG 做布尔运算,一个方块减去一个圆柱和一个方块,就挖出了门洞。
## CSG 适合快速搭原型(白盒)。它也能自带碰撞(use_collision),但导航烘焙解析 CSG 时
## 要把网格从显卡读回来,很慢;所以这里等 CSG 算完后,把形状烘焙成一个普通的静态碰撞体。
func _build_arches() -> void:
	for s: int in [-1, 1]:
		var wall := CSGBox3D.new()
		wall.size = Vector3(0.8, 4.0, 9.0)
		wall.position = Vector3(s * 10.5, 2.0, 0)
		wall.material = mats.wall
		var door := CSGBox3D.new()
		door.operation = CSGShape3D.OPERATION_SUBTRACTION
		door.size = Vector3(2.0, 2.2, 2.8)
		door.position = Vector3(0, -0.9, 0)          # 相对墙中心:门洞底部贴地
		wall.add_child(door)
		var round_top := CSGCylinder3D.new()
		round_top.operation = CSGShape3D.OPERATION_SUBTRACTION
		round_top.radius = 1.4
		round_top.height = 2.0
		round_top.sides = 20
		round_top.rotation.z = PI / 2                # 圆柱默认沿 y 轴,转 90° 让它横穿墙体
		round_top.position = Vector3(0, 0.2, 0)
		wall.add_child(round_top)
		add_child(wall)
		_csg_walls.append(wall)


## CSG 的结果 → ConcavePolygonShape3D(三角形网格碰撞)→ 挂到一个 StaticBody3D 上。
func _csg_to_static_body(csg: CSGShape3D) -> void:
	var body := StaticBody3D.new()
	body.collision_layer = 1
	body.collision_mask = 0
	var shape := CollisionShape3D.new()
	shape.shape = csg.bake_collision_shape()
	body.add_child(shape)
	body.transform = csg.transform
	add_child(body)
CSG 的布尔运算比较慢,而且是延迟到帧末才执行的,所以刚 add_child 的 CSG 节点当帧拿不到形状。另一个坑:导航网格烘焙时如果直接解析 CSG,会把网格数据从显卡读回 CPU,引擎会打出一条很长的性能警告。示例里等 CSG 算完后用 bake_collision_shape() 得到一个 ConcavePolygonShape3D,挂到普通的 StaticBody3D 上,这样导航烘焙只解析碰撞体,又快又不报警告。正式项目里,CSG 搭好的关卡最好用 bake_static_mesh() 转成普通网格。

其余的几何体(地面、墙、箱子、集装箱)都用一个小函数生成:一个 StaticBody3D,下面挂一个同样大小的 BoxShape3D 和 BoxMesh。

world/level.gd第 216–240 行在工程中查看 ↗
## 造一个带碰撞的方块:StaticBody3D(碰撞层 1 = 世界)+ BoxShape3D + 同样大小的 BoxMesh。
func _box(pos: Vector3, size: Vector3, mat: Material, yaw := 0.0, collide := true) -> Node3D:
	var mesh := BoxMesh.new()
	mesh.size = size
	var mi := MeshInstance3D.new()
	mi.mesh = mesh
	mi.material_override = mat
	if not collide:
		mi.position = pos
		mi.rotation.y = yaw
		add_child(mi)
		return mi
	var body := StaticBody3D.new()
	body.collision_layer = 1
	body.collision_mask = 0
	var shape := CollisionShape3D.new()
	var box := BoxShape3D.new()
	box.size = size
	shape.shape = box
	body.add_child(shape)
	body.add_child(mi)
	body.position = pos
	body.rotation.y = yaw
	add_child(body)
	return body

三种渲染器在 3D 上的差异

Godot 4 有三种渲染器:Forward+(桌面,功能最全)、Mobile(手机)、Compatibility(基于 OpenGL ES 3 / WebGL 2,最老的硬件和浏览器都能跑)。Web 导出只支持 Compatibility。下面是在 4.7.2 的 Compatibility 渲染器里逐项打开、截图并和关闭时做像素比较的结果(测试场景是一个方块、一个发光球和一块地面):

功能Compatibility 实测说明
平行光阴影、点光源阴影可用每盏带阴影的光源都要多画一遍场景
Glow可用(简化版)glow_levels、glow_blend_mode、glow_strength 等无效
深度雾 / 指数雾 / 高度雾可用
色调映射 ACES / AgX、亮度对比度饱和度调整可用
ReflectionProbe可用每个网格最多受 2 个探针影响
GPUParticles3D、CPUParticles3D可用
MSAA可用示例开了 2× MSAA
SSAO可用(4.7 起)文档写明 Forward+ 和 Compatibility 支持;实测效果较弱、Web 上有开销,示例没开
Decal不可用,且没有任何提示放上去画面完全不变(像素差 0),弹孔只能用小四边形代替(第 17 章)
体积雾、FogVolume、SSR、SSIL、SDFGI、VoxelGI、FXAA不可用大部分会在输出里打一条 "only available when using the Forward+ renderer" 警告
灯光投影贴图、PCSS 软阴影不可用
最容易踩的坑是 Decal 静默失效:在 Forward+ 里调好的弹孔、血迹、地面标记,切到 Compatibility 后全部消失,而且控制台没有任何报错。如果你的游戏要发布 Web 版,从第一天起就把项目设成 Compatibility 开发,别等到导出时才发现画面不一样。

另外,Compatibility 每个物体默认最多受 8 盏点光源 / 聚光灯影响(rendering/limits/opengl/max_lights_per_object),而且光照是按物体逐盏叠加的,灯越多绘制调用越多。示例只用了 4 盏不带阴影的灯泡。

Web 版的性能预算

示例在桌面 Chrome(独立显卡或 Apple 芯片)上稳定 100 帧以上。下面是实测的渲染统计,示例运行时右侧"实时数据"里也能看到:

开阴影关阴影
绘制调用 / 帧约 160约 90
三角形 / 帧约 3900约 2100
.pck 大小约 650 KB(引擎 wasm 另算,所有示例共用)

三角形数非常少(整个关卡都是方块),瓶颈在绘制调用:每个方块是一次绘制,每多一次阴影级联、每多一盏照到它的灯,都要再画一次。真实项目里,把静态几何体合并成少数几个网格,比减少面数更有效。

在 Forward+ 里做好的弹孔用的是 Decal 节点。项目切到 Compatibility 渲染器后,会发生什么?

  1. 控制台报错,游戏无法启动
  2. 弹孔不显示,控制台也没有警告
  3. 弹孔正常显示,只是没有法线效果
  4. 引擎自动换成四边形网格

Decal 在 Compatibility 里不被支持,而且不会给出任何提示。示例实测:加上 Decal 前后画面像素完全一样。所以要在 Compatibility 下做弹孔,只能自己用贴在表面上的小网格。

场景里只有一盏太阳光,背光面一片死黑。下面哪个办法最省性能?

  1. 加一盏方向相反、亮度很低、不投阴影的平行光
  2. 把太阳光的阴影关掉
  3. 在背光面放 20 盏点光源
  4. 开启 SDFGI 全局光照

补光只多一次光照计算,不投阴影就不会多画阴影贴图。关阴影会让画面变平;20 盏点光源在 Compatibility 里每盏都增加绘制;SDFGI 只有 Forward+ 支持。

从 Godot 3 迁移过来的名字变化:Spatial → Node3D,Transform → Transform3D,translation → position,SpatialMaterial → StandardMaterial3D,Camera 的 current 现在是 Camera3D.current 或 make_current(),KinematicBody → CharacterBody3D。另外 Godot 3 的 GLES2 渲染器已经没有了,GLES3 后端改名叫 Compatibility。

小结

  • Godot 3D 是右手坐标系,Y 朝上,-Z 是前方。
  • Transform3D = basis(三根轴,存旋转和缩放)+ origin(位置);transform * p 把局部坐标变到父坐标系。
  • 欧拉角默认按 YXZ 顺序组合,±90° 俯仰时会万向节锁;要做朝向插值就用四元数 slerp。
  • 相机的 fov 是垂直视野角;near / far 比值别太大。
  • 阴影用级联阴影贴图,级联数和最大距离直接影响清晰度和性能。
  • PBR 材质主要调 albedo / metallic / roughness / normal;三平面映射让任意大小的方块贴图都不拉伸。
  • Compatibility 能做阴影、glow、雾、色调映射、反射探针,但 Decal 会静默失效,SSR、体积雾、SDFGI 等都不可用。

下一章开始写玩家:一个手感扎实的第一人称控制器。