-
Notifications
You must be signed in to change notification settings - Fork 0
Rendering
[PreSkiaPipeline — once per frame]
└── SkiaRenderer.Begin() // acquire Skia GL surface
[GuiBase.OnRenderGUI — per open window]
└── RenderObject.AdvanceFrame()
└── _tickerScheduler.Update(elapsed)
└── new/reset PaintingContext(canvas)
└── BuildOwner.BuildDirtyElements() // rebuild dirty widget subtrees (parents first)
└── rootRo.Layout(constraints) // if NeedsLayout or ChildNeedsLayout
└── canvas.Translate(WindowPos) // position window on screen
└── canvas.ClipRect(windowBounds)
└── rootRo.Paint(context) // recursive tree walk
├── PaintInternal() // draw background, border, text, image
└── children (translated by X, Y)
└── child.Paint(context)
└── DebugPainter overlay // if debug mode enabled
[PostSkiaPipeline — once per frame]
└── SkiaRenderer.End() // flush to OpenGL framebuffer
Each GuiBase window runs this pipeline independently. The canvas translate/clip ensures
widgets paint in window-local coordinates while rendering at the correct screen position.
SkiaRenderer bridges Skia (SkiaSharp) and OpenGL. It is created by GuiModSystem and
shared across all open GUI screens.
Begin(width, height, scale):
- Acquires the GL framebuffer as an
SKSurface - Scales the canvas by
uiScaleso all coordinates are in UI points, not pixels - Returns an
SKCanvasready for drawing
End():
- Flushes the Skia command buffer to OpenGL
- Restores OpenGL state that Skia modifies (viewport, depth mask, blend, scissor, textures)
You never call SkiaRenderer directly — GuiBase.OnRenderGUI handles it.
PaintingContext wraps an SKCanvas and provides a stable drawing API to all RenderObject
subclasses. It maintains a canvas stack so that RepaintBoundary can push a recording
canvas during cache capture without affecting the active draw target.
Draws a filled rounded rectangle with an optional border:
// Called by RenderBox.PaintInternal internally.
// Direct use is rare — prefer Container/BoxStyle.
context.DrawBox(
pos: Vector2.Zero,
size: Size,
color: new Vector4(0.1f, 0.1f, 0.1f, 0.9f),
radii: new Vector4(8), // X=TR, Y=BR, Z=TL, W=BL
border: 2f,
borderColor: new Vector4(1, 1, 1, 0.4f)
);Draws a bitmap using pre-computed srcRect and dstRect (computed by BoxFit logic),
clipped to a rounded rectangle:
context.DrawImage(bitmap, pos, size, srcRect, dstRect, radii, border, borderColor);Draws text with optional glow and outline passes:
context.DrawText(
text: "Hello",
pos: new Vector2(0, fontSize), // Y is baseline
fontSize: 14f,
color: Vector4.One,
fontFamily: "sans-serif",
weight: FontWeight.Normal,
boldness: 0.1f,
outlineWidth: 0.05f,
outlineColor: new Vector4(0, 0, 0, 1),
glowWidth: 0.3f,
glowColor: new Vector4(0.5f, 0.8f, 1, 0.5f)
);Drawing order: glow (blur filter) → outline (stroke) → fill. This creates a layered effect.
RenderBox supports CSS-like box shadows via the Shadows property (set from
BoxStyle.BoxShadows). The paint order in PaintInternal is:
1. Outer shadows (behind the box)
2. Fill + border (the box itself)
3. Inner shadows (inside the box, above fill, below children)
Outer shadows use SKMaskFilter.CreateBlur on a rounded rect expanded by SpreadRadius
and offset by Offset. A ClipRoundRect(Difference) clips out the box interior so the
shadow never bleeds through transparent fills.
Inner shadows use a SaveLayer compositing technique:
- Clip to the box boundary (respecting corner radii)
-
SaveLayerwith anSKImageFilterblur - Flood the layer with the shadow color
- Punch a transparent hole with
DstOutblend (contracted by spread, shifted by offset) -
Restore— the blur filter applies to the composited result
Blur filters (SKMaskFilter for outer, SKImageFilter for inner) are cached in
PaintingContext by rounded sigma value, avoiding per-frame filter recreation.
RenderRepaintBoundary inflates its SKPictureRecorder bounds by the maximum outer shadow
extent in the subtree so shadows are not clipped by the recording rect.
Used by Opacity to create an isolated compositing layer:
context.SaveLayer(bounds: null, paint: alphaPaint);
// draw children at full opacity
context.Restore(); // composite with alphaRenderObject.Paint(context) is the recursive paint entry point:
1. Mark WasPaintedThisFrame (frame-ID comparison, no reset needed)
2. Fire OnAnyPaint if dirty (performance metrics)
3. Save canvas + apply ClipBehavior if != None
4. Call PaintInternal(context) ← subclass draws its visual content here
5. Clear NeedsPaint
6. foreach child:
canvas.Save()
canvas.Translate(child.X, child.Y) ← position child in local space
child.Paint(context)
canvas.Restore()
7. Clear ChildNeedsPaint
8. Restore canvas (from clip)
The canvas translation model means every RenderObject draws at local origin (0, 0). The
parent is responsible for positioning via Translate.
RepaintBoundary is the primary performance tool for large, mostly-static subtrees. It caches
its entire subtree as an SKPicture object.
// Wrap stable subtrees adjacent to animated content:
new RepaintBoundary(
new Sidebar(...) // static — cache hits every animation frame
)
// DO NOT wrap the animated widget itself — cache is invalidated every frame:
// new RepaintBoundary(new AnimatedBuilder(...)) ← wrong, pure overheadOn every Paint call, RenderRepaintBoundary decides between:
Cache hit — NeedsPaint == false && ChildNeedsPaint == false && _cache != null:
canvas.DrawPicture(_cache) // replay recorded commands, no widget rebuild needed
Cache miss — subtree is dirty or cache is empty:
SKPictureRecorder recorder
context.PushCanvas(recorder.BeginRecording(...))
// paint self and all children into recording canvas
context.PopCanvas()
_cache = recorder.EndRecording()
canvas.DrawPicture(_cache)
OnMarkNeedsPaint() is overridden to immediately dispose _cache (clearing it), so the next
frame will re-record. The cache is never stale.
- Sidebar / static panels that don't change but share a frame with animated content
- Item grids / lists that are stable while animation plays elsewhere
Wrap the static neighbors of animated content, not the animated widget itself.
- On animated subtrees — an animated child calls
MarkNeedsPaint()every frame, which walks up past the boundary settingChildNeedsPaintand clearing_cache. The result is a cache miss every frame: record overhead with zero hits. - On frequently-changing subtrees for the same reason
- On tiny widgets (the
SKPictureoverhead outweighs the savings)
Implementing a custom RenderObject lets you control layout and painting directly.
// 1. Define the widget:
class CircleWidget : RenderObjectWidget
{
public Vector4 Color { get; }
public float Radius { get; }
public CircleWidget(Vector4 color, float radius)
{
Color = color;
Radius = radius;
}
public override Element CreateElement() => new RenderObjectElement(this);
public override RenderObject CreateRenderObject() =>
new _RenderCircle { Color = Color, Radius = Radius };
public override void UpdateRenderObject(RenderObject ro)
{
var circle = (_RenderCircle)ro;
circle.Color = Color;
circle.Radius = Radius;
}
}
// 2. Implement the RenderObject:
class _RenderCircle : RenderBox
{
private Vector4 _color;
public Vector4 Color
{
get => _color;
set => SetProperty(ref _color, value, repaint: true);
}
private float _radius;
public float Radius
{
get => _radius;
set => SetProperty(ref _radius, value, relayout: true);
}
protected override void PerformLayout()
{
float diameter = _radius * 2;
Size = new Vector2(diameter, diameter);
}
protected override void PaintInternal(PaintingContext context)
{
var paint = context.SharedPaint;
paint.Color = _color.ToSkColor();
paint.Style = SKPaintStyle.Fill;
context.Canvas.DrawCircle(_radius, _radius, _radius, paint);
}
}Important rules for PerformLayout:
- Set
Sizeto the desired render object size - Pass appropriate constraints to each child and call
child.Layout(constraints) - Set
child.Xandchild.Yto position each child
Important rules for PaintInternal:
- Draw at local origin
(0, 0)— the canvas is already translated - Do not translate the canvas yourself for children (the base
Paintdoes this) - Use
SetProperty(ref _field, value, repaint: true/false, relayout: true/false)in setters — never callMarkNeeds*directly - Use
context.SharedPaint— never allocatenew SKPaintinsidePaintInternal
Use RenderRepaintBoundary for stable subtrees adjacent to animated content.
Avoid per-frame allocations in PaintInternal. The SKPaint in a custom renderer should
be reused. PaintingContext uses a shared _sharedPaint instance internally for this reason.
Keep Build() pure. Do not perform layout calculations in Build(). Build() may run
multiple times per frame during cascaded rebuilds.
Font caching. TextLayoutHelper caches SKFont instances keyed by family + size (rounded
to 0.5pt) + weight. Animations that smoothly change font size pay a small cost for cache misses
near .5pt boundaries but avoid object allocation on every frame.
WasPaintedThisFrame uses a frame-ID counter rather than a boolean flag, eliminating the
need for a full tree walk at the end of each frame to reset it.