Color Swap with Hue Variation Preservation

This shader will convert a color to another with a margin of tolerance while trying to account for the slights variations in hue and saturations artists use when adding shades/highlights.

 

See the screenshot for a comparison on how it looks compared to tinting the values.

 

The way it works is pretty similar to HSV Adjustment in Krita or Photoshop, except you just indicate what final color you want instead of fiddling with the hsv sliders.

Shader code
// https://godotshaders.com/shader/color-swap-with-hue-variation-preservation
shader_type canvas_item;

// How to use:
// 1) Store the initial color in `from`
// 2) Store the target color in `to`
// 3) Adjust tolerance to grab a range of hues or set to 0 for exact match

uniform vec4 from : source_color;
uniform vec4 to : source_color;
uniform float tolerance: hint_range(0.0, 1.0);



// Color space conversion from https://godotshaders.com/shader/color-range-swap/
vec3 rgb2hsv(vec3 c)
{
    vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
    vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
    vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));

    float d = q.x - min(q.w, q.y);
    float e = 1.0e-10;
    return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

// All components are in the range [0…1], including hue.
vec3 hsv2rgb(vec3 c)
{
    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}


void fragment()
{
	// we usually want more granularity the closer we are to the original color
	float _tol = tolerance * tolerance;
	
	vec4 tex = texture(TEXTURE, UV);
	vec3 source_hsv = rgb2hsv(tex.rgb);
	vec3 initial_hsv = rgb2hsv(from.rgb);
	vec3 hsv_shift = rgb2hsv(to.rgb) - initial_hsv;
	
	float hue = initial_hsv.r;
	
	// the .r here represents HUE, .g is SATURATION, .b is LUMINANCE
	if (hue - source_hsv.r >= -_tol && hue - source_hsv.r <= +_tol)
	{
		vec3 final_hsv = source_hsv + hsv_shift;
		tex.rgb = hsv2rgb(final_hsv);
	}
	
	COLOR = tex;
}
Live Preview
Tags
Color, hue, palette, recolor, swap
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

Related shaders

Comments 2

  1. GourabSN

    For Godot 4, use this code.

    // https://godotshaders.com/shader/color-swap-with-hue-variation-preservation
    shader_type canvas_item;
    
    // How to use:
    // 1) Store the initial color in from
    // 2) Store the target color in to
    // 3) Adjust tolerance to grab a range of hues or set to 0 for exact match
    
    uniform vec4 from : source_color;
    uniform vec4 to : source_color;
    uniform float tolerance: hint_range(0.0, 1.0);
    uniform sampler2D SCREEN_TEXTURE : hint_screen_texture;
    
    
    
    // Color space conversion from https://godotshaders.com/shader/color-range-swap/
    vec3 rgb2hsv(vec3 c)
    {
        vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
        vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
        vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
    
        float d = q.x - min(q.w, q.y);
        float e = 1.0e-10;
        return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
    }
    
    // All components are in the range [0…1], including hue.
    vec3 hsv2rgb(vec3 c)
    {
        vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
        vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
        return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
    }
    
    
    void fragment()
    {
        // we usually want more granularity the closer we are to the original color
        float _tol = tolerance * tolerance;
        
        vec4 tex = texture(SCREEN_TEXTURE,SCREEN_UV);
        vec3 source_hsv = rgb2hsv(tex.rgb);
        vec3 initial_hsv = rgb2hsv(from.rgb);
        vec3 hsv_shift = rgb2hsv(to.rgb) - initial_hsv;
        
        float hue = initial_hsv.r;
        
        // the .r here represents HUE, .g is SATURATION, .b is LUMINANCE
        if (hue - source_hsv.r >= -_tol && hue - source_hsv.r <= +_tol)
        {
            vec3 final_hsv = source_hsv + hsv_shift;
            tex.rgb = hsv2rgb(final_hsv);
        }
        
        COLOR = tex;
    }
    
    3
  2. Great work! Thank you for posting this. I used it for my character customization screen (hair, beard, shirt, pants, shoe color), and it works like a charm. I tried to automate it a little bit by setting the "from" color to the most used color in the current sprite and the "to" color to a color picker control node.
    I'll leave these functions here maybe they'll be of some use to someone.

    #sometimes the most used color is my "baked into sprite" black outline and I want to ignore that. hence ignore_color
    static func extract_most_used_color(texture: Texture2D, ignore_color: Color) -> Color:
    	# Get the image from the texture
    	var image: Image = texture.get_image()
    	
    	# Dictionary to store color counts
    	var color_count: Dictionary= {}
    
    	# Iterate through each pixel
    	for y in range(image.get_height()):
    		for x in range(image.get_width()):
    			var color: Color = image.get_pixel(x, y)
    			if color.a == 0: # Ignore fully transparent pixels
    				continue
    			if color == ignore_color: # Ignore the specific color
    				continue
    			if color_count.has(color):
    				color_count[color] += 1
    			else:
    				color_count[color] = 1
    	
    	# Find the most used color
    	var most_used_color: Color = Color()
    	var max_count: int = 0
    	for color:Color in color_count.keys():
    		if color_count[color] > max_count:
    			max_count = color_count[color]
    			most_used_color = color
    	
    	return most_used_color
    

    The I use this function like

    #Called in the chain of color_changed signal of Colorpicker
    func update_part_color(value:Color,part:String) -> void:
    	#Set colorpicked Color for persistant savegame feature
    	Globals.clothingcolors[part+"Color"] = value
    	#for profile picture and character customization dummycharacter
    	for dude in get_tree().get_nodes_in_group("menududes"):
    		#If bodypart has the shader
    		if dude.get_node(part).material:
    			var most_used_color:Color = extract_most_used_color(dude.get_node(part).texture, Color("#0e071b"))
    			#Set shader params from and to
    			dude.get_node(part).material.set("shader_parameter/from", most_used_color)
    			dude.get_node(part).material.set("shader_parameter/to", Globals.clothingcolors[part+"Color"])
    
    0