Table of Contents

Class SpirvCombinedSamplerPairs

Namespace
ShadowDusk.Core.Reflection
Assembly
ShadowDusk.Core.dll

Derives, in exactly the order SPIRV-Cross declares them, the combined-sampler uniforms that the SPIR-V → GLSL transpile will emit — purely managed, straight from the SPIR-V words.

Why this exists. The MonoGame OpenGL runtime binds a texture unit to a sampler by GLSL uniform NAME (glGetUniformLocation("ps_s{k}")), and MonoGameGlslRewriter names those ps_s{k} in emitted-declaration order. SPIRV-Cross emits one combined sampler per (texture, sampler) pair, so neither the reflected texture list nor the reflected sampler list is the list the .mgfx sampler table has to mirror: N textures read through one shared SamplerState is N uniforms but one reflected sampler, and the mirror shape (one texture, N samplers) is N uniforms but one reflected texture. Keying the table on either list silently binds the wrong texture (Phase 51 A7).

Why not just call SPIRV-Cross. The pass already computes this list, but spvc_compiler_get_combined_image_samplers is not one of the 11 functions src/ShadowDusk.Wasm/wwwroot/spirv-cross/spirv-cross.wasm exports, and that module is an out-of-band emscripten build. Reading it natively would fix the desktop path only and break the CLI-vs-WASM byte-identity promise for this shape. Deriving it from the SPIR-V is host-independent by construction, the same reason RdefReader and SpirvReflector exist.

The ordering rule is transcribed, not guessed (read from the pinned SPIRV-Cross tree the WASM module is built from, tag vulkan-sdk-1.4.335.0):

  • Compiler::build_combined_image_samplers (spirv_cross.cpp) runs traverse_all_reachable_opcodes from the single entry-point function: blocks in binary order, ops in binary order, recursing into each OpFunctionCall target with a parameter-to-argument remapping pushed for the callee's scope.
  • The only trigger is OpSampledImage. Its image and sampler operands are resolved back to global variables through remap_parametermaybe_get_backing_variable (back through OpLoad / OpAccessChain), and the pair is appended if not already present. So the order is first-use order, deduplicated — unrelated to declaration order, bind slots, or binding numbers.
  • The synthesized variable ids come from ir.increase_bound_by(2), so they are monotonic in first-use order and sort after every original module id; CompilerGLSL::emit_resources walks variables in id order and skips every separate image/sampler when vulkan_semantics is off. Hence emitted declaration order IS first-use pair order.

The emitted GLSL cannot be used instead. SPIRV-Cross's readable SPIRV_Cross_Combined<Image><Sampler> name is applied by its command-line tool, not by the pass, so through the C API the uniforms arrive as bare _<id> (uniform sampler2D _40;) carrying no pair identity at all.

Every shape this model does not cover fails loudly with SD0217 rather than producing a plausible-but-wrong order, because a subtly wrong order is exactly the silent mis-bind this class exists to eliminate.

public static class SpirvCombinedSamplerPairs
Inheritance
SpirvCombinedSamplerPairs
Inherited Members

Methods

Extract(ReadOnlyMemory<byte>)

Extracts the combined-sampler pairs in SPIRV-Cross declaration order. An empty list is a valid result (a shader that samples nothing).

public static Result<IReadOnlyList<CombinedSamplerPair>, ShaderError> Extract(ReadOnlyMemory<byte> spirvBlob)

Parameters

spirvBlob ReadOnlyMemory<byte>

Returns

Result<IReadOnlyList<CombinedSamplerPair>, ShaderError>

ResolveSlots(IReadOnlyList<CombinedSamplerPair>, IReadOnlyDictionary<string, int>?, IReadOnlySet<int>?)

The GL texture unit each pair must bind to, indexed by SPIRV-Cross declaration order — the SINGLE definition of that rule, shared by the GLSL rewriter (which names the uniforms ps_s{slot}) and the .mgfx sampler table (whose records must name those same uniforms). Two callers deriving it independently is exactly how a table and the GLSL it describes drift apart, so both go through here.

The rule, in one sentence: in texture-declaration order, a pair whose sampler declared an explicit register takes it, and every other pair takes the lowest register not already taken and not reserved by a modern SamplerState : register(sN).

That single rule reproduces every shape measured against the pinned mgfxc, and it is why the legacy form is not a special case: compiling for OpenGL means compiling at ps_3_0, where a texture and a sampler are ONE object in ONE register namespace. A legacy sampler X : register(sN) IS that combined object, so it lands on N. A modern SamplerState is a sampler-only object that still occupies its register, so the combined samplers fxc synthesizes are allocated around it — which is why one texture plus SamplerState S : register(s0) yields ps_s1, not ps_s0.

Two pairs sharing one texture (the linear+point idiom) need no special handling either: they share a declaration index, neither carries its own explicit register, and pass 2 hands them consecutive free registers — the same 0/1 that shipped before issue #189. That fallback is deliberate rather than derived: no mgfxc golden exists for the shape, so the previous positional numbering is kept instead of inventing an answer.

It is pinned at record level, NOT by a render, and the distinction matters: MonoGame 3.8.2's GL backend has no sampler objects and applies filtering with glTexParameteri on the bound texture, so one texture object on two units collapses to whichever filter was applied last — the shape is un-renderable as a visible distinction in that runtime. The guarantees are held by OpenGl_OneTextureTwoSamplers_BakesEachPairsOwnSamplerState and OpenGl_OneTextureTwoSamplers_NamesEverySamplerUniformTheGlslDeclares. validation/SamplerPairsGl arm B does not cover this shape (its fixture SamplerPairMirror.fx uses two DISTINCT textures, and its identical pixels make it blind to slot assignment anyway — the retracted claim behind issue #189).

public static IReadOnlyList<int> ResolveSlots(IReadOnlyList<CombinedSamplerPair> pairs, IReadOnlyDictionary<string, int>? explicitSlots = null, IReadOnlySet<int>? reservedSlots = null)

Parameters

pairs IReadOnlyList<CombinedSamplerPair>
explicitSlots IReadOnlyDictionary<string, int>
reservedSlots IReadOnlySet<int>

Returns

IReadOnlyList<int>