[orx-keyframer] Add envelopes, remove repetitions

This commit is contained in:
Edwin Jakobs
2021-01-09 19:46:58 +01:00
parent 3e23d77fdd
commit 829e0bbf7e
10 changed files with 188 additions and 164 deletions

View File

@@ -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": <number>` 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__ -->
## 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)

View File

@@ -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)
}
}
}

View File

@@ -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"
}
]

View File

@@ -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
}
]
}
}
]

View File

@@ -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

View File

@@ -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() {

View File

@@ -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<Key>()
@@ -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)
}
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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<String, Any> ?: 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<Map<String, Any>> ?: 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()) {