Render Pipeline
The render pipeline turns the tree of Layout nodes into draw calls on
IRenderer. It lives in Render.cpp and is driven by Layout::draw,
which recurses through the tree.
This document covers:
- The two render paths: inline and layer.
- The transform stack.
- The clip stack.
- Z-ordered child drawing.
- The portal drain at the root.
- The effect (shader) resolution and push/pop.
- The filter chain and its
IRenderercontract.
For the backend interface itself, see Backend.
1. The frame-level structure
Rendering happens in the second phase of the frame:
BeginDrawing();
ClearBackground(BLACK);
root->renderFrame();
EndDrawing();
renderFrame on the root:
void Layout::renderFrame() {
auto* r = UIContext::get().renderer;
if (!r) return;
r->beginFrame();
draw();
r->endFrame();
}
beginFrame / endFrame are the backend's opportunity to do
per-frame setup. In RaylibRenderer, beginFrame pushes a DPI scale
matrix if dpiScale_ != 1.0, and endFrame pops it. The two are
symmetric and must be paired.
draw() is called with the default opacity of 1.0f. Recursion passes
a cumulative opacity down to children.
2. draw(parentOpacity)
The entry point for each node. Let's trace it in order.
2.1 Portal registration
if (config.portal) {
UIContext::get().framePortals.push_back(weak_from_this());
return;
}
A portal doesn't draw itself where it is in the tree. It registers for later drawing at the root. See Portals for the full lifecycle.
The weak_from_this() is what makes the portal safe to destroy: if the
portal is removed between registration and the root's drain, the
weak_ptr::lock() at drain time returns null and the portal is
skipped.
2.2 Frustum culling
if (rect.width > 0.0f && rect.height > 0.0f) {
Rect clip = renderer->getClipRect();
float m = 40.0f;
if (rect.x > clip.x + clip.width + m ||
rect.x + rect.width < clip.x - m ||
rect.y > clip.y + clip.height + m ||
rect.y + rect.height < clip.y - m)
return;
}
If the node is entirely outside the current clip rect, skip it. The
40.0f margin is a safety buffer: it accounts for effects like blur,
drop-shadow, box-shadow, and rotation that can draw outside the rect.
The clip rect comes from IRenderer::getClipRect(), which returns the
current clip if any, or the full viewport otherwise. So a node inside
a scrolled container that's off-screen gets culled.
This is the primary optimization for large lists: a ScrollView with
1000 children only draws the ~15 that are visible.
2.3 Render style
ComputedStyle renderStyle = style_.currentStyle;
Anim::overlayCssComputed(renderStyle, anim_.css);
resolveEffectIfNeeded(renderStyle);
float globalOp = renderStyle.opacity * parentOpacity;
if (globalOp <= 0.001f) return;
Three steps:
- Copy the current style.
- Overlay CSS keyframe animations on it. The overlay is local:
style_.currentStyleis not modified. OnlyrenderStylesees the animated values. - Resolve the effect if the style has an
effectproperty and the resolved handle changed.
Then compute the effective opacity: this node's opacity times the
accumulated parentOpacity. If it's effectively zero, skip the whole
subtree.
2.4 Path selection
const bool isLayer =
!renderStyle.filters.empty() &&
renderer->supports(Feature::Effects) &&
rect.width > 0.0f && rect.height > 0.0f &&
!renderer->inTarget();
if (isLayer) drawLayer(renderStyle, globalOp);
else drawInline(renderStyle, globalOp);
The layer path is used only when:
- The style declares at least one
filter:. - The backend supports effects.
- The node has non-zero size.
- We're not already inside a render target.
The last condition is the Raylib limitation: the backend can't nest targets. A filtered node inside another layer falls back to inline, which means the inner filter is ignored.
2.5 Scrollbar
scroll_.drawScrollbar(*this, parentOpacity);
Drawn after the content and children. The scrollbar is on top of
everything the container draws. It uses parentOpacity, not globalOp
— the scrollbar's own opacity comes from the container's style, applied
inside drawScrollbar.
The scrollbar is not drawn in drawInline / drawLayer — it's a
separate call after them. So a filtered scroll container draws its
content into the layer and the scrollbar directly, on top.
2.6 Portal drain
if (!hasParent()) {
auto portals = UIContext::get().framePortals;
for (auto &wp : portals) {
auto sp = wp.lock();
if (!sp) continue;
bool wasPortal = sp->isPortal();
sp->setPortal(false);
sp->draw(1.0f);
sp->setPortal(wasPortal);
}
}
Only the root runs this, and only after the normal tree is done. Steps:
- Copy
framePortalsinto a local. This is important:sp->draw(1.0f)can register new portals (a portal's child might be a portal), and iterating while the vector is being modified would be UB. - For each alive portal (weak_ptr locks), temporarily clear the
config.portalflag, draw it normally, then restore the flag. - Portals are drawn at full opacity (
1.0f). The portal's ownopacityis applied inside itsdrawviarenderStyle.opacity(times 1.0).
The order is declaration order within framePortals — the order in
which portals registered themselves during the tree walk. This is
typically bottom-to-top in the tree, but a portal registered by a
later sibling is drawn after one registered by an earlier sibling.
Note: because framePortals was already drained in beginFrame
(the previous frame's content became activePortals), this drain only
sees the portals registered during this draw. And the root is drawn
before any portal is drained, so any portal that registers during
the drain is also drawn (in the local copy).
3. drawInline
The common path: draw chrome, content, and children directly to the current framebuffer.
3.1 Transform
Transform2D tr = currentTransform(renderStyle);
const bool identity =
tr.translate.x == 0.0f && tr.translate.y == 0.0f &&
tr.rotationDeg == 0.0f && tr.scale == 1.0f;
if (!identity)
renderer->pushTransform(tr);
currentTransform builds a Transform2D from the node's resolved
style:
Transform2D Layout::currentTransform(const ComputedStyle& style) const {
Transform2D tr;
tr.pivot = rect.center();
tr.translate = {
style.translateX.resolveSelfH(rect.width, rect.height),
style.translateY.resolveSelfV(rect.width, rect.height)
};
tr.rotationDeg = style.rotation;
tr.scale = style.scale;
return tr;
}
Pivot is always the node's center. translate-x / translate-y
resolve against the node's own width / height (resolveSelfH /
resolveSelfV), not the parent's.
If the transform is identity (no translate, no rotation, no scale),
skip the push/pop. This is a common case and avoids the overhead of
rlPushMatrix / rlPopMatrix.
3.2 Effects
if (hasShader && renderer->supports(Feature::Effects))
renderer->pushEffect(customEffect);
if (currentEffect_.valid() && renderer->supports(Feature::Effects))
renderer->pushEffect(currentEffect_);
Two effects can be active:
customEffect— set viasetShader(EffectHandle). This is the old API, still used byCanvasLayout. It's a "hardcoded" shader handle stored on the node.currentEffect_— resolved from the style'seffectproperty viaresolveEffectIfNeeded.
Both can be active at once, but this is unusual. setShader is
deprecated in favor of effect: in .zstyle, but not removed.
The effects are pushed in order: customEffect first, then
currentEffect_. Both are popped in reverse order at the end.
A backend that doesn't support effects ignores these calls (the
supports(Feature::Effects) check).
3.3 Clip
bool needsClip = renderStyle.overflowX != Overflow::Visible ||
renderStyle.overflowY != Overflow::Visible;
if (needsClip)
renderer->pushClip(rect);
If either axis has overflow != visible, clip to the node's rect.
The clip is pushed after the transform, so the clip rect is
transformed by the backend before being applied.
The actual clipping mechanism is backend-specific:
- In
RaylibRenderer,pushClipcallstransformClipToScreento compute the screen-space AABB of the clip rect (applying all active transforms), intersects it with the current clip, and callsBeginScissorMode. - Inside a render target,
pushClipis a no-op (the target is rendered without a scissor).
3.4 Chrome, content, children
renderChrome(globalOp, renderStyle);
renderContent(globalOp, renderStyle);
drawChildren(globalOp);
Three virtual calls in order:
renderChrome— background,box-shadow, border. Default implementation handles solid backgrounds, textures (with nine-slice), and thebox-shadowapproximation.renderContent— widget-specific: text, tracks, marks, cursors. Empty by default.drawChildren— the children, in z-order.
3.5 Teardown
if (needsClip)
renderer->popClip();
if (currentEffect_.valid() && renderer->supports(Feature::Effects))
renderer->popEffect();
if (hasShader && renderer->supports(Feature::Effects))
renderer->popEffect();
if (!identity)
renderer->popTransform();
In reverse order of push: clip, effects, transform. The symmetric push/pop is essential — the backend maintains stacks, and an unbalanced push would corrupt all subsequent draws.
3.6 Nested state
The push/pop calls are strictly nested: drawChildren recurses, and
each child does its own push/pop within the parent's pushed state. So
by the time a child draws, the transform, clip, and effects of its
ancestors are active.
A child that wants to not inherit an ancestor's effect can push another effect on top — but it can't remove the ancestor's. In practice, effects are rare enough that this isn't a problem.
4. drawLayer
Used when the node has filters. The subtree is rendered into an offscreen target, then the filter chain is applied.
4.1 Transformed bounding box
The region computation starts by computing the axis-aligned bounding
box of the node after its full transform. All three transform
components are applied: translate, scale, and rotation. The transform
is built by currentTransform(renderStyle), which uses the node's
center as pivot.
The AABB is computed by transforming the four corners of rect with
applyTransform, then taking min/max on each axis:
Vec2 p0 = applyTransform(tr, {rect.x, rect.y});
Vec2 p1 = applyTransform(tr, {rect.x + rect.width, rect.y});
Vec2 p2 = applyTransform(tr, {rect.x, rect.y + rect.height});
Vec2 p3 = applyTransform(tr, {rect.x + rect.width, rect.y + rect.height});
Rect transformedRect = {
std::min({p0.x, p1.x, p2.x, p3.x}),
std::min({p0.y, p1.y, p2.y, p3.y}),
std::max({p0.x, p1.x, p2.x, p3.x}) - <min>,
std::max({p0.y, p1.y, p2.y, p3.y}) - <min>
};
Rotation is why this matters: a 100x100 square rotated 45 degrees has an AABB of ~141x141. If the region ignored rotation, the filter result would be clipped at the corners.
applyTransform is the same function the renderer uses for the clip
AABB (transformClipToScreen in the Raylib backend) and that
hitTest uses in its inverse form (applyInverseTransform). Keeping
all three consistent guarantees that "the node's transformed area" is
computed the same way everywhere in the framework.
4.2 Region
Rect clip = renderer->getClipRect();
Rect region;
region.x = std::max(transformedRect.x, clip.x);
region.y = std::max(transformedRect.y, clip.y);
float right = std::min(transformedRect.x + transformedRect.width,
clip.x + clip.width);
float bottom = std::min(transformedRect.y + transformedRect.height,
clip.y + clip.height);
region.width = std::max(0.0f, right - region.x);
region.height = std::max(0.0f, bottom - region.y);
if (region.width < 1.0f || region.height < 1.0f) return;
The region is the intersection of the transformed rect and the current clip. It's the area of the framebuffer that the filter will end up writing to. Everything outside is clipped away.
If the intersection is degenerate, skip the layer entirely — nothing would be visible.
4.3 Target allocation
const int tw = (int)std::ceil(region.width);
const int th = (int)std::ceil(region.height);
if (tw <= 0 || th <= 0) return;
constexpr int MAX_TARGET_DIM = 4096;
if (tw > MAX_TARGET_DIM || th > MAX_TARGET_DIM) {
drawInline(renderStyle, globalOp);
return;
}
if (!layerTarget_.valid() || layerTarget_.width != tw ||
layerTarget_.height != th)
{
if (layerTarget_.valid()) renderer->destroyTarget(layerTarget_);
layerTarget_ = renderer->createTarget(tw, th);
if (!layerTarget_.valid()) return;
}
if (!layerScratch_.valid() || layerScratch_.width != tw ||
layerScratch_.height != th)
{
if (layerScratch_.valid()) renderer->destroyTarget(layerScratch_);
layerScratch_ = renderer->createTarget(tw, th);
if (!layerScratch_.valid()) return;
}
Two targets are ensured:
layerTarget_— the subtree is rendered here, then filtered.layerScratch_— for separable filters (blur), the intermediate pass.
Both are cached on the node. They're only reallocated when the region
size changes. Targets are destroyed in ~Layout.
The MAX_TARGET_DIM = 4096 hard limit falls back to inline rendering.
This is a defensive check to avoid allocating pathological targets.
4.4 Render the subtree into the target
renderer->pushTarget(layerTarget_);
Transform2D offsetTr;
offsetTr.pivot = {0.0f, 0.0f};
offsetTr.translate = {-region.x, -region.y};
Transform2D tr = currentTransform(renderStyle);
const bool identity =
tr.translate.x == 0.0f && tr.translate.y == 0.0f &&
tr.rotationDeg == 0.0f && tr.scale == 1.0f;
if (!identity) renderer->pushTransform(tr);
renderer->pushTransform(offsetTr);
if (hasShader && renderer->supports(Feature::Effects))
renderer->pushEffect(customEffect);
if (currentEffect_.valid() && renderer->supports(Feature::Effects))
renderer->pushEffect(currentEffect_);
renderChrome(globalOp, renderStyle);
renderContent(globalOp, renderStyle);
drawChildren(globalOp);
if (currentEffect_.valid() && renderer->supports(Feature::Effects))
renderer->popEffect();
if (hasShader && renderer->supports(Feature::Effects))
renderer->popEffect();
renderer->popTransform();
if (!identity) renderer->popTransform();
renderer->popTarget();
Steps:
pushTarget(layerTarget_)— subsequent draws go to the target, starting from(0, 0). The backend may reset the matrix (Raylib'sRaylibRendererdoesrlLoadIdentity).offsetTr— translate by-region.x, -region.yso the subtree draws at(0, 0)of the target instead of at the screen position.tr— the node's own transform (scale, translate, rotation) is applied inside the target. So the target contains the transformed subtree. The offset is applied after the transform, so the net effect is: transform → offset → target.- Effects are pushed inside the target. They apply to every draw call inside the subtree.
renderChrome,renderContent,drawChildren— the normal inline sequence.- Pop everything in reverse.
popTarget()— returns drawing to the framebuffer.
Note the offset is applied after the transform. The order in the code
is pushTransform(tr); pushTransform(offsetTr);, and the backend stacks
them so offsetTr is the innermost (applied last in the vertex
transform). So a point p in the node's local space becomes
tr * p (its visual position), then + offsetTr.translate
(shifted to target space).
4.5 Apply the filter chain
registerBuiltinFilters();
if (!layerSeparable_.valid() ||
layerSeparable_.width != tw || layerSeparable_.height != th)
{
if (layerSeparable_.valid())
renderer->destroyTarget(layerSeparable_);
layerSeparable_ = renderer->createTarget(tw, th);
if (!layerSeparable_.valid()) return;
}
FilterContext ctx;
ctx.renderer = renderer;
ctx.node = this;
ctx.scratch = layerSeparable_;
ctx.region = region;
ctx.dst = {0, 0, (float)tw, (float)th};
ctx.opacity = globalOp;
auto& registry = FilterRegistry::get();
if (renderStyle.filters.empty()) {
renderer->drawTarget(layerTarget_, region,
Colors::White.withAlpha(globalOp));
return;
}
const size_t numFilters = renderStyle.filters.size();
TargetHandle currentRead = layerTarget_;
TargetHandle currentWrite = layerScratch_;
for (size_t i = 0; i < numFilters; ++i) {
const bool isLast = (i == numFilters - 1);
ctx.ref = &renderStyle.filters[i];
ctx.src = currentRead;
ctx.write_target = isLast ? TargetHandle{} : currentWrite;
if (const auto* def = registry.find(ctx.ref->name)) {
applyFilter(*def, ctx);
} else {
// Unknown filter: draw unmodified to the same destination
// the filter would have written to.
if (isLast) {
renderer->drawTarget(currentRead, region,
Colors::White.withAlpha(globalOp));
} else {
renderer->pushTarget(currentWrite);
renderer->drawTarget(currentRead, ctx.dst, Colors::White);
renderer->popTarget();
}
}
if (!isLast)
std::swap(currentRead, currentWrite);
}
Three things changed compared to a single-filter pass:
1. Filters chain
Each filter reads the previous filter's output. The chain alternates
between layerTarget_ and layerScratch_:
- Filter 0:
read = layerTarget_,write = layerScratch_. - Filter 1:
read = layerScratch_,write = layerTarget_. - Filter 2:
read = layerTarget_,write = layerScratch_. - ...
- Filter N-1 (last):
read = <whatever>,write = framebuffer.
The swap after each iteration is what makes the alternation work: what was just written becomes the next filter's input.
2. Opacity is applied once, on the last filter
ctx.opacity is globalOp (the node's opacity times all ancestors).
It's used only when the last filter writes to the framebuffer. All
intermediate writes use Colors::White and go into a target, which
starts transparent. If we applied opacity at every step, it would
compound: a node at 0.5 with two filters would produce a result at
0.25.
3. Separable filters need a third target
blur is a Separable pattern: horizontal pass into an intermediate,
then vertical pass into the destination. If the intermediate were one
of the two ping-pong targets, the V-pass would overwrite what the
H-pass just read. Hence layerSeparable_, allocated once per
drawLayer call (with the same dimensions as the other two).
Fallback
If registry.find(name) returns nullptr (shader not registered, typo
in .zstyle, missing asset), the code draws the current source
unmodified to the same destination the filter would have written to.
The chain continues: subsequent filters read that unmodified image
instead of skipping the step entirely.
This means a broken filter in the middle of a chain is not a hard failure — it degrades to a pass-through. The visual result is "as if that filter weren't there", with the other filters still applied.
4.6 applyFilter patterns
applyFilter dispatches by def.pattern. The four patterns:
SinglePass:
c.renderer->pushEffect(fx);
c.renderer->setEffectVec2(fx, "texSize", {c.dst.width, c.dst.height});
c.renderer->drawTarget(c.src, c.region, Colors::White.withAlpha(c.opacity));
c.renderer->popEffect();
One draw with the shader. texSize is set to the target's dimensions.
The result goes to region on the framebuffer.
Separable:
// H pass: src → scratch
c.renderer->pushTarget(c.scratch);
c.renderer->pushEffect(fx);
c.renderer->setEffectVec2(fx, "texSize", {c.dst.width, c.dst.height});
c.renderer->setEffectVec2(fx, "direction", {1.0f, 0.0f});
c.renderer->drawTarget(c.src, c.dst, Colors::White);
c.renderer->popEffect();
c.renderer->popTarget();
// V pass: scratch → framebuffer
c.renderer->pushEffect(fx);
c.renderer->setEffectVec2(fx, "texSize", {c.dst.width, c.dst.height});
c.renderer->setEffectVec2(fx, "direction", {0.0f, 1.0f});
c.renderer->drawTarget(c.scratch, c.region, Colors::White.withAlpha(c.opacity));
c.renderer->popEffect();
Two passes. The direction uniform tells the shader which axis to
sample. The scratch target holds the H-pass result. The final V-pass
writes to region with the node's opacity.
The intermediate H-pass draws at full opacity to c.dst — it's
compositing the same subtree image, so opacity would be applied twice
otherwise.
Silhouette:
float dx = 2.0f, dy = 2.0f;
for (const auto& p : def.params) {
if (p.uniform == "dx" && p.argIndex < (int)c.ref->args.size())
dx = parseLengthArg(c.ref->args[p.argIndex]);
if (p.uniform == "dy" && p.argIndex < (int)c.ref->args.size())
dy = parseLengthArg(c.ref->args[p.argIndex]);
}
Rect shadowDst = {c.region.x + dx, c.region.y + dy,
c.region.width, c.region.height};
c.renderer->pushEffect(fx);
c.renderer->setEffectVec2(fx, "texSize", {c.dst.width, c.dst.height});
c.renderer->drawTarget(c.src, shadowDst, Colors::White.withAlpha(c.opacity));
c.renderer->popEffect();
c.renderer->drawTarget(c.src, c.region, Colors::White.withAlpha(c.opacity));
Two draws: the first offsets the destination by (dx, dy) and uses the
shader to color the silhouette; the second draws the original at the
original position with no shader (so the original renders normally on
top of the shadow).
The dx / dy args are read from c.ref->args and re-parsed to
compute the offset destination. This duplicates the parsing done in
setParams, which sets the same uniforms. A bit redundant but simple.
Custom:
if (def.custom) def.custom(ctx);
The user-supplied lambda runs with full access to the context. It can push targets, effects, draw any number of passes — whatever it needs.
4.7 setParams
Before each pattern runs, applyFilter calls setParams, which sets
the shader uniforms from the filter def's params and the CSS args:
static void setParams(IRenderer* r, EffectHandle fx,
const FilterDef& def, const FilterRef& ref)
{
for (const auto& p : def.params) {
const int n = (int)ref.args.size();
switch (p.type) {
case FilterParamType::Float:
float v = p.fdef;
if (p.argIndex < n) v = parseLengthArg(ref.args[p.argIndex]);
r->setEffectFloat(fx, p.uniform.c_str(), v);
break;
case FilterParamType::Vec2:
// reads argIndex and argIndex + 1
// ...
case FilterParamType::Color:
Color c = p.cdef;
if (p.argIndex < n) {
if (auto col = parseColorToken(ref.args[p.argIndex]))
c = *col;
}
r->setEffectVec4(fx, p.uniform.c_str(), c);
break;
}
}
}
Each param has:
uniform— the shader uniform name.type— how to set it (Float/Vec2/Vec3/Vec4/Color).argIndex— which CSS argument to read.- A default value.
parseLengthArg strips px suffixes and parses the float.
parseColorToken parses a color (hex or named).
Note: Vec3 and Vec4 params go through setEffectVec4 with
their float args clamped to [0, 1] and scaled to [0, 255] (as
uint8_t). This is a quirk of the current implementation — the shader
receives values that it should normalize back to [0, 1]. See
Effects §4.2.
5. drawChildren(globalOp)
void Layout::drawChildren(float globalOp) {
drawNegZ_.clear();
drawNormal_.clear();
drawPosZ_.clear();
for (auto& child : children) {
if (child->isStackingContext()) {
if (child->getZIndex() < 0) drawNegZ_.push_back(child.get());
else drawPosZ_.push_back(child.get());
} else {
drawNormal_.push_back(child.get());
}
}
auto sortByZ = [](Layout* a, Layout* b) {
return a->getZIndex() < b->getZIndex();
};
std::stable_sort(drawNegZ_.begin(), drawNegZ_.end(), sortByZ);
std::stable_sort(drawPosZ_.begin(), drawPosZ_.end(), sortByZ);
for (auto* child : drawNegZ_) child->draw(globalOp);
for (auto* child : drawNormal_) child->draw(globalOp);
for (auto* child : drawPosZ_) child->draw(globalOp);
}
The classic z-order partition:
- Partition children into three buckets by stacking context.
- Stable-sort each bucket by z-index. Stable so declaration order is preserved within the same z-index.
- Draw
negZ(ascending z), thennormal(declaration order), thenposZ(ascending z).
The three vectors are members of Layout (drawNegZ_, drawNormal_,
drawPosZ_), reused across frames to avoid per-frame allocation. They're
cleared at the start of each drawChildren call.
5.1 Stacking context
bool isStackingContext() const {
return style_.currentStyle.position != Position::Static &&
!style_.currentStyle.zIndex.isAuto;
}
Only nodes with both non-static position and explicit z-index are
stacking contexts. Nodes with z-index but position: static fall
into normalFlow and draw in declaration order.
Nodes without z-index but with position: absolute / relative also
fall into normalFlow — they're positioned out of flow but don't have
a z-ordering priority.
This matches the CSS 2.1 rules for stacking contexts, simplified (no
opacity < 1 or transform as stacking context triggers).
5.2 The draw recursion
Each child->draw(globalOp) recurses into the child. The child does
its own push/pop, its own culling, its own path selection. The parent's
globalOp is the accumulated opacity so far.
A child that is a portal registers itself and returns. The parent's loop continues, but the portal isn't drawn here — it'll be drained at the root.
5.3 Auto-removal during draw
If a child's draw calls removeFromParent (e.g. via a callback that
fires during draw — unusual but possible), the child is still in
children at this point. The internal_.wantsRemoval flag is set, but the
child is drawn one more time. It'll be culled at the end of the next
update.
In practice, callbacks don't fire during draw — they fire during
update. So this is a non-issue.
5.4 Capacity and allocation
The three partition vectors (drawNegZ_, drawNormal_, drawPosZ_)
are members of Layout and are reused across frames. clear() at the
top of each call doesn't deallocate — it just resets the size to zero
while keeping the capacity. push_back then fills the vectors up to
their previous maximum size without reallocating.
The steady state is therefore: allocate once on the frame with the highest child count, then reuse indefinitely. The only ways to trigger a reallocation are:
- The first time a node draws with N children.
- When a node's child count grows past its historical maximum.
Both are one-shot. There's no per-frame allocation in drawChildren,
which is why micro.tree.drawChildren.500 shows stable timing across
runs (37.1 µs, sd 1.2 µs on the reference machine).
reserve() is not called explicitly. Adding it would over-allocate for
the common case (a node with 2–3 children) and provide no benefit for
the case that actually matters (a node whose child count grew once and
then stabilized).
6. resolveEffectIfNeeded
void Layout::resolveEffectIfNeeded(const ComputedStyle& style) {
if (style.effect.empty()) {
effectName_.clear();
currentEffect_ = {};
return;
}
if (style.effect == effectName_) return;
effectName_ = style.effect;
if (auto* assets = UIContext::get().assets)
currentEffect_ = assets->getEffect(style.effect);
else
currentEffect_ = {};
}
Resolves the string effect name to an EffectHandle. Caches the last
resolved name (effectName_) and the handle (currentEffect_), so
the lookup only happens when the name changes.
Three states:
- Empty
effect→ clear the cache, no effect. - Same name as last resolved → reuse the handle.
- New name → resolve via
IAssetProvider::getEffect.
If assets is null (headless or misconfigured), the handle is invalid
and no effect is pushed.
This is called from draw, after the CSS animation overlay, so a
CSS animation that changes the effect property would re-resolve
correctly. (Though the effect property snaps — no interpolation —
so the change is instantaneous.)
7. The renderChrome default
renderChrome is defined inline in Layout.hpp as the default
background+border renderer. Its four phases:
7.1 box-shadow
if (style.boxShadow.enabled) {
const auto& sh = style.boxShadow;
float sx = sh.x.resolveSelfH(rect.width, rect.height);
float sy = sh.y.resolveSelfV(rect.width, rect.height);
float blur = sh.blur.resolveSelfH(rect.width, rect.height);
Rect shadowRect = {rect.x + sx, rect.y + sy, rect.width, rect.height};
if (blur <= 0.5f) {
r->fillRoundedRect(shadowRect, rPx, sh.color.withAlpha(op));
} else {
constexpr int LAYERS = 5;
for (int i = 0; i < LAYERS; ++i) {
float expand = blur * (float)(i + 1) / (float)LAYERS;
float alpha = (1.0f - (float)i / (float)LAYERS) * 0.35f;
Rect r2 = {
shadowRect.x - expand,
shadowRect.y - expand,
shadowRect.width + expand * 2.0f,
shadowRect.height + expand * 2.0f
};
float rPx2 = std::clamp(rPx + expand, 0.0f, expand + maxRadius);
r->fillRoundedRect(r2, rPx2, sh.color.withAlpha(op * alpha));
}
}
}
The box-shadow is not a filter or a shader. It's drawn as a sequence of 5 concentric rounded rects with decreasing alpha for the blur approximation. Each layer is larger and less opaque. The result is a soft-edged shadow that's much cheaper than a Gaussian blur but also much less accurate.
Zero blur → a single rounded rect, drawn exactly at the shadow offset.
7.2 Background color
Color bg = style.background.withAlpha(op);
if (bg.a > 0) {
if (rPx > 0.0f && maxRadius > 0.0f)
r->fillRoundedRect(rect, rPx, bg);
else
r->fillRect(rect, bg);
}
The background is drawn as a rounded rect if the radius > 0, else a
plain rect. The radius is clamped to min(rect.width, rect.height) * 0.5
so it can't exceed half the smallest dimension.
7.3 Texture
auto resolved = resolveBgTexture(style, bgTexture, bgPatchInfo);
if (resolved.valid()) {
Color tint = style.tint.withAlpha(op);
if (resolved.slice.left || resolved.slice.top ||
resolved.slice.right || resolved.slice.bottom)
{
r->drawNineSlice(resolved.tex, resolved.slice, rect, tint);
} else {
Rect src{0, 0, (float)resolved.tex.width, (float)resolved.tex.height};
r->drawTexture(resolved.tex, src, rect, tint);
}
}
Two sources:
style.backgroundTexture— aTextureRefin the style, resolved by name viaIAssetProvider.bgTexture— the widget's hardcoded fallback (set viasetBackgroundTexture).
Style wins if both are set. The texture is drawn stretched to rect,
or nine-sliced if insets are provided.
The tint multiplies the texture. Default is white (no tint).
7.4 Border
Color bc = style.borderColor.withAlpha(op);
float bw = style.borderWidth.resolveH(maxRadius * 2.0f, maxRadius * 2.0f);
if (bc.a > 0 && bw > 0.0f) {
if (rPx > 0.0f && maxRadius > 0.0f)
r->strokeRoundedRect(rect, rPx, bw, bc);
else
r->strokeRect(rect, bw, bc);
}
Border is drawn as a stroke around rect, with the same radius as the
background. The border is drawn inside the rect (not offset), so
it overlaps the background edge.
Border width is resolved against maxRadius * 2.0f for the percentage
case — matching how radius is resolved.
8. Clipping in detail
Clipping is the most backend-specific part of the pipeline. The
IRenderer contract is:
pushClip(Rect r)— clip subsequent draws tor.popClip()— undo the last push.getClipRect()— the current effective clip.
The Raylib implementation:
void RaylibRenderer::pushClip(Rect r) {
if (insideTarget_) return;
Rect screenRect = transformClipToScreen(r);
if (clipActive) {
clipStack.push_back(currentClip);
currentClip = intersectRect(currentClip, screenRect);
} else {
clipStack.push_back({0, 0, (float)GetScreenWidth() / dpiScale_,
(float)GetScreenHeight() / dpiScale_});
currentClip = screenRect;
clipActive = true;
}
int x = (int)std::floor(currentClip.x * dpiScale_);
int y = (int)std::floor(currentClip.y * dpiScale_);
int w = (int)std::ceil(currentClip.width * dpiScale_);
int h = (int)std::ceil(currentClip.height * dpiScale_);
if (w <= 0 || h <= 0) {
BeginScissorMode(0, 0, 0, 0);
return;
}
BeginScissorMode(x, y, w, h);
}
Three important details:
-
Inside a target, clipping is a no-op. Raylib's render targets don't support scissor the same way as the framebuffer. Instead, the target itself is the clip — anything outside the target dimensions is naturally excluded. So clip pushes/pops are skipped inside a layer.
-
The clip rect is transformed to screen space.
transformClipToScreenapplies all active transforms to the four corners of the clip rect and computes the AABB. This makes the clip follow the transform: a node withscale: 2clips to twice the size. -
Nested clips are intersected. Each
pushClipintersects the new rect with the current one. So overlapping clips correctly accumulate. The intersection is an AABB approximation — for rotated clips this would be conservative (larger than necessary).
The clip is stored in currentClip and pushed/popped in clipStack.
getClipRect() returns currentClip if active, else the full viewport.
8.1 Frustum culling uses the clip
draw's frustum culling reads renderer->getClipRect() and compares
against rect. So a node outside the current clip is skipped. This is
how scroll containers avoid drawing off-screen children.
The margin of 40.0f is a safety buffer for effects that draw beyond
rect (blur, drop-shadow, box-shadow). Without it, those effects would
be culled at the edges.
9. Interaction with renderContent and parts
renderContent and renderChrome are called inside the pushed
transform, clip, and effects. So:
- A draw call in
renderContentis transformed by the node's transform. - It's clipped by the node's overflow (if any) and any ancestor's.
- It's colored by the node's effects (if any).
renderContent reads renderStyle (or a part style via
partStyle(name)) and draws primitives. Every color drawn must have
.withAlpha(op) applied — otherwise the node's opacity and all
ancestor opacities are ignored.
9.1 Parts
partStyle(name) is a method of Layout that calls
style_.partFor(*this, name). The returned Style has the part's
resolved style, with transitions and animations applied.
The part is not a Layout — it has no rect. The widget's
renderContent computes the geometry and reads the style:
Style knobStyle = partStyle("knob");
Color knobColor = knobStyle.color.is_set
? knobStyle.color.value
: defaultColor;
r->fillCircle(center, radius, knobColor.withAlpha(op));
Each partStyle call re-resolves the part, ticks its transition, and
overlays its animations. In practice, once per frame per part.
10. Interaction with the portal drain
The root's draw drains framePortals after drawing the tree. Each
portal is drawn with setPortal(false) temporarily so it renders
normally.
Important consequences:
- Portals draw on top of the entire tree, regardless of z-index in the
tree. If you need a floating element behind other content, use
position: absolute+z-indexwithin a stacking context — not a portal. - Portals draw at
opacity = 1.0f— their own opacity is applied inside theirdrawcall. - The order of portals in
framePortalsdetermines the draw order. It's the order they registered themselves during the tree walk, which is roughly declaration order for portals at the same depth, but nested portals register before their ancestors are drawn (since drawing is depth-first).
11. What the backend sees
The IRenderer interface is the contract. The pipeline uses:
fillRect,fillRoundedRect,fillCircle— for chrome and content.strokeRect,strokeRoundedRect— for borders.drawTexture,drawNineSlice— for backgrounds.drawText— for text.pushTransform/popTransform— for the transform stack.pushEffect/popEffect— for shader push/pop.pushClip/popClip/getClipRect— for the clip stack.createTarget/destroyTarget/pushTarget/popTarget/drawTarget— for render target management.setEffectFloat/setEffectVec2/setEffectVec4— for shader uniforms.clearTarget/inTarget— for target state queries.beginFrame/endFrame— for frame-level setup.setDpiScale— for DPI management.supports(Feature)— for feature detection.
The pipeline never touches the backend's own types. Everything crosses
the interface as framework types (Rect, Color, Vec2, handles).
12. The Raylib backend specifics
RaylibRenderer is the reference implementation. Key decisions:
12.1 Clip stack
Maintained as std::vector<Rect> clipStack and a bool clipActive with
the current currentClip. The scissor is set via BeginScissorMode,
which must be called after any state change (including popTarget).
The renderer handles this by re-issuing the scissor after popTarget.
12.2 Transform stack
Maintained as std::vector<Transform2D> transformStack. pushTransform
calls rlPushMatrix and applies the transform via rlTranslatef,
rlRotatef, rlScalef. popTransform calls rlPopMatrix.
The transformStack is used by transformClipToScreen to compute the
screen-space AABB of a clip rect: it applies all active transforms to
the four corners.
12.3 Target stack
pushTarget saves the current matrix (rlPushMatrix, rlLoadIdentity),
sets insideTarget_ = true, ends any active scissor, and calls
BeginTextureMode. popTarget calls EndTextureMode, restores the
matrix (rlPopMatrix), and re-issues the scissor if one was active.
The "no nested targets" limitation comes from Raylib: BeginTextureMode
while already in a texture mode doesn't work correctly. The pipeline
avoids this by the !renderer->inTarget() check in draw.
12.4 Effects
pushEffect uses BeginShaderMode. popEffect calls EndShaderMode
and re-pushes the previous effect if there's one on the stack
(effectStack_). This is because Raylib's shader mode is not stackable
by default.
12.5 DPI
beginFrame pushes a scale matrix if dpiScale_ != 1.0f. endFrame
pops it. pushClip multiplies the clip rect by dpiScale_ before
calling BeginScissorMode, because scissor operates in physical pixels
but the clip is in logical.
measureText also applies DPI scaling to the text size and then
divides the result by dpiScale_ to return logical pixels.
13. Performance notes
13.1 Frustum culling
The biggest win for large UIs. A 1000-child scroll view only draws the
~15 visible children. Each draw call does the AABB check cheaply.
13.2 Target caching
Render targets are cached per node and only reallocated when the size changes. A filtered node that doesn't resize doesn't allocate in a frame.
13.3 The three z-vectors
drawNegZ_, drawNormal_, drawPosZ_ are members, reused across
frames. The only allocations are when the vectors grow beyond their
capacity, which stabilizes after the first few frames.
13.4 String comparisons
resolveEffectIfNeeded compares style.effect to effectName_. This
is a std::string comparison, done once per draw call per node. For
nodes with no effect, it's style.effect.empty() — cheap. For nodes
with an effect that didn't change, it's a std::string::operator== on
the last resolved name — also cheap but not free.
13.5 The effect and clip stacks
Every pushTransform / pushClip / pushEffect is a stack operation.
For deeply nested trees with many transforms, this accumulates. The
identity checks in drawInline / drawLayer skip push/pop when
there's no transform, which is the common case.
13.6 The renderStyle copy
ComputedStyle renderStyle = style_.currentStyle; copies a struct with
~45 fields (some with std::string, std::vector, unordered_map).
For each node, every frame. This is a nontrivial copy — the Style
struct has grown over time.
For most nodes, the copy is cheap because the strings and vectors are
empty (default). For nodes with font, backgroundTexture, filters,
or animations, the copy allocates.
This is a known cost. An optimization would be to apply the animation overlay in place on a reference or to use copy-on-write. For now, it's acceptable.
13.7 The double overlay evaluation
CSS animations are evaluated twice per frame: once in tickCss (to
detect finish), once in draw (to apply). Each evaluation iterates
~45 properties. For a node with multiple active animations, this is
the dominant cost.
An optimization would be to cache the evaluated Style between
tickCss and draw. Not currently implemented.
14. Pitfalls
Inside a render target, clip is a no-op.
pushClip in RaylibRenderer returns immediately if insideTarget_.
So a filtered scroll container doesn't clip its content to its rect —
it relies on the target's bounds instead. This is mostly invisible but
can cause content to be drawn slightly outside the container if the
target is larger than the rect (which happens when the region is
computed from the transformed rect).
box-shadow is not a Gaussian blur.
It's 5 stacked rounded rects. For large blur radii, the approximation
is visible (stepped edges). For small radii, it's indistinguishable.
Effects apply to each draw call individually.
A node with effect: hueShift and three draw calls (background,
border, text) applies the shader three times. This is different from
filter:, which operates on the composited image. For per-fragment
shaders that don't depend on neighbors, this is fine. For shaders that
need the full image, use filter:.
The root's portal drain modifies framePortals while copying.
The drain copies framePortals into a local, then iterates the local.
If a portal registers itself during its own draw, it's added to
framePortals (the member), not the local copy. So it won't be drawn
this frame — it'll be drawn next frame, when the local copy is taken
again.
In practice, portals don't register during draw (they register once, the first time they're drawn), so this is a non-issue. But it's a subtle behavior.
draw returns early for portals and for zero-opacity nodes.
A portal's draw is a one-liner (register + return). A zero-opacity
node's draw returns before drawing anything. Both skip the
renderChrome / renderContent / drawChildren calls.
drawChildren allocates three vectors on first call per node.
Not per frame — the vectors are members. The first time a node draws
its children, the three vectors allocate. After that, they're reused
with clear() (which doesn't deallocate).
Z-index only works with non-static position.
position: static (the default) means the node goes into normalFlow
regardless of its z-index. This is the CSS rule, but it's easy to
forget. To use z-index, set position: relative (or absolute).
Stacking contexts are not inherited.
A child of a stacking context is not automatically in the same context
— it depends on its own position and z-index. Nested stacking
contexts are possible and are handled by the recursive drawing.
The region computed in drawLayer uses renderer->getClipRect().
This returns the current clip in logical coordinates. The region
is intersected with the transformed rect (also logical). The target
size tw, th is the region's logical size, rounded up. The target
is created at that size. When drawing back, drawTarget(layerTarget_,
region, ...) draws it at region, which is in logical coordinates.
DPI scaling is handled inside the backend.
The MAX_TARGET_DIM fallback can cause visible flicker.
A node that's sometimes larger than 4096 and sometimes smaller will
alternate between filtered and unfiltered rendering. This is rare but
possible. If you see flicker on a large filtered node, this is why.
clearTarget is not called before pushTarget.
In pushTarget, Raylib calls ClearBackground(::BLANK) after
BeginTextureMode. So the target starts transparent. The pipeline
doesn't call clearTarget explicitly — the backend does it as part of
pushTarget.
drawTarget flips the Y coordinate.
Raylib's render textures are stored upside-down relative to the screen.
drawTarget uses a negative source height to flip the texture back:
::Rectangle src = {0, 0, (float)tex.width, -(float)tex.height};
This is backend-specific and hidden behind the interface. A different backend might not need this.
Text rendering is not affected by transforms in measureText.
measureText measures the text at its natural size, ignoring any
active transform. The drawn text is scaled by the transform, but its
measured size is not. So a node with scale: 2 will have its text
drawn at 2× size but measured at 1× — the layout will be wrong.
In practice, text nodes are rarely scaled.
The insideTarget_ flag disables clipping but not frustum culling.
Inside a target, pushClip is a no-op, but draw's frustum culling
still uses getClipRect(). Which returns currentClip — the clip
that was active before the target was pushed. So a node inside a
target is culled based on the outer clip, not the target bounds.
Usually correct (targets are sized to the clip intersection), but
edge cases exist.