texture_viewer.cpp

cross platform rendering playground

src/editor/texture_viewer.cpp

21.97 KB
#ifdef ENABLE_EDITOR
#define IMGUI_DEFINE_MATH_OPERATORS
#include "editor.h"

#include <imgui.h>
#include <imgui_internal.h>

#include <cmath>
#include <cstdio>
#include <cstring>
#include <vector>

#include "backend/imgui_backend.h"
#include "quick_submit.h"

namespace editor {

enum class Channel : u8 {
    RGB,
    R,
    G,
    B,
    A,
};

constexpr const char *CHANNEL_NAMES[] = {"RGB", "R", "G", "B", "A"};

struct InspectorState {
    ImGuiID id;
    const rhi::Image *image = nullptr;
    f32 scale = 1.0f;
    u64 desc = UINT64_MAX;
    bool y_flip = false;
    u32 mip_level = 0;
    u32 array_layer = 0;

    rhi::ImageView custom_view;
    bool owns_view = false;
    const rhi::Image *view_image = nullptr;
    u32 view_mip = 0;
    u32 view_layer = 0;
    Channel view_channel = Channel::RGB;

    Channel channel = Channel::RGB;
    bool show_checker = false;
    bool show_grid = false;
    bool reset_scroll = false;

    // Hover readback cache
    const rhi::Image *read_image = nullptr;
    i64 read_tx = -1;
    i64 read_ty = -1;
    u32 read_mip = 0;
    u32 read_layer = 0;
    f32 read_uv_x = 0.0f;
    f32 read_uv_y = 0.0f;
    bool read_valid = false;
    char hover_text[256];
};

std::vector<InspectorState> INSPECTORS;

InspectorState *find_or_create(ImGuiID id) {
    for (auto &insp : INSPECTORS) {
        if (insp.id == id) {
            return &insp;
        }
    }
    INSPECTORS.push_back({});
    INSPECTORS.back().id = id;
    return &INSPECTORS.back();
}

struct ReadbackScratch {
    rhi::Device *device = nullptr;
    rhi::Buffer stage{};
};

ReadbackScratch RB;

void ensure_scratch(rhi::Device &device) {
    if (RB.device == &device) {
        return;
    }
    RB.device = &device;

    rhi::BufferDesc bd{};
    bd.size = 256;
    bd.usage = rhi::BufferUsage::CopyDst;
    bd.memory = rhi::BufferMemType::Readback;
    bd.name = "rb/stage";
    rhi::create_buffer(device, bd, RB.stage);
}

void destroy_scratch(rhi::Device &device) {
    if (RB.device == nullptr) {
        return;
    }
    rhi::destroy_buffer(device, RB.stage);
    RB = {};
}

rhi::Image CHECKER_IMG;
rhi::ImageView CHECKER_VIEW;
u64 CHECKER_DESC = UINT64_MAX;

bool create_checkerboard(rhi::Device &device, QuickSubmit &qs, const rhi::Sampler *sampler) {
    if (CHECKER_DESC != UINT64_MAX || sampler == nullptr) {
        return CHECKER_DESC != UINT64_MAX;
    }
    ensure_scratch(device);

    constexpr u32 checker_size = 16;
    constexpr u32 checker_cell = 8;
    std::vector<u8> px(checker_size * checker_size * 4);
    for (u32 y = 0; y < checker_size; ++y) {
        for (u32 x = 0; x < checker_size; ++x) {
            bool light = (((x / checker_cell) + (y / checker_cell)) & 1) == 0;
            u8 c = light ? 130 : 85;
            u32 i = (y * checker_size + x) * 4;
            px[i + 0] = c;
            px[i + 1] = c;
            px[i + 2] = c;
            px[i + 3] = 255;
        }
    }

    rhi::ImageDesc img_desc{};
    img_desc.width = checker_size;
    img_desc.height = checker_size;
    img_desc.format = rhi::ImageFormat::RGBA8_UNORM;
    img_desc.usage = rhi::ImageUsage::Sampled | rhi::ImageUsage::TransferDst;
    img_desc.name = "tex/checker";
    if (!rhi::create_image(device, img_desc, CHECKER_IMG)) {
        return false;
    }
    rhi::ImageViewDesc view_desc{};
    view_desc.image = &CHECKER_IMG;
    if (!rhi::create_image_view(device, view_desc, CHECKER_VIEW)) {
        rhi::destroy_image(device, CHECKER_IMG);
        return false;
    }

    rhi::BufferDesc bd{};
    bd.size = px.size();
    bd.usage = rhi::BufferUsage::CopySrc;
    bd.memory = rhi::BufferMemType::Upload;
    bd.name = "rb/checker-stage";
    rhi::Buffer stage{};
    if (!rhi::create_buffer(device, bd, stage)) {
        rhi::destroy_image_view(device, CHECKER_VIEW);
        rhi::destroy_image(device, CHECKER_IMG);
        return false;
    }
    std::memcpy(stage.mapped, px.data(), px.size());
    rhi::buffer_flush(device, stage, 0, px.size());

    QsCmd *qcmd = qs_acquire(qs);
    {
        rhi::ImageRange range{};
        rhi::barrier(qcmd->cmd, CHECKER_IMG, range, CHECKER_IMG.state, rhi::ResourceState::TransferTo);
    }
    rhi::BufferTextureCopyRegion region{};
    region.buffer_offset = 0;
    region.texture_extent_width = checker_size;
    region.texture_extent_height = checker_size;
    region.texture_extent_depth = 1;
    region.base_array_layer = 0;
    region.mip_level = 0;
    region.layer_count = 1;
    rhi::copy_buffer_to_texture(qcmd->cmd, stage, CHECKER_VIEW, region);
    {
        rhi::ImageRange range{};
        rhi::barrier(qcmd->cmd, CHECKER_IMG, range, rhi::ResourceState::TransferTo, rhi::ResourceState::TextureSample);
    }

    qs_submit_and_wait(qs, *qcmd);

    rhi::destroy_buffer(device, stage);
    CHECKER_DESC = imgui_backend::texture(CHECKER_VIEW);
    return CHECKER_DESC != UINT64_MAX;
}

void destroy_checkerboard(rhi::Device &device) {
    CHECKER_DESC = UINT64_MAX;
    if (rhi::valid(CHECKER_VIEW)) {
        rhi::destroy_image_view(device, CHECKER_VIEW);
    }
    if (rhi::valid(CHECKER_IMG)) {
        rhi::destroy_image(device, CHECKER_IMG);
    }
}

bool format_readback_supported(rhi::ImageFormat fmt) {
    switch (fmt) {
    case rhi::ImageFormat::R8_UNORM:
    case rhi::ImageFormat::RGBA8_UNORM:
    case rhi::ImageFormat::RGBA8_SRGB:
    case rhi::ImageFormat::BGRA8_UNORM:
    case rhi::ImageFormat::R10G10B10A2_UNORM:
    case rhi::ImageFormat::RGBA16_FLOAT:
    case rhi::ImageFormat::RG16_FLOAT:
    case rhi::ImageFormat::R16_FLOAT:
    case rhi::ImageFormat::R32_FLOAT:
    case rhi::ImageFormat::R32_UINT:
    case rhi::ImageFormat::RGBA32_FLOAT:
    case rhi::ImageFormat::D32_FLOAT:
        return true;
    default:
        return false;
    }
}

static f32 half_to_float(u16 h) {
    u32 sign = (u32)((h >> 15) & 1);
    u32 exp = (u32)((h >> 10) & 0x1f);
    u32 man = (u32)(h & 0x3ff);
    u32 f;
    if (exp == 0) {
        if (man == 0) {
            f = sign << 31;
        } else {
            exp = 127 - 15 + 1;
            while ((man & 0x400) == 0) {
                man <<= 1;
                exp--;
            }
            man &= 0x3ff;
            f = (sign << 31) | (exp << 23) | (man << 13);
        }
    } else if (exp == 31) {
        f = (sign << 31) | 0x7f800000u | (man << 13);
    } else {
        f = (sign << 31) | ((exp - 15 + 127) << 23) | (man << 13);
    }
    f32 out;
    std::memcpy(&out, &f, sizeof(f32));
    return out;
}

void decode_texel(rhi::ImageFormat fmt, const void *data, char *out, usize out_size) {
    switch (fmt) {
    case rhi::ImageFormat::R8_UNORM: {
        u8 v = ((const u8 *)data)[0];
        (void)std::snprintf(out, out_size, "R: %u (%.3f)", v, v / 255.0f);
        break;
    }
    case rhi::ImageFormat::RGBA8_UNORM:
    case rhi::ImageFormat::RGBA8_SRGB: {
        const u8 *p = (const u8 *)data;
        (void)std::snprintf(out, out_size, "R: %u  G: %u  B: %u  A: %u", p[0], p[1], p[2], p[3]);
        break;
    }
    case rhi::ImageFormat::BGRA8_UNORM: {
        const u8 *p = (const u8 *)data;
        (void)std::snprintf(out, out_size, "B: %u  G: %u  R: %u  A: %u", p[0], p[1], p[2], p[3]);
        break;
    }
    case rhi::ImageFormat::R10G10B10A2_UNORM: {
        u32 p = *(const u32 *)data;
        (void)std::snprintf(
            out,
            out_size,
            "R: %.3f  G: %.3f  B: %.3f  A: %.3f",
            (f32)(p & 0x3ff) / 1023.0f,
            (f32)((p >> 10) & 0x3ff) / 1023.0f,
            (f32)((p >> 20) & 0x3ff) / 1023.0f,
            (f32)((p >> 30) & 0x3) / 3.0f
        );
        break;
    }
    case rhi::ImageFormat::RGBA16_FLOAT: {
        const u16 *p = (const u16 *)data;
        (void)std::snprintf(
            out,
            out_size,
            "R: %.4g  G: %.4g  B: %.4g  A: %.4g",
            half_to_float(p[0]),
            half_to_float(p[1]),
            half_to_float(p[2]),
            half_to_float(p[3])
        );
        break;
    }
    case rhi::ImageFormat::RG16_FLOAT: {
        const u16 *p = (const u16 *)data;
        (void)std::snprintf(out, out_size, "R: %.4g  G: %.4g", half_to_float(p[0]), half_to_float(p[1]));
        break;
    }
    case rhi::ImageFormat::R16_FLOAT: {
        (void)std::snprintf(out, out_size, "R: %.4g", half_to_float(*(const u16 *)data));
        break;
    }
    case rhi::ImageFormat::R32_FLOAT:
    case rhi::ImageFormat::D32_FLOAT: {
        (void)std::snprintf(out, out_size, "V: %.6g", *(const f32 *)data);
        break;
    }
    case rhi::ImageFormat::R32_UINT: {
        (void)std::snprintf(out, out_size, "ID: %u", *(const u32 *)data);
        break;
    }
    case rhi::ImageFormat::RGBA32_FLOAT: {
        const f32 *p = (const f32 *)data;
        (void)std::snprintf(out, out_size, "R: %.4g  G: %.4g  B: %.4g  A: %.4g", p[0], p[1], p[2], p[3]);
        break;
    }
    default:
        (void)std::snprintf(out, out_size, "readback unsupported");
        break;
    }
}

bool readback_texel_raw(
    rhi::Device &device,
    QuickSubmit &qs,
    rhi::ImageView &view,
    u32 mip,
    u32 layer,
    i64 tx,
    i64 ty,
    void *out,
    usize out_size
) {
    if (out == nullptr || out_size == 0) {
        return false;
    }
    rhi::Image &img = *view.desc.image;
    // multisampling, cannot be copied directly
    if (img.desc.sample_count != rhi::SampleCount::Sample1) {
        return false;
    }

    ensure_scratch(device);
    rhi::ResourceState saved = img.state;
    rhi::ImageRange vr{};
    vr.mip = view.desc.mip_start;
    vr.mip_count = view.desc.mip_count;
    vr.layer = view.desc.layer_start;
    vr.layer_count = view.desc.layer_count;
    vr.aspect = view.desc.aspect;

    QsCmd *qcmd = qs_acquire(qs);

    rhi::barrier(qcmd->cmd, img, vr, saved, rhi::ResourceState::TransferFrom);

    rhi::BufferTextureCopyRegion region{};
    region.buffer_offset = 0;
    region.mip_level = mip;
    region.base_array_layer = layer;
    region.layer_count = 1;
    region.texture_offset_x = (u32)tx;
    region.texture_offset_y = (u32)ty;
    region.texture_offset_z = 0;
    region.texture_extent_width = 1;
    region.texture_extent_height = 1;
    region.texture_extent_depth = 1;
    rhi::copy_image_to_buffer(qcmd->cmd, view, RB.stage, region);

    // restore the subrange to its exact prior state so the framegraph's
    // tracked state stays consistent with the GPU. img.state was untouched
    // (sub-range barriers never write it), so this is TransferFrom -> saved.
    rhi::barrier(qcmd->cmd, img, vr, rhi::ResourceState::TransferFrom, saved);

    qs_submit_and_wait(qs, *qcmd);

    if (RB.stage.mapped == nullptr) {
        return false;
    }
    rhi::buffer_invalidate(device, RB.stage, 0, out_size);
    std::memcpy(out, RB.stage.mapped, out_size);
    return true;
}

bool readback_texel(
    rhi::Device &device,
    QuickSubmit &qs,
    rhi::ImageView &view,
    u32 mip,
    u32 layer,
    i64 tx,
    i64 ty,
    char *out,
    usize out_size
) {
    if (!format_readback_supported(view.desc.image->desc.format)) {
        (void)std::snprintf(out, out_size, "readback unsupported for this format");
        return false;
    }
    u8 raw[16];
    if (!readback_texel_raw(device, qs, view, mip, layer, tx, ty, raw, sizeof(raw))) {
        (void)std::snprintf(out, out_size, "readback failed");
        return false;
    }
    decode_texel(view.desc.image->desc.format, raw, out, out_size);
    return true;
}

void build_swizzle(
    Channel channel,
    rhi::ImageFormat fmt,
    rhi::ComponentSwizzle &sr,
    rhi::ComponentSwizzle &sg,
    rhi::ComponentSwizzle &sb,
    rhi::ComponentSwizzle &sa
) {
    sr = rhi::ComponentSwizzle::Identity;
    sg = rhi::ComponentSwizzle::Identity;
    sb = rhi::ComponentSwizzle::Identity;
    sa = rhi::ComponentSwizzle::Identity;

    if (fmt == rhi::ImageFormat::D32_FLOAT) {
        // depth views restrict swizzle: G/B/A may only be R/IDENTITY/ZERO.
        // Grayscale display: depth -> RGB, alpha stays Identity (sampled as 1).
        if (channel == Channel::R) {
            sr = sg = sb = rhi::ComponentSwizzle::R;
            sa = rhi::ComponentSwizzle::Identity;
        }
        return;
    }

    switch (channel) {
    case Channel::R:
        sr = sg = sb = rhi::ComponentSwizzle::R;
        sa = rhi::ComponentSwizzle::One;
        break;
    case Channel::G:
        sr = sg = sb = rhi::ComponentSwizzle::G;
        sa = rhi::ComponentSwizzle::One;
        break;
    case Channel::B:
        sr = sg = sb = rhi::ComponentSwizzle::B;
        sa = rhi::ComponentSwizzle::One;
        break;
    case Channel::A:
        sr = sg = sb = rhi::ComponentSwizzle::A;
        sa = rhi::ComponentSwizzle::One;
        break;
    default:
        break;
    }
}

bool draw_texture_inspector(rhi::Device &device, QuickSubmit &qs, const char *id, const TextureViewerDesc &desc) {
    ImGuiID imgui_id = ImGui::GetID(id);
    const ImGuiIO &io = ImGui::GetIO();
    InspectorState *state = find_or_create(imgui_id);

    if (!desc.image || !rhi::valid(*desc.image)) {
        if (state->owns_view) {
            rhi::destroy_image_view(device, state->custom_view);
            state->owns_view = false;
        }
        ImGui::TextUnformatted("No texture");
        return false;
    }

    if (desc.image != state->image) {
        state->image = desc.image;
        state->scale = 1.0f;
        state->y_flip = false;
        state->mip_level = 0;
        state->array_layer = 0;
        state->channel = Channel::RGB;

        // Auto-fit the newly opened texture.
        f32 w = (f32)ImMax(desc.image->desc.width, 1u);
        f32 h = (f32)ImMax(desc.image->desc.height, 1u);
        ImVec2 a = ImGui::GetContentRegionAvail();
        a.y -= 60;
        state->scale = ImMax(ImMin(a.x / w, a.y / h), 0.0625f);
        state->read_valid = false;
    }

    // Depth sources display as grayscale; the channel selector is disabled.
    bool is_depth = desc.image->desc.format == rhi::ImageFormat::D32_FLOAT;
    if (is_depth) {
        state->channel = Channel::R;
    }

    u32 max_mip = (desc.image->desc.mip_levels > 1) ? desc.image->desc.mip_levels - 1 : 0;
    u32 max_layer = (desc.image->desc.layers > 1) ? desc.image->desc.layers - 1 : 0;
    if (state->mip_level > max_mip) {
        state->mip_level = max_mip;
    }
    if (state->array_layer > max_layer) {
        state->array_layer = max_layer;
    }

    // Rebuild the owned view only when its parameters actually change.
    const bool want_custom =
        is_depth || state->mip_level > 0 || state->array_layer > 0 || state->channel != Channel::RGB;
    const bool view_changed = state->view_image != desc.image || state->view_mip != state->mip_level ||
                              state->view_layer != state->array_layer || state->view_channel != state->channel;
    if (state->owns_view && view_changed) {
        rhi::destroy_image_view(device, state->custom_view);
        state->owns_view = false;
    }

    if (!state->owns_view) {
        rhi::ImageAspect aspect =
            desc.image->desc.format == rhi::ImageFormat::D32_FLOAT ? rhi::ImageAspect::Depth : rhi::ImageAspect::Color;
        rhi::ImageViewDesc vd{};
        vd.image = desc.image;
        vd.aspect = aspect;
        vd.dimension = rhi::TextureViewDimension::TEXTURE_2D;
        vd.mip_start = want_custom ? state->mip_level : 0;
        vd.mip_count = 1;
        vd.layer_start = want_custom ? state->array_layer : 0;
        vd.layer_count = 1;
        if (want_custom) {
            build_swizzle(
                state->channel, desc.image->desc.format, vd.swizzle_r, vd.swizzle_g, vd.swizzle_b, vd.swizzle_a
            );
        }
        state->owns_view = rhi::create_image_view(device, vd, state->custom_view);
        if (!state->owns_view) {
            return false;
        }
    }
    rhi::ImageView &view = state->custom_view;
    state->view_image = desc.image;
    state->view_mip = state->mip_level;
    state->view_layer = state->array_layer;
    state->view_channel = state->channel;

    state->desc = imgui_backend::texture(view);
    if (state->desc == UINT64_MAX) {
        return false;
    }

    f32 &scale = state->scale;
    f32 tex_w = (f32)ImMax(desc.image->desc.width >> state->mip_level, 1u);
    f32 tex_h = (f32)ImMax(desc.image->desc.height >> state->mip_level, 1u);
    ImVec2 tex_size = ImVec2(tex_w, tex_h);

    if (desc.name) {
        ImGui::Text("%s", desc.name);
        if (desc.format_name) {
            ImGui::SameLine();
            ImGui::Text("  %s", desc.format_name);
        }
        ImGui::SameLine();
        ImGui::Text("  %ux%u", desc.image->desc.width, desc.image->desc.height);
    }

    // Row 1: view controls
    if (ImGui::Button("1:1")) {
        scale = 1.0f;
        state->reset_scroll = true;
    }
    ImGui::SameLine();
    if (ImGui::Button("Fit")) {
        ImVec2 a = ImGui::GetContentRegionAvail();
        a.y -= 60;
        scale = ImMax(ImMin(a.x / tex_size.x, a.y / tex_size.y), 0.0625f);
        state->reset_scroll = true;
    }
    ImGui::SameLine();
    ImGui::SetNextItemWidth(100);
    ImGui::SliderFloat("##zoom", &scale, 0.0625f, 64.0f, "%.2fx");
    if (max_mip > 0) {
        ImGui::SameLine();
        ImGui::SetNextItemWidth(80);
        int m = (int)state->mip_level;
        if (ImGui::SliderInt("##mip", &m, 0, (int)max_mip, "Mip %d")) {
            state->mip_level = (u32)m;
        }
    }
    if (max_layer > 0) {
        ImGui::SameLine();
        ImGui::SetNextItemWidth(80);
        int l = (int)state->array_layer;
        if (ImGui::SliderInt("##layer", &l, 0, (int)max_layer, "Lyr %d")) {
            state->array_layer = (u32)l;
        }
    }
    ImGui::SameLine();
    if (ImGui::Button(state->y_flip ? "Y-Flip ON" : "Y-Flip OFF")) {
        state->y_flip = !state->y_flip;
    }

    // Row 2: display controls
    if (is_depth) {
        ImGui::TextUnformatted("Depth");
    } else {
        ImGui::SetNextItemWidth(70);
        int ch = (int)state->channel;
        if (ImGui::Combo("##channel", &ch, CHANNEL_NAMES, IM_ARRAYSIZE(CHANNEL_NAMES))) {
            state->channel = (Channel)ch;
        }
    }
    ImGui::SameLine();
    ImGui::Checkbox("Checker", &state->show_checker);
    ImGui::SameLine();
    ImGui::Checkbox("Grid", &state->show_grid);

    ImGui::BeginChild("##img", ImVec2(0, 0), 0, ImGuiWindowFlags_HorizontalScrollbar);
    if (state->reset_scroll) {
        ImGui::SetScrollX(0.0f);
        ImGui::SetScrollY(0.0f);
        state->reset_scroll = false;
    }
    ImVec2 img_size = tex_size * scale;
    ImVec2 cursor_tl = ImGui::GetCursorScreenPos();
    ImVec2 uv0(0, state->y_flip ? 1 : 0), uv1(1, state->y_flip ? 0 : 1);

    ImDrawList *dl = ImGui::GetWindowDrawList();
    if (state->show_checker) {
        if (create_checkerboard(device, qs, desc.sampler)) {
            dl->AddImage((ImTextureID)(u64)CHECKER_DESC, cursor_tl, cursor_tl + img_size, uv0, uv1);
        }
    }

    ImGui::Image((ImTextureID)(u64)state->desc, img_size, uv0, uv1);
    bool hovered = ImGui::IsItemHovered();

    if (state->show_grid && scale > 4.0f) {
        f32 a = ImClamp((scale - 4.0f) / 20.0f, 0.0f, 1.0f);
        ImU32 col = IM_COL32(255, 255, 255, (int)(255.0f * (0.2f + 0.4f * a)));
        ImVec2 br = cursor_tl + img_size;
        for (int x = 1; x < (int)tex_w; ++x) {
            float sx = ImFloor(cursor_tl.x + x * scale) + 0.5f;
            dl->AddLine(ImVec2(sx, cursor_tl.y), ImVec2(sx, br.y), col);
        }
        for (int y = 1; y < (int)tex_h; ++y) {
            float sy = ImFloor(cursor_tl.y + y * scale) + 0.5f;
            dl->AddLine(ImVec2(cursor_tl.x, sy), ImVec2(br.x, sy), col);
        }
        dl->AddRect(cursor_tl, br, col);
    }

    if (hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
        ImVec2 a = ImGui::GetContentRegionAvail();
        a.y -= 60;
        scale = ImMax(ImMin(a.x / tex_size.x, a.y / tex_size.y), 0.0625f);
        state->reset_scroll = true;
    }
    if (hovered && io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_0)) {
        scale = 1.0f;
        state->reset_scroll = true;
    }

    if (hovered && io.MouseWheel != 0.0f) {
        f32 os = scale;
        f32 f = (io.MouseWheel > 0) ? 1.3f : (1 / 1.3f);
        f32 ns = ImClamp(os * f, 0.0625f, 64.0f);
        if (ns != os) {
            ImVec2 mu = (io.MousePos - cursor_tl) / (tex_size * os);
            scale = ns;
            ImVec2 nm = mu * tex_size * scale;
            ImGui::SetScrollX(ImGui::GetScrollX() + nm.x - (io.MousePos.x - cursor_tl.x));
            ImGui::SetScrollY(ImGui::GetScrollY() + nm.y - (io.MousePos.y - cursor_tl.y));
        }
    }
    if (hovered) {
        ImVec2 mf = (io.MousePos - cursor_tl) / img_size;
        f32 vf = state->y_flip ? 1.0f - mf.y : mf.y;
        i64 tx = (i64)ImClamp(ImFloor(mf.x * tex_w), 0.0f, tex_w - 1.0f);
        i64 ty = (i64)ImClamp(ImFloor(vf * tex_h), 0.0f, tex_h - 1.0f);

        state->read_uv_x = mf.x;
        state->read_uv_y = vf;
        state->read_valid = true;

        if (state->read_image != desc.image || state->read_tx != tx || state->read_ty != ty ||
            state->read_mip != state->mip_level || state->read_layer != state->array_layer) {
            state->read_image = desc.image;
            state->read_tx = tx;
            state->read_ty = ty;
            state->read_mip = state->mip_level;
            state->read_layer = state->array_layer;
            readback_texel(
                device,
                qs,
                view,
                state->mip_level,
                state->array_layer,
                tx,
                ty,
                state->hover_text,
                sizeof(state->hover_text)
            );
        }

        ImGui::BeginTooltip();
        ImGui::Text(
            "UV: (%.4f, %.4f)  Texel: (%lld, %lld)  Mip: %u  Layer: %u",
            mf.x,
            vf,
            tx,
            ty,
            state->mip_level,
            state->array_layer
        );
        ImGui::TextUnformatted(state->hover_text);
        ImGui::EndTooltip();
    }
    ImGui::EndChild();

    return true;
}

void shutdown_texture_inspectors(rhi::Device &device) {
    for (auto &insp : INSPECTORS) {
        if (insp.owns_view) {
            rhi::destroy_image_view(device, insp.custom_view);
        }
        insp.desc = UINT64_MAX;
    }
    INSPECTORS.clear();
    destroy_checkerboard(device);
    destroy_scratch(device);
}

} // namespace editor

#endif // ENABLE_EDITOR