Psychedelic Graphics: Gallery
This is the last part of a series on creating psychedelic-looking visuals, especially for animation and games. If you haven't yet, go read the introduction and part 1 first.
Here, I want to show what is possible using many of the techniques introduced in this article series and as well as some more advanced ones. Explanations will be more tailored for those who have a programming background.
Think of this as an exhibition. I will periodically add more. To see creations by other people written using computer graphics, check out Shadertoy.
Ray Marching
Ray Marching is a rendering technique where you define shapes using SDFs (see circle_sdf and rect_sdf) and then use them to figure out how far away the closest shape is at each part of the screen. You figure out how far the closest shape is by repeatedly marching a point forwards out into space until either (1) it's so close to a shape that you say it's collided, (2) it's gone so far away that you just assume there's nothing there, or (3) you've marched it forwards N times (30 in all these examples) and still nothing's happened.
The SDFs in part 2 tell us how far our 2D point is from flat 2D shapes. Here, we are calculating how far a 3D point is from 3D shapes. Similar to the circle_sdf in part 2, we have a sphere_sdf to tell us how far a 3D point is from a 3D sphere. As we march our point forwards, we decide how far we can march it by using sphere_sdf to calculate how much clearance we have to move the point forward before it might intersect any of the 3D shapes in our scene (in this case, there's just the one sphere).
Once you've figured out how far away everything is, there are many ways to turn the distance into a color. The simplest is probably to mod the distance and use this number between 0 and 1 with any of the techniques we've talked about previously (e.g. rainbow_gradient). Ray Marching is a somewhat advanced concept and I learned much of what I know from kishimisu, The Art of Code, and Inigo Quilez.
repeated_pos = mod(repeated_pos, 0.6) - 0.3;
By using mod at every step, we can essentially copy our sphere_sdf infinitely in one or more directions. While it looks like we've copied and pasted the sphere everywhere, what's really happening when the point we're marching goes out of bounds, beyond some small space (a cube of width, height, and length 0.6), we wrap it back around into the space it started in, over and over again. Almost like how in many games, if you go past the far right side of a level, you show up on the left side.
The additional spheres we add using this wrapping technique (mod) look further away because when we wrap the point around, we don't mod the distance (total_dist). The distance continues to grow and therefore we mark it further away when it finally gets close to the sphere (or goes off into the background).
While the structure is the same here as before, swapping which SDF we're using has a huge impact on visual appearance (menger_sponge_sdf credit to ShroomLab). The Menger sponge is a 3D shape and it's a fractal. It's like a Rubix Cube, where some of the squares have been removed and each square you didn't remove is, itself, a smaller Menger sponge.
float current_dist = menger_sponge_sdf(repeated_pos, 4);
This 4 above means that we will draw smaller versions of the larger structure 4 times (4 iterations), so the large Menger sponge contains Menger sponges that contain Menger sponges that contain Menger sponges 😵💫. Try changing it to 1 or 2 to see what the basic structure looks like. I recommend not to make it too high (7+), as the difference probably won't be noticeable but it will still choke your graphics card.
Bloom
This heavily relies on polar coordinates (see xy_to_r_theta and r_theta_to_xy). There are a couple interesting parts:
r = pow(r, 3.0);
The distance from the center, r, is 0.0 at the center and increases from there. But it doesn't ever increase to 1.0. We know that because the length and width of this box that contains the graphics are 1.0 and no point inside the box is a whole box's length from the center.
Why is this important? If r is greater than or equal to 0.0 and less than 1.0, then we know using pow (multiplying r by itself 3.0 times) will make r smaller, with the parts closer to the center becoming much smaller, like a black hole sucking in colors 🌀. This is why it seems to quickly bloom from the center as we subtract time: r - time / 50.0
float petals = 3.0; r = r + sin(theta * petals) * 0.01;
The next interesting bit relies on theta, or the degree of rotation around the center. In sin(theta * petals), if we multiply theta by 3.0, as the rotation goes from 0° to 360° (actually 0.0 to ~6.28 because it's in radians), sin will repeat itself 3.0 times. Adding that to r makes certain parts pretend to be closer and others further as it goes around the center, creating a flowery, wavey look.
Sliding In
color = mix(painting(new_uv), painting(-uv), mask);
This is like a transition you can use to move between two images or video clips. There are two images, painting(new_uv) and painting(-uv). mask drives the transition. It will either be 0.0 (shows the first image) or 1.0 (show the second image).
float prog_total = mod(time / 10.0, 1.0); float prog_row = smoothstep(row, row + size, prog_total);
prog_total represents how much progress we've made transitioning the whole image and prog_row represents how much progress we've made transitioning the row that our UV coordinate falls into. For prog_row, row tells us the height of our row and row + size is the next row up. For example, if row is 0.4, then prog_row will move from 0.0 to 1.0 as prog_total moves from 0.4 to 0.5.
smoothstep takes prog_total and gives back a number between 0.0 and 1.0 depending on where prog_total falls between row and row + size. It's like the opposite of mix. What happens if prog_total is less than row? smoothstep gives us 0.0. Above row + size? smoothstep gives us 1.0. prog_total will be out of this range most of the time and only be in the range when it's that specific horizontal band's turn to transition.
This is similar to the above, but there are a few important distinctions.
float prog_total = mod(time / 10.0, 1.0); float toggle_size = float(prog_total < 0.5); float size = mix(0.05, 0.1, toggle_size);
The first is that the size is flipping between 0.05 and 0.1. prog_total repeatedly loops through the range 0.0 to 1.0. As a result, half the time toggle_size will be 0.0 and half the time it will be 1.0. Then we use it like a mask, but instead of showing one color or another, we're choosing which number (0.05 or 0.1) size will be.
vec2 square = floor(uv / size) * size; float a = random(square); a = floor(a / size) * size;
Second, we are breaking the space up into random squares instead of consecutive bands. a is a float between 0.0 and 1.0 and each square will have a unique value for a. This determines what order each square will transition in.
float offset = prog_square * size; float mask_y = mix(square.y, square.y + size, prog_square); float mask = float(uv.y < mask_y);
Last, look at how mask is calculated. Since the squares are all in different places, we can't just use float(uv.y < prog_square) like we did above... What we do instead is use prog_square (the progress we've made on our square) to figure out how far the mask should be from the bottom of the square (square.y) to the top (square.y + size). So mask_y will start at the bottom of the square (square.y). When its our square's turn to transition, prog_square transitions from 0.0 to 1.0 and mask_y therefore transitions from square.y to square.y + size.