Skip to content

ShaderSPIRVUtils.kt

src/jvmMain/kotlin/afield/graphics/shader/ShaderSPIRVUtils.kt · 434 lines

package afield.graphics.shader

import afield.graphics.render.main
import afield.graphics.scene.main

import afield.graphics.vulkan.util.Options
import afield.app.diagnostics.LatencySpans
import afield.app.diagnostics.logging.lprintln
import org.lwjgl.system.MemoryUtil
import org.lwjgl.system.NativeResource
import org.lwjgl.util.shaderc.Shaderc
import org.lwjgl.util.shaderc.ShadercIncludeResolve
import org.lwjgl.util.shaderc.ShadercIncludeResult
import org.lwjgl.util.shaderc.ShadercIncludeResultRelease
import org.lwjgl.vulkan.KHRRayTracingPipeline
import org.lwjgl.vulkan.EXTMeshShader.VK_SHADER_STAGE_MESH_BIT_EXT
import org.lwjgl.vulkan.EXTMeshShader.VK_SHADER_STAGE_TASK_BIT_EXT
import org.lwjgl.vulkan.VK10
import java.io.File
import java.io.IOException
import java.net.URI
import java.net.URISyntaxException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
import java.nio.file.Paths
import java.util.concurrent.ConcurrentHashMap

GLSL to SPIR-V compilation through shaderc, with #include resolution and line-numbered error reports.

ShaderSPIRVUtils compiles a source for one ShaderKind (vertex, fragment, compute, mesh, task and the ray-tracing stages), and throws CompilerError with the compiler's message on failure. A ShaderIncludeResolver passed in a ShaderCompileSource answers each include request.

enum class ShaderKind(internal val kind: Int)

enum class ShaderKind(internal val kind: Int) {
    VERTEX_SHADER(Shaderc.shaderc_glsl_vertex_shader), GEOMETRY_SHADER(Shaderc.shaderc_glsl_geometry_shader), FRAGMENT_SHADER(
        Shaderc.shaderc_glsl_fragment_shader
    ),
    COMPUTE_SHADER(Shaderc.shaderc_glsl_compute_shader),

    MESH_SHADER(Shaderc.shaderc_glsl_mesh_shader),
    TASK_SHADER(Shaderc.shaderc_glsl_task_shader),

    RAYGEN_SHADER(Shaderc.shaderc_glsl_raygen_shader),
    ANY_HIT_SHADER(Shaderc.shaderc_glsl_anyhit_shader),
    CLOSEST_HIT_SHADER(Shaderc.shaderc_glsl_closesthit_shader),
    MISS_SHADER(Shaderc.shaderc_glsl_miss_shader),
    INTERSECTION_SHADER(Shaderc.shaderc_glsl_intersection_shader),
    CALLABLE_SHADER(Shaderc.shaderc_glsl_callable_shader);

fun toVulkan(): Int

The VK_SHADER_STAGE_* bit for this kind, as a pipeline stage wants it. Mesh and task are the VK_EXT_mesh_shader bits and the six ray-tracing kinds the VK_KHR_ray_tracing_pipeline ones, so the stage a shader compiles for is also the extension it needs.

    fun toVulkan(): Int {
        return when(kind) {
            Shaderc.shaderc_glsl_vertex_shader -> VK10.VK_SHADER_STAGE_VERTEX_BIT
            Shaderc.shaderc_glsl_fragment_shader -> VK10.VK_SHADER_STAGE_FRAGMENT_BIT
            Shaderc.shaderc_glsl_geometry_shader -> VK10.VK_SHADER_STAGE_GEOMETRY_BIT
            Shaderc.shaderc_glsl_compute_shader -> VK10.VK_SHADER_STAGE_COMPUTE_BIT
            Shaderc.shaderc_glsl_mesh_shader -> VK_SHADER_STAGE_MESH_BIT_EXT
            Shaderc.shaderc_glsl_task_shader ->VK_SHADER_STAGE_TASK_BIT_EXT
            Shaderc.shaderc_glsl_raygen_shader -> KHRRayTracingPipeline.VK_SHADER_STAGE_RAYGEN_BIT_KHR
            Shaderc.shaderc_glsl_anyhit_shader -> KHRRayTracingPipeline.VK_SHADER_STAGE_ANY_HIT_BIT_KHR
            Shaderc.shaderc_glsl_closesthit_shader -> KHRRayTracingPipeline.VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR
            Shaderc.shaderc_glsl_miss_shader -> KHRRayTracingPipeline.VK_SHADER_STAGE_MISS_BIT_KHR
            Shaderc.shaderc_glsl_intersection_shader -> KHRRayTracingPipeline.VK_SHADER_STAGE_INTERSECTION_BIT_KHR
            Shaderc.shaderc_glsl_callable_shader -> KHRRayTracingPipeline.VK_SHADER_STAGE_CALLABLE_BIT_KHR
            else -> throw RuntimeException(" unknown shader type $kind")
        }
    }
}

class CompilerError(override val message : String) : Exception(message)

class CompilerError(override val message : String) : Exception(message) {

}

enum class ShaderIncludeType(internal val shadercType: Int)

enum class ShaderIncludeType(internal val shadercType: Int) {
    RELATIVE(Shaderc.shaderc_include_type_relative),
    STANDARD(Shaderc.shaderc_include_type_standard);

companion object

fun fromShaderc(type: Int): ShaderIncludeType

        fun fromShaderc(type: Int): ShaderIncludeType = when (type) {
            Shaderc.shaderc_include_type_relative -> RELATIVE
            Shaderc.shaderc_include_type_standard -> STANDARD
            else -> throw IllegalArgumentException("Unknown shader include type $type")
        }
    }
}

data class ShaderIncludeRequest

data class ShaderIncludeRequest(
    /** Exactly what the `#include` said, unresolved. */
    val requestedPath: String,
    /** `RELATIVE` for `#include "…"`, `STANDARD` for `#include <…>`. */
    val includeType: ShaderIncludeType,
    /**
     * The name of the source that wrote this `#include`, or null when shaderc did not supply one.
     * An unresolved include reports that case as `<root>`.
     */
    val requestingSource: String?,
    /**
     * How deep this include is nested, straight from shaderc. Informational: nothing here enforces
     * a limit on it.
     */
    val includeDepth: Long
)

data class ShaderIncludeResolution

data class ShaderIncludeResolution(
    /**
     * What to call the included text. shaderc reports errors inside it against this name, and it is
     * the `requestingSource` of anything the text itself includes.
     */
    val resolvedPath: String,
    /** The text to compile in place of the `#include` line. */
    val source: String
)

fun interface ShaderIncludeResolver

fun interface ShaderIncludeResolver {

fun resolve(request: ShaderIncludeRequest): ShaderIncludeResolution?

Answers one #include, or returns null to fail the compile with "Unable to resolve include '…' from '…'". Throwing fails it the same way, with the exception's own message, so a resolver need not catch anything to produce a readable error.

data class ShaderCompileSource

data class ShaderCompileSource(
    /** What to call this source in compiler errors: a filename, or any name the reader will know. */
    val sourceName: String,
    /** The GLSL itself. It is passed with an explicit length, so shaderc sees exactly these characters. */
    val sourceText: String,
    /**
     * Resolves this source's `#include` lines. Left null, no include callback is installed at all,
     * so nothing can answer one.
     */
    val includeResolver: ShaderIncludeResolver? = null
)

object ShaderSPIRVUtils

object ShaderSPIRVUtils {

fun wrapBytecode(bytes: ByteArray): SPIRV

Wraps SPIR-V that something else compiled (slangc's output, or a .spv read from disk) as a SPIRV the pipeline code accepts. The bytes are copied into a direct, native-order buffer with no shaderc result behind it, so freeing the result releases only the reference.

    fun wrapBytecode(bytes: ByteArray): SPIRV {
        val buffer = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder())
        buffer.put(bytes)
        buffer.flip()
        return SPIRV(0L, buffer)
    }

fun compileShaderFile(shaderFile: String?, shaderKind: ShaderKind): SPIRV?

Compiles a shader stored as a classpath resource, named the way the system class loader wants it. A name no resource matches throws rather than returning null — the null result is for a resource that is found and then cannot be read.

    fun compileShaderFile(shaderFile: String?, shaderKind: ShaderKind): SPIRV? {
        return compileShaderAbsoluteFile(
            ClassLoader.getSystemClassLoader().getResource(shaderFile).toExternalForm(), shaderKind
        )
    }

fun compileShaderAbsoluteFile(shaderFile: String, shaderKind: ShaderKind): SPIRV?

Compiles the shader at a file: URI, as compileShaderFile hands one over. Returns null when the URI will not parse or the file will not read, printing the stack trace; a compile error still arrives as CompilerError.

    fun compileShaderAbsoluteFile(shaderFile: String, shaderKind: ShaderKind): SPIRV? {
        try {
            val source = String(Files.readAllBytes(Paths.get(URI(shaderFile))))
            return compileShader(ShaderCompileSource(shaderFile, source), shaderKind)
        } catch (e: IOException) {
            e.printStackTrace()
        } catch (e: URISyntaxException) {
            e.printStackTrace()
        }
        return null
    }

fun compileShader(filename: String, source: String?, shaderKind: ShaderKind): SPIRV

Compiles source under the name filename, or reads the file at filename when source is null. No include resolver, so this overload is for a self-contained stage.

    fun compileShader(filename: String, source: String?, shaderKind: ShaderKind): SPIRV {
        return compileShader(
            ShaderCompileSource(
                sourceName = filename,
                sourceText = source ?: File(filename).readText()
            ),
            shaderKind
        )
    }

    @Volatile private var warnedPrintfWithoutLayer = false

One shaderc compiler for the life of the JVM, never released. Releasing the last compiler finalizes glslang and drops its built-in symbol tables, so a compiler made and released per call rebuilt them every time: ~55 ms per stage on an M1 Max, for any shader, however small (shader.compile in dev_docs/testing/FEEL_BENCHMARKS.md). shaderc documents a compiler as safe to use from several threads at once; options stay per call.

    private val sharedCompiler: Long by lazy {
        Shaderc.shaderc_compiler_initialize().also {
            if (it == MemoryUtil.NULL) throw RuntimeException("Failed to create shader compiler")
        }
    }

fun compileShader(source: ShaderCompileSource, shaderKind: ShaderKind): SPIRV

The compile every other overload ends at: shaderc, targeting Vulkan 1.3 and SPIR-V 1.5, with main as the entry point and this source's resolver answering its includes. Debug info is emitted only when validation or shader printf is on, and the whole call is timed as shader.compile.

body — 195 lines, from line 218
    fun compileShader(source: ShaderCompileSource, shaderKind: ShaderKind): SPIRV =
        LatencySpans.time("shader.compile", detail = "${shaderKind.name} ${source.sourceName}") {
            compileShaderUntimed(source, shaderKind)
        }

    private fun compileShaderUntimed(source: ShaderCompileSource, shaderKind: ShaderKind): SPIRV {
        // debugPrintfEXT is implemented by the validation layer: with neither validation nor shader
        // printf on, the call compiles and prints nothing. Say so once rather than leave it silent.
        if (!warnedPrintfWithoutLayer && !(Options.validate || Options.shaderDebugPrintf) &&
            source.sourceText.contains("debugPrintfEXT")
        ) {
            warnedPrintfWithoutLayer = true
            afield.app.diagnostics.logging.lprintln(
                "warning,shader",
                "${source.sourceName} calls debugPrintfEXT, but neither Options.validate nor " +
                    "Options.shaderDebugPrintf (-Dafield.four.shaderDebugPrintf=true) is on, so it will print nothing",
            )
        }
        val compiler = sharedCompiler

        val opts = Shaderc.shaderc_compile_options_initialize()
//        Shaderc.shaderc_compile_options_set_target_env(opts, Shaderc.shaderc_target_env_webgpu, Shaderc.shaderc_env_version_webgpu)

        if (Options.validate || Options.shaderDebugPrintf) {
            Shaderc.shaderc_compile_options_set_generate_debug_info(opts)
        }

        Shaderc.shaderc_compile_options_set_target_env(
            opts, Shaderc.shaderc_target_env_vulkan, Shaderc.shaderc_env_version_vulkan_1_3
        )
        Shaderc.shaderc_compile_options_set_target_spirv(
            opts, Shaderc.shaderc_spirv_version_1_5
        )

        val includeBridge = source.includeResolver?.let(::NativeShadercIncludeBridge)
        if (includeBridge != null) {
            Shaderc.shaderc_compile_options_set_include_callbacks(
                opts,
                includeBridge.resolveCallback,
                includeBridge.releaseCallback,
                MemoryUtil.NULL
            )
        }

        // Encode onto the heap, not LWJGL's MemoryStack. The CharSequence
        // overloads of shaderc_compile_into_spv stack-allocate their arguments,
        // and that stack defaults to 64KB — so a large *generated* shader (a
        // RectSL scene lowered to an SDF, say) fails with a bare "Out of stack
        // space." that reads like a compiler bug rather than a buffer limit.
        var sourceBuffer: ByteBuffer? = null
        var nameBuffer: ByteBuffer? = null
        var entryBuffer: ByteBuffer? = null
        try {
            if (Options.logShadercSource) {
                logShadercSource(source, shaderKind)
            }

            // The source is passed with an explicit length, so a NUL terminator
            // would be read as part of the program; the two names are ordinary C
            // strings and do need one.
            sourceBuffer = MemoryUtil.memUTF8(source.sourceText, false)
            nameBuffer = MemoryUtil.memUTF8(source.sourceName, true)
            entryBuffer = MemoryUtil.memUTF8("main", true)

            val result = Shaderc.shaderc_compile_into_spv(
                compiler,
                sourceBuffer,
                shaderKind.kind,
                nameBuffer,
                entryBuffer,
                opts
            )

            if (result == MemoryUtil.NULL) {
                throw CompilerError("Failed to compile shader ${source.sourceName} into SPIR-V")
            }

            if (Shaderc.shaderc_result_get_compilation_status(result) != Shaderc.shaderc_compilation_status_success) {
                lprintln("error,graphics", "Failed to compile shader ${source.sourceName} into SPIR-V: error is")
                lprintln("error,graphics", Shaderc.shaderc_result_get_error_message(result))
                throw CompilerError(Shaderc.shaderc_result_get_error_message(result)?:"")
            }

            val res = SPIRV(result, Shaderc.shaderc_result_get_bytes(result))
            lprintln("info,graphics", "compiled, got results $res ${res.bytecode()}")
            return res
        } finally {
            sourceBuffer?.let(MemoryUtil::memFree)
            nameBuffer?.let(MemoryUtil::memFree)
            entryBuffer?.let(MemoryUtil::memFree)
            includeBridge?.free()
            Shaderc.shaderc_compile_options_release(opts)
        }
    }

    private fun logShadercSource(source: ShaderCompileSource, shaderKind: ShaderKind) {
        lprintln("debug,graphics", buildString {
            appendLine(
                "GLSL source sent to shaderc: ${source.sourceName} " +
                    "kind=${shaderKind.name} chars=${source.sourceText.length}"
            )
            appendLine("----- BEGIN GLSL ${source.sourceName} -----")
            append(lineNumberedShaderSource(source.sourceText))
            if (!endsWith('\n')) appendLine()
            appendLine("----- END GLSL ${source.sourceName} -----")
        })
    }

    private fun lineNumberedShaderSource(sourceText: String): String {
        if (sourceText.isEmpty()) return "1 | "
        val lines = sourceText.split('\n')
        val renderedLineCount = if (sourceText.endsWith('\n')) lines.size - 1 else lines.size
        val width = renderedLineCount.coerceAtLeast(1).toString().length
        return buildString {
            lines.forEachIndexed { index, line ->
                if (index == lines.lastIndex && sourceText.endsWith('\n')) return@forEachIndexed
                append((index + 1).toString().padStart(width))
                append(" | ")
                append(line)
                if (index != lines.lastIndex || sourceText.endsWith('\n')) appendLine()
            }
        }
    }

    private data class NativeIncludeAllocation(
        val result: ShadercIncludeResult,
        val sourceName: ByteBuffer?,
        val content: ByteBuffer?
    ) {
        fun free() {
            sourceName?.let(MemoryUtil::memFree)
            content?.let(MemoryUtil::memFree)
            result.free()
        }
    }

    private class NativeShadercIncludeBridge(
        private val resolver: ShaderIncludeResolver
    ) : NativeResource {
        private val allocations = ConcurrentHashMap<Long, NativeIncludeAllocation>()

        val resolveCallback = ShadercIncludeResolve.create { _, requestedSource, type, requestingSource, includeDepth ->
            val request = ShaderIncludeRequest(
                requestedPath = MemoryUtil.memUTF8(requestedSource),
                includeType = ShaderIncludeType.fromShaderc(type),
                requestingSource = if (requestingSource == MemoryUtil.NULL) null else MemoryUtil.memUTF8(requestingSource),
                includeDepth = includeDepth
            )

            val resolution: ShaderIncludeResolution? = try {
                resolver.resolve(request)
            } catch (t: Throwable) {
                return@create failedInclude(request, t.message ?: "Unable to resolve include '${request.requestedPath}'.")
            }

            if (resolution == null) {
                failedInclude(request, unresolvedIncludeMessage(request))
            } else {
                createIncludeResult(resolution.resolvedPath, resolution.source)
            }
        }

        val releaseCallback = ShadercIncludeResultRelease.create { _, includeResult ->
            allocations.remove(includeResult)?.free()
        }

        override fun free() {
            allocations.values.forEach { it.free() }
            allocations.clear()
            resolveCallback.free()
            releaseCallback.free()
        }

        private fun failedInclude(request: ShaderIncludeRequest, message: String): Long {
            return createIncludeResult("", message)
        }

        private fun unresolvedIncludeMessage(request: ShaderIncludeRequest): String {
            val source = request.requestingSource?.takeIf { it.isNotBlank() } ?: "<root>"
            return "Unable to resolve include '${request.requestedPath}' from '$source'."
        }

        private fun createIncludeResult(sourceName: String, content: String): Long {
            val result = ShadercIncludeResult.calloc()
            val sourceNameBuffer = MemoryUtil.memUTF8(sourceName, true)
            val contentBuffer = MemoryUtil.memUTF8(content, true)
            result.source_name(sourceNameBuffer)
            result.content(contentBuffer)
            ShadercIncludeResult.nsource_name_length(result.address(), (sourceNameBuffer.remaining() - 1).coerceAtLeast(0).toLong())
            ShadercIncludeResult.ncontent_length(result.address(), (contentBuffer.remaining() - 1).coerceAtLeast(0).toLong())
            val address = result.address()
            allocations[address] = NativeIncludeAllocation(result, sourceNameBuffer, contentBuffer)
            return address
        }
    }

class SPIRV(private val handle: Long, private var bytecode: ByteBuffer?) : NativeResource

A compiled module: the bytes a VkShaderModule is created from. When compileShader produced it those bytes are shaderc's own, so freeing it releases shaderc's result and the buffer stops being readable; when wrapBytecode did, there is no result and freeing only drops the reference.

fun bytecode(): ByteBuffer?

        fun bytecode(): ByteBuffer? {
            bytecode?.position(0)
            return bytecode
        }

override fun free()

        override fun free() {
            if (handle != 0L) {
                Shaderc.shaderc_result_release(handle)
            }
            bytecode = null // Help the GC
        }
    }
}