Effects
ZenitUI has two systems for visual post-processing:
filter:— a chain of pixel filters applied to a subtree. The subtree is rendered into an offscreen render target, then each filter in the list processes that target. This is the general-purpose system for blur, drop-shadow, color grading, and anything else that needs the full rendered image.effect:— a single shader applied to the node's own draw calls. No render target, no ping-pong. Cheaper, but limited: the shader sees the vertices and fragments of the node's own geometry, not the composited result.
They can coexist on the same node. filter: wraps the node's subtree
in a layer and processes the whole image; effect: applies a shader to
the individual draw calls inside that layer (or directly on screen if
there's no filter).
There's a third, unrelated feature in this area: box-shadow, which
is a styling feature (drawn by renderChrome) — not a shader, no
render target, no registry. It's included here because it's visually
adjacent, but it's documented in ZStyle §4.7
as a styling property.
1. filter: — the layer pipeline
1.1 When a node becomes a layer
A node goes through the "layer" render path if all of these are true:
- Its
ComputedStyle::filtersis non-empty. - The active renderer reports
supports(Feature::Effects) == true. - Its
recthas non-zero width and height. - It's not already inside a render target
(
renderer->inTarget() == false).
The last condition is important: the Raylib backend can't nest render targets, so a filtered node inside another layer falls back to inline rendering (no filter). See §7 for the implications.
When the conditions are met, Layout::drawLayer runs:
- Computes the transformed bounding box of the node
(
rectafterscaleandtranslate). - Intersects it with the current clip rect to get the visible
region. - Ensures two render targets (
layerTarget_andlayerScratch_) are sized to the region. - Renders the subtree (chrome + content + children) into
layerTarget_. - Runs the filter chain on the target.
- Draws the result to the framebuffer at
region, with the node's opacity applied.
The render target is cached on the node and only recreated when the region size changes. This matters for performance — a filtered node that doesn't resize doesn't reallocate its targets.
1.2 Declaring a filter
.card {
filter: blur(4px);
}
Multiple filters, comma-separated:
.card {
filter: blur(2px), drop-shadow(2px, 2px, #000);
}
Syntax: <name>(<args>), where <args> is a comma-separated list of
values. Filters with no arguments are written without parentheses:
.image {
filter: grayscale;
}
The parser stores the name and the args as raw strings. Resolution
to a FilterDef happens at draw time via FilterRegistry::find(name).
If the filter name isn't registered, the layer falls back to drawing the target unmodified — the subtree renders normally, just without the effect. A missing shader is not an error.
1.3 What a filter does to the node
- The whole subtree is rasterized. Chrome, content, and children all go into the render target.
- Transforms are baked in. The node's own
translate,scale, androtationare applied inside the target, so the target contains the already-transformed image. (Actually the code applies the transform before rendering into the target, then draws the target at the transformed region.) - Rotation is approximated.
drawLayercomputes the bounding box after scale + translate, but ignores rotation for the region computation. A rotated node with a filter will have its region slightly off. This is a known limitation; rotations on filtered nodes are rare. - Clipping is intersected, not applied. The filter's
regionis the intersection of the transformed rect and the current clip. The parts of the subtree outside that region are still rendered into the target but are not drawn back to the framebuffer.
1.4 Opacity
The node's opacity × parentOpacity is passed to the final
drawTarget call as the tint's alpha. So filter: blur(4px); opacity: 0.5
results in a blurred, 50%-transparent image.
The opacity is not applied inside the target — the subtree is rendered at full opacity into the target, then the whole image is composited with the alpha. This is the correct behavior: it avoids double-blending and matches what you'd expect from CSS.
1.5 Multiple filters
Filters chain. The subtree is rendered once into layerTarget_,
then each filter in the list processes the output of the previous
filter. The result of the last filter is composited onto the
framebuffer.
.card { filter: blur(2px), sepia(0.6); }
Produces sepia(blur(original)):
- The subtree is rendered into
layerTarget_. blurreadslayerTarget_and writes its output intolayerScratch_.sepiareadslayerScratch_and writes the final result into the framebuffer.
Order matters. blur(2px), sepia(0.6) and sepia(0.6), blur(2px) are
visibly different: the first blurs the card, then tints it sepia; the
second tints the card, then blurs the tinted result. In general, f1,
f2 produces f2(f1(x)), matching CSS's semantics.
The ping-pong
The chain alternates between two render targets:
- Filter 0 reads
layerTarget_, writeslayerScratch_. - Filter 1 reads
layerScratch_, writeslayerTarget_. - Filter 2 reads
layerTarget_, writeslayerScratch_. - ...
- The last filter writes to the framebuffer at the node's region, not to a target.
A third target (layerSeparable_) exists for Separable filters (like
blur). The blur is implemented as two passes: horizontal into
layerSeparable_, then vertical into the destination. Without a third
target, the H-pass would overwrite the read target mid-computation.
Opacity
The node's opacity is applied only on the last filter, when the
result is written to the framebuffer. Intermediate passes work at full
opacity to avoid compounding alpha. This means a node with
opacity: 0.5; filter: blur(2px), sepia(0.6) produces an image that
is blurred, seppia-toned, and 50% transparent — not blurred at 50%,
then sepia'd at 50%, then composited at 50% again.
Custom filters
The FilterContext now exposes a write_target field. A Custom
filter must honor it: when write_target is valid, render into that
target (using pushTarget / popTarget) instead of the framebuffer.
When write_target is invalid, the filter is the last in the chain and
should write to ctx.region with ctx.opacity applied.
See FilterRegistry for the full
FilterContext contract.
2. effect: — a single shader
2.1 When it's used
If the node has effect: <name> set:
- In inline mode (no filter, or filter unsupported), the shader is pushed before drawing chrome + content + children, and popped after.
- In layer mode (with a filter), the shader is pushed inside the target, so it applies to the subtree but is then subject to the filter chain on the final image.
Either way, the shader sees every draw call the node makes (and its children make, unless they set their own effect).
2.2 Declaring an effect
.btn-primary {
effect: hueShift;
}
The value is a shader name registered with IAssetProvider. The name
is resolved once per style change and cached in effectName_ /
currentEffect_.
If the shader isn't registered, currentEffect_ is invalid and the
shader isn't pushed. No error, no log.
2.3 Effect vs filter
Use effect: when:
- You want a per-draw-call shader (e.g. a shader that manipulates UVs, vertex colors, or the fragment color of the node's own geometry).
- You don't need to process the composited image.
- You want the lowest overhead — no render target allocation.
Use filter: when:
- You need to blur, sample neighbors, or otherwise operate on the composited image.
- You want to affect the whole subtree, including children.
- The effect can't be expressed as a per-fragment operation.
The demo uses effect: hueShift on .btn-primary — but that's
technically misusing it, since hueShift is a per-pixel color operation
that would work better as a filter:. It works, but at the cost of
applying the shader to each draw call individually (background, border,
text), which is fine for a button but would be wrong for a complex
subtree.
3. Built-in filters
Two filters are registered by default via registerBuiltinFilters():
3.1 blur
.card {
filter: blur(4px);
}
| Arg | Type | Default | Meaning |
|---|---|---|---|
radius |
float (px) | 4.0 |
Blur radius. |
Pattern: Separable. Runs two passes (horizontal, then vertical)
using the same shader with a direction uniform. Requires the
blur.fs shader to be loaded and registered under the name blur:
assets.loadEffect("blur", "assets/blur.fs");
If the shader isn't loaded, the filter falls back to drawing the target unmodified.
3.2 drop-shadow
.card {
filter: drop-shadow(2px, 2px, #000000);
}
| Arg | Type | Default | Meaning |
|---|---|---|---|
dx |
float (px) | 2.0 |
Horizontal offset. |
dy |
float (px) | 2.0 |
Vertical offset. |
shadowColor |
color | #000000B4 |
Shadow color. |
Pattern: Silhouette. Draws a colored shadow of the subtree's
silhouette at an offset, then draws the subtree on top. This is not a
Gaussian drop-shadow — it's a solid silhouette offset. The blur is
not applied to the shadow; for a soft shadow, chain a blur after
the drop-shadow... except that chaining doesn't compose (see §1.5),
so in practice you need a custom filter for a soft drop-shadow.
Requires dropShadow.fs to be loaded and registered under the name
dropShadow:
assets.loadEffect("dropShadow", "assets/drop_shadow.fs");
3.3 Chaining blur and drop-shadow
Both built-in filters compose with each other and with custom filters. The order affects the result:
/* Blur the card, then add a sharp drop-shadow of the blurred silhouette. */
.card-a { filter: blur(2px), drop-shadow(3px, 3px, #000000cc); }
/* Add a sharp drop-shadow of the original silhouette, then blur the
whole image (shadow included). The shadow ends up soft. */
.card-b { filter: drop-shadow(3px, 3px, #000000cc), blur(2px); }
card-a produces a blurred card with a sharp shadow around it —
the shadow is applied after the blur, on the already-blurred silhouette.
card-b produces a blurred card with a soft shadow — the blur
runs last and smears the previously sharp shadow.
For a soft drop-shadow on a sharp card, the two are not enough:
drop-shadow produces a solid silhouette, not a blurred one. Chain a
blur after the shadow to soften it, but keep in mind the blur will
also affect the card itself. To blur only the shadow, write a
Custom filter that runs silhouette → blur → composite original.
See Effects §1.5 for how the chain alternates between targets.
4. Custom filters
You can register your own filters with FilterRegistry::get().add(...).
4.1 The simple case: a shader with parameters
#include "FilterRegistry.hpp"
// Load the shader once
assets.loadEffect("sepia", "assets/sepia.fs");
// Register the filter
ZenitUI::FilterRegistry::get().add(ZenitUI::FilterDef{
.name = "sepia",
.shader = "sepia",
.pattern = ZenitUI::FilterPattern::SinglePass,
.params = {
{"amount", ZenitUI::FilterParamType::Float, 0, 0.7f}
}
});
Now you can use it:
.photo {
filter: sepia(0.8);
}
The FilterDef tells the registry:
- name — the string used in
.zstyle. - shader — the asset name registered with
IAssetProvider. - pattern — how to run the passes (see §4.3).
- params — how to map CSS arguments to shader uniforms.
At draw time, the framework calls setParams, which walks params and
sets each uniform from the corresponding CSS argument (or the default if
the argument wasn't provided).
4.2 Parameter mapping
Each FilterParam describes one shader uniform:
struct FilterParam {
std::string uniform; // uniform name in the shader
FilterParamType type; // Float, Vec2, Vec3, Vec4, Color
int argIndex; // which CSS argument to read
float fdef; // default (Float)
Vec2 vdef; // default (Vec2)
Color cdef; // default (Color)
};
The argIndex is the position in the CSS argument list (0-based):
filter: sepia(0.8);
/* ^ arg 0 */
For Vec2 / Vec3 / Vec4, the param consumes that many consecutive
arguments:
{"center", FilterParamType::Vec2, 0, /* default */}
consumes args 0 and 1:
filter: myfilter(100, 200);
/* ^ ^ */
/* arg0 arg1 */
Type mapping:
| FilterParamType | Uniform setter | CSS argument parsing |
|---|---|---|
Float |
setEffectFloat |
parseLengthArg (strips px) |
Vec2 |
setEffectVec2 |
Two parseLengthArg |
Vec3 |
setEffectVec4 (alpha=1) |
Three parseLengthArg, each clamped to [0,1] and expanded to 0..255 |
Vec4 |
setEffectVec4 |
Four parseLengthArg, same as Vec3 |
Color |
setEffectVec4 |
parseColorToken |
Vec3 and Vec4 pass their float arguments through to the shader
unchanged, as a vec3 / vec4 in the natural float range. A filter
written as myfilter(0.5, 0.25, 0.75) receives vec3(0.5, 0.25, 0.75)
in the shader. No clamping or scaling is applied.
4.3 Patterns
FilterPattern decides how many passes run and what uniforms the
framework sets.
SinglePass
.pattern = FilterPattern::SinglePass
One pass: push shader, draw src → region, pop shader. The framework
sets one uniform automatically: texSize = {dst.width, dst.height}.
Your params are set before the pass. Use this for color operations (sepia, grayscale, brightness, contrast, hue shift, etc.).
{"texSize", vec2} // set automatically
{"amount", float} // set from your params
Separable
.pattern = FilterPattern::Separable
Two passes. The framework:
- Pass H: push
scratchtarget, push shader, settexSize, setdirection = {1, 0}, draw src → dst region, pop shader, pop target. - Pass V: push shader, set
texSize, setdirection = {0, 1}, draw scratch → region, pop shader.
Your shader should sample along direction using the radius (or
whatever your blur axis parameter is called). Both texSize and
direction are set by the framework; your params define the radius.
The node's layerScratch_ target is used for the intermediate pass.
It's allocated lazily and cached.
Silhouette
.pattern = FilterPattern::Silhouette
Two draws on the same pass. The framework:
- Sets
texSize. - Draws the subtree at
region + (dx, dy)— using your shader, which should color the silhouette. - Draws the subtree at
region— using your shader, which now draws the original.
The dx and dy uniforms are set from your params and used to
compute the shadow's destination rect. This is how drop-shadow works:
the shader colors the alpha, the framework offsets the destination.
Custom
.pattern = FilterPattern::Custom,
.custom = [](const FilterContext& ctx) { /* ... */ }
Full control. The framework calls your lambda with the FilterContext
and does nothing else. Use this for multi-pass pipelines that need
different shaders at each step, or for filters that need to read the
node's state.
ZenitUI::FilterRegistry::get().addCustom("invert-blur",
[](const ZenitUI::FilterContext& ctx) {
// ctx.renderer, ctx.node, ctx.src, ctx.scratch, ctx.region, ctx.dst, ctx.opacity, ctx.ref
ctx.renderer->drawTarget(ctx.src, ctx.region, Colors::White.withAlpha(ctx.opacity));
// ... your pipeline ...
});
The FilterContext:
| Field | Description |
|---|---|
renderer |
IRenderer* — the active renderer. |
node |
Layout* — the filtered node. |
src |
TargetHandle — the render target with the subtree. |
scratch |
TargetHandle — a second target, same size, for intermediate passes. |
region |
Rect — where to draw on the framebuffer (screen coords). |
dst |
Rect — {0, 0, tw, th}, the target's coordinate space. |
opacity |
float — the node's computed opacity. |
ref |
const FilterRef* — the CSS filter reference (name + args). |
ctx.ref->args is the raw CSS argument list, useful if you want to
parse them yourself.
5. Examples
5.1 Blur a card
assets.loadEffect("blur", "assets/blur.fs");
.card {
filter: blur(4px);
}
Same card, but no shader loaded:
// assets.loadEffect("blur", ...); // missing
.card {
filter: blur(4px); /* silently does nothing */
}
5.2 Register and use a sepia filter
// main.cpp
assets.loadEffect("sepia", "assets/sepia.fs");
ZenitUI::FilterRegistry::get().add(ZenitUI::FilterDef{
.name = "sepia",
.shader = "sepia",
.pattern = ZenitUI::FilterPattern::SinglePass,
.params = {
{"amount", ZenitUI::FilterParamType::Float, 0, 0.7f}
}
});
/* game.zstyle */
.demo-content .card { filter: sepia(0.8); }
Every card inside .demo-content gets a sepia tone. The .demo-content
part is important — a filter on the container itself would be applied
to the whole container (all cards at once), which produces a different
visual because the sepia pass runs on the composited image.
5.3 Hover to change a filter
Filters can be animated? Not directly — the filters list is a
std::vector<FilterRef>, and lerpProp for vectors snaps:
inline std::vector<FilterRef> lerpProp(const std::vector<FilterRef>& a,
const std::vector<FilterRef>& b,
float t) {
return t > 0.0f ? b : a;
}
So a transition: filter 0.3s has no visible effect — the filter
switches instantly at the midpoint (t > 0). To animate a filter
parameter, animate the underlying value that the shader uses, and
re-register the filter with the new value:
// Not currently supported via the CSS filter API.
// Workaround: use effect: with a shader uniform you control manually.
Or use an imperative animation to drive a uniform via onUpdate.
5.4 Custom multi-pass filter
ZenitUI::FilterRegistry::get().addCustom("soft-shadow",
[](const ZenitUI::FilterContext& ctx) {
auto r = ctx.renderer;
// Pass 1: silhouette into scratch
r->pushTarget(ctx.scratch);
r->pushEffect(assets->getEffect("silhouette"));
r->setEffectVec2(assets->getEffect("silhouette"), "texSize",
{ctx.dst.width, ctx.dst.height});
r->setEffectVec4(assets->getEffect("silhouette"), "shadowColor",
Color{0, 0, 0, 180});
r->drawTarget(ctx.src, ctx.dst, Colors::White);
r->popEffect();
r->popTarget();
// Pass 2: blur the silhouette into the framebuffer
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.scratch, ctx.region, Colors::White);
r->popEffect();
// Pass 3: draw the original on top
r->drawTarget(ctx.src, ctx.region, Colors::White.withAlpha(ctx.opacity));
});
This is the kind of thing Custom exists for. Note that accessing
UIContext::get().assets (to get the shader handle) is the way to
retrieve shaders by name inside a custom filter.
6. Shader requirements
A filter shader is a fragment shader. It receives:
- The source texture (Raylib's convention:
texture0). texSize(vec2) — the target dimensions in pixels.direction(vec2) — forSeparablepatterns.- Any uniforms declared in your
FilterDef::params.
Minimal sepia.fs (Raylib style):
#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;
}
For a Separable blur, you'd use direction to pick the axis:
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 is Raylib's default unless you
pass an explicit vsPath.
7. Limitations and known issues
7.1 No nested render targets
The Raylib backend can't push a target while already inside one. The
check in drawLayer:
const bool isLayer =
!renderStyle.filters.empty() &&
renderer->supports(Feature::Effects) &&
rect.width > 0.0f && rect.height > 0.0f &&
!renderer->inTarget(); // <-- this
If a filtered node is inside another filtered node, the inner one falls
back to drawInline — no filter applied. The subtree renders normally
without the effect.
Workaround: don't nest filters. If you need composition, use a Custom
filter on the outer node that does the whole pipeline.
7.2 Rotation ignored
drawLayer computes the transformed bounding box from scale and
translate, but not rotation. A rotated node with a filter will have
its region computed as if it weren't rotated. The filter will still run,
but the region may be slightly too small or too large. In practice,
rotating a filtered node is rare.
7.3 No FilterRef interpolation
transition: filter 0.3s has no visible effect — the filters list
snaps to the target at t > 0. To animate filter parameters, drive a
uniform via an imperative animation, or make the change instantaneous.
7.4 Render target size
The render target is sized to the intersection of the transformed rect
and the current clip. For a filtered node that fills the viewport, this
is a full-viewport target — potentially large. Memory-wise, two targets
(layerTarget_ + layerScratch_) per filtered node, allocated lazily
and cached.
There's a hard limit: MAX_TARGET_DIM = 4096. If the region exceeds
this in either dimension, the layer path falls back to drawInline:
if (tw > MAX_TARGET_DIM || th > MAX_TARGET_DIM) {
drawInline(renderStyle, globalOp);
return;
}
7.5 effect: and filter: on the same node
A node can have both properties at once. They operate at different levels of the render pipeline, and understanding which is which explains why the combination behaves the way it does.
What each one does
effect: pushes a shader around the node's own draw calls. Every
primitive the node draws (background, border, text, children) is
rasterized with the shader active. The shader sees each primitive
individually — it does not see the composited subtree image.
filter: renders the subtree into an offscreen target, then runs
the filter chain on the composited image. The filter sees the whole
subtree at once, with all its layers already blended.
What happens when both are set
- The effect shader is pushed.
- The subtree is rendered into
layerTarget_with the shader active. So each draw call inside the subtree is processed by the shader individually. - The filter chain runs on the resulting composited image.
The result: the shader is applied N times, once per draw call inside the subtree. Then the composite is filtered.
Why this is intentional
The two properties serve different purposes:
effect:is for per-primitive shaders: hue rotation on a specific shape, custom vertex manipulation, a per-fragment tint that must be applied to the node's geometry directly. When applied to a single primitive, it's cheap and predictable.filter:is for image-level effects: blur, drop-shadow, sepia, anything that needs to read neighboring pixels of the composited result.
Using them together is rare but valid — for example, a node that needs a per-primitive hue shift and a global blur on top.
When NOT to combine them
If you want a single shader to process the composited subtree, use
filter: with a Custom filter that pushes the shader. This gives
you the "one shader, whole image" semantics without the per-draw-call
multiplication.
If you want per-primitive processing only, use effect: alone. Don't
add a filter: unless you actually need image-level composition.
Cost
Both properties are more expensive than either alone. The effect adds a shader push/pop around each draw call inside the subtree; the filter adds a render target and at least one full-screen pass. Combining them means paying both costs. If you're seeing performance issues on a specific node, check whether both are actually needed.
8. Complete example
A filtered card with a custom filter:
// 1. Load the shader
assets.loadEffect("sepia", "assets/sepia.fs");
// 2. Register the filter
ZenitUI::FilterRegistry::get().add(ZenitUI::FilterDef{
.name = "sepia",
.shader = "sepia",
.pattern = ZenitUI::FilterPattern::SinglePass,
.params = {
{"amount", ZenitUI::FilterParamType::Float, 0, 0.7f}
}
});
// 3. Register a second filter (blur) as a built-in
// (or rely on the default registration in registerBuiltinFilters)
assets.loadEffect("blur", "assets/blur.fs");
/* assets/game.zstyle */
.card {
background: #2A2A32;
radius: 8px;
padding: 3vh;
filter: sepia(0.8);
}
.card:hover {
filter: sepia(0.2);
}
Hovering a card switches its filter. Because filter transitions snap, the switch is instantaneous — but since both filters produce a full render target, it's still a visible change on each frame.
If you want a smooth transition, animate a uniform instead:
auto card = VStack()->cls("card");
auto anim = std::make_shared<UIAnimation>(0.3f);
anim->addTrack<float>(0.8f, 0.2f,
[](Layout* l, float v) {
// Update the sepia filter's amount uniform
// (requires access to the filter registry and the node's effect handle)
},
TransitionFunction::EaseOut);
The workaround is verbose because the current API doesn't expose a per-node filter parameter. For 90% of cases, the snap behavior is fine.