Implement Source engine lightcache system for entity lighting #30

Closed
opened 2026-03-18 13:21:52 +00:00 by kit · 1 comment
Owner

Implement the full Source engine lightcache pipeline for entity lighting, matching how the engine combines ambient cubes with world lights (point lights, spotlights, sun) including occlusion traces.

Current state

We have a partial implementation:

  • BSP leaf ambient cube sampling (lumps 51/55, 52/56)
  • BSP node tree traversal for leaf lookup
  • Leaf sky visibility flags for binary sun occlusion
  • Per-entity onBeforeRender callbacks for per-draw-call lighting
  • Basic tone mapping for HDR ambient cube values

But we're missing the core of what Source does — the world lights system.

How Source does it (from SDK 2013 source)

Entity lighting = ambient cube + up to 4 local lights, computed per-entity:

  1. Start with the leaf's pre-baked ambient cube (what we have now)
  2. Iterate ALL world lights from BSP lump 15/54 (dworldlight_t[])
  3. For each light:
    • PVS fast reject — skip if light's cluster isn't visible from entity's leaf
    • Distance/direction reject — skip if too far or outside spotlight cone
    • Occlusion trace — ray trace from entity to light (or toward sun for emit_skylight)
    • Compute intensity based on distance falloff + angular attenuation
  4. Top 4 brightest lights become locallight[] entries with full per-vertex shading
  5. Remaining lights get folded into the ambient cube via hemisphere projection
  6. Lights with DWL_FLAGS_INAMBIENTCUBE are skipped (already baked into leaf ambient)

Shader pipeline (from common_vs_fxc.h)

color = AmbientLight(worldNormal)           // ambient cube: c21-c26
      + sum(DoLightInternal(pos, N, i))     // up to 4 local lights: c27-c46

Each local light has: position, direction, color, attenuation params, spotlight cone angles.

Requirements

1. Parse world lights from BSP

Parse lump 15 (LUMP_WORLDLIGHTS) or 54 (LUMP_WORLDLIGHTS_HDR):

dworldlight_t: 88 bytes
  Vector origin (12)        — world position
  Vector intensity (12)     — RGB * brightness (HDR, can exceed 1.0)
  Vector normal (12)        — direction (spotlights/sun)
  int32 cluster (4)         — PVS cluster
  int32 type (4)            — emittype_t enum
  int32 style (4)           — light style index
  float stopdot (4)         — spotlight inner cone cos
  float stopdot2 (4)        — spotlight outer cone cos
  float exponent (4)        — spotlight falloff exponent
  float radius (4)          — cutoff distance (0 = infinite)
  float constant_attn (4)   — attenuation constant term
  float linear_attn (4)     — attenuation linear term
  float quadratic_attn (4)  — attenuation quadratic term
  int32 flags (4)           — DWL_FLAGS_INAMBIENTCUBE = 0x0001
  int32 texinfo (4)
  int32 owner (4)

emittype_t: 0=surface, 1=point, 2=spotlight, 3=skylight, 4=quakelight, 5=skyambient

2. Implement lightcache per entity

For each entity position:

  • Find BSP leaf (existing)
  • Get leaf ambient cube (existing)
  • Iterate world lights, reject by PVS visibility
  • For remaining lights, compute intensity at entity position:
    • emit_skylight: check leaf sky flag (existing), full contribution if visible
    • emit_point: intensity / (const + linear*dist + quad*dist²), with radius cutoff
    • emit_spotlight: same + angular attenuation via stopdot/stopdot2
    • emit_surface: same as spotlight with 90° cone
    • Skip emit_skyambient (already in leaf ambient cube)
    • Skip lights with DWL_FLAGS_INAMBIENTCUBE when leaf ambient is used
  • Select top 4 brightest as local lights
  • Fold remaining into ambient cube

3. Update shader to handle local lights

Add to the uber-shader:

  • uniform vec3 localLightPos[4]
  • uniform vec3 localLightColor[4]
  • uniform vec3 localLightDir[4] (for spotlights/directional)
  • uniform vec4 localLightAtten[4] (const, linear, quad, type)
  • uniform vec4 localLightSpot[4] (stopdot, stopdot2, exponent, 0)
  • uniform int numLocalLights

Per-vertex or per-fragment light evaluation:

for (int i = 0; i < numLocalLights; i++) {
    vec3 lightDir = normalize(localLightPos[i] - worldPos);
    float NdotL = max(dot(N, lightDir), 0.0);
    float dist = length(localLightPos[i] - worldPos);
    float atten = 1.0 / (atten[i].x + atten[i].y * dist + atten[i].z * dist * dist);
    // spotlight cone...
    color += localLightColor[i] * NdotL * atten;
}

4. Remove hardcoded sun handling

The sun (emit_skylight) should be just another world light that goes through the same pipeline. Remove the separate sunDirection/sunColor/sunOcclusion uniforms for the ambient cube path — the sun becomes one of the 4 local lights (or gets folded into the ambient cube if it's not in the top 4).

Context

  • BSP parser: client/src/bsp/parser.js
  • Leaf lighting: client/src/bsp/leaf-lighting.js
  • Material/shader: client/src/bsp/material.js
  • Scene manager: client/src/scene.js
  • Source SDK 2013 reference: /home/kit/Develop/source-sdk-2013/src/public/bspfile.h
  • Source SDK 2013 shaders: /home/kit/Develop/source-sdk-2013/src/materialsystem/stdshaders/common_vs_fxc.h
  • Relates to #22 (ambient cube lighting)
Implement the full Source engine lightcache pipeline for entity lighting, matching how the engine combines ambient cubes with world lights (point lights, spotlights, sun) including occlusion traces. ## Current state We have a partial implementation: - [x] BSP leaf ambient cube sampling (lumps 51/55, 52/56) - [x] BSP node tree traversal for leaf lookup - [x] Leaf sky visibility flags for binary sun occlusion - [x] Per-entity `onBeforeRender` callbacks for per-draw-call lighting - [x] Basic tone mapping for HDR ambient cube values But we're missing the core of what Source does — the world lights system. ## How Source does it (from SDK 2013 source) Entity lighting = **ambient cube + up to 4 local lights**, computed per-entity: 1. Start with the leaf's pre-baked ambient cube (what we have now) 2. Iterate ALL world lights from BSP lump 15/54 (`dworldlight_t[]`) 3. For each light: - **PVS fast reject** — skip if light's cluster isn't visible from entity's leaf - **Distance/direction reject** — skip if too far or outside spotlight cone - **Occlusion trace** — ray trace from entity to light (or toward sun for `emit_skylight`) - Compute intensity based on distance falloff + angular attenuation 4. **Top 4 brightest** lights become `locallight[]` entries with full per-vertex shading 5. **Remaining lights** get folded into the ambient cube via hemisphere projection 6. Lights with `DWL_FLAGS_INAMBIENTCUBE` are skipped (already baked into leaf ambient) ### Shader pipeline (from `common_vs_fxc.h`) ``` color = AmbientLight(worldNormal) // ambient cube: c21-c26 + sum(DoLightInternal(pos, N, i)) // up to 4 local lights: c27-c46 ``` Each local light has: position, direction, color, attenuation params, spotlight cone angles. ## Requirements ### 1. Parse world lights from BSP Parse lump 15 (`LUMP_WORLDLIGHTS`) or 54 (`LUMP_WORLDLIGHTS_HDR`): ``` dworldlight_t: 88 bytes Vector origin (12) — world position Vector intensity (12) — RGB * brightness (HDR, can exceed 1.0) Vector normal (12) — direction (spotlights/sun) int32 cluster (4) — PVS cluster int32 type (4) — emittype_t enum int32 style (4) — light style index float stopdot (4) — spotlight inner cone cos float stopdot2 (4) — spotlight outer cone cos float exponent (4) — spotlight falloff exponent float radius (4) — cutoff distance (0 = infinite) float constant_attn (4) — attenuation constant term float linear_attn (4) — attenuation linear term float quadratic_attn (4) — attenuation quadratic term int32 flags (4) — DWL_FLAGS_INAMBIENTCUBE = 0x0001 int32 texinfo (4) int32 owner (4) ``` emittype_t: 0=surface, 1=point, 2=spotlight, 3=skylight, 4=quakelight, 5=skyambient ### 2. Implement lightcache per entity For each entity position: - Find BSP leaf (existing) - Get leaf ambient cube (existing) - Iterate world lights, reject by PVS visibility - For remaining lights, compute intensity at entity position: - `emit_skylight`: check leaf sky flag (existing), full contribution if visible - `emit_point`: `intensity / (const + linear*dist + quad*dist²)`, with radius cutoff - `emit_spotlight`: same + angular attenuation via `stopdot`/`stopdot2` - `emit_surface`: same as spotlight with 90° cone - Skip `emit_skyambient` (already in leaf ambient cube) - Skip lights with `DWL_FLAGS_INAMBIENTCUBE` when leaf ambient is used - Select top 4 brightest as local lights - Fold remaining into ambient cube ### 3. Update shader to handle local lights Add to the uber-shader: - `uniform vec3 localLightPos[4]` - `uniform vec3 localLightColor[4]` - `uniform vec3 localLightDir[4]` (for spotlights/directional) - `uniform vec4 localLightAtten[4]` (const, linear, quad, type) - `uniform vec4 localLightSpot[4]` (stopdot, stopdot2, exponent, 0) - `uniform int numLocalLights` Per-vertex or per-fragment light evaluation: ```glsl for (int i = 0; i < numLocalLights; i++) { vec3 lightDir = normalize(localLightPos[i] - worldPos); float NdotL = max(dot(N, lightDir), 0.0); float dist = length(localLightPos[i] - worldPos); float atten = 1.0 / (atten[i].x + atten[i].y * dist + atten[i].z * dist * dist); // spotlight cone... color += localLightColor[i] * NdotL * atten; } ``` ### 4. Remove hardcoded sun handling The sun (`emit_skylight`) should be just another world light that goes through the same pipeline. Remove the separate `sunDirection`/`sunColor`/`sunOcclusion` uniforms for the ambient cube path — the sun becomes one of the 4 local lights (or gets folded into the ambient cube if it's not in the top 4). ## Context - BSP parser: `client/src/bsp/parser.js` - Leaf lighting: `client/src/bsp/leaf-lighting.js` - Material/shader: `client/src/bsp/material.js` - Scene manager: `client/src/scene.js` - Source SDK 2013 reference: `/home/kit/Develop/source-sdk-2013/src/public/bspfile.h` - Source SDK 2013 shaders: `/home/kit/Develop/source-sdk-2013/src/materialsystem/stdshaders/common_vs_fxc.h` - Relates to #22 (ambient cube lighting)
Author
Owner

Fixed in fa5cb22. Three bugs were causing incorrect BSP lighting:

  1. ColorRGBExp32 decodeTexLightToLinear is c * 2^exp (no normalization). Our decode had a spurious /255, making all lightmap values 255x too small.

  2. Bumpmapped lightmap stride — Faces with SURF_BUMPLIGHT store 4 lightmap layers per style (flat + 3 directional). We assumed 1 layer per style, so 1032 bumpmapped faces with style 33 were reading style 0's bump data instead of the actual sun contribution.

  3. Gamma correction — Linear lightmap values multiplied directly against sRGB base textures crushed dark areas. Now gamma-encode lighting (pow(light, 1/2.2)) before texture multiplication. Applied to lightmap, ambient cube, and local light paths.

Also added:

  • LDR/HDR dual lightmap atlas (both lighting lumps decoded at load time)
  • HDR tonemap toggle and intensity slider in GUI
Fixed in fa5cb22. Three bugs were causing incorrect BSP lighting: 1. **ColorRGBExp32 decode** — `TexLightToLinear` is `c * 2^exp` (no normalization). Our decode had a spurious `/255`, making all lightmap values 255x too small. 2. **Bumpmapped lightmap stride** — Faces with `SURF_BUMPLIGHT` store 4 lightmap layers per style (flat + 3 directional). We assumed 1 layer per style, so 1032 bumpmapped faces with style 33 were reading style 0's bump data instead of the actual sun contribution. 3. **Gamma correction** — Linear lightmap values multiplied directly against sRGB base textures crushed dark areas. Now gamma-encode lighting (`pow(light, 1/2.2)`) before texture multiplication. Applied to lightmap, ambient cube, and local light paths. Also added: - LDR/HDR dual lightmap atlas (both lighting lumps decoded at load time) - HDR tonemap toggle and intensity slider in GUI
kit closed this issue 2026-03-19 08:11:22 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
kit/gmod-web-stream#30
No description provided.