Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Docs/Content/Lighting.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ _lighting.LightIntensity = 1.2f; // global brightness multiplier
_lighting.MaxLights = 64; // hard cap, sorted by distance to camera
_lighting.MaxShadowcastingLights = 16; // shadow-map row budget
_lighting.HardShadows = true; // cheap single-sample shadows
_lighting.WallBleedEnabled = true; // soft glow bleeding through walls
_lighting.WallBleedEnabled = true; // walls pick up the glow of nearby lights
_lighting.LightBlurEnabled = true; // blur final lightmap
```

Expand Down Expand Up @@ -178,11 +178,11 @@ Anything using the `Unshaded` technique is drawn at full brightness after the li

Each frame, `LightingSystem`:

1. Collects all `PointLight`/`SpotLight`/`TextureLight` entities and all `Occluder` entities.
2. Renders a 1D cylindrical shadow map (one row per shadow-casting light — point and spot share the same map and row budget).
3. Builds an occlusion mask of which pixels are reachable by lit area (cone-shaped for spotlights).
4. Draws every light additively into a lightmap render target, sampling the shadow map for occlusion. Spotlights additionally discard pixels outside their cone.
5. Optionally blurs the lightmap (wall-bleed pass and/or final Gaussian blur), depending on `LightingManager` settings.
1. Collects visible `PointLight`/`SpotLight` entities and all `Occluder` entities near the view (padded by the largest light radius, so off-screen walls still cast shadows into view).
2. Builds the occluder edge geometry once and renders a 1D cylindrical shadow map (one row per shadow-casting light — point and spot share the same map and row budget).
3. Draws every light additively into a lightmap render target, sampling the shadow map for occlusion. Spotlights additionally discard pixels outside their cone. Texture lights are drawn on top with plain additive sprites.
4. Wall bleed (optional): blurs the lightmap at half resolution, then draws the occluder quads over the lightmap so each wall pixel shows the blurred glow of nearby lights.
5. Light blur (optional): a final 2-pass separable Gaussian over the lightmap, smoothing shadow banding.
6. Multiplies the rendered scene by the lightmap and writes the result to the backbuffer; `Unshaded` sprites are drawn on top afterward at full brightness.

---
Expand All @@ -191,4 +191,4 @@ Each frame, `LightingSystem`:

- `TextureLightComponent.CastShadows` is not yet wired up — texture lights never occlude and are always drawn unoccluded regardless of the field's value. (`PointLight` and `SpotLight` both cast shadows correctly.)
- Shadows use standard PCF, not true variance shadow mapping — soft shadow quality is driven by `Softness` / `LightSoftness`, not VSM probability falloff.
- All occluders are evaluated against every shadow-casting light; there is no per-light occluder culling by distance yet.
- Occluder bounds are axis-aligned and ignore entity rotation/scale.
22 changes: 5 additions & 17 deletions Engine.Client/Graphics/Lighting/LightOcclusionSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@
namespace Engine.Client.Graphics.Lighting;

/// <summary>
/// Resolves the world-space AABB of <see cref="OccluderComponent"/> instances
/// for the lighting system. Kept as an EntitySystem so it shares the standard
/// system lifecycle (Init/Update/Draw), but currently no per-frame work is
/// required — the AABB calculation is pure data and is exposed as a
/// method that <see cref="LightingSystem"/> calls directly while iterating
/// its own entity query.
/// Resolves the world-space AABB of occluders for the lighting system.
/// </summary>
public sealed class LightOcclusionSystem : EntitySystem
{
Expand All @@ -24,11 +19,9 @@ public LightOcclusionSystem()
}

/// <summary>
/// Get the world-space AABB for an occluder. For <see cref="OccluderShape.Rectangle"/>
/// and <see cref="OccluderShape.Circle"/> this is computed from the shape
/// parameters. For <see cref="OccluderShape.Sprite"/> the entity's
/// <see cref="SpriteComponent"/> is queried for the resolved atlas
/// region; a 32×32 fallback is used when no sprite can be resolved.
/// AABB for an occluder. Rectangle and Circle come from the shape
/// parameters; Sprite uses the resolved sprite region, with a 32x32
/// fallback when nothing can be resolved.
/// </summary>
public Rectangle GetOccluderBounds(
EntityUid uid,
Expand Down Expand Up @@ -66,12 +59,7 @@ private Rectangle SpriteBounds(
TransformComponent transform,
EntityManager? entMan)
{
// The CachedRegion on the sprite is the atlas-space bounding box of
// the chosen tile — the physical world size is the region's
// width/height (the atlas is 1:1 with the world for non-tilemap
// sprites). Fall back to 32×32 if nothing is resolved, so we never
// return a zero-size box (which would be silently ignored by
// ShadowGeometry).
// the atlas region is 1:1 with world size for regular sprites
const int Fallback = 32;
if (entMan is null) return SizedBox(transform, Fallback, Fallback);

Expand Down
2 changes: 1 addition & 1 deletion Engine.Client/Graphics/Lighting/LightingCvars.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Engine.Client.Graphics.Lighting;
public static class LightingCvars
{
public static readonly CVarDef<float> LightmapScale =
CVarDef.Create("lighting.lightmap-scale", 1.0f);
CVarDef.Create("lighting.lightmap-scale", 0.5f);

public static readonly CVarDef<bool> PixelatedLighting =
CVarDef.Create("lighting.pixelated", false);
Expand Down
81 changes: 27 additions & 54 deletions Engine.Client/Graphics/Lighting/LightingManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,83 +5,67 @@
namespace Engine.Client.Graphics.Lighting;

/// <summary>
/// Central service that owns lighting configuration for the engine.
/// Resolved via IoC: <c>IoCManager.Resolve&lt;LightingManager&gt;()</c> or
/// injected with <c>[Dependency] private readonly LightingManager _lighting;</c>.
/// Runtime configuration for the lighting pipeline. Resolve via IoC.
/// </summary>
public sealed class LightingManager
{
/// <summary>
/// Master toggle. When <c>false</c>, the <see cref="LightingSystem"/>
/// does nothing in either Update or Draw — zero overhead, the scene
/// is rendered with raw sprite colors.
/// Master toggle. When false the lighting system does no work at all.
/// </summary>
public bool Enabled { get; private set; } = false;

/// <summary>
/// Default ambient color used when no <see cref="Components.Lighting.AmbientLightComponent"/>
/// is present in the scene.
/// Ambient color used when the scene has no AmbientLightComponent.
/// </summary>
public Color AmbientLight { get; set; } = new Color(0, 0, 0);

/// <summary>
/// Global multiplier on top of every light's intensity. (1.0 = normal.)
/// Global multiplier applied on top of every light's intensity.
/// </summary>
public float LightIntensity { get; set; } = 1f;

/// <summary>
/// Hard cap on the number of lights processed per frame. Lights beyond
/// this cap (sorted by distance to the camera) are skipped.
/// Max lights processed per frame, sorted by distance to the camera.
/// </summary>
public int MaxLights { get; set; } = 64;

/// <summary>
/// Number of shadow-casting lights allowed per frame. Determines the
/// height of the shadow map render target. Lights beyond this cap
/// have shadows disabled (still render as plain lights).
/// Shadow-casting light budget. Also the height of the shadow map (one
/// row per light). Lights over the cap still render, just without shadows.
/// </summary>
public int MaxShadowcastingLights { get; set; } = 16;

/// <summary>
/// Width of the shadow map (in texels) — each row is a 1D unwrapped
/// 360° view around a single shadow-casting light. 1024 keeps angular
/// shadows from looking like visible ray slices around small occluders.
/// Shadow map width in texels. Each row is a full 360° unwrap around one
/// light, so too few texels makes shadows look like ray slices.
/// </summary>
public int ShadowMapSize { get; set; } = 1024;

/// <summary>
/// Extra shadow bias, in world pixels, subtracted from the blocker depth.
/// This makes shadows start slightly before the occluder edge so filtered
/// lighting cannot leave a bright fringe around walls.
/// Extra shadow bias in world pixels, so filtered light doesn't leave a
/// bright fringe around wall edges.
/// </summary>
public float ShadowContactBias { get; set; } = 1.5f;

/// <summary>
/// Multiplier on the soft-shadow kernel size (per-light via
/// <see cref="Components.Lighting.PointLightComponent.Softness"/>).
/// Larger values = softer/wider penumbra.
/// Multiplier on the soft-shadow kernel size. Larger = wider penumbra.
/// </summary>
public float LightSoftness { get; set; } = 1.0f;

/// <summary>
/// When true, light that "bleeds" through walls is blurred back into
/// the occluders' footprints, simulating subsurface light scatter /
/// glow. Off = cheaper, slightly harsher edges.
/// Adds a blurred copy of the lightmap on top of walls, so they pick up
/// the glow of nearby lights instead of staying pitch black.
/// </summary>
public bool WallBleedEnabled { get; set; } = false;
public bool WallBleedEnabled { get; set; } = true;

/// <summary>
/// When true, the accumulated lightmap is blurred (3-pass separable
/// Gaussian) before being applied to the scene, smoothing shadow
/// banding and angular aliasing.
/// Gaussian blur over the finished lightmap, smooths shadow banding.
/// </summary>
public bool LightBlurEnabled { get; set; } = false;
public bool LightBlurEnabled { get; set; } = true;

/// <summary>
/// Fraction of the virtual viewport used for the lightmap.
/// 1.0 = native resolution (sharpest, most expensive).
/// 0.5 = half resolution (blurrier edges, ~4× cheaper fill).
/// Clamped to [0.1, 1.0].
/// Fraction of the viewport resolution used for the lightmap.
/// 1.0 = native, 0.5 = half res (~4x cheaper fill, blurrier edges).
/// </summary>
public float LightmapScale
{
Expand All @@ -91,16 +75,13 @@ public float LightmapScale
private float _lightmapScale = 1.0f;

/// <summary>
/// When <c>true</c>, the lightmap is rendered at one texel per
/// <see cref="LightPixelSize"/> screen pixels and upscaled with
/// nearest-neighbour sampling. UV snapping in the shader guarantees
/// each screen pixel maps to exactly one lightmap texel (no mixels).
/// Renders the lightmap at one texel per LightPixelSize screen pixels
/// and upscales with nearest-neighbour, for a chunky pixel-art look.
/// </summary>
public bool PixelatedLighting { get; set; } = false;

/// <summary>
/// Size of each light "pixel" in screen pixels when
/// <see cref="PixelatedLighting"/> is enabled. Clamped to [1, 64].
/// Size of each light "pixel" in screen pixels when PixelatedLighting is on.
/// </summary>
public int LightPixelSize
{
Expand All @@ -110,15 +91,13 @@ public int LightPixelSize
private int _lightPixelSize = 8;

/// <summary>
/// When <c>true</c>, the raw lightmap is drawn on top of the screen
/// instead of being applied. Used for debugging.
/// Draws the raw lightmap instead of applying it. Debug only.
/// </summary>
public bool DebugDraw { get; set; } = false;

/// <summary>
/// When <c>true</c>, shadows use a single shadow-map sample (hard edges)
/// instead of the 7-tap PCF kernel. Significantly cheaper on the GPU —
/// use on low-end hardware or when shadow softness is not important.
/// Single-sample shadows instead of the 7-tap PCF kernel. Cheaper,
/// hard edges.
/// </summary>
public bool HardShadows { get; set; } = false;

Expand All @@ -129,7 +108,6 @@ public int LightPixelSize
public int LastShadowMapHeight { get; private set; }
public double LastLightingTotalMs { get; private set; }
public double LastShadowPassMs { get; private set; }
public double LastOcclusionMaskMs { get; private set; }
public double LastLightPassMs { get; private set; }
public double LastWallBleedMs { get; private set; }
public double LastLightBlurMs { get; private set; }
Expand All @@ -142,7 +120,6 @@ internal void RecordFrameStats(
int shadowMapHeight,
double totalMs,
double shadowPassMs,
double occlusionMaskMs,
double lightPassMs,
double wallBleedMs,
double lightBlurMs)
Expand All @@ -154,21 +131,17 @@ internal void RecordFrameStats(
LastShadowMapHeight = shadowMapHeight;
LastLightingTotalMs = totalMs;
LastShadowPassMs = shadowPassMs;
LastOcclusionMaskMs = occlusionMaskMs;
LastLightPassMs = lightPassMs;
LastWallBleedMs = wallBleedMs;
LastLightBlurMs = lightBlurMs;
}

/// <summary>
/// Fired when <see cref="Enabled"/> changes. Useful for systems that
/// need to (re)allocate render targets.
/// Fired when Enabled changes, in case something needs to reallocate
/// render targets.
/// </summary>
public event Action<bool>? OnEnabledChanged;

/// <summary>
/// Enable or disable the lighting system at runtime.
/// </summary>
public void SetEnabled(bool value)
{
if (Enabled == value) return;
Expand Down
8 changes: 3 additions & 5 deletions Engine.Client/Graphics/Lighting/LightingRenderTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
namespace Engine.Client.Graphics.Lighting;

/// <summary>
/// Owns the <see cref="RenderTarget2D"/> used to bake the lightmap each frame.
/// Sized to match the virtual viewport (1:1 with the final frame) so the
/// <c>LightingApply.fx</c> shader can sample it directly without scaling.
/// Owns the render target the lightmap is baked into each frame.
/// </summary>
internal sealed class LightingRenderTarget
{
Expand All @@ -22,8 +20,8 @@ internal LightingRenderTarget()
=> IoCManager.ResolveDependencies(this);

/// <summary>
/// Make sure the backing texture matches the requested size.
/// Cheap to call every frame — only allocates on resize.
/// Cheap to call every frame, only reallocates when the size changes.
/// Defaults to the virtual viewport size when no size is given.
/// </summary>
public void EnsureSize(int w = 0, int h = 0)
{
Expand Down
Loading
Loading