From b3d60db499d89047b3d96e4c03fd2cc2fc2251b3 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Fri, 8 May 2026 07:42:35 -0700 Subject: [PATCH 01/15] Implement color management v1 Also store color representation v1 pointer. --- src/api/wayfire/core.hpp | 3 +++ src/api/wayfire/nonstd/wlroots.hpp | 2 ++ src/core/core.cpp | 41 +++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/api/wayfire/core.hpp b/src/api/wayfire/core.hpp index 1e1761887..4bcf8d69e 100644 --- a/src/api/wayfire/core.hpp +++ b/src/api/wayfire/core.hpp @@ -195,6 +195,9 @@ class compositor_core_t : public wf::object_base_t, public signal::provider_t wlr_xdg_foreign_v2 *foreign_v2; wlr_ext_data_control_manager_v1 *ext_data_control; + + wlr_color_manager_v1 *color_manager_v1 = NULL; + wlr_color_representation_manager_v1 *color_representation_v1 = NULL; } protocols; std::string to_string() const diff --git a/src/api/wayfire/nonstd/wlroots.hpp b/src/api/wayfire/nonstd/wlroots.hpp index 63b8c6c23..47502dce1 100644 --- a/src/api/wayfire/nonstd/wlroots.hpp +++ b/src/api/wayfire/nonstd/wlroots.hpp @@ -57,6 +57,8 @@ extern "C" struct wlr_viewporter; struct wlr_ext_data_control_manager_v1; + struct wlr_color_manager_v1; + struct wlr_color_representation_manager_v1; #include #include diff --git a/src/core/core.cpp b/src/core/core.cpp index 16f602b0f..c49edf113 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -245,7 +245,46 @@ void wf::compositor_core_impl_t::init() wlr_fractional_scale_manager_v1_create(display, 1); wlr_single_pixel_buffer_manager_v1_create(display); - wlr_color_representation_manager_v1_create_with_renderer(display, 1, renderer); + protocols.color_representation_v1 = + wlr_color_representation_manager_v1_create_with_renderer(display, 1, renderer); + + if (renderer->features.input_color_transform) + { + static const enum wp_color_manager_v1_render_intent render_intents[] = { + WP_COLOR_MANAGER_V1_RENDER_INTENT_PERCEPTUAL, + }; + + size_t transfer_functions_len = 0; + enum wp_color_manager_v1_transfer_function *transfer_functions = + wlr_color_manager_v1_transfer_function_list_from_renderer(renderer, &transfer_functions_len); + + size_t primaries_len = 0; + enum wp_color_manager_v1_primaries *primaries = + wlr_color_manager_v1_primaries_list_from_renderer(renderer, &primaries_len); + + wlr_color_manager_v1_options cm_options{}; + cm_options.features.parametric = true; + cm_options.features.set_mastering_display_primaries = true; + cm_options.render_intents = render_intents; + cm_options.render_intents_len = sizeof(render_intents) / sizeof(render_intents[0]); + cm_options.transfer_functions = transfer_functions; + cm_options.transfer_functions_len = transfer_functions_len; + cm_options.primaries = primaries; + cm_options.primaries_len = primaries_len; + + protocols.color_manager_v1 = wlr_color_manager_v1_create(display, 2, &cm_options); + if (!protocols.color_manager_v1) + { + LOGE("Failed to create wlr_color_manager_v1 global"); + } + + free(transfer_functions); + free(primaries); + } else + { + LOGI("Renderer does not support input color transforms; " + "wp_color_management_v1 will not be available."); + } this->bindings = std::make_unique(); image_io::init(); From 569c612ada45b7edb85d8023202aa254bf1c297c Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Fri, 8 May 2026 07:46:19 -0700 Subject: [PATCH 02/15] Add extra parameters to color_transform_t For future proofing, in case they become useful to the render setup. --- src/api/wayfire/render.hpp | 13 +++++++++++++ src/render.cpp | 4 +++- src/view/wlr-surface-node.cpp | 9 +++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/api/wayfire/render.hpp b/src/api/wayfire/render.hpp index 002e830b3..08d90ea6a 100644 --- a/src/api/wayfire/render.hpp +++ b/src/api/wayfire/render.hpp @@ -52,6 +52,19 @@ struct color_transform_t */ wlr_color_range color_range = WLR_COLOR_RANGE_NONE; + /** + * Alpha mode of the texture, as set via wp_color_representation_v1. The default + * (premultiplied electrical) matches the wlroots renderer's compositing model. Other modes + * are not currently forwarded to the renderer. + */ + wlr_alpha_mode alpha_mode = WLR_COLOR_ALPHA_MODE_PREMULTIPLIED_ELECTRICAL; + + /** + * Chroma sample location, as set via wp_color_representation_v1. Used only for ycbcr textures. + * Not currently forwarded to the renderer. + */ + wlr_color_chroma_location chroma_location = WLR_COLOR_CHROMA_LOCATION_NONE; + bool operator ==(const color_transform_t& other) const; bool operator !=(const color_transform_t& other) const; }; diff --git a/src/render.cpp b/src/render.cpp index 20b47471f..8efbfc9c8 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -11,7 +11,9 @@ bool wf::color_transform_t::operator ==(const color_transform_t& other) const return transfer_function == other.transfer_function && primaries == other.primaries && color_encoding == other.color_encoding && - color_range == other.color_range; + color_range == other.color_range && + alpha_mode == other.alpha_mode && + chroma_location == other.chroma_location; } bool wf::color_transform_t::operator !=(const color_transform_t& other) const diff --git a/src/view/wlr-surface-node.cpp b/src/view/wlr-surface-node.cpp index d8487e43b..5ba9b2995 100644 --- a/src/view/wlr-surface-node.cpp +++ b/src/view/wlr-surface-node.cpp @@ -90,6 +90,9 @@ void wf::scene::surface_state_t::merge_state(wlr_surface *surface) wlr_color_representation_v1_get_surface_state(surface); if (color_repr != NULL) { + this->color_transform.alpha_mode = wlr_color_representation_v1_alpha_mode_to_wlr( + color_repr->alpha_mode); + if (color_repr->coefficients != 0) { this->color_transform.color_encoding = wlr_color_representation_v1_color_encoding_to_wlr( @@ -101,6 +104,12 @@ void wf::scene::surface_state_t::merge_state(wlr_surface *surface) this->color_transform.color_range = wlr_color_representation_v1_color_range_to_wlr( (wp_color_representation_surface_v1_range)color_repr->range); } + + if (color_repr->chroma_location != 0) + { + this->color_transform.chroma_location = wlr_color_representation_v1_chroma_location_to_wlr( + (wp_color_representation_surface_v1_chroma_location)color_repr->chroma_location); + } } if (surface->current.viewport.has_src) From 102bb792b5b02952a05b74285db723842e965982 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Fri, 8 May 2026 07:50:41 -0700 Subject: [PATCH 03/15] Set output color transfer function --- src/api/wayfire/render.hpp | 19 ++++++++++- src/output/render-manager.cpp | 59 +++++++++++++++++++++++++++++++++-- src/render.cpp | 5 ++- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/api/wayfire/render.hpp b/src/api/wayfire/render.hpp index 08d90ea6a..2606de31d 100644 --- a/src/api/wayfire/render.hpp +++ b/src/api/wayfire/render.hpp @@ -437,11 +437,28 @@ struct render_target_t : public render_buffer_t /** * Set a new color transform for the render target. + * + * @param transform The wlr_color_transform to apply when rendering to this target. Wayfire + * takes a reference; the caller retains ownership of its own reference. + * @param target_tf The transfer function that the @transform encodes to. Used to compute + * per-texture luminance multipliers when SDR content is rendered to HDR targets and vice + * versa. + */ + void set_color_transform(wlr_color_transform *transform, + wlr_color_transfer_function target_tf = WLR_COLOR_TRANSFER_FUNCTION_SRGB); + + /** + * The transfer function that the render target's color transform encodes to. Read this to + * determine whether the target is HDR (ST2084_PQ) or SDR. */ - void set_color_transform(wlr_color_transform *transform); + wlr_color_transfer_function get_output_transfer_function() const + { + return output_transfer_function; + } private: wlr_color_transform *inverse_eotf = nullptr; + wlr_color_transfer_function output_transfer_function = WLR_COLOR_TRANSFER_FUNCTION_SRGB; void copy_from(const render_target_t& other); }; diff --git a/src/output/render-manager.cpp b/src/output/render-manager.cpp index ca77d68da..be17df9ba 100644 --- a/src/output/render-manager.cpp +++ b/src/output/render-manager.cpp @@ -809,6 +809,52 @@ class wf::render_manager::impl wf::option_wrapper_t background_color_opt; std::unique_ptr current_pass; wf::option_wrapper_t icc_profile; + wf::option_wrapper_t hdr; + + /** + * The inverse-EOTF transform that matches the output's currently-committed image description. + * Cached so that it is not recreated each frame. + */ + wlr_color_transform *output_inverse_eotf = nullptr; + wlr_color_transfer_function output_inverse_eotf_tf = (wlr_color_transfer_function)0; + + /** + * The transfer function the output expects in its committed image description, or sRGB if no + * image description has been set. + */ + wlr_color_transfer_function get_output_transfer_function() + { + if (output->handle->image_description) + { + return output->handle->image_description->transfer_function; + } + + return WLR_COLOR_TRANSFER_FUNCTION_SRGB; + } + + wlr_color_transform *get_output_inverse_eotf() + { + wlr_color_transfer_function tf = get_output_transfer_function(); + if (output_inverse_eotf && (output_inverse_eotf_tf == tf)) + { + return output_inverse_eotf; + } + + if (output_inverse_eotf) + { + wlr_color_transform_unref(output_inverse_eotf); + } + + output_inverse_eotf = wlr_color_transform_init_linear_to_inverse_eotf(tf); + output_inverse_eotf_tf = tf; + if (!output_inverse_eotf) + { + LOGE("Failed to create inverse-EOTF transform for output ", output->to_string(), + " (transfer function ", (int)tf, ")"); + } + + return output_inverse_eotf; + } wlr_color_transform *get_color_transform() { @@ -817,9 +863,7 @@ class wf::render_manager::impl return icc_color_transform; } - static wlr_color_transform *default_transform = - wlr_color_transform_init_linear_to_inverse_eotf(WLR_COLOR_TRANSFER_FUNCTION_SRGB); - return default_transform; + return get_output_inverse_eotf(); } impl(output_t *o) : output(o), env_allow_scanout(check_scanout_enabled()) @@ -936,6 +980,11 @@ class wf::render_manager::impl ~impl() { set_icc_transform(nullptr); + if (output_inverse_eotf) + { + wlr_color_transform_unref(output_inverse_eotf); + output_inverse_eotf = nullptr; + } } const bool env_allow_scanout; @@ -1012,6 +1061,10 @@ class wf::render_manager::impl params.target = postprocessing->get_target_framebuffer().translated( wf::origin(output->get_layout_geometry())); + // Set the target's transfer function to match the output, so that render_pass_t::add_texture + // can derive a luminance multiplier when SDR content is being composited onto an HDR output. + params.target.set_color_transform(params.target.get_color_transform(), + get_output_transfer_function()); params.damage = damage_manager->get_scheduled_damage(params.target); params.background_color = background_color_opt; diff --git a/src/render.cpp b/src/render.cpp index 8efbfc9c8..e7b1c8770 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -403,6 +403,7 @@ void wf::render_target_t::copy_from(const render_target_t& other) scale = other.scale; subbuffer = other.subbuffer; inverse_eotf = other.inverse_eotf; + output_transfer_function = other.output_transfer_function; } wf::render_target_t::render_target_t(const render_target_t& other) : render_buffer_t(other) @@ -855,7 +856,8 @@ wlr_render_pass*wf::render_pass_t::_get_pass() return _pass; } -void wf::render_target_t::set_color_transform(wlr_color_transform *transform) +void wf::render_target_t::set_color_transform(wlr_color_transform *transform, + wlr_color_transfer_function target_tf) { if (transform) { @@ -868,4 +870,5 @@ void wf::render_target_t::set_color_transform(wlr_color_transform *transform) } inverse_eotf = transform; + output_transfer_function = target_tf; } From 363d053ebd97d9810c8a06c2eb53a2ad634df2f5 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Fri, 8 May 2026 07:53:33 -0700 Subject: [PATCH 04/15] Reset output transfer function on config change --- src/output/render-manager.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/output/render-manager.cpp b/src/output/render-manager.cpp index be17df9ba..e4693d9ea 100644 --- a/src/output/render-manager.cpp +++ b/src/output/render-manager.cpp @@ -923,6 +923,21 @@ class wf::render_manager::impl reload_icc_profile(); damage_manager->damage_whole_idle(); }); + hdr.load_option(section, "hdr"); + hdr.set_callback([=] () + { + // Drop the cached inverse-EOTF: by the time the next frame is rendered, the + // output's image_description will have been re-committed by output-layout, and + // get_output_inverse_eotf() will lazily regenerate the transform to match. + if (output_inverse_eotf) + { + wlr_color_transform_unref(output_inverse_eotf); + output_inverse_eotf = nullptr; + output_inverse_eotf_tf = (wlr_color_transfer_function)0; + } + + damage_manager->damage_whole_idle(); + }); reload_icc_profile(); } From f57d1904da2e3073a4c5b638b27966e6d1b43ca5 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Fri, 8 May 2026 07:54:19 -0700 Subject: [PATCH 05/15] Adjust render transfer functions --- src/render.cpp | 3 ++- src/view/wlr-surface-node.cpp | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/render.cpp b/src/render.cpp index e7b1c8770..143f54b13 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -393,7 +393,8 @@ wf::render_target_t::render_target_t(const auxilliary_buffer_t& buffer) : render { // By default, we keep aux buffers in SRGB color space, as SRGB is efficiently implemented in Vulkan. set_color_transform( - wlr_color_transform_init_linear_to_inverse_eotf(WLR_COLOR_TRANSFER_FUNCTION_EXT_LINEAR)); + wlr_color_transform_init_linear_to_inverse_eotf(WLR_COLOR_TRANSFER_FUNCTION_EXT_LINEAR), + WLR_COLOR_TRANSFER_FUNCTION_EXT_LINEAR); } void wf::render_target_t::copy_from(const render_target_t& other) diff --git a/src/view/wlr-surface-node.cpp b/src/view/wlr-surface-node.cpp index 5ba9b2995..16a6f6c76 100644 --- a/src/view/wlr-surface-node.cpp +++ b/src/view/wlr-surface-node.cpp @@ -74,16 +74,28 @@ void wf::scene::surface_state_t::merge_state(wlr_surface *surface) this->size = {0, 0}; } + // The wp_color_management_v1 protocol nominally treats surfaces without an image description + // as sRGB, but for compositing purposes sRGB and gamma 2.2 are approximately equivalent. We + // use gamma 2.2 here so that the surface forward-EOTF and the SDR output inverse-EOTF go + // through different code paths in the renderer (avoiding a fast-path that would short-circuit + // proper linear-space blending when both happen to be sRGB). this->color_transform = wf::color_transform_t{}; this->color_transform.transfer_function = WLR_COLOR_TRANSFER_FUNCTION_GAMMA22; const wlr_image_description_v1_data *img_desc = wlr_surface_get_image_description_v1_data(surface); if (img_desc != NULL) { - this->color_transform.transfer_function = wlr_color_manager_v1_transfer_function_to_wlr( - (wp_color_manager_v1_transfer_function)img_desc->tf_named); - this->color_transform.primaries = wlr_color_manager_v1_primaries_to_wlr( - (wp_color_manager_v1_primaries)img_desc->primaries_named); + if (img_desc->tf_named != 0) + { + this->color_transform.transfer_function = wlr_color_manager_v1_transfer_function_to_wlr( + (wp_color_manager_v1_transfer_function)img_desc->tf_named); + } + + if (img_desc->primaries_named != 0) + { + this->color_transform.primaries = wlr_color_manager_v1_primaries_to_wlr( + (wp_color_manager_v1_primaries)img_desc->primaries_named); + } } const wlr_color_representation_v1_surface_state *color_repr = From 2e4ef5653b7bad1f7cc3260be2598e997dd29fe0 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Fri, 8 May 2026 07:54:59 -0700 Subject: [PATCH 06/15] Adjust luminance for sRGB/gamma22 brightness --- src/render.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/render.cpp b/src/render.cpp index 143f54b13..41bcb4675 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -708,6 +708,18 @@ void wf::render_pass_t::add_texture(const std::shared_ptr& textur opts.primaries = &primaries; opts.transfer_function = ct.transfer_function; + // The wlroots renderer does no implicit luminance scaling: the forward EOTF for SDR transfer + // functions yields values in [0,1] relative to the SDR reference white, but the inverse EOTF + // for ST2084 PQ interprets [0,1] as 0–10000 cd/m² absolute. Without correction, SDR content + // composited on an HDR output would appear ~100× too bright. Compute a multiplier that brings + // the per-texture linear values into the target's expected absolute domain. + const float luminance_multiplier = compute_luminance_multiplier( + ct.transfer_function, adjusted_target.get_output_transfer_function()); + if (luminance_multiplier != 1.0f) + { + opts.luminance_multiplier = &luminance_multiplier; + } + wlr_render_pass_add_texture(get_wlr_pass(), &opts); } From 8340829b60c53469df4c00212dfac9462de63f63 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Fri, 8 May 2026 07:55:53 -0700 Subject: [PATCH 07/15] Support surface preferred image description Defaulting to sRGB/gamma22 for non-matching parameters. --- src/api/wayfire/unstable/wlr-surface-node.hpp | 1 + src/view/wlr-surface-node.cpp | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/api/wayfire/unstable/wlr-surface-node.hpp b/src/api/wayfire/unstable/wlr-surface-node.hpp index bf5289afc..b919592b6 100644 --- a/src/api/wayfire/unstable/wlr-surface-node.hpp +++ b/src/api/wayfire/unstable/wlr-surface-node.hpp @@ -88,6 +88,7 @@ class wlr_surface_node_t : public node_t, public zero_copy_texturable_node_t void handle_enter(wf::output_t *output); void handle_leave(wf::output_t *output); void update_pending_outputs(); + void update_preferred_image_description(); wf::wl_idle_call idle_update_outputs; wf::wl_listener_wrapper on_surface_destroyed; diff --git a/src/view/wlr-surface-node.cpp b/src/view/wlr-surface-node.cpp index 16a6f6c76..30b79dd58 100644 --- a/src/view/wlr-surface-node.cpp +++ b/src/view/wlr-surface-node.cpp @@ -547,7 +547,57 @@ void wf::scene::wlr_surface_node_t::update_pending_outputs() wlr_fractional_scale_v1_notify_scale(surface, max_scale); wlr_surface_set_preferred_buffer_scale(surface, max_scale); + update_preferred_image_description(); } pending_visibility_delta.clear(); } + +void wf::scene::wlr_surface_node_t::update_preferred_image_description() +{ + if (!surface) + { + return; + } + + auto cm = wf::get_core().protocols.color_manager_v1; + if (!cm) + { + return; + } + + // Pick the "most capable" image description amongst the outputs we are visible on. + // HDR-capable outputs win over SDR ones so that clients are told they may use HDR if any + // of their outputs supports it. + const wlr_output_image_description *best = nullptr; + for (auto& [wo, _] : visibility) + { + const wlr_output_image_description *img = wo->handle->image_description; + if (!img) + { + continue; + } + + if (!best || + ((best->transfer_function != WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ) && + (img->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ))) + { + best = img; + } + } + + if (!best) + { + wlr_image_description_v1_data data{}; + data.tf_named = WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_GAMMA22; + data.primaries_named = WP_COLOR_MANAGER_V1_PRIMARIES_SRGB; + wlr_color_manager_v1_set_surface_preferred_image_description(cm, surface, &data); + return; + } + + wlr_image_description_v1_data data{}; + data.tf_named = wlr_color_manager_v1_transfer_function_from_wlr(best->transfer_function); + data.primaries_named = wlr_color_manager_v1_primaries_from_wlr(best->primaries); + + wlr_color_manager_v1_set_surface_preferred_image_description(cm, surface, &data); +} From f59965cd2fc62e74197df6b376d382075ef788c8 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Fri, 8 May 2026 07:56:24 -0700 Subject: [PATCH 08/15] Compensate for pre-multiplied alpha and gamma22 --- src/render.cpp | 82 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/src/render.cpp b/src/render.cpp index 41bcb4675..9d530a1e4 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -4,8 +4,83 @@ #include "wayfire/nonstd/reverse.hpp" #include "wayfire/opengl.hpp" #include +#include #include +/** + * SDR reference white luminance in cd/m², used when bridging between [0,1]-relative SDR linear + * values and the absolute PQ luminance range. Matches BT.2408 ("graphics white") and the default + * used by KDE/GNOME for SDR-on-HDR compositing. + */ +constexpr float SDR_REFERENCE_WHITE_NITS = 203.0f; +constexpr float PQ_MAX_NITS = 10000.0f; + +static bool is_hdr_transfer_function(wlr_color_transfer_function tf) +{ + return tf == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; +} + +/** + * Compute the luminance multiplier needed when a texture with @source_tf is rendered to a target + * with @target_tf. The wlroots renderer does no implicit luminance scaling between SDR and HDR + * domains, so SDR content composited onto a PQ output (or vice-versa) needs an explicit factor + * to bridge the [0,1]-relative SDR linear range and the 0–10000 cd/m² absolute PQ linear range. + */ +static float compute_luminance_multiplier(wlr_color_transfer_function source_tf, + wlr_color_transfer_function target_tf) +{ + const bool source_pq = is_hdr_transfer_function(source_tf); + const bool target_pq = is_hdr_transfer_function(target_tf); + + if (source_pq == target_pq) + { + return 1.0f; + } + + if (target_pq) + { + // SDR source → HDR target: scale [0,1] relative down so 1.0 maps to the SDR + // reference white in the PQ-relative range. + return SDR_REFERENCE_WHITE_NITS / PQ_MAX_NITS; + } + + // HDR source → SDR target: scale up so the SDR reference luminance maps to 1.0. + return PQ_MAX_NITS / SDR_REFERENCE_WHITE_NITS; +} + +static float gamma22_to_linear(float c) +{ + return powf(std::max(c, 0.0f), 2.2f); +} + +static float linear_to_gamma22(float c) +{ + return powf(std::max(c, 0.0f), (1.0f / 2.2f)); +} + +static wlr_render_color color_to_render_color(const wf::color_t& color, + wlr_color_transfer_function target_tf) +{ + if (!is_hdr_transfer_function(target_tf)) + { + return wlr_render_color{ + .r = static_cast(color.r), + .g = static_cast(color.g), + .b = static_cast(color.b), + .a = static_cast(color.a) + }; + } + + const float scale = SDR_REFERENCE_WHITE_NITS / PQ_MAX_NITS; + const float alpha = static_cast(color.a); + return wlr_render_color{ + .r = linear_to_gamma22(gamma22_to_linear(static_cast(color.r) / alpha) * scale) * alpha, + .g = linear_to_gamma22(gamma22_to_linear(static_cast(color.g) / alpha) * scale) * alpha, + .b = linear_to_gamma22(gamma22_to_linear(static_cast(color.b) / alpha) * scale) * alpha, + .a = alpha, + }; +} + bool wf::color_transform_t::operator ==(const color_transform_t& other) const { return transfer_function == other.transfer_function && @@ -733,12 +808,7 @@ void wf::render_pass_t::add_rect(const wf::color_t& color, const wf::render_targ wf::region_t fb_damage = adjusted_target.framebuffer_region_from_geometry_region(damage); wlr_render_rect_options opts; - opts.color = { - .r = static_cast(color.r), - .g = static_cast(color.g), - .b = static_cast(color.b), - .a = static_cast(color.a), - }; + opts.color = color_to_render_color(color, adjusted_target.get_output_transfer_function()); opts.blend_mode = WLR_RENDER_BLEND_MODE_PREMULTIPLIED; opts.clip = fb_damage.to_pixman(); opts.box = fbox_to_geometry(adjusted_target.framebuffer_box_from_geometry_box(geometry)); From 9dedad305cc6b44ddba77a8d0b1dcea9864314e9 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Sat, 23 May 2026 23:50:28 -0700 Subject: [PATCH 09/15] Linear space color rendering Since the wlroots Vulkan renderer already supports intermediate float16 render pass, take advantage of it, and support allocating other intermediate buffers with a float16 hint, otherwise falling back on fixed 16 or fixed 8 where the others are unavailable. --- src/api/wayfire/render.hpp | 7 +++++++ src/output/render-manager.cpp | 11 +++++------ src/render.cpp | 25 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/api/wayfire/render.hpp b/src/api/wayfire/render.hpp index 2606de31d..ef9e632bc 100644 --- a/src/api/wayfire/render.hpp +++ b/src/api/wayfire/render.hpp @@ -250,6 +250,13 @@ struct render_buffer_t struct buffer_allocation_hints_t { bool needs_alpha = true; + /** + * If set, prefer a half-float (FP16) per-channel format so the buffer can store + * extended-range linear values without clipping or banding. Used for HDR linear + * compositing intermediates. Falls back to a 16-bit fixed format and then to 8-bit + * if no FP16 format is available. + */ + bool hdr_linear = false; }; /** diff --git a/src/output/render-manager.cpp b/src/output/render-manager.cpp index e4693d9ea..5ae56e111 100644 --- a/src/output/render-manager.cpp +++ b/src/output/render-manager.cpp @@ -1076,10 +1076,9 @@ class wf::render_manager::impl params.target = postprocessing->get_target_framebuffer().translated( wf::origin(output->get_layout_geometry())); - // Set the target's transfer function to match the output, so that render_pass_t::add_texture - // can derive a luminance multiplier when SDR content is being composited onto an HDR output. - params.target.set_color_transform(params.target.get_color_transform(), - get_output_transfer_function()); + params.target.set_color_transform(get_color_transform(), get_output_transfer_function()); + pass_opts.color_transform = get_color_transform(); + params.damage = damage_manager->get_scheduled_damage(params.target); params.background_color = background_color_opt; @@ -1087,8 +1086,8 @@ class wf::render_manager::impl params.renderer = output->handle->renderer; params.flags = RPASS_CLEAR_BACKGROUND | RPASS_EMIT_SIGNALS; - pass_opts.timer = NULL; // TODO: do we care about this? could be useful for dynamic frame scheduling - pass_opts.color_transform = get_color_transform(); + pass_opts.timer = NULL; // TODO: do we care about this? could be useful for dynamic frame + // scheduling params.pass_opts = std::move(pass_opts); this->current_pass = std::make_unique(params); diff --git a/src/render.cpp b/src/render.cpp index 9d530a1e4..0c37e75bf 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -227,6 +227,18 @@ wf::auxilliary_buffer_t::~auxilliary_buffer_t() static const wlr_drm_format *choose_format_from_set(const wlr_drm_format_set *set, wf::buffer_allocation_hints_t hints) { + // Half-float formats: preferred when storing extended-range linear values + // (HDR scene intermediate). RGBA16F has alpha; we use it for both alpha and + // no-alpha cases since we need the precision regardless. + static std::vector hdr_linear_formats = { + DRM_FORMAT_ABGR16161616F, + DRM_FORMAT_XBGR16161616F, + // 16-bit fixed-point fallback. Sufficient range for SDR-relative linear + // values up to ~49.26 (HDR peak in our domain). + DRM_FORMAT_ABGR16161616, + DRM_FORMAT_XBGR16161616, + }; + static std::vector alpha_formats = { DRM_FORMAT_ARGB8888, DRM_FORMAT_ABGR8888, @@ -241,6 +253,19 @@ static const wlr_drm_format *choose_format_from_set(const wlr_drm_format_set *se DRM_FORMAT_BGRX8888, }; + if (hints.hdr_linear) + { + for (auto drm_format : hdr_linear_formats) + { + if (auto layout = wlr_drm_format_set_get(set, drm_format)) + { + return layout; + } + } + + // Fall through to 8-bit if no high-precision format is available. + } + const auto& possible_formats = hints.needs_alpha ? alpha_formats : no_alpha_formats; for (auto drm_format : possible_formats) { From 7f2c3ad3962bac740f54617f97b2615e5491ce91 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Sat, 9 May 2026 16:33:47 -0700 Subject: [PATCH 10/15] Allocate FP16 intermediates for plugins on HDR outputs Plugin-managed linear-space buffers (Expo's per-workspace aux, the cube's per-workspace framebuffers, the grid crossfade snapshot, view snapshots, and transformer inner_content) had no opinion about their underlying format. With an HDR (PQ) output, the per-source luminance multiplier in render_pass_t::add_texture scales PQ contents up to ~49.26 in the SDR-relative linear domain (PQ peak / SDR reference white) before they land in those buffers. An 8-bit linear backing clipped that to 1.0, dimming HDR highlights. Pass hdr_linear through buffer_allocation_hints_t at each of those sites, gated on the output's committed transfer function being ST2084_PQ. SDR outputs keep their 8-bit allocations. transformer_base_node_t::get_updated_contents gains an optional wf::output_t* parameter so it can make the same decision; the call site in transformer_render_instance_t::get_texture forwards _shown_on. The default nullptr preserves the existing behavior for any out-of-tree callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/common/workspace-wall.cpp | 8 ++++++++ plugins/cube/cube.cpp | 11 ++++++++++- plugins/grid/wayfire/plugins/crossfade.hpp | 9 ++++++++- src/api/wayfire/view-transform.hpp | 10 ++++++++-- src/view/view-3d.cpp | 12 ++++++++++-- src/view/view.cpp | 8 +++++++- 6 files changed, 51 insertions(+), 7 deletions(-) diff --git a/plugins/common/workspace-wall.cpp b/plugins/common/workspace-wall.cpp index b3f583c6e..ff27bb893 100644 --- a/plugins/common/workspace-wall.cpp +++ b/plugins/common/workspace-wall.cpp @@ -259,9 +259,17 @@ class workspace_wall_t::workspace_wall_node_t : public scene::node_t auto bbox = workspaces[i][j]->get_bounding_box(); + // The aux buffer stores a linear-space scene composite (target_tf == EXT_LINEAR). + // When the output is HDR (PQ), HDR sources contribute SDR-relative linear values up to + // ~49.26 (PQ peak / SDR reference white), which would clip in an 8-bit linear buffer. + const auto *img_desc = wall->output->handle->image_description; + const bool is_hdr = img_desc && + img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; + aux_buffers[i][j].allocate(wf::dimensions(bbox), wall->output->handle->scale, wf::buffer_allocation_hints_t{ .needs_alpha = false, + .hdr_linear = is_hdr, }); aux_buffer_damage[i][j] |= bbox; aux_buffer_current_scale[i][j] = 1.0; diff --git a/plugins/cube/cube.cpp b/plugins/cube/cube.cpp index 8765e5c94..1557602b1 100644 --- a/plugins/cube/cube.cpp +++ b/plugins/cube/cube.cpp @@ -98,11 +98,20 @@ class wayfire_cube : public wf::per_output_plugin_instance_t, public wf::pointer damage ^= bbox; + // Per-workspace aux buffers are linear scene composites (target_tf == EXT_LINEAR). + // On HDR (PQ) outputs, HDR sources land in the SDR-relative linear domain at values + // up to ~49.26 (PQ peak / SDR reference white) and need an FP16 backing to avoid + // clipping. + const auto *img_desc = self->cube->output->handle->image_description; + const bool is_hdr = img_desc && + img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; + for (int i = 0; i < (int)ws_instances.size(); i++) { const float scale = self->cube->output->handle->scale; auto bbox = self->workspaces[i]->get_bounding_box(); - framebuffers[i].allocate(wf::dimensions(bbox), scale); + framebuffers[i].allocate(wf::dimensions(bbox), scale, + wf::buffer_allocation_hints_t{.hdr_linear = is_hdr}); wf::render_target_t target{framebuffers[i]}; target.geometry = self->workspaces[i]->get_bounding_box(); diff --git a/plugins/grid/wayfire/plugins/crossfade.hpp b/plugins/grid/wayfire/plugins/crossfade.hpp index 466985112..c237b0730 100644 --- a/plugins/grid/wayfire/plugins/crossfade.hpp +++ b/plugins/grid/wayfire/plugins/crossfade.hpp @@ -50,7 +50,14 @@ class crossfade_node_t : public scene::view_2d_transformer_t const wf::geometry_t bbox = root_node->get_bounding_box(); const wf::geometry_t g = view->get_geometry(); const float scale = view->get_output()->handle->scale; - original_buffer.allocate(wf::dimensions(g), scale); + // The original-contents buffer is a linear-space view snapshot. On HDR outputs the view's + // PQ contents are bridged to SDR-relative linear by the per-source luminance multiplier, + // landing at values up to ~49.26 — request FP16 storage so they aren't clipped. + const auto *img_desc = view->get_output()->handle->image_description; + const bool is_hdr = img_desc && + img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; + original_buffer.allocate(wf::dimensions(g), scale, + wf::buffer_allocation_hints_t{.hdr_linear = is_hdr}); wf::render_target_t target{original_buffer}; target.geometry = view->get_geometry(); diff --git a/src/api/wayfire/view-transform.hpp b/src/api/wayfire/view-transform.hpp index a5d3128cb..fef31cafd 100644 --- a/src/api/wayfire/view-transform.hpp +++ b/src/api/wayfire/view-transform.hpp @@ -61,8 +61,14 @@ class transformer_base_node_t : public scene::floating_inner_node_t // children's current content. wf::region_t cached_damage; + /** + * @param output Optional. When provided, the inner buffer will be allocated with + * the @hdr_linear hint if the output is currently in an HDR (PQ) configuration, + * so HDR contents above SDR reference white aren't clipped or banded by an + * 8-bit linear backing. + */ std::shared_ptr get_updated_contents(const wf::geometry_t& bbox, float scale, - std::vector& children); + std::vector& children, wf::output_t *output = nullptr); void release_buffers(); ~transformer_base_node_t(); @@ -134,7 +140,7 @@ class transformer_render_instance_t : public render_instance_t return tex; } - return self->get_updated_contents(self->get_children_bounding_box(), scale, children); + return self->get_updated_contents(self->get_children_bounding_box(), scale, children, _shown_on); } void presentation_feedback(wf::output_t *output) override diff --git a/src/view/view-3d.cpp b/src/view/view-3d.cpp index 14556117b..db6b6f99f 100644 --- a/src/view/view-3d.cpp +++ b/src/view/view-3d.cpp @@ -619,9 +619,17 @@ uint32_t transformer_base_node_t::optimize_update(uint32_t flags) } std::shared_ptr transformer_base_node_t::get_updated_contents(const wf::geometry_t& bbox, - float scale, std::vector& children) + float scale, std::vector& children, wf::output_t *output) { - if (inner_content.allocate(wf::dimensions(bbox), scale) != buffer_reallocation_result_t::SAME) + // The inner buffer is a linear-space composite of the children (target_tf == EXT_LINEAR). + // On HDR (PQ) outputs, HDR sources contribute SDR-relative linear values up to ~49.26 + // (PQ peak / SDR reference white) which would be clipped by an 8-bit linear backing. + const auto *img_desc = output ? output->handle->image_description : nullptr; + const bool is_hdr = img_desc && + img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; + + if (inner_content.allocate(wf::dimensions(bbox), scale, + wf::buffer_allocation_hints_t{.hdr_linear = is_hdr}) != buffer_reallocation_result_t::SAME) { cached_damage |= bbox; } diff --git a/src/view/view.cpp b/src/view/view.cpp index fb71dd119..8852a0e2d 100644 --- a/src/view/view.cpp +++ b/src/view/view.cpp @@ -96,7 +96,13 @@ void wf::view_interface_t::take_snapshot(wf::auxilliary_buffer_t& buffer) auto root_node = get_surface_root_node(); const wf::geometry_t bbox = root_node->get_bounding_box(); float scale = get_output()->handle->scale; - buffer.allocate(wf::dimensions(bbox), scale); + // On HDR outputs the snapshot is a linear-space composite that may receive PQ content scaled + // up by ~49.26x (SDR-relative linear). Use FP16 storage so HDR highlights aren't clipped. + const auto *img_desc = get_output()->handle->image_description; + const bool is_hdr = img_desc && + img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; + buffer.allocate(wf::dimensions(bbox), scale, + wf::buffer_allocation_hints_t{.hdr_linear = is_hdr}); wf::render_target_t target{buffer}; target.geometry = bbox; From 4d5ba8d202bad7bbdb6fa5dbc3357b8299968532 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Sat, 9 May 2026 19:02:59 -0700 Subject: [PATCH 11/15] wlr-surface-node: skip direct scanout when buffer doesn't match output Direct scanout commits the surface buffer straight to the primary plane via wlr_output_state_set_buffer, which leaves buffer_src_box / buffer_dst_box unset and lets wlroots default both to the buffer's pixel dimensions. On surfaces where the dimensions do not match the output exactly, some drivers may malfunction and ignore the position and/or dimensions of the direct scanout operation. Fall back to compositing for these surfaces so the renderer can sample the buffer correctly. --- src/view/wlr-surface-node.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/view/wlr-surface-node.cpp b/src/view/wlr-surface-node.cpp index 30b79dd58..07d3e54d8 100644 --- a/src/view/wlr-surface-node.cpp +++ b/src/view/wlr-surface-node.cpp @@ -406,6 +406,13 @@ class wf::scene::wlr_surface_node_t::wlr_surface_render_instance_t : public rend return direct_scanout::OCCLUSION; } + // Resolution should match the output, otherwise funny things happen + if ((wlr_surf->current.width != output->handle->width) || + (wlr_surf->current.height != output->handle->height)) + { + return direct_scanout::OCCLUSION; + } + // Finally, the opaque region must be the full surface. wf::region_t non_opaque = output->get_relative_geometry(); non_opaque ^= wf::region_t{&wlr_surf->opaque_region}; From 6d48db76c9bc2a82e32daf497cc51bf5f43fb2fd Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Sun, 10 May 2026 05:01:05 -0700 Subject: [PATCH 12/15] output-layout: force reconfiguration when toggling HDR wlr_output_state_set_image_description() neither sets allow_reconfiguration nor triggers wlroots' empty-buffer allocation path, so HDR toggles produced atomic commits with no fresh primary buffer and no DRM_MODE_ATOMIC_ALLOW_MODESET flag. amdgpu rejected those, leaving HDR_OUTPUT_METADATA unapplied. Re-pin the current render format and set allow_reconfiguration on the pending state so wlroots attaches a new primary FB and the DRM backend issues a modeset commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/output-layout.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/core/output-layout.cpp b/src/core/output-layout.cpp index ea5694f18..e04746f08 100644 --- a/src/core/output-layout.cpp +++ b/src/core/output-layout.cpp @@ -873,7 +873,6 @@ struct output_layout_output_t { LOGC(OUTPUT, "Disabling HDR on output ", handle->name); wlr_output_state_set_image_description(&pending_state.pending, NULL); - pending_state.commit(handle); } else { LOGC(OUTPUT, "Enabling HDR on output ", handle->name); @@ -888,6 +887,15 @@ struct output_layout_output_t wlr_output_state_set_image_description(&pending_state.pending, &image_desc); } + // wlroots' set_image_description doesn't trigger empty-buffer allocation or set + // allow_reconfiguration, so re-pin the current render format and request a + // reconfiguration. That forces wlroots to attach a fresh primary buffer and the + // DRM backend to set DRM_MODE_ATOMIC_ALLOW_MODESET, which amdgpu requires for + // HDR_OUTPUT_METADATA changes. + wlr_output_state_set_render_format(&pending_state.pending, handle->render_format); + pending_state.pending.allow_reconfiguration = true; + pending_state.commit(handle); + current_hdr_enabled = want_hdr_enabled; } From dfb67a12bb5cb179ed40a9bcd97719c0030f8e6e Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Sat, 9 May 2026 19:13:31 -0700 Subject: [PATCH 13/15] render-manager: convert sRGB-primaries blend image to output primaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wlroots Vulkan two-pass renderer composites every add_texture into an FP16 blend image with sRGB primaries (it builds a sRGB-target absolute-colorimetric matrix per source primaries in pass.c). Wayfire's output color_transform was a pure linear→inverse-EOTF transform, which the second pass applies with an identity color matrix — leaving the final output buffer with sRGB primaries values that an HDR display configured for Rec.2020 then over-saturates. Build a 2-stage [matrix(sRGB→output_primaries), inverse_eotf] pipeline when the output's committed image description advertises non-sRGB primaries (e.g. BT.2020 on HDR), and fall back to the bare inverse_eotf otherwise. The wlroots Vulkan renderer accepts that pipeline shape via unwrap_color_transform's COLOR_TRANSFORM_PIPELINE branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/output/render-manager.cpp | 80 +++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/src/output/render-manager.cpp b/src/output/render-manager.cpp index 5ae56e111..fc7d52dff 100644 --- a/src/output/render-manager.cpp +++ b/src/output/render-manager.cpp @@ -812,11 +812,17 @@ class wf::render_manager::impl wf::option_wrapper_t hdr; /** - * The inverse-EOTF transform that matches the output's currently-committed image description. - * Cached so that it is not recreated each frame. + * The output color transform that matches the output's currently-committed image description. + * For non-sRGB output primaries, this is a pipeline of [sRGB→output-primaries matrix, + * inverse-EOTF]; otherwise it is just the inverse-EOTF. The wlroots Vulkan two-pass renderer + * composites every add_texture into an sRGB-primaries FP16 blend image, so without the + * primaries-conversion stage Rec.2020/PQ outputs would have sRGB-primaries values encoded + * via PQ — which the display interprets as Rec.2020 primaries, producing oversaturated + * colors. Cached so that it is not recreated each frame. */ wlr_color_transform *output_inverse_eotf = nullptr; wlr_color_transfer_function output_inverse_eotf_tf = (wlr_color_transfer_function)0; + wlr_color_named_primaries output_inverse_eotf_primaries = (wlr_color_named_primaries)0; /** * The transfer function the output expects in its committed image description, or sRGB if no @@ -832,10 +838,26 @@ class wf::render_manager::impl return WLR_COLOR_TRANSFER_FUNCTION_SRGB; } + /** + * The primaries the output expects in its committed image description, or sRGB if no + * image description has been set. + */ + wlr_color_named_primaries get_output_primaries() + { + if (output->handle->image_description && output->handle->image_description->primaries) + { + return output->handle->image_description->primaries; + } + + return WLR_COLOR_NAMED_PRIMARIES_SRGB; + } + wlr_color_transform *get_output_inverse_eotf() { wlr_color_transfer_function tf = get_output_transfer_function(); - if (output_inverse_eotf && (output_inverse_eotf_tf == tf)) + wlr_color_named_primaries prim = get_output_primaries(); + if (output_inverse_eotf && (output_inverse_eotf_tf == tf) && + (output_inverse_eotf_primaries == prim)) { return output_inverse_eotf; } @@ -845,14 +867,55 @@ class wf::render_manager::impl wlr_color_transform_unref(output_inverse_eotf); } - output_inverse_eotf = wlr_color_transform_init_linear_to_inverse_eotf(tf); - output_inverse_eotf_tf = tf; - if (!output_inverse_eotf) + wlr_color_transform *eotf = wlr_color_transform_init_linear_to_inverse_eotf(tf); + if (!eotf) { LOGE("Failed to create inverse-EOTF transform for output ", output->to_string(), " (transfer function ", (int)tf, ")"); + output_inverse_eotf = nullptr; + output_inverse_eotf_tf = tf; + output_inverse_eotf_primaries = prim; + return nullptr; } + if (prim == WLR_COLOR_NAMED_PRIMARIES_SRGB) + { + output_inverse_eotf = eotf; + output_inverse_eotf_tf = tf; + output_inverse_eotf_primaries = prim; + return output_inverse_eotf; + } + + wlr_color_primaries srgb_primaries{}; + wlr_color_primaries dst_primaries{}; + wlr_color_primaries_from_named(&srgb_primaries, WLR_COLOR_NAMED_PRIMARIES_SRGB); + wlr_color_primaries_from_named(&dst_primaries, prim); + float matrix[9]; + wlr_color_primaries_transform_absolute_colorimetric(&srgb_primaries, &dst_primaries, matrix); + wlr_color_transform *mat = wlr_color_transform_init_matrix(matrix); + if (!mat) + { + LOGE("Failed to create primaries-conversion matrix transform for output ", + output->to_string()); + output_inverse_eotf = eotf; + output_inverse_eotf_tf = tf; + output_inverse_eotf_primaries = prim; + return output_inverse_eotf; + } + + wlr_color_transform *stages[2] = {mat, eotf}; + wlr_color_transform *pipeline = wlr_color_transform_init_pipeline(stages, 2); + // init_pipeline references the stages; drop our own refs. + wlr_color_transform_unref(mat); + wlr_color_transform_unref(eotf); + if (!pipeline) + { + LOGE("Failed to create color-transform pipeline for output ", output->to_string()); + } + + output_inverse_eotf = pipeline; + output_inverse_eotf_tf = tf; + output_inverse_eotf_primaries = prim; return output_inverse_eotf; } @@ -926,14 +989,15 @@ class wf::render_manager::impl hdr.load_option(section, "hdr"); hdr.set_callback([=] () { - // Drop the cached inverse-EOTF: by the time the next frame is rendered, the - // output's image_description will have been re-committed by output-layout, and + // Drop the cached output color transform: by the time the next frame is rendered, + // the output's image_description will have been re-committed by output-layout, and // get_output_inverse_eotf() will lazily regenerate the transform to match. if (output_inverse_eotf) { wlr_color_transform_unref(output_inverse_eotf); output_inverse_eotf = nullptr; output_inverse_eotf_tf = (wlr_color_transfer_function)0; + output_inverse_eotf_primaries = (wlr_color_named_primaries)0; } damage_manager->damage_whole_idle(); From 9d91ac6c7db1956cefc8833030e3642d27f93c4f Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Thu, 14 May 2026 04:47:34 -0700 Subject: [PATCH 14/15] render: consolidate HDR-output detection into wf::output_t::is_hdr() Plugins computing the FP16-backing hint for linear aux buffers each inlined the same image-description / PQ-transfer check with a different verbose comment. Move the check to a single helper in render.hpp and keep the rationale comment there. v2: Amended to move the checker function to the right place. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/common/workspace-wall.cpp | 9 +-------- plugins/cube/cube.cpp | 8 +------- plugins/grid/wayfire/plugins/crossfade.hpp | 8 +------- src/api/wayfire/output.hpp | 12 ++++++++++++ src/api/wayfire/render.hpp | 1 + src/output/output.cpp | 11 +++++++++++ src/render.cpp | 1 + src/view/view-3d.cpp | 10 ++-------- src/view/view.cpp | 7 +------ 9 files changed, 31 insertions(+), 36 deletions(-) diff --git a/plugins/common/workspace-wall.cpp b/plugins/common/workspace-wall.cpp index ff27bb893..3343b3c0c 100644 --- a/plugins/common/workspace-wall.cpp +++ b/plugins/common/workspace-wall.cpp @@ -259,17 +259,10 @@ class workspace_wall_t::workspace_wall_node_t : public scene::node_t auto bbox = workspaces[i][j]->get_bounding_box(); - // The aux buffer stores a linear-space scene composite (target_tf == EXT_LINEAR). - // When the output is HDR (PQ), HDR sources contribute SDR-relative linear values up to - // ~49.26 (PQ peak / SDR reference white), which would clip in an 8-bit linear buffer. - const auto *img_desc = wall->output->handle->image_description; - const bool is_hdr = img_desc && - img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; - aux_buffers[i][j].allocate(wf::dimensions(bbox), wall->output->handle->scale, wf::buffer_allocation_hints_t{ .needs_alpha = false, - .hdr_linear = is_hdr, + .hdr_linear = wall->output && wall->output->is_hdr(), }); aux_buffer_damage[i][j] |= bbox; aux_buffer_current_scale[i][j] = 1.0; diff --git a/plugins/cube/cube.cpp b/plugins/cube/cube.cpp index 1557602b1..ae0cd7397 100644 --- a/plugins/cube/cube.cpp +++ b/plugins/cube/cube.cpp @@ -98,13 +98,7 @@ class wayfire_cube : public wf::per_output_plugin_instance_t, public wf::pointer damage ^= bbox; - // Per-workspace aux buffers are linear scene composites (target_tf == EXT_LINEAR). - // On HDR (PQ) outputs, HDR sources land in the SDR-relative linear domain at values - // up to ~49.26 (PQ peak / SDR reference white) and need an FP16 backing to avoid - // clipping. - const auto *img_desc = self->cube->output->handle->image_description; - const bool is_hdr = img_desc && - img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; + const bool is_hdr = self->cube->output && self->cube->output->is_hdr(); for (int i = 0; i < (int)ws_instances.size(); i++) { diff --git a/plugins/grid/wayfire/plugins/crossfade.hpp b/plugins/grid/wayfire/plugins/crossfade.hpp index c237b0730..0b629bd97 100644 --- a/plugins/grid/wayfire/plugins/crossfade.hpp +++ b/plugins/grid/wayfire/plugins/crossfade.hpp @@ -50,14 +50,8 @@ class crossfade_node_t : public scene::view_2d_transformer_t const wf::geometry_t bbox = root_node->get_bounding_box(); const wf::geometry_t g = view->get_geometry(); const float scale = view->get_output()->handle->scale; - // The original-contents buffer is a linear-space view snapshot. On HDR outputs the view's - // PQ contents are bridged to SDR-relative linear by the per-source luminance multiplier, - // landing at values up to ~49.26 — request FP16 storage so they aren't clipped. - const auto *img_desc = view->get_output()->handle->image_description; - const bool is_hdr = img_desc && - img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; original_buffer.allocate(wf::dimensions(g), scale, - wf::buffer_allocation_hints_t{.hdr_linear = is_hdr}); + wf::buffer_allocation_hints_t{.hdr_linear = view->get_output() && view->get_output()->is_hdr()}); wf::render_target_t target{original_buffer}; target.geometry = view->get_geometry(); diff --git a/src/api/wayfire/output.hpp b/src/api/wayfire/output.hpp index 84ad634c2..893f4f1b6 100644 --- a/src/api/wayfire/output.hpp +++ b/src/api/wayfire/output.hpp @@ -203,6 +203,18 @@ class output_t : public wf::object_base_t, public wf::signal::provider_t virtual void add_axis(option_sptr_t axis, wf::axis_callback*) = 0; virtual void add_button(option_sptr_t button, wf::button_callback*) = 0; virtual void add_activator(option_sptr_t activator, wf::activator_callback*) = 0; + /** + * Check whether the output is currently driving an HDR (ST2084 PQ) image description. + * + * Plugins keep their linear-space auxilliary buffers in the SDR-relative linear domain + * (target_tf == EXT_LINEAR). On HDR (PQ) outputs, HDR sources contribute SDR-relative linear + * values up to ~49.26 (PQ peak / SDR reference white), which would be clipped or quantized by + * an 8-bit linear backing. Plugins use this hint to request FP16 storage + * (@buffer_allocation_hints_t::hdr_linear) for such intermediates. + * + * Returns false when no image description has been negotiated. + */ + bool is_hdr() const; /** * Remove all bindings which have the given callback, regardless of the type. diff --git a/src/api/wayfire/render.hpp b/src/api/wayfire/render.hpp index ef9e632bc..36c7f9d2a 100644 --- a/src/api/wayfire/render.hpp +++ b/src/api/wayfire/render.hpp @@ -19,6 +19,7 @@ namespace wf { class output_t; struct auxilliary_buffer_t; + namespace vk { class command_buffer_t; diff --git a/src/output/output.cpp b/src/output/output.cpp index ec97c8945..06a82314a 100644 --- a/src/output/output.cpp +++ b/src/output/output.cpp @@ -441,6 +441,17 @@ void wf::output_impl_t::rem_binding(void *callback) remove_binding(activator_map, (activator_callback*)callback); } +static bool is_hdr_transfer_function(wlr_color_transfer_function tf) +{ + return tf == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; +} + +bool wf::output_t::is_hdr() const +{ + const auto *img_desc = this->handle->image_description; + return img_desc && is_hdr_transfer_function(img_desc->transfer_function); +} + wayfire_view get_active_view_for_output(wf::output_t *output) { if (output == wf::get_core().seat->get_active_output()) diff --git a/src/render.cpp b/src/render.cpp index 0c37e75bf..6e1d4a06f 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -3,6 +3,7 @@ #include "wayfire/dassert.hpp" #include "wayfire/nonstd/reverse.hpp" #include "wayfire/opengl.hpp" +#include "wayfire/output.hpp" #include #include #include diff --git a/src/view/view-3d.cpp b/src/view/view-3d.cpp index db6b6f99f..197caac70 100644 --- a/src/view/view-3d.cpp +++ b/src/view/view-3d.cpp @@ -621,15 +621,9 @@ uint32_t transformer_base_node_t::optimize_update(uint32_t flags) std::shared_ptr transformer_base_node_t::get_updated_contents(const wf::geometry_t& bbox, float scale, std::vector& children, wf::output_t *output) { - // The inner buffer is a linear-space composite of the children (target_tf == EXT_LINEAR). - // On HDR (PQ) outputs, HDR sources contribute SDR-relative linear values up to ~49.26 - // (PQ peak / SDR reference white) which would be clipped by an 8-bit linear backing. - const auto *img_desc = output ? output->handle->image_description : nullptr; - const bool is_hdr = img_desc && - img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; - if (inner_content.allocate(wf::dimensions(bbox), scale, - wf::buffer_allocation_hints_t{.hdr_linear = is_hdr}) != buffer_reallocation_result_t::SAME) + wf::buffer_allocation_hints_t{.hdr_linear = output && output->is_hdr()}) != + buffer_reallocation_result_t::SAME) { cached_damage |= bbox; } diff --git a/src/view/view.cpp b/src/view/view.cpp index 8852a0e2d..5ca93f730 100644 --- a/src/view/view.cpp +++ b/src/view/view.cpp @@ -96,13 +96,8 @@ void wf::view_interface_t::take_snapshot(wf::auxilliary_buffer_t& buffer) auto root_node = get_surface_root_node(); const wf::geometry_t bbox = root_node->get_bounding_box(); float scale = get_output()->handle->scale; - // On HDR outputs the snapshot is a linear-space composite that may receive PQ content scaled - // up by ~49.26x (SDR-relative linear). Use FP16 storage so HDR highlights aren't clipped. - const auto *img_desc = get_output()->handle->image_description; - const bool is_hdr = img_desc && - img_desc->transfer_function == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; buffer.allocate(wf::dimensions(bbox), scale, - wf::buffer_allocation_hints_t{.hdr_linear = is_hdr}); + wf::buffer_allocation_hints_t{.hdr_linear = get_output() && get_output()->is_hdr()}); wf::render_target_t target{buffer}; target.geometry = bbox; From 4cb033c1720076e71a7b6720aea2ffc733daf2ef Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Sun, 24 May 2026 01:12:41 -0700 Subject: [PATCH 15/15] render-manager: composite software cursors as sRGB content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wlr_output_add_software_cursors_to_render_pass submits each cursor via wlr_render_pass_add_texture with only texture / src_box / dst_box / clip / transform set — no .primaries, .transfer_function, or .luminance_multiplier. wlroots therefore treats cursor pixels as already in the output's color space. On PQ outputs the sRGB cursor values are encoded as ~10000 cd/m² absolute Rec.2020, producing dazzlingly bright and oversaturated cursors. Open-code the cursor iteration so each cursor can carry the same sRGB- primaries + gamma2.2 tagging wlr_surface_node applies to regular surfaces, plus the SDR→PQ luminance multiplier from compute_luminance_multiplier. SDR outputs are unaffected: the multiplier resolves to 1.0 and the primaries conversion is identity. The cursor box is built in scaled fb-pixel coords and run through wlr_box_transform exactly as wlroots' helper does, avoiding the floor/ceil rounding errors that a logical-coord roundtrip would introduce on fractionally-scaled outputs. compute_luminance_multiplier is promoted from a static helper in render.cpp to wf:: so render-manager can call it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/api/wayfire/render.hpp | 10 +++++ src/output/render-manager.cpp | 76 ++++++++++++++++++++++++++++++++--- src/render.cpp | 10 +---- 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/api/wayfire/render.hpp b/src/api/wayfire/render.hpp index 36c7f9d2a..e14b87b73 100644 --- a/src/api/wayfire/render.hpp +++ b/src/api/wayfire/render.hpp @@ -70,6 +70,16 @@ struct color_transform_t bool operator !=(const color_transform_t& other) const; }; +/** + * Compute the luminance multiplier needed when a texture with @source_tf is rendered to a + * target with @target_tf. The wlroots renderer does no implicit luminance scaling between + * SDR and HDR domains, so SDR content composited onto a PQ output (or vice-versa) needs + * an explicit factor to bridge the [0,1]-relative SDR linear range and the 0–10000 cd/m² + * absolute PQ linear range. Returns 1.0f when no bridging is needed. + */ +float compute_luminance_multiplier(wlr_color_transfer_function source_tf, + wlr_color_transfer_function target_tf); + /** * A wrapper around wlr_texture which ensures that the texture is kept alive as long as the wrapper object * is alive. diff --git a/src/output/render-manager.cpp b/src/output/render-manager.cpp index fc7d52dff..e64d8d6ce 100644 --- a/src/output/render-manager.cpp +++ b/src/output/render-manager.cpp @@ -1263,18 +1263,82 @@ class wf::render_manager::impl void render_sw_cursors(swapchain_damage_manager_t::frame_object_t *next_frame) { - wlr_buffer_pass_options options{}; - options.color_transform = get_color_transform(); - auto sw_cursor_pass = - wlr_renderer_begin_buffer_pass(output->handle->renderer, next_frame->buffer, &options); + if (swap_damage.empty()) + { + return; + } + + wlr_buffer_pass_options pass_options{}; + pass_options.color_transform = get_color_transform(); + auto *sw_cursor_pass = wlr_renderer_begin_buffer_pass( + output->handle->renderer, next_frame->buffer, &pass_options); if (!sw_cursor_pass) { LOGE("Failed to render software cursors!"); return; } - wlr_output_add_software_cursors_to_render_pass(output->handle, - sw_cursor_pass, swap_damage.to_pixman()); + const auto output_tf = get_output_transfer_function(); + const float luminance_multiplier = wf::compute_luminance_multiplier( + WLR_COLOR_TRANSFER_FUNCTION_GAMMA22, output_tf); + + wlr_color_primaries srgb_primaries{}; + wlr_color_primaries_from_named(&srgb_primaries, WLR_COLOR_NAMED_PRIMARIES_SRGB); + + int transformed_width, transformed_height; + wlr_output_transformed_resolution(output->handle, &transformed_width, &transformed_height); + + wlr_output_cursor *cursor; + wl_list_for_each(cursor, &output->handle->cursors, link) + { + if (!cursor->enabled || !cursor->visible || + output->handle->hardware_cursor == cursor || !cursor->texture) + { + continue; + } + + // wlr_output_cursor stores x/y/width/height/hotspot in scaled + // buffer-pixel units, pre-output-transform (see wlr_output_cursor_move + // and wlr_output_cursor_set_buffer in wlroots). Mirror the wlroots + // helper: build the integer fb-coord box, then apply the inverse + // output transform so the final dst_box is in framebuffer pixels. + wlr_box box{ + static_cast(cursor->x - cursor->hotspot_x), + static_cast(cursor->y - cursor->hotspot_y), + static_cast(cursor->width), + static_cast(cursor->height), + }; + wlr_box_transform(&box, &box, + wlr_output_transform_invert(output->handle->transform), + transformed_width, transformed_height); + + wf::region_t cursor_damage{box}; + cursor_damage &= swap_damage; + if (cursor_damage.empty()) + { + continue; + } + + // Tag the cursor as sRGB-primaries / gamma2.2 — same treatment + // wlr_surface_node gives regular surfaces — so the wlroots renderer + // applies the primaries conversion and SDR→PQ luminance multiplier + // needed for correct compositing on HDR (PQ) outputs. + wlr_render_texture_options opts{}; + opts.texture = cursor->texture; + opts.src_box = cursor->src_box; + opts.dst_box = box; + opts.clip = cursor_damage.to_pixman(); + opts.transform = output->handle->transform; + opts.transfer_function = WLR_COLOR_TRANSFER_FUNCTION_GAMMA22; + opts.primaries = &srgb_primaries; + if (luminance_multiplier != 1.0f) + { + opts.luminance_multiplier = &luminance_multiplier; + } + + wlr_render_pass_add_texture(sw_cursor_pass, &opts); + } + wlr_render_pass_submit(sw_cursor_pass); } diff --git a/src/render.cpp b/src/render.cpp index 6e1d4a06f..d5ad0d2b1 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -21,13 +21,7 @@ static bool is_hdr_transfer_function(wlr_color_transfer_function tf) return tf == WLR_COLOR_TRANSFER_FUNCTION_ST2084_PQ; } -/** - * Compute the luminance multiplier needed when a texture with @source_tf is rendered to a target - * with @target_tf. The wlroots renderer does no implicit luminance scaling between SDR and HDR - * domains, so SDR content composited onto a PQ output (or vice-versa) needs an explicit factor - * to bridge the [0,1]-relative SDR linear range and the 0–10000 cd/m² absolute PQ linear range. - */ -static float compute_luminance_multiplier(wlr_color_transfer_function source_tf, +float wf::compute_luminance_multiplier(wlr_color_transfer_function source_tf, wlr_color_transfer_function target_tf) { const bool source_pq = is_hdr_transfer_function(source_tf); @@ -814,7 +808,7 @@ void wf::render_pass_t::add_texture(const std::shared_ptr& textur // for ST2084 PQ interprets [0,1] as 0–10000 cd/m² absolute. Without correction, SDR content // composited on an HDR output would appear ~100× too bright. Compute a multiplier that brings // the per-texture linear values into the target's expected absolute domain. - const float luminance_multiplier = compute_luminance_multiplier( + const float luminance_multiplier = wf::compute_luminance_multiplier( ct.transfer_function, adjusted_target.get_output_transfer_function()); if (luminance_multiplier != 1.0f) {