Blog


Maxscript: Constrain to Biped

So in 2016, I wrote a maxscript to constrain humanoid characters to 3ds Max bipeds for easier animation.  Every once and awhile, I get someone asking me for the script and some questions regarding it, so I decided to write a post about it for the future.

Here are two videos demonstrating how it works and its setup:


A quick summary:

  • What this script does:
    • Builds a biped, sizing it to fit a specified character
    • Uses Orientation Constraints to drive the original rig’s bones to the biped
  • What this script does NOT do:
    • Require new reskinning or transfer of the original rig’s skinning, which often causes issues.
    • Transfer .fbx animations — or any kinds for that matter — to the biped.  This is JUST for the rig itself.

Why?

Why write a script like this?  Well, for one I’m old-fashioned.  I’ve always liked the 3ds Max’s biped.  It’s not perfect, a little buggy; however, I’ve felt it gets the job done and has a lot of extra features — saving poses, postures, animations, etc. — that writing on my own would be rather time-consuming.  Additionally, exporting just the biped itself can be rather problematic as it sometimes moves bone objects, which causes issues with animation retargeting since that is focused more on rotation.  Since this original rig is preserved and only driven by Orientation Constraints from the biped, this is less of a problem.

Then, despite the fact other character creator tools such as Mixamo supplied rig to biped scripts, though scripts never quite worked as well as I would want, often deforming the original mesh or rig and causing unforeseen issues.

The Script

Firstly, you can download the script here.  Note, this script was written for 3ds Max 2016 but has also been tested in 2017.

Instructions

Unzip the downloaded file and run the .ms file.  You should then see the following window:

There are two columns.  The left column, Biped Bones,  is for all of the biped bones that’ll be created; the right column, Character Bones, is for the bones in the original rig.  Note, there are 2 neck bones and 3 spines in the left column; however, the 2nd neck joint and 3rd spine joint do not need to be defined and are prefaced with [IGNORE].  When this was written, it was for one specific rig that used 3 spine joints and 2 neck joints; however, since this caused issues with Unity, I decided to remove those; thus enabling it to work on more humanoid rigs.  Unity can now handle a 3rd spinal joint, but still doesn’t use a 2nd neck joint in its default, humanoid rigs.

To start populating the right column, click the row you want to define and then click the bone / node you’d like to associate with the biped bone.  It’s a bit tedious.  There are two buttons for saving and loading, Save Selection Set and Load Selection Set, respectively, that can help a bit.  If you know the names or they are named in a way that can be populated quickly through copy-and-paste, this can be done by saving a text file, updating it, and then reloading it.  In the .zip, there are two examples of these files; they are setup for use with iClone Character Creator 1 rigs.

Once the right column has been populated properly, the Validate Bones button will check to make sure the bone slots are all assigned.  This will also show a pop-up for any bones that are missing.  Warning:  This’ll generate a pop-up for every missing bone.  

If all bones have been signed, click the Build Biped button.  This will generate a biped that’ll match the size of the original rig.  You do not need to, but it is suggested to then rotate the biped as closely to the original rig.

Then, the Build Helper Rig button will create a new rig that is identical to the original rig except it’s bone orientation will match the biped’s, meaning the up, forward, and right axes will match the biped’s.  This is important for the next step.  Essentially, an early thought for this experiment was to:

  • Build a biped
  • Align the original rig to the biped

However, one of the big issues is that rigs and their bone rotations can come in a variety of orientations.  If you use 3ds Max’s default align too, arms will sometimes be rotated in strange positions.  The helper rig solves this by standing as the middleman between your original rig and the biped.  It’ll be the same size as your original rig but the bone’s will match the orientation of the biped.

Next there is the Align To Biped button.  This aligns the helper rig to the biped and then the original rig to the helper.  This is why aligning the biped to the original rig helps; otherwise the changes can look rather broken.  They are easy to fix because, again, this is just affecting rotation and not placement of the original rig.

The Create Constraint button is the final step.  All other steps should be completed first — including making backups in case there is an issue.  This will create Orientation Constraints between your original rig to the helper rig and from the helper rig to the biped.

Once this is done, the rig should now be driven by the biped.

Other Buttons & Tips

As you may note, there are two buttons I’ve yet to discuss, Quick Parent and Quick Child.  Quick Parent create a parent bone the selected bone’s parent and itself.  This would be used for something like a rig with only one spine.  This will create the second spine automatically that can be used in the rig.  Then, Quick Child, creates a joint at the end of a joint.  The biped rig requires 5 fingers as well say finger nubs, for example, and this button will create these quickly.

Another tip is that if you create a child, for something like the head nub, make sure that they are aligned perfectly vertically; otherwise, the head will be tilted when aligned to the biped.  The toes have a similar problem I haven’t quiet figured out, but again, aligning the created biped as closely to the original rig as possible will help resolve some misalignment issues.  Another tip is that instead of rotating the biped once it’s created, rotated the bones of the original rig to match the newly created biped as closely as possible.

Final Steps

After completing the steps, you can now animate just the biped as your would except you should NOT rotate the pelvis bone; this causes the hip and spine bones to translate slightly, causing issues upon export.  They will export fine, but your animations won’t match perfectly and when importing to Unity, you’ll get errors about how those bones have translation data and that said data will be ignored if it’s part of a humanoid avatar.

Also, don’t export everything; use the export selection and select only the original rig’s joints and/or any meshes you’d like to export.

Quick Summary

  • Unzip this file.
  • Run the BipedRigCreator.ms script in 3ds Max
  • Define the joints in the right column, creating children or parents where needed
  • Validate the bones
  • Build the biped
  • Build the helper rig
  • Align to the biped
  • BACKUP (if not already)
  • Create constraints

Wishlist

I’m unsure if I’ll add anything to this script anytime soon, but here is a list of things I’d like to do:

  • Streamline the bone selection or remove the left, right column idea as they aren’t lined up
  • Allow for multiple spine joints / make the correct number of spines based on the number of spine joints assigned)
  • Adjust errors for the head nub and foot nub issues
  • Not show a pop-up for every missing bone, but instead a list of all missing bones upon validation

Anyway, if you use the script, great!  I’d love to see what people do with it.  Again, I mostly wrote this so people who would like to use it in the future have something to refer to.


Unity3D Script: Quick Texture Editor

Last year I wrote a Unity3D editor script for combining textures as well as swapping and combining their different color channels.


Someone on YouTube recently commented, asking for more details. Since I haven’t touched the script in over a year, I decided to just make the script public. It’s not perfect and some of my comments don’t make sense. I’ll probably clean it up in the future, or at least add better documentation.  I sound very professional right now.

via GIPHY

What this script does:

  • Allows you to swap color channels
    • For example, take the red channel of a grayscale smoothness map and apply it to the alpha channel of your albedo texture
  • Allows you to combine texture onto a new, larger texture
    • You have two 512×512 texture and want to combine them onto one 1024×512 texture

What this script does NOT do:

  • Resize textures
  • Rearrange meshes’ UVs
  • Paint onto textures
  • Create textures other than PNGs
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using System.IO;
 
namespace MattrifiedGames.Assets.TextureHelpers.Editor
{
    /// <summary>
    /// Editor window for quickly swapping, rearranging, and other things to textures in Unity3D.
    /// </summary>
    public class QuickTextureEditor : EditorWindow
    {
        /// <summary>
        /// A list of the current affected textures.
        /// </summary>
        List<TextureInformation> texturePositionList;
 
        /// <summary>
        /// If true, the new texture's size will be forced to the nearest power of two.
        /// </summary>
        bool forcePowerOfTwo = false;
 
        /// <summary>
        /// Width of the new texture.
        /// </summary>
        int newTexWidth = 512;
 
        /// <summary>
        /// Height of the new texture.
        /// </summary>
        int newTexHeight = 512;
 
        /// <summary>
        /// The name of the new texture to be created.
        /// </summary>
        string newTextureName = "New Texture";
 
        /// <summary>
        /// Operations affecting different channels.
        /// </summary>
        public enum ChannelOperations
        {
            Ignore = 0,
            Set = 1,
            Add = 2,
            Subtract = 3,
            Multiply = 4,
            Divide = 5,
        }
 
        public struct ChannelBlendSetup
        {
            public ChannelOperations rCU, gCU, bCU, aCU;
        }
 
        /// <summary>
        /// Information about each texture being used to create the new texture.
        /// </summary>
        internal class TextureInformation
        {
            /// <summary>
            /// The texture being used.
            /// </summary>
            public Texture2D texture;
 
            /// <summary>
            /// The x and y position of the new texture.
            /// </summary>
            public int xPos, yPos;
 
            /// <summary>
            /// The x and y position of the new texture.
            /// </summary>
            public int width, height;
 
            /// <summary>
            /// Should a multiply color be used?
            /// </summary>
            public ChannelOperations blendColorUse = ChannelOperations.Ignore;
             
            /// <summary>
            /// The color to be blended with the texture.
            /// </summary>
            public Color blendColor;
 
            public ChannelBlendSetup rBS = new ChannelBlendSetup() { rCU = ChannelOperations.Set },
                gBS = new ChannelBlendSetup() { gCU = ChannelOperations.Set },
                bBS = new ChannelBlendSetup() { bCU = ChannelOperations.Set },
                aBS = new ChannelBlendSetup() { aCU = ChannelOperations.Set };
 
            public void OnGUI(string label, ref int refWidth, ref int refHeight)
            {
                if (texture != null)
                    label = texture.name;
                texture = (Texture2D)EditorGUILayout.ObjectField(label, texture, typeof(Texture2D), false);
 
                if (GUILayout.Button("Set as new texture size."))
                {
                    refWidth = width;
                    refHeight = height;
                }
 
                if (texture == null)
                {
                    Vector2 s = new Vector2(width, height);
                    s = EditorGUILayout.Vector2Field("Size", s);
                    width = Mathf.Max(1, Mathf.RoundToInt(s.x));
                    height = Mathf.Max(1, Mathf.RoundToInt(s.y));
                }
                else
                {
                    width = texture.width;
                    height = texture.height;
                }
 
                blendColorUse = (ChannelOperations)EditorGUILayout.EnumPopup("Blend Color Usage", blendColorUse);
                if (blendColorUse != ChannelOperations.Ignore)
                    blendColor = EditorGUILayout.ColorField(blendColor);
                else
                    blendColor = Color.white;
 
                Vector2 v = new Vector2(xPos, yPos);
                v = EditorGUILayout.Vector2Field("Pos", v);
                xPos = Mathf.RoundToInt(v.x);
                yPos = Mathf.RoundToInt(v.y);
 
                EditorGUILayout.BeginHorizontal();
 
                EditorGUILayout.BeginVertical();
                GUILayout.Label("");
                GUI.color = Color.red;
                GUILayout.Label("R");
 
                GUI.color = Color.green;
                GUILayout.Label("G");
 
                GUI.color = Color.blue;
                GUILayout.Label("B");
 
                GUI.color = Color.white;
                GUILayout.Label("A");
                EditorGUILayout.EndVertical();
 
                ChangeBlendSetup("R", ref rBS, Color.red);
                ChangeBlendSetup("G", ref gBS, Color.green);
                ChangeBlendSetup("B", ref bBS, Color.blue);
                ChangeBlendSetup("A", ref aBS, Color.white);
 
                EditorGUILayout.EndHorizontal();
            }
 
            private void ChangeBlendSetup(string p, ref ChannelBlendSetup bS, Color guiColor)
            {
                EditorGUILayout.BeginVertical();
                GUI.color = guiColor;
                GUILayout.Label(p);
                GUI.color = Color.white;
                 
                bS.rCU = (ChannelOperations)EditorGUILayout.EnumPopup(bS.rCU);
                bS.gCU = (ChannelOperations)EditorGUILayout.EnumPopup(bS.gCU);
                bS.bCU = (ChannelOperations)EditorGUILayout.EnumPopup(bS.bCU);
                bS.aCU = (ChannelOperations)EditorGUILayout.EnumPopup(bS.aCU);
                 
                EditorGUILayout.EndVertical();
            }
 
            internal void EditColor(ref Color colorOutput, ref Color colorInput)
            {
                EditChannel(ref colorOutput.r, ref colorInput, rBS);
                EditChannel(ref colorOutput.g, ref colorInput, gBS);
                EditChannel(ref colorOutput.b, ref colorInput, bBS);
                EditChannel(ref colorOutput.a, ref colorInput, aBS);
            }
 
            private void EditChannel(ref float outputValue, ref Color inputColor, ChannelBlendSetup bs)
            {
                EditChannel(ref outputValue, ref inputColor.r, bs.rCU);
                EditChannel(ref outputValue, ref inputColor.g, bs.gCU);
                EditChannel(ref outputValue, ref inputColor.b, bs.bCU);
                EditChannel(ref outputValue, ref inputColor.a, bs.aCU);
            }
 
            private void EditChannel(ref float output, ref float input, ChannelOperations channelUsage)
            {
                switch (channelUsage)
                {
                    case ChannelOperations.Set:
                        output = input;
                        break;
                    case ChannelOperations.Add:
                        output += input;
                        break;
                    case ChannelOperations.Divide:
                        output /= input;
                        break;
                    case ChannelOperations.Multiply:
                        output *= input;
                        break;
                    case ChannelOperations.Subtract:
                        output -= input;
                        break;
                    case ChannelOperations.Ignore:
                        return;
                }
            }
        }
 
         
 
        // Add menu named "My Window" to the Window menu
        [MenuItem("Tools/Quick Texture Editor")]
        static void Init()
        {
            // Get existing open window or if none, make a new one:
            QuickTextureEditor window = (QuickTextureEditor)EditorWindow.GetWindow(typeof(QuickTextureEditor));
            window.Show();
        }
 
        /// <summary>
        /// On GUI function that displays information in the editor.
        /// </summary>
        void OnGUI()
        {
            OnGUICombineTextures();
        }
 
        /// <summary>
        /// Quickly gets the importer of a specified asset
        /// </summary>
        /// <typeparam name="T">The type of importer to be used.</typeparam>
        /// <param name="asset">The asset whose importer is being referenced.</param>
        /// <returns>The importer, converted to the requested type.</returns>
        private T GetImporter<T>(UnityEngine.Object asset) where T : AssetImporter
        {
            return (T)AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(asset));
        }
 
        private void SetupList<T>(ref List<T> list, int p)
        {
            if (list == null)
                list = new List<T>();
            while (list.Count <= p)
                list.Add(default(T));
        }
 
        private T GetFromList<T>(ref List<T> list, int p)
        {
            SetupList(ref list, p);
            return list[p];
        }
 
        private void DefineTexturePose(int index)
        {
            SetupList(ref texturePositionList, index);
            if (texturePositionList[index] == null)
                texturePositionList[index] = new TextureInformation();
 
            texturePositionList[index].OnGUI("Texture " + index, ref newTexWidth, ref newTexHeight);
        }
 
        private static Color DivideColor(Color c)
        {
            return new Color(1f / c.r, 1f / c.g, 1f / c.b, 1f / c.a);
        }
 
        Vector2 scroll;
        private void OnGUICombineTextures()
        {
            // Defines information about the new texture.
            newTextureName = EditorGUILayout.TextField("New Texture Name", newTextureName);
 
            forcePowerOfTwo = EditorGUILayout.Toggle("Force Power of 2", forcePowerOfTwo);
            if (forcePowerOfTwo)
            {
                newTexWidth = Mathf.ClosestPowerOfTwo(EditorGUILayout.IntField("Width", newTexWidth));
                newTexHeight = Mathf.ClosestPowerOfTwo(EditorGUILayout.IntField("Height", newTexHeight));
            }
            else
            {
                newTexWidth = EditorGUILayout.IntField("Width", newTexWidth);
                newTexHeight = EditorGUILayout.IntField("Height", newTexHeight);
            }
 
            EditorGUILayout.Separator();
 
            scroll = EditorGUILayout.BeginScrollView(scroll);
            if (texturePositionList == null)
                texturePositionList = new List<TextureInformation>();
            for (int i = 0; i < texturePositionList.Count; i++)
            {
                DefineTexturePose(i);
            }
 
 
            EditorGUILayout.BeginHorizontal();
            if (GUILayout.Button("Add Texture"))
            {
                texturePositionList.Add(new TextureInformation());
                return;
            }
            if (GUILayout.Button("Remove Texture"))
            {
                texturePositionList.RemoveAt(texturePositionList.Count - 1);
                return;
            }
            EditorGUILayout.EndHorizontal();
 
            EditorGUILayout.EndScrollView();
 
            EditorGUILayout.Separator();
 
            if (GUILayout.Button("Save Texture"))
            {
                int textureCount = texturePositionList.Count;
 
                Texture2D newTex = new Texture2D(newTexWidth, newTexHeight);
                newTex.name = string.IsNullOrEmpty(newTextureName) ? "New Texture" : newTextureName; 
                Color[] mainColors = new Color[newTex.width * newTex.height];
                newTex.SetPixels(mainColors);
 
                List<TextureInformation> pulledTextures = new List<TextureInformation>();
                for (int i = 0; i < textureCount; i++)
                {
                    TextureInformation pos = GetFromList(ref texturePositionList, i);
                    if (pos == null)
                        continue;
                    else if (pos.texture == null)
                    {
                        pos.texture = new Texture2D(pos.width, pos.height);
                        pos.texture.name = "Texture " + i;
                        Color[] c = new Color[pos.width * pos.height];
                        for (int j = 0; j < c.Length; j++) c[j] = pos.blendColor; pos.texture.SetPixels(c); pos.texture.Apply(); } if (pos.texture.width + pos.xPos > newTex.width ||
                        pos.texture.height + pos.yPos > newTex.height)
                    {
                        Debug.LogWarning(pos.texture.name + " will not fit into new texture.  Skipping.");
                        continue;
                    }
 
                    pulledTextures.Add(pos);
                }
 
                for (int i = 0; i < pulledTextures.Count; i++)
                {
                    EditorUtility.DisplayProgressBar("Saving Texture", "Working on Texture " + i, (i + 1) / (pulledTextures.Count));
 
                    TextureImporter ti = GetImporter<TextureImporter>(pulledTextures[i].texture);
                    bool wasReadable = ti.isReadable;
                    bool wasNormal = ti.normalmap;
 
                    if (wasReadable != true)
                    {
                        ti.isReadable = true;
                        ti.SaveAndReimport();
                    }
 
                    if (wasNormal)
                    {
                        ti.normalmap = false;
                        ti.SaveAndReimport();
                    }
 
 
                    Color[] pulledColors = pulledTextures[i].texture.GetPixels();
 
                    if (pulledTextures[i].blendColorUse != ChannelOperations.Ignore)
                    {
                        for (int c = 0; c < pulledColors.Length; c++)
                        {
                            switch (pulledTextures[i].blendColorUse)
                            {
                                case ChannelOperations.Set:
                                    pulledColors = pulledTextures[i].blendColor;
                                    break;
                                case ChannelOperations.Add:
                                    pulledColors += pulledTextures[i].blendColor;
                                    break;
                                case ChannelOperations.Divide:
                                    pulledColors *= DivideColor(pulledTextures[i].blendColor);
                                    break;
                                case ChannelOperations.Multiply:
                                    pulledColors *= pulledTextures[i].blendColor;
                                    break;
                            }
                        }
                    }
 
                    Color[] colorsToModify =
                        newTex.GetPixels(pulledTextures[i].xPos, pulledTextures[i].yPos, pulledTextures[i].texture.width, pulledTextures[i].texture.height);
                     
                    // Adds these colors instead of setting.  Slower, but allows for combining channels or for combining reasons.
                    for (int c = 0; c < colorsToModify.Length; c++)
                        pulledTextures[i].EditColor(ref colorsToModify, ref pulledColors);
 
                    newTex.SetPixels(pulledTextures[i].xPos, pulledTextures[i].yPos, pulledTextures[i].texture.width, pulledTextures[i].texture.height,
                        colorsToModify);
 
                    if (ti.isReadable != wasReadable)
                    {
                        ti.isReadable = wasReadable;
                        ti.SaveAndReimport();
                    }
 
                    if (wasNormal)
                    {
                        ti.normalmap = true;
                        ti.SaveAndReimport();
                    }
                }
 
                SaveTexture(newTex);
 
                EditorUtility.ClearProgressBar();
            }
        }
 
        void SaveTexture(Texture2D texture2D)
        {
            byte[] bytes = texture2D.EncodeToPNG();
 
            File.WriteAllBytes(Application.dataPath + "/" + texture2D.name + ".png", bytes);
 
            AssetDatabase.Refresh();
        }
    }
}

If you use the script, credit would be nice. If you have any questions, feel free to ask here or on my twitter.


Starting “Over” & 2017 Thus Far

So I decided to “start over” with my blog. Blogger or Blogspot or whatever was becoming irritating to use and felt dated. The biggest issue is that writing code samples like this —

public class MyClass
{
    void Awake()
    {
        Debug.Log("Hello wor-, I mean planet.");
    }
}

— was a real pain.
Anyway, since this is the first blog that’ll appear on the official Mattrified Games website, I decided to do a quick retrospective of 2017 thus far.

MAGFest & Battle High 2 A+

In January, I went to my first MAGFest.  I went to show off Battle High 2 A+ as part of their independent games areas.  It was a great learning and motivating — to a degree — experience.  It was fun seeing people play the game and enjoying it.  There was even a Battle High 2 A+ tournament, which was awesome as well!  I could have definitely done a few things better; for example, not having an attract screen was probably not the best idea.  Also, I was at the booth so much, that it was hard to enjoy the festival itself; fortunately, it was 24 hour, so it wasn’t like it was impossible, but fatigue did set in a bit.

I did start a mailing list for Battle High 2 A+ and took it to another smaller and local Retro Games Festival.  There was also another tournament at ReplayFX.  Like I said, however, showing the game off was only motivating to a degree.  As much as I love the Battle High series, I’ve been working on it for a long time.  I’m not going to stop working on it entirely, but at this time, I’m pursuing different games and ideas.  There is still at least one Battle High 2 A+ character I would like to release, and there is still plenty of time to release said character before 2018, but I’m not going to promise it at this time.

The Aquatic Tactics Fighter

One game I’ve been developing off and on for awhile is a merfolk-themed tactics fighting game.  After MAGFest, I took a break from Battle High to work on this idea.  I really enjoy developing fighting games, but I wanted to develop something with more emphasis on story and single-player interactions.  So, for a bit, I was working on this a game that combined elements of a tactics RPG with those of a fighting game.  The problem, however, was that frakensteining the two genres together made me come to a few revelations.  One, it’s WAY too monumental of a task for a solo developer such as myself to take on.  Though hard, I don’t believe solo development is impossible, but for this game, trying to combine two large genres into one solid idea was intimidating.  At the same time, I was discovering that there are parts of tactics games I just don’t enjoy trying to develop or at least don’t feel inspired by.  So, due to these two issues, I decided to pause the idea indefinitely.

A New and True Fighting Game

I think one of my biggest regrets with Battle High is that I never took the time to try and develop a online multiplayer solution.  I felt very conflicted about the idea, ultimately deciding that the amount of time it would take to implement would be too much.  I’d probably would have never released.  So around the time I began losing passion for the Tactics Fighter, I discovered TrueSync by Exit Games.  This rollback netcode solution was made for Unity and though it’s still in beta, it’s giving me rather promising results.

Now, I’ve yet to really develop anything solid with it, but I’m confident that I can get something sooner than the Tactics Fighter.  In fact, I even signed up to give a talk at Unite 2017.  I really feel that TrueSync does a great job democratizing one of the more challenging aspects of online multiplayer for action games in a clear, easy-to-understand approach.

Anyway, my year so far started with Battle High, continued with the Tactics Fighter, and will probably end with a TrueSync fighter.  I’m hoping to release an alpha of some kind before 2018, before the fall actually.  I also plan to write more blogs here in the future; again, I hadn’t been keeping up to date with it because writing code samples, managing images, headings, and more was just a pain.  Hopefully in this new format, keeping my game work in one official place will be more manageable — again, hopefully.