ff8b9f51
feat(renderer): render pass + pipeline + clear-and-present loop
a73x 2026-04-08 09:07
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>
diff --git a/src/main.zig b/src/main.zig index e644bdc..0cf4c25 100644 --- a/src/main.zig +++ b/src/main.zig @@ -24,9 +24,44 @@ pub fn main() !void { return runVulkanSmokeTest(alloc); } if (args.len >= 2 and std.mem.eql(u8, args[1], "--render-smoke-test")) { return runRenderSmokeTest(alloc); } std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{}); } fn runRenderSmokeTest(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-render-smoke"); defer window.deinit(); std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height }); _ = 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("rendering 60 frames...\n", .{}); var i: u32 = 0; while (i < 60) : (i += 1) { _ = conn.display.dispatchPending(); const t: f32 = @as(f32, @floatFromInt(i)) / 60.0; try ctx.drawClear(.{ t, 0.5, 1.0 - t, 1.0 }); } _ = try ctx.vkd.deviceWaitIdle(ctx.device); std.debug.print("done\n", .{}); } fn runVulkanSmokeTest(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 e21751d..c5c6161 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -181,6 +181,25 @@ fn createSwapchain( }; } /// Push constants layout matching cell.vert pub const PushConstants = extern struct { viewport_size: [2]f32, cell_size: [2]f32, }; /// Per-vertex data (binding 0, per-vertex rate) pub const Vertex = extern struct { unit_pos: [2]f32, // location 0 }; /// Per-instance data (binding 1, per-instance rate) pub const Instance = extern struct { cell_pos: [2]f32, // location 1 uv_rect: [4]f32, // location 2 fg: [4]f32, // location 3 bg: [4]f32, // location 4 }; pub const Context = struct { alloc: std.mem.Allocator, vkb: vk.BaseWrapper, @@ -199,6 +218,23 @@ pub const Context = struct { swapchain_extent: vk.Extent2D, swapchain_images: []vk.Image, swapchain_image_views: []vk.ImageView, // Render pass + framebuffers render_pass: vk.RenderPass, framebuffers: []vk.Framebuffer, // Descriptor set layout + pool + set descriptor_set_layout: vk.DescriptorSetLayout, descriptor_pool: vk.DescriptorPool, descriptor_set: vk.DescriptorSet, // Pipeline pipeline_layout: vk.PipelineLayout, pipeline: vk.Pipeline, // Commands command_pool: vk.CommandPool, command_buffer: vk.CommandBuffer, // Sync image_available: vk.Semaphore, render_finished: vk.Semaphore, in_flight_fence: vk.Fence, pub fn init( alloc: std.mem.Allocator, @@ -288,6 +324,264 @@ pub const Context = struct { vkd.destroySwapchainKHR(device, sc.swapchain, null); } // Create render pass const color_attachment = vk.AttachmentDescription{ .format = sc.format, .samples = .{ .@"1_bit" = true }, .load_op = .clear, .store_op = .store, .stencil_load_op = .dont_care, .stencil_store_op = .dont_care, .initial_layout = .undefined, .final_layout = .present_src_khr, }; const color_ref = vk.AttachmentReference{ .attachment = 0, .layout = .color_attachment_optimal, }; const subpass = vk.SubpassDescription{ .pipeline_bind_point = .graphics, .color_attachment_count = 1, .p_color_attachments = @ptrCast(&color_ref), }; const dep = vk.SubpassDependency{ .src_subpass = vk.SUBPASS_EXTERNAL, .dst_subpass = 0, .src_stage_mask = .{ .color_attachment_output_bit = true }, .dst_stage_mask = .{ .color_attachment_output_bit = true }, .src_access_mask = .{}, .dst_access_mask = .{ .color_attachment_write_bit = true }, }; const render_pass = try vkd.createRenderPass(device, &vk.RenderPassCreateInfo{ .attachment_count = 1, .p_attachments = @ptrCast(&color_attachment), .subpass_count = 1, .p_subpasses = @ptrCast(&subpass), .dependency_count = 1, .p_dependencies = @ptrCast(&dep), }, null); errdefer vkd.destroyRenderPass(device, render_pass, null); // Create framebuffers (one per swapchain image view) const framebuffers = try alloc.alloc(vk.Framebuffer, sc.image_views.len); errdefer alloc.free(framebuffers); var fbs_created: usize = 0; errdefer { for (framebuffers[0..fbs_created]) |fb| vkd.destroyFramebuffer(device, fb, null); } for (sc.image_views, 0..) |view, i| { framebuffers[i] = try vkd.createFramebuffer(device, &vk.FramebufferCreateInfo{ .render_pass = render_pass, .attachment_count = 1, .p_attachments = @ptrCast(&view), .width = sc.extent.width, .height = sc.extent.height, .layers = 1, }, null); fbs_created += 1; } // Create descriptor set layout (single combined image sampler for glyph atlas) const dsl_binding = vk.DescriptorSetLayoutBinding{ .binding = 0, .descriptor_type = .combined_image_sampler, .descriptor_count = 1, .stage_flags = .{ .fragment_bit = true }, }; const descriptor_set_layout = try vkd.createDescriptorSetLayout(device, &vk.DescriptorSetLayoutCreateInfo{ .binding_count = 1, .p_bindings = @ptrCast(&dsl_binding), }, null); errdefer vkd.destroyDescriptorSetLayout(device, descriptor_set_layout, null); // Create pipeline layout (push constants + descriptor set) const push_range = vk.PushConstantRange{ .stage_flags = .{ .vertex_bit = true }, .offset = 0, .size = @sizeOf(PushConstants), }; const pipeline_layout = try vkd.createPipelineLayout(device, &vk.PipelineLayoutCreateInfo{ .set_layout_count = 1, .p_set_layouts = @ptrCast(&descriptor_set_layout), .push_constant_range_count = 1, .p_push_constant_ranges = @ptrCast(&push_range), }, null); errdefer vkd.destroyPipelineLayout(device, pipeline_layout, null); // Create shader modules const vert_module = try vkd.createShaderModule(device, &vk.ShaderModuleCreateInfo{ .code_size = cell_vert_spv.len, .p_code = @ptrCast(@alignCast(cell_vert_spv.ptr)), }, null); defer vkd.destroyShaderModule(device, vert_module, null); const frag_module = try vkd.createShaderModule(device, &vk.ShaderModuleCreateInfo{ .code_size = cell_frag_spv.len, .p_code = @ptrCast(@alignCast(cell_frag_spv.ptr)), }, null); defer vkd.destroyShaderModule(device, frag_module, null); // Shader stages const shader_stages = [_]vk.PipelineShaderStageCreateInfo{ .{ .stage = .{ .vertex_bit = true }, .module = vert_module, .p_name = "main", }, .{ .stage = .{ .fragment_bit = true }, .module = frag_module, .p_name = "main", }, }; // Vertex input const binding_descs = [_]vk.VertexInputBindingDescription{ .{ .binding = 0, .stride = @sizeOf(Vertex), .input_rate = .vertex }, .{ .binding = 1, .stride = @sizeOf(Instance), .input_rate = .instance }, }; const attr_descs = [_]vk.VertexInputAttributeDescription{ .{ .location = 0, .binding = 0, .format = .r32g32_sfloat, .offset = 0 }, .{ .location = 1, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "cell_pos") }, .{ .location = 2, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "uv_rect") }, .{ .location = 3, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "fg") }, .{ .location = 4, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "bg") }, }; const vertex_input_info = vk.PipelineVertexInputStateCreateInfo{ .vertex_binding_description_count = binding_descs.len, .p_vertex_binding_descriptions = &binding_descs, .vertex_attribute_description_count = attr_descs.len, .p_vertex_attribute_descriptions = &attr_descs, }; const input_assembly = vk.PipelineInputAssemblyStateCreateInfo{ .topology = .triangle_list, .primitive_restart_enable = .false, }; // Dynamic viewport + scissor (set at draw time) const dynamic_states = [_]vk.DynamicState{ .viewport, .scissor }; const dynamic_state = vk.PipelineDynamicStateCreateInfo{ .dynamic_state_count = dynamic_states.len, .p_dynamic_states = &dynamic_states, }; const viewport_state = vk.PipelineViewportStateCreateInfo{ .viewport_count = 1, .scissor_count = 1, }; const rasterizer = vk.PipelineRasterizationStateCreateInfo{ .depth_clamp_enable = .false, .rasterizer_discard_enable = .false, .polygon_mode = .fill, .cull_mode = .{ .back_bit = true }, .front_face = .clockwise, .depth_bias_enable = .false, .depth_bias_constant_factor = 0.0, .depth_bias_clamp = 0.0, .depth_bias_slope_factor = 0.0, .line_width = 1.0, }; const multisampling = vk.PipelineMultisampleStateCreateInfo{ .rasterization_samples = .{ .@"1_bit" = true }, .sample_shading_enable = .false, .min_sample_shading = 1.0, .alpha_to_coverage_enable = .false, .alpha_to_one_enable = .false, }; const color_blend_attachment = vk.PipelineColorBlendAttachmentState{ .blend_enable = .true, .src_color_blend_factor = .src_alpha, .dst_color_blend_factor = .one_minus_src_alpha, .color_blend_op = .add, .src_alpha_blend_factor = .one, .dst_alpha_blend_factor = .zero, .alpha_blend_op = .add, .color_write_mask = .{ .r_bit = true, .g_bit = true, .b_bit = true, .a_bit = true }, }; const color_blend = vk.PipelineColorBlendStateCreateInfo{ .logic_op_enable = .false, .logic_op = .copy, .attachment_count = 1, .p_attachments = @ptrCast(&color_blend_attachment), .blend_constants = .{ 0.0, 0.0, 0.0, 0.0 }, }; const pipeline_create_info = vk.GraphicsPipelineCreateInfo{ .stage_count = shader_stages.len, .p_stages = &shader_stages, .p_vertex_input_state = &vertex_input_info, .p_input_assembly_state = &input_assembly, .p_viewport_state = &viewport_state, .p_rasterization_state = &rasterizer, .p_multisample_state = &multisampling, .p_color_blend_state = &color_blend, .p_dynamic_state = &dynamic_state, .layout = pipeline_layout, .render_pass = render_pass, .subpass = 0, .base_pipeline_index = -1, }; var pipeline: vk.Pipeline = undefined; _ = try vkd.createGraphicsPipelines(device, .null_handle, 1, @ptrCast(&pipeline_create_info), null, @ptrCast(&pipeline)); errdefer vkd.destroyPipeline(device, pipeline, null); // Descriptor pool + set const pool_size = vk.DescriptorPoolSize{ .type = .combined_image_sampler, .descriptor_count = 1, }; const descriptor_pool = try vkd.createDescriptorPool(device, &vk.DescriptorPoolCreateInfo{ .max_sets = 1, .pool_size_count = 1, .p_pool_sizes = @ptrCast(&pool_size), }, null); errdefer vkd.destroyDescriptorPool(device, descriptor_pool, null); var descriptor_set: vk.DescriptorSet = undefined; try vkd.allocateDescriptorSets(device, &vk.DescriptorSetAllocateInfo{ .descriptor_pool = descriptor_pool, .descriptor_set_count = 1, .p_set_layouts = @ptrCast(&descriptor_set_layout), }, @ptrCast(&descriptor_set)); // Command pool + buffer const command_pool = try vkd.createCommandPool(device, &vk.CommandPoolCreateInfo{ .flags = .{ .reset_command_buffer_bit = true }, .queue_family_index = pd_info.graphics_queue_family, }, null); errdefer vkd.destroyCommandPool(device, command_pool, null); var command_buffer: vk.CommandBuffer = undefined; try vkd.allocateCommandBuffers(device, &vk.CommandBufferAllocateInfo{ .command_pool = command_pool, .level = .primary, .command_buffer_count = 1, }, @ptrCast(&command_buffer)); // Sync objects const image_available = try vkd.createSemaphore(device, &vk.SemaphoreCreateInfo{}, null); errdefer vkd.destroySemaphore(device, image_available, null); const render_finished = try vkd.createSemaphore(device, &vk.SemaphoreCreateInfo{}, null); errdefer vkd.destroySemaphore(device, render_finished, null); const in_flight_fence = try vkd.createFence(device, &vk.FenceCreateInfo{ .flags = .{ .signaled_bit = true }, // start signaled so first wait returns immediately }, null); errdefer vkd.destroyFence(device, in_flight_fence, null); return .{ .alloc = alloc, .vkb = vkb, @@ -306,18 +600,124 @@ pub const Context = struct { .swapchain_extent = sc.extent, .swapchain_images = sc.images, .swapchain_image_views = sc.image_views, .render_pass = render_pass, .framebuffers = framebuffers, .descriptor_set_layout = descriptor_set_layout, .descriptor_pool = descriptor_pool, .descriptor_set = descriptor_set, .pipeline_layout = pipeline_layout, .pipeline = pipeline, .command_pool = command_pool, .command_buffer = command_buffer, .image_available = image_available, .render_finished = render_finished, .in_flight_fence = in_flight_fence, }; } pub fn deinit(self: *Context) void { // Wait for device to be idle before destroying anything _ = self.vkd.deviceWaitIdle(self.device) catch {}; // Sync objects self.vkd.destroyFence(self.device, self.in_flight_fence, null); self.vkd.destroySemaphore(self.device, self.render_finished, null); self.vkd.destroySemaphore(self.device, self.image_available, null); // Command pool (also frees command buffers) self.vkd.destroyCommandPool(self.device, self.command_pool, null); // Pipeline self.vkd.destroyPipeline(self.device, self.pipeline, null); self.vkd.destroyPipelineLayout(self.device, self.pipeline_layout, null); // Descriptor pool (also frees descriptor sets) + layout self.vkd.destroyDescriptorPool(self.device, self.descriptor_pool, null); self.vkd.destroyDescriptorSetLayout(self.device, self.descriptor_set_layout, null); // Framebuffers for (self.framebuffers) |fb| self.vkd.destroyFramebuffer(self.device, fb, null); self.alloc.free(self.framebuffers); // Render pass self.vkd.destroyRenderPass(self.device, self.render_pass, null); // Swapchain 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); } /// Record a command buffer that begins the render pass with the given clear color and presents. /// Does not bind the pipeline or draw — just clear + present. /// Blocks until the previous frame's fence signals. pub fn drawClear(self: *Context, clear_color: [4]f32) !void { // Wait for previous frame to finish _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64)); try self.vkd.resetFences(self.device, 1, @ptrCast(&self.in_flight_fence)); // Acquire next image const acquire = try self.vkd.acquireNextImageKHR( self.device, self.swapchain, std.math.maxInt(u64), self.image_available, .null_handle, ); const image_index = acquire.image_index; // Record command buffer try self.vkd.resetCommandBuffer(self.command_buffer, .{}); try self.vkd.beginCommandBuffer(self.command_buffer, &vk.CommandBufferBeginInfo{ .flags = .{ .one_time_submit_bit = true }, }); const clear_value = vk.ClearValue{ .color = .{ .float_32 = clear_color }, }; self.vkd.cmdBeginRenderPass(self.command_buffer, &vk.RenderPassBeginInfo{ .render_pass = self.render_pass, .framebuffer = self.framebuffers[image_index], .render_area = .{ .offset = .{ .x = 0, .y = 0 }, .extent = self.swapchain_extent, }, .clear_value_count = 1, .p_clear_values = @ptrCast(&clear_value), }, .@"inline"); // Don't bind pipeline or draw — just clear. self.vkd.cmdEndRenderPass(self.command_buffer); try self.vkd.endCommandBuffer(self.command_buffer); // Submit const wait_stage = vk.PipelineStageFlags{ .color_attachment_output_bit = true }; try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{ .wait_semaphore_count = 1, .p_wait_semaphores = @ptrCast(&self.image_available), .p_wait_dst_stage_mask = @ptrCast(&wait_stage), .command_buffer_count = 1, .p_command_buffers = @ptrCast(&self.command_buffer), .signal_semaphore_count = 1, .p_signal_semaphores = @ptrCast(&self.render_finished), }), self.in_flight_fence); // Present _ = try self.vkd.queuePresentKHR(self.present_queue, &vk.PresentInfoKHR{ .wait_semaphore_count = 1, .p_wait_semaphores = @ptrCast(&self.render_finished), .swapchain_count = 1, .p_swapchains = @ptrCast(&self.swapchain), .p_image_indices = @ptrCast(&image_index), }); } }; test "vulkan module imports" {