Visual themes — MRTK2

Themes allow for flexible control of UX assets in response to various states transitions. This may involve changing a button's color, resizing an element in response to focus, etc. The Visual Themes framework is made up of two key pieces: 1) configuration and 2) runtime engines.

Theme configurations are definitions of properties and types while Theme Engines are classes that consume the configurations and implement the logic to update transforms, materials, and more at runtime.

Theme configuration

Theme configurations are ScriptableObjects that define how Theme Engines will be initialized at runtime. They define what properties and values to utilize in response to input or other state changes when the app is running. As ScriptableObjects assets, theme configurations can be defined once and then re-used across different UX components.

To create a new Theme asset:

  1. Right click in the Project Window
  2. Select Create > Mixed Reality Toolkit > Theme

Example Theme configuration assets can be found under MRTK/SDK/Features/UX/Interactable/Themes.

Theme ScriptableObject example in inspector

States

When creating a new Theme, the first thing to set is what states are available. The States property indicates how many values a Theme configuration needs to define as there will be one value per state. In the example image above, the default states defined for the Interactable component are Default, Focus, Pressed, and Disabled. These are defined in the DefaultInteractableStates (Assets/MRTK/SDK/Features/UX/Interactable/States) asset file.

To create a new State asset:

  1. Right click in the Project Window
  2. Select Create > Mixed Reality Toolkit > State

States ScriptableObject example in inspector

A State ScriptableObject defines both the list of states as well as the type of StateModel to create for these states. A StateModel is a class that extends BaseStateModel and implements the state machine logic to generate the current state at runtime. The current state from this class is generally used by Theme Engines at runtime to dictate what values to set against material properties, GameObject transforms, and more.

Theme engine properties

Outside of States, a Theme asset also defines a list of Theme Engines and the associated properties for these engines. A Theme engine again defines the logic to set the correct values against a GameObject at runtime.

A Theme asset can define multiple Theme Engines to achieve sophisticated visual states transitions targeting multiple GameObject properties.

Theme Runtime

Defines the class type of the Theme engine that will be created

Easing

Some Theme Engines, if they define their property IsEasingSupported as true, support easing between states. For example, lerping between two colors when a state change occurs. The Duration defines in seconds how long to ease from start value to end value and the Animation Curve defines the rate of change during that time period.

Shader properties

Some Theme Engines, if they define their property AreShadersSupported as true, will modify particular shader properties at runtime. The Shader and Property fields define the shader property to target.

Create a theme configuration via code

In general, it is easier to design Theme configurations via the Unity inspector but there are cases where Themes must be dynamically generated at runtime via code. The code snippet below gives an example of how to accomplish this task.

To help expedite development, the following helper methods are useful for simplifying setup.

Interactable.GetDefaultInteractableStates() - creates a new States ScriptableObject with the four default state values used in the Interactable component.

ThemeDefinition.GetDefaultThemeDefinition<T>() - Every Theme Engine defines a default configuration with the correct properties needed for that Theme runtime type. This helper creates a definition for the given Theme Engine type.

// This code example builds a Theme ScriptableObject that can be used with an Interactable component.
// A random color is selected for the on pressed state every time this code is executed.

// Use the default states utilized in the Interactable component
var defaultStates = Interactable.GetDefaultInteractableStates();

// Get the default configuration for the Theme engine InteractableColorTheme
var newThemeType = ThemeDefinition.GetDefaultThemeDefinition<InteractableColorTheme>().Value;

// Define a color for every state in our Default Interactable States
newThemeType.StateProperties[0].Values = new List<ThemePropertyValue>()
{
    new ThemePropertyValue() { Color = Color.black},  // Default
    new ThemePropertyValue() { Color = Color.black}, // Focus
    new ThemePropertyValue() { Color = Random.ColorHSV()},   // Pressed
    new ThemePropertyValue() { Color = Color.black},   // Disabled
};

// Create the Theme configuration asset
Theme testTheme = ScriptableObject.CreateInstance<Theme>();
testTheme.States = defaultStates;
testTheme.Definitions = new List<ThemeDefinition>() { newThemeType };

Theme engines

A Theme Engine is a class that extends from the InteractableThemeBase class. These classes are instantiated at runtime and configured with a ThemeDefinition object as outlined earlier.

Default theme engines

MRTK ships with a default set of Theme Engines listed below:

The default Theme Engines can be found under MRTK/SDK/Features/UX/Scripts/VisualThemes/ThemeEngines.

Custom theme engines

As stated, a Theme Engine is defined as a class that extends from the InteractableThemeBase class. Thus, new Theme Engine need only extend this class and implement the following:

Mandatory implementations

public abstract void SetValue(ThemeStateProperty property, int index, float percentage)

For the given property, which can be identified by ThemeStateProperty.Name, set its current state value on the targeted GameObject host (i.e set the material color, etc). The index indicates the current state value to access and the percentage, a float between 0 and 1, is used for easing/lerping between values.

public abstract ThemePropertyValue GetProperty(ThemeStateProperty property)

For the given property, which can be identified by ThemeStateProperty.Name, return the current value set on the targeted Host GameObject (i.e the current material color, the current local position offset, etc). This is primarily used for caching the start value when easing between states.

public abstract ThemeDefinition GetDefaultThemeDefinition()

Returns a ThemeDefinition object that defines the default properties and configuration needed for the custom theme

protected abstract void SetValue(ThemeStateProperty property, ThemePropertyValue value)

Protected variant of the public SetValue() definition, except provided ThemePropertyValue to set instead of directing to use index and/or percentage configuration.

InteractableThemeBase.Init(GameObject host, ThemeDefinition settings)

Perform any initialization steps here targeting the provided GameObject parameter and using the properties and configurations defined in the ThemeDefinition parameter. It is recommended to call base.Init(host, settings) at the beginning of an override.

InteractableThemeBase.IsEasingSupported

If the custom Theme Engine can support easing between values which is configured via the ThemeDefinition.Easing property.

InteractableThemeBase.AreShadersSupported

If the custom Theme Engine can support targeting shader properties. It is recommended to extend from InteractableShaderTheme to benefit from the existing infrastructure to efficiently set/get shader properties via MaterialPropertyBlocks. The shader property information is stored in each ThemeStateProperty via ThemeStateProperty.TargetShader and ThemeStateProperty.ShaderPropertyName.

Note

If extending InteractableShaderTheme, it can also be useful to override the InteractableShaderTheme.DefaultShaderProperty via new.

Example code: protected new const string DefaultShaderProperty = "_Color";

Furthermore, the following classes below extend the InteractableShaderTheme class which again uses MaterialPropertyBlocks to modify shader property values. This approach helps performance because MaterialPropertyBlocks do not create new instanced materials when values change. However, accessing the typical Material class properties will not return expected values. Use MaterialPropertyBlocks to get and validate current material property values (i.e _Color or _MainTex).

InteractableThemeBase.Reset

Directs the theme to reset any modified properties back to their original values that were set on the host GameObject when this theme engine was initialized.

Custom theme engine example

The class below is an example of a custom new Theme Engine. This implementation will find a MeshRenderer component on the initialized host object and control its visibility based on the current state.

using Microsoft.MixedReality.Toolkit.UI;
using System;
using System.Collections.Generic;
using UnityEngine;

// This class demonstrates a custom theme to control a Host's MeshRenderer visibility
public class MeshVisibilityTheme : InteractableThemeBase
{
    // Bool visibility does not make sense for lerping
    public override bool IsEasingSupported => false;

    // No material or shaders are being modified
    public override bool AreShadersSupported => false;

    // Cache reference to the MeshRenderer component on our Host
    private MeshRenderer meshRenderer;

    public MeshVisibilityTheme()
    {
        Types = new Type[] { typeof(MeshRenderer) };
        Name = "Mesh Visibility Theme";
    }

    // Define a default configuration to simplify initialization of this theme engine
    // There is only one state property with a value per available state
    // This state property is a boolean that defines whether the renderer is enabled
    public override ThemeDefinition GetDefaultThemeDefinition()
    {
        return new ThemeDefinition()
        {
            ThemeType = GetType(),
            StateProperties = new List<ThemeStateProperty>()
            {
                new ThemeStateProperty()
                {
                    Name = "Mesh Visible",
                    Type = ThemePropertyTypes.Bool,
                    Values = new List<ThemePropertyValue>(),
                    Default = new ThemePropertyValue() { Bool = true }
                },
            },
            CustomProperties = new List<ThemeProperty>()
        };
    }

    // When initializing, cache a reference to the MeshRenderer component
    public override void Init(GameObject host, ThemeDefinition definition)
    {
        base.Init(host, definition);

        meshRenderer = host.GetComponent<MeshRenderer>();
    }

    // Get the current state of the MeshRenderer visibility
    public override ThemePropertyValue GetProperty(ThemeStateProperty property)
    {
        return new ThemePropertyValue()
        {
            Bool = meshRenderer.enabled
        };
    }

    // Update the MeshRenderer visibility based on the property state value data
    public override void SetValue(ThemeStateProperty property, int index, float percentage)
    {
        meshRenderer.enabled = property.Values[index].Bool;
    }
}

End-to-end example

Extending off of the custom Theme Engine defined in the earlier section, the code example below demonstrates how to control this theme at runtime. In particular, how to set the current state on the theme so the MeshRenderer visibility is updated appropriately.

Note

theme.OnUpdate(state,force) should generally be called in the Update() method to support Theme Engines that utilize easing/lerping between values.

using Microsoft.MixedReality.Toolkit.UI;
using System;
using System.Collections.Generic;
using UnityEngine;

public class MeshVisibilityController : MonoBehaviour
{
    private MeshVisibilityTheme themeEngine;
    private bool hideMesh = false;

    private void Start()
    {
        // Define the default configuration. State 0 will be on while State 1 will be off
        var themeDefinition = ThemeDefinition.GetDefaultThemeDefinition<MeshVisibilityTheme>().Value;
        themeDefinition.StateProperties[0].Values = new List<ThemePropertyValue>()
        {
            new ThemePropertyValue() { Bool = true }, // show state
            new ThemePropertyValue() { Bool = false }, // hide state
        };

        // Create the actual Theme engine and initialize it with the GameObject we are attached to
        themeEngine = (MeshVisibilityTheme)InteractableThemeBase.CreateAndInitTheme(themeDefinition, this.gameObject);
    }

    private void Update()
    {
        // Update the theme engine to set our MeshRenderer visibility
        // based on our current state (i.e the hideMesh variable)
        themeEngine.OnUpdate(Convert.ToInt32(hideMesh));
    }

    public void ToggleVisibility()
    {
        // Alternate state of visibility
        hideMesh = !hideMesh;
    }
}

See also