Credits And Reuse
This tutorial text and the included script structure are written as a separate, generic example.
The goal is to provide a clean learning resource that is not tied to another tutorial's exact structure, wording, demo asset, or attribution expectations.
Practical attribution guideline:
Credit people when you include their actual code, text, assets, packaged examples, or other files in your release.
Important:
If you only use this file and your own asset, you do not need to add credits for unrelated tutorial packages that are not copied into your release.
A voluntary mention of learning resources is always friendly, especially when something helped you understand a problem faster. But learning a general technique is different from redistributing someone else's licensed work.
Custom Asset Shape Controller Tutorial
This short guide explains how to use
The script is intentionally generic. It does not require a specific demo asset. You only need to rename a few constants so they match the transform names in your own Unity prefab.
Before You Start
This guide focuses only on the VaM plugin side: finding a loaded CustomUnityAsset, waiting until its hierarchy is ready, and controlling named transforms from script UI.
It does not explain how to create Unity prefabs or build VaM asset bundles. If you still need that part, start with a dedicated asset-bundle guide first:
Once your asset can be loaded in a CustomUnityAsset atom, this tutorial shows how to control parts of it with a script.
1. Prepare Your Unity Asset
Create your Unity prefab as usual and export it as a VaM-compatible CustomUnityAsset.
Inside the prefab, choose the transforms that the script should control. They can be deeply nested; the script searches recursively through the whole loaded hierarchy.
For the default tutorial script, create or rename three transforms:
These names are only placeholders. You can use names like
2. Edit The Script Constants
Open
Change these constants for your asset:
Use
For large assets, there is no fixed waiting value to tune. The script waits for the selected asset hierarchy to stop changing before it searches for the configured target transforms.
3. Load It In VaM
If the asset is ready and the target transforms are found, the sliders and toggle appear. If something is missing, the plugin shows a status message instead of throwing errors.
4. How The Loading Works
The script does not use
When VaM reports that a CUA was loaded, the script starts a coroutine. That coroutine waits until:
Only after the hierarchy is structurally stable does the script search for the configured target transforms. If the hierarchy is stable but a target is missing, the script shows the missing transform names immediately. That usually means the constant names do not match the prefab, or the desired node lives in a different part of the hierarchy than expected.
This is important for big assets. A load callback can happen before every nested transform is safely available. Waiting for the hierarchy to stop changing makes the script much more reliable without relying on arbitrary delay values.
5. How To Extend It
The default controls are only examples.
To add your own control:
Keep each setting small and focused. It makes the script easier to read and easier to debug.
6. Troubleshooting
If no controls appear, check the status message first.
Common causes:
If the controls appear but nothing moves, confirm that the transform you named is the transform you actually want to manipulate. Imported Unity assets often contain many empty parent transforms, pivots, and model children.
This tutorial text and the included script structure are written as a separate, generic example.
The goal is to provide a clean learning resource that is not tied to another tutorial's exact structure, wording, demo asset, or attribution expectations.
Practical attribution guideline:
Credit people when you include their actual code, text, assets, packaged examples, or other files in your release.
Important:
If you only use this file and your own asset, you do not need to add credits for unrelated tutorial packages that are not copied into your release.
A voluntary mention of learning resources is always friendly, especially when something helped you understand a problem faster. But learning a general technique is different from redistributing someone else's licensed work.
Custom Asset Shape Controller Tutorial
This short guide explains how to use
CustomAssetShapeController.cs as a reusable VaM tutorial template for controlling transforms inside a CustomUnityAsset atom.The script is intentionally generic. It does not require a specific demo asset. You only need to rename a few constants so they match the transform names in your own Unity prefab.
Before You Start
This guide focuses only on the VaM plugin side: finding a loaded CustomUnityAsset, waiting until its hierarchy is ready, and controlling named transforms from script UI.
It does not explain how to create Unity prefabs or build VaM asset bundles. If you still need that part, start with a dedicated asset-bundle guide first:
Once your asset can be loaded in a CustomUnityAsset atom, this tutorial shows how to control parts of it with a script.
1. Prepare Your Unity Asset
Create your Unity prefab as usual and export it as a VaM-compatible CustomUnityAsset.
Inside the prefab, choose the transforms that the script should control. They can be deeply nested; the script searches recursively through the whole loaded hierarchy.
For the default tutorial script, create or rename three transforms:
PositionTargetcontrols the local Y and Z position values.RotationTargetcontrols the local Y-axis rotation.VisibilityTargetis enabled or disabled by the toggle.
These names are only placeholders. You can use names like
Door_Left, LightPivot, ControlHandle, or anything else that fits your asset. The important part is that the names in Unity and the names in the script match exactly enough to be found.2. Edit The Script Constants
Open
CustomAssetShapeController.cs and look near the top of the file.Change these constants for your asset:
C#:
private const string OptionalExpectedAssetRootName = "";
private const string PositionTargetNodeName = "PositionTarget";
private const string RotationTargetNodeName = "RotationTarget";
private const string VisibilityTargetNodeName = "VisibilityTarget";
Use
OptionalExpectedAssetRootName only when the plugin should reject every asset except one specific prefab root. Leave it empty when the plugin should work with any CUA that contains the required target transforms.For large assets, there is no fixed waiting value to tune. The script waits for the selected asset hierarchy to stop changing before it searches for the configured target transforms.
3. Load It In VaM
- Copy or keep
CustomAssetShapeController.csinCustom/Scripts/.... - Add a
CustomUnityAssetatom to your scene. - Load your Unity asset into that atom.
- Add this script as a plugin on the same
CustomUnityAssetatom. - Open the plugin UI.
If the asset is ready and the target transforms are found, the sliders and toggle appear. If something is missing, the plugin shows a status message instead of throwing errors.
4. How The Loading Works
The script does not use
Update() or blind timer guesses.When VaM reports that a CUA was loaded, the script starts a coroutine. That coroutine waits until:
- the atom has a
rescaleObject, - a loaded asset root exists under it,
- the same asset root is still selected,
- the recursive child count stops changing for two consecutive frames.
Only after the hierarchy is structurally stable does the script search for the configured target transforms. If the hierarchy is stable but a target is missing, the script shows the missing transform names immediately. That usually means the constant names do not match the prefab, or the desired node lives in a different part of the hierarchy than expected.
This is important for big assets. A load callback can happen before every nested transform is safely available. Waiting for the hierarchy to stop changing makes the script much more reliable without relying on arbitrary delay values.
5. How To Extend It
The default controls are only examples.
To add your own control:
- Add a new
JSONStorableFloat,JSONStorableBool, or another storable field. - Register it in
CreateSavedSettings(). - Create its UI element in
BuildControls(). - Cache the transform or component you need in
TryCacheTutorialTargets(). - Apply the value in a small method like
ApplyYawRotation(). - Call that method from
ApplyAllSettings().
Keep each setting small and focused. It makes the script easier to read and easier to debug.
6. Troubleshooting
If no controls appear, check the status message first.
Common causes:
- The script is not attached to a
CustomUnityAssetatom. - The asset is not loaded yet.
- One of the configured transform names does not exist in the prefab.
- The name differs by spelling, spaces, or punctuation.
- The hierarchy is stable, but your configured target names do not match the prefab.
If the controls appear but nothing moves, confirm that the transform you named is the transform you actually want to manipulate. Imported Unity assets often contain many empty parent transforms, pivots, and model children.
C#:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace CustomUnityAssetTutorial
{
public class CustomAssetShapeController : MVRScript
{
private const string PluginTitle = "Custom Asset Shape Controller";
private const string RequiredAtomType = "CustomUnityAsset";
private const string RescaleObjectName = "rescaleObject";
/*
* Tutorial setup values
*
* Change these names to match your own Unity asset hierarchy.
* Leave OptionalExpectedAssetRootName empty when this script should accept any CUA.
* The target node names are found recursively, so they can be deeply nested.
*/
private const string OptionalExpectedAssetRootName = "";
private const string PositionTargetNodeName = "PositionTarget";
private const string RotationTargetNodeName = "RotationTarget";
private const string VisibilityTargetNodeName = "VisibilityTarget";
private const int RequiredStableHierarchyFrames = 2;
public JSONStorableFloat targetHeightOffset;
public JSONStorableFloat targetYawRotation;
public JSONStorableFloat targetDepthOffset;
public JSONStorableBool targetIsVisible;
private readonly List<UIDynamicSlider> createdSliders = new List<UIDynamicSlider>();
private readonly List<UIDynamicToggle> createdToggles = new List<UIDynamicToggle>();
private UIDynamicTextField headerTextField;
private UIDynamicTextField statusTextField;
private string statusMessageKey;
private CustomUnityAssetLoader assetLoader;
private Coroutine assetBindingRoutine;
private Transform loadedAssetRoot;
private Transform positionTarget;
private Transform rotationTarget;
private Transform visibilityTarget;
private Transform lastObservedAssetRoot;
private int lastObservedDescendantCount;
private int stableFrameCount;
private bool loaderCallbacksRegistered;
private bool assetIsBound;
public override void Init()
{
try
{
/*
* Init is the right place to create JSONStorables.
* VaM saves and restores these values with the scene.
* The actual asset may not exist yet, so callbacks must be safe when no target is bound.
*/
CreateSavedSettings();
ShowHeader();
}
catch (Exception exception)
{
LogException("Init failed", exception);
}
}
private void Start()
{
try
{
/*
* Start runs after VaM has attached the plugin to an atom.
* At this point we can check whether the host atom is a CustomUnityAsset atom
* and then listen for asset load/unload events.
*/
if (!ValidateHostAtom())
{
return;
}
if (!TryConnectAssetLoader())
{
return;
}
StartAssetBindingRoutine();
}
catch (Exception exception)
{
LogException("Start failed", exception);
}
}
private void OnDestroy()
{
/*
* Always unregister callbacks and remove dynamic UI.
* VaM can destroy plugin instances while a scene is unloading, so every cleanup step is null-safe.
*/
StopAssetBindingRoutine();
DisconnectAssetLoader();
RemoveControls();
ClearStatusMessage();
ClearHeader();
ClearAssetState();
}
private void CreateSavedSettings()
{
targetHeightOffset = new JSONStorableFloat("targetHeightOffset", 0f, -2f, 2f);
targetHeightOffset.setCallbackFunction += delegate { ApplyHeightOffset(); };
RegisterFloat(targetHeightOffset);
targetYawRotation = new JSONStorableFloat("targetYawRotation", 0f, -180f, 180f);
targetYawRotation.setCallbackFunction += delegate { ApplyYawRotation(); };
RegisterFloat(targetYawRotation);
targetDepthOffset = new JSONStorableFloat("targetDepthOffset", 0f, -2f, 2f);
targetDepthOffset.setCallbackFunction += delegate { ApplyDepthOffset(); };
RegisterFloat(targetDepthOffset);
targetIsVisible = new JSONStorableBool("targetIsVisible", true);
targetIsVisible.setCallbackFunction += delegate { ApplyVisibility(); };
RegisterBool(targetIsVisible);
}
private bool ValidateHostAtom()
{
if (containingAtom == null)
{
ShowStatusMessage("This plugin is not attached to an atom yet.", true, 100, "#d93232");
return false;
}
if (containingAtom.type != RequiredAtomType)
{
ShowStatusMessage("Place this plugin on a CustomUnityAsset atom.", true, 100, "#d93232");
return false;
}
if (containingAtom.reParentObject == null)
{
ShowStatusMessage("The CustomUnityAsset atom is missing its reParentObject.", true, 110, "#d93232");
return false;
}
return true;
}
private bool TryConnectAssetLoader()
{
Transform rescaleRoot = FindDescendantByName(containingAtom.reParentObject, RescaleObjectName);
if (rescaleRoot == null)
{
ShowStatusMessage("Could not find the CUA rescale object.", true, 100, "#d93232");
return false;
}
assetLoader = rescaleRoot.GetComponent<CustomUnityAssetLoader>();
if (assetLoader == null)
{
ShowStatusMessage("Could not find the CustomUnityAssetLoader component.", true, 110, "#d93232");
return false;
}
assetLoader.RegisterAssetLoadedCallback(OnAssetLoaded);
assetLoader.RegisterAssetClearedCallback(OnAssetCleared);
loaderCallbacksRegistered = true;
return true;
}
private void DisconnectAssetLoader()
{
if (!loaderCallbacksRegistered || assetLoader == null)
{
return;
}
assetLoader.DeregisterAssetLoadedCallback(OnAssetLoaded);
assetLoader.DeregisterAssetClearedCallback(OnAssetCleared);
loaderCallbacksRegistered = false;
assetLoader = null;
}
private void StartAssetBindingRoutine()
{
/*
* Large Unity assets can need a few frames before the full hierarchy is available.
* The loader callback is the start signal; after that we wait frame-by-frame
* until the selected asset root stops changing structurally.
*/
StopAssetBindingRoutine();
assetBindingRoutine = StartCoroutine(BindWhenAssetHierarchyIsStable());
}
private void StopAssetBindingRoutine()
{
if (assetBindingRoutine == null)
{
return;
}
StopCoroutine(assetBindingRoutine);
assetBindingRoutine = null;
}
private IEnumerator BindWhenAssetHierarchyIsStable()
{
RemoveControls();
ClearAssetState();
ResetHierarchyStabilityTracker();
ShowStatusMessage("Waiting for a loaded CustomUnityAsset root...", true, 110, "#2f62cc");
while (true)
{
Transform currentRoot = FindCurrentAssetRoot();
if (currentRoot == null || !IsAcceptedAssetRoot(currentRoot))
{
ResetHierarchyStabilityTracker();
ShowStatusMessage("Waiting for a loaded CustomUnityAsset root...", true, 110, "#2f62cc");
yield return null;
continue;
}
loadedAssetRoot = currentRoot;
int currentDescendantCount = CountDescendants(currentRoot);
if (currentRoot == lastObservedAssetRoot && currentDescendantCount == lastObservedDescendantCount)
{
stableFrameCount++;
}
else
{
lastObservedAssetRoot = currentRoot;
lastObservedDescendantCount = currentDescendantCount;
stableFrameCount = 1;
ShowStatusMessage("Waiting for the CustomUnityAsset hierarchy to stabilize...", true, 110, "#2f62cc");
}
if (stableFrameCount >= RequiredStableHierarchyFrames)
{
List<string> missingTargets = new List<string>();
if (!TryCacheTutorialTargets(missingTargets))
{
ClearAssetState();
RemoveControls();
ShowStatusMessage("The asset hierarchy is stable, but target nodes are missing: " + string.Join(", ", missingTargets.ToArray()) + ".", true, 150, "#d98200");
assetBindingRoutine = null;
yield break;
}
assetIsBound = true;
ClearStatusMessage();
BuildControls();
ApplyAllSettings();
assetBindingRoutine = null;
yield break;
}
yield return null;
}
}
private void ResetHierarchyStabilityTracker()
{
lastObservedAssetRoot = null;
lastObservedDescendantCount = -1;
stableFrameCount = 0;
}
private Transform FindCurrentAssetRoot()
{
/*
* VaM places the loaded Unity prefab below the atom's rescaleObject.
* When the user switches assets quickly, more than one child can briefly exist.
* The newest child is the one at the end.
*/
if (containingAtom == null || containingAtom.reParentObject == null)
{
return null;
}
Transform rescaleRoot = FindDescendantByName(containingAtom.reParentObject, RescaleObjectName);
if (rescaleRoot == null || rescaleRoot.childCount == 0)
{
return null;
}
return rescaleRoot.GetChild(rescaleRoot.childCount - 1);
}
private bool IsAcceptedAssetRoot(Transform assetRoot)
{
if (assetRoot == null)
{
return false;
}
if (string.IsNullOrEmpty(OptionalExpectedAssetRootName))
{
return true;
}
string cleanRootName = assetRoot.name.Replace("(Clone)", "").Trim();
return string.Equals(cleanRootName, OptionalExpectedAssetRootName, StringComparison.OrdinalIgnoreCase);
}
private bool TryCacheTutorialTargets(List<string> missingTargets)
{
/*
* These recursive searches are the main tutorial pattern.
* Your asset can have folders, pivots, nested prefabs, or imported model hierarchy.
* The script only needs stable names for the nodes it wants to control.
*/
positionTarget = FindDescendantByName(loadedAssetRoot, PositionTargetNodeName);
rotationTarget = FindDescendantByName(loadedAssetRoot, RotationTargetNodeName);
visibilityTarget = FindDescendantByName(loadedAssetRoot, VisibilityTargetNodeName);
if (positionTarget == null)
{
missingTargets.Add(PositionTargetNodeName);
}
if (rotationTarget == null)
{
missingTargets.Add(RotationTargetNodeName);
}
if (visibilityTarget == null)
{
missingTargets.Add(VisibilityTargetNodeName);
}
return missingTargets.Count == 0;
}
private void BuildControls()
{
/*
* UI controls are created only after a compatible asset is bound.
* This prevents users from moving sliders that cannot affect anything yet.
*/
RemoveControls();
UIDynamicSlider heightSlider = CreateSlider(targetHeightOffset, false);
heightSlider.label = "Target height offset";
createdSliders.Add(heightSlider);
UIDynamicSlider yawSlider = CreateSlider(targetYawRotation, false);
yawSlider.label = "Target yaw rotation";
createdSliders.Add(yawSlider);
UIDynamicSlider depthSlider = CreateSlider(targetDepthOffset, false);
depthSlider.label = "Target depth offset";
createdSliders.Add(depthSlider);
UIDynamicToggle visibilityToggle = CreateToggle(targetIsVisible, false);
visibilityToggle.label = "Target visible";
createdToggles.Add(visibilityToggle);
}
private void RemoveControls()
{
for (int i = 0; i < createdSliders.Count; i++)
{
if (createdSliders[i] != null)
{
RemoveSlider(createdSliders[i]);
}
}
createdSliders.Clear();
for (int i = 0; i < createdToggles.Count; i++)
{
if (createdToggles[i] != null)
{
RemoveToggle(createdToggles[i]);
}
}
createdToggles.Clear();
}
private void ApplyAllSettings()
{
/*
* VaM may restore saved storable values before the asset exists.
* Once the hierarchy is bound, apply every saved setting once.
*/
ApplyHeightOffset();
ApplyYawRotation();
ApplyDepthOffset();
ApplyVisibility();
}
private void ApplyHeightOffset()
{
if (!assetIsBound || positionTarget == null)
{
return;
}
Vector3 position = positionTarget.localPosition;
position.y = targetHeightOffset.val;
positionTarget.localPosition = position;
}
private void ApplyYawRotation()
{
if (!assetIsBound || rotationTarget == null)
{
return;
}
rotationTarget.localRotation = Quaternion.Euler(0f, targetYawRotation.val, 0f);
}
private void ApplyDepthOffset()
{
if (!assetIsBound || positionTarget == null)
{
return;
}
Vector3 position = positionTarget.localPosition;
position.z = targetDepthOffset.val;
positionTarget.localPosition = position;
}
private void ApplyVisibility()
{
if (!assetIsBound || visibilityTarget == null)
{
return;
}
visibilityTarget.gameObject.SetActive(targetIsVisible.val);
}
private void ClearAssetState()
{
assetIsBound = false;
loadedAssetRoot = null;
positionTarget = null;
rotationTarget = null;
visibilityTarget = null;
ResetHierarchyStabilityTracker();
}
private void OnAssetLoaded()
{
try
{
StartAssetBindingRoutine();
}
catch (Exception exception)
{
LogException("Asset load callback failed", exception);
}
}
private void OnAssetCleared()
{
try
{
StopAssetBindingRoutine();
ClearAssetState();
RemoveControls();
ShowStatusMessage("No CustomUnityAsset is loaded. Select an asset first.", true, 110, "#2f62cc");
}
catch (Exception exception)
{
LogException("Asset clear callback failed", exception);
}
}
private Transform FindDescendantByName(Transform parent, string childName)
{
/*
* Recursive search is slower than direct child access, but it is safer for tutorial code.
* Real CUA files often contain imported folders and deeply nested transforms.
*/
if (parent == null || string.IsNullOrEmpty(childName))
{
return null;
}
foreach (Transform child in parent)
{
if (string.Equals(child.name, childName, StringComparison.OrdinalIgnoreCase))
{
return child;
}
Transform found = FindDescendantByName(child, childName);
if (found != null)
{
return found;
}
}
return null;
}
private int CountDescendants(Transform parent)
{
if (parent == null)
{
return 0;
}
int count = 0;
foreach (Transform child in parent)
{
count++;
count += CountDescendants(child);
}
return count;
}
private void ShowHeader()
{
ClearHeader();
headerTextField = CreateInfoText(
"customAssetTutorialHeader",
"<color=#000><size=34><b>" + PluginTitle + "</b></size>\n\n<size=24>A reusable tutorial pattern for controlling loaded CustomUnityAsset transforms.</size></color>",
false,
130,
TextAnchor.MiddleLeft
);
}
private void ClearHeader()
{
if (headerTextField == null)
{
return;
}
RemoveTextField(headerTextField);
headerTextField = null;
}
private void ShowStatusMessage(string message, bool rightSide, int height, string color)
{
string newStatusMessageKey = message + "|" + rightSide + "|" + height + "|" + color;
if (statusTextField != null && statusMessageKey == newStatusMessageKey)
{
return;
}
ClearStatusMessage();
statusMessageKey = newStatusMessageKey;
statusTextField = CreateInfoText(
"customAssetTutorialStatus",
"<b><color=" + color + "><size=28>" + message + "</size></color></b>",
rightSide,
height,
TextAnchor.MiddleLeft
);
}
private void ClearStatusMessage()
{
if (statusTextField == null)
{
return;
}
RemoveTextField(statusTextField);
statusTextField = null;
statusMessageKey = null;
}
private UIDynamicTextField CreateInfoText(string id, string text, bool rightSide, int height, TextAnchor alignment)
{
JSONStorableString textValue = new JSONStorableString(id, text);
textValue.isStorable = false;
textValue.isRestorable = false;
textValue.hidden = true;
UIDynamicTextField textField = CreateTextField(textValue, rightSide);
textField.backgroundColor = new Color(1f, 1f, 1f, 0f);
textField.UItext.alignment = alignment;
textField.height = height;
LayoutElement layout = textField.GetComponent<LayoutElement>();
if (layout != null)
{
layout.preferredHeight = height;
layout.minHeight = height;
}
DisableTextFieldScrolling(textField);
return textField;
}
private void DisableTextFieldScrolling(UIDynamicTextField textField)
{
if (textField == null || textField.UItext == null)
{
return;
}
Transform scrollRoot = textField.UItext.transform;
for (int i = 0; i < 3 && scrollRoot != null; i++)
{
scrollRoot = scrollRoot.parent;
}
if (scrollRoot == null)
{
return;
}
CanvasGroup canvasGroup = scrollRoot.gameObject.GetComponent<CanvasGroup>();
if (canvasGroup == null)
{
canvasGroup = scrollRoot.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.blocksRaycasts = false;
ScrollRect scrollRect = scrollRoot.GetComponent<ScrollRect>();
if (scrollRect != null)
{
scrollRect.horizontal = false;
scrollRect.vertical = false;
}
}
private void LogException(string context, Exception exception)
{
SuperController.LogError(PluginTitle + " - " + context + ": " + exception);
}
}
}