UI/Qt: Present macOS frames with QRhiWidget

Use QRhiWidget for the Qt web content view on macOS and render the
current IOSurface-backed shared image into the widget's Metal render
target. This keeps normal presentation on the GPU instead of painting a
QImage wrapper over the shared bitmap.

Force Qt Widgets' RHI backing store to Metal before QApplication is
created so QRhiWidget can obtain the top-level QRhi even when the native
window is created before the web content widget enters the hierarchy.
Other platforms keep the existing QWidget and QPainter path.
This commit is contained in:
Andreas Kling 2026-06-01 02:29:15 +02:00 committed by Andreas Kling
parent 044fc0c519
commit 7cc81327a1
5 changed files with 371 additions and 24 deletions

View file

@ -3,6 +3,13 @@ set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Widgets)
if (APPLE)
# The macOS QRhiWidget presentation path uses QRhi's private Metal native
# handle types to import IOSurfaces without CPU readback.
set(QT_NO_PRIVATE_MODULE_WARNING ON)
find_package(Qt6 REQUIRED COMPONENTS GuiPrivate)
endif()
qt_add_executable(ladybird main.cpp)
target_sources(ladybird PRIVATE
Application.cpp
@ -27,8 +34,11 @@ target_sources(ladybird PRIVATE
target_link_libraries(ladybird PRIVATE Qt::Core Qt::Gui Qt::Widgets)
if (APPLE)
target_sources(ladybird PRIVATE MacWindow.mm)
target_link_libraries(ladybird PRIVATE "-framework Cocoa" "-framework QuartzCore")
target_sources(ladybird PRIVATE
MacWindow.mm
WebContentViewMac.mm
)
target_link_libraries(ladybird PRIVATE Qt::GuiPrivate "-framework Cocoa" "-framework IOSurface" "-framework Metal" "-framework QuartzCore")
endif()
create_ladybird_bundle(ladybird)

View file

@ -53,8 +53,12 @@ namespace Ladybird {
bool is_using_dark_system_theme(QWidget&);
WebContentView::WebContentView(QWidget* window, RefPtr<WebView::WebContentClient> parent_client, size_t page_index, WebContentViewInitialState initial_state)
: QWidget(window)
: WebContentViewBase(window)
{
#ifdef AK_OS_MACOS
setApi(QRhiWidget::Api::Metal);
#endif
m_client_state.client = parent_client;
m_client_state.page_index = page_index;
@ -186,7 +190,12 @@ WebContentView::WebContentView(QWidget* window, RefPtr<WebView::WebContentClient
};
}
WebContentView::~WebContentView() = default;
WebContentView::~WebContentView()
{
#ifdef AK_OS_MACOS
release_metal_resources();
#endif
}
void WebContentView::select_dropdown_action()
{
@ -463,14 +472,14 @@ void WebContentView::leaveEvent(QEvent* event)
{
if (is_node_picker_active()) {
clear_node_picker();
QWidget::leaveEvent(event);
WebContentViewBase::leaveEvent(event);
return;
}
static QMouseEvent mouse_event { QEvent::Type::Leave, {}, {}, Qt::MouseButton::NoButton, Qt::MouseButton::NoButton, Qt::KeyboardModifier::NoModifier };
enqueue_native_event(Web::MouseEvent::Type::MouseLeave, mouse_event);
QWidget::leaveEvent(event);
WebContentViewBase::leaveEvent(event);
}
void WebContentView::mouseMoveEvent(QMouseEvent* event)
@ -488,7 +497,7 @@ void WebContentView::mouseMoveEvent(QMouseEvent* event)
}
enqueue_native_event(Web::MouseEvent::Type::MouseMove, *event);
QWidget::mouseMoveEvent(event);
WebContentViewBase::mouseMoveEvent(event);
}
void WebContentView::mousePressEvent(QMouseEvent* event)
@ -616,21 +625,37 @@ void WebContentView::focusOutEvent(QFocusEvent*)
client().async_set_has_focus(m_client_state.page_index, false);
}
Optional<WebContentView::Paintable> WebContentView::current_paintable() const
{
Gfx::SharedImageBuffer const* shared_image_buffer = nullptr;
Gfx::IntSize bitmap_size;
if (m_client_state.has_usable_bitmap) {
VERIFY(m_client_state.front_bitmap.shared_image_buffer);
shared_image_buffer = m_client_state.front_bitmap.shared_image_buffer.ptr();
bitmap_size = m_client_state.front_bitmap.last_painted_size.to_type<int>();
} else if (m_backup_shared_image_buffer) {
shared_image_buffer = m_backup_shared_image_buffer.ptr();
bitmap_size = m_backup_bitmap_size.to_type<int>();
}
if (!shared_image_buffer)
return {};
return Paintable { shared_image_buffer, bitmap_size };
}
#ifndef AK_OS_MACOS
void WebContentView::paintEvent(QPaintEvent*)
{
QPainter painter(this);
painter.scale(1 / m_device_pixel_ratio, 1 / m_device_pixel_ratio);
auto paintable = current_paintable();
Gfx::Bitmap const* bitmap = nullptr;
Gfx::IntSize bitmap_size;
if (m_client_state.has_usable_bitmap) {
VERIFY(m_client_state.front_bitmap.shared_image_buffer);
bitmap = m_client_state.front_bitmap.shared_image_buffer->bitmap().ptr();
bitmap_size = m_client_state.front_bitmap.last_painted_size.to_type<int>();
} else if (m_backup_shared_image_buffer) {
bitmap = m_backup_shared_image_buffer->bitmap().ptr();
bitmap_size = m_backup_bitmap_size.to_type<int>();
if (paintable.has_value()) {
bitmap = paintable->shared_image_buffer->bitmap().ptr();
bitmap_size = paintable->bitmap_size;
}
if (bitmap) {
@ -652,10 +677,11 @@ void WebContentView::paintEvent(QPaintEvent*)
auto background_color = page_background_color();
painter.fillRect(QRect(0, 0, m_viewport_size.width(), m_viewport_size.height()), QColor(background_color.red(), background_color.green(), background_color.blue()));
}
#endif
void WebContentView::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
WebContentViewBase::resizeEvent(event);
update_viewport_size();
handle_resize();
}
@ -719,13 +745,13 @@ void WebContentView::update_zoom()
void WebContentView::showEvent(QShowEvent* event)
{
QWidget::showEvent(event);
WebContentViewBase::showEvent(event);
set_system_visibility_state(Web::HTML::VisibilityState::Visible);
}
void WebContentView::hideEvent(QHideEvent* event)
{
QWidget::hideEvent(event);
WebContentViewBase::hideEvent(event);
set_system_visibility_state(Web::HTML::VisibilityState::Hidden);
}
@ -926,7 +952,7 @@ bool WebContentView::event(QEvent* event)
update_palette();
update();
});
return QWidget::event(event);
return WebContentViewBase::event(event);
}
if (event->type() == QEvent::ShortcutOverride) {
@ -940,7 +966,7 @@ bool WebContentView::event(QEvent* event)
return true;
}
return QWidget::event(event);
return WebContentViewBase::event(event);
}
void WebContentView::enqueue_native_event(Web::MouseEvent::Type type, QSinglePointEvent const& event)
@ -1075,10 +1101,10 @@ void WebContentView::finish_handling_key_event(Web::KeyEvent const& key_event)
switch (key_event.type) {
case Web::KeyEvent::Type::KeyDown:
QWidget::keyPressEvent(&event);
WebContentViewBase::keyPressEvent(&event);
break;
case Web::KeyEvent::Type::KeyUp:
QWidget::keyReleaseEvent(&event);
WebContentViewBase::keyReleaseEvent(&event);
break;
}

View file

@ -21,27 +21,40 @@
#include <QMenu>
#include <QTimer>
#include <QUrl>
#include <QWidget>
#ifdef AK_OS_MACOS
# include <QRhiWidget>
#else
# include <QWidget>
#endif
class QKeyEvent;
class QSinglePointEvent;
namespace Ladybird {
#ifdef AK_OS_MACOS
using WebContentViewBase = QRhiWidget;
#else
using WebContentViewBase = QWidget;
#endif
struct WebContentViewInitialState {
double maximum_frames_per_second { 60.0 };
Optional<u64> display_id;
};
class WebContentView final
: public QWidget
: public WebContentViewBase
, public WebView::ViewImplementation {
Q_OBJECT
public:
WebContentView(QWidget* window, RefPtr<WebView::WebContentClient> parent_client = nullptr, size_t page_index = 0, WebContentViewInitialState initial_state = {});
virtual ~WebContentView() override;
#ifndef AK_OS_MACOS
virtual void paintEvent(QPaintEvent*) override;
#endif
virtual void resizeEvent(QResizeEvent*) override;
virtual void leaveEvent(QEvent* event) override;
virtual void mouseMoveEvent(QMouseEvent*) override;
@ -93,6 +106,20 @@ private:
virtual Gfx::IntPoint to_content_position(Gfx::IntPoint widget_position) const override;
virtual Gfx::IntPoint to_widget_position(Gfx::IntPoint content_position) const override;
#ifdef AK_OS_MACOS
// ^QRhiWidget
virtual void initialize(QRhiCommandBuffer*) override;
virtual void render(QRhiCommandBuffer*) override;
virtual void releaseResources() override;
#endif
struct Paintable {
Gfx::SharedImageBuffer const* shared_image_buffer { nullptr };
Gfx::IntSize bitmap_size;
};
Optional<Paintable> current_paintable() const;
void update_viewport_size();
void update_cursor(Gfx::Cursor cursor);
void update_compositor_display_metadata();
@ -120,6 +147,21 @@ private:
int m_click_count { 0 };
QMenu* m_select_dropdown { nullptr };
#ifdef AK_OS_MACOS
bool prepare_metal_renderer(unsigned long render_target_pixel_format);
bool update_imported_iosurface_texture(Gfx::SharedImageBuffer const&);
void release_metal_resources();
void release_imported_iosurface_texture();
void* m_metal_device { nullptr };
void* m_metal_library { nullptr };
void* m_metal_pipeline_state { nullptr };
void* m_metal_sampler_state { nullptr };
void* m_imported_iosurface_texture { nullptr };
Gfx::SharedImageBuffer const* m_imported_shared_image_buffer { nullptr };
unsigned long m_render_target_pixel_format { 0 };
#endif
};
}

260
UI/Qt/WebContentViewMac.mm Normal file
View file

@ -0,0 +1,260 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Debug.h>
#include <AK/Math.h>
#include <LibGfx/SharedImageBuffer.h>
#include <UI/Qt/WebContentView.h>
#include <QColor>
#include <rhi/qrhi.h>
#include <rhi/qrhi_platform.h>
#import <IOSurface/IOSurface.h>
#import <Metal/Metal.h>
namespace Ladybird {
static void release_metal_object(void*& object)
{
if (!object)
return;
[(id)object release];
object = nullptr;
}
void WebContentView::release_imported_iosurface_texture()
{
release_metal_object(m_imported_iosurface_texture);
m_imported_shared_image_buffer = nullptr;
}
void WebContentView::release_metal_resources()
{
release_imported_iosurface_texture();
release_metal_object(m_metal_sampler_state);
release_metal_object(m_metal_pipeline_state);
release_metal_object(m_metal_library);
m_metal_device = nullptr;
m_render_target_pixel_format = 0;
}
bool WebContentView::prepare_metal_renderer(unsigned long render_target_pixel_format)
{
auto const* rhi_native_handles = static_cast<QRhiMetalNativeHandles const*>(rhi()->nativeHandles());
if (!rhi_native_handles || !rhi_native_handles->dev)
return false;
auto* device = (id<MTLDevice>)rhi_native_handles->dev;
if (m_metal_device != device || m_render_target_pixel_format != render_target_pixel_format) {
release_metal_resources();
m_metal_device = device;
m_render_target_pixel_format = render_target_pixel_format;
}
if (m_metal_pipeline_state && m_metal_sampler_state)
return true;
// NB: QRhiWidget gives us a Metal render target for the widget, but QRhi does
// not expose a way to wrap the IOSurface-backed texture from WebContent as a
// QRhiTexture. Importing the IOSurface with Metal and drawing a textured quad
// lets us present it without doing a CPU readback through QImage/QPainter.
//
// The shader deliberately keeps the geometry tiny: it maps the painted content
// rectangle into the QRhiWidget render target and samples the corresponding
// sub-rectangle from the IOSurface. The IOSurface is imported as BGRA below;
// Metal texture sampling exposes those pixels to the shader as logical RGBA
// components, so the fragment shader does not need an explicit swizzle.
auto const* shader_source = R"(
#include <metal_stdlib>
using namespace metal;
struct Uniforms {
float2 target_size;
float2 content_size;
float2 source_size;
};
struct VertexOut {
float4 position [[position]];
float2 texture_coordinate;
};
vertex VertexOut vertex_main(uint vertex_id [[vertex_id]], constant Uniforms& uniforms [[buffer(0)]])
{
float2 unit_positions[4] = {
float2(0.0, 0.0),
float2(1.0, 0.0),
float2(0.0, 1.0),
float2(1.0, 1.0),
};
float2 unit_position = unit_positions[vertex_id];
float2 pixel_position = unit_position * uniforms.content_size;
VertexOut out;
out.position = float4(
pixel_position.x / uniforms.target_size.x * 2.0 - 1.0,
1.0 - pixel_position.y / uniforms.target_size.y * 2.0,
0.0,
1.0);
out.texture_coordinate = pixel_position / uniforms.source_size;
return out;
}
fragment half4 fragment_main(VertexOut in [[stage_in]], texture2d<half> texture [[texture(0)]], sampler texture_sampler [[sampler(0)]])
{
return texture.sample(texture_sampler, in.texture_coordinate);
}
)";
NSError* error = nil;
auto* library = [device newLibraryWithSource:[NSString stringWithUTF8String:shader_source] options:nil error:&error];
if (!library) {
dbgln("Failed to create Metal shader library for Qt WebContentView");
return false;
}
m_metal_library = library;
auto* vertex_function = [library newFunctionWithName:@"vertex_main"];
auto* fragment_function = [library newFunctionWithName:@"fragment_main"];
if (!vertex_function || !fragment_function) {
[vertex_function release];
[fragment_function release];
dbgln("Failed to create Metal shader functions for Qt WebContentView");
return false;
}
auto* pipeline_descriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipeline_descriptor.vertexFunction = vertex_function;
pipeline_descriptor.fragmentFunction = fragment_function;
pipeline_descriptor.colorAttachments[0].pixelFormat = static_cast<MTLPixelFormat>(render_target_pixel_format);
auto* pipeline_state = [device newRenderPipelineStateWithDescriptor:pipeline_descriptor error:&error];
[pipeline_descriptor release];
[vertex_function release];
[fragment_function release];
if (!pipeline_state) {
dbgln("Failed to create Metal render pipeline for Qt WebContentView");
return false;
}
m_metal_pipeline_state = pipeline_state;
auto* sampler_descriptor = [[MTLSamplerDescriptor alloc] init];
sampler_descriptor.minFilter = MTLSamplerMinMagFilterNearest;
sampler_descriptor.magFilter = MTLSamplerMinMagFilterNearest;
sampler_descriptor.sAddressMode = MTLSamplerAddressModeClampToEdge;
sampler_descriptor.tAddressMode = MTLSamplerAddressModeClampToEdge;
m_metal_sampler_state = [device newSamplerStateWithDescriptor:sampler_descriptor];
[sampler_descriptor release];
if (!m_metal_sampler_state) {
dbgln("Failed to create Metal sampler for Qt WebContentView");
return false;
}
return true;
}
bool WebContentView::update_imported_iosurface_texture(Gfx::SharedImageBuffer const& shared_image_buffer)
{
if (m_imported_shared_image_buffer == &shared_image_buffer && m_imported_iosurface_texture)
return true;
release_imported_iosurface_texture();
auto* device = (id<MTLDevice>)m_metal_device;
if (!device)
return false;
auto const& iosurface_handle = shared_image_buffer.iosurface_handle();
// LibGfx IOSurfaces are BGRA in memory. Match that storage format when
// importing the IOSurface so Metal can do the channel mapping while sampling.
auto* descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm
width:iosurface_handle.width()
height:iosurface_handle.height()
mipmapped:NO];
descriptor.storageMode = MTLStorageModeShared;
descriptor.usage = MTLTextureUsageShaderRead;
m_imported_iosurface_texture = [device newTextureWithDescriptor:descriptor
iosurface:(IOSurfaceRef)iosurface_handle.core_foundation_pointer()
plane:0];
if (!m_imported_iosurface_texture)
return false;
m_imported_shared_image_buffer = &shared_image_buffer;
return true;
}
void WebContentView::initialize(QRhiCommandBuffer*)
{
release_metal_resources();
}
void WebContentView::releaseResources()
{
release_metal_resources();
}
void WebContentView::render(QRhiCommandBuffer* command_buffer)
{
auto background_color = page_background_color();
auto clear_color = QColor(background_color.red(), background_color.green(), background_color.blue());
command_buffer->beginPass(renderTarget(), clear_color, { 1.0f, 0 }, nullptr, QRhiCommandBuffer::ExternalContent);
auto paintable = current_paintable();
if (!paintable.has_value() || paintable->bitmap_size.is_empty() || !rhi() || rhi()->backend() != QRhi::Metal) {
command_buffer->endPass();
return;
}
auto native_color_texture = colorTexture()->nativeTexture();
auto* render_target_texture = (id<MTLTexture>)native_color_texture.object;
if (!render_target_texture || !prepare_metal_renderer(render_target_texture.pixelFormat) || !update_imported_iosurface_texture(*paintable->shared_image_buffer)) {
command_buffer->endPass();
return;
}
auto target_size = colorTexture()->pixelSize();
auto content_width = min(paintable->bitmap_size.width(), target_size.width());
auto content_height = min(paintable->bitmap_size.height(), target_size.height());
if (content_width <= 0 || content_height <= 0) {
command_buffer->endPass();
return;
}
struct Uniforms {
float target_size[2];
float content_size[2];
float source_size[2];
} uniforms {
{ static_cast<float>(target_size.width()), static_cast<float>(target_size.height()) },
{ static_cast<float>(content_width), static_cast<float>(content_height) },
{ static_cast<float>(paintable->shared_image_buffer->iosurface_handle().width()), static_cast<float>(paintable->shared_image_buffer->iosurface_handle().height()) },
};
// The pass was opened with ExternalContent so we can encode the Metal draw
// directly into Qt's command buffer.
command_buffer->beginExternal();
auto const* command_buffer_native_handles = static_cast<QRhiMetalCommandBufferNativeHandles const*>(command_buffer->nativeHandles());
if (command_buffer_native_handles && command_buffer_native_handles->encoder) {
auto* encoder = command_buffer_native_handles->encoder;
[encoder setRenderPipelineState:(id<MTLRenderPipelineState>)m_metal_pipeline_state];
[encoder setVertexBytes:&uniforms length:sizeof(uniforms) atIndex:0];
[encoder setFragmentTexture:(id<MTLTexture>)m_imported_iosurface_texture atIndex:0];
[encoder setFragmentSamplerState:(id<MTLSamplerState>)m_metal_sampler_state atIndex:0];
[encoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4];
}
command_buffer->endExternal();
command_buffer->endPass();
}
}

View file

@ -13,6 +13,8 @@
#include <UI/Qt/BrowserWindow.h>
#include <UI/Qt/Settings.h>
#include <QtGlobal>
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
# include <QStyleHints>
#endif
@ -43,6 +45,13 @@ ErrorOr<int> ladybird_main(Main::Arguments arguments)
{
AK::set_rich_debug_enabled(true);
#ifdef AK_OS_MACOS
if (!qEnvironmentVariableIsSet("QT_WIDGETS_RHI"))
qputenv("QT_WIDGETS_RHI", "1");
if (!qEnvironmentVariableIsSet("QT_WIDGETS_RHI_BACKEND"))
qputenv("QT_WIDGETS_RHI_BACKEND", "metal");
#endif
auto app = TRY(Ladybird::Application::create(arguments));
WebView::BrowserProcess browser_process;