Skip to content

GpuImageCapture.kt

src/jvmMain/kotlin/afield/graphics/vulkan/GpuImageCapture.kt · 268 lines

package afield.graphics.vulkan

import afield.graphics.scene.*
import afield.graphics.texture.vkFormatBytesPerPixel
import org.lwjgl.stb.STBImageWrite.stbi_write_jpg
import org.lwjgl.vulkan.VK10.*
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
import java.nio.file.Path

internal object GpuImageCapture

internal object GpuImageCapture {

fun bytesPerPixel(format: Int, sourceName: String): Int

Size of the staging buffer one pixel of format needs, from the shared table in afield.graphics.texture. Throws for formats the table does not know — an under-sized staging buffer would let vkCmdCopyImageToBuffer run off the end of the allocation, which is far harder to trace back than a named format in an exception.

    fun bytesPerPixel(format: Int, sourceName: String): Int =
        vkFormatBytesPerPixel(format, "$sourceName capture")

fun readRgba(staging: ByteBuffer, format: Int, width: Int, height: Int, sourceName: String): ByteArray

body — 129 lines, from line 22
    fun readRgba(staging: ByteBuffer, format: Int, width: Int, height: Int, sourceName: String): ByteArray {
        val rgba = ByteArray(width * height * 4)
        var dst = 0
        fun put(r: Byte, g: Byte, b: Byte, a: Byte) {
            rgba[dst++] = r
            rgba[dst++] = g
            rgba[dst++] = b
            rgba[dst++] = a
        }

        staging.rewind()
        staging.order(ByteOrder.LITTLE_ENDIAN)
        val pixelCount = width * height
        when (format) {
            VK_FORMAT_B8G8R8_UNORM -> repeat(pixelCount) {
                val b = staging.get()
                val g = staging.get()
                val r = staging.get()
                put(r, g, b, 255.toByte())
            }

            VK_FORMAT_R8G8B8_UNORM -> repeat(pixelCount) {
                val r = staging.get()
                val g = staging.get()
                val b = staging.get()
                put(r, g, b, 255.toByte())
            }

            VK_FORMAT_B8G8R8A8_UNORM,
            VK_FORMAT_B8G8R8A8_SRGB -> repeat(pixelCount) {
                val b = staging.get()
                val g = staging.get()
                val r = staging.get()
                val a = staging.get()
                put(r, g, b, a)
            }

            VK_FORMAT_R8G8B8A8_UNORM,
            VK_FORMAT_R8G8B8A8_SRGB -> repeat(pixelCount) {
                val r = staging.get()
                val g = staging.get()
                val b = staging.get()
                val a = staging.get()
                put(r, g, b, a)
            }

            VK_FORMAT_R16G16B16A16_SFLOAT -> repeat(pixelCount) {
                put(
                    linearFloatChannelToSrgbByte(halfFloatToFloat(staging.getShort())),
                    linearFloatChannelToSrgbByte(halfFloatToFloat(staging.getShort())),
                    linearFloatChannelToSrgbByte(halfFloatToFloat(staging.getShort())),
                    floatChannelToByte(halfFloatToFloat(staging.getShort()))
                )
            }

            VK_FORMAT_R32G32B32A32_SFLOAT -> repeat(pixelCount) {
                put(
                    linearFloatChannelToSrgbByte(staging.getFloat()),
                    linearFloatChannelToSrgbByte(staging.getFloat()),
                    linearFloatChannelToSrgbByte(staging.getFloat()),
                    floatChannelToByte(staging.getFloat())
                )
            }

            // One- and two-channel targets carry no colour of their own — an ONNX depth map is the
            // motivating case. Replicate a lone channel to grey, and pad a missing one with zero,
            // so the capture shows the data instead of refusing it. SFLOAT channels are linear
            // light and get the same sRGB encode as the RGBA float branches above; UNORM/SNORM are
            // already encoded, so they pass through the way the 8-bit branches do.
            VK_FORMAT_R16_SFLOAT -> repeat(pixelCount) {
                val v = linearFloatChannelToSrgbByte(halfFloatToFloat(staging.getShort()))
                put(v, v, v, 255.toByte())
            }

            VK_FORMAT_R32_SFLOAT -> repeat(pixelCount) {
                val v = linearFloatChannelToSrgbByte(staging.getFloat())
                put(v, v, v, 255.toByte())
            }

            VK_FORMAT_R16_UNORM -> repeat(pixelCount) {
                val v = unormShortToByte(staging.getShort())
                put(v, v, v, 255.toByte())
            }

            VK_FORMAT_R16_SNORM -> repeat(pixelCount) {
                val v = snormShortToByte(staging.getShort())
                put(v, v, v, 255.toByte())
            }

            VK_FORMAT_R16G16_SFLOAT -> repeat(pixelCount) {
                put(
                    linearFloatChannelToSrgbByte(halfFloatToFloat(staging.getShort())),
                    linearFloatChannelToSrgbByte(halfFloatToFloat(staging.getShort())),
                    0,
                    255.toByte()
                )
            }

            VK_FORMAT_R32G32_SFLOAT -> repeat(pixelCount) {
                put(
                    linearFloatChannelToSrgbByte(staging.getFloat()),
                    linearFloatChannelToSrgbByte(staging.getFloat()),
                    0,
                    255.toByte()
                )
            }

            VK_FORMAT_R16G16B16_SFLOAT -> repeat(pixelCount) {
                put(
                    linearFloatChannelToSrgbByte(halfFloatToFloat(staging.getShort())),
                    linearFloatChannelToSrgbByte(halfFloatToFloat(staging.getShort())),
                    linearFloatChannelToSrgbByte(halfFloatToFloat(staging.getShort())),
                    255.toByte()
                )
            }

            VK_FORMAT_R32G32B32_SFLOAT -> repeat(pixelCount) {
                put(
                    linearFloatChannelToSrgbByte(staging.getFloat()),
                    linearFloatChannelToSrgbByte(staging.getFloat()),
                    linearFloatChannelToSrgbByte(staging.getFloat()),
                    255.toByte()
                )
            }

            else -> throw IllegalStateException("Unsupported $sourceName capture format $format")
        }
        return rgba
    }

fun packRgbaToRgbBottomUp(rgba: ByteArray, width: Int, height: Int): ByteBuffer

Pack rows in reverse, for a source whose row 0 really is the bottom of the picture.

Nothing in this repo currently has such a source. Every readback here goes through vkCmdCopyImageToBuffer, which is top-down. This was reached by writeJpeg on the belief that the offscreen render-target path was bottom-up; it was not, and that inverted every RenderToTexture.saveJpgAsync frame. Kept — with a test — because a genuinely bottom-up source (a GL glReadPixels, a user buffer that says so) is a legitimate thing to have, and because deleting it would leave the reason the top-down twin exists undocumented.

If you are about to call this, say in a comment which source of yours is bottom-up and why. See CaptureOrientation for the per-path table.

    fun packRgbaToRgbBottomUp(rgba: ByteArray, width: Int, height: Int): ByteBuffer {
        val rgb = ByteBuffer.allocateDirect(width * height * 3)
        for (y in height - 1 downTo 0) {
            var src = y * width * 4
            repeat(width) {
                rgb.put(rgba[src])
                rgb.put(rgba[src + 1])
                rgb.put(rgba[src + 2])
                src += 4
            }
        }
        rgb.flip()
        return rgb
    }

fun packRgbaToRgbTopDown(rgba: ByteArray, width: Int, height: Int): ByteBuffer

Pack rows without changing their order — the right choice for every readback in this repo, because vkCmdCopyImageToBuffer is top-down whether the source is the swapchain or an offscreen render target.

    fun packRgbaToRgbTopDown(rgba: ByteArray, width: Int, height: Int): ByteBuffer {
        val rgb = ByteBuffer.allocateDirect(width * height * 3)
        var src = 0
        repeat(width * height) {
            rgb.put(rgba[src])
            rgb.put(rgba[src + 1])
            rgb.put(rgba[src + 2])
            src += 4
        }
        rgb.flip()
        return rgb
    }

fun writeJpegRgb(outputPath: String, rgb: ByteBuffer, width: Int, height: Int, quality: Int)

    fun writeJpegRgb(outputPath: String, rgb: ByteBuffer, width: Int, height: Int, quality: Int) {
        val ok = stbi_write_jpg(outputPath, width, height, 3, rgb, quality.coerceIn(1, 100))
        if (!ok) {
            throw IllegalStateException("stbi_write_jpg failed for $outputPath")
        }
    }

fun writeJpeg(outputPath: String, rgba: ByteArray, width: Int, height: Int, quality: Int)

Write rgba as a JPEG, preserving row order.

The only caller is RenderToTexture.completeFileSave, whose rows come from vkCmdCopyImageToBuffer on a colour attachment — the same top-down-preserving copy the swapchain path uses, and rendered through the same negative-height viewport. So it needs packRgbaToRgbTopDown, not its reversing twin. See CaptureOrientation.

body — 55 lines, from line 213
    fun writeJpeg(outputPath: String, rgba: ByteArray, width: Int, height: Int, quality: Int) {
        val path = Path.of(outputPath)
        path.parent?.let { Files.createDirectories(it) }
        writeJpegRgb(path.toString(), packRgbaToRgbTopDown(rgba, width, height), width, height, quality)
    }

    private fun halfFloatToFloat(raw: Short): Float {
        val h = raw.toInt() and 0xffff
        val sign = (h and 0x8000) shl 16
        val exponent = (h ushr 10) and 0x1f
        val mantissa = h and 0x03ff
        val bits = when (exponent) {
            0 -> {
                if (mantissa == 0) {
                    sign
                } else {
                    var m = mantissa
                    var e = -14
                    while ((m and 0x0400) == 0) {
                        m = m shl 1
                        e--
                    }
                    m = m and 0x03ff
                    sign or ((e + 127) shl 23) or (m shl 13)
                }
            }
            0x1f -> sign or 0x7f800000 or (mantissa shl 13)
            else -> sign or ((exponent - 15 + 127) shl 23) or (mantissa shl 13)
        }
        return Float.fromBits(bits)
    }

    private fun unormShortToByte(raw: Short): Byte = ((raw.toInt() and 0xffff) ushr 8).toByte()

    private fun snormShortToByte(raw: Short): Byte =
        floatChannelToByte(raw.toInt().coerceAtLeast(-32767) / 32767f)

    private fun floatChannelToByte(value: Float): Byte {
        if (!value.isFinite() || value <= 0f) return 0
        if (value >= 1f) return 255.toByte()
        return (value * 255f + 0.5f).toInt().toByte()
    }

    private fun linearFloatChannelToSrgbByte(value: Float): Byte {
        if (!value.isFinite() || value <= 0f) return 0
        val encoded = if (value >= 1f) {
            1f
        } else if (value <= 0.0031308f) {
            value * 12.92f
        } else {
            1.055f * Math.pow(value.toDouble(), 1.0 / 2.4).toFloat() - 0.055f
        }
        return floatChannelToByte(encoded)
    }
}