diff --git a/orx-time-operators/README.md b/orx-time-operators/README.md new file mode 100644 index 00000000..a9227389 --- /dev/null +++ b/orx-time-operators/README.md @@ -0,0 +1,42 @@ +# orx-time-operators + +A collection of time-sensitive functions aimed at controlling raw data over-time. + +## Usage + +Use the TimeOperators extension to `tick` the operators, making them advance in time. + +```kotlin +extend(TimeOperators()) { + track(envelope, lfo) +} +``` + +### Envelope + +```kotlin +val size = Envelope(50.0, 400.0, 0.5, 0.5) + +if (frameCount % 80 == 0) { + size.trigger() // also accepts a new target value +} + +drawer.circle(0.0, 0.0, size.value) +``` + +### LFO + +```kotlin +val size = LFO(LFOWave.SINE) // default LFOWave.SAW + +val freq = 0.5 +val phase = 0.5 + +drawer.circle(0.0, 0.0, size.sample(freq, phase)) + +// or + +drawer.circle(0.0, 0.0, size.sine(freq, phase)) +``` + + diff --git a/orx-time-operators/build.gradle b/orx-time-operators/build.gradle new file mode 100644 index 00000000..d1479bb6 --- /dev/null +++ b/orx-time-operators/build.gradle @@ -0,0 +1,19 @@ +sourceSets { + demo { + java { + srcDirs = ["src/demo/kotlin"] + compileClasspath += main.getCompileClasspath() + runtimeClasspath += main.getRuntimeClasspath() + } + } +} + +dependencies { + implementation project(":orx-parameters") + + demoImplementation(project(":orx-camera")) + demoImplementation("org.openrndr:openrndr-core:$openrndrVersion") + demoRuntimeOnly("org.openrndr:openrndr-gl3:$openrndrVersion") + demoRuntimeOnly("org.openrndr:openrndr-gl3-natives-$openrndrOS:$openrndrVersion") + demoImplementation(sourceSets.getByName("main").output) +} \ No newline at end of file diff --git a/orx-time-operators/src/demo/kotlin/DemoEnvelope.kt b/orx-time-operators/src/demo/kotlin/DemoEnvelope.kt new file mode 100644 index 00000000..fd11abe2 --- /dev/null +++ b/orx-time-operators/src/demo/kotlin/DemoEnvelope.kt @@ -0,0 +1,40 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.draw.isolated +import org.openrndr.extra.timeoperators.Envelope +import org.openrndr.extra.timeoperators.TimeOperators + +fun main() { + application { + program { + val size = Envelope(50.0, 400.0, 0.5, 0.5) + val rotation = Envelope(easingFactor = 0.4) + + extend(TimeOperators()) { + track(size, rotation) + } + extend { + if (frameCount % 80 == 0) { + size.trigger() + } + + if (frameCount % 50 == 0) { + rotation.trigger(180.0) + } + + drawer.isolated { + drawer.stroke = ColorRGBa.PINK + drawer.fill = ColorRGBa.PINK + + drawer.translate(width / 2.0, height / 2.0) + drawer.rotate(rotation.value) + + val side = size.value / 2.0 + val offset = side / 2.0 + + drawer.rectangle(0.0 - offset, 0.0 - offset, side, side) + } + } + } + } +} \ No newline at end of file diff --git a/orx-time-operators/src/demo/kotlin/DemoLFO.kt b/orx-time-operators/src/demo/kotlin/DemoLFO.kt new file mode 100644 index 00000000..b6146fad --- /dev/null +++ b/orx-time-operators/src/demo/kotlin/DemoLFO.kt @@ -0,0 +1,34 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.draw.isolated +import org.openrndr.extra.timeoperators.Envelope +import org.openrndr.extra.timeoperators.LFO +import org.openrndr.extra.timeoperators.LFOWave +import org.openrndr.extra.timeoperators.TimeOperators + +fun main() { + application { + program { + val size = LFO() + val rotation = LFO(LFOWave.SINE) + + extend(TimeOperators()) { + track(size, rotation) + } + extend { + drawer.isolated { + drawer.stroke = ColorRGBa.PINK + drawer.fill = ColorRGBa.PINK + + drawer.translate(width / 2.0, height / 2.0) + drawer.rotate(rotation.sample() * 180.0) + + val side = (size.sample(0.5) * 400.0) / 2.0 + val offset = side / 2.0 + + drawer.rectangle(0.0 - offset, 0.0 - offset, side, side) + } + } + } + } +} \ No newline at end of file diff --git a/orx-time-operators/src/main/kotlin/Envelope.kt b/orx-time-operators/src/main/kotlin/Envelope.kt new file mode 100644 index 00000000..efe51d9d --- /dev/null +++ b/orx-time-operators/src/main/kotlin/Envelope.kt @@ -0,0 +1,138 @@ +package org.openrndr.extra.timeoperators + +import org.openrndr.extra.parameters.BooleanParameter +import org.openrndr.extra.parameters.DoubleParameter +import org.openrndr.math.clamp +import org.openrndr.math.mix +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + + +enum class EnvelopePhase { + Rest, Attack, Decay +} + +// Exponential Ease-In and Ease-Out by Golan Levin +// http://www.flong.com/texts/code/shapers_exp/ +private fun exponentialEasing(x: Double, a: Double): Double { + var a = a + val epsilon = 0.00001 + val minParamA = (0.0 + epsilon) + val maxParamA = (1.0 - epsilon) + a = max(minParamA, min(maxParamA, a)) + + return if (a < 0.5) { + // emphasis + a = (2.0 * a) + x.pow(a) + } else { + // de-emphasis + a = (2.0 * (a - 0.5)) + x.pow(1.0 / (1 - a)) + } +} + +class Envelope( + var restValue: Double = 0.0, + var targetValue: Double = 1.0, + @DoubleParameter("Attack Duration", 0.0, 5.0, 3, 0) + var attack: Double = 0.3, + @DoubleParameter("Decay Duration", 0.0, 5.0, 3, 1) + var decay: Double = 0.5, + @DoubleParameter("Easing Factor", 0.0, 1.0, 3, 2) + var easingFactor: Double = 0.3, + @BooleanParameter("Re-trigger", 3) + var reTrigger: Boolean = false +) : TimeTools +{ + var phase = EnvelopePhase.Rest + set(value) { + if (value == EnvelopePhase.Rest) { + initialTime = Double.NEGATIVE_INFINITY + current = initialRestValue + } + + field = value + } + + val value: Double + get() { + return current + } + + private var initialTime = Double.NEGATIVE_INFINITY + private var current = restValue + private var initialRestValue = restValue + private val cycleDuration: Double + get() { + return attack + decay + } + + override fun tick(seconds: Double, deltaTime: Double, frameCount: Int) { + if (phase == EnvelopePhase.Rest) return + + // TODO: what happens if deltaTime < cycleDuration? + + if (initialTime == Double.NEGATIVE_INFINITY) { + initialTime = seconds + } + + val cycleTime = seconds - initialTime + + if (cycleTime < 0) { + phase = EnvelopePhase.Rest + return + } + + if (cycleTime <= attack) { + phase = EnvelopePhase.Attack + } else if (cycleTime > attack && cycleTime < cycleDuration) { + phase = EnvelopePhase.Decay + } else { + phase = EnvelopePhase.Rest + return + } + + if (phase == EnvelopePhase.Attack) { + current = if (attack == 0.0) { + targetValue + } else { + val t = clamp(cycleTime / attack, 0.0, 1.0) + + mix(restValue, targetValue, exponentialEasing(t, easingFactor)) + } + } + + if (phase == EnvelopePhase.Decay) { + current = if (decay == 0.0) { + initialRestValue + } else { + val t = clamp((cycleTime - attack) / decay, 0.0, 1.0) + + mix(targetValue, initialRestValue, exponentialEasing(t, easingFactor)) + } + } + + if (current.isNaN()) { + println("current is NaN, $phase") + } + } + + fun trigger(value: Double = targetValue) { + restValue = if (initialTime != Double.NEGATIVE_INFINITY) { + current + } else { + initialRestValue + } + + if (reTrigger) { + restValue = initialRestValue + } + + initialTime = Double.NEGATIVE_INFINITY + phase = EnvelopePhase.Attack + + targetValue = value + } +} \ No newline at end of file diff --git a/orx-time-operators/src/main/kotlin/LFO.kt b/orx-time-operators/src/main/kotlin/LFO.kt new file mode 100644 index 00000000..572e1da7 --- /dev/null +++ b/orx-time-operators/src/main/kotlin/LFO.kt @@ -0,0 +1,60 @@ +package org.openrndr.extra.timeoperators + +import org.openrndr.math.clamp +import org.openrndr.math.mod +import kotlin.math.* + +internal const val TAU = 2.0 * PI + +// TODO: When there's a @DropdownParameter switch from Int to String +enum class LFOWave(val wave: Int) { + SAW(0), SINE(1), SQUARE(2), TRIANGLE(3) +} + +@Suppress("UNUSED") +class LFO(var wave: LFOWave = LFOWave.SAW) : TimeTools { + private var current = 0.0 + set(value) { + field = clamp(value, 0.0, 1.0) + } + + private var initialTime = Double.NEGATIVE_INFINITY + private var dt = 0.0 + private var time = 0.0 + + override fun tick(seconds: Double, deltaTime: Double, frameCount: Int) { + time += deltaTime + } + + fun sample(frequency: Double = 1.0, phase: Double = 0.0): Double { + return when(wave) { + LFOWave.SAW -> saw(frequency, phase) + LFOWave.SINE -> sine(frequency, phase) + LFOWave.SQUARE -> square(frequency, phase) + LFOWave.TRIANGLE -> triangle(frequency, phase) + } + } + + fun saw(frequency: Double = 1.0, phase: Double = 0.0): Double { + val cycleFreq = 1.0 / frequency + val cycleTime = mod(time + (phase * frequency), cycleFreq) + current = (cycleTime) / cycleFreq + return current + } + + fun sine(frequency: Double = 1.0, phase: Double = 0.0): Double { + current = sin((phase * TAU) + time * frequency * TAU) * 0.5 + 0.5 + return current + } + + fun square(frequency: Double = 1.0, phase: Double = 0.0): Double { + current = max(sign(sin((phase * TAU) + time * frequency * TAU)), 0.0) + return current + } + + fun triangle(frequency: Double = 1.0, phase: Double = 0.0): Double { + val t = (time * frequency) + (phase * frequency) + current = 1.0 - 2.0 * abs(mod(t, 1.0) - 0.5) + return current + } +} \ No newline at end of file diff --git a/orx-time-operators/src/main/kotlin/TimeOperators.kt b/orx-time-operators/src/main/kotlin/TimeOperators.kt new file mode 100644 index 00000000..c7fbcc5f --- /dev/null +++ b/orx-time-operators/src/main/kotlin/TimeOperators.kt @@ -0,0 +1,23 @@ +package org.openrndr.extra.timeoperators + +import org.openrndr.Extension +import org.openrndr.Program +import org.openrndr.draw.Drawer + +interface TimeTools { + fun tick(seconds: Double, deltaTime: Double, frameCount: Int) +} + +class TimeOperators : Extension { + override var enabled: Boolean = true + + private val operators = mutableSetOf() + + fun track(vararg tools: TimeTools) { + operators.addAll(tools) + } + + override fun beforeDraw(drawer: Drawer, program: Program) { + operators.forEach { it.tick(program.seconds, program.deltaTime, program.frameCount) } + } +} diff --git a/settings.gradle b/settings.gradle index 3af9efd2..000cc4a9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,6 +34,7 @@ include 'orx-camera', 'orx-syphon', 'orx-temporal-blur', 'orx-timer', + 'orx-time-operators', 'orx-kinect-common', 'orx-kinect-v1', 'orx-kinect-v1-natives-linux-arm64',