LWJGL教程(2)——游戏循环
核心概念:游戏循环的作用
游戏程序的核心是一个不断重复执行的循环,负责协调游戏的各个方面:
- 处理输入 (
input()
): 读取键盘、鼠标、手柄等输入设备的状态。 - 更新游戏逻辑 (
update()
): 根据输入、时间推移和其他规则更新游戏状态(角色位置、物理、AI、分数等)。 - 渲染画面 (
render()
): 将当前游戏状态绘制到屏幕上。
基础结构
教程展示了最简单的游戏程序框架:
public void startGame() {init(); // 初始化:创建窗口、加载资源(纹理、声音、模型等)gameLoop(); // 核心游戏循环dispose(); // 清理:释放所有资源
}
init()
和 dispose()
相对直接,重点是 gameLoop()
的设计。
游戏循环的演进
-
简单游戏循环 (问题:CPU 占用过高)
public void gameLoop() {while (running) {input();update();render();} }
- 问题: 这个循环会以尽可能快的速度运行(受限于 CPU/GPU 能力),导致 CPU 使用率飙升到 100%,浪费资源,产生不必要的热量和功耗。
- 解决思路: 需要控制循环的执行速度。
-
简单循环 + 固定休眠 (问题:帧率不灵活)
public void gameLoop() {long sleepTime = 1000L / 60L; // 目标:60 FPS (≈16ms)while (running) {input();update();render();sleep(sleepTime); // 固定休眠时间} }
- 改进: 通过固定的休眠时间 (
sleepTime
) 限制循环速度(例如目标 60 FPS)。 - 问题:
- 帧率不稳定: 如果
input()
,update()
,render()
的总耗时超过sleepTime
,实际 FPS 会低于目标值。如果耗时短于sleepTime
,则会浪费休眠时间,无法充分利用硬件达到更高帧率。 - 逻辑更新与帧率绑定:
update()
的调用频率与渲染帧率相同。如果帧率波动,游戏逻辑更新速度也会波动(例如,物体移动速度忽快忽慢),这通常是不期望的(尤其在物理模拟中)。
- 帧率不稳定: 如果
- 改进: 通过固定的休眠时间 (
-
可变时间步长循环 (Variable Timestep - 解耦逻辑与渲染)
public void gameLoop() {while (running) {float delta = timer.getDelta(); // 获取上一帧到当前帧经过的时间 (秒)input();update(delta); // 将时间差delta传递给更新函数render();window.update(); // 通常包括交换缓冲区、处理事件 (如GLFW的glfwPollEvents)} }
- 核心改进: 引入了 Delta Time (δt) - 即上一帧到当前帧实际经过的时间(秒)。
- 关键点 (
update(delta)
): 游戏逻辑更新函数 (update
) 接受delta
作为参数。所有需要随时间变化的逻辑(如移动、动画)都应基于delta
进行计算。- 例如:
position += velocity * delta;
(物体移动距离 = 速度 * 时间)
- 例如:
- 优点:
- 帧率独立性 (Frame Rate Independence): 游戏逻辑更新不再与渲染帧率绑定。无论电脑快慢、帧率高低(30 FPS 或 120 FPS),物体移动的 速度 (单位时间内的位移) 都能保持一致。逻辑更新的频率自然地等于渲染帧率。
- 实现相对简单: 结构清晰。
- 问题:
- 非确定性 (Non-deterministic): 由于
delta
是变化的,每次更新的“步长”不同。在非常复杂的模拟或对确定性要求极高的场景(如精确的网络同步、物理重放)中,可能导致细微差异积累。 - 物理模拟不稳定: 物理引擎通常需要固定频率的更新步长来保证数值积分的稳定性和准确性。可变
delta
可能导致物理抖动或不一致。教程提到这是“主要缺点”。
- 非确定性 (Non-deterministic): 由于
- 休眠控制 (可选):
long targetTime = 1000L / targetFPS; // 目标每帧耗时 (毫秒) long startTime = timer.getTime() * 1000; // 循环开始时间 (毫秒) // ... (input, update(delta), render, window.update) long endTime = timer.getTime() * 1000; // 循环结束时间 (毫秒) sleep(startTime + targetTime - endTime); // 计算并休眠剩余时间
- 这尝试控制整体循环时间接近目标帧率。但核心逻辑更新仍然是基于
delta
的。 - 注意休眠限制: 实际休眠时间可能不精确,且不能为负数(如果一帧耗时已超过
targetTime
,则不休眠)。 - 垂直同步 (VSync): 教程提到启用 GLFW 的
glfwSwapInterval(1)
进行垂直同步是更常见、更有效(且通常由 GPU 硬件更好处理)的帧率限制方法,它可以替代或减少显式的sleep
。
- 这尝试控制整体循环时间接近目标帧率。但核心逻辑更新仍然是基于
-
固定时间步长循环 (Fixed Timestep - 稳定逻辑更新)
public void gameLoop() {float delta;float accumulator = 0f; // 累积未处理的游戏时间float interval = 1f / targetUPS; // 目标更新间隔 (秒)。例如 targetUPS=60 -> interval≈0.0167sfloat alpha; // 插值因子while (running) {delta = timer.getDelta(); // 获取上一帧到当前帧经过的时间 (秒)accumulator += delta; // 将新经过的时间累积起来input();// 用累积的时间进行尽可能多的逻辑更新 (每次消耗一个interval)while (accumulator >= interval) {update(interval); // 以固定间隔interval进行更新accumulator -= interval;}alpha = accumulator / interval; // 计算插值因子 (0.0 - 1.0)render(alpha); // 渲染,使用alpha进行插值window.update();} }
- 核心思想: 将游戏逻辑更新 (
update
) 的频率 (targetUPS
- Updates Per Second) 与渲染帧率 (targetFPS
) 完全解耦。 - 关键组件:
interval
: 期望的逻辑更新间隔时间(秒)。例如,targetUPS = 60
意味着interval = 1/60 ≈ 0.0167
秒(每 16.67ms 更新一次逻辑)。accumulator
(累加器): 累积自上次逻辑更新以来所经过的、尚未被处理的时间(秒)。delta
: 与可变步长相同,记录真实的时间流逝。alpha
(插值因子): 表示当前渲染时刻距离上一次逻辑更新 (update
) 和下一次逻辑更新之间的比例 (0.0 表示完全在上次更新状态,1.0 表示完全在下次更新状态)。
- 工作原理:
- 累积时间: 将每一帧经过的真实时间
delta
加到accumulator
上。 - 批量更新逻辑: 只要
accumulator
中累积的时间大于等于一个interval
,就执行一次update(interval)
(使用固定的interval
作为更新步长),并从累加器中减去interval
。这个过程重复直到累加器中的时间不足一个interval
。这确保了逻辑更新以固定的频率 (targetUPS
) 发生,不受渲染帧率波动影响。 - 计算插值因子:
alpha = accumulator / interval
。这个值在0.0
(刚更新完) 到< 1.0
(即将更新) 之间。它表示“距离下一次逻辑更新还有多远”。 - 插值渲染 (
render(alpha)
): 在渲染时,利用alpha
对游戏状态(主要是位置、旋转等视觉相关的状态)在 上一次完整逻辑更新后的状态 (State n) 和 下一次即将发生的逻辑更新应该达到的状态 (State n+1) 之间进行插值。- 例如:
renderPosition = positionOld * (1 - alpha) + positionNew * alpha;
- 目的: 即使渲染帧率 (
FPS
) 高于逻辑更新率 (UPS
),也能提供更平滑的视觉表现,避免在两次逻辑更新之间画面静止不动导致的“卡顿”感。如果逻辑更新率 (UPS
) 很高(如 >= 渲染帧率),插值的效果可能不明显。
- 例如:
- 累积时间: 将每一帧经过的真实时间
- 优点:
- 逻辑更新稳定且确定: 物理模拟和其他对时间敏感的精确逻辑在固定间隔下运行,结果更稳定、更可预测、更易重现。
- 帧率独立性: 逻辑更新速度 (
UPS
) 恒定。 - 视觉平滑性: 通过状态插值 (
alpha
),可以在高渲染帧率下获得比固定逻辑更新频率更平滑的视觉运动效果。
- 缺点/复杂性:
- 实现更复杂: 需要维护累加器、插值因子,逻辑更新和状态管理需要支持插值。
- 额外状态存储: 渲染时需要知道“上一逻辑状态”和“当前逻辑状态”来进行插值。
- 潜在的“螺旋死亡”: 如果单帧
delta
时间过大(例如游戏卡顿或调试暂停),累加器会积累大量时间,导致需要连续执行非常多次update(interval)
来“追赶”。如果每次更新都消耗大量 CPU,可能会加剧卡顿。需要防范措施(如限制每帧最大更新次数)。
update()
的参数: 教程提到update(interval)
中的参数可以是固定的interval
(强调固定步长),也可以传递interval
作为delta
(update(delta)
),但此时delta
是恒定的interval
。本质是逻辑更新使用固定的时间步长。
- 核心思想: 将游戏逻辑更新 (
总结与选择
- 简单循环: 仅用于快速原型或测试,避免在实际项目中使用。
- 固定休眠: 简单但效果有限,不推荐。
- 可变时间步长 (Variable Timestep):
- 优点: 实现简单,逻辑更新与帧率基本解耦。
- 缺点: 非确定性,物理模拟可能不稳定。
- 适用: 对确定性要求不高、没有复杂物理模拟的游戏(如回合制策略、简单的 2D 平台跳跃)。
- 固定时间步长 (Fixed Timestep):
- 优点: 逻辑更新稳定、确定,物理模拟友好,通过插值可实现平滑渲染。
- 缺点: 实现复杂,需要额外状态管理。
- 适用: 需要精确物理模拟、网络同步、或对逻辑确定性要求高的游戏(如动作游戏、赛车游戏、RTS)。是现代游戏引擎(如 Unity, Unreal)的常见选择。
与 LWJGL 的关系
虽然教程基于 LWJGL,但游戏循环的设计模式是通用的游戏编程概念,适用于任何游戏开发库或引擎(如 SDL, SFML, Unity, Unreal)。LWJGL 本身主要提供:
- 计时器 (
Timer
): 教程中timer.getDelta()
和timer.getTime()
的实现需要借助 LWJGL 或 Java 的系统计时 API(如System.nanoTime()
)来获取高精度的时间差。 - 窗口管理 (
window.update()
):window.update()
通常封装了 LWJGL GLFW 的glfwPollEvents()
(处理输入和窗口事件)和glfwSwapBuffers()
(交换前后缓冲区,显示渲染结果)。 - 垂直同步 (VSync): 通过
glfwSwapInterval(1)
开启,是帧率限制的有效手段。
实例代码和解析
Game代码
下面我们将给出案例代码和详细的解释,大家记得看完哦
/** The MIT License (MIT)** Copyright © 2014, Heiko Brumme** Permission is hereby granted, free of charge, to any person obtaining a copy* of this software and associated documentation files (the "Software"), to deal* in the Software without restriction, including without limitation the rights* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell* copies of the Software, and to permit persons to whom the Software is* furnished to do so, subject to the following conditions:** The above copyright notice and this permission notice shall be included in all* copies or substantial portions of the Software.** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE* SOFTWARE.*/
package silvertiger.tutorial.lwjgl.core;import java.util.logging.Level;
import java.util.logging.Logger;
import org.lwjgl.glfw.Callbacks;
import org.lwjgl.glfw.GLFWErrorCallback;
import silvertiger.tutorial.lwjgl.state.StateMachine;
import silvertiger.tutorial.lwjgl.state.EmptyState;
import silvertiger.tutorial.lwjgl.state.ExampleState;
import silvertiger.tutorial.lwjgl.state.LegacyExampleState;
import silvertiger.tutorial.lwjgl.graphic.Renderer;
import silvertiger.tutorial.lwjgl.graphic.Window;import static org.lwjgl.glfw.GLFW.glfwInit;
import static org.lwjgl.glfw.GLFW.glfwSetErrorCallback;
import static org.lwjgl.glfw.GLFW.glfwTerminate;
import static org.lwjgl.opengl.GL11.GL_TRUE;
import static org.lwjgl.opengl.GL11.glGetInteger;
import static org.lwjgl.opengl.GL30.GL_MAJOR_VERSION;
import static org.lwjgl.opengl.GL30.GL_MINOR_VERSION;/*** The game class just initializes the game and starts the game loop. After* ending the loop it will get disposed.** @author Heiko Brumme*/
public abstract class Game {public static final int TARGET_FPS = 75;public static final int TARGET_UPS = 30;/*** The error callback for GLFW.*/private GLFWErrorCallback errorCallback;/*** Shows if the game is running.*/protected boolean running;/*** The GLFW window used by the game.*/protected Window window;/*** Used for timing calculations.*/protected Timer timer;/*** Used for rendering.*/protected Renderer renderer;/*** Stores the current state.*/protected StateMachine state;/*** Default contructor for the game.*/public Game() {timer = new Timer();renderer = new Renderer();state = new StateMachine();}/*** This should be called to initialize and start the game.*/public void start() {init();gameLoop();dispose();}/*** Releases resources that where used by the game.*/public void dispose() {/* Dipose renderer */renderer.dispose();/* Set empty state to trigger the exit method in the current state */state.change(null);/* Release window and its callbacks */window.destroy();/* Terminate GLFW and release the error callback */glfwTerminate();errorCallback.release();}/*** Initializes the game.*/public void init() {/* Set error callback */errorCallback = Callbacks.errorCallbackPrint();glfwSetErrorCallback(errorCallback);/* Initialize GLFW */if (glfwInit() != GL_TRUE) {throw new IllegalStateException("Unable to initialize GLFW!");}/* Create GLFW window */window = new Window(640, 480, "Game", true);/* Initialize timer */timer.init();/* Initialize renderer */renderer.init();/* Initialize states */initStates();/* Initializing done, set running to true */running = true;}/*** Initializes the states.*/public void initStates() {state.add(null, new EmptyState());if (isDefaultContext()) {state.add("example", new ExampleState());} else {state.add("example", new LegacyExampleState());}state.change("example");}/*** The game loop. <br/>* For implementation take a look at <code>VariableDeltaGame</code> and* <code>FixedTimestepGame</code>.*/public abstract void gameLoop();/*** Handles input.*/public void input() {state.input();}/*** Updates the game (fixed timestep).*/public void update() {state.update();}/*** Updates the game (variable timestep).** @param delta Time difference in seconds*/public void update(float delta) {state.update(delta);}/*** Renders the game (no interpolation).*/public void render() {state.render();}/*** Renders the game (with interpolation).** @param alpha Alpha value, needed for interpolation*/public void render(float alpha) {state.render(alpha);}/*** Synchronizes the game at specified frames per second.** @param fps Frames per second*/public void sync(int fps) {double lastLoopTime = timer.getLastLoopTime();double now = timer.getTime();float targetTime = 1f / fps;while (now - lastLoopTime < targetTime) {Thread.yield();/* This is optional if you want your game to stop consuming too muchCPU but you will loose some accuracy because Thread.sleep(1) couldsleep longer than 1 millisecond */try {Thread.sleep(1);} catch (InterruptedException ex) {Logger.getLogger(Game.class.getName()).log(Level.SEVERE, null, ex);}now = timer.getTime();}}/*** Determines if the OpenGL context supports version 3.2.** @return true, if OpenGL context supports version 3.2, else false*/public boolean isDefaultContext() {int major = glGetInteger(GL_MAJOR_VERSION);int minor = glGetInteger(GL_MINOR_VERSION);return major == 3 && minor == 2;}
}
代码详细解析
类定义和常量
public abstract class Game {public static final int TARGET_FPS = 75; // 目标渲染帧率(帧/秒)public static final int TARGET_UPS = 30; // 目标逻辑更新率(更新/秒)
- TARGET_FPS:目标帧率,控制每秒渲染次数(75帧/秒)
- TARGET_UPS:目标更新率,控制每秒逻辑更新次数(30次/秒)
核心字段
private GLFWErrorCallback errorCallback; // GLFW错误回调处理器
protected boolean running; // 游戏运行状态标志
protected Window window; // GLFW窗口对象
protected Timer timer; // 时间计算工具
protected Renderer renderer; // 渲染器
protected StateMachine state; // 游戏状态机
关键方法解析
- 初始化方法
init()
public void init() {errorCallback = GLFWErrorCallback.createPrint(); // 创建GLFW错误打印器glfwSetErrorCallback(errorCallback); // 设置错误回调if (!glfwInit()) { // 初始化GLFWthrow new IllegalStateException("Unable to initialize GLFW!");}window = new Window(640, 480, "Simple Game", true); // 创建游戏窗口timer.init(); // 初始化计时器renderer.init(); // 初始化渲染器initStates(); // 初始化游戏状态running = true; // 设置运行标志
}
- 状态初始化
initStates()
public void initStates() {if (Game.isDefaultContext()) { // 检测OpenGL版本state.add("example", new ExampleState()); // 添加现代版状态state.add("texture", new TextureState());} else {state.add("example", new LegacyExampleState()); // 添加兼容版状态state.add("texture", new LegacyTextureState());}state.add("game", new GameState(renderer)); // 添加游戏主状态state.change("game"); // 切换到游戏状态
}
- 输入/更新/渲染代理方法
public void input() {state.input(); // 委托给当前状态处理输入
}public void update() {state.update(); // 固定步长更新(无时间参数)
}public void update(float delta) {state.update(delta); // 可变步长更新(带时间参数)
}public void render() {state.render(); // 普通渲染(无插值)
}public void render(float alpha) {state.render(alpha); // 插值渲染
}
- 帧率同步
sync(int fps)
public void sync(int fps) {double lastLoopTime = timer.getLastLoopTime(); // 上次循环时间double now = timer.getTime(); // 当前时间float targetTime = 1f / fps; // 目标帧时间while (now - lastLoopTime < targetTime) { // 循环等待直到达到目标时间Thread.yield(); // 让出CPU资源try {Thread.sleep(1); // 精确睡眠1ms} catch (InterruptedException ex) { ... }now = timer.getTime(); // 更新当前时间}
}
- OpenGL版本检测
isDefaultContext()
public static boolean isDefaultContext() {return GL.getCapabilities().OpenGL32; // 检测是否支持OpenGL 3.2
}
核心概念解析
1. Delta (δ)
- 定义:
delta
表示 两帧之间的时间差(单位:秒) - 计算方式:
delta = currentTime - lastFrameTime
- 作用:
- 实现 帧率无关的更新逻辑
position += velocity * delta; // 物体移动距离 = 速度 × 时间
- 确保在不同性能设备上游戏逻辑速度一致
- 用于可变时间步长更新方法:
update(float delta)
- 实现 帧率无关的更新逻辑
2. Alpha (α)
- 定义:插值因子,范围
[0, 1)
- 计算方式:
alpha = 剩余累积时间 / 更新间隔
(例:累积时间14.67ms / 间隔33.33ms ≈ 0.44) - 作用:
- 实现 状态平滑插值渲染
renderPosition = prevPos * (1 - alpha) + nextPos * alpha;
- 在固定更新率(UPS)下实现更高渲染帧率(FPS)
- 用于带插值的渲染方法:
render(float alpha)
- 实现 状态平滑插值渲染
3. FPS (Frames Per Second)
- 定义:每秒渲染的帧数
- 目标值:
TARGET_FPS = 75
- 作用:
- 控制 画面流畅度(人眼舒适帧率 60-120 FPS)
- 通过
sync(TARGET_FPS)
实现帧率同步 - 计算公式:
帧时间 = 1秒 / FPS
(75 FPS → 每帧13.3ms)
三者协作关系
- 逻辑更新:基于固定UPS(30次/秒)或可变δ时间
- 画面渲染:基于目标FPS(75帧/秒)+ α插值
- 效果:
在30次逻辑更新/秒基础上,实现75帧/秒的平滑画面
关键优势:解耦逻辑更新和画面渲染,确保物理模拟稳定性(固定UPS)同时提供流畅视觉效果(高FPS+插值)