RtxdiEngine.kt
src/jvmMain/kotlin/afield/graphics/render/lighting/RtxdiEngine.kt · 226 lines
package afield.graphics.render.lighting
import afield.graphics.vulkan.buffers.*
import afield.graphics.vulkan.device.CommandBuffer
import afield.document.context.*
import afield.graphics.render.*
import afield.graphics.scene.*
import afield.graphics.shader.*
import afield.graphics.texture.TextureSampler
import afield.graphics.shader.fromViews
import afield.math.linalg.Matrix4f
import afield.math.linalg.Rectanglef
import org.lwjgl.vulkan.VK13.*
import kotlin.math.*
The RTXDI implementation behind DirectLighting's two RTXDI modes: reservoir resampling run entirely as compute passes over GPU buffers, with none of the SDK's CPU-side wrapper linked in.
An engine is built when RTXDI is enabled and sized from the render dimensions it finds then, and
it owns what that sizing implies: a packed surface buffer, host-visible buffers per swapchain
image for the camera matrices and for this frame's and the previous frame's area lights, and
reservoir storage of three slices of 24 bytes a pixel, rounded up to whole 16×16 tiles. That
allocation is checked against the device's maxStorageBufferRange as the engine is built, so an
oversized render resolution is refused there rather than inside a descriptor write. Ray queries
are required.
Reuse across frames holds only while the history does. Every frame compares the projection, the camera (a view difference over 0.75 counts as a cut), the acceleration structure's build count and a signature of the sampling settings, and any change resets the history and records which of them it was. A reference render runs a single reference estimator in place of the four resampling stages and leaves no history behind. The reconstruction filter that can follow runs over the radiance view alone, and feeds neither the estimator nor the reservoirs.
internal class RtxdiEngine(private val owner: DirectLighting, program: RtxdiProgram, val settings: RtxdiSettings,
One graphics queue, two surface pages, three SDK reservoir slices. No native CPU SDK wrapper.
@Suppress("UNNECESSARY_LATEINIT") // cleanup also handles partially constructed pipelines
internal class RtxdiEngine(private val owner: DirectLighting, program: RtxdiProgram, val settings: RtxdiSettings,
private val pixels: VulkanBuffer) : AutoCloseable {
private val renderer=owner.renderer
private val count=owner.width*owner.height
init
init {
require(owner.visibility.available) { "RTXDI requires enabled ray queries for this bridge" }
val limit=renderer.device.physicalDevice.vkPhysicalDeviceProperties.limits().maxStorageBufferRange().toUInt().toLong()
require(reservoirBytes(owner.width,owner.height)<=limit) { "RTXDI reservoir descriptor exceeds maxStorageBufferRange=$limit; reduce the render resolution" }
}
private val owned=mutableListOf<VulkanBuffer>()
private val bindings=mutableMapOf<String,BufferParameters>()
private val sampler=TextureSampler(renderer,1,false,filter=VK_FILTER_NEAREST)
private val surfaces=allocate(count*160L)
Host snapshots are indexed by acquired swapchain image, whose fence has already completed.
private val matrices=Array(renderer.numImages) { allocate(192,true) }
private val lights=Array(renderer.numImages) { allocate(4096*80L,true) }
private val previousLights=Array(renderer.numImages) { allocate(4096*80L,true) }
private val timers=List(7) { LightingGpuTimer(renderer) }
private lateinit var reconstruction: ComputeModule
private lateinit var reconstructionTask: ComputeMesh
private var filterBuffers: List<VulkanBuffer> = emptyList()
var displayPixels: VulkanBuffer = pixels; private set
private lateinit var snapshot: ComputeModule
private lateinit var shader: ComputeModule
private lateinit var snapshotTask: ComputeMesh
private lateinit var task: ComputeMesh
private var frame=0
private var hasHistory=false
private var epoch=-1L
private var geometry=-1
private var signature=""
private var previousView=Matrix4f()
private var previousProjection=Matrix4f()
private var previous=emptyList<IdentifiedAreaLight>()
var historyValid=false; private set
var resetCount=0; private set
var resetReason="initial"; private set
val workingBytes get()=owned.sumOf { it.requestedSize }
val timings get()
val timings get()=listOf("surfaceSnapshot","initialCandidates","temporalReuse","spatialReuse","visibilityShading","reference","reconstruction")
.zip(timers.map { it.samples.toList() }).toMap()
init
body — 47 lines, from line 79
init {
try {
val textures=mapOf("positions" to owner.surfaces.position,"normals" to owner.surfaces.normal,
"albedo" to owner.surfaces.albedo,"source" to owner.surfaces.source)
snapshot=ComputeModule(Parameters(renderer,name="rtxdi-surface-snapshot",
compute=javaClass.getResource("/afield/four/lighting/rtxdi/snapshot.comp")!!.readText(),
textureResolver={ t -> textures[t.name]?.let { fromViews(listOf(it),t.name,sampler) } }))
check(snapshot.valid) { "RTXDI surface snapshot compilation failed" }
snapshotTask=mesh(snapshot)
shader=compile(program)
task=mesh(shader)
reconstruction=ComputeModule(Parameters(renderer,name="direct-light-reconstruction",
compute=javaClass.getResource("/afield/four/lighting/reconstruct.comp")!!.readText()))
check(reconstruction.valid) { "Direct-light reconstruction compilation failed" }
reconstructionTask=mesh(reconstruction)
// A fixed disk sequence; the SDK rotates/indexes this per pixel. Unlike its optional
// SNORM texture path, StructuredBuffer<float2> needs no quantization or C++ helper.
bindings.getValue("offsets").result!!.get().mapAndWrite {
repeat(256) { i -> val r=sqrt((i+0.5)/256.0);val a=i*2.399963229728653
put((r*cos(a)).toFloat());put((r*sin(a)).toFloat()) }
}
} catch(t: Throwable) { close();throw t }
}
private fun allocate(size: Long,host: Boolean=false): VulkanBuffer = VulkanBuffer(renderer,size,
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT or VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT or VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
if(host) VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT else VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
needsDeviceAddress=true).also { owned+=it }
private fun compile(program: RtxdiProgram): ComputeModule {
val errors=mutableListOf<String>()
val module=ComputeModule(Parameters(renderer,name="Field-RTXDI",computeIntrospection=program.reflection,
ssboResolver={ requested -> bindings.getOrPut(requested.name) {
requested.customize.add {
size=when(name) { "reservoirs" -> reservoirBytes(owner.width,owner.height); "offsets" -> 256*8L;else -> error("Unknown RTXDI binding $name") }
if(name=="reservoirs") { notHosted();memory=VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT }
}
requested
} },accelerationStructureResolver={ owner.visibility },errorHandler={ m,_ -> errors+=m }))
if(!module.valid) { module.cleanup();error(errors.joinToString("\n")) }
bindings.values.flatMap { it.result!!.buffers }.forEach { if(it !in owned)owned+=it }
return module
}
private fun mesh(module: ComputeModule): ComputeMesh {
val pointer=module.pointer()
return ComputeMesh(renderer,pointer).also { it.x=(owner.width+7)/8;it.y=(owner.height+7)/8
it.push.setMembers(module.ci.pushConstants.single().members);pointer += "dispatch" to it }
}
fun reload(program: RtxdiProgram): Result<Unit>
fun reload(program: RtxdiProgram): Result<Unit> = runCatching {
val abi=computeShaderAbiCheck(shader.ci,program.reflection)
require(abi.compatible) { abi.message }
val next=compile(program);val nextTask=mesh(next);val old=shader
shader=next;task=nextTask;invalidate("shader reload")
renderer.cleanup+=Runnable { old.cleanup() }
}
fun clearTimings() { timers.forEach { it.samples.clear() } }
fun invalidate(reason: String) { hasHistory=false;resetReason=reason }
fun render(cmd: CommandBuffer,index: Int,reference: Boolean)
body — 75 lines, from line 136
fun render(cmd: CommandBuffer,index: Int,reference: Boolean) {
val view=Matrix4f(settings.view());val projection=Matrix4f(settings.projection())
val nextSignature="${settings.temporal}/${settings.spatialSamples}/${settings.spatialRadius}/${settings.maxHistory}/${settings.shadows}/${owner.samples}/${owner.seed}"
val reason=when {
!hasHistory -> resetReason
epoch!=settings.epoch -> settings.resetReason
geometry!=owner.visibility.tlasBuilds -> "geometry or instance revision"
matrixDifference(projection,previousProjection)>1e-6f -> "projection change"
matrixDifference(view,previousView)>0.75f -> "camera cut"
signature!=nextSignature -> "sampling settings changed"
else -> null
}
historyValid=reason==null && !reference
if(reason!=null) { resetReason=reason;resetCount++ }
val current=settings.lights
writeLights(lights[index],current,previous)
writeLights(previousLights[index],previous,current)
matrices[index].mapAndWrite_bytes {
view.get(0,this);previousView.get(64,this);Matrix4f(previousProjection).mul(previousView).get(128,this)
}
val page=frame and 1
snapshotTask.push.apply {
this["surfaces"]=surfaces;this["matrices"]=matrices[index]
this["width"]=owner.width;this["height"]=owner.height;this["page"]=page
}
DirectLighting.barrier(cmd,VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,VK_ACCESS_2_MEMORY_WRITE_BIT or VK_ACCESS_2_MEMORY_READ_BIT,
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,VK_ACCESS_2_SHADER_READ_BIT or VK_ACCESS_2_SHADER_WRITE_BIT)
timers[0].begin(cmd,index);snapshot.render(cmd,Rectanglef());timers[0].end(cmd,index)
task.push.apply {
this["surfaces"]=surfaces.getGPUAddress(cmd);this["lights"]=lights[index].getGPUAddress(cmd)
this["previousLights"]=previousLights[index].getGPUAddress(cmd);this["pixels"]=pixels.getGPUAddress(cmd)
this["width"]=owner.width;this["height"]=owner.height;this["frameIndex"]=if(settings.animateSeed)frame else 0
this["page"]=page;this["candidates"]=owner.samples;this["lightCount"]=current.size;this["previousLightCount"]=previous.size
this["temporal"]=if(settings.temporal)1 else 0;this["spatial"]=settings.spatialSamples
this["historyValid"]=if(historyValid)1 else 0;this["maxHistory"]=settings.maxHistory
this["radius"]=settings.spatialRadius;this["normalThreshold"]=settings.normalThreshold;this["depthThreshold"]=settings.depthThreshold
this["epsilon"]=owner.epsilon;this["diagnostic"]=owner.view.ordinal;this["seed"]=owner.seed;this["weight"]=owner.effectWeight
this["shadows"]=if(settings.shadows)1 else 0
}
for(stage in if(reference)4..4 else 0..3) {
DirectLighting.barrier(cmd,VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,VK_ACCESS_2_SHADER_WRITE_BIT or VK_ACCESS_2_SHADER_READ_BIT,
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,VK_ACCESS_2_SHADER_READ_BIT or VK_ACCESS_2_SHADER_WRITE_BIT)
task.push["stage"]=stage
timers[stage+1].begin(cmd,index);shader.render(cmd,Rectanglef());timers[stage+1].end(cmd,index)
}
displayPixels=pixels
// Reconstruction never feeds the estimator or reservoir history. Diagnostics bypass it.
if(!reference && settings.denoisePasses>0 && owner.view==DirectLighting.View.RADIANCE && owner.effectWeight>0f) {
if(filterBuffers.isEmpty())filterBuffers=List(2) { allocate(count*16L) }
timers[6].begin(cmd,index)
repeat(settings.denoisePasses) { pass ->
val destination=filterBuffers[pass and 1]
DirectLighting.barrier(cmd,VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,VK_ACCESS_2_SHADER_WRITE_BIT or VK_ACCESS_2_SHADER_READ_BIT,
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,VK_ACCESS_2_SHADER_READ_BIT or VK_ACCESS_2_SHADER_WRITE_BIT)
reconstructionTask.push.apply {
this["surfaces"]=surfaces;this["inputPixels"]=displayPixels;this["outputPixels"]=destination
this["width"]=owner.width;this["height"]=owner.height;this["page"]=page;this["stepWidth"]=1 shl pass
this["weight"]=owner.effectWeight;this["firstPass"]=if(pass==0)1 else 0
this["lastPass"]=if(pass==settings.denoisePasses-1)1 else 0
}
reconstruction.render(cmd,Rectanglef());displayPixels=destination
}
timers[6].end(cmd,index)
}
hasHistory=!reference;epoch=settings.epoch;geometry=owner.visibility.tlasBuilds;signature=nextSignature
previous=current;previousView=view;previousProjection=projection;frame++
}
private fun writeLights(buffer: VulkanBuffer,current: List<IdentifiedAreaLight>,other: List<IdentifiedAreaLight>) {
val indices=other.withIndex().associate { it.value.id to it.index }
buffer.mapAndWrite_bytes { current.forEach { light ->
val e=light.emitter
listOf(e.center,e.edgeU,e.edgeV,e.radiance).forEach { putFloat(it.x);putFloat(it.y);putFloat(it.z);putFloat(0f) }
putInt(light.id);putInt(indices[light.id] ?: -1);putLong(0L)
} }
}
override fun close()
override fun close() {
if(::reconstruction.isInitialized)reconstruction.cleanup()
if(::shader.isInitialized)shader.cleanup()
if(::snapshot.isInitialized)snapshot.cleanup()
bindings.values.flatMap { it.result?.buffers ?: emptyList() }.forEach { if(it !in owned)owned+=it }
owned.forEach { it.cleanup() };owned.clear();sampler.cleanup();timers.forEach { it.close() }
}
companion object
internal fun reservoirBytes(width: Int,height: Int)
internal fun reservoirBytes(width: Int,height: Int)=((width+15)/16).toLong()*((height+15)/16)*256*24*3
private fun matrixDifference(a: Matrix4f,b: Matrix4f): Float {
val x=FloatArray(16);val y=FloatArray(16);a.get(x);b.get(y)
return x.indices.maxOf { abs(x[it]-y[it]) }
}
}
}