EmittersShapes/ConchplexCylinderEmitter.cs

A particle emitter component that spawns particles inside a cylinder or disk shape. It computes spawn positions with configurable outer radius, thickness modes (Hollow or hole), height, distance and position bias, applies axis clamping and optionally gives initial velocity from the cylinder surface, and draws editor gizmos.

Rough CodeReflection
🐞 Clamp calls invert min and max in normal flow, for example random.z = random.z.Clamp(0.5f, -0.5f) and clamping x/y against possibly negative randomXYNormal, so components collapse to wrong bounds during emission.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static Sandbox.VertexLayout;

namespace ConchplexEmitters;



/// <summary>
/// Emits particles within a cylinder / disk
/// </summary>
[Title( "Conchplex Cylinder Emitter" ), Category( "Particles" ), Icon( "donut_large" )]
public sealed class ConchplexCylinderEmitter : ConchplexEmitter
{

	/// <summary>
	/// 1 = spawns on edge, 0 = spawns in centre
	/// </summary>
	[Property, Group("Spawn Bias"), Order(0)]
	[Range(0f, 1f), Step(0.01f)]
	public float DistanceBias { get; set; } = 0.5f;

	/// <summary>
	/// Direction + intensity to bias particles to spawn towards
	/// </summary>
	[Property, Group("Spawn Bias"), Order(0)]
	[Range(-1, 1)]
	public Vector3 PositionBias { get; set; } = 0f;

	/// <summary>
	/// Particles only spawn on the axes marked
	/// </summary>
	[Property, Group("Spawn Bias"), Order(0)]
	public AxisClampFlags AxisClamp { get; set; }
	/// <summary>
	/// Which axis to clamp to / in between
	/// </summary>
	public enum EmitFaces
	{
		Both,
		Sides,
	}

	public enum ThicknessMode
	{
		Hollow,
		hole
	}

	[Property]
	public float OutterRadius { get; set; } = 25f;

	[Property]
	[Range(0, 1)]
	public float Thickness { get; set; } = 0;

	[Property]
	public float height { get; set; } = 50f;

	[Property]
	public ThicknessMode ThickMode { get; set; }


	[Property, Group( "Initial Velocity" )]
	public float VelocityFromCylinder = 0f;

	private float ThickRadius => MathX.Lerp(0, 1, Thickness);
	private float MidRadius => Thickness > 0 ? (ThickRadius + 1) * 0.5f : 0f;
	private float MidHeight => (1 + Thickness) * 0.5f;

	protected override void DrawGizmos()
	{
		// TODO - THIS IS FUCKED 

		if (!Gizmo.IsSelected) return;
		
		Gizmo.Draw.Color = Color.White.WithAlpha(0.5f);
		Gizmo.GizmoDraw draw = Gizmo.Draw;

		// draw outter ring
		var top = Vector3.Zero.WithZ(height * 0.5f);
		var bottom = top.WithZ(-top.z);
		draw.LineCylinder(bottom, top, OutterRadius, OutterRadius, 12);

		var sThick = ThickRadius * OutterRadius;
		var sMid = MidRadius * OutterRadius;
		var sThickHeight = MidHeight * height; 

	
		// draw thickness
		if( Thickness != 0 )
		{
			var thickTop = ThickMode == ThicknessMode.Hollow ? top * Thickness : top;
			draw.LineCylinder(thickTop.WithZ(-thickTop.z), thickTop, sThick, sThick, 12);
		}


		// draw distance bias
		if( DistanceBias != 0 )
		{
			draw.Color = Color.Red.WithAlpha(0.5f);
			var biasHeight = MathX.Lerp(sThickHeight * 0.5f, top.z, DistanceBias);
			var thickBiasHeight = ThickMode == ThicknessMode.Hollow ? top.z * Thickness : top.z;
			var fucksake = MathX.Lerp(sThickHeight * 0.5f, thickBiasHeight, DistanceBias);
			var biasOutterRadius = MathX.Lerp(sMid, OutterRadius, DistanceBias);
			var biasInnerRadius = MathX.Lerp(sMid, sThick, DistanceBias);

			if(ThickMode == ThicknessMode.Hollow)
			{
				var biasHollowRadius = MathX.Lerp(sThick, OutterRadius, DistanceBias);
				var biasHollowHeight = MathX.Lerp(top.z * Thickness, top.z, DistanceBias);
				draw.LineCylinder(bottom.WithZ(-biasHollowHeight), top.WithZ(biasHollowHeight), biasHollowRadius, biasHollowRadius, 12);
			}
			else
			{
				draw.LineCylinder(bottom.WithZ(-biasHeight), top.WithZ(biasHeight), biasOutterRadius, biasOutterRadius, 12);
				draw.LineCylinder(bottom.WithZ(-fucksake), top.WithZ(fucksake), biasInnerRadius, biasInnerRadius, 12);
			}
				
		}

		// draw Position Bias
		if (PositionBias.IsNearlyZero()) return;
		Gizmo.Draw.Color = Color.Red.WithAlpha(0.8f);
		Vector3 biasClamped = Vector3.Clamp(PositionBias, new Vector3(-1f), new Vector3(1f)) * (OutterRadius) * 1.1f;
		draw.Arrow(Vector3.Zero, biasClamped, biasClamped.Length * 0.1f, biasClamped.Length * 0.1f);
		

	}

	public override Particle Emit(ParticleEffect target )
	{

		// Random point in a unity cylinder
		Vector3 random = Random.Shared.VectorInCircle(1f);
		random.z = Random.Shared.Float(-0.5f, 0.5f);

		// POSITION BIAS
		var positionBias = PositionBias.Clamp(-1, 1);
		random = random + positionBias;
		// Correct bounds
		var randomXY = new Vector2(random.x, random.y);
		Vector2 randomXYNormal = randomXY.Normal;
		random.x = random.x.Clamp(0f, randomXYNormal.x);
		random.y = random.y.Clamp(0f, randomXYNormal.y);
		random.z = random.z.Clamp(0.5f,-0.5f);
		randomXY = new Vector2(random.x, random.y);

		// get probabilty if a particle should be shoved to sides or top
		var outterTopBottom = 2 * (OutterRadius* OutterRadius);
		var innerTopBottom = 2 * ((OutterRadius * Thickness) * (OutterRadius * Thickness));
		outterTopBottom = positionBias.z != 0f ? outterTopBottom * 0.5f : outterTopBottom;
		innerTopBottom = positionBias.z != 0f ? innerTopBottom * 0.5f : innerTopBottom;

		var outterSides = (height * OutterRadius);
		var innerSides = (height * (Thickness * OutterRadius));

		var totalArea = outterTopBottom + outterSides + innerTopBottom + innerSides;
		var totalTopBottomArea = outterTopBottom + innerTopBottom;
		var sidesProbability = (outterSides + innerSides) / totalArea;
		var innerChance = (innerTopBottom + innerSides) / totalArea;



		// TODO
		/*
			- get all points pushed to cylinder outter edges
			- get all points pushed to cylinder inner thickness edges
			- get all points pushed into the "middle"

			- new Random
				- lerp between Outter edge / Inner edge, based on old random.length
			- Max pos
				- if pointsXY & or pointsZ are greater than the mid bounds
					TRUE - use outter edge
					FALSE - use innter edge
			- Min pos
				- use middle points
		*/

		// Prepare min/max bounds for hollow/hole modes
		Vector3 randomHollowMin = random, randomHollowMax = random;
		Vector2 minHollowRandomXY = randomXY, maxHollowRandomXY = randomXY;
		Vector3 randomHoleMax = random;
		Vector2 maxHoleRandomXY = randomXY;

		float randVal = Random.Shared.Float();

		// Only calculate min/max for the current mode
		if (ThickMode == ThicknessMode.Hollow)
		{
			if (randVal > sidesProbability)
			{
				// Top/bottom face
				bool isTop = random.z >= 0;
				if (positionBias.z > 0f) isTop = true;
				else if (positionBias.z < 0f) isTop = false;
				float zPos = isTop ? 0.5f : -0.5f;
				randomHollowMin *= Thickness;
				randomHollowMin.z = zPos * Thickness;
				randomHollowMax.z = zPos;
			}
			else
			{
				// Sides
				minHollowRandomXY = minHollowRandomXY.Normal * Thickness;
				randomHollowMin.x = minHollowRandomXY.x;
				randomHollowMin.y = minHollowRandomXY.y;
				randomHollowMin.z *= Thickness;
				maxHollowRandomXY = maxHollowRandomXY.Normal;
				randomHollowMax.x = maxHollowRandomXY.x;
				randomHollowMax.y = maxHollowRandomXY.y;
			}
		}
		else // Hole mode
		{
			if (randVal > sidesProbability)
			{
				bool isTop = random.z >= 0;
				if (positionBias.z > 0f) isTop = true;
				else if (positionBias.z < 0f) isTop = false;
				float zPos = isTop ? 0.5f : -0.5f;
				randomHoleMax.z = zPos;
				// Remap for hole
				randomHoleMax.x = MathX.Remap(randomHoleMax.x, 0f, maxHoleRandomXY.Normal.x, maxHoleRandomXY.Normal.x * Thickness, maxHoleRandomXY.Normal.x);
				randomHoleMax.y = MathX.Remap(randomHoleMax.y, 0f, maxHoleRandomXY.Normal.y, maxHoleRandomXY.Normal.y * Thickness, maxHoleRandomXY.Normal.y);
			}
			else
			{
				float innerSideChance = innerSides / totalArea;
				maxHoleRandomXY = Random.Shared.Float() < innerSideChance ? maxHoleRandomXY.Normal * Thickness : maxHoleRandomXY.Normal;
				randomHoleMax.x = maxHoleRandomXY.x;
				randomHoleMax.y = maxHoleRandomXY.y;
			}
		}

		// Interpolate between min/max for hollow/hole
		if (ThickMode == ThicknessMode.Hollow)
		{
			float bias = 1 - MathF.Pow(randVal, (Thickness + 1) * 10);
			random = Vector3.Lerp(randomHollowMin, randomHollowMax, bias);
		}
		else
		{
			random.x = MathX.Remap(random.x, 0, randomXYNormal.x, randomXYNormal.x * Thickness, randomXYNormal.x);
			random.y = MathX.Remap(random.y, 0, randomXYNormal.y, randomXYNormal.y * Thickness, randomXYNormal.y);
		}

		// Midpoint for distance bias
		Vector3 randomMid = random;
		randomMid.z *= MidHeight;
		randomXY = randomXYNormal * MidRadius;
		randomMid.x = randomXY.x;
		randomMid.y = randomXY.y;

		// Distance bias
		if (DistanceBias <= 0.5f)
		{
			float scaledBias = DistanceBias / 0.5f;
			var lowBias = ThickMode == ThicknessMode.Hollow ? randomHollowMin : randomMid;
			random = Vector3.Lerp(lowBias, random, scaledBias);
		}
		else
		{
			var maxRandom = ThickMode == ThicknessMode.Hollow ? randomHollowMax : randomHoleMax;
			float scaledBias = (DistanceBias - 0.5f) / 0.5f;
			random = Vector3.Lerp(random, maxRandom, scaledBias);
		}

		// Scale to world
		random.x *= OutterRadius;
		random.y *= OutterRadius;
		random.z *= height;

		var cylinderVelocity = random.WithZ(0f).Normal;

		var clampedParticlePoint = ProcessClampPosition(random, cylinderVelocity, AxisClamp);
		Vector3 pos = (clampedParticlePoint.Position * LocalScale * WorldRotation) + WorldPosition;
		
		Particle particle = target.Emit(pos, base.Delta);
		particle.Velocity += (clampedParticlePoint.Velocity * WorldRotation) * VelocityFromCylinder;

		return particle;
	}
}