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.
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);
}
}
(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);
}
}
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.