From 7a8e171e849c961a557cea094d9a2c05e9658b93 Mon Sep 17 00:00:00 2001 From: Aciz Date: Tue, 25 Aug 2026 20:00:27 +0300 Subject: [PATCH 1/3] Optimize visible brush geometry memory allocation Replace the fixed 'MAX_FACE_VERTS' vertex buffer pre-allocated for every face of every visible brush with lazily allocated, growable buffers (start at 8 verts, double, capped at 'MAX_FACE_VERTS'). Faces that end up with few or no verts no longer waste the full 64-vert allocation. On average, this is roughly 10x less memory allocated for the visible brushes, tested ~15 maps of various sizes and complexities. --- src/client/cl_tc_vis.c | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/client/cl_tc_vis.c b/src/client/cl_tc_vis.c index 7d694bee..52861339 100644 --- a/src/client/cl_tc_vis.c +++ b/src/client/cl_tc_vis.c @@ -17,6 +17,7 @@ typedef enum {TRIGGER_BRUSH = 0, CLIP_BRUSH, SLICK_BRUSH/*, LANDMINE_BRUSH*/} vi typedef struct { int numVerts; + int cap; polyVert_t *verts; vec3_t mins; vec3_t maxs; @@ -274,9 +275,11 @@ static void gen_visible_brush(int brushnum, const vec3_t origin, visBrushType_t node = malloc(sizeof(visBrushNode_t)); node->numFaces = brush->numsides; node->faces = malloc(node->numFaces * sizeof(visFace_t)); + for (i = 0; i < node->numFaces; i++) { node->faces[i].numVerts = 0; - node->faces[i].verts = malloc(MAX_FACE_VERTS * sizeof(polyVert_t)); + node->faces[i].cap = 0; + node->faces[i].verts = NULL; } for (i = 0; i < brush->numsides; i++) { @@ -428,8 +431,24 @@ static int winding_cmp(const void *a, const void *b) { } static void add_vert_to_face(visFace_t *face, const vec3_t vert, const vec4_t color, const vec2_t tex_coords) { - if (face->numVerts >= MAX_FACE_VERTS) + if (face->numVerts >= MAX_FACE_VERTS) { return; + } + + // allocate memory if needed - if current number of verts is equal to cap, + // we either have not allocated anything yet (both are 0), or we need to + // reallocate more memory + // each realloc doubles the cap, up to 'MAX_FACE_VERTS', starting at 8 + if (face->numVerts == face->cap) { + int newCap = face->cap ? face->cap * 2 : 8; + + if (newCap > MAX_FACE_VERTS) { + newCap = MAX_FACE_VERTS; + } + + face->verts = realloc(face->verts, (size_t)newCap * sizeof(polyVert_t)); + face->cap = newCap; + } VectorCopy(vert, face->verts[face->numVerts].xyz); Vector4Copy(color, face->verts[face->numVerts].modulate); @@ -489,4 +508,4 @@ static void draw(visBrushNode_t *brush, qhandle_t shader) { } brush = brush->next; } -} \ No newline at end of file +} From 572c245068f9a0827ebe7cf5c7ad9345c0279f9d Mon Sep 17 00:00:00 2001 From: Aciz Date: Tue, 25 Aug 2026 21:47:04 +0300 Subject: [PATCH 2/3] Allocate visible-brush geometry from a chunked arena Replace the per-brush malloc()/realloc() calls (node, faces array, and per-face vert growth) with a module-local bump allocator. Data is rebuilt on map load and lives until the next one; whole structure is bulk-freed with a single arena reset. Blocks are never moved, so the pointers held across frames for drawing remain valid. Per-face verts keep the lazy doubling growth (8 -> 16 -> 32 -> 64, capped at MAX_FACE_VERTS) via arena alloc + copy, preserving the memory reduction from the previous commit. This cuts down the total amount of malloc() calls on the entire thing from potentially hundreds of thousands to few dozen in extreme cases. Previously, the amount of calls to malloc() could choke the heap allocator on win32, making some maps unplayable (very extreme cases with over 50k generated visible brushes). --- src/client/cl_tc_vis.c | 106 +++++++++++++++++++++++++++++++---------- 1 file changed, 81 insertions(+), 25 deletions(-) diff --git a/src/client/cl_tc_vis.c b/src/client/cl_tc_vis.c index 52861339..61a72290 100644 --- a/src/client/cl_tc_vis.c +++ b/src/client/cl_tc_vis.c @@ -33,6 +33,73 @@ typedef struct visBrushNode_s { struct visBrushNode_s *next; } visBrushNode_t; +// chunked bump allocator used for all visible-brush geometry +// blocks are never moved, so pointers into the arena stay valid for the +// whole map session; everything is bulk-freed on the next map load +#define VIS_ARENA_INITIAL_BLOCK (64 * 1024) +#define VIS_ARENA_MAX_BLOCK (8 * 1024 * 1024) + +typedef struct visArenaBlock_s { + struct visArenaBlock_s *next; + size_t size; + size_t used; + /* payload follows */ +} visArenaBlock_t; + +typedef struct { + visArenaBlock_t *head; + visArenaBlock_t *tail; +} visArena_t; + +static visArena_t visArena; + +static void *visArena_Alloc(visArena_t *arena, size_t size) { + static const size_t align = 8; + const size_t payload = (size + align - 1) & ~(align - 1); + const size_t payloadOffset = (sizeof(visArenaBlock_t) + align - 1) & ~(align - 1); + visArenaBlock_t *blk = arena->tail; + + if (blk == NULL || blk->size - blk->used < payload) { + size_t blkSize = payload; + if (blk != NULL) { + size_t grown = blk->size * 2; + if (grown > VIS_ARENA_MAX_BLOCK) + grown = VIS_ARENA_MAX_BLOCK; + if (blkSize < grown) + blkSize = grown; + } else if (blkSize < VIS_ARENA_INITIAL_BLOCK) { + blkSize = VIS_ARENA_INITIAL_BLOCK; + } + blk = malloc(payloadOffset + blkSize); + if (blk == NULL) + return NULL; + blk->next = NULL; + blk->size = blkSize; + blk->used = 0; + if (arena->tail != NULL) + arena->tail->next = blk; + else + arena->head = blk; + arena->tail = blk; + } + + { + byte *p = (byte *)blk + payloadOffset + blk->used; + blk->used += payload; + return p; + } +} + +static void visArena_FreeAll(visArena_t *arena) { + visArenaBlock_t *blk = arena->head; + while (blk != NULL) { + visArenaBlock_t *next = blk->next; + free(blk); + blk = next; + } + arena->head = NULL; + arena->tail = NULL; +} static void add_triggers(void); static void add_clips(void); @@ -44,10 +111,8 @@ static qboolean point_in_brush(const vec3_t point, const cbrush_t *brush); static int winding_cmp(const void *a, const void *b); static void add_vert_to_face(visFace_t *face, const vec3_t vert, const vec4_t color, const vec2_t tex_coords); static float *get_uv_coords(vec2_t uv, const vec3_t vert, const vec3_t normal); -static void free_vis_brushes(visBrushNode_t *brushes); static void draw(visBrushNode_t *brush, qhandle_t shader); - static visBrushNode_t *trigger_head = NULL; static visBrushNode_t *clip_head = NULL; static visBrushNode_t *slick_head = NULL; @@ -83,10 +148,7 @@ static const vec4_t slick_color = { 0, 64, 128, 255 }; static const cplane_t *frustum; void tc_vis_init(void) { - free_vis_brushes(trigger_head); - free_vis_brushes(clip_head); - free_vis_brushes(slick_head); - //free_vis_brushes(landmine_head); + visArena_FreeAll(&visArena); trigger_head = NULL; clip_head = NULL; slick_head = NULL; @@ -196,7 +258,7 @@ static void add_clips(void) { gen_visible_brush(i, vec3_origin, CLIP_BRUSH, clip_color); } // this should match weaponclips, although it might also match other shaders. - // weaponclips don't have a specific content/surfaceparm, so in reality this is just a hack + // weaponclips don't have a specific content/surfaceparm, so in reality this is just a hack // to match parameters used in weaponclip shaders for (s = 0; s < brush->numsides; s++) { @@ -242,7 +304,7 @@ static void add_slicks(void) { for (s = 0; s < brush->numsides; s++) { const cbrushside_t* side = &brush->sides[s]; if ((side->surfaceFlags & SURF_SLICK) || - (!(side->surfaceFlags & SURF_NODRAW) && !(side->surfaceFlags & SURF_SKY) && angleSlick(side->plane))) { + (!(side->surfaceFlags & SURF_NODRAW) && !(side->surfaceFlags & SURF_SKY) && angleSlick(side->plane))) { gen_visible_brush(i, vec3_origin, SLICK_BRUSH, slick_color); break; } @@ -272,9 +334,9 @@ static void gen_visible_brush(int brushnum, const vec3_t origin, visBrushType_t if ( !brush->numsides ) return; - node = malloc(sizeof(visBrushNode_t)); + node = visArena_Alloc(&visArena, sizeof(visBrushNode_t)); node->numFaces = brush->numsides; - node->faces = malloc(node->numFaces * sizeof(visFace_t)); + node->faces = visArena_Alloc(&visArena, node->numFaces * sizeof(visFace_t)); for (i = 0; i < node->numFaces; i++) { node->faces[i].numVerts = 0; @@ -437,8 +499,8 @@ static void add_vert_to_face(visFace_t *face, const vec3_t vert, const vec4_t co // allocate memory if needed - if current number of verts is equal to cap, // we either have not allocated anything yet (both are 0), or we need to - // reallocate more memory - // each realloc doubles the cap, up to 'MAX_FACE_VERTS', starting at 8 + // grow the buffer + // each growth doubles the cap, up to 'MAX_FACE_VERTS', starting at 8 if (face->numVerts == face->cap) { int newCap = face->cap ? face->cap * 2 : 8; @@ -446,7 +508,13 @@ static void add_vert_to_face(visFace_t *face, const vec3_t vert, const vec4_t co newCap = MAX_FACE_VERTS; } - face->verts = realloc(face->verts, (size_t)newCap * sizeof(polyVert_t)); + { + polyVert_t *newVerts = visArena_Alloc(&visArena, (size_t)newCap * sizeof(polyVert_t)); + if (face->verts != NULL) { + memcpy(newVerts, face->verts, (size_t)face->numVerts * sizeof(polyVert_t)); + } + face->verts = newVerts; + } face->cap = newCap; } @@ -475,18 +543,6 @@ static float *get_uv_coords(vec2_t uv, const vec3_t vert, const vec3_t normal) { return uv; } -static void free_vis_brushes(visBrushNode_t *brushes) { - while (brushes != NULL) { - visBrushNode_t *next = brushes->next; - int i; - for (i = 0; i < brushes->numFaces; i++) - free(brushes->faces[i].verts); - free(brushes->faces); - free(brushes); - brushes = next; - } -} - static qboolean CullFace(const visFace_t *face) { int i; if (!face->numVerts) return qtrue; From c6272a5e9cca79597ca91074863023b2aa01958a Mon Sep 17 00:00:00 2001 From: Aciz Date: Tue, 25 Aug 2026 22:04:57 +0300 Subject: [PATCH 3/3] Add tcVisMemInfo command to display vis brush arena status Prints out the allocated arena size, as well as memory used by per-brush type. --- src/client/cl_tc_vis.c | 58 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/client/cl_tc_vis.c b/src/client/cl_tc_vis.c index 61a72290..09620b7f 100644 --- a/src/client/cl_tc_vis.c +++ b/src/client/cl_tc_vis.c @@ -13,7 +13,13 @@ #define MAX_FACE_VERTS 64 #define MIN_WALK_NORMAL 0.7f -typedef enum {TRIGGER_BRUSH = 0, CLIP_BRUSH, SLICK_BRUSH/*, LANDMINE_BRUSH*/} visBrushType_t; +typedef enum { + TRIGGER_BRUSH = 0, + CLIP_BRUSH, + SLICK_BRUSH, + //LANDMINE_BRUSH, + NUM_VIS_BRUSH_TYPES, +} visBrushType_t; typedef struct { int numVerts; @@ -49,6 +55,8 @@ typedef struct visArenaBlock_s { typedef struct { visArenaBlock_t *head; visArenaBlock_t *tail; + size_t totalSize; /* sum of all block payload capacities */ + size_t totalUsed; /* sum of payload bytes handed out */ } visArena_t; static visArena_t visArena; @@ -81,11 +89,13 @@ static void *visArena_Alloc(visArena_t *arena, size_t size) { else arena->head = blk; arena->tail = blk; + arena->totalSize += blkSize; } { byte *p = (byte *)blk + payloadOffset + blk->used; blk->used += payload; + arena->totalUsed += payload; return p; } } @@ -99,6 +109,8 @@ static void visArena_FreeAll(visArena_t *arena) { } arena->head = NULL; arena->tail = NULL; + arena->totalSize = 0; + arena->totalUsed = 0; } static void add_triggers(void); @@ -112,12 +124,17 @@ static int winding_cmp(const void *a, const void *b); static void add_vert_to_face(visFace_t *face, const vec3_t vert, const vec4_t color, const vec2_t tex_coords); static float *get_uv_coords(vec2_t uv, const vec3_t vert, const vec3_t normal); static void draw(visBrushNode_t *brush, qhandle_t shader); +static void tc_vis_meminfo_f(void); static visBrushNode_t *trigger_head = NULL; static visBrushNode_t *clip_head = NULL; static visBrushNode_t *slick_head = NULL; //static visBrushNode_t *landmine_head = NULL; +static size_t visBrushTypeAlloc[NUM_VIS_BRUSH_TYPES]; +static const char * const visBrushTypeNames[NUM_VIS_BRUSH_TYPES] = { "trigger", "clip", "slick" }; +static qboolean tcVisMemInfoRegistered = qfalse; + /* needed for winding_cmp */ static vec3_t w_center, w_normal, w_ref_vec; static float w_ref_vec_len; @@ -148,6 +165,13 @@ static const vec4_t slick_color = { 0, 64, 128, 255 }; static const cplane_t *frustum; void tc_vis_init(void) { + size_t usedBefore; + + if (!tcVisMemInfoRegistered) { + Cmd_AddCommand("tcVisMemInfo", tc_vis_meminfo_f); + tcVisMemInfoRegistered = qtrue; + } + visArena_FreeAll(&visArena); trigger_head = NULL; clip_head = NULL; @@ -173,9 +197,17 @@ void tc_vis_init(void) { // return; //} + usedBefore = visArena.totalUsed; add_triggers(); + visBrushTypeAlloc[TRIGGER_BRUSH] = visArena.totalUsed - usedBefore; + + usedBefore = visArena.totalUsed; add_clips(); + visBrushTypeAlloc[CLIP_BRUSH] = visArena.totalUsed - usedBefore; + + usedBefore = visArena.totalUsed; add_slicks(); + visBrushTypeAlloc[SLICK_BRUSH] = visArena.totalUsed - usedBefore; //add_landmines(); } @@ -200,6 +232,28 @@ void tc_vis_render(void) { }*/ } +static void tc_vis_meminfo_f(void) { + int i, blocks = 0; + size_t totalBrushes = 0; + visArenaBlock_t *blk; + + for (blk = visArena.head; blk != NULL; blk = blk->next) { + blocks++; + } + + Com_Printf( "visible brush geometry:\n" ); + for (i = 0; i < NUM_VIS_BRUSH_TYPES; i++) { + Com_Printf( "%9zu bytes (%6.2f MB) in %s brushes\n", visBrushTypeAlloc[i], visBrushTypeAlloc[i] / Square(1024.0), visBrushTypeNames[i] ); + totalBrushes += visBrushTypeAlloc[i]; + } + Com_Printf( "%9zu bytes (%6.2f MB) total in visible brushes\n", totalBrushes, totalBrushes / Square(1024.0) ); + Com_Printf( "\n" ); + Com_Printf( "%9zu bytes (%6.2f MB) total arena\n", visArena.totalSize, visArena.totalSize / Square(1024.0) ); + Com_Printf( "%9zu bytes (%6.2f MB) arena used\n", visArena.totalUsed, visArena.totalUsed / Square(1024.0) ); + Com_Printf( "%9zu bytes (%6.2f MB) arena unused\n", visArena.totalSize - visArena.totalUsed, (visArena.totalSize - visArena.totalUsed) / Square(1024.0) ); + Com_Printf( "%i arena blocks\n", blocks ); +} + // ripped from breadsticks static void add_triggers(void) { const char *entities = cm.entityString; @@ -416,6 +470,8 @@ static void gen_visible_brush(int brushnum, const vec3_t origin, visBrushType_t /*case LANDMINE_BRUSH: head = &landmine_head; break;*/ + default: + break; }; assert(head); node->next = *head;