LibGDX 移动简单图像时出现轻微卡顿
结论
这段移动逻辑没有明显问题。deltaTime 在 0.0167 到 0.0183 秒之间波动很常见。即使设置了 60 FPS,每一帧也未必都能严格保持 1 / 60 秒。
轻微卡顿可能来自以下因素:
- 每帧执行
System.out.println(),会产生同步 I/O、字符串分配和潜在的 GC 停顿。 - FPS 限制器依赖操作系统调度,
sleep或等待时间会有误差。 - VSync、显示器刷新率和应用帧率没有配合好。
- 在 60 Hz 下,
300 px/s相当于每帧移动约 5 像素,视觉上可能出现离散感。 - 逻辑帧与显示帧偶尔错开,造成 frame pacing 问题。
- 模拟器、调试模式、桌面合成器以及移动设备的节能策略也会影响画面表现。
OpenGL 能提高绘制吞吐量,却不能保证帧间隔稳定。绘制一个 logo 几乎不会给 GPU 带来压力,因此更应该检查帧调度和显示同步。
先移除逐帧日志
不要在 render() 中逐帧打印 deltaTime:
System.out.println("deltaTime:" + deltaTime);
控制台输出通常是同步操作,而且会创建临时字符串。IDE 控制台在处理大量输出时还可能间歇性阻塞,表现出来就是偶发卡顿。
可以改为每秒统计一次:
private float logTimer;
private int frameCount;
@Override
public void render() {
float deltaTime = Gdx.graphics.getDeltaTime();
logTimer += deltaTime;
frameCount++;
if (logTimer >= 1f) {
Gdx.app.log("FrameStats",
"fps=" + frameCount + ", lastDelta=" + deltaTime);
logTimer -= 1f;
frameCount = 0;
}
// 更新和绘制
}
测试流畅度时,最好关闭日志并使用非调试构建。如果条件允许,尽量在真实设备上测试。
60 FPS 不是精确计时器
60 FPS 的理想帧间隔是:
1 / 60 ≈ 0.0166667 秒
实际间隔还会受到操作系统调度、窗口合成器、VSync 等待、后台任务和 JVM 暂停的影响。应用中设置的 60 FPS 通常只是目标值或上限,无法保证每一帧都严格等于 0.0166667 秒。
0.0167 到 0.0183 秒相差约:
0.0183 - 0.0167 = 0.0016 秒
这类波动可能影响运动观感,但应先排除测量过程造成的干扰,尤其是逐帧输出控制台日志。
问题中的 Java 循环范围写成了 0.0166665 到 0.166668 秒。如果后一个数值确实是 0.166668,两者相差约 0.15 秒,并非 0.0000003 秒。只有原意为 0.0166668 时,才符合所述差值。
System.nanoTime() 精度高,说明计时读数足够精细,但无法证明图像提交到屏幕的时间同样稳定。使用忙等待的自定义循环可能得到更整齐的时序,同时也可能持续占用 CPU。
优先使用 VSync 控制呈现节奏
在桌面端的 LWJGL3 启动配置中,可以启用 VSync,同时关闭额外的前台 FPS 限制,避免两套节流机制互相干扰:
Lwjgl3ApplicationConfiguration config =
new Lwjgl3ApplicationConfiguration();
config.setTitle("My Game");
config.useVsync(true);
config.setForegroundFPS(0);
new Lwjgl3Application(new MyGdxGame(), config);
在 LWJGL3 中,setForegroundFPS(0) 表示不使用 LibGDX 的前台 FPS 限制,交换节奏交给 VSync 控制。默认值可能随后端和版本变化,需要检查项目当前使用的启动配置。
如果关闭 VSync,可以设置目标 FPS:
config.useVsync(false);
config.setForegroundFPS(60);
这种方式依赖软件限帧,帧间隔往往不如 VSync 稳定,还可能出现画面撕裂。一般不建议同时使用 VSync 和数值相同的软件限帧器来追求严格的 60 FPS。
还需要确认显示器的实际刷新率。应用输出 60 FPS 时:
- 60 Hz 显示器通常可以让一帧对应一次刷新。
- 120 Hz 显示器通常会把每帧显示两次。
- 75 Hz 等无法被 60 整除的刷新率可能产生周期性顿挫。
移动平台的呈现节奏由系统和 LibGDX 后端共同管理,不能直接照搬所有 LWJGL3 配置。测试时应使用真实设备,并检查刷新率、节能模式和应用能否稳定获得前台渲染时间。
保留基于 deltaTime 的移动
按时间计算移动距离的思路是对的:
private static final float MAX_DELTA = 1f / 15f;
@Override
public void render() {
float deltaTime = Math.min(
Gdx.graphics.getDeltaTime(),
MAX_DELTA
);
float direction = 0f;
if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
direction -= 1f;
}
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
direction += 1f;
}
pos.x += direction * speed * deltaTime;
ScreenUtils.clear(1f, 0f, 0f, 1f);
batch.begin();
batch.draw(img, pos.x, pos.y);
batch.end();
}
给 deltaTime 设置上限,可以防止窗口被拖动、应用短暂挂起或调试器暂停后,物体突然跳过很长一段距离。这里的 1 / 15 只是保护上限,可以根据游戏需求调整。
如果直接写成:
pos.x += 5f;
移动速度就会依赖实际帧率。60 FPS 时速度是 300 px/s,30 FPS 时只有 150 px/s。如果某一帧发生延迟,物体仍然只移动 5 像素,实时速度会短暂下降,所以这种写法解决不了帧时长波动。
需要稳定模拟时使用固定时间步和插值
对于简单的 logo 移动,直接使用变量 deltaTime 一般就够了。如果后续加入物理、碰撞或需要确定性的游戏逻辑,可以用固定时间步更新状态,并在绘制时插值:
import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.ScreenUtils;
public class MyGdxGame extends ApplicationAdapter {
private static final float STEP = 1f / 60f;
private static final float MAX_FRAME_TIME = 0.25f;
private SpriteBatch batch;
private Texture img;
private float previousX;
private float currentX;
private float accumulator;
private float speed = 300f;
@Override
public void create() {
batch = new SpriteBatch();
img = new Texture("badlogic.jpg");
}
@Override
public void render() {
float frameTime = Math.min(
Gdx.graphics.getDeltaTime(),
MAX_FRAME_TIME
);
accumulator += frameTime;
while (accumulator >= STEP) {
previousX = currentX;
float direction = 0f;
if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
direction -= 1f;
}
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
direction += 1f;
}
currentX += direction * speed * STEP;
accumulator -= STEP;
}
float alpha = accumulator / STEP;
float renderX = MathUtils.lerp(previousX, currentX, alpha);
ScreenUtils.clear(1f, 0f, 0f, 1f);
batch.begin();
batch.draw(img, renderX, 0f);
batch.end();
}
@Override
public void dispose() {
batch.dispose();
img.dispose();
}
}
固定时间步让游戏状态按稳定间隔更新,插值可以缓解逻辑更新频率与显示刷新不同步造成的跳动。这种方法无法修复系统层面的丢帧或不稳定 VSync,但可以避免模拟状态使用不规则的更新步长。
检查纹理采样造成的视觉抖动
SpriteBatch 支持浮点坐标。如果纹理使用最近邻采样,图像移动到非整数像素位置时,边缘可能轻微跳变,看起来很像卡顿。可以测试线性过滤:
img.setFilter(
Texture.TextureFilter.Linear,
Texture.TextureFilter.Linear
);
线性过滤可能会让像素画变模糊。像素风格游戏通常会保留 Nearest,然后将绘制坐标取整:
float drawX = Math.round(pos.x);
batch.draw(img, drawX, pos.y);
取整会造成位置量化,适合像素风格,但不适合需要连续亚像素移动的普通图片。
建议的排查顺序
- 删除
render()中的所有逐帧日志。 - 使用非调试构建,并在真实设备上测试。
- 只保留一种主要帧节流方式,优先测试 VSync。
- 检查屏幕的实际刷新率是否与目标 FPS 匹配。
- 继续使用
speed * deltaTime计算移动距离。 - 判断卡顿来自帧时长波动,还是纹理在亚像素位置采样时产生的边缘跳变。
- 游戏逻辑需要稳定更新时,再加入固定时间步和渲染插值。
- 观察连续多帧的帧时间分布,不要只比较单个
deltaTime数值或平均 FPS。
只绘制一个纹理时,GPU 通常不会成为瓶颈。应先检查日志、VSync、刷新率和帧节奏,再考虑调整 SpriteBatch 或移动公式。
备注:内容仅供参考。
