Skip to content

Vulkan backend: parallel record perf, IR-replay gate, external framebuffer threading - #9

Merged
brianmk merged 21 commits into
freecad-masterfrom
fix/vulkan-replay-perf
Sep 6, 2026
Merged

Vulkan backend: parallel record perf, IR-replay gate, external framebuffer threading#9
brianmk merged 21 commits into
freecad-masterfrom
fix/vulkan-replay-perf

Conversation

@brianmk

@brianmk brianmk commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Vulkan backend: parallel record perf + retained-IR replay gate + external framebuffer threading

This is the bundled-Coin portion of the FreeCAD-vulkan renderer work. It builds
on the earlier fix/vulkan-replay-perf PR (#2) with the milestone chain
(IR rendering rework, M1b/M1c/M1d recording) that was never merged, plus a set
of correctness and performance fixes that landed together on the record/replay
path.

Highlights

M1d — parallel recording (submission concurrency fix)

A single shared VkCommandPool was used by every worker thread. The Vulkan
spec requires a command pool "must not be used concurrently in multiple
threads... including use via recording commands on any command buffers
allocated from the pool". The result was a native crash with several worker
threads simultaneously inside the NVIDIA driver at vkCmdDraw. Command pools
are now one-per-worker (secondaryCommandPools vector), allocated slot-major
([slot * W + worker]) so workerSecondary() resolves the right buffer/pool
pair, and released by the owning worker.

Retained-IR replay perf fix

The graph-fingerprint recompute walked the whole scene tree every frame
(~28 ms on a 1000-box scene) because a scene-root sensor set the dirty flag on
any descendant notification — including the camera pose, since FreeCAD keeps
the camera inside the scene graph. Camera motion never changes the retained
main-list content, so the expensive walk is now gated on the cheap draw-list
fingerprint (world matrices + geometry pointers + counts), which is
camera-invariant. Replay also keys on fingerprint equality rather than the
sensor flag. This takes a 1000-box frame from ~73 ms to ~8 ms.

Texture staging pool fix

ensureStagingPoolSize() checked capacity >= required instead of
cursor + required, so two mid-size uploads in one frame could overrun the
buffer end (heap corruption). It now sizes the pool to cursor + required.

External (GUI/QVulkanWindow) path — framebuffer threading

renderExternal() never created its render-pass framebuffer, so the
secondary-path gate could never engage in FreeCAD's GUI. Extracted an
ensureFramebuffer() helper used by both renderInternal() and
renderExternal(), and threaded the caller-owned VkFramebuffer through
SoVulkanRenderManager::renderExternal(), renderExternal*(), recordFrame()
and recordSecondaryChunk(). The secondary path now sees a compatible
pass/framebuffer pair and stays correct for Qt's MSAA swapchain pass.

Instrumentation

  • [TRC] per-step recording traces (SoVulkanRenderBackendP.h, gated by
    FC_VULKAN_TRACE) plus [RTDBG] cpuTimingRaster total= and perf-vkCall
    breadcrumbs so a multi-thread frame can be read back in order.

Tests

  • testsuite/vulkan/vulkan-backend-parallel-test.cpp: retained-IR replay
    parity harness (PAR_HASH c2c444f48137cf11 identical across the
    FC_VULKAN_PARALLEL_RECORD / FC_VULKAN_MEM_POOL / FC_VULKAN_RP_CLEAR
    matrix, 12/12 + 20/20 stress).
  • The parallel test passes (rc=0) against the merged tree.

Companion main-repo PR: brianmk/FreeCAD-vulkan#19 (bumps this coin commit).

…, dedup descriptor binds, widen-line scratch reuse + persistent host mapping

- graphFingerprintWalk: exclude node-ids for SoCamera/SoLight/SoEnvironment/SoRotation/
  SoTransformSeparator and bare SoGroup/SoSeparator so a frame fingerprint is stable
  under camera orbit (fp stable, replayed=1); geometry edits still invalidate (rep=0).
  apply() before/after: 37.9ms -> 0.00ms.
- recordDrawCommand: cache the lighting dynamic offset + texture set per command, bind
  descriptor set 0 only when the lighting handle changes and set 1 alone per draw, and
  fast-path resolveTextureSet no-texture -> white set (skips per-draw map lookup).
- expandWideLines: reuse backend clip/distance/quad scratch via assign() instead of
  allocating fresh std::vector per line per frame; keep a persistent host mapping on
  each ring slot (HOST_VISIBLE|HOST_COHERENT) so the steady-state update is a plain
  memcpy instead of a per-command vkMapMemory/vkUnmapMemory pair, which dominated the
  wide-line cost (~100us -> ~2-13us per command).

cubes1000 orbit: avg 43.1 -> ~29.9ms, 23.2 -> 33.4 fps, apply 37.9 -> 0.00ms,
record median ~18.2 -> 17.1ms.  Correctness: red edges render, pick probe PASS,
motion proof changed=1.
Collapse the per-cube vkCmdDraw flood into a few instanced draws.

Vertex.glsl reads a per-instance model matrix from four instanced vertex
attributes (locations 4-7), reconstructed as mat4. The pipeline adds an
INSTANCE-rate binding (1, stride 64) and attributes at 4-7 for non-wide-line
pipelines. recordDrawCommand writes each command's model to a per-command
ring slot and binds binding 1 (fixing the one-cube bug where all models were
written to offset 0). A new recordCommandBatch reserves N UBO slots, writes N
models, and emits ONE vkCmdDraw(vertexCount, instanceCount=N).

Opaque pass now buckets depth-tested, non-wide-line commands by a batch key
(pipeline/descriptor/push/material) using explicit field-by-field state
compare (memcmp is unsafe on padded structs), then re-verifies pairwise
compatibility before emitting instanced batches.

Cumulative result: recordFrame steady-state ~18ms -> ~1.9ms; CUBEPERF avg
~30ms -> ~6.4ms. pick probe PASS; frame dump nonbg 96.9% + red_edge_px 242;
motion proof changed=1.

Also includes the replay-gate stabilization (SoVulkanRenderManager): a retained
frame only re-records when the graph fingerprint actually changes; camera-only
frames reuse the last command buffer (apply 37.9 -> ~0.0ms).
getElement() is the single most frequently invoked method in the whole
library: every element accessor of every traversed node funnels through it,
and the profiling flame graph showed a quarter of render-path CPU sitting in
it as a pure leaf (per-access function-call overhead + debug assert on a
tiny function called billions of times).  The lazy copy-on-write push was
not the bottleneck (allocation did not show in the leaf top).

Split it:
  * SoState::getElement() is now an inline fast path defined in SoElement.h
    (the one header where both SoElement and SoState are complete) -- element
    enabled + already at the current depth -> return the element with no
    cross-TU call, no virtual dispatch and no debug assertion.
  * Only the rare stale-element copy-on-write push branches out of line to
    SoState::getElementPush() (SoState.cpp), which keeps the original
    ispopping assert and the nextup/createInstance/push append logic.

Supporting changes: element depth is stored directly on SoState (SoStateP
can't be seen from the header), and SoElement::getDepth()/setDepth() are
inlined (they are read on every fast-path hit).

Behavior is unchanged; pick probe PASS, frame parity intact, recordFrame
steady-state ~1.9ms (no regression).
…etry cache fixes

Lighting fixes for the RT path tracer so transparent (thin-glass) surfaces
render correctly and the headlight reaches the ray tracer:

- setSceneLights(): let the GL host push the authoritative eye-space light
  set (viewer headlight + document SoLight nodes).  The IR draw-list
  lighting capture (SoLightElement::getLights) drops to zero lights on the
  retained/replayed frame, which rendered surfaces at ambient-only
  (near-black).  updateMaterials() now prefers the pushed set.

- Transparent shadow rays: traceShadow() (opaque, terminate-on-first-hit)
  is replaced by shadowTransmittance(), which walks up to 8 nearest-hit
  queries, reading each surface's opacity (mat.diffuse.a = 1 - Transparency).
  An opaque surface returns 0 (fully blocks); a translucent surface
  accumulates (1-alpha) and lets the ray continue, so a pane casts a
  proportionally lighter shadow instead of a fully-opaque silhouette.

- G-module shader split: PathTrace.glsl's PBR/NEE/lighting helpers move into
  RTShadingCommon.glsl and RTRayTrace.glsl, and the vendored PathTrace.spv.h
  is regenerated accordingly.

- materialHash: a pure material edit (same geometry/transform) previously
  produced an identical geometry content hash and never fired cacheChanged,
  so the tracer kept stale accumulation until the camera moved.  Comparing a
  material hash in the geometry cache turns a recolor/Transparency edit into
  a scene change that restarts accumulation.

- geometryDegenerate(): FreeCAD's selection/preselection highlight swaps a
  shape's base geometry for a degenerate/garbage buffer for a frame or two;
  treat that as a transient (re-stamp liveness, no cacheChanged) instead of a
  content change that would refit the BLAS and restart accumulation.

- BLAS build now includes SO_RENDERPASS_TRANSPARENT commands (thin-glass
  needs a BLAS like opaque); only OVERLAY commands are skipped.  Skipping
  transparent left entry.blas == VK_NULL_HANDLE forever, making
  updateGeometryCache report a change every frame.
The bundled Coin is built with COIN_STRICT_WARNINGS=OFF, so these warnings only
surface on a Windows /WX build.  Fix the class of issue clang-cl /W4 /WX turns
into errors so the fork's Windows CI does not fail at the Coin compile step.

- CMake: add -DNOMINMAX to the MSVC define block (windows.h leaks the min/max
  macros, breaking std::min/std::max via the Vulkan WSI headers)
- SoVulkanShared: mark the unused VkAllocationCallbacks* param unused
- RTX geometry: drop the unused 'mapped' lambda capture
- RTX denoise: []() instead of [this]()  + mark the diagnostic nrmR maybe_unused
- RTX path tracing: mark the unused drawlist param unnamed
- Vulkan frame: mark the unused raster reference maybe_unused
- Render manager: mark the debug vkRenderBreadcrumb helper maybe_unused

getenv C4996 needs nothing: the coin CMake if(MSVC) block already defines
_CRT_SECURE_NO_WARNINGS.  Verified with a Linux clang -Wall -Wextra -Werror scan
of the Vulkan/RTX renderer (0 -Wunused-*) and a clang -Werror bundled build.
…ld is clean

Rendering:
- SoGLDriverDatabase: mark the parsed-but-unread min/max version fields [[maybe_unused]]
- SoGLNurbs: mark the assert-only 'i' [[maybe_unused]] (NDEBUG drops its reader)
- SbViewportRegion: explicitly default the copy assignment to drop the C++20
  -Wdeprecated-copy-with-user-provided-copy warning (user-declared copy ctor)
- SoRenderManager: unname the unused attachClipSensor param; mark oldorigin [[maybe_unused]]

XML:
- attribute/document/element: mark assert-only bufsize/doc/assumed/bytes/numchildren [[maybe_unused]]
- utils: cast the signed long file size for the size_t comparison (sign-compare)
- path: mark the uncalled path_node_delete helper [[maybe_unused]]

Only warnings (COIN_STRICT_WARNINGS stays OFF), so nothing changed behaviourally.
Move the per-recording command-buffer target and the deduplicated dynamic
state (bound pipeline/viewport/scissor, lighting/texture descriptor caches)
out of backend members into a VulkanRecordContext struct threaded through
every record* helper.  No behavior change; this is the structural prerequisite
for recording secondary command buffers on worker threads (each worker gets
its own context).
The per-instance model-matrix buffer uses the same ring slot layout as the
lighting UBO ((frameIndex % maxFrames) * uboSlotsPerFrame + slotIndex), but it
was grown lazily per draw via ensureInstanceModelBuffer().  That growth path
calls createBuffer/vkMapMemory/deferDestroy, which is a per-draw branch and a
race hazard once recording moves to worker threads.

Size the buffer to the full ring (ensureInstanceModelRingCapacity) at every
site that creates/resizes the UBO ring (createLightingUniformBuffer and
swapLightingBuffer, covering both growLightingUbo and setMaxFramesInFlight).
The per-draw call sites now perform a pure bounds check with no growth path.

Verified with the Vulkan parity probe: plain and wide2 line scenes render
with identical pixel counts before and after.
Every texture upload vkCreateImage -> vkAllocateMemory -> vkBindImageMemory,
and its staging buffer similarly, allocating and freeing device memory against
the driver per texture.  That is slow and counts against maxMemoryAllocationCount.

Add a minimal SoVulkanMemPool sub-allocator: per-memory-type blocks (geometric
growth) with a sorted free-list (first-fit, coalescing) so ranges are reused
instead of hitting vkAllocateMemory/vkFreeMemory each time.  Enabled by
FC_VULKAN_MEM_POOL (off by default) so behavior is byte-for-byte the legacy
path unless explicitly enabled.

Texture image memory now sub-allocates from the pool when enabled; the free
returns to the pool inside the deferred-destroy lambda (by which point the GPU
can no longer reference the range).  Standalone allocations (pool disabled or
pool cannot grow the range) fall back to the legacy path.  Staging buffers and
device-local geometry remain on direct allocation (not the churn source and
lower aliasing risk).

Verified with the Coin Vulkan testsuite binaries compiled against the built
libCoin: texture, texture-models, clear, blending, vertex-color, material and
transparency tests all pass with the pool both off and on.  A latent aliasing
bug (grow() not consuming the requested range from a fresh block) was found and
fixed; texture-test previously failed with the pool on.
Pack interleaved vertices interleaved at 32 bytes: world pos/normal stay
R32G32B32_SFLOAT (offset 0/12), diffuse color quantized to R8G8B8A8_UNORM
(offset 24), texcoord to R16G16_SFLOAT half-float (offset 28). Matching
vendor-neutral floatToHalf() encoder, RGBA->UNORM quantizer, and updated
VkVertexInputAttributeDescription formats/offsets in the visual binding.

The ambient per-vertex fetch drops from 48 to 32 bytes, reducing vertex
bandwidth for large meshes while keeping f32 precision on positions/normals
needed for correct CAD lighting. Shaders are untouched (SPIR-V inputs are
format-agnostic; the vertex-input description drives decoding). uploadScratch
becomes a byte vector sized VULKAN_VERTEX_STRIDE per vertex.

Verified: vulkan-backend-vertex-color-test, -texture-test, and
-texture-models-test pass with FC_VULKAN_MEM_POOL both 0 and 1 (pool
interacts here via shared geometry blocks). Wide-line path keeps its own
36-byte clip-space layout unchanged.
…on (M4)

updateGeometryCache() walked a ~3k-sample FNV content hash on every
non-replayed frame, even for shape-retained geometry whose SoGeometryDesc
points the producer guarantees change exactly when the content changes
(tessellation reallocates the stream buffers on rebuild, SoShape.cpp).
That defeated the documented SoGeometryDesc::retained contract the producer
relies on to skip a per-frame hash.

Honor that contract: when a command is retained (or the frame is a replay
with geometryContentUnchanged), pointer/count identity alone is a correct
change detector and the hash walk is skipped. Non-retained commands (per-
frame arena streams that rewrite the same pointer in place, e.g. per-vertex
colors) still fall back to the sampled content hash to catch in-place edits.

Verified with a new testsuite case: frame1 uploads=1, an unchanged retained
command renders in ~6us with uploads=0 (hash skipped), and a changed retained
position pointer re-uploads (uploads=1). Material, vertex-color, texture, and
texture-models tests still pass with FC_VULKAN_MEM_POOL both 0 and 1.
Currently every clear is recorded as a vkCmdClearAttachments region clear
inside the render pass, even when the viewport covers the whole target. For a
full-target clear, clearing via the render pass attachment loadOp at
VkCmdBeginRenderPass is cheaper (no separate clear command, better early-off).

Add color/depth loadOps to RenderPassIdentity so a CLEAR pass is distinct from
a LOAD pass in the cache, parameterize createRenderPass and getOrCreateRenderPass,
and select the CLEAR variant on the own-queue path when FC_VULKAN_RP_CLEAR is set
and isFullTargetClear(params, target) holds. The begin info carries the matching
clear values (attachment 0 = color, 1 = depth). recordClear() skips the color/
depth vkCmdClearAttachments whose attachment was already cleared by the loadOp.

Partial-viewport clears keep the LOAD pass + region clear, and the caller-
supplied external render passes are left on LOAD (their flags are cleared on
entry). Verified: clear, blending, culling, alpha, material, vertex-color,
texture, and texture-models tests pass with FC_VULKAN_RP_CLEAR both 0 and 1
with pixel-identical output; the pre-existing depth-write-disabled harness
artifact fails identically on and off and is unrelated.
…ket scratch)

Three small, behavior-preserving optimizations for the Vulkan render backend:

1. Hoist the device-pixel-ratio scalar into a per-frame member (frameDpr,
   set by cacheFrameMatrices()) so recordDrawCommand()/recordCommandBatch()
   read a float instead of re-evaluating params.devicePixelRatio per draw.

2. Sampler cache: textures that share filter/wrap state now reuse one
   VkSampler from a member samplerCache (keyed on minFilter/magFilter/wrapS/
   wrapT) instead of creating one per texture entry.  destroyTextureEntry()
   and deferDestroyTextureEntry() no longer destroy the (now shared) sampler;
   the cache owns them and releases every entry once at shutdown() after the
   texture cache is emptied.

3. recordFrame()'s opaque batching pass now reuses a member batchBucketScratch
   map instead of allocating a fresh std::unordered_map per frame.

Verified: material, vertex-color, texture, texture-models, clear, blending,
alpha, culling, scissor, fill-mode, and transparency tests pass. The pre-
existing depth-function / strip-topology / sorted-order harness artifacts fail
identically and are unrelated.
Textures changed in a frame were each staged into a freshly allocated
host-visible buffer (createBuffer + data), freed after the submission/force.
On the external (offscreen/export) path that is N vkFreeMemory per frame plus
N failure branches to clean up.  Consolidate the staging memory into a single
growable, host-visible staging pool owned by the backend:

- prepareTextureUpload() now stages pixels into stagingPoolBuffer at a
  running per-frame cursor instead of allocating a per-upload staging buffer.
- recordTextureUpload() copies from a byte offset into the pool.
- Both the own-queue (recordPendingTextureUploads) and external
  (flushPendingTextureUploadsExternal) paths read from the pool; the external
  path submits once and reuses the pool next frame instead of reallocating.
- The pool is reused across frames (grown on demand, preserve-staged-bytes on
  grow), so its memory surface is one allocation with one cleanup at
  shutdown(), and a failure anywhere leaves a single buffer to release
  instead of N per-upload stagings.

Verified: texture, texture-models, material, vertex-color, clear, blending,
culling, fill-mode, scissor, transparency, alpha, and render-external tests
pass with FC_VULKAN_MEM_POOL both 0 and 1; plus a new multitex-staging test
that uploads two distinct-colored textures in one frame and asserts each half
samples its own texel (no cursor aliasing).
Extract the frame's record ordering (bucketed opaque, instanced batches,
painter-order transparent, wireframe/point overlay redraws, and on-top
annotations) into a read-only buildWorkItems() worklist.  Each VulkanWorkItem
carries its command (single draw or a batch array), a pre-assigned disjoint
slotBase (the first of `count` consecutive lighting-UBO / instance-model ring
slots it will consume), and the pass/fill/color overrides.

recordFrame() now records from the worklist instead of re-walking the draw
list and re-doing the bucketing inline.  It plants uboCmdIndex = item.slotBase
before each record call so the ring cursor can never overflow and, because the
pre-assigned slotBase values mirror the previous per-draw uboCmdIndex++
sequence exactly, the recorded frame is pixel-identical.

This isolates all per-frame state mutation (uboCmdIndex) at the single record
dispatch site, which is the precondition for the secondary-buffer / parallel
recording stages: workers can later record disjoint slot ranges without
sharing cursor state.  The worklist and bucket vectors are reused members, so
an ordinary frame does not heap-allocate them.

Verified pixel parity on the vulkan testsuite with FC_VULKAN_MEM_POOL 0/1 and
FC_VULKAN_RP_CLEAR 0/1 (clear, blending, culling, fill-mode, transparency,
alpha, material, vertex-color, texture, texture-models) plus the retained and
multi-texture staging regression tests.
…(M1c)

The render-order-independent opaque pass (the bucketed, optionally-instanced
depth-tested geometry) is now recorded into a per-frame-in-flight secondary
command buffer instead of directly into the primary, then replayed in place
with vkCmdExecuteCommands inside the already-begun render pass.  Painter-order
transparent, wireframe/point overlay redraws, and depth-off on-top annotations
remain recorded inline in the primary, in the exact order they used.

Secondary lifetime is one buffer per in-flight slot so a secondary is never
reset while a primary that executed it is still pending (the pool's
RESET_COMMAND_BUFFER_BIT lets each be re-recorded after its slot's fence is
waited).  Recording uses RENDER_PASS_CONTINUE | ONE_TIME_SUBMIT with
inheritance {renderPass, subpass 0, own-queue framebuffer}, and the draw
recorders set viewport + scissor per draw (the only dynamic states), so the
secondary is fully self-contained.

The VulkanRecordContext dedup cache is reset at each primary/secondary
boundary: secondary buffers inherit no pipeline, descriptor, or dynamic state
from the primary, so a stale lastBound* entry would otherwise suppress a
needed re-bind.  Paths without a real framebuffer fall back to fully-inline
record, preserving identical output.

The worklist from M1b is now split by a recordToSecondary tag the record path
consumes; because slotBase is pre-assigned and the opaque items sit at the
front of the list (in the original bucket order), the recorded draw order --
opaque (secondary) then overlay-redraw / transparent / annotations (inline) --
and the lighting-slot assignment are identical to the previous fully-inline
recording.

Verified pixel parity on the vulkan testsuite with FC_VULKAN_MEM_POOL 0/1 and
FC_VULKAN_RP_CLEAR 0/1 (clear, blending, culling, fill-mode, transparency,
alpha, material, vertex-color, texture, texture-models) plus the retained and
multi-texture staging regression tests.
…ontext

The lighting/instance-model UBO slot cursor (uboCmdIndex) is per-recording
state, not backend state.  Moved it out of the backend member into
VulkanRecordContext so each recording (and, later, each parallel worker
thread) owns its own cursor.

The record path plants ctx.uboCmdIndex = item.slotBase before each work item
(M1b) and recordDrawCommand/recordCommandBatch advance it, so the draw order
and pre-assigned disjoint slot layout are unchanged.  resetBoundState() (called
at frame start by beginFrame() on every render path) already zeroes the whole
context, so all three entry points keep the correct "starts at slot zero of the
own ring half" semantics that prepareLightingSlots() previously enforced.

Read-only in the dedup path: getOrCreatePipeline()'s warm fast path and
resolveTextureSet() are map reads, so after the pre-pass warms the pipeline
cache, concurrent workers that only hit the warm fast path can record in
parallel without racing.

Verified pixel parity across the vulkan testsuite (pool 0/1, RP_CLEAR 0/1).
…uffer threading

This bundles the accumulated Vulkan-renderer work on top of the M1c/M1d
milestones (uncommitted relative to the previous milestone tip).  Primary
deliverables follow, with the standalone parallel-test harness included.

M1d - parallel recording (submission concurrency fix)
  * A shared VkCommandPool was used by every worker thread.  The Vulkan
    spec requires a command pool to not be used concurrently in multiple
    threads ("that includes use via recording commands on any command
    buffers allocated from the pool"); the resulting data race surfaced as
    a native crash with several worker threads simultaneously inside the
    driver at vkCmdDraw.  Command pools are now one-per-worker
    (secondaryCommandPools vector), allocated slot-major ([slot * W +
    worker]) so workerSecondary() resolves the right buffer/pool pair, and
    released by the owning worker.

Renderer perf fixes
  * Retained-IR replay gate: the graph-fingerprint recompute walked the
    whole scene tree every frame (~28 ms on a 1000-box scene) because a
    scene-root sensor set the dirty flag on ANY descendant notification,
    including the camera pose (FreeCAD keeps the camera inside the scene
    graph).  Camera motion never changes the retained main-list content, so
    the expensive walk is now gated on the cheap draw-list fingerprint
    (world matrices + geometry pointers + counts), which is camera-invariant.
    Replay also keys on fingerprint equality rather than the sensor flag.
    This takes a 1000-box frame from ~73 ms to ~8 ms.
  * Texture staging pool: ensureStagingPoolSize() checked capacity >=
    required instead of cursor + required, so two mid-size uploads in one
    frame could overrun the buffer end (heap corruption).  Fixed to size the
    pool to cursor + required.

External (GUI/QVulkanWindow) path  -- framebuffer threading
  * renderExternal() never created its render-pass framebuffer, so the
    secondary-path gate could never engage in FreeCAD's GUI.  Extracted an
    ensureFramebuffer() helper used by both renderInternal() and
    renderExternal(), and threaded the caller-owned VkFramebuffer through
    SoVulkanRenderManager::renderExternal(), renderExternal*(), recordFrame()
    and recordSecondaryChunk().  The secondary path sees a compatible
    pass/framebuffer pair and stays correct for Qt's MSAA swapchain pass.

Instrumentation
  * [TRC] per-step recording traces (SoVulkanRenderBackendP.h, FC_VULKAN_TRACE)
    plus [RTDBG] cpuTimingRaster total= and per-vkCall [TRC] breadcrumbs so a
    multi-thread frame can be read in order.

Tests
  * testsuite/vulkan/vulkan-backend-parallel-test.cpp: retained-IR replay
    parity harness (PAR_HASH c2c444f48137cf11 identical across the
    FC_VULKAN_PARALLEL_RECORD / FC_VULKAN_MEM_POOL / FC_VULKAN_RP_CLEAR
    matrix, 12/12 + 20/20 stress).

Co-authored-by: phantomcake <brianmgs@pm.me>
Bring in the fork's merged base commits (coingzm-fseek bounds checks, null
byte-buffer copies, rtx-geometry-ownership, rtx-perf) so this branch is a
clean descendant of freecad-master for the PR.
SoGLRenderBackend::uploadLighting() rerouted every retained light through
SoRenderIR::lightToEye() to convert the setup to eye space.  That is the
IR/Vulkan convention (fillLightingBlock applies it with a toEye matrix), but
the legacy GL Visual executor and its RetainedMaterialLightingGLTest treat
the drawlist lighting data as already in the space the shader consumes.  The
double conversion broke two-sided lighting (wrong viewer-facing side) and
non-uniform model scale (corrupted normal lighting).

Revert uploadLighting() to upload the retained light data as-is, matching the
GL executor's contract and the master behavior this test codifies.

The real CI runner still lacks a usable EGL display (EGL_NOT_INITIALIZED /
EGL_BAD_DISPLAY), so the texture/geometry sub-tests cannot render there in
any variant; those are environment artifacts, not code regressions.

Co-authored-by: phantomcake <brianmgs@pm.me>
@brianmk
brianmk merged commit 4deddf8 into freecad-master Sep 6, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant