Skip to content

Ground tile variants and surface blending - #333

Merged
kcjonson merged 91 commits into
mainfrom
kj/ground-blending
Nov 2, 2026
Merged

kcjonson merged 91 commits into
mainfrom
kj/ground-blending

Conversation

@kcjonson

@kcjonson kcjonson commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Ground tile variants (#332) and ground surface blending, landing together. This branch carries #332's commits, so merging it closes both; #332 is the place to read the first half on its own.

From #332: ground tiles are Lua-generated assets (role ground), baked at load into mipmapped texture arrays with a disk cache, hex-tiled in land.glsl with a per-chunk macro tint. The atlas is gone, and with it the faint tile-edge lines that flickered while panning.

This half:

  • Organic biome edges. Each tile picks its biome from the corner weights plus world-space gradient fBm and an ecotone, which replaces the 16 m sector staircase (WOR-467). The generators use gradient noise.
  • Heights in the ground arrays' alpha (path:setHeight), and a height contest in land.glsl that replaces the fringe, its breakup and Rock's rim. Each pair's recipe comes from <transition> blocks in the ground defs.
  • Art round 2 for Rock, Mud, Sand and Snow.
  • The near-interior shortcut re-runs after the first fine octave, bit for bit with SurfaceField.

Since the manual test:

  • WOR-565: the tile shaders are GLSL 4.10, so textureGather is core and macOS accepts it.
  • WOR-569: where three surfaces met, the band's distance was measured to one surface's isoline instead of the painted edge, which left pale slivers between grass and sand. It now comes from the paint rule and is zero on the painted edge.
  • A five-way review, and fixes for what it confirmed:
    • an out-of-bounds read in the biome pick at a float rounding tie;
    • the bake cache keyed on the def as loaded, with safe file names;
    • one bake path, with failed steps reported instead of retried forever;
    • GL limit and error checks;
    • validation for <macro>, variant counts and layer sizes, and the def's period as the only period;
    • a triangular pair table (fragment uniforms down from about 909 to 550 components);
    • fold-safe bias math in the noise;
    • the golden test over negative tiles;
    • lighter asset-manager thumbnails.
  • Main merged in. The ground tiles scenario is now 09, since main added an 08.

Perf (RTX 3090, 3072x1728, GPU held at P0): the tile pass is at or under main in 26 of 27 cells. Desert idle zoom 3 is 0.58 ms against main's 0.56. Cold bake is about 21 s and warm about 40 ms, and the arrays are 56 MiB. The tables are in docs/technical/ground-textures.md.

Tests: world 193, world-view 37, assets 242, sim 678 and world-sim 7 pass. Regression scenarios 01, 06 and 09 pass on the final build, including the direct reload entry into the game scene.

Follow-ups are listed in status.md under Ground Tile Follow-ups: data-lod SVG ground defs, BC3/BC7, golden on a software GL in CI, salal requesting a 4.1 context, and the cache location and key.

tessellateAsset took its base index as a uint16 with no limit check, so an
asset over 65,535 vertices wrapped its indices and drew garbage without a
word. It now packs paths into as many meshes as it takes, in path order, and
AssetRegistry::buildMeshes hands them all back. buildMesh stays for callers
that draw one mesh (flora, groundcover, the asset-manager thumbnails) and now
fails with an error when the asset needs more than one, instead of
corrupting. A single path that tessellates past the limit on its own is
dropped with a warning, the way SVGLoader does.

GeneratedPath gains minPixelsPerMeter (0 = always drawn), and buildMeshes takes
a pixel density that drops the paths asking for more, which is what the ground
tile bake will use to leave fine detail out of coarse mips.
noise2(x, y, seed) is salal's gradientNoise2, about -1 to 1, so a generator
gets the same deterministic field the rest of the engine uses. periodicNoise2
(x, y, period, seed) wraps the integer lattice every `period` cells, so the
field tiles seamlessly (ground tiles need that); inside cells 0 to period - 2
it equals noise2 bit for bit. The period arrives as a double because P / F is
a float in Lua even when it divides evenly, and anything that isn't a whole
number >= 1 is a script error.

path:setLod(n) sets GeneratedPath::minPixelsPerMeter. Existing bindings are
unchanged.
A ground def (role ground, at assets/world/ground/<Surface>/<Surface>.xml) is
the tile art for one land surface. It names its <surface>, and optionally a
<period> in meters (default 4), <pixelsPerMeter> (64), <rotate> free, quarter
or none (free), <variantCount> (8 here, 1 elsewhere) and a <macro> with dry
and lush r,g,b tints, an amplitude and two wavelengths. The generator or
svgPath are the existing ones. assets already links world, so the surface is a
world::Surface, parsed with a new surfaceFromString next to surfaceToString;
AssetDefinition only forward-declares the enum, as PlacementExecutor.h does.

The validator checks that period divides 512 and fills whole pixels at the
chosen density, that variantCount is 1 to 32, pixelsPerMeter is a power of two
from 16 to 512, the surface is a land surface, that no two ground defs paint
the same surface, and that a ground def has no <biome> rules (placement
already skips defs without any). Once the tree has any ground def it warns for
each land surface that lacks one; that becomes an error when the content
lands. Ground-only elements on other roles warn.

Ground defs are skipped by the eager collision capture, which would otherwise
run their (heavy) generators at load for nothing.
… split

Validator rules for period, pixelsPerMeter, variantCount, surface, duplicates
and missing surfaces; schema parse and defaults; noise2 against salal's
gradientNoise2 and per seed; periodicNoise2 repeating along x, y and both,
including across the period seam and negative cells; setLod landing on the
path; a 22,000 triangle asset splitting into two valid meshes with every
vertex kept; and the pixel density filter.
…sRGB encode, metrics

Every downsample is a Kaiser-windowed sinc (alpha 4, 3 texels each way) that wraps,
clamped to the min and max under its main lobe, then given the source's mean back:
the clamp alone brightened a coarse mip of small light detail by 25%. The mean lock
adds a 2-texel blur of the residual against the reference, so the mean moves and
surviving detail keeps its contrast. Encoding dithers half a code so flat far mips
keep their mean.
GroundTileBaker draws each mip from the def's meshes at that mip's texel density, so
setLod paths leave at the level their author chose. A triangle's wrap copies follow
it, which keeps the generator's paint order at the edges. Mips 0 and 1 render at 8x8,
the rest at 4x4, tiled past 4096 px, then go through the Kaiser downsample, the mean
lock against mip 0 and sRGB encoding. It returns CPU chains plus per-mip metrics
(mean, delta E, variant spread, windowed contrast) and timings, logs the table, and
puts the coordinate system, viewport and bound target back.
Every level costs one full generator run whatever survives the LOD cut, about 95 ms
a level on the snow placeholder, which the per-mip column makes plain.
A ground def's detail pane gets a Vectors / Bake switch. Bake runs the game's
GroundTileBaker one variant a frame and shows the variant's mips at one texel a pixel,
a strip of what the game draws at 160 down to 3 px/m (a 2x2 of four variants, one LOD a
draw with the +0.25 bias, trilinear in linear light, computed on the CPU since salal
textures clamp), and the metrics table. Next variant steps through the chain; Reload
drops the cached bakes so the def bakes again.
Drawing a tile's whole mesh 3x3 doesn't wrap it: each copy's full-tile base
paints over the overhang of the copies drawn before it, and its own overhang
lands on top of everything drawn after, so blades get cut at two edges and float
over the other two. buildMeshes now repeats every path of a procedural ground
def that crosses an edge of its [0, period] square at the offsets it crosses
into, right after the original, so paint order holds across the seam. Clipped
to the square, the meshes are the seamless tile, which means the baker draws
them once instead of 3x3; the spec says so now.
A dense ground def needs several meshes, and buildMesh refuses it, so the asset
render path takes them all from buildMeshes. A ground def is framed on its
[0, period] square rather than its mesh bounds, and drawn clipped to it, since
its art crosses the edges. The detail pane shows the first variant tiled 3x3,
each copy clipped to its own cell, beside the usual variant grid, so a seam
would show. asset-cli's render smoke pass uses buildMeshes too, so a big ground
def isn't reported as producing nothing.

Drawing a ground def's 15 copies is slow in Debug, about 2 s a frame with the
grass tile selected.
ground_grass.lua paints one 4 m tile dark to light: a far stack for the coarse
mips (mean base, soil, clover and tuft proxies), a gap-shadow cover tagged
setLod(20), then soil peeks with pebbles, dark undergrowth fans, two-tone tuft
fans grown in clumps that share a tone and a lean and shade like a dome from
above, broadleaf rosettes, clover in Thomas clusters, and top blades with
centered underlays, broad blades, straw and dry tips. Density fields are
periodicNoise2 at 0.8 and 0.4 m, counts are per square meter, and every look
knob is a param, so the other grass surfaces can reuse the script.

Ground_Grass is the Grass surface's def with the tuned params. Mean is about
sRGB (73, 103, 49), the eight variants agree within 0.6% in luma, and the far
stack lands within 0.1% of the full-detail mean. In RelWithDebInfo a variant
is about 190 ms of Lua and 340 ms with wrap and tessellation, 27.7k paths and
230k vertices.
…ures-1cd28e

# Conflicts:
#	apps/asset-manager/AssetDetailView.h
buildMeshes repeats every ground path that crosses the tile edge right after the original, so the baker no longer emits 3x3 copies of each triangle. It scales the meshes into render pixels, drops what misses the target, and draws them in paint order; the target's clip is the wrap.
buildPaths runs the generator once and tessellates each path once, tagged
with its setLod density, and TessellatedPaths::meshesAt packs the paths a
density keeps into the meshes buildMeshes returned there. buildMeshes is
now a thin call over the two, and tessellateAsset is gone. The registry
also resolves a def's script path for callers, and remembers which XML
file a def came from.
…kes on disk

The baker builds every variant's paths at once on the TaskPool, one
generator run and one tessellation each, then draws each mip from them.
Each def's bake is kept in cache/ground beside the exe, keyed by its XML,
its script, its shape and the baker's version, so an unchanged def comes
back in about 20 ms. Ground_Grass in RelWithDebInfo goes from 19.6 s to
5.8 s cold; what's left is the GPU finishing salal's batched draws.

The asset-manager's Bake view takes the same steps, the paths in one
frame and a variant's render a frame, and Reload bakes a def again once
its files change.
GrassShort, GrassMeadow, GrassTall, Dirt, Sand, Mud, Rock and Snow get placeholder defs that share
ground_placeholder.lua: a flat base in the surface's old color under a light, low-contrast speckle,
4 variants at 64 px/m. Real art replaces them later. With all nine covered, the validator's
missing-surface warning is now an error; the ground tests build a full tree so they still check
one def at a time.
GroundTileBank bakes every ground def with GroundTileBaker once the registry has loaded, a variant a
call so the splash keeps drawing (its boot log counts them; the loading scene finishes the bake after
a --scene jump), and uploads them into GL_SRGB8_ALPHA8 texture arrays, one per layer size, every mip,
with a per-surface table of layers, period, density, rotation and macro tints.

land.glsl samples them on a chunk-continuous hex lattice (4 m columns, 148 rows a chunk): per-cell
variant, rotation and offset, Mikkelsen's luminance-weighted blend with a gain that relaxes with zoom,
one sample where a cell holds 98% or below terrain/ground/hexMinPxPerMeter, textureLod at a per-surface
LOD the CPU computes once a frame. The fringe's other surface takes the dominant cell alone. Samples
blend in linear light and are encoded back to sRGB for the rest of the pass.

The chunk worker fills a 257x257 macro texture at 2 m a texel, uploaded with the render tiles: a dry
to lush mix at 36 and 14 m and value noise at 10 and 5 m. Its last row and column are the next
chunk's first, so borders match, and each lattice corner is hashed once a chunk (3 ms a chunk).

Deleted: the tile atlas (GameBootstrap's initializeTileAtlas and surfacePatternPath), the old pattern
SVGs in assets/tiles/surfaces, and Chunk::getSurfaceColor and getBiomeColor.
The near stack now runs down to mip 3, so the default 24 px/m view (two
thirds mip 3) shows real clumps: tufts around a lit crown, light cores,
dark ground between them, clover and the odd soil peek. The far stack
for mips 4 to 6 is a coarse tone model of the near stack averaged into
one rect a mip 4 texel, so it keeps mip 0's mean and patches in the same
places instead of bright clover and tuft proxies.

Blades take the model's tone where they lie, over a proxy strip of that
tone kept to mip 3, so a light blade and its dark underlay average to
what the coarser mips show. Clump sizes and tones are normalized per
variant. The mean lock is under 2.3% on every mip (it was 8 to 17%),
mips 4 to 6 sit at 72 to 94% contrast, and the palette leans a little
more olive.
…res-1cd28e

The baker builds every variant's paths once on the pool, bakes a variant from them on the GL thread, and keeps each def's bake on disk under cache/ground beside the exe.
…-1cd28e

The bank now bakes through the staged baker, so there's one baker API and
the game reads the same kind of disk cache the asset-manager does. Each step
uploads every def in a row the cache holds, and at the first it doesn't,
builds that def's paths on the pool; later steps bake and upload a variant
each, and the last one stores the def. Cold, the loading scene's bake takes
4.5 s of steps; warm, 36 ms.
Each hex cell flips left to right or not off a hash bit, with its random uv offset and no rotation, so art with an up (grass blades) still breaks repetition without spinning sideways.
The camera is a hair off top-down, so the tile is drawn that way now: tufts are upright fans
rooted at their base, blades lean within about 30 degrees of vertical, and everything standing
is painted back to front by root. Shapes crossing the top or bottom edge are split there, so the
depth order holds across the wrap.

Undergrowth goes where a cover grid says the ground would show, until about a tenth of the tile
is left open, so every variant shows the same 15-20% of earthy soil and litter at close zoom.
Blade proxies take the blade's own average, the far stack is pinned to the measured mip 0 mean,
and the tile doesn't rotate.
On light views the driver drops the 3090 to P3 or P2 and tileGpuMs then
measures the clock, not the code (WOR-553). GpuHold draws a throwaway
shader into its own 512x512 target after the scene's GPU timer closes,
so no scene timer counts it and the frame is untouched, and sizes that
work each frame so scene plus hold GPU time sits near 0.8 of the frame.
Off by default, toggled with /api/control?action=gpuhold&value=0|1 and
read back from /api/state?what=gpuhold, so only builds with the dev
server can turn it on.

perf-capture.ps1 turns it on for the whole capture (-NoGpuHold skips it),
records the P-state and clocks per sample from a read-only nvidia-smi
query, and warns in red when a sample isn't P0.
ground-textures.md describes how captures hold P0 now (WOR-553), with
the on and off numbers for main and this branch, and the testing README
lists the gpuhold control and state view. The terrain spec's perf section
points at the hold instead of nvidia-smi -lgc.
The tile pass is short of quarter-rate work (int/float conversions and
MUFU ops), and the hex sampler added a dozen conversions a pixel. Cell ids
stay floats and cross to int through the 1.5 * 2^23 bias, the variant pick
rounds in float on the hash's low 12 bits instead of an integer modulo by
a uniform (the same pick for every power-of-two variant count), and the CPU
folds the chunk's cell origin and the macro tint and value constants.

River view: tileGpuMs down 0.01 to 0.02 ms in every cell.
Each inner tile's 3x3 binomial sum comes from its rows' 1 2 1 sums, read
from a nibble table, and the thin-feature test runs on the whole 4x4 mask
at once. The sums, and so the field, are bit-identical to the per-bit
version and to SurfaceField.cpp. The distance to an isoline takes one
inversesqrt in place of length() and a divide.

tileGpuMs on the river view drops 0.02 to 0.07 ms against the previous
commit in every cell; with both commits, coast and desert drop 0.03 to
0.09 ms against e533e5b.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The pixel-width floor can extend a transition beyond the field’s supported reach, causing an abrupt color change when tint is enabled.

1 open issue
What changed in this PR

Builds on #332’s baked ground textures with height-based surface blending and organic biome boundaries, connecting terrain generation, asset definitions, rendering, and preview tools.

Changes:

  • Replaces sector-based biome selection and value noise with shared gradient-noise fields.
  • Adds baked heights, validated transition recipes, shared grass-family seeds, and rendering optimizations.
  • Retunes ground art and adds height previews, automated coverage, and a manual regression scenario.
File Description
libs/​world/​world/​generation/​WetlandGenerator.h Uses shared noise for marsh islands.
libs/​world/​world/​generation/​TundraGenerator.h Uses shared noise for rock outcrops.
libs/​world/​world/​generation/​PatchNoise.h Defines lazily cached patch fields.
libs/​world/​world/​generation/​PatchNoise.cpp Builds seeded gradient-noise octaves.
libs/​world/​world/​generation/​OceanGenerator.h Returns the water surface directly.
libs/​world/​world/​generation/​NoiseUtils.h Removes obsolete value-noise utilities.
libs/​world/​world/​generation/​MountainGenerator.h Uses shared noise for snow patches.
libs/​world/​world/​generation/​GrasslandGenerator.h Selects grass variants through shared fields.
libs/​world/​world/​generation/​GenerationResult.h Removes the unused result wrapper.
libs/​world/​world/​generation/​GenerationContext.h Exposes shared patch-noise sampling.
libs/​world/​world/​generation/​ForestGenerator.h Uses shared moisture and outcrop fields.
libs/​world/​world/​generation/​DesertGenerator.h Updates rock-outcrop selection.
libs/​world/​world/​generation/​BiomeDispatcher.h Dispatches generators returning surfaces directly.
libs/​world/​world/​generation/​BeachGenerator.h Updates beach-outcrop selection.
libs/​world/​world/​chunk/​TileBiomes.h Defines per-tile biome selection interfaces.
libs/​world/​world/​chunk/​TerrainPolygonTestSupport.h Updates a comment about sampler resolution.
libs/​world/​world/​chunk/​TerrainPolygonSeams.test.cpp Migrates seam fixtures to chunk neighborhoods.
libs/​world/​world/​chunk/​TerrainPolygonBuilder.test.cpp Removes sector-grid setup from fixtures.
libs/​world/​world/​chunk/​TerrainPolygonBuilder.bench.cpp Uses neighborhood biome and apron queries.
libs/​world/​world/​chunk/​SurfaceField.test.cpp Tests the tighter shortcut and noise bounds.
libs/​world/​world/​chunk/​SurfaceField.h Documents the expanded near-interior shortcut.
libs/​world/​world/​chunk/​SurfaceField.cpp Checks for early exit after one fine octave.
libs/​world/​world/​chunk/​NoiseLattice.h Defines reusable cached noise grids.
libs/​world/​world/​chunk/​NoiseLattice.cpp Implements lattice caching and sampling coordinates.
libs/​world/​world/​chunk/​MockWorldSampler.test.cpp Tests corner-derived tile biomes.
libs/​world/​world/​chunk/​MockWorldSampler.cpp Removes sector-grid generation.
libs/​world/​world/​chunk/​GroundMacro.cpp Reuses the shared noise-grid implementation.
libs/​world/​world/​chunk/​GeneratedWorldSampler.cpp Removes sector-grid generation.
libs/​world/​world/​chunk/​ChunkSampleResult.h Removes sector storage and lookup helpers.
libs/​world/​world/​chunk/​Chunk.h Updates tile-generation inputs and interfaces.
libs/​world/​world/​chunk/​Chunk.cpp Integrates neighborhood biomes and patch fields.
libs/​world/​world/​chunk/​ApronField.test.cpp Adapts apron tests to neighborhood inputs.
libs/​world/​world/​chunk/​ApronField.h Introduces shared chunk-neighborhood state.
libs/​world/​world/​chunk/​ApronField.cpp Resolves apron tiles from owning chunks.
libs/​world/​CMakeLists.txt Registers new terrain-generation sources.
libs/​world-view/​world/​rendering/​shaders/​tile.frag Updates the land-color call.
libs/​world-view/​world/​rendering/​shaders/​includes/​hash_noise.glsl Optimizes conversions and separates octave evaluation.
libs/​world-view/​world/​rendering/​GroundTileFilter.test.cpp Tests height filtering and rank normalization.
libs/​world-view/​world/​rendering/​GroundTileFilter.h Defines height-data filtering interfaces.
libs/​world-view/​world/​rendering/​GroundTileCache.test.cpp Tests height metrics and transition-independent keys.
libs/​world-view/​world/​rendering/​GroundTileCache.h Documents revised cache-key inputs.
libs/​world-view/​world/​rendering/​GroundTileCache.cpp Versions height data and excludes transition XML.
libs/​world-view/​world/​rendering/​GroundTileBank.h Adds pair tables and shared hash identities.
libs/​world-view/​world/​rendering/​GroundTileBank.cpp Integrates height baking and transition tables.
libs/​world-view/​world/​rendering/​GroundTileBaker.test.cpp Tests explicit heights, paint order, and mip preservation.
libs/​world-view/​world/​rendering/​GroundTileBaker.h Adds height-baking interfaces and metrics.
libs/​world-view/​world/​rendering/​GroundPairs.test.cpp Tests recipe mapping and uniform packing.
libs/​world-view/​world/​rendering/​GroundPairs.h Defines surface-pair rendering parameters.
libs/​world-view/​world/​rendering/​GroundPairs.cpp Packs zoom-dependent transition uniforms.
libs/​world-view/​world/​rendering/​ChunkRenderer.h Replaces fringe uniforms with pair-table state.
libs/​world-view/​world/​rendering/​ChunkRenderer.cpp Uploads transition recipes and shared grass hashes.
libs/​world-view/​CMakeLists.txt Registers pair-table implementation.
libs/​sim/​ecs/​systems/​VisionSystem.test.cpp Replaces sector-based lake fixtures with ponds.
libs/​assets/​CMakeLists.txt Registers transition parsing.
libs/​assets/​assets/​TessellatedPaths.h Adds path heights and height-mesh output.
libs/​assets/​assets/​TessellatedPaths.cpp Shares mesh packing with the height pass.
libs/​assets/​assets/​lua/​LuaEngine.cpp Adds validated setHeight bindings.
libs/​assets/​assets/​IAssetGenerator.h Stores optional generated-path heights.
libs/​assets/​assets/​GroundTransition.h Defines transition recipes and validation interfaces.
libs/​assets/​assets/​GroundTransition.cpp Parses recipes and strips them from bake-key XML.
libs/​assets/​assets/​GroundAssets.test.cpp Tests heights, seed groups, and transition loading.
libs/​assets/​assets/​AssetValidator.test.cpp Covers transition validation rules.
libs/​assets/​assets/​AssetValidator.cpp Reports invalid or misplaced transition fields.
libs/​assets/​assets/​AssetRegistry.cpp Loads recipes and assigns paint-order heights.
libs/​assets/​assets/​AssetDefinition.h Adds seed groups and transition settings.
docs/​testing/​scenarios/​08-ground-tiles.md Adds ground-rendering regression steps.
docs/​technical/​chunk-management-system.md Marks the sector-grid design as superseded.
docs/​technical/​3d-to-2d-sampling.md Documents replacement of sector interpolation.
assets/​world/​ground/​Snow/​Snow.xml Retunes snow art, heights, and transitions.
assets/​world/​ground/​Sand/​Sand.xml Retunes sand detail and mud transitions.
assets/​world/​ground/​Mud/​Mud.xml Retunes puddles, colors, and distant appearance.
assets/​world/​ground/​GrassTall/​GrassTall.xml Shares grass seeds and defines transitions.
assets/​world/​ground/​GrassShort/​GrassShort.xml Shares grass seeds and defines transitions.
assets/​world/​ground/​GrassMeadow/​GrassMeadow.xml Retunes grass appearance and transitions.
assets/​world/​ground/​Grass/​Grass.xml Adds shared seeds and transition recipes.
assets/​world/​ground/​Dirt/​Dirt.xml Documents heights and adds transition recipes.
apps/​asset-manager/​GroundBakeView.h Adds height-preview state.
apps/​asset-manager/​GroundBakeView.cpp Displays baked heights separately from opaque color.

Review effort: Balanced

- One layer-size rule (GroundLayer.h: period x pixelsPerMeter a whole number of texels, 1 to 1024) shared by AssetValidator, GroundTileBaker::begin and GroundTileBank::begin, so an 8192 texel layer is rejected at validation and the bank allocates no array for it.
- Validation reports an SVG (assetType simple) ground def as an error; the baker only runs generators. The spec says so.
- periodicNoise2 wraps the lattice index without overflowing int32 for periods near INT32_MAX.
- perf-capture.ps1's cleanup resets the pan too, so a failed scroll scenario doesn't leave the camera moving.
The key hashed the def's XML as it was on disk when the bake began, while the generator runs the params the registry parsed at load, so an edit between the two stored a stale bake under a fresh key. It now hashes the parsed params, and groundBakeXml and AssetDefinition::sourceFile go. Cache files are named from a sanitized stem and a hash of the exact name, so names differing in case or in path characters never share a file or leave the directory.
… and vertices

A ground def's <period> is now its only period: generateAsset passes it in the GenerationContext and Lua reads it as the global period, so ground_grass.lua and ground_bare.lua no longer read a <params><period> that had to match it by hand. Every def's period is 4, and all nine bake bit for bit as before. periodicNoise2 takes a period within 1e-6 of a whole number as that number, since P / F lands a hair off one, and addVertex raises a Lua error for a NaN or infinite coordinate.
…em at bake

readGroundMacro (GroundTint.cpp) reads <macro> for the loader and the validator alike: three finite tints from 0 to 2, an amplitude from 0 to 1 and two positive wavelengths, so a NaN no longer tints a whole surface. GroundLayer.h now holds every bake limit, and the validator reports ground defs that need more distinct layer sizes than the bank's four arrays. GroundTileBaker::begin refuses a variant count outside 1 to 32 before anything is generated, the bake keeps the count it began with, and the asset-manager's Bake view shows a def's validation errors instead of baking it.
…ke path

Each bank step now catches what it throws and fails its def with the message, so salal's guard no longer reruns the same step every frame with the splash stuck on its count. The bank checks GL_MAX_TEXTURE_SIZE and GL_MAX_ARRAY_TEXTURE_LAYERS before it allocates, fits the baker's render tile under the first, and checks glGetError after each array, dropping the defs of one GL refuses. world_sim::stepGroundBake is the one way the scenes bake, behind the splash's validation gate: the splash, the loading scene after a --scene jump, and a dev switch straight to the game scene, which goes to the splash on failure. The bank's bakeAll and started go, and the splash's new members lose their m_ prefix.
The value and target rules move from GpuHold::configure into parseGpuHoldCommand (GpuHoldCommand.cpp), which world-sim-tests covers the way it covers CameraCommand, the 400 paths included. GameScene's new GpuHold member loses its m_ prefix.
prepareAsset builds a ground tile at the pixels a meter its square lands at, device pixels included, so paths tagged finer than a thumbnail's pixels are neither tessellated nor drawn; Grass's tree thumbnail no longer holds the full-detail tile. The thumbnails' shared mesh cache drops its least recently fetched entries past 128 MiB, and a thumbnail keeps the mesh it draws through a shared_ptr, so a Randomize or a resize no longer adds an entry without limit.
The period source, the macro and limit rules, the Lua guards, the one bake path and its errors, and the cache's key and file names.
At a rounding tie the prune's b + reach <= a dropped a runner-up that the
settled test's a - b >= reach had just called open, so rank() came back
without one and the pick read past its candidates. Both compare the
leader's lead now. The tie tile is a regression test, and the sector-grid
test asserts what its comment and the spec claim.
…an't fail

NoiseOctaveGrid matches gradientNoise2 bit for bit wherever that function's
float position is exact, at origins on both sides of zero, and gives a shared
point the same bits from any grid origin. TileBiomesComeFromTheCorners could
only ever pass: the pick's candidates come from the corners.
GenerationContext::elevation, selectSurfaceFor's elevationMeters and
rankNormalize's tieBreak were never read or passed. The biome water
comment names what Chunk::generate answers from now.
The staircase lake modeled the deleted 16 m sector shoreline. LakeShore
runs Chunk::generate on a planet blend band across the chunk, so the per-tile
pick draws the shore with its bays and islands: 723 ring vertices where the
staircase had 405.
…le sums

The 36 pairs a band reads take 72 vec4 at upper * (upper - 1) / 2 + lower,
where the square table took 162, 648 of GL 4.1's 1024 guaranteed fragment
uniform components. The 1.5 x 2^23 bias is exact only in source order, so
it no longer meets a sum a compiler could fold it through: the noise's
offset in its cell and the gather corner convert with float(), the variant
pick rounds a floor, and a cell's uv offset and angle come from a mantissa.
Same values, and on the desert view the same tileGpuMs as the tip in every
cell; precise held the order too but cost 0.02 to 0.05 ms.
Two more regions: the middle of a chunk 70 km out across the origin, and
the far corner of chunk (-1, -1), whose reads cross zero, so the noise's
floor division and hashing run on negative tiles on the GPU. The band
distance and far-zoom tests cover them as well.
The co-bake's shared hex hash, what the golden test covers and exempts,
the triangular pair table, the mip window as the code computes it, the
bias's fold safety and its A/Bs, the tightened sector-grid numbers, the
sector grid's wording outside its banners, and scenarios 05, 06 and 08
in the testing index.
Both epics move to Recently Completed with dev log entries, the follow-ups
become a planned epic, the ground spec drops its draft-PR wording, and
status.md keeps only the last four completed epics.
@kcjonson kcjonson changed the title Blend ground surfaces by height, with organic biome edges Ground tile variants and surface blending Nov 2, 2026
@kcjonson
kcjonson changed the base branch from kj/terrain-edges-grass-textures-1cd28e to main November 2, 2026 20:49
@kcjonson
kcjonson merged commit 735d7f6 into main Nov 2, 2026
3 checks passed
@kcjonson
kcjonson deleted the kj/ground-blending branch November 2, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants