Combining Signed Distance Fields in 2D

Original author: ronja
  • Transfer
In the previous tutorial, we learned how to create and move simple shapes using signed distance functions. In this article, we will learn how to combine several shapes to create more complex distance fields. Most of the techniques described here, I learned from the library of distance functions with a sign on glsl, which can be found here . There are also several ways of combining figures that I do not consider here.


Training


To visualize distance fields with a sign (signed distance fields, SDF), we will use one simple configuration and then apply operators to it. To display distance fields, it will use visualization of distance lines from the first tutorial. For the sake of simplicity, we will set all the parameters except for the rendering parameters in the code, but you can replace any value with a property to make it customizable.

The main shader, with which we start, looks like this:

Shader "Tutorial/035_2D_SDF_Combinations/Champfer Union"{
    Properties{
        _InsideColor("Inside Color", Color) = (.5, 0, 0, 1)
        _OutsideColor("Outside Color", Color) = (0, .5, 0, 1)
        _LineDistance("Mayor Line Distance", Range(0, 2)) = 1
        _LineThickness("Mayor Line Thickness", Range(0, 0.1)) = 0.05
        [IntRange]_SubLines("Lines between major lines", Range(1, 10)) = 4
        _SubLineThickness("Thickness of inbetween lines", Range(0, 0.05)) = 0.01
    }
    SubShader{
        //the material is completely non-transparent and is rendered at the same time as the other opaque geometry
        Tags{ "RenderType"="Opaque" "Queue"="Geometry"}
        Pass{
            CGPROGRAM
            #include "UnityCG.cginc"
            #include "2D_SDF.cginc"
            #pragma vertex vert
            #pragma fragment frag
            struct appdata{
                float4 vertex : POSITION;
            };
            struct v2f{
                float4 position : SV_POSITION;
                float4 worldPos : TEXCOORD0;
            };
            v2f vert(appdata v){
                v2f o;
                //calculate the position in clip space to render the object
                o.position = UnityObjectToClipPos(v.vertex);
                //calculate world position of vertex
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                return o;
            }
            float scene(float2 position) {
                const float PI = 3.14159;
                float2 squarePosition = position;
                squarePosition = translate(squarePosition, float2(1, 0));
                squarePosition = rotate(squarePosition, .125);
                float squareShape = rectangle(squarePosition, float2(2, 2));
                float2 circlePosition = position;
                circlePosition = translate(circlePosition, float2(-1.5, 0));
                float circleShape = circle(circlePosition, 2.5);
                float combination = combination_function(circleShape, squareShape);
                return combination;
            }
            float4 _InsideColor;
            float4 _OutsideColor;
            float _LineDistance;
            float _LineThickness;
            float _SubLines;
            float _SubLineThickness;
            fixed4 frag(v2f i) : SV_TARGET{
                float dist = scene(i.worldPos.xz);
                fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist));
                float distanceChange = fwidth(dist) * 0.5;
                float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance;
                float majorLines = smoothstep(_LineThickness - distanceChange, _LineThickness + distanceChange, majorLineDistance);
                float distanceBetweenSubLines = _LineDistance / _SubLines;
                float subLineDistance = abs(frac(dist / distanceBetweenSubLines + 0.5) - 0.5) * distanceBetweenSubLines;
                float subLines = smoothstep(_SubLineThickness - distanceChange, _SubLineThickness + distanceChange, subLineDistance);
                return col * majorLines * subLines;
            }
            ENDCG
        }
    }
    FallBack "Standard" //fallback adds a shadow pass so we get shadows on other objects
}

And the function 2D_SDF.cginc in one folder with the shader, which we will expand, initially looks like this:

#ifndef SDF_2D
#define SDF_2D
//transforms
float2 rotate(float2 samplePosition, float rotation){
    const float PI = 3.14159;
    float angle = rotation * PI * 2 * -1;
    float sine, cosine;
    sincos(angle, sine, cosine);
    return float2(cosine * samplePosition.x + sine * samplePosition.y, cosine * samplePosition.y - sine * samplePosition.x);
}
float2 translate(float2 samplePosition, float2 offset){
    //move samplepoint in the opposite direction that we want to move shapes in
    return samplePosition - offset;
}
float2 scale(float2 samplePosition, float scale){
    return samplePosition / scale;
}
//shapes
float circle(float2 samplePosition, float radius){
    //get distance from center and grow it according to radius
    return length(samplePosition) - radius;
}
float rectangle(float2 samplePosition, float2 halfSize){
    float2 componentWiseEdgeDistance = abs(samplePosition) - halfSize;
    float outsideDistance = length(max(componentWiseEdgeDistance, 0));
    float insideDistance = min(max(componentWiseEdgeDistance.x, componentWiseEdgeDistance.y), 0);
    return outsideDistance + insideDistance;
}
#endif

Simple combinations


We'll start with a few simple ways of combining two shapes to create one big shape, mates, intersections, and subtractions, as well as a way to transform one shape into another.

Pairing


The easiest operator is mate. With it, we can put two figures together and get the distance with the sign of the connected figure. When we have a distance with the sign of two figures, we can combine them by taking the smaller of the two with the function min.

Because of the choice of the smaller of two values, the final shape will be below 0 (visible) where one of the two incoming shapes has a distance to the edge less than 0; the same applies to all other distance values, showing a combination of two figures.

Here I will name the function to create the “merge” mate, in part because we are merging, partly because the union keyword in hlsl is reserved, so it cannot be used as a function name.

//in 2D_SDF.cginc include file
float merge(float shape1, float shape2){
    return min(shape1, shape2);
}

//in scene function in shader
float combination = merge(circleShape, squareShape);




Intersection


Another common way to join shapes is to use areas in which two shapes overlap each other. To do this, we take the maximum value of the distances of two figures that we want to combine. When using the largest of two values, we get a value greater than 0 (outside the figure), when any of the distances to two figures is outside the figure, and other distances are also aligned in the same way.

//in 2D_SDF.cginc include file
float intersect(float shape1, float shape2){
    return max(shape1, shape2);
}

//in scene function in shader
float combination = intersect(circleShape, squareShape);


Subtraction


However, often we don’t want to process both figures in the same way, and we need to subtract another from one figure. This is fairly easy to do by running the intersection between the shape that we want to change, and everyone except the shape that we want to subtract. We get inverse values ​​for the inner and outer parts of the figure, inverting the distance with the sign. What was 1 unit outside the figure is now 1 unit inside.

//in 2D_SDF.cginc include file
float subtract(float base, float subtraction){
    return intersect(base, -subtraction);
}

//in scene function in shader
float combination = subtract(squareShape, circleShape);


Interpolation


The unobvious way of combining two figures is an interpolation between them. It is also, to a certain extent, possible for polygonal meshes with blendshapes, but much more limited than what we can do with signed distance fields. Simple interpolation between the distances of two figures, we achieve a smooth flow of one into another. For interpolation, you can simply use the method lerp.

//in 2D_SDF.cginc include file
float interpolate(float shape1, float shape2, float amount){
    return lerp(shape1, shape2, amount);
}

//in scene function in shader
float pulse = sin(_Time.y) * 0.5 + 0.5;
float combination = interpolate(circleShape, pulse);


Other compounds


Having received simple connections, we already have everything necessary for simple combination of figures, but the surprising property of distance marked fields is that we can not limit ourselves to this, there are many different ways of combining figures and performing interesting actions in the places of their connection. Here I will again explain only some of these techniques, but you can find many others in the http://mercury.sexy/hg_sdf library (write to me if you know other useful SDF libraries).

Rounding


We can interpret the surface of two combined figures as x-axis and y position in the coordinate system, and then calculate the distance to the origin point of this position. If we do this, we will get a very strange figure, but if we restrict the axis to values ​​below 0, we will get something resembling a smooth conjugation of the internal distances of two figures.

float round_merge(float shape1, float shape2, float radius){
    float2 intersectionSpace = float2(shape1, shape2);
    intersectionSpace = min(intersectionSpace, 0);
    return length(intersectionSpace);
}


This is beautiful, but we cannot use this to change the line where the distance is 0, so such an operation is not more valuable than ordinary mating. But before connecting the two figures, we can slightly increase them. In the same way as we created a circle, to increase the shape, we subtract from its distance in order to push further out the line in which the distance with the sign is 0.

float radius = max(sin(_Time.y * 5) * 0.5 + 0.4, 0);
float combination = round_intersect(squareShape, circleShape, radius);

float round_merge(float shape1, float shape2, float radius){
    float2 intersectionSpace = float2(shape1 - radius, shape2 - radius);
    intersectionSpace = min(intersectionSpace, 0);
    return length(intersectionSpace);
}


It simply increases the shape and ensures smooth transitions inside, but we don’t want to enlarge the shapes, we only need a smooth transition. The solution is to subtract the radius again after calculating the length. Most parts will look the same as before, except for the transition between the figures, which is beautifully smoothed out according to the radius. The outer part of the figure, we still ignore.

float round_merge(float shape1, float shape2, float radius){
    float2 intersectionSpace = float2(shape1 - radius, shape2 - radius);
    intersectionSpace = min(intersectionSpace, 0);
    return length(intersectionSpace) - radius;
}


The last stage is the correction of the external part of the figure. In addition, while the insides of the figure are green, we use this color for the outer part. The first step is to swap the external and internal parts, simply reversing their distance with a sign. Then we replace the part where the radius is subtracted. First we change it from subtraction to addition. This is necessary because before combining with the radius we drew the distance of the vector, therefore, in accordance with this, the mathematical operation used must be reversed. Then we will replace the radius with a normal mate, which will give us the correct values ​​outside the shape, but not close to the edges and inside the shape. To avoid this, we take the maximum between the value and the radius, thus obtaining a positive value of the correct values ​​outside the shape, as well as the desired addition of the radius inside the shape.

float round_merge(float shape1, float shape2, float radius){
    float2 intersectionSpace = float2(shape1 - radius, shape2 - radius);
    intersectionSpace = min(intersectionSpace, 0);
    float insideDistance = -length(intersectionSpace);
    float simpleUnion = merge(shape1, shape2);
    float outsideDistance = max(simpleUnion, radius);
    return  insideDistance + outsideDistance;
}


To create an intersection, we need to do the opposite — reduce the shapes by the radius, ensure that all components of the vector are greater than 0, take the length and do not change its sign. So we will create the outer part of the figure. Then, in order to create the inside, we take the usual intersection and ensure that it is not less than minus radius. Then we, as before, add the internal and external values.

float round_intersect(float shape1, float shape2, float radius){
    float2 intersectionSpace = float2(shape1 + radius, shape2 + radius);
    intersectionSpace = max(intersectionSpace, 0);
    float outsideDistance = length(intersectionSpace);
    float simpleIntersection = intersect(shape1, shape2);
    float insideDistance = min(simpleIntersection, -radius);
    return outsideDistance + insideDistance;
}


And as a last point, subtraction can again be described as the intersection between the base figure and everything except the figure we subtract.

float round_subtract(float base, float subtraction, float radius){
    round_intersect(base, -subtraction, radius);
}


Here, and especially when subtracting, you can see artifacts arising from the assumption that we can use two figures as coordinates, but for most applications, the distance fields are still pretty good.

Bevel


We can also mow the transition to give it a chamfer-like angle. To achieve this effect, we first create a new shape, adding up the two. If we again assume that the point at which two figures meet is orthogonal, then this operation will give us a diagonal line through the meeting point of the two surfaces.


Since we simply added two components, the distance with the sign of this new line has an incorrect scale, but we can fix it by dividing it by the diagonal of the unit square, that is, by the square root of 2. The division by the root of 2 is the same as multiplying by the square root of 0.5, and we can simply write this value into the code so as not to calculate the same root each time.

Now, having received a shape that has the shape of a desired bevel, we will expand it so that the bevel goes beyond the boundaries of the shape. In the same way as before, we subtract the value we need to increase the shape. Then we merge the bevel shape with the output of a normal merge, resulting in a beveled transition.

float champferSize = sin(_Time.y * 5) * 0.3 + 0.3;
float combination = champfer_merge(circleShape, squareShape, champferSize);

float champfer_merge(float shape1, float shape2, float champferSize){
    const float SQRT_05 = 0.70710678118;
    float simpleMerge = merge(shape1, shape2);
    float champfer = (shape1 + shape2) * SQRT_05;
    champfer = champfer - champferSize;
    return merge(simpleMerge, champfer);
}


To obtain a crossed bevel, we, as before, add two figures, but then reduce the figure, adding the amount of the bevel, and perform the intersection with the usual crossed figure.

float champfer_intersect(float shape1, float shape2, float champferSize){
    const float SQRT_05 = 0.70710678118;
    float simpleIntersect = intersect(shape1, shape2);
    float champfer = (shape1 + shape2) * SQRT_05;
    champfer = champfer + champferSize;
    return intersect(simpleIntersect, champfer);
}


And similarly with the previous subtractions, we can also perform the intersection with the inverted second figure here.

float champfer_subtract(float base, float subtraction, float champferSize){
    return champfer_intersect(base, -subtraction, champferSize);
}


Rounded intersection


So far we have only used boolean operators (except for interpolation). But we can combine the shapes in other ways, for example, by creating new rounded shapes in places where the boundaries of the two shapes overlap.

To do this, we again need to interpret the two figures as the x and y axes of the point. Then we simply calculate the distance of this point to the origin point. Where the boundaries of two figures overlap, the distance to both figures will be 0, which gives us a distance of 0 to the starting point of our fictional coordinate system. Then, if we have a distance to the origin point, we can perform the same operations with it as for the circles, and subtract the radius.

float round_border(float shape1, float shape2, float radius){
    float2 position = float2(shape1, shape2);
    float distanceFromBorderIntersection = length(position);
    return distanceFromBorderIntersection - radius;
}


Border notch


The last thing I will explain is a way to create a notch in one shape at the position of the border of another shape.

We begin by calculating the shape of the border of a circle. This can be done by obtaining the absolute value of the distance of the first shape, while the inner and outer parts will be considered the inner part of the shape, but the border still has the value 0. If we increase this shape by subtracting the width of the notch, we get the shape along the border of the previous shape .

float depth = max(sin(_Time.y * 5) * 0.5 + 0.4, 0);
float combination = groove_border(squareShape, circleShape, .3, depth);

float groove_border(float base, float groove, float width, float depth){
    float circleBorder = abs(groove) - width;
    return circleBorder;
}


Now we need the boundary of the circle to go deep only by the value indicated by us. To do this, we subtract from it a smaller version of the base figure. The magnitude of the decrease in the base figure is the depth of the notch.

float groove_border(float base, float groove, float width, float depth){
    float circleBorder = abs(groove) - width;
    float grooveShape = subtract(circleBorder, base + depth);
    return grooveShape;
}


The final step is to subtract the groove from the base shape and return the result.

float groove_border(float base, float groove, float width, float depth){
    float circleBorder = abs(groove) - width;
    float grooveShape = subtract(circleBorder, base + depth);
    return subtract(base, grooveShape);
}


Sources


Library



#ifndef SDF_2D
#define SDF_2D
//transforms
float2 rotate(float2 samplePosition, float rotation){
    const float PI = 3.14159;
    float angle = rotation * PI * 2 * -1;
    float sine, cosine;
    sincos(angle, sine, cosine);
    return float2(cosine * samplePosition.x + sine * samplePosition.y, cosine * samplePosition.y - sine * samplePosition.x);
}
float2 translate(float2 samplePosition, float2 offset){
    //move samplepoint in the opposite direction that we want to move shapes in
    return samplePosition - offset;
}
float2 scale(float2 samplePosition, float scale){
    return samplePosition / scale;
}
//combinations
///basic
float merge(float shape1, float shape2){
    return min(shape1, shape2);
}
float intersect(float shape1, float shape2){
    return max(shape1, shape2);
}
float subtract(float base, float subtraction){
    return intersect(base, -subtraction);
}
float interpolate(float shape1, float shape2, float amount){
    return lerp(shape1, shape2, amount);
}
/// round
float round_merge(float shape1, float shape2, float radius){
    float2 intersectionSpace = float2(shape1 - radius, shape2 - radius);
    intersectionSpace = min(intersectionSpace, 0);
    float insideDistance = -length(intersectionSpace);
    float simpleUnion = merge(shape1, shape2);
    float outsideDistance = max(simpleUnion, radius);
    return  insideDistance + outsideDistance;
}
float round_intersect(float shape1, float shape2, float radius){
    float2 intersectionSpace = float2(shape1 + radius, shape2 + radius);
    intersectionSpace = max(intersectionSpace, 0);
    float outsideDistance = length(intersectionSpace);
    float simpleIntersection = intersect(shape1, shape2);
    float insideDistance = min(simpleIntersection, -radius);
    return outsideDistance + insideDistance;
}
float round_subtract(float base, float subtraction, float radius){
    return round_intersect(base, -subtraction, radius);
}
///champfer
float champfer_merge(float shape1, float shape2, float champferSize){
    const float SQRT_05 = 0.70710678118;
    float simpleMerge = merge(shape1, shape2);
    float champfer = (shape1 + shape2) * SQRT_05;
    champfer = champfer - champferSize;
    return merge(simpleMerge, champfer);
}
float champfer_intersect(float shape1, float shape2, float champferSize){
    const float SQRT_05 = 0.70710678118;
    float simpleIntersect = intersect(shape1, shape2);
    float champfer = (shape1 + shape2) * SQRT_05;
    champfer = champfer + champferSize;
    return intersect(simpleIntersect, champfer);
}
float champfer_subtract(float base, float subtraction, float champferSize){
    return champfer_intersect(base, -subtraction, champferSize);
}
/// round border intersection
float round_border(float shape1, float shape2, float radius){
    float2 position = float2(shape1, shape2);
    float distanceFromBorderIntersection = length(position);
    return distanceFromBorderIntersection - radius;
}
float groove_border(float base, float groove, float width, float depth){
    float circleBorder = abs(groove) - width;
    float grooveShape = subtract(circleBorder, base + depth);
    return subtract(base, grooveShape);
}
//shapes
float circle(float2 samplePosition, float radius){
    //get distance from center and grow it according to radius
    return length(samplePosition) - radius;
}
float rectangle(float2 samplePosition, float2 halfSize){
    float2 componentWiseEdgeDistance = abs(samplePosition) - halfSize;
    float outsideDistance = length(max(componentWiseEdgeDistance, 0));
    float insideDistance = min(max(componentWiseEdgeDistance.x, componentWiseEdgeDistance.y), 0);
    return outsideDistance + insideDistance;
}
#endif

Основа шейдера



Shader "Tutorial/035_2D_SDF_Combinations/Round"{
    Properties{
        _InsideColor("Inside Color", Color) = (.5, 0, 0, 1)
        _OutsideColor("Outside Color", Color) = (0, .5, 0, 1)
        _LineDistance("Mayor Line Distance", Range(0, 2)) = 1
        _LineThickness("Mayor Line Thickness", Range(0, 0.1)) = 0.05
        [IntRange]_SubLines("Lines between major lines", Range(1, 10)) = 4
        _SubLineThickness("Thickness of inbetween lines", Range(0, 0.05)) = 0.01
    }
    SubShader{
        //the material is completely non-transparent and is rendered at the same time as the other opaque geometry
        Tags{ "RenderType"="Opaque" "Queue"="Geometry"}
        Pass{
            CGPROGRAM
            #include "UnityCG.cginc"
            #include "2D_SDF.cginc"
            #pragma vertex vert
            #pragma fragment frag
            struct appdata{
                float4 vertex : POSITION;
            };
            struct v2f{
                float4 position : SV_POSITION;
                float4 worldPos : TEXCOORD0;
            };
            v2f vert(appdata v){
                v2f o;
                //calculate the position in clip space to render the object
                o.position = UnityObjectToClipPos(v.vertex);
                //calculate world position of vertex
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                return o;
            }
            float scene(float2 position) {
                const float PI = 3.14159;
                float2 squarePosition = position;
                squarePosition = translate(squarePosition, float2(1, 0));
                squarePosition = rotate(squarePosition, .125);
                float squareShape = rectangle(squarePosition, float2(2, 2));
                float2 circlePosition = position;
                circlePosition = translate(circlePosition, float2(-1.5, 0));
                float circleShape = circle(circlePosition, 2.5);
                float combination = /* combination calculation here */;
                return combination;
            }
            float4 _InsideColor;
            float4 _OutsideColor;
            float _LineDistance;
            float _LineThickness;
            float _SubLines;
            float _SubLineThickness;
            fixed4 frag(v2f i) : SV_TARGET{
                float dist = scene(i.worldPos.xz);
                fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist));
                float distanceChange = fwidth(dist) * 0.5;
                float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance;
                float majorLines = smoothstep(_LineThickness - distanceChange, _LineThickness + distanceChange, majorLineDistance);
                float distanceBetweenSubLines = _LineDistance / _SubLines;
                float subLineDistance = abs(frac(dist / distanceBetweenSubLines + 0.5) - 0.5) * distanceBetweenSubLines;
                float subLines = smoothstep(_SubLineThickness - distanceChange, _SubLineThickness + distanceChange, subLineDistance);
                return col * majorLines * subLines;
            }
            ENDCG
        }
    }
    FallBack "Standard" //fallback adds a shadow pass so we get shadows on other objects
}

Also popular now: