Our first scene
This tutorial will help us setup our first NGT scene and introduce us to some of its core concepts.
- We are using Inline Template syntax for this tutorial
- We will put everything in
app.component.ts
for simplicity
Create a root component for our Scene graph
Let's start by creating a Component as the root of our Scene graph. We'll put the component in app.component.ts
for now
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
standalone: true,
template: ``,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SceneGraph {}
@Component({
/*...*/
})
export class AppComponent {}
- The
template
is intentionally left empty, we'll fill it out later. - The
selector
is intentionally left out because we'll not render ourSceneGraph
component on the template. CUSTOM_ELEMENTS_SCHEMA
is required because Angular does not support custom schemas so our Custom Elements won't work without it.
Set up the Canvas
The scene graph in NGT starts with <ngt-canvas>
. Let's render <ngt-canvas>
on our app.component.ts
template.
Make sure to import NgtCanvas
from angular-three
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { NgtCanvas } from 'angular-three';
@Component({
/*...*/
})
export class SceneGraph {}
@Component({
selector: 'app-root',
standalone: true,
template: `<ngt-canvas [sceneGraph]="SceneGraph" />`,
imports: [NgtCanvas],
})
export class AppComponent {
readonly SceneGraph = SceneGraph;
}
<ngt-canvas>
has a required input[sceneGraph]
which accepts a Component class. We pass ourSceneGraph
component into that input.
ngt-canvas
sets up the following:
- A
WebGLRenderer
, a defaultScene
, and a defaultPerspectiveCamera
- A render loop that renders our scene evere frame outside of Change Detection
- A
window:resize
listener that updates our Renderer and Camera when the viewport is resized
ngt-canvas
renders the SceneGraph
input in a detached environment from Angular Change Detection. It also provides the custom Angular Renderer
to render THREE.js entities instead of DOM elements.
Next, we'll set some basic styles in styles.css
html,
body {
height: 100%;
width: 100%;
margin: 0;
}
ngt-canvas
is designed to fit the parent container so we can control our 3D scene by adjusting the parent's dimensions.
<div class="canvas-container">
<ngt-canvas [sceneGraph]="..." />
</div>
Extend THREE.js catalogue
NGT is a custom Angular Renderer so it has to have a collection of "what to render", we call it catalogue. By default, the catalogue is empty.
We can add elements to the catalogue by calling extend()
and pass in a dictionary of THREE entities.
The Renderer then maps the catalogue to Custom Element tags. The convention of our Custom Element tags is:
<ngt-{name-of-the-THREE-element-kebab-case}></ngt-{name-of-the-THREE-element-kebab-case}>
In Angular 15.1+, we can also use Self Closing tag <ngt-{name-of-the-THREE-element-kebab-case} />
For example:
Mesh
->ngt-mesh
BoxGeometry
->ngt-box-geometry
import { extend } from 'angular-three';
import { Mesh } from 'three';
extend({ Mesh });
// if we want to render `<ngt-some-mesh>` then we can do
// extend({ SomeMesh: Mesh })
We can extend the entire THREE.js collection with extend(THREE)
but that will include everything from THREE
namespace in our bundle. For the purpose of this tutorial, we'll be using extend(THREE)
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { NgtCanvas, extend } from 'angular-three';
import * as THREE from 'three';
extend(THREE);
/* the rest of the code */
Adding a Mesh
Now that we have THREE.js in our catalogue, we are ready to render some THREE.js entities. Let's start with adding a THREE.Mesh
.
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { NgtCanvas, extend } from 'angular-three';
import * as THREE from 'three';
extend(THREE);
@Component({
standalone: true,
template: `
<ngt-mesh></ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SceneGraph {}
/* AppComponent code */
THREE.Mesh
is one of the most fundamental objects in THREE.js. It is used to hold a Geometry and a Material
to represent a shape in the 3D space. For this tutorial, we'll use BoxGeometry
, and MeshBasicMaterial
to create a cube.
/* imports */
extend(THREE);
@Component({
standalone: true,
template: `
<ngt-mesh>
<ngt-box-geometry />
<ngt-mesh-basic-material />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SceneGraph {}
/* AppComponent code */
At this point, we'll have something on the scene to look at.
Animating our cube
Animation-on-the-web-101: Change the object's properties little by little, frame by frame to animate it in an animation loop.
That's right! NGT renders our Scene graph in an animation loop (typically 60FPS, or 60 frames per second). This means we
can change our cube's properties (eg: rotation
) little by little to animate it. In NGT, we can use (beforeRender)
event binding to tap into this animation loop.
/* imports */
import { NgtCanvas, extend, NgtBeforeRenderEvent } from 'angular-three';
extend(THREE);
@Component({
standalone: true,
template: `
<ngt-mesh
(beforeRender)="onBeforeRender($any($event))"
>
<ngt-box-geometry />
<ngt-mesh-basic-material />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SceneGraph {
onBeforeRender(event: NgtBeforeRenderEvent<THREE.Mesh>) {
event.object.rotation.x += 0.01;
}
}
/* AppComponent code */
- We listen for
(beforeRender)
on ourngt-mesh
withonBeforeRender()
handler. - We use
$any($event)
due to Angular Language Service's limitation. - On
onBeforeRender()
, we typeevent
withNgtBeforeRenderEvent<THREE.Mesh>
event.state
is the state of our Scene graph; mouse position, clock, delta, scene, camera, the GL renderer etc...event.object
is the instance of the object we're attaching(beforeRender)
on. In this case, it is aTHREE.Mesh
- We change
rotation.x
by incrementing it0.01
radian on every frame. The result is we have a spinning cube
Wow, that was easy! Before we move on, let's pause for a moment to understand what is happening here.
Here are the code of the above scene with Angular Three and plain THREE.js
- Angular Three
- Plain THREE.js
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { NgtCanvas, extend, NgtBeforeRenderEvent } from 'angular-three';
import * as THREE from 'three';
extend(THREE);
@Component({
standalone: true,
template: `
<ngt-mesh (beforeRender)="onBeforeRender($any($event))">
<ngt-box-geometry />
<ngt-mesh-basic-material />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SceneGraph {
onBeforeRender(event: NgtBeforeRenderEvent<THREE.Mesh>) {
event.object.rotation.x += 0.01;
}
}
@Component({
selector: 'app-root',
standalone: true,
template: `<ngt-canvas [sceneGraph]="SceneGraph" />`,
imports: [NgtCanvas],
})
export class AppComponent {
readonly SceneGraph = SceneGraph;
}
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, document.clientWidth / document.clientHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({
antialiasing: true,
alpha: true,
powerPreference: 'high-power',
});
renderer.setSize(document.clientWidth, document.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio || 1);
document.querySelector('app-root').appendChild(renderer.domElement);
function resize() {
renderer.setSize(document.clientWidth, document.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio || 1);
camera.aspect = document.clientWidth / document.clientHeight;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
// then setup window.removeEventListener('resize', resize) somewhere
const cube = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial());
scene.add(cube);
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
renderer.render(scene, camera);
}
animate();
Plain THREE.js does not look so bad but it is imperative. By leveraging Angular template, we can express our Scene in a
more declarative manner. We can use Angular features like *ngIf
, *ngFor
, other Directives, DI, and more to allow our Scene
to be more dynamic. In addition, the THREE entities expressed in NGT are aware of their life-cycles which allows them
to automatically clean up when they are destroyed.
Next section of this tutorial shows an even better reason to use NGT.
Componentize our cube
Using Angular means we can make components out of our template. Let's do that for our cube
/* imports */
extend(THREE);
@Component({
selector: 'demo-cube',
standalone: true,
template: `
<ngt-mesh (beforeRender)="onBeforeRender($any($event))">
<ngt-box-geometry />
<ngt-mesh-basic-material />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class Cube {
onBeforeRender(event: NgtBeforeRenderEvent<THREE.Mesh>) {
event.object.rotation.x += 0.01;
}
}
@Component({
standalone: true,
template: ` <demo-cube /> `,
imports: [Cube],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SceneGraph {}
/* AppComponent code */
Everything works as before but now we have a Cube
component that can have internal states.
We will add two states hovered
and active
to our Cube
component
- When we hover over the cube, we set
hovered
totrue
and vice versa - When we click the cube, we toggle the
active
state - When
active
, we make the cube bigger - When
hovered
, we change the color of the cube
/* imports */
extend(THREE);
@Component({
selector: 'demo-cube',
standalone: true,
template: `
<ngt-mesh
(beforeRender)="onBeforeRender($any($event))"
(click)="active = !active"
(pointerover)="hovered = true"
(pointerout)="hovered = false"
[scale]="active ? 1.5 : 1"
>
<ngt-box-geometry />
<ngt-mesh-basic-material [color]="hovered ? 'darkred' : 'red'" />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class Cube {
active = false;
hovered = false;
onBeforeRender(event: NgtBeforeRenderEvent<THREE.Mesh>) {
event.object.rotation.x += 0.01;
}
}
/* SceneGraph code */
/* AppComponent code */
Familiar, right? This is one of the goals of NGT. Interact with the cube now and see the magic
(click)
,(pointerover)
, and(pointerout)
look like DOM events but they are not. These events are named as such to give a sense of familiarity to Angular developers.- These events automatically calls
changeDetectorRef.detectChanges
so we can update states (eg:hovered
andactive
).
Now that our cube is interactive and fun, we can render another demo-cube
to double the fun. But first, we need to add a position
input
so we can show both Cube
in different positions on the Scene.
/* imports */
extend(THREE);
@Component({
selector: 'demo-cube',
standalone: true,
template: `
<ngt-mesh
(beforeRender)="onBeforeRender($any($event))"
(click)="active = !active"
(pointerover)="hovered = true"
(pointerout)="hovered = false"
[scale]="active ? 1.5 : 1"
[position]="position"
>
<ngt-box-geometry />
<ngt-mesh-basic-material [color]="hovered ? 'darkred' : 'red'" />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class Cube {
@Input() position = [0, 0, 0];
active = false;
hovered = false;
onBeforeRender(event: NgtBeforeRenderEvent<THREE.Mesh>) {
event.object.rotation.x += 0.01;
}
}
@Component({
standalone: true,
template: `
<demo-cube [position]="[1.5, 0, 0]" />
<demo-cube [position]="[-1.5, 0, 0]" />
`,
imports: [Cube],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SceneGraph {}
/* AppComponent code */
and voila, we have 2 Cube
that have their own states, and reacting to events independently.
Adding lights
Our cubes are interactive, but they look bland. They don't look like 3D objects at the moment, because they lack Light Reflections.
First, let's switch out <ngt-mesh-basic-material>
for <ngt-mesh-standard-material>
/* imports */
extend(THREE);
@Component({
selector: 'demo-cube',
standalone: true,
template: `
<ngt-mesh
/* mesh properties/events */
>
<ngt-box-geometry />
- <ngt-mesh-basic-material [color]="hovered ? 'darkred' : 'red'" />
+ <ngt-mesh-standard-material [color]="hovered ? 'darkred' : 'red'" />
</ngt-mesh>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class Cube {
/* component code */
}
/* SceneGraph code */
/* AppComponent code */
We can check the Scene now and notice that our cubes are pitch black.
This is because MeshStandardMaterial
is a material that needs to be lit up by lights.
Imagine a dark room with no lights, any object would be black. Our scene background just happens to be "white" by default.
Next, let's start adding lights to our SceneGraph
component
/* imports */
extend(THREE);
/* Cube code */
@Component({
standalone: true,
template: `
<ngt-ambient-light [intensity]="0.5" />
<ngt-spot-light [position]="10" [angle]="0.15" [penumbra]="1" />
<ngt-point-light [position]="-10" />
<demo-cube [position]="[1.5, 0, 0]" />
<demo-cube [position]="[-1.5, 0, 0]" />
`,
imports: [Cube],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SceneGraph {}
/* AppComponent code */
We can always look at THREE.js documentations for details on these THREE.js lights
Our cubes look a lot better now, with dimensionality, showing they are 3D objects.
Bonus: Taking control of the camera
Who hasn't tried to "grab" the scene and move it around? We cannot do that yet as our Camera is static in its position.
Let's take over the Camera with OrbitControls
.
First, let's install three-stdlib
, which provides OrbitControls
object
npm i three-stdlib
Next, we'll update our code as follow
/* imports */
import { NgtCanvas, extend, NgtBeforeRenderEvent, NgtStore, NgtArgs } from 'angular-three';
import { OrbitControls } from 'three-stdlib';
extend(THREE);
extend({ OrbitControls });
/* Cube code */
@Component({
standalone: true,
template: `
<ngt-ambient-light [intensity]="0.5" />
<ngt-spot-light [position]="10" [angle]="0.15" [penumbra]="1" />
<ngt-point-light [position]="-10" />
<demo-cube [position]="[1.5, 0, 0]" />
<demo-cube [position]="[-1.5, 0, 0]" />
<ngt-orbit-controls *args="[camera, glDom]" [enableDamping]="true" />
`,
imports: [Cube, NgtArgs],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SceneGraph {
private readonly store = inject(NgtStore);
readonly camera = this.store.get('camera');
readonly glDom = this.store.get('gl', 'domElement');
}
/* AppComponent code */
- We call
inject(NgtStore)
to inject an object which stores all information about the Canvas.- We extract
camera
and thegl.domElement
from theNgtStore
- We extract
- We import
OrbitControls
fromthree-stdlib
and callextend()
with it so our catalogue knows aboutOrbitControls
, allowing us to render<ngt-orbit-controls>
OrbitControls
needs two constructor arguments;new OrbitControls(camera, domElement)
. That's what*args
directive is for.- We set
enableDamping
on theOrbitControls
totrue
so we have a smooth experience when we move the Camera around.
- We set
That's it! That concludes our tutorial.
What's next?
- Try different Geometries, different colors, different Lights
- Try placing more
demo-cube
in different positions - Immerse yourself in THREE.js documentation