This document describes MewUI's render-loop model: how rendering is scheduled relative to the message loop and layout, how continuous/on-request rendering is controlled, and how each backend maps that control onto its present/vsync mechanism. It is an internal guide for backend/platform behavior and scheduling.
- Keep the UI responsive by decoupling rendering from message/layout processing.
- Support both:
- On-request rendering (the default): a window renders only when something invalidates it.
- Continuous rendering: the loop renders every window every iteration, for animations and max-FPS scenarios.
- Give backend-level vsync control with consistent behavior across the Win32, X11, and macOS platform hosts and the Direct2D, MewVG (OpenGL/Metal), and GDI graphics backends.
The loop is configured via Application.Current.RenderLoopSettings (Aprillz.MewUI.RenderLoopSettings):
-
Continuous(bool) - user flag that forces continuous rendering. This is the only direct way to request continuous rendering; the animation system requests it on its own (see below) while clocks are running.SetContinuous(bool)is a convenience setter for the same flag. -
TargetFps(int) - frame cap for continuous rendering.0(the default) means uncapped. -
VSyncEnabled(bool) - backend present/swap behavior. Defaults totrue. Turning it off also forces continuous rendering (seeIsContinuousbelow), since there is no vsync to pace on. -
IsContinuous(bool, read-only) -truewhen the loop should run continuously:IsContinuous = !VSyncEnabled || Continuous || AnimationActiveThis is the single value the platform host reads every loop iteration; nothing sets the loop mode directly.
-
AnimationActive(internal) - driven byAnimationManager: set while one or more animation clocks are active, cleared when idle. Not a user-facing knob.
There is no separate "mode" enum - on-request vs. continuous is entirely the computed
IsContinuous value. By default (Continuous = false, VSyncEnabled = true, no active
animations) IsContinuous is false, i.e. on-request rendering.
A real example, from the Gallery sample's "Max FPS" toggle:
var scheduler = Application.Current.RenderLoopSettings;
scheduler.TargetFps = 0;
scheduler.VSyncEnabled = !maxFpsEnabled;
scheduler.SetContinuous(maxFpsEnabled);Both the Win32 and X11 platform hosts (Win32PlatformHost, X11PlatformHost) run a single
message/render loop (PumpLoop) per process, shared by every registered window - there is no
per-window OS loop. Each iteration branches on RenderLoopSettings.IsContinuous:
- The loop blocks (
MsgWaitForMultipleObjectsExon Win32, the platform equivalent on X11) until either an OS message arrives or a window has requested a render. - When woken, only windows whose backend has
NeedsRender == trueare rendered (RenderIfNeeded), then pending OS messages are drained. - If a window still needs a render afterward (e.g. an invalidation happened while draining messages), the render-request event is re-armed for the next iteration.
- Multiple invalidations before the next wake-up coalesce into a single render, similar to WPF-style render-request merging.
- Every iteration processes pending messages, then renders every window unconditionally
(
RenderNow), regardless of whether it was invalidated. - If
TargetFps > 0, the loop sleeps/waits out the remainder of the frame budget after rendering;TargetFps == 0means it renders as fast as it can loop. - This is what animations rely on (see
AnimationActiveabove) and what "Max FPS" profiling toggles use.
The macOS platform host (MacOSPlatformHost) implements the same NeedsRender /
RenderIfNeeded / RenderNow / IsContinuous / TargetFps contract.
A window's invalidation and rendering funnel through the UI dispatcher's priority queue
(DispatcherPriority: Idle < Background < Render < Layout < Input < Normal, higher
runs first). Each dispatcher drain (DispatcherQueue.Process) processes queues from highest
priority to lowest, so anything a Layout-priority action enqueues at Render priority is still
drained within the same pass:
UIElement.InvalidateVisualState()queues the element on its window (Window.RegisterVisualStateDirty) for reconciliation.Window.InvalidateMeasure()/InvalidateArrange()callRequestUpdatePass(), which posts a merged action atDispatcherPriority.Layout. That action runsWindow.PerformLayout()- which reconciles queued visual-state invalidations (UpdateVisualStates()), resolves the window's own style, applies the template, then measures/arranges - and finally callsRequestRender().RequestRender()(internal) posts a merged action atDispatcherPriority.Renderthat calls_backend.Invalidate(true), which sets the backend'sNeedsRenderflag and asks the platform host to wake the render loop.Window.Invalidate()andWindow.InvalidateVisual()(public) both callRequestRender()directly, skipping the layout stage - use these when layout is already known to be current.
So the effective per-frame order is: OS/dispatcher messages -> update pass (visual-state
reconciliation, then measure/arrange, only if something is dirty) -> platform host render call.
Inside the actual render (Window.RenderFrame -> RenderFrameCore), AnimationManager.Instance .Update() runs first to advance active animation clocks before the draw traversal, so animated
values are current for that frame's paint.
A frame does not repaint the whole window. Elements invalidated since the previous frame are drawn again, the kept drawing of every other element is reused, and only the area of the window that changed is repainted. A frame in which nothing changed has nothing to repaint. What this asks of OnRender is described in Layout under the Render rules.
WM_PAINTis still handled (Win32WindowBackend.HandlePaint) and renders immediately insideBeginPaint/EndPaint(viaUpdateLayeredWindowinstead for per-pixel-alpha windows) - the platform host does not forceWM_PAINTfor its own request-driven renders, and callsValidateRectafter presenting so it doesn't generate a redundant repaint.- OS-driven modal loops (interactive resize/move, or a native menu -
WM_ENTERSIZEMOVE/WM_ENTERMENULOOP) run insideDefWindowProcand suspendPumpLoop. To keep animations and pending invalidations progressing during that time, the backend starts aWM_TIMER(8 ms) on entry and stops it onWM_EXITSIZEMOVE/WM_EXITMENULOOP.
| Backend | VSyncEnabled = true | VSyncEnabled = false |
|---|---|---|
| Direct2D (swap-chain present) | DXGI Present sync interval 1 |
sync interval 0 |
| Direct2D (HwndRenderTarget present) | D2D1_PRESENT_OPTIONS.NONE |
D2D1_PRESENT_OPTIONS.IMMEDIATELY |
| MewVG / OpenGL (Win32 WGL, X11 GLX/EGL) | swap interval 1 |
swap interval 0 |
| MewVG / Metal (macOS) | CAMetalLayer.displaySyncEnabled = true |
displaySyncEnabled = false |
| GDI | no effect - GDI has no vsync concept | no effect |
GDI still fully participates in on-request/continuous scheduling (that mechanism lives in the
platform host, not the graphics backend) - VSyncEnabled just has nothing to control there.
Window.FrameRendered(event Action?) fires after every rendered frame, in both scheduling modes.Window.FirstFrameRenderedfires once, the first time a frame is rendered.Window.LastFrameStats(RenderStats) holds the previous frame'sDrawCalls,CullCount,RenderedCalls, andCullRatio.- The Sample and Gallery apps use
FrameRenderedto accumulate frames over a one-second window and compute/display an FPS readout, and readLastFrameStatsto show draw/cull counts.
- Rendering is kept separate from invalidation so continuous/max-FPS mode is just "always treat every window as needing a render," without a different code path.
- On-request renders are coalesced (merged dispatcher posts,
NeedsRenderflag) so redundant invalidations collapse into one render per wake-up. - Continuous mode renders even without invalidation so animations - and anything else driving
AnimationActive- can advance every frame. - Scheduling decides when a frame runs; invalidation decides what that frame repaints. Continuous mode runs frames back to back, but an element is still drawn again only after
InvalidateVisual().