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: The 3D FBO -> widget blit path (readPixels 113ms, texSubImage2D 83ms, do_mirror, QImage::copy, bufferData). Investigate View3DInventorViewer.cpp (+ SoQtQuarterAdaptor/Quarter wasm paint path): how does a Coin frame reach the canvas? - Trace: Coin renders to FBO -> grabFramebuffer/readPixels into a QImage -> (mirror/copy) -> QPainter::drawImage into the widget -> Qt raster -> backingstore -> texSubImage2D/canvas. Every arrow is a full-framebuffer copy. - Is readPixels (GPU->CPU) needed at all? Could the Coin FBO texture be composited directly onto the canvas GL context (a textured quad) instead of round-tripping through CPU + Qt raster? Qt-wasm renders the UI via canvas 2D/webgl — can the 3D texture be drawn as a GL quad, bypassing readPixels + the whole Qt raster compositing? - do_mirror + QImage::copy: is the vertical flip (GL bottom-left vs Qt top-left) done on CPU each frame? Can it be a texcoord flip instead? Propose: the biggest structural win (e.g. GL-quad composite bypassing readPixels+raster) AND a cheaper incremental one (avoid the CPU mirror/copy). Estimate ms saved + effort.
Fable
thinking
I'm looking into how the framebuffer object gets blitted to the widget in View3DInventorViewer.cpp.
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && grep -n \"grabFramebuffer\\|readPixels\\|QOpenGLFramebufferObject\\|drawImage\\|do_mirror\\|mirror\\|QImage::copy\\|texSubImage\\|bufferData\\|__EMSCRIPTEN__\\|FC_OS_WA…
tool result
100:#include <QOpenGLFramebufferObject>
374:    return QOpenGLFramebufferObject::hasOpenGLFramebufferBlit();
2347:    // grabFramebuffer() reads the already-rendered widget, so it cannot honor
2363:        img = self->grabFramebuffer();
2365:        img = img.mirrored();
2485:            painter.drawImage(0, 0, img);
2828:        QOpenGLFramebufferObjectFormat fboFormat;
2836:#ifdef __EMSCRIPTEN__
2837:    // The Image render type snapshots the scene via grabFramebuffer() and blits
2867:          …[truncated]
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && ls SoQtQuarterAdaptor* Quarter* 2>/dev/null; find . -iname \"*quarter*\" 2>/dev/null | head", "description": "Locate Quarter adaptor files"}
tool result
(eval):1: no matches found: SoQtQuarterAdaptor*
./Quarter
./Quarter/Quarter.cpp
./Quarter/Quarter.h
./Quarter/QuarterP.cpp
./Quarter/QuarterP.h
./Quarter/QuarterWidget.h
./Quarter/SoQTQuarterAdaptor.cpp
./Quarter/SoQTQuarterAdaptor.h
./Quarter/QuarterWidgetP.cpp
./Quarter/QuarterWidgetP.h
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
I need to trace through the rendering pipeline to understand how frames get painted to the canvas during user interaction, specifically looking at QuarterWidget's paint path and how actualRedraw triggers paintEvent.
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && grep -n \"paintEvent\\|paintGL\\|actualRedraw\\|readPixels\\|grabFramebuffer\\|drawImage\\|mirror\\|QImage\\|texSubImage\\|__EMSCRIPTEN__\\|glReadPixels\\|backingstore\…
tool result
Quarter/QuarterWidgetP.cpp:65:#ifdef __EMSCRIPTEN__
Quarter/QuarterWidget.cpp:70:#ifdef __EMSCRIPTEN__
Quarter/QuarterWidget.cpp:142:#ifdef __EMSCRIPTEN__
Quarter/QuarterWidget.cpp:148:// (makeCurrent/doneCurrent/isValid/context/grabFramebuffer) Quarter relies on.
Quarter/QuarterWidget.cpp:210:    // paintGL() is invoked when e.g. using the method grabFramebuffer of this class
Quarter/QuarterWidget.cpp:216:    // img = gl.grabFramebuffer()
Quarter/QuarterWidget.cpp:218:    void paintGL() overrid  …[truncated]
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/View3DInventorViewer.cpp", "offset": 2820, "limit": 180}
tool result
2820	    }
2821	    else if (format == "GL_RGB32F") {
2822	        return GL_RGB32F_ARB;
2823	    }
2824	    else if (format == "GL_RGBA32F") {
2825	        return GL_RGBA32F_ARB;
2826	    }
2827	    else {
2828	        QOpenGLFramebufferObjectFormat fboFormat;
2829	        return fboFormat.internalTextureFormat();
2830	    }
2831	    // NOLINTEND
2832	}
2833	
2834	void View3DInventorViewer::setRenderType(RenderType type)
2835	{
2836	#ifdef __EMSCRIPTEN__
2837	    // The Image render type snapsh  …[truncated]
Fable
thinking
I'm tracing through the wasm rendering pipeline in QuarterWidget.cpp, following the path from paintEvent through actualRedraw and readbackImage to painter.drawImage, and I need to examine the WasmGLWidget implementation to understand the wasm-specific paint mechanics.
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Quarter/QuarterWidget.cpp", "offset": 60, "limit": 260}
tool result
60	#include <QGuiApplication>
61	#include <QMetaObject>
62	#include <QOpenGLDebugLogger>
63	#include <QOpenGLDebugMessage>
64	#include <QOpenGLFunctions>
65	#include <QOpenGLWidget>
66	#include <QPaintEvent>
67	#include <QResizeEvent>
68	#include <QWindow>
69	
70	#ifdef __EMSCRIPTEN__
71	#include <QPainter>
72	#include "../WasmGLWidget.h"
73	extern "C" void fcWasmSyncGLContext(void);
74	#endif
75	
76	#include <Inventor/C/basic.h>
77	#include <Inventor/SbByteBuffer.h>
78	
79	#include <Inventor/Sb  …[truncated]
Fable
thinking
I'm examining the paintEvent and actualRedraw code paths in the WASM implementation, along with how the WasmGLWidget handles image readback.
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Quarter/QuarterWidget.cpp", "offset": 866, "limit": 120}
tool result
866	void QuarterWidget::paintEvent(QPaintEvent* event)
867	{
868	    ZoneScoped;
869	
870	    if (updateDevicePixelRatio()) {
871	        qreal dev_pix_ratio = devicePixelRatio();
872	        int width = static_cast<int>(dev_pix_ratio * this->width());
873	        int height = static_cast<int>(dev_pix_ratio * this->height());
874	        SbViewportRegion vp(width, height);
875	        PRIVATE(this)->sorendermanager->setViewportRegion(vp);
876	        PRIVATE(this)->soeventmanager->setViewportReg  …[truncated]
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && cat WasmGLWidget.h", "description": "Read WasmGLWidget header"}
tool result
/***************************************************************************
 *   wasm replacement viewport for Quarter's QOpenGLWidget.                 *
 *                                                                         *
 *   QOpenGLWidget cannot composite on Qt for WebAssembly: a WebGL context  *
 *   is tied to a single surface with no cross-context resource sharing, so  *
 *   the window compositor cannot sample a child widget's FBO texture and    *
 *   nothing paints (the 3D regi  …[truncated]
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && cat WasmGLWidget.cpp", "description": "Read WasmGLWidget implementation"}
tool result
/***************************************************************************
 *   wasm offscreen-FBO viewport for Quarter — see WasmGLWidget.h.          *
 ***************************************************************************/

#include "PreCompiled.h"
#ifdef __EMSCRIPTEN__

#include "WasmGLWidget.h"

#include <QOpenGLContext>
#include <QOffscreenSurface>
#include <QOpenGLFramebufferObject>
#include <QResizeEvent>

using namespace Gui;

WasmGLWidget::WasmGLWidget(QWidget* parent)
    : QWi  …[truncated]
Fable
thinking
I'm tracing through the per-frame performance bottleneck: the readback operation combines GPU-to-CPU transfer via `glReadPixels` (taking 113ms), a vertical flip operation that mirrors the framebuffer (33ms), and QImage allocation, all happening in `readbackImage()` when converting the FBO to an image.

Then QPainter draws this image into the widget's backing store, but since the rect dimensions may differ from the image size, it triggers a scaled texture blend path that's expensive in RGBA64 format (224ms). Finally, the entire backing store gets uploaded back to the GPU as a WebGL texture for the canvas (83ms) — so we're doing a full round-trip: GPU texture → CPU readback → CPU mirror and sc  …
Fable
⚙ Bash {"command": "cd /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm && grep -n \"texSubImage\\|texImage\\|Format_\\|toImage\\|QImage\\|drawImage\\|flush\\|backingStore\\|m_image\\|blit\\|premultipl\" qwasmbackingstore.cpp qwasmcomposito…
tool result
qwasmbackingstore.cpp:37:    return &m_image;
qwasmbackingstore.cpp:40:void QWasmBackingStore::flush(QWindow *window, const QRegion &region, const QPoint &offset)
qwasmbackingstore.cpp:58:                "createImageData", emscripten::val(m_image.width()),
qwasmbackingstore.cpp:59:                emscripten::val(m_image.height()));
qwasmbackingstore.cpp:63:    QRect imageRect = m_image.rect();
qwasmbackingstore.cpp:67:        qreal dpr = m_image.devicePixelRatio();
qwasmbackingstore.cpp:82:       …[truncated]
Fable
thinking
The backing store uses `Format_RGBA8888`, and I'm tracing how it gets pushed to the canvas through the image drawing function. For the Coin FBO, I need to verify what format `toImage()` returns—it's likely either `Format_RGBA8888_Premultiplied` or `ARGB32_Premultiplied` in Qt6.

When painting the frame onto the widget's backing store via `painter.drawImage()`, the target is the viewport (a QWidget), which means it's writing to the window's `Format_RGBA8888` backing store. The frame comes from `toImage()` with its own format, and when there's a format or premultiplication mismatch combined with the scaled rect drawing, Qt falls back to the slow general blend path—reading the source as RGBA888  …
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
thinking
Tracing through the compositor path—`updateTexture` copies the image into a JS ImageData via `dom::drawImageToWebImageDataArray`, then `getUpdatedWebImage` returns that data. The window compositing can happen either through WebGL using `texSubImage2D` with the ImageData, or through `putImageData` on a 2D canvas context. The profile is showing `texSubImage2D` calls alongside `do_mirror`, which might be part of the image drawing process.
Fable
⚙ Bash {"command": "cd /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm && grep -n \"drawImageToWebImageDataArray\\|texSubImage\\|texImage\\|putImageData\\|mirror\\|createTexture\\|bindTexture\\|WebGL\\|getContext\\|context2d\\|rgbSwap\\|do…
tool result
qwasmdom.cpp:305:void drawImageToWebImageDataArray(const QImage &sourceImage, emscripten::val destinationImageData,
qwasmcompositor.cpp:40:        return !element->context2d().isUndefined();
qwasmwindow.cpp:139:        m_context2d = m_canvas.call<emscripten::val>("getContext", emscripten::val("2d"));
qwasmwindow.cpp:246:    QWasmOpenGLContext::destroyWebGLContext(this);
qwasmwindow.cpp:259:    m_context2d = emscripten::val::undefined();
qwasmwindow.cpp:394:    if (!m_backingStore || !isVisible()  …[truncated]
Fable
thinking
Looking at the performance profile, Qt-wasm is compositing through Canvas 2D with putImageData rather than WebGL, so the texSubImage2D call showing 83ms isn't coming from Qt's compositor—it's likely from Coin's texture uploads or emscripten's WebGL context handling fonts and textures.

The real bottleneck I'm tracking is the readback and blit path: drawImageToWebImageDataArray is doing a full-frame copy into the JS ImageData buffer each frame, potentially with format conversion like RGB swapping.
Fable
⚙ Read {"file_path": "/opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmdom.cpp", "offset": 305, "limit": 70}
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
So the final path through the compositor is a direct memory copy of the RGBA8888 backing store into the JS ImageData, then putImageData—no mirroring needed since RGBA8888 is exactly what putImageData expects. Now I'm putting together the complete picture of how this all fits together, starting with the per-frame CPU pipeline.

The rendering flow goes: Coin renderers feed into the WasmGLWidget FBO for the 3D work, then `readbackImage()` pulls the framebuffer from GPU to CPU via glReadPixels, applies a vertical flip, and allocates a QImage in RGBA8888_Premultiplied format. When that image gets painted into the QWidget via `drawImage()`, Qt uses a transformed blit path even at 1:1 scale, which   …
Fable
⚙ Bash {"command": "cd /opt/toolchains/qtsrc/qtbase/src/gui/opengl && grep -n \"QImage QOpenGLFramebufferObject::toImage\\|Format_\\|qt_gl_read_framebuffer\\|flipped\\|premultipl\\|::toImage\" qopenglframebufferobject.cpp 2>/dev/null | head -30", …
tool result
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
⚙ Bash {"command": "find /opt/toolchains/qtsrc -name \"qopenglframebufferobject.cpp\" 2>/dev/null; find /opt/toolchains/qtsrc -name \"qopengl.cpp\" -path \"*gui*\" 2>/dev/null", "description": "Locate FBO source"}
tool result
/opt/toolchains/qtsrc/qtbase/src/opengl/qopenglframebufferobject.cpp
/opt/toolchains/qtsrc/qtbase/src/gui/opengl/qopengl.cpp
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
⚙ Bash {"command": "cd /opt/toolchains/qtsrc/qtbase/src && grep -n \"toImage\\|Format_RGBA8888\\|Format_ARGB32\\|qt_gl_read_framebuffer\\|include_alpha\\|flip\" opengl/qopenglframebufferobject.cpp | head -30; echo ---GUI---; grep -n \"qt_gl_read_f…
tool result
1353:static inline QImage qt_gl_read_framebuffer_rgba8(const QSize &size, bool include_alpha, QOpenGLContext *context)
1360:        QImage img(size, include_alpha ? QImage::Format_ARGB32_Premultiplied : QImage::Format_RGB32);
1371:    QImage rgbaImage(size, include_alpha ? QImage::Format_RGBA8888_Premultiplied : QImage::Format_RGBX8888);
1377:static inline QImage qt_gl_read_framebuffer_rgb10a2(const QSize &size, bool include_alpha, QOpenGLContext *context)
1380:    QImage img(size, include_alpha  …[truncated]
Fable
thinking
So `fbo_->toImage()` is returning RGBA8888_Premultiplied format through the Qt framebuffer reading function, and then the `.flipped()` call applies a vertical flip with CPU-side mirroring and a new QImage allocation.

The real bottlenecks are clear: the format mismatch between the FBO's premultiplied RGBA8888 and the backing store's non-premultiplied RGBA8888 forces a general conversion path, the glReadPixels call takes 113ms, and the `.flipped()` operation adds another 99ms combined (33ms mirror + 66ms allocation). Plus we're allocating a fresh QImage every frame with no reuse.

Since the 3D scene is fully opaque, we don't actually need alpha at all. The real solution is to skip `toImage()`  …
Fable
I have the complete picture of the blit path. Let me compile the findings.
Fable
⚙ StructuredOutput {"lane": "3D FBO → widget blit path (readback + QPainter raster composite to the Qt-wasm Canvas2D surface)", "root_cause": "see root_cause field", "optimizations": [{"fix": "In QuarterWidget::paintEvent (Quarter/QuarterWidget.cpp:930-934), …
tool result
Structured output provided successfully