Code/EmittersShapes/ConchplexBoxEmitter.cs

A particle emitter component that spawns particles inside or on the faces of an axis-aligned box. It supports spawn bias toward edges or center, positional and axis biases, thickness (inner box), gizmo drawing for editor visualization, and emits particles with optional velocity derived from the box face normal.

Native Interop
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

namespace ConchplexEmitters;

/// <summary>
/// Emits particles within a box min/max bounds
/// </summary>
[Title("Conchplex Box Emitter"), Category("Particles"), Icon("border_outer")]
public sealed class ConchplexBoxEmitter : ConchplexEmitter
{
	// Emitter shape


	/// <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;

	[Property, Group("Spawn Bias"), Order(0)]
	[Range(-1, 1)]
	public Vector3 AxisBias { get; set; } = 0f;
	/// <summary>
	/// Particles only spawn on the axes marked
	/// </summary>
	[Property, Group("Spawn Bias"), Order(0)]
	public AxisClampFlags AxisClamp { get; set; }

	[Property]
	public Vector3 Size { get; set; } = 50f;

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

	[Property, Group("Initial Velocity")]
	public float VelocityFromSquare { get; set; } = 0f;


	protected override void DrawGizmos()
	{
		if (Gizmo.IsSelected)
		{
			var point = Vector3.Zero;
			Gizmo.Draw.Color = Color.White.WithAlpha(0.5f);
			Gizmo.GizmoDraw draw = Gizmo.Draw;

			// Draw max bounds
			BBox box = BBox.FromPositionAndSize(point, Size);
			draw.LineBBox(box);

			// Draw min bounds
			BBox boxMin = BBox.FromPositionAndSize(point, Size * Thickness);
			draw.LineBBox(boxMin);

			// Draw Distance Bias
			Gizmo.Draw.Color = Color.Red.WithAlpha(0.5f);
			float biasX = MathX.Lerp(Size.x, Size.x * Thickness, 1 - DistanceBias);
			float biasY = MathX.Lerp(Size.y, Size.y * Thickness, 1 - DistanceBias);
			float biasZ = MathX.Lerp(Size.z, Size.z * Thickness, 1 - DistanceBias);
			BBox biasBox = BBox.FromPositionAndSize(point, new Vector3(biasX, biasY, biasZ) + point);
			draw.LineBBox(biasBox);

			if (PositionBias.IsNearlyZero()) return;
			Gizmo.Draw.Color = Color.Red.WithAlpha(0.8f);
			Vector3 biasClamped = Vector3.Clamp(PositionBias * 300f, new Vector3(-Size * 0.5f), new Vector3(Size * 0.5f));
			draw.Arrow(point, biasClamped, biasClamped.Length * 0.1f, biasClamped.Length * 0.1f);
		}
	}

	public override Particle Emit(ParticleEffect target)
	{
		Vector3 random = Random.Shared.VectorInCube(0.5f);
		Vector3 sizeMin = Size * Thickness;
		Vector3 sizeMax = Size;
		Vector3 targetSize = Vector3.Lerp(sizeMin, sizeMax, DistanceBias);
		Vector3 surfaceDirrection = Vector3.Zero;

		// POSITION BIAS
		var posBias = Vector3.Clamp(PositionBias, new Vector3(-1f), new Vector3(1f));
		float biasStrength = posBias.Length;
		if (biasStrength > 0f)
		{
			float randomFlipChance = Random.Shared.Float();
			if (MathF.Abs(posBias.x) > randomFlipChance && MathF.Sign(posBias.x) != MathF.Sign(random.x))
				random.x = -random.x;
			if (MathF.Abs(posBias.y) > randomFlipChance && MathF.Sign(posBias.y) != MathF.Sign(random.y))
				random.y = -random.y;
			if (MathF.Abs(posBias.z) > randomFlipChance && MathF.Sign(posBias.z) != MathF.Sign(random.z))
				random.z = -random.z;

			random = Vector3.Lerp(random, random + posBias * 0.5f, biasStrength);
		}

		// Figure out Min / Max Distance bias
		var maxEdgePos = RandomPositionOnBoxFace(random, Size, posBias, out surfaceDirrection);
		var minEdgePos = maxEdgePos * Thickness;
		// lol this is really stupid and nasty but it works!
		var fuckedUpRandom = Random.Shared.Float();
		var randomBetweenPos = Vector3.Lerp(maxEdgePos, minEdgePos, fuckedUpRandom * fuckedUpRandom * fuckedUpRandom);
		// Apply Distance Bias

		if (DistanceBias <= 0.5f)
		{
			float scaledBias = DistanceBias / 0.5f;
			random = Vector3.Lerp(minEdgePos, randomBetweenPos, scaledBias);
		}
		else
		{
			float scaledBias = (DistanceBias - 0.5f) / 0.5f;
			random = Vector3.Lerp(randomBetweenPos, maxEdgePos, scaledBias);
		}

		var clampedParticlePoint = ProcessClampPosition(random, surfaceDirrection, AxisClamp);
		var pos = (clampedParticlePoint.Position * LocalScale * WorldRotation) + WorldPosition;
		var velocity = clampedParticlePoint.Velocity * LocalRotation;

		Particle particle = target.Emit(pos, base.Delta);
		particle.Velocity += velocity * VelocityFromSquare;

		return particle;
	}

	// this ensure particles are placed evenly on faces + corrects any overshooting
	Vector3 RandomPositionOnBoxFace(Vector3 randomPos, Vector3 boxSize, Vector3 positionBias, out Vector3 surfaceDirrection)
	{
		// Scale the random position to the box size
		randomPos = Vector3.Clamp(randomPos, new Vector3(-0.5f), new Vector3(0.5f));

		var scaledPos = randomPos * boxSize;
		// Determine valid faces for selection
		List<(int axis, bool positiveSide)> validFaces = new List<(int, bool)>();

		for (int i = 0; i < 3; i++) // Iterate over X, Y, Z axes
		{
			if (positionBias[i] >= 0) // Allow positive face if bias is >= 0
				validFaces.Add((i, true));
			if (positionBias[i] <= 0) // Allow negative face if bias is <= 0
				validFaces.Add((i, false));
		}

		if (validFaces.Count == 0)
			throw new InvalidOperationException("No valid faces available due to position bias.");

		// Randomly select a valid face
		var selectedFace = validFaces[Random.Shared.Next(validFaces.Count)];
		int fixedAxis = selectedFace.axis;
		bool positiveSide = selectedFace.positiveSide;

		// Push the position to the selected face
		scaledPos[fixedAxis] = positiveSide ? (boxSize[fixedAxis] * 0.5f) : -(boxSize[fixedAxis] * 0.5f);
		surfaceDirrection = Vector3.Zero;
		surfaceDirrection[fixedAxis] = positiveSide ? 1f : -1f;

		return scaledPos;
	}
}