There has recently been a newfound excitement for pattern-based post-processing effects all over my timeline, as softwares such as Paper, Efecto, or Unicorn Studio are democratizing the use of shaders for both designers and developers. While some of these patterns originated as workarounds due to technical limitations we have since overcome, they now serve as an artistic direction to create distinct designs with self-imposed constraints.
One of those effects that kept coming back over and over again is halftone, the classic dot pattern, arranging dots of different sizes in a grid to give the optical illusion of a gradient of color to the observer. This technique was originally used to print images with limited ink colors, while today, it is more a versatile artistic tool used across media and the web to give some kind of texture or grain to digital outputs. I personally find this effect very interesting, as it is inherently simple to implement in its classic form, but can quickly branch off to some complex and intricate visuals.
I’ve dedicated a lot of time over the past few months to trying the different flavors that halftone can take and overlaying them on top of static images, videos, or interactive 3D scenes as shaders. Simple dots on a grid, ink splatters, grids overlapping at an angle yielding some Moiré effects, blending colors in interesting ways, breaking the grid, etc. I even tried my hand at seeing how halftone could be animated in a fun yet meaningful way. Every single one of these variants had its own interesting implementation details/shading techniques and aesthetic that I felt were worth writing about and breaking down to make building and designing with halftone more approachable.
Enjoying my writing and feeling like supporting my work? You can show your appreciation by buying me a coffee (I really really really do like coffee) which will give me the much-needed energy (and fuel my caffeine addiction) to take on more ambitious/high-quality articles and (probably over-engineered but fun) projects. As a token of gratitude, your name will be featured on this little screen below! MADE IN NYC - @MAXIMEHECKEL - 2025 - MADE IN NYC - @MAXIMEHECKEL - 2025 - MADE IN NYC - @MAXIMEHECKEL - 2025 - MADE IN NYC - @MAXIMEHECKEL - 2025 - Thank you for reading!
Behind the Dot Pattern I explored several optical illusions and “trompe l’oeil” post-processing effects in Post-Processing Shaders as a Creative Medium that create the illusion of texture or material, like woven crochet or glass. Halftone is, funnily enough, not so different as it is inherently an optical illusion itself. The effect creates the impression of continuous/smooth tones, much like dithering, by providing a high-frequency grid of dots. Because these dots can be smaller than the eye's spatial resolution, the brain ends up performing a spatial average of the pattern. Thus, past a certain dot radius, we stop seeing individual dots composing the grid and instead see the ratio of 'ink' to 'empty space' as smooth tones. Classic halftone pattern We will use those characteristics to guide and break down our implementation of halftone as a shader with GLSL. Rendering a grid of dots To ensure this article is as approachable as possible to beginners, we’re going to build this effect from the ground up, starting with its most fundamental pieces. The first step is, as you may expect, to render a single dot or circle using GLSL and UV coordinates. Diagram breaking down the distance field and masking aspect of drawing a circle in a fragment shader The diagram above illustrates the two key aspects of drawing a circle in a fragment shader: A distance d that represents the distance to the center point {0.5, 0.5} of our UV coordinate system. The results this yield is also called a distance field A mask: a threshold from which we decide what is in the dot, and what is outside. Circle shader 1 float dist = length ( cellUv - 0.5 ) ; 2 float circle = step ( 0.35 , dist ) ; 3 4 vec3 color = mix ( vec3 ( 1.0 ) , vec3 ( 0.0 ) , circle ) ; By adjusting the mask function, we can also choose whether we prefer a softer or more defined result. Softer circle shader 1 float dist = length ( cellUv - 0.5 ) ; 2 float circle = smoothstep ( 0.32 , 0.37 , dist ) ; 3 4 vec3 color = mix ( vec3 ( 1.0 ) , vec3 ( 0.0 ) , circle ) ; With that, we now have the fundamental shape for our halftone effect and can focus on its second main characteristic, the grid. In GLSL, a grid can be achieved via the fract 1 function, which returns the fractional part of its number argument: fract(1.1) returns 0.1
fract(0.9) returns 0.9
fract(20.3) returns 0.3 Essentially, it is equivalent to doing mod(x, 1) , which returns any number after the floating point of x . Of course, if we just do fract(uv) , nothing will change, since our UVs are already defined within the standard 0.0 → 1.0 range. What we need to do first is scale our UV coordinates by multiplying them by our grid size, and then use the scaled UVs as an argument for fract , which will result in our fragment shader tiling. The diagram below showcases our UV coordinates before and after tiling. Diagram showcasing the process of repeating patterns in a fragment shader using the fract function Notice how we now have multiple cells, each with their own UV coordinates ranging from 0.0 -> 1.0 and essentially running the same fragment shader. If we combine this with our circle code from above, each of these cell will draw its own circle, thus giving us our grid of dots! The widget below showcases that and lets you: Scale up and down the grid.
Scale up and down the scale of the radius. while also displaying some variation in dot size, so you can start seeing the halftone effect at play with this very simple example. Uniforms Radius 0.40 Grid Size 10.00 1 # version 300 es 2 precision highp float ; 3 in vec2 vUv ; 4 out vec4 fragColor ; 5 uniform vec2 uResolution ; 6 uniform float uRadius ; 7 uniform float uGridSize ; 8 9 void main ( ) { 10 vec2 cellUv = fract ( vUv * uGridSize ) ; 11 12 float dist = length ( cellUv - 0.5 ) ; 13 float radius = uRadius * max ( max ( vUv . x , vUv . y ) , 0.2 ) ; 14 15 float circle = smoothstep ( radius - 0.01 , radius + 0.01 , dist ) ; 16 vec3 color = mix ( vec3 ( 0.4 , 0.75 , 1.0 ) , vec3 ( 0.0 , 0.0 , 0.0 ) , circle ) ; 17 fragColor = vec4 ( color , 1.0 ) ; 18 } 19 Applying Halftone While playing with dot radii can yield some beautiful patterns, one of the main appeals of halftone is to apply it as a filter on top of a 3D scene, image, or video. The result comes with a loss of details from the underlying media, but what we obtain in return is a kind of microtexture that fills in empty spaces with interesting shapes where we’d normally see flat colors. If we simply apply the grid of dots from the previous section as a filter and color the dots based on the sampled pixels at that position, we’re only going to see a simple mask. To achieve a true halftone effect, we need to process our underlying texture first by pixelating it in such a way that it matches 1:1 our grid of dots. Pixels I already touched upon the topic of pixelization in Post-processing as a creative medium. The process and code remain the same here! Pixelization 1 vec2 normalizedPixelSize = pixelSize / resolution ; 2 vec2 uvPixel = normalizedPixelSize * floor ( uv / normalizedPixelSize ) ; 3 4 vec4 color = texture2D ( inputBuffer , uvPixel ) ; An interesting aspect of this pixelization logic is how we rely on the floor function to define our pixelatedUV coordinates. This function removes the decimals of our UV coordinates, the opposite effect of fract , and thus ensures that each pixel within a given cell samples the same color, thus resulting in a pixelated look and feel for our texture. Regular halftone grid 1 vec2 uv = vUv ; 2 vec2 normalizedPixelSize = uPixelSize / uResolution ; 3 vec2 uvPixel = normalizedPixelSize * floor ( uv / normalizedPixelSize ) ; 4 5 vec4 color = texture ( inputBuffer , uvPixel ) ; 6 7 vec2 cellUv = fract ( uv / normalizedPixelSize ) ; 8 float dist = length ( cellUv - 0.5 ) ; 9 10 float circle = smoothstep ( uRadius - 0.01 , uRadius + 0.01 , dist ) ; 11 12 color = mix ( color , vec4 ( 0.0 , 0.0 , 0.0 , 1.0 ) , circle ) ; Combining this with our grid of dots, like in the code snippet above, brings us one step closer to a halftone effect. Uniforms Radius 0.40 Cell Size 32 x 32 Display Circle Mask Use Pixelated UV Of course, there’s still something missing here from our original definition of halftone, the variation of dot size! We can implement this easily by using the luma of a given pixel, and tweak the radius based on its value. Luma-based halftone grid 1 vec2 uv = vUv ; 2 vec2 normalizedPixelSize = uPixelSize / uResolution ; 3 vec2 uvPixel = normalizedPixelSize * floor ( uv / normalizedPixelSize ) ; 4 5 vec4 color = texture ( inputBuffer , uvPixel ) ; 6 float luma = dot ( vec3 ( 0.2126 , 0.7152 , 0.0722 ) , color . rgb ) ; 7 float radius = uRadius * ( 0.1 + luma ) ; 8 9 vec2 cellUv = fract ( uv / normalizedPixelSize ) ; 10 float dist = length ( cellUv - 0.5 ) ; 11 12 float circle = smoothstep ( radius - 0.01 , radius + 0.01 , dist ) ; 13 14 color = mix ( color , vec4 ( 0.0 , 0.0 , 0.0 , 1.0 ) , circle ) ; Moreover, if we wanted to display a grayscale version of our halftone, we could also do so using that same value, which you can see the result below by toggling on the "Luma-based radius" and changing the color mode of the output. Uniforms Radius 0.40 Cell Size 32 x 32 Mode Colored Display Circle Mask Use Pixelated UV Use Luma based radius Notice how the variant where the dot size is adjusted based on luma makes the pattern from the original texture more noticeable as it increases contrast. More interesting variants of halftone can be achieved by introducing a grid offset, thus staggering the dots instead of having them perfectly aligned. This allows for a higher density of dots in our pattern, reducing the amount of white space. This offset is defined by tweaking our UVs, thus propagating it not only in how we lay down our dots but also how we sample our texture and pixelize it. Grid UV offset 1 vec2 uv = vUv ; 2 vec2 normalizedPixelSize = pixelSize / resolution ; 3 vec2 offsetUv = uv ; 4 5 float rowIndex = floor ( uv . y / normalizedPixelSize . y ) ; 6 if ( offset && mod ( rowIndex , 2.0 ) == 1.0 ) { 7 offsetUv . x += normalizedPixelSize . x * 0.5 ; 8 } 9 10 vec2 uvPixel = normalizedPixelSize * floor ( offsetUv / normalizedPixelSize ) ; 11 vec4 color = texture2D ( inputBuffer , uvPixel ) ; 12 13 The playground below implements this on top of a React Three Fiber 3D scene, thus letting you not only explore this variant alongside all the others we’ve seen so far but also play with many of the settings over a dynamic scene where I think halftone shines the most: on top of organic-moving objects, bringing a lovely contrasting aesthetic to the scene. Not just dots When the Paper team announced their halftone presets, back in November 2025, they showcased a lot of halftone variants I had not seen before. It turns out that circles are just a part of the wide spectrum of halftone patterns available, and we can allow ourselves to tweak the original definition of the effect with what I dubbed “dot adjacent” shapes. Stephen Haney @ sdothaney Today we launch an iconic new shader in @paper Yesterday's constraints, a portal to the past, what if the classics never faded? Come play with Halftone Dots. Drop in any image to create an iconic design. Design can feel like play again... link in the replies https://t.co/xrI6bOhJ2n 20 429 6:18 PM - Nov 24, 2025 The first one we can take a look at is directly inspired by the video featured in the tweet above: dots and squares, which is a combination of: The classic dot pattern we implemented already
An inverted pattern where we can find white dots inside colored pixels. The darker the underlying pixel, the bigger the white dot. Diagram showcasing the white dot halftone pattern based on the luma of the underlying cell color Those two patterns complement each other very well, and can be mixed and matched based on luma, as showcased in the demo below: A darker pixel would yield a “square with white dot.”
A lighter pixel would yield a standard dot. This has the effect of preserving much more of the original texture while still making the brighter parts of it standard halftone. We can also choose to go all in one of the patterns as showcased below, where I overlaid the different patterns on top of a video so we can see the effect at play on a subtly dynamic media: Pixel Size 16 x 16 Radius 0.25 Another type of halftone I found interesting to highlight is the ring variant. I originally got inspired some of the work of @poetengineer__ who made some beautiful uncommon halftone renders: Kat ⊷ the Poet Engineer @ poetengineer__ ☁️ https://t.co/P9jKe6NSBj 1 101 1:50 AM - Sep 22, 2025 Making a ring is actually simpler than it looks: we just need to define two circles, and combine them using an A AND NOT B combinatory logic. Diagram illustrating an arbitrary set of nodes for a given material and a representation of the data they can override. Ring shader 1 2 3 float radius = uRadius * ( 0.2 + luma ) ; 4 5 float ringThickness = 0.1 ; 6 float innerRadius = radius - ringThickness ; 7 8 float outerCircle = smoothstep ( radius - 0.01 , radius + 0.01 , dist ) ; 9 float innerCircle = smoothstep ( innerRadius - 0.01 , innerRadius + 0.01 , dist ) ; 10 11 float ring = innerCircle * ( 1.0 - outerCircle ) ; 12 13 color = mix ( vec4 ( 0.0 , 0.0 , 0.0 , 1.0 ) , color , ring ) ; Applying this effect with a monochromatic palette to a video yields a technical/low-res aesthetic, almost terminal-like, unlike the more in-your-face and high contrast big bright dots of your standard halftone variant. Split 0.50 Cell Size 8.00 Antialiasing You may notice that on displays with a high device pixel ratio, your circles may appear a bit blurry or mushy. This is mostly due to aliasing. To fix this, and as you may have seen in a couple of my examples already, we can opt to use smoothstep instead of step to introduce a tiny bit of fading over a narrow window. Diagram illustrating a simple antialiasing process using smoothstep to blur the edges of a circle and make its edge softer While this works well on most displays, we may still end up with circles that are too sharp or too blurry, and finding the right edge value to fit all our displays may be challenging. To make our antialiasing resolution agnostic, we can use the fwidth function, which tells us how much the UV value changes from one pixel to the next 2
... continue reading