a73x

5686b7ab

feat(renderer): vulkan instance + surface + device + swapchain

a73x   2026-04-08 09:01

Implements Context.init/deinit in renderer.zig: loads libvulkan.so.1 via
dlopen, creates Vulkan instance with KHR_surface + KHR_wayland_surface,
picks a physical device + queue families, creates the logical device +
swapchain + image views. Adds --vulkan-smoke-test to main.zig that
exercises the full init path and prints diagnostics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/build.zig b/build.zig
index 06dde99..ffd6045 100644
--- a/build.zig
+++ b/build.zig
@@ -175,8 +175,10 @@ pub fn build(b: *std.Build) void {
        .root_source_file = renderer_zig_path,
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });
    renderer_mod.addImport("vulkan", vulkan_module);
    renderer_mod.linkSystemLibrary("dl", .{});
    exe_mod.addImport("renderer", renderer_mod);

    // Test renderer.zig
@@ -184,8 +186,10 @@ pub fn build(b: *std.Build) void {
        .root_source_file = renderer_zig_path,
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });
    renderer_test_mod.addImport("vulkan", vulkan_module);
    renderer_test_mod.linkSystemLibrary("dl", .{});
    const renderer_tests = b.addTest(.{
        .root_module = renderer_test_mod,
    });
diff --git a/src/main.zig b/src/main.zig
index f413a5e..e644bdc 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const vt = @import("vt");
const pty = @import("pty");
const wayland_client = @import("wayland-client");
const renderer = @import("renderer");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
@@ -19,9 +20,40 @@ pub fn main() !void {
        return runWaylandSmokeTest(alloc);
    }

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--vulkan-smoke-test")) {
        return runVulkanSmokeTest(alloc);
    }

    std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{});
}

fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init();
    defer conn.deinit();
    std.debug.print("wayland connected\n", .{});

    const window = try conn.createWindow(alloc, "waystty-vulkan-smoke");
    defer window.deinit();
    std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });

    // Roundtrip to ensure configure events have arrived before Vulkan touches the surface
    _ = conn.display.roundtrip();

    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width,
        window.height,
    );
    defer ctx.deinit();

    std.debug.print("vulkan ok\n", .{});
    std.debug.print("  format: {any}\n", .{ctx.swapchain_format});
    std.debug.print("  extent: {d}x{d}\n", .{ ctx.swapchain_extent.width, ctx.swapchain_extent.height });
    std.debug.print("  image count: {d}\n", .{ctx.swapchain_images.len});
}

fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init();
    defer conn.deinit();
diff --git a/src/renderer.zig b/src/renderer.zig
index 5942c5c..e21751d 100644
--- a/src/renderer.zig
+++ b/src/renderer.zig
@@ -1,9 +1,325 @@
const std = @import("std");
const vk = @import("vulkan");

const dl = @cImport({
    @cInclude("dlfcn.h");
});

pub const cell_vert_spv: []const u8 = @embedFile("cell.vert.spv");
pub const cell_frag_spv: []const u8 = @embedFile("cell.frag.spv");

var vk_lib_handle: ?*anyopaque = null;

fn getVkGetInstanceProcAddr() !vk.PfnGetInstanceProcAddr {
    if (vk_lib_handle == null) {
        vk_lib_handle = dl.dlopen("libvulkan.so.1", dl.RTLD_NOW);
    }
    const handle = vk_lib_handle orelse return error.VulkanLibraryNotFound;
    const sym = dl.dlsym(handle, "vkGetInstanceProcAddr") orelse return error.NoVkGetInstanceProcAddr;
    return @ptrCast(@alignCast(sym));
}

// Wrap the raw PfnGetInstanceProcAddr so it matches the anytype loader signature
// expected by BaseWrapper.load (accepts instance + name, returns optional fn ptr).
fn makeBaseLoader(pfn: vk.PfnGetInstanceProcAddr) vk.PfnGetInstanceProcAddr {
    return pfn;
}

const PhysicalDeviceInfo = struct {
    physical: vk.PhysicalDevice,
    graphics_queue_family: u32,
    present_queue_family: u32,
};

const SwapchainResult = struct {
    swapchain: vk.SwapchainKHR,
    format: vk.Format,
    extent: vk.Extent2D,
    images: []vk.Image,
    image_views: []vk.ImageView,
};

fn pickPhysicalDevice(
    alloc: std.mem.Allocator,
    vki: vk.InstanceWrapper,
    instance: vk.Instance,
    surface: vk.SurfaceKHR,
) !PhysicalDeviceInfo {
    var count: u32 = 0;
    _ = try vki.enumeratePhysicalDevices(instance, &count, null);
    if (count == 0) return error.NoVulkanDevices;

    const devices = try alloc.alloc(vk.PhysicalDevice, count);
    defer alloc.free(devices);
    _ = try vki.enumeratePhysicalDevices(instance, &count, devices.ptr);

    for (devices[0..count]) |pd| {
        var qf_count: u32 = 0;
        vki.getPhysicalDeviceQueueFamilyProperties(pd, &qf_count, null);
        const qfs = try alloc.alloc(vk.QueueFamilyProperties, qf_count);
        defer alloc.free(qfs);
        vki.getPhysicalDeviceQueueFamilyProperties(pd, &qf_count, qfs.ptr);

        var graphics_idx: ?u32 = null;
        var present_idx: ?u32 = null;
        for (qfs[0..qf_count], 0..) |qf, i| {
            if (qf.queue_flags.graphics_bit) graphics_idx = @intCast(i);

            const supported = try vki.getPhysicalDeviceSurfaceSupportKHR(pd, @intCast(i), surface);
            if (supported == vk.Bool32.true) present_idx = @intCast(i);

            if (graphics_idx != null and present_idx != null) break;
        }

        if (graphics_idx != null and present_idx != null) {
            return .{
                .physical = pd,
                .graphics_queue_family = graphics_idx.?,
                .present_queue_family = present_idx.?,
            };
        }
    }
    return error.NoSuitableDevice;
}

fn createSwapchain(
    alloc: std.mem.Allocator,
    vki: vk.InstanceWrapper,
    vkd: vk.DeviceWrapper,
    pd_info: PhysicalDeviceInfo,
    surface: vk.SurfaceKHR,
    device: vk.Device,
    width: u32,
    height: u32,
) !SwapchainResult {
    const caps = try vki.getPhysicalDeviceSurfaceCapabilitiesKHR(pd_info.physical, surface);

    var fmt_count: u32 = 0;
    _ = try vki.getPhysicalDeviceSurfaceFormatsKHR(pd_info.physical, surface, &fmt_count, null);
    if (fmt_count == 0) return error.NoSurfaceFormats;
    const formats = try alloc.alloc(vk.SurfaceFormatKHR, fmt_count);
    defer alloc.free(formats);
    _ = try vki.getPhysicalDeviceSurfaceFormatsKHR(pd_info.physical, surface, &fmt_count, formats.ptr);

    var chosen = formats[0];
    for (formats[0..fmt_count]) |f| {
        if (f.format == .b8g8r8a8_unorm and f.color_space == .srgb_nonlinear_khr) {
            chosen = f;
            break;
        }
    }

    var extent = caps.current_extent;
    if (extent.width == 0xFFFFFFFF) {
        extent = .{ .width = width, .height = height };
    }

    var image_count: u32 = caps.min_image_count + 1;
    if (caps.max_image_count > 0 and image_count > caps.max_image_count) {
        image_count = caps.max_image_count;
    }

    const same_family = pd_info.graphics_queue_family == pd_info.present_queue_family;
    const families = [_]u32{ pd_info.graphics_queue_family, pd_info.present_queue_family };

    const swapchain = try vkd.createSwapchainKHR(device, &vk.SwapchainCreateInfoKHR{
        .surface = surface,
        .min_image_count = image_count,
        .image_format = chosen.format,
        .image_color_space = chosen.color_space,
        .image_extent = extent,
        .image_array_layers = 1,
        .image_usage = .{ .color_attachment_bit = true },
        .image_sharing_mode = if (same_family) .exclusive else .concurrent,
        .queue_family_index_count = if (same_family) 0 else 2,
        .p_queue_family_indices = if (same_family) null else &families,
        .pre_transform = caps.current_transform,
        .composite_alpha = .{ .opaque_bit_khr = true },
        .present_mode = .fifo_khr,
        .clipped = .true,
    }, null);

    var sc_count: u32 = 0;
    _ = try vkd.getSwapchainImagesKHR(device, swapchain, &sc_count, null);
    const images = try alloc.alloc(vk.Image, sc_count);
    errdefer alloc.free(images);
    _ = try vkd.getSwapchainImagesKHR(device, swapchain, &sc_count, images.ptr);

    const image_views = try alloc.alloc(vk.ImageView, sc_count);
    errdefer {
        // Clean up any views created before failure
        alloc.free(image_views);
    }
    var views_created: usize = 0;
    errdefer {
        for (image_views[0..views_created]) |view| vkd.destroyImageView(device, view, null);
    }

    for (images[0..sc_count], 0..) |img, i| {
        image_views[i] = try vkd.createImageView(device, &vk.ImageViewCreateInfo{
            .image = img,
            .view_type = .@"2d",
            .format = chosen.format,
            .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
            .subresource_range = .{
                .aspect_mask = .{ .color_bit = true },
                .base_mip_level = 0,
                .level_count = 1,
                .base_array_layer = 0,
                .layer_count = 1,
            },
        }, null);
        views_created += 1;
    }

    return .{
        .swapchain = swapchain,
        .format = chosen.format,
        .extent = extent,
        .images = images,
        .image_views = image_views,
    };
}

pub const Context = struct {
    alloc: std.mem.Allocator,
    vkb: vk.BaseWrapper,
    instance: vk.Instance,
    vki: vk.InstanceWrapper,
    surface: vk.SurfaceKHR,
    physical_device: vk.PhysicalDevice,
    graphics_queue_family: u32,
    present_queue_family: u32,
    device: vk.Device,
    vkd: vk.DeviceWrapper,
    graphics_queue: vk.Queue,
    present_queue: vk.Queue,
    swapchain: vk.SwapchainKHR,
    swapchain_format: vk.Format,
    swapchain_extent: vk.Extent2D,
    swapchain_images: []vk.Image,
    swapchain_image_views: []vk.ImageView,

    pub fn init(
        alloc: std.mem.Allocator,
        wl_display: *anyopaque,
        wl_surface: *anyopaque,
        width: u32,
        height: u32,
    ) !Context {
        const get_proc_addr = try getVkGetInstanceProcAddr();
        const vkb = vk.BaseWrapper.load(get_proc_addr);

        // Create instance
        const app_info = vk.ApplicationInfo{
            .p_application_name = "waystty",
            .application_version = @bitCast(vk.makeApiVersion(0, 0, 0, 1)),
            .p_engine_name = "waystty",
            .engine_version = @bitCast(vk.makeApiVersion(0, 0, 0, 1)),
            .api_version = @bitCast(vk.API_VERSION_1_2),
        };

        const instance_exts = [_][*:0]const u8{
            vk.extensions.khr_surface.name,
            vk.extensions.khr_wayland_surface.name,
        };

        const instance = try vkb.createInstance(&vk.InstanceCreateInfo{
            .p_application_info = &app_info,
            .enabled_extension_count = instance_exts.len,
            .pp_enabled_extension_names = &instance_exts,
        }, null);

        const vki = vk.InstanceWrapper.load(instance, vkb.dispatch.vkGetInstanceProcAddr.?);
        errdefer vki.destroyInstance(instance, null);

        // Create wayland surface
        const surface = try vki.createWaylandSurfaceKHR(instance, &vk.WaylandSurfaceCreateInfoKHR{
            .display = @ptrCast(wl_display),
            .surface = @ptrCast(wl_surface),
        }, null);
        errdefer vki.destroySurfaceKHR(instance, surface, null);

        // Pick physical device + queue families
        const pd_info = try pickPhysicalDevice(alloc, vki, instance, surface);

        // Create logical device
        const priority: f32 = 1.0;
        var queue_create_infos: [2]vk.DeviceQueueCreateInfo = undefined;
        var queue_count: u32 = 1;
        queue_create_infos[0] = .{
            .queue_family_index = pd_info.graphics_queue_family,
            .queue_count = 1,
            .p_queue_priorities = @ptrCast(&priority),
        };
        if (pd_info.graphics_queue_family != pd_info.present_queue_family) {
            queue_create_infos[1] = .{
                .queue_family_index = pd_info.present_queue_family,
                .queue_count = 1,
                .p_queue_priorities = @ptrCast(&priority),
            };
            queue_count = 2;
        }

        const device_exts = [_][*:0]const u8{vk.extensions.khr_swapchain.name};

        const empty_layer_name: *const u8 = @ptrFromInt(1); // dummy, count=0 so never dereferenced
        const device = try vki.createDevice(pd_info.physical, &vk.DeviceCreateInfo{
            .queue_create_info_count = queue_count,
            .p_queue_create_infos = &queue_create_infos,
            .enabled_layer_count = 0,
            .pp_enabled_layer_names = &empty_layer_name,
            .enabled_extension_count = device_exts.len,
            .pp_enabled_extension_names = &device_exts,
        }, null);

        const vkd = vk.DeviceWrapper.load(device, vki.dispatch.vkGetDeviceProcAddr.?);
        errdefer vkd.destroyDevice(device, null);

        const graphics_queue = vkd.getDeviceQueue(device, pd_info.graphics_queue_family, 0);
        const present_queue = vkd.getDeviceQueue(device, pd_info.present_queue_family, 0);

        // Create swapchain
        const sc = try createSwapchain(alloc, vki, vkd, pd_info, surface, device, width, height);
        errdefer {
            for (sc.image_views) |view| vkd.destroyImageView(device, view, null);
            alloc.free(sc.image_views);
            alloc.free(sc.images);
            vkd.destroySwapchainKHR(device, sc.swapchain, null);
        }

        return .{
            .alloc = alloc,
            .vkb = vkb,
            .instance = instance,
            .vki = vki,
            .surface = surface,
            .physical_device = pd_info.physical,
            .graphics_queue_family = pd_info.graphics_queue_family,
            .present_queue_family = pd_info.present_queue_family,
            .device = device,
            .vkd = vkd,
            .graphics_queue = graphics_queue,
            .present_queue = present_queue,
            .swapchain = sc.swapchain,
            .swapchain_format = sc.format,
            .swapchain_extent = sc.extent,
            .swapchain_images = sc.images,
            .swapchain_image_views = sc.image_views,
        };
    }

    pub fn deinit(self: *Context) void {
        for (self.swapchain_image_views) |view| self.vkd.destroyImageView(self.device, view, null);
        self.alloc.free(self.swapchain_image_views);
        self.alloc.free(self.swapchain_images);
        self.vkd.destroySwapchainKHR(self.device, self.swapchain, null);
        self.vkd.destroyDevice(self.device, null);
        self.vki.destroySurfaceKHR(self.instance, self.surface, null);
        self.vki.destroyInstance(self.instance, null);
    }
};

test "vulkan module imports" {
    _ = vk;
}