Skip to content

Material calibration

Two offline fixtures, added 2026-09-15 after feedback that Mineclonia in full sun looked "plastic", measure what the renderer makes of a given roughness, metalness and water depth before any texture is blamed. Both use the world's own sky and lighting recipe as lighting_chart.gd copies it from main.gd, with no server and no world. Godot 4.5.1, Vulkan Forward+, RTX 3090. The ramps are the measurement; an in-world screenshot is only confirmation (see docs/systems/materials.md for the channel layout they decode).

The material ramp

PROBE_OUT=/tmp/ramp RAMP_OUT=/tmp/ramp/ramp.json \
  godot --path project studies/material_ramp.tscn

Three rows of node sized cubes through nodes_array.gdshader: a grey dielectric at smoothness 0, 64, 128, 192, 230 and 255, the same as a metal, and twelve baked sets from the pack. Three lighting cases: noon, afternoon, and glint, where the sun is moved to the mirror direction of each column in turn so every step is measured at the same angle. The first version put one sun over the whole row and measured the layout instead of the roughness; a smooth lobe is narrower than the few degrees between columns. GOANNA_VARIED_DIR draws a second pack row from another directory, and GOANNA_BAKED_DIR swaps the pack outright, which is the clean comparison (same cubes, same pixels, two runs).

What it settled, patch means in sRGB, the sun at 1.0:

  • The sun glint dies above smoothness 230 on a dielectric. Glint top face luma by step: 130, 151, 217, 255, 248, 145. Roughness 0.04 gives a lobe a pixel or two wide under a point sun, and light_angular_distance at the real sun's 0.53 degrees changes nothing (Godot widens shadows with it, not the specular lobe). Metals keep a glint at 255 only because the sky radiance map carries the sun disc, which a metal reflects at full strength and a dielectric at 4 percent. The usable top of the dielectric range is about 230.
  • The pack's terrain shows almost no specular in daylight. Grass at smoothness 76 and planks at 56 sit between the first two steps, whose glint is 130 against 151. Stone, dirt, sand and gravel are lower still. Uniform sheen on terrain is not where the plastic look comes from.
  • Roughness variation at those levels is invisible in the sun. With tools/pbr/pbr_spec_variance.py spreading the eight core dielectrics from a standard deviation of 0.01 to 0.04 up to 0.04 to 0.10, no patch moved by more than one count at noon or afternoon, top or front. The spread only matters where the lobe is narrow: wet surfaces (goanna_wetness pulls roughness toward 0.32, and standing water in a relief's hollows to 0.06, docs/systems/weather.md) and grazing low sun. The tool is kept for those; it is not a fix for the daylight look.
  • Metals reflect only the sky. There is no screen space reflection and SDFGI carries no specular, so a metal at smoothness 255 is albedo times the sky radiance: the grey cube's top reads 82, 99, 137 under a sky whose top of frame is 144, 168, 205. A mirror finish cannot appear on any surface in this renderer as configured; the calibration says so rather than the maps.

The water ramp

PROBE_OUT=/tmp/w WATER_OUT=/tmp/w/water.json \
  godot --path project studies/water_ramp.tscn

A sheet of water.gdshader over sand stepped from half a node to eight deep, beside the same sand dry, seen from thirty degrees down (GOANNA_PITCH changes it). It prints each strip's colour, luma and saturation next to the dry sand and the sky. The tile is Mineclonia's water animation, first frame, from the game as installed.

Before, deep water read 113 to 120 luma against the dry sand's 162, at 0.45 saturation against the sand's 0.27: a sun lit blue floor at seven tenths of the sand's brightness, which is the painted look. Two things in the shader did that. The bed seen through the water went out through ALBEDO, so the lamp lit it a second time, and the deep colour was the tile at half strength, also lit by the full sun, when a real water column returns a few percent of what falls on it.

The shader now splits the two: the transmitted bed is emission, attenuated by the same absorption as before and never shaded again, and only the column's own scatter (body_gain, 0.35 of the old half strength tile) is an albedo the sun and sky light. After, at thirty degrees: the half node shallow reads 159 against dry sand at 163 and shows the bed, the two node strip 128, the eight node strip 48 to 79. Saturation rose to 0.55 to 0.60 in the deep strips, because what remains there is the bed transmitted through an absorption that kills red first, which is what clear water does; the grey blue of a hazy lake is the sky, not the body. From twelve degrees the eight node strip reads 125, 151, 199 under a horizon sky of 145, 165, 187, converging on the reflection as it should.

Not changed, and worth knowing: the sky is reflected twice at small amounts, once by the shader's own reflected term and once by Godot's environment specular from the same radiance map, about five percent each at thirty degrees. Setting SPECULAR to zero would remove the second but also the sun glint, so it stays.

Confirmed only on the fixture. No Mineclonia server was up on the day, and the working tree carried another session's uncommitted C++, which the shared checkout rules say not to link for a measurement.

The close-up, and what plastic turned out to mean

The wide ramp measures levels. The complaint was about structure, which the wide ramp cannot show: a cube forty pixels across has no texel detail left. GOANNA_CLOSE=1 lays the pack row out four to a row with the camera near enough that a cube is about three hundred pixels wide, and the ramp applies the relief gain the client would (GOANNA_NORMAL_GAIN pins it), which for the Mineclonia bake is 2.86.

Measured on the pack's normal maps, 2026-09-15: median texel tilt 5.9 degrees over 400 maps, stone 12.6, cobble 12.4, grass top 7.9, and the occlusion channel never below 0.81 on any of them. A cobble with mortar has facets at forty to sixty degrees along every joint and occlusion under a third in the cracks. The bake's relief is the pixel grid embossed: each 16 px source texel a plateau with a soft edge, and at the client's gain every texel outline becomes a ridge. That, not the specular level, is what reads as moulded plastic. A correct BSDF on a surface with no structure.

Three sets of the same twelve stems were put on the close-up:

  • the bake as it is, at gain 2.86;
  • the bake lifted (tools/pbr/pbr_relief_normalise.py, new): tangent slope scaled to a class target tilt, occlusion recomputed from the height channel stretched to full range. Stone went 12.6 to 31.1 degrees and its occlusion floor from 0.81 to 0.03. It looks like the bake, deeper: the same embossed texel edges, more of them;
  • authored (tools/pbr/pbr_author/, new): one script per stem builds a height field and a smoothness field from the game's 16 px art, deciding what the surface is (which texels are one stone, where the mortar runs, what the grain does inside a plank), and lib.py derives the normal, the occlusion and the _s map from those two fields so they agree. Written by three subagents against metric targets they could check without seeing: mean tilt by class, occlusion floor, smoothness spread, seam on every map.

Judged by eye on the close-up: sand and snow are the clear wins, real grain and a soft crust where the bake had a plateau per texel. Planks read as boards with grain. Stone, dirt and gravel are better than the bake but still carry its signature, because the scripts dome each region of the art with a distance field on the nearest upscaled label map, so every dome has the square outline of the texels it came from. The next step there is rounding the region silhouettes before the distance field (a smooth upscale of each region mask, edges warped by noise), which is a lib.py change and a rerun, not new authoring.

None of this reaches the distant view, where texel detail is under a pixel and what makes a reference hillside read as a surface is variation between blocks. That is a shader question and is not started.

Parallax occlusion and self shadow

Shown at the close-up, even the authored sets read crisp but flat: a normal map shades a surface, it does not put depth in it. Godot has no tessellation, so the depth is read in the fragment. nodes_array.gdshader now marches the eye ray through the _n height channel and moves the tile coordinate to where the ray meets the surface, so the albedo, the normal and the material are all read at the texel actually seen, and then climbs from that point toward goanna_sun_dir through the same field to find whether the texel is in its own shade. The shadow reaches direct light by folding into AO with AO_LIGHT_AFFECT raised to (1 - shadow) / (1 - AO), which makes Godot's direct light scale exactly the shadow wherever the occlusion is at or above it, and the specular is scaled by it too.

Two things about it that were not obvious:

  • The tangent basis is solved from the screen derivatives of world position against those of the tile coordinate, after the per node turn and shift. That is exact inside a node whatever goanna_node_uv did, and it asks nothing of the mesh's tangent convention, which this shader has a history with (see the green flip note in the decode).
  • LabPBR carries no depth scale, and one depth for every material turned sand into fur: a sand grain's height byte spans the same range as a cobble joint's. Depth comes from the class (goanna_class_depth in the include): a tenth of a node for stone and gravel, a hundredth for sand, cloth and metal, parallax_depth scaling the table. The better answer is to measure each map's own depth in the client from the ratio of its normal slope to its height gradient, the way pack_normal_gain is measured, and pass it per layer; that is C++ and waits for a clean tree.

The march fades out over the second half of parallax_range (40 nodes) and hands the relief back to the normal map, which the far flatten later trades for roughness, so the relief has a continuous story from the eye to the horizon. Within range it costs up to 24 height reads for the march and 8 for the shadow per fragment; not measured against the benchmark yet. The scissor variant (leaves, plants) is untouched, and the moon and lamps cast no self shadow.

None of this drew in play until 2026-09-27: every Mineclonia array holds a cut-out, and the shader was chosen per array, so the ground went through the scissor variant. It is now chosen per tile (docs/systems/materials.md, "Which shader draws a tile"). What is written here was judged on the ramp, which always used the opaque shader for opaque stems.

On the close-up the bake with parallax is the embossing with depth, which is worse; the authored sets with parallax are surfaces. Sand is sand and snow is snow at any angle, planks are boards, and the stony three show real joints with sun on one wall and shade on the other, still with the square silhouettes noted above. The ramp's low case (sun at 0.18 from the camera's right) is the one to judge the self shadow on.

The authored pack

With the close-up and parallax in place the user's judgement was that the authored sets look better under every setting for no measurable cost, so the authoring was scaled out: eight subagents in parallel, one material family each (soils, sands and sandstone, natural rock, masonry, logs, planks, leaves, and obsidian, bedrock and lapis), forty one more stems on top of the first nine, every script in tools/pbr/pbr_author/ and every set judged on the close-up ramp before install. tools/pbr/pbr_author/build_pack.py rebuilds all of them from the scripts and installs into pbr_packs/mineclonia/textures, appending an attribution note.

What the review caught, for the next fleet:

  • The seam measure is blind to phase. Several scripts rolled the art a few texels to move a joint off the wrap so the seam number would pass. On a cobble that only changes which stone is at the corner; on the polished stones, whose drawn bevel lives on the tile's edge, it moved the relief into the middle of the block as a cross. The roll came out of the three polished stones and they passed the seam without it, so the trap was never real there. A masonry course must not move at all: the brick and stone brick scripts keep the art's rows.
  • Segmentation tolerance is the whole design decision for a mosaic. Cobble at 0.06 found two stones and read as a slab with holes; at 0.03 it found eighty three and read as cobbles.
  • lib.class_of reads the class back out of the bake, which carries the bake's own mistakes: coarse dirt came back as leaves from a stray scattering byte and mud as cloth from a tie on the smoothness level. The scripts override where the art is plainly something else and say so.
  • Three stems whose art does not wrap by design (podzol side, sandstone bottom, the polished stones) show a high albedo seam. That is the art, so the albedo seam is reported and never fails.
  • The output directory is shared with the ramp and the other authors. One early agent cleared it. The brief now says never to.

The far look is unchanged by all of this: texel detail is under a pixel past a few dozen nodes and what makes a hillside read there is variation between blocks, which is a shader question still not started.

The user's review of the first fleet found five defects, each traced to the script rather than the art and fixed the same day: sandstone beds were domed per dash and stopped partway (now a per row profile), stone brick's lower course joint was a slit because one cut never separates a wrapped ring (the flood fill now cuts at the tile seam when a course has exactly one joint), brick's joint geometry came from warped brick labels rather than the mortar mask, the polished stones were speckled by crystal noise at stone's parallax depth (the crystal is now in the smoothness only, and their tilt sits at 21 degrees by choice), and mossy cobble was a Voronoi jigsaw (now cobble's own segmentation). The log tops had radial cracks converging on the pith, which read as a pinch; they now start a third of the way out and are shallow. The ramp takes "side+top" entries so a log renders with bark on its sides and the cut face on its ends, the way the world dresses it, since a single stem wrapped around all six faces had the cut on the sides too.

The second fleet: the built world

With the surface set judged better under every setting, the authoring went to what a village is made of once you stand inside it: five subagents, one family each, for the furniture (crafting table, furnace and lit furnace, bookshelf, hay bale, TNT), the doors and cut-outs (wood and iron doors, trapdoors, ladder, rails, torch), the colour families (wool, terracotta, concrete, concrete powder, sixteen dyes each behind one module and a one line script per stem), and the stone variants with the glowing blocks (cracked, mossy and carved stone brick, carved and smooth sandstone, glowstone, pumpkins). About a hundred and twenty stems, all in tools/pbr/pbr_author/, build_pack.py skipping the family modules.

Three things the packer and the ramp learnt from it:

  • pack takes an emission field, the art's bright texels as a 0..1 glow, written to the _s alpha as LabPBR has it. The lit furnace, the torch, glowstone and the jack o'lantern use it; the shader multiplies the albedo by it, so the glow keeps the art's colour.
  • A nearly flat material must not fill the height range. The shader gives the full 0..1 range the depth of the class, so terracotta stretched to the byte rendered as pumice on the ramp. lib.band holds such a field in a narrow band about the middle; normalise01 is for surfaces that really have the class's depth.
  • The ramp now sets each cube's class from its maps, so the parallax depth on the ramp is the world's. Every cube had been marching at class none, which is half stone's depth and four times sand's; concrete powder looked like grit and, once corrected, the stony blocks looked like rubble at stone's own 0.08. The class depth table came down to 0.045 for stone, which is where a joint reads as a joint and where the frames the look was settled on had been.

Manufactured blocks came out better than the natural surfaces did, because their art carries designed structure a script can honour: recessed door panels, rivets found as luminance peaks, a crafting grid read as saw kerfs, book spines as chips. The cut-outs draw through the scissor variant, which has no parallax march, so their relief reads from shading. The seam and tilt measures do not apply to a face that is mostly transparent or a door whose top and bottom edges differ by design, and those scripts say where they miss and why. The pack install into the tracked pbr_packs/mineclonia is still refused by the permission classifier; the standalone authored pack at baked/authored-mineclonia/textures, linked into the launcher's texture pack list as mineclonia_authored, carries the whole set.

The second fleet's review found the same failure four times over: scripts reaching for domes and grain where the art wants flat faces and thin lines. The furnace got cobble lumps, TNT's paper a concrete grain, book spines the shape of chocolate blocks, and the crafting table's engraved motif was raised as a steel tool. All four were rebuilt as flat faces in a narrow band with joints, creases or lines recessed into them, and the rule is in the brief. The ores were reworked to be reflective, metal veins and gems carrying their own F0 through a new packer argument, and redstone glows at a quarter strength; the packer's smoothness mean is now taken over the ordinary texels so a vein at 0.9 no longer drags its matrix down. Grass plants, the grass path and the snowed side were added. The grass block side cannot take maps: the game draws it as the dirt side with an overlay composited on, and companions are looked up by the base name. Lava is a liquid on its own material and never reads these maps.

Kythen

The first game after Mineclonia, run to the playbook on 2026-09-16 and 17: fifteen agents in two waves, batched by culture and family, 228 stems against the 286 the bake covered, every batch rendered on the close-up ramp under sun and lamp before commit. Kythen's art is 32 px, so a source texel is eight map texels, and the library scaled without change; the culture recipes in the game's own materials.json files gave the agents the layout of masonry and bark where the art alone was ambiguous. Three things came back into the library and the playbook: the smooth upscale now wraps, a sharp periodic feature that lands on the tile edge fools the seam measure and wants a wide taper plus a narrow kerf rather than a roll, and a soil's occlusion comes from a sparse flat floored hollow, not from its clods. Two reworks from review: ash bark's diamond lattice read as chain link and became furrows, and the Gondar rubble's recess read as a waffle and was banded. The standalone pack is baked/authored-kythen/textures, in the launcher's list as kythen_authored; the production path is a new terrain bundle version.

The Material Maker benchmark

A pack assembled from free Material Maker materials owes nothing to the bake or the authoring, so what it shows is the renderer. Fifty two materials from the Material Maker site were exported at 256 px with tools/pbr/mm_export.py, which runs the flatpak's runner headless with tools/pbr/mm_export.gd in place of the start scene because 1.7's own --export-material never opens the directory it tests. Fifty six sets came out (a few files hold two materials); seven were blank because their graphs no longer compile in 1.7, and two hung or had no Godot target. tools/pbr/pbr_from_mm.py packs a set into the client's pair, reading which of occlusion, roughness and metal the material file actually binds, since a graph with no metallic input still writes a white blue channel. Twenty seven sets dressed Mineclonia's main blocks over the authored pack as baked/mm-mineclonia/textures, in the launcher's list as mineclonia_mm; mm_map.txt beside it is the map. The materials and exports stay in the maintainer's checkout under asset_bundles/materials/ and asset_bundles/mm_out/, ignored by git: this was an experiment, not something to ship, and each library material carries its own author and licence.

Findings, 2026-09-18, on the close-up ramp under sun and lamp:

  • The client renders third party PBR cleanly. Masonry, rock, soils, sand, planks, bark and the hammered metals all read as their material; the metals under the lamp are the strongest case for the spec path.
  • Displacement is the open problem, not the maps. Parallax never moves a silhouette, so every cube edge stays a ruled line however deep the joints. The class depth table is small (stone 0.045 of a block after the halving), and a Material Maker height sits in a slice of the byte (the sandstone bricks in 13 to 38 of 255), so at the table's depth those sets showed almost nothing; the converter now stretches height to the byte, and at three times the table (GOANNA_PARALLAX_DEPTH=3) their mortar recesses like the authored sets'.
  • A silhouette cut (parallax_silhouette, off by default, ramp only) discards a fragment whose march leaves the node's tile, so joints notch the block's edge and a corner loses its sliver. It reads well on the ramp. It is only right on a single node quad: a merged quad's inner boundaries are not edges, so the world would need the quad's extent from the mesher before it could turn on, and it can only cut inward. Real outward displacement means subdividing faces in the mesher and is not started.
  • Scale is the thing a generic material cannot know: the sand's grains and the planks' boards are sized for a wall, not a block, which is why this is a benchmark and not a pack to ship.

Depth from the map, not the class

Measured 2026-09-18 after the user read every ramp sheet as flat. The maps' own normals imply 2 to 16 cm of relief (the normal's tangent slope over the height byte's gradient, at the median, over the tile width: authored stone 16 cm, cobble 5, dirt 6, planks 8, sand 1; the Material Maker sets 1 to 9), and the class table marched 1 to 5 cm, so the shading said one depth and the parallax another and the eye read the flatter one. The array build now measures that depth per authored layer (reliefDepth in goanna_textures.cpp, layer_depth in the shader) and the march uses it, capped at a tenth of a node because the first fleet's stone and stone brick were authored with normals steeper than their height and at 16 cm the march reads the wrapped far side of the tile on every edge. Authoring should set normal_strength from an intended depth, which is a library rule not yet written.

Two things the running client showed that the ramp had not:

  • The client's pack gain, meant to lift a flat bake toward 55 degrees at the ninth decile, reads the authored pack as flat (median 4.5 degrees, p90 11.8 over 244 layers, because most manufactured blocks are held nearly flat by lib.band) and applies its 4x ceiling in play. The ramp runs at 1x. Every in-game judgement of the authored pack so far, including the halving of the depth table after the cave review, was of normals four times steeper than authored. The rule needs to leave a pack with authored height alone, or the target needs to be a number an authored pack can meet; not changed here.
  • The per layer depth was not observed in play. The opaque node materials are not reachable from the scene tree through the control channel, and the harness's posed camera came back rolled, so the change is verified on the ramp and by the build only.

The smoothness mean, 2026-09-25

Eleven authored Mineclonia sets shipped in pack 1.0.0 as known failures of the quality gate's "too smooth for its class" rule. Three causes, none of them a judgement about the surface:

  • lib.pack put the mean on the class level by a plain shift and then clipped at 0. Soil's level is 0.05, and a script with the spread the targets ask for put up to two fifths of its texels below zero, so the clip lifted default_dirt, default_clay, coarse dirt and both podzol faces to 0.09 to 0.11 against the gate's 0.075. The offset is now solved so the clipped mean lands on the level, and only ever lowered. Every set whose shift clipped moved down onto its level (87 Mineclonia, 228 Kythen, _s only); stone and planks, which had sat 0.01 to 0.04 over theirs, move by less than the ramp can show at these levels (see the material ramp above).
  • The mean was taken over the whole tile, a cut-out's holes included, though the holes are overwritten with the neutral fill afterwards. A low rough hole floor held the mean down, so the torch and the tall grass top drew 0.2 and 0.17 over their level. It is now taken over the drawn texels; the leaves, plants, ladder, trapdoors and one door face moved onto their level with them (17 Mineclonia, 54 Kythen).
  • The rails were packed as class metal with the steel in metal_mask, so the level went to the texels that are not metal, the timber sleepers, and lifted them and the steel to metal's 0.40. They are packed as wood now, the way an ore is packed as stone with its veins as metal.

After, the Mineclonia authored set passes the gate with no failures (it had 11), and tools/pbr/check-pbr-height.py passes. Kythen's authored set goes from 88 failing stems to 44; what is left is scripts that pack a different class from the one the classification review records (sand, gravel or snow scripts for stems reviewed as soil, metal for stone), which is a question per stem, not a packing fault. The previews of default_dirt and default_clay differ by under one count on average: the lift was invisible, which is why it went unnoticed.

lib.check's smoothness spread target (0.08) now fails for three Mineclonia and twenty five Kythen soils that the clip had been widening. A mean of 0.05 floored at zero leaves little room for that spread; the target, not the level, is the thing to revisit for soil.