diff --git a/plugins/common/workspace-wall.cpp b/plugins/common/workspace-wall.cpp index b3f583c6e..3343b3c0c 100644 --- a/plugins/common/workspace-wall.cpp +++ b/plugins/common/workspace-wall.cpp @@ -262,6 +262,7 @@ class workspace_wall_t::workspace_wall_node_t : public scene::node_t aux_buffers[i][j].allocate(wf::dimensions(bbox), wall->output->handle->scale, wf::buffer_allocation_hints_t{ .needs_alpha = false, + .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 8765e5c94..ae0cd7397 100644 --- a/plugins/cube/cube.cpp +++ b/plugins/cube/cube.cpp @@ -98,11 +98,14 @@ class wayfire_cube : public wf::per_output_plugin_instance_t, public wf::pointer damage ^= bbox; + const bool is_hdr = self->cube->output && self->cube->output->is_hdr(); + 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..0b629bd97 100644 --- a/plugins/grid/wayfire/plugins/crossfade.hpp +++ b/plugins/grid/wayfire/plugins/crossfade.hpp @@ -50,7 +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; - original_buffer.allocate(wf::dimensions(g), scale); + original_buffer.allocate(wf::dimensions(g), scale, + 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/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/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/plugin.hpp b/src/api/wayfire/plugin.hpp index 34adb607a..040334b86 100644 --- a/src/api/wayfire/plugin.hpp +++ b/src/api/wayfire/plugin.hpp @@ -107,7 +107,7 @@ using wayfire_plugin_load_func = wf::plugin_interface_t * (*)(); /** * The version is defined as macro as well, to allow conditional compilation. */ -#define WAYFIRE_API_ABI_VERSION_MACRO 2026'04'17 +#define WAYFIRE_API_ABI_VERSION_MACRO 2026'05'24 /** * The version of Wayfire's API/ABI diff --git a/src/api/wayfire/render.hpp b/src/api/wayfire/render.hpp index 002e830b3..e14b87b73 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; @@ -52,10 +53,33 @@ 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; }; +/** + * 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. @@ -237,6 +261,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; }; /** @@ -424,11 +455,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); + 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. + */ + 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/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/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/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(); 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; } 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/output/render-manager.cpp b/src/output/render-manager.cpp index ca77d68da..9713b7d8d 100644 --- a/src/output/render-manager.cpp +++ b/src/output/render-manager.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -795,6 +796,27 @@ struct repaint_delay_manager_t class wf::render_manager::impl { public: + struct color_transform_deleter_t + { + void operator ()(wlr_color_transform *transform) const + { + if (transform) + { + wlr_color_transform_unref(transform); + } + } + }; + + using color_transform_ptr = + std::unique_ptr; + + struct output_inverse_eotf_cache_t + { + color_transform_ptr transform; + wlr_color_transfer_function tf; + wlr_color_named_primaries primaries; + }; + wf::wl_listener_wrapper on_frame; wf::wl_timer repaint_timer; @@ -809,17 +831,112 @@ 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 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. + */ + std::optional output_inverse_eotf_cache; + color_transform_ptr icc_color_transform; + + /** + * 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; + } + + /** + * 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(); + wlr_color_named_primaries prim = get_output_primaries(); + if (output_inverse_eotf_cache && (output_inverse_eotf_cache->tf == tf) && + (output_inverse_eotf_cache->primaries == prim)) + { + return output_inverse_eotf_cache->transform.get(); + } + + output_inverse_eotf_cache.reset(); + + 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, ")"); + return nullptr; + } + + if (prim == WLR_COLOR_NAMED_PRIMARIES_SRGB) + { + output_inverse_eotf_cache = output_inverse_eotf_cache_t{color_transform_ptr{eotf}, tf, prim}; + return output_inverse_eotf_cache->transform.get(); + } + + 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_cache = output_inverse_eotf_cache_t{color_transform_ptr{eotf}, tf, prim}; + return output_inverse_eotf_cache->transform.get(); + } + + 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()); + return nullptr; + } + + output_inverse_eotf_cache = output_inverse_eotf_cache_t{color_transform_ptr{pipeline}, tf, prim}; + return output_inverse_eotf_cache->transform.get(); + } wlr_color_transform *get_color_transform() { if (icc_color_transform) { - return icc_color_transform; + return icc_color_transform.get(); } - 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()) @@ -879,11 +996,20 @@ class wf::render_manager::impl reload_icc_profile(); damage_manager->damage_whole_idle(); }); + hdr.load_option(section, "hdr"); + hdr.set_callback([=] () + { + // 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. + output_inverse_eotf_cache.reset(); + + damage_manager->damage_whole_idle(); + }); reload_icc_profile(); } - wlr_color_transform *icc_color_transform = NULL; wlr_buffer_pass_options pass_opts{}; void reload_icc_profile() @@ -925,17 +1051,13 @@ class wf::render_manager::impl void set_icc_transform(wlr_color_transform *transform) { - if (icc_color_transform) - { - wlr_color_transform_unref(icc_color_transform); - } - - icc_color_transform = transform; + icc_color_transform.reset(transform); } ~impl() { set_icc_transform(nullptr); + output_inverse_eotf_cache.reset(); } const bool env_allow_scanout; @@ -1012,6 +1134,9 @@ class wf::render_manager::impl params.target = postprocessing->get_target_framebuffer().translated( wf::origin(output->get_layout_geometry())); + 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; @@ -1019,8 +1144,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); @@ -1132,18 +1257,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 20b47471f..d5ad0d2b1 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -3,15 +3,87 @@ #include "wayfire/dassert.hpp" #include "wayfire/nonstd/reverse.hpp" #include "wayfire/opengl.hpp" +#include "wayfire/output.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; +} + +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); + 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 && 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 @@ -150,6 +222,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, @@ -164,6 +248,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) { @@ -391,7 +488,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) @@ -401,6 +499,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) @@ -704,6 +803,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 = wf::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); } @@ -717,12 +828,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)); @@ -853,7 +959,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) { @@ -866,4 +973,5 @@ void wf::render_target_t::set_color_transform(wlr_color_transform *transform) } inverse_eotf = transform; + output_transfer_function = target_tf; } diff --git a/src/view/view-3d.cpp b/src/view/view-3d.cpp index 14556117b..197caac70 100644 --- a/src/view/view-3d.cpp +++ b/src/view/view-3d.cpp @@ -619,9 +619,11 @@ 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) + if (inner_content.allocate(wf::dimensions(bbox), scale, + 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 fb71dd119..5ca93f730 100644 --- a/src/view/view.cpp +++ b/src/view/view.cpp @@ -96,7 +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; - buffer.allocate(wf::dimensions(bbox), scale); + buffer.allocate(wf::dimensions(bbox), scale, + wf::buffer_allocation_hints_t{.hdr_linear = get_output() && get_output()->is_hdr()}); wf::render_target_t target{buffer}; target.geometry = bbox; diff --git a/src/view/wlr-surface-node.cpp b/src/view/wlr-surface-node.cpp index d8487e43b..600c97cdb 100644 --- a/src/view/wlr-surface-node.cpp +++ b/src/view/wlr-surface-node.cpp @@ -74,22 +74,37 @@ 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 = 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 +116,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) @@ -385,6 +406,15 @@ 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 + int width, height; + wlr_output_transformed_resolution(output->handle, &width, &height); + if ((wlr_surf->current.buffer_width != width) || + (wlr_surf->current.buffer_height != 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}; @@ -526,7 +556,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); +}