Skip to content

FilterRegistry — API Reference

The registry that maps filter names (used in .zstyle's filter:) to filter definitions and their rendering pipelines.

  • Header: FilterRegistry.hpp
  • Namespace: ZenitUI

For usage examples, see Effects. For the render pipeline that invokes filters, see Render Pipeline.


1. Overview

A filter is a post-processing pass applied to a subtree. The subtree is rendered into a render target (a "layer"), then the filter's pipeline processes that target and composites the result onto the framebuffer.

The registry holds filter definitions — named descriptors that tell the framework:

  • Which shader to use.
  • What pattern (single-pass, separable, silhouette, custom) to run.
  • How to map CSS arguments to shader uniforms.

A .zstyle rule references a filter by name:

.card {
    filter: blur(4px);
}

The FilterRegistry looks up "blur", gets its FilterDef, and runs the pipeline.


2. FilterPattern

enum class FilterPattern {
    SinglePass,   // push shader, draw src → region
    Separable,    // 2 pass (H, V) with a direction uniform
    Silhouette,   // shadow color, two draws (shadow then src)
    Custom        // arbitrary lambda
};
Pattern Passes Description
SinglePass 1 A single draw with the shader. The framework sets texSize.
Separable 2 Horizontal then vertical pass, with direction = {1,0} and {0,1}. Uses layerScratch_ for the intermediate.
Silhouette 2 draws Draws the subtree offset (colored by the shader), then the original on top.
Custom N The registered lambda runs with full control.

3. FilterParamType

enum class FilterParamType {
    Float,
    Vec2,
    Vec3,
    Vec4,
    Color
};

The type of a shader uniform, and how many CSS arguments it consumes.

Type Consumes Set with
Float 1 arg setEffectFloat
Vec2 2 args setEffectVec2
Vec3 3 args setEffectVec3
Vec4 4 args setEffectVec4f
Color 1 arg (#RRGGBB[AA]) setEffectVec4

Vec3 and Vec4 are set via setEffectVec3 / setEffectVec4f. Their float arguments are passed through unchanged.


4. FilterParam

struct FilterParam {
    std::string     uniform;
    FilterParamType type{FilterParamType::Float};
    int             argIndex{0};
    float           fdef{0.0f};      // default for Float
    Vec2            vdef{};          // default for Vec2
    Color           cdef{Colors::White}; // default for Color
};

Describes one uniform to set from the CSS arguments.

Field Description
uniform The uniform name in the shader.
type The type (see §3).
argIndex Which CSS argument to read (0-based).
fdef / vdef / cdef Default values per type.

Constructors

// Float
FilterParam(std::string u, FilterParamType t, int ai, float fd);

// Color
FilterParam(std::string u, FilterParamType t, int ai, Color cd);

Only these two convenience constructors exist. For Vec2 / Vec3 / Vec4, use aggregate initialization:

FilterParam{.uniform = "dir", .type = FilterParamType::Vec2,
            .argIndex = 0, .vdef = {1.0f, 0.0f}}

Example

For a blur(radius) filter:

FilterParam{"radius", FilterParamType::Float, 0, 4.0f}
  • radius — the shader uniform.
  • Float — a single float.
  • 0 — read the CSS arg at index 0 (4px in blur(4px)).
  • 4.0f — the default if no arg is given.

5. FilterDef

struct FilterDef {
    std::string              name;
    std::string              shader;
    FilterPattern            pattern{FilterPattern::SinglePass};
    std::vector<FilterParam> params;
    FilterCustomFn           custom;   // used if pattern == Custom
};

A registered filter descriptor.

Field Description
name The name used in .zstyle ("blur").
shader The asset name registered with IAssetProvider ("blur").
pattern The pipeline pattern.
params The uniform bindings.
custom The lambda for Custom patterns.

The name and shader are often the same string, but they're independent — you can have a filter named "soft-shadow" that uses a shader named "dropShadow".

Example

FilterDef{
    .name    = "sepia",
    .shader  = "sepia",
    .pattern = FilterPattern::SinglePass,
    .params  = {
        {"amount", FilterParamType::Float, 0, 0.7f}
    }
}

6. FilterContext

struct FilterContext {
    IRenderer*       renderer{nullptr};
    Layout*          node{nullptr};
    TargetHandle     src{};
    TargetHandle     scratch{};
    TargetHandle     write_target{};
    Rect             region{};    // where to draw on the framebuffer
    Rect             dst{};       // {0, 0, tw, th}: target coordinates
    float            opacity{1.0f};
    const FilterRef* ref{nullptr};
};

The context passed to a filter's pipeline. Contains everything the filter needs to process and composite the layer.

Field Description
renderer The active IRenderer.
node The filtered Layout node.
src The render target holding the subtree.
scratch A second target of the same size, for intermediate passes.
write_target Where the filter should write its result. If valid, render into this target using pushTarget / popTarget. If invalid, this is the last filter in the chain: write to region on the framebuffer, applying opacity.
region The destination rect on the framebuffer (in logical pixels).
dst {0, 0, tw, th} — the target's coordinate space.
opacity The node's computed opacity (parent × own).
ref The FilterRef from the CSS filter: list (name + args).

Coordinate systems

region is where the filter should draw on the screen. It's the intersection of the node's transformed bounding box and the current clip, in logical pixels.

dst is the target's coordinate space, starting at (0, 0). It's used to set the texSize uniform (which should be the target's dimensions) and as the destination rect when rendering into a target.

write_target decides where the result goes. In a single-filter node, it's invalid — the filter writes directly to the framebuffer at region with opacity applied. In a multi-filter chain, every filter except the last receives a valid write_target and must render into it at full opacity; the last filter writes to the framebuffer. See Render Pipeline §4.5 for the ping-pong mechanism.

Usage

Every applyFilter variant reads from ctx.src and writes to either ctx.write_target (if valid) or the framebuffer at ctx.region (if not). The scratch target is used only by Separable patterns for the H-pass intermediate. opacity is applied only when writing to the framebuffer — intermediate writes to write_target use full opacity.


7. FilterCustomFn

using FilterCustomFn = std::function<void(const FilterContext&)>;

The signature of a custom filter. Called with a FilterContext and expected to do the whole pipeline.

The custom function has full control — it can push targets, effects, set uniforms, and draw. It's the escape hatch for filters that don't fit the standard patterns.

Custom filters must honor ctx.write_target. When it's valid, the filter is not the last in the chain: render into the target (push it, draw, pop it) and don't apply opacity. When it's invalid, the filter is the last one: render to the framebuffer at ctx.region, applying ctx.opacity.

A Custom filter that ignores write_target and always writes to the framebuffer will produce a correct result only when it's the last (or only) filter. In a chain it will short-circuit the composition.


8. FilterRegistry

class FilterRegistry {
public:
    static FilterRegistry& get();

    void add(FilterDef def);
    void addCustom(std::string name, FilterCustomFn fn);
    const FilterDef* find(const std::string& name) const;

private:
    std::unordered_map<std::string, FilterDef> defs_;
};

A singleton registry of filter definitions.

get()

static FilterRegistry& get();

Returns the global instance. Thread-safe initialization.

add(def)

void add(FilterDef def);

Registers a filter. If the name already exists, it's silently overwritten. Useful for overriding built-ins.

FilterRegistry::get().add(FilterDef{
    .name    = "sepia",
    .shader  = "sepia",
    .pattern = FilterPattern::SinglePass,
    .params  = {{"amount", FilterParamType::Float, 0, 0.7f}}
});

addCustom(name, fn)

void addCustom(std::string name, FilterCustomFn fn);

Convenience wrapper for a Custom-pattern filter.

FilterRegistry::get().addCustom("invert", [](const FilterContext& ctx) {
    // custom pipeline
});

Equivalent to:

add(FilterDef{
    .name = name,
    .pattern = FilterPattern::Custom,
    .custom = std::move(fn)
});

find(name)

const FilterDef* find(const std::string& name) const;

Looks up a filter by name. Returns nullptr if not found.

if (const auto* def = FilterRegistry::get().find("blur")) {
    // def is valid
}

The returned pointer is stable as long as the registry isn't modified (no add / addCustom calls). In practice, filters are registered at startup and then read-only.


9. registerBuiltinFilters

void registerBuiltinFilters();

Registers the built-in filters (blur, drop-shadow). Idempotent — calling multiple times has no effect after the first.

Called automatically by Layout::drawLayer before applying a filter chain. So the built-ins are always available, even if the user never calls registerBuiltinFilters explicitly.

blur

reg.add({
    .name    = "blur",
    .shader  = "blur",
    .pattern = FilterPattern::Separable,
    .params  = {
        FilterParam{"radius", FilterParamType::Float, 0, 4.0f}
    }
});
Arg Type Default Meaning
radius float (px) 4.0 Blur radius.

Requires a blur shader registered via IAssetProvider:

assets.loadEffect("blur", "assets/blur.fs");

drop-shadow

reg.add({
    .name    = "drop-shadow",
    .shader  = "dropShadow",
    .pattern = FilterPattern::Silhouette,
    .params  = {
        FilterParam{"dx", FilterParamType::Float, 0, 2.0f},
        FilterParam{"dy", FilterParamType::Float, 1, 2.0f},
        FilterParam{"shadowColor", FilterParamType::Color, 2,
                    Color{0, 0, 0, 180}}
    }
});
Arg Type Default Meaning
dx float (px) 2.0 Horizontal offset.
dy float (px) 2.0 Vertical offset.
shadowColor color #000000B4 Shadow color.

Requires a dropShadow shader:

assets.loadEffect("dropShadow", "assets/drop_shadow.fs");

10. applyFilter

void applyFilter(const FilterDef& def, const FilterContext& ctx);

Runs the pipeline for a given filter definition. Called by Layout::drawLayer for each filter in the node's filters list.

Handles:

  • Setting uniforms via setParams.
  • Dispatching to the pattern-specific pass function.
  • Falling back to a plain drawTarget if the shader isn't valid.

Dispatch

switch (def.pattern) {
case FilterPattern::SinglePass: applySinglePass(ctx, fx);   break;
case FilterPattern::Separable:  applySeparable(ctx, fx, def); break;
case FilterPattern::Silhouette: applySilhouette(ctx, fx, def); break;
case FilterPattern::Custom:     def.custom(ctx);            break;
}

The Custom pattern doesn't require a shader — the lambda receives the context directly.

Fallback

If the shader name resolves to an invalid handle (shader not loaded), the function logs a warning and draws the target unmodified:

if (!fx.valid()) {
    ctx.renderer->drawTarget(ctx.src, ctx.region,
                             Colors::White.withAlpha(ctx.opacity));
    return;
}

This means a missing shader is a silent failure (visually, the subtree renders without the filter). Check that shaders are loaded before relying on a filter.


11. Complete examples

Registering a single-pass filter

assets.loadEffect("sepia", "assets/sepia.fs");

FilterRegistry::get().add(FilterDef{
    .name    = "sepia",
    .shader  = "sepia",
    .pattern = FilterPattern::SinglePass,
    .params  = {
        {"amount", FilterParamType::Float, 0, 0.7f}
    }
});
.photo {
    filter: sepia(0.8);
}

Registering a custom filter

FilterRegistry::get().addCustom("invert-blur",
    [](const FilterContext& ctx) {
        auto r = ctx.renderer;

        // Blur the src into scratch
        r->pushTarget(ctx.scratch);
        r->pushEffect(assets.getEffect("blur"));
        r->setEffectVec2(assets.getEffect("blur"), "texSize",
                         {ctx.dst.width, ctx.dst.height});
        r->setEffectVec2(assets.getEffect("blur"), "direction", {1, 0});
        r->drawTarget(ctx.src, ctx.dst, Colors::White);
        r->popEffect();
        r->popTarget();

        // Draw scratch back, inverted (example)
        r->pushEffect(assets.getEffect("invert"));
        r->drawTarget(ctx.scratch, ctx.region,
                      Colors::White.withAlpha(ctx.opacity));
        r->popEffect();
    });

Overriding a built-in

// Increase the default blur radius
FilterRegistry::get().add(FilterDef{
    .name    = "blur",
    .shader  = "blur",
    .pattern = FilterPattern::Separable,
    .params  = {
        FilterParam{"radius", FilterParamType::Float, 0, 8.0f}
    }
});

Now filter: blur (without args) uses radius 8 instead of 4.

Using multiple filters

.card {
    filter: blur(2px), sepia(0.5);
}

Produces two FilterRef in the node's style. drawLayer runs applyFilter for each, in order. Each filter reads the original layerTarget_ — they don't chain. See Effects §1.5 for the caveat.


12. Shader contract

A filter shader is a fragment shader. It receives:

  • The source texture — as texture0 (Raylib convention).
  • texSize — a vec2 with the target's dimensions.
  • direction — a vec2 (only for Separable patterns).
  • Any uniforms declared in the FilterDef::params.

The framework sets texSize automatically before calling the shader. For Separable, it sets direction as well.

Minimal sepia.fs

#version 330

in vec2 fragTexCoord;
in vec4 fragColor;
out vec4 finalColor;

uniform sampler2D texture0;
uniform vec2 texSize;
uniform float amount;

void main() {
    vec4 c = texture(texture0, fragTexCoord);
    float r = c.r * 0.393 + c.g * 0.769 + c.b * 0.189;
    float g = c.r * 0.349 + c.g * 0.686 + c.b * 0.168;
    float b = c.r * 0.272 + c.g * 0.534 + c.b * 0.131;
    vec3 sepia = vec3(r, g, b);
    finalColor = vec4(mix(c.rgb, sepia, amount), c.a) * fragColor;
}

Minimal blur.fs (separable)

#version 330

in vec2 fragTexCoord;
in vec4 fragColor;
out vec4 finalColor;

uniform sampler2D texture0;
uniform vec2 texSize;
uniform vec2 direction;
uniform float radius;

void main() {
    vec2 texel = 1.0 / texSize;
    vec4 sum = vec4(0.0);
    float total = 0.0;
    for (float i = -radius; i <= radius; i += 1.0) {
        float w = 1.0 - abs(i) / (radius + 1.0);
        sum += texture(texture0, fragTexCoord + direction * texel * i) * w;
        total += w;
    }
    finalColor = (sum / total) * fragColor;
}

The shader is loaded with assets.loadEffect(name, path), which uses Raylib's LoadShader. The vertex shader defaults to Raylib's standard one unless you pass an explicit vsPath.


13. Common tasks

Registering a new filter at startup

Add the loadEffect call (to load the shader) and the add call (to register the filter) in main, before the first frame:

assets.loadEffect("sepia", "assets/sepia.fs");
FilterRegistry::get().add(FilterDef{
    .name    = "sepia",
    .shader  = "sepia",
    .pattern = FilterPattern::SinglePass,
    .params  = {{"amount", FilterParamType::Float, 0, 0.7f}}
});

Adding a filter dynamically

You can register filters at any time, but only effects applied to nodes that are drawn after the registration will pick them up. A node that's already mid-layer (in drawLayer) uses the registry's state at the time of the lookup.

In practice, register filters at startup. The registry isn't designed for dynamic mid-frame changes.

Listing registered filters

The registry has no public iteration API. To inspect the registered filters, you'd need to add a method or hold your own list of names.

Unregistering a filter

There's no remove method. To "remove" a filter, add a new one with the same name and a no-op shader or a fallback pattern.

In practice, the registry is populated once and not modified.


14. Pitfalls

Missing shader → silent fallback.

If the shader name isn't registered with IAssetProvider, applyFilter draws the target unmodified. No error is visible to the user (only a warning in the log). If a filter "does nothing", check the log and verify the shader was loaded.

Missing filter name → silent fallback.

If a .zstyle rule has filter: unknown, drawLayer calls registry.find("unknown"), gets nullptr, and draws the target unmodified. Same silent behavior.

The registry is a singleton.

FilterRegistry::get() returns the same instance from every call site. Filters registered in one test can affect subsequent tests. Reset between tests isn't provided — you'd need to clear() the registry manually (there's no public API for it, so this requires modifying the class or using a fresh process).

Multiple filters don't chain.

Each filter in a filters list reads the original target. See Effects §1.5. To compose, use a Custom filter that runs the whole pipeline.

Rotation is ignored in region computation.

drawLayer doesn't account for rotation when computing the region. A rotated filtered node has its layer computed as if it weren't rotated.

The render target has a size limit.

MAX_TARGET_DIM = 4096. A filtered node larger than this falls back to inline rendering (no filter). If you see a large node lose its filter, this is why.

Nested filters are unsupported on the Raylib backend.

!renderer->inTarget() is part of the isLayer check. A filtered node inside another filtered node falls back to inline. The inner filter is silently ignored.

FilterDef::custom is only used for Custom pattern.

If you set custom on a SinglePass filter, it's ignored. The pattern determines which code path runs.

Shader uniforms that don't exist are silent no-ops.

setEffectFloat etc. check the shader's uniform location. If the uniform doesn't exist in the shader, the location is -1 and the call does nothing. No error, no warning. A typo'd uniform name silently does nothing.

FilterContext::ref can be null.

It's set by drawLayer before calling applyFilter, but a Custom filter that's invoked outside the standard flow (e.g. from a test) might not have it. Check before dereferencing.

FilterRegistry::find returns a pointer into the internal map.

The pointer is valid until the next add / addCustom call (which can rehash the map). Don't hold the pointer across registration calls.

In practice, you look up and use the pointer immediately, which is safe.

The built-in filters are registered lazily.

registerBuiltinFilters is called by drawLayer the first time a filter is applied. If you inspect the registry before the first frame, blur and drop-shadow might not be there yet. Call registerBuiltinFilters() explicitly if you need them available before the first frame.

No filter parameters from C++.

A filter's parameters are all defined in the FilterDef and read from the CSS arguments. There's no way to set a uniform from C++ for a specific node — the node's .zstyle determines the filter's arguments.

To control a filter parameter from C++, use an imperative animation that updates a uniform via setEffectFloat, or define the parameter as a CSS custom property.

The filter runs on every frame.

A filtered node's layer is re-rendered every frame. There's no caching of the target between frames (except for the size). This means a filtered node is more expensive than an unfiltered one, even if the content hasn't changed.

For static content, consider baking the filter into a texture offline, or caching the result manually.

Layout::drawLayer doesn't call FilterRegistry::get() every frame.

Wait — actually it does:

auto& registry = FilterRegistry::get();

This is a lookup, but it returns a reference to a static instance, so it's cheap. The cost is in find, which is a unordered_map lookup per filter per frame.

For a node with 2 filters drawn every frame, that's 2 hash lookups per frame. Negligible.

Adding a filter with the same name as a built-in overrides it.

If you register a filter named "blur" after registerBuiltinFilters has run, your definition replaces the built-in. This is useful for customizing the default, but could also silently break code that expects the built-in behavior.

The registry is not thread-safe.

add / find / addCustom don't lock. The registry is meant to be populated at startup and then read-only. Concurrent registration from multiple threads isn't supported.


15. See also

  • Effects — user-facing guide to filters and effects.
  • Render Pipeline — where drawLayer and applyFilter fit in the frame.
  • Backend — IRenderer, IAssetProvider, and the shader loading interface.
  • ZStyle — the filter: property grammar.