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):
- 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”.
- 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.”
- Structs start a row. “Each structure forces the next variable to start on the next four-component vector.”
- 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
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:
dxc -T ps_6_0 -E main Packing.hlsl -Fc packing.dxil.txt grep -E "Offset: " packing.dxil.txt
; 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
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"
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:
| Member | Direct3D 12 | Vulkan (DXC default) | Why |
|---|---|---|---|
float3 SunColor | 0 | 0 | Starts row 0 |
float Exposure | 12 | 12 | Fits in the rest of row 0 |
float2 Jitter | 16 | 16 | Row 1 |
float3 FogColor | 32 | 32 | At 24 it would straddle into row 2, so it moves |
float Weights[3] | 48 | 48 | One row per element: 48, 64, 80 |
float AfterArray | 84 | 96 | Direct3D packs it after the last element; Vulkan rounds the array up to whole rows |
Light Sun | 96 | 112 | A struct starts a row |
float AfterStruct | 112 | 128 | The struct ends its row |
float3x3 Rotation | 128 | 144 | Column-major: three columns, one row each |
float Last | 172 | 192 | Same as arrays: Direct3D packs into the matrix’s last row |
| Size | 176 | 208 |
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”:
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"
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:
spirv-val --target-env vulkan1.2 packing.dx.spv spirv-val --target-env vulkan1.2 --scalar-block-layout packing.dx.spv
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:
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
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:
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; }
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:
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>()}");
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
StructuredBufferandRWStructuredBufferuse “vector-relaxed OpenGLstd430” on Vulkan in DXC’s default, per its manual. Arrays there aren’t rounded to rows: in an elementstruct S { float W[2]; float X; },Whas a 4-byte stride andXis 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). packoffsetplaces 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.