renderer/vulkan: per-surface platform routing for splits/tabs

New surfaces (splits, tabs, new windows) showed the placeholder
forever and never received their first dmabuf frame. The frames
were arriving — just at the wrong window.

Root cause: `Device` is process-global (shared across surfaces, as
intended) and it caches the `Platform.Vulkan` callbacks given to its
first init. Those callbacks include `userdata`, which is the
`GhosttySurface *` the `present` callback routes the dmabuf to. So
every surface's renderer was calling `present(userdata=surface_1)`,
even when the frame belonged to surface_2 — dmabuf frames landed in
surface_1's `m_pending`, and surface_2 sat at its placeholder.

Fix:
- `Target.Options` gains a `platform: ?Platform.Vulkan` field with
  the SAME shape as `Device.platform`, but per-surface. `present()`
  uses it when set; falls back to the singleton's copy otherwise
  (for the smoke test, which has no apprt surface).
- `Vulkan.initTarget` reaches through `self.rt_surface.platform` to
  pull the surface's own platform callbacks (correct `userdata`) and
  passes them to `Target.init`.
- `surfacePlatform()` helper isolates the apprt-tag match so
  non-Vulkan platforms (smoke test, OpenGL surfaces) cleanly resolve
  to null.

The placeholder still briefly flashes when a new surface opens —
that's the 'awaiting first dmabuf frame' painting before libghostty
emits its initial render — and is expected behavior.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-24 15:57:26 -05:00
parent ab14f8f214
commit 0442416ac8
2 changed files with 43 additions and 3 deletions

View File

@ -254,7 +254,6 @@ pub fn initShaders(
}
pub fn initTarget(self: *const Vulkan, width: usize, height: usize) !Target {
_ = self;
// SRGB format so the hardware gamma-encodes the linear premultiplied
// shader output at framebuffer-write time. The renderer's shaders
// produce linear premultiplied alpha; without an sRGB format the
@ -263,14 +262,35 @@ pub fn initTarget(self: *const Vulkan, width: usize, height: usize) !Target {
// encoded colors would look way too dark. The DRM fourcc the
// host sees is still ARGB8888; SRGB encoding is a Vulkan-side
// concern only.
//
// Per-surface platform: pulled from rt_surface so the `present`
// callback's `userdata` points at THIS surface's window. The
// process-global Device has its own `platform` copy from
// whichever surface first initialized it; splits and tabs would
// otherwise route their dmabuf frames to the wrong window.
const platform = surfacePlatform(self.rt_surface);
return try Target.init(.{
.device = devicePtr(),
.format = vk.VK_FORMAT_B8G8R8A8_SRGB,
.width = @intCast(width),
.height = @intCast(height),
.platform = platform,
});
}
/// Extract the Vulkan platform callbacks from a surface, when the
/// surface was created with the Vulkan platform tag. Returns null
/// otherwise (smoke test / OpenGL surfaces).
fn surfacePlatform(rt_surface: *apprt.Surface) ?apprt.embedded.Platform.Vulkan {
return switch (apprt.runtime) {
else => null,
apprt.embedded => switch (rt_surface.platform) {
.vulkan => |p| p,
else => null,
},
};
}
pub fn surfaceSize(self: *const Vulkan) !struct { width: u32, height: u32 } {
const size = self.rt_surface.size;
return .{ .width = size.width, .height = size.height };

View File

@ -33,6 +33,7 @@ const Self = @This();
const std = @import("std");
const vk = @import("vulkan").c;
const apprt = @import("../../apprt.zig");
const Device = @import("Device.zig");
const log = std.log.scoped(.vulkan);
@ -51,6 +52,15 @@ pub const Options = struct {
/// defaults (`COLOR_ATTACHMENT_BIT | SAMPLED_BIT |
/// TRANSFER_SRC_BIT`). Rarely needed.
extra_usage: vk.VkImageUsageFlags = 0,
/// Per-surface platform callbacks. `Device.platform` is also a
/// `Platform.Vulkan`, but it's the singleton's copy its
/// `userdata` points at whichever surface initialized the
/// device first. Splits/tabs share the device but each gets its
/// own platform with the right `userdata`, so `present()` reaches
/// the right window. Falls back to `device.platform` when
/// null (e.g. smoke test).
platform: ?apprt.embedded.Platform.Vulkan = null,
};
pub const Error = error{
@ -61,6 +71,10 @@ pub const Error = error{
device: *const Device,
/// Per-surface platform see `Options.platform`. Null means "use
/// `device.platform`" (the singleton's copy from the first surface).
platform: ?apprt.embedded.Platform.Vulkan = null,
// ---- render image (OPTIMAL, internal) -------------------------------
image: vk.VkImage,
image_memory: vk.VkDeviceMemory,
@ -250,6 +264,7 @@ pub fn init(opts: Options) Error!Self {
return .{
.device = dev,
.platform = opts.platform,
.image = image,
.image_memory = image_memory,
.view = view,
@ -375,8 +390,13 @@ pub fn recordCopyToDmabuf(self: *Self, cb: vk.VkCommandBuffer) void {
}
pub fn present(self: *const Self) void {
self.device.platform.present(
self.device.platform.userdata,
// Prefer the per-surface platform its `userdata` points at THIS
// surface's GhosttySurface, so present reaches the right window.
// Fall back to the device's singleton copy when no platform was
// attached (only the smoke test does this).
const platform = if (self.platform) |p| p else self.device.platform;
platform.present(
platform.userdata,
self.fd,
self.drm_format,
self.drm_modifier,