ShaderTraining.kt
src/jvmMain/kotlin/afield/graphics/shader/learning/ShaderTraining.kt · 122 lines
package afield.graphics.shader.learning
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.device.Fence
import afield.media.reconstruction.ReconstructionCapture
import afield.graphics.vulkan.targets.RenderToTexture
import org.json.JSONObject
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.VK13.*
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
import java.util.Random
import java.util.concurrent.CompletableFuture
Offline distillation of a Field render target into a small learned shader: capture samples, then train them in Python.
ShaderTraining.capture renders the source RenderToTexture across a parameter range and writes train and held-out sample files, keyed by a digest of the source's shaders. ShaderTraining.trainAsync runs tools/lighting/train_shader.py through uv as a child process and returns a ShaderTrainingJob whose result completes with the model directory. The source shaders keep running in Vulkan; only their output images reach Python.
object ShaderTraining
Offline image-space distillation of a real Field render target: normalized (x,y,parameter) -> linear RGB. Source shaders and their existing bindings execute in Vulkan; they are never translated to Python. Geometry, camera, textures and other uniforms must stay fixed unless represented by capture's parameter.
fun sourceDigest(source: RenderToTexture): String
fun sourceDigest(source: RenderToTexture): String {
val digest=MessageDigest.getInstance("SHA-256")
source.modules.toSortedMap().forEach { (key,pointer) ->
digest.update(key.toByteArray())
listOf(pointer.current.vi,pointer.current.fi).forEach { digest.update(it.code.bytecode()!!.duplicate()) }
}
return digest.digest().joinToString(""){"%02x".format(it)}
}
fun capture(renderer: Renderer,source: RenderToTexture,directory: File,
Blocking export, called BETWEEN frames. Writes a new directory only; failures leave no valid manifest. Rows are six little-endian float32 values: normalized x,y,t followed by unclamped linear r,g,b. Validation uses different parameter slices, not merely different pixels from the training images.
body — 56 lines, from line 42
fun capture(renderer: Renderer,source: RenderToTexture,directory: File,
parameterRange: ClosedFloatingPointRange<Float> = -1f..1f,trainingFrames: Int=48,
validationFrames: Int=13,pixelsPerFrame: Int=2048,seed: Long=4090,
fixedState: String="Fixed camera, geometry, textures and all other uniforms",
setParameter: (Float)->Unit): File {
check(renderer.currentCommandBuffer==null) { "Capture between frames, outside a render pass" }
require(parameterRange.start.isFinite() && parameterRange.endInclusive.isFinite() && parameterRange.start<parameterRange.endInclusive)
require(trainingFrames in 2..512 && validationFrames in 2..128 && pixelsPerFrame in 1..65536)
require(source.colorImageRefs.size==1 && source.colorFormats.single() in listOf(VK_FORMAT_R32G32B32A32_SFLOAT,VK_FORMAT_R16G16B16A16_SFLOAT)) { "Capture needs one linear RGBA16F/RGBA32F attachment" }
require(source.modules.isNotEmpty() && source.modules.values.all { it.current.valid }) { "The source target needs a valid module" }
require(!directory.exists() || directory.listFiles().isNullOrEmpty()) { "Choose a new dataset directory: $directory" }
directory.mkdirs()
val hash=sourceDigest(source)
source.modules.toSortedMap().entries.forEachIndexed { i,(_,p) ->
File(directory,"source-$i.vert").writeText(p.current.vi.source)
File(directory,"source-$i.frag").writeText(p.current.fi.source)
}
val command=CommandBuffer(renderer.commandPool,true,true);val fence=Fence(renderer.device,false)
val random=Random(seed);val width=source.width;val height=source.height
val files=JSONObject()
try {
for((split,count) in listOf("train" to trainingFrames,"validation" to validationFrames)) {
val times=mutableListOf<Float>();val file=File(directory,"$split.f32")
file.outputStream().buffered().use { output ->
repeat(count) { frame ->
val fraction=if(split=="train")frame.toFloat()/(count-1) else (frame+.381966f)/count
val normalized=fraction*2-1
val parameter=parameterRange.start+fraction*(parameterRange.endInclusive-parameterRange.start)
times+=parameter;setParameter(parameter)
check(sourceDigest(source)==hash) { "Source shader changed during capture" }
vkResetCommandBuffer(command.vkCommandBuffer,0);command.beginRecording();source.render(command,0);command.endRecording()
MemoryStack.stackPush().use { renderer.graphQueue.submit(it.pointers(command.vkCommandBuffer),null,null,null,fence) }
fence.fenceWait();fence.reset()
val image=ReconstructionCapture.read(renderer,source)
check(image.all { it.isFinite() }) { "Source produced NaN/Inf at parameter $parameter" }
check((0 until width*height).all { kotlin.math.abs(image[it*4+3]-1)<.001f }) { "The learned RGB model requires opaque output; handle coverage separately" }
val bytes=ByteBuffer.allocate(pixelsPerFrame*24).order(ByteOrder.LITTLE_ENDIAN)
repeat(pixelsPerFrame) {
val x=random.nextInt(width);val y=random.nextInt(height);val index=(y*width+x)*4
bytes.putFloat((x+.5f)/width*2-1).putFloat((y+.5f)/height*2-1).putFloat(normalized)
repeat(3){c->bytes.putFloat(image[index+c])}
}
output.write(bytes.array())
}
}
files.put(split,JSONObject().put("file",file.name).put("rows",count*pixelsPerFrame).put("parameters",times)
.put("sha256",MessageDigest.getInstance("SHA-256").digest(file.readBytes()).joinToString(""){"%02x".format(it)}))
}
} finally {fence.cleanup();command.cleanup()}
File(directory,"dataset.json").writeText(JSONObject().put("schema",1).put("sourceKind","captured-module")
.put("sourceShaderDigest",hash).put("fixedState",fixedState).put("resolution",listOf(width,height))
.put("parameterRange",listOf(parameterRange.start,parameterRange.endInclusive)).put("rowFormat","little-endian float32: x,y,t,r,g,b")
.put("coordinates","pixel centers; x right, y down; x,y,t normalized to [-1,1]")
.put("color","linear RGB, unclamped, opaque").put("seed",seed).put("splits",files).toString(2)+"\n")
return directory
}
fun trainAsync(dataset: File,modelDirectory: File,steps: Int=12000): ShaderTrainingJob
Train in a separate CPU process. This never evaluates a shader or mutates GPU state on that worker.
fun trainAsync(dataset: File,modelDirectory: File,steps: Int=12000): ShaderTrainingJob {
require(File(dataset,"dataset.json").isFile)
require(steps in 1..200000)
require(!modelDirectory.exists() || modelDirectory.listFiles().isNullOrEmpty()) { "Use a new model directory so a failed job cannot replace working weights" }
modelDirectory.mkdirs()
val log=File(modelDirectory,"training.log")
val process=ProcessBuilder("uv","run","--project",".","--group","docs","python","tools/lighting/train_shader.py",
"--dataset",dataset.absolutePath,"--out",modelDirectory.absolutePath,"--steps",steps.toString())
.redirectErrorStream(true).redirectOutput(log).apply { environment()["OPENBLAS_NUM_THREADS"]="1" }.start()
return ShaderTrainingJob(process,modelDirectory,log)
}
}
class ShaderTrainingJob internal constructor(private val process: Process,val modelDirectory: File,val log: File): AutoCloseable
Poll result from a Field frame; close cancels the child process, including on scene Stop.
val result: CompletableFuture<File>
val result: CompletableFuture<File> = process.onExit().thenApply {
check(it.exitValue()==0) { "Training failed (${it.exitValue()}): ${log.readText().takeLast(3000)}" }
check(File(modelDirectory,"weights.json").isFile)
modelDirectory
}
override fun close()
override fun close() { if(process.isAlive){process.descendants().forEach { it.destroy() };process.destroy()} }
}