RollingCapture.kt
src/jvmMain/kotlin/afield/graphics/vulkan/targets/RollingCapture.kt · 357 lines
package afield.graphics.vulkan.targets
import afield.graphics.vulkan.device.vkLayout
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.buffers.VulkanBuffer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.device.CommandPool
import afield.graphics.vulkan.device.Fence
import afield.graphics.vulkan.device.Queue
import afield.graphics.texture.Image
import afield.graphics.texture.Image.ImageData
import afield.graphics.texture.ImageView
import afield.graphics.texture.image_layout_transition
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.VK10.*
import org.lwjgl.vulkan.VK13.*
import org.lwjgl.vulkan.VkBufferImageCopy
import org.lwjgl.vulkan.VkImageCopy
import org.lwjgl.vulkan.VkImageBlit
import java.nio.ByteBuffer
import kotlin.concurrent.thread
Reads a rendered image back to the CPU a strip at a time, so a full-frame capture never stalls a frame.
RollingCapture copies one horizontal strip per frame into a snapshot image, then copies the finished snapshot into a CPU-visible staging buffer.
class RollingCapture
Rolling capture that copies a horizontal strip from a source image into an internal snapshot image each frame. Once a full frame has been copied, you can copy the snapshot to a CPU-visible buffer (e.g., on a low-priority transfer queue) without stalling rendering.
Usage: 1) Construct with the source image handle/format/extent. 2) Call recordStrip at the end of each frame on the graphics command buffer. 3) When fullFrameReady becomes true, schedule copySnapshotToStaging on a transfer/graphics command buffer, then read stagingBuffer on the CPU. After copying, fullFrameReady is reset.
class RollingCapture(
private val renderer: Renderer,
private val source: Image,
private val rowsPerFrame: Int = 16,
private val sourceLayout: Int = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
private val downsample: Int = 1 // >1 will downscale source by this factor on ingest
) {
private val targetWidth = source.imageData.width / downsample
private val targetHeight = source.imageData.height / downsample
private val snapshotImageData = ImageData()
.width(targetWidth)
.height(targetHeight)
.format(source.imageData.format)
.mipLevels(1)
.usage(VK_IMAGE_USAGE_TRANSFER_DST_BIT or VK_IMAGE_USAGE_TRANSFER_SRC_BIT)
private val snapshotImage = Image(renderer.device, snapshotImageData)
val snapshotView
A view of the snapshot image itself, so a pass can sample the partly-rebuilt frame on the GPU without waiting for a CPU readback. Expect to see the strip boundary: the rows above recordStrip's next row are from this sweep and the rows below are from the previous one.
val snapshotView = ImageView(
renderer.device,
snapshotImage,
ImageView.ImageViewData()
.format(source.imageData.format)
.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT)
.mipLevels(1)
)
val stagingBuffer
Host-visible, host-coherent buffer holding one whole captured frame, tightly packed at the
capture's own size after downsample. copySnapshotToStaging fills it; nothing here
synchronises the read, so wait on that copy's fence before looking at the bytes.
val stagingBuffer = VulkanBuffer(
renderer = renderer,
requestedSize = targetWidth.toLong() * targetHeight.toLong() * source.imageData.bytesPerPixel,
usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT,
reqMask = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
)
private var snapshotLayout: Int = VK_IMAGE_LAYOUT_UNDEFINED
private var nextRow: Int = 0
var fullFrameReady: Boolean
True once the strips have covered the whole image and the snapshot is a complete frame. While it is true recordStrip does nothing, so capture pauses rather than overwriting a frame nobody has taken; copySnapshotToStaging clears it and the next strip starts a new sweep.
var fullFrameReady: Boolean = false
private set
Dedicated transfer queue/pool/buffer for async staging copies (low-priority queue if available)
private val transferQueue: Queue
private val transferPool: CommandPool
private val transferCmd: CommandBuffer
@Volatile private var readerRunning = false
private var readerThread: Thread? = null
private var onFrame: ((ByteBuffer) -> Unit)? = null
init
init {
val transferFamily = pickTransferFamily(renderer.device)
transferQueue = Queue(renderer.device, transferFamily, 0)
transferPool = CommandPool(renderer.device, transferQueue.queueFamilyIndex)
transferCmd = CommandBuffer(transferPool, primary = true, oneTimeSubmit = true)
}
fun recordStrip(command: CommandBuffer)
Record a strip copy from the source image into the snapshot image. Call this at the end of your frame on the graphics command buffer.
body — 96 lines, from line 115
fun recordStrip(command: CommandBuffer) {
if (fullFrameReady) return
val rows = rowsPerFrame.coerceAtMost(targetHeight - nextRow)
MemoryStack.stackPush().use { stack ->
val range = org.lwjgl.vulkan.VkImageSubresourceRange.calloc(stack)
.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT)
.baseMipLevel(0)
.levelCount(1)
.baseArrayLayer(0)
.layerCount(1)
// Src: previous -> TRANSFER_SRC
image_layout_transition(
command.vkCommandBuffer,
source,
VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT,
VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT, // conservative; about to copy
VK_ACCESS_2_SHADER_READ_BIT,
0,
sourceLayout,
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
range
)
// Dst: previous -> TRANSFER_DST
image_layout_transition(
command.vkCommandBuffer,
snapshotImage,
VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT,
0,
VK_ACCESS_2_TRANSFER_WRITE_BIT,
snapshotLayout,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
range
)
if (downsample <= 1) {
val copy = VkImageCopy.calloc(1, stack)
copy[0]
.srcSubresource { it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).mipLevel(0).baseArrayLayer(0).layerCount(1) }
.dstSubresource { it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).mipLevel(0).baseArrayLayer(0).layerCount(1) }
.srcOffset { it.x(0).y(nextRow).z(0) }
.dstOffset { it.x(0).y(nextRow).z(0) }
.extent { it.width(targetWidth).height(rows).depth(1) }
vkCmdCopyImage(
command.vkCommandBuffer,
source.vkImage, vkLayout(VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL),
snapshotImage.vkImage, vkLayout(VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL),
copy
)
} else {
val srcY0 = nextRow * downsample
val srcY1 = (nextRow + rows) * downsample
val blit = VkImageBlit.calloc(1, stack)
blit[0]
.srcSubresource { it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).mipLevel(0).baseArrayLayer(0).layerCount(1) }
.dstSubresource { it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).mipLevel(0).baseArrayLayer(0).layerCount(1) }
.srcOffsets(0).set(0, srcY0, 0)
blit[0].srcOffsets(1).set(targetWidth * downsample, srcY1, 1)
blit[0].dstOffsets(0).set(0, nextRow, 0)
blit[0].dstOffsets(1).set(targetWidth, nextRow + rows, 1)
vkCmdBlitImage(
command.vkCommandBuffer,
source.vkImage, vkLayout(VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL),
snapshotImage.vkImage, vkLayout(VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL),
blit,
VK_FILTER_LINEAR
)
}
snapshotLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL
// Restore src to its expected layout
image_layout_transition(
command.vkCommandBuffer,
source,
VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT,
0,
VK_ACCESS_2_SHADER_READ_BIT,
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
sourceLayout,
range
)
}
nextRow += rows
if (nextRow >= targetHeight) {
fullFrameReady = true
nextRow = 0
}
}
fun copySnapshotToStaging(command: CommandBuffer)
Copy the accumulated snapshot image to the staging buffer. Call on a transfer/graphics command buffer after checking fullFrameReady.
body — 47 lines, from line 216
fun copySnapshotToStaging(command: CommandBuffer) {
if (!fullFrameReady) return
MemoryStack.stackPush().use { stack ->
val range = org.lwjgl.vulkan.VkImageSubresourceRange.calloc(stack)
.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT)
.baseMipLevel(0)
.levelCount(1)
.baseArrayLayer(0)
.layerCount(1)
image_layout_transition(
command.vkCommandBuffer,
snapshotImage,
VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT,
0,
VK_ACCESS_2_TRANSFER_READ_BIT,
snapshotLayout,
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
range
)
val copy = VkBufferImageCopy.calloc(1, stack)
copy[0]
.bufferOffset(0L)
.bufferRowLength(0)
.bufferImageHeight(0)
.imageSubresource { it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).mipLevel(0).baseArrayLayer(0).layerCount(1) }
.imageOffset { it.x(0).y(0).z(0) }
.imageExtent { it.width(targetWidth).height(targetHeight).depth(1) }
vkCmdCopyImageToBuffer(
command.vkCommandBuffer,
snapshotImage.vkImage,
vkLayout(VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL),
stagingBuffer.buffer,
copy
)
// Keep snapshot ready for next rolling copy
snapshotLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL
snapshotImage.setLayoutFor(command.vkCommandBuffer.address(), snapshotLayout)
}
fullFrameReady = false
}
fun copySnapshotToStagingAsync(): Fence
Convenience: records and submits the staging copy on the internal transfer queue. Returns a fence you can wait on from a CPU thread.
fun copySnapshotToStagingAsync(): Fence {
if (!fullFrameReady) return Fence(renderer.device, true)
transferCmd.reset()
transferCmd.beginRecording()
copySnapshotToStaging(transferCmd)
transferCmd.endRecording()
val fence = Fence(renderer.device, signaled = false)
MemoryStack.stackPush().use { stack ->
val pCmd = stack.pointers(transferCmd.vkCommandBuffer.address())
transferQueue.submit(pCmd, null, null, null, fence)
}
return fence
}
fun startBackgroundRead(onFrame: (ByteBuffer) -> Unit, sleepMs: Long
Start a background thread that waits for full captures, kicks the async staging copy,
waits the fence, and hands the CPU buffer to onFrame.
fun startBackgroundRead(onFrame: (ByteBuffer) -> Unit, sleepMs: Long = 1L) {
if (readerRunning) return
readerRunning = true
this.onFrame = onFrame
readerThread = thread(name = "RollingCaptureReader", isDaemon = true) {
while (readerRunning) {
if (fullFrameReady) {
val fence = copySnapshotToStagingAsync()
fence.fenceWait()
stagingBuffer.mapAndRead_bytes { onFrame(this) }
fence.cleanup()
} else {
try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { break }
}
}
}
}
fun stopBackgroundRead()
fun stopBackgroundRead() {
readerRunning = false
readerThread?.interrupt()
readerThread = null
onFrame = null
}
fun cleanup()
fun cleanup() {
snapshotView.cleanup()
snapshotImage.cleanup()
stagingBuffer.cleanup()
transferCmd.cleanup()
transferPool.cleanup()
stopBackgroundRead()
}
fun install(r : Renderer, onFrame: (ByteBuffer) -> Unit, name : String
fun install(r : Renderer, onFrame: (ByteBuffer) -> Unit, name : String = "__rolling_capture__") {
r.renderList.others[name] = Target { command, index ->
recordStrip(command)
copySnapshotToStaging(command)
}
startBackgroundRead(onFrame)
}
}
Pick a transfer-capable family if available; fall back to graphics family 0.
private fun pickTransferFamily(device: afield.graphics.vulkan.device.Device): Int {
val props = device.physicalDevice.vkQueueFamilyProps
var transferFamily = -1
for (i in 0 until props.capacity()) {
val flags = props[i].queueFlags()
val transferOnly = (flags and VK_QUEUE_TRANSFER_BIT) != 0 &&
(flags and VK_QUEUE_GRAPHICS_BIT) == 0 &&
(flags and VK_QUEUE_COMPUTE_BIT) == 0
if (transferOnly) {
transferFamily = i
break
}
}
if (transferFamily < 0) {
// fallback: first transfer-capable family (even if also graphics/compute)
for (i in 0 until props.capacity()) {
if ((props[i].queueFlags() and VK_QUEUE_TRANSFER_BIT) != 0) {
transferFamily = i
break
}
}
}
if (transferFamily < 0) transferFamily = 0
return transferFamily
}