From 91a54db085cd876223bbb4d431981bc3419e9d12 Mon Sep 17 00:00:00 2001 From: Max Thiele Date: Sun, 25 Oct 2026 13:19:43 +0100 Subject: [PATCH] Add headless / offscreen mode to Vulkan backend and render tests Vulkan backend: - Extract frame output abstraction (FrameOutput) from Swapchain and move per-frame fences into FrameSync, so that the backend can run without a surface / swapchain - Add HeadlessOutput, which blits the screen pass output into an image and reads it back via per-frame readback buffers (sync or async) - Make surface, present queue and swapchain extension optional - Track validation layer errors in Instance (used by render tests) - Add VkSetup.preferredDeviceType, forceDeviceName substring matching - Fix device selection falling back to first device instead of first suitable device, skip resolve image with numSamples = 1, fix source layout restore in ImageVk.copyFromImage Headless window subsystem (platform/headless): - HeadlessWindowSubsystem with Manual (tests) and Continuous (embedding) frame loops, frame capture / frame listeners, optional fixed time step - Thread-safe resize and input injection (HeadlessWindow, HeadlessInput) - Fails with a clear message if used with a non-Vulkan backend Render tests: - Headless render test harness comparing frames against reference images, failing on validation errors - Separate renderTest gradle task (not part of build / desktopTest) - Manually triggered GitHub workflow running tests on lavapipe Co-Authored-By: Claude Opus 5.5 --- .github/workflows/render-tests.yml | 49 ++++ kool-core/build.gradle.kts | 43 ++++ .../kotlin/de/fabmax/kool/KoolContext.kt | 2 + .../pipeline/backend/vk/BindGroupDataVk.kt | 6 +- .../backend/vk/DescriptorPoolManager.kt | 2 +- .../fabmax/kool/pipeline/backend/vk/Device.kt | 12 +- .../kool/pipeline/backend/vk/FrameOutput.kt | 45 ++++ .../kool/pipeline/backend/vk/FrameSync.kt | 66 +++++ .../pipeline/backend/vk/HeadlessOutput.kt | 171 +++++++++++++ .../kool/pipeline/backend/vk/ImageVk.kt | 4 +- .../kool/pipeline/backend/vk/Instance.kt | 42 ++++ .../kool/pipeline/backend/vk/MemoryManager.kt | 6 +- .../pipeline/backend/vk/PassEncoderState.kt | 18 +- .../pipeline/backend/vk/PhysicalDevice.kt | 34 +-- .../kool/pipeline/backend/vk/ReleaseQueue.kt | 2 +- .../pipeline/backend/vk/RenderBackendVk.kt | 73 ++++-- .../kool/pipeline/backend/vk/ScreenPassVk.kt | 100 +++++--- .../kool/pipeline/backend/vk/Swapchain.kt | 72 +++--- .../kool/pipeline/backend/vk/VkSetup.kt | 24 +- .../de/fabmax/kool/platform/Lwjgl3Context.kt | 11 +- .../kool/platform/headless/HeadlessFrame.kt | 113 +++++++++ .../kool/platform/headless/HeadlessInput.kt | 99 ++++++++ .../kool/platform/headless/HeadlessWindow.kt | 156 ++++++++++++ .../headless/HeadlessWindowSubsystem.kt | 235 ++++++++++++++++++ .../fabmax/kool/rendertest/BasicRenderTest.kt | 180 ++++++++++++++ .../kool/rendertest/ContinuousRenderTest.kt | 110 ++++++++ .../fabmax/kool/rendertest/ImageComparison.kt | 109 ++++++++ .../de/fabmax/kool/rendertest/RenderTest.kt | 196 +++++++++++++++ .../kool/rendertest/RenderTestContext.kt | 105 ++++++++ .../de/fabmax/kool/rendertest/UiRenderTest.kt | 65 +++++ .../basic/animated-cube-30deg.png | Bin 0 -> 1865 bytes .../render-references/basic/clear-color.png | Bin 0 -> 868 bytes .../render-references/basic/pbr-spheres.png | Bin 0 -> 17720 bytes .../render-references/basic/resized-cube.png | Bin 0 -> 1837 bytes .../render-references/basic/textured-quad.png | Bin 0 -> 3871 bytes .../basic/unlit-vertex-color-cube.png | Bin 0 -> 1728 bytes .../render-references/ui/button-clicked.png | Bin 0 -> 7070 bytes .../render-references/ui/button-initial.png | Bin 0 -> 6985 bytes 38 files changed, 2014 insertions(+), 136 deletions(-) create mode 100644 .github/workflows/render-tests.yml create mode 100644 kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/FrameOutput.kt create mode 100644 kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/FrameSync.kt create mode 100644 kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/HeadlessOutput.kt create mode 100644 kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessFrame.kt create mode 100644 kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessInput.kt create mode 100644 kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessWindow.kt create mode 100644 kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessWindowSubsystem.kt create mode 100644 kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/BasicRenderTest.kt create mode 100644 kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/ContinuousRenderTest.kt create mode 100644 kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/ImageComparison.kt create mode 100644 kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/RenderTest.kt create mode 100644 kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/RenderTestContext.kt create mode 100644 kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/UiRenderTest.kt create mode 100644 kool-core/src/desktopTest/resources/render-references/basic/animated-cube-30deg.png create mode 100644 kool-core/src/desktopTest/resources/render-references/basic/clear-color.png create mode 100644 kool-core/src/desktopTest/resources/render-references/basic/pbr-spheres.png create mode 100644 kool-core/src/desktopTest/resources/render-references/basic/resized-cube.png create mode 100644 kool-core/src/desktopTest/resources/render-references/basic/textured-quad.png create mode 100644 kool-core/src/desktopTest/resources/render-references/basic/unlit-vertex-color-cube.png create mode 100644 kool-core/src/desktopTest/resources/render-references/ui/button-clicked.png create mode 100644 kool-core/src/desktopTest/resources/render-references/ui/button-initial.png diff --git a/.github/workflows/render-tests.yml b/.github/workflows/render-tests.yml new file mode 100644 index 000000000..8388e4723 --- /dev/null +++ b/.github/workflows/render-tests.yml @@ -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 diff --git a/kool-core/build.gradle.kts b/kool-core/build.gradle.kts index a3d1834a2..44beee1ff 100644 --- a/kool-core/build.gradle.kts +++ b/kool-core/build.gradle.kts @@ -106,3 +106,46 @@ tasks.register("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("desktopTest") { + exclude(renderTestPackage) +} + +tasks.register("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() } +} diff --git a/kool-core/src/commonMain/kotlin/de/fabmax/kool/KoolContext.kt b/kool-core/src/commonMain/kotlin/de/fabmax/kool/KoolContext.kt index 88b5e3627..f0e436ff9 100644 --- a/kool-core/src/commonMain/kotlin/de/fabmax/kool/KoolContext.kt +++ b/kool-core/src/commonMain/kotlin/de/fabmax/kool/KoolContext.kt @@ -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 diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/BindGroupDataVk.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/BindGroupDataVk.kt index 3c5ace9de..90f49cd95 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/BindGroupDataVk.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/BindGroupDataVk.kt @@ -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) @@ -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 -> @@ -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) diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/DescriptorPoolManager.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/DescriptorPoolManager.kt index 3b6800d51..59eea85cb 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/DescriptorPoolManager.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/DescriptorPoolManager.kt @@ -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 { return pools.getOrPut(poolLayout) { DescriptorPools(poolLayout) }.allocateSets(layout, numSets, stack) } diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Device.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Device.kt index aaad191dd..3757e47fe 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Device.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Device.kt @@ -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? @@ -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 -> @@ -71,12 +71,12 @@ 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) } @@ -84,7 +84,7 @@ class Device(val backend: RenderBackendVk) : BaseReleasable() { vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice.vkPhysicalDevice, cnt, buf) } graphicsQueueProperties = queueProperties[graphicsQueueIdx] - presentQueueProperties = queueProperties[presentQueueIdx] + presentQueueProperties = presentQueueIdx?.let { queueProperties[it] } transferQueueProperties = queueProperties[transferQueueIdx] computeQueueProperties = computeQueueIdx?.let { queueProperties[it] } } diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/FrameOutput.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/FrameOutput.kt new file mode 100644 index 000000000..ef7e24709 --- /dev/null +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/FrameOutput.kt @@ -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 +} diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/FrameSync.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/FrameSync.kt new file mode 100644 index 000000000..59ee00331 --- /dev/null +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/FrameSync.kt @@ -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 + 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 + } +} diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/HeadlessOutput.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/HeadlessOutput.kt new file mode 100644 index 000000000..16fe0a7a3 --- /dev/null +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/HeadlessOutput.kt @@ -0,0 +1,171 @@ +package de.fabmax.kool.pipeline.backend.vk + +import de.fabmax.kool.pipeline.BufferedImageData2d +import de.fabmax.kool.platform.headless.HeadlessFrame +import de.fabmax.kool.platform.headless.HeadlessPixelFormat +import de.fabmax.kool.platform.headless.HeadlessWindow +import de.fabmax.kool.util.Time +import de.fabmax.kool.util.cancelReleaseWith +import de.fabmax.kool.util.logT +import de.fabmax.kool.util.releaseWith +import kotlinx.coroutines.CompletableDeferred +import org.lwjgl.system.MemoryStack +import org.lwjgl.vulkan.KHRSynchronization2.vkCmdPipelineBarrier2KHR +import org.lwjgl.vulkan.VK10.* +import org.lwjgl.vulkan.VK13.* +import org.lwjgl.vulkan.VkMemoryBarrier2 + +/** + * Frame output used in headless mode: The screen pass output is blitted into an output image (converting it into + * the requested pixel format), which is then copied into a host-visible readback buffer. There is one readback + * buffer per frame in flight, so that frames can be read back asynchronously without stalling the render loop. + */ +class HeadlessOutput(val backend: RenderBackendVk, val window: HeadlessWindow) : FrameOutput() { + private val subsystem = window.subsystem + private val frameIndex: Int get() = backend.frameSync.currentFrameIndex + + override val width: Int = window.framebufferSize.x + override val height: Int = window.framebufferSize.y + + // use the same format as in regular windowed mode (typical swapchain format), so that rendering is identical + override val renderFormat: Int = VK_FORMAT_B8G8R8A8_UNORM + + override val waitSemaphore: VkSemaphore? get() = null + override val signalSemaphore: VkSemaphore? get() = null + + private val pixelFormat = subsystem.pixelFormat + private val outputImage: ImageVk + private val readbackBuffers: List + private val slots = List(FrameSync.MAX_FRAMES_IN_FLIGHT) { ReadbackSlot(it) } + + init { + val outputFormat = when (pixelFormat) { + HeadlessPixelFormat.RGBA8 -> VK_FORMAT_R8G8B8A8_UNORM + HeadlessPixelFormat.BGRA8 -> VK_FORMAT_B8G8R8A8_UNORM + } + outputImage = ImageVk( + backend = backend, + imageInfo = ImageInfo( + imageType = VK_IMAGE_TYPE_2D, + format = outputFormat, + width = width, + height = height, + depth = 1, + arrayLayers = 1, + mipLevels = 1, + samples = VK_SAMPLE_COUNT_1_BIT, + usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT or VK_IMAGE_USAGE_TRANSFER_SRC_BIT, + aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + label = "headless-output" + ) + ) + val bufferSize = width.toLong() * height * HeadlessFrame.BYTES_PER_PIXEL + readbackBuffers = List(FrameSync.MAX_FRAMES_IN_FLIGHT) { + backend.memManager.createBuffer( + MemoryInfo( + label = "headless-readback-$it", + size = bufferSize, + usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT, + isReadback = true + ) + ) + } + releaseWith(backend.device) + logT { "Created headless output ($width x $height, $pixelFormat)" } + } + + override fun acquireNextImage(): Boolean { + // the frame previously rendered with the current frame index is complete -> deliver it if not done yet + deliverIfPending(slots[frameIndex]) + return true + } + + override fun recordOutput(screenPass: ScreenPassVk, passEncoderState: PassEncoderState) { + if (!subsystem.isFrameRequested) { + // nobody is interested in this frame, skip the readback + return + } + + val isSync = subsystem.isSyncReadback + val captures = subsystem.takePendingCaptures() + val slot = slots[frameIndex] + check(!slot.isPending) { "Readback slot $frameIndex is still pending" } + + screenPass.blitOutputImage( + dstImage = outputImage.vkImage, + dstWidth = width, + dstHeight = height, + dstLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + passEncoderState = passEncoderState + ) + outputImage.lastKnownLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL + outputImage.copyToBuffer(readbackBuffers[frameIndex], passEncoderState.commandBuffer, passEncoderState.stack) + + // make the transfer write visible to the host once the frame fence is signaled + passEncoderState.stack.apply { + val barrier = VkMemoryBarrier2.calloc(1, this) + barrier[0] + .`sType$Default`() + .srcStageMask(VK_PIPELINE_STAGE_2_ALL_TRANSFER_BIT) + .srcAccessMask(VK_ACCESS_2_TRANSFER_WRITE_BIT) + .dstStageMask(VK_PIPELINE_STAGE_2_HOST_BIT) + .dstAccessMask(VK_ACCESS_2_HOST_READ_BIT) + val dependency = callocVkDependencyInfo { pMemoryBarriers(barrier) } + vkCmdPipelineBarrier2KHR(passEncoderState.commandBuffer, dependency) + } + + slot.isPending = true + slot.isSync = isSync + slot.frameCount = Time.frameCount + slot.captures = captures + } + + override fun present(stack: MemoryStack): Boolean { + val prevSlot = slots[(frameIndex + FrameSync.MAX_FRAMES_IN_FLIGHT - 1) % FrameSync.MAX_FRAMES_IN_FLIGHT] + val currentSlot = slots[frameIndex] + + // deliver frames in order: the previous frame first (if it is complete) + if (prevSlot.isPending && (currentSlot.isSync || backend.frameSync.isFrameComplete(prevSlot.index))) { + deliverIfPending(prevSlot) + } + if (currentSlot.isPending && currentSlot.isSync) { + backend.frameSync.waitForCurrentFrame() + deliverIfPending(currentSlot) + } + return true + } + + private fun deliverIfPending(slot: ReadbackSlot) { + if (!slot.isPending) { + return + } + val buffer = readbackBuffers[slot.index] + val mapped = checkNotNull(buffer.mapped) { "readback buffer was not created mapped" } + backend.memManager.invalidateBuffer(buffer) + + val captures = slot.captures + slot.isPending = false + slot.captures = emptyList() + + val pixels = mapped.duplicate().clear().asReadOnlyBuffer() + val frame = HeadlessFrame(width, height, pixelFormat, pixels, slot.frameCount) + subsystem.deliverFrame(frame, captures) + } + + override fun doRelease() { + cancelReleaseWith(backend.device) + // device is idle when the output is released (on resize or shutdown) -> deliver pending frames in order + slots.filter { it.isPending }.sortedBy { it.frameCount }.forEach { deliverIfPending(it) } + + outputImage.release() + readbackBuffers.forEach { backend.memManager.freeBuffer(it) } + logT { "Destroyed headless output" } + } + + private class ReadbackSlot(val index: Int) { + var isPending = false + var isSync = false + var frameCount = 0 + var captures: List> = emptyList() + } +} diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ImageVk.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ImageVk.kt index 2c51a1aa5..d99237d08 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ImageVk.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ImageVk.kt @@ -167,7 +167,9 @@ class ImageVk( } vkCmdCopyImage(commandBuffer, src.vkImage.handle, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, vkImage.handle, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, region) } - src.transitionLayout(VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, src.lastKnownLayout, commandBuffer, stack = this) + if (srcLayout != VK_IMAGE_LAYOUT_UNDEFINED) { + src.transitionLayout(VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, srcLayout, commandBuffer, stack = this) + } transitionLayout(VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, dstLayout, commandBuffer, stack = this) } } diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Instance.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Instance.kt index fa199e0d6..2f031e422 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Instance.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Instance.kt @@ -9,12 +9,43 @@ import org.lwjgl.system.MemoryStack import org.lwjgl.system.MemoryUtil import org.lwjgl.vulkan.* import org.lwjgl.vulkan.VK10.* +import java.util.concurrent.CopyOnWriteArrayList class Instance(val backend: RenderBackendVk, appName: String) : BaseReleasable() { val vkInstance: VkInstance private var debugMessenger = 0L + /** + * Names of the layers enabled for this instance (requested layers, which are not available are skipped). + */ + var enabledLayers: Set = emptySet() + private set + + /** + * True if the validation layer is enabled and validation messages are reported. + */ + val isValidationActive: Boolean get() = debugMessenger != 0L && VkSetup.VALIDATION_LAYER in enabledLayers + + /** + * Error messages reported by the validation layer (only available if validation is enabled via + * [VkSetup.isValidation]). Only the first [MAX_RECORDED_VALIDATION_ERRORS] errors are recorded. + */ + val validationErrors: List + field = CopyOnWriteArrayList() + + /** + * Total number of error messages reported by the validation layer since the last call to + * [clearValidationErrors]. + */ + var validationErrorCount = 0 + private set + + fun clearValidationErrors() { + validationErrors.clear() + validationErrorCount = 0 + } + init { scopedMem { val enabledLayers: PointerBuffer? = getRequestedLayers() @@ -80,6 +111,7 @@ class Instance(val backend: RenderBackendVk, appName: String) : BaseReleasable() } val enableLayers = backend.setup.requestedLayers - missingLayers + enabledLayers = enableLayers.map { it.name }.toSet() return if (enableLayers.isNotEmpty()) { val ptrs = mallocPointer(enableLayers.size) logD("Instance") { "Enabling layers:" } @@ -145,6 +177,12 @@ class Instance(val backend: RenderBackendVk, appName: String) : BaseReleasable() val logStr = arg.pMessage()?.let { MemoryUtil.memUTF8(it) } ?: "" val msgSplit = logStr.replace(" | ", "\n") + if (messageSeverity >= EXTDebugUtils.VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) { + validationErrorCount++ + if (validationErrors.size < MAX_RECORDED_VALIDATION_ERRORS) { + validationErrors += logStr + } + } when { messageSeverity >= EXTDebugUtils.VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT -> logE(tag) { msgSplit } messageSeverity >= EXTDebugUtils.VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT -> logW(tag) { msgSplit } @@ -155,6 +193,10 @@ class Instance(val backend: RenderBackendVk, appName: String) : BaseReleasable() } } + companion object { + const val MAX_RECORDED_VALIDATION_ERRORS = 100 + } + private fun getMessageTypeName(messageType: Int): String { return when (messageType) { EXTDebugUtils.VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT -> "General" diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/MemoryManager.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/MemoryManager.kt index 9d4ebace9..e3b0ab148 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/MemoryManager.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/MemoryManager.kt @@ -29,7 +29,7 @@ class MemoryManager(val backend: RenderBackendVk) : BaseReleasable() { return buffer } - fun freeBuffer(buffer: VkBuffer, deferTicks: Int = Swapchain.MAX_FRAMES_IN_FLIGHT) { + fun freeBuffer(buffer: VkBuffer, deferTicks: Int = FrameSync.MAX_FRAMES_IN_FLIGHT) { check(buffer in buffers) { "buffer was already release" } buffers -= buffer impl.freeBuffer(deferTicks, buffer) @@ -46,7 +46,7 @@ class MemoryManager(val backend: RenderBackendVk) : BaseReleasable() { inline fun stagingBuffer( size: Long, usage: Int = VK_BUFFER_USAGE_TRANSFER_SRC_BIT, - bufferDeleteTicks: Int = Swapchain.MAX_FRAMES_IN_FLIGHT, + bufferDeleteTicks: Int = FrameSync.MAX_FRAMES_IN_FLIGHT, block: (VkBuffer) -> Unit ) { val stagingInfo = MemoryInfo( @@ -66,7 +66,7 @@ class MemoryManager(val backend: RenderBackendVk) : BaseReleasable() { return image } - fun freeImage(image: VkImage, deferTicks: Int = Swapchain.MAX_FRAMES_IN_FLIGHT) { + fun freeImage(image: VkImage, deferTicks: Int = FrameSync.MAX_FRAMES_IN_FLIGHT) { images -= image impl.freeImage(deferTicks, image) } diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/PassEncoderState.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/PassEncoderState.kt index a1aa0a0e9..88a10037f 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/PassEncoderState.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/PassEncoderState.kt @@ -21,7 +21,7 @@ class PassEncoderState(val backend: RenderBackendVk) { val gpuRenderPass: RenderPassVk get() = _gpuRenderPass!! val renderPass: RenderPass get() = _renderPass!! val stack: MemoryStack get() = _stack!! - val frameIndex: Int get() = backend.swapchain.currentFrameIndex + val frameIndex: Int get() = backend.frameSync.currentFrameIndex var isPassActive = false private set @@ -50,12 +50,18 @@ class PassEncoderState(val backend: RenderBackendVk) { with(stack) { commandBuffer.end() - backend.device.graphicsQueue.submit(backend.swapchain.inFlightFence, this) { - pWaitDstStageMask(ints(VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT or VK_PIPELINE_STAGE_TRANSFER_BIT)) + val waitSema = backend.output.waitSemaphore + val signalSema = backend.output.signalSemaphore + backend.device.graphicsQueue.submit(backend.frameSync.inFlightFence, this) { pCommandBuffers(pointers(commandBuffer)) - pSignalSemaphores(longs(backend.swapchain.renderFinishedSema.handle)) - pWaitSemaphores(longs(backend.swapchain.imageAvailableSema.handle)) - waitSemaphoreCount(1) + if (waitSema != null) { + pWaitDstStageMask(ints(VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT or VK_PIPELINE_STAGE_TRANSFER_BIT)) + pWaitSemaphores(longs(waitSema.handle)) + waitSemaphoreCount(1) + } + if (signalSema != null) { + pSignalSemaphores(longs(signalSema.handle)) + } } _commandBuffer = null } diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/PhysicalDevice.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/PhysicalDevice.kt index 61ef888f7..a5547dd22 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/PhysicalDevice.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/PhysicalDevice.kt @@ -40,6 +40,7 @@ class PhysicalDevice(val backend: RenderBackendVk) : BaseReleasable() { val depthFormat: Int private val selectedDevice: PhysicalDeviceWrapper + private val isPresentRequired: Boolean get() = backend.surface != null private val imgFormatTilingFeatures = mutableMapOf() init { @@ -108,13 +109,6 @@ class PhysicalDevice(val backend: RenderBackendVk) : BaseReleasable() { releaseWith(backend.instance) } - fun querySurfaceFormat(): VkSurfaceFormatKHR { - return scopedMem { - val swapChainInfo = selectedDevice.querySwapChainSupport(this) - swapChainInfo.chooseSurfaceFormat() - } - } - fun querySwapchainSupport(stack: MemoryStack): SwapChainSupportDetails { return selectedDevice.querySwapChainSupport(stack) } @@ -122,6 +116,7 @@ class PhysicalDevice(val backend: RenderBackendVk) : BaseReleasable() { private fun MemoryStack.selectPhysicalDevice(devices: List): PhysicalDeviceWrapper { backend.setup.forceDeviceName?.let { name -> val forced = devices.find { it.properties.deviceNameString() == name } + ?: devices.find { it.properties.deviceNameString().contains(name, ignoreCase = true) } if (forced != null) { logI("PhysicalDevice") { "Forced device selection to device \"$name\"" } return forced @@ -131,9 +126,9 @@ class PhysicalDevice(val backend: RenderBackendVk) : BaseReleasable() { } val suitableDevices = devices.filter { - it.queueFamiliyIndices.isComplete && - it.isSupportingExtensions(backend.setup.requestedDeviceExtensions) && - it.querySwapChainSupport(this).isValid + it.queueFamiliyIndices.isComplete(isPresentRequired) && + it.isSupportingExtensions(backend.requestedDeviceExtensions) && + (!isPresentRequired || it.querySwapChainSupport(this).isValid) } check(suitableDevices.isNotEmpty()) { "No suitable Vulkan devices found! Available devices: " + @@ -145,7 +140,8 @@ class PhysicalDevice(val backend: RenderBackendVk) : BaseReleasable() { logI("PhysicalDevice") { "Multiple Vulkan capable GPUs found:" } suitableDevices.forEach { dev -> logI("PhysicalDevice") { " ${dev.properties.deviceNameString()}" } } } - return suitableDevices.find { it.properties.deviceType() == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU } ?: devices.first() + val preferredType = backend.setup.preferredDeviceType.vkDeviceType + return suitableDevices.find { it.properties.deviceType() == preferredType } ?: suitableDevices.first() } private fun getMaxUsableSampleCount(): Int { @@ -235,9 +231,12 @@ class PhysicalDevice(val backend: RenderBackendVk) : BaseReleasable() { var transferFamily: Int? = null for (i in 0 until nFams[0]) { val props = queueFamilies[i] - vkGetPhysicalDeviceSurfaceSupportKHR(physicalDevice, i, backend.surface.surfaceHandle, ip) - if (presentFamily == null && props.queueCount() > 0 && ip[0] != 0) { - presentFamily = i + val surface = backend.surface + if (surface != null) { + vkGetPhysicalDeviceSurfaceSupportKHR(physicalDevice, i, surface.surfaceHandle, ip) + if (presentFamily == null && props.queueCount() > 0 && ip[0] != 0) { + presentFamily = i + } } if (graphicsFamily == null && props.queueCount() > 0 && props.queueFlags() and VK_QUEUE_GRAPHICS_BIT != 0) { graphicsFamily = i @@ -269,7 +268,7 @@ class PhysicalDevice(val backend: RenderBackendVk) : BaseReleasable() { fmts: VkSurfaceFormatKHR.Buffer? = null ): SwapChainSupportDetails { val ip = stack.mallocInt(1) - val surface = backend.surface.surfaceHandle + val surface = checkNotNull(backend.surface) { "Swapchain support can't be queried in headless mode" }.surfaceHandle val capabilities = caps ?: VkSurfaceCapabilitiesKHR.malloc(stack) vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physicalDevice, surface, capabilities) @@ -303,13 +302,16 @@ class PhysicalDevice(val backend: RenderBackendVk) : BaseReleasable() { val presentFamily: Int?, val transferFamily: Int? ) { - val isComplete = graphicsFamily != null && presentFamily != null val uniqueFamilies: Set = buildSet { if (graphicsFamily != null) { add(graphicsFamily) } if (computeFamily != null) { add(computeFamily) } if (presentFamily != null) { add(presentFamily) } if (transferFamily != null) { add(transferFamily) } } + + fun isComplete(isPresentRequired: Boolean): Boolean { + return graphicsFamily != null && (presentFamily != null || !isPresentRequired) + } } inner class SwapChainSupportDetails( diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ReleaseQueue.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ReleaseQueue.kt index fc1f058c9..73f7a45e2 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ReleaseQueue.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ReleaseQueue.kt @@ -3,7 +3,7 @@ package de.fabmax.kool.pipeline.backend.vk internal object ReleaseQueue { private val queuedReleases = mutableListOf() - fun enqueue(ticks: Int = Swapchain.MAX_FRAMES_IN_FLIGHT, task: () -> Unit) { + fun enqueue(ticks: Int = FrameSync.MAX_FRAMES_IN_FLIGHT, task: () -> Unit) { queuedReleases += QueuedRelease(ticks, task) } diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/RenderBackendVk.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/RenderBackendVk.kt index 44960d7a6..c6933a12f 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/RenderBackendVk.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/RenderBackendVk.kt @@ -11,6 +11,7 @@ import de.fabmax.kool.pipeline.backend.stats.BackendStats import de.fabmax.kool.platform.KoolWindowJvm import de.fabmax.kool.platform.Lwjgl3Context import de.fabmax.kool.platform.createVkWindow +import de.fabmax.kool.platform.headless.HeadlessWindow import de.fabmax.kool.scene.Scene import de.fabmax.kool.util.* import kotlinx.coroutines.CompletableDeferred @@ -32,15 +33,22 @@ class RenderBackendVk(val ctx: Lwjgl3Context) : RenderBackendJvm { val setup = KoolSystem.configJvm.vkSetup ?: VkSetup() + /** + * Device extensions requested by [setup], minus the ones not needed in the current mode (i.e. no swapchain + * extension in headless mode). + */ + val requestedDeviceExtensions: Set + val instance: Instance - var surface: Surface + var surface: Surface?; private set val physicalDevice: PhysicalDevice val device: Device val memManager: MemoryManager val commandPool: CommandPool val commandBuffers: List val descriptorPools: DescriptorPoolManager - var swapchain: Swapchain; private set + val frameSync: FrameSync + var output: FrameOutput; private set val textureLoader: TextureLoaderVk val pipelineManager: PipelineManager val screenPass: ScreenPassVk @@ -60,11 +68,23 @@ class RenderBackendVk(val ctx: Lwjgl3Context) : RenderBackendJvm { private var recreateSurfaceFlag = false + /** + * Window used in headless mode, null in regular windowed mode. + */ + private val headlessWindow: HeadlessWindow? + val isHeadless: Boolean get() = headlessWindow != null + init { window = ctx.windowSubsystem.createVkWindow(ctx) window.setFullscreen(KoolSystem.configJvm.isFullscreen) + headlessWindow = window as? HeadlessWindow + requestedDeviceExtensions = if (isHeadless) { + setup.requestedDeviceExtensions - VkSetup.deviceExtensionSwapchain + } else { + setup.requestedDeviceExtensions + } instance = Instance(this, KoolSystem.configJvm.windowTitle) - surface = Surface(this) + surface = if (isHeadless) null else Surface(this) window.setVisible(KoolSystem.configJvm.showWindowOnStart) physicalDevice = PhysicalDevice(this) @@ -98,9 +118,10 @@ class RenderBackendVk(val ctx: Lwjgl3Context) : RenderBackendJvm { memManager = MemoryManager(this) commandPool = CommandPool(this, device.graphicsQueue) - commandBuffers = commandPool.allocateCommandBuffers(Swapchain.MAX_FRAMES_IN_FLIGHT) + commandBuffers = commandPool.allocateCommandBuffers(FrameSync.MAX_FRAMES_IN_FLIGHT) descriptorPools = DescriptorPoolManager(this) - swapchain = Swapchain(this) + frameSync = FrameSync(this) + output = createOutput() timestampQueryPool = TimestampQueryPool(this) textureLoader = TextureLoaderVk(this) pipelineManager = PipelineManager(this) @@ -125,8 +146,17 @@ class RenderBackendVk(val ctx: Lwjgl3Context) : RenderBackendJvm { BackendStats.resetPerFrameCounts() scopedMem { - var imgOk = swapchain.acquireNextImage() + if (windowResized) { + // recreate output before rendering the frame, so that the frame is rendered with the new size + logD { "Recreate frame output (window resized)" } + windowResized = false + recreateOutput(setup.recreateSurfaceWithSwapchain) + } + + frameSync.waitForCurrentFrame() + var imgOk = output.acquireNextImage() if (imgOk) { + frameSync.resetCurrentFence() ReleaseQueue.processQueue() passEncoderState.beginFrame(this) @@ -147,7 +177,7 @@ class RenderBackendVk(val ctx: Lwjgl3Context) : RenderBackendJvm { // copy all buffers requested for readback to temporary buffers using the current command encoder copyReadbacks(passEncoderState) } - screenPass.blitOutputImage(passEncoderState) + output.recordOutput(screenPass, passEncoderState) frameTimer.end(passEncoderState.commandBuffer) timestampQueryPool.pollResults(passEncoderState.commandBuffer, this) passEncoderState.endFrame() @@ -157,12 +187,12 @@ class RenderBackendVk(val ctx: Lwjgl3Context) : RenderBackendJvm { vkQueueWaitIdle(device.graphicsQueue) mapReadbacks() } - imgOk = swapchain.presentNextImage(this) + imgOk = output.present(this) + frameSync.advanceFrame() } - if (!imgOk || windowResized || recreateSurfaceFlag) { - logD { "Recreate swapchain" } - windowResized = false - recreateSwapchain(setup.recreateSurfaceWithSwapchain || recreateSurfaceFlag) + if (!imgOk || recreateSurfaceFlag) { + logD { "Recreate frame output" } + recreateOutput(setup.recreateSurfaceWithSwapchain || recreateSurfaceFlag) recreateSurfaceFlag = false } } @@ -210,7 +240,8 @@ class RenderBackendVk(val ctx: Lwjgl3Context) : RenderBackendJvm { override fun cleanup(ctx: KoolContext) { device.waitForIdle() ReleaseQueue.processQueue(true) - surface.release() + output.release() + surface?.release() instance.release() ReleaseQueue.processQueue(true) } @@ -340,7 +371,11 @@ class RenderBackendVk(val ctx: Lwjgl3Context) : RenderBackendJvm { } } - private fun recreateSwapchain(recreateSurface: Boolean) { + private fun createOutput(): FrameOutput { + return headlessWindow?.let { HeadlessOutput(this, it) } ?: Swapchain(this, checkNotNull(surface)) + } + + private fun recreateOutput(recreateSurface: Boolean) { // Theoretically it might be possible for the swapchain image format to change (e.g. because the window // is moved to another monitor with HDR) in that case the screen render pass would also need to be // recreated. @@ -348,13 +383,13 @@ class RenderBackendVk(val ctx: Lwjgl3Context) : RenderBackendJvm { // for all the fuzz. device.waitForIdle() - swapchain.release() - if (recreateSurface) { - surface.release() + output.release() + if (recreateSurface && !isHeadless) { + surface?.release() surface = Surface(this) } - swapchain = Swapchain(this) - screenPass.onSwapchainRecreated() + output = createOutput() + screenPass.onOutputRecreated() } private interface GpuReadback diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ScreenPassVk.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ScreenPassVk.kt index 209c4d004..d470f2b85 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ScreenPassVk.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/ScreenPassVk.kt @@ -1,6 +1,8 @@ package de.fabmax.kool.pipeline.backend.vk +import de.fabmax.kool.KoolSystem import de.fabmax.kool.PassData +import de.fabmax.kool.configJvm import de.fabmax.kool.math.Vec3i import de.fabmax.kool.pipeline.* import de.fabmax.kool.util.logI @@ -8,60 +10,71 @@ import de.fabmax.kool.util.releaseWith import org.lwjgl.vulkan.KHRCopyCommands2.vkCmdBlitImage2KHR import org.lwjgl.vulkan.KHRDynamicRendering.vkCmdBeginRenderingKHR import org.lwjgl.vulkan.KHRDynamicRendering.vkCmdEndRenderingKHR -import org.lwjgl.vulkan.KHRSwapchain.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR import org.lwjgl.vulkan.VK10.* import org.lwjgl.vulkan.VkCommandBuffer class ScreenPassVk(backend: RenderBackendVk) : - RenderPassVk(true, backend.swapchain.numSamples, backend) + RenderPassVk(true, KoolSystem.configJvm.numSamples, backend) { - private val _colorTargetFormats = mutableListOf(backend.physicalDevice.querySurfaceFormat().format()) - override val colorTargetFormats: List get() = _colorTargetFormats + override val colorTargetFormats: List + field = mutableListOf(backend.output.renderFormat) private var isStore = false private lateinit var colorImage: ImageVk private lateinit var depthImage: ImageVk - private lateinit var resolveImage: ImageVk + private var resolveImage: ImageVk? = null private lateinit var colorImageView: VkImageView private lateinit var depthImageView: VkImageView - private lateinit var resolveImageView: VkImageView private var colorImageViews: List = emptyList() private var resolveImageViews: List = emptyList() + /** + * Single-sampled final output image of the screen pass: The resolve image if multi-sampling is enabled, the + * color image otherwise. + */ + val outputImage: ImageVk get() = resolveImage ?: colorImage + private val frameCopyPasses = mutableMapOf() init { releaseWith(backend.device) - onSwapchainRecreated() + onOutputRecreated() } - fun onSwapchainRecreated() { + fun onOutputRecreated() { // todo: if this would really change, draw pipelines would also need to be recreated... - _colorTargetFormats[0] = backend.physicalDevice.querySurfaceFormat().format() + colorTargetFormats[0] = backend.output.renderFormat backend.commandPool.singleShotCommands { commandBuffer -> val (cImage, cImageView) = createColorResources(numSamples, commandBuffer) - colorImage = cImage.also { it.releaseWith(backend.swapchain) } + colorImage = cImage.also { it.releaseWith(backend.output) } colorImageView = cImageView colorImageViews = listOf(cImageView) val (dImage, dImageView) = createDepthResources(commandBuffer) - depthImage = dImage.also { it.releaseWith(backend.swapchain) } + depthImage = dImage.also { it.releaseWith(backend.output) } depthImageView = dImageView - val (rImage, rImageView) = createColorResources(1, commandBuffer) - resolveImage = rImage.also { it.releaseWith(backend.swapchain) } - resolveImageView = rImageView - resolveImageViews = listOf(rImageView) + if (isMultiSampled) { + val (rImage, rImageView) = createColorResources(1, commandBuffer) + resolveImage = rImage.also { it.releaseWith(backend.output) } + resolveImageViews = listOf(rImageView) + } else { + // without multi-sampling, the color image is directly used as output, no need to resolve anything + resolveImage = null + resolveImageViews = emptyList() + } } } override fun beginRenderPass(passEncoderState: PassEncoderState, forceLoad: Boolean) { val isLoad = forceLoad || passEncoderState.renderPass.colorAttachments[0].clearColor == ClearColorLoad - val storeOp = if (isStore) VK_ATTACHMENT_STORE_OP_STORE else VK_ATTACHMENT_STORE_OP_DONT_CARE + // without multi-sampling the color image is the output image and needs to be stored in any case + val storeOp = if (isStore || !isMultiSampled) VK_ATTACHMENT_STORE_OP_STORE else VK_ATTACHMENT_STORE_OP_DONT_CARE - val srcLayout = if (isLoad) resolveImage.lastKnownLayout else VK_IMAGE_LAYOUT_UNDEFINED - resolveImage.transitionLayout( + val output = outputImage + val srcLayout = if (isLoad) output.lastKnownLayout else VK_IMAGE_LAYOUT_UNDEFINED + output.transitionLayout( oldLayout = srcLayout, newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, commandBuffer = passEncoderState.commandBuffer, @@ -81,12 +94,14 @@ class ScreenPassVk(backend: RenderBackendVk) : vkCmdBeginRenderingKHR(passEncoderState.commandBuffer, renderingInfo) } - fun blitOutputImage(passEncoderState: PassEncoderState) { - val swapchain = backend.swapchain - val dstImage = swapchain.images[backend.swapchain.nextSwapImage] - - resolveImage.transitionLayout( - oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + /** + * Blits the screen pass output into the given destination image (e.g. a swapchain image). The previous content + * of [dstImage] is discarded, after the blit [dstImage] is transitioned into [dstLayout]. + */ + fun blitOutputImage(dstImage: VkImage, dstWidth: Int, dstHeight: Int, dstLayout: Int, passEncoderState: PassEncoderState) { + val srcImage = outputImage + srcImage.transitionLayout( + oldLayout = srcImage.lastKnownLayout, newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, commandBuffer = passEncoderState.commandBuffer, stack = passEncoderState.stack @@ -105,8 +120,8 @@ class ScreenPassVk(backend: RenderBackendVk) : val blit = passEncoderState.stack.callocVkBlitImageInfo2 { val region = passEncoderState.stack.callocVkImageBlit2N(1) { - srcOffsets(1).set(resolveImage.width, resolveImage.height, 1) - dstOffsets(1).set(swapchain.width, swapchain.height, 1) + srcOffsets(1).set(srcImage.width, srcImage.height, 1) + dstOffsets(1).set(dstWidth, dstHeight, 1) srcSubresource { it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT) it.layerCount(1) @@ -116,7 +131,7 @@ class ScreenPassVk(backend: RenderBackendVk) : it.layerCount(1) } } - srcImage(resolveImage.vkImage.handle) + srcImage(srcImage.vkImage.handle) srcImageLayout(VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL) dstImage(dstImage.handle) dstImageLayout(VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) @@ -127,7 +142,7 @@ class ScreenPassVk(backend: RenderBackendVk) : dstImage.transitionLayout( oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, - newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + newLayout = dstLayout, aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, baseMipLevel = 0, mipLevels = 1, @@ -144,7 +159,7 @@ class ScreenPassVk(backend: RenderBackendVk) : override fun generateMipLevels(passEncoderState: PassEncoderState) { } override fun copy(frameCopy: FrameCopy, passEncoderState: PassEncoderState) { - if (!isStore) { + if (!isStore && isMultiSampled) { isStore = true logI { "Screen copy requested. Enabling screen copy feature. First capture might be corrupted." } } @@ -156,9 +171,9 @@ class ScreenPassVk(backend: RenderBackendVk) : val renderScale = backend.window.renderResolutionFactor val imgInfo = ImageInfo( imageType = VK_IMAGE_TYPE_2D, - format = backend.swapchain.imageFormat, - width = (backend.swapchain.width * renderScale).toInt(), - height = (backend.swapchain.height * renderScale).toInt(), + format = backend.output.renderFormat, + width = (backend.output.width * renderScale).toInt(), + height = (backend.output.height * renderScale).toInt(), depth = 1, arrayLayers = 1, mipLevels = 1, @@ -180,14 +195,14 @@ class ScreenPassVk(backend: RenderBackendVk) : val imgInfo = ImageInfo( imageType = VK_IMAGE_TYPE_2D, format = backend.physicalDevice.depthFormat, - width = (backend.swapchain.width * renderScale).toInt(), - height = (backend.swapchain.height * renderScale).toInt(), + width = (backend.output.width * renderScale).toInt(), + height = (backend.output.height * renderScale).toInt(), depth = 1, arrayLayers = 1, mipLevels = 1, samples = numSamples, tiling = VK_IMAGE_TILING_OPTIMAL, - usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT or VK_IMAGE_USAGE_SAMPLED_BIT, + usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT or VK_IMAGE_USAGE_SAMPLED_BIT or VK_IMAGE_USAGE_TRANSFER_SRC_BIT, aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT, label = "screen-depth" ) @@ -227,14 +242,14 @@ class ScreenPassVk(backend: RenderBackendVk) : val imgInfo = ImageInfo( imageType = VK_IMAGE_TYPE_2D, - format = backend.physicalDevice.querySurfaceFormat().format(), + format = colorTargetFormats[0], width = colorImage.width, height = colorImage.height, depth = 1, arrayLayers = 1, mipLevels = 1, samples = VK_SAMPLE_COUNT_1_BIT, - usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT or VK_IMAGE_USAGE_SAMPLED_BIT, + usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT or VK_IMAGE_USAGE_SAMPLED_BIT or VK_IMAGE_USAGE_TRANSFER_DST_BIT, aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, label = colorDst.name ) @@ -265,7 +280,7 @@ class ScreenPassVk(backend: RenderBackendVk) : arrayLayers = 1, mipLevels = 1, samples = VK_SAMPLE_COUNT_1_BIT, - usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT or VK_IMAGE_USAGE_SAMPLED_BIT, + usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT or VK_IMAGE_USAGE_SAMPLED_BIT or VK_IMAGE_USAGE_TRANSFER_DST_BIT, aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT, label = depthDst.name ) @@ -283,7 +298,16 @@ class ScreenPassVk(backend: RenderBackendVk) : val depthCopyImg = getOrCreateDepthCopy() passEncoderState.ensureRenderPassInactive() + if (isMultiSampled) { + resolveCopy(colorCopyImg, depthCopyImg, passEncoderState) + } else { + // single-sampled images can't be resolved -> copy them instead + colorCopyImg?.copyFromImage(colorImage, passEncoderState.commandBuffer, stack = passEncoderState.stack) + depthCopyImg?.copyFromImage(depthImage, passEncoderState.commandBuffer, stack = passEncoderState.stack) + } + } + private fun resolveCopy(colorCopyImg: ImageVk?, depthCopyImg: ImageVk?, passEncoderState: PassEncoderState) { colorCopyImg?.transitionLayout( oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Swapchain.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Swapchain.kt index c4a3ba39d..7b2673b7c 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Swapchain.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/Swapchain.kt @@ -1,42 +1,40 @@ package de.fabmax.kool.pipeline.backend.vk -import de.fabmax.kool.KoolSystem -import de.fabmax.kool.configJvm import de.fabmax.kool.math.Vec2i -import de.fabmax.kool.util.* +import de.fabmax.kool.util.cancelReleaseWith +import de.fabmax.kool.util.logT +import de.fabmax.kool.util.releaseWith +import de.fabmax.kool.util.scopedMem import org.lwjgl.BufferUtils import org.lwjgl.system.MemoryStack import org.lwjgl.vulkan.KHRSurface.VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR import org.lwjgl.vulkan.KHRSwapchain.* import org.lwjgl.vulkan.VK10.* -class Swapchain(val backend: RenderBackendVk) : BaseReleasable() { +class Swapchain(val backend: RenderBackendVk, surface: Surface) : FrameOutput() { private val physicalDevice: PhysicalDevice get() = backend.physicalDevice private val device: Device get() = backend.device + private val frameIndex: Int get() = backend.frameSync.currentFrameIndex private val nextImage = BufferUtils.createIntBuffer(1) val nextSwapImage: Int get() = nextImage[0] - var currentFrameIndex = 0 - private set val vkSwapchain: VkSwapchain val imageFormat: Int val images: List val imageViews: List - val numSamples = KoolSystem.configJvm.numSamples.coerceAtMost(backend.features.maxSamples) val extent: Vec2i - val width: Int get() = extent.x - val height: Int get() = extent.y + override val width: Int get() = extent.x + override val height: Int get() = extent.y + override val renderFormat: Int get() = imageFormat private val imageAvailableSemas: List private val renderFinishedSemas: List - private val inFlightFences: List - val imageAvailableSema: VkSemaphore get() = imageAvailableSemas[currentFrameIndex] - val renderFinishedSema: VkSemaphore get() = renderFinishedSemas[currentFrameIndex] - val inFlightFence: VkFence get() = inFlightFences[currentFrameIndex] + override val waitSemaphore: VkSemaphore get() = imageAvailableSemas[frameIndex] + override val signalSemaphore: VkSemaphore get() = renderFinishedSemas[frameIndex] init { scopedMem { @@ -50,7 +48,7 @@ class Swapchain(val backend: RenderBackendVk) : BaseReleasable() { } vkSwapchain = device.createSwapchain(this) { - surface(backend.surface.surfaceHandle) + surface(surface.surfaceHandle) minImageCount(imageCount) imageFormat(surfaceFormat.format()) imageColorSpace(surfaceFormat.colorSpace()) @@ -62,10 +60,11 @@ class Swapchain(val backend: RenderBackendVk) : BaseReleasable() { presentMode(presentMode) clipped(true) - val indices = physicalDevice.queueFamiliyIndices - if (indices.graphicsFamily != indices.presentFamily) { + val graphicsFamily = physicalDevice.queueFamiliyIndices.graphicsFamily!! + val presentFamily = physicalDevice.queueFamiliyIndices.presentFamily!! + if (graphicsFamily != presentFamily) { imageSharingMode(VK_SHARING_MODE_CONCURRENT) - pQueueFamilyIndices(ints(indices.graphicsFamily!!, indices.presentFamily!!)) + pQueueFamilyIndices(ints(graphicsFamily, presentFamily)) } else { imageSharingMode(VK_SHARING_MODE_EXCLUSIVE) } @@ -93,15 +92,10 @@ class Swapchain(val backend: RenderBackendVk) : BaseReleasable() { } imageAvailableSemas = buildList { - repeat(MAX_FRAMES_IN_FLIGHT) { add(device.createSemaphore(this@scopedMem)) } + repeat(FrameSync.MAX_FRAMES_IN_FLIGHT) { add(device.createSemaphore(this@scopedMem)) } } renderFinishedSemas = buildList { - repeat(MAX_FRAMES_IN_FLIGHT) { add(device.createSemaphore(this@scopedMem)) } - } - inFlightFences = buildList { - repeat(MAX_FRAMES_IN_FLIGHT) { - add(device.createFence(this@scopedMem) { flags(VK_FENCE_CREATE_SIGNALED_BIT) }) - } + repeat(FrameSync.MAX_FRAMES_IN_FLIGHT) { add(device.createSemaphore(this@scopedMem)) } } } @@ -109,10 +103,8 @@ class Swapchain(val backend: RenderBackendVk) : BaseReleasable() { logT { "Created swap chain" } } - fun acquireNextImage(): Boolean { - vkWaitForFences(device.vkDevice, inFlightFence.handle, true, -1) - vkResetFences(device.vkDevice, inFlightFence.handle) - return when (vkAcquireNextImageKHR(device.vkDevice, vkSwapchain.handle, -1, imageAvailableSema.handle, 0, nextImage)) { + override fun acquireNextImage(): Boolean { + return when (vkAcquireNextImageKHR(device.vkDevice, vkSwapchain.handle, -1, waitSemaphore.handle, 0, nextImage)) { VK_SUCCESS -> true VK_SUBOPTIMAL_KHR -> true // also considered OK VK_ERROR_OUT_OF_DATE_KHR -> false @@ -120,20 +112,29 @@ class Swapchain(val backend: RenderBackendVk) : BaseReleasable() { } } - fun presentNextImage(stack: MemoryStack): Boolean { + override fun recordOutput(screenPass: ScreenPassVk, passEncoderState: PassEncoderState) { + screenPass.blitOutputImage( + dstImage = images[nextSwapImage], + dstWidth = width, + dstHeight = height, + dstLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + passEncoderState = passEncoderState + ) + } + + override fun present(stack: MemoryStack): Boolean { stack.apply { val presentInfo = callocVkPresentInfoKHR { - pWaitSemaphores(longs(renderFinishedSema.handle)) + pWaitSemaphores(longs(signalSemaphore.handle)) pSwapchains(longs(vkSwapchain.handle)) swapchainCount(1) pImageIndices(nextImage) } - currentFrameIndex = (currentFrameIndex + 1) % MAX_FRAMES_IN_FLIGHT - return when (vkQueuePresentKHR(device.presentQueue, presentInfo)) { + return when (vkQueuePresentKHR(device.presentQueue!!, presentInfo)) { VK_SUCCESS -> true VK_SUBOPTIMAL_KHR -> true // also considered OK VK_ERROR_OUT_OF_DATE_KHR -> false - else -> error("failed to acquire swap chain image") + else -> error("failed to present swap chain image") } } } @@ -146,12 +147,7 @@ class Swapchain(val backend: RenderBackendVk) : BaseReleasable() { imageAvailableSemas.forEach { device.destroySemaphore(it) } renderFinishedSemas.forEach { device.destroySemaphore(it) } - inFlightFences.forEach { device.destroyFence(it) } logT { "Destroyed swap chain" } } - - companion object { - const val MAX_FRAMES_IN_FLIGHT = 2 - } } diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/VkSetup.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/VkSetup.kt index 5f9d11786..48e7ab729 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/VkSetup.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/pipeline/backend/vk/VkSetup.kt @@ -16,6 +16,7 @@ import org.lwjgl.vulkan.KHRPortabilityEnumeration.VK_KHR_PORTABILITY_ENUMERATION import org.lwjgl.vulkan.KHRPortabilitySubset.VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME import org.lwjgl.vulkan.KHRSwapchain.VK_KHR_SWAPCHAIN_EXTENSION_NAME import org.lwjgl.vulkan.KHRSynchronization2.VK_KHR_SYNCHRONIZATION_2_EXTENSION_NAME +import org.lwjgl.vulkan.VK10.* import org.lwjgl.vulkan.VK11.VK_API_VERSION_1_1 import org.lwjgl.vulkan.VK12.VK_API_VERSION_1_2 import org.lwjgl.vulkan.VK13.VK_API_VERSION_1_3 @@ -39,7 +40,17 @@ class VkSetup( val preferredColorSpace: ColorSpace = ColorSpace.sRGB, + /** + * Name of the device to use. If no device with exactly this name exists, the first device containing the given + * string in its name (ignoring case) is used, e.g. "llvmpipe" selects Mesa's software renderer (lavapipe). + */ val forceDeviceName: String? = null, + + /** + * Preferred device type, used if multiple suitable devices are available and [forceDeviceName] is not set. + */ + val preferredDeviceType: DeviceType = DeviceType.DiscreteGpu, + val recreateSurfaceWithSwapchain: Boolean = false, ) { val requestedLayers = mutableSetOf() @@ -48,7 +59,7 @@ class VkSetup( val requestedDeviceExtensions = mutableSetOf(deviceExtensionSwapchain) init { - requestedLayers.addOrRemove(RequestedFeature("VK_LAYER_KHRONOS_validation", false), isValidation) + requestedLayers.addOrRemove(RequestedFeature(VALIDATION_LAYER, false), isValidation) requestedInstanceExtensions.addOrRemove( RequestedFeature(EXTDebugUtils.VK_EXT_DEBUG_UTILS_EXTENSION_NAME, false), isValidation @@ -86,6 +97,8 @@ class VkSetup( data class RequestedFeature(val name: String, val isRequired: Boolean) companion object { + const val VALIDATION_LAYER = "VK_LAYER_KHRONOS_validation" + val instanceExtensionPortability = RequestedFeature(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME, false) val deviceExtensionPortability = RequestedFeature(VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME, false) val deviceExtensionSwapchain = RequestedFeature(VK_KHR_SWAPCHAIN_EXTENSION_NAME, true) @@ -112,4 +125,11 @@ enum class ColorSpace { sRGB, AdobeRGB, DCI_P3, -} \ No newline at end of file +} + +enum class DeviceType(val vkDeviceType: Int) { + DiscreteGpu(VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU), + IntegratedGpu(VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU), + VirtualGpu(VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU), + Cpu(VK_PHYSICAL_DEVICE_TYPE_CPU), +} diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/Lwjgl3Context.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/Lwjgl3Context.kt index 93f3f75ff..fc3ad2edd 100644 --- a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/Lwjgl3Context.kt +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/Lwjgl3Context.kt @@ -4,6 +4,7 @@ import de.fabmax.kool.* import de.fabmax.kool.math.clamp import de.fabmax.kool.pipeline.backend.RenderBackendJvm import de.fabmax.kool.pipeline.backend.gl.RenderBackendGl +import de.fabmax.kool.platform.headless.HeadlessWindowSubsystem import de.fabmax.kool.util.ApplicationScope import de.fabmax.kool.util.KoolDispatchers import de.fabmax.kool.util.logE @@ -13,6 +14,7 @@ import java.awt.Desktop import java.net.URI import java.util.* import kotlin.math.min +import kotlin.time.DurationUnit suspend fun Lwjgl3Context(): Lwjgl3Context { val config = KoolSystem.configJvm @@ -44,7 +46,7 @@ class Lwjgl3Context internal constructor (val config: KoolConfigJvm) : KoolConte backend = when { backendResult.isSuccess -> backendResult.getOrThrow() - config.useOpenGlFallback && backendProvider != RenderBackendGl -> { + config.useOpenGlFallback && backendProvider != RenderBackendGl && windowSubsystem !is HeadlessWindowSubsystem -> { logE { "Failed creating render backend ${backendProvider.displayName}: ${backendResult.exceptionOrNull()}\nFalling back to OpenGL" } RenderBackendGl.createBackend(this@Lwjgl3Context).getOrThrow() } @@ -79,7 +81,12 @@ class Lwjgl3Context internal constructor (val config: KoolConfigJvm) : KoolConte val frameData = nextFrameData?.await() ?: render() frameData.syncData() - incrementFrameTime() + val fixedTimeStep = (windowSubsystem as? HeadlessWindowSubsystem)?.fixedTimeStep + if (fixedTimeStep != null) { + incrementFrameTimeBy(fixedTimeStep.toDouble(DurationUnit.SECONDS)) + } else { + incrementFrameTime() + } if (config.asyncSceneUpdate) { nextFrameData = ApplicationScope.async { render() } diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessFrame.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessFrame.kt new file mode 100644 index 000000000..5e0a9225c --- /dev/null +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessFrame.kt @@ -0,0 +1,113 @@ +package de.fabmax.kool.platform.headless + +import de.fabmax.kool.pipeline.BufferedImageData2d +import de.fabmax.kool.pipeline.TexFormat +import de.fabmax.kool.util.Uint8Buffer +import de.fabmax.kool.util.Uint8BufferImpl +import java.nio.ByteBuffer + +/** + * Pixel format of frames rendered in headless mode. Both formats use 8 bits per channel and contain sRGB-encoded + * (i.e. display-ready) color values. + */ +enum class HeadlessPixelFormat { + /** + * Byte order R, G, B, A. Matches [TexFormat.RGBA] and is the most convenient format for writing image files. + */ + RGBA8, + + /** + * Byte order B, G, R, A. This is the native format of many UI toolkits (AWT `TYPE_INT_ARGB`, Skia `N32` on + * little-endian platforms, JavaFX `ByteBgra`), using it avoids a conversion on the receiving side. + */ + BGRA8, +} + +/** + * Determines how frames are driven in headless mode. + */ +sealed interface HeadlessFrameLoop { + /** + * Frames are only rendered on request via [HeadlessWindowSubsystem.renderFrame] / + * [HeadlessWindowSubsystem.captureFrame]. Rendering is synchronous: The output of a frame is available when + * the render call returns. Intended for tests. + */ + data object Manual : HeadlessFrameLoop + + /** + * Frames are rendered continuously by the render loop started with `KoolContext.run()` (e.g. via + * `KoolApplication { }`), limited to [maxFps]. Frame outputs are read back asynchronously without stalling the + * render loop. Intended for embedding kool into other UI frameworks. + */ + data class Continuous(val maxFps: Int = 60) : HeadlessFrameLoop +} + +/** + * A rendered frame passed to frame listeners registered via [HeadlessWindowSubsystem.onFrame]. + * + * [pixels] directly references GPU readback memory and is only valid during the listener call. Its content must + * be copied (or converted via [toImageData]) if it is needed afterward. + */ +class HeadlessFrame internal constructor( + val width: Int, + val height: Int, + val format: HeadlessPixelFormat, + + /** + * Pixel data, rows are ordered top to bottom, without padding between rows (see [rowStride]). + */ + val pixels: ByteBuffer, + + /** + * Value of `Time.frameCount` when this frame was rendered. + */ + val frameCount: Int, +) { + /** + * Number of bytes per pixel row. + */ + val rowStride: Int get() = width * BYTES_PER_PIXEL + + /** + * Frame output is opaque, i.e. alpha values should be ignored. Blending can leave alpha values < 1 in the + * framebuffer, which have no meaning for the displayed image. + */ + val isOpaque: Boolean get() = true + + /** + * Copies this frame into a new [BufferedImageData2d] with [TexFormat.RGBA] format. Alpha values are set to 255. + */ + fun toImageData(): BufferedImageData2d { + val numPixels = width * height + val data = Uint8Buffer(numPixels * BYTES_PER_PIXEL) as Uint8BufferImpl + data.useRaw { dst -> + val src = pixels.duplicate() + when (format) { + HeadlessPixelFormat.RGBA8 -> { + for (i in 0 until numPixels) { + val o = i * BYTES_PER_PIXEL + dst.put(o, src.get(o)) + dst.put(o + 1, src.get(o + 1)) + dst.put(o + 2, src.get(o + 2)) + dst.put(o + 3, OPAQUE_ALPHA) + } + } + HeadlessPixelFormat.BGRA8 -> { + for (i in 0 until numPixels) { + val o = i * BYTES_PER_PIXEL + dst.put(o, src.get(o + 2)) + dst.put(o + 1, src.get(o + 1)) + dst.put(o + 2, src.get(o)) + dst.put(o + 3, OPAQUE_ALPHA) + } + } + } + } + return BufferedImageData2d(data, width, height, TexFormat.RGBA, id = "headless-frame:$frameCount") + } + + companion object { + const val BYTES_PER_PIXEL = 4 + private const val OPAQUE_ALPHA = 255.toByte() + } +} diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessInput.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessInput.kt new file mode 100644 index 000000000..2d7621dd7 --- /dev/null +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessInput.kt @@ -0,0 +1,99 @@ +package de.fabmax.kool.platform.headless + +import de.fabmax.kool.input.* +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * Input of a headless window. There are no OS-level input devices in headless mode, instead input events can be + * forwarded by the host application (e.g. from the UI framework kool is embedded in) or be injected by tests. + * + * All functions are thread-safe. Events are queued and applied at the beginning of the next frame. + * Pointer coordinates are given in framebuffer pixels (i.e. the same coordinate space as + * [HeadlessWindow.framebufferSize]) with the origin in the upper left corner. + */ +class HeadlessInput internal constructor(private val subsystem: HeadlessWindowSubsystem) : PlatformInput { + private val queuedEvents = ConcurrentLinkedQueue<() -> Unit>() + + /** + * Called (on the kool render thread) whenever the cursor mode requested by kool changes. Hosts can use this to + * hide / lock the cursor. + */ + var onCursorModeChanged: ((CursorMode) -> Unit)? = null + + /** + * Called (on the kool render thread) whenever the cursor shape requested by kool changes. Hosts can use this to + * change the cursor icon. + */ + var onCursorShapeChanged: ((CursorShape) -> Unit)? = null + + private var currentCursorShape = CursorShape.DEFAULT + + private val inputScale: Float get() = subsystem.window.renderResolutionFactor + + fun mouseMove(x: Float, y: Float) = enqueue { + subsystem.window.isMouseOverWindow = true + PointerInput.handleMouseMove(x * inputScale, y * inputScale) + } + + /** + * Mouse button event, [button] is one of [PointerInput.LEFT_BUTTON], [PointerInput.RIGHT_BUTTON], etc. + */ + fun mouseButton(button: Int, isDown: Boolean) = enqueue { + PointerInput.handleMouseButtonEvent(button, isDown) + } + + fun mouseScroll(xTicks: Float, yTicks: Float) = enqueue { + PointerInput.handleMouseScroll(xTicks, yTicks) + } + + fun mouseExit() = enqueue { + subsystem.window.isMouseOverWindow = false + PointerInput.handleMouseExit() + } + + fun touchStart(pointerId: Int, x: Float, y: Float) = enqueue { + PointerInput.handleTouchStart(pointerId, x * inputScale, y * inputScale) + } + + fun touchMove(pointerId: Int, x: Float, y: Float) = enqueue { + PointerInput.handleTouchMove(pointerId, x * inputScale, y * inputScale) + } + + fun touchEnd(pointerId: Int) = enqueue { + PointerInput.handleTouchEnd(pointerId) + } + + fun touchCancel(pointerId: Int) = enqueue { + PointerInput.handleTouchCancel(pointerId) + } + + fun keyEvent(event: KeyEvent) = enqueue { + KeyboardInput.handleKeyEvent(event) + } + + fun charTyped(char: Char) = enqueue { + KeyboardInput.handleCharTyped(char) + } + + private fun enqueue(event: () -> Unit) { + queuedEvents += event + } + + internal fun applyQueuedEvents() { + while (true) { + val event = queuedEvents.poll() ?: break + event() + } + } + + override fun setCursorMode(cursorMode: CursorMode) { + onCursorModeChanged?.invoke(cursorMode) + } + + override fun applyCursorShape(cursorShape: CursorShape) { + if (cursorShape != currentCursorShape) { + currentCursorShape = cursorShape + onCursorShapeChanged?.invoke(cursorShape) + } + } +} diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessWindow.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessWindow.kt new file mode 100644 index 000000000..f4218655d --- /dev/null +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessWindow.kt @@ -0,0 +1,156 @@ +package de.fabmax.kool.platform.headless + +import de.fabmax.kool.* +import de.fabmax.kool.math.Vec2i +import de.fabmax.kool.modules.ui2.UiScale +import de.fabmax.kool.platform.KoolWindowJvm +import de.fabmax.kool.util.BufferedList +import de.fabmax.kool.util.WindowTitleHoverHandler +import org.lwjgl.vulkan.VkInstance +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.roundToInt + +/** + * Virtual window used in headless mode. It has no OS-level window and no surface. Its size and scale are set by + * the host application (or test) via [setSize]. + */ +class HeadlessWindow internal constructor( + val subsystem: HeadlessWindowSubsystem, + initialSize: Vec2i, + initialScale: Float, +) : KoolWindowJvm { + + override var isMouseOverWindow: Boolean = false + internal set + + override var parentScreenScale: Float = initialScale + private set + override val positionOnScreen: Vec2i = Vec2i.ZERO + override val sizeOnScreen: Vec2i get() = Vec2i( + (framebufferSize.x / parentScreenScale).roundToInt(), + (framebufferSize.y / parentScreenScale).roundToInt() + ) + + override var renderResolutionFactor: Float = 1f + set(value) { + if (value != field) { + field = value + updateSizesAndScales() + } + } + override var framebufferSize: Vec2i = initialSize.coerceValid() + private set + override var size: Vec2i = framebufferSize + private set + override val renderScale: Float get() = parentScreenScale * renderResolutionFactor + + override var title: String = KoolSystem.configJvm.windowTitle + + override var flags: WindowFlags = WindowFlags(isVisible = true, isFocused = true) + private set(value) { + if (value != field) { + val oldFlags = field + field = value + flagListeners.updated().forEach { it.onFlagsChanged(oldFlags, value) } + } + } + + override val capabilities: WindowCapabilities = WindowCapabilities.NONE.copy( + canSetSize = true, + canSetVisibility = true, + canSetTitle = true, + ) + + override val resizeListeners: BufferedList = BufferedList() + override val scaleChangeListeners: BufferedList = BufferedList() + override val flagListeners: BufferedList = BufferedList() + override val closeListeners: BufferedList = BufferedList() + override val dragAndDropListeners: BufferedList = BufferedList() + + override var windowTitleHoverHandler: WindowTitleHoverHandler = WindowTitleHoverHandler() + + private val requestedSize = AtomicReference(null) + private val requestedFlags = AtomicReference<((WindowFlags) -> WindowFlags)?>(null) + + init { + updateSizesAndScales() + } + + /** + * Requests a new window size in framebuffer pixels and, optionally, a new content scale (e.g. the DPI scale of + * the host UI). Can be called from any thread, the new size is applied at the beginning of the next frame. + */ + fun setSize(framebufferSize: Vec2i, scale: Float = parentScreenScale) { + requestedSize.set(SizeRequest(framebufferSize.coerceValid(), scale)) + } + + override fun setSizeOnScreen(size: Vec2i) { + setSize(Vec2i((size.x * parentScreenScale).roundToInt(), (size.y * parentScreenScale).roundToInt())) + } + + /** + * Sets the focused state of this window. Can be called from any thread, the new state is applied at the + * beginning of the next frame. + */ + fun setFocused(flag: Boolean) = updateFlags { it.copy(isFocused = flag) } + + override fun setVisible(flag: Boolean) = updateFlags { it.copy(isVisible = flag) } + + // fullscreen, maximize, etc. have no meaning in headless mode and are silently ignored + override fun setFullscreen(flag: Boolean) { } + override fun setMaximized(flag: Boolean) { } + override fun setMinimized(flag: Boolean) { } + override fun setPositionOnScreen(pos: Vec2i) { } + override fun setTitleBarVisibility(flag: Boolean) { } + override fun requestFocus() = setFocused(true) + + private fun updateFlags(block: (WindowFlags) -> WindowFlags) { + requestedFlags.getAndUpdate { prev -> if (prev == null) block else { f -> block(prev(f)) } } + } + + /** + * Applies pending size / flag changes and queued input events. Called by [HeadlessWindowSubsystem] on the + * render thread before each frame. + */ + override fun pollEvents() { + requestedSize.getAndSet(null)?.let { request -> + val isSizeChange = request.size != framebufferSize + val isScaleChange = request.scale != parentScreenScale + if (isSizeChange || isScaleChange) { + framebufferSize = request.size + parentScreenScale = request.scale + updateSizesAndScales() + } + } + requestedFlags.getAndSet(null)?.let { flags = it(flags) } + subsystem.input.applyQueuedEvents() + } + + private fun updateSizesAndScales() { + size = Vec2i( + (framebufferSize.x * renderResolutionFactor).toInt(), + (framebufferSize.y * renderResolutionFactor).toInt() + ) + UiScale.updateUiScaleFromWindowScale(renderScale) + resizeListeners.updated().forEach { it.onResize(size) } + scaleChangeListeners.updated().forEach { it.onScaleChanged(renderScale) } + } + + override fun createVulkanSurface(instance: VkInstance): Long { + throw UnsupportedOperationException("HeadlessWindow has no surface") + } + + override fun destroyVulkanSurface(surface: Long, instance: VkInstance) { } + + override fun swapBuffers() { + throw UnsupportedOperationException("HeadlessWindow does not support OpenGL") + } + + override fun close() { + subsystem.close() + } + + private fun Vec2i.coerceValid() = Vec2i(x.coerceAtLeast(1), y.coerceAtLeast(1)) + + private class SizeRequest(val size: Vec2i, val scale: Float) +} diff --git a/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessWindowSubsystem.kt b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessWindowSubsystem.kt new file mode 100644 index 000000000..7564cd47b --- /dev/null +++ b/kool-core/src/desktopMain/kotlin/de/fabmax/kool/platform/headless/HeadlessWindowSubsystem.kt @@ -0,0 +1,235 @@ +package de.fabmax.kool.platform.headless + +import de.fabmax.kool.KoolSystem +import de.fabmax.kool.configJvm +import de.fabmax.kool.math.Vec2i +import de.fabmax.kool.pipeline.BufferedImageData2d +import de.fabmax.kool.pipeline.backend.vk.RenderBackendVk +import de.fabmax.kool.platform.* +import de.fabmax.kool.util.logI +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration + +/** + * Window subsystem for running kool without any window ("headless mode"). Frames are rendered into an offscreen + * image, which can be captured ([captureFrame]) or streamed to frame listeners ([onFrame]). Input events can be + * forwarded via [input]. + * + * Use cases are rendering tests (comparing rendered frames against reference images) and embedding kool into other + * UI frameworks. Headless mode is only supported by the Vulkan backend ([RenderBackendVk]). No windowing library + * (SDL / GLFW / AWT) is initialized, so there are no main-thread requirements (e.g. `-XstartOnFirstThread` on macOS). + * + * Example: + * ``` + * val headless = HeadlessWindowSubsystem(initialSize = Vec2i(800, 600)) + * val ctx = createContext(KoolConfigJvm(windowSubsystem = headless, renderBackend = RenderBackendVk)) + * ctx.scenes += myScene + * val image: BufferedImageData2d = headless.captureFrame() + * ``` + */ +class HeadlessWindowSubsystem( + /** + * Initial window (= output image) size in pixels. Can be changed later via [HeadlessWindow.setSize]. + */ + val initialSize: Vec2i = Vec2i(1280, 720), + + /** + * Initial content scale, i.e. the UI scale of the host (1.0 = 100%). + */ + val initialScale: Float = 1f, + + /** + * Pixel format of the frames passed to [onFrame] listeners. Frames returned by [captureFrame] are always RGBA. + */ + val pixelFormat: HeadlessPixelFormat = HeadlessPixelFormat.RGBA8, + + /** + * Determines whether frames are rendered on request (default, for tests) or continuously by a render loop. + */ + val frameLoop: HeadlessFrameLoop = HeadlessFrameLoop.Manual, + + /** + * If set, each frame advances the time by exactly this duration (instead of the real elapsed time). This makes + * time-based animations deterministic, which is useful for tests. + */ + val fixedTimeStep: Duration? = null, +) : WindowSubsystem { + + private var _window: HeadlessWindow? = null + val window: HeadlessWindow get() = checkNotNull(_window) { "Headless window not yet created" } + + override val input: HeadlessInput = HeadlessInput(this) + + private val closeRequested = AtomicBoolean(false) + override val isCloseRequested: Boolean get() = closeRequested.get() + + private val frameListeners = CopyOnWriteArrayList<(HeadlessFrame) -> Unit>() + private val pendingCaptures = ConcurrentLinkedQueue>() + + private val ctx: Lwjgl3Context get() = KoolSystem.requireContext() as Lwjgl3Context + + private var isShutdown = false + + /** + * Registers a listener, which is called for every rendered frame on the kool render thread. The frame's pixel + * data is only valid during the call. Frames are only read back from the GPU while at least one listener + * is registered. + */ + fun onFrame(listener: (HeadlessFrame) -> Unit): (HeadlessFrame) -> Unit { + frameListeners += listener + return listener + } + + fun removeFrameListener(listener: (HeadlessFrame) -> Unit) { + frameListeners -= listener + } + + /** + * Renders a single frame. Only available with [HeadlessFrameLoop.Manual], must be called from the thread the + * context was created on. + */ + suspend fun renderFrame() { + check(frameLoop == HeadlessFrameLoop.Manual) { + "renderFrame() is only available in HeadlessFrameLoop.Manual mode, frames are rendered automatically in $frameLoop mode" + } + check(!isShutdown) { "Headless context was already shut down" } + window.pollEvents() + ctx.renderFrame() + } + + /** + * Renders [count] frames. Only available with [HeadlessFrameLoop.Manual]. + */ + suspend fun renderFrames(count: Int) { + repeat(count) { renderFrame() } + } + + /** + * Captures the next rendered frame and returns it as RGBA image data. + * + * With [HeadlessFrameLoop.Manual], the frame is rendered by this function (after [warmupFrames] additional + * frames, which can be used to let asynchronous stuff, like texture uploads, settle). With + * [HeadlessFrameLoop.Continuous], the function suspends until the next frame is rendered by the render loop and + * can be called from any thread. + */ + suspend fun captureFrame(warmupFrames: Int = 0): BufferedImageData2d { + val capture = CompletableDeferred() + if (frameLoop == HeadlessFrameLoop.Manual) { + renderFrames(warmupFrames) + pendingCaptures += capture + renderFrame() + } else { + check(!isCloseRequested) { "Headless context is closed" } + pendingCaptures += capture + } + return capture.await() + } + + /** + * Requests the context to be closed. With [HeadlessFrameLoop.Continuous], the render loop exits after the + * current frame and the context is shut down. With [HeadlessFrameLoop.Manual], call [shutdown] afterward + * (or instead) to release all resources. + */ + fun close() { + closeRequested.set(true) + } + + /** + * Shuts down the context and releases all resources. Only needed with [HeadlessFrameLoop.Manual], must be + * called from the thread the context was created on. With [HeadlessFrameLoop.Continuous], use [close] instead. + */ + fun shutdown() { + check(frameLoop == HeadlessFrameLoop.Manual) { "In $frameLoop mode, use close() to end the render loop" } + if (!isShutdown) { + close() + shutdownContext() + } + } + + private fun shutdownContext() { + isShutdown = true + (this as WindowSubsystem).shutdown() + failPendingCaptures() + } + + internal val isFrameRequested: Boolean get() = frameListeners.isNotEmpty() || pendingCaptures.isNotEmpty() + + internal val isSyncReadback: Boolean get() = frameLoop == HeadlessFrameLoop.Manual || pendingCaptures.isNotEmpty() + + internal fun takePendingCaptures(): List> { + return buildList { + while (true) { + add(pendingCaptures.poll() ?: break) + } + } + } + + internal fun deliverFrame(frame: HeadlessFrame, captures: List>) { + frameListeners.forEach { it(frame) } + if (captures.isNotEmpty()) { + val image = frame.toImageData() + captures.forEach { it.complete(image) } + } + } + + private fun failPendingCaptures() { + val error = IllegalStateException("Headless context was shut down before frame was captured") + takePendingCaptures().forEach { it.completeExceptionally(error) } + } + + override fun queryRequiredVkExtensions(): List = emptyList() + + override fun createWindow(clientApi: ClientApi, glInitCallback: GlInitCallback?, ctx: Lwjgl3Context): KoolWindowJvm { + check(clientApi == ClientApi.UNMANAGED) { UNSUPPORTED_BACKEND_MSG } + check(_window == null) { "HeadlessWindowSubsystem only supports a single window" } + return HeadlessWindow(this, initialSize, initialScale).also { _window = it } + } + + override fun onEarlyInit() { + val backend = KoolSystem.configJvm.renderBackend + check(backend === RenderBackendVk) { + "$UNSUPPORTED_BACKEND_MSG Configured render backend: ${backend.displayName}" + } + } + + override fun onBackendCreated(ctx: Lwjgl3Context) { } + + override fun runRenderLoop() { + val loop = frameLoop + check(loop is HeadlessFrameLoop.Continuous) { + "HeadlessFrameLoop.Manual does not have a render loop. Call renderFrame() / captureFrame() to render " + + "frames instead of KoolContext.run() / KoolApplication { }, or use HeadlessFrameLoop.Continuous." + } + val ctx = ctx + val frameNanos = if (loop.maxFps > 0) 1_000_000_000L / loop.maxFps else 0L + runBlocking { + logI { "Starting headless render loop (max fps: ${loop.maxFps})" } + var nextFrame = System.nanoTime() + while (!isCloseRequested) { + window.pollEvents() + ctx.renderFrame() + + if (frameNanos > 0) { + nextFrame += frameNanos + val now = System.nanoTime() + if (nextFrame > now) { + Thread.sleep((nextFrame - now) / 1_000_000L, ((nextFrame - now) % 1_000_000L).toInt()) + } else { + // we are behind schedule, don't try to catch up + nextFrame = now + } + } + } + } + shutdownContext() + } + + companion object { + private const val UNSUPPORTED_BACKEND_MSG = "HeadlessWindowSubsystem requires the Vulkan render backend: Use " + + "KoolConfigJvm(renderBackend = RenderBackendVk, ...). Headless mode is not supported by OpenGL." + } +} diff --git a/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/BasicRenderTest.kt b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/BasicRenderTest.kt new file mode 100644 index 000000000..a21f32fd9 --- /dev/null +++ b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/BasicRenderTest.kt @@ -0,0 +1,180 @@ +package de.fabmax.kool.rendertest + +import de.fabmax.kool.math.Vec2i +import de.fabmax.kool.math.Vec3f +import de.fabmax.kool.math.deg +import de.fabmax.kool.modules.ksl.KslPbrShader +import de.fabmax.kool.modules.ksl.KslUnlitShader +import de.fabmax.kool.modules.ksl.blocks.ColorSpaceConversion +import de.fabmax.kool.pipeline.* +import de.fabmax.kool.scene.addColorMesh +import de.fabmax.kool.scene.addTextureMesh +import de.fabmax.kool.util.Color +import de.fabmax.kool.util.MdColor +import de.fabmax.kool.util.Time +import de.fabmax.kool.util.Uint8Buffer +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.DurationUnit + +class BasicRenderTest { + + @Test + fun clearColor() = renderTest { + addScene { + clearColor = ClearColorFill(MdColor.INDIGO) + } + val image = captureFrame() + assertMatchesReference("basic/clear-color", image, ImageTolerance(maxChannelDiff = 1, maxDiffPixelRatio = 0.0)) + + // additionally check pixel values directly: this also catches errors in the reference image handling + // clear colors are written to the (UNORM) output as is, without any color space conversion + val expected = MdColor.INDIGO + val center = image.toBufferedImage().getRGB(image.width / 2, image.height / 2) + assertChannel(expected.r, (center shr 16) and 0xff, "red") + assertChannel(expected.g, (center shr 8) and 0xff, "green") + assertChannel(expected.b, center and 0xff, "blue") + } + + @Test + fun unlitVertexColorCube() = renderTest { + addScene { + camera.setupCamera(position = Vec3f(2f, 2f, 3f), lookAt = Vec3f.ZERO) + addColorMesh { + generate { + cube { colored() } + } + shader = KslUnlitShader { color { vertexColor() } } + } + } + assertFrameMatchesReference("basic/unlit-vertex-color-cube") + } + + @Test + fun texturedQuad() = renderTest { + val texture = Texture2d( + data = checkerboard(64, 8, Color.WHITE, MdColor.DEEP_ORANGE), + mipMapping = MipMapping.Off, + samplerSettings = SamplerSettings().nearest(), + name = "render-test-checkerboard" + ) + addScene { + camera.setupCamera(position = Vec3f(0f, -1.5f, 2f), lookAt = Vec3f.ZERO) + addTextureMesh { + generate { + rect { + size.set(2f, 2f) + } + } + shader = KslUnlitShader { + color { textureColor(texture) } + // texture colors are converted to linear color space, convert them back for the sRGB output + colorSpaceConversion = ColorSpaceConversion.LinearToSrgb() + } + } + onRelease { texture.release() } + } + assertFrameMatchesReference("basic/textured-quad") + } + + @Test + fun pbrSpheres() = renderTest(400, 200) { + addScene { + camera.setupCamera(position = Vec3f(0f, 0f, 7f), lookAt = Vec3f.ZERO) + lighting.singleDirectionalLight { + setup(Vec3f(-1f, -1f, -1f)) + setColor(Color.WHITE, 5f) + } + val roughness = listOf(0.1f, 0.4f, 0.7f, 1f) + roughness.forEachIndexed { i, r -> + addColorMesh { + generate { + color = MdColor.LIGHT_BLUE + translate((i - 1.5f) * 2.2f, 0f, 0f) + icoSphere { + steps = 3 + } + } + shader = KslPbrShader { + color { vertexColor() } + metallic(0f) + roughness(r) + } + } + } + } + assertFrameMatchesReference("basic/pbr-spheres") + } + + @Test + fun fixedTimeStepAnimation() = renderTest { + var deltaT = 0f + addScene { + camera.setupCamera(position = Vec3f(0f, 1.5f, 3f), lookAt = Vec3f.ZERO) + addColorMesh { + generate { + cube { colored() } + } + shader = KslUnlitShader { color { vertexColor() } } + + // rotates the cube by 90° per second -> 1.5° per frame with the fixed time step of 1/60 s + var angle = 0f + onUpdate { + deltaT = Time.deltaT + angle += 90f * Time.deltaT + transform.setIdentity().rotate(angle.deg, Vec3f.Y_AXIS) + } + } + } + // 19 warmup frames + 1 captured frame -> 20 updates -> 30° rotation + val image = captureFrame(warmupFrames = 19) + assertEquals(RenderTestContext.timeStep.toDouble(DurationUnit.SECONDS).toFloat(), deltaT, 1e-6f) + assertMatchesReference("basic/animated-cube-30deg", image) + } + + @Test + fun resize() = renderTest(320, 240) { + addScene { + camera.setupCamera(position = Vec3f(2f, 2f, 3f), lookAt = Vec3f.ZERO) + addColorMesh { + generate { + cube { colored() } + } + shader = KslUnlitShader { color { vertexColor() } } + } + } + val before = captureFrame() + assertEquals(320, before.width) + assertEquals(240, before.height) + + window.setSize(Vec2i(200, 300)) + val after = captureFrame() + assertEquals(200, after.width) + assertEquals(300, after.height) + assertEquals(Vec2i(200, 300), window.size) + assertMatchesReference("basic/resized-cube", after) + } + + private fun assertChannel(expected: Float, actual: Int, channel: String) { + assertTrue(abs(expected * 255f - actual) <= 1f, "Unexpected $channel channel value: $actual, expected: ${expected * 255f}") + } + + private fun checkerboard(size: Int, cells: Int, colorA: Color, colorB: Color): BufferedImageData2d { + val data = Uint8Buffer(size * size * 4) + val cellSize = size / cells + for (y in 0 until size) { + for (x in 0 until size) { + val c = if ((x / cellSize + y / cellSize) % 2 == 0) colorA else colorB + val o = (y * size + x) * 4 + data[o] = (c.r * 255f).roundToInt().toUByte() + data[o + 1] = (c.g * 255f).roundToInt().toUByte() + data[o + 2] = (c.b * 255f).roundToInt().toUByte() + data[o + 3] = 255u + } + } + return BufferedImageData2d(data, size, size, TexFormat.RGBA) + } +} diff --git a/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/ContinuousRenderTest.kt b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/ContinuousRenderTest.kt new file mode 100644 index 000000000..a3c8cf53e --- /dev/null +++ b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/ContinuousRenderTest.kt @@ -0,0 +1,110 @@ +package de.fabmax.kool.rendertest + +import de.fabmax.kool.KoolConfigJvm +import de.fabmax.kool.createContext +import de.fabmax.kool.math.Vec2i +import de.fabmax.kool.math.Vec3f +import de.fabmax.kool.modules.ksl.KslUnlitShader +import de.fabmax.kool.pipeline.backend.vk.RenderBackendVk +import de.fabmax.kool.pipeline.backend.vk.VkSetup +import de.fabmax.kool.platform.headless.HeadlessFrame +import de.fabmax.kool.platform.headless.HeadlessFrameLoop +import de.fabmax.kool.platform.headless.HeadlessPixelFormat +import de.fabmax.kool.platform.headless.HeadlessWindowSubsystem +import de.fabmax.kool.scene.addColorMesh +import de.fabmax.kool.scene.scene +import de.fabmax.kool.util.Log +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Tests headless mode with a continuous render loop, as it would be used when embedding kool into another UI + * framework: The render loop runs on its own thread, frames are streamed to a frame listener and captured from + * another thread. This uses its own context and therefore must be the only test in this class (the renderTest + * task runs each test class in a separate JVM). + */ +class ContinuousRenderTest { + + @Test + fun continuousRenderLoop() { + Log.level = Log.Level.WARN + val headless = HeadlessWindowSubsystem( + initialSize = Vec2i(RenderTestContext.DEFAULT_WIDTH, RenderTestContext.DEFAULT_HEIGHT), + pixelFormat = HeadlessPixelFormat.BGRA8, + frameLoop = HeadlessFrameLoop.Continuous(maxFps = 60), + ) + val config = KoolConfigJvm( + renderBackend = RenderBackendVk, + windowSubsystem = headless, + vkSetup = VkSetup( + isValidation = System.getProperty("kool.renderTest.validation")?.toBoolean() ?: true, + forceDeviceName = System.getProperty("kool.renderTest.device")?.ifBlank { null }, + ), + useOpenGlFallback = false, + ) + val ctx = runBlocking { createContext(config) } + val instance = (ctx.backend as RenderBackendVk).instance + + // same scene as in BasicRenderTest.unlitVertexColorCube -> must produce the same image + ctx.addScene(scene { + camera.setupCamera(position = Vec3f(2f, 2f, 3f), lookAt = Vec3f.ZERO) + addColorMesh { + generate { + cube { colored() } + } + shader = KslUnlitShader { color { vertexColor() } } + } + }) + + val frames = CopyOnWriteArrayList() + headless.onFrame { frame -> frames += FrameInfo(frame) } + + var renderLoopError: Throwable? = null + val renderThread = thread(name = "kool-continuous-render-test") { + try { + ctx.run() + } catch (e: Throwable) { + renderLoopError = e + } + } + + try { + val image = runBlocking { + withTimeout(30.seconds) { + // capture from this (non-render) thread, skip a few frames to let everything settle + repeat(3) { headless.captureFrame() } + headless.captureFrame() + } + } + assertImageMatchesReference("basic/unlit-vertex-color-cube", image) + + // frame listeners receive all frames in order and with the requested pixel format + assertTrue(frames.size >= 4, "Expected at least 4 frames, got ${frames.size}") + assertTrue(frames.all { it.format == HeadlessPixelFormat.BGRA8 && it.width == image.width && it.height == image.height }) + assertEquals(frames.map { it.frameCount }.sorted(), frames.map { it.frameCount }, "Frames not delivered in order") + assertEquals(frames.size, frames.map { it.frameCount }.distinct().size, "Frames delivered more than once") + } finally { + headless.close() + renderThread.join(10_000) + } + assertFalse(renderThread.isAlive, "Render loop did not terminate after close()") + renderLoopError?.let { throw it } + + // includes errors reported during shutdown (e.g. leaked objects) + assertNoValidationErrors(instance) + } + + private class FrameInfo(frame: HeadlessFrame) { + val width = frame.width + val height = frame.height + val format = frame.format + val frameCount = frame.frameCount + } +} diff --git a/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/ImageComparison.kt b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/ImageComparison.kt new file mode 100644 index 000000000..b077f0605 --- /dev/null +++ b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/ImageComparison.kt @@ -0,0 +1,109 @@ +package de.fabmax.kool.rendertest + +import de.fabmax.kool.pipeline.BufferedImageData2d +import de.fabmax.kool.pipeline.TexFormat +import de.fabmax.kool.util.Uint8Buffer +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.math.abs +import kotlin.math.max + +/** + * Tolerance used when comparing rendered images against reference images. Different GPUs / drivers produce + * slightly different results (rasterization rules, MSAA sample positions, floating point precision), so an exact + * comparison is not feasible. + * + * A pixel is considered different if any of its color channels differs by more than [maxChannelDiff]. The comparison + * fails if the ratio of different pixels exceeds [maxDiffPixelRatio]. + */ +data class ImageTolerance( + val maxChannelDiff: Int = 16, + val maxDiffPixelRatio: Double = 0.005, +) { + companion object { + val Exact = ImageTolerance(0, 0.0) + val Default = ImageTolerance() + } +} + +class ImageComparisonResult( + val diffPixels: Int, + val totalPixels: Int, + val maxChannelDiff: Int, + val diffImage: BufferedImage, +) { + val diffPixelRatio: Double get() = diffPixels.toDouble() / totalPixels + + fun isWithin(tolerance: ImageTolerance) = diffPixelRatio <= tolerance.maxDiffPixelRatio + + override fun toString(): String { + return "$diffPixels of $totalPixels pixels differ (%.3f %%), max channel difference: $maxChannelDiff" + .format(diffPixelRatio * 100.0) + } +} + +object ImageComparison { + /** + * Compares the two images pixel by pixel. Images must have the same size. The returned diff image highlights + * different pixels in red (bright red = large difference), equal pixels are shown dimmed in gray scale. + */ + fun compare(actual: BufferedImage, expected: BufferedImage, tolerance: ImageTolerance): ImageComparisonResult { + require(actual.width == expected.width && actual.height == expected.height) { + "Image sizes differ: actual: ${actual.width} x ${actual.height}, expected: ${expected.width} x ${expected.height}" + } + val width = actual.width + val height = actual.height + val actualPixels = actual.getRGB(0, 0, width, height, null, 0, width) + val expectedPixels = expected.getRGB(0, 0, width, height, null, 0, width) + val diffPixels = IntArray(width * height) + + var numDiff = 0 + var maxDiff = 0 + for (i in actualPixels.indices) { + val a = actualPixels[i] + val e = expectedPixels[i] + val diff = max( + max(abs(a.channel(16) - e.channel(16)), abs(a.channel(8) - e.channel(8))), + max(abs(a.channel(0) - e.channel(0)), abs(a.channel(24) - e.channel(24))) + ) + maxDiff = max(maxDiff, diff) + diffPixels[i] = if (diff > tolerance.maxChannelDiff) { + numDiff++ + val r = 128 + diff / 2 + (0xff shl 24) or (r shl 16) + } else { + val gray = (a.channel(16) * 77 + a.channel(8) * 151 + a.channel(0) * 28) shr 10 + (0xff shl 24) or (gray shl 16) or (gray shl 8) or gray + } + } + + val diffImage = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + diffImage.setRGB(0, 0, width, height, diffPixels, 0, width) + return ImageComparisonResult(numDiff, width * height, maxDiff, diffImage) + } + + private fun Int.channel(shift: Int): Int = (this ushr shift) and 0xff +} + +fun BufferedImageData2d.toBufferedImage(): BufferedImage { + check(format == TexFormat.RGBA) { "Unsupported image format: $format (only RGBA is supported)" } + val src = data as Uint8Buffer + val pixels = IntArray(width * height) { i -> + val r = src[i * 4].toInt() + val g = src[i * 4 + 1].toInt() + val b = src[i * 4 + 2].toInt() + val a = src[i * 4 + 3].toInt() + (a shl 24) or (r shl 16) or (g shl 8) or b + } + return BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB).apply { + setRGB(0, 0, width, height, pixels, 0, width) + } +} + +fun BufferedImage.writePng(file: File) { + file.parentFile?.mkdirs() + check(ImageIO.write(this, "png", file)) { "Failed writing image $file" } +} + +fun readPng(file: File): BufferedImage = checkNotNull(ImageIO.read(file)) { "Failed reading image $file" } diff --git a/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/RenderTest.kt b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/RenderTest.kt new file mode 100644 index 000000000..ca892ea7b --- /dev/null +++ b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/RenderTest.kt @@ -0,0 +1,196 @@ +package de.fabmax.kool.rendertest + +import de.fabmax.kool.math.Vec2i +import de.fabmax.kool.pipeline.BufferedImageData2d +import de.fabmax.kool.pipeline.Texture +import de.fabmax.kool.pipeline.backend.vk.Instance +import de.fabmax.kool.platform.Lwjgl3Context +import de.fabmax.kool.platform.headless.HeadlessInput +import de.fabmax.kool.platform.headless.HeadlessWindow +import de.fabmax.kool.platform.headless.HeadlessWindowSubsystem +import de.fabmax.kool.scene.Scene +import kotlinx.coroutines.delay +import java.io.File +import kotlin.test.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +/** + * Runs a render test: [block] is executed on the render thread of the shared headless [RenderTestContext]. Scenes + * added via [RenderTestScope.addScene] are removed and released after the test. The test fails if the Vulkan + * validation layer reports any errors while the test is running. + */ +fun renderTest( + width: Int = RenderTestContext.DEFAULT_WIDTH, + height: Int = RenderTestContext.DEFAULT_HEIGHT, + block: suspend RenderTestScope.() -> Unit +) = RenderTestContext.runOnRenderThread { + val instance = RenderTestContext.backend.instance + if (RenderTestContext.isValidationRequired && !instance.isValidationActive) { + fail("Vulkan validation layer is required (kool.renderTest.requireValidation=true) but not available") + } + + // make sure the window has the requested size before the test starts + val headless = RenderTestContext.headless + headless.window.setSize(Vec2i(width, height), 1f) + headless.renderFrame() + instance.clearValidationErrors() + + val scope = RenderTestScope(headless) + try { + scope.block() + } finally { + scope.tearDown() + } + + assertNoValidationErrors(instance) +} + +/** + * Fails if the Vulkan validation layer reported any errors since the last call to [Instance.clearValidationErrors]. + * Clears the reported errors. + */ +fun assertNoValidationErrors(instance: Instance) { + val errorCount = instance.validationErrorCount + if (errorCount > 0) { + val errors = instance.validationErrors.joinToString("\n\n") + instance.clearValidationErrors() + fail("Vulkan validation layer reported $errorCount errors:\n$errors") + } +} + +class RenderTestScope internal constructor(val headless: HeadlessWindowSubsystem) { + val ctx: Lwjgl3Context get() = RenderTestContext.ctx + val window: HeadlessWindow get() = headless.window + val input: HeadlessInput get() = headless.input + + private val scenes = mutableListOf() + + /** + * Creates a new scene, adds it to the context and removes / releases it after the test. + */ + fun addScene(name: String? = null, block: Scene.() -> Unit): Scene { + val scene = Scene(name).apply(block) + addScene(scene) + return scene + } + + fun addScene(scene: Scene) { + scenes += scene + ctx.addScene(scene) + } + + suspend fun renderFrame() = headless.renderFrame() + + suspend fun renderFrames(count: Int) = headless.renderFrames(count) + + /** + * Renders frames until [condition] becomes true. Useful to wait for asynchronously loaded resources (e.g. + * textures loaded from assets). Fails if the condition is not met within [timeout]. + */ + suspend fun renderUntil(timeout: Duration = 10.seconds, condition: () -> Boolean) { + val mark = TimeSource.Monotonic.markNow() + while (!condition()) { + if (mark.elapsedNow() > timeout) { + fail("Condition not met within $timeout") + } + renderFrame() + // assets are loaded by other threads, don't hog the CPU + delay(1) + } + } + + /** + * Renders frames until all given textures are loaded (see [renderUntil]). + */ + suspend fun awaitTexturesLoaded(vararg textures: Texture<*>, timeout: Duration = 10.seconds) { + renderUntil(timeout) { textures.all { it.isLoaded } } + } + + /** + * Renders [warmupFrames] frames followed by a captured frame, which is returned. + */ + suspend fun captureFrame(warmupFrames: Int = DEFAULT_WARMUP_FRAMES): BufferedImageData2d { + return headless.captureFrame(warmupFrames) + } + + /** + * Captures a frame (see [captureFrame]) and compares it with the reference image [referenceName]. + */ + suspend fun assertFrameMatchesReference( + referenceName: String, + tolerance: ImageTolerance = ImageTolerance.Default, + warmupFrames: Int = DEFAULT_WARMUP_FRAMES, + ) { + assertMatchesReference(referenceName, captureFrame(warmupFrames), tolerance) + } + + fun assertMatchesReference( + referenceName: String, + image: BufferedImageData2d, + tolerance: ImageTolerance = ImageTolerance.Default, + ) = assertImageMatchesReference(referenceName, image, tolerance) + + internal suspend fun tearDown() { + input.mouseExit() + scenes.forEach { ctx.removeScene(it) } + // render a frame without the removed scenes before releasing them + headless.renderFrame() + scenes.forEach { it.release() } + scenes.clear() + // let the backend process the deferred release of GPU resources + headless.renderFrames(3) + } + + companion object { + const val DEFAULT_WARMUP_FRAMES = 2 + } +} + +/** + * Compares the given image with the reference image [referenceName] (a path relative to the reference image + * directory, without file extension). If reference images are updated (`kool.renderTest.updateReferences=true`), + * the given image is written as new reference image instead. + */ +fun assertImageMatchesReference( + referenceName: String, + image: BufferedImageData2d, + tolerance: ImageTolerance = ImageTolerance.Default, +) { + val actual = image.toBufferedImage() + val referenceFile = File(RenderTestContext.referenceDir, "$referenceName.png") + + if (RenderTestContext.isUpdateReferences) { + actual.writePng(referenceFile) + println("Updated reference image: $referenceFile") + return + } + + val reportPrefix = File(RenderTestContext.reportDir, referenceName).path + val actualFile = File("$reportPrefix-actual.png") + if (!referenceFile.exists()) { + actual.writePng(actualFile) + fail( + "Reference image $referenceFile does not exist. Rendered image was written to $actualFile. " + + "Run with -Pkool.renderTest.updateReferences=true to generate reference images." + ) + } + + val expected = readPng(referenceFile) + if (expected.width != actual.width || expected.height != actual.height) { + actual.writePng(actualFile) + fail( + "Rendered image size (${actual.width} x ${actual.height}) differs from reference image size " + + "(${expected.width} x ${expected.height}): $referenceFile. Rendered image: $actualFile" + ) + } + + val result = ImageComparison.compare(actual, expected, tolerance) + if (!result.isWithin(tolerance)) { + actual.writePng(actualFile) + expected.writePng(File("$reportPrefix-expected.png")) + result.diffImage.writePng(File("$reportPrefix-diff.png")) + fail("Rendered image differs from reference $referenceName: $result (tolerance: $tolerance). See $reportPrefix-*.png") + } +} diff --git a/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/RenderTestContext.kt b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/RenderTestContext.kt new file mode 100644 index 000000000..b47c33c44 --- /dev/null +++ b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/RenderTestContext.kt @@ -0,0 +1,105 @@ +package de.fabmax.kool.rendertest + +import de.fabmax.kool.KoolConfigJvm +import de.fabmax.kool.createContext +import de.fabmax.kool.math.Vec2i +import de.fabmax.kool.pipeline.backend.vk.RenderBackendVk +import de.fabmax.kool.pipeline.backend.vk.VkSetup +import de.fabmax.kool.platform.Lwjgl3Context +import de.fabmax.kool.platform.headless.HeadlessFrameLoop +import de.fabmax.kool.platform.headless.HeadlessWindowSubsystem +import de.fabmax.kool.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.concurrent.Executors +import kotlin.time.Duration.Companion.seconds + +/** + * Shared headless kool context used by all render tests within a JVM (there can only be one context per JVM). The + * context is created lazily on first use and is never shut down explicitly, it lives until the test JVM exits. + * + * All interactions with the context happen on a dedicated render thread, see [runOnRenderThread]. + * + * Configuration is done via system properties (forwarded from gradle properties with the same name by the + * `renderTest` task, e.g. `./gradlew :kool-core:renderTest -Pkool.renderTest.device=llvmpipe`): + * + * - `kool.renderTest.updateReferences`: If true, reference images are (re-)generated instead of compared. + * - `kool.renderTest.referenceDir`: Directory containing the reference images. + * - `kool.renderTest.reportDir`: Directory, actual / expected / diff images of failed tests are written to. + * - `kool.renderTest.device`: Name (or part of the name) of the Vulkan device to use, e.g. "llvmpipe" for lavapipe. + * - `kool.renderTest.validation`: Enables the Vulkan validation layer (default: true, ignored if layer isn't available). + * - `kool.renderTest.requireValidation`: Fails tests if the validation layer is not available (default: false). + * - `kool.renderTest.logLevel`: Log level used during render tests (default: WARN). + */ +object RenderTestContext { + val isUpdateReferences: Boolean = System.getProperty("kool.renderTest.updateReferences").toBoolean() + val referenceDir = File(System.getProperty("kool.renderTest.referenceDir", "src/desktopTest/resources/render-references")) + val reportDir = File(System.getProperty("kool.renderTest.reportDir", "build/reports/render-tests")) + + private val deviceName: String? = System.getProperty("kool.renderTest.device")?.ifBlank { null } + private val isValidation: Boolean = System.getProperty("kool.renderTest.validation")?.toBoolean() ?: true + val isValidationRequired: Boolean = System.getProperty("kool.renderTest.requireValidation").toBoolean() + private val logLevel: Log.Level = System.getProperty("kool.renderTest.logLevel") + ?.let { Log.Level.valueOf(it.uppercase()) } ?: Log.Level.WARN + + /** + * Fixed time step used for each frame, which makes animations deterministic. + */ + val timeStep = (1.0 / 60.0).seconds + + private val renderThread = Executors.newSingleThreadExecutor { r -> + Thread(r, "kool-render-test").apply { isDaemon = true } + }.asCoroutineDispatcher() + + private var _headless: HeadlessWindowSubsystem? = null + private var _ctx: Lwjgl3Context? = null + + val headless: HeadlessWindowSubsystem get() = checkNotNull(_headless) { "Render test context not initialized" } + val ctx: Lwjgl3Context get() = checkNotNull(_ctx) { "Render test context not initialized" } + val backend: RenderBackendVk get() = ctx.backend as RenderBackendVk + + /** + * Executes the given block on the render thread and blocks until it completes. Creates the context if it + * does not exist yet. + */ + fun runOnRenderThread(block: suspend CoroutineScope.() -> T): T = runBlocking(renderThread) { + if (_ctx == null) { + createTestContext() + } + block() + } + + private suspend fun createTestContext() { + Log.level = logLevel + val headless = HeadlessWindowSubsystem( + initialSize = Vec2i(DEFAULT_WIDTH, DEFAULT_HEIGHT), + frameLoop = HeadlessFrameLoop.Manual, + fixedTimeStep = timeStep, + ) + val config = KoolConfigJvm( + renderBackend = RenderBackendVk, + windowSubsystem = headless, + vkSetup = VkSetup(isValidation = isValidation, forceDeviceName = deviceName), + useOpenGlFallback = false, + asyncSceneUpdate = false, + numSamples = 4, + storageDir = "build/render-test-storage", + httpCacheDir = "build/render-test-http-cache", + ) + _headless = headless + _ctx = createContext(config) + + val validationState = when { + backend.instance.isValidationActive -> "active" + isValidation -> "requested but not available" + else -> "disabled" + } + // always print this, independent of the log level, so that it is clear what the tests were run with + println("Render test context created, device: ${backend.deviceName}, validation: $validationState") + } + + const val DEFAULT_WIDTH = 320 + const val DEFAULT_HEIGHT = 240 +} diff --git a/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/UiRenderTest.kt b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/UiRenderTest.kt new file mode 100644 index 000000000..dd0e41a30 --- /dev/null +++ b/kool-core/src/desktopTest/kotlin/de/fabmax/kool/rendertest/UiRenderTest.kt @@ -0,0 +1,65 @@ +package de.fabmax.kool.rendertest + +import de.fabmax.kool.input.PointerInput +import de.fabmax.kool.modules.ui2.* +import de.fabmax.kool.pipeline.ClearColorFill +import de.fabmax.kool.scene.Scene +import de.fabmax.kool.util.MdColor +import de.fabmax.kool.util.MsdfFont +import kotlin.test.Test +import kotlin.test.assertEquals + +class UiRenderTest { + + @Test + fun buttonClick() = renderTest(480, 360) { + var buttonNode: UiNode? = null + var clickCount = 0 + + addScene { + setupUiScene(ClearColorFill(Scene.DEFAULT_CLEAR_COLOR)) + addPanelSurface(colors = Colors.singleColorLight(MdColor.LIGHT_GREEN)) { + modifier + .size(400.dp, 300.dp) + .align(AlignmentX.Center, AlignmentY.Center) + .background(RoundRectBackground(colors.background, 16.dp)) + + var clicks by remember(0) + buttonNode = Button("Click me!") { + modifier + .alignX(AlignmentX.Center) + .margin(sizes.largeGap * 4f) + .padding(horizontal = sizes.largeGap, vertical = sizes.gap) + .font(sizes.largeText) + .onClick { + clicks++ + clickCount++ + } + }.uiNode + Text("Button clicked $clicks times") { + modifier.alignX(AlignmentX.Center) + } + } + } + // the default font texture is loaded asynchronously + awaitTexturesLoaded(MsdfFont.DEFAULT_FONT_DATA.map) + assertFrameMatchesReference("ui/button-initial") + + // click the button via injected input events (pointer coordinates are in framebuffer pixels) + val button = checkNotNull(buttonNode) + val x = button.leftPx + button.widthPx * 0.5f + val y = button.topPx + button.heightPx * 0.5f + input.mouseMove(x, y) + renderFrame() + input.mouseButton(PointerInput.LEFT_BUTTON, true) + renderFrame() + input.mouseButton(PointerInput.LEFT_BUTTON, false) + renderFrame() + // the click is reported by the pointer in the frame the button is released and handled by the UI in the next + renderFrame() + assertEquals(1, clickCount) + + // button is hovered and text shows the updated click count + assertFrameMatchesReference("ui/button-clicked") + } +} diff --git a/kool-core/src/desktopTest/resources/render-references/basic/animated-cube-30deg.png b/kool-core/src/desktopTest/resources/render-references/basic/animated-cube-30deg.png new file mode 100644 index 0000000000000000000000000000000000000000..7f1c35ca8c6dc55e207da37e9408149a4e58f704 GIT binary patch literal 1865 zcmd^A`BPI@7`^ZDAPEQrM8P5vL>gJb5^9i55=aJ^NPr3$GYBc56ay|mS%p4835qO^ zEY^TzP^eU-fNYVXNfly?inVP7!V*W600tKj#T0F*PXCDZmpkX)@0|0UnfuMXCqh{M z`nsR#0s!;_1L$7@08usg1y)D19wGJd0Knh_>9nwnDAhRa2SK=*^pOGGu4|aXu=|>M z+EHlDpwr@Y?>(d6$}`7@(oGfQv3yrkMNH&C<#XSQ-oFH0-!jcip7`LJv9*GU!OWMX zOkB6>KEER;OS@bdp*97!2)@V$r8+=J49*hI1bJ*2d&`(=Sq0#>|4B&{FzJ<>swzvL z6unbR`sRFysv(IBQhqhT+HLvGyy{AXOX3I8_G6&E$IsV|k56`hE(#iayNjn?_{P+w z_LE~%6{iI^7tZG3EX`}@C=ZMAWIiY~_fGB2AY3g0Iz3bIb4^$)0hUV(Ka|pkwX%oV z_iMXz``*o!7|L>AW{#NrJQFc?ev>=WDzrt#wQDoyw$`@TZ4A{Pccm_V?}^9M9~5&8YTWeZ{S$!`-%NXC|X_a#hRrK2hpd^Xa_! zA+{z;j>clHad>5Ul<3rdaIKs8C5{`KG?f;(w^B5d=Eeu5!JznnmfP+pa)nEqoDQT! zlri3+x81)IyJQeGH=^EjrPednNL*ivQxq9RdZY=kinmDXrC6<=8PG@00;+ zl%e7jPDn+E3aatFL8GqmUNAds2(VZiBqH9{Y1w)A#~P2NaqXH|PUXzXmg|4W#~rJL zJiNHR3aj|lrCSnuFuj+Z*F*!w95Q;w-Du-+u>cBDM&xLmkPy&!R#8aWP^5MSH;yfN)F;oHE*6EjH~yNOu}(o5^N^ak2n+v zB@R`!;kal+`o~rPdUn%*>kXSxff_H)LYz^s^p$oa0b0|#_&p6-P4h1oZP41D5F2{h z;uFsb)r^+L;DYz=0U~NO%6%;xsIOnzxuFgeva{ATmN2%Z0iLngGN~i$KtX5;kZnO} zRW;gOl1l@+JA+`e{Y?$Z*~v9TbQ_K^`^lE*zJC>8*!UUqUM8qC*Uw7wT`1Xg=?=TViid6N2 zkN6v$rIULqfo+S{SNnlIDFKMTp~9qZrpf;39;AF5|BjZwzEB=Tf#NRYOy^l^hb*#< zs0TZO5vLWwqedle6A6mDELspV^@Yl|643tVSf;{8!#c|ZBMB^I$buRiX<&ukY3C6# zTEIzy6i?kRrx<$e1B|jLq`Ys;M6wGBd8-TpBZFO;T=QlEz03w@ncbT}7#^4lsh2ik zEioe|5=xxfcn2#8xU)SC6ALJO+M(z9`t`<7@6Z*Lv*}=r(Ror@%~M{G~~bkWUpXESJ}nQ@styqFA@YYSo9WO GZvNj|biqsj literal 0 HcmV?d00001 diff --git a/kool-core/src/desktopTest/resources/render-references/basic/clear-color.png b/kool-core/src/desktopTest/resources/render-references/basic/clear-color.png new file mode 100644 index 0000000000000000000000000000000000000000..7a368e22df03d84469e88296465dbe5b421e4d99 GIT binary patch literal 868 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 D0y-)y literal 0 HcmV?d00001 diff --git a/kool-core/src/desktopTest/resources/render-references/basic/resized-cube.png b/kool-core/src/desktopTest/resources/render-references/basic/resized-cube.png new file mode 100644 index 0000000000000000000000000000000000000000..d4a40f426de353cc3328ad5ec74e2f6ba532d170 GIT binary patch literal 1837 zcmeAS@N?(olHy`uVBq!ia0vp^Cm0wQbvW37thQf&_AxN9eera045^5Fdv|YFimSx& zhrBL-97J4L5AwU12?(DRadB~UbPn|KaS`BgF)$YJIdVi>;0$ZB{(8@8c2Zi5k;66R`yZhCf%w zeE#mAe?OAV?QQ;ExqoW?>-&el3Z6J)miXuA3Fhs$`j-9MtmCg>Y$@XQXv$7GrkOWX z6W?sMOl7$|&q2+|UE=qSXq1m}cpG^fBre~WBXwKu|A+3m&uu3yWEMPTa%$=-K4%44;bSGQv_pLr=Kg$jvAoRg zG$fNT%O(k{`ar^{Wd?2ot5X`zv1&qb?cI>oa$%2<>hwl%92(Sa#g1- zl4JY#env3Rq}$>rw&lP7f8%A4*tza-soaElOA%isIs?zs!w zR3@C`Oo{vYS?=nF#-4rLf*#ULFQ+)%67X0)jVDDHth%deg`3g@GnPx5jaw8a_~}ZX zh?>@N^g?6L9vu;c^0>O+%jX`}8TS(2#*(a)4zHfuY+A@HSsAH;WQOR8 zSzt3nz9#fKDdci231-Sn{^8(uw^au$)#_lS3RP}cqp%nlLR=}n(O;Yt=2m^$Jiokc zJ8L6P+LhQ7L5)|=mzOZTydu=N=Is|yuwQFjW7wDU!=bBchua0C30sc^0u5iw6nWeJ z^|dg;6CJxJh&8UU?E{)L%UYoDk28=q7AU;4xy!*VM(mtuhs8gCg}JNPtGOQC*xKdL zC3Mnb;g!R+nuT}rOdE5g6{l1k&{ypH8zy9KLK}%6@_pz!M{i=PJIi7f2|28j( z|DWNJLaW9(52c(|w*MC>oa3$_9$_@as=-C(x$p^=sQCSUe_sZDy+8diXG)T;lcnke z(|Yp>%sZL;> zTmAQzc*pwnv*tOhJFs%$9kC9JXNnWFi+waofx7w~)`f7T0z>1Bw?g>4ZLhCCOK9v;t literal 0 HcmV?d00001 diff --git a/kool-core/src/desktopTest/resources/render-references/basic/textured-quad.png b/kool-core/src/desktopTest/resources/render-references/basic/textured-quad.png new file mode 100644 index 0000000000000000000000000000000000000000..90e1ed9b82760614001b02ec63027871b11e8235 GIT binary patch literal 3871 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 UxFy!H@Q*L%;p*waSw}zoFJSIG@Bjb+ literal 0 HcmV?d00001 diff --git a/kool-core/src/desktopTest/resources/render-references/basic/unlit-vertex-color-cube.png b/kool-core/src/desktopTest/resources/render-references/basic/unlit-vertex-color-cube.png new file mode 100644 index 0000000000000000000000000000000000000000..a3d8feddb676e7f7f2cd52e38cd25b2e6d626899 GIT binary patch literal 1728 zcmd^A|2LFr6o1~gF}>4a8Wb;)sf?{987Djr zyL~ZCyKSqogV`)o!jv#e31`c+hB3(GRKB!nM##R?ztJ!E-21ur^E~H%&b`l57!?_6 zvCMuM0Kg(_8#5XJh=Kc42obj#8yiyrAm)cLgLWQGc%=y}&U);qoUTCbqIm_(HJ3?i zmos~rH6GL;w-rBnuHW+;DM$RN*!?uyx>v0x_AZUqn~k;_J-r`vJoisI!qGo!yvchz zw;`aF++tH<%oG^PH*YTw;2&EO^(JW^Tn+}O+#tfA1d7UvK@I=7192?>O|euvZ@4Pe z%rB%=YxXQ`5iDI@1U!om8=JCqxBO&R)))9YLrKww)9L3Q#t$0<_qoF==ST}}KpSVw z1(PJNyhh*4m4_!CgjHegc1`@3R=)Wl^W4?EblXk4KR*L9t9{ESec@9Y8rN;!lo%=G5fzhpXS+@oiZ<7hBc#C7dQJNU#jt+eXaF*v^eV& zm)bJog6*4`lKpXnUuh(<&jKPzYgjk=g#cDNV{7giWHTziW8=%E=`uS}DPWyg7%q)l zv)-P^ht)0^l-~HZ>tTDKu~T+(;rT#ZBD%WavC5B_?u>1L(Hc8t#82pieRgPd)7;%B z|4u7N?E7eQk5*uuPBULLjP%vRnX-vvJ#rCvi{Pm{b9?6|rn=*$pboE`OfZh@8YTZV z=veYY?2VY=de165krc2b*2&adl?hWR$5h%Z4sEq2nLAWdG>EC?)GGVEJbOpEB;) zl*ZPv0l{@(xoyC%Y~|(0Ouq9r>2gM>i~Tj8F4-(_B?tMC*ebO2YMiKpE4;G~I%K63 z-|B+9#v6T_)J2aS2sL3~E8)BP4A}vSk^*Vc_PW{=JeYvDs;`I$nP(UDQ}wr*U;rM+ zi8QI1CIno>Z$O3y9C9XbDAjzZdT{Ze>sK+>N_R+Tx8!CPtdbBsIOY#}NmQ zoL2~LoAuH#dfyS7C=NLgG7g1-+#Aq}Ko*Sdv=~y5so0Lh z13w=Ws;@inbnY zkc70WUab{Kf`RW-TH6Rci)R@aNyEO}UqvaAuPSuXQy4B@$%yv&NU?0F&?&}>!J)Sv zy|W1$ZeKwV$ChzBlXinv(RGAs%RPil;(gL-(C9F^G1-$CHGkm}c+H+T9;vWnG><)S zAc#X0u6ZwT5xABNo8--Tl_uAUVe`$(dtWy#j6czP&_3Sx@%?f)+QKSdi_}W|X98g% Kk<6-KcFzCbLzWx> literal 0 HcmV?d00001 diff --git a/kool-core/src/desktopTest/resources/render-references/ui/button-clicked.png b/kool-core/src/desktopTest/resources/render-references/ui/button-clicked.png new file mode 100644 index 0000000000000000000000000000000000000000..a99b93781a7ee5e7e20e3985b90e67691e74597d GIT binary patch literal 7070 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 literal 0 HcmV?d00001 diff --git a/kool-core/src/desktopTest/resources/render-references/ui/button-initial.png b/kool-core/src/desktopTest/resources/render-references/ui/button-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..519b64f1fbdcace2c1974063442de6e385cdcfc5 GIT binary patch literal 6985 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 literal 0 HcmV?d00001