diff --git a/AGENTS.md b/AGENTS.md index 10904ab..bb19ac8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ - 设计结构确认后,再进入具体实现与代码补全。 - 如果需求变更会影响节点结构、连接关系、渲染流程或交互挂接方式,应重新对齐设计后再修改代码。 +## 实现默认策略 +- 首次设计确认时,默认只输出 3 到 6 个核心脚本或模块,不主动展开完整分层架构,除非用户明确要求。 +- 未经确认,不引入 Builder、Runtime、Config、Interaction Framework 等扩展层。 +- 测试阶段优先使用最少脚本、最少运行时对象、最少中间抽象的实现。 +- 新增方案优先选择容易验证、容易回退、容易观察结果的写法。 + ## 当前需求 - 鱼线由多节点组成,节点分为逻辑节点和虚拟节点。 - 逻辑节点用于表达真实业务结构,数量和类型会随钓鱼场景变化。 @@ -48,6 +54,8 @@ - `Verlet` 渲染出的鱼线长度应与 `Joint` 约束链的实际长度保持一致。 - 不允许因平滑处理导致视觉上的鱼线长度明显长于物理长度。 - 渲染层应服务于物理结果表现,不能脱离节点链和约束长度单独扩张。 +- 任何平滑、插值、样条、圆角化处理,都不得使任一局部段长或整条视觉长度超过约束链长度。 +- 若无法低成本严格保证长度不超限,则默认不启用该类视觉平滑。 ## 虚拟节点规则 - 第一段为动态段,即起点到第一个逻辑节点之间的线段。 @@ -57,5 +65,23 @@ - 当按固定步长切分第一段时,不能整除的余量应落在起点到第一个虚拟节点之间。 - 例如基础步长为 0.1 米、第一段总长度为 1.12 米时,离起点最近的第一个虚拟节点距离应为 0.02 米,后续节点再按 0.1 米递进。 +## 虚拟节点默认策略 +- 虚拟节点默认使用纯数据结构,不创建运行时 `GameObject`。 +- 只有在明确需要场景交互、挂载组件、独立调试对象时,才将虚拟节点提升为实体对象。 +- 长线场景下应优先避免大量 `Transform` 或 `GameObject` 带来的层级与性能开销。 +- 第一段动态虚拟节点在增减时,应尽量保持已有节点身份与历史状态稳定,避免链路重排导致明显抽动。 + +## 水面与地面处理 +- 水面或地面表现优先采用轻量规则修正,如高度约束、投影贴附。 +- 不默认引入额外碰撞采样器、完整碰撞链或新增物理节点。 +- 应支持排除头尾若干节点不参与表面修正,以保留杆梢入水、浮漂黑漂等表现自由度。 +- 若仅为钓鱼表现需求,优先使用固定水面高度或简单表面规则,不优先构建完整环境碰撞解。 + +## 调试规则 +- 调试默认优先使用 Scene Gizmos、必要日志和可开关的调试绘制。 +- 不默认添加 GUI 面板、屏幕按钮、大段调试文字,除非用户明确要求。 +- 虚拟节点即使为纯数据,也应支持 Gizmos 可视化,便于在 Scene 中观察。 +- 调试功能应尽量不影响运行时结构与性能,不为调试方便引入长期保留的重对象方案。 + ## 后续补充 - 具体功能边界、数据结构、更新方式、测试用例在后续阶段继续补充。 diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity index ce7ec9b..8736c4e 100644 --- a/Assets/Scenes/SampleScene.unity +++ b/Assets/Scenes/SampleScene.unity @@ -468,8 +468,24 @@ MonoBehaviour: solverIterations: 8 damping: 0.98 gravityScale: 1 - pinFollowStrength: 50 - writeBackVirtualNodeTransforms: 1 + simulationStep: 0.0166667 + maxDeltaTime: 0.0333333 + constrainToWaterSurface: 1 + waterSurfaceTransform: {fileID: 0} + waterSurfaceHeight: 0.01 + ignoreHeadNodeCount: 1 + ignoreTailNodeCount: 1 + waterSurfaceFollowSpeed: 12 + maxSubStepsPerFrame: 2 + sleepVelocityThreshold: 0.001 + sleepDistanceThreshold: 0.002 + stableFramesBeforeSleep: 4 + wakeDistanceThreshold: 0.001 + tautSegmentThreshold: 0.002 + smoothCorners: 1 + minCornerAngle: 12 + maxCornerSmoothDistance: 0.03 + cornerSmoothSubdivisions: 3 drawDebugSamples: 0 debugSampleColor: {r: 1, g: 0.2, b: 0.2, a: 1} debugSampleRadius: 0.015 @@ -533,7 +549,7 @@ LineRenderer: m_Curve: - serializedVersion: 3 time: 0 - value: 0.01 + value: 0.002 inSlope: 0 outSlope: 0 tangentMode: 0 diff --git a/Assets/Scripts/FishingLine/FishingLineRenderer.cs b/Assets/Scripts/FishingLine/FishingLineRenderer.cs index c4e7884..20dd227 100644 --- a/Assets/Scripts/FishingLine/FishingLineRenderer.cs +++ b/Assets/Scripts/FishingLine/FishingLineRenderer.cs @@ -44,6 +44,17 @@ namespace F2RopeLine2.FishingLine [SerializeField] private int stableFramesBeforeSleep = 4; [Min(0f)] [SerializeField] private float wakeDistanceThreshold = 0.001f; + [Min(0f)] + [SerializeField] private float tautSegmentThreshold = 0.002f; + + [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; @@ -52,6 +63,7 @@ namespace F2RopeLine2.FishingLine [SerializeField] private float debugSampleRadius = 0.015f; 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(); @@ -68,9 +80,10 @@ namespace F2RopeLine2.FishingLine get { var total = 0f; - for (var i = 0; i < positions.Count - 1; i++) + var source = renderPositions.Count > 0 ? renderPositions : positions; + for (var i = 0; i < source.Count - 1; i++) { - total += Vector3.Distance(positions[i], positions[i + 1]); + total += Vector3.Distance(source[i], source[i + 1]); } return total; @@ -235,6 +248,19 @@ namespace F2RopeLine2.FishingLine positions[i] = current + velocity + gravity; } + SolveDistanceConstraints(points, restLengths); + + ApplyWaterSurfaceConstraint(stepDelta); + SolveDistanceConstraints(points, restLengths); + StraightenTautLogicalSegments(points, restLengths); + PinLogicalPoints(points); + ApplySleep(); + } + + private void SolveDistanceConstraints( + IReadOnlyList points, + IReadOnlyList restLengths) + { for (var iteration = 0; iteration < solverIterations; iteration++) { PinLogicalPoints(points); @@ -244,10 +270,6 @@ namespace F2RopeLine2.FishingLine SatisfyDistanceConstraint(segmentIndex, restLengths[segmentIndex]); } } - - ApplyWaterSurfaceConstraint(stepDelta); - PinLogicalPoints(points); - ApplySleep(); } private void PinLogicalPoints(IReadOnlyList points) @@ -306,6 +328,76 @@ namespace F2RopeLine2.FishingLine positions[segmentIndex + 1] -= correction; } + private void StraightenTautLogicalSegments( + IReadOnlyList points, + IReadOnlyList restLengths) + { + if (points.Count < 2 || restLengths.Count == 0) + { + return; + } + + var segmentStartIndex = 0; + while (segmentStartIndex < points.Count - 1) + { + if (!points[segmentStartIndex].IsLogical) + { + segmentStartIndex++; + continue; + } + + var segmentEndIndex = segmentStartIndex + 1; + while (segmentEndIndex < points.Count && !points[segmentEndIndex].IsLogical) + { + segmentEndIndex++; + } + + if (segmentEndIndex >= points.Count) + { + break; + } + + ProjectLogicalSegmentIfTaut(segmentStartIndex, segmentEndIndex, restLengths); + segmentStartIndex = segmentEndIndex; + } + } + + private void ProjectLogicalSegmentIfTaut( + int startIndex, + int endIndex, + IReadOnlyList restLengths) + { + if (endIndex - startIndex <= 1) + { + return; + } + + var segmentRestLength = 0f; + for (var i = startIndex; i < endIndex; i++) + { + segmentRestLength += restLengths[i]; + } + + var start = positions[startIndex]; + var end = positions[endIndex]; + var delta = end - start; + var endpointDistance = delta.magnitude; + if (endpointDistance <= 0.0001f || endpointDistance + tautSegmentThreshold < segmentRestLength) + { + return; + } + + var direction = delta / endpointDistance; + var accumulatedDistance = 0f; + for (var pointIndex = startIndex + 1; pointIndex < endIndex; pointIndex++) + { + accumulatedDistance += restLengths[pointIndex - 1]; + var projectedPosition = start + direction * accumulatedDistance; + positions[pointIndex] = projectedPosition; + previousPositions[pointIndex] = projectedPosition; + } + } + private void ApplyWaterSurfaceConstraint(float stepDelta) { if (!constrainToWaterSurface || positions.Count == 0) @@ -473,13 +565,115 @@ namespace F2RopeLine2.FishingLine private void ApplyToRenderer() { - lineRenderer.positionCount = positions.Count; - for (var i = 0; i < positions.Count; i++) + BuildRenderPositions(); + + lineRenderer.positionCount = renderPositions.Count; + for (var i = 0; i < renderPositions.Count; i++) { - lineRenderer.SetPosition(i, positions[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) diff --git a/Assets/Scripts/FishingLine/FishingLineSolver.cs b/Assets/Scripts/FishingLine/FishingLineSolver.cs index 91455fe..e36cd41 100644 --- a/Assets/Scripts/FishingLine/FishingLineSolver.cs +++ b/Assets/Scripts/FishingLine/FishingLineSolver.cs @@ -157,16 +157,36 @@ namespace F2RopeLine2.FishingLine RebuildRuntimeChain(); } - public void SetFirstSegmentLength(float length) + public void SetLenght(float length, int segmentIndex = 0) { var clamped = Mathf.Max(0f, length); - if (Mathf.Approximately(clamped, firstSegmentLength)) + var currentLength = GetSegmentLength(segmentIndex); + if (Mathf.Approximately(clamped, currentLength)) { return; } - firstSegmentLength = clamped; - UpdateJointLimit(1, firstSegmentLength); + if (segmentIndex <= 0) + { + firstSegmentLength = clamped; + } + else + { + if (logicalNodes == null || segmentIndex >= logicalNodes.Length) + { + return; + } + + var sourceNode = logicalNodes[segmentIndex]; + if (sourceNode == null) + { + return; + } + + sourceNode.SegmentLengthToNext = clamped; + } + + UpdateJointLimit(segmentIndex + 1, clamped); chainDirty = true; } diff --git a/Assets/Scripts/FishingLine/FishingLineTestController.cs b/Assets/Scripts/FishingLine/FishingLineTestController.cs index f15e80c..60ad61f 100644 --- a/Assets/Scripts/FishingLine/FishingLineTestController.cs +++ b/Assets/Scripts/FishingLine/FishingLineTestController.cs @@ -39,7 +39,7 @@ namespace F2RopeLine2.FishingLine } targetFirstSegmentLength = Mathf.Clamp(initialFirstSegmentLength, minFirstSegmentLength, maxFirstSegmentLength); - solver.SetFirstSegmentLength(targetFirstSegmentLength); + solver.SetLenght(targetFirstSegmentLength); solver.BuildLine(); } @@ -65,7 +65,7 @@ namespace F2RopeLine2.FishingLine { targetFirstSegmentLength += input * lineAdjustSpeed * Time.deltaTime; targetFirstSegmentLength = Mathf.Clamp(targetFirstSegmentLength, minFirstSegmentLength, maxFirstSegmentLength); - solver.SetFirstSegmentLength(targetFirstSegmentLength); + solver.SetLenght(targetFirstSegmentLength); } }