Skip to content

MultiPingPongRenderToTexture.kt

src/jvmMain/kotlin/afield/graphics/vulkan/targets/MultiPingPongRenderToTexture.kt · 627 lines

package afield.graphics.vulkan.targets

import afield.app.protocol.Rect
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.document.context.fromContext
import afield.graphics.render.pointer
import afield.graphics.texture.HasDefaultTextureSampler
import afield.graphics.texture.HasImageView
import afield.graphics.texture.HasImageViewArray
import afield.graphics.texture.ImageView
import afield.graphics.texture.TextureSampler
import afield.graphics.vulkan.util.Window
import org.lwjgl.vulkan.VK10
import kotlin.math.max

Several passes per frame between two images: MultiPingPongRenderToTexture runs an optional init pass, a number of middle passes and an optional final pass, each reading what the last one wrote.

It suits iterated kernels such as blurs or relaxation steps, where one frame needs many passes. Shaders can tell the passes apart through push constants the target fills in (pass index, pass count, and whether this is the init or final pass).

class MultiPingPongRenderToTexture

Multi-pass ping-pong render target.

Executes in this order each frame: 1) optional init pass 2) otherPassCount "other" passes 3) optional final pass

Passes bounce between two internal single-color RenderToTexture targets. inputImageView always points at the current read/source image for the active pass, allowing modules to bind it in texture resolvers and sample the previous pass output.

For shaders, this class updates mesh push constants (when declared) with: - passIndex (0-based over all passes in the frame) - passCount (total passes in the frame) - isInitPass (1 for init pass, else 0) - isFinalPass (1 for final pass, else 0) - otherPassIndex (-1 outside other-pass block, otherwise 0..otherPassCount-1)

class MultiPingPongRenderToTexture(
    /** The renderer the two internal targets are built on. */
    val renderer: Renderer = fromContext(),
    configure: RTTParameters.() -> Unit = {}
) : Target, HasImageView, HasImageViewArray, HasDefaultTextureSampler {

enum class PassKind

    enum class PassKind {
        INIT,
        OTHER,
        FINAL,
        NONE
    }

var parameters = RTTParameters().apply(configure)

The RTTParameters both internal targets were built from. One color attachment and no external views, which the constructor requires; depth is forced off, and the size, format, mip and clearing fields are copied into each target when it is created, so editing them afterwards does not reach the images.

override var defaultSampler: TextureSampler? = null

fun setDefaultSampler

    fun setDefaultSampler(
        anisotropyEnable: Boolean = true,
        filter: Int = VK10.VK_FILTER_LINEAR,
        mipmapMode: Int = if (filter == VK10.VK_FILTER_LINEAR) VK10.VK_SAMPLER_MIPMAP_MODE_LINEAR else VK10.VK_SAMPLER_MIPMAP_MODE_NEAREST,
        addressModeU: Int = VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
        addressModeV: Int = VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
        addressModeW: Int = VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE
    ): TextureSampler {
        val sampler = TextureSampler(
            renderer = renderer,
            mipLevels = parameters.mipLevels,
            anisotropyEnable = anisotropyEnable,
            filter = filter,
            mipmapMode = mipmapMode,
            addressModeU = addressModeU,
            addressModeV = addressModeV,
            addressModeW = addressModeW
        )
        defaultSampler = sampler
        return sampler
    }

fun repeatU

    fun repeatU(
        anisotropyEnable: Boolean = true,
        filter: Int = VK10.VK_FILTER_LINEAR,
        mipmapMode: Int = if (filter == VK10.VK_FILTER_LINEAR) VK10.VK_SAMPLER_MIPMAP_MODE_LINEAR else VK10.VK_SAMPLER_MIPMAP_MODE_NEAREST
    ): TextureSampler = setDefaultSampler(
        anisotropyEnable = anisotropyEnable,
        filter = filter,
        mipmapMode = mipmapMode,
        addressModeU = VK10.VK_SAMPLER_ADDRESS_MODE_REPEAT,
        addressModeV = VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
        addressModeW = VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE
    )

init

body — 58 lines, from line 106
    init {
        require(parameters.colorAttachments == 1) {
            "MultiPingPongRenderToTexture currently supports colorAttachments == 1"
        }
        require(parameters.externalColorViews.isEmpty()) {
            "MultiPingPongRenderToTexture does not support externalColorViews"
        }
    }

    private val ping = RenderToTexture(renderer) {
        this.width = this@MultiPingPongRenderToTexture.parameters.width
        this.height = this@MultiPingPongRenderToTexture.parameters.height
        this.mipLevels = this@MultiPingPongRenderToTexture.parameters.mipLevels
        this.format = this@MultiPingPongRenderToTexture.parameters.format
        this.colorAttachments = 1
        this.colorFormats = if (this@MultiPingPongRenderToTexture.parameters.colorFormats.isNotEmpty()) listOf(this@MultiPingPongRenderToTexture.parameters.colorFormats.first()) else emptyList()
        this.colorLayerCount = this@MultiPingPongRenderToTexture.parameters.colorLayerCount
        this.forceColorArrayView = this@MultiPingPongRenderToTexture.parameters.forceColorArrayView
        this.viewports = this@MultiPingPongRenderToTexture.parameters.viewports
        this.multiviewViewMask = this@MultiPingPongRenderToTexture.parameters.multiviewViewMask
        this.clearColor = this@MultiPingPongRenderToTexture.parameters.clearColor
        this.clearFirst = this@MultiPingPongRenderToTexture.parameters.clearFirst
        this.colorStoreOp = this@MultiPingPongRenderToTexture.parameters.colorStoreOp
        this.colorSampled = this@MultiPingPongRenderToTexture.parameters.colorSampled
        this.colorTransferSrc = this@MultiPingPongRenderToTexture.parameters.colorTransferSrc
        this.colorTransferDst = this@MultiPingPongRenderToTexture.parameters.colorTransferDst
        this.generateColorMipmaps = this@MultiPingPongRenderToTexture.parameters.generateColorMipmaps
        this.mipBlitFilter = this@MultiPingPongRenderToTexture.parameters.mipBlitFilter
        this.useDepth = false
    }

    private val pong = RenderToTexture(renderer) {
        this.width = this@MultiPingPongRenderToTexture.parameters.width
        this.height = this@MultiPingPongRenderToTexture.parameters.height
        this.mipLevels = this@MultiPingPongRenderToTexture.parameters.mipLevels
        this.format = this@MultiPingPongRenderToTexture.parameters.format
        this.colorAttachments = 1
        this.colorFormats = if (this@MultiPingPongRenderToTexture.parameters.colorFormats.isNotEmpty()) listOf(this@MultiPingPongRenderToTexture.parameters.colorFormats.first()) else emptyList()
        this.colorLayerCount = this@MultiPingPongRenderToTexture.parameters.colorLayerCount
        this.forceColorArrayView = this@MultiPingPongRenderToTexture.parameters.forceColorArrayView
        this.viewports = this@MultiPingPongRenderToTexture.parameters.viewports
        this.multiviewViewMask = this@MultiPingPongRenderToTexture.parameters.multiviewViewMask
        this.clearColor = this@MultiPingPongRenderToTexture.parameters.clearColor
        this.clearFirst = this@MultiPingPongRenderToTexture.parameters.clearFirst
        this.colorStoreOp = this@MultiPingPongRenderToTexture.parameters.colorStoreOp
        this.colorSampled = this@MultiPingPongRenderToTexture.parameters.colorSampled
        this.colorTransferSrc = this@MultiPingPongRenderToTexture.parameters.colorTransferSrc
        this.colorTransferDst = this@MultiPingPongRenderToTexture.parameters.colorTransferDst
        this.generateColorMipmaps = this@MultiPingPongRenderToTexture.parameters.generateColorMipmaps
        this.mipBlitFilter = this@MultiPingPongRenderToTexture.parameters.mipBlitFilter
        this.useDepth = false
    }

    private val targets = listOf(ping, pong)

    private var readIndex = 1
    private var writeIndex = 0
    private var lastWrittenIndex = 0

var currentPassIndex: Int

Which pass is recording, counted from 0 over every pass in the frame, and -1 between frames and whenever the frame has no passes at all. The same number the shaders read as passIndex.

    var currentPassIndex: Int = -1
        private set

var currentPassKind: PassKind

Which phase is recording: the init pass, one of the middle passes, or the final one, and PassKind.NONE outside a pass.

    var currentPassKind: PassKind = PassKind.NONE
        private set

var otherPassCount: Int = 1

How many middle passes to run each frame, between the init and final ones. This is the dial an iterated kernel turns: four blur passes, or forty relaxation steps. A negative value is read as none.

var runInitPass: Boolean = true

Whether to run the init pass, the one that seeds the image the middle passes iterate.

var runFinalPass: Boolean = true

Whether to run the final pass, the one that reads the last middle pass and writes the image this target then publishes as its own view.

val initModules = mutableMapOf<String, ModulePointer>()

The modules the init pass draws, by name. Added with addInit.

val otherPassModules = mutableMapOf<String, ModulePointer>()

The modules every middle pass draws, by name: the same modules re-recorded once per pass, reading what the pass before them wrote. Added with +=, or with addOther.

val finalModules = mutableMapOf<String, ModulePointer>()

The modules the final pass draws, by name. Added with addFinal.

val inputImageView: HasImageView

The image the recording pass reads: whichever of the two targets the pass before it wrote. Bind this where a module samples the previous pass's output; the object is fixed and its view follows the swap.

    val inputImageView: HasImageView = object : HasImageView, HasDefaultTextureSampler {
        override val imageView: ImageView
            get() = targets[readIndex].imageView
        override var defaultSampler: TextureSampler?
            get() = this@MultiPingPongRenderToTexture.defaultSampler
            set(value) {
                this@MultiPingPongRenderToTexture.defaultSampler = value
            }
    }

val inputImageViewArray: HasImageViewArray

inputImageView as a one-element array, for a binding that takes an image array rather than a single image.

    val inputImageViewArray: HasImageViewArray = object : HasImageViewArray, HasDefaultTextureSampler {
        override val imageViewArray: List<HasImageView>
            get() = listOf(inputImageView)
        override var defaultSampler: TextureSampler?
            get() = this@MultiPingPongRenderToTexture.defaultSampler
            set(value) {
                this@MultiPingPongRenderToTexture.defaultSampler = value
            }

    }

val writeImageView: HasImageView

The image the recording pass is writing — the other target from inputImageView. Reading it while that pass runs is reading an attachment the pass has not finished, so the frame's result is the view this target itself publishes.

    val writeImageView: HasImageView = object : HasImageView, HasDefaultTextureSampler {
        override val imageView: ImageView
            get() = targets[writeIndex].imageView
        override var defaultSampler: TextureSampler?
            get() = this@MultiPingPongRenderToTexture.defaultSampler
            set(value) {
                this@MultiPingPongRenderToTexture.defaultSampler = value
            }
    }

override val imageView: ImageView

    override val imageView: ImageView
        get() = targets[lastWrittenIndex].imageView

override val imageViewArray: List<HasImageView>

    override val imageViewArray: List<HasImageView>
        get() = listOf(this)

fun blitToScreen

Matches RenderToTexture.blitToScreen, delegating to whichever internal target contains the final output for the configured pass count parity.

    fun blitToScreen(
        renderer: Renderer = this.renderer,
        target: RenderTarget = renderer.renderList,
        attachment: Int = 0,
        name: String = "rtt-blit",
        viewport: Rect? = null,
        sourceRect: Rect? = null,
        blend: Boolean = false,
        additive: Boolean = false,
        sourceLayer: Int = 0,
        warper: String? = null,
        numSegmentsW: Int = 1,
        numSegmentsH: Int = 1,
        configure: RenderToTexture.ColorGrade.() -> Unit = {}
    ): ModulePointer {
        require(attachment == 0) { "MultiPingPongRenderToTexture only supports attachment 0" }
        val outputTarget = targetForConfiguredPassParity()
        return outputTarget.blitToScreen(
            renderer = renderer,
            target = target,
            attachment = 0,
            name = name,
            viewport = viewport,
            sourceRect = sourceRect,
            blend = blend,
            additive = additive,
            sourceLayer = sourceLayer,
            warper = warper,
            numSegmentsW = numSegmentsW,
            numSegmentsH = numSegmentsH,
            configure = configure
        )
    }

fun blitToScreen

    fun blitToScreen(
        windowIndex: Int,
        renderer: Renderer = this.renderer,
        attachment: Int = 0,
        name: String = "rtt-blit",
        viewport: Rect? = null,
        sourceRect: Rect? = null,
        blend: Boolean = false,
        additive: Boolean = false,
        sourceLayer: Int = 0,
        warper: String? = null,
        numSegmentsW: Int = 1,
        numSegmentsH: Int = 1,
        configure: RenderToTexture.ColorGrade.() -> Unit = {}
    ): ModulePointer = blitToScreen(
        renderer = renderer,
        target = renderer.renderTargetFor(windowIndex),
        attachment = attachment,
        name = name,
        viewport = viewport,
        sourceRect = sourceRect,
        blend = blend,
        additive = additive,
        sourceLayer = sourceLayer,
        warper = warper,
        numSegmentsW = numSegmentsW,
        numSegmentsH = numSegmentsH,
        configure = configure
    )

fun blitToScreen

    fun blitToScreen(
        window: Window,
        renderer: Renderer = this.renderer,
        attachment: Int = 0,
        name: String = "rtt-blit",
        viewport: Rect? = null,
        sourceRect: Rect? = null,
        blend: Boolean = false,
        additive: Boolean = false,
        sourceLayer: Int = 0,
        warper: String? = null,
        numSegmentsW: Int = 1,
        numSegmentsH: Int = 1,
        configure: RenderToTexture.ColorGrade.() -> Unit = {}
    ): ModulePointer = blitToScreen(
        windowIndex = renderer.windowIndex(window),
        renderer = renderer,
        attachment = attachment,
        name = name,
        viewport = viewport,
        sourceRect = sourceRect,
        blend = blend,
        additive = additive,
        sourceLayer = sourceLayer,
        warper = warper,
        numSegmentsW = numSegmentsW,
        numSegmentsH = numSegmentsH,
        configure = configure
    )

fun debugBlitToScreen

    fun debugBlitToScreen(
        renderer: Renderer = this.renderer,
        target: RenderTarget = renderer.renderList,
        attachment: Int = 0,
        name: String = "rtt-debug-blit",
        viewport: Rect? = null,
        sourceRect: Rect? = null,
        blend: Boolean = false,
        additive: Boolean = false,
        sourceLayer: Int = 0,
        warper: String? = null,
        numSegmentsW: Int = 1,
        numSegmentsH: Int = 1,
        configure: RenderToTexture.ColorGrade.() -> Unit = {}
    ): ModulePointer = blitToScreen(
        renderer = renderer,
        target = target,
        attachment = attachment,
        name = name,
        viewport = viewport,
        sourceRect = sourceRect,
        blend = blend,
        additive = additive,
        sourceLayer = sourceLayer,
        warper = warper,
        numSegmentsW = numSegmentsW,
        numSegmentsH = numSegmentsH,
        configure = configure
    )

fun debugBlitToScreen

    fun debugBlitToScreen(
        windowIndex: Int,
        renderer: Renderer = this.renderer,
        attachment: Int = 0,
        name: String = "rtt-debug-blit",
        viewport: Rect? = null,
        sourceRect: Rect? = null,
        blend: Boolean = false,
        additive: Boolean = false,
        sourceLayer: Int = 0,
        warper: String? = null,
        numSegmentsW: Int = 1,
        numSegmentsH: Int = 1,
        configure: RenderToTexture.ColorGrade.() -> Unit = {}
    ): ModulePointer = debugBlitToScreen(
        renderer = renderer,
        target = renderer.renderTargetFor(windowIndex),
        attachment = attachment,
        name = name,
        viewport = viewport,
        sourceRect = sourceRect,
        blend = blend,
        additive = additive,
        sourceLayer = sourceLayer,
        warper = warper,
        numSegmentsW = numSegmentsW,
        numSegmentsH = numSegmentsH,
        configure = configure
    )

fun debugBlitToScreen

    fun debugBlitToScreen(
        window: Window,
        renderer: Renderer = this.renderer,
        attachment: Int = 0,
        name: String = "rtt-debug-blit",
        viewport: Rect? = null,
        sourceRect: Rect? = null,
        blend: Boolean = false,
        additive: Boolean = false,
        sourceLayer: Int = 0,
        warper: String? = null,
        numSegmentsW: Int = 1,
        numSegmentsH: Int = 1,
        configure: RenderToTexture.ColorGrade.() -> Unit = {}
    ): ModulePointer = debugBlitToScreen(
        windowIndex = renderer.windowIndex(window),
        renderer = renderer,
        attachment = attachment,
        name = name,
        viewport = viewport,
        sourceRect = sourceRect,
        blend = blend,
        additive = additive,
        sourceLayer = sourceLayer,
        warper = warper,
        numSegmentsW = numSegmentsW,
        numSegmentsH = numSegmentsH,
        configure = configure
    )

fun addInit(name: String, module: ModulePointer)

    fun addInit(name: String, module: ModulePointer) {
        validateSingleColorModule(module.current)
        initModules[name] = module
    }

fun addInit(name: String, module: Module)

    fun addInit(name: String, module: Module) {
        addInit(name, module.pointer())
    }

fun addOther(name: String, module: ModulePointer)

    fun addOther(name: String, module: ModulePointer) {
        validateSingleColorModule(module.current)
        otherPassModules[name] = module
    }

fun addOther(name: String, module: Module)

    fun addOther(name: String, module: Module) {
        addOther(name, module.pointer())
    }

fun addFinal(name: String, module: ModulePointer)

    fun addFinal(name: String, module: ModulePointer) {
        validateSingleColorModule(module.current)
        finalModules[name] = module
    }

fun addFinal(name: String, module: Module)

    fun addFinal(name: String, module: Module) {
        addFinal(name, module.pointer())
    }

operator fun plusAssign(m: Pair<String, ModulePointer>)

    @JvmName("plusAssign_other")
    operator fun plusAssign(m: Pair<String, ModulePointer>) {
        addOther(m.first, m.second)
    }

operator fun plusAssign(m: Pair<String, Module>)

    @JvmName("plusAssign_other_module")
    operator fun plusAssign(m: Pair<String, Module>) {
        addOther(m.first, m.second)
    }

override fun render(command: CommandBuffer, index: Int)

body — 137 lines, from line 457
    override fun render(command: CommandBuffer, index: Int) {
        resetPassStateForFrame()

        val stableOtherCount = max(0, otherPassCount)
        val totalPasses = (if (runInitPass) 1 else 0) + stableOtherCount + (if (runFinalPass) 1 else 0)

        if (totalPasses == 0) {
            currentPassIndex = -1
            currentPassKind = PassKind.NONE
            return
        }

        var passCursor = 0

        if (runInitPass) {
            runPass(
                kind = PassKind.INIT,
                passIndex = passCursor,
                passCount = totalPasses,
                otherIndex = -1,
                modules = initModules,
                command = command,
                swapchainIndex = index
            )
            passCursor++
        }

        for (i in 0 until stableOtherCount) {
//            println(" -- pass ${passCursor}")
            runPass(
                kind = PassKind.OTHER,
                passIndex = passCursor,
                passCount = totalPasses,
                otherIndex = i,
                modules = otherPassModules,
                command = command,
                swapchainIndex = index
            )
            passCursor++
        }

        if (runFinalPass) {
            runPass(
                kind = PassKind.FINAL,
                passIndex = passCursor,
                passCount = totalPasses,
                otherIndex = -1,
                modules = finalModules,
                command = command,
                swapchainIndex = index
            )
            passCursor++
        }

        currentPassIndex = -1
        currentPassKind = PassKind.NONE
    }

    private fun runPass(
        kind: PassKind,
        passIndex: Int,
        passCount: Int,
        otherIndex: Int,
        modules: Map<String, ModulePointer>,
        command: CommandBuffer,
        swapchainIndex: Int
    ) {
        currentPassIndex = passIndex
        currentPassKind = kind

        val target = targets[writeIndex]
        target.modules.clear()
        target.modules.putAll(modules)

        updatePassPushConstants(
            modules = modules,
            passIndex = passIndex,
            passCount = passCount,
            isInit = kind == PassKind.INIT,
            isFinal = kind == PassKind.FINAL,
            otherIndex = otherIndex
        )

        target.render(command, swapchainIndex)
        lastWrittenIndex = writeIndex
        swap()
    }

    private fun swap() {
        val t = readIndex
        readIndex = writeIndex
        writeIndex = t
    }

    private fun resetPassStateForFrame() {
        // Keep a deterministic start orientation per frame so output parity is stable and
        // blit selection based on pass count does not flicker frame-to-frame.
        readIndex = 1
        writeIndex = 0
    }

    private fun updatePassPushConstants(
        modules: Map<String, ModulePointer>,
        passIndex: Int,
        passCount: Int,
        isInit: Boolean,
        isFinal: Boolean,
        otherIndex: Int
    ) {
        modules.values.forEach { modulePointer ->
            modulePointer.current.meshes.values.forEach { mesh ->
                if (mesh.push.offsets.contains("passIndex")) mesh.push["passIndex"] = passIndex
                if (mesh.push.offsets.contains("passCount")) mesh.push["passCount"] = passCount
                if (mesh.push.offsets.contains("isInitPass"))mesh.push["isInitPass"] = if (isInit) 1 else 0
                if (mesh.push.offsets.contains("isFinalPass"))mesh.push["isFinalPass"] = if (isFinal) 1 else 0
                if (mesh.push.offsets.contains("otherPassIndex"))mesh.push["otherPassIndex"] = otherIndex
            }
        }
    }

    private fun validateSingleColorModule(module: Module) {
        val moduleColorCount = if (module.parameters.colorFormats.isNotEmpty()) {
            module.parameters.colorFormats.size
        } else {
            module.parameters.color
        }
        require(moduleColorCount == 1) {
            "MultiPingPongRenderToTexture expects modules with exactly 1 color attachment, but module '${module.parameters.name}' declares $moduleColorCount"
        }

        val expectedFormat = if (parameters.colorFormats.isNotEmpty()) parameters.colorFormats.first() else parameters.format
        if (module.parameters.colorFormats.isNotEmpty()) {
            require(module.parameters.colorFormats.first() == expectedFormat) {
                "MultiPingPongRenderToTexture color format mismatch for module '${module.parameters.name}': module=${module.parameters.colorFormats.first()} target=$expectedFormat. Add +jfa inside the module's parameters."
            }
        }
    }

override fun toString(): String

    override fun toString(): String {
        return "MultiPingPongRenderToTexture[${parameters.width}x${parameters.height}, otherPassCount=$otherPassCount, init=$runInitPass, final=$runFinalPass]"
    }

    private fun targetForConfiguredPassParity(): RenderToTexture {
        val stableOtherCount = max(0, otherPassCount)
        val totalPasses = (if (runInitPass) 1 else 0) + stableOtherCount + (if (runFinalPass) 1 else 0)
        if (totalPasses <= 0) return targets[lastWrittenIndex]
        val outputIndex = if ((totalPasses and 1) == 1) 0 else 1
        return targets[outputIndex]
    }

companion object

fun jumpFloodOtherPassCount(width: Int, height: Int): Int

        fun jumpFloodOtherPassCount(width: Int, height: Int): Int {
            val m = max(width, height).coerceAtLeast(1)
            val maxPow2Ceil = highestPowerOfTwoCeil(m)
            var step = maxPow2Ceil / 2
            var count = 0
            while (step >= 1) {
                count++
                step /= 2
            }
            return count
        }

        private fun highestPowerOfTwoCeil(value: Int): Int {
            var v = 1
            while (v < value) v = v shl 1
            return v
        }
    }
}