Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/render-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Render Tests

# Headless Vulkan render tests (kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest), run on Mesa's software
# Vulkan implementation (lavapipe) with the Vulkan validation layer enabled. Currently only triggered manually.
on:
workflow_dispatch:
inputs:
updateReferences:
description: 'Generate new reference images (uploaded as artifact) instead of comparing'
type: boolean
default: false

jobs:
render-tests:
name: Render tests (lavapipe)
runs-on: ubuntu-24.04

steps:
- name: Checkout Repository
uses: actions/checkout@v5

- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'

- name: Install lavapipe and Vulkan validation layer
run: |
sudo apt-get update
sudo apt-get install -y mesa-vulkan-drivers vulkan-validationlayers vulkan-tools
vulkaninfo --summary

- name: Run render tests
run: >
./gradlew :kool-core:renderTest
-Pkool.renderTest.device=llvmpipe
-Pkool.renderTest.requireValidation=true
-Pkool.renderTest.updateReferences=${{ inputs.updateReferences }}

- name: Upload test report and images
if: always()
uses: actions/upload-artifact@v4
with:
name: render-test-results
path: |
kool-core/build/reports/tests/renderTest
kool-core/build/reports/render-tests
kool-core/src/desktopTest/resources/render-references
43 changes: 43 additions & 0 deletions kool-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,46 @@ tasks.register<VersionNameUpdate>("updateVersion") {

tasks["compileKotlinJs"].dependsOn("generateTypeVariants")
tasks["compileKotlinDesktop"].dependsOn("generateTypeVariants")

// Render tests render images with the headless Vulkan backend and compare them against reference images. They
// require a Vulkan capable device (or a software implementation like lavapipe) and are therefore not part of the
// regular desktopTest task. Run them via `./gradlew :kool-core:renderTest`. Gradle properties prefixed with
// `kool.renderTest.` are forwarded as system properties, e.g. `-Pkool.renderTest.updateReferences=true` re-generates
// the reference images (see RenderTestContext for all options).
val renderTestPackage = "de/fabmax/kool/rendertest/**"
val desktopTestTask = tasks.named<Test>("desktopTest") {
exclude(renderTestPackage)
}

tasks.register<Test>("renderTest") {
group = "verification"
description = "Runs the headless Vulkan render tests, comparing rendered images against reference images."

testClassesDirs = files(desktopTestTask.map { it.testClassesDirs })
classpath = files(desktopTestTask.map { it.classpath })
javaLauncher = desktopTestTask.flatMap { it.javaLauncher }
useJUnit()
include(renderTestPackage)

// there can only be one kool context per JVM: use a separate JVM for each test class
forkEvery = 1
jvmArgs("--enable-native-access=ALL-UNNAMED")

val referenceDir = layout.projectDirectory.dir("src/desktopTest/resources/render-references")
val reportDir = layout.buildDirectory.dir("reports/render-tests/images")
systemProperty("java.awt.headless", "true")
systemProperty("kool.renderTest.referenceDir", referenceDir.asFile.absolutePath)
systemProperty("kool.renderTest.reportDir", reportDir.get().asFile.absolutePath)
providers.gradlePropertiesPrefixedBy("kool.renderTest.").get().forEach { (key, value) ->
systemProperty(key, value)
}
testLogging {
events("passed", "skipped", "failed")
showStandardStreams = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}

// rendering depends on the GPU / driver, never consider this up-to-date
outputs.upToDateWhen { false }
doFirst { reportDir.get().asFile.deleteRecursively() }
}
2 changes: 2 additions & 0 deletions kool-core/src/commonMain/kotlin/de/fabmax/kool/KoolContext.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ abstract class KoolContext {
Time.frameCount++
}

protected fun incrementFrameTimeBy(dt: Double) = incrementFrameTime(prevFrameTime + dt)

protected suspend fun render(): FrameData {
val frameData = frameDatas[frameDataPtr].also { it.reset() }
frameDataPtr = ++frameDataPtr and 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class BindGroupDataVk(
init {
data.bufferedBindings.forEach { binding ->
when (binding) {
is BindGroupData.UniformBufferBindingData<*> -> uboBindings += UboBinding(binding, Swapchain.MAX_FRAMES_IN_FLIGHT)
is BindGroupData.UniformBufferBindingData<*> -> uboBindings += UboBinding(binding, FrameSync.MAX_FRAMES_IN_FLIGHT)
is BindGroupData.StorageBufferBindingData -> storageBufferBindings += StorageBufferBinding(binding)

is BindGroupData.Texture1dBindingData -> textureBindings += TextureBinding(binding, VK_IMAGE_VIEW_TYPE_1D)
Expand Down Expand Up @@ -133,7 +133,7 @@ class BindGroupDataVk(
backend.descriptorPools.releaseSets(poolLayout, descriptorSets)
descriptorSets = backend.descriptorPools.allocateSets(poolLayout, gpuLayout, this)
val numBindings = uboBindings.size + storageBufferBindings.size + textureBindings.size + storageTextureBindings.size
val descriptorWrite = callocVkWriteDescriptorSetN(Swapchain.MAX_FRAMES_IN_FLIGHT * numBindings) { }
val descriptorWrite = callocVkWriteDescriptorSetN(FrameSync.MAX_FRAMES_IN_FLIGHT * numBindings) { }
var descriptorWriteIdx = 0
descriptorSets.forEachIndexed { setIdx, descriptorSet ->
uboBindings.forEach { ubo ->
Expand Down Expand Up @@ -171,7 +171,7 @@ class BindGroupDataVk(
scopedMem {
val backend = KoolSystem.requireContext().backend as RenderBackendVk
val emptyLayout = PoolLayout(0, 0, 0, 0)
val emptyPool = backend.device.createDescriptorPool(this) { maxSets(Swapchain.MAX_FRAMES_IN_FLIGHT) }
val emptyPool = backend.device.createDescriptorPool(this) { maxSets(FrameSync.MAX_FRAMES_IN_FLIGHT) }
val emptySetLayout = backend.device.createDescriptorSetLayout { }
val emptySet = backend.device.allocateDescriptorSets {
descriptorPool(emptyPool.handle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class DescriptorPoolManager(val backend: RenderBackendVk) : BaseReleasable() {
poolLayout: PoolLayout,
layout: VkDescriptorSetLayout,
stack: MemoryStack,
numSets: Int = Swapchain.MAX_FRAMES_IN_FLIGHT
numSets: Int = FrameSync.MAX_FRAMES_IN_FLIGHT
): List<VkDescriptorSet> {
return pools.getOrPut(poolLayout) { DescriptorPools(poolLayout) }.allocateSets(layout, numSets, stack)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ class Device(val backend: RenderBackendVk) : BaseReleasable() {

val vkDevice: VkDevice
val graphicsQueue: VkQueue
val presentQueue: VkQueue
val presentQueue: VkQueue?
val transferQueue: VkQueue
val computeQueue: VkQueue?

val graphicsQueueProperties: VkQueueFamilyProperties
val presentQueueProperties: VkQueueFamilyProperties
val presentQueueProperties: VkQueueFamilyProperties?
val transferQueueProperties: VkQueueFamilyProperties
val computeQueueProperties: VkQueueFamilyProperties?

Expand All @@ -34,7 +34,7 @@ class Device(val backend: RenderBackendVk) : BaseReleasable() {
}
}

val enableExtensions = backend.setup.requestedDeviceExtensions.filter { it.name in physicalDevice.availableDeviceExtensions }
val enableExtensions = backend.requestedDeviceExtensions.filter { it.name in physicalDevice.availableDeviceExtensions }
val extNames = mallocPointer(enableExtensions.size)
logD("Device") { "Enabling device extensions:" }
enableExtensions.forEachIndexed { i, extension ->
Expand Down Expand Up @@ -71,20 +71,20 @@ class Device(val backend: RenderBackendVk) : BaseReleasable() {
}

val graphicsQueueIdx = physicalDevice.queueFamiliyIndices.graphicsFamily!!
val presentQueueIdx = physicalDevice.queueFamiliyIndices.presentFamily!!
val presentQueueIdx = physicalDevice.queueFamiliyIndices.presentFamily
val transferQueueIdx = physicalDevice.queueFamiliyIndices.transferFamily ?: graphicsQueueIdx
val computeQueueIdx = physicalDevice.queueFamiliyIndices.computeFamily

graphicsQueue = getQueue(graphicsQueueIdx, 0)
presentQueue = getQueue(presentQueueIdx, 0)
presentQueue = presentQueueIdx?.let { getQueue(it, 0) }
transferQueue = getQueue(transferQueueIdx, 0)
computeQueue = computeQueueIdx?.let { getQueue(it, 0) }

val queueProperties = enumerateQueueProperties { cnt, buf ->
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice.vkPhysicalDevice, cnt, buf)
}
graphicsQueueProperties = queueProperties[graphicsQueueIdx]
presentQueueProperties = queueProperties[presentQueueIdx]
presentQueueProperties = presentQueueIdx?.let { queueProperties[it] }
transferQueueProperties = queueProperties[transferQueueIdx]
computeQueueProperties = computeQueueIdx?.let { queueProperties[it] }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package de.fabmax.kool.pipeline.backend.vk

import de.fabmax.kool.util.BaseReleasable
import org.lwjgl.system.MemoryStack

/**
* Final destination of rendered frames. Either a [Swapchain] (regular windowed mode) or a [HeadlessOutput]
* (headless mode without any window / surface).
*/
abstract class FrameOutput : BaseReleasable() {
abstract val width: Int
abstract val height: Int

/**
* Image format used by the screen render pass. Draw pipelines are created for this format, so it must not
* change when the output is recreated.
*/
abstract val renderFormat: Int

/**
* Semaphore the frame submission has to wait for (if any).
*/
abstract val waitSemaphore: VkSemaphore?

/**
* Semaphore signaled by the frame submission (if any).
*/
abstract val signalSemaphore: VkSemaphore?

/**
* Called at the beginning of a frame, after the previous frame with the same frame index was completed.
* Returns false if the output is out of date and needs to be recreated.
*/
abstract fun acquireNextImage(): Boolean

/**
* Records the commands needed to transfer the final screen pass output into this output.
*/
abstract fun recordOutput(screenPass: ScreenPassVk, passEncoderState: PassEncoderState)

/**
* Called after the frame was submitted. Returns false if the output is out of date and needs to be recreated.
*/
abstract fun present(stack: MemoryStack): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package de.fabmax.kool.pipeline.backend.vk

import de.fabmax.kool.util.BaseReleasable
import de.fabmax.kool.util.logT
import de.fabmax.kool.util.releaseWith
import de.fabmax.kool.util.scopedMem
import org.lwjgl.vulkan.VK10.*

/**
* Manages the frames in flight: Keeps track of the current frame index and owns the fences used to wait for
* frames to complete. Independent of the actual frame output (swapchain or headless).
*/
class FrameSync(val backend: RenderBackendVk) : BaseReleasable() {
private val device: Device get() = backend.device

var currentFrameIndex = 0
private set

private val inFlightFences: List<VkFence>
val inFlightFence: VkFence get() = inFlightFences[currentFrameIndex]

init {
inFlightFences = scopedMem {
List(MAX_FRAMES_IN_FLIGHT) {
device.createFence(this) { flags(VK_FENCE_CREATE_SIGNALED_BIT) }
}
}
releaseWith(backend.device)
logT { "Created frame sync" }
}

/**
* Blocks until the GPU finished the frame previously rendered with the current frame index.
*/
fun waitForCurrentFrame() {
vkWaitForFences(device.vkDevice, inFlightFence.handle, true, -1)
}

/**
* Returns true if the GPU finished the frame previously rendered with the given frame index.
*/
fun isFrameComplete(frameIndex: Int): Boolean {
return vkGetFenceStatus(device.vkDevice, inFlightFences[frameIndex].handle) == VK_SUCCESS
}

/**
* Resets the fence of the current frame. Must only be called right before the fence is used for a new submit,
* otherwise the next [waitForCurrentFrame] call for this frame index would block forever.
*/
fun resetCurrentFence() {
vkResetFences(device.vkDevice, inFlightFence.handle)
}

fun advanceFrame() {
currentFrameIndex = (currentFrameIndex + 1) % MAX_FRAMES_IN_FLIGHT
}

override fun doRelease() {
inFlightFences.forEach { device.destroyFence(it) }
logT { "Destroyed frame sync" }
}

companion object {
const val MAX_FRAMES_IN_FLIGHT = 2
}
}
Loading
Loading