Skip to content

Commit fcc27e2

Browse files
authored
UIComponent: Introduce new UpdateFunc API
Intended to be used in almost all places where `animationFrame` was used before (only major exception being animated constraints, which will receive a different replacement). Unlike `animationFrame`, which runs at a fixed rate of (by default) 244 times per second, the `UpdateFunc` API is variable rate, meaning it'll be called exactly once per frame with the time that passed since the last frame. This allows it to match the true framerate exactly, animations won't slow down under high load, and it won't waste tons of CPU time on traversing the entire tree potentially multiple times each frame. The UpdateFunc API notably also allows modifying the component hierarchy and accessing layout information directly from within a UpdateFunc call, both of which are quite tricky to do correctly from `animationFrame`. GitHub: #155
1 parent cc508f3 commit fcc27e2

File tree

5 files changed

+328
-1
lines changed

5 files changed

+328
-1
lines changed

api/Elementa.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public abstract class gg/essential/elementa/UIComponent : java/util/Observable,
2424
public fun <init> ()V
2525
public fun addChild (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent;
2626
public fun addChildren ([Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent;
27+
public final fun addUpdateFunc (Lkotlin/jvm/functions/Function2;)V
2728
public fun afterDraw ()V
2829
public fun afterDraw (Lgg/essential/universal/UMatrixStack;)V
2930
public final fun afterDrawCompat (Lgg/essential/universal/UMatrixStack;)V
@@ -134,6 +135,7 @@ public abstract class gg/essential/elementa/UIComponent : java/util/Observable,
134135
public fun removeChild (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent;
135136
public final fun removeEffect (Lgg/essential/elementa/effects/Effect;)V
136137
public final fun removeEffect (Ljava/lang/Class;)V
138+
public final fun removeUpdateFunc (Lkotlin/jvm/functions/Function2;)V
137139
public fun replaceChild (Lgg/essential/elementa/UIComponent;Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent;
138140
protected final fun requireChildrenUnlocked ()V
139141
public final fun setChildOf (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent;
@@ -2583,6 +2585,7 @@ public final class gg/essential/elementa/dsl/UtilitiesKt {
25832585
public abstract class gg/essential/elementa/effects/Effect {
25842586
protected field boundComponent Lgg/essential/elementa/UIComponent;
25852587
public fun <init> ()V
2588+
protected final fun addUpdateFunc (Lkotlin/jvm/functions/Function2;)V
25862589
public fun afterDraw ()V
25872590
public fun afterDraw (Lgg/essential/universal/UMatrixStack;)V
25882591
public final fun afterDrawCompat (Lgg/essential/universal/UMatrixStack;)V
@@ -2595,6 +2598,7 @@ public abstract class gg/essential/elementa/effects/Effect {
25952598
public final fun beforeDrawCompat (Lgg/essential/universal/UMatrixStack;)V
25962599
public final fun bindComponent (Lgg/essential/elementa/UIComponent;)V
25972600
protected final fun getBoundComponent ()Lgg/essential/elementa/UIComponent;
2601+
protected final fun removeUpdateFunc (Lkotlin/jvm/functions/Function2;)V
25982602
protected final fun setBoundComponent (Lgg/essential/elementa/UIComponent;)V
25992603
public fun setup ()V
26002604
}

src/main/kotlin/gg/essential/elementa/UIComponent.kt

Lines changed: 263 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package gg.essential.elementa
22

3+
import gg.essential.elementa.components.NOP_UPDATE_FUNC
4+
import gg.essential.elementa.components.NopUpdateFuncList
35
import gg.essential.elementa.components.UIBlock
46
import gg.essential.elementa.components.UIContainer
7+
import gg.essential.elementa.components.UpdateFunc
58
import gg.essential.elementa.components.Window
69
import gg.essential.elementa.constraints.*
710
import gg.essential.elementa.constraints.animation.*
@@ -49,14 +52,19 @@ abstract class UIComponent : Observable(), ReferenceHolder {
4952
return field
5053
}
5154
open val children = CopyOnWriteArrayList<UIComponent>().observable()
52-
val effects = mutableListOf<Effect>()
55+
val effects: MutableList<Effect> = mutableListOf<Effect>().observable().apply {
56+
addObserver { _, event ->
57+
updateUpdateFuncsOnChangedEffect(event)
58+
}
59+
}
5360

5461
private var childrenLocked = 0
5562
init {
5663
children.addObserver { _, event ->
5764
requireChildrenUnlocked()
5865
setWindowCacheOnChangedChild(event)
5966
updateFloatingComponentsOnChangedChild(event)
67+
updateUpdateFuncsOnChangedChild(event)
6068
}
6169
}
6270

@@ -1050,6 +1058,258 @@ abstract class UIComponent : Observable(), ReferenceHolder {
10501058
}
10511059
}
10521060

1061+
//region Public UpdateFunc API
1062+
fun addUpdateFunc(func: UpdateFunc) {
1063+
val updateFuncs = updateFuncs ?: mutableListOf<UpdateFunc>().also { updateFuncs = it }
1064+
val index = updateFuncs.size
1065+
updateFuncs.add(func)
1066+
1067+
val indexInWindow = allocUpdateFuncs(index, 1)
1068+
if (indexInWindow != -1) {
1069+
cachedWindow!!.allUpdateFuncs[indexInWindow] = func
1070+
assertUpdateFuncInvariants()
1071+
}
1072+
}
1073+
1074+
fun removeUpdateFunc(func: UpdateFunc) {
1075+
val updateFuncs = updateFuncs ?: return
1076+
val index = updateFuncs.indexOf(func)
1077+
if (index == -1) return
1078+
updateFuncs.removeAt(index)
1079+
1080+
freeUpdateFuncs(index, 1)
1081+
}
1082+
//endregion
1083+
1084+
//region Internal UpdateFunc tracking
1085+
private var updateFuncParent: UIComponent? = null
1086+
private var updateFuncs: MutableList<UpdateFunc>? = null // only allocated if used
1087+
private var effectUpdateFuncs = 0 // count of effect funcs
1088+
private var totalUpdateFuncs = 0 // count of own funcs + effect funcs + children total funcs
1089+
1090+
private fun localUpdateFuncIndexForEffect(effectIndex: Int, indexInEffect: Int): Int {
1091+
var localIndex = updateFuncs?.size ?: 0
1092+
for ((otherEffectIndex, otherEffect) in effects.withIndex()) {
1093+
if (otherEffectIndex >= effectIndex) {
1094+
break
1095+
} else {
1096+
if (otherEffect.updateFuncParent != this) continue // can happen if added to two components at the same time
1097+
localIndex += otherEffect.updateFuncs?.size ?: 0
1098+
}
1099+
}
1100+
localIndex += indexInEffect
1101+
return localIndex
1102+
}
1103+
1104+
private fun localUpdateFuncIndexForChild(childIndex: Int, indexInChild: Int): Int {
1105+
var localIndex = (updateFuncs?.size ?: 0) + effectUpdateFuncs
1106+
for ((otherChildIndex, otherChild) in children.withIndex()) {
1107+
if (otherChildIndex >= childIndex) {
1108+
break
1109+
} else {
1110+
if (otherChild.updateFuncParent != this) continue // can happen if added to two components at the same time
1111+
localIndex += otherChild.totalUpdateFuncs
1112+
}
1113+
}
1114+
localIndex += indexInChild
1115+
return localIndex
1116+
}
1117+
1118+
internal fun addUpdateFunc(effect: Effect, indexInEffect: Int, func: UpdateFunc) {
1119+
effectUpdateFuncs++
1120+
val indexInWindow = allocUpdateFuncs(localUpdateFuncIndexForEffect(effects.indexOf(effect), indexInEffect), 1)
1121+
if (indexInWindow != -1) {
1122+
cachedWindow!!.allUpdateFuncs[indexInWindow] = func
1123+
assertUpdateFuncInvariants()
1124+
}
1125+
}
1126+
1127+
internal fun removeUpdateFunc(effect: Effect, indexInEffect: Int) {
1128+
effectUpdateFuncs--
1129+
freeUpdateFuncs(localUpdateFuncIndexForEffect(effects.indexOf(effect), indexInEffect), 1)
1130+
}
1131+
1132+
private fun allocUpdateFuncs(childIndex: Int, indexInChild: Int, count: Int): Int {
1133+
return allocUpdateFuncs(localUpdateFuncIndexForChild(childIndex, indexInChild), count)
1134+
}
1135+
1136+
private fun freeUpdateFuncs(childIndex: Int, indexInChild: Int, count: Int) {
1137+
freeUpdateFuncs(localUpdateFuncIndexForChild(childIndex, indexInChild), count)
1138+
}
1139+
1140+
private fun allocUpdateFuncs(localIndex: Int, count: Int): Int {
1141+
totalUpdateFuncs += count
1142+
if (this is Window) {
1143+
if (nextUpdateFuncIndex > localIndex) {
1144+
nextUpdateFuncIndex += count
1145+
}
1146+
if (count == 1) {
1147+
allUpdateFuncs.add(localIndex, NOP_UPDATE_FUNC)
1148+
} else {
1149+
allUpdateFuncs.addAll(localIndex, NopUpdateFuncList(count))
1150+
}
1151+
return localIndex
1152+
} else {
1153+
val parent = updateFuncParent ?: return -1
1154+
return parent.allocUpdateFuncs(parent.children.indexOf(this), localIndex, count)
1155+
}
1156+
}
1157+
1158+
private fun freeUpdateFuncs(localIndex: Int, count: Int) {
1159+
totalUpdateFuncs -= count
1160+
if (this is Window) {
1161+
if (nextUpdateFuncIndex > localIndex) {
1162+
nextUpdateFuncIndex -= min(count, nextUpdateFuncIndex - localIndex)
1163+
}
1164+
if (count == 1) {
1165+
allUpdateFuncs.removeAt(localIndex)
1166+
} else {
1167+
allUpdateFuncs.subList(localIndex, localIndex + count).clear()
1168+
}
1169+
assertUpdateFuncInvariants()
1170+
} else {
1171+
val parent = updateFuncParent ?: return
1172+
parent.freeUpdateFuncs(parent.children.indexOf(this), localIndex, count)
1173+
}
1174+
}
1175+
1176+
private fun updateUpdateFuncsOnChangedChild(possibleEvent: Any) {
1177+
@Suppress("UNCHECKED_CAST")
1178+
when (val event = possibleEvent as? ObservableListEvent<UIComponent> ?: return) {
1179+
is ObservableAddEvent -> {
1180+
val (childIndex, child) = event.element
1181+
child.updateFuncParent?.let { oldParent ->
1182+
oldParent.updateUpdateFuncsOnChangedChild(ObservableRemoveEvent(
1183+
IndexedValue(oldParent.children.indexOf(child), child)))
1184+
}
1185+
assert(child.updateFuncParent == null)
1186+
child.updateFuncParent = this
1187+
1188+
if (child.totalUpdateFuncs == 0) return
1189+
var indexInWindow = allocUpdateFuncs(childIndex, 0, child.totalUpdateFuncs)
1190+
if (indexInWindow == -1) return
1191+
val allUpdateFuncs = cachedWindow!!.allUpdateFuncs
1192+
fun register(component: UIComponent) {
1193+
component.updateFuncs?.let { funcs ->
1194+
for (func in funcs) {
1195+
allUpdateFuncs[indexInWindow++] = func
1196+
}
1197+
}
1198+
component.effects.forEach { effect ->
1199+
if (effect.updateFuncParent != component) return@forEach // can happen if added to two components at the same time
1200+
effect.updateFuncs?.let { funcs ->
1201+
for (func in funcs) {
1202+
allUpdateFuncs[indexInWindow++] = func
1203+
}
1204+
}
1205+
}
1206+
component.children.forEach { child ->
1207+
if (child.updateFuncParent != component) return@forEach // can happen if added to two components at the same time
1208+
register(child)
1209+
}
1210+
}
1211+
register(child)
1212+
assertUpdateFuncInvariants()
1213+
}
1214+
is ObservableRemoveEvent -> {
1215+
val (childIndex, child) = event.element
1216+
if (child.updateFuncParent != this) return // double remove can happen if added to two component at once
1217+
child.updateFuncParent = null
1218+
1219+
if (child.totalUpdateFuncs == 0) return
1220+
freeUpdateFuncs(childIndex, 0, child.totalUpdateFuncs)
1221+
}
1222+
is ObservableClearEvent -> {
1223+
event.oldChildren.forEach { if (it.updateFuncParent == this) it.updateFuncParent = null }
1224+
1225+
val remainingFuncs = (updateFuncs?.size ?: 0) + effectUpdateFuncs
1226+
val removedFuncs = totalUpdateFuncs - remainingFuncs
1227+
freeUpdateFuncs(remainingFuncs, removedFuncs)
1228+
}
1229+
}
1230+
}
1231+
1232+
private fun updateUpdateFuncsOnChangedEffect(possibleEvent: Any) {
1233+
@Suppress("UNCHECKED_CAST")
1234+
when (val event = possibleEvent as? ObservableListEvent<Effect> ?: return) {
1235+
is ObservableAddEvent -> {
1236+
val (effectIndex, effect) = event.element
1237+
effect.updateFuncParent?.let { oldParent ->
1238+
oldParent.updateUpdateFuncsOnChangedEffect(ObservableRemoveEvent(
1239+
IndexedValue(oldParent.effects.indexOf(effect), effect)))
1240+
}
1241+
assert(effect.updateFuncParent == null)
1242+
effect.updateFuncParent = this
1243+
1244+
val funcs = effect.updateFuncs ?: return
1245+
if (funcs.isEmpty()) return
1246+
effectUpdateFuncs += funcs.size
1247+
var indexInWindow = allocUpdateFuncs(localUpdateFuncIndexForEffect(effectIndex, 0), funcs.size)
1248+
if (indexInWindow == -1) return
1249+
val allUpdateFuncs = cachedWindow!!.allUpdateFuncs
1250+
for (func in funcs) {
1251+
allUpdateFuncs[indexInWindow++] = func
1252+
}
1253+
assertUpdateFuncInvariants()
1254+
}
1255+
is ObservableRemoveEvent -> {
1256+
val (effectIndex, effect) = event.element
1257+
if (effect.updateFuncParent != this) return // double remove can happen if added to two component at once
1258+
effect.updateFuncParent = null
1259+
1260+
val funcs = effect.updateFuncs?.size ?: 0
1261+
if (funcs == 0) return
1262+
effectUpdateFuncs -= funcs
1263+
freeUpdateFuncs(localUpdateFuncIndexForEffect(effectIndex, 0), funcs)
1264+
}
1265+
is ObservableClearEvent -> {
1266+
event.oldChildren.forEach { if (it.updateFuncParent == this) it.updateFuncParent = null }
1267+
1268+
val removedFuncs = effectUpdateFuncs
1269+
effectUpdateFuncs = 0
1270+
freeUpdateFuncs(updateFuncs?.size ?: 0, removedFuncs)
1271+
}
1272+
}
1273+
}
1274+
1275+
internal fun assertUpdateFuncInvariants() {
1276+
if (!ASSERT_UPDATE_FUNC_INVARINTS) return
1277+
1278+
val window = cachedWindow ?: return
1279+
val allUpdateFuncs = window.allUpdateFuncs
1280+
1281+
var indexInWindow = 0
1282+
1283+
fun visit(component: UIComponent) {
1284+
val effectUpdateFuncs = component.effects.sumOf { if (it.updateFuncParent == component) it.updateFuncs?.size ?: 0 else 0 }
1285+
val childUpdateFuncs = component.children.sumOf { if (it.updateFuncParent == component) it.totalUpdateFuncs else 0 }
1286+
assert(component.effectUpdateFuncs == effectUpdateFuncs)
1287+
assert(component.totalUpdateFuncs == (component.updateFuncs?.size ?: 0) + effectUpdateFuncs + childUpdateFuncs)
1288+
1289+
component.updateFuncs?.let { funcs ->
1290+
for (func in funcs) {
1291+
assert(func == allUpdateFuncs[indexInWindow++])
1292+
}
1293+
}
1294+
component.effects.forEach { effect ->
1295+
if (effect.updateFuncParent != component) return@forEach // can happen if added to two components at the same time
1296+
effect.updateFuncs?.let { funcs ->
1297+
for (func in funcs) {
1298+
assert(func == allUpdateFuncs[indexInWindow++])
1299+
}
1300+
}
1301+
}
1302+
component.children.forEach { child ->
1303+
if (child.updateFuncParent != component) return@forEach // can happen if added to two components at the same time
1304+
visit(child)
1305+
}
1306+
}
1307+
visit(window)
1308+
1309+
assert(indexInWindow == allUpdateFuncs.size)
1310+
}
1311+
//endregion
1312+
10531313
/**
10541314
* Field animation API
10551315
*/
@@ -1264,6 +1524,8 @@ abstract class UIComponent : Observable(), ReferenceHolder {
12641524
// Default value for componentName used as marker for lazy init.
12651525
private val defaultComponentName = String()
12661526

1527+
private val ASSERT_UPDATE_FUNC_INVARINTS = System.getProperty("elementa.debug.assertUpdateFuncInvariants").toBoolean()
1528+
12671529
val DEBUG_OUTLINE_WIDTH = System.getProperty("elementa.debug.width")?.toDoubleOrNull() ?: 2.0
12681530

12691531
/**

src/main/kotlin/gg/essential/elementa/components/Window.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ class Window @JvmOverloads constructor(
2323
val animationFPS: Int = 244
2424
) : UIComponent() {
2525
private var systemTime = -1L
26+
27+
private var lastDrawTime: Long = -1
28+
29+
internal var allUpdateFuncs: MutableList<UpdateFunc> = mutableListOf()
30+
internal var nextUpdateFuncIndex = 0
31+
2632
private var currentMouseButton = -1
2733

2834
private var legacyFloatingComponents = mutableListOf<UIComponent>()
@@ -43,6 +49,7 @@ class Window @JvmOverloads constructor(
4349

4450
init {
4551
super.parent = this
52+
cachedWindow = this
4653
}
4754

4855
override fun afterInitialization() {
@@ -74,9 +81,23 @@ class Window @JvmOverloads constructor(
7481

7582
if (systemTime == -1L)
7683
systemTime = System.currentTimeMillis()
84+
if (lastDrawTime == -1L)
85+
lastDrawTime = System.currentTimeMillis()
86+
87+
val now = System.currentTimeMillis()
88+
val dtMs = now - lastDrawTime
89+
lastDrawTime = now
7790

7891
try {
7992

93+
assertUpdateFuncInvariants()
94+
nextUpdateFuncIndex = 0
95+
while (true) {
96+
val func = allUpdateFuncs.getOrNull(nextUpdateFuncIndex) ?: break
97+
nextUpdateFuncIndex++
98+
func(dtMs / 1000f, dtMs.toInt())
99+
}
100+
80101
//If this Window is more than 5 seconds behind, reset it be only 5 seconds.
81102
//This will drop missed frames but avoid the game freezing as the Window tries
82103
//to catch after a period of inactivity
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package gg.essential.elementa.components
2+
3+
/**
4+
* Called once at the start of every frame to update any animations and miscellaneous state.
5+
*
6+
* @param dt Time (in seconds) since last frame
7+
* @param dtMs Time (in milliseconds) since last frame
8+
*
9+
* This differs from `(dt / 1000).toInt()` in that it will account for the fractional milliseconds which would
10+
* otherwise be lost to rounding. E.g. if there are three frames each lasting 16.4ms,
11+
* `(dt / 1000).toInt()` would be 16 each time, but `dtMs` will be 16 on the first two frames and 17 on the third.
12+
*/
13+
typealias UpdateFunc = (dt: Float, dtMs: Int) -> Unit
14+
15+
internal val NOP_UPDATE_FUNC: UpdateFunc = { _, _ -> }
16+
17+
internal class NopUpdateFuncList(override val size: Int) : AbstractList<UpdateFunc>() {
18+
override fun get(index: Int): UpdateFunc = NOP_UPDATE_FUNC
19+
}

0 commit comments

Comments
 (0)