Skip to content

Commit c98c138

Browse files
authored
Merge pull request #8 from silenium-dev/feat/external-interface
feat: add statistics
2 parents dba0310 + 6611953 commit c98c138

File tree

3 files changed

+147
-10
lines changed

3 files changed

+147
-10
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package dev.silenium.compose.gl.surface
2+
3+
import kotlinx.coroutines.flow.MutableStateFlow
4+
import kotlinx.coroutines.flow.StateFlow
5+
import kotlinx.coroutines.flow.asStateFlow
6+
import kotlin.time.Duration
7+
import kotlin.time.Duration.Companion.seconds
8+
9+
interface Stats<T : Comparable<T>> {
10+
val values: List<T>
11+
val sum: T
12+
val average: T
13+
val min: T
14+
val max: T
15+
val median: T
16+
fun percentile(percentile: Double, direction: Percentile = Percentile.UP): T
17+
18+
enum class Percentile {
19+
UP, LOWEST
20+
}
21+
}
22+
23+
data class DurationStats(override val values: List<Duration>) : Stats<Duration> {
24+
override val sum by lazy { values.fold(Duration.ZERO) { a, it -> a + it } }
25+
override val average by lazy { sum / values.size }
26+
override val min by lazy { values.minOrNull() ?: Duration.ZERO }
27+
override val max by lazy { values.maxOrNull() ?: Duration.ZERO }
28+
override val median by lazy {
29+
if (values.isEmpty()) return@lazy Duration.ZERO
30+
if (values.size == 1) return@lazy values.first()
31+
val sorted = values.sorted()
32+
val middle = sorted.size / 2
33+
if (sorted.size % 2 == 0) {
34+
(sorted[middle - 1] + sorted[middle]) / 2
35+
} else {
36+
sorted[middle]
37+
}
38+
}
39+
40+
override fun percentile(percentile: Double, direction: Stats.Percentile): Duration {
41+
if (values.isEmpty()) return Duration.ZERO
42+
val sorted = values.sorted()
43+
val index = when(direction) {
44+
Stats.Percentile.UP -> (percentile * sorted.size).toInt()
45+
Stats.Percentile.LOWEST -> sorted.size - (percentile * sorted.size).toInt() - 1
46+
}
47+
return sorted[index]
48+
}
49+
}
50+
51+
data class DoubleStats(override val values: List<Double>) : Stats<Double> {
52+
override val sum by lazy { values.fold(0.0) { a, it -> a + it } }
53+
override val average by lazy { sum / values.size }
54+
override val min by lazy { values.minOrNull() ?: 0.0 }
55+
override val max by lazy { values.maxOrNull() ?: 0.0 }
56+
override val median by lazy {
57+
if (values.isEmpty()) return@lazy 0.0
58+
if (values.size == 1) return@lazy values.first()
59+
val sorted = values.sorted()
60+
val middle = sorted.size / 2
61+
if (sorted.size % 2 == 0) {
62+
(sorted[middle - 1] + sorted[middle]) / 2
63+
} else {
64+
sorted[middle]
65+
}
66+
}
67+
68+
override fun percentile(percentile: Double, direction: Stats.Percentile): Double {
69+
if (values.isEmpty()) return 0.0
70+
val sorted = values.sorted()
71+
val index = when(direction) {
72+
Stats.Percentile.UP -> (percentile * sorted.size).toInt()
73+
Stats.Percentile.LOWEST -> sorted.size - (percentile * sorted.size).toInt() - 1
74+
}
75+
return sorted[index]
76+
}
77+
}
78+
79+
data class RollingWindowStatistics(
80+
val windowSize: Duration = 5.seconds,
81+
val values: Map<Long, Duration> = emptyMap(),
82+
) {
83+
val frameTimes by lazy { DurationStats(values.values.toList()) }
84+
val fps by lazy {
85+
DoubleStats(
86+
if (values.size < 2) emptyList()
87+
else values.keys.sorted().zipWithNext().map { (a, b) -> 1_000_000_000.0 / (b - a) }
88+
)
89+
}
90+
91+
fun add(nanos: Long, time: Duration): RollingWindowStatistics {
92+
val newValues = values.toMutableMap()
93+
newValues[nanos] = time
94+
return copy(values = newValues.filter { it.key >= nanos - windowSize.inWholeNanoseconds })
95+
}
96+
}
97+
98+
class GLSurfaceState {
99+
internal val renderStatistics_ = MutableStateFlow(RollingWindowStatistics())
100+
internal val displayStatistics_ = MutableStateFlow(RollingWindowStatistics())
101+
102+
val renderStatistics: StateFlow<RollingWindowStatistics> get() = renderStatistics_.asStateFlow()
103+
val displayStatistics: StateFlow<RollingWindowStatistics> get() = displayStatistics_.asStateFlow()
104+
105+
internal fun onDisplay(nanos: Long, frameTime: Duration) {
106+
displayStatistics_.tryEmit(displayStatistics_.value.add(nanos, frameTime))
107+
}
108+
109+
internal fun onRender(nanos: Long, frameTime: Duration) {
110+
renderStatistics_.tryEmit(renderStatistics_.value.add(nanos, frameTime))
111+
}
112+
}

src/main/java/dev/silenium/compose/gl/surface/GLSurfaceView.kt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ import java.util.concurrent.atomic.AtomicInteger
2020
import kotlin.time.Duration.Companion.milliseconds
2121
import kotlin.time.Duration.Companion.nanoseconds
2222

23+
@Composable
24+
fun rememberGLSurfaceState() = remember { GLSurfaceState() }
2325

2426
@Composable
2527
fun GLSurfaceView(
28+
state: GLSurfaceState = rememberGLSurfaceState(),
2629
modifier: Modifier = Modifier,
2730
paint: Paint = Paint(),
2831
presentMode: GLSurfaceView.PresentMode = GLSurfaceView.PresentMode.FIFO,
@@ -33,6 +36,7 @@ fun GLSurfaceView(
3336
val surfaceView = remember {
3437
val currentContext = EGLContext.fromCurrent() ?: error("No current EGL context")
3538
GLSurfaceView(
39+
state = state,
3640
parentContext = currentContext,
3741
invalidate = { invalidations++ },
3842
paint = paint,
@@ -59,7 +63,8 @@ fun GLSurfaceView(
5963
}
6064
}
6165

62-
class GLSurfaceView(
66+
class GLSurfaceView internal constructor(
67+
private val state: GLSurfaceState,
6368
private val parentContext: EGLContext,
6469
private val drawBlock: suspend GLDrawScope.() -> Unit,
6570
private val invalidate: () -> Unit = {},
@@ -103,8 +108,11 @@ class GLSurfaceView(
103108
}
104109

105110
fun display(canvas: Canvas, displayContext: DirectContext) {
111+
val t1 = System.nanoTime()
106112
fboPool?.display { displayImpl(canvas, displayContext) }
107113
invalidate()
114+
val t2 = System.nanoTime()
115+
state.onDisplay(t2, (t2 - t1).nanoseconds)
108116
}
109117

110118
private fun GLDisplayScope.displayImpl(
@@ -148,12 +156,14 @@ class GLSurfaceView(
148156
initEGL()
149157
var lastFrame: Long? = null
150158
while (isActive) {
151-
val now = System.nanoTime()
152-
val deltaTime = lastFrame?.let { now - it } ?: 0
159+
val renderStart = System.nanoTime()
160+
val deltaTime = lastFrame?.let { renderStart - it } ?: 0
153161
val waitTime = fboPool!!.render(deltaTime.nanoseconds, drawBlock) ?: continue
154162
invalidate()
155-
val renderTime = (System.nanoTime() - now).nanoseconds
156-
lastFrame = now
163+
val renderEnd = System.nanoTime()
164+
val renderTime = (renderEnd - renderStart).nanoseconds
165+
state.onRender(renderEnd, renderTime)
166+
lastFrame = renderStart
157167
try {
158168
select<Unit> {
159169
updateRequest.onReceive { }

src/test/kotlin/Main.kt

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ import androidx.compose.animation.core.LinearEasing
33
import androidx.compose.animation.core.tween
44
import androidx.compose.desktop.ui.tooling.preview.Preview
55
import androidx.compose.foundation.background
6-
import androidx.compose.foundation.layout.Box
7-
import androidx.compose.foundation.layout.aspectRatio
8-
import androidx.compose.foundation.layout.fillMaxSize
9-
import androidx.compose.foundation.layout.padding
6+
import androidx.compose.foundation.layout.*
107
import androidx.compose.material.Button
118
import androidx.compose.material.MaterialTheme
129
import androidx.compose.material.Text
@@ -19,6 +16,8 @@ import androidx.compose.ui.window.ApplicationScope
1916
import androidx.compose.ui.window.Window
2017
import androidx.compose.ui.window.awaitApplication
2118
import dev.silenium.compose.gl.surface.GLSurfaceView
19+
import dev.silenium.compose.gl.surface.Stats
20+
import dev.silenium.compose.gl.surface.rememberGLSurfaceState
2221
import kotlinx.coroutines.delay
2322
import org.lwjgl.opengles.GLES30.*
2423
import kotlin.time.Duration.Companion.milliseconds
@@ -28,7 +27,7 @@ import kotlin.time.Duration.Companion.milliseconds
2827
fun ApplicationScope.App() {
2928
MaterialTheme {
3029
Box(contentAlignment = Alignment.TopStart, modifier = Modifier.fillMaxSize().background(Color.Black)) {
31-
Text("Hello, World!", color = Color.White, style = MaterialTheme.typography.h1)
30+
val state = rememberGLSurfaceState()
3231
var targetHue by remember { mutableStateOf(0f) }
3332
val color by animateColorAsState(
3433
Color.hsl(targetHue, 1f, 0.5f, 0.1f),
@@ -41,6 +40,7 @@ fun ApplicationScope.App() {
4140
}
4241
}
4342
GLSurfaceView(
43+
state = state,
4444
modifier = Modifier.aspectRatio(1f).fillMaxSize(),
4545
) {
4646
glClearColor(color.red, color.green, color.blue, color.alpha)
@@ -52,6 +52,21 @@ fun ApplicationScope.App() {
5252
// println("Wait: $wait")
5353
// println("FPS: ${1_000_000.0 / deltaTime.inWholeMicroseconds}")
5454
}
55+
Column(modifier = Modifier.align(Alignment.TopStart).padding(4.dp)) {
56+
val display by state.displayStatistics.collectAsState()
57+
Text("Display datapoints: ${display.frameTimes.values.size}")
58+
Text("Display frame time: ${display.frameTimes.median.inWholeMicroseconds / 1000.0} ms")
59+
Text("Display frame time (99th): ${display.frameTimes.percentile(0.99).inWholeMicroseconds / 1000.0} ms")
60+
Text("Display FPS: ${display.fps.median}")
61+
Text("Display FPS (99th): ${display.fps.percentile(0.99, Stats.Percentile.LOWEST)}")
62+
63+
val render by state.renderStatistics.collectAsState()
64+
Text("Render datapoints: ${render.frameTimes.values.size}")
65+
Text("Render frame time: ${render.frameTimes.median.inWholeMicroseconds / 1000.0} ms")
66+
Text("Render frame time (99th): ${render.frameTimes.percentile(0.99).inWholeMicroseconds / 1000.0} ms")
67+
Text("Render FPS: ${render.fps.median} ms")
68+
Text("Render FPS (99th): ${render.fps.percentile(0.99, Stats.Percentile.LOWEST)}")
69+
}
5570
Button(
5671
onClick = ::exitApplication,
5772
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),

0 commit comments

Comments
 (0)