Fiber is a declarative library for creating games in Unity. It is derived and inspired by web libraries such as React and Solid.
- Declarative - Define what you want for particular state instead of defining how you want to create it.
- Component based - Create self contained components that can be reused in different contexts.
- Reactive - Signals are reactive primitives that makes it possible for Fiber to only update what needs to be updated.
- Extendable - Fiber is built to be extendable. Create your own renderer extension if there something that you natively are missing.
- More than UI - Fiber is not only for UI. It can be used to declare anything in your game, eg. any game object in your scene.
using System;
using UnityEngine;
using Fiber;
using Fiber.GameObjects;
using Fiber.Suite;
using Signals;
public class RotatingCubesExample : MonoBehaviour
{
[Serializable]
public class Materials
{
public Material CubeDefault;
public Material CubeHovered;
}
[SerializeField]
private Materials _materials;
public class CubeComponent : BaseComponent
{
private Vector3 _position;
public CubeComponent(Vector3 position)
{
_position = position;
}
public override VirtualBody Render()
{
var _ref = new Ref<GameObject>();
F.CreateUpdateEffect((deltaTime) =>
{
_ref.Current.transform.Rotate(new Vector3(25, 25, 25) * deltaTime);
});
var isHovered = new Signal<bool>(false);
var clicked = new Signal<bool>(false);
return F.GameObject(
name: "Cube",
_ref: _ref,
position: _position,
localScale: F.CreateComputedSignal((clicked) => clicked ? Vector3.one * 1.5f : Vector3.one, clicked),
primitiveType: PrimitiveType.Cube,
children: F.Children(
F.GameObjectPointerEvents(
onClick: () => { clicked.Value = !clicked.Value; },
onPointerEnter: () => { isHovered.Value = true; },
onPointerExit: () => { isHovered.Value = false; }
),
F.MeshRenderer(
material: F.CreateComputedSignal((isHovered) => isHovered ?
G<Materials>().CubeHovered : G<Materials>().CubeDefault,
isHovered
)
)
)
);
}
}
public class RotatingCubesComponent : BaseComponent
{
public override VirtualBody Render()
{
return F.GameObjectPointerEventsManager(F.Children(
new CubeComponent(new Vector3(1.2f, 0, 0)),
new CubeComponent(new Vector3(-1.2f, 0, 0))
));
}
}
void Start()
{
var fiber = new FiberSuite(rootGameObject: gameObject, globals: new()
{
{ typeof(Materials), _materials }
});
fiber.Render(new RotatingCubesComponent());
}
}
Disclaimer: This example is inspired and taken from @react/three-fiber. Since there is a lot of overlap between the projects, but they are operating in different tech stacks, it is interesting to compare how the 2 differ when rendering the same scene.
Add the package via Unity's package manager using the git url:
https://github.com/unity-atoms/fiber.git?path=/Assets/Fiber
See Unity's docs for more info.
FiberUtils
: Common utils and classes used by all other Fiber packages.Signals
: Reactive primitives. Depends on FiberUtils.Fiber
: The core declarative library. Depends on FiberUtils and Signals.Fiber.GameObjects
: GameObjects renderer extension. Depends on FiberUtils, Signals and Fiber.Fiber.UIElements
: UI Elements renderer extension. Depends on FiberUtils, Signals, Fiber and FiberGameObjects.Fiber.Router
: A router for Fiber. Depends on Signals and Fiber.Fiber.Suite
: A suite of all Fiber packages, exposing a convenient API for end users. Depends on all other Fiber packages.SilkUI
: A design system and component library for Unity powered by Fiber. Depends on FiberUtils, Signals, Fiber, FiberGameObjects and FiberUIElements.
Fiber is built upon reactivity and the ability to track changes to data.
It is possible to use Signals and Computed Signals in your game without using Fiber's renderer.
Signals are reactive primitives that wraps a value. It is possible to both retrieve and imperatively set the value of a signal. When a signal is updated, Fiber will only update the parts of the UI that depends on that signal.
Useful built-in signals:
Signal<T>
- A writeable signal.ShallowSignalList<T>
- A list as a signal. Changes of items in the list are not tracked.SignalList<T>
- A list as a signal where each item is a signal itself. Changes of items in the list are tracked.ShallowSignalDictionary<T>
- A dictionary as a signal. Changes of items in the dictionary are not tracked.SignalDictionary<T>
- A dictionary as a signal where each value is a signal itself. Changes of items in the dictionary are tracked.IndexedSignalDictionary<T>
- Same asSignalDictionary<T>
, but where each item also has an index. Useful for when you need to iterate over the dictionary witout allocating memory.StaticSignal<T>
- A read only signal.
Computed signals are signals that are derived from other signals. When a signal that a computed signal depends on is updated, the computed signal will also be updated. Computed signals are read only.
Useful built-in computed signals:
ComputedSignal<..., RT>
- A computed signal from 1 to many other signals.DynamicComputedSignal<..., DT, RT>
- A computed signal where the exact signal dependencies are not known at the time of creation of the signal. In other words, signal dependencies can be added and removed after the computed signal has been created.ComputedSignalsByKey<Key, KeysSignal, Keys, ItemSignal, ItemType>
- A computed signal dictionary where each value is in itself a computed signal. This is useful for more dynamic scenarios, eg. where we need a computed signal for each item in aSignalList<T>
.NegatedBoolSignal
- Computed signal that negates a bool signal.IntToStringSignal
- Computed signal that converts an int signal to a string signal.
A signal in itself can't be subscribed to directly. Instead, all signals have a dirty flag, called dirty bit. When a signal is updated, the dirty bit is incremented. Underlying primitives and systems (eg. effects or SignalSubscribtionManager
) are polling and checking if the dirty bit has changed. For example when a computed signal's value is read, it will check if the dirty bit of any of its dependencies have changed, and if it has it will recompute its value.
Effects takes one or more signals and calls a function each time a signal is updated. Effects are useful to perform side effects, eg. updating a game object's transform based on a signal. Note that effects are not called immediately when a signal is updated, but instead will be called by Fiber when there is time to do so, which most of the time is in the next frame.
Example of an effect that updates if a game object with a rigidbody is kinematic or not:
public class PhysicsObjectComponent : BaseComponent
{
BaseSignal<bool> IsKinematicSignal; // Created and set by a parent component
public PhysicsObjectComponent(BaseSignal<bool> isKinematicSignal)
{
IsKinematicSignal = isKinematicSignal;
}
public override VirtualBody Render()
{
var _ref = new Ref<GameObject>();
CreateEffect((isKinematic) =>
{
_ref.Current.GetComponent<Rigidbody>().isKinematic = isKinematic;
return null;
}, IsKinematicSignal, runOnMount: true);
return F.GameObject(_ref: _ref, getInstance: () =>
{
var go = new GameObject();
go.AddComponent<Rigidbody>();
return go;
});
}
}
Rendering is the process of taking virtual nodes (user defined components of built-ins) and create native nodes. Native nodes are objects that wrap native Unity entities, eg. GameObject
or VisualElement
.
The entry point for rendering can easiest be defined using Fiber.Suite
:
var fiber = new FiberSuite(rootGameObject: gameObject, defaultPanelSettings: _myDefaultPanelSettings);
fiber.Render(new MyComponent());
It is possible to define several entries in the same app in order to just Fiber in different smaller parts of your app. This can be useful if you for example want to gradually migrate an existing app to Fiber.
Components are self contained and re-useable pieces of code that defines one part of your app.
All built-in components can be added via the F
property, eg. F.GameObject
.
A user defined component uses built in components and other user defined components to define a part of your app. The component can be re-used in other components and in multiple places in your app.
Components can be nested to create a tree and a hierarchy of components. The children of a component are defined by the children
prop. The component itself should not care what children it renders, just where they are rendered.
Simple example panel component using the children
prop:
public class PanelComponent : BaseComponent
{
public PanelComponent(List<VirtualNode> children) : base(children) { }
public override VirtualBody Render()
{
return F.View(
style: new Style(marginRight: 10, marginBottom: 10, marginLeft: 10, marginTop: 10, backgroundColor: Color.magenta),
children: children
);
}
}
Example of using the above component adding different children to each instance of the panel:
public class MyPageComponent : BaseComponent
{
public override VirtualBody Render()
{
return F.Fragment(
F.Children(
new PanelComponent(F.Children(F.Button(text: "Button 1", onClick: (e) => { Debug.Log("Button 1 clicked"); }))),
new PanelComponent(F.Children(
F.Button(text: "Button 2", onClick: (e) => { Debug.Log("Button 2 clicked"); }),
F.Button(text: "Button 3", onClick: (e) => { Debug.Log("Button 3 clicked"); })
)),
new PanelComponent(F.Children(F.Button(text: "Button 4", onClick: (e) => { Debug.Log("Button 4 clicked"); })))
)
);
}
}
A Fragment is a component does not render anything itself, but instead renders its children directly. This is useful when you want to return multiple components from a component, eg. when you want to return a list of components from a component.
F.Fragment(children);
Context is useful to pass values down the component tree without having to pass it down as props. A context can be defined like this:
var intSignal = new Signal<int>(5);
var myContext = new MyContext(intSignal);
F.ContextProvider<MyContext>(value: myContext, children: children);
The above context can be accessed in any child component like this:
var myContext = GetContext<MyContext>();
// Alternatively the shorthand can be used:
var myContext = C<MyContext>();
Globals are references that are injected from the outside and can be accessed from any component. Globals are useful to pass down references to services or other objects that are not part of the component tree.
Globals are injected when creating a FiberSuite
instance:
var myService = new MyService();
new FiberSuite(
rootGameObject: gameObject,
globals: new()
{
{typeof(MyService), myService},
}
);
The above global can be accessed in any child component like this:
var myService = GetGlobal<MyService>();
// Alternatively the shorthand can be used:
var myService = G<MyService>();
Component that renders a game object.
F.GameObject(name: "MyGameObject", children: children);
Component that renders a game object with a UIDocument
component.
F.UIDocument(children: children);
Component that renders a VirtualElement.
F.View(children: children);
Component that renders a Button.
F.Button(style: new Style(color: Color.black, fontSize: 20), text: "Click me", onClick: (e) => { Debug.Log("Clicked!"); });
Component that renders a TextElement.
F.Text(style: new Style(color: Color.black, fontSize: 20), text: "Hello world!");
Component that renders a TextField.
var textFieldSignal = new Signal<string>("Foo");
F.TextField(value: textFieldSignal, onChange: (e) => { textSignal.Value = e.newValue; });
Component that renders a ScrollView.
F.ScrollView(children: F.Children(
F.View(className: F.ClassName("tall-container"))
));
Control flow components are built-in components that will efficiently alter what is rendered based on state.
This component enables or disables underlying nodes and their effects to react to signal updates.
var enableSignal = new Signal<bool>(true);
F.Enable(when: showSignal, children: F.Children(F.Text(text: "Hello world!")));
This component makes underlying native nodes visible or hidden.
var visibleSignal = new Signal<bool>(true);
F.Visible(when: visibleSignal, children: F.Children(F.Text(text: "Hello world!")));
This component is a composition of the Enable
and Visible
components above.
var activeSignal = new Signal<bool>(true);
F.Active(when: activeSignal, children: F.Children(F.Text(text: "Hello world!")));
This component renders and mounts a component based on a signal value.
NOTE: Compareable to solidjs's Show
component.
var showSignal = new Signal<bool>(true);
F.Mount(when: showSignal, children: F.Children(F.Text(text: "Hello world!")));
Renders a list of components based on a signal list. Each item in the list needs a key, which uniquely indentifies an item.
var todoItemsSignal = new ShallowSignalList<TodoItem>(new ShallowSignalList<TodoItem>());
For<TodoItem, ShallowSignalList<TodoItem>, ShallowSignalList<TodoItem>, int>(
each: todoItemsSignal,
children: (item, i) =>
{
return (item.Id, F.Text(text: item.Text));
}
);
The following sections describes how Fiber works under the hood.
In its essence, Fiber is building and maintaining a tree structure of nodes, which represents what currently is present in your scene. The tree is made up of so called Fiber nodes, which holds information about its parent, child and direct sibling. This info makes it easy to iterate the tree. The Fiber node can also hold a reference to a native node, which is a node wrapping a native object, such as a GameObject
or a VisualElement
. It also holds a reference to a virtual node, which is the underying component that was used to create the Fiber node.
Fiber has a work loop that runs every frame. The work loop prioritize and performs some units of work:
- Rendering - The most prioritized work which executes the
Render
method of a component pending to be rendered. Note that rendering will create underlying native nodes, but nodes are not added to the tree yet and are set to not be visisble. - Mount - Mounting is the process of adding a node to the tree and making it visible.
- Unmount - Unmounting is the process of removing a node from the tree and making it invisible.
- Move - Moving is the process of moving a node in the tree.
- Node work loop - Runs update on the nodes in tree, which trigger effects if there are any pending and updates props tied to signals.
There is a time budget for the work loop (which is configureable). If the time budget is exceeded, the work loop will yield and continue the next frame.
A Fiber node is during its lifespan in different phases. Phases are chronlogical to the order of definitionm which means that Fiber nodes never can go back to a previous phase. The phases are:
AddedToVirtualTree
- Initial phase for when the node is created.Rendered
- A node is set toRendered
after Fiber has rendered the node, eg created an underlyng game object.Mounted
- A node is set toMounted
after Fiber has mounted the node.RemovedFromVirtualTree
- A node is set toRemovedFromVirtualTree
when Fiber has decided to remove it. This action also sets the underlying native node to be not visible.Unmounted
- A node is set toUnmounted
when Fiber has unmounted the node.