Unity Products:Amplify Shader Editor/API

From Amplify Creations Wiki
Jump to: navigation, search

Product Page - Included Shaders - Manual - Shader Functions - Tutorials - API - Shader Templates - Nodes - Community Nodes

API Description

Introduction

Is this section the most important aspects of ASE API are going to be described via a demonstration on how to create a simple node.

Nodes are made of three major components

  • Node Body
  • Input Ports ( Left-Side Ports )
  • Output Ports ( Right-Side Ports )

A node output result will always be a string containing shader instructions required to achieve its goal.

In this example a node will be created with two input ports and an output port. It will calculate a simplified fmod and output the resulting shader operation.

Each Shader Graph always have at least one node, the Master Node, which is responsible for generating the shader. When hitting the Update button, each one of its Input ports will be analyzed and if connected a value will be requested via its connection.

Nodes are always analyzed from Input port of a node to the output of its connected node and so on.

So, let's begin...

Inherit from Parent Node

The first step on creating your custom node is creating a new c# script, which we are going to call MyTestNode, and make its class inherit from ParentNode. A really important aspect to take into account is that both your class and its properties must be serializable in order to Unity hot code reload to work correctly and no data in your node is lost.

public NodeAttributes( string name, string category, string description , ... )
  1. Create class and inherit from Parent Node
  2. Create NodeAttributes Class Attribute and associate it with your class
    1. Fill up at least first three arguments
      1. Name: Node Name
      2. Category: Node category
      3. Description: Basic description to be shown as a tooltip

Here's a snippet on how it looks on your class

using UnityEngine;
using System;
using AmplifyShaderEditor;

[Serializable]
[NodeAttributes( "My Test Node", "My Test Category", "My Test Description")]
public class MyTestNode : ParentNode
{		
}

It is essential that your class contains Node Attributes since only these are registered into our Node Palette and made available to use.

Methods to Override

Your next step is to override at least these methods:

  • void CommonInit( int uniqueId ):
    • This is where all the Input and Output ports are created and the nodes initial setup is done
      • Always call base.CommonInit( ... )
  • GenerateShaderForOutput( int outputId, WirePortDataType inputPortType, ref MasterNodeDataCollector dataCollector, bool ignoreLocalvar )
    • This is where the node will generate its shader instructions
  • These next methods need only to be overridden if your node needs to read/write internal data into the shader
    • void ReadFromString( ref string[] nodeParams )
      • In here our special node data is read from the shader
        • Always call base.ReadFromString(...)
    • void WriteToString( ref string nodeInfo, ref string connectionsInfo )
      • In here our special node data is written into the shader
        • Always call base.WriteToString(...)

Ports

Like stated earlier, there are two types of ports, Inputs and Outputs.

  • Input ports Located at the nodes left side, they are responsible for retrieving data from other nodes necessary to its internal operations.
  • Output ports Located at the nodes right side, they are responsible for transmitting nodes results to other nodes

Both Input and Output ports must always have a specified Data Type, though you have the ability to change it whenever you want or make them adaptable to their connections. To see all available data types please refer to here.

Also important to refer that it's always an Input Port which requests new data from an Output port and not other way around.

Output Ports

A node needs at least to have one Output port so some sort of information can be generated and make its way through into the master node.
To add Output ports you can use:

  • AddOutputPort( WirePortDataType type, string name ): Add a single output port of the specified type and name
  • AddOutputVectorPorts( WirePortDataType type, string name ): Add both an output port from the selected vector type(FLOAT2...4), but also add outport ports for each of its components (X...W)
  • AddOutputColorPorts( string name ): Similar to the method above but specific for the COLOR type
Input Ports

A node may not need input ports as it can generate data via its internal properties. Each input port also contains its own internal data so you can have the node generating results even if not all, or none, of the input ports are connected. However this might not be the behavior every node creator wants so, in order for you to have internal input port data being used you need to set the m_useInternalPortData flag to true on your CommonInit(...) method since its default value is false.

Adding input ports can only be done via:
AddInputPort( WirePortDataType type, bool typeLocked, string name, int orderId = -1, MasterNodePortCategory category = MasterNodePortCategory.Fragment )

  • WirePortDataType type: Data Type to be used on Port
  • bool typeLocked: If set to true, only Output ports from that specific type will be able to establish connection
  • These next parameters for now are only used by the Master Node so you can leave them on their default values
    • int orderId: Ports, by default[-1], are analyzed by their creation order, unless a different order Id is specified
    • MasterNodePortCategory category: Specifies to which area this input will generate the shader code. Most ports are set to Fragment except Local Vertex Offset ( Vertex ), Tessellation and Debug ( Debug ).

Getting back to our node, we want to add two input ports and a single output port.
Here's how it looks like:

using UnityEngine;
using System;
using AmplifyShaderEditor;
[NodeAttributes( "My Test Node", "My Test Category", "My Test Description")]
[Serializable]
public class MyTestNode : ParentNode
{
	protected override void CommonInit( int uniqueId )
	{
		base.CommonInit( uniqueId );
		AddInputPort( WirePortDataType.FLOAT, false, "X");
		AddInputPort( WirePortDataType.FLOAT, false, "Y");
		AddOutputPort( WirePortDataType.FLOAT, "Out");
	}	
}
Automatically adapting ports to connections

In certain nodes you may want for your ports to adapt to the outputs connected to them.

For example, on a Multiply node each input port must adapt to what is connected to it. Also, its output is dependent on the input types.

For this to correctly happen, two situations need to be taken into account by overriding two specific methods.

  • void OnInputPortConnected( int portId, int otherNodeId, int otherPortId, bool activateNode = true ): New connection is established with input port
  • void OnConnectedOutputNodeChanges( int inputPortId, int otherNodeId, int otherPortId, string name, WirePortDataType type ): Node connected to ours inputs changes its output type on an already established connection

On a typical situation you will need to override both these methods and call MatchPortConnection() method on the modified port to automatically adapt it to the newly connected type.

You can also use thise next method for both Input and Output ports to force a specific type to your port.

  • ChangeType( WirePortDataType newType, bool invalidateConnections )

Now let's make our node automatically adapt to its connections.

public override void OnConnectedOutputNodeChanges( int inputPortId, int otherNodeId, int otherPortId, string name, WirePortDataType type )
{
	UpdateConnection( inputPortId );
}

public override void OnInputPortConnected( int inputPortId, int otherNodeId, int otherPortId, bool activateNode = true )
{
	base.OnInputPortConnected( inputPortId, otherNodeId, otherPortId, activateNode );
	UpdateConnection( inputPortId );
}

void UpdateConnection( int portId )
{
	m_inputPorts[ portId ].MatchPortToConnection();
	WirePortDataType outputType = ( UIUtils.GetPriority( m_inputPorts[ 0 ].DataType ) > UIUtils.GetPriority( m_inputPorts[ 1 ].DataType ) ) ? m_inputPorts[ 0 ].DataType : m_inputPorts[ 1 ].DataType;
	m_outputPorts[ 0 ].ChangeType( outputType, false );
}

On this snippet, the method UpdateConnection( int portId ) is called on both the situations stated earlier. The first step is to match the input port to its new type, after that we calculate and set a new type for our output port by checking which of the input port types have the most priority. Our full priority list is shown here. Please note that you need to call OnInputPortConnected base method since it's also responsible for setting connection status with the Master Node.

Besides watching for created connections, you can also check when a connection is removed. This can be done by overriding the void OnInputPortDisconnected( int portId ) method.

Code generation and Master Node Data Collector

Like described earlier, it’s on the GenerateShaderForOutput(...) overridden method where your node shader operations happens. There’s no need to call base.GenerateShaderForOutput() since it is an empty method.

In here you will fetch data from your inputs, cast and apply an operation on top of them. There are three separate ways for you to retrieve data from your inputs. They vary on if you want to automatically cast them to the input type, force cast to a certain type or do no cast at all.

  • GenerateShaderForOutput(ref MasterNodeDataCollector dataCollector, bool ignoreLocalVar ): Do no cast at all
  • GenerateShaderForOutput(ref MasterNodeDataCollector dataCollector, WirePortDataType myCastType, bool ignoreLocalVar, bool autoCast = false ): Cast to a specific type
  • GeneratePortInstructions(ref MasterNodeDataCollector dataCollector ): Automatically cast to the input port type

On our example we want to cast to our most priority type which is set on the output port

public override string GenerateShaderForOutput( int outputId, ref MasterNodeDataCollector dataCollector, bool ignoreLocalvar )
{
	WirePortDataType mainType = m_outputPorts[ 0 ].DataType;
	//Get value from input 0 
	string valueInput0 = m_inputPorts[ 0 ].GenerateShaderForOutput( ref dataCollector, mainType, ignoreLocalvar, true );
	
	//Get value from input 1 
	string valueInput1 = m_inputPorts[ 1 ].GenerateShaderForOutput( ref dataCollector, mainType, ignoreLocalvar, true );

	// This is a simplified fmod operation only for demonstration purposes
	string finalCalculation = string.Format( "frac({0}/{1})*{1}", valueInput0, valueInput1 );

	// Return result contained on finalCalculation
	return finalCalculation;
}
Adding Local Variables

We already have some automatic mechanisms on forcing local variables to be generated in order to optimize instruction count and re-use previously calculated values. You can however explicitly request a local variable to be created and re-use them on all later node calls. Here’s a snippet illustrating a simple change which forces the generation/usage of what have been said.

public override string GenerateShaderForOutput( int outputId, ref MasterNodeDataCollector dataCollector, bool ignoreLocalvar )
{
	// If local variable is already created then you need only to re-use i
	if ( m_outputPorts[ 0 ].IsLocalValue )
		return m_outputPorts[ 0 ].LocalValue;

	WirePortDataType mainType = m_outputPorts[ 0 ].DataType;
	//Get value from input 0 
	string valueInput0 = m_inputPorts[ 0 ].GenerateShaderForOutput( ref dataCollector, mainType, ignoreLocalvar, true );
	//Get value from input 1 
	string valueInput1 = m_inputPorts[ 1 ].GenerateShaderForOutput( ref dataCollector, mainType, ignoreLocalvar, true );

	// This is a simplified fmod operation only for demonstration purposes
	string finalCalculation = string.Format( "frac({0}/{1})*{1}", valueInput0, valueInput1 );

	//Register the final operation on a local variable associated with our output port
	RegisterLocalVariable( 0, finalCalculation, ref dataCollector, "myLocalVar" + m_uniqueId );

	// Use the newly created local variable
	return m_outputPorts[ 0 ].LocalValue;
}

In this example we use void RegisterLocalVariable( int outputId, string value, ref MasterNodeDataCollector dataCollector , string customName = null) to register a local variable and associate it with our output port. This way if this output is used in more than one nodes then the already created local value will be returned instead of re-calculating everything.

Draw Node and Draw Properties

You can change both how your node looks on the canvas and how its properties are displayed on the Node Properties Window. For our example, we’ll create a boolean property called m_adjustPorts which will (un)lock the node automatic port adjustment. This boolean will be toggleable from both the node itself and its Node Property window.

Draw Node

For you do change how the node looks on the main canvas you’ll need to override:

  • void Draw( DrawInfo drawInfo )

If you want to maintain the node default look and just add additional info then start by calling its base method ( highly recommended ). You can easily add extra size inside the node you’ll need to specify it through m_extraSize variable.
By adding a value into m_extraSize.x or m_extraSize.y you’ll be increasing the nodes overall width and height.

A really important aspect to take into account is that you need to manually position and scale your additional UI Components to take canvas zoom into account. Your available area to work with is given by the m_remainingBox rectangle and the current zoom can be easily be accessed through the InvertedZoom component passed via the drawInfo parameters.

protected override void CommonInit( int uniqueId )
{
	base.CommonInit( uniqueId );
	AddInputPort( WirePortDataType.FLOAT, false, "X" );
	AddInputPort( WirePortDataType.FLOAT, false, "Y" );
	AddOutputPort( WirePortDataType.FLOAT, "Out" );
	m_insideSize.Set( 50, 25 );
}

public override void Draw( DrawInfo drawInfo )
{
	base.Draw( drawInfo );
	Rect newPos = m_remainingBox;
	newPos.x += newPos.width * 0.3f * drawInfo.InvertedZoom;
	newPos.y += newPos.height * 0.3f * drawInfo.InvertedZoom;
	m_adjustPorts = EditorGUI.Toggle( newPos, m_adjustPorts );
}

In the provided snippet you can notice we’re increasing the node default size by 50 pixels on width and 25 on height. We then manually position our Toggle Box inside the node available on the Draw() method.

Draw Properties

When each node is selected, all its properties can be drawn on the left side menu, Node Properties. When creating a new node, it’s the creator responsibility to choose what is being drawn. For that, the DrawProperties() method must be overridden and base.DrawProperties() must be called. This method is being called from within a GUILayout.BeginArea(... ) - GUILayout.EndArea()

Here’s a snippet of DrawProperties().

public override void DrawProperties()
{
	base.DrawProperties();
	m_adjustPorts = EditorGUILayout.Toggle( "Auto-Adjust", m_adjustPorts );
}

This is much simpler, in here you can use Unity EditorGUILayout and GUILayout tools and the generated UI will be automatically placed inside the menu available area.

Read/Write Meta Data

All node information is also saved on the shader body as a comment block at the end of the file. Each node has the responsibility to read/write data from/to this block. Please notice that order matters and data must read in the same order it is being written.

Write

For you to write your node data you’ll need to override this method:

  • WriteToString( ref string nodeInfo, ref string connectionsInfo ).

The first instruction inside the method must be a base.WriteToString(...).

After that you can add your data by simply calling:

  • IOUtils.AddFieldValueToString( ref nodeInfo, myData )

One aspect to take into account is that this utility function internally calls the objects ToString() to retrieve the data to write into the shader. You can call AddFieldValueToString(...) using your data directly if it is C# built-in data. In any other type of data you must build a string from it which must then must be also parsed when reading from the shader.

Our utility class IOUtils already have static functions to both read and write from string to Unity Vector data types:

  • Vector2: IOUtils.Vector2ToString( Vector2 value )
  • Vector3: IOUtils.Vector3ToString( Vector3 value )
  • Vector4: IOUtils.Vector4ToString( Vector4 value )
  • Color: IOUtils.ColorToString( Color value )
Read

For you to Read data from the shader you’ll need to override this method on your node.

  • void ReadFromString( ref string[] nodeParams )

Again the first instruction needs to be base.ReadFromString(...). For you to get each element of data you have previously written you’ll just need to use GetCurrentParam( ref nodeParams ). Please note this utility function returns a string so you’ll need to parse it to your data type. Here are some examples on how to parse for the most common data types:

  • Bool: System.Convert.ToBoolean( string value )
  • Int: System.Convert.ToInt32( string value )
  • Float: System.Convert.ToSingle( string value )
  • Vector2: IOUtils.StringToVector2( string value )
  • Vector3: IOUtils.StringToVector3( string value )
  • Vector4: IOUtils.StringToVector4( string value )
  • Color: IOUtils.StringToColor( string value )
  • Enum: Enum.Parse( string value )

Here’s a snippet on how everything is built into our sample class

public override void WriteToString( ref string nodeInfo, ref string connectionsInfo )
{
	base.WriteToString( ref nodeInfo, ref connectionsInfo );
	IOUtils.AddFieldValueToString( ref nodeInfo, m_adjustPorts );
}

public override void ReadFromString( ref string[] nodeParams )
{
	base.ReadFromString( ref nodeParams );
	m_adjustPorts = Convert.ToBoolean( GetCurrentParam( ref nodeParams ) );
}

Finished Node

And here is our final node:

using UnityEngine;
using UnityEditor;
using System;
using AmplifyShaderEditor;

[Serializable]
[NodeAttributes( "My Test Node", "My Test Category", "My Test Description" )]
public class MyTestNode : ParentNode
{
	[SerializeField]
	private bool m_adjustPorts = false;

	protected override void CommonInit( int uniqueId )
	{
		base.CommonInit( uniqueId );
		AddInputPort( WirePortDataType.FLOAT, false, "X" );
		AddInputPort( WirePortDataType.FLOAT, false, "Y" );
		AddOutputPort( WirePortDataType.FLOAT, "Out" );
		m_insideSize.Set( 50, 25 );
	}

	public override void OnConnectedOutputNodeChanges( int inputPortId, int otherNodeId, int otherPortId, string name, WirePortDataType type )
	{
		UpdateConnection( inputPortId );
	}

	public override void OnInputPortConnected( int inputPortId, int otherNodeId, int otherPortId, bool activateNode = true )
	{
		base.OnInputPortConnected( inputPortId, otherNodeId, otherPortId, activateNode );
		UpdateConnection( inputPortId );
	}
	
	void UpdateConnection( int portId )
	{
		if ( m_adjustPorts )
		{
			m_inputPorts[ portId ].MatchPortToConnection();
			WirePortDataType outputType = ( UIUtils.GetPriority( m_inputPorts[ 0 ].DataType ) > UIUtils.GetPriority( m_inputPorts[ 1 ].DataType ) ) ? m_inputPorts[ 0 ].DataType : m_inputPorts[ 1 ].DataType;
			m_outputPorts[ 0 ].ChangeType( outputType, false );
		}
	}

	public override void Draw( DrawInfo drawInfo )
	{
		base.Draw( drawInfo );
		Rect newPos = m_remainingBox;
		newPos.x += newPos.width * 0.3f * drawInfo.InvertedZoom;
		newPos.y += newPos.height * 0.3f * drawInfo.InvertedZoom;
		m_adjustPorts = EditorGUI.Toggle( newPos, m_adjustPorts );
	}

	public override void DrawProperties()
	{
		base.DrawProperties();
		m_adjustPorts = EditorGUILayout.Toggle( "Auto-Adjust", m_adjustPorts );
	}

	public override string GenerateShaderForOutput( int outputId, ref MasterNodeDataCollector dataCollector, bool ignoreLocalvar )
	{
		// If local variable is already created then you need only to re-use it
		if ( m_outputPorts[ 0 ].IsLocalValue )
			return m_outputPorts[ 0 ].LocalValue;

		WirePortDataType mainType = m_outputPorts[ 0 ].DataType;
		//Get value from input 0 
		string valueInput0 = m_inputPorts[ 0 ].GenerateShaderForOutput( ref dataCollector, mainType, ignoreLocalvar, true );

		//Get value from input 1 
		string valueInput1 = m_inputPorts[ 1 ].GenerateShaderForOutput( ref dataCollector, mainType, ignoreLocalvar, true );

		// This is a simplified fmod operation only for demonstration purposes
		string finalCalculation = string.Format( "frac({0}/{1})*{1}", valueInput0, valueInput1 );

		//Register the final operation on a local variable associated with our output port
		RegisterLocalVariable( 0, finalCalculation, ref dataCollector, "myLocalVar" + m_uniqueId );

		// Use the newly created local variable
		return m_outputPorts[ 0 ].LocalValue;
	}

	public override void WriteToString( ref string nodeInfo, ref string connectionsInfo )
	{
		base.WriteToString( ref nodeInfo, ref connectionsInfo );
		IOUtils.AddFieldValueToString( ref nodeInfo, m_adjustPorts );
	}

	public override void ReadFromString( ref string[] nodeParams )
	{
		base.ReadFromString( ref nodeParams );
		m_adjustPorts = Convert.ToBoolean( GetCurrentParam( ref nodeParams ) );
	}
}

Advanced

Here we'll place additional info regarding the in-depth behavior of ASE.

Available Master Node Port Categories
  • Vertex: Write to vertex function
  • Fragment: Write to surface main function
  • Tessellation: Write to tessellation function
  • Debug: Special value that should only be set on the Master Node Debug port ( also writes into the surface main function
Available Data Types
  • OBJECT: Generic data type ( don’t use it )
  • FLOAT: Single Floating point value
  • FLOAT2: Two component vector (x,y)
  • FLOAT3: Three component vector (x,y,z)
  • FLOAT4: Four component vector (x,y,z,w)
  • FLOAT3x3: 3x3 Square Matrix
  • FLOAT4x4: 4x4 Square Matrix
  • COLOR: Similar to FLOAT4 but represents a Color ( UI wize ) and also swizzles with .rgba
  • INT: Integer
  • SAMPLER1D: 1D Texture Object
  • SAMPLER2D: 2D Texture Object
  • SAMPLER3D: 3D Texture Object
  • SAMPLERCUBE: CubeTexture Object

Sampler Types are very specific and shouldn’t be used. These are specific of the Texture Object nodes.

Type Priorities
Type Priority
COLOR 7
FLOAT4 7
FLOAT3 6
FLOAT2 5
FLOAT 4
INT 3
FLOAT4x4 2
FLOAT3x3 1
OBJECT 0

The higher the number the greater the priority.

Adding Includes

You might need to add specific libraries into your shader which are needed by your node. This can be easily done by calling AddToIncludes( int nodeId, string value ) where node id is your node unique id and value is the library you want to include:

public override string GenerateShaderForOutput( int outputId, WirePortDataType inputPortType, ref MasterNodeDataCollector dataCollector, bool ignoreLocalvar )
{
	// do awesome stuff	
	dataCollector.AddToIncludes( m_uniqueId, "UnityShaderVariables.cginc" );
	// do awesome stuff
}


Adding Shader Code Commentary

You can add commentaries directly into the shader source code via the: AddCodeComments( bool forceForwardSlash, params string[] comments ) The forceForwardSlash forces // into multi-line comments instead of using /* */

public override string GenerateShaderForOutput( int outputId, WirePortDataType inputPortType, ref MasterNodeDataCollector dataCollector, bool ignoreLocalvar )
{
	dataCollector.AddCodeComments( false, "This is my", "awesome comment" );
	// do awesome stuff
}


Controlling Precision

By inheriting from ParentNode you also get access to m_currentPrecisionType, on which you can make accessible for the user to change by calling DrawPrecisionProperty() on your DrawProperties() override method.

The shader overall precision is set by the master node. For you to get your current precision ( minimum between node and master node precision ) you can use our utils function:

  • public static PrecisionType GetFinalPrecision( PrecisionType precision ).

And if you want to get the final cg variable type already taking precision into account you call use our utils function:

  • public static string FinalPrecisionWirePortToCgType( PrecisionType precisionType, WirePortDataType type )


Unfinished Topics

This section is for topics still being written. We will move them to their proper categories as soon as they are finished.

Adding Properties/Global Variables

TODO: You can add shader properties and global variables via, once more, the data collector.

  • void AddToProperties( int nodeId, string value, int orderIndex ):
Adding additional instructions

TODO

Adding functions

TODO

Master Node Port Category

TODO: Talk about how important port signal propagation works and may need to be changed inside certain nodes so instructions are written on the correct place ( vertex function vs surface function vs tessellation function )

Usage of Constants class

TODO: Talk about using static constants on this class to prevent future errors on nodes is API is changed

Master Node Port Execution Order

TODO: Talk about Normal port being executed first and why