diff --git a/include/cute_app.h b/include/cute_app.h index ead3b798c..e37b0e823 100644 --- a/include/cute_app.h +++ b/include/cute_app.h @@ -221,8 +221,8 @@ typedef enum CF_AppOptionFlagBits * @param display_index The index of the display to spawn upon. Set this to zero for the primary display. See `cf_get_display_list`. * @param x The x position of the window. * @param y The y position of the window. - * @param w The width of the window in pixels. - * @param h The height of the window in pixels. + * @param w The width of the window in points (see `cf_app_get_pixel_scale`; a point is a pixel unless the OS clusters pixels). + * @param h The height of the window in points. * @param options 0 by default; a bitmask of `app_options` flags. * @param argv0 The first argument passed to your main function in the `argv` parameter. * @return Returns any errors on failure as a `CF_Result`. @@ -400,12 +400,11 @@ CF_API void CF_CALL cf_app_show_window(void); /** * @function cf_app_get_display_scale * @category app - * @brief Returns the OS's display scale for the window's current display. - * @remarks On some devices (e.g. Apple Retina or iOS) pixels are clustered in 4x4 packs and abstracted as a single pixel - * called a "point". The intent is for applications to work in points, and scale their UI elements by a factor of 2x - * to aid in readability. These devices have very small pixels. Most of the time you should ignore dpi and let the OS - * handle this. CF enables DPI settings by default, but, you can see if this function returns 2.0f to let you know if - * pixels are clustered for you under the hood. + * @brief Returns the OS's suggested UI scale for the window's current display, e.g. 1.5f on a Windows desktop at 150%. + * @remarks This is a hint about how large the user would like UI to appear. It is informational only: CF never applies it. + * On devices that cluster pixels into points (Apple Retina, iOS) it equals `cf_app_get_pixel_scale`, which CF + * does honor automatically. On Windows/Linux desktops a pixel stays a pixel regardless of this value; honor it + * yourself (e.g. by picking a larger window size or UI scale at startup) if your app should follow the OS setting. * @related cf_app_set_size cf_app_get_position cf_app_set_position cf_app_get_width cf_app_get_height cf_app_get_display_scale cf_app_display_scale_was_changed */ CF_API float CF_CALL cf_app_get_display_scale(void); @@ -433,9 +432,11 @@ CF_API float CF_CALL cf_app_get_pixel_scale(void); /** * @function cf_app_set_size * @category app - * @brief Sets the size of the window in pixels. - * @param w The width of the window in pixels. - * @param h The height of the window in pixels. + * @brief Sets the size of the window in points. + * @param w The width of the window in points (a point is a pixel unless the OS clusters pixels; see `cf_app_get_pixel_scale`). + * @param h The height of the window in points. + * @remarks The app canvas and the default 2d projection follow automatically: the canvas is recreated at + * `w * cf_app_get_pixel_scale()` by `h * cf_app_get_pixel_scale()` pixels and the projection spans `w` by `h` points. * @related cf_app_get_size cf_app_get_position cf_app_set_position */ CF_API void CF_CALL cf_app_set_size(int w, int h); @@ -729,11 +730,15 @@ CF_API CF_Canvas CF_CALL cf_app_get_canvas(void); * @param h The height in pixels to resize the canvas to. * @remarks Be careful about calling this function, as it will invalidate any old references from `cf_app_get_canvas`. * + * The default 2d projection is rebuilt to span `w` by `h`, so the draw API works in the canvas's own pixels: + * a 320x180 retro target draws as a 320x180 world and is blitted up to the window (see `cf_app_set_canvas_blit_filter`). + * * This is a one-shot override. The app's canvas is automatically recreated at window size (in points) times * `cf_app_get_pixel_scale` on every canvas recreation event -- a window resize, moving to a display with a - * different pixel density, `cf_app_set_size`, or `cf_app_set_msaa` -- so a custom size lasts only until the - * next such event. For a persistent fixed-resolution render target (e.g. a retro/pixel-art look) make your - * own canvas with `cf_make_canvas` and draw it scaled-up with `cf_draw_canvas`; see the canvas_modes sample. + * different pixel density, `cf_app_set_size`, or `cf_app_set_msaa` -- and the projection goes back to spanning + * the window in points, so a custom size lasts only until the next such event. For a persistent fixed-resolution + * render target (e.g. a retro/pixel-art look) make your own canvas with `cf_make_canvas` and draw it scaled-up + * with `cf_draw_canvas`, or re-apply the size when `cf_app_was_resized`; see the canvas_modes sample. * @related cf_app_get_canvas cf_app_get_canvas_width cf_app_get_canvas_height cf_app_get_pixel_scale cf_app_set_canvas_blit_filter cf_make_canvas cf_draw_canvas */ CF_API void CF_CALL cf_app_set_canvas_size(int w, int h); diff --git a/src/cute_app.cpp b/src/cute_app.cpp index fe7a07845..e95a542b9 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -156,7 +156,6 @@ static void s_canvas(int w, int h) app->offscreen_canvas = cf_make_canvas(params); app->canvas_w = w; app->canvas_h = h; - cf_draw_on_app_canvas_resized(w, h); } void cf_app_recreate_default_canvas_if_needed() @@ -164,6 +163,7 @@ void cf_app_recreate_default_canvas_if_needed() int w = (int)CF_ROUNDF(app->w * app->pixel_scale); int h = (int)CF_ROUNDF(app->h * app->pixel_scale); s_canvas(w, h); + cf_draw_on_app_canvas_resized(app->w, app->h); // The draw API stays in window points. } CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, int y, int w, int h, CF_AppOptionFlags options, const char* argv0) @@ -787,6 +787,7 @@ CF_Canvas cf_app_get_canvas() void cf_app_set_canvas_size(int w, int h) { s_canvas(w, h); + cf_draw_on_app_canvas_resized(w, h); // An explicit canvas size is drawn in its own pixels (a 320x180 retro target). } int cf_app_get_canvas_width() diff --git a/src/cute_draw.cpp b/src/cute_draw.cpp index 545bfb43c..2232dd15d 100644 --- a/src/cute_draw.cpp +++ b/src/cute_draw.cpp @@ -1025,7 +1025,7 @@ void cf_make_draw() { s_draw = CF_NEW(CF_Draw); s_draw->path_image_id_gen = CF_PATH_ID_RANGE_LO; - s_draw->projection = ortho_2d(0, 0, (float)app->w, (float)app->h); + s_draw->projection = s_draw->default_projection = ortho_2d(0, 0, (float)app->w, (float)app->h); s_draw->reset_cam(); s_draw->uniform_arena = cf_make_arena(32, CF_MB); @@ -5319,11 +5319,21 @@ static void s_process_command(CF_Canvas canvas, CF_Command* cmd, CF_Command* nex // one frame of extra draw calls for brand-new content, instead of N defrags every frame. void cf_draw_on_app_canvas_resized(int w, int h) { - // The default 2d projection tracks the app canvas 1:1. It used to be computed once at - // startup and never again, so any resize (cf_app_set_size or a user dragging a resizable - // window) silently rescaled every world-space 2d draw. Refresh it with the canvas; a - // custom cf_draw_projection is per-frame state and simply overrides this as usual. - if (s_draw) s_draw->projection = ortho_2d(0, 0, (float)w, (float)h); + if (!s_draw) return; + CF_M3x2 old = s_draw->default_projection; + CF_M3x2 next = ortho_2d(0, 0, (float)w, (float)h); + s_draw->default_projection = next; + s_draw->projection = next; + // A resize from inside a cf_draw_push scope (a settings menu applying a resolution) must + // survive the matching pop: the saved copies of the old default become the new one, while + // a user's own saved projection is left alone. + for (int i = 0; i < s_draw->projection_stack.size(); ++i) { + if (!CF_MEMCMP(&s_draw->projection_stack[i], &old, sizeof(old))) s_draw->projection_stack[i] = next; + } + // Mirrors cf_draw_projection so a mid-frame cf_app_set_size takes effect immediately, and + // re-derives the AA factor since pixel_scale may be what changed. + CF_MUL_M32_M32(s_draw->mvp, next, s_draw->cam_stack.last()); + s_draw->set_aaf(); } void cf_atlas_defrag_once() diff --git a/src/internal/cute_draw_internal.h b/src/internal/cute_draw_internal.h index 01a5e7cd9..94d1879cc 100644 --- a/src/internal/cute_draw_internal.h +++ b/src/internal/cute_draw_internal.h @@ -356,6 +356,7 @@ struct CF_Draw Cute::Array projection_stack; float aaf = 0; CF_M3x2 projection; + CF_M3x2 default_projection; // The last projection CF set itself; cf_draw_on_app_canvas_resized tells it apart from a user's. CF_M3x2 mvp; void reset_cam(); void set_aaf(); @@ -469,8 +470,12 @@ void cf_draw3d_free_cmd(CF_Command* cmd); // Runs the atlas defrag at most once per frame (see CF_Draw::defragged_this_frame). void cf_atlas_defrag_once(); -// Called when the app's offscreen canvas is recreated (window resize / cf_app_set_size): -// refreshes the default 2d projection, which tracks the app canvas 1:1. +// Called when the app's offscreen canvas is recreated: rebuilds the default 2d projection to span +// w by h, refreshes the mvp + AA factor so the very next draw sees it, and swaps the new default +// into any cf_draw_push-saved copies of the old one so a pop cannot restore a stale size. The +// automatic path (window resize, density change, cf_app_set_size, cf_app_set_msaa) passes the +// window size in LOGICAL POINTS -- never the canvas's pixel size, which halves everything on a 2x +// display -- and cf_app_set_canvas_size passes its own size so a retro canvas draws in its pixels. void cf_draw_on_app_canvas_resized(int w, int h); // Called by cf_render_layers_to before the canvas (and its render pass) is applied: stages diff --git a/test/test_app.cpp b/test/test_app.cpp index 637384789..d6fe3c468 100644 --- a/test/test_app.cpp +++ b/test/test_app.cpp @@ -115,6 +115,80 @@ TEST_CASE(test_app_set_canvas_size_is_one_shot) return true; } +TEST_CASE(test_app_default_projection_is_logical_points_at_2x) +{ + REQUIRE(!cf_is_error(cf_make_app(NULL, 0, 0, 0, 200, 100, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); + OwnedAppGuard guard; + + // Pretend this is a 2x (Retina-class) display: the OS clusters 2x2 pixels into one point. + // Forcing it here means a 1x CI machine exercises the same path a real 2x display takes. + app->pixel_scale = 2.0f; + + // A recreation event rebuilds the canvas at window * pixel_scale... + cf_app_set_size(256, 128); + REQUIRE(cf_app_get_canvas_width() == 512); + REQUIRE(cf_app_get_canvas_height() == 256); + + // ...but the default 2d projection must keep spanning the window in POINTS, not canvas + // pixels. The regression: it was rebuilt from the canvas size, so on a 2x display the + // top-left of the screen mapped to (-256, 128) and every draw rendered at half size. + CF_V2 top_left = cf_screen_to_world(cf_v2(0, 0)); + REQUIRE(CF_FABSF(top_left.x - -128.0f) < 0.01f); + REQUIRE(CF_FABSF(top_left.y - 64.0f) < 0.01f); + CF_V2 bottom_right = cf_screen_to_world(cf_v2(256, 128)); + REQUIRE(CF_FABSF(bottom_right.x - 128.0f) < 0.01f); + REQUIRE(CF_FABSF(bottom_right.y - -64.0f) < 0.01f); + + // And it takes effect immediately, mid-frame, under a pushed camera: the mvp is refreshed + // like cf_draw_projection does, so a translate still composes on top of the new projection. + cf_draw_push(); + cf_draw_translate(10, 0); + cf_app_set_size(300, 150); + CF_V2 origin = cf_world_to_screen(cf_v2(0, 0)); + REQUIRE(CF_FABSF(origin.x - 160.0f) < 0.01f); + REQUIRE(CF_FABSF(origin.y - 75.0f) < 0.01f); + cf_draw_pop(); + + // The pop restores the projection saved at push time -- which must be the NEW default, not + // the stale pre-resize one (a settings menu applying a resolution from inside a push scope). + top_left = cf_screen_to_world(cf_v2(0, 0)); + REQUIRE(CF_FABSF(top_left.x - -150.0f) < 0.01f); + REQUIRE(CF_FABSF(top_left.y - 75.0f) < 0.01f); + + // A user's own saved projection is not touched by a resize. + cf_draw_projection(cf_ortho_2d(0, 0, 40, 20)); + cf_draw_push(); + cf_app_set_size(400, 200); + cf_draw_pop(); + top_left = cf_screen_to_world(cf_v2(0, 0)); + REQUIRE(CF_FABSF(top_left.x - -20.0f) < 0.01f); + REQUIRE(CF_FABSF(top_left.y - 10.0f) < 0.01f); + + return true; +} + +TEST_CASE(test_app_set_canvas_size_projection_spans_the_canvas) +{ + REQUIRE(!cf_is_error(cf_make_app(NULL, 0, 0, 0, 1280, 720, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); + OwnedAppGuard guard; + + // A retro target: the game draws in 320x180 canvas pixels and the canvas is blitted up. The + // default projection follows the explicit canvas size, so the top-left of the screen is the + // top-left of the 320x180 world -- no cf_draw_projection call needed. + cf_app_set_canvas_size(320, 180); + CF_V2 top_left = cf_screen_to_world(cf_v2(0, 0)); + REQUIRE(CF_FABSF(top_left.x - -160.0f) < 0.01f); + REQUIRE(CF_FABSF(top_left.y - 90.0f) < 0.01f); + + // The next recreation event snaps the canvas back to the window, and the projection with it. + cf_app_set_size(640, 360); + top_left = cf_screen_to_world(cf_v2(0, 0)); + REQUIRE(CF_FABSF(top_left.x - -320.0f) < 0.01f); + REQUIRE(CF_FABSF(top_left.y - 180.0f) < 0.01f); + + return true; +} + TEST_CASE(test_app_msaa_change_resets_canvas_size) { REQUIRE(!cf_is_error(cf_make_app(NULL, 0, 0, 0, 200, 100, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); @@ -223,6 +297,8 @@ TEST_SUITE(test_app) // https://github.com/RandyGaul/cute_framework/pull/517 RUN_TEST_CASE(test_app_set_canvas_size_is_one_shot); RUN_TEST_CASE(test_app_msaa_change_resets_canvas_size); + RUN_TEST_CASE(test_app_default_projection_is_logical_points_at_2x); + RUN_TEST_CASE(test_app_set_canvas_size_projection_spans_the_canvas); RUN_TEST_CASE(test_app_present_mode_vsync_always_supported); RUN_TEST_CASE(test_app_present_mode_off_round_trip); RUN_TEST_CASE(test_app_present_mode_mailbox_failure_does_not_corrupt_state);