Skip to content

DlssReconstruction.kt

src/jvmMain/kotlin/afield/media/reconstruction/DlssReconstruction.kt · 212 lines

DLSS super resolution, DLAA and Ray Reconstruction through NVIDIA NGX, as a pass recorded into a Vulkan command buffer.

DlssReconstruction takes render-resolution color, depth and motion (ReconstructionInputs), plus RayReconstructionGuides when ray reconstruction is on, and writes an output-resolution texture. Each frame is described by a ReconstructionFrame: its index, the ReconstructionJitter the scene was sampled with (ReconstructionJitter.halton gives a sequence sized to the upscale ratio), frame time and pre-exposure. Call reset on camera cuts and live edits so history is discarded.

package afield.media.reconstruction

import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.render.lighting.LightingGpuTimer
import afield.graphics.vulkan.targets.RenderToTexture
import afield.graphics.texture.*
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.VK13.*
import org.lwjgl.vulkan.VkImageSubresourceRange
import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout.*
import kotlin.math.ceil

data class ReconstructionInputs(val color: HasImageView,val depth: HasImageView,val motion: HasImageView,

Motion defaults to input pixels: origin top left, X right, Y down, current-to-previous, without jitter. For Field direct-lighting motion, set motionCurrentMinusPrevious true and motionInPixels false; the adapter supplies the corresponding sign and scale to NGX without an intermediate image pass. Color is opaque linear HDR RGB, pre-exposure explicitly supplied; coverage/latent channels stay outside reconstruction. Depth is standard Vulkan [0,1], background=1. All inputs are render-resolution, sampled images, mip 0 / layer 0; producer and consumer must use the renderer's graphics queue.

data class ReconstructionInputs(val color: HasImageView,val depth: HasImageView,val motion: HasImageView,
    val motionCurrentMinusPrevious: Boolean=false,val motionInPixels: Boolean=true) {

internal fun motionScale(size: ReconstructionSize): Pair<Float,Float>

    internal fun motionScale(size: ReconstructionSize): Pair<Float,Float> {
        val sign=if(motionCurrentMinusPrevious)-1f else 1f
        return sign*(if(motionInPixels)1f else size.width.toFloat()) to
            sign*(if(motionInPixels)1f else size.height.toFloat())
    }
}

data class RayReconstructionGuides(val diffuseAlbedo: HasImageView,val specularAlbedo: HasImageView,

RR guides at input resolution. Diffuse/specular albedo are the actual BRDF response; normals.xyz are world-space unit normals and .w is linear roughness. Matrices are unjittered column-major right-multiply (equivalent to NGX's row-major left-multiply convention). Optional specular hit distance is world-space distance from the primary surface; sky=65504.

data class RayReconstructionGuides(val diffuseAlbedo: HasImageView,val specularAlbedo: HasImageView,
    val normalRoughness: HasImageView,val worldToView: afield.math.linalg.Matrix4f,val viewToClip: afield.math.linalg.Matrix4f,val specularHitDistance: HasImageView?=null)

data class ReconstructionFrame(val index: Long,val jitter: ReconstructionJitter,val milliseconds: Float=16.6667f,

data class ReconstructionFrame(val index: Long,val jitter: ReconstructionJitter,val milliseconds: Float=16.6667f,
    val preExposure: Float=1f,val reset: Boolean=false,val revision: Long=0) {

init

    init { require(milliseconds.isFinite() && milliseconds>0 && preExposure.isFinite() && preExposure>0) }
}

data class ReconstructionJitter(val x: Float,val y: Float)

Projection displacement in input pixels, positive right/down. Sample the scene at pixel-jitter.

init { require(x.isFinite() && y.isFinite() && x in -0.5f..0.5f && y in -0.5f..0.5f) }

companion object

fun halton(frame: Long,input: ReconstructionSize,output: ReconstructionSize): ReconstructionJitter

        fun halton(frame: Long,input: ReconstructionSize,output: ReconstructionSize): ReconstructionJitter {
            require(frame>=0)
            val phases=ceil(8.0*output.width/input.width*output.height/input.height).toInt().coerceAtLeast(8)
            fun radical(i: Int,base: Int): Float { var n=i;var f=1f;var value=0f
                while(n>0){f/=base;value+=f*(n%base);n/=base};return value }
            val i=(frame%phases).toInt()+1
            return ReconstructionJitter(radical(i,2)-0.5f,radical(i,3)-0.5f)
        }

fun fieldMotionToPixels(currentMinusPrevious: Float,dimension: Int): Float

Field direct-lighting UV velocity has the opposite sign.

        fun fieldMotionToPixels(currentMinusPrevious: Float,dimension: Int): Float = -currentMinusPrevious*dimension
    }
}

class DlssReconstruction(val renderer: Renderer,val inputSize: ReconstructionSize,val outputSize: ReconstructionSize,

One mono view. Construct/edit/evaluate/close on the render thread, outside a render pass. Query recommend before allocating the scene targets. Recreate on resolution/mode/format change; the old instance retires behind Field's GPU completion queue. Failure leaves caller's source intact. SR is independent of ray tracing. UI and unsupported animated layers belong after this pass.

class DlssReconstruction(val renderer: Renderer,val inputSize: ReconstructionSize,val outputSize: ReconstructionSize,
    val mode: ReconstructionMode,val rayReconstruction: Boolean=false) : AutoCloseable {
    private val runtime=renderer.device.ngxRuntime.getOrThrow()
    private val native=runtime.native

val recommendation

    val recommendation=runtime.recommend(outputSize,mode,rayReconstruction).also { rec ->
        rec.validate(inputSize,outputSize,mode)
    }
    private val arena=Arena.ofShared()
    private val images=arena.allocate(256,8)
    private val matrices=arena.allocate(128,4)
    private val frameData=arena.allocate(24,4)
    private val handleSlot=arena.allocate(ADDRESS)
    private var handle=MemorySegment.NULL
    private val storage=StorageOutput(renderer,outputSize)

val output: RenderToTexture get()=storage

    private val timer=LightingGpuTimer(renderer)

val gpuMilliseconds get()=timer.samples.toList()

val sdkAllocatedBytes get()=runtime.allocatedBytes

SDK-reported device allocation, shared by all views; 0 means statistic unavailable.

val outputAllocatedBytes get()=storage.colorImageRefs.sumOf { it?.memorySize ?: 0 }

    private var closed=false
    private var lastPresentationRevision=-1L
    private var lastFrame=-1L
    private var lastRevision=Long.MIN_VALUE
    private var pendingReset=true
    private var lastInputs=longArrayOf()
    private var lastMotionScale: Pair<Float,Float>?=null

var resetCount=0;private set

var resetReason="initial";private set

fun reset(reason: String="camera cut or live edit") { pendingReset=true;resetReason=reason }

fun evaluate(command: CommandBuffer,index: Int,source: RenderToTexture,inputs: ReconstructionInputs,

Record source, then reconstruct its completed attachments in the same command buffer. This owns the source's scheduling; registering it separately in preTargets is rejected, including registrations added after this view was created.

    fun evaluate(command: CommandBuffer,index: Int,source: RenderToTexture,inputs: ReconstructionInputs,
        frame: ReconstructionFrame,guides: RayReconstructionGuides?=null) {
        check(!closed && !command.inRenderPassInstance)
        require(frame.index>=0 && frame.index!=lastFrame) { "A view must evaluate once per frame" }
        validateSourceScheduling(source)
        source.render(command,index)
        evaluate(command,inputs,frame,guides)
    }

internal fun validateSourceScheduling(source: RenderToTexture)

    internal fun validateSourceScheduling(source: RenderToTexture) {
        require(source.renderer===renderer) { "Reconstruction and its source must use the same renderer" }
        check(renderer.renderList.preTargets.values.none { it===source }) {
            "DLSS owns this source's rendering; remove its independent preTargets registration to avoid rendering it twice"
        }
    }

fun evaluate(command: CommandBuffer,inputs: ReconstructionInputs,frame: ReconstructionFrame,guides: RayReconstructionGuides?=null)

body — 65 lines, from line 120
    fun evaluate(command: CommandBuffer,inputs: ReconstructionInputs,frame: ReconstructionFrame,guides: RayReconstructionGuides?=null) {
        check(!closed && !command.inRenderPassInstance)
        require(frame.index>=0 && frame.index!=lastFrame) { "A view must evaluate once per frame" }
        require(rayReconstruction==(guides!=null)) { "RR requires its BRDF guides and camera matrices on every frame" }
        images.fill(0)
        val views=listOf(inputs.color,inputs.depth,inputs.motion,storage)+
            (guides?.let { listOf(it.diffuseAlbedo,it.specularAlbedo,it.normalRoughness)+listOfNotNull(it.specularHitDistance) } ?: emptyList())
        views.forEachIndexed { i,source ->
            if(i!=3)(source as? PreparesSampledImages)?.prepareSampledImagesFor(command)
            val v=source.imageView;val image=requireNotNull(v.underlyingImage) { "NGX inputs require a Field-owned image" }
            val size=if(i==3)outputSize else inputSize
            require(image.imageData.width==size.width && image.imageData.height==size.height)
            require(v.mipLevels==1 && v.imageViewData.layerCount==1 && v.imageViewData.baseArrayLayer==0)
            if(i!=3) {
                require(image.imageData.usage and VK_IMAGE_USAGE_SAMPLED_BIT!=0)
                require(image.getLastKnownLayout() in setOf(VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL)) {
                    "NGX input $i is not in sampled-read layout; finish its producer before evaluation"
                }
            }
            require(when(i) {
                0,3,4,5,6 -> image.format==VK_FORMAT_R16G16B16A16_SFLOAT || image.format==VK_FORMAT_R32G32B32A32_SFLOAT
                7 -> image.format==VK_FORMAT_R32_SFLOAT
                1 -> image.format==VK_FORMAT_R32_SFLOAT || image.format==VK_FORMAT_D32_SFLOAT
                else -> image.format==VK_FORMAT_R16G16_SFLOAT || image.format==VK_FORMAT_R32G32_SFLOAT
            }) { "Unsupported NGX input format ${image.format} at slot $i" }
            val o=i*32L;images.set(JAVA_LONG,o,image.vkImage);images.set(JAVA_LONG,o+8,v.vkImageView)
            images.set(JAVA_INT,o+16,image.format);images.set(JAVA_INT,o+20,size.width);images.set(JAVA_INT,o+24,size.height);images.set(JAVA_INT,o+28,v.aspectMask)
        }
        timer.begin(command,renderer.currentImage)
        if(handle==MemorySegment.NULL) {
            // SDK guide 3.2.2: create at the optimal size; evaluate at the legal render subrect size.
            native.check((if(rayReconstruction)native.rrCreate else native.create).invokeWithArguments(runtime.handle,command.vkCommandBuffer.address(),recommendation.optimal.width,recommendation.optimal.height,
                outputSize.width,outputSize.height,mode.sdkValue,handleSlot))
            handle=handleSlot.get(ADDRESS,0)
        }
        storage.transition(command,VK_IMAGE_LAYOUT_GENERAL)
        frameData.set(JAVA_FLOAT,0,frame.jitter.x);frameData.set(JAVA_FLOAT,4,frame.jitter.y)
        frameData.set(JAVA_FLOAT,8,frame.milliseconds);frameData.set(JAVA_FLOAT,12,frame.preExposure)
        val motionScale=inputs.motionScale(inputSize)
        frameData.set(JAVA_FLOAT,16,motionScale.first);frameData.set(JAVA_FLOAT,20,motionScale.second)
        val inputIds=views.filterIndexed { i,_ -> i!=3 }.map { it.imageView.image }.toLongArray()
        if(lastPresentationRevision!=renderer.presentationRevision)reset("presentation resumed")
        val reset=pendingReset || frame.reset || frame.index!=lastFrame+1 || lastRevision!=frame.revision ||
            !inputIds.contentEquals(lastInputs) || motionScale!=lastMotionScale
        if(reset && !pendingReset)resetReason=when {
            frame.reset -> "explicit frame reset"
            frame.index!=lastFrame+1 -> "skipped frame"
            lastRevision!=frame.revision -> "scene revision"
            motionScale!=lastMotionScale -> "motion convention changed"
            else -> "input resources replaced"
        }
        if(guides!=null) {
            val bytes=matrices.asByteBuffer().order(java.nio.ByteOrder.nativeOrder())
            guides.worldToView.get(0,bytes);guides.viewToClip.get(64,bytes)
            require((0 until 32).all { matrices.get(JAVA_FLOAT,it*4L).isFinite() }) { "RR camera matrices must be finite" }
            native.check(native.rrEvaluate.invokeWithArguments(handle,command.vkCommandBuffer.address(),images,frameData,matrices,if(reset)1 else 0))
        } else native.check(native.evaluate.invokeWithArguments(handle,command.vkCommandBuffer.address(),images,frameData,if(reset)1 else 0))
        // Modules bind pipeline, descriptors, push constants, and dynamic graphics state every draw.
        // No cached command state may be carried across the SDK call.
        storage.transition(command,VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
        timer.end(command,renderer.currentImage)
        if(reset)resetCount++
        lastPresentationRevision=renderer.presentationRevision
        pendingReset=false;lastFrame=frame.index;lastRevision=frame.revision;lastInputs=inputIds;lastMotionScale=motionScale
    }

override fun close()

    override fun close() {
        if(closed)return;closed=true
        val old=handle
        renderer.cleanup+=Runnable {
            if(old!=MemorySegment.NULL)native.destroy.invokeWithArguments(old)
            timer.close();storage.imageViewArray.forEach { it.imageView.cleanup() };storage.colorImageRefs.forEach { it?.cleanup() }
            storage.defaultSampler?.cleanup();arena.close()
        }
    }

companion object

fun recommend(renderer: Renderer,output: ReconstructionSize,mode: ReconstructionMode,rayReconstruction: Boolean=false): ReconstructionRecommendation

        fun recommend(renderer: Renderer,output: ReconstructionSize,mode: ReconstructionMode,rayReconstruction: Boolean=false): ReconstructionRecommendation =
            renderer.device.ngxRuntime.getOrThrow().recommend(output,mode,rayReconstruction)
    }
}

private class StorageOutput(renderer: Renderer,size: ReconstructionSize) : RenderToTexture(renderer,{
    width=size.width;height=size.height;format=VK_FORMAT_R16G16B16A16_SFLOAT;useDepth=false;colorStorage=true;colorTransferDst=true;debugName="NGX output"
}) {
    fun transition(command: CommandBuffer,layout: Int) = MemoryStack.stackPush().use { s ->
        val image=colorImageRefs[0]!!;val old=image.getLastKnownLayout() ?: VK_IMAGE_LAYOUT_UNDEFINED
        image_layout_transition(command.vkCommandBuffer,image,VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
            if(old==VK_IMAGE_LAYOUT_UNDEFINED)0 else VK_ACCESS_2_MEMORY_READ_BIT or VK_ACCESS_2_MEMORY_WRITE_BIT,
            VK_ACCESS_2_MEMORY_READ_BIT or VK_ACCESS_2_MEMORY_WRITE_BIT,old,layout,
            VkImageSubresourceRange.calloc(s).aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).levelCount(1).layerCount(1))
        colorLayout[0]=layout
    }
}