Search code examples
androidgradientrgbporter-duff

Merge two RGB gradients


I have two LinearGradients which I want to merge:

  • One horizontal going from rgb(0, 0, 0) to rgb(0, 255, 0) (black to green)
  • One vertical going from rgb(0, 0, 0) to rgb(0, 0, 255) (black to blue)

My code looks like this:

Shader horizontal = new LinearGradient(0, 0, width, 0, new float[]{Color.rgb(0, 0, 0), Color.rgb(0, 255, 0)}, null, Shader.TileMode.CLAMP);
Shader vertical = new LinearGradient(0, 0, 0, height, new float[]{Color.rgb(0, 0, 0), Color.rgb(0, 0, 255)}, null, Shader.TileMode.CLAMP);
ComposeShader shader = new ComposeShader(horizontal, vertical, mode);
paint.setShader(shader);

The red value may change but the two others are constant. I want to use the resulting gradient in a color picker. It has to look like this: (you can see it on here too, you have to click on the R letter on the right pane of the color picker)

gradient

I tried several PorterDuff modes, a few came close but none matched what I need. SCREEN is almost perfect but sometimes it's too light. ADD show red values smaller than 128 as if it was 0. MULTIPLY fills the square with one solid color and that's it. I also tried setting the colors of the gradients to alpha 128. This makes ADD too dark, XOR and SCREEN too pale.

How can I make this gradient correctly? What PorterDuff mode should I use?


I draw the cursor the same color as the selected color to test if the gradient is correctly drawn. (Selected color is calculated with coordinates) For all pivot values except value, the cursor hard to see/invisible.

hsv

Looks like the white gradient turns transparent too quickly. To make it I dew two lineargradients then merged them with ComposeShader and SRC_OVER PorterDuff mode. Then I draw a black rectangle with transparency corresponding to the value (brightness) value. I can post code if you need.


Solution

  • EDIT:

    I am going to make some assumptions. Based on the link you referenced, I'll assume you'll want to do something similar where you can change the "pivot" color in real time using a slider control like the vertical slider to the right. Also I'll assume that you want to switch between red/green/blue as the pivot color.

    Here's how to increase your performance:

    • Allocate an int array for the colors once and reuse that array.
    • Allocate a bitmap once and reuse the bitmap.
    • Always make the bitmap 256 x 256 and scale the bitmap to the right size when you draw it. This way every calculation counts; no duplicate pixels.

    With all those things in mind, here's a rewrite of the routine:

        private void changeColor(int w, int h, int[] pixels, char pivotColor, int pivotColorValue, boolean initial) {
    
            if (pivotColorValue < 0 || pivotColorValue > 255) {
                throw new IllegalArgumentException("color value must be between 0 and 255, was " + pivotColorValue);
            }
    
            if (initial) {
    
                // set all the bits of the color
    
                int alpha = 0xFF000000;
    
                for (int y = 0; y < h; y++) {
                    for (int x = 0; x < w; x++) {
                        int r = 0, b = 0, g = 0;
                        switch (pivotColor) {
                            case 'R':
                            case 'r':
                                r = pivotColorValue << 16;
                                g = (256 * x / w) << 8;
                                b = 256 * y / h;
                                break;
                            case 'G':
                            case 'g':
                                r = (256 * x / w) << 16;
                                g = pivotColorValue << 8;
                                b = 256 * y / h;
                                break;
                            case 'B':
                            case 'b':
                                r = (256 * x / w) << 16;
                                g = (256 * y / h) << 8;
                                b = pivotColorValue;
                                break;
                        }
                        int index = y * w + x;
                        pixels[index] = alpha | r | g | b;
                    }
                }
            } else {
    
                // only set the bits of the color that is changing
    
                int colorBits = 0;
                switch (pivotColor) {
                    case 'R':
                    case 'r':
                        colorBits = pivotColorValue << 16;
                        break;
                    case 'G':
                    case 'g':
                        colorBits = pivotColorValue << 8;
                        break;
                    case 'B':
                    case 'b':
                        colorBits = pivotColorValue;
                        break;
                }
    
                for (int i = 0; i < pixels.length; i++) {
                    switch (pivotColor) {
                        case 'R':
                        case 'r':
                            pixels[i] = (pixels[i] & 0xFF00FFFF) | colorBits;
                            break;
                        case 'G':
                        case 'g':
                            pixels[i] = (pixels[i] & 0xFFFF00FF) | colorBits;
                            break;
                        case 'B':
                        case 'b':
                            pixels[i] = (pixels[i] & 0xFFFFFF00) | colorBits;
                            break;
                    }
                }
            }
    

    Here's how I tested it:

    public class MainActivity extends AppCompatActivity {
    
        private static final String TAG = "MainActivity";
    
        private ImageView mImageView;
    
        private Bitmap mBitmap;
    
        private int[] mPixels;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
            setTitle("Demo");
    
            mPixels = new int[256 * 256];
            mBitmap = Bitmap.createBitmap(256, 256, Bitmap.Config.ARGB_8888);
            mImageView = (ImageView) findViewById(R.id.imageview);
    
            long start = SystemClock.elapsedRealtime();
    
            changeColor(256, 256, mPixels, 'r', 0, true);
            mBitmap.setPixels(mPixels, 0, 256, 0, 0, 256, 256);
            mImageView.setImageBitmap(mBitmap);
    
            long elapsed = SystemClock.elapsedRealtime() - start;
            Log.d(TAG, "initial elapsed time: " + elapsed + " ms");
    
            SeekBar seekBar = (SeekBar) findViewById(R.id.seekbar);
            seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
                @Override
                public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
    
                    long start = SystemClock.elapsedRealtime();
    
                    changeColor(256, 256, mPixels, 'r', progress, false);
                    mBitmap.setPixels(mPixels, 0, 256, 0, 0, 256, 256);
                    mImageView.setImageBitmap(mBitmap);
    
                    long elapsed = SystemClock.elapsedRealtime() - start;
                    Log.d(TAG, "elapsed time: " + elapsed + " ms");
                }
    
                @Override
                public void onStartTrackingTouch(SeekBar seekBar) { }
    
                @Override
                public void onStopTrackingTouch(SeekBar seekBar) { }
            });
    
        }
    
        // changeColor method goes here
    }
    

    activity_main.xml:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/activity_main"
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:paddingBottom="@dimen/activity_vertical_margin"
        android:paddingLeft="@dimen/activity_horizontal_margin"
        android:paddingRight="@dimen/activity_horizontal_margin"
        android:paddingTop="@dimen/activity_vertical_margin">
    
        <ImageView
            android:id="@+id/imageview"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:scaleType="fitCenter"/>
    
        <SeekBar
            android:id="@+id/seekbar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="24dp"
            android:max="255"/>
    
    </LinearLayout>
    

    Try that out and see if it performs well enough for you. I thought it was reasonable.


    I think the underlying Skia library has a Porter-Duff mode that would do this, but it's not available in android.graphics.PorterDuff.Mode.

    Okay fine, I guess we'll just have to do it our damn selves:

        private Bitmap makeColorPicker(int w, int h, int r) {
    
            if (r < 0 || r > 255) {
                throw new IllegalArgumentException("red value must be between 0 and 255, was " + r);
            }
    
            // need to manage memory, OutOfMemoryError could happen here
            int[] pixels = new int[w * h];
    
            int baseColor = 0xFF000000 | (r << 16);  // alpha and red value
    
            for (int y = 0; y < h; y++) {
                for (int x = 0; x < w; x++) {
                    int g = (256 * x / w) << 8;
                    int b = 256 * y / h;
                    int index = y * w + x;
                    pixels[index] = baseColor | g | b;
                }
            }
    
            return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888);
        }
    

    Regarding HSV:

    Once you switch to HSV color space, some different options open up for you. Now compositing two images like you were originally considering makes sense. I'm just going to give you the thousand words versions of the images. Please don't make me open up PhotoShop.

    • Pivot on Hue:

      I'm picturing a two-way gradient image that could be rendered at development time. This gradient would have zero alpha at the upper right corner, full black at the bottom edge and full white at the upper left corner. As you move through the hue angles, you would just draw a solid color rectangle underneath this image. The color would be the desired hue at full saturation and brightness, so you would see just this color in the upper right corner.

    • Pivot on Saturation:

      Here I'm picturing two gradient images, both could be rendered at development time. The first would be full saturation, where you see the horizontal rainbow at the top blending into black at the bottom. The second would be zero saturation, with white at the top and black at the bottom. You draw the rainbow gradient on the bottom, then draw the white/black gradient on top. Changing the alpha of the top image from zero to full will show the change from full saturation to zero saturation.

    • Pivot on Brightness (Value)

      For this I'm picturing a black rectangle base with another image that is also a horizontal rainbow gradient than tweens vertically to white at the bottom (full brightness). Now you pivot on brightness by changing the rainbow image from full alpha to zero alpha, revealing the black rectangle underneath.

    I'd have to do some math to make sure these alpha composites represent the actual color space, but I think I'm pretty close.