using System.Collections.Generic; using UnityEngine; namespace NBF { [RequireComponent(typeof(LineRenderer))] public class FishingLineRenderer : MonoBehaviour { [Header("References")] [SerializeField] private FishingLineSolver solver; [SerializeField] private LineRenderer lineRenderer; [Header("Verlet")] [Min(1)] [SerializeField] private int solverIterations = 8; [Range(0f, 1f)] [SerializeField] private float damping = 0.98f; [Min(0f)] [SerializeField] private float gravityScale = 1f; [Min(0.001f)] [SerializeField] private float simulationStep = 0.0166667f; [Min(0.001f)] [SerializeField] private float maxDeltaTime = 0.0333333f; [Header("Water Surface")] [SerializeField] private bool constrainToWaterSurface = true; [SerializeField] private Transform waterSurfaceTransform; [SerializeField] private float waterSurfaceHeight; [Min(0)] [SerializeField] private int ignoreHeadNodeCount = 1; [Min(0)] [SerializeField] private int ignoreTailNodeCount = 1; [Min(0f)] [SerializeField] private float waterSurfaceFollowSpeed = 12f; [Header("Stability")] [Min(1)] [SerializeField] private int maxSubStepsPerFrame = 2; [Min(0f)] [SerializeField] private float sleepVelocityThreshold = 0.001f; [Min(0f)] [SerializeField] private float sleepDistanceThreshold = 0.002f; [Min(1)] [SerializeField] private int stableFramesBeforeSleep = 4; [Min(0f)] [SerializeField] private float wakeDistanceThreshold = 0.001f; [Header("Corner Smoothing")] [SerializeField] private bool smoothCorners = true; [Range(0f, 180f)] [SerializeField] private float minCornerAngle = 12f; [Min(0f)] [SerializeField] private float maxCornerSmoothDistance = 0.03f; [Min(1)] [SerializeField] private int cornerSmoothSubdivisions = 3; [Header("Debug")] [SerializeField] private bool drawDebugSamples; [SerializeField] private Color debugLogicalSampleColor = Color.cyan; [SerializeField] private Color debugVirtualSampleColor = new(1f, 0.55f, 0.15f, 1f); [Min(0.001f)] [SerializeField] private float debugLogicalSampleRadius = 0.018f; [Min(0.001f)] [SerializeField] private float debugVirtualSampleRadius = 0.012f; private readonly List positions = new(); private readonly List renderPositions = new(); private readonly List previousPositions = new(); private readonly List sampledPointKeys = new(); private readonly List lastPinnedPointPositions = new(); private readonly List lastRestLengths = new(); private bool[] pinnedFlags = System.Array.Empty(); private float accumulatedTime; private bool isSleeping; private int stableFrameCounter; public int SampleCount => positions.Count; public float CurrentRenderedLength { get { var total = 0f; var source = renderPositions.Count > 0 ? renderPositions : positions; for (var i = 0; i < source.Count - 1; i++) { total += Vector3.Distance(source[i], source[i + 1]); } return total; } } private void Reset() { TryGetComponent(out lineRenderer); if (solver == null) { TryGetComponent(out solver); } } private void Awake() { if (lineRenderer == null) { TryGetComponent(out lineRenderer); } } public void Render(FishingLineSolver sourceSolver, float deltaTime) { if (lineRenderer == null) { return; } solver = sourceSolver; var points = solver.ChainPoints; var restLengths = solver.RestLengths; var pinnedIndices = solver.PinnedIndices; if (points.Count == 0) { lineRenderer.positionCount = 0; return; } var topologyChanged = EnsureBuffers(points, pinnedIndices); if (topologyChanged || ShouldWake(points, restLengths)) { WakeUp(); } Simulate(points, restLengths, deltaTime); ApplyToRenderer(); CacheFrameState(points, restLengths); } private bool EnsureBuffers( IReadOnlyList points, IReadOnlyList pinnedIndices) { var topologyChanged = sampledPointKeys.Count != points.Count; if (!topologyChanged) { for (var i = 0; i < points.Count; i++) { if (sampledPointKeys[i] == points[i].Key) { continue; } topologyChanged = true; break; } } var previousPositionMap = new Dictionary(sampledPointKeys.Count); var previousHistoryMap = new Dictionary(sampledPointKeys.Count); for (var i = 0; i < sampledPointKeys.Count; i++) { previousPositionMap[sampledPointKeys[i]] = positions[i]; previousHistoryMap[sampledPointKeys[i]] = previousPositions[i]; } positions.Clear(); previousPositions.Clear(); sampledPointKeys.Clear(); pinnedFlags = new bool[points.Count]; for (var i = 0; i < points.Count; i++) { var point = points[i]; sampledPointKeys.Add(point.Key); if (previousPositionMap.TryGetValue(point.Key, out var preservedPosition)) { positions.Add(preservedPosition); previousPositions.Add(previousHistoryMap[point.Key]); continue; } positions.Add(point.Position); previousPositions.Add(point.Position); } for (var i = 0; i < pinnedIndices.Count; i++) { var pinnedIndex = pinnedIndices[i]; if (pinnedIndex >= 0 && pinnedIndex < pinnedFlags.Length) { pinnedFlags[pinnedIndex] = true; } } return topologyChanged; } private void Simulate( IReadOnlyList points, IReadOnlyList restLengths, float deltaTime) { if (isSleeping) { PinLogicalPoints(points); return; } var clampedDelta = Mathf.Clamp(deltaTime, 0f, maxDeltaTime); accumulatedTime = Mathf.Min(accumulatedTime + clampedDelta, simulationStep * maxSubStepsPerFrame); var subStepCount = 0; while (accumulatedTime >= simulationStep && subStepCount < maxSubStepsPerFrame) { SimulateStep(points, restLengths, simulationStep); accumulatedTime -= simulationStep; subStepCount++; } if (subStepCount == 0) { PinLogicalPoints(points); ApplySleep(); } EvaluateSleepState(restLengths); } private void SimulateStep( IReadOnlyList points, IReadOnlyList restLengths, float stepDelta) { var gravity = Physics.gravity * gravityScale * stepDelta * stepDelta; for (var i = 0; i < points.Count; i++) { if (pinnedFlags[i]) { positions[i] = points[i].Position; previousPositions[i] = points[i].Position; continue; } var current = positions[i]; var velocity = (current - previousPositions[i]) * damping; previousPositions[i] = current; positions[i] = current + velocity + gravity; } SolveDistanceConstraints(points, restLengths); ApplyWaterSurfaceConstraint(stepDelta); SolveDistanceConstraints(points, restLengths); // Keep a final pure distance solve so the render chain settles back to its rest-length budget // without reintroducing the old forced-straightening behavior. SolveDistanceConstraints(points, restLengths); PinLogicalPoints(points); ApplySleep(); } private void SolveDistanceConstraints( IReadOnlyList points, IReadOnlyList restLengths) { for (var iteration = 0; iteration < solverIterations; iteration++) { PinLogicalPoints(points); for (var segmentIndex = 0; segmentIndex < restLengths.Count; segmentIndex++) { SatisfyDistanceConstraint(segmentIndex, restLengths[segmentIndex]); } } } private void PinLogicalPoints(IReadOnlyList points) { for (var i = 0; i < points.Count; i++) { if (!pinnedFlags[i]) { continue; } positions[i] = points[i].Position; previousPositions[i] = points[i].Position; } } private void SatisfyDistanceConstraint(int segmentIndex, float restLength) { var pointA = positions[segmentIndex]; var pointB = positions[segmentIndex + 1]; var delta = pointB - pointA; var distance = delta.magnitude; if (distance <= 0.0001f) { return; } var correctionScale = (distance - restLength) / distance; if (Mathf.Approximately(correctionScale, 0f)) { return; } var pointAPinned = pinnedFlags[segmentIndex]; var pointBPinned = pinnedFlags[segmentIndex + 1]; if (pointAPinned && pointBPinned) { return; } if (pointAPinned) { positions[segmentIndex + 1] -= delta * correctionScale; return; } if (pointBPinned) { positions[segmentIndex] += delta * correctionScale; return; } var correction = delta * (correctionScale * 0.5f); positions[segmentIndex] += correction; positions[segmentIndex + 1] -= correction; } private void ApplyWaterSurfaceConstraint(float stepDelta) { if (!constrainToWaterSurface || positions.Count == 0) { return; } var surfaceHeight = waterSurfaceTransform != null ? waterSurfaceTransform.position.y : waterSurfaceHeight; var startIndex = Mathf.Clamp(ignoreHeadNodeCount, 0, positions.Count); var endExclusive = Mathf.Clamp(positions.Count - ignoreTailNodeCount, startIndex, positions.Count); var followFactor = Mathf.Clamp01(waterSurfaceFollowSpeed * stepDelta); for (var i = startIndex; i < endExclusive; i++) { if (pinnedFlags[i]) { continue; } var current = positions[i]; if (current.y >= surfaceHeight) { continue; } var nextY = Mathf.Lerp(current.y, surfaceHeight, followFactor); positions[i] = new Vector3(current.x, nextY, current.z); var previous = previousPositions[i]; previousPositions[i] = new Vector3( previous.x, Mathf.Lerp(previous.y, nextY, followFactor), previous.z); } } private void ApplySleep() { for (var i = 0; i < positions.Count; i++) { if (pinnedFlags[i]) { continue; } var velocityMagnitude = (positions[i] - previousPositions[i]).magnitude; if (velocityMagnitude <= sleepVelocityThreshold) { previousPositions[i] = positions[i]; } } } private void EvaluateSleepState(IReadOnlyList restLengths) { var isStable = true; for (var i = 0; i < positions.Count; i++) { if (pinnedFlags[i]) { continue; } if ((positions[i] - previousPositions[i]).magnitude > sleepVelocityThreshold) { isStable = false; break; } } if (isStable) { for (var i = 0; i < restLengths.Count; i++) { var error = Mathf.Abs(Vector3.Distance(positions[i], positions[i + 1]) - restLengths[i]); if (error > sleepDistanceThreshold) { isStable = false; break; } } } if (!isStable) { stableFrameCounter = 0; return; } stableFrameCounter++; if (stableFrameCounter < stableFramesBeforeSleep) { return; } isSleeping = true; accumulatedTime = 0f; for (var i = 0; i < positions.Count; i++) { previousPositions[i] = positions[i]; } } private bool ShouldWake( IReadOnlyList points, IReadOnlyList restLengths) { if (!isSleeping) { return false; } if (lastPinnedPointPositions.Count != points.Count || lastRestLengths.Count != restLengths.Count) { return true; } for (var i = 0; i < points.Count; i++) { if (!pinnedFlags[i]) { continue; } if (Vector3.Distance(points[i].Position, lastPinnedPointPositions[i]) > wakeDistanceThreshold) { return true; } } for (var i = 0; i < restLengths.Count; i++) { if (Mathf.Abs(restLengths[i] - lastRestLengths[i]) > wakeDistanceThreshold) { return true; } } return false; } private void CacheFrameState( IReadOnlyList points, IReadOnlyList restLengths) { lastPinnedPointPositions.Clear(); for (var i = 0; i < points.Count; i++) { lastPinnedPointPositions.Add(points[i].Position); } lastRestLengths.Clear(); for (var i = 0; i < restLengths.Count; i++) { lastRestLengths.Add(restLengths[i]); } } private void WakeUp() { isSleeping = false; stableFrameCounter = 0; accumulatedTime = 0f; } private void ApplyToRenderer() { BuildRenderPositions(); lineRenderer.positionCount = renderPositions.Count; for (var i = 0; i < renderPositions.Count; i++) { lineRenderer.SetPosition(i, renderPositions[i]); } } private void BuildRenderPositions() { renderPositions.Clear(); if (positions.Count == 0) { return; } if (!smoothCorners || positions.Count < 3) { renderPositions.AddRange(positions); return; } renderPositions.Add(positions[0]); for (var i = 1; i < positions.Count - 1; i++) { var previous = positions[i - 1]; var current = positions[i]; var next = positions[i + 1]; if (!TryBuildSmoothedCorner(previous, current, next, out var entry, out var exit)) { AddRenderPointIfDistinct(current); continue; } AddRenderPointIfDistinct(entry); for (var subdivision = 1; subdivision <= cornerSmoothSubdivisions; subdivision++) { var t = subdivision / (cornerSmoothSubdivisions + 1f); var pointOnCurve = EvaluateQuadraticBezier(entry, current, exit, t); AddRenderPointIfDistinct(pointOnCurve); } AddRenderPointIfDistinct(exit); } AddRenderPointIfDistinct(positions[^1]); } private bool TryBuildSmoothedCorner( Vector3 previous, Vector3 current, Vector3 next, out Vector3 entry, out Vector3 exit) { entry = current; exit = current; var incoming = current - previous; var outgoing = next - current; var incomingLength = incoming.magnitude; var outgoingLength = outgoing.magnitude; if (incomingLength <= 0.0001f || outgoingLength <= 0.0001f) { return false; } var cornerAngle = Vector3.Angle(incoming, outgoing); if (cornerAngle < minCornerAngle) { return false; } var trimDistance = Mathf.Min( maxCornerSmoothDistance, incomingLength * 0.5f, outgoingLength * 0.5f); if (trimDistance <= 0.0001f) { return false; } entry = current - (incoming / incomingLength) * trimDistance; exit = current + (outgoing / outgoingLength) * trimDistance; return true; } private void AddRenderPointIfDistinct(Vector3 point) { if (renderPositions.Count > 0 && Vector3.Distance(renderPositions[^1], point) <= 0.0001f) { return; } renderPositions.Add(point); } private static Vector3 EvaluateQuadraticBezier(Vector3 start, Vector3 control, Vector3 end, float t) { var oneMinusT = 1f - t; return (oneMinusT * oneMinusT * start) + (2f * oneMinusT * t * control) + (t * t * end); } private void OnDrawGizmosSelected() { if (!drawDebugSamples || positions.Count == 0) { return; } for (var i = 0; i < positions.Count; i++) { var isLogicalPoint = solver != null && solver.ChainPoints != null && i < solver.ChainPoints.Count && solver.ChainPoints[i].IsLogical; Gizmos.color = isLogicalPoint ? debugLogicalSampleColor : debugVirtualSampleColor; Gizmos.DrawSphere( positions[i], isLogicalPoint ? debugLogicalSampleRadius : debugVirtualSampleRadius); } } } }