Color Picker UI for Unity
Prerequisites
- Knowledge about shader structure for ShaderLab shaders
- Knowledge about Unity UI
Goal
- Creating a Shader to render a square surface with interpolated colours
- Creating a shader to render a surface with a hue gradient
- Method to sample a colour from the picker
- A simple UI to make use of these
- Shader support for URP and Legacy Render Pipelines
General
Converting HSV to RGB
Shaders does not work directly in HSV and as such we need a way to convert the colors into RGB. This handy function is a pretty common sight when dealing with this conversion. Giving it a float3 containing the HSV values it will give us back a float3 with the appropriate RGB values.
This function can also be found in:
Packages/com.unity.render-pipelines.core@8.2.0/ShaderLibrary/Color.hlsl
along with converting from RGB to HSV. I’ve chosen to include it here to make the transition between URP and Legacy Pipelines easier.
float3 hsv2rgb(float3 c) {
c = float3(c.x, clamp(c.yz, 0.0, 1.0));
float4 K = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
float3 p = abs(frac(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * lerp(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
The Color Picker Shader
To construct our colors we are going to make use of the HSV color space. This makes it much easier for us to pick a certain hue that we can later on modify the saturation and value of.
Shader Properties
First we need to setup the properties used by the shader. Since we want to interpolate over a surface the only value we actually need is the hue. We will do the saturation and value with the UV coordinates in our fragment shader.
Shader "ColorPickerShader"
{
Properties
{
_Hue ("Hue", Range(0.0, 1.0)) = 0.0
}
SubShader { ... }
}
The Vertex Program
The vertex program will simply just give us the UV and Clip coordinates directly. This shader is not dependent on a texture so we can simply forego the usual TRANSFORM_TEX macro you commonly see in this stage. This is the only stage that will differ between URP and Legacy rendering.
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
The Fragment Program
This is where the magic happens. We need to come up with a solution that will interpolate the color over the surface we are drawing on. Since we will operate on a square we can make use of the UV coordinates as supplied by the vertex stage.
Saturation is in the horizontal direction and Value is in the vertical direction so our code can be really simple to get the wanted effect. We simply pass i.uv
into the hsv2rgb(...)
function along with the hue and it will give us back an interpolated color we can use.
float4 frag (v2f i) : SV_Target
{
float3 rgb = float3(_Hue, i.uv.x, i.uv.y);
return float4(hsv2rgb(rgb), 1.0);
}
One note on mobile support is that you probably want to change float4 and float3 into fixed4 and fixed3. fixed is also know as half precision meaning it will be in 16 bits. This does not make a difference for modern desktop GPUs because they always use 32 bit, but for most mobile GPUs it will make a difference.
The Hue Shader
Second shader we need is very similar to the one above, but we now only want to interpolate the color over the surface to represent the hue. As such we will reuse some of the code. This shader requires no properties and we dont need to set any values to it later on.
The Fragment Program
We are just interested in the color interpolated along the x-axis of our surface. As such we will still make use of the UV space, but this time we only need the X component. Use the same hsv2rgb(...)
function as described above and use i.uv.x
as the hue or x component of the HSV value we put into the function.
A material created from this shader will make the surface go through all the available RGB values interpolated over the x range of the UV space of the mesh.
fixed4 frag (v2f i) : SV_Target
{
float4 col = 1;
col.rgb = hsv2rgb(float3(i.uv.x, 1, 1));
return col;
}
To make this support a vertical slider you’d change
i.uv.x
toi.uv.y
.
Constructing the Shader for the different Pipelines
Below you will see the base shell for a shader that will support either Legacy or URP. You’ll probably notice that they’re very similar in structure. The only difference is that the Scriptable Render Pipeline uses shader code from the HLSLPROGRAM / ENDHLSL
and the old legacy pipeline uses CGPROGRAM / ENDCG
. We also need to change our includes because the new Scriptable Render Pipeline has a completely new library backend.
Follow the comments and make one shader for “HuePicker” and one for “ColorPicker” by pasting the code as shown above. The vertex program is the same for both shaders, but the fragment program will be different between them.
Shader "ShaderName"
{
Properties {...}
SubShader
{
Tags { ... }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
// Paste the vert program from above
// Paste the hsv2rbg function from above
// Paste the frag program for either ColorPicker or Hue Shader above
ENDCG
}
}
}
For full source of the Shaders above you can check out the GitHub Repo
The UI/C# Part
We are now done with the shader part and can create our Color Picker UI so we can interact and use the shaders above. The shaders themselves are just the visual part and we wont actually be using them to pick the colours.
Setting up the UI Hierarchy
Our UI is split into two parts, one for the hue and another for the saturation and value components of the HSV color.
The Outer Container
We need something to hold these two components so we create the base container, namely the ColorPickerUI object as seen in the image above. This controls the overall size of the Color Picker component and we will attach the ColorPickerUI
component to this object.
ColorPickerUI
For now this class contains a lot of knowledge about how we solve the UI setup. The important bits for now is how we sample the color in C#. Lucky for us Unity has a utility method we can use to convert HSV to RGB which you can call by using Color.HSVToRGB(...)
.
To get the selected color from the picker you can either call on ColorPickerUI.SelectedColor
or subscribe to the colorChanged
event that is present in this class. Having an event available means that we can hook into the color without directly having a reference to this class. You could also make this be a UnityEvent<Color>
so you can hook it all up from the inspector.
public class ColorPickerUI : MonoBehaviour
{
[SerializeField] AreaSliderUI colorPicker;
[SerializeField] SliderUIBase hueSlider;
Color selectedColor;
public Color SelectedColor => selectedColor;
public event System.Action<Color> colorChanged;
void Start()
{
colorPicker.valueChanged += (value) =>
{
selectedColor = Color.HSVToRGB(hueSlider.Percent, value.x, value.y);
colorPicker.SetKnobColor(selectedColor);
colorChanged?.Invoke(selectedColor);
};
hueSlider.valueChanged += (percent) =>
{
selectedColor = Color.HSVToRGB(
percent,
colorPicker.NormalizedPosition.x,
colorPicker.NormalizedPosition.y);
colorPicker.SetKnobColor(selectedColor);
colorPicker
.GetComponent<RawImage>()
.materialForRendering
.SetFloat("_Hue", percent);
colorChanged?.Invoke(selectedColor);
};
}
}
The Slider Base Class
To make our sliders a bit more generic and usable for different contexts we make an abstract base class for them. All sliders represent a normalized value but the UI part could be a 1D or 2D where each of those can have different variants that put different constraints on the selection knob. As such our base class will have some generic properties that are common for all sliders. The generic type argument T
could be a float
for a 1D slider or a Vector2
for a 2D slider.
We again create an event and a public property to access the current value of the slider. In this instance we lock the value to be normalized between 0 and 1 in order to simplify it. The valueChanged
event is what we use in the ColorPickerUI
class above in order to update our selected color only when the slider changed.
It’s important that the container
field is set to the RectTransform
that defines the size of the area the slider is constrained to. If the slider is set to inherit the parent size you need to set the parent’s RectTransform
as the container
GUIEvent Class is explained in the “Extras” section below.
public abstract class SliderUIBase<T> : MonoBehaviour, IPointerDownHandler
{
[SerializeField] protected RectTransform container;
[SerializeField] protected GUIEvent knob;
protected RectTransform knobTransform;
public T Value { get; protected set; }
public event System.Action<T> valueChanged;
void Start()
{
knob.mouseDrag += OnKnobDrag;
if (container == null)
{
Debug.LogException(
new System.ArgumentNullException(
$"Container of {this.GetType().Name} is null"),
this.gameObject);
}
knobTransform = knob.GetComponent<RectTransform>();
OnKnobDrag(Vector2.zero);
}
protected virtual void OnAreaClicked(Vector2 pos) { }
protected virtual void OnKnobDrag(Vector2 delta) { }
protected void DispatchValueChanged()
{
valueChanged?.Invoke(Value);
}
public abstract void TrySetKnob(Vector2 pixelPos);
public abstract void OnPointerDown(PointerEventData eventData);
}
The Slider Class
This class is responsible for all sliders that will be constrained to 1D and in this instance it is only usable for sliders in the horizontal direction.
To get the normalized value out of the slider we need both the size of the containing RectTransform and the position of the Knob. Because of how layout was setup we need to add 0.5f
to the resulting value to offset it. A call to the DispatchValueChange()
method on the base class ensures that any observers are notified of the change in value.
As an additional note, we also listen to the
OnPointerDown(...)
event here in order to make click-then-drag action available. This is a nice Quality of Life feature that makes the UI feel more responsive.
public class SliderUI : SliderUIBase<float>
{
protected override void OnAreaClicked(Vector2 pos)
{
Vector3 newPos = new Vector3(
pos.x,
knob.transform.position.y,
knob.transform.position.z);
TrySetKnob(pos);
}
protected override void OnKnobDrag(Vector2 delta)
{
RectTransform knobTransform = knob.GetComponent<RectTransform>();
Vector3 newPos = knobTransform.position + new Vector3(delta.x, 0, 0);
TrySetKnob(newPos);
}
public override void TrySetKnob(Vector2 pixelPos)
{
var rect = container.rect;
rect.size *= container.lossyScale;
rect.center = (Vector2)container.position;
// Limit Position
if (rect.Contains(pixelPos))
{
knobTransform.position = pixelPos;
}
Value = (knobTransform.localPosition.x / container.sizeDelta.x) + 0.5f;
knobTransform.localPosition =
new Vector3(knobTransform.localPosition.x, 0f, 0f);
DispatchValueChanged();
}
public override void OnPointerDown(PointerEventData eventData)
{
OnAreaClicked(eventData.pressPosition);
knob.SetState(GUIEvent.State.Drag);
}
}
The AreaSlider Class
The AreaSlider class still makes use of SliderUIBase<T>
but now we will use Vector2
as the type so we can represent a 2D value. We also have to constrict the position of the knob to be inside the RectTransform
of the GameObject
this component is attached to.
Getting the normalized value of a 2D slider is similar to how it works with a 1D slider. We take the Knobs position and divide it by the sizeDelta
of the RectTransform. We still need to offset it by 0.5f
in order to account for the layout settings for the UI, although this might differ if you configure your layout differently.
public class AreaSliderUI : SliderUIBase<Vector2>
{
protected override void OnKnobDrag(Vector2 delta)
{
Vector3 newPos = knob.transform.position + (Vector3)delta;
TrySetKnob(newPos);
}
public override void TrySetKnob(Vector2 pixelPos)
{
var rect = container.rect;
rect.size *= container.lossyScale;
rect.center = (Vector2)container.position;
// Limit X Position
if (pixelPos.x > rect.xMin && pixelPos.x < rect.xMax)
{
knob.transform.position =
new Vector3(pixelPos.x,
knob.transform.position.y,
knob.transform.position.z);
}
// Limit Y Position
if (pixelPos.y > rect.yMin && pixelPos.y < rect.yMax)
{
knob.transform.position =
new Vector3(knob.transform.position.x,
pixelPos.y,
knob.transform.position.z);
}
Value =
(knob.transform.localPosition / container.rect.size)
+ Vector2.one * 0.5f;
DispatchValueChanged();
}
public void SetKnobColor(Color color)
{
knob.GetComponent<Image>().color = color;
}
public override void OnPointerDown(PointerEventData eventData)
{
TrySetKnob(eventData.position);
knob.SetState(GUIEvent.State.Drag);
}
}
Putting it all together
We are now ready to attach the components we created and hook it all together into a functioning Color Picker.
Color Picker
The ColorPicker GameObject needs to be a RawImage
with two additional components, AreaSliderUI
and RawImageMaterialCopy
. Attach a material you make from the ColorPickerShader
we made above to the RawImage
The ColorPicker object has one child that is the Knob with a GUIEvent
component attached to it. Drag this into the Knob
field and this part of the Color Picker is done.
We use RawImage because a regular Image component does not support standard materials.
RawImageMaterialCopy ensures that we create a copy of the material so we can have multiple Color Pickers active at a time. More on this in the Extras section below.
Hue Picker
We again use a RawImage
component so that we can attach a material created from the HuePickerShader
we created. To make the hue picker stick to the bottom of the outer container we also attach a Layout Element
where we can decide the wanted height and other settings. The height we set here should correspond to the height we remove from the bottom
field of the Color Picker’s RectTransform
. Lastly we need to attach the SliderUI
component and add a child with the GUIEvent
component that we bind to it in the inspector.
This UI element does not need the
RawImageMaterialCopy
component as we don’t need to change any properties on the material.
Complete
You should now have all the pieces and have put together the Color Picker UI. You can tweak and modify it to your needs as most of the UI design is completely up to you. Explanations of some of the extra utility classes used can also be found below, under the Extras section.
Extras
RawImageMaterialCopy Class
We need to instantiate our materials in order to allow multiple Color Pickers active at a time. This can simply be done by creating a script that instantiates a copy of the material at runtime. Other solutions exists but for something as simple as this where we wont see hundreds of these active at a time this is the simplest solution.
public class RawImageMaterialCopy : MonoBehaviour
{
RawImage rawImage;
void Awake()
{
rawImage = GetComponent<RawImage>();
rawImage.material = new Material(rawImage.material);
}
}
GUIEvent Class
You probably noticed the use of a GUIEvent class above. This is a class to make listening to pointer events on UI objects easier, while also giving us the current state it is in. We also keep track of the current drag delta whenever the object is dragged with the mouse.
We again make use of events in order to subscribe to the different states the UI component can be in. This comes in handy when we f.ex. wanted to have a movable knob on our slider above.
I wont go into too much detail here, but we essentially have an enum of the State that is set to Flag mode. Flag mode allows us to use the enum as a bit-mask meaning it can have any combination of the given values. You might notice the use of 1u << 0
and so on to set a specific bit. 1u << 0
will set the first bit of a 32 bit unsigned int, thus giving it a value of 1.
public class GUIEvent : MonoBehaviour,
IPointerDownHandler, IPointerClickHandler,
IPointerEnterHandler, IPointerExitHandler
{
[System.Flags]
public enum State : uint
{
None = 0u,
All = ~0u,
Down = 1u << 0,
Drag = 1u << 2,
Over = 1u << 3
}
public event System.Action mouseDown;
public event System.Action mouseUp;
public event System.Action mouseEnter;
public event System.Action mouseLeave;
public event System.Action<Vector2> mouseDrag;
State state;
public State CurrentState => state;
Vector3 lastMousePosition;
Vector2 mouseDelta;
public void OnPointerDown(PointerEventData eventData)
{
state |= State.Down | State.Drag;
mouseDown?.Invoke();
}
public void OnPointerClick(PointerEventData eventData)
{
state &= ~State.Down;
state &= ~State.Drag;
mouseUp?.Invoke();
}
public void OnPointerEnter(PointerEventData eventData)
{
state |= State.Over;
mouseEnter?.Invoke();
}
public void OnPointerExit(PointerEventData eventData)
{
state &= ~State.Over;
mouseLeave?.Invoke();
}
void Update()
{
mouseDelta = Input.mousePosition - lastMousePosition;
lastMousePosition = Input.mousePosition;
if ((state & State.Drag) != 0)
{
mouseDrag?.Invoke(mouseDelta);
}
if ((state & State.Drag) != 0 && Input.GetKeyUp(KeyCode.Mouse0))
{
state &= ~State.Drag;
}
}
public void SetState(State state)
{
this.state |= state;
}
}