Question

How do I use a Unity unlit Shader to plot a curve with even thickness

I'm new to Unity shaders and I'm using a Unity unlit shader to plot a function of x. For example, y = sin(x). This is very easy with the following code:

            float4 frag(v2f i) : SV_Target
            {
                float4 color = float4(0, 0, 0, 1);
                float y = 0.45 * sin(20 * i.uv.x) + 0.5;
                float thickness = 0.02;
                if (i.uv.y > (y - thickness) && i.uv.y < (y + thickness))
                {
                    color = float4(1, 0, 0, 1);
                }
                
                return color;
            }

What's harder is getting the curve to have even thickness. The curve thickness will be thinner when the slope is greater.

I've mainly tried to make the thickness variable a function of the slope of the curve with poor results and poor performance. However, I've noticed that with Unity Shaders, there's always a simple trick to these sort of problems. Any recommendations from Shader experts?

 4  55  4
1 Jan 1970

Solution

 3

I'm no shader expert, but this problem looked interesting enough that I wanted to take a stab at it myself. I did some searching online and found Plotting High-frequency Functions Using a GPU by Mikael Hvidtfeldt Christensen, which proposes the solution of sampling the function multiple times per pixel and basing the result on the average:

// http://blog.hvidtfeldts.net/index.php/2011/07/plotting-high-frequency-functions-using-a-gpu/

for (float i = 0.0; i < samples; i++) {
    for (float  j = 0.0;j < samples; j++) {
        float f = function(pos.x+ i*step.x)-(pos.y+ j*step.y);
        count += (f>0.) ? 1 : -1;
    }
}
// base color on abs(count)/(samples*samples)   

This method also has the advantage of being able to (as the article states), graph high-frequency functions.

There's two unknown variables there, int(?) samples and float2(?) step. samples is self-explanatory, we're sampling multiple times. Step seems to be how much to move between each sample. It's very similar to a blurring shader: increasing samples leads to an increase in thickness.

Showing varying samples, from 2 to 50

float function(float x) {
    return 0.45 * sin(20.0 * x) + 0.5;
}

float4 frag (v2f i) : SV_Target
{
    float2 uv = i.uv;
    float2 step = float2(0.001,0.001);

    float count = 0.;
    for (float i = 0.0; i < _Samples; i++) {
        for (float  j = 0.0; j < _Samples; j++) {
            float f = function(uv.x + i * _StepX) - (uv.y + j * _StepY);
            count += (f > 0.0) ? 1.0 : -1.0;
        }
    }

    float result = 1.0 - (abs(count) / (_Samples * _Samples));
    return fixed4(result, 0, 0, 1);
}

Alright, this looks good enough to start with. Though, the thickness is based on an arbitrary sample count, and the line is blurry. We can fix the blurry line using a clipping value that'll set the color to be our line color whenever it's above some value. For the thickness issue, lets instead base the samples off the target thickness, as well as doing pow(result, 1.0 / _Thickness); to not drop off so fast:

float function(float x) {
    return 0.45 * sin(20.0 * x) + 0.5;
}

float4 frag (v2f i) : SV_Target
{
    float2 uv = i.uv;
    float2 step = float2(0.001,0.001);

    float samples = 5 * _Thickness;
    float count = 0.;
    for (float i = 0.0; i < samples; i++) {
        for (float  j = 0.0; j < samples; j++) {
            float f = function(uv.x + i * _StepX) - (uv.y + j * _StepY);
            count += (f > 0.0) ? 1.0 : -1.0;
        }
    }

    float result = 1.0 - (abs(count) / (samples * samples));
    result = pow(result, 1.0 / _Thickness);

    if (result > _ThicknessClip) {
        return fixed4(1, 0, 0, 1);
    } else {
        return fixed4(0, 0, 0, 1);
    }
}

Showing thickness increasing, but shifting towards the bottom left

(Though, now that I think of it, the clipping value might break the "high-frequency" functions. If this happens, and you need to graph a high-frequency function, just ignore that part.)


This is even better, but now I'm noticing that we're shifting the origin of the function based on the thickness (sample count). This is because our step uses a constant value, and bigger sample counts means we offset more and more.

I fixed this by looping from -samples/2 to samples/2 instead of 0 to samples:

float function(float x) {
    return 0.45 * sin(20.0 * x) + 0.5;
}

float4 frag (v2f i) : SV_Target
{
    float2 uv = i.uv;
    float2 step = float2(0.001,0.001);

    float samples = 5 * _Thickness;
    float count = 0.;
    for (float i = -samples/2; i < samples/2; i++) {
        for (float j = -samples/2; j < samples/2; j++) {
            float f = function(uv.x + i * _StepX) - (uv.y + j * _StepY);
            count += (f > 0.0) ? 1.0 : -1.0;
        }
    }

    float result = 1.0 - (abs(count) / (samples * samples));
    result = pow(result, 1.0 / _Thickness);

    if (result > _ThicknessClip) {
        return fixed4(1, 0, 0, 1);
    } else {
        return fixed4(0,0,0,1);
    }
}

Showing thickness increasing without shifting


I'd say that's good enough, at small thicknesses at least. There's probably a much better way to be doing this, probably through the derivative method you mentioned before, but this would work for functions that don't have a(n easily calculable) derivative.

Obviously extend this yourself as needed; things like exposing the colors to the shader, changing the function, etc. I'm not sure of a good way to expose the function that we're graphing to the user, but I suppose if needed you can make shader variants with different functions.

Some functions might need fine-tuning when it comes to the step size; I didn't try to make a clean way of calculating that. At ClipY=0.001, thickness 20 seems to be cut off at the peaks, but ClipY=0.002 works great, preserving the curvature.

2024-07-03
ipodtouch0218

Solution

 1

I was able to come up with a solution. Sorry for being unclear in my original post but I'm looking for constant thickness through out the curve. Curve thickness should be the same width at the max, min and inflection points. In the following code, a sin wave is use for testing purposes but the real curve I'm using comes from a Bezier calculation. Hence the estimate for the tangent line. The following code works pretty well. If you execute the code and zoom in to the curve, the curve thickness should be the same everywhere.

        float4 frag(v2f i) : SV_Target
        {
            // Default color.
            float4 color = float4(0.0, 0.0, 0.0, 1.0);

            // We need to estimate the derivate of the curve by creating 2 points offset from i.uv.x
            // Do this by 1 pixel on each side.
            float pi = 3.1415926535f;
            float delta = 1.0f / _ScreenParams.x; // 1 pixel normalized.
            float x1 = i.uv.x - delta;
            float x2 = i.uv.x + delta;
            float y1 = 0.45 * sin(20 * x1) + 0.5; // For testing without recompile.
            float y2 = 0.45 * sin(20 * x2) + 0.5; // For testing without recompile.
            float rise = y2 - y1;
            float run = x2 - x1;
            if (run > 0.0)
            {
                // Value of curve at i.uv.x
                float y = 0.45 * sin(20 * i.uv.x) + 0.5; // For testing without recompile.

                // Slope of tangent.
                // float slope = 9 * cos(20 * i.uv.x); // dy/dx of 0.45 * sin(20 * x1) + 0.5
                float slope = rise / run;

                // Components of normal to the curve.
                float thickness = 0.01;
                float b = thickness * sin((pi / 2) - atan(slope));
                float a = sqrt(pow(thickness, 2) - pow(b, 2));

                // Get the variable y range
                float yrange = 0.0;
                float adjust = 0.5; // Hack to make the curve totally event everywhere.
                if (slope < 0.0f)
                {
                    yrange = -a * slope * adjust + b;
                }
                else
                {
                    yrange = a * slope * adjust + b;
                }

                // Finally, color the curve with the correct aliasing.
                if (i.uv.y <= (y + yrange) && i.uv.y >= (y - yrange))
                {
                    color = fixed4(1.0f, 0.5f, 0.0f, 1.0 - abs(y - i.uv.y) / yrange);
                }
            }
            
            return color;
        }
2024-07-04
groszobquivousemmerde