a73x

2d429b7f

Add offscreen render target + readback to renderer

a73x   2026-04-17 15:14

New OffscreenTarget, createOffscreen/destroyOffscreen, renderToOffscreen,
and readbackOffscreen. Draws into a dedicated TRANSFER_SRC image,
copies to a host-visible buffer, returns RGBA pixels.

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

diff --git a/src/renderer.zig b/src/renderer.zig
index 3ee0f2b..118e483 100644
--- a/src/renderer.zig
+++ b/src/renderer.zig
@@ -424,6 +424,125 @@ fn swapchainNeedsRebuild(result: vk.Result) bool {
    return result == .suboptimal_khr;
}

/// Offscreen color attachment + readback staging buffer.
/// The image is allocated with COLOR_ATTACHMENT_BIT | TRANSFER_SRC_BIT so the
/// renderer can draw into it and then copy it into the host-visible readback
/// buffer. Created via `createOffscreen` and freed with `destroyOffscreen`.
pub const OffscreenTarget = struct {
    width: u32,
    height: u32,
    format: vk.Format,
    image: vk.Image,
    memory: vk.DeviceMemory,
    view: vk.ImageView,
    framebuffer: vk.Framebuffer,
    readback_buffer: vk.Buffer,
    readback_memory: vk.DeviceMemory,
    readback_size: u64,
};

/// Allocate an offscreen color-attachment image plus matching readback buffer.
/// The framebuffer is compatible with `render_pass` — pass the same render pass
/// the swapchain uses so the existing pipeline is reusable.
pub fn createOffscreen(
    vki: vk.InstanceWrapper,
    vkd: vk.DeviceWrapper,
    physical: vk.PhysicalDevice,
    device: vk.Device,
    render_pass: vk.RenderPass,
    format: vk.Format,
    width: u32,
    height: u32,
) !OffscreenTarget {
    // 1. Color attachment image with TRANSFER_SRC_BIT | COLOR_ATTACHMENT_BIT
    const image = try vkd.createImage(device, &vk.ImageCreateInfo{
        .image_type = .@"2d",
        .format = format,
        .extent = .{ .width = width, .height = height, .depth = 1 },
        .mip_levels = 1,
        .array_layers = 1,
        .samples = .{ .@"1_bit" = true },
        .tiling = .optimal,
        .usage = .{ .color_attachment_bit = true, .transfer_src_bit = true },
        .sharing_mode = .exclusive,
        .initial_layout = .undefined,
    }, null);
    errdefer vkd.destroyImage(device, image, null);

    // 2. Device-local memory bound
    const img_reqs = vkd.getImageMemoryRequirements(device, image);
    const img_mem_idx = try findMemoryType(vki, physical, img_reqs.memory_type_bits, .{ .device_local_bit = true });
    const memory = try vkd.allocateMemory(device, &vk.MemoryAllocateInfo{
        .allocation_size = img_reqs.size,
        .memory_type_index = img_mem_idx,
    }, null);
    errdefer vkd.freeMemory(device, memory, null);
    try vkd.bindImageMemory(device, image, memory, 0);

    // 3. ImageView + Framebuffer (using the provided render_pass)
    const view = try vkd.createImageView(device, &vk.ImageViewCreateInfo{
        .image = image,
        .view_type = .@"2d",
        .format = 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);
    errdefer vkd.destroyImageView(device, view, null);

    const framebuffer = try vkd.createFramebuffer(device, &vk.FramebufferCreateInfo{
        .render_pass = render_pass,
        .attachment_count = 1,
        .p_attachments = @ptrCast(&view),
        .width = width,
        .height = height,
        .layers = 1,
    }, null);
    errdefer vkd.destroyFramebuffer(device, framebuffer, null);

    // 4. Host-visible readback buffer (TRANSFER_DST, width*height*4 bytes)
    const readback_size: u64 = @as(u64, width) * @as(u64, height) * 4;
    const readback = try createHostVisibleBuffer(
        vki,
        physical,
        vkd,
        device,
        @intCast(readback_size),
        .{ .transfer_dst_bit = true },
    );
    errdefer {
        vkd.destroyBuffer(device, readback.buffer, null);
        vkd.freeMemory(device, readback.memory, null);
    }

    return .{
        .width = width,
        .height = height,
        .format = format,
        .image = image,
        .memory = memory,
        .view = view,
        .framebuffer = framebuffer,
        .readback_buffer = readback.buffer,
        .readback_memory = readback.memory,
        .readback_size = readback_size,
    };
}

pub fn destroyOffscreen(vkd: vk.DeviceWrapper, device: vk.Device, t: OffscreenTarget) void {
    vkd.destroyFramebuffer(device, t.framebuffer, null);
    vkd.destroyImageView(device, t.view, null);
    vkd.destroyImage(device, t.image, null);
    vkd.freeMemory(device, t.memory, null);
    vkd.destroyBuffer(device, t.readback_buffer, null);
    vkd.freeMemory(device, t.readback_memory, null);
}

pub const Context = struct {
    alloc: std.mem.Allocator,
    vkb: vk.BaseWrapper,
@@ -479,6 +598,9 @@ pub const Context = struct {
    // Dedicated transfer command buffer + fence
    atlas_transfer_cb: vk.CommandBuffer,
    atlas_transfer_fence: vk.Fence,
    // Dedicated capture command buffer + fence (used by renderToOffscreen)
    capture_cmd: vk.CommandBuffer,
    capture_fence: vk.Fence,

    pub fn init(
        alloc: std.mem.Allocator,
@@ -935,6 +1057,19 @@ pub const Context = struct {
        }, null);
        errdefer vkd.destroyFence(device, atlas_transfer_fence, null);

        // --- Dedicated capture (offscreen render + readback) command buffer + fence ---
        var capture_cmd: vk.CommandBuffer = undefined;
        try vkd.allocateCommandBuffers(device, &vk.CommandBufferAllocateInfo{
            .command_pool = command_pool,
            .level = .primary,
            .command_buffer_count = 1,
        }, @ptrCast(&capture_cmd));

        const capture_fence = try vkd.createFence(device, &vk.FenceCreateInfo{
            .flags = .{ .signaled_bit = true },
        }, null);
        errdefer vkd.destroyFence(device, capture_fence, null);

        // Bind atlas to descriptor set
        const img_info = vk.DescriptorImageInfo{
            .sampler = atlas_sampler,
@@ -997,6 +1132,8 @@ pub const Context = struct {
            .atlas_staging_memory = atlas_staging.memory,
            .atlas_transfer_cb = atlas_transfer_cb,
            .atlas_transfer_fence = atlas_transfer_fence,
            .capture_cmd = capture_cmd,
            .capture_fence = capture_fence,
        };
    }

@@ -1012,6 +1149,7 @@ pub const Context = struct {
        self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null);
        self.vkd.freeMemory(self.device, self.atlas_staging_memory, null);
        self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null);
        self.vkd.destroyFence(self.device, self.capture_fence, null);
        self.vkd.destroyBuffer(self.device, self.instance_buffer, null);
        self.vkd.freeMemory(self.device, self.instance_memory, null);
        self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null);
@@ -1486,6 +1624,67 @@ pub const Context = struct {
        return false;
    }

    /// Shared "bind pipeline + push constants + vertex/instance buffers +
    /// dynamic viewport/scissor + drawInstanced" block used by both the
    /// swapchain draw path (`drawCells`) and the offscreen draw path
    /// (`renderToOffscreen`).
    ///
    /// The caller is responsible for recording the surrounding
    /// `cmdBeginRenderPass`/`cmdEndRenderPass` pair with the appropriate
    /// framebuffer and render area.
    fn recordDrawCommands(
        self: *Context,
        cmd: vk.CommandBuffer,
        extent: vk.Extent2D,
        instance_count: u32,
        push: PushConstants,
    ) void {
        self.vkd.cmdBindPipeline(cmd, .graphics, self.pipeline);

        // Dynamic viewport + scissor
        const viewport = vk.Viewport{
            .x = 0.0,
            .y = 0.0,
            .width = @floatFromInt(extent.width),
            .height = @floatFromInt(extent.height),
            .min_depth = 0.0,
            .max_depth = 1.0,
        };
        self.vkd.cmdSetViewport(cmd, 0, 1, @ptrCast(&viewport));

        const scissor = vk.Rect2D{
            .offset = .{ .x = 0, .y = 0 },
            .extent = extent,
        };
        self.vkd.cmdSetScissor(cmd, 0, 1, @ptrCast(&scissor));

        // Push constants
        self.vkd.cmdPushConstants(
            cmd,
            self.pipeline_layout,
            .{ .vertex_bit = true, .fragment_bit = true },
            0,
            @sizeOf(PushConstants),
            @ptrCast(&push),
        );

        // Bind descriptor set (atlas sampler)
        self.vkd.cmdBindDescriptorSets(
            cmd,
            .graphics,
            self.pipeline_layout,
            0, 1, @ptrCast(&self.descriptor_set),
            0, null,
        );

        // Bind vertex buffers: binding 0 = quad, binding 1 = instances
        const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer };
        const offsets = [_]vk.DeviceSize{ 0, 0 };
        self.vkd.cmdBindVertexBuffers(cmd, 0, 2, &buffers, &offsets);

        self.vkd.cmdDraw(cmd, 6, instance_count, 0, 0);
    }

    /// Full draw pass: bind pipeline, push constants, vertex + instance buffers, draw, present.
    pub fn drawCells(
        self: *Context,
@@ -1531,26 +1730,6 @@ pub const Context = struct {
            .p_clear_values = @ptrCast(&clear_value),
        }, .@"inline");

        self.vkd.cmdBindPipeline(self.command_buffer, .graphics, self.pipeline);

        // Dynamic viewport + scissor
        const viewport = vk.Viewport{
            .x = 0.0,
            .y = 0.0,
            .width = @floatFromInt(self.swapchain_extent.width),
            .height = @floatFromInt(self.swapchain_extent.height),
            .min_depth = 0.0,
            .max_depth = 1.0,
        };
        self.vkd.cmdSetViewport(self.command_buffer, 0, 1, @ptrCast(&viewport));

        const scissor = vk.Rect2D{
            .offset = .{ .x = 0, .y = 0 },
            .extent = self.swapchain_extent,
        };
        self.vkd.cmdSetScissor(self.command_buffer, 0, 1, @ptrCast(&scissor));

        // Push constants
        const pc = PushConstants{
            .viewport_size = .{
                @floatFromInt(self.swapchain_extent.width),
@@ -1559,30 +1738,7 @@ pub const Context = struct {
            .cell_size = cell_size,
            .coverage_params = coverage_params,
        };
        self.vkd.cmdPushConstants(
            self.command_buffer,
            self.pipeline_layout,
            .{ .vertex_bit = true, .fragment_bit = true },
            0,
            @sizeOf(PushConstants),
            @ptrCast(&pc),
        );

        // Bind descriptor set (atlas sampler)
        self.vkd.cmdBindDescriptorSets(
            self.command_buffer,
            .graphics,
            self.pipeline_layout,
            0, 1, @ptrCast(&self.descriptor_set),
            0, null,
        );

        // Bind vertex buffers: binding 0 = quad, binding 1 = instances
        const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer };
        const offsets = [_]vk.DeviceSize{ 0, 0 };
        self.vkd.cmdBindVertexBuffers(self.command_buffer, 0, 2, &buffers, &offsets);

        self.vkd.cmdDraw(self.command_buffer, 6, instance_count, 0, 0);
        self.recordDrawCommands(self.command_buffer, self.swapchain_extent, instance_count, pc);

        self.vkd.cmdEndRenderPass(self.command_buffer);
        try self.vkd.endCommandBuffer(self.command_buffer);
@@ -1612,6 +1768,176 @@ pub const Context = struct {
        };
        if (swapchainNeedsRebuild(present_result)) return error.OutOfDateKHR;
    }

    /// Render `instance_data` into the offscreen target and copy the rendered
    /// image into the target's host-visible readback buffer.
    ///
    /// After this call returns, `target.readback_buffer` contains the raw
    /// bytes in the target's native format (typically BGRA8). Use
    /// `readbackOffscreen` to pull them out as RGBA8.
    pub fn renderToOffscreen(
        self: *Context,
        target: *const OffscreenTarget,
        instance_data: []const Instance,
        push: PushConstants,
    ) !void {
        // 1. Upload instances (same path drawCells uses)
        try self.uploadInstances(instance_data);

        // 2. Reset + begin capture command buffer
        _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
        try self.vkd.resetFences(self.device, 1, @ptrCast(&self.capture_fence));

        try self.vkd.resetCommandBuffer(self.capture_cmd, .{});
        try self.vkd.beginCommandBuffer(self.capture_cmd, &vk.CommandBufferBeginInfo{
            .flags = .{ .one_time_submit_bit = true },
        });

        // 3. Transition target.image UNDEFINED -> COLOR_ATTACHMENT_OPTIMAL.
        //    The render pass's `initial_layout = .undefined` technically
        //    accepts the image in any layout, but an explicit barrier makes
        //    the access masks/stage masks unambiguous.
        const to_color = vk.ImageMemoryBarrier{
            .src_access_mask = .{},
            .dst_access_mask = .{ .color_attachment_write_bit = true },
            .old_layout = .undefined,
            .new_layout = .color_attachment_optimal,
            .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
            .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
            .image = target.image,
            .subresource_range = .{
                .aspect_mask = .{ .color_bit = true },
                .base_mip_level = 0,
                .level_count = 1,
                .base_array_layer = 0,
                .layer_count = 1,
            },
        };
        self.vkd.cmdPipelineBarrier(
            self.capture_cmd,
            .{ .top_of_pipe_bit = true },
            .{ .color_attachment_output_bit = true },
            .{},
            0, null,
            0, null,
            1, @ptrCast(&to_color),
        );

        // 4. Begin the render pass on target.framebuffer / extent
        const clear_value = vk.ClearValue{ .color = .{ .float_32 = .{ 0.0, 0.0, 0.0, 1.0 } } };
        const extent = vk.Extent2D{ .width = target.width, .height = target.height };
        self.vkd.cmdBeginRenderPass(self.capture_cmd, &vk.RenderPassBeginInfo{
            .render_pass = self.render_pass,
            .framebuffer = target.framebuffer,
            .render_area = .{
                .offset = .{ .x = 0, .y = 0 },
                .extent = extent,
            },
            .clear_value_count = 1,
            .p_clear_values = @ptrCast(&clear_value),
        }, .@"inline");

        // 5. Shared draw commands (same as drawCells)
        self.recordDrawCommands(self.capture_cmd, extent, @intCast(instance_data.len), push);

        // 6. End render pass (the pass's final_layout is .present_src_khr)
        self.vkd.cmdEndRenderPass(self.capture_cmd);

        // 7. Transition target.image PRESENT_SRC_KHR -> TRANSFER_SRC_OPTIMAL
        //    The render pass forced the final layout to .present_src_khr
        //    (it's the same render pass the swapchain uses). Barrier from
        //    there to TRANSFER_SRC before cmdCopyImageToBuffer.
        const to_transfer = vk.ImageMemoryBarrier{
            .src_access_mask = .{ .color_attachment_write_bit = true },
            .dst_access_mask = .{ .transfer_read_bit = true },
            .old_layout = .present_src_khr,
            .new_layout = .transfer_src_optimal,
            .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
            .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
            .image = target.image,
            .subresource_range = .{
                .aspect_mask = .{ .color_bit = true },
                .base_mip_level = 0,
                .level_count = 1,
                .base_array_layer = 0,
                .layer_count = 1,
            },
        };
        self.vkd.cmdPipelineBarrier(
            self.capture_cmd,
            .{ .color_attachment_output_bit = true },
            .{ .transfer_bit = true },
            .{},
            0, null,
            0, null,
            1, @ptrCast(&to_transfer),
        );

        // 8. Copy image -> readback buffer
        const region = vk.BufferImageCopy{
            .buffer_offset = 0,
            .buffer_row_length = 0,
            .buffer_image_height = 0,
            .image_subresource = .{
                .aspect_mask = .{ .color_bit = true },
                .mip_level = 0,
                .base_array_layer = 0,
                .layer_count = 1,
            },
            .image_offset = .{ .x = 0, .y = 0, .z = 0 },
            .image_extent = .{ .width = target.width, .height = target.height, .depth = 1 },
        };
        self.vkd.cmdCopyImageToBuffer(
            self.capture_cmd,
            target.image,
            .transfer_src_optimal,
            target.readback_buffer,
            1,
            @ptrCast(&region),
        );

        // 9. End + submit + wait
        try self.vkd.endCommandBuffer(self.capture_cmd);
        try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
            .command_buffer_count = 1,
            .p_command_buffers = @ptrCast(&self.capture_cmd),
        }), self.capture_fence);
        _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
    }

    /// Read the offscreen target's readback buffer into `out_rgba`.
    /// Converts BGRA (the swapchain/native format) to RGBA and forces
    /// alpha to 0xFF. Caller must ensure a prior `renderToOffscreen` has
    /// finished (it waits on the capture fence, so simple back-to-back
    /// call is safe).
    pub fn readbackOffscreen(
        self: *Context,
        target: *const OffscreenTarget,
        out_rgba: []u8,
    ) !void {
        if (out_rgba.len < target.readback_size) return error.BufferTooSmall;

        const mapped = try self.vkd.mapMemory(
            self.device,
            target.readback_memory,
            0,
            @intCast(target.readback_size),
            .{},
        );
        defer self.vkd.unmapMemory(self.device, target.readback_memory);

        const src = @as([*]const u8, @ptrCast(mapped))[0..target.readback_size];
        const pixel_count: usize = @intCast(@as(u64, target.width) * @as(u64, target.height));
        var i: usize = 0;
        while (i < pixel_count) : (i += 1) {
            const o = i * 4;
            // Source is BGRA8 (swapchain-native); produce RGBA8, force alpha=0xFF.
            out_rgba[o + 0] = src[o + 2]; // R <- B
            out_rgba[o + 1] = src[o + 1]; // G <- G
            out_rgba[o + 2] = src[o + 0]; // B <- R
            out_rgba[o + 3] = 0xFF;
        }
    }
};

test "vulkan module imports" {