pin_drop当前位置:知识文库 ❯ 图文

游戏开发:游戏循环与帧率控制 - 从入门到实践详解

教程简介

游戏循环是任何游戏的心脏,它驱动着游戏的更新和渲染。一个设计良好的游戏循环能够确保游戏在不同硬件上以一致的速度运行,提供流畅的用户体验。本教程将深入讲解游戏循环的原理、requestAnimationFrame的使用、固定与可变时间步长的实现、deltaTime计算、帧率限制、性能监控,以及游戏暂停与恢复的机制。

核心概念

游戏循环原理

游戏循环是一个持续运行的循环,每一帧执行以下三个步骤:

  1. 处理输入:读取键盘、鼠标、触摸等输入状态

  2. 更新状态:根据输入和时间间隔更新游戏逻辑

  3. 渲染画面:将当前游戏状态绘制到屏幕上

代码示例


          游戏循环                 
                                  
                      
    处理输入  
                     
                                
                     
    更新状态                    
                     
                                
                     
    渲染画面  
                      
                                  

requestAnimationFrame

requestAnimationFrame(简称rAF)是浏览器提供的专门用于动画的API,它会在浏览器下一次重绘之前调用指定的回调函数。

优势:

  • 与浏览器刷新率同步(通常60Hz)

  • 当页面不可见时自动暂停,节省CPU/GPU资源

  • 比setInterval/setTimeout更精确、更节能

  • 浏览器可以优化多个动画的合并渲染

代码示例

function gameLoop(timestamp) {
    // 更新和渲染
    update();
    render();

    // 请求下一帧
    requestAnimationFrame(gameLoop);
}

// 启动游戏循环
requestAnimationFrame(gameLoop);

固定时间步长 vs 可变时间步长

固定时间步长(Fixed Timestep):

  • 每次更新使用固定的时间增量(如1/60秒)

  • 逻辑更新与渲染帧率解耦

  • 确保在不同硬件上游戏行为一致

  • 适合物理模拟等需要确定性的场景

可变时间步长(Variable Timestep):

  • 每次更新使用实际的帧间隔时间

  • 实现简单,渲染帧率与逻辑帧率一致

  • 在帧率波动时可能导致物理不稳定

  • 适合简单游戏

deltaTime计算

deltaTime是上一帧到当前帧经过的时间,用于确保游戏逻辑与帧率无关:

代码示例

let lastTime = 0;

function gameLoop(currentTime) {
    const deltaTime = (currentTime - lastTime) / 1000; // 转换为秒
    lastTime = currentTime;

    // 限制deltaTime最大值,防止标签页切换后出现巨大跳跃
    const clampedDelta = Math.min(deltaTime, 0.1);

    update(clampedDelta);
    render();

    requestAnimationFrame(gameLoop);
}

帧率限制

某些情况下需要限制帧率(如省电模式、低端设备):

代码示例

const TARGET_FPS = 30;
const FRAME_DURATION = 1000 / TARGET_FPS;
let lastFrameTime = 0;

function gameLoop(currentTime) {
    const elapsed = currentTime - lastFrameTime;

    if (elapsed >= FRAME_DURATION) {
        const deltaTime = elapsed / 1000;
        lastFrameTime = currentTime - (elapsed % FRAME_DURATION);

        update(deltaTime);
        render();
    }

    requestAnimationFrame(gameLoop);
}

性能监控

实时监控帧率和帧时间是优化游戏性能的基础:

代码示例

class PerformanceMonitor {
    constructor() {
        this.fps = 0;
        this.frameTime = 0;
        this.frameCount = 0;
        this.fpsUpdateTimer = 0;
        this.maxFrameTime = 0;
        this.minFrameTime = Infinity;
    }

    update(deltaTime) {
        this.frameCount++;
        this.fpsUpdateTimer += deltaTime;
        this.frameTime = deltaTime * 1000; // 转换为毫秒

        if (this.frameTime > this.maxFrameTime) {
            this.maxFrameTime = this.frameTime;
        }
        if (this.frameTime < this.minFrameTime) {
            this.minFrameTime = this.frameTime;
        }

        if (this.fpsUpdateTimer >= 1.0) {
            this.fps = this.frameCount;
            this.frameCount = 0;
            this.fpsUpdateTimer = 0;
        }
    }
}

游戏暂停与恢复

游戏暂停时需要正确处理时间累积,避免恢复后出现时间跳跃:

代码示例

class Game {
    constructor() {
        this.isPaused = false;
        this.lastTime = 0;
        this.accumulator = 0;
    }

    loop(currentTime) {
        if (this.isPaused) {
            this.lastTime = currentTime;
            requestAnimationFrame((t) => this.loop(t));
            return;
        }

        const deltaTime = (currentTime - this.lastTime) / 1000;
        this.lastTime = currentTime;

        this.update(Math.min(deltaTime, 0.1));
        this.render();

        requestAnimationFrame((t) => this.loop(t));
    }

    pause() {
        this.isPaused = true;
    }

    resume() {
        this.isPaused = false;
        // lastTime将在下一帧loop中更新,避免时间跳跃
    }
}

语法与用法

完整的可变时间步长游戏循环

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>可变时间步长游戏循环</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { background: #0a0a1a; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Segoe UI', sans-serif; color: #fff; }
        canvas { border: 2px solid #2a2a4a; border-radius: 8px; }
        .controls { margin-top: 16px; display: flex; gap: 12px; }
        button { padding: 8px 20px; border: none; border-radius: 6px; background: #4a90d9; color: #fff; cursor: pointer; font-size: 14px; transition: background 0.2s; }
        button:hover { background: #357abd; }
        button:disabled { background: #555; cursor: not-allowed; }
        #pauseBtn { background: #e94560; }
        #pauseBtn:hover { background: #c73652; }
    </style>
</head>
<body>
    <canvas id="gameCanvas" width="800" height="500"></canvas>
    <div class="controls">
        <button id="pauseBtn">暂停</button>
        <button id="addBtn">添加球体</button>
        <button id="clearBtn">清除</button>
    </div>
    <script>
        class Ball {
            constructor(x, y, radius, color) {
                this.x = x;
                this.y = y;
                this.radius = radius;
                this.color = color;
                this.vx = (Math.random() - 0.5) * 300;
                this.vy = (Math.random() - 0.5) * 300;
                this.trail = [];
                this.maxTrail = 15;
            }

            update(dt, width, height) {
                // 记录轨迹
                this.trail.push({ x: this.x, y: this.y });
                if (this.trail.length > this.maxTrail) {
                    this.trail.shift();
                }

                // 更新位置
                this.x += this.vx * dt;
                this.y += this.vy * dt;

                // 边界碰撞
                if (this.x - this.radius < 0) {
                    this.x = this.radius;
                    this.vx = Math.abs(this.vx);
                }
                if (this.x + this.radius > width) {
                    this.x = width - this.radius;
                    this.vx = -Math.abs(this.vx);
                }
                if (this.y - this.radius < 0) {
                    this.y = this.radius;
                    this.vy = Math.abs(this.vy);
                }
                if (this.y + this.radius > height) {
                    this.y = height - this.radius;
                    this.vy = -Math.abs(this.vy);
                }
            }

            render(ctx) {
                // 绘制轨迹
                for (let i = 0; i < this.trail.length; i++) {
                    const alpha = (i / this.trail.length) * 0.3;
                    const size = (i / this.trail.length) * this.radius;
                    ctx.beginPath();
                    ctx.arc(this.trail[i].x, this.trail[i].y, size, 0, Math.PI * 2);
                    ctx.fillStyle = this.color.replace('1)', alpha + ')');
                    ctx.fill();
                }

                // 绘制球体
                ctx.beginPath();
                ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
                ctx.fillStyle = this.color;
                ctx.fill();

                // 高光
                ctx.beginPath();
                ctx.arc(this.x - this.radius * 0.3, this.y - this.radius * 0.3, this.radius * 0.3, 0, Math.PI * 2);
                ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
                ctx.fill();
            }
        }

        class VariableTimeStepGame {
            constructor(canvasId) {
                this.canvas = document.getElementById(canvasId);
                this.ctx = this.canvas.getContext('2d');
                this.width = this.canvas.width;
                this.height = this.canvas.height;
                this.balls = [];
                this.isPaused = false;
                this.lastTime = 0;
                this.fps = 0;
                this.frameCount = 0;
                this.fpsTimer = 0;

                this.setupControls();
                this.addInitialBalls();
            }

            addInitialBalls() {
                const colors = [
                    'rgba(233, 69, 96, 1)',
                    'rgba(74, 144, 217, 1)',
                    'rgba(22, 199, 154, 1)',
                    'rgba(240, 165, 0, 1)',
                    'rgba(83, 52, 131, 1)'
                ];
                for (let i = 0; i < 5; i++) {
                    const radius = 15 + Math.random() * 20;
                    this.balls.push(new Ball(
                        radius + Math.random() * (this.width - radius * 2),
                        radius + Math.random() * (this.height - radius * 2),
                        radius,
                        colors[i % colors.length]
                    ));
                }
            }

            setupControls() {
                document.getElementById('pauseBtn').addEventListener('click', () => {
                    this.isPaused = !this.isPaused;
                    document.getElementById('pauseBtn').textContent = this.isPaused ? '恢复' : '暂停';
                });

                document.getElementById('addBtn').addEventListener('click', () => {
                    const colors = [
                        'rgba(233, 69, 96, 1)',
                        'rgba(74, 144, 217, 1)',
                        'rgba(22, 199, 154, 1)',
                        'rgba(240, 165, 0, 1)',
                        'rgba(83, 52, 131, 1)'
                    ];
                    const radius = 15 + Math.random() * 20;
                    this.balls.push(new Ball(
                        this.width / 2,
                        this.height / 2,
                        radius,
                        colors[Math.floor(Math.random() * colors.length)]
                    ));
                });

                document.getElementById('clearBtn').addEventListener('click', () => {
                    this.balls = [];
                });
            }

            start() {
                this.lastTime = performance.now();
                requestAnimationFrame((t) => this.loop(t));
            }

            loop(currentTime) {
                const deltaTime = (currentTime - this.lastTime) / 1000;
                this.lastTime = currentTime;

                // FPS计算
                this.frameCount++;
                this.fpsTimer += deltaTime;
                if (this.fpsTimer >= 1) {
                    this.fps = this.frameCount;
                    this.frameCount = 0;
                    this.fpsTimer = 0;
                }

                if (!this.isPaused) {
                    const clampedDelta = Math.min(deltaTime, 0.1);
                    this.update(clampedDelta);
                }
                this.render();

                requestAnimationFrame((t) => this.loop(t));
            }

            update(dt) {
                for (const ball of this.balls) {
                    ball.update(dt, this.width, this.height);
                }
            }

            render() {
                const ctx = this.ctx;

                // 背景
                ctx.fillStyle = '#0a0a1a';
                ctx.fillRect(0, 0, this.width, this.height);

                // 网格
                ctx.strokeStyle = '#1a1a3a';
                ctx.lineWidth = 0.5;
                for (let x = 0; x < this.width; x += 50) {
                    ctx.beginPath();
                    ctx.moveTo(x, 0);
                    ctx.lineTo(x, this.height);
                    ctx.stroke();
                }
                for (let y = 0; y < this.height; y += 50) {
                    ctx.beginPath();
                    ctx.moveTo(0, y);
                    ctx.lineTo(this.width, y);
                    ctx.stroke();
                }

                // 球体
                for (const ball of this.balls) {
                    ball.render(ctx);
                }

                // HUD
                ctx.fillStyle = '#fff';
                ctx.font = '14px monospace';
                ctx.textAlign = 'left';
                ctx.fillText(`FPS: ${this.fps}`, 10, 20);
                ctx.fillText(`球体: ${this.balls.length}`, 10, 40);
                ctx.fillText(`循环类型: 可变时间步长`, 10, 60);

                if (this.isPaused) {
                    ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
                    ctx.fillRect(0, 0, this.width, this.height);
                    ctx.fillStyle = '#fff';
                    ctx.font = '36px Arial';
                    ctx.textAlign = 'center';
                    ctx.fillText('已暂停', this.width / 2, this.height / 2);
                }
            }
        }

        const game = new VariableTimeStepGame('gameCanvas');
        game.start();
    </script>
</body>
</html>

固定时间步长游戏循环

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>固定时间步长游戏循环</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { background: #0a0a1a; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Segoe UI', sans-serif; color: #fff; }
        canvas { border: 2px solid #2a2a4a; border-radius: 8px; }
        .info { margin-top: 16px; padding: 12px 24px; background: #1a1a3a; border-radius: 8px; font-size: 13px; line-height: 1.8; }
        .info span { color: #4a90d9; font-weight: bold; }
    </style>
</head>
<body>
    <canvas id="gameCanvas" width="800" height="500"></canvas>
    <div class="info">
        <span>固定时间步长</span>:逻辑更新使用固定间隔(1/60秒),渲染在逻辑更新之间插值<br>
        确保在不同帧率下游戏行为一致,物理模拟更稳定
    </div>
    <script>
        class FixedTimestepGame {
            constructor(canvasId) {
                this.canvas = document.getElementById(canvasId);
                this.ctx = this.canvas.getContext('2d');
                this.width = this.canvas.width;
                this.height = this.canvas.height;

                // 固定时间步长配置
                this.FIXED_DT = 1 / 60;       // 逻辑更新间隔
                this.MAX_ACCUMULATOR = 0.25;   // 最大累积时间,防止死循环

                this.accumulator = 0;
                this.lastTime = 0;
                this.fps = 0;
                this.frameCount = 0;
                this.fpsTimer = 0;
                this.isPaused = false;

                // 游戏对象
                this.player = {
                    x: 100,
                    y: 250,
                    width: 40,
                    height: 40,
                    vx: 150,
                    vy: 0,
                    prevX: 100,
                    prevY: 250,
                    gravity: 500,
                    jumpForce: -300,
                    isOnGround: false,
                    color: '#4a90d9'
                };

                this.platforms = [
                    { x: 0, y: 450, width: 800, height: 50 },
                    { x: 200, y: 350, width: 150, height: 20 },
                    { x: 450, y: 280, width: 150, height: 20 },
                    { x: 100, y: 200, width: 150, height: 20 },
                    { x: 550, y: 150, width: 150, height: 20 }
                ];

                this.keys = {};
                this.setupInput();
            }

            setupInput() {
                document.addEventListener('keydown', (e) => {
                    this.keys[e.code] = true;
                    if (e.code === 'Space') {
                        e.preventDefault();
                        this.isPaused = !this.isPaused;
                    }
                });
                document.addEventListener('keyup', (e) => {
                    this.keys[e.code] = false;
                });
            }

            start() {
                this.lastTime = performance.now();
                requestAnimationFrame((t) => this.loop(t));
            }

            loop(currentTime) {
                const frameTime = (currentTime - this.lastTime) / 1000;
                this.lastTime = currentTime;

                // FPS计算
                this.frameCount++;
                this.fpsTimer += frameTime;
                if (this.fpsTimer >= 1) {
                    this.fps = this.frameCount;
                    this.frameCount = 0;
                    this.fpsTimer = 0;
                }

                if (!this.isPaused) {
                    // 限制帧时间,防止累积器过大
                    const clampedFrameTime = Math.min(frameTime, this.MAX_ACCUMULATOR);
                    this.accumulator += clampedFrameTime;

                    // 固定时间步长更新
                    while (this.accumulator >= this.FIXED_DT) {
                        this.fixedUpdate(this.FIXED_DT);
                        this.accumulator -= this.FIXED_DT;
                    }
                }

                // 计算插值因子(用于渲染平滑)
                const alpha = this.accumulator / this.FIXED_DT;
                this.render(alpha);

                requestAnimationFrame((t) => this.loop(t));
            }

            fixedUpdate(dt) {
                const player = this.player;

                // 保存上一帧位置(用于插值渲染)
                player.prevX = player.x;
                player.prevY = player.y;

                // 水平移动
                if (this.keys['ArrowLeft'] || this.keys['KeyA']) {
                    player.vx = -200;
                } else if (this.keys['ArrowRight'] || this.keys['KeyD']) {
                    player.vx = 200;
                } else {
                    player.vx = 0;
                }

                // 跳跃
                if ((this.keys['ArrowUp'] || this.keys['KeyW'] || this.keys['Space']) && player.isOnGround) {
                    player.vy = player.jumpForce;
                    player.isOnGround = false;
                }

                // 重力
                player.vy += player.gravity * dt;

                // 更新位置
                player.x += player.vx * dt;
                player.y += player.vy * dt;

                // 平台碰撞
                player.isOnGround = false;
                for (const platform of this.platforms) {
                    if (this.checkCollision(player, platform)) {
                        // 从上方落下
                        if (player.vy > 0 && player.prevY + player.height <= platform.y + 5) {
                            player.y = platform.y - player.height;
                            player.vy = 0;
                            player.isOnGround = true;
                        }
                    }
                }

                // 边界
                if (player.x < 0) player.x = 0;
                if (player.x + player.width > this.width) player.x = this.width - player.width;
                if (player.y > this.height) {
                    player.y = 250;
                    player.vy = 0;
                    player.x = 100;
                }
            }

            checkCollision(rect, platform) {
                return rect.x < platform.x + platform.width &&
                       rect.x + rect.width > platform.x &&
                       rect.y < platform.y + platform.height &&
                       rect.y + rect.height > platform.y;
            }

            render(alpha) {
                const ctx = this.ctx;
                const player = this.player;

                // 插值渲染位置
                const renderX = player.prevX + (player.x - player.prevX) * alpha;
                const renderY = player.prevY + (player.y - player.prevY) * alpha;

                // 背景
                ctx.fillStyle = '#0a0a1a';
                ctx.fillRect(0, 0, this.width, this.height);

                // 平台
                for (const platform of this.platforms) {
                    const gradient = ctx.createLinearGradient(
                        platform.x, platform.y,
                        platform.x, platform.y + platform.height
                    );
                    gradient.addColorStop(0, '#2a4a6a');
                    gradient.addColorStop(1, '#1a3a5a');
                    ctx.fillStyle = gradient;
                    ctx.fillRect(platform.x, platform.y, platform.width, platform.height);

                    // 平台顶部高亮
                    ctx.fillStyle = '#4a90d9';
                    ctx.fillRect(platform.x, platform.y, platform.width, 3);
                }

                // 玩家(使用插值位置渲染)
                ctx.fillStyle = player.color;
                ctx.fillRect(renderX, renderY, player.width, player.height);

                // 玩家眼睛
                ctx.fillStyle = '#fff';
                ctx.fillRect(renderX + 10, renderY + 10, 8, 8);
                ctx.fillRect(renderX + 24, renderY + 10, 8, 8);
                ctx.fillStyle = '#0a0a1a';
                ctx.fillRect(renderX + 13, renderY + 13, 4, 4);
                ctx.fillRect(renderX + 27, renderY + 13, 4, 4);

                // HUD
                ctx.fillStyle = '#fff';
                ctx.font = '14px monospace';
                ctx.textAlign = 'left';
                ctx.fillText(`FPS: ${this.fps}`, 10, 20);
                ctx.fillText(`逻辑帧率: 60 Hz (固定)`, 10, 40);
                ctx.fillText(`插值因子: ${alpha.toFixed(3)}`, 10, 60);
                ctx.fillText(`累积器: ${this.accumulator.toFixed(4)}s`, 10, 80);

                // 操作提示
                ctx.textAlign = 'right';
                ctx.fillStyle = '#888';
                ctx.fillText('方向键/WASD移动 | 空格跳跃 | P暂停', this.width - 10, 20);

                if (this.isPaused) {
                    ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
                    ctx.fillRect(0, 0, this.width, this.height);
                    ctx.fillStyle = '#fff';
                    ctx.font = '36px Arial';
                    ctx.textAlign = 'center';
                    ctx.fillText('已暂停', this.width / 2, this.height / 2);
                }
            }
        }

        const game = new FixedTimestepGame('gameCanvas');
        game.start();
    </script>
</body>
</html>

帧率限制与性能监控

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>帧率限制与性能监控</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { background: #0a0a1a; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Segoe UI', sans-serif; color: #fff; }
        canvas { border: 2px solid #2a2a4a; border-radius: 8px; }
        .controls { margin-top: 16px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
        .btn-group { display: flex; gap: 4px; }
        .btn-group button { padding: 6px 14px; border: 1px solid #4a90d9; background: transparent; color: #4a90d9; cursor: pointer; font-size: 13px; transition: all 0.2s; }
        .btn-group button:first-child { border-radius: 6px 0 0 6px; }
        .btn-group button:last-child { border-radius: 0 6px 6px 0; }
        .btn-group button.active { background: #4a90d9; color: #fff; }
        .btn-group button:hover:not(.active) { background: #4a90d922; }
        label { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #aaa; }
        input[type="range"] { width: 100px; }
    </style>
</head>
<body>
    <canvas id="gameCanvas" width="800" height="500"></canvas>
    <div class="controls">
        <div class="btn-group">
            <button data-fps="15">15 FPS</button>
            <button data-fps="30">30 FPS</button>
            <button data-fps="60" class="active">60 FPS</button>
            <button data-fps="0">不限</button>
        </div>
        <label>
            粒子数量: <span id="particleCount">200</span>
            <input type="range" id="particleSlider" min="50" max="1000" value="200" step="50">
        </label>
    </div>
    <script>
        class Particle {
            constructor(x, y) {
                this.x = x;
                this.y = y;
                this.vx = (Math.random() - 0.5) * 200;
                this.vy = (Math.random() - 0.5) * 200;
                this.life = 1.0;
                this.decay = 0.3 + Math.random() * 0.5;
                this.size = 2 + Math.random() * 4;
                this.hue = Math.random() * 60 + 200; // 蓝紫色系
            }

            update(dt) {
                this.x += this.vx * dt;
                this.y += this.vy * dt;
                this.life -= this.decay * dt;
                this.vy += 50 * dt; // 轻微重力
            }

            isDead() {
                return this.life <= 0;
            }
        }

        class PerformanceDemo {
            constructor(canvasId) {
                this.canvas = document.getElementById(canvasId);
                this.ctx = this.canvas.getContext('2d');
                this.width = this.canvas.width;
                this.height = this.canvas.height;

                // 帧率控制
                this.targetFPS = 60;
                this.frameDuration = 1000 / 60;
                this.lastFrameTime = 0;

                // 性能监控
                this.fps = 0;
                this.frameCount = 0;
                this.fpsTimer = 0;
                this.frameTimeHistory = [];
                this.maxHistoryLength = 120;

                // 粒子系统
                this.particles = [];
                this.targetParticleCount = 200;

                this.lastTime = 0;
                this.setupControls();
            }

            setupControls() {
                // FPS按钮
                document.querySelectorAll('[data-fps]').forEach(btn => {
                    btn.addEventListener('click', () => {
                        document.querySelectorAll('[data-fps]').forEach(b => b.classList.remove('active'));
                        btn.classList.add('active');
                        this.targetFPS = parseInt(btn.dataset.fps);
                        this.frameDuration = this.targetFPS > 0 ? 1000 / this.targetFPS : 0;
                    });
                });

                // 粒子滑块
                const slider = document.getElementById('particleSlider');
                slider.addEventListener('input', () => {
                    this.targetParticleCount = parseInt(slider.value);
                    document.getElementById('particleCount').textContent = this.targetParticleCount;
                });
            }

            spawnParticles() {
                while (this.particles.length < this.targetParticleCount) {
                    this.particles.push(new Particle(
                        this.width / 2 + (Math.random() - 0.5) * 100,
                        this.height / 2 + (Math.random() - 0.5) * 100
                    ));
                }
            }

            start() {
                this.lastTime = performance.now();
                this.lastFrameTime = this.lastTime;
                requestAnimationFrame((t) => this.loop(t));
            }

            loop(currentTime) {
                const frameTime = (currentTime - this.lastTime) / 1000;
                this.lastTime = currentTime;

                // FPS计算
                this.frameCount++;
                this.fpsTimer += frameTime;
                if (this.fpsTimer >= 1) {
                    this.fps = this.frameCount;
                    this.frameCount = 0;
                    this.fpsTimer = 0;
                }

                // 帧率限制
                const elapsed = currentTime - this.lastFrameTime;
                if (this.frameDuration === 0 || elapsed >= this.frameDuration) {
                    const dt = Math.min(frameTime, 0.1);
                    this.lastFrameTime = this.frameDuration > 0
                        ? currentTime - (elapsed % this.frameDuration)
                        : currentTime;

                    // 记录帧时间
                    this.frameTimeHistory.push(dt * 1000);
                    if (this.frameTimeHistory.length > this.maxHistoryLength) {
                        this.frameTimeHistory.shift();
                    }

                    this.spawnParticles();
                    this.update(dt);
                    this.render();
                }

                requestAnimationFrame((t) => this.loop(t));
            }

            update(dt) {
                // 更新粒子
                for (let i = this.particles.length - 1; i >= 0; i--) {
                    this.particles[i].update(dt);
                    if (this.particles[i].isDead()) {
                        // 重新生成
                        this.particles[i] = new Particle(
                            this.width / 2 + (Math.random() - 0.5) * 100,
                            this.height / 2 + (Math.random() - 0.5) * 100
                        );
                    }
                }

                // 限制粒子数量
                while (this.particles.length > this.targetParticleCount) {
                    this.particles.pop();
                }
            }

            render() {
                const ctx = this.ctx;

                // 背景
                ctx.fillStyle = '#0a0a1a';
                ctx.fillRect(0, 0, this.width, this.height);

                // 粒子
                for (const p of this.particles) {
                    ctx.globalAlpha = p.life;
                    ctx.fillStyle = `hsl(${p.hue}, 80%, 60%)`;
                    ctx.beginPath();
                    ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
                    ctx.fill();
                }
                ctx.globalAlpha = 1;

                // 性能面板
                this.renderPerformancePanel(ctx);
            }

            renderPerformancePanel(ctx) {
                const panelX = 10;
                const panelY = 10;
                const panelW = 220;
                const panelH = 140;

                // 面板背景
                ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
                ctx.fillRect(panelX, panelY, panelW, panelH);
                ctx.strokeStyle = '#4a90d9';
                ctx.lineWidth = 1;
                ctx.strokeRect(panelX, panelY, panelW, panelH);

                // 文字信息
                ctx.fillStyle = '#fff';
                ctx.font = '12px monospace';
                ctx.textAlign = 'left';
                ctx.fillText(`FPS: ${this.fps}`, panelX + 10, panelY + 20);
                ctx.fillText(`目标FPS: ${this.targetFPS === 0 ? '不限' : this.targetFPS}`, panelX + 10, panelY + 36);
                ctx.fillText(`粒子数: ${this.particles.length}`, panelX + 10, panelY + 52);

                // 帧时间图表
                const graphX = panelX + 10;
                const graphY = panelY + 65;
                const graphW = panelW - 20;
                const graphH = 60;

                // 图表背景
                ctx.fillStyle = '#0a0a2a';
                ctx.fillRect(graphX, graphY, graphW, graphH);

                // 16.67ms参考线(60fps)
                const refLine60 = graphY + graphH - (16.67 / 33.33) * graphH;
                const refLine30 = graphY + graphH - (33.33 / 33.33) * graphH;

                ctx.strokeStyle = '#16c79a44';
                ctx.lineWidth = 1;
                ctx.setLineDash([4, 4]);
                ctx.beginPath();
                ctx.moveTo(graphX, refLine60);
                ctx.lineTo(graphX + graphW, refLine60);
                ctx.stroke();
                ctx.strokeStyle = '#e9456044';
                ctx.beginPath();
                ctx.moveTo(graphX, refLine30);
                ctx.lineTo(graphX + graphW, refLine30);
                ctx.stroke();
                ctx.setLineDash([]);

                // 帧时间曲线
                if (this.frameTimeHistory.length > 1) {
                    ctx.strokeStyle = '#4a90d9';
                    ctx.lineWidth = 1.5;
                    ctx.beginPath();
                    for (let i = 0; i < this.frameTimeHistory.length; i++) {
                        const x = graphX + (i / this.maxHistoryLength) * graphW;
                        const y = graphY + graphH - Math.min(this.frameTimeHistory[i] / 33.33, 1) * graphH;
                        if (i === 0) ctx.moveTo(x, y);
                        else ctx.lineTo(x, y);
                    }
                    ctx.stroke();
                }

                // 标签
                ctx.fillStyle = '#888';
                ctx.font = '9px monospace';
                ctx.fillText('16ms', graphX + graphW + 2, refLine60 + 3);
                ctx.fillText('33ms', graphX + graphW + 2, refLine30 + 3);
            }
        }

        const game = new PerformanceDemo('gameCanvas');
        game.start();
    </script>
</body>
</html>

浏览器兼容性

特性 Chrome Firefox Safari Edge 移动端Chrome 移动端Safari
requestAnimationFrame 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
performance.now() 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
Page Visibility API 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
Web Workers 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
OffscreenCanvas 完全支持 完全支持 不支持 完全支持 不支持 不支持
requestIdleCallback 完全支持 完全支持 不支持 完全支持 完全支持 不支持

注意事项与最佳实践

  1. 始终使用requestAnimationFrame:不要使用setInterval或setTimeout来实现游戏循环。rAF与浏览器刷新率同步,在页面不可见时自动暂停,能提供最佳的用户体验和电池寿命。

  2. 限制deltaTime最大值:当用户切换标签页后回来,deltaTime可能非常大。始终使用Math.min(deltaTime, 0.1)来限制最大值,防止物体穿越边界或物理爆炸。

  3. 固定时间步长用于物理:如果游戏包含物理模拟,使用固定时间步长更新物理逻辑,可以确保物理行为在不同帧率下保持一致。

  4. 插值渲染:固定时间步长配合插值渲染,可以在逻辑更新频率低于渲染频率时提供平滑的视觉效果。

  5. 避免在游戏循环中创建对象:频繁创建和销毁对象会触发垃圾回收,导致帧率波动。使用对象池复用对象。

  6. 监控性能:在开发阶段始终显示FPS和帧时间图表,及时发现性能问题。

  7. 正确处理暂停:暂停时记录时间戳,恢复时重置lastTime,避免时间跳跃。

  8. 使用Page Visibility API:当页面不可见时自动暂停游戏循环,节省资源。

代码示例

document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
        game.pause();
    } else {
        game.resume();
    }
});

代码规范示例

代码示例

/**
 * 游戏循环管理器
 * 支持固定时间步长和可变时间步长
 */
class GameLoop {
    /**
     * @param {Object} config - 配置选项
     * @param {number} [config.fixedDelta=1/60] - 固定更新间隔(秒)
     * @param {number} [config.maxAccumulator=0.25] - 最大累积时间(秒)
     * @param {Function} config.onUpdate - 逻辑更新回调
     * @param {Function} config.onRender - 渲染回调
     * @param {Function} [config.onFixedUpdate] - 固定更新回调(可选)
     */
    constructor(config) {
        this.fixedDelta = config.fixedDelta || (1 / 60);
        this.maxAccumulator = config.maxAccumulator || 0.25;
        this.onUpdate = config.onUpdate;
        this.onRender = config.onRender;
        this.onFixedUpdate = config.onFixedUpdate || null;

        this._accumulator = 0;
        this._lastTime = 0;
        this._isRunning = false;
        this._isPaused = false;
        this._animationFrameId = null;

        // 性能统计
        this._fps = 0;
        this._frameCount = 0;
        this._fpsTimer = 0;
    }

    /** 当前FPS */
    get fps() { return this._fps; }

    /** 是否正在运行 */
    get isRunning() { return this._isRunning; }

    /** 是否暂停 */
    get isPaused() { return this._isPaused; }

    /**
     * 启动游戏循环
     */
    start() {
        if (this._isRunning) return;
        this._isRunning = true;
        this._isPaused = false;
        this._lastTime = performance.now();
        this._animationFrameId = requestAnimationFrame((t) => this._loop(t));
    }

    /**
     * 停止游戏循环
     */
    stop() {
        this._isRunning = false;
        if (this._animationFrameId) {
            cancelAnimationFrame(this._animationFrameId);
            this._animationFrameId = null;
        }
    }

    /**
     * 暂停游戏
     */
    pause() {
        this._isPaused = true;
    }

    /**
     * 恢复游戏
     */
    resume() {
        if (this._isPaused) {
            this._isPaused = false;
            this._lastTime = performance.now();
            this._accumulator = 0;
        }
    }

    /**
     * 主循环
     * @param {number} currentTime - 当前时间戳
     * @private
     */
    _loop(currentTime) {
        if (!this._isRunning) return;

        const frameTime = (currentTime - this._lastTime) / 1000;
        this._lastTime = currentTime;

        // 更新FPS统计
        this._frameCount++;
        this._fpsTimer += frameTime;
        if (this._fpsTimer >= 1) {
            this._fps = this._frameCount;
            this._frameCount = 0;
            this._fpsTimer -= 1;
        }

        if (!this._isPaused) {
            const clampedFrameTime = Math.min(frameTime, this.maxAccumulator);

            if (this.onFixedUpdate) {
                // 固定时间步长模式
                this._accumulator += clampedFrameTime;
                while (this._accumulator >= this.fixedDelta) {
                    this.onFixedUpdate(this.fixedDelta);
                    this._accumulator -= this.fixedDelta;
                }
                // 可变更新
                if (this.onUpdate) {
                    this.onUpdate(clampedFrameTime);
                }
                // 渲染(传入插值因子)
                const alpha = this._accumulator / this.fixedDelta;
                this.onRender(alpha);
            } else {
                // 纯可变时间步长模式
                if (this.onUpdate) {
                    this.onUpdate(clampedFrameTime);
                }
                this.onRender(1);
            }
        }

        this._animationFrameId = requestAnimationFrame((t) => this._loop(t));
    }
}

常见问题与解决方案

问题1:帧率不稳定,游戏时快时慢

原因:使用了可变时间步长但deltaTime计算有误,或游戏逻辑没有乘以deltaTime。

解决方案:确保所有运动计算都乘以deltaTime,或改用固定时间步长。

代码示例

// 错误:帧率依赖
player.x += player.speed;

// 正确:帧率无关
player.x += player.speed * deltaTime;

问题2:物理模拟在低帧率下不稳定

原因:可变时间步长下,大deltaTime导致物理计算不准确。

解决方案:使用固定时间步长更新物理,限制最大deltaTime。

代码示例

// 物理始终以1/60秒的间隔更新
const PHYSICS_DT = 1 / 60;
let accumulator = 0;

function loop(currentTime) {
    const frameTime = Math.min((currentTime - lastTime) / 1000, 0.1);
    lastTime = currentTime;
    accumulator += frameTime;

    while (accumulator >= PHYSICS_DT) {
        updatePhysics(PHYSICS_DT);
        accumulator -= PHYSICS_DT;
    }
}

问题3:切换标签页后回来游戏出现时间跳跃

原因:rAF在页面不可见时暂停,恢复后deltaTime可能非常大。

解决方案:限制deltaTime最大值,暂停时重置时间。

代码示例

// 限制最大deltaTime
const dt = Math.min(deltaTime, 0.1);

// 或使用Page Visibility API自动暂停
document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
        game.pause();
    } else {
        game.resume(); // resume中重置lastTime
    }
});

问题4:高刷新率显示器上游戏速度过快

原因:120Hz/144Hz显示器下,rAF回调频率更高,如果逻辑依赖帧数而非时间,游戏会变快。

解决方案:始终使用基于时间的更新,而非基于帧数的更新。

问题5:内存泄漏导致帧率逐渐下降

原因:游戏循环中不断创建新对象,垃圾回收压力增大。

解决方案:使用对象池,复用对象而非频繁创建和销毁。

代码示例

class ObjectPool {
    constructor(createFn, resetFn, initialSize = 50) {
        this.createFn = createFn;
        this.resetFn = resetFn;
        this.pool = [];
        for (let i = 0; i < initialSize; i++) {
            this.pool.push(createFn());
        }
    }

    get() {
        if (this.pool.length > 0) {
            const obj = this.pool.pop();
            this.resetFn(obj);
            return obj;
        }
        return this.createFn();
    }

    release(obj) {
        this.pool.push(obj);
    }
}

总结

游戏循环是游戏开发中最基础也是最重要的概念。本教程详细讲解了游戏循环的工作原理,对比了可变时间步长和固定时间步长的优缺点,展示了如何使用requestAnimationFrame构建高效的游戏循环,以及如何实现deltaTime计算、帧率限制、性能监控和游戏暂停恢复。

关键要点:

  • 始终使用requestAnimationFrame而非setInterval/setTimeout

  • 所有运动和逻辑更新都应基于时间(deltaTime),而非帧数

  • 物理模拟推荐使用固定时间步长,确保行为一致性

  • 限制deltaTime最大值,防止标签页切换后的时间跳跃

  • 使用插值渲染提升固定时间步长下的视觉平滑度

  • 监控FPS和帧时间,及时发现性能问题

  • 正确处理暂停和恢复,避免时间累积错误

掌握这些知识后,你将能够构建稳定、流畅的游戏循环,为后续的游戏开发打下坚实基础。

常见问题

什么是游戏循环与帧率控制?

游戏循环与帧率控制是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。

如何学习游戏循环与帧率控制的实际应用?

教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。

游戏循环与帧率控制有哪些注意事项?

常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。

游戏循环与帧率控制适合初学者吗?

本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。

游戏循环与帧率控制的核心要点是什么?

核心要点包括教程简介等内容,建议按教程顺序逐步学习。

标签: 碰撞检测 Canvas 键盘输入 内存管理 场景管理 帧率优化 物理模拟 触摸控制

本文由小确幸生活整理发布,转载请注明出处

本文涉及AI创作

内容由AI创作,请仔细甄别

list快速访问

上一篇: 游戏开发:HTML游戏开发概述 - 从入门到实践详解 下一篇: 游戏开发:Canvas游戏绘图基础 - 从入门到实践详解

poll相关推荐