将双曲线转换为贝塞尔曲线以绘制轨道路径

Convert hyperbola to Bézier curve for graphing orbital paths

我正在使用涉及轨道力学的 HTML canvas 编写 2D 模拟器和游戏。该程序的一个特点是获取卫星在某一点的位置和速度矢量,以及 return 绕一颗行星的二维轨道的半长轴、偏心率、近拱点角等。当偏心率小于一时,我可以使用 ctx.ellipse() 轻松地将轨道绘制为椭圆。然而,对于大于一的偏心率,轨道的正确形状是双曲线。目前,如果偏心率大于一,我的程序什么都不画,但我希望它绘制出正确的双曲线轨道。由于没有内置的“双曲线”函数,我需要将我的轨道转换为贝塞尔曲线。对于如何执行此操作,我有点不知所措。输入将是一个焦点的位置、半长轴、偏心率和近点角(基本上是轨道旋转的距离),它应该 return 正确的控制点来绘制贝塞尔曲线近似值双曲线。它不必非常完美,只要足够贴合即可。我该如何解决这个问题?

就圆锥曲线而言,不幸的是,双曲线是class Canvas 无法 原生呈现的曲线之一,所以你被困住了近似你需要的曲线。这里有一些选项:

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

1:曲线变平

曲线变平基本上是微不足道的。旋转曲线直到它与轴对齐,然后使用标准双曲线函数计算给定 xy,其中 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;
}

让我们绘制红色的双曲线和蓝色的近似值:

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

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 段组成的多贝塞尔曲线,这可能是获胜策略。

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)

现在,大多数双曲线实际上“看起来是直的”,所以对这些部分使用大量贝塞尔曲线确实感觉有点傻:为什么不仅用曲线建模弯曲位,而用直线建模直线位线?

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

当然,诀窍是找到单条曲线仍然捕捉曲率的距离,同时也使我们的无限远的线看起来像它们平滑地连接到我们的单条曲线曲线。贝塞尔 projection identity 再次派上用场:我们希望 A(0,0) 并且我们希望贝塞尔中点在 (a,0),这意味着我们的起点和终点应该x 坐标为 4a:

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

这给了我们以下结果(蓝色贝塞尔曲线,黑色线段):

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

5:合并 (1) 和 (3)

如果单个贝塞尔曲线不起作用,并且我们已经看到使用 Catmull-Rom 样条而不是单个曲线效果更好,那么我们当然也可以将方法 1 和方法 3 结合起来。我们可以形成通过生成以极值为中心的五个点并将通过这些点生成的 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,黑色为线段):

这可能是我们在“计算成本低”、“易于扩展”和“外观正确”之间进行权衡的最佳结果。

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

请注意,这是以 原点 为中心的双曲线,而不是焦点。我建议以这种方式编码,然后根据需要使用变换来定位和旋转它。我在 Desmos, the exact graph is 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( φ) = e.那么控制点就是切线的交点,所以:

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

对我来说这条曲线有点太短了。我试图添加额外的点来扩展它,但很难做到正确

其他选项

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