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 000000000..7f1c35ca8 Binary files /dev/null and b/kool-core/src/desktopTest/resources/render-references/basic/animated-cube-30deg.png differ 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 000000000..7a368e22d Binary files /dev/null and b/kool-core/src/desktopTest/resources/render-references/basic/clear-color.png differ diff --git a/kool-core/src/desktopTest/resources/render-references/basic/pbr-spheres.png b/kool-core/src/desktopTest/resources/render-references/basic/pbr-spheres.png new file mode 100644 index 000000000..131436979 Binary files /dev/null and b/kool-core/src/desktopTest/resources/render-references/basic/pbr-spheres.png differ 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 000000000..d4a40f426 Binary files /dev/null and b/kool-core/src/desktopTest/resources/render-references/basic/resized-cube.png differ 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 000000000..90e1ed9b8 Binary files /dev/null and b/kool-core/src/desktopTest/resources/render-references/basic/textured-quad.png differ 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 000000000..a3d8feddb Binary files /dev/null and b/kool-core/src/desktopTest/resources/render-references/basic/unlit-vertex-color-cube.png differ 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 000000000..a99b93781 Binary files /dev/null and b/kool-core/src/desktopTest/resources/render-references/ui/button-clicked.png differ 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 000000000..519b64f1f Binary files /dev/null and b/kool-core/src/desktopTest/resources/render-references/ui/button-initial.png differ