Skip to content

Shader pipeline: libisf and gfx renderers audited against the shader libraries - #2364

Merged
jcelerier merged 141 commits into
masterfrom
bugfix/shader-ledger
Oct 28, 2026
Merged

jcelerier merged 141 commits into
masterfrom
bugfix/shader-ledger

Conversation

@jcelerier

Copy link
Copy Markdown
Member

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::automation and ossia::curve so 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

  • isf: in a compute shader's RESOURCES, image is always a storage image and texture is sampled; texture gains IS_ARRAY, STATIC and CUBE. INPUTS in a compute shader keep their old meaning, with a deprecation warning. Saving a compute shader writes RESOURCES.
  • isf: sampling an INPUTS image that ACCESS / FORMAT make a storage image is refused with a message saying how to fix it.
  • gfx: a texture input's sampler is resolved field by field (filter, address mode, mipmaps): the user's setting, then the shader's JSON, then the default. The inspector shows "Shader: X" / "Default: X". Old documents keep the value they saved.
  • gfx: mouse and touch are explicit Window addresses (cursor/clip, touch/points, touch/history), bound by default the same way in every shader format. touch/history is vertexshaderart's 32x240 history; VSA mouse reads cursor/clip (-1..1, y up) as on vertexshaderart.
  • gfx: raw texture() / texelFetch() in VSA shaders read the same row on every backend.
  • isf: header keys are case-independent, one spelling per key, the header may follow a licence comment and use JSON5-style comments; unknown TYPEs, PIPELINE_STATE values, EXTENSIONS and names that clash with generated code are parse errors.
  • the scene's per-channel texture pool aliases are back, with a deprecation warning.

Rendering

  • isf: the last pass of a single- or multi-pass ISF keeps its own size, format and filter; a four-channel FORMAT, FLOAT or FILTER last pass still draws straight into the destination.
  • isf: declared depth reaches the next node on every route: direct, a last pass with its own size / single-channel FORMAT / PERSISTENT (the copy samples its depth and writes it), and each MRT output edge. A copied producer depth-tests against the other producers in the same port. A shader that writes depth without declaring DEPTH_TEST / DEPTH_WRITE gets a one-time warning; depth stays off unless declared. Shaders that write no depth get no extra target, attachment, pass or sampler (frame times and GPU allocations unchanged).
  • cubemap inputs without MIPMAP_MODE follow their texture's mips (black on OpenGL before).
  • CSF: storage image formats, sizes and history images; INDIRECT dispatch and draw records; storage buffers up to the device limit; byte / half / integer / struct / bool / matrix attributes decoded and published as what they are; uniform and storage inputs bind their cable's published range.
  • raw raster: per-iteration targets keep every colour attachment, honour SAMPLES, write their depth output, generate mips; MSAA_SAMPLES; draw counts and lowered multiview.
  • VSA: LINE_LOOP closes; event inputs fire for one frame; header defaults follow the shader; no triangle fans reach a backend without them.
  • scene: scene_data payloads as auxiliaries, GPU-resident primitive clouds, instance-group tags, scene filter matching, draw-count fallbacks on Metal, recycled buffer addresses.
  • Metal: CULL_DISTANCES refused with one warning; STENCIL_TEST into a target without stencil dropped instead of aborting.
  • D3D: gl_NumWorkGroups is flagged; the HLSL path needs qt-patches: HLSL gl_NumWorkGroups, Metal per-call buffer sizes, Vulkan render-pass sync sdk#52.

Performance

  • shader file family read from the header only (5612 files: 114 ms vs 186 ms).
  • buffer readbacks are counted; a GPU-only particle chain issues none.

Other

  • vst: a control of a VST that is not installed gets a slider (the debug build asserted).
  • threedim: Model Display Far defaults to 1000; blend operation labels.

Tests

  • every shader of the user libraries parses; library 3D viewers, scene shaders, material presets and delayed self-cables are exercised.
  • new gfx tests for the above, including touch history, window device tree, cubemap mips, multiview faces on OpenGL, ISF depth routes.
  • the edit fuzzer builds and reports hangs without Linux-only calls (now runs on macOS); curve edition scenarios run in process on macOS.
  • the threedim renders run their scene again at the goldens' size.

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-cube golden (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

@coveralls

coveralls commented Oct 27, 2026 •

Copy link
Copy Markdown

Coverage Status

Coverage is 49.18% — bugfix/shader-ledger into master. No base build found for master.

@jcelerier
jcelerier marked this pull request as ready for review October 27, 2026 16:58
jcelerier and others added 26 commits October 27, 2026 22:47
…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
jcelerier and others added 25 commits October 27, 2026 22:47
…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
jcelerier force-pushed the bugfix/shader-ledger branch from a2868bf to b3ba415 Compare October 28, 2026 03:01
@jcelerier
jcelerier added this pull request to the merge queue Oct 28, 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 28, 2026
@jcelerier
jcelerier merged commit b3ba415 into master Oct 28, 2026
39 of 42 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.

2 participants