Skip to content

Scene Descriptors

The scene descriptor describes the structure of your scene at a point in time. As you apply new scene descriptors over time based on the changing state of your application, Troika tracks the differences from one descriptor to the next, and creates/destroys/updates a tree of Facade instances to match.

Describing your scene declaratively this way removes the mental overhead of having to track and modify individual objects over time, making your scenes much easier to understand and debug.

If you are familiar with React, this will feel very familiar. In fact Troika's scene descriptor model is highly inspired by React. The scene descriptor behaves very much like JSX descriptors, and Troika's Facades are in some ways similar to React's Components.

A Basic Descriptor

A descriptor is just a plain JS object with a set of properties. Here's a basic example:

{
  facade: BallFacade,
  x: 1,
  y: 5,
  z: -10,
  color: 0x3333cc
}

The only property that a descriptor must include is facade. It defines the specific Facade subclass that will be instantiated for this object.

All the other properties are simply copied to that facade instance when the scene is updated. That specific facade's implementation controls what those properties mean in terms of their representation in your graphical scene. Typically this means syncing those property changes to a more complex underlying API such as a Three.js mesh/geometry/material.

While Troika has a few built-in facade types, for the most part they will be something that you must implement for the kinds of objects in your scene. See Facade Basics for details and simple examples.

Special Descriptor Properties

While most properties are just copied to the facade instance, a few of them have special meanings:

key

A descriptor may include a key string property, identifying the specific facade instance corresponding to that descriptor object. The key must be unique among its siblings within a given parent.

If omitted, Troika will generate a key internally based on the facade subclass and position among siblings. However it is recommended that you always include an explicit key, to avoid sometimes confusing situations with instance swapping, particularly when using animations and transitions.

{
  facade: BallFacade,
  key: 'ball1',
  x: -2
},
{
  facade: BallFacade,
  key: 'ball2',
  x: 2
}

children

Many Troika facades (those inheriting from ParentFacade) allow a children property, pointing to an array of child descriptors, or a single child descriptor object if there is only one.

{
  facade: Group3DFacade,
  key: 'group',
  rotateZ: Math.PI / 2,
  children: [
    {
      facade: BallFacade,
      key: 'ball1',
      x: -2
    },
    {
      facade: BallFacade,
      key: 'ball2',
      x: 2
    }
  ]
}

transition, animation, exitAnimation

A descriptor with one of these properties causes the instantiated Facade class to be wrapped as Animatable, allowing you to declaratively define how other property values should change over time.

These are based very closely on CSS Transitions and CSS Keyframe Animations, and are covered in detail in Animations and Transitions.

pointerStates

Similarly, the presence of a pointerStates property will wrap the facade instance so that it automatically changes its state in response to pointer events. This gives you declarative control over styling for hover and active states, much like CSS pseudoclasses, without having to write imperative event handlers every time. This is covered in detail in Interactivity and Events.

ref

If your code needs a reference to the Facade object instantiated for a given descriptor, you can give it a ref property pointing to a function. That function will be called, passed the facade instance as its argument, when the facade is created. It will also be called, with null as its argument, when the facade is destroyed (removed from the scene.)

If the ref is reassigned to a different function during an update, the old one will be called with null and the new one will be called with the facade instance. This is usually not what you want, so to avoid this churn make sure the exact same function is passed across updates.

function ballRefFunction(ballFacade) {
  console.log('BallFacade was ' + (ballFacade ? 'created' : 'destroyed'))
}

//...

{
  facade: BallFacade,
  key: 'ball',
  ref: ballRefFunction
}

As in React, there is seldom a need to use ref, but it can be a useful tool in those rare cases.

Data Lists

In cases where you need to describe a large number of scene objects, such as when you are mapping from a large set of data items, it is inefficient to create a large array of children with a scene descriptor object for each item. Troika provides a ListFacade to handle these cases more efficiently.

Instead of a descriptor object for each item, you define a single descriptor object bound to a data array, with a template object that describes how the items in that data array should be mapped to facade instances.

import {ListFacade} from 'troika-3d'

//...

{
  facade: ListFacade,
  key: 'myList',
  data: myArrayOfDataItems,
  template: {
    facade: BallFacade,
    key: d => d.id,
    x: (d, i) => i * 2,
    y: d => d.value / maxValue * 10,
    z: -10
  }
}

This pattern is inspired by how d3.js binds data to attributes without creating a full set of intermediary objects.

Each property in the template can either be a constant literal value (e.g. a number or string), or a function. Literals will be copied directly to each spawned child. Functions will be called for each item, passing three arguments: the current item from the data array, the current index in the array, and the full array. The value returned by the function will be copied to that child facade.

In cases where you want an actual function to be copied, such as assigning event handlers, you will need to wrap those in a function that returns your function.

  template: {
    //...
    onClick: () => this.onBallClicked
  }

Alternate JSX syntax

If you are using React for the rest of your application, it can sometimes be confusing to have to step between using JSX for React content descriptors and the plain JS object descriptors for Troika content. To smooth this over, Troika is able to accept JSX elements in place of most JS object descriptors, assuming your build pipeline pre-transforms JSX to React.createElement() calls.

When representing Troika descriptors in JSX, the facade value is used as the JSX element name, children are represented as nested JSX child elements, and all other properties are written as JSX attributes.

For example:

{
  facade: Group3DFacade,
  key: 'grp',
  rotateZ: Math.PI / 2,
  children: [
    {
      facade: BallFacade,
      key: 'ball1',
      x: -2
    },
    {
      facade: BallFacade,
      key: 'ball2',
      x: 2
    }
  ]
}

...is equivalent to:

<Group3DFacade
  key="group"
  rotateZ={Math.PI / 2}
>
  <BallFacade
    key="ball1"
    x={-2}
  />
  <BallFacade
    key="ball2"
    x={2}
  />
</Group3DFacade>

While the JSX sugar can often be more readable, it does have a slight performance impact due to more transient objects being created and React.createElement()'s own internal logic being run for each element. Try to avoid it and stick with plain JS descriptors when your scene contains a large number of them.


Last update: 2024-04-09