Skip to main content
GameDev.net gamedev.net

Max-Filter Dilation

by GameDev.net · GLSL ES 3.00 (WebGL2) · 30 Aug 2026

Use in your engine

Run the shader to adjust these controls.

What it demonstrates

Dilation is mathematical morphology implemented as a neighbourhood maximum. Bright regions expand into nearby dark pixels while their original values remain unchanged. The generated input combines thin lines, isolated points, rings, and coloured blocks, making growth and kernel shape easy to see without a texture asset.

Source Revision 1

Author notes are linked to specific lines.

Shader inputs

void mainImage(out vec4 fragColor, in vec2 fragCoord)

Called once per pixel. Write the colour to fragColor.

iResolution vec3
Viewport size in pixels (z is the pixel aspect ratio).
iTime float
Seconds since the shader started.
iTimeDelta float
Seconds since the previous frame.
iFrameRate float
Frames per second, smoothed.
iFrame int
Frames rendered since the start.
iMouse vec4
Mouse position: xy while held, zw of the last click.
iDate vec4
Year, month, day, and seconds within the day.
iChannel0 sampler2D
Texture bound to channel 0.
iChannel1 sampler2D
Texture bound to channel 1.
iChannel2 sampler2D
Texture bound to channel 2.
iChannel3 sampler2D
Texture bound to channel 3.
iChannelResolution vec3[4]
Pixel size of each bound channel texture.
iChannelTime float[4]
Playback time of each channel, in seconds.
iSampleRate float
Audio sample rate, always 44100.
Common
vec3 renderMaskScene(vec2 p, float time) {
    vec3 color = vec3(0.015, 0.02, 0.035);
    float ring = abs(length(p - vec2(-0.22, 0.08)) - 0.17);
    float ringMask = 1.0 - smoothstep(0.006, 0.012, ring);
    float cuts = step(0.20, fract(atan(p.y - 0.08, p.x + 0.22) * 1.273 + 0.5));
    color = mix(color, vec3(1.0, 0.18, 0.12), ringMask * cuts);
    vec2 boxPoint = abs(p - vec2(0.24, -0.08));
    float boxEdge = abs(max(boxPoint.x - 0.14, boxPoint.y - 0.10));
    color = mix(color, vec3(0.10, 0.85, 0.32), 1.0 - smoothstep(0.004, 0.010, boxEdge));
    float line = 1.0 - smoothstep(0.004, 0.009, abs(p.y - 0.30 * p.x + 0.20));
    color = mix(color, vec3(0.18, 0.42, 1.0), line * smoothstep(0.42, 0.34, abs(p.x)));
    for (int i = 0; i < 8; i++) {
        float fi = float(i);
        vec2 point = vec2(-0.42 + fi * 0.12, -0.31 + 0.018 * sin(time + fi));
        float dotMask = 1.0 - smoothstep(0.004, 0.008, length(p - point));
        color = mix(color, vec3(1.0, 0.85, 0.16), dotMask);
    }
    return color;
}

vec2 maskPoint(vec2 fragCoord) {
    return (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
}
Buffer A1
1 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2 fragColor = vec4(renderMaskScene(maskPoint(fragCoord), iTime), 1.0);
3 }
4
Main Image4
1 uniform float uRadius; // @param 1.0..3.0 = 2.0 "Radius"
2 uniform float uStrength; // @param 0.0..1.0 = 1.0 "Strength"
3
4 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
5 vec2 uv = fragCoord / iResolution.xy;
6 vec3 original = texture(iChannel0, uv).rgb;
7 vec3 maximum = vec3(0.0);
8 for (int y = -3; y <= 3; y++) {
9 for (int x = -3; x <= 3; x++) {
10 float distanceFromCenter = length(vec2(float(x), float(y)));
11 if (distanceFromCenter <= uRadius) {
12 vec2 offset = vec2(float(x), float(y)) / iResolution.xy;
13 maximum = max(maximum, texture(iChannel0, uv + offset).rgb);
14 }
15 }
16 }
17 vec3 color = mix(original, maximum, uStrength);
18 fragColor = vec4(color, 1.0);
19 }
20

Inputs for this pass

  • iChannel0 Buffer A
  • iChannel1 Empty
  • iChannel2 Empty
  • iChannel3 Empty

Learn from this shader

How it works

Buffer A renders the source mask scene. For each output pixel, the image pass visits a bounded seven-by-seven neighbourhood and keeps the component-wise maximum. Only taps inside Radius form the circular structuring element, so expansion is less boxy than a full square kernel. Increasing radius thickens strokes, closes narrow gaps, and grows isolated islands. Unlike blur, the operation never averages values: a bright sample is copied outward. Strength blends between the original and the morphological result for comparison, while a nearest-filtered input preserves exact mask boundaries. The fixed maximum loop keeps GPU work predictable at no more than forty-nine reads.

Try changing

Start with Radius one and watch the single-pixel points become small discs. Increase it until gaps in the broken ring close. Set Strength to zero to inspect the source, then to one for true dilation. Compare how the red, green, and blue shapes grow independently because the maximum is calculated per component.

Using it in a game

Replace Buffer A with a binary or soft mask. Dilation can thicken screen-space outlines, grow bloom masks, create selection halos, pad signed glyph coverage, or close tiny holes before later processing. For larger radii, use separable horizontal and vertical max passes or process a lower-resolution mask instead of expanding the loop. Clamp sampling at frame edges, as this package does, and perform the operation before compositing the mask's final colour over the scene.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...