Skip to content

A 3D Scene with Mobius

Three.js Integration already walks through the most common mobius setup — subclassing MobiusScreenView for a full-screen isometric 3D stage. ThreeStage and ThreeUtils document the lower-level pieces underneath that path. This page builds directly on both: it repeats the full-screen MobiusScreenView shape briefly for contrast, then focuses on the other common shape — a fixed-size 3D viewport (ThreeNode) placed as one ordinary child alongside 2D scenery content, which is the better fit when 3D content is a small part of a screen rather than its entire backdrop.

No new mobius APIs are introduced here beyond what Three.js Integration, ThreeStage, and ThreeNode, ThreeInstrumentable, and ThreeObject3DPhetioObject already document.

Option A: full-screen isometric stage (MobiusScreenView)

For 3D content that is the screen — the shape Three.js Integration covers in depth — subclass MobiusScreenView, which builds a ThreeIsometricNode (this.sceneNode) sized to the screen automatically:

ts
import { MobiusScreenView, type MobiusScreenViewOptions } from 'scenerystack/sim';
import { THREE } from 'scenerystack/mobius';

class OrbitScreenView extends MobiusScreenView {
  private readonly planet: THREE.Mesh;

  public constructor( providedOptions: MobiusScreenViewOptions ) {
    super( providedOptions );

    const geometry = new THREE.SphereGeometry( 0.4, 32, 16 );
    const material = new THREE.MeshNormalMaterial();
    this.planet = new THREE.Mesh( geometry, material );
    this.sceneNode.stage.threeScene.add( this.planet );
    this.sceneNode.stage.threeScene.add( new THREE.DirectionalLight( 0xffffff, 1 ) );
  }

  public override step( dt: number ): void {
    this.planet.rotation.y += dt * 0.5;
    super.step( dt ); // re-renders the stage
  }
}

Reach for this when 3D content occupies the whole screen and should pan/scale with the layout the way Three.js Integration describes.

Option B: a fixed-size viewport alongside 2D content (ThreeNode)

When 3D content is one element among ordinary scenery Nodes — a small rotating preview next to a control panel, say — ThreeNode is the better fit: it hosts its own ThreeStage at a fixed pixel size and behaves like any other Node for layout purposes.

ts
import { ScreenView, type ScreenViewOptions } from 'scenerystack/sim';
import { VBox, Text } from 'scenerystack/scenery';
import { ThreeNode, THREE } from 'scenerystack/mobius';
import { Vector3 } from 'scenerystack/dot';
import { Checkbox } from 'scenerystack/sun';
import { BooleanProperty } from 'scenerystack/axon';

// The model itself has nothing 3D-specific about it - it just owns a Property the view reads.
class MoleculePreviewModel {
  public readonly rotationEnabledProperty = new BooleanProperty( true );
  public reset(): void {
    this.rotationEnabledProperty.reset();
  }
}

class MoleculePreviewScreenView extends ScreenView {
  private readonly model: MoleculePreviewModel;
  private readonly moleculeThreeNode: ThreeNode;
  private readonly moleculeMesh: THREE.Mesh;

  public constructor( model: MoleculePreviewModel, providedOptions: ScreenViewOptions ) {
    super( providedOptions );
    this.model = model;

    this.moleculeThreeNode = new ThreeNode( 300, 300, {
      cameraPosition: new Vector3( 0, 0, 3 )
    } );

    const geometry = new THREE.IcosahedronGeometry( 0.6, 0 );
    const material = new THREE.MeshNormalMaterial();
    this.moleculeMesh = new THREE.Mesh( geometry, material );
    this.moleculeThreeNode.stage.threeScene.add( this.moleculeMesh );
    this.moleculeThreeNode.stage.threeScene.add( new THREE.DirectionalLight( 0xffffff, 1 ) );

    // ThreeNode is a plain Node - it composes with ordinary 2D layout containers.
    const rotateCheckbox = new Checkbox( model.rotationEnabledProperty, new Text( 'Rotate' ), {
      tandem: this.tandem.createTandem( 'rotateCheckbox' )
    } );

    const controlsColumn = new VBox( {
      spacing: 10,
      align: 'left',
      children: [ this.moleculeThreeNode, rotateCheckbox ],
      center: this.layoutBounds.center
    } );

    this.children = [ controlsColumn ];

    // ThreeNode needs an explicit layout() call once its position in the global
    // scene graph is settled, unlike MobiusScreenView (which handles this for you).
    this.moleculeThreeNode.layout();
  }

  public step( dt: number ): void {
    if ( this.model.rotationEnabledProperty.value ) {
      this.moleculeMesh.rotation.y += dt;
    }
    this.moleculeThreeNode.render();
  }
}

A few things worth noticing:

PieceWhy it's there
ThreeNode( 300, 300, ... )A fixed pixel size, unlike MobiusScreenView's screen-filling ThreeIsometricNode — see the constructor signature on ThreeNode
moleculeThreeNode as a VBox childThreeNode extends scenery's Node, so it participates in layout containers exactly like any 2D Node — no special-casing needed in the layout code
.layout() called once, explicitlyRecomputes the stage's pixel dimensions from the Node's current global bounds; call it again after any transform change that affects moleculeThreeNode's placement, since (unlike MobiusScreenView) nothing calls it for you automatically
.render() called every step()ThreeNode doesn't render itself on a timer — the hosting ScreenView's step(dt) is responsible, the same as MobiusScreenView.step() does internally for its own stage

Instrumenting a mesh for PhET-iO, if needed

If a specific THREE.Object3D needs its own PhET-iO identity (so Studio can select or reference it), wrap its constructor with ThreeInstrumentable rather than trying to attach a tandem to the plain three.js object directly — three.js objects have no PhET-iO concept of their own (see ThreeNode, ThreeInstrumentable, and ThreeObject3DPhetioObject):

ts
import { ThreeInstrumentable, THREE } from 'scenerystack/mobius';

const InstrumentedMesh = ThreeInstrumentable( THREE.Mesh );

const instrumentedMolecule = new InstrumentedMesh( geometry, material, {
  tandem: this.tandem.createTandem( 'moleculeMesh' )
} );
this.moleculeThreeNode.stage.threeScene.add( instrumentedMolecule );

This only gives PhET-iO Studio an address for the object to select — it does not save/restore its position or rotation on state restore; model that state as ordinary Propertys if it needs to survive a PhET-iO save/load.

Choosing between the two options

Use MobiusScreenView (full-screen)Use ThreeNode (fixed-size)
3D content is the entire screen's subject3D content is a small part of a larger, mostly-2D screen
Isometric framing that adapts to the layout bounds is wantedA fixed pixel footprint next to ordinary controls is wanted
No 2D scenery siblings need to share layout with the 3D contentThe 3D viewport needs to sit inside a VBox/HBox/Panel with 2D Nodes

Both paths share the same ThreeStage underneath

Whichever Node you reach for, the actual three.js objects (THREE.Scene, THREE.PerspectiveCamera, THREE.WebGLRenderer) live on the same ThreeStage type — .stage is available on both ThreeIsometricNode/MobiusScreenView.sceneNode and ThreeNode. Anything documented on ThreeStage (projecting points, checking isWebGLEnabled(), disposing) works identically regardless of which hosting Node you chose.

Where to go next