F1: soft-fade biome color blending (BLEND_BAND_METERS, geometry untouched)

Vertex colour is now chosen from the true, unrounded surface height and faded
between adjacent materials across a tunable band, instead of switching at one
rounded integer depth. This removes the horizontal contour banding.

Colour path only: density field, Marching Cubes interpolation/welding, chunk
dimensions, the .dat contract and all road logic are untouched. BlockID
classification is unchanged and still drives non-visual use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-05 01:51:06 -04:00
parent 7f0f43b5fb
commit ea11258871
6 changed files with 185 additions and 9 deletions

View file

@ -8,8 +8,12 @@ namespace IslaApocalypse.Client
{ {
public void RenderChunk(ChunkData data) public void RenderChunk(ChunkData data)
{ {
// 1. Pass the BlockIDs array to the generator! // 1. Pass the BlockIDs array to the generator, plus the per-column
Mesh = MarchingCubes.GenerateMesh(data.Densities, data.BlockIDs, Constants.ISO_LEVEL, Constants.VOXEL_SCALE); // surface/biome/road data it needs to fade colours smoothly.
Mesh = MarchingCubes.GenerateMesh(
data.Densities, data.BlockIDs,
data.SurfaceHeights, data.ColumnBiomes, data.ColumnIsRoad,
Constants.ISO_LEVEL, Constants.VOXEL_SCALE);
StandardMaterial3D mat = new StandardMaterial3D(); StandardMaterial3D mat = new StandardMaterial3D();

View file

@ -59,5 +59,111 @@ namespace IslaApocalypse.Core
return BlockRegistry.DIRT; // Fallback return BlockRegistry.DIRT; // Fallback
} }
} }
// ===================================================================
// SOFT-FADE COLOURING (rendering only)
// ===================================================================
// GetVoxelID above is the authoritative, INTEGER classification. It
// still decides what block is actually stored in each voxel, and it is
// deliberately left exactly as it was.
//
// What follows answers the same question — "what material is here?" —
// but for the RENDERER, and it does it differently in two ways:
// 1. It measures depth as a real number from the true surface height,
// instead of counting whole blocks down from a rounded one.
// 2. Instead of returning one material, it returns the two materials
// either side of the nearest boundary plus how far between them we
// are, so the renderer can fade rather than snap.
// That is the whole fix for the horizontal colour banding.
// Thickness of the top "skin" layer. Mirrors GetVoxelID's rule that
// only the single topmost block gets grass/snow/asphalt.
private const float SKIN_DEPTH_METERS = 1.0f;
// Depth at which we are into solid stone. Mirrors "depthBelowSurface > 4".
private const float STONE_DEPTH_METERS = 4.0f;
/// <summary>
/// Picks the two materials to fade between for a point sitting
/// <paramref name="depthBelowSurface"/> metres under the real surface.
/// </summary>
/// <param name="blendBand">Metres over which one material becomes the next. 0 = hard switch.</param>
/// <param name="idNear">The material on the shallower side of the boundary.</param>
/// <param name="idFar">The material on the deeper side.</param>
/// <param name="blend">0 = fully idNear, 1 = fully idFar.</param>
public static void GetBlendedVoxelIDs(Biome biome, bool isRoad, float depthBelowSurface,
float blendBand, out byte idNear, out byte idFar, out float blend)
{
// Above the surface can happen by a hair on interpolated vertices; treat as surface.
float depth = Mathf.Max(depthBelowSurface, 0.0f);
byte skinID = GetSkinMaterial(biome, isRoad);
byte shallowID = GetShallowMaterial(biome);
// Two boundaries exist: skin->shallow and shallow->stone. Work out
// which one we are closest to and fade across that one.
if (depth < (SKIN_DEPTH_METERS + STONE_DEPTH_METERS) * 0.5f)
{
idNear = skinID;
idFar = shallowID;
blend = Fade(depth, SKIN_DEPTH_METERS, blendBand);
}
else
{
idNear = shallowID;
idFar = BlockRegistry.STONE;
blend = Fade(depth, STONE_DEPTH_METERS, blendBand);
}
}
/// <summary>
/// 0 before the boundary, 1 after it, smoothly eased across a band of
/// <paramref name="band"/> metres centred on the boundary.
/// </summary>
private static float Fade(float depth, float boundary, float band)
{
// A band of zero means "behave exactly like the old hard switch".
if (band <= 0.0f) return depth < boundary ? 0.0f : 1.0f;
float t = Mathf.Clamp((depth - (boundary - band * 0.5f)) / band, 0.0f, 1.0f);
return t * t * (3.0f - 2.0f * t); // smoothstep — eases in and out
}
/// <summary>The topmost material: what you actually walk on.</summary>
private static byte GetSkinMaterial(Biome biome, bool isRoad)
{
if (isRoad) return BlockRegistry.ASPHALT;
switch (biome)
{
case Biome.Beach:
case Biome.Crater: return BlockRegistry.SAND;
case Biome.Snow:
case Biome.Mountain: return BlockRegistry.SNOW;
case Biome.Wasteland: return BlockRegistry.WASTELAND_DIRT;
case Biome.Jungle: return BlockRegistry.GRASS_JUNGLE;
case Biome.Paradise: return BlockRegistry.GRASS_PARADISE;
case Biome.Tropical: return BlockRegistry.GRASS_TROPICAL;
default: return BlockRegistry.DIRT;
}
}
/// <summary>The layer just underneath the skin, before stone takes over.</summary>
private static byte GetShallowMaterial(Biome biome)
{
switch (biome)
{
// Sand goes all the way down on beaches and in the crater.
case Biome.Beach:
case Biome.Crater: return BlockRegistry.SAND;
// Mountains have essentially no topsoil — straight to rock.
case Biome.Snow:
case Biome.Mountain: return BlockRegistry.STONE;
case Biome.Wasteland: return BlockRegistry.WASTELAND_DIRT;
default: return BlockRegistry.DIRT;
}
}
} }
} }

View file

@ -17,6 +17,21 @@ namespace IslaApocalypse.Core
public float[,,] Densities = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_HEIGHT + 1, Constants.CHUNK_SIZE_Z + 1]; public float[,,] Densities = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_HEIGHT + 1, Constants.CHUNK_SIZE_Z + 1];
public byte[,,] BlockIDs = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_HEIGHT + 1, Constants.CHUNK_SIZE_Z + 1]; public byte[,,] BlockIDs = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_HEIGHT + 1, Constants.CHUNK_SIZE_Z + 1];
// --- PER-COLUMN DATA, FOR VERTEX COLOURING ONLY ----------------------
// One entry per X/Z column of this chunk (same +1 padding as above).
//
// Why these exist: BlockIDs are classified against a ROUNDED surface
// height, which is fine for storage but makes the rendered colour jump
// in whole-metre steps (the "contour band" artefact). The renderer
// instead reads the TRUE, unrounded surface height from here, so the
// material fade follows the real ground.
//
// These are read by the mesher only. Nothing about geometry, collision
// or storage depends on them.
public float[,] SurfaceHeights = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
public Biome[,] ColumnBiomes = new Biome[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
public bool[,] ColumnIsRoad = new bool[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
// For our future delta-save system // For our future delta-save system
public bool IsPlayerProtected = false; public bool IsPlayerProtected = false;
public bool NeedsSaving = false; public bool NeedsSaving = false;

View file

@ -10,5 +10,18 @@ namespace IslaApocalypse.Core
// World Generation // World Generation
public const float ISO_LEVEL = 0.0f; // The threshold for Marching Cubes public const float ISO_LEVEL = 0.0f; // The threshold for Marching Cubes
public const float VOXEL_SCALE = 1.0f; // 1 Pixel = 1 Meter public const float VOXEL_SCALE = 1.0f; // 1 Pixel = 1 Meter
// --- APPEARANCE TUNING ---------------------------------------------
// How many metres it takes for one surface material to fade into the
// next (grass -> dirt -> stone) on the rendered mesh.
//
// TUNE THIS BY EYE: bigger = softer, more gradual fade.
// 0.0 = hard switch, exactly like the old banded look
// 2.0 = default, a gentle two-metre blend
// 4.0+ = very soft, materials smear into each other
//
// Colour only. This does NOT affect terrain shape, collision, or what
// block is actually stored in a voxel.
public const float BLEND_BAND_METERS = 2.0f;
} }
} }

View file

@ -403,7 +403,17 @@ namespace IslaApocalypse.Core
return new Vector3(gx, gy, gz).Normalized(); return new Vector3(gx, gy, gz).Normalized();
} }
public static ArrayMesh GenerateMesh(float[,,] scalarField, byte[,,] blockIDs, float isolevel = 0.0f, float voxelScale = 1.0f) /// <summary>
/// Builds the chunk mesh.
/// </summary>
/// <param name="blockIDs">Kept for non-visual use and as a colour fallback; the vertex
/// colour itself now comes from the per-column data below, which is not rounded.</param>
/// <param name="surfaceHeights">True (unrounded) surface height per X/Z column.</param>
/// <param name="columnBiomes">Biome per X/Z column.</param>
/// <param name="columnIsRoad">Whether each X/Z column is roadbed.</param>
public static ArrayMesh GenerateMesh(float[,,] scalarField, byte[,,] blockIDs,
float[,] surfaceHeights, Biome[,] columnBiomes, bool[,] columnIsRoad,
float isolevel = 0.0f, float voxelScale = 1.0f)
{ {
var st = new SurfaceTool(); var st = new SurfaceTool();
st.Begin(Mesh.PrimitiveType.Triangles); st.Begin(Mesh.PrimitiveType.Triangles);
@ -479,11 +489,32 @@ namespace IslaApocalypse.Core
float mu = (isolevel - valA) / (valB - valA); float mu = (isolevel - valA) / (valB - valA);
Vector3 exactNormal = (gradA + mu * (gradB - gradA)).Normalized(); Vector3 exactNormal = (gradA + mu * (gradB - gradA)).Normalized();
// 4. THE COLOR FIX: Grab the BlockID from CornerA and paint the vertex! // 4. THE COLOUR: fade between materials using the TRUE surface height.
// (We just reuse the gridPosA we already declared above the if-statement!) //
byte blockID = blockIDs[gridPosA.X, gridPosA.Y, gridPosA.Z]; // The old code read a BlockID here. That ID had been classified
// against a ROUNDED surface height, so the colour flipped between
// grass and dirt depending on which way the rounding fell — which
// is what drew those horizontal contour bands across slopes.
//
// Now we ask: how far below this column's real, unrounded surface
// does this exact vertex sit? Then we fade between the two
// materials either side of the nearest boundary.
int colX = gridPosA.X;
int colZ = gridPosA.Z;
st.SetColor(GetBlockColor(blockID)); // exactPos is scaled by voxelScale, so divide it back out to get
// the height in the same units the surface heights are stored in.
float vertexY = exactPos.Y / voxelScale;
float depthBelowSurface = surfaceHeights[colX, colZ] - vertexY;
BiomePalette.GetBlendedVoxelIDs(
columnBiomes[colX, colZ],
columnIsRoad[colX, colZ],
depthBelowSurface,
Constants.BLEND_BAND_METERS,
out byte idNear, out byte idFar, out float blendAmount);
st.SetColor(GetBlockColor(idNear).Lerp(GetBlockColor(idFar), blendAmount));
// 5. Feed the perfect normal AND the position to Godot! // 5. Feed the perfect normal AND the position to Godot!
// (SetColor and SetNormal MUST be called immediately before AddVertex) // (SetColor and SetNormal MUST be called immediately before AddVertex)

View file

@ -174,6 +174,13 @@ namespace IslaApocalypse.Server
Biome columnBiome = _blueprint.BiomeMap[globalX, globalZ]; Biome columnBiome = _blueprint.BiomeMap[globalX, globalZ];
// Hand the renderer the TRUE surface height for this column, plus the
// biome and road flag that go with it. The BlockIDs below still use the
// rounded height (unchanged) — but the colour no longer has to.
newChunk.SurfaceHeights[x, z] = exactSurfaceY;
newChunk.ColumnBiomes[x, z] = columnBiome;
newChunk.ColumnIsRoad[x, z] = isMainRoad;
float hRight = surfaceRight - exactSurfaceY; float hRight = surfaceRight - exactSurfaceY;
float hFwd = surfaceFwd - exactSurfaceY; float hFwd = surfaceFwd - exactSurfaceY;
float slopeX = hRight / Constants.VOXEL_SCALE; float slopeX = hRight / Constants.VOXEL_SCALE;