Recursive AI

HLSL constant buffer packing rules

How HLSL packs cbuffer members into 16-byte rows, where Direct3D and Vulkan layouts differ (arrays, matrices), and how to write a C# struct that matches.

HLSL packs a constant buffer (cbuffer or ConstantBuffer<T>) in 16-byte rows. Members fill a row in order, a member that would straddle a row boundary moves to the next row, every array element starts a new row, and every struct starts a new row. Those rules come from Direct3D. On Vulkan, DXC’s default layout follows them too, except for what comes after an array or a matrix, where a Vulkan buffer has gaps that a Direct3D buffer doesn’t. A C# struct has to repeat the layout of the API you upload to, byte for byte.

The offsets below aren’t worked out by hand. They are what DXC 1.9.0.5512 (Vulkan SDK 1.4.363.0) and OneShader 1.2.0 wrote for one constant buffer that hits every rule.

The rules

From Microsoft’s packing rules for constant variables (updated 31 January 2024):

  1. 4-byte packing within 16-byte rows. Rules “similar to performing a #pragma pack 4”, except that HLSL “packs data so that it does not cross a 16-byte boundary”.
  2. No straddling. “Variables are packed into a given four-component vector until the variable will straddle a 4-vector boundary; the next variables will be bounced to the next four-component vector.”
  3. Structs start a row. “Each structure forces the next variable to start on the next four-component vector.”
  4. Arrays aren’t packed. “Every element in an array is stored in a four-component vector”, so a float[3] takes three rows. The last element’s row isn’t padded out, though: on Direct3D the next scalar can sit right after it.

One buffer that hits every rule

Packing.hlslHLSL
struct Light { float3 Direction; float Intensity; };
 
cbuffer Scene : register(b0)
{
    float3   SunColor;     // fills x, y, z of row 0
    float    Exposure;     // packs into w of row 0
    float2   Jitter;       // row 1
    float3   FogColor;     // would straddle row 1, so moves to row 2
    float    Weights[3];   // each element starts a row: rows 3, 4, 5
    float    AfterArray;   // where this lands differs between D3D and Vulkan
    Light    Sun;          // a struct starts a new row
    float    AfterStruct;
    float3x3 Rotation;     // 3 rows of 16 bytes, column-major
    float    Last;
};
 
[shader("pixel")]
float4 main() : SV_Target
{
    return float4(SunColor * Exposure + FogColor * Jitter.x + Sun.Direction * Weights[2] + Rotation[0],
                  AfterArray + AfterStruct + Sun.Intensity + Last);
}

Direct3D 12 offsets

DXC writes the layout it uses for DXIL as comments in the listing produced by -Fc:

TerminalDXC 1.9.0.5512
dxc -T ps_6_0 -E main Packing.hlsl -Fc packing.dxil.txt
grep -E "Offset: " packing.dxil.txt
Output
;       float3 SunColor;                              ; Offset:    0
;       float Exposure;                               ; Offset:   12
;       float2 Jitter;                                ; Offset:   16
;       float3 FogColor;                              ; Offset:   32
;       float Weights[3];                             ; Offset:   48
;       float AfterArray;                             ; Offset:   84
;           float3 Direction;                         ; Offset:   96
;           float Intensity;                          ; Offset:  108
;       } Sun;                                        ; Offset:   96
;       float AfterStruct;                            ; Offset:  112
;       column_major float3x3 Rotation;               ; Offset:  128
;       float Last;                                   ; Offset:  172
;   } Scene;                                          ; Offset:    0 Size:   176

Vulkan offsets

TerminalDXC 1.9.0.5512
dxc -spirv -T ps_6_0 -E main Packing.hlsl -Fo packing.spv
spirv-dis packing.spv | grep -E "OpMemberDecorate %type_Scene [0-9]+ Offset"
Output
               OpMemberDecorate %type_Scene 0 Offset 0
               OpMemberDecorate %type_Scene 1 Offset 12
               OpMemberDecorate %type_Scene 2 Offset 16
               OpMemberDecorate %type_Scene 3 Offset 32
               OpMemberDecorate %type_Scene 4 Offset 48
               OpMemberDecorate %type_Scene 5 Offset 96
               OpMemberDecorate %type_Scene 6 Offset 112
               OpMemberDecorate %type_Scene 7 Offset 128
               OpMemberDecorate %type_Scene 8 Offset 144
               OpMemberDecorate %type_Scene 9 Offset 192

The members are numbered in declaration order. Here they are side by side:

MemberDirect3D 12Vulkan (DXC default)Why
float3 SunColor00Starts row 0
float Exposure1212Fits in the rest of row 0
float2 Jitter1616Row 1
float3 FogColor3232At 24 it would straddle into row 2, so it moves
float Weights[3]4848One row per element: 48, 64, 80
float AfterArray8496Direct3D packs it after the last element; Vulkan rounds the array up to whole rows
Light Sun96112A struct starts a row
float AfterStruct112128The struct ends its row
float3x3 Rotation128144Column-major: three columns, one row each
float Last172192Same as arrays: Direct3D packs into the matrix’s last row
Size176208

The two differences are the members right after an array and right after a matrix. Everything after them shifts. DXC’s SPIR-V manual explains why: its default for constant buffers is “vector-relaxed OpenGL std140”, which satisfies Vulkan’s standard uniform buffer layout, and std140 rounds arrays and matrices up to a whole number of 16-byte rows. The “vector-relaxed” part gives a vector its element’s alignment as long as it doesn’t straddle a row, as on Direct3D. In cbuffer C { float A; float2 B; }, both layouts put B at 4, where strict std140 would put it at 8. So most buffers have the same layout on both APIs, until an array or a matrix is followed by something.

Making Vulkan match Direct3D

DXC can use the Direct3D layout on Vulkan with -fvk-use-dx-layout, which, its manual notes, “requires VK_EXT_scalar_block_layout to be enabled on the application side”:

TerminalDXC 1.9.0.5512
dxc -spirv -fvk-use-dx-layout -T ps_6_0 -E main Packing.hlsl -Fo packing.dx.spv
spirv-dis packing.dx.spv | grep -E "OpMemberDecorate %type_Scene [0-9]+ Offset"
Output
               OpMemberDecorate %type_Scene 0 Offset 0
               OpMemberDecorate %type_Scene 1 Offset 12
               OpMemberDecorate %type_Scene 2 Offset 16
               OpMemberDecorate %type_Scene 3 Offset 32
               OpMemberDecorate %type_Scene 4 Offset 48
               OpMemberDecorate %type_Scene 5 Offset 84
               OpMemberDecorate %type_Scene 6 Offset 96
               OpMemberDecorate %type_Scene 7 Offset 112
               OpMemberDecorate %type_Scene 8 Offset 128
               OpMemberDecorate %type_Scene 9 Offset 172

The offsets now equal the Direct3D ones. The validator agrees about the requirement: without scalar block layout the module is invalid, with it the module passes:

TerminalSPIRV-Tools v2026.4
spirv-val --target-env vulkan1.2 packing.dx.spv
spirv-val --target-env vulkan1.2 --scalar-block-layout packing.dx.spv
Output
error: line 62: Structure member 5 at offset 84 overlaps previous member ending at offset 95. Instantiated via '5[%Scene]' in the Uniform storage class using extended layout rules.
This is may be allowed if you enable the uniformBufferStandardLayout feature (or use the --uniform-buffer-standard-layout command line flag)
  %type_Scene = OpTypeStruct %v3float %float %v2float %v3float %_arr_float_uint_3 %float %Light %float %mat3v3float %float

The other way round needs no device feature: order members so that no scalar follows an array or a matrix, or pad explicitly, and the layout is the same on both APIs. OneShader always uses DXC’s default Vulkan layout, for SPIR-V, Metal and WGSL alike, and rejects -fvk-use-dx-layout. Its reflection reports the same offsets as DXC’s SPIR-V above:

TerminalOneShader 1.2.0
oneshader -T ps_6_0 -E main Packing.hlsl -Fo packing.osh.spv --reflect packing.json
jq -r '.resources[] | select(.name == "Scene") | .layout.members[] | "\(.offset)\t\(.name)"' packing.json
Output
0	SunColor
12	Exposure
16	Jitter
32	FogColor
48	Weights
96	AfterArray
112	Sun
128	AfterStruct
144	Rotation
192	Last

A C# struct that matches

Write the struct with LayoutKind.Explicit and one FieldOffset per member. An array becomes one field per element, and a float3x3 one Vector3 per column, each at the start of its row:

Program.cs.NET 10
using System.Numerics;
using System.Runtime.InteropServices;
 
Console.WriteLine($"Size {Marshal.SizeOf<SceneVulkan>()}");
foreach (var name in new[] { "Weights0", "AfterArray", "Sun", "AfterStruct", "Rotation0", "Last" })
    Console.WriteLine($"{name,-12} {Marshal.OffsetOf<SceneVulkan>(name)}");
 
// The Scene constant buffer as DXC lays it out for Vulkan (vector-relaxed std140).
[StructLayout(LayoutKind.Explicit, Size = 208)]
struct SceneVulkan
{
    [FieldOffset(0)]   public Vector3 SunColor;
    [FieldOffset(12)]  public float Exposure;
    [FieldOffset(16)]  public Vector2 Jitter;
    [FieldOffset(32)]  public Vector3 FogColor;
    [FieldOffset(48)]  public float Weights0;    // each array element starts a 16-byte row
    [FieldOffset(64)]  public float Weights1;
    [FieldOffset(80)]  public float Weights2;
    [FieldOffset(96)]  public float AfterArray;  // 84 on Direct3D
    [FieldOffset(112)] public Light Sun;
    [FieldOffset(128)] public float AfterStruct;
    [FieldOffset(144)] public Vector3 Rotation0; // float3x3, column-major: one column per row
    [FieldOffset(160)] public Vector3 Rotation1;
    [FieldOffset(176)] public Vector3 Rotation2;
    [FieldOffset(192)] public float Last;        // 172 on Direct3D
}
 
[StructLayout(LayoutKind.Explicit, Size = 16)]
struct Light
{
    [FieldOffset(0)]  public Vector3 Direction;
    [FieldOffset(12)] public float Intensity;
}
Output
Size 208
Weights0     48
AfterArray   96
Sun          112
AfterStruct  128
Rotation0    144
Last         192

Setting Size = 208 matters as much as the offsets. A sequential C# struct would pack AfterArray right after Weights2 and come out shorter than the buffer the shader declares. For Direct3D 12 the same struct would use 84 and 172 for the two marked fields, and later offsets would shift as in the table above.

Let the build write it

Hand-written offsets break silently when someone edits the shader. With RecursiveAI.OneShader.Build, the struct is generated from the compiled shader on every dotnet build, so it can’t drift. Add Packing.hlsl to a project as Shaders/Packing.hlsl and list the fields of the generated Packing.Scene:

Program.csRecursiveAI.OneShader.Build 1.2.0
using System.Runtime.InteropServices;
using App.Shaders;
 
foreach (var field in typeof(Packing.Scene).GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic))
    Console.WriteLine($"{field.Name,-14} {Marshal.OffsetOf<Packing.Scene>(field.Name),4}  {field.FieldType.Name}");
Console.WriteLine($"Size {Marshal.SizeOf<Packing.Scene>()}");
Output
SunColor          0  Vector3
Exposure         12  Single
Jitter           16  Vector2
FogColor         32  Vector3
Weights_0        48  Single
Weights_1        64  Single
Weights_2        80  Single
AfterArray       96  Single
Sun             112  Light
AfterStruct     128  Single
_Rotation_c0    144  Vector3
_Rotation_c1    160  Vector3
_Rotation_c2    176  Vector3
Last            192  Single
Size 208

The generated struct has the same offsets and size as the reflection and as DXC’s SPIR-V. Vectors are System.Numerics types, arrays are one field per element (Weights_0 to Weights_2), and the matrix is stored column by column behind a Matrix4x4 property. See the constant buffer structs recipe and generated bindings.

Other buffer types

  • StructuredBuffer and RWStructuredBuffer use “vector-relaxed OpenGL std430” on Vulkan in DXC’s default, per its manual. Arrays there aren’t rounded to rows: in an element struct S { float W[2]; float X; }, W has a 4-byte stride and X is at 8, the same as the Direct3D listing, and the element stride is 12.
  • Push constants ([[vk::push_constant]]) follow the structured-buffer rules, not the constant-buffer rules, in DXC’s output and OneShader’s (push constants recipe).
  • packoffset places a member at a chosen offset on Direct3D. On Vulkan, DXC’s [[vk::offset]] does the same and “overrules :packoffset” (SPIR-V manual).

Common questions

Why is my HLSL constant buffer bigger than my C# struct?

Because HLSL rounds structs, array elements and matrix columns to 16-byte rows and C# doesn’t. Check the offsets the compiler reports (the -Fc listing, spirv-dis, or reflection) against Marshal.OffsetOf for each field.

Does a float4 always have to be 16-byte aligned?

In a constant buffer it can’t straddle a 16-byte row, so in practice it always starts a row. A float3 also has to fit in one row, but a float can follow it in the same row.

What about Metal and WebGPU?

Their own layout rules differ again. Core WGSL, for one, requires a 16-byte array stride in uniform buffers. OneShader keeps the Vulkan byte layout on all three targets, so one C# struct works everywhere, as the HLSL to WGSL comparison shows with this same buffer.

To generate matching structs for your own shaders, run dotnet add package RecursiveAI.OneShader.Build and follow getting started.