I do not recommend Microsoft Edge or Safari (and therefore iOS) for viewing this page. The animations require support for WebGL 2.

par_streamlines

par_streamlines.h is my dependency-free C library for triangulating wide lines, Béziers, and streamlines. The following canvases use the library inside a small WebAssembly module. There are no memory allocations occurring per-frame.

To see the sample code that drives the animations on this page, go to this GitHub repo.

Overview

The library is very simple. It consumes a list of paths (or a vector field function), and produces a little struct that describes a triangle mesh:

typedef struct {
    uint32_t num_vertices;
    uint32_t num_triangles;
    uint32_t* triangle_indices;
    parsl_position* positions;
    parsl_annotation* annotations; // optional
    float* spine_lengths;          // optional
    float* random_offsets;         // optional
} parsl_mesh;

You can use whatever renderer you want to draw the triangle mesh. I’m using sokol_gfx for the live examples on this page.

The mesh structure contains several arrays of vertex data, including positions and optional shading annotations. The position structure is just a two-tuple:

typedef struct {
    float x;
    float y;
} parsl_position;

The annotation structure is bit more interesting. It allows you to implement cool effects in your shader:

typedef struct {
    float u_along_curve;   // longitudinal coordinate
    float v_across_curve;  // either + or - depending on the side
    float spine_to_edge_x; // normalized vector from spine to edge
    float spine_to_edge_y; // normalized vector from spine to edge
} parsl_annotation;

I’ll provide examples of how to use these annotations later in the post. The entire API has only six entry points:

parsl_context* parsl_create_context(parsl_config);
void parsl_destroy_context(parsl_context*);

parsl_mesh* parsl_mesh_from_lines(parsl_context*, parsl_spine_list);
parsl_mesh* parsl_mesh_from_curves_cubic(parsl_context*, parsl_spine_list);
parsl_mesh* parsl_mesh_from_curves_quadratic(parsl_context*, parsl_spine_list);
parsl_mesh* parsl_mesh_from_streamlines(parsl_context*, parsl_advection_callback, ...);

The API does not provide a parsl_destroy_mesh() because mesh memory is automatically reclaimed in the subsequent call to the API, and freed when the context is destroyed.

For animated lines, be sure to re-use the same context object from frame to frame to avoid memory allocations.

Next, let’s see some examples.


Basic usage

The above example has two spines, the first is defined with 3 vertices and the second is defined with 2 vertices. The API consumes a flattened list of 5 vertices, and an additional list that specifies the number of vertices in each spine.

parsl_position vertices[] = {
    {50, 150}, {200, 100}, {550, 200},
    {400, 200}, {400, 100}
};

uint16_t spine_lengths[] = { 3, 2 };

parsl_context* context = parsl_create_context({
    .thickness = 15
});

parsl_mesh* mesh = parsl_mesh_from_lines(context, {
    .num_vertices = sizeof(vertices) / sizeof(parsl_position),
    .num_spines = sizeof(spine_lengths) / sizeof(uint16_t),
    .vertices = vertices,
    .spine_lengths = spine_lengths
});

copyToIndexBuffer(mesh->triangle_indices, mesh->num_triangles);
copyToVertexBuffer(mesh->positions, mesh->num_vertices);

Simple shading

For the above example, we enabled a flag in the configuration structure, and extracted additional vertex data from the generated mesh:

parsl_context* context = parsl_create_context({
    .thickness = 15,
    .flags = PARSL_FLAG_ANNOTATIONS
});

...

copyToIndexBuffer(mesh->triangle_indices, mesh->num_triangles);
copyToVertexBuffer(mesh->positions, mesh->num_vertices);
copyToVertexBuffer(mesh->annotations, mesh->num_vertices);

This supplies us with an extra vertex attribute for drawing the gradient in the fragment shader:

in vec4 annotation;
out vec4 frag_color;

void main() {
  float t = annotation.x;
  vec3 color = mix(vec3(0.0, 0.0, 0.8), vec3(0.0, 0.8, 0.0), t);
  frag_color = vec4(color, 1);
}

If you adjust the blending factor according to a time uniform, you can make the line appear as though it is moving longitudinally, even when the vertex buffer is static. We’ll use this trick later in the post when demonstrating streamlines.


Closed paths

To make each spine loop back on itself, you can ask the library to insert an endpoint in each spine that matches up exactly with its first point. To do this, enable the closed boolean in your spine list:

parsl_mesh* mesh = parsl_mesh_from_lines(context, {
    .num_vertices = sizeof(vertices) / sizeof(parsl_position),
    .num_spines = sizeof(spine_lengths) / sizeof(uint16_t),
    .vertices = vertices,
    .spine_lengths = spine_lengths,
    .closed = true
});

The closed flag is more than a convenience function, you must enable this for the extruded vertices to line up correctly.

Note that the above demo has some sharp joints but the thickness stays constant throughout the path. In SVG parlance, this is called the miter scheme for joining. The parsl_config structure that you pass into the context creation function provides a miter_limit field that allows clamping the level of extrusion. This was used for the animation at the top of the page, which would otherwise exhibit strange “joint inflation” during the animation.


Endcaps

The par_streamlines library does not provide a facility for tessellating custom endcaps, but the effect is easy to achieve in your shader. When setting up the config structure, change the u_mode from its default value (normalized distance) to absolute distance:

parsl_context* context = parsl_create_context({
    .thickness = 15,
    .flags = PARSL_FLAG_ANNOTATIONS,
    .u_mode = PAR_U_MODE_DISTANCE
});

The following shader uses the annotation attribute to draw a half-circle endcap using alpha. This shader has a fair bit of complexity, but modern GPU’s can handle this effortlessly. Note the usage of smoothstep and fwidth, which are used to achieve antialiasing.

const float radius = 15.0;
const float radius2 = radius * radius;

in vec4 annotation;
out vec4 frag_color;

void main() {
  float dist = annotation.x;
  float alpha = 1.0;
  if (dist < radius) {
      float x = dist - radius;
      float y = annotation.y * radius;
      float d2 = x * x + y * y;
      float t = fwidth(d2);
      alpha = 1.0 - 0.99 * smoothstep(radius2 - t, radius2 + t, d2);
  }
  frag_color = vec4(0, 0, 0, alpha);
}

To draw endcaps on both ends of the spine, you can enable another attribute that tells the shader how long the spine is:

parsl_context* context = parsl_create_context({
    .thickness = 15,
    .flags = PARSL_FLAG_ANNOTATIONS | PARSL_FLAG_SPINE_LENGTHS,
    .u_mode = PAR_U_MODE_DISTANCE
});

Next, modify the shader as follows.

in vec4 annotation;
in float spine_length;
out vec4 frag_color;

void main() {
  float dist1 = abs(annotation.x);
  float dist2 = spine_length - dist1;
  float dist = min(dist1, dist2);
  // ...see prevous example for remainder...
}

Displacement

The above example shows how annotations can be used in the vertex shader. Dynamic thickness is achieved via the spine_to_edge vector in the annotation attribute.

layout(location=0) in vec2 position;
layout(location=1) in vec4 annotation;

void main() {
  vec2 spine_to_edge = annotation.zw;
  float wave = 0.5 + 0.5 * sin(10.0 * 6.28318 * annotation.x);
  vec2 p = position + spine_to_edge * 0.01 * wave;
  gl_Position = vec4(p, 0.0, 1.0);
}

Assuming you have a noise function available (e.g. this shadertoy), you can also draw a noisy line that looks handwritten:

Unlike the first displacement example, the thickness does not vary, only the direction varies. To achieve this effect you’ll need to flip the spine_to_edge vector on one side. This can be done by multiplying it with v_across_curve like so:

// Displace p by adding noise in the correct direction:
p += annotation.y * annotation.zw * noise(freq * annotation.x);

Vector fields

So far we’ve only been showing off the parsl_mesh_from_lines() function, but the library also offers high-level functions for vector fields and Bézier curves. First let’s show how to draw streamlines like this:

Each streamline in the above image is a triangle strip with varying alpha. It was generated by providing the library with an advection function. The sole job of this callback is to modify the parsl_position that lives at the given pointer.

void advect(parsl_position* point, void* userdata) {
    point->x += ...;
    point->y += ...;
}

void init() {

    parsl_context* context = parsl_create_context({
        .thickness = 3,
        .streamlines_seed_spacing = 20,
        .streamlines_seed_viewport = { left, top, right, bottom },
        .flags = PARSL_FLAG_ANNOTATIONS
    });

    parsl_mesh* mesh = parsl_mesh_from_streamlines(state->context, advect,
           0, 100, nullptr);

    ...
}

Note the additional fields we set up in the configuration, streamlines_seed_spacing and streamlines_seed_viewport. These are used to set up the initial conditions for the particles.

Since the fragment shader can be provided with a distance value, achieving animation with a static vertex buffer is fairly easy:

This is done in GLSL like so:

uniform float time;
in vec4 annotation;
out vec4 frag_color;

void main() {
  float alpha = annotation.x - time;
  frag_color = vec4(0.0, 0.0, 0.0, fract(alpha));
}

One issue with the above animation is that it resets itself every so often, which detracts from the data visualization. To mitigate this issue, the library can supply you with a list of random time offsets. Simply enable the PARSL_FLAG_RANDOM_OFFSETS flag in your configuration and copy mesh->random_offsets into a vertex attribute. By modifying the time value according to the given offset values, viewers will see a seamless loop, as seen in the demo at the top of the page.


Bézier curves

Finally, let’s cover the two curve tessellation functions. The cubic function produces a series of piecewise curves where each curve has two control points. In the following example, one spine contains two piecewise curves. The first control point in the second curve is inferred from the second control point in the first curve.

Take care when feeding vertices into the library, some are control points and some are endpoints. Study the comments at the top of the file and enable assertions to make sure that you’re arranging the data correctly in your spine list.

// parsl_mesh_from_curves_cubic()
//
// The number of vertices in each spine should be 4+(n-1)*2 where n is the
// number of piecewise curves.
//
// Each spine is equivalent to an SVG path that looks like M C S S S.
parsl_mesh* parsl_mesh_from_curves_cubic(parsl_context*, parsl_spine_list);

The library also offers a quadratic function whereby each piecewise curve has only one control point.

// parsl_mesh_from_curves_quadratic()
//
// The number of vertices in each spine should be 3+(n-1)*2 where n is the
// number of piecewise curves.
//
// Each spine is equivalent to an SVG path that looks like M Q M Q M Q.
parsl_mesh* parsl_mesh_from_curves_quadratic(parsl_context*, parsl_spine_list);

The curve functions consume the same parsl_spine_list structure used for low-level lines, but the number of generated triangles varies according to curvature. You can control the tessellation factor by setting the curves_max_flatness value in the configuration structure. If you leave it set to zero, the library will attempt to stop tessellation at a reasonable level of detail.

Thanks for reading this overview of par_streamlines.h, written by Philip Rideout in 2019.