Skip to content

SlangCompiler.kt

src/jvmMain/kotlin/afield/graphics/shader/SlangCompiler.kt · 420 lines

package afield.graphics.shader

import afield.graphics.render.pointer

import afield.app.diagnostics.LatencySpans
import afield.app.diagnostics.logging.lprintln
import org.json.JSONObject
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.util.Comparator
import kotlin.io.path.deleteIfExists
import kotlin.io.path.readText
import kotlin.io.path.writeText

Compiles Slang shaders by running an external slangc, to GLSL, SPIR-V and reflection JSON.

SlangCompiler looks for the executable in AFIELD_SLANGC, then -Dafield.slangc, then the pinned download, the Vulkan SDK and PATH. compileVertexFragment and compileSharedStage return one SlangStageArtifacts per stage, ready for Introspection.

private data class SlangTargetOptions(
    val stage: String,
    val glslProfile: String,
    val spirvProfile: String = "sm_6_5"
)

private data class SlangExecutableCandidate(
    val command: String,
    val description: String
)

data class SlangEntryPoint

data class SlangEntryPoint(
    /**
     * The function's name, as the reflection JSON spells it. It is what reaches `slangc` as
     * `-entry` on the compile that follows.
     */
    val name: String,
    /**
     * The stage the function is written for, lowercased: `vertex`, `fragment`, `compute`, `mesh`,
     * `task`, or one of the ray-tracing stages such as `closesthit`. Compiling a stage matches this
     * against the `slangc` stage word for the shader kind asked for.
     */
    val stage: String
)

data class SlangStageArtifacts

data class SlangStageArtifacts(
    /** The shader kind this stage was compiled as, which chose the profile and the stage word. */
    val kind: ShaderKind,
    /**
     * The one entry point in the source that claimed this stage. A file with none, or with more
     * than one, does not compile.
     */
    val entryPoint: SlangEntryPoint,
    /**
     * The GLSL `slangc` emitted for this stage, with parameter names and layout preserved
     * (`-no-mangle`, `-preserve-params`, column-major matrices) so that introspecting it recovers
     * the names the Slang source used.
     */
    val glslSource: String,
    /**
     * `slangc`'s reflection JSON, as emitted beside the SPIR-V rather than beside the GLSL, so it
     * describes the SPIR-V binding numbers.
     */
    val reflectionJson: String,
    /** The compiled SPIR-V, already in the off-heap buffer the Vulkan calls want. */
    val spirv: ShaderSPIRVUtils.SPIRV
)

object SlangCompiler

object SlangCompiler {
    private val isWindows: Boolean
        get() = System.getProperty("os.name")?.contains("Windows", ignoreCase = true) == true

    private fun executableCandidates(): List<SlangExecutableCandidate> {
        System.getenv("AFIELD_SLANGC")
            ?.takeIf { it.isNotBlank() }
            ?.let {
                return listOf(SlangExecutableCandidate(it, "AFIELD_SLANGC"))
            }

        System.getProperty("afield.slangc")
            ?.takeIf { it.isNotBlank() }
            ?.let {
                return listOf(SlangExecutableCandidate(it, "-Dafield.slangc"))
            }

        // Pinned, then the Vulkan SDK's, then PATH: a stray PATH slangc is often an old one
        // (a 2025 /usr/local/bin/slangc shadowed both the 2026 SDK and the pin on a dev machine).
        return buildList {
            // prepareSlangCompiler (first-run setup) or buildLearnedShaders fetches the pinned
            // compiler into the shared native cache; the local pointer keeps platform/cache/version
            // rules out of the live compiler.
            val prepared = Path.of("build/lighting/slangc.path")
            if (Files.isRegularFile(prepared)) {
                prepared.readText(StandardCharsets.UTF_8).trim().takeIf { it.isNotEmpty() }?.let {
                    add(SlangExecutableCandidate(it, "pinned (build/lighting/slangc.path)"))
                }
            }
            vulkanSdkSlangc()?.let { add(SlangExecutableCandidate(it, "Vulkan SDK")) }
            if (isWindows) add(SlangExecutableCandidate("slangc.exe", "PATH"))
            add(SlangExecutableCandidate("slangc", "PATH"))
        }
    }

The LunarG SDK has shipped slangc in bin/ since 1.3.296. On macOS, use the same SDK Field loads Vulkan from; elsewhere VULKAN_SDK is the SDK's own convention.

    private fun vulkanSdkSlangc(): String? {
        val bin = afield.graphics.vulkan.util.Options.macVulkanSdk?.resolve("bin")
            ?: System.getenv("VULKAN_SDK")?.takeIf { it.isNotBlank() }?.let { java.io.File(it, "bin") }
            ?: return null
        return bin.resolve(if (isWindows) "slangc.exe" else "slangc").takeIf { it.canExecute() }?.absolutePath
    }

fun compileToGlsl(source: String, kind: ShaderKind, entryFileName: String): String

Translates source to GLSL for one stage and returns the text, with no entry point named and no SPIR-V or reflection asked for. For a stage that will be introspected and loaded, use compileSharedStage instead.

    fun compileToGlsl(source: String, kind: ShaderKind, entryFileName: String): String {
        val options = targetOptionsFor(kind)
        return withTempSource(entryFileName, source) { candidate, tempDir, inputFile ->
            val outputFile = tempDir.resolve(entryFileName.substringBeforeLast('.', entryFileName) + ".glsl")
            runSlangc(
                candidate,
                listOf(
                    "-target", "glsl",
                    "-profile", options.glslProfile,
                    "-stage", options.stage,
                    "-no-mangle",
                    "-preserve-params",
                    "-matrix-layout-column-major",
                    "-o", outputFile.toString(),
                    inputFile.toString()
                )
            )

            if (!Files.exists(outputFile)) {
                throw CompilerError("slangc completed but produced no GLSL output for $entryFileName")
            }

            outputFile.readText(StandardCharsets.UTF_8).also { generatedGlsl ->
                lprintln(
                    "debug,slang",
                    "Generated GLSL for $entryFileName (${kind.name}):\n$generatedGlsl"
                )
            }
        }
    }

fun compileVertexFragment(source: String, entryFileName: String = "vertex_fragment.slang"): Pair<SlangStageArtifacts, SlangStageArtifacts>

Compiles both halves of a graphics shader out of one Slang file, which is where Slang differs from Field's GLSL pair: one source holds a vertex entry point and a fragment one, and the structs they pass between them are written once. The file is compiled twice, once per stage.

    fun compileVertexFragment(source: String, entryFileName: String = "vertex_fragment.slang"): Pair<SlangStageArtifacts, SlangStageArtifacts> {
        val vertex = compileSharedStage(source, ShaderKind.VERTEX_SHADER, entryFileName)
        val fragment = compileSharedStage(source, ShaderKind.FRAGMENT_SHADER, entryFileName)
        return vertex to fragment
    }

fun compileSharedStage(source: String, kind: ShaderKind, entryFileName: String): SlangStageArtifacts

Compiles one stage of a Slang source and returns everything Field needs to load it. Three slangc runs: one with -no-codegen to list the file's entry points, then one for GLSL and one for SPIR-V and reflection. The source must declare exactly one entry point for this stage. None, or two, is a CompilerError naming the ones it found.

body — 250 lines, from line 170
    fun compileSharedStage(source: String, kind: ShaderKind, entryFileName: String): SlangStageArtifacts {
        return withTempSource(entryFileName, source) { candidate, tempDir, inputFile ->
            val entryPoints = inventoryEntryPoints(candidate, tempDir, inputFile)
            val entryPoint = requireSingleEntryPoint(entryPoints, targetOptionsFor(kind).stage, entryFileName)
            compileStageArtifacts(candidate, tempDir, inputFile, kind, entryPoint, entryFileName)
        }
    }

    private fun targetOptionsFor(kind: ShaderKind): SlangTargetOptions = when (kind) {
        ShaderKind.VERTEX_SHADER -> SlangTargetOptions(stage = "vertex", glslProfile = "glsl_450")
        ShaderKind.FRAGMENT_SHADER -> SlangTargetOptions(stage = "fragment", glslProfile = "glsl_450")
        ShaderKind.COMPUTE_SHADER -> SlangTargetOptions(stage = "compute", glslProfile = "glsl_450")
        ShaderKind.MESH_SHADER -> SlangTargetOptions(stage = "mesh", glslProfile = "glsl_460")
        ShaderKind.TASK_SHADER -> SlangTargetOptions(stage = "task", glslProfile = "glsl_460")
        ShaderKind.RAYGEN_SHADER -> SlangTargetOptions(stage = "raygeneration", glslProfile = "glsl_460")
        ShaderKind.MISS_SHADER -> SlangTargetOptions(stage = "miss", glslProfile = "glsl_460")
        ShaderKind.CLOSEST_HIT_SHADER -> SlangTargetOptions(stage = "closesthit", glslProfile = "glsl_460")
        ShaderKind.ANY_HIT_SHADER -> SlangTargetOptions(stage = "anyhit", glslProfile = "glsl_460")
        ShaderKind.INTERSECTION_SHADER -> SlangTargetOptions(stage = "intersection", glslProfile = "glsl_460")
        ShaderKind.CALLABLE_SHADER -> SlangTargetOptions(stage = "callable", glslProfile = "glsl_460")
        else -> throw IllegalArgumentException("Slang is not supported for shader kind $kind")
    }

    private fun <T> withTempSource(
        entryFileName: String,
        source: String,
        block: (candidate: SlangExecutableCandidate, tempDir: Path, inputFile: Path) -> T
    ): T {
        val tempDir = Files.createTempDirectory("afield-slang-")
        val inputFile = tempDir.resolve(entryFileName)
        inputFile.writeText(source, StandardCharsets.UTF_8)

        return try {
            withResolvedExecutable { candidate ->
                block(candidate, tempDir, inputFile)
            }
        } catch (e: IOException) {
            val hint = e.message ?: "unable to launch slangc"
            throw CompilerError(hint)
        } finally {
            cleanupTempDirectory(tempDir)
        }
    }

    @Volatile
    private var announced: String? = null

    private fun <T> withResolvedExecutable(block: (SlangExecutableCandidate) -> T): T {
        val candidates = executableCandidates()
        var lastMissingExecutable: IOException? = null

        for (candidate in candidates) {
            try {
                return block(candidate).also {
                    if (announced != candidate.command) {
                        announced = candidate.command
                        lprintln("info,slang", "slangc: ${candidate.command} (${candidate.description})")
                    }
                }
            } catch (e: IOException) {
                if (isExecutableNotFound(e)) {
                    lastMissingExecutable = e
                    continue
                }
                throw CompilerError(
                    "Failed to launch slangc via ${candidate.description} (${candidate.command}): ${e.message ?: "unknown error"}"
                )
            }
        }

        throw CompilerError(buildExecutableNotFoundMessage(candidates, lastMissingExecutable))
    }

    private fun inventoryEntryPoints(
        candidate: SlangExecutableCandidate,
        tempDir: Path,
        inputFile: Path
    ): List<SlangEntryPoint> {
        val reflectionFile = tempDir.resolve("inventory-reflection.json")
        runSlangc(
            candidate,
            listOf(
                "-target", "spirv",
                "-profile", "sm_6_5",
                "-no-codegen",
                "-reflection-json", reflectionFile.toString(),
                inputFile.toString()
            )
        )

        if (!Files.exists(reflectionFile)) {
            throw CompilerError("slangc completed but produced no reflection JSON for ${inputFile.fileName}")
        }

        val root = JSONObject(reflectionFile.readText(StandardCharsets.UTF_8))
        val entryPoints = mutableListOf<SlangEntryPoint>()
        val jsonEntryPoints = root.optJSONArray("entryPoints") ?: return emptyList()
        for (i in 0 until jsonEntryPoints.length()) {
            val entry = jsonEntryPoints.getJSONObject(i)
            entryPoints += SlangEntryPoint(
                name = entry.getString("name"),
                stage = entry.getString("stage").lowercase()
            )
        }
        return entryPoints
    }

    private fun requireSingleEntryPoint(
        entryPoints: List<SlangEntryPoint>,
        stage: String,
        entryFileName: String
    ): SlangEntryPoint {
        val matches = entryPoints.filter { it.stage == stage.lowercase() }
        if (matches.size == 1) return matches.first()

        val available = if (entryPoints.isEmpty()) {
            "none"
        } else {
            entryPoints.joinToString(", ") { "${it.stage}:${it.name}" }
        }
        throw CompilerError(
            "Expected exactly one $stage entry point in $entryFileName, found ${matches.size}. Available entry points: $available"
        )
    }

    private fun compileStageArtifacts(
        candidate: SlangExecutableCandidate,
        tempDir: Path,
        inputFile: Path,
        kind: ShaderKind,
        entryPoint: SlangEntryPoint,
        entryFileName: String
    ): SlangStageArtifacts {
        val options = targetOptionsFor(kind)
        val stem = "${entryFileName.substringBeforeLast('.', entryFileName)}-${options.stage}"
        val outputFile = tempDir.resolve("$stem.glsl")
        val spirvFile = tempDir.resolve("$stem.spv")
        val reflectionFile = tempDir.resolve("$stem-reflection.json")

        runSlangc(
            candidate,
            listOf(
                "-target", "glsl",
                "-profile", options.glslProfile,
                "-stage", options.stage,
                "-entry", entryPoint.name,
                "-no-mangle",
                "-preserve-params",
                "-matrix-layout-column-major",
                "-o", outputFile.toString(),
                inputFile.toString()
            )
        )

        if (!Files.exists(outputFile)) {
            throw CompilerError("slangc completed but produced no GLSL output for ${entryPoint.name}")
        }

        val generatedGlsl = outputFile.readText(StandardCharsets.UTF_8).also {
            lprintln(
                "debug,slang",
                "Generated GLSL for ${entryFileName.substringBeforeLast('.', entryFileName)}.${entryPoint.name} (${kind.name}):\n$it"
            )
        }

        runSlangc(
            candidate,
            listOf(
                "-target", "spirv",
                "-profile", options.spirvProfile,
                "-stage", options.stage,
                "-entry", entryPoint.name,
                "-no-mangle",
                "-preserve-params",
                "-matrix-layout-column-major",
                "-reflection-json", reflectionFile.toString(),
                "-o", spirvFile.toString(),
                inputFile.toString()
            )
        )

        if (!Files.exists(spirvFile)) {
            throw CompilerError("slangc completed but produced no SPIR-V output for ${entryPoint.name}")
        }
        if (!Files.exists(reflectionFile)) {
            throw CompilerError("slangc completed but produced no reflection JSON for ${entryPoint.name}")
        }

        return SlangStageArtifacts(
            kind = kind,
            entryPoint = entryPoint,
            glslSource = generatedGlsl,
            reflectionJson = reflectionFile.readText(StandardCharsets.UTF_8),
            spirv = ShaderSPIRVUtils.wrapBytecode(Files.readAllBytes(spirvFile))
        )
    }

    private fun runSlangc(candidate: SlangExecutableCandidate, arguments: List<String>): String {
        val command = mutableListOf(candidate.command)
        command.addAll(arguments)
        val (output, exitCode) = LatencySpans.time("shader.slangc", detail = arguments.lastOrNull()) {
            val process = ProcessBuilder(command)
                .redirectErrorStream(true)
                .start()
            process.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } to process.waitFor()
        }
        if (exitCode != 0) {
            val message = output.trim().ifBlank { "slangc exited with code $exitCode" }
            throw CompilerError(message)
        }
        return output
    }

    private fun cleanupTempFile(path: Path) {
        runCatching { path.deleteIfExists() }
    }

    private fun cleanupTempDirectory(path: Path) {
        runCatching {
            if (!Files.exists(path)) return@runCatching
            Files.walk(path).use { stream ->
                stream.sorted(Comparator.reverseOrder()).forEach { cleanupTempFile(it) }
            }
        }
    }

    private fun isExecutableNotFound(e: IOException): Boolean {
        val message = e.message ?: return false
        return message.contains("No such file", ignoreCase = true) ||
            message.contains("CreateProcess error=2", ignoreCase = true) ||
            message.contains("error=2", ignoreCase = true)
    }

    private fun buildExecutableNotFoundMessage(
        candidates: List<SlangExecutableCandidate>,
        lastMissingExecutable: IOException?
    ): String {
        val searched = candidates.joinToString("\n") { "  - ${it.description}: ${it.command}" }
        val detail = lastMissingExecutable?.message?.let { "\nLast launch error: $it" } ?: ""
        return """
            Unable to find `slangc`.
            Searched:
            $searched$detail

            Run ./gradlew prepareSlangCompiler (or pick "Slang shader compiler" in ./field.sh --setup)
            to fetch Field's pinned compiler, install the Vulkan SDK or a compiler on PATH,
            or set an explicit override with `AFIELD_SLANGC` or `-Dafield.slangc=/full/path/to/slangc`.
        """.trimIndent()
    }
}