Your First Scene
Initial application structure
First step is to open up the app.component.ts
file and import NgtCanvas
component then render it on the template.
import { Component } from "@angular/core";import { NgtCanvas } from "angular-three/dom";
@Component({ selector: "app-root", template: ` <ngt-canvas></ngt-canvas> `, imports: [NgtCanvas]})export class AppComponent {}
NgtCanvas
is the entry-point to Angular Three. It creates a WebGLRenderer
, a default Scene
, and a default PerspectiveCamera
; the 3 main building blocks
for a THREE.js application.
We’ll also need to adjust the NgtCanvas
dimensions by adjusting the parent’s dimensions.
html,body { height: 100%; width: 100%; margin: 0;}
import { Component } from "@angular/core";import { NgtCanvas } from "angular-three/dom";
@Component({ selector: "app-root", template: ` <ngt-canvas></ngt-canvas> `, imports: [NgtCanvas], styles: ` :host { display: block; height: 100dvh; } `})export class AppComponent {}
NgtCanvas
also provides a store that the Angular Three application will act upon. To improve accessing this store via Dependency Injection, it is recommended to create a separate component scene-graph.ts
and render it as the NgtCanvas
content.
import { Component } from "@angular/core";
@Component({ selector: "app-scene-graph", template: ``})export class SceneGraph {}
import { Component } from "@angular/core";import { NgtCanvas } from "angular-three/dom";import { SceneGraph } from "./scene-graph";
@Component({ selector: "app-root", template: ` <ngt-canvas> <app-scene-graph *canvasContent /> </ngt-canvas> `, imports: [NgtCanvas] imports: [NgtCanvas, SceneGraph]})export class AppComponent {}
Provide the Renderer
In order to actually render our THREE.js scene graph to the canvas, we need to provide the custom renderer from angular-three
import { ApplicationConfig } from "@angular/core";import { provideNgtRenderer } from "angular-three/dom";
export const appConfig: ApplicationConfig = { providers: [ // other providers provideNgtRenderer(), ]}
Create objects
In scene-graph.ts
, we’ll create a simple THREE.js cube which is comprised of a THREE.Mesh
with a THREE.BoxGeometry
and a THREE.MeshBasicMaterial
.
But before we can do so, we need to tell Angular Three about these objects by extending the catalogue. By default, this catalogue is empty. Angular Three maps the catalogue to Custom Elements tags with the following naming convention:
<ngt-{entityName-in-kebab-case} />
Call extend
and pass in a Record
of entities.
import { Component } from "@angular/core";import { extend } from "angular-three";import { Mesh, MeshBasicMaterial, BoxGeometry } from "three";
extend({ Mesh, // makes ngt-mesh available MeshBasicMaterial, // makes ngt-mesh-basic-material available BoxGeometry, // makes ngt-box-geometry available});
import { Component } from "@angular/core";import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";import { extend } from "angular-three";import * as THREE from "three";
extend(THREE);
@Component({ selector: "app-scene-graph", template: ` <ngt-mesh> <ngt-box-geometry /> <ngt-mesh-basic-material /> </ngt-mesh> `, schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class SceneGraph {}
THREE.js equivalent
import * as THREE from 'three';
// create a mesh, geometry, and materialconst mesh = new THREE.Mesh();const geometry = new THREE.BoxGeometry();const material = new THREE.MeshBasicMaterial();
// "attach" geometry and material to the meshmesh.geometry = geometry;mesh.material = material;
Modify objects
We can modify any THREE.js entities by leveraging Angular Property Binding on any properties that the THREE.js exposes for the class that we’re using.
For example, we can modify the Mesh
’s position
, and the MeshBasicMaterial
’s color
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";import { extend } from "angular-three";import * as THREE from "three";
extend(THREE);
@Component({ selector: "app-scene-graph", template: ` <ngt-mesh [position]="[0, 1, 0]" > <ngt-box-geometry /> <ngt-mesh-basic-material color="mediumpurple" /> </ngt-mesh> `, schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class SceneGraph {}
THREE.js equivalent
import * as THREE from 'three';
const mesh = new THREE.Mesh();const geometry = new THREE.BoxGeometry();const material = new THREE.MeshBasicMaterial();
mesh.geometry = geometry;mesh.material = material;
// set the position of the meshmesh.position.fromArray([0, 1, 0]);
// set the color of the materialmaterial.color.set('mediumpurple');
As for entities like THREE.BoxGeometry
whose Constructor Arguments changes will require the entity to be reconstructed, Angular Three provides a structural directive called NgtArgs
. Let’s see how we can use it to modify the THREE.BoxGeometry
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";import { extend } from "angular-three";import { extend, NgtArgs } from "angular-three";import * as THREE from "three";
extend(THREE);
@Component({ selector: "app-scene-graph", template: ` <ngt-mesh [position]="[0, 1, 0]"> <ngt-box-geometry *args="[1, 2, 1]" /> <ngt-mesh-basic-material color="mediumpurple" /> </ngt-mesh> `, imports: [NgtArgs], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class SceneGraph {}
THREE.js equivalent
import * as THREE from 'three';
const mesh = new THREE.Mesh();// set the dimensions of the geometryconst geometry = new THREE.BoxGeometry(1, 2, 1);const material = new THREE.MeshBasicMaterial();
mesh.geometry = geometry;mesh.material = material;
mesh.position.fromArray([0, 1, 0]);material.color.set('mediumpurple');
Modify the Default Camera
Up to this point, we’ve only been looking at the side of the cube. This is because the camera’s default position set by Angular Three. To modify this default camera, we can use the [camera]
and [lookAt]
inputs on the NgtCanvas
component.
import { Component } from "@angular/core";import { NgtCanvas } from "angular-three/dom";import { SceneGraph } from "./scene-graph";
@Component({ selector: "app-root", template: ` <ngt-canvas [camera]="{ position: [5, 5, 5] }" [lookAt]="[0, 1, 0]" > <app-scene-graph *canvasContent /> </ngt-canvas> `, imports: [NgtCanvas, SceneGraph], styles: ` :host { display: block; height: 100dvh; } `})export class AppComponent {}
Animate the Cube
Let’s make our cube less boring by adding some motion to it. The best way to do this in Angular Three is to participate in Angular Three’s unified frame loop via injectBeforeRender()
function.
First, we’ll need to get a reference to the undering THREE.Mesh
element from <ngt-mesh>
. We can use viewChild
to do this.
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";import { Component, CUSTOM_ELEMENTS_SCHEMA, viewChild, ElementRef } from "@angular/core";import { extend, NgtArgs } from "angular-three";import * as THREE from "three";
extend(THREE);
@Component({ selector: "app-scene-graph", template: ` <ngt-mesh #mesh [position]="[0, 1, 0]" > <ngt-box-geometry *args="[1, 2, 1]" /> <ngt-mesh-basic-material color="mediumpurple" /> </ngt-mesh> `, imports: [NgtArgs], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class SceneGraph { private meshRef = viewChild.required<ElementRef<THREE.Mesh>>('mesh');}
Next, we’ll use meshRef
in injectBeforeRender
and update its property frame by frame before the canvas is rendered.
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";import { Component, CUSTOM_ELEMENTS_SCHEMA, viewChild, ElementRef } from "@angular/core";import { extend, NgtArgs } from "angular-three";import { extend, NgtArgs, injectBeforeRender } from "angular-three";import * as THREE from "three";
extend(THREE);
@Component({ selector: "app-scene-graph", template: ` <ngt-mesh #mesh [position]="[0, 1, 0]"> <ngt-box-geometry *args="[1, 2, 1]" /> <ngt-mesh-basic-material color="mediumpurple" /> </ngt-mesh> `, imports: [NgtArgs], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class SceneGraph { private meshRef = viewChild.required<ElementRef<THREE.Mesh>>('mesh');
constructor() { injectBeforeRender(({ delta }) => { this.meshRef().nativeElement.rotation.y += delta; }); }}
Make a component
Using Angular means we can componentize our cube so we can reuse it. This is what it means to “scale at ease”. Let’s create a new component called cube.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild } from "@angular/core";import { NgtArgs, injectBeforeRender } from "angular-three";import * as THREE from "three";
@Component({ selector: "app-cube", template: ` <ngt-mesh #mesh [position]="[0, 1, 0]"> <ngt-box-geometry *args="[1, 2, 1]" /> <ngt-mesh-basic-material color="mediumpurple" /> </ngt-mesh> `, imports: [NgtArgs], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class Cube { private meshRef = viewChild.required<ElementRef<THREE.Mesh>>('mesh');
constructor() { injectBeforeRender(({ delta }) => { this.meshRef().nativeElement.rotation.y += delta; }); }}
Then, we’ll use this Cube
component in our SceneGraph
import { Component, CUSTOM_ELEMENTS_SCHEMA, viewChild, ElementRef } from "@angular/core";import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";import { extend, NgtArgs, injectBeforeRender } from "angular-three";import { extend } from "angular-three";import * as THREE from "three";
import { Cube } from "./cube";
extend(THREE);
@Component({ selector: "app-scene-graph", template: ` <ngt-mesh #mesh [position]="[0, 1, 0]"> <ngt-box-geometry *args="[1, 2, 1]" /> <ngt-mesh-basic-material color="mediumpurple" /> </ngt-mesh> <app-cube /> `, imports: [NgtArgs], imports: [Cube], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class SceneGraph { private meshRef = viewChild.required<ElementRef<THREE.Mesh>>('mesh');
constructor() { injectBeforeRender(({ delta }) => { this.meshRef().nativeElement.rotation.y += delta; }); }}
Add state to the Cube
Everything is the same as before, except we now have a Cube
component that can have its own state and logic. We’ll add 2 states hovered
and clicked
to the Cube
component:
- When the cube is hovered, it will change its color.
- When the cube is clicked, it will change its scale.
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild, signal} from "@angular/core";import { NgtArgs, injectBeforeRender } from "angular-three";import * as THREE from "three";
@Component({ selector: "app-cube", template: ` <ngt-mesh #mesh [position]="[0, 1, 0]" [scale]="clicked() ? 1.5 : 1" (pointerover)="hovered.set(true)" (pointerout)="hovered.set(false)" (click)="clicked.set(!clicked())" > <ngt-box-geometry *args="[1, 2, 1]" /> <ngt-mesh-basic-material color="mediumpurple" [color]="hovered() ? 'hotpink' : 'mediumpurple'" /> </ngt-mesh> `, imports: [NgtArgs], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class Cube { private meshRef = viewChild.required<ElementRef<THREE.Mesh>>('mesh');
protected hovered = signal(false); protected clicked = signal(false);
constructor() { injectBeforeRender(({ delta }) => { this.meshRef().nativeElement.rotation.y += delta; }); }}
Render another cube
Having our Cube
component means that we can render as many cubes as we want. Let’s render another cube on the canvas. However, we need to add a position
input to the Cube
component so that the SceneGraph
can put the cubes in the correct positions for them to show up.
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild, signal, input} from "@angular/core";import { NgtArgs, injectBeforeRender } from "angular-three";import * as THREE from "three";
@Component({ selector: "app-cube", template: ` <ngt-mesh #mesh [position]="[0, 1, 0]" [position]="[positionX(), 1, 0]" [scale]="clicked() ? 1.5 : 1" (pointerover)="hovered.set(true)" (pointerout)="hovered.set(false)" (click)="clicked.set(!clicked())" > <ngt-box-geometry *args="[1, 2, 1]" /> <ngt-mesh-basic-material [color]="hovered() ? 'hotpink' : 'mediumpurple'" /> </ngt-mesh> `, imports: [NgtArgs], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class Cube { positionX = input(0);
private meshRef = viewChild.required<ElementRef<THREE.Mesh>>('mesh');
protected hovered = signal(false); protected clicked = signal(false);
constructor() { injectBeforeRender(({ delta }) => { this.meshRef().nativeElement.rotation.y += delta; }); }}
Next, we’ll update the SceneGraph
component.
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";import { extend } from "angular-three";import * as THREE from "three";
import { Cube } from "./cube";
extend(THREE);
@Component({ selector: "app-scene-graph", template: ` <app-cube /> <app-cube [positionX]="-2" /> <app-cube [positionX]="2" /> `, imports: [Cube], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class SceneGraph {}
Now, we have two cubes that have their own states, and react to events independently.
Adjust the 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 } from "@angular/core";import { extend } from "angular-three";import * as THREE from "three";
import { Cube } from "./cube";
extend(THREE);
@Component({ selector: "app-scene-graph", template: ` <ngt-ambient-light [intensity]="0.5" /> <ngt-spot-light [position]="[5, 10, -10]" [intensity]="0.5 * Math.PI" [angle]="0.5" [penumbra]="1" [decay]="0" /> <ngt-point-light [position]="-10" [intensity]="0.5 * Math.PI" [decay]="0" /> <app-cube [positionX]="-2" /> <app-cube [positionX]="2" /> `, imports: [Cube], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class SceneGraph { protected readonly Math = Math;}
Next, we will want to change the material of the cube to THREE.MeshStandardMaterial
so that it can be lit by the lights.
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild, signal, input} from "@angular/core";import { NgtArgs, injectBeforeRender } from "angular-three";import * as THREE from "three";
@Component({ selector: "app-cube", template: ` <ngt-mesh #mesh [position]="[positionX(), 1, 0]" [scale]="clicked() ? 1.5 : 1" (pointerover)="hovered.set(true)" (pointerout)="hovered.set(false)" (click)="clicked.set(!clicked())" > <ngt-box-geometry *args="[1, 2, 1]" /> <ngt-mesh-basic-material [color]="hovered() ? 'hotpink' : 'mediumpurple'" /> <ngt-mesh-standard-material [color]="hovered() ? 'hotpink' : 'mediumpurple'" /> </ngt-mesh> `, imports: [NgtArgs], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class Cube { positionX = input(0);
private meshRef = viewChild.required<ElementRef<THREE.Mesh>>('mesh');
protected hovered = signal(false); protected clicked = signal(false);
constructor() { injectBeforeRender(({ delta }) => { this.meshRef().nativeElement.rotation.y += delta; }); }}
Cast shadows
We’re almost there! Now that we have some lightings, let’s add some shadows so our scene is even more interesting.
First, we need to turn on shadows support explicitly since rendering shadows is an expensive operation.
import { Component } from "@angular/core";import { NgtCanvas } from "angular-three/dom";import { SceneGraph } from "./scene-graph";
@Component({ selector: "app-root", template: ` <ngt-canvas shadows [camera]="{ position: [5, 5, 5] }" [lookAt]="[0, 1, 0]" > <app-scene-graph *canvasContent /> </ngt-canvas> `, imports: [NgtCanvas, SceneGraph], styles: ` :host { display: block; height: 100dvh; } `})export class AppComponent {}
Next, we’ll adjust our THREE.SpotLight
and our objects to cast and receive shadows.
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";import { extend } from "angular-three";import * as THREE from "three";
import { Cube } from "./cube";
extend(THREE);
@Component({ selector: "app-scene-graph", template: ` <ngt-ambient-light [intensity]="0.5" /> <ngt-spot-light [position]="[5, 10, -10]" [intensity]="0.5 * Math.PI" [angle]="0.5" [penumbra]="1" [decay]="0" castShadow /> <ngt-point-light [position]="-10" [intensity]="0.5 * Math.PI" [decay]="0" />
<ngt-mesh [rotation]="[-Math.PI / 2, 0, 0]" receiveShadow> <ngt-circle-geometry *args="[4, 40]" /> <ngt-mesh-standard-material /> </ngt-mesh>
<app-cube [positionX]="-2" /> <app-cube [positionX]="2" /> `, imports: [Cube], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class SceneGraph { protected readonly Math = Math;}
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild, signal, input} from "@angular/core";import { NgtArgs, injectBeforeRender } from "angular-three";import * as THREE from "three";
@Component({ selector: "app-cube", template: ` <ngt-mesh #mesh [position]="[positionX(), 1, 0]" [scale]="clicked() ? 1.5 : 1" (pointerover)="hovered.set(true)" (pointerout)="hovered.set(false)" (click)="clicked.set(!clicked())" castShadow > <ngt-box-geometry *args="[1, 2, 1]" /> <ngt-mesh-standard-material [color]="hovered() ? 'hotpink' : 'mediumpurple'" /> </ngt-mesh> `, imports: [NgtArgs], schemas: [CUSTOM_ELEMENTS_SCHEMA]})export class Cube { positionX = input(0);
private meshRef = viewChild.required<ElementRef<THREE.Mesh>>('mesh');
protected hovered = signal(false); protected clicked = signal(false);
constructor() { injectBeforeRender(({ delta }) => { this.meshRef().nativeElement.rotation.y += delta; }); }}
Conclusion
Congratulations. We have learned how to create a basic scene, add some lights, and make our cubes interactive. This guide, while short, includes important THREE.js and Angular Three concepts that should give you a good starting point for your own projects.
Here are several things you can play around to understand more how THREE.js works:
- Can you change the cube to a different geometry?
- Can you change how the objects are animated?
- Can you change the lights’ positions and intensities?
- Can you integrate
OrbitControls
to take control of the camera? (hint: useextend
)