When you write a struct in C# and send it to a GPU buffer, the bytes on both sides must match exactly. The problem is that the GPU and the CPU follow different layout rules. A struct that looks like 20 bytes in C# can silently become 32 bytes in HLSL — and your shader reads garbage from the wrong offset, with no error, no warning, no crash.
Struct packing is the most common silent data corruption bug in GPU programming. It produces no exceptions. The shader simply reads the wrong memory. Everything compiles, nothing crashes, the output just looks wrong — or subtly wrong in ways that only appear in specific conditions.
Three dominant layout rules govern how GPU compilers arrange struct members in memory:
| LAYOUT | API | KEY RULE | STRUCT ALIGNMENT | ARRAY STRIDE |
|---|---|---|---|---|
| std140 | GL / Vulkan UBO | vec3 rounds up to vec4 (16 B). Arrays: each element padded to 16 B regardless of type. | 16 bytes | 16 bytes minimum |
| std430 | Vulkan SSBO / GL SSBO | Tighter packing. vec3 still 12 B but aligns to 16 B. Arrays use natural element stride. | 4 bytes (scalar) | Natural element size |
| HLSL cbuffer | DX11/DX12/Unity | Members pack into 16-byte rows. No member spans a 16-byte boundary. float3 + float can pack into 16 B. Arrays: each element 16-byte aligned. | 16 bytes (cbuffer) | 16 bytes (arrays) |
| scalar | DXC / GLSL 450 | Natural C-style packing. Opt-in via [[vk::ext_scalar_block_layout]] or #pragma pack_matrix. Matches C# struct layout exactly. | Natural (4 B) | Natural element size |
The most common offender: putting a float after a float3 in HLSL saves 4 bytes. Putting the float3 after anything that would push it across a 16-byte boundary wastes the entire gap.
Packing rule of thumb: sort struct members from largest to smallest. Put float4 and matrices first, then float3 immediately followed by a float to fill the 4th slot, then float2 pairs, then individual float/int. Always verify with a static assert on the C# side.
Every field in your C# struct must mirror the GPU struct exactly — same order, same type size, same total byte count. Unity's CBUFFER_START macro wraps constants in a 16-byte-aligned block; SSBOs and ComputeBuffers use scalar layout when marshalled via SetData.
// ── C# side (CPU) ─────────────────────────────────────────
[StructLayout(LayoutKind.Sequential)] // force C-style packing
struct LightData
{
public Vector3 direction; // 12 B
public float intensity; // 4 B → total 16 B, packed into one vec4 slot
public Vector4 color; // 16 B
public float radius; // 4 B
public int flags; // 4 B
public Vector2 reserved; // 8 B → total 16 B
}
// Total C# struct: 48 B
// Verify at compile time — catches mismatches before runtime:
static LightData() {
Debug.Assert(Marshal.SizeOf() == 48,
"LightData size mismatch — check HLSL struct alignment");
}
// ── HLSL side (GPU) ───────────────────────────────────────
struct LightData
{
float3 direction; // 12 B \
float intensity; // 4 B / → 16 B (one float4 slot)
float4 color; // 16 B
float radius; // 4 B \
int flags; // 4 B > 16 B (packed into one slot)
float2 reserved; // 8 B /
};
// Total HLSL struct: 48 B ← must match C# exactly
StructuredBuffer<LightData> _Lights : register(t0);
// Per-frame cbuffer — uses 16-byte row packing:
CBUFFER_START(UnityPerFrame)
float4x4 _ViewProj; // 64 B
float3 _SunDir; // 12 B \
float _Time; // 4 B / packed → 16 B
float4 _FogColor; // 16 B
CBUFFER_END
| HLSL TYPE | GLSL TYPE | C# TYPE | SIZE | std140 ALIGN | std430 ALIGN |
|---|---|---|---|---|---|
| float | float | float | 4 B | 4 B | 4 B |
| float2 | vec2 | Vector2 | 8 B | 8 B | 8 B |
| float3 | vec3 | Vector3 | 12 B | 16 B ! | 16 B |
| float4 | vec4 | Vector4 | 16 B | 16 B | 16 B |
| float4x4 | mat4 | Matrix4x4 | 64 B | 64 B | 64 B |
| float3x4 | mat3x4 | — | 48 B | 48 B | 48 B |
| int | int | int | 4 B | 4 B | 4 B |
| uint | uint | uint | 4 B | 4 B | 4 B |
| bool | bool | — | 4 B (GPU!) | 4 B | 4 B |
bool on GPU = 4 bytes, not 1. C# bool is 1 byte. HLSL bool is 4 bytes. Never send a C# bool field directly to a GPU buffer — use int or pack flags into a uint bitmask instead.
Once the struct is correctly packed, you need to move it to the GPU. The mechanism for this — and its cost — depends on the platform, the data update frequency, and the size of the payload. Choosing the wrong pattern is the second most common performance mistake after struct misalignment.
| PATTERN | FREQUENCY | MECHANISM | COST | EXAMPLE |
|---|---|---|---|---|
| Static | Once at load | Upload buffer → GPU copy → discard upload buffer | One-time PCIe transfer | Mesh vertex data, baked lighting, LUTs |
| Dynamic (per frame) | Every frame | Ring buffer of N copies (typically 2–3). Map current frame's copy, write, submit. | CPU write + PCIe each frame | Per-frame matrices, time, light positions |
| Streaming | As-needed | Async DMA transfer. CPU queues transfer, GPU signals fence on completion. | DMA bandwidth, fence sync | Texture mip streaming, LOD mesh streaming |
| Push Constants | Per draw call | Written directly into the command buffer. No buffer allocation. | Near-zero — command buffer write | Draw ID, MVP matrix, material index |
// ── STATIC upload (DX12) ──────────────────────────────────
// Step 1: Create upload heap (CPU-visible)
// Step 2: Copy data to upload heap
// Step 3: Record CopyBufferRegion to default heap (VRAM)
// Step 4: Signal fence, wait, release upload heap
ID3D12Resource* uploadBuf; // UPLOAD heap
ID3D12Resource* gpuBuf; // DEFAULT heap (fast VRAM)
memcpy(uploadBuf->Map(), meshData, size);
cmdList->CopyBufferRegion(gpuBuf, 0, uploadBuf, 0, size);
// After fence signal: uploadBuf safe to release
// ── DYNAMIC per-frame (Vulkan ring buffer) ────────────────
// Three copies of the UBO — one per frame-in-flight
VkBuffer frameUBOs[3]; // ring
void* mappedPtrs[3]; // persistently mapped
uint32_t frameIdx = 0;
// Each frame:
memcpy(mappedPtrs[frameIdx], &perFrameData, sizeof(PerFrameData));
// Bind frameUBOs[frameIdx] as descriptor
frameIdx = (frameIdx + 1) % 3;
// ── PUSH CONSTANTS (DX12) ────────────────────────────────
struct DrawPushConstants {
uint32_t instanceOffset; // 4 B
uint32_t materialIndex; // 4 B
};
DrawPushConstants pc{ drawOffset, matIdx };
cmdList->SetGraphicsRoot32BitConstants(0, 2, &pc, 0);
In Unity, every ComputeBuffer.SetData(T[]) call with a managed array allocates temporary memory, triggers a garbage collection stall, and performs an extra copy. At 60 Hz with 10 000 instances this is visible in the profiler as recurring GC spikes.
// Unity — GC-heavy pattern
// Allocates on managed heap every frame
Matrix4x4[] matrices = new Matrix4x4[count]; // GC alloc
for (int i = 0; i < count; i++)
matrices[i] = transforms[i].localToWorldMatrix;
// SetData boxes the array, allocates internal temp buffer
computeBuffer.SetData(matrices); // GC pressure
// matrices[] becomes eligible for GC → stall risk
// Unity — zero-GC pattern
NativeArray<Matrix4x4> matrices =
new NativeArray<Matrix4x4>(count, Allocator.TempJob);
// Fill via Burst-compiled IJobParallelFor — no GC
new BuildMatricesJob {
Transforms = transformAccessArray,
Output = matrices
}.Schedule(count, 64).Complete();
// SetData from NativeArray: direct pointer, no copy, no GC
computeBuffer.SetData(matrices);
matrices.Dispose(); // explicit free — no GC involvement
Rule: any buffer that updates every frame must use NativeArray or GraphicsBuffer with Lock/Unlock. Managed arrays for GPU data are only acceptable for one-time uploads at load time.
Standard pattern: CPU writes to a staging buffer in CPU-accessible memory (UPLOAD heap in DX12, HOST_VISIBLE in Vulkan), then a DMA engine copies it to device-local VRAM asynchronously. The GPU can continue rendering while the DMA runs.
With ReBAR / Smart Access Memory enabled: the entire VRAM is CPU-addressable. Skip the staging buffer — write directly to VRAM. Reduces latency and saves one buffer allocation per resource.
// Vulkan — check for ReBAR and use direct write if available
VkPhysicalDeviceMemoryProperties memProps;
vkGetPhysicalDeviceMemoryProperties(physDevice, &memProps);
// ReBAR: find DEVICE_LOCAL | HOST_VISIBLE heap
for (uint32_t i = 0; i < memProps.memoryTypeCount; i++) {
auto flags = memProps.memoryTypes[i].propertyFlags;
bool isBAR = (flags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) &&
(flags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT);
if (isBAR) {
// Allocate here for direct CPU→VRAM writes (no staging)
rebarMemoryTypeIndex = i;
}
}
PS5 and Xbox have no PCIe, no staging buffer, no upload pass. CPU allocates from the unified pool, writes data, submits to GPU. The only cost is a timeline fence to ensure the CPU write completes before the GPU reads.
// PS5 — CPU writes, GPU reads, same pointer
void* ptr = garlicAllocator.allocate(sizeof(PerFrameData), 256);
memcpy(ptr, &perFrameData, sizeof(PerFrameData));
// No copy. Signal fence so GPU waits for CPU write to complete.
sce::Gnmx::submitAndFlip(cmdBuf, displayBuffer, fence);
// GPU picks up ptr directly at the original address
Mobile shares one LPDDR5 pool between CPU and GPU. Every byte you upload competes with the GPU's texture fetches and framebuffer reads. Keep per-frame payloads small. Pack per-instance data into a UBO rather than a large SSBO when possible — the constant buffer cache is more aggressively optimised on Mali/Adreno than general buffer paths.
On TBDR mobile, avoid mapping (writing) a buffer that the GPU is currently reading. This forces a pipeline stall — the GPU must finish its current tile before the CPU can proceed. Always use double or triple buffering for any buffer written from CPU while the GPU renders.
| CONDITION | PC | Console | Mobile |
|---|---|---|---|
| Data updated: never (static) | Upload → device-local. Release staging buffer. | Write once → unified pool. No staging. | Upload once. Keep in GPU pool. |
| Data updated: per frame, <64 KB | Ring buffer (3×) in upload heap. Persistent map. | Write directly to unified ptr. Fence only. | UBO ring buffer. Keep payloads minimal. |
| Data updated: per frame, >64 KB | Staging ring buffer → async DMA. Double buffer. | Large unified alloc. Write, fence, submit. | SSBO ring buffer. Profile bandwidth cost. |
| Per-draw data, <256 B | Push / Root Constants. Zero allocation. | Push / Root Constants. Zero allocation. | setVertexBytes() (Metal) / Push Constants. |
| Unity: per-frame arrays | NativeArray + Burst Job → GraphicsBuffer.SetData(). Never managed T[]. | ||