Particles
Verified against Minecraft 26.3 · Part XI · A player breaks a block, and the puff of block texture appears on every screen within sixty-four blocks.
The block goes. On the breaker’s own machine the puff is already there, predicted, before the server has heard the dig finish; on every other machine within sixty-four blocks it arrives a moment later as a level event and lands as the same sixty-four textured quads, built by the same method from the same shape. Two entirely different routes, one visual result — and the interesting thing is what neither route does. Neither asks, on your machine, how far away you are. Neither asks whether the packet was worth sending. And neither asks your particle setting, which three pieces of the client read three different ways, while the server is told what you chose and never once acts on it. A particle is not something the game decides to show you. It is something that survives a series of gates that disagree about what they are gating.
The cast
| class | what it decides | thread |
|---|---|---|
ServerLevel | which players are told about a particle at all — on dimension and distance, and for a level event who caused it | Server thread |
ParticleType | the type’s identity in the registry, and ParticleType.getOverrideLimiter, the “ignore the limits” flag baked into it | either |
ClientLevel | the gated entry point, ClientLevel.doAddParticle — and the ungated ones beside it | Render thread |
ParticleResources | which provider a type gets, registered once at construction, and which SpriteSet — rebound on every reload | load off-thread, bind on the Render thread |
ParticleProvider | one per registered type: what class of Particle an options record turns into | Render thread |
ParticleEngine | the groups, the one-tick admission queue, the emitters, the per-limit counts | Render thread |
ParticleGroup | whether there is room: the per-render-type cap and the probabilistic reservoir | Render thread |
ClientExplosionTracker | how many explosion particles happen this tick, and where — the client’s own budgeted generator | Render thread |
SingleQuadParticle.Layer | which of three atlases a quad reads, and which pipelines draw it | Render thread |
Everything below the second row runs on the Render thread, and the only
off-thread work in the system is the load half of ParticleResources.reload
— the bind that rebuilds each SpriteSet comes back to the Render thread.
Several things cross the network and none of them is a particle. Three carry
the bulk of it: a ClientboundLevelParticlesPacket is an explicit request
carrying count, spread, a speed on each axis, a randomization type and two
override flags, a ClientboundLevelEventPacket is an event the client
interprets, and a
ClientboundExplodePacket is a
description the client expands itself.
Does the particle happen at all?
Both routes start in the same place. At the end of a dig that
succeeded, Block.spawnDestroyByEntityParticles
raises level event LevelEvent.PARTICLES_AND_SOUND_DESTROY_BLOCK with the
breaker as the source, and the two branches diverge only because of who
that source is relative to whoever is watching. That the wire carries an int rather than a
particle at all — and what else comes through the same door — is what makes a
sound happen, which owns
LevelEventHandler; this page owns what the client does with the puff once
the switch has decided.
flowchart TD
subgraph BC["the breaker's client"]
MPGM["MultiPlayerGameMode.destroyBlock, predicting"] --> PWD1["Block.playerWillDestroy, which calls Block.spawnDestroyByEntityParticles"]
end
subgraph SV["the server"]
SPGM["ServerPlayerGameMode.destroyBlock, once the dig succeeds"] --> PWD2["the same, on the server's level"]
PWD2 --> SL["ServerLevel.levelEvent, the breaker as source"]
SL --> PL["PlayerList.broadcast: within 64 blocks, not the breaker"]
end
subgraph OC["each other client in range"]
CPL["ClientPacketListener.handleLevelEvent"]
end
MPGM -- "the dig reaches the server" --> SPGM
PL -- "ClientboundLevelEventPacket" --> CPL
PWD1 --> CL["ClientLevel.levelEvent: LevelEventHandler, the sound, then ClientLevel.addDestroyBlockEffect"]
CPL --> CL
CL -- "one TerrainParticle per cell" --> PE["ParticleEngine.add, never through ClientLevel.addParticle"]
One break puff by two routes: the breaker’s client raises the event itself, and the server sends it to everyone else in range. Both routes end in the same client code, which hands finished particles straight to the engine.
Neither route is gated. Both end in ClientLevel.addDestroyBlockEffect,
which calls ParticleEngine.add directly and never passes through
ClientLevel.addParticle — so neither the distance check nor the particle
setting applies to either of them. The branches differ in who dispatches the
event, not in what the client then does with it.
And the breaker is not always a player.
LevelEvent.PARTICLES_AND_SOUND_DESTROY_BLOCK has fourteen call sites and
only three pass a source at all — a bed, a tall plant, and
Block.spawnDestroyByEntityParticles, which passes on the player
Block.playerWillDestroy hands it. A fox faceplanting into snow, a rabbit
eating a carrot down one age, a sheep eating grass, a zombie breaking a door,
a suspicious block that fell and shattered, and Level.destroyBlock itself,
through Block.spawnDestroyParticles, all raise it with a null source
— which means the server broadcasts it to everybody, including whoever
caused it.
Sixty-four quads make a full cube’s puff, because
ClientLevel.addDestroyBlockEffect walks every box of
BlockBehaviour.BlockStateBase.getShape on a fixed quarter-block grid with
a minimum of two cells per axis.
That is the outline shape, not the collision shape, and the difference is
visible: a torch has no collision shape at all and still gives twelve
particles, where reading the collision shape would give none. Each particle
shows a different randomly-offset quarter-crop of the block’s sprite, which
is why the puff does not look tiled. A block may opt out of the whole thing
— BlockBehaviour.BlockStateBase.shouldSpawnTerrainParticles gates both the
destroy and the crack particles — and the providers that build a TerrainParticle
for other callers additionally refuse Blocks.MOVING_PISTON.
Three neighbours that look like the same thing
The crack particles that fly off while you are still mining come from
ClientLevel.addBreakingBlockEffects, and the server drives them:
ServerPlayerGameMode.tick raises LevelEvent.PARTICLES_DESTROY_PROGRESS
once per server tick of the dig —
LevelEvent.PARTICLES_AND_SOUND_DESTROY_PROGRESS every fourth, which adds
the hit sound — with a null source, so every screen within sixty-four
blocks, yours included, adds one crack particle per tick of your dig. Your
own client adds one more itself, with the hit sound, when the dig starts,
from MultiPlayerGameMode.startDestroyBlock. The /particle command arrives as
ClientboundLevelParticlesPacket and goes through ClientLevel.addParticle
once per requested count with Gaussian spread, unless the requested count is
zero, which is a second mode entirely: one particle whose velocity is the
offset vector scaled by the speed, which is how a directed particle is
spawned. And the ambient scatter is a fixed cost paid every tick regardless
of what is there — ClientLevel.animateTick samples 1,334 random block
positions, 667 within sixteen blocks and 667 within thirty-two, most of
which do nothing. Riding on that one loop are the drip particles
(ClientLevel.trySpawnDripParticles), the biome’s AmbientParticle list
named by EnvironmentAttributes.AMBIENT_PARTICLES
(environment attributes and timelines),
and the barrier and light block markers — in creative, holding one of
those two items makes every matching block in range emit a marker particle,
which is the only reason you can see them.
Who is allowed to see it?
There are three distance rules on the two routes this section follows, the particle packet and the level event, enforced by three different pieces of code, and two of them happen to be the same number; an explosion’s packet has a 64-block rule of its own, and an entity’s own packets (a crit, an item pickup) reach whoever is tracking it. The table’s fourth row is the absence that makes the page’s hook true: once a finished particle is handed to the engine, the client applies no distance rule at all.
| the gate | measured from | the distance | what an override does to it |
|---|---|---|---|
the server choosing whom to send a ClientboundLevelParticlesPacket to | the receiving player | 32 blocks | widens it, to 512 — but from the caller’s own boolean, in practice /particle … force and the creaking heart’s, and never from the particle type |
the client deciding whether to build the particle at all, in ClientLevel.doAddParticle | the camera | 32 blocks | skips the check entirely |
| the server broadcasting a level event — the break puff’s second route | the receiving player | 64 blocks | not consulted: a level event carries no particle type |
ClientLevel.addDestroyBlockEffect, and everything else that hands ParticleEngine.add a finished particle | — | none | nothing to override |
The two thirty-twos are independent, not one check written twice. A particle
that clears the server’s test can still be dropped by the client’s, because
the client measures from where you are looking rather than from where your
feet are, and a packet that took a tick to arrive is measured against a
camera that has since moved. And the two overrides are not one flag, though
one feeds the other: the client’s comes off the particle type, through
ParticleType.getOverrideLimiter, whose three readers are all client-side,
while the server’s is a boolean the caller passes in, which the packet then
carries down as an override of its own. On the client an override deletes the
distance check and the setting check outright; on the server it multiplies a
different check by sixteen.
The 64-block radius is the third rule, and nothing overrides it in either
direction. Once the puff’s level event lands the client asks no further questions,
which is why a break puff at the edge of view is unconditional where the
same particle requested by /particle would never have been sent.
Does the setting apply?
Three pieces of the client read the same particle setting, and none of them agrees with the others about what its values mean.
| who reads it | what it does |
|---|---|
ClientLevel.doAddParticle | an override creates the particle whatever the setting; otherwise decreased is rewritten to minimal about a third of the time, and minimal drops everything — except that the always-show flag rescues a minimal setting one time in ten, and the rescue lands on decreased, which is then re-rolled |
ClientExplosionTracker | anything below All is off. The pending explosions are cleared unused, and there is no decreased tier for explosion block particles at all |
ClientLevel.tickWeatherEffects | on decreased, halves its column count — rain thins rather than stopping; on minimal it breaks out before adding anything, and there it does stop |
Only the first of those is the gate everything is nominally supposed to go
through, and four call sites outside the particle package bypass it
entirely by handing a constructed particle straight to ParticleEngine.add:
the break puff, the crack effect, the firework starter and the item-pickup
streak. A fifth is inside the system, a firework spark spawning more sparks. The same puff arriving as a particle
packet would be distance-culled and might be diced away by the setting.
Arriving as a level event, it is unconditional.
The last piece reads like a bug and is not. The server knows your particle setting and never uses it. It arrives in the client information and is stored on the player, and the broadcasts filter on dimension, distance and, for a level event, who caused it, never on the setting — so turning particles down saves your GPU and costs the server exactly nothing.
Is there room for it?
Explosions budget themselves before they ask anyone else, and they are not
a particle packet at all. What ServerLevel.explode
sends is a description: a ClientboundExplodePacket of a radius, a block
count and a WeightedList of ExplosionParticleInfo, which
ClientPacketListener.handleExplosion hands to ClientExplosionTracker to
expand locally.
Each tick it totals the block counts of every explosion it holds, caps the
total at ClientExplosionTracker.MAX_PARTICLES_PER_TICK, and draws that many
weighted samples, each a random direction at a cube-root-distributed radius
so the samples fill the volume evenly rather than crowding the centre, and
each rejected outright if the block there is not air. Then the whole list is
cleared, spent or not — which is the budget: an explosion gets one tick’s
worth of particles and no second chance.
Everything that reaches ParticleEngine.add, an explosion’s samples included,
then meets the engine’s own two limits.
flowchart TD
A["a particle reaches ParticleEngine.add"] --> B{"a ParticleLimit?"}
B -- "none, nearly all" --> Q
B -- "ParticleLimit.SPORE_BLOSSOM, full" --> X1["dropped, never queued"]
B -- "under it, counted" --> Q["queued in ParticleEngine.particlesToAdd"]
Q --> D{"next ParticleEngine.tick: its group's room"}
D -- "at ParticleGroup.MAX_PARTICLES" --> X2["refused; a counted one's count given back"]
D -- "past ParticleGroup.RESERVOIR_START" --> E{"kept by chance?"}
D -- "below ParticleGroup.RESERVOIR_START" --> K["kept"]
E -- "yes" --> K
E -- "no" --> X2
K --> T["ticked from the next tick onward"]
The engine’s two limits in the order a particle meets them: the per-limit
count on arrival, and its render type’s group at the next tick. Only a
particle carrying ParticleLimit.SPORE_BLOSSOM has a count to meet, and one the
group refuses gives its count back.
There are four render types in the game: ParticleRenderType.SINGLE_QUADS,
which is almost everything, ParticleRenderType.ITEM_PICKUP,
ParticleRenderType.ELDER_GUARDIANS and ParticleRenderType.NO_RENDER. One
ParticleGroup exists per type. The cap, ParticleGroup.MAX_PARTICLES, is per render type rather than
global, and its last quarter is probabilistic: past
ParticleGroup.RESERVOIR_START the acceptance
probability falls as the square of the free fraction, so the last few
hundred slots are very hard to fill and a particle storm degrades gradually
rather than hitting a wall. Since almost everything is a
single-quad particle, that one group’s budget is effectively the whole
budget; the other three have their own.
The per-limit machinery beside it is the strangest thing in the system.
ParticleLimit is a full accounting apparatus — a key carried by the
particle, a count map in ParticleEngine.trackedParticleCounts, a decrement
when a group refuses a particle the limit had already accepted — and it has
exactly one instance, ParticleLimit.SPORE_BLOSSOM. The whole mechanism
exists to hold down one ambient particle, the haze of spores a spore blossom
scatters around itself, and
ParticleEngine.hasSpaceInParticleLimit is the only thing that ever reads the
map. The number on the debug screen is a different count:
ParticleEngine.countParticles walks the live render-type groups, and
DebugEntryParticleRenderStats is its only consumer.
When does it move, and when is it drawn?
Admission is deferred by up to a tick. ParticleEngine.add puts the
particle in ParticleEngine.particlesToAdd, and ParticleEngine.tick —
which runs from Minecraft.tick, right after the ambient scatter, and only
while the game is unpaused and the level is running normally — does three things in a fixed order:
tick every existing group, then tick the emitters, then drain the queue into
groups. Because the drain is last, a particle never moves on the tick that
admits it, and a particle created during rendering is invisible until a tick
has run.
The emitters are the exception to almost everything. A TrackingEmitter is
a NoRenderParticle bolted to a moving entity, spending its short life
calling ClientLevel.addParticle on that entity’s behalf — crits, the
enchanted-hit sparkles and the totem burst are all emitters. It lives in
ParticleEngine.trackingEmitters rather than in a ParticleGroup, so it is
never counted, never culled and never extracted. It is only ticked. So is a
NoRenderParticleGroup: ParticleEngine.tick iterates the whole group map,
but ParticleEngine.extract iterates ParticleEngine.RENDER_ORDER, which
lists three of the four render types, so a no-render group ticks its
contents forever and is never asked for a render state. Which is exactly
what a no-render particle is for.
The eighty-odd subclasses, which are one shape
The eighty-odd Particle subclasses are a family, and the shape above is all
of it: a provider for most of them, a lifetime, a per-tick move, and a group. The two largest
files are DripParticle and FireworkParticles, the second a holder class
rather than a subclass, and both are large for the same reason: each is a nest
of providers and nested subclasses (every fluid that drips, every stage of a
rocket) behind one class name. What varies between any two of them is which ParticleOptions
record the registry hands the provider: ParticleType is
a data-driven registry
entry
like any other,
and ParticleOptions is the argument its codec parses — usually nothing at
all, sometimes a block state, an item stack, a colour or a target position.
Extract, where the interpolation and the culling both live
Everything visible happens at extract time, once per frame, from
LevelExtractor. That is where the particle’s previous and current
positions are lerped by the partial tick and made camera-relative before
being packed — interpolation is not a property of the particle, it is a
property of the extract. It is also where the culling happens, and the cull
is a point test: the particle’s centre, not its quad, against a Frustum
whose origin has been slid a few blocks behind the camera so that
particles just past the near plane survive. Two of the other three groups take a
Frustum and ignore it, and the no-render group is never asked. Only
QuadParticleGroup culls.
What survives is packed into ParticlesRenderState, one
ParticleGroupRenderState per group, with QuadParticleRenderState writing
twelve floats and two integers per particle into a per-layer
QuadParticleRenderState.Storage — a growable pair of arrays, one of floats and
one of integers, reset and reused each frame rather than reallocated — and
QuadParticleFeatureRenderer turning that into draws through
Blaze3D. The layer decides which atlas is bound, and the
particle system draws from three of them, not one:
| the sprite lives on | opaque | translucent |
|---|---|---|
| the particle atlas | SingleQuadParticle.Layer.OPAQUE | SingleQuadParticle.Layer.TRANSLUCENT |
| the block atlas | SingleQuadParticle.Layer.OPAQUE_TERRAIN | SingleQuadParticle.Layer.TRANSLUCENT_TERRAIN |
| the item atlas | SingleQuadParticle.Layer.OPAQUE_ITEMS | SingleQuadParticle.Layer.TRANSLUCENT_ITEMS |
Without improved transparency the six resolve to two pipelines,
RenderPipelines.OPAQUE_PARTICLE and
RenderPipelines.TRANSLUCENT_PARTICLE. SingleQuadParticle.Layer.bySprite
picks a row and a column by reading whether the stitched sprite
contains translucent texels and which atlas the sprite lives on — and only
three particle classes ever ask it: TerrainParticle, BlockMarker and
BreakingItemParticle. Every other quad particle hard-codes opaque or
translucent on the particle atlas, so the four terrain and item layers exist
solely for block- and item-textured particles
(models and atlases).
The last surprise is in the submission. A quad particle group is submitted
twice per frame — once into the solid bucket and once into the
after-terrain bucket, the same render state object entered twice, with the
feature renderer filtering each entry by whether the layer is translucent.
Opaque particles therefore draw before terrain-translucent geometry and
translucent ones after, and the per-particle packing still happens only
once. The feature renderer owns no render target and draws into the pass it
is handed; under improved transparency the after-terrain bucket is the
order-independent one, and the three translucent layers draw once per
OitStage through RenderPipelines.OIT_PARTICLE
(visibility and the frame graph). Two
particles’ draws escape this system: ItemPickupParticle carries an
EntityRenderState and is submitted through EntityRenderDispatcher, so
the item flying into your inventory is
a rendered entity wearing a particle’s lifetime, and
ElderGuardianParticle is submitted as a model.
What empties the engine, and what it crashes on
Two events empty the engine wholesale, and both have to.
ParticleEngine.clearParticles runs on a resource reload, because a quad
particle holds a sprite reference into an atlas that no longer exists, and
ParticleEngine.setLevel runs the same clear, emitters and all.
Particles are crash-report sites by design, though none of the reports is
raised on ParticleEngine: they come from ParticleGroup.tickParticle,
from QuadParticleGroup.extractRenderState, and from
ClientLevel.doAddParticle for a provider that throws while constructing.
The one malformed particle that does not crash is one arriving in a
particle packet — ClientPacketListener.handleParticleEvent logs it and drops
it, with the rest of that packet’s count. And Particle.move skips the collision sweep above a fixed speed, so a
particle thrown hard enough passes through the world for as long as it moves that fast, while
one that has been stopped by a collision once stays flagged as stopped.
For a 1.21-era reader. Particle.getLightColor is now
Particle.getLightCoordsand ParticleFeatureRenderer isQuadParticleFeatureRenderer. SingleQuadParticle.Layer.TERRAIN and ITEMS each split into an opaque and a translucent layer, whichSingleQuadParticle.Layer.bySpritepicks between, and a fullParticleGroupnow refuses a newcomer where it used to evict its oldest.ParticleResources,ParticleRenderTypeandParticleLimitkept their names.
Where to look
ClientLevel.doAddParticle first — it is the gate the page is about, and
reading it tells you at once how little goes through it. Then
ClientLevel.addDestroyBlockEffect for the route that does not, and
ClientLevel.animateTick for the loop that pays for the ambient world.
ParticleEngine.add and ParticleEngine.tick for admission and the order
the three things in a tick happen in, ParticleGroup for the cap and the
reservoir, ParticleResources.registerProviders for the catalogue of types, and ParticleEngine.extract for where interpolation and culling
actually live. SingleQuadParticle.Layer.bySprite is the small surprise
worth reading on its own — a layer chosen by looking at a sprite’s pixels.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.