From 8a8036e04893681e2eddd3d7a6a9ccf6063d9db7 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 20:45:56 -0400 Subject: [PATCH 01/27] Add Examples/HUD benchmark for per-frame UI performance Renders a representative game HUD (health, armor, ammo, crosshair, countdown timer, chat log, toggling scoreboard) with each widget updating on an interval, and times the style, layout, draw and endFrame passes individually, printing a summary once per second. Intended to measure CPU cost of driving a per-frame HUD with MVC before and after performance changes. MVC_HUD_FRAMES=N exits after N frames; MVC_HUD_HIDDEN=1 creates the window hidden. Baseline (M-series macOS, MVC_HUD_FRAMES=1400 MVC_HUD_HIDDEN=1, vsync ~120fps): idle frames style avg ~2-5us (max 233us), layout avg ~0.5us (max 47us), draw avg ~8-38us (max 2.5ms), endFrame avg ~25-52us (max 226us); 6 draw calls idle, 26 with scoreboard shown. Co-Authored-By: Claude Fable 5 --- Examples/HUD.c | 416 +++++++++++++++++++++++++++++++++++++++++++ Examples/Makefile.am | 6 +- 2 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 Examples/HUD.c diff --git a/Examples/HUD.c b/Examples/HUD.c new file mode 100644 index 00000000..686f342f --- /dev/null +++ b/Examples/HUD.c @@ -0,0 +1,416 @@ +/* + * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C. + * Copyright (C) 2014 Jay Dolan + * + * This software is provided 'as-is', without any express or implied + * warranty. In no event will the authors be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, subject to the following restrictions: + * + * 1. The origin of this software must not be misrepresented; you must not + * claim that you wrote the original software. If you use this software + * in a product, an acknowledgment in the product documentation would be + * appreciated but is not required. + * + * 2. Altered source versions must be plainly marked as such, and must not be + * misrepresented as being the original software. + * + * 3. This notice may not be removed or altered from any source distribution. + */ + +/** + * @file + * @brief A representative game HUD, used as a performance benchmark. + * @details Renders health, armor and ammo counters, a crosshair, a countdown + * timer, a chat log and a toggling scoreboard, each updating on an interval. + * The frame passes (style, layout, draw, endFrame) are timed individually and + * a summary is printed once per second. + * + * Environment: + * - `MVC_HUD_FRAMES=N` exits successfully after N frames (for benchmarking). + * - `MVC_HUD_HIDDEN=1` creates the window hidden (best effort headless). + */ + +#define SDL_MAIN_USE_CALLBACKS + +#include +#include + +#include +#include + +#define HUD_WINDOW_W 1024 +#define HUD_WINDOW_H 720 + +/** + * @brief Accumulated timing for one frame pass. + */ +typedef struct { + double sum; + double max; +} PassStats; + +/** + * @brief SDL application state passed via pointer to callbacks. + */ +typedef struct { + + SDL_Window *window; + RenderDevice *renderDevice; + Framebuffer *framebuffer; + WindowController *windowController; + + /** + * @brief HUD widgets, borrowed references into the View hierarchy. + */ + Label *health, *armor, *ammo, *timer; + StackView *chat; + Panel *scoreboard; + + /** + * @brief Next update deadline per widget, in SDL ticks. + */ + Uint64 healthDue, armorDue, ammoDue, timerDue, chatDue, scoreboardDue; + + /** + * @brief Frame counters and per-pass timing since the last report. + */ + Uint64 frames, maxFrames, reportDue, reportFrames; + PassStats style, layout, draw, endFrame; + size_t draws, vertices; +} AppState; + +static AppState application; + +#pragma mark - HUD construction + +/** + * @brief Creates a Label with the given text, aligned within its superview. + */ +static Label *label(View *superview, const char *text, ViewAlignment alignment) { + + Label *label = $(alloc(Label), initWithText, text, NULL); + label->view.alignment = alignment; + + $(superview, addSubview, (View *) label); + release(label); + + return label; +} + +/** + * @brief Creates a StackView aligned within its superview. + */ +static StackView *stackView(View *superview, ViewAlignment alignment) { + + StackView *stack = $(alloc(StackView), initWithFrame, NULL); + stack->view.alignment = alignment; + stack->view.autoresizingMask = ViewAutoresizingContain; + stack->spacing = 4; + + $(superview, addSubview, (View *) stack); + release(stack); + + return stack; +} + +/** + * @brief Builds the HUD View hierarchy on the given root View. + */ +static void buildHUD(AppState *app, View *root) { + + StackView *status = stackView(root, ViewAlignmentBottomLeft); + app->health = label((View *) status, "Health 100", ViewAlignmentNone); + app->armor = label((View *) status, "Armor 100", ViewAlignmentNone); + + app->ammo = label(root, "Ammo 50", ViewAlignmentBottomRight); + app->timer = label(root, "10:00", ViewAlignmentTopCenter); + + label(root, "+", ViewAlignmentMiddleCenter); + + app->chat = stackView(root, ViewAlignmentTopLeft); + + Panel *scoreboard = $(alloc(Panel), initWithFrame, NULL); + scoreboard->control.view.alignment = ViewAlignmentMiddleCenter; + + for (int i = 0; i < 8; i++) { + StackView *row = $(alloc(StackView), initWithFrame, NULL); + row->axis = StackViewAxisHorizontal; + row->spacing = 32; + row->view.autoresizingMask = ViewAutoresizingContain; + + Label *name = $(alloc(Label), initWithText, "Player", NULL); + $(name->text, setTextWithFormat, "Player %d", i + 1); + $((View *) row, addSubview, (View *) name); + release(name); + + Label *score = $(alloc(Label), initWithText, "0", NULL); + $(score->text, setTextWithFormat, "%d", (8 - i) * 5); + $((View *) row, addSubview, (View *) score); + release(score); + + $((View *) scoreboard->contentView, addSubview, (View *) row); + release(row); + } + + $((View *) scoreboard, setHidden, true); + $(root, addSubview, (View *) scoreboard); + release(scoreboard); + + app->scoreboard = scoreboard; +} + +#pragma mark - HUD updates + +/** + * @brief Applies interval-based updates to the HUD widgets. + * @details Deliberately NOT per-frame: most frames are idle, which is what a + * real HUD looks like, and what exposes both the idle cost and the cost of a + * single widget update. + */ +static void updateHUD(AppState *app, Uint64 ticks) { + + if (ticks >= app->healthDue) { + app->healthDue = ticks + 2000; + $(app->health->text, setTextWithFormat, "Health %d", (int) (25 + ticks / 100 % 75)); + } + + if (ticks >= app->armorDue) { + app->armorDue = ticks + 3000; + $(app->armor->text, setTextWithFormat, "Armor %d", (int) (ticks / 200 % 100)); + } + + if (ticks >= app->ammoDue) { + app->ammoDue = ticks + 700; + $(app->ammo->text, setTextWithFormat, "Ammo %d", (int) (50 - ticks / 700 % 50)); + } + + if (ticks >= app->timerDue) { + app->timerDue = ticks + 1000; + const int remaining = (int) (600 - ticks / 1000 % 600); + $(app->timer->text, setTextWithFormat, "%d:%02d", remaining / 60, remaining % 60); + } + + if (ticks >= app->chatDue) { + app->chatDue = ticks + 4000; + + Label *message = $(alloc(Label), initWithText, NULL, NULL); + $(message->text, setTextWithFormat, "Player %d: message at %d", (int) (ticks / 4000 % 8 + 1), (int) ticks); + $((View *) app->chat, addSubview, (View *) message); + release(message); + + const Array *messages = (Array *) app->chat->view.subviews; + if (messages->count > 5) { + View *first = $(messages, firstObject); + $((View *) app->chat, removeSubview, first); + } + } + + if (ticks >= app->scoreboardDue) { + app->scoreboardDue = ticks + 5000; + $((View *) app->scoreboard, setHidden, !app->scoreboard->control.view.hidden); + } +} + +#pragma mark - Timing + +/** + * @return Elapsed microseconds between the given performance counter values. + */ +static double microseconds(Uint64 start, Uint64 end) { + return (end - start) * 1e6 / (double) SDL_GetPerformanceFrequency(); +} + +/** + * @brief Accumulates one sample into the given PassStats. + */ +static void sample(PassStats *stats, Uint64 start, Uint64 end) { + + const double us = microseconds(start, end); + + stats->sum += us; + stats->max = SDL_max(stats->max, us); +} + +/** + * @brief Prints the per-pass summary and resets the accumulators. + */ +static void report(AppState *app) { + + const double n = (double) app->reportFrames; + + printf("HUD %llu frames | style avg %.1fus max %.1fus | layout avg %.1fus max %.1fus | " + "draw avg %.1fus max %.1fus | endFrame avg %.1fus max %.1fus | draws %zu verts %zu\n", + (unsigned long long) app->reportFrames, + app->style.sum / n, app->style.max, + app->layout.sum / n, app->layout.max, + app->draw.sum / n, app->draw.max, + app->endFrame.sum / n, app->endFrame.max, + app->draws, app->vertices); + + app->reportFrames = 0; + app->style = app->layout = app->draw = app->endFrame = (PassStats) { 0 }; +} + +#pragma mark - SDL application callbacks + +/** + * @brief SDL3 application initialization callback. + */ +SDL_AppResult SDL_AppInit(void **appState, int argc, char *argv[]) { + + AppState *app = *appState = &application; + + MVC_LogSetPriority(SDL_LOG_PRIORITY_WARN); + + MVC_Assert(SDL_Init(SDL_INIT_VIDEO), "SDL_Init"); + + SDL_WindowFlags flags = SDL_WINDOW_HIGH_PIXEL_DENSITY; + + const char *hidden = SDL_getenv("MVC_HUD_HIDDEN"); + if (hidden && *hidden == '1') { + flags |= SDL_WINDOW_HIDDEN; + } + + const char *frames = SDL_getenv("MVC_HUD_FRAMES"); + if (frames) { + app->maxFrames = SDL_strtoull(frames, NULL, 10); + } + + app->window = SDL_CreateWindow("ObjectivelyMVC HUD", HUD_WINDOW_W, HUD_WINDOW_H, flags); + MVC_Assert(app->window, "SDL_CreateWindow"); + + app->renderDevice = $(alloc(RenderDevice), initWithWindow, app->window, NULL); + + int w = 0, h = 0; + SDL_GetWindowSizeInPixels(app->window, &w, &h); + + const SDL_GPUTextureFormat format = $(app->renderDevice, getSwapchainTextureFormat); + + app->framebuffer = $(app->renderDevice, createFramebuffer, &(GPU_FramebufferCreateInfo) { + .size = MakeSize(w, h), + .colorAttachments = { { .format = format, .clearColor = { 0.05f, 0.05f, 0.1f, 1.f } } }, + .numColorTargets = 1, + .sampleCount = SDL_GPU_SAMPLECOUNT_1, + }); + + $(app->renderDevice, setFramebuffer, app->framebuffer); + + app->windowController = $(alloc(WindowController), initWithDevice, app->renderDevice); + + ViewController *viewController = $(alloc(ViewController), init); + $(app->windowController, setViewController, viewController); + release(viewController); + + buildHUD(app, viewController->view); + + app->scoreboardDue = SDL_GetTicks() + 5000; + app->reportDue = SDL_GetTicks() + 1000; + + return SDL_APP_CONTINUE; +} + +/** + * @brief SDL3 frame iteration callback. + * @details Hand-rolls WindowController::renderTo in order to time each pass + * (style, layout, draw, endFrame) individually. + */ +SDL_AppResult SDL_AppIterate(void *appState) { + + AppState *app = appState; + + const Uint64 ticks = SDL_GetTicks(); + + updateHUD(app, ticks); + + CommandBuffer *commands = $(app->renderDevice, beginFrame); + if (commands) { + + const SDL_GPUColorTargetInfo color = $(app->framebuffer, colorTargetInfo, 0, SDL_GPU_LOADOP_CLEAR, SDL_GPU_STOREOP_STORE); + RenderPass *clear = $(commands, beginRenderPass, &color, 1, NULL); + release(clear); + + WindowController *windowController = app->windowController; + View *view = windowController->viewController->view; + Renderer *renderer = windowController->renderer; + + $(renderer, beginFrameWith, commands, app->framebuffer); + + const Uint64 t0 = SDL_GetPerformanceCounter(); + $(view, applyThemeIfNeeded, windowController->theme); + + const Uint64 t1 = SDL_GetPerformanceCounter(); + $(view, layoutIfNeeded); + + const Uint64 t2 = SDL_GetPerformanceCounter(); + $(view, draw, renderer); + + const Uint64 t3 = SDL_GetPerformanceCounter(); + app->draws = renderer->drawArrays->count; + app->vertices = renderer->vertices->count; + $(renderer, endFrame); + + const Uint64 t4 = SDL_GetPerformanceCounter(); + + sample(&app->style, t0, t1); + sample(&app->layout, t1, t2); + sample(&app->draw, t2, t3); + sample(&app->endFrame, t3, t4); + app->reportFrames++; + + $(app->renderDevice, endFrame); + } + + app->frames++; + + if (ticks >= app->reportDue && app->reportFrames) { + app->reportDue = ticks + 1000; + report(app); + } + + if (app->maxFrames && app->frames >= app->maxFrames) { + if (app->reportFrames) { + report(app); + } + return SDL_APP_SUCCESS; + } + + return SDL_APP_CONTINUE; +} + +/** + * @brief SDL3 event callback. + */ +SDL_AppResult SDL_AppEvent(void *appState, SDL_Event *event) { + + AppState *app = appState; + + $(app->windowController, respondToEvent, event); + + if (event->type == SDL_EVENT_QUIT) { + return SDL_APP_SUCCESS; + } + + return SDL_APP_CONTINUE; +} + +/** + * @brief SDL3 quit callback. + */ +void SDL_AppQuit(void *appState, SDL_AppResult result) { + + AppState *app = appState; + + $(app->renderDevice, waitForIdle); + + release(app->windowController); + release(app->framebuffer); + release(app->renderDevice); + + SDL_DestroyWindow(app->window); + + SDL_Quit(); +} diff --git a/Examples/Makefile.am b/Examples/Makefile.am index 7b1dcc06..a1a1edf1 100644 --- a/Examples/Makefile.am +++ b/Examples/Makefile.am @@ -2,7 +2,8 @@ noinst_HEADERS = \ HelloViewController.h noinst_PROGRAMS = \ - Hello + Hello \ + HUD CFLAGS += \ -I$(top_srcdir)/Assets \ @@ -25,6 +26,9 @@ Hello_SOURCES = \ HelloViewController.c \ Hello.c +HUD_SOURCES = \ + HUD.c + # Runtime assets loaded via the Resource API (from the EXAMPLES path), plus the # versioned shader sources/blobs. Normal builds never need glslc/shadercross; # run 'make shaders' after editing a .glsl. From 893a58c044228d64d707364e2372b2d2cae15c32 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 20:49:02 -0400 Subject: [PATCH 02/27] Add View::setNeedsLayout and View::setNeedsApplyTheme Introduce propagating setters for the dirty flags: in addition to setting the flag on the View, they mark needsLayoutSubviews or needsApplyThemeSubviews on each ancestor, recording that a descendant is dirty. The ancestor walk stops at the first already-marked View, so repeated invalidations are amortized O(1). Convert every in-tree flag write to the setters (View internals and all widgets). No behavior change yet: the subtree flags are not read until the traversal gating that follows. Applications that assign needsLayout or needsApplyTheme directly MUST migrate to the setters; direct writes will not propagate, and the View may be skipped once applyThemeIfNeeded and layoutIfNeeded are gated on the subtree flags. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/CollectionView.c | 2 +- Sources/ObjectivelyMVC/Control.c | 2 +- Sources/ObjectivelyMVC/Label.c | 2 +- Sources/ObjectivelyMVC/Option.c | 2 +- Sources/ObjectivelyMVC/PageView.c | 2 +- Sources/ObjectivelyMVC/ProgressBar.c | 2 +- Sources/ObjectivelyMVC/ScrollBar.c | 6 ++-- Sources/ObjectivelyMVC/ScrollView.c | 8 ++--- Sources/ObjectivelyMVC/Select.c | 6 ++-- Sources/ObjectivelyMVC/Slider.c | 2 +- Sources/ObjectivelyMVC/TabView.c | 2 +- Sources/ObjectivelyMVC/TableView.c | 4 +-- Sources/ObjectivelyMVC/TextView.c | 6 ++-- Sources/ObjectivelyMVC/View.c | 45 ++++++++++++++++++++----- Sources/ObjectivelyMVC/View.h | 43 +++++++++++++++++++++++ 15 files changed, 103 insertions(+), 31 deletions(-) diff --git a/Sources/ObjectivelyMVC/CollectionView.c b/Sources/ObjectivelyMVC/CollectionView.c index cc678aed..e8965d4c 100644 --- a/Sources/ObjectivelyMVC/CollectionView.c +++ b/Sources/ObjectivelyMVC/CollectionView.c @@ -429,7 +429,7 @@ static void reloadData(CollectionView *self) { release(indexPath); } - ((View *) self)->needsLayout = true; + $((View *) self, setNeedsLayout); } /** diff --git a/Sources/ObjectivelyMVC/Control.c b/Sources/ObjectivelyMVC/Control.c index 866cb7b3..f78edb91 100644 --- a/Sources/ObjectivelyMVC/Control.c +++ b/Sources/ObjectivelyMVC/Control.c @@ -352,7 +352,7 @@ static void stateDidChange(Control *self) { $(this, invalidateStyle); - this->needsLayout = true; + $(this, setNeedsLayout); } #pragma mark - Class lifecycle diff --git a/Sources/ObjectivelyMVC/Label.c b/Sources/ObjectivelyMVC/Label.c index 22831809..0a28b37a 100644 --- a/Sources/ObjectivelyMVC/Label.c +++ b/Sources/ObjectivelyMVC/Label.c @@ -78,7 +78,7 @@ static void awakeWithDictionary(View *self, const Dictionary *dictionary) { $(self, bind, inlets, dictionary); - self->needsLayout = true; + $(self, setNeedsLayout); } /** diff --git a/Sources/ObjectivelyMVC/Option.c b/Sources/ObjectivelyMVC/Option.c index b563ebcf..601b6e80 100644 --- a/Sources/ObjectivelyMVC/Option.c +++ b/Sources/ObjectivelyMVC/Option.c @@ -118,7 +118,7 @@ static void setSelected(Option *self, bool isSelected) { $((View *) self, invalidateStyle); - self->view.needsLayout = true; + $((View *) self, setNeedsLayout); } } diff --git a/Sources/ObjectivelyMVC/PageView.c b/Sources/ObjectivelyMVC/PageView.c index 5aa28aaa..93b07278 100644 --- a/Sources/ObjectivelyMVC/PageView.c +++ b/Sources/ObjectivelyMVC/PageView.c @@ -148,7 +148,7 @@ static void setCurrentPage(PageView *self, View *currentPage) { } } - self->view.needsLayout = true; + $((View *) self, setNeedsLayout); } } diff --git a/Sources/ObjectivelyMVC/ProgressBar.c b/Sources/ObjectivelyMVC/ProgressBar.c index db873b7b..9fd7c787 100644 --- a/Sources/ObjectivelyMVC/ProgressBar.c +++ b/Sources/ObjectivelyMVC/ProgressBar.c @@ -173,7 +173,7 @@ static void setValue(ProgressBar *self, double value) { const double frac = self->value / (self->max - self->min); self->foreground->view.frame.w = bounds.w * frac; - self->view.needsLayout = true; + $((View *) self, setNeedsLayout); $(self, formatLabel); diff --git a/Sources/ObjectivelyMVC/ScrollBar.c b/Sources/ObjectivelyMVC/ScrollBar.c index 94692746..6617f5f3 100644 --- a/Sources/ObjectivelyMVC/ScrollBar.c +++ b/Sources/ObjectivelyMVC/ScrollBar.c @@ -58,7 +58,7 @@ static void didDragHandle(ScrollHandle *handle, float delta) { offset.y -= (int) (delta * ((float) scrollRange / travel)); $(self->scrollView, scrollToOffset, &offset); - ((View *) self)->needsLayout = true; + $((View *) self, setNeedsLayout); } } } @@ -146,7 +146,7 @@ static void respondToEvent(View *self, const SDL_Event *event) { } $(this->scrollView, scrollToOffset, &offset); - self->needsLayout = true; + $(self, setNeedsLayout); } return; @@ -198,7 +198,7 @@ static void setScrollView(ScrollBar *self, ScrollView *scrollView) { self->scrollView = scrollView; - ((View *) self)->needsLayout = true; + $((View *) self, setNeedsLayout); } #pragma mark - Class lifecycle diff --git a/Sources/ObjectivelyMVC/ScrollView.c b/Sources/ObjectivelyMVC/ScrollView.c index edfc10e6..4d41d854 100644 --- a/Sources/ObjectivelyMVC/ScrollView.c +++ b/Sources/ObjectivelyMVC/ScrollView.c @@ -76,7 +76,7 @@ static void applyStyle(View *self, const Style *style) { $(self, bind, inlets, (Dictionary *) style->attributes); - self->needsLayout = true; + $(self, setNeedsLayout); } /** @@ -207,8 +207,8 @@ static void scrollToOffset(ScrollView *self, const SDL_Point *offset) { self->contentOffset.x = self->contentOffset.y = 0; } - self->control.view.needsLayout = true; - ((View *) self->scrollBar)->needsLayout = true; + $((View *) self, setNeedsLayout); + $((View *) self->scrollBar, setNeedsLayout); } /** @@ -244,7 +244,7 @@ static void setContentView(ScrollView *self, View *contentView) { static void setScrollBarVisibility(ScrollView *self, ScrollBarVisibility visibility) { self->scrollBarVisibility = visibility; - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } #pragma mark - Class lifecycle diff --git a/Sources/ObjectivelyMVC/Select.c b/Sources/ObjectivelyMVC/Select.c index a65a5527..ec43a440 100644 --- a/Sources/ObjectivelyMVC/Select.c +++ b/Sources/ObjectivelyMVC/Select.c @@ -296,7 +296,7 @@ static void addOption(Select *self, const char *title, ident value) { release(option); - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } /** @@ -372,7 +372,7 @@ static void removeOption(Select *self, Option *option) { } } - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } } @@ -412,7 +412,7 @@ static void selectOption(Select *self, Option *option) { } } - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } /** diff --git a/Sources/ObjectivelyMVC/Slider.c b/Sources/ObjectivelyMVC/Slider.c index e5b3f83c..fcf194f9 100644 --- a/Sources/ObjectivelyMVC/Slider.c +++ b/Sources/ObjectivelyMVC/Slider.c @@ -309,7 +309,7 @@ static void setValue(Slider *self, double value) { const double delta = fabs(self->value - value); if (delta > __DBL_EPSILON__) { self->value = value; - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); $(self, formatLabel); } diff --git a/Sources/ObjectivelyMVC/TabView.c b/Sources/ObjectivelyMVC/TabView.c index c17f31ce..c30d74e6 100644 --- a/Sources/ObjectivelyMVC/TabView.c +++ b/Sources/ObjectivelyMVC/TabView.c @@ -250,7 +250,7 @@ static void selectTab(TabView *self, TabViewItem *tab) { } } - self->stackView.view.needsLayout = true; + $((View *) self, setNeedsLayout); } } diff --git a/Sources/ObjectivelyMVC/TableView.c b/Sources/ObjectivelyMVC/TableView.c index a7a997a9..25f7fe3a 100644 --- a/Sources/ObjectivelyMVC/TableView.c +++ b/Sources/ObjectivelyMVC/TableView.c @@ -111,7 +111,7 @@ static void layoutSubviews(View *self) { } scrollView->frame = frame; - scrollView->needsLayout = true; + $(scrollView, setNeedsLayout); $(scrollView, layoutIfNeeded); } @@ -438,7 +438,7 @@ static void reloadData(TableView *self) { $((View *) self->contentView, addSubview, (View *) row); } - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } /** diff --git a/Sources/ObjectivelyMVC/TextView.c b/Sources/ObjectivelyMVC/TextView.c index 5967db70..81ea1ba3 100644 --- a/Sources/ObjectivelyMVC/TextView.c +++ b/Sources/ObjectivelyMVC/TextView.c @@ -308,7 +308,7 @@ static bool captureEvent(Control *self, const SDL_Event *event) { } if (didEdit) { - self->view.needsLayout = true; + $((View *) self, setNeedsLayout); if (this->delegate.didEdit) { this->delegate.didEdit(this); } @@ -388,7 +388,7 @@ static void setAttributedText(TextView *self, const char *attributedText) { self->position = self->attributedText->length; - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } } @@ -408,7 +408,7 @@ static void setDefaultText(TextView *self, const char *defaultText) { self->defaultText = NULL; } - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } } diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index 5fe8795b..9cbd1c26 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -217,7 +217,7 @@ static void addSubviewRelativeTo(View *self, View *subview, View *other, ViewPos $(subview, invalidateStyle); - self->needsLayout = true; + $(self, setNeedsLayout); } /** @@ -485,8 +485,8 @@ static bool _bind(View *self, const Inlet *inlets, const Dictionary *dictionary) if (inlets) { if (bindInlets(inlets, dictionary)) { - self->needsApplyTheme = true; - self->needsLayout = true; + $(self, setNeedsApplyTheme); + $(self, setNeedsLayout); return true; } } @@ -655,7 +655,7 @@ static void didMoveToWindow(View *self, SDL_Window *window) { $(self, sizeToFill); } - self->needsLayout = true; + $(self, setNeedsLayout); } } @@ -1030,6 +1030,7 @@ static void invalidateStyle_enumerate(View *view, ident data) { */ static void invalidateStyle(View *self) { $(self, enumerate, invalidateStyle_enumerate, NULL); + $(self, setNeedsApplyTheme); } /** @@ -1398,7 +1399,7 @@ static void removeSubview(View *self, View *subview) { $(self->subviews, removeObject, subview); - self->needsLayout = true; + $(self, setNeedsLayout); } } @@ -1551,10 +1552,10 @@ static void resize(View *self, const SDL_Size *size) { self->frame.w = w; self->frame.h = h; - self->needsLayout = true; + $(self, setNeedsLayout); if (self->superview && $(self->superview, isContainer)) { - self->superview->needsLayout = true; + $(self->superview, setNeedsLayout); } } } @@ -1672,11 +1673,37 @@ static void setHidden(View *self, bool hidden) { self->hidden = hidden; if (self->superview && $(self->superview, isContainer)) { - self->superview->needsLayout = true; + $(self->superview, setNeedsLayout); } } } +/** + * @fn void View::setNeedsApplyTheme(View *self) + * @memberof View + */ +static void setNeedsApplyTheme(View *self) { + + self->needsApplyTheme = true; + + for (View *view = self->superview; view && !view->needsApplyThemeSubviews; view = view->superview) { + view->needsApplyThemeSubviews = true; + } +} + +/** + * @fn void View::setNeedsLayout(View *self) + * @memberof View + */ +static void setNeedsLayout(View *self) { + + self->needsLayout = true; + + for (View *view = self->superview; view && !view->needsLayoutSubviews; view = view->superview) { + view->needsLayoutSubviews = true; + } +} + /** * @fn SDL_Size View::size(const View *self) * @memberof View @@ -2110,6 +2137,8 @@ static void initialize(Class *clazz) { ((ViewInterface *) clazz->interface)->select = _select; ((ViewInterface *) clazz->interface)->selectFirst = selectFirst; ((ViewInterface *) clazz->interface)->setHidden = setHidden; + ((ViewInterface *) clazz->interface)->setNeedsApplyTheme = setNeedsApplyTheme; + ((ViewInterface *) clazz->interface)->setNeedsLayout = setNeedsLayout; ((ViewInterface *) clazz->interface)->size = size; ((ViewInterface *) clazz->interface)->sizeThatContains = sizeThatContains; ((ViewInterface *) clazz->interface)->sizeThatFills = sizeThatFills; diff --git a/Sources/ObjectivelyMVC/View.h b/Sources/ObjectivelyMVC/View.h index 2fd90596..0cc9d66d 100644 --- a/Sources/ObjectivelyMVC/View.h +++ b/Sources/ObjectivelyMVC/View.h @@ -252,14 +252,35 @@ struct View { /** * @brief If true, this View will apply the Theme before it is drawn. + * @remarks Set this via View::setNeedsApplyTheme, which propagates + * `needsApplyThemeSubviews` to ancestors; a direct write does not, and the View MAY be + * skipped by View::applyThemeIfNeeded. */ bool needsApplyTheme; + /** + * @brief If true, a descendant of this View has `needsApplyTheme` set. + * @remarks Maintained by View::setNeedsApplyTheme; View::applyThemeIfNeeded only descends + * into subtrees with this flag set. + * @private + */ + bool needsApplyThemeSubviews; + /** * @brief If true, this View will layout its subviews before it is drawn. + * @remarks Set this via View::setNeedsLayout, which propagates `needsLayoutSubviews` to + * ancestors; a direct write does not, and the View MAY be skipped by View::layoutIfNeeded. */ bool needsLayout; + /** + * @brief If true, a descendant of this View has `needsLayout` set. + * @remarks Maintained by View::setNeedsLayout; View::layoutIfNeeded only descends into + * subtrees with this flag set. + * @private + */ + bool needsLayoutSubviews; + /** * @brief The next responder, or event handler, in the chain. * @remarks By default, Views propagate events to their superview. If this member is not `NULL`, @@ -1055,6 +1076,28 @@ struct ViewInterface { */ void (*setHidden)(View *self, bool hidden); + /** + * @fn void View::setNeedsApplyTheme(View *self) + * @brief Marks this View as needing Theme application before it is next drawn. + * @param self The View. + * @remarks Callers MUST use this method rather than assigning `needsApplyTheme` directly: + * it propagates `needsApplyThemeSubviews` to ancestors, which View::applyThemeIfNeeded + * requires in order to find this View. A View flagged by direct assignment MAY be skipped. + * @memberof View + */ + void (*setNeedsApplyTheme)(View *self); + + /** + * @fn void View::setNeedsLayout(View *self) + * @brief Marks this View as needing layout before it is next drawn. + * @param self The View. + * @remarks Callers MUST use this method rather than assigning `needsLayout` directly: it + * propagates `needsLayoutSubviews` to ancestors, which View::layoutIfNeeded requires in + * order to find this View. A View flagged by direct assignment MAY be skipped. + * @memberof View + */ + void (*setNeedsLayout)(View *self); + /** * @fn SDL_Size View::size(const View *self) * @param self The View. From 2a6c8b166b9e3433f116610de74a5e6837221415 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 20:51:18 -0400 Subject: [PATCH 03/27] Gate applyThemeIfNeeded and layoutIfNeeded on subtree dirty flags Both traversals previously recursed into every subview each frame, making the per-frame cost O(tree) even when nothing was invalidated. They now return immediately unless the View or a descendant is dirty, making the steady-state cost O(dirty path). The subtree flag is cleared before doing any work, so invalidations that occur during the traversal itself (e.g. View::resize propagating setNeedsLayout, or a widget marking a sibling mid-layout) survive to the next frame rather than being lost. Behavior change: a dirty View's subviews are no longer laid out before the View itself. Previously layoutIfNeeded recursed into children first and then re-arranged them via layoutWithConstraint, laying out dirty children twice; children are now laid out once, by their parent's layout pass, with a follow-up recursion catching any descendant skipped by an overridden layoutSubviews. Examples/HUD idle frames: style pass avg 2-5us -> 0.2us; layout pass avg similar with maxima reduced. All tests pass. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/View.c | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index 9cbd1c26..f2edfaaf 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -327,6 +327,14 @@ static void applyThemeIfNeeded(View *self, const Theme *theme) { assert(theme); + if (!self->needsApplyTheme && !self->needsApplyThemeSubviews) { + return; + } + + // Cleared before descending so that any re-invalidation during the traversal + // propagates fresh subtree flags and survives to the next frame. + self->needsApplyThemeSubviews = false; + $(self, enumerateSubviews, _applyThemeIfNeeded, (ident) theme); if (self->needsApplyTheme) { @@ -1113,7 +1121,13 @@ static void layoutIfNeeded_enumerate(View *subview, ident data) { */ static void layoutIfNeeded(View *self) { - $(self, enumerateSubviews, layoutIfNeeded_enumerate, NULL); + if (!self->needsLayout && !self->needsLayoutSubviews) { + return; + } + + // Cleared before laying out so that any re-invalidation during layout (e.g. a resize + // propagating setNeedsLayout, or a widget marking a sibling) survives to the next frame. + self->needsLayoutSubviews = false; if (self->needsLayout) { @@ -1127,6 +1141,11 @@ static void layoutIfNeeded(View *self) { $(self, layoutWithConstraint, w, h); } + + // Subviews just arranged by layoutWithConstraint are clean, so this recursion visits only + // direct subviews; it descends only where a dirty descendant was skipped by an overridden + // layoutSubviews, or was marked during the layout above. + $(self, enumerateSubviews, layoutIfNeeded_enumerate, NULL); } /** From c984ed25e08871a65653c5ed8822f97ea8456f25 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 20:54:16 -0400 Subject: [PATCH 04/27] Cache View::renderFrame and View::clippingFrame per render pass Both were recomputed from scratch on every call -- renderFrame is O(depth), and clippingFrame recursed into every clipping ancestor's clippingFrame, making it super-linear -- and they are called three to six times per View per frame (Renderer::drawView, View::render, subclass render methods), plus twice per View per mouse motion event. Views now memoize both rects, stamped against a process-global render frame generation. WindowController::renderTo bumps the generation via MVC_InvalidateRenderFrames after layout and before drawing, so the draw pass and subsequent hit-testing observe post-layout frames at O(1) amortized per View. A zero generation (callers that never invalidate, e.g. unit tests) disables cache reads entirely. clippingFrame now intersects only the nearest clipping ancestor's clippingFrame, which already folds in every outer clip; this is equivalent to the previous every-ancestor loop without the redundant super-linear work. Frames mutated outside of layout (e.g. Panel dragging) are observed by hit-testing at the next render rather than immediately; MVC_InvalidateRenderFrames is exported for callers that need same-batch precision. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/View.c | 73 ++++++++++++++++------- Sources/ObjectivelyMVC/View.h | 35 +++++++++++ Sources/ObjectivelyMVC/WindowController.c | 6 ++ 3 files changed, 91 insertions(+), 23 deletions(-) diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index f2edfaaf..13f3293b 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -36,6 +36,17 @@ Uint32 MVC_NOTIFICATION_EVENT; Uint32 MVC_VIEW_EVENT; +/** + * @brief The render frame generation; per-View renderFrame and clippingFrame caches are + * valid only while their stamp matches this. Zero disables caching entirely, for callers + * that never invalidate (e.g. unit tests). + */ +static uint64_t _renderFrameGeneration; + +void MVC_InvalidateRenderFrames(void) { + _renderFrameGeneration++; +} + const EnumName ViewAlignmentNames[] = MakeEnumNames( MakeEnumAlias(ViewAlignmentNone, none), MakeEnumAlias(ViewAlignmentTop, top), @@ -562,29 +573,38 @@ static void clearWarnings(const View *self, WarningType type) { */ static SDL_Rect clippingFrame(const View *self) { + View *this = (View *) self; + + if (_renderFrameGeneration && this->clippingFrameStamp == _renderFrameGeneration) { + return this->cachedClippingFrame; + } + SDL_Rect frame = $(self, renderFrame); if (self->borderWidth && self->borderColor.a) { - for (int i = 0; i < self->borderWidth; i++) { - frame.x -= 1; - frame.y -= 1; - frame.w += 2; - frame.h += 2; - } + frame.x -= self->borderWidth; + frame.y -= self->borderWidth; + frame.w += self->borderWidth * 2; + frame.h += self->borderWidth * 2; } + // The nearest clipping ancestor's clippingFrame already folds in every outer clip, so a + // single intersection with it is equivalent to intersecting each clipping ancestor in turn. const View *superview = self->superview; - while (superview) { - if (superview->clipsSubviews) { - const SDL_Rect clippingFrame = $(superview, clippingFrame); - if (SDL_GetRectIntersection(&clippingFrame, &frame, &frame) == false) { - frame.w = frame.h = 0; - break; - } - } + while (superview && !superview->clipsSubviews) { superview = superview->superview; } + if (superview) { + const SDL_Rect clippingFrame = $(superview, clippingFrame); + if (SDL_GetRectIntersection(&clippingFrame, &frame, &frame) == false) { + frame.w = frame.h = 0; + } + } + + this->cachedClippingFrame = frame; + this->clippingFrameStamp = _renderFrameGeneration; + return frame; } @@ -1491,24 +1511,31 @@ static void renderDeviceWillReset(View *self) { */ static SDL_Rect renderFrame(const View *self) { + View *this = (View *) self; + + if (_renderFrameGeneration && this->renderFrameStamp == _renderFrameGeneration) { + return this->cachedRenderFrame; + } + SDL_Rect frame = self->frame; - const View *view = self; - const View *superview = view->superview; - while (superview) { + const View *superview = self->superview; + if (superview) { - frame.x += superview->frame.x; - frame.y += superview->frame.y; + const SDL_Rect superFrame = $(superview, renderFrame); - if (view->alignment != ViewAlignmentInternal) { + frame.x += superFrame.x; + frame.y += superFrame.y; + + if (self->alignment != ViewAlignmentInternal) { frame.x += superview->padding.left; frame.y += superview->padding.top; } - - view = superview; - superview = view->superview; } + this->cachedRenderFrame = frame; + this->renderFrameStamp = _renderFrameGeneration; + return frame; } diff --git a/Sources/ObjectivelyMVC/View.h b/Sources/ObjectivelyMVC/View.h index 0cc9d66d..b54e4667 100644 --- a/Sources/ObjectivelyMVC/View.h +++ b/Sources/ObjectivelyMVC/View.h @@ -207,12 +207,32 @@ struct View { */ int borderWidth; + /** + * @brief The cached View::clippingFrame, valid while `clippingFrameStamp` matches the + * current render frame generation. + * @private + */ + SDL_Rect cachedClippingFrame; + + /** + * @brief The cached View::renderFrame, valid while `renderFrameStamp` matches the current + * render frame generation. + * @private + */ + SDL_Rect cachedRenderFrame; + /** * @brief The class names. * @see Style */ Set *classNames; + /** + * @brief The render frame generation at which `cachedClippingFrame` was computed. + * @private + */ + uint64_t clippingFrameStamp; + /** * @brief If true, subviews will be clipped to this View's frame. */ @@ -293,6 +313,12 @@ struct View { */ ViewPadding padding; + /** + * @brief The render frame generation at which `cachedRenderFrame` was computed. + * @private + */ + uint64_t renderFrameStamp; + /** * @brief The element-level Style of this View. * @remarks Attributes in this Style are local to this View, and override any Attributes matched @@ -1306,3 +1332,12 @@ struct ViewInterface { * @memberof View */ OBJECTIVELYMVC_EXPORT Class *_View(void); + +/** + * @brief Invalidates every View's cached View::renderFrame and View::clippingFrame. + * @details WindowController::renderTo invokes this after layout, before drawing, so that + * drawing and subsequent hit-testing observe post-layout frames; both methods memoize their + * result until the next invocation. Callers that mutate View frames outside of a layout pass + * MUST invoke this afterwards if hit-testing precision is required before the next render. + */ +OBJECTIVELYMVC_EXPORT void MVC_InvalidateRenderFrames(void); diff --git a/Sources/ObjectivelyMVC/WindowController.c b/Sources/ObjectivelyMVC/WindowController.c index 30dca2c7..834541d6 100644 --- a/Sources/ObjectivelyMVC/WindowController.c +++ b/Sources/ObjectivelyMVC/WindowController.c @@ -90,6 +90,9 @@ static void debug(WindowController *self) { $(debugViewController->view, applyThemeIfNeeded, self->theme); $(debugViewController->view, layoutIfNeeded); + + MVC_InvalidateRenderFrames(); + $(debugViewController->view, draw, self->renderer); } } @@ -174,6 +177,9 @@ static void renderTo(WindowController *self, CommandBuffer *commands, Framebuffe $(self->viewController->view, applyThemeIfNeeded, self->theme); $(self->viewController->view, layoutIfNeeded); + + MVC_InvalidateRenderFrames(); + $(self->viewController->view, draw, self->renderer); $(self, debug); From 584d5812f8f810dacdaac6d24a5ed7ea4e75af66 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 20:55:57 -0400 Subject: [PATCH 05/27] Memoize Text::naturalSize Text measurement went through Font::sizeCharacters on every call, which strdups the string and invokes SDL_ttf per line; a container measuring its children during layout re-measured every unchanged Text descendant. The color escapes path is far more expensive still. Cache the measured size on the Text, keyed by the Font's scale so pixel-density changes re-measure without additional hooks, and invalidate wherever the rendered texture is invalidated (setText, setFont, color change, scale change, device reset) as well as in awakeWithDictionary, whose text inlet bypasses setText. Examples/HUD layout-pass maxima on widget-update frames drop from ~25-50us to ~5-10us. All tests pass. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/Text.c | 17 +++++++++++++++++ Sources/ObjectivelyMVC/Text.h | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Sources/ObjectivelyMVC/Text.c b/Sources/ObjectivelyMVC/Text.c index 59b80d2f..8203decd 100644 --- a/Sources/ObjectivelyMVC/Text.c +++ b/Sources/ObjectivelyMVC/Text.c @@ -286,6 +286,7 @@ static void applyStyle(View *self, const Style *style) { if ($(self, bind, colorInlets, style->attributes)) { this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); + this->naturalSizeValid = false; } char *fontFamily = NULL; @@ -329,6 +330,8 @@ static void awakeWithDictionary(View *self, const Dictionary *dictionary) { $(self, bind, inlets, dictionary); + this->naturalSizeValid = false; + $(self, sizeToFit); } @@ -356,6 +359,7 @@ static void render(View *self, Renderer *renderer) { $(self, renderDeviceDidReset); this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); + this->naturalSizeValid = false; } if (this->text) { @@ -465,6 +469,7 @@ static void renderDeviceWillReset(View *self) { this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); + this->naturalSizeValid = false; super(View, self, renderDeviceWillReset); } @@ -499,6 +504,10 @@ static Text *initWithText(Text *self, const char *text, Font *font) { */ static SDL_Size naturalSize(const Text *self) { + if (self->naturalSizeValid && self->font && self->font->scale == self->naturalSizeScale) { + return self->naturalSizeCache; + } + SDL_Size size = MakeSize(0, 0); if (self->font) { @@ -509,6 +518,12 @@ static SDL_Size naturalSize(const Text *self) { } else { $(self->font, sizeCharacters, text, &size.w, &size.h); } + + Text *this = (Text *) self; + + this->naturalSizeCache = size; + this->naturalSizeScale = self->font->scale; + this->naturalSizeValid = true; } return size; @@ -529,6 +544,7 @@ static void setFont(Text *self, Font *font) { self->texture = release(self->texture); self->textureSize = MakeSize(0, 0); + self->naturalSizeValid = false; $((View *) self, sizeToFit); } @@ -552,6 +568,7 @@ static void setText(Text *self, const char *text) { self->texture = release(self->texture); self->textureSize = MakeSize(0, 0); + self->naturalSizeValid = false; $((View *) self, sizeToFit); } diff --git a/Sources/ObjectivelyMVC/Text.h b/Sources/ObjectivelyMVC/Text.h index 3e4c9e06..b2bfa5e4 100644 --- a/Sources/ObjectivelyMVC/Text.h +++ b/Sources/ObjectivelyMVC/Text.h @@ -105,6 +105,25 @@ struct Text { */ bool lineWrap; + /** + * @brief The cached Text::naturalSize, valid while `naturalSizeValid` is set and + * `naturalSizeScale` matches the Font's scale. + * @private + */ + SDL_Size naturalSizeCache; + + /** + * @brief The Font scale at which `naturalSizeCache` was measured. + * @private + */ + float naturalSizeScale; + + /** + * @brief True while `naturalSizeCache` is valid. + * @private + */ + bool naturalSizeValid; + /** * @brief The text. * @remarks Do not set this property directly. From 568395c68490d3849a5c23640b118f6c2ae2da89 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 20:57:43 -0400 Subject: [PATCH 06/27] Reduce Renderer draw calls and per-vertex overhead pushDrawArrays now merges a record into the previous one when both bind the same texture and scissor: vertices are appended contiguously, so extending the prior record's vertexCount is equivalent and saves a setScissor, bindFragmentSamplers and drawPrimitives per merged record in endFrame. Blending order is preserved since only adjacent records merge. Vertices are appended with one capacity check and a direct array write instead of a virtual Vector::add per vertex, and drawLines uses a stack buffer for polylines up to 16 segments (drawLine and drawRect always qualify) instead of a malloc/free per call -- previously every bordered View allocated every frame. Merging favors untextured geometry (backgrounds, borders, bevels share the 1x1 white texture); distinct Text textures still cost one draw each. All tests pass; Examples/HUD and Hello render identically. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/Renderer.c | 33 +++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/Sources/ObjectivelyMVC/Renderer.c b/Sources/ObjectivelyMVC/Renderer.c index 9cf32150..6cba8f9b 100644 --- a/Sources/ObjectivelyMVC/Renderer.c +++ b/Sources/ObjectivelyMVC/Renderer.c @@ -144,7 +144,9 @@ static void drawLines(const Renderer *self, const SDL_Point *points, size_t coun } const size_t segCount = count - 1; - MVC_Vertex *verts = malloc(segCount * 6 * sizeof(MVC_Vertex)); + + MVC_Vertex stack[16 * 6]; + MVC_Vertex *verts = segCount <= 16 ? stack : malloc(segCount * 6 * sizeof(MVC_Vertex)); assert(verts); for (size_t i = 0; i < segCount; i++) { @@ -171,7 +173,9 @@ static void drawLines(const Renderer *self, const SDL_Point *points, size_t coun $(self, pushDrawArrays, verts, segCount * 6, NULL, color); - free(verts); + if (verts != stack) { + free(verts); + } } /** @@ -369,13 +373,30 @@ static void pushDrawArrays(const Renderer *self, const MVC_Vertex *verts, size_t .scissor = self->scissor, }; + Vector *vertices = self->vertices; + + if (vertices->count + count > vertices->capacity) { + $(vertices, resize, max(vertices->capacity * 2, vertices->count + count)); + } + + MVC_Vertex *out = VectorElement(vertices, MVC_Vertex, vertices->count); for (size_t i = 0; i < count; i++) { - MVC_Vertex v = verts[i]; - v.color = *color; - $(self->vertices, add, &v); + out[i] = verts[i]; + out[i].color = *color; } - $(self->drawArrays, add, (MVC_DrawArrays *) &draw); + vertices->count += count; + + // Vertices are appended contiguously, so a record contiguous with the previous one that + // binds the same texture and scissor extends it instead of costing another draw call. + MVC_DrawArrays *last = self->drawArrays->count ? + VectorElement(self->drawArrays, MVC_DrawArrays, self->drawArrays->count - 1) : NULL; + + if (last && last->texture == draw.texture && SDL_RectsEqual(&last->scissor, &draw.scissor)) { + last->vertexCount += draw.vertexCount; + } else { + $(self->drawArrays, add, (MVC_DrawArrays *) &draw); + } } /** From 946735a7c7680b3cf203ef195f1f2a16b1ffba82 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 20:59:13 -0400 Subject: [PATCH 07/27] Group memoization fields into per-cache structs Collect each cache's value and validity into a single anonymous struct member (renderFrameCache, clippingFrameCache on View; naturalSizeCache on Text) rather than parallel loose fields, keeping the value and the stamp or flag that guards it visibly paired. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/Text.c | 22 +++++++++++----------- Sources/ObjectivelyMVC/Text.h | 22 +++++++--------------- Sources/ObjectivelyMVC/View.c | 16 ++++++++-------- Sources/ObjectivelyMVC/View.h | 30 ++++++++++++------------------ 4 files changed, 38 insertions(+), 52 deletions(-) diff --git a/Sources/ObjectivelyMVC/Text.c b/Sources/ObjectivelyMVC/Text.c index 8203decd..743c3bee 100644 --- a/Sources/ObjectivelyMVC/Text.c +++ b/Sources/ObjectivelyMVC/Text.c @@ -286,7 +286,7 @@ static void applyStyle(View *self, const Style *style) { if ($(self, bind, colorInlets, style->attributes)) { this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); - this->naturalSizeValid = false; + this->naturalSizeCache.valid = false; } char *fontFamily = NULL; @@ -330,7 +330,7 @@ static void awakeWithDictionary(View *self, const Dictionary *dictionary) { $(self, bind, inlets, dictionary); - this->naturalSizeValid = false; + this->naturalSizeCache.valid = false; $(self, sizeToFit); } @@ -359,7 +359,7 @@ static void render(View *self, Renderer *renderer) { $(self, renderDeviceDidReset); this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); - this->naturalSizeValid = false; + this->naturalSizeCache.valid = false; } if (this->text) { @@ -469,7 +469,7 @@ static void renderDeviceWillReset(View *self) { this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); - this->naturalSizeValid = false; + this->naturalSizeCache.valid = false; super(View, self, renderDeviceWillReset); } @@ -504,8 +504,8 @@ static Text *initWithText(Text *self, const char *text, Font *font) { */ static SDL_Size naturalSize(const Text *self) { - if (self->naturalSizeValid && self->font && self->font->scale == self->naturalSizeScale) { - return self->naturalSizeCache; + if (self->naturalSizeCache.valid && self->font && self->font->scale == self->naturalSizeCache.scale) { + return self->naturalSizeCache.size; } SDL_Size size = MakeSize(0, 0); @@ -521,9 +521,9 @@ static SDL_Size naturalSize(const Text *self) { Text *this = (Text *) self; - this->naturalSizeCache = size; - this->naturalSizeScale = self->font->scale; - this->naturalSizeValid = true; + this->naturalSizeCache.size = size; + this->naturalSizeCache.scale = self->font->scale; + this->naturalSizeCache.valid = true; } return size; @@ -544,7 +544,7 @@ static void setFont(Text *self, Font *font) { self->texture = release(self->texture); self->textureSize = MakeSize(0, 0); - self->naturalSizeValid = false; + self->naturalSizeCache.valid = false; $((View *) self, sizeToFit); } @@ -568,7 +568,7 @@ static void setText(Text *self, const char *text) { self->texture = release(self->texture); self->textureSize = MakeSize(0, 0); - self->naturalSizeValid = false; + self->naturalSizeCache.valid = false; $((View *) self, sizeToFit); } diff --git a/Sources/ObjectivelyMVC/Text.h b/Sources/ObjectivelyMVC/Text.h index b2bfa5e4..8e88d968 100644 --- a/Sources/ObjectivelyMVC/Text.h +++ b/Sources/ObjectivelyMVC/Text.h @@ -106,23 +106,15 @@ struct Text { bool lineWrap; /** - * @brief The cached Text::naturalSize, valid while `naturalSizeValid` is set and - * `naturalSizeScale` matches the Font's scale. + * @brief The cached Text::naturalSize, valid while `valid` is set and `scale` matches the + * Font's scale. * @private */ - SDL_Size naturalSizeCache; - - /** - * @brief The Font scale at which `naturalSizeCache` was measured. - * @private - */ - float naturalSizeScale; - - /** - * @brief True while `naturalSizeCache` is valid. - * @private - */ - bool naturalSizeValid; + struct { + SDL_Size size; + float scale; + bool valid; + } naturalSizeCache; /** * @brief The text. diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index 13f3293b..572c7f61 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -575,8 +575,8 @@ static SDL_Rect clippingFrame(const View *self) { View *this = (View *) self; - if (_renderFrameGeneration && this->clippingFrameStamp == _renderFrameGeneration) { - return this->cachedClippingFrame; + if (_renderFrameGeneration && this->clippingFrameCache.stamp == _renderFrameGeneration) { + return this->clippingFrameCache.frame; } SDL_Rect frame = $(self, renderFrame); @@ -602,8 +602,8 @@ static SDL_Rect clippingFrame(const View *self) { } } - this->cachedClippingFrame = frame; - this->clippingFrameStamp = _renderFrameGeneration; + this->clippingFrameCache.frame = frame; + this->clippingFrameCache.stamp = _renderFrameGeneration; return frame; } @@ -1513,8 +1513,8 @@ static SDL_Rect renderFrame(const View *self) { View *this = (View *) self; - if (_renderFrameGeneration && this->renderFrameStamp == _renderFrameGeneration) { - return this->cachedRenderFrame; + if (_renderFrameGeneration && this->renderFrameCache.stamp == _renderFrameGeneration) { + return this->renderFrameCache.frame; } SDL_Rect frame = self->frame; @@ -1533,8 +1533,8 @@ static SDL_Rect renderFrame(const View *self) { } } - this->cachedRenderFrame = frame; - this->renderFrameStamp = _renderFrameGeneration; + this->renderFrameCache.frame = frame; + this->renderFrameCache.stamp = _renderFrameGeneration; return frame; } diff --git a/Sources/ObjectivelyMVC/View.h b/Sources/ObjectivelyMVC/View.h index b54e4667..468cf93e 100644 --- a/Sources/ObjectivelyMVC/View.h +++ b/Sources/ObjectivelyMVC/View.h @@ -207,20 +207,6 @@ struct View { */ int borderWidth; - /** - * @brief The cached View::clippingFrame, valid while `clippingFrameStamp` matches the - * current render frame generation. - * @private - */ - SDL_Rect cachedClippingFrame; - - /** - * @brief The cached View::renderFrame, valid while `renderFrameStamp` matches the current - * render frame generation. - * @private - */ - SDL_Rect cachedRenderFrame; - /** * @brief The class names. * @see Style @@ -228,10 +214,14 @@ struct View { Set *classNames; /** - * @brief The render frame generation at which `cachedClippingFrame` was computed. + * @brief The cached View::clippingFrame, valid while `stamp` matches the current render + * frame generation. * @private */ - uint64_t clippingFrameStamp; + struct { + SDL_Rect frame; + uint64_t stamp; + } clippingFrameCache; /** * @brief If true, subviews will be clipped to this View's frame. @@ -314,10 +304,14 @@ struct View { ViewPadding padding; /** - * @brief The render frame generation at which `cachedRenderFrame` was computed. + * @brief The cached View::renderFrame, valid while `stamp` matches the current render + * frame generation. * @private */ - uint64_t renderFrameStamp; + struct { + SDL_Rect frame; + uint64_t stamp; + } renderFrameCache; /** * @brief The element-level Style of this View. From 729b16134fec06cde0bb1ba0f97c41a9b1ea9040 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 21:13:51 -0400 Subject: [PATCH 08/27] Ignore the HUD example binary Co-Authored-By: Claude Fable 5 --- Examples/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/Examples/.gitignore b/Examples/.gitignore index e965047a..eb16b152 100644 --- a/Examples/.gitignore +++ b/Examples/.gitignore @@ -1 +1,2 @@ Hello +HUD From 21543aafd0d1bbc5073f18011d41b59f66a29ec6 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 21:22:00 -0400 Subject: [PATCH 09/27] Restore subviews-first ordering in layoutIfNeeded Code review found the self-first ordering introduced with traversal gating regressed convergence: a descendant whose layout resizes it marks its ancestors mid-pass, and with self-first ordering a clean ancestor's dirty check has already run, deferring its re-arrangement by one rendered frame per nesting level. Subviews-first restores the original bottom-up single-pass convergence while keeping the gating. Also complete setter adoption flagged by review: invalidateStyle's enumerator now uses setNeedsApplyTheme rather than writing the flag directly (the trailing self call becomes redundant), the layout unit tests use setNeedsLayout, and ScrollBar's docs reference the setter. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/ScrollBar.h | 2 +- Sources/ObjectivelyMVC/View.c | 13 ++++++------- Tests/ObjectivelyMVC/View.c | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Sources/ObjectivelyMVC/ScrollBar.h b/Sources/ObjectivelyMVC/ScrollBar.h index 59854316..aac7da4d 100644 --- a/Sources/ObjectivelyMVC/ScrollBar.h +++ b/Sources/ObjectivelyMVC/ScrollBar.h @@ -52,7 +52,7 @@ typedef struct ScrollView ScrollView; * * The handle's size tracks the visible/content ratio and its position tracks * the ScrollView's contentOffset; dragging the handle scrolls the content. - * Anything that changes either just flags `needsLayout` -- ScrollBar's own + * Anything that changes either just calls View::setNeedsLayout -- ScrollBar's own * `layoutSubviews` is what actually repositions/resizes the handle. * * Styling is plain View attributes, set in the stylesheet like any other diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index 572c7f61..0d77a61b 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -1049,7 +1049,7 @@ static View *initWithFrame(View *self, const SDL_Rect *frame) { * @brief ViewEnumerator for invalidateStyle. */ static void invalidateStyle_enumerate(View *view, ident data) { - view->needsApplyTheme = true; + $(view, setNeedsApplyTheme); } /** @@ -1058,7 +1058,6 @@ static void invalidateStyle_enumerate(View *view, ident data) { */ static void invalidateStyle(View *self) { $(self, enumerate, invalidateStyle_enumerate, NULL); - $(self, setNeedsApplyTheme); } /** @@ -1149,6 +1148,11 @@ static void layoutIfNeeded(View *self) { // propagating setNeedsLayout, or a widget marking a sibling) survives to the next frame. self->needsLayoutSubviews = false; + // Subviews first, so that a dirty descendant whose layout resizes it marks its ancestors + // before their own dirty checks below run; the whole tree then converges bottom-up in a + // single pass, exactly as invalidations propagate. + $(self, enumerateSubviews, layoutIfNeeded_enumerate, NULL); + if (self->needsLayout) { // No ancestor is actively arranging self right now, so there's no fresh constraint to @@ -1161,11 +1165,6 @@ static void layoutIfNeeded(View *self) { $(self, layoutWithConstraint, w, h); } - - // Subviews just arranged by layoutWithConstraint are clean, so this recursion visits only - // direct subviews; it descends only where a dirty descendant was skipped by an overridden - // layoutSubviews, or was marked during the layout above. - $(self, enumerateSubviews, layoutIfNeeded_enumerate, NULL); } /** diff --git a/Tests/ObjectivelyMVC/View.c b/Tests/ObjectivelyMVC/View.c index b39b0425..439ad2b7 100644 --- a/Tests/ObjectivelyMVC/View.c +++ b/Tests/ObjectivelyMVC/View.c @@ -118,7 +118,7 @@ START_TEST(containStackViewWithFillChild) { const SDL_Rect frame = fillChild->view.frame; - stackView->view.needsLayout = true; + $((View *) stackView, setNeedsLayout); $((View *) stackView, layoutIfNeeded); ck_assert_int_eq(frame.w, fillChild->view.frame.w); @@ -198,7 +198,7 @@ START_TEST(standaloneRelayoutDoesNotShrinkFillChild) { // Simulate a style rebind on the row alone (e.g. a `:selected` pseudo-class match), which // marks only the row -- not its superview -- needsLayout, per View::_bind's contract. - row->view.needsLayout = true; + $((View *) row, setNeedsLayout); $((View *) row, layoutIfNeeded); ck_assert_int_eq(200, row->view.frame.w); From cdf0cc74673aae40d25861ae1d475688e5b2d4de Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 21:22:09 -0400 Subject: [PATCH 10/27] Key Text::naturalSize cache on colorEscapes colorEscapes is a public, setter-less field that switches the measurement path, and flipping it after the first measurement (the documented usage) left the cache returning the escape-blind size indefinitely. Include it in the cache key. Addresses code review feedback. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/Text.c | 4 +++- Sources/ObjectivelyMVC/Text.h | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/ObjectivelyMVC/Text.c b/Sources/ObjectivelyMVC/Text.c index 743c3bee..353e1638 100644 --- a/Sources/ObjectivelyMVC/Text.c +++ b/Sources/ObjectivelyMVC/Text.c @@ -504,7 +504,8 @@ static Text *initWithText(Text *self, const char *text, Font *font) { */ static SDL_Size naturalSize(const Text *self) { - if (self->naturalSizeCache.valid && self->font && self->font->scale == self->naturalSizeCache.scale) { + if (self->naturalSizeCache.valid && self->font && self->font->scale == self->naturalSizeCache.scale && + self->colorEscapes == self->naturalSizeCache.colorEscapes) { return self->naturalSizeCache.size; } @@ -523,6 +524,7 @@ static SDL_Size naturalSize(const Text *self) { this->naturalSizeCache.size = size; this->naturalSizeCache.scale = self->font->scale; + this->naturalSizeCache.colorEscapes = self->colorEscapes; this->naturalSizeCache.valid = true; } diff --git a/Sources/ObjectivelyMVC/Text.h b/Sources/ObjectivelyMVC/Text.h index 8e88d968..6700ad88 100644 --- a/Sources/ObjectivelyMVC/Text.h +++ b/Sources/ObjectivelyMVC/Text.h @@ -106,13 +106,15 @@ struct Text { bool lineWrap; /** - * @brief The cached Text::naturalSize, valid while `valid` is set and `scale` matches the - * Font's scale. + * @brief The cached Text::naturalSize, valid while `valid` is set and `scale` and + * `colorEscapes` match the Font's scale and this Text's `colorEscapes` -- the latter + * because it is a public, setter-less field that changes the measurement path. * @private */ struct { SDL_Size size; float scale; + bool colorEscapes; bool valid; } naturalSizeCache; From 7ebbfd8f98a0639013f4961b11764868f02e59e5 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 21:22:23 -0400 Subject: [PATCH 11/27] Invalidate render frame caches at out-of-layout frame mutations Code review found the in-tree violators of the new invalidation contract: Panel mutates its frame directly while dragging, leaving hit-testing for the remainder of the event batch on the pre-move cached clippingFrame, and Text resizes itself mid-draw on a pixel density change but then read the renderFrame stamped earlier in the same pass. Both now call MVC_InvalidateRenderFrames after mutating. The HUD benchmark also never bumped the generation, so it measured the frame-cache feature disabled; it now mirrors renderTo. Co-Authored-By: Claude Fable 5 --- Examples/HUD.c | 2 ++ Sources/ObjectivelyMVC/Panel.c | 4 ++++ Sources/ObjectivelyMVC/Text.c | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/Examples/HUD.c b/Examples/HUD.c index 686f342f..1f88d023 100644 --- a/Examples/HUD.c +++ b/Examples/HUD.c @@ -345,6 +345,8 @@ SDL_AppResult SDL_AppIterate(void *appState) { const Uint64 t1 = SDL_GetPerformanceCounter(); $(view, layoutIfNeeded); + MVC_InvalidateRenderFrames(); + const Uint64 t2 = SDL_GetPerformanceCounter(); $(view, draw, renderer); diff --git a/Sources/ObjectivelyMVC/Panel.c b/Sources/ObjectivelyMVC/Panel.c index 10c6be14..20b8cdbb 100644 --- a/Sources/ObjectivelyMVC/Panel.c +++ b/Sources/ObjectivelyMVC/Panel.c @@ -173,6 +173,10 @@ static bool captureEvent(Control *self, const SDL_Event *event) { self->view.frame.x += dx; self->view.frame.y += dy; + + // Direct frame mutation outside of layout: refresh the render frame caches so + // hit-testing for the remainder of this event batch sees the moved Panel. + MVC_InvalidateRenderFrames(); } return true; diff --git a/Sources/ObjectivelyMVC/Text.c b/Sources/ObjectivelyMVC/Text.c index 353e1638..147d2a4f 100644 --- a/Sources/ObjectivelyMVC/Text.c +++ b/Sources/ObjectivelyMVC/Text.c @@ -360,6 +360,10 @@ static void render(View *self, Renderer *renderer) { this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); this->naturalSizeCache.valid = false; + + // renderDeviceDidReset resized this View mid-draw; refresh the render frame caches so + // the renderFrame read below observes the new size rather than this pass's stale stamp. + MVC_InvalidateRenderFrames(); } if (this->text) { From 3e418d08252e62b95ece3f7599ec39c2f03c20d4 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 21:22:23 -0400 Subject: [PATCH 12/27] Fix ProgressBar progress ignoring min and dividing by zero progress and setValue computed value / (max - min), which reports 100% at zero progress for any non-zero min, and divides by zero when max == min (reachable via bound inlets with no validation). Compute (value - min) / (max - min), guarded to 0% when max <= min, and derive setValue's fraction from progress rather than duplicating the formula. Pre-existing defect surfaced by code review. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/ProgressBar.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/ObjectivelyMVC/ProgressBar.c b/Sources/ObjectivelyMVC/ProgressBar.c index 9fd7c787..63a40ce8 100644 --- a/Sources/ObjectivelyMVC/ProgressBar.c +++ b/Sources/ObjectivelyMVC/ProgressBar.c @@ -140,7 +140,12 @@ static ProgressBar *initWithFrame(ProgressBar *self, const SDL_Rect *frame) { * @memberof ProgressBar */ static double progress(const ProgressBar *self) { - return 100.0 * self->value / (self->max - self->min); + + if (self->max <= self->min) { + return 0.0; + } + + return 100.0 * (self->value - self->min) / (self->max - self->min); } /** @@ -170,7 +175,7 @@ static void setValue(ProgressBar *self, double value) { self->value = value; const SDL_Rect bounds = $((View *) self, bounds); - const double frac = self->value / (self->max - self->min); + const double frac = $(self, progress) / 100.0; self->foreground->view.frame.w = bounds.w * frac; $((View *) self, setNeedsLayout); From edbe43053b67fa8abd9dd65191ef9cda432bbc93 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 21:25:05 -0400 Subject: [PATCH 13/27] Add ObjectivelyMVC-HUD Xcode target and scheme Mirrors the ObjectivelyMVC-Hello command line tool target: HUD.c, the same framework links and search paths, and a shared scheme, so the benchmark can be run and profiled from Xcode. Co-Authored-By: Claude Fable 5 --- ObjectivelyMVC.xcodeproj/project.pbxproj | 101 ++++++++++++++++++ .../xcschemes/ObjectivelyMVC-HUD.xcscheme | 90 ++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme diff --git a/ObjectivelyMVC.xcodeproj/project.pbxproj b/ObjectivelyMVC.xcodeproj/project.pbxproj index 580860c4..39ce9a0a 100644 --- a/ObjectivelyMVC.xcodeproj/project.pbxproj +++ b/ObjectivelyMVC.xcodeproj/project.pbxproj @@ -87,6 +87,11 @@ CE12D4471C4C38C700CD0B13 /* ViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = CE12D4261C4C367100CD0B13 /* ViewController.h */; settings = {ATTRIBUTES = (Public, ); }; }; CE12D4481C4C38C700CD0B13 /* ObjectivelyMVC.h in Headers */ = {isa = PBXBuildFile; fileRef = CE12D4271C4C367100CD0B13 /* ObjectivelyMVC.h */; settings = {ATTRIBUTES = (Public, ); }; }; CE12D4611C4C8F2A00CD0B13 /* ObjectivelyMVC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CED157CD1C4BF3AC00FBA2DE /* ObjectivelyMVC.framework */; }; + HUDA00000000000000000003 /* HUD.c in Sources */ = {isa = PBXBuildFile; fileRef = HUDA00000000000000000001 /* HUD.c */; }; + HUDA00000000000000000004 /* SDL3.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = B1AA0001000000000000000A /* SDL3.xcframework */; }; + HUDA00000000000000000005 /* Objectively.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE73E9FA2FEEA5F60048BC24 /* Objectively.framework */; }; + HUDA00000000000000000006 /* ObjectivelyGPU.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEEAAFF52FED7E6000FFEBE6 /* ObjectivelyGPU.framework */; }; + HUDA00000000000000000007 /* ObjectivelyMVC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CED157CD1C4BF3AC00FBA2DE /* ObjectivelyMVC.framework */; }; CE12D4621C4C8F4800CD0B13 /* Hello.c in Sources */ = {isa = PBXBuildFile; fileRef = CE12D42B1C4C37E500CD0B13 /* Hello.c */; }; CE12D4661C4D587E00CD0B13 /* Log.h in Headers */ = {isa = PBXBuildFile; fileRef = CE12D4651C4D587E00CD0B13 /* Log.h */; settings = {ATTRIBUTES = (Public, ); }; }; CE12D46B1C4D810F00CD0B13 /* Button.c in Sources */ = {isa = PBXBuildFile; fileRef = CE12D4691C4D810F00CD0B13 /* Button.c */; }; @@ -225,6 +230,13 @@ remoteGlobalIDString = CED157CC1C4BF3AC00FBA2DE; remoteInfo = "ObjectivelyMVC-Xcode"; }; + HUDA00000000000000000010 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CED157931C4BEA8900FBA2DE /* Project object */; + proxyType = 1; + remoteGlobalIDString = CED157CC1C4BF3AC00FBA2DE; + remoteInfo = "ObjectivelyMVC-Xcode"; + }; CE34C8971FB29F700025F231 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CED157931C4BEA8900FBA2DE /* Project object */; @@ -402,8 +414,10 @@ CE12D4261C4C367100CD0B13 /* ViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = ""; }; CE12D4271C4C367100CD0B13 /* ObjectivelyMVC.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ObjectivelyMVC.h; sourceTree = ""; }; CE12D42B1C4C37E500CD0B13 /* Hello.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = Hello.c; sourceTree = ""; }; + HUDA00000000000000000001 /* HUD.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = HUD.c; sourceTree = ""; }; CE12D42D1C4C383D00CD0B13 /* Makefile.am */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Makefile.am; sourceTree = ""; }; CE12D4551C4C8E8E00CD0B13 /* ObjectivelyMVC-Hello */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "ObjectivelyMVC-Hello"; sourceTree = BUILT_PRODUCTS_DIR; }; + HUDA00000000000000000002 /* ObjectivelyMVC-HUD */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "ObjectivelyMVC-HUD"; sourceTree = BUILT_PRODUCTS_DIR; }; CE12D4651C4D587E00CD0B13 /* Log.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Log.h; sourceTree = ""; }; CE12D4691C4D810F00CD0B13 /* Button.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = Button.c; sourceTree = ""; }; CE12D46A1C4D810F00CD0B13 /* Button.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = Button.h; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; @@ -601,6 +615,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + HUDA00000000000000000009 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + HUDA00000000000000000004 /* SDL3.xcframework in Frameworks */, + HUDA00000000000000000005 /* Objectively.framework in Frameworks */, + HUDA00000000000000000006 /* ObjectivelyGPU.framework in Frameworks */, + HUDA00000000000000000007 /* ObjectivelyMVC.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CE34C89A1FB29F700025F231 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -790,6 +815,7 @@ isa = PBXGroup; children = ( CE12D42B1C4C37E500CD0B13 /* Hello.c */, + HUDA00000000000000000001 /* HUD.c */, HS00000000000000000000A3 /* Hello.frag.glsl */, HS00000000000000000000A4 /* Hello.frag.spv */, HS00000000000000000000A5 /* Hello.frag.metal */, @@ -919,6 +945,7 @@ children = ( CED157CD1C4BF3AC00FBA2DE /* ObjectivelyMVC.framework */, CE12D4551C4C8E8E00CD0B13 /* ObjectivelyMVC-Hello */, + HUDA00000000000000000002 /* ObjectivelyMVC-HUD */, CE8819971F91B0BE000D5AB7 /* ObjectivelyMVC-Style */, CE55C1C91F944C6D00D5A326 /* ObjectivelyMVC-Selector */, CE34C8A21FB29F700025F231 /* ObjectivelyMVC-Stylesheet */, @@ -1052,6 +1079,23 @@ productReference = CE12D4551C4C8E8E00CD0B13 /* ObjectivelyMVC-Hello */; productType = "com.apple.product-type.tool"; }; + HUDA00000000000000000015 /* ObjectivelyMVC-HUD */ = { + isa = PBXNativeTarget; + buildConfigurationList = HUDA00000000000000000014 /* Build configuration list for PBXNativeTarget "ObjectivelyMVC-HUD" */; + buildPhases = ( + HUDA00000000000000000008 /* Sources */, + HUDA00000000000000000009 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + HUDA00000000000000000011 /* PBXTargetDependency */, + ); + name = "ObjectivelyMVC-HUD"; + productName = "ObjectivelyMVC-HUD"; + productReference = HUDA00000000000000000002 /* ObjectivelyMVC-HUD */; + productType = "com.apple.product-type.tool"; + }; CE34C8951FB29F700025F231 /* ObjectivelyMVC-Stylesheet */ = { isa = PBXNativeTarget; buildConfigurationList = CE34C89F1FB29F700025F231 /* Build configuration list for PBXNativeTarget "ObjectivelyMVC-Stylesheet" */; @@ -1172,6 +1216,7 @@ 8A563153D68C9DE444FBF31C /* ObjectivelyMVC-View */, CE423A451F534657002767E7 /* ObjectivelyMVC-Tests */, CE12D4541C4C8E8E00CD0B13 /* ObjectivelyMVC-Hello */, + HUDA00000000000000000015 /* ObjectivelyMVC-HUD */, B1DD00010000000000000001 /* ObjectivelyMVC-Hello-iOS */, CE3CB2991F40F9A100FAA016 /* ObjectivelyMVC-Examples */, ); @@ -1339,6 +1384,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + HUDA00000000000000000008 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + HUDA00000000000000000003 /* HUD.c in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CE34C8981FB29F700025F231 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1436,6 +1489,11 @@ target = CED157CC1C4BF3AC00FBA2DE /* ObjectivelyMVC */; targetProxy = CE12D4631C4C8F5400CD0B13 /* PBXContainerItemProxy */; }; + HUDA00000000000000000011 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CED157CC1C4BF3AC00FBA2DE /* ObjectivelyMVC */; + targetProxy = HUDA00000000000000000010 /* PBXContainerItemProxy */; + }; CE34C8961FB29F700025F231 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = CED157CC1C4BF3AC00FBA2DE /* ObjectivelyMVC */; @@ -1629,6 +1687,40 @@ }; name = Release; }; + HUDA00000000000000000012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(SRCROOT)/Frameworks", + "$(BUILT_PRODUCTS_DIR)", + "$(inherited)", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(SRCROOT)/Frameworks", + "$(BUILT_PRODUCTS_DIR)", + "$(inherited)", + ); + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + HUDA00000000000000000013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(SRCROOT)/Frameworks", + "$(BUILT_PRODUCTS_DIR)", + "$(inherited)", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(SRCROOT)/Frameworks", + "$(BUILT_PRODUCTS_DIR)", + "$(inherited)", + ); + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; CE34C8A01FB29F700025F231 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1880,6 +1972,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + HUDA00000000000000000014 /* Build configuration list for PBXNativeTarget "ObjectivelyMVC-HUD" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + HUDA00000000000000000012 /* Debug */, + HUDA00000000000000000013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; CE34C89F1FB29F700025F231 /* Build configuration list for PBXNativeTarget "ObjectivelyMVC-Stylesheet" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme b/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme new file mode 100644 index 00000000..b464f903 --- /dev/null +++ b/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 5cb63e6dfbc9bece07692c5f0cb9283d10b44020 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 21:28:51 -0400 Subject: [PATCH 14/27] Add MVC_HUD_SCALE to grow the HUD benchmark View tree Multiplies the scoreboard row count so frame cost can be measured as a function of UI complexity; the default HUD is too small for tree-size-dependent costs to dominate. Co-Authored-By: Claude Fable 5 --- Examples/HUD.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Examples/HUD.c b/Examples/HUD.c index 1f88d023..0f5f99d6 100644 --- a/Examples/HUD.c +++ b/Examples/HUD.c @@ -32,6 +32,8 @@ * Environment: * - `MVC_HUD_FRAMES=N` exits successfully after N frames (for benchmarking). * - `MVC_HUD_HIDDEN=1` creates the window hidden (best effort headless). + * - `MVC_HUD_SCALE=N` multiplies the scoreboard row count (default 1), + * scaling the View tree to gauge how frame cost grows with UI complexity. */ #define SDL_MAIN_USE_CALLBACKS @@ -136,7 +138,14 @@ static void buildHUD(AppState *app, View *root) { Panel *scoreboard = $(alloc(Panel), initWithFrame, NULL); scoreboard->control.view.alignment = ViewAlignmentMiddleCenter; - for (int i = 0; i < 8; i++) { + int rows = 8; + + const char *scale = SDL_getenv("MVC_HUD_SCALE"); + if (scale) { + rows *= SDL_max(1, SDL_atoi(scale)); + } + + for (int i = 0; i < rows; i++) { StackView *row = $(alloc(StackView), initWithFrame, NULL); row->axis = StackViewAxisHorizontal; row->spacing = 32; From 8dc6f0da21ac8d5da9f5e75957c24b3ab8b8e2c6 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 21:55:21 -0400 Subject: [PATCH 15/27] Time device acquire and submit in the HUD benchmark The UI passes accounted for only a fraction of the process's CPU; the acquire (RenderDevice::beginFrame through the clear pass) and submit (RenderDevice::endFrame, which blocks on present with vsync) columns attribute the remainder, distinguishing engine/driver floor from MVC cost. Co-Authored-By: Claude Fable 5 --- Examples/HUD.c | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Examples/HUD.c b/Examples/HUD.c index 0f5f99d6..557e2428 100644 --- a/Examples/HUD.c +++ b/Examples/HUD.c @@ -81,7 +81,7 @@ typedef struct { * @brief Frame counters and per-pass timing since the last report. */ Uint64 frames, maxFrames, reportDue, reportFrames; - PassStats style, layout, draw, endFrame; + PassStats acquire, style, layout, draw, endFrame, submit; size_t draws, vertices; } AppState; @@ -251,17 +251,20 @@ static void report(AppState *app) { const double n = (double) app->reportFrames; - printf("HUD %llu frames | style avg %.1fus max %.1fus | layout avg %.1fus max %.1fus | " - "draw avg %.1fus max %.1fus | endFrame avg %.1fus max %.1fus | draws %zu verts %zu\n", + printf("HUD %llu frames | acquire avg %.1fus max %.1fus | style avg %.1fus max %.1fus | " + "layout avg %.1fus max %.1fus | draw avg %.1fus max %.1fus | endFrame avg %.1fus max %.1fus | " + "submit avg %.1fus max %.1fus | draws %zu verts %zu\n", (unsigned long long) app->reportFrames, + app->acquire.sum / n, app->acquire.max, app->style.sum / n, app->style.max, app->layout.sum / n, app->layout.max, app->draw.sum / n, app->draw.max, app->endFrame.sum / n, app->endFrame.max, + app->submit.sum / n, app->submit.max, app->draws, app->vertices); app->reportFrames = 0; - app->style = app->layout = app->draw = app->endFrame = (PassStats) { 0 }; + app->acquire = app->style = app->layout = app->draw = app->endFrame = app->submit = (PassStats) { 0 }; } #pragma mark - SDL application callbacks @@ -335,6 +338,7 @@ SDL_AppResult SDL_AppIterate(void *appState) { updateHUD(app, ticks); + const Uint64 tAcquire = SDL_GetPerformanceCounter(); CommandBuffer *commands = $(app->renderDevice, beginFrame); if (commands) { @@ -365,14 +369,17 @@ SDL_AppResult SDL_AppIterate(void *appState) { $(renderer, endFrame); const Uint64 t4 = SDL_GetPerformanceCounter(); + $(app->renderDevice, endFrame); + + const Uint64 t5 = SDL_GetPerformanceCounter(); + sample(&app->acquire, tAcquire, t0); sample(&app->style, t0, t1); sample(&app->layout, t1, t2); sample(&app->draw, t2, t3); sample(&app->endFrame, t3, t4); + sample(&app->submit, t4, t5); app->reportFrames++; - - $(app->renderDevice, endFrame); } app->frames++; From 12c5a1a24f56ff6dc51a9e9bcf03c62cff03d81a Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 22:24:39 -0400 Subject: [PATCH 16/27] Include stdio for printf Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Examples/HUD.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Examples/HUD.c b/Examples/HUD.c index 557e2428..23363486 100644 --- a/Examples/HUD.c +++ b/Examples/HUD.c @@ -38,6 +38,8 @@ #define SDL_MAIN_USE_CALLBACKS +#include + #include #include From 5219455a58a424dd92084830a9816c7c717ce807 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 22:34:59 -0400 Subject: [PATCH 17/27] Always stack-allocate drawLines vertices Replace the stack-or-malloc dual path with a fixed stack buffer, emitting long polylines in batches: pushDrawArrays merges adjacent records with equal texture and scissor, so a polyline of any length still produces a single draw call, with no heap allocation for any caller. In-tree callers pass at most four segments and take one batch. Addresses review feedback on #46. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/Renderer.c | 74 ++++++++++++++++--------------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/Sources/ObjectivelyMVC/Renderer.c b/Sources/ObjectivelyMVC/Renderer.c index 6cba8f9b..d9d07f3d 100644 --- a/Sources/ObjectivelyMVC/Renderer.c +++ b/Sources/ObjectivelyMVC/Renderer.c @@ -145,36 +145,38 @@ static void drawLines(const Renderer *self, const SDL_Point *points, size_t coun const size_t segCount = count - 1; - MVC_Vertex stack[16 * 6]; - MVC_Vertex *verts = segCount <= 16 ? stack : malloc(segCount * 6 * sizeof(MVC_Vertex)); - assert(verts); - - for (size_t i = 0; i < segCount; i++) { - const float ax = (float) points[i].x, ay = (float) points[i].y; - const float bx = (float) points[i+1].x, by = (float) points[i+1].y; - - const float dx = bx - ax, dy = by - ay; - const float len = sqrtf(dx * dx + dy * dy); - - float nx = 0.0f, ny = 0.0f; - if (len > 0.001f) { - nx = (-dy / len) * 0.5f; - ny = ( dx / len) * 0.5f; + // Emitted in fixed-size batches on the stack; pushDrawArrays merges adjacent records + // with equal texture and scissor, so a polyline of any length still costs one draw call. + MVC_Vertex verts[16 * 6]; + const size_t batchSize = lengthof(verts) / 6; + + for (size_t seg = 0; seg < segCount; ) { + + const size_t batch = min(segCount - seg, batchSize); + + for (size_t i = 0; i < batch; i++, seg++) { + const float ax = (float) points[seg].x, ay = (float) points[seg].y; + const float bx = (float) points[seg+1].x, by = (float) points[seg+1].y; + + const float dx = bx - ax, dy = by - ay; + const float len = sqrtf(dx * dx + dy * dy); + + float nx = 0.0f, ny = 0.0f; + if (len > 0.001f) { + nx = (-dy / len) * 0.5f; + ny = ( dx / len) * 0.5f; + } + + MVC_Vertex *v = &verts[i * 6]; + v[0] = (MVC_Vertex) { { { ax - nx, ay - ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[1] = (MVC_Vertex) { { { ax + nx, ay + ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[2] = (MVC_Vertex) { { { bx - nx, by - ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[3] = (MVC_Vertex) { { { ax + nx, ay + ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[4] = (MVC_Vertex) { { { bx + nx, by + ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[5] = (MVC_Vertex) { { { bx - nx, by - ny } }, { { 0.0f, 0.0f } }, { 0 } }; } - MVC_Vertex *v = &verts[i * 6]; - v[0] = (MVC_Vertex) { { { ax - nx, ay - ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[1] = (MVC_Vertex) { { { ax + nx, ay + ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[2] = (MVC_Vertex) { { { bx - nx, by - ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[3] = (MVC_Vertex) { { { ax + nx, ay + ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[4] = (MVC_Vertex) { { { bx + nx, by + ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[5] = (MVC_Vertex) { { { bx - nx, by - ny } }, { { 0.0f, 0.0f } }, { 0 } }; - } - - $(self, pushDrawArrays, verts, segCount * 6, NULL, color); - - if (verts != stack) { - free(verts); + $(self, pushDrawArrays, verts, batch * 6, NULL, color); } } @@ -387,13 +389,15 @@ static void pushDrawArrays(const Renderer *self, const MVC_Vertex *verts, size_t vertices->count += count; - // Vertices are appended contiguously, so a record contiguous with the previous one that - // binds the same texture and scissor extends it instead of costing another draw call. - MVC_DrawArrays *last = self->drawArrays->count ? - VectorElement(self->drawArrays, MVC_DrawArrays, self->drawArrays->count - 1) : NULL; - - if (last && last->texture == draw.texture && SDL_RectsEqual(&last->scissor, &draw.scissor)) { - last->vertexCount += draw.vertexCount; + // If this drawArrays is contiguous with the last one, combine them into a single call + + if (self->drawArrays->count) { + MVC_DrawArrays *last = VectorElement(self->drawArrays, MVC_DrawArrays, self->drawArrays->count - 1); + if (last->texture == draw.texture && SDL_RectsEqual(&last->scissor, &draw.scissor)) { + last->vertexCount += draw.vertexCount; + } else { + $(self->drawArrays, add, (MVC_DrawArrays *) &draw); + } } else { $(self->drawArrays, add, (MVC_DrawArrays *) &draw); } From 44b27d846c77c88c4d24144fc3b81c12b98023a9 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 22:49:32 -0400 Subject: [PATCH 18/27] Xcode --- .../xcschemes/ObjectivelyMVC-HUD.xcscheme | 24 +++++++------------ .../xcschemes/ObjectivelyMVC-Hello.xcscheme | 1 + 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme b/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme index b464f903..0eb2c0c7 100644 --- a/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme +++ b/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme @@ -1,10 +1,11 @@ + version = "1.7"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> - - - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> + viewDebuggingEnabled = "No" + queueDebuggingEnableBacktraceRecording = "Yes"> From 23aac87b3ea36924a340157222c65628e39cdaa2 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 1 Sep 2026 22:49:38 -0400 Subject: [PATCH 19/27] Cosmetics --- Sources/ObjectivelyMVC/Renderer.c | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Sources/ObjectivelyMVC/Renderer.c b/Sources/ObjectivelyMVC/Renderer.c index d9d07f3d..b538a5a9 100644 --- a/Sources/ObjectivelyMVC/Renderer.c +++ b/Sources/ObjectivelyMVC/Renderer.c @@ -143,20 +143,19 @@ static void drawLines(const Renderer *self, const SDL_Point *points, size_t coun return; } - const size_t segCount = count - 1; + const size_t segments = count - 1; - // Emitted in fixed-size batches on the stack; pushDrawArrays merges adjacent records - // with equal texture and scissor, so a polyline of any length still costs one draw call. MVC_Vertex verts[16 * 6]; const size_t batchSize = lengthof(verts) / 6; - for (size_t seg = 0; seg < segCount; ) { + for (size_t s = 0; s < segments; ) { - const size_t batch = min(segCount - seg, batchSize); + const size_t batch = min(segments - s, batchSize); - for (size_t i = 0; i < batch; i++, seg++) { - const float ax = (float) points[seg].x, ay = (float) points[seg].y; - const float bx = (float) points[seg+1].x, by = (float) points[seg+1].y; + for (size_t i = 0; i < batch; i++, s++) { + + const float ax = (float) points[s].x, ay = (float) points[s].y; + const float bx = (float) points[s + 1].x, by = (float) points[s + 1].y; const float dx = bx - ax, dy = by - ay; const float len = sqrtf(dx * dx + dy * dy); From c7df16095a09e8cc1c880a32df0f1b2146d96985 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Wed, 2 Sep 2026 10:31:13 -0400 Subject: [PATCH 20/27] Rename render frame cache stamp to generation Matches the renderFrameGeneration terminology. The counter and stamps are unsigned: the per-frame increment would reach signed overflow (undefined behavior) within months of continuous rendering. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/View.c | 10 +++++----- Sources/ObjectivelyMVC/View.h | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index 0d77a61b..ed3a4b16 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -41,7 +41,7 @@ Uint32 MVC_VIEW_EVENT; * valid only while their stamp matches this. Zero disables caching entirely, for callers * that never invalidate (e.g. unit tests). */ -static uint64_t _renderFrameGeneration; +static unsigned _renderFrameGeneration; void MVC_InvalidateRenderFrames(void) { _renderFrameGeneration++; @@ -575,7 +575,7 @@ static SDL_Rect clippingFrame(const View *self) { View *this = (View *) self; - if (_renderFrameGeneration && this->clippingFrameCache.stamp == _renderFrameGeneration) { + if (_renderFrameGeneration && this->clippingFrameCache.generation == _renderFrameGeneration) { return this->clippingFrameCache.frame; } @@ -603,7 +603,7 @@ static SDL_Rect clippingFrame(const View *self) { } this->clippingFrameCache.frame = frame; - this->clippingFrameCache.stamp = _renderFrameGeneration; + this->clippingFrameCache.generation = _renderFrameGeneration; return frame; } @@ -1512,7 +1512,7 @@ static SDL_Rect renderFrame(const View *self) { View *this = (View *) self; - if (_renderFrameGeneration && this->renderFrameCache.stamp == _renderFrameGeneration) { + if (_renderFrameGeneration && this->renderFrameCache.generation == _renderFrameGeneration) { return this->renderFrameCache.frame; } @@ -1533,7 +1533,7 @@ static SDL_Rect renderFrame(const View *self) { } this->renderFrameCache.frame = frame; - this->renderFrameCache.stamp = _renderFrameGeneration; + this->renderFrameCache.generation = _renderFrameGeneration; return frame; } diff --git a/Sources/ObjectivelyMVC/View.h b/Sources/ObjectivelyMVC/View.h index 468cf93e..480232b5 100644 --- a/Sources/ObjectivelyMVC/View.h +++ b/Sources/ObjectivelyMVC/View.h @@ -220,7 +220,7 @@ struct View { */ struct { SDL_Rect frame; - uint64_t stamp; + unsigned generation; } clippingFrameCache; /** @@ -304,13 +304,13 @@ struct View { ViewPadding padding; /** - * @brief The cached View::renderFrame, valid while `stamp` matches the current render - * frame generation. + * @brief The cached View::renderFrame, valid while `generation` matches the current + * `renderFrameGeneration`. * @private */ struct { SDL_Rect frame; - uint64_t stamp; + unsigned generation; } renderFrameCache; /** From e652810930e7202e9b9a39c45a07cb2f00b31c13 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Wed, 2 Sep 2026 10:31:13 -0400 Subject: [PATCH 21/27] Move the Font instance cache to WindowController Fonts are opened at a display pixel density, which is a property of a window -- but instances were cached globally and mutated in place on density changes, so two windows on different-density displays fought over shared instances, and Text::render had to detect density changes mid-draw, mutating layout state during the draw walk. Font instances are now immutable: initWithData opens the TTF at size * pixelDensity, and Font::renderDeviceDidReset is gone. The global cache in Font.c retains only font Data (TTF bytes, density independent); instances are cached per window on WindowController, keyed by the new Font::name ("family-size-style", e.g. "Coda-16-regular"). Font::fontWithName parses that format back, from the right, so families may contain dashes. Font::scale is renamed pixelDensity throughout. Attribute normalization is shared by Font::nameWithAttributes and Font::fontWithAttributes (which returns an owned reference, unlike WindowController::cachedFont, whose references the cache owns). Density handling now lives entirely on the event path: the WindowController empties its font cache -- only when the density actually changed, since SDL emits PIXEL_SIZE_CHANGED continuously during interactive resizes -- and resets both the main and debug view trees; Text re-resolves its Font in didMoveToWindow (so Views attached to a HiDPI window from startup rasterize at the correct density) and in renderDeviceDidReset. The per-window cache is keyed by the resolved Font's own name, so an unregistered family cannot pin the default Font under the requested key. This deletes Text::render's mid-draw density block: the draw path no longer mutates layout state or the render frame generation. Fonts set programmatically MUST be re-set after a density change. Text::render also no longer re-converts the surface returned by Font::renderCharacters, which already converts to RGBA32. Co-Authored-By: Claude Fable 5 --- Sources/ObjectivelyMVC/Font.c | 173 ++++++++++++++-------- Sources/ObjectivelyMVC/Font.h | 98 ++++++++---- Sources/ObjectivelyMVC/Text.c | 128 ++++++++-------- Sources/ObjectivelyMVC/Text.h | 10 +- Sources/ObjectivelyMVC/WindowController.c | 72 ++++++++- Sources/ObjectivelyMVC/WindowController.h | 31 ++++ 6 files changed, 353 insertions(+), 159 deletions(-) diff --git a/Sources/ObjectivelyMVC/Font.c b/Sources/ObjectivelyMVC/Font.c index 8b5f8051..ccfe9c06 100644 --- a/Sources/ObjectivelyMVC/Font.c +++ b/Sources/ObjectivelyMVC/Font.c @@ -110,7 +110,6 @@ static bool isEqual(const Object *self, const Object *other) { #pragma mark - Font static Dictionary *_cache; -static Array *_fonts; /** * @fn void Font::cacheFont(Data *data, const char *family) @@ -121,46 +120,46 @@ static void cacheFont(Data *data, const char *family) { } /** - * @fn Font *Font::cachedFont(const char *family, int size, int style) - * @memberof Font + * @brief Normalizes Font attributes to their defaults, in one place, so that + * Font::nameWithAttributes and Font::fontWithAttributes always agree. */ -static Font *cachedFont(const char *family, int size, int style) { +static void normalizeAttributes(const char **family, int *size, int *style) { - if (family == NULL) { - family = DEFAULT_FONT_FAMILY; + if (*family == NULL) { + *family = DEFAULT_FONT_FAMILY; } - if (size < 1) { - size = DEFAULT_FONT_SIZE; + + if (*size < 1) { + *size = DEFAULT_FONT_SIZE; } - if (style < FontStyleRegular || style > FontStyleStrikeThrough) { - style = DEFAULT_FONT_STYLE; + + if (*style < FontStyleRegular || *style > FontStyleStrikeThrough) { + *style = DEFAULT_FONT_STYLE; } +} - const Array *fonts = (Array *) _fonts; - for (size_t i = 0; i < fonts->count; i++) { +/** + * @fn Font *Font::fontWithAttributes(const char *family, int size, int style, float pixelDensity) + * @memberof Font + */ +static Font *fontWithAttributes(const char *family, int size, int style, float pixelDensity) { - Font *font = $(fonts, objectAtIndex, i); + normalizeAttributes(&family, &size, &style); - if (!strcmp(font->family, family) && - font->size == size && - font->style == style) { - return font; - } + if (pixelDensity < 1.f) { + pixelDensity = 1.f; } Data *data = $((Dictionary *) _cache, objectForKeyPath, family); if (data) { - Font *font = $(alloc(Font), initWithData, data, family, size, style); + Font *font = $(alloc(Font), initWithData, data, family, size, style, pixelDensity); assert(font); - $(_fonts, addObject, font); - release(font); - return font; } MVC_LogWarn("%s-%d-%d not found\n", family, size, style); - return $$(Font, defaultFont); + return retain($$(Font, defaultFont)); } /** @@ -171,6 +170,8 @@ static void clearCache(void) { $(_cache, removeAllObjects); } +static Font *_defaultFont; + /** * @fn Font *Font::defaultFont(void) * @memberof Font @@ -185,16 +186,57 @@ static Font *defaultFont(void) { $$(Font, cacheFont, data, DEFAULT_FONT_FAMILY); release(data); + + _defaultFont = $$(Font, fontWithAttributes, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, DEFAULT_FONT_STYLE, 1.f); + assert(_defaultFont); }); - return $$(Font, cachedFont, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, DEFAULT_FONT_STYLE); + return _defaultFont; +} + +/** + * @fn Font *Font::fontWithName(const char *name, float pixelDensity) + * @memberof Font + */ +static Font *fontWithName(const char *name, float pixelDensity) { + + assert(name); + + char *chars = strdup(name); + assert(chars); + + Font *font = NULL; + + char *styleToken = strrchr(chars, '-'); + if (styleToken) { + *styleToken++ = '\0'; + + char *sizeToken = strrchr(chars, '-'); + if (sizeToken) { + *sizeToken++ = '\0'; + + const int style = valueof(FontStyleNames, styleToken); + const int size = (int) strtol(sizeToken, NULL, 10); + + font = $$(Font, fontWithAttributes, chars, size, style, pixelDensity); + } + } + + free(chars); + + if (font == NULL) { + MVC_LogWarn("%s is not a valid Font name\n", name); + font = retain($$(Font, defaultFont)); + } + + return font; } /** * @fn Font *Font::initWithData(Font *self, Data *data, int size, int index) * @memberof Font */ -static Font *initWithData(Font *self, Data *data, const char *family, int size, int style) { +static Font *initWithData(Font *self, Data *data, const char *family, int size, int style, float pixelDensity) { self = (Font *) super(Object, self, init); if (self) { @@ -209,14 +251,48 @@ static Font *initWithData(Font *self, Data *data, const char *family, int size, assert(self->size); self->style = style; - self->scale = 1.0f; - $(self, renderDeviceDidReset); + self->pixelDensity = pixelDensity; + self->renderSize = self->size * self->pixelDensity; + + SDL_IOStream *buffer = SDL_IOFromConstMem(self->data->bytes, (int) self->data->length); + assert(buffer); + + self->font = TTF_OpenFontIO(buffer, 1, self->renderSize); + assert(self->font); + + TTF_SetFontStyle(self->font, self->style); + TTF_SetFontHinting(self->font, TTF_HINTING_LIGHT_SUBPIXEL); } return self; } +/** + * @fn String *Font::name(const Font *self) + * @memberof Font + */ +static String *name(const Font *self) { + return $$(Font, nameWithAttributes, self->family, self->size, self->style); +} + +/** + * @fn String *Font::nameWithAttributes(const char *family, int size, int style) + * @memberof Font + */ +static String *nameWithAttributes(const char *family, int size, int style) { + + normalizeAttributes(&family, &size, &style); + + for (const EnumName *en = FontStyleNames; en->name; en++) { + if (en->value == style) { + return str("%s-%d-%s", family, size, en->alias ?: en->name); + } + } + + return str("%s-%d-%d", family, size, style); +} + /** * @fn void Font::renderCharacters(const Font *self, const char *chars, SDL_Color color, int wrapWidth) * @memberof Font @@ -225,7 +301,7 @@ static SDL_Surface *renderCharacters(const Font *self, const char *chars, SDL_Co SDL_Surface *surface; if (wrapWidth) { - surface = TTF_RenderText_Blended_Wrapped(self->font, chars, 0, color, wrapWidth * self->scale); + surface = TTF_RenderText_Blended_Wrapped(self->font, chars, 0, color, wrapWidth * self->pixelDensity); } else { surface = TTF_RenderText_Blended(self->font, chars, 0, color); } @@ -241,32 +317,6 @@ static SDL_Surface *renderCharacters(const Font *self, const char *chars, SDL_Co return converted; } -/** - * @fn void Font::renderDeviceDidReset(Font *self) - * @memberof Font - */ -static void renderDeviceDidReset(Font *self) { - - const int renderSize = self->size * self->scale; - if (renderSize != self->renderSize) { - - self->renderSize = renderSize; - - if (self->font) { - TTF_CloseFont(self->font); - } - - SDL_IOStream *buffer = SDL_IOFromConstMem(self->data->bytes, (int) self->data->length); - assert(buffer); - - self->font = TTF_OpenFontIO(buffer, 1, self->renderSize); - assert(self->font); - - TTF_SetFontStyle(self->font, self->style); - TTF_SetFontHinting(self->font, TTF_HINTING_LIGHT_SUBPIXEL); - } -} - /** * @fn void Font::sizeCharacters(const Font *self, const char *chars, int *w, int *h) * @memberof Font @@ -315,10 +365,10 @@ static void sizeCharacters(const Font *self, const char *chars, int *w, int *h) free(lines); if (w) { - *w = ceilf(*w / self->scale); + *w = ceilf(*w / self->pixelDensity); } if (h) { - *h = ceilf(*h / self->scale); + *h = ceilf(*h / self->pixelDensity); } } } @@ -334,13 +384,15 @@ static void initialize(Class *clazz) { ((ObjectInterface *) clazz->interface)->hash = hash; ((ObjectInterface *) clazz->interface)->isEqual = isEqual; - ((FontInterface *) clazz->interface)->cachedFont = cachedFont; + ((FontInterface *) clazz->interface)->fontWithAttributes = fontWithAttributes; ((FontInterface *) clazz->interface)->cacheFont = cacheFont; ((FontInterface *) clazz->interface)->clearCache = clearCache; ((FontInterface *) clazz->interface)->defaultFont = defaultFont; + ((FontInterface *) clazz->interface)->fontWithName = fontWithName; ((FontInterface *) clazz->interface)->initWithData = initWithData; + ((FontInterface *) clazz->interface)->name = name; + ((FontInterface *) clazz->interface)->nameWithAttributes = nameWithAttributes; ((FontInterface *) clazz->interface)->renderCharacters = renderCharacters; - ((FontInterface *) clazz->interface)->renderDeviceDidReset = renderDeviceDidReset; ((FontInterface *) clazz->interface)->sizeCharacters = sizeCharacters; const bool init = TTF_Init(); @@ -349,9 +401,6 @@ static void initialize(Class *clazz) { _cache = $$(Dictionary, dictionary); assert(_cache); - - _fonts = $$(Array, array); - assert(_fonts); } /** @@ -360,7 +409,7 @@ static void initialize(Class *clazz) { static void destroy(Class *clazz) { release(_cache); - release(_fonts); + release(_defaultFont); TTF_Quit(); } diff --git a/Sources/ObjectivelyMVC/Font.h b/Sources/ObjectivelyMVC/Font.h index 8a2dc3e5..ddfaffb3 100644 --- a/Sources/ObjectivelyMVC/Font.h +++ b/Sources/ObjectivelyMVC/Font.h @@ -28,6 +28,7 @@ #include #include #include +#include #include @@ -87,17 +88,16 @@ struct Font { * @brief The backing font. */ TTF_Font *font; - + /** - * @brief The render size, adjusted for display density. + * @brief The display pixel density scale, greater than 1.0 on high-density displays. */ - int renderSize; + float pixelDensity; /** - * @brief The display pixel density scale (e.g. 2.0 on Retina). Updated by callers - * via the scale field before invoking renderDeviceDidReset. + * @brief The render size, adjusted for pixel density. */ - float scale; + int renderSize; /** * @brief The point size. @@ -120,18 +120,6 @@ struct FontInterface { */ ObjectInterface objectInterface; - /** - * @static - * @fn Font *Font::cachedFont(const char *family, int size, int style) - * @brief Resolves the cached Font with the given attributes. - * @param family The family. - * @param size The size. - * @param style The style. - * @return The cached Font, or the default Font if not found. - * @memberof Font - */ - Font *(*cachedFont)(const char *family, int size, int style); - /** * @static * @brief Caches the specified font Data. @@ -152,23 +140,80 @@ struct FontInterface { /** * @static * @fn Font *Font::defaultFont(void) - * @return The default Font. + * @return The default Font, at a pixel density of `1.0`. + * @remarks This is the fallback for Views that are not yet attached to a window; on + * attachment, style application re-resolves Fonts through the window's cache. * @memberof Font */ Font *(*defaultFont)(void); /** - * @fn Font *Font::initWithData(Font *self, Data *data, const char *family, int size, int style) + * @static + * @fn Font *Font::fontWithName(const char *name, float pixelDensity) + * @brief Resolves a Font from a name in the format produced by Font::name. + * @details The name is parsed from the right, so families MAY contain `-`: the final + * token is the style, the preceding token the point size, and the remainder the family. + * @param name The name, e.g. `"Coda-16-regular"`. + * @param pixelDensity The pixel density. + * @return The Font, or the default Font if `name` is not parseable or not registered. + * The caller owns the returned Font, and MUST release it. + * @memberof Font + */ + Font *(*fontWithName)(const char *name, float pixelDensity); + + /** + * @static + * @fn Font *Font::fontWithAttributes(const char *family, int size, int style, float pixelDensity) + * @brief Resolves a new Font with the given attributes from the cached font Data. + * @details "Cached" refers to the font Data registered via Font::cacheFont; the returned + * instance itself is newly created. Instance caching is provided per-window by + * WindowController::cachedFont, which callers with a window SHOULD prefer, since it + * supplies the window's pixel density implicitly. Attributes are normalized to their + * defaults exactly as Font::nameWithAttributes normalizes them. + * @param family The family, or `NULL` for the default. + * @param size The point size, or `0` for the default. + * @param style The FontStyle, or `-1` for the default. + * @param pixelDensity The pixel density. + * @return The Font, or the default Font if `family` is not registered. The caller owns + * the returned Font, and MUST release it. + * @memberof Font + */ + Font *(*fontWithAttributes)(const char *family, int size, int style, float pixelDensity); + + /** + * @fn Font *Font::initWithData(Font *self, Data *data, const char *family, int size, int style, float pixelDensity) * @brief Initializes this Font with the given TTF Data and attributes. + * @details Fonts are immutable: the backing TTF_Font is opened here at + * `size * pixelDensity` and never re-opened. A pixel density change MUST be handled by + * resolving a new instance (see WindowController::cachedFont). * @param self The Font. * @param data The Data. * @param family The family. - * @param size The size. + * @param size The point size. * @param style The style. + * @param pixelDensity The pixel density. * @return The initialized Font, or `NULL` on error. * @memberof Font */ - Font *(*initWithData)(Font *self, Data *data, const char *family, int size, int style); + Font *(*initWithData)(Font *self, Data *data, const char *family, int size, int style, float pixelDensity); + + /** + * @fn String *Font::name(const Font *self) + * @return This Font's name, in the format `family-size-style`, e.g. `"Coda-16-regular"`. + * @remarks The format is stable and MAY be parsed by Font::fontWithName; it is also the + * key under which WindowController caches Font instances. Pixel density is deliberately + * absent: it is implied by the window whose cache is consulted. + * @memberof Font + */ + String *(*name)(const Font *self); + + /** + * @static + * @fn String *Font::nameWithAttributes(const char *family, int size, int style) + * @return The Font name for the given attributes, in the format of Font::name. + * @memberof Font + */ + String *(*nameWithAttributes)(const char *family, int size, int style); /** * @fn SDL_Surface *Font::renderCharacters(const Font *self, const char *chars, SDL_Color color, int wrapWidth) @@ -182,15 +227,6 @@ struct FontInterface { */ SDL_Surface *(*renderCharacters)(const Font *self, const char *chars, SDL_Color color, int wrapWidth); - /** - * @fn void Font::renderDeviceDidReset(Font *self) - * @brief This method should be invoked when the render context is invalidated. - * Callers must update self->scale before invoking this method. - * @param self The Font. - * @memberof Font - */ - void (*renderDeviceDidReset)(Font *self); - /** * @fn void Font::sizeCharacters(const Font *self, const char *chars, int *w, int *h) * @brief Measures the given characters in this Font. diff --git a/Sources/ObjectivelyMVC/Text.c b/Sources/ObjectivelyMVC/Text.c index 147d2a4f..3ebac666 100644 --- a/Sources/ObjectivelyMVC/Text.c +++ b/Sources/ObjectivelyMVC/Text.c @@ -32,6 +32,7 @@ #include "Colors.h" #include "Text.h" +#include "WindowController.h" #define _Class _Text @@ -141,7 +142,7 @@ static CharInfo *buildCharInfo(const Font *font, const char *text, const size_t strippedLen = strlen(stripped); CharInfo *chars = malloc(sizeof(CharInfo) * strippedLen); - const int scaledWrapWidth = wrapWidth ? (int) (wrapWidth * font->scale) : 0; + const int scaledWrapWidth = wrapWidth ? (int) (wrapWidth * font->pixelDensity) : 0; int lineHeight; $(font, sizeCharacters, "A", NULL, &lineHeight); @@ -178,9 +179,9 @@ static CharInfo *buildCharInfo(const Font *font, const char *text, int charW; TTF_GetStringSize(font->font, stripped + charIdx, 1, &charW, NULL); - chars[charIdx].rect.x = (int) (lineX / font->scale); - chars[charIdx].rect.y = (int) (currH / font->scale) - lineHeight; - chars[charIdx].rect.w = (int) (charW / font->scale); + chars[charIdx].rect.x = (int) (lineX / font->pixelDensity); + chars[charIdx].rect.y = (int) (currH / font->pixelDensity) - lineHeight; + chars[charIdx].rect.w = (int) (charW / font->pixelDensity); chars[charIdx].rect.h = lineHeight; chars[charIdx].color = currentColor; @@ -211,7 +212,7 @@ static SDL_Surface *renderWithColorEscapes(const Text *self, int wrapWidth) { if (charInfo) { SDL_LockSurface(surface); for (int i = 0; i < charCount; i++) { - colorize(surface, &charInfo[i], self->font->scale); + colorize(surface, &charInfo[i], self->font->pixelDensity); } SDL_UnlockSurface(surface); free(charInfo); @@ -286,7 +287,7 @@ static void applyStyle(View *self, const Style *style) { if ($(self, bind, colorInlets, style->attributes)) { this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); - this->naturalSizeCache.valid = false; + this->naturalSizeCache.isValid = false; } char *fontFamily = NULL; @@ -300,10 +301,24 @@ static void applyStyle(View *self, const Style *style) { if ($(self, bind, fontInlets, style->attributes)) { - Font *font = $$(Font, cachedFont, fontFamily , fontSize, fontStyle); + // Attached Views resolve through the window's Font cache, which supplies the window's + // pixel density; unattached Views fall back to a density of 1.0, and re-resolve in + // didMoveToWindow on attachment. The cache owns its Fonts, so its reference is + // retained here to mirror the owned reference the fallback returns. + WindowController *windowController = self->window ? + $$(WindowController, windowController, self->window) : NULL; + + Font *font; + if (windowController) { + font = retain($(windowController, cachedFont, fontFamily, fontSize, fontStyle)); + } else { + font = $$(Font, fontWithAttributes, fontFamily, fontSize, fontStyle, 1.f); + } + assert(font); $(this, setFont, font); + release(font); if (fontFamily) { free(fontFamily); @@ -330,11 +345,32 @@ static void awakeWithDictionary(View *self, const Dictionary *dictionary) { $(self, bind, inlets, dictionary); - this->naturalSizeCache.valid = false; + this->naturalSizeCache.isValid = false; $(self, sizeToFit); } +/** + * @see View::didMoveToWindow(View *, SDL_Window *) + */ +static void didMoveToWindow(View *self, SDL_Window *window) { + + super(View, self, didMoveToWindow, window); + + // A Font resolved before attachment (e.g. the default Font) was opened at a pixel + // density of 1.0; re-resolve through the window's cache at the window's actual density. + if (window) { + Text *this = (Text *) self; + if (this->font) { + WindowController *windowController = $$(WindowController, windowController, window); + if (windowController) { + Font *font = $(windowController, cachedFont, this->font->family, this->font->size, this->font->style); + $(this, setFont, font); + } + } + } +} + /** * @see View::init(View *) */ @@ -353,18 +389,10 @@ static void render(View *self, Renderer *renderer) { assert(this->font); - const float scale = SDL_GetWindowPixelDensity(self->window); - - if (this->font->scale != scale) { - $(self, renderDeviceDidReset); - this->texture = release(this->texture); - this->textureSize = MakeSize(0, 0); - this->naturalSizeCache.valid = false; - - // renderDeviceDidReset resized this View mid-draw; refresh the render frame caches so - // the renderFrame read below observes the new size rather than this pass's stale stamp. - MVC_InvalidateRenderFrames(); - } + // The Font is opened at the window's pixel density: density changes arrive via the + // WindowController, which empties its font cache and resets the render device, so by + // draw time this Font is always current. + const float scale = this->font->pixelDensity; if (this->text) { @@ -390,44 +418,17 @@ static void render(View *self, Renderer *renderer) { this->textureSize.w = (int) textureWidth; this->textureSize.h = (int) textureHeight; - SDL_Surface *upload = surface; - SDL_Surface *converted = NULL; - - if (SDL_BYTESPERPIXEL(surface->format) == 1) { - converted = SDL_CreateSurface(surface->w, surface->h, SDL_PIXELFORMAT_RGBA32); - assert(converted); - const SDL_PixelFormatDetails *details = SDL_GetPixelFormatDetails(SDL_PIXELFORMAT_RGBA32); - assert(details); - const Uint8 *src = (const Uint8 *) surface->pixels; - Uint32 *dst = (Uint32 *) converted->pixels; - for (int y = 0; y < surface->h; y++) { - for (int x = 0; x < surface->w; x++) { - const Uint8 a = src[y * surface->pitch + x]; - dst[y * surface->w + x] = SDL_MapRGBA(details, NULL, 255, 255, 255, a); - } - } - upload = converted; - } else if (surface->format != SDL_PIXELFORMAT_RGBA32) { - converted = SDL_ConvertSurface(surface, SDL_PIXELFORMAT_RGBA32); - assert(converted); - upload = converted; - } - const SDL_GPUTextureCreateInfo texInfo = { .type = SDL_GPU_TEXTURETYPE_2D, .format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, .usage = SDL_GPU_TEXTUREUSAGE_SAMPLER, - .width = (Uint32) upload->w, - .height = (Uint32) upload->h, + .width = (Uint32) surface->w, + .height = (Uint32) surface->h, .layer_count_or_depth = 1, .num_levels = 1, }; - this->texture = $(renderer->device, createTexture, &texInfo, upload->pixels); - - if (converted) { - SDL_DestroySurface(converted); - } + this->texture = $(renderer->device, createTexture, &texInfo, surface->pixels); SDL_DestroySurface(surface); } @@ -440,10 +441,12 @@ static void render(View *self, Renderer *renderer) { // from the texture's actual resolution -- stretching it by that (sub-)pixel remainder. Since // the remainder depends on the string's own pixel width, this stretch changes with every // keystroke, visibly shifting every glyph in the string, not just the one that was typed. + const SDL_FRect draw_rect = { (float) frame.x, (float) frame.y, this->texture->size.w / scale, this->texture->size.h / scale }; + $(renderer, drawTexture, this->texture, &draw_rect, &Colors.White); } } @@ -455,10 +458,16 @@ static void renderDeviceDidReset(View *self) { Text *this = (Text *) self; - this->font->scale = SDL_GetWindowPixelDensity(self->window); + // Fonts are immutable, opened at their window's pixel density; re-resolve through the + // window's cache, which the WindowController empties before resetting the device. + if (self->window) { + WindowController *windowController = $$(WindowController, windowController, self->window); + if (windowController) { + Font *font = $(windowController, cachedFont, this->font->family, this->font->size, this->font->style); + $(this, setFont, font); + } + } - $(this->font, renderDeviceDidReset); - $(self, sizeToFit); super(View, self, renderDeviceDidReset); @@ -473,7 +482,7 @@ static void renderDeviceWillReset(View *self) { this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); - this->naturalSizeCache.valid = false; + this->naturalSizeCache.isValid = false; super(View, self, renderDeviceWillReset); } @@ -508,7 +517,7 @@ static Text *initWithText(Text *self, const char *text, Font *font) { */ static SDL_Size naturalSize(const Text *self) { - if (self->naturalSizeCache.valid && self->font && self->font->scale == self->naturalSizeCache.scale && + if (self->naturalSizeCache.isValid && self->font && self->font->pixelDensity == self->naturalSizeCache.pixelDensity && self->colorEscapes == self->naturalSizeCache.colorEscapes) { return self->naturalSizeCache.size; } @@ -527,9 +536,9 @@ static SDL_Size naturalSize(const Text *self) { Text *this = (Text *) self; this->naturalSizeCache.size = size; - this->naturalSizeCache.scale = self->font->scale; + this->naturalSizeCache.pixelDensity = self->font->pixelDensity; this->naturalSizeCache.colorEscapes = self->colorEscapes; - this->naturalSizeCache.valid = true; + this->naturalSizeCache.isValid = true; } return size; @@ -550,7 +559,7 @@ static void setFont(Text *self, Font *font) { self->texture = release(self->texture); self->textureSize = MakeSize(0, 0); - self->naturalSizeCache.valid = false; + self->naturalSizeCache.isValid = false; $((View *) self, sizeToFit); } @@ -574,7 +583,7 @@ static void setText(Text *self, const char *text) { self->texture = release(self->texture); self->textureSize = MakeSize(0, 0); - self->naturalSizeCache.valid = false; + self->naturalSizeCache.isValid = false; $((View *) self, sizeToFit); } @@ -614,6 +623,7 @@ static void initialize(Class *clazz) { ((ViewInterface *) clazz->interface)->applyStyle = applyStyle; ((ViewInterface *) clazz->interface)->awakeWithDictionary = awakeWithDictionary; + ((ViewInterface *) clazz->interface)->didMoveToWindow = didMoveToWindow; ((ViewInterface *) clazz->interface)->init = init; ((ViewInterface *) clazz->interface)->render = render; ((ViewInterface *) clazz->interface)->renderDeviceDidReset = renderDeviceDidReset; diff --git a/Sources/ObjectivelyMVC/Text.h b/Sources/ObjectivelyMVC/Text.h index 6700ad88..c9dd5a3e 100644 --- a/Sources/ObjectivelyMVC/Text.h +++ b/Sources/ObjectivelyMVC/Text.h @@ -106,16 +106,16 @@ struct Text { bool lineWrap; /** - * @brief The cached Text::naturalSize, valid while `valid` is set and `scale` and - * `colorEscapes` match the Font's scale and this Text's `colorEscapes` -- the latter - * because it is a public, setter-less field that changes the measurement path. + * @brief The cached Text::naturalSize, valid while `isValid` is set and `pixelDensity` + * and `colorEscapes` match the Font's pixel density and this Text's `colorEscapes` -- + * the latter because it is a public, setter-less field that changes the measurement path. * @private */ struct { SDL_Size size; - float scale; + float pixelDensity; bool colorEscapes; - bool valid; + bool isValid; } naturalSizeCache; /** diff --git a/Sources/ObjectivelyMVC/WindowController.c b/Sources/ObjectivelyMVC/WindowController.c index 834541d6..b5e6087b 100644 --- a/Sources/ObjectivelyMVC/WindowController.c +++ b/Sources/ObjectivelyMVC/WindowController.c @@ -60,6 +60,7 @@ static void dealloc(Object *self) { WindowController *this = (WindowController *) self; release(this->debugViewController); + release(this->fontCache); release(this->renderer); release(this->theme); release(this->viewController); @@ -69,6 +70,43 @@ static void dealloc(Object *self) { #pragma mark - WindowController +/** + * @fn Font *WindowController::cachedFont(WindowController *self, const char *family, int size, int style) + * @memberof WindowController + */ +static Font *cachedFont(WindowController *self, const char *family, int size, int style) { + + String *name = $$(Font, nameWithAttributes, family, size, style); + assert(name); + + Font *font = $(self->fontCache, objectForKeyPath, name->chars); + if (font == NULL) { + + Font *resolved = $$(Font, fontWithAttributes, family, size, style, SDL_GetWindowPixelDensity(self->window)); + assert(resolved); + + // Keyed by the resolved Font's own name: when the requested family is not registered, + // Font::fontWithAttributes falls back to the default Font, and caching that under the + // requested key would pin the wrong Font to it permanently. This way the request + // simply misses again, and heals once the family is registered via Font::cacheFont. + String *resolvedName = $(resolved, name); + assert(resolvedName); + + font = $(self->fontCache, objectForKeyPath, resolvedName->chars); + if (font == NULL) { + $(self->fontCache, setObjectForKeyPath, resolved, resolvedName->chars); + font = resolved; + } + + release(resolvedName); + release(resolved); + } + + release(name); + + return font; +} + /** * @fn void WindowController::debug(WindowController *self) * @memberof WindowController @@ -137,6 +175,9 @@ static WindowController *initWithDevice(WindowController *self, RenderDevice *de self = (WindowController *) super(Object, self, init); if (self) { + self->fontCache = $$(Dictionary, dictionary); + assert(self->fontCache); + self->renderer = $(alloc(Renderer), initWithDevice, device); assert(self->renderer); @@ -263,11 +304,29 @@ static void respondToEvent(WindowController *self, const SDL_Event *event) { break; case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: case SDL_EVENT_WINDOW_DISPLAY_CHANGED: - case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: { $(self, setWindow, self->window); + + // Emptied only when the density actually changed: SDL emits PIXEL_SIZE_CHANGED + // continuously during interactive resizes, and discarding open TTF_Fonts on each + // event would re-parse every face, tens of times per second. + const float pixelDensity = SDL_GetWindowPixelDensity(self->window); + if (pixelDensity != self->pixelDensity) { + self->pixelDensity = pixelDensity; + $(self->fontCache, removeAllObjects); + } + $(self->viewController, renderDeviceWillReset); $(self->viewController, renderDeviceDidReset); $(self->viewController->view, updateBindings); + + if (self->debugViewController) { + ViewController *debugViewController = (ViewController *) self->debugViewController; + $(debugViewController, renderDeviceWillReset); + $(debugViewController, renderDeviceDidReset); + $(debugViewController->view, updateBindings); + } + } break; case SDL_EVENT_WINDOW_DESTROYED: case SDL_EVENT_WINDOW_CLOSE_REQUESTED: @@ -418,8 +477,16 @@ static void setViewController(WindowController *self, ViewController *viewContro */ static void setWindow(WindowController *self, SDL_Window *window) { + assert(window); + + // A different window may reside on a different-density display; discard Fonts opened + // at the previous window's density so they re-resolve against the new one. + if (window != self->window) { + self->pixelDensity = SDL_GetWindowPixelDensity(window); + $(self->fontCache, removeAllObjects); + } + self->window = window; - assert(self->window); SDL_PropertiesID properties = SDL_GetWindowProperties(self->window); @@ -528,6 +595,7 @@ static void initialize(Class *clazz) { ((ObjectInterface *) clazz->interface)->dealloc = dealloc; + ((WindowControllerInterface *) clazz->interface)->cachedFont = cachedFont; ((WindowControllerInterface *) clazz->interface)->debug = debug; ((WindowControllerInterface *) clazz->interface)->keyResponder = keyResponder; ((WindowControllerInterface *) clazz->interface)->keyResponders = keyResponders; diff --git a/Sources/ObjectivelyMVC/WindowController.h b/Sources/ObjectivelyMVC/WindowController.h index 1c3adc87..e477697d 100644 --- a/Sources/ObjectivelyMVC/WindowController.h +++ b/Sources/ObjectivelyMVC/WindowController.h @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -60,6 +61,23 @@ struct WindowController { */ DebugViewController *debugViewController; + /** + * @brief The Font instance cache for this window, keyed by Font::name. + * @details Fonts are opened at this window's pixel density, which is why the cache lives + * here rather than on Font: density is implied by the window. The cache is emptied when + * the window's pixel density or display changes, and Views re-resolve their Fonts during + * the ensuing render device reset. + * @private + */ + Dictionary *fontCache; + + /** + * @brief The pixel density of the window when `fontCache` was last emptied, used to + * empty it only when the density actually changes. + * @private + */ + float pixelDensity; + /** * @brief The Renderer. */ @@ -91,6 +109,19 @@ struct WindowControllerInterface { */ ObjectInterface objectInterface; + /** + * @fn Font *WindowController::cachedFont(WindowController *self, const char *family, int size, int style) + * @brief Resolves the cached Font with the given attributes at this window's pixel density. + * @param self The WindowController. + * @param family The family, or `NULL` for the default. + * @param size The point size, or `0` for the default. + * @param style The FontStyle, or `-1` for the default. + * @return The cached Font. The returned Font is owned by the cache; callers retaining it + * beyond the current frame MUST re-resolve after a pixel density change. + * @memberof WindowController + */ + Font *(*cachedFont)(WindowController *self, const char *family, int size, int style); + /** * @private * @fn void WindowController::debug(WindowController *self) From 3b95473f5ebdf8f961158c1a0689dab678ebacdc Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Wed, 2 Sep 2026 10:45:06 -0400 Subject: [PATCH 22/27] Cosmetics. --- Examples/Hello.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Examples/Hello.c b/Examples/Hello.c index eb15c0c8..ebd1b512 100644 --- a/Examples/Hello.c +++ b/Examples/Hello.c @@ -65,7 +65,14 @@ typedef struct { * @brief A decoded PCM sound effect, loaded from a WAV resource. */ typedef struct { + /** + * @brief The PCM buffer. + */ Uint8 *buffer; + + /** + * @brief The buffer length in bytes. + */ Uint32 length; } Sound; @@ -119,6 +126,9 @@ static AppState application; #pragma mark - Scene management +/** + * @brief The cube vertex type. + */ typedef struct { vec3 position; vec3 color; From 490adbdcdc40214d753f9233ceec7e81fd85b9b5 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Wed, 2 Sep 2026 10:50:33 -0400 Subject: [PATCH 23/27] Close font before freeing TTF data. --- Sources/ObjectivelyMVC/Font.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/ObjectivelyMVC/Font.c b/Sources/ObjectivelyMVC/Font.c index ccfe9c06..6fe90833 100644 --- a/Sources/ObjectivelyMVC/Font.c +++ b/Sources/ObjectivelyMVC/Font.c @@ -57,11 +57,11 @@ static void dealloc(Object *self) { Font *this = (Font *) self; + TTF_CloseFont(this->font); + free(this->family); - release(this->data); - - TTF_CloseFont(this->font); + release(this->data); super(Object, self, dealloc); } From ab4c5760c21f7370484b7d0100ffb9ac21b6a206 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Wed, 2 Sep 2026 10:57:09 -0400 Subject: [PATCH 24/27] Whitespace. --- Sources/ObjectivelyMVC/Font.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ObjectivelyMVC/Font.h b/Sources/ObjectivelyMVC/Font.h index ddfaffb3..e1ac1959 100644 --- a/Sources/ObjectivelyMVC/Font.h +++ b/Sources/ObjectivelyMVC/Font.h @@ -88,7 +88,7 @@ struct Font { * @brief The backing font. */ TTF_Font *font; - + /** * @brief The display pixel density scale, greater than 1.0 on high-density displays. */ From e4cd1c93f737759c8fc8659d940396ba156f193a Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Wed, 2 Sep 2026 11:47:44 -0400 Subject: [PATCH 25/27] Don't tread on 0 for renderFrameGeneration sentinel. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/ObjectivelyMVC/View.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index ed3a4b16..9af2e406 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -44,7 +44,9 @@ Uint32 MVC_VIEW_EVENT; static unsigned _renderFrameGeneration; void MVC_InvalidateRenderFrames(void) { - _renderFrameGeneration++; + if (++_renderFrameGeneration == 0) { + _renderFrameGeneration = 1; + } } const EnumName ViewAlignmentNames[] = MakeEnumNames( From 06f6ab861605a943b9ce1e0c5a8f5a3bb03ba7ab Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Wed, 2 Sep 2026 11:48:00 -0400 Subject: [PATCH 26/27] Documentation fix. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/ObjectivelyMVC/View.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index 9af2e406..8f7bb398 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -38,7 +38,7 @@ Uint32 MVC_VIEW_EVENT; /** * @brief The render frame generation; per-View renderFrame and clippingFrame caches are - * valid only while their stamp matches this. Zero disables caching entirely, for callers + * valid only while their generation matches this. Zero disables caching entirely, for callers * that never invalidate (e.g. unit tests). */ static unsigned _renderFrameGeneration; From dca847a53fe6867d9cdea6c1aaea14d3618ffed8 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Wed, 2 Sep 2026 11:48:30 -0400 Subject: [PATCH 27/27] Documentation fix. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/ObjectivelyMVC/View.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ObjectivelyMVC/View.h b/Sources/ObjectivelyMVC/View.h index 480232b5..b427a773 100644 --- a/Sources/ObjectivelyMVC/View.h +++ b/Sources/ObjectivelyMVC/View.h @@ -214,7 +214,7 @@ struct View { Set *classNames; /** - * @brief The cached View::clippingFrame, valid while `stamp` matches the current render + * @brief The cached View::clippingFrame, valid while `generation` matches the current render * frame generation. * @private */