Recursive AI

Match HLSL constant buffers with C# structs

All oneshader recipes

Get a C# struct whose field offsets equal an HLSL constant buffer’s packing, 16-byte rows, arrays and matrices included, so you can upload its bytes without hand-written padding.

HLSL packs constant buffers in 16-byte rows. A vector can’t cross a row boundary, and each array element starts a new row. A hand-written C# struct has to repeat those rules exactly, and a mistake shows up as wrong values on screen. With RecursiveAI.OneShader.Build, the struct is generated from the compiled shader on every build, so it can’t drift from it.

Shaders/Lighting.hlsl:

HLSL
cbuffer Light : register(b0)
{
    float3 LightDirection;   // 12 bytes, and Intensity fits in the same 16-byte row
    float  Intensity;
    float2 ShadowBias;
    float3 Ambient;          // would cross a 16-byte row after ShadowBias, so it starts the next one
    float  Weights[4];       // each element of an array starts a new row
    float4x4 LightViewProjection;
};
 
[shader("pixel")]
float4 PSMain(float3 normal : NORMAL) : SV_Target
{
    float l = saturate(dot(normal, -LightDirection)) * Intensity;
    float w = Weights[0] + Weights[1] + Weights[2] + Weights[3];
    return float4(Ambient + l * w + ShadowBias.x, LightViewProjection[0][0]);
}

Program.cs, in a project that references RecursiveAI.OneShader.Build 1.2.0:

C#
using System.Numerics;
using System.Reflection;
using System.Runtime.InteropServices;
using Packing.Shaders;
 
// The generated struct has the shader's offsets, so its bytes can be uploaded as they are.
Console.WriteLine($"Light.Size = {Lighting.Light.Size}, Marshal.SizeOf = {Marshal.SizeOf<Lighting.Light>()}");
foreach (FieldInfo field in typeof(Lighting.Light).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
    Console.WriteLine($"{field.Name,-24} {field.FieldType.Name,-8} offset {Marshal.OffsetOf<Lighting.Light>(field.Name)}");
 
var light = new Lighting.Light
{
    LightDirection = Vector3.Normalize(new Vector3(0, -1, -1)),
    Intensity = 2.5f,
    Weights_0 = 0.25f, Weights_1 = 0.25f, Weights_2 = 0.25f, Weights_3 = 0.25f,
    LightViewProjection = Matrix4x4.Identity,   // element (i, j) is HLSL's LightViewProjection[i][j]
};
ReadOnlySpan<byte> bytes = MemoryMarshal.AsBytes(new ReadOnlySpan<Lighting.Light>(in light));
Console.WriteLine($"{bytes.Length} bytes to upload");
Text
Light.Size = 176, Marshal.SizeOf = 176
LightDirection           Vector3  offset 0
Intensity                Single   offset 12
ShadowBias               Vector2  offset 16
Ambient                  Vector3  offset 32
Weights_0                Single   offset 48
Weights_1                Single   offset 64
Weights_2                Single   offset 80
Weights_3                Single   offset 96
_LightViewProjection_c0  Vector4  offset 112
_LightViewProjection_c1  Vector4  offset 128
_LightViewProjection_c2  Vector4  offset 144
_LightViewProjection_c3  Vector4  offset 160
176 bytes to upload

DXC agrees. Its SPIR-V for the same file (dxc -T ps_6_0 -E PSMain -spirv) declares:

Text
               OpDecorate %_arr_float_uint_4 ArrayStride 16
               OpMemberDecorate %type_Light 0 Offset 0
               OpMemberDecorate %type_Light 1 Offset 12
               OpMemberDecorate %type_Light 2 Offset 16
               OpMemberDecorate %type_Light 3 Offset 32
               OpMemberDecorate %type_Light 4 Offset 48
               OpMemberDecorate %type_Light 5 Offset 112

What the generator does

  • Explicit layout. The struct is [StructLayout(LayoutKind.Explicit)] with a FieldOffset per member and a uint Size constant. It’s blittable, so MemoryMarshal.AsBytes gives the bytes to copy into the buffer.
  • Arrays of scalars become one field per element (Weights_0 to Weights_3), each at the start of its own row, the 16-byte stride HLSL uses.
  • Matrices are a System.Numerics.Matrix4x4 property over four private column vectors, because HLSL’s default packing is column-major. Set it as a normal Matrix4x4: element (i, j) is HLSL’s M[i][j]. A row_major matrix is stored the way Matrix4x4 is, so it becomes a plain Matrix4x4 field. Either way the C# code is the same.
  • The same bytes for every target. SPIR-V, Metal and WGSL use one byte layout, so one struct serves all three. The MSL output carries static_asserts on the size and offsets, compiled when ONESHADER_CHECK_LAYOUT is defined.
  • Overlapping packoffset members are an error (OSH0332) instead of invalid output.
  • Push constants follow different rules (a float3 may share a row with the next scalar). See Push constants on every target.

The full rules, structured buffers and resource arrays included, are in Generated bindings.

Direct3D and Vulkan offsets side by side: HLSL constant buffer packing rules.