Skip to content
Comfy Engine

Comfy 0.4 sprite culling and 4x faster rendering, explained

Comfy 0.4 sprite culling and 4x faster rendering, explained

Comfy 0.4 came out on 3 May 2024, and it is a release about speed and small comforts rather than headline features. The open-source 2D Rust engine reports that its sprite drawing is now roughly four times faster: by the project's own rough benchmark, about 120,000 sprites at 60 FPS on the maintainer's machine, where the previous version managed around 15 FPS with the same scene. Alongside that come built-in sprite culling, a SpatialHash helper, runtime screenshot capture and a long tail of quality-of-life changes.

The maintainers are careful to call the benchmark figure a very rough estimate, and I will treat it that way. What interests me more is the culling, because the release notes say something that every engine user should hear: the engine's culling is a free speed-up, but your own culling will usually be better.

What changed, briefly

The bigger items in the changelog:

  • much faster sprite batching, the source of the 4x figure;
  • sprite culling for draw_sprite_pro, Sprite and AnimatedSprite, on by default;
  • a SpatialHash helper for game-side spatial queries;
  • a screenshot history you can record during play and save later;
  • an enable_child_transforms switch on GameConfig to skip parent and child transform updates when you do not use them;
  • target_framerate can now change during gameplay, not only at start-up;
  • mouse_input_this_frame() and mouse_moved_this_frame(), handy for switching prompts between mouse and gamepad;
  • an upgrade to newer wgpu, winit and egui versions, plus a configurable GPU power preference.

A few things went away, too. An implicit pause that toggled when Esc was pressed was removed as an oversight, and some gameplay components that had leaked into the engine (health and damage types) were dropped. The notes point out they are trivial to define yourself.

How engine-side culling works

Culling means not drawing what the camera cannot see. In Comfy 0.4, each sprite draw is checked against the main camera's viewport before it goes into a batch. The check is deliberately conservative, so a sprite near the edge is kept rather than risking a visible pop. You can toggle it at runtime with set_cull_sprites(false) and read the current state with get_sprite_culling, which is useful for a debug panel.

In code, the idea is just a rectangle overlap test. This is an illustration, not Comfy's internal implementation:

#[derive(Clone, Copy)]
struct Rect { min: (f32, f32), max: (f32, f32) }

fn overlaps(a: Rect, b: Rect) -> bool {
    a.min.0 <= b.max.0 && a.max.0 >= b.min.0
        && a.min.1 <= b.max.1 && a.max.1 >= b.min.1
}

fn sprite_bounds(pos: (f32, f32), half: (f32, f32), margin: f32) -> Rect {
    Rect {
        min: (pos.0 - half.0 - margin, pos.1 - half.1 - margin),
        max: (pos.0 + half.0 + margin, pos.1 + half.1 + margin),
    }
}

The margin is what makes the test conservative. A rotated sprite's corners can poke beyond its unrotated half size, so padding the bounds avoids clipping it at the screen edge.

Why game-side culling wins

The release notes make a point that is easy to skim past. Comfy cannot do the optimal thing, because it only learns about a sprite at the moment you call draw_sprite. By then your game has already looped over the entity, looked up its components, computed its position and built the call. The engine can skip the GPU work, but you have already paid for everything before it.

Some numbers make this concrete. Say a level holds 40,000 decorative sprites, and the camera sees about 2,000 of them at any time.

Approach Draw calls made by game code Bounds tests Sprites batched
No culling 40,000 0 40,000
Engine culling 40,000 40,000 about 2,000
Game culling with a spatial query about 2,000 a few hundred cells about 2,000

Engine culling saves the batching and GPU cost of 38,000 sprites, which is a real win for no effort. But your loop still runs 40,000 times. Game-side culling, using a structure that already knows where things are, never touches most of those entities. That is why the notes suggest that games which really need performance should skip calling draw_sprite for off-screen objects themselves.

Spatial hashing in a few lines

The tool for that job is a spatial hash, and Comfy 0.4 ships one as SpatialHash. The maintainers describe their version as far from optimal but fast enough for most uses, which is the honest description of nearly every spatial hash.

The idea: divide the world into square cells, say 64 by 64 units. Each object is stored in the cell, or cells, its bounds touch. To ask what is inside the camera rectangle, you compute which cells the rectangle covers and only look in those. Here is a minimal version in plain std Rust, illustrative and not Comfy's API:

use std::collections::HashMap;

struct Grid { cell: f32, cells: HashMap<(i32, i32), Vec<u32>> }

impl Grid {
    fn key(&self, x: f32, y: f32) -> (i32, i32) {
        ((x / self.cell).floor() as i32, (y / self.cell).floor() as i32)
    }

    fn insert(&mut self, id: u32, x: f32, y: f32) {
        let k = self.key(x, y);
        self.cells.entry(k).or_default().push(id);
    }

    fn query(&self, min: (f32, f32), max: (f32, f32), out: &mut Vec<u32>) {
        let (x0, y0) = self.key(min.0, min.1);
        let (x1, y1) = self.key(max.0, max.1);
        for cx in x0..=x1 {
            for cy in y0..=y1 {
                if let Some(ids) = self.cells.get(&(cx, cy)) {
                    out.extend_from_slice(ids);
                }
            }
        }
    }
}

This stores each object by its centre only, which is fine when objects are smaller than a cell and you pad the query rectangle by half the largest object size. Static decoration goes in once at level load. Moving objects need removing and reinserting when they change cell, which is where real implementations get fiddly.

Picking the cell size is the main tuning knob. With a 1280 by 720 view and 64 unit cells, the camera covers about 21 by 12 cells, so roughly 250 lookups per frame. Make the cells tiny and you do thousands of lookups; make them huge and each cell holds too many objects to be selective. A cell a few times larger than your typical sprite is a sensible starting point.

Screenshot history

The screenshot capture is aimed at a very real problem: the best moment for a store page or trailer always happens when you are not recording. With record_screenshots enabled on the renderer's screenshot parameters, Comfy keeps a rolling history of frames, taken every n frames up to a set length, and save_screenshots_to_folder writes them out. So you play, see something good, and save the last few seconds after the fact.

The notes are frank about the cost: around 20 milliseconds per frame at full HD on the maintainer's machine. At 60 FPS your whole budget is 16.7 milliseconds, so this is a development tool only. Gate it behind a debug flag and never ship it switched on. It is marked experimental.

A change of backend ahead

The release also announces that 0.4 is planned to be the last Comfy version on wgpu. A port to macroquad as the rendering backend is in progress. The original reason for a custom wgpu renderer was HDR, which needed f16 textures that macroquad did not support at the time; that has since changed. The expected gains are wider device support and a much smaller renderer codebase. Drawing APIs are meant to stay the same, with custom shaders the likeliest thing to need changes. The maintainers also said they will no longer be using Rust for their own games, while stating clearly that the engine is not being abandoned.

The things that keep Comfy distinct from using macroquad directly are listed too: y-sorting, a z-index on every draw call, animated sprites, particles, 2D lighting and the bundled egui, LDtk and hecs integrations, most of which were present from the first release onwards and are covered on the features page. The custom shader work from 0.3 is the part to watch during the port.

If you are on 0.3, upgrade, then try one experiment: put a counter on your draw calls and compare the frame time with set_cull_sprites on and off in your busiest scene. If engine culling saves a lot, that is your cue to add a spatial query on the game side and stop issuing those calls at all.

Filed in Engines & Architecture · Graphics & Shaders

Written by Kasimir Lund

Kasimir writes about Rust for games: engine internals, ownership patterns that survive a real game loop, and getting builds onto other people's machines. He has been writing small engines for himself since the SDL days and lives in Gothenburg.

Keep reading