RtxdiSettings.kt
src/jvmMain/kotlin/afield/graphics/render/lighting/RtxdiSettings.kt · 85 lines
Live controls for the RTXDI resampling path of DirectLighting.
RtxdiSettings holds the camera matrices, temporal and spatial reuse parameters, shadowing and an optional spatial denoise, and takes the light list as IdentifiedAreaLights through RtxdiSettings.commitLights, so a light keeps its history across reordering. Edit it on the render thread between frames; RtxdiSettings.resetHistory discards history after a cut or material edit.
package afield.graphics.render.lighting
import afield.math.linalg.Matrix4f
import afield.math.linalg.Vector3f
data class IdentifiedAreaLight(val id: Int, val emitter: AreaEmitter)
Stable light identity, independent of order. Opaque one-sided area emitters only.
class RtxdiSettings
Live RTXDI controls. Commit lights and edit these values between frames on Field's render thread. Current/previous camera matrices use Field's Vulkan projection (the raster viewport flips Y). Geometry commits invalidate all history; material semantics edits must call resetHistory.
class RtxdiSettings(
/** The camera's view matrix, read once per frame rather than stored, so a camera that moves between frames needs no further bookkeeping here. */
val view: () -> Matrix4f,
/** The camera's projection matrix, read per frame like [view]. Field's Vulkan convention, where the raster viewport flips Y. */
val projection: () -> Matrix4f
) {
var denoisePasses
Surface-guided spatial reconstruction, separate from reservoir reuse. Zero preserves raw output. No color history is stored, so moving lights and camera cuts cannot leave color trails. Each pass doubles the pixel spacing; three passes have a 14-pixel support radius.
var denoisePasses = 0
set(value) { require(value in 0..4); field=value }
var temporal = true
Whether each pixel may reuse the reservoir its surface carried last frame. Off, every frame resamples from scratch: noisier, and the only setting under which a still camera does not keep improving.
var spatialSamples
How many neighbouring pixels' reservoirs each pixel resamples, 0 to 32. This is the reuse that cleans up a single frame, and the cost that scales with it.
var spatialSamples = 4
set(value) { require(value in 0..32);field=value }
var maxHistory
The largest number of frames' worth of samples a reservoir may claim to hold, 1 to 32. Capping it is what lets a reservoir forget light that has moved; raising it trades responsiveness for a quieter image.
var maxHistory = 8
set(value) { require(value in 1..32);field=value }
var spatialRadius
The radius in pixels that spatial neighbours are drawn from, 0 to 64.
var spatialRadius = 12f
set(value) { require(value.isFinite() && value in 0f..64f);field=value }
var normalThreshold
How closely two surface normals must agree before one's reservoir may be reused for the other, as a dot product from 0 to 1 — the default admits normals within about 18° of each other, which is what keeps light from bleeding around a crease. Handed to both the temporal and the spatial resampling pass.
var normalThreshold = 0.95f
set(value) { require(value.isFinite() && value in 0f..1f);field=value }
var depthThreshold
The companion depth test, from 0 to 0.2, so that a pixel sitting on a surface behind this one is not reused for it. Handed to the same two passes as normalThreshold.
var depthThreshold = 0.02f
set(value) { require(value.isFinite() && value in 0f..0.2f);field=value }
var shadows = true
Whether a shadow ray is traced towards the chosen light. Off, every sample is treated as visible, which is faster and wrong wherever anything occludes anything.
var animateSeed = true
Whether the sampling seed advances with the frame counter. Off, every frame draws the same sequence, which is what makes two renders of one scene comparable pixel for pixel.
internal var epoch = 0L
internal var lights = emptyList<IdentifiedAreaLight>()
internal var lightRevision = 0L
var resetReason = "initial"; private set
The reason last passed to resetHistory, or initial before any call. The renderer reads it
when it notices the reset and reports it as its own reason for that frame, which is how a
deliberate cut can be told apart from the resets it detects for itself — a geometry commit, a
change of projection, a large camera move, or an edit to temporal, spatialSamples,
spatialRadius, maxHistory or shadows, which are the settings that invalidate history.
fun resetHistory(reason: String = "explicit cut or edit") { epoch++;resetReason=reason }
fun commitLights(values: List<IdentifiedAreaLight>)
Transactional snapshot. Moving/reordered lights retain their IDs; deleted IDs map to -1.
fun commitLights(values: List<IdentifiedAreaLight>) {
require(values.size<=4096)
require(values.all { it.id>=0 } && values.map { it.id }.distinct().size==values.size) { "Light IDs must be unique nonnegative integers" }
val copy=values.map { v ->
v.emitter.validate()
IdentifiedAreaLight(v.id,AreaEmitter(Vector3f(v.emitter.center),Vector3f(v.emitter.edgeU),Vector3f(v.emitter.edgeV),Vector3f(v.emitter.radiance)))
}
lights=copy;lightRevision++
}
}