zirc

Online Scruffy Bunny

Programmer, Artist, Rabbit
in their early 20s

 


 

📢 Don't wanna see shares?
Follow @zircus for less noise!


Thought I'd share this fragment shader I wrote for my personal website not too long ago. Given a list of 3D vectors representing point charges (z-coordinate is the charge value), it draws equipotential lines using the formula for electric potential.

I used this contour lines shader by a user named 8x as a starting point, so big thanks to them for having that code available.

Breakdown on different aspects of the code below:


Calculating Electric Potential

The formula for electric potential that I used is as follows:

V = k * (q/r)

Where q is the charge (in coulombs), r is the distance from that charge, and k is Coulomb's constant. Surprisingly simple formula, I implemented the calculation like so:

#define K 8.987551788  

/** Calculates electric potential at a point */  
float potential(float q, float r) {  
    return K * (q / r);    
}  

Point charges are passed into the shader as a uniform array of 3D vectors that store the x-position, y-position, and charge (q) values. On my website, the magnitude for q is calculated as the area of a given HTML element's bounding rectangle multiplied by a constant scale factor and divided by the area of the entire canvas.

For the lines to not go on forever towards the center of each charge, I chose to clamp the electric potentials between a min and max value that seemed reasonable (I call this the "height cutoff" in my code).

Line Thickness

As the name for "equipotential" implies, lines are drawn at spots with equal potential. In this case, the shader draws lines where the electric potential is a whole-number value. Doing that alone would likely leave us with lines that are only about a pixel wide. For thicker lines, I have them drawn at a range of values where the center is a whole number (e.g. between 1.95v and 2.05v). It gets a little tricky trying to explain this further without diving into the next topic, but you'll see in my code that I add and subtract a "radius" value in a few places that represents half of the line's width. Since the electric potential is being used to determine the final line thickness, the line will be thicker in areas where electric potential doesn't change much (the slope is smaller), and thinner in areas with high rates of change (the slope is larger and steeper).

Anti-Aliasing / Line Smoothing

This one was probably the hardest part for me to wrap my head around. My solution might not be perfect, but I'm proud with how well it turned out in the end. I originally found this article hoping it would help, but ultimately didn't get very far. I decided to leave the shader aliased and set it aside for a while.

Fast forward a month or two later, I gave it another go. This time, I dug into the comment thread of that contour lines shader for ideas and did a bit of tinkering around with the code to figure out what exactly is going on as best I can. Eventually, this is what I ended up with:

First, we gotta figure out how far away we are from the nearest “edge” (a.k.a. nearest whole number ± half the line thickness). Given that distance is determined by the nearest whole number, we could grab the fractional part of our value and subtract the half-line thickness from it. Lucky for us, there is a built-in function for grabbing the fractional part of a number:

fract(2.87); // returns 0.87

Here is how I use it:

float d = fract(z+0.1) - radius;

Where ‘z’ is the electric potential at a given point, and ‘radius’ is half of the configured line thickness. For values that are too far away from the charge’s center, the electric potential approaches zero (a whole number), so those pixels will be colored red (or whatever was picked as the line color). I wanted the background color (black) instead, so I added the line thickness (which I had configured to be 0.1) to force the minimum value to be something that is “outside” of the line. As I am writing this, I realize I probably should have used a variable there instead of a literal value.

(I should also note that I omit the +0.1 in the Shadertoy demo because it resulted in the negative charge's center to be filled in, so I'm not completely certain this is the most effective solution to that issue).

Anyways, so far we pretty much have determined the distance away from the nearest line edge as the variable d, kinda. If the fractional part of a number is greater than 0.5, then that value is actually closer to the whole number above it rather than below, so far the code only assumed that the nearest whole number was always less than or equal to the current value. SO, quick fix right here, just subtract d from 1 (and then some):

if(d > 0.5) d = 1. - d - radius;

Now we make sure to clamp d between 0 and 1 since it will be used to figure out how much we want to fade the line color out by:

d = clamp(d, 0., 1.);

If we were to skip down and draw our lines now, the code so far would draw gradients spanning the entire distance between each line (you can test this by commenting out the line I'm about to show below). They'll need to be shortened a little if we want a convincing anti-aliasing effect.

Here's how I do it, this goes right before the clamping function above:

d = d/(fwidth(z)+radius)/BLUR_AMOUNT;

From my understanding, fwidth could be understood as a partial derivative at that point, where the GPU compares the value for z against values computed for z at neighboring fragments and returns the rate of change in that area. This means that “steeper” areas with higher rates of change will result in a thinner gradient than areas that are less steep. I also include the half-line width here again, as well as a constant “blur amount” variable that can be adjusted for one’s needs. I feel like I probably could’ve worked with a more efficient computation (without fwidth) than what I settled with here, but I haven’t really bothered to investigate that yet.

The chunk of code covered in this section so far looks like this:

float d = fract(z+0.1) - radius; // distance from the edge (line's middle is where z is a whole number)  
if(d > 0.5) d = 1. - d - radius; // assures that it's distance from the /nearest/ edge  
d = d/(fwidth(z)+radius)/BLUR_AMOUNT; // dampens the length of the gradient  

// Clamp the value of 'd' between 0 and 1
d = clamp(d, 0., 1.);

Finally, the value for d can be used to determine how much we interpolate between the foreground (line) and background color for that fragment:

fragColor = mix(fg, bg, vec4(d)); 

And that’s pretty much it. I would recommend looking at the source code I linked at the top to see how everything gets pieced together if you’re interested. Otherwise, thank you for reading this far into my rough explanation of this shader! There were some Typescript classes and React (technically Preact) components I had written to control it also, so stay tuned for when I get around to isolating them into their own repository, I guess!


You must log in to comment.