RSS FEED

.NET in MAXScript: Beyond Basics A Practical Guide to the native↔managed interop

More often than not, I reach for .NET in Max just because there's that one property or method not exposed to MAXScript, yet everything else could be easily done MAXScript-side. And when you're trying to move fast and explore ideas, switching apps to compile new libraries and the workarounds needed for hot reloading derail your focus, and feel like total overkill. On-the-fly compilation helps, but you lose syntax highlighting, error offsets become a math puzzle, and the boilerplate often dwarfs the actual logic – especially now with the CodeDomProvider.CompileAssemblyFromSource vs CSharpUtilities.CSharpCompilationHelper split.

Let's have a look at some ways on how I use the .NET API without stepping outside the MAXScript world. While it won't make everything elegant, it shouldn't get all that cumbersome either – and if it ever does, chances are you would be better off writing a proper C++ plugin and skipping the shenanigans altogether anyway.

I'll be using a simple modifier as the example, just like in the C++ post. Let's make one for imported models that may have mirrored normals if the original parts' transforms were applied during import. The simpleMeshMod modifier is both flexible and simple as you don't have to in any way handle the baseobject type or any modifiers below, and as Mesh as a type is very easy to reason about.

Here's a prototype that's for now using some placeholder types and methods:

plugin simpleMeshMod mirrorNormals
    name:"Mirror Normals" category:"Examples"
    classID:#(0x7d6536c9, 0x74f97216)
(
    parameters main rollout:params
    (
        flip type:#boolTab tabSize:3 ui:(chbXAxis, chbYAxis, chbZAxis)
    )

    rollout params "Parameters"
    (
        checkButton chbXAxis "Flip X Axis" width:100
        checkButton chbYAxis "Flip Y Axis" width:100
        checkButton chbZAxis "Flip Z Axis" width:100
    )

    fn anythingToFlip = 

    on modifyMesh do if flip[1] or flip[2] or flip[3] do 
    (
        if mesh.explicitNormals.count <= 0 do return() 
        local multipliers = [if flip[1] then -1 else 1, if flip[2] then -1 else 1, if flip[3] then -1 else 1]
        for normal in mesh.explicitNormals do normal *= multipliers
    )
)

Just for completeness sake, the next code sample is the boilerplate that we won't be using and which would be needed for the aforementioned on-the-fly compilation. It also muddles the logic a bit as you wouldn't want to be repeatedly converting the the mesh pointer to its .NET wrapper for each of the normals; so you either shift the looping logic into the function or you'd have to initialize the class with the current mesh – doesn't make that much of a difference here but it can get out of hand pretty fast and is one more thing you'd have to think about:

local libraries = #(getDir #maxRoot + "Autodesk.Max.dll")
local source = "
    using System;
    using Autodesk.Max;

    class MeshNormals {
        public static void MutliplyNormals(IntPtr mesh_pointer, float[] multipliers) {
            ...
        }
    }"

fn compilationHelper source libraries className =
(
    local netCompilationHelper = dotNetClass "CSharpUtilities.CSharpCompilationHelper"
    netCompilationHelper.compile source libraries
    dotNetClass className
)

fn codeDomCompiler source libraries className =
(
    local compilerParams = dotNetObject "System.CodeDom.Compiler.CompilerParameters" libraries
    compilerParams.GenerateInMemory = true

    local compilerResults = (dotNetObject "Microsoft.CSharp.CSharpCodeProvider").CompileAssemblyFromSource compilerParams #(source)
    for err = 0 to compilerResults.errors.count - 1 do print (compilerResults.errors.item[err].ToString())
    compilerResults.CompiledAssembly.CreateInstance className
)

local meshNormals = (if (maxVersion())[8] >= 2026 then compilationHelper else codeDomCompiler) source libraries "MeshNormals"

I don't know about you but I'm happy to skip that and do a little coding instead 🤓

First of all, let's get the IMesh counterpart of the TriMesh value to get access to the explicit normals. There's one interface that's extremely useful and which we can also use here:

local iGlobal = (dotNetClass "Autodesk.Max.GlobalInterface").Instance

You might already be familiar with using it to bring all sorts of scene entities, be it nodes, controllers, paramBlocks or arbitrary ReferenceTargets:

iGlobal.Animatable.GetAnimByHandle (getHandleByAnim maxEntity)

Well, this won't work here as the TriMesh provided by the modifier is a Value and not an Animatable. But there's a way to marshal native objects to managed ones so all we need is to get the pointer to the Value. Maybe by (ab)using the dotNetMXSValue?

local wrapped = dotNetMXSValue mesh
local dotnetVal = dotNet.ValueToDotNetObject wrapped (dotNetClass "System.Object")
dotnetVal.get_value() --> System.Reflection.Pointer

We could work with that in compiled code but that would a) beat the purpose of this post, and b) with .NET methods there are better ways, like using the special pointer argument naming that was introduced to make interop with MCG easier.

In MAXScript, there's no reasonably simple way of getting the underlying value of the returned System.Reflection.Pointer. But there's another feature introduced with MCG, and that's the MaxPlus types mirroring native max types. With those, we can easily wrap and unwrap it again:

((dotNetClass "Autodesk.Max.MaxPlus.Mesh")._CreateWrapper mesh).GetUnwrappedPtr()

The MaxPlus.Mesh is only used as an intermediary as it doesn't expose any of the properties we want to accept. As you could expect, there's also MaxPlus.Point3, MaxPlus.Matrix3, MaxPlus.Box3, MaxPlus.BitArray and so on, covering most of the types you might want to bring across. You can get a full list like this:

maxPlus = dotNet.loadAssembly "MaxPlusDotNet.dll"
for type in maxPlus.GetExportedTypes() do
(
    local found = false
    for method in type.GetMethods() while not found do found = method.Name == "_CreateWrapper"
    if found do format "Autodesk.Max.MaxPlus.%\n" type.Name
)

Now the only missing link is getting the IMesh object from the IntPtr. There are two ways you can use, both do the same and it's up to which one you will use. For our mesh, the methods would be:

iMesh = iGlobal.Mesh.Marshal meshPtr
iMesh = ((dotNetClass "Autodesk.Max.Wrappers.CustomMarshalerMesh").GetInstance "").MarshalNativeToManaged meshPtr

In both cases, you can replace Mesh with Point3 or other type, and in both cases, if you want to use them more than once it makes sense to make a variable to alias the method since the repeated lookup would incurr quite a heavy penalty otherwise.

Let's see what the updated modifier code looks like now:

plugin simpleMeshMod mirrorNormals
    name:"Mirror Normals" category:"Examples"
    classID:#(0x7d6536c9, 0x74f97216)
(
    parameters main rollout:params
    (
        flip type:#boolTab tabSize:3 ui:(chbXAxis, chbYAxis, chbZAxis)
    )

    rollout params "Parameters"
    (
        checkButton chbXAxis "Flip X Axis" width:100
        checkButton chbYAxis "Flip Y Axis" width:100
        checkButton chbZAxis "Flip Z Axis" width:100
    )

    local iGlobal = (dotNetClass "Autodesk.Max.GlobalInterface").Instance

    on modifyMesh do if flip[1] or flip[2] or flip[3] do
    (
        local meshPtr = ((dotNetClass "Autodesk.Max.MaxPlus.Mesh")._CreateWrapper mesh).GetUnwrappedPtr()
        local normals = (iGlobal.Mesh.Marshal meshPtr).SpecifiedNormals
        if normals == undefined or not normals.AnyExplicitNormalsSet do return()

        local multipliers = [if flip[1] then -1 else 1, if flip[2] then -1 else 1, if flip[3] then -1 else 1]
        for normal in normals.NormalArray do normal.MultiplyBy multipliers
    )
)

This won't work just yet. For one, we will have to replace the 'multipliers' Point3 value with a proper IPoint3 wrapper. That's quite easy as the global interface also provides a way to construct native max types:

local multipliers = iGlobal.Point3.Create (if flip[1] then -1 else 1) (if flip[2] then -1 else 1) (if flip[3] then -1 else 1)

Where it gets a bit less obvious is that the normals.NormalArray doesn't provide a proper array – instead, we get the first element of the array only (or rather an IPoint3 interface to the underlying native object it points to). That won't be an issue though because you can still iterate over the array manually – and there are lots of ways to do that depending on what you want to do with the values.

For example, if all we cared about was to flip all the normals, we could Marshal.Copy 3 * numNormals of floats to a temp array, flip all the signs, and copy everything back again. This works because in memory layout, Point3 is represented as three successive floats, and the array comprises of all the successive Point3s:

marshal = dotNetClass "System.Runtime.InteropServices.Marshal"
floats = (dotNetClass "System.Array").CreateInstance (dotNet.getType "System.Single") (numNormals *3) asDotNetObject:on
marshal.Copy normalPtr floats 0 (numNormals * 3)
mxsFloats = dotnet.dotNetObjectToValue floats
for i = 1 to (numNormals * 3) do mxsFloats[i] *= -1 --> faster than setting the values of the .NET array
marshal.Copy mxsFloats 0 normalPtr (numNormals * 3)

For our intended use case, it could be done in a similar manner when flipping just select axes, too, but both from the readability standpoint and ease of later translation to C++ (or at least a C# DLL), I'm okay with sacrificing the performance. Just bear in mind that you will pay the performance penalty both for accessing any property or method of a .NET entity in MAXScript, as well as the penalty for creating the managed .NET objects that wrap the native objects. When the plan is to create a huge number of such temporary objects, always double-check for other possible approaches.

Either way, in both cases we first need to get that pointer out of the IPoint3 wrapper. Max provides us with a handy property INativeObject__NativePointer — note that it used to be called just NativePointer before Max 2020, and even earlier it was called Handle (but that was before MCG and the handy MaxPlus types were introduced anyway).

Retrieving its value is straightforward:

normalPtr = normals.NormalArray.INativeObject__NativePointer

If you for some reason needed it in the form of the original IntPtr, you could prevent the implicit conversion through getProperty:

normalPtr = getProperty normals.NormalArray #INativeObject__NativePointer asDotNetObject:on

Alternatively, if you want to avoid version-specific switches, you can also get the pointer this way:

marshalerPoint3 = (dotNetClass "Autodesk.Max.Wrappers.CustomMarshalerPoint3").GetInstance ""
ptr = marshalerPoint3.MarshalManagedToNative normals.NormalArray

And now we can increment the pointer we got by the size of Point3. In this case, it would be easy to calculate that yourself — but even if you had a sudden brain fog or felt extra lazy, a quick check with Visual Studio and the SDK will quickly provide the exact value. Hover over the type and voilà:


Same with checking the offsets, click the Memory Layout link and everything will be laid out in a nice table:


While hardcoding the value is quite a safe bet with basic types like Point3, don't rely on it – the size and offsets may change between SDK versions.

The last missing step is getting the IPoint3 that corresponds to the pointer, which is once again a case for marshalling:

normal = iGlobal.Point3.Marshal normalPtr

Putting it all together, it may look for example like this. I've opted for a do-while loop decrementing the number of remaining normals until there's none left to make it more obvious, but you can do the pointer arithmetic in a regular for loop instead, just as long as you get the math right:

plugin simpleMeshMod mirrorNormals
    name:"Mirror Normals" category:"Examples"
    classID:#(0x7d6536c9, 0x74f97216)
(
    parameters main rollout:params
    (
        flip type:#boolTab tabSize:3 ui:(chbXAxis, chbYAxis, chbZAxis)
    )

    rollout params "Parameters"
    (
        checkButton chbXAxis "Flip X Axis" width:100
        checkButton chbYAxis "Flip Y Axis" width:100
        checkButton chbZAxis "Flip Z Axis" width:100
    )

    local iGlobal = (dotNetClass "Autodesk.Max.GlobalInterface").Instance
    local marshalPoint3 = iGlobal.Point3.Marshal

    on modifyMesh do if flip[1] or flip[2] or flip[3] do
    (
        local meshPtr = ((dotNetClass "Autodesk.Max.MaxPlus.Mesh")._CreateWrapper mesh).GetUnwrappedPtr()
        local normals = (iGlobal.Mesh.Marshal meshPtr).SpecifiedNormals

        if mesh.numVerts < 3 or normals == undefined or not normals.AnyExplicitNormalsSet do return()

        local remainingNormals = normals.NumNormals
        local normalPtr = normals.NormalArray.INativeObject__NativePointer
        local multipliers = iGlobal.Point3.Create (if flip[1] then -1 else 1) (if flip[2] then -1 else 1) (if flip[3] then -1 else 1)

        do (
            local normal = marshalPoint3 normalPtr
            normal.MultiplyBy multipliers
            normalPtr += 12
        )
        while (remainingNormals -= 1) > 0
    )
)

It's a modifier and it does what we wanted it to do, mirrors explicit normals. Once all three axes are checked, the outcome is visually the same as just flipping the normals – however, unlike the Normal modifier, it doesn't flip faces, only the vertex normals change:


Although it will work for the imported Editable Mesh objects, the normals might be cleared in other cases. To prevent that, setting the MESH_NORMAL_MODIFIER_SUPPORT is needed – you can find the value under MaxPlus.Constants. And while getting the array of normals was instructive in showing how to work with arrays, there's a better of transforming multiple normals at once, and that's using the MeshNormalSpec.Transform method:

plugin simpleMeshMod mirrorNormals
    name:"Mirror Normals" category:"Examples"
    classID:#(0x7d6536c9, 0x74f97216)
(
    parameters main rollout:params
    (
        flip type:#boolTab tabSize:3 ui:(chbXAxis, chbYAxis, chbZAxis)
    )

    rollout params "Parameters"
    (
        checkButton chbXAxis "Flip X Axis" width:100
        checkButton chbYAxis "Flip Y Axis" width:100
        checkButton chbZAxis "Flip Z Axis" width:100
    )

    local iGlobal = (dotNetClass "Autodesk.Max.GlobalInterface").Instance
    local normalSupport = (dotNetClass "Autodesk.Max.MaxPlus.Constants").MeshNormalModifierSupport

    on modifyMesh do if flip[1] or flip[2] or flip[3] do
    (
        local meshPtr = ((dotNetClass "Autodesk.Max.MaxPlus.Mesh")._CreateWrapper mesh).GetUnwrappedPtr()
        local normals = (iGlobal.Mesh.Marshal meshPtr).SpecifiedNormals

        if mesh.numVerts < 3 or normals == undefined or not normals.AnyExplicitNormalsSet do return()

        local multipliers = iGlobal.Point3.Create (if flip[1] then -1 else 1) (if flip[2] then -1 else 1) (if flip[3] then -1 else 1)
        local tm = iGlobal.Matrix3.Create()
        tm.Scale multipliers off

        normals.Transform tm off () on
        normals.SetFlag normalSupport on
    )
)

That’s it! Together with the Python’s ctypes module and its windll interface, which provides access to system dlls like user32, this basically eliminated the need for on‑the‑fly compilation in my workflow. You may also find few more examples of code snippets using .NET in MAXScript among my public gists.

Hope you find inspiration and have fun!


ACKNOWLEDGEMENTS: Special thanks to Denis Trofimov for advice that made the code and the article better, and for all his contributions over the years that I could learn from in the first place.

DISCLAIMER: All scripts and snippets are provided as is under Creative Commons Zero (public domain, no restrictions) license. The author and this blog cannot be held liable for any loss caused as a result of inaccuracy or error within these web pages. Use at your own risk.

This Post needs Your Comment!

Return to top