NodeBestPractices

Previous: Manifests | Table of Contents

Node Design Best Practices

This guide contains guidelines for creating nodes that will play nice with the rest of the other nodes and graph system.

Have a suggestion on a best practice? Feel free to open up a Issue/PR and start a discussion.

Keep Things Immutable

A node should never make modifications to both the input provided to it, or the output it produces after it's been returned. Breaking immutability removes the garuntee that a graph's output is reproducable.

Avoid Pointers

Avoiding the use of pointers both aids in the effort of keeping things immutable, as well as simplifies the datatypes a node needs to account for.

So instead of this:

type MathNode struct {
	A nodes.Output[*float64]
	B nodes.Output[*float64]
}

Prefer:

type MathNode struct {
	A nodes.Output[float64]
	B nodes.Output[float64]
}

Never Panic

Your node outputs should never panic. Doing so halts the execution of the graph, preventing any output from being produced and shown to the user. If you want communicate that something is wrong with the configuration of the node, utilize the CaptureError method of the nodes.StructOutput, and return something "sensible".

func (mn MathNode) Divide(out *nodes.StructOutput[float64]) {
    a := nodes.TryGetOutputValue(mn.A, 0)
    b := nodes.TryGetOutputValue(mn.B, 0)

    if b == 0 {
        out.CaptureError(errors.New("can't divide by 0"))
        return
    }

    out.Set(a / b)
}

Make Math Generic When Sensible

There's a lot of basic math operations that can operate over different datatypes. In these scenarios, you can make your nodes generic to take into account of those valid datatypes by using vector.Number. This is especially useful for vector math.

So instead of this:

type MathNode struct {
	A nodes.Output[vector3.Float64]
	B nodes.Output[vector3.Float64]
}

Prefer

type MathNode[T vector.Number] struct {
	A nodes.Output[vector3.Vector[T]]
	B nodes.Output[vector3.Vector[T]]
}

Prefer float64 and int Data Types

In golang, int8, int16, int, int32, int64, float32, float64 are all valid datatypes you can do all basic arthimetic with. It'd be messy to create versions of nodes for all of these different datatypes. For that reason, you should opt for using float64 when you need floating point data, and int when you want to restrict your input to whole numbers.

Emulate No-op for Undefined Behaviour

When defining nodes meant to "act on a thing", and the node is missing appropariate data to perform it's action, the output of the node should be the "thing" untouched.

For example, if we're scaling a vector by some amount, but no amount has been specified, then we just return the vector untouched.

type ScaleNode struct {
    Vector nodes.Output[vector3.Float64]
    Amount nodes.Output[float64]
}

func (sn ScaleNode) Scale(out *nodes.StructOutput[vector3.Float64]) {
    vector := nodes.TryGetOutputValue(out, mn.Vector, vector3.Zero[float64]())

    if sn.Amount == nil {
        out.Set(vector)
        return
    }

    amount := nodes.GetOutputValue(out, mn.Amount)
    out.Set(vector.Scale(amount))
}

Avoid Using Unessessary Input

When a node's configuration lends itself to not requiring all it's input, then we should avoid referencing it to avoid unessessary computation.

So instead of this:

func (mn MathNode) Divide(out *nodes.StructOutput[float64]) {
    a := nodes.TryGetOutputValue(mn.A, 0)
    b := nodes.TryGetOutputValue(mn.B, 0)

    if b == 0 {
        out.CaptureError(errors.New("can't divide by 0"))
        return
    }

    out.Set(a / b)
}

Since the A value isn't nessessary when B is 0, we can avoid an unessessary call to A.

func (mn MathNode) Divide(out *nodes.StructOutput[float64]) {
    b := nodes.TryGetOutputValue(mn.B, 0)
    if b == 0 {
        out.CaptureError(errors.New("can't divide by 0"))
        return
    }

    a := nodes.TryGetOutputValue(mn.A, 0)
    out.Set(a / b)
}

Last updated