Parsing hierarchical file formats like glTF in C presents a classic memory management challenge. When utilizing an arena allocator (also known as a region-based allocator), handling nested dynamic arrays—such as a list of meshes, each containing a dynamic list of primitives—can feel like fighting the allocator's design.

In a typical arena, allocations are contiguous. If you try to dynamically resize Array A, but have since allocated elements for nested Array B, you cannot expand Array A without either overwriting Array B or copying Array A to the end of the arena, leaving behind dead, wasted space.

Fortunately, there are several elegant, industry-standard patterns to solve this when parsing glTF or similar nested structures. Let's explore the best approaches.

Method 1: The Scratch Arena Pattern (Recommended)

The most robust and idiomatic way to handle nested, growing structures in arena-based systems is using a Scratch Arena (or temporary arena).

Instead of allocating directly into your long-lived asset arena, you perform your dynamic, unpredictable allocations in a temporary scratch arena. Once the exact size of the nested arrays is known, you pack them into a single, contiguous block inside your main arena and discard (reset) the scratch arena.

Because scratch arenas are cheap to reset (just resetting a pointer to zero), the "wasted" memory from resizing is instantly reclaimed at the end of the frame or parsing phase.

// High-level conceptual implementation of Scratch Arena usage
typedef struct {
    void *memory;
    size_t capacity;
    size_t offset;
} Arena;

// Temporary dynamic array helper
typedef struct {
    Primitive *data;
    size_t count;
    size_t capacity;
} TempPrimitiveArray;

void parse_meshes(Arena *main_arena, Arena *scratch_arena, const char *json_string) {
    // Keep track of our scratch offset so we can free everything at the end
    size_t scratch_checkpoint = scratch_arena->offset;

    // 1. Accumulate primitives in the scratch arena dynamically
    TempPrimitiveArray temp_prims = {0};
    while (has_more_primitives()) {
        if (temp_prims.count >= temp_prims.capacity) {
            // Grow the array in scratch space
            size_t old_cap = temp_prims.capacity;
            temp_prims.capacity = old_cap == 0 ? 8 : old_cap * 2;
            Primitive *new_data = arena_alloc(scratch_arena, temp_prims.capacity * sizeof(Primitive));
            if (temp_prims.data) {
                memcpy(new_data, temp_prims.data, old_cap * sizeof(Primitive));
            }
            temp_prims.data = new_data;
        }
        temp_prims.data[temp_prims.count++] = parse_next_primitive();
    }

    // 2. Commit the final, exact-sized array to the main arena
    Primitive *final_primitives = arena_alloc(main_arena, temp_prims.count * sizeof(Primitive));
    memcpy(final_primitives, temp_prims.data, temp_prims.count * sizeof(Primitive));

    // 3. Reset the scratch arena back to its checkpoint
    scratch_arena->offset = scratch_checkpoint;
}

Method 2: Linked-List Chunks (The "Block" Approach)

If you want to avoid copying memory altogether, you can represent your dynamic arrays as a linked list of fixed-size chunks allocated from the arena.

For example, instead of growing a contiguous array, you allocate chunks of 8 or 16 elements. When a chunk fills up, you allocate a new chunk from the arena and link it to the previous one.

typedef struct PrimitiveChunk {
    Primitive elements[8];
    struct PrimitiveChunk *next;
} PrimitiveChunk;

Pros: No wasted memory, zero reallocations, zero copying during parsing.
Cons: Cache locality is slightly reduced during traversal because the elements are not contiguous in memory. However, for parsing stages, this is often perfectly acceptable. If contiguous memory is strictly required for rendering, you can flatten this linked list into a single array on the main arena as a post-processing step.

Method 3: Two-Pass Parsing

While the original question notes that two-pass parsing "iterates over the string twice," it is highly efficient for JSON/glTF if designed correctly.

In a modern C parser, the first pass does not need to fully parse and validate every JSON token. Instead, you use a fast, lightweight tokenizer (like jsmn or a custom SIMD-accelerated scanner) to count the number of elements. Once counted, you allocate the perfect size from your arena in a single shot, then do a second pass to fill in the data.

Because modern CPUs are incredibly fast at linear memory access, scanning a text string twice is often significantly faster than dealing with heap allocations (malloc/free) or heavy memory copying.

Summary: Which Should You Choose?

  • Choose Scratch Arenas if you want a general-purpose solution for all dynamic structures in your engine. It keeps your main arena clean and contiguous while allowing standard, easy-to-write array-growing logic.
  • Choose Linked-List Chunks if you want to minimize memory copies and do not mind non-contiguous memory during the loading phase.
  • Choose Two-Pass Parsing if your parser is already fast and you want absolutely zero memory overhead or complex allocator tracking.