diff --git a/apps/webgpu_app/App.cpp b/apps/webgpu_app/App.cpp index e1a19a291..fcd9e327b 100644 --- a/apps/webgpu_app/App.cpp +++ b/apps/webgpu_app/App.cpp @@ -49,6 +49,7 @@ #include #include #include +#include "webgpu/base/RenderGraph.h" namespace webgpu_app { @@ -217,20 +218,80 @@ void App::render() m_gputimer->start(encoder); m_frame_count++; - if (m_webgpu_window->needs_redraw() || m_force_repaint || m_force_repaint_once) { - m_webgpu_window->paint(m_framebuffer.get(), encoder); - m_repaint_count++; - m_force_repaint_once = false; + + const bool use_render_graph = g_use_render_graph; + + if (use_render_graph) { + + webgpu::rg::begin_frame(m_context->graph_allocator); + webgpu::rg::RenderGraph* rg = webgpu::rg::start_recording(m_context->graph_allocator); + g_render_graph = rg; + + // import the swapchain texture as an extern dependency + auto swapchain = rg->import_texture("swapchain", + { .view = surface_texture_view, .size = { m_viewport_size.x, m_viewport_size.y, 1 }, .format = viewDescriptor.format } + ); + + auto result = m_webgpu_window->paint(rg, true); + + // Blit the scene into the swapchain + rg->add_pass("blit", webgpu::rg::PassKind::Graphics, + [swapchain, result](webgpu::rg::PassBuilder& b) { + b.color(swapchain, 0, { .load = WGPULoadOp_Load }); + b.sampled(result); + }, + [&](auto& c) { + + webgpu::raii::BindGroup blit_bind_group(c.device, *m_gui_bind_group_layout, + { + c.bind(0, result), + m_gui_ubo->create_bind_group_entry(1), + }, + "blit"); + + wgpuRenderPassEncoderSetPipeline(c.render_pass, m_gui_pipeline.get()->pipeline().handle()); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, blit_bind_group.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); + + for (webgpu::rg::ErrorMessage* error = rg->compile(); error; error = error->next) + qCritical("%.*s", error->message.length, error->message.data); + + rg->execute(m_device, m_queue, encoder, false); + rg->collect_gpu_timings(); + } else { + + if (m_webgpu_window->needs_redraw() || m_force_repaint || m_force_repaint_once) { + m_webgpu_window->paint(m_framebuffer.get(), encoder); + m_repaint_count++; + m_force_repaint_once = false; + } } { - webgpu::raii::RenderPassEncoder render_pass(encoder, surface_texture_view, nullptr); - wgpuRenderPassEncoderSetPipeline(render_pass.handle(), m_gui_pipeline.get()->pipeline().handle()); - wgpuRenderPassEncoderSetBindGroup(render_pass.handle(), 0, m_gui_bind_group->handle(), 0, nullptr); - wgpuRenderPassEncoderDraw(render_pass.handle(), 3, 1, 0, 0); + WGPURenderPassColorAttachment gui_color_attachment {}; + gui_color_attachment.view = surface_texture_view; + gui_color_attachment.loadOp = WGPULoadOp_Load; + gui_color_attachment.storeOp = WGPUStoreOp_Store; + gui_color_attachment.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + + WGPURenderPassDescriptor gui_pass_desc {}; + gui_pass_desc.label = WGPUStringView { .data = "imgui pass", .length = WGPU_STRLEN }; + gui_pass_desc.colorAttachmentCount = 1; + gui_pass_desc.colorAttachments = &gui_color_attachment; + + WGPURenderPassEncoder gui_pass = wgpuCommandEncoderBeginRenderPass(encoder, &gui_pass_desc); + + if (!use_render_graph) { + // Legacy blit: sample m_framebuffer (static bind group) into the swapchain. + wgpuRenderPassEncoderSetPipeline(gui_pass, m_gui_pipeline.get()->pipeline().handle()); + wgpuRenderPassEncoderSetBindGroup(gui_pass, 0, m_gui_bind_group->handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(gui_pass, 3, 1, 0, 0); + } - // We add the GUI drawing commands to the render pass - m_gui_manager->render(render_pass.handle()); + m_gui_manager->render(gui_pass); + wgpuRenderPassEncoderEnd(gui_pass); + wgpuRenderPassEncoderRelease(gui_pass); } if (webgpu::isTimingSupported()) @@ -250,6 +311,11 @@ void App::render() m_cputimer->stop(); + // end the render-graph frame (only if we began one). Kept after submit so the graph's transient resources + // outlive the GPU work that references them. + if (use_render_graph) + webgpu::rg::end_frame(m_context->graph_allocator); + #ifndef __EMSCRIPTEN__ // Surface present in the WEB is handled by the browser! wgpuSurfacePresent(m_surface); @@ -280,7 +346,7 @@ void App::start() connect(m_camera_controller.get(), &nucleus::camera::Controller::definition_changed, m_context->ortho_scheduler(), &nucleus::tile::Scheduler::update_camera); connect(m_camera_controller.get(), &nucleus::camera::Controller::definition_changed, m_context->cloud_scheduler(), &nucleus::tile::Scheduler::update_camera); connect(m_camera_controller.get(), &nucleus::camera::Controller::definition_changed, m_webgpu_window.get(), &webgpu_engine::Window::update_camera); - + connect(m_context->geometry_scheduler(), &nucleus::tile::GeometryScheduler::gpu_tiles_updated, m_webgpu_window.get(), &webgpu_engine::Window::update_requested); connect(m_context->ortho_scheduler(), &nucleus::tile::TextureScheduler::gpu_tiles_updated, m_webgpu_window.get(), &webgpu_engine::Window::update_requested); connect(m_context->cloud_scheduler(), &nucleus::tile::Texture3DScheduler::gpu_tiles_updated, m_webgpu_window.get(), &webgpu_engine::Window::update_requested); diff --git a/apps/webgpu_app/CMakeLists.txt b/apps/webgpu_app/CMakeLists.txt index acd62046a..33a734864 100644 --- a/apps/webgpu_app/CMakeLists.txt +++ b/apps/webgpu_app/CMakeLists.txt @@ -36,6 +36,7 @@ set(SOURCES ui/ImGuiPanel.h ui/TimingPanel.h ui/TimingPanel.cpp + ui/RenderGraphPanel.h ui/RenderGraphPanel.cpp ui/CameraPanel.h ui/CameraPanel.cpp ui/AppPanel.h ui/AppPanel.cpp ui/ShadingPanel.h ui/ShadingPanel.cpp diff --git a/apps/webgpu_app/ImGuiManager.cpp b/apps/webgpu_app/ImGuiManager.cpp index d4fd67ad0..0db09e96f 100644 --- a/apps/webgpu_app/ImGuiManager.cpp +++ b/apps/webgpu_app/ImGuiManager.cpp @@ -41,6 +41,7 @@ #include "ui/CameraPanel.h" #include "ui/CompassPanel.h" #include "ui/LogoPanel.h" +#include "ui/RenderGraphPanel.h" #include "ui/SearchPanel.h" #include "ui/ShadingPanel.h" #include "ui/TimingPanel.h" @@ -57,6 +58,9 @@ namespace webgpu_app { +bool g_use_render_graph = false; +webgpu::rg::RenderGraph* g_render_graph = nullptr; + ImGuiManager::ImGuiManager(App* terrain_renderer) : m_terrain_renderer(terrain_renderer) { @@ -108,6 +112,8 @@ void ImGuiManager::init( #endif m_panels.push_back(std::make_unique(engine_ctx)); + m_panels.push_back(std::make_unique()); + connect(&search_panel, &SearchPanel::search_requested, rc->search_service(), &SearchService::search); connect(&search_panel, &SearchPanel::search_result_selected, diff --git a/apps/webgpu_app/ImGuiManager.h b/apps/webgpu_app/ImGuiManager.h index 2cc9b62ff..95a5c94fb 100644 --- a/apps/webgpu_app/ImGuiManager.h +++ b/apps/webgpu_app/ImGuiManager.h @@ -30,8 +30,15 @@ struct ImFont; #include "ui/ImGuiPanel.h" +namespace webgpu::rg { +struct RenderGraph; +} + namespace webgpu_app { +extern bool g_use_render_graph; +extern webgpu::rg::RenderGraph* g_render_graph; + class App; class ImGuiManager : public QObject { diff --git a/apps/webgpu_app/RenderingContext.cpp b/apps/webgpu_app/RenderingContext.cpp index 1d15aae0b..0debea18b 100644 --- a/apps/webgpu_app/RenderingContext.cpp +++ b/apps/webgpu_app/RenderingContext.cpp @@ -31,6 +31,7 @@ #include "webgpu/engine/overlay/HeightLinesOverlay.h" #include "webgpu/engine/overlay/OverlayRenderer.h" #include "webgpu/engine/overlay/TextureOverlay.h" +#include "webgpu/base/RenderGraph.h" #include "webgpu/engine/tile_mesh/TileMeshRenderer.h" #ifdef ALP_WEBGPU_APP_ENABLE_COMPUTE @@ -178,11 +179,17 @@ void RenderingContext::initialize(webgpu::Context& ctx) "OverlayRenderNode", [ctx = m_engine_context.get()](webgpu::Context&) { return std::make_unique(*ctx); }); #endif + graph_allocator = webgpu::rg::create_allocator(); + nucleus::utils::thread::async_call(this, [this]() { emit this->initialised(); }); } void RenderingContext::destroy() { + if (graph_allocator) { + webgpu::rg::destroy_allocator(graph_allocator); + graph_allocator = nullptr; + } if (!m_geometry_scheduler_holder.scheduler) return; diff --git a/apps/webgpu_app/RenderingContext.h b/apps/webgpu_app/RenderingContext.h index b47658375..54f2aadf6 100644 --- a/apps/webgpu_app/RenderingContext.h +++ b/apps/webgpu_app/RenderingContext.h @@ -33,6 +33,9 @@ namespace webgpu { class Context; } +namespace webgpu::rg { +struct GraphAllocator; +} namespace webgpu_engine { class Context; @@ -75,6 +78,7 @@ class RenderingContext : public QObject { nucleus::tile::TileLoadService* cloud_tile_load_service(); clouds::Manager* clouds_manager(); SearchService* search_service(); + webgpu::rg::GraphAllocator* graph_allocator {}; signals: void initialised(); diff --git a/apps/webgpu_app/ui/RenderGraphPanel.cpp b/apps/webgpu_app/ui/RenderGraphPanel.cpp new file mode 100644 index 000000000..f51661bcb --- /dev/null +++ b/apps/webgpu_app/ui/RenderGraphPanel.cpp @@ -0,0 +1,3078 @@ +/***************************************************************************** + * weBIGeo + * Copyright (C) 2026 Matthias Huerbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + +#include "RenderGraphPanel.h" + +#include "ImGuiManager.h" +#include +#include + +#include +#include +#include + +#include "webgpu/base/RenderGraph_internal.h" + + +using namespace webgpu; +using namespace webgpu::rg; +using namespace webgpu::rg::Internal; + + +#if defined(_MSC_VER) +#pragma warning(push) +#pragma warning(disable : 4456 4457 4100 4189 4505 4996) +#endif + +// short labels/tints for the node boxes +static const char* rg_kind_name(PassKind k) +{ + switch (k) { + case PassKind::Graphics: return "gfx"; + case PassKind::Compute: return "compute"; + case PassKind::Transfer: return "transfer"; + default: return "none"; + } +} +static ImU32 rg_kind_color(PassKind k) +{ + switch (k) { + case PassKind::Graphics: return IM_COL32(54, 96, 156, 255); + case PassKind::Compute: return IM_COL32(156, 96, 40, 255); + case PassKind::Transfer: return IM_COL32(56, 120, 70, 255); + default: return IM_COL32(90, 90, 90, 255); + } +} +static const char* rg_access_name(AccessType t) +{ + switch (t) { + case AccessType::ColorAttachment: return "color"; + case AccessType::DepthStencilAttachment: return "depth"; + case AccessType::DepthStencilReadOnly: return "depth(ro)"; + case AccessType::Sampled: return "sampled"; + case AccessType::StorageRead: return "storage(r)"; + case AccessType::StorageWrite: return "storage(w)"; + case AccessType::Uniform: return "uniform"; + case AccessType::CopySrc: return "copy(src)"; + case AccessType::CopyDst: return "copy(dst)"; + case AccessType::Vertex: return "vertex"; + case AccessType::Index: return "index"; + case AccessType::Indirect: return "indirect"; + } + return "?"; +} +// textures cool, buffers warm +static ImU32 rg_resource_color(ResourceKind k) +{ + return k == ResourceKind::Texture ? IM_COL32(70, 120, 170, 255) + : IM_COL32(170, 120, 60, 255); +} + +// short format label. anything this sample does not create falls back to the raw enum, so an unexpected +// recreate still shows something legible. +static const char* rg_format_name(WGPUTextureFormat f) +{ + switch (f) + { + // 8-bit formats + case WGPUTextureFormat_R8Unorm: return "R8Unorm"; + case WGPUTextureFormat_R8Snorm: return "R8Snorm"; + case WGPUTextureFormat_R8Uint: return "R8Uint"; + case WGPUTextureFormat_R8Sint: return "R8Sint"; + + // 16-bit formats + case WGPUTextureFormat_R16Uint: return "R16Uint"; + case WGPUTextureFormat_R16Sint: return "R16Sint"; + case WGPUTextureFormat_R16Float: return "R16Float"; + + case WGPUTextureFormat_RG8Unorm: return "RG8Unorm"; + case WGPUTextureFormat_RG8Snorm: return "RG8Snorm"; + case WGPUTextureFormat_RG8Uint: return "RG8Uint"; + case WGPUTextureFormat_RG8Sint: return "RG8Sint"; + + // 32-bit formats + case WGPUTextureFormat_R32Float: return "R32Float"; + case WGPUTextureFormat_R32Uint: return "R32Uint"; + case WGPUTextureFormat_R32Sint: return "R32Sint"; + + case WGPUTextureFormat_RG16Uint: return "RG16Uint"; + case WGPUTextureFormat_RG16Sint: return "RG16Sint"; + case WGPUTextureFormat_RG16Float: return "RG16Float"; + + case WGPUTextureFormat_RGBA8Unorm: return "RGBA8Unorm"; + case WGPUTextureFormat_RGBA8UnormSrgb: return "RGBA8UnormSrgb"; + case WGPUTextureFormat_RGBA8Snorm: return "RGBA8Snorm"; + case WGPUTextureFormat_RGBA8Uint: return "RGBA8Uint"; + case WGPUTextureFormat_RGBA8Sint: return "RGBA8Sint"; + + case WGPUTextureFormat_BGRA8Unorm: return "BGRA8Unorm"; + case WGPUTextureFormat_BGRA8UnormSrgb: return "BGRA8UnormSrgb"; + + case WGPUTextureFormat_RGB10A2Uint: return "RGB10A2Uint"; + case WGPUTextureFormat_RGB10A2Unorm: return "RGB10A2Unorm"; + case WGPUTextureFormat_RG11B10Ufloat: return "RG11B10Ufloat"; + + // 64-bit formats + case WGPUTextureFormat_RG32Float: return "RG32Float"; + case WGPUTextureFormat_RG32Uint: return "RG32Uint"; + case WGPUTextureFormat_RG32Sint: return "RG32Sint"; + + case WGPUTextureFormat_RGBA16Uint: return "RGBA16Uint"; + case WGPUTextureFormat_RGBA16Sint: return "RGBA16Sint"; + case WGPUTextureFormat_RGBA16Float: return "RGBA16Float"; + + // 128-bit formats + case WGPUTextureFormat_RGBA32Float: return "RGBA32Float"; + case WGPUTextureFormat_RGBA32Uint: return "RGBA32Uint"; + case WGPUTextureFormat_RGBA32Sint: return "RGBA32Sint"; + + // depth / stencil + case WGPUTextureFormat_Stencil8: return "Stencil8"; + case WGPUTextureFormat_Depth16Unorm: return "Depth16Unorm"; + case WGPUTextureFormat_Depth24Plus: return "Depth24Plus"; + case WGPUTextureFormat_Depth24PlusStencil8: return "Depth24PlusStencil8"; + case WGPUTextureFormat_Depth32Float: return "Depth32Float"; + case WGPUTextureFormat_Depth32FloatStencil8:return "Depth32FloatStencil8"; + + // BC compressed + case WGPUTextureFormat_BC1RGBAUnorm: return "BC1RGBAUnorm"; + case WGPUTextureFormat_BC1RGBAUnormSrgb: return "BC1RGBAUnormSrgb"; + case WGPUTextureFormat_BC2RGBAUnorm: return "BC2RGBAUnorm"; + case WGPUTextureFormat_BC2RGBAUnormSrgb: return "BC2RGBAUnormSrgb"; + case WGPUTextureFormat_BC3RGBAUnorm: return "BC3RGBAUnorm"; + case WGPUTextureFormat_BC3RGBAUnormSrgb: return "BC3RGBAUnormSrgb"; + case WGPUTextureFormat_BC4RUnorm: return "BC4RUnorm"; + case WGPUTextureFormat_BC4RSnorm: return "BC4RSnorm"; + case WGPUTextureFormat_BC5RGUnorm: return "BC5RGUnorm"; + case WGPUTextureFormat_BC5RGSnorm: return "BC5RGSnorm"; + case WGPUTextureFormat_BC6HRGBUfloat: return "BC6HRGBUfloat"; + case WGPUTextureFormat_BC6HRGBFloat: return "BC6HRGBFloat"; + case WGPUTextureFormat_BC7RGBAUnorm: return "BC7RGBAUnorm"; + case WGPUTextureFormat_BC7RGBAUnormSrgb: return "BC7RGBAUnormSrgb"; + + // ETC2 + case WGPUTextureFormat_ETC2RGB8Unorm: return "ETC2RGB8Unorm"; + case WGPUTextureFormat_ETC2RGB8UnormSrgb: return "ETC2RGB8UnormSrgb"; + case WGPUTextureFormat_ETC2RGB8A1Unorm: return "ETC2RGB8A1Unorm"; + case WGPUTextureFormat_ETC2RGB8A1UnormSrgb: return "ETC2RGB8A1UnormSrgb"; + case WGPUTextureFormat_ETC2RGBA8Unorm: return "ETC2RGBA8Unorm"; + case WGPUTextureFormat_ETC2RGBA8UnormSrgb: return "ETC2RGBA8UnormSrgb"; + case WGPUTextureFormat_EACR11Unorm: return "EACR11Unorm"; + case WGPUTextureFormat_EACR11Snorm: return "EACR11Snorm"; + case WGPUTextureFormat_EACRG11Unorm: return "EACRG11Unorm"; + case WGPUTextureFormat_EACRG11Snorm: return "EACRG11Snorm"; + + // ASTC + case WGPUTextureFormat_ASTC4x4Unorm: return "ASTC4x4Unorm"; + case WGPUTextureFormat_ASTC4x4UnormSrgb: return "ASTC4x4UnormSrgb"; + case WGPUTextureFormat_ASTC5x4Unorm: return "ASTC5x4Unorm"; + case WGPUTextureFormat_ASTC5x4UnormSrgb: return "ASTC5x4UnormSrgb"; + case WGPUTextureFormat_ASTC5x5Unorm: return "ASTC5x5Unorm"; + case WGPUTextureFormat_ASTC5x5UnormSrgb: return "ASTC5x5UnormSrgb"; + case WGPUTextureFormat_ASTC6x5Unorm: return "ASTC6x5Unorm"; + case WGPUTextureFormat_ASTC6x5UnormSrgb: return "ASTC6x5UnormSrgb"; + case WGPUTextureFormat_ASTC6x6Unorm: return "ASTC6x6Unorm"; + case WGPUTextureFormat_ASTC6x6UnormSrgb: return "ASTC6x6UnormSrgb"; + case WGPUTextureFormat_ASTC8x5Unorm: return "ASTC8x5Unorm"; + case WGPUTextureFormat_ASTC8x5UnormSrgb: return "ASTC8x5UnormSrgb"; + case WGPUTextureFormat_ASTC8x6Unorm: return "ASTC8x6Unorm"; + case WGPUTextureFormat_ASTC8x6UnormSrgb: return "ASTC8x6UnormSrgb"; + case WGPUTextureFormat_ASTC8x8Unorm: return "ASTC8x8Unorm"; + case WGPUTextureFormat_ASTC8x8UnormSrgb: return "ASTC8x8UnormSrgb"; + case WGPUTextureFormat_ASTC10x5Unorm: return "ASTC10x5Unorm"; + case WGPUTextureFormat_ASTC10x5UnormSrgb: return "ASTC10x5UnormSrgb"; + case WGPUTextureFormat_ASTC10x6Unorm: return "ASTC10x6Unorm"; + case WGPUTextureFormat_ASTC10x6UnormSrgb: return "ASTC10x6UnormSrgb"; + case WGPUTextureFormat_ASTC10x8Unorm: return "ASTC10x8Unorm"; + case WGPUTextureFormat_ASTC10x8UnormSrgb: return "ASTC10x8UnormSrgb"; + case WGPUTextureFormat_ASTC10x10Unorm: return "ASTC10x10Unorm"; + case WGPUTextureFormat_ASTC10x10UnormSrgb: return "ASTC10x10UnormSrgb"; + case WGPUTextureFormat_ASTC12x10Unorm: return "ASTC12x10Unorm"; + case WGPUTextureFormat_ASTC12x10UnormSrgb: return "ASTC12x10UnormSrgb"; + case WGPUTextureFormat_ASTC12x12Unorm: return "ASTC12x12Unorm"; + case WGPUTextureFormat_ASTC12x12UnormSrgb: return "ASTC12x12UnormSrgb"; + + default: + break; + } + + static char buf[32]; + std::snprintf(buf, sizeof(buf), "TextureFormat(%u)", (unsigned)f); + return buf; +} + +// usage bits the pool keys on +static void rg_usage_str(WGPUTextureUsage u, char* out, size_t n) +{ + std::snprintf(out, n, "%s%s%s%s%s", + (u & WGPUTextureUsage_RenderAttachment) ? "A" : "", + (u & WGPUTextureUsage_TextureBinding) ? "T" : "", + (u & WGPUTextureUsage_StorageBinding) ? "S" : "", + (u & WGPUTextureUsage_CopySrc) ? "r" : "", + (u & WGPUTextureUsage_CopyDst) ? "w" : ""); +} + +// usage bits the buffer usage keys on +static void rg_buf_usage_str(WGPUBufferUsage u, char* out, size_t n) +{ + std::snprintf(out, n, "%s%s%s%s%s%s%s", + (u & WGPUBufferUsage_Uniform) ? "U" : "", + (u & WGPUBufferUsage_Storage) ? "S" : "", + (u & WGPUBufferUsage_Vertex) ? "V" : "", + (u & WGPUBufferUsage_Index) ? "I" : "", + (u & WGPUBufferUsage_Indirect) ? "X" : "", + (u & WGPUBufferUsage_CopySrc) ? "r" : "", + (u & WGPUBufferUsage_CopyDst) ? "w" : ""); +} + +static uint64_t rg_entry_bytes(const TransientResourcePool::Entry& e) +{ + return texture_bytes(e.sig.size, e.sig.format, e.sig.mipLevelCount, e.sig.sampleCount, e.sig.dim); +} + +// byte count -> short human string. NOTE(Huerbe): no GB tier, a transient pool is a few MB. +static void rg_bytes_str(uint64_t bytes, char* out, size_t n) +{ + if (bytes >= (1u << 20)) std::snprintf(out, n, "%.1f MB", bytes / (1024.0 * 1024.0)); + else if (bytes >= (1u << 10)) std::snprintf(out, n, "%.1f KB", bytes / 1024.0); + else std::snprintf(out, n, "%llu B", (unsigned long long)bytes); +} + +// argb-alpha tweak + warm/cool access tints, shared by the DAG pins and the lifetime bars +static ImU32 rg_with_alpha(ImU32 c, ImU32 a) { return (c & ~IM_COL32_A_MASK) | (a << IM_COL32_A_SHIFT); } +static constexpr ImU32 kRGWrite = IM_COL32(232, 145, 64, 255); // write / output pin +static constexpr ImU32 kRGRead = IM_COL32(74, 158, 206, 255); // read / input pin +static constexpr ImU32 kRGExt = IM_COL32(118, 196, 132, 255); // external-input source node (imported read) +static constexpr ImU32 kRGPresent = IM_COL32(196, 122, 214, 255); // present / display sink node (imported output) +static constexpr ImU32 kRGDead = IM_COL32(196, 104, 92, 255); // graph-owned write nothing consumes (wasted store) + +// stable colour per group, hashed from the prefix +static ImU32 group_color(WGPUStringView prefix) +{ + uint32_t h = 2166136261u; + for (size_t i = 0; i < sv_length(prefix); ++i) { h ^= (uint8_t)prefix.data[i]; h *= 16777619u; } + static const ImU32 pal[] = { + IM_COL32(120, 180, 230, 230), IM_COL32(230, 170, 90, 230), IM_COL32(150, 210, 140, 230), + IM_COL32(210, 140, 200, 230), IM_COL32(220, 205, 110, 230), IM_COL32(140, 205, 210, 230), + }; + return pal[h % (sizeof pal / sizeof pal[0])]; +} + +// ID-stack-independent key for a group's collapse state. ImGui::GetID is seeded by the current window, +// so the pre-layout read and the in-canvas click write would hash one string to different ids. +static ImGuiID rg_grp_key(WGPUStringView prefix) +{ + ImU32 h = 2166136261u; + for (const char* s = "rg.grp."; *s; ++s) { h ^= (uint8_t)*s; h *= 16777619u; } + for (size_t i = 0; i < sv_length(prefix); ++i) { h ^= (uint8_t)prefix.data[i]; h *= 16777619u; } + return (ImGuiID)h; +} + +// DAG view ----------------------------------------------------------------------------------------- +// one box per pass in dependency columns, one pin per access (reads left, writes right), edges run +// producer-output -> consumer-input. hovering a pin lights the upstream producer cone. reads the internal +// node structs directly and assumes a compiled, realized graph. + +static constexpr int kRgDagMax = 128; +static constexpr int kRgGPinMax = 32; // max interface pins drawn on a collapsed group node (silently capped) + +// one laid-out pass box. its index in box[] is the execution-order index, so it doubles as the +// adjacency / cone index. +struct RgDagBox { PassNode* p; int layer; ImVec2 tl; float w, h; int nIn, nOut; }; + +// one access is one pin, writes land on the right +static bool rg_pass_writes(PassNode* p, uint32_t id) +{ + for (uint32_t i = 0; i < p->accessCount; ++i) + if (p->accesses[i].handle.id == id && access_is_write(p->accesses[i].type)) return true; + return false; +} + +// does this access consume the resource's prior contents, i.e. earn an input pin? plain reads do, and so +// does a LoadOp_Load attachment, which therefore gets BOTH an input and an output pin. +static bool rg_access_reads(const ResourceAccess& a) +{ + if (!access_is_write(a.type)) return true; + return (a.type == AccessType::ColorAttachment || a.type == AccessType::DepthStencilAttachment) + && a.loadOp == WGPULoadOp_Load; +} + +// 0 = no common write, 1 = at DIFFERENT subresources (parallel cascades, not a conflict), 2 = at the +// SAME subresource (a real WAW) +static int rg_shared_write(PassNode* a, PassNode* b) +{ + int r = 0; + for (uint32_t i = 0; i < a->accessCount; ++i) { + if (!access_is_write(a->accesses[i].type)) continue; + for (uint32_t j = 0; j < b->accessCount; ++j) { + if (!access_is_write(b->accesses[j].type) || a->accesses[i].handle.id != b->accesses[j].handle.id) continue; + if (a->accesses[i].baseLayer == b->accesses[j].baseLayer && a->accesses[i].baseMip == b->accesses[j].baseMip) return 2; + r = 1; + } + } + return r; +} + +// index of pass in box[], or -1 +static int rg_box_index(const RgDagBox* box, int n, PassNode* p) +{ + for (int i = 0; i < n; ++i) if (box[i].p == p) return i; + return -1; +} + +// the highest-execution-index writer of `id` among p's predecessors. compile() always links the RAW +// producer into adjacency and a later writer would start a version p cannot see, so the latest +// writer-predecessor IS the producer. -1 = no in-graph producer, so an external input pin. +static int rg_producer_of(const RgDagBox* box, int n, PassNode* p, uint32_t id) +{ + int best = -1; + for (NodeAdjacency* a = p->adjacency; a; a = a->next) { + if (!rg_pass_writes(a->pass, id)) continue; + int idx = rg_box_index(box, n, a->pass); + if (idx > best) best = idx; + } + return best; +} + +// output-pin slot index of resource id on pass p, or -1 +static int rg_out_slot(PassNode* p, uint32_t id) +{ + int slot = 0; + for (uint32_t k = 0; k < p->accessCount; ++k) + if (access_is_write(p->accesses[k].type)) { if (p->accesses[k].handle.id == id) return slot; ++slot; } + return -1; +} + +// the read side of rg_out_slot. matches the encounter-order slot the pin loop assigns, so it lands on +// the right pin centre. +static int rg_in_slot(PassNode* p, uint32_t id) +{ + int slot = 0; + for (uint32_t k = 0; k < p->accessCount; ++k) + if (rg_access_reads(p->accesses[k])) { if (p->accesses[k].handle.id == id) return slot; ++slot; } + return -1; +} + +// external interface of the pass run [gi, gj): reads produced outside become in-pins, writes consumed +// outside (or imported/persistent) become out-pins, interior resources get no pin. both lists dedup and +// cap at kRgGPinMax silently. one walk for every group node. +static void rg_group_interface(RenderGraph* rg, const RgDagBox* box, int n, int gi, int gj, + uint32_t* inId, int& nIn, uint32_t* outId, int& nOut) +{ + nIn = 0; nOut = 0; + for (int k = gi; k < gj; ++k) { + PassNode* p = box[k].p; + for (uint32_t ai = 0; ai < p->accessCount; ++ai) { + if (!rg_access_reads(p->accesses[ai])) continue; + uint32_t id = p->accesses[ai].handle.id; + int prod = rg_producer_of(box, n, p, id); + if (prod >= gi && prod < gj) continue; + bool seen = false; for (int s = 0; s < nIn; ++s) seen |= inId[s] == id; + if (!seen && nIn < kRgGPinMax) inId[nIn++] = id; + } + } + for (int k = gi; k < gj; ++k) { + PassNode* p = box[k].p; + for (uint32_t ai = 0; ai < p->accessCount; ++ai) { + if (!access_is_write(p->accesses[ai].type)) continue; + uint32_t id = p->accesses[ai].handle.id; + ResourceNode* r = find_node(rg, { id }); + bool external = r && (r->imported || r->persistent); // history write leaves to next frame -> group output + for (int j = 0; j < n && !external; ++j) { + if (j >= gi && j < gj) continue; + if (rg_in_slot(box[j].p, id) < 0) continue; + int pr = rg_producer_of(box, n, box[j].p, id); + if (pr >= gi && pr < gj) external = true; + } + if (!external) continue; + bool seen = false; for (int s = 0; s < nOut; ++s) seen |= outId[s] == id; + if (!seen && nOut < kRgGPinMax) outId[nOut++] = id; + } + } +} + +// the upstream producer cone: DFS over predecessor edges. iterative, a long chain would overflow the +// stack. +static void rg_mark_cone(const RgDagBox* box, int n, int seed, bool* inCone) +{ + if (seed < 0) return; + int stack[kRgDagMax], sp = 0; + stack[sp++] = seed; inCone[seed] = true; + while (sp) { + PassNode* p = box[stack[--sp]].p; + for (NodeAdjacency* a = p->adjacency; a; a = a->next) { + int idx = rg_box_index(box, n, a->pass); + if (idx >= 0 && !inCone[idx]) { inCone[idx] = true; stack[sp++] = idx; } + } + } +} + +// case-insensitive name filter +static bool rg_name_has(WGPUStringView name, const char* needle) +{ + if (!needle || !needle[0]) return true; + if (!name.data) return false; + size_t q = 0; while (needle[q]) ++q; + auto lc = [](char c) { return (c >= 'A' && c <= 'Z') ? char(c + 32) : c; }; + for (size_t i = 0; i + q <= name.length; ++i) { + size_t j = 0; while (j < q && lc(name.data[i + j]) == lc(needle[j])) ++j; + if (j == q) return true; + } + return false; +} + +// squared distance from p to segment ab, for edge hit-testing +static float rg_seg_d2(ImVec2 p, ImVec2 a, ImVec2 b) +{ + float vx = b.x - a.x, vy = b.y - a.y, wx = p.x - a.x, wy = p.y - a.y; + float L = vx * vx + vy * vy, t = L > 0 ? (wx * vx + wy * vy) / L : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + float dx = a.x + t * vx - p.x, dy = a.y + t * vy - p.y; + return dx * dx + dy * dy; +} + +// a true graph output: writes an imported resource whose value leaves the frame. compile()'s p->sink +// also fires for history-only writers, which are not graph outputs, so the halo skips them. +static bool rg_pass_is_sink(RenderGraph* rg, PassNode* p) +{ + for (uint32_t i = 0; i < p->accessCount; ++i) { + if (!access_is_write(p->accesses[i].type)) continue; + ResourceNode* r = find_node(rg, p->accesses[i].handle); + if (r && r->imported) return true; + } + return false; +} + +// shorten `s` until it fits `avail` px, marking the cut with "..". always keeps the START: clipping a +// right-aligned label from the left turns "clouds.lo_color" into "louds.lo_color", a different resource. +static void rg_fit_text(char* s, float avail) +{ + if (avail <= 0.0f) { s[0] = '\0'; return; } + if (ImGui::CalcTextSize(s).x <= avail) return; + for (size_t n = std::strlen(s); n >= 2; --n) { + s[n - 2] = '.'; s[n - 1] = '.'; s[n] = '\0'; // drop one real char, keep the ".." marker + if (ImGui::CalcTextSize(s).x <= avail) return; + } + s[0] = '\0'; // nothing fits +} + +// round for textures, square for buffers. filled = normal, hollow = external input. the square +// half-extent equals the circle radius, so one hover ring frames either shape. +static void rg_draw_pin(ImDrawList* dl, ImVec2 c, float r, ImU32 col, bool filled, bool buffer) +{ + if (buffer) { + ImVec2 a(c.x - r, c.y - r), b(c.x + r, c.y + r); + if (filled) dl->AddRectFilled(a, b, col, 1.5f); + else dl->AddRect(a, b, col, 1.5f, 0, 2.0f); + } + else { + if (filled) dl->AddCircleFilled(c, r, col, 12); + else dl->AddCircle(c, r, col, 12, 2.0f); + } +} + +// signed horizontal control-point offset for an edge bezier. a plain dx*0.5 suits horizontal hops, but +// once the drop dwarfs the run it draws a detour around an obstacle that is not there. fade the tangent +// out with the run/drop ratio, floored so shallow hops keep the rounded look. MUST be shared by the draw +// and the hit-test, or hovering stops matching where the edge is drawn. +static float rg_edge_tangent(ImVec2 a, ImVec2 b) +{ + const float dx = b.x - a.x, dy = b.y - a.y; + const float adx = dx < 0 ? -dx : dx, ady = dy < 0 ? -dy : dy; + float k = ady > 1e-3f ? adx / ady : 1.0f; + k = k < 0.35f ? 0.35f : (k > 1.0f ? 1.0f : k); + return dx * 0.5f * k; +} + +// ImDrawList has no dashed stroke, so sample the curve and draw every other span. marks the history +// feedback links as cross-frame. +static void rg_dashed_cubic(ImDrawList* dl, ImVec2 p0, ImVec2 p1, ImVec2 p2, ImVec2 p3, ImU32 col, float th) +{ + constexpr int kSeg = 24; + ImVec2 prev = p0; + for (int i = 1; i <= kSeg; ++i) { + float t = (float)i / kSeg, u = 1 - t, w0 = u*u*u, w1 = 3*u*u*t, w2 = 3*u*t*t, w3 = t*t*t; + ImVec2 q(w0*p0.x + w1*p1.x + w2*p2.x + w3*p3.x, w0*p0.y + w1*p1.y + w2*p2.y + w3*p3.y); + if (i & 1) dl->AddLine(prev, q, col, th); // every other span = a dash + prev = q; + } +} + +// arrowhead at `tip`, marking the read end of a feedback link +static void rg_arrowhead(ImDrawList* dl, ImVec2 from, ImVec2 tip, ImU32 col, float sz) +{ + float dx = tip.x - from.x, dy = tip.y - from.y, L = std::sqrt(dx*dx + dy*dy); + if (L < 1e-3f) return; + dx /= L; dy /= L; + ImVec2 a(tip.x - dx*sz - dy*sz*0.5f, tip.y - dy*sz + dx*sz*0.5f); + ImVec2 b(tip.x - dx*sz + dy*sz*0.5f, tip.y - dy*sz - dx*sz*0.5f); + dl->AddTriangleFilled(tip, a, b, col); +} + +// ---- DAG intermediate representation. a node is a pass or a virtual endpoint, linked by RgEdges. the +// pass-graph (box[]) still drives layout, the draw consumes this IR for the nodes + virtual links. groups +// live in the GView side-table. see docs/rendergraph-nested-layout.md. +struct RgNode { + enum class Kind : uint8_t { Pass, Group, Virtual }; + Kind kind{}; + PassNode* pass{}; // kind::Pass + ResourceNode* res{}; // kind::Virtual (the endpoint's resource) + WGPUStringView label{}; // kind::Virtual caption + ImVec2 pos{}; float w = 0, h = 0; int col = 0; // layout result -> consumed by draw + ImU32 tint = 0; // kind::Virtual base colour (read/write/imported/present) +}; +struct RgEdge { + int srcNode = -1, dstNode = -1; uint16_t srcPin = 0, dstPin = 0; uint32_t resId = 0; + enum class Kind : uint8_t { Raw, History, Fanout } kind = Kind::Raw; +}; + +// barycenter crossing-reduction + y-relax over a layered node set (Sugiyama). lcol[c] = node indices in +// column c, reordered in place. lleft/lright[i] = neighbours, h[i] = height, writes cy[i]. shared by the +// top-level layout and each expanded group's interior. +// clus/clusPar: per-node cluster id + per-cluster parent. keeps every cluster's members contiguous, so an +// expanded group's members never interleave with outsiders and its border stays a clean hull. +// thin: this layered node is an edge lane, not a box. charging a lane a full row inflates every column an +// edge crosses, shoving real boxes apart for traffic. +static void rg_barycenter_relax(std::vector* lcol, int maxCol, + const std::vector>& lleft, const std::vector>& lright, + const std::vector& h, std::vector& cy, float rowGap, + const int* clus = nullptr, const int* clusPar = nullptr, int nClus = 0, const char* thin = nullptr) +{ + int LN = (int)cy.size(); + static std::vector lrank; lrank.assign(LN, 0); + auto reix = [&](int c) { for (int r = 0; r < (int)lcol[c].size(); ++r) lrank[lcol[c][r]] = r; }; + for (int c = 0; c <= maxCol; ++c) reix(c); + auto bnb = [&](int li, bool right) { const std::vector& nb = right ? lright[li] : lleft[li]; float s = 0; int c = 0; for (int x : nb) { s += lrank[x]; ++c; } return c ? s / c : -1.0f; }; + auto lsweep = [&](bool backward) { + int from = backward ? maxCol - 1 : 1, to = backward ? -1 : maxCol + 1, step = backward ? -1 : 1; + for (int c = from; c != to; c += step) { + std::vector& m = lcol[c]; + std::vector key(m.size()); + for (int j = 0; j < (int)m.size(); ++j) { float b = bnb(m[j], backward); key[j] = b < 0 ? (float)lrank[m[j]] : b; } + for (int a = 1; a < (int)m.size(); ++a) { int mv = m[a]; float kv = key[a]; int b = a - 1; while (b >= 0 && key[b] > kv) { m[b + 1] = m[b]; key[b + 1] = key[b]; --b; } m[b + 1] = mv; key[b + 1] = kv; } + reix(c); + } + }; + lsweep(true); lsweep(false); lsweep(true); + // re-sort each column so a cluster's members stay together. clusters are weighted by their members' + // mean rank, so a group still floats to its barycenter, it just moves as one block. + if (clus) { + // root..innermost chain of cluster ids, depth returned. shallow, path length <= ~4. + auto chain = [&](int li, int* out) -> int { + int tmp[16], d = 0; for (int c = clus[li]; c >= 0 && d < 16; c = clusPar[c]) tmp[d++] = c; + for (int i = 0; i < d; ++i) out[i] = tmp[d - 1 - i]; return d; + }; + static std::vector cavg; static std::vector ccnt; + for (int c = 0; c <= maxCol; ++c) { + std::vector& m = lcol[c]; + cavg.assign(nClus, 0.0f); ccnt.assign(nClus, 0); + for (int li : m) { int ch[16]; int d = chain(li, ch); for (int i = 0; i < d; ++i) { cavg[ch[i]] += lrank[li]; ++ccnt[ch[i]]; } } + for (int x = 0; x < nClus; ++x) if (ccnt[x]) cavg[x] /= ccnt[x]; + // compare down the cluster paths, weighting a shared-prefix cluster by its mean rank and a + // node's own level by its rank. first differing weight decides, ties keep stable order. + auto before = [&](int a, int b) -> bool { + int ca[16], cb[16]; int da = chain(a, ca), db = chain(b, cb); + for (int d = 0;; ++d) { + if (d < da && d < db && ca[d] == cb[d]) continue; // same cluster here -> go deeper + float wa = d < da ? cavg[ca[d]] : (float)lrank[a]; + float wb = d < db ? cavg[cb[d]] : (float)lrank[b]; + if (wa != wb) return wa < wb; + return lrank[a] < lrank[b]; + } + }; + for (int i = 1; i < (int)m.size(); ++i) { int v = m[i], j = i - 1; while (j >= 0 && before(v, m[j])) { m[j + 1] = m[j]; --j; } m[j + 1] = v; } + reix(c); + } + } + // gap only BETWEEN two real nodes. a folded member is zero-height and must not reserve a row, or a + // collapsed group leaves a tall empty column. an edge lane gets a sliver, since charging it a whole + // rowGap scaled a column's height with the traffic crossing it. + auto gap = [&](int a, int b) { + if (h[a] <= 0.5f || h[b] <= 0.5f) return 0.0f; + if (thin && (thin[a] || thin[b])) return rowGap * 0.5f; + return rowGap; + }; + for (int c = 0; c <= maxCol; ++c) { float y = 0; int prev = -1; for (int li : lcol[c]) { if (prev >= 0) y += gap(prev, li); cy[li] = y + h[li] * 0.5f; y += h[li]; prev = li; } } + for (int it = 0; it < 8; ++it) + for (int c = 0; c <= maxCol; ++c) { + std::vector& m = lcol[c]; + for (int r = 0; r < (int)m.size(); ++r) { + int li = m[r]; float s = 0; int cnt = 0; + for (int x : lleft[li]) { s += cy[x]; ++cnt; } + for (int x : lright[li]) { s += cy[x]; ++cnt; } + if (!cnt) continue; + float d = s / cnt; + float lo = r > 0 ? cy[m[r - 1]] + (h[m[r - 1]] + h[li]) * 0.5f + gap(m[r - 1], li) : -1e30f; + float hi = r + 1 < (int)m.size() ? cy[m[r + 1]] - (h[li] + h[m[r + 1]]) * 0.5f - gap(li, m[r + 1]) : 1e30f; + cy[li] = d < lo ? lo : d > hi ? hi : d; + } + } +} + +// the first `segs` dotted segments of `name`: "bloom.down.0" with segs=2 -> "bloom.down". empty if the +// name has fewer. generalizes group_prefix (the segs==1 case) so a group tree can partition each level. +static WGPUStringView group_prefix_n(WGPUStringView name, int segs) +{ + size_t n = sv_length(name); int seen = 0; + for (size_t i = 0; i < n; ++i) + if (name.data[i] == '.' && ++seen == segs) return WGPUStringView{ name.data, i }; + return (seen + 1 == segs) ? name : WGPUStringView{}; // exactly `segs` segments -> whole name is the prefix +} + +// a contiguous run [gi, gj) of box[], contiguous because passes are declared grouped. gtree[0] is the +// root. kids are deeper sub-runs, and passes no kid covers are this node's leaf members. layout fields are +// filled post-order by layout_gnode, relative to the node's content origin. +struct GNode { + WGPUStringView prefix; // full dotted prefix at this depth ("bloom", "bloom.down"); empty at root + int gi, gj, depth; + bool collapsed; + std::vector kids; // child GNode indices in the gtree arena + float w, h; // box (collapsed) or region (expanded) size, content-local + int nIn, nOut; + uint32_t inId[kRgGPinMax], outId[kRgGPinMax]; + float inY[kRgGPinMax], outY[kRgGPinMax]; // interface pin y, relative to the node top +}; + +// build the group tree over box[gi, gj), returning the new node's index. partitions into maximal sub-runs +// sharing their first depth+1 segments, and a run of >=2 that is not the whole range becomes a child. +// collapse state is the tri-state ImGuiStorage the draw uses: 0 follow-default, 1 open, 2 closed. +static int build_gtree(std::vector& gtree, const RgDagBox* box, int n, + ImGuiStorage* grpStore, bool collapseDefault, int gi, int gj, int depth, WGPUStringView prefix) +{ + int self = (int)gtree.size(); + gtree.push_back(GNode{}); + GNode g{}; + g.prefix = prefix; g.gi = gi; g.gj = gj; g.depth = depth; + if (sv_length(prefix)) { int st = grpStore->GetInt(rg_grp_key(prefix), 0); g.collapsed = st == 2 ? true : st == 1 ? false : collapseDefault; } + for (int a = gi; a < gj;) { + WGPUStringView sub = group_prefix_n(box[a].p->id.name, depth + 1); + int b = a + 1; + while (b < gj && sv_length(sub) && sv_eq(group_prefix_n(box[b].p->id.name, depth + 1), sub)) ++b; + if (sv_length(sub) && b - a >= 2 && !(a == gi && b == gj)) + g.kids.push_back(build_gtree(gtree, box, n, grpStore, collapseDefault, a, b, depth + 1, sub)); + a = b; + } + gtree[self] = std::move(g); + return self; +} + +// the OUTERMOST collapsed group ancestor of pass k, -1 if visible at every level. a pass with an owner is +// hidden, and the owner's first member is the rep standing in for the whole subtree. top-level and nested +// collapse differ only in where the rep cell lands, not in how membership is decided. +static void rg_mark_collapsed(const std::vector& gt, int* collOwner, int node, int owner) +{ + const GNode& g = gt[node]; + int myOwner = owner; + if (owner < 0 && sv_length(g.prefix) && g.collapsed) myOwner = node; + if (myOwner >= 0) for (int k = g.gi; k < g.gj; ++k) if (collOwner[k] < 0) collOwner[k] = myOwner; + for (int kid : g.kids) rg_mark_collapsed(gt, collOwner, kid, myOwner); +} + +// the innermost EXPANDED gtree node containing pass k, and its parent. drives the cluster-contiguity +// ordering so an expanded group and its expanded subgroups lay out as one block. a collapsed group folds, +// so its subtree is not descended and members keep their nearest expanded ancestor. +static void rg_assign_clusters(const std::vector& gt, int* clusterOf, int* clusterParent, int node) +{ + const GNode& g = gt[node]; + for (int kid : g.kids) clusterParent[kid] = node; + if (sv_length(g.prefix)) { + if (g.collapsed) return; // folded subtree -> members keep the parent expanded cluster + for (int k = g.gi; k < g.gj; ++k) clusterOf[k] = node; + } + for (int kid : g.kids) rg_assign_clusters(gt, clusterOf, clusterParent, kid); +} + +// leaves a box horizontally, S-curves to a flat top lane, runs across, S-curves back down. horizontal +// tangents at the pins AND the plateau, so no corner is sharp. +static void rg_over_arc(ImDrawList* dl, ImVec2 a, ImVec2 b, float topY, ImU32 col, float th, float maxSh) +{ + float dx = b.x - a.x, adx = dx < 0 ? -dx : dx, dir = dx < 0 ? -1.0f : 1.0f; + float sh = adx * 0.5f; if (sh > maxSh) sh = maxSh; // shoulder run; shrinks for short hops so the two S's don't overlap + ImVec2 aTop(a.x + dir * sh, topY), bTop(b.x - dir * sh, topY); + float m1 = a.x + dir * sh * 0.5f, m2 = b.x - dir * sh * 0.5f; + dl->AddBezierCubic(a, ImVec2(m1, a.y), ImVec2(m1, topY), aTop, col, th); + if ((bTop.x - aTop.x) * dir > 0.5f) dl->AddLine(aTop, bTop, col, th); + dl->AddBezierCubic(bTop, ImVec2(m2, topY), ImVec2(m2, b.y), b, col, th); +} + +static void rg_draw_dag(RenderGraph* rg, RenderGraphStorage& s) +{ + float kBoxW = 190.0f, kColGap = 65.0f, kRowGap = 50.0f; + float kHeaderH = 22.0f, kFooterH = 14.0f, kPinRowH = 18.0f, kMinBodyH = 12.0f; + float kPinR = 5.0f, kPinHit = 8.0f; + float kRegionPad = 24.0f; // padding from a group's member bbox out to its hull border + + // resolved BEFORE layout, so positions, scrolling and font all use one zoom this frame. resolving it + // after layout drew the graph at the old scale for a frame and flickered. + static ImVec2 scrolling(0, 0); + static bool userMoved = false; // true once the user pans/zooms; until then keep the graph centred + static bool s_canvasHovered = false; // last frame's hover (input is read before the canvas item exists) + static ImVec2 s_winPos(0, 0); // last frame's canvas top-left, for the cursor-anchored zoom + + // 1.0 is closest, the authored sizes, and the wheel only zooms OUT toward the cursor. every layout + // constant scales by it so layout and draw stay one coordinate system. text scales separately. + static float zoom = 1.0f; + { + ImGuiIO& io = ImGui::GetIO(); + if (s_canvasHovered && io.MouseWheel != 0.0f) { + float z0 = zoom; + zoom *= io.MouseWheel > 0 ? 1.1f : 1.0f / 1.1f; + if (zoom > 1.0f) zoom = 1.0f; + if (zoom < 0.2f) zoom = 0.2f; + float r = zoom / z0; // positions rescale by r this frame; anchor the canvas point under the cursor + scrolling.x = io.MousePos.x - s_winPos.x - (io.MousePos.x - s_winPos.x - scrolling.x) * r; + scrolling.y = io.MousePos.y - s_winPos.y - (io.MousePos.y - s_winPos.y - scrolling.y) * r; + userMoved = true; + } + } + const float z = zoom; + kBoxW *= z; kColGap *= z; kRowGap *= z; + kHeaderH *= z; kFooterH *= z; kPinRowH *= z; kMinBodyH *= z; + kPinR *= z; kPinHit *= z; + kRegionPad *= z; + + + // frame-boundary endpoints, toggled from the toolbar. read before layout, so disabling them also drops + // their layout influence. + static bool showVirtual = true; + // imported buffers: on = one source node fanning faint edges to every reader, off = a node per use + // site. read here so it shapes the layout too. + static bool fanBuffers = true; + // read before layout, so a collapsed group reserves its compact slot the same frame the checkbox flips + static bool collapseDefault = false; + // captured in the OUTER window so the pre-layout reads and the in-canvas click writes hit one store + ImGuiStorage* grpStore = ImGui::GetStateStorage(); + + // ---- layout pass, sink-anchored Sugiyama. column = longest distance TO a sink over the FULL + // dependency graph, so sinks land rightmost and a WAW-ordered pass still sits in order rather than + // floating off as a fake sink. crossing reduction counts only the DRAWN edges, since WAW has no pin to + // cross. one barycenter sweep back from the sinks, then a settle. + RgDagBox box[kRgDagMax]; + int n = 0; + for (PassNode* p = s.m_passes; p && n < kRgDagMax; p = p->next, ++n) { + int nIn = 0, nOut = 0; // a Load attachment counts as both (read-modify-write) + for (uint32_t k = 0; k < p->accessCount; ++k) { + if (rg_access_reads(p->accesses[k])) ++nIn; + if (access_is_write(p->accesses[k].type)) ++nOut; + } + int rows = nIn > nOut ? nIn : nOut; + float h = kHeaderH + (rows ? rows * kPinRowH : kMinBodyH) + kFooterH; + box[n] = { p, 0, ImVec2(0, 0), kBoxW, h, nIn, nOut }; + } + // so the toolbar can flag a graph larger than the DAG cap, which truncates silently + int rgPassTotal = 0; for (PassNode* q = s.m_passes; q; q = q->next) ++rgPassTotal; + + // longest path to a sink over all deps. reverse exec order visits sinks first and relaxes each + // producer, then column = maxDist - dist. + int dist[kRgDagMax] = {}, maxDist = 0; + for (int i = n - 1; i >= 0; --i) { + for (NodeAdjacency* a = box[i].p->adjacency; a; a = a->next) { + int q = rg_box_index(box, n, a->pass); + if (q >= 0 && dist[i] + 1 > dist[q]) dist[q] = dist[i] + 1; + } + if (dist[i] > maxDist) maxDist = dist[i]; + } + + // passes writing one resource at DIFFERENT subresources with no data dependency, e.g. CSM cascades. + // the whole-resource WAW would string them across columns, so union them onto one level instead. + // anc[v][u] means u is a transitive ancestor of v, so a real chain like a mip pyramid is not + // mistaken for parallel. + static bool anc[kRgDagMax][kRgDagMax]; + for (int v = 0; v < n; ++v) for (int u = 0; u < n; ++u) anc[v][u] = false; + for (int v = 0; v < n; ++v) { + PassNode* p = box[v].p; + for (uint32_t k = 0; k < p->accessCount; ++k) { + if (!rg_access_reads(p->accesses[k])) continue; + int u = rg_producer_of(box, n, p, p->accesses[k].handle.id); + if (u < 0) continue; + anc[v][u] = true; + for (int w = 0; w < n; ++w) if (anc[u][w]) anc[v][w] = true; + } + } + int groupRep[kRgDagMax]; + for (int i = 0; i < n; ++i) groupRep[i] = i; + auto find = [&](int x) { while (groupRep[x] != x) { groupRep[x] = groupRep[groupRep[x]]; x = groupRep[x]; } return x; }; + for (int i = 0; i < n; ++i) + for (int j = i + 1; j < n; ++j) + if (rg_shared_write(box[i].p, box[j].p) == 1 && !anc[i][j] && !anc[j][i]) groupRep[find(i)] = find(j); + for (int i = 0; i < n; ++i) groupRep[i] = find(i); // flatten to roots + + // snap each group to its rightmost member so siblings share one level, then compact the empties + int colOf[kRgDagMax], groupCol[kRgDagMax] = {}; + for (int i = 0; i < n; ++i) colOf[i] = maxDist - dist[i]; + for (int i = 0; i < n; ++i) if (colOf[i] > groupCol[groupRep[i]]) groupCol[groupRep[i]] = colOf[i]; + for (int i = 0; i < n; ++i) colOf[i] = groupCol[groupRep[i]]; + int remap[kRgDagMax]; for (int c = 0; c <= maxDist; ++c) remap[c] = -1; + for (int i = 0; i < n; ++i) remap[colOf[i]] = 1; + int nextCol = 0; for (int c = 0; c <= maxDist; ++c) if (remap[c] == 1) remap[c] = nextCol++; + for (int i = 0; i < n; ++i) colOf[i] = remap[colOf[i]]; + maxDist = nextCol ? nextCol - 1 : 0; + + // built here, where columns are final, and consumed by the layout + draw. NOTE(Huerbe): not yet wired + // into layout, the reservation blocks below still run. this only stands the structure up. + static std::vector gtree; gtree.clear(); + build_gtree(gtree, box, n, grpStore, collapseDefault, 0, n, 0, WGPUStringView{}); + IM_ASSERT(gtree[0].gi == 0 && gtree[0].gj == n); + int collOwner[kRgDagMax]; for (int i = 0; i < n; ++i) collOwner[i] = -1; + rg_mark_collapsed(gtree, collOwner, 0, -1); + // a pass inside ANY collapsed group is not drawn as its own box, the group draws one node in its place + bool drawHidden[kRgDagMax]; for (int i = 0; i < n; ++i) drawHidden[i] = collOwner[i] >= 0; + + // ---- collapse folding, pre-layout. every OUTERMOST collapsed group folds to ONE node at its rep + // cell, and the other members get zero height and snap onto the rep's column so they reserve nothing. + // EXPANDED groups reserve no slot: their members stay first-class nodes in their own columns, held + // together by the cluster-contiguity pass and hulled later. + float effH[kRgDagMax]; + for (int i = 0; i < n; ++i) effH[i] = box[i].h; + for (int o = 0; o < (int)gtree.size(); ++o) { + GNode& g = gtree[o]; + if (!sv_length(g.prefix) || !g.collapsed) continue; + if (collOwner[g.gi] != o) continue; // shadowed by a collapsed ancestor -> that outer group is the rep + uint32_t inId[kRgGPinMax], outId[kRgGPinMax]; int ni = 0, no = 0; + rg_group_interface(rg, box, n, g.gi, g.gj, inId, ni, outId, no); + int rows = ni > no ? ni : no; + float compactH = kHeaderH + (rows ? rows * kPinRowH : kMinBodyH) + kFooterH; + int colA = colOf[g.gi]; for (int k = g.gi; k < g.gj; ++k) if (colOf[k] < colA) colA = colOf[k]; // leftmost member column + for (int k = g.gi; k < g.gj; ++k) { effH[k] = (k == g.gi ? compactH : 0.0f); colOf[k] = colA; } + } + + // cluster ids + parent links for the layout ordering + int clusterOf[kRgDagMax]; for (int i = 0; i < n; ++i) clusterOf[i] = -1; + static std::vector clusterParent; clusterParent.assign(gtree.size(), -1); + rg_assign_clusters(gtree, clusterOf, clusterParent.data(), 0); + + // recompute columns with each collapsed group contracted to its rep: an edge inside costs 0, a + // crossing edge 1. a consumer of a collapsed sub-chain then lands right after the rep instead of where + // the unfolded chain stranded it. expanded groups reproduce the original columns exactly. + { + int rep[kRgDagMax]; for (int i = 0; i < n; ++i) rep[i] = collOwner[i] >= 0 ? gtree[collOwner[i]].gi : i; + int dist2[kRgDagMax]; for (int i = 0; i < n; ++i) dist2[i] = 0; int maxD2 = 0; + for (int i = n - 1; i >= 0; --i) + for (NodeAdjacency* a = box[i].p->adjacency; a; a = a->next) { + int q = rg_box_index(box, n, a->pass); if (q < 0) continue; // a->pass = producer of i + int w = rep[i] == rep[q] ? 0 : 1; + if (dist2[rep[i]] + w > dist2[rep[q]]) dist2[rep[q]] = dist2[rep[i]] + w; + } + for (int i = 0; i < n; ++i) if (dist2[rep[i]] > maxD2) maxD2 = dist2[rep[i]]; + for (int i = 0; i < n; ++i) colOf[i] = maxD2 - dist2[rep[i]]; // members of a group share their rep's column + // re-snap parallel-writer groups to one column, then compact the empties + int gc[kRgDagMax]; for (int i = 0; i < n; ++i) gc[i] = 0; + for (int i = 0; i < n; ++i) if (colOf[i] > gc[groupRep[i]]) gc[groupRep[i]] = colOf[i]; + for (int i = 0; i < n; ++i) colOf[i] = gc[groupRep[i]]; + maxDist = maxD2; + for (int c = 0; c <= maxDist; ++c) remap[c] = -1; + for (int i = 0; i < n; ++i) remap[colOf[i]] = 1; + nextCol = 0; for (int c = 0; c <= maxDist; ++c) if (remap[c] == 1) remap[c] = nextCol++; + for (int i = 0; i < n; ++i) colOf[i] = remap[colOf[i]]; + maxDist = nextCol ? nextCol - 1 : 0; + } + + // ---- virtual nodes: frame-boundary endpoints drawn as real DAG nodes, so the column/barycenter/y + // code places them like any pass. each attaches to ONE pass pin, a read one column before its reader and + // a write one column after its writer, so a widely-read texture gets a small node per use site rather + // than one node fanning edges everywhere. three kinds, all gated by the toolbar toggle: + // * history: curr (written this frame for next) and prev (last frame's curr). cross-frame, so no + // in-frame edge joins the pair. + // * external input: an imported resource read with no in-graph writer. a texture gets a node per + // reader, a buffer gets ONE source fanning faint edges, so a uniform does not swamp the view. + // * present: an imported resource that IS written, the swapchain. + // an endpoint for a pass inside an expanded group parks in the column JUST OUTSIDE it, so the relax + // reserves space outside the hull rather than at member_col +/-1, which can land inside the border. + static std::vector vReadCol, vWriteCol; vReadCol.assign(n, 0); vWriteCol.assign(n, 0); + for (int i = 0; i < n; ++i) { vReadCol[i] = colOf[i] - 1; vWriteCol[i] = colOf[i] + 1; } + { + static std::vector sMin, sMax; sMin.assign(gtree.size(), 1 << 30); sMax.assign(gtree.size(), -1); + for (int o = 1; o < (int)gtree.size(); ++o) { + GNode& g = gtree[o]; if (!sv_length(g.prefix) || g.collapsed) continue; + if (collOwner[g.gi] >= 0 && collOwner[g.gi] != o) continue; + for (int k = g.gi; k < g.gj; ++k) { if (colOf[k] < sMin[o]) sMin[o] = colOf[k]; if (colOf[k] > sMax[o]) sMax[o] = colOf[k]; } + } + for (int i = 0; i < n; ++i) { + if (collOwner[i] >= 0) continue; // folded -> routes to a compact pin, not a hull pin + int best = -1, bd = 1 << 30; + for (int o = 1; o < (int)gtree.size(); ++o) { + GNode& g = gtree[o]; if (!sv_length(g.prefix) || g.collapsed || sMax[o] < 0) continue; + if (g.gi <= i && i < g.gj && g.depth < bd) { bd = g.depth; best = o; } + } + if (best >= 0) { vReadCol[i] = sMin[best] - 1; vWriteCol[i] = sMax[best] + 1; } + } + } + struct TNode { bool isRead; int passBox, pin; ResourceNode* res; int col; float w, h; const char* cap; ImU32 tint; int li; }; + static std::vector tnodes; tnodes.clear(); + auto push_tnode = [&](bool isRead, int passBox, int pin, ResourceNode* res, const char* cap, ImU32 tint) { + char b[48]; std::snprintf(b, sizeof b, "%.*s", (int)res->id.name.length, res->id.name.data ? res->id.name.data : "?"); + ImVec2 ns = ImGui::CalcTextSize(b), cs = ImGui::CalcTextSize(cap); + float w = ((ns.x > cs.x ? ns.x : cs.x) + 16) * zoom, h = (ns.y + cs.y + 10) * zoom; + tnodes.push_back({ isRead, passBox, pin, res, isRead ? vReadCol[passBox] : vWriteCol[passBox], w, h, cap, tint, -1 }); + }; + if (showVirtual) + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + // keyed on `history`, NOT `persistent`, which also covers plain create_persistent_* resources. + // keying this arm on persistent swallowed those, leaving them with no endpoint at all. + if (r->history) { // history: write node at the curr writer, read node at each prev reader + bool curr = r->historyIndex == 0; + for (int i = 0; i < n; ++i) { + int sl = curr ? rg_out_slot(box[i].p, r->handle.id) : rg_in_slot(box[i].p, r->handle.id); + if (sl >= 0) push_tnode(!curr, i, sl, r, curr ? "next frame" : "last frame", curr ? kRGWrite : kRGRead); + } + continue; + } + // imported is caller-owned, plain persistent is pool-backed and carried in place. either way + // the value crosses the frame boundary rather than being produced here, so both earn an endpoint. + if (!r->imported && !r->persistent) { + // graph-owned and written but never read, so the store is wasted and StoreOp_Discard would + // make it memoryless. worth a marker rather than a dangling pin. + int lw = -1, lwSlot = -1, readers = 0; + for (int i = 0; i < n; ++i) { + int sl = rg_out_slot(box[i].p, r->handle.id); + if (sl >= 0) { lw = i; lwSlot = sl; } + if (rg_in_slot(box[i].p, r->handle.id) >= 0) ++readers; + } + if (lw >= 0 && readers == 0) push_tnode(false, lw, lwSlot, r, "dead end", kRGDead); + continue; + } + const char* readCap = r->imported ? "imported" : "persistent"; + const char* writeCap = r->imported ? "present" : "persists"; + const ImU32 writeTint = r->imported ? kRGPresent : kRGWrite; + int lwb = -1, lws = -1; // last writer, if any + for (int i = 0; i < n; ++i) { int sl = rg_out_slot(box[i].p, r->handle.id); if (sl >= 0) { lwb = i; lws = sl; } } + // source and sink are independent, not an either/or on whether anything writes this. the + // swapchain is both: blitted with LoadOp_Load, so keying the source off `lwb < 0` hid the input. + auto externalRead = [&](int i) { + return rg_in_slot(box[i].p, r->handle.id) >= 0 && rg_producer_of(box, n, box[i].p, r->handle.id) < 0; + }; + if (r->imported && r->kind == ResourceKind::Buffer && fanBuffers) { + // a uniform is read almost everywhere, so emit ONE source at its earliest reader and let + // the draw fan faint edges out. imported only, the IR's fan-out edge kind keys on that too. + int best = -1, bestSl = -1; + for (int i = 0; i < n; ++i) { if (!externalRead(i)) continue; if (best < 0 || colOf[i] < colOf[best]) { best = i; bestSl = rg_in_slot(box[i].p, r->handle.id); } } + if (best >= 0) push_tnode(true, best, bestSl, r, readCap, kRGExt); + } + else // texture, persistent buffer, or an imported buffer with fan-out off -> node at each reader pin + for (int i = 0; i < n; ++i) if (externalRead(i)) push_tnode(true, i, rg_in_slot(box[i].p, r->handle.id), r, readCap, kRGExt); + if (lwb >= 0) // written in-graph -> sink node at the last writer (present / persists) + push_tnode(false, lwb, lws, r, writeCap, writeTint); + } + // keep columns in [0, maxDist], shifting right if a read node landed left of 0 + int tmin = 0; for (TNode& t : tnodes) if (t.col < tmin) tmin = t.col; + if (tmin < 0) { for (int i = 0; i < n; ++i) colOf[i] -= tmin; maxDist -= tmin; for (TNode& t : tnodes) t.col -= tmin; } + for (TNode& t : tnodes) if (t.col > maxDist) maxDist = t.col; + + // ===== layered routing. an edge skipping columns gets a dummy in each crossed column, reserving a + // lane so it never hides behind a box. + struct LNode { int col, box, tn; float x, y; }; // box == -1 => dummy/history; tn >= 0 => history node + struct REdge { int src, dst, sOut, dIn; uint32_t id; int chainN, chain[kRgDagMax]; }; + static std::vector lnode; lnode.clear(); + static std::vector edge; edge.clear(); + for (int i = 0; i < n; ++i) lnode.push_back({ colOf[i], i, -1, 0, 0 }); // reals: lnode index == box index + + // a read pin fed by its producer + parallel-writer siblings, each with a dummy chain + for (int i = 0; i < n; ++i) { + PassNode* p = box[i].p; int inS = 0; + for (uint32_t k = 0; k < p->accessCount; ++k) { + if (!rg_access_reads(p->accesses[k])) continue; + uint32_t id = p->accesses[k].handle.id; int dIn = inS++; + int prod = rg_producer_of(box, n, p, id); + if (prod < 0) continue; + for (int w = 0; w < n; ++w) { + if (groupRep[w] != groupRep[prod] || colOf[w] >= colOf[i]) continue; + int sOut = rg_out_slot(box[w].p, id); + if (sOut < 0) continue; + REdge e{ w, i, sOut, dIn, id, 0, {} }; + for (int c = colOf[w] + 1; c < colOf[i]; ++c) { e.chain[e.chainN++] = (int)lnode.size(); lnode.push_back({ c, -1, -1, 0, 0 }); } + edge.push_back(e); + } + } + } + + // history nodes join the layered list as their own nodes, box == -1 and tn >= 0 + for (int ti = 0; ti < (int)tnodes.size(); ++ti) { tnodes[ti].li = (int)lnode.size(); lnode.push_back({ tnodes[ti].col, -1, ti, 0, 0 }); } + + // neighbour lists for the barycenter, per-column members built after the remap below + const int LN = (int)lnode.size(); + static std::vector> lleft, lright; lleft.assign(LN, {}); lright.assign(LN, {}); + for (REdge& e : edge) { + int prev = e.src; + for (int t = 0; t < e.chainN; ++t) { lright[prev].push_back(e.chain[t]); lleft[e.chain[t]].push_back(prev); prev = e.chain[t]; } + lright[prev].push_back(e.dst); lleft[e.dst].push_back(prev); + } + // a read node sits left of its reader, a write node right of its writer. one link each is enough to + // order them and align them to the pass's row. + for (TNode& t : tnodes) + if (t.isRead) { lright[t.li].push_back(t.passBox); lleft[t.passBox].push_back(t.li); } + else { lright[t.passBox].push_back(t.li); lleft[t.li].push_back(t.passBox); } + + // a real box carries its pass's cluster, a dummy carries the deepest expanded group containing BOTH + // ends of its edge, so interior lanes stay in the band and boundary lanes stay out. history = -1. + static std::vector lclus; lclus.assign(LN, -1); + for (int i = 0; i < n; ++i) lclus[i] = clusterOf[i]; + { + static std::vector anc; anc.assign(gtree.size(), 0); + auto lca = [&](int a, int b) -> int { + if (a < 0 || b < 0) return -1; + for (int x = a; x >= 0; x = clusterParent[x]) anc[x] = 1; + int r = -1; for (int x = b; x >= 0; x = clusterParent[x]) if (anc[x]) { r = x; break; } + for (int x = a; x >= 0; x = clusterParent[x]) anc[x] = 0; + return r; + }; + for (REdge& e : edge) { int ec = lca(clusterOf[e.src], clusterOf[e.dst]); for (int t = 0; t < e.chainN; ++t) lclus[e.chain[t]] = ec; } + } + + // ---- independent work graphs, laid out separately. disconnected components were right-anchored to the + // global sink column, so they shared buckets and the barycenter + width passes cross-influenced them. + // give each component its own disjoint dense column range from its base. a component is gap-free within + // its span, so base + (col - min) remaps it densely and the ranges sum to <= n columns. each bucket then + // holds one component, and a vertical band separates them. + static std::vector comp; comp.assign(LN, 0); + static std::vector compMinCol; compMinCol.assign(LN, 1 << 30); + { + for (int li = 0; li < LN; ++li) comp[li] = li; + auto cfind = [&](int x) { while (comp[x] != x) { comp[x] = comp[comp[x]]; x = comp[x]; } return x; }; + for (int li = 0; li < LN; ++li) { for (int x : lleft[li]) comp[cfind(li)] = cfind(x); for (int x : lright[li]) comp[cfind(li)] = cfind(x); } + // a group run draws as one bordered region, so force it into one component. clear-only members have + // no read edge, so each would otherwise split off and the banding would stack them. + for (int ga = 0; ga < n;) { + WGPUStringView pre = group_prefix(box[ga].p->id.name); + int gb = ga + 1; + while (gb < n && sv_length(pre) && sv_eq(group_prefix(box[gb].p->id.name), pre)) ++gb; + if (sv_length(pre) && gb - ga >= 2) for (int k = ga + 1; k < gb; ++k) comp[cfind(ga)] = cfind(k); + ga = gb; + } + for (int li = 0; li < LN; ++li) comp[li] = cfind(li); + // compact each root to a first-seen id, which is exec order since reals come first + static std::vector cid; cid.assign(LN, -1); int numComp = 0; + for (int li = 0; li < LN; ++li) if (cid[comp[li]] < 0) cid[comp[li]] = numComp++; + static std::vector cMin, cMax; cMin.assign(numComp, 1 << 30); cMax.assign(numComp, -1); + for (int li = 0; li < LN; ++li) { int c = cid[comp[li]], oc = lnode[li].col; if (oc < cMin[c]) cMin[c] = oc; if (oc > cMax[c]) cMax[c] = oc; } + static std::vector cBase; cBase.assign(numComp, 0); int acc = 0; + for (int c = 0; c < numComp; ++c) { cBase[c] = acc; acc += cMax[c] - cMin[c] + 1; } + for (int li = 0; li < LN; ++li) { int c = cid[comp[li]]; lnode[li].col = cBase[c] + (lnode[li].col - cMin[c]); } + for (int i = 0; i < n; ++i) colOf[i] = lnode[i].col; // reals: lnode i == box i; keep colW indexing in sync + maxDist = acc > 0 ? acc - 1 : 0; + for (int li = 0; li < LN; ++li) if (lnode[li].col < compMinCol[comp[li]]) compMinCol[comp[li]] = lnode[li].col; + } + + // per-column members, now that columns are final per-component + static std::vector lcol[kRgDagMax]; + for (int c = 0; c <= maxDist; ++c) lcol[c].clear(); + for (int li = 0; li < LN; ++li) lcol[lnode[li].col].push_back(li); + + // materialize node heights first: a box gets its reserved height, a history node its own, a dummy a lane + const float kLane = 16.0f * zoom; + static std::vector lh; lh.assign(LN, 0.0f); + for (int li = 0; li < LN; ++li) { int b = lnode[li].box; lh[li] = b >= 0 ? effH[b] : (lnode[li].tn >= 0 ? tnodes[lnode[li].tn].h : kLane); } + static std::vector cy; cy.assign(LN, 0.0f); + // neither a box nor an endpoint is a bare edge lane, so it reserves a sliver not a row + static std::vector lthin; lthin.assign(LN, 0); + for (int li = 0; li < LN; ++li) lthin[li] = (lnode[li].box < 0 && lnode[li].tn < 0) ? 1 : 0; + rg_barycenter_relax(lcol, maxDist, lleft, lright, lh, cy, kRowGap, lclus.data(), clusterParent.data(), (int)gtree.size(), lthin.data()); + + // stack the components into disjoint vertical bands, each shifted below the previous one's extent + { + static std::vector cTop, cBot, cOff; static std::vector seen; + cTop.assign(LN, 1e30f); cBot.assign(LN, -1e30f); cOff.assign(LN, 0.0f); seen.assign(LN, 0); + for (int li = 0; li < LN; ++li) { float t = cy[li] - lh[li] * 0.5f, b = cy[li] + lh[li] * 0.5f; if (t < cTop[comp[li]]) cTop[comp[li]] = t; if (b > cBot[comp[li]]) cBot[comp[li]] = b; } + float cursor = 0.0f; + for (int li = 0; li < LN; ++li) { int c = comp[li]; if (seen[c]) continue; seen[c] = 1; cOff[c] = cursor - cTop[c]; cursor += (cBot[c] - cTop[c]) + kRowGap * 2.0f; } + for (int li = 0; li < LN; ++li) cy[li] += cOff[comp[li]]; + } + + // shove non-members out of each expanded group's band so the hull encloses ONLY its members, e.g. a + // history node the relax floated into a column the group spans. deepest-first, so a node ends up outside + // the OUTERMOST band it intruded on. + { + auto inSub = [&](int li, int anc) { for (int c = lclus[li]; c >= 0; c = clusterParent[c]) if (c == anc) return true; return false; }; + for (int o = (int)gtree.size() - 1; o >= 1; --o) { + GNode& g = gtree[o]; + if (!sv_length(g.prefix) || g.collapsed) continue; // only expanded groups draw a hull + if (collOwner[g.gi] >= 0 && collOwner[g.gi] != o) continue; // shadowed by a collapsed ancestor -> not drawn + float bandTop = 1e30f, bandBot = -1e30f; int colMin = 1 << 30, colMax = -1; + for (int li = 0; li < LN; ++li) if (inSub(li, o)) { + if (cy[li] - lh[li] * 0.5f < bandTop) bandTop = cy[li] - lh[li] * 0.5f; + if (cy[li] + lh[li] * 0.5f > bandBot) bandBot = cy[li] + lh[li] * 0.5f; + int c = lnode[li].col; if (c < colMin) colMin = c; if (c > colMax) colMax = c; + } + if (colMax < colMin) continue; + float hullTop = bandTop - kRegionPad - kHeaderH, hullBot = bandBot + kRegionPad, mid = (bandTop + bandBot) * 0.5f; + const float kClear = kRowGap * 0.5f; + // decide first, then place. evicted only if it is a non-member, sits under the hull's columns, + // is not wired to a member (those belong AT the border), and actually overlaps the band. + static std::vector evict; evict.assign(LN, 0); + for (int li = 0; li < LN; ++li) { + if (inSub(li, o)) continue; // a member / interior dummy belongs inside + int c = lnode[li].col; + if (c < colMin || c > colMax) continue; // not under the hull + bool linked = false; + for (int x : lleft[li]) if (inSub(x, o)) { linked = true; break; } + if (!linked) for (int x : lright[li]) if (inSub(x, o)) { linked = true; break; } + if (linked) continue; + float t = cy[li] - lh[li] * 0.5f, b = cy[li] + lh[li] * 0.5f; + if (b <= hullTop || t >= hullBot) continue; // already clear + evict[li] = 1; + } + // place each evicted node in the nearest free gap on its side. two things at once: several + // evictions in one column must stack (the frontier), and a slot must not land on a node staying + // put there (the walk). the exempt linked endpoints are exactly that hazard, parking at the + // border where the frontier starts. walking past only what is in the way keeps the node near the + // pass it annotates. + static std::vector topFront, botFront; + topFront.assign(maxDist + 1, hullTop - kClear); + botFront.assign(maxDist + 1, hullBot + kClear); + for (int li = 0; li < LN; ++li) { + if (!evict[li]) continue; + const int c = lnode[li].col; + const float h = lh[li]; + const bool up = cy[li] < mid; + float edge = up ? topFront[c] : botFront[c]; // free boundary to grow from + for (int guard = 0; guard < LN + 2; ++guard) { + const float t = up ? edge - h : edge, b = up ? edge : edge + h; + int hit = -1; + for (int x = 0; x < LN && hit < 0; ++x) { + if (x == li || evict[x] || lnode[x].col != c) continue; + // only dodge things that draw a box. a bare dummy is an edge lane, and a busy column + // carries one per through-edge, which is how endpoints ended up hundreds of px away. + if (lnode[x].box < 0 && lnode[x].tn < 0) continue; + const float xt = cy[x] - lh[x] * 0.5f, xb = cy[x] + lh[x] * 0.5f; + if (b > xt && t < xb) hit = x; + } + if (hit < 0) break; + edge = up ? cy[hit] - lh[hit] * 0.5f - kClear : cy[hit] + lh[hit] * 0.5f + kClear; + } + cy[li] = up ? edge - h * 0.5f : edge + h * 0.5f; + if (up) topFront[c] = edge - h - kClear; else botFront[c] = edge + h + kClear; + } + } + } + + // positions + the canvas-local bounding box, used to centre the view + float gxMin = 1e30f, gyMin = 1e30f, gxMax = -1e30f, gyMax = -1e30f; + // every node is one box wide, expanded groups reserve no wide region, so column x is a uniform cadence + float colW[kRgDagMax], colX[kRgDagMax]; + for (int c = 0; c <= maxDist; ++c) colW[c] = kBoxW; + // an endpoint is centred in its column and a long resource name can outgrow a pass box, so widen the + // column to fit. otherwise it overhangs kColGap both sides and eventually lands on the neighbours. + for (const TNode& t : tnodes) { int c = lnode[t.li].col; if (t.w > colW[c]) colW[c] = t.w; } + colX[0] = 0; for (int c = 1; c <= maxDist; ++c) colX[c] = colX[c - 1] + colW[c - 1] + kColGap; + for (int c = 0; c <= maxDist; ++c) { + for (int li : lcol[c]) { + float cx = colX[c] - colX[compMinCol[comp[li]]]; // left-align this component to x=0 + if (lnode[li].box >= 0) { + int b = lnode[li].box; box[b].tl = ImVec2(cx, cy[li] - effH[b] * 0.5f); box[b].layer = c; + if (cx < gxMin) gxMin = cx; if (cx + box[b].w > gxMax) gxMax = cx + box[b].w; + if (box[b].tl.y < gyMin) gyMin = box[b].tl.y; if (box[b].tl.y + effH[b] > gyMax) gyMax = box[b].tl.y + effH[b]; + } + else { + lnode[li].x = cx; lnode[li].y = cy[li]; // dummy: column left edge + lane centre + int t = lnode[li].tn; + if (t >= 0) { // history node: centre in the (possibly widened) column slot, include in the view bbox + float ctr = cx + colW[c] * 0.5f, hw = tnodes[t].w * 0.5f, hh = tnodes[t].h * 0.5f; + lnode[li].x = ctr; + if (ctr - hw < gxMin) gxMin = ctr - hw; if (ctr + hw > gxMax) gxMax = ctr + hw; + if (cy[li] - hh < gyMin) gyMin = cy[li] - hh; if (cy[li] + hh > gyMax) gyMax = cy[li] + hh; + } + } + } + } + // ---- lane pull. the relax minimises CROSSINGS, not detour, so a lane can end up far past the box it + // dodged and turn a short hop into a canvas-spanning swoop. pull each lane onto the straight line + // between its pins, then push it clear. obstacles include lanes already placed this pass, which is what + // stops parallel edges collapsing onto one ideal line. the push direction is fixed on the first hit, so + // a lane wedged between two obstacles walks out one side instead of oscillating. + { + static std::vector laneSet; laneSet.assign(LN, 0); + auto extent = [&](int x, float& t, float& b) -> bool { // vertical extent of an obstacle, false = not one + if (lnode[x].box >= 0) { t = box[lnode[x].box].tl.y; b = t + effH[lnode[x].box]; return effH[lnode[x].box] > 0.5f; } + if (lnode[x].tn >= 0) { const float h = tnodes[lnode[x].tn].h; t = lnode[x].y - h * 0.5f; b = lnode[x].y + h * 0.5f; return true; } + if (laneSet[x]) { t = lnode[x].y - kLane * 0.5f; b = lnode[x].y + kLane * 0.5f; return true; } + return false; + }; + // hull band of every expanded group, mirroring the draw's border rect, so a lane can be held inside + // the group it belongs to + static std::vector gTop, gBot; static std::vector gcMin, gcMax; + gTop.assign(gtree.size(), 0.0f); gBot.assign(gtree.size(), 0.0f); + gcMin.assign(gtree.size(), 1 << 30); gcMax.assign(gtree.size(), -1); + for (int o = 1; o < (int)gtree.size(); ++o) { + const GNode& g = gtree[o]; + if (!sv_length(g.prefix) || g.collapsed) continue; + if (collOwner[g.gi] >= 0 && collOwner[g.gi] != o) continue; + float t0 = 1e30f, b0 = -1e30f; + for (int k = g.gi; k < g.gj; ++k) { + if (effH[k] <= 0.5f) continue; // folded member reserves nothing + if (box[k].tl.y < t0) t0 = box[k].tl.y; + if (box[k].tl.y + effH[k] > b0) b0 = box[k].tl.y + effH[k]; + if (colOf[k] < gcMin[o]) gcMin[o] = colOf[k]; + if (colOf[k] > gcMax[o]) gcMax[o] = colOf[k]; + } + if (b0 < t0) { gcMax[o] = -1; continue; } + gTop[o] = t0 - kRegionPad - kHeaderH; gBot[o] = b0 + kRegionPad; + } + // two rounds, and the order is the point. a lane with an endpoint inside a group must stay in that + // group's often-few-px band or the edge visibly leaves the hull. a pass-through edge has no such + // constraint, so it claims last, or it takes the interior slots first and evicts the constrained one. + for (int round = 0; round < 2; ++round) + for (REdge& e : edge) { + if (!e.chainN) continue; + const bool grouped = clusterOf[e.src] >= 0 || clusterOf[e.dst] >= 0; + if ((round == 0) != grouped) continue; + const float sy = box[e.src].tl.y + kHeaderH + e.sOut * kPinRowH + kPinRowH * 0.5f; + const float dy = box[e.dst].tl.y + kHeaderH + e.dIn * kPinRowH + kPinRowH * 0.5f; + const float sx = box[e.src].tl.x + box[e.src].w, ex = box[e.dst].tl.x; + for (int t = 0; t < e.chainN; ++t) { + const int li = e.chain[t], c = lnode[li].col; + const float lx = lnode[li].x + kBoxW * 0.5f; + float f = ex > sx ? (lx - sx) / (ex - sx) : 0.5f; + f = f < 0.0f ? 0.0f : f > 1.0f ? 1.0f : f; + float want = sy + (dy - sy) * f; // straight-line y at this column + // does either end of this edge sit inside group o + auto belongsTo = [&](int o) { + const GNode& g = gtree[o]; + return (e.src >= g.gi && e.src < g.gj) || (e.dst >= g.gi && e.dst < g.gj); + }; + // inside a group the edge heads for that group's BORDER pin, not its far endpoint, so the + // global source->dest line is the wrong target: a destination below the hull aims the lane + // straight out through the floor. hold it in the band of every group it belongs to. + auto holdInOwn = [&](float y) { + for (int o = 1; o < (int)gtree.size(); ++o) { + if (gcMax[o] < gcMin[o] || c < gcMin[o] || c > gcMax[o] || !belongsTo(o)) continue; + const float lo = gTop[o] + kLane, hi = gBot[o] - kLane; + if (hi > lo) y = y < lo ? lo : (y > hi ? hi : y); + } + return y; + }; + // walk out from `start` until nothing overlaps. a FOREIGN hull counts as one solid obstacle: + // pushing just outside it up front was not enough, since a later step of this walk would + // deposit the lane back inside. + auto settle = [&](float start, int dir) { + float y = start; + for (int guard = 0; guard < LN + 8; ++guard) { + bool hit = false; float ht = 0, hb = 0; + for (int x = 0; x < LN && !hit; ++x) { + if (x == li || lnode[x].col != c) continue; + float xt, xb; + if (!extent(x, xt, xb)) continue; + if (y + kLane * 0.5f > xt && y - kLane * 0.5f < xb) { hit = true; ht = xt; hb = xb; } + } + for (int o = 1; o < (int)gtree.size() && !hit; ++o) { + if (gcMax[o] < gcMin[o] || c < gcMin[o] || c > gcMax[o] || belongsTo(o)) continue; + const float xt = gTop[o] - kLane, xb = gBot[o] + kLane; + if (y + kLane * 0.5f > xt && y - kLane * 0.5f < xb) { hit = true; ht = xt; hb = xb; } + } + if (!hit) break; + y = dir < 0 ? ht - kLane * 0.5f - 4.0f : hb + kLane * 0.5f + 4.0f; + } + return y; + }; + want = holdInOwn(want); + // try BOTH sides and keep the nearer resting place. the nearer side of the FIRST obstacle can + // hide a whole group hull to climb, where the other way clears one box. + const float up = settle(want, -1), down = settle(want, +1); + want = holdInOwn((want - up <= down - want) ? up : down); + lnode[li].y = want; laneSet[li] = 1; + } + } + } + + if (gxMax < gxMin) { gxMin = gyMin = gxMax = gyMax = 0; } // empty graph + + // ---- IR mirror: RgNodes index-aligned to box[], then Virtual endpoints, plus RgEdges. the draw + // consumes this for the nodes + virtual links, box[]/edge[] still drives layout and the solid edges. + static std::vector rgn; rgn.clear(); + static std::vector rge; rge.clear(); + for (int i = 0; i < n; ++i) { + RgNode nd{}; nd.kind = RgNode::Kind::Pass; nd.pass = box[i].p; + nd.label = box[i].p->id.name; nd.pos = box[i].tl; nd.w = box[i].w; nd.h = box[i].h; nd.col = box[i].layer; + rgn.push_back(nd); + } + for (REdge& e : edge) { + RgEdge ge{}; ge.srcNode = e.src; ge.dstNode = e.dst; + ge.srcPin = (uint16_t)e.sOut; ge.dstPin = (uint16_t)e.dIn; ge.resId = e.id; ge.kind = RgEdge::Kind::Raw; + rge.push_back(ge); + } + // one Virtual RgNode at its laid-out spot + an RgEdge per link to the anchor pin, a fan source emitting + // one per reader. additive for now, the draw still reads tnodes. + for (TNode& t : tnodes) { + int vi = (int)rgn.size(); + RgNode nd{}; nd.kind = RgNode::Kind::Virtual; nd.res = t.res; + nd.label = WGPUStringView{ t.cap, t.cap ? strlen(t.cap) : 0 }; + nd.pos = ImVec2(lnode[t.li].x - t.w * 0.5f, lnode[t.li].y - t.h * 0.5f); + nd.w = t.w; nd.h = t.h; nd.col = t.col; nd.tint = t.tint; + rgn.push_back(nd); + RgEdge::Kind ek = t.res->persistent ? RgEdge::Kind::History : RgEdge::Kind::Raw; + bool fan = fanBuffers && t.isRead && t.res->imported && t.res->kind == ResourceKind::Buffer; + if (fan) + for (int i = 0; i < n; ++i) { int sl = rg_in_slot(box[i].p, t.res->handle.id); if (sl < 0) continue; + rge.push_back(RgEdge{ vi, i, 0, (uint16_t)sl, t.res->handle.id, RgEdge::Kind::Fanout }); } + else if (t.isRead) rge.push_back(RgEdge{ vi, t.passBox, 0, (uint16_t)t.pin, t.res->handle.id, ek }); + else rge.push_back(RgEdge{ t.passBox, vi, (uint16_t)t.pin, 0, t.res->handle.id, ek }); + } + + // reset/centre the view + a name filter that dims non-matching passes + static char filter[64] = ""; + bool doReset = ImGui::Button("Reset view"); + ImGui::SameLine(); ImGui::SetNextItemWidth(180); + ImGui::InputTextWithHint("##rgfilter", "filter passes...", filter, sizeof filter); + ImGui::SameLine(); ImGui::Checkbox("virtual nodes", &showVirtual); + ImGui::SameLine(); ImGui::Checkbox("fan-out buffers", &fanBuffers); // off -> one virtual node per buffer use site + ImGui::SameLine(); ImGui::Checkbox("collapse groups", &collapseDefault); // default state; a group header click overrides + if (rgPassTotal > n) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.95f, 0.70f, 0.30f, 1), "| showing %d of %d passes (cap %d)", n, rgPassTotal, kRgDagMax); } + const bool filterActive = filter[0] != 0; + + // drag left/middle to pan, wheel to zoom, handled at the top before layout. no scrollbar, navigation is + // panning, so a big graph is not boxed in. + ImGui::BeginChild("rg_canvas", ImVec2(0, 0), true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoMove); + ImGui::SetWindowFontScale(zoom); // text scales with the canvas; restored to 1.0 before the overlays + ImGuiIO& io = ImGui::GetIO(); + const ImVec2 winPos = ImGui::GetCursorScreenPos(); + const ImVec2 winSize = ImGui::GetContentRegionAvail(); + ImGui::InvisibleButton("canvas", ImVec2(winSize.x > 0 ? winSize.x : 1, winSize.y > 0 ? winSize.y : 1)); + const bool canvasHovered = ImGui::IsItemHovered(); + const bool canvasActive = ImGui::IsItemActive(); + s_canvasHovered = canvasHovered; s_winPos = winPos; // for next frame's wheel zoom (resolved pre-layout) + bool panned = false; + if (canvasActive && (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 0.0f) || ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 0.0f))) { + scrolling.x += io.MouseDelta.x; scrolling.y += io.MouseDelta.y; userMoved = true; panned = true; + } + // snap to the graph's top-left until the user pans, riding through the frames where the content region + // is still settling. reset and a double-click re-arm it. + if (doReset || (canvasHovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))) userMoved = false; + if (doReset) zoom = 1.0f; // reset view also returns to the closest zoom + if (!userMoved && !panned && winSize.x > 1 && winSize.y > 1) { + const float kCornerPad = 40.0f; + scrolling.x = kCornerPad - gxMin; + scrolling.y = kCornerPad - gyMin; + } + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 origin(winPos.x + scrolling.x, winPos.y + scrolling.y); // panned top-left; node coords add this + + // faint grid, scrolling with the canvas + const float kGrid = 48.0f; + for (float gx = std::fmod(scrolling.x, kGrid); gx < winSize.x; gx += kGrid) + dl->AddLine(ImVec2(winPos.x + gx, winPos.y), ImVec2(winPos.x + gx, winPos.y + winSize.y), IM_COL32(255, 255, 255, 14)); + for (float gy = std::fmod(scrolling.y, kGrid); gy < winSize.y; gy += kGrid) + dl->AddLine(ImVec2(winPos.x, winPos.y + gy), ImVec2(winPos.x + winSize.x, winPos.y + gy), IM_COL32(255, 255, 255, 14)); + + // ---- group runs: a contiguous run of passes sharing a dotted-name prefix is one logical group. + // expanded draws a labelled frame behind the members, collapsed draws one synthetic node carrying the + // external pins. the collapse flag is a tri-state in ImGuiStorage (0 follow-default, 1 open, 2 closed), + // so it survives the per-frame rebuild and the toolbar can flip every group at once. + struct GView { + int gi, gj, depth; WGPUStringView prefix; bool collapsed; ImGuiID key; // depth 1 = top-level group, >=2 = subgroup + ImVec2 bb0, bb1, h0, h1; // box rect + header (click) rect, screen + int nIn, nOut; uint32_t inId[kRgGPinMax], outId[kRgGPinMax]; + ImVec2 inC[kRgGPinMax], outC[kRgGPinMax]; bool inDrawn[kRgGPinMax]; + }; + static std::vector groups; groups.clear(); + static std::vector nodeGView; nodeGView.assign(gtree.size(), -1); // gtree node index -> its GView in groups (subgroups), else -1 + int groupOf[kRgDagMax]; for (int i = 0; i < n; ++i) groupOf[i] = -1; + // the barycentre of the member pins it serves INSIDE and the external pins it serves OUTSIDE. weighing + // only the inside puts the pin beside its members but leaves the outside to tangle. for a COLLAPSED + // group the inside term is constant, so the result orders by the outside anchors, which is what you want + // when the interior is not drawn. + auto ifaceY = [&](int gi, int gj, uint32_t id, bool write, float fb) -> float { + float inSum = 0, outSum = 0; int inCnt = 0, outCnt = 0; + for (int k = 0; k < n; ++k) { + const bool inside = k >= gi && k < gj; + // an out-pin is fed by member writers and consumed by outside readers, an in-pin is the mirror + const int slot = inside ? (write ? rg_out_slot(rgn[k].pass, id) : rg_in_slot(rgn[k].pass, id)) + : (write ? rg_in_slot(rgn[k].pass, id) : rg_out_slot(rgn[k].pass, id)); + if (slot < 0) continue; + const float y = origin.y + rgn[k].pos.y + kHeaderH + slot * kPinRowH + kPinRowH * 0.5f; + if (inside) { inSum += y; ++inCnt; } else { outSum += y; ++outCnt; } + } + if (inCnt && outCnt) return (inSum / inCnt + outSum / outCnt) * 0.5f; + if (inCnt) return inSum / inCnt; + if (outCnt) return outSum / outCnt; + return fb; + }; + // the y anchors one interface pin serves on one side of the border + static constexpr int kRGAnchor = 16; + auto ifaceAnchors = [&](int gi, int gj, uint32_t id, bool write, bool inside, float* out) -> int { + int c = 0; + for (int k = 0; k < n && c < kRGAnchor; ++k) { + const bool isIn = k >= gi && k < gj; + if (isIn != inside) continue; + const int slot = isIn ? (write ? rg_out_slot(rgn[k].pass, id) : rg_in_slot(rgn[k].pass, id)) + : (write ? rg_in_slot(rgn[k].pass, id) : rg_out_slot(rgn[k].pass, id)); + if (slot < 0) continue; + out[c++] = origin.y + rgn[k].pos.y + kHeaderH + slot * kPinRowH + kPinRowH * 0.5f; + } + return c; + }; + // order a group's interface pins by counting crossings directly rather than via a barycentre, which is + // only a proxy and breaks when the two sides disagree: averaging then splits the difference and leaves + // crossings on both. count, per adjacent pair, how many edge pairs invert either way and swap when that + // lowers it. pin counts are tiny, so adjacent exchange is plenty and always terminates. + auto orderIface = [&](int gi, int gj, uint32_t* ids, int cnt, bool write) { + if (cnt < 2) return; + static float inY[kRgGPinMax][kRGAnchor], outY[kRgGPinMax][kRGAnchor]; + int inN[kRgGPinMax], outN[kRgGPinMax], perm[kRgGPinMax]; + for (int i = 0; i < cnt; ++i) { + inN[i] = ifaceAnchors(gi, gj, ids[i], write, true, inY[i]); + outN[i] = ifaceAnchors(gi, gj, ids[i], write, false, outY[i]); + perm[i] = i; + } + auto inv = [&](int p, int q) { // crossings with p placed above q, both sides + int c = 0; + for (int a = 0; a < inN[p]; ++a) for (int b = 0; b < inN[q]; ++b) if (inY[p][a] > inY[q][b]) ++c; + for (int a = 0; a < outN[p]; ++a) for (int b = 0; b < outN[q]; ++b) if (outY[p][a] > outY[q][b]) ++c; + return c; + }; + for (int pass = 0; pass < cnt; ++pass) { + bool moved = false; + for (int i = 0; i + 1 < cnt; ++i) { + const int p = perm[i], q = perm[i + 1]; + if (inv(q, p) < inv(p, q)) { perm[i] = q; perm[i + 1] = p; moved = true; } + } + if (!moved) break; + } + uint32_t tmp[kRgGPinMax]; + for (int i = 0; i < cnt; ++i) tmp[i] = ids[perm[i]]; + for (int i = 0; i < cnt; ++i) ids[i] = tmp[i]; + }; + // de-overlap a border's pins AND keep the run on that border in one step. members in one row want the + // same y, which would stack every pin on a point, so enforce a min gap then recentre on the desired mean. + // the two-sided barycentre can land past the group's span, so the run is SHIFTED into [lo, hi] rather + // than clamped, which would restack exactly the spacing the gap pass just bought. it compresses only when + // the band cannot hold cnt pins at `gap`, and then evenly. + // NOTE: the gap is enforced in ARRAY order, which orderIface already chose to minimise crossings. + // re-sorting by y would silently undo that whenever the two disagree, the very case orderIface exists for. + auto fitPins = [&](ImVec2* pins, int cnt, float gap, float lo, float hi) { + if (cnt <= 0 || hi <= lo) return; + if (cnt == 1) { pins[0].y = pins[0].y < lo ? lo : (pins[0].y > hi ? hi : pins[0].y); return; } + float before = 0, after = 0, prev = -1e30f; + for (int i = 0; i < cnt; ++i) before += pins[i].y; + for (int i = 0; i < cnt; ++i) { if (pins[i].y < prev + gap) pins[i].y = prev + gap; prev = pins[i].y; after += pins[i].y; } + for (int i = 0; i < cnt; ++i) pins[i].y += (before - after) / cnt; // recentre on the desired mean + const float top = pins[0].y, bot = pins[cnt - 1].y, span = hi - lo; + if (bot - top > span) { // band too tight for the ideal gap: even spacing, still never coincident + const float g2 = span / (cnt - 1); + for (int i = 0; i < cnt; ++i) pins[i].y = lo + g2 * i; + return; + } + const float shift = top < lo ? lo - top : (bot > hi ? hi - bot : 0.0f); + if (shift != 0.0f) for (int i = 0; i < cnt; ++i) pins[i].y += shift; + }; + for (int gi = 0; gi < n;) { + WGPUStringView pre = group_prefix(box[gi].p->id.name); + int gj = gi + 1; + while (gj < n && sv_length(pre) && sv_eq(group_prefix(box[gj].p->id.name), pre)) ++gj; + if (!(sv_length(pre) && gj - gi >= 2)) { gi = gj; continue; } + + float x0 = 1e30f, y0 = 1e30f, x1 = -1e30f, y1 = -1e30f; + for (int k = gi; k < gj; ++k) { + float ax = origin.x + box[k].tl.x, ay = origin.y + box[k].tl.y; + if (ax < x0) x0 = ax; if (ay < y0) y0 = ay; + if (ax + box[k].w > x1) x1 = ax + box[k].w; if (ay + box[k].h > y1) y1 = ay + box[k].h; + } + + GView g{}; g.gi = gi; g.gj = gj; g.depth = 1; g.prefix = pre; g.key = rg_grp_key(pre); + int st = grpStore->GetInt(g.key, 0); + g.collapsed = st == 2 ? true : st == 1 ? false : collapseDefault; + + // for collapsed AND expanded groups: a member read produced outside becomes an in-pin, an + // external/imported/persistent member write an out-pin, and interior resources get no pin. + rg_group_interface(rg, box, n, gi, gj, g.inId, g.nIn, g.outId, g.nOut); + orderIface(gi, gj, g.inId, g.nIn, false); + orderIface(gi, gj, g.outId, g.nOut, true); + + if (!g.collapsed) { + // a hull border around the member boxes. the cluster-contiguity ordering keeps them a tight + // block, so the bbox is theirs alone. pad past any nested subgroup border to enclose it. + ImU32 gcol = group_color(pre); + ImVec2 a(x0 - kRegionPad, y0 - kRegionPad - kHeaderH), b(x1 + kRegionPad, y1 + kRegionPad); + dl->AddRectFilled(a, b, rg_with_alpha(gcol, 22), 6.0f); + dl->AddRect(a, b, gcol, 6.0f, 0, 2.0f); + char lbl[64]; std::snprintf(lbl, sizeof lbl, "[-] %.*s x%d", (int)sv_length(pre), pre.data, gj - gi); + dl->AddText(ImVec2(a.x + 6, a.y + 3), gcol, lbl); + g.bb0 = a; g.bb1 = b; g.h0 = a; g.h1 = ImVec2(b.x, a.y + kHeaderH); + // pins on the hull edges, each aligned to the members it serves, so boundary edges run straight + // across instead of arcing up to a top-aligned row + const float pinLo = a.y + kHeaderH + kPinRowH * 0.5f, pinHi = b.y - kPinRowH * 0.5f; + for (int s = 0; s < g.nIn; ++s) g.inC[s] = ImVec2(a.x, ifaceY(gi, gj, g.inId[s], false, a.y + kHeaderH + s * kPinRowH + kPinRowH * 0.5f)); + for (int s = 0; s < g.nOut; ++s) g.outC[s] = ImVec2(b.x, ifaceY(gi, gj, g.outId[s], true, a.y + kHeaderH + s * kPinRowH + kPinRowH * 0.5f)); + fitPins(g.inC, g.nIn, kPinRowH, pinLo, pinHi); fitPins(g.outC, g.nOut, kPinRowH, pinLo, pinHi); + } + else { + int rows = g.nIn > g.nOut ? g.nIn : g.nOut; + float needH = kHeaderH + (rows ? rows * kPinRowH : kMinBodyH) + kFooterH; + // collapse over the FIRST member's slot, already an overlap-free position, not the member bbox, + // which for a multi-column group would cover the nodes laid out between its members. + // NOTE(Huerbe): no relayout, so a group with more pins than the anchor's rows grows down and can + // touch a neighbour. promote collapsed groups to single layout nodes if that ever bites. + ImVec2 a(origin.x + rgn[gi].pos.x, origin.y + rgn[gi].pos.y); + ImVec2 b(a.x + kBoxW, a.y + needH); // == effH[gi], the slot reserved before layout + g.bb0 = a; g.bb1 = b; g.h0 = a; g.h1 = ImVec2(b.x, a.y + kHeaderH); + for (int s = 0; s < g.nIn; ++s) g.inC[s] = ImVec2(a.x, a.y + kHeaderH + s * kPinRowH + kPinRowH * 0.5f); + for (int s = 0; s < g.nOut; ++s) g.outC[s] = ImVec2(b.x, a.y + kHeaderH + s * kPinRowH + kPinRowH * 0.5f); + } + + int idxG = (int)groups.size(); groups.push_back(g); + for (int k = gi; k < gj; ++k) groupOf[k] = idxG; + // map this top-level group to its gtree node too, so collOwner-keyed lookups find its compact pin. + // without it, collapsing a top-level group sends member edges to their own undrawn pins. + for (int gn = 1; gn < (int)gtree.size(); ++gn) if (gtree[gn].depth == 1 && gtree[gn].gi == gi && gtree[gn].gj == gj) { nodeGView[gn] = idxG; break; } + gi = gj; + } + + // every gtree node below the top level becomes a GView too, so the same draw / click / pin passes treat + // it like a top-level group. nests to any depth. + // pass 1: rects deepest-first, so a parent's border encloses its child borders with a gap and members + // never sit flush. pass 2 builds the GViews parent-first. + static std::vector subA, subB; subA.assign(gtree.size(), ImVec2(0, 0)); subB.assign(gtree.size(), ImVec2(0, 0)); + const float kSubPad = 11.0f * zoom; // gap from a subgroup border to its members / nested child borders + for (int gni = (int)gtree.size() - 1; gni >= 1; --gni) { + GNode& g2 = gtree[gni]; + if (g2.depth < 2 || !sv_length(g2.prefix)) continue; + if (collOwner[g2.gi] >= 0 && collOwner[g2.gi] != gni) continue; // inside a collapsed ancestor -> not drawn + if (g2.collapsed) { // compact node over the rep cell (sized compact at layout time via repH) + uint32_t ii[kRgGPinMax], oo[kRgGPinMax]; int ni = 0, no = 0; rg_group_interface(rg, box, n, g2.gi, g2.gj, ii, ni, oo, no); + int rows = ni > no ? ni : no; + ImVec2 a(origin.x + rgn[g2.gi].pos.x, origin.y + rgn[g2.gi].pos.y); + subA[gni] = a; subB[gni] = ImVec2(a.x + kBoxW, a.y + kHeaderH + (rows ? rows * kPinRowH : kMinBodyH) + kFooterH); + continue; + } + float x0 = 1e30f, y0 = 1e30f, x1 = -1e30f, y1 = -1e30f; + for (int k = g2.gi; k < g2.gj; ++k) { + if (collOwner[k] >= 0 && k != gtree[collOwner[k]].gi) continue; // folded member -> not framed + float kx = origin.x + rgn[k].pos.x, ky = origin.y + rgn[k].pos.y; + if (kx < x0) x0 = kx; if (ky < y0) y0 = ky; + if (kx + rgn[k].w > x1) x1 = kx + rgn[k].w; if (ky + rgn[k].h > y1) y1 = ky + rgn[k].h; + } + for (int kid : g2.kids) { // enclose child subgroup borders (computed already, this pass runs deepest-first) + if (subB[kid].x < subA[kid].x) continue; + if (subA[kid].x < x0) x0 = subA[kid].x; if (subA[kid].y < y0) y0 = subA[kid].y; + if (subB[kid].x > x1) x1 = subB[kid].x; if (subB[kid].y > y1) y1 = subB[kid].y; + } + if (x1 < x0) continue; + subA[gni] = ImVec2(x0 - kSubPad, y0 - kSubPad - kHeaderH); subB[gni] = ImVec2(x1 + kSubPad, y1 + kSubPad); + } + for (int gni = 1; gni < (int)gtree.size(); ++gni) { + GNode& g2 = gtree[gni]; + if (g2.depth < 2 || !sv_length(g2.prefix)) continue; + if (collOwner[g2.gi] >= 0 && collOwner[g2.gi] != gni) continue; + if (subB[gni].x < subA[gni].x) continue; // nothing visible + GView g{}; g.gi = g2.gi; g.gj = g2.gj; g.depth = g2.depth; g.prefix = g2.prefix; + g.key = rg_grp_key(g2.prefix); g.collapsed = g2.collapsed; + rg_group_interface(rg, box, n, g2.gi, g2.gj, g.inId, g.nIn, g.outId, g.nOut); + orderIface(g2.gi, g2.gj, g.inId, g.nIn, false); + orderIface(g2.gi, g2.gj, g.outId, g.nOut, true); + ImVec2 a = subA[gni], b = subB[gni]; + g.bb0 = a; g.bb1 = b; g.h0 = a; g.h1 = ImVec2(b.x, a.y + kHeaderH); + if (!g.collapsed) { // expanded: bordered region + label (the compact-node pass draws the collapsed ones) + ImU32 sc = group_color(g2.prefix); + dl->AddRect(a, b, sc, 5.0f, 0, 1.5f); + WGPUStringView sn = g2.prefix; size_t off = 0; for (size_t i = 0; i < sv_length(sn); ++i) if (sn.data[i] == '.') off = i + 1; + char slbl[48]; std::snprintf(slbl, sizeof slbl, "[-] %.*s x%d", (int)(sv_length(sn) - off), sn.data + off, g2.gj - g2.gi); + dl->AddText(ImVec2(a.x + 4, a.y + 1), sc, slbl); + } + // expanded aligns pins to the members they serve so edges run across, collapsed top-aligns them + for (int s = 0; s < g.nIn; ++s) g.inC[s] = ImVec2(a.x, g.collapsed ? a.y + kHeaderH + s * kPinRowH + kPinRowH * 0.5f : ifaceY(g2.gi, g2.gj, g.inId[s], false, a.y + kHeaderH + s * kPinRowH + kPinRowH * 0.5f)); + for (int s = 0; s < g.nOut; ++s) g.outC[s] = ImVec2(b.x, g.collapsed ? a.y + kHeaderH + s * kPinRowH + kPinRowH * 0.5f : ifaceY(g2.gi, g2.gj, g.outId[s], true, a.y + kHeaderH + s * kPinRowH + kPinRowH * 0.5f)); + if (!g.collapsed) { + const float pinLo = a.y + kHeaderH + kPinRowH * 0.5f, pinHi = b.y - kPinRowH * 0.5f; + fitPins(g.inC, g.nIn, kPinRowH, pinLo, pinHi); fitPins(g.outC, g.nOut, kPinRowH, pinLo, pinHi); + } + nodeGView[gni] = (int)groups.size(); + groups.push_back(g); + } + + // pin centres, screen space. reads fill left slots in encounter order, writes fill right the same way. + auto inPin = [&](int b, int slot) { return ImVec2(origin.x + rgn[b].pos.x, + origin.y + rgn[b].pos.y + kHeaderH + slot * kPinRowH + kPinRowH * 0.5f); }; + auto outPin = [&](int b, int slot) { return ImVec2(origin.x + rgn[b].pos.x + rgn[b].w, + origin.y + rgn[b].pos.y + kHeaderH + slot * kPinRowH + kPinRowH * 0.5f); }; + // would the direct a->b cubic clip a box other than its two? sampled, an exact intersection is overkill. + auto directClear = [&](ImVec2 a, ImVec2 b, int src, int dst) { + const float dx = rg_edge_tangent(a, b); + const ImVec2 c1(a.x + dx, a.y), c2(b.x - dx, b.y); + for (int s = 1; s < 16; ++s) { + const float t = s / 16.0f, u = 1 - t; + const float w0 = u * u * u, w1 = 3 * u * u * t, w2 = 3 * u * t * t, w3 = t * t * t; + const ImVec2 q(w0 * a.x + w1 * c1.x + w2 * c2.x + w3 * b.x, w0 * a.y + w1 * c1.y + w2 * c2.y + w3 * b.y); + for (int i = 0; i < n; ++i) { + if (i == src || i == dst || drawHidden[i]) continue; + const float bx = origin.x + rgn[i].pos.x, by = origin.y + rgn[i].pos.y; + if (q.x >= bx - 2 && q.x <= bx + rgn[i].w + 2 && q.y >= by - 2 && q.y <= by + rgn[i].h + 2) return false; + } + } + return true; + }; + // screen-space polyline of an edge, shared by hit-test + draw. the lane chain only exists to stop an + // edge hiding behind a box, and a relaxed lane is what makes an edge detour, so take the lanes only when + // the direct run actually hits something. + auto edgePoints = [&](const REdge& e, ImVec2* pts) -> int { + const ImVec2 sp = outPin(e.src, e.sOut), dp = inPin(e.dst, e.dIn); + if (e.chainN == 0 || directClear(sp, dp, e.src, e.dst)) { pts[0] = sp; pts[1] = dp; return 2; } + int np = 0; + pts[np++] = sp; + for (int t = 0; t < e.chainN; ++t) { float lx = origin.x + lnode[e.chain[t]].x, ly = origin.y + lnode[e.chain[t]].y; pts[np++] = ImVec2(lx, ly); pts[np++] = ImVec2(lx + kBoxW, ly); } + pts[np++] = dp; + return np; + }; + bool matchBox[kRgDagMax]; + for (int i = 0; i < n; ++i) matchBox[i] = rg_name_has(rgn[i].pass->id.name, filter); + + // ---- hovered pin. manual rect test, pins are small and overlap the box button. + int hovB = -1, hovSlot = -1; bool hovWrite = false; uint32_t hovId = 0, hovMip = 0, hovLayer = 0; AccessType hovType{}; + if (canvasHovered && !canvasActive) { + for (int i = 0; i < n && hovB < 0; ++i) { + if (drawHidden[i]) continue; // hidden inside a collapsed group + int inS = 0, outS = 0; PassNode* p = rgn[i].pass; + for (uint32_t k = 0; k < p->accessCount && hovB < 0; ++k) { + const ResourceAccess& acc = p->accesses[k]; + if (rg_access_reads(acc)) { // input pin (left) + ImVec2 c = inPin(i, inS); + if (ImGui::IsMouseHoveringRect(ImVec2(c.x - kPinHit, c.y - kPinHit), ImVec2(c.x + kPinHit, c.y + kPinHit))) + { hovB = i; hovSlot = inS; hovWrite = false; hovId = acc.handle.id; hovType = acc.type; hovMip = acc.baseMip; hovLayer = acc.baseLayer; } + ++inS; + } + if (hovB < 0 && access_is_write(acc.type)) { // output pin (right) + ImVec2 c = outPin(i, outS); + if (ImGui::IsMouseHoveringRect(ImVec2(c.x - kPinHit, c.y - kPinHit), ImVec2(c.x + kPinHit, c.y + kPinHit))) + { hovB = i; hovSlot = outS; hovWrite = true; hovId = acc.handle.id; hovType = acc.type; hovMip = acc.baseMip; hovLayer = acc.baseLayer; } + ++outS; + } + } + } + } + // ---- group border pins, hovered like a member's pin so cone + tooltip + lock work. map the border pin + // to a representative member, then reuse the hovered-pin path below. the per-pass test above already + // catches expanded member pins, this adds the ones on the region edge. + if (hovB < 0 && canvasHovered && !canvasActive) + for (GView& g : groups) { + if (hovB >= 0) continue; + // both collapsed and expanded groups carry selectable interface pins + for (int s = 0; s < g.nIn && hovB < 0; ++s) { + ImVec2 c = g.inC[s]; + if (!ImGui::IsMouseHoveringRect(ImVec2(c.x - kPinHit, c.y - kPinHit), ImVec2(c.x + kPinHit, c.y + kPinHit))) continue; + for (int k = g.gi; k < g.gj && hovB < 0; ++k) { PassNode* mp = rgn[k].pass; + for (uint32_t ai = 0; ai < mp->accessCount; ++ai) + if (mp->accesses[ai].handle.id == g.inId[s] && rg_access_reads(mp->accesses[ai])) + { hovB = k; hovWrite = false; hovId = g.inId[s]; hovSlot = rg_in_slot(mp, g.inId[s]); hovType = mp->accesses[ai].type; hovMip = mp->accesses[ai].baseMip; hovLayer = mp->accesses[ai].baseLayer; break; } + } + } + for (int s = 0; s < g.nOut && hovB < 0; ++s) { + ImVec2 c = g.outC[s]; + if (!ImGui::IsMouseHoveringRect(ImVec2(c.x - kPinHit, c.y - kPinHit), ImVec2(c.x + kPinHit, c.y + kPinHit))) continue; + for (int k = g.gi; k < g.gj && hovB < 0; ++k) { PassNode* mp = rgn[k].pass; + for (uint32_t ai = 0; ai < mp->accessCount; ++ai) + if (mp->accesses[ai].handle.id == g.outId[s] && access_is_write(mp->accesses[ai].type)) + { hovB = k; hovWrite = true; hovId = g.outId[s]; hovSlot = rg_out_slot(mp, g.outId[s]); hovType = mp->accesses[ai].type; hovMip = mp->accesses[ai].baseMip; hovLayer = mp->accesses[ai].baseLayer; break; } + } + } + } + + // only for the fallback reads/writes tooltip when no pin caught the mouse + int hovBox = -1; + if (hovB < 0 && canvasHovered && !canvasActive) + for (int i = 0; i < n; ++i) { + if (drawHidden[i]) continue; // hidden inside a collapsed group + ImVec2 tl(origin.x + rgn[i].pos.x, origin.y + rgn[i].pos.y); + if (ImGui::IsMouseHoveringRect(tl, ImVec2(tl.x + rgn[i].w, tl.y + rgn[i].h))) { hovBox = i; break; } + } + + // ---- click a pin to LOCK its cone, click empty canvas to clear + static int lockB = -1, lockSlot = -1; static bool lockWrite = false; static uint32_t lockId = 0; + static bool pressed = false; static ImVec2 pressAt; + if (canvasHovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { pressed = true; pressAt = io.MousePos; } + if (pressed && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + pressed = false; + float mdx = io.MousePos.x - pressAt.x, mdy = io.MousePos.y - pressAt.y; + if (mdx * mdx + mdy * mdy < 16) { // a click, not a pan + int hitG = -1; // a group header click toggles that group's collapse state, overriding the default + for (int gI = 0; gI < (int)groups.size(); ++gI) + if (io.MousePos.x >= groups[gI].h0.x && io.MousePos.x <= groups[gI].h1.x && + io.MousePos.y >= groups[gI].h0.y && io.MousePos.y <= groups[gI].h1.y) hitG = gI; + if (hitG >= 0) grpStore->SetInt(groups[hitG].key, groups[hitG].collapsed ? 1 : 2); // flip effective state + else if (hovB >= 0) { + if (lockB == hovB && lockWrite == hovWrite && lockSlot == hovSlot) lockB = -1; // toggle off + else { lockB = hovB; lockWrite = hovWrite; lockSlot = hovSlot; lockId = hovId; } + } + else if (hovBox >= 0) { // a pass-body click selects the whole pass (lockSlot -1 / lockId 0 = no pin) + if (lockB == hovBox && lockSlot == -1) lockB = -1; // toggle off + else { lockB = hovBox; lockWrite = false; lockSlot = -1; lockId = 0; } + } + else lockB = -1; + } + } + if (lockB >= n) lockB = -1; // stale lock after the graph shrank + + // slot of resource `id` among a group's interface pins, -1 = none + auto gpin_slot = [](const uint32_t* ids, int cnt, uint32_t id) { for (int i = 0; i < cnt; ++i) if (ids[i] == id) return i; return -1; }; + // the group's compact-node pin when the member sits in a collapsed group, else the member's own pin + auto memberOut = [&](int k, uint32_t id) -> ImVec2 { + if (collOwner[k] >= 0) { int gv = nodeGView[collOwner[k]]; if (gv >= 0) { int sl = gpin_slot(groups[gv].outId, groups[gv].nOut, id); if (sl >= 0) return groups[gv].outC[sl]; } } + int ss = rg_out_slot(rgn[k].pass, id); return outPin(k, ss >= 0 ? ss : 0); + }; + auto memberIn = [&](int k, uint32_t id) -> ImVec2 { + if (collOwner[k] >= 0) { int gv = nodeGView[collOwner[k]]; if (gv >= 0) { int sl = gpin_slot(groups[gv].inId, groups[gv].nIn, id); if (sl >= 0) return groups[gv].inC[sl]; } } + int ds = rg_in_slot(rgn[k].pass, id); return inPin(k, ds >= 0 ? ds : 0); + }; + // terminals + the lane chain + any crossed group interface pins, ordered left-to-right by x, which also + // nests correctly. shared by hit-test and draw so a rerouted edge is hoverable where it is drawn. + // caller's wp must hold >= 3*kRgDagMax points. + auto buildEdgePath = [&](const REdge& e, ImVec2* wp) -> int { + ImVec2 sp = collOwner[e.src] >= 0 ? memberOut(e.src, e.id) : outPin(e.src, e.sOut); + ImVec2 dp = collOwner[e.dst] >= 0 ? memberIn(e.dst, e.id) : inPin(e.dst, e.dIn); + int exitG[kRgGPinMax], entryG[kRgGPinMax], nex = 0, nen = 0; + for (int gx = 0; gx < (int)groups.size(); ++gx) { + GView& g = groups[gx]; if (g.collapsed) continue; + bool hS = g.gi <= e.src && e.src < g.gj, hD = g.gi <= e.dst && e.dst < g.gj; + if (hS && !hD && gpin_slot(g.outId, g.nOut, e.id) >= 0 && nex < kRgGPinMax) exitG[nex++] = gx; + if (hD && !hS && gpin_slot(g.inId, g.nIn, e.id) >= 0 && nen < kRgGPinMax) entryG[nen++] = gx; + } + if (nex == 0 && nen == 0 && collOwner[e.src] < 0 && collOwner[e.dst] < 0) return edgePoints(e, wp); // plain edge + // the border pins are required waypoints, the lane chain is not, it only dodges boxes. taking the + // lanes unconditionally makes an already-clear run climb to the lane's y and read as a detour, so + // build the lane-free path first and keep it when every span is clear. + auto assemble = [&](bool withLanes) -> int { + int nw2 = 0; wp[nw2++] = sp; + if (withLanes) + for (int t = 0; t < e.chainN; ++t) { + float lx = origin.x + lnode[e.chain[t]].x, ly = origin.y + lnode[e.chain[t]].y; + wp[nw2++] = ImVec2(lx, ly); wp[nw2++] = ImVec2(lx + kBoxW, ly); + } + for (int i2 = 0; i2 < nex; ++i2) { GView& g = groups[exitG[i2]]; wp[nw2++] = g.outC[gpin_slot(g.outId, g.nOut, e.id)]; } + for (int i2 = 0; i2 < nen; ++i2) { GView& g = groups[entryG[i2]]; wp[nw2++] = g.inC [gpin_slot(g.inId, g.nIn, e.id)]; } + wp[nw2++] = dp; + for (int a2 = 1; a2 < nw2; ++a2) { ImVec2 v = wp[a2]; int b2 = a2 - 1; while (b2 >= 0 && wp[b2].x > v.x) { wp[b2 + 1] = wp[b2]; --b2; } wp[b2 + 1] = v; } + return nw2; + }; + int nw = assemble(false); + bool clear = true; + for (int t = 0; t + 1 < nw && clear; ++t) clear = directClear(wp[t], wp[t + 1], e.src, e.dst); + if (!clear && e.chainN) nw = assemble(true); + return nw; + }; + + // ---- hovered edge, only when not over a pin or box + int hovEdge = -1; + if (hovB < 0 && hovBox < 0 && canvasHovered && !canvasActive) { + float best = 6.0f * 6.0f; + for (int ei = 0; ei < (int)edge.size(); ++ei) { + if (collOwner[edge[ei].src] >= 0 && collOwner[edge[ei].src] == collOwner[edge[ei].dst]) continue; // vanished + ImVec2 pts[3 * kRgDagMax]; int np = buildEdgePath(edge[ei], pts); + for (int t = 0; t + 1 < np; ++t) { + ImVec2 a2 = pts[t], b2 = pts[t + 1]; float dx = rg_edge_tangent(a2, b2); + ImVec2 c1(a2.x + dx, a2.y), c2(b2.x - dx, b2.y), prev = a2; + for (int s2 = 1; s2 <= 10; ++s2) { // sample the cubic + float tt = s2 / 10.0f, u = 1 - tt, w0 = u * u * u, w1 = 3 * u * u * tt, w2 = 3 * u * tt * tt, w3 = tt * tt * tt; + ImVec2 q(w0 * a2.x + w1 * c1.x + w2 * c2.x + w3 * b2.x, w0 * a2.y + w1 * c1.y + w2 * c2.y + w3 * b2.y); + float d2 = rg_seg_d2(io.MousePos, prev, q); if (d2 < best) { best = d2; hovEdge = ei; } + prev = q; + } + } + } + } + + // ---- cones, seeded from the hovered pin, else the locked one. upstream is the producers needed to + // make the resource, and an output pin also marks downstream. + int coneB = hovB >= 0 ? hovB : lockB; + bool coneWrite = hovB >= 0 ? hovWrite : lockWrite; + uint32_t coneId = hovB >= 0 ? hovId : lockId; + const bool conePass = hovB < 0 && lockB >= 0 && lockSlot == -1; // whole-pass selection: both cones from the pass + bool inCone[kRgDagMax] = {}, downCone[kRgDagMax] = {}; bool coneActive = false; + if (coneB >= 0) { + int seed = (coneWrite || conePass) ? coneB : rg_producer_of(box, n, box[coneB].p, coneId); + if (seed >= 0) { rg_mark_cone(box, n, seed, inCone); inCone[coneB] = true; coneActive = true; } // include the hovered node so its immediate edge lights + if (coneActive && (coneWrite || conePass)) { // downstream descendants over the data edges + int st[kRgDagMax], sp = 0; st[sp++] = coneB; + while (sp) { int u = st[--sp]; for (REdge& e : edge) if (e.src == u && !downCone[e.dst]) { downCone[e.dst] = true; st[sp++] = e.dst; } } + } + } + auto fout = [&](int i) { return filterActive && !matchBox[i]; }; + auto isDim = [&](int i) { return fout(i) || (coneActive && !inCone[i] && !downCone[i] && !(coneWrite && i == coneB)); }; + auto inUp = [&](int i) { return coneActive && inCone[i] && !fout(i); }; + auto inDn = [&](int i) { return coneActive && (downCone[i] || (coneWrite && i == coneB)) && !fout(i); }; + // by cone state: gold upstream, teal down, grey normal, faint dim. shared by the top-level and interior + // edge loops, so an edge inside a group reads the same as one outside. + auto edgeCol = [&](int s, int d, float* th) { + bool eup = inUp(s) && inUp(d), edn = inDn(s) && inDn(d); + if (eup) { if (th) *th = 2.5f; return IM_COL32(245, 222, 120, 235); } + if (edn) { if (th) *th = 2.5f; return IM_COL32(120, 222, 180, 235); } + if (isDim(s) || isDim(d)) { if (th) *th = 2.0f; return IM_COL32(150, 150, 150, 34); } + if (th) *th = 2.0f; return IM_COL32(170, 170, 170, 200); + }; + + // ---- data edges. an edge interior to a collapsed group vanishes, one crossing a border threads through + // that group's interface pin, everything else routes through its dummy waypoints. + for (int ei = 0; ei < (int)edge.size(); ++ei) { + REdge& e = edge[ei]; + if (collOwner[e.src] >= 0 && collOwner[e.src] == collOwner[e.dst]) continue; // interior to one collapsed group (any level) -> vanishes + ImU32 col; float th; + if (ei == hovEdge) { col = IM_COL32(255, 255, 255, 255); th = 3.0f; } + else col = edgeCol(e.src, e.dst, &th); + ImVec2 wp[3 * kRgDagMax]; int nw = buildEdgePath(e, wp); + for (int t = 0; t + 1 < nw; ++t) { ImVec2 a2 = wp[t], b2 = wp[t + 1]; float dx = rg_edge_tangent(a2, b2); dl->AddBezierCubic(a2, ImVec2(a2.x + dx, a2.y), ImVec2(b2.x - dx, b2.y), b2, col, th); } + } + + // no interior group edges or subgroup stubs: an expanded group's members are first-class boxes, so their + // intra-group flow is ordinary edge[] entries drawn above. + + // ---- boxes + for (int i = 0; i < n; ++i) { + if (drawHidden[i]) continue; // hidden inside a collapsed group node (drawn separately below) + ImVec2 tl(origin.x + rgn[i].pos.x, origin.y + rgn[i].pos.y), br(tl.x + rgn[i].w, tl.y + rgn[i].h); + bool dim = isDim(i), up = inUp(i), dn = inDn(i); + + ImU32 fill = rg_kind_color(rgn[i].pass->kind); + dl->AddRectFilled(tl, br, dim ? rg_with_alpha(fill, 55) : fill, 5.0f); + ImU32 edgec = up ? IM_COL32(255, 255, 255, 255) : dn ? IM_COL32(120, 222, 180, 255) : dim ? IM_COL32(40, 40, 40, 120) : IM_COL32(20, 20, 20, 255); + dl->AddRect(tl, br, edgec, 5.0f, 0, (up || dn) ? 2.5f : 1.0f); + if (lockB == i && lockSlot == -1) // whole-pass selection (body click): blue selection ring + dl->AddRect(ImVec2(tl.x - 2, tl.y - 2), ImVec2(br.x + 2, br.y + 2), IM_COL32(90, 200, 230, 255), 6.0f, 0, 2.0f); + dl->AddLine(ImVec2(tl.x, tl.y + kHeaderH), ImVec2(br.x, tl.y + kHeaderH), IM_COL32(255, 255, 255, dim ? 18 : 40)); + + // imported writers only. history writers are sinks to the culler but not graph outputs. + if (rg_pass_is_sink(rg, rgn[i].pass)) + for (int e = 3; e >= 1; --e) + dl->AddRect(ImVec2(tl.x - e, tl.y - e), ImVec2(br.x + e, br.y + e), IM_COL32(255, 100, 0, dim ? 80 : 200), 6.0f, 0, 1.0f); + + WGPUStringView nm = rgn[i].pass->id.name; + char head[96]; + std::snprintf(head, sizeof head, "P%d %.*s", i, (int)nm.length, nm.data ? nm.data : ""); + dl->PushClipRect(tl, ImVec2(br.x - 4, br.y), true); + dl->AddText(ImVec2(tl.x + 7, tl.y + 4), dim ? IM_COL32(190, 190, 190, 120) : IM_COL32(255, 255, 255, 255), head); + dl->PopClipRect(); + + const char* kn = rg_kind_name(rgn[i].pass->kind); + ImVec2 ks = ImGui::CalcTextSize(kn); + dl->AddText(ImVec2(tl.x + (rgn[i].w - ks.x) * 0.5f, br.y - kFooterH + 1), + dim ? IM_COL32(200, 200, 200, 110) : IM_COL32(225, 225, 225, 220), kn); + } + + // ---- pins + resource labels + for (int i = 0; i < n; ++i) { + if (drawHidden[i]) continue; // hidden inside a collapsed group node (drawn separately below) + PassNode* p = rgn[i].pass; bool dim = isDim(i); + const float mid = origin.x + rgn[i].pos.x + rgn[i].w * 0.5f; + int inS = 0, outS = 0; + for (uint32_t k = 0; k < p->accessCount; ++k) { + const ResourceAccess& acc = p->accesses[k]; + ResourceNode* r = find_node(rg, acc.handle); + WGPUStringView rn = r ? r->id.name : WGPUStringView{}; + char lbl[48]; std::snprintf(lbl, sizeof lbl, "%.*s", (int)rn.length, rn.data ? rn.data : "?"); + const ImU32 lc = dim ? IM_COL32(190, 190, 190, 110) : IM_COL32(230, 230, 230, 255); + const bool isBuf = r && r->kind == ResourceKind::Buffer; // square pin vs round (texture) + + if (rg_access_reads(acc)) { // input pin (left); hollow if no in-graph producer (external input) + int slot = inS++; ImVec2 c = inPin(i, slot); + ImU32 base = dim ? rg_with_alpha(kRGRead, 70) : kRGRead; + rg_draw_pin(dl, c, kPinR, base, rg_producer_of(box, n, p, acc.handle.id) >= 0, isBuf); + if (lockB == i && !lockWrite && slot == lockSlot) dl->AddCircle(c, kPinR + 3.0f, IM_COL32(90, 200, 230, 255), 16, 2.0f); + if (i == hovB && !hovWrite && slot == hovSlot) dl->AddCircle(c, kPinR + 3.0f, IM_COL32(255, 255, 255, 255), 16, 2.0f); + const float lx = c.x + kPinR + 3; + char t[48]; std::snprintf(t, sizeof t, "%s", lbl); + rg_fit_text(t, mid - lx); // reads own the left half of the row + ImVec2 ts = ImGui::CalcTextSize(t); + dl->PushClipRect(ImVec2(lx, c.y - kPinRowH * 0.5f), ImVec2(mid, c.y + kPinRowH * 0.5f), true); + dl->AddText(ImVec2(lx, c.y - ts.y * 0.5f), lc, t); + dl->PopClipRect(); + } + if (access_is_write(acc.type)) { // output pin (right) + int slot = outS++; ImVec2 c = outPin(i, slot); + ImU32 base = dim ? rg_with_alpha(kRGWrite, 70) : kRGWrite; + rg_draw_pin(dl, c, kPinR, base, true, isBuf); + if (lockB == i && lockWrite && slot == lockSlot) dl->AddCircle(c, kPinR + 3.0f, IM_COL32(90, 200, 230, 255), 16, 2.0f); + if (i == hovB && hovWrite && slot == hovSlot) dl->AddCircle(c, kPinR + 3.0f, IM_COL32(255, 255, 255, 255), 16, 2.0f); + const float rx = c.x - kPinR - 3; + char t[48]; std::snprintf(t, sizeof t, "%s", lbl); + rg_fit_text(t, rx - mid); // writes own the right half; fitted so the name keeps its head + ImVec2 ts = ImGui::CalcTextSize(t); + dl->PushClipRect(ImVec2(mid, c.y - kPinRowH * 0.5f), ImVec2(rx, c.y + kPinRowH * 0.5f), true); + dl->AddText(ImVec2(rx - ts.x, c.y - ts.y * 0.5f), lc, t); + dl->PopClipRect(); + } + } + } + + // ---- one synthetic box per collapsed group, drawn over the edges like a real pass, carrying the + // external read pins left and write pins right + for (GView& g : groups) { + if (!g.collapsed) continue; + ImVec2 a = g.bb0, b = g.bb1; + const float mid = (a.x + b.x) * 0.5f; + dl->AddRectFilled(a, b, IM_COL32(70, 70, 96, 255), 5.0f); + dl->AddRect(a, b, IM_COL32(20, 20, 20, 255), 5.0f, 0, 1.0f); + dl->AddLine(ImVec2(a.x, a.y + kHeaderH), ImVec2(b.x, a.y + kHeaderH), IM_COL32(255, 255, 255, 40)); + char head[64]; std::snprintf(head, sizeof head, "[+] %.*s x%d", (int)sv_length(g.prefix), g.prefix.data, g.gj - g.gi); + dl->PushClipRect(a, ImVec2(b.x - 4, b.y), true); + dl->AddText(ImVec2(a.x + 7, a.y + 4), IM_COL32(255, 255, 255, 255), head); + dl->PopClipRect(); + for (int s = 0; s < g.nIn; ++s) { + ResourceNode* r = find_node(rg, { g.inId[s] }); + WGPUStringView rn = r ? r->id.name : WGPUStringView{}; + bool buf = r && r->kind == ResourceKind::Buffer; + bool prod = false; for (int j = 0; j < n; ++j) if (rg_pass_writes(box[j].p, g.inId[s])) prod = true; + rg_draw_pin(dl, g.inC[s], kPinR, kRGRead, prod, buf); // hollow = external input (no in-graph producer) + if (hovB >= g.gi && hovB < g.gj && !hovWrite && hovId == g.inId[s]) dl->AddCircle(g.inC[s], kPinR + 3.0f, IM_COL32(255, 255, 255, 255), 16, 2.0f); + if (lockB >= g.gi && lockB < g.gj && !lockWrite && lockId == g.inId[s]) dl->AddCircle(g.inC[s], kPinR + 3.0f, IM_COL32(90, 200, 230, 255), 16, 2.0f); + char lbl[48]; std::snprintf(lbl, sizeof lbl, "%.*s", (int)rn.length, rn.data ? rn.data : "?"); + ImVec2 ls = ImGui::CalcTextSize(lbl); + dl->PushClipRect(ImVec2(g.inC[s].x + kPinR + 3, g.inC[s].y - kPinRowH * 0.5f), ImVec2(mid, g.inC[s].y + kPinRowH * 0.5f), true); + dl->AddText(ImVec2(g.inC[s].x + kPinR + 3, g.inC[s].y - ls.y * 0.5f), IM_COL32(230, 230, 230, 255), lbl); + dl->PopClipRect(); + } + for (int s = 0; s < g.nOut; ++s) { + ResourceNode* r = find_node(rg, { g.outId[s] }); + WGPUStringView rn = r ? r->id.name : WGPUStringView{}; + bool buf = r && r->kind == ResourceKind::Buffer; + rg_draw_pin(dl, g.outC[s], kPinR, kRGWrite, true, buf); + if (hovB >= g.gi && hovB < g.gj && hovWrite && hovId == g.outId[s]) dl->AddCircle(g.outC[s], kPinR + 3.0f, IM_COL32(255, 255, 255, 255), 16, 2.0f); + if (lockB >= g.gi && lockB < g.gj && lockWrite && lockId == g.outId[s]) dl->AddCircle(g.outC[s], kPinR + 3.0f, IM_COL32(90, 200, 230, 255), 16, 2.0f); + char lbl[48]; std::snprintf(lbl, sizeof lbl, "%.*s", (int)rn.length, rn.data ? rn.data : "?"); + ImVec2 ls = ImGui::CalcTextSize(lbl); + dl->PushClipRect(ImVec2(mid, g.outC[s].y - kPinRowH * 0.5f), ImVec2(g.outC[s].x - kPinR - 3, g.outC[s].y + kPinRowH * 0.5f), true); + dl->AddText(ImVec2(g.outC[s].x - kPinR - 3 - ls.x, g.outC[s].y - ls.y * 0.5f), IM_COL32(230, 230, 230, 255), lbl); + dl->PopClipRect(); + } + } + + // the dots on the hull edges that every boundary edge threads through. a ring lights when a member pin + // for the same resource is hovered or locked. + for (GView& g : groups) { + if (g.collapsed) continue; + for (int s = 0; s < g.nIn; ++s) { + ResourceNode* r = find_node(rg, { g.inId[s] }); bool buf = r && r->kind == ResourceKind::Buffer; + bool prod = false; for (int j = 0; j < n; ++j) if (rg_pass_writes(box[j].p, g.inId[s])) { prod = true; break; } + rg_draw_pin(dl, g.inC[s], kPinR, kRGRead, prod, buf); + if (hovB >= g.gi && hovB < g.gj && !hovWrite && hovId == g.inId[s]) dl->AddCircle(g.inC[s], kPinR + 3.0f, IM_COL32(255, 255, 255, 255), 16, 2.0f); + if (lockB >= g.gi && lockB < g.gj && !lockWrite && lockId == g.inId[s]) dl->AddCircle(g.inC[s], kPinR + 3.0f, IM_COL32(90, 200, 230, 255), 16, 2.0f); + } + for (int s = 0; s < g.nOut; ++s) { + ResourceNode* r = find_node(rg, { g.outId[s] }); bool buf = r && r->kind == ResourceKind::Buffer; + rg_draw_pin(dl, g.outC[s], kPinR, kRGWrite, true, buf); + if (hovB >= g.gi && hovB < g.gj && hovWrite && hovId == g.outId[s]) dl->AddCircle(g.outC[s], kPinR + 3.0f, IM_COL32(255, 255, 255, 255), 16, 2.0f); + if (lockB >= g.gi && lockB < g.gj && lockWrite && lockId == g.outId[s]) dl->AddCircle(g.outC[s], kPinR + 3.0f, IM_COL32(90, 200, 230, 255), 16, 2.0f); + } + } + + // ---- virtual endpoint nodes + dashed links, from the IR mirror. a source feeds a pass input pin from + // the left, a sink is fed by an output pin, an imported buffer fans one source to all readers. + // this resolves the pin position on a collapsed group, or the box edge at `y` when the resource crosses + // the boundary without a pin, e.g. a history write consumed only next frame. + auto grpPin = [&](GView& g, uint32_t id, bool write, float y) -> ImVec2 { + int sl = gpin_slot(write ? g.outId : g.inId, write ? g.nOut : g.nIn, id); + if (sl >= 0) return write ? g.outC[sl] : g.inC[sl]; + return ImVec2(write ? g.bb1.x : g.bb0.x, y); + }; + // the border pins between member pe and the outside, so a virtual link threads through the same pins the + // data edges do. fills outermost-first, returns the count. + auto memberPins = [&](int pe, uint32_t id, bool write, ImVec2* out) -> int { + int idx[kRgGPinMax], ni = 0; + for (int gx = 0; gx < (int)groups.size(); ++gx) { + GView& g = groups[gx]; if (g.collapsed) continue; + if (!(g.gi <= pe && pe < g.gj)) continue; + if (gpin_slot(write ? g.outId : g.inId, write ? g.nOut : g.nIn, id) < 0) continue; + if (ni < kRgGPinMax) idx[ni++] = gx; + } + for (int a = 1; a < ni; ++a) { int v = idx[a], b = a - 1; while (b >= 0 && groups[idx[b]].depth > groups[v].depth) { idx[b + 1] = idx[b]; --b; } idx[b + 1] = v; } + for (int i = 0; i < ni; ++i) { GView& g = groups[idx[i]]; out[i] = write ? g.outC[gpin_slot(g.outId, g.nOut, id)] : g.inC[gpin_slot(g.inId, g.nIn, id)]; } + return ni; + }; + static std::vector vLive; vLive.assign(rgn.size(), 0); + for (RgEdge& ge : rge) { + bool srcV = ge.srcNode >= n, dstV = ge.dstNode >= n; + if (!srcV && !dstV) continue; // pass<->pass edge: drawn earlier + int vi = srcV ? ge.srcNode : ge.dstNode, pe = srcV ? ge.dstNode : ge.srcNode; // virtual node, anchor pass + bool faint = ge.kind == RgEdge::Kind::Fanout; // imported-buffer fan: one faint edge per reader + if (fout(pe) || (faint && drawHidden[pe])) continue; + RgNode& vn = rgn[vi]; + ImU32 tint = isDim(pe) ? rg_with_alpha(vn.tint, 40) : (faint ? rg_with_alpha(vn.tint, 90) : vn.tint); // cone-dim with anchor + float vcy = origin.y + vn.pos.y + vn.h * 0.5f; + // thread the dashed link through the same border pins as a data edge. collapsed groups terminate at + // the compact pin. + ImVec2 wp[2 + kRgGPinMax]; int nw = 0; ImVec2 gp[kRgGPinMax]; + if (srcV) { // read: node right edge -> reader input pin + wp[nw++] = ImVec2(origin.x + vn.pos.x + vn.w, vcy); + if (groupOf[pe] >= 0 && groups[groupOf[pe]].collapsed) { // collapsed top-level group: its compact in-pin + GView& g = groups[groupOf[pe]]; + if (faint) { int s = gpin_slot(g.inId, g.nIn, ge.resId); if (s < 0 || g.inDrawn[s]) continue; g.inDrawn[s] = true; wp[nw++] = g.inC[s]; } + else wp[nw++] = grpPin(g, ge.resId, false, wp[0].y); + } + else if (collOwner[pe] >= 0) wp[nw++] = memberIn(pe, ge.resId); // folded into a collapsed subgroup + else { // expanded/ungrouped: thread group pins -> member + int ng = memberPins(pe, ge.resId, false, gp); + for (int i = 0; i < ng; ++i) wp[nw++] = gp[i]; + wp[nw++] = inPin(pe, ge.dstPin); + } + } + else { // write: writer output pin -> node left edge + ImVec2 qq(origin.x + vn.pos.x, vcy); + if (groupOf[pe] >= 0 && groups[groupOf[pe]].collapsed) wp[nw++] = grpPin(groups[groupOf[pe]], ge.resId, true, qq.y); + else if (collOwner[pe] >= 0) wp[nw++] = memberOut(pe, ge.resId); + else { wp[nw++] = outPin(pe, ge.srcPin); int ng = memberPins(pe, ge.resId, true, gp); for (int i = ng - 1; i >= 0; --i) wp[nw++] = gp[i]; } + wp[nw++] = qq; + } + vLive[vi] = 1; + for (int t = 0; t + 1 < nw; ++t) { ImVec2 a2 = wp[t], b2 = wp[t + 1]; float dx = rg_edge_tangent(a2, b2); rg_dashed_cubic(dl, a2, ImVec2(a2.x + dx, a2.y), ImVec2(b2.x - dx, b2.y), b2, tint, faint ? 1.5f : 2.0f); } + ImVec2 tip = wp[nw - 1]; + rg_arrowhead(dl, ImVec2(tip.x - 8, tip.y), tip, tint, faint ? 6.0f : 7.0f); + } + for (int vi = n; vi < (int)rgn.size(); ++vi) { + if (!vLive[vi]) continue; // node with no surviving link (all readers filtered/deduped) stays hidden + RgNode& vn = rgn[vi]; + ImVec2 a(origin.x + vn.pos.x, origin.y + vn.pos.y), b(a.x + vn.w, a.y + vn.h); + char nm[48]; std::snprintf(nm, sizeof nm, "%.*s", (int)vn.res->id.name.length, vn.res->id.name.data ? vn.res->id.name.data : "?"); + const char* cap = vn.label.data ? vn.label.data : ""; + dl->AddRectFilled(a, b, IM_COL32(32, 30, 40, 240), 4.0f); + dl->AddRect(a, b, vn.tint, 4.0f, 0, 1.5f); + float cx = (a.x + b.x) * 0.5f; + ImVec2 ns = ImGui::CalcTextSize(nm), cs = ImGui::CalcTextSize(cap); + dl->AddText(ImVec2(cx - ns.x * 0.5f, a.y + 3), IM_COL32(238, 236, 242, 255), nm); + dl->AddText(ImVec2(cx - cs.x * 0.5f, b.y - cs.y - 3), rg_with_alpha(vn.tint, 220), cap); + } + + // ---- tooltip. a hovered pin wins, else the per-pass reads/writes list. + if (hovB >= 0) { + PassNode* p = rgn[hovB].pass; ResourceNode* r = find_node(rg, { hovId }); + WGPUStringView rn = r ? r->id.name : WGPUStringView{}; + ImGui::BeginTooltip(); + ImGui::Text("%.*s", (int)rn.length, rn.data ? rn.data : "?"); + if (r) { + if (r->kind == ResourceKind::Texture) ImGui::Text("texture %u x %u", r->resolved.width, r->resolved.height); + else ImGui::Text("buffer %llu bytes", (unsigned long long)r->bufferSize); + } + // the subresource this pin touches: layer for an array, mip for a chain + if (r && r->kind == ResourceKind::Texture) { + uint32_t layers = r->resolved.depthOrArrayLayers ? r->resolved.depthOrArrayLayers : 1; + uint32_t mips = r->mipLevelCount ? r->mipLevelCount : 1; + if (layers > 1 || mips > 1 || hovLayer > 0 || hovMip > 0) { + char sub[64]; int o = 0; + if (layers > 1 || hovLayer > 0) o += std::snprintf(sub + o, sizeof sub - o, "layer %u / %u", hovLayer, layers); + if (mips > 1 || hovMip > 0) o += std::snprintf(sub + o, sizeof sub - o, "%smip %u / %u", o ? " " : "", hovMip, mips); + ImGui::TextDisabled("%s", sub); + } + } + const char* verb = hovWrite ? "write" + : (hovType == AccessType::ColorAttachment || hovType == AccessType::DepthStencilAttachment) ? "load" + : "read"; + ImGui::TextDisabled("%s on P%d (%s)", verb, hovB, rg_access_name(hovType)); + ImGui::Separator(); + if (hovWrite) ImGui::Text("produced here"); + else { + int prod = rg_producer_of(box, n, p, hovId); + if (prod >= 0) { + WGPUStringView pn = rgn[prod].pass->id.name; + ImGui::Text("produced by P%d %.*s", prod, (int)pn.length, pn.data ? pn.data : ""); + } + else ImGui::TextDisabled(r && r->imported ? "imported (external input)" : "external input (no producer)"); + } + ImGui::EndTooltip(); + } + else if (hovBox >= 0) { + PassNode* p = rgn[hovBox].pass; WGPUStringView nm = p->id.name; + const char* kn = rg_kind_name(p->kind); + ImGui::BeginTooltip(); + ImGui::Text("%.*s [%s]", (int)nm.length, nm.data ? nm.data : "", kn); + ImGui::Separator(); + for (uint32_t k = 0; k < p->accessCount; ++k) { + const ResourceAccess& acc = p->accesses[k]; + ResourceNode* r = find_node(rg, acc.handle); + WGPUStringView rn = r ? r->id.name : WGPUStringView{}; + ImGui::Text("[%s] %.*s (%s)%s", access_is_write(acc.type) ? "W" : "R", + (int)rn.length, rn.data ? rn.data : "", rg_access_name(acc.type), + r ? (r->imported ? " [imported]" : r->history ? " [history]" : r->persistent ? " [persistent]" : "") : ""); + } + ImGui::EndTooltip(); + } + else if (hovEdge >= 0) { + REdge& e = edge[hovEdge]; ResourceNode* r = find_node(rg, { e.id }); + WGPUStringView rn = r ? r->id.name : WGPUStringView{}; + WGPUStringView sn = rgn[e.src].pass->id.name, dn = rgn[e.dst].pass->id.name; + ImGui::BeginTooltip(); + ImGui::Text("%.*s", (int)rn.length, rn.data ? rn.data : "?"); + ImGui::TextDisabled("P%d %.*s -> P%d %.*s", e.src, (int)sn.length, sn.data ? sn.data : "", + e.dst, (int)dn.length, dn.data ? dn.data : ""); + ImGui::EndTooltip(); + } + + ImGui::SetWindowFontScale(1.0f); // overlays (details panel, zoom indicator) stay readable at any zoom + + // ---- details panel for the locked pin + its pass. a top-right overlay on the canvas draw list, no + // nested child. a pin click fills it, an empty-canvas click clears it. + if (lockB >= 0 && lockB < n) { + PassNode* p = rgn[lockB].pass; + ResourceNode* r = find_node(rg, { lockId }); + // the access this pin stands for, matched on id + side, giving type + touched subresource + AccessType selType{}; uint32_t selMip = 0, selLayer = 0; + for (uint32_t k = 0; k < p->accessCount; ++k) { + const ResourceAccess& a = p->accesses[k]; + if (a.handle.id == lockId && access_is_write(a.type) == lockWrite) + { selType = a.type; selMip = a.baseMip; selLayer = a.baseLayer; break; } + } + + char ln[48][128]; ImU32 lc[48]; int nl = 0; + const ImU32 cTitle = IM_COL32(238, 236, 242, 255), cDim = IM_COL32(150, 150, 162, 255), + cHead = IM_COL32(120, 200, 190, 255); + auto add = [&](ImU32 col, const char* fmt, ...) { + if (nl >= 48) return; + va_list ap; va_start(ap, fmt); std::vsnprintf(ln[nl], sizeof ln[nl], fmt, ap); va_end(ap); + lc[nl++] = col; + }; + + const bool pinSel = lockSlot >= 0; // a specific pin vs. a whole-pass selection (body click) + WGPUStringView pn = p->id.name; + add(cTitle, "P%d %.*s [%s]", lockB, (int)pn.length, pn.data ? pn.data : "", rg_kind_name(p->kind)); + add(cDim, ""); + if (pinSel) { + add(cHead, "SELECTED PIN"); + WGPUStringView rn = r ? r->id.name : WGPUStringView{}; + add(r ? rg_resource_color(r->kind) : cDim, "%.*s", (int)rn.length, rn.data ? rn.data : "?"); + if (r) { + if (r->kind == ResourceKind::Texture) add(cDim, "texture %u x %u", r->resolved.width, r->resolved.height); + else add(cDim, "buffer %llu bytes", (unsigned long long)r->bufferSize); + } + if (r && r->kind == ResourceKind::Texture) { + uint32_t layers = r->resolved.depthOrArrayLayers ? r->resolved.depthOrArrayLayers : 1; + uint32_t mips = r->mipLevelCount ? r->mipLevelCount : 1; + if (layers > 1 || mips > 1 || selLayer > 0 || selMip > 0) + add(cDim, "layer %u / %u mip %u / %u", selLayer, layers, selMip, mips); + } + const char* verb = lockWrite ? "write" + : (selType == AccessType::ColorAttachment || selType == AccessType::DepthStencilAttachment) ? "load" : "read"; + add(cDim, "%s (%s)", verb, rg_access_name(selType)); + if (lockWrite) add(cDim, "produced here"); + else { + int prod = rg_producer_of(box, n, p, lockId); + if (prod >= 0) { WGPUStringView qn = rgn[prod].pass->id.name; add(cDim, "produced by P%d %.*s", prod, (int)qn.length, qn.data ? qn.data : ""); } + else add(cDim, r && r->imported ? "imported (external input)" : "external input"); + } + add(cDim, ""); + } + add(cHead, "PASS ACCESSES"); + for (uint32_t k = 0; k < p->accessCount; ++k) { + const ResourceAccess& acc = p->accesses[k]; + ResourceNode* ar = find_node(rg, acc.handle); + WGPUStringView arn = ar ? ar->id.name : WGPUStringView{}; + bool sel = acc.handle.id == lockId && access_is_write(acc.type) == lockWrite; + add(sel ? cTitle : cDim, "%s [%s] %.*s (%s)%s", sel ? ">" : " ", + access_is_write(acc.type) ? "W" : "R", (int)arn.length, arn.data ? arn.data : "", + rg_access_name(acc.type), ar ? (ar->imported ? " [imp]" : ar->history ? " [hist]" : ar->persistent ? " [pers]" : "") : ""); + } + + // size to content, place top-right clamped into the canvas, then draw + float lineH = ImGui::GetTextLineHeightWithSpacing(), maxW = 0; + for (int i = 0; i < nl; ++i) { float w = ImGui::CalcTextSize(ln[i]).x; if (w > maxW) maxW = w; } + const float padX = 10, padY = 8; + float panelW = maxW + padX * 2, panelH = nl * lineH + padY * 2; + ImVec2 tl(winPos.x + winSize.x - panelW - 8, winPos.y + 8); + if (tl.x < winPos.x + 8) tl.x = winPos.x + 8; + dl->AddRectFilled(tl, ImVec2(tl.x + panelW, tl.y + panelH), IM_COL32(24, 26, 32, 235), 5.0f); + dl->AddRect(tl, ImVec2(tl.x + panelW, tl.y + panelH), IM_COL32(80, 86, 100, 255), 5.0f); + float y = tl.y + padY; + for (int i = 0; i < nl; ++i) { dl->AddText(ImVec2(tl.x + padX, y), lc[i], ln[i]); y += lineH; } + } + + // ---- zoom level, bottom-right corner + char zb[16]; std::snprintf(zb, sizeof zb, "%d%%", (int)(zoom * 100.0f + 0.5f)); + ImVec2 zs = ImGui::CalcTextSize(zb); + ImVec2 zp(winPos.x + winSize.x - zs.x - 10, winPos.y + winSize.y - zs.y - 8); + dl->AddRectFilled(ImVec2(zp.x - 6, zp.y - 4), ImVec2(zp.x + zs.x + 6, zp.y + zs.y + 4), IM_COL32(24, 26, 32, 200), 4.0f); + dl->AddText(zp, IM_COL32(220, 220, 230, 255), zb); + + ImGui::EndChild(); +} + +// what `p` does to `id`: bit 1 reads, bit 2 writes, 0 does not touch it +static int rg_pass_access(PassNode* p, uint32_t id) +{ + int a = 0; + for (uint32_t i = 0; i < p->accessCount; ++i) + if (p->accesses[i].handle.id == id) + a |= access_is_write(p->accesses[i].type) ? 2 : 1; + return a; +} + +// colour per alias slot, so transients packed onto one slot read as one hue. cycles a small palette, +// keeping adjacent slot indices distinguishable. +static ImU32 rg_slot_color(uint32_t slot) +{ + static const ImU32 pal[] = { + IM_COL32( 90, 170, 250, 255), IM_COL32(250, 170, 90, 255), IM_COL32(130, 220, 130, 255), + IM_COL32(220, 130, 220, 255), IM_COL32(230, 210, 90, 255), IM_COL32(110, 210, 220, 255), + IM_COL32(240, 140, 140, 255), IM_COL32(170, 160, 250, 255), + }; + return pal[slot % (sizeof pal / sizeof pal[0])]; +} + +// short format tag for the slot row labels, display only +static const char* rg_format_short(WGPUTextureFormat f) +{ + switch (f) { + case WGPUTextureFormat_RGBA8Unorm: return "RGBA8"; + case WGPUTextureFormat_BGRA8Unorm: return "BGRA8"; + case WGPUTextureFormat_RGBA16Float: return "RGBA16F"; + case WGPUTextureFormat_R32Float: return "R32F"; + case WGPUTextureFormat_Depth32Float: return "D32F"; + case WGPUTextureFormat_Depth24Plus: return "D24+"; + case WGPUTextureFormat_R8Unorm: return "R8"; + default: return "tex"; + } +} + +// lifetime grid over the graph's own per-frame GPU textures, passes in execution order across the top. +// each row is ONE dedicated allocation, never a bare logical handle: +// * aliasing ON -> a physical slot, its bar showing every occupant's writes (warm) and reads (cool), +// with empty gaps where the texture is free between occupants +// * aliasing OFF -> each realized transient is its own texture, one row, named for the resource +// toggling 'alias transients' collapses N transient rows into M shared textures, and that drop plus the +// gaps is the aliasing made visible. imported, history/persistent and dead resources have no dedicated +// per-frame allocation and are NOT shown. firstUse/lastUse come from compile() phase 3, slots from 4. +static void rg_draw_lifetimes(RenderGraph* rg, RenderGraphStorage& s) +{ + constexpr int kMax = kRgDagMax; // one cap for every tab, so the same graph never shows a different pass set + constexpr float kLabelW = 150.0f, kColW = 88.0f, kRowH = 24.0f, kHeaderH = 24.0f; + + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(kRGWrite), "write"); + ImGui::SameLine(0, 4); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(kRGRead), "read"); + ImGui::SameLine(); + ImGui::TextDisabled("(rows = the graph's dedicated per-frame GPU objects: textures + buffers)"); + + // rows below are physical slots or per-transient objects, either way only dedicated graph-owned + // allocations. the row count + saved MB are the win. + auto phBytes = [](const PhysicalResource& ph) -> uint64_t { + return ph.kind == ResourceKind::Buffer ? ph.bufferSize : texture_bytes(ph.sig.size, ph.sig.format); + }; + if (s.m_slotCount) { + uint32_t logical = 0; uint64_t logicalBytes = 0, physicalBytes = 0; + for (uint32_t i = 0; i < s.m_slotCount; ++i) physicalBytes += phBytes(s.m_slots[i]); + for (ResourceNode* r = s.m_resouces; r; r = r->next) + if (r->aliasSlot != ResourceNode::kNoSlot) { ++logical; logicalBytes += phBytes(s.m_slots[r->aliasSlot]); } + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(IM_COL32(150, 230, 150, 255)), + "aliasing ON: %u transients packed onto %u GPU objects, saved %.2f MB", + logical, s.m_slotCount, (double)(logicalBytes - physicalBytes) / (1024.0 * 1024.0)); + } else { + uint32_t live = 0; + for (ResourceNode* r = s.m_resouces; r; r = r->next) + if (!r->is_external() && r->firstUse != ResourceNode::kNoPass) ++live; + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(IM_COL32(205, 205, 120, 255)), + "aliasing OFF: %u dedicated transient objects (tick 'alias transients' in Demos to pack)", live); + } + + PassNode* passAt[kMax]; + int nPass = 0; + for (PassNode* p = s.m_passes; p && nPass < kMax; p = p->next) passAt[nPass++] = p; + int passTotal = 0; for (PassNode* p = s.m_passes; p; p = p->next) ++passTotal; // for the cap note below + + // every row is one physical texture, whether aliasing is on or off. a slot hosts >=1 disjoint-lifetime + // transient, a non-aliased transient is its own single-occupant texture. imported/history/persistent and + // dead transients are excluded, so only real GPU textures appear. + struct Row { ResourceNode* r; int slot; uint32_t first, last; }; + Row row[kMax]; + int nRow = 0; + for (uint32_t si = 0; si < s.m_slotCount && nRow < kMax; ++si) { + uint32_t f = ResourceNode::kNoPass, l = 0; + for (ResourceNode* o = s.m_resouces; o; o = o->next) + if (o->aliasSlot == si) { if (o->firstUse < f) f = o->firstUse; if (o->lastUse > l) l = o->lastUse; } + if (f != ResourceNode::kNoPass) row[nRow++] = { nullptr, (int)si, f, l }; + } + for (ResourceNode* r = s.m_resouces; r && nRow < kMax; r = r->next) + if (!r->is_external() && r->aliasSlot == ResourceNode::kNoSlot && r->firstUse != ResourceNode::kNoPass) + row[nRow++] = { r, -1, r->firstUse, r->lastUse }; + + // silent truncation is the one thing a debug view must not do + if (passTotal > nPass || nRow == kMax) { + ImGui::TextColored(ImVec4(0.95f, 0.70f, 0.30f, 1), + "view capped at %d: showing %d of %d passes%s", kMax, nPass, passTotal, nRow == kMax ? ", rows truncated" : ""); + } + + // uniform per-row queries over both kinds, so the draw + details code is identical either way + auto rowFmt = [&](const Row& rw) { return rw.slot >= 0 ? s.m_slots[rw.slot].sig.format : rw.r->format; }; + auto rowSize = [&](const Row& rw) { return rw.slot >= 0 ? s.m_slots[rw.slot].sig.size : rw.r->resolved; }; + auto rowUsage = [&](const Row& rw) { return rw.slot >= 0 ? s.m_slots[rw.slot].texUsage : rw.r->texUsage; }; + auto rowKind = [&](const Row& rw) { return rw.slot >= 0 ? s.m_slots[rw.slot].kind : rw.r->kind; }; + auto rowAccess = [&](const Row& rw, uint32_t c) -> int { + if (rw.slot < 0) return rg_pass_access(passAt[c], rw.r->handle.id); + int a = 0; + for (ResourceNode* o = s.m_resouces; o; o = o->next) + if (o->aliasSlot == (uint32_t)rw.slot) a |= rg_pass_access(passAt[c], o->handle.id); + return a; + }; + // a solo row spans [first,last], a slot row is live only while some occupant is, so the gaps between + // occupants render empty: the texture freed and reused, on the timeline + auto rowLive = [&](const Row& rw, uint32_t c) -> bool { + if (rw.slot < 0) return rw.first <= c && c <= rw.last; + for (ResourceNode* o = s.m_resouces; o; o = o->next) + if (o->aliasSlot == (uint32_t)rw.slot && o->firstUse <= c && c <= o->lastUse) return true; + return false; + }; + // drives the label: a texture backing one resource shows its NAME, a shared one the synthetic "image N" + auto rowOccupants = [&](const Row& rw, ResourceNode*& sole) -> int { + if (rw.slot < 0) { sole = rw.r; return 1; } + int n = 0; sole = nullptr; + for (ResourceNode* o = s.m_resouces; o; o = o->next) + if (o->aliasSlot == (uint32_t)rw.slot) { if (!n) sole = o; ++n; } + return n; + }; + + // selection persists across frames, but the graph is rebuilt each frame and handle ids are not stable, + // so key a slot row by its index and a solo row by its resource NAME + static int selSlot = -2; // -2 = nothing; -1 = a solo row (selName); >= 0 = slot index + static char selName[96] = {}; + auto rowSelected = [&](const Row& rw) -> bool { + if (rw.slot >= 0) return rw.slot == selSlot; + return selSlot == -1 && rw.r->id.name.data && std::strcmp(selName, rw.r->id.name.data) == 0; + }; + + // grid on top, details for the selected texture below, only when the tab is tall enough + const float availY = ImGui::GetContentRegionAvail().y; + const float gridH = availY > 240.0f ? availY * 0.62f : availY; + + ImGui::BeginChild("rg_life_grid", ImVec2(0, gridH), true, ImGuiWindowFlags_HorizontalScrollbar); + const ImVec2 origin = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(kLabelW + nPass * kColW, kHeaderH + nRow * kRowH)); // reserve scroll region + ImDrawList* dl = ImGui::GetWindowDrawList(); + + const float gridR = origin.x + kLabelW + nPass * kColW; + const float gridT = origin.y + kHeaderH; + const float gridB = gridT + nRow * kRowH; + + // one clipped pass name per column + a faint column line, hover a header for the full name + for (int c = 0; c < nPass; ++c) { + float x = origin.x + kLabelW + c * kColW; + dl->AddLine(ImVec2(x, origin.y), ImVec2(x, gridB), IM_COL32(255, 255, 255, 22)); + WGPUStringView nm = passAt[c]->id.name; + if (nm.data) { + dl->PushClipRect(ImVec2(x + 4, origin.y), ImVec2(x + kColW, gridT), true); + dl->AddText(ImVec2(x + 6, origin.y + 5), IM_COL32(225, 225, 225, 255), nm.data, nm.data + nm.length); + dl->PopClipRect(); + } + ImGui::SetCursorScreenPos(ImVec2(x, origin.y)); + ImGui::PushID(c); + ImGui::InvisibleButton("h", ImVec2(kColW, kHeaderH)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("P%d %.*s [%s]", c, (int)nm.length, nm.data ? nm.data : "", rg_kind_name(passAt[c]->kind)); + ImGui::EndTooltip(); + } + ImGui::PopID(); + } + dl->AddLine(ImVec2(origin.x, gridT), ImVec2(gridR, gridT), IM_COL32(255, 255, 255, 40)); // axis underline + + // one row per physical texture, with occupant write/read cells across the timeline and empty gaps where + // it is free. click a row to inspect it below. + for (int i = 0; i < nRow; ++i) { + const float y = gridT + i * kRowH; + ResourceNode* sole = nullptr; + const int nocc = rowOccupants(row[i], sole); + const bool shared = nocc > 1; + // shared textures get "image N" + a slot colour so they stand out, a single-occupant one keeps the + // resource name. format and size are in the details panel. + const ImU32 col = shared ? rg_slot_color((uint32_t)i) : IM_COL32(210, 210, 210, 255); + const bool seld = rowSelected(row[i]); + if (seld) dl->AddRectFilled(ImVec2(origin.x, y), ImVec2(gridR, y + kRowH), IM_COL32(255, 255, 255, 30)); + else if (i & 1) dl->AddRectFilled(ImVec2(origin.x, y), ImVec2(gridR, y + kRowH), IM_COL32(255, 255, 255, 10)); + + const bool isBuf = rowKind(row[i]) == ResourceKind::Buffer; + char label[96]; + if (shared) std::snprintf(label, sizeof label, "%s %d (x%d)", isBuf ? "buffer" : "image", i, nocc); + else std::snprintf(label, sizeof label, "%.*s", (int)sole->id.name.length, sole->id.name.data ? sole->id.name.data : ""); + if (shared) // gutter swatch only for shared (aliased) textures + dl->AddRectFilled(ImVec2(origin.x + 1, y + 4), ImVec2(origin.x + 4, y + kRowH - 4), col); + dl->PushClipRect(ImVec2(origin.x + 6, y), ImVec2(origin.x + kLabelW - 4, y + kRowH), true); + dl->AddText(ImVec2(origin.x + 8, y + 4), col, label); + dl->PopClipRect(); + + // the whole row, gutter and grid, selects this texture for the details panel + ImGui::SetCursorScreenPos(ImVec2(origin.x, y)); + ImGui::PushID(kMax + i); + if (ImGui::InvisibleButton("row", ImVec2(kLabelW + nPass * kColW, kRowH))) { + selSlot = row[i].slot; + if (row[i].slot < 0) std::snprintf(selName, sizeof selName, "%.*s", + (int)row[i].r->id.name.length, row[i].r->id.name.data ? row[i].r->id.name.data : ""); + } + const bool hov = ImGui::IsItemHovered(); + ImGui::PopID(); + + const float x0 = origin.x + kLabelW + row[i].first * kColW + 3.0f; + const float x1 = origin.x + kLabelW + (row[i].last + 1) * kColW - 3.0f; + const float ty = y + 3.0f, by = y + kRowH - 3.0f; + + // gaps are left empty, so reuse reads off the timeline + const ImU32 bandCol = rg_with_alpha(col, 45); + for (uint32_t c = row[i].first; c <= row[i].last; ++c) { + if (!rowLive(row[i], c)) continue; + float sx0 = (c == row[i].first) ? x0 : origin.x + kLabelW + c * kColW; + float sx1 = (c == row[i].last) ? x1 : origin.x + kLabelW + (c + 1) * kColW; + dl->AddRectFilled(ImVec2(sx0, ty), ImVec2(sx1, by), bandCol, 0.0f); + int acc = rowAccess(row[i], c); + if (!acc) continue; + if (acc == 3) { // read + write -> split top (write) / bottom (read) + float mid = (ty + by) * 0.5f; + dl->AddRectFilled(ImVec2(sx0, ty), ImVec2(sx1, mid), kRGWrite, 0.0f); + dl->AddRectFilled(ImVec2(sx0, mid), ImVec2(sx1, by), kRGRead, 0.0f); + } else + dl->AddRectFilled(ImVec2(sx0, ty), ImVec2(sx1, by), acc == 2 ? kRGWrite : kRGRead, 0.0f); + } + + // outline each contiguous occupancy run, so the gaps stay open and each occupant reads as its own + // block + const ImU32 edge = (seld || hov) ? IM_COL32(255, 255, 255, 255) : col; + const float wth = (seld || hov) ? 2.0f : 1.0f; + for (uint32_t c = row[i].first; c <= row[i].last; ) { + if (!rowLive(row[i], c)) { ++c; continue; } + uint32_t segStart = c; + while (c <= row[i].last && rowLive(row[i], c)) ++c; + float sx0 = origin.x + kLabelW + segStart * kColW + 3.0f; + float sx1 = origin.x + kLabelW + c * kColW - 3.0f; + dl->AddRect(ImVec2(sx0, ty), ImVec2(sx1, by), edge, 0.0f, 0, wth); + } + } + ImGui::EndChild(); + + // ---- details panel: the selected physical texture and the logical resource(s) it backs ---- + if (gridH < availY) { + ImGui::BeginChild("rg_life_details", ImVec2(0, 0), true); + int selIdx = -1; + for (int i = 0; i < nRow; ++i) if (rowSelected(row[i])) { selIdx = i; break; } + if (selIdx < 0) { + ImGui::TextDisabled("click a texture above to inspect it"); + } else { + const Row& rw = row[selIdx]; + const bool isBuf = rowKind(rw) == ResourceKind::Buffer; + ResourceNode* psole = nullptr; + const int pnocc = rowOccupants(rw, psole); + const ImU32 hcol = pnocc > 1 ? rg_slot_color((uint32_t)selIdx) : IM_COL32(225, 225, 225, 255); + if (pnocc > 1) ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(hcol), "%s %d (shared by %d)", + isBuf ? "buffer" : "image", selIdx, pnocc); + else ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(hcol), "%.*s", + (int)psole->id.name.length, psole->id.name.data ? psole->id.name.data : ""); + ImGui::SameLine(); + + char ub[160]; ub[0] = '\0'; + if (isBuf) { + uint64_t bytes = rw.slot >= 0 ? s.m_slots[rw.slot].bufferSize : rw.r->bufferSize; + WGPUBufferUsage u = rw.slot >= 0 ? s.m_slots[rw.slot].bufUsage : rw.r->bufUsage; + ImGui::Text("- buffer - %.1f KB", bytes / 1024.0); + auto addb = [&](WGPUBufferUsage bit, const char* nm) { if (u & bit) { if (ub[0]) std::strcat(ub, " | "); std::strcat(ub, nm); } }; + addb(WGPUBufferUsage_Storage, "Storage"); addb(WGPUBufferUsage_Uniform, "Uniform"); + addb(WGPUBufferUsage_Vertex, "Vertex"); addb(WGPUBufferUsage_Index, "Index"); + addb(WGPUBufferUsage_Indirect, "Indirect"); addb(WGPUBufferUsage_CopySrc, "CopySrc"); + addb(WGPUBufferUsage_CopyDst, "CopyDst"); + } else { + WGPUExtent3D sz = rowSize(rw); + WGPUTextureFormat fmt = rowFmt(rw); + WGPUTextureUsage u = rowUsage(rw); + ImGui::Text("- %s %u x %u - %.1f KB", rg_format_short(fmt), sz.width, sz.height, texture_bytes(sz, fmt) / 1024.0); + auto addu = [&](WGPUTextureUsage bit, const char* nm) { if (u & bit) { if (ub[0]) std::strcat(ub, " | "); std::strcat(ub, nm); } }; + addu(WGPUTextureUsage_RenderAttachment, "RenderAttachment"); + addu(WGPUTextureUsage_TextureBinding, "TextureBinding"); + addu(WGPUTextureUsage_StorageBinding, "StorageBinding"); + addu(WGPUTextureUsage_CopySrc, "CopySrc"); + addu(WGPUTextureUsage_CopyDst, "CopyDst"); + } + ImGui::TextDisabled("usage: %s", ub[0] ? ub : "(none)"); + + int occ = 0; + if (rw.slot >= 0) { for (ResourceNode* o = s.m_resouces; o; o = o->next) if (o->aliasSlot == (uint32_t)rw.slot) ++occ; } + else occ = 1; + ImGui::Separator(); + if (occ > 1) ImGui::Text("%d logical resources share this %s (disjoint lifetimes):", occ, isBuf ? "buffer" : "texture"); + else ImGui::Text("backs 1 logical resource:"); + + auto detailOne = [&](ResourceNode* o) { + WGPUStringView f = pass_name_at(s.m_passes, o->firstUse); + WGPUStringView l = pass_name_at(s.m_passes, o->lastUse); + ImGui::BulletText("%.*s [%.*s .. %.*s]", + (int)o->id.name.length, o->id.name.data ? o->id.name.data : "", + (int)f.length, f.data ? f.data : "", (int)l.length, l.data ? l.data : ""); + ImGui::Indent(); + for (uint32_t c = o->firstUse; c <= o->lastUse; ++c) { + int a = rg_pass_access(passAt[c], o->handle.id); + if (!a) continue; + WGPUStringView pn = passAt[c]->id.name; + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(a == 1 ? kRGRead : kRGWrite), + "%s %.*s", a == 3 ? "rw" : a == 2 ? " w" : " r", (int)pn.length, pn.data ? pn.data : ""); + } + ImGui::Unindent(); + }; + if (rw.slot >= 0) { for (ResourceNode* o = s.m_resouces; o; o = o->next) if (o->aliasSlot == (uint32_t)rw.slot) detailOne(o); } + else detailOne(rw.r); + } + ImGui::EndChild(); + } +} + +// GPU-memory view across every pool the graph owns: the transient pool, a descriptor-keyed cache of +// textures + buffers, and the history/persistent ping-pong. the grand total answers what the graph costs +// in VRAM, and the transient pool's create/evict log keeps steady-state reuse verifiable. drawn after +// realize() and before release_resources()/end_frame(), so every count is this frame's live allocation. +static void rg_draw_memory(RenderGraphStorage& s) +{ + TransientResourcePool& tp = s.m_allocator->transient; + PersistentResourcePool& pool = s.m_allocator->pool; + + // one descriptor-keyed cache tagged by Entry::kind, one physical object per entry, idle ones retained + // kRetain frames. held includes idle. + int held = 0, inUse = 0, texHeld = 0, bufHeld = 0; + uint64_t texBytes = 0, texInUseBytes = 0, bufBytes = 0, bufInUseBytes = 0; + for (const TransientResourcePool::Entry* ep = tp.entries; ep; ep = ep->next) { + const TransientResourcePool::Entry& e = *ep; + ++held; if (e.inUse) ++inUse; + if (e.kind == ResourceKind::Buffer) { ++bufHeld; bufBytes += e.bufferSize; if (e.inUse) bufInUseBytes += e.bufferSize; } + else { + const uint64_t b = rg_entry_bytes(e); + ++texHeld; texBytes += b; + if (e.inUse) texInUseBytes += b; + } + } + + // one name-keyed pool: a history entry holds kLayers physical objects, a persistent entry 1. the buffer + // arm leaves size {} and format Undefined, so split on bufferSize. mem already scales by layers. + int tmpTexCount = 0, tmpBufCount = 0; + uint64_t tmpTexBytes = 0, tmpBufBytes = 0; + for (const PersistentResourcePool::Entry* ep = pool.entries; ep; ep = ep->next) { + const PersistentResourcePool::Entry& e = *ep; + if (!e.created) continue; + if (e.bufferSize) { ++tmpBufCount; tmpBufBytes += e.bufferSize * e.layers; } + else { ++tmpTexCount; tmpTexBytes += texture_bytes(e.sig.size, e.sig.format, e.sig.mipLevelCount, e.sig.sampleCount, e.sig.dim) * e.layers; } + } + + const uint64_t grand = texBytes + bufBytes + tmpTexBytes + tmpBufBytes; + + ImGui::Text("frame %llu | transient pool: %d held, %d in use", (unsigned long long)tp.frame, held, inUse); + ImGui::SameLine(); + if (tp.createdThisFrame == 0) + ImGui::TextColored(ImVec4(0.45f, 0.85f, 0.45f, 1), " | 0 created (reused from pool)"); + else + ImGui::TextColored(ImVec4(0.95f, 0.70f, 0.30f, 1), " | %u created this frame", tp.createdThisFrame); + + char gb[24]; rg_bytes_str(grand, gb, sizeof gb); + ImGui::Text("VRAM %s total", gb); + ImGui::SameLine(); ImGui::TextDisabled("(graph-allocated; imported/caller-owned excluded, listed below)"); + { + char a[24], ib[24], idb[24], bb[24], bib[24], bidb[24], cb[24], cbb[24]; + rg_bytes_str(texBytes, a, sizeof a); + rg_bytes_str(texInUseBytes, ib, sizeof ib); + rg_bytes_str(texBytes - texInUseBytes, idb, sizeof idb); // in use is a subset sum -> no underflow + rg_bytes_str(bufBytes, bb, sizeof bb); + rg_bytes_str(bufInUseBytes, bib, sizeof bib); + rg_bytes_str(bufBytes - bufInUseBytes, bidb, sizeof bidb); + rg_bytes_str(tmpTexBytes, cb, sizeof cb); + rg_bytes_str(tmpBufBytes, cbb, sizeof cbb); + ImGui::BulletText("transient tex %-9s %d held (%s in use, %s idle)", a, texHeld, ib, idb); + ImGui::BulletText("transient buf %-9s %d held (%s in use, %s idle)", bb, bufHeld, bib, bidb); + ImGui::BulletText("pool tex %-9s %d entries", cb, tmpTexCount); + ImGui::BulletText("pool buf %-9s %d entries", cbb, tmpBufCount); + } + + // each packed transient would otherwise own a slot-sized object, so the win is the slot bytes counted + // once per member minus once per slot + if (s.m_slotCount) { + auto phBytes = [](const PhysicalResource& ph) -> uint64_t { + return ph.kind == ResourceKind::Buffer ? ph.bufferSize : texture_bytes(ph.sig.size, ph.sig.format); + }; + uint32_t logical = 0; uint64_t logicalBytes = 0, physicalBytes = 0; + for (uint32_t i = 0; i < s.m_slotCount; ++i) physicalBytes += phBytes(s.m_slots[i]); + for (ResourceNode* r = s.m_resouces; r; r = r->next) + if (r->aliasSlot != ResourceNode::kNoSlot) { ++logical; logicalBytes += phBytes(s.m_slots[r->aliasSlot]); } + char sb[24]; rg_bytes_str(logicalBytes - physicalBytes, sb, sizeof sb); + ImGui::TextColored(ImVec4(0.59f, 0.90f, 0.59f, 1), + "aliasing: %u transients packed onto %u objects, saved %s", logical, s.m_slotCount, sb); + } + + // an attachment cleared+discarded in one pass and never read need not leave the GPU. the feature flag + // decides whether the bit reaches the driver, this list shows what qualified. + { + if (s.m_allocator->transientFeatureOn) + ImGui::TextColored(ImVec4(0.59f, 0.90f, 0.59f, 1), + "transient attachments: feature ON | %u memoryless (TRANSIENT_ATTACHMENT emitted; driver may skip VRAM)", + s.transientCount); + else + ImGui::TextColored(ImVec4(0.85f, 0.80f, 0.45f, 1), + "transient attachments: feature OFF | %u candidate(s) (bit dropped -> realized as plain RENDER_ATTACHMENT)", + s.transientCount); + if (ImGui::IsItemHovered() && ImGui::BeginTooltip()) { + ImGui::TextUnformatted( + "A graph-owned attachment cleared + discarded in one render pass, never sampled/copied/stored,\n" + "never needs to leave the GPU. On a TBDR/tile-memory device it stays on-chip and skips its VRAM\n" + "allocation. Inferred from usage; no API declares it. No-op on D3D12 (feature absent there)."); + ImGui::EndTooltip(); + } + const ImGuiTableFlags ttf = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit; + if (s.transientCount && ImGui::BeginTable("rg_transient", 4, ttf)) { + ImGui::TableSetupColumn("resource"); + ImGui::TableSetupColumn("size"); + ImGui::TableSetupColumn("format"); + ImGui::TableSetupColumn("samples"); + ImGui::TableHeadersRow(); + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + if (!r->transientAttachment) continue; + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::Text("%.*s", (int)r->id.name.length, r->id.name.data ? r->id.name.data : ""); + ImGui::TableNextColumn(); ImGui::Text("%ux%u", r->resolved.width, r->resolved.height); + ImGui::TableNextColumn(); ImGui::Text("%s", rg_format_name(r->format)); + ImGui::TableNextColumn(); ImGui::Text("%ux", r->sampleCount); + } + ImGui::EndTable(); + } + ImGui::Spacing(); + } + + ImGui::TextDisabled("tex usage A=attach T=sampled S=storage r=copy-src w=copy-dst | buf usage U=uniform S=storage V=vertex I=index X=indirect | evict after %llu idle frames", + (unsigned long long)TransientResourcePool::kRetain); + ImGui::Separator(); + + const ImGuiTableFlags tf = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit; + + // currently held physical textures + ImGui::TextDisabled("transient textures"); + if (ImGui::BeginTable("tp_live", 9, tf)) { + ImGui::TableSetupColumn("size"); + ImGui::TableSetupColumn("mips"); + ImGui::TableSetupColumn("layers"); + ImGui::TableSetupColumn("samples"); + ImGui::TableSetupColumn("format"); + ImGui::TableSetupColumn("usage"); + ImGui::TableSetupColumn("mem"); + ImGui::TableSetupColumn("state"); + ImGui::TableSetupColumn("last use"); // descriptor-keyed pool has no per-row name; show the last claimant + ImGui::TableHeadersRow(); + int idx = 0; + for (const TransientResourcePool::Entry* ep = tp.entries; ep; ep = ep->next) { + const TransientResourcePool::Entry& e = *ep; + if (e.kind == ResourceKind::Buffer) continue; // buffers listed in their own table below + char ub[8]; rg_usage_str(e.usage, ub, sizeof ub); + const uint64_t eb = rg_entry_bytes(e); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::Text("%ux%u", e.sig.size.width, e.sig.size.height); + ImGui::TableNextColumn(); ImGui::Text("%u", e.sig.mipLevelCount); + ImGui::TableNextColumn(); ImGui::Text("%u", e.sig.size.depthOrArrayLayers); + ImGui::TableNextColumn(); ImGui::Text("%ux", e.sig.sampleCount); + ImGui::TableNextColumn(); ImGui::Text("%s", rg_format_name(e.sig.format)); + ImGui::TableNextColumn(); ImGui::Text("%s", ub); + ImGui::TableNextColumn(); + if (eb) { char mb[24]; rg_bytes_str(eb, mb, sizeof mb); ImGui::Text("%s", mb); } + else ImGui::TextDisabled("?"); + ImGui::TableNextColumn(); + if (e.inUse) ImGui::TextColored(ImVec4(0.55f, 0.80f, 1.0f, 1), "in use"); + else ImGui::TextDisabled("idle %lluf", (unsigned long long)(tp.frame - e.lastUsedFrame)); + ImGui::TableNextColumn(); + ImGui::TextDisabled("%s", e.identity ? (const char*)e.identity : "-"); // interned, NUL-terminated + } + ImGui::EndTable(); + } + + // the buffer arm of the same pool + ImGui::Spacing(); + ImGui::TextDisabled("transient buffers"); + if (ImGui::BeginTable("tp_buf", 4, tf)) { + ImGui::TableSetupColumn("size"); + ImGui::TableSetupColumn("usage"); + ImGui::TableSetupColumn("mem"); + ImGui::TableSetupColumn("state"); + ImGui::TableHeadersRow(); + bool any = false; + for (const TransientResourcePool::Entry* ep = tp.entries; ep; ep = ep->next) { + const TransientResourcePool::Entry& e = *ep; + if (e.kind != ResourceKind::Buffer) continue; + any = true; + char ub[12]; rg_buf_usage_str(e.bufUsage, ub, sizeof ub); + char mb[24]; rg_bytes_str(e.bufferSize, mb, sizeof mb); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::Text("%llu B", (unsigned long long)e.bufferSize); + ImGui::TableNextColumn(); ImGui::Text("%s", ub); + ImGui::TableNextColumn(); ImGui::Text("%s", mb); + ImGui::TableNextColumn(); + if (e.inUse) ImGui::TextColored(ImVec4(0.55f, 0.80f, 1.0f, 1), "in use"); + else ImGui::TextDisabled("idle %lluf", (unsigned long long)(tp.frame - e.lastUsedFrame)); + } + ImGui::EndTable(); + if (!any) ImGui::TextDisabled("(none)"); + } + + // one row per name. layers reads 2 for a ping-pong history entry, 1 for a persistent one. + ImGui::Spacing(); + ImGui::TextDisabled("persistent + history textures (layers: 2 = history ping-pong, 1 = persistent)"); + if (ImGui::BeginTable("tp_tmp", 8, tf)) { + ImGui::TableSetupColumn("name"); + ImGui::TableSetupColumn("size"); + ImGui::TableSetupColumn("mips"); + ImGui::TableSetupColumn("layers"); + ImGui::TableSetupColumn("samples"); + ImGui::TableSetupColumn("format"); + ImGui::TableSetupColumn("usage"); + ImGui::TableSetupColumn("mem"); + ImGui::TableHeadersRow(); + bool any = false; + for (const PersistentResourcePool::Entry* ep = pool.entries; ep; ep = ep->next) { + const PersistentResourcePool::Entry& e = *ep; + if (!e.created || e.bufferSize) continue; // buffer-arm entries listed in their own table below + any = true; + char ub[8]; rg_usage_str(e.usage, ub, sizeof ub); + const uint64_t eb = texture_bytes(e.sig.size, e.sig.format, e.sig.mipLevelCount, e.sig.sampleCount, e.sig.dim) * e.layers; + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::Text("%.*s", (int)e.name.length, e.name.data ? e.name.data : ""); + ImGui::TableNextColumn(); ImGui::Text("%ux%u", e.sig.size.width, e.sig.size.height); + ImGui::TableNextColumn(); ImGui::Text("%u", e.sig.mipLevelCount); + ImGui::TableNextColumn(); ImGui::Text("%u x%u", e.sig.size.depthOrArrayLayers, e.layers); + ImGui::TableNextColumn(); ImGui::Text("%ux", e.sig.sampleCount); + ImGui::TableNextColumn(); ImGui::Text("%s", rg_format_name(e.sig.format)); + ImGui::TableNextColumn(); ImGui::Text("%s", ub); + ImGui::TableNextColumn(); + if (eb) { char mb[24]; rg_bytes_str(eb, mb, sizeof mb); ImGui::Text("%s", mb); } + else ImGui::TextDisabled("?"); + } + ImGui::EndTable(); + if (!any) ImGui::TextDisabled("(none)"); + } + + // one row per name, mem already scaled by layers + ImGui::Spacing(); + ImGui::TextDisabled("persistent + history buffers"); + if (ImGui::BeginTable("tp_tmpbuf", 3, tf)) { + ImGui::TableSetupColumn("name"); + ImGui::TableSetupColumn("usage"); + ImGui::TableSetupColumn("mem (x layers)"); + ImGui::TableHeadersRow(); + bool any = false; + for (const PersistentResourcePool::Entry* ep = pool.entries; ep; ep = ep->next) { + const PersistentResourcePool::Entry& e = *ep; + if (!e.created || !e.bufferSize) continue; + any = true; + char ub[12]; rg_buf_usage_str(e.bufUsage, ub, sizeof ub); + char mb[24]; rg_bytes_str(e.bufferSize * e.layers, mb, sizeof mb); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::Text("%.*s", (int)e.name.length, e.name.data ? e.name.data : ""); + ImGui::TableNextColumn(); ImGui::Text("%s", ub); + ImGui::TableNextColumn(); ImGui::Text("%s", mb); + } + ImGui::EndTable(); + if (!any) ImGui::TextDisabled("(none)"); + } + + // caller-owned: real GPU memory the graph writes but does NOT allocate, so it sits outside the total + // above. the mem column stays blank, since the graph never owns the bytes and import records no format. + // the point is to make the caller-owned surface visible, not to double-count it. + ImGui::Spacing(); + ImGui::TextDisabled("imported (caller-owned, not in VRAM total)"); + if (ImGui::BeginTable("tp_imported", 3, tf)) { + ImGui::TableSetupColumn("name"); + ImGui::TableSetupColumn("kind"); + ImGui::TableSetupColumn("size"); + ImGui::TableHeadersRow(); + bool any = false; + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + if (!r->imported) continue; + any = true; + const bool isBuf = r->kind == ResourceKind::Buffer; + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::Text("%.*s", (int)r->id.name.length, r->id.name.data ? r->id.name.data : ""); + ImGui::TableNextColumn(); ImGui::Text("%s", isBuf ? "buffer" : "texture"); + ImGui::TableNextColumn(); + if (isBuf) ImGui::TextDisabled("-"); + else ImGui::Text("%u x %u", r->resolved.width, r->resolved.height); + } + ImGui::EndTable(); + if (!any) ImGui::TextDisabled("(none)"); + } + + ImGui::Spacing(); + ImGui::Text("transient pool events (newest first)"); + if (ImGui::Button("reset log")) tp.log_reset(); + ImGui::BeginChild("tp_log", ImVec2(0, 0), true); + const uint64_t total = tp.eventCount; + const uint64_t shown = total < TransientResourcePool::kLog ? total : TransientResourcePool::kLog; + for (uint64_t k = 0; k < shown; ++k) { + const TransientResourcePool::LogRec& r = tp.eventLog[(total - 1 - k) % TransientResourcePool::kLog]; + const bool create = r.event == TransientResourcePool::Event::Create; + const ImVec4 col = create ? ImVec4(0.95f, 0.70f, 0.30f, 1) : ImVec4(0.60f, 0.60f, 0.60f, 1); + if (r.kind == ResourceKind::Buffer) + ImGui::TextColored(col, "f%-6llu %-6s buf %llu B", (unsigned long long)r.frame, create ? "CREATE" : "evict", + (unsigned long long)r.bufferSize); + else + ImGui::TextColored(col, "f%-6llu %-6s %ux%u %s", (unsigned long long)r.frame, create ? "CREATE" : "evict", + r.size.width, r.size.height, rg_format_name(r.format)); + } + if (shown == 0) ImGui::TextDisabled("(no events yet)"); + ImGui::EndChild(); +} + +// one usage bar for a growable block-chained arena: `value` bytes against the arena's CURRENT capacity, +// which itself grows as blocks chain on. a block count > 1 means the arena outgrew its first block this +// run. label doubles as the InvisibleButton id, so keep them unique. +static void rg_draw_arena_bar(const char* label, const char* valueLabel, size_t value, const Arena& arena, ImU32 color) +{ + const double kib = 1024.0; + const double cap = arena.totalCapacity ? (double)arena.totalCapacity : 1.0; + float frac = (float)((double)value / cap); + if (frac > 1.0f) frac = 1.0f; + if (frac < 0.0f) frac = 0.0f; + + constexpr float kLabelW = 56.0f; // fixed gutter so both bars' left edges line up under each other + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(kLabelW); + + const ImVec2 p0 = ImGui::GetCursorScreenPos(); + float w = ImGui::GetContentRegionAvail().x; + if (w < 1.0f) w = 1.0f; // collapsed window -> keep InvisibleButton happy + const float h = ImGui::GetFrameHeight(); + const ImVec2 p1(p0.x + w, p0.y + h); + + ImGui::InvisibleButton(label, ImVec2(w, h)); + const bool hov = ImGui::IsItemHovered(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + + dl->AddRectFilled(p0, p1, IM_COL32(28, 28, 28, 255), 3.0f); // track + if (frac > 0.0f) + dl->AddRectFilled(p0, ImVec2(p0.x + w * frac, p1.y), color, 0.0f); // fill + dl->AddRect(p0, p1, hov ? IM_COL32(255, 255, 255, 255) : IM_COL32(20, 20, 20, 180), 3.0f); + + char ov[80]; + std::snprintf(ov, sizeof ov, "%.1f / %.0f KB x%llu blk", value / kib, cap / kib, + (unsigned long long)arena.blockCount); + const ImVec2 ts = ImGui::CalcTextSize(ov); + dl->AddText(ImVec2(p0.x + (w - ts.x) * 0.5f, p0.y + (h - ts.y) * 0.5f), IM_COL32(235, 235, 235, 255), ov); + + if (hov) { + ImGui::BeginTooltip(); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color), "%s", label); + ImGui::Text("%-9s%llu B (%.2f KB)", valueLabel, (unsigned long long)value, value / kib); + ImGui::Text("capacity %.2f KB (grows)", cap / kib); + ImGui::Text("blocks %llu x %.0f KB", (unsigned long long)arena.blockCount, arena.blockSize / kib); + ImGui::EndTooltip(); + } +} + +// the GraphAllocator as three stacked bars, one per independent arena. `front` holds the frame's permanent +// objects, `scratch` holds compile()'s temporaries and is rewound per scope, so it shows a per-frame +// high-water rather than the empty live value, and `persist` backs the pool Entry cells and is never +// per-frame reset, so its live value is the pools' bounded footprint. +static void rg_draw_arena(GraphAllocator& a) +{ + rg_draw_arena_bar("front", "used", a.front.live_used(), a.front, kRGRead); + rg_draw_arena_bar("scratch", "peak", a.scratch.peakUsage, a.scratch, kRGWrite); + rg_draw_arena_bar("persist", "used", a.persist.live_used(), a.persist, kRGExt); +} + +// cycled by series index, kept bright so thin polylines read against the dark canvas +static ImU32 rg_series_color(uint32_t i) +{ + static const ImU32 pal[] = { + IM_COL32(235, 110, 110, 255), IM_COL32(110, 200, 120, 255), IM_COL32(110, 170, 240, 255), + IM_COL32(235, 200, 90, 255), IM_COL32(200, 130, 230, 255), IM_COL32(90, 215, 215, 255), + IM_COL32(240, 150, 90, 255), IM_COL32(170, 220, 110, 255), IM_COL32(150, 150, 240, 255), + IM_COL32(230, 130, 180, 255), IM_COL32(120, 220, 170, 255), IM_COL32(210, 210, 130, 255), + }; + return pal[i % (sizeof(pal) / sizeof(pal[0]))]; +} + +// per-pass GPU us over time as a running multi-line graph, one line per pass. sampling happens in +// imgui_layer_draw_graph, so it keeps running while another tab is foregrounded. +static void rg_draw_timings(RenderGraphStorage& s) +{ + GpuProfiler& gp = s.m_allocator->profiler; + + if (!gp.initialized) { + ImGui::TextDisabled("Enable \"gpu timings\" in the Demos window to record per-pass GPU time."); + return; + } + + if (gp.recording) { if (ImGui::Button("Stop recording")) gp.recording = false; } + else { if (ImGui::Button("Start recording")) gp.recording = true; } + ImGui::SameLine(); + if (ImGui::Button("Clear")) gp.clear_history(); + ImGui::SameLine(); + ImGui::Text("%u / %u frames%s", gp.historyLen, GpuProfiler::kHistory, gp.recording ? " (rec)" : ""); + + // peak us across the ENABLED series over the retained window, giving the Y scale + float maxV = 0.0f; + for (uint32_t si = 0; si < gp.seriesCount; ++si) + if (gp.series[si].enabled) + for (uint32_t c = 0; c < gp.historyLen; ++c) + if (gp.series[si].v[c] > maxV) maxV = gp.series[si].v[c]; + if (maxV <= 0.0f) maxV = 1.0f; // avoid div-by-zero before any data + + // ---- canvas ---- + const ImVec2 p0 = ImGui::GetCursorScreenPos(); + const float w = ImGui::GetContentRegionAvail().x; + const float h = 200.0f; + ImGui::InvisibleButton("rg_timings_canvas", ImVec2(w > 0 ? w : 1, h)); + const bool canvasHovered = ImGui::IsItemHovered(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 p1(p0.x + w, p0.y + h); + dl->AddRectFilled(p0, p1, IM_COL32(18, 18, 24, 255), 3.0f); + dl->AddRect(p0, p1, IM_COL32(80, 86, 100, 255), 3.0f); + dl->PushClipRect(p0, p1, true); + + // faint mid gridline + the peak label + dl->AddLine(ImVec2(p0.x, (p0.y + p1.y) * 0.5f), ImVec2(p1.x, (p0.y + p1.y) * 0.5f), IM_COL32(255, 255, 255, 24)); + char lbl[32]; + std::snprintf(lbl, sizeof(lbl), "%.1f us", maxV); + dl->AddText(ImVec2(p0.x + 4, p0.y + 3), IM_COL32(180, 180, 190, 220), lbl); + + // ring geometry, shared by the line draw + the scrub cursor. valid only with >=1 column. + const uint32_t oldest = (gp.historyHead + GpuProfiler::kHistory - gp.historyLen) % GpuProfiler::kHistory; + const float stepX = w / float(GpuProfiler::kHistory - 1); + auto colY = [&](float v) { return p1.y - (v / maxV) * (h - 4.0f) - 2.0f; }; + + // snap the mouse X to the nearest column while hovering, driving the cursor line + list readout + int scrubCol = -1; // logical column in [0, historyLen) + if (canvasHovered && gp.historyLen >= 1) { + int c = (int)((ImGui::GetIO().MousePos.x - p0.x) / stepX + 0.5f); + scrubCol = c < 0 ? 0 : (c > (int)gp.historyLen - 1 ? (int)gp.historyLen - 1 : c); + } + + // oldest valid column in the ring, so the draw runs left to right + if (gp.historyLen >= 2) { + static ImVec2 pts[GpuProfiler::kHistory]; + for (uint32_t si = 0; si < gp.seriesCount; ++si) { + if (!gp.series[si].enabled) continue; + for (uint32_t c = 0; c < gp.historyLen; ++c) + pts[c] = ImVec2(p0.x + c * stepX, colY(gp.series[si].v[(oldest + c) % GpuProfiler::kHistory])); + dl->AddPolyline(pts, (int)gp.historyLen, rg_series_color(si), 0, 1.5f); + } + } + + // vertical line + a dot on each enabled series at the snapped column + if (scrubCol >= 0) { + const float cx = p0.x + scrubCol * stepX; + dl->AddLine(ImVec2(cx, p0.y), ImVec2(cx, p1.y), IM_COL32(230, 230, 235, 160)); + const uint32_t col = (oldest + (uint32_t)scrubCol) % GpuProfiler::kHistory; + for (uint32_t si = 0; si < gp.seriesCount; ++si) + if (gp.series[si].enabled) + dl->AddCircleFilled(ImVec2(cx, colY(gp.series[si].v[col])), 2.5f, rg_series_color(si)); + } + dl->PopClipRect(); + + // the list reads the scrubbed column while hovering, else the latest sample + const uint32_t readCol = gp.historyLen + ? (oldest + (uint32_t)(scrubCol >= 0 ? scrubCol : (int)gp.historyLen - 1)) % GpuProfiler::kHistory + : 0; + ImGui::Spacing(); + if (scrubCol >= 0) ImGui::TextDisabled("scrub: %d frames ago", (int)gp.historyLen - 1 - scrubCol); + else ImGui::TextDisabled("latest sample (hover the graph to scrub)"); + + // ---- per-pass list: color swatch + enable checkbox + value at readCol ---- + for (uint32_t si = 0; si < gp.seriesCount; ++si) { + const ImVec2 cur = ImGui::GetCursorScreenPos(); + dl->AddRectFilled(ImVec2(cur.x, cur.y + 3), ImVec2(cur.x + 11, cur.y + 14), rg_series_color(si), 2.0f); + ImGui::Dummy(ImVec2(15, 0)); + ImGui::SameLine(); + char id[80]; + std::snprintf(id, sizeof(id), "%.*s##series%u", + (int)gp.series[si].name.length, gp.series[si].name.data ? gp.series[si].name.data : "", si); + ImGui::Checkbox(id, &gp.series[si].enabled); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 70.0f); + ImGui::Text("%6.1f us", gp.historyLen ? gp.series[si].v[readCol] : 0.0f); + } +} + +namespace webgpu_app { + +void RenderGraphPanel::draw() +{ + // consume the frame's graph unconditionally, its nodes die at the next begin_frame() and a hidden + // window must not leave the global dangling + webgpu::rg::RenderGraph* rg = g_render_graph; + g_render_graph = nullptr; + + static uint32_t render_gaph_enabled = 0; + if(ImGuiManager::FloatingToggleButton("ToggleRenderGraphButton", ICON_FA_PROJECT_DIAGRAM, "RenderGraph", &render_gaph_enabled)) + { + render_gaph_enabled &= 1; + } + + if(render_gaph_enabled == 0) + return; + + ImGui::Begin("RenderGraph"); + + // reachable whether or not a graph ran this frame, so the user can switch back from the legacy path, + // which feeds no graph + ImGui::Checkbox("Drive frame from render graph", &g_use_render_graph); + ImGui::Separator(); + + if (!rg) { + ImGui::TextUnformatted("Render graph inactive (legacy render path)."); + ImGui::End(); + return; + } + + RenderGraphStorage& s = *storage(rg); + + // append a history column each frame the window draws, whichever tab is open + s.m_allocator->profiler.sample_history(); + + ImGui::Text(" %1.1f FPS (%1.2f ms)", ImGui::GetIO().Framerate, 1000.0f / ImGui::GetIO().Framerate); + ImGui::Text(" compile %1.0f us realize %1.0f us execute %1.0f us", s.timing_compile_us, s.timing_realize_us, s.timing_execute_us); + + const bool hasErrors = rg->get_errors() != nullptr; + if (ErrorMessage* e = rg->get_errors()) { + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(230, 90, 80, 255)); + ImGui::TextUnformatted("compile failed:"); + for (; e; e = e->next) + ImGui::TextWrapped(" %.*s", (int)e->message.length, e->message.data ? e->message.data : ""); + ImGui::PopStyleColor(); + ImGui::Separator(); + } + + // opt-in: total of the last completed per-pass timestamp read-back, breakdown on hover + if (const GpuProfiler& gp = s.m_allocator->profiler; gp.resultCount) { + float totalUs = 0.0f; + for (uint32_t i = 0; i < gp.resultCount; ++i) + totalUs += gp.resultUs[i]; + ImGui::SameLine(); + ImGui::Text(" gpu %.2f ms", totalUs / 1000.0f); + if (ImGui::IsItemHovered() && ImGui::BeginTooltip()) { + for (uint32_t i = 0; i < gp.resultCount; ++i) + ImGui::Text("%6.1f us %.*s", gp.resultUs[i], + (int)gp.resultNames[i].length, gp.resultNames[i].data ? gp.resultNames[i].data : ""); + ImGui::EndTooltip(); + } + } + + rg_draw_arena(*s.m_allocator); + ImGui::Separator(); + + // a failed compile never realized: no slots, no pool entries, adjacency half-built. those views would + // render misleading empties, so gate them behind a clean graph and let the errors stand alone. + if (hasErrors) { + ImGui::TextDisabled("Graph, Lifetimes and Memory views are hidden until the errors above are fixed."); + } + else if (ImGui::BeginTabBar("rg_tabs")) { + if (ImGui::BeginTabItem("Graph")) { + rg_draw_dag(rg, s); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Lifetimes")) { + rg_draw_lifetimes(rg, s); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Memory")) { + rg_draw_memory(s); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Timings")) { + rg_draw_timings(s); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + ImGui::End(); +} + +} // namespace webgpu_app + +#if defined(_MSC_VER) +#pragma warning(pop) +#endif diff --git a/apps/webgpu_app/ui/RenderGraphPanel.h b/apps/webgpu_app/ui/RenderGraphPanel.h new file mode 100644 index 000000000..fee5a9394 --- /dev/null +++ b/apps/webgpu_app/ui/RenderGraphPanel.h @@ -0,0 +1,30 @@ +/***************************************************************************** + * Copyright (C) 2026 Matthias Huerbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + +#pragma once + +#include "ImGuiPanel.h" + +namespace webgpu_app { + + +class RenderGraphPanel : public ImGuiPanel { +public: + void draw() override; +}; + +} // namespace webgpu_app diff --git a/docs/rendergraph.md b/docs/rendergraph.md new file mode 100644 index 000000000..8923f7dbf --- /dev/null +++ b/docs/rendergraph.md @@ -0,0 +1,1382 @@ +# Render Graph + +An immediate-mode frame scheduler for WebGPU (`webgpu/base/RenderGraph.h`, namespace +`webgpu::rg`). Each frame you declare the resources and passes the frame needs. The graph +compiles them into an execution order, manages resource lifetimes, and records the passes into +your command encoder. + +The graph owns two things: pass ordering and resource lifetime. It is not a GPU-API wrapper. +Pass bodies call `wgpu*` functions directly. + +> The header [`webgpu/base/RenderGraph.h`](../webgpu/base/RenderGraph.h) is the per-call +> reference: every public declaration carries its parameters, defaults, and constraints. This +> document covers how the pieces fit together, with worked examples. It does not restate the +> signatures. + +## Contents + +- [What it is](#what-it-is) +- [The frame loop](#the-frame-loop) +- [Resources](#resources) +- [Passes](#passes) +- [The two rules](#the-two-rules) +- [Ordering and culling](#ordering-and-culling) +- [Pass groups](#pass-groups) +- [Views and ViewRange](#views-and-viewrange) +- [Buffers](#buffers) +- [Attachments](#attachments) +- [Compute passes](#compute-passes) +- [Transfer passes and copies](#transfer-passes-and-copies) +- [initialize() and hash-gated rebakes](#initialize-and-hash-gated-rebakes) +- [Examples](#examples) +- [Debugging](#debugging) + +--- + +## What it is + +Three concepts carry the whole API. + +A resource is a GPU texture or buffer the graph knows about. Most resources are transient: +declared fresh every frame, allocated from a pool, and possibly aliased onto the same GPU +memory as another transient whose lifetime does not overlap. External objects such as the +swapchain are imported instead. + +A handle is what declaring a resource returns. `ResourceHandle` is a small value type that +names the resource for this frame. Handles are cheap to copy. They are what you pass between +systems and capture into pass lambdas, and they never carry the GPU object itself: the graph +resolves a handle to a real `WGPUTexture` or `WGPUBuffer` only during execution. + +Creators return the kind-tagged `TextureHandle` or `BufferHandle`, and the declarations take +those, so handing a buffer to `sampled()` fails to compile rather than tripping an assert. Both +convert to `ResourceHandle` for the calls that accept either, `bind()` among them, but never the +other way round. Store handles in their typed form, and prefer `auto` for locals. + +A pass is a unit of GPU work declared with `add_pass`. It names every resource it reads and +writes, and the graph derives ordering from those declarations. A pass whose output nothing +consumes is culled, unless it is marked `force_keep()`. + +`add_pass` takes two lambdas, and the split between them is the core of the API. Setup runs +immediately, at record time. It receives a `PassBuilder&` and declares accesses: attachments, +sampled textures, storage buffers, copies. No GPU work happens in setup. Execute runs later, +inside `execute()`, once passes are scheduled and resources are realized. It receives a +`PassContext&` that resolves handles to live GPU objects and holds the encoders. Execute is +where you record draw, dispatch, and copy commands. + +### A frame at a glance + +A typical deferred frame. Passes (rectangles) declare reads and writes of resources (rounded). +The graph derives every arrow from those declarations. The swapchain is imported, so the pass +that writes it is a sink and cannot be culled. + +```mermaid +flowchart LR + subgraph passes [ ] + direction LR + Tiles["Tiles
(Graphics)"] + Atmos["Atmosphere
(Graphics)"] + Clouds["CloudsRender
(Compute)"] + Light["Lighting
(Graphics)"] + Comp["Composite
(Graphics)"] + end + + gbuffer(["gbuffer
albedo/normal/depth"]) + atmoRT(["atmosphere.rt"]) + cloudTex(["clouds.color"]) + lit(["lit.color"]) + swap[["swapchain
(imported sink)"]] + + Tiles --> gbuffer + gbuffer --> Light + Atmos --> atmoRT + gbuffer --> Clouds + Clouds --> cloudTex + Light --> lit + lit --> Comp + atmoRT --> Comp + cloudTex --> Comp + Comp --> swap +``` + +`Composite` transitively feeds the imported `swapchain`, so every pass above survives culling. +Drop the `Composite -> swapchain` edge, say by making nothing read `lit`, and the whole chain +becomes dead. See [Ordering and culling](#ordering-and-culling). + +--- + +## The frame loop + +The call order is a contract. Follow it exactly. + +```cpp +// Startup, once: +webgpu::rg::GraphAllocator* allocator = webgpu::rg::create_allocator(); + +// Every frame: +webgpu::rg::begin_frame(allocator); +webgpu::rg::RenderGraph* rg = webgpu::rg::start_recording(allocator); + +// Import the swapchain first if a pass renders to it (imports happen during recording): +auto swapchain = rg->import_texture("swapchain", + { .view = surface_view, .size = { size.x, size.y, 1 }, .format = surface_format }); + +// ...declare resources and passes... + +// once, after all passes are declared. returns the error chain, and is [[nodiscard]]: +// an unchecked compile() is how a broken frame becomes a silent black screen. +for (auto* e = rg->compile(); e; e = e->next) // always check. see Debugging. + qCritical("%.*s", (int)e->message.length, e->message.data); + +rg->execute(device, queue, encoder, /*enableProfiling*/ false); +// ...submit the encoder to the queue yourself... +rg->collect_gpu_timings(); // safe always. no-op unless profiling was on. +webgpu::rg::end_frame(allocator); // after the queue submit + +// Shutdown, once: +webgpu::rg::destroy_allocator(allocator); +``` + +Three ordering constraints cause most of the bugs here: + +1. Every `import_*`, `create_*`, and `add_pass` runs before `compile()`. After compile the + graph is frozen. +2. `end_frame()` runs after you submit the encoder, not before. The graph's pooled objects are + still referenced by the commands you are submitting. +3. You own the encoder and the submit. The graph records into your encoder and never submits + for you. + +Several graphs per frame are allowed. A later graph may reuse an earlier one's pooled +transients, which assumes the graphs are submitted in execute order on one queue. + +--- + +## Resources + +Everything a pass touches must be a graph resource, declared before `compile()`. There are five +kinds. They differ in who owns the GPU object and how long it lives. + +| Kind | Lifetime | Create with | Use for | +| --- | --- | --- | --- | +| Transient (default) | this frame | `create_transient_texture` / `_buffer` | intermediate targets, scratch | +| Import | caller-owned | `import_texture` / `import_buffer` | swapchain, objects other systems own | +| History | cross-frame ping-pong | `create_history_texture` / `_buffer` | temporal effects (TAA, reprojection) | +| Persistent | cross-frame, one object | `create_persistent_texture` / `_buffer` | graph-owned caches | +| Initialized | cross-frame, baked once | `create_initialized_texture` / `_buffer` | fallbacks for optional bind slots | + +Default to transient. Reach for the others only when you need cross-frame memory. + +```cpp +auto rt = rg->create_transient_texture("atmosphere.rt", { + .dimension = WGPUTextureDimension_2D, + .format = WGPUTextureFormat_RGBA8Unorm, + .absolute = { w, h, 1 }, +}); +``` + +### Relative sizing + +`TextureDesc::sizeKind = Relative` sizes a texture at `scaleX` and `scaleY` times the size of +`relativeTo`, so a half-resolution pass tracks its source through window resizes without you +recomputing anything: + +```cpp +auto half = rg->create_transient_texture("bloom.half", { + .dimension = WGPUTextureDimension_2D, + .format = WGPUTextureFormat_RGBA16Float, + .sizeKind = webgpu::rg::SizeKind::Relative, + .scaleX = 0.5f, + .scaleY = 0.5f, + .relativeTo = fullResColor, +}); +``` + +`relativeTo` may itself be relative. The chain resolves at `compile()`. A cycle is a compile +error, not a hang. + +Known limitation: view-format reinterpretation is unsupported. WebGPU needs reinterpret formats +listed at texture creation, and `TextureDesc` does not plumb `viewFormats` yet. + +### Transient aliasing + +With aliasing on, which is `compile(true)` and the default, two transients pack onto the same +GPU object when their lifetimes do not overlap and their descriptors match (size, format, +dimension, mip and sample count, usage). A transient's lifetime runs from its first write to its +last read. This keeps peak VRAM down. + +Overlapping lifetimes never share, even with identical descriptors. `compile(false)` turns +aliasing off, giving every transient its own object. If a bug disappears with aliasing off, a +lifetime was mis-declared, usually an undeclared read or write. + +Aliasing stays invisible to correctness as long as your declarations are honest: the graph only +reuses memory it has proven dead. The one thing you must not do is reach around the graph and +cache a transient's `WGPUTexture` or view across passes or frames. The object behind a handle +can be a different, shared one next time. Always resolve inside the pass body via `PassContext`. + +### Undeclared reads and aliasing + +The `ctx.*` resolvers assert the handle was declared in this pass, so you cannot create an +undeclared read through the intended path: a missing `sampled()` makes `ctx.view()` assert-fail. +The one way past that is to stash a transient's raw view outside the graph and bind it with the +free `webgpu::bind()` (from `gpu_utils.h`) in a later pass that never declares the read: + +```cpp +WGPUTextureView m_cached_normal_view = nullptr; // a transient's view, escaped + +auto normal = rg->create_transient_texture("gbuffer.normal", desc); + +// Pass A legitimately writes `normal`, then caches its view "to avoid rebuilding": +rg->add_pass("GBuffer", webgpu::rg::PassKind::Graphics, + [normal](webgpu::rg::PassBuilder& b) { b.color(normal, 0); }, + [this, normal](webgpu::rg::PassContext& c) { + draw_gbuffer(c); + m_cached_normal_view = c.view(normal); // legal here, but now it has escaped the graph + }); + +// ...intervening passes declare their own transients. To the graph, `normal` is dead after +// GBuffer (no later pass declares a read), so the aliaser packs one of them onto its slot... + +// Pass B reads `normal` but forgets to declare it, and binds the cached view raw: +rg->add_pass("Lighting", webgpu::rg::PassKind::Graphics, + [lit](webgpu::rg::PassBuilder& b) { + b.color(lit, 0); + // BUG: no b.sampled(normal); the graph never learns Lighting reads `normal` + }, + [this](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bg(c.device, *m_light_layout, + { webgpu::bind(0, m_cached_normal_view) }, // raw bind, invisible to hazards and aliasing + "lighting"); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); +``` + +Under `compile(false)` this looks correct: `normal` keeps its own texture, so the cached view +still points at real data. Under `compile(true)` another transient was packed onto that physical +slot after GBuffer, so the same cached view now reads its pixels. The result is subtly wrong +lighting from identical code, flipped only by aliasing. The fix is to declare the read with +`b.sampled(normal)` and bind it with `c.bind(0, normal)`. Never cache the view. + +### Cross-frame pooling + +Aliasing packs transients within a frame. The transient pool is separate: it keeps GPU objects +across frames. At `end_frame` a transient's object is returned to the pool rather than +destroyed, and next frame's matching `create_transient_*` gets it back. An object left +unclaimed for `kRetain` (4) frames is destroyed. That idle window is deliberate: a pass that +records on only some frames must not pay a destroy and create every time it skips one. + +A resize would defeat that window. The old-size objects are never claimed again, so each one +would linger 4 more frames, and a drag-resize spikes VRAM with a generation per frame. So the +pool also supersede-evicts: at `end_frame`, an idle entry is destroyed immediately if a sibling +created this frame is the same resource at a different size. + +"The same resource" is keyed on the resource's interned name, not its descriptor. Two different +transients that merely differ in size look identical to a descriptor-only check, and evicting on +that guess churns without bound: the idle one is destroyed, recreated on its next claim, and +then supersedes the other. That is one destroy plus create every frame, forever, for any +transient not claimed every frame. The name key is what tells a genuine resize apart from a +coincidence. + +The asymmetry to remember when touching this: a missed supersede only delays an evict to +`kRetain`, while a false supersede churns. When in doubt the predicate refuses. An entry with no +identity is never superseded. + +### History + +`create_history_texture` and `create_history_buffer` return a `HistoryTexture` or +`HistoryBuffer`, a pair of handles. Write `.curr`, read `.prev`. This frame's `.curr` becomes next frame's `.prev`. Gate +every read of `.prev` with `ctx.history_valid(history)`, passing the pair itself rather than +either handle. It returns false on the frame the +`.prev` object was recreated and cleared (first use, resize, reset); skip the history sample +then. A non-zero `hash` invalidates the history whenever the hash changes. + +The two backing textures ping-pong. Frame N writes buffer A and reads B; frame N+1 writes B and +reads A. The dashed edge is the cross-frame carry the graph tracks for you: + +```mermaid +flowchart LR + subgraph fN [Frame N] + pN["TAA"] --> currN(["curr = A"]) + prevN(["prev = B"]) --> pN + end + subgraph fN1 [Frame N+1] + pN1["TAA"] --> currN1(["curr = B"]) + prevN1(["prev = A"]) --> pN1 + end + currN -. "becomes prev" .-> prevN1 + currN1 -. "becomes prev" .-> prevN +``` + +`.prev` is meaningful only when last frame actually wrote `.curr` into the same pool entry. It +is not meaningful on the first frame, the frame after a resize, or any frame the history was +invalidated. In all of these the backing object was just recreated and holds no prior result. +`ctx.history_valid(history)` folds all three cases into one bool. False means skip the temporal +sample this frame and fall back to the non-history path. + +```cpp +// TAA: blend with history only when it is valid, otherwise output the fresh sample as-is. +float alpha = c.history_valid(history) ? 0.9f : 0.0f; // 0 == ignore history +``` + +Read `.prev` without this gate and the first frame, and every frame after a reset, samples +cleared or uninitialized memory: smeared or black temporal output that clears up after a frame. + +### Camera cuts via the history hash + +The optional `hash` on `create_history_texture` and `_buffer` is a content-identity stamp. The +graph keeps it with the pool entry. When the hash you pass differs from the stored one, the +entry is destroyed and recreated blank, which drops `history_valid()` to false for that frame, +exactly as a resize would. Pass the same hash and the ping-pong keeps rotating untouched. + +This is the mechanism for a camera cut. On a teleport, a projection swap, or any jump where last +frame's pixels no longer correspond to this frame's, reprojection is worse than useless. Feed a +hash that changes on the cut and history self-resets for one frame: + +```cpp +// Any value that changes exactly when temporal continuity breaks. The graph only compares it to +// last frame's, so how you derive it is yours: a counter you bump on the cut is enough. +uint64_t cut = cameraCutCounter; +auto taa = rg->create_history_texture("taa.color", desc, cut); +// The frame `cut` changes, history_valid(taa) is false, so skip the reprojected sample. +``` + +`hash == 0` (the default) disables hash invalidation, so history then resets only on first use +and resize. You do not detect the cut frame yourself and branch: change the hash and let +`history_valid()` report the reset at the read site. This is the same mechanism `initialize()` +uses for gated rebakes. See [initialize() and hash-gated rebakes](#initialize-and-hash-gated-rebakes). + +### Initialized fallbacks + +`create_initialized_texture` takes a `WGPUColor` fill; `create_initialized_buffer` takes an +optional `data` pointer (null zero-fills). Contents are baked once and reused every frame. This +is the standard fallback for an optional binding slot: when a feature is off, bind a 1x1 +initialized texture instead of restructuring the pipeline layout. To bake more complex content +once, such as a LUT computed by a pass, use a persistent resource plus `PassBuilder::initialize()`. +See [initialize() and hash-gated rebakes](#initialize-and-hash-gated-rebakes). + +### Import + +`import_texture` wraps a GPU object the graph does not own. The caller guarantees it stays alive +for the frame. Imports are never culled: writing an imported resource is an output that leaves +the frame, which is what keeps the chain feeding the swapchain alive. + +```cpp +auto swapchain = rg->import_texture("swapchain", + { .view = surface_view, .size = { size.x, size.y, 1 }, .format = surface_format }); +``` + +Passing the backing `texture` enables the copy family and `ctx.texture()` on this handle, and lets +the graph build views from a declared `ViewRange` as it would for a graph-owned texture. Leaving it +null registers only the view, which restricts the handle to sample and attach, and makes every +subresource selection moot: `ctx.view()` returns the registered view whatever you declared. +`mipCount`, `sampleCount`, and `dimension` default to a single-sample, single-mip 2D texture. +Pass the real values if the source differs. `dimension` is the source texture's own dimension, +so an array or cube source says `2D`. + +### Names + +Every declaration takes the resource or pass name as a `std::string_view`. The graph hashes it +and copies the name into its arena during the call. The name backs labels, the debug UI, and +error messages; the hash speeds up cross-frame lookups. So the view only has to be valid for +that one call, which is the ordinary `string_view` rule. + +```cpp +// Both fine. The argument is alive for the duration of the call: +rg->create_transient_texture("overlay." + std::to_string(i), desc); // temporary lives to the ';' + +const std::string name = "overlay." + std::to_string(i); +rg->create_transient_texture(name, desc); // named string, obviously alive +``` + +The only trap is the generic one: do not hand the graph a `string_view` into a string that has +already been destroyed. String literals such as `"clouds.lo_color"` live forever and are always +safe. + +--- + +## Passes + +`add_pass(id, kind, setup, execute)` declares one unit of GPU work. The `kind` decides which +encoder the body gets. For `Graphics` you do not call `wgpuCommandEncoderBeginRenderPass`. +`ctx.render_pass` arrives ready, cleared or loaded per your `color()` and `depth_stencil()` +declarations. + +```cpp +rg->add_pass("Atmosphere", webgpu::rg::PassKind::Graphics, + // SETUP: runs now. Declare every resource this pass reads and writes. No GPU work. + [&](webgpu::rg::PassBuilder& b) { + b.color(rt, 0); + }, + // EXECUTE: runs later inside execute(). Record the GPU commands. + [camera_bg, pipeline = m_pipeline->pipeline().handle()](webgpu::rg::PassContext& ctx) { + wgpuRenderPassEncoderSetBindGroup(ctx.render_pass, 0, camera_bg, 0, nullptr); + wgpuRenderPassEncoderSetPipeline(ctx.render_pass, pipeline); + wgpuRenderPassEncoderDraw(ctx.render_pass, 3, 1, 0, 0); + }); +return rt; // hand the handle to whoever consumes this pass's output +``` + +### Setup declares, execute records + +The split between the two lambdas matters more than any single method. + +Setup gets a `PassBuilder&` and declares every resource the pass touches: attachments (`color`, +`depth_stencil`, `resolve`), shader resources (`sampled`, `storage_read`, `storage_write`, +`storage_read_write`, `uniform`, `host_write`), buffer inputs (`vertex_buffer`, `index_buffer`, +`indirect_buffer`), and copies. Those declarations are the only thing the graph knows about the +pass. It derives ordering from them, infers usage flags from them, and decides aliasing from +them. Setup does no GPU work. + +Two builder calls declare no access but change scheduling. `initialize(target, hash)` gates the +pass on a bake being stale (see [initialize()](#initialize-and-hash-gated-rebakes)), and +`force_keep()` exempts it from culling (see [Ordering and culling](#ordering-and-culling)). + +Execute gets a `PassContext&` and records commands. Its resolvers (`ctx.view`, `ctx.bind`, +`ctx.texture`, `ctx.buffer`, the sizes and formats, the copy infos) each assert the handle was +declared in this pass. That assert stops most undeclared reads at the door. The one path around +it is [Undeclared reads and aliasing](#undeclared-reads-and-aliasing). + +For the per-call reference, every parameter, default, and constraint, read +[`webgpu/base/RenderGraph.h`](../webgpu/base/RenderGraph.h). It is the source of truth and is +commented for that purpose. This document does not restate it. + +--- + +## The two rules + +Break either one and you get a compile error, a crash, or silently wrong pixels. + +### Rule 1: the execute lambda must be trivially destructible + +Its closure is stored in an arena that frees memory without running destructors. A +`static_assert` in `add_pass` enforces this. Capture handles, raw GPU handles (`WGPUBindGroup`, +`WGPURenderPipeline`), plain values, and `this` by value. Never capture a `std::string`, smart +pointer, container, or RAII wrapper. + +```cpp +// OK: raw handle pulled out at declaration time. +[pipeline = m_pipeline->pipeline().handle()](webgpu::rg::PassContext& ctx) { ... } +// Compile error: std::string is not trivially destructible. +[label = std::string("debug")](webgpu::rg::PassContext& ctx) { ... } +``` + +### Rule 2: never pre-build a bind group over a transient's view + +A transient's real GPU texture is not chosen until `execute()`, and it can change frame to frame +through pooling and aliasing. A bind group built outside the pass body points at stale or wrong +memory. Build bind groups inside the execute lambda, as a body-local `webgpu::raii::BindGroup`, +from `ctx.bind(...)` entries: + +```cpp +[this, result](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bg(c.device, *m_layout, { + c.bind(0, result), // graph resource -> ctx.bind + m_ubo->create_bind_group_entry(1), // app-owned static -> its own helper + }, "blit"); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); +} +``` + +The body-local group is safe to destroy at end of scope. The encoder keeps its own reference +once `SetBindGroup` records it. + +The same rule forbids caching a transient's view to bind raw in a later pass. That read is +undeclared, so the aliaser can hand its memory to another transient and the cached view reads +the wrong pixels, a bug that shows only with aliasing on. See +[Undeclared reads and aliasing](#undeclared-reads-and-aliasing) and the `compile(false)` row in +[Troubleshooting](#troubleshooting). + +Mixing rule: any resource a graph pass touches binds with `ctx.bind(handle)`, so the graph can +order and alias it. A static or externally-synced object, such as a static UBO or a shadow map +the app owns, binds with its own `create_bind_group_entry()` or the free `webgpu::bind()` +overloads (in `webgpu/base/gpu_utils.h`): + +```cpp +WGPUBindGroupEntry bind(uint32_t binding, WGPUTextureView view); +WGPUBindGroupEntry bind(uint32_t binding, WGPUSampler sampler); +WGPUBindGroupEntry bind(uint32_t binding, WGPUBuffer buffer, uint64_t offset, uint64_t size); +``` + +--- + +## Ordering and culling + +You never write dependencies by hand. The graph derives order from your access declarations: a +`sampled(x)` runs after whatever `color(x)` or `storage_write(x)` produced `x`. To chain two +passes, write a fresh transient in the producer, capture the handle, and declare it as a read in +the consumer: + +```cpp +auto out = producer(rg); // returns a handle it wrote +consumer(rg, /*reads*/ out); // declares b.sampled(out) -> ordered after producer +``` + +Read-after-write and write-after-write both become ordering edges. Write-after-write keeps +declaration order. Declare the producing pass before its readers, def before use. + +Below, `Bake` writes `lut` and `Shade` reads it: one edge, one order. `DebugDump` writes only a +plain transient nobody reads, so it never reaches a sink and `compile()` drops it (dashed): + +```mermaid +flowchart LR + Bake["Bake"] --> lut(["lut"]) + lut --> Shade["Shade"] + Shade --> out(["shaded"]) + out --> swap[["swapchain
(sink)"]] + + Dump["DebugDump
(culled)"] -.-> scratch(["scratch
(no reader)"]) + + classDef dead fill:#8883,stroke-dasharray:4 3,color:#888; + class Dump,scratch dead; +``` + +To keep `DebugDump` (readback, indirect-arg generation, a bake nobody reads this frame), mark it +`force_keep()` or gate it with `initialize()`. + +`compile()` drops every pass whose output nothing needs. A pass is kept only if it transitively +feeds a sink: + +| Sink | Why it counts | +| --- | --- | +| A read by another surviving pass | ordinary producer/consumer dependency | +| A write to an imported resource | the value leaves the frame (e.g. the swapchain) | +| A write to a history `.curr` | it becomes next frame's `.prev` | +| A `force_keep()` pass | explicit side-effect root | + +A pass that only writes a plain transient nobody reads is dead and silently culled. It leaves +the debug panel and GPU timings. If your pass has a side effect the graph cannot see (readback, +indirect-arg generation, a bake nobody reads this frame), mark it `force_keep()` or gate it with +`initialize()`. + +--- + +## Pass groups + +A `.` in a pass name creates a group. The span before the first dot is the group name, and +passes that share it are bracketed together in GPU captures and drawn as one region in the +RenderGraph panel. Naming costs nothing and makes a 40-pass frame readable. + +```cpp +rg->add_pass("bloom.threshold", ...); +rg->add_pass("bloom.blurH", ...); +rg->add_pass("bloom.blurV", ...); +rg->add_pass("bloom.composite", ...); +``` + +Grouping is a labelling feature only. It has no effect on ordering, culling, aliasing, usage +inference, or correctness. A pass with no dot in its name belongs to no group. + +### What the group drives + +During `execute()`, the graph brackets each run of same-group passes in a command-encoder debug +group with `wgpuCommandEncoderPushDebugGroup` and `PopDebugGroup`. A RenderDoc, PIX, or Xcode +capture then shows `bloom` as one collapsible region containing its four passes instead of four +unrelated entries. + +The panel uses the same prefix to draw the run inside one bordered region and to keep it as a +single block in the layout, so a group stays visually together instead of being scattered by the +dependency columns. A region needs at least two passes; a lone `bloom.threshold` draws as an +ordinary box. + +### Nesting with more dots + +The encoder debug group uses the first segment only. The panel goes further and builds a tree +from the remaining segments, so `bloom.down.0` and `bloom.down.1` nest under `bloom.down`, which +nests under `bloom`. Each level collapses independently and the panel remembers the collapse +state per prefix across frames. + +```cpp +rg->add_pass("bloom.down.0", ...); // panel: bloom > down > 0 +rg->add_pass("bloom.down.1", ...); +rg->add_pass("bloom.up.0", ...); // panel: bloom > up > 0 +``` + +As with the top level, a nested level needs at least two members to become its own subgroup. + +### Groups follow execution order, not declaration order + +The run is computed over passes in execution order, which `compile()` derives from your +declarations. Passes sharing a prefix that do not end up scheduled next to each other produce +two separate debug groups with the same name, not one merged region. + +This is usually invisible, because a group is normally a chain and a chain schedules +consecutively. It shows up when an unrelated pass gets scheduled into the middle of a group, for +example when a group member reads a resource produced late. If a group looks split in a capture, +check the Graph tab for what landed between its members. + +Culling applies first. A culled pass is not in the execution order at all, so it contributes +nothing to its group, and a group whose members are all culled disappears. + +By convention resource names use dots too, as in `gbuffer.albedo` and `clouds.lo_color`. That is +for readability in labels and error messages. Only pass names form groups. + +--- + +## Views and ViewRange + +`ctx.view(h)` returns a view whose shape matches what you declared: same base mip and layer, +same `ViewRange`. You never build a `WGPUTextureViewDescriptor`. + +Which subresource you mean is part of the same option struct as everything else. `sampled()` and +the `storage_*()` family take a `ViewRange`, whose `baseMip`/`baseLayer` default to 0. Attachments +and copies take a `Subresource` instead, since they address exactly one mip and one layer. + +```cpp +b.sampled(tex, { .baseMip = 2 }); // read mip 2 +b.color(tex, 0, { .sub = { .mip = 3 } }); // render into mip 3 +// In the body, fetch a specific subresource explicitly: +WGPUTextureView mip2 = c.view(tex, 2); // mip 2, layer 0 +auto entry = c.bind(0, tex, 2); // same view, as a bind-group entry +``` + +`ViewRange` is how you name more than one subresource: a mip chain, an array slice, a cubemap, +or a single aspect of a depth-stencil texture. It is the optional last argument of `sampled()` +and the `storage_*()` family. Attachments take a `Subresource` instead, since a render target is +always one mip and one layer. + +Write it with designated initializers. The fields are same-typed, so a positional +`{ 1, 2, 0, 6 }` compiles happily while meaning something else entirely. The `cube()`, +`cube_array()` and `whole()` helpers return a ready-made range, and `.at(mip, layer)` rebases +one: `cube().at(0, 4)` is cube face 4. The shape you declare here is the shape `ctx.view(h)` hands back in the body. + +### The default is one subresource + +Every field has a default that suits a plain 2D texture, so `{}` (what you get by omitting it) +means one mip, one layer, all aspects, dimension inferred. Set a field only when that is not +what you want. + +### mipCount and layerCount count forward, and 0 means the rest + +Both are counts from the base, not indices. `b.sampled(tex, { .baseMip = 2, .mipCount = 3 })` is mips 2, +3, and 4. + +`0` is the useful special case: all remaining from the base. Prefer it to counting by hand. An +over-wide count is the usual cause of a `"declares a view of …"` compile error, and `0` cannot +overrun. + +```cpp +b.sampled(prefiltered, { .mipCount = 0 }); // the whole mip chain +b.sampled(cascades, { .layerCount = 4 }); // layers 0..3, as a 2DArray +b.sampled(cascades, { .baseLayer = 2, .layerCount = 0 }); // layer 2 to the end +``` + +### dim is inferred unless you set it + +`dim` defaults to `Undefined`, which resolves to `3D` for a 3D texture, else `2DArray` when the +view covers more than one layer, else `2D`. That is right nearly always. Set it explicitly for a +cube, or to force `2DArray` over a single layer because the shader binding says +`texture_2d_array`. + +### aspect picks a plane + +For a combined depth-stencil format, say which plane a sampled read means: + +```cpp +b.sampled(depth, { .aspect = WGPUTextureAspect_DepthOnly }); +``` + +### Layers come from the texture, not the range + +`layerCount` can only slice layers the texture actually has, and array layers live in +`TextureDesc::absolute.depthOrArrayLayers` of a 2D texture. An array is 2D with depth > 1, not +`WGPUTextureDimension_3D`. Confuse the two and you get a `3D` view where you wanted an array, or +a range error: + +```cpp +auto cascades = rg->create_transient_texture("shadow.cascades", { + .dimension = WGPUTextureDimension_2D, // 2D, not 3D + .format = WGPUTextureFormat_Depth32Float, + .absolute = { 2048, 2048, /*layers*/ 4 }, // the layer count lives here +}); +b.sampled(cascades, { .layerCount = 4 }); // -> 2DArray of 4 +``` + +### Cubes: use the helpers + +Cube views have strict rules: exactly 6 layers, a positive multiple of 6 for an array, and a 2D +source. Three constexpr helpers spell them out correctly. `whole()` is the everything-from-the- +base shorthand: + +```cpp +b.sampled(envMap, webgpu::rg::cube()); // 6 layers, dim Cube +b.sampled(probes, webgpu::rg::cube_array(4)); // 4 cubes = 24 layers +b.sampled(prefiltered, webgpu::rg::whole()); // every mip and layer +// each helper also takes an aspect and a mipCount: +b.sampled(envMap, webgpu::rg::cube(WGPUTextureAspect_All, /*mipCount*/ 0)); // cube + full chain +``` + +`compile()` checks the rest and names the offending pass and resource: a cube covering other +than 6 layers, a cube array not a multiple of 6, a base plus count past the last layer, or a +cube view of a non-2D texture. One rule has no workaround: a cube view on a `storage_read` or +`storage_write` is an error, because WebGPU storage textures cannot be cube. Sample it as a +cube, or bind it as a `2DArray` for storage. + +### Recipe: generate a mip chain + +A copy cannot resize, so mip generation is a blit pass per level: sample mip `i`, render into +mip `i+1`, same handle, two subresources. Hazard tracking is whole-resource, so the chain +serializes level by level, which is the order you want anyway. + +The texture must be created with the levels it is about to fill. `TextureDesc::mipLevelCount` +defaults to 1 and there is no `0` meaning all, so spell the chain out; a loop past the declared +count is the `"is accessed at mip …"` compile error: + +```cpp +constexpr uint32_t kMips = 8; +auto tex = rg->create_transient_texture("prefiltered", { + .dimension = WGPUTextureDimension_2D, + .format = WGPUTextureFormat_RGBA16Float, + .absolute = { 256, 256, 1 }, + .mipLevelCount = kMips, // without this the texture has one mip +}); + +for (uint32_t mip = 1; mip < kMips; ++mip) { + rg->add_pass(names[mip], webgpu::rg::PassKind::Graphics, // names[] must outlive each add_pass call + [&](webgpu::rg::PassBuilder& b) { + b.sampled(tex, { .baseMip = mip - 1 }); // read mip i-1 + b.color(tex, 0, { .sub = { .mip = mip } }); // write mip i + }, + [tex, mip, layout = m_blitLayout.handle()](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup group(c.device, layout, { c.bind(0, tex, mip - 1) }, "mip blit"); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, group.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); +} +``` + +--- + +## Buffers + +Declare buffers with `create_transient_buffer`, `create_persistent_buffer`, or +`create_history_buffer`, each taking a `BufferDesc { size }`. `import_buffer` takes the caller's +`WGPUBuffer` instead of a desc, since the object already has its size. Usage flags are inferred +from how passes declare the buffer. You never set `WGPUBufferUsage` yourself: + +| Declaration | Inferred usage | WGSL | +| --- | --- | --- | +| `b.uniform(h)` | Uniform | `var` | +| `b.storage_read(h)` | Storage (read) | `var` | +| `b.storage_write(h)` / `b.storage_read_write(h)` | Storage (write / read_write) | `var` | +| `b.vertex_buffer(h)` | Vertex | vertex fetch | +| `b.index_buffer(h)` | Index | index fetch | +| `b.indirect_buffer(h)` | Indirect | draw/dispatch indirect args | +| `b.host_write(h)` | CopyDst | filled via `wgpuQueueWriteBuffer` in the body | +| `b.copy_buffer(src,dst,…)` | CopySrc / CopyDst | see copies | + +`ctx.buffer(h)` gives the `WGPUBuffer`, and `ctx.buffer_size(h)` its realized size. +`ctx.bind(binding, h)` makes a whole-buffer binding at offset 0 with the realized size, unless the +declaration carried a `BufferRange` (below). + +### Binding a sub-range + +`uniform()` and the `storage_*()` buffer declarations take an optional `BufferRange { offset, size }`, +the buffer analog of `ViewRange`: the shape you declare is the shape `ctx.bind(binding, h)` hands +back. `size = 0` (the default) means all remaining bytes from `offset`, resolved at declare time. + +```cpp +// bind the second 256-byte slice of a packed parameter buffer +b.uniform(params, { .offset = 256, .size = 256 }); +b.storage_read(packed, { .offset = 512 }); // byte 512 to the end +``` + +Constraints, all checked at `compile()`: the range must fit the buffer, must not be empty, and +`offset` must be a multiple of 256, the spec-default uniform/storage offset alignment. One range per +buffer per pass; two bind declarations giving the same buffer different ranges is ambiguous and +asserts in `ctx.bind`. Hazard tracking stays whole-resource, so two passes touching disjoint ranges +of one buffer still order serially; use separate transients if that matters. + +```cpp +auto args = rg->create_transient_buffer("cull.args", { .size = 16 }); + +rg->add_pass("Cull", webgpu::rg::PassKind::Compute, + [args, ids](webgpu::rg::PassBuilder& b) { + b.storage_read(ids); + b.storage_write(args); // -> Storage usage on args + }, + [this, args, ids](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bg(c.device, *m_cull_layout, + { c.bind(0, ids), c.bind(1, args) }, "cull"); + wgpuComputePassEncoderSetPipeline(c.compute_pass, m_cull_pipeline); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bg.handle(), 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass, groups, 1, 1); + }); +// A later Graphics pass declares b.indirect_buffer(args) -> args gains Indirect usage +// and the draw is ordered after Cull. +``` + +--- + +## Attachments + +The graph schedules passes and wires attachments. The pass body owns the pipeline (blend, +depth-stencil state, `multisample.count`). The graph does not validate formats; Dawn does. + +### Color and depth + +`color()` and `depth_stencil()` declare a write into one subresource (a render target is always +one mip, one layer). Their load and store ops are plain WebGPU, passed through untouched: `Clear` +starts from the clear value, `Load` keeps what is there, `Store` keeps the result, `Discard` +throws it away. + +`color()` takes the attachment slot explicitly, right after the handle. It is the `@location` +the fragment shader writes, not the order you declare in. Slots may be sparse, but one slot +twice in a pass is an error. + +```cpp +// Slot 0, clear to opaque black, keep the result: +b.color(target, 0); // defaults: Clear, Store, {0,0,0,1} +// Draw over what a previous pass produced (composite, overlays, tracks): +b.color(target, 0, { .load = WGPULoadOp_Load }); +// Depth attachment, reverse-Z: clear to 0.0 and pair with a Greater/GreaterEqual pipeline. +b.depth_stencil(depth, { .clearDepth = 0.0f }); +``` + +A multi-target pass names one slot per `color()`, matching its fragment shader's `@location`s, +plus an optional `depth_stencil()`: + +```cpp +rg->add_pass("Tiles", webgpu::rg::PassKind::Graphics, + [albedo, position, normal, gdepth](webgpu::rg::PassBuilder& b) { + b.color(albedo, 0, { .clear = { 0, 0, 0, 0 } }); + b.color(position, 1, { .clear = { 0, 0, 0, 0 } }); + b.color(normal, 2, { .clear = { 0, 0, 0, 0 } }); + b.depth_stencil(gdepth, { .clearDepth = 0.0f }); // reverse-Z + }, + [this](webgpu::rg::PassContext& c) { /* set bind groups + pipeline; draw */ }); +``` + +Up to 8 color attachments (`kMaxColorAttachments`). A test-only pass that reads depth without +writing it declares `depth_stencil_read_only(depth)`, the cheapest way to avoid a false write +hazard. + +### MSAA and resolve + +`TextureDesc::sampleCount = 4` makes a texture multisampled (WebGPU 1.0 allows 1 or 4). MSAA and +non-MSAA textures never alias each other. + +`resolve(src, target)` declares `target` as the single-sample resolve of `src`. The `src` handle +must already have a `color()` declaration in this pass, or you get an error naming the pass. Each +color attachment takes at most one resolve target. + +```cpp +auto msaaColor = rg->create_transient_texture("msaa.color", + { .dimension = WGPUTextureDimension_2D, .format = kColorFormat, .absolute = size, .sampleCount = 4 }); +auto msaaDepth = rg->create_transient_texture("msaa.depth", + { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_Depth32Float, .absolute = size, .sampleCount = 4 }); +auto resolved = rg->create_transient_texture("resolved", + { .dimension = WGPUTextureDimension_2D, .format = kColorFormat, .absolute = size }); // single-sample + +rg->add_pass("forward.msaa", webgpu::rg::PassKind::Graphics, + [&](webgpu::rg::PassBuilder& b) { + b.color(msaaColor, 0); // multisample color + b.resolve(msaaColor, resolved); // src must already be declared as a color() + b.depth_stencil(msaaDepth); // depth is NOT resolved + }, + [pipeline = msaaPipe](webgpu::rg::PassContext& ctx) { + // pipeline MUST be built with .multisample = { .count = 4, .mask = ~0u } + wgpuRenderPassEncoderSetPipeline(ctx.render_pass, pipeline); + wgpuRenderPassEncoderDraw(ctx.render_pass, 3, 1, 0, 0); + }); +``` + +The resolve target may be an imported texture, resolving straight into the swapchain: +`b.resolve(msaaColor, swapchain)`. Dawn enforces the rest: every attachment in one pass shares +one `sampleCount` equal to the pipeline's; a resolve target is single-sample with the same +format and size as its color; depth and stencil cannot be resolved through a render pass. + +### Stencil + +Use a depth+stencil format (`Depth24PlusStencil8`, `Depth32FloatStencil8`) and pass the stencil +load, store, and clear to `depth_stencil()`. The graph only carries the attachment's load, store, +and clear. The actual stencil compare and write live in the pipeline's `WGPUDepthStencilState`. +The stencil params default to `Undefined`, so depth-only formats are unaffected; leave them out. + +```cpp +// Pass 1: write the mask (pipeline: stencil passOp = Replace, ref = 1). +b.depth_stencil(ds, { .stencilLoad = WGPULoadOp_Clear, .stencilStore = WGPUStoreOp_Store }); +// Pass 2: effect only where stencil == 1 (pipeline: compare = Equal, ref = 1). +b.depth_stencil_read_only(ds); // test only -> reads ds, orders after the mask pass +b.color(outColor, 0, { .load = WGPULoadOp_Load }); +``` + +`depth_stencil_read_only()` marks both depth and stencil read-only. Marking stencil read-only +while depth stays writable is not expressible; they share one flag. + +--- + +## Compute passes + +`PassKind::Compute` gives you `ctx.compute_pass`. Declare storage, sampled, and uniform accesses +in setup; build the bind group in the body from `ctx.bind(...)` and dispatch. A pass that writes +a storage target and a later pass that samples it are ordered automatically. + +```cpp +rg->add_pass("CloudsRender", webgpu::rg::PassKind::Compute, + [lo_color, lo_depth, gbuffer_depth](webgpu::rg::PassBuilder& b) { + b.storage_write(lo_color); // binding 4 (rgba16float) + b.storage_write(lo_depth); // binding 5 (r32float) + b.sampled(gbuffer_depth); // ordering only: bound via a pre-built depth bind group + }, + [this, lo_color, lo_depth, depth_bg, shared_config_bg](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bind_group(c.device, + m_ctx->resource_registry().bind_group_layout("render_clouds"), + { + m_params_ubo->raw_buffer().create_bind_group_entry(0), // static + m_atlas_view->create_bind_group_entry(1), // static + c.bind(4, lo_color), // graph storage-write + c.bind(5, lo_depth), + }, "CloudsRender"); + wgpuComputePassEncoderSetPipeline(c.compute_pass, m_render_pipeline->handle()); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bind_group.handle(), 0, nullptr); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 1, depth_bg, 0, nullptr); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 2, shared_config_bg, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass, + ceil_div(res.x, 8u), ceil_div(res.y, 8u), 1); + }); +``` + +Storage and sampled transitions between compute passes, and between compute and graphics, are +auto-synchronized by Dawn. There are no manual barriers. A read and write in the same dispatch +uses `storage_read_write()`; in-dispatch races are the shader's responsibility. + +--- + +## Transfer passes and copies + +`PassKind::Transfer` gets no pass object. The body encodes copies directly on `ctx.encoder`. +Each copy method declares `CopySrc` on src and `CopyDst` on dst, and the ctx resolvers build the +WebGPU copy descriptors with the declared subresource applied. + +Four copies are declarable: `copy_texture` (subresource to subresource, it cannot resize, so it +is no use for mip generation), `copy_texture_to_buffer` (readback and export), +`copy_buffer_to_texture` (host-staged upload), and `copy_buffer` (a byte range; offsets and size +must be multiples of 4, and src and dst must differ). + +Resolvers give you one unambiguous handle and direction per pass. For several copies over one +resource, build the infos by hand via `ctx.texture()` and `ctx.buffer()`. + +```cpp +// Buffer -> buffer (e.g. counter -> indirect args): +rg->add_pass("CopyArgs", webgpu::rg::PassKind::Transfer, + [counter, indirectArgs](webgpu::rg::PassBuilder& b) { + b.copy_buffer(counter, indirectArgs, 0, 0, 16); + }, + [counter, indirectArgs](webgpu::rg::PassContext& ctx) { + auto c = ctx.buffer_copy_info(counter, indirectArgs); // size already expanded + wgpuCommandEncoderCopyBufferToBuffer(ctx.encoder, c.src, c.srcOffset, c.dst, c.dstOffset, c.size); + }); +``` + +The buffer side of a texture copy is yours to describe, and WebGPU requires each row to start on +a 256-byte boundary. `webgpu::rg::aligned_bytes_per_row(widthTexels, texelBlockBytes)` does that +rounding so the readback buffer and the layout agree: + +```cpp +// Texture -> buffer readback. The buffer-side layout is the body's; rows are 256-aligned. +rg->add_pass("Readback", webgpu::rg::PassKind::Transfer, + [src, dst](webgpu::rg::PassBuilder& b) { b.copy_texture_to_buffer(src, dst); }, + [src, dst, w, h](webgpu::rg::PassContext& ctx) { + WGPUTexelCopyBufferLayout layout { + .offset = 0, + .bytesPerRow = webgpu::rg::aligned_bytes_per_row(w, /*texelBytes*/ 4), + .rowsPerImage = h, + }; + auto s = ctx.copy_src_info(src); // texture side, declared mip/layer applied + auto d = ctx.copy_dst_buffer(dst, layout); // buffer side, {layout, buffer} + auto sz = ctx.copy_extent_src(src); + wgpuCommandEncoderCopyTextureToBuffer(ctx.encoder, &s, &d, &sz); + }); +``` + +Hazard tracking is whole-resource: two copies over disjoint ranges of the same pair still order +serially. Copying an imported texture requires that the import passed its backing `WGPUTexture`. +Copies are not auto-encoded in `execute()`; the body calls `wgpu*` directly, the same contract +as every other pass kind. + +--- + +## initialize() and hash-gated rebakes + +`initialize()` marks a pass as the baker for a pool-backed resource: a persistent texture or +buffer, or a history `.curr`. You still declare the write normally (`color()`, `storage_write()`, +a copy). `initialize()` only decides whether the pass runs this frame. The graph runs it only +when the target needs rebaking, and skips it, culling the pass so it leaves the panel and +timings, on every frame the baked content is still valid. Readers bind the pooled result either +way: persistent resources are external to the frame, so no in-graph writer is required for a +reader to resolve them. + +Declare it in setup, alongside the write it gates: + +```cpp +// Bake a BRDF or atmosphere LUT once, then reuse the pooled texture every frame. +auto lut = rg->create_persistent_texture("brdf.lut", lutDesc); + +rg->add_pass("BakeBRDF", webgpu::rg::PassKind::Compute, + [lut](webgpu::rg::PassBuilder& b) { + b.storage_write(lut); // the actual write + b.initialize(lut); // hash 0 -> bake once, then skip forever + }, + [this, lut](webgpu::rg::PassContext& c) { /* dispatch the bake */ }); +// Every later frame: BakeBRDF is skipped and culled; samplers still bind the pooled LUT. +``` + +### The rebake triggers + +A pass gated by `initialize(target, hash)` runs when any of these holds; otherwise it is skipped +and culled: + +| Trigger | Meaning | +| --- | --- | +| First frame | the pool entry has never been realized or baked | +| Pool recreation | the entry was evicted, or resized or reformatted (a bigger usage set, a size/format/dim/mip/sample change). Recreation clears the baked flag | +| Hash mismatch | the `hash` you pass differs from the one stamped at the last bake | + +A body bakes all its targets or none: one stale target re-arms the whole pass. The hash is +re-stamped when the pass actually runs, during `execute()`, so a frame that fails `compile()` +never claims a bake it did not perform. + +### Ad-hoc rebuilding via the hash + +`hash == 0` means bake once, never again. A non-zero hash turns `initialize()` into an on-demand +rebuild: pass a value derived from every input the baked content depends on, and the bake re-runs +exactly on the frames that value changes. There is no dirty flag, no manual invalidation call, +and no rebuild every frame. + +```cpp +// Rebake only when a setting that feeds the LUT changes. Any hash over those inputs works, the +// graph just compares it to the value stamped at the last bake: +uint64_t sig = std::hash {}( + { reinterpret_cast(&lutParams), sizeof(lutParams) }); // explicit size, not strlen + +rg->add_pass("BakeBRDF", webgpu::rg::PassKind::Compute, + [lut, sig](webgpu::rg::PassBuilder& b) { + b.storage_write(lut); + b.initialize(lut, sig); // re-runs only on the frame `sig` changes + }, + [this, lut](webgpu::rg::PassContext& c) { /* dispatch the bake */ }); +``` + +This is the same hash mechanism history uses for +[camera cuts](#camera-cuts-via-the-history-hash): one number, changed when the content's +identity changes, and the graph rebuilds on that frame. Use it for LUTs that depend on tunables, +precomputed shadow or irradiance data keyed to sun direction, and any expensive result whose +inputs change occasionally but not per frame. + +--- + +## Examples + +Each example is a small slice of a real frame graph. They compose the pieces from the sections +above: MRT attachments, cross-stage ordering, relative sizing, history, persistent bakes, and +imports. The pass bodies are abbreviated to the graph-relevant calls; pipeline creation and +draw setup live in the app. + +### Deferred shading: G-buffer plus compute lighting + +A Graphics pass fills a multi-target G-buffer. A Compute pass reads the three targets and writes +a lit storage texture. The read-after-write edge is derived from the declarations, so the compute +pass runs after the G-buffer with no manual barrier. + +```cpp +auto albedo = rg->create_transient_texture("gbuffer.albedo", + { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_RGBA8Unorm, .absolute = size }); +auto normal = rg->create_transient_texture("gbuffer.normal", + { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_RGBA16Float, .absolute = size }); +auto depth = rg->create_transient_texture("gbuffer.depth", + { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_Depth32Float, .absolute = size }); +auto lit = rg->create_transient_texture("lit.color", + { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_RGBA16Float, .absolute = size }); + +rg->add_pass("GBuffer", webgpu::rg::PassKind::Graphics, + [albedo, normal, depth](webgpu::rg::PassBuilder& b) { + b.color(albedo, 0, { .clear = { 0, 0, 0, 0 } }); + b.color(normal, 1, { .clear = { 0, 0, 0, 0 } }); + b.depth_stencil(depth, { .clearDepth = 0.0f }); // reverse-Z + }, + [this](webgpu::rg::PassContext& c) { draw_scene(c); }); + +rg->add_pass("Lighting", webgpu::rg::PassKind::Compute, + [albedo, normal, depth, lit](webgpu::rg::PassBuilder& b) { + b.sampled(albedo); + b.sampled(normal); + b.sampled(depth); // depth-only format, so no aspect needed + b.storage_write(lit); // ordered after GBuffer by the reads above + }, + [this, albedo, normal, depth, lit, lights_bg](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bg(c.device, *m_light_layout, { + c.bind(0, albedo), c.bind(1, normal), c.bind(2, depth), c.bind(3, lit), + }, "tiled-lighting"); + wgpuComputePassEncoderSetPipeline(c.compute_pass, m_light_pipeline); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bg.handle(), 0, nullptr); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 1, lights_bg, 0, nullptr); // app-owned light list + wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass, + ceil_div(size.width, 16u), ceil_div(size.height, 16u), 1); // one workgroup per 16x16 tile + }); +// return `lit` to the tonemap/composite pass. +``` + +For a tiled light culling prepass, add a `create_transient_buffer` for the per-tile light index +list, `storage_write` it in a cull compute pass, and `storage_read` it here. The cull pass then +orders before lighting. + +### Bloom: relative-sized downsample chain + +The bright-pass and blur targets are half resolution, sized with `Relative` so they follow the +HDR target through resizes. The chain is a sequence of transients, each written by one pass and +read by the next. The final composite loads the HDR target and adds the blurred bloom on top. + +```cpp +webgpu::rg::TextureDesc halfDesc { + .dimension = WGPUTextureDimension_2D, + .format = WGPUTextureFormat_RGBA16Float, + .sizeKind = webgpu::rg::SizeKind::Relative, + .scaleX = 0.5f, .scaleY = 0.5f, + .relativeTo = hdrColor, +}; +auto bright = rg->create_transient_texture("bloom.bright", halfDesc); +auto blurH = rg->create_transient_texture("bloom.blur_h", halfDesc); +auto blurV = rg->create_transient_texture("bloom.blur_v", halfDesc); + +// A tiny helper: full-screen pass that samples one texture and writes another. +auto blit_pass = [&](const char* name, webgpu::rg::TextureHandle src, + webgpu::rg::TextureHandle dst, WGPURenderPipeline pipe) { + rg->add_pass(name, webgpu::rg::PassKind::Graphics, + [src, dst](webgpu::rg::PassBuilder& b) { b.sampled(src); b.color(dst, 0); }, + [this, src, pipe](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bg(c.device, *m_blit_layout, { c.bind(0, src) }, "bloom"); + wgpuRenderPassEncoderSetPipeline(c.render_pass, pipe); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); +}; + +blit_pass("bloom.threshold", hdrColor, bright, m_threshold_pipe); +blit_pass("bloom.blurH", bright, blurH, m_blur_h_pipe); +blit_pass("bloom.blurV", blurH, blurV, m_blur_v_pipe); + +// Composite: keep the HDR target, add bloom. Additive blend lives in m_add_pipe. +rg->add_pass("bloom.composite", webgpu::rg::PassKind::Graphics, + [hdrColor, blurV](webgpu::rg::PassBuilder& b) { + b.color(hdrColor, 0, { .load = WGPULoadOp_Load }); // load, do not clear + b.sampled(blurV); + }, + [this, blurV](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bg(c.device, *m_blit_layout, { c.bind(0, blurV) }, "bloom.add"); + wgpuRenderPassEncoderSetPipeline(c.render_pass, m_add_pipe); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); +``` + +`bright` is dead after `bloom.blurH` reads it, so the aliaser can pack `blurV` onto the same +memory. The three half-res targets cost one or two physical textures, not three. + +### TAA: history plus a camera-cut hash + +TAA reads last frame's resolved color and blends it with the current frame. The history resource +gives the ping-pong pair. `history_valid` handles the first frame and any reset. The hash resets +history on a camera cut, so reprojection does not smear across the discontinuity. + +```cpp +uint64_t cut = cameraCutCounter; // changes on teleport, projection swap, etc. +auto taa = rg->create_history_texture("taa.color", colorDesc, cut); + +rg->add_pass("TAA", webgpu::rg::PassKind::Graphics, + [currentColor, velocity, taa](webgpu::rg::PassBuilder& b) { + b.sampled(currentColor); + b.sampled(velocity); + b.sampled(taa.prev); // last frame's result + b.color(taa.curr, 0); // this frame's result, becomes next frame's prev + }, + [this, currentColor, velocity, taa](webgpu::rg::PassContext& c) { + float alpha = c.history_valid(taa) ? 0.9f : 0.0f; // 0 == ignore history this frame + wgpuQueueWriteBuffer(c.queue, m_taa_ubo, 0, &alpha, sizeof(alpha)); // shader reads the blend weight + webgpu::raii::BindGroup bg(c.device, *m_taa_layout, { + c.bind(0, currentColor), c.bind(1, velocity), c.bind(2, taa.prev), + webgpu::bind(3, m_taa_ubo, 0, sizeof(float)), // app-owned UBO + }, "taa"); + wgpuRenderPassEncoderSetPipeline(c.render_pass, m_taa_pipe); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); +// A later tonemap pass samples taa.curr for display. +``` + +Writing `taa.curr` makes the pass a sink, so it is never culled even on a frame where nothing +downstream has been declared yet. + +### IBL: import an environment cube and bake a specular BRDF LUT + +Two resources with different lifetimes back image-based lighting. The environment cubemap is +loaded once by the app and imported; the split-sum BRDF LUT is baked once into a persistent +texture and reused every frame. + +An imported cubemap registered view-only, meaning `.texture` left null, hands back the caller's +cube view as-is. The graph never builds a view for it, so `ctx.view()` ignores any `ViewRange` you +declared and `ctx.view(h, mip, layer)` asserts unless both are 0. Sample it with a plain +`sampled()` and no range. + +`cube()` here is not merely redundant, it fails to compile the graph: the cube check validates +against the layer count in the import's `size`, which is 1 below, so `b.sampled(env, cube())` +errors with "(baseLayer 0 + 6 layers) exceeds the texture's 1 layer(s)". Declare the real layer +count in `size` and it compiles, but on a view-only import the range still selects nothing. The +`cube()` and `cube_array()` helpers are for textures the graph builds views for. + +That is the view-only case specifically, not imports in general. An import that does pass its +backing `texture` takes the ordinary path: the graph builds views from the `ViewRange` you +declare, cube helpers included, exactly as for a graph-owned texture. + +```cpp +// App owns and keeps alive a cube WGPUTextureView. Register it view-only (no backing texture). +auto env = rg->import_texture("ibl.env", + { .view = m_env_cube_view, .size = { envSize, envSize, 1 }, .format = WGPUTextureFormat_RGBA16Float }); + +// The split-sum BRDF LUT: a 2D rg16f table, baked once, then read every frame. +auto brdfLut = rg->create_persistent_texture("ibl.brdf_lut", { + .dimension = WGPUTextureDimension_2D, + .format = WGPUTextureFormat_RG16Float, + .absolute = { 512, 512, 1 }, +}); + +rg->add_pass("BakeBRDF", webgpu::rg::PassKind::Compute, + [brdfLut](webgpu::rg::PassBuilder& b) { + b.storage_write(brdfLut); + b.initialize(brdfLut); // hash 0: bake once, then the pass is skipped and culled + }, + [this, brdfLut](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bg(c.device, *m_brdf_layout, { c.bind(0, brdfLut) }, "bake-brdf"); + wgpuComputePassEncoderSetPipeline(c.compute_pass, m_brdf_pipe); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bg.handle(), 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass, 512 / 8, 512 / 8, 1); + }); + +// The lighting pass consumes both. `env` is imported and `brdfLut` is persistent, so neither +// needs an in-graph writer this frame for the reads to resolve. +rg->add_pass("IBL", webgpu::rg::PassKind::Graphics, + [env, brdfLut, gbuffer_normal, lit](webgpu::rg::PassBuilder& b) { + b.sampled(env); // imported cube view, returned as registered + b.sampled(brdfLut); + b.sampled(gbuffer_normal); + b.color(lit, 0, { .load = WGPULoadOp_Load }); + }, + [this, env, brdfLut, gbuffer_normal](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bg(c.device, *m_ibl_layout, { + c.bind(0, env), c.bind(1, brdfLut), c.bind(2, gbuffer_normal), + m_ibl_sampler->create_bind_group_entry(3), // app-owned sampler + }, "ibl"); + wgpuRenderPassEncoderSetPipeline(c.render_pass, m_ibl_pipe); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); +``` + +### Prefiltered specular: bake a mip-per-roughness cube + +The import above is view-only, so `sampled(env)` returns whatever view the app registered and the +graph has no say in its mip coverage. To let the graph own the roughness chain, make the +prefiltered specular map a graph-owned persistent cube instead. Then the range you declare is +honored, and `cube(All, 0)` binds every roughness level. + +Storage textures cannot be cube, so the bake renders each face and mip as a color attachment: one +pass per (mip, layer), each declaring its own subresource write and all gating on the same +`initialize()` target so the whole set bakes together. Key the hash on the environment's identity +to rebake when the sky changes. + +```cpp +constexpr uint32_t kFaces = 6; +constexpr uint32_t kRoughnessMips = 5; // roughness 0..1 across the chain + +auto prefiltered = rg->create_persistent_texture("ibl.prefiltered", { + .dimension = WGPUTextureDimension_2D, // a cube is 2D with 6 layers + .format = WGPUTextureFormat_RGBA16Float, + .absolute = { 128, 128, kFaces }, + .mipLevelCount = kRoughnessMips, // spell the chain out, there is no 0 = all +}); + +uint64_t envId = m_env_cube_hash; // changes when the loaded environment changes +for (uint32_t mip = 0; mip < kRoughnessMips; ++mip) + for (uint32_t face = 0; face < kFaces; ++face) + rg->add_pass("ibl.prefilter", webgpu::rg::PassKind::Graphics, + [prefiltered, mip, face](webgpu::rg::PassBuilder& b) { + b.color(prefiltered, 0, { .sub = { .mip = mip, .layer = face } }); // one face+mip + b.initialize(prefiltered, envId); // whole set bakes once, rebakes when envId changes + }, + [this, mip, face](webgpu::rg::PassContext& c) { /* draw the prefiltered face at this mip */ }); +``` + +The lighting pass then samples it as a whole-chain cube. Because `prefiltered` is graph-owned, the +`cube()` range is built by the graph, and `mipCount = 0` means every roughness level: + +```cpp +// ...in the IBL pass setup, alongside b.sampled(env) etc.: +b.sampled(prefiltered, webgpu::rg::cube(WGPUTextureAspect_All, /*mipCount*/ 0)); +// ...in the body: +c.bind(4, prefiltered); // the shader picks the mip: textureSampleLevel(cube, s, dir, roughness * maxMip) +``` + +Since the bakes gate on `initialize(prefiltered, envId)`, all `kFaces * kRoughnessMips` passes run +the first frame and whenever `envId` changes, and are skipped and culled otherwise. The pooled cube +survives, so the lighting pass keeps binding it. + +--- + +## Debugging + +### The error model + +Authoring errors, such as reading a transient before any pass writes it or a cyclic dependency, +do not throw. Instead: + +1. `compile()` poisons the graph, entering a Failed state. +2. `compile()` returns a chain of `ErrorMessage { WGPUStringView message; ErrorMessage* next; }`, + null when healthy. It is `[[nodiscard]]`, so ignoring it is a compiler warning. `get_errors()` + returns the same chain later in the same frame, for code that does not compile the graph + itself. The chain lives in the frame arena, so read it before the next `begin_frame()`; + holding it past that asserts. Copy the text out if you need it to outlive the frame. +3. `execute()` becomes a no-op. Nothing is recorded and nothing crashes. Anything rendered + outside the graph still works. + +A poisoned graph therefore shows as a black scene while the UI keeps running. Loop the chain +`compile()` hands back (see [The frame loop](#the-frame-loop)); it is your only signal. Messages also appear as a red banner in the RenderGraph panel. + +### Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| Black scene, UI still fine | Graph poisoned; `execute()` is a no-op | Read `get_errors()` or the red banner | +| `"read before write"` | A pass reads a transient no earlier pass writes | Add or reorder the producer, or make it an import/history/initialized resource | +| `"accessed at mip/layer …"`, `"declares a view of …"`, `"range … covers bytes …"`, `"starts at byte …"`, `"not 256-byte aligned"` | A declared range does not fit the resource: a `baseMip`/`baseLayer` past the end, a `ViewRange` count that overruns, a `copy_buffer` or `BufferRange` past a buffer's end, or a bind offset off the 256-byte alignment | Fix the range on the `b.*()` call. `mipCount`/`layerCount`/`size` of 0 means all remaining and always fits | +| `"cycle"` but no real cycle | Whole-resource tracking: two passes touch disjoint ranges of the same two resources in opposite directions | Split into two handles. Transients are pooled, extra ones are nearly free | +| Pass missing from panel/timings | Culled, nothing reads its output | `force_keep()` or `initialize()`, or wire a real consumer | +| Crash or garbage in a bind group | Broke Rule 2 (pre-built bind group over a transient) | Build it inside the execute body from `ctx.bind` | +| Won't compile at `add_pass` | Broke Rule 1 (non-trivial capture) | Capture raw handles and values, not `std::string`/RAII | +| Bug vanishes with `compile(false)` | A transient lifetime is mis-declared, usually a read that was never declared because the object was bound raw from a cached view | Search pass bodies for `webgpu::bind(` on a view that traces back to a graph transient; declare the read and use `ctx.bind` instead (see [Undeclared reads and aliasing](#undeclared-reads-and-aliasing)) | + +### The RenderGraph panel + +Open it from the floating toolbar (project-diagram icon). Every frame it shows FPS and frame +time, the graph's own CPU cost (`compile`/`realize`/`execute` µs), and the red compile-failed +banner. Four tabs: + +- Graph: the frame's DAG, one box per pass, colored by kind, with dependency columns and sinks + on the right. Reads on the left edge, writes on the right; hollow pins are external inputs; + history edges are dashed. First place to look for "why does B run before A" or "why was my + pass culled". +- Lifetimes: per-resource first-use to last-use spans. Shows how aliasing packed disjoint + lifetimes onto shared memory and how much VRAM it saved. +- Memory: every pool and the graph's total VRAM. +- Timings: per-pass GPU time over 256 frames (needs profiling, below). + +### GPU profiling + +Opt-in. Needs the `TimestampQuery` device feature. + +```cpp +rg->execute(device, queue, encoder, /*enableProfiling*/ true); +// ...submit... +rg->collect_gpu_timings(); // kicks async readback; safe to call always +``` + +Results arrive via the instance event pump a few frames later, not the same frame. The panel +header and Timings tab display them as they come in. diff --git a/unittests/webgpu_engine/CMakeLists.txt b/unittests/webgpu_engine/CMakeLists.txt index 9fe24a890..1381c8fde 100644 --- a/unittests/webgpu_engine/CMakeLists.txt +++ b/unittests/webgpu_engine/CMakeLists.txt @@ -25,6 +25,7 @@ alp_add_unittest(unittests_webgpu_engine test_GpuShaderFunctions.cpp test_ShaderPreprocessor.cpp test_wgpu_string.cpp + test_RenderGraph.cpp ) target_link_libraries(unittests_webgpu_engine PUBLIC webgpu_engine) diff --git a/unittests/webgpu_engine/test_RenderGraph.cpp b/unittests/webgpu_engine/test_RenderGraph.cpp new file mode 100644 index 000000000..415530bc5 --- /dev/null +++ b/unittests/webgpu_engine/test_RenderGraph.cpp @@ -0,0 +1,5021 @@ +/***************************************************************************** + * Copyright (C) 2026 Matthias Huerbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + + +#include "UnittestWebgpuContext.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace webgpu; +using namespace webgpu::rg; +using webgpu::rg::Internal::AccessType; +using webgpu::rg::Internal::PassNode; +using webgpu::rg::Internal::ResourceAccess; +using webgpu::rg::Internal::storage; + + +static_assert(!std::is_default_constructible_v); +static_assert(!std::is_default_constructible_v); +static_assert(!std::is_default_constructible_v); +static_assert(!std::is_copy_constructible_v && !std::is_move_constructible_v); +static_assert(!std::is_copy_constructible_v && !std::is_move_constructible_v); +static_assert(!std::is_copy_constructible_v && !std::is_move_constructible_v); + +// handles stay cheap values, and a typed handle converts to the base but never back +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_convertible_v); +static_assert(!std::is_convertible_v); +static_assert(!std::is_convertible_v); +static_assert(!std::is_convertible_v); +static_assert(!static_cast(ResourceHandle {})); +static_assert(!static_cast(TextureHandle {})); +static_assert(static_cast(ResourceHandle { 1, ResourceKind::Texture, 7 })); +static_assert(ResourceHandle { 1, ResourceKind::Texture, 7 } == ResourceHandle { 1, ResourceKind::Texture, 7 }); +static_assert(!(ResourceHandle { 1, ResourceKind::Texture, 7 } == ResourceHandle { 1, ResourceKind::Texture, 8 })); +static_assert(!(ResourceHandle { 1, ResourceKind::Texture, 7 } == ResourceHandle { 1, ResourceKind::Buffer, 7 })); + +struct TestGraph { + GraphAllocator* allocator = create_allocator(); + RenderGraph* rg; + + TestGraph() + { + begin_frame(allocator); + rg = start_recording(allocator); + } + ~TestGraph() { destroy_allocator(allocator); } + + TestGraph(const TestGraph&) = delete; + TestGraph& operator=(const TestGraph&) = delete; + + TextureHandle transient(std::string_view id, uint32_t mipLevelCount = 1) + { + TextureDesc desc {}; + desc.dimension = WGPUTextureDimension_2D; + desc.format = WGPUTextureFormat_RGBA8Unorm; + desc.absolute = { 16, 16, 1 }; + desc.mipLevelCount = mipLevelCount; + return rg->create_transient_texture(id, desc); + } + + BufferHandle transient_buffer(std::string_view id, uint64_t size = 64) + { + BufferDesc desc {}; + desc.size = size; + return rg->create_transient_buffer(id, desc); + } +}; + +// declare a compute producer for buf so a later copy_buffer src is not a read-before-write error +static void add_buffer_producer(RenderGraph* rg, BufferHandle buf) +{ + rg->add_pass( + "produce", PassKind::Compute, [&](PassBuilder& b) { b.storage_write(buf); }, [](PassContext&) {}); +} + +// 16x16 RGBA8Unorm 2D, matching TestGraph::transient +static TextureDesc tex2d() +{ + TextureDesc d {}; + d.dimension = WGPUTextureDimension_2D; + d.format = WGPUTextureFormat_RGBA8Unorm; + d.absolute = { 16, 16, 1 }; + return d; +} + +// fake non-null view. compile() only reads the `imported` flag, and these tests stop at compile(). +// import_buffer would not do: it calls wgpuBufferGetSize. +static TextureHandle import_tex(RenderGraph* rg, std::string_view id) +{ + return rg->import_texture(id, { .view = (WGPUTextureView)0x1, .size = { 16, 16, 1 }, .format = WGPUTextureFormat_RGBA8Unorm }); +} + +// surviving passes in execution order after compile() +static std::vector pass_order(RenderGraph* rg) +{ + std::vector v; + for (PassNode* p = storage(rg)->m_passes; p; p = p->next) + v.emplace_back(p->id.name.data, p->id.name.length); + return v; +} +static int idx_of(const std::vector& v, const char* n) +{ + for (int i = 0; i < static_cast(v.size()); ++i) + if (v[i] == n) + return i; + return -1; +} + +// --------------------------------------------------------------------------------------------------- +// ViewRange presets and cube-view validation. + +// pins the specific diagnostic, not just "some error fired" +static bool error_mentions(RenderGraph* rg, std::string_view needle) +{ + for (ErrorMessage* e = rg->get_errors(); e; e = e->next) + if (std::string_view(e->message.data, e->message.length).find(needle) != std::string_view::npos) + return true; + return false; +} + + +TEST_CASE("rg::cube preset builds a 6-layer cube view", "[RenderGraph]") +{ + constexpr ViewRange c = cube(); + STATIC_REQUIRE(c.dim == WGPUTextureViewDimension_Cube); + STATIC_REQUIRE(c.layerCount == 6); // the whole point: the bare ViewRange default (1) is an invalid cube + STATIC_REQUIRE(c.mipCount == 1); // default stays single-mip + STATIC_REQUIRE(c.aspect == WGPUTextureAspect_All); + + // aspect passes through, for a depth shadow cube + STATIC_REQUIRE(cube(WGPUTextureAspect_DepthOnly).aspect == WGPUTextureAspect_DepthOnly); + // mipCount 0 == all remaining, for a prefiltered IBL env cube spanning its whole mip chain + STATIC_REQUIRE(cube(WGPUTextureAspect_All, 0).mipCount == 0); +} + +TEST_CASE("rg::cube_array preset builds a 6*N-layer cube-array view", "[RenderGraph]") +{ + STATIC_REQUIRE(cube_array(1).layerCount == 6); + STATIC_REQUIRE(cube_array(3).layerCount == 18); + STATIC_REQUIRE(cube_array(3).dim == WGPUTextureViewDimension_CubeArray); + STATIC_REQUIRE(cube_array(3).mipCount == 1); + STATIC_REQUIRE(cube_array(2, WGPUTextureAspect_All, 0).mipCount == 0); +} + +TEST_CASE("rg::whole preset is all mips and all layers with an inferred dimension", "[RenderGraph]") +{ + constexpr ViewRange w = whole(); + STATIC_REQUIRE(w.dim == WGPUTextureViewDimension_Undefined); // inferred from the texture + STATIC_REQUIRE(w.mipCount == 0); // 0 == all remaining + STATIC_REQUIRE(w.layerCount == 0); +} + +// a `layers`-layer 2D array, written by a producer so the sampled consumer is not a read-before-write, +// then sampled through `range`. +static TextureHandle cube_source(TestGraph& g, uint32_t layers, WGPUTextureDimension dim = WGPUTextureDimension_2D) +{ + TextureDesc desc {}; + desc.dimension = dim; + desc.format = WGPUTextureFormat_RGBA8Unorm; + desc.absolute = { 16, 16, layers }; + auto h = g.rg->create_transient_texture("cubesrc", desc); + g.rg->add_pass( + "produce", PassKind::Graphics, + [h, layers, dim](PassBuilder& b) { + // a 3D texture has no array layers to attach per-layer, so one attachment defines it + uint32_t n = (dim == WGPUTextureDimension_2D) ? layers : 1; + for (uint32_t l = 0; l < n; ++l) + b.color(h, l, { .clear = { 0, 0, 0, 1 }, .sub = { .layer = l } }); + }, + [](PassContext&) {}); + return h; +} + +TEST_CASE("compile - a valid 6-layer cube view passes validation", "[RenderGraph]") +{ + TestGraph g; + auto h = cube_source(g, 6); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, cube()); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +TEST_CASE("compile - a cube view without exactly 6 layers is rejected", "[RenderGraph]") +{ + TestGraph g; + auto h = cube_source(g, 4); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, ViewRange { .mipCount = 1, .layerCount = 4, .dim = WGPUTextureViewDimension_Cube }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "needs exactly 6")); // named error, not an opaque device error +} + +TEST_CASE("compile - a cube-array view whose layer count is not a multiple of 6 is rejected", "[RenderGraph]") +{ + TestGraph g; + auto h = cube_source(g, 8); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, ViewRange { .mipCount = 1, .layerCount = 8, .dim = WGPUTextureViewDimension_CubeArray }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "positive multiple of 6")); +} + +// the overrun check sits after the exactly-6 / multiple-of-6 arms, so reaching it needs a view those +// accept: a well-formed 6-layer cube starting too late in an 8-layer texture. 8 is the widest source +// here, since cube_source declares one color() per layer and kMaxColorAttachments caps that. +TEST_CASE("compile - a well-formed cube view starting past the layer count is rejected", "[RenderGraph]") +{ + TestGraph g; + auto h = cube_source(g, 8); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, cube().at(0, 4)); // 4 + 6 > 8 + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "exceeds the texture's 8 layer(s)")); + REQUIRE_FALSE(error_mentions(g.rg, "needs exactly 6")); // the layer count itself is fine +} + +// cube validation reads the layer count off the import's declared size, so a caller-owned cube view +// registered as the 1-layer default is rejected however cube-shaped the real view is. documented in +// docs/rendergraph.md, the IBL example: sample a view-only import with a plain sampled(), no range. +TEST_CASE("compile - a cube view on a view-only import is rejected on the declared layer count", "[RenderGraph]") +{ + TestGraph g; + auto env = import_tex(g.rg, "ibl.env"); // size { 16, 16, 1 }, so one layer + g.rg->add_pass( + "read", PassKind::Compute, + [env](PassBuilder& b) { + b.sampled(env, cube()); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + // the overrun arm, not the covers-N one: cube() sets exactly 6 layers, so the count is well formed + // and it is the 1-layer import it does not fit + REQUIRE(error_mentions(g.rg, "exceeds the texture's 1 layer(s)")); + REQUIRE_FALSE(error_mentions(g.rg, "needs exactly 6")); +} + +// the same import declaring its real layer count compiles: the check is on the declared size, not on +// imports as such. +TEST_CASE("compile - a cube view on an import declaring 6 layers passes", "[RenderGraph]") +{ + TestGraph g; + auto env = g.rg->import_texture( + "ibl.env", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm }); + g.rg->add_pass( + "read", PassKind::Compute, + [env](PassBuilder& b) { + b.sampled(env, cube()); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +// the prefiltered specular cube from docs/rendergraph.md: a graph-owned persistent cube baked one +// (mip, layer) at a time via color(), every bake pass gating on initialize() of the same target, then +// sampled as a whole-chain cube. proves the doc's three load-bearing claims compile together: many +// initialize() passes share one target, a per-layer color() bake coexists with a cube() read of the +// same resource, and cube(All, 0) accepts the full mip chain. +TEST_CASE("compile - a prefiltered specular cube bakes per (mip, layer) and samples as a full-chain cube", "[RenderGraph]") +{ + constexpr uint32_t kFaces = 6; + constexpr uint32_t kRoughnessMips = 3; + + TestGraph g; + TextureDesc desc {}; + desc.dimension = WGPUTextureDimension_2D; + desc.format = WGPUTextureFormat_RGBA16Float; + desc.absolute = { 128, 128, kFaces }; + desc.mipLevelCount = kRoughnessMips; + auto prefiltered = g.rg->create_persistent_texture("ibl.prefiltered", desc); + + for (uint32_t mip = 0; mip < kRoughnessMips; ++mip) + for (uint32_t face = 0; face < kFaces; ++face) + g.rg->add_pass( + "prefilter.bake", PassKind::Graphics, + [prefiltered, mip, face](PassBuilder& b) { + b.color(prefiltered, 0, { .sub = { .mip = mip, .layer = face } }); + b.initialize(prefiltered, /*hash*/ 42); // one env identity, whole set bakes together + }, + [](PassContext&) {}); + + auto lit = g.transient("lit"); + g.rg->add_pass( + "IBL", PassKind::Graphics, + [prefiltered, lit](PassBuilder& b) { + b.sampled(prefiltered, cube(WGPUTextureAspect_All, /*mipCount*/ 0)); // every roughness level + b.color(lit, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +TEST_CASE("compile - a cube view on a storage access is rejected", "[RenderGraph]") +{ + TestGraph g; + auto h = cube_source(g, 6); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.storage_read(h, cube()); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "cannot be cube")); +} + +TEST_CASE("compile - a cube view on a non-2D texture is rejected", "[RenderGraph]") +{ + TestGraph g; + auto h = cube_source(g, 6, WGPUTextureDimension_3D); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, cube()); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "not a 2D texture")); +} + +// the IBL environment path. import_texture defaults dimension to 2D precisely so node_layers sees the +// imported extent's 6 layers, not the Undefined default's 1. +TEST_CASE("compile - an imported 6-layer cube view passes validation", "[RenderGraph]") +{ + TestGraph g; + auto h = g.rg->import_texture("envcube", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm }); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, cube()); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +// an imported texture's layer count is its extent's, not 1, which only holds if the node knows it is 2D +TEST_CASE("compile - an imported texture reports its real layer count to cube validation", "[RenderGraph]") +{ + TestGraph g; + auto h = g.rg->import_texture("arr", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 4 }, .format = WGPUTextureFormat_RGBA8Unorm }); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, ViewRange { .mipCount = 1, .layerCount = 0, .dim = WGPUTextureViewDimension_Cube }); // 0 == all remaining + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "covers 4 layer(s)")); // not "1 layer(s)" +} + +// a cube view of an imported 3D texture is rejected as non-2D +TEST_CASE("compile - an imported 3D texture is rejected for a cube view", "[RenderGraph]") +{ + TestGraph g; + auto h = g.rg->import_texture("vol", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm, .dimension = WGPUTextureDimension_3D }); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, cube()); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "not a 2D texture")); +} + +// --------------------------------------------------------------------------------------------------- +// access range validation. ranges are fully declared in setup, so compile() rejects one that does not +// fit its resource. + +TEST_CASE("compile - an access past the texture's mip count is rejected", "[RenderGraph]") +{ + TestGraph g; + auto h = g.transient("tex"); // 1 mip + g.rg->add_pass( + "write", PassKind::Graphics, + [h](PassBuilder& b) { + b.color(h, 0, { .sub = { .mip = 3 } }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "accessed at mip 3 but has 1 mip(s)")); +} + +TEST_CASE("compile - an access past the texture's layer count is rejected", "[RenderGraph]") +{ + TestGraph g; + auto h = cube_source(g, 6); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, { .baseLayer = 9 }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "accessed at layer 9 but has 6 layer(s)")); +} + +TEST_CASE("compile - a view whose mip range ends past the chain is rejected", "[RenderGraph]") +{ + TestGraph g; + auto h = g.transient("chain", 2); + g.rg->add_pass( + "write", PassKind::Compute, + [h](PassBuilder& b) { + b.storage_write(h, ViewRange { .mipCount = 3, .layerCount = 1 }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "view of 3 mip(s) from mip 0 but has 2 mip(s)")); +} + +// the layer half. a non-cube viewDim keeps this out of cube validation, so the range check must catch it. +TEST_CASE("compile - a view whose layer range ends past the array is rejected", "[RenderGraph]") +{ + TestGraph g; + auto h = cube_source(g, 2); + g.rg->add_pass( + "read", PassKind::Compute, + [h](PassBuilder& b) { + b.sampled(h, ViewRange { .mipCount = 1, .layerCount = 3 }); // 0 + 3 > 2 + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "view of 3 layer(s) from layer 0 but has 2 layer(s)")); +} + +// 0 means all remaining, so rg::whole() stays clean on any mip chain +TEST_CASE("compile - a whole() view over a mip chain passes range validation", "[RenderGraph]") +{ + TestGraph g; + auto h = g.transient("chain", 4); + g.rg->add_pass( + "write", PassKind::Compute, + [h](PassBuilder& b) { + b.storage_write(h, whole()); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +TEST_CASE("compile - a copy_texture past the dst's layer count is rejected", "[RenderGraph]") +{ + TestGraph g; + auto src = g.transient("src"); + auto dst = g.transient("dst"); // 1 layer + g.rg->add_pass( + "produce", PassKind::Graphics, [src](PassBuilder& b) { b.color(src, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "copy", PassKind::Transfer, + [src, dst](PassBuilder& b) { + b.copy_texture(src, dst, {}, { .layer = 2 }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "accessed at layer 2 but has 1 layer(s)")); +} + +// copy_buffer resolves the size at declare and records it on both sides, so the dst range is checked +// against the dst buffer +TEST_CASE("compile - a copy_buffer range that overruns the dst is rejected", "[RenderGraph]") +{ + TestGraph g; + auto bufA = g.transient_buffer("bufA", 64); + auto bufB = g.transient_buffer("bufB", 64); + + add_buffer_producer(g.rg, bufA); + g.rg->add_pass( + "copy", PassKind::Transfer, + [bufA, bufB](PassBuilder& b) { + b.copy_buffer(bufA, bufB, 0, /*dstOffset*/ 32, /*size*/ 64); // 32 + 64 > 64 + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "covers bytes [32, 96) but the buffer is 64 byte(s)")); +} + +// size 0 expands against the src at declare time, so an offset src copy lands a smaller resolved size +// on both accesses and stays inside a same-sized dst +TEST_CASE("compile - a whole copy_buffer from an offset src fits a same-sized dst", "[RenderGraph]") +{ + TestGraph g; + auto bufA = g.transient_buffer("bufA", 64); + auto bufB = g.transient_buffer("bufB", 64); + + add_buffer_producer(g.rg, bufA); + g.rg->add_pass( + "copy", PassKind::Transfer, + [bufA, bufB](PassBuilder& b) { + b.copy_buffer(bufA, bufB, /*srcOffset*/ 32, 0, /*size*/ 0); // resolves to 32 bytes + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +// a declared bind range shares the copy range's bounds check, so an overrun is caught at compile +TEST_CASE("compile - a bind range that overruns the buffer is rejected", "[RenderGraph]") +{ + TestGraph g; + auto buf = g.transient_buffer("buf", 64); + + add_buffer_producer(g.rg, buf); + g.rg->add_pass( + "read", PassKind::Compute, + [buf](PassBuilder& b) { + b.storage_read(buf, { .offset = 32, .size = 64 }); // 32 + 64 > 64 + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "covers bytes [32, 96) but the buffer is 64 byte(s)")); +} + +// bind offsets must hit the spec-default 256-byte uniform/storage offset alignment, or Dawn rejects +// the bind group at execute with a far less local message +TEST_CASE("compile - a bind range with a misaligned offset is rejected", "[RenderGraph]") +{ + TestGraph g; + auto buf = g.transient_buffer("buf", 64); + + add_buffer_producer(g.rg, buf); + g.rg->add_pass( + "read", PassKind::Compute, + [buf](PassBuilder& b) { + b.uniform(buf, { .offset = 16, .size = 32 }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "not 256-byte aligned")); +} + +// an offset at the end resolves to a zero-byte range, which the > bounds check alone would miss +TEST_CASE("compile - a bind range starting at the buffer's end is rejected", "[RenderGraph]") +{ + TestGraph g; + auto buf = g.transient_buffer("buf", 512); + + add_buffer_producer(g.rg, buf); + g.rg->add_pass( + "read", PassKind::Compute, + [buf](PassBuilder& b) { + b.storage_read(buf, { .offset = 512 }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "starts at byte 512 but the buffer is 512 byte(s)")); +} + +// --------------------------------------------------------------------------------------------------- +// compile() reports through the error chain rather than asserting, so these are observable in any build. + +TEST_CASE("compile - a cyclic relativeTo chain is rejected", "[RenderGraph]") +{ + TestGraph g; + // the public API cannot author a cycle, relativeTo only ever points backwards. build the loop on the + // nodes to reach resolve_size's recursion guard. + auto a = g.transient("a"); + TextureDesc rel {}; + rel.dimension = WGPUTextureDimension_2D; + rel.format = WGPUTextureFormat_RGBA8Unorm; + rel.sizeKind = SizeKind::Relative; + rel.relativeTo = a; + auto b = g.rg->create_transient_texture("b", rel); + + Internal::ResourceNode* an = Internal::find_node(g.rg, a); + an->sizeKind = SizeKind::Relative; // close the loop: a sizes against b, b sizes against a + an->relativeToHandle = b; + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "cyclic relativeTo chain")); +} + +// --------------------------------------------------------------------------------------------------- +// foreign/stale handle backstops, not authoring diagnostics: an out-of-range id would index compile()'s +// phase-1/2 scratch tables out of bounds. the builder's Q_ASSERTs stop such a handle being declared at +// all, hence the white-box stamping. a null byId witnesses that compile() bailed before building the +// tables the bad id would have indexed. + +TEST_CASE("compile - an out-of-range access handle is rejected before the scratch tables are built", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + g.rg->add_pass( + "write", PassKind::Graphics, + [tex](PassBuilder& b) { + b.color(tex, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + // an id past next_resource_id is the case that would read byId[] out of bounds + storage(g.rg)->m_passes->accesses[0].handle.id = 9999; + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "uses an invalid resource handle (id 9999)")); + REQUIRE(storage(g.rg)->byId == nullptr); // bailed before byId[9999] could be written +} + +TEST_CASE("compile - an access handle from another graph is rejected", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + g.rg->add_pass( + "write", PassKind::Graphics, + [tex](PassBuilder& b) { + b.color(tex, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + // in range, but stamped with another graph's generation: the id would resolve to the wrong node + storage(g.rg)->m_passes->accesses[0].handle.generation = storage(g.rg)->generation + 1; + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "uses an invalid resource handle")); + REQUIRE(storage(g.rg)->byId == nullptr); +} + +TEST_CASE("compile - a foreign initialize() target handle is rejected", "[RenderGraph]") +{ + TestGraph g; + auto pers = g.rg->create_persistent_texture("pers", tex2d()); + g.rg->add_pass( + "bake", PassKind::Graphics, + [pers](PassBuilder& b) { + b.color(pers, 0); + b.initialize(pers); + b.force_keep(); + }, + [](PassContext&) {}); + + // only the init target goes stale, so the access loop passes and the init loop must catch this + storage(g.rg)->m_passes->initTargets[0].target.generation = storage(g.rg)->generation + 1; + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "initialize() target handle")); + REQUIRE(storage(g.rg)->byId == nullptr); +} + +TEST_CASE("compile - a foreign relativeTo handle is rejected", "[RenderGraph]") +{ + TestGraph g; + auto base = g.transient("base"); + TextureDesc rel {}; + rel.dimension = WGPUTextureDimension_2D; + rel.format = WGPUTextureFormat_RGBA8Unorm; + rel.sizeKind = SizeKind::Relative; + rel.relativeTo = base; + auto scaled = g.rg->create_transient_texture("scaled", rel); + + // resolve_size() indexes relativeTo unguarded, so the prologue is the only thing stopping an + // out-of-bounds read + Internal::find_node(g.rg, scaled)->relativeToHandle.generation = storage(g.rg)->generation + 1; + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "relativeTo")); + REQUIRE(error_mentions(g.rg, "from another graph or a previous frame")); + REQUIRE(storage(g.rg)->byId == nullptr); +} + +TEST_CASE("compile - a second compile() on the same graph is rejected", "[RenderGraph]") +{ + TestGraph g; + REQUIRE(g.rg->compile() == nullptr); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "already compiled")); +} + +// --------------------------------------------------------------------------------------------------- +// explicit color-attachment slots + +// the ColorAttachment access for `h` in `p`, or null when the pass declares none +static const ResourceAccess* color_access(PassNode* p, ResourceHandle h) +{ + for (uint32_t i = 0; i < p->accessCount; ++i) + if (p->accesses[i].type == AccessType::ColorAttachment && p->accesses[i].handle.id == h.id) + return &p->accesses[i]; + return nullptr; +} + +static uint32_t count_access(PassNode* p, AccessType t) +{ + uint32_t n = 0; + for (uint32_t i = 0; i < p->accessCount; ++i) + if (p->accesses[i].type == t) + ++n; + return n; +} + +TEST_CASE("PassBuilder::color - the declared slot is carried on the access", "[RenderGraph]") +{ + TestGraph g; + auto a = g.transient("a"); + auto b = g.transient("b"); + + g.rg->add_pass( + "mrt", PassKind::Graphics, + [a, b](PassBuilder& pb) { + pb.color(a, 3); + pb.color(b, 1); + pb.force_keep(); + }, + [](PassContext&) {}); + + PassNode* p = storage(g.rg)->m_passes; + REQUIRE(color_access(p, a)->colorIndex == 3); + REQUIRE(color_access(p, b)->colorIndex == 1); // declared second, but slot 1 +} + +// release-only: these feed color()/resolve() an author error the builder guards with Q_ASSERT. in debug +// the abort IS the contract, so only the release backstop is observable here. +#ifdef QT_NO_DEBUG +TEST_CASE("PassBuilder::color - a slot declared twice in one pass is rejected", "[RenderGraph]") +{ + TestGraph g; + auto a = g.transient("a"); + auto b = g.transient("b"); + + g.rg->add_pass( + "dup", PassKind::Graphics, + [a, b](PassBuilder& pb) { + pb.color(a, 0); + pb.color(b, 0); // same slot -> rejected, access not recorded + pb.force_keep(); + }, + [](PassContext&) {}); + + PassNode* p = storage(g.rg)->m_passes; + REQUIRE(count_access(p, AccessType::ColorAttachment) == 1); + REQUIRE(color_access(p, a) != nullptr); + REQUIRE(color_access(p, b) == nullptr); // the duplicate never bound +} +#endif + +#ifdef QT_NO_DEBUG +TEST_CASE("PassBuilder::color - a slot at or past kMaxColorAttachments is rejected", "[RenderGraph]") +{ + TestGraph g; + auto a = g.transient("a"); + + g.rg->add_pass( + "oob", PassKind::Graphics, + [a](PassBuilder& pb) { + pb.color(a, webgpu::rg::Internal::kMaxColorAttachments); + pb.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(count_access(storage(g.rg)->m_passes, AccessType::ColorAttachment) == 0); +} +#endif + +TEST_CASE("PassBuilder::resolve - the target carries its src color slot", "[RenderGraph]") +{ + TestGraph g; + TextureDesc msaaDesc = tex2d(); + msaaDesc.sampleCount = 4; + auto msaa = g.rg->create_transient_texture("msaa", msaaDesc); + auto target = g.transient("target"); + + g.rg->add_pass( + "fwd", PassKind::Graphics, + [msaa, target](PassBuilder& b) { + b.color(msaa, 2); + b.resolve(msaa, target); + b.force_keep(); + }, + [](PassContext&) {}); + + PassNode* p = storage(g.rg)->m_passes; + bool found = false; + for (uint32_t i = 0; i < p->accessCount; ++i) + if (p->accesses[i].type == AccessType::ResolveAttachment && p->accesses[i].handle.id == target.id) { + REQUIRE(p->accesses[i].colorIndex == 2); // the slot of the color() it resolves, not 0 + found = true; + } + REQUIRE(found); +} + +// pairing does not depend on declaration adjacency: an unrelated color() may sit between the two +TEST_CASE("PassBuilder::resolve - src pairing does not depend on declaration adjacency", "[RenderGraph]") +{ + TestGraph g; + TextureDesc msaaDesc = tex2d(); + msaaDesc.sampleCount = 4; + auto msaa = g.rg->create_transient_texture("msaa", msaaDesc); + auto other = g.transient("other"); + auto target = g.transient("target"); + + g.rg->add_pass( + "fwd", PassKind::Graphics, + [msaa, other, target](PassBuilder& b) { + b.color(msaa, 0); + b.color(other, 1); // declared between the color() and its resolve() + b.resolve(msaa, target); + b.force_keep(); + }, + [](PassContext&) {}); + + PassNode* p = storage(g.rg)->m_passes; + bool found = false; + for (uint32_t i = 0; i < p->accessCount; ++i) + if (p->accesses[i].type == AccessType::ResolveAttachment && p->accesses[i].handle.id == target.id) { + REQUIRE(p->accesses[i].colorIndex == 0); // msaa's slot, not the adjacent other's + found = true; + } + REQUIRE(found); +} + +#ifdef QT_NO_DEBUG +TEST_CASE("PassBuilder::resolve - a src that is not a color() in this pass is rejected", "[RenderGraph]") +{ + TestGraph g; + auto notAnAttachment = g.transient("nope"); + auto target = g.transient("target"); + + g.rg->add_pass( + "fwd", PassKind::Graphics, + [notAnAttachment, target](PassBuilder& b) { + b.resolve(notAnAttachment, target); // src was never declared via color() + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(count_access(storage(g.rg)->m_passes, AccessType::ResolveAttachment) == 0); +} +#endif + +#ifdef QT_NO_DEBUG +TEST_CASE("PassBuilder::resolve - two resolve targets for one color slot are rejected", "[RenderGraph]") +{ + TestGraph g; + TextureDesc msaaDesc = tex2d(); + msaaDesc.sampleCount = 4; + auto msaa = g.rg->create_transient_texture("msaa", msaaDesc); + auto t1 = g.transient("t1"); + auto t2 = g.transient("t2"); + + g.rg->add_pass( + "fwd", PassKind::Graphics, + [msaa, t1, t2](PassBuilder& b) { + b.color(msaa, 0); + b.resolve(msaa, t1); + b.resolve(msaa, t2); // same slot resolved twice -> rejected + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(count_access(storage(g.rg)->m_passes, AccessType::ResolveAttachment) == 1); +} +#endif + +// the remaining builder guards, same release-only reasoning. unlike color()/resolve() these do NOT drop +// the declaration, so the release behavior worth pinning is that the access lands anyway. +#ifdef QT_NO_DEBUG +TEST_CASE("PassBuilder - reading and writing one resource in a pass is rejected", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + + g.rg->add_pass( + "rw", PassKind::Graphics, + [tex](PassBuilder& b) { + b.sampled(tex); // same subresource, read and written in one pass: the graph cannot order these + b.color(tex, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + // both accesses are recorded: the guard traps, it does not filter + PassNode* p = storage(g.rg)->m_passes; + REQUIRE(count_access(p, AccessType::Sampled) == 1); + REQUIRE(count_access(p, AccessType::ColorAttachment) == 1); +} +#endif + +// the Transfer exemption is not release-only: each copy is its own usage scope, so this stays silent in +// debug too. pins the access count that exemption produces. +TEST_CASE("PassBuilder - a Transfer pass may read and write one resource", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex", /*mipLevelCount*/ 2); + + g.rg->add_pass( + "produce", PassKind::Graphics, [tex](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "copy", PassKind::Transfer, + [tex](PassBuilder& b) { + b.copy_texture(tex, tex, {}, { .mip = 1 }); // src and dst are the same handle, no conflict trap + b.force_keep(); + }, + [](PassContext&) {}); + + PassNode* copyPass = storage(g.rg)->m_passes->next; + REQUIRE(count_access(copyPass, AccessType::CopySrc) == 1); + REQUIRE(count_access(copyPass, AccessType::CopyDst) == 1); +} + +#ifdef QT_NO_DEBUG +TEST_CASE("PassBuilder::depth_stencil - a second depth-stencil attachment is rejected", "[RenderGraph]") +{ + TestGraph g; + TextureDesc dsDesc = tex2d(); + dsDesc.format = WGPUTextureFormat_Depth32Float; + auto d1 = g.rg->create_transient_texture("d1", dsDesc); + auto d2 = g.rg->create_transient_texture("d2", dsDesc); + + g.rg->add_pass( + "shadow", PassKind::Graphics, + [d1, d2](PassBuilder& b) { + b.depth_stencil(d1); + b.depth_stencil(d2); // a render pass has one depth-stencil slot + b.force_keep(); + }, + [](PassContext&) {}); + + // no early return, so both land and execute() would silently let the last one win + REQUIRE(count_access(storage(g.rg)->m_passes, AccessType::DepthStencilAttachment) == 2); +} +#endif + +#ifdef QT_NO_DEBUG +TEST_CASE("PassBuilder::initialize - the same target twice in one pass is rejected", "[RenderGraph]") +{ + TestGraph g; + BufferDesc bd {}; + bd.size = 64; + auto p = g.rg->create_persistent_buffer("pers", bd); + + g.rg->add_pass( + "bake", PassKind::Compute, + [p](PassBuilder& b) { + b.storage_write(p); + b.initialize(p); + b.initialize(p); // duplicate: asserted, but not dropped + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(storage(g.rg)->m_passes->initCount == 2); +} +#endif + +#ifdef QT_NO_DEBUG +TEST_CASE("PassBuilder::initialize - a target past kMaxInitTargets is dropped", "[RenderGraph]") +{ + TestGraph g; + BufferDesc bd {}; + bd.size = 64; + // buffers, not textures: kMaxColorAttachments would cap a multi-target Graphics pass at 8 first + constexpr uint32_t kOver = Internal::PassNode::kMaxInitTargets + 1; + std::vector targets; + std::vector names(kOver); + for (uint32_t i = 0; i < kOver; ++i) { + names[i] = "pers" + std::to_string(i); + targets.push_back(g.rg->create_persistent_buffer(names[i], bd)); + } + + g.rg->add_pass( + "bake", PassKind::Compute, + [&targets](PassBuilder& b) { + for (BufferHandle t : targets) { + b.storage_write(t); + b.initialize(t); + } + b.force_keep(); + }, + [](PassContext&) {}); + + // the 9th target is dropped rather than overrunning the fixed array + REQUIRE(storage(g.rg)->m_passes->initCount == Internal::PassNode::kMaxInitTargets); +} +#endif + +TEST_CASE("RenderGraph - valid graph compiles clean", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + + g.rg->add_pass( + "write", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(tex, 0); + b.force_keep(); // nothing reads tex; keep the pass past culling + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + // keeps the foreign-handle tests honest: they assert byId stays null when compile() bails, so a clean + // compile must be what populates it + REQUIRE(storage(g.rg)->byId != nullptr); +} + +TEST_CASE("RenderGraph - read before write is reported as an error", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + + // reads a transient no pass ever writes -> compile() must poison the graph + g.rg->add_pass( + "read", PassKind::Compute, + [&](PassBuilder& b) { + b.sampled(tex); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + // nobody declared a write anywhere, which is the first of the three read-before-write diagnoses + REQUIRE(error_mentions(g.rg, "that no pass ever writes")); +} + +// the third read-before-write diagnosis: a writer exists, but the reader is declared first, so no RAW +// edge forms and the writer is culled instead of being pulled ahead of it. +TEST_CASE("RenderGraph - reading before a later-declared writer names the ordering diagnosis", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + + g.rg->add_pass( // declared first, so at sweep time tex has no producer to depend on + "read", PassKind::Compute, + [&](PassBuilder& b) { + b.sampled(tex); + b.force_keep(); + }, + [](PassContext&) {}); + g.rg->add_pass( + "write", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "before any pass writes it")); + REQUIRE_FALSE(error_mentions(g.rg, "that no pass ever writes")); // a writer does exist +} + + +TEST_CASE("RenderGraph - single clear+discard attachment is inferred transient", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + + g.rg->add_pass( + "write", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(tex, 0, { .store = WGPUStoreOp_Discard }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(storage(g.rg)->transientCount == 1); +} + + +TEST_CASE("RenderGraph - sampled attachment is not transient", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + auto out = g.transient("out"); + + g.rg->add_pass( + "write", PassKind::Graphics, + [&](PassBuilder& b) { b.color(tex, 0); }, + [](PassContext&) {}); + g.rg->add_pass( + "read", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(tex); + b.color(out, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(storage(g.rg)->transientCount == 0); +} + +TEST_CASE("RenderGraph - consumer runs after producer across a deduped edge", "[RenderGraph]") +{ + TestGraph g; + auto a = g.transient("a"); + auto b = g.transient("b"); + auto out = g.transient("out"); + + g.rg->add_pass( + "producer", PassKind::Graphics, + [&](PassBuilder& pb) { + pb.color(a, 0); + pb.color(b, 1); + }, + [](PassContext&) {}); + g.rg->add_pass( + "consumer", PassKind::Graphics, + [&](PassBuilder& pb) { + pb.sampled(a); + pb.sampled(b); + pb.color(out, 0); + pb.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + + // m_passes is the post-cull execution order, so the producer must precede the consumer + int producerPos = -1, consumerPos = -1, pos = 0; + for (PassNode* p = storage(g.rg)->m_passes; p; p = p->next, ++pos) { + if (p->id == make_resource_id("producer")) + producerPos = pos; + else if (p->id == make_resource_id("consumer")) + consumerPos = pos; + } + REQUIRE(producerPos >= 0); + REQUIRE(consumerPos >= 0); + REQUIRE(producerPos < consumerPos); +} + +// record + compile an N-pass linear chain. pass i samples tex[i-1] and writes tex[i], so every edge is a +// real RAW hazard: stresses hazard detection, topo sort and lifetime packing. execute() needs a device +// and is out of scope. +TEST_CASE("RenderGraph - compile perf (linear chain)", "[RenderGraph][!benchmark]") +{ + constexpr int N = 256; + + std::vector passNames(N), texNames(N); + for (int i = 0; i < N; ++i) { + passNames[i] = "pass" + std::to_string(i); + texNames[i] = "tex" + std::to_string(i); + } + + GraphAllocator* allocator = create_allocator(); + + auto build_chain = [&](RenderGraph* rg) { + TextureDesc desc {}; + desc.dimension = WGPUTextureDimension_2D; + desc.format = WGPUTextureFormat_RGBA8Unorm; + desc.absolute = { 16, 16, 1 }; + + TextureHandle prev {}; + for (int i = 0; i < N; ++i) { + auto tex = rg->create_transient_texture(texNames[i], desc); + const bool last = (i == N - 1); + rg->add_pass( + passNames[i], PassKind::Graphics, + [&, i, tex, prev, last](PassBuilder& b) { + if (i > 0) + b.sampled(prev); + b.color(tex, 0); + if (last) + b.force_keep(); + }, + [](PassContext&) {}); + prev = tex; + } + }; + + + { + begin_frame(allocator); + RenderGraph* rg = start_recording(allocator); + build_chain(rg); + REQUIRE(rg->compile() == nullptr); + } + + BENCHMARK("record + compile " + std::to_string(N) + "-pass chain") + { + begin_frame(allocator); // per-frame arena reset + RenderGraph* rg = start_recording(allocator); + build_chain(rg); + return rg->compile(); + }; + + destroy_allocator(allocator); +} + +TEST_CASE("RenderGraph - copy_buffer compiles clean and records the byte range", "[RenderGraph]") +{ + using webgpu::rg::Internal::AccessType; + using webgpu::rg::Internal::ResourceAccess; + + TestGraph g; + auto bufA = g.transient_buffer("bufA"); + auto bufB = g.transient_buffer("bufB"); + + add_buffer_producer(g.rg, bufA); + g.rg->add_pass( + "copy", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_buffer(bufA, bufB, 0, 4, 4); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + + // second declared pass is the transfer pass, check both recorded accesses + PassNode* copyPass = storage(g.rg)->m_passes->next; + REQUIRE(copyPass != nullptr); + const ResourceAccess* src = nullptr; + const ResourceAccess* dst = nullptr; + for (uint32_t i = 0; i < copyPass->accessCount; ++i) { + const ResourceAccess& a = copyPass->accesses[i]; + if (a.type == AccessType::CopySrc) + src = &a; + if (a.type == AccessType::CopyDst) + dst = &a; + } + REQUIRE(src != nullptr); + REQUIRE(dst != nullptr); + REQUIRE(src->handle.id == bufA.id); + REQUIRE(src->bufOffset == 0); + REQUIRE(src->bufSize == 4); + REQUIRE(dst->handle.id == bufB.id); + REQUIRE(dst->bufOffset == 4); + REQUIRE(dst->bufSize == 4); +} + +TEST_CASE("RenderGraph - copy_buffer from an unwritten transient is an error", "[RenderGraph]") +{ + TestGraph g; + auto bufA = g.transient_buffer("bufA"); + auto bufB = g.transient_buffer("bufB"); + + // no producer for bufA -> read before write must poison the graph + g.rg->add_pass( + "copy", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_buffer(bufA, bufB); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "that no pass ever writes")); +} + +TEST_CASE("RenderGraph - partial copy_buffer dst is not a first-define", "[RenderGraph]") +{ + TestGraph g; + auto bufA = g.transient_buffer("bufA"); + auto bufB = g.transient_buffer("bufB"); + + add_buffer_producer(g.rg, bufA); + g.rg->add_pass( + "copy", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_buffer(bufA, bufB, 0, /*dstOffset*/ 4, /*size*/ 4); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + // bytes outside [4,8) are untouched, so bufB must not be eligible as a full first-define for aliasing + REQUIRE(webgpu::rg::Internal::find_node(g.rg, bufB)->firstDefines == false); +} + +TEST_CASE("RenderGraph - copy_texture compiles clean and records the subresources", "[RenderGraph]") +{ + using webgpu::rg::Internal::AccessType; + using webgpu::rg::Internal::ResourceAccess; + + TestGraph g; + auto texA = g.transient("texA"); + auto texB = g.transient("texB", /*mipLevelCount*/ 2); + + g.rg->add_pass( + "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(texA, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "copy", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_texture(texA, texB, {}, { .mip = 1 }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + + PassNode* copyPass = storage(g.rg)->m_passes->next; + REQUIRE(copyPass != nullptr); + const ResourceAccess* src = nullptr; + const ResourceAccess* dst = nullptr; + for (uint32_t i = 0; i < copyPass->accessCount; ++i) { + const ResourceAccess& a = copyPass->accesses[i]; + if (a.type == AccessType::CopySrc) + src = &a; + if (a.type == AccessType::CopyDst) + dst = &a; + } + REQUIRE(src != nullptr); + REQUIRE(dst != nullptr); + REQUIRE(src->handle.id == texA.id); + REQUIRE(src->baseMip == 0); + REQUIRE(dst->handle.id == texB.id); + REQUIRE(dst->baseMip == 1); +} + +TEST_CASE("RenderGraph - copy_texture between subresources of one texture is legal", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex", /*mipLevelCount*/ 2); + + g.rg->add_pass( + "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "copy", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_texture(tex, tex, {}, { .mip = 1 }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +TEST_CASE("RenderGraph - copy_texture_to_buffer dst is never a first-define", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + auto buf = g.transient_buffer("buf"); + + g.rg->add_pass( + "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "readback", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_texture_to_buffer(tex, buf); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + // coverage depends on the body's layout, so the aliaser must not treat the dst as fully defined + REQUIRE(webgpu::rg::Internal::find_node(g.rg, buf)->firstDefines == false); +} + +TEST_CASE("RenderGraph - copy_buffer_to_texture write satisfies a later read", "[RenderGraph]") +{ + TestGraph g; + auto buf = g.transient_buffer("buf"); + auto tex = g.transient("tex"); + auto out = g.transient("out"); + + g.rg->add_pass( + "upload", PassKind::Transfer, [&](PassBuilder& b) { b.host_write(buf); }, [](PassContext&) {}); + g.rg->add_pass( + "stage", PassKind::Transfer, [&](PassBuilder& b) { b.copy_buffer_to_texture(buf, tex); }, [](PassContext&) {}); + // the copy's CopyDst is tex's writer, so sampling it afterwards is not a read-before-write + g.rg->add_pass( + "consume", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(tex); + b.color(out, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +TEST_CASE("RenderGraph - explicit full-size copy_buffer dst is a first-define", "[RenderGraph]") +{ + TestGraph g; + auto bufA = g.transient_buffer("bufA", 64); + auto bufB = g.transient_buffer("bufB", 64); + + add_buffer_producer(g.rg, bufA); + g.rg->add_pass( + "copy", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_buffer(bufA, bufB, 0, 0, /*size*/ 64); // explicit size spanning the whole dst + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(webgpu::rg::Internal::find_node(g.rg, bufB)->firstDefines == true); +} + +TEST_CASE("RenderGraph - whole copy_buffer dst is a first-define", "[RenderGraph]") +{ + TestGraph g; + auto bufA = g.transient_buffer("bufA"); + auto bufB = g.transient_buffer("bufB"); + + add_buffer_producer(g.rg, bufA); + g.rg->add_pass( + "copy", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_buffer(bufA, bufB); // offsets 0, size 0 = whole buffer + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(webgpu::rg::Internal::find_node(g.rg, bufB)->firstDefines == true); +} + +// no reader, not imported/persistent, no force_keep -> dead +TEST_CASE("RenderGraph - dead pass with no reader is culled", "[RenderGraph]") +{ + TestGraph g; + auto sink = import_tex(g.rg, "sink"); + auto mid = g.transient("mid"); + auto orphan = g.transient("orphan"); + + g.rg->add_pass( + "writer", PassKind::Graphics, [&](PassBuilder& b) { b.color(mid, 0); }, [](PassContext&) {}); + g.rg->add_pass( // writes orphan, read by nobody -> dead + "dead", PassKind::Graphics, [&](PassBuilder& b) { b.color(orphan, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "reader", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(mid); + b.color(sink, 0, { .load = WGPULoadOp_Load }); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + auto order = pass_order(g.rg); + REQUIRE(order.size() == 2); + REQUIRE(idx_of(order, "dead") < 0); // dropped + REQUIRE(idx_of(order, "writer") >= 0); + REQUIRE(idx_of(order, "reader") >= 0); +} + +// force_keep() rescues a reader-less, sink-less side-effect pass +TEST_CASE("RenderGraph - force_keep rescues an otherwise-dead pass", "[RenderGraph]") +{ + { // baseline: no force_keep -> the pass is dead and culled to nothing + TestGraph g; + auto buf = g.transient_buffer("scratch", 256); + g.rg->add_pass( + "effect", PassKind::Compute, [&](PassBuilder& b) { b.storage_write(buf); }, [](PassContext&) {}); + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(pass_order(g.rg).empty()); + } + { // same pass + force_keep() -> survives + TestGraph g; + auto buf = g.transient_buffer("scratch", 256); + g.rg->add_pass( + "effect", PassKind::Compute, + [&](PassBuilder& b) { + b.storage_write(buf); + b.force_keep(); + }, + [](PassContext&) {}); + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(pass_order(g.rg).size() == 1); + } +} + +// WAW is an ordering edge, so the earlier writer survives cull and is scheduled first +TEST_CASE("RenderGraph - write-after-write orders both writers before the reader", "[RenderGraph]") +{ + TestGraph g; + auto sink = import_tex(g.rg, "sink"); + auto x = g.transient("x"); + + g.rg->add_pass( + "w1", PassKind::Graphics, [&](PassBuilder& b) { b.color(x, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "w2", PassKind::Graphics, [&](PassBuilder& b) { b.color(x, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "reader", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(x); + b.color(sink, 0, { .load = WGPULoadOp_Load }); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + auto order = pass_order(g.rg); + REQUIRE(order.size() == 3); + REQUIRE(idx_of(order, "w1") < idx_of(order, "w2")); + REQUIRE(idx_of(order, "w2") < idx_of(order, "reader")); +} + +// legal, an imported value comes from outside the frame. unlike a transient read-before-write. +TEST_CASE("RenderGraph - reading an imported resource with no writer is legal", "[RenderGraph]") +{ + TestGraph g; + auto src = import_tex(g.rg, "src"); // imported, never written by any pass + auto sink = import_tex(g.rg, "sink"); + + g.rg->add_pass( + "blit", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(src); + b.color(sink, 0, { .load = WGPULoadOp_Load }); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(pass_order(g.rg).size() == 1); +} + +// a history .curr producer is a cull root, so it survives with no same-frame reader +TEST_CASE("RenderGraph - history .curr write is a cull sink", "[RenderGraph]") +{ + TestGraph g; + auto hist = g.rg->create_history_texture("hist", tex2d()); + + g.rg->add_pass( + "temporal", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(hist.prev); + b.color(hist.curr, 0); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + auto order = pass_order(g.rg); + REQUIRE(order.size() == 1); + REQUIRE(idx_of(order, "temporal") >= 0); +} + +// a plain persistent write is NOT a sink, it is pool-cached and dependency-driven. contrast +// imported/history. +TEST_CASE("RenderGraph - persistent write with no reader is culled", "[RenderGraph]") +{ + TestGraph g; + auto pers = g.rg->create_persistent_texture("pers", tex2d()); + + g.rg->add_pass( + "bake", PassKind::Graphics, [&](PassBuilder& b) { b.color(pers, 0); }, [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(pass_order(g.rg).empty()); +} + +// disjoint lifetimes + identical signatures -> one physical slot +TEST_CASE("RenderGraph - disjoint transients share one physical slot", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + using webgpu::rg::Internal::ResourceNode; + + TestGraph g; + auto sink1 = import_tex(g.rg, "sink1"); + auto sink2 = import_tex(g.rg, "sink2"); + auto t1 = g.transient("t1"); + auto t2 = g.transient("t2"); + + g.rg->add_pass( + "a", PassKind::Graphics, [&](PassBuilder& b) { b.color(t1, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "b", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(t1); + b.color(sink1, 0, { .load = WGPULoadOp_Load }); + }, + [](PassContext&) {}); + g.rg->add_pass( // t1 is dead by here, so t2 can reuse its storage + "c", PassKind::Graphics, [&](PassBuilder& b) { b.color(t2, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "d", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(t2); + b.color(sink2, 0, { .load = WGPULoadOp_Load }); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile(true) == nullptr); // enableAlias + REQUIRE(pass_order(g.rg).size() == 4); + REQUIRE(storage(g.rg)->m_slotCount == 1); + ResourceNode* r1 = find_node(g.rg, t1); + ResourceNode* r2 = find_node(g.rg, t2); + REQUIRE(r1->aliasSlot != ResourceNode::kNoSlot); + REQUIRE(r1->aliasSlot == r2->aliasSlot); +} + +// aliasing off: no slots built, every transient keeps its own object +TEST_CASE("RenderGraph - aliasing disabled builds no slots", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + using webgpu::rg::Internal::ResourceNode; + + TestGraph g; + auto sink1 = import_tex(g.rg, "sink1"); + auto sink2 = import_tex(g.rg, "sink2"); + auto t1 = g.transient("t1"); + auto t2 = g.transient("t2"); + + g.rg->add_pass( + "a", PassKind::Graphics, [&](PassBuilder& b) { b.color(t1, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "b", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(t1); + b.color(sink1, 0, { .load = WGPULoadOp_Load }); + }, + [](PassContext&) {}); + g.rg->add_pass( + "c", PassKind::Graphics, [&](PassBuilder& b) { b.color(t2, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "d", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(t2); + b.color(sink2, 0, { .load = WGPULoadOp_Load }); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile(false) == nullptr); // aliasing OFF + REQUIRE(storage(g.rg)->m_slotCount == 0); + REQUIRE(find_node(g.rg, t1)->aliasSlot == ResourceNode::kNoSlot); + REQUIRE(find_node(g.rg, t2)->aliasSlot == ResourceNode::kNoSlot); +} + +// both alive at the consumer -> distinct slots. the packer respects lifetime, not just signature. +TEST_CASE("RenderGraph - overlapping-lifetime transients do not share a slot", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + using webgpu::rg::Internal::ResourceNode; + + TestGraph g; + auto sink = import_tex(g.rg, "sink"); + auto t1 = g.transient("t1"); + auto t2 = g.transient("t2"); + + g.rg->add_pass( + "a", PassKind::Graphics, [&](PassBuilder& b) { b.color(t1, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "b", PassKind::Graphics, [&](PassBuilder& b) { b.color(t2, 0); }, [](PassContext&) {}); + g.rg->add_pass( // reads both -> t1 and t2 alive together here + "c", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(t1); + b.sampled(t2); + b.color(sink, 0, { .load = WGPULoadOp_Load }); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile(true) == nullptr); + REQUIRE(storage(g.rg)->m_slotCount == 2); + ResourceNode* r1 = find_node(g.rg, t1); + ResourceNode* r2 = find_node(g.rg, t2); + REQUIRE(r1->aliasSlot != ResourceNode::kNoSlot); + REQUIRE(r2->aliasSlot != ResourceNode::kNoSlot); + REQUIRE(r1->aliasSlot != r2->aliasSlot); +} + +// greedy reuse past two: three back-to-back disjoint transients collapse onto one slot. force_keep +// gives each a one-pass life. +TEST_CASE("RenderGraph - three disjoint transients collapse onto one slot", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + using webgpu::rg::Internal::ResourceNode; + + TestGraph g; + auto t1 = g.transient("t1"); + auto t2 = g.transient("t2"); + auto t3 = g.transient("t3"); + + g.rg->add_pass( + "k1", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t1, 0); + b.force_keep(); + }, + [](PassContext&) {}); + g.rg->add_pass( + "k2", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t2, 0); + b.force_keep(); + }, + [](PassContext&) {}); + g.rg->add_pass( + "k3", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t3, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile(true) == nullptr); + REQUIRE(pass_order(g.rg).size() == 3); + REQUIRE(storage(g.rg)->m_slotCount == 1); // one physical slot for three transients + ResourceNode* r1 = find_node(g.rg, t1); + ResourceNode* r2 = find_node(g.rg, t2); + ResourceNode* r3 = find_node(g.rg, t3); + REQUIRE(r1->aliasSlot != ResourceNode::kNoSlot); + REQUIRE(r1->aliasSlot == r2->aliasSlot); + REQUIRE(r2->aliasSlot == r3->aliasSlot); +} + +// --------------------------------------------------------------------------------------------------- + +// sampleCount 4 color + single-sample resolve target. resolve() is a write, so a later +// sampled(resolved) orders after it. +TEST_CASE("RenderGraph - msaa resolve is a write that satisfies a later read", "[RenderGraph]") +{ + using webgpu::rg::Internal::AccessType; + using webgpu::rg::Internal::find_node; + + TestGraph g; + TextureDesc msaaDesc {}; + msaaDesc.dimension = WGPUTextureDimension_2D; + msaaDesc.format = WGPUTextureFormat_RGBA8Unorm; + msaaDesc.absolute = { 16, 16, 1 }; + msaaDesc.sampleCount = 4; + auto msaaColor = g.rg->create_transient_texture("msaa.color", msaaDesc); + auto resolved = g.transient("resolved"); // single-sample, same format+size + auto out = g.transient("out"); + + g.rg->add_pass( + "forward.msaa", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(msaaColor, 0); + b.resolve(msaaColor, resolved); + }, + [](PassContext&) {}); + g.rg->add_pass( + "present", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(resolved); + b.color(out, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(find_node(g.rg, msaaColor)->sampleCount == 4); + + // recorded as a ResolveAttachment write on `resolved` + PassNode* msaaPass = storage(g.rg)->m_passes; + bool foundResolve = false; + for (uint32_t i = 0; i < msaaPass->accessCount; ++i) + if (msaaPass->accesses[i].type == AccessType::ResolveAttachment && msaaPass->accesses[i].handle.id == resolved.id) + foundResolve = true; + REQUIRE(foundResolve); + + // present ordered after the resolve + int msaaPos = -1, presentPos = -1, pos = 0; + for (PassNode* p = storage(g.rg)->m_passes; p; p = p->next, ++pos) { + if (p->id == make_resource_id("forward.msaa")) + msaaPos = pos; + else if (p->id == make_resource_id("present")) + presentPos = pos; + } + REQUIRE(msaaPos >= 0); + REQUIRE(presentPos >= 0); + REQUIRE(msaaPos < presentPos); +} + +// the stencil load/store/clear reaches the access, and a read-only reader orders after the mask writer +TEST_CASE("RenderGraph - stencil mask write then read-only test pass", "[RenderGraph]") +{ + using webgpu::rg::Internal::AccessType; + + TestGraph g; + TextureDesc dsDesc {}; + dsDesc.dimension = WGPUTextureDimension_2D; + dsDesc.format = WGPUTextureFormat_Depth24PlusStencil8; + dsDesc.absolute = { 16, 16, 1 }; + auto ds = g.rg->create_transient_texture("mask.ds", dsDesc); + auto out = g.transient("out"); + + g.rg->add_pass( + "stencil.mask", PassKind::Graphics, + [&](PassBuilder& b) { + b.depth_stencil(ds, { .stencilLoad = WGPULoadOp_Clear, .stencilStore = WGPUStoreOp_Store, .stencilClear = 7 }); + }, + [](PassContext&) {}); + g.rg->add_pass( + "stencil.effect", PassKind::Graphics, + [&](PassBuilder& b) { + b.depth_stencil_read_only(ds); + b.color(out, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + + PassNode* maskPass = storage(g.rg)->m_passes; + const webgpu::rg::Internal::ResourceAccess* dsWrite = nullptr; + for (uint32_t i = 0; i < maskPass->accessCount; ++i) + if (maskPass->accesses[i].type == AccessType::DepthStencilAttachment) + dsWrite = &maskPass->accesses[i]; + REQUIRE(dsWrite != nullptr); + REQUIRE(dsWrite->stencilLoadOp == WGPULoadOp_Clear); + REQUIRE(dsWrite->stencilStoreOp == WGPUStoreOp_Store); + REQUIRE(dsWrite->stencilClear == 7); + + // effect pass reads via DepthStencilReadOnly and runs after the mask write + int maskPos = -1, effectPos = -1, pos = 0; + bool foundReadOnly = false; + for (PassNode* p = storage(g.rg)->m_passes; p; p = p->next, ++pos) { + if (p->id == make_resource_id("stencil.mask")) + maskPos = pos; + if (p->id == make_resource_id("stencil.effect")) { + effectPos = pos; + for (uint32_t i = 0; i < p->accessCount; ++i) + if (p->accesses[i].type == AccessType::DepthStencilReadOnly) + foundReadOnly = true; + } + } + REQUIRE(foundReadOnly); + REQUIRE(maskPos >= 0); + REQUIRE(effectPos >= 0); + REQUIRE(maskPos < effectPos); +} + +// the declared view shape reaches the access, so ctx.view()/ctx.bind() hand back exactly it +TEST_CASE("RenderGraph - sampled ViewRange is recorded on the access", "[RenderGraph]") +{ + using webgpu::rg::Internal::AccessType; + + TestGraph g; + TextureDesc desc {}; + desc.dimension = WGPUTextureDimension_2D; + desc.format = WGPUTextureFormat_RGBA8Unorm; + desc.absolute = { 16, 16, 6 }; + desc.mipLevelCount = 4; + auto env = g.rg->create_transient_texture("env", desc); + auto out = g.transient("out"); + + g.rg->add_pass( + "produce", PassKind::Compute, [&](PassBuilder& b) { b.storage_write(env); }, [](PassContext&) {}); + g.rg->add_pass( + "consume", PassKind::Graphics, + [&](PassBuilder& b) { + // cube view over all 6 layers, mips 1..2, from rendergraph.md Views + b.sampled(env, { .baseMip = 1, .mipCount = 2, .layerCount = 6, .dim = WGPUTextureViewDimension_Cube }); + b.color(out, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + + PassNode* consume = storage(g.rg)->m_passes->next; + REQUIRE(consume != nullptr); + const webgpu::rg::Internal::ResourceAccess* read = nullptr; + for (uint32_t i = 0; i < consume->accessCount; ++i) + if (consume->accesses[i].type == AccessType::Sampled) + read = &consume->accesses[i]; + REQUIRE(read != nullptr); + REQUIRE(read->viewDim == WGPUTextureViewDimension_Cube); + REQUIRE(read->baseMip == 1); + REQUIRE(read->mipCount == 2); + REQUIRE(read->baseLayer == 0); + REQUIRE(read->layerCount == 6); +} + +// per level, sample mip i-1 and render into mip i of the SAME handle. whole-resource hazards serialize +// the chain in level order. +TEST_CASE("RenderGraph - mip chain blit on one handle compiles and serializes", "[RenderGraph]") +{ + constexpr uint32_t kMips = 4; + TestGraph g; + auto tex = g.transient("chain", kMips); + + g.rg->add_pass( + "mip0", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, + [](PassContext&) {}); + + std::string names[kMips]; + for (uint32_t mip = 1; mip < kMips; ++mip) { + names[mip] = "mip" + std::to_string(mip); + const bool last = (mip == kMips - 1); + g.rg->add_pass( + names[mip], PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(tex, { .baseMip = mip - 1 }); + b.color(tex, 0, { .sub = { .mip = mip } }); + if (last) + b.force_keep(); + }, + [](PassContext&) {}); + } + + REQUIRE(g.rg->compile() == nullptr); + + // execution order preserves the level order: mip0, mip1, mip2, mip3 + uint32_t pos = 0; + for (PassNode* p = storage(g.rg)->m_passes; p; p = p->next, ++pos) { + REQUIRE(pos < kMips); + std::string expected = "mip" + std::to_string(pos); + REQUIRE(std::string_view(p->id.name.data, p->id.name.length) == expected); + } + REQUIRE(pos == kMips); // all passes survived culling +} + +// --------------------------------------------------------------------------------------------------- +// TransientResourcePool supersede-eviction, device-free. destroy() null-guards every GPU release, so +// hand-populated null entries drive end_frame() directly. + +using webgpu::rg::Internal::Arena; +using webgpu::rg::Internal::StringInterner; +using webgpu::rg::Internal::SubviewPool; +using webgpu::rg::Internal::TexSignature; +using webgpu::rg::Internal::TransientResourcePool; + +// --------------------------------------------------------------------------------------------------- +// StringInterner. a stack arena stands in for the allocator's persist, nothing here needs a device. + +struct InternerFixture { + Arena arena {}; + StringInterner interner {}; + + InternerFixture() { interner.arena = &arena; } + ~InternerFixture() { arena.free_all(); } +}; + +TEST_CASE("StringInterner - equal strings share one canonical copy", "[RenderGraph]") +{ + InternerFixture f; + + // distinct std::string objects, so a pointer match cannot come from the caller's own storage + std::string a = "gbuffer.albedo"; + std::string b = "gbuffer.albedo"; + REQUIRE(a.data() != b.data()); + + ResourceId ia = f.interner.intern(a); + ResourceId ib = f.interner.intern(b); + + REQUIRE(ia.name.data == ib.name.data); // one canonical copy + REQUIRE(ia.value == ib.value); + REQUIRE(ia.value == webgpu::rg::fnv1a("gbuffer.albedo")); // the id hash is still fnv1a of the name + REQUIRE(std::string_view(ia.name.data, ia.name.length) == "gbuffer.albedo"); +} + +TEST_CASE("StringInterner - distinct strings get distinct canonical copies", "[RenderGraph]") +{ + InternerFixture f; + ResourceId a = f.interner.intern("gbuffer.albedo"); + ResourceId b = f.interner.intern("gbuffer.normal"); + + REQUIRE(a.name.data != b.name.data); + REQUIRE(a.value != b.value); +} + +// the pre-fix pool compared names under a 63-char truncation. the interner stores each in full. +TEST_CASE("StringInterner - names differing only past 63 chars stay distinct", "[RenderGraph]") +{ + InternerFixture f; + const std::string prefix(63, 'x'); + ResourceId a = f.interner.intern(prefix + "alpha"); + ResourceId b = f.interner.intern(prefix + "beta"); + + REQUIRE(a.name.data != b.name.data); + REQUIRE(a.value != b.value); + REQUIRE(a.name.length == 68); + REQUIRE(std::string_view(a.name.data, a.name.length) == prefix + "alpha"); // stored in full, not truncated + REQUIRE(std::string_view(b.name.data, b.name.length) == prefix + "beta"); +} + +// a default-constructed string_view's null data must not reach memcmp +TEST_CASE("StringInterner - an empty name interns without dereferencing null", "[RenderGraph]") +{ + InternerFixture f; + ResourceId a = f.interner.intern(std::string_view {}); + ResourceId b = f.interner.intern(""); // same content, different data pointer on the way in + + REQUIRE(a.name.data == b.name.data); // still dedups to one canonical copy + REQUIRE(a.name.length == 0); + REQUIRE(a.name.data[0] == '\0'); + REQUIRE(a.name.data != f.interner.intern("nonempty").name.data); +} + +TEST_CASE("StringInterner - a name longer than the old inline caps is stored in full", "[RenderGraph]") +{ + InternerFixture f; + const std::string longName(200, 'q'); // past both the old 64-char pool cap and 48-char profiler cap + ResourceId id = f.interner.intern(longName); + + REQUIRE(id.name.length == 200); + REQUIRE(std::string_view(id.name.data, id.name.length) == longName); + REQUIRE(id.name.data[200] == '\0'); // NUL-terminated, so it is usable as a WebGPU label +} + +// the interner never rehashes, which is what makes a canonical pointer safe to keep. fill past kBuckets +// so every bucket chains. +TEST_CASE("StringInterner - canonical pointers stay stable across many interns", "[RenderGraph]") +{ + InternerFixture f; + ResourceId first = f.interner.intern("first.name"); + const char* firstData = first.name.data; + + std::vector ids; + for (int i = 0; i < StringInterner::kBuckets * 8; ++i) + ids.push_back(f.interner.intern("filler." + std::to_string(i))); + + REQUIRE(f.interner.intern("first.name").name.data == firstData); // same node, no relocation + REQUIRE(std::string_view(firstData, first.name.length) == "first.name"); // bytes still intact + + // every filler is still its own canonical copy and re-interns to the same pointer + for (size_t i = 0; i < ids.size(); ++i) { + ResourceId again = f.interner.intern("filler." + std::to_string(i)); + REQUIRE(again.name.data == ids[i].name.data); + } +} + +// live-cell count for either pool's intrusive entry list +template static uint32_t list_count(const E* head) +{ + uint32_t n = 0; + for (const E* e = head; e; e = e->next) + ++n; + return n; +} + +// the minimum end_frame()/superseded_by need without a device: cells from `arena`, and a `subviewPool` +// for destroy() to recycle the (always null here) subview list into. +struct PoolFixture { + Arena arena {}; + SubviewPool subviewPool {}; + TransientResourcePool pool {}; + + PoolFixture() + { + subviewPool.arena = &arena; + pool.arena = &arena; + pool.subviewPool = &subviewPool; + } + // drain the pool BEFORE freeing the arena its cells live in. a destructor body runs before its members + // are destroyed, so a bare free_all() would leave ~TransientResourcePool() walking freed memory. + ~PoolFixture() + { + pool.destroy_all(); + arena.free_all(); + } + + // appends rather than prepends, so list order matches declaration order for the positional reads + TransientResourcePool::Entry* push(const TransientResourcePool::Entry& proto) + { + TransientResourcePool::Entry* e = pool.alloc_entry(); + TransientResourcePool::Entry* next = e->next; + *e = proto; + e->next = next; + TransientResourcePool::Entry** pp = &pool.entries; + while (*pp) + pp = &(*pp)->next; + *pp = e; + e->next = nullptr; + return e; + } + + uint32_t count() const { return list_count(pool.entries); } + + // nth live entry, for the positional checks + const TransientResourcePool::Entry* at(uint32_t i) const + { + const TransientResourcePool::Entry* e = pool.entries; + for (uint32_t k = 0; e && k < i; ++k) + e = e->next; + return e; + } +}; + +// stand-ins for the interned name pointers acquire passes as an entry's supersede identity. only the +// address matters, superseded_by compares pointers. +static const char kIdA[] = "resourceA"; +static const char kIdB[] = "resourceB"; + +// GPU handles left null so destroy() is a no-op. identity defaults to kIdA, so a test that does not care +// gets two entries of the SAME logical resource, i.e. the resize case. +static TransientResourcePool::Entry tex_entry( + WGPUExtent3D size, uint64_t createdFrame, uint64_t lastUsedFrame, const void* identity = kIdA) +{ + TransientResourcePool::Entry e {}; + e.kind = ResourceKind::Texture; + e.sig.size = size; + e.sig.format = WGPUTextureFormat_RGBA8Unorm; + e.sig.dim = WGPUTextureDimension_2D; + e.sig.mipLevelCount = 1; + e.sig.sampleCount = 1; + e.usage = (WGPUTextureUsage)(WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_TextureBinding); + e.createdFrame = createdFrame; + e.lastUsedFrame = lastUsedFrame; + e.identity = identity; + return e; +} + +TEST_CASE("TransientResourcePool - old-size idle texture is superseded by a new-size sibling", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); // old size, idle this frame + f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10)); // new size, created this frame + + f.pool.end_frame(); + + // the old-size entry is evicted at end_frame, before kRetain (frame - 9 == 1 < 4) would fire. + REQUIRE(f.count() == 1); + REQUIRE(f.at(0)->sig.size.width == 1024); +} + +TEST_CASE("TransientResourcePool - texture claimed this frame is not superseded", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 10)); // used THIS frame -> live + f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10)); + + f.pool.end_frame(); + + REQUIRE(f.count() == 2); // both kept +} + +TEST_CASE("TransientResourcePool - descriptor mismatch does not supersede", "[RenderGraph]") +{ + auto survives = [](auto mutate) { + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); // old, idle + TransientResourcePool::Entry sibling = tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10); + mutate(sibling); + f.push(sibling); + f.pool.end_frame(); + // old entry not superseded -> both survive (frame - 9 == 1 < kRetain, so kRetain keeps it too) + return f.count() == 2; + }; + + REQUIRE(survives([](TransientResourcePool::Entry& s) { s.sig.format = WGPUTextureFormat_BGRA8Unorm; })); + REQUIRE(survives([](TransientResourcePool::Entry& s) { s.sig.dim = WGPUTextureDimension_3D; })); + REQUIRE(survives([](TransientResourcePool::Entry& s) { s.sig.mipLevelCount = 4; })); + REQUIRE(survives([](TransientResourcePool::Entry& s) { s.sig.sampleCount = 4; })); + // a differently-purposed sibling must not sweep the old entry: STORAGE does not cover SAMPLED+ATTACHMENT + REQUIRE(survives([](TransientResourcePool::Entry& s) { s.usage = WGPUTextureUsage_StorageBinding; })); +} + +TEST_CASE("TransientResourcePool - sibling created in an earlier frame does not supersede", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); // old, idle + f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 8, /*lastUsed*/ 9)); // different size but NOT this frame + + f.pool.end_frame(); + + REQUIRE(f.count() == 2); // no supersede; kRetain keeps both (idle only 1 frame) +} + +// a resize that also flips a usage bit is still a resize. usage matches by superset, so the added +// STORAGE bit does not hide the supersede. +TEST_CASE("TransientResourcePool - a wider-usage new-size sibling supersedes the old extent", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); // old size, idle + TransientResourcePool::Entry sibling = tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10); + sibling.usage = (WGPUTextureUsage)(sibling.usage | WGPUTextureUsage_StorageBinding); // superset of the old entry's + f.push(sibling); + + f.pool.end_frame(); + + REQUIRE(f.count() == 1); + REQUIRE(f.at(0)->sig.size.width == 1024); +} + +// GPU handles null so destroy() is a no-op. identity defaults to kIdA, see tex_entry. +static TransientResourcePool::Entry buf_entry( + uint64_t size, WGPUBufferUsage usage, uint64_t createdFrame, uint64_t lastUsedFrame, const void* identity = kIdA) +{ + TransientResourcePool::Entry e {}; + e.kind = ResourceKind::Buffer; + e.bufferSize = size; + e.bufUsage = usage; + e.createdFrame = createdFrame; + e.lastUsedFrame = lastUsedFrame; + e.identity = identity; + return e; +} + +// the buffer arm. pre-patch this short-circuited on non-textures, so a resize drag stacked up to +// kRetain generations of screen-derived buffers. +TEST_CASE("TransientResourcePool - a resized idle buffer is superseded by its new-size sibling", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 9)); // old size, idle + f.push(buf_entry(512, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst), /*created*/ 10, /*lastUsed*/ 10)); + + f.pool.end_frame(); + + REQUIRE(f.count() == 1); // collapsed same-frame, not after kRetain + REQUIRE(f.at(0)->bufferSize == 512); +} + +TEST_CASE("TransientResourcePool - a buffer whose usage the sibling does not cover is not superseded", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(buf_entry(256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_Indirect), /*created*/ 3, /*lastUsed*/ 9)); + f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 10, /*lastUsed*/ 10)); // misses Indirect + + f.pool.end_frame(); + + REQUIRE(f.count() == 2); // differently-purposed, so not swept +} + +TEST_CASE("TransientResourcePool - a buffer claimed this frame is not superseded", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 10)); // used THIS frame -> live + f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 10, /*lastUsed*/ 10)); + + f.pool.end_frame(); + + REQUIRE(f.count() == 2); // the lastUsedFrame >= frame guard holds for buffers too +} + +TEST_CASE("TransientResourcePool - a buffer sibling created in an earlier frame does not supersede", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 9)); + f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 8, /*lastUsed*/ 9)); // not this frame's replacement + + f.pool.end_frame(); + + REQUIRE(f.count() == 2); +} + +// a texture and a buffer never supersede each other, whatever their sizes +TEST_CASE("TransientResourcePool - kinds do not supersede across each other", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); + f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 10, /*lastUsed*/ 10)); + + f.pool.end_frame(); + + REQUIRE(f.count() == 2); +} + +TEST_CASE("TransientResourcePool - same-size buffers are not superseded", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + + f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 9)); + // same size -> not a resize, so not a supersede + f.push(buf_entry(256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst), /*created*/ 10, /*lastUsed*/ 10)); + + f.pool.end_frame(); + + REQUIRE(f.count() == 2); +} + +TEST_CASE("TransientResourcePool - drag resize keeps only the newest generation each frame", "[RenderGraph]") +{ + PoolFixture f; + + // simulate three consecutive frames, each acquiring a fresh, larger size while the previous + // generation goes idle. after each end_frame() only the size just created must remain. + const WGPUExtent3D sizes[3] = { { 800, 600, 1 }, { 900, 675, 1 }, { 1000, 750, 1 } }; + for (int i = 0; i < 3; ++i) { + // this frame's new-size entry, claimed this frame + f.push(tex_entry(sizes[i], /*created*/ f.pool.frame, /*lastUsed*/ f.pool.frame)); + f.pool.createdThisFrame = 1; + + f.pool.end_frame(); // supersede-evicts any older-size idle generation, then advances frame + + REQUIRE(f.count() == 1); + REQUIRE(f.at(0)->sig.size.width == sizes[i].width); + + // going into the next frame the surviving entry is now idle (not touched next frame until acquired) + } +} + +TEST_CASE("TransientResourcePool - two idle generations are both swept by one new-size sibling", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + // two older generations idle at once, which the drag-resize test never produces. both well inside + // kRetain, so only supersede can take them: end_frame's scan clears the whole backlog in one pass. + f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 8)); + f.push(tex_entry({ 900, 675, 1 }, /*created*/ 8, /*lastUsed*/ 9)); + f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10)); + + f.pool.end_frame(); + + REQUIRE(f.count() == 1); + REQUIRE(f.at(0)->sig.size.width == 1024); +} + +TEST_CASE("TransientResourcePool - the event log records the supersede evict", "[RenderGraph]") +{ + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); + f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10)); + f.pool.log_reset(); + + f.pool.end_frame(); + + // the surviving counts cannot say WHEN an eviction fired, the log can. only Evict is visible here, + // Create is logged by acquire, which needs a device. + REQUIRE(f.pool.eventCount == 1); + REQUIRE(f.pool.eventLog[0].event == TransientResourcePool::Event::Evict); + REQUIRE(f.pool.eventLog[0].kind == ResourceKind::Texture); + REQUIRE(f.pool.eventLog[0].frame == 10); // logged before end_frame advances the clock + REQUIRE(f.pool.eventLog[0].size.width == 800); // the old generation, not the survivor + REQUIRE(f.pool.eventLog[0].size.height == 600); +} + +// --------------------------------------------------------------------------------------------------- +// supersede must not fire across two DIFFERENT resources that merely differ in size. the pool matches by +// descriptor, so without an identity key an idle resource looks like a stale generation of any +// same-shaped sibling created this frame. + +// claim the pooled buffer matching size+usage, else create what acquire's miss path would. identity +// tracks the LAST claimant, since an entry may legitimately change hands. true if this created one. +static bool claim_or_create_buf(PoolFixture& f, uint64_t size, WGPUBufferUsage usage, const void* identity) +{ + for (TransientResourcePool::Entry* e = f.pool.entries; e; e = e->next) + if (e->kind == ResourceKind::Buffer && !e->inUse && e->bufferSize == size && e->bufUsage == usage) { + e->lastUsedFrame = f.pool.frame; + e->identity = identity; + return false; + } + f.push(buf_entry(size, usage, /*created*/ f.pool.frame, /*lastUsed*/ f.pool.frame, identity)); + ++f.pool.createdThisFrame; + return true; +} + +// tex_entry's counterpart to claim_or_create_buf +static bool claim_or_create_tex(PoolFixture& f, WGPUExtent3D size, const void* identity) +{ + TransientResourcePool::Entry want = tex_entry(size, 0, 0, identity); + for (TransientResourcePool::Entry* e = f.pool.entries; e; e = e->next) + if (e->kind == ResourceKind::Texture && !e->inUse && e->sig == want.sig && e->usage == want.usage) { + e->lastUsedFrame = f.pool.frame; + e->identity = identity; + return false; + } + f.push(tex_entry(size, /*created*/ f.pool.frame, /*lastUsed*/ f.pool.frame, identity)); + ++f.pool.createdThisFrame; + return true; +} + +TEST_CASE("TransientResourcePool - alternately claimed buffers of different sizes do not churn", "[RenderGraph]") +{ + PoolFixture f; + + // two unrelated buffers, same usage, different sizes, each claimed every OTHER frame. neither is a + // resize of the other, and each is idle only 1 frame at a time, so both survive untouched. + uint32_t creates = 0; + for (uint64_t i = 0; i < 6; ++i) { + if (i % 2 == 0) + creates += claim_or_create_buf(f, 256, WGPUBufferUsage_Storage, kIdA); + else + creates += claim_or_create_buf(f, 512, WGPUBufferUsage_Storage, kIdB); + f.pool.release_claims(); + f.pool.end_frame(); + } + + REQUIRE(creates == 2); // one per resource, on its first claim, never recreated + REQUIRE(f.count() == 2); + REQUIRE(f.pool.eventCount == 0); // no evict ever logged +} + +TEST_CASE("TransientResourcePool - alternately claimed textures of different extents do not churn", "[RenderGraph]") +{ + PoolFixture f; + + // the texture arm of the same hole, differing only in extent + uint32_t creates = 0; + for (uint64_t i = 0; i < 6; ++i) { + if (i % 2 == 0) + creates += claim_or_create_tex(f, { 800, 600, 1 }, kIdA); + else + creates += claim_or_create_tex(f, { 400, 300, 1 }, kIdB); + f.pool.release_claims(); + f.pool.end_frame(); + } + + REQUIRE(creates == 2); + REQUIRE(f.count() == 2); + REQUIRE(f.pool.eventCount == 0); +} + +TEST_CASE("TransientResourcePool - a size-only difference across identities does not supersede", "[RenderGraph]") +{ + // the identity bail alone, without the alternating-frame timing above. same shape as "a resized idle + // buffer is superseded by its new-size sibling", only the identity differs. + { + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 9, kIdA)); + f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 10, /*lastUsed*/ 10, kIdB)); + + f.pool.end_frame(); + + REQUIRE(f.count() == 2); + } + { + PoolFixture f; + f.pool.frame = 10; + f.pool.createdThisFrame = 1; + f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9, kIdA)); + f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10, kIdB)); + + f.pool.end_frame(); + + REQUIRE(f.count() == 2); + } +} + +TEST_CASE("TransientResourcePool - different-size buffers claimed every frame are never superseded", "[RenderGraph]") +{ + PoolFixture f; + + // the common case, and the guard that the identity key does not regress it. the lastUsedFrame >= + // frame bail already covers this, with or without identity. + uint32_t creates = 0; + for (uint64_t i = 0; i < 6; ++i) { + creates += claim_or_create_buf(f, 256, WGPUBufferUsage_Storage, kIdA); + creates += claim_or_create_buf(f, 512, WGPUBufferUsage_Storage, kIdB); + f.pool.release_claims(); + f.pool.end_frame(); + } + + REQUIRE(creates == 2); + REQUIRE(f.count() == 2); +} + +// --------------------------------------------------------------------------------------------------- +// transient buffer acquire matching. a real device, so the miss branch actually creates and +// reused-vs-created is observable as an entry count. + +TEST_CASE("TransientResourcePool - a usage-superset idle buffer is reused", "[RenderGraph][gpu]") +{ + UnittestWebgpuContext gpu; + PoolFixture f; + + // seed the pool the way a previous frame would have: one idle Storage|CopySrc buffer + WGPUBuffer first = nullptr; + f.pool.acquire(gpu.device, 256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopySrc), kIdA, first); + REQUIRE(first != nullptr); + REQUIRE(f.count() == 1); + f.pool.release_claims(); + + // a narrower request is covered by that buffer's usage -> reuse the same object, no second entry + WGPUBuffer second = nullptr; + f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdA, second); + REQUIRE(second == first); + REQUIRE(f.count() == 1); + + f.pool.destroy_all(); +} + +TEST_CASE("TransientResourcePool - a buffer whose usage misses a requested bit is not reused", "[RenderGraph][gpu]") +{ + UnittestWebgpuContext gpu; + PoolFixture f; + + WGPUBuffer first = nullptr; + f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdA, first); + f.pool.release_claims(); + + // storage does not cover Storage|Indirect -> a fresh object, since usage matches by superset + WGPUBuffer second = nullptr; + f.pool.acquire(gpu.device, 256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_Indirect), kIdA, second); + REQUIRE(second != first); + REQUIRE(f.count() == 2); + + f.pool.destroy_all(); +} + +// identity must follow the claimant, or the original creator's resize sweeps an object that now belongs +// to someone else. needs a real acquire, the device-free fixtures stamp identity themselves. + +TEST_CASE("TransientResourcePool - a rehomed buffer is not swept by its creator's resize", "[RenderGraph][gpu]") +{ + UnittestWebgpuContext gpu; + PoolFixture f; + + // frame 0: A creates a 256B object + WGPUBuffer a0 = nullptr; + f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdA, a0); + f.pool.release_claims(); + f.pool.end_frame(); + + // frame 1: A does not record, B asks for the same descriptor and is handed A's object + WGPUBuffer b1 = nullptr; + f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdB, b1); + REQUIRE(b1 == a0); // descriptor match -> reused, the object now serves B + f.pool.release_claims(); + f.pool.end_frame(); + + // frame 2: B is idle, A returns resized. a's new object must not sweep the one B is still using. + WGPUBuffer a2 = nullptr; + f.pool.acquire(gpu.device, 512, WGPUBufferUsage_Storage, kIdA, a2); + f.pool.release_claims(); + f.pool.end_frame(); + + REQUIRE(f.count() == 2); // b's object survived A's resize +} + +TEST_CASE("TransientResourcePool - a rehomed texture is not swept by its creator's resize", "[RenderGraph][gpu]") +{ + UnittestWebgpuContext gpu; + PoolFixture f; + + const TransientResourcePool::Entry small = tex_entry({ 16, 16, 1 }, 0, 0); + const TransientResourcePool::Entry large = tex_entry({ 32, 32, 1 }, 0, 0); + + WGPUTexture a0 = nullptr, a2 = nullptr, b1 = nullptr; + WGPUTextureView v = nullptr; + + f.pool.acquire(gpu.device, small.sig, small.usage, kIdA, a0, v); + f.pool.release_claims(); + f.pool.end_frame(); + + f.pool.acquire(gpu.device, small.sig, small.usage, kIdB, b1, v); + REQUIRE(b1 == a0); + f.pool.release_claims(); + f.pool.end_frame(); + + f.pool.acquire(gpu.device, large.sig, large.usage, kIdA, a2, v); + f.pool.release_claims(); + f.pool.end_frame(); + + REQUIRE(f.count() == 2); +} + +// the removed MapRead|MapWrite exact-match reservation. the graph never gives a transient buffer those +// bits, so it only ever blocked reuse of a state the graph cannot produce. +TEST_CASE("TransientResourcePool - a map-bit-carrying idle buffer no longer blocks superset reuse", "[RenderGraph][gpu]") +{ + UnittestWebgpuContext gpu; + PoolFixture f; + + WGPUBuffer seeded = nullptr; + f.pool.acquire(gpu.device, 256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst), kIdA, seeded); + f.pool.release_claims(); + // stamp the map bit on rather than asking WebGPU for the invalid Storage|MapRead combination. only + // the flags matter, acquire compares bufUsage and never the object. + f.pool.entries->bufUsage + = (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead); + + // pre-patch the MapRead bit made this an exact-match miss and forced a second object + WGPUBuffer plain = nullptr; + f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdA, plain); + REQUIRE(plain == seeded); + REQUIRE(f.count() == 1); + + f.pool.destroy_all(); +} + +// =================================================================================================== +// execute-level tests. everything above stops at compile(), these run realize_graph() + execute() against +// a real Dawn device and drive the cross-frame pools over whole frames on one long-lived allocator. +// +// NOTE: release_resources() nulls a transient's realized handles at the end of execute(), so white-box +// handle checks must capture inside a pass body via ctx.*. the pools persist and are read after frame(). + +using webgpu::rg::Internal::PersistentResourcePool; +using webgpu::rg::Internal::ResourceNode; +using webgpu::rg::Internal::find_node; + +struct ExecGraph { + UnittestWebgpuContext gpu; + GraphAllocator* allocator = create_allocator(); + + ~ExecGraph() { destroy_allocator(allocator); } + ExecGraph(const ExecGraph&) = delete; + ExecGraph& operator=(const ExecGraph&) = delete; + ExecGraph() = default; + + // block until the queue drains, so a readback or pool inspection sees finished work + void wait_idle() + { + bool done = false; + WGPUQueueWorkDoneCallbackInfo cb { + .nextInChain = nullptr, + .mode = WGPUCallbackMode_AllowProcessEvents, + .callback = [](WGPUQueueWorkDoneStatus, WGPUStringView, void* d, void*) { *reinterpret_cast(d) = true; }, + .userdata1 = &done, + .userdata2 = nullptr, + }; + WGPUFuture f = wgpuQueueOnSubmittedWorkDone(gpu.queue, cb); + WGPUFutureWaitInfo wi { .future = f, .completed = false }; + REQUIRE(wgpuInstanceWaitAny(gpu.instance, 1, &wi, 1000ull * 1000 * 1000) == WGPUWaitStatus_Success); + } + + // compile + execute + submit one already-recorded graph on its own command buffer + void submit_graph(RenderGraph* rg) + { + REQUIRE(rg->compile() == nullptr); + + WGPUCommandEncoderDescriptor ed {}; + WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(gpu.device, &ed); + rg->execute(gpu.device, gpu.queue, enc); + WGPUCommandBufferDescriptor cd {}; + WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, &cd); + wgpuQueueSubmit(gpu.queue, 1, &cmd); + wgpuCommandBufferRelease(cmd); + wgpuCommandEncoderRelease(enc); + } + + // one full frame: record -> compile -> execute -> submit -> wait -> inspect -> end_frame + template + void frame(Record&& record, Inspect&& inspect) + { + begin_frame(allocator); + RenderGraph* rg = start_recording(allocator); + record(rg); + submit_graph(rg); + wait_idle(); + inspect(rg); + end_frame(allocator); + } + + template + void frame(Record&& record) + { + frame(std::forward(record), [](RenderGraph*) {}); + } +}; + +// texture->buffer readback into an imported `dst`. rows are 256-aligned, so buf size is alignedRow*H. +static void encode_readback(PassContext& ctx, TextureHandle src, BufferHandle dst, uint32_t w, uint32_t h) +{ + WGPUTexelCopyBufferLayout layout { .offset = 0, .bytesPerRow = webgpu::rg::aligned_bytes_per_row(w, 4), .rowsPerImage = h }; + auto s = ctx.copy_src_info(src); + auto d = ctx.copy_dst_buffer(dst, layout); + auto sz = ctx.copy_extent_src(src); + wgpuCommandEncoderCopyTextureToBuffer(ctx.encoder, &s, &d, &sz); +} + +// smoke: clear a transient, copy it into an imported mappable buffer, read the pixel back. exercises +// transient acquire, attachment build, pass ordering and the copy family end to end. +TEST_CASE("RenderGraph exec - clear then readback returns the clear color", "[RenderGraph][gpu]") +{ + constexpr uint32_t W = 16, H = 16; + const uint32_t alignedRow = webgpu::rg::aligned_bytes_per_row(W, 4); + + ExecGraph g; + // imported readback target, MapRead so the host can read it directly after submit + webgpu::raii::RawBuffer readback(g.gpu.device, WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead, alignedRow * H, "rg readback"); + + g.frame([&](RenderGraph* rg) { + auto tex = rg->create_transient_texture("smoke.tex", [] { + TextureDesc d {}; + d.dimension = WGPUTextureDimension_2D; + d.format = WGPUTextureFormat_RGBA8Unorm; + d.absolute = { W, H, 1 }; + return d; + }()); + auto buf = rg->import_buffer("smoke.readback", readback.handle()); + + rg->add_pass( + "clear", PassKind::Graphics, + [&](PassBuilder& b) { b.color(tex, 0, { .clear = { 1.0, 0.0, 0.0, 1.0 } }); }, [](PassContext&) {}); + rg->add_pass( + "readback", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_texture_to_buffer(tex, buf); + b.force_keep(); + }, + [=](PassContext& ctx) { encode_readback(ctx, tex, buf, W, H); }); + }); + + std::vector pixels; + REQUIRE(readback.read_back_sync(g.gpu.instance, g.gpu.device, pixels) == WGPUMapAsyncStatus_Success); + REQUIRE(pixels.size() == alignedRow * H); + // pixel (0,0) = first RGBA8 texel = the clear color (1,0,0,1) -> 255,0,0,255 + REQUIRE(pixels[0] == 255); + REQUIRE(pixels[1] == 0); + REQUIRE(pixels[2] == 0); + REQUIRE(pixels[3] == 255); +} + +// the ping-pong pass shared by the history tests. .curr is a cull sink, so it survives without +// force_keep. `body` rides the arena exec slot, so it must be trivially destructible. +template +static void add_temporal_pass(RenderGraph* rg, HistoryTexture h, Body body) +{ + rg->add_pass( + "temporal", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(h.prev); + b.color(h.curr, 0); + }, + body); +} + +// over two frames the pair ping-pongs its two physical textures, and history_valid(.prev) is false on +// the first frame but true afterwards +TEST_CASE("RenderGraph exec - history ping-pongs and history_valid trips only after the first frame", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUTexture curr[2] = {}, prev[2] = {}; + bool validPrev[2] = {}; + + for (int f = 0; f < 2; ++f) { + WGPUTexture* co = &curr[f]; + WGPUTexture* po = &prev[f]; + bool* vo = &validPrev[f]; + g.frame([&](RenderGraph* rg) { + auto h = rg->create_history_texture("hist", tex2d()); + add_temporal_pass(rg, h, [h, co, po, vo](PassContext& ctx) { + *co = ctx.texture(h.curr); + *po = ctx.texture(h.prev); + *vo = ctx.history_valid(h); + }); + }); + } + + REQUIRE(curr[0] != nullptr); + REQUIRE(prev[0] != nullptr); + REQUIRE(curr[0] != prev[0]); // two distinct physical textures + REQUIRE(curr[1] == prev[0]); // ping-pong: last frame's curr is this frame's prev + REQUIRE(prev[1] == curr[0]); + REQUIRE(validPrev[0] == false); // frame 0: .prev never written + REQUIRE(validPrev[1] == true); // frame 1: .prev holds frame 0's .curr +} + +// a changed hash recreates the entry, zeroing .prev, so history_valid drops to false that frame +TEST_CASE("RenderGraph exec - a changed history hash invalidates .prev", "[RenderGraph][gpu]") +{ + ExecGraph g; + bool validPrev[3] = {}; + const uint64_t hashes[3] = { 111, 111, 222 }; // frame 2 changes the settings hash + + for (int f = 0; f < 3; ++f) { + bool* vo = &validPrev[f]; + const uint64_t hash = hashes[f]; + g.frame([&](RenderGraph* rg) { + auto h = rg->create_history_texture("hist", tex2d(), hash); + add_temporal_pass(rg, h, [h, vo](PassContext& ctx) { *vo = ctx.history_valid(h); }); + }); + } + + REQUIRE(validPrev[0] == false); // first use + REQUIRE(validPrev[1] == true); // steady state + REQUIRE(validPrev[2] == false); // hash change -> recreate -> prev invalid +} + +// an untouched entry is freed after kRetain frames, so its textures do not leak for the process +// lifetime once a feature goes inactive +TEST_CASE("RenderGraph exec - an untouched history entry is evicted after kRetain frames", "[RenderGraph][gpu]") +{ + ExecGraph g; + g.frame([&](RenderGraph* rg) { + auto h = rg->create_history_texture("hist", tex2d()); + add_temporal_pass(rg, h, [](PassContext&) {}); + }); + REQUIRE(list_count(g.allocator->pool.entries) == 1); + + for (uint32_t i = 0; i < PersistentResourcePool::kRetain; ++i) + g.frame([](RenderGraph*) {}); // empty graph: entry goes untouched + REQUIRE((g.allocator->pool.entries == nullptr)); +} + +// the entry holds the interned view, not an inline char[64], so a name past the old 63-char cap +// survives whole +TEST_CASE("RenderGraph exec - a pool entry keeps a long name in full", "[RenderGraph][gpu]") +{ + ExecGraph g; + const std::string longName(120, 'z'); + + g.frame([&](RenderGraph* rg) { + auto h = rg->create_history_texture(longName, tex2d()); + add_temporal_pass(rg, h, [](PassContext&) {}); + }); + + PersistentResourcePool::Entry* e = g.allocator->pool.entries; + REQUIRE(e != nullptr); + REQUIRE(e->name_view().length == 120); + REQUIRE(std::string_view(e->name_view().data, e->name_view().length) == longName); +} + +// the entry stores the canonical pointer, which find() keys on, so a re-declared name resolves to the +// same entry rather than a second one +TEST_CASE("RenderGraph exec - a re-declared name reuses its pool entry across frames", "[RenderGraph][gpu]") +{ + ExecGraph g; + auto record = [](RenderGraph* rg) { + auto h = rg->create_history_texture("hist.reused", tex2d()); + add_temporal_pass(rg, h, [](PassContext&) {}); + }; + + g.frame(record); + REQUIRE(list_count(g.allocator->pool.entries) == 1); + const char* firstName = g.allocator->pool.entries->name_view().data; + + g.frame(record); // same name next frame -> the same canonical pointer -> the same entry + REQUIRE(list_count(g.allocator->pool.entries) == 1); + REQUIRE(g.allocator->pool.entries->name_view().data == firstName); +} + +// --------------------------------------------------------------------------------------------------- +// TransientResourcePool, device-backed. drives acquire()/release_claims across real frames, where the +// tests above are device-free. + +using webgpu::rg::Internal::TransientResourcePool; + +// StoreOp_Store keeps it out of the memoryless path, so its usage bits are stable frame to frame +TEST_CASE("RenderGraph exec - a same-descriptor transient is reused across frames", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUTexture seen[2] = {}; + + for (int f = 0; f < 2; ++f) { + WGPUTexture* out = &seen[f]; + g.frame([&](RenderGraph* rg) { + auto tex = rg->create_transient_texture("t", tex2d()); + rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(tex, 0); + b.force_keep(); + }, + [tex, out](PassContext& ctx) { *out = ctx.texture(tex); }); + }); + } + + REQUIRE(seen[0] != nullptr); + REQUIRE(seen[0] == seen[1]); // same pooled object handed back + REQUIRE(list_count(g.allocator->transient.entries) == 1); // not reallocated +} + +// superset-reuse: frame 0 gives RenderAttachment|CopySrc, frame 1 asks for RenderAttachment alone +TEST_CASE("RenderGraph exec - a wider-usage pooled transient satisfies a narrower request", "[RenderGraph][gpu]") +{ + constexpr uint32_t W = 16, H = 16; + const uint32_t alignedRow = webgpu::rg::aligned_bytes_per_row(W, 4); + + ExecGraph g; + webgpu::raii::RawBuffer readback(g.gpu.device, WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead, alignedRow * H, "superset readback"); + WGPUTexture seen[2] = {}; + + // frame 0: color() + copy_texture_to_buffer -> usage RenderAttachment|CopySrc + g.frame([&](RenderGraph* rg) { + auto tex = rg->create_transient_texture("t", tex2d()); + auto buf = rg->import_buffer("rb", readback.handle()); + rg->add_pass( + "w", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, + [tex, out = &seen[0]](PassContext& ctx) { *out = ctx.texture(tex); }); + rg->add_pass( + "rb", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_texture_to_buffer(tex, buf); + b.force_keep(); + }, + [tex, buf](PassContext& ctx) { encode_readback(ctx, tex, buf, W, H); }); + }); + + // frame 1: color() only -> usage RenderAttachment (a subset) -> must reuse the wider pooled object + g.frame([&](RenderGraph* rg) { + auto tex = rg->create_transient_texture("t", tex2d()); + rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(tex, 0); + b.force_keep(); + }, + [tex, out = &seen[1]](PassContext& ctx) { *out = ctx.texture(tex); }); + }); + + REQUIRE(seen[0] != nullptr); + REQUIRE(seen[0] == seen[1]); // narrower request reused the wider object + REQUIRE(list_count(g.allocator->transient.entries) == 1); +} + +// an object unclaimed for kRetain frames is destroyed by end_frame +TEST_CASE("RenderGraph exec - an idle transient is evicted after kRetain frames", "[RenderGraph][gpu]") +{ + ExecGraph g; + g.frame([&](RenderGraph* rg) { + auto tex = rg->create_transient_texture("t", tex2d()); + rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(tex, 0); + b.force_keep(); + }, + [](PassContext&) {}); + }); + REQUIRE(list_count(g.allocator->transient.entries) == 1); + + for (uint32_t i = 0; i < TransientResourcePool::kRetain; ++i) + g.frame([](RenderGraph*) {}); // empty graph: object goes unclaimed + REQUIRE((g.allocator->transient.entries == nullptr)); +} + +// --------------------------------------------------------------------------------------------------- +// initialize() gating. an init pass is a cull root only while armed: once its target is baked with the +// matching hash compile() sets skipInit and drops it. the counter proves the body runs exactly on the +// frames the pass is armed. +TEST_CASE("RenderGraph exec - initialize() bakes once, skips while the hash holds, re-arms on change", "[RenderGraph][gpu]") +{ + ExecGraph g; + int bakes = 0; + int snap[3] = {}; + const uint64_t hashes[3] = { 7, 7, 9 }; // stable for two frames, then changes + + for (int f = 0; f < 3; ++f) { + const uint64_t hash = hashes[f]; + int* counter = &bakes; + g.frame( + [&](RenderGraph* rg) { + auto p = rg->create_persistent_texture("baked", tex2d()); + rg->add_pass( + "bake", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(p, 0); // declare the write + b.initialize(p, hash); // gate: run only when stale + b.force_keep(); // keep the armed bake (no same-frame reader) + }, + [counter](PassContext&) { (*counter)++; }); + }, + [&, f](RenderGraph*) { snap[f] = bakes; }); + } + + REQUIRE(snap[0] == 1); // frame 0: first bake + REQUIRE(snap[1] == 1); // frame 1: same hash -> skipInit, body did not run + REQUIRE(snap[2] == 2); // frame 2: hash changed -> re-armed +} + +// two graphs in one begin_frame/end_frame. release_resources() at the end of A's execute() frees its +// claim, so B's realize hands back the very same texture. the "execute order on one queue" contract. +TEST_CASE("RenderGraph exec - a second graph in one frame reuses the first graph's released transient", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUTexture a = nullptr, b = nullptr; + + auto record_one = [](RenderGraph* rg, WGPUTexture* out) { + auto tex = rg->create_transient_texture("t", tex2d()); + rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& bb) { + bb.color(tex, 0); + bb.force_keep(); + }, + [tex, out](PassContext& ctx) { *out = ctx.texture(tex); }); + }; + + begin_frame(g.allocator); + { + RenderGraph* rg = start_recording(g.allocator); + record_one(rg, &a); + g.submit_graph(rg); // execute() releases the transient claim on finish + } + { + RenderGraph* rg = start_recording(g.allocator); // same frame, same allocator + record_one(rg, &b); + g.submit_graph(rg); + } + g.wait_idle(); + end_frame(g.allocator); + + REQUIRE(a != nullptr); + REQUIRE(a == b); // graph B reused graph A's now-idle transient + REQUIRE(list_count(g.allocator->transient.entries) == 1); // one physical object served both graphs +} + +// the aging clock ticks once per begin_frame/end_frame, not per execute(): two graphs per frame here, +// yet the transient still survives exactly kRetain idle frames. release_claims() only frees claims for +// same-frame reuse, only end_frame() advances `frame`. +TEST_CASE("RenderGraph exec - transient eviction clock is per-frame, not per-graph", "[RenderGraph][gpu]") +{ + using webgpu::rg::Internal::TransientResourcePool; + ExecGraph g; + + // the filler below uses a different format, so it neither reuses this object nor trips the + // same-format supersede path, leaving only the kRetain idle clock to evict it + auto count_tracked = [&] { + size_t n = 0; + for (const TransientResourcePool::Entry* e = g.allocator->transient.entries; e; e = e->next) + if (e->kind == ResourceKind::Texture && e->sig.size.width == 16 && e->sig.format == WGPUTextureFormat_RGBA8Unorm) + ++n; + return n; + }; + + auto clear_transient = [](RenderGraph* rg, const char* id, uint32_t wh, WGPUTextureFormat fmt) { + TextureDesc d = tex2d(); + d.absolute = { wh, wh, 1 }; + d.format = fmt; + auto t = rg->create_transient_texture(id, d); + rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t, 0); + b.force_keep(); // nothing reads it; keep the pass so the transient is realized + }, + [](PassContext&) {}); + }; + + // frame 0: one graph creates the transient we track + g.frame([&](RenderGraph* rg) { clear_transient(rg, "tracked", 16, WGPUTextureFormat_RGBA8Unorm); }); + REQUIRE(count_tracked() == 1); + + // aging frames: TWO graphs each, on a distinct-format filler so nothing refreshes the tracked + // object's recency. per-execute aging would kill it twice as fast. + auto aging_frame = [&] { + begin_frame(g.allocator); + { RenderGraph* rg = start_recording(g.allocator); clear_transient(rg, "filler", 8, WGPUTextureFormat_R8Unorm); g.submit_graph(rg); } + { RenderGraph* rg = start_recording(g.allocator); clear_transient(rg, "filler", 8, WGPUTextureFormat_R8Unorm); g.submit_graph(rg); } + g.wait_idle(); + end_frame(g.allocator); + }; + + for (uint64_t i = 1; i < TransientResourcePool::kRetain; ++i) { + aging_frame(); + REQUIRE(count_tracked() == 1); // idle < kRetain frames -> retained despite 2 graphs/frame + } + aging_frame(); + REQUIRE(count_tracked() == 0); // idle == kRetain frames -> evicted +} + +// two graphs, one frame, same transient at different sizes. acquire() is exact-size create-on-miss and +// destruction defers to end_frame(), so B never gets A's wrong-sized object, both live through the +// frame, and both being used this frame keeps supersede from firing on either. +TEST_CASE("RenderGraph exec - two sizes used in one frame coexist and neither is evicted", "[RenderGraph][gpu]") +{ + using webgpu::rg::Internal::TransientResourcePool; + ExecGraph g; + WGPUTexture big = nullptr, small = nullptr; + + auto clear_sized = [](RenderGraph* rg, uint32_t wh, WGPUTexture* out) { + TextureDesc d = tex2d(); // same format; only the size differs between the two graphs + d.absolute = { wh, wh, 1 }; + auto t = rg->create_transient_texture("t", d); + rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t, 0); + b.force_keep(); + }, + [t, out](PassContext& ctx) { *out = ctx.texture(t); }); + }; + + auto count = [&](uint32_t w) { + size_t n = 0; + for (const TransientResourcePool::Entry* e = g.allocator->transient.entries; e; e = e->next) + if (e->kind == ResourceKind::Texture && e->sig.size.width == w) + ++n; + return n; + }; + + begin_frame(g.allocator); + { RenderGraph* rg = start_recording(g.allocator); clear_sized(rg, 16, &big); g.submit_graph(rg); } + { RenderGraph* rg = start_recording(g.allocator); clear_sized(rg, 8, &small); g.submit_graph(rg); } + g.wait_idle(); + + // mid-frame: both live at once, and B got a fresh 8x8 rather than A's 16x16 + REQUIRE(big != nullptr); + REQUIRE(small != nullptr); + REQUIRE(big != small); // exact-size match -> distinct objects, no wrong-size reuse + REQUIRE(list_count(g.allocator->transient.entries) == 2); // no mid-frame destruction + + end_frame(g.allocator); + + // both touched this frame -> superseded_by bails on lastUsedFrame >= frame + REQUIRE(count(16) == 1); + REQUIRE(count(8) == 1); +} + +// --------------------------------------------------------------------------------------------------- +// resize end to end. the device-free pool tests drive end_frame() on hand-built entries, these are the +// only proof the real realize -> acquire -> end_frame path resizes as designed, identity and all. + +// RGBA8 2D at a caller-chosen size, for the resize and relative-sizing tests +static TextureDesc tex2d_sized(uint32_t w, uint32_t h) +{ + TextureDesc d = tex2d(); + d.absolute = { w, h, 1 }; + return d; +} + +TEST_CASE("RenderGraph exec - a resize destroys the old-size transient", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUTexture before = nullptr, after = nullptr; + + // one transient, same name every frame: a screen-sized target across a window resize + auto frame_at = [](RenderGraph* rg, uint32_t w, WGPUTexture* out) { + auto t = rg->create_transient_texture("screen", tex2d_sized(w, w)); + rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t, 0); + b.force_keep(); + }, + [t, out](PassContext& ctx) { *out = ctx.texture(t); }); + }; + + uint32_t slotCount = 99; + g.frame([&](RenderGraph* rg) { frame_at(rg, 16, &before); }, + [&](RenderGraph* rg) { slotCount = storage(rg)->m_slotCount; }); + // a lone clear+store attachment is aliasing-eligible, so this rides an alias slot and the identity + // under test is PhysicalResource::identity. the per-resource arm is the alias-excluded test below. + REQUIRE(slotCount == 1); + + g.frame([&](RenderGraph* rg) { frame_at(rg, 16, &before); }); // steady state: pooled, not recreated + REQUIRE(list_count(g.allocator->transient.entries) == 1); + + g.frame([&](RenderGraph* rg) { frame_at(rg, 32, &after); }); + + // the 16x16 is gone the frame the 32x32 appeared, well inside kRetain, so only supersede took it + REQUIRE(after != nullptr); + REQUIRE(after != before); + REQUIRE(list_count(g.allocator->transient.entries) == 1); + REQUIRE(g.allocator->transient.entries->sig.size.width == 32); +} + +TEST_CASE("RenderGraph exec - a non-aliased transient resizes without leaking a generation", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUTexture before = nullptr, after = nullptr; + + // a mip>1 transient is excluded from aliasing, so this drives the per-resource arm, whose identity + // comes off the resource name. without it the supersede silently stops firing. + auto frame_at = [](RenderGraph* rg, uint32_t w, WGPUTexture* out) { + TextureDesc d = tex2d_sized(w, w); + d.mipLevelCount = 2; + auto t = rg->create_transient_texture("chain", d); + rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t, 0); + b.force_keep(); + }, + [t, out](PassContext& ctx) { *out = ctx.texture(t); }); + }; + + uint32_t slotCount = 99; + g.frame([&](RenderGraph* rg) { frame_at(rg, 16, &before); }, + [&](RenderGraph* rg) { slotCount = storage(rg)->m_slotCount; }); + REQUIRE(slotCount == 0); // not aliased -> the per-resource arm + + g.frame([&](RenderGraph* rg) { frame_at(rg, 32, &after); }); + + REQUIRE(after != before); + REQUIRE(list_count(g.allocator->transient.entries) == 1); + REQUIRE(g.allocator->transient.entries->sig.size.width == 32); +} + +TEST_CASE("RenderGraph exec - an aliased slot resizes without leaking a generation", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUTexture a = nullptr, b = nullptr; + + // two disjoint transients on one alias slot, resized. aliasing must not multiply generations. + auto frame_at = [](RenderGraph* rg, uint32_t w, WGPUTexture* o1, WGPUTexture* o2) { + auto t1 = rg->create_transient_texture("t1", tex2d_sized(w, w)); + auto t2 = rg->create_transient_texture("t2", tex2d_sized(w, w)); + rg->add_pass( + "k1", PassKind::Graphics, + [&](PassBuilder& bb) { + bb.color(t1, 0); + bb.force_keep(); + }, + [t1, o1](PassContext& ctx) { *o1 = ctx.texture(t1); }); + rg->add_pass( + "k2", PassKind::Graphics, + [&](PassBuilder& bb) { + bb.color(t2, 0); + bb.force_keep(); + }, + [t2, o2](PassContext& ctx) { *o2 = ctx.texture(t2); }); + }; + + uint32_t slotsBefore = 0; + g.frame([&](RenderGraph* rg) { frame_at(rg, 16, &a, &b); }, + [&](RenderGraph* rg) { slotsBefore = storage(rg)->m_slotCount; }); + REQUIRE(slotsBefore == 1); // both transients share one slot + REQUIRE(a == b); + REQUIRE(list_count(g.allocator->transient.entries) == 1); + + uint32_t slotsAfter = 0; + g.frame([&](RenderGraph* rg) { frame_at(rg, 32, &a, &b); }, + [&](RenderGraph* rg) { slotsAfter = storage(rg)->m_slotCount; }); + + REQUIRE(slotsAfter == 1); + REQUIRE(a == b); + REQUIRE(list_count(g.allocator->transient.entries) == 1); // the 16x16 slot object did not survive + REQUIRE(g.allocator->transient.entries->sig.size.width == 32); +} + +// =================================================================================================== +// gap-analysis additions. compile-only unless tagged [gpu]. relative sizing, history-layer validation, +// storage RMW, buffer/excluded aliasing, the buffer read access types, the initialized/history +// constructors. + +// declaring only slots 0 and 2 must make execute() emit a 3-entry array whose slot 1 is a null +// attachment. a bare render pass would not prove it: with no pipeline bound Dawn barely validates the +// color array. a pipeline writing @location(0) and @location(2) forces it to match 3 targets. +TEST_CASE("RenderGraph exec - a sparse color slot is emitted as a valid null attachment", "[RenderGraph][gpu]") +{ + ExecGraph g; + + static const char* kWgsl = R"( +@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var p = array(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0)); + return vec4f(p[i], 0.0, 1.0); +} +struct Out { + @location(0) a: vec4f, + @location(2) c: vec4f, +} +@fragment fn fs() -> Out { + return Out(vec4f(1.0, 0.0, 0.0, 1.0), vec4f(0.0, 0.0, 1.0, 1.0)); +} +)"; + WGPUShaderSourceWGSL wgsl {}; + wgsl.chain.sType = WGPUSType_ShaderSourceWGSL; + wgsl.code = WGPUStringView { .data = kWgsl, .length = WGPU_STRLEN }; + WGPUShaderModuleDescriptor smd {}; + smd.nextInChain = &wgsl.chain; + WGPUShaderModule sm = wgpuDeviceCreateShaderModule(g.gpu.device, &smd); + REQUIRE(sm != nullptr); + + // slot 1 is deliberately an unused target, so the pipeline expects a null attachment exactly where + // the graph must emit one + WGPUColorTargetState targets[3] {}; + targets[0].format = WGPUTextureFormat_RGBA8Unorm; + targets[0].writeMask = WGPUColorWriteMask_All; + targets[1].format = WGPUTextureFormat_Undefined; + targets[2].format = WGPUTextureFormat_RGBA8Unorm; + targets[2].writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState fs {}; + fs.module = sm; + fs.entryPoint = WGPUStringView { .data = "fs", .length = WGPU_STRLEN }; + fs.targetCount = 3; + fs.targets = targets; + + WGPURenderPipelineDescriptor rpd {}; + rpd.vertex.module = sm; + rpd.vertex.entryPoint = WGPUStringView { .data = "vs", .length = WGPU_STRLEN }; + rpd.primitive.topology = WGPUPrimitiveTopology_TriangleList; + rpd.multisample.count = 1; + rpd.multisample.mask = 0xFFFFFFFF; + rpd.fragment = &fs; + WGPURenderPipeline pipe = wgpuDeviceCreateRenderPipeline(g.gpu.device, &rpd); + REQUIRE(pipe != nullptr); + + wgpuDevicePushErrorScope(g.gpu.device, WGPUErrorFilter_Validation); + + g.frame([pipe](RenderGraph* rg) { + auto s0 = rg->create_transient_texture("slot0", tex2d_sized(16, 16)); + auto s2 = rg->create_transient_texture("slot2", tex2d_sized(16, 16)); + rg->add_pass( + "sparse", PassKind::Graphics, + [s0, s2](PassBuilder& b) { + b.color(s0, 0); + b.color(s2, 2); // slot 1 left unused -> null attachment, colorAttachmentCount == 3 + b.force_keep(); + }, + [pipe](PassContext& c) { + wgpuRenderPassEncoderSetPipeline(c.render_pass, pipe); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); + }); + + struct ScopeResult { + bool done = false; + bool failed = false; + std::string message; + } res; + WGPUPopErrorScopeCallbackInfo ci { + .nextInChain = nullptr, + .mode = WGPUCallbackMode_AllowProcessEvents, + .callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type, WGPUStringView msg, void* d, void*) { + ScopeResult* r = reinterpret_cast(d); + r->done = true; + r->failed = (type != WGPUErrorType_NoError); + if (msg.data) + r->message.assign(msg.data, msg.length); + }, + .userdata1 = &res, + .userdata2 = nullptr, + }; + WGPUFuture f = wgpuDevicePopErrorScope(g.gpu.device, ci); + WGPUFutureWaitInfo wi { .future = f, .completed = false }; + REQUIRE(wgpuInstanceWaitAny(g.gpu.instance, 1, &wi, 1000ull * 1000 * 1000) == WGPUWaitStatus_Success); + REQUIRE(res.done); + INFO(res.message); + REQUIRE_FALSE(res.failed); + + wgpuRenderPipelineRelease(pipe); + wgpuShaderModuleRelease(sm); +} + + +// a relativeTo child is width*scale, rounded not truncated. 10 x 0.25 = 2.5 -> 3. +TEST_CASE("RenderGraph - relative-sized transient rounds against its base", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + + TestGraph g; + auto base = g.rg->create_transient_texture("base", tex2d_sized(10, 10)); + + TextureDesc rd {}; + rd.dimension = WGPUTextureDimension_2D; + rd.format = WGPUTextureFormat_RGBA8Unorm; + rd.sizeKind = SizeKind::Relative; + rd.scaleX = 0.25f; + rd.scaleY = 0.25f; + rd.relativeTo = base; + rd.absolute = { 0, 0, 1 }; // width/height come from the base; depthOrArrayLayers stays 1 + auto child = g.rg->create_transient_texture("child", rd); + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(child, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + // 10 * 0.25 = 2.5 -> round to 3 (truncation would give 2) + REQUIRE(find_node(g.rg, child)->resolved.width == 3); + REQUIRE(find_node(g.rg, child)->resolved.height == 3); +} + +// a two-deep chain resolves recursively: 40 -> mid 0.5 -> leaf 0.5 +TEST_CASE("RenderGraph - relative-size chain resolves each level", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + + TestGraph g; + auto base = g.rg->create_transient_texture("base", tex2d_sized(40, 40)); + + auto half_of = [&](std::string_view id, TextureHandle parent) { + TextureDesc d {}; + d.dimension = WGPUTextureDimension_2D; + d.format = WGPUTextureFormat_RGBA8Unorm; + d.sizeKind = SizeKind::Relative; + d.scaleX = 0.5f; + d.scaleY = 0.5f; + d.relativeTo = parent; + d.absolute = { 0, 0, 1 }; + return g.rg->create_transient_texture(id, d); + }; + auto mid = half_of("mid", base); + auto leaf = half_of("leaf", mid); + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(leaf, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(find_node(g.rg, mid)->resolved.width == 20); + REQUIRE(find_node(g.rg, leaf)->resolved.width == 10); +} + +// --------------------------------------------------------------------------------------------------- +// render-pass descriptor validation. each is a rule WebGPU enforces on the descriptor execute() builds, +// caught here so compile() can name the pass and resources instead of leaving a device error that only +// describes the descriptor. each pass needs force_keep(), or culling drops it before the validator runs. + +static TextureDesc tex2d_fmt(WGPUTextureFormat fmt, uint32_t w = 16, uint32_t h = 16, uint32_t samples = 1) +{ + TextureDesc d {}; + d.dimension = WGPUTextureDimension_2D; + d.format = fmt; + d.absolute = { w, h, 1 }; + d.sampleCount = samples; + return d; +} + +TEST_CASE("compile - a Graphics pass with no attachments is rejected", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + // the writer must come first, or the read-before-write check returns before render-pass validation + g.rg->add_pass( + "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(tex); // reads only, so BeginRenderPass would have nothing to attach + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "Graphics pass with no attachments")); +} + +// depth-only is legal, it is what a shadow map looks like +TEST_CASE("compile - a depth-only Graphics pass is valid", "[RenderGraph]") +{ + TestGraph g; + auto depth = g.rg->create_transient_texture("shadow", tex2d_fmt(WGPUTextureFormat_Depth32Float)); + g.rg->add_pass( + "shadow", PassKind::Graphics, + [&](PassBuilder& b) { + b.depth_stencil(depth); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +TEST_CASE("compile - attachments of different sizes in one pass are rejected", "[RenderGraph]") +{ + TestGraph g; + auto full = g.rg->create_transient_texture("full", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16)); + auto half = g.rg->create_transient_texture("half", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 8, 8)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(full, 0); + b.color(half, 1); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "must be the same size")); +} + +TEST_CASE("compile - attachments of different sample counts in one pass are rejected", "[RenderGraph]") +{ + TestGraph g; + auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4)); + auto single = g.rg->create_transient_texture("single", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 1)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(msaa, 0); + b.color(single, 1); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "must have the same sample count")); +} + +TEST_CASE("compile - a well-formed MSAA resolve is valid", "[RenderGraph]") +{ + TestGraph g; + auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4)); + auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 1)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(msaa, 0); + b.resolve(msaa, target); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +TEST_CASE("compile - resolving a single-sampled source is rejected", "[RenderGraph]") +{ + TestGraph g; + auto src = g.rg->create_transient_texture("src", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 1)); + auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 1)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(src, 0); + b.resolve(src, target); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "must be multisampled")); +} + +TEST_CASE("compile - a multisampled resolve target is rejected", "[RenderGraph]") +{ + TestGraph g; + auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4)); + auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(msaa, 0); + b.resolve(msaa, target); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "must be single-sampled")); +} + +TEST_CASE("compile - a resolve target with a different format is rejected", "[RenderGraph]") +{ + TestGraph g; + auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4)); + auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_R32Float, 16, 16, 1)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(msaa, 0); + b.resolve(msaa, target); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "must have the same format")); +} + +// a resolve target is excluded from the shared-extent check, so the resolve arm must catch this +TEST_CASE("compile - a resolve target of a different size is rejected", "[RenderGraph]") +{ + TestGraph g; + auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4)); + auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 8, 8, 1)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(msaa, 0); + b.resolve(msaa, target); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "they must be the same size")); +} + +TEST_CASE("compile - a colour format bound as depth-stencil is rejected", "[RenderGraph]") +{ + TestGraph g; + auto notDepth = g.transient("notDepth"); // RGBA8Unorm + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.depth_stencil(notDepth); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "format is not a depth format")); +} + +TEST_CASE("compile - stencil ops on a format without a stencil aspect are rejected", "[RenderGraph]") +{ + TestGraph g; + auto depth = g.rg->create_transient_texture("depth", tex2d_fmt(WGPUTextureFormat_Depth32Float)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.depth_stencil(depth, { .stencilLoad = WGPULoadOp_Clear, .stencilStore = WGPUStoreOp_Store }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "has no stencil aspect")); +} + +// the inverse: WebGPU requires the ops when the format has a stencil aspect, and depth_stencil() leaves +// them Undefined, so the default is wrong for a combined format +TEST_CASE("compile - a stencil format without stencil ops is rejected", "[RenderGraph]") +{ + TestGraph g; + auto ds = g.rg->create_transient_texture("ds", tex2d_fmt(WGPUTextureFormat_Depth24PlusStencil8)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.depth_stencil(ds); // stencil ops default to Undefined + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "requires stencil load/store ops")); +} + +TEST_CASE("compile - a stencil format with stencil ops is valid", "[RenderGraph]") +{ + TestGraph g; + auto ds = g.rg->create_transient_texture("ds", tex2d_fmt(WGPUTextureFormat_Depth24PlusStencil8)); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.depth_stencil(ds, { .stencilLoad = WGPULoadOp_Clear, .stencilStore = WGPUStoreOp_Store }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +// execute() never sets depthSlice, which WebGPU requires for a 3D color target +TEST_CASE("compile - a 3D texture used as a render target is rejected", "[RenderGraph]") +{ + TestGraph g; + TextureDesc d = tex2d_fmt(WGPUTextureFormat_RGBA8Unorm); + d.dimension = WGPUTextureDimension_3D; + d.absolute = { 16, 16, 4 }; + auto vol = g.rg->create_transient_texture("vol", d); + g.rg->add_pass( + "draw", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(vol, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "3D texture used as a render target")); +} + +// --------------------------------------------------------------------------------------------------- +// copy_extent_* describes the declared subresource, not everything the texture holds. copy_*_info() +// offsets origin.z by baseLayer, so a whole-array extent would run the copy off the end. resolves from +// the node and access alone, so the pass is built by hand and no device is needed. + +static PassNode* pass_named(RenderGraph* rg, std::string_view name) +{ + for (PassNode* p = storage(rg)->m_passes; p; p = p->next) + if (std::string_view(p->id.name.data, p->id.name.length) == name) + return p; + return nullptr; +} + +// no comma in the name, Catch2 treats one as a filter separator +TEST_CASE("PassContext::copy_extent - an array copy covers one layer rather than the whole array", "[RenderGraph]") +{ + TestGraph g; + TextureDesc d = tex2d(); + d.absolute = { 16, 16, 4 }; // 4 array layers + auto src = g.rg->create_transient_texture("src", d); + auto dst = g.rg->create_transient_texture("dst", d); + + g.rg->add_pass( + "produce", PassKind::Graphics, + [&](PassBuilder& b) { b.color(src, 0, { .sub = { .layer = 2 } }); }, [](PassContext&) {}); + g.rg->add_pass( + "copy", PassKind::Transfer, + [&](PassBuilder& b) { + b.copy_texture(src, dst, { .layer = 2 }, { .layer = 1 }); // layer 2 -> layer 1, one layer + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + + PassNode* copyPass = pass_named(g.rg, "copy"); + REQUIRE(copyPass != nullptr); + Internal::PassContextAccess access(g.rg, copyPass); + PassContext& ctx = access.ctx; + REQUIRE(ctx.copy_extent_src(src).depthOrArrayLayers == 1); // not 4 + REQUIRE(ctx.copy_extent_dst(dst).depthOrArrayLayers == 1); + REQUIRE(ctx.copy_extent_src(src).width == 16); +} + +// 3D depth slices halve with the mip like width and height. only an import can carry both, since +// validate_texture_desc holds graph-created 3D textures to one mip. +TEST_CASE("PassContext::copy_extent - a 3D copy shrinks its depth with the mip", "[RenderGraph]") +{ + TestGraph g; + auto import3d = [&](std::string_view id) { + return g.rg->import_texture(id, { .view = (WGPUTextureView)0x1, .size = { 16, 16, 8 }, .format = WGPUTextureFormat_RGBA8Unorm, .mipCount = 2, .dimension = WGPUTextureDimension_3D }); + }; + auto src = import3d("src3d"); + auto dst = import3d("dst3d"); + + g.rg->add_pass( + "copy", PassKind::Transfer, [&](PassBuilder& b) { b.copy_texture(src, dst, { .mip = 1 }, { .mip = 1 }); }, [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + + PassNode* copyPass = pass_named(g.rg, "copy"); + REQUIRE(copyPass != nullptr); + Internal::PassContextAccess access(g.rg, copyPass); + PassContext& ctx = access.ctx; + WGPUExtent3D e = ctx.copy_extent_src(src); + REQUIRE(e.width == 8); // 16 >> 1 + REQUIRE(e.height == 8); + REQUIRE(e.depthOrArrayLayers == 4); // 8 >> 1, not the base depth of 8 +} + +// --------------------------------------------------------------------------------------------------- +// zero extents. a 0-sized texture fails device validation with no link back to the descriptor, so +// compile() names it first. rounding down to 0 clamps instead, it happens legitimately mid-resize. + +// a child small enough to round to 0 clamps to 1 rather than failing the frame +TEST_CASE("RenderGraph - a relative size that rounds to zero clamps to 1", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + + TestGraph g; + auto base = g.rg->create_transient_texture("base", tex2d_sized(1, 1)); + + TextureDesc rd {}; + rd.dimension = WGPUTextureDimension_2D; + rd.format = WGPUTextureFormat_RGBA8Unorm; + rd.sizeKind = SizeKind::Relative; + rd.scaleX = 0.25f; // 1 * 0.25 = 0.25 -> rounds to 0 + rd.scaleY = 0.25f; + rd.relativeTo = base; + rd.absolute = { 0, 0, 1 }; + auto child = g.rg->create_transient_texture("child", rd); + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(base, 0); + b.color(child, 1); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(find_node(g.rg, child)->resolved.width == 1); + REQUIRE(find_node(g.rg, child)->resolved.height == 1); +} + +// an absolute extent left at its default is an authoring error, not a rounding artifact +TEST_CASE("RenderGraph - a live texture with a zero absolute extent is rejected", "[RenderGraph]") +{ + TestGraph g; + // the declare-time assert covers this in a debug build, so reach the compile backstop the same + // white-box way the cyclic relativeTo test does + auto zero = g.rg->create_transient_texture("zero", tex2d()); + Internal::find_node(g.rg, zero)->absolute = { 0, 0, 1 }; + Internal::find_node(g.rg, zero)->resolved = {}; + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(zero, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "\"zero\" resolves to a zero extent (0 x 0)")); +} + +// the minimized-window case. the error must name the import, not the relative children, which clamp to +// 1 and are not themselves wrong. the height is deliberately non-zero: were the memo guard to test width +// alone this would fall through to the Absolute arm and report 0 x 0, since an import never fills +// `absolute`. +TEST_CASE("RenderGraph - a zero-sized import is rejected and its children are not", "[RenderGraph]") +{ + TestGraph g; + auto surface = g.rg->import_texture("surface", { .view = (WGPUTextureView)0x1, .size = { 0, 16, 1 }, .format = WGPUTextureFormat_RGBA8Unorm }); + + TextureDesc rd {}; + rd.dimension = WGPUTextureDimension_2D; + rd.format = WGPUTextureFormat_RGBA8Unorm; + rd.sizeKind = SizeKind::Relative; + rd.scaleX = 0.5f; + rd.scaleY = 0.5f; + rd.relativeTo = surface; + rd.absolute = { 0, 0, 1 }; + auto child = g.rg->create_transient_texture("half", rd); + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(child, 0); + b.color(surface, 1); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "\"surface\" resolves to a zero extent (0 x 16)")); // not 0 x 0 + REQUIRE_FALSE(error_mentions(g.rg, "\"half\" resolves to a zero extent")); +} + +// relative with no relativeTo would clamp to a silent 1x1, so it is reported instead +TEST_CASE("RenderGraph - a relative size with no relativeTo is rejected", "[RenderGraph]") +{ + TestGraph g; + auto orphan = g.rg->create_transient_texture("orphan", tex2d()); + Internal::find_node(g.rg, orphan)->sizeKind = SizeKind::Relative; // never names a base + Internal::find_node(g.rg, orphan)->resolved = {}; + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(orphan, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "names no resource to size against")); +} + +// depthOrArrayLayers == 0 means one layer everywhere else, so the resolver normalizes rather than +// treating it as a zero extent +TEST_CASE("RenderGraph - a zero depthOrArrayLayers normalizes to one layer", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + + TestGraph g; + TextureDesc d = tex2d(); + d.absolute = { 16, 16, 0 }; + auto tex = g.rg->create_transient_texture("tex", d); + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(tex, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(find_node(g.rg, tex)->resolved.depthOrArrayLayers == 1); +} + +// release-only: validate_texture_desc asserts both at the create call, and in debug the abort IS the +// contract. these reach the release backstop through the public API rather than by patching the node. +#ifdef QT_NO_DEBUG +TEST_CASE("RenderGraph - a zero absolute extent is caught through the public API", "[RenderGraph]") +{ + TestGraph g; + TextureDesc d = tex2d(); + d.absolute = { 0, 0, 1 }; + auto zero = g.rg->create_transient_texture("zero", d); + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(zero, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "resolves to a zero extent")); +} +#endif + +#ifdef QT_NO_DEBUG +TEST_CASE("RenderGraph - a missing relativeTo is caught through the public API", "[RenderGraph]") +{ + TestGraph g; + TextureDesc d = tex2d(); + d.sizeKind = SizeKind::Relative; // relativeTo left at {} + auto orphan = g.rg->create_transient_texture("orphan", d); + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(orphan, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "names no resource to size against")); +} +#endif + +// only live textures are created, so a zero-sized one nothing touches must not fail the frame +TEST_CASE("RenderGraph - an unused zero-sized texture compiles clean", "[RenderGraph]") +{ + TestGraph g; + auto used = g.transient("used"); + auto dead = g.rg->create_transient_texture("dead", tex2d()); + Internal::find_node(g.rg, dead)->absolute = { 0, 0, 1 }; // declared, never accessed + Internal::find_node(g.rg, dead)->resolved = {}; + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(used, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +// writing .curr while nothing reads .prev culls the layers asymmetrically, which is unrealizable +TEST_CASE("RenderGraph - history with a curr writer but no prev reader is an error", "[RenderGraph]") +{ + TestGraph g; + auto hist = g.rg->create_history_texture("hist", tex2d()); + + g.rg->add_pass( + "writeCurr", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(hist.curr, 0); // .curr used, .prev never read + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "read .prev and write .curr in one pass")); +} + +// only .curr is writable +TEST_CASE("RenderGraph - writing a history .prev layer is an error", "[RenderGraph]") +{ + TestGraph g; + auto hist = g.rg->create_history_texture("hist", tex2d()); + + g.rg->add_pass( + "writePrev", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(hist.prev, 0); // layer 1 is read-only this frame + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "only layer 0, the .curr handle, is writable")); +} + +// the same-pass write cannot seed the read, dispatch invocations are unordered +TEST_CASE("RenderGraph - storage_read_write on an unproduced transient is an error", "[RenderGraph]") +{ + TestGraph g; + auto buf = g.transient_buffer("rmw"); + + g.rg->add_pass( + "rmw", PassKind::Compute, + [&](PassBuilder& b) { + b.storage_read_write(buf); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + // the read-modify-write diagnosis, not "no pass ever writes": a writer exists, it just cannot seed + // this read + REQUIRE(error_mentions(g.rg, "can't seed the read")); +} + +// legal once an earlier pass produced the buffer +TEST_CASE("RenderGraph - storage_read_write after a producer compiles clean", "[RenderGraph]") +{ + TestGraph g; + auto buf = g.transient_buffer("rmw"); + + add_buffer_producer(g.rg, buf); // storage_write producer, satisfies the RMW read + g.rg->add_pass( + "rmw", PassKind::Compute, + [&](PassBuilder& b) { + b.storage_read_write(buf); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(idx_of(pass_order(g.rg), "produce") < idx_of(pass_order(g.rg), "rmw")); +} + +// the isBuf path the texture alias tests never reach. storage_write fully defines. +TEST_CASE("RenderGraph - disjoint transient buffers share one physical slot", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + using webgpu::rg::Internal::ResourceNode; + + TestGraph g; + auto b1 = g.transient_buffer("b1", 64); + auto b2 = g.transient_buffer("b2", 64); + + g.rg->add_pass( + "k1", PassKind::Compute, + [&](PassBuilder& b) { + b.storage_write(b1); + b.force_keep(); + }, + [](PassContext&) {}); + g.rg->add_pass( + "k2", PassKind::Compute, + [&](PassBuilder& b) { + b.storage_write(b2); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile(true) == nullptr); + REQUIRE(storage(g.rg)->m_slotCount == 1); + ResourceNode* r1 = find_node(g.rg, b1); + ResourceNode* r2 = find_node(g.rg, b2); + REQUIRE(r1->aliasSlot != ResourceNode::kNoSlot); + REQUIRE(r1->aliasSlot == r2->aliasSlot); +} + +// mip-chain transients are excluded, a slot's default view would not fit +TEST_CASE("RenderGraph - mip-chain transients are excluded from aliasing", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + using webgpu::rg::Internal::ResourceNode; + + TestGraph g; + auto t1 = g.transient("t1", /*mipLevelCount*/ 2); + auto t2 = g.transient("t2", /*mipLevelCount*/ 2); + + g.rg->add_pass( + "k1", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t1, 0); + b.force_keep(); + }, + [](PassContext&) {}); + g.rg->add_pass( + "k2", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t2, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile(true) == nullptr); + REQUIRE(storage(g.rg)->m_slotCount == 0); + REQUIRE(find_node(g.rg, t1)->aliasSlot == ResourceNode::kNoSlot); + REQUIRE(find_node(g.rg, t2)->aliasSlot == ResourceNode::kNoSlot); +} + +// a non-Clear first touch does not fully define the storage, so it is ineligible even though a pass +// writes it +TEST_CASE("RenderGraph - a load-first transient is excluded from aliasing", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + using webgpu::rg::Internal::ResourceNode; + + TestGraph g; + auto t1 = g.transient("t1"); + auto t2 = g.transient("t2"); + + g.rg->add_pass( + "k1", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t1, 0, { .load = WGPULoadOp_Load }); // load-first: not a full define + b.force_keep(); + }, + [](PassContext&) {}); + g.rg->add_pass( + "k2", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t2, 0, { .load = WGPULoadOp_Load }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile(true) == nullptr); + REQUIRE(storage(g.rg)->m_slotCount == 0); + REQUIRE(find_node(g.rg, t1)->aliasSlot == ResourceNode::kNoSlot); + REQUIRE(find_node(g.rg, t2)->aliasSlot == ResourceNode::kNoSlot); +} + +// the depth analogue of the color clear+discard test +TEST_CASE("RenderGraph - single clear+discard depth attachment is inferred transient", "[RenderGraph]") +{ + TestGraph g; + TextureDesc dd {}; + dd.dimension = WGPUTextureDimension_2D; + dd.format = WGPUTextureFormat_Depth32Float; + dd.absolute = { 16, 16, 1 }; + auto ds = g.rg->create_transient_texture("ds", dd); + + g.rg->add_pass( + "depth", PassKind::Graphics, + [&](PassBuilder& b) { + b.depth_stencil(ds, { .store = WGPUStoreOp_Discard }); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(storage(g.rg)->transientCount == 1); +} + +// uniform/vertex/index/indirect accumulate their usage onto the node. read against persistent buffers, +// which are external, so no read-before-write. +TEST_CASE("RenderGraph - buffer read accesses accumulate their usage bits", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + + TestGraph g; + BufferDesc bd {}; + bd.size = 64; + auto ubo = g.rg->create_persistent_buffer("ubo", bd); + auto vbo = g.rg->create_persistent_buffer("vbo", bd); + auto ibo = g.rg->create_persistent_buffer("ibo", bd); + auto indirect = g.rg->create_persistent_buffer("indirect", bd); + auto target = g.transient("target"); // vertex/index/indirect are graphics inputs, so this + // stays a Graphics pass and needs a real attachment + + g.rg->add_pass( + "read", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(target, 0); + b.uniform(ubo); + b.vertex_buffer(vbo); + b.index_buffer(ibo); + b.indirect_buffer(indirect); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE((find_node(g.rg, ubo)->bufUsage & WGPUBufferUsage_Uniform) != 0); + REQUIRE((find_node(g.rg, vbo)->bufUsage & WGPUBufferUsage_Vertex) != 0); + REQUIRE((find_node(g.rg, ibo)->bufUsage & WGPUBufferUsage_Index) != 0); + REQUIRE((find_node(g.rg, indirect)->bufUsage & WGPUBufferUsage_Indirect) != 0); +} + +// the rewrite shares no edge with the reader except the WAR one, so reader-before-rewrite proves it +// fired +TEST_CASE("RenderGraph - write-after-read orders the rewriter after the reader", "[RenderGraph]") +{ + TestGraph g; + auto sink = import_tex(g.rg, "sink"); + auto x = g.transient("x"); + + g.rg->add_pass( + "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(x, 0); }, [](PassContext&) {}); + g.rg->add_pass( + "read", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(x); + b.color(sink, 0, { .load = WGPULoadOp_Load }); + }, + [](PassContext&) {}); + g.rg->add_pass( // clobbers x's version the reader still uses -> WAR edge onto "read" + "rewrite", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(x, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + auto order = pass_order(g.rg); + REQUIRE(idx_of(order, "read") < idx_of(order, "rewrite")); +} + +// initialize() targets must be pool-backed +TEST_CASE("RenderGraph - initialize() on a transient target is an error", "[RenderGraph]") +{ + TestGraph g; + auto tex = g.transient("tex"); + + g.rg->add_pass( + "bake", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(tex, 0); // the required write + b.initialize(tex); // ... but tex is transient, not persistent + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() != nullptr); + REQUIRE(error_mentions(g.rg, "must be persistent or history")); +} + +// the ping-pong pattern for a GPU-authored buffer. .curr is a cull sink and .prev is external, so it +// compiles clean. +TEST_CASE("RenderGraph - history buffer read-prev/write-curr compiles clean", "[RenderGraph]") +{ + TestGraph g; + BufferDesc bd {}; + bd.size = 64; + auto h = g.rg->create_history_buffer("hbuf", bd); + + g.rg->add_pass( + "temporal", PassKind::Compute, + [&](PassBuilder& b) { + b.storage_read(h.prev); + b.storage_write(h.curr); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(pass_order(g.rg).size() == 1); +} + +// synthesizes an initialize()'d bake pass, which a reader pulls in as a dependency +TEST_CASE("RenderGraph - create_initialized_texture bakes a pass a reader can consume", "[RenderGraph]") +{ + TestGraph g; + auto fallback = g.rg->create_initialized_texture("fallback", tex2d(), { 0, 0, 0, 1 }); + auto out = g.transient("out"); + + g.rg->add_pass( + "use", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(fallback); + b.color(out, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(pass_order(g.rg).size() == 2); // the synthesized bake + the reader + REQUIRE(idx_of(pass_order(g.rg), "fallback") != -1); // single layer keeps the bare id, no suffix +} + +// one pass per layer, named ".layer" so debug groups and profiler series stay distinct. a missing +// suffix would not merely blur labels, assert_unique_id rejects duplicate pass ids. +TEST_CASE("RenderGraph - create_initialized_texture names one bake pass per layer", "[RenderGraph]") +{ + TestGraph g; + TextureDesc d = tex2d(); + d.absolute = { 16, 16, 3 }; + auto fallback = g.rg->create_initialized_texture("fallback", d, { 0, 0, 0, 1 }); + auto out = g.transient("out"); + + g.rg->add_pass( + "use", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(fallback, ViewRange { .mipCount = 1, .layerCount = 3, .dim = WGPUTextureViewDimension_2DArray }); + b.color(out, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + + std::vector order = pass_order(g.rg); + REQUIRE(order.size() == 4); // three bakes + the reader + REQUIRE(idx_of(order, "fallback.layer0") != -1); + REQUIRE(idx_of(order, "fallback.layer1") != -1); + REQUIRE(idx_of(order, "fallback.layer2") != -1); + REQUIRE(idx_of(order, "fallback") == -1); // suffixed, not bare +} + +// the suffixed name is sized from the id, not a fixed buffer, so a long id comes through whole +TEST_CASE("RenderGraph - a long multi-layer init id is not truncated", "[RenderGraph]") +{ + TestGraph g; + const std::string longId(200, 'y'); + TextureDesc d = tex2d(); + d.absolute = { 16, 16, 2 }; + auto fallback = g.rg->create_initialized_texture(longId, d, { 0, 0, 0, 1 }); + auto out = g.transient("out"); + + g.rg->add_pass( + "use", PassKind::Graphics, + [&](PassBuilder& b) { + b.sampled(fallback, ViewRange { .mipCount = 1, .layerCount = 2, .dim = WGPUTextureViewDimension_2DArray }); + b.color(out, 0); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + + std::vector order = pass_order(g.rg); + REQUIRE(idx_of(order, (longId + ".layer0").c_str()) != -1); + REQUIRE(idx_of(order, (longId + ".layer1").c_str()) != -1); +} + +// synthesizes a host_write + initialize() bake pass, which a uniform reader pulls in +TEST_CASE("RenderGraph - create_initialized_buffer bakes a pass a reader can consume", "[RenderGraph]") +{ + TestGraph g; + BufferDesc bd {}; + bd.size = 64; + auto fallback = g.rg->create_initialized_buffer("fallback", bd, nullptr); + + g.rg->add_pass( + "use", PassKind::Compute, + [&](PassBuilder& b) { + b.uniform(fallback); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(pass_order(g.rg).size() == 2); // the synthesized upload + the reader +} + +// a memoryless transient has no storage to pack, so it is excluded from aliasing. the third exclusion +// branch, alongside the mip-chain and load-first tests. +TEST_CASE("RenderGraph - a memoryless attachment is excluded from aliasing", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + using webgpu::rg::Internal::ResourceNode; + + TestGraph g; + auto t = g.transient("t"); + + g.rg->add_pass( + "w", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(t, 0, { .store = WGPUStoreOp_Discard }); // clear+discard -> memoryless + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile(true) == nullptr); // aliasing on: a plain clear+store transient here would be eligible + REQUIRE(storage(g.rg)->transientCount == 1); // inferred memoryless + REQUIRE(storage(g.rg)->m_slotCount == 0); // ... so no alias slot was opened for it + REQUIRE(find_node(g.rg, t)->aliasSlot == ResourceNode::kNoSlot); +} + +// the read side of a storage-texture binding, where the suite only exercised storage_write +TEST_CASE("RenderGraph - storage_read on a texture accumulates StorageBinding", "[RenderGraph]") +{ + using webgpu::rg::Internal::find_node; + + TestGraph g; + auto tex = g.transient("tex"); + + g.rg->add_pass( + "produce", PassKind::Compute, [&](PassBuilder& b) { b.storage_write(tex); }, [](PassContext&) {}); + g.rg->add_pass( + "read", PassKind::Compute, + [&](PassBuilder& b) { + b.storage_read(tex); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE((find_node(g.rg, tex)->texUsage & WGPUTextureUsage_StorageBinding) != 0); +} + +// end_pass records one InitTarget per call, and phase 0 arms the whole pass while any target is stale +TEST_CASE("RenderGraph - initialize() records multiple targets in one pass", "[RenderGraph]") +{ + TestGraph g; + auto p1 = g.rg->create_persistent_texture("p1", tex2d()); + auto p2 = g.rg->create_persistent_texture("p2", tex2d()); + + g.rg->add_pass( + "bake", PassKind::Graphics, + [&](PassBuilder& b) { + b.color(p1, 0); // MRT: both targets written + b.color(p2, 1); + b.initialize(p1); + b.initialize(p2); + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); + REQUIRE(pass_order(g.rg).size() == 1); // armed on the first frame, not skipped + REQUIRE(storage(g.rg)->m_passes->initCount == 2); // both targets recorded +} + +// the compile tests above only check slot bookkeeping, this proves the shared object reaches execute() +TEST_CASE("RenderGraph exec - disjoint transients realize onto the same physical texture", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUTexture a = nullptr, b = nullptr; + + g.frame([&](RenderGraph* rg) { + auto t1 = rg->create_transient_texture("t1", tex2d()); + auto t2 = rg->create_transient_texture("t2", tex2d()); + // t1 lives only in k1, t2 only in k2 -> disjoint intervals -> alias onto one slot + rg->add_pass( + "k1", PassKind::Graphics, + [&](PassBuilder& bb) { + bb.color(t1, 0); + bb.force_keep(); + }, + [t1, out = &a](PassContext& ctx) { *out = ctx.texture(t1); }); + rg->add_pass( + "k2", PassKind::Graphics, + [&](PassBuilder& bb) { + bb.color(t2, 0); + bb.force_keep(); + }, + [t2, out = &b](PassContext& ctx) { *out = ctx.texture(t2); }); + }); + + REQUIRE(a != nullptr); + REQUIRE(a == b); // both aliased transients got the one pooled physical texture + REQUIRE(list_count(g.allocator->transient.entries) == 1); // a single object served both +} + +// --------------------------------------------------------------------------------------------------- +// PassContext resolvers. the suite above asserts on the compiled graph with empty bodies, these run +// bodies and assert on what ctx hands back, the half a real renderer touches. + +// two passes read one texture through different shapes, so they must not get the same view. also pins +// the resolve_view cache: asking twice in one body returns one object. +TEST_CASE("RenderGraph exec - ctx.view hands back the shape the access declared", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUTextureView cubeView = nullptr, cubeAgain = nullptr, plainView = nullptr; + + g.frame([&](RenderGraph* rg) { + TextureDesc d = tex2d(); + d.absolute = { 16, 16, 6 }; // 6 layers: samplable as a cube, or as a plain 2D layer + auto env = rg->create_transient_texture("env", d); + // each reader needs its own attachment, and unlike the compile-only cube tests this executes + auto outCube = rg->create_transient_texture("outCube", tex2d()); + auto outPlain = rg->create_transient_texture("outPlain", tex2d()); + + // one layer per pass, six RGBA8 attachments would exceed maxColorAttachmentBytesPerSample + static const char* kFaceNames[6] = { "face0", "face1", "face2", "face3", "face4", "face5" }; + for (uint32_t l = 0; l < 6; ++l) + rg->add_pass( + kFaceNames[l], PassKind::Graphics, + [env, l](PassBuilder& b) { b.color(env, 0, { .clear = { 0, 0, 0, 1 }, .sub = { .layer = l } }); }, + [](PassContext&) {}); + rg->add_pass( + "readCube", PassKind::Graphics, + [env, outCube](PassBuilder& b) { + b.sampled(env, cube()); + b.color(outCube, 0); + b.force_keep(); + }, + [env, cv = &cubeView, ca = &cubeAgain](PassContext& ctx) { + *cv = ctx.view(env); + *ca = ctx.view(env); + }); + rg->add_pass( + "readPlain", PassKind::Graphics, + [env, outPlain](PassBuilder& b) { + b.sampled(env); // default ViewRange: one layer, 2D + b.color(outPlain, 0); + b.force_keep(); + }, + [env, pv = &plainView](PassContext& ctx) { *pv = ctx.view(env); }); + }); + + REQUIRE(cubeView != nullptr); + REQUIRE(plainView != nullptr); + REQUIRE(cubeView == cubeAgain); // one cached view per shape, not one per call + REQUIRE(cubeView != plainView); // the declared range, not the texture, decides the view +} + +// a blit sampling mip N-1 while rendering mip N gets two different views of the one texture +TEST_CASE("RenderGraph exec - ctx.view(mip, layer) resolves per subresource", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUTextureView srcMip = nullptr, dstMip = nullptr; + + g.frame([&](RenderGraph* rg) { + TextureDesc d = tex2d(); + d.mipLevelCount = 4; + auto chain = rg->create_transient_texture("chain", d); + + rg->add_pass( + "mip0", PassKind::Graphics, + [chain](PassBuilder& b) { b.color(chain, 0); }, + [](PassContext&) {}); + rg->add_pass( + "mip1", PassKind::Graphics, + [chain](PassBuilder& b) { + b.sampled(chain); // read mip 0 + b.color(chain, 0, { .sub = { .mip = 1 } }); // write mip 1 + b.force_keep(); + }, + [chain, s = &srcMip, dm = &dstMip](PassContext& ctx) { + *s = ctx.view(chain, 0, 0); + *dm = ctx.view(chain, 1, 0); + }); + }); + + REQUIRE(srcMip != nullptr); + REQUIRE(dstMip != nullptr); + REQUIRE(srcMip != dstMip); // one view per subresource, not one per texture +} + +// size 0 means the rest of the src, expanded at declare time, so the body sees a resolved non-zero size +TEST_CASE("RenderGraph exec - ctx.buffer_copy_info reports the resolved range", "[RenderGraph][gpu]") +{ + ExecGraph g; + PassContext::BufferCopyInfo info {}; + + g.frame([&](RenderGraph* rg) { + BufferDesc bd {}; + bd.size = 64; + auto src = rg->create_transient_buffer("src", bd); + auto dst = rg->create_transient_buffer("dst", bd); + + add_buffer_producer(rg, src); + rg->add_pass( + "copy", PassKind::Transfer, + [src, dst](PassBuilder& b) { + b.copy_buffer(src, dst, /*srcOffset*/ 16, /*dstOffset*/ 8, /*size*/ 0); // -> 64 - 16 = 48 + b.force_keep(); + }, + [src, dst, out = &info](PassContext& ctx) { *out = ctx.buffer_copy_info(src, dst); }); + }); + + REQUIRE(info.size == 48); // expanded against the src, not left at 0 + REQUIRE(info.srcOffset == 16); + REQUIRE(info.dstOffset == 8); + REQUIRE(info.src != nullptr); + REQUIRE(info.dst != nullptr); + REQUIRE(info.src != info.dst); +} + +// a mip halves a 3D texture's depth but never an array's layer count. texture_size answers from the +// declaration alone, so neither import needs realizing. +TEST_CASE("RenderGraph exec - ctx.texture_size shifts 3D depth but not array layers", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUExtent3D vol0 {}, vol1 {}, arr0 {}, arr1 {}; + + g.frame([&](RenderGraph* rg) { + auto vol = rg->import_texture("vol", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 8 }, .format = WGPUTextureFormat_RGBA8Unorm, .mipCount = 4, .dimension = WGPUTextureDimension_3D }); + auto arr = rg->import_texture("arr", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 8 }, .format = WGPUTextureFormat_RGBA8Unorm, .mipCount = 4 }); + + auto t = rg->create_transient_texture("probe", tex2d()); + rg->add_pass( + "probe", PassKind::Graphics, + [t](PassBuilder& b) { + b.color(t, 0); + b.force_keep(); + }, + [vol, arr, v0 = &vol0, v1 = &vol1, a0 = &arr0, a1 = &arr1](PassContext& ctx) { + *v0 = ctx.texture_size(vol, 0); + *v1 = ctx.texture_size(vol, 1); + *a0 = ctx.texture_size(arr, 0); + *a1 = ctx.texture_size(arr, 1); + }); + }); + + REQUIRE(vol0.depthOrArrayLayers == 8); + REQUIRE(vol1.width == 8); + REQUIRE(vol1.height == 8); + REQUIRE(vol1.depthOrArrayLayers == 4); // 3D: depth is a spatial axis, it halves + + REQUIRE(arr0.depthOrArrayLayers == 8); + REQUIRE(arr1.width == 8); + REQUIRE(arr1.height == 8); + REQUIRE(arr1.depthOrArrayLayers == 8); // 2D array: every layer keeps its own mip chain +} + +// a body uploads through the forwarded queue, a later copy moves it to an imported mappable buffer, the +// host reads it back. nothing else in the suite runs a host_write body or touches ctx.queue. +TEST_CASE("RenderGraph exec - a host_write body uploads through ctx.queue", "[RenderGraph][gpu]") +{ + constexpr uint32_t kMagic = 0xABCD1234u; + + ExecGraph g; + webgpu::raii::RawBuffer readback(g.gpu.device, WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead, 4, "hostwrite readback"); + + g.frame([&](RenderGraph* rg) { + BufferDesc bd {}; + bd.size = 4; + auto staged = rg->create_transient_buffer("staged", bd); + auto out = rg->import_buffer("hostwrite.readback", readback.handle()); + + rg->add_pass( + "upload", PassKind::Transfer, [staged](PassBuilder& b) { b.host_write(staged); }, + [staged, magic = kMagic](PassContext& ctx) { + wgpuQueueWriteBuffer(ctx.queue, ctx.buffer(staged), 0, &magic, sizeof(magic)); + }); + rg->add_pass( + "copy", PassKind::Transfer, + [staged, out](PassBuilder& b) { b.copy_buffer(staged, out, 0, 0, 4); }, + [staged, out](PassContext& ctx) { + auto info = ctx.buffer_copy_info(staged, out); + wgpuCommandEncoderCopyBufferToBuffer(ctx.encoder, info.src, info.srcOffset, info.dst, info.dstOffset, info.size); + }); + }); + + std::vector bytes; + REQUIRE(readback.read_back_sync(g.gpu.instance, g.gpu.device, bytes) == WGPUMapAsyncStatus_Success); + REQUIRE(bytes.size() == 4); + uint32_t got = 0; + std::memcpy(&got, bytes.data(), sizeof(got)); + REQUIRE(got == kMagic); // the queue write landed and the copy carried it +} + +// --------------------------------------------------------------------------------------------------- +// import_buffer and PassContext::bind. import_buffer takes no BufferDesc, so unlike every other buffer +// factory its size is queried from the imported object rather than declared. + +TEST_CASE("RenderGraph exec - an imported buffer reports the size queried from the buffer", "[RenderGraph][gpu]") +{ + constexpr uint64_t kSize = 256; + + ExecGraph g; + webgpu::raii::RawBuffer owned(g.gpu.device, WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst, kSize, "imported"); + + uint64_t reported = 0; + WGPUBuffer resolved = nullptr; + WGPUBindGroupEntry entry {}; + + g.frame([&](RenderGraph* rg) { + auto imported = rg->import_buffer("imported", owned.handle()); + rg->add_pass( + "read", PassKind::Compute, + [imported](PassBuilder& b) { + b.storage_read(imported); // an import needs no writer, so this alone is a legal graph + b.force_keep(); + }, + [imported, sz = &reported, buf = &resolved, e = &entry](PassContext& ctx) { + *sz = ctx.buffer_size(imported); + *buf = ctx.buffer(imported); + *e = ctx.bind(7, imported); + }); + }); + + REQUIRE(reported == kSize); // wgpuBufferGetSize, not a declared BufferDesc + REQUIRE(resolved == owned.handle()); // the caller's buffer, not a pooled copy + + // the buffer arm of bind(): whole, at offset 0, per the PassContext contract + REQUIRE(entry.binding == 7); + REQUIRE(entry.buffer == resolved); + REQUIRE(entry.offset == 0); + REQUIRE(entry.size == kSize); + REQUIRE(entry.textureView == nullptr); +} + +// a declared BufferRange is the shape ctx.bind() hands back, same contract as ViewRange for textures +TEST_CASE("RenderGraph exec - bind applies the declared BufferRange", "[RenderGraph][gpu]") +{ + ExecGraph g; + webgpu::raii::RawBuffer owned(g.gpu.device, WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst, 512, "imported"); + + WGPUBindGroupEntry explicitRange {}; + WGPUBindGroupEntry restRange {}; + + g.frame([&](RenderGraph* rg) { + auto imported = rg->import_buffer("imported", owned.handle()); + rg->add_pass( + "explicit", PassKind::Compute, + [imported](PassBuilder& b) { + b.storage_read(imported, { .offset = 256, .size = 128 }); + b.force_keep(); + }, + [imported, e = &explicitRange](PassContext& ctx) { *e = ctx.bind(0, imported); }); + }); + g.frame([&](RenderGraph* rg) { + auto imported = rg->import_buffer("imported", owned.handle()); + rg->add_pass( + "rest", PassKind::Compute, + [imported](PassBuilder& b) { + b.storage_read(imported, { .offset = 256 }); // size 0 = all remaining + b.force_keep(); + }, + [imported, e = &restRange](PassContext& ctx) { *e = ctx.bind(0, imported); }); + }); + + REQUIRE(explicitRange.offset == 256); + REQUIRE(explicitRange.size == 128); + REQUIRE(restRange.offset == 256); + REQUIRE(restRange.size == 256); // resolved at declare time against the queried size +} + +// the queried size is not merely reported back: it is the bound compile() range-checks copies against, +// which is the only thing standing between an oversized copy and a device error. +TEST_CASE("compile - a copy past an imported buffer's queried size is rejected", "[RenderGraph][gpu]") +{ + ExecGraph g; + webgpu::raii::RawBuffer owned(g.gpu.device, WGPUBufferUsage_CopyDst, 64, "small"); + + begin_frame(g.allocator); + RenderGraph* rg = start_recording(g.allocator); + + BufferDesc bd {}; + bd.size = 128; + auto src = rg->create_transient_buffer("src", bd); + auto dst = rg->import_buffer("dst", owned.handle()); + + add_buffer_producer(rg, src); + rg->add_pass( + "copy", PassKind::Transfer, + [src, dst](PassBuilder& b) { + b.copy_buffer(src, dst, 0, 0, 128); // the src fits, the imported dst does not + b.force_keep(); + }, + [](PassContext&) {}); + + REQUIRE(rg->compile() != nullptr); + REQUIRE(error_mentions(rg, "covers bytes [0, 128) but the buffer is 64 byte(s)")); +} + +// bind(binding, ResourceHandle) switches on the handle's kind, so a texture must fill the view arm and +// leave the buffer fields at zero. a wrong arm here is a silently invalid bind group. +TEST_CASE("RenderGraph exec - bind on a texture handle fills the view arm", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUBindGroupEntry entry {}; + WGPUTextureView direct = nullptr; + + g.frame([&](RenderGraph* rg) { + auto tex = rg->create_transient_texture("tex", tex2d()); + auto out = rg->create_transient_texture("out", tex2d()); + + rg->add_pass( + "produce", PassKind::Graphics, [tex](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {}); + rg->add_pass( + "read", PassKind::Graphics, + [tex, out](PassBuilder& b) { + b.sampled(tex); + b.color(out, 0); + b.force_keep(); + }, + [tex, e = &entry, d = &direct](PassContext& ctx) { + *e = ctx.bind(3, tex); // two args, so this is the ResourceHandle overload + *d = ctx.view(tex); + }); + }); + + REQUIRE(entry.binding == 3); + REQUIRE(entry.textureView != nullptr); + REQUIRE(entry.textureView == direct); // the same view the declared access resolves to, not a fresh one + REQUIRE(entry.buffer == nullptr); + REQUIRE(entry.size == 0); +} + +// the subresource overload must forward to view(mip, layer). binding the whole-texture view instead +// would compile and run, just sample the wrong level. +TEST_CASE("RenderGraph exec - bind(mip, layer) carries the per-subresource view", "[RenderGraph][gpu]") +{ + ExecGraph g; + WGPUBindGroupEntry mip0 {}, mip1 {}; + + g.frame([&](RenderGraph* rg) { + TextureDesc d = tex2d(); + d.mipLevelCount = 4; + auto chain = rg->create_transient_texture("chain", d); + + rg->add_pass( + "mip0", PassKind::Graphics, [chain](PassBuilder& b) { b.color(chain, 0); }, [](PassContext&) {}); + rg->add_pass( + "mip1", PassKind::Graphics, + [chain](PassBuilder& b) { + b.sampled(chain); // mip 0 + b.color(chain, 0, { .sub = { .mip = 1 } }); // mip 1 + b.force_keep(); + }, + [chain, a = &mip0, b = &mip1](PassContext& ctx) { + *a = ctx.bind(0, chain, 0, 0); + *b = ctx.bind(1, chain, 1, 0); + }); + }); + + REQUIRE(mip0.binding == 0); + REQUIRE(mip1.binding == 1); + REQUIRE(mip0.textureView != nullptr); + REQUIRE(mip1.textureView != nullptr); + REQUIRE(mip0.textureView != mip1.textureView); // one view per subresource + REQUIRE(mip0.buffer == nullptr); + REQUIRE(mip1.buffer == nullptr); +} + +// ctx.view() branches on whether the import supplied a backing texture, not on `imported` itself. +// view-only: the caller owns the view and the graph hands it back untouched, so a declared ViewRange +// selects nothing. texture-backed: the graph builds the view from the range like any owned texture. +// docs/rendergraph.md states both halves, in the Import section and the IBL example. +TEST_CASE("execute - a view-only import returns the registered view, a texture-backed one builds its own", "[RenderGraph]") +{ + ExecGraph g; + + WGPUTextureDescriptor td {}; + td.dimension = WGPUTextureDimension_2D; + td.format = WGPUTextureFormat_RGBA8Unorm; + td.size = { 16, 16, 6 }; // 6 layers, so a cube view over it is well formed + td.mipLevelCount = 1; + td.sampleCount = 1; + td.usage = WGPUTextureUsage_TextureBinding; + WGPUTexture tex = wgpuDeviceCreateTexture(g.gpu.device, &td); + REQUIRE(tex != nullptr); + + WGPUTextureViewDescriptor vd {}; + vd.format = WGPUTextureFormat_RGBA8Unorm; + vd.dimension = WGPUTextureViewDimension_Cube; + vd.mipLevelCount = 1; + vd.arrayLayerCount = 6; + vd.aspect = WGPUTextureAspect_All; + WGPUTextureView callerView = wgpuTextureCreateView(tex, &vd); + REQUIRE(callerView != nullptr); + + WGPUTextureView seenViewOnly = nullptr; + WGPUTextureView seenBacked = nullptr; + + g.frame([&](RenderGraph* rg) { + // no .texture: the graph has nothing to build a view from + auto viewOnly = rg->import_texture( + "ibl.env.view_only", { .view = callerView, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm }); + // same view registered, but the backing texture comes along too + auto backed = rg->import_texture("ibl.env.backed", + { .view = callerView, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm, .texture = tex }); + + rg->add_pass( + "read", PassKind::Compute, + [viewOnly, backed](PassBuilder& b) { + // a narrowing range on both, to prove only one of them acts on it. base stays 0 on the + // view-only side: a non-zero base there is an assert, not a silently ignored range. + b.sampled(viewOnly, ViewRange { .layerCount = 1 }); + // a layer the caller's whole-cube view cannot be, so an equal result would be a real match + b.sampled(backed, ViewRange { .baseLayer = 2, .layerCount = 1 }); + b.force_keep(); + }, + [viewOnly, backed, a = &seenViewOnly, b = &seenBacked](PassContext& c) { + *a = c.view(viewOnly); + *b = c.view(backed); + }); + }); + + REQUIRE(seenViewOnly == callerView); // handed back as registered, the 1-layer range ignored + REQUIRE(seenBacked != nullptr); + REQUIRE(seenBacked != callerView); // built by the graph, layer 2 alone, not the caller's whole cube + + wgpuTextureViewRelease(callerView); + wgpuTextureRelease(tex); +} + +// The remaining worked examples from docs/rendergraph.md, transcribed to their graph-level calls with +// app-side pipelines and draws stubbed. Each proves the example is a valid graph program and catches +// API drift like the color() signature change. Bodies stop at declaration; compile() is the check. + +TEST_CASE("compile - doc example: deferred shading, G-buffer plus compute lighting", "[RenderGraph]") +{ + TestGraph g; + const WGPUExtent3D size { 64, 64, 1 }; + auto desc = [&](WGPUTextureFormat fmt) { + TextureDesc d {}; + d.dimension = WGPUTextureDimension_2D; + d.format = fmt; + d.absolute = size; + return d; + }; + auto albedo = g.rg->create_transient_texture("gbuffer.albedo", desc(WGPUTextureFormat_RGBA8Unorm)); + auto normal = g.rg->create_transient_texture("gbuffer.normal", desc(WGPUTextureFormat_RGBA16Float)); + auto depth = g.rg->create_transient_texture("gbuffer.depth", desc(WGPUTextureFormat_Depth32Float)); + auto lit = g.rg->create_transient_texture("lit.color", desc(WGPUTextureFormat_RGBA16Float)); + + g.rg->add_pass( + "GBuffer", PassKind::Graphics, + [albedo, normal, depth](PassBuilder& b) { + b.color(albedo, 0, { .clear = { 0, 0, 0, 0 } }); + b.color(normal, 1, { .clear = { 0, 0, 0, 0 } }); + b.depth_stencil(depth, { .clearDepth = 0.0f }); // reverse-Z + }, + [](PassContext&) {}); + + g.rg->add_pass( + "Lighting", PassKind::Compute, + [albedo, normal, depth, lit](PassBuilder& b) { + b.sampled(albedo); + b.sampled(normal); + b.sampled(depth); // depth-only format, no aspect needed + b.storage_write(lit); // ordered after GBuffer by the reads above + b.force_keep(); // stands in for the tonemap/composite consumer the example returns lit to + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); +} + +TEST_CASE("compile - doc example: bloom, relative-sized downsample chain", "[RenderGraph]") +{ + TestGraph g; + TextureDesc fullDesc {}; + fullDesc.dimension = WGPUTextureDimension_2D; + fullDesc.format = WGPUTextureFormat_RGBA16Float; + fullDesc.absolute = { 128, 128, 1 }; + auto hdrColor = g.rg->create_transient_texture("hdr.color", fullDesc); + + // the example assumes hdrColor already exists; give it a producer + g.rg->add_pass( + "scene", PassKind::Graphics, [hdrColor](PassBuilder& b) { b.color(hdrColor, 0); }, [](PassContext&) {}); + + TextureDesc halfDesc {}; + halfDesc.dimension = WGPUTextureDimension_2D; + halfDesc.format = WGPUTextureFormat_RGBA16Float; + halfDesc.sizeKind = SizeKind::Relative; + halfDesc.scaleX = 0.5f; + halfDesc.scaleY = 0.5f; + halfDesc.relativeTo = hdrColor; + auto bright = g.rg->create_transient_texture("bloom.bright", halfDesc); + auto blurH = g.rg->create_transient_texture("bloom.blur_h", halfDesc); + auto blurV = g.rg->create_transient_texture("bloom.blur_v", halfDesc); + + auto blit = [&](const char* name, TextureHandle src, TextureHandle dst) { + g.rg->add_pass( + name, PassKind::Graphics, + [src, dst](PassBuilder& b) { + b.sampled(src); + b.color(dst, 0); + }, + [](PassContext&) {}); + }; + blit("bloom.threshold", hdrColor, bright); + blit("bloom.blurH", bright, blurH); + blit("bloom.blurV", blurH, blurV); + + g.rg->add_pass( + "bloom.composite", PassKind::Graphics, + [hdrColor, blurV](PassBuilder& b) { + b.color(hdrColor, 0, { .load = WGPULoadOp_Load }); // load, do not clear + b.sampled(blurV); + b.force_keep(); // stands in for the swapchain sink downstream + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); // Relative sizing resolves against hdrColor +} + +TEST_CASE("compile - doc example: TAA, history plus a camera-cut hash", "[RenderGraph]") +{ + TestGraph g; + TextureDesc colorDesc {}; + colorDesc.dimension = WGPUTextureDimension_2D; + colorDesc.format = WGPUTextureFormat_RGBA16Float; + colorDesc.absolute = { 64, 64, 1 }; + + auto currentColor = g.rg->create_transient_texture("current.color", colorDesc); + auto velocity = g.rg->create_transient_texture("velocity", colorDesc); + + g.rg->add_pass( + "scene", PassKind::Graphics, + [currentColor, velocity](PassBuilder& b) { + b.color(currentColor, 0); + b.color(velocity, 1); + }, + [](PassContext&) {}); + + uint64_t cut = 5; // changes on teleport, projection swap, etc. + auto taa = g.rg->create_history_texture("taa.color", colorDesc, cut); + + g.rg->add_pass( + "TAA", PassKind::Graphics, + [currentColor, velocity, taa](PassBuilder& b) { + b.sampled(currentColor); + b.sampled(velocity); + b.sampled(taa.prev); // last frame's result + b.color(taa.curr, 0); // this frame's result, becomes next frame's prev + }, + [](PassContext&) {}); + + REQUIRE(g.rg->compile() == nullptr); // writing taa.curr is a sink, so no force_keep needed +} diff --git a/webgpu/base/CMakeLists.txt b/webgpu/base/CMakeLists.txt index ac966abd2..30e9d6844 100644 --- a/webgpu/base/CMakeLists.txt +++ b/webgpu/base/CMakeLists.txt @@ -61,6 +61,8 @@ set(SOURCES RenderResourceRegistry.h RenderResourceRegistry.cpp gpu_utils.h gpu_utils.cpp wgpu_string.h + RenderGraph.h RenderGraph.cpp RenderGraph_internal.h + webgpu_interface.hpp webgpu_interface.cpp) add_library(webgpu STATIC ${SOURCES}) diff --git a/webgpu/base/RenderGraph.cpp b/webgpu/base/RenderGraph.cpp new file mode 100644 index 000000000..9a864d3ff --- /dev/null +++ b/webgpu/base/RenderGraph.cpp @@ -0,0 +1,3562 @@ +/***************************************************************************** + * Copyright (C) 2026 Matthias Huerbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + + +#include "RenderGraph.h" +#include "RenderGraph_internal.h" +#include "gpu_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef __EMSCRIPTEN__ +#include +// on WebGPU the usage flag is the only sign of support, so probe for the constant +EM_JS(int, rg_web_has_transient_attachment, (), { return (typeof GPUTextureUsage !== 'undefined' && ('TRANSIENT_ATTACHMENT' in GPUTextureUsage)) ? 1 : 0; }); +#endif + + +template +static void list_append(T** head, T** tail, T* newNode) +{ + if (*tail) + (*tail)->next = newNode; + else + *head = newNode; + *tail = newNode; +} + +namespace webgpu::rg { + +using namespace Internal; + +namespace Internal { + +size_t sv_length(WGPUStringView s) { return (s.length == WGPU_STRLEN) ? (s.data ? std::strlen(s.data) : 0) : s.length; } + +bool sv_eq(WGPUStringView a, WGPUStringView b) +{ + size_t na = sv_length(a), nb = sv_length(b); + return na == nb && (na == 0 || std::memcmp(a.data, b.data, na) == 0); +} + +WGPUStringView group_prefix(WGPUStringView name) +{ + size_t n = sv_length(name); + for (size_t i = 0; i < n; ++i) + if (name.data[i] == '.') + return WGPUStringView { .data = name.data, .length = i }; + return WGPUStringView {}; +} + +Subview* SubviewPool::alloc() +{ + if (freeList) { + Subview* s = freeList; + freeList = s->next; + return s; + } + return arena->make(); +} + +void SubviewPool::recycle(Subview*& head) +{ + while (Subview* s = head) { + head = s->next; + if (s->view) + wgpuTextureViewRelease(s->view); + s->view = nullptr; + s->next = freeList; + freeList = s; + } +} + +WGPUTextureView subview_for(SubviewPool& pool, Subview*& head, WGPUTexture tex, const ViewKey& k) +{ + for (Subview* s = head; s; s = s->next) + if (s->key == k) + return s->view; + Subview* s = pool.alloc(); + s->key = k; + WGPUTextureViewDescriptor d = k.desc(); + s->view = wgpuTextureCreateView(tex, &d); + s->next = head; // prepend + head = s; + return s->view; +} + +PersistentResourcePool::Entry* PersistentResourcePool::alloc_entry() +{ + if (freeList) { + Entry* e = freeList; + freeList = e->next; + *e = Entry {}; + return e; + } + return arena->make(); +} + +PersistentResourcePool::Entry* PersistentResourcePool::find(ResourceId id) +{ + for (Entry* e = entries; e; e = e->next) + if (e->idValue == id.value && e->name.data == id.name.data) // same data storage so the same data ptr + return e; + return nullptr; +} + +PersistentResourcePool::Entry* PersistentResourcePool::touch(ResourceId id, uint32_t layers, uint64_t hash, ResourceKind kind) +{ + if (Entry* e = find(id)) { + if (e->lastTouched == evictClock) // same resource already touched + return e; + if (e->kind != kind || e->layers != layers) + destroy(e); + e->prevTouched = e->lastTouched; + ++e->frame; + e->lastTouched = evictClock; + e->layers = layers; + e->kind = kind; + if (history_hash_forces_destroy(*e, hash)) { + destroy(e); + e->historyHash = hash; + } + return e; + } + Entry* e = alloc_entry(); + e->name = id.name; + e->idValue = id.value; + e->lastTouched = evictClock; + e->prevTouched = evictClock; + e->layers = layers; + e->kind = kind; + e->historyHash = hash; + e->next = entries; + entries = e; + return e; +} + +uint32_t PersistentResourcePool::slot(const Entry& e, uint32_t layerIndex) const +{ + return (uint32_t)((e.frame + layerIndex) % e.layers); +} + +bool PersistentResourcePool::tex_entry_would_recreate(const Entry& e, const TexSignature& sig, WGPUTextureUsage usage) +{ + return !e.created || !(e.sig == sig) || (usage & ~e.usageAtCreate) != 0; +} + +bool PersistentResourcePool::buf_entry_would_recreate(const Entry& e, uint64_t size, WGPUBufferUsage usage) +{ + return !e.created || e.bufferSize != size || (usage & ~e.bufUsageAtCreate) != 0; +} + +bool PersistentResourcePool::history_hash_forces_destroy(const Entry& e, uint64_t hash) +{ + return hash != 0 && e.historyHash != hash; +} + +void PersistentResourcePool::realize_texture_entry(Entry* e, WGPUDevice device, const TexSignature& sig) +{ + if (!tex_entry_would_recreate(*e, sig, e->usage)) + return; + + destroy(e); + e->sig = sig; + e->usageAtCreate = e->usage; + for (uint32_t i = 0; i < e->layers; ++i) + { + WGPUTextureDescriptor d { + .label = e->name_view(), + .usage = e->usage, + .dimension = sig.dim, + .size = sig.size, + .format = sig.format, + .mipLevelCount = sig.mipLevelCount, + .sampleCount = sig.sampleCount, + }; + e->tex[i] = wgpuDeviceCreateTexture(device, &d); + e->view[i] = wgpuTextureCreateView(e->tex[i], nullptr); + } + e->created = true; + e->createdClock = evictClock; +} + +void PersistentResourcePool::realize_buffer_entry(Entry* e, WGPUDevice device, uint64_t size) +{ + if (!buf_entry_would_recreate(*e, size, e->bufUsage)) + return; + destroy(e); + e->bufferSize = size; + e->bufUsageAtCreate = e->bufUsage; + for (uint32_t i = 0; i < e->layers; ++i) + { + WGPUBufferDescriptor d { + .label = e->name_view(), + .usage = e->bufUsage, + .size = size, + }; + e->buf[i] = wgpuDeviceCreateBuffer(device, &d); + } + e->created = true; + e->createdClock = evictClock; +} + +void PersistentResourcePool::destroy(Entry* e) +{ + for (uint32_t i = 0; i < kLayers; ++i) + { + subviewPool->recycle(e->subviews[i]); + if (e->view[i]) { + wgpuTextureViewRelease(e->view[i]); + e->view[i] = nullptr; + } + if (e->tex[i]) { + wgpuTextureRelease(e->tex[i]); + e->tex[i] = nullptr; + } + if (e->buf[i]) { + wgpuBufferRelease(e->buf[i]); + e->buf[i] = nullptr; + } + } + e->created = false; + e->baked = false; +} + +void PersistentResourcePool::end_frame() +{ + for (Entry** pp = &entries; *pp;) + { + Entry* e = *pp; + if (evictClock - e->lastTouched >= kRetain) + { + destroy(e); + *pp = e->next; + e->next = freeList; // recycle + freeList = e; + } else { + pp = &e->next; + } + } + ++evictClock; +} + +void PersistentResourcePool::destroy_all() +{ + for (Entry* e = entries; e; e = e->next) + destroy(e); + entries = nullptr; + freeList = nullptr; +} + +PersistentResourcePool::~PersistentResourcePool() +{ + destroy_all(); +} + +void TransientResourcePool::log_event(Event event, const Entry& e) +{ + eventLog[eventCount++ % kLog] = { + frame, event, e.kind, e.sig.size, e.sig.format, e.bufferSize + }; +} + +void TransientResourcePool::log_reset() { eventCount = 0; } + +static constexpr bool transient_usage_satisfies(WGPUFlags have, WGPUFlags want, WGPUFlags exact) +{ + return (have & want) == want && (have & exact) == (want & exact); +} + +TransientResourcePool::Entry* TransientResourcePool::alloc_entry() +{ + // recycle + if (freeList) { + Entry* e = freeList; + freeList = e->next; + *e = Entry {}; + return e; + } + return arena->make(); +} + +void TransientResourcePool::acquire(WGPUDevice device, const TexSignature& sig, WGPUTextureUsage usage, const void* identity, WGPUTexture& outTex, WGPUTextureView& outView) +{ + for (Entry* e = entries; e; e = e->next) + { + if (e->kind == ResourceKind::Texture && !e->inUse && e->sig == sig + && transient_usage_satisfies(e->usage, usage, WGPUTextureUsage_TransientAttachment)) + { + e->inUse = true; + e->lastUsedFrame = frame; + e->identity = identity; + outTex = e->tex; + outView = e->view; + return; + } + } + Entry* e = alloc_entry(); + e->kind = ResourceKind::Texture; + e->sig = sig; + e->usage = usage; + e->identity = identity; + WGPUTextureDescriptor d { + .usage = usage, + .dimension = sig.dim, + .size = sig.size, + .format = sig.format, + .mipLevelCount = sig.mipLevelCount, + .sampleCount = sig.sampleCount, + }; + e->tex = wgpuDeviceCreateTexture(device, &d); + e->view = wgpuTextureCreateView(e->tex, nullptr); + e->inUse = true; + e->lastUsedFrame = frame; + e->createdFrame = frame; + e->next = entries; + entries = e; + ++createdThisFrame; + log_event(Event::Create, *e); + outTex = e->tex; + outView = e->view; +} + +void TransientResourcePool::acquire(WGPUDevice device, uint64_t size, WGPUBufferUsage usage, const void* identity, WGPUBuffer& outBuf) +{ + for (Entry* e = entries; e; e = e->next) + { + if (e->kind == ResourceKind::Buffer && !e->inUse && e->bufferSize == size + && transient_usage_satisfies(e->bufUsage, usage, /*exact*/ 0)) + { + e->inUse = true; + e->lastUsedFrame = frame; + e->identity = identity; // last claimant, see the texture overload + outBuf = e->buf; + return; + } + } + Entry* e = alloc_entry(); + e->kind = ResourceKind::Buffer; + e->bufferSize = size; + e->bufUsage = usage; + e->identity = identity; + WGPUBufferDescriptor d { .usage = usage, .size = size }; + e->buf = wgpuDeviceCreateBuffer(device, &d); + e->inUse = true; + e->lastUsedFrame = frame; + e->createdFrame = frame; + e->next = entries; + entries = e; + ++createdThisFrame; + log_event(Event::Create, *e); + outBuf = e->buf; +} + +void TransientResourcePool::release_claims() +{ + for (Entry* e = entries; e; e = e->next) + e->inUse = false; +} + +bool TransientResourcePool::superseded_by(const Entry& e, const Entry& s, uint64_t frame) +{ + if (e.kind != s.kind) // supersede compares like-for-like + return false; + if (e.lastUsedFrame >= frame) // claimed this frame -> still live, not superseded + return false; + if (s.createdFrame != frame) // s must be this frame's replacement + return false; + // same logical resource, or it is not a resize. the descriptor alone cannot tell a resize from an + // unrelated resource of another size, and evicting on the latter churns without bound. + if (!e.identity || e.identity != s.identity) + return false; + + if (e.kind == ResourceKind::Texture) { + // s must cover e under the same rule acquire reuses by. superset, so a resize may add usage bits, + // but a differently-purposed idle sibling still cannot supersede. + if (!transient_usage_satisfies(s.usage, e.usage, WGPUTextureUsage_TransientAttachment)) + return false; + // same signature EXCEPT the extent + if (s.sig.format != e.sig.format || s.sig.dim != e.sig.dim + || s.sig.mipLevelCount != e.sig.mipLevelCount || s.sig.sampleCount != e.sig.sampleCount) + return false; + return !extent_eq(s.sig.size, e.sig.size); // identical but for the extent + } + + if (!transient_usage_satisfies(s.bufUsage, e.bufUsage, /*exact*/ 0)) + return false; + return s.bufferSize != e.bufferSize; +} + +void TransientResourcePool::end_frame() +{ + for (Entry** pp = &entries; *pp;) { + Entry* e = *pp; + bool superseded = false; + + if (createdThisFrame && !e->inUse) { + for (Entry* s = entries; s; s = s->next) + if (superseded_by(*e, *s, frame)) { + superseded = true; + break; + } + } + if (superseded || frame - e->lastUsedFrame >= kRetain) { + log_event(Event::Evict, *e); + destroy(e); + *pp = e->next; + e->next = freeList; // recycle + freeList = e; + } else { + pp = &e->next; + } + } + createdThisFrame = 0; + ++frame; +} + +void TransientResourcePool::destroy(Entry* e) +{ + subviewPool->recycle(e->subviews); + if (e->view) { + wgpuTextureViewRelease(e->view); + e->view = nullptr; + } + if (e->tex) { + wgpuTextureRelease(e->tex); + e->tex = nullptr; + } + if (e->buf) { + wgpuBufferRelease(e->buf); + e->buf = nullptr; + } +} + +void TransientResourcePool::destroy_all() +{ + for (Entry* e = entries; e; e = e->next) + destroy(e); + entries = nullptr; + freeList = nullptr; +} + +TransientResourcePool::~TransientResourcePool() +{ + destroy_all(); +} + +void GpuProfiler::clear_history() +{ + seriesCount = 0; + historyHead = 0; + historyLen = 0; + lastSampledId = 0; +} + +void GpuProfiler::sample_history() +{ + if (!recording || resultId == lastSampledId) + return; + lastSampledId = resultId; + for (uint32_t s = 0; s < seriesCount; ++s) + series[s].v[historyHead] = 0.0f; + for (uint32_t i = 0; i < resultCount; ++i) + { + uint32_t occ = 0; + for (uint32_t j = 0; j < i; ++j) + if (resultNames[j].data == resultNames[i].data) + ++occ; + uint32_t s = 0; + for (uint32_t seen = 0; s < seriesCount; ++s) + if (series[s].name.data == resultNames[i].data && seen++ == occ) + break; + if (s == seriesCount) { + if (seriesCount >= kMaxPasses) + continue; + series[s] = {}; + series[s].name = resultNames[i]; + ++seriesCount; + } + series[s].v[historyHead] = resultUs[i]; + } + historyHead = (historyHead + 1) % kHistory; + if (historyLen < kHistory) + ++historyLen; +} + +void GpuProfiler::init(WGPUDevice device) +{ + if (initialized) + return; + const uint64_t bytes = uint64_t(2) * kMaxPasses * sizeof(uint64_t); + WGPUQuerySetDescriptor qd { .type = WGPUQueryType_Timestamp, .count = 2 * kMaxPasses }; + querySet = wgpuDeviceCreateQuerySet(device, &qd); + WGPUBufferDescriptor rd { .usage = WGPUBufferUsage_QueryResolve | WGPUBufferUsage_CopySrc, .size = bytes }; + resolveBuf = wgpuDeviceCreateBuffer(device, &rd); + for (Slot& s : ring) { + WGPUBufferDescriptor bd { .usage = WGPUBufferUsage_MapRead | WGPUBufferUsage_CopyDst, .size = bytes }; + s.buf = wgpuDeviceCreateBuffer(device, &bd); + } + initialized = true; +} + +int GpuProfiler::free_slot() const +{ + for (uint32_t i = 0; i < kRing; ++i) + if (!ring[i].pending) + return (int)i; + return -1; +} + +Arena::ArenaBlock* Arena::new_block(size_t minPayload) +{ + const size_t maxAlign = alignof(std::max_align_t); + const size_t payloadBytes = minPayload < blockSize ? blockSize : minPayload; + const size_t raw = sizeof(ArenaBlock) + maxAlign + payloadBytes; // +maxAlign covers the round-up + ArenaBlock* b = static_cast(std::malloc(raw)); + if (!b) + return nullptr; + uint8_t* start = reinterpret_cast(b) + sizeof(ArenaBlock); + b->payload = reinterpret_cast(align_up(reinterpret_cast(start), maxAlign)); + b->capacity = payloadBytes; + b->used = 0; + b->next = nullptr; + totalCapacity += payloadBytes; + ++blockCount; + return b; +} + +void Arena::reserve() +{ + if (!current) + head = current = new_block(blockSize); +} + +size_t Arena::live_used() const { return liveBytes; } + +void* Arena::alloc_raw(size_t size, size_t align) +{ + if (!current) + { + head = current = new_block(size + align); + if (!current) + oom(size); + } + + size_t off = align_up(current->used, align); + if (off + size > current->capacity) + { + if (current->next && size + align <= current->next->capacity) { + current = current->next; + liveBytes -= current->used; + current->used = 0; + } else { + ArenaBlock* b = new_block(size + align); + if (!b) + oom(size); + b->next = current->next; + current->next = b; + current = b; + } + off = align_up(current->used, align); + } + + void* p = current->payload + off; + liveBytes += (off + size) - current->used; + current->used = off + size; + if (liveBytes > peakUsage) + peakUsage = liveBytes; + return p; +} + +void* Arena::extend_tail(void* p, size_t oldSize, size_t addSize) +{ + if (!current || !p) + return nullptr; + if (reinterpret_cast(p) + oldSize != current->payload + current->used) + return nullptr; + if (current->used + addSize > current->capacity) + return nullptr; + current->used += addSize; + liveBytes += addSize; + if (liveBytes > peakUsage) + peakUsage = liveBytes; + return p; +} + +[[noreturn]] void Arena::oom(size_t size) +{ + qFatal("[RenderGraph] error: arena alloc failed; malloc of %zu bytes failed, heap exhausted.\n", size); +} + +void Arena::reset() +{ + for (ArenaBlock* b = head; b; b = b->next) { +#ifndef NDEBUG + // poison rewound bytes so a stale pointer reads garbage + std::memset(b->payload, 0xDD, b->used); +#endif + b->used = 0; + } + current = head; + liveBytes = 0; + peakUsage = 0; +} + +void Arena::free_all() +{ + for (ArenaBlock* b = head; b;) { + ArenaBlock* n = b->next; + std::free(b); + b = n; + } + head = current = nullptr; + totalCapacity = peakUsage = blockCount = liveBytes = 0; +} + +Arena::Mark Arena::mark() const +{ + return Mark { current, current ? current->used : 0 }; +} + +void Arena::rewind(Mark m) +{ + for (ArenaBlock* b = (m.block ? m.block->next : head); b; b = b->next) + { + liveBytes -= b->used; + b->used = 0; + } + + if (m.block) + { + liveBytes -= m.block->used - m.used; + m.block->used = m.used; + current = m.block; + } + else + { + current = head; + } +} + +WGPUStringView Arena::copy_string(WGPUStringView s) +{ + const size_t len = (s.length == WGPU_STRLEN) ? (s.data ? std::strlen(s.data) : 0) : s.length; + char* buf = alloc(len + 1); + if (len) + std::memcpy(buf, s.data, len); + buf[len] = '\0'; + return WGPUStringView { buf, len }; +} + +ResourceId StringInterner::intern(std::string_view s) +{ + const uint64_t h = fnv1a(s); + Node*& head = buckets[h & (kBuckets - 1)]; + for (Node* n = head; n; n = n->next) + if (n->hash == h && n->name.length == s.length() + && (s.empty() || std::memcmp(n->name.data, s.data(), s.length()) == 0)) // empty view may carry a null data + return { n->hash, n->name }; + + Node* n = arena->make(); + n->hash = h; + n->name = arena->copy_string(WGPUStringView { s.data(), s.length() }); + n->next = head; + head = n; + return { n->hash, n->name }; +} + +ScopedScratch::ScopedScratch(Arena& a) + : arena(&a) + , mark(a.mark()) +{} + +ScopedScratch::~ScopedScratch() +{ + arena->rewind(mark); +} + +void ScopedScratch::reset() +{ + arena->rewind(mark); +} + +bool ResourceNode::is_external() const +{ + return imported || persistent; +} + +RenderGraphStorage* storage(RenderGraph* rg) +{ + return reinterpret_cast(reinterpret_cast(rg) + GraphAllocator::align_up(sizeof(RenderGraph), alignof(RenderGraphStorage))); +} + +ResourceNode* find_node(RenderGraph* rg, ResourceHandle h) +{ + RenderGraphStorage& s = *storage(rg); + + Q_ASSERT(!(h.id && h.generation && h.generation != s.generation) && "stale/foreign ResourceHandle passed to a ctx resolver"); + + if (s.byId && h.id && h.id < s.next_resource_id) + return s.byId[h.id]; + for (ResourceNode* r = s.m_resouces; r; r = r->next) + if (r->handle.id == h.id) + return r; + return nullptr; +} + + +enum struct DefineMode : uint8_t { None, IfClearLoadOp, Always, CopyDst }; + +struct AccessSemantics { + bool isWrite; // read-vs-write for hazard edges + DefineMode define; // type-level half of access_defines + WGPUTextureUsage texUsage; // WGPUTextureUsage_None for buffer-only accesses + WGPUBufferUsage bufUsage; // WGPUBufferUsage_None for texture-only accesses +}; + +constexpr AccessSemantics access_semantics(AccessType t) +{ + switch (t) { + case AccessType::ColorAttachment: + return { true, DefineMode::IfClearLoadOp, WGPUTextureUsage_RenderAttachment, WGPUBufferUsage_None }; + case AccessType::DepthStencilAttachment: + return { true, DefineMode::IfClearLoadOp, WGPUTextureUsage_RenderAttachment, WGPUBufferUsage_None }; + case AccessType::DepthStencilReadOnly: + return { false, DefineMode::None, WGPUTextureUsage_RenderAttachment, WGPUBufferUsage_None }; + case AccessType::ResolveAttachment: + return { true, DefineMode::None, WGPUTextureUsage_RenderAttachment, WGPUBufferUsage_None }; + case AccessType::Sampled: + return { false, DefineMode::None, WGPUTextureUsage_TextureBinding, WGPUBufferUsage_None }; + case AccessType::StorageRead: + return { false, DefineMode::None, WGPUTextureUsage_StorageBinding, WGPUBufferUsage_Storage }; + case AccessType::StorageWrite: + return { true, DefineMode::Always, WGPUTextureUsage_StorageBinding, WGPUBufferUsage_Storage }; + case AccessType::Uniform: + return { false, DefineMode::None, WGPUTextureUsage_None, WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst }; + case AccessType::CopySrc: + return { false, DefineMode::None, WGPUTextureUsage_CopySrc, WGPUBufferUsage_CopySrc }; + case AccessType::CopyDst: + return { true, DefineMode::CopyDst, WGPUTextureUsage_CopyDst, WGPUBufferUsage_CopyDst }; + case AccessType::Vertex: + return { false, DefineMode::None, WGPUTextureUsage_None, WGPUBufferUsage_Vertex }; + case AccessType::Index: + return { false, DefineMode::None, WGPUTextureUsage_None, WGPUBufferUsage_Index }; + case AccessType::Indirect: + return { false, DefineMode::None, WGPUTextureUsage_None, WGPUBufferUsage_Indirect }; + } + + Q_UNREACHABLE(); + return {}; +} + +bool access_is_write(AccessType t) +{ + return access_semantics(t).isWrite; +} + +WGPUStringView pass_name_at(PassNode* head, uint32_t idx) +{ + for (PassNode* p = head; p; p = p->next) + { + if (idx == 0) + return p->id.name; + --idx; + } + return WGPUStringView {}; +} + +// block size in texels (1x1 for uncompressed) plus bytes per block +TexelBlock format_block(WGPUTextureFormat f) +{ + switch (f) + { + // 8-bit + case WGPUTextureFormat_R8Unorm: + case WGPUTextureFormat_R8Snorm: + case WGPUTextureFormat_R8Uint: + case WGPUTextureFormat_R8Sint: + case WGPUTextureFormat_Stencil8: + return { 1, 1, 1 }; + + // 16-bit + case WGPUTextureFormat_R16Uint: + case WGPUTextureFormat_R16Sint: + case WGPUTextureFormat_R16Float: + + case WGPUTextureFormat_RG8Unorm: + case WGPUTextureFormat_RG8Snorm: + case WGPUTextureFormat_RG8Uint: + case WGPUTextureFormat_RG8Sint: + + case WGPUTextureFormat_Depth16Unorm: + return { 1, 1, 2 }; + + // 32-bit + case WGPUTextureFormat_R32Float: + case WGPUTextureFormat_R32Uint: + case WGPUTextureFormat_R32Sint: + + case WGPUTextureFormat_RG16Uint: + case WGPUTextureFormat_RG16Sint: + case WGPUTextureFormat_RG16Float: + + case WGPUTextureFormat_RGBA8Unorm: + case WGPUTextureFormat_RGBA8UnormSrgb: + case WGPUTextureFormat_RGBA8Snorm: + case WGPUTextureFormat_RGBA8Uint: + case WGPUTextureFormat_RGBA8Sint: + + case WGPUTextureFormat_BGRA8Unorm: + case WGPUTextureFormat_BGRA8UnormSrgb: + + case WGPUTextureFormat_RGB10A2Uint: + case WGPUTextureFormat_RGB10A2Unorm: + case WGPUTextureFormat_RG11B10Ufloat: + + case WGPUTextureFormat_Depth24Plus: + case WGPUTextureFormat_Depth32Float: + return { 1, 1, 4 }; + + // 64-bit + case WGPUTextureFormat_RG32Float: + case WGPUTextureFormat_RG32Uint: + case WGPUTextureFormat_RG32Sint: + + case WGPUTextureFormat_RGBA16Uint: + case WGPUTextureFormat_RGBA16Sint: + case WGPUTextureFormat_RGBA16Float: + + case WGPUTextureFormat_Depth24PlusStencil8: + case WGPUTextureFormat_Depth32FloatStencil8: + return { 1, 1, 8 }; + + // 128-bit + case WGPUTextureFormat_RGBA32Float: + case WGPUTextureFormat_RGBA32Uint: + case WGPUTextureFormat_RGBA32Sint: + return { 1, 1, 16 }; + + + // BC1 / BC4: 8 bytes per 4x4 block + case WGPUTextureFormat_BC1RGBAUnorm: + case WGPUTextureFormat_BC1RGBAUnormSrgb: + case WGPUTextureFormat_BC4RUnorm: + case WGPUTextureFormat_BC4RSnorm: + return { 4, 4, 8 }; + + // BC2/3/5/6H/7: 16 bytes per 4x4 block + case WGPUTextureFormat_BC2RGBAUnorm: + case WGPUTextureFormat_BC2RGBAUnormSrgb: + case WGPUTextureFormat_BC3RGBAUnorm: + case WGPUTextureFormat_BC3RGBAUnormSrgb: + case WGPUTextureFormat_BC5RGUnorm: + case WGPUTextureFormat_BC5RGSnorm: + case WGPUTextureFormat_BC6HRGBUfloat: + case WGPUTextureFormat_BC6HRGBFloat: + case WGPUTextureFormat_BC7RGBAUnorm: + case WGPUTextureFormat_BC7RGBAUnormSrgb: + return { 4, 4, 16 }; + + + // ETC2 RGB8 / RGB8A1 and EAC R11: 8 bytes per 4x4 block + case WGPUTextureFormat_ETC2RGB8Unorm: + case WGPUTextureFormat_ETC2RGB8UnormSrgb: + case WGPUTextureFormat_ETC2RGB8A1Unorm: + case WGPUTextureFormat_ETC2RGB8A1UnormSrgb: + case WGPUTextureFormat_EACR11Unorm: + case WGPUTextureFormat_EACR11Snorm: + return { 4, 4, 8 }; + + // ETC2 RGBA8 and EAC RG11: 16 bytes per 4x4 block + case WGPUTextureFormat_ETC2RGBA8Unorm: + case WGPUTextureFormat_ETC2RGBA8UnormSrgb: + case WGPUTextureFormat_EACRG11Unorm: + case WGPUTextureFormat_EACRG11Snorm: + return { 4, 4, 16 }; + + + // ASTC: always 16 bytes per block, block footprint read straight off the enum name + case WGPUTextureFormat_ASTC4x4Unorm: + case WGPUTextureFormat_ASTC4x4UnormSrgb: return { 4, 4, 16 }; + case WGPUTextureFormat_ASTC5x4Unorm: + case WGPUTextureFormat_ASTC5x4UnormSrgb: return { 5, 4, 16 }; + case WGPUTextureFormat_ASTC5x5Unorm: + case WGPUTextureFormat_ASTC5x5UnormSrgb: return { 5, 5, 16 }; + case WGPUTextureFormat_ASTC6x5Unorm: + case WGPUTextureFormat_ASTC6x5UnormSrgb: return { 6, 5, 16 }; + case WGPUTextureFormat_ASTC6x6Unorm: + case WGPUTextureFormat_ASTC6x6UnormSrgb: return { 6, 6, 16 }; + case WGPUTextureFormat_ASTC8x5Unorm: + case WGPUTextureFormat_ASTC8x5UnormSrgb: return { 8, 5, 16 }; + case WGPUTextureFormat_ASTC8x6Unorm: + case WGPUTextureFormat_ASTC8x6UnormSrgb: return { 8, 6, 16 }; + case WGPUTextureFormat_ASTC8x8Unorm: + case WGPUTextureFormat_ASTC8x8UnormSrgb: return { 8, 8, 16 }; + case WGPUTextureFormat_ASTC10x5Unorm: + case WGPUTextureFormat_ASTC10x5UnormSrgb: return { 10, 5, 16 }; + case WGPUTextureFormat_ASTC10x6Unorm: + case WGPUTextureFormat_ASTC10x6UnormSrgb: return { 10, 6, 16 }; + case WGPUTextureFormat_ASTC10x8Unorm: + case WGPUTextureFormat_ASTC10x8UnormSrgb: return { 10, 8, 16 }; + case WGPUTextureFormat_ASTC10x10Unorm: + case WGPUTextureFormat_ASTC10x10UnormSrgb: return { 10, 10, 16 }; + case WGPUTextureFormat_ASTC12x10Unorm: + case WGPUTextureFormat_ASTC12x10UnormSrgb: return { 12, 10, 16 }; + case WGPUTextureFormat_ASTC12x12Unorm: + case WGPUTextureFormat_ASTC12x12UnormSrgb: return { 12, 12, 16 }; + + default: + break; + } + + return { 0, 0, 0 }; +} + +// exact size of one texture +uint64_t texture_bytes(WGPUExtent3D size, WGPUTextureFormat format, uint32_t mipLevelCount, + uint32_t sampleCount, WGPUTextureDimension dim) +{ + const TexelBlock b = format_block(format); + if (!b.bytes) return 0; + const bool is3D = dim == WGPUTextureDimension_3D; + const uint32_t layers = is3D ? 1u : (size.depthOrArrayLayers ? size.depthOrArrayLayers : 1); + const uint32_t samples = sampleCount ? sampleCount : 1; + uint64_t total = 0; + for (uint32_t m = 0; m < mipLevelCount; ++m) { + const uint32_t w = (size.width >> m) ? (size.width >> m) : 1u; + const uint32_t h = (size.height >> m) ? (size.height >> m) : 1u; + const uint32_t d = is3D ? ((size.depthOrArrayLayers >> m) ? (size.depthOrArrayLayers >> m) : 1u) : 1u; + const uint32_t bw = (w + b.w - 1) / b.w; + const uint32_t bh = (h + b.h - 1) / b.h; + total += (uint64_t)bw * bh * d * layers * samples * b.bytes; + } + return total; +} + +} // namespace Internal + +void* GraphAllocator::alloc_raw(size_t size, size_t align) +{ + return front.alloc_raw(size, align); +} + +WGPUStringView GraphAllocator::copy_string(WGPUStringView s) +{ + return front.copy_string(s); +} + +void GraphAllocator::reset() +{ + front.reset(); + scratch.reset(); + ++frameId; +} + +// printf arg pair for a "%.*s" name, null data prints empty +#define RG_NAME(x) (int)(x).name.length, (x).name.data ? (x).name.data : "" + +// records the error and poisons the graph to the failed state +static void push_error(RenderGraphStorage& s, const char* fmt, ...) +{ + ScopedScratch scratch(s.m_allocator->scratch); + + va_list ap; + va_start(ap, fmt); + va_list probe; + va_copy(probe, ap); + const int len = std::vsnprintf(nullptr, 0, fmt, probe); + va_end(probe); + + char* buf = scratch.alloc(size_t(len) + 1); + std::vsnprintf(buf, size_t(len) + 1, fmt, ap); + va_end(ap); + + ErrorMessage* e = s.m_allocator->make(); + e->message = s.m_allocator->copy_string(WGPUStringView { buf, size_t(len) }); + list_append(&s.m_errors, &s.m_errorsTail, e); + s.m_state = RenderGraphState::Failed; +} + + +static ResourceHandle create_handle(RenderGraphStorage& s, ResourceKind kind) +{ + return { + .id = s.next_resource_id++, + .kind = kind, + .generation = s.generation, + }; +} + +static ResourceId intern_id(GraphAllocator& a, std::string_view s) +{ + return a.names.intern(s); +} + +static void assert_unique_id(RenderGraphStorage& s, ResourceId id) +{ +#ifndef NDEBUG + for (ResourceNode* r = s.m_resouces; r; r = r->next) + Q_ASSERT(!(r->id == id) && "ResourceIds must to be unique!"); +#else + (void)s; + (void)id; +#endif +} + +GraphAllocator* create_allocator() +{ + GraphAllocator* allocator = new GraphAllocator; + allocator->front.blockSize = ARENA_DEFAULT_BLOCK_SIZE; + allocator->scratch.blockSize = ARENA_SCRATCH_DEFAULT_BLOCK_SIZE; + allocator->persist.blockSize = ARENA_SCRATCH_DEFAULT_BLOCK_SIZE; + allocator->front.reserve(); + allocator->scratch.reserve(); + allocator->persist.reserve(); + allocator->names.arena = &allocator->persist; + allocator->pool.arena = &allocator->persist; + allocator->transient.arena = &allocator->persist; + allocator->subviews.arena = &allocator->persist; + allocator->pool.subviewPool = &allocator->subviews; + allocator->transient.subviewPool = &allocator->subviews; + return allocator; +} + +void destroy_allocator(GraphAllocator* allocator) +{ + allocator->pool.destroy_all(); + allocator->transient.destroy_all(); + allocator->front.free_all(); + allocator->scratch.free_all(); + allocator->persist.free_all(); + delete allocator; +} + +void begin_frame(GraphAllocator* allocator) +{ + allocator->reset(); +} + +void end_frame(GraphAllocator* allocator) +{ + allocator->pool.end_frame(); + allocator->transient.end_frame(); +} + +RenderGraph* start_recording(GraphAllocator* allocator) +{ + Q_ASSERT(allocator); + Internal::GraphPair* pair = allocator->make(); + RenderGraph* rg = &pair->rg; + RenderGraphStorage* st = &pair->st; + Q_ASSERT(st == storage(rg) && "storage must sit immediately after the RenderGraph"); + st->m_allocator = allocator; + st->frameId = allocator->frameId; + + static uint64_t g_next_graph_generation = 1; + st->generation = g_next_graph_generation++; + return rg; +} + + +static bool access_defines(const ResourceAccess& a) +{ + switch (access_semantics(a.type).define) { + case DefineMode::None: return false; + case DefineMode::IfClearLoadOp: return a.loadOp == WGPULoadOp_Clear; + case DefineMode::Always: return true; + case DefineMode::CopyDst: return a.handle.kind == ResourceKind::Buffer && a.bufFullDefine; + } + return false; +} + +static WGPUTextureUsage tex_usage_of(AccessType t) { return access_semantics(t).texUsage; } +static WGPUBufferUsage buf_usage_of(AccessType t) { return access_semantics(t).bufUsage; } + +// half-open ranges [base, base+count). count 0 means all remaining. +static constexpr bool ranges_overlap(uint32_t aBase, uint32_t aCount, uint32_t bBase, uint32_t bCount) +{ + uint32_t aEnd = aCount ? aBase + aCount : UINT32_MAX; + uint32_t bEnd = bCount ? bBase + bCount : UINT32_MAX; + return aBase < bEnd && bBase < aEnd; +} + +// All covers depth+stencil, so it overlaps either aspect. otherwise two accesses clash only on the same one. +static constexpr bool aspects_overlap(WGPUTextureAspect a, WGPUTextureAspect b) +{ + return a == WGPUTextureAspect_All || b == WGPUTextureAspect_All || a == b; +} + +static constexpr bool in_pass_accesses_conflict(AccessType a, + uint32_t aBaseMip, + uint32_t aMipCount, + uint32_t aBaseLayer, + uint32_t aLayerCount, + WGPUTextureAspect aAspect, + AccessType b, + uint32_t bBaseMip, + uint32_t bMipCount, + uint32_t bBaseLayer, + uint32_t bLayerCount, + WGPUTextureAspect bAspect) +{ + if (!ranges_overlap(aBaseMip, aMipCount, bBaseMip, bMipCount)) + return false; // disjoint mips + if (!ranges_overlap(aBaseLayer, aLayerCount, bBaseLayer, bLayerCount)) + return false; // disjoint layers + if (!aspects_overlap(aAspect, bAspect)) + return false; // disjoint aspects + if (!access_is_write(a) && !access_is_write(b)) + return false; + if ((a == AccessType::StorageRead && b == AccessType::StorageWrite) || (a == AccessType::StorageWrite && b == AccessType::StorageRead)) + return false; + return true; +} + +static void validate_texture_desc(const TextureDesc& desc) +{ + Q_ASSERT_X(desc.dimension != WGPUTextureDimension_1D, "validate_texture_desc", + "The render graph does not support 1d textures, use 2d textures instead"); + Q_ASSERT_X(desc.relativeTo.id == 0 || (desc.scaleX > 0.0f && desc.scaleY > 0.0f), "validate_texture_desc", + "When relative to another texture it cannot be a scale of 0"); + Q_ASSERT_X(desc.sizeKind != SizeKind::Relative || desc.relativeTo.id != 0, "validate_texture_desc", + "SizeKind::Relative needs a relativeTo handle to size against, otherwise it resolves to nothing"); + Q_ASSERT_X(desc.sizeKind != SizeKind::Absolute || (desc.absolute.width && desc.absolute.height), "validate_texture_desc", + "SizeKind::Absolute needs a non-zero width and height in TextureDesc.absolute"); + Q_ASSERT_X(desc.sampleCount == 1 || desc.sampleCount == 4, "validate_texture_desc", + "WebGPU(1.0) only supports MSAA samples of 1 or 4."); + Q_ASSERT_X(desc.dimension != WGPUTextureDimension_3D || desc.sampleCount == 1, "validate_texture_desc", + "Texture3D does not support MSAA"); + Q_ASSERT_X(desc.dimension != WGPUTextureDimension_3D || desc.mipLevelCount == 1, "validate_texture_desc", + "Texture3D only supports mipLevelCount of 1"); +} + +static void apply_texture_desc(ResourceNode* r, const TextureDesc& desc) +{ + validate_texture_desc(desc); + r->dimension = desc.dimension; + r->format = desc.format; + r->sizeKind = desc.sizeKind; + r->scaleX = desc.scaleX; + r->scaleY = desc.scaleY; + r->relativeToHandle = desc.relativeTo; + r->absolute = desc.absolute; + r->mipLevelCount = desc.mipLevelCount ? desc.mipLevelCount : 1; + r->sampleCount = desc.sampleCount ? desc.sampleCount : 1; +} + +static TexSignature tex_signature(const ResourceNode* r) +{ + return TexSignature { r->resolved, r->format, r->dimension, r->mipLevelCount, r->sampleCount }; +} + +TextureHandle RenderGraph::create_transient_texture(std::string_view id, const TextureDesc& desc) +{ + RenderGraphStorage& s = *storage(this); + Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()"); + + const ResourceId rid = intern_id(*s.m_allocator, id); + assert_unique_id(s, rid); + Q_ASSERT((desc.relativeTo.id == 0 || desc.relativeTo.generation == s.generation) && "TextureDesc.relativeTo is a stale/foreign handle"); + + ResourceNode* resouce = s.m_allocator->make(); + + resouce->handle = create_handle(s, ResourceKind::Texture); + resouce->id = rid; + resouce->kind = ResourceKind::Texture; + apply_texture_desc(resouce, desc); + + list_append(&s.m_resouces, &s.m_resoucesTail, resouce); + + return TextureHandle { resouce->handle }; +} + +BufferHandle RenderGraph::create_transient_buffer(std::string_view id, const BufferDesc& desc) +{ + RenderGraphStorage& s = *storage(this); + Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()"); + + const ResourceId rid = intern_id(*s.m_allocator, id); + assert_unique_id(s, rid); + + ResourceNode* resouce = s.m_allocator->make(); + + resouce->handle = create_handle(s, ResourceKind::Buffer); + resouce->id = rid; + resouce->kind = ResourceKind::Buffer; + resouce->bufferSize = desc.size; + + list_append(&s.m_resouces, &s.m_resoucesTail, resouce); + + return BufferHandle { resouce->handle }; +} + +TextureHandle RenderGraph::import_texture(std::string_view id, const ImportTextureDesc& desc) +{ + const WGPUTextureView view = desc.view; + const WGPUExtent3D size = desc.size; + const WGPUTextureFormat format = desc.format; + WGPUTexture texture = desc.texture; + const uint32_t mipCount = desc.mipCount; + const uint32_t sampleCount = desc.sampleCount; + const WGPUTextureDimension dimension = desc.dimension; + + RenderGraphStorage& s = *storage(this); + Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()"); + + const ResourceId rid = intern_id(*s.m_allocator, id); + assert_unique_id(s, rid); + + ResourceNode* resouce = s.m_allocator->make(); + + resouce->handle = create_handle(s, ResourceKind::Texture); + resouce->id = rid; + resouce->kind = ResourceKind::Texture; + Q_ASSERT(view && "import_texture: ImportTextureDesc.view is required"); + Q_ASSERT(format != WGPUTextureFormat_Undefined && "import_texture: pass the imported view's format"); + resouce->imported = true; + resouce->view = view; + resouce->texture = texture; + if (texture) { + Q_ASSERT(size.width == wgpuTextureGetWidth(texture) && size.height == wgpuTextureGetHeight(texture) + && size.depthOrArrayLayers == wgpuTextureGetDepthOrArrayLayers(texture) + && "import_texture: size disagrees with the backing texture (stale extent after resize?)"); + } + resouce->resolved = size; + resouce->format = format; + resouce->mipLevelCount = mipCount ? mipCount : 1; + resouce->sampleCount = sampleCount ? sampleCount : 1; + Q_ASSERT_X(dimension != WGPUTextureDimension_1D, "import_texture", + "The render graph does not support 1d textures, use 2d textures instead"); + resouce->dimension = dimension; + + list_append(&s.m_resouces, &s.m_resoucesTail, resouce); + + return TextureHandle { resouce->handle }; +} + +BufferHandle RenderGraph::import_buffer(std::string_view id, WGPUBuffer buffer) +{ + RenderGraphStorage& s = *storage(this); + Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()"); + Q_ASSERT(buffer && "import_buffer: null buffer; wgpuBufferGetSize below would dereference it"); + + const ResourceId rid = intern_id(*s.m_allocator, id); + assert_unique_id(s, rid); + + ResourceNode* resouce = s.m_allocator->make(); + + resouce->handle = create_handle(s, ResourceKind::Buffer); + resouce->id = rid; + resouce->kind = ResourceKind::Buffer; + resouce->imported = true; + resouce->buffer = buffer; + resouce->bufferSize = wgpuBufferGetSize(buffer); + + list_append(&s.m_resouces, &s.m_resoucesTail, resouce); + + return BufferHandle { resouce->handle }; +} + +HistoryTexture RenderGraph::create_history_texture(std::string_view id, const TextureDesc& desc, uint64_t hash) +{ + RenderGraphStorage& s = *storage(this); + Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()"); + + const ResourceId rid = intern_id(*s.m_allocator, id); + assert_unique_id(s, rid); + Q_ASSERT((desc.relativeTo.id == 0 || desc.relativeTo.generation == s.generation) && "TextureDesc.relativeTo is a stale/foreign handle"); + + HistoryTexture out {}; + for (uint32_t i = 0; i < PersistentResourcePool::kLayers; ++i) + { + ResourceNode* resouce = s.m_allocator->make(); + resouce->handle = create_handle(s, ResourceKind::Texture); + resouce->id = rid; + resouce->kind = ResourceKind::Texture; + resouce->persistent = true; + resouce->history = true; + resouce->historyIndex = i; + resouce->historyHash = hash; + apply_texture_desc(resouce, desc); + list_append(&s.m_resouces, &s.m_resoucesTail, resouce); + if (i == 0) + out.curr = TextureHandle { resouce->handle }; + else + out.prev = TextureHandle { resouce->handle }; + } + return out; +} + +HistoryBuffer RenderGraph::create_history_buffer(std::string_view id, const BufferDesc& desc, uint64_t hash) +{ + RenderGraphStorage& s = *storage(this); + Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()"); + + const ResourceId rid = intern_id(*s.m_allocator, id); + assert_unique_id(s, rid); + + HistoryBuffer out {}; + for (uint32_t i = 0; i < PersistentResourcePool::kLayers; ++i) + { + ResourceNode* resouce = s.m_allocator->make(); + resouce->handle = create_handle(s, ResourceKind::Buffer); + resouce->id = rid; + resouce->kind = ResourceKind::Buffer; + resouce->persistent = true; + resouce->history = true; + resouce->historyIndex = i; + resouce->historyHash = hash; + resouce->bufferSize = desc.size; + list_append(&s.m_resouces, &s.m_resoucesTail, resouce); + if (i == 0) + out.curr = BufferHandle { resouce->handle }; + else + out.prev = BufferHandle { resouce->handle }; + } + return out; +} + + +BufferHandle RenderGraph::create_persistent_buffer(std::string_view id, const BufferDesc& desc) +{ + RenderGraphStorage& s = *storage(this); + Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()"); + + const ResourceId rid = intern_id(*s.m_allocator, id); + assert_unique_id(s, rid); + + ResourceNode* resouce = s.m_allocator->make(); + resouce->handle = create_handle(s, ResourceKind::Buffer); + resouce->id = rid; + resouce->kind = ResourceKind::Buffer; + resouce->persistent = true; + resouce->historyIndex = 0; // the only layer + resouce->bufferSize = desc.size; + list_append(&s.m_resouces, &s.m_resoucesTail, resouce); + return BufferHandle { resouce->handle }; +} + + +TextureHandle RenderGraph::create_persistent_texture(std::string_view id, const TextureDesc& desc) +{ + RenderGraphStorage& s = *storage(this); + Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()"); + + const ResourceId rid = intern_id(*s.m_allocator, id); + assert_unique_id(s, rid); + Q_ASSERT((desc.relativeTo.id == 0 || desc.relativeTo.generation == s.generation) && "TextureDesc.relativeTo is a stale/foreign handle"); + + ResourceNode* resouce = s.m_allocator->make(); + resouce->handle = create_handle(s, ResourceKind::Texture); + resouce->id = rid; + resouce->kind = ResourceKind::Texture; + resouce->persistent = true; + resouce->historyIndex = 0; // the only layer + apply_texture_desc(resouce, desc); + list_append(&s.m_resouces, &s.m_resoucesTail, resouce); + return TextureHandle { resouce->handle }; +} + +static bool is_depth_format(WGPUTextureFormat f) +{ + switch (f) + { + case WGPUTextureFormat_Depth16Unorm: + case WGPUTextureFormat_Depth24Plus: + case WGPUTextureFormat_Depth24PlusStencil8: + case WGPUTextureFormat_Depth32Float: + case WGPUTextureFormat_Depth32FloatStencil8: + return true; + default: + return false; + } +} + +static bool format_has_stencil(WGPUTextureFormat f) +{ + switch (f) + { + case WGPUTextureFormat_Stencil8: + case WGPUTextureFormat_Depth24PlusStencil8: + case WGPUTextureFormat_Depth32FloatStencil8: + return true; + default: + return false; + } +} + +TextureHandle RenderGraph::create_initialized_texture(std::string_view id, const TextureDesc& desc, WGPUColor fill) +{ + // the fill is a clear on a render target, and the graph cannot render into a 3D texture + Q_ASSERT_X(desc.dimension != WGPUTextureDimension_3D, "create_initialized_texture", + "3D textures cannot be cleared this way, the graph cannot use them as render targets"); + TextureHandle h = create_persistent_texture(id, desc); + uint32_t layers = desc.absolute.depthOrArrayLayers ? desc.absolute.depthOrArrayLayers : 1; + bool depth = is_depth_format(desc.format); + Arena& scratch = storage(this)->m_allocator->scratch; + + for (uint32_t layer = 0; layer < layers; ++layer) + { + ScopedScratch ss(scratch); + std::string_view passId = id; + if (layers > 1) { + const int n = std::snprintf(nullptr, 0, "%.*s.layer%u", (int)id.size(), id.data(), layer); + char* buf = ss.alloc(size_t(n) + 1); + std::snprintf(buf, size_t(n) + 1, "%.*s.layer%u", (int)id.size(), id.data(), layer); + passId = std::string_view(buf, size_t(n)); + } + add_pass( + passId, + PassKind::Graphics, + [h, depth, fill, layer](PassBuilder& b) { + if (depth) + b.depth_stencil(h, { .clearDepth = (float)fill.r, .sub = { .layer = layer } }); + else + b.color(h, 0, { .clear = fill, .sub = { .layer = layer } }); + b.initialize(h); + }, + [](PassContext&) {}); + } + return h; +} + +BufferHandle RenderGraph::create_initialized_buffer(std::string_view id, const BufferDesc& desc, const void* data) +{ + BufferHandle h = create_persistent_buffer(id, desc); + RenderGraphStorage& s = *storage(this); + // copy into the arena, the caller's data does not outlive this call + uint8_t* bytes = s.m_allocator->alloc(desc.size); + if (data) + std::memcpy(bytes, data, desc.size); + uint64_t size = desc.size; + add_pass( + id, + PassKind::Transfer, + [h](PassBuilder& b) { + b.host_write(h); + b.initialize(h); + }, + [h, bytes, size](PassContext& ctx) { wgpuQueueWriteBuffer(ctx.queue, ctx.buffer(h), 0, bytes, size); }); + return h; +} + +void RenderGraph::begin_pass(PassBuilder& builder, std::string_view id, PassKind kind) +{ + RenderGraphStorage& s = *storage(this); + Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()"); + PassNode* pass = s.m_allocator->make(); + pass->id = intern_id(*s.m_allocator, id); + pass->kind = kind; + + builder.m_pass = pass; + builder.m_graph = this; +} + +void RenderGraph::end_pass(PassBuilder& builder) +{ + PassNode* pass = builder.m_pass; + for (uint32_t t = 0; t < pass->initCount; ++t) + { + bool wrote = false; + for (uint32_t i = 0; i < pass->accessCount; ++i) + if (pass->accesses[i].handle.id == pass->initTargets[t].target.id && access_is_write(pass->accesses[i].type)) { + wrote = true; + break; + } + Q_ASSERT(wrote + && "initialize() target must also be written by this pass (e.g. .color()/.storage_write()). " + "initialize() only gates skip/run, it is not itself a write"); + } + RenderGraphStorage& s = *storage(this); + list_append(&s.m_passes, &s.m_passesTail, pass); +} + +void* RenderGraph::alloc_exec(size_t size, size_t align) +{ + return storage(this)->m_allocator->alloc_raw(size, align); +} + +void RenderGraph::set_exec(PassBuilder& builder, void* obj, void (*fn)(void*, PassContext&)) +{ + builder.m_pass->exec_obj = obj; + builder.m_pass->exec_fn = fn; +} + +static void add_dependency(GraphAllocator* alloc, PassNode* p, PassNode* dep) +{ + NodeAdjacency* link = alloc->make(); + link->pass = dep; + link->next = p->adjacency; + p->adjacency = link; +} + +// one declaration-order sweep with implicit SSA resource versioning +static void sweep_resource_versions(Arena& scratch, PassNode* head, uint32_t next_resource_id, uint32_t passCount, GraphAllocator* alloc) +{ + ScopedScratch ss(scratch); + PassNode** currentProducer = ss.alloc(next_resource_id); + NodeAdjacency** pendingReaders = ss.alloc(next_resource_id); + uint32_t* linkedBy = ss.alloc(passCount); + + auto emit_edge = [&](PassNode* dependent, PassNode* dep) { + if (linkedBy[dep->index] == dependent->index) + return; + linkedBy[dep->index] = dependent->index; + add_dependency(alloc, dependent, dep); + }; + + for (PassNode* p = head; p; p = p->next) { + if (p->skipInit) + continue; // initialize() pass already satisfied + for (uint32_t i = 0; i < p->accessCount; ++i) { + uint32_t id = p->accesses[i].handle.id; + if (!id) + continue; + if (access_is_write(p->accesses[i].type)) { + // WAW: order this write after the previous writer. without it two writers of one + // resource have no edge -> undefined order -> corruption. + if (currentProducer[id] && currentProducer[id] != p) + emit_edge(p, currentProducer[id]); // WAW + // WAR: order this write after every reader still using the version it clobbers. + for (NodeAdjacency* r = pendingReaders[id]; r; r = r->next) + if (r->pass != p) + emit_edge(p, r->pass); // WAR + currentProducer[id] = p; // new version + pendingReaders[id] = nullptr; // readers retired + } else { + // RAW: this read depends on the producer of the version it sees. a read before any + // writer binds to "no producer" (no edge). + if (currentProducer[id] && currentProducer[id] != p) + emit_edge(p, currentProducer[id]); // RAW + // register as a pending reader of the current version (for a future write's WAR). + NodeAdjacency* link = ss.make(); + link->pass = p; + link->next = pendingReaders[id]; + pendingReaders[id] = link; + } + } + } +} + +// one level of the explicit DFS stack +struct TopoFrame { + PassNode* pass {}; + NodeAdjacency* next {}; // unvisited remainder of pass->adjacency +}; + +// post-order DFS +static void topo_visit(PassNode* root, PassNode** order, uint32_t& count, bool& hadCycle, TopoFrame* stack) +{ + if (root->onStack) { // trap for cycle detection + hadCycle = true; + return; + } + if (root->placed) + return; + + uint32_t depth = 0; + root->placed = root->onStack = true; + stack[depth++] = { root, root->adjacency }; + + while (depth) { + TopoFrame& f = stack[depth - 1]; + if (f.next) { + PassNode* child = f.next->pass; + f.next = f.next->next; + if (child->onStack) { // trap for cycle detection + hadCycle = true; + continue; + } + if (child->placed) + continue; + child->placed = child->onStack = true; + stack[depth++] = { child, child->adjacency }; + continue; + } + + f.pass->onStack = false; + order[count++] = f.pass; + --depth; + } +} + +static bool is_sink(PassNode* p, const bool* sinkRoot) +{ + for (uint32_t i = 0; i < p->accessCount; ++i) + if (access_is_write(p->accesses[i].type) && sinkRoot[p->accesses[i].handle.id]) + return true; + return false; +} + +static WGPUExtent3D resolve_size(ResourceNode* r, RenderGraphStorage& s) +{ + if (r->imported || r->resolved.width) + return r->resolved; + if (r->sizeKind == SizeKind::Absolute) { + r->resolved = r->absolute; + if (!r->resolved.depthOrArrayLayers) + r->resolved.depthOrArrayLayers = 1; // 0 layers means 1 everywhere else, see node_layers() + return r->resolved; + } + if (r->resolving) { + push_error(s, + "resource \"%.*s\" sits on a cyclic relativeTo chain. A relative size must " + "eventually resolve against an absolutely sized resource.", + RG_NAME(r->id)); + return WGPUExtent3D {}; + } + + ResourceNode* base = r->relativeToHandle.id ? s.byId[r->relativeToHandle.id] : nullptr; + if (!base) { + push_error(s, + "resource \"%.*s\" is SizeKind::Relative but names no resource to size against. " + "set TextureDesc.relativeTo, or use SizeKind::Absolute.", + RG_NAME(r->id)); + return r->resolved = WGPUExtent3D {}; + } + r->resolving = true; + WGPUExtent3D b = resolve_size(base, s); + r->resolving = false; + uint32_t layers = r->absolute.depthOrArrayLayers ? r->absolute.depthOrArrayLayers : 1; + // round, don't truncate. clamp to 1 + uint32_t w = (uint32_t)(b.width * r->scaleX + 0.5f); + uint32_t h = (uint32_t)(b.height * r->scaleY + 0.5f); + return r->resolved = { w ? w : 1u, h ? h : 1u, layers }; +} + +static void detect_transient_attachments(RenderGraphStorage& s) +{ + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + if (r->is_external() || r->kind != ResourceKind::Texture) + continue; + if (r->texUsage != WGPUTextureUsage_RenderAttachment) + continue; + if (r->dimension != WGPUTextureDimension_2D || r->mipLevelCount != 1 || r->resolved.depthOrArrayLayers > 1) + continue; + + // exactly one recorded access, otherwise its contents span passes and cannot be memoryless + if (r->liveAccessCount != 1) + continue; + const ResourceAccess* a = r->soleAccess; + + // a writable color/depth attachment only + if (a->type != AccessType::ColorAttachment && a->type != AccessType::DepthStencilAttachment) + continue; + if (a->loadOp != WGPULoadOp_Clear || a->storeOp != WGPUStoreOp_Discard) + continue; + + if (a->type == AccessType::DepthStencilAttachment && a->stencilLoadOp != WGPULoadOp_Undefined + && (a->stencilLoadOp != WGPULoadOp_Clear || a->stencilStoreOp != WGPUStoreOp_Discard)) + continue; + + r->transientAttachment = true; + ++s.transientCount; + } +} + +// a history resource is two layer nodes sharing one pool entry +static void validate_history_layers(RenderGraphStorage& s) +{ + auto pool_used = [](const ResourceNode* r) { return r->kind == ResourceKind::Texture ? r->texUsage != 0 : r->bufUsage != 0; }; + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + if (!r->history || r->historyIndex != 0) + continue; // anchor on .curr, skip .prev and single-layer persistent + ResourceNode* prev = r->next; // .prev (layer 1) was appended right after .curr + // both ids are interned, so canonical pointers match exactly where hashes could collide + if (!prev || !prev->history || prev->id.name.data != r->id.name.data || prev->historyIndex != 1) { + push_error(s, + "history resource \"%.*s\": its .curr and .prev layer nodes are not adjacent in " + "declaration order. This is an internal error, not an authoring one.", + RG_NAME(r->id)); + continue; + } + if (pool_used(r) != pool_used(prev)) + push_error(s, + "history resource \"%.*s\": .curr (layer 0) is %s but .prev (layer 1) is %s " + "after culling; read .prev and write .curr in one pass, or use neither.", + RG_NAME(r->id), + pool_used(r) ? "used" : "unused", + pool_used(prev) ? "used" : "unused"); + } +} + +// validate cube/cubearray view layer counts +static void validate_cube_views(RenderGraphStorage& s); + +// validate every access's subresource / byte range against the resource it names +static void validate_access_ranges(RenderGraphStorage& s); + +// validate the render-pass descriptor each Graphics pass will build: attachment sizes and sample counts, +// resolve pairs, depth-stencil formats +static void validate_render_passes(RenderGraphStorage& s); + +// compile() +static void compile_impl(RenderGraph* graph, bool enableAlias) +{ + auto t0 = std::chrono::steady_clock::now(); + RenderGraphStorage& s = *storage(graph); + if (s.m_state != RenderGraphState::Recording) { + static constexpr const char* kStateName[] = { "recording", "compiled", "failed", "finished" }; + push_error(s, "compile() called on a graph that is already %s. compile() runs once per recording.", + kStateName[(int)s.m_state]); + return; + } + + // a handle this frame's graph did not create + auto foreign = [&s](ResourceHandle h) { return h.id >= s.next_resource_id || h.generation != s.generation; }; + + uint32_t passIndex = 0; + for (PassNode* p = s.m_passes; p; p = p->next) { + p->index = passIndex++; + for (uint32_t i = 0; i < p->accessCount; ++i) { + ResourceHandle h = p->accesses[i].handle; + if (h.id == 0 || foreign(h)) { + push_error(s, + "pass \"%.*s\" uses an invalid resource handle (id %u): zero, out of range, " + "or from another graph / a previous frame.", + RG_NAME(p->id), + h.id); + return; + } + } + for (uint32_t i = 0; i < p->initCount; ++i) { + ResourceHandle t = p->initTargets[i].target; + if (foreign(t)) { + push_error(s, + "pass \"%.*s\" initialize() target handle (id %u) is from another graph or a previous frame.", + RG_NAME(p->id), + t.id); + return; + } + } + } + s.passCount = passIndex; + + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + ResourceHandle rel = r->relativeToHandle; + if (rel.id != 0 && foreign(rel)) { + push_error(s, + "resource \"%.*s\" relativeTo (id %u) is from another graph or a previous frame.", + RG_NAME(r->id), + rel.id); + return; + } + } + + // build fast index based lookup + s.byId = s.m_allocator->alloc(s.next_resource_id); + for (ResourceNode* r = s.m_resouces; r; r = r->next){ + if(r->handle.id < s.next_resource_id) + s.byId[r->handle.id] = r; + } + + WGPUTextureUsage* predTexUsage = s.m_allocator->alloc(s.next_resource_id); + WGPUBufferUsage* predBufUsage = s.m_allocator->alloc(s.next_resource_id); + bool* hasWriter = s.m_allocator->alloc(s.next_resource_id); + for (PassNode* p = s.m_passes; p; p = p->next) + for (uint32_t i = 0; i < p->accessCount; ++i) { + const ResourceAccess& a = p->accesses[i]; + predTexUsage[a.handle.id] |= tex_usage_of(a.type); + predBufUsage[a.handle.id] |= buf_usage_of(a.type); + hasWriter[a.handle.id] |= access_is_write(a.type); // OR: any declared write, not just the last access to id + } + + // phase 0: skip up-to-date bake passes. a skipped pass produces no version and is not a cull root, so + // phases 1-2 drop it. readers still bind to the pooled result. + for (PassNode* p = s.m_passes; p; p = p->next) { + p->skipInit = false; + if (!p->initCount) + continue; + // skip only when every target is realized and baked with its exact hash. one stale target re-arms + // the pass, since a body bakes all its targets or none. + bool allBaked = true; + for (uint32_t i = 0; i < p->initCount; ++i) { + ResourceNode* r = s.byId[p->initTargets[i].target.id]; // non-null: backstop range-checked the id + if (!r->persistent) { + push_error(s, "initialize() target must be persistent or history (create_persistent_texture/_buffer or create_history_texture/_buffer)"); + allBaked = false; + continue; + } + PersistentResourcePool::Entry* e = s.m_allocator->pool.find(r->id); + bool upToDate = e && e->created && e->baked && e->initHash == p->initTargets[i].hash; + // a matching bake is still stale if realize() would recreate the entry after skipInit was + // decided, leaving it blank for a frame. + bool wouldRecreate = false; + if (e && e->created) { + // same predicate realize_*_entry uses, so the prediction cannot drift from the decision. + if (r->kind == ResourceKind::Texture) { + resolve_size(r, s); // tex_signature reads r->resolved + wouldRecreate = PersistentResourcePool::tex_entry_would_recreate(*e, tex_signature(r), predTexUsage[r->handle.id]); + } else { + wouldRecreate = PersistentResourcePool::buf_entry_would_recreate(*e, r->bufferSize, predBufUsage[r->handle.id]); + } + // touch() also recreates on a history-hash mismatch, and has not run yet at phase-0 time. + wouldRecreate = wouldRecreate || PersistentResourcePool::history_hash_forces_destroy(*e, r->historyHash); + } + if (!upToDate || wouldRecreate) + allBaked = false; + } + p->skipInit = allBaked; + } + if (s.m_state == RenderGraphState::Failed) // a non-persistent initialize() target or a cyclic relativeTo chain + return; + + // phase 1: build the dependency DAG in the "depends-on" direction. reads before any writer get no edge + // here, the post-cull pass turns them into errors. + sweep_resource_versions(s.m_allocator->scratch, s.m_passes, s.next_resource_id, s.passCount, s.m_allocator); + + // phase 2: cull dead passes and topo sort, fused into one DFS seeded from sinks. + { + // sinkRoot = imported or history .curr, whose value must survive without a same-frame reader. + // persistent bakes are excluded on purpose, they are pool-cached and dependency-driven. + ScopedScratch ss(s.m_allocator->scratch); + bool* sinkRoot = ss.alloc(s.next_resource_id); + for (ResourceNode* r = s.m_resouces; r; r = r->next) + sinkRoot[r->handle.id] = r->imported || r->history; + + // topo into a scratch array, then relink the intrusive list into execution order + PassNode** order = ss.alloc(s.passCount); + TopoFrame* topoStack = ss.alloc(s.passCount); + uint32_t count = 0; + bool hadCycle = false; + for (PassNode* p = s.m_passes; p; p = p->next) + if (!p->skipInit + && (is_sink(p, sinkRoot) || p->forceKeep)) // satisfied initialize() pass is not a root; force_keep() keeps a reader-less side-effect pass alive + { + p->sink = true; + topo_visit(p, order, count, hadCycle, topoStack); // only reaches passes that feed a sink + } + + // acyclic by construction, phase 1 emits only backward edges, so a cycle means corruption. + if (hadCycle) { + push_error(s, + "compile() found a cycle in the pass dependency graph. The graph is " + "acyclic by construction, so this is an internal error, not an authoring one."); + return; + } + + // relink next-pointers to follow topo order + for (uint32_t i = 0; i + 1 < count; ++i) + order[i]->next = order[i + 1]; + if (count) + order[count - 1]->next = nullptr; + s.m_passes = count ? order[0] : nullptr; + s.m_passesTail = count ? order[count - 1] : nullptr; + } + + // post-cull validation over the final schedule: reading a transient no earlier surviving pass produced + // samples uninitialized pool contents. external resources are exempt, their value comes from outside + // the frame. bails before phase 3, so a misordered graph never reaches realize(). + { + ScopedScratch ss(s.m_allocator->scratch); + bool* produced = ss.alloc(s.next_resource_id); // a surviving pass has written it so far + bool* external = ss.alloc(s.next_resource_id); // imported or history + bool* prevLayer = ss.alloc(s.next_resource_id); // history layer k>0, read-only this frame + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + external[r->handle.id] = r->is_external(); + prevLayer[r->handle.id] = r->persistent && r->historyIndex != 0; + } + + bool hadError = false; + for (PassNode* p = s.m_passes; p; p = p->next) + for (uint32_t i = 0; i < p->accessCount; ++i) { + uint32_t id = p->accesses[i].handle.id; + if (access_is_write(p->accesses[i].type)) { + produced[id] = true; + // older layers of a history resource are read-only this frame, .curr is the only legal + // write target. writing layer k>0 clobbers a slot that becomes a future current. + if (prevLayer[id]) { + ResourceNode* w = s.byId[id]; // non-null: prevLayer is only set from a live node + push_error(s, + "pass \"%.*s\" writes history resource \"%.*s\" " + "layer %u; only layer 0, the .curr handle, is writable.", + RG_NAME(p->id), + RG_NAME(w->id), + w->historyIndex); + hadError = true; + } + continue; + } + if (id == 0 || external[id] || produced[id]) + continue; + ResourceNode* r = s.byId[id]; // non-null: the handle backstop range-checked every access id + + // a write to id in THIS pass makes the read an in-place read_write, which needs its own + // diagnosis below. + bool samePassWrite = false; + for (uint32_t j = 0; j < p->accessCount; ++j) + if (p->accesses[j].handle.id == id && access_is_write(p->accesses[j].type)) { + samePassWrite = true; + break; + } + + // three diagnoses of one shape, all naming the pass then the resource twice + const char* fmt = !hasWriter[id] + ? "pass \"%.*s\" reads transient resource \"%.*s\" that no pass ever writes; " + "a transient starts uninitialized, so write \"%.*s\" before reading it." + : samePassWrite + ? "pass \"%.*s\" read-modify-writes transient resource \"%.*s\" that no earlier pass " + "produced; the same-pass write can't seed the read (it sees uninitialized contents). " + "Write \"%.*s\" in an earlier pass, or use storage_write if the shader is write-only." + : "pass \"%.*s\" reads resource \"%.*s\" before any pass " + "writes it; declare a writer of \"%.*s\" first."; + push_error(s, fmt, RG_NAME(p->id), RG_NAME(r->id), RG_NAME(r->id)); + hadError = true; + } + if (hadError) + return; + } + + // phase 3: accumulate WGPU usage and resolve concrete sizes. WebGPU requires the usage bits at create + // time, so realize() is left with only the device create calls. + { + uint32_t passIdx = 0; + for (PassNode* p = s.m_passes; p; p = p->next, ++passIdx) // m_passes == surviving (post-cull) passes + for (uint32_t i = 0; i < p->accessCount; ++i) { + ResourceNode* r = s.byId[p->accesses[i].handle.id]; + if (!r) + continue; + // transient-attachment bookkeeping: count live accesses and keep the last one + ++r->liveAccessCount; + r->soleAccess = &p->accesses[i]; + // lifetime: the walk is in execution order, so the first touch is firstUse and each later + // one overwrites lastUse. + if (!r->is_external()) { // external is pool- or caller-owned -> excluded from aliasing + if (r->firstUse == ResourceNode::kNoPass) { + r->firstUse = passIdx; + r->firstDefines = access_defines(p->accesses[i]); // aliasing: does the first touch overwrite? + } + r->lastUse = passIdx; + if (access_is_write(p->accesses[i].type)) + r->hasWriter = true; + } + // tex_usage_of/buf_usage_of are kind-agnostic, so dispatch on the node kind + if (r->kind == ResourceKind::Texture) + r->texUsage |= tex_usage_of(p->accesses[i].type); + else + r->bufUsage |= buf_usage_of(p->accesses[i].type); + } + + // walk each texture's relativeTo chain. memoized and recursive, so any declaration order works. + for (ResourceNode* r = s.m_resouces; r; r = r->next) + if (r->kind == ResourceKind::Texture) + resolve_size(r, s); + if (s.m_state == RenderGraphState::Failed) // a cyclic relativeTo chain leaves sizes unresolved + return; + + // a zero extent would reach wgpuDeviceCreateTexture and surface as a device validation error with + // no link back to the descriptor that caused it. + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + if (r->kind != ResourceKind::Texture || !r->texUsage) + continue; + if (r->resolved.width && r->resolved.height) + continue; + push_error(s, + "resource \"%.*s\" resolves to a zero extent (%u x %u); a texture needs a non-zero width " + "and height. Check its TextureDesc.absolute, or the relativeTo base it scales against.", + RG_NAME(r->id), + r->resolved.width, + r->resolved.height); + } + if (s.m_state == RenderGraphState::Failed) + return; + + // NOTE(Huerbe): usage==0 here == untouched by a live pass -> realize() skips it = free + // dead-resource culling. no separate resource liveness list needed. + } + + // reject a history whose .curr/.prev layers ended up asymmetrically culled. needs phase-3 usage. + validate_history_layers(s); + if (s.m_state == RenderGraphState::Failed) + return; + + // reject misauthored cube/cubearray views (needs the resolved layer counts from phase-3). + validate_cube_views(s); + if (s.m_state == RenderGraphState::Failed) + return; + + // reject accesses whose subresource/byte range does not fit the resource (same phase-3 dependency). + validate_access_ranges(s); + if (s.m_state == RenderGraphState::Failed) + return; + + // reject render passes WebGPU would refuse. runs after the range checks, so the attachment subresources + // it measures are already known to be in range. + validate_render_passes(s); + if (s.m_state == RenderGraphState::Failed) + return; + + // infer transient (memoryless) attachments from the usage gather. + detect_transient_attachments(s); + + // phase 4: transient memory aliasing (opt-in). pack disjoint-lifetime, same-signature transients onto + // a shared physical object + if (enableAlias) + { + ScopedScratch ss(s.m_allocator->scratch); + ResourceNode** elig = ss.alloc(s.next_resource_id); + uint32_t nElig = 0; + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + // a transientAttachment is memoryless, so there is no storage to alias + if (r->is_external() || r->firstUse == ResourceNode::kNoPass || !r->hasWriter || !r->firstDefines || r->transientAttachment) + continue; + if (r->kind == ResourceKind::Texture && (r->mipLevelCount != 1 || r->sampleCount != 1 || r->resolved.depthOrArrayLayers > 1)) + continue; + elig[nElig++] = r; + } + + // by firstUse asc. stable so equal firstUse keeps declaration order, which first-fit below depends on + std::stable_sort(elig, elig + nElig, [](const ResourceNode* a, const ResourceNode* b) { return a->firstUse < b->firstUse; }); + + // first-fit: reuse the first signature-matching slot whose occupant is already dead, else open a + // new one. strict freeFrom < firstUse, equality means they share a pass, which WebGPU forbids. + if (nElig) + s.m_slots = s.m_allocator->alloc(nElig); + for (uint32_t i = 0; i < nElig; ++i) { + ResourceNode* r = elig[i]; + const bool isBuf = r->kind == ResourceKind::Buffer; + // the signature match below assumes the eligibility filter kept mip>1 / MSAA nodes out. were + // that filter to change, the match would silently pass them, so trip here instead. + if (!isBuf && (r->mipLevelCount != 1 || r->sampleCount != 1)) { + push_error(s, + "resource \"%.*s\" reached alias slot assignment with %u mip(s) and %u sample(s); " + "aliasing eligibility must exclude mip>1 / MSAA nodes. This is an internal error, " + "not an authoring one.", + RG_NAME(r->id), + r->mipLevelCount, + r->sampleCount); + return; + } + uint32_t slot = ResourceNode::kNoSlot; + for (uint32_t k = 0; k < s.m_slotCount; ++k) { + PhysicalResource& ph = s.m_slots[k]; + if (ph.kind != r->kind) + continue; // never mix textures + buffers in one slot + const bool sig = isBuf ? (ph.bufferSize == r->bufferSize) : (ph.sig == tex_signature(r)); + if (sig && ph.freeFrom < r->firstUse) { + slot = k; + break; + } // no shared pass + } + if (slot == ResourceNode::kNoSlot) { + slot = s.m_slotCount++; + PhysicalResource& ph = s.m_slots[slot]; + ph.kind = r->kind; + ph.identity = r->id.name.data; // the member that opens the slot names it, see PhysicalResource + if (isBuf) + ph.bufferSize = r->bufferSize; + else + ph.sig = tex_signature(r); + } + PhysicalResource& ph = s.m_slots[slot]; + if (isBuf) + ph.bufUsage |= r->bufUsage; // widen to the union: every member shares this one object + else + ph.texUsage |= r->texUsage; + ph.freeFrom = r->lastUse; + r->aliasSlot = slot; + } + } + + auto t1 = std::chrono::steady_clock::now(); + s.timing_compile_us = std::chrono::duration(t1 - t0).count(); + + s.m_state = (s.m_errors == nullptr) ? RenderGraphState::Compiled : RenderGraphState::Failed; +} + +ErrorMessage* RenderGraph::compile(bool enableAlias) +{ + compile_impl(this, enableAlias); + return get_errors(); +} + +static uint32_t node_layers(const ResourceNode* r) +{ + return (r->dimension == WGPUTextureDimension_2D) ? (r->resolved.depthOrArrayLayers ? r->resolved.depthOrArrayLayers : 1) : 1; +} + +static uint32_t mip_dim(uint32_t v, uint32_t mip) +{ + v >>= mip; + return v ? v : 1; +} + +static void validate_cube_views(RenderGraphStorage& s) +{ + for (PassNode* p = s.m_passes; p; p = p->next) + for (uint32_t i = 0; i < p->accessCount; ++i) { + const ResourceAccess& a = p->accesses[i]; + if (a.viewDim != WGPUTextureViewDimension_Cube && a.viewDim != WGPUTextureViewDimension_CubeArray) + continue; + ResourceNode* r = s.byId[a.handle.id]; + if (!r || r->kind != ResourceKind::Texture) + continue; + + if (a.type == AccessType::StorageRead || a.type == AccessType::StorageWrite) { + push_error(s, "pass \"%.*s\": resource \"%.*s\" requests a cube view on a storage access; " + "WebGPU storage textures cannot be cube.", RG_NAME(p->id), RG_NAME(r->id)); + continue; + } + if (r->dimension != WGPUTextureDimension_2D) { + push_error(s, "pass \"%.*s\": resource \"%.*s\" requests a cube view but is not a 2D texture; " + "cube views require a 2D texture with 6 (cube) or 6*N (cube array) layers.", RG_NAME(p->id), RG_NAME(r->id)); + continue; + } + uint32_t layers = node_layers(r); + uint32_t layerCount = a.layerCount ? a.layerCount : (layers - a.baseLayer); // 0 == all remaining + if (a.viewDim == WGPUTextureViewDimension_Cube && layerCount != 6) + push_error(s, "pass \"%.*s\": cube view of \"%.*s\" covers %u layer(s); a cube view needs exactly 6. " + "Use ViewRange.layerCount = 6 (e.g. rg::cube()).", RG_NAME(p->id), RG_NAME(r->id), layerCount); + else if (a.viewDim == WGPUTextureViewDimension_CubeArray && (layerCount == 0 || layerCount % 6 != 0)) + push_error(s, "pass \"%.*s\": cube-array view of \"%.*s\" covers %u layer(s); a cube array needs a " + "positive multiple of 6 (e.g. rg::cube_array(n)).", RG_NAME(p->id), RG_NAME(r->id), layerCount); + else if (a.baseLayer + layerCount > layers) + push_error(s, "pass \"%.*s\": cube view of \"%.*s\" (baseLayer %u + %u layers) exceeds the " + "texture's %u layer(s).", RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.baseLayer, layerCount, layers); + } +} + +// render-pass rules WebGPU enforces on the descriptor execute() builds. checking them here names the pass +// and the resources that disagree, where the device can only name the descriptor it was handed. +static void validate_render_passes(RenderGraphStorage& s) +{ + struct Att { + const ResourceAccess* a; + ResourceNode* r; + }; + + // an attachment view is a single mip, so its size is that mip's size + auto att_w = [](const Att& t) { return mip_dim(t.r->resolved.width, t.a->baseMip); }; + auto att_h = [](const Att& t) { return mip_dim(t.r->resolved.height, t.a->baseMip); }; + + for (PassNode* p = s.m_passes; p; p = p->next) { + if (p->kind != PassKind::Graphics) + continue; + + Att color[kMaxColorAttachments] {}; + uint32_t colorCount = 0; + Att resolve[kMaxColorAttachments] {}; + uint32_t resolveCount = 0; + Att depth {}; + bool hasDepth = false; + + for (uint32_t i = 0; i < p->accessCount; ++i) { + const ResourceAccess& a = p->accesses[i]; + ResourceNode* r = s.byId[a.handle.id]; + if (!r) + continue; + const Att t { &a, r }; + if (a.type == AccessType::ColorAttachment && colorCount < kMaxColorAttachments) + color[colorCount++] = t; + else if (a.type == AccessType::ResolveAttachment && resolveCount < kMaxColorAttachments) + resolve[resolveCount++] = t; + else if (a.type == AccessType::DepthStencilAttachment || a.type == AccessType::DepthStencilReadOnly) { + depth = t; + hasDepth = true; + } else + continue; + + // execute() always leaves depthSlice at UNDEFINED, which WebGPU requires a 3D color target to + // set, so a 3D attachment can never be valid here + if (r->dimension == WGPUTextureDimension_3D) + push_error(s, + "pass \"%.*s\": resource \"%.*s\" is a 3D texture used as a render target; the graph " + "does not set depthSlice, so it cannot render into one. Use a 2D array texture.", + RG_NAME(p->id), RG_NAME(r->id)); + } + + if (!colorCount && !hasDepth) { + push_error(s, + "pass \"%.*s\" is a Graphics pass with no attachments; a render pass needs at least one " + "color() or a depth_stencil(). Use PassKind::Compute if the pass records no draws.", + RG_NAME(p->id)); + continue; + } + + // every color and depth attachment shares one size and sample count. resolve targets are excluded, + // they are single-sample by definition and checked against their own color below. + const Att& ref = colorCount ? color[0] : depth; + auto check_against_ref = [&](const Att& t) { + if (att_w(t) != att_w(ref) || att_h(t) != att_h(ref)) + push_error(s, + "pass \"%.*s\": attachment \"%.*s\" is %u x %u but \"%.*s\" is %u x %u; every " + "attachment in a render pass must be the same size.", + RG_NAME(p->id), RG_NAME(t.r->id), att_w(t), att_h(t), RG_NAME(ref.r->id), att_w(ref), att_h(ref)); + if (t.r->sampleCount != ref.r->sampleCount) + push_error(s, + "pass \"%.*s\": attachment \"%.*s\" has %u sample(s) but \"%.*s\" has %u; every " + "attachment in a render pass must have the same sample count.", + RG_NAME(p->id), RG_NAME(t.r->id), t.r->sampleCount, RG_NAME(ref.r->id), ref.r->sampleCount); + }; + for (uint32_t i = 0; i < colorCount; ++i) + check_against_ref(color[i]); + if (hasDepth) + check_against_ref(depth); + + if (hasDepth) { + const ResourceAccess& a = *depth.a; + if (!is_depth_format(depth.r->format)) + push_error(s, + "pass \"%.*s\": resource \"%.*s\" is bound as the depth-stencil attachment but its " + "format is not a depth format.", + RG_NAME(p->id), RG_NAME(depth.r->id)); + else { + const bool stencilOps = a.stencilLoadOp != WGPULoadOp_Undefined || a.stencilStoreOp != WGPUStoreOp_Undefined; + const bool hasStencil = format_has_stencil(depth.r->format); + if (stencilOps && !hasStencil) + push_error(s, + "pass \"%.*s\": depth-stencil attachment \"%.*s\" declares stencil load/store ops " + "but its format has no stencil aspect.", + RG_NAME(p->id), RG_NAME(depth.r->id)); + // read-only binds both aspects read-only, so WebGPU asks for no ops at all + if (!stencilOps && hasStencil && a.type == AccessType::DepthStencilAttachment) + push_error(s, + "pass \"%.*s\": depth-stencil attachment \"%.*s\" has a stencil aspect, so WebGPU " + "requires stencil load/store ops; pass them to depth_stencil().", + RG_NAME(p->id), RG_NAME(depth.r->id)); + } + } + + // a resolve target takes the multisampled colour of the slot it was declared against + for (uint32_t i = 0; i < resolveCount; ++i) { + const Att& t = resolve[i]; + const Att* src = nullptr; + for (uint32_t k = 0; k < colorCount; ++k) + if (color[k].a->colorIndex == t.a->colorIndex) { + src = &color[k]; + break; + } + if (!src) + continue; // resolve() rejects a src that is not a color() in this pass + if (src->r->sampleCount <= 1) + push_error(s, + "pass \"%.*s\": \"%.*s\" resolves \"%.*s\", which has %u sample(s); a resolve source " + "must be multisampled.", + RG_NAME(p->id), RG_NAME(t.r->id), RG_NAME(src->r->id), src->r->sampleCount); + if (t.r->sampleCount != 1) + push_error(s, + "pass \"%.*s\": resolve target \"%.*s\" has %u sample(s); a resolve target must be " + "single-sampled.", + RG_NAME(p->id), RG_NAME(t.r->id), t.r->sampleCount); + if (t.r->format != src->r->format) + push_error(s, + "pass \"%.*s\": resolve target \"%.*s\" and its source \"%.*s\" must have the same " + "format.", + RG_NAME(p->id), RG_NAME(t.r->id), RG_NAME(src->r->id)); + if (att_w(t) != att_w(*src) || att_h(t) != att_h(*src)) + push_error(s, + "pass \"%.*s\": resolve target \"%.*s\" is %u x %u but its source \"%.*s\" is %u x %u; " + "they must be the same size.", + RG_NAME(p->id), RG_NAME(t.r->id), att_w(t), att_h(t), RG_NAME(src->r->id), att_w(*src), att_h(*src)); + } + } +} + +// true for the access types whose declared BufferRange ctx.bind() applies +static bool is_bind_access(AccessType t) { return t == AccessType::Uniform || t == AccessType::StorageRead || t == AccessType::StorageWrite; } + +static void validate_access_ranges(RenderGraphStorage& s) +{ + for (PassNode* p = s.m_passes; p; p = p->next) + for (uint32_t i = 0; i < p->accessCount; ++i) { + const ResourceAccess& a = p->accesses[i]; + ResourceNode* r = s.byId[a.handle.id]; + if (!r) + continue; + + if (r->kind == ResourceKind::Texture) { + const uint32_t mips = r->mipLevelCount; + const uint32_t layers = node_layers(r); + if (a.baseMip >= mips) { + push_error(s, "pass \"%.*s\": resource \"%.*s\" is accessed at mip %u but has %u mip(s).", + RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.baseMip, mips); + continue; + } + if (a.baseLayer >= layers) { + push_error(s, "pass \"%.*s\": resource \"%.*s\" is accessed at layer %u but has %u layer(s).", + RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.baseLayer, layers); + continue; + } + // a count of 0 means all remaining, so only an explicit count can overrun. + if (a.mipCount && uint32_t(a.baseMip) + a.mipCount > mips) + push_error(s, "pass \"%.*s\": resource \"%.*s\" declares a view of %u mip(s) from mip %u but has %u mip(s).", + RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.mipCount, (uint32_t)a.baseMip, mips); + if (a.layerCount && uint32_t(a.baseLayer) + a.layerCount > layers) + push_error(s, "pass \"%.*s\": resource \"%.*s\" declares a view of %u layer(s) from layer %u but has %u layer(s).", + RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.layerCount, (uint32_t)a.baseLayer, layers); + continue; + } + + if (!a.bufSize && !a.bufOffset) + continue; + if (a.bufOffset + a.bufSize > r->bufferSize) + push_error(s, "pass \"%.*s\": range on \"%.*s\" covers bytes [%llu, %llu) but the buffer is %llu byte(s).", + RG_NAME(p->id), RG_NAME(r->id), (unsigned long long)a.bufOffset, (unsigned long long)(a.bufOffset + a.bufSize), + (unsigned long long)r->bufferSize); + if (is_bind_access(a.type)) { + // a declared offset at or past the end resolves to a zero-byte range + if (!a.bufSize) + push_error(s, "pass \"%.*s\": range on \"%.*s\" starts at byte %llu but the buffer is %llu byte(s).", + RG_NAME(p->id), RG_NAME(r->id), (unsigned long long)a.bufOffset, (unsigned long long)r->bufferSize); + // 256 is the spec-default minUniform/StorageBufferOffsetAlignment. the graph checks + // the portable default rather than querying device limits. + if (a.bufOffset % 256 != 0) + push_error(s, "pass \"%.*s\": binding range on \"%.*s\" starts at byte %llu, which is not 256-byte aligned.", + RG_NAME(p->id), RG_NAME(r->id), (unsigned long long)a.bufOffset); + } + } +} + +static WGPUTextureViewDimension default_view_dim(const ResourceNode* r, uint32_t layerCount) +{ + return (r->dimension == WGPUTextureDimension_3D) ? WGPUTextureViewDimension_3D + : (layerCount > 1) ? WGPUTextureViewDimension_2DArray + : WGPUTextureViewDimension_2D; +} + +static Subview** find_subview_list(GraphAllocator* a, WGPUTexture tex) +{ + if (!tex) + return nullptr; + for (TransientResourcePool::Entry* e = a->transient.entries; e; e = e->next) + if (e->kind == ResourceKind::Texture && e->tex == tex) + return &e->subviews; + for (PersistentResourcePool::Entry* e = a->pool.entries; e; e = e->next) + for (uint32_t i = 0; i < PersistentResourcePool::kLayers; ++i) + if (e->tex[i] == tex) + return &e->subviews[i]; + return nullptr; +} + + +static ViewKey view_signature_for(const ResourceNode* r, const ResourceAccess& a) +{ + uint32_t layers = node_layers(r); + uint32_t mipCount = a.mipCount ? a.mipCount : (r->mipLevelCount - a.baseMip); + uint32_t layerCount = a.layerCount ? a.layerCount : (layers - a.baseLayer); + WGPUTextureViewDimension dim = a.viewDim; + if (dim == WGPUTextureViewDimension_Undefined) + dim = default_view_dim(r, layerCount); + return ViewKey { + .format = r->format, + .dimension = dim, + .aspect = a.viewAspect, + .baseMipLevel = a.baseMip, + .mipLevelCount = mipCount, + .baseArrayLayer = a.baseLayer, + .arrayLayerCount = layerCount, + }; +} + +static bool is_full_view(const ResourceNode* r, const ViewKey& k) +{ + uint32_t layers = node_layers(r); + WGPUTextureViewDimension full = default_view_dim(r, layers); + return k.baseMipLevel == 0 && k.mipLevelCount == r->mipLevelCount && k.baseArrayLayer == 0 && k.arrayLayerCount == layers + && k.aspect == WGPUTextureAspect_All && k.format == r->format && k.dimension == full; +} + +static WGPUTextureView resolve_view(RenderGraphStorage& s, ResourceNode* r, const ViewKey& key) +{ + if (!r->texture) { // imported + Q_ASSERT(key.baseMipLevel == 0 && key.baseArrayLayer == 0 && "subresource selection on an imported texture is ignored (caller owns the view)"); + Q_ASSERT(key.aspect == WGPUTextureAspect_All && key.format == r->format + && "aspect/format reinterpretation on an imported texture is ignored (caller owns the view)"); + return r->view; + } + if (is_full_view(r, key)) + return r->view; + if (r->subviews) + return subview_for(s.m_allocator->subviews, *r->subviews, r->texture, key); + + if (s.viewScratchN == s.viewScratchCap) + { + uint32_t newCap = s.viewScratchCap ? s.viewScratchCap * 2 : 8; + WGPUTextureView* grown = s.m_allocator->scratch.alloc(newCap); + if (s.viewScratchN) + std::memcpy(grown, s.viewScratch, (size_t)s.viewScratchN * sizeof(WGPUTextureView)); + s.viewScratch = grown; + s.viewScratchCap = newCap; + } + WGPUTextureViewDescriptor desc = key.desc(); + return s.viewScratch[s.viewScratchN++] = wgpuTextureCreateView(r->texture, &desc); +} + +static const ResourceAccess* find_pass_access(const PassNode* pass, ResourceHandle h) +{ + for (uint32_t i = 0; i < pass->accessCount; ++i) + if (pass->accesses[i].handle.id == h.id) + return &pass->accesses[i]; + return nullptr; +} + +static const ResourceAccess* find_copy_access(PassNode* pass, ResourceHandle h, bool wantDst, const char* who) +{ + const ResourceAccess* found = nullptr; + AccessType want = wantDst ? AccessType::CopyDst : AccessType::CopySrc; + for (uint32_t i = 0; i < pass->accessCount; ++i) + { + const ResourceAccess& a = pass->accesses[i]; + if (a.handle.id != h.id || a.type != want) + continue; + Q_ASSERT(!found && "ctx: two copy declarations pair the same handle+direction in this pass; ambiguous subresource"); + found = &a; + } + Q_ASSERT_X(found, "find_copy_access", who); + return found; +} + +// finds the bind-type access for h +static const ResourceAccess* find_bind_access(PassNode* pass, ResourceHandle h) +{ + const ResourceAccess* found = nullptr; + for (uint32_t i = 0; i < pass->accessCount; ++i) + { + const ResourceAccess& a = pass->accesses[i]; + if (a.handle.id != h.id || !is_bind_access(a.type)) + continue; + Q_ASSERT((!found || (found->bufOffset == a.bufOffset && found->bufSize == a.bufSize)) + && "ctx.bind: two bind declarations give the same buffer different ranges in this pass; ambiguous bind"); + found = &a; + } + return found; +} + +static WGPUExtent3D copy_extent_for(RenderGraph* graph, PassNode* pass, ResourceHandle h, bool wantDst) +{ + Q_ASSERT(h.kind == ResourceKind::Texture); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + const ResourceAccess* a = find_copy_access(pass, h, wantDst, "copy_extent: no matching copy declared with this handle in this pass"); + uint32_t depth; + if (r->dimension == WGPUTextureDimension_3D) + depth = mip_dim(r->resolved.depthOrArrayLayers, a->baseMip); // baseLayer is 0 here, node_layers() reports 1 for 3D so validate_access_ranges rejects any other + else + depth = a->layerCount ? a->layerCount : (node_layers(r) - a->baseLayer); // 0 == all remaining + return WGPUExtent3D { mip_dim(r->resolved.width, a->baseMip), mip_dim(r->resolved.height, a->baseMip), depth }; +} + +static bool history_valid_impl(RenderGraph* graph, ResourceHandle h) +{ + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "ctx: failed to find node! Handle is 0 or from another graph"); + if (!r) + return false; + Q_ASSERT(r->persistent && "ctx: can only be called on persistent resources"); + return r->historyValid; +} + +bool PassContext::history_valid_impl(ResourceHandle prev) const +{ + return webgpu::rg::history_valid_impl(graph, prev); +} + +WGPUTextureView PassContext::view(TextureHandle h) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Texture && "ctx.view: only textures can create a view"); + Q_ASSERT(pass); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) + { + return {}; + } + Q_ASSERT(find_pass_access(pass, h) && "ctx.view: resource not declared as an access in this pass's setup"); + if (!r->texture) + return r->view; + + const ResourceAccess* rd = nullptr; + const ResourceAccess* wr = nullptr; + for (uint32_t i = 0; i < pass->accessCount; ++i) { + const ResourceAccess& a = pass->accesses[i]; + if (a.handle.id != h.id) + continue; + if (!access_is_write(a.type)) { + Q_ASSERT(!rd && "ctx.view: two reads of one handle in a pass; ambiguous subresource; use ctx.texture"); + rd = &a; + } else { + wr = &a; + } + } + const ResourceAccess* acc = rd ? rd : wr; + if (!acc) + return r->view; + RenderGraphStorage& s = *storage(graph); + return resolve_view(s, r, view_signature_for(r, *acc)); +} + + +static bool subres_declared(const PassNode* pass, const ResourceNode* r, ResourceHandle h, uint32_t mip, uint32_t layer) +{ + for (uint32_t i = 0; i < pass->accessCount; ++i) { + const ResourceAccess& a = pass->accesses[i]; + if (a.handle.id != h.id) + continue; + uint32_t mipEnd = a.mipCount ? a.baseMip + a.mipCount : r->mipLevelCount; + uint32_t layerEnd = a.layerCount ? a.baseLayer + a.layerCount : node_layers(r); + if (mip >= a.baseMip && mip < mipEnd && layer >= a.baseLayer && layer < layerEnd) + return true; + } + return false; +} + +WGPUTextureView PassContext::view(TextureHandle h, uint32_t mip, uint32_t layer) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Texture && "ctx.view: only textures can create a view"); + Q_ASSERT(pass); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) { + return {}; + } + Q_ASSERT(find_pass_access(pass, h) && "ctx.view: resource not declared as an access in this pass's setup"); + if (!r->texture) { // imported: the caller-owned view spans the whole resource + Q_ASSERT(mip == 0 && layer == 0 && "subresource selection on an imported texture is ignored (caller owns the view)"); + return r->view; + } + Q_ASSERT(mip < r->mipLevelCount && "ctx.view: mip past the texture's mip count"); + Q_ASSERT(layer < node_layers(r) && "ctx.view: layer past the texture's layer count"); + Q_ASSERT(subres_declared(pass, r, h, mip, layer) && "ctx.view: mip/layer outside every range this pass declared on the resource"); + ViewKey key { + .format = r->format, + .dimension = default_view_dim(r, 1), // single-layer subview -> never the 2DArray arm + .aspect = WGPUTextureAspect_All, + .baseMipLevel = mip, + .mipLevelCount = 1, + .baseArrayLayer = layer, + .arrayLayerCount = 1, + }; + return resolve_view(*storage(graph), r, key); +} + +WGPUTexture PassContext::texture(TextureHandle h) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Texture); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) { + return {}; + } + Q_ASSERT(find_pass_access(pass, h) && "ctx.texture: resource not declared as an access in this pass's setup"); + return r->texture; +} + +WGPUBuffer PassContext::buffer(BufferHandle h) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Buffer); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) { + return {}; + } + Q_ASSERT(find_pass_access(pass, h) && "ctx.buffer: resource not declared as an access in this pass's setup"); + Q_ASSERT(r->buffer && "ctx.buffer(): resource declared but never realized (no live writer)"); + return r->buffer; +} + +WGPUBindGroupEntry PassContext::bind(uint32_t binding, ResourceHandle h) const +{ + if (h.kind == ResourceKind::Texture) + return webgpu::bind(binding, view(TextureHandle { h })); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + const ResourceAccess* a = find_bind_access(pass, h); + if (a && a->bufSize) + return webgpu::bind(binding, buffer(BufferHandle { h }), a->bufOffset, a->bufSize); + return webgpu::bind(binding, buffer(BufferHandle { h }), 0, r ? r->bufferSize : 0); +} + +WGPUBindGroupEntry PassContext::bind(uint32_t binding, TextureHandle h, uint32_t mip, uint32_t layer) const +{ + return webgpu::bind(binding, view(h, mip, layer)); +} + +WGPUExtent3D PassContext::texture_size(TextureHandle h) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Texture); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) { + return {}; + } + return r->resolved; +} + +WGPUExtent3D PassContext::texture_size(TextureHandle h, uint32_t mip) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Texture); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) { + return {}; + } + Q_ASSERT(mip < (r->mipLevelCount ? r->mipLevelCount : 1) && "ctx.texture_size: mip past the texture's mip count"); + uint32_t d = r->resolved.depthOrArrayLayers ? r->resolved.depthOrArrayLayers : 1; + if (r->dimension == WGPUTextureDimension_3D) + d = mip_dim(d, mip); // depth shifts, array layers don't + return WGPUExtent3D { mip_dim(r->resolved.width, mip), mip_dim(r->resolved.height, mip), d }; +} + +WGPUTextureFormat PassContext::format(TextureHandle h) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Texture); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) { + return WGPUTextureFormat_Undefined; + } + Q_ASSERT(r->format != WGPUTextureFormat_Undefined && "ctx.format: imported texture without a format; pass it to import_texture"); + return r->format; +} + +uint32_t PassContext::mip_count(TextureHandle h) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Texture); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) { + return 0; + } + Q_ASSERT(r->mipLevelCount && "ctx.mip_count: mip count is 0"); + return r->mipLevelCount; +} + +uint32_t PassContext::sample_count(TextureHandle h) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Texture); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) { + return 0; + } + Q_ASSERT(r->sampleCount && "ctx.sample_count: sample count is 0"); + return r->sampleCount; +} + +uint64_t PassContext::buffer_size(BufferHandle h) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Buffer); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + if (!r) { + return {}; + } + return r->bufferSize; +} + +PassContext::BufferCopyInfo PassContext::buffer_copy_info(BufferHandle src, BufferHandle dst) const +{ + Q_ASSERT(src.id != 0 && dst.id != 0); + Q_ASSERT(src.kind == ResourceKind::Buffer && dst.kind == ResourceKind::Buffer && "buffer_copy_info: both handles must be buffers"); + const ResourceAccess* sa = find_copy_access(pass, src, false, "buffer_copy_info: no copy_buffer() with this handle as src declared in this pass"); + const ResourceAccess* da = find_copy_access(pass, dst, true, "buffer_copy_info: no copy_buffer() with this handle as dst declared in this pass"); + ResourceNode* sn = find_node(graph, src); + ResourceNode* dn = find_node(graph, dst); + Q_ASSERT(sn && dn && "buffer_copy_info: handle is 0 or from another graph"); + Q_ASSERT(sn->buffer && dn->buffer && "buffer_copy_info: buffer declared but never realized"); + + return { sn->buffer, sa->bufOffset, dn->buffer, da->bufOffset, sa->bufSize }; +} + +static WGPUTexelCopyTextureInfo copy_texture_info(RenderGraph* graph, PassNode* pass, ResourceHandle h, bool wantDst, WGPUOrigin3D origin, WGPUTextureAspect aspect) +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Texture); + ResourceNode* r = find_node(graph, h); + Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph"); + Q_ASSERT(r->texture + && "copy_*_info: imported texture has no backing WGPUTexture; pass the WGPUTexture to " + "import_texture to enable copies, or copy via a render-pass blit instead"); + const ResourceAccess* a = find_copy_access(pass, + h, + wantDst, + wantDst ? "copy_dst_info: no copy with this handle as dst declared in this pass" + : "copy_src_info: no copy with this handle as src declared in this pass"); + origin.z += a->baseLayer; + return WGPUTexelCopyTextureInfo { .texture = r->texture, .mipLevel = a->baseMip, .origin = origin, .aspect = aspect }; +} + +WGPUTexelCopyTextureInfo PassContext::copy_src_info(TextureHandle h, WGPUOrigin3D origin, WGPUTextureAspect aspect) const +{ + return copy_texture_info(graph, pass, h, false, origin, aspect); +} + +WGPUTexelCopyTextureInfo PassContext::copy_dst_info(TextureHandle h, WGPUOrigin3D origin, WGPUTextureAspect aspect) const +{ + return copy_texture_info(graph, pass, h, true, origin, aspect); +} + +WGPUExtent3D PassContext::copy_extent_src(TextureHandle h) const +{ + return copy_extent_for(graph, pass, h, false); +} + +WGPUExtent3D PassContext::copy_extent_dst(TextureHandle h) const +{ + return copy_extent_for(graph, pass, h, true); +} + +WGPUTexelCopyBufferInfo PassContext::copy_src_buffer(BufferHandle h, WGPUTexelCopyBufferLayout layout) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Buffer); + find_copy_access(pass, h, /*wantDst=*/false, "copy_src_buffer: no copy with this handle as src declared in this pass"); + return WGPUTexelCopyBufferInfo { .layout = layout, .buffer = buffer(h) }; +} + +WGPUTexelCopyBufferInfo PassContext::copy_dst_buffer(BufferHandle h, WGPUTexelCopyBufferLayout layout) const +{ + Q_ASSERT(h.id != 0); + Q_ASSERT(h.kind == ResourceKind::Buffer); + find_copy_access(pass, h, /*wantDst=*/true, "copy_dst_buffer: no copy with this handle as dst declared in this pass"); + return WGPUTexelCopyBufferInfo { .layout = layout, .buffer = buffer(h) }; +} + +static void release_resources(RenderGraph* rg); + +static constexpr bool is_mappable(WGPUBufferUsage usage) +{ + return (usage & (WGPUBufferUsage_MapRead | WGPUBufferUsage_MapWrite)) != 0; +} + +// create the GPU resources compile() worked out +static void realize_graph(RenderGraph* rg, WGPUDevice device) +{ + auto t0 = std::chrono::steady_clock::now(); + RenderGraphStorage& s = *storage(rg); + if (s.m_state == RenderGraphState::Failed) + return; + if (s.m_state != RenderGraphState::Compiled) { + push_error(s, "realize() reached a graph that was never compiled. This is an internal error, not an authoring one."); + return; + } + + // inferred transient attachments get the memoryless usage bit only where the platform supports it +#ifdef __EMSCRIPTEN__ + const bool transientOK = rg_web_has_transient_attachment() != 0; +#else + const bool transientOK = wgpuDeviceHasFeature(device, kTransientAttachmentsFeature); +#endif + s.m_allocator->transientFeatureOn = transientOK; + + PersistentResourcePool& pool = s.m_allocator->pool; + TransientResourcePool& transient = s.m_allocator->transient; + + auto pool_used = [](const ResourceNode* r) { return r->kind == ResourceKind::Texture ? r->texUsage != 0 : r->bufUsage != 0; }; + + // touch + union usage into the entry. curr and prev must be co-used, or the rotation would surface a + // never-written slot. compile() already rejects that asymmetry. + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + if (!r->persistent || !pool_used(r)) + continue; + PersistentResourcePool::Entry* e = pool.touch(r->id, r->history ? PersistentResourcePool::kLayers : 1, r->historyHash, r->kind); + if (r->kind == ResourceKind::Texture) + e->usage |= r->texUsage; + else + e->bufUsage |= r->bufUsage; + } + + // realize + point each layer node at its rotated slot + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + if (!r->persistent || !pool_used(r)) + continue; + PersistentResourcePool::Entry* e = pool.find(r->id); // touched above -> non-null + if (!e) { + push_error(s, + "persistent resource \"%.*s\" is used but has no pool entry from the touch pass. " + "This is an internal error, not an authoring one.", + RG_NAME(r->id)); + return; + } + // preconditions realize_*_entry relies on and cannot report itself: usage only ever grows, and a + // recreate never lands twice in one frame. + const bool usageGrewOnly = r->kind == ResourceKind::Texture ? (e->usageAtCreate & ~e->usage) == 0 : (e->bufUsageAtCreate & ~e->bufUsage) == 0; + const bool wouldRecreate = r->kind == ResourceKind::Texture + ? PersistentResourcePool::tex_entry_would_recreate(*e, tex_signature(r), e->usage) + : PersistentResourcePool::buf_entry_would_recreate(*e, r->bufferSize, e->bufUsage); + if (!usageGrewOnly || (wouldRecreate && e->createdClock == pool.evictClock)) { + push_error(s, + "persistent resource \"%.*s\": %s. This is an internal error, not an authoring one.", + RG_NAME(r->id), + !usageGrewOnly ? "its pooled usage shrank since the entry was created" + : "its layer nodes disagree on the descriptor, so the pool entry would be recreated twice in one frame"); + return; + } + uint32_t sl = pool.slot(*e, r->historyIndex); + if (r->kind == ResourceKind::Texture) { + pool.realize_texture_entry(e, device, tex_signature(r)); + r->texture = e->tex[sl]; + r->view = e->view[sl]; + } else { + pool.realize_buffer_entry(e, device, r->bufferSize); + r->buffer = e->buf[sl]; + } + // .prev holds valid history only if the entry survived from a prior frame, was not recreated this + // frame, and was used last frame. + r->historyValid = r->history && (e->createdClock != pool.evictClock) && (e->prevTouched == pool.evictClock - 1); + } + + // aliasing (compile() phase 4): one acquire per slot with the union usage, then point every member at + // it. per slot, not per member, keeps the claim count right. m_slotCount is 0 when aliasing is off, + // leaving the loops below to realize each transient alone. + for (uint32_t i = 0; i < s.m_slotCount; ++i) { + PhysicalResource& ph = s.m_slots[i]; + if (ph.kind == ResourceKind::Texture) { + transient.acquire(device, ph.sig, ph.texUsage, ph.identity, ph.texture, ph.view); + continue; + } + if (is_mappable(ph.bufUsage)) { + push_error(s, + "alias slot %u asks for a mappable transient buffer; transient buffers are never mappable, " + "import a caller-owned buffer for readback or staging. This is an internal error, not an " + "authoring one.", + i); + return; + } + transient.acquire(device, ph.bufferSize, ph.bufUsage, ph.identity, ph.buffer); + } + for (ResourceNode* r = s.m_resouces; r; r = r->next) + if (r->aliasSlot != ResourceNode::kNoSlot) { + PhysicalResource& ph = s.m_slots[r->aliasSlot]; + r->texture = ph.texture; + r->view = ph.view; + r->buffer = ph.buffer; + } + + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + // graph-owned per-frame TEXTURES not on an alias slot: reuse a pooled texture matching this + // descriptor. release_resources() leaves it alone, the pool evicts at end_frame(). + if (r->is_external() || r->kind != ResourceKind::Texture || !r->texUsage || r->aliasSlot != ResourceNode::kNoSlot) + continue; + // memoryless hint for an inferred transient attachment. the pool keys on usage, so these never + // collide with ordinary render targets. + WGPUTextureUsage usage = r->texUsage; + if (transientOK && r->transientAttachment) + usage |= WGPUTextureUsage_TransientAttachment; + transient.acquire(device, tex_signature(r), usage, r->id.name.data, r->texture, r->view); + } + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + // graph-owned per-frame BUFFERS not on an alias slot. + if (r->is_external() || r->kind != ResourceKind::Buffer || !r->bufUsage || r->aliasSlot != ResourceNode::kNoSlot) + continue; + if (is_mappable(r->bufUsage)) { + push_error(s, + "transient buffer \"%.*s\" asks for a mappable usage; transient buffers are never mappable, " + "import a caller-owned buffer for readback or staging. This is an internal error, not an " + "authoring one.", + RG_NAME(r->id)); + return; + } + transient.acquire(device, r->bufferSize, r->bufUsage, r->id.name.data, r->buffer); + } + + // point each texture node at the subview cache of its realized physical texture, so ctx.view() reuses + // subresource views across frames. by handle, and after every acquire, so it cannot dangle. + for (ResourceNode* r = s.m_resouces; r; r = r->next) + if (r->kind == ResourceKind::Texture && r->texture) + r->subviews = find_subview_list(s.m_allocator, r->texture); + + auto t1 = std::chrono::steady_clock::now(); + s.timing_realize_us = std::chrono::duration(t1 - t0).count(); +} + +void RenderGraph::execute(WGPUDevice device, WGPUQueue queue, WGPUCommandEncoder encoder, bool enableProfiling) +{ + RenderGraphStorage& s = *storage(this); + if (s.m_state != RenderGraphState::Compiled) { + Q_ASSERT(s.m_state == RenderGraphState::Failed && "execute(): graph not compiled; compile() missing, or execute() called twice"); + return; + } + + ScopedScratch scratch(s.m_allocator->scratch); + s.viewScratchCap = 8; + s.viewScratch = scratch.alloc(s.viewScratchCap); + + realize_graph(this, device); + if (s.m_state != RenderGraphState::Compiled) + return; // realize failed: the pass bodies would encode against unrealized resources + + auto t0 = std::chrono::steady_clock::now(); + + // opt-in GPU timing: lazily build the query set and buffers, then grab a free read-back ring slot. with + // every slot awaiting its map callback, skip profiling this frame rather than stall. + GpuProfiler& prof = s.m_allocator->profiler; + bool profiling = enableProfiling; + int slotIdx = -1; + // one pendingSlot per allocator, so only one profiled graph per frame. run this one unprofiled rather + // than clobber an earlier graph's results. + if (profiling && prof.pendingSlot != -1) { + qDebug("[RenderGraph] profiling skipped for this graph: an earlier graph this frame is already profiled\n"); + profiling = false; + } + if (profiling) { + prof.init(device); + // disable profiling when no free slot is found + slotIdx = prof.free_slot(); + if (slotIdx < 0) + profiling = false; + } + GpuProfiler::Slot* slot = profiling ? &prof.ring[slotIdx] : nullptr; + uint32_t pi = 0; // begin/end pair index, advanced only for passes that run, keeps the resolve range contiguous + + // bracket each contiguous run of same-prefix passes in an encoder debug group, so a RenderDoc/PIX + // capture shows collapsible regions. push/pop happen between passes, so they are balanced. + WGPUStringView openGroup {}; + for (PassNode* p = s.m_passes; p; p = p->next) { + Q_ASSERT(p->kind != PassKind::None && "a pass of kind None is not allowed"); + WGPUStringView grp = group_prefix(p->id.name); + if (!sv_eq(grp, openGroup)) { + if (sv_length(openGroup)) + wgpuCommandEncoderPopDebugGroup(encoder); + if (sv_length(grp)) + wgpuCommandEncoderPushDebugGroup(encoder, grp); + openGroup = grp; + } + + // an initialize() pass that survived the cull bakes this frame: stamp each target baked, so next + // frame's compile() skips it. recorded here, not in compile(), so no frame claims a bake it never ran. + if (p->initCount && p->exec_fn) + for (uint32_t i = 0; i < p->initCount; ++i) + if (ResourceNode* t = find_node(this, p->initTargets[i].target)) + if (PersistentResourcePool::Entry* e = s.m_allocator->pool.find(t->id)) { + e->initHash = p->initTargets[i].hash; + e->baked = true; + } + + PassContext ctx {}; + ctx.encoder = encoder; + ctx.graph = this; + ctx.queue = queue; + ctx.device = device; + ctx.pass = p; + s.viewScratchN = 0; // per-pass: attachment + body ctx.view() views accumulate here, freed after the body + + // time this pass iff profiling is live, the pass records a body, and the set has a free pair. + // Transfer passes need RG_TIME_TRANSFER_PASSES, they use off-spec encoder timestamps. +#ifdef RG_TIME_TRANSFER_PASSES + constexpr bool kTimeTransfer = true; +#else + constexpr bool kTimeTransfer = false; +#endif + const bool timeThis = profiling && p->exec_fn && pi < GpuProfiler::kMaxPasses && (kTimeTransfer || p->kind != PassKind::Transfer); + WGPUPassTimestampWrites tw { .querySet = prof.querySet, .beginningOfPassWriteIndex = 2 * pi, .endOfPassWriteIndex = 2 * pi + 1 }; + if (timeThis) + slot->names[pi] = p->id.name; // interned view, stable until the read-back lands frames later + + if (p->kind == PassKind::Compute && p->exec_fn) { + WGPUComputePassDescriptor cd { .label = p->id.name, .timestampWrites = timeThis ? &tw : nullptr }; + ctx.compute_pass = wgpuCommandEncoderBeginComputePass(encoder, &cd); + p->exec_fn(p->exec_obj, ctx); + wgpuComputePassEncoderEnd(ctx.compute_pass); + wgpuComputePassEncoderRelease(ctx.compute_pass); + } else if (p->kind == PassKind::Graphics) { + // no exec_fn guard: a body-less graphics pass still begins and ends, so its clear loadOps run + // gather declared attachments from the access list -> WebGPU render pass descriptor + WGPURenderPassColorAttachment color[kMaxColorAttachments] {}; + // color() slots may be sparse, so pre-seed every entry as a valid null attachment. depthSlice + // is the one field whose null value is not 0. + for (uint32_t i = 0; i < kMaxColorAttachments; ++i) + color[i].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + uint32_t maxColorSlot = 0; // highest explicit slot seen -> colorAttachmentCount = maxColorSlot + 1 + bool anyColor = false; + WGPURenderPassDepthStencilAttachment depth {}; + bool hasDepth = false; + + // an attachment view is exactly one subresource, a render target cannot span a mip chain or an + // array. attachment accesses carry no ViewRange, so this is the (baseMip, baseLayer) slice. + auto attach_view = [&](ResourceNode* r, const ResourceAccess& a) -> WGPUTextureView { + Q_ASSERT(a.baseMip < r->mipLevelCount && "attachment baseMip past the texture's mip count"); + Q_ASSERT((!r->texture || a.baseLayer < node_layers(r)) && "attachment baseLayer past the texture's layer count"); + return resolve_view(s, r, view_signature_for(r, a)); + }; + + for (uint32_t i = 0; i < p->accessCount; ++i) { + const ResourceAccess& a = p->accesses[i]; + ResourceNode* r = find_node(this, a.handle); + if (!r) + continue; + if (a.type == AccessType::ColorAttachment && a.colorIndex < kMaxColorAttachments) { // color() asserted range; release backstop + color[a.colorIndex] = WGPURenderPassColorAttachment { + .view = attach_view(r, a), + .depthSlice = WGPU_DEPTH_SLICE_UNDEFINED, + .resolveTarget = nullptr, // patched below if a resolve() in this pass targets this slot + .loadOp = a.loadOp, + .storeOp = a.storeOp, + .clearValue = WGPUColor { a.clearColor[0], a.clearColor[1], a.clearColor[2], a.clearColor[3] }, // f32 -> WGPUColor(double) + }; + if (!anyColor || a.colorIndex > maxColorSlot) + maxColorSlot = a.colorIndex; + anyColor = true; + } else if (a.type == AccessType::ResolveAttachment && a.colorIndex < kMaxColorAttachments) { + // resolve() stamped the color slot it targets onto colorIndex, so patch that slot. + color[a.colorIndex].resolveTarget = attach_view(r, a); + } else if (a.type == AccessType::DepthStencilAttachment || a.type == AccessType::DepthStencilReadOnly) { + depth = WGPURenderPassDepthStencilAttachment { + .view = attach_view(r, a), + .depthLoadOp = a.loadOp, + .depthStoreOp = a.storeOp, + .depthClearValue = a.clearDepth, + .depthReadOnly = a.type == AccessType::DepthStencilReadOnly, + .stencilLoadOp = a.stencilLoadOp, + .stencilStoreOp = a.stencilStoreOp, + .stencilClearValue = a.stencilClear, + .stencilReadOnly = a.type == AccessType::DepthStencilReadOnly, + }; + hasDepth = true; + } + } + + WGPURenderPassDescriptor rd { + .label = p->id.name, + .colorAttachmentCount = anyColor ? maxColorSlot + 1 : 0, + .colorAttachments = color, + .depthStencilAttachment = hasDepth ? &depth : nullptr, + .timestampWrites = timeThis ? &tw : nullptr, + }; + ctx.render_pass = wgpuCommandEncoderBeginRenderPass(encoder, &rd); + if (p->exec_fn) + p->exec_fn(p->exec_obj, ctx); + wgpuRenderPassEncoderEnd(ctx.render_pass); + wgpuRenderPassEncoderRelease(ctx.render_pass); + } else { // Transfer: body records straight onto the encoder. no pass object -> bracket with encoder timestamps. + if (p->exec_fn) { +#ifdef RG_TIME_TRANSFER_PASSES // off-spec: the device must be created with the allow_unsafe_apis toggle + if (timeThis) + wgpuCommandEncoderWriteTimestamp(encoder, prof.querySet, 2 * pi); +#endif + p->exec_fn(p->exec_obj, ctx); +#ifdef RG_TIME_TRANSFER_PASSES + if (timeThis) + wgpuCommandEncoderWriteTimestamp(encoder, prof.querySet, 2 * pi + 1); +#endif + } + } + + if (timeThis) + ++pi; + + // free the pass-scoped subresource views, attachments and ctx.view() alike + for (uint32_t i = 0; i < s.viewScratchN; ++i) + wgpuTextureViewRelease(s.viewScratch[i]); + } + if (sv_length(openGroup)) + wgpuCommandEncoderPopDebugGroup(encoder); // close the last open group + + // resolve the queries we wrote into the staging buffer, then stage them into the chosen ring slot. the + // copy rides this frame's submit, which collect_gpu_timings() maps once queued. + if (profiling && pi > 0) { + wgpuCommandEncoderResolveQuerySet(encoder, prof.querySet, 0, 2 * pi, prof.resolveBuf, 0); + wgpuCommandEncoderCopyBufferToBuffer(encoder, prof.resolveBuf, 0, slot->buf, 0, uint64_t(pi) * 2 * sizeof(uint64_t)); + slot->count = pi; + prof.pendingSlot = slotIdx; + } + + s.m_state = RenderGraphState::Finished; + release_resources(this); + auto t1 = std::chrono::steady_clock::now(); + s.timing_execute_us = std::chrono::duration(t1 - t0).count(); +} + +// kick the async read-back of the slot execute() just filled. after the queue submit, the copy must be +// queued first. the callback lands a couple of frames on, during an instance event pump. +void RenderGraph::collect_gpu_timings() +{ + GpuProfiler& prof = storage(this)->m_allocator->profiler; + if (prof.pendingSlot < 0) + return; + GpuProfiler::Slot* slot = &prof.ring[prof.pendingSlot]; + prof.pendingSlot = -1; + slot->pending = true; + + WGPUBufferMapCallbackInfo cb {}; + cb.mode = WGPUCallbackMode_AllowSpontaneous; + cb.callback = [](WGPUMapAsyncStatus status, WGPUStringView, void* u1, void* u2) { + auto* slot = static_cast(u1); + auto* prof = static_cast(u2); + if (status == WGPUMapAsyncStatus_Success) { + const auto* t = static_cast(wgpuBufferGetConstMappedRange(slot->buf, 0, slot->count * 2 * sizeof(uint64_t))); + if (t) { + prof->resultCount = slot->count; + for (uint32_t i = 0; i < slot->count; ++i) { + const uint64_t b = t[2 * i], e = t[2 * i + 1]; + prof->resultUs[i] = (e > b) ? float((e - b) / 1000.0) : 0.0f; // ns -> us; clamp reorders + prof->resultNames[i] = slot->names[i]; // interned, so it outlives the slot's reuse + } + ++prof->resultId; // signal the history sampler that a fresh read-back landed + } + wgpuBufferUnmap(slot->buf); + } + slot->pending = false; + }; + cb.userdata1 = slot; + cb.userdata2 = &prof; + wgpuBufferMapAsync(slot->buf, WGPUMapMode_Read, 0, slot->count * 2 * sizeof(uint64_t), cb); +} + +ErrorMessage* RenderGraph::get_errors() const +{ + RenderGraphStorage& s = *storage(const_cast(this)); + Q_ASSERT(s.frameId == s.m_allocator->frameId && "get_errors(): graph outlived its frame, read errors before the next begin_frame()"); + return s.m_errors; +} + +static void release_resources(RenderGraph* rg) +{ + RenderGraphStorage& s = *storage(rg); + Q_ASSERT(s.m_state == RenderGraphState::Finished); + for (ResourceNode* r = s.m_resouces; r; r = r->next) { + if (r->is_external()) + continue; + r->texture = nullptr; + r->view = nullptr; + r->buffer = nullptr; + } + + for (uint32_t i = 0; i < s.m_slotCount; ++i) { + s.m_slots[i].texture = nullptr; + s.m_slots[i].view = nullptr; + s.m_slots[i].buffer = nullptr; + } + + s.m_allocator->transient.release_claims(); +} + +// which resource kind an access is legal +static constexpr bool access_allows_kind(AccessType t, ResourceKind k) +{ + switch (t) { + case AccessType::ColorAttachment: + case AccessType::DepthStencilAttachment: + case AccessType::DepthStencilReadOnly: + case AccessType::ResolveAttachment: + case AccessType::Sampled: + return k == ResourceKind::Texture; + case AccessType::Uniform: + case AccessType::Vertex: + case AccessType::Index: + case AccessType::Indirect: + return k == ResourceKind::Buffer; + default: // StorageRead/Write, CopySrc/Dst: both kinds are legal + return true; + } +} + +static void use(PassNode* pass, RenderGraph* graph, + ResourceHandle handle, + AccessType type, + WGPULoadOp load = WGPULoadOp_Undefined, + WGPUStoreOp store = WGPUStoreOp_Undefined, + WGPUColor clear = {}, + float clearDepth = {}, + uint32_t baseMip = 0, + uint32_t baseLayer = 0, + ViewRange range = {}, + WGPULoadOp stencilLoad = WGPULoadOp_Undefined, + WGPUStoreOp stencilStore = WGPUStoreOp_Undefined, + uint32_t stencilClear = 0) +{ + Q_ASSERT(handle.id != 0); + Q_ASSERT(handle.id < storage(graph)->next_resource_id && "stale or foreign ResourceHandle; not created by this frame's graph"); + Q_ASSERT(handle.generation == storage(graph)->generation && "stale/foreign ResourceHandle: not created by this graph"); + Q_ASSERT(access_allows_kind(type, handle.kind) && "handle kind disagrees with this access; a handle was converted across kinds"); + + // WebGPU permits several writable-storage uses in one scope. the graph is stricter, it cannot + // synchronize two writes inside a pass. Transfer passes are exempt, each copy is its own usage scope. + const bool w = access_is_write(type); + if (pass->kind != PassKind::Transfer) + for (uint32_t i = 0; i < pass->accessCount; ++i) { + if (pass->accesses[i].handle.id != handle.id) + continue; + if (in_pass_accesses_conflict(type, + baseMip, + range.mipCount, + baseLayer, + range.layerCount, + range.aspect, + pass->accesses[i].type, + pass->accesses[i].baseMip, + pass->accesses[i].mipCount, + pass->accesses[i].baseLayer, + pass->accesses[i].layerCount, + pass->accesses[i].viewAspect)) { + qDebug("[RenderGraph] error: pass \"%.*s\" uses resource id %u %s in one pass; a " + "written resource must be its only use in the pass.\n", + RG_NAME(pass->id), + handle.id, + (w && access_is_write(pass->accesses[i].type)) ? "as more than one write (unsynchronized)" : "as both written and read"); + Q_ASSERT(false && "RenderGraph: illegal in-pass resource usage (read+write or double write in one pass)"); + } + } + + if (pass->accessCount == pass->accessCap) { + GraphAllocator* A = storage(graph)->m_allocator; + uint32_t oldCap = pass->accessCap; + uint32_t newCap = oldCap ? oldCap * 2 : 4; + ResourceAccess* buf = nullptr; + if (oldCap) + buf = static_cast( + A->front.extend_tail(pass->accesses, (size_t)oldCap * sizeof(ResourceAccess), (size_t)(newCap - oldCap) * sizeof(ResourceAccess))); + if (!buf) { + buf = A->alloc(newCap); + if (pass->accessCount) + std::memcpy(buf, pass->accesses, (size_t)pass->accessCount * sizeof(ResourceAccess)); + } + pass->accesses = buf; + pass->accessCap = newCap; + } + + pass->accesses[pass->accessCount++] = ResourceAccess { + .handle = handle, + .clearColor = { float(clear.r), float(clear.g), float(clear.b), float(clear.a) }, + .clearDepth = clearDepth, + .loadOp = load, + .storeOp = store, + .stencilLoadOp = stencilLoad, + .stencilStoreOp = stencilStore, + .viewDim = range.dim, + .viewAspect = range.aspect, + .baseLayer = uint16_t(baseLayer), + .layerCount = uint16_t(range.layerCount), + .type = type, + .stencilClear = uint8_t(stencilClear), + .baseMip = uint8_t(baseMip), + .mipCount = uint8_t(range.mipCount), + }; +} + +void PassBuilder::color(TextureHandle handle, uint32_t attachmentIndex, ColorAttachment a) +{ + const WGPULoadOp load = a.load; + const WGPUStoreOp store = a.store; + const WGPUColor clear = a.clear; + const uint32_t baseMip = a.sub.mip; + const uint32_t baseLayer = a.sub.layer; + + Q_ASSERT(handle.id != 0); + Q_ASSERT(m_pass->kind == PassKind::Graphics && "color attachment is only legal for graphics passes."); + + if (attachmentIndex >= kMaxColorAttachments) { + qDebug("[RenderGraph] error: pass \"%.*s\" declares color attachment slot %u >= %u " + "(WebGPU maxColorAttachments); attachment on resource id %u dropped at execute.\n", + RG_NAME(m_pass->id), + attachmentIndex, + kMaxColorAttachments, + handle.id); + Q_ASSERT(false && "RenderGraph: color() attachmentIndex out of range"); + return; + } + // a slot binds exactly one resource per pass. + for (uint32_t i = 0; i < m_pass->accessCount; ++i) + if (m_pass->accesses[i].type == AccessType::ColorAttachment && m_pass->accesses[i].colorIndex == attachmentIndex) { + qDebug("[RenderGraph] error: pass \"%.*s\" declares color attachment slot %u twice; " + "each slot binds one resource.\n", + RG_NAME(m_pass->id), + attachmentIndex); + Q_ASSERT(false && "RenderGraph: duplicate color() attachmentIndex in one pass"); + return; + } + + use(m_pass, m_graph, handle, AccessType::ColorAttachment, load, store, clear, {}, baseMip, baseLayer); + m_pass->accesses[m_pass->accessCount - 1].colorIndex = uint8_t(attachmentIndex); +} + +void PassBuilder::resolve(TextureHandle src, TextureHandle target, Subresource sub) +{ + const uint32_t baseMip = sub.mip; + const uint32_t baseLayer = sub.layer; + + Q_ASSERT(src.id != 0); + Q_ASSERT(target.id != 0); + Q_ASSERT(m_pass->kind == PassKind::Graphics && "resolve target is only legal for graphics passes."); + + uint8_t srcSlot = 0; + bool foundSrc = false; + for (uint32_t i = 0; i < m_pass->accessCount; ++i) + if (m_pass->accesses[i].type == AccessType::ColorAttachment && m_pass->accesses[i].handle.id == src.id) { + srcSlot = m_pass->accesses[i].colorIndex; + foundSrc = true; + break; + } + if (!foundSrc) { + qDebug("[RenderGraph] error: pass \"%.*s\" calls resolve() whose src (resource id %u) is not " + "a color attachment declared in this pass; declare its color() first.\n", + RG_NAME(m_pass->id), + src.id); + Q_ASSERT(false && "RenderGraph: resolve() src is not a color attachment in this pass"); + return; + } + // one resolve target per color slot. + for (uint32_t i = 0; i < m_pass->accessCount; ++i) + if (m_pass->accesses[i].type == AccessType::ResolveAttachment && m_pass->accesses[i].colorIndex == srcSlot) { + qDebug("[RenderGraph] error: pass \"%.*s\" declares two resolve targets for color slot %u; " + "one resolve target per color attachment.\n", + RG_NAME(m_pass->id), + srcSlot); + Q_ASSERT(false && "RenderGraph: two resolve() targets for one color slot in a pass"); + return; + } + + use(m_pass, m_graph, target, AccessType::ResolveAttachment, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer); + m_pass->accesses[m_pass->accessCount - 1].colorIndex = srcSlot; +} + +static void assert_single_depth(PassNode* pass) +{ + for (uint32_t i = 0; i < pass->accessCount; ++i) { + AccessType t = pass->accesses[i].type; + if (t == AccessType::DepthStencilAttachment || t == AccessType::DepthStencilReadOnly) { + qDebug("[RenderGraph] error: pass \"%.*s\" declares a second depth-stencil attachment; " + "a render pass has one depth-stencil slot, so only the last would take effect.\n", + RG_NAME(pass->id)); + Q_ASSERT(false && "RenderGraph: two depth-stencil attachments in one pass"); + } + } +} + +void PassBuilder::depth_stencil(TextureHandle handle, DepthStencilAttachment a) +{ + const WGPULoadOp load = a.load; + const WGPUStoreOp store = a.store; + const float clearDepth = a.clearDepth; + const uint32_t baseMip = a.sub.mip; + const uint32_t baseLayer = a.sub.layer; + const WGPULoadOp stencilLoad = a.stencilLoad; + const WGPUStoreOp stencilStore = a.stencilStore; + const uint32_t stencilClear = a.stencilClear; + + Q_ASSERT(handle.id != 0); + Q_ASSERT(m_pass->kind == PassKind::Graphics && "depth-stencil attachment is only legal for graphics passes."); + assert_single_depth(m_pass); + use(m_pass, m_graph, handle, AccessType::DepthStencilAttachment, load, store, {}, clearDepth, baseMip, baseLayer, {}, stencilLoad, stencilStore, stencilClear); +} + +void PassBuilder::depth_stencil_read_only(TextureHandle handle, Subresource sub) +{ + const uint32_t baseMip = sub.mip; + const uint32_t baseLayer = sub.layer; + + Q_ASSERT(handle.id != 0); + Q_ASSERT(m_pass->kind == PassKind::Graphics && "depth-stencil attachment is only legal for graphics passes."); + assert_single_depth(m_pass); + use(m_pass, m_graph, handle, AccessType::DepthStencilReadOnly, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer); +} + +void PassBuilder::sampled(TextureHandle handle, ViewRange range) +{ + const uint32_t baseMip = range.baseMip; + const uint32_t baseLayer = range.baseLayer; + + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::Sampled, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer, range); +} + +void PassBuilder::storage_read(TextureHandle handle, ViewRange range) +{ + const uint32_t baseMip = range.baseMip; + const uint32_t baseLayer = range.baseLayer; + + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::StorageRead, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer, range); +} + +void PassBuilder::storage_write(TextureHandle handle, ViewRange range) +{ + const uint32_t baseMip = range.baseMip; + const uint32_t baseLayer = range.baseLayer; + + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::StorageWrite, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer, range); +} + +void PassBuilder::storage_read_write(TextureHandle handle, ViewRange range) +{ + Q_ASSERT(handle.id != 0); + storage_read(handle, range); + storage_write(handle, range); +} + +// records a declared bind range on the access use() just appended. ONLY USE after calling use() +static void set_bind_range(RenderGraph* graph, PassNode* pass, BufferHandle handle, BufferRange range) +{ + if (!range.offset && !range.size) + return; + ResourceNode* n = find_node(graph, handle); + Q_ASSERT(n && "bind range: handle is 0 or from another graph"); + const uint64_t sz = range.size ? range.size : (n->bufferSize > range.offset ? n->bufferSize - range.offset : 0); + pass->accesses[pass->accessCount - 1].bufOffset = range.offset; + pass->accesses[pass->accessCount - 1].bufSize = sz; +} + +void PassBuilder::storage_read(BufferHandle handle, BufferRange range) +{ + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::StorageRead); + set_bind_range(m_graph, m_pass, handle, range); +} + +void PassBuilder::storage_write(BufferHandle handle, BufferRange range) +{ + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::StorageWrite); + set_bind_range(m_graph, m_pass, handle, range); +} + +void PassBuilder::storage_read_write(BufferHandle handle, BufferRange range) +{ + Q_ASSERT(handle.id != 0); + storage_read(handle, range); + storage_write(handle, range); +} + +void PassBuilder::uniform(BufferHandle handle, BufferRange range) +{ + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::Uniform); + set_bind_range(m_graph, m_pass, handle, range); +} + +void PassBuilder::host_write(BufferHandle handle) +{ + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::CopyDst); +} + +void PassBuilder::copy_texture(TextureHandle src, TextureHandle dst, Subresource srcSub, Subresource dstSub) +{ + const uint32_t srcMip = srcSub.mip, srcLayer = srcSub.layer; + const uint32_t dstMip = dstSub.mip, dstLayer = dstSub.layer; + + Q_ASSERT(src.id != 0 && dst.id != 0); + Q_ASSERT(m_pass->kind == PassKind::Transfer && "copy_texture() is only legal in Transfer passes"); + Q_ASSERT(!(src.id == dst.id && srcMip == dstMip && srcLayer == dstLayer) && "copy_texture: src and dst are the same subresource"); + use(m_pass, m_graph, src, AccessType::CopySrc, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, srcMip, srcLayer); + use(m_pass, m_graph, dst, AccessType::CopyDst, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, dstMip, dstLayer); +} + +void PassBuilder::copy_texture_to_buffer(TextureHandle src, BufferHandle dst, Subresource srcSub) +{ + const uint32_t srcMip = srcSub.mip, srcLayer = srcSub.layer; + + Q_ASSERT(src.id != 0 && dst.id != 0); + Q_ASSERT(m_pass->kind == PassKind::Transfer && "copy_texture_to_buffer() is only legal in Transfer passes"); + use(m_pass, m_graph, src, AccessType::CopySrc, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, srcMip, srcLayer); + use(m_pass, m_graph, dst, AccessType::CopyDst); +} + +void PassBuilder::copy_buffer_to_texture(BufferHandle src, TextureHandle dst, Subresource dstSub) +{ + const uint32_t dstMip = dstSub.mip, dstLayer = dstSub.layer; + + Q_ASSERT(src.id != 0 && dst.id != 0); + Q_ASSERT(m_pass->kind == PassKind::Transfer && "copy_buffer_to_texture() is only legal in Transfer passes"); + use(m_pass, m_graph, src, AccessType::CopySrc); + use(m_pass, m_graph, dst, AccessType::CopyDst, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, dstMip, dstLayer); +} + +void PassBuilder::copy_buffer(BufferHandle src, BufferHandle dst, uint64_t srcOffset, uint64_t dstOffset, uint64_t size) +{ + Q_ASSERT(src.id != 0 && dst.id != 0); + Q_ASSERT(m_pass->kind == PassKind::Transfer && "copy_buffer() is only legal in Transfer passes"); + Q_ASSERT(src.id != dst.id && "copy_buffer: WebGPU forbids a same-buffer copyBufferToBuffer"); + Q_ASSERT((srcOffset % 4 == 0 && dstOffset % 4 == 0 && size % 4 == 0) && "copy_buffer: offsets and size must be multiples of 4 (WebGPU)"); + ResourceNode* sn = find_node(m_graph, src); + ResourceNode* dn = find_node(m_graph, dst); + Q_ASSERT(sn && dn && "copy_buffer: handle is 0 or from another graph"); + + const uint64_t n = size ? size : (sn->bufferSize > srcOffset ? sn->bufferSize - srcOffset : 0); + use(m_pass, m_graph, src, AccessType::CopySrc); + m_pass->accesses[m_pass->accessCount - 1].bufOffset = srcOffset; + m_pass->accesses[m_pass->accessCount - 1].bufSize = n; + use(m_pass, m_graph, dst, AccessType::CopyDst); + m_pass->accesses[m_pass->accessCount - 1].bufOffset = dstOffset; + m_pass->accesses[m_pass->accessCount - 1].bufSize = n; + // full define for aliasing only when this copy provably overwrites every dst byte + m_pass->accesses[m_pass->accessCount - 1].bufFullDefine = (dstOffset == 0 && n == dn->bufferSize); +} + +void PassBuilder::vertex_buffer(BufferHandle handle) +{ + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::Vertex); +} + +void PassBuilder::index_buffer(BufferHandle handle) +{ + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::Index); +} + +void PassBuilder::indirect_buffer(BufferHandle handle) +{ + Q_ASSERT(handle.id != 0); + use(m_pass, m_graph, handle, AccessType::Indirect); +} + +void PassBuilder::initialize(ResourceHandle target, uint64_t hash) +{ + Q_ASSERT(target.id != 0); + Q_ASSERT(target.id < storage(m_graph)->next_resource_id && "stale or foreign ResourceHandle; not created by this frame's graph"); + if (target.id == 0) + return; + for (uint32_t i = 0; i < m_pass->initCount; ++i) + Q_ASSERT(m_pass->initTargets[i].target.id != target.id && "initialize() called twice for the same target in one pass"); + if (m_pass->initCount >= PassNode::kMaxInitTargets) { + qDebug("[RenderGraph] error: pass \"%.*s\" declares more than %u initialize() targets. target on resource id %u dropped.", + RG_NAME(m_pass->id), + PassNode::kMaxInitTargets, + target.id); + Q_ASSERT(false && "RenderGraph: more than kMaxInitTargets initialize() targets in one pass"); + return; + } + + if (!m_pass->initTargets) { + GraphAllocator* A = storage(m_graph)->m_allocator; + m_pass->initTargets = A->alloc(PassNode::kMaxInitTargets); + } + m_pass->initTargets[m_pass->initCount++] = { target, hash }; +} + +void PassBuilder::force_keep() +{ + m_pass->forceKeep = true; +} + +} // namespace webgpu::rg diff --git a/webgpu/base/RenderGraph.h b/webgpu/base/RenderGraph.h new file mode 100644 index 000000000..4ba760314 --- /dev/null +++ b/webgpu/base/RenderGraph.h @@ -0,0 +1,401 @@ +/***************************************************************************** + * Copyright (C) 2026 Matthias Huerbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + + +// immediate-mode render graph for WebGPU. does pass ordering and resource lifetime + +#pragma once + + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace webgpu::rg { + +// picks the encoder the pass records into, and which accesses are legal +enum struct PassKind : uint8_t { + None = 0, + Graphics, // PassContext::render_pass, attachments already bound + Compute, // PassContext::compute_pass + Transfer // no pass object, copies go on PassContext::encoder +}; + +enum struct SizeKind : uint8_t { + Absolute, + Relative // scaled relative to relativeTo +}; + +enum struct ResourceKind : uint8_t { + Texture, + Buffer, +}; + +// handle for one a render graph resource for this frame +struct ResourceHandle { + uint32_t id {}; + ResourceKind kind {}; + uint64_t generation {}; + + constexpr explicit operator bool() const { return id != 0; } + constexpr bool operator==(const ResourceHandle&) const = default; +}; + +struct TextureHandle : ResourceHandle {}; +struct BufferHandle : ResourceHandle {}; + +// ping-pong pair, rotated each frame. this frame's curr is next frame's prev. +template +struct History { + H curr; // written this frame + H prev; // last frame's curr +}; +using HistoryTexture = History; +using HistoryBuffer = History; + +namespace Internal { +struct ResourceNode; +struct PassNode; +struct GraphPair; +struct PassContextAccess; +} +struct GraphAllocator; +struct RenderGraph; + +struct ErrorMessage { + WGPUStringView message; + ErrorMessage* next {}; +}; + +// resolves handles to live GPU objects during execute() +struct PassContext { + WGPUCommandEncoder encoder {}; + WGPURenderPassEncoder render_pass {}; // Graphics passes, else null + WGPUComputePassEncoder compute_pass {}; // Compute passes, else null + WGPUQueue queue {}; + WGPUDevice device {}; + + // shape comes from this pass's access declaration, ViewRange included + WGPUTextureView view(TextureHandle h) const; + WGPUTextureView view(TextureHandle h, uint32_t mip, uint32_t layer = 0) const; + + // buffers bind at the BufferRange declared in this pass, or whole at offset 0 when none was + WGPUBindGroupEntry bind(uint32_t binding, ResourceHandle h) const; + WGPUBindGroupEntry bind(uint32_t binding, TextureHandle h, uint32_t mip, uint32_t layer = 0) const; + + // all assert the handle has a declared access in this pass + WGPUTexture texture(TextureHandle h) const; + WGPUBuffer buffer(BufferHandle h) const; + WGPUExtent3D texture_size(TextureHandle h) const; + WGPUExtent3D texture_size(TextureHandle h, uint32_t mip) const; + uint64_t buffer_size(BufferHandle h) const; + WGPUTextureFormat format(TextureHandle h) const; + uint32_t mip_count(TextureHandle h) const; + uint32_t sample_count(TextureHandle h) const; + + // false on the frame .prev was (re)created and cleared to 0 + template + bool history_valid(const History& history) const + { + return history_valid_impl(history.prev); + } + + // copy descriptors for a copy declared in this pass, declared mip/layer applied + WGPUTexelCopyTextureInfo copy_src_info(TextureHandle h, WGPUOrigin3D origin = {}, WGPUTextureAspect aspect = WGPUTextureAspect_All) const; + WGPUTexelCopyTextureInfo copy_dst_info(TextureHandle h, WGPUOrigin3D origin = {}, WGPUTextureAspect aspect = WGPUTextureAspect_All) const; + // the declared subresource at its mip level, never the whole array + WGPUExtent3D copy_extent_src(TextureHandle h) const; + WGPUExtent3D copy_extent_dst(TextureHandle h) const; + // buffer side of a texture<->buffer copy. layout is the caller's data layout. + WGPUTexelCopyBufferInfo copy_src_buffer(BufferHandle h, WGPUTexelCopyBufferLayout layout) const; + WGPUTexelCopyBufferInfo copy_dst_buffer(BufferHandle h, WGPUTexelCopyBufferLayout layout) const; + + // size is already expanded, never 0 + struct BufferCopyInfo { + WGPUBuffer src {}; + uint64_t srcOffset {}; + WGPUBuffer dst {}; + uint64_t dstOffset {}; + uint64_t size {}; + }; + BufferCopyInfo buffer_copy_info(BufferHandle src, BufferHandle dst) const; + +private: + RenderGraph* graph {}; + Internal::PassNode* pass {}; + + bool history_valid_impl(ResourceHandle prev) const; + + PassContext() = default; + PassContext(const PassContext&) = delete; + PassContext& operator=(const PassContext&) = delete; + friend struct RenderGraph; + friend struct Internal::PassContextAccess; +}; + +// exactly one mip level and one array layer +struct Subresource { + uint32_t mip = 0; + uint32_t layer = 0; +}; + +// view shape for a sampled/storage access. the default is one mip, one layer, all aspects, dimension +// inferred. +struct ViewRange { + uint32_t baseMip = 0; // first mip level the view sees + uint32_t mipCount = 1; // from baseMip. 0 = all remaining. + uint32_t baseLayer = 0; // first array layer the view sees + uint32_t layerCount = 1; // from baseLayer. 0 = all remaining. + // Undefined infers 3D for a 3D texture, else 2DArray when the view covers more than one layer, else 2D + WGPUTextureViewDimension dim = WGPUTextureViewDimension_Undefined; + WGPUTextureAspect aspect = WGPUTextureAspect_All; // picks one plane of a depth-stencil texture + + // same shape, rebased + constexpr ViewRange at(uint32_t mip, uint32_t layer = 0) const + { + ViewRange r = *this; + r.baseMip = mip; + r.baseLayer = layer; + return r; + } +}; + +// cube view, exactly 6 layers. sampling only, WebGPU storage textures cannot be cube. +constexpr ViewRange cube(WGPUTextureAspect aspect = WGPUTextureAspect_All, uint32_t mipCount = 1) +{ + return ViewRange { .mipCount = mipCount, .layerCount = 6, .dim = WGPUTextureViewDimension_Cube, .aspect = aspect }; +} + +// cube array view, 6*cubes layers +constexpr ViewRange cube_array(uint32_t cubes, WGPUTextureAspect aspect = WGPUTextureAspect_All, uint32_t mipCount = 1) +{ + return ViewRange { .mipCount = mipCount, .layerCount = 6 * cubes, .dim = WGPUTextureViewDimension_CubeArray, .aspect = aspect }; +} + +// all mips and layers from baseMip/baseLayer, dimension inferred +constexpr ViewRange whole(WGPUTextureAspect aspect = WGPUTextureAspect_All) +{ + return ViewRange { .mipCount = 0, .layerCount = 0, .aspect = aspect }; +} + +// byte range of a buffer binding. the default binds the whole buffer +struct BufferRange { + uint64_t offset = 0; // must be a multiple of 256 according to the spec + uint64_t size = 0; // from offset. 0 = all remaining. +}; + +// the 256-byte row alignment WebGPU requires for buffer<->texture copies +inline uint32_t aligned_bytes_per_row(uint32_t widthTexels, uint32_t texelBlockBytes) +{ + constexpr uint32_t align = 256u; + uint32_t raw = widthTexels * texelBlockBytes; + return (raw + (align - 1u)) & ~(align - 1u); +} + +// how a color attachment is bound. the default is clear to black, store, mip 0, layer 0. +struct ColorAttachment { + WGPULoadOp load = WGPULoadOp_Clear; // Load keeps the previous contents, Clear overwrites with clear + WGPUStoreOp store = WGPUStoreOp_Store; // Discard when nothing reads the result afterwards + WGPUColor clear = { 0, 0, 0, 1 }; // used only with WGPULoadOp_Clear + Subresource sub {}; // which mip and layer to render into +}; + +// left Undefined, the graph fills them in from the format. +struct DepthStencilAttachment { + WGPULoadOp load = WGPULoadOp_Clear; // depth plane. Load keeps last frame's depth + WGPUStoreOp store = WGPUStoreOp_Store; // Discard when no later pass samples the depth + float clearDepth = 1.0f; // used only with WGPULoadOp_Clear. 1.0 is the far plane + Subresource sub {}; // which mip and layer to render into + WGPULoadOp stencilLoad = WGPULoadOp_Undefined; // Undefined derives from the format, set only to override + WGPUStoreOp stencilStore = WGPUStoreOp_Undefined; // Undefined derives from the format, set only to override + uint32_t stencilClear = 0; // used only with a stencil clear +}; + +// declares one pass's resource accesses. one call per use. +struct PassBuilder { + // attachmentIndex is the fragment shader @location. slots may be sparse. + void color(TextureHandle handle, uint32_t attachmentIndex, ColorAttachment attachment = {}); + void depth_stencil(TextureHandle handle, DepthStencilAttachment attachment = {}); + // test only, no write + void depth_stencil_read_only(TextureHandle handle, Subresource sub = {}); + // MSAA resolve of src into target + void resolve(TextureHandle src, TextureHandle target, Subresource sub = {}); + void sampled(TextureHandle handle, ViewRange range = {}); + void storage_read(TextureHandle handle, ViewRange range = {}); + void storage_write(TextureHandle handle, ViewRange range = {}); + void storage_read_write(TextureHandle handle, ViewRange range = {}); + // storage buffer. range picks the bytes ctx.bind() binds; one range per buffer per pass. + void storage_read(BufferHandle handle, BufferRange range = {}); + void storage_write(BufferHandle handle, BufferRange range = {}); + void storage_read_write(BufferHandle handle, BufferRange range = {}); + void uniform(BufferHandle handle, BufferRange range = {}); + void host_write(BufferHandle handle); + void copy_texture(TextureHandle src, TextureHandle dst, Subresource srcSub = {}, Subresource dstSub = {}); + + void copy_texture_to_buffer(TextureHandle src, BufferHandle dst, Subresource srcSub = {}); + void copy_buffer_to_texture(BufferHandle src, TextureHandle dst, Subresource dstSub = {}); + + // size 0 means the whole src buffer from srcOffset + void copy_buffer(BufferHandle src, BufferHandle dst, uint64_t srcOffset = 0, uint64_t dstOffset = 0, uint64_t size = 0); + void vertex_buffer(BufferHandle handle); + void index_buffer(BufferHandle handle); + void indirect_buffer(BufferHandle handle); + + // bake pass for a pool-backed resource. runs only when the target is stale. + void initialize(ResourceHandle target, uint64_t hash = 0); + + // exempts this pass from dead-pass culling + void force_keep(); + +private: + Internal::PassNode* m_pass {}; + RenderGraph* m_graph {}; + + PassBuilder() = default; + PassBuilder(const PassBuilder&) = delete; + PassBuilder& operator=(const PassBuilder&) = delete; + friend struct RenderGraph; +}; + +// usage flags are inferred from how passes declare the resource +struct TextureDesc { + WGPUTextureDimension dimension = WGPUTextureDimension_Undefined; // set 2D or 3D explicitly, Undefined is not normalized. 1D is unsupported. + WGPUTextureFormat format = WGPUTextureFormat_Undefined; // required, there is no default + SizeKind sizeKind = SizeKind::Absolute; // picks between absolute and the scale/relativeTo pair + float scaleX = 1.0f; // Relative only: factor of relativeTo's size + float scaleY = 1.0f; // Relative only + TextureHandle relativeTo {}; // Relative only: the texture whose size is scaled + WGPUExtent3D absolute = WGPU_EXTENT_3D_INIT; // Absolute only: size in texels, depthOrArrayLayers is the layer count for 2D + uint32_t mipLevelCount = 1; // full chain must be spelled out, there is no 0 = all + uint32_t sampleCount = 1; // 1 or 4 (WebGPU 1.0 limit) +}; + +struct BufferDesc { + uint64_t size {}; // bytes +}; + +// an object the graph does not own. a null texture registers the view only, which rules out +// ctx.texture() and the copy family. dimension is the source texture's own, so an array or cube source +// says 2D. +struct ImportTextureDesc { + WGPUTextureView view = nullptr; // required, what ctx.view() returns for this handle + WGPUExtent3D size = WGPU_EXTENT_3D_INIT; // required, the graph cannot query an imported view + WGPUTextureFormat format = WGPUTextureFormat_Undefined; // required, must match the view's format + WGPUTexture texture = nullptr; // optional, enables ctx.texture() and the copy family + uint32_t mipCount = 1; // of the underlying texture, only relevant when texture is set + uint32_t sampleCount = 1; // must match the source, wrong value breaks attachment use + WGPUTextureDimension dimension = WGPUTextureDimension_2D; // the source texture's own dimension, see the struct comment +}; + +// render graph api for creating resources and adding passes +struct RenderGraph { + // lives for this frame, pooled and possibly aliased with another transient + TextureHandle create_transient_texture(std::string_view id, const TextureDesc& desc); + BufferHandle create_transient_buffer(std::string_view id, const BufferDesc& desc); + + // wraps an object the graph does not own, usually the swapchain. writing an import is a frame + // output, so those passes are never culled. + TextureHandle import_texture(std::string_view id, const ImportTextureDesc& desc); + + BufferHandle import_buffer(std::string_view id, WGPUBuffer buffer); + + // cross-frame ping-pong for temporal effects. hash != 0 resets history when the hash changes. + HistoryTexture create_history_texture(std::string_view id, const TextureDesc& desc, uint64_t hash = 0); + HistoryBuffer create_history_buffer(std::string_view id, const BufferDesc& desc, uint64_t hash = 0); + + // graph-owned, kept across frames + BufferHandle create_persistent_buffer(std::string_view id, const BufferDesc& desc); + + // initialized once from data, or zero-filled + BufferHandle create_initialized_buffer(std::string_view id, const BufferDesc& desc, const void* data = nullptr); + + TextureHandle create_persistent_texture(std::string_view id, const TextureDesc& desc); + + // cleared once to fill + TextureHandle create_initialized_texture(std::string_view id, const TextureDesc& desc, WGPUColor fill); + + // setup(PassBuilder&) declares the pass's accesses now. executeFn(PassContext&) records the GPU + // commands during execute(), and must be trivially destructible. + template + void add_pass(std::string_view id, PassKind kind, BuilderFn&& setup, ExecuteFn&& executeFn) + { + Q_ASSERT(!id.empty() && "must have name"); + Q_ASSERT(kind != PassKind::None); + PassBuilder builder; + begin_pass(builder, id, kind); + setup(builder); + store_exec(builder, std::forward(executeFn)); + end_pass(builder); + } + + // schedules the graph and freezes it. returns the authoring-error chain, null when ok. + [[nodiscard]] ErrorMessage* compile(bool enableAlias = true); + + // records the surviving passes into encoder. does not submit. + void execute(WGPUDevice device, WGPUQueue queue, WGPUCommandEncoder encoder, bool enableProfiling = false); + + // kicks the async read-back of the GPU timings + void collect_gpu_timings(); + + // returns the errors captured by compile() + ErrorMessage* get_errors() const; + +private: + RenderGraph() = default; + RenderGraph(const RenderGraph&) = delete; + RenderGraph& operator=(const RenderGraph&) = delete; + friend struct Internal::GraphPair; + + template + void store_exec(PassBuilder& b, F&& f) + { + using D = std::decay_t; + static_assert(std::is_trivially_destructible_v, + "execute callback must be trivially destructible; the arena frees without running destructors, so capture handles or ids by value"); + void* m = alloc_exec(sizeof(D), alignof(D)); // never null, arena OOM aborts + ::new (m) D(std::forward(f)); + set_exec(b, m, [](void* o, PassContext& c) { (*static_cast(o))(c); }); + } + void* alloc_exec(size_t size, size_t align); + void set_exec(PassBuilder& builder, void* obj, void (*fn)(void*, PassContext&)); + + + void begin_pass(PassBuilder& builder, std::string_view id, PassKind kind); + void end_pass(PassBuilder& builder); +}; + +// backs all graphs, arenas and the GPU object pool. one per device. +GraphAllocator* create_allocator(); + +// destroys everything it owns, pooled GPU objects included +void destroy_allocator(GraphAllocator* allocator); + +// all graphs and handles from the previous frame die here +void begin_frame(GraphAllocator* allocator); + +// starts a frame-scoped graph. several per frame are allowed. +RenderGraph* start_recording(GraphAllocator* allocator); + +// ages the pools so unused GPU objects get evicted +void end_frame(GraphAllocator* allocator); + +} // namespace webgpu::rg diff --git a/webgpu/base/RenderGraph_internal.h b/webgpu/base/RenderGraph_internal.h new file mode 100644 index 000000000..4392e2ac4 --- /dev/null +++ b/webgpu/base/RenderGraph_internal.h @@ -0,0 +1,786 @@ +/***************************************************************************** + * Copyright (C) 2026 Matthias Huerbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + +// internal node and allocator layout. not public API, may change without notice. +// used for unit tests and for the RenderGraphPanel + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "RenderGraph.h" + +namespace webgpu::rg { + +// FNV-1a, the basis of ResourceId::value +inline constexpr uint64_t fnv1a(std::string_view s) +{ + uint64_t hash = 14695981039346656037ULL; + for (char c : s) { + hash ^= static_cast(c); + hash *= 1099511628211ULL; + } + return hash; +} + +// carries the name too, so a hash collision never conflates two names +struct ResourceId { + uint64_t value {}; + WGPUStringView name {}; +}; + +// borrows the name, so an id outliving the caller's string must intern it +constexpr ResourceId make_resource_id(std::string_view s) { + return ResourceId(fnv1a(s), WGPUStringView { .data = s.data(), .length = s.length() }); +} + +constexpr bool operator==(ResourceId a, ResourceId b) +{ + return a.value == b.value && a.name.length == b.name.length && + std::string_view(a.name.data, a.name.length) == std::string_view(b.name.data, b.name.length); +} + +} // namespace webgpu::rg + +namespace webgpu::rg::Internal { + +// TransientAttachments, a Dawn-native feature. by value, since the emdawnwebgpu web headers ship the +// usage bit without the feature enum. +static constexpr WGPUFeatureName kTransientAttachmentsFeature = (WGPUFeatureName)0x00050006u; + +// WebGPU spec limit per render pass +static constexpr uint32_t kMaxColorAttachments = 8; + +// drives hazard edges and usage bits (columns: category, direction, usage) +enum struct AccessType : uint8_t { + ColorAttachment, // attachment write tex RenderAttachment + DepthStencilAttachment, // attachment write tex RenderAttachment + DepthStencilReadOnly, // attachment-read read tex RenderAttachment (test only, no write hazard) + ResolveAttachment, // attachment write tex RenderAttachment + Sampled, // constant read tex TextureBinding + StorageRead, // storage-read read tex StorageBinding / buf Storage + StorageWrite, // storage write tex StorageBinding / buf Storage + Uniform, // constant read buf Uniform (+CopyDst) + CopySrc, // copy read tex/buf CopySrc + CopyDst, // copy write tex/buf CopyDst + Vertex, // input read buf Vertex + Index, // input read buf Index + Indirect, // input read buf Indirect +}; + +// one recorded access on a pass. fields ordered wide-to-narrow to kill padding (~96B). +struct ResourceAccess { + ResourceHandle handle {}; + + // byte range of a copy_buffer() or a declared BufferRange on a uniform/storage bind. resolved at + // declare time so a recorded size is never 0 for a valid range. texture accesses and whole-buffer + // binds leave both 0. + uint64_t bufOffset {}; + uint64_t bufSize {}; + + // f32 not WGPUColor, lossless for every WebGPU color format at half the size + float clearColor[4] {}; + float clearDepth {}; + + // attachment only + WGPULoadOp loadOp {}; + WGPUStoreOp storeOp {}; + // set for a depth+stencil format + WGPULoadOp stencilLoadOp {}; + WGPUStoreOp stencilStoreOp {}; + + WGPUTextureViewDimension viewDim { WGPUTextureViewDimension_Undefined }; // Undefined -> infer + WGPUTextureAspect viewAspect { WGPUTextureAspect_All }; + + // subresource + view range. + uint16_t baseLayer {}; + uint16_t layerCount { 1 }; + AccessType type {}; + uint8_t stencilClear {}; + // color slot of a ColorAttachment, or of the color() a ResolveAttachment resolves. 0 otherwise. + uint8_t colorIndex {}; + // buffer CopyDst only: provably overwrites the whole buffer. a copy_texture_to_buffer dst stays + // false, its coverage is only known at encode time. + bool bufFullDefine {}; + uint8_t baseMip {}; + uint8_t mipCount { 1 }; +}; + +// resolves the WGPU_STRLEN sentinel +size_t sv_length(WGPUStringView s); + +bool sv_eq(WGPUStringView a, WGPUStringView b); + +// the span before the first '.', empty if none +WGPUStringView group_prefix(WGPUStringView name); + +struct Arena; + +struct ViewKey { + WGPUTextureFormat format = WGPUTextureFormat_Undefined; + WGPUTextureViewDimension dimension = WGPUTextureViewDimension_Undefined; + WGPUTextureAspect aspect = WGPUTextureAspect_All; + uint32_t baseMipLevel = 0, mipLevelCount = 0, baseArrayLayer = 0, arrayLayerCount = 0; + + bool operator==(const ViewKey& o) const { + return format == o.format && dimension == o.dimension && aspect == o.aspect + && baseMipLevel == o.baseMipLevel && mipLevelCount == o.mipLevelCount + && baseArrayLayer == o.baseArrayLayer && arrayLayerCount == o.arrayLayerCount; + } + WGPUTextureViewDescriptor desc() const { + return WGPUTextureViewDescriptor { + .format = format, .dimension = dimension, + .baseMipLevel = baseMipLevel, .mipLevelCount = mipLevelCount, + .baseArrayLayer = baseArrayLayer, .arrayLayerCount = arrayLayerCount, + .aspect = aspect, + }; + } +}; + +// one cached non-full view of a physical texture +struct Subview { + ViewKey key; + WGPUTextureView view; + Subview* next; +}; + + +struct SubviewPool { + Arena* arena = nullptr; + Subview* freeList = nullptr; + + // pop a recycled node or bump a fresh one + Subview* alloc(); + // release each view in the list, push its nodes onto freeList + void recycle(Subview*& head); +}; + +// cached non-full view for this key on one texture's list +WGPUTextureView subview_for(SubviewPool& pool, Subview*& head, WGPUTexture tex, const ViewKey& k); + +inline bool extent_eq(WGPUExtent3D a, WGPUExtent3D b) { + return a.width == b.width && a.height == b.height && a.depthOrArrayLayers == b.depthOrArrayLayers; +} + +// a texture's create-time signature +struct TexSignature { + WGPUExtent3D size {}; + WGPUTextureFormat format = WGPUTextureFormat_Undefined; + WGPUTextureDimension dim = WGPUTextureDimension_Undefined; + uint32_t mipLevelCount = 1; + uint32_t sampleCount = 1; + + bool operator==(const TexSignature& o) const { + return extent_eq(size, o.size) && format == o.format && dim == o.dim + && mipLevelCount == o.mipLevelCount && sampleCount == o.sampleCount; + } +}; + +// owns GPU resources that outlive the per-frame graph teardown. one Entry per logical resource, keyed +// by name content, holding the physical objects the graph ping-pongs. +struct PersistentResourcePool { + static constexpr uint32_t kLayers = 2; // current + previous. N>2 deliberately unsupported. + static constexpr uint64_t kRetain = 4; // free an entry untouched for this many frames + + struct Entry { + uint64_t idValue = 0; + WGPUStringView name {}; // identity across frames + uint64_t frame = 0; // rotation counter + uint64_t lastTouched = 0; // evictClock at the last touch + uint64_t prevTouched = 0; // evictClock at the touch before this frame's + uint32_t layers = kLayers; // kLayers = history, 1 = single in-place + ResourceKind kind = ResourceKind::Texture; + uint64_t initHash = 0; // initialize(): hash of the content baked in + bool baked = false; // initialize(): cleared on (re)create + uint64_t historyHash = 0; // mismatch -> destroy+recreate + + WGPUTexture tex[kLayers] = {}; + WGPUTextureView view[kLayers] = {}; // whole-texture view per layer + Subview* subviews[kLayers] = {}; + + // texture + bool created = false; + uint64_t createdClock = UINT64_MAX; // catches a same-frame recreate + TexSignature sig {}; + WGPUTextureUsage usage = {}; // union over layers + WGPUTextureUsage usageAtCreate = {}; + + // buffer + WGPUBuffer buf[kLayers] = {}; + uint64_t bufferSize = 0; + WGPUBufferUsage bufUsage = {}; // union over layers/frames + WGPUBufferUsage bufUsageAtCreate = {}; + + Entry* next = nullptr; // live-list link when in entries, free-list link when recycled + + // interned, NUL-terminated, cross-frame stable + WGPUStringView name_view() const { return name; } + }; + + Entry* entries = nullptr; + Entry* freeList = nullptr; + Arena* arena = nullptr; + SubviewPool* subviewPool = nullptr; + uint64_t evictClock = 0; + + // pop a recycled cell or create a new one + Entry* alloc_entry(); + + // null if absent + Entry* find(ResourceId id); + + // ensure the entry exists and advance its rotation, so rotation tracks use, not declaration. + // idempotent within a frame: a history's two layer nodes share one entry, only the first rotates. + Entry* touch(ResourceId id, uint32_t layers, uint64_t hash, ResourceKind kind); + + // physical slot backing a layer under the entry's current rotation + uint32_t slot(const Entry& e, uint32_t layerIndex) const; + + // would realize_*_entry destroy+recreate this entry + static bool tex_entry_would_recreate(const Entry& e, const TexSignature& sig, WGPUTextureUsage usage); + static bool buf_entry_would_recreate(const Entry& e, uint64_t size, WGPUBufferUsage usage); + + // does this hash force touch() to destroy the entry + static bool history_hash_forces_destroy(const Entry& e, uint64_t hash); + + void realize_texture_entry(Entry* e, WGPUDevice device, const TexSignature& sig); + + // usage is the union across layers, since each physical buffer cycles through every layer role + void realize_buffer_entry(Entry* e, WGPUDevice device, uint64_t size); + + void destroy(Entry* e); + + // free entries untouched for kRetain frames, then advance the clock. once per realized frame. + void end_frame(); + + // destroys all managed entities + void destroy_all(); + + ~PersistentResourcePool(); +}; + +// caches per-frame transient GPU objects across teardown so realize() stops churning the driver. one +// physical object per Entry, keyed by descriptor and tagged by kind. usage matches by superset, except +// the restriction bits in acquire(). objects unclaimed for kRetain frames are evicted. +struct TransientResourcePool { + static constexpr uint64_t kRetain = 4; + + struct Entry { + ResourceKind kind = ResourceKind::Texture; // picks the arm + // texture arm + TexSignature sig {}; + WGPUTextureUsage usage = {}; + WGPUTexture tex = {}; + WGPUTextureView view = {}; // whole-texture view, the resolve_view fast path + Subview* subviews = nullptr; + // buffer arm + uint64_t bufferSize = 0; + WGPUBufferUsage bufUsage = {}; + WGPUBuffer buf = {}; + // shared + bool inUse = false; // released in release_claims + uint64_t lastUsedFrame = 0; // eviction only + uint64_t createdFrame = 0; // supersede-eviction key, with identity + // interned name of the entry's LAST claimant, null if never claimed by name + const void* identity = nullptr; + Entry* next = nullptr; // live-list link when in entries, free-list link when recycled + }; + // cells bump-allocated from arena, evicted cells recycle through freeList + Entry* entries = nullptr; + Entry* freeList = nullptr; + Arena* arena = nullptr; + SubviewPool* subviewPool = nullptr; // destroy() recycles into it + uint64_t frame = 0; + uint32_t createdThisFrame = 0; + + // pop a recycled cell or bump a fresh one, default-constructed + Entry* alloc_entry(); + + // debug event log + enum class Event : uint8_t { Create, Evict }; + struct LogRec { + uint64_t frame; + Event event; + ResourceKind kind; + WGPUExtent3D size; // texture + WGPUTextureFormat format; // texture + uint64_t bufferSize; // buffer + }; + static constexpr uint32_t kLog = 128; + LogRec eventLog[kLog] = {}; + uint64_t eventCount = 0; + + void log_event(Event event, const Entry& e); + + void log_reset(); + + // hand out a free texture matching the signature (usage by superset), or create one + void acquire(WGPUDevice device, const TexSignature& sig, WGPUTextureUsage usage, const void* identity, WGPUTexture& outTex, WGPUTextureView& outView); + + void acquire(WGPUDevice device, uint64_t size, WGPUBufferUsage usage, const void* identity, WGPUBuffer& outBuf); + + // drop every inUse claim so the frame's objects become reusable + void release_claims(); + + // true iff idle entry e is made redundant this frame by same-kind sibling s, identical but for its + // size. kills the resize VRAM spike. keyed on identity too, so only a resize of the SAME resource + // supersedes. + static bool superseded_by(const Entry& e, const Entry& s, uint64_t frame); + + // destroy objects idle >= kRetain frames, advance the clock. once per frame. + void end_frame(); + + void destroy(Entry* e); + + // idempotent. must run while arena is still alive. + void destroy_all(); + + ~TransientResourcePool(); +}; + +// opt-in per-pass GPU timing. two timestamp queries per executed pass, resolved into a staging buffer +// and copied into a ring of mappable read-back buffers so a frame never stalls. created on the first +// profiled frame, and only when the device has TimestampQuery. +struct GpuProfiler { + static constexpr uint32_t kMaxPasses = 64; // -> 2*64 timestamps in the set + static constexpr uint32_t kRing = 3; // covers GPU read-back latency without stalling + + WGPUQuerySet querySet {}; // type Timestamp, count 2*kMaxPasses + WGPUBuffer resolveBuf {}; // 2*kMaxPasses*8, QueryResolve | CopySrc + + struct Slot { + WGPUBuffer buf {}; // 2*kMaxPasses*8, MapRead | CopyDst + bool pending {}; // mapAsync in flight -> not reusable until the cb fires + uint32_t count {}; // passes captured into this fill + WGPUStringView names[kMaxPasses] {}; // interned pass names, captured at execute + } ring[kRing]; + int pendingSlot { -1 }; // slot execute() filled this frame, awaiting the map kick + + // last completed read-back, read by the debug UI + uint32_t resultCount {}; + WGPUStringView resultNames[kMaxPasses] {}; // interned, so they outlive the ring slot's reuse + float resultUs[kMaxPasses] {}; + + bool initialized {}; + + // timing history, driven by the "Timings" tab + static constexpr uint32_t kHistory = 256; // frames retained + bool recording {}; + uint32_t resultId {}; // bumped by the read-back cb per sample, dedupes capture + struct Series { + WGPUStringView name {}; // interned, series matched by canonical .data pointer + bool enabled { true }; + float v[kHistory] {}; + }; + Series series[kMaxPasses]; + uint32_t seriesCount {}; + uint32_t historyHead {}; // next write column + uint32_t historyLen {}; // valid columns (<= kHistory) + uint32_t lastSampledId {}; // resultId already appended + + void clear_history(); + + // append one column iff recording and a fresh read-back arrived since last call. results match a + // series by interned name and occurrence index, so repeated pass names keep distinct rows. + // NOTE(Huerbe): series keyed by name. switching graphs adds new series and zero-fills old ones. clear resets. + void sample_history(); + + // one-time creation of the query set, resolve buffer, and read-back ring + void init(WGPUDevice device); + + // first ring slot not awaiting a map, -1 if all are in flight + int free_slot() const; +}; + +static constexpr size_t ARENA_DEFAULT_BLOCK_SIZE = 16 * 1024; +static constexpr size_t ARENA_SCRATCH_DEFAULT_BLOCK_SIZE = 2 * 1024; + +// chained-block bump allocator behind all graph storage. frees nothing until reset() or free_all(). +struct Arena { + + struct ArenaBlock { + ArenaBlock* next; + uint8_t* payload; // aligned payload start, inside this same malloc + size_t capacity; + size_t used; // bump cursor + }; + + ArenaBlock* head {}; + ArenaBlock* current {}; + size_t blockSize = ARENA_DEFAULT_BLOCK_SIZE; + + // debug stats + size_t totalCapacity {}; + size_t peakUsage {}; + size_t blockCount {}; + size_t liveBytes {}; + + // alignment must be a power of two + static constexpr size_t align_up(size_t value, size_t alignment) + { + return (value + alignment - 1) & ~(alignment - 1); + } + + ArenaBlock* new_block(size_t minPayload); + + // ensure the first block exists ahead of the first alloc + void reserve(); + + size_t live_used() const; + + void* alloc_raw(size_t size, size_t align); + + // grow p in place when it is the tail of the current block, null when it cannot + void* extend_tail(void* p, size_t oldSize, size_t addSize); + + // fatal, aborts via qFatal. alloc_raw and its callers rely on this and never null-check. + [[noreturn]] void oom(size_t size); + + // rewind every block, keeping the memory + void reset(); + + void free_all(); + + struct Mark { + ArenaBlock* block; + size_t used; + }; + Mark mark() const; + void rewind(Mark m); + + template + static T* construct(void* m, Args&&... args) + { + return ::new (m) T(std::forward(args)...); + } + + template + static T* zero(void* m, size_t count) + { + std::memset(m, 0, sizeof(T) * count); + return static_cast(m); + } + + template + T* make(Args&&... args) + { + return construct(alloc_raw(sizeof(T), alignof(T)), std::forward(args)...); + } + + // zeroed POD storage, contiguous within one block + template + T* alloc(size_t count = 1) + { + return zero(alloc_raw(sizeof(T) * count, alignof(T)), count); + } + + // arena-owned copy, always null-terminated + WGPUStringView copy_string(WGPUStringView s); +}; + +struct StringInterner { + static constexpr uint32_t kBuckets = 64; // power of two, index = hash & (kBuckets-1) + + struct Node { + uint64_t hash; // matched before the content compare + WGPUStringView name; // NUL-terminated + Node* next; + }; + + Arena* arena = nullptr; + Node* buckets[kBuckets] = {}; + + ResourceId intern(std::string_view s); +}; + + +} // namespace webgpu::rg::Internal + +namespace webgpu::rg { + +// persistent data storage for the render graph +struct GraphAllocator { + Internal::Arena front; // permanent per-frame nodes + Internal::Arena scratch; // compile()-local temporaries, driven by ScopedScratch + Internal::Arena persist; // Entry cells for both pools. never per-frame reset, freed at destroy_allocator. + + Internal::StringInterner names; // dedup table for every resource/pass/pool/profiler name + Internal::PersistentResourcePool pool; // name-keyed history textures + buffers + Internal::TransientResourcePool transient; // descriptor-keyed per-frame texture + buffer cache + Internal::SubviewPool subviews; // shared cache of non-full texture views for both pools + Internal::GpuProfiler profiler; // opt-in per-pass GPU timestamps + + // whether the device advertises WGPUFeatureName_TransientAttachments + bool transientFeatureOn {}; + + // bumped by reset() + uint64_t frameId { 1 }; + + static constexpr size_t align_up(size_t value, size_t alignment) { return Internal::Arena::align_up(value, alignment); } + + // front-arena forwarders for per-frame allocations + void* alloc_raw(size_t size, size_t align); + template + T* make(Args&&... args) + { + return front.make(std::forward(args)...); + } + template + T* alloc(size_t count = 1) + { + return front.alloc(count); + } + // null-terminated front-arena copy + WGPUStringView copy_string(WGPUStringView s); + + // per-frame teardown: rewind both arenas + void reset(); +}; + +} // namespace webgpu::rg + +namespace webgpu::rg::Internal { + +struct ScopedScratch { + Arena* arena; + Arena::Mark mark; + explicit ScopedScratch(Arena& a); + ~ScopedScratch(); + ScopedScratch(const ScopedScratch&) = delete; + ScopedScratch& operator=(const ScopedScratch&) = delete; + + void reset(); + + template + T* alloc(size_t count = 1) + { + return arena->alloc(count); + } + template + T* make(Args&&... args) + { + return arena->make(std::forward(args)...); + } +}; + +struct ResourceNode { + ResourceHandle handle {}; + ResourceId id {}; + ResourceKind kind {}; + bool imported : 1 {}; // managed outside the graph, e.g. a swapchain texture + bool persistent : 1 {}; // one layer of a PersistentResourcePool-backed resource + bool history : 1 {}; // .curr written this frame, read next + bool historyValid : 1 {}; // false if the entry was recreated this frame + bool resolving : 1 {}; // on the relativeTo recursion stack -> re-entry is a cycle + bool hasWriter : 1 {}; // phase-4 aliasing eligibility: some pass writes this + bool firstDefines : 1 {}; // phase-4 aliasing eligibility: first touch fully overwrites the occupant + bool transientAttachment : 1 {}; // graph-owned attachment that never leaves the GPU + + uint32_t historyIndex {}; // 0 == current (write target), 1 == previous (read) + uint64_t historyHash {}; + + // storage not owned by the per-frame graph, imported or pool-backed + bool is_external() const; + + // texture fields + WGPUTextureDimension dimension = WGPUTextureDimension_Undefined; + WGPUTextureFormat format = WGPUTextureFormat_Undefined; + SizeKind sizeKind = SizeKind::Absolute; + float scaleX = 1.0f; + float scaleY = 1.0f; + ResourceHandle relativeToHandle {}; + WGPUExtent3D absolute = WGPU_EXTENT_3D_INIT; + uint32_t mipLevelCount = 1; // > 1 = mip chain, per-mip views built at bind/attach time + uint32_t sampleCount = 1; // > 1 = MSAA + + // buffer fields + uint64_t bufferSize {}; + + // realized GPU handles + WGPUTexture texture {}; // created: the texture object backing view + WGPUTextureView view {}; // imported: the registered swapchain view + // head slot of the subview list of the pooled physical texture backing this node + Subview** subviews {}; + WGPUBuffer buffer {}; // imported: the registered buffer + WGPUExtent3D resolved = WGPU_EXTENT_3D_INIT; // the base for Relative resolution + WGPUTextureUsage texUsage {}; // accumulated in compile() from the access list + WGPUBufferUsage bufUsage {}; + + // first/last surviving pass to touch this, in execution order. filled in compile() phase 3 + static constexpr uint32_t kNoPass = ~0u; + uint32_t firstUse = kNoPass; // kNoPass = dead transient or imported. + uint32_t lastUse = kNoPass; // kNoPass = dead transient or imported. + + // the physical slot this transient shares with other disjoint-lifetime resources. kNoSlot means its own object, ineligible or aliasing off. + static constexpr uint32_t kNoSlot = ~0u; + uint32_t aliasSlot = kNoSlot; + + // filled during the phase-3 access walk. soleAccess is the last one recorded, the only one when the count is 1. + uint32_t liveAccessCount {}; + const ResourceAccess* soleAccess {}; + + ResourceNode* next {}; +}; + +struct NodeAdjacency; + +// intrusive list node with a growable access array +struct PassNode { + ResourceId id {}; + PassKind kind {}; + + // dense declaration-order index, assigned early in compile() + uint32_t index {}; + + // type-erased pass body, invoked by execute() + void* exec_obj {}; + void (*exec_fn)(void*, PassContext&) {}; + + ResourceAccess* accesses {}; + uint32_t accessCount {}; + uint32_t accessCap {}; + + NodeAdjacency* adjacency {}; + + bool placed {}; // topo sort: already emitted into execution order + bool onStack {}; // topo sort: on the DFS stack -> a re-entry is a back-edge + bool sink {}; // cull root: writes an imported or history resource, so it survives with no reader + bool forceKeep {}; // force_keep(): extra cull root + + // initialize() targets this pass bakes. empty is an ordinary pass. compile() sets skipInit only when + // every target is populated and baked with a matching hash, since a body bakes all or none. + static constexpr uint32_t kMaxInitTargets = 8; + struct InitTarget { + ResourceHandle target; + uint64_t hash; + }; + + InitTarget* initTargets {}; + uint32_t initCount {}; + bool skipInit {}; + + PassNode* next {}; +}; + +// pass must execute before the owner of this list +struct NodeAdjacency { + PassNode* pass {}; + NodeAdjacency* next {}; +}; + +// one physical GPU object shared by transients with disjoint lifetimes and an identical signature +// (aliasing, compile() phase 4). a slot never mixes the texture and buffer arms. +struct PhysicalResource { + ResourceKind kind {}; + uint32_t freeFrom {}; // occupant lastUse, reusable iff the next member's firstUse > this + // interned name of the first member to open this slot. stable across frames, which is what lets + // superseded_by recognise a resize of it. + const void* identity {}; + // texture + TexSignature sig {}; // members must match exactly to share this slot + WGPUTextureUsage texUsage {}; // union over members + WGPUTexture texture {}; // filled by realize() via transient.acquire() + WGPUTextureView view {}; + // buffer + uint64_t bufferSize {}; + WGPUBufferUsage bufUsage {}; // union over members + WGPUBuffer buffer {}; +}; + +enum struct RenderGraphState { + Recording = 0, // mutable + Compiled, // read-only, ready to realize/execute + Failed, // authoring error, realize/execute no-op + Finished // spent for this frame +}; + +// concrete state behind the opaque RenderGraph handle, co-allocated in the same block +struct RenderGraphStorage { + GraphAllocator* m_allocator {}; + ResourceNode* m_resouces {}; + ResourceNode* m_resoucesTail {}; + PassNode* m_passes {}; + PassNode* m_passesTail {}; + RenderGraphState m_state {}; + uint32_t next_resource_id = 1; // 0 = invalid handle, so this - 1 is the resource count + uint32_t passCount {}; // incl. skipInit. sizes the dedup stamp. + uint64_t generation {}; // graph-instance id + uint64_t frameId {}; // checked against the allocator's + ResourceNode** byId {}; + PhysicalResource* m_slots {}; + uint32_t m_slotCount {}; + // graph-owned attachments flagged memoryless + uint32_t transientCount {}; + // execute() scratch: subresource views for the current pass, released after the body. one view per + // access, so the per-pass access ceiling bounds it. + WGPUTextureView* viewScratch {}; + uint32_t viewScratchN {}; + uint32_t viewScratchCap {}; // grows on demand, monotonic across passes + ErrorMessage* m_errors {}; // non-null => m_state is Failed + ErrorMessage* m_errorsTail {}; + + // CPU timings + float timing_compile_us {}; + float timing_realize_us {}; + float timing_execute_us {}; +}; + +// storage() relies on st sitting immediately after rg +struct GraphPair { + RenderGraph rg; + RenderGraphStorage st; +}; + +// unit-test helper +struct PassContextAccess { + PassContext ctx; + PassContextAccess(RenderGraph* graph, PassNode* pass) + { + ctx.graph = graph; + ctx.pass = pass; + } +}; + +RenderGraphStorage* storage(RenderGraph* rg); + +ResourceNode* find_node(RenderGraph* rg, ResourceHandle h); + +// the write half of AccessType +bool access_is_write(AccessType t); + +// empty past the end. for the debug lifetime widget. +WGPUStringView pass_name_at(PassNode* head, uint32_t idx); + +// texel-block descriptor +struct TexelBlock { uint32_t w, h, bytes; }; +TexelBlock format_block(WGPUTextureFormat f); + +uint64_t texture_bytes(WGPUExtent3D size, WGPUTextureFormat format, uint32_t mipLevelCount = 1, uint32_t sampleCount = 1, WGPUTextureDimension dim = WGPUTextureDimension_2D); + +} // namespace webgpu::rg::Internal diff --git a/webgpu/base/gpu_utils.cpp b/webgpu/base/gpu_utils.cpp index 5cc80f602..1a8a97f6e 100644 --- a/webgpu/base/gpu_utils.cpp +++ b/webgpu/base/gpu_utils.cpp @@ -141,4 +141,30 @@ void compute_mipmaps_for_texture(Context& ctx, const raii::Texture* texture, WGP wgpuQueueOnSubmittedWorkDone(queue, on_done); } +WGPUBindGroupEntry bind(uint32_t binding, WGPUTextureView view) +{ + WGPUBindGroupEntry e {}; + e.binding = binding; + e.textureView = view; + return e; +} + +WGPUBindGroupEntry bind(uint32_t binding, WGPUSampler sampler) +{ + WGPUBindGroupEntry e {}; + e.binding = binding; + e.sampler = sampler; + return e; +} + +WGPUBindGroupEntry bind(uint32_t binding, WGPUBuffer buffer, uint64_t offset, uint64_t size) +{ + WGPUBindGroupEntry e {}; + e.binding = binding; + e.buffer = buffer; + e.offset = offset; + e.size = size; + return e; +} + } // namespace webgpu diff --git a/webgpu/base/gpu_utils.h b/webgpu/base/gpu_utils.h index 8702059ef..5f357582b 100644 --- a/webgpu/base/gpu_utils.h +++ b/webgpu/base/gpu_utils.h @@ -34,4 +34,8 @@ void compute_mipmaps_for_texture(Context& ctx, const raii::Texture* texture); // Async overload: calls on_done after the mipmap work is submitted to the queue. void compute_mipmaps_for_texture(Context& ctx, const raii::Texture* texture, WGPUQueueWorkDoneCallbackInfo on_done); +WGPUBindGroupEntry bind(uint32_t binding, WGPUTextureView view); +WGPUBindGroupEntry bind(uint32_t binding, WGPUSampler sampler); +WGPUBindGroupEntry bind(uint32_t binding, WGPUBuffer buffer, uint64_t offset, uint64_t size); + } // namespace webgpu diff --git a/webgpu/engine/Window.cpp b/webgpu/engine/Window.cpp index d0201cb61..938a3c0a0 100644 --- a/webgpu/engine/Window.cpp +++ b/webgpu/engine/Window.cpp @@ -27,9 +27,12 @@ #include #include #include +#include "webgpu/base/RenderGraph.h" #include +#include + namespace webgpu_engine { Window::Window() { } @@ -215,6 +218,152 @@ void Window::paint(webgpu::Framebuffer* framebuffer, WGPUCommandEncoder command_ m_paint_number++; } +webgpu::rg::TextureHandle Window::paint(webgpu::rg::RenderGraph* rg, bool use_render_graph) +{ + // ToDo only update on change? + m_shared_config_ubo->data = m_context->shared_config(); + m_shared_config_ubo->update_gpu_data(m_context->webgpu_ctx().queue()); + + webgpu::rg::TextureHandle atmosphere; + if (m_context->shared_config().m_atmosphere_enabled) { + atmosphere = m_context->atmosphere_renderer()->draw(rg, m_camera_bind_group->handle()); + } else { + atmosphere = rg->create_initialized_texture("atmosphere.fallback", + { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_RGBA8Unorm, .absolute = { 1, 1, 1 } }, + { 0, 0, 0, 1 }); + } + + if (!use_render_graph) + return atmosphere; + + const uint32_t w = uint32_t(m_swapchain_size.x); + const uint32_t h = uint32_t(m_swapchain_size.y); + + auto make_target = [&](std::string_view id, WGPUTextureFormat fmt) { + return rg->create_transient_texture(id, + { + .dimension = WGPUTextureDimension_2D, + .format = fmt, + .absolute = { w, h, 1 }, + }); + }; + auto albedo = make_target("gbuffer.albedo", WGPUTextureFormat_R32Uint); + + auto position = rg->import_texture("gbuffer.position", + { .view = m_gbuffer->color_texture_view(1).handle(), + .size = { w, h, 1 }, + .format = WGPUTextureFormat_RGBA32Float }); + auto normal = make_target("gbuffer.normal", WGPUTextureFormat_RG16Uint); + auto overlay = make_target("gbuffer.overlay", WGPUTextureFormat_R32Uint); + + auto gdepth = rg->import_texture("gbuffer.depth", + { .view = m_gbuffer->depth_texture_view().handle(), + .size = { w, h, 1 }, + .format = m_gbuffer_format.depth_format }); + + + rg->add_pass("Tiles", webgpu::rg::PassKind::Graphics, + [albedo, position, normal, overlay, gdepth](webgpu::rg::PassBuilder& b) { + b.color(albedo, 0, { .clear = { 0, 0, 0, 0 } }); + b.color(position, 1, { .clear = { 0, 0, 0, 0 } }); + b.color(normal, 2, { .clear = { 0, 0, 0, 0 } }); + b.color(overlay, 3, { .clear = { 0, 0, 0, 0 } }); + b.depth_stencil(gdepth, { .clearDepth = 0.0f }); // reverse-Z: clear to 0 + }, + [this](webgpu::rg::PassContext& c) { + + // render tiles to geometry buffers + using namespace nucleus::tile; + const auto draw_list = drawing::compute_bounds( + drawing::limit(drawing::generate_list(m_camera, m_context->aabb_decorator(), m_max_zoom_level), 1024), m_context->aabb_decorator()); + const auto culled_draw_list = drawing::sort(drawing::cull(draw_list, m_camera), m_camera.position()); + + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, m_shared_config_bind_group->handle(), 0, nullptr); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 1, m_camera_bind_group->handle(), 0, nullptr); + m_context->tile_mesh_renderer()->draw(c.render_pass, m_camera, culled_draw_list); + }); + + // Compose: resolve G-buffer + atmosphere into a full-size colour target. + auto composed = rg->create_transient_texture("composed_color", + { + .dimension = WGPUTextureDimension_2D, + .format = m_context->webgpu_ctx().surface_texture_format(), + .absolute = { w, h, 1 }, + }); + auto compose_depth = make_target("compose_depth", WGPUTextureFormat_Depth24Plus); + + webgpu::rg::TextureHandle cloud_color; + webgpu::rg::TextureHandle cloud_depth; + if (m_context->shared_config().m_clouds_enabled) { + auto cloud = m_context->cloud_renderer()->draw( + rg, m_depth_texture_bind_group->handle(), m_shared_config_bind_group->handle(), m_camera, m_paint_number, gdepth); + cloud_color = cloud.color; + cloud_depth = cloud.depth; + m_needs_redraw |= m_context->cloud_renderer()->needs_redraw(); + } else { + + cloud_color = rg->create_initialized_texture("clouds.fallback_color", + { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_RGBA16Float, .absolute = { 1, 1, 1 } }, + { 0, 0, 0, 1 }); + cloud_depth = rg->create_initialized_texture("clouds.fallback_depth", + { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_R32Float, .absolute = { 1, 1, 1 } }, + { 0, 0, 0, 0 }); + } + + auto overlay_result = m_context->overlay_renderer()->draw( + rg, position, normal, overlay, m_shared_config_bind_group->handle(), m_camera_bind_group->handle()); + auto overlay_pre = overlay_result.pre; + auto overlay_post = overlay_result.post; + + rg->add_pass("Compose", webgpu::rg::PassKind::Graphics, + [composed, compose_depth, albedo, position, normal, atmosphere, overlay, cloud_color, cloud_depth, overlay_pre, overlay_post](webgpu::rg::PassBuilder& b) { + b.color(composed, 0, { .clear = { 0, 0, 0, 1 } }); + b.depth_stencil(compose_depth, { .clearDepth = 0.0f }); + b.sampled(albedo); + b.sampled(position); + b.sampled(normal); + b.sampled(atmosphere); + b.sampled(overlay); + b.sampled(cloud_color); + b.storage_read(cloud_depth); + b.sampled(overlay_pre); + b.sampled(overlay_post); + }, + [this, albedo, position, normal, atmosphere, overlay, cloud_color, cloud_depth, overlay_pre, overlay_post](webgpu::rg::PassContext& c) { + auto& webgpu_ctx = m_context->webgpu_ctx(); + webgpu::raii::BindGroup compose_bind_group(c.device, webgpu_ctx.resource_registry().bind_group_layout("compose"), + { + c.bind(0, albedo), + c.bind(1, position), + c.bind(2, normal), + c.bind(3, atmosphere), + c.bind(4, overlay), + c.bind(5, cloud_color), + c.bind(6, cloud_depth), + m_shadow_texture->texture_view().create_bind_group_entry(7), + m_shadow_texture->sampler().create_bind_group_entry(8), + c.bind(9, position), + c.bind(10, overlay_post), + c.bind(11, overlay_pre), + }, + "Compose"); + + wgpuRenderPassEncoderSetPipeline(c.render_pass, m_compose_pipeline->pipeline().handle()); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, m_shared_config_bind_group->handle(), 0, nullptr); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 1, m_camera_bind_group->handle(), 0, nullptr); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 2, compose_bind_group.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); + + if (m_context->shared_config().m_track_render_mode > 0) { + m_context->track_renderer()->render(rg, composed, gdepth, + m_shared_config_bind_group->handle(), m_camera_bind_group->handle(), m_depth_texture_bind_group->handle()); + } + + m_paint_number++; + return composed; +} + glm::vec4 Window::synchronous_position_readback(const glm::dvec2& ndc) { if (m_position_readback_buffer->map_state() == WGPUBufferMapState_Unmapped) { diff --git a/webgpu/engine/Window.h b/webgpu/engine/Window.h index c0b23183e..65285421a 100644 --- a/webgpu/engine/Window.h +++ b/webgpu/engine/Window.h @@ -24,6 +24,7 @@ #include "nucleus/AbstractRenderWindow.h" #include "nucleus/camera/AbstractDepthTester.h" #include "nucleus/camera/Definition.h" +#include "nucleus/tile/types.h" #include "nucleus/utils/ColourTexture.h" #include #include @@ -31,6 +32,11 @@ class QOpenGLFramebufferObject; +namespace webgpu::rg +{ +struct RenderGraph; +} + namespace webgpu_engine { class Window : public nucleus::AbstractRenderWindow, public nucleus::camera::AbstractDepthTester { @@ -45,6 +51,7 @@ class Window : public nucleus::AbstractRenderWindow, public nucleus::camera::Abs void resize_framebuffer(int w, int h) override; void ready(); void paint(webgpu::Framebuffer* framebuffer, WGPUCommandEncoder command_encoder); + webgpu::rg::TextureHandle paint(webgpu::rg::RenderGraph* rg, bool use_render_graph); // void paint(WGPUTextureView target_color_texture, WGPUTextureView target_depth_texture, WGPUCommandEncoder encoder); void paint([[maybe_unused]] QOpenGLFramebufferObject* framebuffer = nullptr) override { throw std::runtime_error("Not implemented"); } diff --git a/webgpu/engine/atmosphere/AtmosphereRenderer.cpp b/webgpu/engine/atmosphere/AtmosphereRenderer.cpp index fcafa1e09..fcdc19882 100644 --- a/webgpu/engine/atmosphere/AtmosphereRenderer.cpp +++ b/webgpu/engine/atmosphere/AtmosphereRenderer.cpp @@ -23,6 +23,7 @@ #include #include #include +#include namespace webgpu_engine { @@ -65,6 +66,32 @@ void AtmosphereRenderer::draw(const WGPUCommandEncoder& command_encoder, const W wgpuRenderPassEncoderDraw(render_pass->handle(), 3, 1, 0, 0); } +webgpu::rg::TextureHandle AtmosphereRenderer::draw(webgpu::rg::RenderGraph* rg, const WGPUBindGroup& camera_bind_group) +{ + auto s = m_atmosphere_framebuffer->size(); + + auto renderTarget = rg->create_transient_texture("atmosphere_framebuffer", + { + .dimension = WGPUTextureDimension_2D, + .format = WGPUTextureFormat_RGBA8Unorm, + .absolute = {s.x, s.y, 1}, + }); + + + rg->add_pass("Atmosphere", webgpu::rg::PassKind::Graphics, + [&](webgpu::rg::PassBuilder& b) { + b.color(renderTarget, 0); + }, + [camera_bind_group, pipeline = m_pipeline->pipeline().handle()] (webgpu::rg::PassContext& ctx) { + wgpuRenderPassEncoderSetBindGroup(ctx.render_pass, 0, camera_bind_group, 0, nullptr); + wgpuRenderPassEncoderSetPipeline(ctx.render_pass, pipeline); + wgpuRenderPassEncoderDraw(ctx.render_pass, 3, 1, 0, 0); + } + ); + + return renderTarget; +} + const webgpu::raii::TextureView* AtmosphereRenderer::result_view() const { return &m_atmosphere_framebuffer->color_texture_view(0); } } // namespace webgpu_engine diff --git a/webgpu/engine/atmosphere/AtmosphereRenderer.h b/webgpu/engine/atmosphere/AtmosphereRenderer.h index 7ec43a144..ac69b2fbc 100644 --- a/webgpu/engine/atmosphere/AtmosphereRenderer.h +++ b/webgpu/engine/atmosphere/AtmosphereRenderer.h @@ -27,6 +27,7 @@ #include #include #include +#include namespace webgpu_engine { @@ -40,7 +41,7 @@ class AtmosphereRenderer : public QObject { void resize(int w, int h); void draw(const WGPUCommandEncoder& command_encoder, const WGPUBindGroup& camera_bind_group); - + webgpu::rg::TextureHandle draw(webgpu::rg::RenderGraph* rg, const WGPUBindGroup& camera_bind_group); [[nodiscard]] const webgpu::raii::TextureView* result_view() const; private: diff --git a/webgpu/engine/cloud/CloudRenderer.cpp b/webgpu/engine/cloud/CloudRenderer.cpp index 2be1bd257..b2d7052ae 100644 --- a/webgpu/engine/cloud/CloudRenderer.cpp +++ b/webgpu/engine/cloud/CloudRenderer.cpp @@ -18,6 +18,8 @@ #include "CloudRenderer.h" +#include + #include "glm/ext/matrix_relational.hpp" #include "nucleus/camera/Definition.h" #include "nucleus/srs.h" @@ -462,6 +464,145 @@ void CloudRenderer::draw(const WGPUCommandEncoder& command_encoder, } } +CloudRenderer::GraphOutput CloudRenderer::draw(webgpu::rg::RenderGraph* rg, + const WGPUBindGroup& depth_texture_bind_group, + const WGPUBindGroup& shared_config_bind_group, + const nucleus::camera::Definition& camera, + uint32_t frame_number, + webgpu::rg::TextureHandle gbuffer_depth) +{ + auto jitter_offset = generate_jitter_simple_4x(frame_number, m_output_lo_resolution); + glm::mat4 unjittered_projection = camera.projection_matrix(); + glm::mat4 jittered_projection = jitter_projection_matrix(unjittered_projection, jitter_offset); + glm::mat4 view_matrix = camera.local_view_matrix(); + glm::mat4 inverse_view_matrix = glm::inverse(view_matrix); + + bool stable = glm::all(glm::equal(m_upscale_shader_params_ubo->data.previous_camera.view_matrix, view_matrix)) + && glm::all(glm::equal(m_upscale_shader_params_ubo->data.previous_camera.proj_matrix, unjittered_projection)); + if (stable) { + m_stable_frames++; + } else { + m_stable_frames = 0; + } + + // render params + m_render_shader_params_ubo->data.camera = { + .view_matrix = view_matrix, + .proj_matrix = jittered_projection, + .inv_view_matrix = inverse_view_matrix, + .inv_proj_matrix = glm::inverse(jittered_projection), + .position = glm::vec4(camera.position(), 0.0f), + }; + m_render_shader_params_ubo->data.frame_index = frame_number; + m_render_shader_params_ubo->data.jitter = jitter_offset * glm::dvec2(m_output_hi_resolution); + m_render_shader_params_ubo->data.step_size_min = shader_params.step_size_min; + m_render_shader_params_ubo->data.step_size_distance_factor = shader_params.step_size_distance_factor; + m_render_shader_params_ubo->data.step_size_horizon_factor = shader_params.step_size_horizon_factor; + m_render_shader_params_ubo->data.extinction_coeff = shader_params.extinction_coeff; + m_render_shader_params_ubo->data.scattering_coeff = shader_params.scattering_coeff; + m_render_shader_params_ubo->data.albedo = shader_params.albedo; + m_render_shader_params_ubo->data.sun_light_scale = shader_params.sun_light_scale; + m_render_shader_params_ubo->data.ambient_light_scale = shader_params.ambient_light_scale; + m_render_shader_params_ubo->data.atm_light_scale = shader_params.atmospheric_light_scale; + m_render_shader_params_ubo->data.shadow_extinction_scale = shader_params.shadow_extinction_scale; + m_render_shader_params_ubo->data.fade_factor = shader_params.fade_factor; + m_render_shader_params_ubo->data.powder_scale = shader_params.powder_scale; + m_render_shader_params_ubo->update_gpu_data(m_ctx->queue()); + + m_cloud_tile_info_buffer->write(m_ctx->queue(), m_tile_infos.data(), m_tile_infos.size()); + + // upscale (TAAU) params + m_upscale_shader_params_ubo->data.previous_camera = m_upscale_shader_params_ubo->data.current_camera; + m_upscale_shader_params_ubo->data.current_camera = { + .view_matrix = view_matrix, + .proj_matrix = unjittered_projection, + .inv_view_matrix = inverse_view_matrix, + .inv_proj_matrix = glm::inverse(unjittered_projection), + .position = glm::vec4(camera.position(), 0.0f), + }; + m_upscale_shader_params_ubo->data.prev_jitter = m_upscale_shader_params_ubo->data.jitter; + m_upscale_shader_params_ubo->data.jitter = jitter_offset; + m_upscale_shader_params_ubo->update_gpu_data(m_ctx->queue()); + + const glm::uvec2 lo = m_output_lo_resolution; + const glm::uvec2 hi = m_output_hi_resolution; + + auto lo_color = rg->create_transient_texture("clouds.lo_color", + { + .dimension = WGPUTextureDimension_2D, + .format = WGPUTextureFormat_RGBA16Float, + .absolute = { lo.x, lo.y, 1 }, + }); + auto lo_depth = rg->create_transient_texture("clouds.lo_depth", + { + .dimension = WGPUTextureDimension_2D, + .format = WGPUTextureFormat_R32Float, + .absolute = { lo.x, lo.y, 1 }, + }); + auto hi_color = rg->create_history_texture("clouds.hi_color", + { + .dimension = WGPUTextureDimension_2D, + .format = WGPUTextureFormat_RGBA16Float, + .absolute = { hi.x, hi.y, 1 }, + }); + + + rg->add_pass("Clouds.Render", webgpu::rg::PassKind::Compute, + [lo_color, lo_depth, gbuffer_depth](webgpu::rg::PassBuilder& b) { + b.storage_write(lo_color); // binding 4 (rgba16float) + b.storage_write(lo_depth); // binding 5 (r32float) + b.sampled(gbuffer_depth); // ordering only: bound via the pre-built depth_texture bind group (group 1) + }, + [this, lo_color, lo_depth, depth_texture_bind_group, shared_config_bind_group](webgpu::rg::PassContext& c) { + + webgpu::raii::BindGroup bind_group(c.device, m_ctx->resource_registry().bind_group_layout("render_clouds"), + { + m_render_shader_params_ubo->raw_buffer().create_bind_group_entry(0), + m_cloud_atlas_view->create_bind_group_entry(1), + m_cloud_linear_sampler->create_bind_group_entry(2), + m_cloud_tile_info_buffer->create_bind_group_entry(3), + c.bind(4, lo_color), + c.bind(5, lo_depth), + }, + "CloudsRender"); + + wgpuComputePassEncoderSetPipeline(c.compute_pass, m_render_clouds_pipeline->handle()); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bind_group.handle(), 0, nullptr); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 1, depth_texture_bind_group, 0, nullptr); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 2, shared_config_bind_group, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups( + c.compute_pass, ceil_div(m_output_lo_resolution.x, 8u), ceil_div(m_output_lo_resolution.y, 8u), 1); + }); + + rg->add_pass("Clouds.Upscale", webgpu::rg::PassKind::Compute, + [lo_color, lo_depth, hi_color](webgpu::rg::PassBuilder& b) { + b.sampled(lo_color); + b.sampled(lo_depth); + b.sampled(hi_color.prev); + b.storage_write(hi_color.curr); + }, + [this, lo_color, lo_depth, hi_color](webgpu::rg::PassContext& c) { + + webgpu::raii::BindGroup bind_group(c.device, m_ctx->resource_registry().bind_group_layout("upscale_clouds"), + { + m_upscale_shader_params_ubo->raw_buffer().create_bind_group_entry(0), + c.bind(1, lo_color), + c.bind(2, lo_depth), + m_linear_sampler->create_bind_group_entry(3), + c.bind(4, hi_color.prev), + c.bind(5, hi_color.curr), + }, + "CloudsUpscale"); + + wgpuComputePassEncoderSetPipeline(c.compute_pass, m_upscale_clouds_pipeline->handle()); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bind_group.handle(), 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups( + c.compute_pass, ceil_div(m_output_hi_resolution.x, 8u), ceil_div(m_output_hi_resolution.y, 8u), 1); + }); + + return { hi_color.curr, lo_depth }; +} + void CloudRenderer::set_tile_limit(unsigned int num_tiles) { m_loaded_cloud_textures.set_tile_limit(num_tiles); } void CloudRenderer::update_gpu_tiles_cloud(const std::vector& deleted_tiles, const std::vector& new_tiles) diff --git a/webgpu/engine/cloud/CloudRenderer.h b/webgpu/engine/cloud/CloudRenderer.h index 3018f8cfa..b55f5e823 100644 --- a/webgpu/engine/cloud/CloudRenderer.h +++ b/webgpu/engine/cloud/CloudRenderer.h @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -91,6 +92,18 @@ class CloudRenderer : public QObject { const nucleus::camera::Definition& camera, uint32_t frame_number); + struct GraphOutput { + webgpu::rg::TextureHandle color; // hi-res TAAU colour (.curr) + webgpu::rg::TextureHandle depth; // lo-res ray depth + }; + + GraphOutput draw(webgpu::rg::RenderGraph* rg, + const WGPUBindGroup& depth_texture_bind_group, + const WGPUBindGroup& shared_config_bind_group, + const nucleus::camera::Definition& camera, + uint32_t frame_number, + webgpu::rg::TextureHandle gbuffer_depth); + [[nodiscard]] bool needs_redraw() const { return m_stable_frames <= static_cast(shader_params.stable_frames_limit); } void set_tile_limit(unsigned new_limit); diff --git a/webgpu/engine/overlay/HeightLinesOverlay.cpp b/webgpu/engine/overlay/HeightLinesOverlay.cpp index 2e1a72185..321e0c19f 100644 --- a/webgpu/engine/overlay/HeightLinesOverlay.cpp +++ b/webgpu/engine/overlay/HeightLinesOverlay.cpp @@ -19,6 +19,7 @@ #include "HeightLinesOverlay.h" #include "webgpu/engine/Context.h" +#include #include #include #include @@ -134,4 +135,43 @@ void HeightLinesOverlay::draw(const WGPUCommandEncoder& command_encoder, m_pipeline->run(compute_pass, workgroup_counts); } +void HeightLinesOverlay::draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle normal, + webgpu::rg::TextureHandle /*overlay*/, + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg, + webgpu::rg::TextureHandle source, + webgpu::rg::TextureHandle target, + glm::uvec2 output_size) +{ + if (!m_pipeline) + return; + + render_graph->add_pass("overlay.height_lines", webgpu::rg::PassKind::Compute, + [position, normal, source, target](webgpu::rg::PassBuilder& b) { + b.sampled(position); + b.sampled(normal); + b.storage_write(target); + b.sampled(source); + }, + [this, position, normal, source, target, shared_config_bg, camera_bg, output_size](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bind_group(c.device, m_ctx->resource_registry().bind_group_layout("height_lines_overlay"), + { + c.bind(0, position), + c.bind(1, normal), + m_settings_uniform->raw_buffer().create_bind_group_entry(2), + c.bind(3, target), + c.bind(4, source), + }, + "overlay.height_lines"); + + wgpuComputePassEncoderSetPipeline(c.compute_pass, m_pipeline->handle()); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, shared_config_bg, 0, nullptr); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 1, camera_bg, 0, nullptr); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 2, bind_group.handle(), 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass, (output_size.x + 15u) / 16u, (output_size.y + 15u) / 16u, 1); + }); +} + } // namespace webgpu_engine diff --git a/webgpu/engine/overlay/HeightLinesOverlay.h b/webgpu/engine/overlay/HeightLinesOverlay.h index c7a3e85e6..988dce78d 100644 --- a/webgpu/engine/overlay/HeightLinesOverlay.h +++ b/webgpu/engine/overlay/HeightLinesOverlay.h @@ -50,6 +50,16 @@ class HeightLinesOverlay : public Overlay { webgpu::raii::TextureWithSampler& target_output, glm::uvec2 output_size) override; + void draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle normal, + webgpu::rg::TextureHandle overlay, + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg, + webgpu::rg::TextureHandle source, + webgpu::rg::TextureHandle target, + glm::uvec2 output_size) override; + Settings settings; private: diff --git a/webgpu/engine/overlay/Overlay.h b/webgpu/engine/overlay/Overlay.h index d867e49b8..4f5dcfe7f 100644 --- a/webgpu/engine/overlay/Overlay.h +++ b/webgpu/engine/overlay/Overlay.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -57,6 +58,17 @@ class Overlay { webgpu::raii::TextureWithSampler& target_output, glm::uvec2 output_size) = 0; + + virtual void draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle normal, + webgpu::rg::TextureHandle overlay, // GBuffer slot 3 (packed tile-debug data) + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg, + webgpu::rg::TextureHandle source, + webgpu::rg::TextureHandle target, + glm::uvec2 output_size) + = 0; }; } // namespace webgpu_engine diff --git a/webgpu/engine/overlay/OverlayRenderer.cpp b/webgpu/engine/overlay/OverlayRenderer.cpp index d7e8a3bae..a81911884 100644 --- a/webgpu/engine/overlay/OverlayRenderer.cpp +++ b/webgpu/engine/overlay/OverlayRenderer.cpp @@ -22,6 +22,7 @@ #include "webgpu/engine/Context.h" #include #include +#include #include namespace webgpu_engine { @@ -178,6 +179,40 @@ void OverlayRenderer::draw_bucket(const WGPUCommandEncoder& command_encoder, } } +OverlayRenderer::GraphResult OverlayRenderer::draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle normal, + webgpu::rg::TextureHandle overlay, + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg) +{ + const glm::uvec2 output_size(m_pre[0]->texture().width(), m_pre[0]->texture().height()); + + + auto run_bucket = [&](const std::vector& bucket, std::string_view clear_id, const char* target_prefix) -> webgpu::rg::TextureHandle { + webgpu::rg::TextureDesc desc {}; + desc.dimension = WGPUTextureDimension_2D; + desc.format = WGPUTextureFormat_RGBA8Unorm; + desc.absolute = { output_size.x, output_size.y, 1 }; + + webgpu::rg::TextureHandle source = render_graph->create_initialized_texture(clear_id, desc, { 0.0, 0.0, 0.0, 0.0 }); + + int stage = 0; + for (Overlay* o : bucket) { + const std::string name = std::string(target_prefix) + std::to_string(stage++); + const webgpu::rg::TextureHandle target = render_graph->create_transient_texture(name, desc); + + o->draw(render_graph, position, normal, overlay, shared_config_bg, camera_bg, source, target, output_size); + source = target; + } + return source; + }; + + webgpu::rg::TextureHandle pre = run_bucket(m_pre_overlays, "overlay.pre.clear", "overlay.pre."); + webgpu::rg::TextureHandle post = run_bucket(m_post_overlays, "overlay.post.clear", "overlay.post."); + return { pre, post }; +} + const webgpu::raii::TextureView* OverlayRenderer::result_pre_view() const { return m_pre[0] ? &m_pre[0]->texture_view() : nullptr; } const webgpu::raii::TextureView* OverlayRenderer::result_post_view() const { return m_post[0] ? &m_post[0]->texture_view() : nullptr; } diff --git a/webgpu/engine/overlay/OverlayRenderer.h b/webgpu/engine/overlay/OverlayRenderer.h index f51848950..f6e88b102 100644 --- a/webgpu/engine/overlay/OverlayRenderer.h +++ b/webgpu/engine/overlay/OverlayRenderer.h @@ -57,6 +57,19 @@ class OverlayRenderer : public QObject { const WGPUBindGroup& shared_config_bg, const WGPUBindGroup& camera_bg); + + struct GraphResult { + webgpu::rg::TextureHandle pre; + webgpu::rg::TextureHandle post; + }; + + [[nodiscard]] GraphResult draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle normal, + webgpu::rg::TextureHandle overlay, + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg); + [[nodiscard]] const webgpu::raii::TextureView* result_pre_view() const; [[nodiscard]] const webgpu::raii::TextureView* result_post_view() const; diff --git a/webgpu/engine/overlay/ScreenSpaceSnowOverlay.cpp b/webgpu/engine/overlay/ScreenSpaceSnowOverlay.cpp index 78378c76e..06b5a1135 100644 --- a/webgpu/engine/overlay/ScreenSpaceSnowOverlay.cpp +++ b/webgpu/engine/overlay/ScreenSpaceSnowOverlay.cpp @@ -20,6 +20,7 @@ #include "ScreenSpaceSnowOverlay.h" #include "webgpu/engine/Context.h" +#include #include #include #include @@ -135,4 +136,43 @@ void ScreenSpaceSnowOverlay::draw(const WGPUCommandEncoder& command_encoder, m_pipeline->run(compute_pass, workgroup_counts); } +void ScreenSpaceSnowOverlay::draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle normal, + webgpu::rg::TextureHandle /*overlay*/, + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg, + webgpu::rg::TextureHandle source, + webgpu::rg::TextureHandle target, + glm::uvec2 output_size) +{ + if (!m_pipeline) + return; + + render_graph->add_pass("overlay.screen_space_snow", webgpu::rg::PassKind::Compute, + [position, normal, source, target](webgpu::rg::PassBuilder& b) { + b.sampled(position); + b.sampled(normal); + b.storage_write(target); + b.sampled(source); + }, + [this, position, normal, source, target, shared_config_bg, camera_bg, output_size](webgpu::rg::PassContext& c) { + webgpu::raii::BindGroup bind_group(c.device, m_ctx->resource_registry().bind_group_layout("screen_space_snow_overlay"), + { + c.bind(0, position), + c.bind(1, normal), + m_settings_uniform->raw_buffer().create_bind_group_entry(2), + c.bind(3, target), + c.bind(4, source), + }, + "overlay.screen_space_snow"); + + wgpuComputePassEncoderSetPipeline(c.compute_pass, m_pipeline->handle()); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, shared_config_bg, 0, nullptr); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 1, camera_bg, 0, nullptr); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 2, bind_group.handle(), 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass, (output_size.x + 15u) / 16u, (output_size.y + 15u) / 16u, 1); + }); +} + } // namespace webgpu_engine diff --git a/webgpu/engine/overlay/ScreenSpaceSnowOverlay.h b/webgpu/engine/overlay/ScreenSpaceSnowOverlay.h index fe6cf14c0..4580e1ec1 100644 --- a/webgpu/engine/overlay/ScreenSpaceSnowOverlay.h +++ b/webgpu/engine/overlay/ScreenSpaceSnowOverlay.h @@ -54,6 +54,16 @@ class ScreenSpaceSnowOverlay : public Overlay { webgpu::raii::TextureWithSampler& target_output, glm::uvec2 output_size) override; + void draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle normal, + webgpu::rg::TextureHandle overlay, + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg, + webgpu::rg::TextureHandle source, + webgpu::rg::TextureHandle target, + glm::uvec2 output_size) override; + Settings settings; private: diff --git a/webgpu/engine/overlay/TextureOverlay.cpp b/webgpu/engine/overlay/TextureOverlay.cpp index 496782ceb..2c8f38c89 100644 --- a/webgpu/engine/overlay/TextureOverlay.cpp +++ b/webgpu/engine/overlay/TextureOverlay.cpp @@ -20,6 +20,7 @@ #include "TextureOverlay.h" #include "webgpu/engine/Context.h" +#include #include #include #include @@ -229,4 +230,45 @@ void TextureOverlay::draw(const WGPUCommandEncoder& command_encoder, wgpuRenderPassEncoderDraw(render_pass.handle(), 3, 1, 0, 0); } +void TextureOverlay::draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle /*normal*/, + webgpu::rg::TextureHandle /*overlay*/, + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg, + webgpu::rg::TextureHandle source, + webgpu::rg::TextureHandle target, + glm::uvec2 /*output_size*/) +{ + if (!m_pipeline) + return; + + render_graph->add_pass("overlay.texture", webgpu::rg::PassKind::Graphics, + [position, source, target](webgpu::rg::PassBuilder& b) { + b.color(target, 0, { .clear = { 0.0, 0.0, 0.0, 0.0 } }); + b.sampled(position); + b.sampled(source); + }, + [this, position, source, shared_config_bg, camera_bg](webgpu::rg::PassContext& c) { + const webgpu::raii::TextureWithSampler* raster = m_linked_texture ? m_linked_texture : m_overlay_texture.get(); + assert(raster && m_pipeline); + + webgpu::raii::BindGroup bind_group(c.device, m_ctx->resource_registry().bind_group_layout("texture_overlay"), + { + c.bind(0, position), + m_settings_uniform->raw_buffer().create_bind_group_entry(1), + raster->texture_view().create_bind_group_entry(2), + raster->sampler().create_bind_group_entry(3), + c.bind(4, source), + }, + "overlay.texture"); + + wgpuRenderPassEncoderSetPipeline(c.render_pass, m_pipeline->pipeline().handle()); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, shared_config_bg, 0, nullptr); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 1, camera_bg, 0, nullptr); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 2, bind_group.handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0); + }); +} + } // namespace webgpu_engine diff --git a/webgpu/engine/overlay/TextureOverlay.h b/webgpu/engine/overlay/TextureOverlay.h index 6158d6fb9..0da59358c 100644 --- a/webgpu/engine/overlay/TextureOverlay.h +++ b/webgpu/engine/overlay/TextureOverlay.h @@ -70,6 +70,16 @@ class TextureOverlay : public Overlay { webgpu::raii::TextureWithSampler& target_output, glm::uvec2 output_size) override; + void draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle normal, + webgpu::rg::TextureHandle overlay, + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg, + webgpu::rg::TextureHandle source, + webgpu::rg::TextureHandle target, + glm::uvec2 output_size) override; + private: struct GpuSettings { glm::vec2 aabb_min = glm::vec2(0.0f); diff --git a/webgpu/engine/overlay/TileDebugOverlay.cpp b/webgpu/engine/overlay/TileDebugOverlay.cpp index 6d42b060b..63d71faeb 100644 --- a/webgpu/engine/overlay/TileDebugOverlay.cpp +++ b/webgpu/engine/overlay/TileDebugOverlay.cpp @@ -20,6 +20,7 @@ #include "TileDebugOverlay.h" #include "webgpu/engine/Context.h" +#include #include #include #include @@ -134,4 +135,40 @@ void TileDebugOverlay::draw(const WGPUCommandEncoder& command_encoder, m_pipeline->run(compute_pass, workgroup_counts); } +void TileDebugOverlay::draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle /*position*/, + webgpu::rg::TextureHandle /*normal*/, + webgpu::rg::TextureHandle overlay, + const WGPUBindGroup& /*shared_config_bg*/, + const WGPUBindGroup& /*camera_bg*/, + webgpu::rg::TextureHandle source, + webgpu::rg::TextureHandle target, + glm::uvec2 output_size) +{ + if (!m_pipeline) + return; + + render_graph->add_pass("overlay.tile_debug", webgpu::rg::PassKind::Compute, + [overlay, source, target](webgpu::rg::PassBuilder& b) { + b.sampled(overlay); + b.storage_write(target); + b.sampled(source); + }, + [this, overlay, source, target, output_size](webgpu::rg::PassContext& c) { + + webgpu::raii::BindGroup bind_group(c.device, m_ctx->resource_registry().bind_group_layout("tile_debug_overlay"), + { + c.bind(0, overlay), + m_settings_uniform->raw_buffer().create_bind_group_entry(1), + c.bind(2, target), + c.bind(3, source), + }, + "overlay.tile_debug"); + + wgpuComputePassEncoderSetPipeline(c.compute_pass, m_pipeline->handle()); + wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bind_group.handle(), 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass, (output_size.x + 15u) / 16u, (output_size.y + 15u) / 16u, 1); + }); +} + } // namespace webgpu_engine diff --git a/webgpu/engine/overlay/TileDebugOverlay.h b/webgpu/engine/overlay/TileDebugOverlay.h index 11afca07c..da508e7fc 100644 --- a/webgpu/engine/overlay/TileDebugOverlay.h +++ b/webgpu/engine/overlay/TileDebugOverlay.h @@ -60,6 +60,16 @@ class TileDebugOverlay : public Overlay { webgpu::raii::TextureWithSampler& target_output, glm::uvec2 output_size) override; + void draw(webgpu::rg::RenderGraph* render_graph, + webgpu::rg::TextureHandle position, + webgpu::rg::TextureHandle normal, + webgpu::rg::TextureHandle overlay, + const WGPUBindGroup& shared_config_bg, + const WGPUBindGroup& camera_bg, + webgpu::rg::TextureHandle source, + webgpu::rg::TextureHandle target, + glm::uvec2 output_size) override; + Settings settings; private: diff --git a/webgpu/engine/track/TrackRenderer.cpp b/webgpu/engine/track/TrackRenderer.cpp index 3e8ab8ea9..f73fd61bd 100644 --- a/webgpu/engine/track/TrackRenderer.cpp +++ b/webgpu/engine/track/TrackRenderer.cpp @@ -193,4 +193,28 @@ void TrackRenderer::render(WGPUCommandEncoder command_encoder, } } +void TrackRenderer::render(webgpu::rg::RenderGraph* rg, + webgpu::rg::TextureHandle target_color, + webgpu::rg::TextureHandle gbuffer_depth, + const WGPUBindGroup& shared_config, + const WGPUBindGroup& camera_config, + const WGPUBindGroup& depth_texture) +{ + rg->add_pass("Track", webgpu::rg::PassKind::Graphics, + [target_color, gbuffer_depth](webgpu::rg::PassBuilder& b) { + b.color(target_color, 0, { .load = WGPULoadOp_Load }); + b.sampled(gbuffer_depth); + }, + [this, shared_config, camera_config, depth_texture](webgpu::rg::PassContext& c) { + wgpuRenderPassEncoderSetPipeline(c.render_pass, m_pipeline->handle()); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, shared_config, 0, nullptr); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 1, camera_config, 0, nullptr); + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 2, depth_texture, 0, nullptr); + for (size_t i = 0; i < m_bind_groups.size(); i++) { + wgpuRenderPassEncoderSetBindGroup(c.render_pass, 3, m_bind_groups.at(i)->handle(), 0, nullptr); + wgpuRenderPassEncoderDraw(c.render_pass, uint32_t(m_position_buffers.at(i)->size()), 1, 0, 0); + } + }); +} + } // namespace webgpu_engine diff --git a/webgpu/engine/track/TrackRenderer.h b/webgpu/engine/track/TrackRenderer.h index dafa4351b..0d92d476a 100644 --- a/webgpu/engine/track/TrackRenderer.h +++ b/webgpu/engine/track/TrackRenderer.h @@ -28,6 +28,7 @@ #include #include #include +#include #include namespace webgpu_engine { @@ -62,6 +63,13 @@ class TrackRenderer : public QObject { const webgpu::raii::BindGroup& depth_texture, const webgpu::raii::TextureView& color_texture); + void render(webgpu::rg::RenderGraph* rg, + webgpu::rg::TextureHandle target_color, + webgpu::rg::TextureHandle gbuffer_depth, + const WGPUBindGroup& shared_config, + const WGPUBindGroup& camera_config, + const WGPUBindGroup& depth_texture); + private: webgpu::Context* m_ctx;