0

Let's say I already have a bezier curve approximated by many straight lines (the bezier array in the code), and I would like to draw it with a series of rectangles. I have the following code below that does exactly this:

// don't change this array
const bezier = [{x:167.00,y:40.00},{x:154.37,y:42.09},{x:143.09,y:44.48},{x:133.08,y:47.15},{x:124.26,y:50.09},{x:116.55,y:53.27},{x:109.87,y:56.68},{x:104.15,y:60.31},{x:99.32,y:64.14},{x:95.28,y:68.15},{x:91.97,y:72.34},{x:89.31,y:76.67},{x:87.22,y:81.14},{x:85.63,y:85.74},{x:84.44,y:90.43},{x:83.60,y:95.22},{x:83.02,y:100.08},{x:82.63,y:105.00},{x:82.33,y:109.96},{x:82.07,y:114.94},{x:81.76,y:119.94},{x:81.33,y:124.93},{x:80.69,y:129.89},{x:79.77,y:134.82},{x:78.49,y:139.70},{x:76.78,y:144.50},{x:74.55,y:149.22},{x:71.74,y:153.84},{x:68.25,y:158.34},{x:64.03,y:162.71},{x:58.97,y:166.93},{x:53.02,y:170.98},{x:46.10,y:174.86},{x:38.11,y:178.54},{x:29.00,y:182.00}];

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

const thickness = 35;

function rotateCanvas(x, y, a) {
  ctx.translate(x, y);
  ctx.rotate(a);
  ctx.translate(-x, -y);
}

function drawRectangle(rX, rY, rW, rH, rA, color) {
  ctx.beginPath();
  rotateCanvas(rX + rW / 2, rY + rH / 2, rA);
  ctx.rect(rX, rY, rW, rH);
  rotateCanvas(rX + rW / 2, rY + rH / 2, -rA);
  ctx.fill();
}

function calcRectFromLine(x1, y1, x2, y2) {
  const dx = x2 - x1;
  const dy = y2 - y1;
  const mag = Math.sqrt(dx * dx + dy * dy);
  const angle = Math.atan2(dy, dx);

  return { 
    x: (x1 + x2) / 2 - mag / 2,
    y: (y1 + y2) / 2 - thickness / 2,
    w: mag,
    h: thickness,
    a: angle
  };
}

function calculateRectangles() {
  const result = [];

  for (let i = 1; i < bezier.length; i++) {
    const prev = bezier[i - 1];
    const curr = bezier[i];

    result.push(calcRectFromLine(prev.x, prev.y, curr.x, curr.y));
  }

  return result;
}

const rectangles = calculateRectangles();

for (let r of rectangles) {
  drawRectangle(r.x, r.y, r.w, r.h, r.a);
}
<canvas width="400" height="400"></canvas>

If you run the snippet you'll see that the curve is not fully thick, and the fact that it is a series of rectangles is very obvious.

If you change the thickness parameter from 35 to a lower number and re-run it, it looks fine. It's only when it's very thick does this occur.

The code currently takes the bezier array, and creates a series of rotated rectangles and then renders them.

Is there any way to modify the calculateRectangles function to return a better approximation of the curve? Ideally it would still return a list of rectangles rotated around their center, but when rendered it would look more like the curve, and less like a list of rectangles.

The only idea I could think of is to somehow return twice as many rectangles from calculateRectangles, where each one is inverted from the previous one, such that both sides of the line are filled in, and while I think that might work, it unfortunately has the side-effect of returning twice as many rectangles, which is undesirable and I would to avoid it if possible.

Ryan Peschel
  • 11,087
  • 19
  • 74
  • 136
  • see this: [rendering thick cubic curve in GLSL](https://stackoverflow.com/a/60113617/2521214) its using kind of SDF to the curve so the result is pixel perfect however it require quite a lot of computation power if you are not on GPU like parallelized shaders as you need to do this for every pixel of BBOX ... For polygonal approach see [draw outline for some connected lines](https://stackoverflow.com/a/22068534/2521214) but this one is tricky to handle all edge cases.... why trigonometry tag? (not a critique I just do not see the connection so I am currious) – Spektre Apr 22 '22 at 06:54

3 Answers3

2

you can't really make a "thick bezier" by drawing rectangles, you're just going to end up with lots of gaps between them on one size, and weird looking overlap on the other side. If you want to stick with the polygon approximation, you'll need to use the normal at each of your points, and then draw lines to connect those. This leads to trapezoidal sections, so we can't use plain rects if we want decent looking results.

However, the bigger the offset, the more you're going to have problems in areas with tiny radius of curvature: you can already see one such problem just by looking at the normals crossing each other underneath the crest in the upper left, where we don't actually want to connect all normal-offset vertices, because one of them lies inside the shape we want to trace.

Alternatively, you can offset the bezier curve itself with "more beziers", e.g. https://pomax.github.io/bezierinfo/#offsetting, but even then you're still going to have to resolve overlaps in the offset shape.

Instead, you can approximate the curve using circular arcs, which make "thickening the curve" a bit easier because you simply use the same arc angles and center, with two different values for the radius to get your two offset segments.

Mike 'Pomax' Kamermans
  • 49,297
  • 16
  • 112
  • 153
  • Oh hey Mike! I was so excited to see just now that you replied, as I've been digging through your library all morning. In fact that `bezier` array above was generated through the use of your `getLUT` function, so thanks for that! The reason I must use rectangular approximation is that it is trivial to find collisions between (and draw!) rectangles and circles, so my game is using only those. Your idea of adding more beziers seems good too.. Also, what do you mean I have to draw lines to connect those and not rects? In my experiment drawing lines and rotated rectangles seems to be the same, no? – Ryan Peschel Apr 21 '22 at 15:58
  • indeed they are, but if that's the goal you probably want to not approximate the curve with polygons, but with circular arcs (and then offsetting becomes a near-trivial "same arc angles, same arc center, just a smaller and larger radius". As for "not rects": the curve is, not unsurprisingly, curved, so for properly connected areas you need trapezoids, not rectangles. I've added a second image to show off that you're not dealing with rects. – Mike 'Pomax' Kamermans Apr 21 '22 at 15:59
  • Hmm, you're probably right, but I don't know anything about circular arcs. It's trivial for my game engine to draw rectangles and also to detect collisions between rectangles and circles. So, I'm not really sure how to render custom arcs or detect collisions between circles and custom arcs. I'm probably going to stick with just using rectangles for this case. But in any case I think your reply solves my issue.. Because you're right I can just generate another curve at an offset and that'll be good enough. Since the issue only occurs with very thick curves. I'll just make many non-thick curves. – Ryan Peschel Apr 21 '22 at 16:02
  • As luck would happen, though: I do, which is why there's [a whole chapter about it](https://pomax.github.io/bezierinfo/#arcapproximation) in the Primer, as well as a utility function for it [in the bezier.js library](https://pomax.github.io/bezierjs/#arcs) ;) – Mike 'Pomax' Kamermans Apr 21 '22 at 16:03
  • (that said, using simple arcs rather than biarcs can lead to some visual artifacts if you make the curve "too thick", and I've not done a writeup/utility implementation for biarcs yet so) – Mike 'Pomax' Kamermans Apr 21 '22 at 16:07
  • Well I still don't know how I would render bezier curves using WebGL, so I would need to keep using rectangles regardless. Anyways, are you sure that drawing a mirrored approximation wouldn't work here? I was looking at the rendering in my original post again just now, and I _feel_ like it would be decent, no? And then you would only need twice as many rotated rectangles (aka line segments), as opposed to how many times more with the offset approach. – Ryan Peschel Apr 21 '22 at 16:07
  • Nowhere in your original question do you mention using webgl, which would be *pretty crucial information* if that's your target. All your code is canvas2d code, which has nothing to do with webgl (and has Bezier primitives available =). Rotated lined segments only work when you keep them thin. The moment you make them a little thicker, you're going to end up with rectangle corners protruding out of, or not-quite-reaching, the next segment. – Mike 'Pomax' Kamermans Apr 21 '22 at 16:10
  • Yeah sorry I was trying to keep the question very specific. But yeah the purposes of all of this is to draw a bezier curve in-game (using WebGL) and also to be able to collide against it. It's trivial to draw rectangles and check for collisions against them (and virtually free with a spatial hash), so I'm currently doing that. – Ryan Peschel Apr 21 '22 at 16:12
  • WebGL and Canvas2D are completely different technologies, so if your goal is to have a solution for a game that uses WebGL, any answers you get based on Canvas2D are going to literally be (nearly) completely useless (to you, at least. Not to future visitors). I'd strongly recommend writing a new question that covers what you _actually_ need while leaving this one up for folks who do need a Canvas2D answer. – Mike 'Pomax' Kamermans Apr 21 '22 at 16:14
  • I don't really see how it makes a difference honestly. I'm drawing rectangles with canvas. I'm drawing rectangles with WebGL. It's the same thing. Regardless of where I draw them. And it doesn't really change the question either. Regardless I need a list of rectangles to render. I was just using canvas to demonstrate the problem and to show people the issue. – Ryan Peschel Apr 21 '22 at 16:15
  • The difference is that WebGL and Canvas2D have a completely different API and set of primitives. Remember that the whole point of SO tags and [posting guidelines](/help/how-to-ask) is to make sure the question is specific to the technology and answer you need. You don't ask about C++ if you're writing in Java, for instance, either. Sure, both have draw libraries, but they're completely different, and future visitors with webgl needs should be able to find your question based on the webgl tag, and seeing the post focussing on webgl (with code and explanation of course). – Mike 'Pomax' Kamermans Apr 21 '22 at 16:18
  • I very strongly disagree. Canvas has nothing to do with this question either. The core question that I am asking is how to get a list of rectangles that approximate a bezier curve. It's not related to WebGL or canvas. I was simply using canvas to demonstrate the problem so that people could visually see the issue. If you'd like you can remove the canvas tag. – Ryan Peschel Apr 21 '22 at 16:19
  • Disagreeing isn't really an option here, though, because the API you're asking about determines _which solutions exist_ to solve the problem you're trying to tackle, rather than the implemention you already decided on: that's the XY problem rearing its ugly head. Your stated _goal_ is getting a thick bezier curve, and the way you get those depends entirely on which drawing library/API you have at your disposal. – Mike 'Pomax' Kamermans Apr 21 '22 at 16:22
  • Again, I disagree. I am not asking how to draw a thick bezier curve. I am asking for a function which generates a list of rectangles that approximate a thick bezier curve, given an array of line segments as an input. And since every drawing API has a function to draw a rectangle, it is library-agnostic. – Ryan Peschel Apr 21 '22 at 16:24
  • Then you already have all the information. Rectangles are going to either intersect, or not reach each other. Trapezoids generated based on the original polygon that goes through your set-of-rects, based on the average normal at each vertex, will get you better results, triangle meshes based on the distance-repameterized curve will obviously get you _even_ better results, but that's a whole different implementation and if you only have a set of rects, almost certainly not an option. – Mike 'Pomax' Kamermans Apr 21 '22 at 16:28
  • I don't think I really do have my answer though. Because looking at the output of the HTML above, I can see that it doesn't look like it'd be _that_ hard to modify the function to fill in the gaps further. Simply having an extra rectangle every half step going in the reverse direction looks like it might be the ticket, but I'm not sure on the exact math for that (which is why I posted here to begin with). I feel like I specifically mentioned desiring rectangles enough in the original post to indicate this, as well. In any case, I'll await additional answers, or figure it out myself I suppose. – Ryan Peschel Apr 21 '22 at 16:31
  • fair enough: the problem I'm flagging for you is that _no matter how many rects you use_ you're going to run into the fact that two adjacent rects, at sufficient scale, at a strong enough curve, _will_ mess up, and the way we deal with that in polygonal approximations is to not use rects, but to turn them into trapezoids (as per the second image). You don't even need the underlying Bezier curve for that, you just need the angle between two subsequent rects (which is a matter of an `atan2(dy,dx)` call) – Mike 'Pomax' Kamermans Apr 21 '22 at 16:34
  • Yeah luckily my bezier curves won't be _that_ thick, so luckily I shouldn't have to deal with something like that. For my purposes all of them will be around the same as the one in the original post. But yes I agree with you that this sort of solution would not scale well. But I don't really need it to scale that well so it's okay. – Ryan Peschel Apr 21 '22 at 16:37
  • Well, in any case, I guess I'll start working on the inversion solution. Not entirely sure on the math yet. I'll work on it for a couple hours and if I don't figure it out I guess I'll post another question similar to this one but specifically asking how to perform that algorithm specifically (because it looks like it would use less rectangles than the offsetting algorithm at the scale I am using? not sure) – Ryan Peschel Apr 21 '22 at 16:41
  • Not sure I understand you on this: the example in your post shows drastic artifacting with tons of gaps between each rect due to scale. Joining up the offset vertices instead of using rects (trivial in both canvas and webgl) completely solves that. (with the caveat mentioned in the answer) – Mike 'Pomax' Kamermans Apr 21 '22 at 16:41
  • I don't understand your most recent comment. Are you saying to use trapezoids or triangles instead of rectangles? Yes, that would likely work, but I desire a solution that only uses rectangles. – Ryan Peschel Apr 21 '22 at 16:42
  • In that case I guess we're done, because no amount of rectangles will yield the result you want (unless you generate such a density as to cover the outline you need, at which point you're wasting so much cpu/gpu that any benefit of "using rects" rather than just computing your offset vertices and joining them with lines is lost). Insisting on using rects simply makes no sense here. – Mike 'Pomax' Kamermans Apr 21 '22 at 16:44
  • But it's not true! It is plain to see that simply offsetting the thin curve at various distances to generate many thin curves that together form a thick curve, or having twice as many rectangles and inverting them, would generate the desired effect. Also, insisting on using rectangles isn't that insane, because it is known that rectangles are a very efficient primitive, both to draw and to check collisions with. So it is only natural to desire a solution that uses rectangles. It doesn't have to be perfect. It just has to look okay. – Ryan Peschel Apr 21 '22 at 16:46
  • That might be where the mistunderstanding lies: yes, rectangles are efficient primitives, but _all polygons are_. That's why you compute the offset vertices, and then you draw the enclosing polygon, which is _super efficient_. Far more so than drawing lots of rects. – Mike 'Pomax' Kamermans Apr 21 '22 at 16:48
  • It is static geometry. So all the rectangles will be loaded into the buffer once and then drawing all of them at once every frame will take a fraction of a millisecond. Plus then I do not have to develop custom shaders or support for different shapes. Everything is just simply a rectangle. In addition, because of spatial hashing, even if there exist thousands and thousands of rectangles, you will only have to check for collisions against one or two of them. And checking for a collision between rectangles is trivial. I do not want to complicate things by introducing additional shapes. – Ryan Peschel Apr 21 '22 at 16:50
2

The shapes that you should draw are not rectangles but quadrilaterals, obtained by joining the endpoints of the successive normals to the curve. Presumably, you can achieve that by means of Path objects.

In zones of high curvature, you may have to yet reduce the step, because the outer curve might not be smooth.


In fact, you can "flatten" a Bezier curve by choosing steps so that the deviation between successive segments remains bounded below a fixed tolerance.

In the case of a thick curve, you can keep that idea but making sure that the bounded deviation holds for both sides of the curve.

  • I'm not really sure how to interpret this reply. What does "bounded deviation holds for both sides of the curve" mean? Are you saying there is a solution to this problem? – Ryan Peschel Apr 21 '22 at 16:19
  • @RyanPeschel: If you are able to flatten correctly a thin curve, you flatten a thick curve by the same technique, applied to the sides of the thick curve. –  Apr 21 '22 at 16:40
1

This is an okay first attempt, but I'm going to keep trying. Simply add this to the end of the getRectangles function add further approximation rectangles. Seems good enough for my purposes (and simple!), but I'm going to keep investigating a bit. I'm aware it doesn't work perfectly, but it's okay, and I don't really need much better than okay:

let len = result.length;

for (let i = 1; i < len; i++) {
  const prevR = result[i - 1];
  const currR = result[i - 0];

  result.push({
    x: (prevR.x + currR.x) / 2,
    y: (prevR.y + currR.y) / 2,
    w: (prevR.w + currR.w) / 2,
    h: (prevR.h + currR.h) / 2,
    a: (prevR.a + currR.a) / 2
  });
}

Actually, this is slightly better than okay the more and more I play with it. I think this might be a good enough solution. Unless someone can come up with something better.

Here's a GIF of the difference:

enter image description here

Ryan Peschel
  • 11,087
  • 19
  • 74
  • 136