Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

classwhat it decidesthread
ServerLevelwhich players are told about a particle at all — on dimension and distance, and for a level event who caused itServer thread
ParticleTypethe type’s identity in the registry, and ParticleType.getOverrideLimiter, the “ignore the limits” flag baked into iteither
ClientLevelthe gated entry point, ClientLevel.doAddParticle — and the ungated ones beside itRender thread
ParticleResourceswhich provider a type gets, registered once at construction, and which SpriteSet — rebound on every reloadload off-thread, bind on the Render thread
ParticleProviderone per registered type: what class of Particle an options record turns intoRender thread
ParticleEnginethe groups, the one-tick admission queue, the emitters, the per-limit countsRender thread
ParticleGroupwhether there is room: the per-render-type cap and the probabilistic reservoirRender thread
ClientExplosionTrackerhow many explosion particles happen this tick, and where — the client’s own budgeted generatorRender thread
SingleQuadParticle.Layerwhich of three atlases a quad reads, and which pipelines draw itRender 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 gatemeasured fromthe distancewhat an override does to it
the server choosing whom to send a ClientboundLevelParticlesPacket tothe receiving player32 blockswidens 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.doAddParticlethe camera32 blocksskips the check entirely
the server broadcasting a level event — the break puff’s second routethe receiving player64 blocksnot consulted: a level event carries no particle type
ClientLevel.addDestroyBlockEffect, and everything else that hands ParticleEngine.add a finished particle—nonenothing 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 itwhat it does
ClientLevel.doAddParticlean 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
ClientExplosionTrackeranything below All is off. The pending explosions are cleared unused, and there is no decreased tier for explosion block particles at all
ClientLevel.tickWeatherEffectson 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 onopaquetranslucent
the particle atlasSingleQuadParticle.Layer.OPAQUESingleQuadParticle.Layer.TRANSLUCENT
the block atlasSingleQuadParticle.Layer.OPAQUE_TERRAINSingleQuadParticle.Layer.TRANSLUCENT_TERRAIN
the item atlasSingleQuadParticle.Layer.OPAQUE_ITEMSSingleQuadParticle.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.getLightCoords and ParticleFeatureRenderer is QuadParticleFeatureRenderer. SingleQuadParticle.Layer.TERRAIN and ITEMS each split into an opaque and a translucent layer, which SingleQuadParticle.Layer.bySprite picks between, and a full ParticleGroup now refuses a newcomer where it used to evict its oldest. ParticleResources, ParticleRenderType and ParticleLimit kept 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.