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.2 · 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 about the swing; 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 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, nothing elseServer
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 itClient
ParticleResourceswhich provider a type gets, registered once at construction, and which SpriteSet — rebound on every reloadload off-thread, bind on Client
ParticleEnginethe groups, the one-tick admission queue, the emitters, the per-type countsClient
ParticleGroupwhether there is room: the per-render-type cap and the probabilistic reservoirClient
ClientExplosionTrackerhow many explosion particles happen this tick, and where — the client’s own budgeted generatorClient
SingleQuadParticle.Layerwhich of three atlases a quad reads, and which of two pipelines draws itClient

Everything below the second row runs on the client 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 client 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, speed 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. Block.spawnDestroyParticles raises level event LevelEvent.PARTICLES_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.

sequenceDiagram
    participant MPGM as MultiPlayerGameMode
    participant Block as Block
    participant SL as ServerLevel
    participant PL as PlayerList
    participant CPL as ClientPacketListener
    participant CL as ClientLevel
    participant PE as ParticleEngine

    Note over MPGM,PE: the breaker's own client, predicting
    MPGM->>Block: playerWillDestroy, then spawnDestroyParticles
    Block->>CL: levelEvent, PARTICLES_DESTROY_BLOCK, the breaker as source
    CL->>CL: LevelEventHandler, the sound, then addDestroyBlockEffect
    CL->>PE: add, one TerrainParticle per quarter-block cell of the shape
    Note over SL,PE: everybody else, within 64 blocks
    Block->>SL: the same levelEvent, on the server's copy of the block
    SL->>PL: broadcast within 64 blocks, skipping the source
    PL->>CPL: ClientboundLevelEventPacket
    CPL->>CL: levelEvent, then the same addDestroyBlockEffect
    CL->>PE: add, the identical particles

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_DESTROY_BLOCK has fifteen call sites and only three pass a source at all — a bed, a tall plant, and Block.playerWillDestroy itself. 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 all raise it with a null source — which means the server broadcasts it to everybody, including whoever caused it.

Sixty-four — quads in 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 effects — and TerrainParticle additionally refuses air and 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.addBreakingBlockEffect, called once per client tick from Minecraft.continueAttack by way of Minecraft.handleKeybinds, and never networked at all — your neighbour’s screen shows their own crack particles, computed locally, not yours. 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, they are enforced by three different pieces of code, and two of them happen to be the same number.

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 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 particlenonenothing 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 either: 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. One deletes a check outright; the other multiplies a different check by sixteen.

The 64-block radius is the third rule, and nothing overrides it in either direction. Once a 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.doAddParticledecreased 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 broadcast filters on dimension and distance and nothing else — so turning particles down saves your GPU and costs the server exactly nothing.

Is there room for it?

Explosions are the one source that budgets itself before it asks anyone else, and they are not a particle packet at all. ServerLevel.explode sends a ClientboundExplodePacket carrying a radius, a block count and a WeightedList of ExplosionParticleInfo, and ClientPacketListener.handleExplosion hands that to ClientLevel.trackExplosionEffects and the ClientExplosionTracker. Each tick the tracker totals the block counts of every explosion it is holding, caps the result at ClientExplosionTracker.MAX_PARTICLES_PER_TICK, and draws that many weighted samples: a random direction, a cube-root-distributed radius so the samples fill the volume evenly, rejected outright if the block there is not air. Each survivor picks an ExplosionParticleInfo from the weighted list for its type, its positional scaling and its speed multiplier. Then the whole list is cleared, spent or not.

Everything else meets the engine’s own two limits.

flowchart TD
    A["a constructed particle reaches ParticleEngine.add"] --> B{"does Particle.getParticleLimit name a ParticleLimit"}
    B -- "no limit, the overwhelming majority" --> D
    B -- "SPORE_BLOSSOM, already at its count" --> X["dropped"]
    B -- "SPORE_BLOSSOM, under its count" --> Q["queued in particlesToAdd until the next ParticleEngine.tick"]
    Q --> D{"then ParticleGroup.add, for the particle's ParticleRenderType"}
    D -- "at ParticleGroup.MAX_PARTICLES" --> X
    D -- "past ParticleGroup.RESERVOIR_START" --> E["kept with probability equal to the square of the fraction of RESERVOIR_SIZE still free"]
    D -- "below RESERVOIR_START" --> K["kept"]
    E --> K
    E --> X
    K --> T["ticked from the next tick onward"]

The cap is per render type, not global, and the last quarter of it 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 ParticleRenderType.SINGLE_QUADS particle, that one group’s budget is effectively the whole budget; the other three groups have their own.

The per-type 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 kind of falling petal, 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 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.

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. Three of the four groups take a Frustum and ignore it. 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 struct-of-arrays, 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

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 actually 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 dedicated particle render target exists only under the transparency post chain, and even then only translucent particles use it (visibility and the frame graph). One particle escapes this system entirely: 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.

Two events empty the engine wholesale, and both have to. ParticleEngine.clearParticles runs on a resource reload, because every live particle holds a sprite reference into an atlas that no longer exists, and ParticleEngine.setLevel clears the particles and the emitters both. 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 the one arriving over the network — ClientPacketListener.handleParticleEvent logs it and drops it. And Particle.move skips the collision sweep above a fixed speed, so a particle thrown hard enough stops colliding with the world altogether, while one that has been stopped by a collision once stays flagged as stopped.

For a 1.21-era reader. ParticleEngine no longer owns providers, sprites, reloading or rendering — those became ParticleResources and the extract-plus-feature-renderer pipeline, and every provider and sprite-set member moved off the engine. TextureSheetParticle merged into SingleQuadParticle; the sheet-based ParticleRenderType constants became SingleQuadParticle.Layer, leaving ParticleRenderType a record with four values; Particle.getRenderType is Particle.getGroup; Particle.getLightColor is Particle.getLightCoords; Particle.render and ParticleEngine.render are an extract method plus QuadParticleFeatureRenderer; ParticleEngine.destroy and crack are on ClientLevel. And the name ParticleGroup was reused for something completely different: it is the per-render-type bucket now, and the limiter record it used to be is ParticleLimit.

Where to look

Block.spawnDestroyParticles · ClientLevel.addDestroyBlockEffect · ClientLevel.addBreakingBlockEffect · ClientLevel.doAddParticle, the gate everything else bypasses · ClientLevel.animateTick · ClientExplosionTracker.tick · ParticleEngine.add · ParticleEngine.tick · ParticleEngine.extract · ParticleGroup.MAX_PARTICLES · ParticleLimit · ParticleResources.registerProviders for the catalogue · SingleQuadParticle.Layer.bySprite · QuadParticleFeatureRenderer


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.