Fix HiDPI half-size regression: default 2d projection is logical points - #575
Fix HiDPI half-size regression: default 2d projection is logical points#575pusewicz wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a HiDPI regression where the default 2D projection was refreshed using app-canvas pixel dimensions instead of window logical-point dimensions (causing point-space content to render at half size on 2x displays after the first frame). Adds deterministic HiDPI test coverage by introducing an internal pixel-scale override hook used by a new test_hidpi suite.
Changes:
- Make default-projection refresh use window logical size (points), and refresh
mvp+ AA factor on canvas recreation (with safe deferral during draw-list recording / push-pop). - Add
cf_app_force_pixel_scale(internal/test hook) and integrate it with the pixel-density refresh path. - Add a new
test_hidpisuite and wire it into the test build and runner; updatecf_app_set_sizedocs to clarify logical-point units.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/test_hidpi.cpp | New suite that forces 2x pixel scale and readback-verifies projection/resize/draw-list behavior. |
| test/test_app_shared.cpp | Sweeps any leaked forced pixel scale when reusing a test app instance. |
| test/main.cpp | Registers and runs the new test_hidpi suite. |
| test/CMakeLists.txt | Adds test_hidpi.cpp to the tests target sources. |
| src/internal/cute_draw_internal.h | Adds pending-projection state and clarifies resized-hook contract (logical units). |
| src/internal/cute_app_internal.h | Declares cf_app_force_pixel_scale and tracks pixel_scale_override in CF_App. |
| src/cute_input.cpp | Skips real pixel-scale refresh while an override is active. |
| src/cute_draw.cpp | Updates resized-hook behavior: logical extents, mid-frame safety, refresh mvp/AA, deferred apply. |
| src/cute_app.cpp | Passes logical window size to draw resized hook; clamps recreated canvas pixel size; implements force-scale hook. |
| include/cute_app.h | Updates cf_app_set_size docs to specify logical points and mention pixel-density behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // A custom cf_draw_projection is per-frame state and simply overrides this as usual. | ||
| if (!s_draw) return; | ||
| CF_ASSERT(w == app->w && h == app->h); | ||
| // Applying mid draw-list-recording would stomp the recording's identity space (baking | ||
| // the ortho into recorded geometry, doubled again at replay), and applying inside a | ||
| // cf_draw_push/pop pair would just be clobbered by the pop. Park the refresh; reset_cam | ||
| // applies it at the frame boundary. | ||
| if (s_draw->recording_list || s_draw->projection_stack.count() > 0) { | ||
| s_draw->pending_projection = true; | ||
| s_draw->pending_projection_w = w; | ||
| s_draw->pending_projection_h = h; | ||
| return; | ||
| } | ||
| s_draw->pending_projection = false; | ||
| s_draw->projection = ortho_2d(0, 0, (float)w, (float)h); | ||
| // Refresh mvp too, mirroring cf_draw_projection: reset_cam only re-latches it at end of | ||
| // frame, so without this the rest of the resize frame draws with the stale matrix. The | ||
| // AA factor divides by pixel_scale, so it is equally stale on a density change. | ||
| CF_MUL_M32_M32(s_draw->mvp, s_draw->projection, s_draw->cam_stack.last()); | ||
| s_draw->set_aaf(); |
There was a problem hiding this comment.
Fixed in ecca88b: clamps only the value fed to ortho_2d (via a local ortho_w/ortho_h), so w/h still report the real logical size to callers and CF_ASSERT(w == app->w && h == app->h) is untouched.
Some platforms deliver a 0x0 logical window size mid-minimize/resize; cf_ortho_2d divides by the extents, so a bare 0 poisons the projection with inf. Only the value fed to ortho_2d is clamped -- callers still see the real (possibly 0) logical size. Addresses Copilot review comment on PR RandyGaul#575.
Some platforms deliver a 0x0 logical window size mid-minimize/resize; cf_ortho_2d divides by the extents, so a bare 0 poisons the projection with inf. Only the value fed to ortho_2d is clamped -- callers still see the real (possibly 0) logical size. Addresses Copilot review comment on PR RandyGaul#575.
66d4a31 to
d1dd464
Compare
Some platforms deliver a 0x0 logical window size mid-minimize/resize; cf_ortho_2d divides by the extents, so a bare 0 poisons the projection with inf. Only the value fed to ortho_2d is clamped -- callers still see the real (possibly 0) logical size. Addresses Copilot review comment on PR RandyGaul#575.
d1dd464 to
d55840a
Compare
There was a problem hiding this comment.
cf_draw_projection is exposed to the user but it conflicts with the automagic behaviour. And the automagic way also forces one to provide an override for testing on non-HiDPI monitor.
I think there should be no automagic behaviour at all:
- Expose dpi scale:
cf_app_get_dpi_scale - Expose dpi change event:
cf_app_dpi_scale_was_changed - Have
cf_app_{get,set}_pixel_scale. This is now a user controlled value just like window and canvas size. Text and shapes can reference this. - Depending on the creation bit, create the canvas, set pixel scale and projection only once. Maybe even get rid of the bit and just be aware by default. Most of the time, the window is not resizable and DPI does not change. To handle it, copy from the sample or doc.
Then we can update the dpi sample and document on how to handle the events (resize, dpi change...) and how to resize the canvas and recalculate projection. The sample can also force pixel scale to facilitate testing on non-HiDPI screen. There is no more "override" since pixel scale is a user controlled variable, just that it has an initial default.
The sample can switch between:
• Scale = forced 1x
• Scale = natural (reported by SDL)
• Scale = forced 2x, forced 4x...
This means handling dpi change is done the same way as resize: on the event, make the calls to the appropriate functions. Just copy and paste from samples or doc if needed.
Basically:
- To be unaware: set pixel scale to 1, don't react to dpi event, only to resize event. Update canvas and projection accordingly.
- To be aware, set pixel scale to dpi scale, also react to dpi change event.
The one thing I haven't looked at is how window size and mouse position is reported. IIRC, SDL reports raw values from the OS which is weird. The OSX way makes the most sense. Windows is weird. Linux depends on whether it's Xorg (Windows way) or Wayland (OSX way). That part should be normalized.
| if (!s_draw) return; | ||
| // Applying mid draw-list-recording would stomp the recording's identity space, and | ||
| // inside a cf_draw_push/pop pair the pop would just clobber it. Park the refresh; | ||
| // reset_cam applies it at the frame boundary. |
There was a problem hiding this comment.
When would this happen? The event loop drains all event before user code can run so a resize event can't happen during draw.
| // cf_draw_projection refreshes mvp too: reset_cam only re-latches it at end of frame, | ||
| // so the rest of the resize frame would otherwise draw with the stale matrix. Ditto | ||
| // the AA factor, which divides by pixel_scale. | ||
| cf_draw_projection(s_default_projection()); |
There was a problem hiding this comment.
This is probably correct most of the time but it overrides what was set by cf_draw_projection which was supposed to be sticky.
|
Hmm, the two cases (aware vs unaware) are similar enough that maybe a single: The user is responsible for calling it at the correct time. |
bullno1's follow-up suggestion on the PR RandyGaul#575 thread: package the recurring three-step reaction (set pixel scale, resize canvas to window * scale, rebuild the logical-points projection) into a single public helper. The window size is read internally rather than passed in -- it is always current by the time user code reacts to an event, and passing it would only invite stale values. The sample, the shared test-app sweep, and the hidpi tests all shrink to one call each, which was the tell that the helper deserved to be public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn
…ion) Experiment, not a proposal: remove all event-driven HiDPI automagic in favor of user-controlled state, to see what the model looks like. - cf_app_set_pixel_scale: pixel scale is now a plain user value (AA + glyph density); the canvas is resized explicitly via set_canvas_size. - cf_app_pixel_scale_was_changed + cf_app_get_natural_pixel_scale; a density change only raises dpi_scale_was_changed, never applies. - Startup is the single automatic step: canvas at natural density, default 2d projection from the logical window size -- both set once. - cf_draw_projection is now truly sticky: window resizes, canvas recreation, and MSAA changes never touch the projection. - hidpi sample carries the copy-paste resize/density recipe and forced 1x/2x/4x switching; test_hidpi reworked for the new contracts. 350/350 tests pass on a 2x display (master baseline: 319/339 -- the 20 Retina failures were the half-size regression, which dies structurally here since nothing ever rebuilds the projection in pixel units). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn
bullno1's follow-up suggestion on the PR RandyGaul#575 thread: package the recurring three-step reaction (set pixel scale, resize canvas to window * scale, rebuild the logical-points projection) into a single public helper. The window size is read internally rather than passed in -- it is always current by the time user code reacts to an event, and passing it would only invite stale values. The sample, the shared test-app sweep, and the hidpi tests all shrink to one call each, which was the tell that the helper deserved to be public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn
3f5a828 wired cf_draw_on_app_canvas_resized to the canvas's *pixel* dimensions. The default canvas is window_points * pixel_scale, so on a 2x display the projection extent doubled and everything drawn in point space rendered at half size -- from the second frame on, since the hook never refreshed mvp and reset_cam only latches it at end of frame. At 1x displays points and pixels coincide, which is why CI never saw it. The hook now takes no extents at all -- it builds the default projection from the window's logical size directly and refreshes mvp and the AA factor via cf_draw_projection. When a refresh lands mid draw-list-recording or inside a push/pop pair it parks in pending_projection and reset_cam applies it at the frame boundary -- applying immediately would bake the ortho into recorded draw lists (doubled again at replay) or be clobbered by the pop. DPI bugs were untestable on 1x machines, so cf_app_force_pixel_scale (internal) pins pixel_scale through the real recreation path: the new test_hidpi suite readback-verifies the projection contract, the resize frame, one-shot cf_app_set_canvas_size behavior, and draw-list recording across a resize, at a forced 2x on any machine. Pixel-scale resolution (NO_HIGH_DPI pin, then the override, then SDL's reported density) is centralized in one resolver shared by init, the density events, and the force hook; the global app pointer is published before init uses it. NO_GFX apps skip the canvas recreate, and the shared test fixture sweeps a leaked force. Canvas dimensions clamp to >= 1 against degenerate scales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EzCXyMG8sBxLtDSTv2rFi
Some platforms deliver a 0x0 logical window size mid-minimize/resize; cf_ortho_2d divides by the extents, so a bare 0 poisons the projection with inf. Only the value fed to ortho_2d is clamped -- callers still see the real (possibly 0) logical size. Addresses Copilot review comment on PR RandyGaul#575.
A WINDOW_RESIZED event reporting the size app->w/h already have (e.g. an X11/Xvfb ConfigureNotify fired on window map with no actual geometry change) was still treated as a recreation event, silently discarding an active cf_app_set_canvas_size one-shot override. This is what failed test_hidpi_one_shot_canvas_keeps_points_projection on Linux CI. window_state.resized still updates unconditionally -- cf_app_was_resized() must keep reporting true after a programmatic cf_app_set_size, which relies on this same event arriving with matching dimensions. Only the app->w/h store and the canvas recreate are gated on an actual size change (storing the same values is a no-op anyway).
CI's ubuntu/gcc and ubuntu/clang jobs still fail test_hidpi_one_shot_canvas_keeps_points_projection after the redundant-resize-event fix, so something else on Linux is still recreating (or otherwise resizing) the one-shot canvas before the readback. A bare size mismatch gives no way to tell what actually happened; this dumps the app's canvas/window dims and pixel_scale at the point of failure.
…t test The diagnostic added in 9b391fb caught it: ubuntu/clang/static failed with app_canvas=640x480, app_window=320x240, pixel_scale=2.0 -- the canvas was rebuilt from a value that matches the CURRENT window/scale, not a stale one, so af516a7's redundant-event guard doesn't apply here. The shared test app's own cf_app_set_size (inside test_make_app) can still have an SDL_SetWindowSize in flight when this test starts. On X11 its ConfigureNotify confirmation arrives asynchronously, and when a prior test resized the window more than once, it can arrive as a stale-then-fresh burst -- each event looks like a real change relative to the one just before it, so a same-value dedup guard can't tell it apart from a genuine resize. Settling the window (sync + drain) before establishing the one-shot override, instead of leaving it to land wherever the test happens to poll events next, avoids the race entirely without weakening what WINDOW_RESIZED does for a real, externally-driven resize.
d55840a to
d345669
Compare
…ion) Experiment, not a proposal: remove all event-driven HiDPI automagic in favor of user-controlled state, to see what the model looks like. - cf_app_set_pixel_scale: pixel scale is now a plain user value (AA + glyph density); the canvas is resized explicitly via set_canvas_size. - cf_app_pixel_scale_was_changed + cf_app_get_natural_pixel_scale; a density change only raises dpi_scale_was_changed, never applies. - Startup is the single automatic step: canvas at natural density, default 2d projection from the logical window size -- both set once. - cf_draw_projection is now truly sticky: window resizes, canvas recreation, and MSAA changes never touch the projection. - hidpi sample carries the copy-paste resize/density recipe and forced 1x/2x/4x switching; test_hidpi reworked for the new contracts. 350/350 tests pass on a 2x display (master baseline: 319/339 -- the 20 Retina failures were the half-size regression, which dies structurally here since nothing ever rebuilds the projection in pixel units). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn
bullno1's follow-up suggestion on the PR RandyGaul#575 thread: package the recurring three-step reaction (set pixel scale, resize canvas to window * scale, rebuild the logical-points projection) into a single public helper. The window size is read internally rather than passed in -- it is always current by the time user code reacts to an event, and passing it would only invite stale values. The sample, the shared test-app sweep, and the hidpi tests all shrink to one call each, which was the tell that the helper deserved to be public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn
3f5a828 wired
cf_draw_on_app_canvas_resizedto the canvas's pixel dimensions. The default canvas iswindow_points * pixel_scale, so on a 2x display the projection extent doubled and everything drawn in point space rendered at half size — from the second frame on, since the hook never refreshedmvpandreset_camonly latches it at end of frame. At 1x displays points and pixels coincide, which is why CI never saw it, and why it's invisible on most dev machines too.s_canvasnow hands the hook window points, and the hook refreshesmvp(mirroringcf_draw_projection) and the AA factor. When a refresh lands mid draw-list-recording or inside a push/pop pair it parks inpending_projectionandreset_camapplies it at the frame boundary — applying immediately would bake the ortho into recorded draw lists (doubled again at replay) or be clobbered by the pop.DPI bugs were untestable on 1x machines, so
cf_app_force_pixel_scale(internal) pinspixel_scalethrough the real recreation path: the newtest_hidpisuite readback-verifies the projection contract, the resize frame, one-shotcf_app_set_canvas_sizebehavior, and draw-list recording across a resize, at a forced 2x on any machine.On this Retina Mac, master currently fails 20
test_draw_tiledcases; this branch is 346/346.First of a 3-PR series (viewport/scissor scaling and 3d stroke thickness build on this one's test infra) — holding the rest until this lands so each diff stays reviewable on its own.