1
\$\begingroup\$

First up, the name of the question is terrible and I am open to suggestions. For context: I'm still relatively new to C#/Unity and it's hard to ask questions properly when I don't know the terminology. I'm reasonably experienced in Java/Python/javascript though so I'm not new to programming in general. Also since I have no idea how to ask this question in a structured way I'm just going to show all the relevant stuff and hopefully someone who understands it can put succinctly what I'm trying to ask.

I have this struct:

[System.Serializable]
public struct FieldLayer3D {
    public  Vector3        position;
    public  Quaternion     rotation;
    public  Vector3        scale;
    [SerializeReference] public  FieldGeneric3D field; //this is where we have a problem
    public  BlendType      blendType;
    public  float          opacity;
    public  Channel        channel;
    public  float          smoothParameter;
    public  NormaliseType  NormaliseType;
    public  float          bias;
    public  bool           invertLayer;
}

My plan was to use arrays of these to define some procedurally generated content and let me combine noise and patterns in ways similar to graphics programs combine layers of images. I need the larger class that will contain an array of FieldLayer3Ds to be serializable so I can save/load my work. The FieldGeneric3D typed variable "field" is what defines the type of noise/pattern/whatever and FieldGeneric3D is an abstract class defined as follows:

[System.Serializable]
public abstract class FieldGeneric3D {
    //These are the fields for "FieldLayer3D" 
    public abstract float FieldAt(Vector3 p);
}

The reason I defined it this way is because each field needs its own set of variables that are different from each other, but will always value types like Vector3 or float such as in these two examples:

[System.Serializable]
public class FieldSDFBox3D: FieldGeneric3D {
    public Vector3 boxSize;
    public FieldSDFBox3D(Vector3 boxSize) { this.boxSize = boxSize; }
    public override float FieldAt(Vector3 p) { return PatternGenerator.Sample_SDF_Box(p, boxSize); }
}
[System.Serializable]
public class FieldSimplex3D : FieldGeneric3D {
    public int seed;
    public FieldSimplex3D(int seed) { this.seed = seed; }
    public override float FieldAt(Vector3 p) {
        NoiseGenerator noiseGenerator = new NoiseGenerator(seed);
        return noiseGenerator.Sample_Simplex(p);
    }
}

However, this is what I see when I use the FieldLayer3D struct in a MonoBehaviour: Image of the inspector showing that the Field variable has blank space next to it instead of any way to set the variable.

I was expecting something analogous to how materials work that you could drop in one of the script files that defines the appropriate class and I was HOPING that after you dragged in the script it would let you mess with public variables for the instance of the object.

Solutions I've thought and the issues with them:

  1. Just use an enum instead of this class structure thing and let whatever object is controlling the field generation handle it. This doesn't work because I need to be able to set variables for each layer and the types of variable are different depending on the type of field.

  2. Make FieldGeneric3D inherit from ScriptableObject and create assets for every layer I ever want to make. This is problematic because can you imagine how messy and clunky things would get in a program like photoshop if every time you wanted to add a layer you needed to save it as a new separate file? This just isn't workable, and the same goes for making it a MonoBehaviour because then I need to fill my scene with dummy GameObjects for every layer of noise. I also think these would be difficult to simplify and serialize. It is possible Ill make the parent class that combines and stores these layers a ScriptableObject because I'd like the combination to be a savable asset without too much trouble but I'm leaning towards using JSON for the whole saving and loading of layers.

  3. Use the enum approach but just include a bunch of null variables that are only ever read from if the correct type of layer is used. This isn't ideal for extensibility in the future because I'll need to update things in multiple places. It also means that if I add a new type of FieldGeneric3D with new variables I'll need to add those variables to the FieldLayer3D class which might break saving/loading for all the ones I've already saved. I also don't know much about writing my own editor or anything so going about and conditionally hiding all the unused variables sounds like something that could result in issues if I implement it wrong. Leaving them all always visible is not ideal because I need other people to be able to use this tool without too much confusion or reading of manuals.

  4. Some fancy custom editor shenanigans. Seems like it would take a long time to learn how to do properly, so I was trying to just make simple classes and hope Unity would understand my intent.

The ultimate question is this I suppose: How do I achieve what I want to achieve, within the Unity ecosystem in such a way as to be editable in the inspector, save/loadable, understandable by other people, and without future additions becoming a major headache?

Also, I have seen this How do I expose a field of an abstract type to the Inspector? question and the only thing I could see there of help was "Composition over Inheritance" but I'm not sure how that would be applied here.

To clarify, I am willing to do any of the four ideas I had if the answer is just that that is the best option available to me.

\$\endgroup\$

2 Answers 2

1
\$\begingroup\$

One possible solution is that instead of having it as a field in this class, have it as a separate monobehaviour component that inherits from an abstract class or interface. You can then get a reference to it using GetComponent(). GetComponent is a lot more friendly to interfaces and abstract classes.

So then instead of dragging a reference in the inspector, you add a brand new component next to it. And then that component could have its own specific references if needed.

I suspect this might be a possible solution that encompasses the idea of “composition over inheritance” since you are composing the behaviour using monobehaviour components that can be swapped out in a more Unity friendly way.

\$\endgroup\$
1
\$\begingroup\$

One simple way to get this behaviour out of the box is to make your FieldGeneric3D inherit from ScriptableObject:

public abstract class FieldGeneric3D : ScriptableObject {
    //These are the fields for "FieldLayer3D" 
    public abstract float FieldAt(Vector3 p);
}

Then for each subclass, add a menu item that lets you create an instance of that class.

[CreateAssetMenu(fileName = "NewSimplexField.Asset", menuName = "Field/Simplex")]
public class FieldSimplex3D : FieldGeneric3D {
    public int seed;
    public FieldSimplex3D(int seed) { this.seed = seed; }
    public override float FieldAt(Vector3 p) {
        NoiseGenerator noiseGenerator = new NoiseGenerator(seed);
        return noiseGenerator.Sample_Simplex(p);
    }
}

This will add a "Field" subfolder to the asset creation menu you get when you right-click in the project window, and you can create new asset files corresponding to fields there. Each one is an asset you can select like a texture, and configure its public/serialized properties in the inspector.

You no longer need the [SerializeReference] attribute, because ScriptableObject references are serialized automatically.

This also makes it easy to re-use the same field settings in different combinations, but the downside is that you need to make a new asset for every unique choice of field parameters. You also need to click on the individual assets to configure them, rather than doing so in-line within your array, though various folks have written extensions to add the in-line editing functionality.


An alternative is to write your own custom inspector for this FieldGeneric3D type that presents an interface to choose a subclass and draw that subclass's inspector fields. Here's a rough sketch of how that could be done, but it has room for improvement:

using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(FieldGeneric3D))]
public class FieldGeneric3DDrawer : PropertyDrawer
{
    // For a simple example, I'm hard-coding the field types as enum values.
    // A more robust approach would use reflection to find all matching types.
    public enum FieldType : int {
        None,
        FieldSimplex3D,
        FieldRadial3D,
        FieldBox3D
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        float height = EditorGUIUtility.singleLineHeight;

        if (property.isExpanded) {
            // Note where this property ends and the next one begins.
            var endMarker = property.Copy();
            endMarker.NextVisible(false);

            // Descend into the first nested child property.
            var nested = property.Copy();
            if (nested.NextVisible(true) && !SerializedProperty.EqualContents(nested, endMarker)) {
                do { // For each child property, accumulate its height.
                    height += EditorGUI.GetPropertyHeight(nested, nested.isExpanded);
                } while (nested.NextVisible(false) && !SerializedProperty.EqualContents(nested, endMarker));
            }
        }        

        return height;        
    }
    
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        // Using BeginProperty / EndProperty on the parent property means that
        // prefab override logic works on the entire property.
        EditorGUI.BeginProperty(position, label, property);           
        
        // Draw a drop-down to select what type of field we want.
        var currentType = GetTypeID(property);
        var newType = (FieldType)EditorGUI.EnumPopup(position, label, currentType);
        if (newType != currentType) {
            // If the user changes the field type, assign a new instance of that type.
            property.managedReferenceValue = MakeDefault(newType);                      
            property.serializedObject.ApplyModifiedProperties();
        }

        // Draw a fold-out if this type has child properties.
        if (property.hasVisibleChildren) {
            var foldZone = position;
            foldZone.height = EditorGUIUtility.singleLineHeight;
            property.isExpanded = EditorGUI.Foldout(foldZone, property.isExpanded, " ");

            // Draw those properties, if folded-out.
            if (property.isExpanded) DrawChildren(position, property);
        }       

        EditorGUI.EndProperty();
    }

    void DrawChildren(Rect position, SerializedProperty property) {
        // Inset our drawing inside the folded-out region.
        int indent = EditorGUI.indentLevel;
        EditorGUI.indentLevel++;

        position.y += EditorGUIUtility.singleLineHeight;
        position.height -= EditorGUIUtility.singleLineHeight;

        // Note where this property ends and the next one begins.
        var endMarker = property.Copy();
        endMarker.NextVisible(false);

        // Descend into the first child property.
        var nested = property.Copy();
        nested.NextVisible(true);
        do { // For each child property, draw its inspector widgets in a stack.
            EditorGUI.PropertyField(position, nested);

            // Advance the positioning of the next property by how much space this one took up.
            float height = EditorGUI.GetPropertyHeight(nested, nested.isExpanded);
            position.y += height;
            position.height -= height;
        } while (nested.NextVisible(false) && !SerializedProperty.EqualContents(nested, endMarker));

        // Return the indent to where we found it.
        EditorGUI.indentLevel = indent;
    }

    // Helper method to get the type of field as an enum value.
    static FieldType GetTypeID(SerializedProperty property)
    {
        var typeName = property.managedReferenceFullTypename;
        if (string.IsNullOrEmpty(typeName)) return FieldType.None;

        typeName = typeName.Substring(typeName.LastIndexOf(' ') + 1);

        return (FieldType)System.Enum.Parse(typeof(FieldType), typeName);
    }

    // Helper method to make a new instance of a field class given an enum type ID.
    static FieldGeneric3D MakeDefault(FieldType fieldType)
    {
        switch (fieldType)
        {
            case FieldType.FieldSimplex3D: return new FieldSimplex3D();
            case FieldType.FieldBox3D: return new FieldBox3D();
            case FieldType.None: return null;
            default: throw new System.NotImplementedException($"Unknown field type {fieldType}.");
        }
    }
}

Place this script in a folder called "Editor" and modify it to support your collection of field types. It will handle drawing appropriate controls when a reference to FieldGeneric3D is encountered in any inspector.

This method saves you from explicitly creating new assets for each field instance - instead, these instances are created implicitly. But this also means that the instances are not shared: if you have many scripts that all need the same base noise field, they'll each possess their own unique copy.

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .