برچسب: Dithering

  • Interactive WebGL Backgrounds: A Quick Guide to Bayer Dithering

    Interactive WebGL Backgrounds: A Quick Guide to Bayer Dithering



    User experience relies on small, thoughtful details that fit well into the overall design without overpowering the user. This balance can be tricky, especially with technologies like WebGL. While they can create amazing visuals, they can also become too complicated and distracting if not handled carefully.

    One subtle but effective technique is the Bayer Dithering Pattern. For example, JetBrains’ recent Junie campaign page uses this approach to craft an immersive and engaging atmosphere that remains visually balanced and accessible.

    In this tutorial, I’ll introduce you to the Bayer Dithering Pattern. I’ll explain what it is, how it works, and how you can apply it to your own web projects to enhance visual depth without overpowering the user experience.

    Bayer Dithering

    The Bayer pattern is a type of ordered dithering, which lets you simulate gradients and depth using a fixed matrix.

    If we scale this matrix appropriately, we can target specific values and create basic patterns.

    Here’s a simple example:

    // 2×2 Bayer matrix pattern: returns a value in [0, 1)
    float Bayer2(vec2 a)
    {
        a = floor(a);                // Use integer cell coordinates
        return fract(a.x / 2.0 + a.y * a.y * 0.75);
        // Equivalent lookup table:
        // (0,0) → 0.0,  (1,0) → 0.5
        // (0,1) → 0.75, (1,1) → 0.25
    }

    Let’s walk through an example of how this can be used:

    // 1. Base mask: left half is a black-to-white gradient 
    float mask = uv.y;
    
    // 2. Right half: apply ordered dithering
    if (uv.x > 0.5) {
        float dither = Bayer2(fragCoord);
        mask += dither - 0.5;
        mask  = step(0.5, mask); // binary threshold
    }
    
    // 3. Output the result
    fragColor = vec4(vec3(mask), 1.0);

    So with just a small matrix, we get four distinct dithering values—essentially for free.

    See the Pen
    Bayer2x2 by zavalit (@zavalit)
    on CodePen.

    Creating a Background Effect

    This is still pretty basic—nothing too exciting UX-wise yet. Let’s take it further by creating a grid on our UV map. We’ll define the size of a “pixel” and the size of the matrix that determines whether each “pixel” is on or off using Bayer ordering.

    const float PIXEL_SIZE = 10.0; // Size of each pixel in the Bayer matrix
    const float CELL_PIXEL_SIZE = 5.0 * PIXEL_SIZE; // 5x5 matrix
    
     
    float aspectRatio = uResolution.x / uResolution.y;
       
    vec2 pixelId = floor(fragCoord / PIXEL_SIZE); 
    vec2 cellId = floor(fragCoord / CELL_PIXEL_SIZE); 
    vec2 cellCoord = cellId * CELL_PIXEL_SIZE;
    
    vec2 uv = cellCoord/uResolution * vec2(aspectRatio, 1.0);
    
    vec3 baseColor = vec3(uv, 0.0);       

    You’ll see a rendered UV grid with blue dots for pixels and white (and subsequent blocks of the same size) for the Bayer matrix.

    See the Pen
    Pixel & Cell UV by zavalit (@zavalit)
    on CodePen.

    Recursive Bayer Matrices

    Bayer’s genius was a recursively generated mask that keeps noise high-frequency and code low-complexity. So now let’s try it out, and apply also larger dithering matrix:

    float Bayer2(vec2 a) { a = floor(a); return fract(a.x / 2. + a.y * a.y * .75); }
    #define Bayer4(a)   (Bayer2(0.5 * (a)) * 0.25 + Bayer2(a))
    #define Bayer8(a)   (Bayer4(0.5 * (a)) * 0.25 + Bayer2(a))
    #define Bayer16(a)   (Bayer8(0.5 * (a)) * 0.25 + Bayer2(a))
    
    ...
      if(uv.x > .2) dither = Bayer2 (pixelId);   
      if(uv.x > .4) dither = Bayer4 (pixelId);
      if(uv.x > .6) dither = Bayer8 (pixelId);
      if(uv.x > .8) dither = Bayer16(pixelId);
    ...

    This gives us a nice visual transition from a basic UV grid to Bayer matrices of increasing complexity (2×2, 4×4, 8×8, 16×16).

    See the Pen
    Bayer Ranges Animation by zavalit (@zavalit)
    on CodePen.

    As you see, the 8×8 and 16×16 patterns are quite similar—beyond 8×8, the perceptual gain becomes minimal. So we’ll stick with Bayer8 for the next step.

    Now, we’ll apply Bayer8 to a UV map modulated by fbm noise to make the result feel more organic—just as we promised.

    See the Pen
    Bayer fbm noise by zavalit (@zavalit)
    on CodePen.

    Adding Interactivity

    Here’s where things get exciting: real-time interactivity that background videos can’t replicate. Let’s run a ripple effect around clicked points using the dithering pattern. We’ll iterate over all active clicks and compute a wave:

     for (int i = 0; i < MAX_CLICKS; ++i) {
    
        // convert this click to square‑unit UV
        vec2 pos = uClickPos[i];
        if(pos.x < 0.0 && pos.y < 0.0) continue; // skip empty clicks
            
        vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution) )) * vec2(aspectRatio, 1.0);
    
        float t = max(uTime - uClickTimes[i], 0.0);
        float r = distance(uv, cuv);
    
        float waveR = speed * t;
        float ring  = exp(-pow((r - waveR) / thickness, 2.0));
        float atten = exp(-dampT * t) * exp(-dampR * r);
    
        feed = max(feed, ring * atten);           // brightest wins
    }

    Try to click on the CodePen bellow:

    See the Pen
    Untitled by zavalit (@zavalit)
    on CodePen.

    Final Thoughts

    Because the entire Bayer-dither background is generated in a single GPU pass, it renders in under 0.2 ms even at 4K, ships in ~3 KB (+ Three.js in this case), and consumes zero network bandwidth after load. SVG can’t touch that once you have thousands of nodes, and autoplay video is two orders of magnitude heavier on bandwidth, CPU and battery. In short: this is the probably one of the lightest fully-interactive background effect you can build on the open web today.



    Source link

  • Building a Real-Time Dithering Shader

    Building a Real-Time Dithering Shader


    In this post, we’ll take a closer look at the dithering-shader project: a minimal, real-time ordered dithering effect built using GLSL and the Post Processing library.

    Rather than just creating a one-off visual effect, the goal was to build something clean, composable, and extendable: a drop-in shader pass that brings pixel-based texture into modern WebGL pipelines.

    What It Does

    This shader applies ordered dithering as a postprocessing effect. It transforms smooth gradients into stylized, binary (or quantized) pixel patterns, simulating the visual language of early bitmap displays, dot matrix printers, and 8-bit games.

    It supports:

    • Dynamic resolution via pixelSize
    • Optional grayscale mode
    • Composability with bloom, blur, or other passes
    • Easy integration via postprocessing‘s Effect class

    Fragment Shader

    Our dithering shader implementation consists of two main components:

    1. The Core Shader

    The heart of the effect lies in the GLSL fragment shader that implements ordered dithering:

    bool getValue(float brightness, vec2 pos) {
    
    // Early return for extreme values
    if (brightness > 16.0 / 17.0) return false;
    if (brightness < 1.0 / 17.0) return true;
    
    // Calculate position in 4x4 dither matrix
    vec2 pixel = floor(mod(pos.xy / gridSize, 4.0));
    int x = int(pixel.x);
    int y = int(pixel.y);
    
    // 4x4 Bayer matrix threshold map
    // ... threshold comparisons based on matrix position
    
    }

    The getValue function is the core of the dithering algorithm. It:

    • Takes brightness and position: Uses the pixel’s luminance value and screen position
    • Maps to dither matrix: Calculates which cell of the 4×4 Bayer matrix the pixel belongs to
    • Applies threshold: Compares the brightness against a predetermined threshold for that matrix position
    • Returns binary decision: Whether the pixel should be black or colored

    Key Shader Features

    • gridSize: Controls the size of the dithering pattern
    • pixelSizeRatio: Adds pixelation effect for enhanced retro feel
    • grayscaleOnly: Converts the image to grayscale before dithering
    • invertColor: Inverts the final colors for different aesthetic effects

    2. Pixelation Integration

    float pixelSize = gridSize * pixelSizeRatio;
    vec2 pixelatedUV = floor(fragCoord / pixelSize) * pixelSize / resolution;
    baseColor = texture2D(inputBuffer, pixelatedUV).rgb;

    The shader combines dithering with optional pixelation, creating a compound retro effect that’s perfect for game-like visuals.

    Creating a Custom Postprocessing Effect

    The shader is wrapped using the Effect base class from the postprocessing library. This abstracts away the boilerplate of managing framebuffers and passes, allowing the shader to be dropped into a scene with minimal setup.

    export class DitheringEffect extends Effect {
      uniforms: Map<string, THREE.Uniform<number | THREE.Vector2>>;
    
      constructor({
        time = 0,
        resolution = new THREE.Vector2(1, 1),
        gridSize = 4.0,
        luminanceMethod = 0,
        invertColor = false,
        pixelSizeRatio = 1,
        grayscaleOnly = false
      }: DitheringEffectOptions = {}) {
        const uniforms = new Map<string, THREE.Uniform<number | THREE.Vector2>>([
          ["time", new THREE.Uniform(time)],
          ["resolution", new THREE.Uniform(resolution)],
          ["gridSize", new THREE.Uniform(gridSize)],
          ["luminanceMethod", new THREE.Uniform(luminanceMethod)],
          ["invertColor", new THREE.Uniform(invertColor ? 1 : 0)],
          ["ditheringEnabled", new THREE.Uniform(1)],
          ["pixelSizeRatio", new THREE.Uniform(pixelSizeRatio)],
          ["grayscaleOnly", new THREE.Uniform(grayscaleOnly ? 1 : 0)]
        ]);
    
        super("DitheringEffect", ditheringShader, { uniforms });
        this.uniforms = uniforms;
      }
    
     ...
    
    }

    Optional: Integrating with React Three Fiber

    Once defined, the effect is registered and applied using @react-three/postprocessing. Here’s a minimal usage example with bloom and dithering:

    <Canvas>
      {/* ... your scene ... */}
      <EffectComposer>
        <Bloom intensity={0.5} />
        <Dithering pixelSize={2} grayscale />
      </EffectComposer>
    </Canvas>

    You can also tweak pixelSize dynamically to scale the effect with resolution, or toggle grayscale mode based on UI controls or scene context.

    Extending the Shader

    This shader is intentionally kept simple, a foundation rather than a full system. It’s easy to customize or extend. Here are some ideas you can try:

    • Add color quantization: convert color.rgb to indexed palettes
    • Pack depth-based dither layers for fake shadows
    • Animate the pattern for VHS-like shimmer
    • Interactive pixelation: use mouse proximity to affect u_pixelSize

    Why Not Use a Texture?

    Some dithering shaders rely on threshold maps or pre-baked noise textures. This one doesn’t. The matrix pattern is deterministic and screen-space based, which means:

    • No texture loading required
    • Fully procedural
    • Clean pixel alignment

    It’s not meant for photorealism. It’s for styling and flattening. Think more zine than render farm.

    Final Thoughts

    This project started as a side experiment to explore what it would look like to bring tactile, stylized “non-photorealism” back into postprocessing workflows. But I found it had broader use cases, especially in cases where design direction favors abstraction or controlled distortion.

    If you’re building UIs, games, or interactive 3D scenes where “perfect” isn’t the goal, maybe a little pixel grit is exactly what you need.



    Source link