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 method | Default | Meaning |
|---|---|---|
blend(Blend) | ALPHA | ALPHA or ADDITIVE |
facing(Facing) | CAMERA | Camera-facing or Y-axis-facing |
flipbook(cols, rows) | none | Row-major sheet, advanced once over the lifetime |
lifetime(ticks) | 20 | Particle lifetime |
scale(...) | 1 | Constant, (start, end, easing), or a full ScalarCurve |
aspectRatio(w/h) | 1 | Non-square particles |
color(...) | white | Constant, (start, end, easing), or a ColorGradient |
alpha(...) | 1 | (start, end, easing) or a ScalarCurve |
rotation(ScalarCurve) | 0 | Roll over lifetime, radians |
gravity(float) | 0 | Per-tick vertical acceleration (negative = upward drift) |
acceleration(Vec3d) | zero | Constant acceleration in world axes |
drag(float) | 1 | Per-tick velocity multiplier |
forceChannel(int) | 1 | Bitmask matched against attractors |
collision(ParticleCollision) | none | Opt-in block collision |
softness(blocks) | 0 | Scene-depth fading near geometry; 0 keeps the cheaper hard path |
maxRenderDistance(blocks) | 128 | Cull 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();
| Factory | Behavior |
|---|---|
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_TRANSLUCENTstage; with an active Iris shader pack, particles move to Nebulon's post-compositeEND_MAINwindow.
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.