Hey there š! Iām Matias, a creative web dev based in Buenos Aires, Argentina. I currently run joyco.studio, and we recently released the first version of our website. It features a comic-like dot grid shader background, and in this post, Iāll show you how itās coded, breaking it down into 4 simple steps.
But before we begin, Iāll ask you to turn ON the compositional thinking switch. Getting to a complex render is the result of achieving smaller and less complex outputs first, and then blending them all into something interesting. Have a look at what we will be creating:
That being said, letās start with a base dot grid.
Step 1: The Dot Grid
Our backgroundās base aesthetic will be a dotted grid pattern, for which we only need a screen quad (full-screen plane geometry) and a custom shader material.
Making a screen quad is super simple since we donāt need any 3D projection calculation, so itās even simpler than the average vertex shader:
void main() {
gl_Position = vec4(position.xy, 0.0, 1.0);
}
And hereās the fragment shader:
void main() {
vec2 screenUv = gl_FragCoord.xy / resolution; // get uvs from pixel coord
vec2 uv = coverUv(screenUv); // aspect correct
vec2 gridUv = fract(uv * gridSize);
gl_FragColor = vec4(gridUv.x, gridUv.y, 0.0, 1.0);
}
Thatās it; now you have the base grid. Yes, I knowāwhy is it squared? what are those colors? where are my dots?! Weāre getting there. They are squared because our shader subdivides the screen coordinates that go from 0 (left)
to 1 (right)
into smaller chunks controlled by our gridSize
uniform. Those colors are UVs for each grid box, basically local coordinates. The color grading is due to uv.x
and uv.y
being used as the red and green channels, respectively.
Our last thing to do here is to turn our pointy squares into round dots. We can do that by measuring the distance from the center of each local box; we can achieve it using the sdfCircle(point, radius)
function (SDF stands for āSigned Distance Functionā). The size of the circle will be determined by the radiusāletās say 0.3
(tweak it yourself and see the result!). Letās update the fragment code:
//...
float baseDot = sdfCircle(gridUv, radius); // sdfCircle code available on demo below
gl_FragColor = vec4(vec3(baseDot), 1.0);
//...
If the result of the sdfCircle
function is less than or equal to zero, then that pixel fragment is considered part of our circle. Thatās why our circles are black in the center and as you go further away, they turn white 0 -> ā
. There are a LOT of well-known SDF functions, andĀ Inigo Quilez caught āem all.
Step 2: The Mouse Trail
@react-three/drei
got us covered here; it has a hook for this. It handles the creation of a 2D canvas and drawing to it based on the onPointerMove
event. The event.uv
tells where the mouse intersection was. Check the source code here.
If it doesnāt start right away, click and move the mouse.
This is just perfect! Later, weāll sample this mouse trail texture to highlight the dots that are being hovered. But we wonāt do it the āeasyā way, which would be using the dots as a mask over the trail texture. Itās not bad, but that would render the underlying trail texture gradient inside the circles. Instead, I want each circle to be color uniform, and we can achieve that by sampling the trail texture from the center of each grid box (which is the center of our dots too). See? Itās essentially a pixelation effect.
Step 3: The Mask
The first is a radial gradient at y: 110%
and x: 0.7
from the bottom-left.
A linear gradient from the screen top to the screen bottom.
And a time-based animated radial gradient with the center at the same point as the first one.
Weāll only blend the first two, and the animated one will be used later. Hereās the code:
float circleMaskCenter = length(uv - vec2(0.70, 1.0));
float circleMask = smoothstep(0.4, 1.0, circleMaskCenter);
float circleAnimatedMask = sin(time * 2.0 + circleMaskCenter * 10.0);
float screenMask = smoothstep(0.0, 1.0, 1.0 - uv.y);
// Blend
float combinedMask = screenMask * circleMask;
Step 4: The Composition
Yes! Started from the bottom, now we are here! We made it to our last step: blending it all together.
Letās pick special colors for this special post (my first one in codrops š„³) Iāll use #FF5001 and #FFF for the background and dots respectively. The fun part is that we are free to tweak how each composition step interacts with each other. Eg, how much opacity adds the mouse trail to the dots? Should it scale them too? Can it also change their colors?! My answer here is āFollow your heartā. I made my choices for this demo but feel free to tweak them.
The dot scale and opacity are affected by the mouseTrail, combinedMask, and circleAnimatedMask.
// The mouse trail is a B&W image, we only need the red channel.
float mouseInfluence = texture2D(mouseTrail, gridUvCenterInScreenCoords).r;
float scaleInfluence = max(mouseInfluence * 0.5, circleAnimatedMask * 0.3);
float opacityInfluence = max(mouseInfluence * 15.0, circleAnimatedMask * 0.5);
float sdfDot = sdfCircle(gridUv, dotSize * (1.0 + scaleInfluence * 0.5));
float smoothDot = smoothstep(0.05, 0.0, sdfDot); // Smooth the edges
vec3 composition = mix(bgColor, dotColor, smoothDot * combinedMask * dotOpacity * (1.0 + opacityInfluence));
gl_FragColor = vec4(composition, 1.0);
As our last act, we should apply tone mapping and adjust the output to the rendererās color space; otherwise, our colors wonāt display accurately. Before our shader gets compiled, threejs
imports its internal lib chunks of code if it finds an #include <{chunk_name}>
snippet in the shader. We can borrow the tonemapping_fragment
and colorspace_fragment
from there. Hereās the code:
#include <tonemapping_fragment>
#include <colorspace_fragment>
FYI: Thereās a whole library of shader chunks available in threejs
check āem out. It might save you some time in the future!
Thatās it for this post! I hope you enjoyed diving into the creative process behind building a custom shader-powered dot grid and learning some new tricks along the way.
Thanks for following along, and donāt forget to eat your vegetables! See you around on X!