Skip to content

Fix HiDPI half-size regression: default 2d projection is logical points - #575

Open
pusewicz wants to merge 5 commits into
RandyGaul:masterfrom
pusewicz:hidpi-01-projection-fix
Open

Fix HiDPI half-size regression: default 2d projection is logical points#575
pusewicz wants to merge 5 commits into
RandyGaul:masterfrom
pusewicz:hidpi-01-projection-fix

Conversation

@pusewicz

Copy link
Copy Markdown
Contributor

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, and why it's invisible on most dev machines too.

s_canvas now hands the hook window points, and the hook refreshes mvp (mirroring cf_draw_projection) and the AA factor. 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.

On this Retina Mac, master currently fails 20 test_draw_tiled cases; 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.

Copilot AI lite review requested due to automatic review settings August 10, 2026 20:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_hidpi suite and wire it into the test build and runner; update cf_app_set_size docs 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.

Comment thread src/cute_draw.cpp Outdated
Comment on lines +5215 to +5234
// 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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 12, 2026
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.
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 13, 2026
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.
@pusewicz
pusewicz force-pushed the hidpi-01-projection-fix branch from 66d4a31 to d1dd464 Compare August 13, 2026 22:34
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 13, 2026
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.
@pusewicz
pusewicz force-pushed the hidpi-01-projection-fix branch from d1dd464 to d55840a Compare August 13, 2026 23:13

@bullno1 bullno1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/cute_draw.cpp
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would this happen? The event loop drains all event before user code can run so a resize event can't happen during draw.

Comment thread src/cute_draw.cpp
// 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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably correct most of the time but it overrides what was set by cf_draw_projection which was supposed to be sticky.

@bullno1

bullno1 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Hmm, the two cases (aware vs unaware) are similar enough that maybe a single: void cf_app_update_display_metrics(width, height, pixel_scale) helper can work. It will do all: projection, canvas, pixel scale.

The user is responsible for calling it at the correct time. pixel_scale can be an arbitrary value for testing or just pass cf_app_get_dpi_scale through.

pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 14, 2026
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
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 17, 2026
…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
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 17, 2026
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
pusewicz and others added 5 commits August 18, 2026 01:00
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.
@pusewicz
pusewicz force-pushed the hidpi-01-projection-fix branch from d55840a to d345669 Compare August 17, 2026 23:00
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 21, 2026
…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
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 21, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants