Game Loop e Rendering em Zig: Fixed Timestep, Sprites e Texturas

O game loop e o coracao de qualquer jogo. Ele e responsavel por atualizar a logica do jogo, processar input e renderizar a tela em uma sequencia continua. Um game loop bem implementado garante que o jogo rode de forma suave e consistente em diferentes hardwares. Neste artigo, construimos um game loop profissional em Zig e exploramos tecnicas de renderizacao.

Este artigo continua do setup e janela grafica. Certifique-se de ter o ambiente configurado.

O Game Loop Classico

O game loop mais simples segue o padrao:

while (rodando) {
    processar_input();
    atualizar_logica();
    renderizar();
}

Mas essa abordagem tem um problema: a velocidade do jogo depende da velocidade do hardware. Em um computador rapido, o jogo roda mais rapido; em um lento, roda devagar.

Variable Timestep

A primeira solucao e usar delta time — o tempo entre frames:

const rl = @cImport(@cInclude("raylib.h"));

const Jogador = struct {
    x: f32 = 400,
    y: f32 = 300,
    velocidade: f32 = 200, // pixels por segundo

    pub fn update(self: *Jogador, dt: f32) void {
        if (rl.IsKeyDown(rl.KEY_RIGHT)) self.x += self.velocidade * dt;
        if (rl.IsKeyDown(rl.KEY_LEFT)) self.x -= self.velocidade * dt;
        if (rl.IsKeyDown(rl.KEY_DOWN)) self.y += self.velocidade * dt;
        if (rl.IsKeyDown(rl.KEY_UP)) self.y -= self.velocidade * dt;
    }

    pub fn draw(self: *const Jogador) void {
        rl.DrawRectangle(
            @intFromFloat(self.x),
            @intFromFloat(self.y),
            32,
            32,
            rl.BLUE,
        );
    }
};

pub fn main() void {
    rl.InitWindow(800, 600, "Variable Timestep");
    defer rl.CloseWindow();
    rl.SetTargetFPS(60);

    var jogador = Jogador{};

    while (!rl.WindowShouldClose()) {
        const dt: f32 = rl.GetFrameTime();

        jogador.update(dt);

        rl.BeginDrawing();
        rl.ClearBackground(rl.BLACK);
        jogador.draw();
        rl.EndDrawing();
    }
}

Fixed Timestep (Recomendado)

Para fisica deterministica e reproducibilidade, use fixed timestep com interpolacao:

const std = @import("std");
const rl = @cImport(@cInclude("raylib.h"));

const TICK_RATE: f32 = 1.0 / 60.0; // 60 updates por segundo

const Estado = struct {
    x: f32 = 400,
    y: f32 = 300,
    vel_x: f32 = 0,
    vel_y: f32 = 0,
};

pub fn lerp(a: f32, b: f32, t: f32) f32 {
    return a + (b - a) * t;
}

pub fn main() void {
    rl.InitWindow(800, 600, "Fixed Timestep");
    defer rl.CloseWindow();

    var estado_atual = Estado{};
    var estado_anterior = Estado{};
    var acumulador: f32 = 0;
    const velocidade: f32 = 200;

    while (!rl.WindowShouldClose()) {
        const dt: f32 = rl.GetFrameTime();
        acumulador += dt;

        // Processar input
        var input_x: f32 = 0;
        var input_y: f32 = 0;
        if (rl.IsKeyDown(rl.KEY_RIGHT)) input_x = 1;
        if (rl.IsKeyDown(rl.KEY_LEFT)) input_x = -1;
        if (rl.IsKeyDown(rl.KEY_DOWN)) input_y = 1;
        if (rl.IsKeyDown(rl.KEY_UP)) input_y = -1;

        // Fixed timestep updates
        while (acumulador >= TICK_RATE) {
            estado_anterior = estado_atual;

            estado_atual.vel_x = input_x * velocidade;
            estado_atual.vel_y = input_y * velocidade;
            estado_atual.x += estado_atual.vel_x * TICK_RATE;
            estado_atual.y += estado_atual.vel_y * TICK_RATE;

            acumulador -= TICK_RATE;
        }

        // Interpolacao para rendering suave
        const alpha = acumulador / TICK_RATE;
        const render_x = lerp(estado_anterior.x, estado_atual.x, alpha);
        const render_y = lerp(estado_anterior.y, estado_atual.y, alpha);

        // Render
        rl.BeginDrawing();
        rl.ClearBackground(rl.BLACK);

        rl.DrawRectangle(
            @intFromFloat(render_x),
            @intFromFloat(render_y),
            32,
            32,
            rl.BLUE,
        );

        rl.DrawFPS(10, 10);
        rl.EndDrawing();
    }
}

A interpolacao entre estado_anterior e estado_atual garante rendering suave mesmo quando a taxa de update e a taxa de render sao diferentes.

Renderizacao de Sprites

Carregando e Desenhando Texturas

const rl = @cImport(@cInclude("raylib.h"));

pub fn main() void {
    rl.InitWindow(800, 600, "Sprites");
    defer rl.CloseWindow();
    rl.SetTargetFPS(60);

    // Carregar textura
    const sprite = rl.LoadTexture("assets/sprites/heroi.png");
    defer rl.UnloadTexture(sprite);

    var pos_x: f32 = 400;
    var pos_y: f32 = 300;
    var escala: f32 = 2.0;
    var rotacao: f32 = 0;

    while (!rl.WindowShouldClose()) {
        const dt = rl.GetFrameTime();

        // Mover
        if (rl.IsKeyDown(rl.KEY_RIGHT)) pos_x += 200 * dt;
        if (rl.IsKeyDown(rl.KEY_LEFT)) pos_x -= 200 * dt;

        // Rotacionar
        if (rl.IsKeyDown(rl.KEY_Q)) rotacao -= 90 * dt;
        if (rl.IsKeyDown(rl.KEY_E)) rotacao += 90 * dt;

        rl.BeginDrawing();
        rl.ClearBackground(rl.DARKGRAY);

        // Desenhar sprite com rotacao e escala
        rl.DrawTextureEx(
            sprite,
            .{ .x = pos_x, .y = pos_y },
            rotacao,
            escala,
            rl.WHITE,
        );

        rl.EndDrawing();
    }
}

Sprite Sheet e Animacao

Para animacoes, usamos sprite sheets — imagens com varios frames:

const rl = @cImport(@cInclude("raylib.h"));

const AnimacaoSprite = struct {
    textura: rl.Texture2D,
    frame_largura: f32,
    frame_altura: f32,
    frames_total: u32,
    frame_atual: u32 = 0,
    timer: f32 = 0,
    velocidade: f32 = 0.1, // segundos por frame

    pub fn update(self: *AnimacaoSprite, dt: f32) void {
        self.timer += dt;
        if (self.timer >= self.velocidade) {
            self.timer = 0;
            self.frame_atual = (self.frame_atual + 1) % self.frames_total;
        }
    }

    pub fn draw(self: *const AnimacaoSprite, x: f32, y: f32) void {
        const source = rl.Rectangle{
            .x = @as(f32, @floatFromInt(self.frame_atual)) * self.frame_largura,
            .y = 0,
            .width = self.frame_largura,
            .height = self.frame_altura,
        };

        const dest = rl.Rectangle{
            .x = x,
            .y = y,
            .width = self.frame_largura * 2,
            .height = self.frame_altura * 2,
        };

        rl.DrawTexturePro(self.textura, source, dest, .{ .x = 0, .y = 0 }, 0, rl.WHITE);
    }
};

pub fn main() void {
    rl.InitWindow(800, 600, "Animacao");
    defer rl.CloseWindow();
    rl.SetTargetFPS(60);

    const sheet = rl.LoadTexture("assets/sprites/heroi_walk.png");
    defer rl.UnloadTexture(sheet);

    var anim = AnimacaoSprite{
        .textura = sheet,
        .frame_largura = 32,
        .frame_altura = 32,
        .frames_total = 8,
        .velocidade = 0.08,
    };

    while (!rl.WindowShouldClose()) {
        const dt = rl.GetFrameTime();
        anim.update(dt);

        rl.BeginDrawing();
        rl.ClearBackground(rl.BLACK);
        anim.draw(384, 284);
        rl.EndDrawing();
    }
}

Gerenciador de Assets

Para projetos maiores, um gerenciador de assets centralizado evita carregar recursos duplicados:

const std = @import("std");
const rl = @cImport(@cInclude("raylib.h"));

pub const AssetManager = struct {
    texturas: std.StringHashMap(rl.Texture2D),
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator) AssetManager {
        return .{
            .texturas = std.StringHashMap(rl.Texture2D).init(allocator),
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *AssetManager) void {
        // Descarregar todas as texturas
        var iter = self.texturas.valueIterator();
        while (iter.next()) |tex| {
            rl.UnloadTexture(tex.*);
        }
        self.texturas.deinit();
    }

    pub fn carregarTextura(self: *AssetManager, nome: []const u8, caminho: [*:0]const u8) !rl.Texture2D {
        // Verificar cache
        if (self.texturas.get(nome)) |tex| {
            return tex;
        }

        // Carregar nova textura
        const tex = rl.LoadTexture(caminho);
        if (tex.id == 0) {
            return error.TexturaInvalida;
        }

        try self.texturas.put(nome, tex);
        return tex;
    }

    pub fn obterTextura(self: *AssetManager, nome: []const u8) ?rl.Texture2D {
        return self.texturas.get(nome);
    }
};

Camadas de Renderizacao

Jogos 2D tipicamente usam camadas (layers) para controlar a ordem de desenho:

const std = @import("std");
const rl = @cImport(@cInclude("raylib.h"));

const Camada = enum(u8) {
    fundo = 0,
    cenario = 1,
    entidades = 2,
    efeitos = 3,
    ui = 4,
};

const ComandoDesenho = struct {
    camada: Camada,
    textura: rl.Texture2D,
    origem: rl.Rectangle,
    destino: rl.Rectangle,
    cor: rl.Color = rl.WHITE,
};

const Renderer = struct {
    comandos: std.ArrayList(ComandoDesenho),

    pub fn init(allocator: std.mem.Allocator) Renderer {
        return .{
            .comandos = std.ArrayList(ComandoDesenho).init(allocator),
        };
    }

    pub fn deinit(self: *Renderer) void {
        self.comandos.deinit();
    }

    pub fn submeter(self: *Renderer, cmd: ComandoDesenho) !void {
        try self.comandos.append(cmd);
    }

    pub fn renderizar(self: *Renderer) void {
        // Ordenar por camada
        std.sort.insertion(ComandoDesenho, self.comandos.items, {}, struct {
            fn cmp(_: void, a: ComandoDesenho, b: ComandoDesenho) bool {
                return @intFromEnum(a.camada) < @intFromEnum(b.camada);
            }
        }.cmp);

        // Desenhar tudo
        for (self.comandos.items) |cmd| {
            rl.DrawTexturePro(
                cmd.textura,
                cmd.origem,
                cmd.destino,
                .{ .x = 0, .y = 0 },
                0,
                cmd.cor,
            );
        }

        // Limpar para o proximo frame
        self.comandos.clearRetainingCapacity();
    }
};

Camera 2D

Uma camera permite seguir o jogador e criar efeitos de parallax:

const rl = @cImport(@cInclude("raylib.h"));

pub fn main() void {
    rl.InitWindow(800, 600, "Camera 2D");
    defer rl.CloseWindow();
    rl.SetTargetFPS(60);

    var jogador_x: f32 = 400;
    var jogador_y: f32 = 300;

    var camera = rl.Camera2D{
        .target = .{ .x = jogador_x, .y = jogador_y },
        .offset = .{ .x = 400, .y = 300 }, // Centro da tela
        .rotation = 0,
        .zoom = 1,
    };

    while (!rl.WindowShouldClose()) {
        const dt = rl.GetFrameTime();

        // Mover jogador
        if (rl.IsKeyDown(rl.KEY_RIGHT)) jogador_x += 300 * dt;
        if (rl.IsKeyDown(rl.KEY_LEFT)) jogador_x -= 300 * dt;
        if (rl.IsKeyDown(rl.KEY_DOWN)) jogador_y += 300 * dt;
        if (rl.IsKeyDown(rl.KEY_UP)) jogador_y -= 300 * dt;

        // Zoom
        camera.zoom += rl.GetMouseWheelMove() * 0.1;
        if (camera.zoom < 0.25) camera.zoom = 0.25;

        // Camera segue jogador suavemente
        camera.target.x += (jogador_x - camera.target.x) * 5 * dt;
        camera.target.y += (jogador_y - camera.target.y) * 5 * dt;

        rl.BeginDrawing();
        rl.ClearBackground(rl.BLACK);

        rl.BeginMode2D(camera);
        {
            // Desenhar mundo (relativo a camera)
            var i: c_int = -10;
            while (i <= 10) : (i += 1) {
                var j: c_int = -10;
                while (j <= 10) : (j += 1) {
                    rl.DrawRectangle(i * 100, j * 100, 90, 90, rl.DARKGRAY);
                }
            }

            // Desenhar jogador
            rl.DrawRectangle(
                @intFromFloat(jogador_x - 16),
                @intFromFloat(jogador_y - 16),
                32,
                32,
                rl.RED,
            );
        }
        rl.EndMode2D();

        // UI (nao afetada pela camera)
        rl.DrawFPS(10, 10);
        rl.EndDrawing();
    }
}

Exercicios

  1. Tilemap renderer: Crie um sistema que carregue um mapa de tiles a partir de um array 2D e os renderize na tela.

  2. Parallax scrolling: Implemente um fundo com multiplas camadas que se movem em velocidades diferentes para criar efeito de profundidade.

  3. Particle system: Crie um sistema de particulas simples que emita e anime particulas na posicao do mouse.


Proximo Artigo

No proximo artigo, exploramos input handling em profundidade, cobrindo teclado, mouse, gamepad e sistemas de input abstratos.

Conteudo Relacionado


Compartilhe seus jogos feitos em Zig com a comunidade Zig Brasil!

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.