3D 基础:空间、相机、光照与材质
从 2D 跨到 3D 要补的几块基础:右手坐标系和 -Z 前方、Transform3D 与 Basis、欧拉角和四元数、相机投影、光源和阴影、PBR 材质、WorldEnvironment,以及 Compatibility 渲染器在 3D 上能做什么、不能做什么。
第四部分用一个小型第一人称射击游戏(FPS)把 3D 的主要知识串起来。本章先不讲玩法,只讲"把一个 3D 场景搭起来并且看着顺眼"需要的东西:坐标怎么表示、物体怎么旋转、相机怎么把三维投到屏幕上、光和材质怎么配合、环境怎么调。接下来两章再分别讲第一人称控制器和战斗系统。
下面就是本部分的示例。它运行在 Compatibility 渲染器上(Web 导出只能用这个),整个竞技场由代码搭建,贴图是用 Python 生成的,没有任何外部美术素材。点击画面后鼠标会被锁定,按 Esc 释放。本章只显示和画面有关的参数。
坐标系: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 就变成了世界方向;枪口在相机的局部坐标里,乘上相机的变换就得到世界坐标。
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)。
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 完再转回来。
## 在两个朝向之间平滑过渡:转成四元数做球面插值(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。
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 是单位矩阵(没有旋转)。它"面朝"世界坐标的哪个方向?
- +X
- +Z
- -Z
- +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 横版游戏。
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()光源与阴影
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),太大阴影会和物体脱开(漏光)。
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 后用的是世界坐标,所有方块的贴图连成一片,像同一块水泥浇出来的。箱子用的是物体空间的三平面映射,每个面正好一张贴图。
## 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 = lamptextures/make_textures.py:用 NumPy 生成可平铺的噪声,再画上网格线、板缝、箱子框和警示条纹,法线贴图由高度图的梯度算出。全部贴图加起来不到 450 KB,Web 版的 .pck 只有 650 KB 左右。WorldEnvironment:天空、环境光、色调映射、雾
场景里放一个 WorldEnvironment 节点,给它一个 Environment 资源,就能统一设置"整个世界"的效果:
- 背景和天空:
ProceduralSkyMaterial用几个颜色画出渐变天空,太阳的位置自动取自平行光。 - 环境光:没有被灯直接照到的地方有多亮。取自天空时,朝上的面偏天空色,朝下的面偏地面色,比单一颜色自然。
- 色调映射(tonemap):光照计算的结果是 HDR,亮度可以远超 1。色调映射把它压回屏幕能显示的范围,高光不会一片死白。ACES 对比度高、偏电影感;AgX 更柔和、颜色更"真"。
- 雾:远处的物体逐渐融进雾色,制造纵深感,也能掩盖远裁剪面。
- glow:亮度超过阈值的像素向外扩散出光晕。示例里的灯泡、敌人的面罩、枪口火光都靠它发光。
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)用 CSG 快速搭原型
CSG(构造实体几何)节点可以对方块、圆柱、球体做加、减、交运算。比如一面墙减去一个方块再减去一个横放的圆柱,就挖出了一个拱门。它很适合在编辑器里快速"白盒"关卡,验证玩法后再换成美术做的模型。
## 带拱门的隔墙: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)add_child 的 CSG 节点当帧拿不到形状。另一个坑:导航网格烘焙时如果直接解析 CSG,会把网格数据从显卡读回 CPU,引擎会打出一条很长的性能警告。示例里等 CSG 算完后用 bake_collision_shape() 得到一个 ConcavePolygonShape3D,挂到普通的 StaticBody3D 上,这样导航烘焙只解析碰撞体,又快又不报警告。正式项目里,CSG 搭好的关卡最好用 bake_static_mesh() 转成普通网格。其余的几何体(地面、墙、箱子、集装箱)都用一个小函数生成:一个 StaticBody3D,下面挂一个同样大小的 BoxShape3D 和 BoxMesh。
## 造一个带碰撞的方块: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 软阴影 | 不可用 |
另外,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 渲染器后,会发生什么?
- 控制台报错,游戏无法启动
- 弹孔不显示,控制台也没有警告
- 弹孔正常显示,只是没有法线效果
- 引擎自动换成四边形网格
Decal 在 Compatibility 里不被支持,而且不会给出任何提示。示例实测:加上 Decal 前后画面像素完全一样。所以要在 Compatibility 下做弹孔,只能自己用贴在表面上的小网格。
场景里只有一盏太阳光,背光面一片死黑。下面哪个办法最省性能?
- 加一盏方向相反、亮度很低、不投阴影的平行光
- 把太阳光的阴影关掉
- 在背光面放 20 盏点光源
- 开启 SDFGI 全局光照
补光只多一次光照计算,不投阴影就不会多画阴影贴图。关阴影会让画面变平;20 盏点光源在 Compatibility 里每盏都增加绘制;SDFGI 只有 Forward+ 支持。
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 等都不可用。
下一章开始写玩家:一个手感扎实的第一人称控制器。