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.
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.