-
-
Save Ooseykins/0e037914360e33cde0327979a2c3dfdc to your computer and use it in GitHub Desktop.
using System; | |
using System.IO; | |
using System.Collections.Generic; | |
using System.Linq; | |
using BepInEx; | |
using UniVRM10; | |
using GameNetcodeStuff; | |
using UniGLTF; | |
using UnityEngine; | |
using UnityEngine.Rendering; | |
using UnityEngine.SceneManagement; | |
namespace LethalVRM | |
{ | |
public class LethalVRMManager : MonoBehaviour | |
{ | |
private const string MODEL_PATH = "VRMs"; | |
private HashSet<PlayerControllerB> players = new(); | |
private Dictionary<PlayerControllerB, ulong> playerIds = new(); | |
private HashSet<LethalVRMInstance> instances = new (); | |
private bool requirePlayerUpdate; | |
private GameObject playerPrefab; | |
private float playerPrefabHeight; | |
private Material HDRP_BaseMaterial; | |
private void Awake() | |
{ | |
string baseBundlePath = Path.Combine(Paths.PluginPath, "lethalvrm"); | |
string pluginBundlePath = Path.Combine(Paths.PluginPath, "Ooseykins-LethalVRM/lethalvrm"); | |
AssetBundle bundle = null; | |
if (File.Exists(baseBundlePath)) | |
{ | |
bundle = AssetBundle.LoadFromFile(baseBundlePath); | |
} | |
else if (File.Exists(pluginBundlePath)) | |
{ | |
bundle = AssetBundle.LoadFromFile(pluginBundlePath); | |
} | |
if (bundle == null) | |
{ | |
enabled = false; | |
Debug.LogError("LethalVRM failed to load it's asset bundle, this mod will not function"); | |
return; | |
} | |
// The MToon replacement shader has all the same texture properties as the regular MToon shader, but they are unused. | |
HDRPVrm10MToonMaterialImporter.MToonReplacementShader = (Shader)bundle.LoadAsset("MToonParameterShader"); | |
if (HDRPVrm10MToonMaterialImporter.MToonReplacementShader == null) | |
{ | |
enabled = false; | |
Debug.LogError("LethalVRM failed to load the MToon replacement shader, this mod will not function"); | |
return; | |
} | |
// Trigger to check for new player controllers on scene load | |
SceneManager.sceneLoaded += (_,_) => SceneLoad(); | |
// Keep this object alive forever, through scene unloads and more | |
gameObject.hideFlags = HideFlags.HideAndDontSave; | |
// This mod requires this path to be accessible | |
if (!Directory.Exists(MODEL_PATH)) | |
{ | |
Directory.CreateDirectory(MODEL_PATH); | |
} | |
if (!Directory.Exists(MODEL_PATH)) | |
{ | |
enabled = false; | |
Debug.LogError("LethalVRM failed to create directory for models, this mod will not function"); | |
return; | |
} | |
} | |
private void LateUpdate() | |
{ | |
FindUpdatedIDs(); | |
FindMaskEnemies(); | |
AnimateBonePairs(); | |
if (requirePlayerUpdate) | |
{ | |
FindPlayerControllers(); | |
requirePlayerUpdate = false; | |
} | |
} | |
void SceneLoad() | |
{ | |
FindPlayerControllers(); | |
PreparePrefabs(); | |
requirePlayerUpdate = true; | |
} | |
void PreparePrefabs() | |
{ | |
// Prepare the T-pose player prefab, this will live until the application quits | |
if (playerPrefab == null) | |
{ | |
var basePlayer = players.FirstOrDefault(x => x.name == "Player"); | |
if (basePlayer != null) | |
{ | |
basePlayer.gameObject.SetActive(false); | |
playerPrefab = Instantiate(basePlayer.gameObject); | |
playerPrefab.name = "VRM T Pose Match"; | |
playerPrefab.hideFlags = HideFlags.HideAndDontSave; | |
var HDRP_CutoffMaterial = GameObject.Find("CatwalkShip").GetComponent<MeshRenderer>().material; | |
HDRP_BaseMaterial = new Material(HDRP_CutoffMaterial); | |
HDRP_BaseMaterial.SetFloat("_Smoothness", 0f); | |
HDRP_BaseMaterial.SetFloat("_Metallic", 0f); | |
HDRP_BaseMaterial.SetTextureScale("_BaseColorMap",Vector2.one); | |
basePlayer.gameObject.SetActive(true); | |
playerPrefab.GetComponentInChildren<Animator>().enabled = false; | |
playerPrefab.transform.FindDescendant("arm.L_upper").localRotation = Quaternion.Euler(0,90,-10); | |
playerPrefab.transform.FindDescendant("arm.R_upper").localRotation = Quaternion.Euler(-10,0,0); | |
playerPrefab.transform.FindDescendant("thigh.L").localRotation = Quaternion.Euler(20,180,180); | |
playerPrefab.transform.FindDescendant("thigh.R").localRotation = Quaternion.Euler(20,180,180); | |
playerPrefab.transform.FindDescendant("hand.R").localRotation = Quaternion.Euler(0,90,0); | |
playerPrefab.transform.FindDescendant("hand.L").localRotation = Quaternion.Euler(0,270,0); | |
var footL = playerPrefab.transform.FindDescendant("foot.L"); | |
var footR = playerPrefab.transform.FindDescendant("foot.R"); | |
var head = playerPrefab.transform.FindDescendant("spine.004_end"); | |
var p1 = new Vector3(0, ((footL.position+footR.position)/2).y, 0); | |
var p2 = new Vector3(0, head.position.y, 0); | |
playerPrefabHeight = Vector3.Distance(p1, p2); | |
playerPrefab.transform.rotation = Quaternion.identity; | |
Debug.Log($"LethalVRM base prefab set to {playerPrefab.name}, has a height of {playerPrefabHeight:0.###}"); | |
} | |
} | |
} | |
void FindPlayerControllers() | |
{ | |
players = FindObjectsOfType<PlayerControllerB>(true).ToHashSet(); | |
Debug.Log($"LethalVRM found {players.Count} player controllers"); | |
} | |
class LethalVRMInstance | |
{ | |
private const int firstPersonLayer = 23; | |
private const int thirdPersonLayer = 0; | |
public Vrm10Instance Vrm10Instance; | |
public PlayerControllerB PlayerControllerB; | |
public HashSet<(Transform target, Transform source, Quaternion localRotation)> boneTranslation = new(); | |
public HashSet<Renderer> renderers = new(); | |
public Dictionary<Transform, Transform> deadMap; | |
public Transform deadBodyRoot; | |
public float hipOffset; | |
public void SetSkeletonMimic(Transform root) | |
{ | |
deadBodyRoot = root; | |
deadMap = new(); | |
if (PlayerControllerB.deadBody != null && PlayerControllerB.deadBody.transform == root) | |
{ | |
root.name = "spine"; | |
} | |
foreach (var t in boneTranslation) | |
{ | |
var srcT = root.FindDescendant(t.source.parent.name); | |
var newBone = new GameObject("VRM Rotation Bone").transform; | |
newBone.parent = srcT; | |
newBone.position = srcT.position; | |
newBone.localRotation = t.localRotation; | |
deadMap[t.source] = newBone; | |
} | |
foreach (var r in root.GetComponentsInChildren<Renderer>()) | |
{ | |
if ((PlayerControllerB.deadBody != null && PlayerControllerB.deadBody.transform == root) || r.name is "LOD1" or "LOD2" or "LOD3" or "LevelSticker" or "BetaBadge") | |
{ | |
r.enabled = false; | |
} | |
} | |
} | |
public void UpdateVisibility() | |
{ | |
var deadShouldRender = !PlayerControllerB.isPlayerDead || (deadBodyRoot != null && PlayerControllerB.deadBody != null); | |
var localShouldRender = !PlayerControllerB.gameplayCamera.enabled; | |
foreach (var r in renderers) | |
{ | |
r.gameObject.layer = localShouldRender ? thirdPersonLayer : firstPersonLayer; | |
r.enabled = deadShouldRender; | |
} | |
} | |
} | |
void FindUpdatedIDs() | |
{ | |
foreach (var p in players) | |
{ | |
if (p.playerSteamId == 0) | |
{ | |
continue; | |
} | |
// Remove instances and dict entries for disconnected players | |
if (playerIds.ContainsKey(p) && (p.disconnectedMidGame || (p.NetworkObject.OwnerClientId == 0 && p.name != "Player"))) | |
{ | |
foreach (var i in instances) | |
{ | |
if (i.PlayerControllerB == p) | |
{ | |
Destroy(i.Vrm10Instance.gameObject); | |
} | |
} | |
instances.RemoveWhere(x => x.PlayerControllerB == p); | |
playerIds.Remove(p); | |
continue; | |
} | |
if (p.NetworkObject.OwnerClientId == 0 && p.name != "Player") | |
{ | |
continue; | |
} | |
// Add new players to the Id dict. Try and load models for the steamId | |
if (!playerIds.ContainsKey(p) || playerIds[p] != p.playerSteamId) | |
{ | |
playerIds[p] = p.playerSteamId; | |
File.WriteAllText($"{MODEL_PATH}/{p.playerSteamId}_{p.playerUsername}.txt",$"{p.playerSteamId} seen as {p.playerUsername}"); | |
if (instances.Any(x => x.PlayerControllerB.playerSteamId == p.playerSteamId)) | |
{ | |
continue; | |
} | |
string path = $"{MODEL_PATH}/{p.playerSteamId}.vrm"; | |
if (File.Exists(path)) | |
{ | |
Debug.Log($"LethalVRM trying to load model for path {path}"); | |
LoadModelToPlayer(path, p); | |
} | |
else | |
{ | |
Debug.Log($"LethalVRM trying to load model for path {path}, no such file exists"); | |
} | |
} | |
} | |
} | |
void FindMaskEnemies() | |
{ | |
var masks = FindObjectsByType<MaskedPlayerEnemy>(sortMode: FindObjectsSortMode.None); | |
foreach (var m in masks) | |
{ | |
if (m.transform == null || m.mimickingPlayer == null) | |
{ | |
continue; | |
} | |
foreach (var i in instances) | |
{ | |
if (m.mimickingPlayer == i.PlayerControllerB && i.deadBodyRoot != m.transform) | |
{ | |
i.SetSkeletonMimic(m.transform); | |
} | |
} | |
} | |
} | |
async void LoadModelToPlayer(string path, PlayerControllerB player) | |
{ | |
// Let VRM do it's thing, it spits all kinds of errors if things go wrong | |
var instance = await Vrm10.LoadPathAsync(path); | |
if (instance == null) | |
{ | |
enabled = false; | |
Debug.LogError($"LethalVRM had an error loading the VRM at {path}, this mod will not function"); | |
return; | |
} | |
instance.name = $"LethalVRM Character Model {player.playerUsername} {player.playerSteamId}"; | |
instance.transform.position = player.transform.position; | |
// Create instance for LethalVRM | |
LethalVRMInstance newInstance = new LethalVRMInstance(); | |
// Replace VRM materials with Lethal Company shader materials | |
if (HDRP_BaseMaterial == null) | |
{ | |
enabled = false; | |
Debug.LogError("LethalVRM had some error loading the Lethal Company shader material, this mod will not function"); | |
return; | |
} | |
foreach (var r in instance.GetComponentsInChildren<Renderer>(true)) | |
{ | |
newInstance.renderers.Add(r); | |
r.receiveShadows = true; | |
r.shadowCastingMode = ShadowCastingMode.TwoSided; | |
if (r is SkinnedMeshRenderer skinnedMeshRenderer) | |
{ | |
skinnedMeshRenderer.updateWhenOffscreen = true; | |
} | |
Material[] newMaterials = new Material[r.materials.Length]; | |
for(int i = 0; i < r.materials.Length; i++) | |
{ | |
var m = r.materials[i]; | |
Material newM = new Material(HDRP_BaseMaterial); | |
newM.name = m.name; | |
newM.mainTexture = m.mainTexture; | |
if (m.HasProperty("_M_CullMode")) | |
{ | |
newM.SetFloat("_CullMode", m.GetFloat("_M_CullMode")); | |
} | |
if (m.HasProperty("_BumpMap")) | |
{ | |
newM.SetTexture("_NormalMap", m.GetTexture("_BumpMap")); | |
} | |
newMaterials[i] = newM; | |
} | |
r.materials = newMaterials; | |
} | |
// Disable the VRM animators | |
Animator a = instance.Runtime.ControlRig.ControlRigAnimator; | |
a.enabled = false; | |
instance.Runtime.VrmAnimation = null; | |
// Transform names -> Unity bone names | |
(string name, HumanBodyBones bone)[] boneNames = new[] | |
{ | |
("spine", HumanBodyBones.Hips), | |
("spine.001", HumanBodyBones.Spine), | |
("spine.002", HumanBodyBones.Chest), | |
("spine.003", HumanBodyBones.UpperChest), | |
("spine.004", HumanBodyBones.Neck), | |
("shoulder.R", HumanBodyBones.RightShoulder), | |
("arm.R_upper", HumanBodyBones.RightUpperArm), | |
("arm.R_lower", HumanBodyBones.RightLowerArm), | |
("hand.R", HumanBodyBones.RightHand), | |
("shoulder.L", HumanBodyBones.LeftShoulder), | |
("arm.L_upper", HumanBodyBones.LeftUpperArm), | |
("arm.L_lower", HumanBodyBones.LeftLowerArm), | |
("hand.L", HumanBodyBones.LeftHand), | |
("thigh.R", HumanBodyBones.RightUpperLeg), | |
("shin.R", HumanBodyBones.RightLowerLeg), | |
("foot.R", HumanBodyBones.RightFoot), | |
("thigh.L", HumanBodyBones.LeftUpperLeg), | |
("shin.L", HumanBodyBones.LeftLowerLeg), | |
("foot.L", HumanBodyBones.LeftFoot), | |
///////////////////// | |
("finger1.L", HumanBodyBones.LeftThumbProximal), | |
("finger1.L.001", HumanBodyBones.LeftThumbIntermediate), | |
("finger1.L.001_end", HumanBodyBones.LeftThumbDistal), | |
("finger2.L", HumanBodyBones.LeftIndexProximal), | |
("finger2.L.001", HumanBodyBones.LeftIndexIntermediate), | |
("finger2.L.001_end", HumanBodyBones.LeftIndexDistal), | |
("finger3.L", HumanBodyBones.LeftMiddleProximal), | |
("finger3.L.001", HumanBodyBones.LeftMiddleIntermediate), | |
("finger3.L.001_end", HumanBodyBones.LeftMiddleDistal), | |
("finger4.L", HumanBodyBones.LeftRingProximal), | |
("finger4.L.001", HumanBodyBones.LeftRingIntermediate), | |
("finger4.L.001_end", HumanBodyBones.LeftRingDistal), | |
("finger5.L", HumanBodyBones.LeftLittleProximal), | |
("finger5.L.001", HumanBodyBones.LeftLittleIntermediate), | |
("finger5.L.001_end", HumanBodyBones.LeftLittleDistal), | |
///////////////////// | |
("finger1.R", HumanBodyBones.RightThumbProximal), | |
("finger1.R.001", HumanBodyBones.RightThumbIntermediate), | |
("finger1.R.001_end", HumanBodyBones.RightThumbDistal), | |
("finger2.R", HumanBodyBones.RightIndexProximal), | |
("finger2.R.001", HumanBodyBones.RightIndexIntermediate), | |
("finger2.R.001_end", HumanBodyBones.RightIndexDistal), | |
("finger3.R", HumanBodyBones.RightMiddleProximal), | |
("finger3.R.001", HumanBodyBones.RightMiddleIntermediate), | |
("finger3.R.001_end", HumanBodyBones.RightMiddleDistal), | |
("finger4.R", HumanBodyBones.RightRingProximal), | |
("finger4.R.001", HumanBodyBones.RightRingIntermediate), | |
("finger4.R.001_end", HumanBodyBones.RightRingDistal), | |
("finger5.R", HumanBodyBones.RightLittleProximal), | |
("finger5.R.001", HumanBodyBones.RightLittleIntermediate), | |
("finger5.R.001_end", HumanBodyBones.RightLittleDistal), | |
}; | |
// Add extra bones to each player bone to use as reference for world angles | |
// Better way to do this? Probably but I'm not good at math | |
if (playerPrefab == null) | |
{ | |
enabled = false; | |
Debug.LogError("LethalVRM failed to find the player prefab, this mod will not function"); | |
return; | |
} | |
HashSet<(Transform target, Transform source, Quaternion localRotation)> boneTranslation = new(); | |
foreach (var p in boneNames) | |
{ | |
var targetT = a.GetBoneTransform(p.bone); | |
if (targetT == null) | |
{ | |
Debug.Log($"LethalVRM {path} missing bone {p.bone} ({p.name})"); | |
continue; | |
} | |
var srcT = player.transform.FindDescendant(p.name); | |
var poseT = playerPrefab.transform.FindDescendant(p.name); | |
var newBone = new GameObject("VRM Rotation Bone").transform; | |
newBone.parent = poseT; | |
newBone.position = poseT.position; | |
newBone.rotation = targetT.rotation; | |
var localRotation = newBone.localRotation; | |
newBone.parent = srcT; | |
newBone.position = srcT.position; | |
newBone.localRotation = localRotation; | |
boneTranslation.Add((targetT,newBone, localRotation)); | |
} | |
// Calculate VRM height for scaling the player to the correct size | |
Vector3 p1 = new Vector3(0, instance.Humanoid.Head.position.y, 0); | |
Vector3 p2 = new Vector3(0, instance.transform.position.y, 0); | |
float height = Vector3.Distance(p1,p2); | |
float playerScale = playerPrefabHeight/height; | |
instance.transform.localScale = new Vector3(playerScale,playerScale,playerScale); | |
Debug.Log($"LethalVRM {path} has a height of: {height:0.###}, scaling to {playerScale:0.###}"); | |
// Calculate distance from feet to hips to offset the player hips for different leg lengths | |
float vrmHipHeight = Vector3.Distance(instance.Humanoid.Hips.position, | |
(instance.Humanoid.LeftFoot.position + instance.Humanoid.RightFoot.position) / 2f); | |
var head = playerPrefab.transform.FindDescendant("spine"); | |
var leftFoot = playerPrefab.transform.FindDescendant("foot.L"); | |
var rightFoot = playerPrefab.transform.FindDescendant("foot.R"); | |
float lethalHipHeight = Vector3.Distance(head.position, (leftFoot.position+rightFoot.position)/2f); | |
// Set player renderer visibility, done by name to prevent hiding special renderers like first-person arms etc | |
player.transform.FindDescendant("LOD1").gameObject.SetActive(false); | |
player.transform.FindDescendant("LOD2").gameObject.SetActive(false); | |
player.transform.FindDescendant("LOD3").gameObject.SetActive(false); | |
player.transform.FindDescendant("LevelSticker").gameObject.SetActive(false); | |
player.transform.FindDescendant("BetaBadge").gameObject.SetActive(false); | |
// Add new player instance to the set | |
newInstance.Vrm10Instance = instance; | |
newInstance.PlayerControllerB = player; | |
newInstance.hipOffset = vrmHipHeight - lethalHipHeight; | |
newInstance.boneTranslation = boneTranslation; | |
instances.Add(newInstance); | |
Debug.Log($"LethalVRM finished loading {path}"); | |
} | |
void AnimateBonePairs() | |
{ | |
List<LethalVRMInstance> toRemove = new List<LethalVRMInstance>(); | |
foreach (var i in instances) | |
{ | |
// Remove instance if the player is destroyed | |
if (i.Vrm10Instance == null || i.PlayerControllerB == null) | |
{ | |
toRemove.Add(i); | |
continue; | |
} | |
// Remove the dead body if root is destroyed | |
if (i.deadBodyRoot == null) | |
{ | |
i.deadMap = null; | |
i.deadBodyRoot = null; | |
} | |
// Remove instance if the player lost some bones | |
bool exit = false; | |
foreach (var t in i.boneTranslation) | |
{ | |
if (t.target == null || t.source == null) | |
{ | |
toRemove.Add(i); | |
exit = true; | |
break; | |
} | |
// If a dead body bone is missing, clear the dead body | |
if (i.deadMap != null && i.deadMap[t.source] == null) | |
{ | |
i.deadMap = null; | |
i.deadBodyRoot = null; | |
} | |
} | |
if (exit) | |
{ | |
continue; | |
} | |
// Clear the dead body if the player is alive or the dead body no longer exists | |
if (!i.PlayerControllerB.isPlayerDead || i.deadBodyRoot == null) | |
{ | |
i.deadMap = null; | |
i.deadBodyRoot = null; | |
} | |
// Prepare dead body if it changed | |
if (i.PlayerControllerB.isPlayerDead && i.deadBodyRoot == null) | |
{ | |
if (i.PlayerControllerB.deadBody != null) | |
{ | |
i.SetSkeletonMimic(i.PlayerControllerB.deadBody.transform); | |
} | |
} | |
// Position bones | |
foreach (var t in i.boneTranslation) | |
{ | |
t.target.rotation = i.deadMap != null ? i.deadMap[t.source].rotation : t.source.rotation; | |
if (t.source.parent.name == "spine") | |
{ | |
t.target.position = i.deadMap != null ? i.deadMap[t.source].position : t.source.position; | |
t.target.position += t.target.up * i.hipOffset; | |
} | |
} | |
// Set layer and renderer visibility | |
i.UpdateVisibility(); | |
} | |
// Remove instances flagged for deletion | |
foreach (var i in toRemove) | |
{ | |
instances.Remove(i); | |
} | |
} | |
} | |
} |
Hey! Would it be possible to add these 2 things?
-Adding an overall scale modifier using the generated .txt file (or an in-game setting) instead of re-exporting the VRM itself
-Adding support for the Company Issued Protogen mod, where it deletes the protogen model if the player has a VRM model (currently it displays both the VRM and protogen model on the same player, and looks very jank)
In response to the comments from lines 397 & 398.
Here's the math way to do it.
Assuming that we are using this Quaternion extensions class :
public static class QuaternionExtensions
{
public static Quaternion Diff(this Quaternion fromSource, Quaternion toTarget)
{
return Quaternion.Inverse(fromSource) * toTarget;
}
public static Quaternion Add(this Quaternion fromSource, Quaternion diff)
{
return fromSource * diff;
}
}
It can be used as such (sca = Scavenger Model & vrm = VRM Model & diff = Offset) :
// Calculate the offset.
Transform scaTPoseBone;
Transform vrmTPoseBone;
Quaternion diff = scaTPoseBone.rotation.Diff(vrmBoneTPose.rotation);
// Apply the the offset
Transform scaSourceBone;
Transform vrmTargetBone;
vrmTargetBone.rotation = scaSourceBone.rotation.Add(diff);
This is where I found the information from the user luisfinke's post : https://forum.unity.com/threads/get-the-difference-between-two-quaternions-and-add-it-to-another-quaternion.513187/#post-8187513
Instead of outputting a Debug at Line 213, perhaps add support for a fallback avatar (like fallback.vrm or 0.vrm).