PAYATHON 2026

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);

取整会造成位置量化,适合像素风格,但不适合需要连续亚像素移动的普通图片。

建议的排查顺序

  1. 删除 render() 中的所有逐帧日志。
  2. 使用非调试构建,并在真实设备上测试。
  3. 只保留一种主要帧节流方式,优先测试 VSync。
  4. 检查屏幕的实际刷新率是否与目标 FPS 匹配。
  5. 继续使用 speed * deltaTime 计算移动距离。
  6. 判断卡顿来自帧时长波动,还是纹理在亚像素位置采样时产生的边缘跳变。
  7. 游戏逻辑需要稳定更新时,再加入固定时间步和渲染插值。
  8. 观察连续多帧的帧时间分布,不要只比较单个 deltaTime 数值或平均 FPS。

只绘制一个纹理时,GPU 通常不会成为瓶颈。应先检查日志、VSync、刷新率和帧节奏,再考虑调整 SpriteBatch 或移动公式。

备注:内容仅供参考。