SpellCasting - News Update Week 38

It's time for another update!
So what happened since my last post? Actually a lot but let me summarize:
- Node editor UX
- Adding more inputs to nodes which were fixed values before (like targeting)
- SpellCaster got nodes support
- Interface/Tooltips got nodes support
- Overhaul of the node serializer
- Performance testing of 2 different approaches to process nodes (forward vs backwards)

Node editor UX
I've added default inputs for nodes. What was a big FixedValue node before (can be seen in my previous post) is now just a small value box that is directly attached to the node. Adding default inputs also required handling various events like when you want to override these inputs with other nodes. It was kind of tricky to get right but GraphView, UITK and USS works nicely together for things to be customized very well.

Short video that shows it off:



Spellcaster/Interface/Tooltips node support
Spells and effects were already using nodes but the spell caster itself wasn't. So I rewrote the necessary spell caster parts for it. Once that was done the interface was also required to handle nodes. Specifically tooltips and showing all the requirements a spell needs, so it can be cast. Happy to say that I found a way to efficiently calculate them. As effects can affect every possible input it was important to work in realtime and show the player the current values and not some outdated ones.
For the spellcasting and interface spell requirements I introduced new node groups to be as efficient as possible and not calculate nodes which aren't necessary. Which also lead me to a cascading problem.

Node Serializer overhaul
I made an abstraction which I knew wasn't gonna hold but everything went alright up to a certain point, so I didn't bother. Every requirement (cast time, range, ...) was getting a NodeSerializerType.Requirement but actually there were 4 groups (CheckRequirement, Update, Finish and Interface) which were for the spellcaster stages or the interface. The nodes itself registered in these groups so there wasn't any trouble until I added DynamicTargeting and the input for TargetType (Friendly, Hostile, OnlySelf)

DynamicTargeting is a mechanic and resides in the mechanics stack. But spellcasting and the interface needs information about the TargetType because it's important if the spell is even allowed, or in case of OnlySelf that the current target gets overriden by the entity which is casting.
And even that wasn't a problem because I could just register it in the correct node group when the DynamicTargeting node is built. BUT! Here it comes. In and Outputs read and write to a stack with a precalculated byte offset. This byte offset for TargetType wasn't the same when DynamicTargeting is processed during the CalculateSpell stage and the SpellCasting stage as they were in different node groups.
So this whole cascading problem boiled down to my (wrong) assumption that any in/output would have just one clearly defined byte offset.
Before I rewrote the crucial parts to support it I gave myself enough time to think the new design through. Let me tell you, rewriting 70+ nodes is exhausting. The new serialize model is much more stable now and I got rid of the NodeSerializerType. Every node can clearly define now in which group it is and nodes which actually don't know in which groups they are (every input, logic, math, etc...) calculates based on connections where they should end up.
I think this approach is really solid and I'm quite happy with the results.

Performance testing
This was kind of an interesting one. Basically there are 2 ways to process a node graph:
- just moving forward linearly, storing results in a thread local custom stack data container
- moving backwards, resolving node inputs, calling the appropriate methods and returning the values. all stack based.

As an example. A multiply node with 2 fixed inputs A and B:
- forward would process A and B first and store the 2 values in the stack, then the multiply node is processed, reads the offset and then A and B from the stack, then stores the result in the stack
- backwards would process the multiply node first, resolve the input for A and B, call the methods, get A and B returned and then calculate it.

I've implemented forward processing in the beginning, mostly because I wanted to avoid calculating nodes over and over again. Like when an AoE effect hits 50 enemies, don't call the node to get the power value of the source 50 times but just once.
During runtime and actual gameplay this was enough reason for me to stick to this forward approach but to be honest, I had my doubts for single targets or spells/effects where this optimization would not be the case. I thought, the backwards, truly stack based approach must be superior.

So, when in doubt. Performance test.

    
        // backwards
public unsafe struct TestNodeMultiply
{
	public NodeResolver InputA;
	public NodeResolver InputB;
 
	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	public NZValue Execute(ref byte* ptr)
	{
		ptr += UnsafeUtility.SizeOf();
	   
		var a = NodeResolver.Resolve(ref ptr);
		var b = NodeResolver.Resolve(ref ptr);
	   
		return new NZValue() { Value = a.Value * b.Value };
	}
}
 
switch (resolveType)
{
	case TestNodeType.None:
		return default;
	case TestNodeType.FixedValue:
		ref var fixedNode = ref UnsafeUtility.AsRef(ptr);
		return fixedNode.Execute(ref ptr);
	case TestNodeType.Multiply:
		ref var multiplyNode = ref UnsafeUtility.AsRef(ptr);
		return multiplyNode.Execute(ref ptr);
	case TestNodeType.SpellAmount:
		ref var amountNode = ref UnsafeUtility.AsRef(ptr);
		return amountNode.Execute(ref ptr);
	default:
		throw new ArgumentOutOfRangeException();
}
 
// forward
switch (resolveType)
{
	case TestNodeType.None:
		break;
	case TestNodeType.FixedValue:
		ref var fixedValueNode = ref ReadFromNode(ref ptr);
		WriteToLocalStack(fixedValueNode.OutputIndex, fixedValueNode.Value);
		break;
	case TestNodeType.Multiply:
		ref var multiplyNode = ref ReadFromNode(ref ptr);
		var valA = ReadFromLocalStack(multiplyNode.InputIndexA);
		var valB = ReadFromLocalStack(multiplyNode.InputIndexB);
 
		WriteToLocalStack(multiplyNode.OutputIndex, valA.Value * valB.Value);
 
		break;
	case TestNodeType.SpellAmount:
		ref var amountNode = ref ReadFromNode(ref ptr);
		scaleValue = ReadFromLocalStack(amountNode.InputIndex);
 
 
		break;
	default:
		throw new ArgumentOutOfRangeException();
}
    

Turns out the forward method is quite a bit faster. Most of the time it's nearly 2x faster!
I didn't invest too much into finding out why that is. One hunch I had was that the missing inlining is a problem but even inlined it performed the same.

Well, that's it for the update. Thanks for reading!

Leave a comment

Please note that we won't show your email to others, or use it for sending unwanted emails. We will only use it to render your Gravatar image and to validate you as a real person.