14. 移动端适配与优化
2026/4/14大约 9 分钟
移动端适配与优化
你的游戏在电脑上跑 60 帧很流畅,但放到手机上可能只有 15 帧还疯狂发热——因为手机的 CPU 和 GPU 性能通常只有电脑的 1/5 到 1/10。移动端适配就是让游戏在"小身板"的手机上也能流畅运行的艺术。
本章你将学到
- 移动端渲染管线选择(Compatibility 渲染器)
- 触控输入系统(虚拟摇杆、按钮、手势)
- 移动端性能瓶颈分析
- 纹理压缩(ETC2/ASTC)
- LOD 策略(移动端更激进的 LOD)
- 内存管理(资源卸载、纹理流式加载)
- 电池与发热优化
移动端渲染管线选择
Godot 4.x 提供三种渲染器,移动端只能使用 Compatibility(兼容)渲染器:
| 渲染器 | 画质 | 性能要求 | 移动端适用性 |
|---|---|---|---|
| Forward+ | 最高 | 高 | 不支持 |
| Mobile | 高 | 中 | 中高端设备 |
| Compatibility | 中等 | 低 | 所有设备 |
如何选择
- 如果你的目标设备是近 3 年的旗舰手机:选 Mobile 渲染器
- 如果需要覆盖低端设备或 Web 平台:选 Compatibility 渲染器
- 在项目设置 > 渲染 > 渲染器 中切换
触控输入系统
手机没有键盘和鼠标,玩家通过手指在屏幕上操作。最常见的触控 UI 是虚拟摇杆和虚拟按钮。
虚拟摇杆
C#
using Godot;
public partial class VirtualJoystick : Control
{
[Export] public float MaxDistance { get; set; } = 80.0f;
[Export] public float DeadZone { get; set; } = 10.0f;
private TextureRect _background;
private TextureRect _handle;
private Vector2 _center;
private bool _isPressed = false;
private int _touchIndex = -1;
// 输出值:-1 到 1 的二维向量
public Vector2 Output { get; private set; } = Vector2.Zero;
public override void _Ready()
{
_background = GetNode<TextureRect>("Background");
_handle = GetNode<TextureRect>("Handle");
_center = _background.Position + _background.Size / 2;
}
public override void _Input(InputEvent @event)
{
if (@event is InputEventScreenTouch touch)
{
if (touch.Pressed)
{
// 检查触摸是否在摇杆区域内
Rect2 area = new Rect2(
_background.GlobalPosition,
_background.Size * 1.5f // 稍大的触摸区域
);
if (area.HasPoint(touch.Position) && _touchIndex == -1)
{
_isPressed = true;
_touchIndex = touch.Index;
UpdateHandle(touch.Position);
}
}
else if (touch.Index == _touchIndex)
{
ResetJoystick();
}
}
else if (@event is InputEventScreenDrag drag)
{
if (drag.Index == _touchIndex && _isPressed)
{
UpdateHandle(drag.Position);
}
}
}
private void UpdateHandle(Vector2 touchPos)
{
Vector2 direction = touchPos - _center;
float distance = direction.Length();
// 限制在最大距离内
if (distance > MaxDistance)
{
direction = direction.Normalized() * MaxDistance;
}
// 更新摇杆手柄位置
_handle.Position = _center + direction - _handle.Size / 2;
// 计算输出值
if (distance > DeadZone)
{
Output = direction / MaxDistance;
}
else
{
Output = Vector2.Zero;
}
}
private void ResetJoystick()
{
_isPressed = false;
_touchIndex = -1;
_handle.Position = _center - _handle.Size / 2;
Output = Vector2.Zero;
}
}GDScript
extends Control
@export var max_distance: float = 80.0
@export var dead_zone: float = 10.0
@onready var background: TextureRect = $Background
@onready var handle: TextureRect = $Handle
var center: Vector2
var is_pressed: bool = false
var touch_index: int = -1
# 输出值:-1 到 1 的二维向量
var output: Vector2 = Vector2.ZERO
func _ready():
center = background.position + background.size / 2
func _input(event: InputEvent):
if event is InputEventScreenTouch:
var touch = event as InputEventScreenTouch
if touch.pressed:
# 检查触摸是否在摇杆区域内
var area = Rect2(
background.global_position,
background.size * 1.5 # 稍大的触摸区域
)
if area.has_point(touch.position) and touch_index == -1:
is_pressed = true
touch_index = touch.index
_update_handle(touch.position)
elif touch.index == touch_index:
_reset_joystick()
elif event is InputEventScreenDrag:
var drag = event as InputEventScreenDrag
if drag.index == touch_index and is_pressed:
_update_handle(drag.position)
func _update_handle(touch_pos: Vector2):
var direction = touch_pos - center
var distance = direction.length()
# 限制在最大距离内
if distance > max_distance:
direction = direction.normalized() * max_distance
# 更新摇杆手柄位置
handle.position = center + direction - handle.size / 2
# 计算输出值
if distance > dead_zone:
output = direction / max_distance
else:
output = Vector2.ZERO
func _reset_joystick():
is_pressed = false
touch_index = -1
handle.position = center - handle.size / 2
output = Vector2.ZERO触控按钮
C#
using Godot;
public partial class TouchButton : TextureButton
{
[Export] public string InputAction { get; set; } = "";
public override void _Ready()
{
// 连接触摸信号
ButtonDown += OnButtonPressed;
ButtonUp += OnButtonReleased;
}
private void OnButtonPressed()
{
if (!string.IsNullOrEmpty(InputAction))
{
// 模拟按下输入动作
Input.ActionPress(InputAction);
}
}
private void OnButtonReleased()
{
if (!string.IsNullOrEmpty(InputAction))
{
// 模拟释放输入动作
Input.ActionRelease(InputAction);
}
}
}GDScript
extends TextureButton
@export var input_action: String = ""
func _ready():
# 连接触摸信号
button_down.connect(_on_button_pressed)
button_up.connect(_on_button_released)
func _on_button_pressed():
if input_action != "":
# 模拟按下输入动作
Input.action_press(input_action)
func _on_button_released():
if input_action != "":
# 模拟释放输入动作
Input.action_release(input_action)移动端性能瓶颈分析
使用 Godot 性能监视器分析
运行游戏后打开 调试 > 监视器,关注以下指标:
| 指标 | 移动端建议值 | 含义 |
|---|---|---|
| FPS | >= 30 | 每秒帧数 |
| Frame Time | < 33ms | 每帧耗时(30FPS 时) |
| Draw Calls | < 200 | 绘制调用次数 |
| Vertex Count | < 100K | 顶点数量 |
| Texture Memory | < 256MB | 纹理内存占用 |
| Object Count | < 500 | 活跃对象数量 |
纹理压缩
未压缩的纹理是移动端内存的最大杀手。一张 2048x2048 的 RGBA 纹理占用 16MB 内存,10 张就是 160MB——很多手机的 GPU 只有两三百 MB 可用显存。
纹理压缩格式选择
| 格式 | 压缩比 | 质量 | 兼容性 |
|---|---|---|---|
| ETC2 | 4:1 | 中等 | 所有 OpenGL ES 3.0 设备 |
| ASTC 4x4 | 4:1 | 高 | 较新的设备(2016+) |
| ASTC 6x6 | 8:1 | 中等 | 较新的设备 |
| ASTC 8x8 | 12:1 | 低 | 较新的设备 |
Godot 中的纹理压缩设置
在导入面板(FileSystem dock)中选择纹理文件,在 Import 面板中:
- 设置 VRAM Compression 为 VRAM Compressed
- 设置 Compress Mode 为 Lossy
- 设置 HDR Compression 为 Optimize for Mobile
- Android 使用 ETC2 兼容模式,iOS 使用 ASTC
LOD 策略(移动端更激进)
移动端的 LOD 切换距离应该比 PC 短得多。建议:
| LOD 级别 | PC 切换距离 | 移动端切换距离 |
|---|---|---|
| 高精度 | 0 - 30m | 0 - 15m |
| 中精度 | 30 - 60m | 15 - 30m |
| 低精度 | 60 - 100m | 30 - 50m |
| 隐藏(剔除) | 100m+ | 50m+ |
内存管理
手机内存有限,不能像 PC 那样一次性把所有资源都加载进来。
资源按需加载与卸载
C#
using Godot;
using System.Collections.Generic;
public partial class MobileResourceManager : Node
{
private const long MAX_TEXTURE_MEMORY = 200 * 1024 * 1024; // 200MB
private Dictionary<string, Resource> _loadedResources = new();
private List<string> _lruList = new(); // 最近使用列表
/// <summary>
/// 按需加载资源
/// </summary>
public T LoadResource<T>(string path) where T : Resource
{
// 已加载则更新 LRU 并返回
if (_loadedResources.ContainsKey(path))
{
_lruList.Remove(path);
_lruList.Add(path);
return (T)_loadedResources[path];
}
// 检查内存是否够用
CheckMemoryAndUnload();
// 加载新资源
var resource = GD.Load<T>(path);
if (resource != null)
{
_loadedResources[path] = resource;
_lruList.Add(path);
GD.Print($"资源已加载: {path}");
}
return resource;
}
/// <summary>
/// 检查内存并在需要时卸载最久未用的资源
/// </summary>
private void CheckMemoryAndUnload()
{
// 简单策略:当加载资源数超过阈值时,卸载最久未用的
while (_loadedResources.Count > 50 && _lruList.Count > 0)
{
string oldest = _lruList[0];
_lruList.RemoveAt(0);
_loadedResources.Remove(oldest);
// 强制释放资源
var res = ResourceLoader.LoadThreadedGet(oldest);
if (res != null)
{
res.Dispose();
}
GD.Print($"资源已卸载: {oldest}");
}
}
/// <summary>
/// 场景切换时清理所有资源
/// </summary>
public void ClearAll()
{
_loadedResources.Clear();
_lruList.Clear();
GC.Collect();
GD.Print("所有资源已清理");
}
}GDScript
extends Node
const MAX_RESOURCE_COUNT: int = 50
var _loaded_resources: Dictionary = {}
var _lru_list: Array = []
## 按需加载资源
func load_resource(path: String) -> Resource:
# 已加载则更新 LRU 并返回
if _loaded_resources.has(path):
_lru_list.erase(path)
_lru_list.append(path)
return _loaded_resources[path]
# 检查内存是否够用
_check_memory_and_unload()
# 加载新资源
var resource = load(path)
if resource:
_loaded_resources[path] = resource
_lru_list.append(path)
print("资源已加载: ", path)
return resource
## 检查内存并在需要时卸载最久未用的资源
func _check_memory_and_unload():
while _loaded_resources.size() > MAX_RESOURCE_COUNT and _lru_list.size() > 0:
var oldest = _lru_list.pop_front()
_loaded_resources.erase(oldest)
print("资源已卸载: ", oldest)
## 场景切换时清理所有资源
func clear_all():
_loaded_resources.clear()
_lru_list.clear()
print("所有资源已清理")电池与发热优化
手机游戏发热是最常见的差评来源。发热的根本原因是 CPU/GPU 长时间高负载运行。
优化策略
- 降低目标帧率:不总是需要 60FPS。30FPS 对很多游戏已经足够流畅
- 减少不必要的计算:不在视野内的 AI 不需要每帧更新
- 降低物理频率:手机上 30Hz 物理步进通常够用(PC 默认 60Hz)
- 减少光源:每个实时光源都会大幅增加 GPU 负担
C#
using Godot;
public partial class MobileOptimizer : Node
{
[Export] public bool IsMobile { get; set; } = false;
public override void _Ready()
{
IsMobile = OS.GetName() == "Android" || OS.GetName() == "iOS";
if (IsMobile)
{
ApplyMobileSettings();
}
}
private void ApplyMobileSettings()
{
// 1. 降低目标帧率到 30(如果不需要 60)
int targetFPS = GetTargetFPS();
Engine.MaxFps = targetFPS;
GD.Print($"目标帧率: {targetFPS}");
// 2. 降低物理频率
Engine.PhysicsTicksPerSecond = 30;
// 3. 降低阴影质量
var viewport = GetViewport();
if (viewport != null)
{
// 禁用阴影或降低阴影贴图大小
ProjectSettings.SetSetting(
"rendering/lights_and_shadows/directional_shadow/size",
1024 // PC 默认 4096
);
}
// 4. 限制视距
var camera = GetViewport().GetCamera3D();
if (camera != null)
{
camera.Far = 150.0f; // 比 PC 的 500-1000 短得多
}
GD.Print("移动端优化设置已应用");
}
private int GetTargetFPS()
{
// 可以根据设备性能动态选择
// 简单方案:全部 30FPS
return 30;
// 更高级:根据设备信息判断
// string modelName = OS.GetModelName();
// if (IsHighEndDevice(modelName)) return 60;
// return 30;
}
}GDScript
extends Node
@export var is_mobile: bool = false
func _ready():
is_mobile = OS.get_name() in ["Android", "iOS"]
if is_mobile:
_apply_mobile_settings()
func _apply_mobile_settings():
# 1. 降低目标帧率到 30
var target_fps = _get_target_fps()
Engine.max_fps = target_fps
print("目标帧率: ", target_fps)
# 2. 降低物理频率
Engine.physics_ticks_per_second = 30
# 3. 降低阴影质量
ProjectSettings.set_setting(
"rendering/lights_and_shadows/directional_shadow/size",
1024 # PC 默认 4096
)
# 4. 限制视距
var camera = get_viewport().get_camera_3d()
if camera:
camera.far = 150.0 # 比 PC 的 500-1000 短得多
print("移动端优化设置已应用")
func _get_target_fps() -> int:
# 简单方案:全部 30FPS
return 30
# 更高级:根据设备信息判断
# var model_name = OS.get_model_name()
# if _is_high_end_device(model_name):
# return 60
# return 30移动端优化检查清单
本章小结
| 优化方向 | 关键措施 | 影响程度 |
|---|---|---|
| 渲染器 | 选择 Compatibility 或 Mobile | 基础 |
| 纹理压缩 | ETC2/ASTC | 大(内存减少 4-12 倍) |
| LOD | 更短的切换距离 | 大(GPU 负担大幅降低) |
| 内存 | 按需加载 + LRU 卸载 | 中 |
| 帧率 | 30FPS 目标 | 大(CPU/GPU 负担减半) |
| 物理 | 30Hz 步进 | 中 |
| 光源 | 限制 1-2 个 | 大 |
移动端优化的核心原则:少即是多。减少绘制调用、减少内存占用、减少计算量——让有限的硬件资源花在玩家能看到的东西上。
