Repository navigation
Shader pipeline: libisf and gfx renderers audited against the shader libraries - #2364
Merged
Merged
Conversation
jcelerier
force-pushed
the
bugfix/shader-ledger
branch
from
October 27, 2026 02:24
3ae185c to
d039ea8
Compare
jcelerier
marked this pull request as ready for review
October 27, 2026 16:58
…VSA resources Three headers the parser accepts produced GLSL that no backend could compile: - RAW_RASTER_PIPELINE with CLIP_DISTANCES / CULL_DISTANCES redeclared gl_PerVertex without gl_PointSize, which the generated main() writes. - A CSF image with FORMAT bgra8 (or a misspelt format, which the warning said would fall back to rgba8) was emitted verbatim as a layout qualifier. Both now become rgba8, for the declaration and the allocation. - VERTEX_SHADER_ART with storage / uniform / geometry / image inputs declared `layout(binding = N) buffer name;`. VSA has no runtime path for them; they are dropped with a warning. test_unit_isf_generated_glsl_bake bakes each case for all five backends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
D3D11, D3D12 and Metal have no QRhi::TriangleFanTopology. Given a TriangleFan pipeline, the D3D11 and Metal backends reach Q_UNREACHABLE and D3D12 draws a strip. Fans came from VSA TRI_FAN, raw PIPELINE_STATE TOPOLOGY triangle_fan, and geometry labelled triangle_fan (BufferToGeometry, CSF TOPOLOGY), none of them checked. - CustomMesh lowers every fan whose order the CPU can see (non-indexed, or a CPU index buffer, no indirect / per-command draws) to an indexed triangle list, on all backends. - VSA TRI_FAN draws Triangles from an index list over the vertex ids when the backend has no fans. - A fan it cannot rewrite (a procedural PIPELINE_STATE TOPOLOGY triangle_fan, a GPU index buffer, an indirect draw) is not drawn there, with one warning per pipeline: as a triangle list over the same vertices it would draw other triangles than the fan's. setSupportedTopology records such a pipeline by resource id; the raw raster skips its draws. SCORE_GFX_NO_TRIANGLE_FAN=1 reports fans unsupported on any backend, like the SCORE_GFX_NO_GPU_* switches, and caps.triangleFan is logged. test_gfx_triangle_fan_fallback draws a hexagon fan (CPU, CPU indexed, VSA) natively and through the fallback on every available backend; a procedural fan covers nothing through the fallback, with the warning. Fans are lowered on every backend, natively-drawing ones included: one path everywhere, which these tests exercise, for a copy at reload only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
LINE_LOOP was drawn as Lines, so N vertices gave N / 2 separate segments instead of a closed polyline (four square corners: two sides). QRhi has no line loop topology; draw a line strip over the index list 0 .. N - 1, 0, through the same index list the TRI_FAN fallback uses. 47 presets of the default Vertex Shader Art library declare LINE_LOOP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A sampled AUXILIARY texture with DEPTH:true declared a <name>_depth companion sampler, as a top-level image input with DEPTH:true does. An auxiliary texture is one handle on the geometry, and the CSF and raw raster renderers bind one texture per auxiliary, so every binding after it in the shader was one slot past the one the renderer filled (Vulkan: VUID-VkComputePipelineCreateInfo-layout-07988). No shader in the libraries reads such a companion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A RESOURCES entry of TYPE "uniform" takes a binding in the generated compute shader at its position, but buildComputeSrbBindings had no branch for it: the block was never bound, and every later sampled texture and buffer of the shader was one binding off (Vulkan: VUID-VkComputePipelineCreateInfo-layout-07988 / -07990, VUID-vkCmdDispatch-None-08114; OpenGL read the wrong texture). The block is now bound to the buffer cabled to its inlet when that buffer has UniformBuffer usage, else to a zero-filled placeholder sized from its LAYOUT, as the graphics path does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
An INDIRECT pass handed its TARGET buffer and OFFSET to dispatchIndirect with the offset only clamped to >= 0. An unaligned OFFSET hit Qt's "indirectBufferOffset must be a multiple of 4" assert, and a buffer shorter than offset + 12 bytes was read out of bounds (OpenGL: GL_INVALID_OPERATION, nothing dispatched; Vulkan: a device fault). Such a pass now runs with its WORKGROUPS ceiling, which an INDIRECT shader already has to bound itself under, and warns once. OFFSET is taken relative to the region the shader sees, for a buffer borrowed from a geometry auxiliary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A storage buffer was allocated from its LAYOUT with a parser that read only
the last [N] of a TYPE as an integer literal:
- vec4[2][3] took "vec4[2]" for an unknown type, guessed 16 bytes: 64
bytes allocated for a block the shader addresses 112 of;
- float[4 * 8] counted as one element: 8 bytes for 132.
Both are valid GLSL, and the shader wrote past the end of its buffer.
isf::parse_array_type splits a TYPE into its base and every extent, each an
integer constant expression; SSBO.hpp sizes with the product, and with the
inner extents of a runtime-sized array of arrays. The parser rejects with a
message what cannot be sized: an extent that is not a positive integer
constant, a runtime-sized inner array, and, in a compute shader, a storage
buffer with BUFFER_USAGE but no LAYOUT, which was emitted as `buffer { };`
and allocated zero bytes. No shader in the libraries declares any of these.
test_unit_csf_storage_layout_size compares the engine's size with the one
the compiled shader reflects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Dispatch sizing (C36): ceil(items / (workgroup size * stride)) was computed in int. LOCAL_SIZE 1024 with STRIDE_X 4194304 wrapped to 0 and divided by it (SIGFPE). Every workgroup count now goes through a 64-bit helper, the per-dimension limit is the device's (QRhi::MaxThreadGroupsPerDimension) instead of a fixed 65535, and a pass needing more workgroups than the device dispatches is skipped with a warning instead of silently. Storage allocation (C13): a buffer needing more than INT_MAX bytes (the size controls go to 536870911 elements) was created at INT_MAX and the shader run against a fraction of its declaration; a failed regrow kept the old buffer, recorded the new size and uploaded a zero fill of that size into it. A buffer that cannot be allocated as declared now stops the node's passes, with a warning naming it, until its size is reduced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
PERSISTENT keeps the previous frame as <name>_prev for a shader that reads and updates the resource. write_only was rejected, but read_only was accepted -- and recommended by the write_only error -- and generated a _prev binding that the renderers never fed with the bound input's history. Both are now refused with a message pointing at read_write. No shader in the libraries declares either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Two faults of the persistent storage image ping-pong, both on every backend but D3D11: - On the first frame, <name>_prev was bound to the image being written, so a pass reading _prev at a texel it had already written read its own output: a row shifted right by one texel per frame lit entirely on the first frame. _prev is now always the read texture. On the first frame it is filled from <name> before each pass, as D3D11 already did, so a pass still reads what the earlier passes of that frame wrote (an init pass) but never what it is writing. - After the swap, the outputs (graphics passes, m_outputTexture, textureForOutput) were pointed at <name>'s new slot, the one the next frame overwrites: a persistent image was shown one frame late, a plain one on time. They now show the image just written. test_gfx_csf_persistent_image_history checks the shifted row frame by frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The pipeline-state name matcher ignores case, '_', '-' and spaces, but only skipped them in the shader's string: a vocabulary entry spelled with an underscore never matched. TOPOLOGY triangle_strip, triangle_fan, line_strip and line_list all fell back to triangles (triangle_list only "worked" because triangles is the fallback). Separators are now skipped on both sides. toTopology is declared in the header for the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The project is reverse-Z: the depth buffer is cleared to 0 and the nearer fragment has the greater depth. An unknown DEPTH_COMPARE fell back to `less`, which against that clear rejects every fragment. It now falls back to `greater` and warns once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
- Without a positive POINT_COUNT, the Vertex Count control defaulted to 10000; it is now 3, one triangle. - PRIMITIVE_MODE was matched case-sensitively and an unknown one silently drew triangles: matched without regard to case now, and an unknown mode still draws triangles but says so. - The authored DESCRIPTION was replaced by "Vertex Shader Art", which is now only the default; the VSA category is not added twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
- SimpleRenderedISFNode never copied the render list's date into its process UBO: DATE stayed (0, 0, 0, 0) in every single-pass ISF, while the multi-pass, VSA, CSF and raw raster nodes had it. - ProcessUBO::sampleRate (SAMPLERATE, Shadertoy's iSampleRate) was never written. The executor now sends the engine's rate with each tick's timings; a message without one keeps the last. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The VSA renderer never reset its EVENT inputs after the frame, as the ISF, CSF and raw raster renderers do: a single impulse stayed true for good. The ports are now reset once the material is uploaded, and the 0 goes out on the next frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
As in the ISF spec and VVISF, IMG_PIXEL(image, coord) is coord divided by the image's size, and IMG_THIS_PIXEL(image) is IMG_PIXEL(image, gl_FragCoord.xy), pixel-aligned; IMG_THIS_NORM_PIXEL is the form that stretches the image over the frame. Graphics shaders divided by RENDERSIZE and made IMG_THIS_PIXEL the normalised form, so an input of another size than the output was sampled at the wrong place (compute shaders already used the texture size). IMG_DEPTH_PIXEL follows its depth texture's size. An ISF reading IMG_THIS_PIXEL on an input of a different size than its output now gets the pixel-aligned read instead of a stretched one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The render node's initial control values differed from the UI's: a float whose default is 0 (an explicit DEFAULT: 0 included) became (MAX - MIN) / 2, and missing point / colour components became 0.5 where the UI uses 0 and (0.5, 0.5, 0.5, 1). A preview, or the first frame before the executor's values arrive, drew something else than the controls showed. ISFNode and GeometryFilterNode now use the UI's rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A pass's WIDTH / HEIGHT expression binds the shader's controls by name. They were bound by walking the inputs one port each, but a written storage buffer or image creates no input port, and a written buffer ending in a flexible array or a $USER size creates extra ones: every control after them was bound to its neighbour's value. The walk now uses the canonical per-input port counts (walk_descriptor_inputs), the same as the CSF and raw raster nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
FORMAT is emitted as the GLSL image format qualifier, but the CSF
allocation compared it against Qt's enum names: rgb10_a2 ("RGB10A2" in
Qt), r32i / rg32i / rgba32i ("R32SI" ...) fell through to RGBA8 while the
shader declared the real format. The reverse mapping also produced
"rgba10_a2", not a qualifier. The CSF node now allocates from the same
qualifier table as every other storage image (imageFormatFromQualifier),
completed with rg16, rgb10_a2 and the GLSL spellings of the signed formats.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Valid GLSL image formats with no QRhiTexture counterpart (snorm, rg16f / rg32f, 8 / 16-bit integer, rgba16, r11f_g11f_b10f) were emitted as written and allocated as RGBA8: the shader and the texture disagreed. The accepted qualifiers are now the ones the engine allocates; the others fall back to rgba8 in the declaration too, with a warning, like an unknown one. No shader in the libraries uses them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
An image with only one of WIDTH / HEIGHT got an invalid size on the other axis, and the whole size was replaced by the render size. Each axis now takes its expression, else the first input image's size, else the render size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The IS_ARRAY + cube rejection ran before TYPE "image_cube" marked the image as a cube, so a cube array was accepted and allocated as something else; a 3D image with IS_ARRAY silently lost IS_ARRAY. Both are now parse errors naming what to use instead. No shader in the libraries declares either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The cap was INT_MAX bytes on every backend, from `int` sizes in the engine (991418e), while QRhi::newBuffer takes a quint32 and Vulkan devices commonly bind 4 GiB - 1. The limit is now RenderState::Caps:: maxStorageBufferSize: Vulkan maxStorageBufferRange, GL MAX_SHADER_STORAGE_BLOCK_SIZE (2 GiB - 1 on NVIDIA), the QRhi limit elsewhere, where a buffer the device refuses fails in create() as before. SCORE_GFX_MAX_STORAGE_BUFFER_SIZE lowers it; the value is logged. On NVIDIA's Vulkan driver (595.99.02) a buffer above 2 GiB is fully addressable but length() returns 0 in the shader, whole or ranged binding alike; the test checks the last element round-trips instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
With hidden visibility each module had its own type info for it, so code outside the plug-in could not catch it by type (test_gfx_csf_sampled_inputs saw the right message and a different type). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
libisf routes gl_NumWorkGroups through the NUMWORKGROUPS_ uniform because SPIRV-Cross cannot emit the built-in for HLSL. On an INDIRECT pass the count is decided on the GPU, and the uniform held the WORKGROUPS ceiling (shader board C33). ShaderCache now restores the built-in on every target but D3D (numWorkGroupsThroughUniform(), forced everywhere by SCORE_GFX_NO_NATIVE_NUM_WORKGROUPS); on D3D an INDIRECT pass whose code reads it warns once. libisf records whether the CSF's own code reads it. The choice is made when the node compiles its shader and kept with it; the INDIRECT warning reads it per dispatch, not the environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The generated DrawIndexedIndirectCommand called words 0 and 2 vertexCount and firstVertex, the non-indexed spellings, though they hold the index count and the first index (shader board C50). The words keep their native offsets; the non-indexed record keeps its names. No shader in the tree or the library writes an indexed record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…nation A last pass took its own target, blitted into the destination, whenever its FORMAT differed from the destination's (cc4aac4, c37a7b9). At the destination's size a four-channel FORMAT changes nothing a blit would not convert anyway, so such a pass now draws straight into the destination, as FLOAT and FILTER already did; it then also writes the destination's depth attachment. A single-channel FORMAT (r8 / r16f / r32f) keeps its target: its green, blue and alpha are not the shader's. A node that requires depth and still blits its last pass warns once that the depth stays behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…d (P32) a5d6516 removed baseColorArray<k>, metalRoughDyn<s> and the other per-channel names of the Scene Preprocessor's texture pool. Saved documents still declare them (six score-corpus variants, three csf-examples variants), and their materials then sampled an empty texture. The aliases are published again; they cost a vector entry each and no binding unless a shader declares one. A raw raster input that names one warns once that it is deprecated, naming materialArray<k> / materialDyn<s>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…, option B) e994fdc read an "image" with neither ACCESS nor FORMAT in RESOURCES as a sampled texture, as in INPUTS. RESOURCES now has one meaning per type: "image" / "image_cube" are storage images, "texture" (and "cubemap") are sampled. A texture takes IS_ARRAY (sampler2DArray), STATIC (bound straight from the producer) and CUBE (samplerCube) like an ISF image; IS_DEPTH, whose <name>_depth companion only an ISF image input binds, is refused. INPUTS in a COMPUTE_SHADER keeps its ISF meaning ("image" is sampled unless it has an ACCESS or a FORMAT) and now warns that it is deprecated: documents saved with compute shaders still embed it (496 entries in score-corpus and csf-examples). write_isf writes a compute shader's RESOURCES, its sampled images as "texture". The test corpus's four sampled RESOURCES images are textures; the library's seven are migrated in csf-examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
bf847b5 showed a shader-declared sampler read-only: the shader's JSON won whole, and the inlet's flag saying so was not saved, so after a reload the rows looked editable and did nothing. Now each field (filter, address mode, mipmaps) is the inlet's when the user set it, else the shader's when its JSON declares one, else the default (linear, clamped, no mipmaps). COMPARE stays the shader's, and a comparison sampler's rows stay read-only. - TextureInlet's sampler settings are optional; an unset one reaches the renderer as unset (score::gfx::unsetSamplerField), and RenderTargetSpecs says which fields were set. ISFNode::adjustRenderTargetSpecs fills the others from the JSON (resolveSamplerFields), so the sampler and the render target's mip chain follow the same values. A grabbed input keeps the shader's or grab-default sampler and takes the fields set on it, live too. - The inlet reads its shader's sampler from the process that owns it (ShaderSamplerSource: ISF filter, VSA, compute shader, geometry filter), so it survives a reload. The inspector's first entry leaves a field unset, named after what it resolves to ("Shader: Nearest", "Default: Linear"). - Saved: the values as before plus FilterSet / AddressModeSet / MipmapModeSet. A document saved before has set what differs from the default; a default it never changed is unset, so the shader's sampler applies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…a texture In a compute shader's RESOURCES an "image" is a storage image since d2f8631 (C07); the sampled "rt" is a "texture". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
Since f081b2b (U34) an inlet's sampler fields are optional: an inlet never set, or saved before without a MipmapMode, has none, and the inspector shows "Default: None". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
A cubemap input without MIPMAP_MODE samples with linear mips since c98ed99. A one-level cube (what a raw raster CUBEMAP output publishes) is then mipmap-incomplete on OpenGL and reads black; Vulkan clamps to the view's levels. Fails on GL (0,0,0 for red), passes on Vulkan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
c98ed99 gave cubemap inputs linear mips by default so textureLod reaches a prefiltered cube's levels. A raw raster CUBEMAP output (cube copy and direct cube alike) has one level, and GL treats a mip-filtered sampler on a one-level texture as mipmap-incomplete: test-cubemap-output rendered black on GL (the NVIDIA driver says so: "does not have a complete set of mipmaps and cannot be used with a sampler needing mipmaps"), fine on Vulkan, which clamps to the view's levels. The input now takes the texture's mips (followTextureMips, as auxiliaries already do): Linear on a MipMapped texture, None otherwise, re-evaluated whenever the bound texture changes (retargetSampler). A mipmap mode the inlet sets still wins. Generating a mip chain for the cube copy instead would cost a generateMips per frame and a third more memory for every cube output, and would leave every other one-level cube (direct cube target, compute cube images, the empty placeholder) black on GL. Sampler gains two fields, so its structured bindings become member reads. GfxCubemapSixFaces runs its pixel oracle on GL too: the exemption ("headless GL does not render a layered multiview raster") was this bug; it passes on the RTX 4090 and on llvmpipe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
vertexshaderart's `touch` is a 32x240 RGBA float history, NEAREST, one row per frame with the newest at v = 0, each texel (x, y, pressure, time) in clip space (greggman/vertexshaderart src/js/main.js). score has no such source: an unconnected `touch` reads the zero placeholder, so every read is (0, 0, 0, 0) on every backend. A cabled texture read with raw texture() / texelFetch has v = 0 at the bottom on OpenGL and at the top on Vulkan. Adds PointerState, the per-output mouse / touch slots in vertexshaderart's layout, reachable from OutputNode::pointerState(), so that the test can script the pointers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
An unconnected `touch` image input on a VERTEX_SHADER_ART shader now reads the touch history of vertexshaderart.com (greggman/vertexshaderart, src/js/main.js): 32x240 RGBA float, NEAREST, clamp; column c is pointer c (0 = mouse or first finger), each texel (x, y, pressure, time) with x, y in clip space, y up; one row per frame, newest in row 0 at v = 0, v = 1 239 frames ago. SimpleRenderedVSANode keeps the history and uploads it each frame from the output's PointerState; the window output fills that from its mouse (button = pressure 1 on slot 0) and touch events. A texture cabled into `touch` replaces the history, as before. Raw reads of a VSA image input -- texture, textureLod, texelFetch, which is how WebGL art reads them -- now go through isf_vsa_uv / isf_vsa_texel, which flip v on SPIR-V: v = 0 is the bottom of the image on every backend, as IMG_NORM_PIXEL. A cabled image read with raw texture() was upside down on Vulkan relative to OpenGL, Metal and Direct3D (RowOrder.hpp). The history is stored in the same published row order, so it reads newest-at-v = 0 everywhere. The VSA sweep leaves `touch` uncabled, so it renders what a score shows rather than a picture standing for pointer data; Spiral-TRIdNCE1 is then blank at rest, as on the site before the mouse moves (its spiral sits at the centre, mostly off-screen). test_gfx_vsa_touch passes on Vulkan and OpenGL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
VSA art indexes the touch history by n / 240 (mouse-wip's fract(vertexId / 240.)), right on the edge between two rows. Texture units round coordinates to a few sub-texel bits and take either side; mirrored on SPIR-V by isf_vsa_uv they took the other one, so Vulkan read the row next to OpenGL's: on the 4090, texture(touch, 5 / 240) gave row 4 on OpenGL and row 5 on Vulkan, and mouse-wip drew a stray line on Vulkan only. isf_vsa_uv now moves a coordinate within 1/512 texel of an edge 1/128 texel up before mirroring: floor(v * h), the nearest formula, on every backend; linear reads move by at most 1/128 texel, only there. WindowDevice::setPointer(slot, x, y, pressure) sets a pointer of the output from a script, so that a grab can play a gesture into the touch history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
With dynamic plug-ins and -fvisibility=hidden, a class defined only in a header has a local typeinfo in each shared object, and dynamic_cast / safe_cast compare typeinfo by address on Linux: test_unit_curve_edition and test_unit_scriptable_controls could not cast the automation node and its curve, test_unit_avnd_dynamic_ports the LoadPresetWithCablesBackup command, and test_unit_plugin_path_settings tripped safe_cast's assert on PluginSettingsFactoryList. Export LoadPresetWithCablesBackup and PluginSettingsFactoryList with their plug-in's macro, as their exported siblings are, and bump libossia for ossia::nodes::automation and ossia::curve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
ScoreEditFuzzer did not build on macOS: thread ids and signalling went through syscall(SYS_gettid / SYS_tgkill), the forwarded-abort check used Linux's SI_TKILL, ucontext.h was included unguarded. Hangs were only reported through GNU timeout(1), which macOS does not ship. - The timeout is in the fuzzer (--timeout S, std::thread + condition_variable): past the deadline it prints every thread's stack, the GUI thread first, and exits 124, which run-edit-fuzz.sh reports as TIMEOUT. timeout(1)/gtimeout stays as a backstop when installed. - A forwarded external SIGABRT is flagged with an atomic instead of SI_TKILL; thread labels come from std::thread::id; sleeps are std::this_thread::sleep_for. - Listing threads has no portable API: /proc + tgkill on Linux, task_threads + pthread_from_mach_thread_np on macOS, GUI thread only elsewhere. backtrace()/dladdr only where <execinfo.h> exists; the stack scan stays Linux x86-64. Windows gets std::signal handlers and no POSIX headers. - Settings go to an ini file under the temporary config dir on every platform: XDG_CONFIG_HOME only isolated them on Linux. - run-edit-fuzz.sh symbolizes with atos when llvm-symbolizer is absent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
… shader format ef8accb gave an unconnected VSA `touch` input a pointer history that the renderer took from the output: implicit, VSA-only, invisible on the inlet. The pointers are now addresses of the window devices, as the mouse already was (cursor/gl, cursor/absolute): - cursor/clip: the mouse in clip space, [-1, 1], y up: vertexshaderart's `mouse` (src/js/main.js recordMouseMove, x * 2 - 1, y * -2 + 1; the site's help: "mouse position normalized (-1 to 1)"). On Window, MultiWindow (per window), Background and the offscreen device. - touch/points: 32 [x, y, pressure] in clip space, slot 0 the mouse or first finger, from the window's mouse and touch events (PointerState, main.js getTouchIndex / addTouchPressure); writable. - touch/history: a texture, TouchHistoryNode: the last 240 frames of touch/points, 32x240 RGBA32F (x, y, pressure, time), newest at v = 0, NEAREST: vertexshaderart's `touch` (main.js:665-672). An ordinary texture source, as a camera's. setupISFModelPorts, shared by ISF, VSA and the render pipeline, binds by default, on the inlet where it shows and can be changed: an image input named `touch` to touch/history with a 32x240 RGBA32F NEAREST clamped target; `mouse` to cursor/clip in a VSA (the format defines it so), cursor/gl elsewhere, as before. The VSA renderer has no touch code anymore; nothing connected reads the empty placeholder. WindowDevice::setPointer writes touch/points (and cursor/clip, cursor/gl for slot 0). w is the seconds since the history node was created, one clock for every output; vertexshaderart writes the art's `time` there. An ISF reads the history with IMG_NORM_PIXEL_PREMULTIPLIED / IMG_PIXEL_PREMULTIPLIED: IMG_NORM_PIXEL returns straight colour (divides by alpha, the time). Tests: test_gfx_vsa_touch (history read by a VSA and by an ISF, nothing connected is no history, cabled texture orientation), test_unit_shader_pointer_binding (default addresses and target in VSA and ISF, none without a window), test_gfx_window_device_tree (cursor/clip, touch/points from mouse move / press / release, touch/history is a texture). Before: the binding and tree cases failed, "nothing connected" read a history. Pass on Vulkan 4090, OpenGL 4090, OpenGL llvmpipe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
GfxCameraArrayFaces and GfxCubeFaceOrientation skipped their MULTIVIEW pixel cases on OpenGL, citing a GfxMultiview.cpp:64-77 black readback that 54c793f fixed (MULTIVIEW shaders compile and render on OpenGL). They now run there; only a backend without multiview (and, for the camera array, without the per-view lowering) or Null takes the structural lane. Pass with SCORE_TEST_API=opengl on the 4090 (:0, prime offload) and llvmpipe (Xvfb), and on Vulkan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
An INPUTS `image` with ACCESS or FORMAT is a storage image (isf.cpp INPUTS parse, since 4e5917f): an image2D read with imageLoad. A shader sampling it with the IMG_* macros, texture() or texelFetch() got the GLSL compiler's "no matching overloaded function". The ISF and raw raster parsers now refuse it: 'INPUTS "img" is a storage image because it declares ACCESS or FORMAT, and IMG_NORM_PIXEL() samples it. A storage image is read with IMG_LOAD(img, pixel) or imageLoad(img, ivec2 pixel). To sample it instead (...), remove ACCESS and FORMAT from its INPUTS entry.' test_unit_isf_storage_image_sample: 12 read / declaration combinations on ISF and raw raster, failed before ("parse accepted"), pass after; imageLoad and sampled images, and names containing the storage image's, still parse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
test_unit_curve_edition failed 19 or 20 cases on the Mac, every one
"child died with signal 6". The scenarios run in a child forked from the
GUI test process (ForkProbe), and macOS aborts such a child once it
touches the file manager without exec(): the child's crash report shows
DocumentBackupManager::updateBackupData -> QSettings sync -> QSaveFile ->
QFileInfo::isSymLink -> CFURLCopyResourcePropertyForKey -> CarbonCore
USING_FORK_WITHOUT_EXEC_IS_NOT_SUPPORTED_BY_FILE_MANAGER ("multi-threaded
process forked"). Whether a child got that far depended on timers, hence
19 or 20. Not a curve bug: in process, all 95 cases pass on the Mac (3
runs). Linux still forks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…ass route GfxIsfDepthTravel reads back the depth a shader writes through the next shader's _depth companion: one pass, a chain that rewrites it, a last pass drawn into the destination, a last pass with its own target (own size, single-channel FORMAT, PERSISTENT; one pass and the last of two), OUTPUTS colour + depth, a raw raster through 0/1/2 forwarders, and several writers into one port, which must depth-test against each other in either order. A hidden [.perf] case prints the frame time and GPU allocations of plain shaders. Today the own-target, OUTPUTS and copied-producer cases fail, the same way on Vulkan, OpenGL (4090) and llvmpipe. The depth case of GfxIsfLastPassSettings declares its PIPELINE_STATE and checks the depth layer-view.fs reads, not only that an attachment exists: without the state the ISF quad's depth test is off and no backend but Metal writes the depth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…ts own target A last pass with its own target (its WIDTH / HEIGHT, a single-channel FORMAT, PERSISTENT) and the colour OUTPUTS of a shader with a depth OUTPUT were copied into the destination colour only, so a reader's _depth saw the cleared depth. When the shader declares DEPTH_TEST and DEPTH_WRITE for that pass and the destination has a depth attachment, the copy now samples the depth (nearest sampler) and writes it as gl_FragDepth: * RenderedISFNode: the last pass's own target(s), both halves of a PERSISTENT pair, get a samplable depth; createFinalPass picks the depth of the same half as the colour. * SimpleRenderedISFNode: each colour OUTPUT edge's copy carries the depth OUTPUT (not a layered one). The copy keeps the pass's DEPTH_COMPARE rather than always, so a copied producer depth-tests against the other producers of its port as a pass drawn there does; a pass declaring always gets a compare-always copy. The direct last-pass path of 597532e is unchanged. Nothing changes for a shader that writes no depth, or one that does without declaring the state, which the engine does not turn on: it now warns once that Vulkan and OpenGL drop that depth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
QRhi::statistics() and QRhiStats arrived in Qt 6.6; the Coverage job builds the tests against 6.4.2 and its portability guard refused the file. Below 6.6 the allocation columns of the [.perf] printout read 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…t 6.12+ QRhiBuffer::IndirectBuffer arrived in Qt 6.12; the FreeBSD job builds against an older Qt and failed on IndirectCountClamp.cpp. Guarded the same way as allocateSsbo in IsfBindingsBuilder.cpp: below 6.12 there is no drawIndirect path that would consume the clamped buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
ShaderSamplerSource.hpp included <isf.hpp>, and TexturePort.hpp includes it; addons (score-vfx-template, score-avnd-simple-template) build against TexturePort.hpp without libisf on their include path and failed with "'isf.hpp' file not found". The header only needs pointers, so it forward-declares isf::descriptor and isf::sampler_config; the two files that use their members include <isf.hpp> themselves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
…6.12+ QRhiTextureRenderTargetDescription::setDepthLayer arrived in Qt 6.12; the Nix job builds against an older Qt and failed. Guarded like the per-layer path further down the same file: below 6.12 the iteration falls through to a scratch depth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
QRhiResource::rhi() arrived in Qt 6.5; the Ubuntu noble job builds against 6.4.2 and failed on seven new call sites. resourceRhi() returns null there, and each caller already had a device-independent fallback (the requested topology, a 256-byte offset alignment). The retirement assert is guarded like master's own at RenderList.cpp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
The Coverage job builds against Qt 6.4.2. Checked every source and test file the build compiles against the system's 6.4.2 headers, with master as the baseline (master's files are clean there); these were the only new errors: - Utils.cpp: QRhiVulkanNativeHandles comes from qrhi_platform.h, or qrhivulkan_p.h before 6.6, included like RhiTextureReadback.cpp; its `inst` exists from 6.6, below that the shared instance is used as in HWAccelSetup.cpp; the zero upload is built from the QByteArray (6.4 takes an int size). - four new tests include <rhi/qshader.h> / <rhi/qrhi.h>, which 6.4 does not have: use the private headers, like master's tests. - IndirectCountClampTest asks for IndirectBuffer only on 6.12+; GfxCsfImageFormat checks the integer formats only on 6.10+, matching the engine's own guards; GfxCsfStorageTooLarge queries the Vulkan limit only on 6.6+. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex
jcelerier
force-pushed
the
bugfix/shader-ledger
branch
from
October 28, 2026 03:01
a2868bf to
b3ba415
Compare
github-merge-queue
Bot
removed this pull request from the merge queue due to a conflict with the base branch
Oct 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes and spec work on the shader pipeline (ISF, CSF, VSA, raw raster, scene), found by auditing libisf and the gfx renderers against the shader libraries, on Vulkan and OpenGL (RTX 4090, llvmpipe) and Metal (M2), with validation layers on.
Depends on ossia/libossia#967 (exports
ossia::nodes::automationandossia::curveso shared-plugin builds can cast them). The Qt patches found along the way are in ossia/sdk#52; this branch builds and passes without them.Changes in behaviour
imageis always a storage image andtextureis sampled;texturegains IS_ARRAY, STATIC and CUBE. INPUTS in a compute shader keep their old meaning, with a deprecation warning. Saving a compute shader writes RESOURCES.cursor/clip,touch/points,touch/history), bound by default the same way in every shader format.touch/historyis vertexshaderart's 32x240 history; VSAmousereadscursor/clip(-1..1, y up) as on vertexshaderart.texture()/texelFetch()in VSA shaders read the same row on every backend.Rendering
gl_NumWorkGroupsis flagged; the HLSL path needs qt-patches: HLSL gl_NumWorkGroups, Metal per-call buffer sizes, Vulkan render-pass sync sdk#52.Performance
Other
Tests
Results: Linux full gfx + unit + threedim suite 625/625 (Vulkan on the 4090, OpenGL on the 4090 and llvmpipe), excluding soak / golden and
test_unit_faust_dsp(its link failure against the SDK's libfaust is on master too). Metal tests for the touched areas pass on the M2 with MTL_DEBUG_LAYER. The 39 shader-sweep entries still failing are the same on master 7391d18.Known, not in this PR:
obj-cubegolden (the Light projection's shading is wrong on master and in the golden; separate fix).🤖 Generated with Claude Code
https://claude.ai/code/session_01JeL19ofVgPe9u9q7J1V9ex