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:
- define an open or closed 2d line defined by points and a thickness
- figure out the corner points (vertices) to draw the line with consistent thickness
- define triangles using the indices of those vertices
Why construct geometry before the shader
This technique has the following benefits:
- If your lines won’t be very dynamic, you’ll be able to cache the results
- The shader will be the most basic “color this triangle” without any special logic, so it can be reused for other meshes/primitives, allowing you to draw all of it in one draw call
- You can operate on/manipulate the geometry on the CPU
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.
- Posts on the old Cinder forums by paul.houx
- Drawing lines with WebGL by Oliver Foreman
- Drawing Lines is Hard by Matt DesLauriers
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.