Skip to content

Our First 3D Scene

Before diving into the API details of Angular Three, let’s create a simple scene together to get a feel for what it’s like to use Angular Three.

Create the SceneGraph component

This component will be the root of our scene graph.

src/app/scene-graph.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';
@Component({
standalone: true,
template: `
<!-- we'll fill this in later -->
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SceneGraph {}
  • CUSTOM_ELEMENTS_SCHEMA is required to use Angular Three components.
  • selector is left empty because we’re not rendering SceneGraph as a regular Angular component.

Set up the Canvas

The SceneGraph component will be rendered by the NgtCanvas component.

src/app/app.component.ts
import { Component } from '@angular/core';
import { NgtCanvas } from 'angular-three';
import { SceneGraph } from './scene-graph.component';
@Component({
selector: 'app-root',
standalone: true,
template: `
<ngt-canvas [sceneGraph]="sceneGraph" />
`,
imports: [NgtCanvas],
})
export class AppComponent {
protected sceneGraph = SceneGraph;
}

NgtCanvas uses the sceneGraph input to render the SceneGraph component with the Custom Renderer as well as sets up the following THREE.js building blocks:

  • A WebGLRenderer with anti-aliasing enabled and transparent background.
  • A default PerspectiveCamera with a default position of [x:0, y:0, z:5].
  • A default Scene
  • A render loop that renders the scene on every frame
  • A window:resize event listener that automatically updates the Renderer and Camera when the viewport is resized

Set the dimensions of the canvas

We’ll set up some basic styles in styles.css

src/styles.css
html,
body {
height: 100%;
width: 100%;
margin: 0;
}

Next, let’s set some styles for :host in src/app/app.component.ts

import { Component } from '@angular/core';
import { NgtCanvas } from 'angular-three';
import { SceneGraph } from './scene-graph.component';
@Component({
selector: 'app-root',
standalone: true,
template: `
<ngt-canvas [sceneGraph]="sceneGraph" />
`,
imports: [NgtCanvas],
styles: [`
:host {
display: block;
height: 100dvh;
}
`],
})
export class AppComponent {
protected sceneGraph = SceneGraph;
}

Extending Angular Three Catalogue

As a custom renderer, Angular Three maintains a single catalogue of entities to render. By default, the catalogue is empty. We can extend the catalogue by calling the extend function and pass in a Record of entities. Angular Three then maps the catalogue to Custom Elements tags with the following naming convention:

<ngt-{entityName-in-kebab-case} />

For example:

import { extend } from 'angular-three';
import { Mesh, BoxGeometry } from 'three';
extend({
Mesh, // makes ngt-mesh available
BoxGeometry // makes ngt-box-geometry available,
MyMesh: Mesh, // makes ngt-my-mesh available
});

For the purpose of this guide, we’ll extend THREE.js namespace so we do not have to go back and forth to extend more entities as we go.

src/app/app.component.ts
import { NgtCanvas } from 'angular-three';
import { NgtCanvas, extend } from 'angular-three';
import * as THREE from 'three';
extend(THREE);
/* the rest of the code remains the same */

Render a THREE.js Entity

Now that we have extended the THREE.js namespace, we can render a THREE.js entity. Let’s render a cube with a Mesh and BoxGeometry from THREE.js.

src/app/scene-graph.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';
@Component({
standalone: true,
template: `
<ngt-mesh>
<ngt-box-geometry />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SceneGraph {}

And here’s the result:

Animation

The best way to animate a THREE.js entity is to participate in the animation loop with injectBeforeRender. Let’s animate the cube by rotating it on the X and Y axes.

src/app/scene-graph.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, ElementRef, viewChild } from '@angular/core';
import { injectBeforeRender } from 'angular-three';
import { Mesh } from 'three';
@Component({
standalone: true,
template: `
<ngt-mesh #mesh>
<ngt-box-geometry />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SceneGraph {
meshRef = viewChild.required<ElementRef<Mesh>>('mesh');
constructor() {
injectBeforeRender(() => {
const mesh = this.meshRef().nativeElement;
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.01;
});
}
}

Make a Component

Using Angular means we can make components out of our template. Let’s do that for our cube

import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild } from '@angular/core';
import { injectBeforeRender } from 'angular-three';
import { Mesh } from 'three';
@Component({
selector: 'app-cube',
standalone: true,
template: `
<ngt-mesh #mesh>
<ngt-box-geometry />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class Cube {
meshRef = viewChild.required<ElementRef<Mesh>>('mesh');
constructor() {
injectBeforeRender(({ delta }) => {
const mesh = this.meshRef().nativeElement;
mesh.rotation.x += delta;
mesh.rotation.y += delta;
});
}
}

Everything is the same as before, except we now have a Cube component that can have its own state and logic.

We will add 2 states hovered and clicked to the cube component:

  • When the cube is hovered, we’ll change its color
  • When the cube is clicked, we’ll change its scale
src/app/cube.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild } from '@angular/core';
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild, signal } from '@angular/core';
import { injectBeforeRender } from 'angular-three';
import { Mesh } from 'three';
@Component({
selector: 'app-cube',
standalone: true,
template: `
<ngt-mesh
#mesh
[scale]="clicked() ? 1.5 : 1"
(pointerover)="hovered.set(true)"
(pointerout)="hovered.set(false)"
(click)="clicked.set(!clicked())"
>
<ngt-box-geometry />
<ngt-mesh-basic-material [color]="hovered() ? 'darkred' : 'mediumpurple'" />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class Cube {
meshRef = viewChild.required<ElementRef<Mesh>>('mesh');
hovered = signal(false);
clicked = signal(false);
constructor() {
injectBeforeRender(() => {
const mesh = this.meshRef().nativeElement;
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.01;
});
}
}

Our cube is now interactive!

Render another Cube

Just like any other Angular component, we can render another cube by adding another <app-cube /> tag to the template. However, we need to render the cube in different positions so we can see them both on the scene.

Let’s do that by adding a position input to the Cube component

src/app/cube.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild, signal } from '@angular/core';
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild, signal, input } from '@angular/core';
import { injectBeforeRender } from 'angular-three';
import { injectBeforeRender, NgtVector3 } from 'angular-three';
import { Mesh } from 'three';
@Component({
selector: 'app-cube',
standalone: true,
template: `
<ngt-mesh
#mesh
[position]="position()"
[scale]="clicked() ? 1.5 : 1"
(pointerover)="hovered.set(true)"
(pointerout)="hovered.set(false)"
(click)="clicked.set(!clicked())"
>
<ngt-box-geometry />
<ngt-mesh-basic-material [color]="hovered() ? 'darkred' : 'mediumpurple'" />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class Cube {
position = input<NgtVector3>([0, 0, 0]);
meshRef = viewChild.required<ElementRef<Mesh>>('mesh');
hovered = signal(false);
clicked = signal(false);
constructor() {
injectBeforeRender(() => {
const mesh = this.meshRef().nativeElement;
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.01;
});
}
}

position input is a NgtVector3 which is an expanded version of THREE.Vector3. It can accept:

  • A THREE.Vector3 instance
  • A tuple of [x, y, z]
  • A scalar value that will be used for all axes
src/app/scene-graph.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';
import { Cube } from './cube.component';
@Component({
standalone: true,
template: `
<app-cube />
<app-cube [position]="[1.5, 0, 0]" />
<app-cube [position]="[-1.5, 0, 0]" />
`,
imports: [Cube],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SceneGraph {}

and now we have 2 cubes that have their own states, and react to events independently.

Lighting

Let’s add some lights to our scene to make the cubes look more dynamic as they look bland at the moment.

src/app/scene-graph.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';
import { Cube } from './cube.component';
@Component({
standalone: true,
template: `
<ngt-ambient-light [intensity]="0.5" />
<ngt-spot-light [position]="10" [intensity]="0.5 * Math.PI" [angle]="0.15" [penumbra]="1" [decay]="0" />
<ngt-point-light [position]="-10" [intensity]="0.5 * Math.PI" [decay]="0" />
<app-cube [position]="[1.5, 0, 0]" />
<app-cube [position]="[-1.5, 0, 0]" />
`,
imports: [Cube],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SceneGraph {
protected readonly Math = Math;
}

Next, we will want to change the Material of the cube to MeshStandardMaterial so that it can be lit by the lights.

src/app/cube.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild, signal, input } from '@angular/core';
import { injectBeforeRender, NgtVector3 } from 'angular-three';
import { Mesh } from 'three';
@Component({
selector: 'app-cube',
standalone: true,
template: `
<ngt-mesh
#mesh
[position]="position()"
[scale]="clicked() ? 1.5 : 1"
(pointerover)="hovered.set(true)"
(pointerout)="hovered.set(false)"
(click)="clicked.set(!clicked())"
>
<ngt-box-geometry />
<ngt-mesh-basic-material [color]="hovered() ? 'darkred' : 'mediumpurple'" />
<ngt-mesh-standard-material [color]="hovered() ? 'darkred' : 'mediumpurple'" />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class Cube {
position = input<NgtVector3>([0, 0, 0]);
meshRef = viewChild.required<ElementRef<Mesh>>('mesh');
hovered = signal(false);
clicked = signal(false);
constructor() {
injectBeforeRender(() => {
const mesh = this.meshRef().nativeElement;
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.01;
});
}
}

Our cubes look better now, with dimensionality, showing that they are 3D objects.

Bonus: Take control of the camera

Who hasn’t tried to grab the scene and move it around? Let’s take control of the camera and make it move around with OrbitControls.

Terminal window
npm install three-stdlib
# yarn add three-stdlib
# pnpm add three-stdlib

three-stdlib provides a better API to work with THREE.js extra modules like OrbitControls.

src/app/scene-graph.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';
import { injectStore, extend, NgtArgs } from 'angular-three';
import { OrbitControls } from 'three-stdlib';
import { Cube } from './cube.component';
extend({ OrbitControls }); // makes ngt-orbit-controls available
@Component({
standalone: true,
template: `
<ngt-ambient-light [intensity]="0.5" />
<ngt-spot-light [position]="10" [intensity]="0.5 * Math.PI" [angle]="0.15" [penumbra]="1" [decay]="0" />
<ngt-point-light [position]="-10" [intensity]="0.5 * Math.PI" [decay]="0" />
<app-cube [position]="[1.5, 0, 0]" />
<app-cube [position]="[-1.5, 0, 0]" />
<ngt-orbit-controls *args="[camera(), glDomElement()]" />
<ngt-grid-helper />
`,
imports: [Cube],
imports: [Cube, NgtArgs],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SceneGraph {
protected readonly Math = Math;
private store = injectStore();
protected camera = this.store.select('camera');
protected glDomElement = this.store.select('gl', 'domElement');
}

If we were to use OrbitControls in a vanilla THREE.js application, we would need to instantiate it with the camera and WebGLRenderer#domElement.

With Angular Three, we use NgtArgs structural directive to pass Constructor Arguments to the underlying element.

To access the camera and glDomElement, we use injectStore to access the state of the canvas.

And that concludes our guide. We have learned how to create a basic scene, add some lights, and make our cubes interactive. We also learned how to use NgtArgs to pass arguments to the underlying THREE.js elements. Finally, we learned how to use injectStore to access the state of the canvas.

What’s next?

Now that we have a basic understanding of how to create a scene, we can start building more complex scenes.

  • Try different geometries and materials
  • Try different lights
  • Immerse yourself in the world of THREE.js