Interpolated fill for SVG triangle

Viewed 593

My goal is to create an arbitrary triangle in SVG with one vertex each of red, yellow, and green, and have the fill color be interpolated based on the colors of the vertices.

Very similar to the early RGB triangle tutorials that DirectX, OpenGL, etc. provide:

enter image description here

Mine works well enough with acute triangles:

decent-looking triangle

But not so much with obtuse triangles:

enter image description here

I created the following SVG, with VueJS for data binding:

            <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="800" height="600">
                <defs>
                    <radialGradient id="red" gradientUnits="userSpaceOnUse" :cx="points[0].x" :cy="points[0].y"
                        :r="radius(points[0], points[1], points[2])">
                        <stop offset="0%" stop-color="#ff0000ff" />
                        <stop offset="100%" stop-color="#7fff0000" />
                    </radialGradient>

                    <radialGradient id="green" gradientUnits="userSpaceOnUse" :cx="points[1].x" :cy="points[1].y"
                        :r="radius(points[1], points[0], points[2])">
                        <stop offset="0%" stop-color="#00ff00ff" />
                        <stop offset="100%" stop-color="#ff7f0000" />
                    </radialGradient>

                    <radialGradient id="yellow" gradientUnits="userSpaceOnUse" :cx="points[2].x" :cy="points[2].y"
                        :r="radius(points[2], points[0], points[1])">
                        <stop offset="0%" stop-color="#ffff00ff" />
                        <stop offset="100%" stop-color="#7f7f0000" />
                    </radialGradient>
                </defs>

                <path :d="svgTriangle" fill="url(#red)" />
                <path :d="svgTriangle" fill="url(#yellow)" />
                <path :d="svgTriangle" fill="url(#green)" />
            </svg>

Points are generated randomly within the SVG's 800x600 space:

for (let p = 0; p < 3; p++) {
    this.points[p] = {
        id: `p${p}`,
        x: Math.floor(Math.random() * 800),
        y: Math.floor(Math.random() * 600)
    };
}

And the radius calculation is based on the length of the line to the midpoint of the other 2 vertices:

radius: function (me, other1, other2) {
    const mid = {
        x: (other1.x + other2.x) / 2,
        y: (other1.y + other2.y) / 2
    };
    return Math.sqrt(Math.abs(me.x - mid.x) ** 2 + Math.abs(me.y - mid.y) ** 2);
}

I believe the problem is that the yellow and green (which are rendered after the red) have a much longer radius and are basically hiding the red. Linear gradients aren't any better. Since the gradient approach may be flawed, is there a better approach using SVG?

I know this is possible using Canvas/WebGL (example), but can this same thing even be done using SVG, perhaps with blend filters? Or if I want this type of interpolation, will I need to use Canvas/WebGL

Edit: I couldn't quite get the color blending as nice as I wanted in all the edge cases using SVG, so in the end I ended up moving to canvas and WebGL.

1 Answers

EDIT: There seems to be an actually correct solution besides the approximation I explain in this answer – look here.

There are two problems:

  1. The green gradient has too long radius, as you mentioned.
  2. The green gradient is rendered above the other two (and the yellow one above the red one), thus, the colors would be unbalanced even in an equilateral triangle.

I will try to help with the former. Good news, you can definitely use gradients! The gradientTransform attribute allows the gradient to be elliptical instead of circular, which gives you much more options. You can use

        <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="800" height="600">
            <defs>
                <radialGradient id="red"  gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1"
                    :gradientTransform="transformation(points[0], points[1], points[2])">
                    <stop offset="0%" stop-color="#ff0000ff" />
                    <stop offset="100%" stop-color="#7fff0000" />
                </radialGradient>

                <radialGradient id="green" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1"
                    :gradientTransform="transformation(points[1], points[2], points[0])">
                    <stop offset="0%" stop-color="#00ff00ff" />
                    <stop offset="100%" stop-color="#ff7f0000" />
                </radialGradient>

                <radialGradient id="yellow" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1"
                    :gradientTransform="transformation(points[2], points[0], points[1])">
                    <stop offset="0%" stop-color="#ffff00ff" />
                    <stop offset="100%" stop-color="#7f7f0000" />
                </radialGradient>
            </defs>

            <path :d="svgTriangle" fill="url(#red)" />
            <path :d="svgTriangle" fill="url(#yellow)" />
            <path :d="svgTriangle" fill="url(#green)" />
        </svg>

with this function instead of radius:

transformation: function (me, other1, other2) {
    const side1vector = { 
        x: other1.x - me.x,
        y: other1.y - me.y
    };
    const side2vector = { 
        x: other2.x - me.x,
        y: other2.y - me.y
    };
    const matrix = [
        side1vector.x * Math.sqrt(3)/2,
        side1vector.y * Math.sqrt(3)/2,
        side2vector.x - 0.5*side1vector.x,
        side2vector.y - 0.5*side1vector.y,
        me.x,
        me.y
    ];
    return "matrix(" + matrix.join(" ") + ")";
}

This should do what you want.

Explanation: Each radial gradient is originally centered in point [0;0] and has radius 1. Then a affine transformation is applied that sends the center [0;0] to the respective vertex and sends points [2*√3/3;0] and [√3/3;1] to the other vertices (you can check it yourself). Since these points are outside the original gradient (further away from [0;0] than 1), the other vertices will also be outside the transformed gradient, so the gradient will never hide them.

Furthermore, this code produces the same result as your code in case of an equilateral triangle. And if you fill any other triangle using this code, it will be the same as if you filled an equilateral triangle, and then squished it with some affine transformation to the shape of the other one (this is because a combination of affine transformations is still a affine transformation and a affine transformation of one specific object is uniquely defined by positions of three transformed points, in this case the vertices). The important consequence is that each color covers the same "percentage" of the overall area in every triangle, so no need to fear that red can be missing altogether.

Related