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
}
}
}
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.