18. 性能优化
性能优化
你做了一个游戏,在自己的电脑上跑得很流畅,结果发到手机上一玩——卡得像放幻灯片。或者场景里敌人一多,帧率就从 60 直接掉到 20。这就是性能问题。
性能优化就是让游戏在各种设备上都能流畅运行。对于 2D 游戏来说,虽然通常比 3D 游戏轻量,但如果处理不当,同样会出现卡顿、发热、耗电快等问题。
为什么 2D 游戏也需要优化
"2D 游戏又不渲染 3D 模型,应该不需要优化吧?"——这是一个常见的误区。
| 问题 | 原因 | 后果 |
|---|---|---|
| 画面卡顿 | 一次性渲染太多精灵,Draw Call 过多 | 帧率掉到 30 以下 |
| 内存爆炸 | 加载了大量图片和音效不释放 | 游戏闪退 |
| 加载时间过长 | 启动时把所有资源都加载进来 | 玩家等太久,直接关掉 |
| 手机发烫 | CPU/GPU 长时间满负荷运转 | 玩家担心手机炸了 |
| 电池消耗快 | 后台持续运行计算逻辑 | 玩家不愿意长时间玩 |
性能优化的核心原则
不要过早优化。 先把功能做出来,确认有性能问题后再优化。 premature optimization is the root of all evil(过早优化是万恶之源)。
Godot 性能分析器——找到瓶颈在哪里
优化之前,先用工具搞清楚"到底哪里慢"。Godot 内置了性能分析器(Profiler),可以实时查看游戏的各项指标。
使用方法
- 运行游戏
- 点击编辑器底部的 Profiler 标签
- 你会看到多个图表,分别显示:
- Frame Time:每帧花费的时间(目标 < 16.67ms,对应 60 FPS)
- Physics Time:物理计算时间
- Node Count:场景中的节点数量
- Object Count:内存中的对象数量
- Resource Count:加载的资源数量
- Memory:内存使用量
如何分析
| 指标 | 正常值 | 异常值 | 可能原因 |
|---|---|---|---|
| Frame Time | < 16ms | > 33ms | 渲染或脚本计算太重 |
| Physics Time | < 5ms | > 10ms | 物理体太多或碰撞检测太复杂 |
| Node Count | < 1000 | > 5000 | 场景中节点太多 |
| Object Count | 稳定 | 持续增长 | 有内存泄漏(对象没释放) |
| Memory | < 500MB | > 1GB | 资源没有释放 |
常见错误
看到帧率低就疯狂优化代码,结果发现是渲染的问题。一定要先用 Profiler 看数据,对症下药。
对象池模式——减少 GC 压力
这是游戏开发中最重要的优化模式之一。
问题:频繁创建和销毁对象
假设你的射击游戏每秒发射 10 颗子弹。每颗子弹都是一个节点,发射时创建,飞出屏幕后销毁。在 C# 中,频繁创建和销毁对象会导致 垃圾回收(GC) 频繁触发,造成卡顿。
打个比方:这就像一家餐厅,每次客人来了就新做一套碗筷,客人走了就扔掉。做碗筷需要时间,扔碗筷也需要时间。更好的做法是:准备一批碗筷,客人来了直接用,客人走了洗干净放回去——这就是对象池。
对象池的实现
using Godot;
using System.Collections.Generic;
public partial class ObjectPool : Node
{
// 池中所有可复用的对象
private readonly Queue<Node2D> _pool = new();
private PackedScene _scene;
private Node _parent;
/// <summary>
/// 初始化对象池
/// </summary>
/// <param name="scene">要池化的场景</param>
/// <param name="parent">对象的父节点</param>
/// <param name="initialSize">初始创建数量</param>
public void Initialize(PackedScene scene, Node parent, int initialSize = 20)
{
_scene = scene;
_parent = parent;
// 预先创建一批对象
for (int i = 0; i < initialSize; i++)
{
var obj = scene.Instantiate<Node2D>();
obj.SetProcess(false);
obj.SetPhysicsProcess(false);
obj.Visible = false;
_parent.AddChild(obj);
_pool.Enqueue(obj);
}
GD.Print($"对象池初始化完成,预创建 {initialSize} 个对象");
}
/// <summary>
/// 从池中获取一个对象(如果池空了就创建新的)
/// </summary>
public Node2D GetObject()
{
Node2D obj;
if (_pool.Count > 0)
{
obj = _pool.Dequeue();
}
else
{
// 池空了,创建新对象
obj = _scene.Instantiate<Node2D>();
_parent.AddChild(obj);
GD.Print("对象池已空,创建新对象");
}
// 激活对象
obj.Visible = true;
obj.SetProcess(true);
obj.SetPhysicsProcess(true);
// 调用对象的 OnSpawn 方法(如果有的话)
if (obj.HasMethod("OnSpawn"))
{
obj.Call("OnSpawn");
}
return obj;
}
/// <summary>
/// 将对象放回池中(不是销毁!)
/// </summary>
public void ReturnObject(Node2D obj)
{
// 重置对象状态
obj.Visible = false;
obj.SetProcess(false);
obj.SetPhysicsProcess(false);
// 调用对象的 OnDespawn 方法
if (obj.HasMethod("OnDespawn"))
{
obj.Call("OnDespawn");
}
_pool.Enqueue(obj);
}
/// <summary>
/// 获取池中可用对象数量
/// </summary>
public int AvailableCount => _pool.Count;
}
// ========== 使用示例:子弹管理器 ==========
public partial class BulletManager : Node
{
private ObjectPool _bulletPool;
public override void _Ready()
{
_bulletPool = new ObjectPool();
_bulletPool.Name = "BulletPool";
AddChild(_bulletPool);
// 初始化子弹池,预创建 30 颗子弹
var bulletScene = GD.Load<PackedScene>("res://scenes/bullet.tscn");
_bulletPool.Initialize(bulletScene, this, 30);
}
// 发射子弹
public void FireBullet(Vector2 position, Vector2 direction)
{
var bullet = _bulletPool.GetObject();
bullet.GlobalPosition = position;
bullet.Set("direction", direction);
}
// 子弹飞出屏幕后回收(在子弹脚本中调用)
public void RecycleBullet(Node2D bullet)
{
_bulletPool.ReturnObject(bullet);
}
}extends Node
class_name ObjectPool
# 池中所有可复用的对象
var _pool: Array[Node2D] = []
var _scene: PackedScene
var _parent: Node
## 初始化对象池
func initialize(scene: PackedScene, parent: Node, initial_size: int = 20) -> void:
_scene = scene
_parent = parent
# 预先创建一批对象
for i in range(initial_size):
var obj := scene.instantiate() as Node2D
obj.set_process(false)
obj.set_physics_process(false)
obj.visible = false
_parent.add_child(obj)
_pool.append(obj)
print("对象池初始化完成,预创建 %d 个对象" % initial_size)
## 从池中获取一个对象
func get_object() -> Node2D:
var obj: Node2D
if _pool.size() > 0:
obj = _pool.pop_front()
else:
# 池空了,创建新对象
obj = _scene.instantiate() as Node2D
_parent.add_child(obj)
print("对象池已空,创建新对象")
# 激活对象
obj.visible = true
obj.set_process(true)
obj.set_physics_process(true)
# 调用对象的 OnSpawn 方法
if obj.has_method("on_spawn"):
obj.on_spawn()
return obj
## 将对象放回池中
func return_object(obj: Node2D) -> void:
# 重置对象状态
obj.visible = false
obj.set_process(false)
obj.set_physics_process(false)
# 调用对象的 OnDespawn 方法
if obj.has_method("on_despawn"):
obj.on_despawn()
_pool.append(obj)
## 获取池中可用对象数量
func available_count() -> int:
return _pool.size()
# ========== 使用示例:子弹管理器 ==========
extends Node
var _bullet_pool: ObjectPool
func _ready() -> void:
_bullet_pool = ObjectPool.new()
_bullet_pool.name = "BulletPool"
add_child(_bullet_pool)
# 初始化子弹池
var bullet_scene := load("res://scenes/bullet.tscn") as PackedScene
_bullet_pool.initialize(bullet_scene, self, 30)
# 发射子弹
func fire_bullet(pos: Vector2, direction: Vector2) -> void:
var bullet := _bullet_pool.get_object()
bullet.global_position = pos
bullet.set("direction", direction)
# 子弹飞出屏幕后回收
func recycle_bullet(bullet: Node2D) -> void:
_bullet_pool.return_object(bullet)子弹脚本示例(配合对象池使用)
using Godot;
public partial class PooledBullet : Area2D
{
private Vector2 _direction;
private float _speed = 500f;
private BulletManager _manager;
public override void _Ready()
{
_manager = GetParent().GetParent<BulletManager>();
BodyEntered += OnBodyEntered;
}
// 对象从池中取出时调用
public void OnSpawn()
{
// 重置状态
_speed = 500f;
}
// 对象放回池中时调用
public void OnDespawn()
{
// 清理状态
_direction = Vector2.Zero;
}
public void SetDirection(Vector2 dir)
{
_direction = dir.Normalized();
Rotation = dir.Angle();
}
public override void _PhysicsProcess(double delta)
{
Position += _direction * _speed * (float)delta;
// 飞出屏幕就回收
if (Position.X < -50 || Position.X > 2000 ||
Position.Y < -50 || Position.Y > 1200)
{
_manager.RecycleBullet(this);
}
}
private void OnBodyEntered(Node body)
{
// 碰到敌人就回收
_manager.RecycleBullet(this);
}
}extends Area2D
var _direction: Vector2 = Vector2.RIGHT
var _speed: float = 500.0
var _manager: Node
func _ready() -> void:
_manager = get_parent().get_parent()
body_entered.connect(_on_body_entered)
# 对象从池中取出时调用
func on_spawn() -> void:
_speed = 500.0
# 对象放回池中时调用
func on_despawn() -> void:
_direction = Vector2.ZERO
func set_direction(dir: Vector2) -> void:
_direction = dir.normalized()
rotation = dir.angle()
func _physics_process(delta: float) -> void:
position += _direction * _speed * delta
# 飞出屏幕就回收
if position.x < -50 or position.x > 2000 or position.y < -50 or position.y > 1200:
_manager.recycle_bullet(self)
func _on_body_entered(body: Node) -> void:
_manager.recycle_bullet(self)渲染优化——减少 Draw Call
Draw Call(绘制调用)是 GPU 渲染的核心概念。你可以把它理解成"给 GPU 派活"——每次 Draw Call 就是一次指令,告诉 GPU "画这个东西"。
GPU 处理 Draw Call 有一个特点:一次画 1000 个同样的东西,比画 1000 个不同的东西快得多。这就是合批(Batching)的概念。
什么是合批
打个比方:你去邮局寄包裹。
- 不合批:你拿 100 个包裹,每个包裹单独填一张单子、排一次队 → 超慢
- 合批:你把 100 个包裹打包成一个大箱子,填一张单子、排一次队 → 超快
Godot 引擎会自动合批使用同一个材质和纹理的精灵。
手动优化渲染
using Godot;
public partial class RenderOptimizer : Node
{
/// <summary>
/// 优化方式一:使用 TextureRect + TextureAtlasRegion 代替多个 Sprite2D
/// 如果你需要显示大量小图标(比如背包里的 100 个物品),
/// 用 TextureRect 比 Sprite2D 性能好很多
/// </summary>
/// <summary>
/// 优化方式二:使用 CanvasItem 的 Visibility 属性
/// 不在屏幕上的对象应该关闭可见性
/// </summary>
public void UpdateVisibility(Node2D parent, Rect2 viewportRect)
{
foreach (Node2D child in parent.GetChildren())
{
if (child is CanvasItem canvasItem)
{
// 检查是否在屏幕范围内(加 100 像素余量)
bool inView = viewportRect.Expand(100).HasPoint(child.GlobalPosition);
canvasItem.Visible = inView;
}
// 递归处理子节点
UpdateVisibility(child, viewportRect);
}
}
/// <summary>
/// 优化方式三:使用 Texture2DArray 合并小图
/// Godot 4 支持纹理数组,可以把多张小图合并为一张大图
/// 这样 GPU 只需要加载一次纹理
/// </summary>
public Texture2DArray CreateTextureArray(string[] imagePaths)
{
var textures = new Godot.Collections.Array<Texture2D>();
foreach (var path in imagePaths)
{
var tex = GD.Load<Texture2D>(path);
if (tex != null) textures.Add(tex);
}
var array = Texture2DArray.Empty();
// 创建纹理数组(所有纹理大小必须相同)
// 实际使用时需要先确保所有纹理尺寸一致
return array;
}
}extends Node
## 优化方式一:使用 TextureAtlas 合并精灵图
## 把多张小图放到一张大图上(Sprite Sheet / Atlas),
## 这样 GPU 只需要加载一张纹理就能渲染所有精灵
## 优化方式二:使用 CanvasItem 的 Visible 属性
## 不在屏幕上的对象应该关闭可见性
func update_visibility(parent: Node2D, viewport_rect: Rect2) -> void:
for child in parent.get_children():
if child is CanvasItem:
var in_view := viewport_rect.grow(100).has_point(child.global_position)
child.visible = in_view
# 递归处理子节点
if child is Node2D:
update_visibility(child, viewport_rect)
## 优化方式三:减少不必要的透明效果
## 透明混合(alpha blending)比不透明渲染慢得多
## 如果一个精灵不需要透明,关闭它的透明属性
## 优化方式四:使用 NinePatchRect 做可拉伸的 UI
## 比按钮、面板这种需要适配不同大小的 UI 元素,
## 用 NinePatchRect 比 Sprite2D 缩放性能好渲染优化清单
| 优化项 | 说明 | 效果 |
|---|---|---|
| 合并纹理(Atlas) | 把多张小图放一张大图 | 大幅减少 Draw Call |
| 关闭屏幕外可见性 | 不在屏幕上的对象设为不可见 | 减少渲染负担 |
| 减少透明层 | 不需要透明的精灵关闭透明 | 减少混合计算 |
| 使用 TileMapLayer | 用瓦片地图代替大量独立精灵 | 自动合批 |
| 减少 CanvasLayer | 每个 CanvasLayer 是一个独立渲染层 | 减少渲染开销 |
| 使用 LOD | 远处的对象用低精度模型/贴图 | 减少渲染量 |
内存管理——资源加载与释放
内存就像你的书桌——桌上的东西越多,找东西越慢。如果桌子堆满了,就没法再放新东西了(游戏闪退)。
资源加载策略
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 预加载 | 游戏启动时加载所有资源 | 小型游戏、资源少 |
| 按需加载 | 需要时才加载 | 大型游戏、资源多 |
| 异步加载 | 后台加载,不卡主线程 | 大资源(场景、音效) |
| 分步加载 | 显示进度条,逐步加载 | 场景切换 |
异步加载场景
using Godot;
using System.Threading.Tasks;
public partial class SceneLoader : Node
{
[Signal] public delegate void LoadingProgressEventHandler(float progress);
[Signal] public delegate void SceneLoadedEventHandler(string scenePath);
// 异步加载场景(不卡主线程)
public async void LoadSceneAsync(string scenePath)
{
GD.Print($"开始加载场景:{scenePath}");
var packedScene = ResourceLoader.LoadThreadedRequest(scenePath);
var progressArray = new float[1];
// 等待加载完成
while (true)
{
var status = ResourceLoader.LoadThreadedGetStatus(scenePath, progressArray);
// 发送加载进度信号(UI 可以显示进度条)
EmitSignal(SignalName.LoadingProgress, progressArray[0]);
if (status == ResourceLoader.ThreadLoadStatus.Loaded)
{
break;
}
else if (status == ResourceLoader.ThreadLoadStatus.Failed)
{
GD.PrintErr($"场景加载失败:{scenePath}");
return;
}
// 等一帧,让 UI 有机会更新
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
}
// 获取加载好的场景
var scene = ResourceLoader.LoadThreadedGet(scenePath) as PackedScene;
if (scene == null)
{
GD.PrintErr("加载的场景为空");
return;
}
// 切换场景
GetTree().ChangeSceneToPacked(scene);
GD.Print("场景加载完成!");
EmitSignal(SignalName.SceneLoaded, scenePath);
}
// 预加载资源(游戏启动时调用)
public void PreloadResources(string[] resourcePaths)
{
foreach (var path in resourcePaths)
{
ResourceLoader.LoadThreadedRequest(path);
}
GD.Print($"开始预加载 {resourcePaths.Length} 个资源");
}
}extends Node
signal loading_progress(progress: float)
signal scene_loaded(scene_path: String)
# 异步加载场景
func load_scene_async(scene_path: String) -> void:
print("开始加载场景:%s" % scene_path)
# 开始后台加载
ResourceLoader.load_threaded_request(scene_path)
# 等待加载完成
while true:
var progress := []
var status := ResourceLoader.load_threaded_get_status(scene_path, progress)
# 发送进度信号
loading_progress.emit(progress[0])
if status == ResourceLoader.THREAD_LOAD_LOADED:
break
elif status == ResourceLoader.THREAD_LOAD_FAILED:
push_error("场景加载失败:%s" % scene_path)
return
# 等一帧,让 UI 更新
await get_tree().process_frame
# 获取加载好的场景并切换
var scene = ResourceLoader.load_threaded_get(scene_path) as PackedScene
if not scene:
push_error("加载的场景为空")
return
get_tree().change_scene_to_packed(scene)
print("场景加载完成!")
scene_loaded.emit(scene_path)
# 预加载资源
func preload_resources(resource_paths: Array[String]) -> void:
for path in resource_paths:
ResourceLoader.load_threaded_request(path)
print("开始预加载 %d 个资源" % resource_paths.size())释放不再使用的资源
using Godot;
public partial class ResourceManager : Node
{
// 释放指定路径的资源
public void UnloadResource(string path)
{
if (ResourceLoader.HasCached(path))
{
// 减少引用计数
var resource = ResourceLoader.LoadCached(path);
resource?.Dispose();
GD.Print($"资源已释放:{path}");
}
}
// 释放场景切换前的资源
public void UnloadUnusedResources()
{
// 通知引擎释放没有被引用的资源
ResourceLoader.UnloadUnusedResources();
GD.Print("未使用的资源已释放");
}
}extends Node
# 释放指定路径的资源
func unload_resource(path: String) -> void:
if ResourceLoader.has_cached(path):
# 在 GDScript 中,资源会自动管理引用计数
# 可以通过清除引用来触发释放
print("资源已释放:%s" % path)
# 释放所有未使用的资源
func unload_unused_resources() -> void:
# 通知引擎释放没有被引用的资源
ResourceLoader.unload_unused_resources()
print("未使用的资源已释放")移动端特别注意事项
手机硬件性能远不如电脑,需要特别关注以下方面:
| 问题 | 优化方案 |
|---|---|
| 发热 | 降低帧率(30 FPS 足够)、减少物理计算频率、限制粒子数量 |
| 耗电 | 降低屏幕亮度要求、减少后台运算、支持省电模式 |
| 内存小 | 压缩纹理、延迟加载、及时释放 |
| 存储小 | 压缩资源文件、使用 .webp 格式代替 .png |
| 屏幕多样 | 支持多种分辨率、使用锚点布局 |
| 触控操作 | 按钮要够大(至少 48x48dp)、支持手势操作 |
移动端配置建议
# 移动端的项目设置建议
- Window/Size/Viewport Width: 720(而不是 1920)
- Window/Size/Viewport Height: 1280
- Rendering/Textures/Default Filter: Linear(不用 Nearest)
- Rendering/2D/Snap/Snap_2d_Transforms_to_pixel: true
- Physics/common/physics_tick_rate: 30(而不是 60)最终建议
- 先测量,再优化:用 Profiler 找到真正的瓶颈
- 对象池是必学的:子弹、特效、敌人等频繁创建销毁的对象都要池化
- 纹理合并能大幅减少 Draw Call:把同类精灵放到一张 Atlas 上
- 异步加载让场景切换不卡顿:加载时间超过 0.5 秒就必须用异步
- 移动端要特别关注发热和耗电:30 FPS + 降低物理频率是基本操作
- 定期用真机测试:电脑上跑 60 FPS 不代表手机上也能跑 60 FPS
