From File to Pixel · Part 03: The Data

pavelzosim:~/atlas_SYS.ONLINE / UTC+3

05 Memory Alignment & Struct Packing

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.

std140 / std430 / cbuffer — Packing Rules

Three dominant layout rules govern how GPU compilers arrange struct members in memory:

[ PACKING_RULES // std140 · std430 · HLSL cbuffer ]
LAYOUTAPIKEY RULESTRUCT ALIGNMENTARRAY 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

Visual Layout — Waste vs Packed

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.

// std140 WASTE — naive struct order
float time
time
padding ×12 B
16 B used, 12 wasted
vec3 lightDir
lightDir.xyz
pad ×4 B
16 B used, 4 wasted
float intensity
intensity
padding ×12 B
16 B used, 12 wasted
Total: 48 B declared · 20 B useful · 28 B wasted (58%)
// PACKED — vec3 + float in same 16-byte slot
vec3 lightDir
lightDir.xyz
intensity
16 B · 0 wasted
float time
time
padding ×12 B
16 B · 12 wasted
Total: 32 B declared · 20 B useful · 12 B wasted (37%) — 33% smaller

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.

C# → HLSL Struct Mapping

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

Type Size Reference

HLSL TYPEGLSL TYPEC# TYPESIZEstd140 ALIGNstd430 ALIGN
floatfloatfloat4 B4 B4 B
float2vec2Vector28 B8 B8 B
float3vec3Vector312 B16 B !16 B
float4vec4Vector416 B16 B16 B
float4x4mat4Matrix4x464 B64 B64 B
float3x4mat3x448 B48 B48 B
intintint4 B4 B4 B
uintuintuint4 B4 B4 B
boolbool4 B (GPU!)4 B4 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.

CPU→GPU Data Flow

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.

Upload Patterns — Static / Dynamic / Streaming

[ UPLOAD_PATTERNS // BY UPDATE FREQUENCY ]
PATTERNFREQUENCYMECHANISMCOSTEXAMPLE
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);

GC Starvation — ComputeBuffer vs NativeArray

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.

Δ1 // MANAGED ARRAY — GC alloc every frame
// 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
Δ2 // NATIVEARRAY — zero GC, Burst-compatible
// 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.

Platform Upload Differences

PC Staging buffers, ReBAR, async DMA

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;
    }
}

Console Unified memory — no upload, fence only

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 Shared LPDDR5 — minimise upload size, use UBO for small data

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.

Upload Strategy Decision Matrix

[ UPLOAD_STRATEGY // DECISION_MATRIX ]
CONDITIONPCConsoleMobile
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[].
// FROM_FILE_TO_PIXEL // PART 03 OF 05 // CHAPTERS 05–06 // EOF