From 8646ca1e35d7ad9cdf358bee44064b5c5b44a6b5 Mon Sep 17 00:00:00 2001 From: rudrabeniwal Date: Wed, 9 Apr 2025 14:55:27 +0530 Subject: [PATCH 1/4] Implement GLFW window system with event handling and Vulkan support --- build.sbt | 5 +- .../computenode/cyfra/window/GLFWSystem.scala | 43 ++++ .../cyfra/window/GLFWWindowSystem.scala | 217 ++++++++++++++++++ .../cyfra/window/WindowHandle.scala | 84 +++++++ .../cyfra/window/WindowSystem.scala | 31 +++ .../cyfra/window/WindowSystemTest.scala | 102 ++++++++ 6 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 src/main/scala/io/computenode/cyfra/window/GLFWSystem.scala create mode 100644 src/main/scala/io/computenode/cyfra/window/GLFWWindowSystem.scala create mode 100644 src/main/scala/io/computenode/cyfra/window/WindowHandle.scala create mode 100644 src/main/scala/io/computenode/cyfra/window/WindowSystem.scala create mode 100644 src/main/scala/io/computenode/cyfra/window/WindowSystemTest.scala diff --git a/build.sbt b/build.sbt index be3ae143..cc433562 100644 --- a/build.sbt +++ b/build.sbt @@ -44,6 +44,8 @@ lazy val root = (project in file(".")) "org.lwjgl" % "lwjgl-vma" % lwjglVersion, "org.lwjgl" % "lwjgl" % lwjglVersion classifier lwjglNatives, "org.lwjgl" % "lwjgl-vma" % lwjglVersion classifier lwjglNatives, + "org.lwjgl" % "lwjgl-glfw" % lwjglVersion, + "org.lwjgl" % "lwjgl-glfw" % lwjglVersion classifier lwjglNatives, "org.joml" % "joml" % jomlVersion, "commons-io" % "commons-io" % "2.16.1", "org.slf4j" % "slf4j-api" % "1.7.30", @@ -52,7 +54,8 @@ lazy val root = (project in file(".")) "org.junit.jupiter" % "junit-jupiter" % "5.6.2" % Test, "org.junit.jupiter" % "junit-jupiter-engine" % "5.7.2" % Test, "com.lihaoyi" %% "sourcecode" % "0.4.3-M5" - ) + ), + mainClass := Some("com.computenode.cyfra.app.Main") ) lazy val vulkanSdk = System.getenv("VULKAN_SDK") diff --git a/src/main/scala/io/computenode/cyfra/window/GLFWSystem.scala b/src/main/scala/io/computenode/cyfra/window/GLFWSystem.scala new file mode 100644 index 00000000..b0f85224 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/window/GLFWSystem.scala @@ -0,0 +1,43 @@ +package io.computenode.cyfra.window + +import org.lwjgl.glfw.{GLFW, GLFWErrorCallback} +import org.lwjgl.system.MemoryUtil.NULL +import org.lwjgl.glfw.GLFWVulkan.glfwVulkanSupported + +import scala.util.{Try, Success, Failure} + +/** + * GLFW window system implementation. + */ +object GLFWSystem { + /** + * Initializes GLFW with appropriate configuration for Vulkan rendering. + * + * @return Success if initialization was successful, Failure otherwise + */ + def initializeGLFW(): Try[Unit] = Try { + // Setup error callback + val errorCallback = GLFWErrorCallback.createPrint(System.err) + GLFW.glfwSetErrorCallback(errorCallback) + + // Initialize GLFW + if (!GLFW.glfwInit()) { + throw new RuntimeException("Failed to initialize GLFW") + } + + // Configure GLFW for Vulkan + GLFW.glfwWindowHint(GLFW.GLFW_CLIENT_API, GLFW.GLFW_NO_API) // No OpenGL context + GLFW.glfwWindowHint(GLFW.GLFW_RESIZABLE, GLFW.GLFW_TRUE) // Window resizable + + // Check Vulkan support + if (!glfwVulkanSupported()) { + throw new RuntimeException("GLFW: Vulkan is not supported") + } + + // Register shutdown hook to terminate GLFW + sys.addShutdownHook { + GLFW.glfwTerminate() + if (errorCallback != null) errorCallback.free() + } + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/window/GLFWWindowSystem.scala b/src/main/scala/io/computenode/cyfra/window/GLFWWindowSystem.scala new file mode 100644 index 00000000..40fc82f4 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/window/GLFWWindowSystem.scala @@ -0,0 +1,217 @@ +package io.computenode.cyfra.window + +import org.lwjgl.glfw.{GLFW, GLFWWindowSizeCallback, GLFWKeyCallback, GLFWMouseButtonCallback, + GLFWCursorPosCallback, GLFWFramebufferSizeCallback, GLFWWindowCloseCallback, + GLFWCharCallback, GLFWScrollCallback} +import org.lwjgl.system.MemoryUtil.NULL +import org.lwjgl.system.MemoryStack +import java.util.concurrent.ConcurrentLinkedQueue +import scala.jdk.CollectionConverters._ +import scala.util.{Try, Success, Failure} + +/** + * GLFW implementation of WindowSystem interface. + */ +class GLFWWindowSystem extends WindowSystem { + // Thread-safe event queue to collect events between polls + private val eventQueue = new ConcurrentLinkedQueue[WindowEvent]() + + // Initialize GLFW first + GLFWSystem.initializeGLFW() match { + case Failure(exception) => throw exception + case Success(_) => // GLFW initialized successfully + } + + /** + * Applies window hints for GLFW window creation. + * This method centralizes all window configuration options. + */ + private def applyWindowHints(): Unit = { + // Core window hints + GLFW.glfwWindowHint(GLFW.GLFW_CLIENT_API, GLFW.GLFW_NO_API) // No OpenGL context, using Vulkan + GLFW.glfwWindowHint(GLFW.GLFW_RESIZABLE, GLFW.GLFW_TRUE) // Window resizable + + // Platform-specific hints + val osName = System.getProperty("os.name").toLowerCase + + if (osName.contains("mac")) { + // macOS specific hints + GLFW.glfwWindowHint(GLFW.GLFW_COCOA_RETINA_FRAMEBUFFER, GLFW.GLFW_FALSE) + GLFW.glfwWindowHint(GLFW.GLFW_COCOA_GRAPHICS_SWITCHING, GLFW.GLFW_TRUE) + } else if (osName.contains("win")) { + // Windows-specific hints + GLFW.glfwWindowHint(GLFW.GLFW_SCALE_TO_MONITOR, GLFW.GLFW_TRUE) + } else if (osName.contains("linux") || osName.contains("unix")) { + // Linux specific hints + GLFW.glfwWindowHint(GLFW.GLFW_FOCUS_ON_SHOW, GLFW.GLFW_TRUE) + } + } + + /** + * Creates a new GLFW window with specified dimensions and title. + * + * @param width The width of the window in pixels + * @param height The height of the window in pixels + * @param title The window title + * @return A handle to the created window + */ + override def createWindow(width: Int, height: Int, title: String): WindowHandle = { + // Apply window hints before creating the window + applyWindowHints() + + // Create the window + val windowPtr = GLFW.glfwCreateWindow(width, height, title, NULL, NULL) + if (windowPtr == NULL) { + throw new RuntimeException("Failed to create GLFW window") + } + + val handle = new GLFWWindowHandle(windowPtr) + + // Register callbacks for this window + setupCallbacks(handle) + + // Position window in the center of the primary monitor + val vidMode = GLFW.glfwGetVideoMode(GLFW.glfwGetPrimaryMonitor()) + GLFW.glfwSetWindowPos( + windowPtr, + (vidMode.width() - width) / 2, + (vidMode.height() - height) / 2 + ) + + // Make the window visible + GLFW.glfwShowWindow(windowPtr) + + handle + } + + /** + * Polls for and returns pending window events. + * + * @return List of window events that occurred since the last poll + */ + override def pollEvents(): List[WindowEvent] = { + // Poll for events + GLFW.glfwPollEvents() + + // Drain the event queue to a list + val events = eventQueue.asScala.toList + eventQueue.clear() + events + } + + /** + * Checks if a window should close. + * + * @param window The window handle to check + * @return true if the window should close, false otherwise + */ + override def shouldWindowClose(window: WindowHandle): Boolean = { + GLFW.glfwWindowShouldClose(window.nativePtr) + } + + /** + * Sets up GLFW callbacks for the given window handle. + * Callbacks will populate the eventQueue with WindowEvents. + */ + private def setupCallbacks(window: WindowHandle): Unit = { + val windowPtr = window.nativePtr + + // Window framebuffer size callback (for handling DPI changes) + GLFW.glfwSetFramebufferSizeCallback(windowPtr, new GLFWFramebufferSizeCallback { + override def invoke(window: Long, width: Int, height: Int): Unit = { + eventQueue.add(WindowEvent.Resize(width, height)) + } + }) + + // Window size callback + GLFW.glfwSetWindowSizeCallback(windowPtr, new GLFWWindowSizeCallback { + override def invoke(window: Long, width: Int, height: Int): Unit = { + eventQueue.add(WindowEvent.Resize(width, height)) + } + }) + + // Key callback + GLFW.glfwSetKeyCallback(windowPtr, new GLFWKeyCallback { + override def invoke(window: Long, key: Int, scancode: Int, action: Int, mods: Int): Unit = { + eventQueue.add(WindowEvent.Key(key, action, mods)) + } + }) + + // Character input callback (for text input) + GLFW.glfwSetCharCallback(windowPtr, new GLFWCharCallback { + override def invoke(window: Long, codepoint: Int): Unit = { + eventQueue.add(WindowEvent.CharInput(codepoint)) + } + }) + + // Mouse button callback - FIX THE BUFFER ALLOCATION + GLFW.glfwSetMouseButtonCallback(windowPtr, new GLFWMouseButtonCallback { + override def invoke(window: Long, button: Int, action: Int, mods: Int): Unit = { + // Create the buffers within the scope of this function + val stack = MemoryStack.stackPush() + try { + val xBuffer = stack.mallocDouble(1) + val yBuffer = stack.mallocDouble(1) + GLFW.glfwGetCursorPos(window, xBuffer, yBuffer) + val x = xBuffer.get(0) + val y = yBuffer.get(0) + + eventQueue.add(WindowEvent.MouseButton(button, action == GLFW.GLFW_PRESS, x, y)) + } finally { + stack.pop() + } + } + }) + + // Cursor position callback + GLFW.glfwSetCursorPosCallback(windowPtr, new GLFWCursorPosCallback { + override def invoke(window: Long, x: Double, y: Double): Unit = { + eventQueue.add(WindowEvent.MouseMove(x, y)) + } + }) + + // Scroll callback + GLFW.glfwSetScrollCallback(windowPtr, new GLFWScrollCallback { + override def invoke(window: Long, xoffset: Double, yoffset: Double): Unit = { + eventQueue.add(WindowEvent.Scroll(xoffset, yoffset)) + } + }) + + // Set close callback + GLFW.glfwSetWindowCloseCallback(windowPtr, new GLFWWindowCloseCallback { + override def invoke(window: Long): Unit = { + eventQueue.add(WindowEvent.Close) + } + }) + } + + /** + * Example of how to handle specific events like resize and close. + * + * @param events List of events to handle + */ + def handleEvents(events: List[WindowEvent], window: WindowHandle): Unit = { + events.foreach { + case WindowEvent.Resize(width, height) => + // Handle resize event, e.g., update viewport + println(s"Window resized to ${width}x${height}") + + case WindowEvent.Close => + // Handle close event, e.g., clean up resources + println("Window close requested") + GLFW.glfwSetWindowShouldClose(window.nativePtr, true) + + case WindowEvent.Key(keyCode, action, mods) => + // Handle key events + val actionName = action match { + case GLFW.GLFW_PRESS => "pressed" + case GLFW.GLFW_RELEASE => "released" + case GLFW.GLFW_REPEAT => "repeated" + case _ => "unknown" + } + println(s"Key $keyCode was $actionName with modifiers $mods") + + case _ => // Ignore other events + } + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/window/WindowHandle.scala b/src/main/scala/io/computenode/cyfra/window/WindowHandle.scala new file mode 100644 index 00000000..e9c4e412 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/window/WindowHandle.scala @@ -0,0 +1,84 @@ +package io.computenode.cyfra.window + +/** + * Platform-agnostic handle to a window instance. + */ +trait WindowHandle { + /** + * Returns the native window pointer. + * For GLFW, this is the GLFWwindow pointer as a long value. + */ + def nativePtr: Long +} + +/** + * Implementation of WindowHandle for GLFW windows. + */ +class GLFWWindowHandle(val nativePtr: Long) extends WindowHandle { + // GLFW-specific window operations could be added here if needed +} + +/** + * Represents window-related events. + */ +sealed trait WindowEvent + +/** + * Common window events. + */ +object WindowEvent { + /** + * Window resize event. + * + * @param width The new width of the window + * @param height The new height of the window + */ + case class Resize(width: Int, height: Int) extends WindowEvent + + /** + * Key event. + * + * @param keyCode The GLFW key code + * @param action The action (press, release, repeat) + * @param mods Modifier keys that were held + */ + case class Key(keyCode: Int, action: Int, mods: Int) extends WindowEvent + + /** + * Character input event (for text input). + * + * @param codepoint Unicode code point of the character + */ + case class CharInput(codepoint: Int) extends WindowEvent + + /** + * Mouse movement event. + * + * @param x The x coordinate + * @param y The y coordinate + */ + case class MouseMove(x: Double, y: Double) extends WindowEvent + + /** + * Mouse button event. + * + * @param button The button number + * @param pressed True if pressed, false if released + * @param x The x coordinate + * @param y The y coordinate + */ + case class MouseButton(button: Int, pressed: Boolean, x: Double, y: Double) extends WindowEvent + + /** + * Scroll wheel event. + * + * @param xOffset Horizontal scroll amount + * @param yOffset Vertical scroll amount + */ + case class Scroll(xOffset: Double, yOffset: Double) extends WindowEvent + + /** + * Window close request event. + */ + case object Close extends WindowEvent +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/window/WindowSystem.scala b/src/main/scala/io/computenode/cyfra/window/WindowSystem.scala new file mode 100644 index 00000000..d7a8dd59 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/window/WindowSystem.scala @@ -0,0 +1,31 @@ +package io.computenode.cyfra.window + +/** + * Platform-agnostic interface for window management operations. + */ +trait WindowSystem { + /** + * Creates a new window with specified dimensions and title. + * + * @param width The width of the window in pixels + * @param height The height of the window in pixels + * @param title The window title + * @return A handle to the created window + */ + def createWindow(width: Int, height: Int, title: String): WindowHandle + + /** + * Polls for and returns pending window events. + * + * @return List of window events that occurred since the last poll + */ + def pollEvents(): List[WindowEvent] + + /** + * Checks if a window should close. + * + * @param window The window handle to check + * @return true if the window should close, false otherwise + */ + def shouldWindowClose(window: WindowHandle): Boolean +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/window/WindowSystemTest.scala b/src/main/scala/io/computenode/cyfra/window/WindowSystemTest.scala new file mode 100644 index 00000000..c12f0863 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/window/WindowSystemTest.scala @@ -0,0 +1,102 @@ +package io.computenode.cyfra.window + +import scala.util.{Try, Success, Failure} +import org.lwjgl.glfw.GLFW +import org.lwjgl.system.MemoryUtil + +/** + * Test program for validating the GLFWWindowSystem implementation. + */ +object WindowSystemTest { + // Window dimensions and title + private val Width = 800 + private val Height = 600 + private val Title = "GLFW Window System Test" + + // Target FPS for the main loop + private val TargetFPS = 60 + private val FrameTime = 1.0 / TargetFPS + + def main(args: Array[String]): Unit = { + println("Starting Window System Test") + + // Create window system in a try block to handle initialization errors + try { + val windowSystem = new GLFWWindowSystem() + var window: WindowHandle = null + + try { + // Create a window + println(s"Creating window (${Width}x${Height}: $Title)") + window = windowSystem.createWindow(Width, Height, Title) + + // Enter the main event loop + mainLoop(windowSystem, window) + } catch { + case ex: Exception => + println(s"Error during window operation: ${ex.getMessage}") + ex.printStackTrace() + } finally { + // Clean up window if it was created + if (window != null && window.nativePtr != MemoryUtil.NULL) { + println("Destroying window") + GLFW.glfwDestroyWindow(window.nativePtr) + } + + // Additional cleanup if needed + println("Terminating GLFW") + GLFW.glfwTerminate() + } + } catch { + case ex: Exception => + println(s"Error initializing window system: ${ex.getMessage}") + ex.printStackTrace() + } + + println("Window System Test completed") + } + + /** + * Main application loop that polls and handles events. + */ + private def mainLoop(windowSystem: GLFWWindowSystem, window: WindowHandle): Unit = { + println("Entering main loop") + + var lastTime = GLFW.glfwGetTime() + var frameCount = 0 + var frameTimer = 0.0 + + // Run until the window should close + while (!windowSystem.shouldWindowClose(window)) { + val currentTime = GLFW.glfwGetTime() + val deltaTime = currentTime - lastTime + lastTime = currentTime + + // Update FPS counter + frameCount += 1 + frameTimer += deltaTime + if (frameTimer >= 1.0) { + println(s"FPS: $frameCount") + frameCount = 0 + frameTimer = 0.0 + } + + // Poll and handle window events + val events = windowSystem.pollEvents() + if (events.nonEmpty) { + println(s"Received ${events.size} events:") + windowSystem.handleEvents(events, window) + } + + // Simulate rendering (just sleep to maintain frame rate) + val frameTimeRemaining = FrameTime - (GLFW.glfwGetTime() - currentTime) + if (frameTimeRemaining > 0) { + Thread.sleep((frameTimeRemaining * 1000).toLong) + } + } + + println("Window closed, exiting main loop") + } +} + +// Test this file using - sbt "runMain io.computenode.cyfra.window.WindowSystemTest" \ No newline at end of file From eb6f460639f9c6b2e74a8bb88fa11e33735052c9 Mon Sep 17 00:00:00 2001 From: rudrabeniwal Date: Wed, 9 Apr 2025 19:42:52 +0530 Subject: [PATCH 2/4] Add Surface Creation and Management in Vulkan --- build.sbt | 5 +- .../cyfra/vulkan/VulkanContext.scala | 88 ++++++++++++++++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index cc433562..a1de91f1 100644 --- a/build.sbt +++ b/build.sbt @@ -53,11 +53,12 @@ lazy val root = (project in file(".")) "org.scalameta" % "munit_3" % "1.0.0" % Test, "org.junit.jupiter" % "junit-jupiter" % "5.6.2" % Test, "org.junit.jupiter" % "junit-jupiter-engine" % "5.7.2" % Test, + "org.scalatest" %% "scalatest" % "3.2.16" % Test, // Added ScalaTest dependency "com.lihaoyi" %% "sourcecode" % "0.4.3-M5" ), + Test / fork := true, mainClass := Some("com.computenode.cyfra.app.Main") ) lazy val vulkanSdk = System.getenv("VULKAN_SDK") -javaOptions += s"-Dorg.lwjgl.vulkan.libname=$vulkanSdk/lib/libvulkan.1.dylib" - +javaOptions += "-Dorg.lwjgl.vulkan.libname=C:\\Windows\\System32\\vulkan-1.dll" diff --git a/src/main/scala/io/computenode/cyfra/vulkan/VulkanContext.scala b/src/main/scala/io/computenode/cyfra/vulkan/VulkanContext.scala index 18c918db..9fcd9e50 100644 --- a/src/main/scala/io/computenode/cyfra/vulkan/VulkanContext.scala +++ b/src/main/scala/io/computenode/cyfra/vulkan/VulkanContext.scala @@ -1,8 +1,14 @@ package io.computenode.cyfra.vulkan import io.computenode.cyfra.vulkan.command.{CommandPool, Queue, StandardCommandPool} -import io.computenode.cyfra.vulkan.core.{DebugCallback, Device, Instance} +import io.computenode.cyfra.vulkan.core.{DebugCallback, Device, Instance, Surface, SurfaceCapabilities} import io.computenode.cyfra.vulkan.memory.{Allocator, DescriptorPool} +import io.computenode.cyfra.vulkan.util.Util.{check, pushStack} +import org.lwjgl.vulkan.KHRSurface.* +import org.lwjgl.vulkan.VK10.* +import org.lwjgl.vulkan.{VkSurfaceCapabilitiesKHR, VkSurfaceFormatKHR} + +import scala.jdk.CollectionConverters.given /** @author * MarconZet Created 13.04.2020 @@ -24,6 +30,86 @@ private[cyfra] class VulkanContext(val enableValidationLayers: Boolean = false) val descriptorPool: DescriptorPool = new DescriptorPool(device) val commandPool: CommandPool = new StandardCommandPool(device, computeQueue) + /** Get surface capabilities for a surface + * + * @param surface The surface to query capabilities for + * @return A SurfaceCapabilities object containing the queried capabilities + */ + def getSurfaceCapabilities(surface: Surface): SurfaceCapabilities = { + new SurfaceCapabilities(device.physicalDevice, surface) + } + + /** Get available surface formats for a surface + * + * @param surface The surface to query formats for + * @return A list of supported surface formats + */ + def getSurfaceFormats(surface: Surface): List[VkSurfaceFormatKHR] = pushStack { stack => + val countPtr = stack.callocInt(1) + check( + vkGetPhysicalDeviceSurfaceFormatsKHR(device.physicalDevice, surface.get, countPtr, null), + "Failed to get surface format count" + ) + + val count = countPtr.get(0) + if (count == 0) { + return List.empty + } + + val surfaceFormats = VkSurfaceFormatKHR.calloc(count, stack) + check( + vkGetPhysicalDeviceSurfaceFormatsKHR(device.physicalDevice, surface.get, countPtr, surfaceFormats), + "Failed to get surface formats" + ) + + surfaceFormats.iterator().asScala.toList + } + + /** Get available presentation modes for a surface + * + * @param surface The surface to query presentation modes for + * @return A list of supported presentation modes + */ + def getPresentModes(surface: Surface): List[Int] = pushStack { stack => + val countPtr = stack.callocInt(1) + check( + vkGetPhysicalDeviceSurfacePresentModesKHR(device.physicalDevice, surface.get, countPtr, null), + "Failed to get presentation mode count" + ) + + val count = countPtr.get(0) + if (count == 0) { + return List.empty + } + + val presentModes = stack.callocInt(count) + check( + vkGetPhysicalDeviceSurfacePresentModesKHR(device.physicalDevice, surface.get, countPtr, presentModes), + "Failed to get presentation modes" + ) + + val result = collection.mutable.ListBuffer[Int]() + for (i <- 0 until count) { + result += presentModes.get(i) + } + result.toList + } + + /** Check if a queue family supports presentation to a surface + * + * @param queueFamilyIndex The queue family index to check + * @param surface The surface to check presentation support for + * @return Whether the queue family supports presentation to the surface + */ + def isQueueFamilyPresentSupported(queueFamilyIndex: Int, surface: Surface): Boolean = pushStack { stack => + val pSupported = stack.callocInt(1) + check( + vkGetPhysicalDeviceSurfaceSupportKHR(device.physicalDevice, queueFamilyIndex, surface.get, pSupported), + "Failed to check queue family presentation support" + ) + pSupported.get(0) == VK_TRUE + } + def destroy(): Unit = { commandPool.destroy() descriptorPool.destroy() From 14632d712dd56f56b2cdefdb94b25aee9d90626f Mon Sep 17 00:00:00 2001 From: rudrabeniwal Date: Wed, 9 Apr 2025 19:43:11 +0530 Subject: [PATCH 3/4] Add Vulkan surface and capabilities management classes with tests --- .../cyfra/vulkan/core/Instance.scala | 3 + .../cyfra/vulkan/core/Surface.scala | 60 ++++++ .../vulkan/core/SurfaceCapabilities.scala | 192 ++++++++++++++++++ .../cyfra/vulkan/core/SurfaceFactory.scala | 73 +++++++ .../cyfra/vulkan/core/SurfaceManager.scala | 186 +++++++++++++++++ .../vulkan/core/SurfaceCapabilitiesTest.scala | 109 ++++++++++ .../vulkan/core/SurfaceFactoryTest.scala | 74 +++++++ .../vulkan/core/SurfaceManagerTest.scala | 114 +++++++++++ .../cyfra/vulkan/core/SurfaceTest.scala | 76 +++++++ 9 files changed, 887 insertions(+) create mode 100644 src/main/scala/io/computenode/cyfra/vulkan/core/Surface.scala create mode 100644 src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceCapabilities.scala create mode 100644 src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceFactory.scala create mode 100644 src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceManager.scala create mode 100644 src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceCapabilitiesTest.scala create mode 100644 src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceFactoryTest.scala create mode 100644 src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceManagerTest.scala create mode 100644 src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceTest.scala diff --git a/src/main/scala/io/computenode/cyfra/vulkan/core/Instance.scala b/src/main/scala/io/computenode/cyfra/vulkan/core/Instance.scala index fff50fe5..f912f2e7 100644 --- a/src/main/scala/io/computenode/cyfra/vulkan/core/Instance.scala +++ b/src/main/scala/io/computenode/cyfra/vulkan/core/Instance.scala @@ -10,6 +10,7 @@ import org.lwjgl.vulkan.EXTDebugReport.VK_EXT_DEBUG_REPORT_EXTENSION_NAME import org.lwjgl.vulkan.KHRPortabilityEnumeration.{VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR, VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME} import org.lwjgl.vulkan.VK10.* import org.lwjgl.vulkan.VK13.* +import org.lwjgl.vulkan.KHRSurface.VK_KHR_SURFACE_EXTENSION_NAME import scala.collection.mutable import scala.jdk.CollectionConverters.given @@ -21,6 +22,7 @@ import scala.util.chaining.* object Instance { val ValidationLayersExtensions: Seq[String] = List(VK_EXT_DEBUG_REPORT_EXTENSION_NAME) val MacOsExtensions: Seq[String] = List(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) + val PlatformExtensions: Seq[String] = Seq(VK_KHR_SURFACE_EXTENSION_NAME, "VK_KHR_win32_surface") lazy val (extensions, layers): (Seq[String], Seq[String]) = pushStack { stack => val ip = stack.ints(1) @@ -94,6 +96,7 @@ private[cyfra] class Instance(enableValidationLayers: Boolean) extends VulkanObj val extensions = mutable.Buffer.from(Instance.MacOsExtensions) if (enableValidationLayers) extensions.addAll(Instance.ValidationLayersExtensions) + extensions.addAll(Instance.PlatformExtensions) val ppEnabledExtensionNames = stack.callocPointer(extensions.size) extensions.foreach(x => ppEnabledExtensionNames.put(stack.ASCII(x))) diff --git a/src/main/scala/io/computenode/cyfra/vulkan/core/Surface.scala b/src/main/scala/io/computenode/cyfra/vulkan/core/Surface.scala new file mode 100644 index 00000000..12ae376b --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/vulkan/core/Surface.scala @@ -0,0 +1,60 @@ +package io.computenode.cyfra.vulkan.core + +import io.computenode.cyfra.vulkan.util.Util.{check, pushStack} +import io.computenode.cyfra.vulkan.util.{VulkanAssertionError, VulkanObjectHandle} +import org.lwjgl.glfw.GLFWVulkan +import org.lwjgl.system.MemoryStack +import org.lwjgl.vulkan.KHRSurface.* +import org.lwjgl.vulkan.VK10.* +import org.lwjgl.glfw.GLFW +import org.lwjgl.vulkan.{VkPhysicalDevice, VkSurfaceCapabilitiesKHR} + +/** Class that encapsulates a Vulkan surface (VkSurfaceKHR) + * + * @author + * Created based on project conventions + */ +private[cyfra] class Surface(instance: Instance, windowHandle: Long) extends VulkanObjectHandle { + + protected val handle: Long = pushStack { stack => + val pSurface = stack.callocLong(1) + check( + GLFWVulkan.glfwCreateWindowSurface(instance.get, windowHandle, null, pSurface), + "Failed to create window surface" + ) + pSurface.get(0) + } + + /** Get surface capabilities for a physical device + * + * @param physicalDevice The physical device to query capabilities for + * @return Surface capabilities structure + */ + def getCapabilities(physicalDevice: VkPhysicalDevice): VkSurfaceCapabilitiesKHR = pushStack { stack => + val capabilities = VkSurfaceCapabilitiesKHR.calloc(stack) + check( + vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physicalDevice, handle, capabilities), + "Failed to get surface capabilities" + ) + capabilities + } + + /** Check if the physical device supports presentation on this surface + * + * @param physicalDevice The physical device to check + * @param queueFamilyIndex The queue family index to check + * @return True if presentation is supported + */ + def supportsPresentationFrom(physicalDevice: VkPhysicalDevice, queueFamilyIndex: Int): Boolean = pushStack { stack => + val pSupported = stack.callocInt(1) + check( + vkGetPhysicalDeviceSurfaceSupportKHR(physicalDevice, queueFamilyIndex, handle, pSupported), + "Failed to check presentation support" + ) + pSupported.get(0) == VK_TRUE + } + + override protected def close(): Unit = { + vkDestroySurfaceKHR(instance.get, handle, null) + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceCapabilities.scala b/src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceCapabilities.scala new file mode 100644 index 00000000..8e53ad10 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceCapabilities.scala @@ -0,0 +1,192 @@ +package io.computenode.cyfra.vulkan.core + +import io.computenode.cyfra.vulkan.util.Util.{check, pushStack} +import org.lwjgl.system.MemoryStack +import org.lwjgl.vulkan.KHRSurface.* +import org.lwjgl.vulkan.VK10.* +import org.lwjgl.vulkan.{VkExtent2D, VkPhysicalDevice, VkSurfaceCapabilitiesKHR, VkSurfaceFormatKHR} + +import scala.jdk.CollectionConverters.given +import collection.mutable.ArrayBuffer + +/** Class that encapsulates Vulkan surface capabilities (VkSurfaceCapabilitiesKHR) + * and provides properties for presentation modes, formats, image count, etc. + * + * @param physicalDevice The physical device to query capabilities for + * @param surface The surface to query capabilities for + */ +private[cyfra] class SurfaceCapabilities(physicalDevice: VkPhysicalDevice, surface: Surface) { + + /** Get the surface capabilities */ + def getCapabilities(): VkSurfaceCapabilitiesKHR = pushStack { stack => + val capabilities = VkSurfaceCapabilitiesKHR.calloc(stack) + check( + vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physicalDevice, surface.get, capabilities), + "Failed to get surface capabilities" + ) + capabilities + } + + /** Get the supported surface formats */ + def getSurfaceFormats(): Seq[VkSurfaceFormatKHR] = pushStack { stack => + val countPtr = stack.callocInt(1) + check( + vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface.get, countPtr, null), + "Failed to get surface format count" + ) + + val count = countPtr.get(0) + if (count == 0) { + return Seq.empty + } + + val surfaceFormats = VkSurfaceFormatKHR.calloc(count, stack) + check( + vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface.get, countPtr, surfaceFormats), + "Failed to get surface formats" + ) + + surfaceFormats.iterator().asScala.toSeq + } + + /** Get the supported presentation modes */ + def getPresentationModes(): Seq[Int] = pushStack { stack => + val countPtr = stack.callocInt(1) + check( + vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface.get, countPtr, null), + "Failed to get presentation mode count" + ) + + val count = countPtr.get(0) + if (count == 0) { + return Seq.empty + } + + val presentModes = stack.callocInt(count) + check( + vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface.get, countPtr, presentModes), + "Failed to get presentation modes" + ) + + val result = ArrayBuffer[Int]() + for (i <- 0 until count) { + result += presentModes.get(i) + } + result.toSeq + } + + /** Get the minimum image count supported */ + def getMinImageCount(): Int = getCapabilities().minImageCount() + + /** Get the maximum image count supported (0 means no limit) */ + def getMaxImageCount(): Int = getCapabilities().maxImageCount() + + /** Get the current surface extent */ + def getCurrentExtent(): (Int, Int) = { + val extent = getCapabilities().currentExtent() + (extent.width(), extent.height()) + } + + /** Get the minimum surface extent */ + def getMinExtent(): (Int, Int) = { + val extent = getCapabilities().minImageExtent() + (extent.width(), extent.height()) + } + + /** Get the maximum surface extent */ + def getMaxExtent(): (Int, Int) = { + val extent = getCapabilities().maxImageExtent() + (extent.width(), extent.height()) + } + + /** Check if a specific format is supported */ + def isFormatSupported(format: Int, colorSpace: Int): Boolean = { + getSurfaceFormats().exists(sf => sf.format() == format && sf.colorSpace() == colorSpace) + } + + /** Check if a specific presentation mode is supported */ + def isPresentModeSupported(presentMode: Int): Boolean = { + getPresentationModes().contains(presentMode) + } + + /** Get the supported transforms */ + def getSupportedTransforms(): Int = getCapabilities().supportedTransforms() + + /** Get the current transform */ + def getCurrentTransform(): Int = getCapabilities().currentTransform() + + /** Check if a specific transform is supported */ + def isTransformSupported(transform: Int): Boolean = { + (getSupportedTransforms() & transform) != 0 + } + + /** Choose an optimal surface format from the available formats + * + * @param preferredFormat The preferred format + * @param preferredColorSpace The preferred color space + * @return The selected format or the first available format if the preferred one isn't available + */ + def chooseSurfaceFormat(preferredFormat: Int = VK_FORMAT_B8G8R8A8_SRGB, + preferredColorSpace: Int = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR): VkSurfaceFormatKHR = { + val formats = getSurfaceFormats() + + // First look for exact match + formats.find(format => + format.format() == preferredFormat && format.colorSpace() == preferredColorSpace + ).getOrElse { + // Then look for format with any color space + formats.find(_.format() == preferredFormat) + .getOrElse(formats.head) // Just return the first format if no match + } + } + + /** Choose an optimal presentation mode from the available modes + * + * @param preferredMode The preferred presentation mode + * @return The selected presentation mode, or VK_PRESENT_MODE_FIFO_KHR if preferred isn't available + */ + def choosePresentMode(preferredMode: Int = VK_PRESENT_MODE_MAILBOX_KHR): Int = { + val modes = getPresentationModes() + + // If the preferred mode is supported, use it + if (modes.contains(preferredMode)) { + preferredMode + } else { + // FIFO is guaranteed to be supported + VK_PRESENT_MODE_FIFO_KHR + } + } + + /** Choose an appropriate swap extent + * + * @param width The window width + * @param height The window height + * @return The selected extent that fits within min/max bounds + */ + def chooseSwapExtent(width: Int, height: Int): VkExtent2D = pushStack { stack => + val capabilities = getCapabilities() + + // If currentExtent is set to max uint32, the window manager allows us to set our own resolution + if (capabilities.currentExtent().width() != Int.MaxValue) { + return capabilities.currentExtent() + } + + val extent = VkExtent2D.calloc(stack) + .width(width) + .height(height) + + // Clamp the extent between min and max + val minExtent = capabilities.minImageExtent() + val maxExtent = capabilities.maxImageExtent() + + extent.width( + Math.max(minExtent.width(), Math.min(maxExtent.width(), extent.width())) + ) + + extent.height( + Math.max(minExtent.height(), Math.min(maxExtent.height(), extent.height())) + ) + + extent + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceFactory.scala b/src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceFactory.scala new file mode 100644 index 00000000..12893d9a --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceFactory.scala @@ -0,0 +1,73 @@ +package io.computenode.cyfra.vulkan.core + +import io.computenode.cyfra.vulkan.VulkanContext +import io.computenode.cyfra.vulkan.util.Util.{check, pushStack} +import io.computenode.cyfra.vulkan.util.{VulkanAssertionError, VulkanObjectHandle} +import org.lwjgl.glfw.{GLFW, GLFWVulkan} +import org.lwjgl.system.{Platform, MemoryStack} +import org.lwjgl.system.MemoryUtil.NULL +import org.lwjgl.vulkan.KHRSurface.VK_KHR_SURFACE_EXTENSION_NAME +import org.lwjgl.vulkan.VK10.* +import org.lwjgl.vulkan.EXTMetalSurface + +/** Factory object to create Vulkan surfaces using GLFW + * + * @author + * Created based on project conventions + */ +private[cyfra] object SurfaceFactory { + // Extension name constants + private val WIN32_EXTENSION_NAME = "VK_KHR_win32_surface" + private val XCB_EXTENSION_NAME = "VK_KHR_xcb_surface" + private val XLIB_EXTENSION_NAME = "VK_KHR_xlib_surface" + private val WAYLAND_EXTENSION_NAME = "VK_KHR_wayland_surface" + private val METAL_EXTENSION_NAME = "VK_EXT_metal_surface" + + /** Creates a Vulkan surface using GLFW's cross-platform method + * + * @param context The Vulkan context containing the instance + * @param windowHandle The handle to the window to create the surface for + * @return A new Surface object + */ + def create(context: VulkanContext, windowHandle: Long): Surface = { + val instance = context.instance + + if (!checkSurfaceExtensionSupport(instance)) { + throw new VulkanAssertionError("Required surface extensions not available", -1) + } + + // Create surface using GLFW - this works on all platforms + new Surface(instance, windowHandle) + } + + /** Checks whether the required surface extensions are available for the current platform + * + * @param instance The Vulkan instance to check against + * @return true if all required extensions are available + */ + def checkSurfaceExtensionSupport(instance: Instance): Boolean = { + val requiredExtensions = getPlatformSpecificExtensions() + requiredExtensions.forall(ext => Instance.extensions.contains(ext)) + } + + /** Gets the platform-specific surface extensions required + * + * @return A sequence of extension names + */ + def getPlatformSpecificExtensions(): Seq[String] = { + val platformExtensions = Platform.get() match { + case Platform.WINDOWS => + Seq(WIN32_EXTENSION_NAME) + case Platform.LINUX => + // On Linux we might need one of these based on window system + Seq(XCB_EXTENSION_NAME, XLIB_EXTENSION_NAME, WAYLAND_EXTENSION_NAME) + case Platform.MACOSX => + Seq(METAL_EXTENSION_NAME) + case _ => + Seq.empty + } + + // Always include the base surface extension + VK_KHR_SURFACE_EXTENSION_NAME +: platformExtensions + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceManager.scala b/src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceManager.scala new file mode 100644 index 00000000..f71f46e0 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/vulkan/core/SurfaceManager.scala @@ -0,0 +1,186 @@ +package io.computenode.cyfra.vulkan.core + +import io.computenode.cyfra.vulkan.VulkanContext +import io.computenode.cyfra.vulkan.util.{VulkanAssertionError, VulkanObject} +import org.lwjgl.glfw.{GLFW, GLFWFramebufferSizeCallback} +import org.lwjgl.vulkan.VK10.* +import org.lwjgl.vulkan.KHRSurface.* + +import scala.collection.mutable +import scala.util.{Failure, Success, Try} + +/** Class that manages multiple Vulkan surfaces + * + * Provides centralized management for all active surfaces, including + * creation, destruction, and event routing. + * + * @param context The Vulkan context to use for surface operations + */ +private[cyfra] class SurfaceManager(val context: VulkanContext) extends VulkanObject { + + // Registry to track active surfaces by window handle + private val activeSurfaces: mutable.Map[Long, Surface] = mutable.Map.empty + + // Track resize callbacks to prevent garbage collection + private val resizeCallbacks: mutable.Map[Long, GLFWFramebufferSizeCallback] = mutable.Map.empty + + /** Create and register a new surface for a window handle + * + * @param windowHandle The GLFW window handle + * @return The created Surface + */ + def createSurface(windowHandle: Long): Surface = { + if (activeSurfaces.contains(windowHandle)) { + throw new VulkanAssertionError(s"Surface already exists for window $windowHandle", -1) + } + + val surface = SurfaceFactory.create(context, windowHandle) + activeSurfaces(windowHandle) = surface + + // Set up resize callback for this window + setupResizeCallback(windowHandle) + + surface + } + + /** Get a surface by window handle + * + * @param windowHandle The GLFW window handle + * @return The Surface if found + */ + def getSurface(windowHandle: Long): Option[Surface] = { + activeSurfaces.get(windowHandle) + } + + /** Remove and destroy a surface + * + * @param windowHandle The GLFW window handle + * @return True if the surface was found and removed + */ + def removeSurface(windowHandle: Long): Boolean = { + activeSurfaces.remove(windowHandle).map { surface => + surface.destroy() + + // Clean up resize callback + resizeCallbacks.remove(windowHandle).foreach { callback => + callback.free() + } + + true + }.getOrElse(false) + } + + /** Check if a surface exists for a window + * + * @param windowHandle The GLFW window handle + * @return True if a surface exists for this window + */ + def hasSurface(windowHandle: Long): Boolean = { + activeSurfaces.contains(windowHandle) + } + + /** Get count of active surfaces + * + * @return Number of active surfaces + */ + def getActiveSurfaceCount: Int = { + activeSurfaces.size + } + + /** Get all active surfaces + * + * @return Collection of active surfaces + */ + def getAllSurfaces: Iterable[Surface] = { + activeSurfaces.values + } + + /** Get all window handles with active surfaces + * + * @return Collection of window handles + */ + def getAllWindowHandles: Iterable[Long] = { + activeSurfaces.keys + } + + /** Setup framebuffer resize callback for a window + * + * @param windowHandle The GLFW window handle + */ + private def setupResizeCallback(windowHandle: Long): Unit = { + val callback = GLFWFramebufferSizeCallback.create { (window, width, height) => + if (width > 0 && height > 0) { + // Handle resize event - application would typically react here + // For example, recreate swapchain if this were a graphics application + onWindowResize(window, width, height) + } + } + + GLFW.glfwSetFramebufferSizeCallback(windowHandle, callback) + resizeCallbacks(windowHandle) = callback + } + + /** Event handler for window resize + * + * @param windowHandle The GLFW window handle + * @param width The new width + * @param height The new height + */ + def onWindowResize(windowHandle: Long, width: Int, height: Int): Unit = { + // This would typically be overridden or provide a way for the application + // to register its own resize handlers + } + + /** Handle window close event + * + * @param windowHandle The GLFW window handle + */ + def onWindowClose(windowHandle: Long): Unit = { + removeSurface(windowHandle) + } + + /** Check if a specific queue family on a physical device supports presentation to any active surface + * + * @param physicalDevice The physical device + * @param queueFamilyIndex The queue family index + * @return True if presentation is supported on any surface + */ + def isQueueFamilySupportedForAny(physicalDevice: Long, queueFamilyIndex: Int): Boolean = { + if (activeSurfaces.isEmpty) { + return false + } + + activeSurfaces.values.exists { surface => + Try(context.isQueueFamilyPresentSupported(queueFamilyIndex, surface)) match { + case Success(isSupported) => isSupported + case Failure(_) => false + } + } + } + + /** Collect surface capabilities for all active surfaces + * + * @return Map of window handles to surface capabilities + */ + def getAllSurfaceCapabilities: Map[Long, SurfaceCapabilities] = { + activeSurfaces.map { case (windowHandle, surface) => + windowHandle -> context.getSurfaceCapabilities(surface) + }.toMap + } + + /** Close and destroy all resources */ + protected def close(): Unit = { + // Clean up all active surfaces + activeSurfaces.foreach { case (windowHandle, surface) => + Try(surface.destroy()) + + // Clean up callbacks + resizeCallbacks.get(windowHandle).foreach { callback => + callback.free() + } + } + + activeSurfaces.clear() + resizeCallbacks.clear() + } +} \ No newline at end of file diff --git a/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceCapabilitiesTest.scala b/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceCapabilitiesTest.scala new file mode 100644 index 00000000..c460e05e --- /dev/null +++ b/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceCapabilitiesTest.scala @@ -0,0 +1,109 @@ +package io.computenode.cyfra.vulkan.core + +import io.computenode.cyfra.vulkan.VulkanContext +import org.lwjgl.glfw.GLFW +import org.lwjgl.vulkan.KHRSurface._ +import org.lwjgl.vulkan.VK10._ +import org.scalatest.BeforeAndAfterEach +import org.scalatest.funsuite.AnyFunSuite + +class SurfaceCapabilitiesTest extends AnyFunSuite with BeforeAndAfterEach { + private var vulkanContext: VulkanContext = _ + private var windowHandle: Long = _ + private var surface: Surface = _ + private var surfaceCapabilities: SurfaceCapabilities = _ + + override def beforeEach(): Unit = { + assert(GLFW.glfwInit(), "Failed to initialize GLFW") + + GLFW.glfwDefaultWindowHints() + GLFW.glfwWindowHint(GLFW.GLFW_CLIENT_API, GLFW.GLFW_NO_API) + GLFW.glfwWindowHint(GLFW.GLFW_VISIBLE, GLFW.GLFW_FALSE) + + windowHandle = GLFW.glfwCreateWindow(800, 600, "Surface Capabilities Test", 0, 0) + assert(windowHandle != 0, "Failed to create GLFW window") + + vulkanContext = new VulkanContext(true) + surface = new Surface(vulkanContext.instance, windowHandle) + surfaceCapabilities = new SurfaceCapabilities(vulkanContext.device.physicalDevice, surface) + } + + override def afterEach(): Unit = { + if (surface != null) surface.destroy() + if (vulkanContext != null) vulkanContext.destroy() + if (windowHandle != 0) GLFW.glfwDestroyWindow(windowHandle) + GLFW.glfwTerminate() + } + + test("Get surface formats") { + val formats = surfaceCapabilities.getSurfaceFormats() + + assert(formats != null, "Surface formats should not be null") + assert(formats.nonEmpty, "Should get at least one format") + + formats.foreach { format => + assert(format != null, "Format should not be null") + assert(format.format() >= 0, "Format value should be valid") + assert(format.colorSpace() >= 0, "Color space value should be valid") + } + } + + test("Get presentation modes") { + val modes = surfaceCapabilities.getPresentationModes() + + assert(modes != null, "Presentation modes should not be null") + assert(modes.nonEmpty, "Should get at least one presentation mode") + assert(modes.contains(VK_PRESENT_MODE_FIFO_KHR), "FIFO presentation mode should be supported") + } + + test("Image count limits") { + val minCount = surfaceCapabilities.getMinImageCount() + val maxCount = surfaceCapabilities.getMaxImageCount() + + assert(minCount > 0, "Minimum image count should be positive") + assert(maxCount == 0 || maxCount >= minCount, "Maximum image count should be 0 (unlimited) or >= minimum") + } + + test("Extent boundaries") { + val minExtent = surfaceCapabilities.getMinExtent() + val maxExtent = surfaceCapabilities.getMaxExtent() + + assert(minExtent._1 > 0 && minExtent._2 > 0, "Minimum extent should be positive") + assert(maxExtent._1 >= minExtent._1 && maxExtent._2 >= minExtent._2, "Maximum extent should be >= minimum") + } + + test("Choose surface format") { + val format = surfaceCapabilities.chooseSurfaceFormat() + + assert(format != null, "Should choose a surface format") + + val specificFormat = surfaceCapabilities.chooseSurfaceFormat( + VK_FORMAT_B8G8R8A8_UNORM, + VK_COLOR_SPACE_SRGB_NONLINEAR_KHR + ) + + assert(specificFormat != null, "Should choose a format with specific preferences") + } + + test("Choose present mode") { + val mode = surfaceCapabilities.choosePresentMode() + + assert(mode >= 0, "Should choose a valid presentation mode") + + val fifoMode = surfaceCapabilities.choosePresentMode(VK_PRESENT_MODE_FIFO_KHR) + assert(fifoMode == VK_PRESENT_MODE_FIFO_KHR, "Should choose FIFO mode when requested") + } + + test("Choose swap extent") { + val extent = surfaceCapabilities.chooseSwapExtent(1024, 768) + + assert(extent != null, "Should choose a valid extent") + assert(extent.width() > 0 && extent.height() > 0, "Chosen extent should have positive dimensions") + + val (minW, minH) = surfaceCapabilities.getMinExtent() + val (maxW, maxH) = surfaceCapabilities.getMaxExtent() + + assert(extent.width() >= minW && extent.width() <= maxW, "Width should be within bounds") + assert(extent.height() >= minH && extent.height() <= maxH, "Height should be within bounds") + } +} \ No newline at end of file diff --git a/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceFactoryTest.scala b/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceFactoryTest.scala new file mode 100644 index 00000000..1aeab8c0 --- /dev/null +++ b/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceFactoryTest.scala @@ -0,0 +1,74 @@ +package io.computenode.cyfra.vulkan.core + +import io.computenode.cyfra.vulkan.VulkanContext +import org.lwjgl.glfw.{GLFW, GLFWVulkan} +import org.lwjgl.system.Platform +import org.lwjgl.vulkan.KHRSurface.VK_KHR_SURFACE_EXTENSION_NAME +import org.lwjgl.vulkan.VK10._ +import org.scalatest.BeforeAndAfterEach +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Try + +class SurfaceFactoryTest extends AnyFunSuite with BeforeAndAfterEach { + private var vulkanContext: VulkanContext = _ + private var windowHandle: Long = _ + + override def beforeEach(): Unit = { + // Initialize GLFW + assert(GLFW.glfwInit(), "Failed to initialize GLFW") + + // Configure GLFW window creation + GLFW.glfwDefaultWindowHints() + GLFW.glfwWindowHint(GLFW.GLFW_CLIENT_API, GLFW.GLFW_NO_API) + GLFW.glfwWindowHint(GLFW.GLFW_VISIBLE, GLFW.GLFW_FALSE) + + // Create window + windowHandle = GLFW.glfwCreateWindow(800, 600, "Surface Factory Test", 0, 0) + assert(windowHandle != 0, "Failed to create GLFW window") + + // Create Vulkan context with validation + vulkanContext = new VulkanContext(true) + } + + override def afterEach(): Unit = { + // Destroy Vulkan context + if (vulkanContext != null) vulkanContext.destroy() + + // Destroy window and terminate GLFW + if (windowHandle != 0) GLFW.glfwDestroyWindow(windowHandle) + GLFW.glfwTerminate() + } + + test("Create surface") { + val surface = Try(SurfaceFactory.create(vulkanContext, windowHandle)) + + assert(surface.isSuccess, "Surface creation should succeed") + assert(surface.get.get != 0L, "Surface handle should not be 0") + + surface.get.destroy() + } + + test("Get platform-specific extensions") { + val extensions = SurfaceFactory.getPlatformSpecificExtensions() + + assert(extensions.nonEmpty, "Should return at least one extension") + assert(extensions.contains(VK_KHR_SURFACE_EXTENSION_NAME), "Should include KHR_SURFACE extension") + + val platformExtensionFound = Platform.get() match { + case Platform.WINDOWS => + extensions.exists(_.contains("win32")) + case Platform.LINUX => + extensions.exists(ext => ext.contains("xcb") || ext.contains("xlib") || ext.contains("wayland")) + case Platform.MACOSX => + extensions.exists(_.contains("metal")) + case _ => true // Skip check for unsupported platforms + } + + assert(platformExtensionFound, "Should include platform-specific extension") + } + + test("Platform-specific surfaces (disabled)") { + info("Platform-specific surface creation tests are disabled") + } +} \ No newline at end of file diff --git a/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceManagerTest.scala b/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceManagerTest.scala new file mode 100644 index 00000000..139881c4 --- /dev/null +++ b/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceManagerTest.scala @@ -0,0 +1,114 @@ +package io.computenode.cyfra.vulkan.core + +import io.computenode.cyfra.vulkan.VulkanContext +import org.lwjgl.glfw.GLFW +import org.scalatest.BeforeAndAfterEach +import org.scalatest.funsuite.AnyFunSuite + +class SurfaceManagerTest extends AnyFunSuite with BeforeAndAfterEach { + private var vulkanContext: VulkanContext = _ + private var surfaceManager: SurfaceManager = _ + private var windowHandles: Array[Long] = _ + + override def beforeEach(): Unit = { + assert(GLFW.glfwInit(), "Failed to initialize GLFW") + + GLFW.glfwDefaultWindowHints() + GLFW.glfwWindowHint(GLFW.GLFW_CLIENT_API, GLFW.GLFW_NO_API) + GLFW.glfwWindowHint(GLFW.GLFW_VISIBLE, GLFW.GLFW_FALSE) + + windowHandles = Array( + GLFW.glfwCreateWindow(800, 600, "Test Window 1", 0, 0), + GLFW.glfwCreateWindow(640, 480, "Test Window 2", 0, 0) + ) + + windowHandles.foreach(handle => assert(handle != 0, "Failed to create GLFW window")) + + vulkanContext = new VulkanContext(true) + surfaceManager = new SurfaceManager(vulkanContext) + } + + override def afterEach(): Unit = { + if (surfaceManager != null) surfaceManager.destroy() + if (vulkanContext != null) vulkanContext.destroy() + if (windowHandles != null) windowHandles.foreach(handle => if (handle != 0) GLFW.glfwDestroyWindow(handle)) + GLFW.glfwTerminate() + } + + test("Create and get surface") { + val surface = surfaceManager.createSurface(windowHandles(0)) + + assert(surface != null, "Surface should be created") + assert(surface.get != 0L, "Surface handle should not be 0") + + val retrievedSurface = surfaceManager.getSurface(windowHandles(0)) + assert(retrievedSurface.isDefined, "Surface should be retrievable") + assert(retrievedSurface.get.get == surface.get, "Retrieved surface should match created surface") + } + + test("Multiple surfaces") { + val surface1 = surfaceManager.createSurface(windowHandles(0)) + val surface2 = surfaceManager.createSurface(windowHandles(1)) + + assert(surface1 != null, "Surface 1 should be created") + assert(surface2 != null, "Surface 2 should be created") + + assert(surfaceManager.getActiveSurfaceCount == 2, "Should track 2 active surfaces") + assert(surfaceManager.hasSurface(windowHandles(0)), "Should have surface for window 1") + assert(surfaceManager.hasSurface(windowHandles(1)), "Should have surface for window 2") + } + + test("Remove surface") { + surfaceManager.createSurface(windowHandles(0)) + val result = surfaceManager.removeSurface(windowHandles(0)) + + assert(result, "Surface removal should succeed") + assert(!surfaceManager.hasSurface(windowHandles(0)), "Surface should no longer exist") + assert(surfaceManager.getActiveSurfaceCount == 0, "Should have 0 active surfaces") + } + + test("Get all surfaces") { + val surface1 = surfaceManager.createSurface(windowHandles(0)) + val surface2 = surfaceManager.createSurface(windowHandles(1)) + + val allSurfaces = surfaceManager.getAllSurfaces + + assert(allSurfaces.size == 2, "Should return 2 surfaces") + assert(allSurfaces.exists(_.get == surface1.get), "Should contain surface 1") + assert(allSurfaces.exists(_.get == surface2.get), "Should contain surface 2") + } + + test("Event handling") { + var resizeEventReceived = false + val testManager = new SurfaceManager(vulkanContext) { + override def onWindowResize(windowHandle: Long, width: Int, height: Int): Unit = { + resizeEventReceived = true + super.onWindowResize(windowHandle, width, height) + } + } + + testManager.createSurface(windowHandles(0)) + testManager.onWindowResize(windowHandles(0), 1024, 768) + + assert(resizeEventReceived, "Resize event should be processed") + testManager.destroy() + } + + test("Window close handling") { + surfaceManager.createSurface(windowHandles(0)) + surfaceManager.onWindowClose(windowHandles(0)) + + assert(!surfaceManager.hasSurface(windowHandles(0)), "Surface should be removed after close") + } + + test("Get all surface capabilities") { + surfaceManager.createSurface(windowHandles(0)) + surfaceManager.createSurface(windowHandles(1)) + + val allCapabilities = surfaceManager.getAllSurfaceCapabilities + + assert(allCapabilities.size == 2, "Should get capabilities for 2 surfaces") + assert(allCapabilities.contains(windowHandles(0)), "Should contain capabilities for window 1") + assert(allCapabilities.contains(windowHandles(1)), "Should contain capabilities for window 2") + } +} \ No newline at end of file diff --git a/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceTest.scala b/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceTest.scala new file mode 100644 index 00000000..21928c8c --- /dev/null +++ b/src/test/scala/io/computenode/cyfra/vulkan/core/SurfaceTest.scala @@ -0,0 +1,76 @@ +package io.computenode.cyfra.vulkan.core + +import io.computenode.cyfra.vulkan.VulkanContext +import org.lwjgl.glfw.{GLFW, GLFWVulkan} +import org.scalatest.BeforeAndAfterEach +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Try + +class SurfaceTest extends AnyFunSuite with BeforeAndAfterEach { + private var vulkanContext: VulkanContext = _ + private var windowHandle: Long = _ + + override def beforeEach(): Unit = { + // Initialize GLFW + assert(GLFW.glfwInit(), "Failed to initialize GLFW") + + // Configure GLFW window creation + GLFW.glfwDefaultWindowHints() + GLFW.glfwWindowHint(GLFW.GLFW_CLIENT_API, GLFW.GLFW_NO_API) + GLFW.glfwWindowHint(GLFW.GLFW_VISIBLE, GLFW.GLFW_FALSE) + + // Create window + windowHandle = GLFW.glfwCreateWindow(800, 600, "Surface Test", 0, 0) + assert(windowHandle != 0, "Failed to create GLFW window") + + // Create Vulkan context with validation + vulkanContext = new VulkanContext(true) + } + + override def afterEach(): Unit = { + // Destroy Vulkan context + if (vulkanContext != null) vulkanContext.destroy() + + // Destroy window and terminate GLFW + if (windowHandle != 0) GLFW.glfwDestroyWindow(windowHandle) + GLFW.glfwTerminate() + } + + test("Surface creation and destruction") { + val surface = Try(new Surface(vulkanContext.instance, windowHandle)) + + assert(surface.isSuccess, "Surface creation should succeed") + assert(surface.get.get != 0L, "Surface handle should not be 0") + + surface.get.destroy() + } + + test("Get surface capabilities") { + val surface = new Surface(vulkanContext.instance, windowHandle) + + try { + val capabilities = surface.getCapabilities(vulkanContext.device.physicalDevice) + + assert(capabilities != null, "Surface capabilities should not be null") + assert(capabilities.minImageCount() > 0, "Minimum image count should be positive") + } finally { + surface.destroy() + } + } + + test("Supports presentation from compute queue") { + val surface = new Surface(vulkanContext.instance, windowHandle) + + try { + val isSupported = surface.supportsPresentationFrom( + vulkanContext.device.physicalDevice, + vulkanContext.device.computeQueueFamily + ) + + info(s"Presentation support for compute queue: $isSupported") + } finally { + surface.destroy() + } + } +} \ No newline at end of file From a80334b49ae8504dc270dd797fe3fbc185d7a319 Mon Sep 17 00:00:00 2001 From: rudrabeniwal Date: Thu, 10 Apr 2025 16:31:58 +0530 Subject: [PATCH 4/4] Implement SwapChainManager for Vulkan swapchain management and lifecycle --- .../cyfra/vulkan/core/SwapChainManager.scala | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 src/main/scala/io/computenode/cyfra/vulkan/core/SwapChainManager.scala diff --git a/src/main/scala/io/computenode/cyfra/vulkan/core/SwapChainManager.scala b/src/main/scala/io/computenode/cyfra/vulkan/core/SwapChainManager.scala new file mode 100644 index 00000000..b01d2ca3 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/vulkan/core/SwapChainManager.scala @@ -0,0 +1,365 @@ +package io.computenode.cyfra.vulkan.core + +import io.computenode.cyfra.vulkan.VulkanContext +import io.computenode.cyfra.vulkan.util.Util.{check, pushStack} +import io.computenode.cyfra.vulkan.util.{VulkanAssertionError, VulkanObjectHandle} +import org.lwjgl.system.MemoryStack +import org.lwjgl.vulkan.KHRSurface.* +import org.lwjgl.vulkan.KHRSwapchain.* +import org.lwjgl.vulkan.VK10.* +import org.lwjgl.vulkan.{VkExtent2D, VkSwapchainCreateInfoKHR, VkImageViewCreateInfo, VkSurfaceFormatKHR} + +import scala.jdk.CollectionConverters.given +import scala.collection.mutable.ArrayBuffer + +/** + * Manages Vulkan swapchain creation and lifecycle + * + * @param context The Vulkan context providing access to device and queues + * @param surface The surface to create swapchain for + */ +private[cyfra] class SwapChainManager(context: VulkanContext, surface: Surface) extends VulkanObjectHandle { + private val device = context.device + private val physicalDevice = device.physicalDevice + + // Maximum number of frames being processed concurrently + private val MAX_FRAMES_IN_FLIGHT = 2 + + private var swapChainExtent: VkExtent2D = _ + private var swapChainImageFormat: Int = _ + private var swapChainColorSpace: Int = _ + private var swapChainPresentMode: Int = _ + private var swapChainImageCount: Int = _ + private var swapChainImages: Array[Long] = _ + private var swapChainImageViews: Array[Long] = _ + + protected val handle: Long = VK_NULL_HANDLE + + // Add a separate mutable field to track the actual swap chain handle + private var swapChainHandle: Long = VK_NULL_HANDLE + + // Override get to return the actual handle + override def get: Long = { + if (!alive) + throw new IllegalStateException() + + // Return the current swap chain handle instead of the immutable 'handle' + swapChainHandle + } + + /** + * Choose an optimal surface format for the swap chain + * + * @param preferredFormat The preferred format, defaults to VK_FORMAT_B8G8R8A8_SRGB + * @param preferredColorSpace The preferred color space, defaults to VK_COLOR_SPACE_SRGB_NONLINEAR_KHR + * @return The selected surface format + */ + def chooseSwapSurfaceFormat( + preferredFormat: Int = VK_FORMAT_B8G8R8A8_SRGB, + preferredColorSpace: Int = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR + ): VkSurfaceFormatKHR = { + // Query available formats using VulkanContext + val availableFormats = context.getSurfaceFormats(surface) + + if (availableFormats.isEmpty) { + throw new VulkanAssertionError("No surface formats available", -1) + } + + // First look for exact match of format and color space + availableFormats.find(format => + format.format() == preferredFormat && format.colorSpace() == preferredColorSpace + ).getOrElse { + // Then look for format with any color space + availableFormats.find(_.format() == preferredFormat) + .getOrElse(availableFormats.head) // Just return the first format if no match + } + } + + /** + * Choose an optimal presentation mode for the swap chain + * + * @param preferredMode The preferred presentation mode, defaults to VK_PRESENT_MODE_MAILBOX_KHR + * @return The selected presentation mode, or VK_PRESENT_MODE_FIFO_KHR if preferred isn't available + */ + def chooseSwapPresentMode(preferredMode: Int = VK_PRESENT_MODE_MAILBOX_KHR): Int = { + // Query available presentation modes using VulkanContext + val availableModes = context.getPresentModes(surface) + + // If the preferred mode is supported, use it + if (availableModes.contains(preferredMode)) { + preferredMode + } else { + // FIFO is guaranteed to be supported by the Vulkan spec + VK_PRESENT_MODE_FIFO_KHR + } + } + + /** + * Choose an optimal swap extent based on the window dimensions + * and surface capabilities. + * + * @param width The window width + * @param height The window height + * @return The selected extent that fits within min/max bounds + */ + def chooseSwapExtent(width: Int, height: Int): VkExtent2D = pushStack { stack => + val capabilities = context.getSurfaceCapabilities(surface) + + // Get current extent from capabilities + val currentExtent = capabilities.getCurrentExtent() + + // If currentExtent is set to max uint32 (0xFFFFFFFF), the window manager + // allows us to set our own resolution + if (currentExtent._1 != Int.MaxValue && currentExtent._2 != Int.MaxValue) { + val extent = VkExtent2D.calloc(stack) + .width(currentExtent._1) + .height(currentExtent._2) + return extent + } + + val extent = VkExtent2D.calloc(stack) + .width(width) + .height(height) + + // Clamp the extent between min and max + val minExtent = capabilities.getMinExtent() + val maxExtent = capabilities.getMaxExtent() + + extent.width( + Math.max(minExtent._1, Math.min(maxExtent._1, extent.width())) + ) + + extent.height( + Math.max(minExtent._2, Math.min(maxExtent._2, extent.height())) + ) + + extent + } + + /** + * Determine the optimal number of images in the swap chain + * + * @param capabilities The surface capabilities + * @return The number of images to use + */ + def determineImageCount(capabilities: SurfaceCapabilities): Int = { + // Start with minimum + 1 for triple buffering + var imageCount = capabilities.getMinImageCount() + 1 + + // If max count is 0, there is no limit + // Otherwise ensure we don't exceed the maximum + val maxImageCount = capabilities.getMaxImageCount() + if (maxImageCount > 0) { + imageCount = Math.min(imageCount, maxImageCount) + } + + // Make sure we have at least enough images to handle MAX_FRAMES_IN_FLIGHT + imageCount = Math.max(imageCount, MAX_FRAMES_IN_FLIGHT) + + // Double-check against maxImageCount again if we adjusted for MAX_FRAMES_IN_FLIGHT + if (maxImageCount > 0) { + imageCount = Math.min(imageCount, maxImageCount) + } + + imageCount + } + + /** + * Initialize the swap chain with specified dimensions + * + * @param width The current window width + * @param height The current window height + * @return True if initialization was successful + */ + def initialize(width: Int, height: Int): Boolean = pushStack { stack => + // Clean up previous swap chain if it exists + cleanup() + + // Get surface capabilities + val capabilities = context.getSurfaceCapabilities(surface) + + // Select swap chain settings using our methods + val surfaceFormat = chooseSwapSurfaceFormat() + val presentMode = chooseSwapPresentMode() + val extent = chooseSwapExtent(width, height) + + // Store selected format, extent, and present mode + swapChainExtent = extent + swapChainImageFormat = surfaceFormat.format() + swapChainColorSpace = surfaceFormat.colorSpace() + swapChainPresentMode = presentMode + + // Determine optimal image count based on capabilities and MAX_FRAMES_IN_FLIGHT + val imageCount = determineImageCount(capabilities) + swapChainImageCount = imageCount + + // Check if the compute queue supports presentation + val queueFamilyIndex = device.computeQueueFamily + val supportsPresentation = context.isQueueFamilyPresentSupported(queueFamilyIndex, surface) + + // Use exclusive mode since we're only using one queue + val queueFamilyIndices = Array(queueFamilyIndex) + + // Create the swap chain + val createInfo = VkSwapchainCreateInfoKHR.calloc(stack) + .sType$Default() + .surface(surface.get) + .minImageCount(imageCount) + .imageFormat(surfaceFormat.format()) + .imageColorSpace(surfaceFormat.colorSpace()) + .imageExtent(extent) + .imageArrayLayers(1) // Always 1 unless using stereoscopic 3D + .imageUsage(VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT) + .preTransform(capabilities.getCurrentTransform()) // Use current transform + .compositeAlpha(VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR) + .presentMode(presentMode) + .clipped(true) // Ignore obscured pixels + .oldSwapchain(VK_NULL_HANDLE) + + // Set image sharing mode based on queue usage + // Exclusive mode offers better performance + createInfo.imageSharingMode(VK_SHARING_MODE_EXCLUSIVE) + .queueFamilyIndexCount(0) + .pQueueFamilyIndices(null) + + // Create the swap chain + val pSwapChain = stack.callocLong(1) + val result = vkCreateSwapchainKHR(device.get, createInfo, null, pSwapChain) + if (result != VK_SUCCESS) { + throw new VulkanAssertionError("Failed to create swap chain", result) + } + swapChainHandle = pSwapChain.get(0) // Store in the mutable field + + // Get the swap chain images + val pImageCount = stack.callocInt(1) + vkGetSwapchainImagesKHR(device.get, swapChainHandle, pImageCount, null) + val actualImageCount = pImageCount.get(0) + + val pSwapChainImages = stack.callocLong(actualImageCount) + vkGetSwapchainImagesKHR(device.get, swapChainHandle, pImageCount, pSwapChainImages) + + swapChainImages = new Array[Long](actualImageCount) + for (i <- 0 until actualImageCount) { + swapChainImages(i) = pSwapChainImages.get(i) + } + + // Create image views for the swap chain images + createImageViews() + + true + } + + /** + * Create image views for the swap chain images + */ + private def createImageViews(): Unit = pushStack { stack => + if (swapChainImages == null || swapChainImages.isEmpty) { + throw new VulkanAssertionError("Cannot create image views: swap chain images not initialized", -1) + } + + // Clean up previous image views if they exist + if (swapChainImageViews != null) { + swapChainImageViews.foreach(imageView => + if (imageView != VK_NULL_HANDLE) { + vkDestroyImageView(device.get, imageView, null) + } + ) + } + + swapChainImageViews = new Array[Long](swapChainImages.length) + + for (i <- swapChainImages.indices) { + // Configure the image view creation info + val createInfo = VkImageViewCreateInfo.calloc(stack) + .sType$Default() + .image(swapChainImages(i)) + .viewType(VK_IMAGE_VIEW_TYPE_2D) // 2D texture images + .format(swapChainImageFormat) // Same format as the swap chain images + + // Component mapping - use identity swizzle (no remapping) + createInfo.components { components => + components + .r(VK_COMPONENT_SWIZZLE_IDENTITY) + .g(VK_COMPONENT_SWIZZLE_IDENTITY) + .b(VK_COMPONENT_SWIZZLE_IDENTITY) + .a(VK_COMPONENT_SWIZZLE_IDENTITY) + } + + // Define subresource range - for color images without mipmapping or multiple layers + createInfo.subresourceRange { range => + range + .aspectMask(VK_IMAGE_ASPECT_COLOR_BIT) // Color aspect only + .baseMipLevel(0) // Start at first mip level + .levelCount(1) // Only one mip level + .baseArrayLayer(0) // Start at first array layer + .layerCount(1) // Only one array layer + } + + // Create the image view + val pImageView = stack.callocLong(1) + check( + vkCreateImageView(device.get, createInfo, null, pImageView), + s"Failed to create image view for swap chain image $i" + ) + swapChainImageViews(i) = pImageView.get(0) + } + } + + /** + * Get the swap chain extent + * + * @return The current swap chain extent + */ + def getExtent: VkExtent2D = swapChainExtent + + /** + * Get the swap chain image format + * + * @return The current image format + */ + def getImageFormat: Int = swapChainImageFormat + + /** + * Get the swap chain images + * + * @return Array of image handles (VkImage) + */ + def getImages: Array[Long] = swapChainImages + + /** + * Get the swap chain image views + * + * @return Array of image view handles + */ + def getImageViews: Array[Long] = swapChainImageViews + + /** + * Clean up any existing swap chain resources + */ + private def cleanup(): Unit = { + if (swapChainImageViews != null) { + swapChainImageViews.foreach(imageView => + if (imageView != VK_NULL_HANDLE) { + vkDestroyImageView(device.get, imageView, null) + } + ) + swapChainImageViews = null + } + + if (swapChainHandle != VK_NULL_HANDLE) { + vkDestroySwapchainKHR(device.get, swapChainHandle, null) + swapChainHandle = VK_NULL_HANDLE + } + + // These don't need explicit destruction as they're just wrapper objects + swapChainImages = null + swapChainExtent = null + } + + /** + * Close and clean up all resources + */ + override protected def close(): Unit = { + cleanup() + } +} \ No newline at end of file