Brodgar client

Ground detail: the flavor pass, the mesh and the cut lifecycle

How a cut’s decoration is seeded — the tufts, pebbles and flowers a tileset scatters over its tiles — what its ground mesh is made of — the blend layers of a terrain tile and the transition skirts between tile types — and how a cut’s two deferred halves are built, replaced and swapped into the scene. Which cuts are asked for at all is terrain-raster.md.

The flavor pass: where it lives

What Where
A tileset’s flavor list Tileset.flavors, Collection<Indir<Flavor>>. Filled by the tileset’s own set part (Tileset(Resource, Message), part 1): flavprob and, per entry, a resource and a weight, each becoming a cached Tileset.SpriteFlavor — or replaced whole by the resource’s flavobj layers (Tileset.Flavor.Res, applied in Tileset.init)
The probability Computed in that constructor: w / (flavprob × tw), with the first weight doubled by the XXX: Bug-for-bug block. It is SpriteFlavor.p, a per-tile chance, and the only number a density can scale
A published flavor Tileset.Flavor.Res.get(): a flavobj resource that carries a Tileset.Flavor.Factory (@Resource.PublishedCode(name = "flavor")) makes its own Flavor; one that does not becomes a SpriteFlavor with args[0] as p
The pass itself Tileset.SpriteFlavor.flavor(Buffer, Terrain, Random): resolves res first (this.res.get() — a Loading leaves before any state is touched), seeds a DRandom from the cut’s seed, the resource name and the tile id, then for every tile of the terrain reseeds ornd per tile and draws ornd.nextDouble() < p; a hit places a Tileset.Flavor.Obj at a random offset and angle, wearing a ResDrawable of the resource, into buf under Tileset.flavobjmat
The inputs Tileset.Flavor.Buffer (the glob, the cut’s Area in tiles, the seed, and the per-material mats map the pieces collect into) and Tileset.Flavor.Terrain (one tile id over the cut: tiles() lists its coords, mask() its bitmap, both lazy)
Collecting a cut MCache.Grid.makeflavor(Coord): one Buffer per cut, the set of tile ids in it, and for each id a Terrain and a flavor(...) call per entry of that tileset’s flavors, rnd reseeded from buf.seed, the id and the entry index before each; buf.finish() runs what a flavor deferred; the result is a Flavobjs
What the scene gets MCache.Grid.Flavobjs, a RenderTree.Node: one child node per material (mats), each adding every piece’s Gob.placed, and all, the flat array tick/gtick walk
Ticking the pieces MCache.Grid.tick(double) / gtick(Render) — from MCache.ctick/gtick, from Glob.ctick/gtick — call Flavobjs.tick/gtick on every cut’s current fo.cur() of every loaded grid, and those call Gob.ctick/gtick on every piece

Fork. The density is applied inside SpriteFlavor.flavor, on a local copy of p, after the resource has resolved and before the loop: below the maximum, p is scaled by the percentage — unless the resource is ambient, decided once per SpriteFlavor from its own layers and cached on it — and at the maximum the field is used untouched, so the draw sequence is upstream’s bit for bit. A published Flavor of its own is not reached: it never draws against p.

The blend layers: where they live

What Where
The tiler TerrainTile, made by TerrainTile.Factory (@ResName("trn")) once per tileset resource: base (the tileset’s own material), var[] (TerrainTile.Var: a material, a noise band thrl..thrh and a noise offset nz), transset and draw. TerrainTile.RidgeTile (its factory trn-r) extends it and shares all of this
The weights, once per mesh TerrainTile.Blend, built through MapMesh.DataID<Blend> blend and m.data(blend), so faces and _faces of one cut read the same instance. Upstream’s constructor: a noise table lwc per layer over the cut plus a margin of sr (12) vertices, setbase(buf1) choosing one layer per tile from the variants’ noise bands (the base where no band matches) and setting it at the tile’s four corners, then sr blur passes weighted by lwc that end in buf1 = buf2. bv is the result, post-processed (v × 1.2 − 0.1, clamped, 0.25 + 0.75 v between). Neither the noise table nor the layers read the map: both are the tileset’s noise at the tile’s world coordinates
Which layers a tile emits Blend.en[layer][tile], decided per tile from the top layer down: a layer whose four corner weights are all below 0.001 is off, one whose four are all above 0.99 is on and marks fall, so everything under it is off
The vertices Blend.lvfac[layer], a VertFactory per layer: the vertex colour’s alpha is that layer’s weight interpolated over the tile (bv(lc, tcx, tcy)), tangent and bitangent for the bump map, the texture coordinate on a 25/4 grid
Emitting the ground TerrainTile.faces(MapMesh, MPart): for every enabled layer of the tile, one SModel keyed on the layer’s material and the tile’s draw (MapMesh.MLOrder(0, z) for var, VertexColor), so a tile with n layers enabled is n models and n slots
Emitting a transition TerrainTile._faces(MapMesh, int, Tile, MPart): the same loop over enabled layers, each under the transition texture’s AlphaTex and MLOrder(z, layer), through MapMesh.Model.get — one model per (material, order, alpha) key per cut. Reached from tcons, which trans hands to Tiler.MCons

Fork. sr, upstream’s constant 12, is the ground-blend setting, read once per Blend: 0..12 blur passes, and the margin they read beyond the cut. A pass spreads a weight one vertex, so a margin of sr keeps neighbouring cuts seamless at any count. Fewer passes build faster and leave harder edges, where more tiles reach one opaque layer and the en loop enables fewer. At 0 the three steps that compute the weights — the noise table, the layers and the blur — are skipped: buf1[0] is filled with 1 and the variants stay at 0; the post-processing and the en loop are untouched and then enable the base alone, opaque, on every tile, so faces and _faces emit one model where they emitted up to var.length + 1. The blur would leave 1/0 arrays as they are; skipping it and the noise is the build-time half of the saving.

Fork: the weights, bit for bit, at a fraction of the cost. Upstream’s Blend is some 80 % of a cut’s build (some 14 ms of 17 at 12 passes, one thread), and most of it is worked out several times over: a cut’s region is its 25 × 25 tiles and the margin, so upstream computes each noise value and each tile’s layer once for each of the four or so cuts around it. TerrainTile.nblock holds both a block of 25 × 25 world tiles at a time (mkblock: the same noise.getr and noise.get calls on the same coordinates), keyed by the tile’s noise and var (a RidgeTile shares its base’s), least recently used dropped past NBLOCKS (1024, some 10 MB); Blend.fromblocks copies them in. The blur then works only where the cut’s own vertices read it: pass i needs the vertices within sr − 1 − i of the cut and no others, each of which has all four neighbours in the region, so its sum is upstream’s term for term. A cut builds in some 3 to 8 ms, as its neighbours’ blocks are cached or not. ⚠️ bv outside the cut’s own vertices (0..sz) is not upstream’s: nothing reads it (en, lvfac), and nothing may. ⚠️ The blocks never go stale only because the weights read nothing but the tileset and the coordinates: a weight that read the map would need the cache keyed on it.

The transition pass: where it lives

What Where
The two passes of a build MapMesh.build(MCache, Random, Coord, Coord): pass one calls Tiler.model per tile (the surface), pass two calls Tiler.lay then dotrans per tile, then calcnrm/postcalcnrm over every ConsHooks in data and consflat. Before each tile of both passes ns = rnd.nextLong() is drawn and rnd.setSeed(ns) restores it after, so one tile’s draws never move another’s
The pass itself MapMesh.dotrans(MapMesh, Random, Coord, Coord): reads the eight neighbours’ tile ids through MCache.gettile (one tile across the cut edge — the reason a cut at a cache’s own edge throws MCache.LoadingMap), and from the highest id down builds a side mask bm and a corner mask cm; for every id with a mask it calls that tiler’s trans(m, rnd, ground, lc, gc, 255 − id, bm, cm), which lays the skirt of the higher tile over the lower
What a skirt is TerrainTile.trans: returns unless the tile under it has a lower id, picks a side tile from transset.btrans[bmask − 1] and a corner tile from transset.ctrans[cmask − 1] with the tile’s rnd, and lays each over the ground tile through gt.lay(m, lc, gc, tcons(z, tile), false) — an MCons whose faces is _faces above. Tiler.CTrans (tcons(z, bmask, cmask)) is the same pick for a caller that lays the ground itself

Fork. dotrans runs under the transitions switch; rnd.setSeed(ns) follows on the next line, so no other tile’s randomness moves. A remembered cut and a live cut go through this one path: there is one mesh builder, and a switch in it is one switch.

The cut lifecycle: where it lives

What Where
The cut MCache.Grid.Cut: mesh (Deferred<MapMesh>) and fo (Deferred<Flavobjs>), plus the overlay meshes (ols, olols, olstamp). One per MCache.cutsz square of a grid, made with the grid
The deferred half MCache.Grid.Deferred<T>: val (the current value), def (the future in flight), inited (volatile). get() returns val on the unlocked fast path; otherwise, under its own monitor, schedules rebuild() when !inited, and when the future is done swaps the new value in through update(T) and disposes the previous one
cur() vs get() cur() is val and nothing else: no build, no wait, null until the first build lands. get() is what schedules; it throws MCache.LoadingMap when there is neither a value nor a future (the grid was disposed)
invalidate() vs rebuild() rebuild() schedules a Defer.later now, cancelling the previous future. invalidate() cancels the future and clears inited, so the next get() schedules — and a cut nobody asks for is never built. Cut.invalidate() (from Grid.invalidate on Grid.fill(Message), a grid’s arrival, and on Grid.refill (fork, a grid put in by hand filled again from a newer record), and from Grid.ivneigh for the edge cuts of its neighbours) invalidates both halves; the addon: (terrain loading) comment there records the flood that eager rebuild() caused at login
The mesh half’s extra The mesh Deferred’s update sets Grid.olseq = -1, so Grid.getolcut re-lays every overlay over the new vertices on its next call
The accessors MCache.Grid.getcut(Coord) → mesh.get(), reached through MCache.getcut with no lock; MCache.Grid.getfo(Coord) → fo.get(), reached through MCache.getfo, which takes synchronized(grids)
The swap into the scene MapView.MapRaster.Grid.tick: per cut of area, getcut(cc) (the raster’s own, map.getcut or map.getfo) and, when the answer is a different object from cuts.get(cc).a, a new slot is added and the old one removed. Identity is the whole test: a Deferred that keeps answering its old value swaps nothing
What stays built A cut’s value outlives the slot that drew it: MapRaster.Grid.removed clears the raster’s cuts, not the Deferred. MCache.cutbuilt (fork) reads mesh.cur() != null, true while a rebuild is pending too

Fork. A cut carries a stamp per half, set at construction from the holder’s generation: one for the flavor objects, compared in getfo, and one for the ground (blend and transitions), compared in getcut. The accessor compares it before get(): on a mismatch it rewrites the stamp and calls invalidate() on that half, so the get() that follows schedules one build, keeps answering the old value until the build lands, and MapRaster.Grid.tick swaps the slot on the identity change. No lock is added: the stamp is volatile, invalidate() synchronises on the Deferred, getfo already runs inside MCache.getfo’s synchronized(grids), and getcut runs under nothing — two threads racing its compare at worst invalidate twice, and rebuild() cancels the previous future. A cut never built has val == null and is built at the current setting on its first get(), whatever the stamp says. The two generations are separate so a flavor slider’s drag never re-meshes the ground it does not touch.

Gotchas

See also