Fog of War Algorithm Report
Executive Summary
WC3 gameplay visibility is an authoritative low-resolution per-player grid, but the renderer also retains an older high-resolution path that attempts per-revealer occlusion by drawing entity silhouettes. That visual path can look nice, but it scales poorly when many revealers and blockers are present.
Recommended direction: keep the authoritative grid, but calculate only the connected viewers and their shared-vision
owners. Rebuild blocker cells only when blocker state changes, then run grid shadowcasting for those viewers. Upload
the resulting R8 data once per packet and let the renderer filter it. Do not replace this with a result cache: the
large win is eliminating irrelevant players and repeated whole-grid/publication work.
Current Implementation
Relevant files:
renderer/r_fogofwar.crenderer/r_draw.cclient/cl_view.ccommon/shared.hdocs/games/warcraft-3/architecture/map-renderer.md
The current renderer path:
- Collects revealers from
tr.viewDef.entitieswhoseteam == tr.viewDef.player, not hidden, and flaggedRF_FOW_REVEALER. - Collects blockers flagged
RF_FOW_BLOCKERand withradius >= 10. - Builds one blocker vertex buffer if each blocker is near any revealer.
- For each revealer:
- Clears alpha in
FOW_RT_IMMEDIATE. - Draws a smooth circular sight texture.
- Draws blocker silhouette wedges using a ray-cast-like shader.
- Draws white visibility through the resulting alpha mask.
- Accumulates
FOW_RT_IMMEDIATEintoFOW_RT_HISTORYwithGL_MAX. - Produces
FOW_RT_RESULTas half history plus half immediate. - Samples the result in world/minimap shaders.
The authoritative WC3 path now also receives RLE-compressed svc_fogofwar row chunks from the server. The client
decodes each chunk into visible/explored planes, updates only those rows in its combined R8 buffer, and publishes
the assembled texture once after the complete server message. The renderer defines storage on the first update and
uses glTexSubImage2D while dimensions remain unchanged. Chunk boundaries must never trigger independent texture
uploads: they are transport framing, not distinct visual states.
Server reveals are viewer-centric and do not cache reveal results. Each update builds a stack-local bitmask of
connected viewers plus a stack-local owner-to-viewer mask, clears only those grids, and applies each source directly
to viewers that receive its owner's vision. Entities owned by players with no consuming viewer are rejected before
revealer classification, and their unused grids are never touched. Active fog modifiers follow the same viewer rule.
The player must be marked via G_FowConnectPlayer before the first update so its full sync contains current visibility.
On Orc01, a like-for-like 1 kHz Time Profiler capture over 1,200 bounded frames measured G_FowUpdate falling from
430 ms to 61 ms inclusive across the capture (86% lower). The unit-reveal subtree fell from 227 ms to 9 ms (96%
lower). G_FowBlockersChanged is now the largest remaining FOW component at 41 ms across the capture; optimizing that
requires explicit blocker lifecycle invalidation and is separate from the no-cache reveal algorithm.
The original client decoder expanded every run one cell at a time, doing division and modulo for each cell. It then
rescanned the complete grid and called glTexImage2D after every chunk. A 1 kHz Time Profiler capture after replacing
that work with memset runs, row-local compositing, packet-level publication, and glTexSubImage2D measured 2 ms
inclusive in CL_ParseFogOfWar over the complete 20-second capture; the decoder had no sampled self time. Reproduce
with:
xctrace record --template 'Time Profiler' --time-limit 20s --launch -- \
build/bin/openwarcraft3 -data 'data/Warcraft III' +map 'Maps\Campaign\Orc01.w3m' +com_frame_limit 1200
WarSmash is a useful behavioral reference, not a fast implementation to copy verbatim: it uses an ordinary logical player fog grid and separates that state from minimap presentation. OpenRA likewise treats shroud as simulation-owned spatial state exposed to rendering. Both support the same architectural boundary used here: visibility is ordinary game data; texture work is a publication step, never part of RLE parsing.
Important constants:
FOW_UPDATE_INTERVAL_MS = 100SIGHT_DISTANCE = 2000SIGHT_SIZE = 64- FOW render target resolution is map shadow resolution, roughly
(width - 1) * 4by(height - 1) * 4.
Complexity Smell
The expensive part is not just the render-target size. It is the combination of broad blocker search and repeated rendering:
R_CasterNearRevealersmakes blocker collectionO(num_blockers * num_revealers).- The resulting blocker buffer is drawn once per revealer, so raster work trends toward
O(num_revealers * nearby_blocker_vertices). SIGHT_DISTANCEis fixed at2000, not per-unit sight radius, so many units may reveal too much and search too widely.- The legacy renderer visibility and history are GPU-only; WC3 gameplay must continue using its authoritative server grid.
Verdict: yes, it is overly complex for the first fast, correct RTS FOW pass. The current path is closer to a visual shadow/occlusion experiment than a robust RTS visibility system.
Modern RTS Behavior Target
Public StarCraft II documentation does not expose Blizzard's exact internal algorithm, but its behavior strongly suggests a grid/layer visibility model rather than per-object renderer-only shadowing:
- Fog has at least three player-facing states: unexplored/dark, explored/grayed, and currently visible.
- At game start, multiplayer terrain and fixed resource locations are visible, while enemy movements/new structures remain hidden in fog.
- Units, structures, and abilities provide active vision.
- High ground, ramps, smoke/undergrowth, and sight blockers affect vision.
- The public StarCraft II learning/API interface exposes visibility as feature layers: fixed-size spatial grids where each pixel maps to a consistent amount of world space. The raw API reports visible units, while fog still exists.
Design inference: model visibility as simulation data first, then render it. A renderer texture is an output of visibility, not the source of truth.
Simpler Baseline Pattern
A simple RTS FOW pattern is:
per player:
fog_state[cell] = masked | fogged | visible
on fixed FOW tick:
visible cells become fogged
apply permanent/scripted fog modifiers
for each live revealer:
stamp or compute visible cells inside sight radius
upload dirty region or full R8 texture to GPU
render:
sample fog texture in terrain/entity/minimap shaders
linearly filter or blur for soft edges
This has several advantages:
- Predictable cost: roughly
O(players * grid_cells_changed + revealers * sight_area)before occlusion. - Easy multiplayer: each player owns one grid, shared vision ORs/merges allied grids.
- Easy gameplay queries: cell lookups are cheap and deterministic.
- Easy renderer integration: one texture per local player, optionally double-buffered for smooth transitions.
- Easy save/load/replay: fog history is ordinary byte data.
The main downside is visual coarseness unless the renderer smooths it.
Algorithm Options
Option A: Circle Stamp, No Occlusion
For each revealer, stamp a precomputed disk mask into the player's current-visibility grid. Keep explored as a persistent bit. Use bilinear texture filtering and optional blur for visuals.
Cost: very low.
Quality: acceptable for an RTS with no tactical LOS blockers, or as an immediate baseline.
Good fit here: excellent first step. It would probably outperform the current path by a wide margin and give game code an authoritative visibility state.
Option B: Grid Shadowcasting
Use recursive/symmetric shadowcasting on the FOW grid from each revealer. The map provides opaque cells from cliffs, terrain height transitions, trees, buildings, or explicit sight blockers.
Cost: usually near O(visible_cells_per_revealer), because fully shadowed sectors are skipped.
Quality: strong tactical LOS, especially on grid-like terrain.
Good fit here: best second phase if ground-unit LOS blockers matter. Use fixed-size arrays and octant scans in Quake/id style, no heap allocations in the hot path.
Option C: Height-Aware Grid LOS
Keep circle stamping, but gate cells by simple terrain rules:
- Ground units cannot see cells above a height threshold unless the ray crosses a ramp or the unit is also high.
- Air units ignore ground blockers.
- Scripted sight blockers are opaque only for ground vision.
Cost: medium if implemented with shadowcasting; high if implemented as Bresenham rays to every perimeter cell.
Quality: close to modern RTS expectations without full polygon visibility.
Good fit here: likely the right gameplay target after Option A.
Option D: GPU Render Texture Stamping
Render reveal masks into a low-resolution R8/RG8 render target, optionally with instanced quads, then compose current/history textures on GPU.
Cost: low GPU cost if batched; little CPU cost.
Quality: visually smooth. Gameplay still needs CPU-side visibility or a mirrored grid.
Good fit here: good renderer layer, but not as the sole source of truth.
Option E: Polygon/Shadow-Volume LOS
Use blocker geometry and compute visibility polygons or shadow volumes.
Cost: expensive and more complex to maintain.
Quality: visually rich but usually unnecessary for an RTS FOW grid.
Good fit here: not recommended for the core FOW system. Keep this as a future visual enhancement only.
Recommended Architecture
Use a two-layer design:
- Game visibility grid
- Owned by game/server-side state, not renderer-only state.
- One compact grid per player.
- Store bits/bytes:
VISIBLE,EXPLORED, maybeDETECT,RADAR,SCRIPT_VISIBLE. - Update on a fixed cadence, probably 5-10 Hz.
-
Merge allied/shared vision explicitly.
-
Renderer fog texture
- Client receives or derives the local player's grid.
- Upload as
GL_R8orGL_RG8. - Use one channel for target/current visibility and one channel for history or transition if useful.
- Smooth visually in shader; do not make the simulation depend on the smoothed texture.
Suggested first implementation path, when implementation begins:
- Add a small FOW grid module on the game side.
- Implement Option A disk stamping using per-radius cached masks.
- Replace the current per-revealer blocker ray rendering with a simple texture upload path.
- Add gameplay queries and entity visibility filtering.
- Add Option B or C only after profiling and after deciding exact terrain/sight-blocker semantics.
Specific Optimizations If Keeping Current Renderer Path
If the current renderer algorithm is retained temporarily:
- Spatially bucket blockers by FOW/grid tile, so blocker collection is
O(revealers * local_bucket_contents)instead ofO(blockers * revealers). - Build a blocker list per revealer or draw only blockers intersecting that revealer's sight radius.
- Replace
SIGHT_DISTANCEwith per-unit sight radius. - Use
GL_R8render targets instead of RGBA for masks/history/result. - Batch revealer sight circles with instancing instead of one draw setup per revealer.
- Track dirty revealers and skip FOW updates when no revealer moved across a fog cell.
- Keep
FOW_RT_HISTORYas a max/OR state, but mirror it in CPU state for gameplay.
These are still second-best compared with simplifying the architecture.
Open Questions
- Should FOW be authoritative on the server, client-only, or server-authored but client-rendered?
- What exact gameplay states are required: visible, explored, masked, detector, radar, last-known buildings?
- Do trees/buildings block vision, or only terrain/sight-blocker doodads?
- How should high ground work for ground units, air units, and scanner-like abilities?
- What grid scale is acceptable: 128 world units, 256 world units, or tied to pathing cells?
- Is smooth visual interpolation required, or is Warcraft-style cell stepping acceptable initially?
Retail Game.dll Reverse Engineering
The bundled 2002 demo Game.dll contains a CFogOfWarMap implementation. Its initializer rounds the map width up to
the next power of two, stores the corresponding shift count, and allocates multiple 16-bit planes. The update helpers
address rows with row << shift, clamp the horizontal span once, then OR or AND repeated 16-bit words; they do not
visit every cell through a byte-oriented setter. One helper clears the current plane while preserving the history
plane, and another applies rectangular spans to both planes. This is consistent with current visibility plus persistent
exploration, and explains the retail implementation's low constant cost.
WC3_FOW_PACKED_MASK=1 currently enables an evidence-backed prototype of that row-mask representation in
games/warcraft-3/game/g_fow.c. It remains opt-in because the compatibility byte plane and per-cell explored updates
currently cost more than the existing path on the 256×256/160-revealer benchmark (0.16 ms versus 0.13 ms in the
release build). The next optimization is to make the packed history plane authoritative for queries and wire packing,
then unpack only when a client publication actually requires bytes.
Sources
- Blizzard, StarCraft II Game Guide: Map Elements
- Liquipedia, Fog of War, Sight, Sight Blockers
- Vinyals et al., StarCraft II: A New Challenge for Reinforcement Learning
- RogueBasin, FOV using recursive shadowcasting
- Albert Ford, Symmetric Shadowcasting
- Layered Fog of War docs, Getting Started