How to Write a Simple Halftone Shader

March 24, 2021

If you’re new to writing shaders, writing a half tone shader is a nice exercise to get some practice working with coordinates and shapes. This tutorial will walk through writing a simplified half tone shader in WebGL/GLSL. It’s based off of “WebGL halftone shader: a step-by-step tutorial” by Stefan Gustavson, but I’ve simplified a few of the advanced parts in Stefan’s tutorial to make it more beginner friendly.

Note: All the shader previews here can be viewed and edited on ShaderToy if you’d like to play around with the code.

Background

Halftone Example

The halftone pattern comes from the early days of printing where printers couldn’t vary the tone of a color. So if you wanted to print a black and white photo, you could only print black or white but none of the grayscale tones in between. To simulate these tones, they would print dots spaced apart to varying degrees which would create the illusion of different tones. We're going to try to simulate this effect in a WebGL shader.

Drawing Dots

We’ll work our way up to drawing a grid of dots, but let's start with just a grid:

precision mediump float; uniform float u_time; uniform vec3 iResolution; uniform sampler2D u_image; void main() { float gridResolution = 10.0; // Normalized pixel coordinates (from 0 to 1) vec2 uv = gl_FragCoord.xy/iResolution.xy; // Create a grid of (gridResolution x gridResolution) // cells that go from 0 - 1 vec2 uv2 = fract(uv * gridResolution); // Convert the grid dimensions from 0 - 1 to -1 - 1 // Dimensions that center on 0 make it easier to draw circles. vec2 uv3 = 2.0 * uv2 - 1.0; gl_FragColor = vec4(uv2.x, uv2.y, 0.0, 1.0); }

This has already done a lot of the heavy lifting in the overall shader, so it’s worth understanding what’s going on here. It can be really useful to think of things in one coordinate system at a time when you’re writing a shader. In this example, I’ve labeled each change in the coordinate system with uv, uv2, and uv3.

The uv coordinate tell us where the pixel we’re rendering is in from 0-1 on the x and y axes. We multiply it by the resolution to get a number from 0-resolution on each axis and then take the fraction of that number, which breaks that up into grid cells where each cell ranges from 0-1. uv3 then changes that coordinate system from -1 to 1, only because it’s easier to draw circles in each cell when our coordinates are centered on 0. We’re rendering uv2 in the example above to visual what it looks like (uv3 is hard to directly visualize because colors can’t be a negative number).

Drawing the dots becomes pretty straightforward now. We have a pixel coordinate that we’ve put through a series of transformations until we have it’s location within an individual cell, in coordinates ranging from -1 to 1. We find the distance of this point from the center of the cell (0,0) and render it black if it’s within a certain radius.

precision mediump float; uniform float u_time; uniform vec3 iResolution; uniform sampler2D u_image; void main() { float dotResolution = 10.0; float dotRadius = 0.5; // Normalized pixel coordinates (from 0 to 1) vec2 uv = gl_FragCoord.xy/iResolution.xy; vec2 uv2 = 2.0 * fract(uv * dotResolution) - 1.0; float distance = length(uv2); vec4 white = vec4(1.0, 1.0, 1.0, 1.0); vec4 black = vec4(0.0, 0.0, 0.0, 1.0); vec4 color = mix(black, white, step(dotRadius, distance)); // Output to screen gl_FragColor = color; }

Sampling the Image

Sampling the image is pretty straightforward, we can just use the original uv coordinates:

precision mediump float; uniform float u_time; uniform vec3 iResolution; uniform sampler2D u_image; void main() { float dotResolution = 60.0; float dotRadius = 0.8; // Normalized pixel coordinates (from 0 to 1) vec2 uv = gl_FragCoord.xy/iResolution.xy; vec2 uv2 = 2.0 * fract(uv * dotResolution) - 1.0; float distance = length(uv2); vec4 color0 = texture2D(u_image, uv); vec4 white = vec4(1.0, 1.0, 1.0, 1.0); vec4 black = vec4(0.0, 0.0, 0.0, 1.0); vec4 color = mix(color0, white, step(dotRadius, distance)); // Output to screen gl_FragColor = color; }

But we also want to vary the dot size based on the saturation in the original image, so that whiter areas have smaller dots and let the background through. Here we’re using the square root of the average rgb channel to approximate the saturation (you could also try just using the average rgb channel but adding the square root ends up looking better):

precision mediump float; uniform float u_time; uniform vec3 iResolution; uniform sampler2D u_image; void main() { float dotResolution = 80.0; // Normalized pixel coordinates (from 0 to 1) vec2 uv = gl_FragCoord.xy/iResolution.xy; vec2 uv2 = 2.0 * fract(uv * dotResolution) - 1.0; float distance = length(uv2); vec4 color0 = texture2D(u_image, uv); float dotRadius = 1.0 - sqrt((color0.r + color0.g + color0.b) / 3.0); vec4 white = vec4(1.0, 1.0, 1.0, 1.0); vec4 black = vec4(0.0, 0.0, 0.0, 1.0); vec4 color = mix(color0, white, step(dotRadius, distance)); // Output to screen gl_FragColor = color; }

Converting to CMYK Colors

The halftone pattern uses cyan, magenta, yellow, and black dots to approximate different hues. We want to break up our rgb color into cmyk components, and then render each component on its own grid of dots. Let’s start by rendering the magenta grid (you can also choose cyan or yellow, but it’s easier to see the magenta component for this image):

precision mediump float; uniform float u_time; uniform vec3 iResolution; uniform sampler2D u_image; void main() { float dotResolution = 30.; vec2 uv = gl_FragCoord.xy / iResolution.xy; vec4 color = texture2D(u_image, uv); // RGB-to-CMYK conversion vec4 cmyk; cmyk.xyz = 1.0 - color.rgb; cmyk.w = min(cmyk.x, min(cmyk.y, cmyk.z)); cmyk.xyz -= cmyk.w; // These are the same coordinate transformations from the last step, // but we’ll keep track of each CMYK component separately. vec2 mUV = dotResolution*uv; vec2 mUV2 = 2.0*fract(mUV)-1.0; float mR = sqrt(cmyk.y); // We’ll use smoothstep instead of step to remove some aliasing artifacts. float m = smoothstep(mR + 0.1, mR - 0.1, length(mUV2)); // Convert back into RGB vec3 rgb = 1. - vec3(0., m, 0.); gl_FragColor = vec4(rgb, 1.0); }

Now we want to add the other CMY components back, each as their own grid of dots. We don’t want each grid to directly overlap the other ones though, otherwise all of the CMYK colors will blend over each other. To get each grid slightly offset, we’ll rotate them at slightly different angles:

precision mediump float; uniform float u_time; uniform vec3 iResolution; uniform sampler2D u_image; void main() { float dotResolution = 30.; vec2 uv = gl_FragCoord.xy / iResolution.xy; vec4 color = texture2D(u_image, uv); vec4 cmyk; cmyk.xyz = 1.0 - color.rgb; cmyk.w = min(cmyk.x, min(cmyk.y, cmyk.z)); cmyk.xyz -= cmyk.w; vec2 cUV = dotResolution*mat2(0.966, -0.259, 0.259, 0.966)*uv; vec2 cUV2 = 2.0*fract(cUV)-1.0; float cR = sqrt(cmyk.x); float c = smoothstep(cR + 0.1, cR - 0.1, length(cUV2)); vec2 mUV = dotResolution*mat2(0.966, 0.259, -0.259, 0.966)*uv; vec2 mUV2 = 2.0*fract(mUV)-1.0; float mR = sqrt(cmyk.y); float m = smoothstep(mR + 0.1, mR - 0.1, length(mUV2)); vec2 yUV = dotResolution*uv; vec2 yUV2 = 2.0*fract(yUV)-1.0; float yR = sqrt(cmyk.z); float y = smoothstep(yR + 0.1, yR - 0.1, length(yUV2)); vec2 kUV = dotResolution*mat2(0.707, -0.707, 0.707, 0.707)*uv; vec2 kUV2 = 2.0*fract(kUV)-1.0; float kR = sqrt(cmyk.w); float k = smoothstep(kR + 0.1, kR - 0.1, length(kUV2)); vec3 rgb = 1. - vec3(c, m, y); rgb = mix(rgb, vec3(0., 0., 0.), k); gl_FragColor = vec4(rgb, 1.0); }

This may look like a lot, but remember that we’ve just repeated the same grid code for each CMYK component at different angles. We can convert back to RGB now and render (we have to handle k a little bit differently because of how CMYK-RGBA conversion works).

There’s one last bit of polish we’d like to add. Back in the “Drawing Dots” section, our dots look like ovals instead of perfect circles which isn’t true to the halftone effect. This is because the image is wider than its length, so the uv coordinate we calculate maintains the same aspect ratio. We can fix this by adding a colorUV to keep track of the coordinate we want to sample from, and calculate the uv for the grid pattern against just the height of the resolution so the grids and their cells are all perfect squares:

precision mediump float; uniform float u_time; uniform vec3 iResolution; uniform sampler2D u_image; void main() { float dotResolution = 30.; vec2 colorUV = gl_FragCoord.xy / iResolution.xy; // Dividing `uv` by `iResolution.y` // keeps the coordinates a perfect square vec2 uv = gl_FragCoord.xy / iResolution.y; vec4 color = texture2D(u_image, colorUV); // RGB-to-CMYK conversion vec4 cmyk; cmyk.xyz = 1.0 - color.rgb; cmyk.w = min(cmyk.x, min(cmyk.y, cmyk.z)); cmyk.xyz -= cmyk.w; // Build dot grid for each CMYK component vec2 cUV = dotResolution*mat2(0.966, -0.259, 0.259, 0.966)*uv; vec2 cUV2 = 2.0*fract(cUV)-1.0; float cR = sqrt(cmyk.x); float c = smoothstep(cR + 0.1, cR - 0.1, length(cUV2)); vec2 mUV = dotResolution*mat2(0.966, 0.259, -0.259, 0.966)*uv; vec2 mUV2 = 2.0*fract(mUV)-1.0; float mR = sqrt(cmyk.y); float m = smoothstep(mR + 0.1, mR - 0.1, length(mUV2)); vec2 yUV = dotResolution*uv; vec2 yUV2 = 2.0*fract(yUV)-1.0; float yR = sqrt(cmyk.z); float y = smoothstep(yR + 0.1, yR - 0.1, length(yUV2)); vec2 kUV = dotResolution*mat2(0.707, -0.707, 0.707, 0.707)*uv; vec2 kUV2 = 2.0*fract(kUV)-1.0; float kR = sqrt(cmyk.w); float k = smoothstep(kR + 0.1, kR - 0.1, length(kUV2)); vec3 rgb = 1. - vec3(c, m, y); rgb = mix(rgb, vec3(0., 0., 0.), k); gl_FragColor = vec4(rgb, 1.0); }

Now we have a pretty nice halftone effect!

Refinements

This is a very simplified version of a halftone shader, there are a lot of ways we can improve the look:

  • In Stefan’s tutorial, we use a noise function to add a paper-y looking background texture and slight variance to the dots to make it look more like a realistic “comic book” effect.
  • The dots should ideally all be perfect circles. If you look back at the “Sampling the Image” section you’ll notice each dot is sampling from many different colors in the original image. Since the radius of our dots is based on the color in each pixel, we get blobby-looking dots whenever the color changes quickly in the source image. Ideally each output dot should sample from the center of the input dot in the source image so there’s no variance in color for any one dot.
  • For some more suggestions, check out the comments from FabriceNeyret2 on ShaderToy

These are great bonus exercises to try on your own!

Subscribe for more posts