Skip to content

Instantly share code, notes, and snippets.

@Ooseykins
Last active February 4, 2024 21:49
Show Gist options
  • Save Ooseykins/0e037914360e33cde0327979a2c3dfdc to your computer and use it in GitHub Desktop.
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);
}
}
}
}
@OomJan
Copy link

OomJan commented Jan 8, 2024

Instead of outputting a Debug at Line 213, perhaps add support for a fallback avatar (like fallback.vrm or 0.vrm).

string path = $"{MODEL_PATH}/fallback.vrm";
if (File.Exists(path))
{
    Debug.Log($"LethalVRM trying to load fallback model for path {path}");
    LoadModelToPlayer(path, p);
}

@Marxvee
Copy link

Marxvee commented Jan 26, 2024

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)

@ExtraPerry
Copy link

ExtraPerry commented Feb 4, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment