[UPDATED] N64 RDP Dither + VI Post-Process Effect

Updated version, I recommend having your viewport resolution be 320×240

This shader attempts to replicate the distinctive visual characteristics of the Nintendo 64’s rendering pipeline. Specifically focusing on 4 key aspects of it.

  • RDP: 5:5:5 RGB Color Quantization (15-bit Color)
  • RDP: Dithering Patterns
  • VI: ”Dither-Filter / Anti-Dither” Filter
  • VI: Horizontal Blur

Shader written using angrylion-rdp-plus as a point of reference or other wise i had to do alooot of guess work, but even then this shader is NOT an 1:1 conversion of what the original RDP code does but more-so an approximation, i had help from some members from the N64Brew Discord Server on questions related on the final image is rendered on the N64.

Just assign this shader on a color rect and its done.

 

Shader code
shader_type canvas_item;
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_nearest;

// ── Pass 1 settings ──
group_uniforms Quantization;
uniform int bits_per_channel : hint_range(1, 8) = 5;
uniform bool dither_enabled = true;
/** 0 = Magic, 1 = Bayer, 2 = Random Threshold */
uniform int dither_mode : hint_range(0, 2) = 0;
uniform bool time_varying_noise = false;
uniform bool anti_dither_enabled = true;
uniform bool divot_enabled = true;
group_uniforms;

// ── Pass 2 settings ──
group_uniforms VI;
uniform bool vi_enabled = true;
uniform float vi_x_add = 1.0;
uniform float vi_x_start = 0.0;
uniform float gamma : hint_range(0.5, 3.0) = 1.3;
group_uniforms;

const int MAGIC[16] = int[16](
    0,6,1,7, 4,2,5,3, 3,5,2,4, 7,1,6,0
);
const int BAYER[16] = int[16](
    0,4,1,5, 4,0,5,1, 3,7,2,6, 7,3,6,2
);

int dither_value(int x, int y, int mode) {
    int idx = ((y & 3) << 2) | (x & 3);
    return mode == 0 ? MAGIC[idx] : BAYER[idx];
}

int hash_seed(int x, int y, int s) {
    int n = x * 1973 + y * 9277 + s * 26699;
    n = (n << 13) ^ n;
    return n * (n * n * 15731 + 789221) + 1376312589;
}

int median3(int a, int b, int c) {
    return max(min(a, b), min(max(a, b), c));
}

int dither_channel(int c, int m, int shift, int mask, int noise_mask, int clamp_thresh) {
    int rounded = (c & mask) + (1 << shift);
    rounded = c > clamp_thresh ? 255 : rounded;
    int replace = (m - (c & noise_mask)) >> 31;
    return c + ((rounded - c) & replace);
}

ivec3 sample_rgb8(vec2 uv) {
    vec3 rgb = texture(screen_texture, clamp(uv, vec2(0.0), vec2(1.0))).rgb;
    return ivec3(clamp(rgb, 0.0, 1.0) * 255.0 + 0.5);
}

ivec3 dithered_c8(vec2 base_uv, ivec2 off, ivec2 px,
                  vec2 px_size,
                  int shift, int mask, int noise_mask, int clamp_thresh) {
    ivec3 c8 = sample_rgb8(base_uv + vec2(off) * px_size);
    if (!dither_enabled) return c8;
    ivec2 spx = px + off;
    if (dither_mode < 2) {
        int m = dither_value(spx.x, spx.y, dither_mode);
        return ivec3(
            dither_channel(c8.r, m, shift, mask, noise_mask, clamp_thresh),
            dither_channel(c8.g, m, shift, mask, noise_mask, clamp_thresh),
            dither_channel(c8.b, m, shift, mask, noise_mask, clamp_thresh)
        );
    }
    int seed = time_varying_noise ? int(floor(TIME * 1000.0)) : 0;
    int hr = hash_seed(spx.x, spx.y, seed);        hr = (hr ^ (hr >> 16)) & noise_mask;
    int hg = hash_seed(spx.x, spx.y, seed + 9277);  hg = (hg ^ (hg >> 16)) & noise_mask;
    int hb = hash_seed(spx.x, spx.y, seed + 18554); hb = (hb ^ (hb >> 16)) & noise_mask;
    return ivec3(
        dither_channel(c8.r, hr, shift, mask, noise_mask, clamp_thresh),
        dither_channel(c8.g, hg, shift, mask, noise_mask, clamp_thresh),
        dither_channel(c8.b, hb, shift, mask, noise_mask, clamp_thresh)
    );
}

ivec3 anti_dither_reconstruct(vec2 base_uv, ivec2 tex_px,
                               vec2 px_size,
                               int shift, int mask, int noise_mask, int clamp_thresh,
                               ivec3 mid8) {
    ivec3 base_q = mid8 >> shift;
    ivec3 accum  = ivec3(0);
    const ivec2 OFFSETS[9] = ivec2[9](
        ivec2(-1,-1), ivec2(0,-1), ivec2(1,-1),
        ivec2(-1, 0), ivec2(0, 0), ivec2(1, 0),
        ivec2(-1, 1), ivec2(0, 1), ivec2(1, 1)
    );
    for (int i = 0; i < 9; i++) {
        ivec3 nb = (OFFSETS[i] == ivec2(0, 0))
            ? mid8
            : dithered_c8(base_uv, OFFSETS[i], tex_px, px_size,
                          shift, mask, noise_mask, clamp_thresh);
        accum += clamp((nb >> shift) - base_q, ivec3(-1), ivec3(1));
    }
    return clamp((mid8 & ivec3(mask)) + accum, ivec3(0), ivec3(255));
}

ivec3 compute_quantized_pixel(ivec2 px, vec2 px_size,
                               int shift, int mask, int noise_mask, int clamp_thresh) {
    // Reconstruct the UV for this pixel's center
    vec2 base_uv = (vec2(px) + 0.5) * px_size;
    bool use_anti = dither_enabled && anti_dither_enabled && dither_mode != 2;

    ivec3 raw_mid = dithered_c8(base_uv, ivec2(0,0), px, px_size,
                                shift, mask, noise_mask, clamp_thresh);
    ivec3 c8_mid = use_anti
        ? anti_dither_reconstruct(base_uv, px, px_size,
                                  shift, mask, noise_mask, clamp_thresh, raw_mid)
        : ((raw_mid >> shift) << shift);

    if (divot_enabled) {
        ivec3 raw_l = dithered_c8(base_uv, ivec2(-1,0), px, px_size,
                                  shift, mask, noise_mask, clamp_thresh);
        ivec3 raw_r = dithered_c8(base_uv, ivec2( 1,0), px, px_size,
                                  shift, mask, noise_mask, clamp_thresh);
        ivec3 c8_left = use_anti
            ? anti_dither_reconstruct(base_uv, px + ivec2(-1,0), px_size,
                                      shift, mask, noise_mask, clamp_thresh, raw_l)
            : ((raw_l >> shift) << shift);
        ivec3 c8_right = use_anti
            ? anti_dither_reconstruct(base_uv, px + ivec2( 1,0), px_size,
                                      shift, mask, noise_mask, clamp_thresh, raw_r)
            : ((raw_r >> shift) << shift);
        c8_mid = ivec3(
            median3(c8_left.r, c8_mid.r, c8_right.r),
            median3(c8_left.g, c8_mid.g, c8_right.g),
            median3(c8_left.b, c8_mid.b, c8_right.b)
        );
    }
    return c8_mid;
}

void fragment() {
    int shift        = 8 - bits_per_channel;
    int mask         = 0xFF & ~((1 << shift) - 1);
    int noise_mask   = (1 << shift) - 1;
    int clamp_thresh = 255 - (1 << shift) + 1;

    vec2 tex_size = vec2(textureSize(screen_texture, 0));
    vec2 px_size  = 1.0 / tex_size;

    ivec2 tex_px = ivec2(floor(UV * tex_size));
    ivec3 result;

    if (vi_enabled) {
        // ── VI horizontal interpolation coordinates ──
        float x = UV.x * tex_size.x * vi_x_add + vi_x_start;
        int x_base = int(floor(x));
        int x_frac = int(fract(x) * 32.0);

        int max_base = max(int(tex_size.x) - 2, 0);
        x_base = clamp(x_base, 0, max_base);

        int y_px = tex_px.y;

        // ── Compute Pass 1 output for the two interpolation source pixels ──
        ivec3 c0 = compute_quantized_pixel(ivec2(x_base,     y_px), px_size,
                                            shift, mask, noise_mask, clamp_thresh);
        ivec3 c1 = compute_quantized_pixel(ivec2(x_base + 1, y_px), px_size,
                                            shift, mask, noise_mask, clamp_thresh);

        // ── VI horizontal lerp (fixed-point, 5-bit fraction) ──
        result = (c0 + (((c1 - c0) * x_frac + ivec3(16)) >> 5)) & ivec3(255);
    } else {
        // ── No VI — just quantize the current pixel ──
        result = compute_quantized_pixel(tex_px, px_size,
                                          shift, mask, noise_mask, clamp_thresh);
    }

    // ── Gamma correction ──
    vec3 col = vec3(result) / 255.0;
    col = pow(max(col, vec3(0.0)), vec3(1.0 / gamma));

    float alpha = texture(screen_texture, UV).a >= 0.5 ? 1.0 : 0.0;
    COLOR = vec4(col, alpha);
}
Live Preview
Tags
AngryLionRDP, blurry, N64, Nintendo, nintendo 64, post process, retro
The shader code and all code snippets in this post are under MIT license and can be used freely. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from Purpbatboi2i

Related shaders

guest

1 Comment
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
PorpleFish
PorpleFish
16 days ago

Hoooooly cow, that’s a great shader! really cool seeing the iterations on this one over time. Fantastic work!!