diff --git a/application/driveruipanels.cpp b/application/driveruipanels.cpp index 68ff5965..9548dd1e 100644 --- a/application/driveruipanels.cpp +++ b/application/driveruipanels.cpp @@ -1524,8 +1524,8 @@ debug_panel::update_section_renderer( std::vector &Output ) { auto const last { level + 1 == field.levels }; levels += " L" + std::to_string( level ) - + ( last ? " beyond " + to_string( terrain_level_distance( level > 0 ? level - 1 : 0 ), 0 ) - : " <" + to_string( terrain_level_distance( level ), 0 ) ) + + ( last ? " beyond " + to_string( terrain_level_distance( level > 0 ? level - 1 : 0, field.tilesize ), 0 ) + : " <" + to_string( terrain_level_distance( level, field.tilesize ), 0 ) ) + " m (" + to_string( field.gridstep * static_cast( 1u << level ), 0 ) + " m grid): " + std::to_string( stats.perlevel[ level ] ); if( false == last ) { levels += " |"; } diff --git a/rendering/terrainclipmap.cpp b/rendering/terrainclipmap.cpp index ef1ec798..b2fec31d 100644 --- a/rendering/terrainclipmap.cpp +++ b/rendering/terrainclipmap.cpp @@ -110,6 +110,7 @@ terrain_clipmap::resolve_uniforms() { m_uniforms.heightscale = ::glGetUniformLocation( program, "heightscale" ); m_uniforms.nodata = ::glGetUniformLocation( program, "nodata" ); m_uniforms.side = ::glGetUniformLocation( program, "side" ); + m_uniforms.morph = ::glGetUniformLocation( program, "morph" ); m_uniforms.heights = ::glGetUniformLocation( program, "heights" ); m_uniforms.materials = ::glGetUniformLocation( program, "materials" ); m_uniforms.palette = ::glGetUniformLocation( program, "palette" ); @@ -207,8 +208,9 @@ terrain_clipmap::build_palette() { std::uint32_t terrain_clipmap::level_for( double const Distance ) const { - if( Distance <= terrain_finest_range ) { return 0; } - auto const steps { static_cast( std::log2( Distance / terrain_finest_range ) ) + 1 }; + auto const finest { terrain_level_distance( 0, m_reader.tilesize() ) }; + if( Distance <= finest ) { return 0; } + auto const steps { static_cast( std::log2( Distance / finest ) ) + 1 }; return std::min( steps, m_reader.levels() - 1 ); } @@ -323,7 +325,7 @@ terrain_clipmap::update( glm::dvec3 const &Viewpoint ) { auto const moved { glm::length( glm::dvec2 { Viewpoint.x - m_scanpoint.x, Viewpoint.z - m_scanpoint.z } ) }; if( ( m_range != m_scanrange ) - || ( moved > m_reader.tilesize() * 0.25 ) ) { + || ( moved > m_reader.tilesize() * terrain_rescan_tiles ) ) { scan( Viewpoint ); } @@ -480,6 +482,18 @@ terrain_clipmap::render( glm::dvec3 const &Viewpoint ) { ::glUniform3fv( m_uniforms.tileorigin, 1, &origin.x ); ::glUniform1f( m_uniforms.samplestep, gridstep * static_cast( 1u << tile.level ) ); ::glUniform1i( m_uniforms.side, static_cast( tile.side ) ); + // the band over which this level turns into the next. the coarsest level has no + // next one, so it never morphs + if( tile.level + 1 < m_reader.levels() ) { + ::glUniform2f( + m_uniforms.morph, + static_cast( terrain_morph_begin( tile.level, tilesize ) ), + static_cast( terrain_morph_end( tile.level, tilesize ) ) ); + } + else { + // far enough to never be reached, with a band that does not divide by zero + ::glUniform2f( m_uniforms.morph, 1e30f, 2e30f ); + } ::glUniform2f( m_uniforms.tileworld, static_cast( tile.x * tilesize ), static_cast( tile.z * tilesize ) ); diff --git a/rendering/terrainclipmap.cppm b/rendering/terrainclipmap.cppm index c344a526..ea6c69ae 100644 --- a/rendering/terrainclipmap.cppm +++ b/rendering/terrainclipmap.cppm @@ -146,7 +146,7 @@ private: // resolved once at link time; -1 for a uniform the compiler dropped, which gl ignores struct uniform_locations { int tileorigin { -1 }, samplestep { -1 }; - int heightbias { -1 }, heightscale { -1 }, nodata { -1 }, side { -1 }; + int heightbias { -1 }, heightscale { -1 }, nodata { -1 }, side { -1 }, morph { -1 }; int heights { -1 }, materials { -1 }, palette { -1 }; int groundtexture { -1 }, groundscale { -1 }, drawnmaterial { -1 }, tileworld { -1 }; int hastexture { -1 }, fallback { -1 }; diff --git a/rendering/terrainstatus.cppm b/rendering/terrainstatus.cppm index 0e7d45d8..33cd2794 100644 --- a/rendering/terrainstatus.cppm +++ b/rendering/terrainstatus.cppm @@ -19,14 +19,42 @@ export module eu07.rendering.terrainstatus; export { -// distance out to which a terrain tile is drawn at its finest level; each further level -// doubles it -inline constexpr double terrain_finest_range { 512.0 }; +// distance out to which a terrain tile is drawn at its finest level, in tile sides; each +// further level doubles it. four sides is 512 m for the usual 128 m tile. the ranges are +// measured in tiles rather than metres because the morphing below only closes the seams +// when a level's range is several tiles long, whatever the tile size +inline constexpr double terrain_finest_tiles { 4.0 }; -// the distance up to which a level is used +// how far the camera may move before the tiles in range and their levels are worked out +// again, in tile sides +inline constexpr double terrain_rescan_tiles { 0.25 }; + +// the distance up to which a level is used, for tiles of the given side inline double -terrain_level_distance( std::uint32_t const Level ) { - return terrain_finest_range * std::pow( 2.0, Level ); +terrain_level_distance( std::uint32_t const Level, double const Tilesize ) { + return terrain_finest_tiles * Tilesize * std::pow( 2.0, Level ); +} + +// A tile morphs into the next level over a band at the far end of its level's range. Two +// neighbours at levels L and L+1 then meet exactly when, all along their shared edge, the +// finer tile has finished morphing and the coarser one has not started. The band ends a +// rescan's worth of travel short of the range, because levels are chosen where the camera +// was at the last rescan while the morph follows the camera every frame; and it is a fifth +// of the range wide, which keeps the coarser tile's band, a whole range further out, clear of +// any edge a finer tile can reach. Both were settled by a geometric test over random camera +// positions and three tile sizes, which finds no gap with these values and finds them at +// every level boundary with a band ending at the range itself. +inline constexpr double terrain_morph_band { 0.2 }; + +// the distances, from the camera, over which a level L tile morphs into level L+1 +inline double +terrain_morph_end( std::uint32_t const Level, double const Tilesize ) { + return terrain_level_distance( Level, Tilesize ) - terrain_rescan_tiles * Tilesize; +} + +inline double +terrain_morph_begin( std::uint32_t const Level, double const Tilesize ) { + return terrain_morph_end( Level, Tilesize ) - terrain_morph_band * terrain_level_distance( Level, Tilesize ); } // what one terrain field's streamer is doing, kept by the field and read by the debug panel diff --git a/scene/terraincooker.h b/scene/terraincooker.h index f9ad69ad..33954210 100644 --- a/scene/terraincooker.h +++ b/scene/terraincooker.h @@ -485,8 +485,9 @@ private: // lays the mip chain of one tile into the buffer, finest level first. // coarse levels are point sampled rather than averaged, deliberately: every level // keeps the samples that sit on the tile boundary exactly as its neighbour has them, - // so the seam between two tiles stays identical at every level instead of needing - // skirts to hide a mismatch. the cost is aliasing on distant terrain + // so two tiles at the same level meet exactly, and a level's even samples are the next + // level's samples, which is what lets the renderer morph one level into the next from + // a single texture. the cost is aliasing on distant terrain void encode( tile const &Tile, double const Scale, std::vector &Buffer ) const { diff --git a/shaders/terrainclipmap.vert b/shaders/terrainclipmap.vert index 78f49cdb..cc04019d 100644 --- a/shaders/terrainclipmap.vert +++ b/shaders/terrainclipmap.vert @@ -3,6 +3,12 @@ // There is no vertex buffer: the grid exists only as an index buffer, and a sample's // row and column come from gl_VertexID. Heights are read as unsigned integers so that // the cooker's no-data value survives unchanged instead of being normalised into a float. +// +// Levels meet without cracks and change without popping because each vertex morphs into the +// next coarser level as its distance nears the end of its level's range (CDLOD). The mip +// chain is point sampled, so the next level's grid is this level's even samples: an odd +// vertex slides onto its even neighbour and takes its height, and by the end of the range +// the tile is, vertex for vertex, the coarser tile that lies beyond it. #include @@ -16,6 +22,7 @@ uniform float heightbias; // world height of stored value 0 uniform float heightscale; // metres per stored unit uniform uint nodata; uniform int side; // samples along a tile side at this level +uniform vec2 morph; // distance at which morphing into the next level starts, and where it is complete uniform vec2 tileworld; // tile origin in world space, for texture coordinates out vec4 f_pos; @@ -71,6 +78,17 @@ void main() } vec3 local = tileorigin + vec3( float( column ) * samplestep, height, float( row ) * samplestep ); + // the viewpoint is at the horizontal origin, so the distance is the length of local.xz, + // measured as the renderer measures it when choosing the level + float morphing = clamp( ( length( local.xz ) - morph.x ) / ( morph.y - morph.x ), 0.0, 1.0 ); + if( morphing > 0.0 ) { + ivec2 coarse = texel - ( texel % 2 ); + uint coarseraw = texelFetch( heights, coarse, 0 ).r; + float coarseheight = ( coarseraw == nodata ) ? borrowed_height( coarse ) : heightbias + float( coarseraw ) * heightscale; + vec2 grid = mix( vec2( texel ), vec2( coarse ), morphing ); + local = tileorigin + vec3( grid.x * samplestep, mix( height, coarseheight, morphing ), grid.y * samplestep ); + } + // central differences over the neighbouring samples; at a tile edge the clamp repeats // the border sample, which is what the neighbouring tile holds there as well float west = sample_height( texel - ivec2( 1, 0 ), height ); @@ -81,7 +99,7 @@ void main() // world coordinates, so the ground texture stays put as the camera moves and as the // tile changes mip level - f_ground = tileworld + vec2( float( column ), float( row ) ) * samplestep; + f_ground = tileworld + ( local.xz - tileorigin.xz ); f_pos = modelview * vec4( local, 1.0 ); f_normal = normalize( modelviewnormal * normal ); diff --git a/tools/terraincook/seamcheck.py b/tools/terraincook/seamcheck.py new file mode 100755 index 00000000..3024f815 --- /dev/null +++ b/tools/terraincook/seamcheck.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Checks that terrain tiles at neighbouring levels meet without gaps. + +The renderer picks each tile's level from its distance at the last rescan, and morphs every +vertex towards the next level as the camera moves (rendering/terrainclipmap.vert). Two +neighbours at levels L and L+1 share their edge exactly when, all along it, the finer tile +has finished morphing and the coarser one has not started. This walks random camera +positions, lets the camera drift up to a rescan's distance from where the levels were +chosen, and counts every edge vertex where that does not hold. + +The constants mirror rendering/terrainstatus.cppm; change them together. Exits non-zero +when a gap is found. +""" + +import math +import random +import sys + +FINEST_TILES = 4.0 # terrain_finest_tiles +RESCAN_TILES = 0.25 # terrain_rescan_tiles +MORPH_BAND = 0.2 # terrain_morph_band +LEVELS = 6 + + +def check(tilesize, step, trials, reach): + finest = FINEST_TILES * tilesize + + def level_range(level): + return finest * 2 ** level + + def level_for(distance): + if distance <= finest: + return 0 + return min(int(math.log2(distance / finest)) + 1, LEVELS - 1) + + def nearest(x, z, tilex, tilez): + nx = min(max(x, tilex * tilesize), (tilex + 1) * tilesize) + nz = min(max(z, tilez * tilesize), (tilez + 1) * tilesize) + return math.hypot(nx - x, nz - z) + + def morph(level, distance): + if level + 1 >= LEVELS: + return 0.0 + end = level_range(level) - RESCAN_TILES * tilesize + begin = end - MORPH_BAND * level_range(level) + return min(max((distance - begin) / (end - begin), 0.0), 1.0) + + gaps = 0 + example = "" + for _ in range(trials): + scanx, scanz = random.uniform(-5000, 5000), random.uniform(-5000, 5000) + drift, heading = random.uniform(0, RESCAN_TILES * tilesize), random.uniform(0, 2 * math.pi) + camx, camz = scanx + drift * math.cos(heading), scanz + drift * math.sin(heading) + basex, basez = math.floor(camx / tilesize), math.floor(camz / tilesize) + for tilex in range(basex - reach, basex + reach): + for tilez in range(basez - reach, basez + reach): + here = level_for(nearest(scanx, scanz, tilex, tilez)) + for stepx, stepz in ((1, 0), (0, 1)): + there = level_for(nearest(scanx, scanz, tilex + stepx, tilez + stepz)) + if here == there: + continue + if abs(here - there) > 1: + gaps += 1 + continue + fine, coarse = min(here, there), max(here, there) + count = int(tilesize / step) >> fine + for index in range(count + 1): + along = index * tilesize / count + if stepx: + x, z = (tilex + 1) * tilesize, tilez * tilesize + along + else: + x, z = tilex * tilesize + along, (tilez + 1) * tilesize + distance = math.hypot(x - camx, z - camz) + # an odd vertex of the finer tile is not on the coarser grid and has + # to have finished its move; the coarser edge must not be moving yet + if (index % 2 == 1 and morph(fine, distance) < 1.0) or morph(coarse, distance) > 0.0: + gaps += 1 + if not example: + example = (f"camera {camx:.0f},{camz:.0f}, levels {fine}/{coarse}, " + f"distance {distance:.0f} m") + return gaps, example + + +def main(): + random.seed(1) + failed = False + for tilesize, step, trials, reach in ((128, 1.0, 300, 40), (256, 2.0, 300, 40), (12800, 100.0, 60, 6)): + gaps, example = check(tilesize, step, trials, reach) + print(f"tile {tilesize} m, grid {step} m: {gaps} gaps" + (f" (e.g. {example})" if gaps else "")) + failed = failed or gaps > 0 + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())