Skip to main content

Pooled Particles

Nebulon's particle API provides Lodestone-style configuration without a Lodestone dependency and without placing every particle in the persistent effect manager. A reusable, immutable ParticleSpec describes appearance and physics; lightweight ParticleSpawn values provide position, velocity, delay, and rotation per particle.

Simulation is client-local. Servers send one semantic event or emitter description, and every client generates identical particles from the same seed — individual particles never cross the network. See Server synchronization for the server API.


Building a spec

ParticleSpec wisp = NebulonParticles.builder(
Identifier.of("my_mod", "textures/effect/wisp.png"))
.blend(ParticleSpec.Blend.ADDITIVE)
.facing(ParticleSpec.Facing.CAMERA)
.lifetime(40)
.scale(ScalarCurve.three(0.0f, 0.6f, 0.0f, 0.3f, Easing.SINE_OUT, Easing.SINE_IN))
.alpha(ScalarCurve.between(1.0f, 0.0f, Easing.QUAD_IN))
.color(new ColorGradient(
ColorRgba.rgba(0x55ddffff), ColorRgba.rgba(0xaa55ffff), Easing.SINE_IN_OUT))
.rotation(ScalarCurve.linear(0, (float) (Math.PI * 2)))
.gravity(-0.0015f)
.drag(0.97f)
.softness(0.3f)
.build();
Builder methodDefaultMeaning
blend(Blend)ALPHAALPHA or ADDITIVE
facing(Facing)CAMERACamera-facing or Y-axis-facing
flipbook(cols, rows)noneRow-major sheet, advanced once over the lifetime
lifetime(ticks)20Particle lifetime
scale(...)1Constant, (start, end, easing), or a full ScalarCurve
aspectRatio(w/h)1Non-square particles
color(...)whiteConstant, (start, end, easing), or a ColorGradient
alpha(...)1(start, end, easing) or a ScalarCurve
rotation(ScalarCurve)0Roll over lifetime, radians
gravity(float)0Per-tick vertical acceleration (negative = upward drift)
acceleration(Vec3d)zeroConstant acceleration in world axes
drag(float)1Per-tick velocity multiplier
forceChannel(int)1Bitmask matched against attractors
collision(ParticleCollision)noneOpt-in block collision
softness(blocks)0Scene-depth fading near geometry; 0 keeps the cheaper hard path
maxRenderDistance(blocks)128Cull distance

Textures use ordinary Minecraft resource identifiers including textures/ and the extension.

Curves, easings, and gradients

ScalarCurve accepts up to 16 normalized keyframes; the easing stored on a key controls its segment to the next key. Convenience constructors:

ScalarCurve.constant(v);
ScalarCurve.linear(start, end);
ScalarCurve.between(start, end, easing);
ScalarCurve.three(start, middle, end, midpoint, easeIn, easeOut);
ScalarCurve.pulse(peak); // grow-then-shrink envelope

Available Easing values: LINEAR, SINE_IN, SINE_OUT, SINE_IN_OUT, QUAD_IN, QUAD_OUT, QUAD_IN_OUT, CUBIC_IN, CUBIC_OUT, CUBIC_IN_OUT, EXPO_OUT.

ColorGradient interpolates between two ColorRgba values with an easing, or holds a constant via ColorGradient.constant(color).


Spawning

All calls go through NebulonParticles.effects() (a ParticleService). Calls may originate off-thread — requests enter a concurrent queue and apply on the client tick. Pattern seeds make random distributions reproducible.

ParticleService particles = NebulonParticles.effects();

// One particle
particles.spawn(world, wisp,
ParticleSpawn.at(position)
.velocity(new Vec3d(0, 0.03, 0))
.delay(2)
.rotation(0.5f));

// Random burst: position extent, velocity extent, seed
particles.burst(world, wisp, ParticleSpawn.at(position), 32,
new Vec3d(0.3, 0.2, 0.3), new Vec3d(0.04, 0.03, 0.04), seed);

// Ring on an oriented plane: radius, count, outward speed, seed
particles.circle(world, wisp, position, new Vec3d(0, 1, 0), 2.0, 48, 0.02, seed);

// Sphere shell: radius, count, outward speed, seed
particles.sphere(world, wisp, position, 1.0, 64, 0.04, seed);

// Evenly spaced along a segment
particles.line(world, wisp, start, end, 24);

// Cone: direction, half-angle, count, speed, seed
particles.cone(world, wisp, ParticleSpawn.at(position), new Vec3d(0, 1, 0),
Math.toRadians(25), 48, 0.04, seed);

burst also has an overload taking Vec3dRange values for asymmetric min/max spreads.


Collision

Collision is opt-in per spec and tests swept particle bounds against client-side block collision shapes:

ParticleSpec sparks = NebulonParticles.builder(SPARK_TEXTURE)
.collision(ParticleCollision.bounce(0.07, 0.55))
.build();
FactoryBehavior
ParticleCollision.remove(radius)Despawn on contact
ParticleCollision.stop(radius)Halt on contact
ParticleCollision.bounce(radius, restitution)Reflect; restitution in [0, 1]

The radius is in blocks. Only specs that enable collision perform world queries. Nebulon allows 4,096 collision-shape checks per client tick by default; particles beyond the budget simulate without collision for that tick and increment collisionBudgetSkips(). Override with -Dnebulon.particles.collisionBudget=<checks>.


Dynamic attractors

Attractors and repulsors are live, thread-safe handles. Positive strength attracts; negative repels:

ParticleAttractor attractor = particles.attractor(world, center,
new ParticleAttractorSpec(6, 0.002,
ParticleAttractorSpec.Falloff.LINEAR, 0.004, 0b0010));

attractor.setPosition(movingPosition);
attractor.setStrength(-0.002);
attractor.close();

A spec defines radius, strength, falloff (CONSTANT, LINEAR, or INVERSE_SQUARE), a clamped maximum acceleration, and a channel mask. Set .forceChannel(0b0010) on a ParticleSpec to opt those particles into the mask. ParticleAttractorSpec.linear(radius, strength) and .channels(mask) are shorthands.

The default force-test budget is 32,768 particle/attractor pairs per tick; override with -Dnebulon.particles.attractorBudget=<checks>.


Live trails

A trail converts sampled source motion into evenly spaced particles, retaining spacing across calls:

ParticleTrail trail = particles.trail(world, sparks,
ParticleTrailSpec.spaced(0.15)
.velocity(new Vec3d(0, 0.005, 0))
.inheritVelocity(0.03),
initialPosition);

trail.sample(entity.getEntityPos()); // call every tick / frame
trail.reset(teleportDestination); // move without filling the gap
trail.close();

A per-sample cap prevents teleports or stalled producers from creating unbounded bursts; skips are reported by trailLimitSkips(). Attractor and trail handles are released automatically when their client world closes.


Runtime model and budgets

  • One dense struct-of-arrays pool, default capacity 16,384 particles; expired particles use swap removal, so there is no per-particle Java object.
  • When capacity is exhausted, additional particles are dropped rather than growing memory.
  • Rendering batches particles by texture, blend mode, and facing. Alpha particles sort back-to-front per batch; additive particles skip sorting.
  • Vanilla rendering uses the BEFORE_TRANSLUCENT stage; with an active Iris shader pack, particles move to Nebulon's post-composite END_MAIN window.

ParticleService.stats() exposes active/queued/spawned/dropped/expired counts plus collision, attractor, and trail budget skips. NebulonParticles.renderStats() reports render candidates, submitted and culled particles, alpha/additive counts, draw calls, and alpha batches. Press F8 for the live overlay.


Deliberate boundaries

  • World particles only; screen/UI particles are planned separately.
  • Individual particle state is never replicated over the network — only semantic events and emitter descriptions.
  • Built-in physics: gravity, drag, constant acceleration, bounded block collision, channel-filtered attractors, sampled trails. Arbitrary Java tick/render callbacks are deliberately excluded from the portable spec (they would not serialize or replay deterministically); future extensibility will use registered component types.