Skip to content

Commit 8b8800c

Browse files
authored
🔀 Merge pull request #50 from vinceglb/lags-26
⚡️ Prevents particle clustering
2 parents 5210ae7 + 6fa2e4e commit 8b8800c

File tree

4 files changed

+114
-14
lines changed

4 files changed

+114
-14
lines changed

confettikit/src/commonMain/kotlin/io/github/vinceglb/confettikit/core/PartySystem.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ public class PartySystem(
3232
deltaTime: Float,
3333
drawArea: CoreRect,
3434
): List<Particle> {
35-
if (enabled) {
36-
activeParticles.addAll(emitter.createConfetti(deltaTime, party, drawArea))
37-
}
38-
3935
activeParticles.forEach { it.render(deltaTime, drawArea) }
4036

4137
activeParticles.removeAll { it.isDead() }
4238

39+
if (enabled) {
40+
activeParticles.addAll(emitter.createConfetti(deltaTime, party, drawArea))
41+
activeParticles.removeAll { it.isDead() }
42+
}
43+
4344
return activeParticles.filter { it.drawParticle }.map { it.toParticle() }
4445
}
4546

confettikit/src/commonMain/kotlin/io/github/vinceglb/confettikit/core/emitter/PartyEmitter.kt

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import io.github.vinceglb.confettikit.core.models.Size
99
import io.github.vinceglb.confettikit.core.models.Vector
1010
import kotlin.math.PI
1111
import kotlin.math.cos
12+
import kotlin.math.min
1213
import kotlin.math.sin
1314
import kotlin.random.Random
1415

@@ -40,14 +41,11 @@ internal class PartyEmitter(
4041
party: Party,
4142
drawArea: CoreRect,
4243
): List<Confetti> {
43-
createParticleMs += deltaTime
44+
val safeDeltaTimeSeconds = deltaTime.coerceAtLeast(0f)
45+
val deltaTimeSecondsForEmission = limitToRemainingEmittingTime(safeDeltaTimeSeconds)
46+
val postEmissionSeconds = safeDeltaTimeSeconds - deltaTimeSecondsForEmission
4447

45-
// Initial deltaTime can't be higher than the emittingTime, if so calculate
46-
// amount of particles based on max emittingTime
47-
val emittingTime = emitterConfig.emittingTime / 1000f
48-
if (elapsedTime == 0f && deltaTime > emittingTime) {
49-
createParticleMs = emittingTime
50-
}
48+
createParticleMs += deltaTimeSecondsForEmission
5149

5250
var particles = listOf<Confetti>()
5351

@@ -56,13 +54,17 @@ internal class PartyEmitter(
5654
// Calculate how many particle to create in the elapsed time
5755
val amount: Int = (createParticleMs / emitterConfig.amountPerMs).toInt()
5856

59-
particles = (1..amount).map { createParticle(party, drawArea) }
57+
val remainderSeconds = createParticleMs % emitterConfig.amountPerMs
58+
particles = (0 until amount).map { index ->
59+
val ageSeconds = postEmissionSeconds + remainderSeconds + (amount - 1 - index) * emitterConfig.amountPerMs
60+
createParticle(party, drawArea).also { it.render(ageSeconds, drawArea) }
61+
}
6062

6163
// Reset timer and add left over time for next cycle
62-
createParticleMs %= emitterConfig.amountPerMs
64+
createParticleMs = remainderSeconds
6365
}
6466

65-
elapsedTime += deltaTime * 1000
67+
elapsedTime += safeDeltaTimeSeconds * 1000
6668
return particles
6769
}
6870

@@ -178,6 +180,14 @@ internal class PartyEmitter(
178180
}
179181
}
180182

183+
private fun limitToRemainingEmittingTime(deltaTimeSeconds: Float): Float {
184+
val emittingTimeMs = emitterConfig.emittingTime
185+
if (emittingTimeMs <= 0L) return deltaTimeSeconds
186+
187+
val remainingMs = (emittingTimeMs.toFloat() - elapsedTime).coerceAtLeast(0f)
188+
return min(deltaTimeSeconds, remainingMs / 1000f)
189+
}
190+
181191
override fun isFinished(): Boolean {
182192
return if (emitterConfig.emittingTime > 0L) {
183193
elapsedTime >= emitterConfig.emittingTime
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package io.github.vinceglb.confettikit.core.emitter
2+
3+
import io.github.vinceglb.confettikit.core.Party
4+
import io.github.vinceglb.confettikit.core.PartySystem
5+
import io.github.vinceglb.confettikit.core.Position
6+
import io.github.vinceglb.confettikit.core.Rotation
7+
import io.github.vinceglb.confettikit.core.models.CoreRect
8+
import io.github.vinceglb.confettikit.core.models.Shape
9+
import io.github.vinceglb.confettikit.core.models.Size
10+
import kotlin.test.Test
11+
import kotlin.test.assertEquals
12+
import kotlin.test.assertTrue
13+
import kotlin.time.Duration.Companion.seconds
14+
15+
class PartyEmitterFrameSkipTest {
16+
private val drawArea = CoreRect.CoreRectImpl(width = 1000f, height = 1000f)
17+
18+
private fun party(emitterConfig: EmitterConfig): Party =
19+
Party(
20+
emitter = emitterConfig,
21+
position = Position.Absolute(0f, 0f),
22+
spread = 0,
23+
speed = 0f,
24+
maxSpeed = -1f,
25+
damping = 1f,
26+
rotation = Rotation.disabled(),
27+
shapes = listOf(Shape.Square),
28+
colors = listOf(0xffffff),
29+
size = listOf(Size(sizeInDp = 8, mass = 5f, massVariance = 0f)),
30+
timeToLive = 10_000,
31+
fadeOutEnabled = false,
32+
)
33+
34+
@Test
35+
fun createsParticlesWithDifferentAgesWhenDeltaIsLarge() {
36+
val emitterConfig = Emitter(1.seconds).perSecond(amount = 10)
37+
val emitter = PartyEmitter(emitterConfig = emitterConfig, pixelDensity = 1f)
38+
39+
val created = emitter.createConfetti(deltaTime = 0.25f, party = party(emitterConfig), drawArea = drawArea)
40+
41+
assertEquals(2, created.size)
42+
val ys = created.map { it.location.y }
43+
assertTrue(ys[0] > ys[1], "expected older particle to have moved further (y0=${ys[0]}, y1=${ys[1]})")
44+
}
45+
46+
@Test
47+
fun particleSpawnedAtFrameEndIsNotAdvanced() {
48+
val emitterConfig = Emitter(1.seconds).perSecond(amount = 10)
49+
val system = PartySystem(party = party(emitterConfig), pixelDensity = 1f)
50+
51+
val particles = system.render(deltaTime = 0.1f, drawArea = drawArea)
52+
53+
assertEquals(1, particles.size)
54+
assertEquals(0f, particles[0].x)
55+
assertEquals(0f, particles[0].y)
56+
}
57+
}
58+

sample/composeApp/src/commonMain/kotlin/io/github/vinceglb/confettikit/sample/App.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,43 @@ import androidx.compose.foundation.layout.Arrangement
44
import androidx.compose.foundation.layout.Box
55
import androidx.compose.foundation.layout.Column
66
import androidx.compose.foundation.layout.fillMaxSize
7+
import androidx.compose.foundation.layout.Row
78
import androidx.compose.material3.Button
89
import androidx.compose.material3.Scaffold
10+
import androidx.compose.material3.Switch
911
import androidx.compose.material3.Text
1012
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.LaunchedEffect
1114
import androidx.compose.runtime.getValue
1215
import androidx.compose.runtime.mutableStateOf
1316
import androidx.compose.runtime.remember
1417
import androidx.compose.runtime.setValue
1518
import androidx.compose.ui.Alignment
1619
import androidx.compose.ui.Modifier
1720
import androidx.compose.ui.unit.dp
21+
import androidx.compose.animation.core.withInfiniteAnimationFrameMillis
1822
import io.github.vinceglb.confettikit.compose.ConfettiKit
23+
import kotlin.time.Duration.Companion.milliseconds
24+
import kotlin.time.TimeSource
1925

2026
@Composable
2127
fun App() {
2228
var isAnimating by remember { mutableStateOf(false) }
2329
var index by remember { mutableStateOf(0) }
30+
var simulateLag by remember { mutableStateOf(false) }
31+
32+
if (simulateLag) {
33+
LaunchedEffect(Unit) {
34+
while (true) {
35+
withInfiniteAnimationFrameMillis {
36+
val start = TimeSource.Monotonic.markNow()
37+
while (start.elapsedNow() < 40.milliseconds) {
38+
// Busy-loop to simulate main-thread jank
39+
}
40+
}
41+
}
42+
}
43+
}
2444

2545
Scaffold {
2646
Box(
@@ -39,6 +59,17 @@ fun App() {
3959
) {
4060
Text("Fiesta 🥳")
4161
}
62+
63+
Row(
64+
verticalAlignment = Alignment.CenterVertically,
65+
horizontalArrangement = Arrangement.spacedBy(8.dp),
66+
) {
67+
Text("Simulate lag")
68+
Switch(
69+
checked = simulateLag,
70+
onCheckedChange = { simulateLag = it },
71+
)
72+
}
4273
}
4374

4475
if (isAnimating) {

0 commit comments

Comments
 (0)