From 48f34feeba5a43bf40d8adc0fd5e654557849b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ha=C3=AF=7E?= Date: Sat, 21 Dec 2024 23:50:56 +0100 Subject: [PATCH] Make SkinnedMeshes of the local player head cast shadows: - The SkinnedMeshes of the local player that reference any bone under the Head bone should now cast shadows of the head onto the world and other players. - SkinnedMeshes placed directly under the Head bone without any bone reference are not supported in this version. - MeshRenderers placed directly under the Head bone are not supported in this version. - This implementation: - Evaluates at runtime. - Creates a copy of all the transforms under the Head hierarchy. - Creates copies of SkinnedMeshes that reference any bone under the Head. - Bones under Head will reference the aforementionned copy. - Bones not under Head will reference a zero-scale bone under the neck. - Those SkinnedMeshes are set to Shadow Only. - This implementation currently uses "Update When Offscreen" OFF and an off-world "Bounds" value to prevent the shadow from displaying when rendering the local player avatar in third person. - This implementation was chosen because it's currently unclear how to manage the camera layers, and should be discussed further. - Just before any camera is rendered, once per frame, we copy the transforms, SkinnedMesh component enabled-ness, and blendshapes, so that the shadow reflects the visual state of the original SkinnedMesh. --- .../Avatar/BasisAvatarFactory.cs | 14 +- .../Basis Framework/Avatar/BasisLayer.cs | 9 + .../Basis Framework/Avatar/BasisLayer.cs.meta | 3 + .../Devices/Desktop/BasisLocalInputActions.cs | 3 +- .../Drivers/BasisLocalCameraDriver.cs | 6 +- .../Players/BasisHeadShadowDriver.cs | 282 ++++++++++++++++++ .../Players/BasisHeadShadowDriver.cs.meta | 3 + .../Players/BasisLocalPlayer.cs | 5 +- 8 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 Basis/Packages/Basis Framework/Avatar/BasisLayer.cs create mode 100644 Basis/Packages/Basis Framework/Avatar/BasisLayer.cs.meta create mode 100644 Basis/Packages/Basis Framework/Players/BasisHeadShadowDriver.cs create mode 100644 Basis/Packages/Basis Framework/Players/BasisHeadShadowDriver.cs.meta diff --git a/Basis/Packages/Basis Framework/Avatar/BasisAvatarFactory.cs b/Basis/Packages/Basis Framework/Avatar/BasisAvatarFactory.cs index 2ce189083..6a25cf804 100644 --- a/Basis/Packages/Basis Framework/Avatar/BasisAvatarFactory.cs +++ b/Basis/Packages/Basis Framework/Avatar/BasisAvatarFactory.cs @@ -7,13 +7,14 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Basis.Scripts.BasisSdk.Helpers; using UnityEngine; using UnityEngine.AddressableAssets; namespace Basis.Scripts.Avatar { public static class BasisAvatarFactory { - public static BasisLoadableBundle LoadingAvatar = new BasisLoadableBundle() + public static BasisLoadableBundle LoadingAvatar = new BasisLoadableBundle() { BasisBundleInformation = new BasisBundleInformation() { @@ -180,7 +181,7 @@ private static void InitializePlayerAvatar(BasisPlayer Player, GameObject Output localPlayer.InitalizeIKCalibration(localPlayer.AvatarDriver); for (int Index = 0; Index < Avatar.Renders.Length; Index++) { - Avatar.Renders[Index].gameObject.layer = 6; + Avatar.Renders[Index].gameObject.layer = BasisLayer.LocalPlayerAvatar; } Avatar.OnAvatarReady?.Invoke(true); } @@ -191,7 +192,7 @@ private static void InitializePlayerAvatar(BasisPlayer Player, GameObject Output remotePlayer.InitalizeIKCalibration(remotePlayer.RemoteAvatarDriver); for (int Index = 0; Index < Avatar.Renders.Length; Index++) { - Avatar.Renders[Index].gameObject.layer = 7; + Avatar.Renders[Index].gameObject.layer = BasisLayer.RemotePlayerAvatar; } Avatar.OnAvatarReady?.Invoke(false); } @@ -251,7 +252,7 @@ public static void LoadLoadingAvatar(BasisPlayer Player, string LoadingAvatarToU Player.InitalizeIKCalibration(BasisLocalPlayer.AvatarDriver); for (int Index = 0; Index < RenderCount; Index++) { - Avatar.Renders[Index].gameObject.layer = 6; + Avatar.Renders[Index].gameObject.layer = BasisLayer.LocalPlayerAvatar; } } else @@ -262,7 +263,7 @@ public static void LoadLoadingAvatar(BasisPlayer Player, string LoadingAvatarToU Player.InitalizeIKCalibration(BasisRemotePlayer.RemoteAvatarDriver); for (int Index = 0; Index < RenderCount; Index++) { - Avatar.Renders[Index].gameObject.layer = 7; + Avatar.Renders[Index].gameObject.layer = BasisLayer.RemotePlayerAvatar; } } } @@ -305,6 +306,9 @@ public static void CreateLocal(BasisLocalPlayer Player) Debug.LogError("Missing LocalPlayer or Avatar"); return; } + if (!Player.HeadShadowDriver) Player.HeadShadowDriver = BasisHelpers.GetOrAddComponent(Player.gameObject); + + Player.HeadShadowDriver.Initialize(Player.Avatar); Player.AvatarDriver.InitialLocalCalibration(Player); } diff --git a/Basis/Packages/Basis Framework/Avatar/BasisLayer.cs b/Basis/Packages/Basis Framework/Avatar/BasisLayer.cs new file mode 100644 index 000000000..8f01d6b1f --- /dev/null +++ b/Basis/Packages/Basis Framework/Avatar/BasisLayer.cs @@ -0,0 +1,9 @@ +namespace Basis.Scripts.Avatar +{ + public class BasisLayer + { + // TODO: These eventually need to be configurable, with values to be chosen by the framework consumer. + public const int LocalPlayerAvatar = 6; + public const int RemotePlayerAvatar = 7; + } +} diff --git a/Basis/Packages/Basis Framework/Avatar/BasisLayer.cs.meta b/Basis/Packages/Basis Framework/Avatar/BasisLayer.cs.meta new file mode 100644 index 000000000..30506c717 --- /dev/null +++ b/Basis/Packages/Basis Framework/Avatar/BasisLayer.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b2602b63ea984c5f8b58e86f496b10e3 +timeCreated: 1734820085 \ No newline at end of file diff --git a/Basis/Packages/Basis Framework/Device Management/Devices/Desktop/BasisLocalInputActions.cs b/Basis/Packages/Basis Framework/Device Management/Devices/Desktop/BasisLocalInputActions.cs index ee77944d7..b7adecd93 100644 --- a/Basis/Packages/Basis Framework/Device Management/Devices/Desktop/BasisLocalInputActions.cs +++ b/Basis/Packages/Basis Framework/Device Management/Devices/Desktop/BasisLocalInputActions.cs @@ -67,6 +67,7 @@ private void OnBeforeRender() { BasisLocalPlayer.Instance.LocalBoneDriver.SimulateAndApply(Time.timeAsDouble,Time.deltaTime); AfterAvatarChanges?.Invoke(); + BasisLocalPlayer.Instance.HeadShadowDriver.PrepareThisFrame(); } public void Update() @@ -304,4 +305,4 @@ public void RunCancelled() } } } -} \ No newline at end of file +} diff --git a/Basis/Packages/Basis Framework/Drivers/BasisLocalCameraDriver.cs b/Basis/Packages/Basis Framework/Drivers/BasisLocalCameraDriver.cs index fb021affe..8d6b0e203 100644 --- a/Basis/Packages/Basis Framework/Drivers/BasisLocalCameraDriver.cs +++ b/Basis/Packages/Basis Framework/Drivers/BasisLocalCameraDriver.cs @@ -46,7 +46,7 @@ public class BasisLocalCameraDriver : MonoBehaviour public Vector3 largerScale; public static Vector3 LeftEye; public static Vector3 RightEye; - + public Color UnMutedMutedIconColorActive = Color.white; public Color UnMutedMutedIconColorInactive = Color.grey; @@ -297,6 +297,7 @@ public void BeginCameraRendering(ScriptableRenderContext context, Camera Camera) { if (Camera.GetInstanceID() == CameraInstanceID) { + LocalPlayer.HeadShadowDriver.BeforeRenderFirstPerson(); ScaleheadToZero(); if (CameraData.allowXRRendering) { @@ -310,6 +311,7 @@ public void BeginCameraRendering(ScriptableRenderContext context, Camera Camera) } else { + LocalPlayer.HeadShadowDriver.BeforeRenderThirdPerson(); ScaleHeadToNormal(); } } @@ -341,4 +343,4 @@ Vector3 CalculatePosition(Vector2 size, Vector3 percentage) return offset + center; } } -} \ No newline at end of file +} diff --git a/Basis/Packages/Basis Framework/Players/BasisHeadShadowDriver.cs b/Basis/Packages/Basis Framework/Players/BasisHeadShadowDriver.cs new file mode 100644 index 000000000..c64ed580d --- /dev/null +++ b/Basis/Packages/Basis Framework/Players/BasisHeadShadowDriver.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Basis.Scripts.Avatar; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Basis.Scripts.BasisSdk.Players +{ + public class BasisHeadShadowDriver : MonoBehaviour + { + private Transform _headNullable; + + private MeshRenderer[] _meshRenderersUnderHeadBoneOrZero; + private SkinnedMeshRenderer[] _skinnedMeshesOnAvatarThatDependOnHead; + private List _transformsUnderHeadBoneOrZero; + + private int[] _skinnedMeshBlendShapeCount; + + private Transform _nonHeadAndNonNeckDisappearer; + private SkinnedMeshRenderer[] _copiesOfSkinnedMeshes; + private Transform[] _copiesOfTransformsUnderHeadBone; + + private bool _isShadowNecessary; + + private static readonly Bounds DoNotRenderBounds = new(Vector3.one * 999_999_999, Vector3.zero); + + public void Initialize(BasisAvatar avatar) + { + _headNullable = avatar.Animator is { } animator && animator + && animator.GetBoneTransform(HumanBodyBones.Head) is { } head && head + ? head : null; + if (_headNullable == null) + { + Debug.Log("There was Head bone to generate the shadow clone (This is not a problem)"); + } + + _meshRenderersUnderHeadBoneOrZero = _headNullable ? _headNullable.GetComponentsInChildren(true) : Array.Empty(); + _transformsUnderHeadBoneOrZero = _headNullable ? _headNullable.GetComponentsInChildren(true).ToList() : new List(); + + // TODO: Need to handle special case when there is a SMR under the Head hierarchy, which may have no bones in it. + _skinnedMeshesOnAvatarThatDependOnHead = avatar.GetComponentsInChildren(true) + // Ignore SMRs that don't have a mesh. + .Where(meshRenderer => meshRenderer.sharedMesh) + // Intersect is lazily evaluated, so Any will stop when the first element in common is found. + .Where(HasAnyBoneThatRequiresBonesUnderHeadHierarchy) + .ToArray(); + + _isShadowNecessary = _headNullable && (_skinnedMeshesOnAvatarThatDependOnHead.Length > 0 || _meshRenderersUnderHeadBoneOrZero.Length > 0); + if (_isShadowNecessary) + { + // Head can't be null past this point. + var neck = _headNullable.parent; + + _nonHeadAndNonNeckDisappearer = new GameObject("NonHeadAndNonNeckDisappearer") + { + transform = { position = neck.position, rotation = neck.rotation, localScale = Vector3.zero } + }.transform; + _nonHeadAndNonNeckDisappearer.SetParent(neck, true); + + // Create copies + CloneHead(); + + _copiesOfSkinnedMeshes = new SkinnedMeshRenderer[_skinnedMeshesOnAvatarThatDependOnHead.Length]; + _skinnedMeshBlendShapeCount = new int[_skinnedMeshesOnAvatarThatDependOnHead.Length]; + for (var index = 0; index < _skinnedMeshesOnAvatarThatDependOnHead.Length; index++) + { + var originalSmr = _skinnedMeshesOnAvatarThatDependOnHead[index]; + + var copy = new GameObject(NameOfShadowCopy(originalSmr.name)) + { + transform = { parent = originalSmr.transform, localPosition = Vector3.zero, localRotation = Quaternion.identity, localScale = Vector3.one } + }; + copy.SetActive(false); + copy.layer = BasisLayer.LocalPlayerAvatar; + + var smrCopy = copy.AddComponent(); + _copiesOfSkinnedMeshes[index] = smrCopy; + + smrCopy.sharedMesh = originalSmr.sharedMesh; + smrCopy.bones = ProduceNewBoneArrayReferencingShadowCopyHeadBones(originalSmr.bones); + + smrCopy.localBounds = originalSmr.localBounds; + smrCopy.quality = originalSmr.quality; + smrCopy.updateWhenOffscreen = originalSmr.updateWhenOffscreen; + smrCopy.rootBone = originalSmr.rootBone; + + smrCopy.sharedMaterials = originalSmr.sharedMaterials; + + smrCopy.shadowCastingMode = ShadowCastingMode.ShadowsOnly; + smrCopy.receiveShadows = false; + + smrCopy.lightProbeUsage = LightProbeUsage.Off; + smrCopy.probeAnchor = originalSmr.probeAnchor; + + smrCopy.skinnedMotionVectors = originalSmr.skinnedMotionVectors; + smrCopy.allowOcclusionWhenDynamic = originalSmr.allowOcclusionWhenDynamic; + smrCopy.renderingLayerMask = originalSmr.renderingLayerMask; + + _skinnedMeshBlendShapeCount[index] = originalSmr.sharedMesh.blendShapeCount; + + copy.SetActive(true); + } + + // TODO: It would be smarter to let MeshRenderers always display, but: + // - Set it to render Shadow Only when rendering in first-person. + // - Set it to render Mesh and Shadow when rendering in third-person. + } + else + { + _copiesOfTransformsUnderHeadBone = Array.Empty(); + _copiesOfSkinnedMeshes = Array.Empty(); + } + } + + public void PrepareThisFrame() + { + if (!_isShadowNecessary) return; + + for (var index = 0; index < _skinnedMeshesOnAvatarThatDependOnHead.Length; index++) + { + var smr = _skinnedMeshesOnAvatarThatDependOnHead[index]; + if (smr) // Handle the remote possibility that the original SkinnedMeshRenderer may have been deleted by an outside system. + { + var copy = _copiesOfSkinnedMeshes[index]; + if (copy) // Handle the possibility that our copy of the SkinnedMeshRenderer may have been deleted by an outside system. + { + if (smr.enabled != copy.enabled) copy.enabled = smr.enabled; + + // We want the copy to be Inactive when the original or any of its parents is Inactive, + // hence the discrepancy between activeInHierarchy and self. + var isActiveInHierarchy = smr.gameObject.activeInHierarchy; + if (isActiveInHierarchy != copy.gameObject.activeSelf) + { + copy.gameObject.SetActive(isActiveInHierarchy); + } + + var blendShapeCount = _skinnedMeshBlendShapeCount[index]; + for (var blendShapeIndex = 0; blendShapeIndex < blendShapeCount; blendShapeIndex++) + { + copy.SetBlendShapeWeight(blendShapeIndex, smr.GetBlendShapeWeight(blendShapeIndex)); + } + } + } + else + { + if (_copiesOfSkinnedMeshes[index]) + { + Destroy(_copiesOfSkinnedMeshes[index].gameObject); + } + _copiesOfSkinnedMeshes[index] = null; + } + } + + for (var index = 0; index < _transformsUnderHeadBoneOrZero.Count; index++) + { + var from = _transformsUnderHeadBoneOrZero[index]; + var to = _copiesOfTransformsUnderHeadBone[index]; + + if (from && to) + { + to.localPosition = from.localPosition; + to.localRotation = from.localRotation; + to.localScale = from.localScale; + } + } + } + + public void BeforeRenderFirstPerson() + { + if (!_isShadowNecessary) return; + + // Made shadow visible. + // There may be better ways to do this. + for (var index = 0; index < _copiesOfSkinnedMeshes.Length; index++) + { + var original = _skinnedMeshesOnAvatarThatDependOnHead[index]; + var copy = _copiesOfSkinnedMeshes[index]; + + // Handle the possibility of runtime removal by an external system. + if (original && copy) + { + copy.updateWhenOffscreen = original.updateWhenOffscreen; + copy.bounds = original.bounds; + } + } + } + + public void BeforeRenderThirdPerson() + { + if (!_isShadowNecessary) return; + + // Made shadow invisible, so that it doesn't cast shadow on the thing it's trying to copy. + // There may be better ways to do this. + foreach (var copy in _copiesOfSkinnedMeshes) + { + // Handle the possibility of runtime removal by an external system. + if (copy) + { + copy.updateWhenOffscreen = false; + copy.bounds = DoNotRenderBounds; + } + } + } + + private Transform[] ProduceNewBoneArrayReferencingShadowCopyHeadBones(Transform[] originalSmrBones) + { + var newBoneArray = new Transform[originalSmrBones.Length]; + for (var index = 0; index < originalSmrBones.Length; index++) + { + var originalBone = originalSmrBones[index]; + var indexOfBoneOrMinus = _transformsUnderHeadBoneOrZero.IndexOf(originalBone); + if (indexOfBoneOrMinus != -1) + { + newBoneArray[index] = _copiesOfTransformsUnderHeadBone[indexOfBoneOrMinus]; + } + else + { + newBoneArray[index] = _nonHeadAndNonNeckDisappearer; + } + } + + return newBoneArray; + } + + private void CloneHead() + { + if (_headNullable == null) + { + _copiesOfTransformsUnderHeadBone = Array.Empty(); + return; + } + + var copies = new Transform[_transformsUnderHeadBoneOrZero.Count]; + for (var index = 0; index < _transformsUnderHeadBoneOrZero.Count; index++) + { + var current = _transformsUnderHeadBoneOrZero[index]; + var copy = new GameObject(NameOfShadowCopy(current.name)) + { + transform = { position = current.position, rotation = current.rotation, localScale = current.localScale } + }; + copies[index] = copy.transform; + } + + for (var index = 0; index < _transformsUnderHeadBoneOrZero.Count; index++) + { + var current = _transformsUnderHeadBoneOrZero[index]; + var copy = copies[index]; + + var indexOfParentOrMinus = _transformsUnderHeadBoneOrZero.IndexOf(current.parent); + if (indexOfParentOrMinus < 0) + { + // The parent of the Head bone can't be found, so it must be the neck bone. + copy.SetParent(_headNullable.parent, true); + } + else + { + copy.SetParent(copies[indexOfParentOrMinus], true); + } + } + + _copiesOfTransformsUnderHeadBone = copies; + } + + private static string NameOfShadowCopy(string name) + { + return $"_{name}_ShadowCopy"; + } + + private bool HasAnyBoneThatRequiresBonesUnderHeadHierarchy(SkinnedMeshRenderer meshRenderer) + { + // This does not exclude skinned mesh that have something under the Head bone without actually requiring it + // (it would be better if it did, but that's probably a task better done at avatar build time). + return AsSafeSet(meshRenderer.bones).Intersect(_transformsUnderHeadBoneOrZero).Any(); + } + + private static HashSet AsSafeSet(Transform[] transformsWithNullsAndDuplicates) + { + return new HashSet(transformsWithNullsAndDuplicates.Where(t => t != null)); + } + } +} diff --git a/Basis/Packages/Basis Framework/Players/BasisHeadShadowDriver.cs.meta b/Basis/Packages/Basis Framework/Players/BasisHeadShadowDriver.cs.meta new file mode 100644 index 000000000..4a0128daf --- /dev/null +++ b/Basis/Packages/Basis Framework/Players/BasisHeadShadowDriver.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 891f1a68105e46528d49269d5efeac11 +timeCreated: 1734813283 \ No newline at end of file diff --git a/Basis/Packages/Basis Framework/Players/BasisLocalPlayer.cs b/Basis/Packages/Basis Framework/Players/BasisLocalPlayer.cs index 5f9a244d5..ffbcbe61b 100644 --- a/Basis/Packages/Basis Framework/Players/BasisLocalPlayer.cs +++ b/Basis/Packages/Basis Framework/Players/BasisLocalPlayer.cs @@ -34,13 +34,14 @@ public class BasisLocalPlayer : BasisPlayer /// /// the bool when true is the final size /// the bool when false is not the final size - /// use the bool to + /// use the bool to /// public Action OnPlayersHeightChanged; public BasisLocalBoneDriver LocalBoneDriver; public BasisLocalAvatarDriver AvatarDriver; // public BasisFootPlacementDriver FootPlacementDriver; public BasisAudioAndVisemeDriver VisemeDriver; + public BasisHeadShadowDriver HeadShadowDriver; [SerializeField] public LayerMask GroundMask; public static string LoadFileNameAndExtension = "LastUsedAvatar.BAS"; @@ -210,4 +211,4 @@ private void OnPausedEvent(bool IsPaused) } } } -} \ No newline at end of file +}