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()
}
}