using System; using System.Numerics; using Robust.Shared.Collections; using Robust.Shared.ComponentTrees; using Robust.Shared.Configuration; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Map; using Robust.Shared.Map.Components; using Robust.Shared.Maths; using Robust.Shared.Physics; using Robust.Shared.Prototypes; namespace Robust.Shared.Light; /// /// This system provides methods for computing the light level at some point in space. This is intended to /// generally match the light values that would be computed by the default light shader. /// /// /// Note that the server and client might disagree about the computed light levels if there are any non-networked lights /// or lights with client-side animations. /// public sealed partial class LightLevelSystem : EntitySystem { private float _maxLightRadius; private const float LightHeight = 1.0f; [Dependency] private SharedTransformSystem _transform = default!; [Dependency] private SharedMapSystem _map = default!; [Dependency] private OccluderSystem _occluder = default!; [Dependency] private SharedLightTreeSystem _tree = default!; [Dependency] private IPrototypeManager _proto = default!; [Dependency] private IConfigurationManager _cfg = default!; public override void Initialize() { base.Initialize(); Subs.CVar(_cfg, CVars.MaxLightRadius, v => _maxLightRadius = v, true); } /// /// Compute the light level at an entity's position. /// /// /// Note that the server and client might disagree about the computed light levels if there are any non-networked /// lights or lights with client-side animations. /// public float CalculateLightLevel(EntityUid uid) => CalculateLightLevel(_transform.GetMapCoordinates(uid)); /// /// Compute the light level at the given coordinates. /// /// /// Note that the server and client might disagree about the computed light levels if there are any non-networked /// lights or lights with client-side animations. /// public float CalculateLightLevel(EntityCoordinates point) => CalculateLightLevel(_transform.ToMapCoordinates(point)); /// public float CalculateLightLevel(MapCoordinates point) => TryCalculateLightLevel(point, out var level) ? level : 0f; public bool TryCalculateLightLevel(EntityUid uid, out float level, LightLevelQueryOptions options = default) => TryCalculateLightLevel(_transform.GetMapCoordinates(uid), out level, options); public bool TryCalculateLightLevel(EntityCoordinates point, out float level, LightLevelQueryOptions options = default) => TryCalculateLightLevel(_transform.ToMapCoordinates(point), out level, options); public bool TryCalculateLightLevel(MapCoordinates point, out float level, LightLevelQueryOptions options = default) { if (!TryCalculateLightColor(point, out var color, options)) { level = default; return false; } level = ColorToLevel(color); return true; } /// /// Convert from a total light color to a single "brightness/intensity" float. /// public float ColorToLevel(Color color) { // TODO: Colorspace-specific structs I beg, this is linear. var luminance = 0.2126f * color.R + 0.7152f * color.G + 0.0722f * color.B; return Math.Clamp(luminance, 0f, 1f); } /// public Color CalculateLightColor(EntityUid uid) => CalculateLightColor(_transform.GetMapCoordinates(uid)); /// public Color CalculateLightColor(EntityCoordinates point) => CalculateLightColor(_transform.ToMapCoordinates(point)); /// public Color CalculateLightColor(MapCoordinates point) => TryCalculateLightColor(point, out var color) ? color : Color.Black; /// /// Try to compute additive light colour at the given coordinates. /// /// /// This includes map ambient light. If map lighting is disabled, this returns fully-lit white because the renderer /// skips the lighting pass for that map. Client-only clear-color overrides are not represented in shared state, so /// this can differ from a client viewport that overrides the lighting clear color locally. /// public bool TryCalculateLightColor(EntityUid uid, out Color color, LightLevelQueryOptions options = default) => TryCalculateLightColor(_transform.GetMapCoordinates(uid), out color, options); public bool TryCalculateLightColor(EntityCoordinates point, out Color color, LightLevelQueryOptions options = default) => TryCalculateLightColor(_transform.ToMapCoordinates(point), out color, options); public bool TryCalculateLightColor(MapCoordinates point, out Color color, LightLevelQueryOptions options = default) { if (!TryGetAmbientLight(point, out color, out var lightingEnabled)) return false; if (!lightingEnabled) return true; if (!_tree.IsAvailable) return false; var pos = point.Position; var treeSearchAabb = new Box2(pos, pos).Enlarged(_maxLightRadius); var lights = new ValueList(); // We manually do a tree lookup instead of using LightTreeSystem.QueryAabb // This is because the actual area we want to query for intersecting lights is a point, but we want to include trees from further away. foreach (var (tree, treeComp) in _tree.GetIntersectingTrees(point.MapId, treeSearchAabb)) { var localPos = Vector2.Transform(pos, _transform.GetInvWorldMatrix(tree)); treeComp.Tree.QueryPoint(ref lights, options.ShadowCastingOnly ? ShadowcastingCallback : AllLightCallback, localPos, true); } // Compute light positions, and get the maximum radius var lightSpan = lights.Span; var maxRadius = 0f; var maxShadowRadius = 0f; ComputeLightPositions(lightSpan, ref maxRadius); foreach (ref var light in lightSpan) { if (light.Entity.Comp1.CastShadows) maxShadowRadius = Math.Max(Math.Min(light.Entity.Comp1.Radius, _maxLightRadius), maxShadowRadius); } if (maxShadowRadius == 0f) { AddUnoccludedLights(pos, lightSpan, ref color); return true; } // Use the max radius to look for any occluder trees. This could be handled better by only using a Box2 that // contains the centre point of all lights, which would allow us to use the HandleSingleOccluder branch more, // but this approximation is probably fine most of the time. var occluderAabb = new Box2(pos, pos).Enlarged(maxShadowRadius); var occluderTrees = _occluder.GetIntersectingTreesInternal(point.MapId, occluderAabb); // Most of the time, there will probably only be one occluder tree in range var lightColor = occluderTrees.Count == 1 ? HandleSingleOccluder(pos, lightSpan, occluderTrees[0]) : HandleMultipleOccluders(pos, lightSpan, occluderTrees.Span); color = new Color(color.RGBA + lightColor.RGBA); return true; static bool ShadowcastingCallback(ref ValueList lights, in ComponentTreeEntry value) { if (value.Component.CastShadows) lights.Add(new(value)); return true; } static bool AllLightCallback(ref ValueList lights, in ComponentTreeEntry value) { lights.Add(new(value)); return true; } } private void ComputeLightPositions(Span lights, ref float maxRadius) { foreach (ref var light in lights) { (light.Position, light.Rotation) = _transform.GetWorldPositionRotation(light.Entity.Comp2); light.Position += light.Rotation.RotateVec(light.Entity.Comp1.Offset); maxRadius = Math.Max(Math.Min(light.Entity.Comp1.Radius, _maxLightRadius), maxRadius); } } private bool TryGetAmbientLight(MapCoordinates point, out Color color, out bool lightingEnabled) { if (!_map.TryGetMap(point.MapId, out var mapUid) || !TryComp(mapUid, out MapComponent? map)) { color = default; lightingEnabled = false; return false; } lightingEnabled = map.LightingEnabled; if (!map.LightingEnabled) { color = Color.White; return true; } color = CompOrNull(mapUid)?.AmbientLightColor ?? MapLightComponent.DefaultColor; return true; } private void AddUnoccludedLights(Vector2 pos, Span lights, ref Color color) { var colorVec = color.RGBA; foreach (ref var entry in lights) { var delta = pos - entry.Position; if (InRange(entry.Entity.Comp1.Radius, delta)) colorVec += GetColourFromLight(entry.Entity.Comp1, delta, entry.Rotation); } color = new Color(colorVec); } private Color HandleSingleOccluder(Vector2 pos, Span lights, Entity tree) { var (_, rot, mat) = _transform.GetWorldPositionRotationInvMatrix(tree.Owner); rot = -rot; var color = Vector4.Zero; foreach (ref var entry in lights) { var delta = pos - entry.Position; if (!InRange(entry.Entity.Comp1.Radius, delta)) continue; if (!entry.Entity.Comp1.CastShadows || Unoccluded(entry.Position, delta, tree.Comp, in mat, rot)) color += GetColourFromLight(entry.Entity.Comp1, delta, entry.Rotation); } return new Color(color); } private Color HandleMultipleOccluders( Vector2 pos, Span lightSpan, Span<(EntityUid Uid, OccluderTreeComponent Comp)> trees) { var occluderXforms = trees.Length < 16 ? stackalloc OccluderTransform[trees.Length] : new OccluderTransform[trees.Length]; for (var i = 0; i < trees.Length; i++) { var (_, rot, mat) = _transform.GetWorldPositionRotationInvMatrix(trees[i].Uid); occluderXforms[i] = new(-rot, mat); } var color = Vector4.Zero; foreach (ref var entry in lightSpan) { var delta = pos - entry.Position; if (!InRange(entry.Entity.Comp1.Radius, delta)) continue; if (!entry.Entity.Comp1.CastShadows || Unoccluded(entry.Position, delta, trees, occluderXforms)) color += GetColourFromLight(entry.Entity.Comp1, delta, entry.Rotation); } return new Color(color); } private bool InRange(float radius, Vector2 delta) { var cappedRadius = Math.Min(radius, _maxLightRadius); return delta.LengthSquared() <= cappedRadius * cappedRadius; } private static bool Unoccluded( Vector2 lightPos, Vector2 delta, ReadOnlySpan<(EntityUid, OccluderTreeComponent)> trees, Span treeXforms) { var length = delta.Length(); if (MathHelper.CloseTo(length, 0)) return true; var normalized = delta / length; (bool Hit, float Length) state = (false, length); for (var i = 0; i < trees.Length; i++) { var relativeAngle = treeXforms[i].Rotation.RotateVec(normalized); var treeRay = new Ray(Vector2.Transform(lightPos, treeXforms[i].Matrix), relativeAngle); trees[i].Item2.Tree.QueryRay(ref state, Callback, treeRay); if (state.Hit) return false; } return true; } private static bool Unoccluded( Vector2 lightPos, Vector2 delta, OccluderTreeComponent tree, in Matrix3x2 treeXform, Angle treeRot) { var length = delta.Length(); if (MathHelper.CloseTo(length, 0)) return true; var normalized = delta / length; var relativeAngle = treeRot.RotateVec(normalized); var treeRay = new Ray(Vector2.Transform(lightPos, treeXform), relativeAngle); (bool Hit, float Length) state = (false, length); tree.Tree.QueryRay(ref state, Callback, treeRay); return !state.Hit; } private static bool Callback(ref (bool Hit, float Range) state, in ComponentTreeEntry _, in Vector2 __, float dist) { if (dist > state.Range) return true; state.Hit = true; return false; } private Vector4 GetColourFromLight(SharedPointLightComponent light, Vector2 distance, Angle worldRotation) { // Calculate the light level the same way as in light_shared.swsl. var radius = Math.Min(light.Radius, _maxLightRadius); var sqrtDist = Vector2.Dot(distance, distance) + LightHeight; var s = Math.Clamp(MathF.Sqrt(sqrtDist) / radius, 0.0f, 1.0f); var s2 = s * s; var curveFactor = MathHelper.Lerp(s, s2, Math.Clamp(light.CurveFactor, 0.0f, 1.0f)); var lightVal = Math.Clamp(((1.0f - s2) * (1.0f - s2)) / (1.0f + light.Falloff * curveFactor), 0.0f, 1.0f); var finalLightVal = light.Color.RGBA * (light.Energy * lightVal); if (!_proto.TryIndex(light.LightMask, out var mask)) return finalLightVal; var maskRot = SharedPointLightSystem.GetMaskWorldRotation(light, worldRotation); var relativeAngle = MathHelper.CloseTo(distance.LengthSquared(), 0) ? Angle.Zero : Angle.FromWorldVec(distance) - maskRot; // TODO LIGHTLEVEL read light mask // read the mask image into a buffer of pixels and sample the returned color to multiply against the light level before final calculation // var stream = _resource.ContentFileRead(mask.MaskPath); // var image = Image.Load(stream); // Rgba32[] pixelArray = new Rgba32[image.Width * image.Height]; // image.CopyPixelDataTo(pixelArray); var calculatedLight = 0d; foreach (var cone in mask.LightCones) { var delta = Math.Abs(Angle.ShortestDistance(relativeAngle, cone.Direction)); // Target is outside the cone's outer width angle, so ignore if (delta > cone.OuterWidth) continue; // Target is within the inner cone, return the full color if (delta < cone.InnerWidth) return finalLightVal; // Lerp light from 0 to 1 as angle goes from outer to inner. // Not additive because multiple cones for the same mask don't work like that. calculatedLight = Math.Max(calculatedLight, (cone.OuterWidth - delta) / (cone.OuterWidth - cone.InnerWidth)); } return finalLightVal * MathF.Min(1, (float)calculatedLight); } private record struct OccluderTransform(Angle Rotation, Matrix3x2 Matrix); private record struct Light( Entity Entity, Vector2 Position = default, Angle Rotation = default); } public readonly record struct LightLevelQueryOptions(bool ShadowCastingOnly = false);