Wilgre

Create meshes to render 2D lines

Rendering pretty lines is not as straight-forward as one might think, and that may be the reason you found this post. The valid approaches vary depending on your needs, so this one may not fit your requirements. What we’re going to do here is:

Why construct geometry before the shader

This technique has the following benefits:

If that’s not what you need, here are some links that informed this post. The math for different techniques is essentially the same, just how you render them (e.g. using geometry shaders, Signed Distance Functions) will be different.

Ok, you’re still here. Let’s get started!

Setup

Let’s quickly define our line:

Pt :: [2]f32

Line :: struct {
  points: []Pt,
  thickness: f32,
	color: gfx.Color,
  closed: bool,
}

Find the miter points

The articles mentioned above may help to visualize this, but here’s a short overview.

We need to figure out miter points at a corner between two segments. We need three points. And we want to have the segment directions represented as vectors:

dirAB := linalg.normalize(b - a)
dirBC := linalg.normalize(c - b)

We will need the normals. So let’s rotate the directions by 90 degrees:

normalAB := Vec2{-dirAB.y, dirAB.x}
normalBC := Vec2{-dirBC.y, dirBC.x}

Adding the normals together and normalizing the vector gives us the miter direction:

miter_dir := linalg.normalize(normalBC + normalAB)

How do we get the length then? We can project our miter direction onto the normal vector of one of our segments.

length := half_thickness / linalg.dot(miter_dir, normalAB)

Ok, we have everything! Our miter points can be calculated like this:

m0 = b + miter_dir * length
m1 = b - miter_dir * length

That’s it! We can now build closed lines.

Line ends

If the line should not be closed, the vertex positions for the line ends can be calculated using the normal of the last line segment:

m0 = b + normal * half_thickness
m1 = b - normal * half_thickness

Constructing the mesh

We will use a vertex buffer to define our points and an index buffer that defines which vertices make up triangles. Our loops will not be straight-forward because we need to operate on multiple indices at once.

There are probably lots of micro-optimizations that could be done, but this is the moderately readable version I came up with.

First, we create a utility procedure that we’ll need that helps with accessing points at the start or end of lines:

wrap_idx := proc(idx, len: int) -> int {
	return (idx % len + len) % len
}

This is the gist of the main loop:

for i in 0 ..< pts_len {
  idx_a := wrap_idx(i - 1, pts_len)
  idx_b := i
  idx_c := wrap_idx(i + 1, pts_len)
  a := line.pts[idx_a]
  b := line.pts[idx_b]
  c := line.pts[idx_c]

  dirAB := linalg.normalize(b - a)
  dirBC := linalg.normalize(c - b)
  normalAB := Vec2{-dirAB.y, dirAB.x}
  normalBC := Vec2{-dirBC.y, dirBC.x}

  miter_dir := linalg.normalize(normalBC + normalAB)
  length := half_thickness / linalg.dot(miter_dir, normalAB)

  m0 := b + miter_dir * length
  m1 := b - miter_dir * length
}

However, this will calculate miter points for every segment, which should not happen for the line ends of open lines. Here we check the indices and use the normal of B-C for the first index.

m0, m1: Vec2
if !line.closed && (i == 0 || i == pts_len - 1) {
  normal := normalAB
  if i == 0 {
    normal = normalBC
  }
  m0 = b + normal * half_thickness
  m1 = b - normal * half_thickness
} else {
  // miter_dir := ...
}

Now we can simply add m0 and m1 to our vertex buffer.

Indices

Let’s build up our triangles.

Because the buffers are also used by other lines and shapes, as we can reuse the same shader/draw call and don’t want to be wasteful, we have to know where our first vertex is located and work relative from there:

// before the vertex loop
idx_0 := verts_len

We step through the vertices in pairs and use the next two vertices to create two triangles/a quad. If the line is not closed, we can skip the last pair:

last_idx := idx_0 + num_verts - 4
if line.closed {
  last_idx += 2
}
for i := idx_0; i <= last_idx; i += 2 {
  idx_a := i
  idx_b := i + 1
  idx_c := i + 2
  idx_d := i + 3
  if line.closed && idx_a == last_idx {
    idx_c = idx_0
    idx_d = idx_0 + 1
  }
  triangle1 := [3]u16{idx_a, idx_b, idx_c}
  triangle2 := [3]u16{idx_b, idx_d, idx_c}
}

Ok, I may have omitted some implementation details for the sake of clarity. Here’s the whole function:

Full apply_line_geometry function
wrap_idx := proc(idx, len: int) -> int {
	return (idx % len + len) % len
}

buffer_append :: proc(buf: ^[$N]$T, buf_len: ^int, element: T) {
	buf[buf_len^] = element
	buf_len^ += 1
}

apply_line_geometry :: proc(
	line: Line,
	verts: ^[VERT_BUFFER_SIZE]Vert,
	verts_len: ^int,
	idxs: ^[IDX_BUFFER_SIZE][3]u16,
	idxs_len: ^int,
) {
	pts_len := len(line.pts)
	color := [4]f32{line.color.r, line.color.g, line.color.b, line.color.a}
	if pts_len < 2 {
		return
	}

	idx_0 := u16(verts_len^)
	for i in 0 ..< pts_len {
		half_thickness := line.thickness / 2

		idx_a := wrap_idx(i - 1, pts_len)
		idx_b := i
		idx_c := wrap_idx(i + 1, pts_len)
		a := line.pts[idx_a]
		b := line.pts[idx_b]
		c := line.pts[idx_c]

		dirAB := linalg.normalize(b - a)
		dirBC := linalg.normalize(c - b)
		normalAB := Vec2{-dirAB.y, dirAB.x}
		normalBC := Vec2{-dirBC.y, dirBC.x}

		m0, m1: Vec2
		// if the line should not be closed and we are at the beginning
		// or end of the line, we don't calculate miters
		if !line.closed && (i == 0 || i == pts_len - 1) {
			normal := normalAB
			if i == 0 {
				normal = normalBC
			}
			m0 = b + normal * half_thickness
			m1 = b - normal * half_thickness
		} else {
			// get the miter direction using the normals of both segments
			miter_dir := linalg.normalize(normalBC + normalAB)
			// project onto the normal to get the length
			length := half_thickness / linalg.dot(miter_dir, normalAB)

			m0 = b + miter_dir * length
			m1 = b - miter_dir * length
		}

		buffer_append(verts, verts_len, Vert{pos = m0, color = color})
		buffer_append(verts, verts_len, Vert{pos = m1, color = color})
	}

	num_verts := u16(pts_len) * 2
	last_idx := idx_0 + num_verts - 4
	if line.closed {
		last_idx += 2
	}
	for i := idx_0; i <= last_idx; i += 2 {
		idx_a := i
		idx_b := i + 1
		idx_c := i + 2
		idx_d := i + 3
		if line.closed && idx_a == last_idx {
			idx_c = idx_0
			idx_d = idx_0 + 1
		}
		buffer_append(idxs, idxs_len, [3]u16{idx_a, idx_b, idx_c})
		buffer_append(idxs, idxs_len, [3]u16{idx_b, idx_d, idx_c})
	}
}

You can also look at the full example repo that renders the lines using Sokol.

Follow me on Bluesky. :)