Custom components
In this chapter we are going to see how to create custom components. To create a custom component we need to create a class that extends Component directly or indirectly. With this we will be able to use the component, but to load it from a scene we also need to register it.
Note: generally scene files are saved from the editor in C++. If we want to load components, we will also have to implement the equivalent C++ part.
1. Initial project
Section titled “1. Initial project”As an initial project we are going to use scene_from_scratch. Copy the folder as it is, changing the name (I have used custom_component). Then modify the package name in package.json, and run npm install to update possible internal links of the node_modules folder
custom_component/package.json
{ "name": "custom_component", ...2. Create a component
Section titled “2. Create a component”2.1. Class scene.Component
Section titled “2.1. Class scene.Component”Components can be used for several things:
-
Add functionality
-
Store extra data in a node
-
Capture user input
To implement these functions, the Component class provides a series of functions that we can override. These functions correspond to each of the scene events:
-
async init(): Promise<void>: It is called once during the component initialization. -
willUpdate(delta: number): void: It is called before executingupdate()on the node. If we want to modify the component’s transformation we must do it in this function, so we will leave the transformation ready to be executed in the next step, which is theupdate()function. If we do it in theupdate()function, nobody guarantees us that the order of operations will be correct: it could happen thatupdate()ofTransformis executed before that of our component. -
update(delta: number, modelMatrix: Mat4): void: It is called during the execution ofupdate()on that node. -
draw(renderQueue: RenderQueue, modelMatrix: Mat4): void: It is called during the component’s drawing. We are not going to use this function, because it involves low-level draw calls that go too far from the course objectives. -
Event functions: they are called when the user generates the associated input event. It is important to understand that events do not discriminate by node, but they are sent to all components in the scene. For example, one could think that if we implement a component, the event
mouseDownwould only reach a component when the user clicks on aDrawablethat is a sibling of the component we are implementing, but that is not the case: events reach all components in the scene. To implement these functions we have to make use of the selection manager, which we will see in another class.-
keyDown(evt: Bg2KeyboardEvent): void -
keyUp(evt: Bg2KeyboardEvent): void -
mouseUp(evt: Bg2MouseEvent): void -
mouseDown(evt: Bg2MouseEvent): void -
mouseMove(evt: Bg2MouseEvent): void -
mouseOut(evt: Bg2MouseEvent): void -
mouseDrag(evt: Bg2MouseEvent): void -
mouseWheel(evt: Bg2MouseEvent): void -
touchStart(evt: Bg2TouchEvent): void -
touchMove(evt: Bg2TouchEvent): void -
touchEnd(evt: Bg2TouchEvent): void
-
The event names are quite self-explanatory, but there might be some confusion between
mouseMoveandmouseDrag: the former is sent every time the user is moving the mouse over the canvas, the latter is sent only when the user is moving the mouse while pressing some button simultaneously.
2.2. Create RotateComponent
Section titled “2.2. Create RotateComponent”Add a file RotateComponent.ts in the src folder.
src/RotateComponent.ts
import Component from "bg2e-js/ts/scene/Component.ts";
export default class RotateComponent extends Component { constructor() { super("RotateComponent"); }
}This is the minimal component code. The name we pass to the class parent constructor is the name that will have the component type identifier, and therefore it is also the one we will have to use when we want to obtain it with the node.component() function. We are going to add it to the scene, on each of the spheres that are created, and for that the only thing we have to do is modify the function createSphereNode():
src/main.ts
...import RotateComponent from "./RotateComponent.ts"...class MyAppController extends SceneAppController { private async createSphereNode({ name, roughness, metalness, albedo = new Color([0.85, 0, 0, 1]), albedoTexture, normalTexture, position = [0, 0, 0] } : { name: string, roughness: number, metalness: number, albedo?: Color, albedoTexture?: string, normalTexture?: string, position: number[] }) : Promise<Node> { const sphereNode = new Node(name); this._spherePlist = this._spherePlist || createSphere(0.2); sphereNode.addComponent(new Drawable()) sphereNode.drawable?.addPolyList(this._spherePlist, await Material.Deserialize({ albedo, roughness, metalness, albedoTexture, normalTexture }), Mat4.MakeIdentity() ); sphereNode.addComponent(new Transform(Mat4.MakeTranslation(position[0], position[1], position[2]))); // Añadimos el componente RotationComponent sphereNode.addComponent(new RotateComponent()); return sphereNode; }...}Now we are going to implement the component. We will use the function willUpdate(delta: number): void to rotate the sphere:
import Component from "bg2e-js/ts/scene/Component.ts";
export default class RotateComponent extends Component { constructor() { super("RotateComponent"); }
willUpdate(delta: number) : void { if (this.transform) { this.transform.matrix.rotate(delta * 0.002, 0, 1, 0); } }}It seems that we have not obtained the result we wanted: instead of rotating each sphere on itself, they are all rotating around the center of the scene. This problem derives from how the transformation system works.
We mentioned before in this course that we use 4x4 matrices to transform the position of scene objects. In a transformation matrix we can accumulate transformations through multiplications. In this case, what we are doing is:
-
Create the sphere with a
Transform -
Accumulate in the transformation a translation until the position where we want the sphere
-
In each call to
willUpdate()the sphere is rotated.
The problem is that the order of transformations is very important: as we are doing it, the translation will be applied first and then the rotation: that is, the sphere is translated until its position, and then rotated with respect to the center. That is why the result is that the spheres are rotating around the center of the scene.
To do what we want to do, we would have to rotate the sphere first and then translate it. We could do this in two ways:
-
Add the floor position to the
RotateComponentcomponent so that it performs the operations in the order we want in each frame. -
Add a new node that is the parent of the sphere’s node, and place the translation in that node. In this way, the new node will be responsible for positioning the sphere, and the sphere node’s transformation will be used only for the rotation.
We are going to opt for the second method because it is much simpler: we just have to modify the createSphereNode function:
private async createSphereNode({ name, roughness, metalness, albedo = new Color([0.85, 0, 0, 1]), albedoTexture, normalTexture, position = [0, 0, 0] } : { name: string, roughness: number, metalness: number, albedo?: Color, albedoTexture?: string, normalTexture?: string, position: number[] }) : Promise<Node> { // Nuevo nodo para posicionar la esfera en la escena const spherePosition = new Node(name + "_Position"); // La traslación la colocamos en este nodo spherePosition.addComponent(new Transform(Mat4.MakeTranslation(position[0], position[1], position[2])));
const sphereNode = new Node(name); this._spherePlist = this._spherePlist || createSphere(0.2); sphereNode.addComponent(new Drawable()) sphereNode.drawable?.addPolyList(this._spherePlist, await Material.Deserialize({ albedo, roughness, metalness, albedoTexture, normalTexture }), Mat4.MakeIdentity() );
// Ahora añadimos la transformación de la esfera limpia, sin la traslación. sphereNode.addComponent(new Transform()); // Añadimos el componente RotationComponent sphereNode.addComponent(new RotateComponent()); // Añadimos el nodo de la esfera como hijo del nodo de posición spherePosition.addChild(sphereNode); // Devolvemos el nodo de posición, que es el que realmente se añadirá a la escena return spherePosition; }This node composition technique is very typical when we want to change node reference systems in a scene. The most typical case is that of a vehicle. We can create a vehicle in which each wheel is a node that provides the position of each wheel, and in turn each wheel has another node where the rotation for implementing the turn is configured.
2.3. Animation
Section titled “2.3. Animation”It seems we have everything now, but if we don’t move the mouse we don’t see the animation. This is because we have the scene configured in on-demand update mode. When we have components that modify the visual state of the node (in this case the transformation, but it could also be the case of modifying the visibility of a Drawable or some of its materials) we need to tell the engine that we want to update the scene.
From the components we do not have access to the mainLoop class to request an update, but we have another way to do it: scene nodes have a postRedisplayFrames property where we can indicate how many frames we want to be updated. This property is set to zero every time the scene is going to be drawn. When the sequence of calls to update() or willUpdate() is executed, the renderer will take the highest value of postRedisplayFrames that it finds in the scene nodes, and if that value is greater than zero, it will send a postRedisplay() with that number of frames to the mainLoop.
So for the animation to work correctly, the only thing we have to do is update this value:
import Component from "bg2e-js/ts/scene/Component.ts";
export default class RotateComponent extends Component { constructor() { super("RotateComponent"); }
willUpdate(delta: number) : void { if (this.transform) { this.transform.matrix.rotate(delta * 0.002, 0, 1, 0); const numFrames = 10; this.node!.postRedisplayFrames = this.node!.postRedisplayFrames < numFrames ? this.node!.postRedisplayFrames + 1 : numFrames; } }}What difference is there between doing this and directly putting the application in automatic redisplay? It could happen that the animation is not running all the time, in this case the GPU consumption would be high only when necessary.
To see this, let’s update our component’s code:
import Component from "bg2e-js/ts/scene/Component.ts";import Bg2KeyboardEvent, { SpecialKey} from "bg2e-js/ts/app/Bg2KeyboardEvent.ts";
export default class RotateComponent extends Component { private _animation: boolean = false;
constructor() { super("RotateComponent"); }
willUpdate(delta: number) : void { if (this.transform && this._animation) { this.transform.matrix.rotate(delta * 0.002, 0, 1, 0); const numFrames = 10; this.node!.postRedisplayFrames = this.node!.postRedisplayFrames < numFrames ? this.node!.postRedisplayFrames + 1 : numFrames; } }
keyUp(event: Bg2KeyboardEvent) : void { if (event.key === SpecialKey.SPACE) { this._animation = !this._animation; } }}Now the animation will start or stop with the spacebar. You can see in the resource monitor that when the animation is stopped, the GPU usage is very small. When we create a component it is important to consider on-demand rendering mode, so as not to force the application to continuously render when it is not necessary.
3. Component loading
Section titled “3. Component loading”Component registration is used to load scenes. Scenes are saved as JSON files. Each component is responsible for serializing and deserializing its properties to a JSON object.
Although bg2 engine components support serialization (save to scene) and deserialization (load scene), the current practice is to save scenes using the native application implemented in C++, and therefore serialization in the JavaScript/TypeScript API is not used. Therefore, we will focus only on deserialization: that is, loading the data from the scene file into the component.
3.1. Example project
Section titled “3.1. Example project”We are going to create another example project where we will add the RotateComponent, and then we will add the component to the scene, but this time instead of using TypeScript code, we will modify the scene file to add it and that it is created when the scene is loaded.
This time we are going to copy the basic_project project. Copy the folder, change the project name to custom_component_load in the package.json file and run an npm install.
Copy the RotateComponent.ts file from the previous project to the src folder and import it in the header of the main.ts file. Also import the registerComponent function from bg2e-js/ts/scene/Component.ts. Finally, call the registerComponent() function to register the component we have created:
Note: component registration is only necessary if we anticipate that our component will be loaded from a file.
...import RotateComponent from "./RotateComponent";import { registerComponent } from "bg2e-js/ts/scene/Component.js";
class MyAppController extends SceneAppController { async loadScene() {
registerLoaderPlugin(new VitscnjLoaderPlugin({ bg2ioPath: 'bg2e/' }));
registerComponent("RotateComponent", RotateComponent); ...When registering a component we have to pass a string. Generally this coincides with the type identifier, but it is not the same: the name we use here is used to deserialize the component from the scene file, so there is a possibility that it does not coincide.
When we develop a component, it is a good practice to document the type identifier and the name for deserialization from scenes.
Run the example and load it in a browser. At first glance nothing has happened, because we have not added the component to the scene. Open the public/test-scene/test-scene.vitscn file. You will see that it is a normal JSON file (if you want syntax coloring, you can select the file type manually in Visual Studio Code).
If you pay attention to the file structure you will see that it is the same as the structure of scenes: we have an array scene from which all nodes hang, and inside each node there is an array children with its children, and another array components with its components.
Locate the node with name Floor. You will see that it contains three components: Drawable, Transform and Selectable (we will see this component in the lesson on selection). Add in that array the RotateComponent component in the same way the Selectable component is. The name you use here is the one used to register the component in the registerComponent() function:
{ ... "scene": [ ... "children": [ ... { "type": "Node", "name": "Floor", "enabled": true, "steady": false, "children": [], "components": [ ... { "type": "Selectable" }, { "type": "RotateComponent" } ] } ] ]}Reload the application. Press the spacebar and you will see how the animation starts to play.
3.2. Deserialization
Section titled “3.2. Deserialization”It would be nice if the component could be configured with the initial animation state, so when loading you would not have to press the spacebar for it to start rotating. Another thing we could do is add a configuration for the rotation speed.
First let’s add these two parameters to the component:
export default class RotateComponent extends Component { private _animation: boolean = false; private _speed: number = 1;
constructor() { super("RotateComponent"); }
// Getters y setters para animación y velocidad get animation() { return this._animation; }
set animation(value: boolean) { this._animation = value; }
get speed() { return this._speed; }
set speed(value: number) { this._speed = value; }
willUpdate(delta: number) : void { if (this.transform && this._animation) { // Multiplicamos por la velocidad this.transform.matrix.rotate(delta * 0.002 * this._speed, 0, 1, 0); const numFrames = 10; this.node!.postRedisplayFrames = this.node!.postRedisplayFrames < numFrames ? numFrames : this.node!.postRedisplayFrames; } }
keyUp(event: Bg2KeyboardEvent) : void { if (event.key === SpecialKey.SPACE) { this._animation = !this._animation; } }}Now we have access to these properties when we create the component from the code, but not when we load it from the scene. To do this, we have to implement a couple of methods:
-
assign: it is used to assign configuration values to another component of the same type. -
clone: it is used to duplicate a component. Generally in this function what is done is call theassignfunction -
deserialize: it is used to load the component’s configuration values from a scene file. We must keep in mind that the configuration parameters of a scene file can be optional. If this is the case, that configuration value of the component should not be modified.
It is VERY IMPORTANT to implement the functions assign and clone whenever we create a new component, because the default implementation of these functions generates an exception.
RotateComponent.ts
assign(other: Component): void { if (other instanceof RotateComponent) { this._animation = other._animation; this._speed = other._speed; }}
clone(): Component { const newComponent = new RotateComponent(); newComponent.assign(this); return newComponent;}
async deserialize(sceneData: any, _: Loader): Promise<void> { if (sceneData.animation !== undefined && typeof sceneData.animation === "boolean" ) { this._animation = sceneData.animation; }
if (sceneData.speed !== undefined && typeof sceneData.speed === "number" ) { this._speed = sceneData.speed; }}Now we have added the animation and speed properties to the component’s configuration parameters. If we reload the scene we will not see any change, but now you can modify the component’s configuration in the scene file:
{ "type": "RotateComponent", "animation": true, "speed": 0.2}4. Other Component functions
Section titled “4. Other Component functions”There are three other functions that we may need to implement at some point. They have to do with the component lifecycle, and they can serve us when we want to execute code in response to some component state change.
destroy(): void: this is the simplest function to understand. The graphics engine will call this function from our component when it is going to be destroyed. Sometimes we need to execute some completion or cleanup code when it is no longer going to be used. If this is the case, we can use this function for that.
addedToNode(node: Node): void: The graphics engine calls this function when the component has been added to a node. The parameter passed is the node to which the component has been added.
removedFromNode(node: any): void: The graphics engine calls this function when a component has been removed from a node. The parameter received is the node from which the component has been removed.
If a component belongs to a node, and it is added to a different node, we must keep in mind that a component cannot belong to multiple nodes, therefore the graphics engine will remove it from the node that contains it before adding it to the new node. In this case, the component would receive calls to removedFromNode with the previous node, and afterwards to addedToNode with the new node.