Shiny Red Buttons (5)

The gloss gradient code we saw on Tuesday has got a lot of fairly arbitrary math in it – including both constants and functions – that “tunes” the final appearance of the gradient. Today I want to walk through some of that math, and see how it might be adjusted to dampen the caustic effect, and brighten the highlight.

Luminance Gloss Scaling

The perceptualGlossFractionForColor() function exists to scale the overall intensity of the gloss highlight; it does this by first converting the base color to a luminance value, and then returning the value of this computation:

pow(glossScale, REFLECTION_SCALE_NUMBER);

where REFLECTION_SCALE_NUMBER is tunable, but currently hard-coded to 0.2. It’s important to note that this function doesn’t set the shape of the highlight – that’s controlled by calc_glossy_color() – or even the intensity of highlights in general – that’s controlled by REFLECTION_MIN and REFLECTION_MAX in drawGlossyRect:withColor:inContext: – but rather the extent to which highlights are dimmed for darker base colors.

Test resultsAs you can see in the curves to the left, as REFLECTION_SCALE_NUMBER approaches 0, the attenuation becomes “squarer”: all colors receive brighter highlights, but darker colors receive disproportionately brighter highlights. To the extent that one wishes to change highlight intensity for only one range of luminances, this is the contstant to adjust.

Caustic Color

The computation of a caustic color is probably the most complex part of the gloss gradient code. This computation is done by the perceptualCausticColorForColor() function, which contains more fiddly bits per line than any other piece of code.

In the simplest case, this function generates a caustic color by shifting the base color towards bright yellow (hue = 1/6, brightness = 1); the shift is greater for base hues closer to yellow. There are two exceptions to this rule: Base colors that are “too blue” (have a hue greater than MAX_BLUE_THRESHOLD) are shifted towards bright magenta (hue = 5/6, brightness = 1), rather than yellow, and base colors that are “too gray” (have saturation less than 0.001, and so have no well-defined hue) are assigned a special bright yellow with a hard-coded saturation (of GRAYSCALE_CAUSTIC_SATURATION) as a caustic.

I think it’s best to analyze this function by beginning at the end, and seeing how the final caustic color is computed:

UIColor* caustic = [UIColor colorWithHue:hue * (1.0 - scaledCaustic) + targetHue * scaledCaustic
			      saturation:saturation
			      brightness:brightness * (1.0 - scaledCaustic) + targetBrightness * scaledCaustic
				   alpha:inputComponents[3]];

As you can see, this code does a simple linear blend between the original color’s hue and brightness, and a “target” color’s hue and brightness, preserving the original saturation and alpha. The degree of blend is based upon the scaledCaustic variable, which is computed as follows:

float scaledCaustic = CAUSTIC_FRACTION * 0.5 * (1.0 + cos(COSINE_ANGLE_SCALE * M_PI * (hue - targetHue)));

The first few elements in this expression are easy enough to understand:

  • 1.0 is added to the result of cos to convert a [-1,1] range to [0,2]
  • 0.5 is multipled by a [0,2] range to convert it to [0,1]
  • CAUSTIC_FRACTION scales the [0,1] range s.t. “fully shifted” is a blend between the original and target colors, rather than simply the target color itself. It determines the maximum amount of a target color that will be blended into a (non-gray) base color to produce a caustic.
  • The cos expression controls the degree to which a base color’s hue causes more or less (relative to other hues) blending to be used in the computation of its caustic.

Test resultsI had a good deal of trouble understanding the cos expression; the difference between two hues can be understood as an angle, and, since cos is a trigonometric function, it seemed natural to me to apply some geometric rationale to this expression. This appears to have been a mistake. Consider the graph to the left: the red line plots the cos of the angle between a hue and 1/6 (scaled and shifted to the range [0, 1]), while the green line plots scaledCaustic (with a CAUSTIC_FRACTION of 1.0, and a targetHue of 1/6) for hues between -0.05 and 0.7 (hopefully the rationale for this somewhat bizarre domain will become clear as we move forward).

It is clear that scaledCaustic is not a function of the minimum angle to 1/6; there is no minimum at x=2/3, for instance. Instead, cos is being uses as a simple attenuation function that happens to be symmetric about 1/6 – the targetHue in this case. The choice of this function, and the choice to shift hues between 2/3 and 0.7 (among others) towards yellow means that, somewhat bizarrely, hues on different sides of 2/3 (pure blue) will be assigned different scaledCaustic values, despite being equidistant from yellow. (I’m tempted to set the MAX_BLUE_THRESHOLD threshold value at 2/3 to eliminate this problem.)

In terms of tuning, decreasing COSINE_ANGLE_SCALE will tend to equalize the shifts assigned to different hues, by increasing the shifts assigned to hues further from targetHue. Increasing COSINE_ANGLE_SCALE will have the opposite effect at first, but as this value approaches and passes 2.0, minima will begin to appear inside the function’s domain, and some “further” hues will being to receive larger shifts than other “nearer” hues. (I.e. don’t do this.) Increasing or decreasing CAUSTIC_FRACTION will enhance or dampen the caustic effect for all hues, but it is nonsensical to increase this value beyond 1.0.

There are three special cases:

  • Colors with very low saturation
  • Colors with a very high hue value
  • Colors in the blue-red third of the hue wheel

Colors with very low saturation are assigned a caustic with the target hue, a hardcoded saturation (GRAYSCALE_CAUSTIC_SATURATION), and a maximum (CAUSTIC_FRACTION) blend between the base color and full brightness. If you don’t like the way your grays look, you might try adjusting GRAYSCALE_CAUSTIC_SATURATION for a more or less pronounced yellow tint in their caustics.

Colors with a very high hue value (>= 0.95) are ‘wrapped’: they are decremented by 1 (yielding the -0.05 domain bound seen above).
There’s a chance that this hack could allow a sufficiently small scaledCaustic to produce a negative hue in the final color; you probably don’t want to lower CAUSTIC_FRACTION below ~0.15, the minimum value that will preclude this from happening. This wrapping appears to have been done s.t. the colors immediately to the “left” (blue side) of pure red (hue = 0) could be shifted towards yellow; if the normalized hues of those colors were used, they would “break” the cos attenuation function.

Colors with hues between 0.7 (slightly to the red side of pure blue) and 0.95 (slightly to the blue side of pure red) are shifted towards magenta, not yellow. I can’t see why the original code “cheats” these borders into this third of the hue wheel. It seems that a lot of ugliness could be avoided if the borders were at 2/3 and 1.

Gradient Shapes

The “shapes” of the highlight and caustic gradients are determined by some exponential functions in calc_glossy_color(). This function has two distinct halves, one each devoted exclusively to the highlight and caustic gradients. Let’s consider the highlight half first.

The output color is computed by this code:

out[0] = params->color[0] * (1.0 - currentWhite) + currentWhite;
out[1] = params->color[1] * (1.0 - currentWhite) + currentWhite;
out[2] = params->color[2] * (1.0 - currentWhite) + currentWhite;
out[3] = params->color[3] * (1.0 - currentWhite) + currentWhite;

which does a linear blend between the base color and pure white; the blend proportion is given by currentWhite. CurrentWhite is computed by this code:

float currentWhite = progress * (params->finalWhite - params->initialWhite) + params->initialWhite;

which does a linear interpolation between the initial (brightest) and final (dimmest) highlight fractions. This interpolation is driven in turn by progress, which is computed by this code:

progress = 1.0 - params->expScale * (expf(progress * -params->expCoefficient) - params->expOffset);

Test resultsHere, the progress input is a number between 0 and 1 that describes a normalzed position in the highlight portion of the gradient. If you consult drawGlossyRect:withColor:inContext:, you’ll see that expScale and expOffset are derived from expCoefficient s.t. this function’s range is limited to [0, 1]. Without (further) belaboring the matter (too much):

  • expf(progress * -params->expCoefficient) maps a [0, 1] domain to a [1, M] range
  • Subtracting params->expOffset maps a [1, M] domain to a [1-M, 0] range
  • Scaling by params->expScale maps a [1-M, 0] domain to a [1, 0] range
  • Subtracting from 1 maps a [1, 0] domain to a [0, 1] range

The function expands to:

1.0 - 1.0/(1.0 - expf(-params.expCoefficient)) * (expf(progress * -params->expCoefficient) - expf(-params.expCoefficient))

Several plots of this, for different values of expCoefficient, can be seen above.

  • Blue is plotted with an expCoefficient of 0.6
  • Red is plotted with an expCoefficient of 1.2 (the supplied value)
  • Green is plotted with an expCoefficient of 2.2
  • Yellow is plotted with an expCoefficient of 6.0

Basically, as expCoefficient increases, the highlight gradient’s progression becomes less linear, and changes more rapidly near the top of the button.

The caustic half of the gradient is very similar. The output color is computed by this code:

out[0] = params->color[0] * (1.0 - progress) + params->caustic[0] * progress;
out[1] = params->color[1] * (1.0 - progress) + params->caustic[1] * progress;
out[2] = params->color[2] * (1.0 - progress) + params->caustic[2] * progress;
out[3] = params->color[3] * (1.0 - progress) + params->caustic[3] * progress;

which does a linear blend between the base and caustic colors; the blend proportion is given by progress, which is computed by this code:

progress = params->expScale * (expf((1.0 - progress) * -params->expCoefficient) - params->expOffset);

Test resultsHere, the progress input is a number between 0 and 1 that describes a normalzed position in the caustic portion of the gradient. Without (further) belaboring the matter (too much):

  • Subtracting from 1 maps a [0, 1] domain to a [1, 0] range
  • expf(X * -params->expCoefficient) maps a [1, 0] domain to an [M, 1] range
  • Subtracting params->expOffset maps an [M, 1] domain to a [0, 1-M] range
  • Scaling by params->expScale maps a [0, 1-M] domain to a [0, 1] range

The function expands to:

1.0/(1.0 - expf(-params.expCoefficient)) * (expf((1.0 - progress) * -params->expCoefficient) - expf(-params.expCoefficient))

Several plots of this, for different values of expCoefficient, can be seen above.

  • Blue is plotted with an expCoefficient of 0.6
  • Red is plotted with an expCoefficient of 1.2 (the supplied value)
  • Green is plotted with an expCoefficient of 2.2
  • Yellow is plotted with an expCoefficient of 6.0

Basically, as expCoefficient increases, the caustic gradient’s progression becomes less linear, and changes more rapidly near the bottom of the button.

Summary

Ok, that was a little long. As you can see, there are an awful lot of arbitrary elements in this code, driven more by what “looks right” than any physical rationale. Here’s a quick guide to the most tweakable constants:

  • REFLECTION_SCALE_NUMBER affects the relationship between a base color’s luminance and the intensity of its highlight. As this constant decreases, the relationship becomes less linear, and only the darkest colors have their highlights noticeably dimmed.
  • REFLECTION_MIN and REFLECTION_MAX control the overall intensity of the top and bottom of the highlight. Increase them for brighter highlights.
  • COSINE_ANGLE_SCALE affects the relationship between a base color’s hue and the size of the shift used to create the caustic. Decreasing this constant will tend to equalize the shifts between different hues.
  • CAUSTIC_FRACTION affects the overall size of shifts used to generate caustics. Decreasing this constant will tend to produce less dramatic caustics. It might be dangerous to reduce this constant below ~0.15, due to some hacks in the code.
  • GRAYSCALE_CAUSTIC_SATURATION controls the intensity of the caustics generated for grays.
  • EXP_COEFFICIENT controls the ‘shape’ of the gradient; as it increases the gradients change more rapidly at the top and bottom of the button relative to its center.

Test resultsI found the following adjustments to my liking; your mileage may vary:

  • Reduced CAUSTIC_FRACTION from 0.60 to 0.35
  • Increased EXP_COEFFICIENT from 1.2 to 4.0
  • Increased REFLECTION_MAX from 0.60 to 0.80
Share and Enjoy:
  • Twitter
  • Facebook
  • Digg
  • Reddit
  • HackerNews
  • del.icio.us
  • Google Bookmarks
  • Slashdot
This entry was posted in Uncategorized. Bookmark the permalink.