diff --git a/Docs/Content/Lighting.md b/Docs/Content/Lighting.md
index 0ccf4ac..4594419 100644
--- a/Docs/Content/Lighting.md
+++ b/Docs/Content/Lighting.md
@@ -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
```
@@ -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.
---
@@ -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.
diff --git a/Engine.Client/Graphics/Lighting/LightOcclusionSystem.cs b/Engine.Client/Graphics/Lighting/LightOcclusionSystem.cs
index d44fbf3..a246af7 100644
--- a/Engine.Client/Graphics/Lighting/LightOcclusionSystem.cs
+++ b/Engine.Client/Graphics/Lighting/LightOcclusionSystem.cs
@@ -7,12 +7,7 @@
namespace Engine.Client.Graphics.Lighting;
///
-/// Resolves the world-space AABB of 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 calls directly while iterating
-/// its own entity query.
+/// Resolves the world-space AABB of occluders for the lighting system.
///
public sealed class LightOcclusionSystem : EntitySystem
{
@@ -24,11 +19,9 @@ public LightOcclusionSystem()
}
///
- /// Get the world-space AABB for an occluder. For
- /// and this is computed from the shape
- /// parameters. For the entity's
- /// 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.
///
public Rectangle GetOccluderBounds(
EntityUid uid,
@@ -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);
diff --git a/Engine.Client/Graphics/Lighting/LightingCvars.cs b/Engine.Client/Graphics/Lighting/LightingCvars.cs
index b4199df..bd8cc95 100644
--- a/Engine.Client/Graphics/Lighting/LightingCvars.cs
+++ b/Engine.Client/Graphics/Lighting/LightingCvars.cs
@@ -6,7 +6,7 @@ namespace Engine.Client.Graphics.Lighting;
public static class LightingCvars
{
public static readonly CVarDef LightmapScale =
- CVarDef.Create("lighting.lightmap-scale", 1.0f);
+ CVarDef.Create("lighting.lightmap-scale", 0.5f);
public static readonly CVarDef PixelatedLighting =
CVarDef.Create("lighting.pixelated", false);
diff --git a/Engine.Client/Graphics/Lighting/LightingManager.cs b/Engine.Client/Graphics/Lighting/LightingManager.cs
index 7364549..d3627d0 100644
--- a/Engine.Client/Graphics/Lighting/LightingManager.cs
+++ b/Engine.Client/Graphics/Lighting/LightingManager.cs
@@ -5,83 +5,67 @@
namespace Engine.Client.Graphics.Lighting;
///
-/// Central service that owns lighting configuration for the engine.
-/// Resolved via IoC: IoCManager.Resolve<LightingManager>() or
-/// injected with [Dependency] private readonly LightingManager _lighting;.
+/// Runtime configuration for the lighting pipeline. Resolve via IoC.
///
public sealed class LightingManager
{
///
- /// Master toggle. When false, the
- /// 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.
///
public bool Enabled { get; private set; } = false;
///
- /// Default ambient color used when no
- /// is present in the scene.
+ /// Ambient color used when the scene has no AmbientLightComponent.
///
public Color AmbientLight { get; set; } = new Color(0, 0, 0);
///
- /// Global multiplier on top of every light's intensity. (1.0 = normal.)
+ /// Global multiplier applied on top of every light's intensity.
///
public float LightIntensity { get; set; } = 1f;
///
- /// 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.
///
public int MaxLights { get; set; } = 64;
///
- /// 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.
///
public int MaxShadowcastingLights { get; set; } = 16;
///
- /// 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.
///
public int ShadowMapSize { get; set; } = 1024;
///
- /// 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.
///
public float ShadowContactBias { get; set; } = 1.5f;
///
- /// Multiplier on the soft-shadow kernel size (per-light via
- /// ).
- /// Larger values = softer/wider penumbra.
+ /// Multiplier on the soft-shadow kernel size. Larger = wider penumbra.
///
public float LightSoftness { get; set; } = 1.0f;
///
- /// 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.
///
- public bool WallBleedEnabled { get; set; } = false;
+ public bool WallBleedEnabled { get; set; } = true;
///
- /// 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.
///
- public bool LightBlurEnabled { get; set; } = false;
+ public bool LightBlurEnabled { get; set; } = true;
///
- /// 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).
///
public float LightmapScale
{
@@ -91,16 +75,13 @@ public float LightmapScale
private float _lightmapScale = 1.0f;
///
- /// When true, the lightmap is rendered at one texel per
- /// 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.
///
public bool PixelatedLighting { get; set; } = false;
///
- /// Size of each light "pixel" in screen pixels when
- /// is enabled. Clamped to [1, 64].
+ /// Size of each light "pixel" in screen pixels when PixelatedLighting is on.
///
public int LightPixelSize
{
@@ -110,15 +91,13 @@ public int LightPixelSize
private int _lightPixelSize = 8;
///
- /// When true, 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.
///
public bool DebugDraw { get; set; } = false;
///
- /// When true, 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.
///
public bool HardShadows { get; set; } = false;
@@ -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; }
@@ -142,7 +120,6 @@ internal void RecordFrameStats(
int shadowMapHeight,
double totalMs,
double shadowPassMs,
- double occlusionMaskMs,
double lightPassMs,
double wallBleedMs,
double lightBlurMs)
@@ -154,21 +131,17 @@ internal void RecordFrameStats(
LastShadowMapHeight = shadowMapHeight;
LastLightingTotalMs = totalMs;
LastShadowPassMs = shadowPassMs;
- LastOcclusionMaskMs = occlusionMaskMs;
LastLightPassMs = lightPassMs;
LastWallBleedMs = wallBleedMs;
LastLightBlurMs = lightBlurMs;
}
///
- /// Fired when changes. Useful for systems that
- /// need to (re)allocate render targets.
+ /// Fired when Enabled changes, in case something needs to reallocate
+ /// render targets.
///
public event Action? OnEnabledChanged;
- ///
- /// Enable or disable the lighting system at runtime.
- ///
public void SetEnabled(bool value)
{
if (Enabled == value) return;
diff --git a/Engine.Client/Graphics/Lighting/LightingRenderTarget.cs b/Engine.Client/Graphics/Lighting/LightingRenderTarget.cs
index 52b9a8c..fd1b64c 100644
--- a/Engine.Client/Graphics/Lighting/LightingRenderTarget.cs
+++ b/Engine.Client/Graphics/Lighting/LightingRenderTarget.cs
@@ -4,9 +4,7 @@
namespace Engine.Client.Graphics.Lighting;
///
-/// Owns the used to bake the lightmap each frame.
-/// Sized to match the virtual viewport (1:1 with the final frame) so the
-/// LightingApply.fx shader can sample it directly without scaling.
+/// Owns the render target the lightmap is baked into each frame.
///
internal sealed class LightingRenderTarget
{
@@ -22,8 +20,8 @@ internal LightingRenderTarget()
=> IoCManager.ResolveDependencies(this);
///
- /// 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.
///
public void EnsureSize(int w = 0, int h = 0)
{
diff --git a/Engine.Client/Graphics/Lighting/LightingSystem.cs b/Engine.Client/Graphics/Lighting/LightingSystem.cs
index 1a6849a..bd8feca 100644
--- a/Engine.Client/Graphics/Lighting/LightingSystem.cs
+++ b/Engine.Client/Graphics/Lighting/LightingSystem.cs
@@ -14,17 +14,9 @@
namespace Engine.Client.Graphics.Lighting;
///
-/// Coordinates lighting components and produces the final lightmap each frame.
-///
-/// Pipeline (in order):
-/// 1. Shadow pass — writes per-angle occluder distance into a 1D unwrapped
-/// shadow map (one row per shadow-casting light).
-/// 2. Occlusion mask — marks pixels reached by shadow-casting lights.
-/// 3. Light pass — clears lightmap with ambient, then accumulates radial
-/// light disks (point and spot) additively with soft PCF shadows.
-/// 4. Wall bleed + light blur — separable Gaussian blur to soften banding.
-/// 5. Apply pass — done in after the world is
-/// drawn; multiplies the scene by the lightmap.
+/// Builds the lightmap every frame: shadow map, light pass, optional wall
+/// bleed and blur. then multiplies the result
+/// over the rendered scene.
///
public sealed class LightingSystem : EntityDrawSystem
{
@@ -40,24 +32,28 @@ public sealed class LightingSystem : EntityDrawSystem
private readonly LightingRenderTarget _lightmap = new();
private readonly ShadowMapRT _shadowMap = new();
private readonly WallBleedRT _wallBleed = new();
- private readonly OcclusionMaskRT _occlusionMask = new();
+ private readonly ScratchRT _blurScratch = new();
- // Occluder-edge geometry — rebuilt every frame.
- // 256 occluders × 4 edges × 4 verts = 4096 vertices, 6144 indices.
- private const int MaxOccluders = 256;
- private ShadowGeometry.OccluderVertex[] _shadowVerts = new ShadowGeometry.OccluderVertex[MaxOccluders * 16];
- private short[] _shadowIndices = new short[MaxOccluders * 24];
+ // occluder edge geometry, built once per frame and drawn for every
+ // shadow light. Capped at 4096 occluders by the 16 bit index range
+ private const int MaxOccluderCap = 4096;
+ private int _occluderCapacity = 256;
+ private ShadowGeometry.OccluderVertex[] _shadowVerts = new ShadowGeometry.OccluderVertex[256 * 16];
+ private DynamicVertexBuffer? _shadowVB;
+ private IndexBuffer? _shadowIB;
+ private int _shadowTriCount;
- // Reused scratch list for tile AABBs — cleared after CollectOccluders.
private readonly List _scratchTileRects = new();
- // Cached per-frame collections — avoids a new List<> allocation each frame.
+ // reused every frame to avoid allocations
private readonly List _lights = new();
private readonly List<(Rectangle Bounds, TransformComponent Transform)> _occluders = new();
- // Per-light subset of _occluders — cleared and rebuilt inside RenderShadowMap for each shadow light.
- private readonly List<(Rectangle Bounds, TransformComponent Transform)> _culledOccluders = new();
+ private DiskVertex[] _wallVerts = new DiskVertex[256 * 6];
- // World-space disk quad: two triangles covering a 2r×2r square around a light.
+ private float _maxLightRadius;
+ private bool _warnedShadowCap;
+
+ // quad covering a 2r x 2r square around a light, in world space
private struct DiskVertex
{
public Vector2 WorldPos;
@@ -67,7 +63,7 @@ private struct DiskVertex
}
private DiskVertex[] _diskQuad = new DiskVertex[6];
- // Clip-space fullscreen quad for post-process passes.
+ // fullscreen quad in clip space, for the post-process passes
private struct ScreenVertex
{
public Vector2 Position;
@@ -77,9 +73,17 @@ private struct ScreenVertex
new VertexElement(0, VertexElementFormat.Vector2, VertexElementUsage.Position, 0),
new VertexElement(8, VertexElementFormat.Vector2, VertexElementUsage.TextureCoordinate, 0));
}
- private ScreenVertex[] _screenQuad = new ScreenVertex[6];
+ private static readonly ScreenVertex[] ScreenQuad =
+ {
+ new() { Position = new Vector2(-1, -1), TexCoord = new Vector2(0, 0) },
+ new() { Position = new Vector2( 1, -1), TexCoord = new Vector2(1, 0) },
+ new() { Position = new Vector2( 1, 1), TexCoord = new Vector2(1, 1) },
+ new() { Position = new Vector2(-1, -1), TexCoord = new Vector2(0, 0) },
+ new() { Position = new Vector2( 1, 1), TexCoord = new Vector2(1, 1) },
+ new() { Position = new Vector2(-1, 1), TexCoord = new Vector2(0, 1) },
+ };
- // One, One blend — premultiplied RGB light contribution, A its strength.
+ // plain additive blend, lights output premultiplied rgb + strength in alpha
private static readonly BlendState AdditivePremultiplied = new BlendState
{
ColorSourceBlend = Blend.One,
@@ -90,16 +94,14 @@ private struct ScreenVertex
AlphaBlendFunction = BlendFunction.Add,
};
- // Scissor test enabled; CullMode.None because disk quads may have
- // arbitrary winding after the camera transform flips the Y axis.
+ // CullMode.None because the camera transform can flip the quad winding
private static readonly RasterizerState ScissorRasterizer = new RasterizerState
{
CullMode = CullMode.None,
ScissorTestEnable = true,
};
- // LESS (not LessEqual) so overlapping occluder slices resolve to the
- // closest one without corrupting the VSM mean-of-squares in the G channel.
+ // LESS so overlapping occluder slices keep the closest distance
private static readonly DepthStencilState ShadowDepthState = new DepthStencilState
{
DepthBufferEnable = true,
@@ -108,12 +110,20 @@ private struct ScreenVertex
};
private Effect? _applyEffect;
- private Effect? _debugEffect;
private Effect? _shadowDepthEffect;
private Effect? _lightSoftEffect;
private Effect? _lightBlurEffect;
private Effect? _wallMergeEffect;
- private Effect? _occlusionMaskEffect;
+
+ // parameter lookups by name are dictionary hits, cache them once
+ private EffectParameter? _apTexelSize;
+ private EffectParameter? _sdLightPos, _sdLightRadius, _sdWrapPass;
+ private EffectParameter? _lpViewProj, _lpShadowMap, _lpShadowMapTexel,
+ _lpCenter, _lpColor, _lpRange, _lpPower, _lpSoftness, _lpFalloff,
+ _lpCurveFactor, _lpIndex, _lpContactBias,
+ _lpDirection, _lpConeAngle, _lpConeSoftness;
+ private EffectParameter? _blSourceMap, _blSourceTexel, _blIsHorizontal;
+ private EffectParameter? _wmBlurred, _wmViewProj;
private readonly Stopwatch _passTimer = new();
private readonly Stopwatch _frameTimer = new();
@@ -126,13 +136,11 @@ public override void Init()
_cfg.Subs(LightingCvars.PixelatedLighting, v => _lighting.PixelatedLighting = v);
_cfg.Subs(LightingCvars.LightPixelSize, v => _lighting.LightPixelSize = v);
- _applyEffect = _shaders.GetShader("LightingApply")?.Clone();
- _debugEffect = _shaders.GetShader("LightingDebug")?.Clone();
- _shadowDepthEffect = _shaders.GetShader("ShadowDepth")?.Clone();
- _lightSoftEffect = _shaders.GetShader("LightSoft")?.Clone();
- _lightBlurEffect = _shaders.GetShader("LightBlur")?.Clone();
- _wallMergeEffect = _shaders.GetShader("WallMerge")?.Clone();
- _occlusionMaskEffect = _shaders.GetShader("OcclusionMask")?.Clone();
+ _applyEffect = _shaders.GetShader("LightingApply")?.Clone();
+ _shadowDepthEffect = _shaders.GetShader("ShadowDepth")?.Clone();
+ _lightSoftEffect = _shaders.GetShader("LightSoft")?.Clone();
+ _lightBlurEffect = _shaders.GetShader("LightBlur")?.Clone();
+ _wallMergeEffect = _shaders.GetShader("WallMerge")?.Clone();
if (_applyEffect is null)
Log.Warn("LightingApply.fx not found - apply pass will be skipped.");
@@ -140,8 +148,58 @@ public override void Init()
Log.Warn("ShadowDepth.fx not found - shadows will be disabled.");
if (_lightSoftEffect is null)
Log.Warn("LightSoft.fx not found - point/spot lights will not render.");
- if (_occlusionMaskEffect is null)
- Log.Warn("OcclusionMask.fx not found - wall bleed will be unrestricted.");
+ if (_wallMergeEffect is null)
+ Log.Warn("WallMerge.fx not found - wall bleed will be disabled.");
+
+ CacheEffectParameters();
+ }
+
+ private void CacheEffectParameters()
+ {
+ _apTexelSize = _applyEffect?.Parameters["LightmapTexelSize"];
+
+ if (_shadowDepthEffect is not null)
+ {
+ var p = _shadowDepthEffect.Parameters;
+ _sdLightPos = p["lightPos"];
+ _sdLightRadius = p["lightRadius"];
+ _sdWrapPass = p["shadowWrapPass"];
+ }
+
+ if (_lightSoftEffect is not null)
+ {
+ var p = _lightSoftEffect.Parameters;
+ _lpViewProj = p["viewProj"];
+ _lpShadowMap = p["ShadowMap"];
+ _lpShadowMapTexel = p["shadowMapTexel"];
+ _lpCenter = p["lightCenter"];
+ _lpColor = p["lightColor"];
+ _lpRange = p["lightRange"];
+ _lpPower = p["lightPower"];
+ _lpSoftness = p["lightSoftness"];
+ _lpFalloff = p["lightFalloff"];
+ _lpCurveFactor = p["lightCurveFactor"];
+ _lpIndex = p["lightIndex"];
+ _lpContactBias = p["shadowContactBias"];
+ _lpDirection = p["lightDirection"];
+ _lpConeAngle = p["lightConeAngle"];
+ _lpConeSoftness = p["lightConeSoftness"];
+ }
+
+ if (_lightBlurEffect is not null)
+ {
+ var p = _lightBlurEffect.Parameters;
+ _blSourceMap = p["SourceMap"];
+ _blSourceTexel = p["SourceTexel"];
+ _blIsHorizontal = p["isHorizontal"];
+ }
+
+ if (_wallMergeEffect is not null)
+ {
+ var p = _wallMergeEffect.Parameters;
+ _wmBlurred = p["BlurredLightMap"];
+ _wmViewProj = p["viewProj"];
+ }
}
public override void Draw(float dt)
@@ -153,9 +211,8 @@ public override void Draw(float dt)
}
///
- /// Multiply the lightmap over the rendered scene and blit to the backbuffer.
- /// Must be called after has written the
- /// world to .
+ /// Multiplies the lightmap over the scene. Call after the world has been
+ /// drawn to .
///
public void ApplyAfterWorld()
{
@@ -171,9 +228,11 @@ public void ApplyAfterWorld()
if (_lighting.DebugDraw)
{
+ // SpriteBatch coords are viewport relative, so draw at 0,0 -
+ // the letterbox offset is already applied by the viewport
var vp = GameClient.GraphicsDevice.Viewport;
GameClient.SpriteBatch.Begin(samplerState: SamplerState.PointClamp, blendState: BlendState.Opaque);
- GameClient.SpriteBatch.Draw(_lightmap.Target, new Rectangle(vp.X, vp.Y, vp.Width, vp.Height), Color.White);
+ GameClient.SpriteBatch.Draw(_lightmap.Target, new Rectangle(0, 0, vp.Width, vp.Height), Color.White);
GameClient.SpriteBatch.End();
}
else if (_applyEffect is not null)
@@ -182,15 +241,11 @@ public void ApplyAfterWorld()
if (_applyEffect.CurrentTechnique.Name != techniqueName)
_applyEffect.CurrentTechnique = _applyEffect.Techniques[techniqueName];
- _applyEffect.Parameters["Intensity"]?.SetValue(1.0f);
- _applyEffect.Parameters["AmbientColor"]?.SetValue(_lighting.AmbientLight.ToVector4());
-
- if (_lighting.PixelatedLighting && _lightmap.Target is not null)
+ if (_lighting.PixelatedLighting)
{
- var texelSize = new Vector2(
+ _apTexelSize?.SetValue(new Vector2(
1f / _lightmap.Target.Width,
- 1f / _lightmap.Target.Height);
- _applyEffect.Parameters["LightmapTexelSize"]?.SetValue(texelSize);
+ 1f / _lightmap.Target.Height));
}
_render.SubmitFullscreenEffectWithTextures(_applyEffect, scene, _lightmap.Target);
@@ -202,19 +257,17 @@ public override void OnShutdown()
base.OnShutdown();
_shadowMap.Dispose();
_wallBleed.Dispose();
- _occlusionMask.Dispose();
+ _blurScratch.Dispose();
+ _shadowVB?.Dispose(); _shadowVB = null;
+ _shadowIB?.Dispose(); _shadowIB = null;
}
- // -----------------------------------------------------------------------
- // Lightmap building
- // -----------------------------------------------------------------------
-
private void BuildLightmap()
{
_frameTimer.Restart();
- double shadowPassMs = 0, occlusionMaskMs = 0, lightPassMs = 0, wallBleedMs = 0, lightBlurMs = 0;
+ double shadowPassMs = 0, lightPassMs = 0, wallBleedMs = 0, lightBlurMs = 0;
- // Resolve ambient: highest-priority AmbientLightComponent wins.
+ // highest priority AmbientLightComponent wins, fallback is the manager default
var ambientColor = _lighting.AmbientLight;
var ambientIntensity = 1f;
AmbientLightComponent? bestAmbient = null;
@@ -251,14 +304,36 @@ private void BuildLightmap()
if (_shadowDepthEffect is not null && _shadowMap.Target is null)
return;
- _wallBleed.EnsureSize(lightW, lightH);
- _occlusionMask.EnsureSize(lightW, lightH);
+ // blur targets only exist while their feature is on
+ bool wallBleed = _lighting.WallBleedEnabled && _wallMergeEffect is not null && _lightBlurEffect is not null;
+ bool lightBlur = _lighting.LightBlurEnabled && _lightBlurEffect is not null;
+
+ if (wallBleed)
+ {
+ // the bleed blur runs at half res, it's a low frequency glow
+ _wallBleed.EnsureSize(Math.Max(1, lightW / 2), Math.Max(1, lightH / 2));
+ wallBleed = _wallBleed.A is not null && _wallBleed.B is not null;
+ }
+ else
+ {
+ _wallBleed.Dispose();
+ }
+
+ if (lightBlur)
+ {
+ _blurScratch.EnsureSize(lightW, lightH);
+ lightBlur = _blurScratch.Target is not null;
+ }
+ else
+ {
+ _blurScratch.Dispose();
+ }
CollectLights();
CollectOccluders();
int shadowLightCount = CountShadowLights();
- // viewProj shared between DrawPointLights and RenderOcclusionMask.
+ // shared by every pass that rasterizes world-space quads
var viewProj = _camera.GetViewMatrix() * Matrix.CreateOrthographicOffCenter(
0, _viewport.VirtualWidth, _viewport.VirtualHeight, 0, -1, 1);
@@ -270,14 +345,6 @@ private void BuildLightmap()
shadowPassMs = _passTimer.Elapsed.TotalMilliseconds;
}
- if (_occlusionMaskEffect is not null && _occlusionMask.Usable)
- {
- _passTimer.Restart();
- RenderOcclusionMask(viewProj);
- _passTimer.Stop();
- occlusionMaskMs = _passTimer.Elapsed.TotalMilliseconds;
- }
-
_passTimer.Restart();
_render.BeginSceneRender(_lightmap.Target);
GameClient.GraphicsDevice.Clear(baseAmbient);
@@ -287,15 +354,15 @@ private void BuildLightmap()
_passTimer.Stop();
lightPassMs = _passTimer.Elapsed.TotalMilliseconds;
- if (_lighting.WallBleedEnabled && _wallBleed.A is not null && _wallBleed.B is not null)
+ if (wallBleed && _occluders.Count > 0)
{
_passTimer.Restart();
- RunWallBleed();
+ RunWallBleed(viewProj);
_passTimer.Stop();
wallBleedMs = _passTimer.Elapsed.TotalMilliseconds;
}
- if (_lighting.LightBlurEnabled && _wallBleed.A is not null && _wallBleed.B is not null)
+ if (lightBlur)
{
_passTimer.Restart();
RunLightBlur();
@@ -308,34 +375,50 @@ private void BuildLightmap()
_lights.Count, shadowLightCount, _occluders.Count,
shadowW, shadowH,
_frameTimer.Elapsed.TotalMilliseconds,
- shadowPassMs, occlusionMaskMs, lightPassMs, wallBleedMs, lightBlurMs);
+ shadowPassMs, lightPassMs, wallBleedMs, lightBlurMs);
}
- // -----------------------------------------------------------------------
- // Light / occluder collection
- // -----------------------------------------------------------------------
-
private struct LightEntry
{
public EntityUid Uid;
public IRadialLight Comp;
public Vector2 WorldPos;
+ public bool CastShadows;
+ public float DistSq; // squared distance to the camera center
public bool IsSpot;
- public float Direction; // radians, valid only when IsSpot
- public float ConeHalfAngle; // radians, valid only when IsSpot
- public float ConeSoftness; // radians, valid only when IsSpot
+ public float Direction; // radians, spot only
+ public float ConeHalfAngle; // radians, spot only
+ public float ConeSoftness; // radians, spot only
}
+ // shadow casters go last so the MaxLights cut drops them first
+ private static readonly Comparison ShadowsLastThenNearest = static (a, b) =>
+ {
+ int sgn = a.CastShadows.CompareTo(b.CastShadows);
+ return sgn != 0 ? sgn : a.DistSq.CompareTo(b.DistSq);
+ };
+
private void CollectLights()
{
_lights.Clear();
+ _maxLightRadius = 0f;
+ var cam = _camera.WorldCenter;
foreach (var (uid, point, transform) in GetEntitiesWithComp())
{
var worldPos = transform.Position + point.Offset;
if (!_camera.IsOnScreen(worldPos, new Vector2(point.Radius * 2f)))
continue;
- _lights.Add(new LightEntry { Uid = uid, Comp = point, WorldPos = worldPos });
+
+ _maxLightRadius = MathF.Max(_maxLightRadius, point.Radius);
+ _lights.Add(new LightEntry
+ {
+ Uid = uid,
+ Comp = point,
+ WorldPos = worldPos,
+ CastShadows = point.CastShadows,
+ DistSq = (worldPos - cam).LengthSquared(),
+ });
}
foreach (var (uid, spot, transform) in GetEntitiesWithComp())
@@ -348,11 +431,14 @@ private void CollectLights()
float halfAngle = MathHelper.ToRadians(spot.ConeAngle) * 0.5f;
float softness = MathHelper.Clamp(halfAngle * 0.25f, 0.01f, halfAngle);
+ _maxLightRadius = MathF.Max(_maxLightRadius, spot.Radius);
_lights.Add(new LightEntry
{
Uid = uid,
Comp = spot,
WorldPos = worldPos,
+ CastShadows = spot.CastShadows,
+ DistSq = (worldPos - cam).LengthSquared(),
IsSpot = true,
Direction = direction,
ConeHalfAngle = halfAngle,
@@ -360,14 +446,7 @@ private void CollectLights()
});
}
- // Shadow casters last so closer non-shadow lights win the MaxLights cap.
- var cam = _camera.WorldCenter;
- _lights.Sort((a, b) =>
- {
- int sgn = a.Comp.CastShadows.CompareTo(b.Comp.CastShadows);
- if (sgn != 0) return sgn;
- return (a.WorldPos - cam).LengthSquared().CompareTo((b.WorldPos - cam).LengthSquared());
- });
+ _lights.Sort(ShadowsLastThenNearest);
if (_lights.Count > _lighting.MaxLights)
_lights.RemoveRange(_lighting.MaxLights, _lights.Count - _lighting.MaxLights);
@@ -378,7 +457,7 @@ private int CountShadowLights()
int count = 0;
foreach (var entry in _lights)
{
- if (!entry.Comp.CastShadows) continue;
+ if (!entry.CastShadows) continue;
if (++count >= _lighting.MaxShadowcastingLights) return count;
}
return count;
@@ -388,20 +467,26 @@ private void CollectOccluders()
{
_occluders.Clear();
+ // an occluder within a light radius of the view can still push a
+ // shadow into the view, so pad the culling bounds by the biggest one
+ var bounds = _camera.ViewportBounds;
+ int pad = (int)MathF.Ceiling(_maxLightRadius);
+ bounds.Inflate(pad, pad);
+
foreach (var (uid, occluder, transform) in GetEntitiesWithComp())
{
- var bounds = _occlusionSys.GetOccluderBounds(uid, occluder, transform, _entManager);
- if (bounds.Width <= 0 || bounds.Height <= 0) continue;
- _occluders.Add((bounds, transform));
+ var b = _occlusionSys.GetOccluderBounds(uid, occluder, transform, _entManager);
+ if (b.Width <= 0 || b.Height <= 0) continue;
+ if (!bounds.Intersects(b)) continue;
+ _occluders.Add((b, transform));
}
var tilemapSys = _entManager.GetSystem();
if (tilemapSys is not null)
{
- var cameraBounds = _camera.ViewportBounds;
foreach (var (_, tilemap, tmTransform) in GetEntitiesWithComp())
{
- tilemapSys.GetSolidTilesInArea(tilemap, tmTransform, cameraBounds, _scratchTileRects);
+ tilemapSys.GetSolidTilesInArea(tilemap, tmTransform, bounds, _scratchTileRects);
foreach (var rect in _scratchTileRects)
{
if (rect.Width <= 0 || rect.Height <= 0) continue;
@@ -412,10 +497,6 @@ private void CollectOccluders()
}
}
- // -----------------------------------------------------------------------
- // Shadow pass
- // -----------------------------------------------------------------------
-
private void RenderShadowMap()
{
if (!_shadowMap.Usable) return;
@@ -423,69 +504,117 @@ private void RenderShadowMap()
var shadowMap = _shadowMap.Target!;
var depthEffect = _shadowDepthEffect!;
- // Clear to "infinity" (1,1,0,1) so un-written texels read as no occluder.
- // Depth cleared to 1.0 (far) so the LESS test accepts the first write.
+ BuildShadowGeometry();
+
+ // clear color = "no occluder", depth = far so the LESS test accepts the first write
GameClient.GraphicsDevice.SetRenderTarget(shadowMap);
GameClient.GraphicsDevice.Clear(ClearOptions.Target | ClearOptions.DepthBuffer,
new Color(255, 255, 0, 255), 1f, 0);
+ if (_shadowTriCount == 0 || _shadowVB is null || _shadowIB is null)
+ return;
+
var prevBlend = GameClient.GraphicsDevice.BlendState;
var prevDepth = GameClient.GraphicsDevice.DepthStencilState;
GameClient.GraphicsDevice.BlendState = BlendState.Opaque;
GameClient.GraphicsDevice.DepthStencilState = ShadowDepthState;
+ GameClient.GraphicsDevice.SetVertexBuffer(_shadowVB);
+ GameClient.GraphicsDevice.Indices = _shadowIB;
int shadowIdx = 0;
foreach (var entry in _lights)
{
- if (!entry.Comp.CastShadows) continue;
+ if (!entry.CastShadows) continue;
if (shadowIdx >= _lighting.MaxShadowcastingLights)
{
- Log.Warn(
- $"LightingSystem: shadow cap ({_lighting.MaxShadowcastingLights}) reached. " +
- $"Light uid={entry.Uid.Id} at ({entry.WorldPos.X:0},{entry.WorldPos.Y:0}) " +
- "renders without shadows. Raise MaxShadowcastingLights or cull more lights.");
+ if (!_warnedShadowCap)
+ {
+ Log.Warn(
+ $"LightingSystem: shadow cap ({_lighting.MaxShadowcastingLights}) reached, " +
+ "extra lights render without shadows. Raise MaxShadowcastingLights or cull more lights.");
+ _warnedShadowCap = true;
+ }
continue;
}
- // Per-light occluder culling: skip occluders whose AABB doesn't
- // overlap the light's circle, then build geometry only from what's left.
- CullOccludersForLight(entry.WorldPos, entry.Comp.Radius);
- int indexCount = ShadowGeometry.Build(_culledOccluders, _shadowVerts, _shadowIndices, out int vertexCount);
-
GameClient.GraphicsDevice.Viewport = new Viewport(0, shadowIdx, shadowMap.Width, 1);
- depthEffect.Parameters["lightPos"]?.SetValue(entry.WorldPos);
- depthEffect.Parameters["lightRadius"]?.SetValue(entry.Comp.Radius);
+ _sdLightPos?.SetValue(entry.WorldPos);
+ _sdLightRadius?.SetValue(entry.Comp.Radius);
- if (indexCount > 0)
+ // pass 0 = normal range, pass 1 = tail that wraps around the +-pi seam
+ for (int wrapPass = 0; wrapPass < 2; wrapPass++)
{
- int triangles = indexCount / 3;
- // Two draws: pass 0 = primary angular range, pass 1 = wrapped tail
- // around the ±π seam.
- for (int wrapPass = 0; wrapPass < 2; wrapPass++)
+ _sdWrapPass?.SetValue((float)wrapPass);
+ foreach (var pass in depthEffect.CurrentTechnique.Passes)
{
- depthEffect.Parameters["shadowWrapPass"]?.SetValue((float)wrapPass);
- foreach (var pass in depthEffect.CurrentTechnique.Passes)
- {
- pass.Apply();
- GameClient.GraphicsDevice.DrawUserIndexedPrimitives(
- PrimitiveType.TriangleList,
- _shadowVerts, 0, vertexCount,
- _shadowIndices, 0, triangles,
- ShadowGeometry.OccluderVertex.Declaration);
- }
+ pass.Apply();
+ GameClient.GraphicsDevice.DrawIndexedPrimitives(
+ PrimitiveType.TriangleList, 0, 0, _shadowTriCount);
}
}
shadowIdx++;
}
+ GameClient.GraphicsDevice.SetVertexBuffer(null);
+ GameClient.GraphicsDevice.Indices = null;
GameClient.GraphicsDevice.BlendState = prevBlend;
GameClient.GraphicsDevice.DepthStencilState = prevDepth;
}
- // -----------------------------------------------------------------------
- // Light pass
- // -----------------------------------------------------------------------
+ // uploads the frame's occluder geometry to the shared vertex buffer.
+ // The geometry doesn't depend on the light (lightPos is a uniform), so
+ // every shadow light draws the same buffer
+ private void BuildShadowGeometry()
+ {
+ _shadowTriCount = 0;
+
+ int count = Math.Min(_occluders.Count, MaxOccluderCap);
+ if (count == 0) return;
+
+ if (count > _occluderCapacity || _shadowVB is null || _shadowIB is null)
+ {
+ while (_occluderCapacity < count)
+ _occluderCapacity *= 2;
+ _occluderCapacity = Math.Min(_occluderCapacity, MaxOccluderCap);
+
+ if (_shadowVerts.Length < _occluderCapacity * 16)
+ _shadowVerts = new ShadowGeometry.OccluderVertex[_occluderCapacity * 16];
+
+ _shadowVB?.Dispose();
+ _shadowIB?.Dispose();
+ _shadowVB = new DynamicVertexBuffer(GameClient.GraphicsDevice,
+ ShadowGeometry.OccluderVertex.Declaration, _occluderCapacity * 16, BufferUsage.WriteOnly);
+ _shadowIB = new IndexBuffer(GameClient.GraphicsDevice,
+ IndexElementSize.SixteenBits, _occluderCapacity * 24, BufferUsage.WriteOnly);
+ _shadowIB.SetData(BuildQuadIndices(_occluderCapacity * 4));
+ }
+
+ int vertexCount = ShadowGeometry.Build(_occluders, _shadowVerts);
+ if (vertexCount == 0) return;
+
+ _shadowVB.SetData(_shadowVerts, 0, vertexCount, SetDataOptions.Discard);
+ _shadowTriCount = vertexCount / 4 * 2;
+ }
+
+ // 0,1,2 0,2,3 for every quad. Built once per capacity, the pattern
+ // never changes
+ private static short[] BuildQuadIndices(int quadCount)
+ {
+ var indices = new short[quadCount * 6];
+ for (int q = 0; q < quadCount; q++)
+ {
+ int v = q * 4;
+ int i = q * 6;
+ indices[i] = (short)v;
+ indices[i + 1] = (short)(v + 1);
+ indices[i + 2] = (short)(v + 2);
+ indices[i + 3] = (short)v;
+ indices[i + 4] = (short)(v + 2);
+ indices[i + 5] = (short)(v + 3);
+ }
+ return indices;
+ }
private void DrawRadialLights(Matrix viewProj)
{
@@ -493,9 +622,9 @@ private void DrawRadialLights(Matrix viewProj)
var lightEffect = _lightSoftEffect;
- lightEffect.Parameters["viewProj"]?.SetValue(viewProj);
- lightEffect.Parameters["ShadowMap"]?.SetValue(_shadowMap.Target);
- lightEffect.Parameters["shadowMapTexel"]?.SetValue(new Vector2(
+ _lpViewProj?.SetValue(viewProj);
+ _lpShadowMap?.SetValue(_shadowMap.Target);
+ _lpShadowMapTexel?.SetValue(new Vector2(
_shadowMap.Target is null ? 1f : 1f / _shadowMap.Target.Width,
_shadowMap.Target is null ? 1f : 1f / _shadowMap.Target.Height));
@@ -514,7 +643,7 @@ private void DrawRadialLights(Matrix viewProj)
float radius = entry.Comp.Radius;
BuildDiskQuad(entry.WorldPos, radius);
- float lidx = (entry.Comp.CastShadows && shadowIdx < _lighting.MaxShadowcastingLights)
+ float lidx = (entry.CastShadows && shadowIdx < _lighting.MaxShadowcastingLights)
? (shadowIdx + 0.5f) / _lighting.MaxShadowcastingLights
: -1f;
@@ -524,22 +653,21 @@ private void DrawRadialLights(Matrix viewProj)
if (lightEffect.CurrentTechnique.Name != techniqueName)
lightEffect.CurrentTechnique = lightEffect.Techniques[techniqueName];
- lightEffect.Parameters["lightCenter"]?.SetValue(entry.WorldPos);
- lightEffect.Parameters["lightColor"]?.SetValue(entry.Comp.Color.ToVector4());
- lightEffect.Parameters["lightRange"]?.SetValue(radius);
- lightEffect.Parameters["lightRadius"]?.SetValue(radius);
- lightEffect.Parameters["lightPower"]?.SetValue(entry.Comp.Intensity * _lighting.LightIntensity);
- lightEffect.Parameters["lightSoftness"]?.SetValue(entry.Comp.Softness * _lighting.LightSoftness);
- lightEffect.Parameters["lightFalloff"]?.SetValue(FalloffScalar(entry.Comp.Falloff));
- lightEffect.Parameters["lightCurveFactor"]?.SetValue(CurveFactorFor(entry.Comp.Falloff));
- lightEffect.Parameters["lightIndex"]?.SetValue(lidx);
- lightEffect.Parameters["shadowContactBias"]?.SetValue(_lighting.ShadowContactBias / MathF.Max(radius, 0.0001f));
+ _lpCenter?.SetValue(entry.WorldPos);
+ _lpColor?.SetValue(entry.Comp.Color.ToVector4());
+ _lpRange?.SetValue(radius);
+ _lpPower?.SetValue(entry.Comp.Intensity * _lighting.LightIntensity);
+ _lpSoftness?.SetValue(entry.Comp.Softness * _lighting.LightSoftness);
+ _lpFalloff?.SetValue(FalloffScalar(entry.Comp.Falloff));
+ _lpCurveFactor?.SetValue(CurveFactorFor(entry.Comp.Falloff));
+ _lpIndex?.SetValue(lidx);
+ _lpContactBias?.SetValue(_lighting.ShadowContactBias / MathF.Max(radius, 0.0001f));
if (entry.IsSpot)
{
- lightEffect.Parameters["lightDirection"]?.SetValue(entry.Direction);
- lightEffect.Parameters["lightConeAngle"]?.SetValue(entry.ConeHalfAngle);
- lightEffect.Parameters["lightConeSoftness"]?.SetValue(entry.ConeSoftness);
+ _lpDirection?.SetValue(entry.Direction);
+ _lpConeAngle?.SetValue(entry.ConeHalfAngle);
+ _lpConeSoftness?.SetValue(entry.ConeSoftness);
}
GameClient.GraphicsDevice.ScissorRectangle = LightToScissor(entry.WorldPos, radius, viewProj, vpW, vpH);
@@ -551,7 +679,7 @@ private void DrawRadialLights(Matrix viewProj)
PrimitiveType.TriangleList, _diskQuad, 0, 2, DiskVertex.Declaration);
}
- if (entry.Comp.CastShadows && shadowIdx < _lighting.MaxShadowcastingLights)
+ if (entry.CastShadows && shadowIdx < _lighting.MaxShadowcastingLights)
shadowIdx++;
}
@@ -574,6 +702,14 @@ private void DrawTextureLights()
if (!_assets.GetTexture(tex.Texture, out var spr, out var page)) continue;
var worldPos = transform.Position + tex.Offset;
+
+ // 1.5 covers any rotation of the sprite rect
+ float maxDim = MathF.Max(
+ spr.Region.Width * MathF.Abs(tex.Scale.X),
+ spr.Region.Height * MathF.Abs(tex.Scale.Y)) * 1.5f;
+ if (!_camera.IsOnScreen(worldPos, new Vector2(maxDim)))
+ continue;
+
var rotation = (tex.RotatesWithTransform ? transform.Angle : 0f) + tex.Rotation;
var color = tex.Color * tex.Intensity * _lighting.LightIntensity;
@@ -592,115 +728,54 @@ private void DrawTextureLights()
GameClient.SpriteBatch.End();
}
- // -----------------------------------------------------------------------
- // Occlusion mask pass
- // -----------------------------------------------------------------------
-
- private void RenderOcclusionMask(Matrix viewProj)
+ // blurs the lightmap at half res, then draws the occluder quads over
+ // the lightmap replacing each wall pixel with the blurred value, so
+ // walls show the glow of nearby lights (Robust's wall bleed)
+ private void RunWallBleed(Matrix viewProj)
{
- if (_occlusionMaskEffect is null || _occlusionMask.Target is null) return;
-
- _occlusionMaskEffect.Parameters["viewProj"]?.SetValue(viewProj);
- _occlusionMaskEffect.Parameters["ShadowMap"]?.SetValue(_shadowMap.Target);
- _occlusionMaskEffect.Parameters["shadowMapTexel"]?.SetValue(new Vector2(
- _shadowMap.Target is null ? 1f : 1f / _shadowMap.Target.Width,
- _shadowMap.Target is null ? 1f : 1f / _shadowMap.Target.Height));
-
- var prevBlend = GameClient.GraphicsDevice.BlendState;
- var prevDepth = GameClient.GraphicsDevice.DepthStencilState;
- var prevSamp0 = GameClient.GraphicsDevice.SamplerStates[0];
- var prevSamp1 = GameClient.GraphicsDevice.SamplerStates[1];
- var prevRaster = GameClient.GraphicsDevice.RasterizerState;
+ if (_wallMergeEffect is null || _wallBleed.A is null || _wallBleed.B is null || _lightmap.Target is null)
+ return;
- int vpW = _occlusionMask.Target.Width;
- int vpH = _occlusionMask.Target.Height;
+ int needed = _occluders.Count * 6;
+ if (_wallVerts.Length < needed)
+ Array.Resize(ref _wallVerts, needed);
- GameClient.GraphicsDevice.SetRenderTarget(_occlusionMask.Target);
- GameClient.GraphicsDevice.Clear(Color.Transparent);
- GameClient.GraphicsDevice.Viewport = new Viewport(0, 0, vpW, vpH);
- GameClient.GraphicsDevice.BlendState = BlendState.Additive;
- GameClient.GraphicsDevice.DepthStencilState = DepthStencilState.None;
- GameClient.GraphicsDevice.SamplerStates[0] = SamplerState.LinearClamp;
- GameClient.GraphicsDevice.SamplerStates[1] = SamplerState.LinearClamp;
- GameClient.GraphicsDevice.RasterizerState = ScissorRasterizer;
-
- int shadowIdx = 0;
- foreach (var entry in _lights)
+ int n = 0;
+ foreach (var (b, _) in _occluders)
{
- if (!entry.Comp.CastShadows) continue;
- if (shadowIdx >= _lighting.MaxShadowcastingLights) continue;
-
- float radius = entry.Comp.Radius;
- BuildDiskQuad(entry.WorldPos, radius);
-
- var techniqueName = entry.IsSpot
- ? (_lighting.HardShadows ? "SpotOcclusionMaskHard" : "SpotOcclusionMask")
- : (_lighting.HardShadows ? "OcclusionMaskHard" : "OcclusionMask");
- if (_occlusionMaskEffect.CurrentTechnique.Name != techniqueName)
- _occlusionMaskEffect.CurrentTechnique = _occlusionMaskEffect.Techniques[techniqueName];
-
- _occlusionMaskEffect.Parameters["lightCenter"]?.SetValue(entry.WorldPos);
- _occlusionMaskEffect.Parameters["lightRange"]?.SetValue(radius);
- _occlusionMaskEffect.Parameters["lightRadius"]?.SetValue(radius);
- _occlusionMaskEffect.Parameters["lightSoftness"]?.SetValue(entry.Comp.Softness * _lighting.LightSoftness);
- _occlusionMaskEffect.Parameters["lightIndex"]?.SetValue((shadowIdx + 0.5f) / _lighting.MaxShadowcastingLights);
- _occlusionMaskEffect.Parameters["shadowContactBias"]?.SetValue(_lighting.ShadowContactBias / MathF.Max(radius, 0.0001f));
-
- if (entry.IsSpot)
- {
- _occlusionMaskEffect.Parameters["lightDirection"]?.SetValue(entry.Direction);
- _occlusionMaskEffect.Parameters["lightConeAngle"]?.SetValue(entry.ConeHalfAngle);
- _occlusionMaskEffect.Parameters["lightConeSoftness"]?.SetValue(entry.ConeSoftness);
- }
-
- GameClient.GraphicsDevice.ScissorRectangle = LightToScissor(entry.WorldPos, radius, viewProj, vpW, vpH);
-
- foreach (var pass in _occlusionMaskEffect.CurrentTechnique.Passes)
- {
- pass.Apply();
- GameClient.GraphicsDevice.DrawUserPrimitives(
- PrimitiveType.TriangleList, _diskQuad, 0, 2, DiskVertex.Declaration);
- }
-
- shadowIdx++;
+ var tl = new Vector2(b.Left, b.Top);
+ var tr = new Vector2(b.Right, b.Top);
+ var br = new Vector2(b.Right, b.Bottom);
+ var bl = new Vector2(b.Left, b.Bottom);
+ _wallVerts[n++] = new DiskVertex { WorldPos = tl };
+ _wallVerts[n++] = new DiskVertex { WorldPos = tr };
+ _wallVerts[n++] = new DiskVertex { WorldPos = br };
+ _wallVerts[n++] = new DiskVertex { WorldPos = tl };
+ _wallVerts[n++] = new DiskVertex { WorldPos = br };
+ _wallVerts[n++] = new DiskVertex { WorldPos = bl };
}
+ if (n == 0) return;
- GameClient.GraphicsDevice.BlendState = prevBlend;
- GameClient.GraphicsDevice.DepthStencilState = prevDepth;
- GameClient.GraphicsDevice.SamplerStates[0] = prevSamp0;
- GameClient.GraphicsDevice.SamplerStates[1] = prevSamp1;
- GameClient.GraphicsDevice.RasterizerState = prevRaster;
- }
-
- // -----------------------------------------------------------------------
- // Wall bleed + light blur
- // -----------------------------------------------------------------------
-
- private void RunWallBleed()
- {
- if (_lightBlurEffect is null || _wallMergeEffect is null ||
- _wallBleed.A is null || _wallBleed.B is null || _lightmap.Target is null)
- return;
-
- // 2-pass separable Gaussian blur of the lightmap into wallBleed.B.
- BlurPass(_lightmap.Target, _wallBleed.A!, 1f);
- BlurPass(_wallBleed.A!, _wallBleed.B!, 0f);
+ // two blur iterations so the glow reaches deep enough into the walls
+ BlurPass(_lightmap.Target, _wallBleed.A, 1f);
+ BlurPass(_wallBleed.A, _wallBleed.B, 0f);
+ BlurPass(_wallBleed.B, _wallBleed.A, 1f);
+ BlurPass(_wallBleed.A, _wallBleed.B, 0f);
- // Merge blurred lightmap back additively, restricted to lit pixels via
- // the occlusion mask — prevents the glow from bleeding into dark regions.
- _wallMergeEffect.Parameters["BlurredLightMap"]?.SetValue(_wallBleed.B);
- if (_occlusionMask.Usable)
- _wallMergeEffect.Parameters["OcclusionMask"]?.SetValue(_occlusionMask.Target);
+ _wmBlurred?.SetValue(_wallBleed.B);
+ _wmViewProj?.SetValue(viewProj);
GameClient.GraphicsDevice.SetRenderTarget(_lightmap.Target);
- GameClient.GraphicsDevice.BlendState = BlendState.Additive;
- BuildScreenQuad(_screenQuad, _lightmap.Target.Width, _lightmap.Target.Height);
+ GameClient.GraphicsDevice.Viewport = new Viewport(0, 0, _lightmap.Target.Width, _lightmap.Target.Height);
+ GameClient.GraphicsDevice.BlendState = BlendState.Opaque;
+ GameClient.GraphicsDevice.DepthStencilState = DepthStencilState.None;
+ GameClient.GraphicsDevice.RasterizerState = RasterizerState.CullNone;
foreach (var pass in _wallMergeEffect.CurrentTechnique.Passes)
{
pass.Apply();
GameClient.GraphicsDevice.DrawUserPrimitives(
- PrimitiveType.TriangleList, _screenQuad, 0, 2, ScreenVertex.Declaration);
+ PrimitiveType.TriangleList, _wallVerts, 0, n / 3, DiskVertex.Declaration);
}
GameClient.GraphicsDevice.BlendState = BlendState.AlphaBlend;
@@ -708,43 +783,36 @@ private void RunWallBleed()
private void RunLightBlur()
{
- if (_lightBlurEffect is null || _wallBleed.A is null || _wallBleed.B is null || _lightmap.Target is null)
+ if (_lightBlurEffect is null || _blurScratch.Target is null || _lightmap.Target is null)
return;
- // 3-pass separable Gaussian: H → V → H, end on lightmap.
- BlurPass(_lightmap.Target, _wallBleed.A!, 1f);
- BlurPass(_wallBleed.A!, _wallBleed.B!, 0f);
- BlurPass(_wallBleed.B!, _lightmap.Target, 1f);
+ BlurPass(_lightmap.Target, _blurScratch.Target, 1f);
+ BlurPass(_blurScratch.Target, _lightmap.Target, 0f);
GameClient.GraphicsDevice.BlendState = BlendState.AlphaBlend;
}
private void BlurPass(Texture2D source, RenderTarget2D dest, float isHorizontal)
{
- if (_lightBlurEffect is null) return;
-
- _lightBlurEffect.Parameters["SourceMap"]?.SetValue(source);
- _lightBlurEffect.Parameters["SourceTexel"]?.SetValue(new Vector2(1f / source.Width, 1f / source.Height));
- _lightBlurEffect.Parameters["isHorizontal"]?.SetValue(isHorizontal);
- _lightBlurEffect.Parameters["blurStrength"]?.SetValue(1.0f);
+ _blSourceMap?.SetValue(source);
+ _blSourceTexel?.SetValue(new Vector2(1f / source.Width, 1f / source.Height));
+ _blIsHorizontal?.SetValue(isHorizontal);
GameClient.GraphicsDevice.SetRenderTarget(dest);
GameClient.GraphicsDevice.BlendState = BlendState.Opaque;
+ // SpriteBatch leaves CullCounterClockwise on, which would cull the quad
+ GameClient.GraphicsDevice.RasterizerState = RasterizerState.CullNone;
+ GameClient.GraphicsDevice.DepthStencilState = DepthStencilState.None;
GameClient.GraphicsDevice.Viewport = new Viewport(0, 0, dest.Width, dest.Height);
- BuildScreenQuad(_screenQuad, dest.Width, dest.Height);
- foreach (var pass in _lightBlurEffect.CurrentTechnique.Passes)
+ foreach (var pass in _lightBlurEffect!.CurrentTechnique.Passes)
{
pass.Apply();
GameClient.GraphicsDevice.DrawUserPrimitives(
- PrimitiveType.TriangleList, _screenQuad, 0, 2, ScreenVertex.Declaration);
+ PrimitiveType.TriangleList, ScreenQuad, 0, 2, ScreenVertex.Declaration);
}
}
- // -----------------------------------------------------------------------
- // Helpers
- // -----------------------------------------------------------------------
-
private void BuildDiskQuad(Vector2 center, float radius)
{
float r = radius;
@@ -760,16 +828,6 @@ private void BuildDiskQuad(Vector2 center, float radius)
_diskQuad[5] = new DiskVertex { WorldPos = bl };
}
- private static void BuildScreenQuad(ScreenVertex[] quad, int w, int h)
- {
- quad[0] = new ScreenVertex { Position = new Vector2(-1, -1), TexCoord = new Vector2(0, 0) };
- quad[1] = new ScreenVertex { Position = new Vector2( 1, -1), TexCoord = new Vector2(1, 0) };
- quad[2] = new ScreenVertex { Position = new Vector2( 1, 1), TexCoord = new Vector2(1, 1) };
- quad[3] = new ScreenVertex { Position = new Vector2(-1, -1), TexCoord = new Vector2(0, 0) };
- quad[4] = new ScreenVertex { Position = new Vector2( 1, 1), TexCoord = new Vector2(1, 1) };
- quad[5] = new ScreenVertex { Position = new Vector2(-1, 1), TexCoord = new Vector2(0, 1) };
- }
-
private static float FalloffScalar(FalloffMode mode) => mode switch
{
FalloffMode.Linear => 1.0f,
@@ -786,28 +844,7 @@ private static void BuildScreenQuad(ScreenVertex[] quad, int w, int h)
_ => 0.5f,
};
- // Fills _culledOccluders with every entry in _occluders whose AABB
- // intersects the circle at lightPos with the given radius.
- private void CullOccludersForLight(Vector2 lightPos, float radius)
- {
- _culledOccluders.Clear();
- float r2 = radius * radius;
- foreach (var occ in _occluders)
- {
- var b = occ.Bounds;
- // Nearest point on the AABB to the light center.
- float cx = MathHelper.Clamp(lightPos.X, b.Left, b.Right);
- float cy = MathHelper.Clamp(lightPos.Y, b.Top, b.Bottom);
- float dx = lightPos.X - cx;
- float dy = lightPos.Y - cy;
- if (dx * dx + dy * dy <= r2)
- _culledOccluders.Add(occ);
- }
- }
-
- // Returns the lightmap-space scissor rectangle that tightly encloses the
- // projected disk quad for a given light. Coordinates are clamped to
- // [0, vpW] × [0, vpH] so the rect is always valid for SetScissorRectangle.
+ // scissor rect (in lightmap pixels) that encloses the projected light quad
private static Rectangle LightToScissor(Vector2 worldPos, float radius, Matrix viewProj, int vpW, int vpH)
{
float minX = float.MaxValue, minY = float.MaxValue;
@@ -832,7 +869,7 @@ private static void AccumProjectedCorner(
if (MathF.Abs(v.W) < 1e-6f) return;
float ndcX = v.X / v.W;
float ndcY = v.Y / v.W;
- // NDC (+1 = top, −1 = bottom) → screen Y (0 = top, vpH = bottom).
+ // ndc y is +1 at the top, screen y is 0 at the top
float sx = (ndcX + 1f) * 0.5f * vpW;
float sy = (1f - ndcY) * 0.5f * vpH;
if (sx < minX) minX = sx;
diff --git a/Engine.Client/Graphics/Lighting/OcclusionMaskRT.cs b/Engine.Client/Graphics/Lighting/OcclusionMaskRT.cs
deleted file mode 100644
index 1e70446..0000000
--- a/Engine.Client/Graphics/Lighting/OcclusionMaskRT.cs
+++ /dev/null
@@ -1,97 +0,0 @@
-using Microsoft.Xna.Framework.Graphics;
-
-namespace Engine.Client.Graphics.Lighting;
-
-///
-/// Single-channel alpha render target used as an occlusion mask. Built
-/// once per frame by summing per-light "is this pixel lit?" contributions
-/// from . The wall-bleed pass samples it to
-/// restrict the blur-add to pixels actually reached by a shadow-casting
-/// light, instead of bleeding light everywhere like the original
-/// fullscreen-add approach.
-///
-/// We use because the mask only needs
-/// a single channel — additive blending of 0..1 contributions is enough.
-/// On Reach profiles where Alpha8 isn't supported we fall back to Color.
-///
-internal sealed class OcclusionMaskRT
-{
- private RenderTarget2D? _target;
- private int _allocatedWidth;
- private int _allocatedHeight;
-
- public RenderTarget2D? Target => _target;
-
- public int Width => _allocatedWidth;
- public int Height => _allocatedHeight;
-
- public bool Usable { get; private set; }
-
- ///
- /// Resize the target to match the lightmap. Cheap to call every frame —
- /// only allocates on size change.
- ///
- public void EnsureSize(int width, int height)
- {
- if (width <= 0 || height <= 0)
- return;
-
- if (_target is not null && width == _allocatedWidth && height == _allocatedHeight)
- return;
-
- Dispose();
-
- var device = GameClient.GraphicsDevice;
-
- // Prefer Alpha8 (1 byte/pixel, ~4× cheaper than Color). Fall back
- // to Color if the device rejects it.
- try
- {
- _target = new RenderTarget2D(
- device,
- width,
- height,
- false,
- SurfaceFormat.Alpha8,
- DepthFormat.None,
- 0,
- RenderTargetUsage.DiscardContents);
- Usable = true;
- }
- catch (System.Exception)
- {
- try
- {
- _target = new RenderTarget2D(
- device,
- width,
- height,
- false,
- SurfaceFormat.Color,
- DepthFormat.None,
- 0,
- RenderTargetUsage.DiscardContents);
- Usable = true;
- }
- catch (System.Exception ex)
- {
- Log.Warn(
- $"OcclusionMaskRT: allocation failed ({ex.GetType().Name}: {ex.Message}). " +
- "Wall bleed will be unrestricted.");
- Usable = false;
- }
- }
-
- _allocatedWidth = width;
- _allocatedHeight = height;
- }
-
- public void Dispose()
- {
- _target?.Dispose();
- _target = null;
- _allocatedWidth = 0;
- _allocatedHeight = 0;
- Usable = false;
- }
-}
diff --git a/Engine.Client/Graphics/Lighting/ScratchRT.cs b/Engine.Client/Graphics/Lighting/ScratchRT.cs
new file mode 100644
index 0000000..e98f0c0
--- /dev/null
+++ b/Engine.Client/Graphics/Lighting/ScratchRT.cs
@@ -0,0 +1,39 @@
+using Microsoft.Xna.Framework.Graphics;
+
+namespace Engine.Client.Graphics.Lighting;
+
+///
+/// Full-res scratch target for the lightmap blur ping-pong.
+///
+internal sealed class ScratchRT
+{
+ private RenderTarget2D? _target;
+ private int _w, _h;
+
+ public RenderTarget2D? Target => _target;
+
+ public void EnsureSize(int width, int height)
+ {
+ if (width <= 0 || height <= 0)
+ return;
+
+ if (_target is not null && width == _w && height == _h)
+ return;
+
+ Dispose();
+
+ _target = new RenderTarget2D(
+ GameClient.GraphicsDevice, width, height,
+ false, SurfaceFormat.Color, DepthFormat.None,
+ 0, RenderTargetUsage.DiscardContents);
+
+ _w = width;
+ _h = height;
+ }
+
+ public void Dispose()
+ {
+ _target?.Dispose(); _target = null;
+ _w = 0; _h = 0;
+ }
+}
diff --git a/Engine.Client/Graphics/Lighting/ShadowGeometry.cs b/Engine.Client/Graphics/Lighting/ShadowGeometry.cs
index 58280ab..85da33e 100644
--- a/Engine.Client/Graphics/Lighting/ShadowGeometry.cs
+++ b/Engine.Client/Graphics/Lighting/ShadowGeometry.cs
@@ -6,21 +6,17 @@
namespace Engine.Client.Graphics.Lighting;
///
-/// Builds the per-frame occluder EDGE geometry fed to the shadow depth
-/// shader. For each occluder AABB we emit 4 edges. Each edge gets 4
-/// sub-vertices (the corners of the angular strip in the shadow map).
-/// The vertex shader picks the right one based on subVertex.xy:
-///
-/// subVertex.x = 0 → endpoint A, 1 → endpoint B
-/// subVertex.y = 0 → top of the current shadow row, 1 → bottom
-///
-/// 4 edges × 4 verts = 16 vertices per occluder, 24 indices (6 tris per edge).
+/// Builds the occluder edge geometry for the shadow depth shader. Every
+/// occluder AABB becomes 4 edges, every edge becomes a quad that the vertex
+/// shader stretches across the shadow map row. The geometry doesn't depend
+/// on the light, so it's built once per frame and drawn for every light.
+/// Indices are a fixed 0,1,2 0,2,3 pattern owned by the LightingSystem.
///
internal static class ShadowGeometry
{
///
- /// Vertex format: aPos.xy = endpoint A, aPos.zw = endpoint B,
- /// subVertex.xy = (endpoint 0/1, near/far).
+ /// aPos.xy = endpoint A, aPos.zw = endpoint B (world space).
+ /// subVertex.x picks the endpoint (0/1), subVertex.y the row side (0/1).
///
public struct OccluderVertex
{
@@ -35,85 +31,49 @@ public struct OccluderVertex
}
///
- /// Write per-occluder edge vertex data into
- /// and per-triangle indices into .
- /// Returns the number of indices written (multiple of 3).
- /// is set to the number of vertices actually
- /// written — pass it to DrawUserIndexedPrimitives instead of the full array
- /// length to avoid uploading stale data from prior frames.
- /// Stops at the destArrays bound if there isn't enough room.
+ /// Fills the vertex array from the occluder list (16 verts per occluder).
+ /// Returns the number of vertices written; stops early if the array
+ /// runs out of room.
///
public static int Build(
IReadOnlyList<(Rectangle Bounds, TransformComponent Transform)> occluders,
- OccluderVertex[] destVertices,
- short[] destIndices,
- out int vertexCount)
+ OccluderVertex[] destVertices)
{
int vIdx = 0;
- int iIdx = 0;
int vCap = destVertices.Length;
- int iCap = destIndices.Length;
for (int o = 0; o < occluders.Count; o++)
{
+ if (vIdx + 16 > vCap)
+ break;
+
var bounds = occluders[o].Bounds;
- // 4 AABB corners.
float x0 = bounds.Left;
float y0 = bounds.Top;
float x1 = bounds.Right;
float y1 = bounds.Bottom;
- // 4 edges (TL→TR, TR→BR, BR→BL, BL→TL). Hand-unrolled
- // (instead of an inner (A,B)[] loop) so we don't allocate a
- // 4-element tuple array per occluder per frame.
+ // unrolled so we don't allocate an edge array per occluder
for (int e = 0; e < 4; e++)
{
- if (vIdx + 4 > vCap || iIdx + 6 > iCap)
- {
- vertexCount = 0;
- return iIdx;
- }
-
float ax, ay, bx, by;
switch (e)
{
- case 0: ax = x0; ay = y0; bx = x1; by = y0; break; // TL→TR
- case 1: ax = x1; ay = y0; bx = x1; by = y1; break; // TR→BR
- case 2: ax = x1; ay = y1; bx = x0; by = y1; break; // BR→BL
- default: ax = x0; ay = y1; bx = x0; by = y0; break; // BL→TL
+ case 0: ax = x0; ay = y0; bx = x1; by = y0; break; // top
+ case 1: ax = x1; ay = y0; bx = x1; by = y1; break; // right
+ case 2: ax = x1; ay = y1; bx = x0; by = y1; break; // bottom
+ default: ax = x0; ay = y1; bx = x0; by = y0; break; // left
}
- int vBase = vIdx;
-
- // subVertex = (endpoint 0/1, near/far)
- // (0, 0) → A/top
- // (1, 0) → B/top
- // (1, 1) → B/bottom
- // (0, 1) → A/bottom
var aPos = new Vector4(ax, ay, bx, by);
destVertices[vIdx++] = new OccluderVertex { aPos = aPos, subVertex = new Vector2(0, 0) };
destVertices[vIdx++] = new OccluderVertex { aPos = aPos, subVertex = new Vector2(1, 0) };
destVertices[vIdx++] = new OccluderVertex { aPos = aPos, subVertex = new Vector2(1, 1) };
destVertices[vIdx++] = new OccluderVertex { aPos = aPos, subVertex = new Vector2(0, 1) };
-
- // Two triangles forming the quad (CCW): 0,1,2 and 0,2,3.
- short v0 = (short)vBase;
- short v1 = (short)(vBase + 1);
- short v2 = (short)(vBase + 2);
- short v3 = (short)(vBase + 3);
-
- destIndices[iIdx++] = v0;
- destIndices[iIdx++] = v1;
- destIndices[iIdx++] = v2;
-
- destIndices[iIdx++] = v0;
- destIndices[iIdx++] = v2;
- destIndices[iIdx++] = v3;
}
}
- vertexCount = vIdx;
- return iIdx;
+ return vIdx;
}
}
diff --git a/Engine.Client/Graphics/Lighting/ShadowMapRT.cs b/Engine.Client/Graphics/Lighting/ShadowMapRT.cs
index da7234e..6c429ff 100644
--- a/Engine.Client/Graphics/Lighting/ShadowMapRT.cs
+++ b/Engine.Client/Graphics/Lighting/ShadowMapRT.cs
@@ -4,21 +4,13 @@
namespace Engine.Client.Graphics.Lighting;
///
-/// Owns the shadow map render target. The shadow map is a 1D-unwrapped
-/// cylindrical depth texture where each row corresponds to one
-/// shadow-casting light and each column is an angle (-π to +π) around it.
+/// Shadow map render target. One row per shadow-casting light, each row is
+/// a 360° unwrap of occluder distances around that light. The depth buffer
+/// is what keeps the closest occluder per angle.
///
-/// Format: for the color attachment,
-/// with a 16-bit depth attachment used purely as a "minimum distance
-/// per pixel" sort. The fragment shader writes (dist/r, dist²/r²) into
-/// RG; depth is what enforces the "min" across overlapping occluder
-/// slices.
-///
-/// MonoGame's does not expose the depth-stencil
-/// attachment publicly, so we can't reliably introspect whether the driver
-/// actually gave us one. Instead, this class catches allocation exceptions
-/// and exposes a flag the lighting system can check
-/// before drawing.
+/// Prefers Single (32-bit float) so the stored distance doesn't quantize to
+/// 256 steps like an 8-bit channel would; falls back to Color when the
+/// driver refuses it. Allocation failures surface via .
///
internal sealed class ShadowMapRT
{
@@ -31,16 +23,10 @@ internal sealed class ShadowMapRT
public int Width => _allocatedWidth;
public int Height => _allocatedHeight;
- ///
- /// True when the backing texture was allocated successfully. The lighting
- /// system should skip the shadow pass entirely if this is false (e.g.
- /// driver refused Depth16 on this surface format).
- ///
public bool Usable { get; private set; }
///
- /// Make sure the backing textures match the requested dimensions.
- /// Cheap to call every frame — only allocates when size changes.
+ /// Cheap to call every frame, only reallocates when the size changes.
///
public void EnsureSize(int width, int height)
{
@@ -54,31 +40,34 @@ public void EnsureSize(int width, int height)
var device = GameClient.GraphicsDevice;
- // MonoGame creates the depth-stencil buffer internally when the
- // DepthFormat is set on the RenderTarget2D ctor. Depth16 is
- // plenty for our normalized [0..1] distance. Some drivers refuse
- // certain surface-format + depth-format combos — we surface that
- // as Usable=false so the lighting system can skip drawing into it.
+ // Depth16 is enough, the stored distance is normalized to [0..1]
try
{
_target = new RenderTarget2D(
- device,
- width,
- height,
- false,
- SurfaceFormat.Color,
- DepthFormat.Depth16,
- 0,
- RenderTargetUsage.PreserveContents);
+ device, width, height, false,
+ SurfaceFormat.Single, DepthFormat.Depth16,
+ 0, RenderTargetUsage.DiscardContents);
Usable = true;
}
- catch (System.Exception ex)
+ catch (System.Exception)
{
- Log.Warn(
- $"ShadowMapRT: allocation failed ({ex.GetType().Name}: {ex.Message}). " +
- "Shadow casting disabled for this session.");
- Dispose();
- Usable = false;
+ try
+ {
+ _target = new RenderTarget2D(
+ device, width, height, false,
+ SurfaceFormat.Color, DepthFormat.Depth16,
+ 0, RenderTargetUsage.DiscardContents);
+ Usable = true;
+ Log.Debug("ShadowMapRT: Single format not supported, using Color (8-bit shadow depth).");
+ }
+ catch (System.Exception ex)
+ {
+ Log.Warn(
+ $"ShadowMapRT: allocation failed ({ex.GetType().Name}: {ex.Message}). " +
+ "Shadow casting disabled for this session.");
+ Dispose();
+ Usable = false;
+ }
}
_allocatedWidth = width;
@@ -94,4 +83,3 @@ public void Dispose()
Usable = false;
}
}
-
diff --git a/Engine.Client/Graphics/Lighting/WallBleedRT.cs b/Engine.Client/Graphics/Lighting/WallBleedRT.cs
index ab020a7..f81d741 100644
--- a/Engine.Client/Graphics/Lighting/WallBleedRT.cs
+++ b/Engine.Client/Graphics/Lighting/WallBleedRT.cs
@@ -4,9 +4,9 @@
namespace Engine.Client.Graphics.Lighting;
///
-/// Two ping-pong render targets used to blur the lightmap for the
-/// wall-bleed effect. Sized to match the main lightmap; the engine
-/// writes into one, reads from the other, then swaps.
+/// Ping-pong pair of render targets for the wall bleed blur. Runs at half
+/// the lightmap resolution — the bleed is a low frequency glow, so the
+/// smaller targets look the same and cost a quarter of the fill.
///
internal sealed class WallBleedRT
{
@@ -33,12 +33,12 @@ public void EnsureSize(int width, int height)
_a = new RenderTarget2D(
GameClient.GraphicsDevice, width, height,
false, SurfaceFormat.Color, DepthFormat.None,
- 0, RenderTargetUsage.PreserveContents);
+ 0, RenderTargetUsage.DiscardContents);
_b = new RenderTarget2D(
GameClient.GraphicsDevice, width, height,
false, SurfaceFormat.Color, DepthFormat.None,
- 0, RenderTargetUsage.PreserveContents);
+ 0, RenderTargetUsage.DiscardContents);
_w = width;
_h = height;
diff --git a/Engine.Client/UI/Diagnostic/DebugOverlayScreen.cs b/Engine.Client/UI/Diagnostic/DebugOverlayScreen.cs
index 78c276a..1ac7317 100644
--- a/Engine.Client/UI/Diagnostic/DebugOverlayScreen.cs
+++ b/Engine.Client/UI/Diagnostic/DebugOverlayScreen.cs
@@ -179,7 +179,7 @@ private void RefreshProfilerLabels()
$"Lighting: {FormatMs(_lighting.LastLightingTotalMs)} | " +
$"lights {_lighting.LastVisibleLights}/{_lighting.LastShadowLights} | " +
$"occ {_lighting.LastOccluders} | shadow {_lighting.LastShadowMapWidth}x{_lighting.LastShadowMapHeight}\n" +
- $" shadow {FormatMs(_lighting.LastShadowPassMs)} | mask {FormatMs(_lighting.LastOcclusionMaskMs)} | " +
+ $" shadow {FormatMs(_lighting.LastShadowPassMs)} | " +
$"light {FormatMs(_lighting.LastLightPassMs)} | bleed {FormatMs(_lighting.LastWallBleedMs)} | blur {FormatMs(_lighting.LastLightBlurMs)}";
}
diff --git a/Engine.Client/UI/Diagnostic/DebugWindow.LightingTab.cs b/Engine.Client/UI/Diagnostic/DebugWindow.LightingTab.cs
index b7451cc..e32eccc 100644
--- a/Engine.Client/UI/Diagnostic/DebugWindow.LightingTab.cs
+++ b/Engine.Client/UI/Diagnostic/DebugWindow.LightingTab.cs
@@ -188,7 +188,6 @@ private void RefreshLightList()
$"Occluders: {_lighting.LastOccluders} | " +
$"Total: {_lighting.LastLightingTotalMs:0.0}ms\n" +
$"Shadow: {_lighting.LastShadowPassMs:0.0}ms | " +
- $"OccMask: {_lighting.LastOcclusionMaskMs:0.0}ms | " +
$"Light: {_lighting.LastLightPassMs:0.0}ms | " +
$"WallBleed: {_lighting.LastWallBleedMs:0.0}ms | " +
$"Blur: {_lighting.LastLightBlurMs:0.0}ms\n" +