a73x

2d429b7f

Add offscreen render target + readback to renderer

a73x   2026-04-17 15:14

Commit message
Add offscreen render target + readback to renderer

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>

src/renderer.zig
Old New
@@ -424,6 +424,125 @@ fn swapchainNeedsRebuild(result: vk.Result) bool {
424 return result == .suboptimal_khr; 424 return result == .suboptimal_khr;
425 } 425 }
426 426
427 /// Offscreen color attachment + readback staging buffer.
428 /// The image is allocated with COLOR_ATTACHMENT_BIT | TRANSFER_SRC_BIT so the
429 /// renderer can draw into it and then copy it into the host-visible readback
430 /// buffer. Created via `createOffscreen` and freed with `destroyOffscreen`.
431 pub const OffscreenTarget = struct {
432 width: u32,
433 height: u32,
434 format: vk.Format,
435 image: vk.Image,
436 memory: vk.DeviceMemory,
437 view: vk.ImageView,
438 framebuffer: vk.Framebuffer,
439 readback_buffer: vk.Buffer,
440 readback_memory: vk.DeviceMemory,
441 readback_size: u64,
442 };
443
444 /// Allocate an offscreen color-attachment image plus matching readback buffer.
445 /// The framebuffer is compatible with `render_pass` — pass the same render pass
446 /// the swapchain uses so the existing pipeline is reusable.
447 pub fn createOffscreen(
448 vki: vk.InstanceWrapper,
449 vkd: vk.DeviceWrapper,
450 physical: vk.PhysicalDevice,
451 device: vk.Device,
452 render_pass: vk.RenderPass,
453 format: vk.Format,
454 width: u32,
455 height: u32,
456 ) !OffscreenTarget {
457 // 1. Color attachment image with TRANSFER_SRC_BIT | COLOR_ATTACHMENT_BIT
458 const image = try vkd.createImage(device, &vk.ImageCreateInfo{
459 .image_type = .@"2d",
460 .format = format,
461 .extent = .{ .width = width, .height = height, .depth = 1 },
462 .mip_levels = 1,
463 .array_layers = 1,
464 .samples = .{ .@"1_bit" = true },
465 .tiling = .optimal,
466 .usage = .{ .color_attachment_bit = true, .transfer_src_bit = true },
467 .sharing_mode = .exclusive,
468 .initial_layout = .undefined,
469 }, null);
470 errdefer vkd.destroyImage(device, image, null);
471
472 // 2. Device-local memory bound
473 const img_reqs = vkd.getImageMemoryRequirements(device, image);
474 const img_mem_idx = try findMemoryType(vki, physical, img_reqs.memory_type_bits, .{ .device_local_bit = true });
475 const memory = try vkd.allocateMemory(device, &vk.MemoryAllocateInfo{
476 .allocation_size = img_reqs.size,
477 .memory_type_index = img_mem_idx,
478 }, null);
479 errdefer vkd.freeMemory(device, memory, null);
480 try vkd.bindImageMemory(device, image, memory, 0);
481
482 // 3. ImageView + Framebuffer (using the provided render_pass)
483 const view = try vkd.createImageView(device, &vk.ImageViewCreateInfo{
484 .image = image,
485 .view_type = .@"2d",
486 .format = format,
487 .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
488 .subresource_range = .{
489 .aspect_mask = .{ .color_bit = true },
490 .base_mip_level = 0,
491 .level_count = 1,
492 .base_array_layer = 0,
493 .layer_count = 1,
494 },
495 }, null);
496 errdefer vkd.destroyImageView(device, view, null);
497
498 const framebuffer = try vkd.createFramebuffer(device, &vk.FramebufferCreateInfo{
499 .render_pass = render_pass,
500 .attachment_count = 1,
501 .p_attachments = @ptrCast(&view),
502 .width = width,
503 .height = height,
504 .layers = 1,
505 }, null);
506 errdefer vkd.destroyFramebuffer(device, framebuffer, null);
507
508 // 4. Host-visible readback buffer (TRANSFER_DST, width*height*4 bytes)
509 const readback_size: u64 = @as(u64, width) * @as(u64, height) * 4;
510 const readback = try createHostVisibleBuffer(
511 vki,
512 physical,
513 vkd,
514 device,
515 @intCast(readback_size),
516 .{ .transfer_dst_bit = true },
517 );
518 errdefer {
519 vkd.destroyBuffer(device, readback.buffer, null);
520 vkd.freeMemory(device, readback.memory, null);
521 }
522
523 return .{
524 .width = width,
525 .height = height,
526 .format = format,
527 .image = image,
528 .memory = memory,
529 .view = view,
530 .framebuffer = framebuffer,
531 .readback_buffer = readback.buffer,
532 .readback_memory = readback.memory,
533 .readback_size = readback_size,
534 };
535 }
536
537 pub fn destroyOffscreen(vkd: vk.DeviceWrapper, device: vk.Device, t: OffscreenTarget) void {
538 vkd.destroyFramebuffer(device, t.framebuffer, null);
539 vkd.destroyImageView(device, t.view, null);
540 vkd.destroyImage(device, t.image, null);
541 vkd.freeMemory(device, t.memory, null);
542 vkd.destroyBuffer(device, t.readback_buffer, null);
543 vkd.freeMemory(device, t.readback_memory, null);
544 }
545
427 pub const Context = struct { 546 pub const Context = struct {
428 alloc: std.mem.Allocator, 547 alloc: std.mem.Allocator,
429 vkb: vk.BaseWrapper, 548 vkb: vk.BaseWrapper,
@@ -479,6 +598,9 @@ pub const Context = struct {
479 // Dedicated transfer command buffer + fence 598 // Dedicated transfer command buffer + fence
480 atlas_transfer_cb: vk.CommandBuffer, 599 atlas_transfer_cb: vk.CommandBuffer,
481 atlas_transfer_fence: vk.Fence, 600 atlas_transfer_fence: vk.Fence,
601 // Dedicated capture command buffer + fence (used by renderToOffscreen)
602 capture_cmd: vk.CommandBuffer,
603 capture_fence: vk.Fence,
482 604
483 pub fn init( 605 pub fn init(
484 alloc: std.mem.Allocator, 606 alloc: std.mem.Allocator,
@@ -935,6 +1057,19 @@ pub const Context = struct {
935 }, null); 1057 }, null);
936 errdefer vkd.destroyFence(device, atlas_transfer_fence, null); 1058 errdefer vkd.destroyFence(device, atlas_transfer_fence, null);
937 1059
1060 // --- Dedicated capture (offscreen render + readback) command buffer + fence ---
1061 var capture_cmd: vk.CommandBuffer = undefined;
1062 try vkd.allocateCommandBuffers(device, &vk.CommandBufferAllocateInfo{
1063 .command_pool = command_pool,
1064 .level = .primary,
1065 .command_buffer_count = 1,
1066 }, @ptrCast(&capture_cmd));
1067
1068 const capture_fence = try vkd.createFence(device, &vk.FenceCreateInfo{
1069 .flags = .{ .signaled_bit = true },
1070 }, null);
1071 errdefer vkd.destroyFence(device, capture_fence, null);
1072
938 // Bind atlas to descriptor set 1073 // Bind atlas to descriptor set
939 const img_info = vk.DescriptorImageInfo{ 1074 const img_info = vk.DescriptorImageInfo{
940 .sampler = atlas_sampler, 1075 .sampler = atlas_sampler,
@@ -997,6 +1132,8 @@ pub const Context = struct {
997 .atlas_staging_memory = atlas_staging.memory, 1132 .atlas_staging_memory = atlas_staging.memory,
998 .atlas_transfer_cb = atlas_transfer_cb, 1133 .atlas_transfer_cb = atlas_transfer_cb,
999 .atlas_transfer_fence = atlas_transfer_fence, 1134 .atlas_transfer_fence = atlas_transfer_fence,
1135 .capture_cmd = capture_cmd,
1136 .capture_fence = capture_fence,
1000 }; 1137 };
1001 } 1138 }
1002 1139
@@ -1012,6 +1149,7 @@ pub const Context = struct {
1012 self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null); 1149 self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null);
1013 self.vkd.freeMemory(self.device, self.atlas_staging_memory, null); 1150 self.vkd.freeMemory(self.device, self.atlas_staging_memory, null);
1014 self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null); 1151 self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null);
1152 self.vkd.destroyFence(self.device, self.capture_fence, null);
1015 self.vkd.destroyBuffer(self.device, self.instance_buffer, null); 1153 self.vkd.destroyBuffer(self.device, self.instance_buffer, null);
1016 self.vkd.freeMemory(self.device, self.instance_memory, null); 1154 self.vkd.freeMemory(self.device, self.instance_memory, null);
1017 self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null); 1155 self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null);
@@ -1486,6 +1624,67 @@ pub const Context = struct {
1486 return false; 1624 return false;
1487 } 1625 }
1488 1626
1627 /// Shared "bind pipeline + push constants + vertex/instance buffers +
1628 /// dynamic viewport/scissor + drawInstanced" block used by both the
1629 /// swapchain draw path (`drawCells`) and the offscreen draw path
1630 /// (`renderToOffscreen`).
1631 ///
1632 /// The caller is responsible for recording the surrounding
1633 /// `cmdBeginRenderPass`/`cmdEndRenderPass` pair with the appropriate
1634 /// framebuffer and render area.
1635 fn recordDrawCommands(
1636 self: *Context,
1637 cmd: vk.CommandBuffer,
1638 extent: vk.Extent2D,
1639 instance_count: u32,
1640 push: PushConstants,
1641 ) void {
1642 self.vkd.cmdBindPipeline(cmd, .graphics, self.pipeline);
1643
1644 // Dynamic viewport + scissor
1645 const viewport = vk.Viewport{
1646 .x = 0.0,
1647 .y = 0.0,
1648 .width = @floatFromInt(extent.width),
1649 .height = @floatFromInt(extent.height),
1650 .min_depth = 0.0,
1651 .max_depth = 1.0,
1652 };
1653 self.vkd.cmdSetViewport(cmd, 0, 1, @ptrCast(&viewport));
1654
1655 const scissor = vk.Rect2D{
1656 .offset = .{ .x = 0, .y = 0 },
1657 .extent = extent,
1658 };
1659 self.vkd.cmdSetScissor(cmd, 0, 1, @ptrCast(&scissor));
1660
1661 // Push constants
1662 self.vkd.cmdPushConstants(
1663 cmd,
1664 self.pipeline_layout,
1665 .{ .vertex_bit = true, .fragment_bit = true },
1666 0,
1667 @sizeOf(PushConstants),
1668 @ptrCast(&push),
1669 );
1670
1671 // Bind descriptor set (atlas sampler)
1672 self.vkd.cmdBindDescriptorSets(
1673 cmd,
1674 .graphics,
1675 self.pipeline_layout,
1676 0, 1, @ptrCast(&self.descriptor_set),
1677 0, null,
1678 );
1679
1680 // Bind vertex buffers: binding 0 = quad, binding 1 = instances
1681 const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer };
1682 const offsets = [_]vk.DeviceSize{ 0, 0 };
1683 self.vkd.cmdBindVertexBuffers(cmd, 0, 2, &buffers, &offsets);
1684
1685 self.vkd.cmdDraw(cmd, 6, instance_count, 0, 0);
1686 }
1687
1489 /// Full draw pass: bind pipeline, push constants, vertex + instance buffers, draw, present. 1688 /// Full draw pass: bind pipeline, push constants, vertex + instance buffers, draw, present.
1490 pub fn drawCells( 1689 pub fn drawCells(
1491 self: *Context, 1690 self: *Context,
@@ -1531,26 +1730,6 @@ pub const Context = struct {
1531 .p_clear_values = @ptrCast(&clear_value), 1730 .p_clear_values = @ptrCast(&clear_value),
1532 }, .@"inline"); 1731 }, .@"inline");
1533 1732
1534 self.vkd.cmdBindPipeline(self.command_buffer, .graphics, self.pipeline);
1535
1536 // Dynamic viewport + scissor
1537 const viewport = vk.Viewport{
1538 .x = 0.0,
1539 .y = 0.0,
1540 .width = @floatFromInt(self.swapchain_extent.width),
1541 .height = @floatFromInt(self.swapchain_extent.height),
1542 .min_depth = 0.0,
1543 .max_depth = 1.0,
1544 };
1545 self.vkd.cmdSetViewport(self.command_buffer, 0, 1, @ptrCast(&viewport));
1546
1547 const scissor = vk.Rect2D{
1548 .offset = .{ .x = 0, .y = 0 },
1549 .extent = self.swapchain_extent,
1550 };
1551 self.vkd.cmdSetScissor(self.command_buffer, 0, 1, @ptrCast(&scissor));
1552
1553 // Push constants
1554 const pc = PushConstants{ 1733 const pc = PushConstants{
1555 .viewport_size = .{ 1734 .viewport_size = .{
1556 @floatFromInt(self.swapchain_extent.width), 1735 @floatFromInt(self.swapchain_extent.width),
@@ -1559,30 +1738,7 @@ pub const Context = struct {
1559 .cell_size = cell_size, 1738 .cell_size = cell_size,
1560 .coverage_params = coverage_params, 1739 .coverage_params = coverage_params,
1561 }; 1740 };
1562 self.vkd.cmdPushConstants( 1741 self.recordDrawCommands(self.command_buffer, self.swapchain_extent, instance_count, pc);
1563 self.command_buffer,
1564 self.pipeline_layout,
1565 .{ .vertex_bit = true, .fragment_bit = true },
1566 0,
1567 @sizeOf(PushConstants),
1568 @ptrCast(&pc),
1569 );
1570
1571 // Bind descriptor set (atlas sampler)
1572 self.vkd.cmdBindDescriptorSets(
1573 self.command_buffer,
1574 .graphics,
1575 self.pipeline_layout,
1576 0, 1, @ptrCast(&self.descriptor_set),
1577 0, null,
1578 );
1579
1580 // Bind vertex buffers: binding 0 = quad, binding 1 = instances
1581 const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer };
1582 const offsets = [_]vk.DeviceSize{ 0, 0 };
1583 self.vkd.cmdBindVertexBuffers(self.command_buffer, 0, 2, &buffers, &offsets);
1584
1585 self.vkd.cmdDraw(self.command_buffer, 6, instance_count, 0, 0);
1586 1742
1587 self.vkd.cmdEndRenderPass(self.command_buffer); 1743 self.vkd.cmdEndRenderPass(self.command_buffer);
1588 try self.vkd.endCommandBuffer(self.command_buffer); 1744 try self.vkd.endCommandBuffer(self.command_buffer);
@@ -1612,6 +1768,176 @@ pub const Context = struct {
1612 }; 1768 };
1613 if (swapchainNeedsRebuild(present_result)) return error.OutOfDateKHR; 1769 if (swapchainNeedsRebuild(present_result)) return error.OutOfDateKHR;
1614 } 1770 }
1771
1772 /// Render `instance_data` into the offscreen target and copy the rendered
1773 /// image into the target's host-visible readback buffer.
1774 ///
1775 /// After this call returns, `target.readback_buffer` contains the raw
1776 /// bytes in the target's native format (typically BGRA8). Use
1777 /// `readbackOffscreen` to pull them out as RGBA8.
1778 pub fn renderToOffscreen(
1779 self: *Context,
1780 target: *const OffscreenTarget,
1781 instance_data: []const Instance,
1782 push: PushConstants,
1783 ) !void {
1784 // 1. Upload instances (same path drawCells uses)
1785 try self.uploadInstances(instance_data);
1786
1787 // 2. Reset + begin capture command buffer
1788 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
1789 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.capture_fence));
1790
1791 try self.vkd.resetCommandBuffer(self.capture_cmd, .{});
1792 try self.vkd.beginCommandBuffer(self.capture_cmd, &vk.CommandBufferBeginInfo{
1793 .flags = .{ .one_time_submit_bit = true },
1794 });
1795
1796 // 3. Transition target.image UNDEFINED -> COLOR_ATTACHMENT_OPTIMAL.
1797 // The render pass's `initial_layout = .undefined` technically
1798 // accepts the image in any layout, but an explicit barrier makes
1799 // the access masks/stage masks unambiguous.
1800 const to_color = vk.ImageMemoryBarrier{
1801 .src_access_mask = .{},
1802 .dst_access_mask = .{ .color_attachment_write_bit = true },
1803 .old_layout = .undefined,
1804 .new_layout = .color_attachment_optimal,
1805 .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
1806 .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
1807 .image = target.image,
1808 .subresource_range = .{
1809 .aspect_mask = .{ .color_bit = true },
1810 .base_mip_level = 0,
1811 .level_count = 1,
1812 .base_array_layer = 0,
1813 .layer_count = 1,
1814 },
1815 };
1816 self.vkd.cmdPipelineBarrier(
1817 self.capture_cmd,
1818 .{ .top_of_pipe_bit = true },
1819 .{ .color_attachment_output_bit = true },
1820 .{},
1821 0, null,
1822 0, null,
1823 1, @ptrCast(&to_color),
1824 );
1825
1826 // 4. Begin the render pass on target.framebuffer / extent
1827 const clear_value = vk.ClearValue{ .color = .{ .float_32 = .{ 0.0, 0.0, 0.0, 1.0 } } };
1828 const extent = vk.Extent2D{ .width = target.width, .height = target.height };
1829 self.vkd.cmdBeginRenderPass(self.capture_cmd, &vk.RenderPassBeginInfo{
1830 .render_pass = self.render_pass,
1831 .framebuffer = target.framebuffer,
1832 .render_area = .{
1833 .offset = .{ .x = 0, .y = 0 },
1834 .extent = extent,
1835 },
1836 .clear_value_count = 1,
1837 .p_clear_values = @ptrCast(&clear_value),
1838 }, .@"inline");
1839
1840 // 5. Shared draw commands (same as drawCells)
1841 self.recordDrawCommands(self.capture_cmd, extent, @intCast(instance_data.len), push);
1842
1843 // 6. End render pass (the pass's final_layout is .present_src_khr)
1844 self.vkd.cmdEndRenderPass(self.capture_cmd);
1845
1846 // 7. Transition target.image PRESENT_SRC_KHR -> TRANSFER_SRC_OPTIMAL
1847 // The render pass forced the final layout to .present_src_khr
1848 // (it's the same render pass the swapchain uses). Barrier from
1849 // there to TRANSFER_SRC before cmdCopyImageToBuffer.
1850 const to_transfer = vk.ImageMemoryBarrier{
1851 .src_access_mask = .{ .color_attachment_write_bit = true },
1852 .dst_access_mask = .{ .transfer_read_bit = true },
1853 .old_layout = .present_src_khr,
1854 .new_layout = .transfer_src_optimal,
1855 .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
1856 .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
1857 .image = target.image,
1858 .subresource_range = .{
1859 .aspect_mask = .{ .color_bit = true },
1860 .base_mip_level = 0,
1861 .level_count = 1,
1862 .base_array_layer = 0,
1863 .layer_count = 1,
1864 },
1865 };
1866 self.vkd.cmdPipelineBarrier(
1867 self.capture_cmd,
1868 .{ .color_attachment_output_bit = true },
1869 .{ .transfer_bit = true },
1870 .{},
1871 0, null,
1872 0, null,
1873 1, @ptrCast(&to_transfer),
1874 );
1875
1876 // 8. Copy image -> readback buffer
1877 const region = vk.BufferImageCopy{
1878 .buffer_offset = 0,
1879 .buffer_row_length = 0,
1880 .buffer_image_height = 0,
1881 .image_subresource = .{
1882 .aspect_mask = .{ .color_bit = true },
1883 .mip_level = 0,
1884 .base_array_layer = 0,
1885 .layer_count = 1,
1886 },
1887 .image_offset = .{ .x = 0, .y = 0, .z = 0 },
1888 .image_extent = .{ .width = target.width, .height = target.height, .depth = 1 },
1889 };
1890 self.vkd.cmdCopyImageToBuffer(
1891 self.capture_cmd,
1892 target.image,
1893 .transfer_src_optimal,
1894 target.readback_buffer,
1895 1,
1896 @ptrCast(&region),
1897 );
1898
1899 // 9. End + submit + wait
1900 try self.vkd.endCommandBuffer(self.capture_cmd);
1901 try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
1902 .command_buffer_count = 1,
1903 .p_command_buffers = @ptrCast(&self.capture_cmd),
1904 }), self.capture_fence);
1905 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
1906 }
1907
1908 /// Read the offscreen target's readback buffer into `out_rgba`.
1909 /// Converts BGRA (the swapchain/native format) to RGBA and forces
1910 /// alpha to 0xFF. Caller must ensure a prior `renderToOffscreen` has
1911 /// finished (it waits on the capture fence, so simple back-to-back
1912 /// call is safe).
1913 pub fn readbackOffscreen(
1914 self: *Context,
1915 target: *const OffscreenTarget,
1916 out_rgba: []u8,
1917 ) !void {
1918 if (out_rgba.len < target.readback_size) return error.BufferTooSmall;
1919
1920 const mapped = try self.vkd.mapMemory(
1921 self.device,
1922 target.readback_memory,
1923 0,
1924 @intCast(target.readback_size),
1925 .{},
1926 );
1927 defer self.vkd.unmapMemory(self.device, target.readback_memory);
1928
1929 const src = @as([*]const u8, @ptrCast(mapped))[0..target.readback_size];
1930 const pixel_count: usize = @intCast(@as(u64, target.width) * @as(u64, target.height));
1931 var i: usize = 0;
1932 while (i < pixel_count) : (i += 1) {
1933 const o = i * 4;
1934 // Source is BGRA8 (swapchain-native); produce RGBA8, force alpha=0xFF.
1935 out_rgba[o + 0] = src[o + 2]; // R <- B
1936 out_rgba[o + 1] = src[o + 1]; // G <- G
1937 out_rgba[o + 2] = src[o + 0]; // B <- R
1938 out_rgba[o + 3] = 0xFF;
1939 }
1940 }
1615 }; 1941 };
1616 1942
1617 test "vulkan module imports" { 1943 test "vulkan module imports" {