Skip to content

SlugBuffer.kt

src/jvmMain/kotlin/afield/graphics/text/slug/SlugBuffer.kt · 98 lines

The GPU side of Slug filled-path rendering: SlugBuffer holds one SlugPath's atlas words in a Vulkan storage buffer that a slug shader reads.

SlugBuffer.replace swaps in new geometry behind the same buffer supplier, so existing shader bindings keep working, and SlugBuffer.close falls back to a shared empty atlas that draws nothing. SlugRenderSmoke exercises it against a live renderer.

package afield.graphics.text.slug

import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.buffers.FrameBufferGroup
import afield.graphics.vulkan.buffers.HasBuffer
import afield.graphics.vulkan.buffers.defaultBuffer
import afield.graphics.vulkan.device.CommandBuffer
import java.nio.ByteOrder

class SlugBuffer(private val renderer: Renderer, path: SlugPath) : HasBuffer, AutoCloseable

An immutable Slug atlas upload behind a stable address supplier.

Call replace on the render thread after preparing geometry. It uploads a fresh allocation, publishes it, then retires the old allocation through Field's existing deferred GPU cleanup. Camera and fill edits do not call replace.

close can come before or after the consuming mesh is removed. A closed buffer hands its consumers the renderer's empty atlas, whose bounds are empty, so the slug shader clips the quad away and draws nothing; the real allocation is retired once pending frames finish.

class SlugBuffer(private val renderer: Renderer, path: SlugPath) : HasBuffer, AutoCloseable {
    @Volatile private var current: FrameBufferGroup = upload(path)
    private val empty: FrameBufferGroup = emptyAtlas(renderer)

@Volatile var isClosed: Boolean

    @Volatile var isClosed: Boolean = false
        private set

var revision: Long

    var revision: Long = 0
        private set

    private fun upload(path: SlugPath): FrameBufferGroup = upload(renderer, path.byteSize, path.words)

    private companion object {
        /** One empty atlas per renderer, shared by every closed buffer; it lives as long as the renderer. */
        private val emptyAtlases = java.util.WeakHashMap<Renderer, FrameBufferGroup>()

        /** Sixteen zero words: the bounds (words 8–11) are empty, which the vertex shader draws as nothing. */
        fun emptyAtlas(renderer: Renderer): FrameBufferGroup = synchronized(emptyAtlases) {
            emptyAtlases.getOrPut(renderer) { upload(renderer, 16L * 4, IntArray(16)) }
        }

        fun upload(renderer: Renderer, byteSize: Long, words: IntArray): FrameBufferGroup {
            val data = defaultBuffer(name = "slug-atlas") { size = byteSize }.toBuffer(renderer)
            try {
                data.mapAndWrite_bytes { order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().put(words) }
                return data
            } catch (failure: Throwable) {
                data.buffers.forEach { it.cleanup() }
                throw failure
            }
        }
    }

fun replace(path: SlugPath)

Publish newly prepared geometry; existing shader bindings keep the same supplier.

    @Synchronized
    fun replace(path: SlugPath) {
        check(!isClosed) { "SlugBuffer is closed" }
        val next = upload(path)
        val previous = current
        current = next
        revision++
        previous.buffers.forEach { it.cleanup() }
    }

override fun getBuffer(): Long = current.getBuffer()

override fun getGPUAddress(cmd: CommandBuffer): Long = current.getGPUAddress(cmd)

override fun close()

Retire the atlas. A mesh still bound to this buffer draws nothing from the next frame on, so it does not matter whether the mesh is removed first. Repeated close calls are harmless.

    @Synchronized
    override fun close() {
        if (isClosed) return
        isClosed = true
        val previous = current
        current = empty
        previous.buffers.forEach { it.cleanup() }
    }
}

object SlugShaders

Shader sources shared by the documented box and shader/GPU regression tests.

val vertex: String get() = source("vertex")

val fragment: String get() = source("fragment")

    private fun source(stage: String): String = requireNotNull(
        SlugShaders::class.java.getResource("/afield/slug/$stage.glsl")
    ).readText()
}