Having singular, isolated Components isn’t the most exciting. Luckily, we have ways to control and connect Components together. Along the way, we’ll be introducing the last two lifecycle methods and the following concepts:
- inputs
- outputs
- events
- emits
- Paths
- Spies
Inputs, Outputs
Components have two properties representing a way to accept data and to output data called “inputs” and “outputs” respectively.
To demonstrate their use, we’ll create a new Component: Adder
. The Adder
will have 2 inputs, that will be summed together to create a new value for its output. In order to do so, it will need to respond to changes in the inputs by implementing the lifecycle: onInputsUpdated
.
- For efficiency,
onInputsUpdated
is called at most once per frame if the Component’s inputs have changed. It is called with the previous values of the inputs which can be used to compare the changes to the currentthis.inputs
object. If inputs have changed multiple times within a single tick or frame,onInputsUpdated
will still only be called once per frame.
class Adder {
// define 2 inputs: "arg1" and "arg2"
inputs = {
arg1: 0,
arg2: 0,
};
// define a single output: "sum"
outputs = {
sum: 0,
}
onInit() {
// calculate the initial sum
this.outputs.sum = this.inputs.arg1 + this.inputs.arg2;
console.log('initial sum', this.outputs.sum);
}
onInputsUpdated(prevInputs) {
// log the previous sum by using the previous inputs values in `prevInputs`
console.log('previous sum', prevInputs.arg1 + prevInputs.arg2);
// recalculate the sum
this.outputs.sum = this.inputs.arg1 + this.inputs.arg2;
console.log('updated sum', this.outputs.sum);
}
}
The Adder
Component
- has two inputs under its
inputs
object:arg1
andarg2
. - has a single output under its
outputs
object:sum
.- upon initialization, the component’s
onInit
function calculates the initialoutputs.sum
- we also provide a default directly in the
outputs
object
- upon initialization, the component’s
- implements an
onInputsUpdated
function to recalculate and log the newoutputs.sum
. - logs the previous sum using the
prevInputs
provided toonInputsUpdated
.
Running the Adder
To see this new component in action we will
- register it
- add it to a node with some intial input data
- set its inputs
- check its logs
// --- reuse code from the "intro" example that creates `sceneObject` and `node` ---
await mpSdk.Scene.register('adder', () => new Adder());
const adder = node.addComponent('adder', {
// this object is optional but sets the initial values of the Adder
arg1: 2,
arg2: 1,
});
sceneObject.start();
// set the inputs of `adder`
adder.inputs.arg1 = 40;
adder.inputs.arg2 = 2;
// ...some time later; this needs to be deferred until the next frame to get the updated sum
// we'll see how Spies can make this easier to manage later
console.log('outputs.sum', adder.outputs.sum);
Note: If no values are passed into the addComponent
for the initial inputs, the default values set in the class will be used (in the case of the Adder
arg1: 0, arg2: 0).
Things should now be a little more interesting. With the Adder
we should see a bit more interactivity through its logs.
- Our
Adder
computes and logs an initial sum from the initial inputs we provided inaddComponent
(3). - We updated the inputs and the component recalculated and logged the previous and the new sums (3 and 42), and updated its output automatically. Notice there was only one call to the
onInputsUpdated
even though we update two values at two different times. - Finally, we logged the output of the
Adder
from outside of the component.
Paths
Before introducing Events, we need to understand how to “bind” things together. In the previous example, we were setting input values manually, but binding values lets us update them automatically. Binding can be done between an input and an output and an emit and event. Binding is done through a concept called a “Path”. Paths exist within an Object and represent a path to a property in one of its Components, something like “node/adder/input/arg1”.
To demonstrate how to bind inputs and outputs we’ll first introduce a new Component, RNG
, that we will use to generate random numbers every second. Then we’ll use two RNG Components’ outputs to change the input of our Adder
. Finally, we will set up the onTick
lifecycle method to automatically compute the sum each frame.
class RNG {
timeSinceUpdate = 0;
inputs = {
interval: 1000,
};
outputs = {
value: 0,
};
onTick(delta) {
this.timeSinceUpdate += delta;
if (this.timeSinceUpdate >= this.inputs.interval) {
this.timeSinceUpdate = this.timeSinceUpdate % this.inputs.interval;
this.outputs.value = Math.random();
}
}
}
The Random Number Generator (RNG
) Component
- has an
inputs.interval
that defaults to 1 second (1000 milliseconds) - has an
outputs.value
for the values it will generate - uses an
onTick
to check if it needs to update its random value and does so if enough time has passed
Running the Timer and the new RNG
With the RNG Component, we can hook its output values directly into our Adder
inputs. To do this we will:
- Create and add two
RNG
Components and oneAdder
Component. - Create paths for the ouputs of each
RNG
(value) and the two inputs of theAdder
(arg1, arg2). - Bind the paths that we created together, to bind the Components’ inputs and ouputs.
// --- reuse code from the "intro" example that creates `sceneObject` and `node` ---
await sdk.Scene.register('adder', () => new Adder());
await sdk.Scene.register('rng', () => new RNG());
const adder = node.addComponent('adder');
const rng1 = node.addComponent('rng');
const rng2 = node.addComponent('rng');
// create paths to each of the RNGs' values
const randomOutput1 = sceneObject.addOutputPath(rng1, 'value');
const randomOutput2 = sceneObject.addOutputPath(rng2, 'value');
// create paths to the each of the inputs of the Adder
const adderInput1 = sceneObject.addInputPath(adder, 'arg1');
const adderInput2 = sceneObject.addInputPath(adder, 'arg2');
// bind the paths (inputs/outputs)
randomOutput1.bind(adderInput1); // we can bind an output to input ...
adderInput2.bind(randomOutput2); // ... or an input to an output (the result is the same)
sceneObject.start();
Things should look a lot more interesting now.
- Our
Adder
will now be outputting new sums every second. - We have two
RNG
Components generating and outputting values every second. - Most importantly, the
Adder
is automatically responding to any changes in its inputs which are bound to the outputs of theRNG
Components.
Events, Emits
Components can also emit and receive events. This is done through the use of events
and emits
. Events can carry a payload with them much like standard Javascript events.
To demonstrate how to send events between components, we’ll introduce a new Component Timer
that will “emit” every second through the use its notify
function. We’ll also refactor our RNG
Component so that it doesn’t track time, but is instead bound to and handles the emit from the Timer
through the final lifecycle we need to introduce: onEvent
.
onEvent
is called once for each event emitted (that this component handles).onEvent
is not called immediately but on the next tick/frame. This means that multipleonEvent
calls can happen within the same frame.
class Timer {
timeSinceUpdate = 0;
// define an event that can be emitted: "tick"
emits = {
tick: true,
}
constructor(interval) {
this.interval = interval;
}
onTick(delta) {
this.timeSinceUpdate += delta;
if (this.timeSinceUpdate >= this.interval) {
this.timeSinceUpdate = this.timeSinceUpdate % this.interval;
this.notify('tick', { /* This event has no payload but could be any arbitrary object */ });
}
}
}
class RNG {
outputs = {
value: 0,
};
// define an event that can be received: "regenerate"
events = {
regenerate: true,
};
onEvent(eventType, eventData) {
if (eventType === 'regenerate') {
this.outputs.value = Math.random();
console.log(eventData); // events receive data with them, though the 'regenerate' event doesn't use or expect one
}
}
}
With this refactor, we split out the timing functionality of the RNG
Component and created a separate Timer
Component.
The Timer
Component
- has a configurable interval that’s set at construction time (not as an input)
- emits a “tick” event through use of its
notify
The RNG
Component now
- uses
event.regenerate
andonEvent
to generate a new random number and set it as its output
With the new RNG
and the Timer
Components, it is possible to bind these Components’ emits and events. We’ll follow a similar pattern to what we used for inputs and outputs.
- setup all the boilerplate (register and add components)
- create the paths to the
RNG
’s event and theTimer
’s emit - bind the new paths
// --- reuse code from the "intro" example that creates `sceneObject` and `node` ---
await sdk.Scene.register('adder', () => new Adder());
await sdk.Scene.register('rng', () => new RNG());
const adder = node.addComponent('adder');
const rng1 = node.addComponent('rng');
const rng2 = node.addComponent('rng');
const randomOutput1 = sceneObject.addOutputPath(rng1, 'value');
const randomOutput2 = sceneObject.addOutputPath(rng2, 'value');
const adderInput1 = sceneObject.addInputPath(adder, 'arg1');
const adderInput2 = sceneObject.addInputPath(adder, 'arg2');
randomOutput1.bind(adderInput1);
randomOutput2.bind(adderInput2);
// --- new code starts here ---
await sdk.Scene.register('timer', () => new Timer(1000));
const timer = node.addComponent('timer');
// create new paths to the emit and the event
const tick = sceneObject.addEmitPath(timer, 'tick');
const regenRNG1 = sceneObject.addEventPath(rng1, 'regenerate');
const regenRNG2 = sceneObject.addEventPath(rng2, 'regenerate');
tick.bind(regenRNG1); // we can bind an emit to an event
regenRNG2.bind(tick); // ... or an event to emit (the result is the same)
sceneObject.start();
To the end-user there hasn’t been any change in behavior from the previous example but our code is now in much more manageable, reusable Components.
While a bit superficial, the example shows a few interesting concepts.
- Components can communicate between each other.
- The Timer we created can emit events to any number of other Components. A downside to this is that we have more Components to manage which involves more boilerplate: registering, adding, creating paths for, and binding them together.
- Events and Emits don’t need use the same property name in order to be bound together (“tick” and “regenerate” were used in our example)
Disabling Emits or Events
So far our examples have set their emits or events properties to true
. If at any time, a Component should no longer be able to emit events through notify
or receive events through its onEvent
, setting those properties to false
will disable those events.
// from here `timer` will no longer emit 'tick'
timer.emits.tick = false;
// from here `rng1` will no longer receive any 'regenerate' events
rng1.events.regenerate = false;
// we can also disable the emits through a path to it...
tick.disable();
// ... and re-enable it
tick.enable()
// and disable events though its path...
regenRNG1.disable();
// ... and re-enable it
regenRNG1.enable();
Aside: Additional Path Functionality
While we showed how to bind paths in a 1:1 relationship, paths have a few extra capabilities like reading inputs and outputs, manually updating inputs, disabling and enabling events and emits, and manually triggering onEvent
calls to components.
We can bind a single Output Path to multiple Input Paths.
randomOutput1.bind(adderInput1);
randomOutput1.bind(adderInput2);
However, binding multiple outputs to a single input is not allowed.
// this is invalid -- `adderInput1` is already bound
randomOutput1.bind(adderInput1);
randomOutput2.bind(adderInput1);
Input and Output Paths can be used to read their values. Input Paths also allow for setting their value.
console.log('RNG1 has an output of', randomOutput1.get());
console.log(`Adder's first input is`, adderInput1.get());
// manually settng Adder's second input
adderInput2.set(1234);
Similarly, with Event and Emit paths, multiple events (like outputs) can be bound to multiple emits (like inputs); however, unlike inputs and outputs, multiple emits can be bound to multiple events.
// bind multiple events (`regenRNG1`, `regenRNG2`) to a single emit (`tick`)
tick.bind(regenRNG1);
tick.bind(regenRNG2);
// bind a single event (`regenRNG1`) to multiple emits (`tick`, `tick2`) -- notice that `tick2` is a hypothetical path we haven't created thus far
regenRNG1.bind(tick);
regenRNG1.bind(tick2);
Event and Emit Paths can be used to trigger the onEvent
of any and all Components it’s associated with.
// event payloads are optional
tick.emit(/* ... */);
rng1.emit(/* ... */);
Spies
If at any time there’s a need to work outside of the Scene framework, like bridging into another framework, Spies can be used. Spies allow for code that’s not part of a Component to receive all the same events a Component normally would: changes to inputs or outputs, or when a Component emits or receives an event.
To demonstrate Spies, we’ll create several different Spies, one for each Path type, using several of the Components we’ve created up until now.
// spy on input changes to our Adder
// using class syntax
class AdderArg1Spy {
path = adderInput1;
onEvent(newValue) {
console.log(`spied change to "${this.path.property}" its new value is ${newValue}`);
}
}
sceneObject.spyOnEvent(new AdderArg1Spy());
// spy on an output from our Adder
const adderOutput = sceneObject.addOutputPath(adder, 'sum');
// using inline syntax
sceneObject.spyOnEvent({
path: adderOutput,
onEvent(newSum) {
console.log(`spied change to "${this.path.property}" its new value is ${newSum}`);
},
});
// spy on an emit from our Timer
sceneObject.spyOnEvent({
path: tick,
onEvent(eventData) {
console.log(`spied that an event "${this.path.emitName}" was emitted`);
},
});
// spy on an event
sceneObject.spyOnEvent({
path: regenRNG1,
onEvent(eventData) {
console.log(`spied "${this.path.eventName}" received with data ${eventData}`);
},
});
So, Spies are a small wrapper around Paths with a single extra onEvent
. As the properties of each of the Paths changes, the associated Spy’s onEvent
will be called with the new input/output value or data associated with the event/emit.
Source
View or download the source here
To run this example, import the startBoundComponents
function and pass the mpSdk instance to it.
import { startBoundComponents } from './basicComponent';
const mpSdk; // initialized from a successful `connect` call
startBoundComponents(mpSdk);
Continue Reading
Look forward to upcoming documentation on how to:
- Render 3D components
- Add interactivity