Implements a new SH light grid that runs alongside the legacy Q3 light grid, storing 9 RGB L2 spherical harmonic coefficients per grid point for accurate directional lighting of dynamic objects from all angles. BSP format: v47 with 19-lump header (160 bytes) when -sh is used, v46 with 17-lump header (144 bytes) otherwise. SH data stored in LUMP_LIGHTGRID_SH (index 18) with a header containing grid bounds/size/mins followed by the coefficient array. Stock Q3 engines read v46 lumps unchanged. New CLI flags: -sh (enable), -gridscalesh (density multiplier, default 2x), -gridsh (explicit cell size). SH grid receives bounced light with -bouncegrid. Also adds libjpeg-turbo as a proper build dependency with its own vcxproj, fixing the previous external engine path requirement. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
17 KiB
Source Engine 2013 (VRAD) vs Q3Map2 — Lighting System Analysis
1. VRAD Overview
VRAD (Valve RADiosity) is the final stage of the Source Engine map compilation pipeline (after VBSP and VVIS). It takes a compiled BSP file and embeds all precomputed lighting data into it.
Pipeline Stages
- Initialization — Load BSP, parse light entities, load
lights.rad/<mapname>.radtexture-light definitions - Patch Creation — Create one patch per face, then recursively subdivide into a binary tree until patches are smaller than the "chop" size on all axes
- Macro Texture Setup — Optional world-space TGA mask for lighting modulation
- Visibility Determination — Per-face visibility using PVS data (or mark all visible in incremental mode)
- Direct Lighting — Faces broken into triangles, partitioned into a KD-tree. For each luxel sample, ray-cast to light sources through the KD-tree. Apply attenuation (constant/linear/quadratic).
- Indirect Lighting (Radiosity) — Compute form factors between patches (Fij), then use progressive refinement (shooting from brightest patch each iteration) rather than full matrix inversion. Default 100 bounces.
- Post-Processing — Displacement lighting, detail prop lighting, leaf ambient cubes, static prop per-vertex lighting
- Export — Write lightmaps to BSP lighting lump
Core Radiosity Equation
Bi = Ei + Pi * SUM_j(Bj * Fij)
Where Bi = radiosity of patch i, Ei = emissivity, Pi = reflectivity, Fij = form factor (proportion of light from j reaching i).
VRAD uses progressive refinement for memory efficiency — it does not store a full NxN form factor matrix.
2. What Source Engine Bakes
2.1 Lightmaps
- Luxels (lighting pixels) form a grid on every brush face
- Resolution controlled by "Lightmap Scale" per face (default 16 units per luxel; can be 2, 4, 8, 16, 32, 64, 128)
- Storage format:
ColorRGBExp32— 4 bytes per sample (R, G, B as bytes + signed exponent byte). Actual color = (R, G, B) * 2^exponent. This gives HDR range from the storage format itself. - Lightmaps are packed into pages in the BSP lighting lump
- Up to 4 light styles per face (each style gets its own lightmap layer)
- Bumpmapped surfaces store 4x lightmap samples: 3 directional basis lightmaps + 1 flat (non-bumped) fallback
2.2 Ambient Cubes (CompressedLightCube)
- Stored per visleaf (not per-face) — a sparse set of sample points in each leaf
- Each ambient cube = 6 ColorRGBExp32 values (one per cardinal direction: +X, -X, +Y, -Y, +Z, -Z)
- Used to light dynamic objects (players, physics props, prop_dynamic) that don't have lightmaps
- Evaluation: weighted blend of 6 RGB values based on the world-space normal of the receiving surface
- VRAD distributes samples somewhat evenly across leaves; smaller leaves get denser coverage
2.3 Light Grid (comparison to Q3)
Source does NOT use a uniform 3D grid like Quake 3. Instead, it uses the per-leaf ambient cube system described above. This is more memory-efficient but less granular — dynamic objects in large leaves get coarser lighting approximation.
2.4 Static Prop Per-Vertex Lighting
- Enabled via
-StaticPropLightingVRAD flag - For each vertex of each
prop_static, VRAD computes a color+brightness value - The engine interpolates between vertices at runtime to create smooth gradients
- Without this flag, static props use origin-based lighting (single sample at the prop's origin, applied uniformly)
- Static props can also opt into bounced lighting via keyvalue "Enable Bounced Lighting"
-StaticPropPolystreats prop triangles as occluders for shadow casting
2.5 Bounced/Indirect Lighting (Radiosity)
- Faces subdivided into patches (binary tree structure)
CPatchstores: spatial bounds, winding polygon, normal (phong-interpolated), accumulated light (totallight, directlight, samplelight), reflectivity, area- Samples (one per luxel) are distinct from patches — samples map to luxel positions, patches accumulate radiosity
- Form factors computed between patch pairs based on visibility and geometry
- Progressive refinement shoots light from highest-energy patch each iteration
- Default bounce count: 100
-finalflag increases quality by casting more rays for sky and indirect light
3. Light Entity Types in Source Engine
3.1 light (Point Light)
- Omnidirectional point source
- Static (baked into lightmaps by VRAD)
- Parameters: color, brightness, constant/linear/quadratic attenuation
- Default falloff: pure inverse-square (constant=0, linear=0, quadratic=1)
- Can be named (makes it switchable, adds second lightmap page)
3.2 light_spot (Spotlight)
- Directional cone light with inner cone (full brightness) and outer cone (falloff to zero)
- Same attenuation model as
light - Static, baked by VRAD
3.3 light_environment (Sun/Sky)
- Defines two lighting components:
- Brightness: Direct sunlight (directional, parallel rays, specific color/intensity)
- Ambient: Diffuse skylight (omnidirectional fill, separate color/intensity)
- Direction set by entity's pitch/yaw
- Only one per map (last one wins)
- Constant falloff (parallel rays, no distance attenuation)
- Sky light enters through
tools/toolsskyboxsurfaces
3.4 light_dynamic
- Calculated at runtime, NOT baked
- Casts dlights on brushes, elights on models
- Can be toggled on/off, can track entities
- Limit: 3 dynamic lights per model simultaneously
- Cannot cast shadows
- Higher performance cost than static lights
3.5 env_projectedtexture
- Projects a texture as a spotlight with FOV control
- Fully dynamic, can move
- Only 1 active projected texture at a time (engine limitation in most branches)
- Used for flashlights, dynamic shadows
3.6 env_cascade_light (CS:GO+)
- Cascaded shadow maps from sky
- 3 detail levels across configurable distance
- Cannot coexist with projected textures
4. Advanced Features
4.1 HDR Lighting Pipeline
- VRAD compiles separate LDR and HDR lighting data (flag
-both,-hdr, or-ldr) - With
-both, VRAD runs twice (once for each mode) ColorRGBExp32format inherently supports HDR via the exponent byte (range far exceeds 0-255)- At runtime, the engine performs tone mapping: bloom on colors above 100% brightness, virtual camera aperture adjustment for over-exposure
mat_hdr_levelcontrols HDR mode at runtime- Left 4 Dead branch and later: single compile pass for both modes
4.2 Bump-Mapped Lightmaps (Radiosity Normal Mapping)
This is one of Source's most distinctive features, and it is entirely absent from q3map2.
- For bumpmapped surfaces, VRAD stores 3 directional lightmaps plus 1 flat fallback (4x memory)
- The 3 lightmaps correspond to the HL2 basis vectors in tangent space:
basis0 = ( 0.816497, 0.0, 0.57735)
basis1 = (-0.408248, 0.707107, 0.57735)
basis2 = (-0.408248, -0.707107, 0.57735)
- These 3 vectors point roughly 120 degrees apart in the tangent plane, tilted 30 degrees up from the surface
- At runtime, the shader combines the 3 lightmaps weighted by the normal map:
diffuse = normal.x * lightmap0 + normal.y * lightmap1 + normal.z * lightmap2
(where normal is transformed into the RNM basis)
- This allows per-pixel lighting variation from baked lightmaps — bumps catch light directionally
4.3 Self-Shadowed Bumpmaps
- Chris Green's SIGGRAPH 2007 paper: "Efficient Self-Shadowed Radiosity Normal Mapping"
- Adds directional occlusion to bump maps at no extra texture memory cost
- When bump data comes from height maps, standard RNM only shows orientation effects; self-shadowing adds occlusion cues where bumps block light
- Actually faster than the non-shadowing solution due to optimized shader paths
- The directional basis vectors encode both lighting AND shadowing information
4.4 Light Styles (Switchable/Animated Lights)
- A light gets a "style" by: giving it a targetname, setting a style index, or setting a pattern string
- Switchable lights compile two lightmap layers (on/off states) per affected face
- Engine blends between layers at runtime
- Hard limit: 4 light styles per face, 32 total lightmap pages
- Animated patterns use character strings where 'a'=dark, 'z'=bright (e.g., "mmmaaammmaaam" for flickering)
- Exceeding limits causes "Too many light styles on a face" warning
4.5 Texture Light Emission (Surface Lights)
- Defined in
lights.rador<mapname>.radfiles - Format:
<texture_name> <R> <G> <B> <brightness> - Every luxel of a brush face using that texture emits light during VRAD compilation
- The emitting surface is NOT self-lit by its own emission (needs
$selfillumorUnlitGenericshader separately) - Map-specific
.radoverrides globallights.rad - Conceptually similar to q3map2's
q3map_surfacelightshader directive
4.6 Lightmap Filtering and Anti-Aliasing
- VRAD applies filtering to lightmap edges
- Supersampling available via quality settings
-finalflag dramatically increases ray count for smoother indirect lighting- No explicit
-samplesor-filterflags like q3map2; quality is controlled via-extra/-finalpresets
4.7 Macro Textures
- Optional world-space TGA file (matching BSP name) modulates lighting globally
- Maps world bounds onto texture coordinates
- Alpha channel controls per-luxel darkening (255 = no effect, 0 = full shadow)
- Used for large-scale ambient occlusion or artistic lighting control
4.8 Ambient Occlusion
- Source Engine does NOT have a dedicated AO pass in VRAD (unlike q3map2's
-dirtyflag) - AO-like effects emerge naturally from radiosity bounces and the self-shadowed RNM system
- Some Source Engine branches add screen-space AO at runtime
5. Feature Comparison Table
Features Source Has That Q3Map2 Doesn't
| Feature | Source Engine (VRAD) | Q3Map2 | Gap Severity |
|---|---|---|---|
| HDR lightmaps | ColorRGBExp32 (4 bytes, HDR via exponent) |
24-bit RGB, clamped 0-255 | Major |
| Bump-mapped lightmaps (RNM) | 3 directional basis lightmaps + 1 flat per bumped face | Single flat lightmap only | Major |
| Self-shadowed bumpmaps | Directional occlusion baked into RNM basis | None | Major |
| Ambient cubes | 6-axis CompressedLightCube per leaf for dynamic objects |
Uniform 3D grid (direction + ambient) | Medium |
| True progressive radiosity | Form-factor based, patch-to-patch energy transfer | Fake radiosity (spawn point lights at hit surfaces) | Medium |
| Static prop per-vertex lighting | VRAD traces light to each vertex of static models | No model-aware lighting | Medium |
| Hard-falloff / smoothstep lights | Start/end distance with smoothstep fade | No equivalent | Small |
| CLQ attenuation model | Full Constant/Linear/Quadratic per light | Simpler falloff | Small |
| Macro textures | World-space TGA modulates lighting globally | None | Small |
Features Q3Map2 Already Has That Match or Exceed Source
| Feature | Q3Map2 | Source |
|---|---|---|
| Ambient occlusion | Explicit -dirty flag with configurable depth/samples |
No dedicated AO — emerges from bounces |
| Deluxe maps | Stores average light direction per luxel | Similar concept via RNM basis |
| Floodlight | Fills dark areas with ambient fill | No equivalent |
| Light styles | Basic support exists | More mature (4 layers, runtime blending) |
| Surface lights | q3map_surfacelight shader directive |
lights.rad file — functionally equivalent |
| Sun/sky system | _sun entity + sky shaders with deviance jitter |
light_environment — similar capability |
6. The RNM Basis — Source's Key Innovation
Source stores 3 directional lightmaps per bumpmapped face using these tangent-space basis vectors:
basis0 = ( 0.8165, 0.0000, 0.5774) // 120° apart in tangent plane,
basis1 = (-0.4082, 0.7071, 0.5774) // tilted 30° up from surface
basis2 = (-0.4082, -0.7071, 0.5774)
The shader recombines at runtime:
diffuse = dot(n, b0) * lm0 + dot(n, b1) * lm1 + dot(n, b2) * lm2
This gives per-pixel lighting variation from baked data. This is the single biggest visual quality difference between Source and Q3-engine games.
7. Q3Map2 Lighting System Architecture (Current State)
5 Source Files (~340K total)
| File | Size | Role |
|---|---|---|
light.cpp |
82K | Entry point (LightMain), entity lights, core sample tracing |
light_ydnar.cpp |
106K | Lightmap mapping, illumination, dirt, floodlight |
lightmaps_ydnar.cpp |
89K | Lightmap allocation, packing, storage |
light_bounce.cpp |
25K | Radiosity / bounce lighting |
light_trace.cpp |
39K | Ray tracing through BSP (shadow tests) |
Pipeline Flow (orchestrated by LightMain())
1. CreateEntityLights() — parse light entities from BSP
2. CreateSurfaceLights() — create area/sun/sky lights from shaders
3. SetupEnvelopes() — compute bounding boxes for light culling
4. SetupGrid() + TraceGrid() — light the 3D sample grid [threaded]
5. MapRawLightmap() — project surfaces onto lightmap UV [threaded]
6. DirtyRawLightmap() — ambient occlusion / dirt pass [threaded]
7. FloodLightRawLightmap() — fill dark areas with floodlight [threaded]
8. IlluminateRawLightmap() — trace all lights to luxels [threaded]
9. IlluminateVertexes() — light non-lightmapped surfaces [threaded]
10. Bounce loop (if bounce > 0):
└── RadCreateDiffuseLights() → SetupEnvelopes() → re-illuminate
11. StoreSurfaceLightmaps() — pack luxels into final lightmap pages
Key Data Structures (in q3map2.h)
light_t— light source (type, origin, color, photons, envelope, flags)ELightType— Point, Area, Spotlight, SunLightFlags— AttenLinear, AttenAngle, Negative, Dark, Grid, Surfaces, Fast...trace_t— ray trace state (origin, direction, hit info, accumulated color)rawLightmap_t— working lightmap with luxel arrays, floodlight, ambientoutLightmap_t— final packed lightmap pagesun_t/skylight_t— sun and sky light definitions
Core Tracing Function
LightContributionToSample(trace_t*) at light.cpp:777 — the inner loop that calculates one light's contribution to one sample point. Handles all light types, attenuation modes, and shadow rays via TraceLine().
Threading
Uses std::thread with work-stealing dispatch (RunThreadsOnIndividual). All heavy passes (grid, mapping, dirt, illumination, vertex, floodlight, radiosity) run multi-threaded.
Post-Processing Pipeline
ColorToBytes() at light_ydnar.cpp:62: Brightness → Contrast → Gamma → Exposure → Saturation → Clamp
8. Proposed Feature Roadmap (Priority Order)
Phase 1: HDR Lightmaps
Foundation for everything else. Switch from byte[3] to ColorRGBExp32 or half[3] storage. Requires BSP format extension + renderer support.
Phase 2: Radiosity Normal Mapping (Bump-mapped Lightmaps)
The biggest visual win. Requires:
- Computing tangent-space basis vectors per face
- Tracing 3 directional lightmaps per bumped surface
- 4x lightmap storage for bumped faces
- Shader-side recombination with normal maps
Phase 3: True Progressive Refinement Radiosity
Replace q3map2's "fake bounce" (spawning point lights at hit surfaces) with proper patch-based energy transfer. More physically correct indirect lighting.
Phase 4: Ambient Cubes / Enhanced Light Grid
Upgrade the 3D light grid to store 6-axis directional ambient (one color per cardinal direction) for better dynamic object lighting.
Phase 5: Hard-Falloff Lights / CLQ Attenuation
Add smoothstep fade between start/end distances, and full constant/linear/quadratic attenuation control per light.
Phase 6: Static Prop Per-Vertex Lighting
Trace light to model vertices for baked model illumination.
9. References
- How VRAD Works (gpurad technical document)
- Source Lighting Technical Analysis: Part One (Mapcore)
- Source Lighting Technical Analysis: Part Two (Mapcore)
- Shading in Valve's Source Engine (SIGGRAPH 2006 PDF)
- Efficient Self-Shadowed Radiosity Normal Mapping (SIGGRAPH 2007, Chris Green, Valve)
- VRAD - Valve Developer Community
- HDR Lighting Basics - Valve Developer Community
- Advanced Lighting - Valve Developer Community
- BSP (Source) format - Valve Developer Community