using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
using System.Collections.Generic;
using Terraria;
using Terraria.ModLoader;
namespace SoulHarvest.Common;
/// <summary>
/// Draws the Death scythe's domain-filled face as geometry rather than as one
/// rotating sprite. The crescent mask follows the weapon, while its texture
/// coordinates stay anchored to world space; rapid swings therefore reveal a
/// stable window into the Death Necklace domain instead of rotating the scene.
/// </summary>
[Autoload(Side = ModSide.Client)]
internal sealed class DeathDomainCrescentVisualSystem : ModSystem
{
private const int AngularSegments = 128;
private const int DepthBands = 18;
private const int ResidualLifetimeFrames = 30;
private const float HalfSweep = ReaperCombatRegistry.DeathPrimaryHalfSweep;
private const float OuterRadiusRatio = 0.91f;
// The outside blade edge stays on the weapon tip. Additional size grows only
// toward the wielder, as requested, instead of increasing attack reach.
private const float MaximumThicknessRatio = 0.585f;
private static readonly Dictionary<long, CrescentDrawState> drawStates = [];
private static long nextVisualInstanceId;
private static BasicEffect? effect;
private static VertexPositionColorTexture[][] layerVertices =
[
new VertexPositionColorTexture[(AngularSegments + 1) * (DepthBands + 1)],
new VertexPositionColorTexture[(AngularSegments + 1) * (DepthBands + 1)],
new VertexPositionColorTexture[(AngularSegments + 1) * (DepthBands + 1)]
];
private static short[] indices = CreateIndices();
private static VertexPositionColorTexture[] rimVertices =
new VertexPositionColorTexture[AngularSegments * 6];
private readonly record struct CrescentDrawState(
int Owner,
Vector2 Center,
float Rotation,
float Radius,
float Opacity,
int SwingDirection,
float LifeProgress,
bool PreserveInterior,
ulong UpdateTick);
/// <summary>
/// Allocates a client-local identity for one physical swing. Projectile
/// identities and slots can be reused before a residual crescent expires at
/// maximum attack speed, so neither is safe as the draw-state key.
/// </summary>
internal static long AllocateVisualInstanceId()
{
nextVisualInstanceId++;
if (nextVisualInstanceId <= 0)
nextVisualInstanceId = 1;
return nextVisualInstanceId;
}
internal static void Record(long visualInstanceId, int owner,
Vector2 center,
float rotation, float radius, float opacity, int swingDirection,
float lifeProgress, bool preserveInterior = false)
{
if (Main.dedServ || visualInstanceId <= 0 || radius <= 1f
|| opacity <= 0.001f)
return;
if (drawStates.TryGetValue(visualInstanceId,
out CrescentDrawState existing))
{
// A slash is a cut made in world space, not an aura attached to the
// player. Keep its first center, orientation and size immutable;
// subsequent frames only advance the reveal frontier and refresh its
// residual clock. Already drawn material therefore stays exactly
// where the blade passed even if the player moves during the swing.
drawStates[visualInstanceId] = existing with
{
Opacity = opacity,
LifeProgress = Math.Max(existing.LifeProgress,
MathHelper.Clamp(lifeProgress, 0f, 1f)),
PreserveInterior = existing.PreserveInterior || preserveInterior,
UpdateTick = Main.GameUpdateCount
};
return;
}
drawStates[visualInstanceId] = new CrescentDrawState(owner, center, rotation,
radius, opacity, swingDirection < 0 ? -1 : 1,
MathHelper.Clamp(lifeProgress, 0f, 1f), preserveInterior,
Main.GameUpdateCount);
}
public override void PostUpdateEverything()
{
if (drawStates.Count == 0)
return;
List<long>? stale = null;
foreach ((long key, CrescentDrawState state) in drawStates)
{
if (Main.GameUpdateCount <= state.UpdateTick + ResidualLifetimeFrames)
continue;
stale ??= [];
stale.Add(key);
}
if (stale is null)
return;
foreach (long key in stale)
drawStates.Remove(key);
}
public override void OnWorldUnload()
{
drawStates.Clear();
nextVisualInstanceId = 0;
}
public override void Unload()
{
drawStates.Clear();
nextVisualInstanceId = 0;
BasicEffect? oldEffect = effect;
effect = null;
layerVertices = [];
indices = [];
rimVertices = [];
if (oldEffect is not null && !Main.dedServ)
Main.QueueMainThreadAction(oldEffect.Dispose);
}
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;
foreach ((long visualInstanceId, CrescentDrawState state) in drawStates)
{
if (Main.GameUpdateCount > state.UpdateTick + ResidualLifetimeFrames)
continue;
BuildVertices(state);
DrawBackdropLayers(graphicsDevice);
DrawStableRim(graphicsDevice, visualInstanceId, state);
}
}
private static void DrawBackdropLayers(GraphicsDevice graphicsDevice)
{
if (effect is null)
return;
for (int layer = 0; layer < 3; layer++)
{
effect.Texture = DeathDomainBackdropTextureSystem.GetLayer(layer);
VertexPositionColorTexture[] vertices = layerVertices[layer];
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawUserIndexedPrimitives(
PrimitiveType.TriangleList,
vertices,
0,
vertices.Length,
indices,
0,
indices.Length / 3);
}
}
}
private static void BuildVertices(CrescentDrawState state)
{
// The frontier uses the same eased 0..1 clock as the held blade. Revealing
// the whole face at 42% left the weapon floating far behind its hot edge.
float reveal = Smooth01(state.LifeProgress);
float residualProgress = GetResidualProgress(state);
float erosion = Smooth01(residualProgress);
float rotationCos = (float)Math.Cos(state.Rotation);
float rotationSin = (float)Math.Sin(state.Rotation);
int vertexIndex = 0;
for (int segment = 0; segment <= AngularSegments; segment++)
{
float progress = segment / (float)AngularSegments;
float angle = MathHelper.Lerp(-HalfSweep, HalfSweep, progress)
* state.SwingDirection;
float taper = GetCrescentTaper(progress);
// The textured interior and the separately drawn hot blade edge must
// share this exact boundary. Noise belongs inside the material;
// perturbing the outer radius creates a visible air gap at the rim.
float outerRadius = state.Radius * OuterRadiusRatio;
float innerDistortion = GetInnerBoundaryNoise(state, progress,
taper);
float thickness = state.Radius * MaximumThicknessRatio * taper;
float innerRadius = outerRadius - thickness + innerDistortion;
float revealAlpha = 1f - SmoothStep(reveal - 0.024f,
reveal + 0.010f, progress);
float capAlpha = SmoothStep(0f, 0.018f, progress)
* SmoothStep(0f, 0.018f, 1f - progress);
Vector2 radial = angle.ToRotationVector2();
for (int band = 0; band <= DepthBands; band++)
{
float depth = band / (float)DepthBands;
float localRadius = MathHelper.Lerp(outerRadius, innerRadius,
depth);
Vector2 local = radial * localRadius;
Vector2 rotated = new(
local.X * rotationCos - local.Y * rotationSin,
local.X * rotationSin + local.Y * rotationCos);
Vector2 world = state.Center + rotated;
Vector2 screen = world - Main.screenPosition;
float integrity = residualProgress <= 0f || state.PreserveInterior
&& Main.GameUpdateCount <= state.UpdateTick + 1
? 1f
: ReaperCrescentPrimitiveTextureSystem
.SampleDeathInteriorIntegrity(progress, depth, erosion);
// The Death Domain is an opaque world-space window. Only the
// reveal frontier, interior fracture, and terminal dissolve may
// lower alpha; depth never lets the ordinary world bleed through.
float alpha = revealAlpha * capAlpha * integrity;
// Each parallax layer uses the exact framing and scale of the
// Death Necklace. The mask moves, but its domain does not rotate.
for (int layer = 0; layer < 3; layer++)
{
Vector2 uv = DeathDomainBackdropTextureSystem.GetMatchingDomainUv(
layer, world, state.Owner, state.Center);
layerVertices[layer][vertexIndex] =
new VertexPositionColorTexture(
new Vector3(screen, 0f), Color.White * (alpha
* DeathDomainBackdropTextureSystem
.GetLayerOpacity(layer)), uv);
}
vertexIndex++;
}
}
}
private static void DrawStableRim(GraphicsDevice graphicsDevice,
long visualInstanceId, CrescentDrawState state)
{
if (effect is null)
return;
float residualProgress = GetResidualProgress(state);
float opacity = 1f - Smooth01((residualProgress - 0.74f) / 0.26f);
if (opacity <= 0.001f)
return;
DrawRimStrip(graphicsDevice, visualInstanceId, state,
state.Radius * 0.030f,
new Color(238, 5, 60) * (opacity * 0.82f));
DrawRimStrip(graphicsDevice, visualInstanceId, state,
state.Radius * 0.0125f,
new Color(255, 225, 220) * (opacity * 0.96f));
}
private static void DrawRimStrip(GraphicsDevice graphicsDevice,
long visualInstanceId, CrescentDrawState state, float width,
Color color)
{
if (effect is null)
return;
float reveal = Smooth01(state.LifeProgress);
float outerRadius = state.Radius * OuterRadiusRatio;
int vertexIndex = 0;
for (int segment = 0; segment < AngularSegments; segment++)
{
float startProgress = segment / (float)AngularSegments;
float endProgress = (segment + 1f) / AngularSegments;
float middleProgress = (startProgress + endProgress) * 0.5f;
Vector2 overlapProbe = GetCrescentPoint(state, middleProgress,
outerRadius - width * 0.55f);
if (IsCoveredByAnotherCrescent(overlapProbe, visualInstanceId,
width * 0.78f))
continue;
Color startColor = color * GetRimVertexAlpha(startProgress, reveal);
Color endColor = color * GetRimVertexAlpha(endProgress, reveal);
Vector2 outerStart = GetCrescentPoint(state, startProgress,
outerRadius);
Vector2 innerStart = GetCrescentPoint(state, startProgress,
outerRadius - width);
Vector2 outerEnd = GetCrescentPoint(state, endProgress,
outerRadius);
Vector2 innerEnd = GetCrescentPoint(state, endProgress,
outerRadius - width);
WriteRimVertex(ref vertexIndex, outerStart, startColor);
WriteRimVertex(ref vertexIndex, innerStart, startColor);
WriteRimVertex(ref vertexIndex, outerEnd, endColor);
WriteRimVertex(ref vertexIndex, outerEnd, endColor);
WriteRimVertex(ref vertexIndex, innerStart, startColor);
WriteRimVertex(ref vertexIndex, innerEnd, endColor);
}
if (vertexIndex < 3)
return;
effect.TextureEnabled = false;
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawUserPrimitives(PrimitiveType.TriangleList,
rimVertices, 0, vertexIndex / 3);
}
effect.TextureEnabled = true;
}
private static float GetRimVertexAlpha(float progress, float reveal)
{
float revealAlpha = 1f - SmoothStep(reveal - 0.024f,
reveal + 0.010f, progress);
float capAlpha = SmoothStep(0f, 0.018f, progress)
* SmoothStep(0f, 0.018f, 1f - progress);
return revealAlpha * capAlpha;
}
private static Vector2 GetCrescentPoint(CrescentDrawState state,
float progress, float radius)
{
float angle = MathHelper.Lerp(-HalfSweep, HalfSweep, progress)
* state.SwingDirection + state.Rotation;
return state.Center + angle.ToRotationVector2() * radius;
}
private static void WriteRimVertex(ref int vertexIndex, Vector2 world,
Color color)
{
rimVertices[vertexIndex++] = new VertexPositionColorTexture(
new Vector3(world - Main.screenPosition, 0f), color, Vector2.Zero);
}
private static bool IsCoveredByAnotherCrescent(Vector2 world,
long currentVisualInstanceId, float sharedBoundaryTolerance)
{
foreach ((long otherId, CrescentDrawState other) in drawStates)
{
if (otherId == currentVisualInstanceId
|| Main.GameUpdateCount
> other.UpdateTick + ResidualLifetimeFrames)
{
continue;
}
if (ContainsVisibleDomain(other, world,
preferOtherAtSharedBoundary: otherId > currentVisualInstanceId,
sharedBoundaryTolerance))
{
return true;
}
}
return false;
}
private static bool ContainsVisibleDomain(CrescentDrawState state,
Vector2 world, bool preferOtherAtSharedBoundary,
float sharedBoundaryTolerance)
{
Vector2 delta = world - state.Center;
float rotationCos = (float)Math.Cos(state.Rotation);
float rotationSin = (float)Math.Sin(state.Rotation);
Vector2 local = new(
delta.X * rotationCos + delta.Y * rotationSin,
-delta.X * rotationSin + delta.Y * rotationCos);
float radius = local.Length();
float angle = MathHelper.WrapAngle(local.ToRotation());
float progress = state.SwingDirection > 0
? (angle + HalfSweep) / (HalfSweep * 2f)
: (HalfSweep - angle) / (HalfSweep * 2f);
if (progress is < 0f or > 1f
|| progress > Smooth01(state.LifeProgress) + 0.010f)
{
return false;
}
float taper = GetCrescentTaper(progress);
float outerRadius = state.Radius * OuterRadiusRatio;
float innerDistortion = GetInnerBoundaryNoise(state, progress, taper);
float innerRadius = outerRadius
- state.Radius * MaximumThicknessRatio * taper
+ innerDistortion;
if (radius < innerRadius - 1f || radius > outerRadius + 1f)
return false;
// Strictly interior overlap always removes the internal edge. Coincident
// or near-coincident rims use instance order so exactly one copy remains;
// without this tolerance, both ribbons would classify each other's
// inward probe as interior and erase the shared exterior entirely.
float boundaryDepth = outerRadius - radius;
return boundaryDepth > sharedBoundaryTolerance
|| preferOtherAtSharedBoundary;
}
private static float GetCrescentTaper(float progress)
{
float taper = (float)Math.Pow(Math.Max(0f,
Math.Sin(progress * MathHelper.Pi)), 0.67f);
return taper * (1f + 0.22f * (progress * 2f - 1f));
}
private static float GetInnerBoundaryNoise(CrescentDrawState state,
float progress, float taper)
{
// Continuous, layered wave noise keeps the centre-facing edge organic
// without the disconnected saw teeth produced by per-segment randomness.
float seed = state.Rotation * 1.37f
+ state.Center.X * 0.0013f + state.Center.Y * 0.0019f;
float wave = (float)Math.Sin(progress * MathHelper.TwoPi * 5f + seed)
+ (float)Math.Sin(progress * MathHelper.TwoPi * 11f
- seed * 0.71f) * 0.46f
+ (float)Math.Sin(progress * MathHelper.TwoPi * 23f
+ seed * 1.19f) * 0.19f;
return wave * state.Radius * 0.032f * taper;
}
private static float GetResidualProgress(CrescentDrawState state)
{
ulong age = Main.GameUpdateCount > state.UpdateTick
? Main.GameUpdateCount - state.UpdateTick
: 0;
if (age <= 1)
return 0f;
return MathHelper.Clamp((age - 1f) / (ResidualLifetimeFrames - 1f),
0f, 1f);
}
private static short[] CreateIndices()
{
short[] result = new short[AngularSegments * DepthBands * 6];
int index = 0;
int stride = DepthBands + 1;
for (int segment = 0; segment < AngularSegments; segment++)
{
for (int band = 0; band < DepthBands; band++)
{
short topLeft = (short)(segment * stride + band);
short bottomLeft = (short)(topLeft + 1);
short topRight = (short)(topLeft + stride);
short bottomRight = (short)(topRight + 1);
result[index++] = topLeft;
result[index++] = bottomLeft;
result[index++] = topRight;
result[index++] = topRight;
result[index++] = bottomLeft;
result[index++] = bottomRight;
}
}
return result;
}
private static float SmoothStep(float start, float end, float value)
{
if (end <= start)
return value >= end ? 1f : 0f;
return Smooth01((value - start) / (end - start));
}
private static float Smooth01(float value)
{
value = MathHelper.Clamp(value, 0f, 1f);
return value * value * (3f - 2f * value);
}
}
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
using System.Collections.Generic;
using Terraria;
using Terraria.ModLoader;
namespace SoulHarvest.Common;
/// <summary>
/// Draws the Death scythe's domain-filled face as geometry rather than as one
/// rotating sprite. The crescent mask follows the weapon, while its texture
/// coordinates stay anchored to world space; rapid swings therefore reveal a
/// stable window into the Death Necklace domain instead of rotating the scene.
/// </summary>
[Autoload(Side = ModSide.Client)]
internal sealed class DeathDomainCrescentVisualSystem : ModSystem
{
private const int AngularSegments = 128;
private const int DepthBands = 18;
private const int ResidualLifetimeFrames = 30;
private const float HalfSweep = ReaperCombatRegistry.DeathPrimaryHalfSweep;
private const float OuterRadiusRatio = 0.91f;
// The outside blade edge stays on the weapon tip. Additional size grows only
// toward the wielder, as requested, instead of increasing attack reach.
private const float MaximumThicknessRatio = 0.585f;
private static readonly Dictionary<long, CrescentDrawState> drawStates = [];
private static long nextVisualInstanceId;
private static BasicEffect? effect;
private static VertexPositionColorTexture[][] layerVertices =
[
new VertexPositionColorTexture[(AngularSegments + 1) * (DepthBands + 1)],
new VertexPositionColorTexture[(AngularSegments + 1) * (DepthBands + 1)],
new VertexPositionColorTexture[(AngularSegments + 1) * (DepthBands + 1)]
];
private static short[] indices = CreateIndices();
private static VertexPositionColorTexture[] rimVertices =
new VertexPositionColorTexture[AngularSegments * 6];
private readonly record struct CrescentDrawState(
int Owner,
Vector2 Center,
float Rotation,
float Radius,
float Opacity,
int SwingDirection,
float LifeProgress,
bool PreserveInterior,
ulong UpdateTick);
/// <summary>
/// Allocates a client-local identity for one physical swing. Projectile
/// identities and slots can be reused before a residual crescent expires at
/// maximum attack speed, so neither is safe as the draw-state key.
/// </summary>
internal static long AllocateVisualInstanceId()
{
nextVisualInstanceId++;
if (nextVisualInstanceId <= 0)
nextVisualInstanceId = 1;
return nextVisualInstanceId;
}
internal static void Record(long visualInstanceId, int owner,
Vector2 center,
float rotation, float radius, float opacity, int swingDirection,
float lifeProgress, bool preserveInterior = false)
{
if (Main.dedServ || visualInstanceId <= 0 || radius <= 1f
|| opacity <= 0.001f)
return;
if (drawStates.TryGetValue(visualInstanceId,
out CrescentDrawState existing))
{
// A slash is a cut made in world space, not an aura attached to the
// player. Keep its first center, orientation and size immutable;
// subsequent frames only advance the reveal frontier and refresh its
// residual clock. Already drawn material therefore stays exactly
// where the blade passed even if the player moves during the swing.
drawStates[visualInstanceId] = existing with
{
Opacity = opacity,
LifeProgress = Math.Max(existing.LifeProgress,
MathHelper.Clamp(lifeProgress, 0f, 1f)),
PreserveInterior = existing.PreserveInterior || preserveInterior,
UpdateTick = Main.GameUpdateCount
};
return;
}
drawStates[visualInstanceId] = new CrescentDrawState(owner, center, rotation,
radius, opacity, swingDirection < 0 ? -1 : 1,
MathHelper.Clamp(lifeProgress, 0f, 1f), preserveInterior,
Main.GameUpdateCount);
}
public override void PostUpdateEverything()
{
if (drawStates.Count == 0)
return;
List<long>? stale = null;
foreach ((long key, CrescentDrawState state) in drawStates)
{
if (Main.GameUpdateCount <= state.UpdateTick + ResidualLifetimeFrames)
continue;
stale ??= [];
stale.Add(key);
}
if (stale is null)
return;
foreach (long key in stale)
drawStates.Remove(key);
}
public override void OnWorldUnload()
{
drawStates.Clear();
nextVisualInstanceId = 0;
}
public override void Unload()
{
drawStates.Clear();
nextVisualInstanceId = 0;
BasicEffect? oldEffect = effect;
effect = null;
layerVertices = [];
indices = [];
rimVertices = [];
if (oldEffect is not null && !Main.dedServ)
Main.QueueMainThreadAction(oldEffect.Dispose);
}
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;
foreach ((long visualInstanceId, CrescentDrawState state) in drawStates)
{
if (Main.GameUpdateCount > state.UpdateTick + ResidualLifetimeFrames)
continue;
BuildVertices(state);
DrawBackdropLayers(graphicsDevice);
DrawStableRim(graphicsDevice, visualInstanceId, state);
}
}
private static void DrawBackdropLayers(GraphicsDevice graphicsDevice)
{
if (effect is null)
return;
for (int layer = 0; layer < 3; layer++)
{
effect.Texture = DeathDomainBackdropTextureSystem.GetLayer(layer);
VertexPositionColorTexture[] vertices = layerVertices[layer];
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawUserIndexedPrimitives(
PrimitiveType.TriangleList,
vertices,
0,
vertices.Length,
indices,
0,
indices.Length / 3);
}
}
}
private static void BuildVertices(CrescentDrawState state)
{
// The frontier uses the same eased 0..1 clock as the held blade. Revealing
// the whole face at 42% left the weapon floating far behind its hot edge.
float reveal = Smooth01(state.LifeProgress);
float residualProgress = GetResidualProgress(state);
float erosion = Smooth01(residualProgress);
float rotationCos = (float)Math.Cos(state.Rotation);
float rotationSin = (float)Math.Sin(state.Rotation);
int vertexIndex = 0;
for (int segment = 0; segment <= AngularSegments; segment++)
{
float progress = segment / (float)AngularSegments;
float angle = MathHelper.Lerp(-HalfSweep, HalfSweep, progress)
* state.SwingDirection;
float taper = GetCrescentTaper(progress);
// The textured interior and the separately drawn hot blade edge must
// share this exact boundary. Noise belongs inside the material;
// perturbing the outer radius creates a visible air gap at the rim.
float outerRadius = state.Radius * OuterRadiusRatio;
float innerDistortion = GetInnerBoundaryNoise(state, progress,
taper);
float thickness = state.Radius * MaximumThicknessRatio * taper;
float innerRadius = outerRadius - thickness + innerDistortion;
float revealAlpha = 1f - SmoothStep(reveal - 0.024f,
reveal + 0.010f, progress);
float capAlpha = SmoothStep(0f, 0.018f, progress)
* SmoothStep(0f, 0.018f, 1f - progress);
Vector2 radial = angle.ToRotationVector2();
for (int band = 0; band <= DepthBands; band++)
{
float depth = band / (float)DepthBands;
float localRadius = MathHelper.Lerp(outerRadius, innerRadius,
depth);
Vector2 local = radial * localRadius;
Vector2 rotated = new(
local.X * rotationCos - local.Y * rotationSin,
local.X * rotationSin + local.Y * rotationCos);
Vector2 world = state.Center + rotated;
Vector2 screen = world - Main.screenPosition;
float integrity = residualProgress <= 0f || state.PreserveInterior
&& Main.GameUpdateCount <= state.UpdateTick + 1
? 1f
: ReaperCrescentPrimitiveTextureSystem
.SampleDeathInteriorIntegrity(progress, depth, erosion);
// The Death Domain is an opaque world-space window. Only the
// reveal frontier, interior fracture, and terminal dissolve may
// lower alpha; depth never lets the ordinary world bleed through.
float alpha = revealAlpha * capAlpha * integrity;
// Each parallax layer uses the exact framing and scale of the
// Death Necklace. The mask moves, but its domain does not rotate.
for (int layer = 0; layer < 3; layer++)
{
Vector2 uv = DeathDomainBackdropTextureSystem.GetMatchingDomainUv(
layer, world, state.Owner, state.Center);
layerVertices[layer][vertexIndex] =
new VertexPositionColorTexture(
new Vector3(screen, 0f), Color.White * (alpha
* DeathDomainBackdropTextureSystem
.GetLayerOpacity(layer)), uv);
}
vertexIndex++;
}
}
}
private static void DrawStableRim(GraphicsDevice graphicsDevice,
long visualInstanceId, CrescentDrawState state)
{
if (effect is null)
return;
float residualProgress = GetResidualProgress(state);
float opacity = 1f - Smooth01((residualProgress - 0.74f) / 0.26f);
if (opacity <= 0.001f)
return;
DrawRimStrip(graphicsDevice, visualInstanceId, state,
state.Radius * 0.030f,
new Color(238, 5, 60) * (opacity * 0.82f));
DrawRimStrip(graphicsDevice, visualInstanceId, state,
state.Radius * 0.0125f,
new Color(255, 225, 220) * (opacity * 0.96f));
}
private static void DrawRimStrip(GraphicsDevice graphicsDevice,
long visualInstanceId, CrescentDrawState state, float width,
Color color)
{
if (effect is null)
return;
float reveal = Smooth01(state.LifeProgress);
float outerRadius = state.Radius * OuterRadiusRatio;
int vertexIndex = 0;
for (int segment = 0; segment < AngularSegments; segment++)
{
float startProgress = segment / (float)AngularSegments;
float endProgress = (segment + 1f) / AngularSegments;
float middleProgress = (startProgress + endProgress) * 0.5f;
Vector2 overlapProbe = GetCrescentPoint(state, middleProgress,
outerRadius - width * 0.55f);
if (IsCoveredByAnotherCrescent(overlapProbe, visualInstanceId,
width * 0.78f))
continue;
Color startColor = color * GetRimVertexAlpha(startProgress, reveal);
Color endColor = color * GetRimVertexAlpha(endProgress, reveal);
Vector2 outerStart = GetCrescentPoint(state, startProgress,
outerRadius);
Vector2 innerStart = GetCrescentPoint(state, startProgress,
outerRadius - width);
Vector2 outerEnd = GetCrescentPoint(state, endProgress,
outerRadius);
Vector2 innerEnd = GetCrescentPoint(state, endProgress,
outerRadius - width);
WriteRimVertex(ref vertexIndex, outerStart, startColor);
WriteRimVertex(ref vertexIndex, innerStart, startColor);
WriteRimVertex(ref vertexIndex, outerEnd, endColor);
WriteRimVertex(ref vertexIndex, outerEnd, endColor);
WriteRimVertex(ref vertexIndex, innerStart, startColor);
WriteRimVertex(ref vertexIndex, innerEnd, endColor);
}
if (vertexIndex < 3)
return;
effect.TextureEnabled = false;
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawUserPrimitives(PrimitiveType.TriangleList,
rimVertices, 0, vertexIndex / 3);
}
effect.TextureEnabled = true;
}
private static float GetRimVertexAlpha(float progress, float reveal)
{
float revealAlpha = 1f - SmoothStep(reveal - 0.024f,
reveal + 0.010f, progress);
float capAlpha = SmoothStep(0f, 0.018f, progress)
* SmoothStep(0f, 0.018f, 1f - progress);
return revealAlpha * capAlpha;
}
private static Vector2 GetCrescentPoint(CrescentDrawState state,
float progress, float radius)
{
float angle = MathHelper.Lerp(-HalfSweep, HalfSweep, progress)
* state.SwingDirection + state.Rotation;
return state.Center + angle.ToRotationVector2() * radius;
}
private static void WriteRimVertex(ref int vertexIndex, Vector2 world,
Color color)
{
rimVertices[vertexIndex++] = new VertexPositionColorTexture(
new Vector3(world - Main.screenPosition, 0f), color, Vector2.Zero);
}
private static bool IsCoveredByAnotherCrescent(Vector2 world,
long currentVisualInstanceId, float sharedBoundaryTolerance)
{
foreach ((long otherId, CrescentDrawState other) in drawStates)
{
if (otherId == currentVisualInstanceId
|| Main.GameUpdateCount
> other.UpdateTick + ResidualLifetimeFrames)
{
continue;
}
if (ContainsVisibleDomain(other, world,
preferOtherAtSharedBoundary: otherId > currentVisualInstanceId,
sharedBoundaryTolerance))
{
return true;
}
}
return false;
}
private static bool ContainsVisibleDomain(CrescentDrawState state,
Vector2 world, bool preferOtherAtSharedBoundary,
float sharedBoundaryTolerance)
{
Vector2 delta = world - state.Center;
float rotationCos = (float)Math.Cos(state.Rotation);
float rotationSin = (float)Math.Sin(state.Rotation);
Vector2 local = new(
delta.X * rotationCos + delta.Y * rotationSin,
-delta.X * rotationSin + delta.Y * rotationCos);
float radius = local.Length();
float angle = MathHelper.WrapAngle(local.ToRotation());
float progress = state.SwingDirection > 0
? (angle + HalfSweep) / (HalfSweep * 2f)
: (HalfSweep - angle) / (HalfSweep * 2f);
if (progress is < 0f or > 1f
|| progress > Smooth01(state.LifeProgress) + 0.010f)
{
return false;
}
float taper = GetCrescentTaper(progress);
float outerRadius = state.Radius * OuterRadiusRatio;
float innerDistortion = GetInnerBoundaryNoise(state, progress, taper);
float innerRadius = outerRadius
- state.Radius * MaximumThicknessRatio * taper
+ innerDistortion;
if (radius < innerRadius - 1f || radius > outerRadius + 1f)
return false;
// Strictly interior overlap always removes the internal edge. Coincident
// or near-coincident rims use instance order so exactly one copy remains;
// without this tolerance, both ribbons would classify each other's
// inward probe as interior and erase the shared exterior entirely.
float boundaryDepth = outerRadius - radius;
return boundaryDepth > sharedBoundaryTolerance
|| preferOtherAtSharedBoundary;
}
private static float GetCrescentTaper(float progress)
{
float taper = (float)Math.Pow(Math.Max(0f,
Math.Sin(progress * MathHelper.Pi)), 0.67f);
return taper * (1f + 0.22f * (progress * 2f - 1f));
}
private static float GetInnerBoundaryNoise(CrescentDrawState state,
float progress, float taper)
{
// Continuous, layered wave noise keeps the centre-facing edge organic
// without the disconnected saw teeth produced by per-segment randomness.
float seed = state.Rotation * 1.37f
+ state.Center.X * 0.0013f + state.Center.Y * 0.0019f;
float wave = (float)Math.Sin(progress * MathHelper.TwoPi * 5f + seed)
+ (float)Math.Sin(progress * MathHelper.TwoPi * 11f
- seed * 0.71f) * 0.46f
+ (float)Math.Sin(progress * MathHelper.TwoPi * 23f
+ seed * 1.19f) * 0.19f;
return wave * state.Radius * 0.032f * taper;
}
private static float GetResidualProgress(CrescentDrawState state)
{
ulong age = Main.GameUpdateCount > state.UpdateTick
? Main.GameUpdateCount - state.UpdateTick
: 0;
if (age <= 1)
return 0f;
return MathHelper.Clamp((age - 1f) / (ResidualLifetimeFrames - 1f),
0f, 1f);
}
private static short[] CreateIndices()
{
short[] result = new short[AngularSegments * DepthBands * 6];
int index = 0;
int stride = DepthBands + 1;
for (int segment = 0; segment < AngularSegments; segment++)
{
for (int band = 0; band < DepthBands; band++)
{
short topLeft = (short)(segment * stride + band);
short bottomLeft = (short)(topLeft + 1);
short topRight = (short)(topLeft + stride);
short bottomRight = (short)(topRight + 1);
result[index++] = topLeft;
result[index++] = bottomLeft;
result[index++] = topRight;
result[index++] = topRight;
result[index++] = bottomLeft;
result[index++] = bottomRight;
}
}
return result;
}
private static float SmoothStep(float start, float end, float value)
{
if (end <= start)
return value >= end ? 1f : 0f;
return Smooth01((value - start) / (end - start));
}
private static float Smooth01(float value)
{
value = MathHelper.Clamp(value, 0f, 1f);
return value * value * (3f - 2f * value);
}
}