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. Stations are spaced by arc length, 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 atlas texel coordinates: 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 curve's 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 _vertices = new List(); readonly List _uvs = new List(); readonly List _uv2s = new List(); readonly List _indices = new List(); 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 along 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 ) ); } } }