Closed curve coordinates: An exercise

I wanted to see if I could make something dynamic with closed contours entirely on the fragment shader via using the c_contourPosition uniform, so to give a moving particle illusion but where everything is actually really static under the hood.
Here’s the code

    import org.openrndr.KEY_ENTER
    import org.openrndr.application
    import org.openrndr.color.ColorRGBa
    import org.openrndr.draw.*
    import org.openrndr.extra.fx.blur.GaussianBlur
    import org.openrndr.extra.noise.uniform
    import org.openrndr.extra.videoprofiles.gif
    import org.openrndr.ffmpeg.ScreenRecorder
    import org.openrndr.math.Vector2
    import org.openrndr.math.chaikinSmooth
    import org.openrndr.shape.contour
    import kotlin.math.*
    
    
    fun main() = application {
        configure {
            width = 1000
            height = 1000
        }
    
        program {
            var t = 0.0
           
            val rt = renderTarget(width, height){
                colorBuffer()
                depthBuffer()
            }
    
            val rtBright = renderTarget(width, height){
                colorBuffer()
                depthBuffer()
            }
    
            val img = colorBuffer(width, height)
    
            val blur = GaussianBlur()
    
    
            val nVerts = 3
            val rad = 80.0
            val scl = 0.2
            val nContours = 20
    
            val pnts = List(nVerts) {
                val offset = Double.uniform(rad - 20.0, rad + 20.0)
                val x = (rad + offset) * cos(it * 2.0/nVerts * PI)
                val y = (rad + offset) * sin(it * 2.0/nVerts * PI)
                Vector2(x, y)
            }
    
            val points = chaikinSmooth(pnts, 10, true)
    
            val contours = List(nContours) {
                contour {
                    moveTo(points[0] * (1.0 + scl * it ))
                    for (i in 1 until points.size) {
                        lineTo(points[i] * (1.0 + scl * it ))
                    }
                    lineTo(points[0] * (1.0 + scl * it ))
    
                }
            }

            val speeds = contours.map{Double.uniform(0.1, 0.4)}     

            var backCol = ColorRGBa.fromHex("#232323")
    
            extend(ScreenRecorder()) {
                gif()
            }
            extend {
    
                drawer.fill = null
                drawer.stroke = ColorRGBa.WHITE
                drawer.strokeWeight = 4.0
    
    
                drawer.isolatedWithTarget(rt) {
                    drawer.clear(backCol)
                    contours.forEachIndexed { id, c ->
                        drawer.shadeStyle = shadeStyle {
                            fragmentTransform = """ 
                        // Normalize the curve coordinate between 0.0 and 1.0;
                        float t = c_contourPosition/p_l;
                        // Define starting and ending point, namely b and a;
                        float a = mod(p_t, 1.0);
                        float b = mod(p_t + p_d, 1.0);
                        // Create a "step function" which is 1.0 if a < t < b, and 0.0 otherwise;
                        // Notice that if a > b we have to change the condition since the coordinate t will jump when crossing the origin
                        float d = step(a, b ) * step(a, t) * step(t, b) + step(b, a) * (1.0 - step(b, t) * step(t, a));
                        // Make a gradient going from the background color to white;
                        vec3 br = mix(p_backCol.xyz, vec3(1.0), d);
                        // Compute the mixing coefficient for the color gradient, normalized to (0.0, 1.0), and taking into account when 0 is included in the 
                        // moving window; 
                        float mixCoeff = step(a,b) * (b - t)/(b-a) + step(b, a) * (step(t, b) * ( b - t)/(b + 1.0 - a) + step(b, t) * (b + 1.0 - t)/(b + 1.0 - a));
                        // Create the mixing color in such a way that the line gradient fades to background because art :)
                        vec3 col1 = vec3(34, 193, 295)/255.0;
                        vec3 col2 = vec3(253, 187, 45)/255.0;
                        vec3 col =  mix(p_backCol.xyz, pow(vec3(d) * mix(col1, col2, mixCoeff), vec3(1.0)), 1.0 - 0.99 * mixCoeff * d);
                        vec3 color = col + (1.0-vec3(d)) * p_backCol.xyz;
                        x_stroke.rgb = color;
                    """.trimIndent()
                            parameter("l", c.length)
                            parameter("t", t * (1.0 + speeds[id]))
                            parameter("d", 500.0/c.length )
                            parameter("backCol", backCol.toVector4())
    
                        }
                        drawer.pushTransforms()
                        drawer.translate(drawer.bounds.center)
                        drawer.contour(c)
                        drawer.popTransforms()
                    }
                }
    
                rt.colorBuffer(0).copyTo(img)
    
                // Add bloom effect
                if (mouse.position.x < 0.8 * width) {
                    drawer.isolatedWithTarget(rtBright) {
                        drawer.fill = ColorRGBa.WHITE
                        drawer.stroke = null
                        drawer.shadeStyle = shadeStyle {
                            fragmentTransform = """
                            vec2 coord = c_boundsPosition.xy;
                            coord.y = 1.0 - coord.y;
                            vec3 color = texture(p_image, coord).xyz;
                            float br = dot(color, vec3(1.0))/3.0;
                            x_fill.rgb = vec3(step(0.65, br));
                        """.trimIndent()
                            parameter("image", img)
                        }
                        drawer.rectangle(drawer.bounds)
                    }
    
                    blur.window = 10
                    blur.spread = 2.0
                    blur.apply(rtBright.colorBuffer(0), rtBright.colorBuffer(0))
                    blur.apply(rtBright.colorBuffer(0), rtBright.colorBuffer(0))
    
    
                    drawer.isolatedWithTarget(rt) {
                        drawer.fill = ColorRGBa.WHITE
                        drawer.stroke = null
                        drawer.shadeStyle = shadeStyle {
                            fragmentTransform = """
                            vec2 coord = c_boundsPosition.xy;
                            coord.y = 1.0 - coord.y;
                            vec3 color = texture(p_image, coord).xyz;
                            vec3 blurCol = texture(p_image, coord).xyz;
                            x_fill.rgb = color +  blurCol;
                        """.trimIndent()
                            parameter("image", img)
                            parameter("blurred", rtBright.colorBuffer(0))
    
    
                        }
                        drawer.rectangle(drawer.bounds)
                    }
    
                }
    
                if (mouse.position.x < 0.8 * width) drawer.image(rt.colorBuffer(0)) else drawer.image(img)
                t += 0.005
            }
        }
    }

gif1

The basic idea is quite simple as well mischievious: just consider a moving window (a, b) and check if the normalized coordinate t is such that a < t < b. All fine, until you realize that a closed curve cannot have a single continuous coordinate, since you have to identify the value 0.0 and 1.0 (mathematicians would say that you cannot cover a closed simple curve with a single chart ). This problem is solved in the fragment shader with a little bit of “step function acrobatics” to avoid branching, and to have a nice gradient. I think the shader can be further optimized but I am kinda happy with the result. A next step would be to make use of the alpha channel, this should simplify the mixing part.
I have also added a self-made bloom effect: if you move the mouse to the far right you can see how taxing the double gaussian blur pass really is.

2 Likes

Thank you for sharing! Much fun! I wrote this variation:

import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.shadeStyle
import org.openrndr.extra.noise.Random
import org.openrndr.shape.Circle

fun main() = application {
    program {
        val c = List(20) {
            Circle(drawer.bounds.center, 100.0 + it * 10.0).contour
        }
        val style = shadeStyle {
            fragmentPreamble = """
                #define linearstep(edge0, edge1, x) clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0)
            """.trimIndent()
            fragmentTransform = """
                float t = c_contourPosition / p_length;
                float alpha = fract(t - p_time + p_offset);
                // make alpha zero below 0.7, then fade to 1.0
                alpha = linearstep(0.7, 1.0, alpha);
                // square alpha to bias the mix towards the first color
                x_stroke.rgb = mix(vec3(1.0, 0.0, 1.0), vec3(0.0, 1.0, 1.0), alpha*alpha);
                x_stroke.a = alpha;
            """.trimIndent()
        }

        extend {
            drawer.clear(ColorRGBa.WHEAT.shade(0.2))
            drawer.strokeWeight = 4.0
            drawer.fill = null
            drawer.shadeStyle = style
            style.parameter("time", seconds * 0.3)
            c.forEachIndexed { i, c ->
                style.parameter("offset", Random.perlin(i * 0.303, seconds * 0.03 + i * 0.808))
                style.parameter("length", c.length)
                drawer.contour(c)
            }
        }
    }
}

TemplateProgram-2022-11-05-16.20.01

I added a simple linearstep method. Online one can find other variations of smoothstep, like circularstep and others.

1 Like

Very nice! I guess this is the dual picture, where you keep the window (a, b) fixed, and move the coordinate t.

Here’s a variation of the variation, where I basically compute in advance the flow of some dynamical system with a stable orbit. :slight_smile:

import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.shadeStyle
import org.openrndr.extra.noise.Random
import org.openrndr.math.Vector2
import org.openrndr.math.map
import org.openrndr.shape.*


fun main() = application {
    configure {
        width = 1000
        height = 1000
    }

    program {

        fun getFlow(start: Vector2, nSteps: Int, step: Double):ShapeContour {
            var p = start.copy()
            val points = List(nSteps){
                val v = Vector2(p.y, -p.x + 11.0 * p.y * (0.12 - p.x * p.x))
                p += v * step
                p
            }.map{
                Vector2(map(-2.0, 2.0, 0.0, width * 1.0, it.x), map(-2.0, 2.0, height * 1.0, 0.0,  it.y))
            }
            val shp = contour {
                moveTo(points[0])
                for (i in 1 until points.size){
                    lineTo(points[i])
                }
            }
            return shp
        }

        var time = 0.0
        val nSteps = 500
        val step = 0.02
        val nSize = 30

        val startPoints = mutableListOf<Vector2>()
        for (x in 0 .. nSize){
            for(y in 0 .. nSize){
                  startPoints.add(Vector2(map(0.0, nSize * 1.0, -1.8, 1.8, x * 1.0 ),
                      map(0.0, nSize * 1.0, -1.8, 1.8, y * 1.0 )))
              }
          }

        val sh = startPoints.map{getFlow(it, nSteps, step)}
        extend {
            drawer.stroke = ColorRGBa.WHITE
            drawer.fill = null
            drawer.strokeWeight = 2.0
            val style = shadeStyle {
                fragmentPreamble = """
                    #define linearstep(edge0, edge1, x) clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0)
                """.trimIndent()
                fragmentTransform = """
                    float t = c_contourPosition/p_l;
                    float alpha = fract(t - p_time -p_offset);
                    alpha = linearstep(0.92, 1.0, alpha);
                    vec3 col = mix(vec3(0.1, 0.24, 0.4), vec3(0.3, 0.2, 0.9), 1.0 - alpha * alpha);
                    x_stroke.rgb = mix(col, vec3(1.0), 1.0 - alpha * alpha);
                    x_stroke.a = alpha;
                """.trimIndent()
            }


            sh.forEachIndexed {i, c  ->
                drawer.shadeStyle = style
                style.parameter("time", time)
                style.parameter("l", c.length)
                style.parameter("offset",  Random.perlin(i * 0.303,  i * 0.808))
                drawer.contour(c)
            }

            time += 0.005
        }
    }
}

gif1

2 Likes

Very cool !
Can you explain your thought process of creating this ?
From the idea to writing shaders (efficiently, without syntax error etc.) to getting this awesome attractor shape ?

1 Like

Hi @LH99 , thank you!
I can write a few words, but somehow the thought process is almost under the sun here :slight_smile:
I was basically interested in creating the illusion of real-time dynamics but with the dynamics itself already pre-calculated, so that no computation is needed while drawing the frames. I knew that shaderStyle has a uniform that it is basically a coordinate on the contour, so I thought of using it to display just a little bit of the curve via the fragment shader. I then found a solution, posted it here, then @abe showed a much more elegant approach which I started playing with. Since the flow of a dynamical system gives rise to contours, it was immediate to apply this to the flow curves of a dynamical system admitting a stable orbit (well, I’m a mathematician, these are the things that are immediate to me :slight_smile: ).
Then I take an idea an reiterate in multiple forms. For instance, the sketch above gives directly rise to this

gif1

If you look at the code you might think it is something completely different, and to a certain extent it is since it is completely shader based. The foundational ideas that I wanted to explore, though, are the same, and to me that’s what matters the most: I rarely know what I will make, most of the time things and paths emerge and I try to follow them.
As for writing shaders “efficiently, without syntax errors”, I can reassure you that I go through many, many errors while coding! I guess experience reduces some of them, but it is usually a trial and error process, more or less like with any other artistic medium.
I hope this is somehow useful :slight_smile:

1 Like

Thanks @Alessandro,

didn’t really expect anything but trial-and-tweaking and picking the roses on the way :slight_smile: Still good to know that writing shaders is tricky, even for experienced programmers.

I have a background in mathematics, but have never heard of flow as a generalization of “applying a force field to particles”. Quite fancy, indeed.

1 Like