diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/gltf/GltfFile.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/gltf/GltfFile.kt index ca867bd6d..a2ad6123f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/gltf/GltfFile.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/gltf/GltfFile.kt @@ -12,7 +12,7 @@ import ru.hollowhorizon.hollowengine.common.utils.decodeToString import ru.hollowhorizon.hollowengine.common.utils.inflate import ru.hollowhorizon.hollowengine.common.utils.json.JsonFormat import ru.hollowhorizon.hollowengine.common.utils.nbt.ListOrSingle -import ru.hollowhorizon.hollowengine.common.utils.rl +import java.nio.file.Path import java.util.* suspend fun loadGltf(location: ResourceLocation, side: ModelSide = ModelSide.CLIENT): Result { @@ -27,7 +27,6 @@ suspend fun loadGltf(location: ResourceLocation, side: ModelSide = ModelSide.CLI else -> error("Invalid gltf file type: $type ($filePath)") } - val modelBasePath = if (filePath.contains('/')) filePath.substringBeforeLast('/') else "." gltfFile.let { m -> coroutineScope { withContext(Dispatchers.IO) { @@ -47,15 +46,14 @@ suspend fun loadGltf(location: ResourceLocation, side: ModelSide = ModelSide.CLI else -> throw IllegalStateException("Unknown data format: $uri") } return@async - } else { - "${location.namespace}:$modelBasePath/$uri" - } - it.data = Uint8Buffer(bufferUri.rl.readModelBytes(side)) + } else resolveGltfResource(location, uri) + it.data = Uint8Buffer(bufferUri.readModelBytes(side)) } }.awaitAll() m.images.filter { it.uri != null }.forEach { - if (it.uri?.startsWith("data:") == false) it.uri = - "${location.namespace}:$modelBasePath/${it.uri}" + if (it.uri?.startsWith("data:") == false) { + it.uri = resolveGltfResource(location, it.uri!!).toString() + } } m.updateReferences() } @@ -67,6 +65,14 @@ suspend fun loadGltf(location: ResourceLocation, side: ModelSide = ModelSide.CLI } } +/** Resolves a relative glTF URI against the model folder; resource paths may not contain `..` segments. */ +private fun resolveGltfResource(model: ResourceLocation, uri: String): ResourceLocation { + val parent = Path.of(model.path).parent ?: Path.of("") + val path = parent.resolve(uri).normalize() + require(!path.isAbsolute && !path.startsWith("..")) { "glTF resource escapes its namespace: $uri" } + return ResourceLocation.fromNamespaceAndPath(model.namespace, path.toString().replace('\\', '/').lowercase(Locale.ROOT)) +} + private fun ResourceLocation.readModelBytes(side: ModelSide): ByteArray = when (side) { ModelSide.CLIENT -> stream.readBytes() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/ShaderUtil.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/ShaderUtil.kt index b9414d957..42a3fe427 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/ShaderUtil.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/ShaderUtil.kt @@ -10,7 +10,6 @@ import net.minecraft.client.renderer.GameRenderer import net.minecraft.client.renderer.LightTexture import net.minecraft.client.renderer.RenderStateShard import net.minecraft.client.renderer.RenderType -import net.minecraft.client.renderer.texture.TextureManager import org.lwjgl.opengl.GL13 import ru.hollowhorizon.hollowengine.bridge.mixins.client.ShaderInstanceAccessor import ru.hollowhorizon.hollowengine.client.utils.shouldOverrideShaders @@ -48,9 +47,9 @@ inline fun drawWithShader( fun Material.packedLight(worldLight: Int): Int = if (emissive) LightTexture.FULL_BRIGHT else worldLight -fun opaqueShaderState(): RenderType = RenderType.entityCutoutNoCull(TextureManager.INTENTIONAL_MISSING_TEXTURE) +fun opaqueShaderState(): RenderType = RenderType.entityCutoutNoCull(Material.MISSING_TEXTURE) -fun translucentShaderState(): RenderType = RenderType.entityTranslucent(TextureManager.INTENTIONAL_MISSING_TEXTURE) +fun translucentShaderState(): RenderType = RenderType.entityTranslucent(Material.MISSING_TEXTURE) val batchingRenderType: Function = Util.memoize { material: Material -> val compositeState = RenderType.CompositeState.builder() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/AnimationExpressions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/AnimationExpressions.kt index fad691eb4..45d0a5a19 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/AnimationExpressions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/AnimationExpressions.kt @@ -176,6 +176,8 @@ class AnimatorEvaluationContext { var modelToWorld: TrsTransformF? = null var partialTick: Float = 0f + + var gameTicks: Long = 0L var gameTime: Float = 0f var time: Float = 0f var deltaTime: Float = 0f @@ -211,8 +213,13 @@ class AnimatorEvaluationContext { * put in [variables] under that name, or zero. */ internal fun override(name: String): Float = variables[name] ?: 0f + + /** Seconds of game time since [gameTick] on the level clock; negative while it is still ahead. */ + fun secondsSince(gameTick: Long): Float = ((gameTicks - gameTick).toFloat() + partialTick) / TICKS_PER_SECOND } +private const val TICKS_PER_SECOND = 20f + /** * Evaluates the expressions of an [Animator]. */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/AnimationLayer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/AnimationLayer.kt index 0c207e8e7..2b4e9395b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/AnimationLayer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/AnimationLayer.kt @@ -110,7 +110,7 @@ abstract class SpecLayer(initialSpec: AnimatorLayerSpec) : AnimationLayer { } override fun weight(context: AnimatorEvaluationContext): Float { - age += context.deltaTime.coerceAtLeast(0f) + age = nextAge(context) context.layerAge = age context.layerTime = time val declared = evaluator.float(spec.weight, context).coerceIn(0f, 1f) @@ -119,6 +119,9 @@ abstract class SpecLayer(initialSpec: AnimatorLayerSpec) : AnimationLayer { return declared * fadeIn } + /** The layer's age this frame: by default it grows by the frame's step. */ + protected open fun nextAge(context: AnimatorEvaluationContext): Float = age + context.deltaTime.coerceAtLeast(0f) + protected abstract fun accepts(spec: AnimatorLayerSpec): Boolean protected open fun onReconfigured(spec: AnimatorLayerSpec) = Unit @@ -126,21 +129,34 @@ abstract class SpecLayer(initialSpec: AnimatorLayerSpec) : AnimationLayer { protected fun mask(target: PoseTarget): Set = target.mask(spec.mask) } -/** Plays one clip. */ +/** + * Plays one clip. + * + * A clip with a start time plays the game time elapsed since that tick, so a late first frame catches up + * and a long stall does not lose time; a clip without one plays the animator's frame step. + */ class ClipLayer(clip: ClipAnimationLayerSpec) : SpecLayer(clip) { private val playback = ClipPlayback() private val clip: ClipAnimationLayerSpec get() = spec as ClipAnimationLayerSpec + /** Seconds since the start time that playback has already covered. */ + private var played = 0f + override val time: Float get() = playback.time override var finished: Boolean = false private set - override fun accepts(spec: AnimatorLayerSpec): Boolean = spec is ClipAnimationLayerSpec + /** Another start time is another playback, so it gets a fresh layer rather than this one's position. */ + override fun accepts(spec: AnimatorLayerSpec): Boolean = + spec is ClipAnimationLayerSpec && spec.startGameTime == clip.startGameTime + + override fun nextAge(context: AnimatorEvaluationContext): Float = + clip.startGameTime?.let { context.secondsSince(it).coerceAtLeast(0f) } ?: super.nextAge(context) override fun sample(target: PoseTarget, context: AnimatorEvaluationContext): LayerPose? { val animation = target.animations[clip.animation] ?: return null val speed = evaluator.float(clip.speed, context, 1f) - val sampleTime = playback.advance(animation.duration, clip.playMode, speed, context.deltaTime) + val sampleTime = playback.advance(animation.duration, clip.playMode, speed, step(context)) val fadeOut = fadeOutScale(context) if (fadeOut <= 0f && (clip.stopAtGameTime != null || (playback.ended && clip.removeOnEnd))) { @@ -158,6 +174,17 @@ class ClipLayer(clip: ClipAnimationLayerSpec) : SpecLayer(clip) { ) } + /** + * Seconds to play this frame. With a start time that is what the clock moved past since the last frame, + * and nothing while the start is still ahead or the clock steps back. + */ + private fun step(context: AnimatorEvaluationContext): Float { + val start = clip.startGameTime ?: return context.deltaTime + val elapsed = context.secondsSince(start) + if (elapsed <= played) return 0f + return (elapsed - played).also { played = elapsed } + } + /** * How much of the clip is left this frame: a stop request fades from the game time it was asked at, * everything else fades only once a one-shot has played out. @@ -165,7 +192,7 @@ class ClipLayer(clip: ClipAnimationLayerSpec) : SpecLayer(clip) { private fun fadeOutScale(context: AnimatorEvaluationContext): Float { clip.stopAtGameTime?.let { stoppedAt -> if (clip.fadeOut <= 0f) return 0f - val elapsed = (context.gameTime - stoppedAt) / TICKS_PER_SECOND + val elapsed = context.secondsSince(stoppedAt) return (1f - elapsed / clip.fadeOut).coerceIn(0f, 1f) } return if (clip.playMode != AnimationPlayMode.Once || clip.fadeOut <= 0f || !playback.ended) 1f @@ -268,25 +295,15 @@ internal fun wrapTime(time: Float, duration: Float, playMode: AnimationPlayMode, } AnimationPlayMode.PingPong -> { - var nextTime = time - var nextReversed = reversed - while (nextTime !in 0f..duration) { - if (nextTime > duration) { - nextTime = duration - (nextTime - duration) - nextReversed = !nextReversed - } else { - nextTime = -nextTime - nextReversed = !nextReversed - } - } - WrappedTime(nextTime, nextTime, nextReversed, ended = false) + val period = duration * 2f + val phase = (if (reversed) period - time else time).modPositive(period) + if (phase <= duration) WrappedTime(phase, phase, reversed = false, ended = false) + else WrappedTime(period - phase, period - phase, reversed = true, ended = false) } } private fun Float.modPositive(divisor: Float): Float = (this % divisor + divisor) % divisor -private const val TICKS_PER_SECOND = 20f - /** Every node of the hierarchy, indexed the way poses address them. */ fun List.byIndex(): Map = flatMap { it.walk() }.associateBy { it.definition.index } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/EntityAnimationVariables.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/EntityAnimationVariables.kt index 122bcfd5b..269734f38 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/EntityAnimationVariables.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/animator/EntityAnimationVariables.kt @@ -12,13 +12,14 @@ import kotlin.math.abs * Fills [context] with what an animation expression can read this frame. */ fun fillAnimationVariables(context: AnimatorEvaluationContext, entity: Entity?, partialTick: Float) { - val gameTime = Minecraft.getInstance().level?.gameTime?.toFloat() ?: 0f + val gameTicks = entity?.level()?.gameTime ?: Minecraft.getInstance().level?.gameTime ?: 0L context.temporaries.clear() context.entity = entity context.partialTick = partialTick - context.gameTime = gameTime + partialTick - context.time = (entity?.tickCount?.toFloat() ?: gameTime) + partialTick + context.gameTicks = gameTicks + context.gameTime = gameTicks.toFloat() + partialTick + context.time = (entity?.tickCount?.toFloat() ?: gameTicks.toFloat()) + partialTick context.data = entity?.let { AttachmentRegistry.entityDataOrNull(it)?.numericPaths() }.orEmpty() if (entity == null) return diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/manager/HollowModelManager.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/manager/HollowModelManager.kt index 3b2237cc0..87b9af03d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/manager/HollowModelManager.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/manager/HollowModelManager.kt @@ -116,6 +116,11 @@ object HollowModelManager : SimplePreparableReloadListener, update: PreparedModelUpdate, ) { + if (!RenderSystem.isOnRenderThreadOrInit()) { + RenderSystem.recordRenderCall { publish(location, flow, update) } + return + } + val swap = ModelReloadCoordinator.resolveSwap(flow.value, update, Model.EMPTY) flow.value = swap.next swap.retired?.let(::destroyLater) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/InstancingBackend.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/InstancingBackend.kt index c09876595..9e43d2a55 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/InstancingBackend.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/InstancingBackend.kt @@ -86,7 +86,7 @@ fun groupByMaterial( inline fun withInstancingRenderState(body: () -> Unit) { val activeTexture = GlStateManager._getActiveTexture() val currentVao = GL33.glGetInteger(GL33.GL_VERTEX_ARRAY_BINDING) - val currentElementArrayBuffer = GL33.glGetInteger(GL33.GL_ELEMENT_ARRAY_BUFFER_BINDING) + val currentArrayBuffer = GL33.glGetInteger(GL33.GL_ARRAY_BUFFER_BINDING) val shaderTexture0 = RenderSystem.getShaderTexture(0) val shaderTexture1 = RenderSystem.getShaderTexture(1) val shaderTexture2 = RenderSystem.getShaderTexture(2) @@ -121,7 +121,7 @@ inline fun withInstancingRenderState(body: () -> Unit) { RenderSystem.activeTexture(activeTexture) RenderSystem.glBindVertexArray(currentVao) - RenderSystem.glBindBuffer(GL33.GL_ELEMENT_ARRAY_BUFFER, currentElementArrayBuffer) + RenderSystem.glBindBuffer(GL33.GL_ARRAY_BUFFER, currentArrayBuffer) GlStateManager._glUseProgram(0) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/PipelineRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/PipelineRenderer.kt index 77536e298..6d846fc77 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/PipelineRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/PipelineRenderer.kt @@ -56,33 +56,35 @@ class PipelineRenderer(private val primitive: Primitive) : MeshRenderer { } override fun init() { - vao = GL33.glGenVertexArrays() - GL33.glBindVertexArray(vao) - - if (isDynamic) { - initDynamicBuffers() - - deformer = GpuDeformer(primitive) - deformer?.init( - dstPos = posBuffer!!.id, - dstNor = norBuffer!!.id, - dstTan = tanBuffer!!.id - ) + val previousVao = GL33.glGetInteger(GL33.GL_VERTEX_ARRAY_BINDING) + val previousArrayBuffer = GL33.glGetInteger(GL33.GL_ARRAY_BUFFER_BINDING) + try { + vao = GL33.glGenVertexArrays() GL33.glBindVertexArray(vao) - } else { - initStaticBuffers() - } - initCommonBuffers() - if (supportsInstancing) { - initInstancingBuffers() - initInstancedVao() - } + if (isDynamic) { + initDynamicBuffers() - GL33.glBindVertexArray(0) + deformer = GpuDeformer(primitive) + deformer?.init( + dstPos = posBuffer!!.id, + dstNor = norBuffer!!.id, + dstTan = tanBuffer!!.id + ) + GL33.glBindVertexArray(vao) + } else { + initStaticBuffers() + } - posBuffer?.unbind() - indexBuffer?.unbind() + initCommonBuffers() + if (supportsInstancing) { + initInstancingBuffers() + initInstancedVao() + } + } finally { + GL33.glBindVertexArray(previousVao) + GL33.glBindBuffer(GL33.GL_ARRAY_BUFFER, previousArrayBuffer) + } } private fun initStaticBuffers() { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/RenderPipeline.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/RenderPipeline.kt index 3af23826a..134e5e881 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/RenderPipeline.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/RenderPipeline.kt @@ -73,9 +73,9 @@ class ListRenderPipeline : RenderPipeline { fun renderVAO(context: RenderContext) { val activeTexture = GlStateManager._getActiveTexture() - //Получение текущих VAO и IBO + // Binding the saved VAO also restores its element buffer. val currentVAO = GL33.glGetInteger(GL33.GL_VERTEX_ARRAY_BINDING) - val currentElementArrayBuffer = GL33.glGetInteger(GL33.GL_ELEMENT_ARRAY_BUFFER_BINDING) + val currentArrayBuffer = GL33.glGetInteger(GL33.GL_ARRAY_BUFFER_BINDING) transformSkinning() @@ -110,7 +110,7 @@ class ListRenderPipeline : RenderPipeline { RenderSystem.activeTexture(activeTexture) RenderSystem.glBindVertexArray(currentVAO) - RenderSystem.glBindBuffer(GL33.GL_ELEMENT_ARRAY_BUFFER, currentElementArrayBuffer) + RenderSystem.glBindBuffer(GL33.GL_ARRAY_BUFFER, currentArrayBuffer) GlStateManager._glUseProgram(0) } @@ -118,7 +118,7 @@ class ListRenderPipeline : RenderPipeline { fun renderInstanced(context: RenderContext) { val activeTexture = GlStateManager._getActiveTexture() val currentVAO = GL33.glGetInteger(GL33.GL_VERTEX_ARRAY_BINDING) - val currentElementArrayBuffer = GL33.glGetInteger(GL33.GL_ELEMENT_ARRAY_BUFFER_BINDING) + val currentArrayBuffer = GL33.glGetInteger(GL33.GL_ARRAY_BUFFER_BINDING) GL33.glVertexAttribI2i(3, context.overlay and FFFF, context.overlay shr 16 and FFFF) GL33.glVertexAttribI2i(4, context.light and FFFF, context.light shr 16 and FFFF) @@ -148,7 +148,7 @@ class ListRenderPipeline : RenderPipeline { RenderSystem.activeTexture(activeTexture) RenderSystem.glBindVertexArray(currentVAO) - RenderSystem.glBindBuffer(GL33.GL_ELEMENT_ARRAY_BUFFER, currentElementArrayBuffer) + RenderSystem.glBindBuffer(GL33.GL_ARRAY_BUFFER, currentArrayBuffer) GlStateManager._glUseProgram(0) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/utils/VboWrapper.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/utils/VboWrapper.kt index 2cdf85383..3dda272a1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/utils/VboWrapper.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/utils/VboWrapper.kt @@ -25,8 +25,13 @@ class VboWrapper(val id: Int, val target: Int = GL33.GL_ARRAY_BUFFER) { } fun uploadData(data: IntBuffer, usage: Int = GL33.GL_STATIC_DRAW) { - bind() - GL33.glBufferData(target, data, usage) + uploadData(data, usage, target) + } + + /** Index data can be uploaded through GL_ARRAY_BUFFER, so the element buffer of whichever VAO is bound stays untouched. */ + fun uploadData(data: IntBuffer, usage: Int = GL33.GL_STATIC_DRAW, bindingTarget: Int) { + GL33.glBindBuffer(bindingTarget, id) + GL33.glBufferData(bindingTarget, data, usage) } /** @@ -55,4 +60,4 @@ fun Array.toFloatBuffer(elementsPerVertex: Int, putData: (T, FloatBuffer) } buffer.flip() return buffer -} \ No newline at end of file +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/v2/ModelAttachment.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/v2/ModelAttachment.kt index b50985424..75d0da8f6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/v2/ModelAttachment.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/v2/ModelAttachment.kt @@ -1,5 +1,6 @@ package ru.hollowhorizon.hollowengine.client.models.internal.v2 +import com.mojang.blaze3d.systems.RenderSystem import kotlinx.coroutines.flow.StateFlow import net.minecraft.resources.ResourceLocation import net.minecraft.world.entity.EquipmentSlot @@ -50,7 +51,7 @@ class ModelAttachment( private var runtimeNodes: List = emptyList() private var nodesByIndex: Map = emptyMap() private var runtimeMaterials = ModelInstanceMaterials(Model.EMPTY) - private var renderPipeline = ListRenderPipeline() + private var renderPipeline: ListRenderPipeline? = null private var target: PoseTarget? = null private var cachedBounds: Pair? = null private val modelChangeListeners = ArrayList<() -> Unit>() @@ -59,7 +60,12 @@ class ModelAttachment( val nodes: List get() = runtimeNodes val animations: Collection get() = model.animations val materials: List get() = runtimeMaterials.values - val pipeline: RenderPipeline get() = renderPipeline + /** GPU buffers are created on the render thread, the first time something draws this instance. */ + val pipeline: RenderPipeline + get() { + RenderSystem.assertOnRenderThread() + return renderPipeline ?: ListRenderPipeline().apply(this::collectCommands).also { renderPipeline = it } + } val isFrustumCullingEnabled: Boolean get() = HollowModelManager.metadata(location).frustumCulling /** @@ -138,7 +144,7 @@ class ModelAttachment( } ?: emptyList() nodesByIndex = runtimeNodes.byIndex() nodesByIndex.values.forEach(::customizeNode) - renderPipeline = ListRenderPipeline().apply(this::collectCommands) + renderPipeline = null target = null cachedBounds = null modelChangeListeners.forEach { it() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/entity/EmptyEntityRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/entity/EmptyEntityRenderer.kt index 71f982072..bdb139dbe 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/entity/EmptyEntityRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/entity/EmptyEntityRenderer.kt @@ -2,12 +2,12 @@ package ru.hollowhorizon.hollowengine.client.render.entity import net.minecraft.client.renderer.entity.EntityRenderer import net.minecraft.client.renderer.entity.EntityRendererProvider -import net.minecraft.client.renderer.texture.TextureManager +import net.minecraft.client.renderer.texture.MissingTextureAtlasSprite import net.minecraft.resources.ResourceLocation import net.minecraft.world.entity.Entity open class EmptyEntityRenderer(context: EntityRendererProvider.Context) : EntityRenderer(context) { override fun getTextureLocation(entity: Entity): ResourceLocation? { - return TextureManager.INTENTIONAL_MISSING_TEXTURE + return MissingTextureAtlasSprite.getLocation() } -} \ No newline at end of file +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/BuiltinComponentEditors.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/BuiltinComponentEditors.kt index d6fb512e1..fcd700abd 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/BuiltinComponentEditors.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/BuiltinComponentEditors.kt @@ -118,7 +118,13 @@ private fun AnimationsExtras(scope: ComponentEditorScope) { val next = if (playing) { component.withoutClip(name) } else { - component.withClip(ClipAnimationLayerSpec(animation = name, playMode = AnimationPlayMode.Loop)) + component.withClip( + ClipAnimationLayerSpec( + animation = name, + playMode = AnimationPlayMode.Loop, + startGameTime = Minecraft.getInstance().level?.gameTime, + ) + ) } ComponentJson.encode(next)?.let(scope::replace) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxMeshRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxMeshRenderer.kt index 509dc09e3..88d13404d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxMeshRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxMeshRenderer.kt @@ -192,7 +192,7 @@ object VfxMeshRenderer { private fun drawInstanced(order: List, view: VfxView, glow: Boolean) { val engine = ModShaders.VFX_MESH ?: return val previousVao = GL33.glGetInteger(GL33.GL_VERTEX_ARRAY_BINDING) - val previousBuffer = GL33.glGetInteger(GL33.GL_ELEMENT_ARRAY_BUFFER_BINDING) + val previousBuffer = GL33.glGetInteger(GL33.GL_ARRAY_BUFFER_BINDING) try { GL33.glDepthFunc(GL33.GL_LEQUAL) order.forEach { batch -> @@ -223,7 +223,7 @@ object VfxMeshRenderer { VfxMaterialStates.restore() GlStateManager._glUseProgram(0) RenderSystem.glBindVertexArray(previousVao) - RenderSystem.glBindBuffer(GL33.GL_ELEMENT_ARRAY_BUFFER, previousBuffer) + RenderSystem.glBindBuffer(GL33.GL_ARRAY_BUFFER, previousBuffer) } } @@ -254,7 +254,7 @@ object VfxMeshRenderer { val indices = VboWrapper.createElementBuffer().apply { val data = BufferUtils.createIntBuffer(geometry.indices.size) data.put(geometry.indices).flip() - uploadData(data) + uploadData(data, bindingTarget = GL33.GL_ARRAY_BUFFER) } GpuMesh(vertices, indices, geometry.indices.size) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadRenderer.kt index 10a7a5552..67dacfcce 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadRenderer.kt @@ -104,7 +104,7 @@ object VfxQuadRenderer { /** Runs [body] with the quad buffers bound, after uploading [upload] instances when there are any. */ private inline fun withInstanceState(upload: Int, body: () -> Unit) { val previousVao = GL33.glGetInteger(GL33.GL_VERTEX_ARRAY_BINDING) - val previousBuffer = GL33.glGetInteger(GL33.GL_ELEMENT_ARRAY_BUFFER_BINDING) + val previousBuffer = GL33.glGetInteger(GL33.GL_ARRAY_BUFFER_BINDING) val previousTexture = GL33.glGetInteger(GL33.GL_ACTIVE_TEXTURE) ensureBuffers() @@ -116,7 +116,7 @@ object VfxQuadRenderer { GlStateManager._glUseProgram(0) RenderSystem.activeTexture(previousTexture) RenderSystem.glBindVertexArray(previousVao) - RenderSystem.glBindBuffer(GL33.GL_ELEMENT_ARRAY_BUFFER, previousBuffer) + RenderSystem.glBindBuffer(GL33.GL_ARRAY_BUFFER, previousBuffer) } } @@ -159,7 +159,7 @@ object VfxQuadRenderer { val indices = BufferUtils.createIntBuffer(6) indices.put(0).put(1).put(2).put(0).put(2).put(3) indices.flip() - uploadData(indices) + uploadData(indices, bindingTarget = GL33.GL_ARRAY_BUFFER) } instanceBuffer = VboWrapper.createArrayBuffer() instanceCapacity = 0 diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/Animator.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/Animator.kt index c466d1efc..47cdbb7a8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/Animator.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/Animator.kt @@ -71,6 +71,7 @@ data class ClipAnimationLayerSpec( val removeOnEnd: Boolean = playMode == AnimationPlayMode.Once, val removeAtGameTime: Long? = null, val stopAtGameTime: Long? = null, + val startGameTime: Long? = null, ) : AnimatorLayerSpec() { override fun withCommon( id: String, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcAnimationTransitionPacket.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcAnimationTransitionPacket.kt index 16c45d7a7..e37d3d95e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcAnimationTransitionPacket.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcAnimationTransitionPacket.kt @@ -78,15 +78,17 @@ object NpcAnimationRuntime { val updated = to ?.takeIf(String::isNotBlank) ?.let { animation -> + val id = "npc:$animation" withoutOld.withClip( ClipAnimationLayerSpec( - id = "npc:$animation", + id = id, animation = animation, playMode = playMode, fadeIn = fadeIn, fadeOut = fadeOut, removeOnEnd = playMode == AnimationPlayMode.Once, removeAtGameTime = completionGameTime(gameTime, model, animation, playMode, fadeOut), + startGameTime = withoutOld.continuedStart(id, playMode) ?: gameTime, ) ) } ?: withoutOld @@ -141,6 +143,15 @@ object NpcAnimationRuntime { } } + /** + * Where a repeating clip that is already playing started, so playing it again carries on instead of + * jumping back to the first frame. A one-shot always starts over. + */ + private fun AnimationsComponent.continuedStart(id: String, playMode: AnimationPlayMode): Long? { + if (playMode == AnimationPlayMode.Once) return null + return clips.firstOrNull { it.id == id && it.playMode == playMode && it.stopAtGameTime == null }?.startGameTime + } + private fun AnimationsComponent.fadeOutClip( gameTime: Long, animation: String, diff --git a/runtime/src/test/kotlin/AnimatorRuntimeTests.kt b/runtime/src/test/kotlin/AnimatorRuntimeTests.kt index a44499b85..42b873a2c 100644 --- a/runtime/src/test/kotlin/AnimatorRuntimeTests.kt +++ b/runtime/src/test/kotlin/AnimatorRuntimeTests.kt @@ -16,6 +16,7 @@ import ru.hollowhorizon.hollowengine.client.models.bedrock.BedrockContext import ru.hollowhorizon.hollowengine.common.utils.math.TrsTransformF import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f import ru.hollowhorizon.hollowengine.common.utils.rl +import kotlin.math.floor import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -96,6 +97,90 @@ class AnimatorRuntimeTests { assertFalse(state.ended) } + @Test + fun `ping pong folds a step spanning several passes`() { + val state = ClipPlayback() + + assertEquals(0.75f, state.advance(1f, AnimationPlayMode.PingPong, 1f, 5.25f), 0.0001f) + assertTrue(state.reversed) + + assertEquals(0.75f, state.advance(1f, AnimationPlayMode.PingPong, 1f, 3.5f), 0.0001f) + assertFalse(state.reversed) + } + + /** + * The server and a client that first saw the clip late pose the same frame on the same tick, however + * often each of them sampled it and however old the world is. + */ + @Test + fun `clip with a start time plays the game time elapsed since it`() { + val start = 100_000_000L + val playing = clip(id = "npc:wave", animation = "wave", playMode = AnimationPlayMode.Loop) + .copy(startGameTime = start) + val target = PoseTarget(emptyMap(), mapOf("wave" to waveAnimation())) + val everyFrame = ModelAnimator().apply { configure(null, AnimationsComponent(clips = listOf(playing))) } + val lateJoiner = ModelAnimator().apply { configure(null, AnimationsComponent(clips = listOf(playing))) } + + // Thirty ticks at sixty frames a second. + repeat(91) { frame -> everyFrame.applyTo(target, clockAt(start, frame / 3f)) } + lateJoiner.applyTo(target, clockAt(start, 30f)) + + assertEquals(0.5f, everyFrame.layerTime(playing.id)!!, 0.0001f) + assertEquals(0.5f, lateJoiner.layerTime(playing.id)!!, 0.0001f) + } + + /** A client's clock runs behind the server's, so it can see a clip before the tick the clip starts on. */ + @Test + fun `clip waits while its start is still ahead of the clock`() { + val playing = clip(id = "npc:wave", animation = "wave", playMode = AnimationPlayMode.Loop) + .copy(startGameTime = 100L) + val target = PoseTarget(emptyMap(), mapOf("wave" to waveAnimation())) + val animator = ModelAnimator().apply { configure(null, AnimationsComponent(clips = listOf(playing))) } + + animator.applyTo(target, clockAt(90L, 0f)) + assertEquals(0f, animator.layerTime(playing.id)!!, 0.0001f) + + animator.applyTo(target, clockAt(100L, 5f)) + assertEquals(0.25f, animator.layerTime(playing.id)!!, 0.0001f) + } + + @Test + fun `a new start time restarts a clip with the same id`() { + val first = clip(id = "npc:wave", animation = "wave", playMode = AnimationPlayMode.Loop) + .copy(startGameTime = 0L) + val target = PoseTarget(emptyMap(), mapOf("wave" to waveAnimation())) + val animator = ModelAnimator().apply { configure(null, AnimationsComponent(clips = listOf(first))) } + animator.applyTo(target, clockAt(0L, 10f)) + + animator.configure(null, AnimationsComponent(clips = listOf(first.copy(fadeOut = 0.5f)))) + animator.applyTo(target, clockAt(0L, 10f)) + assertEquals(0.5f, animator.layerTime(first.id)!!, 0.0001f) + + animator.configure(null, AnimationsComponent(clips = listOf(first.copy(startGameTime = 10L)))) + animator.applyTo(target, clockAt(0L, 10f)) + assertEquals(0f, animator.layerTime(first.id)!!, 0.0001f) + } + + /** At this clock a float game time is eight ticks coarse, which would put the fade far off. */ + @Test + fun `stop fade is measured exactly on an old world clock`() { + val start = 100_000_000L + val node = testNode() + val target = PoseTarget(mapOf(node.definition.index to node), mapOf("wave" to waveAnimation())) + val stopping = clip(id = "npc:wave", animation = "wave", playMode = AnimationPlayMode.Loop).copy( + startGameTime = start, + stopAtGameTime = start + 10, + fadeOut = 0.5f, + removeOnEnd = false, + ) + val animator = ModelAnimator().apply { configure(null, AnimationsComponent(clips = listOf(stopping))) } + + node.resetPose() + animator.applyTo(target, clockAt(start + 10, 5f)) + + assertEquals(0.5f, node.transform.translation.x, 0.0001f) + } + @Test fun `component helpers replace and remove layers by stable ids`() { val first = clip(id = "manual:idle", animation = "idle") @@ -534,9 +619,12 @@ private fun animationContext( time: Float = 0f, values: Map = emptyMap(), ) = AnimatorEvaluationContext().also { context -> + val clock = values["game_time"] ?: time context.deltaTime = deltaTime context.time = time - context.gameTime = values["game_time"] ?: time + context.gameTime = clock + context.gameTicks = floor(clock).toLong() + context.partialTick = clock - floor(clock) context.horizontalSpeed = values["horizontal_speed"] ?: 0f context.signedHorizontalSpeed = values["movement_animation_speed"] ?: 0f context.headBodyYawDelta = values["head_body_y_delta"] ?: 0f @@ -561,6 +649,14 @@ private fun ModelAnimator.step(node: RuntimeNode, seconds: Float) { private var clockTicks = 0f +/** A context [ticksAfter] ticks past [gameTick] on the level clock, split into whole ticks and a fraction. */ +private fun clockAt(gameTick: Long, ticksAfter: Float) = AnimatorEvaluationContext().also { context -> + val whole = floor(ticksAfter) + context.gameTicks = gameTick + whole.toLong() + context.partialTick = ticksAfter - whole + context.gameTime = context.gameTicks.toFloat() + context.partialTick +} + private fun waveAnimation(): AnimationClip = AnimationClip( name = "wave",