using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; using Terraria; using Terraria.ModLoader; namespace SoulHarvest.Common; /// /// Clips the stationary Death Domain backdrop into an arbitrary blade-tip ribbon. /// World-space texture coordinates keep the scenery fixed while the red rim is /// drawn later by the owning projectile. /// [Autoload(Side = ModSide.Client)] internal sealed class DeathDomainTrailVisualSystem : ModSystem { private const int MaximumPoints = 72; private static readonly Dictionary drawStates = []; private static readonly VertexPositionColorTexture[] vertices = new VertexPositionColorTexture[MaximumPoints * 2]; private static VertexPositionColorTexture[] mergedOutlineVertices = new VertexPositionColorTexture[4096]; private static VertexPositionColorTexture[][] mergedBackdropVertices = [ new VertexPositionColorTexture[4096], new VertexPositionColorTexture[4096], new VertexPositionColorTexture[4096] ]; private static readonly short[] indices = CreateIndices(); private static BasicEffect? effect; private readonly record struct TrailDrawState( int Owner, IReadOnlyList Points, float Width, float Opacity, bool MergeOverlappingRims, float Fracture, bool TaperEnds, bool NoiseFadeEnds, int Seed, Vector4 Bounds, ulong UpdateTick); internal static void Record(int owner, int identity, IReadOnlyList points, float width, float opacity, bool mergeOverlappingRims = false, float fracture = 0f, bool taperEnds = true, bool noiseFadeEnds = false) { if (Main.dedServ || points.Count < 2 || width <= 0.5f || opacity <= 0.001f) { return; } long key = ((long)owner << 32) | (uint)identity; if (drawStates.TryGetValue(key, out TrailDrawState existing) && existing.MergeOverlappingRims == mergeOverlappingRims && existing.TaperEnds == taperEnds && existing.NoiseFadeEnds == noiseFadeEnds && ReferenceEquals(existing.Points, points)) { // Persistent rifts submit the same immutable path every update. Only // opacity and freshness change, so keep their cached world bounds. drawStates[key] = existing with { Opacity = opacity, Fracture = MathHelper.Clamp(fracture, 0f, 1f), UpdateTick = Main.GameUpdateCount }; return; } drawStates[key] = new TrailDrawState(owner, points, width, opacity, mergeOverlappingRims, MathHelper.Clamp(fracture, 0f, 1f), taperEnds, noiseFadeEnds, unchecked(identity * 397 ^ owner * 7919), CalculateTrailBounds(points, width * 1.72f), Main.GameUpdateCount); } public override void PostUpdateEverything() { if (drawStates.Count == 0) return; List? stale = null; foreach ((long key, TrailDrawState state) in drawStates) { if (Main.GameUpdateCount <= state.UpdateTick + 1) continue; stale ??= []; stale.Add(key); } if (stale is null) return; foreach (long key in stale) drawStates.Remove(key); } public override void OnWorldUnload() => ClearVisualState(); public override void Unload() { ClearVisualState(); BasicEffect? oldEffect = effect; effect = null; if (oldEffect is not null && !Main.dedServ) Main.QueueMainThreadAction(oldEffect.Dispose); } private static void ClearVisualState() { drawStates.Clear(); } public override void PostDrawTiles() { if (Main.gameMenu || drawStates.Count == 0 || Main.graphics?.GraphicsDevice is not GraphicsDevice graphicsDevice) { return; } effect ??= new BasicEffect(graphicsDevice) { TextureEnabled = true, VertexColorEnabled = true, LightingEnabled = false, FogEnabled = false }; effect.World = Main.GameViewMatrix.TransformationMatrix; effect.View = Matrix.Identity; effect.Projection = Matrix.CreateOrthographicOffCenter( 0f, Main.screenWidth, Main.screenHeight, 0f, 0f, 1f); graphicsDevice.BlendState = BlendState.AlphaBlend; graphicsDevice.DepthStencilState = DepthStencilState.None; graphicsDevice.RasterizerState = RasterizerState.CullNone; graphicsDevice.SamplerStates[0] = SamplerState.LinearWrap; DrawMergedTrails(graphicsDevice); foreach (TrailDrawState state in drawStates.Values) { if (Main.GameUpdateCount > state.UpdateTick + 1) continue; if (state.MergeOverlappingRims) continue; if (!IsOnScreen(state.Bounds)) continue; int pointCount = Math.Min(MaximumPoints, state.Points.Count); if (pointCount < 2) continue; DrawSolidRibbon(graphicsDevice, state, state.Width * 1.72f, new Color(174, 0, 44, 0) * (state.Opacity * 0.24f)); DrawSolidRibbon(graphicsDevice, state, state.Width * 1.40f, new Color(244, 12, 66, 235) * state.Opacity); DrawSolidRibbon(graphicsDevice, state, state.Width * 1.17f, new Color(255, 187, 178, 235) * (state.Opacity * 0.88f)); effect.TextureEnabled = true; for (int layer = 0; layer < 3; layer++) { BuildVertices(state, state.Width, Color.White * (state.Opacity * DeathDomainBackdropTextureSystem .GetLayerOpacity(layer)), layer); effect.Texture = DeathDomainBackdropTextureSystem.GetLayer(layer); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Apply(); graphicsDevice.DrawUserIndexedPrimitives( PrimitiveType.TriangleList, vertices, 0, pointCount * 2, indices, 0, (pointCount - 1) * 2); } } } } private static void DrawMergedTrails(GraphicsDevice graphicsDevice) { if (effect is null) return; // Render every rim first and the opaque, world-aligned domain ribbons // afterwards. The common backdrop naturally covers rims inside an // overlap, producing one continuous surface without any pairwise trail // intersection tests. Work therefore stays linear as rifts accumulate. DrawMergedOutlineBatch(graphicsDevice, 0.70f, 0.86f, new Color(174, 0, 44, 0), 0.24f); DrawMergedOutlineBatch(graphicsDevice, 0.585f, 0.70f, new Color(244, 12, 66, 235), 1f); DrawMergedOutlineBatch(graphicsDevice, 0.50f, 0.585f, new Color(255, 187, 178, 235), 0.88f); int backdropVertexCount = BuildMergedBackdropBatch(); if (backdropVertexCount < 3) return; effect.TextureEnabled = true; for (int layer = 0; layer < 3; layer++) { effect.Texture = DeathDomainBackdropTextureSystem.GetLayer(layer); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Apply(); graphicsDevice.DrawUserPrimitives(PrimitiveType.TriangleList, mergedBackdropVertices[layer], 0, backdropVertexCount / 3); } } } private static void DrawMergedOutlineBatch(GraphicsDevice graphicsDevice, float innerWidthRatio, float outerWidthRatio, Color baseColor, float opacityMultiplier) { if (effect is null) return; int vertexIndex = 0; foreach (TrailDrawState state in drawStates.Values) { if (!state.MergeOverlappingRims || Main.GameUpdateCount > state.UpdateTick + 1 || !IsOnScreen(state.Bounds)) { continue; } int pointCount = Math.Min(MaximumPoints, state.Points.Count); float innerHalfWidth = state.Width * innerWidthRatio; float outerHalfWidth = state.Width * outerWidthRatio; Color color = baseColor * (state.Opacity * opacityMultiplier); for (int segment = 0; segment < pointCount - 1; segment++) { if (!IsSegmentVisible(state, segment, pointCount)) continue; Vector2 start = state.Points[segment]; Vector2 end = state.Points[segment + 1]; Vector2 startNormal = GetTrailNormal(state.Points, segment, pointCount); Vector2 endNormal = GetTrailNormal(state.Points, segment + 1, pointCount); float startTaper = GetTrailTaper(state, segment / Math.Max(1f, pointCount - 1f)); float endTaper = GetTrailTaper(state, (segment + 1f) / Math.Max(1f, pointCount - 1f)); float startProgress = segment / Math.Max(1f, pointCount - 1f); float endProgress = (segment + 1f) / Math.Max(1f, pointCount - 1f); for (int sideIndex = 0; sideIndex < 2; sideIndex++) { float side = sideIndex == 0 ? -1f : 1f; Color startColor = color * GetEndNoiseOpacity(state, startProgress, side); Color endColor = color * GetEndNoiseOpacity(state, endProgress, side); Vector2 innerStart = start + startNormal * side * innerHalfWidth * startTaper; Vector2 outerStart = start + startNormal * side * outerHalfWidth * startTaper; Vector2 innerEnd = end + endNormal * side * innerHalfWidth * endTaper; Vector2 outerEnd = end + endNormal * side * outerHalfWidth * endTaper; EnsureBatchCapacity(ref mergedOutlineVertices, vertexIndex + 6); WriteBatchVertex(mergedOutlineVertices, ref vertexIndex, outerStart, startColor, Vector2.Zero); WriteBatchVertex(mergedOutlineVertices, ref vertexIndex, innerStart, startColor, Vector2.Zero); WriteBatchVertex(mergedOutlineVertices, ref vertexIndex, outerEnd, endColor, Vector2.Zero); WriteBatchVertex(mergedOutlineVertices, ref vertexIndex, outerEnd, endColor, Vector2.Zero); WriteBatchVertex(mergedOutlineVertices, ref vertexIndex, innerStart, startColor, Vector2.Zero); WriteBatchVertex(mergedOutlineVertices, ref vertexIndex, innerEnd, endColor, Vector2.Zero); } } } if (vertexIndex < 3) return; effect.TextureEnabled = false; foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Apply(); graphicsDevice.DrawUserPrimitives(PrimitiveType.TriangleList, mergedOutlineVertices, 0, vertexIndex / 3); } } private static int BuildMergedBackdropBatch() { int vertexIndex = 0; foreach (TrailDrawState state in drawStates.Values) { if (!state.MergeOverlappingRims || Main.GameUpdateCount > state.UpdateTick + 1 || !IsOnScreen(state.Bounds)) { continue; } int pointCount = Math.Min(MaximumPoints, state.Points.Count); for (int segment = 0; segment < pointCount - 1; segment++) { if (!IsSegmentVisible(state, segment, pointCount)) continue; Vector2 start = state.Points[segment]; Vector2 end = state.Points[segment + 1]; Vector2 startNormal = GetTrailNormal(state.Points, segment, pointCount); Vector2 endNormal = GetTrailNormal(state.Points, segment + 1, pointCount); float startHalfWidth = state.Width * GetTrailTaper(state, segment / Math.Max(1f, pointCount - 1f)) * 0.5f; float endHalfWidth = state.Width * GetTrailTaper(state, (segment + 1f) / Math.Max(1f, pointCount - 1f)) * 0.5f; Vector2 leftStart = start - startNormal * startHalfWidth; Vector2 rightStart = start + startNormal * startHalfWidth; Vector2 leftEnd = end - endNormal * endHalfWidth; Vector2 rightEnd = end + endNormal * endHalfWidth; int bandCount = state.NoiseFadeEnds ? 6 : 1; int segmentVertexCount = bandCount * 6; for (int layer = 0; layer < 3; layer++) EnsureBatchCapacity(ref mergedBackdropVertices[layer], vertexIndex + segmentVertexCount); Vector2 anchor = GetBackgroundAnchor(state); float startProgress = segment / Math.Max(1f, pointCount - 1f); float endProgress = (segment + 1f) / Math.Max(1f, pointCount - 1f); for (int layer = 0; layer < 3; layer++) { int layerVertexIndex = vertexIndex; Color baseColor = Color.White * (state.Opacity * DeathDomainBackdropTextureSystem .GetLayerOpacity(layer)); for (int band = 0; band < bandCount; band++) { float acrossStart = band / (float)bandCount; float acrossEnd = (band + 1f) / bandCount; float lateralStart = MathHelper.Lerp(-1f, 1f, acrossStart); float lateralEnd = MathHelper.Lerp(-1f, 1f, acrossEnd); Vector2 bandStartLeft = Vector2.Lerp(leftStart, rightStart, acrossStart); Vector2 bandStartRight = Vector2.Lerp(leftStart, rightStart, acrossEnd); Vector2 bandEndLeft = Vector2.Lerp(leftEnd, rightEnd, acrossStart); Vector2 bandEndRight = Vector2.Lerp(leftEnd, rightEnd, acrossEnd); Color startLeftColor = baseColor * GetEndNoiseOpacity(state, startProgress, lateralStart); Color startRightColor = baseColor * GetEndNoiseOpacity(state, startProgress, lateralEnd); Color endLeftColor = baseColor * GetEndNoiseOpacity(state, endProgress, lateralStart); Color endRightColor = baseColor * GetEndNoiseOpacity(state, endProgress, lateralEnd); WriteBackdropBatchVertex(layer, state.Owner, ref layerVertexIndex, bandStartLeft, anchor, startLeftColor); WriteBackdropBatchVertex(layer, state.Owner, ref layerVertexIndex, bandStartRight, anchor, startRightColor); WriteBackdropBatchVertex(layer, state.Owner, ref layerVertexIndex, bandEndLeft, anchor, endLeftColor); WriteBackdropBatchVertex(layer, state.Owner, ref layerVertexIndex, bandEndLeft, anchor, endLeftColor); WriteBackdropBatchVertex(layer, state.Owner, ref layerVertexIndex, bandStartRight, anchor, startRightColor); WriteBackdropBatchVertex(layer, state.Owner, ref layerVertexIndex, bandEndRight, anchor, endRightColor); } } vertexIndex += segmentVertexCount; } } return vertexIndex; } private static void WriteBackdropBatchVertex(int layer, int owner, ref int vertexIndex, Vector2 world, Vector2 anchor, Color color) { Vector2 uv = DeathDomainBackdropTextureSystem.GetMatchingDomainUv( layer, world, owner, anchor); WriteBatchVertex(mergedBackdropVertices[layer], ref vertexIndex, world, color, uv); } private static void WriteBatchVertex(VertexPositionColorTexture[] target, ref int vertexIndex, Vector2 world, Color color, Vector2 uv) { target[vertexIndex++] = new VertexPositionColorTexture( new Vector3(world - Main.screenPosition, 0f), color, uv); } private static void EnsureBatchCapacity( ref VertexPositionColorTexture[] target, int required) { if (target.Length >= required) return; int capacity = Math.Max(required, Math.Max(4096, target.Length * 2)); Array.Resize(ref target, capacity); } private static Vector2 GetTrailNormal(IReadOnlyList points, int index, int pointCount) { Vector2 previous = points[Math.Max(0, index - 1)]; Vector2 next = points[Math.Min(pointCount - 1, index + 1)]; return (next - previous).SafeNormalize(Vector2.UnitX) .RotatedBy(MathHelper.PiOver2); } private static float GetTrailTaper(TrailDrawState state, float progress) => state.TaperEnds ? (float)Math.Pow(Math.Max(0f, Math.Sin(MathHelper.Clamp(progress, 0f, 1f) * MathHelper.Pi)), 0.34f) : 1f; private static float GetEndNoiseOpacity(TrailDrawState state, float progress, float lateral) { if (!state.NoiseFadeEnds) return 1f; progress = MathHelper.Clamp(progress, 0f, 1f); const float fadeSpan = 0.28f; float edgeDistance = Math.Min(progress, 1f - progress); if (edgeDistance >= fadeSpan) return 1f; float fade = edgeDistance / fadeSpan; int alongCell = (int)Math.Floor(progress * 53f); int lateralCell = (int)Math.Floor((lateral + 1f) * 17f); uint hash = unchecked((uint)(state.Seed * 747796405 + alongCell * 2891336453 + lateralCell * 1181783497)); hash ^= hash >> 16; hash *= 0x7FEB352Du; hash ^= hash >> 15; float noise = (hash & 0x00FFFFFFu) / 16777215f; float dissolve = (fade - 0.06f) * 1.18f + (noise - 0.5f) * 0.4f; return Smooth01(MathHelper.Clamp(dissolve, 0f, 1f)); } private static bool IsSegmentVisible(TrailDrawState state, int segment, int pointCount) { if (state.Fracture <= 0.001f) return true; uint hash = unchecked((uint)(state.Seed * 747796405 + segment * 2891336453)); hash ^= hash >> 16; hash *= 0x7FEB352Du; hash ^= hash >> 15; float random = (hash & 0x00FFFFFFu) / 16777215f; float along = (segment + 0.5f) / Math.Max(1f, pointCount - 1f); float ripple = 0.5f + 0.5f * (float)Math.Sin( along * MathHelper.TwoPi * 5f + state.Seed * 0.017f); float breakThreshold = 0.10f + random * 0.68f + ripple * 0.16f; return Smooth01(state.Fracture) < breakThreshold; } private static float Smooth01(float value) { value = MathHelper.Clamp(value, 0f, 1f); return value * value * (3f - 2f * value); } private static Vector4 CalculateTrailBounds( IReadOnlyList points, float width) { Vector2 minimum = points[0]; Vector2 maximum = points[0]; int count = Math.Min(MaximumPoints, points.Count); for (int index = 1; index < count; index++) { minimum = Vector2.Min(minimum, points[index]); maximum = Vector2.Max(maximum, points[index]); } float padding = width * 0.5f + 4f; return new Vector4(minimum.X - padding, minimum.Y - padding, maximum.X + padding, maximum.Y + padding); } private static bool IsOnScreen(Vector4 bounds) { const float padding = 96f; float left = Main.screenPosition.X - padding; float top = Main.screenPosition.Y - padding; float right = Main.screenPosition.X + Main.screenWidth + padding; float bottom = Main.screenPosition.Y + Main.screenHeight + padding; return bounds.Z >= left && bounds.W >= top && bounds.X <= right && bounds.Y <= bottom; } private static Vector2 GetBackgroundAnchor(TrailDrawState state) => new((state.Bounds.X + state.Bounds.Z) * 0.5f, (state.Bounds.Y + state.Bounds.W) * 0.5f); private static void DrawSolidRibbon(GraphicsDevice graphicsDevice, TrailDrawState state, float width, Color color) { if (effect is null) return; int pointCount = BuildVertices(state, width, color); effect.TextureEnabled = false; foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Apply(); graphicsDevice.DrawUserIndexedPrimitives( PrimitiveType.TriangleList, vertices, 0, pointCount * 2, indices, 0, (pointCount - 1) * 2); } } private static int BuildVertices(TrailDrawState state, float width, Color color, int backdropLayer = -1) { int count = Math.Min(MaximumPoints, state.Points.Count); for (int index = 0; index < count; index++) { Vector2 point = state.Points[index]; Vector2 previous = state.Points[Math.Max(0, index - 1)]; Vector2 next = state.Points[Math.Min(count - 1, index + 1)]; Vector2 tangent = (next - previous).SafeNormalize(Vector2.UnitX); Vector2 normal = tangent.RotatedBy(MathHelper.PiOver2); float progress = index / Math.Max(1f, count - 1f); float taper = GetTrailTaper(state, progress); float halfWidth = width * taper * 0.5f; for (int side = 0; side < 2; side++) { Vector2 world = point + normal * (side == 0 ? -halfWidth : halfWidth); Vector2 screen = world - Main.screenPosition; Vector2 uv = backdropLayer >= 0 ? DeathDomainBackdropTextureSystem.GetMatchingDomainUv( backdropLayer, world, state.Owner, GetBackgroundAnchor(state)) : Vector2.Zero; vertices[index * 2 + side] = new VertexPositionColorTexture( new Vector3(screen, 0f), color * GetEndNoiseOpacity(state, progress, side == 0 ? -1f : 1f), uv); } } return count; } private static short[] CreateIndices() { short[] result = new short[(MaximumPoints - 1) * 6]; int cursor = 0; for (short index = 0; index < MaximumPoints - 1; index++) { short leftTop = (short)(index * 2); short leftBottom = (short)(leftTop + 1); short rightTop = (short)(leftTop + 2); short rightBottom = (short)(leftTop + 3); result[cursor++] = leftTop; result[cursor++] = leftBottom; result[cursor++] = rightTop; result[cursor++] = rightTop; result[cursor++] = leftBottom; result[cursor++] = rightBottom; } return result; } }