Comfy 0.3: user shaders, glyph atlas text and a 2.5D camera

Comfy 0.3 arrived on 7 December 2023, later than its maintainers had planned. They admit, with some self-awareness, that scope creep is not only a game development problem: the release kept growing as people asked for features. The result is the biggest update to the open-source 2D Rust engine since its first release, and most of it touches rendering, which is my patch. There are user-defined fragment shaders, a new text renderer with its own glyph atlas, a perspective camera, LDtk support and, on the Rust side, the removal of lifetimes from game state.
A clear warning comes with all of it. Many of the new features are marked experimental. They work, and the maintainers are already using them, but the APIs may change. Upgrading from 0.2 is described as a matter of minutes with no expected breakage, so it is safe to try, just do not build a tower on the experimental parts yet.
No more lifetimes in game state
Before 0.3, a Comfy game typically defined a GameContext that borrowed from both the engine and the game state, which meant a lifetime parameter on the struct and on everything that touched it. The maintainers now say plainly that the initial design was wrong: it confused every new user and the benefits were small. The new comfy_game!(...) macro needs no lifetimes and no GameContext.
Why do lifetimes hurt so much in game state specifically? Here is the shape of the old problem, illustrated in plain Rust:
// Illustration: a borrowing context drags a lifetime everywhere.
struct GameState { score: u32 }
struct Engine { dt: f32 }
struct GameContext<'a> {
state: &'a mut GameState,
engine: &'a mut Engine,
}
fn update<'a>(c: &mut GameContext<'a>) {
c.state.score += 1;
}
That 'a spreads. Every helper that takes the context must name it, and any struct that wants to hold the context must carry it too. Game code is full of things that live across frames (timers, tweens, queued effects), and none of them can keep a borrowed context. With the previous release having already moved engine services to globals, as covered in the 0.2 notes, the borrowed context had very little left to justify it. Owning your state and calling engine functions directly is simpler, and the compiler stops asking questions you cannot answer.
User fragment shaders
The feature I was most curious about is user-defined fragment shaders with uniforms and hot reloading. For now, uniforms are limited to f32 values and the shaders are expected to be fairly simple, but that already covers a lot of what a 2D game wants: dissolves, hit flashes, palette pulses, water ripples, a vignette that tightens when health is low.
The idea is that the engine owns the vertex stage and the pipeline, and you supply only the part that decides each pixel's colour. Something like this, which is a generic WGSL illustration rather than Comfy's exact shader interface:
// Illustration: a hit flash driven by one f32 uniform.
@group(1) @binding(0) var<uniform> flash: f32;
@group(0) @binding(0) var tex: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;
@fragment
fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let base = textureSample(tex, samp, uv);
let white = vec3<f32>(1.0, 1.0, 1.0);
let rgb = mix(base.rgb, white, clamp(flash, 0.0, 1.0));
return vec4<f32>(rgb, base.a);
}
The game sets flash to 1.0 when the sprite is hit and lets it decay over about 0.1 seconds. One float, one mix, and the sprite reads as struck. Limiting uniforms to f32 sounds restrictive until you notice how many effects are a single animated number. A colour can be three of them.
Hot reload is the other half. Shader work is iteration: nudge a constant, look, nudge again. Restarting the game for every tweak turns a ten-minute job into an hour. The fragment shader example is the place to start, and the maintainers expect breaking changes here, so keep your shaders small.
Text rendering with a glyph atlas
Until 0.3, Comfy drew text through egui. That works for debug panels but causes subtle problems for in-game text: you are bound to egui's layout and styling, and its text lives in a different part of the frame from your sprites. The new renderer rasterises TrueType fonts with fontdue and packs the glyphs into a texture atlas with etagere.
The principle is worth spelling out. Rasterising a glyph (turning the outline curves into a bitmap at a given size) is expensive, so you do it once per glyph and size, store the bitmap in a shared texture, and from then on each character on screen is just a textured quad pointing at a small rectangle of that texture. etagere's job is to find free rectangles in the atlas as new glyphs arrive.
Comfy currently reserves a single 4096 by 4096 texture for all rasterised glyphs. Is that enough? A quick budget:
| Item | Value |
|---|---|
| Atlas area | 4096 x 4096 = 16,777,216 px |
| Glyph cell at 32 px text, with padding | about 28 x 40 = 1,120 px |
| Packing efficiency, roughly | 80% |
| Glyphs that fit | about 12,000 |
Twelve thousand is a lot of glyphs for a game with Latin text at three or four sizes: a full printable ASCII set is under a hundred characters. It gets tighter with large display sizes, many fonts or a CJK script with thousands of characters. The maintainers note they never hit the limit in testing and that a later release can lift it without breaking user code. As a single-texture design, it also means all text can batch into the same draw calls, which is good for performance.
One colour note from me: rasterised glyphs are coverage masks, so treat them as linear alpha and multiply your text colour in the shader. Tinting a glyph after an sRGB conversion produces fringes that look like bad kerning but are really bad maths.
A perspective camera, carefully described
The 2.5D and 3D support is introduced with deliberate modesty. It means a perspective camera built with glam, plus some configuration, not a 3D engine. Comfy can draw arbitrary meshes with draw_mesh_ex, and with custom fragment shaders that can go further than you might expect. But there is no instanced rendering, and meshes are uploaded to the GPU every frame. For sprites, that is fine. For a 50,000 triangle model, re-uploading every frame would eat your budget quickly.
For 2D games, a perspective camera is mostly about depth cues: parallax that falls out of geometry instead of hand-tuned factors, cards that tilt, a board seen at an angle. Use it for staging rather than for building worlds.
LDtk and a compile-time trick
Comfy now has basic support for the LDtk level editor, and the most interesting part is a build-time detail. LDtk provides a generated serde parser for its file format, and serde's derive-generated code is slow to compile. In the maintainers' testing, calling the generic deserialiser on the LDtk type inside a game crate took incremental builds from under a second to three to five seconds.
The fix is to wrap the generic call in a non-generic function inside a separate crate. Generic code is compiled where it is instantiated, so if the instantiation lives in a dependency that rarely changes, your incremental builds never pay for it again. The shape, in illustration:
// In a small separate crate: the only place serde's generics are instantiated.
pub fn parse_level(json: &str) -> Result<LdtkJson, serde_json::Error> {
serde_json::from_str::<LdtkJson>(json)
}
That brings builds back under a second. It is a pattern worth stealing for any heavy generic dependency. Comfy also adds a few extension traits on LDtk's types, though the maintainers say you should still expect to read LDtk's format documentation yourself.
What to try first
There is also experimental support for custom render targets, which will need more renderer work around ordering before it is truly useful. If you upgrade, start with the shader feature: write a one-uniform hit flash like the one above, hook the uniform to a timer, and adjust the decay curve with hot reload running until the hit feels right. That exercise touches the new pipeline, the uniform path and the reload loop in about twenty lines.
Filed in Graphics & Shaders · Rust Gamedev

Written by Odalys Brisco
Odalys covers graphics programming: wgpu, shaders, lighting and the maths behind a good-looking 2D frame. She came to it from technical art and still sketches every effect on paper before writing a line of WGSL. Based in Valencia.
