Scalar Fields And Systemic Design

Published July 31, 2020
Advertisement

For the Unity project using this code, please see here: https://github.com/Ratstail91/Chemistry/releases/tag/scalar-fields

Please note that 1 pixel is equal to 1 unity unit - this is unrelated to scalar fields; it just feels better to me.

In this post, I'd like to outline how I coded my scalar fields for a systemic game prototype.

First, you'll need to know about systemic games - for that, I highly recommend you watch this video by Mark Brown: https://www.youtube.com/watch?v=SnpAAX9CkIc

Now that you're caught up, lets begin with the core concepts:

What is a scalar field?

In physics, there are a number of “fields” which overlap and interact with each other, existing at every point in the universe - one for every type of particle. This was my inspiration for scalar fields.

Scalar fields are two-dimensional grids of GameObjects that stretch across the (currently tiny) 2D game world. Each “cell” in the field's grid has a floating point value which can be interpreted by the game - however the value of each cell is affected by it's neighboring cells - automatically creating a sort of circus-tent shape of values surrounding sources of high-values. You can see in the gif above that the fire and torches create circular areas of light - this lighter area is caused by a higher value in the light field.

I've dubbed objects that set a specific value in the field “emitters” and objects that react to a field “receptors”. Fields, cells, emitters and receptors are all that's needed for the above example. Let's dig into some code:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ScalarField<T> : MonoBehaviour {
	public Dictionary<(int, int), ScalarCell<T>> cells = new Dictionary<(int, int), ScalarCell<T>>();

	public float cellWidth = 32f;
	public float cellHeight = 32f;

	//the prefabs
	[SerializeField]
	GameObject cellPrefab = null;

	public virtual void Start() {
		SpawnCells(-5, -5, 5, 5);
	}

	public virtual void SpawnCells(int xLower, int yLower, int xUpper, int yUpper) {
		for (int i = xLower; i < xUpper; i++) {
			for (int j = yLower; j < yUpper; j++) {
				//spawn if it doesn't exist
				if (!cells.ContainsKey((i, j))) {
					GameObject go = Instantiate(cellPrefab, new Vector3(i * cellWidth, j * cellHeight, 0f), Quaternion.identity, transform);
					cells[(i, j)] = go.GetComponent<ScalarCell<T>>();

					//initialize the cell
					cells[(i, j)].position = new Vector2Int(i, j);
				}
			}
		}
	}
}

Firstly, you'll notice that "ScalarField" is a generic type. This is what allows me to re-use the same code in different systems - more on that later.

The Dictionary uses a tuple of two integers as a key, with a ScalarCell<T> as it's values. I decided to use a Dictionary instead of a plain Array so that I could generate the grid of cells as needed. “cellWidth” and “cellHeight” are simple - how widely spaced do you want the cells? These should match the cell's GameObject sizes. I have a field for the cell prefab and a Start() function to spawn a simple array by default. SpawnCells only spawns and initializes a new cell where one with that key doesn't already exist in the Dictionary. Finally, I save a reference to that cell's ScalarCell component into the dictionary and initialize the ScalarCell's position field. All pretty simple so far.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ScalarCell<T> : MonoBehaviour {
	//the usable values
	public Vector2Int position = new Vector2Int(0, 0);

	//the field properties
	public float RealValue = 0f;
	public float NextValue = 0f;
	public float EmitterValue = 0f; //set externally

	const float epsilon = 0.001f;

	ScalarField<T> field;

	public virtual void Start() {
		field = GetComponentInParent<ScalarField<T>>();
	}

	public virtual void FixedUpdate() {
		CalculateRealValue();
	}

	public virtual void CalculateRealValue() {
		//emitter propping the scalar field up here
		if (EmitterValue != 0f) {
			RealValue = EmitterValue;
			NextValue = EmitterValue;
			return;
		}

		//set to the value calculated last frame
		RealValue = NextValue;

		//calculate the next value based on neighbouring cells' current value
		float next = 0f;

		const float cardinal = 0.75f;
		const float diagonal = 0.25f;

		//cardinal
		next += GetCellRealValueOrZero(position.x-1, position.y) * cardinal;
		next += GetCellRealValueOrZero(position.x+1, position.y) * cardinal;
		next += GetCellRealValueOrZero(position.x, position.y-1) * cardinal;
		next += GetCellRealValueOrZero(position.x, position.y+1) * cardinal;

		//diagonal
		next += GetCellRealValueOrZero(position.x-1, position.y-1) * diagonal;
		next += GetCellRealValueOrZero(position.x+1, position.y-1) * diagonal;
		next += GetCellRealValueOrZero(position.x-1, position.y+1) * diagonal;
		next += GetCellRealValueOrZero(position.x+1, position.y+1) * diagonal;

		next /= 4f;

		//finally, calculate how different from the entropy the cell should be next frame
		NextValue = Mathf.Abs(next) > epsilon ? next : 0f;
	}

	//utility
	float GetCellRealValueOrZero(int x, int y) {
		if (field.cells.ContainsKey((x, y))) {
			return field.cells[(x, y)].RealValue;
		} else {
			return 0f;
		}
	}
}

The ScalarCell is also a generic type, and is more complex. First, it has it's own position within the grid, three fields for RealValue, NextValue and EmitterValue, a constant epsilon (simply an arbitrarily small number) and a reference to it's parent field. The Start() method simply grabs that parent field.

Please note that CalculateRealValue() is actually called in FixedUpdate() and not Update() - we don't need it every single second, only once a frame. You can change this if needed.

CalculateRealValue() is where the magic begins. First, it checks for an emitter value (which is set elsewhere, particularly in it's children), sets RealValue and NextValue and exits if it's not 0. This is actually how we interact with the field - by setting the EmitterValue later in this article. Then, we set the RealValue to equal NextValue, which was calculated last frame.

Next, we calculate a weighted average from the neighboring cells in the field - we value cardinal cells more than diagonal cells, because of reasons. We also wrap the accessing of the neighboring cells in GetCellRealValueOrZero() to ensure that, when processing cells in the edges of the field, that we don't accidentally access a cell that doesn't exist. Finally, we check the value of next against the epsilon and, of it's below the epsilon, simply set NextValue to 0; otherwise we set NextValue to the weighted average.

OK, this was the most complex part of the system - but what you should be taking away is that it takes a whole frame for the value of the cell to be correct - we push the value to the next frame using NextValue instead. This ensures that all of this cell's neighbors are accessing the same current value, both before and after this cell has processed. I should note that, although I know nothing about Unity's ECS system, processing the layers upon layers of massive fields might be more effective in that system.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ScalarEmitter<T> : MonoBehaviour {
	public float Value = 0f;
}

After ScalarCell, let's have a breather with ScalarEmitter. You'll also notice that this is a generic type - in fact all of the base classes are. This is to allow different emitters to interact with their own fields only. So you can have these, for example:

public class Light {}
public class Heat {}

public class LightScalarEmitter : ScalarEmitter<Light> {
	//EMPTY
}

public class HeatScalarEmitter : ScalarEmitter<Heat> {
	//EMPTY
}

This is how we use both ScalarEmitter<T> and ScalarReceptor<T> - by creating empty types that can be differentiated by each other. Generics are a marvelous thing.

Finally, for completion, let's look at ScalarReceptor:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ScalarReceptor<T> : MonoBehaviour {
	public float Value {
		get {
			return lastScalarCell != null ? lastScalarCell.RealValue : 0f;
		}
		private set {
			//
		}
	}

	ScalarCell<T> lastScalarCell;

	public virtual void OnTriggerEnter2D(Collider2D collider) {
		ScalarCell<T> contact = collider.gameObject.GetComponent<ScalarCell<T>>();

		if (contact != null) {
			lastScalarCell = contact;
		}
	}
}

This simply tracks the last ScalarCell it touched, and subs in that cell's RealValue for it's own Value. This will be useful in a moment. You should also note that ScalarCells need to have trigger BoxCollider2Ds on their prefab objects.

This is the entirety of the scalar field system; what you do with it is up to you. In the example at the top of this article, I'm using two copies of the system layered over one another - one for light, which acts by changing it's opacity according to the light level (with an additional ambiant light feature thrown in for good measure) and one for the heat, which causes the bushes to turn into fire objects when the player's torch get's too close.

Light Field Practical Example

Let's have a look at the light and heat examples:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class LightScalarField : ScalarField<Light> {
	public int width = 16;
	public int height = 11;

	public override void Start() {
		SpawnCells(-width / 2, -height / 2, width /2, height / 2);
	}
}

This LightScalarField inherits from the base ScalarField class, and passes the previously defined Light class in as it's generic field. It also overrides the Start() function, and exposes width and height values that I can use to tweak the size in the inspector.

public class LightScalarCell : ScalarCell<Light> {
	//components
	SpriteRenderer spriteRenderer;

	//TODO: opacity as a receptor?
	const float maxEmitterValue = 10f;
	float realEmitterValue = 0f;

	void Awake() {
		spriteRenderer = GetComponent<SpriteRenderer>();
	}

	void Update() {
		//update opacity
		float opacity = 1 - Mathf.Clamp(RealValue + LightAmbiance.Value, 0f, 1f);
		spriteRenderer.color = new Color(1, 1, 1, opacity);
	}

	void OnTriggerEnter2D(Collider2D collider) {
		LightScalarEmitter emitter = collider.gameObject.GetComponent<LightScalarEmitter>();

		if (emitter != null) {
			realEmitterValue += emitter.Value;

			EmitterValue = Mathf.Clamp(realEmitterValue, 0f, maxEmitterValue);
		}
	}

	void OnTriggerExit2D(Collider2D collider) {
		LightScalarEmitter emitter = collider.gameObject.GetComponent<LightScalarEmitter>();

		if (emitter != null) {
			realEmitterValue -= emitter.Value;

			EmitterValue = Mathf.Clamp(realEmitterValue, 0f, maxEmitterValue);
		}
	}
}

The LightScalarCell actually places a cap on what value the light can reach - 10 units. This particular prefab actually has a sprite attached as well (a black square), the opacity of which is determined and set in Update(). Finally, This also uses a trigger BoxCollider2D to track LightScalarEmitters entering and exiting it's bounds, and alters the EmitterValue accordingly (within it's max value).

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public static class LightAmbiance {
	static float _value = 1f;

	public static float Value {
		get {
			//_value = 2 <-> -0.5
			_value = Mathf.Abs(Time.time / 60 % 2f - 1) * 2.5f - 0.5f;

			return _value;
		}
	}
}

For completion's sake, here is the LightAmbiance class. It's unrelated to ScalarFields in general, but can be modified by you to raise or lower the ambiance of the room (sidenote: Ambiance is misspelled in the github tag… oops). Currently, it powers a day-night cycle over about 2 minutes.

Heat Field Practical Example

Finally, let's look at the invisible heat field:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class HeatScalarField : ScalarField<Heat> {
	public int width = 16;
	public int height = 11;

	public override void Start() {
		SpawnCells(-width / 2, -height / 2, width /2, height / 2);
	}
}

This is identical to the LightScalarField.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class HeatScalarCell : ScalarCell<Heat> {
	const float maxEmitterValue = 99f;
	float realEmitterValue = 0f;

	void OnTriggerEnter2D(Collider2D collider) {
		HeatScalarEmitter emitter = collider.gameObject.GetComponent<HeatScalarEmitter>();

		if (emitter != null) {
			realEmitterValue += emitter.Value;

			EmitterValue = Mathf.Clamp(realEmitterValue, 0f, maxEmitterValue);
		}
	}

	void OnTriggerExit2D(Collider2D collider) {
		HeatScalarEmitter emitter = collider.gameObject.GetComponent<HeatScalarEmitter>();

		if (emitter != null) {
			realEmitterValue -= emitter.Value;

			EmitterValue = Mathf.Clamp(realEmitterValue, 0f, maxEmitterValue);
		}
	}
}

HeatScalarCell is simpler than LightScalarCell, since it lacks any sort of visual cues. It also has as much higher maximum cap, since there's no visual issues caused by extreme heat.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class HeatScalarReceptor : ScalarReceptor<Heat> {
	public bool combustible = false;
	public GameObject combustResult = null;
	public float combustThreshold = 1f;

	void Update() {
		if (combustible &amp;&amp; Value >= combustThreshold) {
			Instantiate(combustResult, transform.position, transform.rotation, transform.parent);
			Destroy(gameObject);
		}
	}
}

Finally, the key piece of the heat field - the heat receptor. When this is attached to something, it can optionally “combust”. That is, when it detects that the heat is too high, it spawns another object (for example, fire) in it's current location and destroys itself.

Conclusion

These two systems, when overlaid and connected via intractable items in the world, create a realistic response.

Stick
Torch

Here we have the inspector showing a regular stick, which is combustible, with a combust threshold of 1 unit. So if the heat reaches 1 or higher, it will turn into a torch (literally lighting a stick on fire). The second image is the torch, which emits 3 units of light and 1 unit of heat.

The gif at the beginning of the article shows bushes catching fire sequentially, before burning out (a simple destroy-self-after coroutine). Hopefully you now understand exactly how it works, and can see the potential uses for ScalarFields. Some suggestions? How about rain, or poison, or magical aether used as fuel for spells?

This is only one element of my systemic game, but I plan on adding many more very soon - things like wolves with burnable fur, ghosts that shy away from light but drop powerful items, and caves that you can explore, but you need to keep lit else you'll be eaten by a grue.

1 likes 0 comments

Comments

Nobody has left a comment. You can be the first!
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Advertisement

Latest Entries

Advertisement