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 adapt it for 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 adapt it for 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
{
// Custom Unity Asset Tutorial Controller
// Demonstrates how to safely bind to a loaded CUA without timer hacks.
// Waits until the hierarchy is stable, then binds to named target nodes.
public class CustomAssetShapeController : MVRScript
{
// ================= TUTORIAL SETUP =================
// Change these to match your own prefab.
// Leave OptionalExpectedAssetRootName empty to accept any CUA.
private const string PluginTitle = "Custom Asset Shape Controller";
private const string RequiredAtomType = "CustomUnityAsset";
private const string RescaleObjectName = "rescaleObject";
private const string OptionalExpectedAssetRootName = "";
private const string PositionTargetNodeName = "PositionTarget";
private const string RotationTargetNodeName = "RotationTarget";
private const string VisibilityTargetNodeName = "VisibilityTarget";
// Large CUAs can take several frames to fully instantiate.
// Waiting for a few stable frames prevents binding to an incomplete hierarchy.
private const int RequiredStableHierarchyFrames = 5;
// ================= SAVED SETTINGS =================
// These values are saved and restored by VaM automatically.
public JSONStorableFloat targetHeightOffset;
public JSONStorableFloat targetYawRotation;
public JSONStorableFloat targetDepthOffset;
public JSONStorableBool targetIsVisible;
// ================= UI =================
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;
// ================= CUA STATE =================
private CustomUnityAssetLoader assetLoader;
private Transform cachedRescaleRoot;
private Coroutine assetBindingRoutine;
private Transform loadedAssetRoot;
private Transform positionTarget;
private Transform rotationTarget;
private Transform visibilityTarget;
private Transform lastObservedAssetRoot;
private int lastObservedChildCount;
private int stableFrameCount;
private bool loaderCallbacksRegistered;
private bool assetIsBound;
public override void Init()
{
try
{
CreateSavedSettings();
ShowHeader();
}
catch (Exception e) { SuperController.LogError($"{PluginTitle} - Init failed: {e}"); }
}
private void Start()
{
try
{
if (!ValidateHostAtom()) return;
if (!TryConnectAssetLoader()) return;
StartAssetBindingRoutine();
}
catch (Exception e) { SuperController.LogError($"{PluginTitle} - Start failed: {e}"); }
}
private void OnDestroy()
{
StopAssetBindingRoutine();
DisconnectAssetLoader();
RemoveControls();
ClearStatusMessage();
ClearHeader();
ClearAssetState();
}
private void CreateSavedSettings()
{
targetHeightOffset = new JSONStorableFloat("targetHeightOffset", 0f, -2f, 2f);
targetHeightOffset.setCallbackFunction += () => ApplyHeightOffset();
RegisterFloat(targetHeightOffset);
targetYawRotation = new JSONStorableFloat("targetYawRotation", 0f, -180f, 180f);
targetYawRotation.setCallbackFunction += () => ApplyYawRotation();
RegisterFloat(targetYawRotation);
targetDepthOffset = new JSONStorableFloat("targetDepthOffset", 0f, -2f, 2f);
targetDepthOffset.setCallbackFunction += () => ApplyDepthOffset();
RegisterFloat(targetDepthOffset);
targetIsVisible = new JSONStorableBool("targetIsVisible", true);
targetIsVisible.setCallbackFunction += () => ApplyVisibility();
RegisterBool(targetIsVisible);
}
private bool ValidateHostAtom()
{
if (containingAtom == null) return false;
if (containingAtom.type != RequiredAtomType)
{
ShowStatusMessage("Please place this plugin on a CustomUnityAsset atom.", true, 110, "#d93232");
return false;
}
if (containingAtom.reParentObject == null) return false;
return true;
}
private bool TryConnectAssetLoader()
{
cachedRescaleRoot = FindDescendantByName(containingAtom.reParentObject, RescaleObjectName);
if (cachedRescaleRoot == null) return false;
assetLoader = cachedRescaleRoot.GetComponent<CustomUnityAssetLoader>();
if (assetLoader == null) return false;
assetLoader.RegisterAssetLoadedCallback(OnAssetLoaded);
assetLoader.RegisterAssetClearedCallback(OnAssetCleared);
loaderCallbacksRegistered = true;
return true;
}
private void DisconnectAssetLoader()
{
if (!loaderCallbacksRegistered) return;
if (assetLoader != null)
{
assetLoader.DeregisterAssetLoadedCallback(OnAssetLoaded);
assetLoader.DeregisterAssetClearedCallback(OnAssetCleared);
}
loaderCallbacksRegistered = false;
assetLoader = null;
cachedRescaleRoot = null;
}
private void StartAssetBindingRoutine()
{
StopAssetBindingRoutine();
assetBindingRoutine = StartCoroutine(BindWhenAssetHierarchyIsStable());
}
private void StopAssetBindingRoutine()
{
if (assetBindingRoutine == null) return;
StopCoroutine(assetBindingRoutine);
assetBindingRoutine = null;
}
private IEnumerator BindWhenAssetHierarchyIsStable()
{
RemoveControls();
ClearAssetState();
ResetTracker();
ShowStatusMessage("Waiting for a loaded CustomUnityAsset...", true, 120, "#2f62cc");
while (true)
{
Transform currentRoot = null;
if (cachedRescaleRoot != null && cachedRescaleRoot.childCount > 0)
{
// When assets are switched quickly, VaM may have two children briefly.
// The last child is always the newest.
currentRoot = cachedRescaleRoot.GetChild(cachedRescaleRoot.childCount - 1);
}
if (currentRoot == null || !IsAcceptedAssetRoot(currentRoot))
{
ResetTracker();
yield return null;
continue;
}
loadedAssetRoot = currentRoot;
int childCount = currentRoot.childCount;
if (currentRoot == lastObservedAssetRoot && childCount == lastObservedChildCount)
stableFrameCount++;
else
{
lastObservedAssetRoot = currentRoot;
lastObservedChildCount = childCount;
stableFrameCount = 1;
ShowStatusMessage("Waiting for hierarchy to stabilize...", true, 110, "#2f62cc");
}
if (stableFrameCount >= RequiredStableHierarchyFrames)
{
var missing = new List<string>();
if (!TryCacheTargets(missing))
{
ShowStatusMessage("Hierarchy is stable but required nodes are missing: " + string.Join(", ", missing.ToArray()), true, 160, "#d98200");
assetBindingRoutine = null;
yield break;
}
assetIsBound = true;
ClearStatusMessage();
BuildControls();
ApplyAllSettings();
assetBindingRoutine = null;
yield break;
}
yield return null;
}
}
private void ResetTracker()
{
lastObservedAssetRoot = null;
lastObservedChildCount = -1;
stableFrameCount = 0;
}
private bool IsAcceptedAssetRoot(Transform root)
{
if (string.IsNullOrEmpty(OptionalExpectedAssetRootName)) return true;
string cleanName = root.name.Replace("(Clone)", "").Trim();
return cleanName.Equals(OptionalExpectedAssetRootName, StringComparison.OrdinalIgnoreCase);
}
private bool TryCacheTargets(List<string> missing)
{
// Recursive search allows targets to be deeply nested inside imported models.
positionTarget = FindDescendantByName(loadedAssetRoot, PositionTargetNodeName);
rotationTarget = FindDescendantByName(loadedAssetRoot, RotationTargetNodeName);
visibilityTarget = FindDescendantByName(loadedAssetRoot, VisibilityTargetNodeName);
if (positionTarget == null) missing.Add(PositionTargetNodeName);
if (rotationTarget == null) missing.Add(RotationTargetNodeName);
if (visibilityTarget == null) missing.Add(VisibilityTargetNodeName);
return missing.Count == 0;
}
private void BuildControls()
{
RemoveControls();
var h = CreateSlider(targetHeightOffset, false); h.label = "Target height offset"; createdSliders.Add(h);
var y = CreateSlider(targetYawRotation, false); y.label = "Target yaw rotation"; createdSliders.Add(y);
var d = CreateSlider(targetDepthOffset, false); d.label = "Target depth offset"; createdSliders.Add(d);
var v = CreateToggle(targetIsVisible, false); v.label = "Target visible"; createdToggles.Add(v);
}
private void RemoveControls()
{
foreach (var s in createdSliders) if (s != null) RemoveSlider(s);
createdSliders.Clear();
foreach (var t in createdToggles) if (t != null) RemoveToggle(t);
createdToggles.Clear();
}
private void ApplyAllSettings()
{
// Saved values may have been restored before the asset existed.
// Apply them once after binding.
ApplyHeightOffset();
ApplyDepthOffset();
ApplyYawRotation();
ApplyVisibility();
}
private void ApplyHeightOffset()
{
if (!assetIsBound || positionTarget == null) return;
Vector3 p = positionTarget.localPosition;
p.y = targetHeightOffset.val;
positionTarget.localPosition = p;
}
private void ApplyDepthOffset()
{
if (!assetIsBound || positionTarget == null) return;
Vector3 p = positionTarget.localPosition;
p.z = targetDepthOffset.val;
positionTarget.localPosition = p;
}
private void ApplyYawRotation()
{
if (!assetIsBound || rotationTarget == null) return;
rotationTarget.localRotation = Quaternion.Euler(0f, targetYawRotation.val, 0f);
}
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;
ResetTracker();
}
private void OnAssetLoaded() { try { StartAssetBindingRoutine(); } catch (Exception e) { SuperController.LogError(e.ToString()); } }
private void OnAssetCleared()
{
StopAssetBindingRoutine();
ClearAssetState();
RemoveControls();
ShowStatusMessage("No CustomUnityAsset loaded. Select an asset in the Asset tab.", true, 120, "#2f62cc");
}
private Transform FindDescendantByName(Transform parent, string childName)
{
if (parent == null || string.IsNullOrEmpty(childName)) return null;
foreach (Transform child in parent)
{
if (child.name.Equals(childName, StringComparison.OrdinalIgnoreCase)) return child;
Transform found = FindDescendantByName(child, childName);
if (found != null) return found;
}
return null;
}
private void ShowHeader()
{
ClearHeader();
headerTextField = CreateInfoText("header",
$"<color=#000><size=34><b>{PluginTitle}</b></size>\n<size=24>\nReusable tutorial pattern for controlling CustomUnityAsset transforms. Controls appear only after a compatible asset is bound.</size></color>",
false, 140, TextAnchor.MiddleLeft);
}
private void ClearHeader() { if (headerTextField != null) RemoveTextField(headerTextField); headerTextField = null; }
private void ShowStatusMessage(string msg, bool rightSide, int height, string color)
{
string key = msg + rightSide + height;
if (statusTextField != null && statusMessageKey == key) return;
ClearStatusMessage();
statusMessageKey = key;
statusTextField = CreateInfoText("status", $"<b><color={color}><size=28>{msg}</size></color></b>", rightSide, height, TextAnchor.MiddleLeft);
}
private void ClearStatusMessage() { if (statusTextField != null) RemoveTextField(statusTextField); statusTextField = null; statusMessageKey = null; }
private UIDynamicTextField CreateInfoText(string id, string text, bool rightSide, int height, TextAnchor alignment)
{
var storable = new JSONStorableString(id, text) { isStorable = false, isRestorable = false, hidden = true };
var field = CreateTextField(storable, rightSide);
field.backgroundColor = new Color(1f, 1f, 1f, 0f);
field.UItext.alignment = alignment;
field.height = height;
var layout = field.GetComponent<LayoutElement>();
if (layout != null) { layout.preferredHeight = height; layout.minHeight = height; }
var scroll = field.GetComponentInParent<ScrollRect>();
if (scroll != null) { scroll.horizontal = false; scroll.vertical = false; }
return field;
}
}
}