RSS FEED

.NET in MAXScript: The Easy Way 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. Using compiled libraries and the inconvenience that comes with that as well as the workarounds needed to make hot reloading possible make that an obvious overkill, especially since these are usually cases of quick experiments and prototyping. For a while, I took advantage of the on-the-fly compilation, but it means you wind up with a big piece of string – you lose syntax highlighting and error offsets become a math puzzle – and now with the CodeDomProvider.CompileAssemblyFromSource vs CSharpUtilities.CSharpCompilationHelper split, the boilerplate even often dwarfs the actual logic.

Let's have a look at some ways on how to use the .NET API without sidestepping the MAXScript world. Sure, you may not be able to do everything in an elegant way, but if it's still a hassle this way, it's more than likely you'd be better off writing a proper C++ plugin anyway and skipping the shenanigans altogether.

As with the C++ post, I'll use a simple modifier as a case study. simpleMeshMod modifiers are 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. Let's make a modifier for imported models that may have mirrored normals if the original parts' transforms were applied during import:

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, here's 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:

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 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 then there're already better ways by using the special pointer argument naming that was introduced to make interop with MCG easier. In MAXScript, there's (by design) 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. Now 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.

For our intended use case, it could be done in a similar manner when flipping just a certain axis, too, but I care more about readability than maybe sacrificing a bit of performance.

Either way, to be able to increment the pointer, 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 in Max 2014 and earlier it was called Handle. 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 – or you can use dotnet.ValueToDotNetObject normalPtr (dotNetClass "IntPtr"), and vice versa with dotnet.DotNetObjectToValue:

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, here the pointer arithmetic happens while decrementing the number of remaining normals until there's none left:

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
    )
)

Nice little modifier prototype. Once all three axes are checked, the outcome is visually the same as flipping the normals – however, unlike the Normal modifier, it won't reorder face vertices so face normals remain unchanged:


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 how that could look like among my public gists. Hope you find inspiration and have fun!

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