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.