📜 ⬆️ ⬇️

Spatial manipulation in 2D using Signed Distance Fields

When working with polygonal asses, you can draw only one object at a time (if you do not take into account such techniques as batching and instancing), but if you use distance signed fields (SDF), we are not limited to this. If two positions have the same coordinate, then the signed distance functions will return the same value, and in one calculation we can get several shapes. To understand how to transform the space used to generate signed distance fields, I recommend to figure out how to create shapes using signed distance functions and combine sdf shapes .


Configuration


For this tutorial, I will modify the conjugation between the square and the circle, but you can use it for any other shape. This is similar to the configuration for the previous tutorial .

It is important here that the part being modified is located before using positions for generating figures.

Shader "Tutorial/036_SDF_Space_Manpulation/Type"{ 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) { // manipulate position with cool methods here! float2 squarePosition = position; squarePosition = translate(squarePosition, float2(2, 2)); squarePosition = rotate(squarePosition, .125); float squareShape = rectangle(squarePosition, float2(1, 1)); float2 circlePosition = position; circlePosition = translate(circlePosition, float2(1, 1.5)); float circleShape = circle(circlePosition, 1); float combination = merge(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" } 

And the 2D_SDF.cginc function in the same folder as 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; } //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 


Space repetition


Mirror reflection


One of the simplest operations is the mirroring of the world relative to the axis. To mirror it relative to the y axis, we take the absolute value of the x component of our position. Thus the coordinates on the right and left of the axis will be the same. (-1, 1) turns into (1, 1) , and turns out to be inside a circle, using (1, 1) as the origin and with a radius greater than 0.

Most often, the code using this function will look something like position = mirror(position); , so we can simplify it a little. We simply declare the position argument as inout. Thus, when writing to an argument, it will also change the variable that we pass to the function. The return value can then be of type void, because we still do not use the return value.

 //in 2D_SDF.cginc void mirror(inout float2 position){ position.x = abs(position.x); } 

 //in shader function mirror(position); 


It turned out quite beautiful, but so we get only one axis for mirroring. We can expand the function by rotating the space as we did when turning the figures. First you need to rotate the space, then mirror it, and then turn it back. This way we can perform mirroring with respect to any angle. The same is possible when transferring space and performing reverse transfer after mirroring. (If you perform both operations, then before mirroring, do not forget to carry out the transfer first and then the turn, after which the first turn takes place.)

 //in shader function float rotation = _Time.y * 0.25; position = rotate(position, rotation); mirror(position); position = rotate(position, -rotation); 


Cells


If you know how noise generation works, then you understand that for procedural generation, we often repeat the position and get small cells that are essentially the same, differing only in minor parameters. We can do the same for distance fields.

Since the fmod function (as well as using% to divide with a remainder) gives us a remainder, rather than a definition of a remainder, we will have to use a trick. First we take the remainder of integer division by the function fmod. For positive numbers, this is exactly what we need, and for negative numbers, this is the desired result minus the period. This can be corrected by adding a period and again taking the remainder of the division. Adding a period will give the desired result for negative input values, and for positive input values ​​a value one period higher. The second remainder of the division will not do anything with values ​​for negative input values, because they are already in the range from 0 to the period, and for positive input values ​​we will essentially subtract one period.

 //in 2D_SDF.cginc void cells(inout float2 position, float2 period){ position = fmod(position, period); //negative positions lead to negative modulo position += period; //negative positions now have correct cell coordinates, positive input positions too high position = fmod(position, period); //second mod doesn't change values between 0 and period, but brings down values that are above period. } 

 //in shader function cells(position, float2(3, 3)); 


The problem of cells is that we are losing continuity, for which we love the distance fields. This is not bad if the figures are only in the middle of the cells, but in the example shown above this can lead to significant artifacts that should be avoided when distance fields are used for a variety of tasks in which distance fields can usually be used.

There is one solution that does not work in every case, but when it works, it is wonderful: to mirror every other cell. To do this, we need a pixel cell index, but we still don't have a return value in the function, so we can just use it to return the cell index.

To calculate the cell index, we divide the position by the period. Thus, 0-1 is the first cell, 1-2 is the second, and so on ... and we can easily discretize it. To get the cell index, we then simply round the value down and return the result. It is important that we calculate the cell index before division with the remainder for repeating cells; otherwise, we would get the index 0 everywhere, because the position cannot exceed the period.

 //in 2D_SDF.cginc float2 cells(inout float2 position, float2 period){ position = fmod(position, period); //negative positions lead to negative modulo position += period; //negative positions now have correct cell coordinates, positive input positions too high position = fmod(position, period); //second mod doesn't change values between 0 and period, but brings down values that are above period. float2 cellIndex = position / period; cellIndex = floor(cellIndex); return cellIndex; } 

With this information, we can flip the cells. In order to understand whether it is necessary or not necessary to turn it over, we divide the cell index modulo 2. The result of this operation is alternately 0 and 1 or -1 every second cell. To make the change more constant, we take an absolute value and get a value that switches between 0 and 1.

To use this value to flip between the normal and inverted position, we need a function that does nothing for the value 0 and subtracts the position from the period in which the inversion is 1. That is, we perform linear interpolation from the normal to the inverted position using the variable flip . Since the flip variable is a 2d vector, its components are reversed separately.

 //in shader function float2 period = 3; float2 cell = cells(position, period); float2 flip = abs(fmod(cell, 2)); position = lerp(position, period - position, flip); 


Radial cells


Another great feature is the repetition of space in a radial pattern.

To get this effect, we first calculate the radial position. To do this, we encode the angle relative to the center of the x axis and the distance from the center along the y axis.

 float2 radialPosition = float2(atan2(position.x, position.y), length(position)); 

Then we repeat the angle. Since transmitting the number of repetitions is much easier than the angle of each piece, we first calculate the size of each piece. The whole circle is 2 * pi, so to get the desired part, we divide 2 * pi by the size of the cell.

 const float PI = 3.14159; float cellSize = PI * 2 / cells; 

With this information, we can repeat the x component of the radial position in every cellSize units. We perform the repetition by dividing with the remainder, so as before we get problems with negative numbers, which can be eliminated with the help of two division functions with the remainder.

 radialPosition.x = fmod(fmod(radialPosition.x, cellSize) + cellSize, cellSize); 

Then you need to move the new position back to the usual xy coordinates. Here we use the sincos function with the x component of the radial position as the angle to write the sine at the x position and the cosine at the y coordinate. With this step we get a normalized position. To get the right direction from the center, you need to multiply it by the component y of the radial position, which means length.

 //in 2D_SDF.cginc void radial_cells(inout float2 position, float cells){ const float PI = 3.14159; float cellSize = PI * 2 / cells; float2 radialPosition = float2(atan2(position.x, position.y), length(position)); radialPosition.x = fmod(fmod(radialPosition.x, cellSize) + cellSize, cellSize); sincos(radialPosition.x, position.x, position.y); position = position * radialPosition.y; } 

 //in shader function float2 period = 6; radial_cells(position, period, false); 


Then we can also add a cell index and mirroring, as we did with regular cells.

It is necessary to calculate the cell index after calculating the radial position, but before obtaining its remainder from division. We get it by dividing the x component of the radial position and rounding the result down. In this case, the index can also be negative, and this is a problem if the number of cells is odd. For example, with 3 cells we get 1 cell with index 0, 1 cell with index -1 and 2 half cells with indices 1 and -2. To get around this problem, we add the number of cells to a variable rounded down, and then divide by the cell size with the remainder.

 //in 2D_SDF.cginc float cellIndex = fmod(floor(radialPosition.x / cellSize) + cells, cells); //at the end of the function: return cellIndex; 

To mirror this, we need the coordinates to be specified in radians, so to avoid recalculating the radial coordinates outside the function, we will add an option to it using the argument bool. Usually, branching (if constructions) is not welcome in shaders, but in this case all the pixels on the screen will follow the same path, so this is normal.

Mirroring should occur after cycling the radial coordinate, but before it is converted back to its normal position. We find out if the current cell should be turned over, dividing the cell index by 2 with the rest. Usually this should give us zeros and ones, but in my case there are a couple of twos, which is strange, and yet we can handle it. To eliminate the twos, we simply subtract 1 from the coup variable, and then take the absolute value. Thus, zeros and twos become ones, and ones become zeros, as we need, only in the reverse order.

Since the zeros and ones are in the wrong order, we perform linear interpolation from an inverted version to an inverted one, and not vice versa, as before. To flip a coordinate, we simply subtract the position from the cell size.

 //in 2D_SDF.cginc float radial_cells(inout float2 position, float cells, bool mirrorEverySecondCell = false){ const float PI = 3.14159; float cellSize = PI * 2 / cells; float2 radialPosition = float2(atan2(position.x, position.y), length(position)); float cellIndex = fmod(floor(radialPosition.x / cellSize) + cells, cells); radialPosition.x = fmod(fmod(radialPosition.x, cellSize) + cellSize, cellSize); if(mirrorEverySecondCell){ float flip = fmod(cellIndex, 2); flip = abs(flip-1); radialPosition.x = lerp(cellSize - radialPosition.x, radialPosition.x, flip); } sincos(radialPosition.x, position.x, position.y); position = position * radialPosition.y; return cellIndex; } 

 //in shader function float2 period = 6; radial_cells(position, period, true); 


Swinging space


But to change the space does not necessarily repeat it. For example, in the tutorial on the basics, we turned it, transferred it, and scaled it. You can also do the following: move each axis based on the other using a sine wave. This will make the distances of the distance function with a sign less accurate, but as long as they do not move too much, everything will be fine.

First, we calculate the magnitude of the change in position, turning over the components x and y, and then multiplying them by the frequency of the fluttering. Then we take the sine of this value and multiply it by the amount of waving we want to add. After that, we simply add this waving coefficient to the position and again apply the result to the position.

 //in 2D_SDF.cginc void wobble(inout float2 position, float2 frequency, float2 amount){ float2 wobble = sin(position.yx * frequency) * amount; position = position + wobble; } 

 //in shader function wobble(position, 5, .05); 


We can also animate this wave by changing its position, applying wave in the offset position and returning the space back. So that the floating point numbers do not become too large, I divide with the remainder pi * 2 by the frequency of waving, this correlates with the waving (a sinusoid repeats every pi * 2 units), so we avoid jumps and too large offsets.

 //in shader function const float PI = 3.14159; float frequency = 5; float offset = _Time.y; offset = fmod(offset, PI * 2 / frequency); position = translate(position, offset); wobble(position, 5, .05); position = translate(position, -offset); 


Sources


2D SDF 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); } // space repetition void mirror(inout float2 position){ position.x = abs(position.x); } float2 cells(inout float2 position, float2 period){ //find cell index float2 cellIndex = position / period; cellIndex = floor(cellIndex); //negative positions lead to negative modulo position = fmod(position, period); //negative positions now have correct cell coordinates, positive input positions too high position += period; //second mod doesn't change values between 0 and period, but brings down values that are above period. position = fmod(position, period); return cellIndex; } float radial_cells(inout float2 position, float cells, bool mirrorEverySecondCell = false){ const float PI = 3.14159; float cellSize = PI * 2 / cells; float2 radialPosition = float2(atan2(position.x, position.y), length(position)); float cellIndex = fmod(floor(radialPosition.x / cellSize) + cells, cells); radialPosition.x = fmod(fmod(radialPosition.x, cellSize) + cellSize, cellSize); if(mirrorEverySecondCell){ float flip = fmod(cellIndex, 2); flip = abs(flip-1); radialPosition.x = lerp(cellSize - radialPosition.x, radialPosition.x, flip); } sincos(radialPosition.x, position.x, position.y); position = position * radialPosition.y; return cellIndex; } void wobble(inout float2 position, float2 frequency, float2 amount){ float2 wobble = sin(position.yx * frequency) * amount; position = position + wobble; } //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 

Basic Demo Shader



 Shader "Tutorial/036_SDF_Space_Manpulation/Mirror"{ 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) { // modify position here! float2 squarePosition = position; squarePosition = translate(squarePosition, float2(2, 2)); squarePosition = rotate(squarePosition, .125); float squareShape = rectangle(squarePosition, float2(1, 1)); float2 circlePosition = position; circlePosition = translate(circlePosition, float2(1, 1.5)); float circleShape = circle(circlePosition, 1); float combination = merge(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 } 

Now you know all the basics of distance functions with a sign that I could remember. In the next tutorial, I will try to do something interesting with them.

Source: https://habr.com/ru/post/439000/