ShaderReloadTransactionSmoke.kt
src/jvmMain/kotlin/afield/examples/four/ShaderReloadTransactionSmoke.kt · 188 lines
package afield.examples.four
import afield.document.graph.Box
import afield.graphics.vulkan.Renderer
import afield.graphics.render.Module
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.vulkan.util.Options
import afield.graphics.vulkan.util.Window
import afield.document.graph.preamble
import afield.graphics.shader._fragment
import afield.graphics.shader.attachShader
import afield.graphics.shader.reloadShader
import kotlin.system.exitProcess
FIELD_TEST id: four.shader-reload.transaction requiresDisplay: true runner: jvm-main entrypoint: afield.examples.four.ShaderReloadTransactionSmoke tags: [smoke, four, gpu, shaders, lifecycle] timeoutSec: 180 expect: exitCode: 0 stdoutContains: - "shader reload transaction PASSED" stdoutNotContains: - "VUID-" - "Validation Error" stderrNotContains: - "VUID-" - "Validation Error"
object ShaderReloadTransactionSmoke
A full shader reload (reloadShader, the Reload Shader action) is a transaction: the new module is
built first, and only a module that actually went live rewrites the box's preamble and retires the
one it replaced.
Three reloads against one box, rendering frames between them so the retirement goes through the renderer's real deferred queue:
- a GLSL compile error —
false, old module still current and valid, preamble untouched, error on the shader property; - GLSL that compiles but whose module cannot be built, because the texture resolver refuses the new
sampler —
false, same guarantees, and the resolver's reason visible oncode. Before the fix this rewrote the preamble to the new interface, kept the old module and returnedtrue; - a successful reload that changes the push-constant interface —
true, preamble now describes the new member, the old module is invalid at once and its pipeline is destroyed a few frames later rather than when the GC finds it.
object ShaderReloadTransactionSmoke {
private val VERTEX = """
#version 450
layout(location = 0) in vec3 pos;
void main() { gl_Position = vec4(pos, 1.0); }
""".trimIndent()
private val FRAGMENT_A = """
#version 450
layout(push_constant) uniform Push { vec4 tintA; } pc;
layout(location = 0) out vec4 outColor;
void main() { outColor = pc.tintA; }
""".trimIndent()
tintB is not a member of the block this declares.
private val FRAGMENT_COMPILE_ERROR = """
#version 450
layout(push_constant) uniform Push { vec4 tintA; } pc;
layout(location = 0) out vec4 outColor;
void main() { outColor = pc.tintB; }
""".trimIndent()
Valid GLSL; the module fails because the resolver below throws for boom.
private val FRAGMENT_UNRESOLVABLE = """
#version 450
layout(push_constant) uniform Push { vec4 tintB; } pc;
layout(set = 0, binding = 0) uniform sampler2D boom;
layout(location = 0) out vec4 outColor;
void main() { outColor = pc.tintB * texture(boom, vec2(0.5)); }
""".trimIndent()
private val FRAGMENT_B = """
#version 450
layout(push_constant) uniform Push { vec4 tintB; } pc;
layout(location = 0) out vec4 outColor;
void main() { outColor = pc.tintB; }
""".trimIndent()
fun main(args: Array<String>)
body — 98 lines, from line 90
@JvmStatic
fun main(args: Array<String>) {
Options.doVulkanSDK()
Options.enableVR = false
Options.validate = true
val window = Window("Shader reload transaction")
val renderer = Renderer(window)
println("validation active: ${renderer.instance.validationActive}")
val errors = mutableListOf<String>()
val box = Box("shader-reload-transaction").apply {
name = "shader-reload-transaction"
output.errorProxy = { message, property, line ->
if (message != null) errors += "$property:$line:$message"
}
}
fun frames(n: Int) = repeat(n) {
window.pollEvents()
renderer.render()
}
val pointer = Module(parameters(renderer) {
name = "shader-reload-transaction"
vertex = VERTEX
fragment = FRAGMENT_A
textureResolver = { texture ->
if (texture.name == "boom") throw IllegalStateException("smoke resolver refuses texture 'boom'")
null
}
}).pointer()
check(pointer.current.valid) { "initial module did not build: $errors" }
renderer += "shader-reload-transaction" to pointer
try {
// attachShader reloads once, which writes the preamble for interface A.
box.attachShader(pointer, overwriteProperties = true)
check(errors.isEmpty()) { "attach reported errors: $errors" }
val moduleA = pointer.current
val preambleA = box.preamble
check("graphics-pc-structure:tintA" in preambleA) { "preamble lacks tintA after attach:\n$preambleA" }
val preparedA = box._fragment
frames(4)
// 1. compile error
box.setPropertyRaw("fragment", FRAGMENT_COMPILE_ERROR)
check(!reloadShader(box)) { "a compile error reported success" }
check(pointer.current === moduleA && moduleA.valid) { "a compile error replaced or invalidated the module" }
check(box.preamble == preambleA) { "a compile error rewrote the preamble:\n${box.preamble}" }
check(box._fragment == preparedA) { "a compile error left the failed source as the prepared stage" }
check(errors.any { it.startsWith("fragment:") }) { "no compile error on the fragment property: $errors" }
println("1. compile error: kept module, kept preamble, error ${errors.first { it.startsWith("fragment:") }.lines().first()}")
errors.clear()
frames(4)
// 2. compiles, but the module cannot be built
box.setPropertyRaw("fragment", FRAGMENT_UNRESOLVABLE)
check(!reloadShader(box)) { "a module that could not be built reported success" }
check(pointer.current === moduleA && moduleA.valid) { "a failed module build replaced or invalidated the module" }
check(box.preamble == preambleA) { "a failed module build rewrote the preamble:\n${box.preamble}" }
check("tintB" !in box.preamble) { "preamble describes an interface that never went live" }
check(box._fragment == preparedA) { "a failed module build left its source as the prepared stage" }
val buildError = errors.firstOrNull { it.startsWith("code:") && "smoke resolver refuses texture 'boom'" in it }
checkNotNull(buildError) { "the resolver's reason did not reach the box's code property: $errors" }
check("previous module is still running" in buildError) { "the error does not say the reload did not happen: $buildError" }
println("2. module build failure: kept module, kept preamble, error ${buildError.lines().first()}")
errors.clear()
frames(4)
// 3. success with a changed interface
box.setPropertyRaw("fragment", FRAGMENT_B)
check(reloadShader(box)) { "a good reload failed: $errors" }
val moduleB = pointer.current
check(moduleB !== moduleA && moduleB.valid) { "a good reload did not install a new module" }
check("graphics-pc-structure:tintB" in box.preamble) { "preamble lacks tintB:\n${box.preamble}" }
check("graphics-pc-structure:tintA" !in box.preamble) { "preamble still describes tintA:\n${box.preamble}" }
check(!moduleA.valid) { "the replaced module was not retired" }
check(moduleA.pipeline != 0L) { "the replaced module's pipeline was destroyed synchronously, under frames in flight" }
var waited = 0
while (moduleA.pipeline != 0L && waited < 200) {
frames(1)
waited++
}
check(moduleA.pipeline == 0L) { "the replaced module's pipeline was never destroyed through the retirement queue" }
check(errors.isEmpty()) { "a good reload reported errors: $errors" }
println("3. interface change: new module live, preamble updated, old pipeline retired after $waited frame(s)")
} finally {
try {
pointer.current.cleanup()
renderer.cleanup()
} finally {
window.cleanup()
}
}
println("shader reload transaction PASSED")
exitProcess(0)
}
}