a73x

ff8b9f51

feat(renderer): render pass + pipeline + clear-and-present loop

a73x   2026-04-08 09:07

Commit message
feat(renderer): render pass + pipeline + clear-and-present loop

Extends Context with render pass, framebuffers, graphics pipeline,
descriptor set layout/pool, command pool/buffer, and sync primitives
(semaphores + fence). Adds drawClear() method that clears to a solid
color each frame. Adds --render-smoke-test to main.zig that renders 60
color-shifting frames and exits cleanly, validating the full present path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

src/main.zig
Old New
@@ -24,9 +24,44 @@ pub fn main() !void {
24 return runVulkanSmokeTest(alloc); 24 return runVulkanSmokeTest(alloc);
25 } 25 }
26 26
27 if (args.len >= 2 and std.mem.eql(u8, args[1], "--render-smoke-test")) {
28 return runRenderSmokeTest(alloc);
29 }
30
27 std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{}); 31 std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{});
28 } 32 }
29 33
34 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
35 var conn = try wayland_client.Connection.init();
36 defer conn.deinit();
37 std.debug.print("wayland connected\n", .{});
38
39 const window = try conn.createWindow(alloc, "waystty-render-smoke");
40 defer window.deinit();
41 std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });
42
43 _ = conn.display.roundtrip();
44
45 var ctx = try renderer.Context.init(
46 alloc,
47 @ptrCast(conn.display),
48 @ptrCast(window.surface),
49 window.width,
50 window.height,
51 );
52 defer ctx.deinit();
53
54 std.debug.print("rendering 60 frames...\n", .{});
55 var i: u32 = 0;
56 while (i < 60) : (i += 1) {
57 _ = conn.display.dispatchPending();
58 const t: f32 = @as(f32, @floatFromInt(i)) / 60.0;
59 try ctx.drawClear(.{ t, 0.5, 1.0 - t, 1.0 });
60 }
61 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
62 std.debug.print("done\n", .{});
63 }
64
30 fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void { 65 fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
31 var conn = try wayland_client.Connection.init(); 66 var conn = try wayland_client.Connection.init();
32 defer conn.deinit(); 67 defer conn.deinit();
src/renderer.zig
Old New
@@ -181,6 +181,25 @@ fn createSwapchain(
181 }; 181 };
182 } 182 }
183 183
184 /// Push constants layout matching cell.vert
185 pub const PushConstants = extern struct {
186 viewport_size: [2]f32,
187 cell_size: [2]f32,
188 };
189
190 /// Per-vertex data (binding 0, per-vertex rate)
191 pub const Vertex = extern struct {
192 unit_pos: [2]f32, // location 0
193 };
194
195 /// Per-instance data (binding 1, per-instance rate)
196 pub const Instance = extern struct {
197 cell_pos: [2]f32, // location 1
198 uv_rect: [4]f32, // location 2
199 fg: [4]f32, // location 3
200 bg: [4]f32, // location 4
201 };
202
184 pub const Context = struct { 203 pub const Context = struct {
185 alloc: std.mem.Allocator, 204 alloc: std.mem.Allocator,
186 vkb: vk.BaseWrapper, 205 vkb: vk.BaseWrapper,
@@ -199,6 +218,23 @@ pub const Context = struct {
199 swapchain_extent: vk.Extent2D, 218 swapchain_extent: vk.Extent2D,
200 swapchain_images: []vk.Image, 219 swapchain_images: []vk.Image,
201 swapchain_image_views: []vk.ImageView, 220 swapchain_image_views: []vk.ImageView,
221 // Render pass + framebuffers
222 render_pass: vk.RenderPass,
223 framebuffers: []vk.Framebuffer,
224 // Descriptor set layout + pool + set
225 descriptor_set_layout: vk.DescriptorSetLayout,
226 descriptor_pool: vk.DescriptorPool,
227 descriptor_set: vk.DescriptorSet,
228 // Pipeline
229 pipeline_layout: vk.PipelineLayout,
230 pipeline: vk.Pipeline,
231 // Commands
232 command_pool: vk.CommandPool,
233 command_buffer: vk.CommandBuffer,
234 // Sync
235 image_available: vk.Semaphore,
236 render_finished: vk.Semaphore,
237 in_flight_fence: vk.Fence,
202 238
203 pub fn init( 239 pub fn init(
204 alloc: std.mem.Allocator, 240 alloc: std.mem.Allocator,
@@ -288,6 +324,264 @@ pub const Context = struct {
288 vkd.destroySwapchainKHR(device, sc.swapchain, null); 324 vkd.destroySwapchainKHR(device, sc.swapchain, null);
289 } 325 }
290 326
327 // Create render pass
328 const color_attachment = vk.AttachmentDescription{
329 .format = sc.format,
330 .samples = .{ .@"1_bit" = true },
331 .load_op = .clear,
332 .store_op = .store,
333 .stencil_load_op = .dont_care,
334 .stencil_store_op = .dont_care,
335 .initial_layout = .undefined,
336 .final_layout = .present_src_khr,
337 };
338
339 const color_ref = vk.AttachmentReference{
340 .attachment = 0,
341 .layout = .color_attachment_optimal,
342 };
343
344 const subpass = vk.SubpassDescription{
345 .pipeline_bind_point = .graphics,
346 .color_attachment_count = 1,
347 .p_color_attachments = @ptrCast(&color_ref),
348 };
349
350 const dep = vk.SubpassDependency{
351 .src_subpass = vk.SUBPASS_EXTERNAL,
352 .dst_subpass = 0,
353 .src_stage_mask = .{ .color_attachment_output_bit = true },
354 .dst_stage_mask = .{ .color_attachment_output_bit = true },
355 .src_access_mask = .{},
356 .dst_access_mask = .{ .color_attachment_write_bit = true },
357 };
358
359 const render_pass = try vkd.createRenderPass(device, &vk.RenderPassCreateInfo{
360 .attachment_count = 1,
361 .p_attachments = @ptrCast(&color_attachment),
362 .subpass_count = 1,
363 .p_subpasses = @ptrCast(&subpass),
364 .dependency_count = 1,
365 .p_dependencies = @ptrCast(&dep),
366 }, null);
367 errdefer vkd.destroyRenderPass(device, render_pass, null);
368
369 // Create framebuffers (one per swapchain image view)
370 const framebuffers = try alloc.alloc(vk.Framebuffer, sc.image_views.len);
371 errdefer alloc.free(framebuffers);
372 var fbs_created: usize = 0;
373 errdefer {
374 for (framebuffers[0..fbs_created]) |fb| vkd.destroyFramebuffer(device, fb, null);
375 }
376 for (sc.image_views, 0..) |view, i| {
377 framebuffers[i] = try vkd.createFramebuffer(device, &vk.FramebufferCreateInfo{
378 .render_pass = render_pass,
379 .attachment_count = 1,
380 .p_attachments = @ptrCast(&view),
381 .width = sc.extent.width,
382 .height = sc.extent.height,
383 .layers = 1,
384 }, null);
385 fbs_created += 1;
386 }
387
388 // Create descriptor set layout (single combined image sampler for glyph atlas)
389 const dsl_binding = vk.DescriptorSetLayoutBinding{
390 .binding = 0,
391 .descriptor_type = .combined_image_sampler,
392 .descriptor_count = 1,
393 .stage_flags = .{ .fragment_bit = true },
394 };
395 const descriptor_set_layout = try vkd.createDescriptorSetLayout(device, &vk.DescriptorSetLayoutCreateInfo{
396 .binding_count = 1,
397 .p_bindings = @ptrCast(&dsl_binding),
398 }, null);
399 errdefer vkd.destroyDescriptorSetLayout(device, descriptor_set_layout, null);
400
401 // Create pipeline layout (push constants + descriptor set)
402 const push_range = vk.PushConstantRange{
403 .stage_flags = .{ .vertex_bit = true },
404 .offset = 0,
405 .size = @sizeOf(PushConstants),
406 };
407 const pipeline_layout = try vkd.createPipelineLayout(device, &vk.PipelineLayoutCreateInfo{
408 .set_layout_count = 1,
409 .p_set_layouts = @ptrCast(&descriptor_set_layout),
410 .push_constant_range_count = 1,
411 .p_push_constant_ranges = @ptrCast(&push_range),
412 }, null);
413 errdefer vkd.destroyPipelineLayout(device, pipeline_layout, null);
414
415 // Create shader modules
416 const vert_module = try vkd.createShaderModule(device, &vk.ShaderModuleCreateInfo{
417 .code_size = cell_vert_spv.len,
418 .p_code = @ptrCast(@alignCast(cell_vert_spv.ptr)),
419 }, null);
420 defer vkd.destroyShaderModule(device, vert_module, null);
421
422 const frag_module = try vkd.createShaderModule(device, &vk.ShaderModuleCreateInfo{
423 .code_size = cell_frag_spv.len,
424 .p_code = @ptrCast(@alignCast(cell_frag_spv.ptr)),
425 }, null);
426 defer vkd.destroyShaderModule(device, frag_module, null);
427
428 // Shader stages
429 const shader_stages = [_]vk.PipelineShaderStageCreateInfo{
430 .{
431 .stage = .{ .vertex_bit = true },
432 .module = vert_module,
433 .p_name = "main",
434 },
435 .{
436 .stage = .{ .fragment_bit = true },
437 .module = frag_module,
438 .p_name = "main",
439 },
440 };
441
442 // Vertex input
443 const binding_descs = [_]vk.VertexInputBindingDescription{
444 .{ .binding = 0, .stride = @sizeOf(Vertex), .input_rate = .vertex },
445 .{ .binding = 1, .stride = @sizeOf(Instance), .input_rate = .instance },
446 };
447
448 const attr_descs = [_]vk.VertexInputAttributeDescription{
449 .{ .location = 0, .binding = 0, .format = .r32g32_sfloat, .offset = 0 },
450 .{ .location = 1, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "cell_pos") },
451 .{ .location = 2, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "uv_rect") },
452 .{ .location = 3, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "fg") },
453 .{ .location = 4, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "bg") },
454 };
455
456 const vertex_input_info = vk.PipelineVertexInputStateCreateInfo{
457 .vertex_binding_description_count = binding_descs.len,
458 .p_vertex_binding_descriptions = &binding_descs,
459 .vertex_attribute_description_count = attr_descs.len,
460 .p_vertex_attribute_descriptions = &attr_descs,
461 };
462
463 const input_assembly = vk.PipelineInputAssemblyStateCreateInfo{
464 .topology = .triangle_list,
465 .primitive_restart_enable = .false,
466 };
467
468 // Dynamic viewport + scissor (set at draw time)
469 const dynamic_states = [_]vk.DynamicState{ .viewport, .scissor };
470 const dynamic_state = vk.PipelineDynamicStateCreateInfo{
471 .dynamic_state_count = dynamic_states.len,
472 .p_dynamic_states = &dynamic_states,
473 };
474
475 const viewport_state = vk.PipelineViewportStateCreateInfo{
476 .viewport_count = 1,
477 .scissor_count = 1,
478 };
479
480 const rasterizer = vk.PipelineRasterizationStateCreateInfo{
481 .depth_clamp_enable = .false,
482 .rasterizer_discard_enable = .false,
483 .polygon_mode = .fill,
484 .cull_mode = .{ .back_bit = true },
485 .front_face = .clockwise,
486 .depth_bias_enable = .false,
487 .depth_bias_constant_factor = 0.0,
488 .depth_bias_clamp = 0.0,
489 .depth_bias_slope_factor = 0.0,
490 .line_width = 1.0,
491 };
492
493 const multisampling = vk.PipelineMultisampleStateCreateInfo{
494 .rasterization_samples = .{ .@"1_bit" = true },
495 .sample_shading_enable = .false,
496 .min_sample_shading = 1.0,
497 .alpha_to_coverage_enable = .false,
498 .alpha_to_one_enable = .false,
499 };
500
501 const color_blend_attachment = vk.PipelineColorBlendAttachmentState{
502 .blend_enable = .true,
503 .src_color_blend_factor = .src_alpha,
504 .dst_color_blend_factor = .one_minus_src_alpha,
505 .color_blend_op = .add,
506 .src_alpha_blend_factor = .one,
507 .dst_alpha_blend_factor = .zero,
508 .alpha_blend_op = .add,
509 .color_write_mask = .{ .r_bit = true, .g_bit = true, .b_bit = true, .a_bit = true },
510 };
511
512 const color_blend = vk.PipelineColorBlendStateCreateInfo{
513 .logic_op_enable = .false,
514 .logic_op = .copy,
515 .attachment_count = 1,
516 .p_attachments = @ptrCast(&color_blend_attachment),
517 .blend_constants = .{ 0.0, 0.0, 0.0, 0.0 },
518 };
519
520 const pipeline_create_info = vk.GraphicsPipelineCreateInfo{
521 .stage_count = shader_stages.len,
522 .p_stages = &shader_stages,
523 .p_vertex_input_state = &vertex_input_info,
524 .p_input_assembly_state = &input_assembly,
525 .p_viewport_state = &viewport_state,
526 .p_rasterization_state = &rasterizer,
527 .p_multisample_state = &multisampling,
528 .p_color_blend_state = &color_blend,
529 .p_dynamic_state = &dynamic_state,
530 .layout = pipeline_layout,
531 .render_pass = render_pass,
532 .subpass = 0,
533 .base_pipeline_index = -1,
534 };
535
536 var pipeline: vk.Pipeline = undefined;
537 _ = try vkd.createGraphicsPipelines(device, .null_handle, 1, @ptrCast(&pipeline_create_info), null, @ptrCast(&pipeline));
538 errdefer vkd.destroyPipeline(device, pipeline, null);
539
540 // Descriptor pool + set
541 const pool_size = vk.DescriptorPoolSize{
542 .type = .combined_image_sampler,
543 .descriptor_count = 1,
544 };
545 const descriptor_pool = try vkd.createDescriptorPool(device, &vk.DescriptorPoolCreateInfo{
546 .max_sets = 1,
547 .pool_size_count = 1,
548 .p_pool_sizes = @ptrCast(&pool_size),
549 }, null);
550 errdefer vkd.destroyDescriptorPool(device, descriptor_pool, null);
551
552 var descriptor_set: vk.DescriptorSet = undefined;
553 try vkd.allocateDescriptorSets(device, &vk.DescriptorSetAllocateInfo{
554 .descriptor_pool = descriptor_pool,
555 .descriptor_set_count = 1,
556 .p_set_layouts = @ptrCast(&descriptor_set_layout),
557 }, @ptrCast(&descriptor_set));
558
559 // Command pool + buffer
560 const command_pool = try vkd.createCommandPool(device, &vk.CommandPoolCreateInfo{
561 .flags = .{ .reset_command_buffer_bit = true },
562 .queue_family_index = pd_info.graphics_queue_family,
563 }, null);
564 errdefer vkd.destroyCommandPool(device, command_pool, null);
565
566 var command_buffer: vk.CommandBuffer = undefined;
567 try vkd.allocateCommandBuffers(device, &vk.CommandBufferAllocateInfo{
568 .command_pool = command_pool,
569 .level = .primary,
570 .command_buffer_count = 1,
571 }, @ptrCast(&command_buffer));
572
573 // Sync objects
574 const image_available = try vkd.createSemaphore(device, &vk.SemaphoreCreateInfo{}, null);
575 errdefer vkd.destroySemaphore(device, image_available, null);
576
577 const render_finished = try vkd.createSemaphore(device, &vk.SemaphoreCreateInfo{}, null);
578 errdefer vkd.destroySemaphore(device, render_finished, null);
579
580 const in_flight_fence = try vkd.createFence(device, &vk.FenceCreateInfo{
581 .flags = .{ .signaled_bit = true }, // start signaled so first wait returns immediately
582 }, null);
583 errdefer vkd.destroyFence(device, in_flight_fence, null);
584
291 return .{ 585 return .{
292 .alloc = alloc, 586 .alloc = alloc,
293 .vkb = vkb, 587 .vkb = vkb,
@@ -306,18 +600,124 @@ pub const Context = struct {
306 .swapchain_extent = sc.extent, 600 .swapchain_extent = sc.extent,
307 .swapchain_images = sc.images, 601 .swapchain_images = sc.images,
308 .swapchain_image_views = sc.image_views, 602 .swapchain_image_views = sc.image_views,
603 .render_pass = render_pass,
604 .framebuffers = framebuffers,
605 .descriptor_set_layout = descriptor_set_layout,
606 .descriptor_pool = descriptor_pool,
607 .descriptor_set = descriptor_set,
608 .pipeline_layout = pipeline_layout,
609 .pipeline = pipeline,
610 .command_pool = command_pool,
611 .command_buffer = command_buffer,
612 .image_available = image_available,
613 .render_finished = render_finished,
614 .in_flight_fence = in_flight_fence,
309 }; 615 };
310 } 616 }
311 617
312 pub fn deinit(self: *Context) void { 618 pub fn deinit(self: *Context) void {
619 // Wait for device to be idle before destroying anything
620 _ = self.vkd.deviceWaitIdle(self.device) catch {};
621
622 // Sync objects
623 self.vkd.destroyFence(self.device, self.in_flight_fence, null);
624 self.vkd.destroySemaphore(self.device, self.render_finished, null);
625 self.vkd.destroySemaphore(self.device, self.image_available, null);
626
627 // Command pool (also frees command buffers)
628 self.vkd.destroyCommandPool(self.device, self.command_pool, null);
629
630 // Pipeline
631 self.vkd.destroyPipeline(self.device, self.pipeline, null);
632 self.vkd.destroyPipelineLayout(self.device, self.pipeline_layout, null);
633
634 // Descriptor pool (also frees descriptor sets) + layout
635 self.vkd.destroyDescriptorPool(self.device, self.descriptor_pool, null);
636 self.vkd.destroyDescriptorSetLayout(self.device, self.descriptor_set_layout, null);
637
638 // Framebuffers
639 for (self.framebuffers) |fb| self.vkd.destroyFramebuffer(self.device, fb, null);
640 self.alloc.free(self.framebuffers);
641
642 // Render pass
643 self.vkd.destroyRenderPass(self.device, self.render_pass, null);
644
645 // Swapchain
313 for (self.swapchain_image_views) |view| self.vkd.destroyImageView(self.device, view, null); 646 for (self.swapchain_image_views) |view| self.vkd.destroyImageView(self.device, view, null);
314 self.alloc.free(self.swapchain_image_views); 647 self.alloc.free(self.swapchain_image_views);
315 self.alloc.free(self.swapchain_images); 648 self.alloc.free(self.swapchain_images);
316 self.vkd.destroySwapchainKHR(self.device, self.swapchain, null); 649 self.vkd.destroySwapchainKHR(self.device, self.swapchain, null);
650
317 self.vkd.destroyDevice(self.device, null); 651 self.vkd.destroyDevice(self.device, null);
318 self.vki.destroySurfaceKHR(self.instance, self.surface, null); 652 self.vki.destroySurfaceKHR(self.instance, self.surface, null);
319 self.vki.destroyInstance(self.instance, null); 653 self.vki.destroyInstance(self.instance, null);
320 } 654 }
655
656 /// Record a command buffer that begins the render pass with the given clear color and presents.
657 /// Does not bind the pipeline or draw — just clear + present.
658 /// Blocks until the previous frame's fence signals.
659 pub fn drawClear(self: *Context, clear_color: [4]f32) !void {
660 // Wait for previous frame to finish
661 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64));
662 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.in_flight_fence));
663
664 // Acquire next image
665 const acquire = try self.vkd.acquireNextImageKHR(
666 self.device,
667 self.swapchain,
668 std.math.maxInt(u64),
669 self.image_available,
670 .null_handle,
671 );
672 const image_index = acquire.image_index;
673
674 // Record command buffer
675 try self.vkd.resetCommandBuffer(self.command_buffer, .{});
676 try self.vkd.beginCommandBuffer(self.command_buffer, &vk.CommandBufferBeginInfo{
677 .flags = .{ .one_time_submit_bit = true },
678 });
679
680 const clear_value = vk.ClearValue{
681 .color = .{ .float_32 = clear_color },
682 };
683
684 self.vkd.cmdBeginRenderPass(self.command_buffer, &vk.RenderPassBeginInfo{
685 .render_pass = self.render_pass,
686 .framebuffer = self.framebuffers[image_index],
687 .render_area = .{
688 .offset = .{ .x = 0, .y = 0 },
689 .extent = self.swapchain_extent,
690 },
691 .clear_value_count = 1,
692 .p_clear_values = @ptrCast(&clear_value),
693 }, .@"inline");
694
695 // Don't bind pipeline or draw — just clear.
696
697 self.vkd.cmdEndRenderPass(self.command_buffer);
698 try self.vkd.endCommandBuffer(self.command_buffer);
699
700 // Submit
701 const wait_stage = vk.PipelineStageFlags{ .color_attachment_output_bit = true };
702 try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
703 .wait_semaphore_count = 1,
704 .p_wait_semaphores = @ptrCast(&self.image_available),
705 .p_wait_dst_stage_mask = @ptrCast(&wait_stage),
706 .command_buffer_count = 1,
707 .p_command_buffers = @ptrCast(&self.command_buffer),
708 .signal_semaphore_count = 1,
709 .p_signal_semaphores = @ptrCast(&self.render_finished),
710 }), self.in_flight_fence);
711
712 // Present
713 _ = try self.vkd.queuePresentKHR(self.present_queue, &vk.PresentInfoKHR{
714 .wait_semaphore_count = 1,
715 .p_wait_semaphores = @ptrCast(&self.render_finished),
716 .swapchain_count = 1,
717 .p_swapchains = @ptrCast(&self.swapchain),
718 .p_image_indices = @ptrCast(&image_index),
719 });
720 }
321 }; 721 };
322 722
323 test "vulkan module imports" { 723 test "vulkan module imports" {