Skip to content

Vertex attributes on node meshes

Every mesh Goanna builds from map data carries the same attributes, whether it came from Luanti's mesher at full detail or from Goanna's own LOD mesher several hundred nodes away. This file is that contract. It exists because three pieces of work depend on it and none can wait for the others: the lighting in docs/design/pbr-plan.md ("Lighting and exposure") reads Luanti's baked light and near field ambient occlusion, docs/systems/far-rendering.md builds the LOD tiers, and docs/design/iris-compat.md rung 5 translates a pack's gbuffers_terrain into the shader that reads all of it. If the two meshers disagree, a shader pack lights the near bubble and leaves the horizon flat, which is the failure docs/design/roadmap.md orders the work to avoid.

Anything not on this list is not carried. Adding to it means changing both meshers, both node array shaders and the LOD material in the same commit.

The layout

Array Contents Notes
ARRAY_VERTEX position in Godot space, nodes, world absolute z is mirrored against Luanti's
ARRAY_NORMAL face normal, z mirrored
ARRAY_TEX_UV tile UV near lava exception below
ARRAY_TEX_UV2 x array layer index, y block semantic ID see below
ARRAY_COLOR tile tint only, never light grass and foliage colourisation, param2 colour; near lava alpha exception below
ARRAY_CUSTOM0 RGBA8_UNORM: block light, sky light, ambient occlusion, freshness see below
ARRAY_CUSTOM1 near lava only: RGB_FLOAT world displacement direction absent on ordinary node meshes
ARRAY_CUSTOM2 RGBA8_UNORM: corner AO, pack block/sky coordinates, exact-source flag native materials retain CUSTOM0 lighting
ARRAY_INDEX triangles, Godot winding

The array layer in UV2.x also decides, on the near mesh, which of the two node array shaders draws the face: a face whose own layer has no alpha goes to nodes_array.gdshader even when its array holds cut-outs, so one upstream mesh buffer can become two surfaces. See docs/systems/materials.md, "Which shader draws a tile".

ARRAY_CUSTOM0 is declared with Mesh::ARRAY_CUSTOM_RGBA8_UNORM << Mesh::ARRAY_FORMAT_CUSTOM0_SHIFT in the flags argument of add_surface_from_arrays, and its data is a PackedByteArray of four bytes per vertex. The shader reads it as CUSTOM0, a vec4 already scaled to 0 to 1.

CUSTOM2.r carries local corner occlusion independently of the broad trace in CUSTOM0.b. Imported shader packs use this local value, scaled by ambientOcclusionLevel, in vertex RGB or alpha according to separateAo. oldLighting defaults off and adds directional face shading only when the pack enables it. Native materials continue to use the combined occlusion in CUSTOM0.b.

Exact meshes derive CUSTOM2.gb from raw Luanti levels before averaging. Both light coordinates use the 0..15 level domain. Luanti's ordinary lamps reach 14, while sunlight reaches 15; that game difference is preserved. If the daytime bank is no brighter than the night bank, its sky contribution is ambiguous and is conservatively set to zero. This prevents lamp light being treated as skylight in a cave, but can underestimate sky where a lamp dominates. Imported programs receive these linear coordinates in gl_MultiTexCoord1, scaled to 0..240, instead of native decoded brightness.

CUSTOM2.a = 255 identifies exact-node samples. Coarse meshes approximately invert their stored native brightness and carry the same linear coordinate contract, with neutral corner AO and CUSTOM2.a = 0. Height previews use full sky and no block light, also marked approximate. Lava subdivision preserves this attribute alongside its displacement direction in CUSTOM1.

Dedicated lava surfaces

Near lava uses the same arrays with material-specific interpretations: ARRAY_TEX_UV contains the continuous liquid velocity projected into the shader's world-space texture coordinates. prepareLavaSurface subdivides these surfaces and fills that field from the map; the lava shader samples its artwork from world position and does not use tangent data. ARRAY_COLOR keeps the tint in RGB; alpha carries crust coverage, tapering from one in the interior to zero at the outer liquid footprint. Lava is opaque, so this does not control transparency. Distant lava ignores this coverage. These meshes are kept outside regional batching. Distant lava ignores UV and uses slow convection only. The array shaders, water, and all other materials retain the ordinary tile UV contract. Near lava additionally carries CUSTOM1, a world-space direction from the gradient of liquid occupancy. Both faces at a corner receive the same direction, so raised falling crust stays joined.

Dedicated portal surfaces

Near Nether portal faces use a single image rather than an array. Their otherwise unused UV2.x carries four frame-contact bits: positive and negative U, then positive and negative V. U is world X on a Z-facing sheet or world Z on an X-facing sheet; V is world Y. A contact uses the lamp occlusion grid's full opaque cube rule. The shader reads the mask flat, so internal node boundaries do not become glowing seams. The mask keeps the rim available when lamp occlusion is off or outside its grid.

End portal faces also leave their animation array, but use ordinary UV for the server texture's coverage. Both shaders anchor their procedural fields in world space. Neither changes the geometry or its depth.

Why light is not in ARRAY_COLOR

Luanti's own mesher multiplies the baked light into the vertex colour and then multiplies the tile tint into the same channel, so upstream's ARRAY_COLOR is light times tint. Goanna cannot use that, because Godot lights the world: multiplying Luanti's light into albedo and then lighting the result gives the light twice, once as a darkening of the surface and once as illumination.

So the two are separated. g_goanna_no_light stays set, which is what makes encode_light return white and leaves ARRAY_COLOR holding the tile tint alone. The light values are sampled separately, by Goanna's own code, from the same node data Luanti would have read, and put in CUSTOM0 where the shader can apply them as light rather than as albedo.

That also settles the question docs/design/roadmap.md used to open with. Godot owns lighting. Luanti's values ride alongside, and the shader decides what they mean.

The four bytes

  • CUSTOM0.r, block light. Luanti's LIGHTBANK_NIGHT at the vertex, decoded through decode_light and averaged over the surrounding nodes. This is torches, lava and glowstone: light that exists at night and underground. The shader adds it as an ambient floor, which is what makes a cave dark and a torch matter, and it is one half of an Iris pack's lmcoord.
  • CUSTOM0.g, sky light. Luanti's LIGHTBANK_DAY, same treatment. How much of the sky reaches this vertex, before the time of day is applied. The shader scales the sky ambient by it, so an interior stops being lit by the sky through its own roof, and since 2026-08-21 also adds a flat fill in the sky's colour scaled by it (goanna_sky_fill, pbr-plan.md, "Lighting and exposure"), which is what the vanilla client's own light is and what lifts a wall under open sky that Godot's GI leaves near black. The other half of lmcoord.

It is not sky alone. Luanti writes artificial light into both banks, so beside a torch or lava this channel is lit however deep underground, and it matches CUSTOM0.r there. The sun itself therefore reads g - r, Luanti's own reading in encode_light: the direct light of the sun, the moon and the bounce is gated by it in direct_light.gdshaderinc, which is what keeps the sun out of a cave whose roof the server never sent. The sky ambient and the sky fill above still take g as it stands. - CUSTOM0.b, ambient occlusion. 1.0 is unoccluded. Goanna's own hemisphere trace against node occupancy, described in docs/systems/far-rendering.md. It multiplies into ambient and the sky fill in full, and into direct light only as far as vertex_ao_light lets it (0.3, so corners still read under the sun; see "Status, 2026-08-21" below). It replaces the corner darkening Luanti computes under smooth lighting, which is why m_smooth_lighting stays off: that path would bake its own occlusion into a channel we do not read. - CUSTOM0.a, freshness. 255 on every vertex of a live block. The far tiers write 96 on faces of a block drawn from the local store rather than received this session (docs/design/far-rendering-plan.md, rung 5). The shaders can pull such surfaces toward grey by stale_strength to show what is remembered, but its default is 0 (kMatStrengthDefaults in src/goanna_client_materials.cpp): near and far are meant to share one colour and light path, so in play the channel is read and not shown. Values between are unused so far; a second use (wetness for weather) would take another byte rather than share this one.

The block semantic ID

Light-source ownership is carried separately through the CPU mesher. A Goanna tile flag prevents emitting and non-emitting nodes from batching together even when they share a texture. MapBlockMesh transfers it into the high bit of Irrlicht's Aux; Godot conversion strips that bit from texture indices and sends the emitting faces to the lamp-excluded caster layer. This avoids guessing shadow ownership from a triangle's position, which fails for thin and oversized torch models. No Godot vertex attribute is added, and the sun still receives these meshes as shadow casters.

UV2.y holds an integer, as a float, naming what kind of thing the surface is: 0 for unclassified, and otherwise an index into a table Goanna keeps. The classifier in docs/design/pbr-plan.md ("A material for every node") fills it from the same nodedef read that assigns a material class, and docs/design/iris-compat.md needs it to answer mc_Entity.x, which is how a shader pack knows that this surface is leaves and should wave, that one is water and should refract.

Zero is the correct failure. A node with no classification renders unremarkably rather than wrongly.

UV2.x keeps its existing meaning, the layer index into the array texture, and is 0 on a surface whose material is not an array texture. Every node surface now carries UV2 whether or not its material is an array, because the block ID has to be there either way.

For an animated tile drawn from an animation array, UV2.x is the layer of the tile's first frame. The mesh never changes as the tile animates: the array shader adds the frame the clock names, from the material's layer_anim table, and reads everything per layer at the result. The far tiers write the same layer. See docs/systems/node-animation.md.

What the LOD mesher does with it

The same, at lower resolution, which is the whole point.

  • Position, normal, UV and array layer: as now, per merged face.
  • Tile tint: the representative node's tint, as now.
  • Light and occlusion: sampled per cell from the coarse chain rather than per node, so a distant hillside still darkens into its valleys and a lit window at 400 nodes still reads as lit.
  • Block ID: the representative node's, so a pack's waving water and waving leaves reach the horizon.

A tier that cannot sample something writes the neutral value, which is 255 for sky light and occlusion and 0 for block light, never anything that darkens or glows. Block light's neutral differs from the near mesh's because the far tiers add it as emission, being past the reach of the node light pool, and 255 there would make every unknown face a lamp. A missing attribute should look unremarkable.

Sky light's neutral of 255 stays 255 even though the near mesh answers 0 for a face with every neighbour solid, and the difference is not an inconsistency. The far mesher never draws a buried face: a top face needs an unfilled cell in front of it and a side face a shorter one, so a far face that reaches the fallback has no light record rather than no light. Answering 0 there was tried on 2026-08-22 and put 53 per cent of far vertices at zero sky light with black patches across the far field (docs/history/far-rendering-log.md, "One light, from your feet to the horizon").

Status, 2026-08-21

Landed: the layout above on Luanti meshed blocks, the sampler (src/goanna_light.cpp), the occlusion tracer and its offline check (src/goanna_occlusion.cpp, src/goanna_light_test.cpp), and both node array shaders reading the channels.

Also landed, later the same day: the LOD tiers (src/goanna_lod.cpp) emit this layout and run the same array shader, so the "what the LOD mesher does with it" section above is what it does. Light is sampled per cell face from the air in front of it, occlusion is traced per face against the tier's own occupancy, and both are quantised to sixteen levels so that faces can merge. The block semantic ID is filled from the classifier's block column (MaterialTable::blockOf, src/goanna_materials.h) on both meshers since later the same day: for the near mesh from the node a triangle belongs to, for the far tiers from the cell's representative node. It is an index into MaterialTable::block_names, built from the texture map, 0 where the map has nothing to say; against Mineclonia with project/texture_maps/mineclonia.csv that is 200 names.

Two things were found by building the instrument first, and both had been invisible.

Luanti's light decode table was never initialised. light_decode_table in luanti/src/light.cpp is a static array of zeros that only set_light_curve() fills, and Luanti's own client calls it during startup. Goanna never did, so decode_light() returned 0 for every light level including full sun, and every path that turns a stored light level into a brightness produced black. g_goanna_no_light discarded the result anyway, so the two faults concealed each other: this is also why turning on Luanti's smooth lighting could not have worked even with that flag cleared, which the earlier investigation recorded as a property of the flag. Found by printing param1 off the wire (14) beside what getLight and decode_light made of the same node (0). GoannaSession now calls set_light_curve() when it creates the settings.

Point sampling along a ray tunnels through thin walls. The first tracer stepped at fixed distances and read the floor of a one node deep pit 0.17 too bright, at every ray count from 8 to 48: rays leaving at a shallow angle stepped straight over the rim without landing in it. More rays cannot fix a step size. Walking the grid cell by cell cannot miss a cell, and is also cheaper: worst error against the dense reference fell from 0.167 to 0.014 at 24 rays, and the cost per sample fell from 0.72 to 0.28 microseconds.

What it looks like so far: almost nothing, and that is informative. Measured in one run, same frame, channels toggled at runtime (GOANNA_AB), at two fixed absolute viewpoints in daylight: turning sky light and occlusion off changes 0.1 per cent of pixels and the frame mean not at all. The channels are not idle, and the same test proves it: allowing occlusion to touch direct light as well (vertex_ao_light) changes 44.5 per cent of pixels and halves their brightness. So occlusion reaches the shading correctly, and what is missing is something to occlude. Ambient was a negligible share of the image, which is the balance the lighting recipe (docs/design/pbr-plan.md, "Lighting and exposure") was reworked to change, and it agrees with the earlier finding that SDFGI replaces environment ambient outright. The default was at first 0, occlusion multiplying ambient only because occlusion is not shadow. That lost to the picture on 2026-08-24: with the sun overhead the corners vanished and the world read flat at noon, so occlusion now reaches direct light at 0.3 (vertex_ao_light). The shader took 0.3 that day, but the client's own table, which sets the uniform on every material, kept 0 until 2026-10-11, so play ran without it on every tier until then (the record).

What it costs

Measured against a local Mineclonia server, Godot 4.5.1, a 20 second run with GOANNA_PERF=1, comparing against GOANNA_NO_VERTEX_LIGHT=1 which skips the sample and writes the neutral values:

with without
per block conversion 4.95 ms 2.42 ms
frames per second 288 295

So the trace costs about 2.5 ms per block, on the main thread, for a block of roughly twenty thousand vertices. That is affordable at the few blocks a frame streaming actually meshes, and it is the obvious thing to optimise if block arrival ever becomes the frame time problem: vertices in a block share their corners heavily, so memoising by quantised position and normal should remove most of it without changing a single output value. Not done, because it is not yet the bottleneck and an optimisation without a measurement to justify it is how the cheap correct version gets lost.

Instruments

  • GOANNA_DEBUG_VCOL=1 prints the spread of per vertex values per mesh buffer. It exists because turning on Luanti's smooth lighting looked like it worked and changed nothing measurable, and it is the check that the light path is live: one distinct value per buffer means it is not.
  • GOANNA_DEBUG_LIGHT=1 prints, per block, the mean and range of each channel and how much of the field carried light at all, which is what separated the dead decode table from a sampling fault.
  • GOANNA_MAT="channel=value,..." sets a material strength channel at startup, and GOANNA_AB="channel,channel" saves a second frame per view with those channels at zero. Both frames come from one run: two runs stream blocks in a different order and the difference that shows up is the streaming, which is how a 103 luma "result" was nearly recorded here before the pair was moved into a single run.
  • debug_nodelight paints the three channels straight onto the world as emission, red block light, green sky, blue occlusion, which answers "is this arriving" in a way no lit frame can.
  • build/goanna_light_test checks the tracer against a dense reference integral on shapes whose answer is known by inspection, and fails on an ordering inversion or an error over 0.12.
  • project/studies/lighting_chart.tscn is where the shader's treatment of these channels is judged, per docs/design/pbr-plan.md ("The material chart"), against printed numbers rather than screenshots.