Skip to content
bg2 engine

Scene from scratch

In the previous chapter we learned how to create a bg2 engine TypeScript project from scratch with Vite, and we used a SceneAppController class to implement a simple application that loaded a saved scene.

In this chapter we’re going to learn the structure of scenes in bg2 engine by creating a scene from scratch. For this example we’ll use three external resources extracted from the bg2e-js repository, inside the samples/resources folder:

  • mirrored_hall_4k.png: an equirectangular image for the environment. You can use another equirectangular image, but bear in mind that to load it its size has to be a power of two (for example 4096x2048 pixels). Additionally, these images are usually in HDR format, because that’s correct for PBR rendering. However, as browsers don’t support HDR image loading, you need to convert it beforehand to an 8-bit RGB format

  • logo.png: the bg2 engine logo

  • logo_nm.png: the logo normal map

  • logo_hm.png: we’ll use this image to modify the logo’s material metalness and roughness properties

  • vintage-tile1_albedo.jpeg

  • vintage-tile1_normal.jpeg

Copy the previous project to start with a base, or recreate it from scratch if you want to practice. Gather these resources and copy them to your Vite project’s public/resources folder. Replace the content of the async loadScene() function with this initial content:

async loadScene() {
this.updateOnInputEvents = true;
this.updateInputEventsFrameCount = 120;
return await super.loadScene();
}

You’ll have many unused imports, but don’t remove them for now, because some will be needed. When we finish the project you’ll have time to delete those that aren’t used.

In the previous lesson we learned something about the basic structure of a scene. Now we’re going to look at this topic in more depth, and we’ll learn to perform some basic operations with scenes.

The first thing we need to know before we start talking about scenes is that bg2 engine uses a right-handed coordinate system with the Y axis pointing up:

  • +x: points to the right of the screen

  • +y: points up of the screen

  • +z: points out of the screen

A bg2 engine scene is composed of nodes. Nodes generate a tree structure, such that a node can have children, and it can also be a child of another node.

To create a node it’s enough to create an instance of Node. The node class, defined in bg2e-js/ts/scene/Node.ts, receives a name in its constructor that serves to identify that node in the scene. The name is optional, and actually it doesn’t serve as a unique node identifier, because node names can repeat. Generally, a node’s name is only used for debugging or to display a readable name in a user interface (for example, in the scene editor)

import Node from "bg2e-js/ts/scene/Node.ts";
...
async loadScene() {
const sceneRoot = new Node("Scene Root");
this.updateOnInputEvents = true;
this.updateInputEventsFrameCount = 120;
return sceneRoot;
}

There are several Node properties that we’ll use frequently:

  • name (read/write): the node’s name

  • enabled (read/write): indicates whether a node is enabled. If a node is not enabled it stops being processed for most actions, for example, for drawing. A disabled node also indirectly disables all the child nodes it contains

  • steady (read/write): indicates whether a node is static. This property can be set to true when we’re sure a node won’t move inside the scene. We need to keep in mind that for a node to be truly static, its parents must also be: in the previous lesson we mentioned that transformations in a node affect its children, therefore if a static node has an ancestor that isn’t static, that node in practice won’t be static. This property only affects optimizations, but if we mark a node as static and it actually isn’t, then artifacts can be caused in rendering

  • parent: (read): returns the parent node. If this property returns null it means the node is the scene’s root, or that the node hasn’t been added to any scene. In practice, any node whose parent is null can be used as a scene root, although it’s necessary to keep in mind that depending on the rendering method, the scene must meet certain requirements. For example: to render scenes with the PBR pipeline, they must contain at least one light source

  • children (read): returns a list of children nodes. It’s very important to never mutate this array. To add or remove nodes, specific functions for this purpose are used

To manage a node’s children, the following functions are used:

  • addChild(node: Node): void: Adds a node to the end of the nodes list. If the node we’re adding already belonged to another node, this function will remove it from its previous parent, since a node can only have one parent

  • removeChild(node: Node): void: Removes a node from the list. This function receives as a parameter the node we want to remove. If we want to find the node to remove it, we can use the children array

  • haveChildren(node: Node): boolean: Returns true if the node passed to us belongs to the nodes list, that is, if the node passed is a child of the node where we’re invoking the function

  • isAncientOf(node: Node): boolean: Returns true if the node passed to us is an ancestor of the node where we invoke the function. That is, it will return true if the node passed is parent, grandparent, or ancestor in general, of the node where we invoke the function

  • emptyChildren(): void: Removes all nodes from the nodes list

It’s possible to perform more complex actions on a node’s children, but for now with these functions we’ll be able to do almost anything with a scene

To manage the components of a node, we have the following functions:

  • addComponent(component: Component): Component: Adds a component to the node. This function returns the same component we added in case we want to obtain a reference to the component to perform more operations on it:
const transform = node.addComponent(new Transform()) as Trasnsform;
// here we can use the transform component
  • component(componentId: string): Component | undefined: Returns a component belonging to a node based on its component identifier, or undefined if no component of that type was located in the node

  • removeComponent(component: Component | string): void: Removes a component from the node. We can pass it a component instance or a component identifier. If the component doesn’t exist, this function won’t fail nor produce any error or exception

Nodes are the elements that give structure and organize the scene in tree form, but they don’t implement any functionality. A node’s functionality is implemented by composition, through components. A component is a class that implements a functionality. Each component has associated with it a type identifier, which is a text string, and implements a series of functions that respond to different window events (resize, update, draw) and user events (keyDown, keyUp, mouseMove, etc)

Components included in bg2 engine are in the scene package, and the most used ones are the following:

  • Camera: implements camera-specific functions, which basically is its projection matrix and other utilities, like projection strategies. There always has to be at least one camera in a scene

  • Drawable: implements an object that can be drawn in the scene. Internally it contains a list of elements that store the necessary information to draw an object with its materials

  • EnvironmentComponent: stores information about the environment that’s used to process global illumination, reflections and other effects needed to implement the PBR pipeline. For example, in this component the skybox texture is saved

  • LightComponent: stores information about a light. Depending on the rendering pipeline type, a scene can have certain light requirements. For example, the basic PBR IBL pipeline requires at least one light, and the total number of lights it supports is limited. In other pipelines, light requirements can change

  • Transform: stores a transformation matrix. By default, a node is always at position (0, 0, 0), with components of type Transform we can move them to another position

A component can access the node that contains it through the read-only node property. If this property returns null it’s because the component wasn’t added to any node

A Transform component modifies the position of a node and also of all its descendants. For example, if we have a node with a transformation that moves it to position 5, 0, 0, and this node has a child node at position 0, 5, 0, the child node’s final position will also accumulate the parent’s transformation. Therefore the final position will be 5, 5, 0

const sceneRoot = new Node("Scene Root");
const sceneElements = new Node("Scene Elements");
sceneRoot.addChild(sceneElements);
sceneElements.addComponent(new Transform(Mat4.MakeTranslation(5, 0, 0)));
const box = new Node("Box");
sceneElements.addChild(box);
box.addComponent(new Transform(Mat4.MakeTranslation(0, 5, 0)));

The problem is that to know which is a node’s position in the scene, it’s not enough to query its Transform component, we also have to query all ancestor nodes to know the accumulated final position. This can be done automatically like this:

const boxMatrix = Transform.GetWorldMatrix(box);
console.log("Box position: " + boxMatrix.translation);

Lights in bg2 engine are formed by two parts:

  • base.Light object: Stores light properties

  • scene.LightComponent component: This component stores a Light object, therefore it’s the junction point of a light with the scene

Lights are always at position (0, 0, 0), and furthermore, lights that have direction always point towards the negative Z axis (towards inside the screen). The light’s final position and direction will be determined by the scene node containing the LightComponent component’s global transformation:

const lightComp = lightNode.component("LightComponent");
// Accessing light properties through the `light` accessor
lightComp.light.color = new Color(1, 0, 0, 1);
// Calculating the light's position:
const lightWorldPos = Transform.GetWorldMatrix(lightNode).translation;

Stores properties of a basic bg2 engine light. A basic light can be of three types:

  • Directional (LightType.DIRECTIONAL): is a light that has a single direction, doesn’t have position and also doesn’t have attenuation. It would be equivalent to sunlight. Directional lights have the capability of casting shadows

  • Spot (LightType.SPOT): is a light with position, direction and attenuation. In addition to this, it has a spot angle that indicates how open or closed the light’s beam is

  • Point (LightType. POINT): is a light that has point position, has attenuation, and is omnidirectional (shines in all directions from its position)

Light properties store the light configuration for any of the four types, therefore depending on the type, a light will only be affected by properties that make sense for its type. For example, a directional light will ignore the position property:

  • enabled (read/write): indicates whether the light is enabled or not

  • type (read/write): indicates the light type:

import Light, { LightType } from "bg2e-js/ts/base/Light.ts"
...
myLight.type = LightType.SPOT;
  • direction (read/write): indicates the light’s direction. It’s possible to modify the direction manually with this property, which by default is (0, 0, -1), but it will be affected by the accumulated transformation matrix of the node containing the Light component, therefore it’s not recommended to use this property to indicate the light’s direction

  • position (read/write): indicates the light’s position. In the same way as happens with direction, it’s affected by the node’s transformation matrix, so it’s better not to modify that property

To obtain a light’s position and orientation in the scene, we need to take into account both the position and direction properties as well as the node’s transformation matrix:

const lightMatrix = Transform.GetWorldMatrix(lightNode);
const lightComp = lightNode.component("LightComponent")!;
const light = lightComp.light;
const lightPos = Mat4.Mult(lightMatrix, light.position);
const lightDir = Mat4.Mult(lightMatrix, light.direction);
  • color (read/write): indicates the light’s color. The color’s alpha component isn’t used. It’s possible to assign the color value with a base.Color object or with a normal array

  • intensity (read/write): indicates the light’s intensity. This value will be used by the shader to calculate the final color

  • spotCutoff (read/write): spot’s cutoff angle. This value allows us to modify how much the spot light angle opens: the greater the angle, the greater the area the spot will illuminate

  • spotExponent (read/write): this value affects how sharp or diffuse the light’s spot area edge appears

  • shadowStrength (read/write): is a value between 0 and 1 that indicates the shadow’s intensity, in lights that cast shadows

  • castShadows (read/write): enables shadow casting in compatible lights

  • shadowBias (read/write): this value affects the shadow’s calculation. A very large value can make the cast shadows separate too much from the surface, while a too small value can cause artifacts in surfaces that shouldn’t have shadows. The optimal value will depend a lot on the scene’s characteristics, camera zoom, etc, but mainly a scene with small objects will require a smaller shadowBias. Typical values are usually between 0.00002 and 0.005

Note: try to avoid shadowBias values ending in 1, for example, 0.0001. Due to the floating point number representation system, these values tend to give more problems

  • projection/depthTexture/viewMatrix: these properties are used to calculate lighting in compatible lights. Generally you shouldn’t touch these properties, since they’re meant to be modified by the concrete renderer that’s drawing the scene

Shadows-compatible lights depend on the renderer being used. The default renderer for WebGL supports shadows in directional lights

It’s the component that contains the light and connects it with the scene. Since this component simply acts as a connection between the light and the scene, it only has one relevant property:

  • light (read/write): reference to the base.Light object that stores the light properties

In addition to this, the LightComponent component implements a series of static functions that can be used to query lights from the scene:

  • LightComponent.GetLights(sceneRoot: Node) : LightComponent[]: Returns all lights contained by the scene

  • LightComponent.GetFirstShadowCastingLight(sceneRoot: Node) : LightComponent | undefined: Returns the first light in the lights list that’s compatible with shadow casting

  • LightComponent.GetMainDirectionalLight(sceneRoot: Node) : LightComponent | undefined: Returns the scene’s main directional light

We’ve already seen quite a bit about cameras in the previous chapter. In this chapter we’re going to add a camera to a scene from scratch, while in the previous chapter what we did was modify an existing camera in a scene. You’ll see that essentially you already know almost everything needed

In this case, to build a camera from scratch, we need a node added to the scene that contains a Camera component and a Transform component. Additionally, if we want to control the camera with user input, we’ll also need a camera controller component

Otherwise, the camera configuration is identical to what we saw in the example of the previous chapter:

import Camera, { OpticalProjectionStrategy } from "bg2e-js/ts/scene/Camera.ts";
import SmoothOrbitCameraController from "bg2e-js/ts/scene/SmoothOrbitCameraController.ts";
...
async loadScene() {
...
const cameraNode = new Node("Camera");
const camera = new Camera();
camera.projectionStrategy = new OpticalProjectionStrategy();
cameraNode.addComponent(camera);
cameraNode.addComponent(new Transform());
cameraNode.addComponent(new SmoothOrbitCameraController());
sceneRoot.addChild(cameraNode);
...
}

The Drawable component serves to add drawable objects to the scene. A Drawable component contains a list of elements that are necessary to draw an object in the scene. Each element contains a PolyList, a Material, and a transformation matrix that allows modifying that element’s position in the list

The base.PolyList object stores 3D mesh data. It can be understood as a submesh of a 3D model: possibly in the 3D editor this element is part of a larger mesh, but bg2 engine separates these meshes into independent elements to facilitate certain operations. When a 3D model is imported in the native bg2 engine format (.bg2 files), the model’s elements are separated into polyLists, regardless of whether each one of those elements formed part of a submesh or whether they were completely independent meshes. The final goal is to be able to treat all list elements the same way

PolyList objects are formed by vertices, and each vertex consists of:

  • A position in space

  • A normal coordinate

  • A minimum of one texture coordinate, and a maximum of three (although currently shaders only support a maximum of two)

  • A tangent coordinate

If the object doesn’t have tangent coordinates, they will be attempted to be generated from normal coordinates and texture coordinates, but it’s possible the final result presents artifacts in lighting

If the object has a single set of texture coordinates, it will be used as the second set: at the end all bg2 engine 3D models have two sets of texture coordinates, even if they’re identical. This facilitates material processing, since material properties that have texture allow specifying which texture coordinate set we want to use for that property

Materials are stored in objects of type base.Material. Contrary to what happens in most 3D engines, materials in bg2 engine aren’t designed to be reused across multiple PolyLists. This is because in bg2 engine we have very few material types (there’s actually only one material type, although in the future more types could be added). Therefore, material properties occupy very little memory, and having a single material type gives us the advantage that the same shader can process all objects’ materials, simply by passing their properties

Thanks to this system, we can modify any object’s material without worrying that this might have effects on other scene elements

Material properties are divided into several groups:

  • Albedo

    • albedo (read/write): material color

    • alphaCutoff (read/write): indicates the alpha value for which the fragment will be discarded by the fragment shader. This can be used to represent objects with very complex geometry that blend opaque and transparent areas, for example a grid. By default it’s 0.5

    • albedoTexture (read/write): texture for the material color, which will be multiplied by the albedo value to obtain the final color

    • albedoScale (read/write): scale to be applied to the texture coordinate for sampling it

    • albedoUV (read/write): texture coordinate set to be used for sampling albedoTexture. By default it’s 0

    • isTransparent (read/write): transparent and opaque objects are processed in different layers. If the value of albedo is less than 1, then the graphics engine already knows that the object is transparent and will be processed as such. But if the transparency is determined by albedoTexture the engine doesn’t have a way to know if the object is transparent or not. This property serves to force an object to be processed in the transparent objects layer

  • Normal Map

    • normalTexture (read/write): normal map texture

    • normalScale (read/write): scale to be applied to the texture coordinate for sampling it

    • normalUV (read/write): texture coordinate set to be used for sampling normalTexture. By default it’s 0

  • Metalness

    • metalness (read/write): material’s metalness, as a value between 0 and 1. Although in the PBR pipeline a material is dielectric (0) or metallic (1), using intermediate values we can create composite material effects that at sub-surface scale may be composed of blends of dielectric and metallic materials

    • metalnessTexture (read/write): texture whose sampled value for the surface pixel will be multiplied by metalness to obtain the final metalness value

    • metalnessScale (read/write): vector that will be multiplied by the texture coordinate for scaling it

    • metalnessChannel (read/write): since metalness is a numeric value, the metalnessTexture texture is used monochromatically. This property indicates which texture channel (R, G, B, A) we want to use

    • metalnessUV (read/write): texture coordinate set to be used for sampling metalnessTexture. By default it’s 0

  • Roughness

    • roughness (read/write): material’s roughness, as a value between 0 and 1

    • roughnessTexture (read/write): roughness texture. The final roughness value will be the roughness value multiplied by the roughnessTexture value sampled for that pixel

    • roughnessChannel (read/write): since the roughness value is monochromatic, this property indicates which texture channel we want to use (R, G, B, A)

    • roughnessScale (read/write): is a vector that multiplies by the texture coordinate for scaling it

    • roughnessUV (read/write): indicates which UV set we’re going to use to sample roughnessTexture. By default it’s 0

  • Fresnel/Sheen: These properties affect the material’s specular reflection color

    • fresnelTint (read/write): tint applied to the specular reflection part when the material is rough

    • sheenIntensity (read/write): specular reflection intensity

    • sheenColor (read/write): specular reflection color

  • Light Emission

    • lightEmissionTexture (read/write): Light emission texture

    • lightEmission (read/write): Light emission color. The final color will be the value sampled for that pixel of lightEmissionTexture multiplied by lightEmission

    • lightEmissionChannel (read/write): The light emission texture is monochromatic, this property indicates the texture channel we’ll use for this purpose (R, G, B, A)

    • lightEmissionScale (read/write): scale applied to the texture coordinate

    • lightEmissionUV (read/write): texture coordinate set to be used for lightEmissionTexture, by default it’s 0

  • Ambient Occlussion

    • ambientOcclussion (read/write): It’s a texture where we can assign the precalculated object illumination. Although it’s called ambientOcclussion, here we can save any precalculated illumination texture with any technique, but this texture is always processed monchromatically

    • ambientOcclussionChannel (read/write): channel of the image we’re going to use as ambient occlussion

    • ambientOcclussionUV (read/write): texture coordinate set we’ll use for the ambient occlussion. By default, unlike the rest of properties, the channel 1 is used

  • Flags

    • castShadows (read/write): indicates whether the object should cast shadows when illuminated by a cast-shadow light. By default it’s true

    • unlit (read/write): indicates whether we want the object not to process illumination information at all. In this case, the shader will simply return the albedo value multiplied by the albedoTexture value sampled

When creating textures for a material, notice that the properties metalnessTexture, roughnessTexture, lightEmissionTexture and ambientOcclussion are all monochromatic. You can use this to encode each of these components in a single texture file to save disk space. However, this won’t have any effect on performance. Internally, bg2 engine will take each of these four textures and combine them into a single image, regardless of whether you indicated four different images or a single image for the four. When the final image is composed for these components, the engine will discard the source textures and won’t load them into the graphics card

Note: not all renderers process material properties equally, and in some cases the properties might not be compatible. For example, a blinn/phong renderer won’t use the metalness nor roughness properties

Until now we’ve seen some code examples. At this point we already know enough to start writing code in our example, so let’s start building a basic scene with what we’ve seen so far

Let’s modify the loadScene() function, but you’ll also have to add some imports in the file header:

import Node from "bg2e-js/ts/scene/Node.ts"
import Transform from "bg2e-js/ts/scene/Transform.ts"
import Drawable from "bg2e-js/ts/scene/Drawable.js";
import Mat4 from "bg2e-js/ts/math/Mat4.ts";
import Vec from "bg2e-js/ts/math/Vec.ts";
import { createSphere, createPlane } from "bg2e-js/ts/primitives/index.ts";
import Material from "bg2e-js/ts/base/Material.ts";
import Color from "bg2e-js/ts/base/Color.js";
import Light from "bg2e-js/ts/base/Light.js";
import LightComponent from "bg2e-js/ts/scene/LightComponent.js";
import Texture from "bg2e-js/ts/base/Texture.js";
async loadScene() {
const sceneRoot = new Node("Scene Root");
const sceneElements = new Node("Scene Elements");
sceneRoot.addChild(sceneElements);
const sphere = new Node("Sphere");
sceneElements.addChild(sphere);
sphere.addComponent(new Transform(Mat4.MakeTranslation(0, 0.5, 0)));
const spherePlist = createSphere(0.5);
const sphereDrawable = new Drawable();
const material = new Material();
sphereDrawable.addPolyList(spherePlist, material, Mat4.MakeIdentity());
sphere.addComponent(sphereDrawable);
const floorNode = new Node("Floor");
floorNode.addComponent(new Transform(Mat4.MakeTranslation(0, -0.5, 0)));
const floorPlist = createPlane(10, 10);
const floorDrawable = new Drawable();
floorDrawable.addPolyList(floorPlist, new Material(), Mat4.MakeIdentity());
floorNode.addComponent(floorDrawable);
sceneElements.addChild(floorNode);
this.updateOnInputEvents = true;
this.updateInputEventsFrameCount = 120;
return sceneRoot;
}

Now let’s add a light and a camera. After the line box.addComponent(boxDrawable) add this code:

const cameraNode = new Node("Camera");
const camera = new Camera();
camera.projectionStrategy = new OpticalProjectionStrategy();
cameraNode.addComponent(camera);
cameraNode.addComponent(new Transform());
cameraNode.addComponent(new SmoothOrbitCameraController());
sceneRoot.addChild(cameraNode);
const lightNode = new Node("Light");
const light = new Light();
lightNode.addComponent(new LightComponent(light));
lightNode.addComponent(new Transform(
Mat4.MakeRotation(0.62, -0.5, 0.5, 0)
));
sceneRoot.addChild(lightNode);

This is our first scene created from scratch

Let’s modify the sphere’s material. Material properties can be either a texture, or any other numeric, vector or color type. If the property is a texture, we first have to load the texture, while if it’s any other type it can be assigned directly to the material

Let’s change the sphere’s material to use a texture and other values of other types:

const material = new Material();
material.albedo = new Color([0.1, 0.9, 0.2, 1]);
material.albedoScale = new Vec(2, 2);
const albedoTexture = new Texture();
albedoTexture.fileName = "/logo_transparent.png";
await albedoTexture.loadImageData();
material.albedoTexture = albedoTexture;

If we want to load more than one texture, we can speed up the process by loading in parallel. For this, we can use the loadImageData() function as promises, add them to a vector, and wait for all at once:

const material = new Material();
material.albedo = new Color([0.1, 0.9, 0.2, 1]);
const scale = new Vec(2, 2);
material.albedoScale = scale;
material.normalScale = scale;
material.metalnessScale = scale;
material.roughnessScale = scale;
const albedoTexture = new Texture();
albedoTexture.fileName = "/logo_transparent.png";
const normalTexture = new Texture();
normalTexture.fileName = "/logo_nm.png";
const metalnessRoughnessTexture = new Texture();
metalnessRoughnessTexture.fileName = "/logo_hm.png";
await Promise.allSettled([
albedoTexture.loadImageData(),
normalTexture.loadImageData(),
metalnessRoughnessTexture.loadImageData()
]);
material.albedoTexture = albedoTexture;
material.normalTexture = normalTexture;
material.metalness = 1;
material.metalnessTexture = metalnessRoughnessTexture;
material.roughnessTexture = metalnessRoughnessTexture;

Note: notice how in the previous code we put the metalness component at 1. The default value of metalness is 0. Since this value is multiplied by metalnessTexture, if we want to use a texture for metalness, we’ll always need to modify metalness to a value greater than zero, otherwise the operation’s result will always be zero, and metalnessTexture won’t have any effect

In the previous scene the lighting looks extremely harsh. This is because in a realistic PBR pipeline a background is necessary. The environment in PBR is used for:

  • Ambient lighting calculation: since there’s no environment, the ambient light is completely black, that’s why everything that isn’t directly illuminated will appear in gray tones

  • Reflection generation: objects with low roughness, besides responding to light with a specular shine, also reflect the environment

Depending on the PBR pipeline type, the environment might be also used for other things besides these. And regardless of all this, the environment can also be used to render the skybox

To configure the environment, we need an equirectangular image. In this case we have the mirrored_hall_4k.png file that we copied at the beginning when preparing the project. Now we’re going to use it to create our environment. Add this code in your loadScene() function, just before this.updateOnInputEvents = true:

const env = new EnvironmentComponent();
env.equirectangularTexture = '/mirrored_hall_4k.png';
env.showSkybox = true;
sceneRoot.addComponent(env);

We added the environment in the root node, but actually it can be added to any node. When only there’s one environment in the scene, it doesn’t matter which node we add it to, nor its position

Generally, creating a static scene like the one we created before doesn’t make much sense: it’s much easier to use the scene creation tool and load the file. Creating scenes by code makes more sense when it’s wanted to be done procedurally. Let’s create a procedural scene, and along the way we’ll see some more utilities for material creation

First, let’s move the sphere we added in the previous step up a bit, so we’ll leave more space below to create more elements:

// Modifying the translation to move the sphere a bit up
sphere.addComponent(new Transform(Mat4.MakeTranslation(0, 1.0, 0)));

Since we’re going to add many spheres, we’re going to create a helper function that we’ll call many times. This way we’ll be able to create a sphere with its material using a single line. Additionally, we’ll also create a variable in the class to store the sphere’s poly list. The idea is to reuse the same poly list for all the Drawable objects we add. If two objects’ geometry is going to be the same, then there’s no reason to load it multiple times. However, we’ll create the material independent for each sphere, because we’re going to specify different properties for each sphere

class MyAppController extends SceneAppController {
private _spherePlist: PolyList | null = null;
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])));
return sphereNode;
}
}

Notice how we use the Material.Deserialize() function to create a material from some properties. This static async function allows loading a material from an object with property names, but additionally it’s capable of loading textures directly from their URL or relative path

Now we’re going to create a grid with spheres using two nested for loops. Add this code in the loadScene() function, between the section where we create the sphere and the section where we create the floor:

async loadScene() {
...
sphere.addComponent(sphereDrawable);
// Creating spheres procedurally
const rows = 10;
const cols = 10;
const separation = 0.75;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const roughness = i / (rows - 1);
const metalness = j / (cols - 1);
sceneElements.addChild(await this.createSphereNode({
name: `Sphere_${i}_${j}`,
roughness,
metalness,
position: [(j - (cols - 1) / 2) * separation, 0.15, (i - (rows - 1) / 2) * separation],
albedoTexture: "/vintage-tile1_albedo.jpeg",
normalTexture: "/vintage-tile1_normal.jpeg"
}));
}
}
const floorNode = new Node("Floor");
...
}

Note about selection: if you click on one of the spheres you’ll see that all are selected. This is because selection works at the PolyList level. Since all spheres are the same PolyList, it’s impossible to select them separately with this configuration. If we needed independent selection, then we would need to create a different PolyList for each sphere

The Visitor design pattern allows separation between data structure and its operations

Instead of adding methods to classes that compose a structure (for example, a scene tree) to perform different tasks such as printing information, validating data or exporting content, the Visitor pattern proposes encapsulating those operations in external objects called visitors

This way:

  • Structure objects (for example, scene nodes) remain focused on their main responsibility: storing and organizing data

  • Operations are implemented in independent classes (visitors)

  • It’s possible to add new operations without modifying existing classes of the structure

This is especially useful in hierarchical structures like a scene tree, where it’s common to want to traverse all nodes to perform different tasks


7.2. Applying the Visitor pattern in bg2 engine

Section titled “7.2. Applying the Visitor pattern in bg2 engine”

In bg2 engine, the scene is organized as a node tree (Node). Each node can contain components and children nodes, forming a hierarchy

The Node class implements the method:

accept(nodeVisitor: NodeVisitor): void

This method allows a visitor object to traverse the scene tree applying an operation on each node. Internally, traversal follows a depth-first (pre-order) scheme:

  1. visit(this) is executed on the current node

  2. All children nodes are traversed recursively

  3. didVisit(this) is executed at the end of traversing the current node’s subtree

In simplified form, the behavior is:

accept(nodeVisitor: any): void {
if (!nodeVisitor.ignoreDisabled || this.enabled) {
nodeVisitor.visit(this);
this._children.forEach(ch => ch.accept(nodeVisitor));
nodeVisitor.didVisit(this);
}
}

This provides two entry points to the visitor:

  • visit(node) → called upon entering the node (pre-order)

  • didVisit(node) → called upon exiting the node (post-order)

This double callback allows implementing visitors that need to maintain hierarchical state, for example transformation stacks, depth counters, accumulators, etc


The NodeVisitor class includes the property:

ignoreDisabled: boolean
  • If ignoreDisabled === true (default value), nodes with enabled = false won’t be visited, nor their children

  • If ignoreDisabled === false, all nodes will be traversed regardless of their state


7.4. Example: Visitor that prints node names

Section titled “7.4. Example: Visitor that prints node names”

Next is shown an example of a visitor that traverses the scene and prints in console the name of each node, respecting the hierarchy through indentation:

import Node from "./Node";
import NodeVisitor from "./NodeVisitor";
export default class PrintNodeNamesVisitor extends NodeVisitor {
private _depth = 0;
override visit(node: Node) {
const indent = " ".repeat(this._depth);
console.log(`${indent}${node.name}`);
this._depth++;
}
override didVisit(node: Node) {
this._depth--;
}
}

Visitor usage:

const visitor = new PrintNodeNamesVisitor();
// Optional: also visit disabled nodes
visitor.ignoreDisabled = false;
rootNode.accept(visitor);

If the scene contains a hierarchy like the following:

  • Root
    • Camera\
    • Room
      • Lamp

The console output will be:

Root Camera Room Lamp

This same mechanism can be used to implement any operation that requires traversing the scene tree without modifying the Node class

bg2 engine includes several predefined scene visitors, and one of the most used is FindNodeVisitor: a visitor that allows locating nodes in a scene following different criteria

When creating the spheres, we named them with the pattern Sphere_X_Y, where X is the row index and Y is the column index. Knowing this, let’s locate the sphere with name Sphere_5_5 and let’s change its material color

Add this code at the end of the async loadScene() function, just before return:

const findVisitor = new FindNodeVisitor();
findVisitor.name = "Sphere_5_5";
sceneRoot.accept(findVisitor);
findVisitor.result?.forEach(node => {
const drw = node.component("Drawable")! as Drawable;
drw.items[0].material.albedo = new Color([0, 1, 0, 1]);
});

FindNodeVisitor includes many search filters. For example, instead of passing a node name, we can pass a regular expression. Modify the previous code to indicate a regular expression that returns all nodes of row 5:

findVisitor.name = /^Sphere\_5\_?/;

We can also add conditions in the search. For example, in the example code we’re assuming that the result contains a Drawable component. In this case it’s clear that it will work, because we generated the scene by code, but scene queries are usually performed on loaded scenes. We could check if a node contains a drawable before assigning the material, but it’s more efficient to add a condition to the search to ensure that the resulting nodes have a Drawable component. The FindNodeVisitor class also includes this functionality. The visitor configuration code would look like this:

const findVisitor = new FindNodeVisitor();
findVisitor.name = /^Sphere\_5\_?/;
findVisitor.hasComponents(["Drawable"]);

It’s possible to reuse a visitor after a search. To do this, before starting a second search you have to call the clear() method to clear previous results and reconfigure the search parameters:

findVisitor.clear();
findVisitor.name = "Scene Elements";
findVisitor.hasComponents([]);
sceneRoot.accept(findVisitor);
const positions = findVisitor.result?.map(node => {
return Transform.GetWorldMatrix(node).translation;
});
console.log(positions);

We’ve seen that to obtain a component of a certain type from a node we use the component() function passing the component’s type identifier. To this function we have to apply a casting to the concrete type to indicate to TypeScript which specific component class we’re obtaining:

const drw = node.component("Drawable") as Drawable;
if (drw) {
// Do something with drw
}

For common component types, there are shorter specific functions that allow obtaining components with the casting already applied:

const trx = node.transform;
const drw = node.drawable;
const light = node.lightComponent;
const camera = node.camera;

8.2. Component access from other components

Section titled “8.2. Component access from other components”

In many occasions we’ll also find ourselves with we have a component and we want to access another sibling component: that is, a component that belongs to the same node

const camera = Camera.GetMain(sceneRoot);
if (camera) {
const trx = camera.node.transform;
// Do something with the camera's transformation
}

The base node.Component class contains direct access functions to obtain references to sibling components, which are named the same as the equivalent functions in Node. Therefore, running camera.node.transform is equivalent to running directly camera.transform. The same happens with any other arbitrary component type: camera.node.component("Transform") is equivalent to camera.component("Transform")