Aseprite

Aseprite

27 ratings
Mixing Colors In Aseprite
By behreajj
This guide describes various approaches to mixing color in Aseprite. It starts with built-in tools, moves on to how Lua scripting can create new tools, then offers a take on color theory.
   
Award
Favorite
Favorited
Unfavorite
Introduction
Hello and thank you for checking out my tutorial on mixing colors in Aseprite. I hope that you find it helpful.

Below, I'll look at how colors can be mixed in two beginner sections: on the canvas, and in the palette. I go through beginner techniques not because I fully endorse them, or because they are without problems. Rather, they do not require tooling outside of Aseprite.

There are no intermediate sections. This is because
  1. color is an inherently complex subject (on which I am not an expert);
  2. many color tutorials are low effort, perpetuate oversimplifications without caveats and illustrate concepts using improper tools, meaning an aspiring artist has as much to unlearn as to learn;
  3. when software doesn't include proper tools for handling color, they must either be sourced elsewhere, or a script must be used.

In the "advanced" sections I introduce the conversion of color to linear sRGB, then to LAB, then to LCH. I also address mixing colors for normal mapping. These sections will be most helpful if you're willing to explore Lua scripting in Aseprite.

Lastly, I offer my opinion on some common advice based on color theory and offer outside resources.

On Word Choice

Many terms used to describe color seem to have a plain meaning in everyday English. Digital software requires more precise definitions, since a color's properties need a mathematical representation. Many terms have also been carelessly ported from the traditional arts into digital art without thinking about relevant differences between the two media. The matter is confused further still since different graphics software may seek to simulate traditional arts. So when advice is offered to beginners, the general concept behind the advice may be sensible, but the execution may be quite poor.

As an example of ambiguous terminology, 'lightness', 'brightness', 'value', 'intensity', 'luma', 'luminance' and 'luminosity' may be synonymous descriptions of a color's tendency in 2D art towards white; or its tendency away from black. Each of these, however, have very different math formulas behind them. For that reason, I've tried to not rely on certain color terms unless they're associated with a color model, a picture or a math formula.

I ask for your patience if you find my use of a color term does not match your own.
Prep Work
I'm starting with the assumption that the sprite is in RGB color mode. This is because Aseprite will attempt to match novel colors to colors in the palette when a sprite is in Indexed color mode. To change a sprite's color mode, go to Sprite > Color Mode > RGB Color.


Second, I'll go to Edit > Preferences and click on the Color tab. I'll prioritize sRGB over display or monitor profiles.


Third, I'll check that my sprite uses the sRGB color profile. To change a sprite's profile, go to Sprite > Properties. A dialog will pop up. Under the Advanced section, a drop down menu will allow you to select a profile.


The 's' stands for standard RGB[en.wikipedia.org]. This will matter less for beginners, but is important for technically inclined readers. To illustrate, I'll use this image of the sRGB-based Hue Saturation Lightness spectrum:


The Assign button will change the sprite's profile, but will not alter the pixels in the sprite. Depending on how different the sprite's new profile is from its previous, the sprite's appearance may change dramatically. Furthermore, the image's appearance will depend on your display monitor, and even on how websites like Steam process uploaded images. In the example below, the image has been assigned Adobe RGB 1998[www.adobe.com].


The Convert button will change the sprite's profile and will alter the sprite's pixels so as to maintain visual continuity. The sprite will look the same, but the 6 digital hexadecimal code you get from the eyedropper[aseprite.org] tool may differ. This depends on the sophistication of the eye dropper you use. For example, the same color may have the code #FE0000 in one image and #DA0000 in another.


If you're a beginner, the upshot is that hexadecimal codes are not universal, objective or constant color identifiers.
Beginner: Canvas
One technique is to open the foreground color[aseprite.org] sliders pop-up, adjust the red, green and blue channels until the desired color is found, then set its alpha channel to about 50%, or 128 over 255.


The translucent color, red (#FF0000) in this case, can be drawn over a base color, green (#00FF00), to find a mix. The result is #807F00.


The discrepancy between 128 (80 in hexadecimal) and 127 (7F in hexadecimal) is a bit of a distraction. The quotient of 255 divided by 2 is a real number, 127.5, not an integer.


Ink types[www.aseprite.org] can simplify the process. Ink types will appear in the context bar[aseprite.org] above the canvas after you select the pencil tool. The Alpha Compositing ink, for example, allows you to choose an opaque color from the palette and mix it on the canvas without having to alter the source color's opacity.


Instead, after Alpha Compositing is chosen, a slider will appear to change opacity in the context bar.


Lock alpha can also be chosen.


This allows you to mix color without changing the shapes you've laid down before. Just beware that, if you forget what ink type you're currently using, it may seem as though the pencil tool has stopped working when you go to create new shapes.


You could also place swatches on separate layers, then change the blend mode[www.aseprite.org] and opacity of the top layer to get different mixes. For example, some artists prefer the multiply blend mode for shading. With this approach, the eye dropper tool should be set to sample from All Layers in the tool bar above the canvas.


For some blend modes, it matters which color is beneath the other, as the blending process is not commutative. For example, pictured above, green over red yields #FF6969 with the Luminosity blend mode. Were the colors switched, red over green would yield #008100.


The blur tool can also assist in mixing colors,


but you have to be careful about feathering edges that should be aliased.


A blur filter can be found under Effects > Convolution Matrix.

Beginner: Palette
A discrete gradient of color swatches can be made in the palette. The double bars at the end of the palette can be dragged to expand the palette's size. By selecting a color swatch, then hovering over its border, then clicking and dragging, you can relocate colors. More demos for how to rearrange the palette are on the Aseprite tutorial[aseprite.org] page.


You can place an origin and destination color at the edges of some swatches you don't mind replacing (black in this case). To get a middle swatch that is a 50-50 mix, use an odd number of middle swatches.


The Gradient command is located in the menu above the palette swatches, to the right of the palette padlock and to the left of the presets button. The menu button's icon is a down arrow in the default Aseprite theme.


There is also a Gradient By Hue command in Aseprite version 1.3.


The hue gradient proceeds through the nearest arc direction, either clockwise or counter-clockwise, in HSV[en.wikipedia.org]. There's no option to create a red-purple-cyan-green gradient.


Beware that Aseprite's hue gradient has a problem. When saturation is 0%, as it is with any gray, its hue is undefined. Many graphics programs, however, default the hue to zero, making grays have the same hue as red. When a gradient mixes between a gray and a color that is far away from red on the color wheel, such as cyan, the following results:


The gradient on the canvas to the right shows a normal gray to cyan gradient for reference. Notice, though, that the gray in the gradient appears to have a reddish-tint relative to the cyan, despite an eye dropper's reading the color as #808080.
Advanced: Linear sRGB
The issue with built-in color mixing methods is that they ignore differences between how humans perceive color and how digital color is stored and displayed. A first step in overcoming the difference is to handle gamma correction. If you've heard of gamma before, it might've been from a video by minutephysics about image blurring:

https://youtu.be/LKnqECcg6Gw

If you have a coding background, you may prefer John Novak's "What every coder should know about gamma[http//What+every+coder+should+know+about+gamma]."

If you wish to see a graph of how this gamma correction effects channel values, this is a snapshot from Desmos graphing calculator[www.desmos.com]:


The exponent 2.2 and it's inverse, one divided by 2.2, approximately 0.4545, are a common simplification of a slightly more complex formula, which can be found on Wikipedia[en.wikipedia.org]. The more complex formula uses an exponent of 2.4. Some software may simplify yet further, from 2.2 to 2.0, so that any value can be multiplied by itself and its square root can be taken in the reverse transformation.

To see the difference, compare these gradients created in sRGB

Red to green

Blue to yellow

Magenta to cyan

to these in linear sRGB.

Red to green

Blue to yellow

Magenta to cyan

To mix color in linear sRGB, you could write a Lua script[aseprite.org] like the one below. Each color channel is divided by 255 to bring it into a range [0.0, 1.0]. Then it is raised to an exponent of 2.2 and mixed. The reverse transformation is performed on the result. The mix is raised to an exponent of 0.4545, multiplied by 255.0, then demoted from a real number to an integer.

To run the script, in Aseprite go to File > Scripts > Open Scripts Folder. The folder that opens should be where you save the script with the lua file extension. Other extensions, like txt, won't register. Back in Aseprite, go to File > Scripts > Rescan Scripts Folder. After this update, the file should appear under the File > Scripts menu.

local dlg = Dialog { title = "Mix Colors" } local function updateMix() -- Unpack arguments. local args = dlg.data local orig = args.orig --[[@as Color]] local dest = args.dest --[[@as Color]] local percent = args.percent --[[@as number]] -- Unpack origin color. local rOrig = orig.red local gOrig = orig.green local bOrig = orig.blue local aOrig = orig.alpha -- Unpack destination color. local rDest = dest.red local gDest = dest.green local bDest = dest.blue local aDest = dest.alpha -- Convert origin range from [0, 255] to [0.0, 1.0]. local ro01 = rOrig / 255.0 local go01 = gOrig / 255.0 local bo01 = bOrig / 255.0 -- Convert destination range from [0, 255] to [0.0, 1.0]. local rd01 = rDest / 255.0 local gd01 = gDest / 255.0 local bd01 = bDest / 255.0 -- Convert origin color from standard to linear. local roLin = ro01 ^ 2.2 local goLin = go01 ^ 2.2 local boLin = bo01 ^ 2.2 -- Convert destination color from standard to linear. local rdLin = rd01 ^ 2.2 local gdLin = gd01 ^ 2.2 local bdLin = bd01 ^ 2.2 -- Mix in in linear space. local t = percent * 0.01 local u = 1.0 - t local mrLin = u * roLin + t * rdLin local mgLin = u * goLin + t * gdLin local mbLin = u * boLin + t * bdLin -- Convert mixed color from linear to standard. local expInvert = 1.0 / 2.2 local mrStd = mrLin ^ expInvert local mgStd = mgLin ^ expInvert local mbStd = mbLin ^ expInvert -- Convert mixed color range from [0.0, 1.0] to [0, 255]. local mr255 = mrStd * 255.0 local mg255 = mgStd * 255.0 local mb255 = mbStd * 255.0 -- Alpha is unaffected by linear transformation. local ma255 = u * aOrig + t * aDest -- Convert from real numbers to integers. -- Add 0.5 so as to round numbers. local mrInt = math.floor(mr255 + 0.5) local mgInt = math.floor(mg255 + 0.5) local mbInt = math.floor(mb255 + 0.5) local maInt = math.floor(ma255 + 0.5) -- Construct mix Aseprite Color. local mixed = Color(mrInt, mgInt, mbInt, maInt) dlg:modify { id = "mix", color = mixed } end dlg:color { id = "orig", label = "Left:", color = Color(app.fgColor), onchange = updateMix } dlg:color { id = "dest", label = "Right:", color = Color(app.bgColor), onchange = updateMix } dlg:slider { id = "percent", label = "Percent:", value = 50, min = 0, max = 100, onchange = updateMix } dlg:color { id = "mix", label = "Mixed:", color = Color(0, 0, 0, 0) } updateMix() dlg:show { wait = false }

When run, the script will display a dialog that looks like this:


The result of mixing red with green, #BABA00 using the simplified exponent will vary slightly with the gradients above, which use the 2.4 exponent and return a result of #BCBC00.
Advanced: LAB
The transformation from linear sRGB to a perceptual color model is a next step to improve on color mixing. The most established, with the longest history, is CIE LAB[en.wikipedia.org]. Newer variants, such as SR LAB 2[www.magnetkern.de] by Jan Behrens and OK LAB[bottosson.github.io] by Bjorn Ottosson, have been developed to improve on color uniformity.

Unless otherwise stated, The code and visuals below use SR LAB 2.


In this color model, color exists in 3 dimensions according to 3 channels of information: lightness (L), A and B. Because it is 3D, one way to visualize this model spatially is as a sequence of cross sections. The lightness axis goes from black at 0.0 to white at 100.0. The A and B axes are unbounded. The image above looks down the L axis at the A B plane as lightness increases from top left to bottom right.


The A axis ranges from green in the negative range to magenta in the positive range. The practical range for sRGB to SR LAB 2 is about [-83, 105].


The B axis ranges from blue in the negative range to yellow in the positive range. The practical range is about [-111, 96].

When plotted, the displayable colors in LAB do not form a convenient, symmetrical shape. Instead, the irregular shape is formed by where colors go out of gamut.

What is gamut? As you may recall from a high school science class, light energy can be represented as a wave. Only a small band of frequencies in this wave are visible to normal human eyes. An even smaller number of colors can be reproduced by most computer displays. A smaller range within that can be reproduced by a color printer. For our purposes, when a color channel is less than 0 or greater than 255, then it is out of gamut.

local dlg = Dialog { title = "Mix Colors" } ---@param lab { l: number, a: number, b: number, alpha: integer } ---@return Color ---@return boolean local function labToAseColor(lab) -- Unpack table. local lightness = lab.l local a = lab.a local b = lab.b local t = lab.alpha -- Convert from LAB to XYZ. local x0 = 0.01 * lightness + 0.000904127 * a + 0.000456344 * b local y0 = 0.01 * lightness - 0.000533159 * a - 0.000269178 * b local z0 = 0.01 * lightness - 0.0058 * b local x1 = x0 * (2700.0 / 24389.0) local y1 = y0 * (2700.0 / 24389.0) local z1 = z0 * (2700.0 / 24389.0) if x0 > 0.08 then x1 = ((x0 + 0.16) / 1.16) ^ 3.0 end if y0 > 0.08 then y1 = ((y0 + 0.16) / 1.16) ^ 3.0 end if z0 > 0.08 then z1 = ((z0 + 0.16) / 1.16) ^ 3.0 end -- Convert from XYZ to linear sRGB. local rl = 5.435679 * x1 - 4.599131 * y1 + 0.163593 * z1 local gl = -1.16809 * x1 + 2.327977 * y1 - 0.159798 * z1 local bl = 0.03784 * x1 - 0.198564 * y1 + 1.160644 * z1 -- Convert from linear sRGB to sRGB. local rs = rl * 12.92 local gs = gl * 12.92 local bs = bl * 12.92 if rl > 0.00304 then rs = 1.055 * (rl ^ (1.0 / 2.4)) - 0.055 end if gl > 0.00304 then gs = 1.055 * (gl ^ (1.0 / 2.4)) - 0.055 end if bl > 0.00304 then bs = 1.055 * (bl ^ (1.0 / 2.4)) - 0.055 end -- Convert range from [0.0, 1.0] to [0, 255]. local r255 = math.floor(0.5 + rs * 255.0) local g255 = math.floor(0.5 + gs * 255.0) local b255 = math.floor(0.5 + bs * 255.0) -- Determine whether color is in gamut. local isInGamut = true if r255 < 0 or r255 > 255 then isInGamut = false end if g255 < 0 or g255 > 255 then isInGamut = false end if b255 < 0 or b255 > 255 then isInGamut = false end -- Clip to gamut. local rClipped = math.min(math.max(r255, 0), 255) local gClipped = math.min(math.max(g255, 0), 255) local bClipped = math.min(math.max(b255, 0), 255) return Color(rClipped, gClipped, bClipped, t), isInGamut end ---@param aseColor Color ---@return { l: number, a: number, b: number, alpha: integer } local function aseColorToLab(aseColor) -- Unpack color. local r255 = aseColor.red local g255 = aseColor.green local b255 = aseColor.blue local a255 = aseColor.alpha -- Convert range from [0, 255] to [0.0, 1.0]. local rNorm = r255 / 255.0 local gNorm = g255 / 255.0 local bNorm = b255 / 255.0 -- Convert from sRGB to linear sRGB. local rLin = rNorm / 12.92 local gLin = gNorm / 12.92 local bLin = bNorm / 12.92 if rNorm > 0.03928 then rLin = ((rNorm + 0.055) / 1.055) ^ 2.4 end if gNorm > 0.03928 then gLin = ((gNorm + 0.055) / 1.055) ^ 2.4 end if bNorm > 0.03928 then bLin = ((bNorm + 0.055) / 1.055) ^ 2.4 end -- Convert from linear sRGB to XYZ. local x0 = 0.32053 * rLin + 0.63692 * gLin + 0.04256 * bLin local y0 = 0.161987 * rLin + 0.756636 * gLin + 0.081376 * bLin local z0 = 0.017228 * rLin + 0.10866 * gLin + 0.874112 * bLin local x1 = x0 * (24389.0 / 2700.0) local y1 = y0 * (24389.0 / 2700.0) local z1 = z0 * (24389.0 / 2700.0) if x0 > (216.0 / 24389.0) then x1 = 1.16 * (x0 ^ (1.0 / 3.0)) - 0.16 end if y0 > (216.0 / 24389.0) then y1 = 1.16 * (y0 ^ (1.0 / 3.0)) - 0.16 end if z0 > (216.0 / 24389.0) then z1 = 1.16 * (z0 ^ (1.0 / 3.0)) - 0.16 end -- Convert from XYZ to LAB. local lightness = 37.095 * x1 + 62.9054 * y1 - 0.0008 * z1 local a = 663.4684 * x1 - 750.5078 * y1 + 87.0328 * z1 local b = 63.9569 * x1 + 108.4576 * y1 - 172.4152 * z1 return { l = lightness, a = a, b = b, alpha = a255 } end local function updateMix() -- Unpack arguments. local args = dlg.data local orig = args.orig --[[@as Color]] local dest = args.dest --[[@as Color]] local percent = args.percent --[[@as number]] -- Convert from Aseprite Color to LAB. local oLab = aseColorToLab(orig) local dLab = aseColorToLab(dest) -- Mix in in LAB. local t = percent * 0.01 local u = 1.0 - t local ml = u * oLab.l + t * dLab.l local ma = u * oLab.a + t * dLab.a local mb = u * oLab.b + t * dLab.b local mt = math.floor(0.5 + (u * oLab.alpha + t * dLab.alpha)) -- Convert back to Aseprite Color. local mLab = { l = ml, a = ma, b = mb, alpha = mt } local mixed, isInGamut = labToAseColor(mLab) dlg:modify { id = "mix", color = mixed } dlg:modify { id = "oogLabel", visible = not isInGamut } end dlg:color { id = "orig", label = "Left:", color = Color(app.fgColor), onchange = updateMix } dlg:color { id = "dest", label = "Right:", color = Color(app.bgColor), onchange = updateMix } dlg:slider { id = "percent", label = "Percent:", value = 50, min = 0, max = 100, onchange = updateMix } dlg:color { id = "mix", label = "Mixed:", color = Color(0, 0, 0, 0) } dlg:label { id = "oogLabel", text = "Out of Gamut", visible = false } updateMix() dlg:show { wait = false }


As a color approaches white, or 100.0 lightness, most hues outside of yellow to cyan go out of gamut. As a color approaches black, or 0.0 lightness, stronger blues remain in gamut. (I've reduced the chroma, a concept introduced further below, to keep each hue ramp pictured above in gamut for the sake of comparison.)


This differs from HSL, where the primary hues are equally vivid at 50.0 lightness, then fade to white or black at equal rates.
Advanced: LCH
Chroma

LAB may be easier to grasp through its polar representation, abbreviated to LCH. The lightness component remains the same, but the Euclidean distance of the A and B axes from the origin are used to calculate the color's chroma. In other words, the square root of A-squared plus B-squared is chroma.

The nearest concept you may have to chroma is "saturation" from HSL and HSV. Saturation could be described as a relationship between lightness and chroma. Since the "saturation" in HSL and HSV is nigh meaningless[en.wikipedia.org], I'll try a different explanation (just bear in mind the air quotes).


Chroma is an absolute, unbounded lateral distance of a color from the gray axis.


"Saturation" is more like a percentage, or relative distance. The upper bound is the maximum beyond which the color no longer fits inside the standard RGB color cube and goes out of gamut. Two colors may have 100% "saturation", but wildly different chromas.

Hue


The arctangent of B and A is used to represent the hue. The hue could be in radians, degrees or between [0.0, 1.0). As far as the math is concerned, the hue's sign and range rarely matter; for readability, it is typically wrapped. For example, the hues -30 and 330 degrees are the same, since 360 - 30 = 330.

Going in the other direction, A and B can be found through the cosine and sine, respectively, of the hue multiplied by the chroma.

LCH has the same problem as Aseprite's hue gradient: if the chroma is zero, then hue is undefined.



The distribution of hues is not the same as in HSL and HSV. Red, for example, begins at approximately 41 degrees in SR LCH. The lime green of sRGB, #00FF00, is at approximately 135 degrees hue, only 94 degrees away from red.

Mixing

The three gradients below were mixed in LAB:




They were created by finding red at 50% lightness, #F30000. Its complementary[en.wikipedia.org] hue, a teal 180 degrees away, #008490, formed the destination color. Next, two hues 120 and 240 degrees away from red were chosen, #008A5F and #6F53FF. Then the complements of these hues were found: #DD00B7 and #917400.




Above, the color pairs were mixed in LCH. The benefit of this option is that, when two high chroma colors are chosen for the gradient's left and right edge, the middle swatch has higher chroma than the straight line mix of LAB above.




The images above were mixed with the opposite hue direction. The disadvantage of arcing by hue is that the middle swatch rarely resembles the colors on the left and right. When arc lengths are unequal, choosing the longer arc length exacerbates the problem.

Tone Mapping

In these gradients, out of gamut colors have been clamped to fit. While clamping is the fastest and simplest way to deal with gamut, it can lead to unwanted hue shifts. You can better see how these gradient functions work, as well as potential problems, if you plot the swatches from the discrete gradients.


One way to handle out of gamut colors is tone mapping. Tone mapping[en.wikipedia.org] is associated with the high dynamic range[en.wikipedia.org] (HDR) in digital photography and photorealistic graphics. However, since pixel art typically has a low count of unique colors, every color choice matters. This blog post[64.github.io] shows how to implement popular algorithms and includes visual comparisons between them.





To not belabor the point, the ACES tonemapping shown only for the red-blue gradient.

Gamut Mapping


Gamut mapping is another way to handle out of gamut colors. The developer of OK LAB wrote an article[bottosson.github.io] on the subject, and follow-up on using gamut mapping to develop OKHSL[bottosson.github.io]. OKHSL was probably not intended to generate gradients. Even so, it can be adapted to the purpose. Similar experiments can be performed with HSLuv[www.hsluv.org] by Alexei Boronine.


A disadvantage to mixing this way is that sharp discontinuities emerge due to swings in chroma. In the above gradient, look at the areas between purple and teal, between green and yellow and between yellow and pink. Among LCH's three channels, chroma is often altered to preserve the other two, held to be more important. That makes sense for color pickers or a hue adjustment to an image; for gradients, it is harder to compress or expand chroma without artifacts.


Despite this issue, the discrete swatches from the OKHSL gradient can be useful as anchor points to feed to a different mixing function.
Advanced: Normal Maps
The power of normal maps is that an artist defers choices about shading to a game engine. Characters can be lit dynamically in response to their proximity to light. Aseprite includes a normal map color wheel but there are caveats. As of version 1.2.40 or older, the normal map is calculated incorrectly.


For version 1.3, someone contributed a fix to the normal wheel, but the discrete wheel quantizes the inclination strangely. It is better to search for alternatives or to make your own.


Above is a custom normal wheel. A normal is a direction on the surface of a shape that instructs simulated light how to bounce off of the shape's surface. This direction is supposed to be of unit length. In other words, when its magnitude is calculated, the result should be 1. If it isn't, then the direction can be normalized through division of its x, y and z components by its magnitude.

  • mag ( v ) = sqrt ( x * x + y * y + z * z )
  • normalize ( v ) = v / mag ( v )

That's why normals are said to lie on a unit sphere, which has a radius of 1.0.


To create a discrete wheel, it helps to understand how to convert from Cartesian to spherical coordinates[en.wikipedia.org] and back again. One angle serves as the azimuth, in the period [0, 360) degrees. Another angle serves as the inclination, in the range [-90, 90]. To use a globe of the Earth as an analogy, the azimuth is like the longitude, or meridian. The inclination is like the latitude.

  • theta = atan2 ( y, x )
  • phi = acos ( z / mag ( v ) )
  • rho = mag ( v ) = 1

Where theta is the azimuth, phi is the inclination and rho is the radius.

  • x = rho * cos ( phi ) * cos ( theta )
  • y = rho * cos ( phi ) * sin ( theta )
  • z = rho * sin ( phi )

The idea is that a discrete wheel uses uniform steps for the inclination -- 0, 18, 36, 54, 72, 90 in this case -- and for the azimuth -- 0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300 and 330 degrees. This creates swatches of a nonuniform size by design. We are looking down at the top of a sphere, and so would see only a thin slice of directions that are orthogonal to our view. The same would be true for the objects we paint on in a normal map image.


Aseprite's built-in discrete wheel has inclinations at steps of 0, 36, 54, 67, 79 then 90.

Beware of wheels that look like the one below, which attempts to quantize normal map colors the same way as regular colors. And beware using SpriteIlluminator[www.codeandweb.com]'s normal map as a reference.


Alternative geometry for a reference is possible. I recommend using Blender[www.blender.org] for that purpose, however.


To mix normal map colors, it's easier to abandon the idea of mixing red, green and blue color channels. Instead, I convert RGB to a normal, then mix, then convert back.

  • normal = normalize(rgb * 2 - 1)
  • rgb = (normal + 1) / 2

#8080FF is such a prominent color in normal maps because 128 red, 128 green and 255 blue transform into (0, 0, 1), the direction that points straight toward the camera or viewer.

A color chosen by the user may not lie on the unit sphere. Depending on the greater goal, it may be clamped to the wheel, normalized or omitted from consideration.

local dlg = Dialog { title = "Mix Colors" } ---@param aseColor Color ---@return { x: number, y: number, z: number } local function aseColorToVector(aseColor) -- Unpack color. local r255 = aseColor.red local g255 = aseColor.green local b255 = aseColor.blue -- Convert range from [0, 255] to [0.0, 1.0]. local rNorm = r255 / 255.0 local gNorm = g255 / 255.0 local bNorm = b255 / 255.0 -- Convert range from [0.0, 1.0] to [-1.0, 1.0]. local x = rNorm * 2.0 - 1.0 local y = gNorm * 2.0 - 1.0 local z = bNorm * 2.0 - 1.0 -- Find the magnitude of the vector. local sqMag = x * x + y * y + z * z if sqMag > 0.0 then -- Normalize the vector. local mag = math.sqrt(sqMag) local xUnit = x / mag local yUnit = y / mag local zUnit = z / mag return { x = xUnit, y = yUnit, z = zUnit } end return { x = 0.0, y = 0.0, z = 1.0 } end ---@param vector { x: number, y: number, z: number } ---@return Color local function vectorToAseColor(vector) local x = vector.x local y = vector.y local z = vector.z local sqMag = x * x + y * y + z * z if sqMag > 0.0 then -- Normalize the vector. local mag = math.sqrt(sqMag) local xUnit = x / mag local yUnit = y / mag local zUnit = z / mag -- Convert from range [-1.0, 1.0] to [0.0, 1.0]. local rNorm = xUnit * 0.5 + 0.5 local gNorm = yUnit * 0.5 + 0.5 local bNorm = zUnit * 0.5 + 0.5 -- Convert from range [0.0, 1.0] to [0, 255]. local r255 = math.floor(rNorm * 255.0 + 0.5) local g255 = math.floor(gNorm * 255.0 + 0.5) local b255 = math.floor(bNorm * 255.0 + 0.5) return Color(r255, g255, b255, 255) end return Color(128, 128, 255, 255) end local function updateMix() -- Unpack arguments. local args = dlg.data local orig = args.orig --[[@as Color]] local dest = args.dest --[[@as Color]] local percent = args.percent --[[@as number]] local origVec = aseColorToVector(orig) local destVec = aseColorToVector(dest) local t = percent * 0.01 local mixVec = { x = 0.0, y = 0.0, z = 1.0 } if t <= 0.0 then mixVec = origVec elseif t >= 1.0 then mixVec = destVec else -- Find the dot product between origin and destination. -- Clamp the result to just shy of [-1.0, 1.0], where -- vectors would be parallel to each other. local odDot = math.min(math.max( origVec.x * destVec.x + origVec.y * destVec.y + origVec.z * destVec.z, -0.999999), 0.999999) -- Find omega from dot product. local omega = math.acos(odDot) local omSin = math.sin(omega) local omSinInv = 1.0 if omSin ~= 0.0 then omSinInv = 1.0 / omSin end local oFac = math.sin((1.0 - t) * omega) * omSinInv local dFac = math.sin(t * omega) * omSinInv local cx = oFac * origVec.x + dFac * destVec.x local cy = oFac * origVec.y + dFac * destVec.y local cz = oFac * origVec.z + dFac * destVec.z mixVec = { x = cx, y = cy, z = cz } end local mixed = vectorToAseColor(mixVec) dlg:modify { id = "mix", color = mixed } end dlg:color { id = "orig", label = "Left:", color = Color(app.fgColor), onchange = updateMix } dlg:color { id = "dest", label = "Right:", color = Color(app.bgColor), onchange = updateMix } dlg:slider { id = "percent", label = "Percent:", value = 50, min = 0, max = 100, onchange = updateMix } dlg:color { id = "mix", label = "Mixed:", color = Color(0, 0, 0, 0) } updateMix() dlg:show { wait = false }

To ensure that the colors created by mixing lie on the sphere, I use spherical linear interpolation (slerp)[en.wikipedia.org]. Knowing the vector dot product[en.wikipedia.org] will make slerp easier to understand.

On the subject of blending normals, I recommend Stephen Hill's "Blending in Detail[blog.selfshadow.com]."
Advanced: Contrast
'Contrast' is one of those terms that are frequently used, but poorly defined. In the context of normal maps from the previous section, artists may want to go back to their artwork and reduce the contrast in lightness. This is to reduce baked in shadows and let dynamic lighting do its work.

To show what I mean, below is a public domain image, Ulysses Deriding Polyphemus[en.wikipedia.org] by J.M.W. Turner. It's not pixel art, but should work for this concept. First, a version with increased contrast in lightness.


Then, with reduced contrast.


The right hand side of each image has been converted to grayscale so the values are easier to see. The above changes were made with OKHSL using the formula

  • L' = (L - 50) * F + 50

where F is an adjustment factor, 0 being no contrast at all. The pivot point is 50% lightness for simplicity, but you could also audit the image's average lightness.

Aseprite's built-in function, under Edit > Adjustments > Brightness/Contrast, should not be used.


Increased contrast above, decreased contrast below.


This is because chroma is conflated with contrast. As contrast is reduced, the image trends towards gray. As contrast is increased, colors become more vivid.


If it's hard to see the problem, review the image above, which has no deliberate contrast adjustment. The left third is the original color. The middle third is over-saturated. The right third is reduced to gray. The gray values are in between the extremes of the OKHSL contrast adjusted images from earlier.

In some tutorials, contrast may be used to refer to contrasts between hue, not lightness. The suggestion being that hues nearer to each other on a color wheel would clash less.


I'm none too sure how you'd go about doing that via Lua script. Perhaps let the user determine a dominant hue, then shift all the hues in an image toward the dominant by a factor. In the image above, for example, the left portion of the image is shifted towards red, by 50% and then 100%. The right, towards yellow.
Advanced: Kubelka-Munk
The last way I know of to mix colors is to simulate paints based on the theory of Kubelka and Munk[en.wikipedia.org]. If you've heard of this, it's probably due to Mixbox, popularized in videos such as this one:

https://www.youtube.com/watch?v=_qa5iWdfNKg

I won't say much here, as I've posted about it on the Aseprite community forum[community.aseprite.org], including Lua code that was adapted from Spectral js[github.com] by Ronald van Wijnen.




You can see the promise of such a mixing method in the ramps above. There are many factors beyond color that contribute to a painterly look, though, and there's a limit to what Lua scripts can contribute to base Aseprite functionality.
Color Theory
Many tutorials advise that color theory guide an artist's color choices. I believe this advice should be reconsidered. Few explain that color in physical media, which seems to inform color theory, differs from digital color. Even if the tenets of color theory had scientific foundations, they still wouldn't transfer from a subtractive medium, like paint on canvas, to an additive medium, like pixels on a screen.

Color Harmonies

There is more than one color wheel. These color wheels, plural, don't have the same primaries or hue distributions. While such wheels are supposed to isolate hue, they often implicitly associate hue with lightness and chroma. As you've seen in previous sections, digital color typically contains 3 dimensions of information minimum. So there's much leeway as to how we project onto 2 dimensions.

To illustrate, see the color "wheels" below. I've made them into regular convex polygons so as to emphasize the number of primaries in each wheel. Compare this red-yellow-blue wheel


with a red-yellow-green-blue wheel,


and with the hexagon formed by the digital sRGB color cube.


In the wheels above, the complement of red varies from green to teal to bright cyan. The complement of yellow varies between blue and purple.

Now look at these wheels in grayscale:




Yellow is typically the lightest color in the wheel, and it would make sense for its complement to be the darkest. Depending on where red is positioned between yellow and its complement, red may be at medium lightness, or slightly darker.


Much finessing is needed to make the sRGB-based hue wheel look even remotely like the others.


The division of a color wheel into warm and cool temperatures is itself a vague concept that has trouble accounting for greens. But even if we accept the division unquestioningly, the digital sRGB wheel doesn't work. The warm side contains too much purple, and red is too near yellow.


Perceptual color has its own issues. Light contrast needs to be increased for the OKSHL color wheel to look more like a traditional wheel. After that adjustment, the hues look a little odd. Teal covers a huge swath of the wheel, green is narrow and you'd be hard-pressed to find red within magenta.


Aseprite includes options for an RGB and RYB color wheel under the palette hamburger menu, as well as options for color harmonies. They will not be very helpful, given the irregular lightness of hues.



Chroma becomes the primary constraint when trying harmonies in a perceptual color model with an irregular shape:


The analogous harmony considers hues 30 and 330 degrees away from the key color; complement, 180 degrees; split analogous, 150 and 210 degrees; square, 90, 180 and 270 degrees degrees; tetradic, 120, 180 and 300 degrees; triadic, 120 and 240 degrees. The monochrome harmony shows a color with 50% the chroma of the key.

The geometric relationship is easier to see when the minimum in-gamut chroma is taken. For example, in the square harmony, the blue hue #007B8F has the minimum chroma. For the green hue, #2E8900 has a higher chroma, but #5F8050 matches the blue in chroma. When the maximum in-gamut chroma per each hue is used, you get more vivid colors at the increased risk of clashes.


When a color remains vivid at the extremes -- high or low lightness -- the likelihood of finding harmonies in gamut diminishes. For that reason, a key color's complement is not only 180 degrees apart in hue, its lightness is inverted as well. Hues perpendicular to the key color remain at 50% lightness.

For example, see the square harmony above. The key color yellow, #FFFF00, is at 97% lightness and 111 degrees hue. Its complement, #0D0030, has 3% lightness and 291 degrees hue. One perpendicular, #008688, has 50% lightness and 201 degrees hue; the other perpendicular, #A66466, has 50% lightness and 21 degrees hue.

Any explainer worth its salt would walk you through an afterimage[en.wikipedia.org] experiment to test your intuition and explain how opponent-process theory[en.wikipedia.org] relates to these harmonies. For example, in this talk at 6:01.

https://www.youtube.com/watch?v=2BReTHTcogM

Hue-Shifted Shading

In pixel art, hue-shifting shades of a color is so prevalent that the distribution of hues on a color wheel is more important than in other genres. It's hard to say what the reasoning behind this technique is, since one is rarely given. One guess is Rayleigh scattering[en.wikipedia.org]. However, not every image depicts a sunny day under blue skies in the verdant countryside. Other guesses are the Bezold-Brucke[en.wikipedia.org] and Purkinje[en.wikipedia.org] shift.




Particularly unfortunate, are beginners who generate palettes according to regular numeric steps, believing that HSL and HSV are adequate models of human perception. The tables of numbers beneath these palettes do not lend them any more objectivity, authority, reproducibility or credibility than palettes created intuitively.

The problem is worsened by conversion to 8-bit unsigned integer RGB color channels, which is not enough precision to prevent numerical drift. For example, 25 degrees hue, 95% saturation, 55% light becomes 97.75% red, 47.875% green, 12.25% blue. Remapped to [0, 255], that's 249 red, 122 green, 31 blue (#F97A1F). If these RGB values are re-converted to HSL, they yield 25.05 degrees hue, 94.78% saturation, 54.9% lightness.




OKHSL or one of the other perceptual color models may help in some cases. For example, in the image above, #72160F and #044168 are key colors. The color wheel was altered to be more like a RYB wheel.

In defense of tutorial creators, hue shifting is better than just adding more black or more white. Ultimately, however, there's no shortcut to learning through observation. In 2D art, color consolidates a number of complex variables that in 3D art would be handled separately by lights and materials. The artist effectively acts as the renderer, a task that would be automated in 3D software like Blender. The benefit of doing such work manually is that you choose the balance between style and realism.

There's more to say, but not much room to say it. For further reading on the subject, I recommend Pixel Parmesan's article "Color Theory for Pixel Artists: It's All Relative[pixelparmesan.com]."
More Resources
The scripts I included here are simplifications of scripts available at AsepriteAddons[github.com] and AsepriteOKHSL[github.com]. I used the the scripts in these repositories to explore and create many visualizations in this tutorial.

Other online color pickers, palette generators and fun toys include:


Specifically for color palettes on Lospec[lospec.com], use Censor, not DawnBringer (DB), if you'd like to analyze a palette. For example here is an analysis of Endesga 64[lospec.com].


The analyzer can be downloaded as a standalone command line program from its Github repository[github.com].