rokojori_action_library/Runtime/Procedural/HeightMaps/Mesh/HeightMapRibbonBuilder.cs

540 lines
17 KiB
C#
Raw Normal View History

2026-11-12 14:28:42 +00:00
using System.Collections.Generic;
using Godot;
using System;
using Rokojori.Extensions;
namespace Rokojori
{
/** Turns a Spline into the ribbon mesh that HeightMapMeshAtlas rasterizes.
This is the line-renderer half of the spline layer: a quad strip walked
along the curve, widened to the per-point `width` data, with one span of
the strip per texture instance. It is the same technique LineVFX uses,
with two differences - the strip is flat in XZ because the map is a height
field, and the deformation happens here on the CPU rather than in a shader,
because there is nothing to animate and the vertex count is in the
hundreds.
<marked>Stations are spaced by arc length</marked>, not by point index. A
rational bezier does not travel at a constant rate along its parameter, so
stepping the parameter evenly would both bunch the geometry up in the tight
parts of the curve and stretch the texture there - see
SplineArcLengthTable, which exists for this.
Everything is emitted in <marked>atlas texel coordinates</marked>: X is the
atlas column, Y is the negated atlas row, Z is the normalized height. That
makes the mesh independent of the atlas's final size - only the camera
depends on it - and it is what lets the atlas pack regions first and size
itself afterwards. Negated Y because image row 0 is at the top of the
camera's view, so rows have to grow downwards in world space.
*/
public class HeightMapRibbonBuilder
{
/** Vertex rows per span. Fixed rather than derived from curvature, because
the strip is rasterized into texels and a span is short - one texture
instance - so the chord error is already far below a texel for anything
that is not a hairpin.
*/
public int rowsPerSpan = 24;
/** How much arc length one copy of the middle texture covers. 0 maps one
copy onto each spline segment instead, which keeps the authoring simple
at the cost of stretching the texture on an unevenly spaced spline.
*/
public float middleTileLength = 0;
/** Atlas slots, as HeightMapRibbonAtlas packs them: 0 start, 1 middle,
2 end, in one horizontal row of square slots.
*/
public int slots = 3;
public int slotSize = 512;
public const int SlotStart = 0;
public const int SlotMiddle = 1;
public const int SlotEnd = 2;
const int MaxRepeatsPerSegment = 64;
/* THE SPLINE STATE
------------------------------------------------------------------------
Resolved once per render by Prepare(), because the curve, the length table
and the widths are all needed twice - once to find the layer's texel rect
and once to build the mesh into the region that rect got.
*/
SplineCurve _curve;
SplineArcLengthTable _table;
float[] _widths;
bool _closed;
public float length => _table == null ? 0 : _table.length;
public int numSegments => _table == null ? 0 : _table.numSegments;
public SplineArcLengthTable table => _table;
/** Reads the spline into a curve, an arc-length table and a per-curve-point
width array. False when there is nothing to draw.
The width array is sized to the <marked>curve's</marked> points, not the
spline's: a closed spline's curve carries one extra point duplicating
index 0, so the last entry duplicates the first width too and the seam
interpolates instead of jumping.
*/
public bool Prepare( Spline spline, string widthDataName, float defaultWidth, int samplesPerSegment )
{
_curve = null;
_table = null;
_widths = null;
if ( spline == null )
{
return false;
}
// The curve is cached inside the Spline and nothing invalidates it when a
// point moves outside the editor's gizmo pass, so it is dropped here.
spline.ClearCurveCache();
var points = spline.GetPoints();
if ( points.Count < 2 )
{
return false;
}
_closed = spline.closed;
_curve = spline.GetCurve();
if ( _curve == null || _curve.points.Count < 2 )
{
_curve = null;
return false;
}
_table = SplineArcLengthTable.Create( _curve, samplesPerSegment );
if ( _table.length <= 0.000001f )
{
_curve = null;
_table = null;
return false;
}
_widths = new float[ _curve.points.Count ];
for ( var i = 0; i < _widths.Length; i++ )
{
var point = points[ i % points.Count ];
_widths[ i ] = Mathf.Max( 0, spline.ResolveFloatData( point, widthDataName, defaultWidth ) );
}
return true;
}
/** Linearly interpolated between the two surrounding points, which is what
"the values are interpolated from one point to the other" means - the
width follows the point spacing, not the curve's own parameter speed.
*/
public float WidthAtPointIndex( float pointIndex )
{
var maxIndex = _widths.Length - 1;
var clamped = Mathf.Clamp( pointIndex, 0, maxIndex );
var lower = Mathf.FloorToInt( clamped );
var upper = Mathf.Min( lower + 1, maxIndex );
return Mathf.Lerp( _widths[ lower ], _widths[ upper ], clamped - lower );
}
public float WidthAtDistance( float distance )
{
return WidthAtPointIndex( _table.PointIndexAtDistance( distance ) );
}
float tangentDelta => Mathf.Max( 0.01f, _table.length * 0.001f );
/** The ribbon's world XZ bounds, including the half width at every station.
Sampled at the same density the mesh will use, so the rect cannot end up
smaller than the geometry that goes into it.
*/
public bool GetWorldBounds( out Vector2 min, out Vector2 max )
{
min = Vector2.Zero;
max = Vector2.Zero;
if ( _table == null )
{
return false;
}
var total = _table.length;
var stations = Mathf.Max( 2, numSegments * Mathf.Max( 2, rowsPerSpan ) );
var first = true;
for ( var i = 0; i < stations; i++ )
{
var distance = total * i / (float) ( stations - 1 );
var position = _table.PositionAtDistance( distance );
var halfWidth = WidthAtDistance( distance ) / 2;
var center = new Vector2( position.X, position.Z );
var radius = new Vector2( halfWidth, halfWidth );
if ( first )
{
min = center - radius;
max = center + radius;
first = false;
continue;
}
min = min.Min( center - radius );
max = max.Max( center + radius );
}
return ! first;
}
/* THE MESH */
readonly List<Vector3> _vertices = new List<Vector3>();
readonly List<Vector2> _uvs = new List<Vector2>();
readonly List<Vector2> _uv2s = new List<Vector2>();
readonly List<int> _indices = new List<int>();
long _signature;
/** Changes whenever the emitted geometry does. The atlas compares these to
decide whether the GPU pass has to run at all - moving a circle layer in
the same map then costs no draw.
*/
public long signature => _signature;
/** One ArrayMesh, one surface. The texture choice is in the UVs rather than
in a material, so a ribbon crossing from the start texture into the
middle one is still a single draw.
`rect` is the layer's texel rect in the map, `regionOrigin` where that
rect was packed into the atlas. The two have the same size by
definition, which is what keeps the later blit a 1:1 texel copy.
*/
public ArrayMesh BuildMesh( HeightMapGenerator generator, Rect2I rect, Vector2I regionOrigin )
{
_vertices.Clear();
_uvs.Clear();
_uv2s.Clear();
_indices.Clear();
_signature = HashSeed;
if ( _table == null )
{
_signature = 0;
return null;
}
HashLong( regionOrigin.X );
HashLong( regionOrigin.Y );
HashLong( rect.Size.X );
HashLong( rect.Size.Y );
// World XZ -> map texel -> atlas texel. The map texel offset of the rect
// is subtracted, so a layer whose rect the map's edge clipped still lands
// at the region's origin.
var texelOffset = new Vector2(
regionOrigin.X - rect.Position.X,
regionOrigin.Y - rect.Position.Y
);
var segments = numSegments;
for ( var segment = 0; segment < segments; segment++ )
{
var slot = SlotFor( segment, segments );
var segmentStart = _table.DistanceAtPointIndex( segment );
var segmentEnd = _table.DistanceAtPointIndex( segment + 1 );
var segmentLength = segmentEnd - segmentStart;
if ( segmentLength <= 0.000001f )
{
continue;
}
var repeats = RepeatsFor( slot, segmentLength );
for ( var repeat = 0; repeat < repeats; repeat++ )
{
var from = Mathf.Lerp( segmentStart, segmentEnd, repeat / (float) repeats );
var to = Mathf.Lerp( segmentStart, segmentEnd, ( repeat + 1 ) / (float) repeats );
BuildSpan( generator, texelOffset, slot, from, to );
}
}
if ( _indices.Count == 0 )
{
_signature = 0;
return null;
}
// Constant and facing the camera. The material is unshaded, so nothing
// reads them - they are here because a surface without normals is an
// unusual vertex format to hand a spatial shader, and the array costs
// nothing at these vertex counts.
var normals = new Vector3[ _vertices.Count ];
for ( var i = 0; i < normals.Length; i++ )
{
normals[ i ] = Vector3.Back;
}
var arrays = new Godot.Collections.Array();
arrays.Resize( (int) Mesh.ArrayType.Max );
arrays[ (int) Mesh.ArrayType.Vertex ] = _vertices.ToArray();
arrays[ (int) Mesh.ArrayType.Normal ] = normals;
arrays[ (int) Mesh.ArrayType.TexUV ] = _uvs.ToArray();
arrays[ (int) Mesh.ArrayType.TexUV2 ] = _uv2s.ToArray();
arrays[ (int) Mesh.ArrayType.Index ] = _indices.ToArray();
var mesh = new ArrayMesh();
mesh.AddSurfaceFromArrays( Mesh.PrimitiveType.Triangles, arrays );
return mesh;
}
/** First segment takes the start texture, last one the end texture, the
rest the middle one.
A closed spline has no first or last segment to speak of - its ends meet
- so it is all middle, which also keeps the seam from showing two
different textures.
*/
int SlotFor( int segment, int segments )
{
if ( _closed )
{
return SlotMiddle;
}
if ( segment == 0 )
{
return SlotStart;
}
if ( segment == segments - 1 )
{
return SlotEnd;
}
return SlotMiddle;
}
int RepeatsFor( int slot, float segmentLength )
{
if ( slot != SlotMiddle || middleTileLength <= 0.000001f )
{
return 1;
}
var repeats = Mathf.RoundToInt( segmentLength / middleTileLength );
return Mathf.Clamp( repeats, 1, MaxRepeatsPerSegment );
}
/** One texture instance: a quad strip from `from` to `to` in arc length,
with V running 0..1 across it and U running 0..1 across the width.
Spans do not share their boundary rows. The two rows at a seam are
emitted twice, once with V = 1 and once with V = 0, which is what lets
the texture restart exactly without any fract() in the sampler and
therefore without a filtering seam.
*/
void BuildSpan( HeightMapGenerator generator, Vector2 texelOffset, int slot, float from, float to )
{
var rows = Mathf.Max( 1, rowsPerSpan );
var delta = tangentDelta;
var total = _table.length;
var firstVertex = _vertices.Count;
var lastNormal = new Vector2( 1, 0 );
for ( var row = 0; row <= rows; row++ )
{
var v = row / (float) rows;
var distance = Mathf.Lerp( from, to, v );
var position = _table.PositionAtDistance( distance );
var direction = _table.DirectionAtDistance( distance, delta );
var directionXZ = new Vector2( direction.X, direction.Z );
// A vertical tangent has no XZ direction to be perpendicular to, so the
// previous row's normal is carried over rather than snapping.
if ( directionXZ.LengthSquared() > 0.000001f )
{
directionXZ = directionXZ.Normalized();
lastNormal = new Vector2( -directionXZ.Y, directionXZ.X );
}
var normal = lastNormal;
var halfWidth = WidthAtDistance( distance ) / 2;
var center = new Vector2( position.X, position.Z );
var level = Mathf.Clamp( generator.NormalizeLevel( position.Y ), 0, 1 );
var w = total <= 0.000001f ? 0 : distance / total;
AddVertex( generator, texelOffset, center - normal * halfWidth, level, slot, 0, v, w );
AddVertex( generator, texelOffset, center + normal * halfWidth, level, slot, 1, v, w );
}
for ( var row = 0; row < rows; row++ )
{
var a = firstVertex + row * 2;
var b = a + 1;
var c = a + 2;
var d = a + 3;
_indices.Add( a ); _indices.Add( b ); _indices.Add( c );
_indices.Add( b ); _indices.Add( d ); _indices.Add( c );
}
}
void AddVertex(
HeightMapGenerator generator, Vector2 texelOffset, Vector2 worldXZ,
float level, int slot, float u, float v, float w
)
{
var texel = generator.WorldXZToTexel( worldXZ ) + texelOffset;
// Negated Y: the camera looks at the atlas from the front with Y up, so
// atlas row 0 has to sit at the top of its view.
var vertex = new Vector3( texel.X, -texel.Y, level );
_vertices.Add( vertex );
_uvs.Add( AtlasUV( slot, u, v ) );
_uv2s.Add( new Vector2( w, 0 ) );
HashFloat( vertex.X );
HashFloat( vertex.Y );
HashFloat( vertex.Z );
HashFloat( u );
HashFloat( v );
HashFloat( w );
HashLong( slot );
}
/** Slot-local 0..1 mapped into the slot's own texels, inset by half a texel
on every side.
The inset is what keeps a linear tap at a slot's border from bleeding the
neighbouring slot in - the slots sit directly against each other, so the
start texture's edge is the middle texture's edge.
*/
Vector2 AtlasUV( int slot, float u, float v )
{
var size = Mathf.Max( 2, slotSize );
var width = Mathf.Max( 1, slots ) * size;
var x = ( slot * size + 0.5f + u * ( size - 1 ) ) / width;
var y = ( 0.5f + v * ( size - 1 ) ) / size;
return new Vector2( x, y );
}
/* THE LEVEL LOOKUP
------------------------------------------------------------------------
The buffer the mesh writes has three usable channels and four things to
carry, so the height level does not fit in it - an opaque spatial material
cannot write alpha, and making the material transparent to get the channel
back would blend the other three by it and skip the depth write.
So the level travels as a function of W instead: a 1D table the blit
samples with the W it read out of the buffer. That works because a
ribbon's level only ever varies <marked>along</marked> the line, never
across it, and W is exactly the along-coordinate. The table is float, so
the height keeps its full precision regardless of what the render target
turned out to be.
*/
ImageTexture _levelTexture;
Image _levelImage;
int _levelSize;
public Texture2D levelTexture => _levelTexture;
/** Rebuilt every render - it is one row of a few hundred texels, and
tracking what would have invalidated it costs more than redoing it.
*/
public void BuildLevels( HeightMapGenerator generator, int size )
{
if ( _table == null )
{
return;
}
var wanted = Mathf.Clamp( size, 2, 4096 );
if ( _levelImage == null || _levelSize != wanted )
{
// Rgbaf rather than a packed byte format: this is the one value in the
// whole path that may not be quantized, since the map itself is half
// float precisely because 8 bits terrace a slope visibly.
_levelImage = Image.CreateEmpty( wanted, 1, false, Image.Format.Rgbaf );
_levelTexture = null;
_levelSize = wanted;
}
var total = _table.length;
for ( var i = 0; i < wanted; i++ )
{
var distance = total * i / (float) ( wanted - 1 );
var position = _table.PositionAtDistance( distance );
var level = Mathf.Clamp( generator.NormalizeLevel( position.Y ), 0, 1 );
_levelImage.SetPixel( i, 0, new Color( level, level, level, 1 ) );
}
if ( _levelTexture == null )
{
_levelTexture = ImageTexture.CreateFromImage( _levelImage );
}
else
{
_levelTexture.Update( _levelImage );
}
}
/* HASHING
------------------------------------------------------------------------
FNV-1a over the raw bits, so two runs that produce the same geometry
produce the same signature and the GPU pass can be skipped.
*/
const long HashSeed = unchecked( (long) 0xcbf29ce484222325 );
void HashLong( long value )
{
_signature = unchecked( ( _signature ^ value ) * 0x100000001b3 );
}
void HashFloat( float value )
{
HashLong( BitConverter.SingleToInt32Bits( value ) );
}
}
}