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…
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"}
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\…
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/View3DInventorViewer.cpp", "offset": 2820, "limit": 180}
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}
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}
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && cat WasmGLWidget.h", "description": "Read WasmGLWidget header"}
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && cat WasmGLWidget.cpp", "description": "Read WasmGLWidget implementation"}
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…
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"}
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…
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}
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", …
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"}
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…
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), …