subagent transcript

session 17f09f89 · workflow wf_2d2ac42d
delegated task
CONTEXT — FreeCAD is ported to WebAssembly (emscripten 4.0.12, clang-22, JSPI, native wasm-EH). 3D rendering is Coin3D (Open Inventor) via Quarter, re
▸ Magik
CONTEXT — FreeCAD is ported to WebAssembly (emscripten 4.0.12, clang-22, JSPI, native wasm-EH). 3D rendering is Coin3D (Open Inventor) via Quarter, rendered to an OFFSCREEN FBO then blitted into a Qt widget via QPainter (Qt-for-wasm has NO native GL widget compositing — the whole Qt UI is a SOFTWARE raster surface blitted to an HTML canvas). WebGL2 via emscripten (FULL_ES3, LEGACY_GL_EMULATION off but Coin uses a GLES2 path). COIN_VBO=0 (client vertex arrays), COIN_VERTEX_ARRAYS=1 (VA fast path default-on for indexed face/line sets). qtFontDpi=80. KEY SOURCE LOCATIONS (find others yourself with grep/find): - FreeCAD 3D viewer + FBO->QPainter blit: /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/View3DInventorViewer.cpp (also SoQtQuarterAdaptor / Quarter / SoFCVectorizeAction). The wasm viewport work is documented — grep for __EMSCRIPTEN__ / FC_OS_WASM / grabFramebuffer / QOpenGLFramebufferObject / drawImage / readPixels in src/Gui. - Coin3D source: locate it (find / -name SoAsciiText.cpp -o -name so_glglue.c 2>/dev/null; try /home/magik6k/lcad-wasm/freecad-artifacts, pyside-port/build/pivy-coin, /opt/toolchains). The GLES2/immediate path is in src/rendering/ (SoGL*.cpp) + src/glue/gl.cpp (cc_glglue). SoAsciiText is src/nodes/SoAsciiText.cpp. - Emscripten GL emulation (the ffVertex/ffNormal3/ffSyncContext/emitImm JS functions seen in the profile are emscripten's FIXED-FUNCTION immediate-mode GLES2 emulation): /opt/toolchains/emsdk/upstream/emscripten/src/ (library_webgl.js / library_glemu.js / lib/libglemu / GLImmediate). Confirm where ff*/emitImm come from. - Qt raster paint (RGBA64 conversions): /opt/toolchains/qtsrc/qtbase/src/gui/painting/qdrawhelper.cpp (storeRGBA8888FromRGBA64PM / fetchRGBA8888ToRGBA64PM / comp_func_SourceOver_rgb64). Qt-wasm backingstore: /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmbackingstore.cpp / qwasmwindow.cpp / qwasmscreen.cpp. - The relink/build to test a fix: cmake build at /opt/toolchains/src/freecad-gui-build; relink via /tmp/claude-1000/.../scratchpad/femrelink-s2.sh (this session's scratchpad). Do NOT run builds — analysis only. THE PROFILE (bottom-up, self-time, ~10s capture during hover/pointer interaction at 3-4 fps): Qt SOFTWARE COMPOSITING (~2.3s total): storeRGBA8888FromRGBA64PM 363ms, fetchRGBA8888ToRGBA64PM 250ms, comp_func_SourceOver_rgb64 104ms, fetchTransformed64<TextureBlendType 2> 224ms, qt_rectfill_nonpremul_rgba 132ms, blend_color_generic 147ms, QSvgNode::draw 1092ms(total) / QSvgG::drawCommand 1087ms / QSvgStrokeStyle::apply / QStroker::processCurrentSubpath 124ms, gray_raster_render 247ms, readPixels 113ms, texSubImage2D 83ms, do_mirror 33ms, QImage::copy 66ms. 3D SCENE (Coin, ~1.7s): SoAsciiText::GLRender 314+40ms + SoAsciiTextP::setUpGlyphs + cc_glyph3d_ref + cc_flw_get_vector_advance, immediate-mode glue ffVertex 268ms + ffNormal3 40ms + ffSyncContext 86ms + emitImm + glVertex3f/glVertex3fv, getParameter 390ms + getError 42ms (redundant GL state queries via wasm->JS->WebGL), bufferData 465ms (client-array re-upload every draw?), sogl_render_sphere 130ms + SoSphere::GLRender, SoSeparator::GLRenderBelowPath 1335ms(total) / SoAction::traverse 1562ms(total) / SoSeparator::cullTest 51ms. TIMERS: setTimeout 382ms + clearTimeout 137ms. Event: pointerenter 635ms self / 2730ms total. You have Read/Grep/Glob/Bash (read-only: grep/find/nm ok; do NOT edit files or run builds). Return ONLY the requested structured fields. Be concrete: cite file:line, the exact mechanism, and a SPECIFIC code-level fix (what to change), with an impact estimate (ms saved / % of frame) and effort. LANE: Qt software compositing — the RGBA64 raster path (~1s: storeRGBA8888FromRGBA64PM/fetchRGBA8888/comp_func_SourceOver_rgb64/fetchTransformed64/qt_rectfill). Investigate: WHY does the Qt-wasm backingstore composite in 64-bit (QRgba64) instead of 32-bit ARGB32? - What QImage::Format does the wasm backingstore / the FBO-blit QImage use? (qwasmbackingstore.cpp, qwasmwindow.cpp). RGBA64 fetch/store implies a source or dest in Format_RGBA64* or a gamma/precision path. Find where it's chosen. - The 3D FBO blit: what format is the grabbed FBO image (RGBA8888? premultiplied?), and does QPainter::drawImage into the widget trigger an RGBA64 conversion (fetchRGBA8888ToRGBA64PM -> composite -> storeRGBA8888FromRGBA64PM)? - Is the whole window surface re-composited each frame, or just the dirty 3D rect? Check the backingstore flush / the widget repaint region. If a full-window RGBA64 recomposite happens per 3D frame, that's the ~1s. Propose: force ARGB32_Premultiplied (32-bit) compositing for the window/FBO blit to skip RGBA64 entirely; and/or limit the repaint to the 3D viewport rect. Estimate ms saved.
Fable
thinking
I'm diving into the Qt-wasm backingstore compositing path to understand how it works.
Fable
⚙ Bash {"command": "ls /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/ 2>/dev/null; echo \"---\"; find /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm -name \"qwasmbackingstore*\" -o -name \"qwasmwindow*\" -o -name \"qwasmscreen*\…
tool result
CMakeLists.txt
main.cpp
qtloader.js
qwasmaccessibility.cpp
qwasmaccessibility.h
qwasmbackingstore.cpp
qwasmbackingstore.h
qwasmbase64iconstore.cpp
qwasmbase64iconstore.h
qwasmclipboard.cpp
qwasmclipboard.h
qwasmcompositor.cpp
qwasmcompositor.h
qwasmcssstyle.cpp
qwasmcssstyle.h
qwasmcursor.cpp
qwasmcursor.h
qwasmdom.cpp
qwasmdom.h
qwasmdrag.cpp
qwasmdrag.h
qwasmevent.cpp
qwasmeventdispatcher.cpp
qwasmeventdispatcher.h
qwasmevent.h
qwasmfiledialoghelper.cpp
qwasmfiledialoghelper.h
qwasmfontdatabas  …[truncated]
Fable
⚙ Bash {"command": "grep -rn \"grabFramebuffer\\|QOpenGLFramebufferObject\\|drawImage\\|Format_RGB\\|Format_ARGB\\|Format_RGBA\\|convertToFormat\" /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/View3DInventorViewer.cpp 2>/dev/null | head -60…
tool result
/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/View3DInventorViewer.cpp:100:#include <QOpenGLFramebufferObject>
/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/View3DInventorViewer.cpp:374:    return QOpenGLFramebufferObject::hasOpenGLFramebufferBlit();
/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/View3DInventorViewer.cpp:401:    QImage rgba = image.convertToFormat(QImage::Format_RGBA8888);
/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/View3DInventorViewer.cpp:2347:      …[truncated]
Fable
thinking
I'm examining the backing store implementation to understand how QImage formats are handled in the WebAssembly compositor.
Fable
⚙ Read {"file_path": "/opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmbackingstore.cpp"}
tool result
1	// Copyright (C) 2018 The Qt Company Ltd.
2	// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
3	// Qt-Security score:significant reason:default
4	
5	#include "qwasmbackingstore.h"
6	#include "qwasmwindow.h"
7	#include "qwasmcompositor.h"
8	#include "qwasmdom.h"
9	
10	#include <QtGui/qpainter.h>
11	#include <QtGui/qbackingstore.h>
12	
13	#include <emscripten.h>
14	#include <emscripten/wire.h>
15	
16	QT_BEGIN_NAMESPACE
17	
18	QWasmBackingStore::QWasmBackingStore(QWasmComposito  …[truncated]
Fable
⚙ Bash {"command": "grep -n \"Format_\\|QImage\\|composeAndFlush\\|blit\\|drawImage\\|QRgba64\\|Rgba64\\|texSubImage\\|readPixels\\|do_mirror\\|colorspace\\|ColorSpace\" /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmwindow.cpp /opt/…
tool result
/opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmscreen.cpp:152:QImage::Format QWasmScreen::format() const
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The backing store uses RGBA8888 format, and the 3D FBO blit can produce either RGB32 or RGBA8888 depending on the code path. I'm tracing through how the RGBA64 path works, which involves QPainter compositing an image source into the RGBA8888 backing store.

The key insight is that RGBA8888 isn't a native Qt format—Qt's optimized paths are for ARGB32_Premultiplied and RGB32. When compositing into RGBA8888, Qt's raster engine falls back to a generic 64-bit pipeline: it fetches the RGBA8888 data, converts to RGBA64 for compositing, then stores back. The profile data matches exactly—the slow functions are the fetch and store conversions plus the 64-bit composite operation. So the root cause is t  …
Fable
⚙ Bash {"command": "grep -n \"Format_RGBA8888\\|convert_generic\\|Rgba64\\|fetchRGBA8888ToRGBA64PM\\|storeRGBA8888FromRGBA64PM\\|Fetch64\\|Store64\\|fetchTransformed64\\|DestFetchProc64\\|fetch64\\|fetchProc64\\|convert_to_rgb64\\|fetchTransformed…
tool result
97:    QRgba64 c = reinterpret_cast<const QRgba64 *>(src)[index];
133:static void QT_FASTCALL convertRGBA64ToRGBA64PM(QRgba64 *buffer, int count)
139:static void QT_FASTCALL convertRGBA64PMToRGBA64PM(QRgba64 *, int)
143:static void QT_FASTCALL convertRGBA16FToRGBA64PM(QRgba64 *buffer, int count)
148:        buffer[i] = QRgba64::fromRgba64(c.red16(), c.green16(), c.blue16(), c.alpha16()).premultiplied();
152:static void QT_FASTCALL convertRGBA16FPMToRGBA64PM(QRgba64 *buffer, int count)
157:        …[truncated]
Fable
⚙ Read {"file_path": "/opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmscreen.cpp", "offset": 148, "limit": 12}
tool result
148	{
149	    return m_depth;
150	}
151	
152	QImage::Format QWasmScreen::format() const
153	{
154	    return m_format;
155	}
156	
157	QDpi QWasmScreen::logicalDpi() const
158	{
159	    emscripten::val dpi = emscripten::val::module_property("qtFontDpi");
Fable
thinking
I need to trace through the blend function selection logic to understand why RGBA8888 compositing uses the 64-bit blend path instead of the 32-bit optimized version. The decision happens in the composition function dispatcher, specifically around whether RGBA8888 has a dedicated 32-bit fast path or falls back to the generic 64-bit handler.
Fable
⚙ Bash {"command": "grep -n \"m_format\\|Format_\" /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmscreen.cpp /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmscreen.h; echo \"=== storeFromRGBA64PM / blend selection ===\";…
tool result
/opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmscreen.h:87:    QImage::Format m_format = QImage::Format_RGB32;
/opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmscreen.cpp:154:    return m_format;
=== storeFromRGBA64PM / blend selection ===
721:    auto store = qStoreFromRGBA64PM[rasterBuffer->format];
3993:static void blend_color_generic(int count, const QT_FT_Span *spans, void *userData)
4064:static void blend_color_generic_rgb64(int count, const QT_FT_Span *spans, voi  …[truncated]
Fable
⚙ Bash {"command": "grep -n \"TextureBlendType\\|fetchTransformed64\\|getBlendType\\|BilinearTransform\\|updateSpanData\\|type = \\|blend = \\|bilinear\" /opt/toolchains/qtsrc/qtbase/src/gui/painting/qdrawhelper.cpp | head -40", "description": "Fi…
tool result
839:  transformed bilinear (tiled and not tiled)
846:enum TextureBlendType {
905:template<TextureBlendType blendType>
936:template<TextureBlendType blendType, QPixelLayout::BPP bpp, typename T>
1100:template<TextureBlendType blendType, QPixelLayout::BPP bpp>
1112:template<TextureBlendType blendType>  /* either BlendTransformed or BlendTransformedTiled */
1113:static const QRgba64 *QT_FASTCALL fetchTransformed64(QRgba64 *buffer, const Operator *, const QSpanData *data,
1135:template<TextureBlendT  …[truncated]
Fable
thinking
I'm zeroing in on why RGBA8888 as the destination format routes through the 64-bit blend path instead of the optimized 32-bit routines. The blend function selection logic in the raster paint engine determines this based on the destination format, and I need to trace exactly where ARGB32PM versus RGBA8888 causes the engine to choose between the specialized 32-bit blend operations and the generic 64-bit fallback.

The key insight is that Qt only has fast-path blend routines for ARGB32_Premultiplied and RGB32 destinations, so any other format like RGBA8888 forces the engine to use the generic blend path that converts to RGBA64, composites there, and converts back. Additionally, the image being   …
Fable
⚙ Read {"file_path": "/opt/toolchains/qtsrc/qtbase/src/gui/painting/qdrawhelper.cpp", "offset": 846, "limit": 14}
tool result
846	enum TextureBlendType {
847	    BlendUntransformed,
848	    BlendTiled,
849	    BlendTransformed,
850	    BlendTransformedTiled,
851	    BlendTransformedBilinear,
852	    BlendTransformedBilinearTiled,
853	    NBlendTypes
854	};
855	
856	static const uint *QT_FASTCALL fetchUntransformed(uint *buffer, const Operator *,
857	                                                  const QSpanData *data, int y, int x, int length)
858	{
859	    const QPixelLayout *layout = &qPixelLayouts[data->texture.f  …[truncated]
Fable
⚙ Bash {"command": "grep -n \"qt_blend_argb32_on_argb32\\|convertToFormat\\|Format_ARGB32_Premult\\|Format_RGB32\\|d->rasterBuffer->format\\|useFastImageBlending\\|canUseFastImageBlend\\|drawImage\\|isBlitting\\|d->blend\" /opt/toolchains/qtsrc/qt…
tool result
267:    QImage::Format_ARGB32_Premultiplied.
441:        if (format == QImage::Format_ARGB32_Premultiplied || format == QImage::Format_RGB32)
474:    // FALCON: get rid of this line, see drawImage call below.
856:void QRasterPaintEnginePrivate::drawImage(const QPointF &pt,
2001:                drawImage(pos, d->rasterBuffer->colorizeBitmap(image, s->pen.color()));
2004:            QRasterPaintEngine::drawImage(pos, image);
2015:                drawImage(pos, d->rasterBuffer->colorizeBitmap(image  …[truncated]
Fable
thinking
So TextureBlendType 2 corresponds to BlendTransformed, which means we're dealing with a scaled or DPI-adjusted framebuffer image being drawn through the 64-bit generic rendering path without bilinear filtering. Now I need to check the qBlendFunctions lookup table to see what blend function entries exist for the destination and source formats.
Fable
⚙ Bash {"command": "grep -n \"qBlendFunctions\\|qTransformFunctions\\|qScaleFunctions\\|SrcOverBlendFunc qBlend\" /opt/toolchains/qtsrc/qtbase/src/gui/painting/qblendfunctions.cpp | head; echo \"=== table def ===\"; sed -n '/SrcOverBlendFunc qBlen…
tool result
548:SrcOverScaleFunc qScaleFunctions[QImage::NImageFormats][QImage::NImageFormats];
549:SrcOverBlendFunc qBlendFunctions[QImage::NImageFormats][QImage::NImageFormats];
550:SrcOverTransformFunc qTransformFunctions[QImage::NImageFormats][QImage::NImageFormats];
554:    qScaleFunctions[QImage::Format_RGB32][QImage::Format_RGB32] = qt_scale_image_rgb32_on_rgb32;
555:    qScaleFunctions[QImage::Format_RGB32][QImage::Format_ARGB32_Premultiplied] = qt_scale_image_argb32_on_argb32;
556:    qScaleFunctio  …[truncated]
Fable
thinking
I found the root cause—the fast blend and transform functions are only registered for premultiplied RGBA8888 format, but the backing store uses non-premultiplied RGBA8888, so those optimized paths never get used and fall back to slower implementations.
Fable
⚙ Bash {"command": "grep -n \"qBlendFunctions\\[QImage::Format_RGBA8888\\]\\|qTransformFunctions\\[QImage::Format_RGBA8888\\]\\|qScaleFunctions\\[QImage::Format_RGBA8888\\]\" /opt/toolchains/qtsrc/qtbase/src/gui/painting/qblendfunctions.cpp; echo …
tool result
exit=1 (nonzero/empty = no non-premult RGBA8888 dest fast path)
=== canUseFastImageBlending ===
bool QRasterPaintEnginePrivate::canUseFastImageBlending(QPainter::CompositionMode mode, const QImage &image) const
{
    Q_Q(const QRasterPaintEngine);
    const QRasterPaintEngineState *s = q->state();

    return s->flags.fast_images
           && (mode == QPainter::CompositionMode_SourceOver
               || (mode == QPainter::CompositionMode_Source
                   && !image.hasAlphaChannel()))  …[truncated]
Fable
thinking
Looking at the backing store configuration, the QImage format is set to RGBA8888 with straight alpha, but the blend, transform, and scale function tables for this format are all unregistered and pointing to null.

When these fast paths aren't available, the raster engine falls back to generic functions that convert everything through the RGBA64 pipeline—fetching to 64-bit, compositing, then storing back. This happens for every blend operation, transform, and scale, including widget paints and the FBO scaling with device pixel ratio.

The fix seems straightforward: switch the backing store to RGBA8888_Premultiplied format, which would unlock all the optimized 32-bit fast paths. But there's a   …
Fable
I have the root cause confirmed. Let me verify one more detail about the upload path's alpha expectation.
Fable
⚙ Bash {"command": "grep -n \"drawImageToWebImageDataArray\\|putImageData\\|premultipli\\|unpremultipli\\|Format_RGBA8888\\|constBits\\|createImageData\\|Uint8\" /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmdom.cpp | head -30", "de…
tool result
305:void drawImageToWebImageDataArray(const QImage &sourceImage, emscripten::val destinationImageData,
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
⚙ Read {"file_path": "/opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmdom.cpp", "offset": 305, "limit": 40}
tool result
305	void drawImageToWebImageDataArray(const QImage &sourceImage, emscripten::val destinationImageData,
306	                                  const QRect &sourceRect)
307	{
308	    Q_ASSERT_X(destinationImageData["constructor"]["name"].as<std::string>() == "ImageData",
309	               Q_FUNC_INFO, "The destination should be an ImageData instance");
310	
311	    constexpr int BytesPerColor = 4;
312	    if (sourceRect.width() == sourceImage.width()) {
313	        // Copy a contiguous chunk of me  …[truncated]
Fable
thinking
The core issue is that the image upload does a direct byte copy into canvas ImageData, which requires RGBA byte order with straight alpha for translucent windows to render correctly—that's why Format_RGBA8888 was chosen, but it's the one format without optimized 32-bit blend paths. So there's a real tradeoff between correctness for transparency and rendering performance, and I'm weighing different format options to resolve it. Translucent popups would show minor artifacts at the edges, but that's acceptable or can be handled per-window. I'm recommending Format_RGBA8888_Premultiplied at line 108 to unlock the fast 32-bit argb32-on-argb32 blending paths for all compositing operations, bypassin  …
Fable
I have everything I need. The root cause is confirmed end-to-end. **The mechanism:** The wasm backingstore QImage is created as `QImage::Format_RGBA8888` (straight/non-premultiplied alpha) at `qwasmbackingstore.cpp:108`. In Qt's raster engine, the optimized 32-bit blit/blend/scale/transform function tables (`qBlendFunctions`, `qTransformFunctions`, `qScaleFunctions` in `qblendfunctions.cpp`) have **no entries for `Format_RGBA8888` as destination** — only `Format_RGBA8888_Premultiplied`, `Format_RGBX8888`, `Format_ARGB32_Premultiplied`, and `Format_RGB32` are registered (grep for `qBlendFunctions[QImage::Format_RGBA8888]` returns nothing). When the fast func pointer is null, `QRasterPaintEngine::drawImage` and the span fillers fall through to `blend_src_generic`/`blend_color_generic`, which run the RGBA64 pipeline: `fetchRGBA8888ToRGBA64PM` → composite in `comp_func_SourceOver_rgb64` → `storeRGBA8888FromRGBA64PM`. That is exactly the profiled lane. The FBO blit adds `fetchTransformed64<BlendTransformed=2>` (DPR-scaled drawImage of the grabbed viewport) through the same generic-64 path.
Fable
⚙ StructuredOutput {"lane": "Qt software compositing — the RGBA64 raster path (storeRGBA8888FromRGBA64PM / fetchRGBA8888ToRGBA64PM / comp_func_SourceOver_rgb64 / fetchTransformed64 / qt_rectfill)", "root_cause": "The Qt-wasm backingstore image is Format_RGBA8…
tool result
Structured output provided successfully