Platformer book

A draft of "macroquad platformer book" - step by step guide of building competitive platformer game.

Tiled

tiled is a free, open source level editor.

macroquad-tiled - is an optional part of macroquad responsible for tiled editor integration.

Designing the world

screenshot

This is how the big level made with very basic ideas to what we are going to use here looked in tiled

Loading in macroquad


#![allow(unused)]
fn main() {
// Load json file exported from tiled
let tiled_map = load_file("level.json").await;

// We used some png files in the tiled
// Those pngs are saved alongside with .json, so lets load them as well
let tileset_texture = load_texture("tileset.png").await;
let background_texture = load_texture("background.png").await;

// And feed all the data loaded to `macroquad-tiled`
let map = tiled::load_map(
    &tiled_map,
    &[
        ("tileset.png", tileset_texture),
        ("background.png", background_texture),
    ],
)?;
}

Drawing: Tilemaps


#![allow(unused)]
fn main() {
// Lets draw the whole map full screen
// Default macroquad camera is pixel perfect with (0, 0) in top left corner and (screen_width(), screen_height()) on bottom right
let dest_rect = Rect::new(0., 0., screen_width(), screen_height());

// We used only part of tiled canvas to create our first level
// So lets draw only that part of the canvas
// Area is hardcoded for now, but we will use the technique of drawing parts of tiled canvas
// to jump through level sections in the future
let source_rect = Rect::new(0, 0, 22, 11);

// And just draw our level!
map.draw_tiles("level", dest_rect, level.area);
}

#level

It works, we got our nice static background

Drawing: Sprites

Now we got the world rendered, let's add some characters. We can draw the player right into the tileset and use draw_tiles with different source/destination rects on this part of the tilemap right as we did it for the world.
But macroquad-tiled have some special functions dedicated for sprites drawing.


#![allow(unused)]
fn main() {
impl Player {
    // name of the tileset contains player sprites in the tiled map file
    const TILESET: &'static str = "tileset";
    // sprite number in that tileset
    const PLAYER_TILE: u32 = 11;

    fn draw(&mut self) {
        self.map.spr(
            "tileset",
            PLAYER_TILE,
            Rect::new(self.pos.x(), self.pos.y(), 8., 8.),
        );
    }
}
}

player_spr

Retro consoles vibe

Now we can draw sprites from tileset and draw the whole maps from the same tileset. This actually is pretty close to what we had for game development back into early video console days with hardware sprite controllers!
Also some fantasy consoles like pico-8 use very similar video memory model.

Paralax

In tiled we had two layers - with foreground and background.

First step: draw the background as well:


#![allow(unused)]
fn main() {
self.tiled_map.draw_tiles("back", dest_rect, source_rect);
self.tiled_map.draw_tiles("level", dest_rect, source_rect);
}

#level_with_background

There is really easy way to add some life to static tiled world: parallax effect.

TODO: figure out order of the chapters to not confuse with suddedenly appeared camera

There are various way to do parallax scrolling, but lets implement some simple hack to demonstrate the idea.

    
// this (with magically appeared camera) simple formula will give us 0..1 value
// with 0 on left side of the level and 1 on the right 
let parallax_offset = level.camera / vec2(deset_rect.w, dest_rect.h);

// we can just draw the background layer slightly bigger than foreground
// to make some room to move it around
let mut dest_rect_parallax = Rect::new(
    -PARALLAX_EXTRA_W / 2.,
    -PARALLAX_EXTRA_H / 2., 
    dest_rect.w + PARALLAX_EXTRA_W / 2., 
    dest_rect.h + PARALLAX_EXTRA_H / 2.);
// and now shift it by our offset
dest_rect_parallax += parallax_offset;
    
self.tiled_map.draw_tiles("back", dest_rect_parallax, level.area);
self.tiled_map.draw_tiles("level", dest_rect, source_rect);

Now the background is moving slightly different than the foreground.

parallax

Player state machine


#![allow(unused)]
fn main() {
pub struct Player {
    pos: Vec2,
    spd: Vec2,
    
    ...
    
    state_machine: StateMachine,
}
}

#![allow(unused)]
fn main() {
impl Player {
    fn new() -> Player {
        let mut state_machine = StateMachine::new();
        
        // update function will be called on each state_machine.update()
        state_machine.insert(
            Self::ST_NORMAL, 
            State::new()
                .update(Self::update_normal));

        // coroutine will be started each time player enter dash state 
        state_machine.insert(
            Self::ST_DASH,
            State::new()
                .update(Self::update_dash)
                .coroutine(Self::dash_coroutine),
        );

    }
}
}

Dash state: both "update" and "coroutine" are used.
"dash_coroutine" is responsible for changing player state once at the beginning of the dash and than switching back to "normal".
"update" may apply some optional physics or game rules that works only in dash state. Is just an empty function right now.


#![allow(unused)]
fn main() {
impl Player {
    // this will be started on each coroutine state enter
    async fn dash_coroutine(&mut self, room: &mut Room) {
        // change the speed with dash speed
        self.spd = self.last_aim * self.dash_speed;
        // and just wait for dash_duration seconds
        // during wait period "update_dash" function will be called on each frame
        wait_seconds(self.dash_duration).await;
        // dash is over, going back to normal
        self.state_machine.set_state(Self::ST_NORMAL);
     
    }

    // dash update is empty: nothing is affecting player during the dash 
    fn update_dash(&mut self, room: &mut Room, dt: f32) {}
}
}

Player's "normal" state update: check controls available in "normal" state and maybe switch to "dash" state:


#![allow(unused)]
fn main() {
impl Player {
    fn start_dash(&mut self) {
        self.dashes = 0;
        // during the dash player has completely different behaviour
        // changing the playr's state to DASH
        self.state_machine.set_state(Self::ST_DASH);
    }
    
    fn jump(&mut self) {
        self.spd.y = self.jump_speed;
        // during jump player is behaving exactly as usual
        // so the state do not change here
    }

    fn update_normal(&mut self, room: &mut Room, dt: f32) {
        if is_key_pressed(KeyCode::A) {
            self.start_dash();
            return;
        }
        
        if is_key_pressed(KeyCode::S) {
            self.jump();
            return;
        }
        
        // running
        ...
        
        // gravity
        ...
    }
}
}

Main player's update function: apply general, state independent game rules and update state machine:


#![allow(unused)]
fn main() {
impl Player {
    fn update(&mut self, room: &mut Room) {
        // physics: apply spd to self.pos with some collisions
        ...
        
        // win conditions check
        ...
        
        // lose conditions check
        ...
        
        // camera update
        ...
        
        self.state_machine.update(room);
    }
}
}

Win state

Small state with cutscene at the end of each level.

cutscene


#![allow(unused)]
fn main() {
impl Player {
    const ST_NORMAL: usize = 0;
    const ST_DASH: usize = 1;
    const ST_DEATH: usize = 2;
    const ST_WIN: usize = 3;

    pub fn new(...) -> Player {
        let mut state_machine = StateMachine::new();

        state_machine.insert(Self::ST_NORMAL, State::new().update(Self::update_normal));
        state_machine.insert(
            Self::ST_DASH,
            State::new()
                .update(Self::update_dash)
                .coroutine(Self::dash_coroutine),
        );
        state_machine.insert(
            Self::ST_DEATH,
            State::new().coroutine(Self::death_coroutine),
        );
        
        // New state added: Win state
        state_machine.insert(Self::ST_WIN, State::new().coroutine(Self::win_coroutine));
        ... 
    }
}
}

#![allow(unused)]
fn main() {
impl Player {
    // this function is supposed to be called by collision detection code and signal the player to start win cutscene
    pub fn win(&mut self, flag_position: Vec2) {
        self.flag_position = flag_position;

        self.state_machine.set_state(Self::ST_WIN);
    }
}
}

#![allow(unused)]
fn main() {
impl Player {
    ...
    async fn win_coroutine(&mut self, room: &mut Room) -> Coroutine {
        let start = self.pos;
        let end = self.flag_position;

        room.in_cutscene = true;

        // here we start 3 independent parallel coroutines moving some player params at the same time
        let rotate = start_coroutine(tweens::linear(&mut self.rotation, 5.0, 0.7));
        let scale = start_coroutine(tweens::linear(&mut self.scale, vec2(0.1, 0.1), 0.9));
        let slowdown = start_coroutine(tweens::linear(&mut self.spd, vec2(0.0, 0.0), 0.3));

        // while coroutines are now runned on background
        // here we can wait while all 3 of them will finish
        // and animate the player with those params
        while !rotate.is_done() || !slowdown.is_done() || !scale.is_done() {
            self.pos += self.spd * delta;
            self.pos = self.pos.lerp(end, 0.02);

            next_frame().await;
        }

        room.in_cutscene = false;
    }
}
}

Animation controller


#![allow(unused)]
fn main() {
// wat really
async fn play_animation(&mut self) {
    self.sprite = 322;
    for _ in 0 .. 9i32 {
        self.sprite += 1;
        wait_seconds(0.05).await;
    }
}
}

#![allow(unused)]
fn main() {
async fn death_coroutine(&mut self, room: Room) {
    self.spd = vec2(0., 0.);
    self.play_animation().await;
    room.lose();
}
}

Tweens


#![allow(unused)]
fn main() {
async fn dash_coroutine(&mut self, room: &mut Room) {
    // change current speed to maximum dash speed
    self.spd = self.last_aim * self.dash_speed;

    // and than wait for some time to move forward with dash
    wait_seconds(self.dash_duration).await;

    // release the controls and go back to normal mode
    self.state_machine.set_state(Self::ST_NORMAL);
}
}

This is cool, but looks not so awesome. To make it better it would be nice to slowly accelerate from current speed to dashspeed instead of instant acceleration.

Tweens are going to be used. Tweens are special coroutines made specifically for this: change some variable for some time.


#![allow(unused)]
fn main() {
async fn dash_coroutine(&mut self, room: &mut Room) {
    let target_dash_speed = self.last_aim * self.dash_speed;

    // accelerate from current speed to dash speed for some time
    tweens::linear(&mut self.spd, target_dash_speed, self.dash_accel_duration).await;

    // and than keep moving with dash speed
    wait_seconds(self.dash_duration).await;

    // release the controls and go back to normal mode
    self.state_machine.set_state(Self::ST_NORMAL);
}
}

Spring

spring


#![allow(unused)]
fn main() {
impl Spring {
    pub fn new(...) -> Spring {
        let mut state_machine = StateMachine::new();
        
        state_machine.insert(Self::ST_NORMAL, State::new().update(Self::update_normal));
        state_machine.insert(
            Self::ST_JUMP,
            State::new()
                .update(Self::update_jump)
                .coroutine(Self::jump_coroutine),
        );

        Spring {
            state_machine: StateMachineContainer::Ready(state_machine),
            
            ...
        }
    }

    // normal state update: when spring is ready to bounce some players
    pub fn update_normal(&mut self, room: &mut Room, _dt: f32) {
        for object in room.objects.iter() {
            if let GameObject::Player(ref mut player) = object.data {
                if self.collide(player) {
                    player.bounce();
                    self.state_machine.set_state(Self::ST_JUMP);
                }
            }
        }
    }

    // during jump animation spring do not work as a spring
    pub fn update_jump(&mut self, _room: &mut Room, _dt: f32) {}

    // this coroutine will be started on enter to jump state
    pub async fn jump_coroutine(&mut self, _room: &mut Room) {
       // change a sprite to a compressed spring
       self.spr = 865;
       // wait a little bit
       wait_seconds(2.0).await;
       // and change sprite back to normal, uncompressed spring
       self.spr = 864;
       // and now the spring will work as a spring again
       self.state_machine.set_state(Self::ST_NORMAL);
    }
}
}

Water level

Lets implement a very special levels with water.
For how to draw water take a look on "screen reading shaders" chapter.

The goal here - implement very unique physics and game rules for being underwater.
Bonus points for keeping all the old code clean from underwater special cases.

Barebone swimming state


#![allow(unused)]
fn main() {
impl Player {
    const ST_NORMAL: usize = 0;
    const ST_DASH: usize = 1;
    const ST_DEATH: usize = 2;
    const ST_WIN: usize = 3;
    // The new state
    const ST_SWIM: usize = 4;

   pub fn new(...) {
   ...
       // state machine configuration for the new state
       // very similar to all the same custom state from previous chapters
       state_machine.insert(Self::ST_SWIM, State::new().update(Self::update_swim));
   }
   
    fn update_swim(&mut self, room: &mut Room, dt: f32) {
        // "swim_check" will look is the middle of the player sprite collides water tile on "water" level
        // TODO: when collision code will be cleaned up - show swim_check contents
        if self.swim_check(room) == false {
            // not in water, back to normal
            self.state_machine.set_state(Self::ST_NORMAL);
        }

        // simple water "physics"
        // if down button is not pushed - player is going up
        // if is pushed - player going to sink 
        let mut floating_speed = self.swim_afloat_speed;
        if is_key_down(KeyCode::Down) {
            floating_speed = self.swim_sink_speed;
        }
        self.spd.y = floating_speed;

        if is_key_down(KeyCode::Right) {
          ..
        }

        if is_key_down(KeyCode::Left) {
          ..
        }
    }
    
    fn update(&mut self, room: &mut Room) {
        ...
        // if player is in the normal state and in the water - switch for water state
        if self.state_machine.state() == Self::ST_NORMAL && self.swim_check(room) {
            self.state_machine.set_state(Self::ST_SWIM);
        }
        ...
    }
}
}

water-simple

Oxygen


#![allow(unused)]
fn main() {
    fn update_swim(&mut self, room: &mut Room, dt: f32) {
        ...
        self.oxygen -= self.oxygen_consumption * dt;

        if self.oxygen <= 0.0 {
            // while there is no cutscene state for dead player underwater - jsut ask room to reload level
            room.lose();
        }
        ...
    }
}

But oxygen recovery is going to happen in any other non-swimming states. So here for the first time swimming-specific behavior is going to leak just from swimming state to game logic in general.
It is possible to justify it, though: oxygen is a fundamental law of the game now, so it is fine to do something about it on each frame in main player's update function:


#![allow(unused)]
fn main() {
impl Player {
    ...
    
    pub fn update(&mut self, room: &mut Room) {
        ...
        if self.swim_check(room) == false {
            self.oxygen = self.max_oxygen.min(self.oxygen + self.oxygen_recovery * dt);
        } else {
            self.oxygen -= self.oxygen_consumption * dt;
        }        
        ...
    }
}
}

Oxygen level UI


#![allow(unused)]
fn main() {
impl Player {
    ...
    
    fn draw(&mut self) {
        // even if player is not swimming state - it would be nice to see how oxygen is recovering
        if self.state_machine.state() == Self::ST_SWIM || self.oxygen != self.max_oxygen {
            draw_rectangle(self.pos.x() - 2.3, self.pos.y() - 0.1, 2.6, 6.2, BLACK);
            draw_rectangle(
                self.pos.x() - 2.0,
                self.pos.y(),
                2.0,
                6.0 * self.oxygen / self.max_oxygen,
                BLUE,
            );
        }
    }
}
}

Result of that magic constants and hand-adjusted positions in "draw" function:

water_bar

Out of oxygen post-effect

For more pressure on the player from running out of oxygen situation lets add some vignette post effect.
Shader used is going to be very similar to the one used in "post-effects" chapter, but this time the amount of post effect is going to depend on in-game content.


#![allow(unused)]
fn main() {
    let material = load_material(
        VIGNETTE_SHADER,
        VIGNETTE_SHADER,
        MaterialParams {
            uniforms: vec![
                ("Target".to_string(), UniformType::Float2),
                ("Amount".to_string(), UniformType::Float1),
            ],
            ..Default::default()
        },
    )
    .unwrap();
}

This way macroquad will know that this material have two uniform variables. And now it is possible to set those variables at runtime:


#![allow(unused)]
fn main() {
        vignette_material.set_uniform("Target", room.vignette_center);
        vignette_material.set_uniform("Amount", room.vignette_amount);

        gl_use_material(vignette_material);
        
        // full-screen quad from "post-processing" chapter
        draw_texture_ex(
            render_target.texture,
            0.,
            0.,
            WHITE,
            DrawTextureParams {
                dest_size: Some(vec2(screen_width(), screen_height())),
                ..Default::default()
            },
        );
        gl_use_default_material();

}

water_vignette

To speed up game tempo we can allow dashing under water.
Dash can consume significant amount of oxygen, so spending non-optimal amount of dashes or going even a slightly wrong direction will result of fast fail.
However now it is possible for a player to take some risks and finish level a bit faster!


#![allow(unused)]
fn main() {
    fn update_swim(&mut self, room: &mut Room, dt: f32) {
        ...
        if self.can_dash() {
            self.oxygen -= self.dash_oxygen_cost;
            
            // the same function that was used in "update_normal"
            // so its going to be exactly the same dash as in underwater state
            self.start_dash();
            return;
        }
        ...
    }

}

water_vignette_dash

Post processing

Step 0: No post processing

#[macroquad::main("Post processing")]
async fn main() {
    loop {
        set_camera(Camera2D {
            zoom: vec2(0.01, 0.01),
            target: vec2(0.0, 0.0),
            ..Default::default()
        });
        
        clear_background(RED);
        draw_line(-30.0, 45.0, 30.0, 45.0, 3.0, BLUE);
        draw_circle(-45.0, -35.0, 20.0, YELLOW);
        draw_circle(45.0, -35.0, 20.0, GREEN);
        
        next_frame().await;
    }
}

Step 1: Pixelisation

let render_target = render_target(320, 150);

set_texture_filter(render_target.texture, FilterMode::Nearest);

loop {
  // drawing to the texture

  // add "render_target" field to camera setup
  // now it will render to texture 
  set_camera(Camera2D {
    zoom: vec2(0.01, 0.01),
    target: vec2(0.0, 0.0),
    render_target: Some(render_target),
    ..Default::default()
  });

  clear_background(RED);

  // draw our game!
  draw_line(-30.0, 45.0, 30.0, 45.0, 3.0, BLUE);
  draw_circle(-45.0, -35.0, 20.0, YELLOW);
  draw_circle(45.0, -35.0, 20.0, GREEN);

  // drawing to the screen

  // 0..1, 0..1 camera
  set_camera(Camera2D {
    zoom: vec2(1.0, 1.0),
    target: vec2(0.0, 0.0),
    ..Default::default()
  });

  // draw full screen quad with previously rendered scene
  draw_texture_ex(
      render_target.texture,
      0.,
      0.,
      WHITE,
      DrawTextureParams {
          dest_size: Some(vec2(1.0, 1.0)),
          ..Default::default()
      },
  );

We got nicely pixelized, old-schoold game.

Step 3: Shaders

But just pixelization is not enough.
Let glean some cool post-processing effect from shadertoy.

https://www.shadertoy.com/view/XtlSD7 CRT effect!

// same render target setup from previous step
let render_target = render_target(320, 150);
set_texture_filter(render_target.texture, FilterMode::Nearest);

// 
let material = load_material(CRT_VERTEX_SHADER, CRT_FRAGMENT_SHADER, Default::default()).unwrap();

// main loop is going to be exactly the same 
loop {
   // drawing to the texture
   ...
   
  // draw full screen quad with previously rendered scene
  // but before drawing texture, default macroquad material
  // needs to be replaced to the new one with CRT shader
  gl_use_material(material);

  draw_texture_ex(
      render_target.texture,
      0.,
      0.,
      WHITE,
      DrawTextureParams {
          dest_size: Some(vec2(1.0, 1.0)),
          ..Default::default()
      },
  );
  // switch back to default material
  gl_use_default_material();
 
}

Entire code for this example is available here: https://github.com/not-fl3/macroquad/blob/master/examples/post_processing.rs Web build: https://not-fl3.github.io/miniquad-samples/post_processing.html

Screen reading shaders

Some effects requires reading from the same target the shader is writing to.

Good example - glass shader with some distortion and refraction. Or water shader in our case!

water

The idea here:

  • render whole scene
  • render the mesh for water with refraction shader
  • make refractions realistic - read screen data in the shader

First step: render water layer with default material


#![allow(unused)]
fn main() {
impl World {
    ...
    fn draw(&mut self) {
        // draw the whole world
        ...
   
       // draw tiled layer with water
       self.tiled_map.draw_tiles("water", dest_rect, level.area);
   }
}
}

water_plain

Second step: add some refractions

Just as in previous chapter we need a material with custom shader


#![allow(unused)]
fn main() {
let water_material = load_material(WATER_VERTEX_SHADER, WATER_FRAGMENT_SHADER, Default::default()).unwrap();

...
// use that material

gl_use_material(self.water_material);
// draw tiled layer with water
self.tiled_map.draw_tiles("water", dest_rect, level.area);
gl_use_default_material();
}

This was pretty much the same as in previous, post-processing chapter.
The most intresting part is in the shader.

In macroquad there are bunch of built-in shader variables.

TODO: link to a list of builtins

For water shader we are going to use two of them:

uniform vec4 _Time;
uniform sampler2D _ScreenTexture;

_Time contains time passed since game start. _ScreenTexture is a very special texture. When material with _ScreenTexture in the shader is used for the first time in current frame, macroquad will take a "snapshot" of current active render target and will place a copy of it into _ScreenTexture.

So in the sahder we will be able to read the frame how it is rendered so far and use the frame data while rendering water mesh itself.

water.glsl

Particles system: water splash


#![allow(unused)]
fn main() {
fn new() -> Player {
    Player {
        ...
        water_particles: Emitter::new(EmitterConfig {
            emitting: false,
            lifetime: 0.8,
            lifetime_randomness: 0.5,
            initial_size: 0.3,
            initial_velocity: 50.0,
            initial_velocity_randomness: 0.3,
            initial_direction_spread: 0.5,
            gravity: vec2(0.0, 150.0),
            material: Some(ParticleMaterial::new(VERTEX, FRAGMENT)),
            ..Default::default()
        }),
    }
}
}

There are two options of using emitters: set emit speed, amount etc parameters to EmitterConfig and let emitter emit by itself.
Or just manually emit some particles in certain place.


#![allow(unused)]
fn main() {
    fn water_splash_effect(&mut self) {
        let amount = 7;

        // Spawn some particles in equally spreaded straight line
        for i in 0..amount {
            self.water_particles.emit(
                self.pos + vec2(i as f32 / amount as f32 * self.width, 0.0),
                1,
            );
        }
    }

}

Vertex shader:

#include "particles.glsl"

PARTICLE_MESH_DECL

varying lowp vec2 texcoord;
varying lowp vec4 particle_data;

void main() {
    gl_Position = particle_transform_vertex();
    texcoord = particle_transform_uv();
    
    particle_data = in_attr_inst_data;
}

Fragment shader:

#include "particles.glsl"

precision lowp float;
varying lowp vec2 texcoord;
varying lowp vec4 particle_data;

uniform sampler2D texture;

void main() {
    // particle_ix is uniquad id of each particle
    float randomize_initial_color = 0.5 + rand(vec2(particle_ix(particle_data), 0)) * 0.5;

    // particle_lifetime is 0..1 value with 0 at the beginning of particle life and 1 just before particle removal
    float fade_during_lifetime = 0.5 + (1.0 - particle_lifetime(particle_data));

    gl_FragColor = texture2D(texture, texcoord) * randomize_initial_color * fade_during_lifetime;
}

Profiling

It may happen that after some change the game start to feel laggy. While the problem may be platform-dependent and hard to trace, this chapter will cover some first steps to take to figure what is going on and what exactly leaded to that slowdown.

First step: measure FPS.

warn!("{}", macroquad::time::get_fps());

will do the job, but it may be more convinient to draw the FPS on the screen:

draw_text(&warn!("{}", macroquad::time::get_fps()), 20.0, 20.0, 20.0, DARKGRAY);

Most platforms have some sort of v-sync mechanism: hardware FPS lock that should be equal to monitor refresh rate. So if the FPS is stable 59 or 60 - everything is fine, the game is working fast enough.

Second step: check macorquad's internal telemetry

Macroquad have macroquad::telemetry module that gives and access to some timing data from the last frame. It is quite a lot of data and visiaulising it may be tricky. macroquad-profiler is a crate that works as an ingame visualiser of telemetry data.

Add macroquad-profiler to cargo.toml:

[dependencies]
macroquad-profiler = "0.1"

And later on somewhere in the main loop:


#![allow(unused)]
fn main() {
loop {
    ...

    macroquad_profiler::profiler(Default::default());
}
}

fps

This will draw a nice FPS counter with a frame time history graph. That counter is clickable and will give some additional info on the frame data.

profiler

The frame graph here is clickable and allows to check the timings inside a frame with a spike.

There are two cases now:

  • "draw" event handler (where user game loop lives) takes too much time
  • profiler will tell that everything is fine, but fps still low

Third step: if slowdown is in update code

It is possible to help macorquad's telemetry to specialize where exactly is the slowdown.
Related functions:


#![allow(unused)]
fn main() {
telemetry::begin_zone();
telemetry::end_zone();
telemetry::ZoneGuard::new();
}

Usage example:


#![allow(unused)]
fn main() {
loop {
  {
    let _z = ZoneGuard::new("input handling");    
    ..
  }
  {
    let _z = ZoneGuard::new("some heavy computation");
    {
        let _z = ZoneGuard::new("suspicions sub-section of heavy computation");
        ..
    }
    ..
  }
  {
    let _z = ZoneGuard::new("some other heavy computation"); 
    ..
  }
  next_frame().await;
}
}

Now the profiler will show the frame breakdown in a more detailed way. It is possible to click on the spike in the frame time history and check what exaclty was going on.

profiling

On that GIF it is clear that "some other heavy computation" is responsible for all the spikes. But it is a synthetic example with some hardcoded Thread::sleep, in reality it may be way less determenistic.

Step four: if profiler says that everything is allright, but fps is still low

None of the event handlers took too much time and everything should works like a charm, but FPS is still low. Unfortunately this also may happen.

FPS is measured as a difference between frames timestamps. It is a total time between two .draw() events.
Profiler timeframes are about time spent in different event handlers.
However it is possible that platform just do not send a new .draw() and just doing something.
This should never happens on PC, but it may be the case on Web and some mobiles.

Known pitfalls:

Firefox/linux webgl blit function on high-res displays.

After WebGL is done with a frame firefox spend some enormous amount of time to blit the canvas data onto the web page. This time is not tracked as a part of glFlush/glFinish and cant be tracked with GPU timer queries. It just happens between the frames.

However there are some ways to check where firefox spend time between requestAnimationFrame calls:

Firefox's "Perfomance" tab is not really helpful here, but profiler.firefox.com gives some clues: profiler-ff

But the best tool to figure whats going on is "layers.acceleration.draw-fps" from about:config profiler-ff

That is known firefox issue and going to be eventually solved.
However the tools and profiling methods may be applied for other slowdowns as well.