diff --git a/blazerod/model/model-base/PhysicalJoint.kt b/blazerod/model/model-base/PhysicalJoint.kt index 70fe534a1..335d71646 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 80861fe9a..c793e6215 100644 --- a/blazerod/model/model-base/RigidBody.kt +++ b/blazerod/model/model-base/RigidBody.kt @@ -17,6 +17,9 @@ data class RigidBody( val repulsion: Float, val frictionForce: Float, 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 e566a8d12..5103b6e75 100644 --- a/blazerod/model/model-pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/PmxLoader.kt @@ -92,6 +92,9 @@ class PmxLoader : ModelFileLoader { private lateinit var rigidBodies: List private var boneToRigidBodyMap = mutableMapOf>() private lateinit 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) { @@ -397,7 +400,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) @@ -405,9 +414,25 @@ class PmxLoader : ModelFileLoader { } // BDEF2 1 -> { - val index1 = readBoneIndex() - val index2 = readBoneIndex() + var index1 = readBoneIndex() + var index2 = readBoneIndex() val weight1 = readWeight() + + if (isKoikatsu) { + // 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 (y > 1.25f) this.spineIndex else if (y > 0.8f) this.pelvisIndex else 0 + } + if (index2 == 0 && (1f - weight1) > 0.9f) { + index2 = if (y > 1.25f) this.spineIndex else if (y > 0.8f) this.pelvisIndex else 0 + } + + // Normal redirection for mixed weights + 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) { @@ -419,14 +444,30 @@ 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 + 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 + if (index4 == 0) index4 = fallback + } + outputBuffer.putInt(outputPosition, index1) outputBuffer.putInt(outputPosition + 4, index2) outputBuffer.putInt(outputPosition + 8, index3) @@ -447,9 +488,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) { @@ -812,6 +859,184 @@ class PmxLoader : ModelFileLoader { } } } + + // 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.contains("bust") || n.contains("breast") || n.contains("skirt") || + n.contains("胸") || n.contains("乳") || n.contains("髪") || n.contains("スカート") + } + if (isKoikatsu) { + // HIERARCHY-REPAIR logs suppressed + } + } + + private fun repairKoikatsuHierarchy() { + if (!isKoikatsu) return + + // 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 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 + + for (i in bones.indices) { + val bone = bones[i] + val n = bone.nameLocal.lowercase() + + // 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 }) { + 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 + 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. + // 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 + + // Tiebreaker: Higher index usually means the more specific/animated joint (waist02 vs hips) + score += i.toDouble() / 1000.0 + + if (score > bestScore) { + bestScore = score + bestIndex = i + } + } + } + return bestIndex + } + + // 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] + } + + val spine03Index = findAnchor(listOf("spine03")) + val spine02Index = findAnchor(listOf("spine02")) + val spine01Index = findAnchor(listOf("spine01")) + 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", "下半身", "lower")) + + val headIndex = findAnchor(listOf("head", "頭")) + val neckIndex = findAnchor(listOf("neck", "首")) + + this.spineIndex = when { + spine03Index != -1 -> spine03Index + spine02Index != -1 -> spine02Index + spine01Index != -1 -> spine01Index + spineIndex != -1 -> spineIndex + else -> -1 + } + + this.pelvisIndex = when { + waist02Index != -1 -> waist02Index + waist01Index != -1 -> waist01Index + hipsIndex != -1 -> hipsIndex + 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 + } + } + if (this.spineIndex == -1) { + val neckFirst = findAnchor(listOf("neck", "首")) + if (neckFirst != -1) { + this.spineIndex = bones[neckFirst].parentBoneIndex ?: -1 + } + } + + val headAnchorIndex = when { + headIndex != -1 -> headIndex + neckIndex != -1 -> neckIndex + this.spineIndex != -1 -> this.spineIndex + else -> -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 { + // logs removed + } + + // 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 + val isCoreSkeleton = name.contains("センター") || name.contains("center") || + name.contains("グルーブ") || name.contains("groove") || + name.contains("全ての親") + + // 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 (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 + + // Skirts and lower body dynamics + (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_") || name.contains("head") || name.contains("neck") || name.contains("首") || name.contains("頭")) && headAnchorIndex != -1 -> headAnchorIndex + else -> -1 + } + if (newParent != -1 && newParent != index && newParent != currentParent) { + // 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 } + + bone.copy(parentBoneIndex = newParent) + } else { + bone + } + } else { + bone + } + } + bones = newBones + bones = newBones } private fun loadMorphTargets(buffer: ByteBuffer) { @@ -1075,7 +1300,7 @@ class PmxLoader : ModelFileLoader { -rotationMinimumOrig.y, -rotationMinimumOrig.z, ) - val positionSpring = loadVector3f(buffer) + val positionSpring = loadVector3f(buffer).div(MMD_SCALE) val rotationSpring = loadVector3f(buffer) PmxJoint( @@ -1108,6 +1333,7 @@ class PmxLoader : ModelFileLoader { loadTextures(buffer) loadMaterials(buffer) loadBones(buffer) + repairKoikatsuHierarchy() loadMorphTargets(buffer) loadDisplayFrames(buffer) loadRigidBodies(buffer) @@ -1115,13 +1341,38 @@ class PmxLoader : ModelFileLoader { val modelId = UUID.randomUUID() val rootNodes = mutableListOf() + val rigidBodyModeStats = linkedMapOf() + val rigidBodyModeExamples = mutableListOf() + val jointStats = linkedMapOf() - fun addBone(index: Int, parentPosition: Vector3fc? = null): Node { + 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] val boneNodeId = NodeId(modelId, index) val children = childBoneMap[index]?.map { - addBone(it, bone.position) + addBone(it, bone.position, depth + 1) } ?: listOf() val components = buildList { @@ -1171,50 +1422,125 @@ 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 -> + val rigidBody = rigidBodies[index] + val name = rigidBody.nameLocal.lowercase() + 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 || + 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. + // Keep dynamic PMX physics only for the accessory groups we intentionally support. + add( NodeComponent.RigidBodyComponent( rigidBodyId = RigidBodyId(modelId, index), rigidBody = rigidBodies[index].let { rigidBody -> - val enableNameBasedOverrides = false + val enableNameBasedOverrides = isKoikatsu val basePhysicsMode = when (rigidBody.physicsMode) { PmxRigidBody.PhysicsMode.FOLLOW_BONE -> RigidBody.PhysicsMode.FOLLOW_BONE PmxRigidBody.PhysicsMode.PHYSICS -> RigidBody.PhysicsMode.PHYSICS PmxRigidBody.PhysicsMode.PHYSICS_PLUS_BONE -> RigidBody.PhysicsMode.PHYSICS_PLUS_BONE } - val nameLocal = rigidBody.nameLocal + val nameLocal = rigidBody.nameLocal.lowercase() + val adjustedPhysicsMode = if (enableNameBasedOverrides) { when { - nameLocal.startsWith("Skirt_D_") -> + isHair || isGarment -> basePhysicsMode + isBreast -> + RigidBody.PhysicsMode.FOLLOW_BONE + 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 + 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_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 -> RigidBody.PhysicsMode.FOLLOW_BONE } } else { basePhysicsMode } - 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", - ) + 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 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 -> { + baseCollisionMask and bodyGroupMask.inv() + } + isBreast -> baseCollisionMask and bodyGroupMask.inv() + isHair -> baseCollisionMask or bodyGroupMask + isSkirt -> baseCollisionMask + 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) { + // 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 + } else 0f + + val sweptRadius = if (needsCCD) { + val minSize = listOf(rigidBody.shapeSize.x, rigidBody.shapeSize.y, rigidBody.shapeSize.z).minOrNull() ?: 1f + minSize * 0.2f + } 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 (isSkirt && adjustedPhysicsMode == RigidBody.PhysicsMode.PHYSICS) { + RigidBody.PhysicsMode.PHYSICS_PLUS_BONE + } 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), @@ -1225,15 +1551,29 @@ class PmxLoader : ModelFileLoader { PmxRigidBody.ShapeType.BOX -> RigidBody.ShapeType.BOX PmxRigidBody.ShapeType.CAPSULE -> RigidBody.ShapeType.CAPSULE }, - shapeSize = rigidBody.shapeSize, + shapeSize = rigidBody.shapeSize, 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), - frictionForce = rigidBody.frictionForce, - physicsMode = adjustedPhysicsMode, + 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) { + rigidBody.mass // Increased mass for better inertia and less shaking + } else { + rigidBody.mass + }, + moveAttenuation = finalMoveAttenuation, + rotationDamping = if (isHair) { + 0.1f + } else if (isBreast) { + 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.03f else 0.04f ) }, ) @@ -1390,11 +1730,47 @@ 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 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") + } + 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("ribbon") + } + val isSkirtJoint = nameCombo.let { n -> + 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") || + 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 || (isHelperJoint && !isHairJoint && !isGarmentJoint))) { + 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( name = joint.nameLocal.takeIf(String::isNotBlank), type = when (joint.type) { @@ -1404,13 +1780,37 @@ class PmxLoader : ModelFileLoader { rigidBodyB = RigidBodyId(modelId, joint.rigidBodyIndexB), position = joint.position, rotation = joint.rotation, - positionMin = joint.positionMinimum, - positionMax = joint.positionMaximum, - rotationMin = joint.rotationMinimum, - rotationMax = joint.rotationMaximum, + positionMin = if (isBreastJoint) Vector3f(0f, 0f, 0f) else joint.positionMinimum, + positionMax = if (isBreastJoint) Vector3f(0f, 0f, 0f) else joint.positionMaximum, + rotationMin = if (isBreastJoint) { + // Extremely tight rotation - almost completely static + Vector3f(-0.017f, -0.017f, -0.017f) // ~1 degree + } else joint.rotationMinimum, + rotationMax = if (isBreastJoint) { + Vector3f(0.017f, 0.017f, 0.017f) + } else joint.rotationMaximum, positionSpring = joint.positionSpring, - rotationSpring = 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 + else -> 1.0f + }, + biasFactor = when { + isBreastJoint -> 0.4f // Stiff error correction for Spring2 (was 0.1) + (rbA.nameLocal + rbB.nameLocal).lowercase().contains("hair") -> 0.1f // Stop violent solver "flicks" + else -> 0.3f + }, + 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()) { @@ -1472,4 +1872,4 @@ class PmxLoader : ModelFileLoader { buffer.order(ByteOrder.LITTLE_ENDIAN) context.load(buffer) } -} \ No newline at end of file +} diff --git a/blazerod/render/api/physics/PhysicsEngine.kt b/blazerod/render/api/physics/PhysicsEngine.kt index d00723652..fe1fa7ea1 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/api/resource/ModelInstance.kt b/blazerod/render/api/resource/ModelInstance.kt index 84fc82e12..d60a4db8f 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) @@ -31,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, @@ -42,4 +44,4 @@ interface ModelInstance : RefCount { interface Factory { fun of(scene: RenderScene): ModelInstance } -} \ No newline at end of file +} diff --git a/blazerod/render/main/animation/CrossfadeAnimationItemInstance.kt b/blazerod/render/main/animation/CrossfadeAnimationItemInstance.kt index e9cabd8e2..60b72a9c1 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) } } diff --git a/blazerod/render/main/physics/PhysicsLibrary.java b/blazerod/render/main/physics/PhysicsLibrary.java index 7ab38a7e0..43ffa5cae 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() { @@ -113,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/physics/PhysicsScene.cpp b/blazerod/render/main/physics/PhysicsScene.cpp index 9cff96f85..2384f6b74 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 = 84; if (size == 0) { throw std::invalid_argument("Empty rigidbody data"); } @@ -56,6 +56,9 @@ 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); + CopyField(rigidbody.collision_margin, rigidbody_data + 80); rigidbodies.push_back(rigidbody); } @@ -64,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"); } @@ -88,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 fdb5eefd4..9dfdb4a1a 100644 --- a/blazerod/render/main/physics/PhysicsScene.h +++ b/blazerod/render/main/physics/PhysicsScene.h @@ -42,6 +42,9 @@ struct RigidBody { float repulsion; float friction_force; PhysicsMode physics_mode; + float ccd_motion_threshold; + float ccd_swept_sphere_radius; + float collision_margin; }; struct Joint { @@ -56,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 63970dcce..1f60c20d0 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 = 84 val rigidBodiesBuffer = ByteBuffer.allocateDirect(rigidBodyItemSize * rigidBodies.size) .order(ByteOrder.nativeOrder()) rigidBodies.forEachIndexed { index, rigidBody -> @@ -45,9 +45,12 @@ 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) + 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 -> @@ -63,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 9a27d8f6c..e5d51160f 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 @@ -193,9 +194,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(); - solver_info.m_numIterations = 20; + solver_info.m_numIterations = 100; solver_info.m_splitImpulse = 1; - solver_info.m_splitImpulsePenetrationThreshold = -0.04f; + 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; @@ -306,6 +310,19 @@ 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) --- + if (rigidbody_item.ccd_motion_threshold > 0.0f) { + rigidbody->setCcdMotionThreshold(rigidbody_item.ccd_motion_threshold); + rigidbody->setCcdSweptSphereRadius(rigidbody_item.ccd_swept_sphere_radius); + } + + // Apply Native Collision Margin + rigidbody->setContactProcessingThreshold(BT_LARGE_FLOAT); + 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); if (rigidbody_item.physics_mode != PhysicsMode::PHYSICS) { @@ -359,19 +376,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)); + 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); - // 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)); + // 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); @@ -384,6 +402,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); @@ -397,6 +417,36 @@ PhysicsWorld::PhysicsWorld(const PhysicsScene& scene, size_t initial_transform_c constraint->setStiffness(5, joint_item.rotation_spring.z); } + // Apply Stability Parameters (ERP = Bias, CFM = Relaxation) + btTranslationalLimitMotor2* transMotor = constraint->getTranslationalLimitMotor(); + 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 + for (int i = 0; i < 3; i++) { + btRotationalLimitMotor2* rotMotor = constraint->getRotationalLimitMotor(i); + rotMotor->m_stopERP = joint_item.bias_factor; + rotMotor->m_stopCFM = joint_item.relaxation_factor; + } + + float mass = rigidbody_b.rigidbody->getInvMass() > 0 ? 1.0f / rigidbody_b.rigidbody->getInvMass() : 1.0f; + for (int i = 0; i < 6; i++) { + 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, i < 3 ? 0.99f : joint_item.softness); + } + } + this->world->addConstraint(constraint.get(), false); this->joints.push_back(std::move(constraint)); } @@ -447,20 +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 world_transform; - rigidbody.motion_state->getWorldTransform(world_transform); - rigidbody.rigidbody->setWorldTransform(world_transform); - rigidbody.rigidbody->setInterpolationWorldTransform(world_transform); + btTransform target_transform; + rigidbody.motion_state->getWorldTransform(target_transform); + + btTransform current_transform = rigidbody.rigidbody->getWorldTransform(); + + 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; + } + + 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()) { @@ -470,4 +571,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 420aa6d8a..06d01fe8a 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 d4d94c976..dec2e6f01 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 5f99ce1b6..b01a84df5 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/BUILD.bazel b/blazerod/render/main/runtime/BUILD.bazel index e73402175..6744329d4 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", ], ) diff --git a/blazerod/render/main/runtime/ModelInstanceImpl.kt b/blazerod/render/main/runtime/ModelInstanceImpl.kt index 12ce10b0a..c04d4934e 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) @@ -83,6 +84,9 @@ class ModelInstanceImpl( bulletWorld.pushTransforms(src) } override fun step(deltaTime: Float, maxSubSteps: Int, fixedTimeStep: Float) { + val isHighQuality = lodDistance < 5f + bulletWorld.setSolverIterations(if (isHighQuality) 32 else 12) + bulletWorld.step(deltaTime, maxSubSteps, fixedTimeStep) } override fun dispose() { @@ -117,6 +121,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 @@ -125,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") @@ -143,7 +165,7 @@ 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 MAX_INTERVAL = 1f / 15f @@ -232,6 +254,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] @@ -335,6 +361,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 9795958ba..1499b7e2d 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,9 +42,11 @@ class RenderSceneImpl( ) : AbstractRefCount(), RenderScene { override val attachments: Map, Any> companion object { - private const val PHYSICS_MAX_SUB_STEP_COUNT = 3 + private val logger = LoggerFactory.getLogger(RenderSceneImpl::class.java) + 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 @@ -138,6 +141,37 @@ 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() + + executePhase(instance, UpdatePhase.PhysicsUpdatePost) + executePhase(instance, UpdatePhase.GlobalTransformPropagation) + logPhysicsAnomalies(instance, data, "reset") + } + /** @@ -218,7 +252,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 +266,7 @@ 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 (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) { @@ -252,8 +287,20 @@ 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) + 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 @@ -313,25 +360,96 @@ 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 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) 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) diff --git a/blazerod/render/main/runtime/node/component/RigidBodyComponent.kt b/blazerod/render/main/runtime/node/component/RigidBodyComponent.kt index 8149ac29e..617c3b173 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) diff --git a/blazerod/render/main/runtime/renderer/CpuTransformRenderer.kt b/blazerod/render/main/runtime/renderer/CpuTransformRenderer.kt index 85800c519..264131c13 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 diff --git a/blazerod/render/main/runtime/resource/RenderPhysicsJoint.kt b/blazerod/render/main/runtime/resource/RenderPhysicsJoint.kt index 17e2d1974..cb8442208 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, )