From 4b5abee946568e1a01447473c667e770beba2704 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 6 Mar 2026 20:38:22 +0700 Subject: [PATCH 01/72] Fix mesh stretching --- .../CrossfadeAnimationItemInstance.kt | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/blazerod/render/main/animation/CrossfadeAnimationItemInstance.kt b/blazerod/render/main/animation/CrossfadeAnimationItemInstance.kt index e9cabd8e..60b72a9c 100644 --- a/blazerod/render/main/animation/CrossfadeAnimationItemInstance.kt +++ b/blazerod/render/main/animation/CrossfadeAnimationItemInstance.kt @@ -14,7 +14,8 @@ import kotlin.math.min class CrossfadeAnimationItemPendingValues( val sourcePending: AnimationItemPendingValues, val targetPending: AnimationItemPendingValues, - var weight: Float + var weight: Float, + var rootWeight: Float ) : AnimationItemPendingValues class CrossfadeAnimationState( @@ -66,8 +67,9 @@ class CrossfadeAnimationItemInstance( val targetPending = targetInstance.update(context, crossfadeState.targetState) val weight = min(1f, max(0f, crossfadeState.elapsedTime / crossfadeState.duration)) + val rootWeight = min(1f, max(0f, crossfadeState.elapsedTime / 0.05f)) - return CrossfadeAnimationItemPendingValues(sourcePending, targetPending, weight) + return CrossfadeAnimationItemPendingValues(sourcePending, targetPending, weight, rootWeight) } @Suppress("UNCHECKED_CAST", "NOTHING_TO_INLINE") @@ -81,8 +83,8 @@ class CrossfadeAnimationItemInstance( val modelImpl = instance as ModelInstanceImpl val weight = crossfadeValues.weight + val rootWeight = crossfadeValues.rootWeight - // If the crossfade is complete, just apply the target animation directly if (weight >= 1f) { targetInstance.apply(modelImpl, crossfadeValues.targetPending) return @@ -91,7 +93,6 @@ class CrossfadeAnimationItemInstance( val sourcePendingImpl = crossfadeValues.sourcePending as AnimationItemPendingValuesImpl val targetPendingImpl = crossfadeValues.targetPending as AnimationItemPendingValuesImpl - // Fast path: if weight is 0, just apply source if (weight <= 0f) { sourceInstance.apply(modelImpl, crossfadeValues.sourcePending) return @@ -121,16 +122,19 @@ class CrossfadeAnimationItemInstance( appliedNodes.add(key) + // Check if this is a Root node (no parent) + val isRoot = modelImpl.scene.nodes[nodeIdx].parent == null + val currentWeight = if (isRoot && tChannel is AnimationChannelItem.TranslationItem) rootWeight else weight + when (tChannel) { is AnimationChannelItem.TranslationItem -> { val tVec = tVal as Vector3f if (sVal != null) { val sVec = sVal as Vector3f - tVec.lerp(sVec, 1f - weight) // if weight=1, tVec unchanged. if weight=0, tVec=sVec. + tVec.lerp(sVec, 1f - currentWeight) } else { - // interpolate from identity (0,0,0) val sVec = Vector3f(0f, 0f, 0f) - tVec.lerp(sVec, 1f - weight) + tVec.lerp(sVec, 1f - currentWeight) } tChannel.applyUnsafe(modelImpl, tVec) } @@ -138,11 +142,10 @@ class CrossfadeAnimationItemInstance( val tQuat = tVal as Quaternionf if (sVal != null) { val sQuat = sVal as Quaternionf - tQuat.slerp(sQuat, 1f - weight) + tQuat.slerp(sQuat, 1f - currentWeight) } else { - // interpolate from identity val sQuat = Quaternionf() - tQuat.slerp(sQuat, 1f - weight) + tQuat.slerp(sQuat, 1f - currentWeight) } tChannel.applyUnsafe(modelImpl, tQuat) } @@ -150,16 +153,15 @@ class CrossfadeAnimationItemInstance( val tVec = tVal as Vector3f if (sVal != null) { val sVec = sVal as Vector3f - tVec.lerp(sVec, 1f - weight) + tVec.lerp(sVec, 1f - currentWeight) } else { - // interpolate from identity (1,1,1) val sVec = Vector3f(1f, 1f, 1f) - tVec.lerp(sVec, 1f - weight) + tVec.lerp(sVec, 1f - currentWeight) } tChannel.applyUnsafe(modelImpl, tVec) } else -> { - if (weight >= 0.5f) { + if (currentWeight >= 0.5f) { tChannel.applyUnsafe(modelImpl, tVal) } else if (sVal != null) { for (j in sourceChannels.indices) { @@ -182,27 +184,31 @@ class CrossfadeAnimationItemInstance( if (!appliedNodes.contains(key)) { val sVal = sourcePendingImpl.pendingValues[i] + + val isRoot = modelImpl.scene.nodes[nodeIdx].parent == null + val currentWeight = if (isRoot && sChannel is AnimationChannelItem.TranslationItem) rootWeight else weight + when (sChannel) { is AnimationChannelItem.TranslationItem -> { val sVec = sVal as Vector3f val tVec = Vector3f(0f, 0f, 0f) - sVec.lerp(tVec, weight) // if weight=1, sVec=(0,0,0) + sVec.lerp(tVec, currentWeight) sChannel.applyUnsafe(modelImpl, sVec) } is AnimationChannelItem.RotationItem -> { val sQuat = sVal as Quaternionf val tQuat = Quaternionf() - sQuat.slerp(tQuat, weight) + sQuat.slerp(tQuat, currentWeight) sChannel.applyUnsafe(modelImpl, sQuat) } is AnimationChannelItem.ScaleItem -> { val sVec = sVal as Vector3f val tVec = Vector3f(1f, 1f, 1f) - sVec.lerp(tVec, weight) + sVec.lerp(tVec, currentWeight) sChannel.applyUnsafe(modelImpl, sVec) } else -> { - if (weight < 0.5f) { + if (currentWeight < 0.5f) { sChannel.applyUnsafe(modelImpl, sVal) } } From d6cdc0575c2b097534ac1fad678765a0dc8606b7 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 7 Mar 2026 16:58:17 +0700 Subject: [PATCH 02/72] Try to make hair and clothes physics better --- blazerod/model/model-pmx/PmxLoader.kt | 24 +++++++------------ .../render/main/physics/PhysicsLibrary.java | 2 ++ blazerod/render/main/physics/PhysicsWorld.cpp | 17 ++++++++++++- blazerod/render/main/physics/PhysicsWorld.h | 1 + blazerod/render/main/physics/PhysicsWorld.kt | 4 ++++ blazerod/render/main/physics/binding.cpp | 13 ++++++++++ .../render/main/runtime/ModelInstanceImpl.kt | 6 +++++ 7 files changed, 50 insertions(+), 17 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index e566a8d1..9cd516ae 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1203,19 +1203,10 @@ class PmxLoader : ModelFileLoader { } val baseGroup = 1 shl rigidBody.groupId - val collisionMask = rigidBody.nonCollisionGroup and 0xFFFF - val defaultMask = collisionMask - println( - "PHYSDBG RB_GROUP " + - "idx=$index " + - "name=${rigidBody.nameLocal} " + - "groupId=${rigidBody.groupId} " + - "nonColl=${rigidBody.nonCollisionGroup} " + - "baseGroup=$baseGroup " + - "defaultMask=$defaultMask " + - "finalMask=$collisionMask", - ) - + // PMX: bit set = IGNORE collision. Bullet: bit set = ALLOW collision. + // So we must invert the nonCollisionGroup mask. + val collisionMask = (rigidBody.nonCollisionGroup.inv() and 0xFFFF).toInt() + RigidBody( name = rigidBody.nameLocal.takeIf(String::isNotBlank), collisionGroup = baseGroup, @@ -1229,9 +1220,10 @@ class PmxLoader : ModelFileLoader { shapePosition = rigidBody.shapePosition, shapeRotation = rigidBody.shapeRotation, mass = rigidBody.mass, - moveAttenuation = (rigidBody.moveAttenuation + 0.05f).coerceAtMost(1f), - rotationDamping = (rigidBody.rotationDamping + 0.05f).coerceAtMost(1f), - repulsion = rigidBody.repulsion.coerceAtMost(0.1f), + // Restore full physical fidelity from PMX file. + moveAttenuation = rigidBody.moveAttenuation, + rotationDamping = rigidBody.rotationDamping, + repulsion = rigidBody.repulsion, frictionForce = rigidBody.frictionForce, physicsMode = adjustedPhysicsMode, ) diff --git a/blazerod/render/main/physics/PhysicsLibrary.java b/blazerod/render/main/physics/PhysicsLibrary.java index 7ab38a7e..b2d4c4bb 100644 --- a/blazerod/render/main/physics/PhysicsLibrary.java +++ b/blazerod/render/main/physics/PhysicsLibrary.java @@ -38,6 +38,8 @@ public native static void resetRigidBody(long physicsWorld, int rigidBodyIndex, public native static void applyVelocityDamping(long physicsWorld, int rigidBodyIndex, float linearAttenuation, float angularAttenuation); + public native static void setSolverIterations(long physicsWorld, int iterations); + public native static void destroyPhysicsWorld(long physicsWorld); public static boolean isPhysicsAvailable() { diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 9a27d8f6..0c90c42f 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -193,9 +193,13 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c this->world->setGravity(btVector3(0, -98.0f, 0)); btContactSolverInfo& solver_info = this->world->getSolverInfo(); - solver_info.m_numIterations = 20; + // Increase solver iterations for improved stability with long hair/skirt chains. + // Standard MMD engines use 50-100. + solver_info.m_numIterations = 50; solver_info.m_splitImpulse = 1; solver_info.m_splitImpulsePenetrationThreshold = -0.04f; + // Set ERP/CFM to MMD compatible values if needed + solver_info.m_erp2 = 0.8f; this->ground_shape = std::make_unique(btVector3(0, 1, 0), 0.0f); btTransform ground_transform; @@ -397,6 +401,13 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->setStiffness(5, joint_item.rotation_spring.z); } + // --- Joint Damping (CRITICAL FIX FOR TWISTING) --- + // BT_G6DOF_SPRING uses a unique damping scale. Standard MMD engines + // use roughly 0.5 to stabilize hair strands. + for (int i = 0; i < 6; i++) { + constraint->setDamping(i, 0.5f); + } + this->world->addConstraint(constraint.get(), false); this->joints.push_back(std::move(constraint)); } @@ -470,4 +481,8 @@ void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_st } } +void PhysicsWorld::SetSolverIterations(int iterations) { + this->world->getSolverInfo().m_numIterations = iterations; +} + } // namespace blazerod::physics diff --git a/blazerod/render/main/physics/PhysicsWorld.h b/blazerod/render/main/physics/PhysicsWorld.h index 420aa6d8..06d01fe8 100644 --- a/blazerod/render/main/physics/PhysicsWorld.h +++ b/blazerod/render/main/physics/PhysicsWorld.h @@ -71,6 +71,7 @@ class PhysicsWorld { void ResetRigidBody(size_t rigidbody_index, float px, float py, float pz, float qx, float qy, float qz, float qw); void Step(float delta_time, int max_sub_steps, float fixed_time_step); + void SetSolverIterations(int iterations); }; } diff --git a/blazerod/render/main/physics/PhysicsWorld.kt b/blazerod/render/main/physics/PhysicsWorld.kt index d4d94c97..dec2e6f0 100644 --- a/blazerod/render/main/physics/PhysicsWorld.kt +++ b/blazerod/render/main/physics/PhysicsWorld.kt @@ -117,6 +117,10 @@ class PhysicsWorld( PhysicsLibrary.stepPhysicsWorld(pointer, deltaTime, maxSubSteps, fixedTimeStep) } + fun setSolverIterations(iterations: Int) { + PhysicsLibrary.setSolverIterations(pointer, iterations) + } + override fun close() { if (closed) { return diff --git a/blazerod/render/main/physics/binding.cpp b/blazerod/render/main/physics/binding.cpp index 5f99ce1b..b01a84df 100644 --- a/blazerod/render/main/physics/binding.cpp +++ b/blazerod/render/main/physics/binding.cpp @@ -128,6 +128,19 @@ JNIEXPORT void JNICALL Java_top_fifthlight_blazerod_physics_PhysicsLibrary_reset physics_world_ptr->ResetRigidBody(static_cast(rigidbody_index), px, py, pz, qx, qy, qz, qw); } +/* + * Class: top_fifthlight_blazerod_physics_PhysicsLibrary + * Method: setSolverIterations + * Signature: (JI)V + */ +JNIEXPORT void JNICALL Java_top_fifthlight_blazerod_physics_PhysicsLibrary_setSolverIterations(JNIEnv* env, + jclass clazz, + jlong physics_world, + jint iterations) { + auto physics_world_ptr = reinterpret_cast(physics_world); + physics_world_ptr->SetSolverIterations(iterations); +} + /* * Class: top_fifthlight_blazerod_physics_PhysicsLibrary * Method: destroyPhysicsWorld diff --git a/blazerod/render/main/runtime/ModelInstanceImpl.kt b/blazerod/render/main/runtime/ModelInstanceImpl.kt index 12ce10b0..c53d93e9 100644 --- a/blazerod/render/main/runtime/ModelInstanceImpl.kt +++ b/blazerod/render/main/runtime/ModelInstanceImpl.kt @@ -83,6 +83,12 @@ class ModelInstanceImpl( bulletWorld.pushTransforms(src) } override fun step(deltaTime: Float, maxSubSteps: Int, fixedTimeStep: Float) { + // Adaptive Physics Solver + // If the model is close (LOD < 10m), use High-Fidelity (50 iterations) + // If far away, drop to 20 iterations to save CPU. + val isHighQuality = lodDistance < 10f + bulletWorld.setSolverIterations(if (isHighQuality) 50 else 20) + bulletWorld.step(deltaTime, maxSubSteps, fixedTimeStep) } override fun dispose() { From 63454ca51d682c35e63a2158d6af4e347934c8de Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 7 Mar 2026 18:04:25 +0700 Subject: [PATCH 03/72] Try to make hair and clothes physics better --- blazerod/model/model-base/RigidBody.kt | 2 + blazerod/model/model-pmx/PmxLoader.kt | 48 +++++++++++++++---- blazerod/render/main/physics/PhysicsScene.cpp | 4 +- blazerod/render/main/physics/PhysicsScene.h | 2 + blazerod/render/main/physics/PhysicsScene.kt | 4 +- blazerod/render/main/physics/PhysicsWorld.cpp | 22 +++++++-- 6 files changed, 67 insertions(+), 15 deletions(-) diff --git a/blazerod/model/model-base/RigidBody.kt b/blazerod/model/model-base/RigidBody.kt index 80861fe9..66da4710 100644 --- a/blazerod/model/model-base/RigidBody.kt +++ b/blazerod/model/model-base/RigidBody.kt @@ -17,6 +17,8 @@ data class RigidBody( val repulsion: Float, val frictionForce: Float, val physicsMode: PhysicsMode, + val ccdMotionThreshold: Float = 0f, + val ccdSweptSphereRadius: Float = 0f, ) { enum class ShapeType { SPHERE, diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 9cd516ae..19f3e74b 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1202,11 +1202,42 @@ class PmxLoader : ModelFileLoader { basePhysicsMode } - val baseGroup = 1 shl rigidBody.groupId - // PMX: bit set = IGNORE collision. Bullet: bit set = ALLOW collision. - // So we must invert the nonCollisionGroup mask. - val collisionMask = (rigidBody.nonCollisionGroup.inv() and 0xFFFF).toInt() + val baseGroup = 1 shl rigidBody.groupId + val collisionMask = (rigidBody.nonCollisionGroup.inv() and 0xFFFF).toInt() + val name = rigidBody.nameLocal.lowercase() + val isPhysicsEnabled = adjustedPhysicsMode != RigidBody.PhysicsMode.FOLLOW_BONE + + val isBreast = name.contains("乳") || name.contains("胸") + val isHair = name.contains("髪") || name.contains("hair") || name.contains("side") || name.contains("tail") + val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") + + // Identify fast-moving or thin bodies that need CCD (Continuous Collision Detection) + val needsCCD = isPhysicsEnabled && (isBreast || isHair || isSkirt) + + val threshold = if (needsCCD) { + // Set threshold based on the smallest dimension to catch tunneling + val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f + minSize * 0.5f // Trigger CCD if moved more than half its size in one step + } else 0f + + val sweptRadius = if (needsCCD) { + val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f + minSize * 0.2f // Internal swept sphere for collision detection + } else 0f + // Safety: Ensure minimum damping for physics-driven bodies to prevent infinite jitter + val safetyDamping = if (isPhysicsEnabled) 0.1f else 0f + val finalMoveAttenuation = rigidBody.moveAttenuation.coerceAtLeast(safetyDamping) + val finalRotationDamping = rigidBody.rotationDamping.coerceAtLeast(safetyDamping) + + // Special: Use PHYSICS_PLUS_BONE for breasts to prevent "rubber stretching" from the torso. + // This keeps them translationally locked to the chest bone while letting Bullet drive rotation. + val finalPhysicsMode = if (isBreast && adjustedPhysicsMode == RigidBody.PhysicsMode.PHYSICS) { + RigidBody.PhysicsMode.PHYSICS_PLUS_BONE + } else { + adjustedPhysicsMode + } + RigidBody( name = rigidBody.nameLocal.takeIf(String::isNotBlank), collisionGroup = baseGroup, @@ -1220,12 +1251,13 @@ class PmxLoader : ModelFileLoader { shapePosition = rigidBody.shapePosition, shapeRotation = rigidBody.shapeRotation, mass = rigidBody.mass, - // Restore full physical fidelity from PMX file. - moveAttenuation = rigidBody.moveAttenuation, - rotationDamping = rigidBody.rotationDamping, + moveAttenuation = finalMoveAttenuation, + rotationDamping = finalRotationDamping, repulsion = rigidBody.repulsion, frictionForce = rigidBody.frictionForce, - physicsMode = adjustedPhysicsMode, + physicsMode = finalPhysicsMode, + ccdMotionThreshold = threshold, + ccdSweptSphereRadius = sweptRadius ) }, ) diff --git a/blazerod/render/main/physics/PhysicsScene.cpp b/blazerod/render/main/physics/PhysicsScene.cpp index 9cff96f8..378f23b0 100644 --- a/blazerod/render/main/physics/PhysicsScene.cpp +++ b/blazerod/render/main/physics/PhysicsScene.cpp @@ -28,7 +28,7 @@ static void CopyVector3fField(Vector3f& vector, void* data) { } static std::vector DeserializeRigidbodies(size_t size, void* data) { - const size_t rigidbody_size = 72; + const size_t rigidbody_size = 80; if (size == 0) { throw std::invalid_argument("Empty rigidbody data"); } @@ -56,6 +56,8 @@ static std::vector DeserializeRigidbodies(size_t size, void* data) { CopyField(rigidbody.rotation_damping, rigidbody_data + 60); CopyField(rigidbody.repulsion, rigidbody_data + 64); CopyField(rigidbody.friction_force, rigidbody_data + 68); + CopyField(rigidbody.ccd_motion_threshold, rigidbody_data + 72); + CopyField(rigidbody.ccd_swept_sphere_radius, rigidbody_data + 76); rigidbodies.push_back(rigidbody); } diff --git a/blazerod/render/main/physics/PhysicsScene.h b/blazerod/render/main/physics/PhysicsScene.h index fdb5eefd..ea25702e 100644 --- a/blazerod/render/main/physics/PhysicsScene.h +++ b/blazerod/render/main/physics/PhysicsScene.h @@ -42,6 +42,8 @@ struct RigidBody { float repulsion; float friction_force; PhysicsMode physics_mode; + float ccd_motion_threshold; + float ccd_swept_sphere_radius; }; struct Joint { diff --git a/blazerod/render/main/physics/PhysicsScene.kt b/blazerod/render/main/physics/PhysicsScene.kt index 63970dcc..c91b3749 100644 --- a/blazerod/render/main/physics/PhysicsScene.kt +++ b/blazerod/render/main/physics/PhysicsScene.kt @@ -28,7 +28,7 @@ class PhysicsScene( .putFloat(offset + 4, vector.y()) .putFloat(offset + 8, vector.z()) - val rigidBodyItemSize = 72 + val rigidBodyItemSize = 80 val rigidBodiesBuffer = ByteBuffer.allocateDirect(rigidBodyItemSize * rigidBodies.size) .order(ByteOrder.nativeOrder()) rigidBodies.forEachIndexed { index, rigidBody -> @@ -45,6 +45,8 @@ class PhysicsScene( rigidBodiesBuffer.putFloat(offset + 60, rigidBody.rotationDamping) rigidBodiesBuffer.putFloat(offset + 64, rigidBody.repulsion) rigidBodiesBuffer.putFloat(offset + 68, rigidBody.frictionForce) + rigidBodiesBuffer.putFloat(offset + 72, rigidBody.ccdMotionThreshold) + rigidBodiesBuffer.putFloat(offset + 76, rigidBody.ccdSweptSphereRadius) } val jointItemSize = 108 diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 0c90c42f..b6e40674 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -198,8 +198,9 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c solver_info.m_numIterations = 50; solver_info.m_splitImpulse = 1; solver_info.m_splitImpulsePenetrationThreshold = -0.04f; - // Set ERP/CFM to MMD compatible values if needed - solver_info.m_erp2 = 0.8f; + // Balanced erp to reduce jitter/flickering while maintaining constraint stiffness. + solver_info.m_erp = 0.4f; + solver_info.m_erp2 = 0.4f; this->ground_shape = std::make_unique(btVector3(0, 1, 0), 0.0f); btTransform ground_transform; @@ -310,6 +311,14 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c rigidbody_info.m_additionalDamping = true; auto rigidbody = std::make_unique(rigidbody_info); + + // --- CCD (Continuous Collision Detection) --- + // Prevents fast-moving small objects (hair/breasts) from tunneling. + if (rigidbody_item.ccd_motion_threshold > 0.0f) { + rigidbody->setCcdMotionThreshold(rigidbody_item.ccd_motion_threshold); + rigidbody->setCcdSweptSphereRadius(rigidbody_item.ccd_swept_sphere_radius); + } + rigidbody->setSleepingThresholds(0.01f, 0.0017453293f); this->world->addRigidBody(rigidbody.get(), rigidbody_item.collision_group, rigidbody_item.collision_mask); if (rigidbody_item.physics_mode != PhysicsMode::PHYSICS) { @@ -403,9 +412,12 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c // --- Joint Damping (CRITICAL FIX FOR TWISTING) --- // BT_G6DOF_SPRING uses a unique damping scale. Standard MMD engines - // use roughly 0.5 to stabilize hair strands. - for (int i = 0; i < 6; i++) { - constraint->setDamping(i, 0.5f); + // use higher rotation damping to stabilize long hair strands. + for (int i = 0; i < 3; i++) { + constraint->setDamping(i, 0.5f); // Linear + } + for (int i = 3; i < 6; i++) { + constraint->setDamping(i, 0.8f); // Rotational - Higher to stop twisting } this->world->addConstraint(constraint.get(), false); From ba87c6e0f55ce71bd2e0dfc4d9c11240a1127a1e Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 7 Mar 2026 19:34:24 +0700 Subject: [PATCH 04/72] Try to make hair and clothes physics better --- blazerod/model/model-pmx/PmxLoader.kt | 8 ++++++-- blazerod/render/main/physics/PhysicsWorld.cpp | 12 ++++-------- blazerod/render/main/runtime/RenderSceneImpl.kt | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 19f3e74b..8a262f46 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1250,10 +1250,14 @@ class PmxLoader : ModelFileLoader { shapeSize = rigidBody.shapeSize, shapePosition = rigidBody.shapePosition, shapeRotation = rigidBody.shapeRotation, - mass = rigidBody.mass, + mass = if (isHair) rigidBody.mass * 0.8f else rigidBody.mass, moveAttenuation = finalMoveAttenuation, rotationDamping = finalRotationDamping, - repulsion = rigidBody.repulsion, + repulsion = when { + isBreast -> rigidBody.repulsion.coerceAtMost(0.02f) + isHair -> rigidBody.repulsion.coerceAtMost(0.1f) + else -> rigidBody.repulsion + }, frictionForce = rigidBody.frictionForce, physicsMode = finalPhysicsMode, ccdMotionThreshold = threshold, diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index b6e40674..8edc1a5f 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -193,14 +193,12 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c this->world->setGravity(btVector3(0, -98.0f, 0)); btContactSolverInfo& solver_info = this->world->getSolverInfo(); - // Increase solver iterations for improved stability with long hair/skirt chains. - // Standard MMD engines use 50-100. solver_info.m_numIterations = 50; solver_info.m_splitImpulse = 1; solver_info.m_splitImpulsePenetrationThreshold = -0.04f; - // Balanced erp to reduce jitter/flickering while maintaining constraint stiffness. solver_info.m_erp = 0.4f; solver_info.m_erp2 = 0.4f; + solver_info.m_globalCfm = 0.0001f; this->ground_shape = std::make_unique(btVector3(0, 1, 0), 0.0f); btTransform ground_transform; @@ -410,14 +408,12 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->setStiffness(5, joint_item.rotation_spring.z); } - // --- Joint Damping (CRITICAL FIX FOR TWISTING) --- - // BT_G6DOF_SPRING uses a unique damping scale. Standard MMD engines - // use higher rotation damping to stabilize long hair strands. + // --- Joint Damping (Modified for more natural flow) --- for (int i = 0; i < 3; i++) { - constraint->setDamping(i, 0.5f); // Linear + constraint->setDamping(i, 0.2f); // Linear - Lowered to let hair drape/fall better } for (int i = 3; i < 6; i++) { - constraint->setDamping(i, 0.8f); // Rotational - Higher to stop twisting + constraint->setDamping(i, 0.6f); // Rotational - Moderate to stop twisting without "freezing" } this->world->addConstraint(constraint.get(), false); diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index 9795958b..a710361d 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -41,7 +41,7 @@ class RenderSceneImpl( ) : AbstractRefCount(), RenderScene { override val attachments: Map, Any> companion object { - private const val PHYSICS_MAX_SUB_STEP_COUNT = 3 + private const val PHYSICS_MAX_SUB_STEP_COUNT = 10 private const val PHYSICS_FPS = 120f private const val PHYSICS_TIME_STEP = 1f / PHYSICS_FPS } From fe1f18780062292e64d961f27bb89f7479c6ebb7 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 7 Mar 2026 20:15:49 +0700 Subject: [PATCH 05/72] Try to make hair and clothes physics better --- blazerod/model/model-base/PhysicalJoint.kt | 3 +++ blazerod/model/model-base/RigidBody.kt | 1 + blazerod/model/model-pmx/PmxLoader.kt | 24 +++++++++++++++---- blazerod/render/main/physics/PhysicsScene.cpp | 8 +++++-- blazerod/render/main/physics/PhysicsScene.h | 4 ++++ blazerod/render/main/physics/PhysicsScene.kt | 8 +++++-- blazerod/render/main/physics/PhysicsWorld.cpp | 12 ++++++++++ .../runtime/resource/RenderPhysicsJoint.kt | 3 +++ 8 files changed, 54 insertions(+), 9 deletions(-) diff --git a/blazerod/model/model-base/PhysicalJoint.kt b/blazerod/model/model-base/PhysicalJoint.kt index 70fe534a..335d7164 100644 --- a/blazerod/model/model-base/PhysicalJoint.kt +++ b/blazerod/model/model-base/PhysicalJoint.kt @@ -16,6 +16,9 @@ data class PhysicalJoint( val rotationMax: Vector3fc, val positionSpring: Vector3fc, val rotationSpring: Vector3fc, + val softness: Float = 1.0f, + val biasFactor: Float = 0.3f, + val relaxationFactor: Float = 1.0f, ) { enum class JointType { SPRING_6DOF, diff --git a/blazerod/model/model-base/RigidBody.kt b/blazerod/model/model-base/RigidBody.kt index 66da4710..c793e621 100644 --- a/blazerod/model/model-base/RigidBody.kt +++ b/blazerod/model/model-base/RigidBody.kt @@ -19,6 +19,7 @@ data class RigidBody( val physicsMode: PhysicsMode, val ccdMotionThreshold: Float = 0f, val ccdSweptSphereRadius: Float = 0f, + val collisionMargin: Float = 0.04f, ) { enum class ShapeType { SPHERE, diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 8a262f46..0be5ab55 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1171,6 +1171,13 @@ class PmxLoader : ModelFileLoader { ) } + val garmentGroupMask = rigidBodies.filter { + val n = it.nameLocal.lowercase() + n.contains("vest") || n.contains("shirt") || n.contains("coat") || + n.contains("jacket") || n.contains("suit") || n.contains("dress") || + n.contains("inner") || n.contains("outer") || n.contains("服") || n.contains("衣") + }.fold(0) { acc, rb -> acc or (1 shl rb.groupId) } + boneToRigidBodyMap[index]?.forEach { index -> add( NodeComponent.RigidBodyComponent( @@ -1203,7 +1210,12 @@ class PmxLoader : ModelFileLoader { } val baseGroup = 1 shl rigidBody.groupId - val collisionMask = (rigidBody.nonCollisionGroup.inv() and 0xFFFF).toInt() + val baseCollisionMask = (rigidBody.nonCollisionGroup.inv() and 0xFFFF).toInt() + val collisionMask = if (isBreast) { + baseCollisionMask and garmentGroupMask.inv() + } else { + baseCollisionMask + } val name = rigidBody.nameLocal.lowercase() val isPhysicsEnabled = adjustedPhysicsMode != RigidBody.PhysicsMode.FOLLOW_BONE @@ -1230,8 +1242,6 @@ class PmxLoader : ModelFileLoader { val finalMoveAttenuation = rigidBody.moveAttenuation.coerceAtLeast(safetyDamping) val finalRotationDamping = rigidBody.rotationDamping.coerceAtLeast(safetyDamping) - // Special: Use PHYSICS_PLUS_BONE for breasts to prevent "rubber stretching" from the torso. - // This keeps them translationally locked to the chest bone while letting Bullet drive rotation. val finalPhysicsMode = if (isBreast && adjustedPhysicsMode == RigidBody.PhysicsMode.PHYSICS) { RigidBody.PhysicsMode.PHYSICS_PLUS_BONE } else { @@ -1258,10 +1268,11 @@ class PmxLoader : ModelFileLoader { isHair -> rigidBody.repulsion.coerceAtMost(0.1f) else -> rigidBody.repulsion }, - frictionForce = rigidBody.frictionForce, + frictionForce = if (isHair) 0f else rigidBody.frictionForce, physicsMode = finalPhysicsMode, ccdMotionThreshold = threshold, - ccdSweptSphereRadius = sweptRadius + ccdSweptSphereRadius = sweptRadius, + collisionMargin = if (isHair || isBreast) 0.001f else 0.04f ) }, ) @@ -1438,6 +1449,9 @@ class PmxLoader : ModelFileLoader { rotationMax = joint.rotationMaximum, positionSpring = joint.positionSpring, rotationSpring = joint.rotationSpring, + softness = 0.9f, + biasFactor = 0.3f, + relaxationFactor = 1.0f, ) }, expressions = buildList { diff --git a/blazerod/render/main/physics/PhysicsScene.cpp b/blazerod/render/main/physics/PhysicsScene.cpp index 378f23b0..2384f6b7 100644 --- a/blazerod/render/main/physics/PhysicsScene.cpp +++ b/blazerod/render/main/physics/PhysicsScene.cpp @@ -28,7 +28,7 @@ static void CopyVector3fField(Vector3f& vector, void* data) { } static std::vector DeserializeRigidbodies(size_t size, void* data) { - const size_t rigidbody_size = 80; + const size_t rigidbody_size = 84; if (size == 0) { throw std::invalid_argument("Empty rigidbody data"); } @@ -58,6 +58,7 @@ static std::vector DeserializeRigidbodies(size_t size, void* data) { CopyField(rigidbody.friction_force, rigidbody_data + 68); CopyField(rigidbody.ccd_motion_threshold, rigidbody_data + 72); CopyField(rigidbody.ccd_swept_sphere_radius, rigidbody_data + 76); + CopyField(rigidbody.collision_margin, rigidbody_data + 80); rigidbodies.push_back(rigidbody); } @@ -66,7 +67,7 @@ static std::vector DeserializeRigidbodies(size_t size, void* data) { } static std::vector DeserializeJoints(size_t size, void* data) { - const size_t joint_size = 108; + const size_t joint_size = 120; if (size % joint_size != 0) { throw std::invalid_argument("Invalid joint size"); } @@ -90,6 +91,9 @@ static std::vector DeserializeJoints(size_t size, void* data) { CopyVector3fField(joint.rotation_max, joint_data + 72); CopyVector3fField(joint.position_spring, joint_data + 84); CopyVector3fField(joint.rotation_spring, joint_data + 96); + CopyField(joint.softness, joint_data + 108); + CopyField(joint.bias_factor, joint_data + 112); + CopyField(joint.relaxation_factor, joint_data + 116); joints.push_back(joint); } diff --git a/blazerod/render/main/physics/PhysicsScene.h b/blazerod/render/main/physics/PhysicsScene.h index ea25702e..9dfdb4a1 100644 --- a/blazerod/render/main/physics/PhysicsScene.h +++ b/blazerod/render/main/physics/PhysicsScene.h @@ -44,6 +44,7 @@ struct RigidBody { PhysicsMode physics_mode; float ccd_motion_threshold; float ccd_swept_sphere_radius; + float collision_margin; }; struct Joint { @@ -58,6 +59,9 @@ struct Joint { Vector3f rotation_max; Vector3f position_spring; Vector3f rotation_spring; + float softness; + float bias_factor; + float relaxation_factor; }; class PhysicsScene { diff --git a/blazerod/render/main/physics/PhysicsScene.kt b/blazerod/render/main/physics/PhysicsScene.kt index c91b3749..1f60c20d 100644 --- a/blazerod/render/main/physics/PhysicsScene.kt +++ b/blazerod/render/main/physics/PhysicsScene.kt @@ -28,7 +28,7 @@ class PhysicsScene( .putFloat(offset + 4, vector.y()) .putFloat(offset + 8, vector.z()) - val rigidBodyItemSize = 80 + val rigidBodyItemSize = 84 val rigidBodiesBuffer = ByteBuffer.allocateDirect(rigidBodyItemSize * rigidBodies.size) .order(ByteOrder.nativeOrder()) rigidBodies.forEachIndexed { index, rigidBody -> @@ -47,9 +47,10 @@ class PhysicsScene( rigidBodiesBuffer.putFloat(offset + 68, rigidBody.frictionForce) rigidBodiesBuffer.putFloat(offset + 72, rigidBody.ccdMotionThreshold) rigidBodiesBuffer.putFloat(offset + 76, rigidBody.ccdSweptSphereRadius) + rigidBodiesBuffer.putFloat(offset + 80, rigidBody.collisionMargin) } - val jointItemSize = 108 + val jointItemSize = 120 val jointsBuffer = ByteBuffer.allocateDirect(jointItemSize * joints.size) .order(ByteOrder.nativeOrder()) joints.forEachIndexed { index, joint -> @@ -65,6 +66,9 @@ class PhysicsScene( jointsBuffer.putVector3f(offset + 72, joint.rotationMax) jointsBuffer.putVector3f(offset + 84, joint.positionSpring) jointsBuffer.putVector3f(offset + 96, joint.rotationSpring) + jointsBuffer.putFloat(offset + 108, joint.softness) + jointsBuffer.putFloat(offset + 112, joint.biasFactor) + jointsBuffer.putFloat(offset + 116, joint.relaxationFactor) } try { diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 8edc1a5f..eaee98e3 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -317,6 +317,10 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c rigidbody->setCcdSweptSphereRadius(rigidbody_item.ccd_swept_sphere_radius); } + // Apply Native Collision Margin + rigidbody->setContactProcessingThreshold(BT_LARGE_FLOAT); + rigidbody->setMargin(rigidbody_item.collision_margin); + rigidbody->setSleepingThresholds(0.01f, 0.0017453293f); this->world->addRigidBody(rigidbody.get(), rigidbody_item.collision_group, rigidbody_item.collision_mask); if (rigidbody_item.physics_mode != PhysicsMode::PHYSICS) { @@ -416,6 +420,14 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->setDamping(i, 0.6f); // Rotational - Moderate to stop twisting without "freezing" } + // --- Constraint Tuning (Proper MMD Fix) --- + // softness, bias_factor, and relaxation_factor control how constraints behave at limits. + for (int i = 0; i < 6; i++) { + constraint->setLimitSoftness(i, joint_item.softness); + constraint->setBiasFactor(i, joint_item.bias_factor); + constraint->setRelaxationFactor(i, joint_item.relaxation_factor); + } + this->world->addConstraint(constraint.get(), false); this->joints.push_back(std::move(constraint)); } diff --git a/blazerod/render/main/runtime/resource/RenderPhysicsJoint.kt b/blazerod/render/main/runtime/resource/RenderPhysicsJoint.kt index 17e2d197..cb844220 100644 --- a/blazerod/render/main/runtime/resource/RenderPhysicsJoint.kt +++ b/blazerod/render/main/runtime/resource/RenderPhysicsJoint.kt @@ -16,4 +16,7 @@ data class RenderPhysicsJoint( val rotationMax: Vector3fc, val positionSpring: Vector3fc, val rotationSpring: Vector3fc, + val softness: Float = 1.0f, + val biasFactor: Float = 0.3f, + val relaxationFactor: Float = 1.0f, ) From 0eccac94e688750afdaaf230a401d7373ad03ee2 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 7 Mar 2026 20:25:34 +0700 Subject: [PATCH 06/72] Try to make hair and clothes physics better --- blazerod/model/model-pmx/PmxLoader.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 0be5ab55..5c3f9778 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1209,6 +1209,13 @@ class PmxLoader : ModelFileLoader { basePhysicsMode } + val name = nameLocal.lowercase() + val isPhysicsEnabled = adjustedPhysicsMode != RigidBody.PhysicsMode.FOLLOW_BONE + + val isBreast = name.contains("乳") || name.contains("胸") + val isHair = name.contains("髪") || name.contains("hair") || name.contains("side") || name.contains("tail") + val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") + val baseGroup = 1 shl rigidBody.groupId val baseCollisionMask = (rigidBody.nonCollisionGroup.inv() and 0xFFFF).toInt() val collisionMask = if (isBreast) { @@ -1216,12 +1223,6 @@ class PmxLoader : ModelFileLoader { } else { baseCollisionMask } - val name = rigidBody.nameLocal.lowercase() - val isPhysicsEnabled = adjustedPhysicsMode != RigidBody.PhysicsMode.FOLLOW_BONE - - val isBreast = name.contains("乳") || name.contains("胸") - val isHair = name.contains("髪") || name.contains("hair") || name.contains("side") || name.contains("tail") - val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") // Identify fast-moving or thin bodies that need CCD (Continuous Collision Detection) val needsCCD = isPhysicsEnabled && (isBreast || isHair || isSkirt) From fc07340bd20278b23c211ef96a44d26ce1eb5543 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 7 Mar 2026 20:37:38 +0700 Subject: [PATCH 07/72] Try to make hair and clothes physics better --- blazerod/render/main/physics/PhysicsWorld.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index eaee98e3..11e66983 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -311,7 +311,6 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c auto rigidbody = std::make_unique(rigidbody_info); // --- CCD (Continuous Collision Detection) --- - // Prevents fast-moving small objects (hair/breasts) from tunneling. if (rigidbody_item.ccd_motion_threshold > 0.0f) { rigidbody->setCcdMotionThreshold(rigidbody_item.ccd_motion_threshold); rigidbody->setCcdSweptSphereRadius(rigidbody_item.ccd_swept_sphere_radius); @@ -319,7 +318,9 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c // Apply Native Collision Margin rigidbody->setContactProcessingThreshold(BT_LARGE_FLOAT); - rigidbody->setMargin(rigidbody_item.collision_margin); + if (rigidbody->getCollisionShape()) { + rigidbody->getCollisionShape()->setMargin(rigidbody_item.collision_margin); + } rigidbody->setSleepingThresholds(0.01f, 0.0017453293f); this->world->addRigidBody(rigidbody.get(), rigidbody_item.collision_group, rigidbody_item.collision_mask); @@ -422,10 +423,12 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c // --- Constraint Tuning (Proper MMD Fix) --- // softness, bias_factor, and relaxation_factor control how constraints behave at limits. - for (int i = 0; i < 6; i++) { - constraint->setLimitSoftness(i, joint_item.softness); - constraint->setBiasFactor(i, joint_item.bias_factor); - constraint->setRelaxationFactor(i, joint_item.relaxation_factor); + constraint->getTranslationalLimitMotor()->m_limitSoftness = joint_item.softness; + for (int i = 0; i < 3; i++) { + btRotationalLimitMotor* rotMotor = constraint->getRotationalLimitMotor(i); + rotMotor->m_limitSoftness = joint_item.softness; + rotMotor->m_biasFactor = joint_item.bias_factor; + rotMotor->m_relaxationFactor = joint_item.relaxation_factor; } this->world->addConstraint(constraint.get(), false); From 0d3c0855d7ded25e308cd32be11e540a8ab46b3e Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 7 Mar 2026 20:48:36 +0700 Subject: [PATCH 08/72] Try to make hair and clothes physics better --- blazerod/render/main/physics/PhysicsWorld.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 11e66983..b705cb01 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -421,14 +421,16 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->setDamping(i, 0.6f); // Rotational - Moderate to stop twisting without "freezing" } - // --- Constraint Tuning (Proper MMD Fix) --- - // softness, bias_factor, and relaxation_factor control how constraints behave at limits. - constraint->getTranslationalLimitMotor()->m_limitSoftness = joint_item.softness; + btTranslationalLimitMotor* transMotor = constraint->getTranslationalLimitMotor(); + transMotor->m_limitSoftness = joint_item.softness; + transMotor->m_stopERP.setValue(joint_item.bias_factor, joint_item.bias_factor, joint_item.bias_factor); + transMotor->m_normalCFM.setValue(joint_item.relaxation_factor, joint_item.relaxation_factor, joint_item.relaxation_factor); + for (int i = 0; i < 3; i++) { btRotationalLimitMotor* rotMotor = constraint->getRotationalLimitMotor(i); rotMotor->m_limitSoftness = joint_item.softness; - rotMotor->m_biasFactor = joint_item.bias_factor; - rotMotor->m_relaxationFactor = joint_item.relaxation_factor; + rotMotor->m_stopERP = joint_item.bias_factor; + rotMotor->m_stopCFM = joint_item.relaxation_factor; } this->world->addConstraint(constraint.get(), false); From 2ed2b59f5018a3e65042295129da95cec6b3b5c8 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 7 Mar 2026 21:43:55 +0700 Subject: [PATCH 09/72] Try to make hair and clothes physics better --- blazerod/model/model-pmx/PmxLoader.kt | 36 +++++++++---------- blazerod/render/main/physics/PhysicsWorld.cpp | 13 ++++--- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 5c3f9778..4872b659 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1212,31 +1212,29 @@ class PmxLoader : ModelFileLoader { val name = nameLocal.lowercase() val isPhysicsEnabled = adjustedPhysicsMode != RigidBody.PhysicsMode.FOLLOW_BONE - val isBreast = name.contains("乳") || name.contains("胸") - val isHair = name.contains("髪") || name.contains("hair") || name.contains("side") || name.contains("tail") - val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") + // Enhanced keyword detection for Japanese and English models + val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") + val isHair = name.contains("髪") || name.contains("hair") || name.contains("side") || name.contains("tail") || + name.contains("twin") || name.contains("pony") || name.contains("braid") || name.contains("毛") + val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") || name.contains("裾") || name.contains("勾") val baseGroup = 1 shl rigidBody.groupId val baseCollisionMask = (rigidBody.nonCollisionGroup.inv() and 0xFFFF).toInt() + val collisionMask = if (isBreast) { - baseCollisionMask and garmentGroupMask.inv() + val bodyGroupMask = (1 shl 0) or (1 shl 1) + baseCollisionMask and garmentGroupMask.inv() and bodyGroupMask.inv() + } else if (isHair) { + val headGroupMask = (1 shl 0) + baseCollisionMask and headGroupMask.inv() } else { baseCollisionMask } // Identify fast-moving or thin bodies that need CCD (Continuous Collision Detection) val needsCCD = isPhysicsEnabled && (isBreast || isHair || isSkirt) - - val threshold = if (needsCCD) { - // Set threshold based on the smallest dimension to catch tunneling - val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f - minSize * 0.5f // Trigger CCD if moved more than half its size in one step - } else 0f - - val sweptRadius = if (needsCCD) { - val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f - minSize * 0.2f // Internal swept sphere for collision detection - } else 0f + val threshold = if (needsCCD) 0.001f else 0f + val sweptRadius = if (needsCCD) 0.01f else 0f // Safety: Ensure minimum damping for physics-driven bodies to prevent infinite jitter val safetyDamping = if (isPhysicsEnabled) 0.1f else 0f @@ -1265,7 +1263,7 @@ class PmxLoader : ModelFileLoader { moveAttenuation = finalMoveAttenuation, rotationDamping = finalRotationDamping, repulsion = when { - isBreast -> rigidBody.repulsion.coerceAtMost(0.02f) + isBreast -> rigidBody.repulsion.coerceAtMost(0.01f) // Even lower repulsion for breasts isHair -> rigidBody.repulsion.coerceAtMost(0.1f) else -> rigidBody.repulsion }, @@ -1273,7 +1271,7 @@ class PmxLoader : ModelFileLoader { physicsMode = finalPhysicsMode, ccdMotionThreshold = threshold, ccdSweptSphereRadius = sweptRadius, - collisionMargin = if (isHair || isBreast) 0.001f else 0.04f + collisionMargin = if (isHair || isBreast) 0.005f else 0.04f // Increased margin slightly from 0.001 to 0.005 ) }, ) @@ -1450,8 +1448,8 @@ class PmxLoader : ModelFileLoader { rotationMax = joint.rotationMaximum, positionSpring = joint.positionSpring, rotationSpring = joint.rotationSpring, - softness = 0.9f, - biasFactor = 0.3f, + softness = if (joint.nameLocal.lowercase().let { it.contains("乳") || it.contains("胸") }) 1.0f else 0.9f, + biasFactor = if (joint.nameLocal.lowercase().let { it.contains("乳") || it.contains("胸") || it.contains("bust") }) 0.5f else 0.3f, relaxationFactor = 1.0f, ) }, diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index b705cb01..0a74b832 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -195,10 +195,10 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c btContactSolverInfo& solver_info = this->world->getSolverInfo(); solver_info.m_numIterations = 50; solver_info.m_splitImpulse = 1; - solver_info.m_splitImpulsePenetrationThreshold = -0.04f; - solver_info.m_erp = 0.4f; - solver_info.m_erp2 = 0.4f; - solver_info.m_globalCfm = 0.0001f; + solver_info.m_splitImpulsePenetrationThreshold = -0.001f; + solver_info.m_erp = 0.8f; + solver_info.m_erp2 = 0.8f; + solver_info.m_globalCfm = 0.00001f; this->ground_shape = std::make_unique(btVector3(0, 1, 0), 0.0f); btTransform ground_transform; @@ -413,12 +413,11 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->setStiffness(5, joint_item.rotation_spring.z); } - // --- Joint Damping (Modified for more natural flow) --- for (int i = 0; i < 3; i++) { - constraint->setDamping(i, 0.2f); // Linear - Lowered to let hair drape/fall better + constraint->setDamping(i, 0.2f); } for (int i = 3; i < 6; i++) { - constraint->setDamping(i, 0.6f); // Rotational - Moderate to stop twisting without "freezing" + constraint->setDamping(i, 0.6f); } btTranslationalLimitMotor* transMotor = constraint->getTranslationalLimitMotor(); From 9e73c9d17a018c3ccd059193681fba0e71609234 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 8 Mar 2026 08:13:41 +0700 Subject: [PATCH 10/72] Try to improve animation and physics --- blazerod/model/model-pmx/PmxLoader.kt | 40 ++++++++++++++----- blazerod/render/main/physics/PhysicsWorld.cpp | 6 +-- .../render/main/runtime/RenderSceneImpl.kt | 4 +- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 4872b659..498d7073 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1215,18 +1215,39 @@ class PmxLoader : ModelFileLoader { // Enhanced keyword detection for Japanese and English models val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") val isHair = name.contains("髪") || name.contains("hair") || name.contains("side") || name.contains("tail") || - name.contains("twin") || name.contains("pony") || name.contains("braid") || name.contains("毛") - val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") || name.contains("裾") || name.contains("勾") + name.contains("twin") || name.contains("pony") || name.contains("braid") || name.contains("毛") || + name.contains("发") || name.contains("髮") || name.contains("髮") + val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") || name.contains("裾") || + name.contains("勾") || name.contains("裙") || name.contains("たれ") val baseGroup = 1 shl rigidBody.groupId val baseCollisionMask = (rigidBody.nonCollisionGroup.inv() and 0xFFFF).toInt() + // Identify all body-related rigid bodies to exclude from collision + val bodyGroupMask = rigidBodies.indices + .filter { i -> + val n = rigidBodies[i].nameLocal.lowercase() + n.contains("body") || n.contains("体") || n.contains("腕") || n.contains("arm") || n.contains("肉") || + n.contains("leg") || n.contains("足") || n.contains("hand") || n.contains("手") || + n.contains("lower") || n.contains("upper") || n.contains("身") || n.contains("頭") || n.contains("首") || + n.contains("膝") || n.contains("肘") || n.contains("肩") || n.contains("ひざ") || n.contains("しり") || + n.contains("おしり") || n.contains("腰") || n.contains("センター") || n.contains("中心") + } + .fold(0) { acc, i -> acc or (1 shl rigidBodies[i].groupId) } + val collisionMask = if (isBreast) { - val bodyGroupMask = (1 shl 0) or (1 shl 1) - baseCollisionMask and garmentGroupMask.inv() and bodyGroupMask.inv() + val safetyBodyMask = (1 shl 0) or (1 shl 1) + baseCollisionMask and garmentGroupMask.inv() and bodyGroupMask.inv() and safetyBodyMask.inv() } else if (isHair) { - val headGroupMask = (1 shl 0) - baseCollisionMask and headGroupMask.inv() + val headAndUpperBodyMask = rigidBodies.indices + .filter { i -> + val n = rigidBodies[i].nameLocal.lowercase() + n.contains("head") || n.contains("頭") || n.contains("face") || n.contains("顔") || + n.contains("body") || n.contains("体") || n.contains("neck") || n.contains("首") + } + .fold(0) { acc, i -> acc or (1 shl rigidBodies[i].groupId) } + + baseCollisionMask and headAndUpperBodyMask.inv() } else { baseCollisionMask } @@ -1236,7 +1257,6 @@ class PmxLoader : ModelFileLoader { val threshold = if (needsCCD) 0.001f else 0f val sweptRadius = if (needsCCD) 0.01f else 0f - // Safety: Ensure minimum damping for physics-driven bodies to prevent infinite jitter val safetyDamping = if (isPhysicsEnabled) 0.1f else 0f val finalMoveAttenuation = rigidBody.moveAttenuation.coerceAtLeast(safetyDamping) val finalRotationDamping = rigidBody.rotationDamping.coerceAtLeast(safetyDamping) @@ -1263,15 +1283,15 @@ class PmxLoader : ModelFileLoader { moveAttenuation = finalMoveAttenuation, rotationDamping = finalRotationDamping, repulsion = when { - isBreast -> rigidBody.repulsion.coerceAtMost(0.01f) // Even lower repulsion for breasts + isBreast -> rigidBody.repulsion.coerceAtMost(0.01f) isHair -> rigidBody.repulsion.coerceAtMost(0.1f) else -> rigidBody.repulsion }, - frictionForce = if (isHair) 0f else rigidBody.frictionForce, + frictionForce = if (isHair) 0.05f else rigidBody.frictionForce, physicsMode = finalPhysicsMode, ccdMotionThreshold = threshold, ccdSweptSphereRadius = sweptRadius, - collisionMargin = if (isHair || isBreast) 0.005f else 0.04f // Increased margin slightly from 0.001 to 0.005 + collisionMargin = if (isHair || isBreast) 0.005f else 0.04f ) }, ) diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 0a74b832..f1b96a5c 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -195,9 +195,9 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c btContactSolverInfo& solver_info = this->world->getSolverInfo(); solver_info.m_numIterations = 50; solver_info.m_splitImpulse = 1; - solver_info.m_splitImpulsePenetrationThreshold = -0.001f; - solver_info.m_erp = 0.8f; - solver_info.m_erp2 = 0.8f; + solver_info.m_splitImpulsePenetrationThreshold = -0.01f; + solver_info.m_erp = 0.2f; + solver_info.m_erp2 = 0.2f; solver_info.m_globalCfm = 0.00001f; this->ground_shape = std::make_unique(btVector3(0, 1, 0), 0.0f); diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index a710361d..b8e11931 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -41,8 +41,8 @@ class RenderSceneImpl( ) : AbstractRefCount(), RenderScene { override val attachments: Map, Any> companion object { - private const val PHYSICS_MAX_SUB_STEP_COUNT = 10 - private const val PHYSICS_FPS = 120f + private const val PHYSICS_MAX_SUB_STEP_COUNT = 24 + private const val PHYSICS_FPS = 240f private const val PHYSICS_TIME_STEP = 1f / PHYSICS_FPS } From de8ea4c94a4016c0bd9eec1d77e9965cdc236e34 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 8 Mar 2026 09:02:59 +0700 Subject: [PATCH 11/72] Improve Performance --- blazerod/render/main/runtime/ModelInstanceImpl.kt | 12 ++++++------ blazerod/render/main/runtime/RenderSceneImpl.kt | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/blazerod/render/main/runtime/ModelInstanceImpl.kt b/blazerod/render/main/runtime/ModelInstanceImpl.kt index c53d93e9..f17ca321 100644 --- a/blazerod/render/main/runtime/ModelInstanceImpl.kt +++ b/blazerod/render/main/runtime/ModelInstanceImpl.kt @@ -84,10 +84,10 @@ class ModelInstanceImpl( } override fun step(deltaTime: Float, maxSubSteps: Int, fixedTimeStep: Float) { // Adaptive Physics Solver - // If the model is close (LOD < 10m), use High-Fidelity (50 iterations) - // If far away, drop to 20 iterations to save CPU. - val isHighQuality = lodDistance < 10f - bulletWorld.setSolverIterations(if (isHighQuality) 50 else 20) + // If the model is close (LOD < 5m), use High-Fidelity (50 iterations) + // If far away, drop to 20 iterations to save CPU. + val isHighQuality = lodDistance < 5f + bulletWorld.setSolverIterations(if (isHighQuality) 50 else 20) bulletWorld.step(deltaTime, maxSubSteps, fixedTimeStep) } @@ -149,9 +149,9 @@ class ModelInstanceImpl( var debugStepCount: Int = 0 companion object { - const val BUDGET_HIGH_MS = 4.0f + const val BUDGET_HIGH_MS = 2.0f const val BUDGET_LOW_MS = 1.0f - const val MIN_INTERVAL = 1f / 120f + const val MIN_INTERVAL = 1f / 60f const val MAX_INTERVAL = 1f / 15f const val SPEED_HISTORY_SIZE = 5 const val SPRINT_SPEED_THRESHOLD = 0.04f // ~squared dist for sprinting speed diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index b8e11931..35ab8676 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -41,8 +41,8 @@ class RenderSceneImpl( ) : AbstractRefCount(), RenderScene { override val attachments: Map, Any> companion object { - private const val PHYSICS_MAX_SUB_STEP_COUNT = 24 - private const val PHYSICS_FPS = 240f + private const val PHYSICS_MAX_SUB_STEP_COUNT = 4 + private const val PHYSICS_FPS = 120f private const val PHYSICS_TIME_STEP = 1f / PHYSICS_FPS } From 7348499400ba75a4bf5d1178c98852d80badbc69 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 8 Mar 2026 09:42:38 +0700 Subject: [PATCH 12/72] Stablize --- blazerod/model/model-pmx/PmxLoader.kt | 59 +++++++++---------- blazerod/render/main/physics/PhysicsWorld.cpp | 4 +- .../render/main/runtime/RenderSceneImpl.kt | 3 +- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 498d7073..a9a13e05 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1209,17 +1209,6 @@ class PmxLoader : ModelFileLoader { basePhysicsMode } - val name = nameLocal.lowercase() - val isPhysicsEnabled = adjustedPhysicsMode != RigidBody.PhysicsMode.FOLLOW_BONE - - // Enhanced keyword detection for Japanese and English models - val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") - val isHair = name.contains("髪") || name.contains("hair") || name.contains("side") || name.contains("tail") || - name.contains("twin") || name.contains("pony") || name.contains("braid") || name.contains("毛") || - name.contains("发") || name.contains("髮") || name.contains("髮") - val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") || name.contains("裾") || - name.contains("勾") || name.contains("裙") || name.contains("たれ") - val baseGroup = 1 shl rigidBody.groupId val baseCollisionMask = (rigidBody.nonCollisionGroup.inv() and 0xFFFF).toInt() @@ -1235,6 +1224,11 @@ class PmxLoader : ModelFileLoader { } .fold(0) { acc, i -> acc or (1 shl rigidBodies[i].groupId) } + val name = rigidBody.nameLocal.lowercase() + val isBreast = name.contains("乳") || name.contains("胸") + val isHair = name.contains("髪") || name.contains("hair") || name.contains("side") || name.contains("tail") + val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") + val collisionMask = if (isBreast) { val safetyBodyMask = (1 shl 0) or (1 shl 1) baseCollisionMask and garmentGroupMask.inv() and bodyGroupMask.inv() and safetyBodyMask.inv() @@ -1248,20 +1242,32 @@ class PmxLoader : ModelFileLoader { .fold(0) { acc, i -> acc or (1 shl rigidBodies[i].groupId) } baseCollisionMask and headAndUpperBodyMask.inv() + } else if (isSkirt) { + baseCollisionMask and bodyGroupMask.inv() } else { baseCollisionMask } - + val isPhysicsEnabled = adjustedPhysicsMode != RigidBody.PhysicsMode.FOLLOW_BONE + // Identify fast-moving or thin bodies that need CCD (Continuous Collision Detection) val needsCCD = isPhysicsEnabled && (isBreast || isHair || isSkirt) - val threshold = if (needsCCD) 0.001f else 0f - val sweptRadius = if (needsCCD) 0.01f else 0f + + val threshold = if (needsCCD) { + // Set threshold based on the smallest dimension to catch tunneling + val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f + minSize * 0.5f // Trigger CCD if moved more than half its size in one step + } else 0f + + val sweptRadius = if (needsCCD) { + val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f + minSize * 0.2f // Internal swept sphere for collision detection + } else 0f - val safetyDamping = if (isPhysicsEnabled) 0.1f else 0f + val safetyDamping = if (isPhysicsEnabled) 0.2f else 0f val finalMoveAttenuation = rigidBody.moveAttenuation.coerceAtLeast(safetyDamping) val finalRotationDamping = rigidBody.rotationDamping.coerceAtLeast(safetyDamping) - - val finalPhysicsMode = if (isBreast && adjustedPhysicsMode == RigidBody.PhysicsMode.PHYSICS) { + + val finalPhysicsMode = if ((isBreast || isSkirt) && adjustedPhysicsMode == RigidBody.PhysicsMode.PHYSICS) { RigidBody.PhysicsMode.PHYSICS_PLUS_BONE } else { adjustedPhysicsMode @@ -1279,19 +1285,15 @@ class PmxLoader : ModelFileLoader { shapeSize = rigidBody.shapeSize, shapePosition = rigidBody.shapePosition, shapeRotation = rigidBody.shapeRotation, - mass = if (isHair) rigidBody.mass * 0.8f else rigidBody.mass, + mass = rigidBody.mass, moveAttenuation = finalMoveAttenuation, rotationDamping = finalRotationDamping, - repulsion = when { - isBreast -> rigidBody.repulsion.coerceAtMost(0.01f) - isHair -> rigidBody.repulsion.coerceAtMost(0.1f) - else -> rigidBody.repulsion - }, - frictionForce = if (isHair) 0.05f else rigidBody.frictionForce, + repulsion = rigidBody.repulsion, + frictionForce = rigidBody.frictionForce, physicsMode = finalPhysicsMode, - ccdMotionThreshold = threshold, - ccdSweptSphereRadius = sweptRadius, - collisionMargin = if (isHair || isBreast) 0.005f else 0.04f + ccdMotionThreshold = threshold, + ccdSweptSphereRadius = sweptRadius, + collisionMargin = if (isHair || isBreast) 0.005f else 0.04f ) }, ) @@ -1468,9 +1470,6 @@ class PmxLoader : ModelFileLoader { rotationMax = joint.rotationMaximum, positionSpring = joint.positionSpring, rotationSpring = joint.rotationSpring, - softness = if (joint.nameLocal.lowercase().let { it.contains("乳") || it.contains("胸") }) 1.0f else 0.9f, - biasFactor = if (joint.nameLocal.lowercase().let { it.contains("乳") || it.contains("胸") || it.contains("bust") }) 0.5f else 0.3f, - relaxationFactor = 1.0f, ) }, expressions = buildList { diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index f1b96a5c..9e4f7529 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -196,8 +196,8 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c solver_info.m_numIterations = 50; solver_info.m_splitImpulse = 1; solver_info.m_splitImpulsePenetrationThreshold = -0.01f; - solver_info.m_erp = 0.2f; - solver_info.m_erp2 = 0.2f; + solver_info.m_erp = 0.6f; + solver_info.m_erp2 = 0.6f; solver_info.m_globalCfm = 0.00001f; this->ground_shape = std::make_unique(btVector3(0, 1, 0), 0.0f); diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index 35ab8676..861aa5ba 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -231,7 +231,8 @@ class RenderSceneImpl( val distSq = rootPos.distanceSquared(data.lastRootPos) val avgSpeed = data.averageRecentSpeed() - if (distSq > 4.0f || (avgSpeed > ModelInstanceImpl.PhysicsData.SPRINT_SPEED_THRESHOLD && distSq < ModelInstanceImpl.PhysicsData.STOP_SPEED_THRESHOLD)) { + // If moved very far (teleport) OR stopped abruptly after sprinting, reset physics to avoid overshoot. + if (distSq > 4.0f || (avgSpeed > ModelInstanceImpl.PhysicsData.SPRINT_SPEED_THRESHOLD && distSq < ModelInstanceImpl.PhysicsData.STOP_SPEED_THRESHOLD * 2f)) { val initPos = Vector3f() val initRot = Quaternionf() for ((nodeIndex, component) in rigidBodyComponents) { From 586fe76982d48883695af75c623346f9656469b9 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 8 Mar 2026 10:34:57 +0700 Subject: [PATCH 13/72] Stablize --- blazerod/model/model-pmx/PmxLoader.kt | 53 ++++++++++++++----- .../render/main/runtime/RenderSceneImpl.kt | 4 +- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index a9a13e05..8ff48137 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1116,12 +1116,12 @@ class PmxLoader : ModelFileLoader { val modelId = UUID.randomUUID() val rootNodes = mutableListOf() - fun addBone(index: Int, parentPosition: Vector3fc? = null): Node { + fun addBone(index: Int, parentPosition: Vector3fc? = null, depth: Int = 0): Node { val bone = bones[index] val boneNodeId = NodeId(modelId, index) val children = childBoneMap[index]?.map { - addBone(it, bone.position) + addBone(it, bone.position, depth + 1) } ?: listOf() val components = buildList { @@ -1285,15 +1285,28 @@ class PmxLoader : ModelFileLoader { shapeSize = rigidBody.shapeSize, shapePosition = rigidBody.shapePosition, shapeRotation = rigidBody.shapeRotation, - mass = rigidBody.mass, - moveAttenuation = finalMoveAttenuation, - rotationDamping = finalRotationDamping, - repulsion = rigidBody.repulsion, - frictionForce = rigidBody.frictionForce, + mass = if (isHair) { + // Mass Gradient: Heavy at root, feather-light at tips (Expert setting) + (rigidBody.mass * (1.0f / (depth + 1))).coerceAtLeast(0.0001f) + } else if (isBreast) { + 0.5f // Consistent mass for breasts to stabilize bounce + } else { + rigidBody.mass + }, + moveAttenuation = if (isHair) { + // Higher damping for hair to prevent "twist" and over-swing + finalMoveAttenuation.coerceAtLeast(0.8f) + } else finalMoveAttenuation, + rotationDamping = if (isHair) { + // Crank rotation damping to 99% for hair to stop "drill" twisting + 0.99f + } else finalRotationDamping, + repulsion = if (isBreast) 0.0f else rigidBody.repulsion, + frictionForce = if (isHair) 0.5f else rigidBody.frictionForce, physicsMode = finalPhysicsMode, - ccdMotionThreshold = threshold, - ccdSweptSphereRadius = sweptRadius, - collisionMargin = if (isHair || isBreast) 0.005f else 0.04f + ccdMotionThreshold = threshold, + ccdSweptSphereRadius = sweptRadius, + collisionMargin = if (isHair || isBreast) 0.005f else 0.04f ) }, ) @@ -1455,6 +1468,12 @@ class PmxLoader : ModelFileLoader { if (joint.rigidBodyIndexB !in rigidBodies.indices) { return@mapNotNull null } + val rbA = rigidBodies[joint.rigidBodyIndexA] + val rbB = rigidBodies[joint.rigidBodyIndexB] + val isBreastJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> + n.contains("乳") || n.contains("胸") || n.contains("bust") || n.contains("breast") + } + PhysicalJoint( name = joint.nameLocal.takeIf(String::isNotBlank), type = when (joint.type) { @@ -1466,9 +1485,17 @@ class PmxLoader : ModelFileLoader { rotation = joint.rotation, positionMin = joint.positionMinimum, positionMax = joint.positionMaximum, - rotationMin = joint.rotationMinimum, - rotationMax = joint.rotationMaximum, - positionSpring = joint.positionSpring, + rotationMin = if (isBreastJoint) { + // Tighten breast rotation to prevent upside-down flips + Vector3f(-0.17f, -0.17f, -0.17f) // ~10 degrees + } else joint.rotationMinimum, + rotationMax = if (isBreastJoint) { + Vector3f(0.17f, 0.17f, 0.17f) + } else joint.rotationMaximum, + positionSpring = if (isBreastJoint) { + // "Studio Quality" vertical bounce (Move Spring = 14.0f) + Vector3f(joint.positionSpring.x, 14.0f, joint.positionSpring.z) + } else joint.positionSpring, rotationSpring = joint.rotationSpring, ) }, diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index 861aa5ba..4a84a2ef 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -218,7 +218,8 @@ class RenderSceneImpl( val minInterval = ModelInstanceImpl.PhysicsData.MIN_INTERVAL / distanceFpsMultiplier val effectiveInterval = maxOf(data.currentPhysicsInterval, minInterval) val maxAccumulator = effectiveInterval * 2f - data.physicsAccumulator = minOf(data.physicsAccumulator + timeStep, maxAccumulator) + // Safety: Never accumulate more than 0.5 seconds of physics time. + data.physicsAccumulator = minOf(data.physicsAccumulator + timeStep, 0.5f) if (data.physicsAccumulator >= effectiveInterval) { data.currentTransforms.copyInto(data.previousTransforms) @@ -231,7 +232,6 @@ class RenderSceneImpl( val distSq = rootPos.distanceSquared(data.lastRootPos) val avgSpeed = data.averageRecentSpeed() - // If moved very far (teleport) OR stopped abruptly after sprinting, reset physics to avoid overshoot. if (distSq > 4.0f || (avgSpeed > ModelInstanceImpl.PhysicsData.SPRINT_SPEED_THRESHOLD && distSq < ModelInstanceImpl.PhysicsData.STOP_SPEED_THRESHOLD * 2f)) { val initPos = Vector3f() val initRot = Quaternionf() From a619532c08ab2bf6afc41b23e6b868b1a9e7b8ff Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 8 Mar 2026 11:12:53 +0700 Subject: [PATCH 14/72] Stablize --- blazerod/model/model-pmx/PmxLoader.kt | 13 ++++++++----- blazerod/render/main/runtime/RenderSceneImpl.kt | 2 +- .../main/runtime/renderer/CpuTransformRenderer.kt | 1 + 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 8ff48137..daf6fd5c 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1225,8 +1225,10 @@ class PmxLoader : ModelFileLoader { .fold(0) { acc, i -> acc or (1 shl rigidBodies[i].groupId) } val name = rigidBody.nameLocal.lowercase() - val isBreast = name.contains("乳") || name.contains("胸") - val isHair = name.contains("髪") || name.contains("hair") || name.contains("side") || name.contains("tail") + val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") + val isHair = name.contains("hair") || name.contains("发") || name.contains("髪") || + name.contains("bang") || name.contains("strand") || name.contains("front") || + name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") val collisionMask = if (isBreast) { @@ -1289,7 +1291,7 @@ class PmxLoader : ModelFileLoader { // Mass Gradient: Heavy at root, feather-light at tips (Expert setting) (rigidBody.mass * (1.0f / (depth + 1))).coerceAtLeast(0.0001f) } else if (isBreast) { - 0.5f // Consistent mass for breasts to stabilize bounce + 1.0f // Increased mass for better inertia and less shaking } else { rigidBody.mass }, @@ -1298,15 +1300,16 @@ class PmxLoader : ModelFileLoader { finalMoveAttenuation.coerceAtLeast(0.8f) } else finalMoveAttenuation, rotationDamping = if (isHair) { - // Crank rotation damping to 99% for hair to stop "drill" twisting 0.99f + } else if (isBreast) { + 0.5f // Increased damping for breaths to eliminate high-frequency jiggle } else finalRotationDamping, repulsion = if (isBreast) 0.0f else rigidBody.repulsion, frictionForce = if (isHair) 0.5f else rigidBody.frictionForce, physicsMode = finalPhysicsMode, ccdMotionThreshold = threshold, ccdSweptSphereRadius = sweptRadius, - collisionMargin = if (isHair || isBreast) 0.005f else 0.04f + collisionMargin = if (isHair) 0.01f else if (isBreast) 0.005f else 0.04f ) }, ) diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index 4a84a2ef..6b5a5a40 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -41,7 +41,7 @@ class RenderSceneImpl( ) : AbstractRefCount(), RenderScene { override val attachments: Map, Any> companion object { - private const val PHYSICS_MAX_SUB_STEP_COUNT = 4 + private const val PHYSICS_MAX_SUB_STEP_COUNT = 12 private const val PHYSICS_FPS = 120f private const val PHYSICS_TIME_STEP = 1f / PHYSICS_FPS } diff --git a/blazerod/render/main/runtime/renderer/CpuTransformRenderer.kt b/blazerod/render/main/runtime/renderer/CpuTransformRenderer.kt index 85800c51..264131c1 100644 --- a/blazerod/render/main/runtime/renderer/CpuTransformRenderer.kt +++ b/blazerod/render/main/runtime/renderer/CpuTransformRenderer.kt @@ -364,5 +364,6 @@ class CpuTransformRenderer private constructor() : override fun close() { dataPool.close() + cpuPool.close() } } \ No newline at end of file From e5c26a30ba7b8f346489914fb477780a4040a3d1 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 8 Mar 2026 11:51:40 +0700 Subject: [PATCH 15/72] Stablize --- blazerod/model/model-pmx/PmxLoader.kt | 32 ++++++++++++++------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index daf6fd5c..bd71776a 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1232,20 +1232,11 @@ class PmxLoader : ModelFileLoader { val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") val collisionMask = if (isBreast) { - val safetyBodyMask = (1 shl 0) or (1 shl 1) - baseCollisionMask and garmentGroupMask.inv() and bodyGroupMask.inv() and safetyBodyMask.inv() + baseCollisionMask } else if (isHair) { - val headAndUpperBodyMask = rigidBodies.indices - .filter { i -> - val n = rigidBodies[i].nameLocal.lowercase() - n.contains("head") || n.contains("頭") || n.contains("face") || n.contains("顔") || - n.contains("body") || n.contains("体") || n.contains("neck") || n.contains("首") - } - .fold(0) { acc, i -> acc or (1 shl rigidBodies[i].groupId) } - - baseCollisionMask and headAndUpperBodyMask.inv() + baseCollisionMask } else if (isSkirt) { - baseCollisionMask and bodyGroupMask.inv() + baseCollisionMask } else { baseCollisionMask } @@ -1304,12 +1295,12 @@ class PmxLoader : ModelFileLoader { } else if (isBreast) { 0.5f // Increased damping for breaths to eliminate high-frequency jiggle } else finalRotationDamping, - repulsion = if (isBreast) 0.0f else rigidBody.repulsion, - frictionForce = if (isHair) 0.5f else rigidBody.frictionForce, + repulsion = 0.0f, + frictionForce = if (isHair) 0.0f else rigidBody.frictionForce, // Frictionless sliding for hair physicsMode = finalPhysicsMode, ccdMotionThreshold = threshold, ccdSweptSphereRadius = sweptRadius, - collisionMargin = if (isHair) 0.01f else if (isBreast) 0.005f else 0.04f + collisionMargin = if (isHair || isBreast || isSkirt) 0.008f else 0.04f ) }, ) @@ -1500,6 +1491,17 @@ class PmxLoader : ModelFileLoader { Vector3f(joint.positionSpring.x, 14.0f, joint.positionSpring.z) } else joint.positionSpring, rotationSpring = joint.rotationSpring, + softness = when { + isBreastJoint -> 0.5f // Soft repositioning + (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.8f + else -> 1.0f + }, + biasFactor = when { + isBreastJoint -> 0.1f // Slow, heavy error correction to stop shaking + (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.2f + else -> 0.3f + }, + relaxationFactor = 1.0f, ) }, expressions = buildList { From 670053ca52c79ac2f8c1ec952e6e2a3a9384ad34 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Mon, 9 Mar 2026 21:55:58 +0700 Subject: [PATCH 16/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 22 +++++++++---------- blazerod/render/main/physics/PhysicsWorld.cpp | 6 ----- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index bd71776a..25e317a1 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1231,14 +1231,14 @@ class PmxLoader : ModelFileLoader { name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") - val collisionMask = if (isBreast) { - baseCollisionMask - } else if (isHair) { - baseCollisionMask - } else if (isSkirt) { - baseCollisionMask - } else { - baseCollisionMask + val collisionMask = when { + isHair && depth <= 2 -> { + baseCollisionMask and bodyGroupMask.inv() + } + isBreast -> baseCollisionMask + isHair -> baseCollisionMask + isSkirt -> baseCollisionMask + else -> baseCollisionMask } val isPhysicsEnabled = adjustedPhysicsMode != RigidBody.PhysicsMode.FOLLOW_BONE @@ -1293,10 +1293,10 @@ class PmxLoader : ModelFileLoader { rotationDamping = if (isHair) { 0.99f } else if (isBreast) { - 0.5f // Increased damping for breaths to eliminate high-frequency jiggle + 0.5f } else finalRotationDamping, repulsion = 0.0f, - frictionForce = if (isHair) 0.0f else rigidBody.frictionForce, // Frictionless sliding for hair + frictionForce = if (isHair) 0.1f else rigidBody.frictionForce, physicsMode = finalPhysicsMode, ccdMotionThreshold = threshold, ccdSweptSphereRadius = sweptRadius, @@ -1498,7 +1498,7 @@ class PmxLoader : ModelFileLoader { }, biasFactor = when { isBreastJoint -> 0.1f // Slow, heavy error correction to stop shaking - (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.2f + (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.25f // Faster correction for hair else -> 0.3f }, relaxationFactor = 1.0f, diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 9e4f7529..99155603 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -413,12 +413,6 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->setStiffness(5, joint_item.rotation_spring.z); } - for (int i = 0; i < 3; i++) { - constraint->setDamping(i, 0.2f); - } - for (int i = 3; i < 6; i++) { - constraint->setDamping(i, 0.6f); - } btTranslationalLimitMotor* transMotor = constraint->getTranslationalLimitMotor(); transMotor->m_limitSoftness = joint_item.softness; From 686f2605b2c6832d9293aab174f75ff935d74c7a Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Tue, 10 Mar 2026 08:20:30 +0700 Subject: [PATCH 17/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 8 ++-- blazerod/render/main/physics/PhysicsWorld.cpp | 37 ++++++++++--------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 25e317a1..4bf52018 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1236,7 +1236,7 @@ class PmxLoader : ModelFileLoader { baseCollisionMask and bodyGroupMask.inv() } isBreast -> baseCollisionMask - isHair -> baseCollisionMask + isHair -> baseCollisionMask or bodyGroupMask isSkirt -> baseCollisionMask else -> baseCollisionMask } @@ -1280,7 +1280,7 @@ class PmxLoader : ModelFileLoader { shapeRotation = rigidBody.shapeRotation, mass = if (isHair) { // Mass Gradient: Heavy at root, feather-light at tips (Expert setting) - (rigidBody.mass * (1.0f / (depth + 1))).coerceAtLeast(0.0001f) + (rigidBody.mass * (1.0f / (depth + 1))).coerceAtLeast(0.05f) } else if (isBreast) { 1.0f // Increased mass for better inertia and less shaking } else { @@ -1291,7 +1291,7 @@ class PmxLoader : ModelFileLoader { finalMoveAttenuation.coerceAtLeast(0.8f) } else finalMoveAttenuation, rotationDamping = if (isHair) { - 0.99f + 0.85f } else if (isBreast) { 0.5f } else finalRotationDamping, @@ -1498,7 +1498,7 @@ class PmxLoader : ModelFileLoader { }, biasFactor = when { isBreastJoint -> 0.1f // Slow, heavy error correction to stop shaking - (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.25f // Faster correction for hair + (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.3f else -> 0.3f }, relaxationFactor = 1.0f, diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 99155603..e223a7f2 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -375,19 +375,20 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c btTransform inverse_a = body_a_transform.inverse() * transform; btTransform inverse_b = body_b_transform.inverse() * transform; - auto constraint = std::make_unique( - *rigidbody_a.rigidbody, *rigidbody_b.rigidbody, inverse_a, inverse_b, true); - constraint->setLinearLowerLimit( - btVector3(joint_item.position_min.x, joint_item.position_min.y, joint_item.position_min.z)); - constraint->setLinearUpperLimit( - btVector3(joint_item.position_max.x, joint_item.position_max.y, joint_item.position_max.z)); - - // Apply angular limits directly from the preprocessed min/max values. - constraint->setAngularLowerLimit( - btVector3(joint_item.rotation_min.x, joint_item.rotation_min.y, joint_item.rotation_min.z)); - constraint->setAngularUpperLimit( - btVector3(joint_item.rotation_max.x, joint_item.rotation_max.y, joint_item.rotation_max.z)); + auto constraint = std::make_unique( + *rigidbody_a.rigidbody, *rigidbody_b.rigidbody, inverse_a, inverse_b); + // Linear Limits + constraint->setLimit(0, joint_item.position_min.x, joint_item.position_max.x); + constraint->setLimit(1, joint_item.position_min.y, joint_item.position_max.y); + constraint->setLimit(2, joint_item.position_min.z, joint_item.position_max.z); + + // Angular Limits + constraint->setLimit(3, joint_item.rotation_min.x, joint_item.rotation_max.x); + constraint->setLimit(4, joint_item.rotation_min.y, joint_item.rotation_max.y); + constraint->setLimit(5, joint_item.rotation_min.z, joint_item.rotation_max.z); + + // Linear Springs if (joint_item.position_spring.x != 0.0f) { constraint->enableSpring(0, true); constraint->setStiffness(0, joint_item.position_spring.x); @@ -400,6 +401,8 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->enableSpring(2, true); constraint->setStiffness(2, joint_item.position_spring.z); } + + // Angular Springs if (joint_item.rotation_spring.x != 0.0f) { constraint->enableSpring(3, true); constraint->setStiffness(3, joint_item.rotation_spring.x); @@ -413,14 +416,14 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->setStiffness(5, joint_item.rotation_spring.z); } - - btTranslationalLimitMotor* transMotor = constraint->getTranslationalLimitMotor(); + // Apply Stability Parameters (Softness, Bias, Relaxation) + btTranslationalLimitMotor2* transMotor = constraint->getTranslationalLimitMotor(); transMotor->m_limitSoftness = joint_item.softness; - transMotor->m_stopERP.setValue(joint_item.bias_factor, joint_item.bias_factor, joint_item.bias_factor); - transMotor->m_normalCFM.setValue(joint_item.relaxation_factor, joint_item.relaxation_factor, joint_item.relaxation_factor); + transMotor->m_stopERP = joint_item.bias_factor; + transMotor->m_stopCFM = joint_item.relaxation_factor; for (int i = 0; i < 3; i++) { - btRotationalLimitMotor* rotMotor = constraint->getRotationalLimitMotor(i); + btRotationalLimitMotor2* rotMotor = constraint->getRotationalLimitMotor(i); rotMotor->m_limitSoftness = joint_item.softness; rotMotor->m_stopERP = joint_item.bias_factor; rotMotor->m_stopCFM = joint_item.relaxation_factor; From c7d12795c3661a1c7a171a2b251e485a99df62af Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Tue, 10 Mar 2026 17:32:48 +0700 Subject: [PATCH 18/72] Fix physics --- blazerod/render/main/physics/PhysicsWorld.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index e223a7f2..15299593 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -1,4 +1,5 @@ #include "PhysicsWorld.h" +#include #include #include @@ -416,19 +417,23 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->setStiffness(5, joint_item.rotation_spring.z); } - // Apply Stability Parameters (Softness, Bias, Relaxation) + // Apply Stability Parameters (ERP = Bias, CFM = Relaxation) + // Spring2's TranslationalLimitMotor2 has btVector3 for m_stopERP / m_stopCFM btTranslationalLimitMotor2* transMotor = constraint->getTranslationalLimitMotor(); - transMotor->m_limitSoftness = joint_item.softness; - transMotor->m_stopERP = joint_item.bias_factor; - transMotor->m_stopCFM = joint_item.relaxation_factor; + transMotor->m_stopERP.setValue(joint_item.bias_factor, joint_item.bias_factor, joint_item.bias_factor); + transMotor->m_stopCFM.setValue(joint_item.relaxation_factor, joint_item.relaxation_factor, joint_item.relaxation_factor); + // Spring2's RotationalLimitMotor2 has btScalar for m_stopERP / m_stopCFM for (int i = 0; i < 3; i++) { btRotationalLimitMotor2* rotMotor = constraint->getRotationalLimitMotor(i); - rotMotor->m_limitSoftness = joint_item.softness; rotMotor->m_stopERP = joint_item.bias_factor; rotMotor->m_stopCFM = joint_item.relaxation_factor; } + // Spring2 advantage: per-spring damping (0 = no damping, 1 = critical damping) + // Set equilibrium to current position so springs know where "rest" is + constraint->setEquilibriumPoint(); + this->world->addConstraint(constraint.get(), false); this->joints.push_back(std::move(constraint)); } From b8166958d7bd54199bf1001d0bb3c3cd048f9044 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Tue, 10 Mar 2026 18:13:16 +0700 Subject: [PATCH 19/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 6 +++--- blazerod/render/main/physics/PhysicsWorld.cpp | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 4bf52018..bfd4e333 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1232,7 +1232,7 @@ class PmxLoader : ModelFileLoader { val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") val collisionMask = when { - isHair && depth <= 2 -> { + isHair && depth <= 1 -> { baseCollisionMask and bodyGroupMask.inv() } isBreast -> baseCollisionMask @@ -1300,7 +1300,7 @@ class PmxLoader : ModelFileLoader { physicsMode = finalPhysicsMode, ccdMotionThreshold = threshold, ccdSweptSphereRadius = sweptRadius, - collisionMargin = if (isHair || isBreast || isSkirt) 0.008f else 0.04f + collisionMargin = if (isHair || isBreast || isSkirt) 0.015f else 0.04f ) }, ) @@ -1497,7 +1497,7 @@ class PmxLoader : ModelFileLoader { else -> 1.0f }, biasFactor = when { - isBreastJoint -> 0.1f // Slow, heavy error correction to stop shaking + isBreastJoint -> 0.4f // Stiff error correction for Spring2 (was 0.1) (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.3f else -> 0.3f }, diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 15299593..ce1ebb1c 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -429,10 +429,14 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c rotMotor->m_stopERP = joint_item.bias_factor; rotMotor->m_stopCFM = joint_item.relaxation_factor; } - // Spring2 advantage: per-spring damping (0 = no damping, 1 = critical damping) - // Set equilibrium to current position so springs know where "rest" is - constraint->setEquilibriumPoint(); + // Apply critical damping to all enabled spring axes to stop oscillation. + // Default equilibrium is 0 (springs pull toward aligned joint frames) which is + // correct for PMX. Do NOT call setEquilibriumPoint() — it captures incorrect + // body offsets during init and causes sideways drift. + for (int i = 0; i < 6; i++) { + constraint->setDamping(i, 1.0f); + } this->world->addConstraint(constraint.get(), false); this->joints.push_back(std::move(constraint)); From eae02e07c2c3491fb708f0886804d2a6539dcf77 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Tue, 10 Mar 2026 19:37:07 +0700 Subject: [PATCH 20/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index bfd4e333..a26a1ebf 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1293,14 +1293,14 @@ class PmxLoader : ModelFileLoader { rotationDamping = if (isHair) { 0.85f } else if (isBreast) { - 0.5f + 0.95f } else finalRotationDamping, repulsion = 0.0f, frictionForce = if (isHair) 0.1f else rigidBody.frictionForce, physicsMode = finalPhysicsMode, ccdMotionThreshold = threshold, ccdSweptSphereRadius = sweptRadius, - collisionMargin = if (isHair || isBreast || isSkirt) 0.015f else 0.04f + collisionMargin = if (isHair || isBreast || isSkirt) 0.03f else 0.04f ) }, ) @@ -1480,11 +1480,11 @@ class PmxLoader : ModelFileLoader { positionMin = joint.positionMinimum, positionMax = joint.positionMaximum, rotationMin = if (isBreastJoint) { - // Tighten breast rotation to prevent upside-down flips - Vector3f(-0.17f, -0.17f, -0.17f) // ~10 degrees + // Very tight rotation — eliminates visible oscillation + Vector3f(-0.05f, -0.05f, -0.05f) // ~3 degrees } else joint.rotationMinimum, rotationMax = if (isBreastJoint) { - Vector3f(0.17f, 0.17f, 0.17f) + Vector3f(0.05f, 0.05f, 0.05f) } else joint.rotationMaximum, positionSpring = if (isBreastJoint) { // "Studio Quality" vertical bounce (Move Spring = 14.0f) From a11ec5f7e77af1e05f94e72707a7ef91522e32e5 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Tue, 10 Mar 2026 20:23:18 +0700 Subject: [PATCH 21/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index a26a1ebf..2094fcff 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1235,7 +1235,7 @@ class PmxLoader : ModelFileLoader { isHair && depth <= 1 -> { baseCollisionMask and bodyGroupMask.inv() } - isBreast -> baseCollisionMask + isBreast -> baseCollisionMask and bodyGroupMask.inv() isHair -> baseCollisionMask or bodyGroupMask isSkirt -> baseCollisionMask else -> baseCollisionMask From 6328982f1d09fb0f0b96341c8bd3e9412edce9ca Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 13 Mar 2026 14:43:43 +0700 Subject: [PATCH 22/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 13 +++++-------- blazerod/render/main/physics/PhysicsWorld.cpp | 5 ++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 2094fcff..fbd4df9c 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1235,7 +1235,7 @@ class PmxLoader : ModelFileLoader { isHair && depth <= 1 -> { baseCollisionMask and bodyGroupMask.inv() } - isBreast -> baseCollisionMask and bodyGroupMask.inv() + isBreast -> 0 isHair -> baseCollisionMask or bodyGroupMask isSkirt -> baseCollisionMask else -> baseCollisionMask @@ -1286,12 +1286,9 @@ class PmxLoader : ModelFileLoader { } else { rigidBody.mass }, - moveAttenuation = if (isHair) { - // Higher damping for hair to prevent "twist" and over-swing - finalMoveAttenuation.coerceAtLeast(0.8f) - } else finalMoveAttenuation, + moveAttenuation = finalMoveAttenuation, rotationDamping = if (isHair) { - 0.85f + 0.1f } else if (isBreast) { 0.95f } else finalRotationDamping, @@ -1493,12 +1490,12 @@ class PmxLoader : ModelFileLoader { rotationSpring = joint.rotationSpring, softness = when { isBreastJoint -> 0.5f // Soft repositioning - (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.8f + (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.1f // Natural spring bounce else -> 1.0f }, biasFactor = when { isBreastJoint -> 0.4f // Stiff error correction for Spring2 (was 0.1) - (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.3f + (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.1f // Stop violent solver "flicks" else -> 0.3f }, relaxationFactor = 1.0f, diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index ce1ebb1c..58d3cf74 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -429,13 +429,12 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c rotMotor->m_stopERP = joint_item.bias_factor; rotMotor->m_stopCFM = joint_item.relaxation_factor; } - // Spring2 advantage: per-spring damping (0 = no damping, 1 = critical damping) - // Apply critical damping to all enabled spring axes to stop oscillation. + // Apply PMX-defined damping/softness to all enabled spring axes. // Default equilibrium is 0 (springs pull toward aligned joint frames) which is // correct for PMX. Do NOT call setEquilibriumPoint() — it captures incorrect // body offsets during init and causes sideways drift. for (int i = 0; i < 6; i++) { - constraint->setDamping(i, 1.0f); + constraint->setDamping(i, joint_item.softness); } this->world->addConstraint(constraint.get(), false); From 015db0806927845f0aac1ac76c5b12d1de8d6f48 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 13 Mar 2026 15:21:19 +0700 Subject: [PATCH 23/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index fbd4df9c..6f10ada7 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1230,12 +1230,13 @@ class PmxLoader : ModelFileLoader { name.contains("bang") || name.contains("strand") || name.contains("front") || name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") + val isBodyPart = (bodyGroupMask and (1 shl rigidBody.groupId)) != 0 val collisionMask = when { isHair && depth <= 1 -> { baseCollisionMask and bodyGroupMask.inv() } - isBreast -> 0 + isBreast -> baseCollisionMask and bodyGroupMask.inv() isHair -> baseCollisionMask or bodyGroupMask isSkirt -> baseCollisionMask else -> baseCollisionMask @@ -1259,8 +1260,7 @@ class PmxLoader : ModelFileLoader { val safetyDamping = if (isPhysicsEnabled) 0.2f else 0f val finalMoveAttenuation = rigidBody.moveAttenuation.coerceAtLeast(safetyDamping) val finalRotationDamping = rigidBody.rotationDamping.coerceAtLeast(safetyDamping) - - val finalPhysicsMode = if ((isBreast || isSkirt) && adjustedPhysicsMode == RigidBody.PhysicsMode.PHYSICS) { + val finalPhysicsMode = if (isSkirt && adjustedPhysicsMode == RigidBody.PhysicsMode.PHYSICS) { RigidBody.PhysicsMode.PHYSICS_PLUS_BONE } else { adjustedPhysicsMode @@ -1275,7 +1275,11 @@ class PmxLoader : ModelFileLoader { PmxRigidBody.ShapeType.BOX -> RigidBody.ShapeType.BOX PmxRigidBody.ShapeType.CAPSULE -> RigidBody.ShapeType.CAPSULE }, - shapeSize = rigidBody.shapeSize, + shapeSize = if (isBodyPart) { + Vector3f(rigidBody.shapeSize).mul(1.3f) + } else { + rigidBody.shapeSize + }, shapePosition = rigidBody.shapePosition, shapeRotation = rigidBody.shapeRotation, mass = if (isHair) { From ef5f7202bbc4d42ea1d7c7ae80e5de7860cf0840 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 13 Mar 2026 15:53:20 +0700 Subject: [PATCH 24/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 5 +---- blazerod/render/main/physics/PhysicsWorld.cpp | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 6f10ada7..957c6490 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1487,10 +1487,7 @@ class PmxLoader : ModelFileLoader { rotationMax = if (isBreastJoint) { Vector3f(0.05f, 0.05f, 0.05f) } else joint.rotationMaximum, - positionSpring = if (isBreastJoint) { - // "Studio Quality" vertical bounce (Move Spring = 14.0f) - Vector3f(joint.positionSpring.x, 14.0f, joint.positionSpring.z) - } else joint.positionSpring, + positionSpring = joint.positionSpring, rotationSpring = joint.rotationSpring, softness = when { isBreastJoint -> 0.5f // Soft repositioning diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 58d3cf74..d3630bf1 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -434,7 +434,7 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c // correct for PMX. Do NOT call setEquilibriumPoint() — it captures incorrect // body offsets during init and causes sideways drift. for (int i = 0; i < 6; i++) { - constraint->setDamping(i, joint_item.softness); + constraint->setDamping(i, 0.99f); } this->world->addConstraint(constraint.get(), false); From e126abf11b91a9702bddd0fea23b4b9b0446c0dd Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 13 Mar 2026 16:34:56 +0700 Subject: [PATCH 25/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 9 ++++++--- blazerod/render/main/physics/PhysicsWorld.cpp | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 957c6490..5cc96b0f 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1230,7 +1230,10 @@ class PmxLoader : ModelFileLoader { name.contains("bang") || name.contains("strand") || name.contains("front") || name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") - val isBodyPart = (bodyGroupMask and (1 shl rigidBody.groupId)) != 0 + val isBodyPart = (bodyGroupMask and (1 shl rigidBody.groupId)) != 0 && + !name.contains("頭") && !name.contains("head") && + !name.contains("首") && !name.contains("neck") && + !name.contains("顔") && !name.contains("face") val collisionMask = when { isHair && depth <= 1 -> { @@ -1478,8 +1481,8 @@ class PmxLoader : ModelFileLoader { rigidBodyB = RigidBodyId(modelId, joint.rigidBodyIndexB), position = joint.position, rotation = joint.rotation, - positionMin = joint.positionMinimum, - positionMax = joint.positionMaximum, + positionMin = if (isBreastJoint) Vector3f(0f, 0f, 0f) else joint.positionMinimum, + positionMax = if (isBreastJoint) Vector3f(0f, 0f, 0f) else joint.positionMaximum, rotationMin = if (isBreastJoint) { // Very tight rotation — eliminates visible oscillation Vector3f(-0.05f, -0.05f, -0.05f) // ~3 degrees diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index d3630bf1..1b708d8c 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -434,7 +434,11 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c // correct for PMX. Do NOT call setEquilibriumPoint() — it captures incorrect // body offsets during init and causes sideways drift. for (int i = 0; i < 6; i++) { - constraint->setDamping(i, 0.99f); + if (i < 3) { + constraint->setDamping(i, 0.99f); + } else { + constraint->setDamping(i, joint_item.softness); + } } this->world->addConstraint(constraint.get(), false); From e1b269cfaf39f1e8c91dc3df1cf9b1ff63cca220 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 13 Mar 2026 17:13:05 +0700 Subject: [PATCH 26/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 12 +++++++++++- blazerod/render/main/physics/PhysicsWorld.cpp | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 5cc96b0f..c3060373 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1471,6 +1471,12 @@ class PmxLoader : ModelFileLoader { val isBreastJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> n.contains("乳") || n.contains("胸") || n.contains("bust") || n.contains("breast") } + val isHairJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> + n.contains("hair") || n.contains("发") || n.contains("髪") || + n.contains("bang") || n.contains("strand") || n.contains("front") || + n.contains("back") || n.contains("side") || n.contains("tail") + } + val isAhogeJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().contains("ahoge") PhysicalJoint( name = joint.nameLocal.takeIf(String::isNotBlank), @@ -1491,7 +1497,11 @@ class PmxLoader : ModelFileLoader { Vector3f(0.05f, 0.05f, 0.05f) } else joint.rotationMaximum, positionSpring = joint.positionSpring, - rotationSpring = joint.rotationSpring, + rotationSpring = if (isHairJoint && !isAhogeJoint) { + Vector3f(0f, 0f, 0f) + } else { + joint.rotationSpring + }, softness = when { isBreastJoint -> 0.5f // Soft repositioning (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.1f // Natural spring bounce diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 1b708d8c..e80f6b73 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -420,7 +420,7 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c // Apply Stability Parameters (ERP = Bias, CFM = Relaxation) // Spring2's TranslationalLimitMotor2 has btVector3 for m_stopERP / m_stopCFM btTranslationalLimitMotor2* transMotor = constraint->getTranslationalLimitMotor(); - transMotor->m_stopERP.setValue(joint_item.bias_factor, joint_item.bias_factor, joint_item.bias_factor); + transMotor->m_stopERP.setValue(0.8f, 0.8f, 0.8f); transMotor->m_stopCFM.setValue(joint_item.relaxation_factor, joint_item.relaxation_factor, joint_item.relaxation_factor); // Spring2's RotationalLimitMotor2 has btScalar for m_stopERP / m_stopCFM From 1ba973a594d2132be0f014186e16839d09c10b40 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 13 Mar 2026 18:02:19 +0700 Subject: [PATCH 27/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 24 +++++++------------ blazerod/render/main/physics/PhysicsWorld.cpp | 3 +-- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index c3060373..b069a126 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1252,12 +1252,12 @@ class PmxLoader : ModelFileLoader { val threshold = if (needsCCD) { // Set threshold based on the smallest dimension to catch tunneling val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f - minSize * 0.5f // Trigger CCD if moved more than half its size in one step + minSize * 0.1f // Trigger CCD if moved more than half its size in one step } else 0f val sweptRadius = if (needsCCD) { val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f - minSize * 0.2f // Internal swept sphere for collision detection + minSize * 0.4f // Internal swept sphere for collision detection } else 0f val safetyDamping = if (isPhysicsEnabled) 0.2f else 0f @@ -1278,18 +1278,14 @@ class PmxLoader : ModelFileLoader { PmxRigidBody.ShapeType.BOX -> RigidBody.ShapeType.BOX PmxRigidBody.ShapeType.CAPSULE -> RigidBody.ShapeType.CAPSULE }, - shapeSize = if (isBodyPart) { - Vector3f(rigidBody.shapeSize).mul(1.3f) - } else { - rigidBody.shapeSize - }, + shapeSize = rigidBody.shapeSize, shapePosition = rigidBody.shapePosition, shapeRotation = rigidBody.shapeRotation, mass = if (isHair) { // Mass Gradient: Heavy at root, feather-light at tips (Expert setting) (rigidBody.mass * (1.0f / (depth + 1))).coerceAtLeast(0.05f) } else if (isBreast) { - 1.0f // Increased mass for better inertia and less shaking + rigidBody.mass // Increased mass for better inertia and less shaking } else { rigidBody.mass }, @@ -1490,18 +1486,14 @@ class PmxLoader : ModelFileLoader { positionMin = if (isBreastJoint) Vector3f(0f, 0f, 0f) else joint.positionMinimum, positionMax = if (isBreastJoint) Vector3f(0f, 0f, 0f) else joint.positionMaximum, rotationMin = if (isBreastJoint) { - // Very tight rotation — eliminates visible oscillation - Vector3f(-0.05f, -0.05f, -0.05f) // ~3 degrees + // Extremely tight rotation - almost completely static + Vector3f(-0.017f, -0.017f, -0.017f) // ~1 degree } else joint.rotationMinimum, rotationMax = if (isBreastJoint) { - Vector3f(0.05f, 0.05f, 0.05f) + Vector3f(0.017f, 0.017f, 0.017f) } else joint.rotationMaximum, positionSpring = joint.positionSpring, - rotationSpring = if (isHairJoint && !isAhogeJoint) { - Vector3f(0f, 0f, 0f) - } else { - joint.rotationSpring - }, + rotationSpring = joint.rotationSpring, // Restore springs so hair holds its sculpted curve instead of acting like a heavy chain softness = when { isBreastJoint -> 0.5f // Soft repositioning (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.1f // Natural spring bounce diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index e80f6b73..7d595072 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -418,9 +418,8 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c } // Apply Stability Parameters (ERP = Bias, CFM = Relaxation) - // Spring2's TranslationalLimitMotor2 has btVector3 for m_stopERP / m_stopCFM btTranslationalLimitMotor2* transMotor = constraint->getTranslationalLimitMotor(); - transMotor->m_stopERP.setValue(0.8f, 0.8f, 0.8f); + transMotor->m_stopERP.setValue(joint_item.bias_factor, joint_item.bias_factor, joint_item.bias_factor); transMotor->m_stopCFM.setValue(joint_item.relaxation_factor, joint_item.relaxation_factor, joint_item.relaxation_factor); // Spring2's RotationalLimitMotor2 has btScalar for m_stopERP / m_stopCFM From 0a72fafb148548a7a3782769e563228db50af9f1 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 13 Mar 2026 19:41:33 +0700 Subject: [PATCH 28/72] Fix physics --- blazerod/render/main/physics/PhysicsWorld.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 7d595072..fb4ee9b6 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -419,7 +419,7 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c // Apply Stability Parameters (ERP = Bias, CFM = Relaxation) btTranslationalLimitMotor2* transMotor = constraint->getTranslationalLimitMotor(); - transMotor->m_stopERP.setValue(joint_item.bias_factor, joint_item.bias_factor, joint_item.bias_factor); + transMotor->m_stopERP.setValue(1.0f, 1.0f, 1.0f); transMotor->m_stopCFM.setValue(joint_item.relaxation_factor, joint_item.relaxation_factor, joint_item.relaxation_factor); // Spring2's RotationalLimitMotor2 has btScalar for m_stopERP / m_stopCFM From e31d7d1d1d51dbbf9008d2b6e3f4f0ca70a14f3e Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 13 Mar 2026 20:43:35 +0700 Subject: [PATCH 29/72] Fix physics --- blazerod/model/model-pmx/PmxLoader.kt | 2 +- blazerod/render/main/physics/PhysicsWorld.cpp | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index b069a126..e51610e9 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1075,7 +1075,7 @@ class PmxLoader : ModelFileLoader { -rotationMinimumOrig.y, -rotationMinimumOrig.z, ) - val positionSpring = loadVector3f(buffer) + val positionSpring = loadVector3f(buffer).div(MMD_SCALE) val rotationSpring = loadVector3f(buffer) PmxJoint( diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index fb4ee9b6..bef1607f 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -328,7 +328,7 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c if (rigidbody_item.physics_mode != PhysicsMode::PHYSICS) { rigidbody->setActivationState(DISABLE_DEACTIVATION); } - if (rigidbody_item.physics_mode == PhysicsMode::FOLLOW_BONE) { + if (rigidbody_item.physics_mode == PhysicsMode::FOLLOW_BONE || rigidbody_item.physics_mode == PhysicsMode::PHYSICS_PLUS_BONE) { rigidbody->setCollisionFlags(rigidbody->getCollisionFlags() | btCollisionObject::CF_KINEMATIC_OBJECT); } @@ -496,8 +496,6 @@ void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_st rigidbody.motion_state->GetFromWorld(this, rigidbody_index); btTransform world_transform; rigidbody.motion_state->getWorldTransform(world_transform); - rigidbody.rigidbody->setWorldTransform(world_transform); - rigidbody.rigidbody->setInterpolationWorldTransform(world_transform); rigidbody.rigidbody->activate(true); this->world->updateSingleAabb(rigidbody.rigidbody.get()); } From 09e248d89fe0b2bb8809b85c3e0d1b45db769203 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 14 Mar 2026 09:35:37 +0700 Subject: [PATCH 30/72] Physics --- blazerod/render/main/physics/PhysicsWorld.cpp | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index bef1607f..0cde8583 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -328,7 +328,7 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c if (rigidbody_item.physics_mode != PhysicsMode::PHYSICS) { rigidbody->setActivationState(DISABLE_DEACTIVATION); } - if (rigidbody_item.physics_mode == PhysicsMode::FOLLOW_BONE || rigidbody_item.physics_mode == PhysicsMode::PHYSICS_PLUS_BONE) { + if (rigidbody_item.physics_mode == PhysicsMode::FOLLOW_BONE) { rigidbody->setCollisionFlags(rigidbody->getCollisionFlags() | btCollisionObject::CF_KINEMATIC_OBJECT); } @@ -494,8 +494,31 @@ void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_st for (auto& rigidbody : this->rigidbodies) { if (rigidbody.physics_mode == PhysicsMode::FOLLOW_BONE || rigidbody.physics_mode == PhysicsMode::PHYSICS_PLUS_BONE) { rigidbody.motion_state->GetFromWorld(this, rigidbody_index); - btTransform world_transform; - rigidbody.motion_state->getWorldTransform(world_transform); + btTransform target_transform; + rigidbody.motion_state->getWorldTransform(target_transform); + if (delta_time > 0) { + btTransform current_transform = rigidbody.rigidbody->getWorldTransform(); + + btVector3 lin_vel = (target_transform.getOrigin() - current_transform.getOrigin()) / delta_time; + rigidbody.rigidbody->setLinearVelocity(lin_vel); + + btQuaternion q1 = current_transform.getRotation(); + btQuaternion q2 = target_transform.getRotation(); + btQuaternion q_diff = q2 * q1.inverse(); + + btVector3 axis = q_diff.getAxis(); + float angle = q_diff.getAngle(); + if (std::abs(angle) > 0.0001f) { + btVector3 ang_vel = (axis * angle) / delta_time; + rigidbody.rigidbody->setAngularVelocity(ang_vel); + } else { + rigidbody.rigidbody->setAngularVelocity(btVector3(0, 0, 0)); + } + } + + rigidbody.rigidbody->setWorldTransform(target_transform); + rigidbody.rigidbody->setInterpolationWorldTransform(target_transform); + rigidbody.rigidbody->activate(true); this->world->updateSingleAabb(rigidbody.rigidbody.get()); } From 4d41e3b1ee4230c0dbed12a9a9cab87e8bdd8b9a Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 14 Mar 2026 11:12:14 +0700 Subject: [PATCH 31/72] Physics --- blazerod/render/main/physics/PhysicsWorld.cpp | 31 ++++++++++++------- .../render/main/runtime/ModelInstanceImpl.kt | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 0cde8583..573e975e 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -196,7 +196,7 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c btContactSolverInfo& solver_info = this->world->getSolverInfo(); solver_info.m_numIterations = 50; solver_info.m_splitImpulse = 1; - solver_info.m_splitImpulsePenetrationThreshold = -0.01f; + solver_info.m_splitImpulsePenetrationThreshold = -0.001f; solver_info.m_erp = 0.6f; solver_info.m_erp2 = 0.6f; solver_info.m_globalCfm = 0.00001f; @@ -428,15 +428,22 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c rotMotor->m_stopERP = joint_item.bias_factor; rotMotor->m_stopCFM = joint_item.relaxation_factor; } - // Apply PMX-defined damping/softness to all enabled spring axes. - // Default equilibrium is 0 (springs pull toward aligned joint frames) which is - // correct for PMX. Do NOT call setEquilibriumPoint() — it captures incorrect - // body offsets during init and causes sideways drift. + + float mass = rigidbody_b.rigidbody->getInvMass() > 0 ? 1.0f / rigidbody_b.rigidbody->getInvMass() : 1.0f; for (int i = 0; i < 6; i++) { - if (i < 3) { - constraint->setDamping(i, 0.99f); + float stiffness = 0.0f; + if (i == 0) stiffness = joint_item.position_spring.x; + else if (i == 1) stiffness = joint_item.position_spring.y; + else if (i == 2) stiffness = joint_item.position_spring.z; + else if (i == 3) stiffness = joint_item.rotation_spring.x; + else if (i == 4) stiffness = joint_item.rotation_spring.y; + else if (i == 5) stiffness = joint_item.rotation_spring.z; + + if (stiffness > 0.0f) { + float critical_damping = 2.0f * std::sqrt(mass * stiffness); + constraint->setDamping(i, critical_damping * 0.95f); } else { - constraint->setDamping(i, joint_item.softness); + constraint->setDamping(i, i < 3 ? 0.99f : joint_item.softness); } } @@ -496,9 +503,8 @@ void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_st rigidbody.motion_state->GetFromWorld(this, rigidbody_index); btTransform target_transform; rigidbody.motion_state->getWorldTransform(target_transform); + btTransform current_transform = rigidbody.rigidbody->getWorldTransform(); if (delta_time > 0) { - btTransform current_transform = rigidbody.rigidbody->getWorldTransform(); - btVector3 lin_vel = (target_transform.getOrigin() - current_transform.getOrigin()) / delta_time; rigidbody.rigidbody->setLinearVelocity(lin_vel); @@ -516,8 +522,9 @@ void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_st } } - rigidbody.rigidbody->setWorldTransform(target_transform); - rigidbody.rigidbody->setInterpolationWorldTransform(target_transform); + + rigidbody.rigidbody->setWorldTransform(current_transform); + rigidbody.rigidbody->setInterpolationWorldTransform(current_transform); rigidbody.rigidbody->activate(true); this->world->updateSingleAabb(rigidbody.rigidbody.get()); diff --git a/blazerod/render/main/runtime/ModelInstanceImpl.kt b/blazerod/render/main/runtime/ModelInstanceImpl.kt index f17ca321..6b77562d 100644 --- a/blazerod/render/main/runtime/ModelInstanceImpl.kt +++ b/blazerod/render/main/runtime/ModelInstanceImpl.kt @@ -151,7 +151,7 @@ class ModelInstanceImpl( companion object { const val BUDGET_HIGH_MS = 2.0f const val BUDGET_LOW_MS = 1.0f - const val MIN_INTERVAL = 1f / 60f + const val MIN_INTERVAL = 1f / 120f const val MAX_INTERVAL = 1f / 15f const val SPEED_HISTORY_SIZE = 5 const val SPRINT_SPEED_THRESHOLD = 0.04f // ~squared dist for sprinting speed From ebdbbe01e696db864b9642e9c092c7f141d63908 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 14 Mar 2026 12:54:36 +0700 Subject: [PATCH 32/72] Physics --- blazerod/model/model-pmx/PmxLoader.kt | 4 +- blazerod/render/main/physics/PhysicsWorld.cpp | 78 +++++++++++++------ .../render/main/runtime/ModelInstanceImpl.kt | 2 +- 3 files changed, 57 insertions(+), 27 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index e51610e9..fcb0416c 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1252,12 +1252,12 @@ class PmxLoader : ModelFileLoader { val threshold = if (needsCCD) { // Set threshold based on the smallest dimension to catch tunneling val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f - minSize * 0.1f // Trigger CCD if moved more than half its size in one step + minSize * 0.5f } else 0f val sweptRadius = if (needsCCD) { val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f - minSize * 0.4f // Internal swept sphere for collision detection + minSize * 0.2f } else 0f val safetyDamping = if (isPhysicsEnabled) 0.2f else 0f diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 573e975e..e5d51160 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -194,11 +194,11 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c this->world->setGravity(btVector3(0, -98.0f, 0)); btContactSolverInfo& solver_info = this->world->getSolverInfo(); - solver_info.m_numIterations = 50; + solver_info.m_numIterations = 100; solver_info.m_splitImpulse = 1; solver_info.m_splitImpulsePenetrationThreshold = -0.001f; - solver_info.m_erp = 0.6f; - solver_info.m_erp2 = 0.6f; + solver_info.m_erp = 0.8f; + solver_info.m_erp2 = 0.8f; solver_info.m_globalCfm = 0.00001f; this->ground_shape = std::make_unique(btVector3(0, 1, 0), 0.0f); @@ -497,41 +497,71 @@ void PhysicsWorld::ResetRigidBody(size_t rigidbody_index, float px, float py, fl } void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_step) { + if (delta_time <= 0) return; + + struct KinematicUpdate { + size_t index; + btTransform start; + btTransform target; + btVector3 linVel; + btVector3 angVel; + }; + std::vector updates; + size_t rigidbody_index = 0; for (auto& rigidbody : this->rigidbodies) { if (rigidbody.physics_mode == PhysicsMode::FOLLOW_BONE || rigidbody.physics_mode == PhysicsMode::PHYSICS_PLUS_BONE) { rigidbody.motion_state->GetFromWorld(this, rigidbody_index); btTransform target_transform; rigidbody.motion_state->getWorldTransform(target_transform); + btTransform current_transform = rigidbody.rigidbody->getWorldTransform(); - if (delta_time > 0) { - btVector3 lin_vel = (target_transform.getOrigin() - current_transform.getOrigin()) / delta_time; - rigidbody.rigidbody->setLinearVelocity(lin_vel); - - btQuaternion q1 = current_transform.getRotation(); - btQuaternion q2 = target_transform.getRotation(); - btQuaternion q_diff = q2 * q1.inverse(); - - btVector3 axis = q_diff.getAxis(); - float angle = q_diff.getAngle(); - if (std::abs(angle) > 0.0001f) { - btVector3 ang_vel = (axis * angle) / delta_time; - rigidbody.rigidbody->setAngularVelocity(ang_vel); - } else { - rigidbody.rigidbody->setAngularVelocity(btVector3(0, 0, 0)); - } + + btVector3 lin_vel = (target_transform.getOrigin() - current_transform.getOrigin()) / delta_time; + btVector3 ang_vel(0, 0, 0); + + btQuaternion q1 = current_transform.getRotation(); + btQuaternion q2 = target_transform.getRotation(); + btQuaternion q_diff = q2 * q1.inverse(); + btVector3 axis = q_diff.getAxis(); + float angle = q_diff.getAngle(); + if (std::abs(angle) > 0.0001f) { + ang_vel = (axis * angle) / delta_time; } - - rigidbody.rigidbody->setWorldTransform(current_transform); - rigidbody.rigidbody->setInterpolationWorldTransform(current_transform); + updates.push_back({rigidbody_index, current_transform, target_transform, lin_vel, ang_vel}); rigidbody.rigidbody->activate(true); - this->world->updateSingleAabb(rigidbody.rigidbody.get()); } rigidbody_index++; } - this->world->stepSimulation(delta_time, max_sub_steps, fixed_time_step); + + int num_steps = std::min((int)std::ceil(delta_time / fixed_time_step), max_sub_steps); + float sub_dt = delta_time / (float)num_steps; + + for (int step = 1; step <= num_steps; step++) { + float alpha = (float)step / (float)num_steps; + + for (auto& up : updates) { + auto& rb = this->rigidbodies[up.index]; + + btVector3 pos = up.start.getOrigin().lerp(up.target.getOrigin(), alpha); + + btQuaternion rot = up.start.getRotation().slerp(up.target.getRotation(), alpha); + + btTransform sub_transform(rot, pos); + + rb.rigidbody->setWorldTransform(sub_transform); + rb.rigidbody->setInterpolationWorldTransform(sub_transform); + rb.rigidbody->setLinearVelocity(up.linVel); + rb.rigidbody->setAngularVelocity(up.angVel); + + this->world->updateSingleAabb(rb.rigidbody.get()); + } + + this->world->stepSimulation(sub_dt, 1, sub_dt); + } + rigidbody_index = 0; for (auto& rigidbody : this->rigidbodies) { if (rigidbody.motion_state->IsDirty()) { diff --git a/blazerod/render/main/runtime/ModelInstanceImpl.kt b/blazerod/render/main/runtime/ModelInstanceImpl.kt index 6b77562d..d501ed26 100644 --- a/blazerod/render/main/runtime/ModelInstanceImpl.kt +++ b/blazerod/render/main/runtime/ModelInstanceImpl.kt @@ -87,7 +87,7 @@ class ModelInstanceImpl( // If the model is close (LOD < 5m), use High-Fidelity (50 iterations) // If far away, drop to 20 iterations to save CPU. val isHighQuality = lodDistance < 5f - bulletWorld.setSolverIterations(if (isHighQuality) 50 else 20) + bulletWorld.setSolverIterations(if (isHighQuality) 100 else 20) bulletWorld.step(deltaTime, maxSubSteps, fixedTimeStep) } From 9106d929400161410520a6f616d053cb2bced4c7 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 14 Mar 2026 13:46:30 +0700 Subject: [PATCH 33/72] Physics --- blazerod/model/model-pmx/PmxLoader.kt | 4 ++-- blazerod/render/main/physics/PhysicsWorld.cpp | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index fcb0416c..817e3ba3 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1289,9 +1289,9 @@ class PmxLoader : ModelFileLoader { } else { rigidBody.mass }, - moveAttenuation = finalMoveAttenuation, + moveAttenuation = if (isHair) 0.8f else finalMoveAttenuation, rotationDamping = if (isHair) { - 0.1f + 0.5f // Increased from 0.1f to kill jitter } else if (isBreast) { 0.95f } else finalRotationDamping, diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index e5d51160..1f55210a 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -520,6 +520,13 @@ void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_st btVector3 lin_vel = (target_transform.getOrigin() - current_transform.getOrigin()) / delta_time; btVector3 ang_vel(0, 0, 0); + const float MAX_KINEMATIC_LIN_VEL = 40.0f; + const float MAX_KINEMATIC_ANG_VEL = 10.0f; + + if (lin_vel.length2() > MAX_KINEMATIC_LIN_VEL * MAX_KINEMATIC_LIN_VEL) { + lin_vel = lin_vel.normalize() * MAX_KINEMATIC_LIN_VEL; + } + btQuaternion q1 = current_transform.getRotation(); btQuaternion q2 = target_transform.getRotation(); btQuaternion q_diff = q2 * q1.inverse(); @@ -527,6 +534,9 @@ void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_st float angle = q_diff.getAngle(); if (std::abs(angle) > 0.0001f) { ang_vel = (axis * angle) / delta_time; + if (ang_vel.length2() > MAX_KINEMATIC_ANG_VEL * MAX_KINEMATIC_ANG_VEL) { + ang_vel = ang_vel.normalize() * MAX_KINEMATIC_ANG_VEL; + } } updates.push_back({rigidbody_index, current_transform, target_transform, lin_vel, ang_vel}); From 2d7dba2a59f8d7c43a6325d3870ac4ee51ae4243 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 27 Mar 2026 18:50:39 +0700 Subject: [PATCH 34/72] Revert "Physics" This reverts commit 9106d929400161410520a6f616d053cb2bced4c7. --- blazerod/model/model-pmx/PmxLoader.kt | 4 ++-- blazerod/render/main/physics/PhysicsWorld.cpp | 10 ---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 817e3ba3..fcb0416c 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1289,9 +1289,9 @@ class PmxLoader : ModelFileLoader { } else { rigidBody.mass }, - moveAttenuation = if (isHair) 0.8f else finalMoveAttenuation, + moveAttenuation = finalMoveAttenuation, rotationDamping = if (isHair) { - 0.5f // Increased from 0.1f to kill jitter + 0.1f } else if (isBreast) { 0.95f } else finalRotationDamping, diff --git a/blazerod/render/main/physics/PhysicsWorld.cpp b/blazerod/render/main/physics/PhysicsWorld.cpp index 1f55210a..e5d51160 100644 --- a/blazerod/render/main/physics/PhysicsWorld.cpp +++ b/blazerod/render/main/physics/PhysicsWorld.cpp @@ -520,13 +520,6 @@ void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_st btVector3 lin_vel = (target_transform.getOrigin() - current_transform.getOrigin()) / delta_time; btVector3 ang_vel(0, 0, 0); - const float MAX_KINEMATIC_LIN_VEL = 40.0f; - const float MAX_KINEMATIC_ANG_VEL = 10.0f; - - if (lin_vel.length2() > MAX_KINEMATIC_LIN_VEL * MAX_KINEMATIC_LIN_VEL) { - lin_vel = lin_vel.normalize() * MAX_KINEMATIC_LIN_VEL; - } - btQuaternion q1 = current_transform.getRotation(); btQuaternion q2 = target_transform.getRotation(); btQuaternion q_diff = q2 * q1.inverse(); @@ -534,9 +527,6 @@ void PhysicsWorld::Step(float delta_time, int max_sub_steps, float fixed_time_st float angle = q_diff.getAngle(); if (std::abs(angle) > 0.0001f) { ang_vel = (axis * angle) / delta_time; - if (ang_vel.length2() > MAX_KINEMATIC_ANG_VEL * MAX_KINEMATIC_ANG_VEL) { - ang_vel = ang_vel.normalize() * MAX_KINEMATIC_ANG_VEL; - } } updates.push_back({rigidbody_index, current_transform, target_transform, lin_vel, ang_vel}); From 2044f2b7d81fb1de52df9cbec0066d8ce623a92b Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 27 Mar 2026 20:36:47 +0700 Subject: [PATCH 35/72] Fix Koikatsu's ghost physics --- blazerod/model/model-pmx/PmxLoader.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index fcb0416c..4fa605c4 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1198,7 +1198,9 @@ class PmxLoader : ModelFileLoader { nameLocal.startsWith("Ribbon_Braid_") -> basePhysicsMode nameLocal.startsWith("Ribbon_") || nameLocal.startsWith("Pocket Watch_") || - nameLocal.startsWith("Strap_") -> + nameLocal.startsWith("Strap_") || + nameLocal.startsWith("cf_j_", ignoreCase = true) || + nameLocal.startsWith("cf_s_", ignoreCase = true) -> RigidBody.PhysicsMode.FOLLOW_BONE nameLocal.startsWith("Skirt_") && basePhysicsMode == RigidBody.PhysicsMode.PHYSICS -> From e3a40bf9fb3279dd5107d6cb8383571182f6bc1f Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 27 Mar 2026 21:17:02 +0700 Subject: [PATCH 36/72] Follow Bone --- blazerod/model/model-pmx/PmxLoader.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 4fa605c4..7057677e 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1183,7 +1183,8 @@ class PmxLoader : ModelFileLoader { NodeComponent.RigidBodyComponent( rigidBodyId = RigidBodyId(modelId, index), rigidBody = rigidBodies[index].let { rigidBody -> - val enableNameBasedOverrides = false + val isKoikatsu = bones.any { it.nameLocal.startsWith("cf_", ignoreCase = true) } + val enableNameBasedOverrides = isKoikatsu val basePhysicsMode = when (rigidBody.physicsMode) { PmxRigidBody.PhysicsMode.FOLLOW_BONE -> RigidBody.PhysicsMode.FOLLOW_BONE PmxRigidBody.PhysicsMode.PHYSICS -> RigidBody.PhysicsMode.PHYSICS @@ -1191,8 +1192,15 @@ class PmxLoader : ModelFileLoader { } val nameLocal = rigidBody.nameLocal + val name = nameLocal.lowercase() + val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") + val isHair = name.contains("hair") || name.contains("发") || name.contains("髪") || name.contains("bang") || name.contains("strand") || name.contains("front") || name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") + val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") + val adjustedPhysicsMode = if (enableNameBasedOverrides) { when { + isBreast || isHair || isSkirt -> + RigidBody.PhysicsMode.FOLLOW_BONE nameLocal.startsWith("Skirt_D_") -> RigidBody.PhysicsMode.FOLLOW_BONE nameLocal.startsWith("Ribbon_Braid_") -> basePhysicsMode @@ -1226,12 +1234,6 @@ class PmxLoader : ModelFileLoader { } .fold(0) { acc, i -> acc or (1 shl rigidBodies[i].groupId) } - val name = rigidBody.nameLocal.lowercase() - val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") - val isHair = name.contains("hair") || name.contains("发") || name.contains("髪") || - name.contains("bang") || name.contains("strand") || name.contains("front") || - name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") - val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") val isBodyPart = (bodyGroupMask and (1 shl rigidBody.groupId)) != 0 && !name.contains("頭") && !name.contains("head") && !name.contains("首") && !name.contains("neck") && From 2f9065148f8b3972477ff6b55dbfc7b7278a9907 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Fri, 27 Mar 2026 21:51:28 +0700 Subject: [PATCH 37/72] Follow Bone --- blazerod/model/model-pmx/PmxLoader.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 7057677e..0813d383 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1477,6 +1477,12 @@ class PmxLoader : ModelFileLoader { n.contains("back") || n.contains("side") || n.contains("tail") } val isAhogeJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().contains("ahoge") + val isSkirtJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> + n.contains("skirt") || n.contains("スカート") || n.contains("ribbon") + } + if (isKoikatsu && (isBreastJoint || isHairJoint || isSkirtJoint)) { + return@mapNotNull null + } PhysicalJoint( name = joint.nameLocal.takeIf(String::isNotBlank), From 7eb3503c43961cb86a0f984ce74d7fe2cf122467 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 07:19:02 +0700 Subject: [PATCH 38/72] Fix build --- blazerod/model/model-pmx/PmxLoader.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 0813d383..3a9b4f09 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1108,6 +1108,7 @@ class PmxLoader : ModelFileLoader { loadTextures(buffer) loadMaterials(buffer) loadBones(buffer) + val isKoikatsu = bones.any { it.nameLocal.startsWith("cf_", ignoreCase = true) } loadMorphTargets(buffer) loadDisplayFrames(buffer) loadRigidBodies(buffer) @@ -1183,7 +1184,6 @@ class PmxLoader : ModelFileLoader { NodeComponent.RigidBodyComponent( rigidBodyId = RigidBodyId(modelId, index), rigidBody = rigidBodies[index].let { rigidBody -> - val isKoikatsu = bones.any { it.nameLocal.startsWith("cf_", ignoreCase = true) } val enableNameBasedOverrides = isKoikatsu val basePhysicsMode = when (rigidBody.physicsMode) { PmxRigidBody.PhysicsMode.FOLLOW_BONE -> RigidBody.PhysicsMode.FOLLOW_BONE From 7e424ae6ab4f3b5cbcb7f268425adde2012ec12d Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 08:29:49 +0700 Subject: [PATCH 39/72] Fix anchoring --- blazerod/model/model-pmx/PmxLoader.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 3a9b4f09..155ff703 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1180,6 +1180,14 @@ class PmxLoader : ModelFileLoader { }.fold(0) { acc, rb -> acc or (1 shl rb.groupId) } boneToRigidBodyMap[index]?.forEach { index -> + val rigidBody = rigidBodies[index] + val name = rigidBody.nameLocal.lowercase() + val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") + val isHair = name.contains("hair") || name.contains("发") || name.contains("髪") || name.contains("bang") || name.contains("strand") || name.contains("front") || name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") + val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") + + if (isKoikatsu && (isBreast || isHair || isSkirt)) return@forEach + add( NodeComponent.RigidBodyComponent( rigidBodyId = RigidBodyId(modelId, index), @@ -1192,10 +1200,6 @@ class PmxLoader : ModelFileLoader { } val nameLocal = rigidBody.nameLocal - val name = nameLocal.lowercase() - val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") - val isHair = name.contains("hair") || name.contains("发") || name.contains("髪") || name.contains("bang") || name.contains("strand") || name.contains("front") || name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") - val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") val adjustedPhysicsMode = if (enableNameBasedOverrides) { when { From fa82084ee347c45be6c4bdae0ec39ad336b257dc Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 09:53:36 +0700 Subject: [PATCH 40/72] Fix some keys in scene --- blazerod/model/model-pmx/PmxLoader.kt | 81 ++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 155ff703..cc6faea1 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -92,6 +92,7 @@ class PmxLoader : ModelFileLoader { private lateinit var rigidBodies: List private var boneToRigidBodyMap = mutableMapOf>() private lateinit var joints: List + private var isKoikatsu: Boolean = false private fun loadRgbColor(buffer: ByteBuffer): RgbColor { if (buffer.remaining() < 3 * 4) { @@ -814,6 +815,74 @@ class PmxLoader : ModelFileLoader { } } + private fun repairKoikatsuHierarchy() { + if (!isKoikatsu) return + + val spine03Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine03", ignoreCase = true) } + val spine02Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine02", ignoreCase = true) } + val spine01Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine01", ignoreCase = true) } + val spineSub01Index = bones.indexOfFirst { it.nameLocal.equals("cf_s_spine01", ignoreCase = true) } + + val hipsIndex = bones.indexOfFirst { it.nameLocal.equals("cf_j_hips", ignoreCase = true) } + val hipsSubIndex = bones.indexOfFirst { it.nameLocal.equals("cf_s_hips", ignoreCase = true) } + + val upperBodyIndex = when { + spine03Index != -1 -> spine03Index + spine02Index != -1 -> spine02Index + spine01Index != -1 -> spine01Index + spineSub01Index != -1 -> spineSub01Index + else -> -1 + } + + val pelvisIndex = when { + hipsIndex != -1 -> hipsIndex + hipsSubIndex != -1 -> hipsSubIndex + else -> -1 + } + + if (upperBodyIndex == -1 && pelvisIndex == -1) { + logger.warn("[HIERARCHY-REPAIR] Koikatsu model detected but no anchor bones (spine/hips) found. Repair might be incomplete.") + } + + logger.info("[HIERARCHY-REPAIR] Koikatsu model detected. Attempting to repair dangling bones...") + + val newBones = bones.mapIndexed { index, bone -> + if (bone.parentBoneIndex == null || bone.parentBoneIndex == -1) { + val name = bone.nameLocal.lowercase() + val newParent = when { + // Breasts usually attach to the upper torso + (name.contains("cf_s_bust") || name.contains("cf_j_bust")) && upperBodyIndex != -1 -> upperBodyIndex + + // Skirts and lower body dynamics attach to the hips + (name.contains("cf_s_skirt") || name.contains("cf_j_sk_") || name.contains("cf_d_sk_")) && pelvisIndex != -1 -> pelvisIndex + + // Auxiliary bones (twist, shoulder helpers) should follow the main hips if they are dangling + // This prevents them from staying at 0,0,0 + (name.contains("cf_s_") || name.contains("cf_d_") || name.contains("捩")) && pelvisIndex != -1 && !name.contains("spine") -> pelvisIndex + + else -> null + } + + if (newParent != null && newParent != index) { + logger.info("[HIERARCHY-REPAIR] Reparenting dangling bone ${bone.nameLocal} (index $index) to parent index $newParent") + + // Update childBoneMap for consistency + childBoneMap.getOrPut(newParent) { mutableListOf() }.add(index) + + // Update rootBones - remove the bone from root list since it now has a parent + rootBones.removeIf { it == index } + + bone.copy(parentBoneIndex = newParent) + } else { + bone + } + } else { + bone + } + } + bones = newBones + } + private fun loadMorphTargets(buffer: ByteBuffer) { val morphTargetCount = buffer.getInt() if (morphTargetCount < 0) { @@ -1108,7 +1177,8 @@ class PmxLoader : ModelFileLoader { loadTextures(buffer) loadMaterials(buffer) loadBones(buffer) - val isKoikatsu = bones.any { it.nameLocal.startsWith("cf_", ignoreCase = true) } + isKoikatsu = bones.any { it.nameLocal.contains("cf_", ignoreCase = true) } + repairKoikatsuHierarchy() loadMorphTargets(buffer) loadDisplayFrames(buffer) loadRigidBodies(buffer) @@ -1473,16 +1543,13 @@ class PmxLoader : ModelFileLoader { val rbA = rigidBodies[joint.rigidBodyIndexA] val rbB = rigidBodies[joint.rigidBodyIndexB] val isBreastJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> - n.contains("乳") || n.contains("胸") || n.contains("bust") || n.contains("breast") + n.contains("乳") || n.contains("胸") || n.contains("bust") || n.contains("breast") || n.contains("cf_s_bust") } val isHairJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> - n.contains("hair") || n.contains("发") || n.contains("髪") || - n.contains("bang") || n.contains("strand") || n.contains("front") || - n.contains("back") || n.contains("side") || n.contains("tail") + n.contains("hair") || n.contains("发") || n.contains("髪") || n.contains("bang") || n.contains("strand") || n.contains("front") || n.contains("back") || n.contains("side") || n.contains("tail") || n.contains("ahoge") } - val isAhogeJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().contains("ahoge") val isSkirtJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> - n.contains("skirt") || n.contains("スカート") || n.contains("ribbon") + n.contains("skirt") || n.contains("スカート") || n.contains("cf_s_skirt") || n.contains("cf_j_sk_") || n.contains("cf_d_sk_") || n.contains("ribbon") } if (isKoikatsu && (isBreastJoint || isHairJoint || isSkirtJoint)) { return@mapNotNull null From dbe07de4602e76687fd2b95d471cacd5ae5d323b Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 10:45:21 +0700 Subject: [PATCH 41/72] Add some missing keys --- blazerod/model/model-pmx/PmxLoader.kt | 100 ++++++++++++++++++-------- 1 file changed, 72 insertions(+), 28 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index cc6faea1..af4f8796 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -813,6 +813,12 @@ class PmxLoader : ModelFileLoader { } } } + + // isKoikatsu detection - broaden to catch all major prefixes + isKoikatsu = bones.any { + val n = it.nameLocal.lowercase() + n.startsWith("cf_") || n.startsWith("ct_") || n.contains("k_f_") || n.contains("k_t_") + } } private fun repairKoikatsuHierarchy() { @@ -822,21 +828,35 @@ class PmxLoader : ModelFileLoader { val spine02Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine02", ignoreCase = true) } val spine01Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine01", ignoreCase = true) } val spineSub01Index = bones.indexOfFirst { it.nameLocal.equals("cf_s_spine01", ignoreCase = true) } + val spineAllIndex = bones.indexOfFirst { it.nameLocal.contains("spine", ignoreCase = true) || it.nameLocal.contains("脊髄", ignoreCase = true) } val hipsIndex = bones.indexOfFirst { it.nameLocal.equals("cf_j_hips", ignoreCase = true) } val hipsSubIndex = bones.indexOfFirst { it.nameLocal.equals("cf_s_hips", ignoreCase = true) } + val hipsAllIndex = bones.indexOfFirst { it.nameLocal.contains("hips", ignoreCase = true) || it.nameLocal.contains("下半身", ignoreCase = true) } + + val headIndex = bones.indexOfFirst { it.nameLocal.contains("head", ignoreCase = true) || it.nameLocal.contains("頭", ignoreCase = true) } + val neckIndex = bones.indexOfFirst { it.nameLocal.contains("neck", ignoreCase = true) || it.nameLocal.contains("首", ignoreCase = true) } val upperBodyIndex = when { spine03Index != -1 -> spine03Index spine02Index != -1 -> spine02Index spine01Index != -1 -> spine01Index spineSub01Index != -1 -> spineSub01Index + spineAllIndex != -1 -> spineAllIndex else -> -1 } val pelvisIndex = when { hipsIndex != -1 -> hipsIndex hipsSubIndex != -1 -> hipsSubIndex + hipsAllIndex != -1 -> hipsAllIndex + else -> -1 + } + + val headAnchorIndex = when { + headIndex != -1 -> headIndex + neckIndex != -1 -> neckIndex + upperBodyIndex != -1 -> upperBodyIndex else -> -1 } @@ -844,26 +864,34 @@ class PmxLoader : ModelFileLoader { logger.warn("[HIERARCHY-REPAIR] Koikatsu model detected but no anchor bones (spine/hips) found. Repair might be incomplete.") } - logger.info("[HIERARCHY-REPAIR] Koikatsu model detected. Attempting to repair dangling bones...") + logger.info("[HIERARCHY-REPAIR] Koikatsu model detected. Identifying and repairing dangling bones...") val newBones = bones.mapIndexed { index, bone -> if (bone.parentBoneIndex == null || bone.parentBoneIndex == -1) { val name = bone.nameLocal.lowercase() val newParent = when { - // Breasts usually attach to the upper torso - (name.contains("cf_s_bust") || name.contains("cf_j_bust")) && upperBodyIndex != -1 -> upperBodyIndex + // Universal body part matching for all Koikatsu prefixes (cf_s, cf_j, cf_d, cf_m, ct_) + (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳")) && upperBodyIndex != -1 -> upperBodyIndex - // Skirts and lower body dynamics attach to the hips - (name.contains("cf_s_skirt") || name.contains("cf_j_sk_") || name.contains("cf_d_sk_")) && pelvisIndex != -1 -> pelvisIndex + // Skirts and lower body dynamics + (name.contains("skirt") || name.contains("スカート") || name.contains("腰") || name.contains("cf_j_sk_") || name.contains("cf_d_sk_")) && pelvisIndex != -1 -> pelvisIndex - // Auxiliary bones (twist, shoulder helpers) should follow the main hips if they are dangling - // This prevents them from staying at 0,0,0 - (name.contains("cf_s_") || name.contains("cf_d_") || name.contains("捩")) && pelvisIndex != -1 && !name.contains("spine") -> pelvisIndex + // Hair and face accessories + (name.contains("hair") || name.contains("髪") || name.contains("ribbon") || name.contains("ct_")) && headAnchorIndex != -1 -> headAnchorIndex + + // General dynamic adjustment bones and twist helpers + (name.contains("cf_s_") || name.contains("cf_d_") || name.contains("cf_m_") || name.contains("捩") || name.contains("肩")) -> { + when { + name.contains("arm") || name.contains("shoulder") || name.contains("hand") || name.contains("bust") -> upperBodyIndex + name.contains("leg") || name.contains("foot") || name.contains("skirt") || name.contains("hips") -> pelvisIndex + else -> upperBodyIndex // Fallback to upper body + } + } else -> null - } + }?.takeIf { it != -1 && it != index } - if (newParent != null && newParent != index) { + if (newParent != null) { logger.info("[HIERARCHY-REPAIR] Reparenting dangling bone ${bone.nameLocal} (index $index) to parent index $newParent") // Update childBoneMap for consistency @@ -881,6 +909,16 @@ class PmxLoader : ModelFileLoader { } } bones = newBones + + // Task 3: Verification - Log any remaining root bones for diagnostic purposes + val remainingRoots = bones.indices.filter { bones[it].parentBoneIndex == null || bones[it].parentBoneIndex == -1 } + if (remainingRoots.isNotEmpty()) { + val rootNames = remainingRoots.take(10).joinToString { bones[it].nameLocal } + val more = if (remainingRoots.size > 10) "... and ${remainingRoots.size - 10} more" else "" + logger.info("[HIERARCHY-REPAIR] Repair complete. $remainingRoots.size bones remain as roots: $rootNames$more") + } else { + logger.info("[HIERARCHY-REPAIR] Repair complete. Zero root bones remain.") + } } private fun loadMorphTargets(buffer: ByteBuffer) { @@ -1256,8 +1294,10 @@ class PmxLoader : ModelFileLoader { val isHair = name.contains("hair") || name.contains("发") || name.contains("髪") || name.contains("bang") || name.contains("strand") || name.contains("front") || name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") - if (isKoikatsu && (isBreast || isHair || isSkirt)) return@forEach - + // Task 2: Refine physics loading logic. + // Instead of skipping, we allow the component creation but force FOLLOW_BONE mode. + // This ensures the mesh has its associated physics metadata but doesn't fly away. + add( NodeComponent.RigidBodyComponent( rigidBodyId = RigidBodyId(modelId, index), @@ -1269,24 +1309,23 @@ class PmxLoader : ModelFileLoader { PmxRigidBody.PhysicsMode.PHYSICS_PLUS_BONE -> RigidBody.PhysicsMode.PHYSICS_PLUS_BONE } - val nameLocal = rigidBody.nameLocal + val nameLocal = rigidBody.nameLocal.lowercase() val adjustedPhysicsMode = if (enableNameBasedOverrides) { when { isBreast || isHair || isSkirt -> RigidBody.PhysicsMode.FOLLOW_BONE - nameLocal.startsWith("Skirt_D_") -> + nameLocal.contains("skirt") || nameLocal.contains("スカート") || nameLocal.contains("腰") -> + RigidBody.PhysicsMode.FOLLOW_BONE + nameLocal.contains("hair") || nameLocal.contains("髪") || nameLocal.contains("ribbon") || nameLocal.contains("ct_") -> + RigidBody.PhysicsMode.FOLLOW_BONE + nameLocal.contains("bust") || nameLocal.contains("breast") || nameLocal.contains("胸") || nameLocal.contains("乳") -> RigidBody.PhysicsMode.FOLLOW_BONE - nameLocal.startsWith("Ribbon_Braid_") -> basePhysicsMode - nameLocal.startsWith("Ribbon_") || - nameLocal.startsWith("Pocket Watch_") || - nameLocal.startsWith("Strap_") || nameLocal.startsWith("cf_j_", ignoreCase = true) || - nameLocal.startsWith("cf_s_", ignoreCase = true) -> + nameLocal.startsWith("cf_s_", ignoreCase = true) || + nameLocal.startsWith("cf_d_", ignoreCase = true) || + nameLocal.startsWith("cf_m_", ignoreCase = true) -> RigidBody.PhysicsMode.FOLLOW_BONE - nameLocal.startsWith("Skirt_") && - basePhysicsMode == RigidBody.PhysicsMode.PHYSICS -> - RigidBody.PhysicsMode.PHYSICS_PLUS_BONE else -> basePhysicsMode } } else { @@ -1542,16 +1581,21 @@ class PmxLoader : ModelFileLoader { } val rbA = rigidBodies[joint.rigidBodyIndexA] val rbB = rigidBodies[joint.rigidBodyIndexB] - val isBreastJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> + val nameCombo = (rbA.nameLocal + rbB.nameLocal).lowercase() + val isBreastJoint = nameCombo.let { n -> n.contains("乳") || n.contains("胸") || n.contains("bust") || n.contains("breast") || n.contains("cf_s_bust") } - val isHairJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> - n.contains("hair") || n.contains("发") || n.contains("髪") || n.contains("bang") || n.contains("strand") || n.contains("front") || n.contains("back") || n.contains("side") || n.contains("tail") || n.contains("ahoge") + val isHairJoint = nameCombo.let { n -> + n.contains("hair") || n.contains("发") || n.contains("髪") || n.contains("bang") || n.contains("strand") || n.contains("front") || n.contains("back") || n.contains("side") || n.contains("tail") || n.contains("ahoge") || n.contains("ct_") || n.contains("ribbon") } - val isSkirtJoint = (rbA.nameLocal + rbB.nameLocal).lowercase().let { n -> - n.contains("skirt") || n.contains("スカート") || n.contains("cf_s_skirt") || n.contains("cf_j_sk_") || n.contains("cf_d_sk_") || n.contains("ribbon") + val isSkirtJoint = nameCombo.let { n -> + n.contains("skirt") || n.contains("スカート") || n.contains("cf_s_skirt") || n.contains("cf_j_sk_") || n.contains("cf_d_sk_") || n.contains("腰") } - if (isKoikatsu && (isBreastJoint || isHairJoint || isSkirtJoint)) { + val isHelperJoint = nameCombo.let { n -> + n.contains("cf_m_") || n.contains("cf_t_") || n.contains("捩") || n.contains("肩") + } + + if (isKoikatsu && (isBreastJoint || isHairJoint || isSkirtJoint || isHelperJoint)) { return@mapNotNull null } From 7b60abab1139d42efedc42d99a48526f92872d56 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 13:10:45 +0700 Subject: [PATCH 42/72] Add some missing root --- blazerod/model/model-pmx/PmxLoader.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index af4f8796..c82c16a9 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -867,8 +867,15 @@ class PmxLoader : ModelFileLoader { logger.info("[HIERARCHY-REPAIR] Koikatsu model detected. Identifying and repairing dangling bones...") val newBones = bones.mapIndexed { index, bone -> - if (bone.parentBoneIndex == null || bone.parentBoneIndex == -1) { - val name = bone.nameLocal.lowercase() + val currentParent = bone.parentBoneIndex ?: -1 + val name = bone.nameLocal.lowercase() + + // Skeleton protection: Don't reparent the main movement roots (Center, Groove, Waist) + val isCoreSkeleton = name.contains("センター") || name.contains("center") || + name.contains("グルーブ") || name.contains("groove") || + name.contains("腰") || name.contains("waist") || name.contains("全ての親") + + if ((currentParent == -1 || currentParent == 0) && !isCoreSkeleton) { val newParent = when { // Universal body part matching for all Koikatsu prefixes (cf_s, cf_j, cf_d, cf_m, ct_) (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳")) && upperBodyIndex != -1 -> upperBodyIndex @@ -889,10 +896,10 @@ class PmxLoader : ModelFileLoader { } else -> null - }?.takeIf { it != -1 && it != index } + }?.takeIf { it != -1 && it != index && it != currentParent } if (newParent != null) { - logger.info("[HIERARCHY-REPAIR] Reparenting dangling bone ${bone.nameLocal} (index $index) to parent index $newParent") + logger.info("[HIERARCHY-REPAIR] Reparenting anchor-locked bone ${bone.nameLocal} (index $index, parent $currentParent) to parent index $newParent") // Update childBoneMap for consistency childBoneMap.getOrPut(newParent) { mutableListOf() }.add(index) @@ -915,7 +922,7 @@ class PmxLoader : ModelFileLoader { if (remainingRoots.isNotEmpty()) { val rootNames = remainingRoots.take(10).joinToString { bones[it].nameLocal } val more = if (remainingRoots.size > 10) "... and ${remainingRoots.size - 10} more" else "" - logger.info("[HIERARCHY-REPAIR] Repair complete. $remainingRoots.size bones remain as roots: $rootNames$more") + logger.info("[HIERARCHY-REPAIR] Repair complete. ${remainingRoots.size} bones remain as roots: $rootNames$more") } else { logger.info("[HIERARCHY-REPAIR] Repair complete. Zero root bones remain.") } From a636c5dc2e4c337120965c8012ebe804b9239973 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 13:46:12 +0700 Subject: [PATCH 43/72] Add some missing fix --- blazerod/model/model-pmx/PmxLoader.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index c82c16a9..b3087dc6 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -814,10 +814,15 @@ class PmxLoader : ModelFileLoader { } } - // isKoikatsu detection - broaden to catch all major prefixes + // isKoikatsu detection - broaden to catch all major prefixes and common Koikatsu naming patterns isKoikatsu = bones.any { val n = it.nameLocal.lowercase() - n.startsWith("cf_") || n.startsWith("ct_") || n.contains("k_f_") || n.contains("k_t_") + n.startsWith("cf_") || n.startsWith("ct_") || n.contains("k_f_") || n.contains("k_t_") || + n.contains("bust") || n.contains("breast") || n.contains("skirt") || + n.contains("胸") || n.contains("乳") || n.contains("髪") || n.contains("スカート") + } + if (isKoikatsu) { + logger.info("[HIERARCHY-REPAIR] Koikatsu-style bone naming detected. Hierarchy repair active.") } } From 0a8ebd4d349e8eff38dd8ab2b3b4f853363738d2 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 14:42:47 +0700 Subject: [PATCH 44/72] Add some missing root --- blazerod/model/model-pmx/PmxLoader.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index b3087dc6..842c28e2 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1227,7 +1227,6 @@ class PmxLoader : ModelFileLoader { loadTextures(buffer) loadMaterials(buffer) loadBones(buffer) - isKoikatsu = bones.any { it.nameLocal.contains("cf_", ignoreCase = true) } repairKoikatsuHierarchy() loadMorphTargets(buffer) loadDisplayFrames(buffer) From 71a463fe78dc8d8934719f1b037e89b10f7daa19 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 15:26:32 +0700 Subject: [PATCH 45/72] Remove some bone --- blazerod/model/model-pmx/PmxLoader.kt | 53 ++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 842c28e2..44552cfc 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -398,7 +398,13 @@ class PmxLoader : ModelFileLoader { when (weightDeformType) { // BDEF1 0 -> { - val index1 = readBoneIndex() + var index1 = readBoneIndex() + if (isKoikatsu && index1 == 0) { + // Weight scrubbing: If a vertex is weighted to Bone 0, it stays at world origin. + // We don't have per-vertex bone names, so we can't be ultra-selective here, + // but usually only "floating" parts or incorrectly ported ones have this issue. + // In this loader, we just let it be, but BDEF2/4 are more surgical. + } outputBuffer.putInt(outputPosition, index1) if (index1 != -1) { outputBuffer.putFloat(outputPosition + 16, 1f) @@ -406,9 +412,17 @@ class PmxLoader : ModelFileLoader { } // BDEF2 1 -> { - val index1 = readBoneIndex() - val index2 = readBoneIndex() + var index1 = readBoneIndex() + var index2 = readBoneIndex() val weight1 = readWeight() + + if (isKoikatsu) { + // If weight 1 is Bone 0 and weight 2 is something else, or vice-versa + // Redirect Bone 0 to the other bone to prevent vertex anchor stretching. + if (index1 == 0 && index2 > 0) index1 = index2 + else if (index2 == 0 && index1 > 0) index2 = index1 + } + outputBuffer.putInt(outputPosition, index1) outputBuffer.putInt(outputPosition + 4, index2) if (index1 != -1) { @@ -420,14 +434,24 @@ class PmxLoader : ModelFileLoader { } // BDEF4, or not really supported QDEF 2, 4 -> { - val index1 = readBoneIndex() - val index2 = readBoneIndex() - val index3 = readBoneIndex() - val index4 = readBoneIndex() + var index1 = readBoneIndex() + var index2 = readBoneIndex() + var index3 = readBoneIndex() + var index4 = readBoneIndex() val weight1 = readWeight() val weight2 = readWeight() val weight3 = readWeight() val weight4 = readWeight() + + if (isKoikatsu) { + // Find first non-zero bone to replace any Bone 0 weights + val fallback = listOf(index1, index2, index3, index4).firstOrNull { it > 0 } ?: 0 + if (index1 == 0) index1 = fallback + if (index2 == 0) index2 = fallback + if (index3 == 0) index3 = fallback + if (index4 == 0) index4 = fallback + } + outputBuffer.putInt(outputPosition, index1) outputBuffer.putInt(outputPosition + 4, index2) outputBuffer.putInt(outputPosition + 8, index3) @@ -448,9 +472,15 @@ class PmxLoader : ModelFileLoader { 3 -> { // SDEF, not really supported, just treat as BDEF2 - val index1 = readBoneIndex() - val index2 = readBoneIndex() + var index1 = readBoneIndex() + var index2 = readBoneIndex() val weight1 = readWeight() + + if (isKoikatsu) { + if (index1 == 0 && index2 > 0) index1 = index2 + else if (index2 == 0 && index1 > 0) index2 = index1 + } + outputBuffer.putInt(outputPosition, index1) outputBuffer.putInt(outputPosition + 4, index2) if (index1 != -1) { @@ -909,6 +939,11 @@ class PmxLoader : ModelFileLoader { // Update childBoneMap for consistency childBoneMap.getOrPut(newParent) { mutableListOf() }.add(index) + // CRITICAL: Remove from old parent child map + if (currentParent != -1) { + childBoneMap[currentParent]?.remove(index) + } + // Update rootBones - remove the bone from root list since it now has a parent rootBones.removeIf { it == index } From 5b88d4bee6af8dcf61000f654470d69302311450 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 16:11:28 +0700 Subject: [PATCH 46/72] Resort --- blazerod/model/model-pmx/PmxLoader.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 44552cfc..0cc9df2b 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -859,6 +859,7 @@ class PmxLoader : ModelFileLoader { private fun repairKoikatsuHierarchy() { if (!isKoikatsu) return + // PASS 1: Discovery. Find anchor bones regardless of their position in the file. val spine03Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine03", ignoreCase = true) } val spine02Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine02", ignoreCase = true) } val spine01Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine01", ignoreCase = true) } @@ -868,6 +869,7 @@ class PmxLoader : ModelFileLoader { val hipsIndex = bones.indexOfFirst { it.nameLocal.equals("cf_j_hips", ignoreCase = true) } val hipsSubIndex = bones.indexOfFirst { it.nameLocal.equals("cf_s_hips", ignoreCase = true) } val hipsAllIndex = bones.indexOfFirst { it.nameLocal.contains("hips", ignoreCase = true) || it.nameLocal.contains("下半身", ignoreCase = true) } + val waistIndex = bones.indexOfFirst { it.nameLocal.contains("腰", ignoreCase = true) || it.nameLocal.contains("waist", ignoreCase = true) } val headIndex = bones.indexOfFirst { it.nameLocal.contains("head", ignoreCase = true) || it.nameLocal.contains("頭", ignoreCase = true) } val neckIndex = bones.indexOfFirst { it.nameLocal.contains("neck", ignoreCase = true) || it.nameLocal.contains("首", ignoreCase = true) } @@ -885,6 +887,7 @@ class PmxLoader : ModelFileLoader { hipsIndex != -1 -> hipsIndex hipsSubIndex != -1 -> hipsSubIndex hipsAllIndex != -1 -> hipsAllIndex + waistIndex != -1 -> waistIndex else -> -1 } @@ -897,10 +900,13 @@ class PmxLoader : ModelFileLoader { if (upperBodyIndex == -1 && pelvisIndex == -1) { logger.warn("[HIERARCHY-REPAIR] Koikatsu model detected but no anchor bones (spine/hips) found. Repair might be incomplete.") + } else { + logger.info("[HIERARCHY-REPAIR] Anchor bones found: Spine=$upperBodyIndex, Hips=$pelvisIndex, Head=$headAnchorIndex") } - logger.info("[HIERARCHY-REPAIR] Koikatsu model detected. Identifying and repairing dangling bones...") + logger.info("[HIERARCHY-REPAIR] Identifying and repairing dangling bones...") + // PASS 2: Repair. Use the pre-discovered anchors to reparent orphan/anchor-locked bones. val newBones = bones.mapIndexed { index, bone -> val currentParent = bone.parentBoneIndex ?: -1 val name = bone.nameLocal.lowercase() From b58a7a6c2bd41c2425fc28615a6cd1d8634833fa Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 17:19:16 +0700 Subject: [PATCH 47/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 47 ++++++++++++++++++--------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 0cc9df2b..bddf9f8c 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -860,34 +860,46 @@ class PmxLoader : ModelFileLoader { if (!isKoikatsu) return // PASS 1: Discovery. Find anchor bones regardless of their position in the file. - val spine03Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine03", ignoreCase = true) } - val spine02Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine02", ignoreCase = true) } - val spine01Index = bones.indexOfFirst { it.nameLocal.equals("cf_j_spine01", ignoreCase = true) } - val spineSub01Index = bones.indexOfFirst { it.nameLocal.equals("cf_s_spine01", ignoreCase = true) } - val spineAllIndex = bones.indexOfFirst { it.nameLocal.contains("spine", ignoreCase = true) || it.nameLocal.contains("脊髄", ignoreCase = true) } - - val hipsIndex = bones.indexOfFirst { it.nameLocal.equals("cf_j_hips", ignoreCase = true) } - val hipsSubIndex = bones.indexOfFirst { it.nameLocal.equals("cf_s_hips", ignoreCase = true) } - val hipsAllIndex = bones.indexOfFirst { it.nameLocal.contains("hips", ignoreCase = true) || it.nameLocal.contains("下半身", ignoreCase = true) } - val waistIndex = bones.indexOfFirst { it.nameLocal.contains("腰", ignoreCase = true) || it.nameLocal.contains("waist", ignoreCase = true) } + // Priority: Explicit cf_j joints first, then any bone with the name (case-insensitive) + fun findAnchor(patterns: List, ignorePatterns: List = listOf("bnip", "tw", "adj")): Int { + // Try cf_j_ prefix first as it's the most reliable for skeleton anchors + val prioritized = bones.indexOfFirst { bone -> + val n = bone.nameLocal.lowercase() + patterns.any { p -> n.startsWith("cf_j_$p") } && !ignorePatterns.any { i -> n.contains(i) } + } + if (prioritized != -1) return prioritized + + // Fallback to name contains (standard skeleton protection) + return bones.indexOfFirst { bone -> + val n = bone.nameLocal.lowercase() + patterns.any { p -> n.contains(p) } && !ignorePatterns.any { i -> n.contains(i) } + } + } - val headIndex = bones.indexOfFirst { it.nameLocal.contains("head", ignoreCase = true) || it.nameLocal.contains("頭", ignoreCase = true) } - val neckIndex = bones.indexOfFirst { it.nameLocal.contains("neck", ignoreCase = true) || it.nameLocal.contains("首", ignoreCase = true) } + val spine03Index = findAnchor(listOf("spine03")) + val spine02Index = findAnchor(listOf("spine02")) + val spine01Index = findAnchor(listOf("spine01")) + val spineAllIndex = findAnchor(listOf("spine", "脊髄")) + + val hipsIndex = findAnchor(listOf("hips")) + val waistIndex = findAnchor(listOf("waist", "腰")) + val hipsAllIndex = findAnchor(listOf("hips", "下半身")) + + val headIndex = findAnchor(listOf("head", "頭")) + val neckIndex = findAnchor(listOf("neck", "首")) val upperBodyIndex = when { spine03Index != -1 -> spine03Index spine02Index != -1 -> spine02Index spine01Index != -1 -> spine01Index - spineSub01Index != -1 -> spineSub01Index spineAllIndex != -1 -> spineAllIndex else -> -1 } val pelvisIndex = when { hipsIndex != -1 -> hipsIndex - hipsSubIndex != -1 -> hipsSubIndex - hipsAllIndex != -1 -> hipsAllIndex waistIndex != -1 -> waistIndex + hipsAllIndex != -1 -> hipsAllIndex else -> -1 } @@ -901,7 +913,10 @@ class PmxLoader : ModelFileLoader { if (upperBodyIndex == -1 && pelvisIndex == -1) { logger.warn("[HIERARCHY-REPAIR] Koikatsu model detected but no anchor bones (spine/hips) found. Repair might be incomplete.") } else { - logger.info("[HIERARCHY-REPAIR] Anchor bones found: Spine=$upperBodyIndex, Hips=$pelvisIndex, Head=$headAnchorIndex") + val spineName = if (upperBodyIndex != -1) bones[upperBodyIndex].nameLocal else "NONE" + val hipsName = if (pelvisIndex != -1) bones[pelvisIndex].nameLocal else "NONE" + val headName = if (headAnchorIndex != -1) bones[headAnchorIndex].nameLocal else "NONE" + logger.info("[HIERARCHY-REPAIR] Anchor bones found: Spine=$upperBodyIndex ($spineName), Hips=$pelvisIndex ($hipsName), Head=$headAnchorIndex ($headName)") } logger.info("[HIERARCHY-REPAIR] Identifying and repairing dangling bones...") From 90512275b2290d1bc04c10284ffaa2fadd37d8d1 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 17:59:06 +0700 Subject: [PATCH 48/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index bddf9f8c..e8412ffb 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -861,19 +861,24 @@ class PmxLoader : ModelFileLoader { // PASS 1: Discovery. Find anchor bones regardless of their position in the file. // Priority: Explicit cf_j joints first, then any bone with the name (case-insensitive) + // CRITICAL: We only accept anchors from index > 50 to avoid early-file nipple/helper "decoys" fun findAnchor(patterns: List, ignorePatterns: List = listOf("bnip", "tw", "adj")): Int { - // Try cf_j_ prefix first as it's the most reliable for skeleton anchors - val prioritized = bones.indexOfFirst { bone -> + // Try cf_j_ prefix first as it's the most reliable for skeleton anchors. + // Use indexOfLast to get the most articulated joint (e.g. spine03 vs spine01) + val prioritized = bones.indices.reversed().firstOrNull { i -> + val bone = bones[i] val n = bone.nameLocal.lowercase() - patterns.any { p -> n.startsWith("cf_j_$p") } && !ignorePatterns.any { i -> n.contains(i) } - } + i > 50 && patterns.any { p -> n.startsWith("cf_j_$p") } && !ignorePatterns.any { pattern -> n.contains(pattern) } + } ?: -1 + if (prioritized != -1) return prioritized - // Fallback to name contains (standard skeleton protection) - return bones.indexOfFirst { bone -> + // Fallback to name contains (with the same index constraint) + return bones.indices.reversed().firstOrNull { i -> + val bone = bones[i] val n = bone.nameLocal.lowercase() - patterns.any { p -> n.contains(p) } && !ignorePatterns.any { i -> n.contains(i) } - } + i > 50 && patterns.any { p -> n.contains(p) } && !ignorePatterns.any { pattern -> n.contains(pattern) } + } ?: -1 } val spine03Index = findAnchor(listOf("spine03")) From 3ee59d5df89c92cbc9592c8ad2d6f8eda0aba885 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 18:56:39 +0700 Subject: [PATCH 49/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index e8412ffb..585e8ec1 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -861,26 +861,33 @@ class PmxLoader : ModelFileLoader { // PASS 1: Discovery. Find anchor bones regardless of their position in the file. // Priority: Explicit cf_j joints first, then any bone with the name (case-insensitive) - // CRITICAL: We only accept anchors from index > 50 to avoid early-file nipple/helper "decoys" - fun findAnchor(patterns: List, ignorePatterns: List = listOf("bnip", "tw", "adj")): Int { + // CRITICAL: We only accept anchors from index > 150 to avoid early-file "decoys" + // (nipples, helpers, muscles) that are often locked to origin in variants like Arslan_Leila. + fun findAnchor(patterns: List, ignorePatterns: List = listOf("bnip", "tw", "adj", "bust")): Int { // Try cf_j_ prefix first as it's the most reliable for skeleton anchors. // Use indexOfLast to get the most articulated joint (e.g. spine03 vs spine01) val prioritized = bones.indices.reversed().firstOrNull { i -> val bone = bones[i] val n = bone.nameLocal.lowercase() - i > 50 && patterns.any { p -> n.startsWith("cf_j_$p") } && !ignorePatterns.any { pattern -> n.contains(pattern) } + i > 150 && patterns.any { p -> n.startsWith("cf_j_$p") } && !ignorePatterns.any { pattern -> n.contains(pattern) } } ?: -1 if (prioritized != -1) return prioritized - // Fallback to name contains (with the same index constraint) + // Fallback to name contains (with the same 150+ index constraint) return bones.indices.reversed().firstOrNull { i -> val bone = bones[i] val n = bone.nameLocal.lowercase() - i > 50 && patterns.any { p -> n.contains(p) } && !ignorePatterns.any { pattern -> n.contains(pattern) } + i > 150 && patterns.any { p -> n.contains(p) } && !ignorePatterns.any { pattern -> n.contains(pattern) } } ?: -1 } + // Diagnostic: Dump the skeletal range if in Koikatsu mode to detect ghost bones + for (i in 150..minOf(bones.size - 1, 280)) { + val b = bones[i] + logger.debug("[HIERARCHY-DIAG] Bone $i: ${b.nameLocal} (Parent: ${b.parentBoneIndex})") + } + val spine03Index = findAnchor(listOf("spine03")) val spine02Index = findAnchor(listOf("spine02")) val spine01Index = findAnchor(listOf("spine01")) From 33ad05121c963db58c5caeff0bec26ed4f9d27b6 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 21:04:00 +0700 Subject: [PATCH 50/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 585e8ec1..de01c9f6 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -864,17 +864,25 @@ class PmxLoader : ModelFileLoader { // CRITICAL: We only accept anchors from index > 150 to avoid early-file "decoys" // (nipples, helpers, muscles) that are often locked to origin in variants like Arslan_Leila. fun findAnchor(patterns: List, ignorePatterns: List = listOf("bnip", "tw", "adj", "bust")): Int { - // Try cf_j_ prefix first as it's the most reliable for skeleton anchors. - // Use indexOfLast to get the most articulated joint (e.g. spine03 vs spine01) + // Step 1: Strict Prefix + Exact Name Search (Best for Koikatsu cf_j_joints) val prioritized = bones.indices.reversed().firstOrNull { i -> val bone = bones[i] val n = bone.nameLocal.lowercase() - i > 150 && patterns.any { p -> n.startsWith("cf_j_$p") } && !ignorePatterns.any { pattern -> n.contains(pattern) } + i > 150 && patterns.any { p -> n == "cf_j_$p" } && !ignorePatterns.any { pattern -> n.contains(pattern) } } ?: -1 if (prioritized != -1) return prioritized - // Fallback to name contains (with the same 150+ index constraint) + // Step 2: Fallback to cf_j_ startsWith (for spine variants) + val secondary = bones.indices.reversed().firstOrNull { i -> + val bone = bones[i] + val n = bone.nameLocal.lowercase() + i > 150 && patterns.any { p -> n.startsWith("cf_j_$p") } && !ignorePatterns.any { pattern -> n.contains(pattern) } + } ?: -1 + + if (secondary != -1) return secondary + + // Final Fallback: Name contains (with strict index 150+ constraint) return bones.indices.reversed().firstOrNull { i -> val bone = bones[i] val n = bone.nameLocal.lowercase() From 812bc6c4cf82f8e5f5330ce6ac3cf345adb67958 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 21:43:02 +0700 Subject: [PATCH 51/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 77 ++++++++++++++++----------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index de01c9f6..218dd18f 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -417,8 +417,16 @@ class PmxLoader : ModelFileLoader { val weight1 = readWeight() if (isKoikatsu) { - // If weight 1 is Bone 0 and weight 2 is something else, or vice-versa - // Redirect Bone 0 to the other bone to prevent vertex anchor stretching. + // Origin Weight Scrubber: If heavily weighted to Bone 0, redirect to skeleton anchors + // to prevent vertices sticking to (0,0,0) world origin. + if (index1 == 0 && weight1 > 0.9f) { + index1 = if (vy > 1.25f) context.spineIndex else if (vy > 0.8f) context.pelvisIndex else 0 + } + if (index2 == 0 && (1f - weight1) > 0.9f) { + index2 = if (vy > 1.25f) context.spineIndex else if (vy > 0.8f) context.pelvisIndex else 0 + } + + // Normal redirection for mixed weights if (index1 == 0 && index2 > 0) index1 = index2 else if (index2 == 0 && index1 > 0) index2 = index1 } @@ -860,34 +868,42 @@ class PmxLoader : ModelFileLoader { if (!isKoikatsu) return // PASS 1: Discovery. Find anchor bones regardless of their position in the file. - // Priority: Explicit cf_j joints first, then any bone with the name (case-insensitive) - // CRITICAL: We only accept anchors from index > 150 to avoid early-file "decoys" - // (nipples, helpers, muscles) that are often locked to origin in variants like Arslan_Leila. + // Using a scoring system to prioritize core Joints (cf_j_) over Sub-bones (cf_s_) + // even if the Sub-bones are at a higher index. fun findAnchor(patterns: List, ignorePatterns: List = listOf("bnip", "tw", "adj", "bust")): Int { - // Step 1: Strict Prefix + Exact Name Search (Best for Koikatsu cf_j_joints) - val prioritized = bones.indices.reversed().firstOrNull { i -> - val bone = bones[i] - val n = bone.nameLocal.lowercase() - i > 150 && patterns.any { p -> n == "cf_j_$p" } && !ignorePatterns.any { pattern -> n.contains(pattern) } - } ?: -1 - - if (prioritized != -1) return prioritized - - // Step 2: Fallback to cf_j_ startsWith (for spine variants) - val secondary = bones.indices.reversed().firstOrNull { i -> - val bone = bones[i] - val n = bone.nameLocal.lowercase() - i > 150 && patterns.any { p -> n.startsWith("cf_j_$p") } && !ignorePatterns.any { pattern -> n.contains(pattern) } - } ?: -1 - - if (secondary != -1) return secondary + var bestIndex = -1 + var bestScore = -1.0 - // Final Fallback: Name contains (with strict index 150+ constraint) - return bones.indices.reversed().firstOrNull { i -> + for (i in bones.indices) { val bone = bones[i] val n = bone.nameLocal.lowercase() - i > 150 && patterns.any { p -> n.contains(p) } && !ignorePatterns.any { pattern -> n.contains(pattern) } - } ?: -1 + + // Still ignore early decoys (expression/eye bones usually < 100) + if (i < 100) continue + + // Check if name matches any pattern + if ((patterns.any { p -> n.contains(p) }) && !ignorePatterns.any { it in n }) { + var score = 0.0 + + // Tier 1: Core Joints (cf_j_) are the highest priority for moving skeleton + if (n.startsWith("cf_j_")) score += 1000.0 + + // Tier 2: Exact name match (prevents thigh matching spine) + if (patterns.any { p -> n == "cf_j_$p" || n == p }) score += 500.0 + + // Tier 3: Sub joints or Dynamic bones (cf_s_ / cf_d_) + if (n.startsWith("cf_s_") || n.startsWith("cf_d_")) score += 100.0 + + // Tiebreaker: Higher index is usually a more specific joint (spine03 > spine01) + score += i.toDouble() / 10000.0 + + if (score > bestScore) { + bestScore = score + bestIndex = i + } + } + } + return bestIndex } // Diagnostic: Dump the skeletal range if in Koikatsu mode to detect ghost bones @@ -899,11 +915,11 @@ class PmxLoader : ModelFileLoader { val spine03Index = findAnchor(listOf("spine03")) val spine02Index = findAnchor(listOf("spine02")) val spine01Index = findAnchor(listOf("spine01")) - val spineAllIndex = findAnchor(listOf("spine", "脊髄")) + val spineIndex = findAnchor(listOf("spine", "脊髄")) val hipsIndex = findAnchor(listOf("hips")) val waistIndex = findAnchor(listOf("waist", "腰")) - val hipsAllIndex = findAnchor(listOf("hips", "下半身")) + val pelvisIndex_alt = findAnchor(listOf("pelvis", "下半身")) val headIndex = findAnchor(listOf("head", "頭")) val neckIndex = findAnchor(listOf("neck", "首")) @@ -912,14 +928,15 @@ class PmxLoader : ModelFileLoader { spine03Index != -1 -> spine03Index spine02Index != -1 -> spine02Index spine01Index != -1 -> spine01Index - spineAllIndex != -1 -> spineAllIndex + spineIndex != -1 -> spineIndex + neckIndex != -1 -> neckIndex else -> -1 } val pelvisIndex = when { hipsIndex != -1 -> hipsIndex + pelvisIndex_alt != -1 -> pelvisIndex_alt waistIndex != -1 -> waistIndex - hipsAllIndex != -1 -> hipsAllIndex else -> -1 } From eb8d6b3aa5b95e01ab43d289956e897be96e62e3 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 21:48:24 +0700 Subject: [PATCH 52/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 218dd18f..ee6134fb 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -91,8 +91,10 @@ class PmxLoader : ModelFileLoader { private val rootBones = mutableListOf() private lateinit var rigidBodies: List private var boneToRigidBodyMap = mutableMapOf>() - private lateinit var joints: List + private var joints: List private var isKoikatsu: Boolean = false + internal var spineIndex: Int = -1 + internal var pelvisIndex: Int = -1 private fun loadRgbColor(buffer: ByteBuffer): RgbColor { if (buffer.remaining() < 3 * 4) { @@ -420,10 +422,10 @@ class PmxLoader : ModelFileLoader { // Origin Weight Scrubber: If heavily weighted to Bone 0, redirect to skeleton anchors // to prevent vertices sticking to (0,0,0) world origin. if (index1 == 0 && weight1 > 0.9f) { - index1 = if (vy > 1.25f) context.spineIndex else if (vy > 0.8f) context.pelvisIndex else 0 + index1 = if (y > 1.25f) this.spineIndex else if (y > 0.8f) this.pelvisIndex else 0 } if (index2 == 0 && (1f - weight1) > 0.9f) { - index2 = if (vy > 1.25f) context.spineIndex else if (vy > 0.8f) context.pelvisIndex else 0 + index2 = if (y > 1.25f) this.spineIndex else if (y > 0.8f) this.pelvisIndex else 0 } // Normal redirection for mixed weights @@ -453,7 +455,13 @@ class PmxLoader : ModelFileLoader { if (isKoikatsu) { // Find first non-zero bone to replace any Bone 0 weights - val fallback = listOf(index1, index2, index3, index4).firstOrNull { it > 0 } ?: 0 + var fallback = listOf(index1, index2, index3, index4).firstOrNull { it > 0 } ?: 0 + + // If everything is Bone 0, use skeletal anchors + if (fallback == 0) { + fallback = if (y > 1.25f) this.spineIndex else if (y > 0.8f) this.pelvisIndex else 0 + } + if (index1 == 0) index1 = fallback if (index2 == 0) index2 = fallback if (index3 == 0) index3 = fallback @@ -933,12 +941,8 @@ class PmxLoader : ModelFileLoader { else -> -1 } - val pelvisIndex = when { - hipsIndex != -1 -> hipsIndex - pelvisIndex_alt != -1 -> pelvisIndex_alt - waistIndex != -1 -> waistIndex - else -> -1 - } + this.spineIndex = upperBodyIndex + this.pelvisIndex = pelvisIndex val headAnchorIndex = when { headIndex != -1 -> headIndex From 6b5bc9827eaf0be08d01abf6321ecb5de39aa341 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sat, 28 Mar 2026 21:59:35 +0700 Subject: [PATCH 53/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index ee6134fb..63e7c5c0 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -91,7 +91,7 @@ class PmxLoader : ModelFileLoader { private val rootBones = mutableListOf() private lateinit var rigidBodies: List private var boneToRigidBodyMap = mutableMapOf>() - private var joints: List + private lateinit var joints: List private var isKoikatsu: Boolean = false internal var spineIndex: Int = -1 internal var pelvisIndex: Int = -1 From 122e108413bdb44f59239551921f5b2bb5088718 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 29 Mar 2026 13:22:57 +0700 Subject: [PATCH 54/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 63e7c5c0..97b7d122 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -877,7 +877,7 @@ class PmxLoader : ModelFileLoader { // PASS 1: Discovery. Find anchor bones regardless of their position in the file. // Using a scoring system to prioritize core Joints (cf_j_) over Sub-bones (cf_s_) - // even if the Sub-bones are at a higher index. + // and honoring the "Early Skeleton" range found in Arslan/Koikatsu ports. fun findAnchor(patterns: List, ignorePatterns: List = listOf("bnip", "tw", "adj", "bust")): Int { var bestIndex = -1 var bestScore = -1.0 @@ -886,8 +886,8 @@ class PmxLoader : ModelFileLoader { val bone = bones[i] val n = bone.nameLocal.lowercase() - // Still ignore early decoys (expression/eye bones usually < 100) - if (i < 100) continue + // Search all bones except index 0 (World Root) + if (i == 0) continue // Check if name matches any pattern if ((patterns.any { p -> n.contains(p) }) && !ignorePatterns.any { it in n }) { @@ -899,10 +899,13 @@ class PmxLoader : ModelFileLoader { // Tier 2: Exact name match (prevents thigh matching spine) if (patterns.any { p -> n == "cf_j_$p" || n == p }) score += 500.0 - // Tier 3: Sub joints or Dynamic bones (cf_s_ / cf_d_) + // Tier 3: Early-Model Bonus (Indices 1-100) - Real skeleton often lives here + if (i in 1..100) score += 300.0 + + // Tier 4: Sub joints or Dynamic bones (cf_s_ / cf_d_) if (n.startsWith("cf_s_") || n.startsWith("cf_d_")) score += 100.0 - // Tiebreaker: Higher index is usually a more specific joint (spine03 > spine01) + // Tiebreaker: Higher index usually means a more specific joint (spine03) score += i.toDouble() / 10000.0 if (score > bestScore) { From 045f0b754a0f30bec136b9851b85c9c5ff276d18 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 29 Mar 2026 14:39:49 +0700 Subject: [PATCH 55/72] debug --- blazerod/model/model-pmx/PmxLoader.kt | 69 +++++++++++++++++---------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 97b7d122..ae3c567a 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -929,76 +929,97 @@ class PmxLoader : ModelFileLoader { val spineIndex = findAnchor(listOf("spine", "脊髄")) val hipsIndex = findAnchor(listOf("hips")) + val waist02Index = findAnchor(listOf("waist02")) + val waist01Index = findAnchor(listOf("waist01")) val waistIndex = findAnchor(listOf("waist", "腰")) - val pelvisIndex_alt = findAnchor(listOf("pelvis", "下半身")) + val pelvisIndex_alt = findAnchor(listOf("pelvis", "下半身", "lower")) val headIndex = findAnchor(listOf("head", "頭")) val neckIndex = findAnchor(listOf("neck", "首")) - val upperBodyIndex = when { + this.spineIndex = when { spine03Index != -1 -> spine03Index spine02Index != -1 -> spine02Index spine01Index != -1 -> spine01Index spineIndex != -1 -> spineIndex - neckIndex != -1 -> neckIndex else -> -1 } - this.spineIndex = upperBodyIndex - this.pelvisIndex = pelvisIndex + this.pelvisIndex = when { + hipsIndex != -1 -> hipsIndex + waist02Index != -1 -> waist02Index + waist01Index != -1 -> waist01Index + waistIndex != -1 -> waistIndex + pelvisIndex_alt != -1 -> pelvisIndex_alt + else -> -1 + } + + // PASS 2: Hierarchical Fallbacks. If anchors are still missing, look at parents of known extremities. + if (this.pelvisIndex == -1) { + val legFirst = findAnchor(listOf("leg_l", "足_l", "leg_r", "足_r")) + if (legFirst != -1) { + this.pelvisIndex = bones[legFirst].parentBoneIndex ?: -1 + logger.info("[HIERARCHY-REPAIR] Pelvis fallback to Leg Parent: ${this.pelvisIndex}") + } + } + if (this.spineIndex == -1) { + val neckFirst = findAnchor(listOf("neck", "首")) + if (neckFirst != -1) { + this.spineIndex = bones[neckFirst].parentBoneIndex ?: -1 + logger.info("[HIERARCHY-REPAIR] Spine fallback to Neck Parent: ${this.spineIndex}") + } + } val headAnchorIndex = when { headIndex != -1 -> headIndex neckIndex != -1 -> neckIndex - upperBodyIndex != -1 -> upperBodyIndex + this.spineIndex != -1 -> this.spineIndex else -> -1 } - if (upperBodyIndex == -1 && pelvisIndex == -1) { + if (this.spineIndex == -1 && this.pelvisIndex == -1) { logger.warn("[HIERARCHY-REPAIR] Koikatsu model detected but no anchor bones (spine/hips) found. Repair might be incomplete.") } else { - val spineName = if (upperBodyIndex != -1) bones[upperBodyIndex].nameLocal else "NONE" - val hipsName = if (pelvisIndex != -1) bones[pelvisIndex].nameLocal else "NONE" + val spineName = if (this.spineIndex != -1) bones[this.spineIndex].nameLocal else "NONE" + val hipsName = if (this.pelvisIndex != -1) bones[this.pelvisIndex].nameLocal else "NONE" val headName = if (headAnchorIndex != -1) bones[headAnchorIndex].nameLocal else "NONE" - logger.info("[HIERARCHY-REPAIR] Anchor bones found: Spine=$upperBodyIndex ($spineName), Hips=$pelvisIndex ($hipsName), Head=$headAnchorIndex ($headName)") + logger.info("[HIERARCHY-REPAIR] Anchor bones found: Spine=${this.spineIndex} ($spineName), Hips=${this.pelvisIndex} ($hipsName), Head=$headAnchorIndex ($headName)") } logger.info("[HIERARCHY-REPAIR] Identifying and repairing dangling bones...") - // PASS 2: Repair. Use the pre-discovered anchors to reparent orphan/anchor-locked bones. + // PASS 3: Repair. Use the pre-discovered anchors to reparent orphan/anchor-locked bones. val newBones = bones.mapIndexed { index, bone -> val currentParent = bone.parentBoneIndex ?: -1 val name = bone.nameLocal.lowercase() - // Skeleton protection: Don't reparent the main movement roots (Center, Groove, Waist) + // Skeleton protection: Don't reparent the main movement roots val isCoreSkeleton = name.contains("センター") || name.contains("center") || name.contains("グルーブ") || name.contains("groove") || name.contains("腰") || name.contains("waist") || name.contains("全ての親") if ((currentParent == -1 || currentParent == 0) && !isCoreSkeleton) { val newParent = when { - // Universal body part matching for all Koikatsu prefixes (cf_s, cf_j, cf_d, cf_m, ct_) - (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳")) && upperBodyIndex != -1 -> upperBodyIndex + // Universal body part matching + (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳")) && this.spineIndex != -1 -> this.spineIndex // Skirts and lower body dynamics - (name.contains("skirt") || name.contains("スカート") || name.contains("腰") || name.contains("cf_j_sk_") || name.contains("cf_d_sk_")) && pelvisIndex != -1 -> pelvisIndex + (name.contains("skirt") || name.contains("スカート") || name.contains("腰") || name.contains("cf_j_sk_") || name.contains("cf_d_sk_")) && this.pelvisIndex != -1 -> this.pelvisIndex // Hair and face accessories (name.contains("hair") || name.contains("髪") || name.contains("ribbon") || name.contains("ct_")) && headAnchorIndex != -1 -> headAnchorIndex - + // General dynamic adjustment bones and twist helpers (name.contains("cf_s_") || name.contains("cf_d_") || name.contains("cf_m_") || name.contains("捩") || name.contains("肩")) -> { when { - name.contains("arm") || name.contains("shoulder") || name.contains("hand") || name.contains("bust") -> upperBodyIndex - name.contains("leg") || name.contains("foot") || name.contains("skirt") || name.contains("hips") -> pelvisIndex - else -> upperBodyIndex // Fallback to upper body + name.contains("arm") || name.contains("shoulder") || name.contains("hand") || name.contains("bust") -> this.spineIndex + name.contains("leg") || name.contains("foot") || name.contains("skirt") || name.contains("hips") -> this.pelvisIndex + else -> this.spineIndex // Fallback to upper body } } - - else -> null - }?.takeIf { it != -1 && it != index && it != currentParent } - - if (newParent != null) { + else -> -1 + } + if (newParent != -1 && newParent != index && newParent != currentParent) { logger.info("[HIERARCHY-REPAIR] Reparenting anchor-locked bone ${bone.nameLocal} (index $index, parent $currentParent) to parent index $newParent") // Update childBoneMap for consistency From c5cba3043f4a4c39801f205d2ec3de808f581dcb Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 29 Mar 2026 15:54:54 +0700 Subject: [PATCH 56/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index ae3c567a..2b6ea699 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -877,7 +877,7 @@ class PmxLoader : ModelFileLoader { // PASS 1: Discovery. Find anchor bones regardless of their position in the file. // Using a scoring system to prioritize core Joints (cf_j_) over Sub-bones (cf_s_) - // and honoring the "Early Skeleton" range found in Arslan/Koikatsu ports. + // and prioritizing the "Animated Skeleton" (Indices 150+) over "Root Skeleton" (Indices 1-100). fun findAnchor(patterns: List, ignorePatterns: List = listOf("bnip", "tw", "adj", "bust")): Int { var bestIndex = -1 var bestScore = -1.0 @@ -896,17 +896,18 @@ class PmxLoader : ModelFileLoader { // Tier 1: Core Joints (cf_j_) are the highest priority for moving skeleton if (n.startsWith("cf_j_")) score += 1000.0 - // Tier 2: Exact name match (prevents thigh matching spine) + // Tier 2: Exact name match if (patterns.any { p -> n == "cf_j_$p" || n == p }) score += 500.0 - // Tier 3: Early-Model Bonus (Indices 1-100) - Real skeleton often lives here - if (i in 1..100) score += 300.0 + // Tier 3: Animated Skeleton Bonus (Indices 100-350) + // In Arslan ports, the character skeleton is often a secondary entity later in the file. + if (i > 100) score += 300.0 // Tier 4: Sub joints or Dynamic bones (cf_s_ / cf_d_) if (n.startsWith("cf_s_") || n.startsWith("cf_d_")) score += 100.0 - // Tiebreaker: Higher index usually means a more specific joint (spine03) - score += i.toDouble() / 10000.0 + // Tiebreaker: Higher index usually means the more specific/animated joint (waist02 vs hips) + score += i.toDouble() / 1000.0 if (score > bestScore) { bestScore = score @@ -946,9 +947,9 @@ class PmxLoader : ModelFileLoader { } this.pelvisIndex = when { - hipsIndex != -1 -> hipsIndex waist02Index != -1 -> waist02Index waist01Index != -1 -> waist01Index + hipsIndex != -1 -> hipsIndex waistIndex != -1 -> waistIndex pelvisIndex_alt != -1 -> pelvisIndex_alt else -> -1 @@ -996,9 +997,9 @@ class PmxLoader : ModelFileLoader { // Skeleton protection: Don't reparent the main movement roots val isCoreSkeleton = name.contains("センター") || name.contains("center") || name.contains("グルーブ") || name.contains("groove") || - name.contains("腰") || name.contains("waist") || name.contains("全ての親") + name.contains("全ての親") - if ((currentParent == -1 || currentParent == 0) && !isCoreSkeleton) { + if ((currentParent == -1 || currentParent <= 10) && !isCoreSkeleton) { val newParent = when { // Universal body part matching (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳")) && this.spineIndex != -1 -> this.spineIndex From 86f68f26bf2a78827c69142398fc8aea51b91d87 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 29 Mar 2026 16:27:55 +0700 Subject: [PATCH 57/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 2b6ea699..abda19d3 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -999,7 +999,9 @@ class PmxLoader : ModelFileLoader { name.contains("グルーブ") || name.contains("groove") || name.contains("全ての親") - if ((currentParent == -1 || currentParent <= 10) && !isCoreSkeleton) { + // V15 Surgical Check: Only reparent if bone is a true orphan (Parent <= 0) + // If it's already part of the character skeleton (Parent > 100), leave it alone! + if (currentParent <= 0 && !isCoreSkeleton) { val newParent = when { // Universal body part matching (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳")) && this.spineIndex != -1 -> this.spineIndex From ebf73af254602789d6e99f72f1bc808db8ba8bf2 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 29 Mar 2026 17:15:38 +0700 Subject: [PATCH 58/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index abda19d3..3dbb8ca8 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1003,20 +1003,20 @@ class PmxLoader : ModelFileLoader { // If it's already part of the character skeleton (Parent > 100), leave it alone! if (currentParent <= 0 && !isCoreSkeleton) { val newParent = when { - // Universal body part matching - (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳")) && this.spineIndex != -1 -> this.spineIndex + // V16: Specialized body part matching for Arslan/Kisara control nodes + (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳") || name.contains("胸操作")) && this.spineIndex != -1 -> this.spineIndex // Skirts and lower body dynamics - (name.contains("skirt") || name.contains("スカート") || name.contains("腰") || name.contains("cf_j_sk_") || name.contains("cf_d_sk_")) && this.pelvisIndex != -1 -> this.pelvisIndex + (name.contains("skirt") || name.contains("スカート") || name.contains("腰") || name.contains("腰操作") || name.contains("cf_j_sk_") || name.contains("cf_d_sk_")) && this.pelvisIndex != -1 -> this.pelvisIndex - // Hair and face accessories - (name.contains("hair") || name.contains("髪") || name.contains("ribbon") || name.contains("ct_")) && headAnchorIndex != -1 -> headAnchorIndex + // Hair and face accessories (including hair-operation nodes) + (name.contains("hair") || name.contains("髪") || name.contains("髪操作") || name.contains("ribbon") || name.contains("ct_")) && headAnchorIndex != -1 -> headAnchorIndex // General dynamic adjustment bones and twist helpers (name.contains("cf_s_") || name.contains("cf_d_") || name.contains("cf_m_") || name.contains("捩") || name.contains("肩")) -> { when { - name.contains("arm") || name.contains("shoulder") || name.contains("hand") || name.contains("bust") -> this.spineIndex - name.contains("leg") || name.contains("foot") || name.contains("skirt") || name.contains("hips") -> this.pelvisIndex + name.contains("arm") || name.contains("shoulder") || name.contains("hand") || name.contains("bust") || name.contains("breast") || name.contains("胸") -> this.spineIndex + name.contains("leg") || name.contains("foot") || name.contains("skirt") || name.contains("hips") || name.contains("腰") -> this.pelvisIndex else -> this.spineIndex // Fallback to upper body } } From 85a56b4dafbf50f94cdbcdbdce3338be13296268 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 29 Mar 2026 21:17:23 +0700 Subject: [PATCH 59/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 3dbb8ca8..6f253437 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -999,9 +999,9 @@ class PmxLoader : ModelFileLoader { name.contains("グルーブ") || name.contains("groove") || name.contains("全ての親") - // V15 Surgical Check: Only reparent if bone is a true orphan (Parent <= 0) - // If it's already part of the character skeleton (Parent > 100), leave it alone! - if (currentParent <= 0 && !isCoreSkeleton) { + // V17 Bypass: Reparent if bone is stuck to a stationary "Ghost Root" (Indices 1-10) + // If it's already part of the HIGH character skeleton (> 100), leave it alone! + if (currentParent <= 10 && !isCoreSkeleton) { val newParent = when { // V16: Specialized body part matching for Arslan/Kisara control nodes (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳") || name.contains("胸操作")) && this.spineIndex != -1 -> this.spineIndex From 82a8e0c9cd40c9b182294fbe9a8fd0f9285e1d77 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Sun, 29 Mar 2026 21:49:32 +0700 Subject: [PATCH 60/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 6f253437..3e5c735c 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -999,9 +999,17 @@ class PmxLoader : ModelFileLoader { name.contains("グルーブ") || name.contains("groove") || name.contains("全ての親") - // V17 Bypass: Reparent if bone is stuck to a stationary "Ghost Root" (Indices 1-10) - // If it's already part of the HIGH character skeleton (> 100), leave it alone! - if (currentParent <= 10 && !isCoreSkeleton) { + // V18 Logical Character Protection: + // Only reparent if: + // 1. It is a known dynamic part (Hair, Breast, Skirt, Operation/Adjustment) + // 2. OR it is in the early ghost root region (< 100) + // 3. AND its parent is currently a stationary ghost root (< 100) + val isDynamicPart = name.contains("skirt") || name.contains("hair") || name.contains("bust") || + name.contains("breast") || name.contains("操作") || name.contains("胸") || + name.contains("髪") || name.contains("腰") || name.contains("ct_") || + name.startsWith("cf_s_") || name.startsWith("cf_d_") || name.startsWith("cf_m_") + + if ((index < 100 || isDynamicPart) && (currentParent >= 0 && currentParent <= 100) && !isCoreSkeleton) { val newParent = when { // V16: Specialized body part matching for Arslan/Kisara control nodes (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳") || name.contains("胸操作")) && this.spineIndex != -1 -> this.spineIndex From e64424f83ec311937fb068f1bea548383cda5e90 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Mon, 30 Mar 2026 09:00:44 +0700 Subject: [PATCH 61/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 3e5c735c..0ac70b7f 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -900,8 +900,9 @@ class PmxLoader : ModelFileLoader { if (patterns.any { p -> n == "cf_j_$p" || n == p }) score += 500.0 // Tier 3: Animated Skeleton Bonus (Indices 100-350) - // In Arslan ports, the character skeleton is often a secondary entity later in the file. - if (i > 100) score += 300.0 + // In Arslan ports, the character skeleton is a secondary entity later in the file. + // We give it a MASSIVE OVERRIDE (+5000) so it beats the +1000 bonus of the stationary early ghost roots. + if (i > 100) score += 5000.0 // Tier 4: Sub joints or Dynamic bones (cf_s_ / cf_d_) if (n.startsWith("cf_s_") || n.startsWith("cf_d_")) score += 100.0 @@ -999,17 +1000,21 @@ class PmxLoader : ModelFileLoader { name.contains("グルーブ") || name.contains("groove") || name.contains("全ての親") - // V18 Logical Character Protection: - // Only reparent if: - // 1. It is a known dynamic part (Hair, Breast, Skirt, Operation/Adjustment) - // 2. OR it is in the early ghost root region (< 100) - // 3. AND its parent is currently a stationary ghost root (< 100) + // V19 Root-Elimination Protection: + // Only reparent if it is a truly orphaned dynamic part (Hair, Breast, Skirt, Operation) + // OR if it is in the early ghost root region (< 100). + // We MUST let high-index character bones (> 100) like cf_s_spine01 remain safely untouched so the animation chain survives. val isDynamicPart = name.contains("skirt") || name.contains("hair") || name.contains("bust") || name.contains("breast") || name.contains("操作") || name.contains("胸") || - name.contains("髪") || name.contains("腰") || name.contains("ct_") || - name.startsWith("cf_s_") || name.startsWith("cf_d_") || name.startsWith("cf_m_") + name.contains("髪") || name.contains("腰") || name.contains("ct_") - if ((index < 100 || isDynamicPart) && (currentParent >= 0 && currentParent <= 100) && !isCoreSkeleton) { + val isEarlyGhostBone = index < 100 + val isParentedToGhostRoot = currentParent >= 0 && currentParent < 100 + + // Do not allow an anchor bone to be reparented to itself or its identical twin + val isMainAnchor = index == this.spineIndex || index == this.pelvisIndex || index == headAnchorIndex + + if ((isEarlyGhostBone || isDynamicPart) && isParentedToGhostRoot && !isCoreSkeleton && !isMainAnchor) { val newParent = when { // V16: Specialized body part matching for Arslan/Kisara control nodes (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳") || name.contains("胸操作")) && this.spineIndex != -1 -> this.spineIndex From 05a8249e4a64f846515d08d38e8ef646531a041d Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Mon, 30 Mar 2026 09:41:58 +0700 Subject: [PATCH 62/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 0ac70b7f..7da571fc 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1000,21 +1000,16 @@ class PmxLoader : ModelFileLoader { name.contains("グルーブ") || name.contains("groove") || name.contains("全ての親") - // V19 Root-Elimination Protection: - // Only reparent if it is a truly orphaned dynamic part (Hair, Breast, Skirt, Operation) - // OR if it is in the early ghost root region (< 100). - // We MUST let high-index character bones (> 100) like cf_s_spine01 remain safely untouched so the animation chain survives. - val isDynamicPart = name.contains("skirt") || name.contains("hair") || name.contains("bust") || - name.contains("breast") || name.contains("操作") || name.contains("胸") || - name.contains("髪") || name.contains("腰") || name.contains("ct_") - - val isEarlyGhostBone = index < 100 + // V20 Total Migration: + // Any bone that is stuck on an early Ghost Root (< 100) MUST be migrated to the animated anchors. + // This includes the entire internal character skeleton (Spine01, etc.) which is otherwise disconnected + // from the Minecraft animation system. val isParentedToGhostRoot = currentParent >= 0 && currentParent < 100 // Do not allow an anchor bone to be reparented to itself or its identical twin val isMainAnchor = index == this.spineIndex || index == this.pelvisIndex || index == headAnchorIndex - if ((isEarlyGhostBone || isDynamicPart) && isParentedToGhostRoot && !isCoreSkeleton && !isMainAnchor) { + if (isParentedToGhostRoot && !isCoreSkeleton && !isMainAnchor) { val newParent = when { // V16: Specialized body part matching for Arslan/Kisara control nodes (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳") || name.contains("胸操作")) && this.spineIndex != -1 -> this.spineIndex @@ -1026,10 +1021,11 @@ class PmxLoader : ModelFileLoader { (name.contains("hair") || name.contains("髪") || name.contains("髪操作") || name.contains("ribbon") || name.contains("ct_")) && headAnchorIndex != -1 -> headAnchorIndex // General dynamic adjustment bones and twist helpers - (name.contains("cf_s_") || name.contains("cf_d_") || name.contains("cf_m_") || name.contains("捩") || name.contains("肩")) -> { + (name.contains("cf_s_") || name.contains("cf_d_") || name.contains("cf_m_") || name.contains("捩") || name.contains("肩") || name.contains("首") || name.contains("頭")) -> { when { - name.contains("arm") || name.contains("shoulder") || name.contains("hand") || name.contains("bust") || name.contains("breast") || name.contains("胸") -> this.spineIndex - name.contains("leg") || name.contains("foot") || name.contains("skirt") || name.contains("hips") || name.contains("腰") -> this.pelvisIndex + name.contains("head") || name.contains("neck") || name.contains("首") || name.contains("頭") -> headAnchorIndex + name.contains("spine") || name.contains("arm") || name.contains("shoulder") || name.contains("hand") || name.contains("bust") || name.contains("breast") || name.contains("胸") -> this.spineIndex + name.contains("leg") || name.contains("foot") || name.contains("skirt") || name.contains("hips") || name.contains("pelvis") || name.contains("腰") -> this.pelvisIndex else -> this.spineIndex // Fallback to upper body } } From cf7f76352c596ed72fcb5eef5f7024504439cb36 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Mon, 30 Mar 2026 10:24:51 +0700 Subject: [PATCH 63/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 34 ++++++++++----------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 7da571fc..0663ae75 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -900,9 +900,9 @@ class PmxLoader : ModelFileLoader { if (patterns.any { p -> n == "cf_j_$p" || n == p }) score += 500.0 // Tier 3: Animated Skeleton Bonus (Indices 100-350) - // In Arslan ports, the character skeleton is a secondary entity later in the file. - // We give it a MASSIVE OVERRIDE (+5000) so it beats the +1000 bonus of the stationary early ghost roots. - if (i > 100) score += 5000.0 + // In Arslan ports, the character skeleton is often a secondary entity later in the file. + // V21 REVERT: 300.0 (Safe fallback) - Do not overpower cf_j_ True Skeleton. + if (i > 100) score += 300.0 // Tier 4: Sub joints or Dynamic bones (cf_s_ / cf_d_) if (n.startsWith("cf_s_") || n.startsWith("cf_d_")) score += 100.0 @@ -999,17 +999,19 @@ class PmxLoader : ModelFileLoader { val isCoreSkeleton = name.contains("センター") || name.contains("center") || name.contains("グルーブ") || name.contains("groove") || name.contains("全ての親") - - // V20 Total Migration: - // Any bone that is stuck on an early Ghost Root (< 100) MUST be migrated to the animated anchors. - // This includes the entire internal character skeleton (Spine01, etc.) which is otherwise disconnected - // from the Minecraft animation system. - val isParentedToGhostRoot = currentParent >= 0 && currentParent < 100 + + // V21 Ground-Root Accessory Migration + // Only safe reparenting: If an ACCESSORY (skirt/bust/hair) is stuck to a Ground Root (World, Center, Groove) + // then pull it to the Spine/Pelvis. Do NOT touch the `cf_j_` or `cf_s_` core character skeleton! + val isParentedToGroundRoot = currentParent >= 0 && currentParent <= 4 + val isAccessoryPart = name.contains("skirt") || name.contains("hair") || name.contains("bust") || + name.contains("breast") || name.contains("ct_") || name.contains("捩") || + name.contains("スカート") || name.contains("胸") || name.contains("髪") || name.contains("操作") // Do not allow an anchor bone to be reparented to itself or its identical twin val isMainAnchor = index == this.spineIndex || index == this.pelvisIndex || index == headAnchorIndex - if (isParentedToGhostRoot && !isCoreSkeleton && !isMainAnchor) { + if (isAccessoryPart && isParentedToGroundRoot && !isCoreSkeleton && !isMainAnchor) { val newParent = when { // V16: Specialized body part matching for Arslan/Kisara control nodes (name.contains("bust") || name.contains("breast") || name.contains("胸") || name.contains("乳") || name.contains("胸操作")) && this.spineIndex != -1 -> this.spineIndex @@ -1018,17 +1020,7 @@ class PmxLoader : ModelFileLoader { (name.contains("skirt") || name.contains("スカート") || name.contains("腰") || name.contains("腰操作") || name.contains("cf_j_sk_") || name.contains("cf_d_sk_")) && this.pelvisIndex != -1 -> this.pelvisIndex // Hair and face accessories (including hair-operation nodes) - (name.contains("hair") || name.contains("髪") || name.contains("髪操作") || name.contains("ribbon") || name.contains("ct_")) && headAnchorIndex != -1 -> headAnchorIndex - - // General dynamic adjustment bones and twist helpers - (name.contains("cf_s_") || name.contains("cf_d_") || name.contains("cf_m_") || name.contains("捩") || name.contains("肩") || name.contains("首") || name.contains("頭")) -> { - when { - name.contains("head") || name.contains("neck") || name.contains("首") || name.contains("頭") -> headAnchorIndex - name.contains("spine") || name.contains("arm") || name.contains("shoulder") || name.contains("hand") || name.contains("bust") || name.contains("breast") || name.contains("胸") -> this.spineIndex - name.contains("leg") || name.contains("foot") || name.contains("skirt") || name.contains("hips") || name.contains("pelvis") || name.contains("腰") -> this.pelvisIndex - else -> this.spineIndex // Fallback to upper body - } - } + (name.contains("hair") || name.contains("髪") || name.contains("髪操作") || name.contains("ribbon") || name.contains("ct_") || name.contains("head") || name.contains("neck") || name.contains("首") || name.contains("頭")) && headAnchorIndex != -1 -> headAnchorIndex else -> -1 } if (newParent != -1 && newParent != index && newParent != currentParent) { From e63f9c793961c7a0e16d40ebc47b8d310e42c986 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Mon, 30 Mar 2026 11:07:18 +0700 Subject: [PATCH 64/72] fix --- blazerod/model/model-pmx/PmxLoader.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 0663ae75..210634ff 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1426,7 +1426,7 @@ class PmxLoader : ModelFileLoader { boneToRigidBodyMap[index]?.forEach { index -> val rigidBody = rigidBodies[index] val name = rigidBody.nameLocal.lowercase() - val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") + val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") || name.contains("ah1") || name.contains("ah2") || name.contains("ah3") val isHair = name.contains("hair") || name.contains("发") || name.contains("髪") || name.contains("bang") || name.contains("strand") || name.contains("front") || name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") From 637c6ce3975eb147ccfe5a8d15c7df24c4e8147b Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Mon, 30 Mar 2026 14:30:55 +0700 Subject: [PATCH 65/72] remove logs --- blazerod/model/model-pmx/PmxLoader.kt | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 210634ff..9d0ffaa1 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -868,7 +868,7 @@ class PmxLoader : ModelFileLoader { n.contains("胸") || n.contains("乳") || n.contains("髪") || n.contains("スカート") } if (isKoikatsu) { - logger.info("[HIERARCHY-REPAIR] Koikatsu-style bone naming detected. Hierarchy repair active.") + // HIERARCHY-REPAIR logs suppressed } } @@ -921,8 +921,7 @@ class PmxLoader : ModelFileLoader { // Diagnostic: Dump the skeletal range if in Koikatsu mode to detect ghost bones for (i in 150..minOf(bones.size - 1, 280)) { - val b = bones[i] - logger.debug("[HIERARCHY-DIAG] Bone $i: ${b.nameLocal} (Parent: ${b.parentBoneIndex})") + // val b = bones[i] } val spine03Index = findAnchor(listOf("spine03")) @@ -961,14 +960,12 @@ class PmxLoader : ModelFileLoader { val legFirst = findAnchor(listOf("leg_l", "足_l", "leg_r", "足_r")) if (legFirst != -1) { this.pelvisIndex = bones[legFirst].parentBoneIndex ?: -1 - logger.info("[HIERARCHY-REPAIR] Pelvis fallback to Leg Parent: ${this.pelvisIndex}") } } if (this.spineIndex == -1) { val neckFirst = findAnchor(listOf("neck", "首")) if (neckFirst != -1) { this.spineIndex = bones[neckFirst].parentBoneIndex ?: -1 - logger.info("[HIERARCHY-REPAIR] Spine fallback to Neck Parent: ${this.spineIndex}") } } @@ -982,14 +979,9 @@ class PmxLoader : ModelFileLoader { if (this.spineIndex == -1 && this.pelvisIndex == -1) { logger.warn("[HIERARCHY-REPAIR] Koikatsu model detected but no anchor bones (spine/hips) found. Repair might be incomplete.") } else { - val spineName = if (this.spineIndex != -1) bones[this.spineIndex].nameLocal else "NONE" - val hipsName = if (this.pelvisIndex != -1) bones[this.pelvisIndex].nameLocal else "NONE" - val headName = if (headAnchorIndex != -1) bones[headAnchorIndex].nameLocal else "NONE" - logger.info("[HIERARCHY-REPAIR] Anchor bones found: Spine=${this.spineIndex} ($spineName), Hips=${this.pelvisIndex} ($hipsName), Head=$headAnchorIndex ($headName)") + // logs removed } - logger.info("[HIERARCHY-REPAIR] Identifying and repairing dangling bones...") - // PASS 3: Repair. Use the pre-discovered anchors to reparent orphan/anchor-locked bones. val newBones = bones.mapIndexed { index, bone -> val currentParent = bone.parentBoneIndex ?: -1 @@ -1024,8 +1016,6 @@ class PmxLoader : ModelFileLoader { else -> -1 } if (newParent != -1 && newParent != index && newParent != currentParent) { - logger.info("[HIERARCHY-REPAIR] Reparenting anchor-locked bone ${bone.nameLocal} (index $index, parent $currentParent) to parent index $newParent") - // Update childBoneMap for consistency childBoneMap.getOrPut(newParent) { mutableListOf() }.add(index) @@ -1046,16 +1036,7 @@ class PmxLoader : ModelFileLoader { } } bones = newBones - - // Task 3: Verification - Log any remaining root bones for diagnostic purposes - val remainingRoots = bones.indices.filter { bones[it].parentBoneIndex == null || bones[it].parentBoneIndex == -1 } - if (remainingRoots.isNotEmpty()) { - val rootNames = remainingRoots.take(10).joinToString { bones[it].nameLocal } - val more = if (remainingRoots.size > 10) "... and ${remainingRoots.size - 10} more" else "" - logger.info("[HIERARCHY-REPAIR] Repair complete. ${remainingRoots.size} bones remain as roots: $rootNames$more") - } else { - logger.info("[HIERARCHY-REPAIR] Repair complete. Zero root bones remain.") - } + bones = newBones } private fun loadMorphTargets(buffer: ByteBuffer) { From 0be9018334577ae3437e3b18df0bb10e25be80c8 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Tue, 16 Jun 2026 12:52:39 +0700 Subject: [PATCH 66/72] upload files --- blazerod/model/model-pmx/PmxLoader.kt | 34 ++++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 9d0ffaa1..c5195657 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1410,10 +1410,15 @@ class PmxLoader : ModelFileLoader { val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") || name.contains("ah1") || name.contains("ah2") || name.contains("ah3") val isHair = name.contains("hair") || name.contains("发") || name.contains("髪") || name.contains("bang") || name.contains("strand") || name.contains("front") || name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") + val isGarment = isSkirt || + name.contains("vest") || name.contains("shirt") || name.contains("coat") || + name.contains("jacket") || name.contains("suit") || name.contains("dress") || + name.contains("inner") || name.contains("outer") || + name.contains("服") || name.contains("衣") // Task 2: Refine physics loading logic. - // Instead of skipping, we allow the component creation but force FOLLOW_BONE mode. - // This ensures the mesh has its associated physics metadata but doesn't fly away. + // Koikatsu models can contain unstable helper/breast bodies that fly away. + // Keep dynamic PMX physics only for the accessory groups we intentionally support. add( NodeComponent.RigidBodyComponent( @@ -1430,12 +1435,13 @@ class PmxLoader : ModelFileLoader { val adjustedPhysicsMode = if (enableNameBasedOverrides) { when { - isBreast || isHair || isSkirt -> - RigidBody.PhysicsMode.FOLLOW_BONE - nameLocal.contains("skirt") || nameLocal.contains("スカート") || nameLocal.contains("腰") -> - RigidBody.PhysicsMode.FOLLOW_BONE - nameLocal.contains("hair") || nameLocal.contains("髪") || nameLocal.contains("ribbon") || nameLocal.contains("ct_") -> + isHair || isGarment -> basePhysicsMode + isBreast -> RigidBody.PhysicsMode.FOLLOW_BONE + nameLocal.contains("skirt") || nameLocal.contains("スカート") || nameLocal.contains("ribbon") -> + basePhysicsMode + nameLocal.contains("hair") || nameLocal.contains("髪") -> + basePhysicsMode nameLocal.contains("bust") || nameLocal.contains("breast") || nameLocal.contains("胸") || nameLocal.contains("乳") -> RigidBody.PhysicsMode.FOLLOW_BONE nameLocal.startsWith("cf_j_", ignoreCase = true) || @@ -1703,16 +1709,22 @@ class PmxLoader : ModelFileLoader { n.contains("乳") || n.contains("胸") || n.contains("bust") || n.contains("breast") || n.contains("cf_s_bust") } val isHairJoint = nameCombo.let { n -> - n.contains("hair") || n.contains("发") || n.contains("髪") || n.contains("bang") || n.contains("strand") || n.contains("front") || n.contains("back") || n.contains("side") || n.contains("tail") || n.contains("ahoge") || n.contains("ct_") || n.contains("ribbon") + n.contains("hair") || n.contains("发") || n.contains("髪") || n.contains("bang") || n.contains("strand") || n.contains("front") || n.contains("back") || n.contains("side") || n.contains("tail") || n.contains("ahoge") || n.contains("ribbon") } val isSkirtJoint = nameCombo.let { n -> - n.contains("skirt") || n.contains("スカート") || n.contains("cf_s_skirt") || n.contains("cf_j_sk_") || n.contains("cf_d_sk_") || n.contains("腰") + n.contains("skirt") || n.contains("スカート") || n.contains("cf_s_skirt") || n.contains("cf_j_sk_") || n.contains("cf_d_sk_") || n.contains("ribbon") + } + val isGarmentJoint = isSkirtJoint || nameCombo.let { n -> + n.contains("vest") || n.contains("shirt") || n.contains("coat") || + n.contains("jacket") || n.contains("suit") || n.contains("dress") || + n.contains("inner") || n.contains("outer") || + n.contains("服") || n.contains("衣") } val isHelperJoint = nameCombo.let { n -> n.contains("cf_m_") || n.contains("cf_t_") || n.contains("捩") || n.contains("肩") } - if (isKoikatsu && (isBreastJoint || isHairJoint || isSkirtJoint || isHelperJoint)) { + if (isKoikatsu && (isBreastJoint || (isHelperJoint && !isHairJoint && !isGarmentJoint))) { return@mapNotNull null } @@ -1809,4 +1821,4 @@ class PmxLoader : ModelFileLoader { buffer.order(ByteOrder.LITTLE_ENDIAN) context.load(buffer) } -} \ No newline at end of file +} From 7cd16baf6a8be593c7566866f0b4c57b02e511ff Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Tue, 16 Jun 2026 13:42:13 +0700 Subject: [PATCH 67/72] fix: allow Koikatsu skirt physics --- blazerod/model/model-pmx/PmxLoader.kt | 67 +++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index c5195657..9cdbf687 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1341,6 +1341,31 @@ class PmxLoader : ModelFileLoader { val modelId = UUID.randomUUID() val rootNodes = mutableListOf() + val rigidBodyModeStats = linkedMapOf() + val rigidBodyModeExamples = mutableListOf() + val jointStats = linkedMapOf() + + fun bumpStat(stats: MutableMap, key: String) { + stats[key] = (stats[key] ?: 0) + 1 + } + + fun physicsModeName(mode: RigidBody.PhysicsMode) = when (mode) { + RigidBody.PhysicsMode.FOLLOW_BONE -> "FOLLOW_BONE" + RigidBody.PhysicsMode.PHYSICS -> "PHYSICS" + RigidBody.PhysicsMode.PHYSICS_PLUS_BONE -> "PHYSICS_PLUS_BONE" + } + + fun accessoryCategory(name: String): String { + val n = name.lowercase() + return when { + n.contains("乳") || n.contains("胸") || n.contains("bust") || n.contains("breast") || n.contains("ah1") || n.contains("ah2") || n.contains("ah3") -> "breast" + n.contains("hair") || n.contains("发") || n.contains("髪") || n.contains("bang") || n.contains("strand") || n.contains("front") || n.contains("back") || n.contains("ahoge") || n.contains("side") || n.contains("tail") || n.contains("ribbon") -> "hair" + n.contains("skirt") || n.contains("スカート") || n.contains("cf_s_sk_") || n.contains("cf_j_sk_") || n.contains("cf_d_sk_") || n.contains("ribbon") -> "skirt" + n.contains("vest") || n.contains("shirt") || n.contains("coat") || n.contains("jacket") || n.contains("suit") || n.contains("dress") || n.contains("inner") || n.contains("outer") || n.contains("服") || n.contains("衣") -> "garment" + n.contains("cf_m_") || n.contains("cf_t_") || n.contains("捩") || n.contains("肩") -> "helper" + else -> "other" + } + } fun addBone(index: Int, parentPosition: Vector3fc? = null, depth: Int = 0): Node { val bone = bones[index] @@ -1407,14 +1432,16 @@ class PmxLoader : ModelFileLoader { boneToRigidBodyMap[index]?.forEach { index -> val rigidBody = rigidBodies[index] val name = rigidBody.nameLocal.lowercase() - val isBreast = name.contains("乳") || name.contains("胸") || name.contains("bust") || name.contains("breast") || name.contains("ah1") || name.contains("ah2") || name.contains("ah3") - val isHair = name.contains("hair") || name.contains("发") || name.contains("髪") || name.contains("bang") || name.contains("strand") || name.contains("front") || name.contains("back") || name.contains("ahoge") || name.contains("side") || name.contains("tail") - val isSkirt = name.contains("skirt") || name.contains("スカート") || name.contains("ribbon") + val relatedBoneName = bones.getOrNull(rigidBody.relatedBoneIndex)?.nameLocal?.lowercase() ?: "" + val classificationName = "$name $relatedBoneName" + val isBreast = classificationName.contains("乳") || classificationName.contains("胸") || classificationName.contains("bust") || classificationName.contains("breast") || classificationName.contains("ah1") || classificationName.contains("ah2") || classificationName.contains("ah3") + val isHair = classificationName.contains("hair") || classificationName.contains("发") || classificationName.contains("髪") || classificationName.contains("bang") || classificationName.contains("strand") || classificationName.contains("front") || classificationName.contains("back") || classificationName.contains("ahoge") || classificationName.contains("side") || classificationName.contains("tail") + val isSkirt = classificationName.contains("skirt") || classificationName.contains("スカート") || classificationName.contains("ribbon") || classificationName.contains("cf_s_sk_") || classificationName.contains("cf_j_sk_") || classificationName.contains("cf_d_sk_") val isGarment = isSkirt || - name.contains("vest") || name.contains("shirt") || name.contains("coat") || - name.contains("jacket") || name.contains("suit") || name.contains("dress") || - name.contains("inner") || name.contains("outer") || - name.contains("服") || name.contains("衣") + classificationName.contains("vest") || classificationName.contains("shirt") || classificationName.contains("coat") || + classificationName.contains("jacket") || classificationName.contains("suit") || classificationName.contains("dress") || + classificationName.contains("inner") || classificationName.contains("outer") || + classificationName.contains("服") || classificationName.contains("衣") // Task 2: Refine physics loading logic. // Koikatsu models can contain unstable helper/breast bodies that fly away. @@ -1438,7 +1465,7 @@ class PmxLoader : ModelFileLoader { isHair || isGarment -> basePhysicsMode isBreast -> RigidBody.PhysicsMode.FOLLOW_BONE - nameLocal.contains("skirt") || nameLocal.contains("スカート") || nameLocal.contains("ribbon") -> + nameLocal.contains("skirt") || nameLocal.contains("スカート") || nameLocal.contains("ribbon") || nameLocal.contains("cf_s_sk_") || nameLocal.contains("cf_j_sk_") || nameLocal.contains("cf_d_sk_") -> basePhysicsMode nameLocal.contains("hair") || nameLocal.contains("髪") -> basePhysicsMode @@ -1508,6 +1535,12 @@ class PmxLoader : ModelFileLoader { } else { adjustedPhysicsMode } + val category = accessoryCategory(classificationName) + val modeTransition = "${physicsModeName(basePhysicsMode)}->${physicsModeName(finalPhysicsMode)}" + bumpStat(rigidBodyModeStats, "$category:$modeTransition") + if (category != "other" && rigidBodyModeExamples.size < 32) { + rigidBodyModeExamples.add("rb#$index ${rigidBody.nameLocal} bone=${bones.getOrNull(rigidBody.relatedBoneIndex)?.nameLocal ?: "?"} category=$category $modeTransition") + } RigidBody( name = rigidBody.nameLocal.takeIf(String::isNotBlank), @@ -1697,14 +1730,18 @@ class PmxLoader : ModelFileLoader { skins = listOf(skin), physicalJoints = this.joints.mapNotNull { joint -> if (joint.rigidBodyIndexA !in rigidBodies.indices) { + bumpStat(jointStats, "drop:invalid") return@mapNotNull null } if (joint.rigidBodyIndexB !in rigidBodies.indices) { + bumpStat(jointStats, "drop:invalid") return@mapNotNull null } val rbA = rigidBodies[joint.rigidBodyIndexA] val rbB = rigidBodies[joint.rigidBodyIndexB] - val nameCombo = (rbA.nameLocal + rbB.nameLocal).lowercase() + val rbABone = bones.getOrNull(rbA.relatedBoneIndex)?.nameLocal ?: "" + val rbBBone = bones.getOrNull(rbB.relatedBoneIndex)?.nameLocal ?: "" + val nameCombo = "${rbA.nameLocal} ${rbB.nameLocal} $rbABone $rbBBone".lowercase() val isBreastJoint = nameCombo.let { n -> n.contains("乳") || n.contains("胸") || n.contains("bust") || n.contains("breast") || n.contains("cf_s_bust") } @@ -1712,7 +1749,7 @@ class PmxLoader : ModelFileLoader { n.contains("hair") || n.contains("发") || n.contains("髪") || n.contains("bang") || n.contains("strand") || n.contains("front") || n.contains("back") || n.contains("side") || n.contains("tail") || n.contains("ahoge") || n.contains("ribbon") } val isSkirtJoint = nameCombo.let { n -> - n.contains("skirt") || n.contains("スカート") || n.contains("cf_s_skirt") || n.contains("cf_j_sk_") || n.contains("cf_d_sk_") || n.contains("ribbon") + n.contains("skirt") || n.contains("スカート") || n.contains("cf_s_skirt") || n.contains("cf_s_sk_") || n.contains("cf_j_sk_") || n.contains("cf_d_sk_") || n.contains("ribbon") } val isGarmentJoint = isSkirtJoint || nameCombo.let { n -> n.contains("vest") || n.contains("shirt") || n.contains("coat") || @@ -1725,8 +1762,10 @@ class PmxLoader : ModelFileLoader { } if (isKoikatsu && (isBreastJoint || (isHelperJoint && !isHairJoint && !isGarmentJoint))) { + bumpStat(jointStats, "drop:${accessoryCategory(nameCombo)}") return@mapNotNull null } + bumpStat(jointStats, "keep:${accessoryCategory(nameCombo)}") PhysicalJoint( name = joint.nameLocal.takeIf(String::isNotBlank), @@ -1760,6 +1799,14 @@ class PmxLoader : ModelFileLoader { }, relaxationFactor = 1.0f, ) + }.also { physicalJoints -> + if (isKoikatsu) { + logger.info("[PMX-PHYSICS] Koikatsu rigid body mode transitions: {}", rigidBodyModeStats.entries.joinToString { "${it.key}=${it.value}" }) + logger.info("[PMX-PHYSICS] Koikatsu physical joints kept: {} / {} ({})", physicalJoints.size, this.joints.size, jointStats.entries.joinToString { "${it.key}=${it.value}" }) + rigidBodyModeExamples.forEach { example -> + logger.info("[PMX-PHYSICS] {}", example) + } + } }, expressions = buildList { for ((index, target) in morphTargets.withIndex()) { From 8585d665e92b08f698c72d271ed5748f3b4d96ff Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Wed, 17 Jun 2026 07:22:22 +0700 Subject: [PATCH 68/72] fix: reset model physics on reuse --- blazerod/render/api/resource/ModelInstance.kt | 3 +- .../render/main/runtime/ModelInstanceImpl.kt | 11 ++++ .../render/main/runtime/RenderSceneImpl.kt | 57 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/blazerod/render/api/resource/ModelInstance.kt b/blazerod/render/api/resource/ModelInstance.kt index 84fc82e1..68e3c629 100644 --- a/blazerod/render/api/resource/ModelInstance.kt +++ b/blazerod/render/api/resource/ModelInstance.kt @@ -15,6 +15,7 @@ interface ModelInstance : RefCount { var lodDistance: Float fun clearTransform() + fun resetPhysics(time: Float) fun setTransformMatrix(nodeIndex: Int, transformId: TransformId, matrix: Matrix4f) fun setTransformMatrix(nodeIndex: Int, transformId: TransformId, updater: Consumer) fun setTransformMatrix(nodeIndex: Int, transformId: TransformId, updater: NodeTransform.Matrix.() -> Unit) @@ -42,4 +43,4 @@ interface ModelInstance : RefCount { interface Factory { fun of(scene: RenderScene): ModelInstance } -} \ No newline at end of file +} diff --git a/blazerod/render/main/runtime/ModelInstanceImpl.kt b/blazerod/render/main/runtime/ModelInstanceImpl.kt index d501ed26..06512cc8 100644 --- a/blazerod/render/main/runtime/ModelInstanceImpl.kt +++ b/blazerod/render/main/runtime/ModelInstanceImpl.kt @@ -123,6 +123,12 @@ class ModelInstanceImpl( speedHistoryIndex = (speedHistoryIndex + 1) % SPEED_HISTORY_SIZE if (speedHistoryIndex == 0) speedHistoryFilled = true } + + fun clearSpeedHistory() { + speedHistory.fill(0f) + speedHistoryIndex = 0 + speedHistoryFilled = false + } fun averageRecentSpeed(): Float { val count = if (speedHistoryFilled) SPEED_HISTORY_SIZE else speedHistoryIndex @@ -147,6 +153,7 @@ class ModelInstanceImpl( var currentPhysicsInterval: Float = MIN_INTERVAL var explosionLogCount: Int = 0 var debugStepCount: Int = 0 + var resetCount: Int = 0 companion object { const val BUDGET_HIGH_MS = 2.0f @@ -238,6 +245,10 @@ class ModelInstanceImpl( } } + override fun resetPhysics(time: Float) { + scene.resetPhysics(this, time) + } + override fun setTransformMatrix(nodeIndex: Int, transformId: TransformId, matrix: Matrix4f) { markNodeTransformDirty(scene.nodes[nodeIndex]) val transform = modelData.transformMaps[nodeIndex] diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index 6b5a5a40..024598d3 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -26,6 +26,7 @@ import top.fifthlight.blazerod.runtime.node.component.RigidBodyComponent import top.fifthlight.blazerod.runtime.node.forEach import top.fifthlight.blazerod.runtime.resource.RenderPhysicsJoint import top.fifthlight.blazerod.runtime.resource.RenderSkin +import org.slf4j.LoggerFactory import kotlin.time.measureTime @@ -41,6 +42,7 @@ class RenderSceneImpl( ) : AbstractRefCount(), RenderScene { override val attachments: Map, Any> companion object { + private val logger = LoggerFactory.getLogger(RenderSceneImpl::class.java) private const val PHYSICS_MAX_SUB_STEP_COUNT = 12 private const val PHYSICS_FPS = 120f private const val PHYSICS_TIME_STEP = 1f / PHYSICS_FPS @@ -138,6 +140,46 @@ class RenderSceneImpl( } } + fun resetPhysics(instance: ModelInstanceImpl, time: Float) { + val data = instance.physicsData ?: return + + instance.updateWorldTransformsNoPhysics() + executePhase(instance, UpdatePhase.PhysicsUpdatePre) + + val initPos = Vector3f() + val initRot = Quaternionf() + for ((nodeIndex, component) in rigidBodyComponents) { + val nodeWorld = instance.modelData.worldTransformsNoPhysics[nodeIndex] + nodeWorld.getTranslation(initPos) + nodeWorld.getUnnormalizedRotation(initRot) + data.world.resetRigidBody(component.rigidBodyIndex, initPos, initRot) + } + + instance.modelData.worldTransformsNoPhysics[rootNode.nodeIndex].getTranslation(data.lastRootPos) + data.world.pullTransforms(data.transformArray) + data.transformArray.copyInto(data.previousTransforms) + data.transformArray.copyInto(data.currentTransforms) + data.lastPhysicsTime = time + data.physicsAccumulator = 0f + data.physicsStepTimeMs = 0f + data.currentPhysicsInterval = ModelInstanceImpl.PhysicsData.MIN_INTERVAL + data.lastFrameDistSq = 0f + data.clearSpeedHistory() + data.resetCount++ + + executePhase(instance, UpdatePhase.PhysicsUpdatePost) + executePhase(instance, UpdatePhase.GlobalTransformPropagation) + + logger.info( + "[PMX-PHYSICS-RUNTIME] resetPhysics count={} bodies={} joints={} time={} interval={}", + data.resetCount, + rigidBodyComponents.size, + physicsJoints.size, + time, + data.currentPhysicsInterval, + ) + } + /** @@ -253,6 +295,7 @@ class RenderSceneImpl( val stepStart = System.nanoTime() data.world.step(data.physicsAccumulator, PHYSICS_MAX_SUB_STEP_COUNT, PHYSICS_TIME_STEP) val stepTimeMs = (System.nanoTime() - stepStart) / 1_000_000f + data.debugStepCount++ data.world.pullTransforms(data.transformArray) data.transformArray.copyInto(data.currentTransforms) @@ -260,6 +303,20 @@ class RenderSceneImpl( // Adapt physics rate based on step cost (EMA with hysteresis) data.physicsStepTimeMs = 0.8f * data.physicsStepTimeMs + 0.2f * stepTimeMs + if (data.debugStepCount % 120 == 0 || stepTimeMs > ModelInstanceImpl.PhysicsData.BUDGET_HIGH_MS) { + logger.info( + "[PMX-PHYSICS-RUNTIME] step count={} bodies={} joints={} stepMs={} emaMs={} interval={} distance={} maxSubSteps={} fixedDt={}", + data.debugStepCount, + rigidBodyComponents.size, + physicsJoints.size, + stepTimeMs, + data.physicsStepTimeMs, + data.currentPhysicsInterval, + distance, + PHYSICS_MAX_SUB_STEP_COUNT, + PHYSICS_TIME_STEP, + ) + } if (data.physicsStepTimeMs > ModelInstanceImpl.PhysicsData.BUDGET_HIGH_MS) { data.currentPhysicsInterval = minOf( data.currentPhysicsInterval * 2f, From e045b834079a015d68c543bf74d3419a13167eee Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Wed, 17 Jun 2026 07:53:30 +0700 Subject: [PATCH 69/72] fix: declare runtime minecraft client deps --- blazerod/render/main/runtime/BUILD.bazel | 1 + 1 file changed, 1 insertion(+) diff --git a/blazerod/render/main/runtime/BUILD.bazel b/blazerod/render/main/runtime/BUILD.bazel index e7340217..6744329d 100644 --- a/blazerod/render/main/runtime/BUILD.bazel +++ b/blazerod/render/main/runtime/BUILD.bazel @@ -26,5 +26,6 @@ kt_merge_library( "@maven//:it_unimi_dsi_fastutil", "@maven//:org_joml_joml", "@maven//:org_lwjgl_lwjgl", + "@minecraft//1.21.8:client_libraries", ], ) From 2a2c91dfc6dfe5ec4dd264e824e96dc99cf5cb78 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Wed, 17 Jun 2026 10:18:01 +0700 Subject: [PATCH 70/72] fix: isolate PMX bake from runtime physics --- blazerod/render/api/resource/ModelInstance.kt | 1 + .../render/main/runtime/ModelInstanceImpl.kt | 13 ++-- .../render/main/runtime/RenderSceneImpl.kt | 74 +++++++++++++++++-- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/blazerod/render/api/resource/ModelInstance.kt b/blazerod/render/api/resource/ModelInstance.kt index 68e3c629..d60a4db8 100644 --- a/blazerod/render/api/resource/ModelInstance.kt +++ b/blazerod/render/api/resource/ModelInstance.kt @@ -32,6 +32,7 @@ interface ModelInstance : RefCount { fun debugRender(viewProjectionMatrix: Matrix4fc, bufferSource: MultiBufferSource, time: Float) fun updateRenderData(time: Float) + fun updateRenderDataNoPhysics(time: Float) fun createRenderTask( modelMatrix: Matrix4fc, diff --git a/blazerod/render/main/runtime/ModelInstanceImpl.kt b/blazerod/render/main/runtime/ModelInstanceImpl.kt index 06512cc8..bfda382f 100644 --- a/blazerod/render/main/runtime/ModelInstanceImpl.kt +++ b/blazerod/render/main/runtime/ModelInstanceImpl.kt @@ -83,11 +83,8 @@ class ModelInstanceImpl( bulletWorld.pushTransforms(src) } override fun step(deltaTime: Float, maxSubSteps: Int, fixedTimeStep: Float) { - // Adaptive Physics Solver - // If the model is close (LOD < 5m), use High-Fidelity (50 iterations) - // If far away, drop to 20 iterations to save CPU. - val isHighQuality = lodDistance < 5f - bulletWorld.setSolverIterations(if (isHighQuality) 100 else 20) + val isHighQuality = lodDistance < 5f + bulletWorld.setSolverIterations(if (isHighQuality) 32 else 12) bulletWorld.step(deltaTime, maxSubSteps, fixedTimeStep) } @@ -154,6 +151,8 @@ class ModelInstanceImpl( var explosionLogCount: Int = 0 var debugStepCount: Int = 0 var resetCount: Int = 0 + var lastStepLogNanos: Long = 0L + var severeStepLogCount: Int = 0 companion object { const val BUDGET_HIGH_MS = 2.0f @@ -352,6 +351,10 @@ class ModelInstanceImpl( scene.updateRenderData(this, time) } + override fun updateRenderDataNoPhysics(time: Float) { + scene.updateRenderDataNoPhysics(this, time) + } + internal fun updateNodeTransform(nodeIndex: Int) { val node = scene.nodes[nodeIndex] updateNodeTransform(node) diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index 024598d3..99e2e4e0 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -43,9 +43,10 @@ class RenderSceneImpl( override val attachments: Map, Any> companion object { private val logger = LoggerFactory.getLogger(RenderSceneImpl::class.java) - private const val PHYSICS_MAX_SUB_STEP_COUNT = 12 + private const val PHYSICS_MAX_SUB_STEP_COUNT = 4 private const val PHYSICS_FPS = 120f private const val PHYSICS_TIME_STEP = 1f / PHYSICS_FPS + private const val PHYSICS_ANOMALY_DISTANCE = 8.0f } override val typeId: String @@ -169,6 +170,7 @@ class RenderSceneImpl( executePhase(instance, UpdatePhase.PhysicsUpdatePost) executePhase(instance, UpdatePhase.GlobalTransformPropagation) + logPhysicsAnomalies(instance, data, "reset") logger.info( "[PMX-PHYSICS-RUNTIME] resetPhysics count={} bodies={} joints={} time={} interval={}", @@ -303,7 +305,15 @@ class RenderSceneImpl( // Adapt physics rate based on step cost (EMA with hysteresis) data.physicsStepTimeMs = 0.8f * data.physicsStepTimeMs + 0.2f * stepTimeMs - if (data.debugStepCount % 120 == 0 || stepTimeMs > ModelInstanceImpl.PhysicsData.BUDGET_HIGH_MS) { + val nowNanos = System.nanoTime() + val logByCadence = data.debugStepCount % 120 == 0 + val logByTime = nowNanos - data.lastStepLogNanos >= 5_000_000_000L + val logBySevereCost = stepTimeMs > 50f && data.severeStepLogCount < 5 + if (logByCadence || logByTime || logBySevereCost) { + data.lastStepLogNanos = nowNanos + if (logBySevereCost) { + data.severeStepLogCount++ + } logger.info( "[PMX-PHYSICS-RUNTIME] step count={} bodies={} joints={} stepMs={} emaMs={} interval={} distance={} maxSubSteps={} fixedDt={}", data.debugStepCount, @@ -316,6 +326,7 @@ class RenderSceneImpl( PHYSICS_MAX_SUB_STEP_COUNT, PHYSICS_TIME_STEP, ) + logPhysicsAnomalies(instance, data, "step") } if (data.physicsStepTimeMs > ModelInstanceImpl.PhysicsData.BUDGET_HIGH_MS) { data.currentPhysicsInterval = minOf( @@ -371,25 +382,78 @@ class RenderSceneImpl( } } - fun updateRenderData(instance: ModelInstanceImpl, time: Float) { + private fun logPhysicsAnomalies( + instance: ModelInstanceImpl, + data: ModelInstanceImpl.PhysicsData, + phase: String, + ) { + var nonFinite = 0 + var farCount = 0 + val samples = mutableListOf() + val noPhysicsPos = Vector3f() + for ((nodeIndex, component) in rigidBodyComponents) { + val offset = component.rigidBodyIndex * 7 + val px = data.transformArray[offset + 0] + val py = data.transformArray[offset + 1] + val pz = data.transformArray[offset + 2] + if (!px.isFinite() || !py.isFinite() || !pz.isFinite()) { + nonFinite++ + if (samples.size < 3) { + samples.add("body=${component.rigidBodyIndex} node=${nodes[nodeIndex].nodeName} rb=${component.rigidBodyData.name} mode=${component.rigidBodyData.physicsMode} pos=($px,$py,$pz)") + } + continue + } + instance.modelData.worldTransformsNoPhysics[nodeIndex].getTranslation(noPhysicsPos) + val dx = px - noPhysicsPos.x + val dy = py - noPhysicsPos.y + val dz = pz - noPhysicsPos.z + val dist = sqrt(dx * dx + dy * dy + dz * dz) + if (dist > PHYSICS_ANOMALY_DISTANCE) { + farCount++ + if (samples.size < 3) { + samples.add("body=${component.rigidBodyIndex} node=${nodes[nodeIndex].nodeName} rb=${component.rigidBodyData.name} mode=${component.rigidBodyData.physicsMode} dist=$dist pos=($px,$py,$pz) bone=(${noPhysicsPos.x},${noPhysicsPos.y},${noPhysicsPos.z})") + } + } + } + if (nonFinite > 0 || farCount > 0) { + logger.warn( + "[PMX-PHYSICS-RUNTIME] anomaly phase={} nonFinite={} farBodies={} threshold={} samples={}", + phase, + nonFinite, + farCount, + PHYSICS_ANOMALY_DISTANCE, + samples.joinToString("; "), + ) + } + } + + private fun updateRenderData(instance: ModelInstanceImpl, time: Float, allowPhysics: Boolean) { if (instance.modelData.undirtyNodeCount != nodes.size) { executePhase(instance, UpdatePhase.GlobalTransformPropagation) executePhase(instance, UpdatePhase.IkUpdate) executePhase(instance, UpdatePhase.InfluenceTransformUpdate) executePhase(instance, UpdatePhase.GlobalTransformPropagation) - if (instance.physicsData != null) { + if (allowPhysics && instance.physicsData != null) { updatePhysics(instance, time) executePhase(instance, UpdatePhase.GlobalTransformPropagation) } executePhase(instance, UpdatePhase.RenderDataUpdate) executePhase(instance, UpdatePhase.CameraUpdate) - } else if (instance.physicsData != null) { + } else if (allowPhysics && instance.physicsData != null) { executePhase(instance, UpdatePhase.GlobalTransformPropagation) updatePhysics(instance, time) executePhase(instance, UpdatePhase.RenderDataUpdate) } } + fun updateRenderData(instance: ModelInstanceImpl, time: Float) { + updateRenderData(instance, time, allowPhysics = true) + } + + fun updateRenderDataNoPhysics(instance: ModelInstanceImpl, time: Float) { + updateRenderData(instance, time, allowPhysics = false) + } + internal fun attachToInstance(instance: ModelInstanceImpl) { executePhase(instance, UpdatePhase.GlobalTransformPropagation) executePhase(instance, UpdatePhase.IkUpdate) From c3b69eb50a473ab5e5b03ed933de2ffba47dedf2 Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Wed, 17 Jun 2026 10:59:14 +0700 Subject: [PATCH 71/72] fix: contain PMX physics NaNs --- blazerod/model/model-pmx/PmxLoader.kt | 6 +++- blazerod/render/api/physics/PhysicsEngine.kt | 11 +++++++ .../render/main/runtime/ModelInstanceImpl.kt | 15 +++++++++- .../render/main/runtime/RenderSceneImpl.kt | 29 +++++++++++++++++++ .../node/component/RigidBodyComponent.kt | 8 +++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/blazerod/model/model-pmx/PmxLoader.kt b/blazerod/model/model-pmx/PmxLoader.kt index 9cdbf687..5103b6e7 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -1476,7 +1476,7 @@ class PmxLoader : ModelFileLoader { nameLocal.startsWith("cf_d_", ignoreCase = true) || nameLocal.startsWith("cf_m_", ignoreCase = true) -> RigidBody.PhysicsMode.FOLLOW_BONE - else -> basePhysicsMode + else -> RigidBody.PhysicsMode.FOLLOW_BONE } } else { basePhysicsMode @@ -1765,6 +1765,10 @@ class PmxLoader : ModelFileLoader { bumpStat(jointStats, "drop:${accessoryCategory(nameCombo)}") return@mapNotNull null } + if (isKoikatsu && !isHairJoint && !isGarmentJoint) { + bumpStat(jointStats, "drop:${accessoryCategory(nameCombo)}") + return@mapNotNull null + } bumpStat(jointStats, "keep:${accessoryCategory(nameCombo)}") PhysicalJoint( diff --git a/blazerod/render/api/physics/PhysicsEngine.kt b/blazerod/render/api/physics/PhysicsEngine.kt index d0072365..fe1fa7ea 100644 --- a/blazerod/render/api/physics/PhysicsEngine.kt +++ b/blazerod/render/api/physics/PhysicsEngine.kt @@ -4,14 +4,17 @@ import top.fifthlight.blazerod.api.resource.ModelInstance object PhysicsEngine { private val activeWorlds = mutableMapOf() + private val providers = mutableMapOf() fun register(instance: ModelInstance, provider: PhysicsProvider) { + providers[instance] = provider if (!activeWorlds.containsKey(instance)) { activeWorlds[instance] = provider.createWorld(instance) } } fun unregister(instance: ModelInstance) { + providers.remove(instance) activeWorlds.remove(instance)?.dispose() } @@ -19,6 +22,14 @@ object PhysicsEngine { return activeWorlds[instance] } + fun recreate(instance: ModelInstance): PhysicsWorld? { + val provider = providers[instance] ?: return null + activeWorlds.remove(instance)?.dispose() + val world = provider.createWorld(instance) + activeWorlds[instance] = world + return world + } + fun update(time: Float) { val iterator = activeWorlds.iterator() while (iterator.hasNext()) { diff --git a/blazerod/render/main/runtime/ModelInstanceImpl.kt b/blazerod/render/main/runtime/ModelInstanceImpl.kt index bfda382f..b1151b9e 100644 --- a/blazerod/render/main/runtime/ModelInstanceImpl.kt +++ b/blazerod/render/main/runtime/ModelInstanceImpl.kt @@ -55,6 +55,7 @@ class ModelInstanceImpl( for (i in scene.nodes.indices) { updateNodeTransform(i) } + updateWorldTransformsNoPhysics() if (physicsData != null) { top.fifthlight.blazerod.api.physics.PhysicsEngine.register( this, @@ -63,7 +64,7 @@ class ModelInstanceImpl( val bulletWorld = MemoryStack.stackPush().use { stack -> val initialTransform = stack.malloc(scene.rigidBodyComponents.size * 64) scene.rigidBodyComponents.forEach { (nodeIndex, component) -> - val nodeWorldTransform = modelData.worldTransforms[nodeIndex] + val nodeWorldTransform = modelData.worldTransformsNoPhysics[nodeIndex] nodeWorldTransform.get(component.rigidBodyIndex * 64, initialTransform) } top.fifthlight.blazerod.physics.PhysicsWorld(physicsData.physicsScene, initialTransform) @@ -134,6 +135,18 @@ class ModelInstanceImpl( for (i in 0 until count) sum += speedHistory[i] return sum / count } + + fun recreateWorldFromCurrentPose() { + instance.updateWorldTransformsNoPhysics() + top.fifthlight.blazerod.api.physics.PhysicsEngine.recreate(instance) + initialize() + lastPhysicsTime = -1f + physicsAccumulator = 0f + physicsStepTimeMs = 0f + currentPhysicsInterval = MIN_INTERVAL + lastFrameDistSq = 0f + clearSpeedHistory() + } val world: top.fifthlight.blazerod.api.physics.PhysicsWorld get() = top.fifthlight.blazerod.api.physics.PhysicsEngine.getWorld(instance) ?: error("PhysicsWorld is not initialized") diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index 99e2e4e0..16f0de5d 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -300,6 +300,17 @@ class RenderSceneImpl( data.debugStepCount++ data.world.pullTransforms(data.transformArray) + if (hasNonFiniteTransforms(data)) { + logPhysicsAnomalies(instance, data, "step") + logger.warn( + "[PMX-PHYSICS-RUNTIME] recreating physics world after non-finite step output count={} time={}", + data.debugStepCount, + time, + ) + data.recreateWorldFromCurrentPose() + resetPhysics(instance, time) + return@let + } data.transformArray.copyInto(data.currentTransforms) data.physicsAccumulator = 0f @@ -427,6 +438,24 @@ class RenderSceneImpl( } } + private fun hasNonFiniteTransforms(data: ModelInstanceImpl.PhysicsData): Boolean { + val count = rigidBodyComponents.size + for (i in 0 until count) { + val offset = i * 7 + if (!data.transformArray[offset + 0].isFinite() || + !data.transformArray[offset + 1].isFinite() || + !data.transformArray[offset + 2].isFinite() || + !data.transformArray[offset + 3].isFinite() || + !data.transformArray[offset + 4].isFinite() || + !data.transformArray[offset + 5].isFinite() || + !data.transformArray[offset + 6].isFinite() + ) { + return true + } + } + return false + } + private fun updateRenderData(instance: ModelInstanceImpl, time: Float, allowPhysics: Boolean) { if (instance.modelData.undirtyNodeCount != nodes.size) { executePhase(instance, UpdatePhase.GlobalTransformPropagation) diff --git a/blazerod/render/main/runtime/node/component/RigidBodyComponent.kt b/blazerod/render/main/runtime/node/component/RigidBodyComponent.kt index 8149ac29..617c3b17 100644 --- a/blazerod/render/main/runtime/node/component/RigidBodyComponent.kt +++ b/blazerod/render/main/runtime/node/component/RigidBodyComponent.kt @@ -84,6 +84,14 @@ class RigidBodyComponent( val qy = array[offset + 4] val qz = array[offset + 5] val qw = array[offset + 6] + if (!px.isFinite() || !py.isFinite() || !pz.isFinite() || + !qx.isFinite() || !qy.isFinite() || !qz.isFinite() || !qw.isFinite() + ) { + instance.setTransformMatrix(node.nodeIndex, TransformId.PHYSICS) { + this.matrix.identity() + } + return + } physicsMatrix.translationRotate(px, py, pz, qx, qy, qz, qw) val localBase = instance.getTransformMap(node).getSum(TransformId.EXTERNAL_PARENT_DEFORM) From f523caacdeebd418522e3149f52a996f67d6de0f Mon Sep 17 00:00:00 2001 From: Sylsatra Date: Wed, 17 Jun 2026 12:17:08 +0700 Subject: [PATCH 72/72] chore: remove PMX physics diagnostic logs --- .../render/main/physics/PhysicsLibrary.java | 2 -- .../render/main/runtime/ModelInstanceImpl.kt | 3 -- .../render/main/runtime/RenderSceneImpl.kt | 33 ------------------- 3 files changed, 38 deletions(-) diff --git a/blazerod/render/main/physics/PhysicsLibrary.java b/blazerod/render/main/physics/PhysicsLibrary.java index b2d4c4bb..43ffa5ca 100644 --- a/blazerod/render/main/physics/PhysicsLibrary.java +++ b/blazerod/render/main/physics/PhysicsLibrary.java @@ -115,8 +115,6 @@ public static boolean load() { return true; } catch (Exception | LinkageError ex) { logger.error("Failed to load bullet physics native library", ex); - System.err.println("CRITICAL JNI LOAD ERROR:"); - ex.printStackTrace(System.err); return false; } } diff --git a/blazerod/render/main/runtime/ModelInstanceImpl.kt b/blazerod/render/main/runtime/ModelInstanceImpl.kt index b1151b9e..c04d4934 100644 --- a/blazerod/render/main/runtime/ModelInstanceImpl.kt +++ b/blazerod/render/main/runtime/ModelInstanceImpl.kt @@ -163,9 +163,6 @@ class ModelInstanceImpl( var currentPhysicsInterval: Float = MIN_INTERVAL var explosionLogCount: Int = 0 var debugStepCount: Int = 0 - var resetCount: Int = 0 - var lastStepLogNanos: Long = 0L - var severeStepLogCount: Int = 0 companion object { const val BUDGET_HIGH_MS = 2.0f diff --git a/blazerod/render/main/runtime/RenderSceneImpl.kt b/blazerod/render/main/runtime/RenderSceneImpl.kt index 16f0de5d..1499b7e2 100644 --- a/blazerod/render/main/runtime/RenderSceneImpl.kt +++ b/blazerod/render/main/runtime/RenderSceneImpl.kt @@ -166,20 +166,10 @@ class RenderSceneImpl( data.currentPhysicsInterval = ModelInstanceImpl.PhysicsData.MIN_INTERVAL data.lastFrameDistSq = 0f data.clearSpeedHistory() - data.resetCount++ executePhase(instance, UpdatePhase.PhysicsUpdatePost) executePhase(instance, UpdatePhase.GlobalTransformPropagation) logPhysicsAnomalies(instance, data, "reset") - - logger.info( - "[PMX-PHYSICS-RUNTIME] resetPhysics count={} bodies={} joints={} time={} interval={}", - data.resetCount, - rigidBodyComponents.size, - physicsJoints.size, - time, - data.currentPhysicsInterval, - ) } @@ -316,29 +306,6 @@ class RenderSceneImpl( // Adapt physics rate based on step cost (EMA with hysteresis) data.physicsStepTimeMs = 0.8f * data.physicsStepTimeMs + 0.2f * stepTimeMs - val nowNanos = System.nanoTime() - val logByCadence = data.debugStepCount % 120 == 0 - val logByTime = nowNanos - data.lastStepLogNanos >= 5_000_000_000L - val logBySevereCost = stepTimeMs > 50f && data.severeStepLogCount < 5 - if (logByCadence || logByTime || logBySevereCost) { - data.lastStepLogNanos = nowNanos - if (logBySevereCost) { - data.severeStepLogCount++ - } - logger.info( - "[PMX-PHYSICS-RUNTIME] step count={} bodies={} joints={} stepMs={} emaMs={} interval={} distance={} maxSubSteps={} fixedDt={}", - data.debugStepCount, - rigidBodyComponents.size, - physicsJoints.size, - stepTimeMs, - data.physicsStepTimeMs, - data.currentPhysicsInterval, - distance, - PHYSICS_MAX_SUB_STEP_COUNT, - PHYSICS_TIME_STEP, - ) - logPhysicsAnomalies(instance, data, "step") - } if (data.physicsStepTimeMs > ModelInstanceImpl.PhysicsData.BUDGET_HIGH_MS) { data.currentPhysicsInterval = minOf( data.currentPhysicsInterval * 2f,