Having trouble rendering nearest pixels with respect to the texture's pixel size

SOLVED! Thanks to @hyvernox for his solution of adding 0.5 to the floored value in my calculate_pixel_perfect function.

float calculate_pixel_perfect(float uv, float size) {
	float pixel_perfect_uv = ((floor(uv * size) + 0.5) / size);
	return pixel_perfect_uv;

Godot Version

v4.5.1.stable.steam [f62fdbde1]

Question

How do I fix my code to accurately render nearest pixels with respect to the texture’s pixel size?

So I have a modified version of Youkuri’s Bounding Battle Background shader and I’m trying to add a pixel_perfect parameter that filters all of the effects applied to the texture to the nearest neighboring pixel with respect to the texture’s pixel size. NOT the screen’s pixel size! The texture’s pixel size! Think of it as the Nearest renderer, but with the texture’s pixel size.

However, I’m having issues with the implementation. The math I’m using to round UV to the nearest neighbor isn’t accurate and results in a lot of deformations, as you’ll see in the examples below.

Examples

Here’s an example with a checkerboard texture:

And here’s another example. This is a background texture with the pixel_perfect parameter disabled:

And the same texture with the pixel_perfect parameter enabled:

If you zoom in closer with pixel_perfect enabled, you will notice a lot of deformations (particularly with chunks of outline not rendering at all):

Code

Here’s the code I use to approximate each pixel’s location.
My logic here was that I’d multiply one of the UV’s components (which is normally a range between 0.0 and 1.0) by the texture width/height (depending on the UV component) to get the UV component relative to the texture size. Then, I’d floor that value to get the nearest pixel relative to texture size and divide that floored value by the texture width/height (depending on the UV component) to get the nearest pixel relative to the UV scale.

float calculate_pixel_perfect(float uv, float size) {
	float pixel_perfect_uv = (floor(uv * size) / size);
	return pixel_perfect_uv;

Here’s the code I use to modify the texture with the pixel_perfect parameter enabled, which includes the calculate_pixel_perfect() function from before:

if (pixel_perfect) {
	// Pixel perfect rendering; doesn't work as intended
	// texture_size is a self-explanatory vec2 calculated earlier in the code with this formula: vec2 texture_size = 1.0 / TEXTURE_PIXEL_SIZE;
	textube = texture(TEXTURE, vec2(calculate_pixel_perfect(UV.x, texture_size.x) + def_x + wav_x, calculate_pixel_perfect(UV.y, texture_size.y) + def_y + wav_y) + move);
}

Here’s the full shader code:

shader_type canvas_item;

// Bounding Battle Backgound shader! Original by Youkuri, modified by DragonAero
// This shader is under the Creative Commons 0 license so feel free to use it to your heart contents! No need of crediting or anything.
// NOTE: Filter *does* matter when it comes to palette cycling, Filtered sprites makes it look smoother and Unfiltered makes them change in an instant

group_uniforms Settings;
uniform bool pixel_perfect = true;
uniform bool snes_transparency = false;
uniform bool gba_transparency = false;
uniform vec2 scanline = vec2(0.0, 0.0);
uniform vec4 scanline_color : source_color = vec4(0.0, 0.0, 0.0, 1.0);
uniform bool enable_palette_cycling = false;

group_uniforms Sprite_Scroll;
uniform vec2 sprite_scroll_direction = vec2(0.0, 0.0);
uniform float sprite_scroll_speed = 0.01;


group_uniforms GBA_Transparency;
uniform vec2 gba_transparency_scroll_direction = vec2(0.0, 0.0);
uniform float gba_transparency_scroll_speed = 0.01;
uniform float gba_transparency_value : hint_range(0.0, 1.0) = 0.5;

group_uniforms Wave;
uniform vec2 wave_amplitude = vec2(0.0, 0.0);
uniform vec2 wave_frequency = vec2(0.0, 0.0);
uniform vec2 wave_speed = vec2(1.0, 1.0);

group_uniforms Deform;
uniform vec2 deform_amplitude = vec2(0.0, 0.0);
uniform vec2 deform_frequency = vec2(0.0, 0.0);
uniform vec2 deform_speed = vec2(1.0, 1.0);

/*group_uniforms Size;
uniform float width = 0.0;
uniform float height = 0.0;*/

group_uniforms Palette_Cycling;
uniform float palette_cycling_speed = 0.1;
uniform sampler2D palette;



float calculate_diff(float uv, float amp, float freq, float spd) {
	float diff_x = amp * sin((freq * uv) + (TIME * spd));
	return diff_x;
}

vec2 calculate_move(vec2 dir, float spd) {
	vec2 move = dir * TIME*sprite_scroll_speed;
	return move;
}

float calculate_pixel_perfect(float uv, float size) {
	float pixel_perfect_uv = (floor(uv * size) / size);
	return pixel_perfect_uv;
}

void fragment() {
	vec2 texture_size = 1.0 / TEXTURE_PIXEL_SIZE;
	
	float def_x = calculate_diff(UV.x, deform_amplitude.x, deform_frequency.x, deform_speed.x);
	float def_y = calculate_diff(UV.y, deform_amplitude.y, deform_frequency.y, deform_speed.y);
	float wav_x = calculate_diff(UV.y, wave_amplitude.x, wave_frequency.x, wave_speed.x);
	float wav_y = calculate_diff(UV.x, wave_amplitude.y, wave_frequency.y, wave_speed.y);
	
	vec2 move = calculate_move(sprite_scroll_direction, sprite_scroll_speed);
	
	
	if ( int( UV.y * texture_size.y ) % 2 == 0 && snes_transparency)
	{
		
		wav_x = -wav_x;
		
	}
	
	
	vec4 textube;
	if (pixel_perfect) {
		textube = texture(TEXTURE, vec2(calculate_pixel_perfect(UV.x, texture_size.x) + def_x + wav_x, calculate_pixel_perfect(UV.y, texture_size.y) + def_y + wav_y) + move);
		//textube = texture(TEXTURE, vec2(floor((UV.x+def_x+wav_x) * texture_size.x) / texture_size.x, floor((UV.y+def_y+wav_y) * texture_size.y) / texture_size.y) + move);
	} else {
		textube = texture(TEXTURE, vec2(UV.x+def_x + wav_x, UV.y+def_y + wav_y) + move);
	}
	
	
	if (gba_transparency)
	{
		
		float copy_wav_x = -calculate_diff(UV.y, wave_amplitude.x, wave_frequency.x, wave_speed.x);
		vec4 tex_copy;
		
		if ( int( UV.y * texture_size.y ) % 2 == 1 && snes_transparency)
		{
			
			copy_wav_x = -copy_wav_x;
			
		}
		
		if (gba_transparency_scroll_direction != vec2(0.0)) {
			
			vec2 copy_move = calculate_move(gba_transparency_scroll_direction, gba_transparency_scroll_speed);
			tex_copy = texture(TEXTURE, vec2(calculate_pixel_perfect(UV.x, texture_size.x) +def_x + copy_wav_x, calculate_pixel_perfect(UV.y, texture_size.y) +def_y + wav_y) + copy_move);
			
		} else {
			if (pixel_perfect) {
				tex_copy = texture(TEXTURE, vec2(calculate_pixel_perfect(UV.x, texture_size.x) + def_x + copy_wav_x, calculate_pixel_perfect(UV.y, texture_size.y) + def_y + wav_y) + move);
			} else {
				tex_copy = texture(TEXTURE, vec2(UV.x+def_x + copy_wav_x, UV.y+def_y + wav_y) + move);
			}	
		}
		
		textube = mix(textube, tex_copy, gba_transparency_value);
		
	}
	
	
	float palette_swap = mod(textube.r - TIME*palette_cycling_speed, 1.0);
	
	if (enable_palette_cycling)
	{
		
		textube = vec4(texture(palette, vec2(palette_swap, 0)).rgb, textube.a);
		
	}
	
	COLOR = textube;
	
	if (bool(scanline.x)) COLOR = mix(scanline_color, COLOR, float(int(UV.y * (texture_size.y / float(scanline.x))) % 2));
	if (bool(scanline.y)) COLOR = mix(scanline_color, COLOR, float(int(UV.x * (texture_size.x / float(scanline.y))) % 2));
	
}

How do I fix my code to accurately render nearest pixels with respect to the texture’s pixel size?

I’d try to add vec2(0.5) to the floored value (to get the center of the texel) before dividing by the width.
Or convert the floored value to ivec2 and use texelFetch() (instead of dividing by the width in this case).

1 Like