Hazel

Post-Processing Effects

A full-screen camera effect is a small Rust struct plus a WGSL shader. Since alpha7 it is also a standalone plugin: it links no Bevy, builds with any toolchain in about a second, and hot-reloads — shader included — while the editor runs.

Every effect the engine ships lives in plugins/. There are 53 of them and they are all the same three files.

The three files

Taking plugins/ascii verbatim.

1. Cargo.toml

[package]
name = "ascii"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
renzora_plugin = "0.1"

One dependency. No Bevy, no workspace, no engine checkout.

2. src/lib.rs

use renzora_plugin::prelude::*;

const WGSL: &str = include_str!("ascii.wgsl");

#[derive(Component)]
#[repr(C)]
pub struct Ascii {
    #[field(min = 2.0, max = 32.0, speed = 0.5)]
    pub char_size: f32,
    #[field(min = 0.0, max = 1.0, speed = 0.01)]
    pub color_mix: f32,
    #[field(min = 0.5, max = 3.0, speed = 0.01)]
    pub contrast: f32,
}

impl Default for Ascii {
    fn default() -> Self {
        Self { char_size: 8.0, color_mix: 0.5, contrast: 1.2 }
    }
}

pub struct AsciiPlugin;

impl Plugin for AsciiPlugin {
    fn build(&self, app: &mut App) {
        app.add_post_process::<Ascii>("ascii", WGSL, RenderPhase::LdrPost, 0.0);
    }
}

renzora_plugin::add!(AsciiPlugin);

add_post_process::<T>(id, wgsl, phase, order) is the whole registration:

ArgumentMeaning
idStable name, used for the pass and for shader hot-reload
wgslThe shader source. include_str! rather than an embedded asset, so editing the .wgsl is a source change the watcher already sees
phaseWhere in the frame it runs — see below
orderSort key within the phase; lower runs first

The struct is registered as an ordinary plugin component, so it appears in the inspector's add-component list with one control per field, laid out from the #[field] ranges. #[field(skip)] keeps a value in the struct and out of the inspector — plugins/sepia uses it for tone weights the shader reads every pixel but nobody should be dragging.

3. src/ascii.wgsl

@group(0) @binding(0) var screen_texture: texture_2d<f32>;
@group(0) @binding(1) var texture_sampler: sampler;

struct AsciiSettings {
    char_size: f32,
    color_mix: f32,
    contrast: f32,
};
@group(0) @binding(2) var<uniform> settings: AsciiSettings;

@fragment
fn fragment(@builtin(position) pos: vec4<f32>, @location(0) in_uv: vec2<f32>) -> @location(0) vec4<f32> {
    let dims = vec2<f32>(textureDimensions(screen_texture));
    let cell = vec2(settings.char_size) / dims;
    let cell_center = (floor(in_uv / cell) + 0.5) * cell;
    let cell_color = textureSample(screen_texture, texture_sampler, cell_center);

    let lum = clamp(dot(cell_color.rgb, vec3(0.299, 0.587, 0.114)) * settings.contrast, 0.0, 1.0);
    let result = mix(vec3(lum), cell_color.rgb, settings.color_mix);
    return vec4(result, 1.0);
}

The shader contract

  • It must be self-contained. A plugin's WGSL is compiled without naga_oil, so #import is not available — it is not valid WGSL, and the import path a Bevy-linked effect used doesn't exist here. Declare the fullscreen inputs yourself, as above. Anything you would have imported, paste in.
  • Write the @fragment entry point only, named fragment. The vertex stage is the engine's fullscreen triangle. Its output arrives as @builtin(position) plus @location(0) UV — name that second parameter something other than uv if the body wants uv as a local, which is why the shipped effects call it in_uv.
  • All bindings are @group(0): 0 is the source colour texture, 1 its sampler, 2 the settings uniform. Binding 2 is required — a shader that declares no uniform there is rejected at registration with that message rather than failing later in the pipeline cache.
  • The WGSL struct mirrors the Rust struct in order and size. Field names are arbitrary. There is no padding to count and no trailing enabled flag: write exactly the fields the effect needs, in any number, and the host rounds the uniform buffer up to a 16-byte multiple on its own. Switching an effect off is removing the component, which is what enabled used to stand in for.

Render phases

RenderPhase says where in the frame the pass runs; order sorts within it.

PhaseImageTypical use
GiHDR, after the main 3D pass, before temporal AAglobal illumination, reflections
HdrPostHDR, after temporal AAbloom, depth of field, motion blur
LdrPostLDR, after tonemappingcolour grading, stylisation, vignette
Overlayfinal, after AAletterboxing, screen transitions, UI-adjacent effects

Most stylistic effects want LdrPost. Anything that needs values above 1.0 wants HdrPost or earlier.

This is the same render composition registry engine passes use, so a plugin effect and an engine pass sort against each other in one list rather than living in separate systems.

Where the settings component goes

Put it on any entity — the effect is global, and the bridge finds the first instance in the world each frame and uploads its bytes. It does not have to be on the camera, and there is no routing table to configure. In the editor, adding the component from the inspector is all there is to it.

The consequence to know: a second entity carrying the same effect component does nothing. One effect, one set of settings.

Animated effects tick their own clock

There is no time binding and no globals uniform. The bridge uploads the settings component's bytes verbatim — it never interprets a field, so a field called time is not special and nothing in the host writes to it. An effect that animates needs a f32 in its struct and a three-line system to advance it:

#[derive(Component)]
#[repr(C)]
pub struct FilmGrain {
    #[field(min = 0.0, max = 2.0, speed = 0.01)]
    pub intensity: f32,
    #[field(skip)]
    pub time: f32,
}

fn sync_time(mut q: Query<&mut FilmGrain>, time: Res<Time>) {
    for g in &mut q {
        g.time += time.delta_secs();
        if g.time > 1024.0 {
            g.time -= 1024.0;   // keep the f32 seed small
        }
    }
}

// ...
app.add_post_process::<FilmGrain>("film_grain", WGSL, RenderPhase::LdrPost, 0.0)
    .add_systems(Update, sync_time);

#[field(skip)] only hides the value from the inspector. It does not mean "engine-driven" — reading it that way is what left several converted effects frozen at 0.0, animating nothing while their shaders faithfully sampled a clock that never moved. plugins/film_grain, plugins/pulse and plugins/ripple are the working shape.

Two details worth copying from film_grain:

  • Wrap the accumulator instead of assigning time.elapsed_secs(). An f32 second count stops resolving small steps after a few hours of uptime.
  • Feed time as its own hash axis, not as an offset added to the sample coordinate. Offsetting the coordinate translates one fixed noise field, which looks like a sheet of dirt sliding over the image rather than something regenerating in place.

Hot reload

Both halves reload. Rebuild the plugin and:

  • Editing a field's value or the shader body takes effect without a restart. The WGSL is overwritten at the handle the pipeline was built against, so the pipeline cache invalidates and recompiles.
  • Adding or removing a struct field is refused, with the reason — entities already carrying the component were allocated for the old layout. Restart to pick it up.
  • Adding a whole new effect to a plugin that already registered one also needs a restart; the render pass is installed once.

A shader that fails to compile leaves the previous one running.

Performance

  • Inactive effects cost nothing. A pass returns immediately when no entity carries its component — no pipeline bind, no pass.
  • A pipeline variant is built per target format and chosen at render time, so an effect works in both HDR and LDR views without you writing two shaders.
  • Each active effect is one fullscreen pass. Fewer, fatter shaders beat many thin ones — fold related work into a single effect where you can.

Effects that are not plugins

Two families deliberately stay in-tree, compiled against real Bevy:

FamilyExamplesWhy
Bevy built-in wrappersbloom, DOF, SSAO, SSR, motion blur, auto-exposure, atmosphere, skybox, vignette, fog, FXAA/SMAA/TAA/CASThey author user-facing settings and route a stock Bevy component onto the camera. There is no custom WGSL pass to move, and the ABI cannot express a Bevy component.
Multi-pass render-graph cratesrenzora_lumen, renzora_rt, renzora_oit, renzora_solariTheir own graph nodes, multiple passes, custom bind groups and extra textures.

The line is a single fullscreen fragment pass over the current image. If that is what your effect is, it belongs in plugins/. If it needs a second input texture, a previous-frame snapshot or its own bind-group layout, it needs a Bevy-linked crate — the ABI has no way to hand you those yet.

renzora_macros::post_process and the PostProcessEffect trait still exist for that path, but nothing in the tree uses them any more: all 53 unified effects moved to the C ABI, and the remaining in-tree effects are wrappers or graph crates that never used the macro.