refactor particle renderer, tests

This commit is contained in:
Moritz Zwerger 2023-12-18 16:17:05 +01:00
parent ffcbd93813
commit 77ba81b4b7
No known key found for this signature in database
GPG Key ID: 5CAD791931B09AC4
6 changed files with 383 additions and 122 deletions

View File

@ -0,0 +1,150 @@
/*
* Minosoft
* Copyright (C) 2020-2023 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.gui.rendering.particle
import de.bixilon.kotlinglm.vec3.Vec3d
import de.bixilon.kutil.observer.DataObserver
import de.bixilon.kutil.reflection.ReflectionUtil.forceSet
import de.bixilon.minosoft.data.registries.identified.Namespaces.minosoft
import de.bixilon.minosoft.data.registries.particle.ParticleType
import de.bixilon.minosoft.data.registries.particle.data.ParticleData
import de.bixilon.minosoft.gui.rendering.RenderContext
import de.bixilon.minosoft.gui.rendering.RenderingStates
import de.bixilon.minosoft.gui.rendering.camera.Camera
import de.bixilon.minosoft.gui.rendering.light.RenderLight
import de.bixilon.minosoft.gui.rendering.particle.types.Particle
import de.bixilon.minosoft.gui.rendering.system.dummy.DummyRenderSystem
import de.bixilon.minosoft.gui.rendering.system.dummy.texture.DummyTextureManager
import de.bixilon.minosoft.gui.rendering.util.vec.vec3.Vec3dUtil.EMPTY
import de.bixilon.minosoft.protocol.network.connection.play.ConnectionTestUtil.createConnection
import de.bixilon.minosoft.protocol.network.connection.play.PlayConnection
import de.bixilon.minosoft.test.ITUtil.allocate
import org.testng.Assert.assertEquals
import org.testng.Assert.assertFalse
import org.testng.annotations.Test
@Test(groups = ["particle"])
class ParticleRendererTest {
private fun create(): ParticleRenderer {
val context = RenderContext::class.java.allocate()
context::connection.forceSet(createConnection(1))
context::state.forceSet(DataObserver(RenderingStates.RUNNING))
context::system.forceSet(DummyRenderSystem(context))
context::textures.forceSet(DummyTextureManager(context))
context::light.forceSet(RenderLight(context))
context::camera.forceSet(Camera(context))
val renderer = ParticleRenderer(context.connection, context)
return renderer
}
private fun ParticleRenderer.draw() {
prePrepareDraw()
prepareDrawAsync()
postPrepareDraw()
}
fun setup() {
create()
}
fun `draw once`() {
val renderer = create()
assertEquals(renderer.size, 0)
val particle = TestParticle(renderer.context.connection)
renderer += particle
renderer.draw()
assertEquals(particle.vertices, 1)
assertEquals(particle.tryTicks, 1)
assertFalse(particle.dead)
assertEquals(renderer.size, 1)
}
fun `draw twice`() {
val renderer = create()
val particle = TestParticle(renderer.context.connection)
renderer += particle
renderer.draw(); renderer.draw()
assertEquals(particle.vertices, 2)
assertEquals(particle.tryTicks, 2)
assertEquals(renderer.size, 1)
}
fun kill() {
val renderer = create()
val particle = TestParticle(renderer.context.connection)
renderer += particle
renderer.draw(); renderer.draw()
particle.dead = true
renderer.draw()
assertEquals(particle.vertices, 2)
assertEquals(particle.tryTicks, 2)
assertEquals(renderer.size, 0)
}
fun `add 2 particles`() {
val renderer = create()
assertEquals(renderer.size, 0)
val a = TestParticle(renderer.context.connection)
val b = TestParticle(renderer.context.connection)
renderer += a; renderer += b
assertEquals(renderer.size, 0) // queue not updated yet
renderer.draw()
assertEquals(renderer.size, 2)
assertEquals(a.vertices, 1); assertEquals(a.tryTicks, 1)
assertEquals(b.vertices, 1); assertEquals(b.tryTicks, 1)
}
fun `discard with maxAmount`() {
val renderer = create()
assertEquals(renderer.size, 0)
renderer.maxAmount = 1
val a = TestParticle(renderer.context.connection)
val b = TestParticle(renderer.context.connection)
renderer += a; renderer += b
assertEquals(renderer.size, 0) // queue not updated yet
renderer.draw()
assertEquals(renderer.size, 1)
assertEquals(a.vertices, 1); assertEquals(a.tryTicks, 1)
assertEquals(b.vertices, 0); assertEquals(b.tryTicks, 0)
}
// TODO: queue, maxAmount, auto ticking
private class TestParticle(connection: PlayConnection) : Particle(connection, Vec3d.EMPTY, Vec3d.EMPTY, DATA) {
var vertices = 0
var tryTicks = 0
init {
maxAge = 10
}
override fun tryTick(time: Long) {
tryTicks++
}
override fun addVertex(mesh: ParticleMesh, translucentMesh: ParticleMesh, time: Long) {
vertices++
}
companion object {
val DATA = ParticleData(ParticleType(minosoft("test"), emptyList(), false, null))
}
}
}

View File

@ -0,0 +1,30 @@
/*
* Minosoft
* Copyright (C) 2020-2023 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.gui.rendering.particle
import de.bixilon.kutil.concurrent.lock.simple.SimpleLock
import de.bixilon.minosoft.gui.rendering.particle.types.Particle
class ParticleList(maxAmount: Int) {
val particles: MutableList<Particle> = ArrayList(maxAmount)
val lock = SimpleLock()
val size get() = particles.size
fun clear() {
lock.lock()
particles.clear()
lock.unlock()
}
}

View File

@ -0,0 +1,59 @@
/*
* Minosoft
* Copyright (C) 2020-2023 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.gui.rendering.particle
import de.bixilon.kutil.concurrent.lock.simple.SimpleLock
import de.bixilon.minosoft.gui.rendering.particle.types.Particle
class ParticleQueue(val renderer: ParticleRenderer) {
private val lock = SimpleLock()
private val queue: MutableList<Particle> = ArrayList(QUEUE_CAPACITY)
operator fun plusAssign(particle: Particle) = queue(particle)
fun queue(particle: Particle) {
lock.lock()
val size = queue.size
if (size > QUEUE_CAPACITY || renderer.size + size > renderer.maxAmount) {
// already overloaded, ignore
lock.unlock()
return
}
queue += particle
lock.unlock()
}
fun clear() {
lock.lock()
queue.clear()
lock.unlock()
}
fun add(list: MutableList<Particle>) {
if (queue.isEmpty()) return
lock.lock()
while (queue.isNotEmpty() && list.size < renderer.maxAmount) {
list.add(queue.removeFirst())
}
lock.unlock()
}
companion object {
const val QUEUE_CAPACITY = 1000
}
}

View File

@ -14,12 +14,8 @@
package de.bixilon.minosoft.gui.rendering.particle
import de.bixilon.kotlinglm.vec3.Vec3
import de.bixilon.kutil.concurrent.lock.simple.SimpleLock
import de.bixilon.kutil.concurrent.schedule.RepeatedTask
import de.bixilon.kutil.concurrent.schedule.TaskScheduler
import de.bixilon.kutil.latch.AbstractLatch
import de.bixilon.kutil.observer.DataObserver.Companion.observe
import de.bixilon.kutil.time.TimeUtil.millis
import de.bixilon.minosoft.data.registries.identified.Namespaces.minosoft
import de.bixilon.minosoft.data.world.particle.AbstractParticleRenderer
import de.bixilon.minosoft.gui.rendering.RenderContext
@ -35,10 +31,7 @@ import de.bixilon.minosoft.gui.rendering.system.base.layer.TranslucentLayer
import de.bixilon.minosoft.gui.rendering.system.base.phases.SkipAll
import de.bixilon.minosoft.modding.event.listener.CallbackEventListener.Companion.listen
import de.bixilon.minosoft.protocol.network.connection.play.PlayConnection
import de.bixilon.minosoft.protocol.network.connection.play.PlayConnectionStates
import de.bixilon.minosoft.protocol.network.connection.play.PlayConnectionStates.Companion.disconnected
import de.bixilon.minosoft.protocol.packets.s2c.play.block.chunk.ChunkUtil.isInViewDistance
import de.bixilon.minosoft.protocol.protocol.ProtocolDefinition
import de.bixilon.minosoft.util.collections.floats.BufferedArrayFloatList
@ -53,49 +46,33 @@ class ParticleRenderer(
private val translucentShader = renderSystem.createShader(minosoft("particle")) { ParticleShader(it) }
// There is no opaque mesh because it is simply not needed (every particle has transparency)
private var mesh = ParticleMesh(context, BufferedArrayFloatList(MAXIMUM_AMOUNT * ParticleMesh.ParticleMeshStruct.FLOATS_PER_VERTEX))
private var translucentMesh = ParticleMesh(context, BufferedArrayFloatList(MAXIMUM_AMOUNT * ParticleMesh.ParticleMeshStruct.FLOATS_PER_VERTEX))
var mesh = ParticleMesh(context, BufferedArrayFloatList(profile.maxAmount * ParticleMesh.ParticleMeshStruct.FLOATS_PER_VERTEX))
var translucentMesh = ParticleMesh(context, BufferedArrayFloatList(profile.maxAmount * ParticleMesh.ParticleMeshStruct.FLOATS_PER_VERTEX))
private val particlesLock = SimpleLock()
private var particles: MutableList<Particle> = mutableListOf()
private var particleQueueLock = SimpleLock()
private var particleQueue: MutableList<Particle> = mutableListOf()
val particles = ParticleList(profile.maxAmount)
val queue = ParticleQueue(this)
val ticker = ParticleTicker(this)
private var matrixUpdate = true
private lateinit var particleTask: RepeatedTask
override val skipAll: Boolean
get() = !enabled
private var enabled = true
var enabled = true
set(value) {
if (!value) {
particlesLock.lock()
particles.clear()
particlesLock.unlock()
particleQueueLock.lock()
particleQueue.clear()
particleQueueLock.unlock()
queue.clear()
}
field = value
}
private var maxAmount = MAXIMUM_AMOUNT
var maxAmount = MAXIMUM_AMOUNT
set(value) {
check(value > 1) { "Can not have negative particle max amount" }
particlesLock.lock()
while (particles.size > value) {
particles.removeAt(0)
if (value < 0) throw IllegalStateException("Can not set negative amount of particles!")
if (value < field) {
removeAllParticles()
}
val particlesSize = particles.size
particlesLock.unlock()
particleQueueLock.lock()
while (particlesSize + particleQueue.size > value) {
particleQueue.removeAt(0)
}
particleQueueLock.unlock()
field = value
}
@ -107,90 +84,45 @@ class ParticleRenderer(
layers.register(TranslucentLayer, translucentShader, this::drawTranslucent)
}
private fun loadTextures() {
for (particle in connection.registries.particleType) {
for (file in particle.textures) {
context.textures.static.create(file)
}
}
}
override fun init(latch: AbstractLatch) {
profile::maxAmount.observe(this, true) { maxAmount = minOf(it, MAXIMUM_AMOUNT) }
profile::enabled.observe(this, true) { enabled = it }
connection.events.listen<CameraMatrixChangeEvent> {
matrixUpdate = true
}
connection.events.listen<CameraMatrixChangeEvent> { matrixUpdate = true }
mesh.load()
translucentMesh.load()
for (particle in connection.registries.particleType) {
for (resourceLocation in particle.textures) {
context.textures.static.create(resourceLocation)
}
}
loadTextures()
DefaultParticleBehavior.register(connection, this)
}
override fun postInit(latch: AbstractLatch) {
shader.load()
translucentShader.load()
ticker.init()
connection.world.particle = this
particleTask = RepeatedTask(ProtocolDefinition.TICK_TIME, maxDelay = ProtocolDefinition.TICK_TIME / 2) {
if (!context.state.running || !enabled || connection.state != PlayConnectionStates.PLAYING) {
return@RepeatedTask
}
val cameraPosition = connection.player.physics.positionInfo.chunkPosition
val particleViewDistance = connection.world.view.particleViewDistance
particlesLock.lock()
try {
val time = millis()
val iterator = particles.iterator()
for (particle in iterator) {
if (!particle.chunkPosition.isInViewDistance(particleViewDistance, cameraPosition)) { // ToDo: Check fog distance
particle.dead = true
iterator.remove()
} else if (particle.dead) {
iterator.remove()
} else {
particle.tryTick(time)
}
}
particleQueueLock.lock()
particles += particleQueue
particleQueue.clear()
particleQueueLock.unlock()
} finally {
particlesLock.unlock()
}
}
TaskScheduler += particleTask
connection::state.observe(this) {
if (!it.disconnected) {
return@observe
}
TaskScheduler -= particleTask
}
}
override fun addParticle(particle: Particle) {
if (!context.state.running || !enabled) {
return
}
val particleCount = particles.size + particleQueue.size
if (particleCount >= maxAmount) {
return
}
if (!particle.chunkPosition.isInViewDistance(connection.world.view.particleViewDistance, connection.player.physics.positionInfo.chunkPosition)) {
particle.dead = true
return
}
particle.tryTick(millis())
particleQueueLock.lock()
particleQueue += particle
particleQueueLock.unlock()
queue += particle
}
private fun updateShaders() {
@ -214,34 +146,16 @@ class ParticleRenderer(
translucentMesh.unload()
}
override fun prepareDrawAsync() {
private fun prepareMesh() {
mesh.data.clear()
translucentMesh.data.clear()
mesh = ParticleMesh(context, mesh.data)
translucentMesh = ParticleMesh(context, translucentMesh.data)
}
particlesLock.acquire()
val start = millis()
var time = start
for ((index, particle) in particles.withIndex()) {
particle.tryTick(time)
if (particle.dead) {
continue
}
particle.addVertex(mesh, translucentMesh, time)
if (index % 1000 == 0) {
time = millis()
if (time - start > MAX_FRAME_TIME) {
// particles are heavily lagging out the game, reducing it.
// TODO: introduce slow particle mode and optimize out slow particles
break
}
}
}
particlesLock.release()
override fun prepareDrawAsync() {
prepareMesh()
ticker.tick(true)
}
override fun postPrepareDraw() {
@ -258,18 +172,12 @@ class ParticleRenderer(
}
override fun removeAllParticles() {
particlesLock.lock()
particles.clear()
particlesLock.unlock()
particleQueueLock.lock()
particleQueue.clear()
particleQueueLock.unlock()
queue.clear()
}
companion object : RendererBuilder<ParticleRenderer> {
const val MAXIMUM_AMOUNT = 50000
const val MAX_FRAME_TIME = 5
override fun build(connection: PlayConnection, context: RenderContext): ParticleRenderer? {
if (connection.profiles.particle.skipLoading) {

View File

@ -0,0 +1,114 @@
/*
* Minosoft
* Copyright (C) 2020-2023 Moritz Zwerger
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This software is not affiliated with Mojang AB, the original developer of Minecraft.
*/
package de.bixilon.minosoft.gui.rendering.particle
import de.bixilon.kutil.concurrent.schedule.RepeatedTask
import de.bixilon.kutil.concurrent.schedule.TaskScheduler
import de.bixilon.kutil.exception.ExceptionUtil.ignoreAll
import de.bixilon.kutil.observer.DataObserver.Companion.observe
import de.bixilon.kutil.time.TimeUtil.millis
import de.bixilon.minosoft.data.world.positions.ChunkPosition
import de.bixilon.minosoft.gui.rendering.particle.types.Particle
import de.bixilon.minosoft.protocol.network.connection.play.PlayConnectionStates
import de.bixilon.minosoft.protocol.packets.s2c.play.block.chunk.ChunkUtil.isInViewDistance
import de.bixilon.minosoft.protocol.protocol.ProtocolDefinition
class ParticleTicker(val renderer: ParticleRenderer) {
private val particles = renderer.particles
private val context = renderer.context
private var task: RepeatedTask? = null
private fun canTick(): Boolean {
if (context.connection.state != PlayConnectionStates.PLAYING) return false
if (!renderer.enabled) return false
if (!context.state.running) return false
return true
}
private fun Particle.tick(viewDistance: Int, cameraPosition: ChunkPosition, millis: Long) {
if (!chunkPosition.isInViewDistance(viewDistance, cameraPosition)) { // ToDo: Check fog distance
dead = true
}
if (dead) return
ignoreAll { tryTick(millis) }
}
fun tick(collect: Boolean) {
if (!canTick()) return
val camera = context.connection.camera.entity.physics.positionInfo
val cameraPosition = camera.chunkPosition
val viewDistance = context.connection.world.view.particleViewDistance
val start = millis()
var time = start
particles.lock.lock()
renderer.queue.add(particles.particles)
val iterator = particles.particles.iterator()
var index = 0
for (particle in iterator) {
particle.tick(viewDistance, cameraPosition, time)
if (particle.dead) {
iterator.remove()
continue
}
if (collect) {
particle.addVertex(renderer.mesh, renderer.translucentMesh, time)
}
if (index % 1000 == 0) {
// check periodically if time is exceeded
time = millis()
if (time - start > MAX_TICK_TIME) {
break
}
}
index++
}
renderer.queue.add(particles.particles)
particles.lock.unlock()
}
private fun unregister() {
val task = this.task ?: return
TaskScheduler -= task
this.task = null
}
private fun register() {
if (this.task != null) unregister()
val task = RepeatedTask(ProtocolDefinition.TICK_TIME, maxDelay = ProtocolDefinition.TICK_TIME / 2) { tick(false) }
this.task = task
TaskScheduler += task
}
fun init() {
context.connection::state.observe(this) {
unregister()
if (it == PlayConnectionStates.PLAYING) {
register()
}
}
}
companion object {
const val MAX_TICK_TIME = 5
}
}

View File

@ -180,7 +180,7 @@ abstract class Particle(
forceMove(velocity)
}
fun tryTick(time: Long) {
open fun tryTick(time: Long) {
if (dead) {
return
}