renderer/vulkan: VkSampler wrapper

Adds `vulkan/Sampler.zig` — wrapper around `VkSampler`, counterpart
to `opengl/Sampler.zig`. Establishes the pattern every Vulkan submodule
that needs new device-level entry points will follow:

  1. Add the function-pointer field to `Device.Dispatch`.
  2. Resolve it via `dl.load(...)` in `Device.init`.
  3. Reference `device.dispatch.foo(...)` from the submodule.

`Sampler.Options` keeps the same shape as the OpenGL backend's
(`min_filter` / `mag_filter` / `wrap_s` / `wrap_t`) so the renderer
contract `Sampler.init(api.samplerOptions())` works against either
backend. Filter / address-mode are Vulkan-native enums backed by
`VK_FILTER_*` / `VK_SAMPLER_ADDRESS_MODE_*` values; the API
mismatch with OpenGL's GL constants stays hidden behind
`api.samplerOptions()`.

A few small Vulkan-specific decisions baked in:
  - `mipmapMode = LINEAR` but `minLod == maxLod == 0`, so it's a
    no-op today but forward-compatible if we ever generate atlas
    mips.
  - `anisotropyEnable = FALSE` — the terminal grid doesn't benefit
    from anisotropy and enabling it would gate on a per-physical-
    device feature toggle the apprt would have to coordinate.
  - `unnormalizedCoordinates = FALSE` to keep the binding portable
    across atlas / image-data / split-divider use cases.

Verification: temporarily flipped `.vulkan` switch to compile-check
via `Vulkan.zig` re-export; Sampler + the new dispatch entries
resolved clean (only failure was the expected
`renderer.Vulkan has no member 'DerivedConfig'` downstream of the
stub substitution). Reverted before commit. OpenGL build still
silent / clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-24 09:08:27 -05:00
parent ac46d91085
commit 92f5ae81b4
3 changed files with 124 additions and 0 deletions

View File

@ -64,3 +64,4 @@
//! See the parity branch description in `qt/PARITY.md` once it lands.
pub const Device = @import("vulkan/Device.zig");
pub const Sampler = @import("vulkan/Sampler.zig");

View File

@ -85,6 +85,10 @@ pub const Dispatch = struct {
// call adds a field here and a `loadDevice` lookup in `init`.
getDeviceQueue: std.meta.Child(vk.PFN_vkGetDeviceQueue),
deviceWaitIdle: std.meta.Child(vk.PFN_vkDeviceWaitIdle),
// Sampler used by `vulkan/Sampler.zig`.
createSampler: std.meta.Child(vk.PFN_vkCreateSampler),
destroySampler: std.meta.Child(vk.PFN_vkDestroySampler),
};
// ---- fields ---------------------------------------------------------
@ -243,6 +247,10 @@ pub fn init(
try dl.load(vk.PFN_vkGetDeviceQueue, "vkGetDeviceQueue");
const device_wait_idle =
try dl.load(vk.PFN_vkDeviceWaitIdle, "vkDeviceWaitIdle");
const create_sampler =
try dl.load(vk.PFN_vkCreateSampler, "vkCreateSampler");
const destroy_sampler =
try dl.load(vk.PFN_vkDestroySampler, "vkDestroySampler");
return .{
.platform = platform,
@ -258,6 +266,8 @@ pub fn init(
.getDeviceProcAddr = get_device_proc_addr,
.getDeviceQueue = get_device_queue,
.deviceWaitIdle = device_wait_idle,
.createSampler = create_sampler,
.destroySampler = destroy_sampler,
},
};
}

View File

@ -0,0 +1,113 @@
//! Wrapper for `VkSampler` the immutable filter / wrap configuration
//! the GPU uses when sampling a texture.
//!
//! libghostty doesn't share samplers across textures (the OpenGL
//! backend already creates one per texture-shaped need); we keep the
//! same per-callsite ownership model so the renderer interface
//! matches.
//!
//! Counterpart: `src/renderer/opengl/Sampler.zig`.
const Self = @This();
const std = @import("std");
const vk = @import("vulkan").c;
const Device = @import("Device.zig");
const log = std.log.scoped(.vulkan);
/// Texel filter mode. Maps 1:1 to `VkFilter`.
pub const Filter = enum(c_int) {
nearest = vk.VK_FILTER_NEAREST,
linear = vk.VK_FILTER_LINEAR,
};
/// Texture coordinate wrap mode. Maps 1:1 to `VkSamplerAddressMode`.
pub const AddressMode = enum(c_int) {
repeat = vk.VK_SAMPLER_ADDRESS_MODE_REPEAT,
mirrored_repeat = vk.VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT,
clamp_to_edge = vk.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
clamp_to_border = vk.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER,
};
/// Sampler construction parameters. The same shape as the OpenGL
/// backend's `Sampler.Options` (so generic.zig can call
/// `Sampler.init(api.samplerOptions())` against either backend), with
/// a `device` reference so we can call `vkCreateSampler` against the
/// host's VkDevice without threading a global through.
pub const Options = struct {
device: *const Device,
min_filter: Filter,
mag_filter: Filter,
wrap_s: AddressMode,
wrap_t: AddressMode,
};
pub const Error = error{
/// `vkCreateSampler` returned a non-success status. Logged with
/// the raw `VkResult` value.
VulkanFailed,
};
sampler: vk.VkSampler,
device: *const Device,
/// Create a sampler against the host's VkDevice. The sampler is
/// destroyed in `deinit`; libghostty owns this handle's lifetime.
pub fn init(opts: Options) Error!Self {
const info: vk.VkSamplerCreateInfo = .{
.sType = vk.VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
.pNext = null,
.flags = 0,
.magFilter = @intFromEnum(opts.mag_filter),
.minFilter = @intFromEnum(opts.min_filter),
// The glyph atlases are 2D textures without mips; the
// renderer doesn't request mipmaps and the value here is
// ignored when `lodMin == lodMax == 0`. Use LINEAR for
// forward-compatibility if we ever generate atlas mips.
.mipmapMode = vk.VK_SAMPLER_MIPMAP_MODE_LINEAR,
.addressModeU = @intFromEnum(opts.wrap_s),
.addressModeV = @intFromEnum(opts.wrap_t),
// 2D textures never sample in W; the renderer ignores it. The
// value still has to be valid pick CLAMP_TO_EDGE.
.addressModeW = vk.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.mipLodBias = 0,
// Anisotropy is a per-physical-device feature toggle; the
// terminal grid doesn't benefit from it and gating on the
// feature flag adds host coordination noise. Skip.
.anisotropyEnable = vk.VK_FALSE,
.maxAnisotropy = 1,
.compareEnable = vk.VK_FALSE,
.compareOp = vk.VK_COMPARE_OP_ALWAYS,
.minLod = 0,
.maxLod = 0,
.borderColor = vk.VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK,
.unnormalizedCoordinates = vk.VK_FALSE,
};
var sampler: vk.VkSampler = undefined;
const result = opts.device.dispatch.createSampler(
opts.device.device,
&info,
null,
&sampler,
);
if (result != vk.VK_SUCCESS) {
log.err("vkCreateSampler failed: result={}", .{result});
return error.VulkanFailed;
}
return .{
.sampler = sampler,
.device = opts.device,
};
}
pub fn deinit(self: Self) void {
self.device.dispatch.destroySampler(self.device.device, self.sampler, null);
}
test {
std.testing.refAllDecls(@This());
}