ReloadCarryOver.kt
src/jvmMain/kotlin/afield/graphics/render/ReloadCarryOver.kt · 193 lines
package afield.graphics.render
import afield.graphics.shader.Introspected
import afield.graphics.shader.ShaderBindingKind
import afield.graphics.shader.ShaderInterfaceTransition
import afield.graphics.shader.sameBlockLayout
import afield.graphics.vulkan.buffers.BufferParameters
import afield.graphics.vulkan.buffers.Structure
import afield.graphics.vulkan.device.UpdatesDescriptorSet
import afield.app.diagnostics.logging.lprintln
import org.lwjgl.system.MemoryUtil
What survives when a module compiles a new shader and swaps it in: which uniform buffers keep their contents, which are reallocated, which are retired, and which of the values already written into them are re-applied.
Every module pointer finishes a reload through ReloadCarryOver.finishAndNotify, which runs this
generic policy first and the ModuleRebuild hooks second, so a hook only fills in what the policy
left. The policy reads the ShaderInterfaceTransition between the old and new introspections. A
uniform block whose layout is unchanged keeps its buffer and its bytes, including across a rename,
since the same members at the same offsets still mean what they meant. A block that was resized or
retyped gets a fresh buffer, zeroed before anything writes it, and the values set on the old one
are re-applied wherever they still fit the new reflection. A buffer the old module allocated and
the new one does not use goes through the renderer's retirement queue rather than being freed under
a frame that may still be reading it.
Only a buffer the module allocated for itself is zeroed, carried by layout or retired. One that came from the surrounding context, or that a resolver the box supplied handed back, is resolved again the way it was the first time and is never freed here. Zeroing applies to uniform blocks only: a storage buffer has its values carried and is retired the same way, but its contents are left as they are.
ModuleReloadReport is what the box side reads back: the transition, and one note per value that could not be carried, so a reload that silently dropped a uniform says which one.
internal fun Structure.reportingTo(parameters: Parameters): Structure
Point a module's uniform structure's refused writes (Structure.reportTo) at the module's error handler.
internal fun Structure.reportingTo(parameters: Parameters): Structure {
reportTo = { message -> parameters.errorHandler(message, null) }
return this
}
data class ModuleReloadReport(val transition: ShaderInterfaceTransition?, val notes: List<String> = emptyList())
What a module pointer's full reload did to the interface: the transition old→new (null when the
pointer could not compare, for want of an introspection) and notes, one per value that could not be
carried to the new module. Read by the box-side reload (reloadShader and siblings) to say it.
data class ModuleReloadReport(val transition: ShaderInterfaceTransition?, val notes: List<String> = emptyList())
internal object ReloadCarryOver
The carry-over policy of a full reload, shared by the four module pointers. Given the ShaderInterfaceTransition for the reload:
- a kept or renamed uniform block keeps its buffer and its values (a rename is the same set, binding and layout, so the bytes still mean the same thing);
- a resized or retyped block gets a new, zeroed buffer, and the values set on the old one are re-applied where they still type-check against the new reflection (Structure.carryValuesFrom);
- an added block starts zeroed;
- a buffer the module allocated that the new module no longer uses — a removed block, or the old buffer of a resized one — is retired through the renderer's deferred queue.
Only buffers the module allocated for itself are carried by layout, zeroed or retired. One somebody supplied — found by name in the context, or handed back by a custom resolver — is resolved again the way it was the first time, and never freed here.
Until 2026-09-29 the graphics pointer carried a block's buffer by name alone, so a grown block kept
its old, smaller buffer; the compute, mesh and ray-tracing pointers carried nothing, so every reload
reset their uniforms; and no pointer carried values except through stack.auto's own hook.
internal object ReloadCarryOver {
fun ubo
For a pointer's uniform-block resolver: the previous module's buffer for request when it can be
kept (same layout, module-allocated), else what original resolves.
fun ubo(
previous: Map<String, UpdatesDescriptorSet>,
transition: ShaderInterfaceTransition?,
request: BufferParameters,
original: (BufferParameters) -> BufferParameters?,
): BufferParameters? {
val oldName = transition?.oldNameOf(ShaderBindingKind.UNIFORM_BLOCK, request.name) ?: request.name
val prev = previous[oldName]
val source = prev?.source
val oldBlock = prev?.binding as? Introspected.UBO
val newBlock = request.source
if (source != null && source.allocatedByModule && source.result != null &&
oldBlock != null && newBlock != null && sameBlockLayout(oldBlock, newBlock)
) return source
return original(request)
}
fun resolve(request: BufferParameters, resolver: (BufferParameters) -> BufferParameters?): BufferParameters? {
Resolve request through resolver and remember whether the buffer is the module's own: the
resolver handed back the request itself, and nothing has been allocated for it yet.
fun resolve(request: BufferParameters, resolver: (BufferParameters) -> BufferParameters?): BufferParameters? {
val resolved = resolver(request) ?: return null
if (resolved === request && request.result == null) request.allocatedByModule = true
return resolved
}
class Finished(val notes: List<String>, val carriedFrom: Map<String, String>)
What finish did: notes, the lines worth telling the person (values that did not survive), and
carriedFrom, for each new structure it filled, the old structure's name.
fun finishAndNotify
finish, then the ModuleRebuild hooks — in that order, which is the contract: the generic carry
decides what survives, and a hook only fills in what it left (see ModuleRebuild). Every module
pointer's reload finishes through here. Returns Finished.notes.
fun finishAndNotify(
pointer: Any,
transition: ShaderInterfaceTransition?,
previousUniforms: Collection<UpdatesDescriptorSet>,
nextUniforms: Map<String, UpdatesDescriptorSet>,
previousStructures: Map<String, Structure>,
nextStructures: Map<String, Structure>,
): List<String> {
val finished = finish(transition, previousUniforms, nextUniforms, previousStructures, nextStructures)
ModuleRebuild.rebuilt(ModuleRebuild.Rebuild(pointer, previousStructures, nextStructures, finished.carriedFrom))
return finished.notes
}
fun finish
Finish a reload that installed a new module: carry values between the structures, zero the uniform blocks allocated for this reload, and retire the old module's own buffers the new one does not use.
fun finish(
transition: ShaderInterfaceTransition?,
previousUniforms: Collection<UpdatesDescriptorSet>,
nextUniforms: Map<String, UpdatesDescriptorSet>,
previousStructures: Map<String, Structure>,
nextStructures: Map<String, Structure>,
): Finished {
val notes = mutableListOf<String>()
val carriedFrom = LinkedHashMap<String, String>()
val previousSources = previousUniforms.mapNotNull { it.source }
val nextSources = nextUniforms.values.mapNotNull { it.source }
// Zero the blocks this reload allocated, before anything writes them: a fresh allocation is
// whatever memory it was given, and a shader reading a member nobody has set yet should read 0.
nextUniforms.values.forEach { u ->
val source = u.source ?: return@forEach
if (u.binding !is Introspected.UBO || isStorage(u) || !source.allocatedByModule) return@forEach
if (previousSources.any { it === source }) return@forEach
zero(source)
}
nextStructures.forEach { (name, structure) ->
val kind = if (isStorage(nextUniforms[name])) ShaderBindingKind.STORAGE_BUFFER else ShaderBindingKind.UNIFORM_BLOCK
val oldName = transition?.oldNameOf(kind, name) ?: name
val old = previousStructures[oldName] ?: return@forEach
notes += structure.carryValuesFrom(old, name)
carriedFrom[name] = oldName
}
previousUniforms.forEach { u ->
val source = u.source ?: return@forEach
if (!source.allocatedByModule || nextSources.any { it === source }) return@forEach
retire(source, u.binding.name)
}
return Finished(notes, carriedFrom)
}
An SSBO: its buffer parameters ask for storage usage. Only uniform blocks are zeroed here.
private fun isStorage(u: UpdatesDescriptorSet?): Boolean {
val source = u?.source ?: return false
return source.usage and org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT != 0 &&
source.usage and org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT == 0
}
private fun zero(source: BufferParameters) {
val group = source.result ?: return
group.buffers.forEach { buffer ->
runCatching {
buffer.mapAndWrite_bytes { MemoryUtil.memSet(MemoryUtil.memAddress(this, 0), 0, capacity().toLong()) }
}.onFailure { lprintln("warning,graphics", "could not zero the new buffer for `${source.name}`: ${it.message}") }
}
}
private fun retire(source: BufferParameters, name: String) {
val group = source.result ?: return
// VulkanBuffer.cleanup defers destruction through the renderer's retirement queue, so a frame
// still recording or executing with the old module finds its buffer valid.
group.buffers.forEach { runCatching { it.cleanup() }.onFailure { e -> lprintln("warning,graphics", "retiring `$name`: ${e.message}") } }
}
}