15. 测试与调试技巧
2026/4/14大约 11 分钟
测试与调试技巧
你写了一段代码,点击运行——画面黑了。没有报错,没有提示,就是一片黑。这种时候该怎么办?调试不是靠"猜",而是靠系统化的方法。本章会教你一套完整的调试工具和方法论,让你能快速定位并修复游戏中的任何问题。
本章你将学到
- Godot 内置调试工具(远程场景树、性能监视器)
- 单元测试框架(GUT for GDScript、C# NUnit)
- 帧调试器(Frame Debugger)
- 性能分析(Profiler)
- 内存泄漏检测
- 常见 Bug 排查流程
- 日志系统设计
- 自动化测试思路
Godot 内置调试工具
远程场景树
运行游戏时,编辑器的"远程"标签页会实时显示当前运行的场景树。你可以:
- 查看节点层级:确认节点是否正确添加到场景中
- 检查节点属性:实时查看位置、旋转、变量值等
- 查看信号连接:确认信号是否正确连接
性能监视器
通过 调试 > 监视器 打开。关键指标:
| 指标 | 说明 | 健康值 |
|---|---|---|
| FPS | 每秒帧数 | >= 60(PC)/ >= 30(移动端) |
| Frame Time | 每帧耗时 | < 16.6ms(60FPS) |
| Physics Time | 物理计算耗时 | < 5ms |
| Draw Calls | 绘制调用次数 | < 500(PC)/ < 200(移动端) |
| Object Count | 活跃对象数 | 取决于游戏规模 |
| Node Count | 节点数量 | 警惕持续增长 |
| Orphan Nodes | 孤儿节点数 | 应该为 0 |
Orphan Nodes(孤儿节点)
孤儿节点是被释放了但还占着内存的节点。如果这个数字持续增长,说明你的代码有内存泄漏——某个地方创建了节点但没有正确释放。这是一个非常重要的指标,养成定期检查的习惯。
单元测试框架
GDScript 测试:GUT(Godot Unit Testing)
GUT 是 Godot 社区最流行的 GDScript 单元测试框架。
安装:在 Asset Library 中搜索 "GUT" 并下载,或从 GitHub 获取。
GDScript - GUT 测试示例
# test/test_health_system.gd
extends GutTest
# 被测试的对象
var health_system
func before_each():
# 每个测试前运行:创建测试对象
health_system = load("res://scripts/health_system.gd").new()
func after_each():
# 每个测试后运行:清理
health_system.free()
func test_initial_health():
# 测试:初始生命值应该等于最大生命值
assert_eq(health_system.current_hp, health_system.max_hp,
"初始生命值应该等于最大生命值")
func test_take_damage():
# 测试:受到伤害后生命值减少
health_system.take_damage(30)
assert_eq(health_system.current_hp, 70,
"受到30点伤害后生命值应该为70")
func test_health_not_below_zero():
# 测试:生命值不能低于0
health_system.take_damage(200)
assert_eq(health_system.current_hp, 0,
"生命值不能低于0")
func test_death_signal():
# 测试:生命值归零时应该发出死亡信号
watch_signals(health_system)
health_system.take_damage(100)
assert_signal_emitted(health_system, "died",
"生命值归零时应该发出 died 信号")
func test_heal():
# 测试:治疗后生命值恢复
health_system.take_damage(50)
health_system.heal(30)
assert_eq(health_system.current_hp, 80,
"受伤50后治疗30,生命值应为80")
func test_heal_not_exceed_max():
# 测试:治疗不会超过最大生命值
health_system.heal(50)
assert_eq(health_system.current_hp, health_system.max_hp,
"治疗不应该让生命值超过最大值")C
using Godot;
using NUnit.Framework;
[TestFixture]
public class HealthSystemTests
{
private HealthSystem _healthSystem;
[SetUp]
public void Setup()
{
// 每个测试前运行:创建测试对象
_healthSystem = new HealthSystem();
_healthSystem.MaxHp = 100;
_healthSystem.CurrentHp = 100;
}
[TearDown]
public void Teardown()
{
// 每个测试后运行:清理
_healthSystem.Free();
}
[Test]
public void InitialHealth_EqualsMaxHealth()
{
// 测试:初始生命值应该等于最大生命值
Assert.AreEqual(_healthSystem.MaxHp, _healthSystem.CurrentHp,
"初始生命值应该等于最大生命值");
}
[Test]
public void TakeDamage_ReducesHealth()
{
// 测试:受到伤害后生命值减少
_healthSystem.TakeDamage(30);
Assert.AreEqual(70, _healthSystem.CurrentHp,
"受到30点伤害后生命值应该为70");
}
[Test]
public void TakeDamage_HealthNotBelowZero()
{
// 测试:生命值不能低于0
_healthSystem.TakeDamage(200);
Assert.AreEqual(0, _healthSystem.CurrentHp,
"生命值不能低于0");
}
[Test]
public void TakeDamage_EmitsDiedSignal()
{
// 测试:生命值归零时应该发出死亡信号
bool signalFired = false;
_healthSystem.Died += () => signalFired = true;
_healthSystem.TakeDamage(100);
Assert.IsTrue(signalFired,
"生命值归零时应该发出死亡信号");
}
[Test]
public void Heal_RestoresHealth()
{
// 测试:治疗后生命值恢复
_healthSystem.TakeDamage(50);
_healthSystem.Heal(30);
Assert.AreEqual(80, _healthSystem.CurrentHp,
"受伤50后治疗30,生命值应为80");
}
[Test]
public void Heal_DoesNotExceedMaxHealth()
{
// 测试:治疗不会超过最大生命值
_healthSystem.Heal(50);
Assert.AreEqual(_healthSystem.MaxHp, _healthSystem.CurrentHp,
"治疗不应该让生命值超过最大值");
}
}
// 被测试的类
public partial class HealthSystem : Node
{
[Signal] public delegate void DiedEventHandler();
public float MaxHp { get; set; } = 100;
public float CurrentHp { get; set; } = 100;
public void TakeDamage(float amount)
{
CurrentHp = Mathf.Max(0, CurrentHp - amount);
if (CurrentHp <= 0)
{
EmitSignal(SignalName.Died);
}
}
public void Heal(float amount)
{
CurrentHp = Mathf.Min(MaxHp, CurrentHp + amount);
}
}帧调试器(Frame Debugger)
帧调试器让你看到 GPU 在每一帧做了什么——渲染了哪些物体、用了哪些 Shader、有几个绘制调用。
使用方法
- 运行游戏
- 在编辑器底部打开 调试 > 帧调试器
- 点击 "Capture" 捕获一帧
- 逐个查看每个绘制调用
关键检查点
- Draw Call 数量过多:看看是不是有很多小物体没有合并
- 同一个物体被渲染多次:可能是 Shader 的 multi-pass 导致的
- 过度绘制(Overdraw):半透明物体叠在一起,GPU 反复画同一个像素
性能分析(Profiler)
Profiler 帮你找到"哪段代码最慢"。Godot 内置的 Profiler 可以看到每个函数的执行时间。
使用方法
- 点击编辑器右上角的 Profiler 按钮(或 F3)
- 运行游戏,Profiler 开始记录
- 停止后查看结果
分析重点
内存泄漏检测
内存泄漏是最难发现的 Bug 之一——游戏开始时运行正常,玩了几十分钟后越来越卡,最终崩溃。
常见泄漏原因
| 原因 | 说明 | 检测方法 |
|---|---|---|
忘记 queue_free() | 创建了节点但没有释放 | 检查 Orphan Nodes 指标 |
| 信号未断开 | 对象被删除但信号还连着 | 在 _exit_tree 中断开 |
| 定时器未停止 | Timer 一直回调已删除的对象 | 在退出时 stop() |
| 循环引用 | A 引用 B,B 又引用 A | 使用 WeakRef 打破循环 |
| 大数组不清理 | 数组只增不减 | 监控内存增长趋势 |
内存监控工具
C#
using Godot;
using System.Collections.Generic;
public partial class MemoryMonitor : Node
{
private Label _label;
private double _updateTimer;
private double _updateInterval = 1.0; // 每秒更新一次
// 记录历史数据用于趋势分析
private List<long> _memoryHistory = new();
public override void _Ready()
{
_label = new Label();
_label.Name = "MemoryMonitor";
_label.Position = new Vector2(10, 10);
_label.Modulate = new Color(1, 1, 1, 0.8);
// 置顶显示
var canvasLayer = new CanvasLayer();
canvasLayer.Layer = 100;
AddChild(canvasLayer);
canvasLayer.AddChild(_label);
}
public override void _Process(double delta)
{
_updateTimer += delta;
if (_updateTimer < _updateInterval) return;
_updateTimer = 0;
// 获取内存信息
var memInfo = Performance.GetMonitor(Performance.Monitor.MemoryStatic);
long memBytes = (long)memInfo;
float memMB = memBytes / (1024f * 1024f);
// 获取对象计数
int nodeCount = GetTree().GetNodeCount();
int orphanCount = (int)Performance.GetMonitor(
Performance.Monitor.ObjectResourceCount);
// 记录历史
_memoryHistory.Add(memBytes);
if (_memoryHistory.Count > 60) _memoryHistory.RemoveAt(0);
// 计算趋势
string trend = "稳定";
if (_memoryHistory.Count >= 10)
{
long recent = _memoryHistory[_memoryHistory.Count - 1];
long earlier = _memoryHistory[_memoryHistory.Count - 10];
float change = (float)(recent - earlier) / (1024f * 1024f);
if (change > 5.0f) trend = "增长中 (+)";
else if (change < -5.0f) trend = "减少中 (-)";
}
_label.Text = $"内存: {memMB:F1}MB [{trend}]\n" +
$"节点: {nodeCount}\n" +
$"FPS: {Engine.GetFramesPerSecond()}";
}
}GDScript
extends Node
var label: Label
var update_timer: float = 0.0
var update_interval: float = 1.0 # 每秒更新一次
# 记录历史数据用于趋势分析
var memory_history: Array = []
func _ready():
label = Label.new()
label.name = "MemoryMonitor"
label.position = Vector2(10, 10)
label.modulate = Color(1, 1, 1, 0.8)
# 置顶显示
var canvas_layer = CanvasLayer.new()
canvas_layer.layer = 100
add_child(canvas_layer)
canvas_layer.add_child(label)
func _process(delta):
update_timer += delta
if update_timer < update_interval:
return
update_timer = 0.0
# 获取内存信息
var mem_bytes = Performance.get_monitor(Performance.MEMORY_STATIC)
var mem_mb = mem_bytes / (1024.0 * 1024.0)
# 获取节点计数
var node_count = get_tree().get_node_count()
var orphan_count = int(Performance.get_monitor(
Performance.OBJECT_ORPHAN_NODE_COUNT))
# 记录历史
memory_history.append(mem_bytes)
if memory_history.size() > 60:
memory_history.pop_front()
# 计算趋势
var trend = "稳定"
if memory_history.size() >= 10:
var recent = memory_history[-1]
var earlier = memory_history[-10]
var change = (recent - earlier) / (1024.0 * 1024.0)
if change > 5.0:
trend = "增长中 (+)"
elif change < -5.0:
trend = "减少中 (-)"
label.text = "内存: %.1fMB [%s]\n节点: %d\n孤儿节点: %d\nFPS: %d" % [
mem_mb, trend, node_count, orphan_count,
Engine.get_frames_per_second()
]常见 Bug 排查流程
黑屏问题排查清单
- 摄像机节点是否存在?
- 摄像机的
current属性是否为true? - 摄像机的
Far距离是否足够大? - 摄像机是否在场景内部被墙壁挡住?
- 场景是否正确加载(检查
scene_file_path)? - WorldEnvironment 节点是否存在?
碰撞穿透解决方案
高速移动的物体会"穿过"薄墙——因为物理引擎是按步长计算的,物体可能一步就跳到了墙的另一边。解决方案是启用 CCD(连续碰撞检测):
C#
using Godot;
public partial class FastProjectile : RigidBody3D
{
public override void _Ready()
{
// 启用连续碰撞检测,防止高速物体穿透
ContinuousCd = true;
ContactMonitor = true;
MaxContactsReported = 4;
}
}GDScript
extends RigidBody3D
func _ready():
# 启用连续碰撞检测,防止高速物体穿透
continuous_cd = true
contact_monitor = true
max_contacts_reported = 4日志系统设计
好的日志系统是调试的"回放录像"——出问题时可以看日志回溯发生了什么。
C#
using Godot;
using System;
using System.IO;
public enum LogLevel
{
Debug, Info, Warning, Error
}
public partial class GameLogger : Node
{
private static GameLogger _instance;
public static GameLogger Instance => _instance;
private string _logFilePath;
private FileAccess _logFile;
private LogLevel _minLevel = LogLevel.Debug;
public override void _Ready()
{
_instance = this;
// 创建日志文件
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
_logFilePath = $"user://logs/game_{timestamp}.log";
DirAccess.MakeDirRecursiveAbsolute("user://logs/");
_logFile = FileAccess.Open(_logFilePath, FileAccess.ModeFlags.Write);
Info("GameLogger", "日志系统初始化完成");
}
public void Debug(string category, string message)
{
Log(LogLevel.Debug, category, message);
}
public void Info(string category, string message)
{
Log(LogLevel.Info, category, message);
}
public void Warning(string category, string message)
{
Log(LogLevel.Warning, category, message);
}
public void Error(string category, string message)
{
Log(LogLevel.Error, category, message);
}
private void Log(LogLevel level, string category, string message)
{
if (level < _minLevel) return;
string timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
string levelStr = level.ToString().ToUpper().PadRight(7);
string logLine = $"[{timestamp}] [{levelStr}] [{category}] {message}";
// 同时输出到控制台和文件
switch (level)
{
case LogLevel.Error:
GD.PrintErr(logLine);
break;
case LogLevel.Warning:
GD.PrintRich($"[color=yellow]{logLine}[/color]");
break;
default:
GD.Print(logLine);
break;
}
// 写入文件
if (_logFile != null)
{
_logFile.StoreLine(logLine);
}
}
public override void _ExitTree()
{
_logFile?.Close();
}
}GDScript
extends Node
enum LogLevel { DEBUG, INFO, WARNING, ERROR }
var _instance: Node
var _log_file_path: String
var _log_file: FileAccess
var _min_level: int = LogLevel.DEBUG
func _ready():
_instance = self
# 创建日志文件
var timestamp = Time.get_datetime_string_from_system().replace(":", "").replace("-", "").replace("T", "_")
_log_file_path = "user://logs/game_%s.log" % timestamp
DirAccess.make_dir_recursive_absolute("user://logs/")
_log_file = FileAccess.open(_log_file_path, FileAccess.WRITE)
info("GameLogger", "日志系统初始化完成")
func debug(category: String, message: String):
_log(LogLevel.DEBUG, category, message)
func info(category: String, message: String):
_log(LogLevel.INFO, category, message)
func warning(category: String, message: String):
_log(LogLevel.WARNING, category, message)
func error(category: String, message: String):
_log(LogLevel.ERROR, category, message)
func _log(level: int, category: String, message: String):
if level < _min_level:
return
var timestamp = Time.get_time_string_from_system()
var level_names = ["DEBUG", "INFO", "WARNING", "ERROR"]
var level_str = level_names[level].rpad(7)
var log_line = "[%s] [%s] [%s] %s" % [timestamp, level_str, category, message]
# 同时输出到控制台和文件
match level:
LogLevel.ERROR:
push_error(log_line)
LogLevel.WARNING:
push_warning(log_line)
_:
print(log_line)
# 写入文件
if _log_file:
_log_file.store_line(log_line)
func _exit_tree():
if _log_file:
_log_file.close()自动化测试思路
自动化测试让测试"自己跑起来",不需要你每次手动操作。
Godot 中的自动化测试方案
命令行运行测试:
# 运行 GUT 测试(GDScript) godot --path /path/to/project -s addons/gut/gut_cmdln.gd # 运行 C# 测试(需要 .NET 工具链) dotnet testCI/CD 集成:在 GitHub Actions 中自动运行测试
# .github/workflows/test.yml 示例 name: Run Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run GUT Tests run: | ./godot --headless --path . -s addons/gut/gut_cmdln.gd场景测试:为每个功能创建专门的测试场景,自动模拟玩家操作
本章小结
| 工具/方法 | 用途 | 使用频率 |
|---|---|---|
| 远程场景树 | 查看运行时节点状态 | 每次调试 |
| 性能监视器 | 查看帧率、内存等指标 | 性能优化时 |
| GUT / NUnit | 单元测试 | 每次写新功能 |
| Profiler | 找到最慢的代码 | 性能瓶颈时 |
| 内存监控 | 检测内存泄漏 | 持续监控 |
| 日志系统 | 记录运行时事件 | 始终开启 |
| 自动化测试 | CI/CD 防止回归 | 每次提交 |
调试是一项需要练习的技能。养成写日志、写测试的习惯,比事后救火要高效得多。
相关章节
- 基础篇 - 项目创建:脚本编写基础
- 性能优化:性能分析和优化的深入方法
- GDExtension:C++ 代码的调试技巧
