WC3 Performance Hot Spots
For process footprint, allocation profiling, and RAM reduction priorities, see WC3 memory.
Profile-driven optimizations across the renderer, client, and server. The five sampled hot spots and the fixes applied to each are listed below so a future reader understands why each path is shaped the way it is.
MDX bone setup (was ~16%)
MDLX_BindBoneMatrices (games/warcraft-3/renderer/mdx/r_mdx_anim.c) is called once per rendered model per frame. The old path:
memset(node_matrices, 0, sizeof(node_matrices))— 64 KiB (1024 nodes × 64-byte matrix) cleared per model.- Two loops over all
MDX_MAX_NODES(1024) slots, each null-checking the sparsemodel->nodes[]array. - A
bone_matrices[MDX_MATRIX_PALETTE]build loop plus aglUniformMatrix4fv(uBones, ...)upload — dead work, becauseMDLX_BindGeosetMatrixPalettere-uploads the real per-geoset palette immediately afterward.
Fix: R_LoadModelMDLX now builds a compact model->node_list[]/num_nodes (the nodes the model actually has, typically tens), and MDLX_BindBoneMatrices iterates only that list, zeroing each used matrix individually to reset the v[15]==0 "computed" flag that R_GetNodeGlobalMatrix relies on. The dead bone_matrices build + upload were removed.
Why pose caching was rejected: a persistent cache key would have to include both the interpolated frame0/frame1 pair (with lerpfrac) and the global-sequence render clock (SDL_GetTicks-derived), which changes every frame for global-sequence tracks — so caching buys little while the memset/scan removal eliminates the bulk of the cost.
MDX geometry / material drawing (was ~19%)
MDLX_BindGeosetMatrixPalette (r_mdx_geoset.c) uploaded the full BZ_BONE_PALETTE_MAX (128) matrices per geoset. Skin indices are geoset-local (0..num_matrixPalette-1), so only min(num_matrixPalette, BZ_BONE_PALETTE_MAX) entries ever need to reach the shader. Uploading 128 when a geoset references, say, 12 was wasted uniform traffic on every draw.
Entity shadows (was ~8%)
Unit shadows are ground decals: one shader (SHADER_SHADOWSPLAT), differing only by texture and rect. R_RenderShadow previously called R_RenderRectSplat per unit, which bound the splat shader/VAO/VBO, uploaded view uniforms, re-set blend/depth-mask, re-set texture wrap, then uploaded the vertex buffer (glBufferData) and issued a draw per shadow — hundreds of tiny draws/upload per scene.
Fix: r_war3map_ground.c now exposes R_BeginSplatBatch / R_AddRectSplat / R_EndSplatBatch. R_RenderRectSplat was refactored to share R_SetupSplatState + R_GenerateSplatTiles with the batch path. R_DrawEntityShadows (in renderer/r_ents.c) runs a dedicated pre-pass that accumulates every visible unit shadow and flushes only when the texture changes or the buffer fills, collapsing N shadows into one upload + draw per contiguous texture run. WoW and SC2 provide immediate fallback implementations (WoW shadows already go through R_GameRenderShadow returning true; SC2 splats are flat quads).
Client entity collection + snapshot copying (was ~11%)
Both CL_ParseFrame (snapshot copy) and CL_AddEntities (collection) scanned all MAX_CLIENT_ENTITIES (16384) slots every frame, even though a scene has far fewer live entities.
Fix: client_state gains active_entities[] / num_active, a compact list of entity numbers whose current state carries a live model. It is maintained incrementally in CL_ReadPacketEntities (U_REMOVE drops the index; a !old.model → model transition adds it) and CL_ParseBaseline, reset in CL_BeginLoadingMap on map change and in CL_ClearState on disconnect. CL_ParseFrame and CL_AddEntities iterate the compact list instead of 16384 slots.
Server entity simulation (was ~10%)
G_RunEntities ran three full passes over globals.num_edicts, and G_RunEntity ran spell_run_frame + unit_updatestatuses + status compression for every edict — including freed edicts, since G_FreeEdict memsets in place and num_edicts is a high-water mark that never shrinks.
Fix: all three G_RunEntities loops skip !ent->inuse, and G_RunEntity guards with if (!ent->inuse) return;. Freed edicts carry no simulation state and are never re-sent, so this is a pure skip.
Lumber order routing hitch (August 2026)
A weighted ARM perf report captured while ordering a Peasant to harvest a tree resolved the server hot path through ai_walktree -> unit_changeangle_for_radius -> M_RefreshHeatmap -> CM_BuildHeatmapForRadius. Roughly half of that route-build cost was build_heatmap() repeatedly expanding the mover footprint, and the other half was bake_flow_field() computing vectors for every reachable cell even though the Peasant samples only its current location.
The route now keeps three performance boundaries:
- Harvest first searches only the small
HARVEST_RANGEinteraction disc for a collision-safe point with a direct static line from the worker. Reachable trees therefore steer to the interaction area without building a whole-map field. - Cached routes store integration prices only.
get_flow_direction()computes the four local samples needed for interpolation on demand; there is no whole-map flow-vector bake on the order path. - Radius-expanded static walkability uses a summed-area table over baked
nowalkcells, making each square footprint query O(1) during a genuine heatmap flood.
The unreachable-interior-tree behavior remains unchanged: when no direct harvest-range point is visible, the collision-sized integration field still drives the worker to the forest edge and exposes route completion/unreachability to Harvest, which owns retargeting.
Verification
make test # full umbrella, incl. in-engine WC3 suites
make test-wc3-engine # 344 tests / 918 assertions, incl. wc3_perf.run_entities_1900
make -j4 openwarcraft3 openwow opensc2 # all three renderers still build
build/bin/openwarcraft3 -data 'data/Warcraft III' +menu_main +screenshot 10 +com_frame_limit 20 # ROC
build/bin/openwarcraft3 -data 'data/Warcraft III' -tft +menu_main +com_frame_limit 20 # TFT
The wc3_perf.run_entities_1900 benchmark (games/warcraft-3/game/tests/t_game.c) measures G_RunEntities over 1900 active units.
Static-scenery snapshot saturation (RG40xx report)
Commit 215d9630 classified doodads/destructibles correctly but made G_FowPlayerCanSeeEntity return true for every
SVF_STATIC_SCENERY entity, including unexplored cells. A bounded ROC Human02 run measured 2,430 edicts, of which 2,299
were static scenery; all 2,299 passed visibility and filled the 1,024-entity snapshot cap on every sampled server frame.
This explained the report's simultaneous tree-pop improvement, unexplored waterfall rendering, and CPU-bound FPS drop.
The accompanying post-regression ARM address map
repeatedly resolves through SV_AddVisibleEntityCandidate / SV_BuildClientFrame, corroborating the measured path. It is
an addr2line mapping without sample counts, so do not derive percentages from repetitions in that text.
The reporter's 22 FPS baseline mapping
contains the expected MDX geoset, entity-shadow, simulation, and fog-update stacks but no server snapshot/candidate stack.
The later capture adding that path is qualitatively consistent with the scenery regression; neither mapping contains the
underlying sample weights, so this comparison identifies a new path but cannot quantify its frame-time share.
Static scenery now follows the explored plane, like buildings: it does not pop when current sight leaves, but unexplored
scenery neither consumes snapshot candidates nor renders through black fog. Diagnose future regressions by temporarily
counting total and visible SVF_STATIC_SCENERY entities in SV_BuildClientFrame during a bounded Human02 run; remove the
counter after capturing the result.
PortMaster building-cache claim audit
Commit f98c70c9
reports a Human02 increase from about 20 to 25 FPS after adding G_FowUnitIsBuilding. The submitted helper is never
called: G_FowPlayerCanSeeEntity still expands UNIT_IS_BUILDING(ent->class_id) directly, and the only executed change
is g_fow_building_cache_count = 0 once during G_FowInit. Release -O2 may remove the unused static helper entirely;
debug builds may retain it and shift code addresses, but neither case supplies a credible 20% frame-time reduction.
The intended optimization became unnecessary after typed-row binding. G_BindEntityData caches each immutable SLK row
on the edict, and UnitMetaBoolean/UnitMetaString resolve reflected fields through a compile-time FOURCC descriptor and
two offsets. Hot visibility code reads ent->runtime.flags directly. Test further changes separately from scene
progression, thermal/DVFS state, controller-helper changes, and build-mode changes; Human02 draw counts change as the
cinematic and entity population advance, so two instantaneous r_stats readings are not an A/B benchmark.
PortMaster post-fix address-map audit
The post-fix Human02 gist is three
addr2line outputs, not the sampled profile that produced the addresses. It contains symbols from FOW, snapshots, AI,
MDX animation/geosets, splats, particles, UI layout, minimap, text, and network parsing, but no sample counts,
percentages, timestamps, shared-library/GL-driver frames, or call tree. Repetition in the text is not a weight: use it
only to identify code that was reachable. Obtain perf report --stdio plus perf script or folded stacks from a fixed
Human02 interval before choosing work from this capture.
The best candidates based on scaling and current code, pending weighted data, are:
- Make FOW work dirty-driven.
G_FowUpdatecurrently hashes every blocker, clears every visible cell, and scans every edict every 100 ms. Track blocker/revealer cell changes and sight/alliance/modifier changes so a stationary world can skip blocker rebuilding and shadowcasts; clear only cells touched by the previous visible generation. - Stop scanning all static scenery for every client snapshot. Human02 has about 2,299 static-scenery edicts. Bucket them by FOW cell and add newly explored buckets to a per-client visible set, while retaining the ordinary dynamic entity path and snapshot delta contract.
- Cache immutable unit metadata at spawn.
G_AcquisitionRangeand building visibility still reach linear SLK lookup paths. Extend the existing per-unit balance data for hot class values instead of queryingUNIT_*macros during AI acquisition or snapshot construction. - Cache decoded client layout geometry until its payload or viewport changes, and preserve precomputed draw-order
lists.
SCR_DrawLayoutcallsSCR_Clearfor every visible layer every frame; that function clears the full frame arrays, decodes the wire payload, and resolves layout again before three frame scans. - Batch adjacent UI quads by texture/shader/blend/clip.
R_DrawImageExcurrently reachesglBufferDataandglDrawArraysfor each image. This is a plausible GL4ES/handheld driver cost even when desktop CPU profiles make it look small. Text already has an atlas batch path that demonstrates the appropriate boundary.
MDX pose sharing and splat topology caching are secondary measurement candidates. Harvest routing is separately covered by the weighted tree-order profile above. The
local Human02 pose-key audit found little identical-pose reuse; disabling shadows barely changed desktop FPS; and
get_flow_direction itself only scans four generations and performs a bilinear interpolation. Do not trade animation,
terrain-conforming shadows, or movement quality for these without weighted evidence. First isolate the handheld run
with r_norefresh, then A/B r_entities, r_unit_shadows, r_particles, and r_fogofwar over the same scripted time
window; a large r_norefresh gain implicates rendering/driver cost, while a low no-refresh rate implicates game/FOW/
snapshot work.
Cutscene snapshot and MDX report audit (August 2026)
The reported SV_SendClientDatagram subtree includes both SV_BuildClientFrame and SV_WriteFrameToClient; it does not
isolate wire serialization. The builder's overflow policy was an actual scaling defect: after filling the 1,024-entity
budget, every additional visible entity scanned all retained candidates to find the farthest one. The candidate set is
now a bounded max-heap, reducing selection from O(EK) to O(Elog K), followed by the same entity-number sort required
by delta encoding. A server test feeds farther entities first, forces nearer replacements after the heap is full, and
checks the final wire order.
Unchanged entity deltas now compare the contiguous entityState_t first. Exact matches skip the descriptor-table walk;
changed or forced states retain field-granular encoding and the existing wire format. This benefits local and remote
clients equally. ioquake3's SV_SendClientSnapshot does not bypass serialization for loopback clients: it exempts
loopback from rate limiting, while only bots consume snapshots without transmission. Keep Open Realm's one snapshot
contract too; a direct-pointer loopback path would hide remote-client cost and create a second state-delivery path.
WC3 MDX key tracks were already contiguous file-shaped allocations, so repacking was not the prerequisite claimed by
the animation report. The measured defect was lookup: each sample scanned the complete packed track once for sequence
bounds and again for its interpolation pair. MDLX_GetModelKeytrackValue now uses binary lower/upper bounds over the
existing variable stride. Tests preserve exact-key sampling, pre-first-key clamping, interpolation, interval-tail wrap,
and exclusion of adjacent-sequence keys.
Do not apply the remaining proposals without a weighted same-scene A/B capture:
nlerpchanges authored rotation timing, while Hermite/Bezier quaternion tracks require spherical quadrangle interpolation. Add a visual corpus and angular-error threshold before changing either the key-track interpolation or the separate old-frame/current-frame render blend.- MDX, M2, and M3 have separate loaders, animation clocks, and render paths. A shared SoA runtime is a cross-game architecture change, not an MDX load-time cleanup; measure each path before replacing its file-shaped representation.
- NEON is useful only after a scalar batch with four independent bones exists. Hierarchy concatenation is parent dependent, and the current global matrix cache is indexed by sparse authored node IDs. Benchmark a scalar packed-pose prototype before adding an architecture-specific kernel.
- Spawn/damage staggering changes JASS-visible timing and ordering. Shadow splats are already batched, and the local Human02 A/B above found negligible FPS change from disabling unit shadows. Neither change is justified by an address map that only proves reachability.
Re-profile a fixed Human02 interval with weighted stacks after these changes. Keep SV_BuildClientFrame,
MSG_WriteDeltaEntity, MDLX_GetModelKeytrackValue, and Quaternion_slerp separate in the report; only then choose
between spatial snapshot indexing, animation cursor state, pose packing, or interpolation approximation.
Implemented follow-up: sparse FOW clears and spawn-cached unit fields
A temporary bounded Human02 diagnostic at the old G_FowClearVisible loop confirmed that each connected player
scanned all 65,536 cells of the 256x256 FOW grid although only 2,569 cells were visible in the sampled updates. The
player grid now records which rows contain visible cells. The next update scans 256 row flags and only memsets rows
that were actually populated; it still marks those rows dirty so the existing network delta contract is unchanged.
The row-occupied state is allocated, reset, and freed with the other per-player FOW planes.
Immutable unit acquisition range and building classification are now resolved by SP_SpawnUnit. AI acquisition and
FOW/snapshot visibility read the cached values instead of repeatedly walking unit metadata/SLK tables. Acquisition
range retains the old default-to-half-day-sight and cap-to-day-sight behavior. Building classification retains the
explored-plane rule, so buildings stay shrouded after current vision leaves while ordinary units disappear.
Clean detached worktrees at d1a60ff4, both including the waterfall particle-FOW shader change, were built with
BUILD=release. Five independent runs produced these medians on Apple M1 arm64:
| In-engine benchmark | Before | After | Change |
|---|---|---|---|
G_FowUpdate, 256x256 grid, 160 revealers, 2 players |
0.20 ms/update | 0.14 ms/update | 30% less time |
G_AcquisitionRange, 1,900 units x 10 passes |
12.02 ms | 0.02 ms | 99.8% less time |
Run the comparison with:
make -j4 BUILD=release build/bin/openwarcraft3-tests
for run in 1 2 3 4 5; do
build/bin/openwarcraft3-tests -data build/tests/wc3-engine-data -tft +dedicated 1 +test 'wc3_perf.*'
done
These are subsystem timings, not a claimed handheld FPS increase. A 1,200-frame release Human02 render A/B changed draw counts throughout the cinematic and showed run-order/thermal noise larger than the expected server saving, so it was not treated as an end-to-end result. Repeat the fixed-scene weighted profile on the reported ARM/gl4es device to measure the actual frame-rate effect; renderer submission still dominates the available weighted profile.
Contributor FOW follow-up audit
The non-PortMaster FOW commits from sookyboo/portmaster_rebase_28_08_2026_2 were audited individually against current
main. ffcb57ca (replace the visible-cell loop with memchr/memset) is obsolete: the visible_rows implementation
above avoids scanning empty rows altogether. The dirty-blocker idea from 722469c8 and rim-cell list from e279778d
remain useful, but were reapplied rather than cherry-picked because current destructable lifecycle ownership has changed.
A temporary bounded ROC Human02 diagnostic over 50 FOW updates recorded 121,500 blocker-hash edict visits and 540,700
second-pass rim-cell visits for 41,909 committed rim cells. Steady updates now skip the blocker hash until a blocker is
spawned, freed, moved, scaled, hidden, killed, restored, or revived. Dirty updates retain the hash comparison as a
correctness check, so redundant invalidations do not rebuild the grid. The rim pass records its temporary blocker cells
while discovering them and commits only that list; initialization treats allocation failure as fatal instead of silently
falling back to the square scan. wc3_game.fow_blocker_cache_skips_clean_and_unchanged_dirty_updates covers the clean
cache hit, dirty/hash hit, and dirty/hash miss paths.
Retail Game.dll fog audit
The ROC demo data/Warcraft3demo/Game.dll (build 4486, SHA-256
286823c37a1083e91f07d040e46a9df7af4c4952e01fcbba460589bd4e297654) retains CFogOfWarMap.cpp,
CFogMaskTable.h, CFogModifier, and a fog-checksum diagnostic. With image base 0x6f000000, the routines at
Game.dll+0x38e730, +0x38e7f0, and +0x38e8b0 index rows through a stored shift, clip spans, expand a 16-bit mask
to both halves of a 32-bit word, and update paired planes with or/and. The dispatcher at +0x38f4b0 clamps a
map-space window, reads table entries, and calls those span helpers. Retail therefore uses packed, table-driven fog
planes rather than a byte setter for every cell.
The packed-mask series ending at af82327b is closer to retail in storage and update shape: it adds 16-bit current
and explored planes and writes word spans. It is not retail-exact. OpenRealm uses (width + 15) / 16 rows rather than
retail's power-of-two dimensions, generates circular spans with sqrtf instead of the unrecovered mask table, and
enables that path only with wc3_fow_fast, which skips blocker occlusion. The normal shadowcast remains the closer
visibility behavior around blockers; packed storage alone is not evidence that fast fog matches retail silhouettes,
modifier selection, plane semantics, or scheduling.
WC3_FOW_PACKED_MASK remains a removable build guard: delete its blocks in g_fow.c, its fields in g_local.h, and
the define in game.mk to remove the experiment. Do not enable wc3_fow_fast for correctness or parity validation
until the retail mask tables and modifier semantics have been recovered.
Local release A/B and remaining cost
A same-machine release-build comparison used ROC Human02, 150 console wait commands, cmd cancel, then 1,200 waits
with r_stats 1 at the default 2048x1536 Retina drawable. Detached worktrees are required because changing
BUILD=debug/release does not invalidate existing make outputs. Results are directional because the campaign continues
to change the scene during the one-second statistic windows:
| Revision/configuration | Settled FPS | Draws/frame |
|---|---|---|
6c274d96 suspected-good |
345, then 435 as entities left the view/snapshot | 115, then 34 |
e471c472 |
311-316 | 146-148 |
| current + explored-scenery fix | 338-351 | 146-148 |
current, r_unit_shadows 0 |
342-350 | 129-130 |
current, r_entities 0 |
up to 472 | 41 |
The current release therefore reaches the expected ~350 FPS locally even at the doubled Retina drawable. Disabling
shadows does not materially change FPS; entity model submission is the remaining scalable owner. A temporary strict
batch key (model, skin, frame, oldframe, team, flags) found 49 visible entities at the settled camera, 40 unique keys,
and only 14 entities across five repeated keys (largest group six). Naive MDX instancing has limited coverage there.
Do not treat the suspected-good 435 FPS window as equivalent content: its draw count fell to 34 as the older visibility
lifecycle removed scenery. Obtain a post-fix handheld profile with sample weights before redesigning MDX pose/geoset
submission; the address-only gists cannot choose between CPU submission and driver/GPU stalls.
Review regression cases (PR #164)
- The active-list invariant is membership iff
cl.ents[index].current.model != 0. Test baselines, duplicate adds, model-to-zero deltas, removal, slot reuse, frame copying, and map reset through the real client parser.SV_BuildClientFramealso transmits model-less entities carrying sound/events, soU_REMOVEis not the only way to lose a model. At34a556f2, a baseline{number=7, model=1}, followed by a packet delta to{number=7, model=0, sound=1}, leavesnum_active == 1; a subsequentU_REMOVEstill leaves that stale entry because removal is guarded byold.model. A focused wire-parser test reproduced both failures; the existing umbrella suite passes but does not exercise this lifecycle. Extendtests/test_net.cfor regression coverage. CL_BeginLoadingMapingames/warcraft-3/tests/test_client_stubs.cdoes not mirror the new list reset; standalone parser tests using that stub do not validate production map-reset behavior.- The WC3 splat implementation batches contiguous texture runs, not all occurrences of each distinct texture.
R_AddRectSplatflushes at every texture change;R_GenerateSplatTilesalso flushes at buffer capacity. Below capacity, A/A/B/B produces two draws, but A/B/A/B produces four. Entity order is not sorted by shadow texture and swap-removal changes it. Use both patterns when checking draw/upload counters; the distinct-texture claims above describe the optimization goal, not a guaranteed bound in this revision. - The follow-up
10904293restores the MDX particle size factor removed fromR_DrawParticlesin97a52d18.ReadParticleEmitterdoubles all threeParticleScalinglifecycle values once; both MDX head and tail spawns consume those values. The shared renderer and M2 particle scaling are unchanged.
Adaptive harvest interaction lanes (August 31, 2026)
An RG40xx-H weighted perf report after the blocked-footprint worker approach fix showed the new interaction helper dominating the server frame. The resolved game stack was G_RunFrame -> G_RunEntities -> G_RunEntity -> monster_think, with about 34% under ai_goldmine_walkback -> gold_find_direct_footprint_approach and another 11% under ai_walkmine -> gold_find_direct_footprint_approach. Both paths entered CM_FindApproachPointToFootprintForRadius, whose old candidate search called CM_DistanceToPathingFootprint for every candidate cell. Because that distance helper scans every authored footprint pixel, one edge selection became candidate-count times footprint-area work and accounted for roughly 45% of sampled cycles in that capture.
Caching one selected edge point for an entire resource leg was rejected after runtime testing. Packed Peasants are displaced by live-unit collision as they converge on a Town Hall; a point that was the nearest legal edge lane earlier in the leg can later lie behind another worker or across the blocked building footprint. Keeping that stale point made several returners repeatedly steer back toward the old lane and visibly dance instead of completing the deposit. Gold and lumber therefore re-select the nearest direct footprint edge from the worker's current position each think. The later 30-Peasant Human02 crowd simulation moved live-unit separation out of same-tree lane allocation entirely: tree approach keeps the closest direct legal chop point, while resource-worker local avoidance queues same-stream workers and uses deterministic bounded right-first passing only for crossing or persistently blocked traffic. This removes the previous full-edict same-tree slot/occupancy scans as well as their forced angular detours. See worker-crowd-routing.md.
CM_FindApproachPointToFootprintForRadius now makes that adaptive selection cheap without changing its authored-footprint semantics. The pathmap owns a reusable one-byte-per-cell scratch mask. For each blocked footprint pixel, the helper marks only nearby pathmap cell centres whose exact grid-rectangle distance is within the requested range; it then scans those marked candidates once. This replaces the nested candidate-by-footprint distance scan with footprint-area times a small local range plus one candidate pass. Once a direct candidate has been found, farther candidates also skip redundant line-walkability traces. The mask is scratch state only and is cleared over the small search rectangle on each call.
Keep the single CM_DistanceToPathingFootprint call in the per-think interaction-range check: that authored-footprint distance decides exact mine/deposit completion. The optimized cost is the optional staging search, not the behavior-owned interaction boundary. wc3_pathfinding.footprint_approach_respects_sparse_path_texture protects irregular footprint accuracy, and wc3_movement.gold_return_reselects_footprint_edge_after_displacement protects the adaptive lane behavior that prevents the Town Hall dance.