MetalShaderErrors.kt
src/jvmMain/kotlin/afield/graphics/shader/MetalShaderErrors.kt · 68 lines
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.graphics.shader
import afield.graphics.scene.*
import java.io.File
Explains shader failures on macOS that come from Metal name collisions rather than from the GLSL.
object MetalShaderErrors
What a shader failure on macOS means, when the cause is Metal rather than your GLSL.
On macOS Field's Vulkan runs through MoltenVK, which translates each shader to the Metal Shading
Language and compiles it with using namespace metal; in scope. A function of yours named like a type in
Metal's standard library — decay and enable_if from its type traits, array from its
containers — then collides with it, and the compile fails with Metal's report ("reference to 'decay' is
ambiguous … metal::decay"), in the translated source's line numbers, which Vulkan passes on
only as "Error creating graphics pipeline: -3". explain turns that report into what happened and what
to do; reservedNames is the list, read from the Metal headers on this machine, so a name can be refused
before anything is compiled.
object MetalShaderErrors {
private val ambiguous = Regex("""reference to '([A-Za-z_][A-Za-z0-9_]*)' is ambiguous""")
private val metalCandidate = Regex("""'metal::([A-Za-z_][A-Za-z0-9_]*)'""")
fun explain(message: String): String?
What message — Vulkan's report of a Metal compile failure — means, in plain words; null when it is not a name collision.
fun explain(message: String): String? {
val name = ambiguous.find(message)?.groupValues?.get(1)
?: metalCandidate.find(message)?.groupValues?.get(1)?.takeIf { "is ambiguous" in message || "redefinition" in message }
?: return null
if (metalCandidate.findAll(message).none { it.groupValues[1] == name }) return null
return "'$name' in your shader has the same name as metal::$name in Metal's standard library. On macOS the shader runs " +
"through MoltenVK, which translates it to Metal with `using namespace metal;`, so the two collide. Rename '$name' — " +
"a function of yours, or a stack.auto slot, whose name is its function's."
}
@Volatile private var last: Pair<Long, String>? = null
fun note(message: String): String?
Record message if it explains a Metal failure, and return the explanation.
fun note(message: String): String? = explain(message)?.also { last = System.currentTimeMillis() to it }
fun recent(withinMs: Long = 10_000): String?
The explanation of a Metal failure noted in the last few seconds, for the pipeline error that follows it; null otherwise.
fun recent(withinMs: Long = 10_000): String? = last?.takeIf { System.currentTimeMillis() - it.first < withinMs }?.second
val reservedNames: Set<String> by lazy
The names of the types Metal's standard library declares, read from the newest set of Metal headers on this machine; empty where there are none (not macOS). A function in a shader named like one of these does not build on macOS.
Only types: a function of yours that shares a name with one of Metal's functions (select, powr) is
one more overload, and those Metal would confuse with GLSL's own (saturate, mix) SPIRV-Cross
renames on the way. Both were tried on 2026-09-28 and built; decay, a type, did not.
val reservedNames: Set<String> by lazy {
runCatching {
val versions = File("/System/Library/PrivateFrameworks/GPUCompiler.framework/Versions").listFiles().orEmpty()
val headers = versions.flatMap { v -> File(v, "Libraries/lib/clang").listFiles().orEmpty().map { File(it, "include/metal") } }
// Every version's directory carries the same timestamp, and older ones can be empty: take the fullest.
.filter { it.isDirectory }.maxByOrNull { d -> d.walkTopDown().count { it.isFile } }?.takeIf { d -> d.walkTopDown().any { it.isFile } }
?: return@runCatching emptySet<String>()
val type = Regex("""^\s*(?:template\s*<[^>]*>\s*)?(?:struct|class)\s+([a-z_][a-z0-9_]*)""", RegexOption.MULTILINE)
headers.walkTopDown().filter { it.isFile }.flatMap { f -> type.findAll(runCatching { f.readText() }.getOrDefault("")).map { it.groupValues[1] } }.toSet()
}.getOrDefault(emptySet())
}
}