Purp’s N64 Soft/Blurry Post Process Effect

JUNE 25 UPDATE – COMPLETE OVERHAUL
The shader has been re-written to be more performant! tho at the cost of some functionality.
Blur radius X/Y, Blur Quality, Blur Strenght, Blur Enable” has been removed for a simple toggle “enable_480i_upscale” and “scanline_intensity” which uses already pre-calculated blur weights. Not as customizable but good for my personal uses.

Made this shader to mimic the Soft/Blurry look of the Nintendo 64, Dithering Matrix Pattern was taken from the AngrylionRDP-Plus.

This shader is meant to be used on the SubViewportContainer node.

Shader code
shader_type canvas_item;

// Purp's N64 Soft/Blurry Post-Process V2

// ============================================================================
// SHADER UNIFORMS - User-configurable parameters
// ============================================================================

/** The resolution of your subviewport */
uniform vec2 virtual_resolution = vec2(320.0, 240.0);

/** You can set the bitdepth value for all 3 color components. (Ex: 555 = 15 BitRGB) */
uniform int color_bitdepth : hint_range(1, 8) = 5;

/** Controls the strength of the dithering effect */
uniform float dither_strength : hint_range(0.0, 1.0) = 1.0;

/** Toggle between standard Bayer matrix and magic matrix */
uniform bool use_magic_matrix = false;

/** Enable/disable dedithering effect */
uniform bool enable_dedithering = false;

/** Controls the strength of the dedithering effect */
uniform float dedither_strength : hint_range(0.0, 1.0) = 1.0;

/** Enables the a quick "480i upscale" to add an horizontal blur */
uniform bool enable_480i_upscale = true;

/** Simple Scanline */
uniform float scanline_intensity : hint_range(0.0, 1.0) = 0.1;

// ============================================================================
// DITHERING MATRICES - Predefined patterns for dithering
// ============================================================================

// Standard 4x4 Bayer matrix - creates ordered dithering pattern
const mat4 bayer_matrix = mat4(
    vec4( 0.0,  4.0,  1.0,  5.0),
    vec4( 4.0,  0.0,  5.0,  1.0),
    vec4( 3.0,  7.0,  2.0,  6.0),
    vec4( 7.0,  3.0,  6.0,  2.0)
);

// Alternative "magic" 4x4 matrix - creates different dithering pattern
const mat4 magic_matrix = mat4(
    vec4( 0.0,  6.0,  1.0,  7.0),
    vec4( 4.0,  2.0,  5.0,  3.0),
    vec4( 3.0,  5.0,  2.0,  4.0),
    vec4( 7.0,  1.0,  6.0,  0.0)
);


// ============================================================================
// CORE DITHERING / DE-DITHERING FUNCTIONS
// ============================================================================

float get_bayer_threshold(vec2 screen_pos) {
    int x = int(floor(screen_pos.x)) % 4;
    int y = int(floor(screen_pos.y)) % 4;

    mat4 selected_matrix = use_magic_matrix ? magic_matrix : bayer_matrix;

    return selected_matrix[y][x] / 7.0;
}

float quantize_channel(float value, int bits, float threshold) {
    if (bits <= 0) return 0.0;
    if (bits >= 8) return value;

    float max_value = float((1 << bits) - 1);
    float dither_amount = (threshold - 0.5) * dither_strength / max_value;
    float dithered_value = clamp(value + dither_amount, 0.0, 1.0);

    return round(dithered_value * max_value) / max_value;
}

vec3 get_dithered_color_at_uv(sampler2D tex, vec2 uv) {
    vec4 color = texture(tex, uv);

    vec2 virtual_pixel_coords = uv * virtual_resolution;
    float bayer_threshold = get_bayer_threshold(virtual_pixel_coords);

    vec3 quantized_color;
    quantized_color.r = quantize_channel(color.r, color_bitdepth, bayer_threshold);
    quantized_color.g = quantize_channel(color.g, color_bitdepth, bayer_threshold);
    quantized_color.b = quantize_channel(color.b, color_bitdepth, bayer_threshold);

    return quantized_color;
}

vec3 reconstruct_color_optimized(vec3 center_color, vec3 neighbors[8], int bits, float strength) {
    float max_value = float((1 << bits) - 1);
    vec3 reconstructed = vec3(0.0);

    float level_tolerance = mix(0.5, 2.0, strength);

    for (int channel = 0; channel < 3; channel++) {
        // Extract current channel value
        float center_val = (channel == 0) ? center_color.r :
                          (channel == 1) ? center_color.g : center_color.b;

        float sum = center_val;
        float count = 1.0;

        for (int i = 0; i < 8; i++) {
            float neighbor_val = (channel == 0) ? neighbors[i].r :
                                (channel == 1) ? neighbors[i].g : neighbors[i].b;

            // Calculate quantization levels
            float center_level = floor(center_val * max_value);
            float neighbor_level = floor(neighbor_val * max_value);

            if (abs(center_level - neighbor_level) <= level_tolerance) {
                sum += neighbor_val * strength;
                count += strength;
            }
        }

        float averaged = sum / count;

        if (channel == 0) reconstructed.r = averaged;
        else if (channel == 1) reconstructed.g = averaged;
        else reconstructed.b = averaged;
    }

    return mix(center_color, reconstructed, strength);
}

vec3 get_processed_color_at_uv_optimized(sampler2D tex, vec2 uv, vec2 texel_size) {
	
    vec3 dithered_color = get_dithered_color_at_uv(tex, uv);

    if (!enable_dedithering) {
        return dithered_color;
    }
	
    vec2 neighbor_offsets[8] = vec2[](
        vec2(-1.0, -1.0), vec2(0.0, -1.0), vec2(1.0, -1.0),
        vec2(-1.0,  0.0),                   vec2(1.0,  0.0),
        vec2(-1.0,  1.0), vec2(0.0,  1.0), vec2(1.0,  1.0) 
    );

    vec3 neighbors[8];
    for (int i = 0; i < 8; i++) {
        vec2 neighbor_uv = uv + neighbor_offsets[i] * texel_size;
		
        if (all(greaterThanEqual(neighbor_uv, vec2(0.0))) && all(lessThanEqual(neighbor_uv, vec2(1.0)))) {
            neighbors[i] = get_dithered_color_at_uv(tex, neighbor_uv);
        } else {
            neighbors[i] = dithered_color;
        }
    }
	
    vec3 dedithered_color = reconstruct_color_optimized(dithered_color, neighbors, color_bitdepth, dedither_strength);
    return clamp(dedithered_color, 0.0, 1.0);
}

// ============================================================================
// 480i Upscale
// ============================================================================

// Fast 3-tap horizontal blur using precalculated weights
// Precalculate blur weights and offsets

const vec3 blur_weights = vec3(0.27901, 0.44198, 0.27901);

vec3 apply_n64_upscale(sampler2D tex, vec3 color, vec2 uv, vec2 texel_size) {
	if (!enable_480i_upscale) return color;
		float pixel_y = uv.y * virtual_resolution.y;
	
		vec3 blur_color = color * blur_weights.y;
	
		vec2 offset = vec2(texel_size.x, 0.0);
		
		if (uv.x >= texel_size.x && uv.x <= 1.0 - texel_size.x) {
    		blur_color += get_processed_color_at_uv_optimized(tex, uv - offset, texel_size) * blur_weights.x + get_processed_color_at_uv_optimized(tex, uv + offset, texel_size) * blur_weights.z;
		} 
		else {
    		blur_color += color * (blur_weights.x + blur_weights.z);
		}
		
	float scanline_darkening = 1.0 - (0.2 * scanline_intensity * float(int(pixel_y) & 1));
	
	return blur_color * scanline_darkening;
}

// ============================================================================
// MAIN FRAGMENT SHADER
// ============================================================================

void fragment() {
    vec4 original_color = texture(TEXTURE, UV);
    vec2 texel_size = 1.0 / virtual_resolution;

    vec3 final_color = get_processed_color_at_uv_optimized(TEXTURE, UV, texel_size);
    
    // Apply N64 upscaling effects with texture parameter
    final_color = apply_n64_upscale(TEXTURE, final_color, UV, texel_size);

    COLOR = vec4(final_color, original_color.a);
}
Live Preview
Tags
blurry, N64, 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

4 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Dr_Box
Dr_Box
6 months ago

This is grey for me when I try it. What am I doing wrong?

MAL7
5 months ago

Preciso de instruções de como usar 👀🙏🏾!!!

sweetbabyalaska
2 months ago
Reply to  MAL7

SubViewPortContainer -> SubViewPort -> Camera3D – is the scene hierarchy that you want, then add a shader material to the SubViewPortContainer and add this shader to that material. Then adjust to your liking.