Skip to content

fix(vulkan,rendering,editor): vkQueueSubmit2 migration + deferred texture upload + async drag-drop - #774

Closed
JeanPhilippeKernel wants to merge 144 commits into
mainfrom
diag/timeline-semaphore-intel-guard
Closed

fix(vulkan,rendering,editor): vkQueueSubmit2 migration + deferred texture upload + async drag-drop#774
JeanPhilippeKernel wants to merge 144 commits into
mainfrom
diag/timeline-semaphore-intel-guard

Conversation

@JeanPhilippeKernel

Copy link
Copy Markdown
Owner

Summary

  • vkQueueSubmit2 migration — replace vkQueueSubmit + VkTimelineSemaphoreSubmitInfo with vkQueueSubmit2 + VkSemaphoreSubmitInfo in all timeline semaphore submit paths (DeviceSwapchain::Present() and VulkanDevice::QueueSubmit(timeline)). Each semaphore carries its own struct, eliminating the parallel-array count ambiguity that caused Intel Windows driver corruption (UINT64_MAX semaphore value). Enable VkPhysicalDeviceSynchronization2Features in VkDeviceCreateInfo — the extension was loaded but the feature flag was never set. Fix type correctness: AsyncGPUOperationHandle::StageFlags, AsyncUploadJob::WaitFlag, and QueueSubmit(timeline) wait_flag now use VkPipelineStageFlags2. Remove ASSERT_TIMELINE_MONOTONIC macro and all 6 call sites — root cause fixed at the API level; the per-frame vkGetSemaphoreCounterValue calls were expensive on MoltenVK.

  • Deferred texture upload — texture async ops and descriptor updates go into 1-frame SPSC deferred queues (DeferredAsyncGPUOperations, DeferredTextureDescriptorUpdates). Present() runs before SubmitAsyncUploads() so the same-frame GPU stall on drag-drop is eliminated. RequestDeferredDescriptorUpdate pre-fills the new slot with the fallback image view (pink) for the one-frame upload window so the mesh renders correctly immediately.

  • Async drag-drop mesh deserializationDeserializeMeshAssetFile and IngestMaterialFromUUID are synchronous file reads that were blocking the render thread on every drag-drop. Both now run on a ThreadPool worker thread using a dedicated per-drop ArenaAllocator (4× file size + 8 MB) that outlives the lambda. The main-thread callback via MainThreadScheduler::Post handles only the lightweight IngestMesh + actor create.

Result

Stable 121 fps through mesh drag-drop with textures. Multiple meshes rendered correctly.

Viewport with dragged meshes

Test plan

  • Drag a cooked .zemesh asset onto the viewport — FPS stays stable, mesh appears with correct textures
  • Drag multiple meshes in sequence — no frame drops, all visible
  • Verify on Windows: no Intel timeline semaphore corruption (UINT64_MAX) with vkQueueSubmit2

github-actions Bot and others added 30 commits August 4, 2026 04:53
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* ci(build): drop macOS x64, add Ninja preset for Windows, cache LLVM and deps

- Remove macOS x64 from all CI jobs and publish workflows; arm64 only
- Add Windows_x64_Debug/Release_Ninja presets using Ninja Multi-Config generator
- Add -Generator param to BuildEngine.ps1 (VisualStudio default for local dev)
- CI passes -Generator Ninja to avoid MSBuild solution-parse hang
- Add ilammy/msvc-dev-cmd to expose cl.exe/link.exe to Ninja on CI
- Cache LLVM 20.1.7 and FetchContent _deps keyed on dependencies.cmake
- Fix job-clangformat.yml missing on: workflow_call trigger
- Remove stale matrix/srcDirectory reference in Engine-CI.yml

* ci(build): skip LLVM install when already present on runner

* ci(build): pin LLVM to 22.1.0 on Windows CI to match local clang-format version

* ci(build): exclude workspace from Windows Defender to eliminate post-configure hang

* ci(build): enable FETCHCONTENT_UPDATES_DISCONNECTED on Ninja preset to skip dep update checks

* ci: skip build when only non-source files change

* ci(build): revert Ninja generator, restore VS generator for Windows
…ne::Initialize (#575)

* feat(core): add IsInitialized() to Logger and ThreadPoolHelper

* feat(core): add IsInitialized() to Logger and ThreadPoolHelper

* feat(engine): move VFS into EngineContext, pass MemoryManager to Engine::Initialize
…ntation (#576)

* feat(core): add IsInitialized() to Logger and ThreadPoolHelper

* feat(core): add IsInitialized() to Logger and ThreadPoolHelper

* feat(engine): move VFS into EngineContext, pass MemoryManager to Engine::Initialize

* docs(engine): reevaluate engine lifecycle doc against current implementation
* fix(engine): align shutdown sequence with engine-lifecycle spec

- Fix shutdown order in Engine::Deinitialize(): set terminate flag first,
  join render thread, then AssetManager::Shutdown, AppRenderPipeline::Shutdown,
  VFSContext::Shutdown, VulkanDevice::Deinitialize, GameWindow::Deinitialize
  (was: Window first while render thread still running — use-after-free on surface)
- Remove false s_request_terminate reset in Engine::Dispose()
- Move AssetManager::Shutdown from Dispose into Deinitialize (correct order)
- Move OnClosing() from GameApplication::Shutdown into Engine::Run(), firing
  before Deinitialize while all subsystems are live (spec: before shutdown Step 1)
- Move OnClosed() to EntryPoint.cpp after manager.Shutdown(), before
  CrashHandler::Uninstall() (spec: after Step 17, before Step 18)
- Add VFSMountTable::Clear() and VFSContext::Shutdown() (spec: shutdown Step 12)
- Add IVFSContext::Shutdown() pure virtual
- Add ThreadPoolHelper::Shutdown() static method; call it in EntryPoint.cpp (Step 15)
- Add Logger::Flush() before Logger::Dispose() in EntryPoint.cpp (Step 16)
- Update engine-lifecycle.md checklist to reflect completed items

* fix(editor): move VFS mount from OnInitializing to OnInitialized

Engine::GetContext()->VFS is null during OnInitializing — the engine has
not been initialized yet at that call site. WorkingSpaceBackend can be
initialized in OnInitializing (no engine dependency), but the Mount call
must happen in OnInitialized where all engine subsystems are live.

* fix(tests): add Shutdown() stub to MockVFSContext

IVFSContext::Shutdown() is now a pure virtual; the mock in VFSScanner_test
must implement it to remain concrete.

* fix(renderer): add missing depth prepass fragment shader; enable VMA/validation debug

- Add depth_prepass_scene.frag (no-op) — MoltenVK requires a fragment
  function even on depth-only pipelines; its absence caused a null-pointer
  dereference inside MVKGraphicsPipeline::newMTLRenderPipelineDescriptor
- Enable ENABLE_VULKAN_VALIDATION_LAYER for Debug builds
- Enable VMA_DEBUG_INITIALIZE_ALLOCATIONS and VMA_DEBUG_DETECT_CORRUPTION
  for Debug builds; VMA leak reports are written to stderr via
  VMA_DEBUG_LOG_FORMAT

* fix(vma): fix dedicated image leak on resize and add VMA_DEBUG_MARGIN

- DeviceSwapchain::Present() was calling Texture::Dispose() (a no-op)
  instead of Image2DBuffer::Dispose() when draining TextureHandleToDispose,
  causing every render target discarded during RenderGraph::Resize to leak
  its VMA dedicated allocation for the lifetime of the session
- VulkanDevice::Deinitialize() now drains TextureHandleToDispose before
  GlobalTextures/Image2DBufferManager teardown, using the same correct
  Image2DBuffer::Dispose() path, so any residual entries at shutdown are
  also freed
- Replace VMA_DEBUG_INITIALIZE_ALLOCATIONS with VMA_DEBUG_MARGIN=16 so
  VMA_DEBUG_DETECT_CORRUPTION actually places guard bytes; without a
  non-zero margin the corruption checker was a silent no-op

Verified: 102 dedicated allocs, 102 frees, no assertion on clean shutdown

* fix(vma): route VMA debug log to OutputDebugStringA on Windows

On Windows, fprintf(stderr, ...) is not visible in the Visual Studio
Output window. Route VMA_DEBUG_LOG_FORMAT through OutputDebugStringA
(and still mirror to stderr) on _WIN32; keep the plain fprintf path
on Linux and macOS.

* style: apply clang-format to VMA_DEBUG_LOG_FORMAT macro

* fix(engine): align lifecycle with spec — AppRenderPipeline ownership and OnClosing ordering

Finding 14: move AppRenderPipeline construction out of Engine::Initialize into
GameApplication::Initialize, after the device is live and before OnInitialized fires.
Removes the static g_appRenderPipeline pointer; Deinitialize now shuts it down via
g_app->RenderPipeline.

Finding 16: split the shutdown signal into two flags so OnClosing fires while all
subsystems are fully live. OnEngineClosed now sets s_close_requested (exits the main
loop) instead of s_request_terminate. Deinitialize sets s_request_terminate to stop
the render thread. OnClosing in Engine::Run fires between the two, matching the spec
requirement that the game DLL can save state before any teardown begins.

* style: clang-format Engine.cpp after lifecycle fix
New documents:
- gpu-allocator-rearchitecture.md: VMA v3.3.0 redesign with segregated
  pools, StagingRing, DeferredFreeQueue, and GpuMemoryDomain budget
- per-frame-upload-heap.md: single 64 MB bump-pointer frame heap
  replacing per-upload vkAllocateMemory
- rendering-flow.md: full per-frame pipeline from main thread to GPU
  submission, post-rearchitecture flow changes and known gaps
- render-graph-redesign.md: production-grade render graph with automatic
  barrier insertion, transient memory aliasing, RGTransientPool,
  kAccessTable, and compute pass support
- draw-call-sorting.md: 64-bit sort key, LSD radix sort, opaque
  front-to-back and transparent back-to-front split, DrawCommandBuffer
- compute-pipeline.md: ComputePipeline struct, CommandBuffer::Dispatch,
  ShaderType::COMPUTE, IComputeCallbackPass, three concrete examples

Correctness fixes to existing documents:
- render-resource-manager.md: FRAMES_IN_FLIGHT 2->3 in checklist,
  staging path updated to Ring.Allocate, DeferredRelease description
  corrected, EndFrame DeferFree call fixed
- gizmo-3d-pass.md: raw VkDeviceMemory fields replaced with VmaAllocation
- shadows.md: ShadowUniformBuffer upload attributed to PerFrameUploadHeap
- post-processing.md: all passes converted to DOD vtable pattern,
  SSAOPass converted, checklist and file layout updated
- render-graph-integration.md: post-process registration updated to
  PostProcessStack::AddPass factory pattern, enable/disable section
  updated to PostProcessStack::SetEnabled
- profiling.md: Execute examples updated to free-function signatures
…nd test suite (#578)

- Add LogChannel (10 channels) and LogLevel enums to Logger.h
- Replace std::function LogEventHandler with plain fn-ptr + void* struct (zero allocation)
- Add unified Logger::Log(channel, level, string_view) dispatch
- Add per-channel runtime level gate via constexpr k_min_level[] table
- Fix unguarded write in AddEventHandler; replace copy-on-log pattern with shared_mutex
- Implement arena-backed ring buffer (s_log_message_rb / s_log_raw_string_rb)
- Add Logger::FlushRingBufferToCrashLog; wire into CrashHandler via SetPreCrashCallback
- Add LoggerConfiguration::CrashLogDir and RingBufferSize fields
- Replace LoggerDefinition.h macros with channel/level compile-time filtering system
- Add backwards-compat ZENGINE_CORE_* aliases routed to ENGINE channel
- Add Scripts/CMake/LoggingDefaults.cmake with per-channel per-build-type level defaults
- Update LogUIComponent handler registration to use new LogEventHandler struct
- Reorder LogMessage fields for 40-byte layout (was 48 bytes)
- Fix Logger::Initialize to treat OutputDirectory as absolute when already absolute
- Fix Logger::Dispose to call spdlog::drop() per logger and reset s_crash_log_dir
- Add ZEngine/tests/Logging/Logger_test.cpp with 13 tests covering handler
  registration/removal, color derivation, backwards-compat macros, runtime level
  filter, ring buffer wrap, FlushRingBufferToCrashLog ordering, no-arg overload,
  and concurrent Add/Log thread safety
- Update logging-policy.md to reflect implementation state and completed checklist
…MinLevelAllChannels for test override in Release builds
…ots (#582)

Add ZEngine/Input/ module:
- InputTypes.h: InputActionType, InputBinding, InputAction, InputButtonState,
  InputAxisState, InputFrame (64 action slots, POD, networking-ready)
- InputManager.h/.cpp: RegisterAction, BindKey, BindMouseButton, BindScrollAxis,
  Poll(GLFWwindow*), GetButton/GetAxis/GetAxis2D, GetMouseDelta/GetMousePosition/
  GetScrollDelta; FNV-32 name hashing; scroll accumulator fed by GLFW callback

Wire into engine:
- EngineContext gains InputArena and InputManager* fields
- Engine::Initialize creates InputManager in a dedicated budgeted arena,
  registers glfwSetScrollCallback trampoline to AccumulateScroll
- GameApplication::Update calls InputManager::Poll before CameraController::Update
- MemoryBudgetConfig gains Input SubArenaConfig (1 MB in Default/Editor/Server)
- CMakeLists.txt: ZENGINE_SOURCES_INPUT glob for Input/*.cpp

Tests:
- 21 InputManager unit tests covering registration, binding API, default state,
  scroll accumulation, InputFrame trivial-copyability, and capacity limits
* feat(camera): redesign FlyCamera with InputManager integration

Replace the event-callback camera system with a poll-based design
that reads from InputManager, eliminating stuck keys, HiDPI sensitivity
bugs, and raw GLFW event interception.

FlyCameraInput / FlyCameraState (new):
- Plain struct owning all input state: buttons, keys[512], mouse deltas,
  scroll delta, viewport-relative cursor position
- Explicit state enum: Free, Orbit, Pan, Animating
- Reset() clears all state; FlushDeltas() zeroes per-frame deltas

FlyCamera:
- Single OnUpdate(float dt) entry point — no more OnMouseMove/OnKeyDown
- Explicit state machine — pan as first-class state, orbit entry/exit
  driven by AltDown+LeftDown, animation saves/restores previous state
- All mouse math divides by m_logicalW/H — HiDPI correct
- GetRayFromViewport takes viewport-relative logical coords
- AdaptiveSpeed uses Hooks.Raycast; FocusOn uses Hooks.GetSelectionBounds
- CameraSetting: remove unused MoveSpeed and FastMoveSpeed fields

FlyCameraController:
- Drops IMouseEventCallback / IKeyboardEventCallback inheritance
- Initialize(InputManager*, ArenaAllocator*) registers 14 camera action
  slots (WASDQE as Axis1D, buttons, scroll via BindScrollAxis)
- Update fills FlyCameraInput from InputManager then calls OnUpdate
- PauseEventProcessing calls Input.Reset() — single guaranteed clear point
- SetViewportOrigin stores viewport origin for scroll-toward-cursor ray

EditorCameraController:
- Initialize gains InputManager* parameter
- Explicit CameraSetting with all fields; SetViewportSize in logical pixels
- Default no-op Hooks wired at construction

ICameraController:
- Add Update, OnEvent, SetViewport, SetViewportOrigin,
  ResumeEventProcessing, PauseEventProcessing as pure virtuals

SceneViewportUIComponent:
- Remove reinterpret_cast to EditorCameraControllerPtr
- Call SetViewportOrigin every frame from m_viewport_bounds[0]

HierarchyViewUIComponent:
- Remove EditorCameraController cast; F-to-frame handled by InputManager

* fix(camera): bind both left and right Alt/Shift/Ctrl for orbit and fast-move
…ion (#581)

* feat(memory): GpuAllocator rearchitecture — VMA pools, staging ring, deferred free

Replace flat VMA usage in VulkanDevice with a GpuAllocator struct owning
segregated memory pools, a persistent 64 MB staging ring, and a
timeline-gated DeferredFreeQueue. Removes the DirtyCollector thread and
all HandleManager-based dirty queues.

Fixes:
- C1: DEDICATED_MEMORY_BIT hardcoded on every image — removed, now
  driver-queried via vmaCreateImage
- C2: WriteTextureData staging used HOST_ACCESS_RANDOM — replaced with
  StagingRingBuffer (sequential write, persistent map)
- C3: VmaAllocatorCreateInfo had no flags — BDA and budget bits wired
- C4: No vmaGetHeapBudgets call — SampleBudgets() added, called from
  TickMemory() each frame
- H2: Per-upload vmaCreateBuffer/vmaDestroyBuffer — replaced with ring
  allocation; one-shot fallback on ring-full
- H3: UniformBuffer missing ALLOW_TRANSFER_INSTEAD — domain flag set
- H5: ClearBuffer skipped vmaFlushAllocation on non-coherent memory — fixed

Key changes:
- GpuAllocator.h/cpp: VMA segregated pools per GpuMemoryDomain,
  StagingRingBuffer (64 MB persistent map, timeline-drained),
  SampleBudgets with vmaCalculateStatistics fallback, VMA leak check
  on shutdown; VMA_STATIC_VULKAN_FUNCTIONS=1 to avoid dynamic dispatch
  blocking under api_dump layer
- DeferredFreeQueue.h: inline Enqueue/Drain replacing DirtyCollector
  thread; dispatches FreeBuffer/FreeImage/vkDestroy* by entry kind
- VulkanDevice.h/cpp: GpuMem + PendingFree members; TickMemory/DeferFree
  wired; CreateBuffer/CreateImage route through GpuMem; typed buffer
  callsites pass GpuMemoryDomain; double PendingFree.Drain at shutdown
  to free all Dispose-enqueued resources before vmaDestroyAllocator
- AsyncResourceLoader.h/cpp: UploadFromStagingBuffer and ClearBuffer use
  ring with one-shot fallback; retire loops use GpuMem.FreeBuffer
- DeviceSwapchain.cpp: AcquireNextImage calls TickMemory; Clear uses
  DeferFree
- Rendering callsites (Fence, Semaphore, Attachment, Pipeline,
  FrameBuffer, Shader): migrated to DeferFree
- ZEngine/tests/CMakeLists.txt: fix IN_LIST check for Xcode generator
- docs: gpu-allocator-rearchitecture.md corrected

Verified: engine initializes and renders on Apple M4 Pro via MoltenVK;
VMA reports no leaks on clean shutdown.

* feat(rendering): implement PerFrameUploadHeap steps 5-10

Migrate camera UBO and indirect commands from per-buffer VmaAllocation
to the PerFrameUploadHeap, and remove UniformBuffer/IndirectBuffer types.

Step 5 — Shader::MarkBindingAsDynamic: patches binding type to
UNIFORM_BUFFER_DYNAMIC, rebuilds the VkDescriptorSetLayout, recreates
the descriptor pool and reallocates descriptor sets for the patched set.

Step 6 — Camera UBO migration: GraphicRenderer::DrawScene pushes
UBOCameraLayout into FrameHeaps[fi] each frame; CameraHeapOffset stored
in SceneData; SetInputFromHeap writes UNIFORM_BUFFER_DYNAMIC descriptor;
BindDescriptorSets counts actual dynamic bindings before passing offsets
to avoid validation errors on passes without dynamic UBOs.

Step 7 — Indirect draw migration: AppRenderPipeline pushes
VkDrawIndirectCommand array into the heap; IndirectHeapOffset and
IndirectCommandCount stored in SceneData; DrawIndirect overload takes
VkBuffer + offset + count directly from the heap.

Step 8 — Remove SceneCameraBufferHandle and IndirectBufferHandle from
SceneData and GraphicRenderer; remove CreateUniformBufferSet and
CreateIndirectBufferSet call sites.

Step 9 — Remove IndirectBufferSetManager accessors from active pass
Execute methods.

Step 10 — Full cleanup: remove UniformBuffer, UniformBufferSet,
IndirectBuffer, IndirectBufferSet type definitions and all associated
HandleManager specializations, Create* methods, and implementations
from VulkanDevice; remove GetBufferUniformSet, GetIndirectBufferSet,
AttachBuffer(UniformBufferSetHandle) from RenderGraph; remove
SetInput(UniformBufferSetHandle) from RenderPass; update DrawIndirect
and DrawIndexedIndirect to take VkBuffer + offset directly.

Verified: engine initializes and renders with dynamic UBO offsets on
Apple M4 Pro via MoltenVK.

* feat(rendering): move env map to project assets, disable skybox when absent

Environment map is now a project-owned asset, not an engine resource.

- Remove Resources/Editor/Settings/EnvironmentMaps/ from engine source;
  bergen_4k.hdr and bergen_4k.zenvmap no longer ship with the engine
- Add environmentMapDir field to project.json (default: Assets/EnvironmentMaps);
  env map filename referenced under sky.environmentMap
- EditorConfiguration gains EnvironmentMapImportPath and
  ActiveEnvironmentMapPath; ReadConfig parses both from project.json
- GameApplication::GetActiveEnvironmentMapPath() virtual hook; Editor
  overrides it to return Configuration->ActiveEnvironmentMapPath
- AppRenderPipeline::ActiveEnvironmentMapPath forwarded to SceneRenderer
  before Initialize so GraphicRenderer::Initialize sees it
- SkyboxPass: EnvMapPath field set by GraphicRenderer before Setup;
  pass is disabled via RenderGraph node when path is null or not found
  in VFS; Execute bails early when m_env_map is invalid
- Env map importer output path changed from Settings/EnvironmentMaps to
  EditorConfiguration::EnvironmentMapImportPath (project/Assets/EnvironmentMaps)
- Add SampleProject/ to .gitignore — user projects are not engine source
- docs/future-plan/sky-rendering.md: detailed design for SkyAtmospherePass,
  SkyLightPass, HDRIBackdropPass, SkySpherePass, and SkySystem

Verified: engine runs without env map — skybox pass disabled, grid
renders cleanly, no validation errors.

* feat(config): adopt assetDirs structure, expand EditorConfiguration paths

Align EditorConfiguration with the new project.json assetDirs layout:
- Replace DefaultImportTexturePath/DefaultImportSoundPath with
  TexturePath, SoundPath, MeshPath, MaterialPath, SpritePath,
  EnvironmentMapImportPath — all resolved under Assets/
- ReadConfig: parse assetDirs (new format) with backward-compat
  fallback to defaultImportDir for existing project files
- $(workingSpace) token expanded for all assetDirs entries
- DockspaceUIComponent: use TexturePath instead of removed
  DefaultImportTexturePath for texture import output path

* docs(config): update project.json examples to assetDirs structure

* feat(rendering): per-scene sky config with runtime ApplySkyConfig

Sky configuration (mode + environment map) is now fully per-scene.
Each .zescene carries its own SkyConfig; switching scenes replaces the
skybox immediately on the render thread.

- SkyConfig lives only on RenderScene — removed from SceneData (GPU
  buffer handles have no business storing sky mode)
- RenderScene gains SkyDirty[3] atomic flag, set for all 3 frame slots
  when a scene is deserialized; consumed by AppRenderPipeline::RenderScene
- GraphicRenderer::ApplySkyConfig(const SkyConfig&) replaces the
  init-time resolve_skybox lambda; resolves env map via VFS, sets
  SkyboxPass::EnvMapPath, toggles NodeMap["Skybox Pass"].Enabled
- GraphicRenderer::Initialize always registers Skybox Pass as disabled;
  first scene load with IsHDRI() == true enables it
- Remove ActiveEnvironmentMapPath from IRenderer, AppRenderPipeline,
  GameApplication, and EditorConfiguration — no longer needed since
  sky config flows through scene load, not renderer init
- Remove GetActiveEnvironmentMapPath() virtual from GameApplication
  and Editor; remove implementation from Editor.cpp
- Remove sky.environmentMap / legacy environmentMap read from
  EditorConfiguration::ReadConfig — project.json no longer owns sky
- Bump SCENE_FILE_VERSION 1.0.0 -> 1.1.0 (breaking: sky fields added
  to .zescene binary stream; old files are not forward-compatible)

* style: apply clang-format

* fix(rendering): default clear color to black when no sky is active

* fix(rendering): use dark carbon (#1C1C1C) as default clear color

* fix(vulkan): remove api_dump from default validation layer stack

api_dump intercepts every Vulkan call and serializes full parameter
structs — 50+ MB per run at 60fps. It belongs in targeted debugging
sessions only, enabled via VK_INSTANCE_LAYERS env var.

* fix(rendering): skip SkyboxPass Verify() when env map is not set
 (#584)

Frame color/depth render targets and g-buffer attachments were
hardcoded to 1280x780. When the editor viewport panel was larger
the scene geometry was clipped at the RT boundary.

All transient render targets now initialise to
SwapchainImageWidth/Height at startup. The existing resize path via
RenderTargetResizeRequests handles subsequent viewport changes.

Also updates input-system.md to include BindScrollAxis and scroll
delta accessor documented during the InputManager session.
…inotify, RDCW) (#580)

Introduces a file watching system wired into VFSContext:

Platform backends:
- VFSFSEventsWatcher (macOS): FSEvents-based, run-loop threaded, stream
  rebuilt on the run loop thread via CFRunLoopPerformBlock
- VFSInotifyWatcher (Linux): inotify-based with poll + wake pipe, recursive
  subtree support via IN_ONLYDIR and auto-watch of new directories
- VFSRDCWatcher (Windows): ReadDirectoryChangesW over an IOCP, recursive
  flag forwarded to the OS

VFSFileWatcher:
- Debounce layer (default 80 ms) over any IVFSPlatformWatcher
- Path-prefix routing to per-watch callbacks; longest-prefix match wins
- Overflow events broadcast to all registered watchers

VFSContext integration:
- InitWatcher allocates the platform and file watcher from the arena
  (ZAlloc + placement new) and wires a root watch that invalidates
  VFSDirectoryCache and triggers VFSScanner on each event
- ShutdownWatcher stops the background thread and calls destructors
  explicitly; arena owns the memory
- Tick() drives the debounce flush each frame
- All silent failure paths log via ZENGINE_LOG_VFS_ERR

API:
- IVFSPlatformWatcher::Poll uses a C function pointer
  void(*)(void*, const VFSWatchEvent&) instead of std::function to
  avoid heap allocation on the hot poll path
- CMakeLists: add VFSFSEventsWatcher.mm to ZENGINE_SOURCES_CORE on
  Apple and link CoreServices framework

Tests: VFSFileWatcherTest (mock-based), vfs_fsevents_test,
vfs_inotify_test, vfs_rdc_test cover creation, modification, deletion,
rename, overflow, debounce, routing, and recursive watching.

Co-authored-by: Jean Philippe <jeanphilippe02666592@outlook.fr>
… (Ticket 5) (#585)

Introduces MetaFileIO, MetaFileData, and ImportStatus so every project
asset receives a stable UUID on first import that survives editor restarts,
reimports, and multi-engineer checkouts.

- Add ZEngine/Core/VFS/Meta/{ImportStatus,MetaFileData,MetaFileIO}.h/.cpp
  - SHA-256 hashing (self-contained, no new dependency)
  - Atomic write protocol: write to .tmp then rename, crash-safe
  - GetOrCreate: New / Stale / UpToDate classification per asset
- Add AssetManager::GetOrCreateUUID replacing per-launch uuid_random_generator
- Extend VFSScanner: MetasCreated/Updated/UpToDate stats, all atomics
  converted to PaddedAtomic, callback changed to void*/fn-ptr to avoid heap
- Add 10 unit tests in tests/VFS/vfs_meta_test.cpp (all passing)
Mark VFS tickets 1-6, InputManager, FlyCamera, logging policy, and engine
lifecycle as Implemented. Mark GpuAllocator and profiling as Partially
implemented. Update scene-serialization deps to reflect VFS 5-6 unblocking.
…iling, gpu-allocator

- input-system: Partially implemented (InputManager done, ECS/networking pending)
- engine-lifecycle: Partially implemented (core lifecycle done, ECS/audio/physics pending)
- profiling: Partially implemented (macros/Tracy/MemoryProfiler done, GPU timestamps/overlay pending)
- gpu-allocator: Implemented (all C1-C4/H2/H3/H5 bugs fixed in PR #581)
…on macOS release

EXPECT_DEATH spawns a child process that calls OnCrash, which pumps the
NSRunLoop waiting for a Cocoa dialog that never appears in headless CI.
The budget is still validated by InitialisesWithinAssetManagerBudget.
…ler docs (#586)

ECS core:
- EntityID (generational index), ComponentTypeID (PaddedAtomic counter),
  ArchetypeMask (uint64_t bitmask, 64-type v1 cap with MaskBit/MaskMatches)
- ComponentStorage<T>: sparse-set, generation check on Get/Has/Remove, swap-and-pop
- EntityRegistry: MAX_ENTITIES=65536, free-list, generational slots
- Scene: CreateEntity/DestroyEntity/AddComponent/ForEach with O(1) mask guard,
  SnapshotTransforms, FillRenderableTransforms for fixed-timestep interpolation
- WorldCommands: deferred mutations (spawn/destroy/add/remove), fn-ptr callbacks
- Query<Ts...>: reusable pre-computed ArchetypeMask wrapper
- RenderableTransform: plain struct for per-frame renderer packet

Actor system:
- Actor: arena-allocated, no Ref<>/RefCounted, explicit lifetime via ActorManager
- ActorManager: HandleManager<Actor*>, MAX_ACTORS=1024, Create<T>/Destroy/Tick/Shutdown
- ECS::Components::TransformComponent: plain data (Vec3f pos/rot/scale + previousPosition)

Engine wiring:
- ECSArena sub-arena (128 MB budget), Scene/ActorManager/WorldCommands in EngineContext
- ActorManager::Shutdown before Scene::Shutdown in Deinitialize lifecycle order

Tests:
- 19 tests in tests/ECS/ECSTest.cpp, passing Debug and Release
- FSEvents drain: spin-poll loop (2s deadline) replaces fixed 400ms sleep

Docs updated:
- actor-ecs-architecture.md: Handle<Actor> rationale, MAX_ACTORS/MAX_ENTITIES,
  memory layout section, archetype and sparse-set concept explanations
- system-scheduler.md: Why This Exists section, WorldCommands& in SystemFn,
  spin-yield barrier with cv.wait fallback, inline single-system wave path,
  commitlint reference (section 13)
- RegisterSystem(fn, deps) returns stable SystemID; MAX_SYSTEMS=64
- OrderBefore(a, b) declares explicit execution ordering
- Commit(): BuildEdges detects conflicts and asserts on missing OrderBefore;
  TopologicalSort uses Kahn's algorithm to group systems into parallel waves;
  asserts on cycles
- Tick(): single-system waves run inline (zero thread pool overhead);
  multi-system waves use spin-yield + cv.wait fallback barrier
- WorldTick wired into EngineContext under ECSArena
- 5 tests in SchedulerTest.cpp: independent wave, separate waves, write-before-read
  ordering verified via state, WorldCommands deferred spawn, three-system chain
* feat(engine): implement fixed-timestep game loop

- ZEngine::Timing::FrameTimer — wall-clock delta, 250ms spike clamp, 8-sample
  smoothed delta for UI and camera
- ZEngine::Timing::FixedTimestepAccumulator — fix-your-timestep pattern, 60Hz
  fixed step, spiral-of-death guard (max 5 steps), Alpha() for renderer lerp
- ZEngine::Timing::FrameRateCap — sleep + spin-wait frame budget, 300fps default,
  bypassed when vsync is on
- ZEngine::Timing::FramePacket — per-frame snapshot with Alpha and ECS
  RenderableTransform array; reuses ECS::RenderableTransform
- Engine::MainThreadRun rewritten: FrameTimer -> accumulator -> fixed-step loop
  (WorldTick + Flush + ActorManager + SnapshotTransforms) -> alpha -> render payload
  -> frame rate cap; existing PrepareScene/overlay path preserved unchanged
- Namespace ZEngine::Timing avoids collision with ZEngine::Engine struct

* refactor: remove dead code — GraphicSceneEntity, serializers, ValidComponent, NameComponent

GraphicSceneEntity and GraphicScene3DSerializer had all logic commented out
and no external callers. ValidComponent and NameComponent had zero live
references. The live rendering path (RenderScene, GraphicScene, Tetragrama)
is unaffected.
- Add GltfImporter (fastgltf v0.9) for .glb/.gltf; AssimpImporter now
  handles .fbx/.obj only
- Add fastgltf v0.9.0 dependency via FetchContent
- Wire VFSBackendCaps::Write on WorkingSpaceBackend so MetaFileIO can
  create .meta sidecar files
- Filter .meta files from VFSFileWatcher Enqueue to prevent self-import
  loop after sidecar creation
- Fix AssetIndex::Register to allow empty-path records (sub-assets such
  as MESH_HIERARCHY have no VFS path)
- Fire hot-reload callback in AssetRegistry::SetState when state
  transitions to Loaded; IngestMesh explicitly calls SetState for both
  mesh and hierarchy UUIDs so Editor callback enqueues handle into
  PendingOnLoadHierarchies
- Fix missing NodeHierarchyUUID copy in AssetManager::IngestMesh
- Stable arena-backed filenames for AsyncResLoader texture Submit calls
- Guard duplicate ingest in GltfImporter; re-drop of already-loaded
  asset is a no-op (multi-instance deferred to RRM)
- Add ImportCoordinator error log for MetaFileIO write failures
- Flush spdlog on critical level so assert messages reach disk
- Fix flaky FSEvents test: accumulate events across polls instead of
  clearing between iterations; bump timeout to 3000ms for CI headroom
…redesign (#590)

* feat(rrm): RenderResourceManager, packed geometry buffers, and scene redesign

Introduce RenderResourceManager (RRM) as the single authority over GPU
resource lifetime, replacing AsyncResourceLoader and IGraphicBuffer
entirely. Add packed global geometry buffers (256 MB vertex + 256 MB
index) shared across all meshes, and redesign the scene system with a
flat MeshInstance list protected by a seqlock.

RenderResourceManager
- Full GPU buffer and texture upload ownership (vertex, index, textures)
- Dedicated transfer command buffer slot (GEOMETRY_UPLOAD_SLOT = 15)
- Timeline semaphore infrastructure for texture retire
- HostUniform buffers use VMA_MEMORY_USAGE_AUTO for HOST_VISIBLE guarantee
  on all GPU types (discrete and integrated)
- RecordAndSubmit helper: wait-fence -> reset -> begin -> record -> end ->
  submit -> wait, using CommandBufferMgr directly

Packed global geometry buffers
- Two 256 MB device-local VkBuffers (m_global_vertex_buf, m_global_index_buf)
- Append-only watermark cursor; per-mesh element-count offsets in MeshSlot
- All shaders index into the global buffers via DrawData.VertexOffset /
  DrawData.IndexOffset
- SetInputByBinding(set, binding, buffer) bypasses ValidateInput name
  lookup for VertexSB / IndexSB (SPIR-V Cross reflection gap)

Scene system redesign
- Replace node hierarchy with flat Array<MeshInstance> + seqlock
  (PaddedAtomic<uint64_t> m_seq: even = stable, odd = writing)
- MeshInstance: { Id, MeshUUID, Transform, Name[128] }
- GetInstancesSnapshot spins on odd seq, copies, verifies unchanged
- Same-mesh re-drop handled via ImportCoordinator::Enqueue returning UUID
- CachedDrawCmds[512] in SceneData fixes stale FrameHeap offset bug
  (heap resets every frame; draw commands now pushed unconditionally)
- SCENE_FILE_VERSION bumped to 2.0.0; serializer writes instance array

Shader / descriptor fixes
- UBCamera (set=0, binding=0) declared UNIFORM_BUFFER_DYNAMIC at SPIR-V
  reflection time -- eliminates MarkBindingAsDynamic and the incomplete
  descriptor layout rebuild it caused
- pool_needs_update_after_bind guard: UPDATE_AFTER_BIND_BIT only set when
  a binding actually uses it -- fixes Vulkan validation crash when pool has
  UPDATE_AFTER_BIND and layout has UNIFORM_BUFFER_DYNAMIC

ImGui per-frame buffers
- VBHandles[3] / IdxBHandles[3] replace single shared VBHandle / IdxBHandle
  to eliminate write-after-read flickering across frames

* fix(vfs): StartThread blocks until FSEvents stream is live

ReportsFileCreation was flaky on slow CI runners because StartThread()
returned immediately after spawning the thread, before FSEventStreamStart
was called. Any file written in that window was missed.

Fix: add a condition_variable that the background thread signals once
RebuildStream() completes (FSEventStreamStart called). StartThread() now
blocks until the signal arrives before returning, so callers can safely
produce filesystem events immediately after the call.
JeanPhilippeKernel and others added 28 commits September 3, 2026 15:04
Full-file re-reads (not shallow grep checks) surfaced real drift between
docs and shipped code across the completed/ and future-plan/ sets:

- ui-system.md: font backend is FreeType not stb_truetype; "Migrated
  Editor Panels" and "ImGui Coexistence" sections described an
  intermediate dev snapshot superseded by ZUIPanelManagerComponent +
  Tetragrama/Panels/*; ImGui/ImGuizmo are unused dependencies, not
  coexisting passes; several widget names drifted.
- vfs-ticket2-mount-backends.md: VFSMountTable::Resolve() picks the
  longest matching prefix, not first-priority-match; VFSDiskBackend has
  no path-traversal sandbox check at all (pure concatenation);
  VFSZipBackend serializes all decompression under one mutex instead of
  the planned lock-free per-file design, and VFSZipFile::m_decompressed
  is an unguarded bool (real data race on concurrent Read()); Array now
  has insert()/erase() so the manual-shift workaround is stale.
- vfs-ticket6-asset-registry.md: CollectCascade never fails with
  OutOfMemory; it rehashes and continues.
- asset-manager.md: memory layout is a direct parent Arena + separate
  256MB TLSFSlab ContainerSlab, not a 512MB carve; dedup uses per-type
  maps (MeshToHierarchySlot/UUIDToTextureHandle/UUIDToMaterialSlot),
  not IsRegistered (which VFSScanner pre-registration makes unreliable
  here); UUIDToMaterialSlot was missing from the doc entirely.
- logging-policy.md: k_min_level is a mutable array with SetMinLevel/
  SetMinLevelAllChannels, contradicting the doc's own "runtime
  adjustment not supported" claim; LogMessageType alias was never
  added; LogUIComponent doesn't exist, the real panel is ConsolePanel
  (ZUI-based, not ImGui-based).
- gpu-allocator-rearchitecture.md, render-resource-manager.md: moved
  back to future-plan/ — findings were severe enough that "completed"
  was the wrong status. GPU allocator's headline "VMA segregated
  pools" feature was never built (GpuAllocator::Pools[5] is populated
  nowhere); AsyncResourceLoader.cpp (its stated fix location) no
  longer exists. RRM's SwapKind::Buffer is silently dropped in
  EndFrame, ScheduleSwap is a stub for both resource kinds, the
  documented deferred-release ring and HandleManager/ABA protection
  don't exist. Filed #740 for the two real runtime-risk findings
  (buffer hot-reload no-op, no ABA protection).

vfs-design.md, memory-allocator-audit.md, system-scheduler.md carry
smaller corrections from the same pass (VFSDiskContext naming,
16-not-13 bug count, two fabricated test sub-bullets).
…738)

* fix(vulkan): remove backward timeline clamp in swapchain recreation — closes #736

DeviceSwapchain::AcquireNextImage's recreation path resynced
RenderTimelineNextValue to vkGetSemaphoreCounterValue's current
'completed' value after waiting on ImageInFlights/PresentCompletes.
That wait set is sized SwapchainImageCount (3), while the engine allows
up to FrameContextPoolSize (BufferedFrameCount * 4, e.g. 8-12) frames'
command buffers to be concurrently in-flight — so 'completed' could be
lower than the real signal target of a still-executing frame beyond
those 3 waited-on slots.

VulkanDevice::DeferFree unconditionally stamps every deferred-free entry
(both this function's own Clear() and unrelated callers like
RenderGraph::Resize disposing viewport render targets) with
RenderTimelineNextValue. Rewinding that counter backward meant any
resource deferred-freed shortly after made Drain() treat it as already
safe to destroy — even while a genuinely in-flight command buffer from a
frame beyond the waited-on slots still referenced it. This produced the
'vkDestroyFramebuffer/vkDestroyImage ... currently in use by
VkCommandBuffer' validation errors (and crash on some drivers) reported
on fast/rapid window or viewport resize.

RenderTimelineNextValue is already a correct, strictly monotonic counter
— it is incremented exactly once per real submission (the two
++RenderTimelineNextValue call sites, one per submit). It never needed
external resyncing; removing the resync removes the only place it could
move backward.

Sanity-checked on macOS: 5 rapid resizes in immediate succession via
System Events, Khronos validation layer active, no validation errors,
engine remained alive throughout. The actual reported race is
Windows + Intel iGPU driver timing specific and needs verification on
that platform.

* fix(vulkan): wait on full frame-context pool before swapchain recreation (#739)

Option B from the #736 analysis, stacked on Option A (PR #738).

ImageInFlights/PresentCompletes are sized SwapchainImageCount (typically
3), but the engine allows up to FrameContextPoolSize
(BufferredFrameCount * 4, e.g. 8-12) frames' command buffers to be
concurrently in-flight. The existing wait loop covers only the smaller
set, so a command buffer using a frame context beyond those tracked
slots could still be executing when recreation proceeds to destroy
old resources.

Add a wait over every FrameContexts[i].Fence (checking Submitted state
first, mirroring the existing PresentCompletes pattern) before Clear()
and the timeline read. This closes the actual undersized-wait gap
independently of Option A's timeline-clamp fix — even if some other
code path reintroduces a timeline miscalculation in the future, this
ensures recreation genuinely waits for the full in-flight depth the
engine allows before destroying anything.

Sanity-checked on macOS with both fixes stacked: 15 rapid random resizes,
Khronos validation layer active, no errors, engine remained alive.
Calling Log before Initialize (or after Dispose) divided by a zero-sized
ring buffer and could recurse through assert macros. Guard with
IsInitialized() and cover before-init / after-dispose paths.

Fixes #746

Co-authored-by: Yuzhong Zhang <BetterAndBetterII@users.noreply.github.com>
…ools, RRM hot-reload (#748)

* feat(render-graph): real topological sort with RAW/WAW/WAR hazard edges

BuildTopology was a flat insertion-order loop despite RGPass already
recording per-pass Reads/Writes — no actual dependency sort ever ran.
Binds each resource's readers directly to its sole writer regardless of
declared order, which is what lets a pass registered before its
producer get fixed instead of just reproducing declaration order (a
single forward scan can never do this, since it can only emit edges to
a later index — checked this against a first design that compiled fine
but, per its own tests, could never reorder anything or detect a cycle).
Falls back to declaration order and logs on a cycle instead of crashing.

Also fixes the resulting Compile() ordering bug: BuildLifetimes must run
after BuildTopology now, since it indexes by sorted execution order, and
one unrelated pre-existing bug found in the same function (a resource
index compared against a pass index, which could never match).

7 new device-free tests in RenderGraphTest.cpp.

* feat(gpu-allocator): real segregated VMA pools for GpuAllocator domains

Pools[5] was declared but never populated — every allocation went
through VMA's default pool regardless of domain. Adds real vmaCreatePool
calls for DeviceGeometry/DeviceTexture/HostUniform/HostStaging
(RenderTarget intentionally stays unpooled — it benefits from VMA's
automatic dedicated-allocation promotion, which pooling would work
against). Wired into AllocateBuffer/AllocateImage with a fallback retry
against the default pool on exhaustion.

Building and testing this against a real headless Vulkan device (no
window/surface needed — GpuAllocator only touches raw Vulkan handles)
caught three real bugs no amount of review would have found:
- VK_ERROR_OUT_OF_POOL_MEMORY doesn't exist in this VMA version at all —
  it's a different Vulkan concept (VkDescriptorPool exhaustion). VMA
  signals VmaPool exhaustion via VK_ERROR_OUT_OF_DEVICE_MEMORY instead.
- A fixed-blockSize pool sized exactly equal to an allocation's byte
  count fails — a block needs alignment headroom beyond its raw size.
  Fixed HostStaging (built around the ring's exact-size buffer) and
  DeviceTexture (single large textures can exceed a fixed block) with
  blockSize=0 (auto-sized), which also preserves dedicated-allocation
  fallback for oversized textures.
- Every custom pool must be destroyed before the VmaAllocator, or
  vmaDestroyAllocator hits VMA's own internal assert.

4 new tests against a real headless Vulkan device in GpuAllocatorTest.cpp.

* feat(rrm): implement hot-reload swap mechanism + ABA fix for handle pools

ScheduleSwap(BufferHandle,...) and ScheduleSwap(ImageHandle,...) were
both literal no-op stubs — neither ever constructed a SwapEntry or
incremented m_swap_count, so EndFrame's swap-processing loop never ran
for either kind (the Image branch was correctly written but permanently
unreachable, since the queue it drained was always empty).

ScheduleSwap now enqueues onto a mutex-guarded m_pending_swaps queue —
a dedicated mutex, not m_pending_mutex, which SubmitTextureFile holds
across file I/O and a GPU call. A new FlushPendingSwaps (render-thread
only, called from BeginFrame) drains it and applies each swap
immediately: no frame-in-flight delay, since tracing every consumer of
RRM handles in this engine found none that needs one (the old
SwapSafeFrame gating was independently broken anyway — it compared
against a wrapped 0..2 swapchain slot index that could never satisfy
frame_index + FRAMES_IN_FLIGHT). Mesh swaps repoint the slot's offsets
at freshly-appended data via a new shared AppendMeshData helper (no GPU
free needed — the global buffer is append-only); image swaps re-upload
and DeferFree the old image. SwapEntry/m_swaps/EndFrame's old drain loop
are removed entirely — the design collapsed to one stage, not two.

Also fixes the ABA bug: AllocMeshSlot/AllocImageSlot/AllocGBufSlot
assigned a deterministic idx+1 generation on slot reuse, so a stale
handle from a released slot could alias whatever got allocated into it
next. Now assigns a never-reset-by-Release monotonic counter per slot.
Also fixes DoUploadTexture silently overwriting AllocImageSlot's
already-correct generation with the old idx+1 scheme right after
allocating it — found while extending AllocImageSlot for the ABA fix.

New tests are honestly skip-gated in RenderResourceManagerHotReloadTest.cpp:
no test in this codebase constructs a real RenderResourceManager today,
and doing so needs a fully-initialized VulkanDevice (Arena, GpuMem, a
DeviceSwapchain with a real timeline semaphore, ThreadPoolHelper::Pool,
CommandPool/CommandBuffer) — a materially bigger fixture than
GpuAllocatorTest's raw-Vulkan-handle approach, left as a documented
follow-up rather than distorting the class for testability.

Fixes #740.

* style: apply clang-format to this month's rendering-foundation changes
#751)

RRM's own ImageHandle/m_image_slots texture system had zero real consumers
and was disconnected from what actually renders (materials sample via a raw
bindless index into Device->GlobalTextures). Delete it in favor of
Rendering::Textures::TextureHandle everywhere, and fix the three structural
gaps that came with it:

- No working texture disposal: TextureHandleToDispose had a consumer but no
  producer. VulkanDevice::DestroyTexture is now the sole producer, timeline-
  gating both the VkImage free and the bindless slot reclaim in Present().
- No real hot-reload trigger: AssetManager::IngestTexture's dedup silently
  blocked re-ingest. A new TextureImporter routes texture files through
  ImportCoordinator like every other asset type, and a dedup hit now calls
  RenderResourceManager::ScheduleTextureReload instead of no-op'ing.
- No reference safety: materials stored texture refs as a bare uint64_t
  index with no generation. AssetManager::ReleaseTexture/FlushTextureReleases
  patch every referencing material to the INVALID_MAP_HANDLE sentinel before
  the underlying bindless slot can ever be reused.

Also: VulkanDevice::ReconstructTexture generalizes the in-place resize
pattern (same handle, same slot) previously duplicated inline in
RenderGraph::Resize; TextureHandleToDispose is now a lock-free SPSC queue
since producer and consumer are both render-thread only; AssetRegistry::
InferTypeFromExtension recognizes all 8 raster extensions TextureImporter
claims plus the pre-existing .exr gap; Image2DBuffer renamed to ImageBuffer
(it holds 2D, cube, and array images, not just 2D); and the fully dead
Texture2D.h/.cpp (a superseded Ref<T>-based texture class, zero callers)
is removed.

Adversarially reviewed in 4 parallel passes; 2 real bugs found and fixed
(an unlocked concurrent read, and a missing arena Clear() that would have
grown unboundedly and crashed on exhaustion). 554/554 tests passing,
5 new (AssetRegistry extension coverage + OnRemoved callback firing,
TextureImporter::CanImport coverage). Verified live in Obelisk under an
aggressive resize stress test with no leaks or crashes.
…e redesign (#752)

The doc still described RRM's ImageHandle/GPUImage/ScheduleSwap(ImageHandle,...)
system as if it existed. PR #751 deleted that system outright (zero real
consumers) in favor of Rendering::Textures::TextureHandle everywhere. Update
the status header, naming note, and the two most misleading checklist items
to point at the real current API (IngestTexture, ScheduleTextureReload,
ReleaseTexture, GetTexture, VulkanDevice::DestroyTexture) instead of the
removed one. Sections 2-10's prose is left as-is and explicitly scoped as
accurate for buffers/meshes only, consistent with this doc's existing
correction-callout convention.
… fallback (#754)

* docs(rrm): correct render-resource-manager.md for the texture pipeline redesign

The doc still described RRM's ImageHandle/GPUImage/ScheduleSwap(ImageHandle,...)
system as if it existed. PR #751 deleted that system outright (zero real
consumers) in favor of Rendering::Textures::TextureHandle everywhere. Update
the status header, naming note, and the two most misleading checklist items
to point at the real current API (IngestTexture, ScheduleTextureReload,
ReleaseTexture, GetTexture, VulkanDevice::DestroyTexture) instead of the
removed one. Sections 2-10's prose is left as-is and explicitly scoped as
accurate for buffers/meshes only, consistent with this doc's existing
correction-callout convention.

* fix(vulkan): guard QueueSubmit's optional args, fix GetQueue transfer fallback

VulkanDevice::QueueSubmit(wait_stage_flag, command_buffer, signal_semaphore,
fence) declares signal_semaphore/fence with nullptr defaults but dereferenced
both unconditionally — any caller relying on the documented default crashed.
Guard every dereference; skip the fence wait when no fence was given instead
of trying to wait on nothing.

VulkanDevice::GetQueue(TRANSFER_QUEUE) computed the family index with the
correct HasSeperateTransfertQueueFamily fallback, but looked up the queue
*handle* using the unadjusted type, which m_queue_map never has an entry for
on devices without a separate transfer queue family — a hard abort via
UnorderedHashMap::at. Mirror QueueWait's existing type-reassignment guard.

Closes #741, #743.
… and scan (#758)

Three independent UUID-minting paths could diverge for the same file:
artifact .meta writes never captured the mesh's own embedded UUID,
VFSScanner minted a random UUID for self-describing files that already
had one baked in, and mesh-import texture UUIDs never touched
MetaFileIO at all. Align all three around the file's own stable UUID
so AssetRegistry::FindByUUID stays reliable after a re-scan, not just
during the same-session fresh import.

Fixes #755
…rop (#756)

* fix(editor): wire up Content Browser context menus, File menu, and drag-and-drop

Content Browser:
- Create Folder/Rename/Delete context menus and modals were built but never
  wired correctly — a popup-timing race meant the modal always closed itself
  before it could render, and the name field never received keyboard focus.
- Polished the modal UI (centered, right-aligned buttons, Enter/Escape,
  disable-when-empty, red Delete styling, pre-selected Rename text).
- The grid's context menu used one shared popup key for every card, so
  opening one card's menu opened every card's; fixed with a per-card key.
- Right-click on empty grid space now opens New File / New Folder.

File menu:
- New Scene only cleared the selection — now actually clears the scene
  (ActorManager::DestroyAll + scene->Reset()).
- Open Scene just logged a hint; now opens a native file dialog defaulting
  to the project's scene folder.
- Save Scene / Save Scene As constructed the serializer as a local stack
  variable handed to a background thread pool that outlived it — a
  dangling-pointer bug. Scene serialization is being rebuilt from scratch
  (#713-#719), so these are stubbed behind Editor::SaveScene/SaveSceneAs
  rather than patched against a format about to be replaced.
- Quit called glfwSetWindowShouldClose, which nothing in the main loop
  reads — added Engine::RequestClose(), which does what the real OS
  window-close path does.
- Removed a leftover dev shortcut that fired a window-close event on every
  Escape press, regardless of context — harmless when this was a bare test
  window, but closes the whole editor today when Escape cancels a modal.
- Deleted Tetragrama/Messengers/ and MessageToken.h: every token they
  carried had zero consumers repo-wide, leftover from the pre-ZUI editor.

Native file dialog:
- NSOpenPanel's runModal blocked the main thread for as long as the dialog
  was open, freezing the whole engine loop. Switched to
  beginWithCompletionHandler, bridged into the existing coroutine scheduler.
  Adds a default-directory and message parameter (Windows/Linux best-effort,
  untested on this machine).

Drag-and-drop:
- Two bugs in ZUIInteractionPass made every drag-and-drop in the app
  non-functional: the drop target was read from ctx->HotKey, which is
  frozen while the mouse is held, so it always resolved to wherever the
  drag started rather than where it was released; and the payload length
  was zeroed in the same statement that reported the drop as successful,
  so every drop reported success with an empty payload.
- Dropping a .zemesh/.zescene onto the viewport now resolves the VFS-style
  drop path to native and ingests the mesh explicitly (the registry's
  auto-ingest path is dead code, tracked separately in #755). Material/
  texture resolution is intentionally left out — the mesh's embedded
  material UUID and the registry's UUID for that file come from
  independent, uncoordinated code paths today (#755).
- Added a small drag-ghost indicator so a drag in progress is visible.

Also fixes the Inspector's generic field renderer reinterpreting a
uuids::uuid as a C string instead of formatting it, and runs clang-format
over the touched trees.

* fix(windows): remove invalid WinRT SuggestedStartFolder call

FileOpenPicker has no SuggestedStartFolder API — WinRT pickers only
support a fixed PickerLocationId enum, not an arbitrary start folder,
which broke the Windows CI build (C2039). default_dir/message have no
effect on Windows as a result; ComputerFolder is used unconditionally.
#757)

* feat(ecs): add component button

* feat(ecs): AddComponentRaw

* fix(ecs): move Add Component button to header, fix popup edge clipping

Move "+ Add Component" from below the component list into the actor
header next to the name field, matching the reference layout. The
relocated button sits flush against the panel's right edge, which
exposed a real bug: ZUIOpenPopup defaults to opening at the click
position with no edge clamping, so the popup's component list was
clipped off the window's right edge. Anchor it leftward from the
button's own screen position instead, same pattern ZUIBeginCombo
already uses for its dropdown.

---------

Co-authored-by: Jean Philippe <jeanphilippe02666592@outlook.fr>
…orter's own Arena (#761)

AssetImporterPanel::m_local_arena is a 64KB arena sized for a handful
of path strings, but AssetImporterPanel::StartImport() was also handing
it to AssimpImporter/GltfImporter/FbxImporter::ImportFile as their
scratch arena for node-hierarchy, mesh, material, and texture data —
guaranteed to exhaust it on any real import (two Mat4f arrays alone
need ~384KB). When the arena ran out, the OOM assert didn't halt
execution without a debugger attached, so Array::reserve() silently
left m_capacity inconsistent with a null m_data, crashing on the next
push().

Each importer already carves its own generously-sized private Arena
at Initialize() (128MB/64MB/512MB) and already uses it for the
file-watcher hot-reload path (Import()) — ImportFile() just wasn't
using it. Route ImportFile() through the same private Arena instead,
matching each importer's own existing scratch-arena convention.

Fixes #760
… on re-ingest (#763)

IngestMesh silently dropped re-ingestion of an already-loaded MeshUUID,
so re-importing a mesh through the GUI panel never reached the
hot-reload swap path. Fixing that dead end surfaced three more bugs
blocking the same real-world workflow: ImportFile minted a fresh
random UUID on every re-cook (mesh and material both), the
OnImportFileComplete .meta write never actually ran due to a bad
path-prefix check, and ReadAssetMeshFileHeader was handed a
VFS-relative path instead of a native one.

- AssetManager::IngestMesh now overwrites the existing mesh/hierarchy
  slots in place on reload and calls the registry's new MarkStale
  (UUID-based, no VFSPath needed) instead of SetState(Loaded), which
  RenderResourceManager's OnReady callback ignores for already-uploaded
  UUIDs.
- AssimpImporter/GltfImporter/FbxImporter's ImportFile now reads the
  destination's existing .meta to keep mesh and material UUIDs stable
  across re-imports instead of minting new ones each time. FbxImporter
  additionally remaps its SubMeshes' by-value MaterialUUID copies.
- AssetImporterPanel::OnImportFileComplete's mesh .meta write now
  parses the already-VFS-relative output path directly instead of
  stripping a native workspace prefix that was never there, and
  resolves a native path before calling ReadAssetMeshFileHeader. A new
  block writes a .meta for every cooked material, which previously had
  none at all.
- VFSFileWatcher's debounce map capacity raised from 64 to 256; a
  material-heavy import's new per-material .meta writes could push the
  pending-entry count past the load-factor assert.

Verified live end-to-end for both meshes and materials: re-importing
the same source keeps the same UUID (confirmed byte-for-byte in the
.meta and the re-cooked artifact's own embedded UUID) and the viewport
hot-swaps to the new geometry/material without a restart.

Fixes #762
… the viewport (#766)

* fix(assets): load materials/textures when dragging a cooked mesh onto the viewport

Dragging an already-cooked .zemesh from the Content Browser onto the
viewport (ViewportPanel::SpawnDroppedMesh) only ever loaded geometry
and hierarchy via IngestMesh — it never resolved or ingested the
mesh's referenced materials or their textures. If none of a mesh's
materials happened to already be resident from an earlier import this
session, the dropped mesh rendered with whatever material happened to
occupy slot 0, i.e. an arbitrary, unrelated material.

- AssetManager gains IngestMaterialFromUUID, the single-UUID
  counterpart to the existing (uncalled) ReloadFromDisk's material
  block: resolve the UUID via the registry, deserialize its
  .zematerial, and ingest it (which transitively ingests its
  textures). SpawnDroppedMesh now calls this for every submesh's
  MaterialUUID before the mesh data is moved into IngestMesh.
- Reserved slot 0 in Materials/GPUMeshMaterials as an intentional
  magenta fallback material, created once at AssetManager::Initialize.
  AppRenderPipeline's render-time material lookup already defaulted
  an unresolved submesh to index 0 — without a reserved slot, that
  meant aliasing to whichever real material happened to be ingested
  first, silently rendering the wrong material instead of a
  recognizable "unresolved" signal.
- GetAsset<AssetMaterial, uuid> now checks AssetRecord::IsLoaded(),
  not just non-null, closing a related false-positive: VFSScanner
  pre-registers every .zematerial UUID with SlotHandle=0 before any
  ingest happens, so the old check returned Materials[0] for any
  UUID that was scanned but never actually ingested.

Verifying the above live surfaced two more, unrelated pre-existing
bugs in the file-watcher path that made every freshly-imported
texture fail to resolve:
- VFSPath::FromNative (used by VFSContext's file-watcher callback) is
  just Parse — it has no notion of the project root, so a native
  watch-event path came out still fully absolute. Every downstream
  consumer (AssetRegistry, ImportCoordinator's importers) expects a
  workspace-relative VFSPath and prepends the workspace root itself,
  so the absolute one got double-prefixed. The watcher callback now
  strips the project root before parsing.
- TextureImporter::Import used path.ToNative() (bare separator
  conversion) instead of ResolveNative(workspace_root, ...) for its
  own file probe, which only "worked" by accident while the path
  above was wrongly absolute already. Fixed to resolve against the
  workspace root like every other importer's native probe.

A separate, still-open rendering bug (freshly-imported textures render
as flat unlit background color despite fully correct data end-to-end)
is tracked in #764 for dedicated GPU-level investigation.

Fixes #764

* fix(vfs): wire up VFSScanner so pre-existing project assets get registered at startup

InitWatcher was always called with cache=nullptr and scanner=nullptr,
so VFSScanner was never constructed anywhere in the live engine — not
even the reactive rescan-on-file-change ever ran (it's gated on
m_scanner being non-null). The only way an asset's UUID ever reached
AssetRegistry was a fresh import this session; anything cooked in a
previous session and never touched since was invisible to any
UUID-based lookup (e.g. IngestMaterialFromUUID from the previous
commit), even though its .zematerial/.zemesh file was sitting right
there on disk.

- Engine::Initialize now constructs a real VFSScanner + VFSDirectoryCache
  and wires the scanner to the registry, then passes both into
  InitWatcher instead of nullptr, nullptr.
- New VFSContext::ScanProject() walks the whole project once, via the
  now-live scanner, registering every asset it finds.
- GameApplication::Initialize calls ScanProject() right after the
  project's own VFS backend is actually mounted at "/" — mounting
  happens after Engine::Initialize returns, so scanning any earlier
  would hit an empty mount table and silently register nothing.

Confirmed via live process inspection: a material UUID that previously
returned nullptr from AssetRegistry::FindByUUID (reported during
manual testing of #766) now resolves to a Loaded record with a valid
slot, without any import happening this session.

* refactor(engine): move Engine::Initialize's static locals into EngineContext

Every importer, the VFS scanner/directory cache, the app pointer, the
render thread handle, and the two lifecycle flags were function-local
statics inside Engine::Initialize (or free functions in the same
translation unit) instead of living on the context object that already
exists to own engine-lifetime state. Moves them all onto EngineContext:

- GltfImporter/FbxImporter/AssimpImporter/EnvironmentMapImporter/
  TextureImporter, VFSDirectoryCache, VFSScanner — were static locals
  inside Initialize(), addresses taken and handed to ImportCoordinator/
  InitWatcher. EngineContext is arena-allocated once and never moved,
  so this is the same stable-address guarantee a static gave, just
  owned by the object whose lifetime they actually track.
- App, RenderThread, RequestTerminate, CloseRequested — were
  namespace-scope statics used across Initialize/Run/Deinitialize/
  MainThreadRun/RenderThreadRun. RequestTerminate/CloseRequested use
  PaddedAtomic<bool>, matching the rest of the codebase's convention
  for cross-thread flags instead of a bare std::atomic_bool.

g_engine_ctx itself stays a free static — it's the pointer *to* the
context, so it can't be a member of the thing it points to.

No behavior change: same construction timing (ZPushStructCtor still
placement-news real constructors), same addresses-are-stable-for-
engine-lifetime property, same call sites otherwise untouched.

* fix(gpu): stop DeviceGeometry/HostUniform VMA pools from exhausting immediately

Both pools used a fixed block size exactly equal to their known-largest
single allocation (DeviceGeometry: RRM's global vertex/index buffers,
512 MB each, blockSize=512 MB; HostUniform: PerFrameUploadHeap alone
requests a full 64 MB, blockSize=64 MB). A VMA block needs bookkeeping
headroom beyond its raw byte count, so a block exactly equal to an
allocation's size can't actually fit it — this exact failure mode was
already known and fixed for HostStaging, just not applied to these two.

In practice this meant every DeviceGeometry/HostUniform allocation fell
back to the default (unpooled) allocator from the very first frame —
"[GPU] Pool for domain N exhausted" fired at startup, before any user
action. Running entirely on the fallback path for the engine's two
busiest domains left no headroom for further growth: dragging a few
more meshes onto the viewport reliably triggered a GPU device-loss
timeout a few seconds later (VK_TIMEOUT / Lost VkDevice, observed
consistently after 3 drops).

Fixed by giving both pools blockSize=0 (auto-sized) and
maxBlockCount=0 (unlimited), matching DeviceTexture's already-correct
configuration for the same reason. Confirmed: pool-exhaustion warnings
are gone on a clean launch, and 8+ consecutive mesh drag-drops that
previously crashed within 3 now run without issue.

* fix(gpu): run ThreadPool worker init in-loop, fix ring buffer offset/retirement

ThreadPool::WorkerRun only checked the init callback once before entering
its loop, but RegisterWorkerInit is always called after workers are
already idle-waiting, so notify_one() never resumed at that check —
t_worker_slab stayed null on every real worker, silently skipping all
async texture pixel uploads. Move the check inside the loop, deduped by
last-seen callback pointer.

CommandBuffer::CopyBufferToImage hardcoded bufferOffset=0, so the GPU
copy for the shared-ring texture upload path always read from the start
of the ring buffer instead of the texture's actual ring_offset, reading
stale bytes. Plumb the offset through as a new parameter.

WriteTextureData's ring allocations were also never paired with a
Ring.Submit() call, leaving the ring unaware those regions were still in
flight. Thread an out_ring_offset through the upload call sites and
submit against the appropriate retirement marker for each queue path.

Fixes #764

* fix(rrm): dedicated batch timeline semaphore, drop m_upload_fences

Started as the #764 GPU-hang follow-up: added VulkanDevice::CheckDeviceLost
wired into every QueueSubmit/Present/AcquireNextImage call site plus a
render-loop guard that freezes safely instead of continuing to call into a
lost device (calling Vulkan after VK_ERROR_DEVICE_LOST is UB and has been
observed to segfault inside the loader). Extracted CommandBufferManager out
of VulkanDevice.h/.cpp so it's includable standalone.

Along the way, found and fixed a real, independently-confirmed bug: mesh/
font-atlas uploads reset and resubmitted a single shared command buffer
indefinitely across the life of the process, and three call sites freed
staging buffers immediately after a blocking fence wait even though a
fence signal isn't trustworthy proof of GPU completion once any command
buffer on the queue has already timed out. Gave uploads dedicated
per-frame command buffers and routed staging frees through timeline-gated
deferral instead. Also fixed a real VFSDiskBackend data race (VFSScanner
fans directory scans across ThreadPool workers against an unsynchronized
file-object pool).

Root cause of a separate, Windows-only bug (Intel driver reporting
UINT64_MAX / non-monotonic timeline semaphore values right after a mesh
drag-drop): RenderResourceManager's deferred batch upload was signalling
DeviceSwapchain::RenderTimeline, which Present() also drives independently
every frame — two writers on one timeline. Fixed by giving the batch its
own dedicated, single-writer timeline semaphore (m_batch_timeline) instead.

With that in place, unified the remaining synchronous, fence-blocking
upload paths (hot-reload mesh swaps, UpdateBuffer's staging sub-case) into
the same per-frame deferred batch mesh uploads already used, opened lazily
via EnsureBatchOpen and closed once per frame from the new RRM::EndFrame.
m_upload_fences (one fence per frame index) is gone; the two paths that
must stay synchronous — UpdateBuffer's ring sub-case (GpuAllocator::Ring
retires against a single hardcoded semaphore, so it can't safely move to a
second one without a larger change there) and UploadFontAtlas (runs once,
pre-render-thread) — now share one fence instead of a per-frame array,
since neither can ever be in flight while the other runs. RecordStagingCopy
gained the VkBufferMemoryBarrier it was missing, needed now that its
caller no longer just blocks until the copy is visible everywhere.

Also seeds Present()'s wait-semaphore aggregation with RenderTimeline's own
value instead of pushing it separately, so an AsyncGPUOperation that also
targets RenderTimeline merges instead of appearing twice in one submit's
wait list with two different values.

* refactor(rrm): drop RecordAndSubmit's dead wait-semaphore params

Its only remaining caller (UpdateBuffer's ring path) never passed them —
leftover from the deleted synchronous AppendToGlobalBuffer branch. Also
fixes a BeginBatchUpload comment that still named that deleted branch.

* fix(rrm): explicit BatchFrameState{} init, fixes GCC brace-init rejection
… add watermark (#768)

Three gaps in the bindless texture system closed (design decision in
bindless-descriptor-architecture.md, closes #665):

- zui_draw.frag was missing nonuniformEXT() around the TextureArray
  index despite requiring GL_EXT_nonuniform_qualifier — spec violation
  that produces silently wrong results on hardware with divergent warp
  indexing. g_buffer.frag already used it correctly.

- Present()'s descriptor update loop called vkUpdateDescriptorSets once
  per dequeued texture (N calls for N textures in the same frame). Now
  accumulates all writes across the full drain and emits one call.

- No observable signal existed when the 8192-slot pool approached
  exhaustion — HandleManager::Size() existed but nothing read it. Adds
  a one-shot WARN at 75% capacity (6144 slots) so pressure is caught
  before CreateTexture starts silently returning null handles.
…n, compaction, VRAM auto-detect (#769)

* feat(streaming): virtual geometry streaming — pool allocator, eviction, compaction, VRAM auto-detect

Closes #622.

GeometryPool (GeometryPool.h/.cpp): variable-size region allocator over
the streaming VB/IB. PoolAllocator-backed intrusive sorted free lists —
O(1) node alloc/free, first-fit search, merge-on-free, FragmentationRatio().
24 unit tests (GeometryPoolTest + GeometryStreamingManagerTest).

RRM wiring: replaced the append-only cursors with GeometryPool m_pool.
AppendMeshData calls m_pool.Allocate(); ReleaseMeshGeometry and
FlushPendingSwaps call m_pool.Free(). Hot-reload swap now frees the old
pool region before repointing the slot (leak fix). Swapped slot is
immediately set to Resident so it renders without a 1-frame gap.

Builtin geometry fix: RegisterBuiltinGeometry writes into separate pinned
m_builtin_vertex_buf / m_builtin_index_buf buffers, fixing a silent
corruption where ResetGeometryBuffers would overwrite builtin draw offsets
on scene reload.

StreamingState + draw guard: MeshSlot gains StreamingState
(Unloaded/Pending/Resident/Evicting), Referenced (clock-hand bit), and
Pinned. RenderScene calls IsMeshResident before emitting draw commands and
MarkMeshReferenced for every mesh that draws; non-resident meshes queue a
reload via RequestMeshLoad.

GeometryStreamingManager (GeometryStreamingManager.h/.cpp): clock-hand
eviction sweep (second-chance, one eviction per call), fragmentation-
triggered compaction request, SPSCQueue<StreamRequest,256>-backed
RequestLoad (render thread producer, Tick consumer), direct-execution
RequestEvict. Tick() called from RRM::BeginFrame.

Compaction (RunCompaction): resets the pool, re-uploads every Resident
mesh from CPU asset data into fresh packed regions via the existing
m_batch_timeline path. Triggered when fragmentation > 0.30.

VRAM auto-detection: DeriveGeometryBudget() computes 15% of the largest
device-local heap (PhysicalDeviceMemoryProperties), clamped to
[128, 512] MB per axis. Optional override via project.json
"memory": { "geometry_streaming_mb": N } — pre-parsed by Engine::Initialize
before RRM::Initialize runs. On Apple M4 Pro: auto-detects 512 MB per axis.

Private section reorganised for alignment: packed bool/uint8 batch fields
together to eliminate ~12 bytes of padding; mutexes co-located with the
data they protect; all methods in one contiguous block.

* fix(memory): default page_size=0 to 4096 in ArenaAllocator::Initialize

On Windows the commit-size mask
  (offset + size + m_mem_page_size - 1) & ~(m_mem_page_size - 1)
evaluates to 0 when m_mem_page_size is 0, causing VirtualAlloc(MEM_COMMIT, 0)
to fail. PoolAllocator::Initialize then hits its null-check assert and aborts.
macOS/Linux are immune because m_committed_size = size skips the commit block.

Fix: treat page_size=0 as 4096 (the default Windows page size) — the same
value all production callers already pass explicitly.

Fixes GeometryPool.FreshPoolIsEmpty crash on Windows CI.
…stry, YAMLSceneSerializer

Closes #713, #714, #715. Partial #719 (YAML tests deferred).

Adds the foundation of the scene serialization system:

- **SceneSnapshot** — lightweight snapshot struct (UUID, name, entity list); Create() factory ensures Entities is initialized before use
- **ISceneSerializer** — abstract Serialize/Deserialize interface over VFS paths
- **ComponentSerializerRegistry** — dynamic dispatch table mapping ComponentTypeID to C-style YAML/binary callbacks; arena-backed; Initialize(arena, capacity) guard prevents double-init
- **YAMLSceneSerializer** — editor-only (#ifdef ZENGINE_EDITOR); human-readable YAML via yaml-cpp; validates all *_uuid fields before mutating the scene; unknown component keys skipped for forward compatibility; entity count sized from the actual YAML sequence; DeserializeYAML callbacks must use safe yaml-cpp APIs (single YAML::Load exception boundary retained as unavoidable third-party constraint)

Reviewed and approved.
…ec4f (#771)

* refactor(rendering): drop GPUTypes.h — replace gpuvec3/gpuvec4 with Vec4f

gpuvec3 had no callers — dead code removed outright.
gpuvec4 was a 16-byte struct with only 4-byte alignment (alignof=4),
making it subtly wrong for GPU std140/std430 buffers if ever placed
after a non-16-byte member.

Fix: add alignas(4 * sizeof(T)) to Vec4 in Vec.h so Vec4f is always
16-byte aligned by the type itself, no per-member alignas needed.
Replace all gpuvec4 usages with Vec4f (MeshMaterial, GpuDirectionalLight,
GpuPointLight) and delete GPUTypes.h.

Array-subscript assignments that relied on gpuvec4::operator=(float[4])
are expanded to element-wise brace-init at the three call sites in
AssetManager.cpp.

* refactor(rendering): split RendererPasses into Graphics/ and Compute/, rename RenderPasses/ to Base/

* feat(rendering): IComputeCallbackPass base, update compute stubs

IComputeCallbackPass (Base/) is the helper base class for all compute
dispatch passes. It sits on top of IRenderGraphCallbackPass so compute
passes plug into the render graph identically to graphics passes:

- Setup()   → delegates to SetupCompute(device, res_builder)
- Compile() → stub for ComputePassBuilder + ComputePipeline (compute-pipeline.md §2/§5)
- Execute() → stub for pipeline bind + delegates to ExecuteCompute()

Subclasses implement only SetupCompute, ExecuteCompute, and GetShaderName.
GetPushConstantSize() is optional (default 0).

The four Compute/ stubs (FrustumCullingPass, SSAOPass, BloomPass,
SkinningPass) are updated to inherit IComputeCallbackPass and implement
the correct two-method interface instead of the graphics-only
IRenderGraphCallbackPass methods.

* refactor(rendering): flatten Contracts/ — RendererDataContract.h → RendererContracts.h

Moves UBOCameraLayout from the Contracts/ subdirectory up to Renderers/
(RendererContracts.h) and drops the Contracts:: namespace qualifier.
Deletes the now-empty Contracts/ directory.

All seven callers updated: GraphicRenderer.cpp and the five Graphics/
pass .cpp files that use sizeof(UBOCameraLayout) for SetDynamicUniform.

* style: remove section-divider comment from IComputeCallbackPass
… guard in VFSDiskBackend (#745) (#772)

#744: VFSZipFile::m_decompressed was a plain bool with no synchronization.
Two threads calling Read() concurrently on the same VFSZipFile could both
observe m_decompressed == false, both allocate and extract, and race on the
m_data/m_decompressed writes. Fixed with double-checked locking:
m_decompressed is now std::atomic<bool> (acquire load on fast path, release
store on write), guarded by a per-file m_decompress_mutex on the slow path.

#745: VFSDiskBackend::ResolveNativePath had no containment check — it was
pure string concatenation with no verification that the composed path stays
within m_native_root. VFSPath::Parse already rejects '..' segments so current
risk is low, but the backend's own design doc specifies this check as
defense-in-depth for any future code path that bypasses Parse. Added a
std::memcmp prefix check + separator guard after building the path.
…l sites

Adds a compile-time-removable macro that calls vkGetSemaphoreCounterValue
before every timeline semaphore signal and asserts two invariants:

1. current != UINT64_MAX  — Intel driver reports UINT64_MAX when the
   semaphore has already been corrupted by a prior non-monotonic signal.
   Firing here means corruption happened upstream.

2. next_signal_value > current — directly detects the non-monotonic
   signal that causes the UINT64_MAX corruption on Intel Windows.

Guards are placed at all 6 signal sites:
  - VulkanDevice::QueueSubmit (timeline overload) — covers all async
    texture upload submissions
  - DeviceSwapchain::Present frame_start_value — acquire bridge signal
  - DeviceSwapchain::Present work_complete_value — render work signal
  - RenderResourceManager::EndBatchUpload — m_batch_timeline signal
  - RenderResourceManager::UploadTextureBuffer (x2) — transfer and
    graphics texture timeline signals

The assert message names the semaphore and value so the crash log
identifies which site fires first, narrowing the root cause.
Previous assert only printed a static string — on Windows the engine
log is the only artifact before the crash. Now ZENGINE_CORE_ERROR logs
the semaphore handle address, current driver value, and attempted next
signal value so the log tells us:
  - 'ALREADY CORRUPTED': corruption happened upstream of this site
  - 'NON-MONOTONIC':     THIS site is the origin, with exact values

Also confirmed: assert fires at frame_start_value (first RenderTimeline
signal in Present()), meaning by the time that Present() runs, the GPU
has already processed a signal >= frame_start_value. Something between
the previous Present() and this one submitted to RenderTimeline with a
value that bypassed or overtook RenderTimelineNextValue.
Root cause of Intel Windows timeline semaphore corruption (UINT64_MAX):

submit2 (the present bridge) used VkTimelineSemaphoreSubmitInfo with
signalSemaphoreValueCount=1 and pSignalSemaphoreValues=&dummy_signal_val
where dummy_signal_val=0. The signal semaphore (render_complete) is BINARY
so the spec says pSignalSemaphoreValues[0] is ignored — but Intel's driver
was treating the zero as a timeline signal of value 0, which is non-monotonic
when RenderTimeline's current value > 0. The driver responds by setting the
semaphore's current value to UINT64_MAX, corrupting all subsequent signals.

Fix: signalSemaphoreValueCount=0 / pSignalSemaphoreValues=nullptr, since no
timeline semaphore is being signalled in submit2. The diagnostic guards from
the diag/timeline-semaphore-intel-guard branch confirmed this site via the
log: dummy signal value 334 was rejected with current=UINT64_MAX immediately
after a mesh was uploaded, right before the first ASSERT_TIMELINE_MONOTONIC
guard fired on the next Present() call.
…hore handles

Two new diagnostic probes:

1. Present() entry log: vkGetSemaphoreCounterValue before any logic so we
   can tell whether corruption arrived from OUTSIDE Present() (EndFrame,
   SubmitAsyncUploads, etc.) or from within it. If UINT64_MAX on entry,
   logs 'corruption happened OUTSIDE Present()'.

2. TRACE log in QueueSubmit(timeline): logs the signal semaphore handle,
   signal value, wait semaphore handle, and wait value for every async
   upload submission. Cross-referencing with the RenderTimeline handle
   from the DIAG-PRESENT log will reveal if any async submission is
   accidentally targeting RenderTimeline.
…ine self-wait

Two root causes of Intel Windows timeline semaphore corruption (UINT64_MAX):

1. submit_0 (acquire bridge): waitSemaphoreValueCount=1 with a BINARY wait
   semaphore (Acquired). Per spec the value is ignored for binary semaphores,
   but Intel's driver was reading ignored_wait_val=0 as a timeline wait at 0,
   corrupting RenderTimeline to UINT64_MAX.
   Fix: waitSemaphoreValueCount=0 / pWaitSemaphoreValues=nullptr.

2. submit_1 (render work): RenderTimeline appeared in both pWaitSemaphores
   (seeded into max_val_timeline_semaphores at frame_start_value) AND
   pSignalSemaphores (to work_complete_value). Intel corrupts semaphores to
   UINT64_MAX when the same semaphore is in both lists of one VkSubmitInfo.
   Fix: remove the RenderTimeline seed. submit_0 and submit_1 are on the
   same graphics queue — queue ordering ensures submit_0 completes before
   submit_1 begins without an explicit timeline wait. For NVIDIA
   (HasSeperateTransfertQueueFamily=true), m_tex_transfer_timelines remain
   in the wait list via AsyncGPUOperations drain for cross-queue texture sync.

submit_2 (present bridge) signalSemaphoreValueCount=0 fix is preserved.
Diagnostic overhead (DIAG-PRESENT, DIAG-SUBMIT1, DIAG-QSUBMIT TRACE) removed.
ASSERT_TIMELINE_MONOTONIC guards remain for Windows test verification.
…stage flag types

Replace vkQueueSubmit+VkTimelineSemaphoreSubmitInfo with vkQueueSubmit2+VkSemaphoreSubmitInfo
in Present() and QueueSubmit(timeline) — each semaphore carries its own VkSemaphoreSubmitInfo
struct, eliminating the parallel-array count ambiguity that caused Intel Windows driver
corruption (UINT64_MAX semaphore value).

Enable VkPhysicalDeviceSynchronization2Features in VkDeviceCreateInfo — the extension was
loaded but the feature flag was never set, causing vkQueueSubmit2 validation errors.

Fix type correctness: AsyncGPUOperationHandle::StageFlags, AsyncUploadJob::WaitFlag, and
QueueSubmit(timeline) wait_flag now use VkPipelineStageFlags2 instead of uint32_t.

Remove ASSERT_TIMELINE_MONOTONIC macro and all 6 call sites — root cause is fixed at the
API level; the per-frame vkGetSemaphoreCounterValue calls were expensive on MoltenVK.
…all on drag-drop

Texture uploads via AsyncUploadQueue now land in DeferredAsyncGPUOperations (SPSC) and
DeferredTextureDescriptorUpdates (SPSC) instead of the live queues. Present() runs before
SubmitAsyncUploads() so by the time Present() drains the deferred queues they only hold
prior-frame items; submit_1 waits on them at zero cost since the GPU transfer is done.

RequestDeferredDescriptorUpdate writes the fallback image view to the new slot synchronously
(PARTIALLY_BOUND set — no validation error) so the mesh renders with the fallback pink for
at most one frame while the upload is in-flight, then with the correct texture on the next.

FallbackDescriptorImageInfo is cached from GetOrCreateFallbackTexture so the fallback view
is always ready before any user-triggered texture upload can occur.
…er-thread file stall

DeserializeMeshAssetFile and IngestMaterialFromUUID are synchronous file reads that were
blocking the render thread on every drag-drop, causing a visible frame stall proportional
to mesh file size.

Move both to a ThreadPool worker via ThreadPoolHelper::Submit. A dedicated ArenaAllocator
(4x file size + 8 MB) is allocated up front and passed through a heap-allocated MeshPayload
struct so AssetMesh/AssetNodeHierarchy Array<T> data stays valid across the thread boundary
(scratch arenas are unsafe here — ZReleaseScratch frees the backing memory before the
callback runs). The main-thread callback via MainThreadScheduler::Post handles only the
lightweight IngestMesh + actor create, then shuts down and deletes the arena.
@JeanPhilippeKernel

Copy link
Copy Markdown
Owner Author

Closing — duplicate of #773 which targets develop correctly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants