The shape below is what the build package generates. Changes to it are listed in the changelog.
Adding shaders to a project
Reference the RecursiveAI.OneShader.Build package. Every *.hlsl file in the project becomes a
OneShader item and is compiled during dotnet build (turn the default glob off with
<EnableDefaultOneShaderItems>false</EnableDefaultOneShaderItems> and list items yourself).
<ItemGroup> <PackageReference Include="RecursiveAI.OneShader.Build" Version="..." /> </ItemGroup>
The package brings RecursiveAI.OneShader (the support types) with it. Build with dotnet build: the
task is a .NET task, so Visual Studio's .NET Framework MSBuild cannot run it. To use a local build
instead of NuGet, tools/pack-local.sh packs both packages into a local folder (.feed by default);
samples/ has projects that restore from it, and one (samples/LocalSource) that references the
repository's projects directly. Only projects that contain shaders reference the build package;
it does not flow to their consumers.
Entry points are found from [shader("vertex")], [shader("pixel")] and [shader("compute")]
attributes. Item metadata can list them explicitly instead.
Item metadata, all optional: EntryPoints="VSMain:Vertex;PSMain:Pixel", Defines="A;B=2",
IncludeDirectories="dir;dir" (searched after the including file's folder), Namespace, ClassName,
Targets, and for variants Axes and MaxVariants (see Variants below).
Project properties: OneShaderNamespace (default $(RootNamespace).Shaders), OneShaderDefines,
OneShaderIncludeDirectories, OneShaderMaxVariants. Listing a file as a OneShader item (to give it metadata) is
fine: the default glob skips files that are already listed. A shader is compiled again only when it, a file it includes, the
options or the compiler changed.
Errors and warnings are ordinary MSBuild diagnostics on the HLSL line:
Shaders/Sprite.hlsl(12,17): error OSH0300: 'Atlass' is not declared. Did you mean 'Atlas'?What is generated
One static partial class per HLSL file, named after the file (see Names below), in the namespace
<RootNamespace>.Shaders (overridable). Nothing uses reflection or file I/O; output is
deterministic.
- Constant buffers: one blittable struct per
cbuffer/ConstantBuffer<T>, named after it, with[StructLayout(LayoutKind.Explicit)], auint Sizeconstant, and field offsets equal to the offsets in the compiled shader. Float vectors and matrices useSystem.Numericstypes. - Bindings: a nested
Bindingsclass with, per resource,const uint Set,Binding,Countandconst ResourceKind Kind, plusAll, a span ofResourceBindingthat also says which stages use each resource. See Resource arrays and Append and consume buffers below. - Entry points: one nested class per entry point, named after the function, with
Stage,EntryPoint, andSpirV(aReadOnlySpan<byte>over data embedded in the assembly).- Vertex entry points add a blittable
Vertexstruct,uint Stride, andInputs, a span ofVertexAttribute(location,VertexFormat, offset). - Compute entry points add
ThreadGroupSizeX,ThreadGroupSizeYandThreadGroupSizeZ. - With the Msl or Wgsl target selected, entry points also have
MslandWgslmembers.
- Vertex entry points add a blittable
- Reflection: the file's class has
static ShaderReflection Reflection, the reflection the rest of the class was generated from (resources with slots and layouts, entry points, vertex inputs, thread-group sizes). It is stored as text in the assembly (ReadOnlySpan<byte> ReflectionData, about 1 to 4 KB per shader) and parsed on the first read, so a program that never reads it carries no parser after trimming. Hot reload compares it with a fresh compile; see "Hot reload" in theRecursiveAI.OneShaderpackage README. - All counts, sizes, strides, offsets, locations and thread-group sizes are
uint, matching the Vulkan API.
Push constants
A struct-typed global or ConstantBuffer<T> marked [[vk::push_constant]] is a push-constant block, not a descriptor:
struct PerDraw { float4x4 Model; float4 Tint; uint Index; }; [[vk::push_constant]] ConstantBuffer<PerDraw> Draw; // or: [[vk::push_constant]] PerDraw Draw;
The generated class gets a blittable PushConstants struct (with Size and the SPIR-V member offsets) and
Bindings.PushConstants with the delivery information; the block is not in Bindings.All.
public static class PushConstants { public const uint Size = 96u; public const ShaderStages Stages = ShaderStages.Vertex | ShaderStages.Pixel; public const uint MslIndex = 0u; // when Msl is selected public const uint WgslGroup = 0u, WgslBinding = 2u; } // when Wgsl is selected
- Vulkan:
vkCmdPushConstants(cmd, layout, (VkShaderStageFlags)Bindings.PushConstants.Stages, 0, Size, &data); the pipeline layout's range is offset 0,Sizebytes, those stages. Vulkan only guarantees 128 bytes; a larger block is warning OSH0334. - Metal:
setVertexBytes/setFragmentBytes(orsetBytesfor compute) with the struct's bytes atMslIndex. - WebGPU: a uniform buffer at
@group(WgslGroup) @binding(WgslBinding), at leastSizerounded up to 16 bytes long. The binding is the one after the highest binding of the highest bind group the shader uses, so it never collides. - Members follow the vector-relaxed std430 rules DXC uses for push constants (a
float3may share a 16-byte row with the next scalar), not the constant-buffer rules; the same bytes are valid on every target. - A file may declare several blocks as long as no entry point reads more than one (OSH0333). With one block the struct and
the
Bindingsclass are namedPushConstants; with several, each is named after its variable,<Name>PushConstants.
Resource arrays
Texture2D<float4> Tex[4], SamplerState Smp[2], arrays of structured buffers and ConstantBuffer<T> Cb[2] are
bounded arrays of resources. An array is one descriptor binding with a descriptor count, as in DXC's
SPIR-V (the next unnumbered resource gets the next binding number, not Binding + Count).
public static class Tex { public const uint Set = 0, Binding = 0, Count = 4; public const ResourceKind Kind = ResourceKind.SampledTexture; } // All contains: new ResourceBinding("Tex", 0, 0, 4, ResourceKind.SampledTexture, ShaderStages.Compute)
Count is 1 for a single resource. The shader may index an array with a constant or a run-time value (pass the
elements to helper functions too); NonUniformResourceIndex(i) marks an index that differs between invocations.
Run-time indices need the Vulkan device features shaderSampledImageArrayDynamicIndexing,
shaderStorageBufferArrayDynamicIndexing, shaderUniformBufferArrayDynamicIndexing and
shaderStorageImageArrayDynamicIndexing (the compiled shader declares the matching capability for the kinds it
indexes), and shaderSampledImageArrayNonUniformIndexing / shaderStorageBufferArrayNonUniformIndexing for
non-uniform indices. Unbounded arrays (Tex[]) are an error (OSH0350); multi-dimensional resource arrays are
an error too.
Append and consume buffers
AppendStructuredBuffer<T> and ConsumeStructuredBuffer<T> take two descriptors: the element buffer and a hidden
4-byte counter buffer in the same set. The counter holds the number of elements: Append adds one and stores at
the old value, Consume subtracts one and reads the element below the old value. GetDimensions reports the
element buffer's capacity and stride, not the counter. The application owns both buffers: bind a 4-byte (or
larger) storage buffer at the counter binding, zero it to start an append list empty, and write the element count
into it to start a consume list full.
The counter binding is [[vk::counter_binding(n)]] if given; otherwise it is the next free binding of the buffer's
set after the resources declared so far (the same numbering DXC uses), so AppendStructuredBuffer<T> A : register(u0)
next to B : register(u1) puts A's counter at 2 and B's at 3 if those are free.
public static class Hits { public const uint Set = 0, Binding = 2, Count = 1; public const ResourceKind Kind = ResourceKind.StorageBuffer; public const uint CounterBinding = 3; // same Set } // All contains the buffer and, right after it, its counter: // new ResourceBinding("Hits", 0, 2, 1, ResourceKind.StorageBuffer, ShaderStages.Compute) // new ResourceBinding("Hits.counter", 0, 3, 1, ResourceKind.StorageBuffer, ShaderStages.Compute)
A resource without a counter has no CounterBinding constant. Arrays of append or consume buffers are an error.
RWStructuredBuffer.IncrementCounter and DecrementCounter are not supported yet.
The names Reflection and ReflectionData are taken in the file's class, so a constant buffer, struct or entry point
with one of those names is a name-clash error.
Member types in generated structs:
float,int,uintand the other scalars map to the C# type of the same width;boolis auint(HLSL stores it as a 32-bit word).float2/3/4areVector2/3/4. Vectors of other element types have noSystem.Numericstype and become one field per component (Offset_X,Offset_Y).- Arrays become one member per element (
Weights_0,Weights_1), so the struct needs no unsafe code. - Nested HLSL structs become nested C# structs with the same rules. If one HLSL struct appears under
two packings (a constant buffer and a structured buffer), the second gets a
_1suffix. - Matrices are
Matrix4x4members, for every size up to 4x4 (elements outside the HLSL shape stay zero). A row-major 4x4 is a plain field; the other shapes are properties over private storage that does the arranging, so element (i, j) isM[i][j]either way. - Each
StructuredBuffer<T>/RWStructuredBuffer<T>whoseTis a struct also gets a C# struct, named afterT, withSizeequal to the element stride. - Vertex fields follow the format:
Float32x2is aVector2; packed formats (Unorm8x4and the like) are auintorulongholding the raw bits; integer and half vectors are one field per component.
The support types (ShaderStage, ResourceKind, VertexFormat, VertexAttribute,
ResourceBinding) live in RecursiveAI.OneShader, which the build package brings in, so a
rendering layer in another assembly can accept them. When only these types are used, trimming
removes the compiler from a NativeAOT app.
Specialization constants
A file that declares [[vk::constant_id(N)]] const T Name = default; constants gets, next to the bindings:
public static class SpecializationConstants { public static class Rounds { public const uint Id = 1u; public const int Default = 4; } /* ... */ } public struct Specialization { public uint UseOffset; public int Rounds; public uint Mask; public float Scale; // 4 bytes each, declaration order; a bool is 0 or 1 public static Specialization Defaults { get; } public SpecializationInfo ToVulkan(ReadOnlySpan<uint> ids = default); // map entries + data for VkSpecializationInfo public IReadOnlyList<FunctionConstantValue> ToMetal(ReadOnlySpan<uint> ids = default); public IReadOnlyDictionary<string, double> ToWebGpu(ReadOnlySpan<uint> ids = default); } // keys: decimal ids public static class CSMain { public static ReadOnlySpan<uint> SpecConstantIds { get; } /* ids this entry point reads */ }
Pass the entry point's SpecConstantIds to the To... methods so a pipeline gets only the constants its entry point reads (WebGPU rejects others; Vulkan and
Metal ignore them). CSMain.Layout.SpecConstants lists them with names, types and defaults, and SpecializationValues.For(CSMain.Layout) is the same data
driven by names (Set("Rounds", 7)) instead of struct fields. Entry points that read no constant have no SpecConstantIds. Names that would clash with a
member of Specialization (Size, Count, Defaults, ...) are reported by the generator. Metal hosts: a function with function constants must be created with
makeFunction(name:constantValues:) even to get the defaults (see the MSL backend notes). CompileOptions.SpecializationOverrides (runtime compiler API) fixes
constants at compile time instead.
In a variant file the same SpecializationConstants and Specialization types are generated inside each variant (Variants.<Name>.Specialization), because the set of constants can differ per variant; the aggregate Entry.SpecConstantIds returns the ids the looked-up variant reads.
Variants
A OneShader item with Axes="ALPHA_TEST;LIGHTING=NONE|LAMBERT|PBR", or OneShaderVariant items naming the shader,
is compiled once per define set (see the build package readme for the MSBuild side). The file's class then has this shape
instead of the single-variant members Bindings, Reflection, Pipeline and the entry-point classes at file level
(those move into each variant):
| Member | Meaning |
|---|---|
Frame, PushConstants, <Entry>Vertex, other structs | generated once when every variant agrees on the layout |
<Axis>Value | enum per choice axis (LightingValue { None, Lambert, Pbr }); flags are bool; named sets use VariantName |
Key | readonly struct, one property per axis, Index (-1 for an enum value out of range), equality |
Variant | Index, Name, Defines, Key, Bindings, Reflection, Pipeline (when any variant has one) and one Entry property per entry point |
Entry | Stage, EntryPoint, SpirV, Msl, MslFunctionName, MslVertexBufferIndex, Wgsl, WgslEntryPoint, Stride, Inputs, ThreadGroupSizeX/Y/Z, SubgroupOperations, RequiredFeatures, Layout (each only when some variant has it) |
Variants | Count, All, Get(Key), Get(int), TryGet(Key, out Variant), and one static class per variant (Variants.AlphaTestOn_LightingPbr) shaped like a single-variant file's class |
Variant order is the cartesian product with the first axis changing slowest; Key.Index is the position in Variants.All.
A flag axis contributes <Axis>On/<Axis>Off to the variant class name, a choice contributes <Axis><Value>, joined by _;
all-capitals macro names and values are PascalCased (ALPHA_TEST gives AlphaTest). Lookup is an array index and the blobs are
returned through switches over constants: no allocation, no reflection, trim- and AOT-clean.
When variants disagree about a struct. Two variants may lay out a cbuffer (or push block, or vertex input) differently, for
example because a member sits under #if. Then no shared type is generated for that name; each variant's class declares its own
(Variants.<Name>.Frame, Variants.<Name>.VSMain.Vertex), matching that variant's reflection. Resource slots that differ between
variants are not an error: read them from Variant.Bindings and Variant.Pipeline.
Pipeline layouts
Each entry-point class has Layout, an OneShader.Layouts.EntryLayout with the resources that entry point uses (with its own stage
only), its push-constant block, its vertex input and its thread-group size, with the slot on every selected target. The file's class has
Pipeline when the file has exactly one vertex and one pixel entry point, or exactly one compute entry point, and no cbuffer, struct or
entry point of that name. For other combinations, merge entry points yourself with PipelineLayout.TryCreate, or with
PipelineLayout.TryCreateUnion when one layout must serve many entry points, several per stage.
public static class VSMain { public static EntryLayout Layout { get; } /* ... */ } public static PipelineLayout Pipeline { get; } // Create(VSMain.Layout, PSMain.Layout)
Both are built on first use from constant arrays (no reflection parsing) and cached. Pipeline.ToVulkan(), TryToMetal() and
TryToWebGpu() convert to the target's description; see the RecursiveAI.OneShader readme.
Names
C# warns about type names made only of lower-case ASCII letters (CS8981), which is an error in
projects that treat warnings as errors. So every generated type name (the file's class, constant
buffers, structs, the classes under Bindings, entry-point classes) follows one rule:
- A name with an upper-case letter keeps its spelling:
Quad,Tile,UiRect,Frame. Characters that cannot be in an identifier (-,., space) become_:UI-rectgivesUI_rect. - Any other name is turned into PascalCase: split at
_and at characters that cannot be in an identifier, upper-case the first letter of each part, join.spritegivesSprite,msdf_textgivesMsdfText,ui-rectgivesUiRect,vs_maingivesVsMain. - A name that starts with a digit gets a
_in front:2d_spritegives_2dSprite. - The
ClassNameitem metadata is used exactly as written.
Strings are never changed: EntryPoint is still "vs_main", and Bindings.All lists the HLSL
resource names. Members (struct fields, properties, constants) keep their HLSL spelling; a C#
keyword is escaped with @ (@class). Two names that map to the same C# name, such as ui-rect
and ui_rect, are a build error that says to rename one or set ClassName.
16-bit and 64-bit members
A buffer member of a sized type is generated at its DXC offset with the matching C# type: half is System.Half, int16_t short, uint16_t ushort,
int64_t long, uint64_t ulong, double double (min-precision types without 16-bit types are float/int/uint). Vectors of these have no
System.Numerics form, so they are one field per component: half3 b becomes b_X, b_Y, b_Z (each at its own offset). A matrix of a 16-bit or 64-bit element is an
error that says to split it (only float matrices up to 4x4 are generated). Each entry class gets RequiredFeatures (a DeviceFeatures constant) beside SubgroupOperations,
and its Layout carries it; enable the Vulkan features it names (or request shader-f16 on WebGPU) when creating the device. Turn the types on with
<OneShaderEnable16BitTypes>true</OneShaderEnable16BitTypes> or Enable16BitTypes="true" on a OneShader item.
Layout rules
- One byte layout per buffer on every target: HLSL packing as used for Vulkan (relaxed std140
for constant buffers, relaxed std430 for structured buffers);
packoffsetand[[vk::offset]]are honoured. The same C# struct is valid for every backend. - Matrices are
column_majorby default, as in HLSL;row_majorand#pragma pack_matrixare honoured. The generated C# member is arranged so that element (i, j) in C# isM[i][j]in HLSL whichever packing the shader uses. - Vulkan descriptor slots:
[[vk::binding(binding, set)]]wins. Otherwise set is the register space and binding is the register number. Two resources on the same slot (for examplet0ands0) are a build error that says to add[[vk::binding]]or renumber. Resources with neither get set 0 and the lowest binding number not taken by an explicit slot, in declaration order (what DXC does). Plain globals outside anycbuffercollect in an implicit constant buffer namedGlobals(_Globalsif that name is taken). - Vertex input locations follow declaration order unless
[[vk::location]]is given. Formats follow the shader type; a packed format is declared on the input field with[[oneshader::format("unorm8x4")]](anyVertexFormatname, case-insensitive; the component count and integer/float kind must match the declared type, e.g.float4forunorm8x4). The shader sees the convertedfloat4. DXC compiles the same file with a warning ("unknown attribute 'format' ignored") and no error. Offsets are packed in declaration order, so the vertex struct follows the format as described under "Vertex fields follow the format".
Example
// Shaders/Sprite.hlsl cbuffer Frame : register(b0) { float4x4 ViewProjection; }; Texture2D<float4> Atlas : register(t1); SamplerState AtlasSampler : register(s2); struct VSIn { float2 Position : POSITION; float2 Uv : TEXCOORD0; float4 Color : COLOR0; }; struct VSOut { float4 Position : SV_Position; float2 Uv : TEXCOORD0; float4 Color : COLOR0; }; [shader("vertex")] VSOut VSMain(VSIn i) { VSOut o; o.Position = mul(ViewProjection, float4(i.Position, 0, 1)); o.Uv = i.Uv; o.Color = i.Color; return o; } [shader("pixel")] float4 PSMain(VSOut i) : SV_Target { return Atlas.Sample(AtlasSampler, i.Uv) * i.Color; }
generates, in outline:
namespace MyGame.Shaders; public static partial class Sprite { [StructLayout(LayoutKind.Explicit, Size = 64)] public struct Frame { public const uint Size = 64; /* ViewProjection at offset 0 */ } public static class Bindings { public static class Frame { public const uint Set = 0, Binding = 0, Count = 1; public const ResourceKind Kind = ResourceKind.ConstantBuffer; } public static class Atlas { public const uint Set = 0, Binding = 1, Count = 1; public const ResourceKind Kind = ResourceKind.SampledTexture; } public static class AtlasSampler { public const uint Set = 0, Binding = 2, Count = 1; public const ResourceKind Kind = ResourceKind.Sampler; } public static ReadOnlySpan<ResourceBinding> All => /* ... */; } public static class VSMain { public const ShaderStage Stage = ShaderStage.Vertex; public const string EntryPoint = "VSMain"; public static ReadOnlySpan<byte> SpirV => /* embedded */; [StructLayout(LayoutKind.Explicit, Size = 32)] public struct Vertex { /* Position @0 Vector2, Uv @8 Vector2, Color @16 Vector4 */ } public const uint Stride = 32; public static ReadOnlySpan<VertexAttribute> Inputs => /* (0, Float32x2, 0), (1, Float32x2, 8), (2, Float32x4, 16) */; } public static class PSMain { public const ShaderStage Stage = ShaderStage.Pixel; public const string EntryPoint = "PSMain"; public static ReadOnlySpan<byte> SpirV => /* embedded */; } }