10. 打磨与发布
2026/4/14大约 9 分钟
打磨与发布
游戏做完了,但还没做完。最后一关是"打磨"——把粗糙的地方磨光滑,把卡顿的地方优化掉,然后打包发布。就像盖完房子之后要打扫卫生、摆上家具、挂上窗帘一样。
本章你将学到
- 大世界 LOD 优化策略
- 流式加载优化(区块管理、异步加载)
- 多人联机的架构思路
- 存档系统设计
- 多平台导出配置
优化策略概览
大世界 LOD 优化
LOD(Level of Detail,层级细节)是一种用"偷懒"来提升性能的技术——近处的东西画得精细,远处的东西画得粗糙。反正玩家也看不清远处,何必浪费性能?
LOD 的三个层级
| 层级 | 距离 | 模型面数 | 纹理大小 | 说明 |
|---|---|---|---|---|
| LOD0(精细) | 0~30 米 | 原始面数 | 原始大小 | 玩家身边的东西,要好看 |
| LOD1(中等) | 30~80 米 | 面数减半 | 缩小 50% | 中等距离,看得出形状就行 |
| LOD2(粗糙) | 80~200 米 | 面数 1/4 | 缩小 25% | 远处,只看个轮廓 |
| 隐藏 | >200 米 | 不渲染 | - | 太远了,直接不画 |
LOD 实现代码
C#
// LODController.cs
// LOD控制器——根据距离切换模型精度
using Godot;
public partial class LODController : Node3D
{
[Export] public NodePath TargetPlayerPath;
[Export] public float Lod0Distance = 30f;
[Export] public float Lod1Distance = 80f;
[Export] public float Lod2Distance = 200f;
private Node3D _player;
private MeshInstance3D _lod0Mesh;
private MeshInstance3D _lod1Mesh;
private MeshInstance3D _lod2Mesh;
public override void _Ready()
{
_player = GetNode<Node3D>(TargetPlayerPath);
_lod0Mesh = GetNode<MeshInstance3D>("LOD0");
_lod1Mesh = GetNode<MeshInstance3D>("LOD1");
_lod2Mesh = GetNode<MeshInstance3D>("LOD2");
// 初始状态:只显示LOD0
SetActiveLod(0);
}
public override void _Process(double delta)
{
if (_player == null) return;
float dist = GlobalPosition.DistanceTo(_player.GlobalPosition);
int newLod = CalculateLod(dist);
SetActiveLod(newLod);
}
private int CalculateLod(float distance)
{
if (distance <= Lod0Distance) return 0;
if (distance <= Lod1Distance) return 1;
if (distance <= Lod2Distance) return 2;
return -1; // 隐藏
}
private void SetActiveLod(int lod)
{
_lod0Mesh.Visible = (lod == 0);
_lod1Mesh.Visible = (lod == 1);
_lod2Mesh.Visible = (lod == 2);
}
}GDScript
# lod_controller.gd
# LOD控制器——根据距离切换模型精度
extends Node3D
@export var target_player_path: NodePath
@export var lod0_distance: float = 30.0
@export var lod1_distance: float = 80.0
@export var lod2_distance: float = 200.0
var _player: Node3D
@onready var _lod0_mesh: MeshInstance3D = $LOD0
@onready var _lod1_mesh: MeshInstance3D = $LOD1
@onready var _lod2_mesh: MeshInstance3D = $LOD2
func _ready() -> void:
_player = get_node(target_player_path)
_set_active_lod(0)
func _process(_delta: float) -> void:
if _player == null:
return
var dist := global_position.distance_to(_player.global_position)
var new_lod := _calculate_lod(dist)
_set_active_lod(new_lod)
func _calculate_lod(distance: float) -> int:
if distance <= lod0_distance:
return 0
if distance <= lod1_distance:
return 1
if distance <= lod2_distance:
return 2
return -1 # 隐藏
func _set_active_lod(lod: int) -> void:
_lod0_mesh.visible = (lod == 0)
_lod1_mesh.visible = (lod == 1)
_lod2_mesh.visible = (lod == 2)流式加载优化
异步加载
区块加载如果放在主线程上,会导致画面卡顿。解决办法是异步加载——在后台线程准备区块内容,准备好了再显示。
C#
// AsyncChunkLoader.cs
// 异步区块加载器——不卡主线程
using Godot;
using System.Collections.Generic;
using System.Threading;
public partial class AsyncChunkLoader : Node3D
{
[Export] public int ChunkSize = 128;
[Export] public int LoadRadius = 3;
private Dictionary<string, Node3D> _loadedChunks = new();
private List<string> _loadQueue = new(); // 等待加载的区块
private List<string> _unloadQueue = new(); // 等待卸载的区块
private Node3D _player;
private Vector2I _currentChunkCoord;
public override void _Ready()
{
_player = GetNode<Node3D>("%Player");
}
public override void _Process(double delta)
{
if (_player == null) return;
Vector2I newCoord = WorldToChunkCoord(_player.GlobalPosition);
if (newCoord != _currentChunkCoord)
{
_currentChunkCoord = newCoord;
UpdateChunkQueues();
}
// 每帧最多加载1个区块(避免卡顿)
if (_loadQueue.Count > 0)
{
string key = _loadQueue[0];
_loadQueue.RemoveAt(0);
LoadChunkImmediate(key);
}
// 每帧最多卸载2个区块
int unloadCount = 0;
while (_unloadQueue.Count > 0 && unloadCount < 2)
{
string key = _unloadQueue[0];
_unloadQueue.RemoveAt(0);
UnloadChunkImmediate(key);
unloadCount++;
}
}
private void UpdateChunkQueues()
{
var needed = new HashSet<string>();
for (int dx = -LoadRadius; dx <= LoadRadius; dx++)
{
for (int dz = -LoadRadius; dz <= LoadRadius; dz++)
{
string key = $"{_currentChunkCoord.X + dx},{_currentChunkCoord.Y + dz}";
needed.Add(key);
if (!_loadedChunks.ContainsKey(key) && !_loadQueue.Contains(key))
{
_loadQueue.Add(key);
}
}
}
foreach (var key in new List<string>(_loadedChunks.Keys))
{
if (!needed.Contains(key) && !_unloadQueue.Contains(key))
{
_unloadQueue.Add(key);
}
}
}
private void LoadChunkImmediate(string key)
{
// TODO: 实例化区块场景并添加到场景树
GD.Print($"异步加载区块: {key}");
}
private void UnloadChunkImmediate(string key)
{
if (_loadedChunks.TryGetValue(key, out var chunk))
{
chunk.QueueFree();
_loadedChunks.Remove(key);
GD.Print($"卸载区块: {key}");
}
}
private Vector2I WorldToChunkCoord(Vector3 pos)
{
return new Vector2I(
(int)Mathf.Floor(pos.X / ChunkSize),
(int)Mathf.Floor(pos.Z / ChunkSize)
);
}
}GDScript
# async_chunk_loader.gd
# 异步区块加载器——不卡主线程
extends Node3D
@export var chunk_size: int = 128
@export var load_radius: int = 3
var _loaded_chunks: Dictionary = {}
var _load_queue: Array = [] # 等待加载的区块
var _unload_queue: Array = [] # 等待卸载的区块
var _player: Node3D
var _current_chunk_coord: Vector2i = Vector2i.ZERO
func _ready() -> void:
_player = get_node("%Player")
func _process(_delta: float) -> void:
if _player == null:
return
var new_coord := _world_to_chunk_coord(_player.global_position)
if new_coord != _current_chunk_coord:
_current_chunk_coord = new_coord
_update_chunk_queues()
# 每帧最多加载1个区块
if _load_queue.size() > 0:
var key: String = _load_queue.pop_front()
_load_chunk_immediate(key)
# 每帧最多卸载2个区块
var unload_count := 0
while _unload_queue.size() > 0 and unload_count < 2:
var key: String = _unload_queue.pop_front()
_unload_chunk_immediate(key)
unload_count += 1
func _update_chunk_queues() -> void:
var needed: Dictionary = {}
for dx in range(-load_radius, load_radius + 1):
for dz in range(-load_radius, load_radius + 1):
var key := "%d,%d" % [_current_chunk_coord.x + dx, _current_chunk_coord.y + dz]
needed[key] = true
if not _loaded_chunks.has(key) and not key in _load_queue:
_load_queue.append(key)
for key in _loaded_chunks:
if not needed.has(key) and not key in _unload_queue:
_unload_queue.append(key)
func _load_chunk_immediate(key: String) -> void:
# TODO: 实例化区块场景
print("异步加载区块: %s" % key)
func _unload_chunk_immediate(key: String) -> void:
if _loaded_chunks.has(key):
var chunk: Node3D = _loaded_chunks[key]
chunk.queue_free()
_loaded_chunks.erase(key)
print("卸载区块: %s" % key)
func _world_to_chunk_coord(pos: Vector3) -> Vector2i:
return Vector2i(
int(floorf(pos.x / chunk_size)),
int(floorf(pos.z / chunk_size))
)对象池优化
频繁创建和删除物体会导致内存碎片和卡顿。用"对象池"——把不用的物体藏起来而不是删除,需要时拿出来而不是新建。
多人联机架构思路
MVP 阶段先做单机版。正式版如果想加多人联机,这里提供架构思路。
联机架构选择
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 服务器-客户端 | 安全、好管理 | 需要服务器成本 | 正式版 |
| P2P | 不需要服务器 | 容易作弊、延迟高 | 原型验证 |
| Godot 内置多人 | 简单易上手 | 性能有限 | 小型游戏 |
核心同步数据
联机时只需要同步"关键数据",不需要同步所有东西:
| 需要同步 | 不需要同步 |
|---|---|
| 玩家位置和动作 | 远处的帕鲁 AI |
| 战斗伤害 | 环境音效 |
| 帕鲁状态变化 | 粒子特效(各自渲染) |
| 建筑放置/删除 | 远处的物体 LOD |
| 物品交易 | 天气渲染 |
存档系统
存档系统让玩家的进度不会丢失。关键是要保存哪些数据。
需要保存的数据
| 数据类型 | 内容 | 存储格式 |
|---|---|---|
| 玩家数据 | 位置、HP、饥饿值、等级 | JSON |
| 帕鲁队伍 | 每只帕鲁的属性和状态 | JSON |
| 基地数据 | 所有建筑的位置和类型 | JSON |
| 资源修改 | 玩家挖过的矿、砍过的树 | 区块差量 |
| 图鉴进度 | 已发现和捕获的帕鲁 | 位掩码 |
| 物品栏 | 所有物品和数量 | JSON |
存档系统代码
C#
// SaveSystem.cs
// 存档系统——保存和加载游戏进度
using Godot;
using System.Text.Json;
public partial class SaveSystem : Node
{
private const string SavePath = "user://save_data.json";
// 保存游戏
public void SaveGame(SaveData data)
{
string json = JsonSerializer.Serialize(data);
var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write);
if (file != null)
{
file.StoreString(json);
file.Close();
GD.Print("游戏已保存");
}
}
// 加载游戏
public SaveData LoadGame()
{
if (!FileAccess.FileExists(SavePath)) return null;
var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read);
if (file != null)
{
string json = file.GetAsText();
file.Close();
return JsonSerializer.Deserialize<SaveData>(json);
}
return null;
}
// 自动保存(每5分钟)
public override void _Ready()
{
var timer = new Timer();
timer.WaitTime = 300.0; // 5分钟
timer.Autostart = true;
timer.Timeout += AutoSave;
AddChild(timer);
}
private void AutoSave()
{
// TODO: 收集当前游戏状态并保存
GD.Print("自动保存...");
}
}
// 存档数据结构
public class SaveData
{
public float PlayerX, PlayerY, PlayerZ;
public float PlayerHp, PlayerHunger, PlayerStamina;
public string[] PalTeam; // 队伍中的帕鲁数据
public string[] Buildings; // 基地建筑数据
public string Inventory; // 物品栏数据
public int[] DiscoveredPals; // 已发现的帕鲁ID
}GDScript
# save_system.gd
# 存档系统——保存和加载游戏进度
extends Node
const SAVE_PATH := "user://save_data.json"
## 保存游戏
func save_game(data: Dictionary) -> void:
var json := JSON.stringify(data)
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file != null:
file.store_string(json)
file.close()
print("游戏已保存")
## 加载游戏
func load_game() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file != null:
var json := file.get_as_text()
file.close()
var result := JSON.parse_string(json)
if result is Dictionary:
return result
return {}
func _ready() -> void:
# 自动保存(每5分钟)
var timer := Timer.new()
timer.wait_time = 300.0
timer.autostart = true
timer.timeout.connect(_auto_save)
add_child(timer)
func _auto_save() -> void:
# TODO: 收集当前游戏状态并保存
print("自动保存...")多平台导出
Godot 支持一键导出到多个平台。
导出配置
| 平台 | 导出格式 | 注意事项 |
|---|---|---|
| Windows | .exe | 最简单,直接导出 |
| macOS | .dmg | 需要签名才能分发 |
| Linux | .x86_64 | AppImage 格式推荐 |
| Web | .html | 性能有限,适合试玩版 |
| Android | .apk | 需要适配触屏操作 |
| iOS | .ipa | 需要 Mac 和开发者账号 |
导出优化建议
| 优化项 | 方法 | 效果 |
|---|---|---|
| 纹理压缩 | 启用 VRAM 压缩 | 减小包体 60% |
| 模型优化 | 去除未使用的顶点 | 减小包体和内存 |
| 音频压缩 | 使用 OGG 格式 | 减小包体 70% |
| 剔除调试 | 导出时关闭调试信息 | 减小包体 |
| 资源加密 | 启用资源加密 | 防止资源被提取 |
发布前检查清单
功能检查
性能检查
体验检查
常见问题
Q:LOD 切换时模型"跳变"怎么解决?
用 LOD Bias(偏移)来平滑过渡。在切换距离的临界点附近(比如前后 5 米),让两个 LOD 的模型同时渲染,用透明度渐变来切换。这样就不会突然跳变了。
Q:异步加载的区块有"空洞"怎么办?
预加载:当玩家距离某个区块还有 2 个区块远时就开始加载,而不是等走到边界才加载。这样玩家到达时区块已经准备好了。
Q:多人联机延迟怎么处理?
用"客户端预测"——玩家的操作在本地立即执行(比如移动),同时发送给服务器确认。如果服务器发现位置不对,再纠正。玩家感觉不到延迟。
Q:导出后游戏闪退怎么办?
常见原因:
- 路径用了绝对路径(应该用
res://或user://) - 漏导出了某些资源
- C# 版本导出需要包含
.dll文件
恭喜完成
到这里,你已经完成了从零开始搭建一个"幻兽帕鲁"类游戏的全部教程。回顾一下我们做了什么:
- 设计了核心玩法的三角循环
- 搭建了开放世界项目结构
- 实现了帕鲁生物系统(AI、属性、繁殖)
- 建造了基地系统
- 设计了生存和合成系统
- 实现了战斗和捕捉机制
- 设计了丰富的世界探索内容
- 搭建了完整的游戏 UI
- 添加了音效和特效
- 优化性能并准备发布
这只是开始。真正的游戏开发是一个反复迭代的过程——发布第一个版本,收集玩家反馈,改进,再发布。祝你的游戏开发之旅顺利!
