fg.cpp

cross platform rendering playground

src/passes/fg.cpp

35.09 KB
#include "fg.h"

#include "core/logger.h"
#include "fg_utils.h"
#include <array>

// each recorder declares rhi::ResourceState, each backend lowers to
// native stages/access/layouts. ideally compatible states ought to  merge (no barrier),
// currently only readonly stuff does
// all writes in the pair force a transition, Idle is the fresh-resource state (not sure if Idle will be ketp)

static bool state_is_read_only(rhi::ResourceState s) {
    using S = rhi::ResourceState;
    switch (s) {
    case S::Idle:
    case S::ColorFetch:
    case S::DepthFetch:
    case S::TextureSample:
    case S::TextureSampleNonFragment:
    case S::StorageRead:
    case S::VertexFetch:
    case S::IndexFetch:
    case S::IndirectFetch:
    case S::UniformRead:
    case S::TransferFrom:
    case S::HostRead:
    case S::AccelTrace:
    case S::Display:
        return true;
    default:
        return false;
    }
}

static rhi::ImageUsage image_state_creation_usage(rhi::ResourceState s) {
    using S = rhi::ResourceState;
    switch (s) {
    case S::ColorDraw:
    case S::ColorFetch:
        return rhi::ImageUsage::ColorAttachment;
    case S::DepthDraw:
    case S::DepthFetch:
        return rhi::ImageUsage::DepthAttachment;
    case S::TextureSample:
    case S::TextureSampleNonFragment:
        return rhi::ImageUsage::Sampled;
    case S::StorageRead:
    case S::StorageReadWrite:
        return rhi::ImageUsage::Storage;
    case S::TransferFrom:
        return rhi::ImageUsage::TransferSrc;
    case S::TransferTo:
        return rhi::ImageUsage::TransferDst;
    case S::Display:
        return rhi::ImageUsage::TransferSrc;
    default:
        return rhi::ImageUsage::None;
    }
}

static rhi::BufferUsage buffer_state_creation_usage(rhi::ResourceState s) {
    using S = rhi::ResourceState;
    using U = rhi::BufferUsage;
    switch (s) {
    case S::StorageRead:
    case S::StorageReadWrite:
        return U::StorageBuffer;
    case S::UniformRead:
        return rhi::BufferUsage::UniformBuffer;
    case S::VertexFetch:
        return rhi::BufferUsage::VertexBuffer;
    case S::IndexFetch:
        return rhi::BufferUsage::IndexBuffer;
    case S::IndirectFetch:
        return rhi::BufferUsage::IndirectBuffer;
    case S::TransferFrom:
    case S::HostRead:
        return rhi::BufferUsage::CopySrc;
    case S::TransferTo:
        return rhi::BufferUsage::CopyDst;
    case S::AccelBuild:
    case S::AccelTrace:
        return rhi::BufferUsage::AccelStructStorage;
    default:
        return rhi::BufferUsage::None;
    }
}

// same-state merge policy for a repeated handle in one pass: keep the
// writable state (conservative union), read-only pairs keep the latest
static rhi::ResourceState merge_image_state(rhi::ResourceState a, rhi::ResourceState b) {
    if (a == b) {
        return a;
    }
    const bool wa = !state_is_read_only(a);
    const bool wb = !state_is_read_only(b);
    if (wa != wb) {
        return wa ? a : b;
    }
    return b;
}

static rhi::ImageAspect aspect_from_format(rhi::ImageFormat fmt) {
    switch (fmt) {
    case rhi::ImageFormat::D32_FLOAT:
        return rhi::ImageAspect::Depth;
    // TODO: stencil?
    default:
        return rhi::ImageAspect::Color;
    }
}

//==========================================================================================

static Operation merge_access(Operation a, Operation b) {
    if (a == Operation::ReadWrite || b == Operation::ReadWrite) {
        return Operation::ReadWrite;
    }
    if (a == Operation::Write || b == Operation::Write) {
        return Operation::Write;
    }
    return Operation::Read;
}

ImageHandle FrameGraph::add_image(ImageResource img) {
    assert(
        (img.lifetime != Lifetime::Imported) ||
        (img.external != nullptr) && "Imported resources must carry an external object (use import_image)"
    );
    u32 id = logical_images.size();
    logical_images.push_back(img);
    return {id};
}

BufferHandle FrameGraph::add_buffer(BufferResource buf) {
    assert(
        (buf.lifetime != Lifetime::Imported) ||
        (buf.external != nullptr) && "Imported resources must carry an external object (use import_buffer)"
    );
    u32 id = logical_buffers.size();
    logical_buffers.push_back(buf);
    return {id};
}

PassBuilder &FrameGraph::add_pass(const char *name) {
    passes.emplace_back();
    passes.back().name = name;
    passes.back().graph_ = this;
    return passes.back();
}

PassBuilder &
PassBuilder::add_image_ref(FrameGraph &g, ImageHandle img, rhi::ResourceState state, Operation op, i32 version_offset) {
    assert(img.is_valid() && img.id < g.logical_images.size() && "invalid image handle in PassBuilder::read");
    if (image_ref_count == 0) {
        image_ref_start = static_cast<u32>(g.image_refs.size());
    }
    for (u32 i = image_ref_start; i < image_ref_start + image_ref_count; ++i) {
        ImageResourceRef &ref = g.image_refs[i];
        if (ref.img.id == img.id && ref.version_offset == version_offset) {
            ref.operation = merge_access(ref.operation, op);
            ref.state = merge_image_state(ref.state, state);
            return *this;
        }
    }
    g.image_refs.push_back({.img = img, .operation = op, .state = state, .version_offset = version_offset});
    image_ref_count++;
    return *this;
}

PassBuilder &PassBuilder::add_buffer_ref(
    FrameGraph &g, BufferHandle buf, rhi::ResourceState state, Operation op, i32 version_offset
) {
    assert(buf.is_valid() && buf.id < g.logical_buffers.size() && "invalid buffer handle in PassBuilder::read");
    if (buffer_ref_count == 0) {
        buffer_ref_start = static_cast<u32>(g.buffer_refs.size());
    }
    for (u32 i = buffer_ref_start; i < buffer_ref_start + buffer_ref_count; ++i) {
        BufferResourceRef &ref = g.buffer_refs[i];
        if (ref.buf.id == buf.id && ref.version_offset == version_offset) {
            ref.operation = merge_access(ref.operation, op);
            ref.state = merge_image_state(ref.state, state);
            return *this;
        }
    }
    g.buffer_refs.push_back({.buf = buf, .operation = op, .state = state, .version_offset = version_offset});
    buffer_ref_count++;
    return *this;
}

PassBuilder &PassBuilder::read(FrameGraph &g, ImageHandle img, rhi::ResourceState state) {
    return add_image_ref(g, img, state, Operation::Read, 0);
}
PassBuilder &PassBuilder::write(FrameGraph &g, ImageHandle image, rhi::ResourceState state) {
    return add_image_ref(g, image, state, Operation::Write, 0);
}
PassBuilder &PassBuilder::read_write(FrameGraph &g, ImageHandle image, rhi::ResourceState state) {
    return add_image_ref(g, image, state, Operation::ReadWrite, 0);
}
PassBuilder &PassBuilder::read(FrameGraph &g, BufferHandle buf, rhi::ResourceState state) {
    return add_buffer_ref(g, buf, state, Operation::Read, 0);
}
PassBuilder &PassBuilder::write(FrameGraph &g, BufferHandle buffer, rhi::ResourceState state) {
    return add_buffer_ref(g, buffer, state, Operation::Write, 0);
}
PassBuilder &PassBuilder::read_write(FrameGraph &g, BufferHandle buffer, rhi::ResourceState state) {
    return add_buffer_ref(g, buffer, state, Operation::ReadWrite, 0);
}

// ---------------------------------------------------------------------------
// Owner-aware forms: forward to the graph this pass was recorded on.

PassBuilder &PassBuilder::read(ImageHandle img, rhi::ResourceState state) {
    return read(*graph_, img, state);
}
PassBuilder &PassBuilder::write(ImageHandle image, rhi::ResourceState state) {
    return write(*graph_, image, state);
}
PassBuilder &PassBuilder::read_write(ImageHandle image, rhi::ResourceState state) {
    return read_write(*graph_, image, state);
}
PassBuilder &PassBuilder::read(BufferHandle buf, rhi::ResourceState state) {
    return read(*graph_, buf, state);
}
PassBuilder &PassBuilder::write(BufferHandle buffer, rhi::ResourceState state) {
    return write(*graph_, buffer, state);
}
PassBuilder &PassBuilder::read_write(BufferHandle buffer, rhi::ResourceState state) {
    return read_write(*graph_, buffer, state);
}

// ---------------------------------------------------------------------------
// History resource versioning forms.

PassBuilder &PassBuilder::write_current(FrameGraph &g, ImageHandle img, rhi::ResourceState state) {
    return add_image_ref(g, img, state, Operation::Write, 0);
}
PassBuilder &PassBuilder::read_previous(FrameGraph &g, ImageHandle img, rhi::ResourceState state) {
    return add_image_ref(g, img, state, Operation::Read, -1);
}
PassBuilder &PassBuilder::write_current(FrameGraph &g, BufferHandle buf, rhi::ResourceState state) {
    return add_buffer_ref(g, buf, state, Operation::Write, 0);
}
PassBuilder &PassBuilder::read_previous(FrameGraph &g, BufferHandle buf, rhi::ResourceState state) {
    return add_buffer_ref(g, buf, state, Operation::Read, -1);
}
PassBuilder &PassBuilder::write_current(ImageHandle img, rhi::ResourceState state) {
    return write_current(*graph_, img, state);
}
PassBuilder &PassBuilder::read_previous(ImageHandle img, rhi::ResourceState state) {
    return read_previous(*graph_, img, state);
}
PassBuilder &PassBuilder::write_current(BufferHandle buf, rhi::ResourceState state) {
    return write_current(*graph_, buf, state);
}
PassBuilder &PassBuilder::read_previous(BufferHandle buf, rhi::ResourceState state) {
    return read_previous(*graph_, buf, state);
}

void FrameGraph::init() {
    assert(
        physical.images.empty() && physical.buffers.empty() &&
        "call destroy() or reset() before init() to release GPU storage"
    );
    logical_images.clear();
    logical_buffers.clear();
    image_refs.clear();
    buffer_refs.clear();
    passes.clear();
    image_barriers.clear();
    buffer_barriers.clear();
    extra_views.clear();
    is_compiled = false;
}

static void destroy_transient_views(FrameGraph &g) {
    if (g.device == nullptr) {
        g.physical.transient_views.clear();
        return;
    }
    for (rhi::ImageView &v : g.physical.transient_views) {
        rhi::destroy_image_view(*g.device, v);
    }
    g.physical.transient_views.clear();
}

static u32 mint_transient_view(FrameGraph &g, rhi::Image *img, const rhi::ImageViewDesc &desc) {
    assert(g.device != nullptr);
    assert(img != nullptr);
    rhi::ImageViewDesc full = desc;
    full.image = img;
    rhi::ImageView v{};
    if (!rhi::create_image_view(*g.device, full, v)) {
        VEL_CRITICAL("FrameGraph::compile: failed transient view mint");
        return INVALID_VIEW_INDEX;
    }
    assert(v.slot <= UINT32_MAX && "bindless slot exceeds u32 shader index");
    g.physical.transient_views.push_back(std::move(v));
    return (u32)g.physical.transient_views.back().slot;
}

u32 FrameGraph::request_extra_view(ImageHandle img, bool all_mips) {
    u32 idx = (u32)extra_views.size();
    extra_views.push_back({img, all_mips, {}, 0});
    return idx;
}

rhi::ImageView PassContext::image_view(ImageHandle handle, const rhi::ImageViewDesc &desc) const {
    return rhi::get_cached_image_view(device, get_image(handle), desc);
}

u32 PassContext::view_extra(u32 extra_idx, i32 version_offset, u32 mip) const {
    assert(extra_views != nullptr && extra_idx < (u32)extra_views->size());
    const ExtraImageView &ev = (*extra_views)[extra_idx];
    u32 vc = storage.image_version_count[ev.img.id];
    i32 v = ((i32)frame_index + version_offset) % (i32)vc;
    if (v < 0) {
        v += (i32)vc;
    }
    if (ev.all_mips) {
        assert(mip < ev.mips_per_version);
        return ev.slots[(u32)v * ev.mips_per_version + mip];
    }
    assert(ev.slots.size() > (u32)v);
    return ev.slots[(u32)v];
}

static void release_image(FrameGraph &g, rhi::Image &img) {
    (void)g;
    rhi::destroy_image_views(*g.device, img);
}

static void release_buffer(FrameGraph &g, rhi::Buffer &buf) {
    (void)g;
    rhi::destroy_buffer_views(*g.device, buf);
}

static void destroy_physicals(FrameGraph &g, bool include_persistent) {
    if (g.device == nullptr) {
        return;
    }
    destroy_transient_views(g);
    for (u32 i = 0; i < g.logical_images.size(); ++i) {
        if (i >= g.physical.image_base.size()) {
            continue;
        }
        if (g.logical_images[i].lifetime == Lifetime::Imported) {
            continue;
        }
        if (!include_persistent && (g.logical_images[i].lifetime == Lifetime::Persistent ||
                                    g.logical_images[i].lifetime == Lifetime::History)) {
            continue;
        }
        const u32 vc = g.physical.image_version_count[i];
        for (u32 v = 0; v < vc; ++v) {
            rhi::Image &img = g.physical.owned_images[g.physical.image_base[i] + v];
            if (!rhi::valid(img)) {
                continue;
            }
            // views are cached on the image; release them BEFORE destroying it
            release_image(g, img);
            rhi::destroy_image(*g.device, img);
        }
    }
    for (u32 i = 0; i < g.logical_buffers.size(); ++i) {
        if (i >= g.physical.buffer_base.size()) {
            continue;
        }
        if (g.logical_buffers[i].lifetime == Lifetime::Imported) {
            continue;
        }
        if (!include_persistent && (g.logical_buffers[i].lifetime == Lifetime::Persistent ||
                                    g.logical_buffers[i].lifetime == Lifetime::History)) {
            continue;
        }
        const u32 vc = g.physical.buffer_version_count[i];
        for (u32 v = 0; v < vc; ++v) {
            rhi::Buffer &buf = g.physical.owned_buffers[g.physical.buffer_base[i] + v];
            if (!rhi::valid(buf)) {
                continue;
            }

            release_buffer(g, buf);
            rhi::destroy_buffer(*g.device, buf);
        }
    }
    if (include_persistent) {
        g.physical.owned_images.clear();
        g.physical.owned_buffers.clear();
        g.physical.image_base.clear();
        g.physical.image_version_count.clear();
        g.physical.buffer_base.clear();
        g.physical.buffer_version_count.clear();
    }
    g.physical.images.clear();
    g.physical.buffers.clear();
    g.physical.image_view_index.clear();
}

static rhi::Extent2D resolve_image_extent(ImageResource &img, u32 swap_width, u32 swap_height) {
    switch (img.size_class) {
    case SizeOp::Absolute: {
        return {img.desc.width, img.desc.height};
    }
    case SizeOp::Relative:
    case SizeOp::Swapchain: {
        u32 w = std::max(1u, static_cast<u32>(static_cast<f32>(swap_width) * img.scale.x));
        u32 h = std::max(1u, static_cast<u32>(static_cast<f32>(swap_height) * img.scale.y));
        return {w, h};
    }
    }
    return {img.desc.width, img.desc.height};
}

static u32 full_mip_chain_levels(u32 w, u32 h) {
    u32 levels = 1;
    while ((w > 1 || h > 1) && levels < 32) {
        w >>= 1;
        h >>= 1;
        ++levels;
    }
    return levels;
}

static rhi::ImageDesc resolve_image_desc(ImageResource &img, rhi::ImageUsage agg_usage, u32 w, u32 h, bool readback) {
    rhi::Extent2D extent = resolve_image_extent(img, w, h);
    rhi::ImageDesc desc = img.desc;
    desc.width = extent.width;
    desc.height = extent.height;
    desc.usage |= agg_usage;
    if (readback) {
        desc.usage |= rhi::ImageUsage::TransferSrc;
    }
    desc.mip_levels = desc.mip_levels == 0 ? full_mip_chain_levels(extent.width, extent.height) : desc.mip_levels;
    return desc;
}

static rhi::BufferDesc resolve_buffer_desc(BufferResource &buf, rhi::BufferUsage agg_usage, u32 w, u32 h) {
    rhi::BufferDesc desc = buf.desc;
    if (buf.size_fn) {
        desc.size = buf.size_fn(w, h);
    }
    desc.usage |= agg_usage;
    return desc;
}

static bool same_image_desc(const rhi::ImageDesc &a, const rhi::ImageDesc &b) {
    return a.width == b.width && a.height == b.height && a.depth == b.depth && a.mip_levels == b.mip_levels &&
           a.layers == b.layers && a.is_cubemap == b.is_cubemap && a.sample_count == b.sample_count &&
           a.format == b.format && a.usage == b.usage;
}

static bool same_buffer_desc(const rhi::BufferDesc &a, const rhi::BufferDesc &b) {
    return a.size == b.size && a.usage == b.usage && a.memory == b.memory && a.dedicated == b.dedicated;
}

bool FrameGraph::compile(rhi::Device &in_device, rhi::Extent2D swapchain_extent) {
    width = swapchain_extent.width;
    height = swapchain_extent.height;
    device = &in_device;

    // clear the previous baked plan up front so a failure below leaves a clean,
    // uncompiled state rather than stale barriers paired with new storage.
    is_compiled = false;
    pending_state_fixup = false;
    image_barriers.clear();
    buffer_barriers.clear();
    for (PassBuilder &p : passes) {
        p.image_barrier_count = 0;
        p.buffer_barrier_count = 0;
    }

    destroy_physicals(*this, false);

    // Rebuild flat-with-stride layout: base offsets + version strides.
    physical.image_base.assign(logical_images.size(), 0);
    physical.image_version_count.assign(logical_images.size(), 1);
    u32 img_total = 0;
    for (u32 i = 0; i < logical_images.size(); ++i) {
        physical.image_version_count[i] = get_version_count(logical_images[i].lifetime);
        physical.image_base[i] = img_total;
        img_total += physical.image_version_count[i];
    }
    physical.buffer_base.assign(logical_buffers.size(), 0);
    physical.buffer_version_count.assign(logical_buffers.size(), 1);
    u32 buf_total = 0;
    for (u32 i = 0; i < logical_buffers.size(); ++i) {
        physical.buffer_version_count[i] = get_version_count(logical_buffers[i].lifetime);
        physical.buffer_base[i] = buf_total;
        buf_total += physical.buffer_version_count[i];
    }

    if (physical.owned_images.size() < img_total) {
        physical.owned_images.resize(img_total);
    } else if (physical.owned_images.size() > img_total) {
        for (u32 i = img_total; i < physical.owned_images.size(); ++i) {
            rhi::Image &img = physical.owned_images[i];
            if (rhi::valid(img)) {
                release_image(*this, img);
                rhi::destroy_image(*device, img);
            }
        }
        physical.owned_images.resize(img_total);
    }
    if (physical.owned_buffers.size() < buf_total) {
        physical.owned_buffers.resize(buf_total);
    } else if (physical.owned_buffers.size() > buf_total) {
        for (u32 i = buf_total; i < physical.owned_buffers.size(); ++i) {
            rhi::Buffer &buf = physical.owned_buffers[i];
            if (rhi::valid(buf)) {
                release_buffer(*this, buf);
                rhi::destroy_buffer(*device, buf);
            }
        }
        physical.owned_buffers.resize(buf_total);
    }

    std::vector<rhi::ImageUsage> img_usage(logical_images.size(), rhi::ImageUsage::None);
    std::vector<rhi::BufferUsage> buffer_usage(logical_buffers.size(), rhi::BufferUsage::None);

    // aggregate resource usages across all passes
    for (const auto &curr_pass : passes) {
        for (u32 j = 0; j < curr_pass.image_ref_count; ++j) {
            const auto &r = image_refs[curr_pass.image_ref_start + j];
            if (logical_images[r.img.id].lifetime != Lifetime::Imported) {
                img_usage[r.img.id] |= image_state_creation_usage(r.state);
            }
        }
        for (u32 ri = 0; ri < curr_pass.buffer_ref_count; ++ri) {
            const auto &r = buffer_refs[curr_pass.buffer_ref_start + ri];
            if (logical_buffers[r.buf.id].lifetime != Lifetime::Imported) {
                buffer_usage[r.buf.id] |= buffer_state_creation_usage(r.state);
            }
        }
    }

    // alloc Physical Images (imports borrow survivors are reused)
    physical.images.resize(img_total);
    physical.image_view_index.assign(img_total, INVALID_VIEW_INDEX);
    for (u32 i = 0; i < logical_images.size(); ++i) {
        ImageResource &img_res = logical_images[i];
        const u32 base = physical.image_base[i];
        if (img_res.lifetime == Lifetime::Imported) {
            if (img_res.external == nullptr) {
                VEL_CRITICAL("FrameGraph::compile: imported image {} has no external image; aborting compile", i);
                return false;
            }
            physical.images[base] = img_res.external;
            continue;
        }

        const u32 vc = physical.image_version_count[i];
        for (u32 v = 0; v < vc; ++v) {
            rhi::Image &img = physical.owned_images[base + v];
            if (rhi::valid(img)) {
                // persistent/history survivor: reuse if its descriptor still
                // matches the resolved target, otherwise recreate (e.g. resize)
                rhi::ImageDesc want = resolve_image_desc(img_res, img_usage[i], width, height, image_readback);
                if (same_image_desc(img.desc, want)) {
                    physical.images[base + v] = &img;
                    continue;
                }
                release_image(*this, img);
                rhi::destroy_image(*device, img);
            }

            rhi::ImageDesc desc = resolve_image_desc(img_res, img_usage[i], width, height, image_readback);
            if (!img_res.name.empty()) {
                desc.name = img_res.name;
                if (vc > 1) {
                    desc.name += "#v" + std::to_string(v);
                }
            }
            if (!rhi::create_image(*device, desc, img)) {
                VEL_CRITICAL("FrameGraph::compile: failed physical image allocation at index {} v{}", i, v);
                physical.images[base + v] = nullptr;
                continue;
            }
            physical.images[base + v] = &img;
        }
    }

    for (u32 i = 0; i < logical_images.size(); ++i) {
        const ImageResource &img_res = logical_images[i];
        if (img_res.view.type == rhi::ImageViewType::None) {
            continue;
        }
        const u32 base = physical.image_base[i];
        const u32 vc = physical.image_version_count[i];
        for (u32 v = 0; v < vc; ++v) {
            rhi::Image *img = physical.images[base + v];
            if (img == nullptr) {
                continue;
            }
            rhi::ImageViewDesc desc = img_res.view;
            if (desc.mip_count == 0) {
                desc.mip_count = img->desc.mip_levels;
            }
            physical.image_view_index[base + v] = mint_transient_view(*this, img, desc);
        }
    }
    for (ExtraImageView &ev : extra_views) {
        const u32 base = physical.image_base[ev.img.id];
        const u32 vc = physical.image_version_count[ev.img.id];
        ev.slots.clear();
        ev.mips_per_version = 0;
        for (u32 v = 0; v < vc; ++v) {
            rhi::Image *img = physical.images[base + v];
            if (img == nullptr) {
                continue;
            }
            if (ev.all_mips) {
                u32 mips = img->desc.mip_levels;
                if (ev.mips_per_version == 0) {
                    ev.mips_per_version = mips;
                    ev.slots.resize(vc * mips, INVALID_VIEW_INDEX);
                }
                for (u32 mip = 0; mip < mips && mip < ev.mips_per_version; ++mip) {
                    rhi::ImageViewDesc desc{};
                    desc.aspect = rhi::ImageAspect::Color;
                    desc.dimension = rhi::TextureViewDimension::TEXTURE_2D;
                    desc.type = rhi::ImageViewType::Storage;
                    desc.mip_start = mip;
                    desc.mip_count = 1;
                    ev.slots[v * ev.mips_per_version + mip] = mint_transient_view(*this, img, desc);
                }
            } else {
                if (ev.slots.empty()) {
                    ev.slots.assign(vc, INVALID_VIEW_INDEX);
                }
                rhi::ImageViewDesc desc{};
                desc.type = rhi::ImageViewType::Storage;
                ev.slots[v] = mint_transient_view(*this, img, desc);
            }
        }
    }

    // 3. Allocate Physical Buffers (imports borrow; survivors are reused)
    physical.buffers.resize(buf_total);
    for (u32 i = 0; i < logical_buffers.size(); ++i) {
        BufferResource &buf_res = logical_buffers[i];
        const u32 base = physical.buffer_base[i];
        if (buf_res.lifetime == Lifetime::Imported) {
            if (buf_res.external == nullptr) {
                VEL_CRITICAL("FrameGraph::compile: imported buffer {} has no external buffer; aborting compile", i);
                return false;
            }
            physical.buffers[base] = buf_res.external;
            continue;
        }

        const u32 vc = physical.buffer_version_count[i];
        for (u32 v = 0; v < vc; ++v) {
            rhi::Buffer &buf = physical.owned_buffers[base + v];
            if (rhi::valid(buf)) {
                // persistent/history survivor: reuse if its descriptor still
                // matches the resolved target, otherwise recreate (e.g. resize)
                rhi::BufferDesc want = resolve_buffer_desc(buf_res, buffer_usage[i], width, height);
                if (same_buffer_desc(buf.desc, want)) {
                    physical.buffers[base + v] = &buf;
                    continue;
                }
                release_buffer(*this, buf);
                rhi::destroy_buffer(*device, buf);
            }

            rhi::BufferDesc desc = resolve_buffer_desc(buf_res, buffer_usage[i], width, height);
            if (!buf_res.name.empty()) {
                desc.name = buf_res.name;
                if (vc > 1) {
                    desc.name += "#v" + std::to_string(v);
                }
            }
            if (!rhi::create_buffer(*device, desc, buf)) {
                VEL_CRITICAL("FrameGraph::compile: failed physical buffer allocation at index {} v{}", i, v);
                physical.buffers[base + v] = nullptr;
                continue;
            }
            physical.buffers[base + v] = &buf;
        }
    }

    image_barriers.clear();
    buffer_barriers.clear();

    auto slot_index = [](i32 version_offset) -> u32 { return (version_offset < 0) ? 1u : 0u; };

    std::vector<std::array<rhi::ResourceState, 2>> sim_img(logical_images.size());
    std::vector<std::array<rhi::ResourceState, 2>> sim_buf(logical_buffers.size());

    for (u32 i = 0; i < logical_images.size(); ++i) {
        if (logical_images[i].lifetime == Lifetime::Imported) {
            sim_img[i][0] = logical_images[i].entry_state;
        } else if (
            (logical_images[i].lifetime == Lifetime::Persistent || logical_images[i].lifetime == Lifetime::History) &&
            rhi::valid(physical.owned_images[physical.image_base[i]])
        ) {
            sim_img[i][0] = physical.owned_images[physical.image_base[i]].state;
            sim_img[i][1] = sim_img[i][0];
        } else {
            sim_img[i] = {};
        }
    }
    for (u32 i = 0; i < logical_buffers.size(); ++i) {
        if (logical_buffers[i].lifetime == Lifetime::Imported) {
            sim_buf[i][0] = logical_buffers[i].entry_state;
        } else if (
            (logical_buffers[i].lifetime == Lifetime::Persistent || logical_buffers[i].lifetime == Lifetime::History) &&
            rhi::valid(physical.owned_buffers[physical.buffer_base[i]])
        ) {
            sim_buf[i][0] = physical.owned_buffers[physical.buffer_base[i]].state;
            sim_buf[i][1] = sim_buf[i][0];
        } else {
            sim_buf[i] = {};
        }
    }

    for (u32 lap = 0; lap < 2; ++lap) {
        const bool bake = (lap == 1);
        for (u32 pi = 0; pi < passes.size(); ++pi) {
            PassBuilder &pass = passes[pi];
            if (bake) {
                pass.image_barrier_start = static_cast<u32>(image_barriers.size());
                pass.buffer_barrier_start = static_cast<u32>(buffer_barriers.size());
            }

            for (u32 ri = 0; ri < pass.image_ref_count; ++ri) {
                const ImageResourceRef &ref = image_refs[pass.image_ref_start + ri];
                const u32 id = ref.img.id;
                const u32 slot = slot_index(ref.version_offset);

                rhi::ImageAspect aspect = rhi::ImageAspect::Color;
                rhi::Image *phys0 = physical.image(id, 0);
                if (phys0) {
                    aspect = aspect_from_format(phys0->desc.format);
                }

                rhi::ResourceState tgt = ref.state;
                rhi::ResourceState &cur = sim_img[id][slot];

                const bool read_after_read = state_is_read_only(tgt) && state_is_read_only(cur) && cur == tgt;
                if (!read_after_read) {
                    // write-after-anything, anything-after-write, or a state change
                    // Idle cur == fresh entry -> legal discard transition
                    if (bake) {
                        image_barriers.push_back({
                            .image_id = id,
                            .version_offset = ref.version_offset,
                            .before = cur,
                            .after = tgt,
                            .aspect = aspect,
                        });
                    }
                    cur = tgt;
                } else {
                    // pure same-state read-after-read: no barrier
                    cur = tgt;
                }
            }

            for (u32 ri = 0; ri < pass.buffer_ref_count; ++ri) {
                const BufferResourceRef &ref = buffer_refs[pass.buffer_ref_start + ri];
                const u32 id = ref.buf.id;
                const u32 slot = slot_index(ref.version_offset);

                rhi::ResourceState tgt = ref.state;
                rhi::ResourceState &cur = sim_buf[id][slot];

                const bool read_after_read = state_is_read_only(tgt) && state_is_read_only(cur) && cur == tgt;
                if (!read_after_read) {
                    if (bake) {
                        buffer_barriers.push_back({
                            .buffer_id = id,
                            .version_offset = ref.version_offset,
                            .before = cur,
                            .after = tgt,
                        });
                    }
                    cur = tgt;
                } else {
                    cur = tgt;
                }
            }

            if (bake) {
                pass.image_barrier_count = static_cast<u32>(image_barriers.size()) - pass.image_barrier_start;
                pass.buffer_barrier_count = static_cast<u32>(buffer_barriers.size()) - pass.buffer_barrier_start;
            }
        }
    }

    // The first execute() after a compile patches each physical slot's first
    // baked barrier with its live state (fresh creates enter Idle, survivors
    // may differ from steady state). Thereafter it replays the pure baked plan
    pending_state_fixup = true;
    touch_img.assign(img_total, 0);
    touch_buf.assign(buf_total, 0);

    is_compiled = true;
    return true;
}

void FrameGraph::execute(rhi::CmdBuffer &cmd, const GraphExecInfo &info) {
    if (!is_compiled) {
        VEL_ERROR("FrameGraph::execute: graph is not compiled; ignoring execute");
        return;
    }

    // on the first execute after a compile, patch each physical slot's first
    // baked barrier with its live state. See pending_state_fixup in compile()
    const bool fixup = pending_state_fixup;
    pending_state_fixup = false;
    if (fixup) {
        touch_img.assign(touch_img.size(), 0);
        touch_buf.assign(touch_buf.size(), 0);
    }

    if (timings) {
        timings->reset_frame(cmd, info.frame_index);
    }

    for (u32 pass_index = 0; pass_index < passes.size(); ++pass_index) {
        PassBuilder &pass = passes[pass_index];

        for (u32 i = 0; i < pass.image_barrier_count; ++i) {
            const ImageBarrierBaked &b = image_barriers[pass.image_barrier_start + i];
            const u32 vc = physical.image_version_count[b.image_id];
            i32 v = ((i32)info.frame_index + b.version_offset) % (i32)vc;
            if (v < 0) {
                v += (i32)vc;
            }
            rhi::Image &img = *physical.image(b.image_id, (u32)v);
            const bool always_live = (vc > 1) || logical_images[b.image_id].lifetime == Lifetime::Imported;
            bool first_touch = false;
            if (fixup && !always_live) {
                const u32 slot = physical.image_base[b.image_id] + (u32)v;
                if (slot < touch_img.size() && !touch_img[slot]) {
                    touch_img[slot] = 1;
                    first_touch = true;
                }
            }
            const rhi::ResourceState before = (always_live || first_touch) ? img.state : b.before;
            rhi::ImageRange range{};
            range.aspect = b.aspect;
            rhi::barrier(cmd, img, range, before, b.after);
        }

        for (u32 i = 0; i < pass.buffer_barrier_count; ++i) {
            const BufferBarrierBaked &b = buffer_barriers[pass.buffer_barrier_start + i];
            const u32 vc = physical.buffer_version_count[b.buffer_id];
            i32 v = ((i32)info.frame_index + b.version_offset) % (i32)vc;
            if (v < 0) {
                v += (i32)vc;
            }
            rhi::Buffer &buf = *physical.buffer(b.buffer_id, (u32)v);
            const bool always_live = (vc > 1) || logical_buffers[b.buffer_id].lifetime == Lifetime::Imported;
            bool first_touch = false;
            if (fixup && !always_live) {
                const u32 slot = physical.buffer_base[b.buffer_id] + (u32)v;
                if (slot < touch_buf.size() && !touch_buf[slot]) {
                    touch_buf[slot] = 1;
                    first_touch = true;
                }
            }
            rhi::barrier(cmd, buf, (always_live || first_touch) ? buf.state : b.before, b.after);
        }

        if (pass.execute_fn) {
            if (timings) {
                timings->begin_pass(cmd, pass_index, info.frame_index);
            }
            PassContext ctx{
                cmd,
                *device,
                physical,
                &extra_views,
                info.frame_data,
                info.render_data,
                info.scene,
                info.frame_index,
                info.frame_count,
                width,
                height,
            };
            pass.execute_fn(ctx);
            if (timings) {
                timings->end_pass(cmd, pass_index, info.frame_index);
            }
        }
    }
}

void FrameGraph::destroy() {
    destroy_physicals(*this, true);
    init();
}

void FrameGraph::reset() {
    destroy_physicals(*this, true);
    init();
}

ImageHandle FrameGraph::import_image(rhi::Image *img, rhi::ResourceState entry_state, const rhi::ImageViewDesc &view) {
    assert(img && "import_image requires a valid external image");
    ImageResource res;
    res.desc = img->desc;
    res.lifetime = Lifetime::Imported;
    res.size_class = SizeOp::Absolute;
    res.external = img;
    res.entry_state = entry_state;
    res.view = view;
    return add_image(res);
}

BufferHandle FrameGraph::import_buffer(rhi::Buffer *buf, rhi::ResourceState entry_state) {
    assert(buf && "import_buffer requires a valid external buffer");
    BufferResource res;
    res.desc = buf->desc;
    res.lifetime = Lifetime::Imported;
    res.size_class = SizeOp::Absolute;
    res.external = buf;
    res.entry_state = entry_state;
    return add_buffer(res);
}

void FrameGraph::overwrite_imported_image(ImageHandle h, rhi::Image *img) {
    assert(h.is_valid() && h.id < logical_images.size() && "invalid imported image handle");
    assert(logical_images[h.id].lifetime == Lifetime::Imported && "only imported images can be overwritten");
    assert(img != nullptr && "overwrite_imported_image requires a valid image");
    logical_images[h.id].external = img;
    u32 base = physical.image_base[h.id];
    if (base < physical.images.size()) {
        physical.images[base] = img;
    }
}