Parse a hex string, shift a hue, blend two colors without going gray in the middle, check text contrast against WCAG, preview a palette through color blindness, and derive harmonies and shading ramps from one base color.
Summary
ColorUtil is a set of helpers for Unity’s Color and Color32, most of them extension methods. It covers the color work that turns up in most projects and is slightly too fiddly to keep rewriting: parsing a hex string a designer pasted from a design tool, nudging a hue without disturbing saturation, and interpolating between two colors without the muddy gray that a naive RGB lerp produces.
The interesting part is the color spaces. Alongside the usual HSV and HSL, ColorUtil adds Oklab and Oklch, a pair of perceptually uniform spaces designed so that equal numeric steps look like equal visual steps. That matters most for gradients and tweens. Lerping red to blue in RGB passes through a desaturated purple-gray because the shortest line in RGB is not the shortest line in perception; LerpOklch walks around the hue circle instead and stays vivid the whole way.
Four smaller classes build on that foundation:
ColorAnalysismeasures colors: WCAG relative luminance and contrast ratio, a pass or fail check against level AA or AAA, perceptual distance in Oklab, and an estimate of color temperature.ColorBlindnessSimulatorshows how a color, or a whole span of colors, looks to a viewer with one of seven color vision deficiencies.ColorHarmonyderives a scheme from one base color, for example a complementary pair, a triad or an analogous set, and can hold perceived brightness constant while it rotates the hue.ColorRampsderives tints, shades, tones, full dark-to-light ramps and four-step material tones, blending in Oklab so the steps look even.
Together they let a theme or a sprite palette start from one base color and be checked for legibility and color blindness before anyone has to squint at a screenshot. There is also a Color32 mirror of most of ColorUtil, and a family of random-color generators for constrained hues, vivid or pastel picks, and colors that stand apart from a background.
Use the color utilities for:
- Turning designer-supplied hex codes into
Colorat runtime, from a config file, a data table or a save. - Deriving UI state colors from one base color: hover, pressed and disabled as brightness or saturation shifts.
- Checking that text and icons meet a WCAG contrast level against the panels they sit on.
- Making sure status colors stay distinguishable for color-blind players.
- Building gradients, harmonies and shading ramps that stay saturated instead of washing out.
Features
- Hex parsing that accepts what people actually paste.
TryParseHexhandles"FF6600","#FF6600"and the eight-digit"FF660080"form with alpha, returningfalserather than throwing on anything it cannot read. - Perceptually uniform blending.
LerpOklabandLerpOklchinterpolate through spaces built so equal steps look equal, which keeps a gradient vivid through the middle instead of sagging to gray. - HSV and HSL both. Get and set hue, saturation, value and lightness individually, or convert whole colors with
ToHSV,FromHSV,ToHSLandFromHSL. - Non-destructive channel edits. The
With*family returns a modified copy:WithAlpha,WithHue,WithSaturation,WithBrightness,WithRed,WithGreen,WithBlue,WithLightness. - In-place mutation where it is cheaper.
SetHue,SetSaturation,SetBrightnessandMultiplyRGBmodify a color by reference, for loops where returning a copy per iteration is wasted work. - Fast luminance.
GetLuminanceapplies the Rec. 7090.2126 / 0.7152 / 0.0722weighting directly to the channel values. It is quick and good enough for “is this background light or dark”, andGrayscaleuses it. - WCAG contrast.
ColorAnalysis.RelativeLuminancelinearizes each channel first, as WCAG requires, andWCAGContrastreturns the familiar ratio from 1 to 21.MeetsWCAGchecks a ratio againstWCAGLevel.AAorWCAGLevel.AAA, with a flag for large text and graphical elements. - Perceptual distance.
ColorAnalysis.OklabDeltaEmeasures how different two colors look, which is a better separation test than comparing RGB channels. - Color temperature.
ColorAnalysis.EstimateCCTestimates the correlated color temperature in Kelvin for colors near the white-light range, and returns 0 for colors that have no sensible temperature. - Color blindness simulation.
ColorBlindnessSimulator.Simulatecovers protanopia, deuteranopia, tritanopia, their milder anomalous forms, and achromatopsia. A span overload transforms a whole palette into a caller-owned buffer without allocating. - Harmony schemes.
ColorHarmony.Generatebuilds tenColorHarmonyTypeschemes, including complementary, split-complementary, triadic, tetradic, square, analogous and monochromatic. The rotation happens in Oklch, HSV or OkHSL, selected byColorHarmonyHueMode. - Tints, shades, tones and ramps.
ColorRampsderives each of these from a base color in Oklab, plus a four-entry shadow, base, highlight and rim set fromGenerateMaterialTones. Every generator has an array overload and an allocation-freeSpan<Color>overload. - Gamut helpers for Oklch.
IsInSRGBGamutreports whether an Oklch color can be displayed without clipping,MaxOklabChromaAtHuefinds the most saturated color a hue can hold at a given lightness, andClampToSRGBGamutreduces chroma until the color fits, keeping its hue and lightness. - Colorspace conversion.
SRGBToLinearandLinearToSRGBconvert single channel values, which is what you need when a shader expects linear and the source data is sRGB. - A
Color32mirror. Hue, saturation, brightness, channel edits, multiplication, inversion, grayscale, luminance and web-safe snapping all have byte-color overloads that skip the float round trip. - Constrained random colors.
RandomColorpins any HSV channel you supply and randomizes the rest,RandomColorInRangebounds hue, saturation and value, andRandomPastel,RandomVividandRandomGrayscaleguarantee a character. - Contrast-aware random.
RandomColorWithContrastpicks a vivid color whose luminance differs from a background by at least a given amount, falling back to black or white when no candidate qualifies. - Web-safe quantization.
SnapToWebSaferounds RGB to the 216-color web palette, leaving alpha alone. Useful for a deliberately restricted retro look.
Components
Everything lives in the Scylla.Core.Util namespace. All of the classes are static. The analysis, simulation, harmony and ramp classes treat every Color they receive as sRGB, which is how Unity stores colors, and convert internally where the math needs linear light.
| Type | Kind | Notes |
|---|---|---|
ColorUtil | Static class | Parsing, HSV, HSL, Oklab and Oklch conversion, channel edits, interpolation, gamut helpers and random colors. Most members are extension methods on Color and Color32. |
ColorAnalysis | Static class | Measures colors: WCAG relative luminance and contrast, Oklab delta-E, and correlated color temperature. Alpha is ignored throughout. |
WCAGLevel | Enum | AA or AAA, passed to ColorAnalysis.MeetsWCAG. AA requires 4.5:1 for standard text and 3:1 for large text and graphics. AAA requires 7:1 and 4.5:1. |
ColorBlindnessSimulator | Static class | Transforms one color or a span of colors to simulate a color vision deficiency. Works in linear light and passes alpha through. GetSimulationMatrix exposes the matrix and severity used for each type. |
ColorBlindnessType | Enum | Normal, Protanopia, Deuteranopia, Tritanopia, Protanomaly, Deuteranomaly, Tritanomaly, Achromatopsia. The three “-anomaly” types are partial deficiencies, simulated at 60 percent severity. |
ColorHarmony | Static class | Derives a harmony scheme from a base color. The first entry of every result is the base hue. GetHueOffsetsDegrees returns a scheme’s rotations without generating colors, for example to draw it on a hue wheel. |
ColorHarmonyType | Enum | The ten schemes and their hue offsets, for example Complementary (0, 180), Triadic (0, 120, 240) and Analogous (0, -30, 30). Rectangular is a synonym of Tetradic. Monochromatic sweeps lightness on a single hue instead of rotating it. |
ColorHarmonyHueMode | Enum | Where the rotation happens. Oklch keeps perceived brightness and chroma, HSV keeps saturation and value, and OkHSL keeps brightness and saturation relative to what each hue can hold. |
ColorRamps | Static class | Tints toward white, shades toward black, tones toward the gray of the same lightness, full dark-to-light ramps, and material tone sets. All blending happens in Oklab. |
Color spaces
Four spaces are available and they are good at different things. In short, use HSV for direct manipulation, HSL when “lightness” needs to behave symmetrically, and Oklch whenever a human will look at the result of an interpolation.
| Space | Components | What it is good for | Notes |
|---|---|---|---|
| RGB | Red, Green, Blue | Storage, shaders, anything the GPU touches | The native representation and the wrong one for almost every kind of manipulation. Lerping in RGB is the classic cause of muddy gradients: the straight line between two saturated colors passes through the desaturated middle of the cube. Fine when both endpoints are close together or when one of them is black or white. |
| HSV | Hue, Saturation, Value | Interactive tweaking, deriving UI states | The space most color pickers present, so the numbers match what a designer expects. “Value” is how bright the brightest channel is, which means pure white and pure yellow both have value 1. Good for “same color, dimmer” and for hue rotation. Not perceptually uniform: a hue step of 0.1 in the greens looks much smaller than the same step in the blues. |
| HSL | Hue, Saturation, Lightness | Symmetric lightening and darkening | Lightness runs from black at 0 through full color at 0.5 to white at 1. That symmetry is what you want when a theme needs both a lighter and a darker variant of the same base and they should feel equally far away. Shares HSV’s lack of perceptual uniformity. |
| Oklab / Oklch | Lightness, and either a/b axes or Chroma plus Hue | Gradients, tweens, harmonies, ramps | Built so that equal numeric distance means roughly equal perceived difference. Oklab uses cartesian a and b axes. Oklch is the same space in polar form, with chroma (saturation) and hue as separate numbers, which is usually the more useful of the two. Hue is a fraction of a full turn from 0 to 1, not degrees. |
Oklab and Oklch are worth the paragraph. They are recent, and they solve a problem older uniform spaces did not solve well: keeping blues from going purple and keeping the middle of a gradient as saturated as its ends. A heat ramp built in RGB usually needs a hand-placed middle stop to stop it looking dead. In Oklch it does not.
The one complication is the gamut. The sRGB gamut is far from a cylinder in Oklch: a saturated yellow can reach roughly twice the chroma of a saturated blue at the same lightness. An Oklch color with more chroma than its hue can hold gets clipped per channel when it is converted back, and that clipping shifts its hue. ClampToSRGBGamut avoids this by giving up saturation instead, and ColorHarmonyHueMode.OkHSL avoids it by measuring saturation against what each hue can actually hold.
Recipes
The recipes below are independent. Pick the one that matches what you’re building rather than reading top to bottom.
Load colors a designer gave you as hex
Problem. Faction colors, damage-type tints and UI accents live in a data table or a config file as hex strings, because that is what the design side works in. They need to become Color at load time, and a typo in the data should not go unnoticed.
Solution. TryParseHex is the safe form. It accepts the shapes people actually paste and reports failure through its return value.
/* Accepts "FF6600", "#FF6600", and the 8-digit "FF660080" with alpha.
Returns false and leaves the out value at Color.black on anything else. */
if (!ColorUtil.TryParseHex(factionRow.TintHex, out var factionTint))
{
/* Bad data in a table should be loud but not fatal. Fall back and log the row. */
Log.Warning($"Faction '{factionRow.ID}' has an unreadable tint '{factionRow.TintHex}'.",
LogCategory.Game);
factionTint = Color.white;
}
banner.color = factionTint;
/* Going the other way, for writing back into a config or showing in a debug overlay.
The result is always eight characters, RRGGBBAA. */
var asHex = ColorUtil.ColorToHex(factionTint);
Derive a button’s hover and pressed colors from one base
Problem. A UI theme defines one accent color per element, and it needs hover, pressed and disabled variants without a designer hand-picking twelve colors that then drift apart when the accent changes.
Solution. Derive them as shifts of the base. The With* family returns a copy, so the base is never disturbed.
var accent = theme.AccentColor;
/* Hover: same hue, a little brighter. Value is HSV brightness, clamped to [0, 1]. */
var hover = accent.WithBrightness(accent.GetValue() * 1.15f);
/* Pressed: darker, which reads as the button sinking. */
var pressed = accent.WithBrightness(accent.GetValue() * 0.8f);
/* Disabled: drop the saturation rather than the alpha, so the control still reads as
solid rather than as a hole in the panel. */
var disabled = accent.WithSaturation(accent.GetSaturation() * 0.25f);
/* Alpha edits are the most common of all and have their own shortcut. */
var ghosted = accent.WithAlpha(0.4f);
Check that UI text meets WCAG AA
Problem. A theme pairs text colors with panel colors, and the pairs need to stay readable for players with low vision, ideally checked automatically when the theme changes rather than by eye.
Solution. WCAGContrast returns the contrast ratio between two colors, and MeetsWCAG compares it against a conformance level. Level AA asks for 4.5:1 on body text and 3:1 on large text, icons and control borders.
var ratio = ColorAnalysis.WCAGContrast(theme.BodyText, theme.PanelBackground);
if (!ColorAnalysis.MeetsWCAG(ratio, WCAGLevel.AA))
{
Log.Warning($"Body text contrast is {ratio:0.00}:1, below the 4.5:1 that AA requires.",
LogCategory.UI);
}
/* Headings, icons and focus rings are held to the relaxed large-text threshold. */
var iconRatio = ColorAnalysis.WCAGContrast(theme.IconTint, theme.PanelBackground);
var iconPasses = ColorAnalysis.MeetsWCAG(iconRatio, WCAGLevel.AA, largeText: true);
The order of the two colors does not matter, and alpha is ignored, so check the color as it ends up on screen. Text drawn at 60 percent opacity over a panel should be blended with that panel first.
Keep damage numbers readable over any background
Problem. Floating combat text sits over whatever the player happens to be standing on. White reads fine on dark stone and vanishes on snow.
Solution. Measure the contrast of both candidates against the backdrop and use whichever wins. Black or white always reaches at least about 4.6:1 against any opaque color, so one of the two passes AA.
var onWhite = ColorAnalysis.WCAGContrast(Color.white, terrainTint);
var onBlack = ColorAnalysis.WCAGContrast(Color.black, terrainTint);
damageLabel.color = onWhite >= onBlack ? Color.white : Color.black;
Preview a palette for deuteranopia
Problem. The HUD uses green for healthy, orange for a warning and red for danger. To most players those are obviously different, but deuteranopia, the most common form of color blindness, can make two of them look the same.
Solution. Run the palette through ColorBlindnessSimulator, then measure each pair of simulated colors with OklabDeltaE. A distance below about 0.10 means the pair is hard to tell apart.
var statusColors = new[]
{
new Color(0.10f, 0.85f, 0.20f), /* Healthy, vivid green. */
new Color(1.00f, 0.55f, 0.00f), /* Warning, saturated orange. */
new Color(0.95f, 0.10f, 0.10f), /* Danger, strong red. */
new Color(0.00f, 0.45f, 1.00f) /* Shield, electric blue. */
};
/* The span overload writes into a caller-owned buffer. The two spans may even be the
same array, since each element is read before it is written. */
var simulated = new Color[statusColors.Length];
ColorBlindnessSimulator.Simulate(statusColors, simulated, ColorBlindnessType.Deuteranopia);
for (var i = 0; i < simulated.Length; i++)
{
for (var j = i + 1; j < simulated.Length; j++)
{
var distance = ColorAnalysis.OklabDeltaE(simulated[i], simulated[j]);
if (distance < 0.10f)
{
Log.Warning($"Status colors {i} and {j} are only {distance:0.000} apart under deuteranopia.",
LogCategory.UI);
}
}
}
Repeat the loop for Protanopia and Tritanopia to cover the other two dichromacies. When a pair collapses, the usual fix is to separate the colors in lightness as well as hue, or to add a second cue such as an icon or a pattern.
Build a harmony palette from a brand color
Problem. A game has one brand color and needs a few more that go with it, for example accents for three factions or a set of chart series, without picking them by hand.
Solution. ColorHarmony.Generate rotates the base hue by a classical scheme and returns the base first, then the derived colors.
var brand = new Color(1f, 0.30f, 0.05f); /* Vivid orange-red. */
/* Three hues spaced evenly around the wheel. Oklch mode is the default and keeps
every entry at the brand color's perceived brightness. */
var factions = ColorHarmony.Generate(brand, ColorHarmonyType.Triadic);
/* OkHSL keeps each hue as saturated as that hue allows, relative to the brand
color, so nothing gets clipped and the set looks evenly vivid. */
var accents = ColorHarmony.Generate(brand, ColorHarmonyType.SplitComplementary,
hueMode: ColorHarmonyHueMode.OkHSL);
/* Monochromatic sweeps lightness on one hue instead, here in seven steps, dark to light. */
var monoSet = ColorHarmony.Generate(brand, ColorHarmonyType.Monochromatic, monoCount: 7);
/* The offsets alone, in degrees, for drawing the scheme on a hue wheel. */
var offsets = ColorHarmony.GetHueOffsetsDegrees(ColorHarmonyType.Triadic); /* 0, 120, 240 */
The choice of hue mode decides what stays constant. Oklch suits colors that must all be equally readable against one background. OkHSL suits colors that should all look equally vivid. HSV matches what most color-wheel tools produce, but yellows will come out much brighter than blues.
Generate a shading ramp for a sprite
Problem. A pixel-art sprite needs a dark-to-light ramp for each base color in its palette, with steps that look even and a highlight that does not wash out.
Solution. GenerateRamp places the base in the middle with shades below and tints above. GenerateMaterialTones gives a shorter shadow, base, highlight and rim set that also shifts the hue slightly, which reads more like a lit surface than a plain lightness ramp.
var armor = new Color(0.05f, 0.40f, 0.95f); /* Saturated cobalt blue. */
/* Seven entries, dark to light. The base color sits at index 3. */
var ramp = ColorRamps.GenerateRamp(armor, 7);
/* Shadow, base, highlight and rim, in that order. */
var tones = ColorRamps.GenerateMaterialTones(armor);
/* The one-directional series start at the base and move away from it. Tints stop
short of white, shades stop short of black, and tones move toward the gray with
the same perceived lightness. */
var tints = ColorRamps.GenerateTints(armor, 4);
var shades = ColorRamps.GenerateShades(armor, 4);
var tones4 = ColorRamps.GenerateTones(armor, 4);
When ramps are regenerated often, for example every time a player edits a color in a character creator, use the Span<Color> overloads with a reused buffer or a stackalloc so nothing is allocated:
Span<Color> buffer = stackalloc Color[7];
ColorRamps.GenerateRamp(playerColor, buffer);
/* Material tones need room for at least four entries and throw otherwise. */
Span<Color> materialBuffer = stackalloc Color[4];
ColorRamps.GenerateMaterialTones(playerColor, materialBuffer);
Pick Oklch colors that the screen can actually show
Problem. Theme colors are authored as Oklch numbers so that they share one lightness, but some combinations of lightness, chroma and hue fall outside sRGB. Converted with FromOklch, those colors are clipped per channel and come out with the wrong hue.
Solution. Test with IsInSRGBGamut, and convert with ClampToSRGBGamut when the hue matters more than the last bit of saturation. Hue is a fraction of a full turn, so 0.25 is a quarter of the way around.
const float lightness = 0.70f;
const float chroma = 0.25f;
for (var i = 0; i < swatches.Length; i++)
{
var hue = i / (float) swatches.Length;
if (!ColorUtil.IsInSRGBGamut(lightness, chroma, hue))
{
/* The highest chroma this hue can hold at this lightness. */
var limit = ColorUtil.MaxOklabChromaAtHue(lightness, hue);
Log.Debug($"Hue {hue:0.00} only holds chroma {limit:0.000}.", LogCategory.UI);
}
/* Keeps the hue and lightness and reduces chroma just enough to fit. */
swatches[i] = ColorUtil.ClampToSRGBGamut(lightness, chroma, hue);
}
Build a gradient that stays vivid in the middle
Problem. A health bar runs green to red, and at fifty percent it is a murky brown instead of the yellow anyone would expect.
Solution. That brown is what RGB interpolation produces. LerpOklch walks the hue circle in a perceptually uniform space, so the midpoint lands where a person would put it.
var healthy = Color.green;
var critical = Color.red;
var t = 1f - (currentHealth / (float) maxHealth);
/* RGB lerp: passes through the desaturated center of the color cube. Muddy. */
var muddy = Color.Lerp(healthy, critical, t);
/* Oklch lerp: interpolates lightness, chroma and hue separately along the shortest
hue arc, so the midpoint is a saturated yellow rather than a brown. */
var clean = ColorUtil.LerpOklch(healthy, critical, t);
healthBar.color = clean;
/* LerpOklab is the cartesian form of the same space. Prefer it when one endpoint is
near-gray, since hue is meaningless there and interpolating it can wander. */
var fade = ColorUtil.LerpOklab(uiPanel, Color.clear, fadeT);
Generate distinguishable colors for player markers
Problem. Up to eight players need map markers that are obviously different from each other and from the map underneath.
Solution. Constrain the hue by index and let the other channels be generated, which spaces the markers around the wheel rather than hoping random lands somewhere useful.
/* Even hue spacing gives maximum separation for a known count. */
for (var i = 0; i < playerCount; i++)
{
var hue = i / (float) playerCount;
/* RandomColor pins every channel you pass and randomizes the rest. Pinning value
as well keeps a marker from coming out nearly black. */
markers[i].color = ColorUtil.RandomColor(hue: hue, saturation: 0.85f, value: 0.95f);
}
/* When the count is not known ahead of time, bound the ranges instead so markers
stay inside a band that reads against the map's palette. */
var neutralMarker = ColorUtil.RandomColorInRange(minHue: 0.55f, maxHue: 0.75f,
minSaturation: 0.7f, minValue: 0.8f);
/* Guaranteed character, when the look matters more than the exact value. */
var uiHighlight = ColorUtil.RandomVivid();
/* A vivid color whose luminance differs from the background by at least 0.5. */
var legibleLabel = ColorUtil.RandomColorWithContrast(mapBackground, minContrast: 0.5f);
Tint vertex colors without the float round trip
Problem. Colors are being written into a mesh’s vertex color array or a texture’s pixel buffer, both of which are Color32, and every helper call converts to float and back.
Solution. Most of ColorUtil has a Color32 overload. Use it and the bytes stay bytes.
var pixels = texture.GetPixels32();
for (var i = 0; i < pixels.Length; i++)
{
/* Color32 overloads throughout: no implicit widening to Color and back on each call. */
pixels[i] = pixels[i].WithSaturation(pixels[i].GetSaturation() * 0.5f);
/* The Color32 overload returns a value, where the Color one mutates by ref.
Either way alpha is left alone, which is what a tint wants: multiplying all
four channels would fade the sprite out as well as tint it. */
pixels[i] = pixels[i].MultiplyRGB(0.8f);
}
texture.SetPixels32(pixels);
texture.Apply();
Match a shader that expects linear values
Problem. The project renders in linear color space, but a value came from a source that is sRGB-encoded, such as a hex string or a color a designer picked in a tool. Feeding it straight into a shader property makes it look washed out.
Solution. Convert the channel values explicitly. These operate on a single float, so you apply them per channel and keep control of alpha.
/* Per-channel conversion. Alpha is not gamma-encoded and must not be converted. */
var linear = new Color(
ColorUtil.SRGBToLinear(source.r),
ColorUtil.SRGBToLinear(source.g),
ColorUtil.SRGBToLinear(source.b),
source.a);
material.SetColor(EmissionColorID, linear);
/* The reverse, for showing a linear value in a swatch or writing it back as hex.
LinearToSRGB clamps its result to [0, 1]. */
var displayable = new Color(
ColorUtil.LinearToSRGB(linear.r),
ColorUtil.LinearToSRGB(linear.g),
ColorUtil.LinearToSRGB(linear.b),
linear.a);
Quantize to a restricted palette for a retro look
Problem. A game wants a deliberately limited color look, in the spirit of the 216-color web-safe palette that early browsers were stuck with.
Solution. SnapToWebSafe rounds each RGB channel to the nearest of six steps. Alpha is left alone, since web-safe was never an alpha concept.
/* Each channel snaps to one of 00, 33, 66, 99, CC, FF. Six values cubed is the
familiar 216-color palette. Snapping an already-snapped color changes nothing. */
var quantized = sourceColor.SnapToWebSafe();
/* The Color32 overload for pixel buffers. */
for (var i = 0; i < pixels.Length; i++)
{
pixels[i] = pixels[i].SnapToWebSafe();
}
Rotate a hue in place across a large buffer
Problem. A damage flash or a faction re-tint shifts the hue of a lot of colors at once, and returning a fresh copy per element is needless work.
Solution. The Set* family mutates by reference instead of returning a copy.
/* Set* takes the color by ref and modifies it, where With* returns a new value.
Same result; this form avoids a copy per iteration in a tight loop. */
for (var i = 0; i < tints.Length; i++)
{
tints[i].SetHue(newFactionHue);
}
/* MultiplyRGB is the same shape, and also has an overload taking another color for
a per-channel tint rather than a uniform scale. Neither clamps, so values above 1
survive for HDR use. */
flashTint.MultiplyRGB(1.5f);
flashTint.MultiplyRGB(factionTint);
API Sketch
Everything is in Scylla.Core.Util. Most ColorUtil members are extension methods on Color or Color32, so they read as myColor.WithAlpha(0.5f) rather than as static calls. The Color32 mirror is listed once rather than repeated in full.
/* --- ColorUtil: hex and string forms --- */
bool TryParseHex(string hex, out Color color); /* "FF6600", "#FF6600", "FF660080" */
Color HexToColor(string hex); /* black on bad input, no exception */
Color32 HexToColor32(string hex);
Color ToColor(this string hex); /* "#FF6600".ToColor() */
Color32 ToColor32(this string hex);
string ColorToHex(Color color); /* always RRGGBBAA */
string Color32ToHex(Color32 color);
string Color32ToHex(Color32 color, bool includeAlpha);
bool TryParseARGB(string hex, out Color color); /* AARRGGBB order */
string FormatARGB(Color color);
/* --- ColorUtil: HSV --- */
float GetHue(this Color color);
float GetSaturation(this Color color);
float GetValue(this Color color);
Vector3 ToHSV(this Color color);
Color FromHSV(Vector3 hsv, float alpha = 1f);
/* --- ColorUtil: HSL --- */
Vector3 ToHSL(this Color color);
Color FromHSL(float h, float s, float l, float alpha = 1f);
float GetLightness(this Color color);
Color WithLightness(this Color color, float lightness);
/* --- ColorUtil: Oklab and Oklch. Oklch hue is a fraction of a turn, 0 to 1. --- */
void ToOklab(this Color color, out float L, out float a, out float b);
Color FromOklab(float L, float a, float b, float alpha = 1f);
Vector3 ToOklch(this Color color);
Color FromOklch(float L, float C, float H, float alpha = 1f);
Color LerpOklab(Color a, Color b, float t);
Color LerpOklch(Color a, Color b, float t);
/* --- ColorUtil: sRGB gamut, for Oklch colors --- */
bool IsInSRGBGamut(float L, float C, float h);
float MaxOklabChromaAtHue(float L, float h);
Color ClampToSRGBGamut(float L, float C, float h, float alpha = 1f);
/* --- ColorUtil: channel edits. With* copies, Set* mutates by ref. --- */
Color WithHue(this Color color, float hue);
Color WithSaturation(this Color color, float saturation);
Color WithBrightness(this Color color, float brightness);
Color WithRed(this Color color, float red);
Color WithGreen(this Color color, float green);
Color WithBlue(this Color color, float blue);
Color WithAlpha(this Color color, float alpha);
void SetHue(this ref Color color, float hue);
void SetSaturation(this ref Color color, float saturation);
void SetBrightness(this ref Color color, float brightness);
void MultiplyRGB(this ref Color color, float k); /* leaves alpha alone */
void MultiplyRGB(this ref Color color, Color other); /* per-channel tint */
/* --- ColorUtil: transforms and interpolation --- */
float GetLuminance(this Color color); /* Rec. 709 on raw channels */
Color Grayscale(this Color color);
Color Invert(this Color color);
Color SnapToWebSafe(this Color color); /* RGB only, alpha untouched */
Color LerpHSV(Color a, Color b, float t);
/* --- ColorUtil: colorspace, per channel --- */
float SRGBToLinear(float c);
float LinearToSRGB(float c);
/* --- ColorUtil: random. UnityEngine.Random, so main thread and not seedable. --- */
Color RandomColor(float? hue = null, float? saturation = null, float? value = null, float? alpha = null);
Color32 RandomColor32(float? hue = null, float? saturation = null, float? value = null, float? alpha = null);
Color RandomColorInRange(float minHue = 0f, float maxHue = 1f, float minSaturation = 0f,
float maxSaturation = 1f, float minValue = 0f, float maxValue = 1f, float alpha = 1f);
Color RandomGrayscale(float minValue = 0f, float maxValue = 1f, float alpha = 1f);
Color RandomPastel(float alpha = 1f);
Color RandomVivid(float alpha = 1f);
Color RandomColorWithContrast(Color background, float minContrast = 0.3f, float alpha = 1f);
/* --- ColorUtil: Color32 mirror --- */
/* GetHue, GetSaturation, GetValue, ToHSV, WithHue, WithSaturation, WithBrightness,
WithRed, WithGreen, WithBlue, WithAlpha (byte channels), MultiplyRGB (returns a
value), Invert, Grayscale, GetLuminance and SnapToWebSafe all have Color32 overloads. */
/* --- ColorAnalysis --- */
float RelativeLuminance(Color color); /* WCAG, linearized first */
float WCAGContrast(Color foreground, Color background); /* 1 to 21 */
bool MeetsWCAG(float ratio, WCAGLevel level, bool largeText = false);
float OklabDeltaE(Color a, Color b);
float EstimateCCT(Color color); /* Kelvin, or 0 when off-locus */
/* --- ColorBlindnessSimulator --- */
Color Simulate(Color color, ColorBlindnessType type);
void Simulate(ReadOnlySpan<Color> source, Span<Color> destination, ColorBlindnessType type);
Matrix4x4 GetSimulationMatrix(ColorBlindnessType type, out float severity);
/* --- ColorHarmony --- */
Color[] Generate(Color baseColor, ColorHarmonyType type, int monoCount = 5,
ColorHarmonyHueMode hueMode = ColorHarmonyHueMode.Oklch);
float[] GetHueOffsetsDegrees(ColorHarmonyType type, int monoCount = 5);
/* --- ColorRamps. Each has an array overload and a Span<Color> overload. --- */
Color[] GenerateTints(Color baseColor, int count);
void GenerateTints(Color baseColor, Span<Color> destination);
Color[] GenerateShades(Color baseColor, int count);
void GenerateShades(Color baseColor, Span<Color> destination);
Color[] GenerateTones(Color baseColor, int count);
void GenerateTones(Color baseColor, Span<Color> destination);
Color[] GenerateRamp(Color baseColor, int count);
void GenerateRamp(Color baseColor, Span<Color> destination);
Color[] GenerateMaterialTones(Color baseColor);
void GenerateMaterialTones(Color baseColor, Span<Color> destination);
See the API reference for full signatures, remarks, and exception contracts on every member.
Best Practices
- Use
TryParseHexfor anything you did not write yourself. Data tables, config files, save games and server responses all eventually contain a typo.HexToColorandToColorquietly return black, so a failure looks like a valid color. - Interpolate in Oklch, store in RGB. The uniform spaces are for the journey, not the destination. Convert, lerp, convert back, and keep stored colors in RGB.
- Drop to
LerpOklabwhen an endpoint is near-gray. Polar interpolation has no meaningful hue to rotate toward when chroma is near zero, and the result can visibly wander. - Prefer
WithAlphato constructing a newColor. It reads better and it cannot transpose two channels by accident, whichnew Color(c.r, c.b, c.g, 0.5f)silently can. - Derive colors from one base rather than hand-picking them. One accent plus a brightness and saturation rule, a
ColorHarmonyscheme, or aColorRampsseries stays consistent when the base changes. Twelve hand-picked colors do not. - Use
ColorAnalysisfor accessibility decisions.WCAGContrastandMeetsWCAGgive the numbers the guidelines are written in.GetLuminanceis fine for a quick light-or-dark split, but it skips linearization and is not a WCAG measurement. - Check status colors under simulation, not just by eye. Run them through
ColorBlindnessSimulatorfor at least deuteranopia and protanopia and compare the results withOklabDeltaE. A test that does this catches regressions when a theme changes. - Pick the harmony hue mode on purpose. Use
Oklchwhen the set must be equally readable on one background,OkHSLwhen it must look equally vivid, andHSVonly when matching an external color-wheel tool. - Use the
Span<Color>overloads for anything regenerated often. Simulating a palette or rebuilding a ramp every time a slider moves should not allocate a new array each time. - Use
ClampToSRGBGamutwhen building colors from Oklch numbers. Giving up a little chroma keeps the hue intact. Clipping per channel does not. - Reach for the
Color32overloads when the data is already bytes. Vertex colors, pixel buffers and packed cell data are allColor32, and routing them throughColorcosts a conversion each way for nothing. - Do not use the random-color family for anything reproducible. It runs on
UnityEngine.Random, so it is main thread only and cannot be seeded. Build the color from Random Utils values and pass them toFromHSVas aVector3when a replay or a daily seed has to match. - Convert sRGB to linear per channel and leave alpha alone. Alpha is not gamma-encoded. Running it through
SRGBToLinearproduces transparency that is subtly wrong in a way that is hard to spot and harder to trace. - Prefer
Set*in loops andWith*everywhere else. The by-ref form exists for the hot path. The copying form is clearer and should be the default when the loop is not large. - Keep named colors in a palette, not in code. These utilities measure, derive and manipulate colors, but they do not organize them. Reach for Palette Utils when the question is “what is our success green” rather than “make this green darker”.
Pitfalls
Related
- 01 Scylla Core
- 40 Core Utilities – Palette
- 30 Core Utilities – Random
- 31 Core Utilities – Math
- API reference:
Scylla.Core.Util.ColorUtil - API reference:
Scylla.Core.Util.ColorAnalysis - API reference:
Scylla.Core.Util.WCAGLevel - API reference:
Scylla.Core.Util.ColorBlindnessSimulator - API reference:
Scylla.Core.Util.ColorBlindnessType - API reference:
Scylla.Core.Util.ColorHarmony - API reference:
Scylla.Core.Util.ColorHarmonyType - API reference:
Scylla.Core.Util.ColorHarmonyHueMode - API reference:
Scylla.Core.Util.ColorRamps