Skip to content

Particle coverage

What a Luanti particle spawner can ask for, and what Goanna actually draws. Written down because "particles work" is true and useless: they have worked since the packets were first read, while most of the format went to the floor. The three columns that matter are whether a field is read off the wire, whether it reaches the renderer, and whether anything is done with it.

The reference is ParticleSpawnerParameters in luanti/src/particles.h and Client::handleCommand_AddParticleSpawner. Goanna's parser follows that order exactly so the two can be diffed when the format next changes.

Spawner fields

Field Read Drawn Note
amount, time yes yes amount is a rate, not a population, and converting it is not optional; see below
pos yes yes box emission shape
vel yes approximated Godot takes a direction and a spread, not a velocity box, so the mid velocity becomes the direction and its span becomes the spread. A box wider in one axis than another loses that difference.
acc yes approximated mid acceleration becomes gravity
exptime yes yes exp_max is the lifetime
size yes yes scale range
collisiondetection yes yes rigid collision
collision_removal yes yes hide on contact
object_collision yes no Godot particles collide with the world, not with entities
vertical yes yes upright billboard turning about its own axis, which is what makes rain and snow read as falling
texture yes yes
texpool yes partly Godot draws one pass with one material, so the first entry is used. A pool of one, the ordinary case, is exact.
animation yes yes vertical strips and 2D sheets both, see below
glow yes no deliberate, see below
node, node_tile yes no a particle taking its texture from a node's tile still needs a content id to tile texture lookup
drag yes approximated per axis in Luanti, damping along the velocity in Godot, so a drag that differs per axis loses its direction
jitter yes approximated a random acceleration each step in Luanti, which Godot has no equivalent for. It becomes extra spread and initial velocity, which looks close for short lived particles and diverges for long lived ones.
bounce yes yes collision bounce
radius yes no
attractors yes no GPUParticlesAttractor3D exists and would fit; not wired up
blendmode yes mostly alpha, add and sub map directly. clip becomes alpha scissor. screen has no Godot equivalent and borrows add, which is brighter than it should be where the two differ.
per texture alpha and scale tweens yes no
tweened start and end values generally yes no only the start range is used, so a spawner that tweens renders as its opening state

Amount is a rate

ParticleSpawner sizes its pool as amount * longest_life when time is zero, and amount / (time / shortest_life) when it is not. Godot's amount is the number alive at once, so passing the server's figure through means drawing a fraction of the weather: Mineclonia's snow asks for 100 with a five second life, which is five hundred flakes in the air and was being drawn as one hundred.

The culling box

Godot culls a particle system by visibility_aabb, whose default is eight units around the emitter. A weather spawner emits across fifty units and its flakes fall for another fifty, so nearly every particle lives outside the default box and the whole snowfall blinks out as soon as that small box leaves the screen. The engine's own documentation says to grow it when particles suddenly appear or disappear, which is precisely the symptom. It is now derived from the emission box plus how far a particle can travel in its lifetime.

Weather

With the Shader weather setting on (the default), a rain or snow spawner that follows the player is drawn by shader and gets no emitter, and the notes above about pool size and the culling box apply only with it off. See weather.md.

Two that are deliberate

Glow is read and not drawn. Particles are drawn unshaded, so they are at full texture brightness everywhere: smoke in an unlit cave is as bright as smoke at noon. Glow sets a minimum light level, which only means something once particles take light from the world at all. Lighting particles is not yet planned anywhere: the materials plan covers terrain, and its remaining work has no particle item. Whatever lights them should take glow with it, and adding a light level here first would have to be undone. It is carried so the day that lands it is one line.

Worth knowing that this was already wrong in a smaller way: single particles parsed glow and spawners did not, so a spawner's fire and torch sparks lost the one property saying they are self lit, while the same particle spawned singly kept it.

Tweens are read and not drawn. A tweened parameter is a start range and an end range, and Godot's particle process material takes one range. Doing this properly means a custom process shader, which is worth doing once and not worth doing badly first.

Why the whole packet is read even where nothing uses it

The format is positional. A field left unread does not just cost you that field, it makes every field after it unreachable, which is how the parser came to stop at the attached object id: that was the end of the packet in 5.5, and everything 5.6 added sat in the buffer unseen for as long as the prefix looked like it worked.

So the rule here is to read to the end of what the protocol version defines, carry it across the thread boundary, and let the renderer ignore what it cannot yet use. Reading is cheap and being unable to read is expensive.

Short packets are tolerated rather than rejected. What was read before the end stands and the rest keeps its default, which is how a client that expects more than a server sends has to behave.

Animation

A 2D sheet gives its frame counts directly. A vertical strip gives the aspect ratio of one frame instead, so the frame count only falls out once the texture's own pixel size is known, which the session does not have. The ratio is carried whole rather than divided, because dividing it in the session rounds it away.

Godot counts animation speed in whole runs over a particle's lifetime, where Luanti gives seconds per frame, so the conversion needs the frame count and the lifetime together.

Breaking a node

Not a protocol feature at all. The pieces a node throws off while it is hit and when it finally gives way are made by the client, and Luanti's own client makes them the same way: nothing is sent, nothing is asked for, and the server never knows. Sixteen pieces on breaking and one per tenth of a second while digging are vanilla's own numbers, matched deliberately, because the point is that a Goanna player sees the same block break as everyone else rather than a better one.

Each piece shows a random patch of the node's own top tile, which is what makes them read as bits of the thing that broke rather than as generic dust. Godot has no per particle texture rectangle, but it does have particle animation frames, so a four by four grid with the animation stopped and its offset randomised gives every piece a different fixed quarter. Vanilla picks a random rectangle of up to a quarter of the texture, so the size matches and only the alignment differs.

The rate limit while digging is deliberate. Vanilla adds a piece per frame from its digging handler, which means a fast machine throws several times more of them than a slow one for the same swing.

Not covered here

Single particles (TOCLIENT_SPAWN_PARTICLE) are parsed with Luanti's own ParticleParameters::deSerialize, so they read the full format for free. They drop the same fields at the drawing end for the same reasons.