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" +