Skip to content

gfx: engine fixes for the shading tiers, scene lights and blending, glTF material parameters - #2326

Merged
jcelerier merged 124 commits into
masterfrom
gfx/shading-tiers
Oct 13, 2026
Merged

jcelerier merged 124 commits into
masterfrom
gfx/shading-tiers

Conversation

@jcelerier

@jcelerier jcelerier commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

These are the engine fixes found while building the shading tiers (unlit / blinn_phong / pbr_fast / classic_pbr) in csf-examples, which use the Scene Preprocessor's aux buffers. Each fix comes with a test that fails without it and passes with it.

Raw raster and CSF

  • AUXILIARY and OUTPUTS size expressions that read an input are evaluated; VERTEX_COUNT and INSTANCE_COUNT take expressions.
  • Top-level PIPELINE_STATE.COLOR_WRITE is honoured. The front face stays correct under CULL_MODE none.
  • An AUXILIARY texture without MIPMAP_MODE reads its mips. A STATIC image input fed by Images or a video reads the image.
  • A CSF processes every mesh of a multi-mesh geometry. It also reads packed and interleaved attributes at their own stride. An attribute it cannot repack reads as zeros.
  • A vertex shader with no inputs keeps no vertex bindings. MULTIVIEW shaders compile and render on OpenGL.
  • A geometry input with several cables warns.

Scene

  • The Scene Preprocessor draws meshes whose index buffer is on the GPU. It copies GPU color0 and texcoord1, and warns once when it skips 16-bit GPU indices.
  • Scenes on one port merge in edge order.
  • Shadow cascades belong to the light that Shadow Cascade Setup fits them to: light_slot, see the libossia PR.
  • Without an Environment node there is no ambient light.
  • glTF and FBX lights light the scene, and cast shadows when the file says so.
  • Alpha-blended draws come after the opaque ones, back to front from the centre of the scene's cameras, with no depth write.
  • glTF normal scale, occlusion strength and clearcoat normal scale reach the shaders. glTF skins keep their joint hierarchy and rest pose.

Nodes

  • PBR Mesh and Material Override take their base and emissive colours from colour controls. PBR Mesh keeps its geometry's auxiliaries and transform, and Material Override takes effect on it.
  • Environment sets the fog mode and density.
  • Shadow Cascade Setup takes the light that casts, and covers the scene without a camera.
  • Configure Primitive's ** glob spans path segments.
  • A CPU texture outlet publishes its texture, so Texture Info reads the real size. Texture to buffer copies a CPU producer's texels.
  • A script can set a texture inlet's format (inl.textureFormat = "RGBA16F").
  • Half-float planar EXRs decode.

Added after review of the first CI run

  • CSF storage declared PERSISTENT reads the previous frame as <name>_prev.
  • Model Display draws a mesh white when nothing is wired to its texture inlet.
  • On Metal, the Scene Preprocessor widens 16-bit GPU indices with a compute pass instead of skipping the mesh.
  • Builds on every supported Qt: the blend pass no longer uses setDepthClamp or QRhiResource::rhi(), and GPU JS needs Qt 6.7, which has QQuickRhiItem. Checked locally with mold:
    • Qt 6.4.2 with the test suite and dynamic plugins;
    • Qt 6.5.3 with PCH;
    • Qt 6.9.1 as a unity build;
    • the SDK's Qt 6.12.

Depends on

Validation

  • Linux: Vulkan and OpenGL, build-sdk43. A full ctest run at 7f2252111d, then re-runs of every failure. Still failing: GL multiview (needs the SDK Qt above), two explorer tests that depend on the desktop window manager (they pass on Xvfb), and the PipeWire and shader-sweep tests that already failed before this PR.
  • macOS (Metal) and Windows (D3D11/12, Vulkan, GL): the example-score sheet was rendered at 132c8b3330. Rebuilds at the current head are in progress.
  • The csf-examples tier, gallery and depth scenes were re-rendered against this branch.

Tests on macOS, Windows and static builds (folded in from #2332):

  • ForkProbe.hpp: resolve __llvm_profile_write_file with dlsym on Apple, as ObjectGallery.cpp does. Mach-O has no weak undefined externals, so seven tests failed to link on macOS outside coverage builds.
  • bitfocus_module_driver is a GUI test: it boots score_test/App.hpp, which with static plug-ins references every plug-in.
  • CanvasPaintingTest: near renamed to colorsClose (<windef.h> defines near).
  • Regex_UI_Test: its Switch uuid no longer clashes with Switch_UI_Test's in unity builds.
  • The test fixture deletes parentless dialogs between applications: the local device's "port taken" box, raised for the port 0 the tests ask for, outlived its QApplication and crashed the next one under xcb (8 integration tests).
  • macOS integration tests use DYLD_FALLBACK_LIBRARY_PATH, since score refuses to start with DYLD_LIBRARY_PATH set (18 tests).
  • score::interactiveSession() moves to score/widgets/MessageBox and the local device uses it: with --script, offscreen or minimal platforms, the port warning is only logged.

Verified together with #2330 on Linux (Vulkan, GL), macOS (Metal) and Windows (D3D11/12, Vulkan, GL) at the same commits.

D3D12 robustness:

  • The Scene Preprocessor merges the mesh-slab uploads of a scene load (one per contiguous run of each stream instead of one per primitive and stream): Sponza went from about 770 uploads into the 128 MB mesh buffers to a handful. Each upload is a staging allocation and a copy on every backend; Qt's D3D12 backend staged the whole buffer per upload, which ran the process out of memory (48 GB, then a lost device). Peak shared GPU memory is now 1.25 GB even on the current SDK's Qt. Vulkan images are unchanged, pixel for pixel.
  • Outputs notice a D3D11/D3D12 device removed under offscreen frames (checkDeviceLost asks the native device for GetDeviceRemovedReason). Qt only reports a loss when a Present or a texture creation fails, so offscreen outputs kept rendering into a dead device until killed.
  • The two Qt bugs behind this (staging sized by the buffer; sRGB mipmaps through typed UAVs) are patched in ossia/sdk 7da23a0 (qt-patches 0006 and 0007). With both, the Sponza and DamagedHelmet scenes that lost the device render on D3D12 within 0.2% of Vulkan.

Backend portability, from the contact-sheet triage of the example scores:

  • Row order: every published 2D image has the row order of a rendered texture. CPU uploads are flipped where the RHI has Y up in NDC (OpenGL, Metal, D3D), in Gfx/Graph/RowOrder.hpp.
  • Alpha: render targets are premultiplied, file textures straight. Texture-reference sampling un-premultiplies render-target sources. Base-colour textures of MASK/BLEND materials get their transparent texels' colour bled in before their mip chain is built.
  • Gaussian splats draw on every backend and keep render-target row order on Metal and D3D.
  • Identifiers the target language reserves are renamed instead of refusing the shader: HLSL and MSL words, plus the GLSL words NVIDIA's GL compiler rejects (char, register, row_major, …).
  • Direct3D:
    • point sprites, drawn as instanced quads;
    • per-view PER_MIP passes with lowered multiview;
    • readonly storage blocks as SRVs on D3D11;
    • no resource bound as both halves of an in-place pair;
    • DRED breadcrumbs and page faults are logged when a D3D12 device is lost.
  • Points below a pixel draw on Metal. ISF depth macros read mip 0, and IMG_NORM_PIXEL_LOD reads an explicit level.
  • With the SDK's Qt patches, all 58 problem scores from the triage render upright on Linux Vulkan/GL, Metal, D3D11, D3D12 and Windows Vulkan/GL.

Merged in: #2331 (camera release, Syphon decoder on Metal, an inert replaced execution context) and #2330 (static clang64 test build, add-ons through response files on Windows).

Crash and build fixes from the full Linux test run:

  • Model Display no longer draws with a vertex buffer that its producer released during a render-list rebuild. This use-after-free crashed model_display_gpu_geometry in 9 of 15 runs, and the same crash was seen on macOS.
  • Boost.Asio's global symbols are versioned on every platform (BOOST_ASIO_ENABLE_VERSION_NAMESPACE). liblsl's standalone Asio had replaced Boost's thread entry point, so mapper_http and mapper_datagram double-freed.
  • A render list's first frame no longer submits init-time uploads into a buffer it just freed. When an inlet's spec changed before that frame, the renderer was re-initialised and its material UBO deleted while the initial update batch still pointed at it. input_sampler_filter (raw raster, OpenGL) crashed or read back black in about half of its runs. The batch is now committed as soon as the frame takes it, with a deterministic regression case in outside_frame_release_buffer.
  • Path resolution in GPU node controls applies only to file and folder choosers. A string control is no longer turned into a path because it starts with a root token.
  • score's fallback VkDevice, used when the GPU has no Vulkan Video decode queue or with SCORE_GFX_NO_SHARED_VKDEVICE, enables DMA-BUF and DRM-modifier import again. vulkan_importer_device_checks imports a LINEAR udmabuf and a vendor-tiled GBM buffer there with no validation errors.
  • Syphon input no longer crashes when the sender quits (Metal, seen in Domeport Pro): the borrowed texture was freed while the sampler still bound it. The last frame stays on screen, and a restarted sender reconnects under its new UUID.
  • Linux hides all of the static LLVM, clang and Polly. Mesa's libLLVM.so.20 was binding to score's LLVM 23 and crashed at exit or in llvmpipe shader compiles.
  • QT3D_RENDERER and the Quick Controls style are set before plug-ins start threads. Adding a variable reallocates the environment on glibc < 2.41, and LeapC's poll thread was reading it at the time.
  • avendish: enum ports start at their declared default.
  • Tests:
    • the uniform_input fixture gets its QRhi;
    • the canvas painter's parts load through its qmldir;
    • ObjectGallery links the Qt platform plug-ins;
    • the scenario presenter is looked up again after adding a root slot;
    • media clips cover the pixel formats that still go through swscale;
    • text_render goldens are regenerated;
    • new test_app_hides_lsl_symbols.

Spout (Windows):

  • D3D11, D3D12 and Vulkan outputs register through a full-size name buffer. When the name was taken, Spout renamed it in place to name_1 inside a temporary std::string, two bytes past the heap block for a 31-character name. Score then released the configured name, which removed the other application's sender from the registry. Score now keeps the registered name, uses it for release and GL frame sync, and logs the rename.
  • The GL input checks IsUpdated() after each receive, as the SDK requires, and follows sender size and format changes from the receiver.

Vulkan interop on Qt Quick's own VkDevice:

  • Every Qt Quick window asks its VkDevice for VK_KHR_external_memory (plus _win32 on Windows), VK_KHR_dedicated_allocation and VK_KHR_get_memory_requirements2. An application event filter merges them into the window's graphics configuration on SurfaceCreated, before the first expose creates the QRhi, and keeps whatever the app already configured. If a window already has a QRhi at that point, the filter logs a warning. A Spout input shown by a QML TextureSource on Vulkan rendered black (vkGetMemoryWin32HandlePropertiesKHR not available) and now shows the sender's pixels.
  • The DMA-BUF, host-import, CUDA-decoder and CUDA-semaphore importers decide availability through the QRhi's own device (vulkanDeviceCommand(), i.e. vkGetDeviceProcAddr) instead of the instance. vkGetInstanceProcAddr hands out a loader trampoline for any device command. On a Qt-default device, all four reported available, and the host import and semaphore export jumped to a null dispatch entry (SIGSEGV). They now report unavailable, and the existing fallbacks run. On score's own device, all four are still available.
  • SpoutInput uses the QRhi's own QVulkanInstance: a Qt Quick device belongs to QVulkanDefaultInstance.
  • New test_gfx_vulkan_importer_device_checks.

Packaging:

  • tools/create-app.sh resolves relative --app-environment, icon, qrc and appdata paths against the caller's directory, and stops at parse time when the environment file is missing. A relative environment path used to work only when a per-OS overlay existed.
  • Windows custom apps get an explicit AppUserModelID: --app-identifier, or ossia.score.custom.<name> without it. The launcher passes it to app-bin.exe, which sets it on the process before any window exists. Every top-level window also carries it, with relaunch properties that point at the launcher. The running app and the launcher now share one taskbar identity, and pinning the running app pins the launcher. Plain score is unchanged.

Release review (fixups, to autosquash with git rebase -i --autosquash origin/master; each comes with a test that fails without it)

  • cbd2ce7 → beefbb6: PBR Mesh no longer forwards a Scene Preprocessor's _blend_draw_cmds as an injection (test_threedim_pbr_mesh_material).
  • 1c20da2 → f8ba9df: row_major inside layout(...) is a qualifier and stays; glslang rejected the renamed form on OpenGL (test_gfx_shader_corpus_targets).
  • fdcbbf1 → 54cf1ef: SCORE_AUDIO_NO_INPUT applies: setupDefaultSettings writes the CardIn default first, so the unconfigured state is the empty card, not a missing key (test_unit_audio_settings_no_input).
  • 8446374 → 97430d4, 04b1c6a → f8ba9df, a6ec6be → 02f03a2: ossia::hash_set / ossia::hash_map where the branch introduced std containers.

Review notes, unconfirmed doubts and design items (per-frame per-index copies for 16-bit GPU indices, the blend sort versus the MDI fast path): /mnt/build/diag/release-review-2326.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex

@jcelerier
jcelerier force-pushed the gfx/shading-tiers branch 2 times, most recently from 4549ebb to 2e9ace7 Compare October 9, 2026 20:05
jcelerier and others added 3 commits October 9, 2026 20:56
… the scene without a camera

The cascades followed the first directional light found, whether or not
it had Cast shadow on; they now follow the first one that casts, and
fall back to the first directional light only when none does, since
casting is off by default. Without a camera in its Scene In the setup
fitted the cascades to an identity camera, a two-unit box at the origin,
so shadows were cut off and with more than one cascade disappeared; it
now covers the Shadow distance around the origin and warns once that the
camera should be wired in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The env block already carried a fog mode (linear, exponential,
exponential squared) and a density, but nothing set them, so only
linear fog could be reached. Two controls are added after the existing
ones so saved inlets keep their index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Primitives point at their material and the preprocessor resolves a
draw's material by looking that pointer up in the scene's material
table. The override only replaced the table with clones, so no draw
found its material and every overridden mesh rendered with the default
one. It now rewrites the meshes and nodes that use a targeted material
to point at the clone, passes untouched subtrees through as they are,
and keeps the rewritten meshes across rebuilds while upstream is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
@coveralls

coveralls commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Status

Coverage is 48.861% — gfx/shading-tiers into master. No base build found for master.

jcelerier and others added 17 commits October 13, 2026 15:02
Joints had no parent and an identity rest transform, so without an
animation every joint matrix was the inverse bind matrix and the mesh
collapsed, and with one the joints did not follow their parents and the
mesh tore. Each joint now takes its node's rest TRS and its parent
joint from the node tree; nodes between two joints become joints of
their own so their animation tracks still apply, and the root joint
gets a fixed parent bringing it into the skinned mesh's space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
textureFormat is an optional texture_format property, which QML could
not assign from a number or a string, so scripts building scenes could
not ask for a float texture. Converters from int, double and string
are registered next to the render-size ones; a negative number, an
empty string or Auto clears the format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…ntrols

The base colour was four sliders and the emissive colour three sliders
from 0 to 10; they become an RGBA colour (alpha is the material's) and
an emissive colour, and Emissive strength goes up to 100 so the
brightest emission the sliders allowed can still be reached. PBR Mesh
is in no release yet, so scores are updated rather than migrated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The texture-reference comment gave the bucket six bits and the layer twenty-four
while the encoder packs seven and twenty-three, the bucket cap was given as 16
against kMaxBuckets = 8, the material slot as 64 bytes against an 80-byte
MaterialGPU, and the camera buffers as binding only the active camera although
the camera auxiliaries carry every packed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A sampled AUXILIARY texture got a sampler without mipmapping unless the shader
declared MIPMAP_MODE, so textureLod on the scene's prefiltered_map read mip 0 at
every roughness and classic_pbr_openpbr reflected like a mirror everywhere.

When the declaration leaves MIPMAP_MODE out, the sampler now follows the texture
it is bound to: linear for a mipmapped texture, none otherwise, since GL treats a
mip-filtered sampler on a single-level texture as incomplete. Raw rasters and CSF
geometry auxiliaries both do this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The camera block a raw raster binds before a scene camera reaches it held
identity matrices and a zero renderSize, so a shader dividing gl_FragCoord by
camera.renderSize divided by zero. It is now seeded with the render size, as the
Scene Preprocessor's camera is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Images and the video renderers only ever drew into their consumers' render
targets and published no texture of their own, so an input that samples the
producer's texture directly (STATIC) read black.

Images now publishes the current image to such an input, and a video renderer
converts the frame into a texture of its own for it. Inputs with a render target
keep the drawn, scaled image: publishing it unconditionally would let texture
inputs that read upstream textures directly bypass the Images controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The expression evaluator read one port per input from port 0, but a raw
raster's port 0 is its Geometry input and an input may own no port or several,
so "$face_size" read another port and the output fell back to the render size.
It now walks the ports the way the node creates them.

The declared size is also re-evaluated every frame and the targets are rebuilt
when it changes, and a single plain colour output with an explicit size now
renders at that size instead of at the consumer's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
PIPELINE_STATE.VERTEX_COUNT and INSTANCE_COUNT took integer literals only, so a
procedural draw could not follow an input or a buffer's size. A string is now
kept as an expression and evaluated every frame against the raw raster's
expression variables ($<input>, $COUNT_<buffer>, ...).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…ated

Any SIZE that was not an integer literal allocated 1024 elements for an
auxiliary buffer nothing upstream publishes, and $COUNT_<name> then reported
1024. SIZE is now evaluated like the other raw raster expressions; 1024 remains
the fallback when it does not resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
COLOR_WRITE was only read inside BLEND, so a top-level one was dropped and every
channel was written. It is now applied to every attachment whatever blend
applies, and "none" disables colour writes instead of meaning rgba.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A compute shader reads one geometry per input, and with several cables on one
the others were dropped silently. That is now reported once per input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The vertex epilogue mirrors Y on D3D and Metal, and for cube faces on every
backend, which reverses the winding; the pipeline's front face was swapped to
compensate only when culling was on. With CULL_MODE none gl_FrontFacing was
therefore inverted there, and two-sided materials flipped their normals the
wrong way. The swap now applies whatever the cull mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A GPU float attribute whose stride is not the std430 one (a tightly packed
float3 from Pointcloud to mesh or Buffers to geometry, an interleaved
XYZ_RGB buffer) was bound as is when declared vec3, so every element after
the first was misread, and left as a one-element buffer when declared vec4.
It is now repacked into an owned std430 buffer by the GPU scatter every
frame, read straight from the upstream buffer.

The scatter wrote its destination elements dst_components apart, so CPU
float3 data declared vec3 was misread the same way; it now takes the
destination stride.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A CSF read only the first mesh of its input geometry, so the other parts of
a multi-part Geometry Loader or of Merge Geometries were dropped from its
output. Each further mesh now runs the passes in its own dispatch, over its
own buffers and SRBs, so length() and $VERTEX_COUNT are the mesh's own and
its index, auxiliaries and textures stay with it; the output carries one mesh
per input mesh. A mesh lacking a required attribute is forwarded unchanged.

Generators, feedback loops, indirect commands and writable storage are state
the meshes would overwrite in turn: those CSFs keep reading the first mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
… GPU

A draw whose indices were GPU-resident was skipped, so PBR Mesh drew
nothing for any indexed geometry that reached it on the GPU: an uploaded
CPU index (Academy USD Mesh Loader), an index a CSF forwards, or a Scene
Preprocessor output wrapped back into a scene. The index buffer is now
copied into the arena slab every frame like the GPU vertex streams:
uint32 in one copy, uint16 widened index by index into zeroed uint32
slots (not on Metal, whose macOS blits need 4-byte alignment). The
mesh fingerprint follows the index buffer handle, and PBR Mesh's index
byte offset reaches the geometry the preprocessor reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Only position, normal, texcoord0 and tangent were copied from GPU
sources; a GPU color0 or texcoord1 (a CSF such as AddColor upstream of
PBR Mesh) came out as the white / zero fallback. Both are now queued
with the other GPU stream copies, at the source stride and offset, and
mixed into the mesh fingerprint. A float3 colour keeps the fallback's
alpha; non-float colour formats still take the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
jcelerier and others added 24 commits October 13, 2026 17:56
glslang accepts `float char` for SPIR-V and SPIRV-Cross passes it through
to the GL target, where NVIDIA refuses it ("unexpected reserved word").
Probing its compiler against the GLSL, C++ and Cg keyword sets finds
char, register, row_major and the NV mesh-shader qualifiers outside the
spec's reserved list, which glslang and SPIRV-Cross already handle. They
are now prefixed with isf_glsl_ for OpenGL bakes, like the HLSL and MSL
renames. The reserved-names fixture also uses register and row_major.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
ShaderCache renames `frac` before the HLSL bake, so the older SPIRV-Cross
on CI cannot emit `float frac = frac(...)` either; the file now has to
bake clean like the rest, and the known-gap list it was the only entry of
is gone. The rename test expected `new` to pass through on D3D, which
the HLSL list renames, and now covers the OpenGL list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Since 3784b82 gave yuv410p, rgb555le, pal8 and bgr8 GPU decoders, only
bgr565le of the clips the swscale case relied on still reaches Rescale,
and Media_LibavDecode failed `exercised >= 4`. The runner now also makes
rgb444le, bgr444le and yvyu422 clips, with an odd-sized yvyu422 for the
4:2:2 rounding; the GPU-decoded clips stay for the other cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
After a render-list rebuild, Model Display re-acquires the geometry it was
last sent, whose buffer handles belong to the released list: a GPU node's
geometry input released its upload with releaseBuffer() and allocated a
new one, but the new handle only reaches the display a frame later. That
frame bound the freed buffer: NVIDIA crashed in vkCmdBindVertexBuffers or
in the GL deferred release, Metal in setVertexInput, and
test_gfx_model_display_gpu_geometry segfaulted in 9 of 15 runs (0 of 10
now).

CustomMesh::drawSingleMesh now skips the draw when a vertex or index
buffer is retiring, like a null one; the display draws again once the new
handle arrives. isRetiringBuffer rather than isRetiredBuffer: the frame
count only advances in RenderList::render, not across every QRhi frame
that frees a buffer. A geometry input marks its fresh uploads live, so a
recycled address does not read as retired, and the retirement lookup
skips the mutex when nothing is retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…tart threads

Setting a new environment variable reallocates environ, so a getenv on
another thread (LeapC's poll loop) could read the freed array and crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Without BOOST_ASIO_ENABLE_VERSION_NAMESPACE, Boost.Asio names its extern "C"
thread entry asio_detail_posix_thread_function, like standalone Asio. In a
static Linux/macOS link the LSL addon's strong definition replaced our weak
one, so Boost.Asio threads freed their own func object on exit and
~io_context's join of the resolver thread read it after free: the
mapper_http / mapper_datagram segfaults.

Enabled on all platforms (was Windows-only), also for external addons, and
libossia bumped to ossia/libossia#960. test_app_hides_lsl_symbols checks
that the app exports none of liblsl's symbols.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Both drifted at glyph edges on llvmpipe (SSIM 0.99992 / 0.99974, max_abs
75 / 170); regenerated with text-render.sh --update-refs --rebless (two
agreeing runs, analyze.py 36/36). size-large still matched and is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The branch commit 94e7df045 was rebased on merge; d606b8801 on master has
the same tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
liblsl now keeps its bundled Asio, pugixml and loguru apart by compiling
them as lsl_asio, lsl_pugi and lsl_loguru (no prelink), so look for those
names in the app's dynamic exports as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
QImage::flipped is new in Qt 6.9; the Coverage job builds with Qt 6.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A window output takes its title from the device name, once, in
window_device's constructor. That is all an embedder gets, and a name it
has to keep stable for other reasons is a poor title: Scenic names its
devices after the node id because that name is also the address prefix its
cabling resolves against, so every Video Monitor window was called
"dst_window_1_dev".

Add a /title parameter beside /fullscreen. It routes through
ScreenNode::setTitle, so the value is cached in m_title and survives the
window being recreated by a swapchain or graphics-API rebuild, which
writing to the QWindow directly does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QYyvhRXvSMSRQPaq12Teq
spoutSenderNames::CreateSender renames a taken sender name to "name_N"
in place, with strcpy_s(name, 256, ...) (SpoutSenderNames.cpp:213-223).
The D3D11, D3D12 and Vulkan outputs passed it the buffer of a temporary
std::string, so a collision wrote past it (two bytes past the 32-byte
block for a 31-character name) and the real name was lost. Release then
used the configured name, which removed the other application's sender
from the registry and left ours dangling.

registerDXSender copies the name into a SpoutMaxSenderNameLen buffer,
keeps what Spout registered, and the release and GL frame-sync calls use
that name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The SDK asks receivers to check IsUpdated() after every receive
(Spout.cpp:995-1006): a receive that sees a new sender texture only
re-links it and returns without copying (Spout.cpp:909-923), and the
next receive clears the flag. The GL input never called it and polled
the registry by name instead. It now reconfigures from the receiver's
own size, format and handle when IsUpdated() is set, after each of its
two receive calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A playback-only app opens a duplex device for no reason, and on macOS the
capture half is gated by a microphone consent prompt that arrives while
restart_engine() runs synchronously from initDocuments() -- so the process
freezes with no window and nothing on screen to explain it.

SCORE_AUDIO_NO_INPUT=1 defaults such an app to no input card while keeping
output, which SCORE_AUDIO_BACKEND=dummy cannot. It only sets the default:
a stored preference still wins, so the setting stays usable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QYyvhRXvSMSRQPaq12Teq
… directory

The platform scripts read them from another directory, so a relative
--app-environment only worked when a per-OS overlay happened to exist
and the merge replaced it with a mktemp path. Icons, qrc and appdata
were passed through the same way. A missing environment file is now
an error at parse time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Closing the Syphon sender crashed score on Metal one second later, in
-[AGXG14XFamilyRenderContext setFragmentTextures:withRange:] under
QRhiMetal::enqueueShaderResourceBindings (the Domeport Pro report).

The sampler's QRhiTexture is createFrom()'d the client's MTLTexture, and
QMetalTexture::createFrom (qrhimetal.mm:5376) stores the pointer with
owns = false: QRhi never retains it. m_currentMtlTexture was its only owner.
Once the server is gone for kReopenAfterEmpty frames, update() clears
enabled, and the next openServer() stopped the client and set
m_currentMtlTexture = nil (ARC: -fobjc-arc for this file), freeing it. With
no server to find, nothing re-created the QRhiTexture, so the SRBs bound the
freed texture on the next draw. The OpenGL path had the same shape: stopping
the client deallocated the SyphonOpenGLImage, which deletes its GL texture,
and the sampler then read an unloadable texture (black).

openServer() now leaves the current texture alone; it is replaced only when a
new frame rebuilds the QRhiTexture, so the last frame stays on screen. The GL
path keeps the SyphonOpenGLImage that owns its texture the same way. Replaced
Metal textures are kept FramesInFlight + 1 frames, since Qt's command buffers
hold unretained references, and releaseState() drops them behind an empty
command buffer once the sampler is gone.

A restarted sender comes back under a new UUID, so the receiver also matches
the name and app it was last connected to; serverStillPresent() checks the
exact UUID it holds, so a quick restart still reconnects.

macmini-m1, external Metal sender quit after 8 s:
before, 3/3 runs SIGSEGV in setFragmentTextures; after, 10/10 Metal and
3/3 OpenGL runs survive with the last frame held, and restarted senders at
640x360 then 480x270 reconnect with exact pixels on both backends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A packaged app is two processes: <App>.exe, the launcher, starts
app-bin.exe and waits. Neither set an AppUserModelID, so Windows derived
one from each executable's path. The windows belong to app-bin.exe, so a
pinned launcher and the running app never shared a taskbar button, and
pinning the running app pinned app-bin.exe, which starts without the
launcher's arguments and environment.

- create-app-windows.sh bakes --app-identifier (or
  ossia.score.custom.<name> without it) into the launcher, reduced to the
  allowed characters and 128 of them.
- The launcher passes it to app-bin.exe as SCORE_CUSTOM_APP_USER_MODEL_ID,
  with its own path as SCORE_CUSTOM_APP_LAUNCHER.
- score reads both with GetEnvironmentVariableW at the top of main(),
  before any window or plug-in thread, and calls
  SetCurrentProcessExplicitAppUserModelID.
- An application event filter labels every top-level Qt::Window and
  Qt::Dialog when its HWND is created (QPlatformSurfaceEvent), widgets and
  Qt Quick windows alike. It sets RelaunchCommand, RelaunchDisplayNameResource
  and RelaunchIconResource (the launcher), then the ID, through
  SHGetPropertyStoreForWindow. It clears them before the HWND is destroyed.
- Without the variable nothing changes: plain score has no explicit ID.

The packaging makes a zip and creates no shortcuts, so there is no
shortcut to label.

https://learn.microsoft.com/en-us/windows/win32/shell/appids

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…ensions

A TextureSource renders on its Qt Quick window's own VkDevice, which Qt
creates with only the scene graph's fixed extension list, so importing a
Spout texture there fails. An application event filter merges
VK_KHR_external_memory(_win32), VK_KHR_dedicated_allocation and
VK_KHR_get_memory_requirements2 into each QQuickWindow's graphics
configuration on SurfaceCreated, before the first expose creates the QRhi,
and warns if a window already has one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
vkGetInstanceProcAddr returns a loader trampoline for any device command,
so DMABufPlaneImporter and HWCudaVulkanDecoder reported themselves usable
on a VkDevice that never enabled VK_KHR_external_memory_fd, such as a Qt
Quick window's, and then called through a null dispatch entry.
vulkanDeviceCommand() resolves through the QRhi's own device, which
returns null for commands of extensions it did not enable. DMA-BUF now
requires vkGetMemoryFdPropertiesKHR and
vkGetImageDrmFormatModifierPropertiesEXT on the device; the physical-device
scan remains only for VK_EXT_external_memory_dma_buf, which has no command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
On a device without VK_KHR_external_semaphore_fd the instance trampoline
for vkGetSemaphoreFdKHR jumps to null; vkGetDeviceProcAddr returns null
instead, create() fails and the interop fence falls back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
requiredAlignment() asked the instance for
vkGetMemoryHostPointerPropertiesEXT, which resolves whether or not the
device enabled VK_EXT_external_memory_host, so an import on such a device
called a null entry. Resolve it through the device, and use the QRhi's own
QVulkanInstance where Qt provides it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A Qt Quick window's VkDevice belongs to QVulkanDefaultInstance, not to
score's static instance. Resolve vkGetMemoryWin32HandlePropertiesKHR
through that instance's vkGetDeviceProcAddr directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
DMA-BUF, host import, the CUDA decoder and the CUDA semaphore must report
unavailable on a device made with Qt's default extensions and available on
one made with sharedVulkanDeviceExtensions(), and an import gated the way
the callers gate it must not reach the Qt-default device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
RenderList::renderImpl carried m_initialBatch, holding every renderer's
init()-time uploads, into the frame's first pass. When an inlet spec changed
before that first frame, the surgical render-target update released and
re-initialised the renderer first, and releaseState() deletes its material
UBO outright, so the batch then named a freed QRhiBuffer. On OpenGL,
enqueueResourceUpdates copies a uniform-buffer update into the buffer's
CPU-side store: a write into freed memory, or, after destroy() cleared it,
into QByteArray's static empty storage. test_gfx_input_sampler_filter
(raw raster, OpenGL) crashed there, or read back black, depending on what
reused the allocation.

The batch is now committed to the command buffer as soon as the frame takes
it, as onEdgeRemoved() and Graph::relinkGraph() already flush it before
releasing renderer state.

The new case in test_gfx_outside_frame_release_buffer re-inits a probe
renderer on the first frame and checks, from its releaseState(), whether the
batch its upload went into still holds it: deterministic, on every backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
@jcelerier
jcelerier added this pull request to the merge queue Oct 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 13, 2026
@jcelerier
jcelerier merged commit fe6d2e7 into master Oct 13, 2026
5 of 45 checks passed
@jcelerier
jcelerier deleted the gfx/shading-tiers branch October 13, 2026 22:55
@jcelerier jcelerier mentioned this pull request Oct 14, 2026
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.

2 participants