a73x

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" {