feat: add Object Toggle component (#942)

* feat: add Object Toggle component

* docs: Object Toggle docs

* chore: add missing HelpURL

* chore: fix broken test
This commit is contained in:
bd_ 2024-08-03 19:56:07 -07:00 committed by GitHub
parent 366ff0832f
commit 32dc864d8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 647 additions and 18 deletions

View File

@ -116,8 +116,20 @@ namespace nadena.dev.modular_avatar.animation
return false;
}
paramName = _readableProperty.ForActiveSelf(_pathMappings.GetObjectIdentifier(obj));
paramName = _readableProperty.ForActiveSelf(path);
return true;
}
public string ForceGetActiveSelfProxy(GameObject obj)
{
if (_selfProxies.TryGetValue(obj, out var paramName) && !string.IsNullOrEmpty(paramName)) return paramName;
var path = PathMappings.GetObjectIdentifier(obj);
paramName = _readableProperty.ForActiveSelf(path);
_selfProxies[obj] = paramName;
return paramName;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 61045dcdc7f24658a5b47fb0b67ab9fe
timeCreated: 1722736548

View File

@ -0,0 +1,22 @@
<UXML xmlns:ui="UnityEngine.UIElements"
xmlns:ma="nadena.dev.modular_avatar.core.editor">
<ui:VisualElement name="root-box">
<ui:VisualElement name="group-box">
<ui:VisualElement name="ListViewContainer">
<ui:ListView virtualization-method="DynamicHeight"
reorder-mode="Animated"
reorderable="true"
show-add-remove-footer="true"
show-border="true"
show-foldout-header="false"
name="Shapes"
item-height="100"
binding-path="m_objects"
style="flex-grow: 1;"
/>
</ui:VisualElement>
</ui:VisualElement>
<ma:LanguageSwitcherElement/>
</ui:VisualElement>
</UXML>

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 02f9cb4b3be34457870f111d73e2fd2f
timeCreated: 1722736548

View File

@ -0,0 +1,41 @@
#region
using System;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
#endregion
namespace nadena.dev.modular_avatar.core.editor.ShapeChanger
{
[CustomEditor(typeof(ModularAvatarObjectToggle))]
public class ObjectSwitcherEditor : MAEditorBase
{
[SerializeField] private StyleSheet uss;
[SerializeField] private VisualTreeAsset uxml;
protected override void OnInnerInspectorGUI()
{
throw new NotImplementedException();
}
protected override VisualElement CreateInnerInspectorGUI()
{
var root = uxml.CloneTree();
Localization.UI.Localize(root);
root.styleSheets.Add(uss);
root.Bind(serializedObject);
var listView = root.Q<ListView>("Shapes");
listView.showBoundCollectionSize = false;
listView.virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight;
return root;
}
}
}

View File

@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: a77f3b5f35d04831a7896261cabd3370
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences:
- uss: {fileID: 7433441132597879392, guid: b7559b81cea245b68c66602ea0cbbbcf, type: 3}
- uxml: {fileID: 9197481963319205126, guid: 02f9cb4b3be34457870f111d73e2fd2f, type: 3}
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,114 @@
VisualElement {
}
#group-box {
margin-top: 4px;
margin-bottom: 4px;
padding: 4px;
border-width: 3px;
border-left-color: rgba(0, 1, 0, 0.2);
border-top-color: rgba(0, 1, 0, 0.2);
border-right-color: rgba(0, 1, 0, 0.2);
border-bottom-color: rgba(0, 1, 0, 0.2);
border-radius: 4px;
/* background-color: rgba(0, 0, 0, 0.1); */
}
#ListViewContainer {
margin-top: 4px;
}
#group-box > Label {
-unity-font-style: bold;
}
.group-root {
margin-top: 4px;
}
.group-root Toggle {
margin-left: 0;
}
.group-children {
padding-left: 10px;
}
.left-toggle {
display: flex;
flex-direction: row;
}
.toggled-object-editor {
flex-direction: row;
justify-content: center;
align-items: center;
}
.toggled-object-editor #f-object {
flex-grow: 1;
height: 100%;
}
#f-active > Toggle {
margin-top: 0;
margin-bottom: 0;
margin-left: -12px;
margin-right: 3px;
}
.toggled-object-editor PropertyField Label {
display: none;
}
#f-change-type {
width: 75px;
}
.f-value {
width: 40px;
}
#f-value-delete {
display: none;
}
.change-type-delete #f-value {
display: none;
}
.change-type-delete #f-value-delete {
display: flex;
}
/* Add shape window */
.add-shape-popup {
margin: 2px;
}
.vline {
width: 100%;
height: 4px;
border-top-width: 4px;
margin-top: 2px;
margin-bottom: 2px;
border-top-color: rgba(0, 0, 0, 0.2);
}
.add-shape-row {
flex-direction: row;
}
.add-shape-row Button {
flex-grow: 0;
}
.add-shape-popup ScrollView Label.placeholder {
-unity-text-align: middle-center;
}
.add-shape-row Label {
flex-grow: 1;
-unity-text-align: middle-left;
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b7559b81cea245b68c66602ea0cbbbcf
timeCreated: 1722736548

View File

@ -0,0 +1,30 @@
#region
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;
#endregion
namespace nadena.dev.modular_avatar.core.editor.ShapeChanger
{
[CustomPropertyDrawer(typeof(ToggledObject))]
public class ToggledObjectEditor : PropertyDrawer
{
private const string Root = "Packages/nadena.dev.modular-avatar/Editor/Inspector/ObjectSwitcher/";
private const string UxmlPath = Root + "ToggledObjectEditor.uxml";
private const string UssPath = Root + "ObjectSwitcherStyles.uss";
public override VisualElement CreatePropertyGUI(SerializedProperty property)
{
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath).CloneTree();
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
Localization.UI.Localize(uxml);
uxml.styleSheets.Add(uss);
uxml.BindProperty(property);
return uxml;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0d18528c5f704d3daf1160d9672bd09e
timeCreated: 1722736548

View File

@ -0,0 +1,6 @@
<UXML xmlns:ui="UnityEngine.UIElements" xmlns:ed="UnityEditor.UIElements">
<ui:VisualElement class="toggled-object-editor">
<ed:PropertyField binding-path="Active" label="" name="f-active"/>
<ed:PropertyField binding-path="Object" label="" name="f-object" class="f-object"/>
</ui:VisualElement>
</UXML>

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 565803cb95a04d1f98f7050c18234cdd
timeCreated: 1722736548

View File

@ -58,7 +58,7 @@ namespace nadena.dev.modular_avatar.core.editor.plugin
seq.WithRequiredExtension(typeof(AnimationServicesContext), _s2 =>
{
seq.Run("Shape Changer", ctx => new PropertyOverlayPass(ctx).Execute())
.PreviewingWith(new ShapeChangerPreview());
.PreviewingWith(new ShapeChangerPreview(), new ObjectSwitcherPreview());
seq.Run(MergeArmaturePluginPass.Instance);
seq.Run(BoneProxyPluginPass.Instance);
seq.Run(VisibleHeadAccessoryPluginPass.Instance);

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 183ea47f9e674c2a8c098b53c98bb38d
timeCreated: 1722480750

View File

@ -0,0 +1,127 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using nadena.dev.ndmf.preview;
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
{
internal class ObjectSwitcherPreview : IRenderFilter
{
public ImmutableList<RenderGroup> GetTargetGroups(ComputeContext context)
{
var allToggles = context.GetComponentsByType<ModularAvatarObjectToggle>();
var objectGroups =
new Dictionary<GameObject, ImmutableList<(ModularAvatarObjectToggle, int)>.Builder>(
new ObjectIdentityComparer<GameObject>());
foreach (var toggle in allToggles)
{
context.Observe(toggle,
t => t.Objects.Select(o => o.Object.referencePath).ToList(),
(x, y) => x.SequenceEqual(y)
);
if (toggle.Objects == null) continue;
var index = -1;
foreach (var switched in toggle.Objects)
{
index++;
if (switched.Object == null) continue;
var target = context.Observe(toggle, _ => switched.Object.Get(toggle));
if (target == null) continue;
if (!objectGroups.TryGetValue(target, out var group))
{
group = ImmutableList.CreateBuilder<(ModularAvatarObjectToggle, int)>();
objectGroups[target] = group;
}
group.Add((toggle, index));
}
}
var affectedRenderers = objectGroups.Keys
.SelectMany(go => context.GetComponentsInChildren<Renderer>(go, true))
// If we have overlapping objects, we need to sort by child to parent, so parent configuration overrides
// the child. We do this by simply looking at how many times we observe each renderer.
.GroupBy(r => r)
.Select(g => g.Key)
.ToList();
var renderGroups = new List<RenderGroup>();
foreach (var r in affectedRenderers)
{
var switchers = new List<(ModularAvatarObjectToggle, int)>();
var obj = r.gameObject;
while (obj != null)
{
var group = objectGroups.GetValueOrDefault(obj);
if (group != null) switchers.AddRange(group);
obj = obj.transform.parent?.gameObject;
}
renderGroups.Add(RenderGroup.For(r).WithData(switchers.ToImmutableList()));
}
return renderGroups.ToImmutableList();
}
public Task<IRenderFilterNode> Instantiate(RenderGroup group, IEnumerable<(Renderer, Renderer)> proxyPairs,
ComputeContext context)
{
var data = group.GetData<ImmutableList<(ModularAvatarObjectToggle, int)>>();
return new Node(data).Refresh(proxyPairs, context, 0);
}
private class Node : IRenderFilterNode
{
public RenderAspects WhatChanged => 0;
private readonly ImmutableList<(ModularAvatarObjectToggle, int)> _controllers;
public Node(ImmutableList<(ModularAvatarObjectToggle, int)> controllers)
{
_controllers = controllers;
}
public Task<IRenderFilterNode> Refresh(IEnumerable<(Renderer, Renderer)> proxyPairs, ComputeContext context,
RenderAspects updatedAspects)
{
foreach (var controller in _controllers)
{
// Ensure we get awoken whenever there's a change in a controlling component, or its enabled state.
context.Observe(controller.Item1);
context.ActiveAndEnabled(controller.Item1);
}
return Task.FromResult<IRenderFilterNode>(this);
}
public void OnFrame(Renderer original, Renderer proxy)
{
var shouldEnable = true;
foreach (var (controller, index) in _controllers)
{
if (controller == null) continue;
if (!controller.gameObject.activeInHierarchy) continue;
if (controller.Objects == null || index >= controller.Objects.Count) continue;
var obj = controller.Objects[index];
shouldEnable = obj.Active;
}
proxy.gameObject.SetActive(shouldEnable);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9773d1a779874850830b43c4db7c3912
timeCreated: 1722480946

View File

@ -27,7 +27,10 @@ namespace nadena.dev.modular_avatar.core.editor
protected override void Execute(ndmf.BuildContext context)
{
if (context.AvatarRootObject.GetComponentInChildren<ModularAvatarShapeChanger>() != null)
var hasShapeChanger = context.AvatarRootObject.GetComponentInChildren<ModularAvatarShapeChanger>() != null;
var hasObjectSwitcher =
context.AvatarRootObject.GetComponentInChildren<ModularAvatarObjectToggle>() != null;
if (hasShapeChanger || hasObjectSwitcher)
{
var clip = new AnimationClip();
clip.name = "MA Shape Changer Defaults";
@ -200,6 +203,8 @@ namespace nadena.dev.modular_avatar.core.editor
internal void Execute()
{
Dictionary<TargetProp, PropGroup> shapes = FindShapes(context);
FindObjectToggles(shapes, context);
PreprocessShapes(shapes, out var initialStates, out var deletedShapes);
ProcessInitialStates(initialStates);
@ -222,6 +227,13 @@ namespace nadena.dev.modular_avatar.core.editor
foreach (var (key, info) in shapes.ToList())
{
if (info.actionGroups.Count == 0)
{
// never active control; ignore it entirely
shapes.Remove(key);
continue;
}
var deletions = info.actionGroups.Where(agk => agk.IsDelete).ToList();
if (deletions.Any(d => d.ControllingObject == null))
{
@ -251,6 +263,10 @@ namespace nadena.dev.modular_avatar.core.editor
private void ProcessInitialStates(Dictionary<TargetProp, float> initialStates)
{
// We need to track _two_ initial states: the initial state we'll apply at build time (which applies
// when animations are disabled) and the animation base state. Confusingly, the animation base state
// should be the state that is currently applied to the object...
var clips = context.Extension<AnimationServicesContext>().AnimationDatabase;
var initialStateHolder = clips.ClipsForPath(PropertyOverlayPrePass.TAG_PATH).FirstOrDefault();
if (initialStateHolder == null) return;
@ -261,12 +277,12 @@ namespace nadena.dev.modular_avatar.core.editor
foreach (var (key, initialState) in initialStates)
{
var curve = new AnimationCurve();
curve.AddKey(0, initialState);
curve.AddKey(1, initialState);
string path;
Type componentType;
var applied = false;
float animBaseState = 0;
if (key.TargetObject is GameObject go)
{
path = RuntimeUtil.RelativePath(context.AvatarRootObject, go);
@ -282,7 +298,13 @@ namespace nadena.dev.modular_avatar.core.editor
var blendShape = key.PropertyName.Substring("blendShape.".Length);
var index = smr.sharedMesh?.GetBlendShapeIndex(blendShape);
if (index != null && index >= 0) smr.SetBlendShapeWeight(index.Value, initialState);
if (index != null && index >= 0)
{
animBaseState = smr.GetBlendShapeWeight(index.Value);
smr.SetBlendShapeWeight(index.Value, initialState);
}
applied = true;
}
}
else
@ -290,6 +312,29 @@ namespace nadena.dev.modular_avatar.core.editor
throw new InvalidOperationException("Invalid target object: " + key.TargetObject);
}
if (!applied)
{
var serializedObject = new SerializedObject(key.TargetObject);
var prop = serializedObject.FindProperty(key.PropertyName);
if (prop != null)
switch (prop.propertyType)
{
case SerializedPropertyType.Boolean:
animBaseState = prop.boolValue ? 1 : 0;
prop.boolValue = initialState > 0.5f;
break;
case SerializedPropertyType.Float:
animBaseState = prop.floatValue;
prop.floatValue = initialState;
break;
}
}
var curve = new AnimationCurve();
curve.AddKey(0, animBaseState);
curve.AddKey(1, animBaseState);
var binding = EditorCurveBinding.FloatCurve(
path,
componentType,
@ -516,6 +561,16 @@ namespace nadena.dev.modular_avatar.core.editor
var binding = EditorCurveBinding.FloatCurve(path, componentType, key.PropertyName);
AnimationUtility.SetEditorCurve(clip, binding, curve);
if (key.TargetObject is GameObject obj && key.PropertyName == "m_IsActive")
{
var asc = context.Extension<AnimationServicesContext>();
if (asc.TryGetActiveSelfProxy(obj, out var propName))
{
binding = EditorCurveBinding.FloatCurve("", typeof(Animator), propName);
AnimationUtility.SetEditorCurve(clip, binding, curve);
}
}
return clip;
}
@ -586,6 +641,57 @@ namespace nadena.dev.modular_avatar.core.editor
return clip;
}
private void FindObjectToggles(Dictionary<TargetProp, PropGroup> objectGroups, ndmf.BuildContext context)
{
var asc = context.Extension<AnimationServicesContext>();
var toggles = this.context.AvatarRootObject.GetComponentsInChildren<ModularAvatarObjectToggle>(true);
foreach (var toggle in toggles)
{
if (toggle.Objects == null) continue;
foreach (var obj in toggle.Objects)
{
var target = obj.Object.Get(toggle);
if (target == null) continue;
// Make sure we generate an animator prop for each controlled object, as we intend to generate
// animations for them.
asc.ForceGetActiveSelfProxy(target);
var key = new TargetProp
{
TargetObject = target,
PropertyName = "m_IsActive"
};
if (!objectGroups.TryGetValue(key, out var group))
{
group = new PropGroup(key, target.activeSelf ? 1 : 0);
objectGroups[key] = group;
}
var value = obj.Active ? 1 : 0;
var action = new ActionGroupKey(asc, key, toggle.gameObject, value);
if (action.ControllingObject == null)
{
if (action.InitiallyActive)
// always active control
group.actionGroups.Clear();
else
// never active control
continue;
}
if (group.actionGroups.Count == 0)
group.actionGroups.Add(action);
else if (!group.actionGroups[^1].TryMerge(action)) group.actionGroups.Add(action);
}
}
}
private Dictionary<TargetProp, PropGroup> FindShapes(ndmf.BuildContext context)
{
var asc = context.Extension<AnimationServicesContext>();

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 57bb6d1a371c4993bf7cfed797b2eb65
timeCreated: 1717105136

View File

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace nadena.dev.modular_avatar.core
{
[Serializable]
public struct ToggledObject
{
public AvatarObjectReference Object;
public bool Active;
}
[AddComponentMenu("Modular Avatar/MA Object Toggle")]
[HelpURL("https://modular-avatar.nadena.dev/docs/reference/object-toggle?lang=auto")]
public class ModularAvatarObjectToggle : AvatarTagComponent
{
[SerializeField] private List<ToggledObject> m_objects = new();
public List<ToggledObject> Objects
{
get => m_objects;
set => m_objects = value;
}
}
}

View File

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

View File

@ -27,14 +27,17 @@ namespace ShapeChangerTests
var clip = subBt.children[0].motion as AnimationClip;
Assert.NotNull(clip);
var smr = root.transform.Find("test mesh").GetComponent<SkinnedMeshRenderer>();
var sharedMesh = smr.sharedMesh;
var bindings = AnimationUtility.GetCurveBindings(clip);
var curve = AnimationUtility.GetEditorCurve(clip, EditorCurveBinding.FloatCurve(
"test mesh",
typeof(SkinnedMeshRenderer),
"blendShape.key1"
));
Assert.AreEqual(10.0f, curve.keys[0].value, 0.1f);
Assert.AreEqual(10.0f, curve.keys[1].value, 0.1f);
Assert.AreEqual(7.0f, curve.keys[0].value, 0.1f);
Assert.AreEqual(7.0f, curve.keys[1].value, 0.1f);
curve = AnimationUtility.GetEditorCurve(clip, EditorCurveBinding.FloatCurve(
"test mesh",
@ -49,14 +52,12 @@ namespace ShapeChangerTests
typeof(SkinnedMeshRenderer),
"blendShape.key3"
));
Assert.AreEqual(100.0f, curve.keys[0].value, 0.1f);
Assert.AreEqual(100.0f, curve.keys[1].value, 0.1f);
Assert.AreEqual(6.0f, curve.keys[0].value, 0.1f);
Assert.AreEqual(6.0f, curve.keys[1].value, 0.1f);
// Check actual blendshape states
var smr = root.transform.Find("test mesh").GetComponent<SkinnedMeshRenderer>();
var sharedMesh = smr.sharedMesh;
Assert.AreEqual(10.0f, smr.GetBlendShapeWeight(sharedMesh.GetBlendShapeIndex("key1")), 0.1f);
Assert.AreEqual(0.0f, smr.GetBlendShapeWeight(sharedMesh.GetBlendShapeIndex("key2")), 0.1f);
Assert.AreEqual(5.0f, smr.GetBlendShapeWeight(sharedMesh.GetBlendShapeIndex("key2")), 0.1f);
Assert.AreEqual(100.0f, smr.GetBlendShapeWeight(sharedMesh.GetBlendShapeIndex("key3")), 0.1f);
}
}

View File

@ -600,6 +600,21 @@ PrefabInstance:
propertyPath: m_AABB.m_Extent.y
value: 1
objectReference: {fileID: 0}
- target: {fileID: -3887185075125053422, guid: cd28f61dacdc2424d951194ff69ba154,
type: 3}
propertyPath: m_BlendShapeWeights.Array.data[0]
value: 5
objectReference: {fileID: 0}
- target: {fileID: -3887185075125053422, guid: cd28f61dacdc2424d951194ff69ba154,
type: 3}
propertyPath: m_BlendShapeWeights.Array.data[1]
value: 6
objectReference: {fileID: 0}
- target: {fileID: -3887185075125053422, guid: cd28f61dacdc2424d951194ff69ba154,
type: 3}
propertyPath: m_BlendShapeWeights.Array.data[2]
value: 7
objectReference: {fileID: 0}
- target: {fileID: 919132149155446097, guid: cd28f61dacdc2424d951194ff69ba154,
type: 3}
propertyPath: m_Name

View File

@ -0,0 +1,43 @@
# Object Toggle
<!-- TODO: Screenshot -->
The Object Toggle component allows you to change the active state of one or more other GameObjects, based on the active
state of a controlling object.
## When should I use it?
This component is useful to disable one mesh when another mesh is covering it entirely. For example, you might want to
disable an underwear mesh when it's fully covered by other clothing.
## Setting up Object Toggle
Simply add an Object Toggle component to the controlling object, then click the + and select a target object to be
controlled. The checkmark controls whether the target object will be enabled or disabled.
### Conflict resolution
When multiple Object Toggles are active and try to control the same target object, the Object Toggle that is last in
hierarchy order will win. When no Object Toggles are active, the original state of the object, or animated state (if
some other animation is trying to animate that object) wins.
### Response timing
Object Toggle updates the affected objects one frame after the controlling object is updated. To avoid any unfortunate
"accidents", when an Object Toggle is disabled, the object that was disabled (either the Object Toggle itself or one of
its parents) in its parent hierarchy will be disabled one frame later than they would otherwise. This ensures that if
you use Object Toggle to hide a mesh when it's fully covered, the covering mesh will remain visible until the same frame
as when the inner mesh is enabled again.
When you use an Object Toggle to control another Object Toggle, this delay only applies to each Object Toggle
individually. That is, if you have A -> B -> C, and A is turned off, the timing will be as follows:
* Frame 1: Nothing happens (A's disable is delayed)
* Frame 2: A is disabled (B's disable is delayed)
* Frame 3: B and C are disabled at the same time.
### Preview system limitations
The effect of Object Toggles on mesh visibility is immediately reflected in the editor scene view. However, the impact
of Object Toggles on other responsive components, such as other Object Toggles or [Shape Changers](./shape-changer.md)
will not be reflected in the preview display. To see the full effect of Object Toggles, you must enter play mode.

View File

@ -0,0 +1,44 @@
# Object Toggle
<!-- TODO: Screenshot -->
Object Toggle コンポーネントは、制御オブジェクトのアクティブ状態に基づいて、他のいくつかのGameObjectのアクティブ状態を変更するコンポーネントです。
## いつ使うべきか?
このコンポーネントは、他の衣服に完全に覆われたときに、隠されたメッシュを無効にしたい場合に便利です。例えば、他の衣服に完全に覆われたときに
下着メッシュを無効にしたい場合に使えます。
## Object Toggleの設定
制御オブジェクトに Object Toggle コンポーネントを追加し、+ をクリックして制御される対象オブジェクトを選択します。
チェックマークは、対象オブジェクトが有効か無効かを制御します。
### コンフリクト解決
複数の Object Toggle がアクティブで、同じ対象オブジェクトを制御しようとする場合、階層順に最後になる Object Toggle
の設定が採用されます。
対象オブジェクトを操作する Object Toggle がすべて非アクティブ状態の場合、オブジェクトの元の状態、または(他のアニメーションがそのオブジェクト
を操作している場合)アニメーション状態が採用されます。
### 応答タイミング
Object Toggle は、制御オブジェクトが更新された後の1フレーム後に影響を受けるオブジェクトを更新します。不幸な「事故」を避けるため、
Object Toggle が無効になると、無効になったオブジェクトObject Toggle 自体か、その親など)が本来より1フレーム後に無効になります。
これにより、外側の衣装を無効化するとき、内側のメッシュが再度有効になるまで、外側のメッシュが表示され続けることが保証されます。
Object Toggle を使用して他の Object Toggle を制御する場合、この遅延は各 Object Toggle にのみ適用されます。つまり、A -> B ->
C という
構造で A がオフになった場合、タイミングは次のようになります:
* フレーム1何も起こらないAの無効化が遅延
* フレーム2Aが無効化されるBの無効化が遅延
* フレーム3BとCが同時に無効化されます。
### プレビューシステムの制限
Object Toggle がメッシュの可視性に与える影響は、エディタのシーンビューにすぐに反映されます。ただし、Object Toggle
が他の応答コンポーネント、
例えば他の Object Toggle または [Shape Changers](./shape-changer.md) に与える影響は、プレビュー表示には反映されません。
Object Toggle の完全な効果を確認するには、再生モードに入る必要があります。