2D lines with round joints using WebGL shaders
A few weeks ago I added chains to Neon Made that the user can drag around to connect things with a chain or rope like behavior. Here you can see how I place two red chains and how they interact with an explosion:
Since I am allowing the user to place a lot of these I needed an efficient way to render them and I especially wanted them to have round line joints without generating extra geometry.
So here is a detailed explanation on what I am doing, meant as a tutorial for others that face similar problems. At the end you can also find a link to github with the example application.
- The cpu has a single buffer it writes the points into.
- Lines are triangulated in the vertex shader using mither joints.
- The fragment shader is passed the line segment each fragment belongs to. It calculates the distance to that segment and only colors fragments close enough to the segment. This creates the round line joints.
The lines are drawn using a single draw call using TRIANGLE_STRIP.
Memory layout for the vertices
To be able to calculate a mither joint at a given line joint you need the previous and the next point. This means the vertex shader will need three attributes:
- previous point: x,y,index
- current point: x,y,index
- next point: x,y,index
Additionally each point of the line has to be pushed onto the buffer twice with an upcounting index to be able to expand them to the triangle mesh.
Obviously this means in naive implementation you’d end up sending a lot of duplicate data. Luckily it is possible to define a memory layout of the vertex buffer that makes it reuse vertices.
The layout for a single line of the three points A, B and C looks like this:
As you can see each point is pushed twice, while the index goes up for each vertex. Additionally to not violate memory borders there is a need to add two startup and two closeup vertices.
The most efficient way to write the points into a buffer is to hold a single big Float32Array that is reused every frame and use the subarray function to get the part of the array that is currently filled with live data. If the user adds more lines than your array can hold, create a new Float32Array with double the size of the old one and copy the data over.
Multiple lines using a single TRIANGLE_STRIP
To put multiple lines into the buffer and render them all using a single draw call you have to put in so called degenerate triangles. If you were to add another line X, Y after the line in the picture above then you would have to add this order of vertices:
A0 A1 A0 A1 B2 B3 C4 C5 C4 C5 C5 X0 X0 X1 X0 X1 Y2 Y3 Y2 Y3
As you can see C5 and X0 are duplicated where the data of the two lines connect. This creates triangles that have zero area, which are therefore not drawn at all and the only visible triangles will be exactly the ones that make up your lines.
Also notice how you have to reset the index counter for the line vertices to zero for the new line.
Generating the actual mesh in the vertex shader
Once the data arrives on the GPU the vertex shader can generate the actual mesh by calculating mither joints between the line segments and pushing the vertices away from the middle of the line they belong to. The exact math that makes it happen is explained very well here.
Up to this point you can now generate a mesh on the GPU that looks like this and just fill it for hard mither joints. For example the two lines from the explanation above could look like this:
Telling the fragment shader what line segment a fragment belongs to
To be able to produce nice looking round line joints in the fragment shader you will need to determine the distance between the fragment and the line segment it belongs to. To do this you need to pass that information into the fragment shader. Since the TRIANGLE_STRIP reuses vertices this is a bit more involved than just passing data through. You’re going to need 6 varyings that work together:
- the index of point, so floor(ix/2) where ix is the index of the vertex that was passed into the fragment shader. You’ll see below how this index is used.
- the calculated world position of the vertices. Thanks to interpolation this will become the exact world position of the fragments.
- dA0, dA1, dB0, dB1 that hold two options for the line segment position. They are used in an alternating fashion by the line segments.
See this image of a simple line with the points A, B, C and D:
The line is made up by three segments. To know to which segment a fragment belongs the fragment shader can take the index varying and use floor to round it down. To then find if dAX or dBX needs to be used it can check if the index of the segment can evenly be divided by two. So overall if this is true: mod(floor(v_index), 2.0) == 0.0 then use the segment dA0 + dA1, else use dB0 + dB1. The red marked combinations are the ones used by all fragments that are part of the given segment.
Once the segment and the world position is known the fragment shader can calculate the distance to the line and compare that with the thickness that I pass as a uniform.
Drawing lines of different color or thickness.
Using this approach you will only be able to draw lines of a single thickness and color. If you need a bigger variation your options are:
- use multiple LineRenderers for the different combinations you have. I do this in Neon Made, as my number of combinations is limited.
- add the color/thickness to the vertex attributes. This gives you total freedom and is the better choice if you have a lot of combinations. However this increases the amount of data you need to push to the GPU for each frame.
Demo implementation with source
Drag a few very sharp turns and see how the round line joints prevent the line from going wild. See the wireframe for how the line would look without a round join.
Find the source here
I am wondering if I could reduce the amount of vertices I have to use for startup, closeup and connection between different lines. It seems kind of wasteful right now, but performance is really great already, so I don’t see a need to spend more time on it.
Sources of information I found helpful
My concept is mainly based on information I got from these two posts:
So that’s it. Lines in WebGL using shaders for basically everything that can be possibly done in shaders. If you have any ideas for improvents or questions don’t hesitate to comment.