mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-16 20:31:14 +00:00
Keep the texture picker from stalling on open (#16547)
# Description Opening the texture picker decoded every texture in the expanded sections on the GUI thread in a single frame, each one twice at full size. This stalled the UI on large libraries, and the full-size decodes stayed in memory afterwards. - Picker thumbnails are loaded lazily: only tiles inside the visible scroll area are considered, at most 2 uncached decodes per frame, and the rest fill in over the following frames. Deferred tiles draw as a placeholder. - Those follow-up frames are requested with `request_extra_frame()`. Marking the canvas dirty from inside the render did not schedule another idle pass, so thumbnails waited for the next mouse event. - The selected texture is still centred on open, even when it is off screen. The scroll reveal runs before the visibility check so that tile is loaded on the next frame. - Library validation no longer decodes pixels. The new `png::is_decodable_8bit_png()` reads only the PNG header and accepts 8-bit grey or RGB/RGBA, which is what `decode_png()` and `decode_colored_png()` take. - Picker thumbnails are decoded without entering the decode cache (`decode_height_texture(layer, keep_decoded = false)`). Only the 128 px thumbnail is kept per library entry; the full-size decode is made transiently and freed. Tradeoff: the header-only check does not read pixel data, so a PNG with a valid header but corrupt or truncated pixel data now passes validation and fails when the texture is loaded. The previous full decode caught that case. Each thumbnail decode still runs at full size on the GUI thread, one at a time. Peak memory per decode is unchanged; a streaming downscale would be needed to reduce it. # Screenshots/Recordings/Graphs Old: https://github.com/user-attachments/assets/03038532-396f-48c2-898c-aa06ef83e5f5 New: https://github.com/user-attachments/assets/49fdc31a-ce1b-470a-88a7-4c6319f03b81 ## Tests Not yet run. Suggested checks: - Open the texture picker with a library of many textures, and do not move the mouse: tiles should fill in over a few frames without a visible stall. - Open the picker with a layer whose texture is far down the list: it should be centred and loaded. - Import a valid 8-bit grey and an RGB PNG, plus an unsupported one (16-bit, palette, or grey-alpha): only the supported ones should be accepted as-is, and the rest converted on load. - Watch memory while scrolling through a large colour library: it should not grow by the full decoded size of every texture shown. <!-- > A guide for users on how to download the artifacts from this PR. --> [How to Download Pull Requests Artifacts for Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
This commit is contained in:
@@ -211,6 +211,35 @@ bool decode_colored_png(const ReadBuf &in_buf, ImageColorscale &out_img)
|
||||
return decode_colored_png(stream, out_img);
|
||||
}
|
||||
|
||||
bool is_decodable_8bit_png(const ReadBuf &pngbuf)
|
||||
{
|
||||
static const constexpr int PNG_SIG_BYTES = 8;
|
||||
|
||||
struct ReadBufStream stream{pngbuf};
|
||||
std::vector<png_byte> sig(PNG_SIG_BYTES, 0);
|
||||
stream.read(sig.data(), PNG_SIG_BYTES);
|
||||
if (!png_check_sig(sig.data(), PNG_SIG_BYTES))
|
||||
return false;
|
||||
|
||||
PNGDescr dsc;
|
||||
dsc.png = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
|
||||
if (!dsc.png)
|
||||
return false;
|
||||
dsc.info = png_create_info_struct(dsc.png);
|
||||
if (!dsc.info)
|
||||
return false;
|
||||
|
||||
if (!png_read_header_guarded(dsc.png, dsc.info, &stream, PNG_SIG_BYTES))
|
||||
return false;
|
||||
|
||||
const size_t color_type = png_get_color_type(dsc.png, dsc.info);
|
||||
const size_t bit_depth = png_get_bit_depth(dsc.png, dsc.info);
|
||||
const bool grey = color_type == PNG_COLOR_TYPE_GRAY;
|
||||
const bool colour = color_type == PNG_COLOR_TYPE_RGB || color_type == PNG_COLOR_TYPE_RGB_ALPHA;
|
||||
return png_get_image_width(dsc.png, dsc.info) > 0 && png_get_image_height(dsc.png, dsc.info) > 0 &&
|
||||
bit_depth == 8 && (grey || colour);
|
||||
}
|
||||
|
||||
|
||||
// Down to earth function to store a packed RGB image to file. Mostly useful for debugging purposes.
|
||||
// Based on https://www.lemoda.net/c/write-png/
|
||||
|
||||
@@ -50,6 +50,9 @@ struct ReadBuf { const void *buf = nullptr; const size_t sz = 0; };
|
||||
|
||||
bool is_png(const ReadBuf &pngbuf);
|
||||
|
||||
// True if decode_png() or decode_colored_png() would accept these bytes, judged from the header alone.
|
||||
bool is_decodable_8bit_png(const ReadBuf &pngbuf);
|
||||
|
||||
struct ReadBufStream: public IStream {
|
||||
const ReadBuf &rbuf_ref;
|
||||
size_t pos = 0;
|
||||
|
||||
@@ -283,7 +283,7 @@ bool height_texture_has_color(const TextureDisplacementLayer &layer)
|
||||
return decode_height_texture(layer).has_color();
|
||||
}
|
||||
|
||||
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer)
|
||||
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer, bool keep_decoded)
|
||||
{
|
||||
DecodedHeightTexture result;
|
||||
if (layer.empty())
|
||||
@@ -351,13 +351,15 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
|
||||
}
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_decoded_texture_cache.mutex);
|
||||
// Opportunistically drop entries for image_data that no longer exists anywhere, so the
|
||||
// cache doesn't grow without bound across many add/remove-texture cycles in a long session.
|
||||
auto &entries = g_decoded_texture_cache.entries;
|
||||
for (auto it = entries.begin(); it != entries.end();)
|
||||
it = it->second.first.expired() ? entries.erase(it) : std::next(it);
|
||||
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), result };
|
||||
if (keep_decoded) {
|
||||
std::lock_guard<std::mutex> lock(g_decoded_texture_cache.mutex);
|
||||
// Opportunistically drop entries for image_data that no longer exists anywhere, so the
|
||||
// cache doesn't grow without bound across many add/remove-texture cycles in a long session.
|
||||
auto &entries = g_decoded_texture_cache.entries;
|
||||
for (auto it = entries.begin(); it != entries.end();)
|
||||
it = it->second.first.expired() ? entries.erase(it) : std::next(it);
|
||||
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), result };
|
||||
}
|
||||
}
|
||||
|
||||
// The image-adjustment panel's live edits, on top of the decode and before the smoothing. Before,
|
||||
|
||||
@@ -599,8 +599,9 @@ DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, i
|
||||
// colour PNGs are accepted; for a colour source the height is its luminance, using the same
|
||||
// coefficients wxImage::ConvertToGreyscale() uses, so a texture imported as colour displaces exactly
|
||||
// as it did when the importer flattened it to grey on the way in. Returns an empty
|
||||
// DecodedHeightTexture if image_data is empty or is not a PNG at all.
|
||||
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer);
|
||||
// DecodedHeightTexture if image_data is empty or is not a PNG at all. keep_decoded = false leaves the
|
||||
// result out of the decode cache, for one-off reads such as a picker thumbnail that must not hold full-size pixels.
|
||||
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer, bool keep_decoded = true);
|
||||
|
||||
// decode_height_texture(layer).has_color(), answered from the decode cache rather than from a copy of the
|
||||
// texture - cheap enough to ask every frame. Smoothing does not change it, so the raw decode is what is read.
|
||||
|
||||
@@ -174,6 +174,10 @@ constexpr int HEIGHT_TEX_MAX_PX = 2048;
|
||||
// The full-size image is only ever adjusted once, on commit.
|
||||
constexpr int ADJUST_PREVIEW_MAX_PX = 384;
|
||||
|
||||
// Library textures the texture picker decodes per frame. Each decode runs on the GUI thread, so the
|
||||
// rest of the visible tiles fill in over the following frames instead of in one stall.
|
||||
constexpr int PICKER_LOADS_PER_FRAME = 2;
|
||||
|
||||
// How many rings of vertex-adjacent triangles either side of the paint's edge join the border refine
|
||||
// band (see collect_paint_region()). Two is enough to grade the size change without the band's own
|
||||
// cost growing to matter: it is a ring around a perimeter, not an area.
|
||||
@@ -4118,7 +4122,7 @@ const GLGizmoTextureDisplacement::LibraryTexture *GLGizmoTextureDisplacement::ge
|
||||
if (entry.image_data) {
|
||||
TextureDisplacementLayer probe;
|
||||
probe.image_data = entry.image_data;
|
||||
entry.thumbnail = upload_height_thumbnail(decode_height_texture(probe));
|
||||
entry.thumbnail = upload_height_thumbnail(decode_height_texture(probe, /* keep_decoded */ false));
|
||||
if (!entry.thumbnail)
|
||||
entry.image_data.reset(); // decoded to nothing usable - treat it as a failed load
|
||||
} else {
|
||||
@@ -4331,6 +4335,8 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
std::optional<TextureLibraryEntry> to_remove;
|
||||
bool import_clicked = false;
|
||||
bool download_clicked = false;
|
||||
int loads_left = PICKER_LOADS_PER_FRAME;
|
||||
bool loads_deferred = false;
|
||||
|
||||
// Tiles are placed on the four-column grid explicitly rather than flowed with SameLine(): a user tile
|
||||
// overlays a remove button on its corner, and that button would otherwise be "the previous item" the
|
||||
@@ -4348,11 +4354,30 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
};
|
||||
// One section's tiles, in the order the section holds them (shipped alphabetically, pack textures by
|
||||
// name, the user's own most recently used first - the order texture_library() produced); a user tile
|
||||
// carries a remove button on hover. Returns how many it drew.
|
||||
// carries a remove button on hover. Returns how many tiles it laid out, drawn or not.
|
||||
const auto grid = [&](const ImVec2 &origin, const std::vector<const TextureLibraryEntry *> &entries, bool removable) {
|
||||
int n = 0;
|
||||
for (const TextureLibraryEntry *entry : entries) {
|
||||
ImGui::SetCursorScreenPos(tile_pos(origin, n++));
|
||||
const ImVec2 rect_min = tile_pos(origin, n++);
|
||||
const ImVec2 rect_max = ImVec2(rect_min.x + tile, rect_min.y + tile);
|
||||
const bool current = entry->path == layer->path;
|
||||
// The selected tile is centred even when it is off screen, so it is drawn and loaded next frame.
|
||||
if (current && m_picker_reveal_selected) {
|
||||
ImGui::SetScrollFromPosY(0.5f * (rect_min.y + rect_max.y) - ImGui::GetWindowPos().y, 0.5f);
|
||||
m_parent.request_extra_frame();
|
||||
}
|
||||
if (!ImGui::IsRectVisible(rect_min, rect_max))
|
||||
continue;
|
||||
// Thumbnails decode on this thread, so only a few per frame; the rest fill in on later frames.
|
||||
const bool cached = m_library_textures.count(entry->path) != 0;
|
||||
if (!cached && loads_left == 0) {
|
||||
loads_deferred = true;
|
||||
dl->AddRectFilled(rect_min, rect_max, ImGui::GetColorU32(ImGuiCol_FrameBg), style.FrameRounding);
|
||||
continue;
|
||||
}
|
||||
if (!cached)
|
||||
--loads_left;
|
||||
ImGui::SetCursorScreenPos(rect_min);
|
||||
ImGui::PushID(entry->path.c_str());
|
||||
const LibraryTexture *tex = get_library_texture(entry->path);
|
||||
GLTexture *thumb = tex != nullptr ? tex->thumbnail.get() : nullptr;
|
||||
@@ -4375,7 +4400,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
if (removable)
|
||||
ImGui::SetItemAllowOverlap(); // the remove button drawn over its corner takes its own clicks
|
||||
const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax();
|
||||
const bool current = entry->path == layer->path;
|
||||
const bool hovered = ImGui::IsItemHovered();
|
||||
// The layer's current texture carries an accent ring, and the tile under the pointer a thinner,
|
||||
// fainter one. Both are drawn inside the tile, where the grid's clip rect never cuts them.
|
||||
@@ -4384,9 +4408,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
dl->AddRect(ImVec2(a.x + 0.5f * w, a.y + 0.5f * w), ImVec2(b.x - 0.5f * w, b.y - 0.5f * w),
|
||||
ImGui::GetColorU32(current ? orca : ImVec4(orca.x, orca.y, orca.z, 0.6f)), 0.f, 0, w);
|
||||
}
|
||||
// Centred, as far as the scroll range allows, so the textures around it are in view too.
|
||||
if (current && m_picker_reveal_selected)
|
||||
ImGui::SetScrollFromPosY(0.5f * (a.y + b.y) - ImGui::GetWindowPos().y, 0.5f);
|
||||
if (hovered)
|
||||
panel_tooltip(entry->name);
|
||||
|
||||
@@ -4575,6 +4596,8 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
}
|
||||
if (loads_deferred)
|
||||
m_parent.request_extra_frame();
|
||||
// Done whether or not the current texture was drawn: one in a collapsed section stays where it is.
|
||||
m_picker_reveal_selected = false;
|
||||
ImGui::EndChild();
|
||||
|
||||
@@ -223,23 +223,11 @@ std::vector<unsigned char> read_file_bytes(const std::string &path)
|
||||
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
// True if these bytes are a PNG libslic3r can decode, i.e. can be stored on a layer as-is. Mirrors
|
||||
// exactly what decode_height_texture() accepts: 8-bit grayscale, or 8-bit colour (whose luminance is
|
||||
// the height and whose RGB is available to colour the model). Anything else is converted on load.
|
||||
// True if decode_height_texture() can read these bytes, so they can be stored on a layer as-is.
|
||||
// Anything else is converted on load.
|
||||
bool is_supported_height_map(const std::vector<unsigned char> &bytes)
|
||||
{
|
||||
if (bytes.empty())
|
||||
return false;
|
||||
const png::ReadBuf rbuf{ bytes.data(), bytes.size() };
|
||||
if (!png::is_png(rbuf))
|
||||
return false;
|
||||
png::ImageGreyscale gray;
|
||||
if (png::decode_png(rbuf, gray) && gray.cols > 0 && gray.rows > 0)
|
||||
return true;
|
||||
png::ImageColorscale color;
|
||||
return png::decode_colored_png(rbuf, color) && color.cols > 0 && color.rows > 0 &&
|
||||
color.bytes_per_pixel >= 3 &&
|
||||
color.buf.size() == color.cols * color.rows * size_t(color.bytes_per_pixel);
|
||||
return !bytes.empty() && png::is_decodable_8bit_png(png::ReadBuf{ bytes.data(), bytes.size() });
|
||||
}
|
||||
|
||||
std::vector<TextureLibraryEntry> g_library;
|
||||
|
||||
Reference in New Issue
Block a user