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.
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';
@Component({ 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 renderingSceneGraph
as a regular Angular component.
Set up the Canvas
The SceneGraph
component will be rendered by the NgtCanvas
component.
import { Component } from '@angular/core';import { NgtCanvas } from 'angular-three';import { SceneGraph } from './scene-graph.component';
@Component({ selector: 'app-root', 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
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', 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.
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.
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';
@Component({ 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.
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, ElementRef, viewChild } from '@angular/core';
import { injectBeforeRender } from 'angular-three';import { Mesh } from 'three';
@Component({ 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', 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; }); }}
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';import { Cube } from './cube.component';
@Component({ template: ` <ngt-mesh> <ngt-box-geometry /> </ngt-mesh> <app-cube /> `, imports: [Cube], schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush,})export class SceneGraph {}
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
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', 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
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', 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
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';import { Cube } from './cube.component';
@Component({ 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.
import { Component, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy } from '@angular/core';import { Cube } from './cube.component';
@Component({ 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.
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', 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
.
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
.
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({ 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