diff --git a/orx-keyframer/README.md b/orx-keyframer/README.md index 4f965edb..1268dddf 100644 --- a/orx-keyframer/README.md +++ b/orx-keyframer/README.md @@ -1,8 +1,8 @@ # orx-keyframer -Create animated timelines by specifying properties and times in keyframes, -then play it back at any speed (even backwards) automatically interpolating properties. -Save, load, use mathematical expressions and callbacks. Powerful and highly reusable. +Create animated timelines by specifying properties and times in keyframes, then play it back at any speed (even +backwards) automatically interpolating properties. Save, load, use mathematical expressions and callbacks. Powerful and +highly reusable. What this allows you to do: @@ -50,7 +50,7 @@ What this allows you to do: "radius": { "value": 50.0, "easing": "linear" - } + } } ] ``` @@ -77,95 +77,152 @@ extend { drawer.circle(animation.position, animation.radius) } ``` + ## Easing -All the easing functions of orx-easing are available +All the easing functions of orx-easing are available - - linear - - back-in - - back-out - - back-in-out - - bounce-in - - bounce-out - - bounce-in-out - - circ-in - - circ-out - - circ-in-out - - cubic-in - - cubic-out - - cubic-in-out - - elastic-in - - elastic-out - - elastic-in-out - - expo-in - - expo-out - - expo-in-out - - quad-in - - quad-out - - quad-in-out - - quart-in - - quart-out - - quart-in-out - - quint-in - - quint-out - - quint-in-out - - sine-in - - sine-out - - sine-in-out - - one - - zero +- linear +- back-in +- back-out +- back-in-out +- bounce-in +- bounce-out +- bounce-in-out +- circ-in +- circ-out +- circ-in-out +- cubic-in +- cubic-out +- cubic-in-out +- elastic-in +- elastic-out +- elastic-in-out +- expo-in +- expo-out +- expo-in-out +- quad-in +- quad-out +- quad-in-out +- quart-in +- quart-out +- quart-in-out +- quint-in +- quint-out +- quint-in-out +- sine-in +- sine-out +- sine-in-out +- one +- zero +## More expressive interface +orx-keyframer has two ways of programming key frames. The first is the `"x": ` style we have seen before. The +second way uses a dictionary instead of a number value. + +For example: + +```json +[ + { + "time": 0.0, + "x": 320.0, + "y": 240.0 + }, + { + "time": 10.0, + "easing": "cubic-out", + "x": { + "easing": "cubic-in-out", + "value": 0.0 + }, + "y": { + "duration": -5.0, + "easing": "cubic-in", + "value": 0.0 + } + }, + { + "time": 20.0, + "x": 640.0, + "y": 480.0, + "easing": "cubic-in-out" + } +] +``` + +Inside the value dictionary one can set `value`, `easing`, `duration` and `envelope`. + + * `value` the target value, required value + * `easing` easing method that overrides the key's easing method, optional value + * `duration` an optional duration for the animation, set to `0` to jump from the previous +value to the new value, a negative value will start the interpolation before `time`. A positive value + wil start the interpolation at `time` and end at `time + duration` +* `envelope` optional 2-point envelope that modifies the playback of the animation. The default envelope is +`[0.0, 1.0]`. Reverse playback is achieved by supplying `[1.0, 0.0]`. To start the animation later try `[0.1, 1.0]`, + to end the animation earlier try `[0.0, 0.9]` + ## Advanced features -orx-keyframer uses two file formats. A `SIMPLE` format and a `FULL` format. For reference check the [example full format .json](src/demo/resources/demo-full-01.json) and the [example program](src/demo/kotlin/DemoFull01.kt). -The full format adds a `parameters` block and a `prototypes` block. +orx-keyframer uses two file formats. A `SIMPLE` format and a `FULL` format. For reference check +the [example full format .json](src/demo/resources/demo-full-01.json) and +the [example program](src/demo/kotlin/DemoFull01.kt). The full format adds a `parameters` block and a `prototypes` +block. -[Repeats](src/demo/resources/demo-simple-repetitions-01.json), simple key repeating mechanism - -[Expressions](src/demo/resources/demo-simple-expressions-01.json), expression mechanism. Currently uses values `r` to indicate repeat index and `t` the last used key time, `v` the last used value (for the animated attribute). +[Expressions](src/demo/resources/demo-simple-expressions-01.json), expression mechanism. Currently uses values `r` to +indicate repeat index and `t` the last used key time, `v` the last used value (for the animated attribute). Supported functions in expressions: - - `min(x, y)`, `max(x, y)` - - `cos(x)`, `sin(x)`, `acos(x)`, `asin(x)`, `tan(x)`, `atan(x)`, `atan2(y, x)` - - `abs(x)`, `saturate(x)` - - `degrees(x)`, `radians(x)` - - `pow(x,y)`, `sqrt(x)`, `exp(x)` - - `mix(left, right, x)` - - `smoothstep(t0, t1, x)` - - `map(leftBefore, rightBefore, leftAfter, rightAfter, x)` - - `random()`, `random(min, max)` - + +- `min(x, y)`, `max(x, y)` +- `cos(x)`, `sin(x)`, `acos(x)`, `asin(x)`, `tan(x)`, `atan(x)`, `atan2(y, x)` +- `abs(x)`, `saturate(x)` +- `degrees(x)`, `radians(x)` +- `pow(x, y)`, `sqrt(x)`, `exp(x)` +- `mix(left, right, x)` +- `smoothstep(t0, t1, x)` +- `map(leftBefore, rightBefore, leftAfter, rightAfter, x)` +- `random()`, `random(min, max)` + [Parameters and prototypes](src/demo/resources/demo-full-01.json) + ## Demos + ### DemoFull01 + [source code](src/demo/kotlin/DemoFull01.kt) ![DemoFull01Kt](https://raw.githubusercontent.com/openrndr/orx/media/orx-keyframer/images/DemoFull01Kt.png) ### DemoScrub01 + [source code](src/demo/kotlin/DemoScrub01.kt) ![DemoScrub01Kt](https://raw.githubusercontent.com/openrndr/orx/media/orx-keyframer/images/DemoScrub01Kt.png) ### DemoSimple01 + [source code](src/demo/kotlin/DemoSimple01.kt) ![DemoSimple01Kt](https://raw.githubusercontent.com/openrndr/orx/media/orx-keyframer/images/DemoSimple01Kt.png) ### DemoSimple02 + [source code](src/demo/kotlin/DemoSimple02.kt) ![DemoSimple02Kt](https://raw.githubusercontent.com/openrndr/orx/media/orx-keyframer/images/DemoSimple02Kt.png) ### DemoSimpleExpressions01 + [source code](src/demo/kotlin/DemoSimpleExpressions01.kt) ![DemoSimpleExpressions01Kt](https://raw.githubusercontent.com/openrndr/orx/media/orx-keyframer/images/DemoSimpleExpressions01Kt.png) ### DemoSimpleRepetitions01 + [source code](src/demo/kotlin/DemoSimpleRepetitions01.kt) ![DemoSimpleRepetitions01Kt](https://raw.githubusercontent.com/openrndr/orx/media/orx-keyframer/images/DemoSimpleRepetitions01Kt.png) diff --git a/orx-keyframer/src/demo/kotlin/DemoSimpleRepetitions01.kt b/orx-keyframer/src/demo/kotlin/DemoEvelope01.kt similarity index 76% rename from orx-keyframer/src/demo/kotlin/DemoSimpleRepetitions01.kt rename to orx-keyframer/src/demo/kotlin/DemoEvelope01.kt index df6319dc..e245555d 100644 --- a/orx-keyframer/src/demo/kotlin/DemoSimpleRepetitions01.kt +++ b/orx-keyframer/src/demo/kotlin/DemoEvelope01.kt @@ -8,10 +8,9 @@ fun main() = application { program { class Animation: Keyframer() { val position by Vector2Channel(arrayOf("x", "y")) - val radius by DoubleChannel("x") } val animation = Animation() - animation.loadFromJson(URL(resourceUrl("/demo-simple-repetitions-01.json"))) + animation.loadFromJson(URL(resourceUrl("/demo-envelope-01.json"))) if (System.getProperty("takeScreenshot") == "true") { extend(SingleScreenshot()) { this.outputFile = System.getProperty("screenshotPath") @@ -19,7 +18,7 @@ fun main() = application { } extend { animation(seconds) - drawer.circle(animation.position, animation.radius) + drawer.circle(animation.position, 100.0) } } } \ No newline at end of file diff --git a/orx-keyframer/src/demo/resources/demo-envelope-01.json b/orx-keyframer/src/demo/resources/demo-envelope-01.json new file mode 100644 index 00000000..aa8b9b3a --- /dev/null +++ b/orx-keyframer/src/demo/resources/demo-envelope-01.json @@ -0,0 +1,25 @@ +[ + { + "time": 0.0, + "x": 320.0, + "y": 240.0 + }, + { + "time": 10.0, + "easing": "cubic-in-out", + "x": { + "envelope": [0.5, 1.0], + "value": 0.0 + }, + "y": { + "envelope": [0.4, 1.0], + "value": 0.0 + } + }, + { + "time": 20.0, + "x": 640.0, + "y": 480.0, + "easing": "cubic-in-out" + } +] \ No newline at end of file diff --git a/orx-keyframer/src/demo/resources/demo-simple-repetitions-01.json b/orx-keyframer/src/demo/resources/demo-simple-repetitions-01.json deleted file mode 100644 index 72100ef3..00000000 --- a/orx-keyframer/src/demo/resources/demo-simple-repetitions-01.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "time": 0.0, - "x": 320.0, - "y": 240.0, - "radius": 0.0 - }, - { - "time": 3.0, - "repeat": { - "count": 5, - "keys": [ - { - "duration": 1.0, - "easing": "cubic-in-out", - "x": 10.0, - "y": 4.0, - "radius": 400 - }, - { - "duration": 1.0, - "easing": "cubic-in-out", - "x": 630.0, - "y": 470.0, - "radius": 40 - } - ] - } - } -] \ No newline at end of file diff --git a/orx-keyframer/src/main/antlr/KeyLangLexer.g4 b/orx-keyframer/src/main/antlr/KeyLangLexer.g4 index d4e35840..a88aa2c4 100644 --- a/orx-keyframer/src/main/antlr/KeyLangLexer.g4 +++ b/orx-keyframer/src/main/antlr/KeyLangLexer.g4 @@ -20,8 +20,8 @@ DECIMAL : 'Decimal'; STRING : 'String'; // Identifiers -ID : [_]*[a-zA-Z][A-Za-z0-9_]* ; -FUNCTION_ID : [_]*[a-z][A-Za-z0-9_]* ; +ID : [$_]*[a-zA-Z][A-Za-z0-9_]* ; +FUNCTION_ID : [$_]*[a-z][A-Za-z0-9_]* ; // Literals diff --git a/orx-keyframer/src/main/kotlin/Expressions.kt b/orx-keyframer/src/main/kotlin/Expressions.kt index 4764334f..d23cd48d 100644 --- a/orx-keyframer/src/main/kotlin/Expressions.kt +++ b/orx-keyframer/src/main/kotlin/Expressions.kt @@ -3,7 +3,9 @@ package org.openrndr.extra.keyframer import org.antlr.v4.runtime.* import org.antlr.v4.runtime.tree.ParseTreeWalker import org.antlr.v4.runtime.tree.TerminalNode -import org.openrndr.extra.keyframer.antlr.* +import org.openrndr.extra.keyframer.antlr.KeyLangLexer +import org.openrndr.extra.keyframer.antlr.KeyLangParser +import org.openrndr.extra.keyframer.antlr.KeyLangParserBaseListener import org.openrndr.extra.noise.uniform import org.openrndr.math.* import java.util.* @@ -357,20 +359,6 @@ fun evaluateExpression( functions: FunctionExtensions = FunctionExtensions.EMPTY ): Double? { val lexer = KeyLangLexer(CharStreams.fromString(input)) -// -// lexer.removeErrorListeners() -// lexer.addErrorListener(object : BaseErrorListener() { -// override fun syntaxError( -// recognizer: Recognizer<*, *>?, -// offendingSymbol: Any?, -// line: Int, -// charPositionInLine: Int, -// msg: String?, -// e: RecognitionException? -// ) { -// println("syntax error!") -// } -// }) val parser = KeyLangParser(CommonTokenStream(lexer)) parser.removeErrorListeners() parser.addErrorListener(object : BaseErrorListener() { diff --git a/orx-keyframer/src/main/kotlin/Key.kt b/orx-keyframer/src/main/kotlin/Key.kt index f599cdc8..08c19285 100644 --- a/orx-keyframer/src/main/kotlin/Key.kt +++ b/orx-keyframer/src/main/kotlin/Key.kt @@ -2,14 +2,12 @@ package org.openrndr.extra.keyframer import org.openrndr.extras.easing.Easing import org.openrndr.extras.easing.EasingFunction +import org.openrndr.math.map -class Key(val time: Double, val value: Double, val easing: EasingFunction) +internal val defaultEnvelope = doubleArrayOf(0.0, 1.0) + +class Key(val time: Double, val value: Double, val easing: EasingFunction, val envelope: DoubleArray = defaultEnvelope) -enum class Hold { - HoldNone, - HoldSet, - HoldAll -} class KeyframerChannel { val keys = mutableListOf() @@ -18,14 +16,17 @@ class KeyframerChannel { return 0.0 } - fun add(time: Double, value: Double?, easing: EasingFunction = Easing.Linear.function, jump: Hold = Hold.HoldNone) { - if (jump == Hold.HoldAll || (jump == Hold.HoldSet && value != null)) { - lastValue()?.let { - keys.add(Key(time, it, Easing.Linear.function)) - } + fun add( + time: Double, + value: Double?, + easing: EasingFunction = Easing.Linear.function, + envelope: DoubleArray = defaultEnvelope + ) { + require(envelope.size >= 2) { + "envelope should contain at least 2 entries" } value?.let { - keys.add(Key(time, it, easing)) + keys.add(Key(time, it, easing, envelope)) } } @@ -65,7 +66,8 @@ class KeyframerChannel { val rightKey = keys[rightIndex] val leftKey = keys[leftIndex] val t0 = (time - leftKey.time) / (rightKey.time - leftKey.time) - val e0 = rightKey.easing(t0, 0.0, 1.0, 1.0) + val te = t0.map(rightKey.envelope[0], rightKey.envelope[1], 0.0, 1.0, clamp = true) + val e0 = rightKey.easing(te, 0.0, 1.0, 1.0) leftKey.value * (1.0 - e0) + rightKey.value * (e0) } } diff --git a/orx-keyframer/src/main/kotlin/KeyQuaternion.kt b/orx-keyframer/src/main/kotlin/KeyQuaternion.kt index 8821e437..24abc0c2 100644 --- a/orx-keyframer/src/main/kotlin/KeyQuaternion.kt +++ b/orx-keyframer/src/main/kotlin/KeyQuaternion.kt @@ -14,12 +14,8 @@ class KeyframerChannelQuaternion { return 0.0 } - fun add(time: Double, value: Quaternion?, easing: EasingFunction = Easing.Linear.function, jump: Hold = Hold.HoldNone) { - if (jump == Hold.HoldAll || (jump == Hold.HoldSet && value != null)) { - lastValue()?.let { - keys.add(KeyQuaternion(time, it, Easing.Linear.function)) - } - } + fun add(time: Double, value: Quaternion?, easing: EasingFunction = Easing.Linear.function) { + value?.let { keys.add(KeyQuaternion(time, it, easing)) } diff --git a/orx-keyframer/src/main/kotlin/KeyVector3.kt b/orx-keyframer/src/main/kotlin/KeyVector3.kt index af9350c9..f4d192e1 100644 --- a/orx-keyframer/src/main/kotlin/KeyVector3.kt +++ b/orx-keyframer/src/main/kotlin/KeyVector3.kt @@ -13,12 +13,7 @@ class KeyframerChannelVector3 { return 0.0 } - fun add(time: Double, value: Vector3?, easing: EasingFunction = Easing.Linear.function, jump: Hold = Hold.HoldNone) { - if (jump == Hold.HoldAll || (jump == Hold.HoldSet && value != null)) { - lastValue()?.let { - keys.add(KeyVector3(time, it, Easing.Linear.function)) - } - } + fun add(time: Double, value: Vector3?, easing: EasingFunction = Easing.Linear.function) { value?.let { keys.add(KeyVector3(time, it, easing)) } diff --git a/orx-keyframer/src/main/kotlin/Keyframer.kt b/orx-keyframer/src/main/kotlin/Keyframer.kt index 2a097889..12c77318 100644 --- a/orx-keyframer/src/main/kotlin/Keyframer.kt +++ b/orx-keyframer/src/main/kotlin/Keyframer.kt @@ -14,7 +14,6 @@ import java.lang.IllegalStateException import java.lang.NullPointerException import java.net.URL import kotlin.math.max -import kotlin.math.roundToInt import kotlin.reflect.KProperty import kotlin.reflect.KProperty1 import kotlin.reflect.full.memberProperties @@ -258,7 +257,6 @@ open class Keyframer { expressionContext.putAll(parameters) expressionContext["t"] = 0.0 - fun easingFunctionFromName(easingCandidate: String): EasingFunction { return when (easingCandidate) { "linear" -> Easing.Linear.function @@ -346,9 +344,20 @@ open class Keyframer { throw ExpressionException("error in $path.'easing': ${e.message ?: ""}") } - val hold = Hold.HoldNone + val envelope = try { + when (val candidate = computed["envelope"]) { + null -> defaultEnvelope + is DoubleArray -> candidate + is List<*> -> candidate.map { it.toString().toDouble() }.toDoubleArray() + is Array<*> -> candidate.map { it.toString().toDouble() }.toDoubleArray() + else -> error("unknown envelope for '$candidate") + } + } catch (e: IllegalStateException) { + throw ExpressionException("error in $path.'envelope': ${e.message ?: ""}") + } - val reservedKeys = setOf("time", "easing", "hold") + + val reservedKeys = setOf("time", "easing", "envelope") for (channelCandidate in computed.filter { it.key !in reservedKeys }) { if (channelCandidate.key in channelKeys) { @@ -385,6 +394,14 @@ open class Keyframer { else -> error("unknown easing for '$candidate'") } + val dictEnvelope = when (val candidate = valueMap["envelope"]) { + null -> envelope + is DoubleArray -> candidate + is List<*> -> candidate.map { it.toString().toDouble() }.toDoubleArray() + is Array<*> -> candidate.map { it.toString().toDouble() }.toDoubleArray() + else -> error("unknown envelope for '$candidate") + + } val dictDuration = try { when (val candidate = valueMap["duration"]) { null -> null @@ -400,12 +417,14 @@ open class Keyframer { if (dictDuration != null) { if (dictDuration <= 0.0) { - channel.add(max(lastTime, time + dictDuration), lastValue, Easing.Linear.function, hold) - channel.add(time, value, dictEasing, hold) + channel.add(max(lastTime, time + dictDuration), lastValue, Easing.Linear.function, defaultEnvelope) + channel.add(time, value, dictEasing, dictEnvelope) } else { - channel.add(time, lastValue, Easing.Linear.function, hold) - channel.add(time + dictDuration, value, dictEasing, hold) + channel.add(time, lastValue, Easing.Linear.function, defaultEnvelope) + channel.add(time + dictDuration, value, dictEasing, dictEnvelope) } + } else { + channel.add(time, value, dictEasing, dictEnvelope) } } else { @@ -420,39 +439,12 @@ open class Keyframer { } catch (e: ExpressionException) { throw ExpressionException("error in $path.'${channelCandidate.key}': ${e.message ?: ""}") } - channel.add(time, value, easing, hold) + channel.add(time, value, easing, envelope) } } } lastTime = time + duration expressionContext["t"] = lastTime - - if (computed.containsKey("repeat")) { - @Suppress("UNCHECKED_CAST") - val repeatObject = computed["repeat"] as? Map ?: error("'repeat' should be a map") - val count = try { - when (val candidate = repeatObject["count"]) { - null -> 1 - is Int -> candidate - is Double -> candidate.toInt() - is String -> evaluateExpression(candidate, expressionContext, functions)?.roundToInt() - ?: error("cannot evaluate expression for count: '$candidate'") - else -> error("unknown value type for count: '$candidate") - } - } catch (e: ExpressionException) { - throw ExpressionException("error in $path.repeat.'count': ${e.message ?: ""}") - } - - @Suppress("UNCHECKED_CAST") - val repeatKeys = repeatObject["keys"] as? List> ?: error("no repeat keys") - - for (i in 0 until count) { - expressionContext["rep"] = i.toDouble() - for (repeatKey in repeatKeys) { - handleKey(repeatKey, "$path.repeat") - } - } - } } for ((index, key) in keys.withIndex()) {