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

Ada*_*man 3 javascript bezier html5-canvas

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

Mik*_*ans 8

就圆锥曲线而言,不幸的是,双曲线是 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
Run Code Online (Sandbox Code Playgroud)

插入您的值,迭代x以获得一系列(x,y) 坐标(记住在极值附近生成更多坐标),然后将它们转换moveTo()为第一个坐标的 a ,然后是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;
}
Run Code Online (Sandbox Code Playgroud)

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

使用对数区间的曲线平坦化

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

2:贝塞尔近似

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

sec(t) = 1 + t²/2 + 5t?/15 + ...
tan(t) = x + t³/3 + 2t?/15 + ...
Run Code Online (Sandbox Code Playgroud)

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

双曲线的二阶/三阶泰勒近似

后来发现,这不会做:这只是方式太不准确,因此我们将不得不更好的近似:我们创建的起点和终点贝塞尔曲线“小康到距离”,与控制点的设定等贝塞尔中点与双曲线的极值重合。如果我们尝试这样做,我们可能会误以为这会起作用:

小区间内的中点对齐 Bezier 近似

但是如果我们选择的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;
}
Run Code Online (Sandbox Code Playgroud)

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

大区间中点对齐的 Bezier 近似

一个明显的问题是曲线最终会超过渐近线。我们可以通过强制控制点到 (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;
}
Run Code Online (Sandbox Code Playgroud)

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

切线对齐的 Bezier 近似

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

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

我们可以通过沿着双曲线使用多条贝塞尔曲线来克服上述问题,我们可以通过在双曲线上选择几个坐标来(几乎是微不足道的)计算,然后通过这些点构建Catmull-Rom 样条。由于通过 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);
}
Run Code Online (Sandbox Code Playgroud)

随着转换功能的存在:

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;
}
Run Code Online (Sandbox Code Playgroud)

让我们绘制:

catmull-Rom 近似

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

4:结合(1)和(2)

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

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

当然,诀窍是找到单条曲线仍然捕获曲率的距离,同时使我们的无穷大线看起来像它们平滑地连接到我们的单条曲线上。Bezier投影恒等式再次派上用场:我们想要A(0,0)并且我们想要 Bezier 中点在(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];
}
Run Code Online (Sandbox Code Playgroud)

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

结合一个贝塞尔曲线和直线

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

5:结合(1)和(3)

如果单个 Bezier 不起作用,并且我们已经看到使用 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 };
}
Run Code Online (Sandbox Code Playgroud)

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

结合 Catmull-Rom 和直线

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