The Magic of the Brush-Stroke Reveal: A Deep Dive into GLSL Shaders (1)
🌀

The Magic of the Brush-Stroke Reveal: A Deep Dive into GLSL Shaders (1)

Tags
Research
Thought Experiments
Published
May 11, 2026
Author
mumeyong
How to create organic, painterly transitions for images and buttons using mask-based thresholding.
 

Intro

In modern UI design, the standard “fade-in” or “slide ” can sometimes feel a bit clinical. Creative developers are increasingly turning to Organic Transitions effects that mimic real-world textures like ink, water or paint. One of the most visually striking examples is the Brush-Stroke Reveal.
 
Whether you’re building a high-end landing page or a stylized game UI, understanding the math behind this shader effect allows you to transform static assets into dynamic, tactile experiences.
Animation Credit: CSS-only coding animation by Chris Dermody
Animation Credit: CSS-only coding animation by Chris Dermody
The Core Concept: Thresholding
The secret isn’t in complex physics simulations; it’s in a technique called Thresholding. To pull this off, you need two assets:
The Source: Your image, button or texture.
The Mask: A graysclae image containing a hand-painted brush stroke.
The shader looks at the “brightness” (luminance) of each pixel in the mask. As we increase a progress uniform from 0.0 to 1.0, the shader performs a comparison. If the pixel’s value in the mask is lower than our current progress, we show the image. If it’s higher, we keep it hidden.
The GLSL Implementation

Static Code Snippets

Below is a production-ready Fragment Shader. This code can be used in WebGL, Three.JS or Unity Shader Graph (as a custom function)
precision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; uniform sampler2D u_mask; uniform float u_progress; uniform float u_smoothness; void main() { // 1. Sample both the source image and the brush mask vec4 color = texture2D(u_texture, v_texCoord); vec4 mask = texture2D(u_mask, v_texCoord); // 2. Extract the intensity of the brush stroke (usually the Red channel) float maskVal = mask.r; // 3. The Logic: Smoothstep creates a soft transition edge // Based on u_progress, we determine the visibility float visibility = smoothstep(u_progress, u_progress + u_smoothness,maskVal); // 4. Output the final color with adjusted alpha gl_FragColor = vec4(color.rgb, color.a * (1.0 - visibility)); }
Breaking Down the Math
The heavy lifting is done by the smoothstep function. Unlike a simple if statement, smoothstep creates a gradient transition between two values. This prevents the "stairstepping" or pixelation often seen in low-resolution masks.
 
đź’ˇ
By adjusting the u_smoothness uniform, you can control the "wetness" of the brush. A high smoothness value makes it look like a soft watercolor bleed, while a value of 0.001 makes it look like a sharp ink stroke.
 
Why This Works for UX
Psychologically, organic reveals feel more “human”. “When a button reveals itself with a brush stroke, it signals to the user that the interface is responsive and crafted. it adds a layer of polish that distinguishes a generic template from a bespoke digital product.
 
Implementation The Code to my Portfolio mumeyong.dev
  1. The Progressive Discard (Reveal Effect) Used for balloons, award cards, and doors. It progressively "eats away" at a sketch texture to reveal whatever is behind it (usually a pre-rendered "painted" version).
// uniform uniform float uProgress; // value from 0.0 to 1.0 // simple 2d noise funtions float revealRand(vec2 n) { return fract(sin(dot(n, vec2(12.9898, 4.1414))) * 43758.5453); } float revealNoise(vec2 p){ vec2 ip = floor(p); vec2 u = fract(p); u = u*u*(3.0-2.0*u); float res = mix ( mix(revealRand(ip), revealRand(ip+vec2(1.0,0.0)),u.x), mix(revealRand(ip+vec2(0.0,1.0)),revealRand(ip+vec2(1.0,1.0)),u.x),u.y); return res*res; // fragment shader logic (injected at alphatest_fragment) void main() { // ... standard texture smpling ... if (uProgress > 0.001) { // generate noise based on UVs float rn = revealNoise(vMapUv * 15.0) * 0.15; // create a mask value that increases from bottom to top (1.0 - vMapUv.y) // adding noise 'rn' gives it the "rough" brush-stroke edge float maskValue = (1.0 - vMapUv.y) + rn; // the threshold is scaled by 1.5 to ensure full reveal at uProgress = 1.0 float threshold = uProgress * 1.5; if (maskValue < threshold) discard; } }
  1. The Texture Blend (Paint Effect) Used when a single mesh needs to transition smoothly between a "sketch" texture and a "painted" texture without needing two separate meshes.
// Fragment Shader Logic (Injected at map_fragment) if (uProgress > 0.001) { vec4 paintedColor = texture2D(uMapPainted, vMapUv); float rn = paintNoise(vMapUv * 15.0) * 0.15; // Reveal from bottom to top with noisy edge float maskValue = (1.0 - vMapUv.y) + rn; float threshold = uProgress * 1.5; if (maskValue < threshold) { // Replace the current diffuse color with the painted version diffuseColor = vec4(paintedColor.rgb, 1.0); } }

Conclusion
  • Coordinate Space: The reveal is calculated in UV space (vMapUv.y), which makes it look like it's being painted from the bottom of the image upwards.
  • Noise Scale: A multiplier of 15.0 on the UVs determines the "granularity" of the brush strokes.
  • Performance: It uses a lightweight 2D noise function instead of complex Perlin/Simplex noise to ensure it runs smoothly even on mobile devices.
👉
Follow me on twitter for more awesome stuff like this @mumeyongdev