feat: add a component to perform nonuniform scale adjustments (#583)

This commit is contained in:
bd_ 2023-12-27 20:29:51 +09:00 committed by GitHub
parent 4d3f49306e
commit 2650566f9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 602 additions and 5 deletions

View File

@ -0,0 +1,148 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEngine;
using static nadena.dev.modular_avatar.core.editor.Localization;
namespace nadena.dev.modular_avatar.core.editor
{
[CustomEditor(typeof(ModularAvatarScaleAdjuster))]
[CanEditMultipleObjects]
internal class ScaleAdjusterInspector : MAEditorBase
{
private SerializedProperty _scale;
private ModularAvatarScaleAdjuster[] _sortedTargets;
private Vector3[] _originalScales;
private Vector3 gizmoScale = Vector3.one;
private bool _adjustChildPositions;
protected void OnEnable()
{
_scale = serializedObject.FindProperty("m_Scale");
_sortedTargets = targets.Cast<ModularAvatarScaleAdjuster>().OrderBy(TransformDepth).ToArray();
_originalScales = _sortedTargets.Select(t => t.Scale).ToArray();
}
private int TransformDepth(ModularAvatarScaleAdjuster obj)
{
var t = obj.transform;
var depth = 0;
while (t != null)
{
depth++;
t = t.parent;
}
return depth;
}
protected void OnDisable()
{
}
public void OnSceneGUI()
{
Selection.selectionChanged -= UnhideTools;
Selection.selectionChanged += UnhideTools;
Tools.hidden = (Tools.current == Tool.Scale);
if (!Tools.hidden) return;
var handlePos = _sortedTargets[0].transform;
EditorGUI.BeginChangeCheck();
var handleSize = HandleUtility.GetHandleSize(handlePos.position);
gizmoScale = Handles.ScaleHandle(gizmoScale, handlePos.position, handlePos.rotation, handleSize);
if (EditorGUI.EndChangeCheck())
{
for (int i = 0; i < _sortedTargets.Length; i++)
{
UpdateScale(i, handlePos);
}
}
}
private void UpdateScale(int i, Transform refTransform)
{
var xform = _sortedTargets[i].transform;
var target = _sortedTargets[i];
Matrix4x4 initialTransform = xform.parent.localToWorldMatrix * Matrix4x4.TRS(
xform.localPosition,
xform.localRotation,
xform.localScale
);
Matrix4x4 initialScale = Matrix4x4.TRS(
Vector3.zero,
Quaternion.identity,
_originalScales[i]
);
Matrix4x4 newTransform = refTransform.localToWorldMatrix * Matrix4x4.TRS(
Vector3.zero,
Quaternion.identity,
gizmoScale
);
float scaleX = TransformVec(Vector3.right);
float scaleY = TransformVec(Vector3.up);
float scaleZ = TransformVec(Vector3.forward);
Undo.RecordObject(target, "Adjust scale");
var targetL2W = target.transform.localToWorldMatrix;
var baseToScaleCoord = (targetL2W * Matrix4x4.Scale(target.Scale)).inverse * targetL2W;
target.Scale = new Vector3(scaleX, scaleY, scaleZ);
var scaleToBaseCoord = Matrix4x4.Scale(target.Scale);
PrefabUtility.RecordPrefabInstancePropertyModifications(target);
// Update child positions
if (_adjustChildPositions)
{
var updateTransform = scaleToBaseCoord * baseToScaleCoord;
foreach (Transform child in target.transform)
{
Undo.RecordObject(child, "Adjust scale");
child.localPosition = updateTransform.MultiplyPoint(child.localPosition);
PrefabUtility.RecordPrefabInstancePropertyModifications(child);
}
}
float TransformVec(Vector3 vec)
{
// first, place our measurement vector into world spoce
vec = (initialTransform * initialScale).MultiplyVector(vec);
// now put it into reference space
vec = refTransform.worldToLocalMatrix.MultiplyVector(vec);
// and return using the adjusted scale
vec = newTransform.MultiplyVector(vec);
// now come back into local space
vec = (initialTransform.inverse).MultiplyVector(vec);
return vec.magnitude;
}
}
private static void UnhideTools()
{
Tools.hidden = false;
}
protected override void OnInnerInspectorGUI()
{
EditorGUILayout.PropertyField(_scale, G("scale_adjuster.scale"));
_adjustChildPositions = EditorGUILayout.Toggle(G("scale_adjuster.adjust_children"), _adjustChildPositions);
serializedObject.ApplyModifiedProperties();
Localization.ShowLanguageUI();
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2632280a7a3445d68589d63458159b8c
timeCreated: 1703662098

View File

@ -232,5 +232,7 @@
"setup_outfit.err.no_animator": "Your avatar does not have an Animator component.",
"setup_outfit.err.no_hips": "Your avatar does not have a Hips bone. Setup Outfit only works on humanoid avatars.",
"setup_outfit.err.no_outfit_hips": "Unable to identify the Hips object for the outfit. Searched for objects containing the following names:",
"move_independently.group-header": "Objects to move together"
"move_independently.group-header": "Objects to move together",
"scale_adjuster.scale": "Scale adjustment",
"scale_adjuster.adjust_children": "Adjust position of child objects"
}

View File

@ -193,5 +193,7 @@
"setup_outfit.err.no_animator": "アバターにAnimatorコンポーネントがありません。",
"setup_outfit.err.no_hips": "アバターにHipsボーンがありません。なお、Setup Outfitはヒューマイドアバター以外には対応していません。",
"setup_outfit.err.no_outfit_hips": "衣装のHipsボーンを発見できませんでした。以下の名前を含むボーンを探しました",
"move_independently.group-header": "一緒に動かすオブジェクト"
"move_independently.group-header": "一緒に動かすオブジェクト",
"scale_adjuster.scale": "Scale調整値",
"scale_adjuster.adjust_children": "子オブジェクトの位置を調整"
}

View File

@ -78,6 +78,11 @@ namespace nadena.dev.modular_avatar.core.editor
TopoProcessMergeArmatures(mergeArmatures);
#if MA_VRCSDK3_AVATARS
foreach (var c in avatarGameObject.transform.GetComponentsInChildren<ScaleProxy>(true))
{
BoneDatabase.AddMergedBone(c.transform);
}
foreach (var c in avatarGameObject.transform.GetComponentsInChildren<VRCPhysBone>(true))
{
if (c.rootTransform == null) c.rootTransform = c.transform;

View File

@ -56,7 +56,6 @@ namespace nadena.dev.modular_avatar.core.ArmatureAwase
private void OnValidate()
{
Debug.Log("=== OnValidate");
hideFlags = HideFlags.DontSave;
_excluded = new HashSet<Transform>();
if (m_groupedBones == null)
@ -110,6 +109,7 @@ namespace nadena.dev.modular_avatar.core.ArmatureAwase
foreach (Transform child in parent)
{
if (_excluded.Contains(child)) continue;
if (child.GetComponent<ScaleProxy>() != null) continue;
_observed.Add(child);

View File

@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.Linq;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
namespace nadena.dev.modular_avatar.core
{
[Serializable]
internal struct ScalePatch
{
public SkinnedMeshRenderer smr;
public int boneIndex;
public ScalePatch(SkinnedMeshRenderer smr, int boneIndex)
{
this.smr = smr;
this.boneIndex = boneIndex;
}
public bool Equals(ScalePatch other)
{
return smr.Equals(other.smr) && boneIndex == other.boneIndex;
}
public override bool Equals(object obj)
{
return obj is ScalePatch other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
return (smr.GetHashCode() * 397) ^ boneIndex;
}
}
}
[ExecuteInEditMode]
[DisallowMultipleComponent]
[AddComponentMenu("Modular Avatar/MA Scale Adjuster")]
[HelpURL("https://modular-avatar.nadena.dev/docs/reference/scale-adjuster?lang=auto")]
public sealed class ModularAvatarScaleAdjuster : AvatarTagComponent
{
[SerializeField] private Vector3 m_Scale = Vector3.one;
public Vector3 Scale
{
get => m_Scale;
set
{
m_Scale = value;
Update();
}
}
[SerializeField] internal Transform scaleProxy;
[SerializeField] private List<ScalePatch> patches = new List<ScalePatch>();
private bool initialized = false;
#if UNITY_EDITOR
private void Update()
{
if (this == null) return;
PatchRenderers();
scaleProxy.localScale = m_Scale;
}
void OnValidate()
{
initialized = false;
EditorApplication.delayCall += Update;
}
private void PatchRenderers()
{
if (initialized || this == null) return;
if (PrefabUtility.IsPartOfPrefabInstance(this))
{
// Ensure we're using the same ScaleProxy as the corresponding prefab asset.
var prefab = PrefabUtility.GetCorrespondingObjectFromSource(this);
if (this.scaleProxy == null || prefab.scaleProxy == null || prefab.scaleProxy !=
PrefabUtility.GetCorrespondingObjectFromSource(this.scaleProxy))
{
if (prefab.scaleProxy == null && scaleProxy != null)
{
// Push our ScaleProxy down into the prefab (this happens after applying the ScaleAdjuster
// component to a prefab)
var assetPath = AssetDatabase.GetAssetPath(prefab);
PrefabUtility.ApplyAddedGameObject(scaleProxy.gameObject, assetPath,
InteractionMode.AutomatedAction);
prefab.scaleProxy = PrefabUtility.GetCorrespondingObjectFromSource(this.scaleProxy);
}
else
{
// Clear any duplicate scaleProxy we have
if (scaleProxy != null) DestroyImmediate(scaleProxy.gameObject);
}
var so = new SerializedObject(this);
var sp = so.FindProperty(nameof(scaleProxy));
PrefabUtility.RevertPropertyOverride(sp, InteractionMode.AutomatedAction);
so.ApplyModifiedPropertiesWithoutUndo();
// Find the corresponding child
foreach (Transform t in transform)
{
if (PrefabUtility.GetCorrespondingObjectFromSource(t) == prefab.scaleProxy)
{
scaleProxy = t;
break;
}
}
}
}
if (scaleProxy == null && !PrefabUtility.IsPartOfPrefabAsset(this))
{
scaleProxy = new GameObject(gameObject.name + " (Scale Proxy)").transform;
scaleProxy.SetParent(transform, false);
scaleProxy.localPosition = Vector3.zero;
scaleProxy.localRotation = Quaternion.identity;
scaleProxy.localScale = m_Scale;
scaleProxy.gameObject.AddComponent<ScaleProxy>();
PrefabUtility.RecordPrefabInstancePropertyModifications(this);
}
if (scaleProxy != null)
{
scaleProxy.hideFlags = HideFlags.HideInHierarchy;
RewriteBoneReferences(transform, scaleProxy);
}
initialized = true;
}
private void RewriteBoneReferences(Transform oldBone, Transform newBone, Transform selfTransform = null)
{
if (selfTransform == null) selfTransform = transform;
var prefabNewBone = PrefabUtility.GetCorrespondingObjectFromSource(newBone);
var oldPatches = new HashSet<ScalePatch>(this.patches);
var newPatches = new HashSet<ScalePatch>();
var avatarRoot = RuntimeUtil.FindAvatarInParents(selfTransform);
if (avatarRoot != null)
{
foreach (var smr in avatarRoot.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
var serializedObject = new SerializedObject(smr);
var bonesArray = serializedObject.FindProperty("m_Bones");
int boneCount = bonesArray.arraySize;
var parentSmr = PrefabUtility.GetCorrespondingObjectFromSource(smr);
var parentBones = parentSmr != null ? parentSmr.bones : null;
var propMods = PrefabUtility.GetPropertyModifications(smr);
bool changed = false;
for (int i = 0; i < boneCount; i++)
{
var boneProp = bonesArray.GetArrayElementAtIndex(i);
var bone = boneProp.objectReferenceValue as Transform;
if (bone == oldBone || bone == newBone ||
(bone == null && oldPatches.Contains(new ScalePatch(smr, i))))
{
if (parentBones != null && parentBones[i] == prefabNewBone)
{
// Remove any prefab overrides for this bone entry
changed = boneProp.objectReferenceValue != newBone;
boneProp.objectReferenceValue = newBone;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
PrefabUtility.RevertPropertyOverride(boneProp, InteractionMode.AutomatedAction);
}
else
{
boneProp.objectReferenceValue = newBone;
changed = true;
}
newPatches.Add(new ScalePatch(smr, i));
}
}
if (changed)
{
serializedObject.ApplyModifiedPropertiesWithoutUndo();
ConfigurePrefab();
}
}
if (this != null && newPatches != oldPatches)
{
this.patches = newPatches.ToList();
PrefabUtility.RecordPrefabInstancePropertyModifications(this);
}
}
}
private void ConfigurePrefab()
{
if (this == null || !PrefabUtility.IsPartOfPrefabInstance(this)) return;
var source = PrefabUtility.GetCorrespondingObjectFromSource(this);
var path = AssetDatabase.GetAssetPath(source);
var root = PrefabUtility.LoadPrefabContents(path);
foreach (var obj in root.GetComponentsInChildren<ModularAvatarScaleAdjuster>())
{
obj.PatchRenderers();
}
PrefabUtility.SaveAsPrefabAsset(root, path);
PrefabUtility.UnloadPrefabContents(root);
initialized = false;
}
protected override void OnDestroy()
{
base.OnDestroy();
UnpatchRenderers();
}
private void UnpatchRenderers()
{
var scaleProxy2 = this.scaleProxy;
var transform2 = this.transform;
EditorApplication.delayCall += () =>
{
if (scaleProxy2 == null) return;
if (transform2 != null)
{
RewriteBoneReferences(scaleProxy2, transform2, transform2);
}
try
{
DestroyImmediate(scaleProxy2.gameObject);
}
catch (InvalidOperationException e)
{
// not supported in Unity 2019...
}
};
}
#else
private void Update()
{
// placeholder to make builds work
}
#endif
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 09a660aa9d4e47d992adcac5a05dd808
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: a8edd5bd1a0a64a40aa99cc09fb5f198, type: 3}
userData:
assetBundleName:
assetBundleVariant:

82
Runtime/ScaleProxy.cs Normal file
View File

@ -0,0 +1,82 @@
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
namespace nadena.dev.modular_avatar.core
{
[AddComponentMenu("")]
internal sealed class ScaleProxy : AvatarTagComponent
{
#if UNITY_EDITOR
void OnValidate()
{
EditorApplication.delayCall += DeferredValidate;
}
private void DeferredValidate()
{
if (this == null) return;
gameObject.hideFlags = HideFlags.HideInHierarchy;
var parentObject = transform.parent;
var parentScaleAdjuster =
parentObject != null ? parentObject.GetComponent<ModularAvatarScaleAdjuster>() : null;
if (parentScaleAdjuster == null || parentScaleAdjuster.scaleProxy != transform)
{
if (PrefabUtility.IsPartOfPrefabAsset(this))
{
var path = AssetDatabase.GetAssetPath(this);
var root = PrefabUtility.LoadPrefabContents(path);
foreach (var obj in root.GetComponentsInChildren<ScaleProxy>())
{
obj.DeferredValidate();
}
PrefabUtility.SaveAsPrefabAsset(root, path);
PrefabUtility.UnloadPrefabContents(root);
}
else
{
SelfDestruct();
}
}
}
private void SelfDestruct()
{
var root = ndmf.runtime.RuntimeUtil.FindAvatarInParents(transform);
if (root == null)
{
root = transform;
while (root.parent != null) root = root.parent;
}
foreach (var smr in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
var bones = smr.bones;
bool changed = false;
for (var i = 0; i < bones.Length; i++)
{
if (bones[i] == transform)
{
bones[i] = transform.parent;
changed = true;
}
}
if (changed)
{
smr.bones = bones;
}
}
DestroyImmediate(gameObject);
}
#endif
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3f0c19b32ba845a2a84f37f48e4ec4d5
timeCreated: 1703659053

View File

@ -39,6 +39,7 @@ namespace modular_avatar_tests
if (type == typeof(Activator)) return;
if (type == typeof(AvatarActivator)) return;
if (type == typeof(TestComponent)) return;
if (type == typeof(ScaleProxy)) return;
// get icon
var component = (MonoBehaviour) _gameObject.AddComponent(type);
@ -63,6 +64,7 @@ namespace modular_avatar_tests
if (type == typeof(Activator)) return;
if (type == typeof(AvatarActivator)) return;
if (type == typeof(TestComponent)) return;
if (type == typeof(ScaleProxy)) return;
// get icon
var helpUrl = type.GetCustomAttribute<HelpURLAttribute>();

View File

@ -20,4 +20,6 @@ For example, you might move the hips and upper leg objects together, but leave t
## Limitations
While this component supports scaling an object independently of its children, non-uniform scales (where the X, Y, and Z
scales are not all the same) are not fully supported, and may result in unexpected behavior.
scales are not all the same) are not fully supported, and may result in unexpected behavior. If you need to adjust the
scale of each axis independently, you should use the [Scale Adjuster](scale-adjuster.md) component in addition to Move
Independently.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -0,0 +1,37 @@
# Scale Adjuster
![Scale Adjuster](scale-adjuster.png)
The Scale Adjuster component allows you to adjust the X/Y/Z scales of a specific bone without causing issues with
rotated child bones.
## When should I use it?
This component is primarily intended for use when fitting clothing not originally designed for your avatar. You can use
this to adjust the dimensions of specific bones, without breaking child bones.
## When shouldn't I use it?
When adjusting the overall scale of a bone (where X/Y/Z are being adjusted equally), it's usually better to use the
normal unity scale tools.
## Setting up Scale Adjuster
Simply add the Scale Adjuster component to the bone in question. Now, when you have the scaling tool selected, changes
will affect only this one bone.
You can check or uncheck the "Adjust child positions" checkbox to adjust the relative position of child bones when the
scale of their parent changes. This is useful when you want to adjust the scale of a bone, but don't want to move the
child bones. Note that this adjusts only the _position_ of child bones, and not their scale.
Scale Adjuster supports adjusting the scale of multiple bones by adding the Scale Component to all of the bones in
question, then selecting multiple bones before adjusting their scale. However, if these bones are rotated, the scale
adjustment won't be perfect, and may not give quite the results you expect.
:::warning
![Use this, not that](scale-adjuster-warning-trs-tool.png)
Scale Adjuster only controls the unity scale tool. The combined Move/Rotate/Scale tool will still affect all children.
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -20,4 +20,4 @@ MA Move Independentlyというコンポーネントを使うと、子オブジ
## 制限事項
子に影響を与えずにオブジェクトの大きさを調整することは対応していますが、XYZそれぞれのスケールが同じ出ない場合は
対応されず、変な挙動になることがあります。ご注意ください
対応されず、変な挙動になることがあります。個別に調整する必要がある場合は、「[Scale Adjuster](scale-adjuster.md)」を併用しましょう

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -0,0 +1,32 @@
# Scale Adjuster
![Scale Adjuster](scale-adjuster.png)
Scale Adjusterを付けることで、回転した子ボーンに影響を与えることなく、ボーンのX/Y/Zスケールを個別に調整することができます。
## いつ使うべきか?
このコンポーネントは、非対応衣装を導入するときに利用することを想定しています。これを利用すると、子ボーンを壊すことなく、
特定のボーンの寸法を調整できます。
## 非推奨の場合
ボーンの全体のスケールを変更する場合(X/Y/Zが連動して変わる場合)は、Unity標準のスケールツールを使いましょう。
## 設定方法
該当のボーンにScale Adjusterコンポーネントを追加します。これで、スケールツールを選択したときに、このボーンのみに影響が出ます。
「子オブジェクトの位置を調整」という設定を入れると、親のスケールが変わる時は子ボーンの相対位置も調整されます。逆にオフにすることで、
子ボーンを移動させずに親のスケールを調整できます。なお、子ボーンの位置のみが調整されます。子ボーンのスケールが変更されません。
複数のボーンのスケールを同時に調整することも対応しています。該当するボーンすべてにScale Adjusterを追加して、複数選択でスケール調整
するとすべて同時に編集できます。ただし、ボーンが回転された場合は完璧に連動が取れない場合があります。ご注意ください。
:::warning
![こっちのほうを使いましょう](scale-adjuster-warning-trs-tool.png)
Scale Adjusterはスケール拡大縮小ツールのみに対応しています。移動・回転・スケールをまとめたツールでは、将来通り子ボーンにも影響が出ます。
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB