TA----Loading...

Recreating Lusion Lab's AKARI light beams

This post will be about recreating Lusion Lab’s AKARI Light Beams, which you can try for yourself here.

Path tracing is an infamously expensive but beautiful method of rendering images. The poor GPU has to send out rays for every single pixel, which can bounce, refract, diffuse, reflect, etc. But when dealing with simple shapes and one less dimension, it can be made real time relatively easily.

The ultimate goal is to make a 2D path tracer that renders soft shadows in real time, but for a first test we simply need to detect whether a pixel is within a circle.

This one is really easy. The equation for a point in a circle is just rx2+y2r\ge\sqrt{x^{2}+y^{2}}, so we can simply return the pixel’s distance from the circle’s center. The trick with these signed distance fields is that, instead of just stopping at “is this point in the shape?” we ask “how much is this point in this shape?” This can adds significant complexity to the equations because they need to return a gradient of values rather than a boolean, but for a circle its quite easy.

The return value will be negative if it is within the circle and positive if it is outside, so we simply return x2+y2r\sqrt{x^{2}+y^{2}}-r

In terms of GLSL shader code, this can simply be written as follows:

GLSL
struct Sphere { vec2 position; float radius; }; // The signed distance field function for a sphere. float sphereSDF(vec2 p, Sphere sphere) { return distance(p, sphere.position) - sphere.radius; }

The next important figure to render is a line. These are a little bit more complicated, as they need to have stroke in addition to two points to define them.

For the rest of this project, I’ve been using Inigo Quilez’s functions for signed distance fields on a variety of primitive shapes. If you’re more interested on the mathematics behind them, he has an oustanding youtube channel.

Implementing the SDF for lines gives the following:

GLSL
struct Line { vec2 point1; vec2 point2; float stroke; }; // The signed distance field function for a line segment. // Credit to [https://iquilezles.org/articles/distfunctions2d/](https://iquilezles.org/articles/distfunctions2d/) float segmentSDF(vec2 p, Line line) { vec2 ba = line.point2 - line.point1; vec2 pa = p - line.point1; float h = clamp( dot(pa,ba)/dot(ba,ba), 0.0, 1.0 ); return length(pa-h*ba) - line.stroke; }

Now let’s make the shapes glow. At the moment, we are assuming all colors that are not within the shapes are black. If we want a glow effect, these pixels must take into account every shape on the screen.The inverse square law dictates that intensity=1distance2\text{intensity} = \frac{1}{\text{distance}^2}, but our virtual world is not bound by the laws of reality. If we want to have more control over how the light diffuses, we can use a quadratic polynomial and include a constant, linear, and quadratic term to determine brightness based on distance.

intensity=1constant+lineardistance+quadraticdistance2\text{intensity} = \cfrac{1}{\text{constant} + \text{linear} * \text{distance} + \text{quadratic} * \text{distance}^2}

There’s still the question of what to do when distance=0\text{distance} = 0, since the denominator of the function will become 0. For this case, I’m going to make the shape white. This may seem odd, but since intensity is approaching infinite, it make more sense than choosing the color of the shape.In order to implement glow, we’ll add a Light struct and a Shape struct that will have either the Line or Sphere struct within them.

GLSL
struct Light { vec3 color; float constant; float linear; float quadratic; }; // ... Shape Structs ... vec3 getAttenuation(Shape shape, float dist) { return shape.light.color * (1.0 / (shape.light.constant + shape.light.linear * dist + shape.light.quadratic * (dist * dist))); } vec3 getShapeEmission(Shape shape, vec2 position) { float distToLight = getDistance(position, shape); if (distToLight <= 0.0) { return vec3(1.0,1.0,1.0); } vec3 attenuation = getAttenuation(shape, distToLight); return attenuation; }

It’s starting to look like the original, but its missing one very key component: shadows. Shadows completely change the game since we now need to detect when one object is obstructing another. Path tracing is by no means the most optimal approach (as said before, its infamously expensive), but it’s the approach I’ll be using for simplicity’s sake.

Path tracing involves tracing rays, which is actually very simple conceptually. In order to find if a ray hits and the position at which it hits, simply use the signed distance functions to get the currently minimum distance in the scene. Then, march the ray forward by that amount. Repeat this until the minimum distance gets really small or if the ray gets too long.

I’ve created up a visualizer that draws a ray from the center of the screen to the mouse position and has two spheres in the scene. Watch how the circles get extra small when the ray is near tangent to the circle. That’ll come in handy later.

GLSL
bool pathTrace(vec2 startPosition, vec2 endPosition) { vec2 direction = normalize(endPosition - startPosition); float maxDist = distance(endPosition, startPosition); float totalDist = 0.0; bool hasHit = false; vec2 position = startPosition; for (int i = 0; i < RING_COUNT; i++) { float minDist = getNearestDist(position); totalDist += minDist; if (hasHit || totalDist >= maxDist ) { rings[i].radius = 0.0; } else { rings[i].position = position; rings[i].radius = minDist; if (minDist <= 1.) { hasHit = true; } position += direction * minDist; } } return hasHit; }

Before we add path tracing, we need to add an additional function for our shapes in addition to the signed distance function: the closest point function. This function essentially describes the point on the perimeter of a shape that is closest to any given point outside of the shape. For a circle, it’s simple because the perimeter is equidistant from the center, but it can get fairly complex for different shapes.

There are many other approaches to 2D path tracing that don’t require a closest function like this and have much more opportunities for optimization. An example is rendering the scene from the perspective of every light source, then using that information to determine if any point is in shadow of any light source very easily. This is often how video games render the shadows from the sun.

In this example, any point in shadow receives no light from the light source. The white sphere is now controlled by the user’s mouse.

GLSL
vec2 getClosestPoint(vec2 p, Shape shape) { if (shape.shapeType == LINE) { return segmentCP(p, shape); } else { return sphereCP(p, shape); } } vec3 getShapeEmission(int shapeIndex, Shape shape, vec2 position) { float distToLight = getDistance(position, shape); // Totally legit anti aliasing if (distToLight <= 0.0) { return vec3(1.0,1.0,1.0); } vec2 lightPosition = getClosestPoint(position, shape); vec2 rayDir = normalize(lightPosition - position); float res = 1.0; float t = 0.0; for (int i = 0; i < STEP_COUNT; i++) { vec2 pos = position + rayDir * t; float h = getNearestDist(pos, shapeIndex); if (h <= MIN_HIT_DIST) { res = 0.0; break; } t += clamp(h, MIN_HIT_DIST, MAX_TRACE_DIST); if(t>distToLight || t>MAX_TRACE_DIST) break; } vec3 attenuation = getAttenuation(shape, distToLight); return attenuation * res; }

The difference is pretty astounding. At this point, to achieve a final look similar to Lusion Lab’s, I just put some vertical beams that flickered and moved horizontally using sine waves. I also made the intensity of the beams fade at the corners, which is fairly easy to add as well.

Another important element I added was pseudo anti-aliasing. The white of the lights often contrasted quite sharply with every other color, and lines at angles would produce jagged edges. To solve this, I blur the edges of the light-emitting shapes between the pure white color and the color of the light when the distance is near 0.

Final Code

GLSL
bool flicker(float seed, float speed) { seed = seed / speed; seed += u_time; return cos( sin(seed / (0.1 * radians(180.0))) + cos(1.5 * seed) ) + sin(seed / (radians(180.0)) + cos(10.0 * seed)) > 1.9; } void main() { // ... Scene Setup (Defining lights, line positions, etc) ... vec2 position = convertPixelToPos(gl_FragCoord.xy); vec3 color = vec3(0.01,0.02,0.05); for (int i = 0; i < SHAPE_COUNT; i++) { if (shapes[i].emissive) { color += getShapeEmission(shapes[i], i, position); } } vec4 fragmentColor = vec4(color.xyz, 1.0); gl_FragColor = fragmentColor; }