Animated node tiles¶
A tile definition can animate: a vertical strip of frames
(vertical_frames) or a sheet (sheet_2d), with a length in seconds.
Luanti's own node_visuals.cpp already cuts every animated tile into one
texture per frame when Goanna fills the node visuals, and works out the
frame count and the length of one frame in whole milliseconds
(TileAnimationParams::determineParams). Until September 2026 Goanna drew
only the first of those frames. This document is what it does now, how that
matches the vanilla client, and what is left.
What the vanilla client does¶
Checked against luanti/ at 5.17.0:
- One clock for the whole client.
Client::stepadds each frame'sdtime, capped atDTIME_LIMIT(2.5 s), tom_animation_timeand keeps it in [0, 60) withfmodf. - One rule for the frame.
AnimationInfo::getTextureinsrc/client/tile.cppdraws frame(u32)(time * 1000 / frame_length_ms) % frame_count. - No per node or per block offset. Every animated tile in view shows the same
frame of its cycle. Older releases had a
desynchronize_mapblock_texture_animationsetting; 5.17.0 does not. - The frame is applied by swapping the texture on each mesh buffer's
material, in
MapBlockMesh::animate, called fromClientMap::renderMap. A block more than 50 nodes away is only re-animated while fewer than 50 blocks (200 with the whole range shown) have been animated that frame, or when its random force timer runs out, so distant animation can hold a frame for a while.
The clock starts when the client starts, so two vanilla clients side by side do not show the same frame either. Matching vanilla timing means the same frame lengths, frame counts, rule and 60 second wrap, not the same phase.
What Goanna does¶
Two paths, because Goanna draws node tiles two ways.
The array path: the shader picks the frame¶
Cube-like tiles that nodes_array.gdshader can draw (back face culled, not
waving, not a liquid, not alpha blended: arrayPathTile in
src/goanna_textures.h) are the common case, and upstream keeps animated
tiles out of its own arrays because it swaps their textures. Goanna does not
patch that. Once the node visuals are filled, on the main thread in
GoannaSession::prepareContentIfReady and before anything is meshed with
them, GoannaTextureSource::buildNodeAnimations collects every animated
TileLayer and packs the frames of these tiles into Goanna's own animation
arrays: each tile's frames as consecutive layers, grouped by frame size and
by whether any frame has alpha, at most 256 layers to an array. The first
layer of each tile records its frame count and frame length.
GoannaClient::keyForIrr then points a buffer whose texture is an animated
tile's first frame at that animation array, and the near mesher writes the
tile's first layer into UV2.x (docs/systems/mesh-attributes.md). The
material hands the per layer frame counts and lengths to the shader as
layer_anim, and the shader turns the base layer into the frame's layer with
the vanilla rule and the clock in the global uniform goanna_node_anim_time
(goanna_anim_layer in nodes_array_common.gdshaderinc). Everything the
fragment reads per layer follows the frame: the colour, the LabPBR _n and _s
companions, and the class tables.
Nothing changes per frame on the CPU for these tiles. Animated tiles of the
same size and alpha share one material where they used to take one
material each, so they also merge into fewer surfaces per near region.
Faces of light emitting nodes are still drawn apart from the rest, as for
every tile (docs/systems/mesh-attributes.md).
A companion follows the frame where a pack ships one per frame.
GoannaTextureSource::companionImage cuts the same frame
(^[verticalframe:N:i or ^[sheet:WxH:x,y) from the companion when the
companion has the base strip's aspect ratio, at any resolution. A companion
with a different shape is taken to be one still map and is used whole for
every frame, which is what a pack with a single normal map for an animated
tile means. Before this, the companion of an animation frame was always the
whole strip, so a per frame _n was squeezed into one tile.
The single image path: one texture swap per material¶
Everything else keeps the single image materials it had: double sided tiles
(torches, lanterns and campfires are mesh nodes, fire is firelike, kelp and
seagrass are plants, and a nether portal is an alpha blended node box), and
tiles on the glass, ice, leaves and plants shaders. Goanna shares one
material per tile across every block, so when a frame changes,
GoannaClient::step_node_animation sets that frame's textures on the one
shared material: the colour, and for a standard material the inferred relief
and the emission mask derived from that frame, or for a special shader the
frame's companions. The frame comes from upstream's own
AnimationInfo::getTexture, the function MapBlockMesh::animate calls. It
is the vanilla mechanism, applied once per material instead of once per mesh
buffer. No material is created or swapped on any surface.
Liquids are left alone¶
Water and lava have their own shaders (water.gdshader, lava.gdshader,
docs/systems/lava-material.md), which animate their surfaces themselves. They
keep drawing the first frame they always drew, and no liquid material is ever
swapped. That includes the fake liquids drawn as ice or glass.
The far field¶
A tile in an animation array is drawn by the far tiers from the same array
and layer (tileFor in src/goanna_lod.cpp), so it stays textured and keeps
moving past the near range instead of turning into its first frame's average
colour at the hand-off. The far flatten blends each frame toward that
frame's own average colour. Tiles on the single image path keep the flat
colour fallback in the far tiers, as before. Water and lava go through
rung 6's water and lava materials, unchanged.
Goanna gives every tile the frame the rule names on every rendered frame, near and far, where the vanilla client may leave a block past 50 nodes on an older frame for a while. Distant animation is therefore steadier than vanilla's; the frame shown is never one the rule would not give.
The clock¶
main.gd calls GoannaClient.step_node_animation(delta) once per rendered
frame. It caps delta at 2.5 s, wraps at 60 s, publishes the time as
goanna_node_anim_time (registered in project.godot) and moves the single
image materials. set_node_animation_time(t) pins the clock, for a capture
that has to show a known frame; a negative value releases it.
node_animation() reports the clock, every animated tile with the frame it
shows now, its frame count and length, and the array and base layer it was
given, and every single image material with the frame on it.
set_node_animation_enabled(false), or GOANNA_NO_NODE_ANIM=1 at startup,
puts every animated tile back on its first frame, drawn as it was before this
existed, and rebuilds the near meshes and materials. It is a kill switch and
the lever for an A/B in one process.
On the first connection Godot's output says what was found, for example on Mineclonia:
Goanna node animation: 41 tiles (594 layers), 11 in 2 animation arrays,
23 double sided and 1 on special shaders change frame per material,
6 liquids keep their own shaders, 0 over 256 frames
GOANNA_DEBUG_ANIM=1 lists every tile.
Verified¶
With Godot 4.5.1 on an RTX 3090, against the Luanti 5.17.0 Flatpak server, 2026-09-19.
project/tests/node_animation.gd(--path project --script res://tests/node_animation.gd) builds an animation array the way the texture source does, sets the clock throughGoannaClientitself and reads back which frame each quad drew, through bothnodes_array.gdshaderandnodes_array_scissor.gdshader. It checks eight clock times against the vanilla rule, that a still layer beside animated ones never changes, that a companion's emission follows the frame, thatdtimeis capped at 2.5 s and that the clock wraps at 60 s. 0 failures.- devtest, fresh world:
testnodes:animplaced through the ordinary place path went into an animation array (4 frames of 1000 ms) and showed A, B, C and D at 0.5, 1.5, 2.5 and 3.5 seconds on the clock. - Mineclonia, fresh world: 41 animated tiles, 11 of them in two animation arrays (prismarine, 22 frames of 2045 ms; magma, sea lantern, sculk, the end portal, the reinforced deepslate, the respawn anchor top, the stone cutter's saw, the command block and the lit furnace fronts). A wall of magma, sea lantern, prismarine, sculk, netherrack with eternal fire, kelp, seagrass, a torch, both lanterns, both campfires and portal nodes, captured at 0.0, 0.5, 1.0 and 2.2 seconds, changes where the frame lengths say it should: fire, both campfires, seagrass, magma and the portal by 0.5 s, sculk and prismarine only by 2.2 s. All eleven single image materials that scene built (kelp, seagrass, torch, both lanterns, fire, both campfires' flames and logs, and the portal) reported the frame the vanilla rule gives for the clock at the moment of reading.
- The far tiers, with the LOD distance set to one mapblock so that wall, 38 nodes away, drew from a far tier (its torches, lanterns and campfires disappear, as far cells draw only cubes): magma, sea lantern, prismarine and sculk stayed textured, magma and sea lantern changed between 0.0 and 0.5 s while prismarine, sculk, netherrack and sand did not, and with animation switched off and the tiers rebuilt the four drew as flat colours, the old fallback.
- The lava suite (
lava_material.gd,lava_continuity.gd,lava_coupling.gd) reports 0 failures and the same pixel counts as before the change.
Cost¶
Measured in one client at a fixed pose over that Mineclonia scene,
switching animation on and off with set_node_animation_enabled and
alternating the order, four samples each, six seconds per sample, vsync off
(GOANNA_PERF=1), 1600x900. The GPU was shared with other sessions'
clients, which is why the comparison is made inside one process rather
than between runs.
| At the wall, 267 block meshes | Animation on | Animation off |
|---|---|---|
| Camera pass draw calls | 570 | 573 |
| Draw calls, all passes | 3358 | 3435 |
| Materials | 61 | 64 |
| Frame time, median | 13.45 ms | 13.59 ms |
| GPU time, median | 12.37 ms | 12.42 ms |
| Render thread CPU, median | 1.47 ms | 1.43 ms |
| From above, 332 block meshes, two samples each | Animation on | Animation off |
|---|---|---|
| Camera pass draw calls | 652.5 | 658 |
| Draw calls, all passes | 1762.5 | 1765 |
| Frame time, median | 14.51 ms | 14.52 ms |
| GPU time, median | 12.99 ms | 13.15 ms |
Separate processes of the unmodified build (a5da4db, three runs) and this one (four runs) at the same poses gave camera pass draw calls of 741 to 756 against 743 to 748 at the wall and 663 to 674 against 664 to 678 from above, and 67 or 68 materials against 64 or 65. Their frame times moved by up to 10 ms between runs of the same build as the other clients' load changed, which is why they are not quoted as a result.
step_node_animation itself costs 0.26 microseconds a frame on the main
thread when no frame changes and 1.6 microseconds when most of the ten
single image materials change on every call. The frame lookup in the shader
was measured separately, in one process, on 40 full screen layers of
overdraw through nodes_array.gdshader with and without it: 3.435 ms and
3.426 ms median GPU time over 560 frames each, which is noise.
What does not work yet¶
- A node being dug on one of the special shaders (glass, ice, leaves,
plants, liquids) shows its first frame with the crack on it, because the
crack is still composited onto a still copy of the tile there. The vanilla
client keeps animating a cracked tile. Array path tiles now keep their own
material and take the crack as a separate pass
(
shaders/crack_overlay.gdshader), so an animated one should keep animating while it is dug; that has not been seen in a running client. - Animated inventory, wield and dropped item images still show their first
frame (
item_visuals_manager.cppasks the stand-in client for an animation time that nothing advances). - Water and lava do not play the animation strip their tiles define; their own shaders animate them instead.
- Tiles on the single image path are a flat colour in the far tiers.
- A tile with more than 256 frames is kept out of the arrays and animates on the single image path instead. None has been seen.
- The companion frame cut has only been exercised by the fixture's own
arrays and by name construction. No pack with per frame
_nor_sstrips for an animated tile has been tried in a live client. - Sea lantern, magma and the other array path tiles used to take the standard material, with its emission mask cut from the tile's brightness; they now take the array shader's material, the same as every static cube, including glowstone. That changes how they look, and the change has not been reviewed on its own.