a73x

5686b7ab

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

a73x   2026-04-08 09:01

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

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>

build.zig
Old New
@@ -175,8 +175,10 @@ pub fn build(b: *std.Build) void {
175 .root_source_file = renderer_zig_path, 175 .root_source_file = renderer_zig_path,
176 .target = target, 176 .target = target,
177 .optimize = optimize, 177 .optimize = optimize,
178 .link_libc = true,
178 }); 179 });
179 renderer_mod.addImport("vulkan", vulkan_module); 180 renderer_mod.addImport("vulkan", vulkan_module);
181 renderer_mod.linkSystemLibrary("dl", .{});
180 exe_mod.addImport("renderer", renderer_mod); 182 exe_mod.addImport("renderer", renderer_mod);
181 183
182 // Test renderer.zig 184 // Test renderer.zig
@@ -184,8 +186,10 @@ pub fn build(b: *std.Build) void {
184 .root_source_file = renderer_zig_path, 186 .root_source_file = renderer_zig_path,
185 .target = target, 187 .target = target,
186 .optimize = optimize, 188 .optimize = optimize,
189 .link_libc = true,
187 }); 190 });
188 renderer_test_mod.addImport("vulkan", vulkan_module); 191 renderer_test_mod.addImport("vulkan", vulkan_module);
192 renderer_test_mod.linkSystemLibrary("dl", .{});
189 const renderer_tests = b.addTest(.{ 193 const renderer_tests = b.addTest(.{
190 .root_module = renderer_test_mod, 194 .root_module = renderer_test_mod,
191 }); 195 });
src/main.zig
Old New
@@ -2,6 +2,7 @@ const std = @import("std");
2 const vt = @import("vt"); 2 const vt = @import("vt");
3 const pty = @import("pty"); 3 const pty = @import("pty");
4 const wayland_client = @import("wayland-client"); 4 const wayland_client = @import("wayland-client");
5 const renderer = @import("renderer");
5 6
6 pub fn main() !void { 7 pub fn main() !void {
7 var gpa: std.heap.DebugAllocator(.{}) = .init; 8 var gpa: std.heap.DebugAllocator(.{}) = .init;
@@ -19,9 +20,40 @@ pub fn main() !void {
19 return runWaylandSmokeTest(alloc); 20 return runWaylandSmokeTest(alloc);
20 } 21 }
21 22
23 if (args.len >= 2 and std.mem.eql(u8, args[1], "--vulkan-smoke-test")) {
24 return runVulkanSmokeTest(alloc);
25 }
26
22 std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{}); 27 std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{});
23 } 28 }
24 29
30 fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
31 var conn = try wayland_client.Connection.init();
32 defer conn.deinit();
33 std.debug.print("wayland connected\n", .{});
34
35 const window = try conn.createWindow(alloc, "waystty-vulkan-smoke");
36 defer window.deinit();
37 std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });
38
39 // Roundtrip to ensure configure events have arrived before Vulkan touches the surface
40 _ = conn.display.roundtrip();
41
42 var ctx = try renderer.Context.init(
43 alloc,
44 @ptrCast(conn.display),
45 @ptrCast(window.surface),
46 window.width,
47 window.height,
48 );
49 defer ctx.deinit();
50
51 std.debug.print("vulkan ok\n", .{});
52 std.debug.print(" format: {any}\n", .{ctx.swapchain_format});
53 std.debug.print(" extent: {d}x{d}\n", .{ ctx.swapchain_extent.width, ctx.swapchain_extent.height });
54 std.debug.print(" image count: {d}\n", .{ctx.swapchain_images.len});
55 }
56
25 fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void { 57 fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void {
26 var conn = try wayland_client.Connection.init(); 58 var conn = try wayland_client.Connection.init();
27 defer conn.deinit(); 59 defer conn.deinit();
src/renderer.zig
Old New
@@ -1,9 +1,325 @@
1 const std = @import("std"); 1 const std = @import("std");
2 const vk = @import("vulkan"); 2 const vk = @import("vulkan");
3 3
4 const dl = @cImport({
5 @cInclude("dlfcn.h");
6 });
7
4 pub const cell_vert_spv: []const u8 = @embedFile("cell.vert.spv"); 8 pub const cell_vert_spv: []const u8 = @embedFile("cell.vert.spv");
5 pub const cell_frag_spv: []const u8 = @embedFile("cell.frag.spv"); 9 pub const cell_frag_spv: []const u8 = @embedFile("cell.frag.spv");
6 10
11 var vk_lib_handle: ?*anyopaque = null;
12
13 fn getVkGetInstanceProcAddr() !vk.PfnGetInstanceProcAddr {
14 if (vk_lib_handle == null) {
15 vk_lib_handle = dl.dlopen("libvulkan.so.1", dl.RTLD_NOW);
16 }
17 const handle = vk_lib_handle orelse return error.VulkanLibraryNotFound;
18 const sym = dl.dlsym(handle, "vkGetInstanceProcAddr") orelse return error.NoVkGetInstanceProcAddr;
19 return @ptrCast(@alignCast(sym));
20 }
21
22 // Wrap the raw PfnGetInstanceProcAddr so it matches the anytype loader signature
23 // expected by BaseWrapper.load (accepts instance + name, returns optional fn ptr).
24 fn makeBaseLoader(pfn: vk.PfnGetInstanceProcAddr) vk.PfnGetInstanceProcAddr {
25 return pfn;
26 }
27
28 const PhysicalDeviceInfo = struct {
29 physical: vk.PhysicalDevice,
30 graphics_queue_family: u32,
31 present_queue_family: u32,
32 };
33
34 const SwapchainResult = struct {
35 swapchain: vk.SwapchainKHR,
36 format: vk.Format,
37 extent: vk.Extent2D,
38 images: []vk.Image,
39 image_views: []vk.ImageView,
40 };
41
42 fn pickPhysicalDevice(
43 alloc: std.mem.Allocator,
44 vki: vk.InstanceWrapper,
45 instance: vk.Instance,
46 surface: vk.SurfaceKHR,
47 ) !PhysicalDeviceInfo {
48 var count: u32 = 0;
49 _ = try vki.enumeratePhysicalDevices(instance, &count, null);
50 if (count == 0) return error.NoVulkanDevices;
51
52 const devices = try alloc.alloc(vk.PhysicalDevice, count);
53 defer alloc.free(devices);
54 _ = try vki.enumeratePhysicalDevices(instance, &count, devices.ptr);
55
56 for (devices[0..count]) |pd| {
57 var qf_count: u32 = 0;
58 vki.getPhysicalDeviceQueueFamilyProperties(pd, &qf_count, null);
59 const qfs = try alloc.alloc(vk.QueueFamilyProperties, qf_count);
60 defer alloc.free(qfs);
61 vki.getPhysicalDeviceQueueFamilyProperties(pd, &qf_count, qfs.ptr);
62
63 var graphics_idx: ?u32 = null;
64 var present_idx: ?u32 = null;
65 for (qfs[0..qf_count], 0..) |qf, i| {
66 if (qf.queue_flags.graphics_bit) graphics_idx = @intCast(i);
67
68 const supported = try vki.getPhysicalDeviceSurfaceSupportKHR(pd, @intCast(i), surface);
69 if (supported == vk.Bool32.true) present_idx = @intCast(i);
70
71 if (graphics_idx != null and present_idx != null) break;
72 }
73
74 if (graphics_idx != null and present_idx != null) {
75 return .{
76 .physical = pd,
77 .graphics_queue_family = graphics_idx.?,
78 .present_queue_family = present_idx.?,
79 };
80 }
81 }
82 return error.NoSuitableDevice;
83 }
84
85 fn createSwapchain(
86 alloc: std.mem.Allocator,
87 vki: vk.InstanceWrapper,
88 vkd: vk.DeviceWrapper,
89 pd_info: PhysicalDeviceInfo,
90 surface: vk.SurfaceKHR,
91 device: vk.Device,
92 width: u32,
93 height: u32,
94 ) !SwapchainResult {
95 const caps = try vki.getPhysicalDeviceSurfaceCapabilitiesKHR(pd_info.physical, surface);
96
97 var fmt_count: u32 = 0;
98 _ = try vki.getPhysicalDeviceSurfaceFormatsKHR(pd_info.physical, surface, &fmt_count, null);
99 if (fmt_count == 0) return error.NoSurfaceFormats;
100 const formats = try alloc.alloc(vk.SurfaceFormatKHR, fmt_count);
101 defer alloc.free(formats);
102 _ = try vki.getPhysicalDeviceSurfaceFormatsKHR(pd_info.physical, surface, &fmt_count, formats.ptr);
103
104 var chosen = formats[0];
105 for (formats[0..fmt_count]) |f| {
106 if (f.format == .b8g8r8a8_unorm and f.color_space == .srgb_nonlinear_khr) {
107 chosen = f;
108 break;
109 }
110 }
111
112 var extent = caps.current_extent;
113 if (extent.width == 0xFFFFFFFF) {
114 extent = .{ .width = width, .height = height };
115 }
116
117 var image_count: u32 = caps.min_image_count + 1;
118 if (caps.max_image_count > 0 and image_count > caps.max_image_count) {
119 image_count = caps.max_image_count;
120 }
121
122 const same_family = pd_info.graphics_queue_family == pd_info.present_queue_family;
123 const families = [_]u32{ pd_info.graphics_queue_family, pd_info.present_queue_family };
124
125 const swapchain = try vkd.createSwapchainKHR(device, &vk.SwapchainCreateInfoKHR{
126 .surface = surface,
127 .min_image_count = image_count,
128 .image_format = chosen.format,
129 .image_color_space = chosen.color_space,
130 .image_extent = extent,
131 .image_array_layers = 1,
132 .image_usage = .{ .color_attachment_bit = true },
133 .image_sharing_mode = if (same_family) .exclusive else .concurrent,
134 .queue_family_index_count = if (same_family) 0 else 2,
135 .p_queue_family_indices = if (same_family) null else &families,
136 .pre_transform = caps.current_transform,
137 .composite_alpha = .{ .opaque_bit_khr = true },
138 .present_mode = .fifo_khr,
139 .clipped = .true,
140 }, null);
141
142 var sc_count: u32 = 0;
143 _ = try vkd.getSwapchainImagesKHR(device, swapchain, &sc_count, null);
144 const images = try alloc.alloc(vk.Image, sc_count);
145 errdefer alloc.free(images);
146 _ = try vkd.getSwapchainImagesKHR(device, swapchain, &sc_count, images.ptr);
147
148 const image_views = try alloc.alloc(vk.ImageView, sc_count);
149 errdefer {
150 // Clean up any views created before failure
151 alloc.free(image_views);
152 }
153 var views_created: usize = 0;
154 errdefer {
155 for (image_views[0..views_created]) |view| vkd.destroyImageView(device, view, null);
156 }
157
158 for (images[0..sc_count], 0..) |img, i| {
159 image_views[i] = try vkd.createImageView(device, &vk.ImageViewCreateInfo{
160 .image = img,
161 .view_type = .@"2d",
162 .format = chosen.format,
163 .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
164 .subresource_range = .{
165 .aspect_mask = .{ .color_bit = true },
166 .base_mip_level = 0,
167 .level_count = 1,
168 .base_array_layer = 0,
169 .layer_count = 1,
170 },
171 }, null);
172 views_created += 1;
173 }
174
175 return .{
176 .swapchain = swapchain,
177 .format = chosen.format,
178 .extent = extent,
179 .images = images,
180 .image_views = image_views,
181 };
182 }
183
184 pub const Context = struct {
185 alloc: std.mem.Allocator,
186 vkb: vk.BaseWrapper,
187 instance: vk.Instance,
188 vki: vk.InstanceWrapper,
189 surface: vk.SurfaceKHR,
190 physical_device: vk.PhysicalDevice,
191 graphics_queue_family: u32,
192 present_queue_family: u32,
193 device: vk.Device,
194 vkd: vk.DeviceWrapper,
195 graphics_queue: vk.Queue,
196 present_queue: vk.Queue,
197 swapchain: vk.SwapchainKHR,
198 swapchain_format: vk.Format,
199 swapchain_extent: vk.Extent2D,
200 swapchain_images: []vk.Image,
201 swapchain_image_views: []vk.ImageView,
202
203 pub fn init(
204 alloc: std.mem.Allocator,
205 wl_display: *anyopaque,
206 wl_surface: *anyopaque,
207 width: u32,
208 height: u32,
209 ) !Context {
210 const get_proc_addr = try getVkGetInstanceProcAddr();
211 const vkb = vk.BaseWrapper.load(get_proc_addr);
212
213 // Create instance
214 const app_info = vk.ApplicationInfo{
215 .p_application_name = "waystty",
216 .application_version = @bitCast(vk.makeApiVersion(0, 0, 0, 1)),
217 .p_engine_name = "waystty",
218 .engine_version = @bitCast(vk.makeApiVersion(0, 0, 0, 1)),
219 .api_version = @bitCast(vk.API_VERSION_1_2),
220 };
221
222 const instance_exts = [_][*:0]const u8{
223 vk.extensions.khr_surface.name,
224 vk.extensions.khr_wayland_surface.name,
225 };
226
227 const instance = try vkb.createInstance(&vk.InstanceCreateInfo{
228 .p_application_info = &app_info,
229 .enabled_extension_count = instance_exts.len,
230 .pp_enabled_extension_names = &instance_exts,
231 }, null);
232
233 const vki = vk.InstanceWrapper.load(instance, vkb.dispatch.vkGetInstanceProcAddr.?);
234 errdefer vki.destroyInstance(instance, null);
235
236 // Create wayland surface
237 const surface = try vki.createWaylandSurfaceKHR(instance, &vk.WaylandSurfaceCreateInfoKHR{
238 .display = @ptrCast(wl_display),
239 .surface = @ptrCast(wl_surface),
240 }, null);
241 errdefer vki.destroySurfaceKHR(instance, surface, null);
242
243 // Pick physical device + queue families
244 const pd_info = try pickPhysicalDevice(alloc, vki, instance, surface);
245
246 // Create logical device
247 const priority: f32 = 1.0;
248 var queue_create_infos: [2]vk.DeviceQueueCreateInfo = undefined;
249 var queue_count: u32 = 1;
250 queue_create_infos[0] = .{
251 .queue_family_index = pd_info.graphics_queue_family,
252 .queue_count = 1,
253 .p_queue_priorities = @ptrCast(&priority),
254 };
255 if (pd_info.graphics_queue_family != pd_info.present_queue_family) {
256 queue_create_infos[1] = .{
257 .queue_family_index = pd_info.present_queue_family,
258 .queue_count = 1,
259 .p_queue_priorities = @ptrCast(&priority),
260 };
261 queue_count = 2;
262 }
263
264 const device_exts = [_][*:0]const u8{vk.extensions.khr_swapchain.name};
265
266 const empty_layer_name: *const u8 = @ptrFromInt(1); // dummy, count=0 so never dereferenced
267 const device = try vki.createDevice(pd_info.physical, &vk.DeviceCreateInfo{
268 .queue_create_info_count = queue_count,
269 .p_queue_create_infos = &queue_create_infos,
270 .enabled_layer_count = 0,
271 .pp_enabled_layer_names = &empty_layer_name,
272 .enabled_extension_count = device_exts.len,
273 .pp_enabled_extension_names = &device_exts,
274 }, null);
275
276 const vkd = vk.DeviceWrapper.load(device, vki.dispatch.vkGetDeviceProcAddr.?);
277 errdefer vkd.destroyDevice(device, null);
278
279 const graphics_queue = vkd.getDeviceQueue(device, pd_info.graphics_queue_family, 0);
280 const present_queue = vkd.getDeviceQueue(device, pd_info.present_queue_family, 0);
281
282 // Create swapchain
283 const sc = try createSwapchain(alloc, vki, vkd, pd_info, surface, device, width, height);
284 errdefer {
285 for (sc.image_views) |view| vkd.destroyImageView(device, view, null);
286 alloc.free(sc.image_views);
287 alloc.free(sc.images);
288 vkd.destroySwapchainKHR(device, sc.swapchain, null);
289 }
290
291 return .{
292 .alloc = alloc,
293 .vkb = vkb,
294 .instance = instance,
295 .vki = vki,
296 .surface = surface,
297 .physical_device = pd_info.physical,
298 .graphics_queue_family = pd_info.graphics_queue_family,
299 .present_queue_family = pd_info.present_queue_family,
300 .device = device,
301 .vkd = vkd,
302 .graphics_queue = graphics_queue,
303 .present_queue = present_queue,
304 .swapchain = sc.swapchain,
305 .swapchain_format = sc.format,
306 .swapchain_extent = sc.extent,
307 .swapchain_images = sc.images,
308 .swapchain_image_views = sc.image_views,
309 };
310 }
311
312 pub fn deinit(self: *Context) void {
313 for (self.swapchain_image_views) |view| self.vkd.destroyImageView(self.device, view, null);
314 self.alloc.free(self.swapchain_image_views);
315 self.alloc.free(self.swapchain_images);
316 self.vkd.destroySwapchainKHR(self.device, self.swapchain, null);
317 self.vkd.destroyDevice(self.device, null);
318 self.vki.destroySurfaceKHR(self.instance, self.surface, null);
319 self.vki.destroyInstance(self.instance, null);
320 }
321 };
322
7 test "vulkan module imports" { 323 test "vulkan module imports" {
8 _ = vk; 324 _ = vk;
9 } 325 }