Component that generates and renders a spline-driven river/stream ribbon mesh. It samples a Spline to produce per-row FlowSample data (position, direction, speed, advected time), builds GPU vertex/index buffers, updates render attributes including exclusion volumes and hulls, provides CPU queries for surface height/flow/containment, and draws gizmos in the editor.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.Rendering;
namespace RedSnail.WaterTool;
/// <summary>
/// Spline-driven flowing water surface (rivers, streams, canals).
///
/// A ribbon mesh is generated along the spline; flow speed is automatically
/// regulated by the spline's slope using a simple energy model: descending
/// sections accelerate the water (gravity), flat sections gradually calm it
/// down (friction). <see cref="InitialVelocity"/> seeds the flow at the source
/// (the first spline point).
///
/// Flow direction and speed are baked per-vertex (in the vertex color) and the
/// water shader makes waves travel downstream and scrolls the surface normals
/// along the flow.
/// </summary>
[Icon("water"), Group("Water"), Title("Water Flow")]
public sealed class WaterFlow : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer
{
#pragma warning disable CS0649
private struct WaterVertex
{
[VertexLayout.Position] public Vector3 Position;
[VertexLayout.Normal] public Vector3 Normal;
[VertexLayout.Tangent] public Vector4 Tangent;
[VertexLayout.TexCoord] public Vector2 TexCoord;
[VertexLayout.Color] public Color Color;
}
#pragma warning restore CS0649
/// <summary>
/// A point along the river centerline with its resolved flow state.
/// </summary>
public struct FlowSample
{
public Vector3 Position; // world-space centerline position
public Vector2 Direction; // world-space flow direction (XY, normalized)
public float Speed; // flow speed in units/s
public float Distance; // distance along the spline
// Travel-time advected along-coordinate: FlowSpeedReference * seconds the water
// takes to reach this row from the source. Waves/textures are parameterized on
// THIS instead of raw distance, so a uniform scroll moves the surface pattern
// at the correct local speed everywhere — scaling time by a per-vertex speed
// would shear the pattern unboundedly over time (compressed bands, patches
// appearing to flow backwards).
public float Advected;
}
private const float BASE_TILE_SIZE = 100.0f;
private const int MAX_ROWS = 4096;
private const int MAX_COLUMNS = 128;
private const int MAX_WATER_EXCLUSION_VOLUMES = 512;
private const int WATER_EXCLUSION_VOLUME_ROWS = 3;
private const int MAX_HULL_EXCLUSION_VOLUMES = 8;
private const int HULL_EXCLUSION_META_ROWS = 6;
private const int HULL_EXCLUSION_META_SIZE = MAX_HULL_EXCLUSION_VOLUMES * HULL_EXCLUSION_META_ROWS;
private const int MAX_HULL_EXCLUSION_TRIS = 16384;
// The spline is authored source -> mouth, in the GameObject's local space.
// Hidden from the inspector — edited through the Water Flow editor tool.
[Property, Hide] public Spline Spline { get; set; } = new();
[Property, Group("General"), Order(0)] public WaterBodyType WaterType { get; set; } = WaterBodyType.River;
[Property, Group("General"), Order(0)] public Material Material { get; set; }
[Property, Group("General"), Step(1), Order(0)] public float Width { get; set { field = value.Clamp(16.0f, 8192.0f); } } = 256.0f;
[Property, Group("General"), Step(1), Order(0)] public float Depth { get; set { field = value.Clamp(1.0f, 4096.0f); } } = 100.0f;
[Property, Group("General"), Order(0)] public float BaseCellSize { get; set { field = value.Clamp(8.0f, 512.0f); } } = 32.0f;
// Flow speed at the source (first spline point), in units/s.
[Property, Group("Flow"), Order(1)] public float InitialVelocity { get; set { field = value.Clamp(0.0f, 2000.0f); } } = 100.0f;
// Acceleration applied while the spline descends — steeper drop = faster water.
[Property, Group("Flow"), Order(1)] public float FlowGravity { get; set { field = value.Clamp(0.0f, 2000.0f); } } = 400.0f;
// Energy lost per unit travelled — calms the water down on flat stretches.
[Property, Group("Flow"), Order(1)] public float FlowFriction { get; set { field = value.Clamp(0.0f, 0.1f); } } = 0.002f;
[Property, Group("Flow"), Order(1)] public float MinFlowSpeed { get; set { field = value.Clamp(0.0f, 500.0f); } } = 20.0f;
[Property, Group("Flow"), Order(1)] public float MaxFlowSpeed { get; set { field = value.Clamp(10.0f, 4000.0f); } } = 800.0f;
// Flow speed (units/s) that reads as "normal agitation" in the shader — sections
// flowing faster than this look rougher, slower sections look calmer.
[Property, Group("Flow"), Order(1)] public float FlowSpeedReference { get; set { field = value.Clamp(1.0f, 2000.0f); } } = 100.0f;
[Property, Group("Texture"), Order(2), Range(0.1f, 2.0f)] public float TextureTilingMultiplier { get; set; } = 1.0f;
private GpuBuffer<WaterVertex> m_VertexBuffer;
private GpuBuffer<uint> m_IndexBuffer;
private int m_TotalIndexCount;
private readonly RenderAttributes m_DrawAttributes = new RenderAttributes();
private GpuBuffer<Vector4> m_WaterExclusionVolumeBuffer;
private readonly Vector4[] m_WaterExclusionVolumeData = new Vector4[MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS];
private GpuBuffer<Vector4> m_HullExclusionBuffer;
private readonly Vector4[] m_HullExclusionData = new Vector4[HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3];
private readonly List<FlowSample> m_Samples = [];
private int m_LastBuildHash;
private bool m_SplineDirty;
/// <summary>Resolved centerline samples (world space) — one per mesh row.</summary>
public IReadOnlyList<FlowSample> Samples => m_Samples;
internal bool ParticipatesInRendering => Material.IsValid() && m_TotalIndexCount > 0;
internal bool HasValidBuffers => m_VertexBuffer.IsValid() && m_IndexBuffer.IsValid();
protected override void OnEnabled()
{
EnsureDefaultSpline();
Spline.SplineChanged += OnSplineChanged;
RebuildMesh();
WaterManager.Current?.RefreshWaterFlowsList();
}
protected override void OnDisabled()
{
Spline.SplineChanged -= OnSplineChanged;
WaterManager.Current?.RefreshWaterFlowsList();
m_VertexBuffer = default;
m_IndexBuffer = default;
m_TotalIndexCount = 0;
m_Samples.Clear();
m_WaterExclusionVolumeBuffer?.Dispose();
m_WaterExclusionVolumeBuffer = null;
m_HullExclusionBuffer?.Dispose();
m_HullExclusionBuffer = null;
}
protected override void OnUpdate()
{
// The source point is pinned to the GameObject origin (see EnforceSourcePoint)
EnforceSourcePoint();
// Keep the mesh and flow samples in sync with the spline even before a
// material is assigned — the editor tool and CPU queries rely on them.
int buildHash = ComputeBuildHash();
if (m_SplineDirty || buildHash != m_LastBuildHash)
RebuildMesh();
if (Material == null)
return;
UpdateShaderAttributes();
}
private void OnSplineChanged()
{
m_SplineDirty = true;
}
// The first spline point is the river's source: its world position IS the
// GameObject's transform, so it's pinned to local (0,0,0). This keeps the source
// from drifting away from the object that owns it — move the GameObject to move
// the source. Enforced here so it holds no matter how the point was edited
// (gizmo, inspector, undo, script). Only the point's tangent/mode stay editable.
private void EnforceSourcePoint()
{
if (Spline.PointCount == 0)
return;
var source = Spline.GetPoint(0);
if (source.Position.LengthSquared > 1e-6f)
{
source.Position = Vector3.Zero;
Spline.UpdatePoint(0, source);
}
}
private void EnsureDefaultSpline()
{
if (Spline.PointCount >= 2)
return;
Spline.Clear();
Spline.AddPoint(new Spline.Point { Position = Vector3.Zero });
Spline.AddPoint(new Spline.Point { Position = new Vector3(512.0f, 0.0f, -16.0f) });
}
[Button("Add Point", "add")]
public void AddPointAtEnd()
{
Vector3 newPosition;
if (Spline.PointCount >= 2)
{
Vector3 last = Spline.GetPoint(Spline.PointCount - 1).Position;
Vector3 previous = Spline.GetPoint(Spline.PointCount - 2).Position;
Vector3 direction = (last - previous).Normal;
newPosition = last + direction * 256.0f;
}
else
{
newPosition = Spline.PointCount > 0
? Spline.GetPoint(Spline.PointCount - 1).Position + new Vector3(256, 0, 0)
: Vector3.Zero;
}
Spline.AddPoint(new Spline.Point { Position = newPosition });
}
[Button("Remove Last Point", "remove")]
public void RemoveLastPoint()
{
if (Spline.PointCount <= 2)
return;
Spline.RemovePoint(Spline.PointCount - 1);
}
private int ComputeBuildHash()
{
var hash = new HashCode();
hash.Add(Width);
hash.Add(BaseCellSize);
hash.Add(InitialVelocity);
hash.Add(FlowGravity);
hash.Add(FlowFriction);
hash.Add(MinFlowSpeed);
hash.Add(MaxFlowSpeed);
hash.Add(FlowSpeedReference);
hash.Add(WorldPosition);
hash.Add(WorldRotation);
for (int i = 0; i < Spline.PointCount; i++)
{
var p = Spline.GetPoint(i);
hash.Add(p.Position);
hash.Add(p.In);
hash.Add(p.Out);
}
return hash.ToHashCode();
}
// ─────────────────────────────────────────────────────────────────────────
// Mesh generation
// ─────────────────────────────────────────────────────────────────────────
private void RebuildMesh()
{
m_SplineDirty = false;
m_LastBuildHash = ComputeBuildHash();
m_Samples.Clear();
m_TotalIndexCount = 0;
if (Spline.PointCount < 2)
return;
float length = Spline.Length;
if (length < 1.0f)
return;
int rows = Math.Clamp((int)MathF.Ceiling(length / BaseCellSize) + 1, 2, MAX_ROWS);
int columns = Math.Clamp((int)MathF.Ceiling(Width / BaseCellSize) + 1, 2, MAX_COLUMNS);
BuildFlowSamples(length, rows);
BuildBuffers(rows, columns);
}
// Walks the spline from source to mouth resolving the flow speed at each row
// with a simple energy model:
// - descending adds kinetic energy (v² += 2 g Δh)
// - friction bleeds energy per unit travelled (v² *= e^(-friction·ds))
// so steep sections rush and long flat stretches settle back down.
private void BuildFlowSamples(float _Length, int _Rows)
{
float step = _Length / (_Rows - 1);
float v2 = InitialVelocity * InitialVelocity;
float previousZ = 0.0f;
Vector2 previousDir = Vector2.Zero;
float flowTime = 0.0f;
for (int r = 0; r < _Rows; r++)
{
float distance = r * step;
Spline.Sample sample = Spline.SampleAtDistance(distance);
Vector3 worldPos = WorldTransform.PointToWorld(sample.Position);
Vector3 worldTangent = WorldRotation * sample.Tangent;
// Flow direction is the horizontal part of the spline tangent
Vector2 dir = new(worldTangent.x, worldTangent.y);
dir = dir.LengthSquared > 1e-6f ? dir.Normal : (previousDir.LengthSquared > 0.0f ? previousDir : new Vector2(1, 0));
if (r > 0)
{
float drop = previousZ - worldPos.z; // positive while descending
v2 += 2.0f * FlowGravity * drop;
v2 *= MathF.Exp(-FlowFriction * step);
}
v2 = Math.Clamp(v2, MinFlowSpeed * MinFlowSpeed, MaxFlowSpeed * MaxFlowSpeed);
float speed = MathF.Sqrt(v2);
// Accumulate the water's travel time from the source (see FlowSample.Advected)
if (r > 0)
flowTime += step / MathF.Max(speed, 1.0f);
m_Samples.Add(new FlowSample
{
Position = worldPos,
Direction = dir,
Speed = speed,
Distance = distance,
Advected = flowTime * FlowSpeedReference
});
previousZ = worldPos.z;
previousDir = dir;
}
}
private void BuildBuffers(int _Rows, int _Columns)
{
int vertexCount = _Rows * _Columns;
var vertices = new WaterVertex[vertexCount];
var indices = new List<uint>((_Rows - 1) * (_Columns - 1) * 6);
for (int r = 0; r < _Rows; r++)
{
FlowSample sample = m_Samples[r];
// Horizontal across vector (the water surface stays level across the channel)
Vector3 across = new(sample.Direction.y, -sample.Direction.x, 0.0f);
for (int c = 0; c < _Columns; c++)
{
float offset = ((float)c / (_Columns - 1) - 0.5f) * Width;
vertices[r * _Columns + c] = new WaterVertex
{
Position = sample.Position + across * offset,
Normal = Vector3.Up,
Tangent = new Vector4(1, 0, 0, 1),
// River-space UV: x = across offset, y = travel-time advected
// along-coordinate (NOT raw distance — see FlowSample.Advected).
// The shader evaluates waves and scrolls uniformly in this space;
// the advection makes the pattern move at the local flow speed.
TexCoord = new Vector2(offset, sample.Advected),
// Flow state: rg = direction, b = speed. Raw floats, decoded by the shader.
Color = new Color(sample.Direction.x, sample.Direction.y, sample.Speed, 1.0f)
};
}
}
// Same winding as the rectangular water grid (across = +X-like, along = +Y-like)
for (int r = 0; r < _Rows - 1; r++)
{
for (int c = 0; c < _Columns - 1; c++)
{
uint i0 = (uint)(r * _Columns + c);
uint i1 = i0 + 1;
uint i2 = i0 + (uint)_Columns;
uint i3 = i2 + 1;
indices.Add(i0); indices.Add(i1); indices.Add(i2);
indices.Add(i1); indices.Add(i3); indices.Add(i2);
}
}
m_VertexBuffer = new GpuBuffer<WaterVertex>(vertexCount, GpuBuffer.UsageFlags.Vertex);
m_VertexBuffer.SetData(vertices);
m_IndexBuffer = new GpuBuffer<uint>(indices.Count, GpuBuffer.UsageFlags.Index);
m_IndexBuffer.SetData(indices);
m_TotalIndexCount = indices.Count;
}
// ─────────────────────────────────────────────────────────────────────────
// Rendering (driven by WaterManager, same pipeline as WaterQuad)
// ─────────────────────────────────────────────────────────────────────────
internal void Draw(CommandList _CommandList)
{
if (!ParticipatesInRendering || !HasValidBuffers)
return;
_CommandList?.DrawIndexed(m_VertexBuffer, m_IndexBuffer, Material, 0, m_TotalIndexCount, m_DrawAttributes);
}
private void UpdateShaderAttributes()
{
m_DrawAttributes.Set("RequireWaterInclusionVolumes", false);
WaterDefinition profile = WaterManager.GetWaveProfile(WaterType);
if (profile.IsValid())
profile.ApplyTo(m_DrawAttributes);
m_DrawAttributes.Set("WaterTime", Time.Now);
m_DrawAttributes.Set("DepthMax", Depth);
// River UVs are raw world units, so the tiling factor alone sets the density
// (one repeat per BASE_TILE_SIZE units, like the static quads).
float tiling = TextureTilingMultiplier / BASE_TILE_SIZE;
m_DrawAttributes.Set("NormalTiling", new Vector2(tiling, tiling));
// Flow mode: waves travel downstream, normals scroll with the current
m_DrawAttributes.Set("FlowWater", true);
m_DrawAttributes.Set("FlowSpeedRef", FlowSpeedReference);
// The river mesh is a uniform grid (no clipmap rings), so the wave-normal
// finite-difference step is simply the cell size everywhere.
m_DrawAttributes.Set("WaveNormalEpsScale", 0.0f);
m_DrawAttributes.Set("WaveNormalEpsMin", BaseCellSize);
WaterManager.Current?.ApplyRippleAttributes(m_DrawAttributes);
WaterManager.Current?.ApplyCalmAttributes(m_DrawAttributes);
SetWaterExclusionVolumes(Scene.Camera.IsValid() ? Scene.Camera.WorldPosition : WorldPosition);
SetHullExclusionVolumes();
}
private void SetWaterExclusionVolumes(Vector3 _ReferencePosition)
{
if (WaterManager.Current == null)
return;
EnsureWaterExclusionVolumeBuffer();
var volumes = WaterManager.Current.ExclusionVolumes
.Where(v => v.IsValid() && v.Active)
.OrderBy(v => v.WorldPosition.DistanceSquared(_ReferencePosition))
.Take(MAX_WATER_EXCLUSION_VOLUMES)
.ToList();
for (int i = 0; i < volumes.Count; i++)
{
var (center, forward, up, half) = volumes[i].GetWorldOBB();
int rowOffset = i * WATER_EXCLUSION_VOLUME_ROWS;
m_WaterExclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);
m_WaterExclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);
m_WaterExclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);
}
m_WaterExclusionVolumeBuffer.SetData(m_WaterExclusionVolumeData.AsSpan(0, volumes.Count * WATER_EXCLUSION_VOLUME_ROWS));
m_DrawAttributes.Set("WaterExclusionVolumeCount", volumes.Count);
m_DrawAttributes.Set("WaterExclusionVolumeRows", m_WaterExclusionVolumeBuffer);
}
private void EnsureWaterExclusionVolumeBuffer()
{
if (m_WaterExclusionVolumeBuffer.IsValid())
return;
m_WaterExclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS);
}
private void SetHullExclusionVolumes()
{
if (WaterManager.Current == null)
return;
var hulls = WaterManager.Current.HullExclusionVolumes
.Where(h => h.IsValid() && h.Active && h.LocalTriangles.Length > 0)
.Take(MAX_HULL_EXCLUSION_VOLUMES)
.ToList();
if (hulls.Count == 0)
{
m_DrawAttributes.Set("WaterHullExclusionCount", 0);
return;
}
EnsureHullExclusionBuffers();
// Triangles are written after the fixed-size metadata section
int triWriteCursor = HULL_EXCLUSION_META_SIZE;
for (int h = 0; h < hulls.Count; h++)
{
var hull = hulls[h];
var tris = hull.LocalTriangles;
int triCount = tris.Length / 3;
if (triWriteCursor + tris.Length > m_HullExclusionData.Length)
break;
hull.GetWorldToLocalRows(out var r0, out var r1, out var r2, out var r3);
int meta = h * HULL_EXCLUSION_META_ROWS;
m_HullExclusionData[meta + 0] = r0;
m_HullExclusionData[meta + 1] = r1;
m_HullExclusionData[meta + 2] = r2;
m_HullExclusionData[meta + 3] = r3;
var aabb = hull.LocalAABB;
m_HullExclusionData[meta + 4] = new Vector4(triWriteCursor, triCount, aabb.Mins.x, aabb.Mins.y);
m_HullExclusionData[meta + 5] = new Vector4(aabb.Mins.z, aabb.Maxs.x, aabb.Maxs.y, aabb.Maxs.z);
for (int i = 0; i < tris.Length; i++)
m_HullExclusionData[triWriteCursor + i] = new Vector4(tris[i].x, tris[i].y, tris[i].z, 0f);
triWriteCursor += tris.Length;
}
m_HullExclusionBuffer.SetData(m_HullExclusionData.AsSpan(0, triWriteCursor));
m_DrawAttributes.Set("WaterHullExclusionCount", hulls.Count);
m_DrawAttributes.Set("WaterHullExclusionData", m_HullExclusionBuffer);
}
private void EnsureHullExclusionBuffers()
{
if (!m_HullExclusionBuffer.IsValid())
m_HullExclusionBuffer = new GpuBuffer<Vector4>(HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3, GpuBuffer.UsageFlags.Structured);
}
// ─────────────────────────────────────────────────────────────────────────
// CPU queries (buoyancy, underwater detection, gameplay)
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Finds the river surface under/over a world position. Returns false when the
/// position is outside the river's footprint (further than Width/2 from the
/// centerline). Height is the level water surface; direction/speed describe the
/// local current.
/// </summary>
public bool TryGetSurfaceAt(Vector3 _WorldPosition, out float _SurfaceHeight, out Vector2 _FlowDirection, out float _FlowSpeed)
{
_SurfaceHeight = float.MinValue;
_FlowDirection = Vector2.Zero;
_FlowSpeed = 0.0f;
if (m_Samples.Count < 2)
return false;
Vector2 p = new(_WorldPosition.x, _WorldPosition.y);
float bestDistSq = float.MaxValue;
int bestSegment = -1;
float bestT = 0.0f;
for (int i = 0; i < m_Samples.Count - 1; i++)
{
Vector2 a = new(m_Samples[i].Position.x, m_Samples[i].Position.y);
Vector2 b = new(m_Samples[i + 1].Position.x, m_Samples[i + 1].Position.y);
Vector2 ab = b - a;
float lenSq = ab.LengthSquared;
float t = lenSq > 1e-6f ? Math.Clamp(Vector2.Dot(p - a, ab) / lenSq, 0.0f, 1.0f) : 0.0f;
Vector2 closest = a + ab * t;
float distSq = (p - closest).LengthSquared;
if (distSq < bestDistSq)
{
bestDistSq = distSq;
bestSegment = i;
bestT = t;
}
}
if (bestSegment < 0 || bestDistSq > (Width * 0.5f) * (Width * 0.5f))
return false;
const float endpointEpsilon = 0.001f;
// Start cap
if (bestSegment == 0 && bestT <= endpointEpsilon)
{
Vector2 start = new(m_Samples[0].Position.x, m_Samples[0].Position.y);
Vector2 forward = m_Samples[0].Direction;
float along = Vector2.Dot(p - start, forward);
if (along < 0.0f)
return false;
}
// End cap
if (bestSegment == m_Samples.Count - 2 && bestT >= 1.0f - endpointEpsilon)
{
Vector2 end = new(m_Samples[^1].Position.x, m_Samples[^1].Position.y);
Vector2 forward = m_Samples[^1].Direction;
float along = Vector2.Dot(p - end, forward);
if (along > 0.0f)
return false;
}
FlowSample s0 = m_Samples[bestSegment];
FlowSample s1 = m_Samples[bestSegment + 1];
_SurfaceHeight = float.Lerp(s0.Position.z, s1.Position.z, bestT);
_FlowDirection = Vector2.Lerp(s0.Direction, s1.Direction, bestT).Normal;
_FlowSpeed = float.Lerp(s0.Speed, s1.Speed, bestT);
return true;
}
/// <summary>World-space flow velocity (XY current) at a position, Zero outside the river.</summary>
public Vector3 GetFlowVelocityAt(Vector3 _WorldPosition)
{
if (!TryGetSurfaceAt(_WorldPosition, out _, out Vector2 dir, out float speed))
return Vector3.Zero;
return new Vector3(dir.x, dir.y, 0.0f) * speed;
}
/// <summary>Whether the position is inside the river volume (footprint + depth).</summary>
public bool ContainsPoint(Vector3 _WorldPosition)
{
if (!TryGetSurfaceAt(_WorldPosition, out float height, out _, out _))
return false;
return _WorldPosition.z <= height && _WorldPosition.z >= height - Depth;
}
// ─────────────────────────────────────────────────────────────────────────
// Gizmos
// ─────────────────────────────────────────────────────────────────────────
protected override void DrawGizmos()
{
if (!Gizmo.IsSelected && !Gizmo.IsHovered)
return;
// Read-only flow visualization. Interactive spline point/tangent editing is
// owned by the WaterFlowTool editor tool (activates when this is selected).
// Centerline (spline points are local, so default gizmo space is correct)
Gizmo.Draw.Color = Color.Cyan;
var polyline = Spline.ConvertToPolyline();
for (int i = 0; i < polyline.Count - 1; i++)
Gizmo.Draw.Line(polyline[i], polyline[i + 1]);
if (!Gizmo.IsSelected || m_Samples.Count < 2)
return;
// Banks
Transform wt = WorldTransform;
float halfWidth = Width * 0.5f;
for (int i = 0; i < m_Samples.Count - 1; i++)
{
FlowSample s0 = m_Samples[i];
FlowSample s1 = m_Samples[i + 1];
Vector3 across0 = new(s0.Direction.y, -s0.Direction.x, 0.0f);
Vector3 across1 = new(s1.Direction.y, -s1.Direction.x, 0.0f);
Vector3 positionA = s0.Position;
Vector3 positionB = s1.Position;
Gizmo.Draw.Line(wt.PointToLocal(positionA + across0 * halfWidth), wt.PointToLocal(positionB + across1 * halfWidth));
Gizmo.Draw.Line(wt.PointToLocal(positionA - across0 * halfWidth), wt.PointToLocal(positionB - across1 * halfWidth));
Vector3 positionC = positionA.WithZ(positionA.z - Depth);
Vector3 positionD = positionB.WithZ(positionB.z - Depth);
Gizmo.Draw.Line(wt.PointToLocal(positionC + across0 * halfWidth), wt.PointToLocal(positionD + across1 * halfWidth));
Gizmo.Draw.Line(wt.PointToLocal(positionC - across0 * halfWidth), wt.PointToLocal(positionD - across1 * halfWidth));
if (i == 0 || i == m_Samples.Count - 2)
{
Gizmo.Draw.Line(wt.PointToLocal(positionA + across0 * halfWidth), wt.PointToLocal(positionC + across0 * halfWidth));
Gizmo.Draw.Line(wt.PointToLocal(positionB - across0 * halfWidth), wt.PointToLocal(positionD - across0 * halfWidth));
Gizmo.Draw.Line(wt.PointToLocal(positionA + across0 * halfWidth), wt.PointToLocal(positionA - across0 * halfWidth));
Gizmo.Draw.Line(wt.PointToLocal(positionC + across0 * halfWidth), wt.PointToLocal(positionC - across0 * halfWidth));
}
}
// Flow arrows colored by speed (cyan = calm, red = rushing)
int arrowStep = Math.Max(1, m_Samples.Count / 24);
for (int i = 0; i < m_Samples.Count; i += arrowStep)
{
FlowSample s = m_Samples[i];
float intensity = Math.Clamp(s.Speed / MathF.Max(FlowSpeedReference * 2.0f, 1.0f), 0.0f, 1.0f);
Vector3 dir3 = new(s.Direction.x, s.Direction.y, 0.0f);
Vector3 tip = s.Position + dir3 * MathF.Min(Width * 0.4f, 24.0f + s.Speed * 0.3f);
Gizmo.Draw.Color = Color.Lerp(Color.Cyan, Color.Red, intensity);
Gizmo.Draw.Arrow(wt.PointToLocal(s.Position), wt.PointToLocal(tip), 8.0f, 4.0f);
}
}
}