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(®ion), ); // 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" {