线测试代码

This commit is contained in:
2026-04-07 20:52:50 +08:00
parent 9d23b378a6
commit 8efb431cb1
5 changed files with 274 additions and 18 deletions

View File

@@ -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 中观察。
- 调试功能应尽量不影响运行时结构与性能,不为调试方便引入长期保留的重对象方案。
## 后续补充
- 具体功能边界、数据结构、更新方式、测试用例在后续阶段继续补充。

View File

@@ -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

View File

@@ -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<Vector3> positions = new();
private readonly List<Vector3> renderPositions = new();
private readonly List<Vector3> previousPositions = new();
private readonly List<long> sampledPointKeys = new();
private readonly List<Vector3> 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<FishingLineSolver.ChainPoint> points,
IReadOnlyList<float> 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<FishingLineSolver.ChainPoint> points)
@@ -306,6 +328,76 @@ namespace F2RopeLine2.FishingLine
positions[segmentIndex + 1] -= correction;
}
private void StraightenTautLogicalSegments(
IReadOnlyList<FishingLineSolver.ChainPoint> points,
IReadOnlyList<float> 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<float> 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)

View File

@@ -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;
}

View File

@@ -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);
}
}