Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overhaul player color logic #19335

Merged
merged 9 commits into from
May 15, 2021
Merged

Overhaul player color logic #19335

merged 9 commits into from
May 15, 2021

Conversation

pchote
Copy link
Member

@pchote pchote commented Apr 11, 2021

The current player color logic has served us well, but has a few problems:

  1. The colors in the palette's remap range are completely ignored, and replaced with a linear range (with a special case for D2k's reverse linear range). This works well enough for RA / TD / D2k, but fails badly on any project that doesn't have uniformly spaced brightness steps between colors. Many community mods (including the planned content update for the SDK example mod) resorted to custom code (@IceReaper's OverlayPlayerColorPalette) to bypass this... but this should really be solved upstream.

  2. The color validator has always been flakey, allowing some players to successfully set colors that make them hard to see on the minimap, and other times rejecting colors that are clearly distinct (including some of the preset colors on the palette tab!)

  3. The brightness adjustments don't work with the chroma-key remapping for RGBA sprites (remaster assets + mod support). While i'm sure it would be possible to come with some ad-hoc rules to make this usable in some form, I don't think its worth the trouble. On the technical side, having only two variables define the player color allows us to pack all the required information (including the min and max hue for the chroma keying) into a single vec4, simplifying the implementation.

This PR aims to solve the above three points. The first three commits move code and definitions around without changing much behaviour. The idea was to centralize all the color picker logic in one place to make it easier for modders to restore the previous behaviour or introduce their own if the new approach does not suit them.

The fourth commit replaces the remap logic with a new implementation that roughly matches the behaviour of the upcoming RGBA chroma-key remapping (but not exactly: we're still overriding the hue and saturation instead of applying a delta - the delta approach looks bad with the RA palette).

The final commit reworks the color validation logic and UI:

hspicker

Closes #10906.
Closes #17582.
Closes #17941.
Fixes #19133.

During my research on this I found https://www.compuphase.com/cmetric.htm, which appears to be the original source for our current validator logic. It turns out that #6239 missed a subtle but important point: the normal Color objects that we are used to are in the SRGB (i.e. gamma-corrected) color space, but the algorithm requires linear (non-gamma-corrected) colors! This goes a long way towards explaining (2).

Reducing the selectable colors to a fixed brightness also helps with (2), as the v=0.95 plane is brighter than most of the default terrain colors, making it impossible to select a color that conflicts with most of the terrain. There is overlap with the resource colors, but the fixed validator logic seems to handle this well enough.

This change also requires a new set of preset colors to be defined for each mod, and rather than repeating the long process from the first time I wrote a script that could visualise the color space and the validation thresholds. I started by placing a ring of 8 saturated and 8 desaturated colors, then tweaked the positions to reduce overlap between eachother and with the resource colors.

Expand to see python code

Paste this into a Jupyter notebook cell (or your preferred alternative):

import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
import numpy as np
from PIL import ImageColor
from scipy.spatial import ConvexHull


def lerp(a, b, t):
    return a + t * (b - a)

def srgb_to_linear(srgb):
    return np.where(srgb <= 0.04045, srgb / 12.92, np.power((srgb + 0.055) / 1.055, 2.4))

def color_distance(srgb1, srgb2):
    c1 = srgb_to_linear(np.atleast_2d(srgb1))
    c2 = srgb_to_linear(np.atleast_2d(srgb2))
    
    rmean = (c1[:, 0] + c2[:, 0]) / 2
    w = np.array([2 + rmean, 4 * np.ones_like(rmean), 3 - rmean]).T
    return np.linalg.norm(np.sqrt(w) * (c1 - c2), axis=1)

def hsv_to_rgb(h, s, v):
    px = np.abs(h % 1 * 6. - 3)
    py = np.abs((h + 2. / 3) % 1 * 6. - 3)
    pz = np.abs((h + 1. / 3) % 1 * 6. - 3)
    return v * np.dstack((
        lerp(1., np.clip(px - 1, 0, 1), s),
        lerp(1., np.clip(py - 1, 0, 1), s),
        lerp(1., np.clip(pz - 1, 0, 1), s)))

def cartesian_to_srgb(x, y, v):
    sat = np.linalg.norm([x, y], axis=0)
    hue = np.arctan2(y, x) / (2 * np.pi)
    return hsv_to_rgb(hue, sat, v)

def generate_samples(density, min_sat=0, max_sat=1, v=0.95):
    y = np.arange(-1, 1, density)
    x = np.arange(-1, 1, density)
    xx, yy = np.meshgrid(x, y)
    srgb = cartesian_to_srgb(xx, yy, v)

    sat = np.linalg.norm([xx, yy], axis=0)
    valid = np.logical_and(sat > min_sat, sat < max_sat)

    return xx[valid], yy[valid], srgb[valid]

def plot_threshold_hull(c, x, y, srgb, threshold=0.314):
    distance = color_distance(c, srgb)
    blocked = np.vstack([x[distance < threshold], y[distance < threshold]]).T
    if len(blocked) == 0:
        return
    
    hull = ConvexHull(blocked)
    for simplex in hull.simplices:
        plt.plot(blocked[simplex, 0], blocked[simplex, 1], '-', color=c[0])

def plot_preset_colors(hue, saturation, terrain, v=0.95):
    plt.figure(figsize=(10, 10))
    ax = plt.gca()

    # Coarse grid for background
    x, y, srgb = generate_samples(0.03, 0.3, 0.95, v=v)
    plt.scatter(x, y, c=srgb, s=2)

    # Fine grid for polygons
    x, y, srgb = generate_samples(0.005, 0.3, 0.95, v=v)

    for tc in terrain:
        c = np.atleast_2d(ImageColor.getcolor(tc, "RGB")) / 255.
        plot_threshold_hull(c, x, y, srgb)

    for j in range(2):
        for i in range(0, 8):
            k = j * 8 + i
            if k >= len(hue):
                break

            # Blocking threshold
            ix = saturation[k] * np.cos(hue[k] * 2 * np.pi)
            iy = saturation[k] * np.sin(hue[k] * 2 * np.pi)
            ic = cartesian_to_srgb(ix, iy, v)[0]

            plot_threshold_hull(ic, x, y, srgb)

            # Color indicator
            plt.plot(ix, iy, 's', color=ic[0])

            # Preset color bar
            plt.plot(i / 4 - 7/8., -j / 4 - 1.25, 's', color=hsv_to_rgb(hue[k], saturation[k], 0.95)[0][0], ms=51)

    plt.xlim(-1, 1)
    plt.ylim(-1.63, 1)
    plt.xticks([])
    plt.gca().set_aspect(1)
    plt.yticks([])

and then generate the plot for e.g. ra:

ra_hue = [0, 0.125, 0.22, 0.375, 0.5, 0.56, 0.8, 0.88, 0, 0.15, 0.235, 0.4, 0.47, 0.55, 0.75, 0.85]
ra_sat = [0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.4, 0.5, 0.4, 0.5, 0.4, 0.5, 0.4, 0.5]
ra_terrain = [
    '#000000',
    '#1C2024',
    '#284428',
    '#44443C',
    '#587474',
    '#5C74A4',
    '#5C8CB4',
    '#5DA5CE',
    '#5E430D',
    '#606060',
    '#6F848B',
    '#745A3F',
    '#8470FF',
    '#865F45',
    '#948060',
    '#A87B53',
    '#B09C78',
    '#C4C4C4',
    '#D0C0A0',
]

plot_preset_colors(ra_hue, ra_sat, ra_terrain)

Here are the colors for TD:

preset_cnc

The small coloured dots in the background define the hue-saturation plane, which is the same as what we show ingame, but in its natural shape instead of cutting and stretching it into a rectangle. The coloured outlines show the validator limits for each of the preset colors marked with a square, and for the green and blue tiberium (top and left of the circle without squares).

The filled squares along the bottom show the presets as they appear in the color chooser.

This shows that the 16 colors are separated about as well as we can hope, covering most of the color space and not blocking eachother (none of the colored squares are inside the shape from another color).

Colors for RA
Colors for D2k
Colors for TS (uses the same as TD for simplicity)

I expect this to be the last must-have remaster support PR needed to release a public preview that supports cross-play with the next release. The rest of the changes (including the chroma-key remapping itself) don't impact multiplayer compatibility, so there is less rush to merge them upstream.

This is a relatively small PR all things considered, but the underlying logic with the different color spaces and transformations is inherantly messy and confusing. It has taken me several sessions over the last few months to properly understand all these details, so I don't expect anybody else to off the top of their head either. If anything doesn't make sense just ask and i'll either try to explain here or if appropriate improve commenting in the code.

public readonly float[] PresetSaturations = { };

[PaletteReference]
public readonly string PaletteName = "colorpicker";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mods might want to support more than one colorpicker palette with different RemapIndices for e.g. FactionPreviewActors or the asset browser. This would be a good time to introduce an IProvidesColorPickerPaletteInfo interface to decouple the palette side from the UI side.

@pchote
Copy link
Member Author

pchote commented Apr 11, 2021

Now needs a rebase/update for #19239.

@pchote
Copy link
Member Author

pchote commented Apr 11, 2021

Updated:

  • Rebased
  • Fixed penev's comments above
  • Fixed some more inconsistencies i noticed myself
  • Fixed the titan turret facing in the TS color picker

Still need to update my own comment above.

OpenRA.Game/Primitives/Color.cs Outdated Show resolved Hide resolved
OpenRA.Game/Primitives/Color.cs Outdated Show resolved Hide resolved
@pchote pchote force-pushed the playercolor branch 5 times, most recently from fb702ce to 736ba98 Compare April 11, 2021 23:03
@teinarss
Copy link
Contributor

Could we update Color.ToAhsv() to use the tuple syntax?

		public (int A, float H, float S, float V) ToAhsv()
		{
			var (h, s, v) = RgbToHsv(R, G, B);

			return (A, h, s, v);
		}

@pchote pchote force-pushed the playercolor branch 3 times, most recently from 5421e7a to 71f7325 Compare April 12, 2021 19:18
@pchote
Copy link
Member Author

pchote commented Apr 12, 2021

Updated.

@pchote
Copy link
Member Author

pchote commented Apr 12, 2021

Fixed.

@pchote pchote force-pushed the playercolor branch 2 times, most recently from a0074a7 to 4167404 Compare May 9, 2021 14:24
@pchote
Copy link
Member Author

pchote commented May 9, 2021

Updated.

@pchote
Copy link
Member Author

pchote commented May 9, 2021

Updated to avoid multiple enumeration.

@pchote
Copy link
Member Author

pchote commented May 15, 2021

Rebased.

@teinarss teinarss merged commit 98caae1 into OpenRA:bleed May 15, 2021
@Therapist1911
Copy link

Therapist1911 commented Jun 27, 2021

Hi, I just wanted to say that word is spreading amongst the Red Alert players that the color picker will be highly gimped next release. This is not going over well with some. Despite it's shortcomings, I've never heard anyone complain about it. Besides a solution for this already exists in the player color stance option. A lot of people are attached to or identify with certain colors that aren't in the selected 16. Please reconsider. Thanks.

@pchote
Copy link
Member Author

pchote commented Jun 28, 2021

The 16 colours in the plots above are the presets on the second tab of the colour chooser. Players are not limited to just those.

@Therapist1911
Copy link

The 16 colours in the plots above are the presets on the second tab of the colour chooser. Players are not limited to just those.

my bad... I guess I was misinformed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
6 participants