Erstellen Sie Desktop-Farbverläufe und Skia-Shader · Push-Pixel

Verlaufszeichnungsfüllungen sind ein grundlegendes Werkzeug, das Teil jedes Grafik-Frameworks sein sollte, und Compose ist keine Ausnahme. Es gibt verschiedene Arten von Farbverläufen – linear, radial, konisch, geschwungen –, die jeweils die „Form“ der Farbverschiebung identifizieren. In diesem Beitrag werde ich mich darauf konzentrieren, die Berechnung von Zwischenfarben dieser Form zu steuern, und daher werde ich ab diesem Punkt nur noch über lineare Farbverläufe sprechen. Und was noch wichtiger ist, damit sich die Codebeispiele auf die Farbinterpolation konzentrieren, werde ich nur über horizontale Verlaufslinien sprechen, die durch zwei Farben definiert sind, Anfang und Ende.

Schauen wir uns zunächst den folgenden Screenshot an:

Hier haben wir vier Gruppen von horizontalen Farbverläufen. Die ersten beiden verwenden Primärfarben – Cyan bis Rot und Grün bis Magenta. Der dritte verwendet Weiß und Blau. Und der vierte verwendet „weiche“ Farben, in diesem Fall Pfirsich und Blaugrün. Die häufigste Sache in jeder Gruppe ist die Farbe des Anfangs und des Endes. In jeder Gruppe beginnen alle Verlaufsreihen in derselben Farbe und enden in derselben Farbe. Was ist anders? Der Unterschied besteht darin, wie wir diese beiden Farben interpolieren, wenn wir uns zwischen diesen beiden Punkten „bewegen“.

Beginnen wir mit der Standarddarstellung der von Compose bereitgestellten linearen Farbverläufe:

Es benutzt Brush.horizontalGradient Compose-API:


Brush.horizontalGradient(
    0.0f to colors.start,
    1.0f to colors.end,
    startX = 0.0f,
    endX = width
)

Dies ist am einfachsten zu verwenden, kann aber auch zu etwas Unvernünftigem führen. Die Mitte zwischen Cyan und Rot ist zu matschig. Die Mitte zwischen Grün und Magenta ist schlammig und hat eine leicht bläuliche Farbe. Und der größte Teil des Übergangs von Weiß zu Blau hat deutliche Andeutungen von Lila. Warum ist das passiert? Es gibt viele Artikel online, die sich mit der tiefen Physik des Lichts und der menschlichen Farbwahrnehmung befassen, und anstatt denselben Inhalt zu ändern, würde ich es tun Du zeigst auf einen von ihnen.

Was kocht es? Bunte Räume. Der Farbraum ist eine Möglichkeit, Farben Zahlen zuzuordnen. Der Standardfarbraum, auch bekannt als derjenige, der zum Erstellen der obigen Farbverläufe verwendet wird, heißt sRGB oder Standard-RGB. Und es ist nicht am besten für die Farbinterpolation geeignet, insbesondere wenn unsere Start- und Endfarben nicht gleich sind – wie Cyan und Rot mit sehr unterschiedlichen Farben oder Weiß und Blau mit sehr unterschiedlicher Helligkeit.

Wenn wir möchten, dass unser Verlaufsübergang näher an der physikalischen Lichtmischung in der realen Welt oder näher an der Wahrnehmung des menschlichen Auges als Farbübergang liegt, müssen wir andere Farbräume untersuchen. Zum jetzigen Zeitpunkt, auch wenn Compose unterstützt eine Vielzahl In Farbräumen werden keine APIs bereitgestellt, um zu konfigurieren, welcher Farbraum für einen bestimmten verwendet werden soll Brush-erstellter Farbverlauf.

Das sollte uns jedoch nicht davon abhalten, auf eine niedrigere Ebene abzusteigen und weiter zu erkunden Skia-Shader in Compose Desktop, diesmal zur besseren Kontrolle unserer Gradienten. Beginnen wir mit dem linearen sRGB-Farbraum, der uns der physikalischen Bewegung des Lichts näher bringt:


val sksl = """
    // https://bottosson.github.io/posts/colorwrong/#what-can-we-do%3F
    vec3 linearSrgbToSrgb(vec3 x) {
        vec3 xlo = 12.92*x;
        vec3 xhi = 1.055 * pow(x, vec3(1.0/2.4)) - 0.055;
        return mix(xlo, xhi, step(vec3(0.0031308), x));
    
    }
    
    vec3 srgbToLinearSrgb(vec3 x) {
        vec3 xlo = x / 12.92;
        vec3 xhi = pow((x + 0.055)/(1.055), vec3(2.4));
        return mix(xlo, xhi, step(vec3(0.04045), x));
    }
    
    uniform vec4 start;
    uniform vec4 end;
    uniform float width;

    half4 main(vec2 fragcoord) {
       // Implicit assumption in here that colors are full opacity
       float fraction = fragcoord.x / width;
       // Convert start and end colors to linear SRGB
       vec3 linearStart = srgbToLinearSrgb(start.xyz);
       vec3 linearEnd = srgbToLinearSrgb(end.xyz);
       // Interpolate in linear SRGB space
       vec3 linearInterpolated = mix(linearStart, linearEnd, fraction);
       // And convert back to SRGB
       return vec4(linearSrgbToSrgb(linearInterpolated), 1.0);
    }
"""

val dataBuffer = ByteBuffer.allocate(36).order(ByteOrder.LITTLE_ENDIAN)
// RGBA colorLight
dataBuffer.putFloat(0, colors.start.red)
dataBuffer.putFloat(4, colors.start.green)
dataBuffer.putFloat(8, colors.start.blue)
dataBuffer.putFloat(12, colors.start.alpha)
// RGBA colorDark
dataBuffer.putFloat(16, colors.end.red)
dataBuffer.putFloat(20, colors.end.green)
dataBuffer.putFloat(24, colors.end.blue)
dataBuffer.putFloat(28, colors.end.alpha)
// Width
dataBuffer.putFloat(32, width)

val effect = RuntimeEffect.makeForShader(sksl)
val shader = effect.makeShader(
    uniforms = Data.makeFromBytes(dataBuffer.array()),
    children = null,
    localMatrix = null,
    isOpaque = false
)

ShaderBrush(shader)

Was machen wir hier? Dies ist ein dreistufiger Prozess:

  • Konvertieren Sie Start- und Endfarben in den linearen sRGB-Farbraum. Denken Sie daran, dass der Farbraum eine Möglichkeit ist, die Farben von Zahlen abzubilden. Was wir hier tun, ist, unsere Farben einem anderen Satz von Zahlen zuzuordnen.
  • Führen Sie eine lineare Interpolation zwischen konvertierten Farben basierend auf X-Koordinaten durch.
  • Konvertieren Sie die interpolierte Farbe zurück in den sRGB-Farbraum.

Schauen wir uns noch einmal das Endergebnis an

Hier sehen wir den größten Teil des Schlamms, der zwischen Cyan und Rot sowie Grün und Magenta verloren geht – auch wenn bei dieser Verschiebung etwas Enthusiasmus verloren geht, insbesondere bei Grün-Magenta. Und wir haben immer noch eine deutliche violette Farbe mit einem weiß-blauen Farbverlauf.

Und jetzt ist es an der Zeit, einen Blick darauf zu werfen kahlköpfig, ein neues Projekt, das darauf abzielt, einen wahrnehmbaren einheitlichen Farbraum bereitzustellen. Hier sind unsere Farbverläufe unter Oklab:

<
val sksl = """
    // https://bottosson.github.io/posts/colorwrong/#what-can-we-do%3F
    vec3 linearSrgbToSrgb(vec3 x) {
        vec3 xlo = 12.92*x;
        vec3 xhi = 1.055 * pow(x, vec3(1.0/2.4)) - 0.055;
        return mix(xlo, xhi, step(vec3(0.0031308), x));
    
    }
    
    vec3 srgbToLinearSrgb(vec3 x) {
        vec3 xlo = x / 12.92;
        vec3 xhi = pow((x + 0.055)/(1.055), vec3(2.4));
        return mix(xlo, xhi, step(vec3(0.04045), x));
    }
    
    // https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab
    const mat3 fromOkStep1 = mat3(
       1.0, 1.0, 1.0,
       0.3963377774, -0.1055613458, -0.0894841775,
       0.2158037573, -0.0638541728, -1.2914855480);
                       
    const mat3 fromOkStep2 = mat3(
       4.0767416621, -1.2684380046, -0.0041960863,
       -3.3077115913, 2.6097574011, -0.7034186147,
       0.2309699292, -0.3413193965,  1.7076147010);
    
    const mat3 toOkStep1 = mat3(
       0.4122214708, 0.2119034982, 0.0883024619,
       0.5363325363, 0.6806995451, 0.2817188376,
       0.0514459929, 0.1073969566, 0.6299787005);
                       
    const mat3 toOkStep2 = mat3(
       0.2104542553, 1.9779984951, 0.0259040371,
       0.7936177850, -2.4285922050, 0.7827717662,
       -0.0040720468, 0.4505937099, -0.8086757660);

    vec3 linearSrgbToOklab(vec3 x) {
        vec3 lms = toOkStep1 * x;
        return toOkStep2 * (sign(lms)*pow(abs(lms), vec3(1.0/3.0)));
    }
    
    vec3 oklabToLinearSrgb(vec3 x) {
        vec3 lms = fromOkStep1 * x;
        return fromOkStep2 * (lms * lms * lms);
    }
    
    uniform vec4 start;
    uniform vec4 end;
    uniform float width;

    half4 main(vec2 fragcoord) {
       // Implicit assumption in here that colors are full opacity
       float fraction = fragcoord.x / width;
       // Convert start and end colors to Oklab
       vec3 oklabStart = linearSrgbToOklab(srgbToLinearSrgb(start.xyz));
       vec3 oklabEnd = linearSrgbToOklab(srgbToLinearSrgb(end.xyz));
       // Interpolate in Oklab space
       vec3 oklabInterpolated = mix(oklabStart, oklabEnd, fraction);
       // And convert back to SRGB
       return vec4(linearSrgbToSrgb(oklabToLinearSrgb(oklabInterpolated)), 1.0);
    }
"""

val dataBuffer = ByteBuffer.allocate(36).order(ByteOrder.LITTLE_ENDIAN)
// RGBA colorLight
dataBuffer.putFloat(0, colors.start.red)
dataBuffer.putFloat(4, colors.start.green)
dataBuffer.putFloat(8, colors.start.blue)
dataBuffer.putFloat(12, colors.start.alpha)
// RGBA colorDark
dataBuffer.putFloat(16, colors.end.red)
dataBuffer.putFloat(20, colors.end.green)
dataBuffer.putFloat(24, colors.end.blue)
dataBuffer.putFloat(28, colors.end.alpha)
// Width
dataBuffer.putFloat(32, width)

val effect = RuntimeEffect.makeForShader(sksl)
val shader = effect.makeShader(
    uniforms = Data.makeFromBytes(dataBuffer.array()),
    children = null,
    localMatrix = null,
    isOpaque = false
)

ShaderBrush(shader)

Dies ist der gleiche dreistufige Prozess wie bei Linear sRGB:

  • Konvertieren Sie Start- und Endfarben in den Oklab-Farbraum. Beachten Sie, dass es einfacher ist, einen von ihnen als Vermittler zuzuweisen, wenn Sie mit einem N-Farbraum arbeiten und Farben zwischen zwei beliebigen Farbräumen konvertieren müssen. In unserem Fall haben wir von sRGB zu Linear sRGB und dann von Linear sRGB zu Oklab gewechselt.
  • Führen Sie eine lineare Interpolation zwischen konvertierten Farben basierend auf X-Koordinaten durch.
  • Konvertieren Sie die interpolierte Farbe zurück in den sRGB-Farbraum (über Linear sRGB).

Schauen wir uns noch einmal das Endergebnis an

Der größte Teil des Schlamms ist zwischen Cyan und Rot verschwunden, und Grün und Magenta sind immer noch verschwunden, aber jetzt haben wir auch etwas mehr Energie bei diesem Übergang, insbesondere bei Cyan-Rot. Und unser Weiß-Blau-Verlauf hat keine Präsenz in dieser violetten Farbe, die wir in sRGB- und linearen sRGB-Verläufen sehen.

Da wir jetzt steuern, wie jedes Pixel seine Farbe erfasst, können wir noch tiefer gehen. Bisher hat der Interpolationsschritt selbst eine einfache lineare Berechnung durchgeführt. Ein Punkt zwischen Start und Ende erhält 50 % jeder Komponente (die in sRGB rot/grün/blau oder in anderen Raumfarben etwas “abstrakter” sein kann). Was wäre, wenn wir eine ungleichmäßige Interpolation unseres Zielfarbraums wünschen, etwas, das beispielsweise den Startpunkt bevorzugt, sodass sich seine Farbe weiter über den endgültigen Farbverlauf ausbreitet?

Unter Beibehaltung des Farbraums in Oklab wechseln wir von einer einheitlichen Interpolation zwischen Start- und Endfarben zu einer benutzerdefinierten Bezier-Kurve:


val sksl = """
    // https://bottosson.github.io/posts/colorwrong/#what-can-we-do%3F
    vec3 linearSrgbToSrgb(vec3 x) {
        vec3 xlo = 12.92*x;
        vec3 xhi = 1.055 * pow(x, vec3(1.0/2.4)) - 0.055;
        return mix(xlo, xhi, step(vec3(0.0031308), x));
    
    }
    
    vec3 srgbToLinearSrgb(vec3 x) {
        vec3 xlo = x / 12.92;
        vec3 xhi = pow((x + 0.055)/(1.055), vec3(2.4));
        return mix(xlo, xhi, step(vec3(0.04045), x));
    }
    
    // https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab
    const mat3 fromOkStep1 = mat3(
       1.0, 1.0, 1.0,
       0.3963377774, -0.1055613458, -0.0894841775,
       0.2158037573, -0.0638541728, -1.2914855480);
                       
    const mat3 fromOkStep2 = mat3(
       4.0767416621, -1.2684380046, -0.0041960863,
       -3.3077115913, 2.6097574011, -0.7034186147,
       0.2309699292, -0.3413193965,  1.7076147010);
    
    const mat3 toOkStep1 = mat3(
       0.4122214708, 0.2119034982, 0.0883024619,
       0.5363325363, 0.6806995451, 0.2817188376,
       0.0514459929, 0.1073969566, 0.6299787005);
                       
    const mat3 toOkStep2 = mat3(
       0.2104542553, 1.9779984951, 0.0259040371,
       0.7936177850, -2.4285922050, 0.7827717662,
       -0.0040720468, 0.4505937099, -0.8086757660);

    vec3 linearSrgbToOklab(vec3 x) {
        vec3 lms = toOkStep1 * x;
        return toOkStep2 * (sign(lms)*pow(abs(lms), vec3(1.0/3.0)));
    }
    
    vec3 oklabToLinearSrgb(vec3 x) {
        vec3 lms = fromOkStep1 * x;
        return fromOkStep2 * (lms * lms * lms);
    }
    
    // https://en.wikipedia.org/wiki/B%C3%A9zier_curve
    vec2 spline(vec2 start, vec2 control1, vec2 control2, vec2 end, float t) {
        float invT = 1.0 - t;
        return start * invT * invT * invT + control1 * 3.0 * t * invT * invT + control2 * 3.0 * t * t * invT + end * t * t * t;
    }

    uniform vec4 start;
    uniform vec4 end;
    uniform float width;

    // Bezier curve points. Note the the first control point is intentionally
    // outside the 0.0-1.0 x range to further "favor" the curve towards the start
    vec2 bstart = vec2(0.0, 0.0);
    vec2 bcontrol1 = vec2(1.3, 0.0);
    vec2 bcontrol2 = vec2(0.9, 0.1);
    vec2 bend = vec2(1.0, 1.0);

    half4 main(vec2 fragcoord) {
       // Implicit assumption in here that colors are full opacity
       float fraction = spline(bstart, bcontrol1, bcontrol2, bend, fragcoord.x / width).y;
       // Convert start and end colors to Oklab
       vec3 oklabStart = linearSrgbToOklab(srgbToLinearSrgb(start.xyz));
       vec3 oklabEnd = linearSrgbToOklab(srgbToLinearSrgb(end.xyz));
       // Interpolate in Oklab space
       vec3 oklabInterpolated = mix(oklabStart, oklabEnd, fraction);
       // And convert back to SRGB
       return vec4(linearSrgbToSrgb(oklabToLinearSrgb(oklabInterpolated)), 1.0);
    }
"""

val dataBuffer = ByteBuffer.allocate(36).order(ByteOrder.LITTLE_ENDIAN)
// RGBA colorLight
dataBuffer.putFloat(0, colors.start.red)
dataBuffer.putFloat(4, colors.start.green)
dataBuffer.putFloat(8, colors.start.blue)
dataBuffer.putFloat(12, colors.start.alpha)
// RGBA colorDark
dataBuffer.putFloat(16, colors.end.red)
dataBuffer.putFloat(20, colors.end.green)
dataBuffer.putFloat(24, colors.end.blue)
dataBuffer.putFloat(28, colors.end.alpha)
// Width
dataBuffer.putFloat(32, width)

val effect = RuntimeEffect.makeForShader(sksl)
val shader = effect.makeShader(
    uniforms = Data.makeFromBytes(dataBuffer.array()),
    children = null,
    localMatrix = null,
    isOpaque = false
)

ShaderBrush(shader)

Die einzigen Unterschiede sind hier die Zeilen:


    // Bezier curve points. Note the the first control point is intentionally
    // outside the 0.0-1.0 x range to further "favor" the curve towards the start
    vec2 bstart = vec2(0.0, 0.0);
    vec2 bcontrol1 = vec2(1.3, 0.0);
    vec2 bcontrol2 = vec2(0.9, 0.1);
    vec2 bend = vec2(1.0, 1.0);

    half4 main(vec2 fragcoord) {
       float fraction = spline(bstart, bcontrol1, bcontrol2, bend, fragcoord.x / width).y;
       ...
    }

Wir verwenden immer noch die X-Koordinate, aber jetzt speisen wir diese in eine kubische Bezier-Kurve ein, die den Startpunkt bevorzugt (siehe Kurvendiagramm hier). Das Endergebnis ist eine Reihe von Farbverläufen mit mehreren „Präsenzen“ der Ausgangsfarbe:

Zusammenfassend sind hier alle Farbverläufe gruppiert nach Farbraum und Interpolationsfunktion:

Einige Notizen:

  • Die Standard-Farbräume sRGB und Linear sRGB sind besonders ungünstig für Farbverläufe mit vielen Farbton- und/oder Helligkeitsvariationen. Wenn Ihre Farbverläufe wahrnehmungsähnliche Farben verwenden, wie z. B. kleine Variationen derselben Farbpalette, brauchen Sie nicht den Aufwand für das Ablegen von Skia-Shadern.
  • Manchmal kann der Unterschied zwischen Linear sRGB und Oklab subjektiv sein, wie der pfirsich-blaugrüne Farbverlauf oben.
  • Die obige Implementierung funktioniert nur für horizontale Gradienten mit zwei Stopps. Ein generischer linearer Multistop-Gradient, der eine beliebige Gradientenrichtung unterstützt, würde eine generischere Implementierung erfordern.
  • Bei radialen, geschwungenen und konischen Farbverläufen bleibt die Kernlogik des Farbwechsels zwischen farbigen Räumen gleich. Der einzige Unterschied ist wie [X,Y] Die Koordinaten eines Pixels werden verwendet, um den Blendfaktor zu berechnen.

Und hier ist es, es ist Zeit, sich von 2021 zu verabschieden. Freuen Sie sich auf weitere Erkundungen von Skia-Shadern bei Compose Desktop im Jahr 2022!

Leave a Reply

Your email address will not be published. Required fields are marked *