【问题标题】:Convert hyperbola to Bézier curve for graphing orbital paths将双曲线转换为贝塞尔曲线以绘制轨道路径
【发布时间】:2021-05-04 20:33:50
【问题描述】:

我正在使用涉及轨道力学的 HTML 画布编写 2D 模拟器和游戏。该程序的一个特点是获取卫星在某一点的位置和速度矢量,并返回围绕一颗行星的二维轨道的半长轴、偏心率、近点角等。当偏心率小于 1 时,我可以使用 ctx.ellipse() 轻松地将轨道绘制为椭圆。然而,对于大于 1 的偏心率,正确的轨道形状是双曲线。目前,如果偏心率大于 1,我的程序不会绘制任何内容,但我希望它能够绘制正确的双曲线轨道。由于没有内置的“双曲线”功能,我需要将我的轨道转换为贝塞尔曲线。我对如何做到这一点有点茫然。输入将是一个焦点的位置、半长轴、偏心率和近点的参数(基本上是轨道旋转了多远),它应该返回正确的控制点以绘制双曲线的贝塞尔曲线近似。它不必非常完美,只要它足够接近即可。我该如何解决这个问题?

【问题讨论】:

  • 在椭圆或双曲线上采样几个点并创建 catmull-rom 样条。 catmull-rom 脊柱的每一段都是三次贝塞尔曲线。
  • 显示您的代码,我们可能会帮助您修复它。

标签: javascript html5-canvas bezier


【解决方案1】:

就圆锥曲线而言,不幸的是,双曲线是 Canvas 无法原生渲染的一类曲线,因此您只能逼近所需的曲线。这里有一些选项:

  1. 通过在远处的一两个点和极值附近的很多个点采样双曲线,使曲线变平,这样您就可以绘制一个看起来的简单多边形像一条曲线。
  2. 使用单个“最佳近似”二次或三次曲线对双曲线建模。
  3. 正如@fang 所提到的:在几个点对曲线进行采样,然后通过这些点将 Catmull-Rom 样条转换为 Bezier 形式。
  4. 结合方法 1 和 2。使用单个贝塞尔曲线来近似双曲线中实际上看起来弯曲的部分,并使用直线来表示没有弯曲的部分。
  5. 结合方法 1 和 3,对弯曲钻头使用 Catmull-Rom 样条,对直钻头使用直线。

1:曲线变平

曲线变平基本上是微不足道的。旋转曲线直到它与轴对齐,然后使用标准双曲函数计算y 给定x,其中a 是极值之间距离的一半,b 是半短轴:

x²/a² - y²/b² = 1
x²/a² = 1 + y²/b² 
x²/a² - 1 = y²/b² 
b²(x²/a² - 1) = y²
b²(x²/a² - 1) = y²
± sqrt(b²(x²/a² - 1)) = y

插入您的值,遍历x 以获得一系列(x,y) 坐标(请记住在极值附近生成更多坐标),然后将它们转换为moveTo() 作为第一个坐标,然后是其余的需要您拨打许多lineTo() 电话。只要您的点密度对于您所呈现的比例足够高,这应该看起来不错:

function flattenHyperbola(a, b, inf=1000) {
  const points = [],
        a2 = a**2,
        b2 = b**2;

  let x, y, x2;

  for (x=inf; x>0.1; x/=2) {
    x2 = (a+x)**2;
    y = -Math.sqrt(b2*x2/a2 - b2);
    points.push({x: a+x, y});
  }

  points.push({x:a, y:0});

  for (x=0.1; x<inf; x*=2) {
    x2 = (a+x)*(a+x);
    y = Math.sqrt(b2*x2/a2 - b2);
    points.push({x:  a+x, y});
  }

  return points;
}

让我们用红色绘制双曲线,用蓝色绘制近似值:

当然,这种方法的缺点是您需要为用户查看图形的每个比例创建单独的扁平曲线。或者,您需要生成一条带有很多点的扁平曲线,然后根据放大/缩小的程度跳过坐标来绘制它。

2:贝塞尔近似

双曲线的参数表示是f(t)=(a*sec(t), b*tan(t))(或者更确切地说,这是 y 轴对齐的双曲线的表示 - 我们可以通过应用标准旋转变换来获得任何其他变体)。我们可以快速查看这些函数的泰勒级数,看看我们可以使用哪种贝塞尔曲线阶数:

sec(t) = 1 + t²/2 + 5t⁴/15 + ...
tan(t) = x + t³/3 + 2t⁵/15 + ...

因此,我们也许可以只使用每个维度的前两项,在这种情况下,我们可以使用三次贝塞尔曲线(因为最高阶是 t³):

事实证明,这是行不通的:它太不准确了,所以我们将不得不更好地近似:我们创建一个贝塞尔曲线,其起点和终点“距离很远”,使用控件点设置such that the Bezier midpoint coincides with the hyperbola's extrema。如果我们尝试这样做,我们可能会误以为这会起作用:

但是如果我们选择足够远的x,我们会看到这个近似值很快就失效了:

function touchingParabolicHyperbola(a, b, inf=1000) {
  const beziers = [],
        a2 = a**2,
        b2 = b**2;

  let x, x2, y, A, CA;

  for(x=50; x<inf; x+=50) {
    x2 = x**2;
    y = sqrt(b2*x2/a2 - b2);

    // Hit up https://pomax.github.io/bezierinfo/#abc
    // and model the hyperbola in the cubic graphic to
    // understand why the next, very simple-looking,
    // line actually works:
    A = a - (x-a)/3;

    // We want the control points for this A to lie on
    // the asymptote, but for small x we want it to be 0,
    // otherwise the curve won't run parallel to the
    // hyperbola at the start and end points.
    CA = lerp(0, A*b/a, x/inf);

    beziers.push([
      {x,    y: -y}, 
      {x: A, y:-CA}, 
      {x: A, y: CA}, 
      {x,    y}, 
    ]);
  }

  return beziers;
}

这向我们展示了一系列曲线,开始看起来不错,但很快就变得完全无用:

一个明显的问题是曲线最终会越过渐近线。我们可以通过将控制点强制为 (0,0) 来解决这个问题,这样贝塞尔外壳是一个三角形,而曲线将始终位于其中。

function tangentialParabolicHyperbola(a, b, inf=1000) {
  const beziers = [],
        a2 = a**2,
        b2 = b**2;

  let x, x2, y;

  for(x=50; x<inf; x+=50) {
    x2 = x**2;
    y = sqrt(b2*x2/a2 - b2);  
    beziers.push([
      {x, y:-y}, 
      {x: 0, y:0}, 
      {x: 0, y:0}, 
      {x, y}, 
    ]);
  }

  return beziers;
}

这导致了一系列曲线,从一侧无用到另一侧无用:

所以单曲线近似并不是那么好。如果我们使用更多的曲线呢?

3:使用 Catmull-Rom 样条的 Poly-Bezier

我们可以通过沿双曲线使用多条贝塞尔曲线来克服上述问题,我们可以(几乎不费吹灰之力)通过在双曲线上选取几个坐标来计算,然后通过这些点构造一个Catmull-Rom spline。由于通过 N 个点的 Catmull-Rom 样条曲线等效于由 N-3 段组成的 poly-Bezier,因此这可能是制胜策略。

function hyperbolaToPolyBezier(a, b, inf=1000) {
  const points = [],
        a2 = a**2,
        b2 = b**2,
        step = inf/10;

  let x, y, x2,
   
  for (x=a+inf; x>a; x-=step) {
    x2 = x**2;
    y = -Math.sqrt(b2*x2/a2 - b2);
    points.push({x, y});
  }

  for (x=a; x<a+inf; x+=step) {
    x2 = x**2;
    y = Math.sqrt(b2*x2/a2 - b2);
    points.push({x, y});
  }

  return crToBezier(points);
}

the conversion function 是:

function crToBezier(points) {
  const beziers = [];

  for(let i=0; i<points.length-3; i++) {
    //  NOTE THE i++ HERE! We're performing a sliding window conversion.
    let [p1, p2, p3, p4] = points.slice(i);
    beziers.push({
      start: p2,
      end: p3,
      c1: { x: p2.x + (p3.x-p1.x)/6, y: p2.y + (p3.y-p1.y)/6 },
      c2: { x: p3.x - (p4.x-p2.x)/6, y: p3.y - (p4.y-p2.y)/6 }
    })
  }

  return beziers;
}

让我们绘制:

与拉平相比,我们必须在前期做更多的工作,但好处是我们现在有一条在任何比例下实际上“看起来都像曲线”的曲线。

4:结合(1)和(2)

现在,大多数双曲线实际上“看起来是直的”,因此对这些部分使用大量 Bezier 曲线确实有点傻:为什么不只用曲线建模弯曲的钻头,而用直线建模直线钻头呢?

我们已经看到,如果我们将控制点固定为 (0,0),则可能会有一条曲线至少足够好,所以让我们结合方法 1 和 2,我们可以创建一条带有 start 和端点“足够接近”曲线,并将两条线段连接到将贝塞尔曲线连接到渐近线上的两个远点(位于y=±b/a * x,因此x 的任何大值将产生够用了y)

当然,诀窍是找到单条曲线仍能捕捉曲率的距离,同时还要使我们的无穷远线看起来像是平滑地连接到我们的单条曲线。 Bezier projection identity 再次派上用场:我们希望 A 位于 (0,0) 并且我们希望 Bezier 中点位于 (a,0),这意味着我们的起点和终点应该具有 x 坐标987654357@:

function hyperbolicallyFitParabolica(a, b, inf=1000) {
  const a2 = a**2,
        b2 = b**2,
        x = 4*a,
        x2 = x**2,
        y = sqrt(b2*x2/a2 - b2)
        bezier = [
          {x: x, y:-y}, 
          {x: 0, y: 0}, 
          {x: 0, y: 0}, 
          {x: x, y: y}, 
        ],
        start = { x1:x, y1:-y, x2:inf, y2: -inf * b/a},
        end   = { x1:x, y1: y, x2:inf, y2:  inf * b/a};

  return [start, bezier, end];
}

这给了我们以下结果(Bezier 为蓝色,线段为黑色):

所以这不是很好,但也不是很糟糕。如果观众不仔细检查渲染当然就足够了,而且它肯定很便宜,但是我们只需多做一点工作就可以做得更好,所以:让我们也看看我们可能在这里想出的最佳近似值:

5:结合(1)和(3)

如果单个 Bezier 不起作用,并且我们已经看到使用 Catmull-Rom 样条而不是单个曲线效果更好,那么我们当然也可以将方法 1 和 3 结合起来。我们可以形成一个 much通过构建两条贝塞尔曲线而不是一条贝塞尔曲线来更好地拟合极值,方法是生成以极值为中心的五个点并将生成的 Catmull-Rom 样条通过这些点转换为贝塞尔形式:

function probablyTheBestHyperbola(a, b, inf=1000) {
  let curve = [],
      a2 = a**2,
      b2 = b**2,
      x, y, x2,
      cover = 100;

  // generate two points approaching the midpoint
  for (x=a+cover; x>a; x-=cover/2) {
    x2 = x**2;
    y = -Math.sqrt(b2*x2/a2 - b2);
    curve.add(new Vec2(x, y));
  }

  // generate three points departing at the midpoint
  for (x=a; x<=a+cover; x+=cover/2) {
    x2 = x*x;
    y = sqrt(b2*x2/a2 - b2);
    curve.add(new Vec2(x, y));
  }

  const beziers = crToBezier(curve),
        start = {
          x1: points.get(1).x, y1: points.get(1).y,
          x2: inf, y2: -inf * b/a
        },
        end = {
          x1: points.get(3).x, y1: points.get(3).y,
          x2: inf, y2: inf * b/a
        };

  return { start, beziers, end };
}

这给了我们以下结果(蓝色为 CR,黑色为线段):

这可能是我们在“计算成本低”、“易于扩展”和“外观合适”之间取得的最佳平衡。

【讨论】:

    【解决方案2】:

    我正在做几乎完全相同的事情,只是我使用的是 SVG 而不是画布。据我回忆,它们非常相似,因此适用。实际上,这通常是关于贝塞尔曲线的,所以应用程序除了注意 a 和 y 的符号(图形应用程序与纯数学相反)之外应该无关紧要。

    请注意,这是以原点为中心的双曲线,而不是焦点。我建议以这种方式对其进行编码,然后根据需要使用变换来定位和旋转它。我在Desmos 中玩了很多,确切的图表是here,但我不会打赌它会工作。

    我做了这两种不同的方式。一种使用二次曲线,一种使用三次曲线。两者都使用 b,它是 sqrt(c^2-a^2) 而不是 e,但很容易计算。

    三次曲线

    我设置三次曲线来拟合从2a到a的双曲线,应该足以覆盖曲率明显的部分。由于顶点位于中间点,因此可以轻松设置控制点的 x 值。 Y 值有点棘手,但结果相当优雅。

    cubic curve

    控制点是

    P1 = (2 * a, b * sqrt(3) )
    P2 = (2/3 * a, b * (48-26 * sqrt(3) ) / 18
    P3 = (2/3 * a, -b * (48-26 * sqrt(3) )/ 18
    P4 = (2 *a, -b * sqrt(3) )
    

    对我来说这是更好的选择。如果你只需要一半的双曲线,比如从停车轨道到弹射轨道,那么你可以剪辑它。

    二次曲线

    quadradic curve img

    要对弹射轨道使用二次曲线,请注意起飞时的切线是垂直的,并且在 90 的真实异常情况下,y 值是参数,并且作为切线的飞行路径角减少为 tan(phi) = e。那么控制点就是切线的交点,所以:

    P1 = (a, 0)
    P2 = (a,-e(a + c) + p
    P3 = (c, p)
    

    对我来说,这条曲线有点太短了。我试图添加额外的点来扩展它,但无法正确处理

    其他选项

    另一种选择是创建曲线并使用变换来更改大小和形状。显然,任何曲线都可以通过仿射变换从任何曲线创建。也可以将一条曲线分成两条曲线并改变曲线的顺序。

    【讨论】:

    • “显然任何曲线都可以通过仿射变换从任何曲线创建”是微不足道的错误。贝塞尔曲线和圆弧都是曲线,再多的仿射变换也无法将其中的一个变为另一个。即使只处理贝塞尔曲线,它也是微不足道的错误,因为仿射变换不能将两个不同的坐标转换为相同的值,所以我们不能在两条曲线 {p1,p2,p3,p4} 之间变换,其中曲线一有四个不同的值, 曲线二有 p2=p3 (这是一个很常见的退化情况)。
    猜你喜欢
    • 2011-02-26
    • 2011-03-10
    • 2021-09-10
    • 2013-06-07
    • 1970-01-01
    • 2016-02-24
    • 2017-01-09
    • 2016-10-05
    • 1970-01-01
    相关资源
    最近更新 更多