Skip to content
Merged
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ add_executable(LoomTests
tests/gpu/ResourcePoolTest.cpp
tests/core/PushPullTest.cpp
tests/gpu/ComputeDispatchTest.cpp
tests/gpu/WindowResizeTest.cpp
)

target_link_libraries(LoomTests PRIVATE
Expand Down
79 changes: 79 additions & 0 deletions TEMP_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,82 @@ During the implementation of cycle detection and topological sorting, several `E
- **Dependency Isolation:** By explicitly providing `SPIRV-Tools` in the workflow, we resolve the "ENABLE_OPT" build error without having to compromise on compiler features.
- **Headless Compatibility:** Making the shader compiler optional ensures that the "Loom Core" (the C++ headless model) can still be developed and verified on machines that do not have a full Vulkan SDK installed.
- **Signal vs. Noise:** Skipping shader-dependent tests instead of failing them provides a clear signal that the environment is restricted, rather than the code being broken.



## Phase 5.5: ImGui Dockspace & Viewport Layout
**Objective:** Implement a professional, persistent workspace layout using ImGui Docking to separate the node graph from the render output.

### Implementation Details
- **Fullscreen Dockspace:**
- Enabled in the ImGui context.
- Utilized to establish a root dock node that covers the entire application window.
- **Conditional DockBuilder API:**
- **Persistence Guard:** Implemented a check using . This ensures that the programmatic layout is only generated on the first launch (or if `imgui.ini` is deleted), allowing user customizations to persist across sessions.
- **Layout Topology:** Programmatically split the dockspace into two regions using with a 0.3f (30%) ratio for the bottom section.
- **Viewport Size Tracking:**
- Used inside the "Viewport" panel to track its actual pixel dimensions in real-time.
- **Vulkan Zero-Size Guard:** Implemented a check. This prevents the downstream Vulkan pipeline from attempting to create 0x0 framebuffers when the panel is collapsed or minimized, which would trigger undefined driver behavior.
- **Dynamic Extent Integration:** Updated to feed the tracked directly into the , ensuring the compute graph always renders at the exact resolution of the UI panel.

### Key Decisions
- **Stable Window Naming:** Standardized on hardcoded strings (`"Viewport"`, `"Node Editor"`) for panel titles. Renaming these would break the link to the saved layout in `imgui.ini`.
- **Internal API Usage:** Included `imgui_internal.h` to access the symbols, which are required for programmatic layout setup but are not part of the standard ImGui public API.
- **Immediate-Mode Resizing:** Chose to update the viewport extent on every frame rather than via a callback. This provides instantaneous visual feedback during panel resizing without the complexity of an event-driven system.

### Phase 6 Engineering Post-Mortem: Docking and Layout Initialization

#### 1. The "Vanishing Windows" Bug (Initialization Order)
* **The Bug:** Initially, was called *after* the layout initialization logic.
* **The Result:** Because implicitly creates the dock node if it doesn't exist, the check was failing to trigger on the first frame, leaving the "Viewport" and "Node Editor" windows floating and undocked.
* **The Fix:** Refactored to retrieve the ID via first, then perform the layout build, and finally call to host the dockspace.

#### 2. The Persistence Conflict
* **The Problem:** Using a simple to trigger layout setup would overwrite the user's `imgui.ini` every time the application restarted.
* **The Decision:** Shifted to the check. This allows ImGui to remain the "source of truth" for the layout after the initial bootstrap, respecting the user's workspace preferences.

#### 3. Redundant Window Definitions
* **The Problem:** Both and were attempting to define the "Node Editor" window, leading to duplicated window logic.
* **The Fix:** Centralized the window definition. now establishes the dock node, and populates it by using the matching window name.

---

## Phase 5.6: Frame Lifecycle Split & Resize Robustness
**Objective:** Resolve the ImGui assertion crash during window resizing and establish a professional, industry-standard render loop.

### The Crash: Anatomy of an Unbalanced Frame
- **The Symptom:** `Assertion failed: ... "Forgot to call Render() or EndFrame() at the end of the previous frame?"`.
- **The Root Cause:** The monolithic `VulkanContext::drawFrame` performed swapchain acquisition and resize checks *after* the UI logic had already called `imgui.beginFrame()`. When a resize was detected, `drawFrame` returned early to recreate the swapchain, skipping `imgui.endFrame()` (which calls `ImGui::Render()`). This left the ImGui state machine in an "active frame" state, causing a crash when the next iteration attempted to start a new frame.

### Implementation: The Split-Lifecycle Pattern
- **Refactored `VulkanContext`:** Split `drawFrame` into `beginFrame()` and `endFrame(cmd, imgui)`.
- **`beginFrame()`:** Handles fence synchronization, minimization guards (waiting for events if size is 0), and swapchain acquisition. It returns the active `VkCommandBuffer` if successful, or `VK_NULL_HANDLE` if the frame should be skipped.
- **`endFrame()`:** Handles image layout transitions, dynamic rendering, ImGui recording, command submission, and presentation.
- **Main Loop Restructuring:** The main loop in `main.cpp` now uses a conditional block:
```cpp
if (VkCommandBuffer cmd = vulkan.beginFrame()) {
imgui.beginFrame();
imgui.drawDockspace();
nodeEditor.draw("Node Editor");

// Evaluate Graph logic here...

vulkan.endFrame(cmd, imgui);
}
```
This ensures that ImGui is only invoked if a valid GPU frame is guaranteed, keeping the CPU/UI and GPU/Render lifecycles perfectly synchronized.

### Key Decisions
- **Industry-Standard vs. Band-aid:** Rejected the simple fix of calling `ImGui::EndFrame()` inside the old `drawFrame`. While functional, it would still waste CPU cycles building UI data for a frame that would never be shown. The split-lifecycle approach is the gold standard for high-performance Vulkan engines.
- **Minimization Guard:** Explicitly handled the "zero-extent" case (minimizing the window). The engine now calls `glfwWaitEvents()` and skips rendering until the window is restored, preventing swapchain recreation loops.
- **Compute Readiness:** By exposing the `VkCommandBuffer` to the main loop, we've laid the architectural foundation for Phase 6, where compute dispatches will be recorded into the same buffer as the UI draw calls.

### Phase 5.6 Engineering Post-Mortem: Synchronization and State
#### 1. The "Suboptimal" Reentry
* **The Problem:** `vkAcquireNextImageKHR` often returns `VK_SUBOPTIMAL_KHR` on macOS during resizing. Treating this as a "success" allowed the frame to proceed, but if the surface was already incompatible, the subsequent `vkQueuePresentKHR` would fail.
* **The Fix:** Updated the present logic to catch both `VK_ERROR_OUT_OF_DATE_KHR` and `VK_SUBOPTIMAL_KHR`, triggering a swapchain recreation for the *next* frame to ensure continuous stability.

#### 2. Fence Reset Timing
* **The Bug:** Resetting the in-flight fence *before* acquiring the next image.
* **The Risk:** If `vkAcquireNextImageKHR` fails or returns early, the fence remains unsignaled, but the CPU has already "forgotten" it waited, potentially leading to a deadlock on the next frame.
* **The Fix:** Strictly moved `vkResetFences` to occur only *after* a successful image acquisition and before command buffer recording begins.
5 changes: 4 additions & 1 deletion include/gpu/VulkanContext.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ class VulkanContext {

void waitIdle() const; // Called from main() before any destructor runs to ensure the GPU has
// finished all in-flight work.
void drawFrame(loom::ui::ImGuiRenderer& imgui);

VkCommandBuffer beginFrame();
void endFrame(VkCommandBuffer cmd, loom::ui::ImGuiRenderer& imgui);

VkCommandBuffer beginSingleTimeCommands();
void endSingleTimeCommands(VkCommandBuffer commandBuffer);
Expand Down Expand Up @@ -131,6 +133,7 @@ class VulkanContext {

// Cycles 0..MAX_FRAMES_IN_FLIGHT-1 each frame.
uint32_t m_currentFrame = 0;
uint32_t m_currentImageIndex = 0;

#ifndef NDEBUG
const bool m_enableValidationLayers = true;
Expand Down
10 changes: 10 additions & 0 deletions include/ui/ImGuiRenderer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

#include <cstdint>

#include "imgui.h"

namespace loom::ui {

struct ImGuiRendererCreateInfo {
Expand Down Expand Up @@ -36,10 +38,18 @@ class ImGuiRenderer {
void endFrame(VkCommandBuffer cmd);
void shutdown();

// Establishes a fullscreen dockspace and generates the default layout
// if no persistent state exists in imgui.ini.
void drawDockspace();

ImVec2 getViewportSize() const { return m_viewportSize; }

private:
// Guards against double-shutdown if the destructor and an explicit shutdown() call overlap
bool m_initialized = false;
VkFormat m_colorFormat = VK_FORMAT_UNDEFINED;

ImVec2 m_viewportSize = {0, 0};
};

} // namespace loom::ui
78 changes: 35 additions & 43 deletions src/gpu/VulkanContext.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -806,15 +806,24 @@ void VulkanContext::transitionImageLayout(VkCommandBuffer cmd, VkImage image,
vkCmdPipelineBarrier2(cmd, &depInfo);
}

void VulkanContext::drawFrame(loom::ui::ImGuiRenderer& imgui) {
VkCommandBuffer VulkanContext::beginFrame() {
// Minimization Guard: Check the GLFW window size.
// If width or height is 0, call glfwWaitEvents() and return VK_NULL_HANDLE.
int width = 0, height = 0;
glfwGetFramebufferSize(m_window, &width, &height);
if (width == 0 || height == 0) {
glfwWaitEvents();
return VK_NULL_HANDLE;
}

// Retrieve Window wrapper class from the GLFW window
auto loomWindow = reinterpret_cast<loom::platform::Window*>(glfwGetWindowUserPointer(m_window));

// Check if the window was resized
if (loomWindow->wasResized()) {
recreateSwapchain();
loomWindow->resetResizedFlag();
return; // Skip this frame and try again next loop
return VK_NULL_HANDLE; // Skip this frame and try again next loop
}

// Step A — Wait for previous frame's fence:
Expand All @@ -823,32 +832,25 @@ void VulkanContext::drawFrame(loom::ui::ImGuiRenderer& imgui) {
vkWaitForFences(m_device, 1, &m_inFlightFences[m_currentFrame], VK_TRUE, UINT64_MAX);

// Step B — Acquire next swapchain image:
uint32_t imageIndex;
VkResult result = vkAcquireNextImageKHR(m_device, m_swapchain, UINT64_MAX,
m_imageAvailableSemaphores[m_currentFrame],
VK_NULL_HANDLE, &imageIndex);
VK_NULL_HANDLE, &m_currentImageIndex);

if (result == VK_ERROR_OUT_OF_DATE_KHR) {
// Swapchain is no longer compatible with the surface — typically caused by a window resize.
// Recreate and skip this frame.
recreateSwapchain();
return;
return VK_NULL_HANDLE;
} else if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) {
throw std::runtime_error("failed to acquire swapchain image!");
}

// Check if a previous frame is using this image (i.e. there is its fence to wait on)
if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) {
vkWaitForFences(m_device, 1, &m_imagesInFlight[imageIndex], VK_TRUE, UINT64_MAX);
if (m_imagesInFlight[m_currentImageIndex] != VK_NULL_HANDLE) {
vkWaitForFences(m_device, 1, &m_imagesInFlight[m_currentImageIndex], VK_TRUE, UINT64_MAX);
}
// Mark the image as now being in use by this frame
m_imagesInFlight[imageIndex] = m_inFlightFences[m_currentFrame];

if (result == VK_ERROR_OUT_OF_DATE_KHR) {
recreateSwapchain();
loomWindow->resetResizedFlag(); // Clear it here too, just in case
return;
}
m_imagesInFlight[m_currentImageIndex] = m_inFlightFences[m_currentFrame];

// Step C — Reset fence AFTER successful acquire:
// Reset only after confirming we will submit
Expand All @@ -867,24 +869,26 @@ void VulkanContext::drawFrame(loom::ui::ImGuiRenderer& imgui) {
throw std::runtime_error("failed to begin command buffer!");
}

return m_commandBuffers[m_currentFrame];
}

void VulkanContext::endFrame(VkCommandBuffer cmd, loom::ui::ImGuiRenderer& imgui) {
// Step F — Transition image to COLOR_ATTACHMENT_OPTIMAL:
// The swapchain image starts in an undefined
// state each frame. Transition it to the layout required
// for color writes before vkCmdBeginRendering.
transitionImageLayout(m_commandBuffers[m_currentFrame], m_swapchainImages[imageIndex],
VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL);
transitionImageLayout(cmd, m_swapchainImages[m_currentImageIndex], VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL);

// Step G — Begin dynamic rendering:
VkRenderingAttachmentInfo colorAttachment{};
colorAttachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
colorAttachment.imageView = m_swapchainImageViews[imageIndex];
colorAttachment.imageView = m_swapchainImageViews[m_currentImageIndex];
colorAttachment.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
colorAttachment.clearValue.color = {{0.1f, 0.1f, 0.1f, 1.0f}};
// Dark grey clear color.
// Replace with black or a compositor-appropriate default
// once the node editor UI is established.

VkRenderingInfo renderingInfo{};
renderingInfo.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
Expand All @@ -894,54 +898,45 @@ void VulkanContext::drawFrame(loom::ui::ImGuiRenderer& imgui) {
renderingInfo.colorAttachmentCount = 1;
renderingInfo.pColorAttachments = &colorAttachment;

vkCmdBeginRendering(m_commandBuffers[m_currentFrame], &renderingInfo);
vkCmdBeginRendering(cmd, &renderingInfo);

// Step H — Record ImGui draw calls:
// endFrame calls ImGui::Render() then
// ImGui_ImplVulkan_RenderDrawData() internally.
// Translates all ImGui geometry into Vulkan draw calls
// recorded into the active command buffer.
// Must be called inside an active vkCmdBeginRendering block.
imgui.endFrame(m_commandBuffers[m_currentFrame]);
imgui.endFrame(cmd);

// Step I — End dynamic rendering:
vkCmdEndRendering(m_commandBuffers[m_currentFrame]);
vkCmdEndRendering(cmd);

// Step J — Transition image to PRESENT_SRC_KHR:
// Transition the image to the layout required
// for presentation. The swapchain will reject images that
// are not in PRESENT_SRC_KHR when vkQueuePresentKHR is called.
transitionImageLayout(m_commandBuffers[m_currentFrame], m_swapchainImages[imageIndex],
transitionImageLayout(cmd, m_swapchainImages[m_currentImageIndex],
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR);

// Step K — End command buffer:
if (vkEndCommandBuffer(m_commandBuffers[m_currentFrame]) != VK_SUCCESS) {
if (vkEndCommandBuffer(cmd) != VK_SUCCESS) {
throw std::runtime_error("failed to record command buffer!");
}

// Step L — Submit:
VkSemaphore waitSemaphores[] = {m_imageAvailableSemaphores[m_currentFrame]};
VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT};
VkSemaphore signalSemaphores[] = {m_renderFinishedSemaphores[imageIndex]};
VkSemaphore signalSemaphores[] = {m_renderFinishedSemaphores[m_currentImageIndex]};

VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = waitSemaphores;
submitInfo.pWaitDstStageMask = waitStages;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &m_commandBuffers[m_currentFrame];
submitInfo.pCommandBuffers = &cmd;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores;

if (vkQueueSubmit(m_graphicsQueue, 1, &submitInfo, m_inFlightFences[m_currentFrame]) !=
VK_SUCCESS) {
throw std::runtime_error("failed to submit draw command buffer!");
}
// The fence signals when the GPU finishes
// this submission. The CPU will wait on it at the start
// of the next use of this frame slot in Step A.

// Step M — Present:
VkSwapchainKHR swapchains[] = {m_swapchain};
Expand All @@ -951,23 +946,20 @@ void VulkanContext::drawFrame(loom::ui::ImGuiRenderer& imgui) {
presentInfo.pWaitSemaphores = signalSemaphores;
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = swapchains;
presentInfo.pImageIndices = &imageIndex;
presentInfo.pImageIndices = &m_currentImageIndex;

VkResult presentResult = vkQueuePresentKHR(m_presentQueue, &presentInfo);

if (presentResult == VK_ERROR_OUT_OF_DATE_KHR || presentResult == VK_SUBOPTIMAL_KHR) {
// Recreate here catches both the
// suboptimal case deferred from Step B and any
// out-of-date result returned by present itself.
auto loomWindow =
reinterpret_cast<loom::platform::Window*>(glfwGetWindowUserPointer(m_window));
loomWindow->resetResizedFlag(); // Ensure flag is set for next frame
recreateSwapchain();
} else if (presentResult != VK_SUCCESS) {
throw std::runtime_error("failed to present swapchain image!");
}

// Step N — Advance frame index. THIS LINE IS MANDATORY:
// Cycle to the next frame slot.
// Omitting this line means every frame uses slot 0,
// breaking the entire frames-in-flight system silently.
// Step N — Advance frame index:
m_currentFrame = (m_currentFrame + 1) % core::MAX_FRAMES_IN_FLIGHT;
}

Expand Down
36 changes: 20 additions & 16 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,26 @@ int main() {
while (!window.shouldClose()) {
window.pollEvents();

// Build UI
imgui.beginFrame();
nodeEditor.draw("Loom Node Editor");

// Evaluate Graph
loom::core::EvaluationContext evalCtx{};
evalCtx.requestedExtent = {1280, 720};
evalCtx.imagePool = &imagePool;
evalCtx.pipelineCache = &pipelineCache;
evalCtx.allocator = vulkan.getVmaAllocator();

// Note: In a real app we'd use the per-frame command buffer from VulkanContext.
// For now, let's keep it simple and just do UI rendering.
// Phase 6 will likely integrate the compute dispatch into drawFrame.

vulkan.drawFrame(imgui);
if (VkCommandBuffer cmd = vulkan.beginFrame()) {
// Build UI
imgui.beginFrame();
imgui.drawDockspace();
nodeEditor.draw("Node Editor");

// Evaluate Graph
loom::core::EvaluationContext evalCtx{};
evalCtx.requestedExtent = {static_cast<uint32_t>(imgui.getViewportSize().x),
static_cast<uint32_t>(imgui.getViewportSize().y)};
evalCtx.imagePool = &imagePool;
evalCtx.pipelineCache = &pipelineCache;
evalCtx.allocator = vulkan.getVmaAllocator();

// Note: In a real app we'd use the per-frame command buffer from VulkanContext.
// For now, let's keep it simple and just do UI rendering.
// Phase 6 will likely integrate the compute dispatch into drawFrame.

vulkan.endFrame(cmd, imgui);
}

imagePool.flushPendingReleases();
}
Expand Down
Loading
Loading