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'sLIGHTBANK_NIGHTat the vertex, decoded throughdecode_lightand 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'slmcoord.CUSTOM0.g, sky light. Luanti'sLIGHTBANK_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 oflmcoord.
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=1prints 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=1prints, 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, andGOANNA_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_nodelightpaints 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_testchecks 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.tscnis where the shader's treatment of these channels is judged, perdocs/design/pbr-plan.md("The material chart"), against printed numbers rather than screenshots.