All Blogs

Convert a vanilla THREE.js Angular application to Angular Three

A couple of days ago, I found a cool Angular WebXR Art Sample application on Google Gemini GitHub. The application was built using vanilla THREE.js. I thought it would be a good idea to convert it to Angular Three (i.e: angular-three), a library that provides a custom Angular Renderer to render THREE.js entities. This way, I can leverage Angular’s features and improve the developer experience when writing 3D applications with Angular.

In this blog post, I will walk you through the steps I took to convert the Angular WebXR Art Sample application to Angular Three. That said, I only focus on the THREE.js conversion process. I won’t cover the WebXR nor the Gemini part of the application.

Additionally, I would like to refer you to the Angular Three with AnalogJS and Astro for an introduction to Angular Three

The example entry point

Before diving into the conversion process, let’s understand the Angular WebXR Art Sample application entry point: MuseumComponent

1
@defer (prefetch on idle) {
2
<art-gallery [artworks]="artworks()" />
3
} @placeholder ( minimum 5s) {
4
<art-loading></art-loading>
5
} @error {
6
<p>Failed to load the gallery</p>
7
}

We can see that there are two branches in the application: art-gallery and art-loading. The art-gallery branch is where the 3D models are rendered, and the art-loading branch is a placeholder for the spinning Angular logo.

Animated gif of the Angular WebXR application Animated gif of the Angular WebXR application

Both art-gallery and art-loading wrap a canvas element where the scene graph is rendered to. Both GalleryComponent and LoadingComponent extend SceneComponent which sets up the required building blocks for a THREE.js application:

1
export class SceneComponent {
2
/* more code */
3
4
ngAfterViewInit() {
5
const canvasEl = this.canvas().nativeElement;
6
7
/* more code */
8
9
// Scene background
10
this.scene.background = new Color("black");
11
this.scene.backgroundBlurriness = 0.3;
12
13
// Camera
14
this.camera = new PerspectiveCamera(45, w / h, 0.1, 500);
15
this.camera.position.set(0, 1.6, 0);
16
this.scene.add(this.camera);
17
18
// Renderer
19
this.renderer = new WebGLRenderer({
20
canvas: canvasEl,
21
antialias: true,
22
powerPreference: "high-performance",
23
alpha: true,
24
});
25
/* more code */
26
}
27
28
/* more code */
29
}

In addition, the SceneComponent also sets up some lights and an OrbitControls for the camera. The OrbitControls allows the user to rotate the camera around the scene.

1
export class SceneComponent {
2
/* more code */
3
4
ngAfterViewInit() {
5
/* setup code */
6
7
// add lights
8
this.addLights();
9
10
// add controls
11
this.addControls();
12
}
13
14
/* more code */
15
16
}

I will not try to explain the code line-by-line here but the gist is that it is imperative code that sets up the base environment for the scene graph. For me personally, this code is hard to follow and maintain.

Here is the same branched template in the Angular Three rewrite:

1
import { ChangeDetectionStrategy, Component } from '@angular/core'
2
import { GalleryScene } from './gallery-scene.component'
3
import { LoadingScene } from './loading-scene.component'
4
import { MuseumCanvas } from './museum-canvas.component'
5
6
@Component({
7
standalone: true,
8
template: `
9
@defer (prefetch on idle) {
10
<app-museum-canvas [scene]="galleryScene" />
11
} @placeholder (minimum 5s) {
12
<app-museum-canvas [scene]="loadingScene" />
13
}
14
`,
15
/* truncated */
16
imports: [MuseumCanvas],
17
})
18
export default class GalleryPage {
19
galleryScene = GalleryScene
20
loadingScene = LoadingScene
21
}

In this version, MuseumCanvas is the component that is responsible for setting up the required building blocks of a 3D scene graph.

1
import { ChangeDetectionStrategy, Component, input, type Type } from '@angular/core'
2
import { NgtCanvas } from 'angular-three'
3
import { NgtsLoader } from 'angular-three-soba/loaders'
4
import * as THREE from 'three'
5
6
@Component({
7
selector: 'app-museum-canvas',
8
standalone: true,
9
template: `
10
<ngt-canvas
11
[sceneGraph]="scene()"
12
[scene]="sceneOptions"
13
[camera]="$any(cameraOptions)"
14
[gl]="glOptions"
15
[shadows]="true"
16
/>
17
<ngts-loader />
18
`,
19
changeDetection: ChangeDetectionStrategy.OnPush,
20
imports: [NgtCanvas, NgtsLoader],
21
})
22
export class MuseumCanvas {
23
scene = input.required<Type<any>>()
24
25
protected sceneOptions = {
26
background: new THREE.Color('black'),
27
backgroundBlurriness: 0.3,
28
}
29
30
protected cameraOptions = {
31
position: [0, 1.6, 0],
32
fov: 45,
33
near: 0.1,
34
far: 500,
35
}
36
37
protected glOptions = {
38
toneMappingExposure: 1.5,
39
// NOTE: Uncomment the following line to enable WebXR
40
// xr: {
41
// enabled: true
42
// }
43
}
44
}

The highlighted code shows the MuseumCanvas accepts a scene Input that is passed into the NgtCanvas component which will render the scene with the Custom Renderer.

Alright, so that takes care of the Renderer, Scene, and Camera. What about the OrbitControls and the lights? Since we are using Angular Three, we can leverage the declarative approach with the Angular Template to build our reusable Lights and Controls components

1
import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, effect } from '@angular/core'
2
import { extend, injectNgtStore } from 'angular-three'
3
import { HemisphereLight, SpotLight } from 'three'
4
5
extend({ HemisphereLight })
6
7
@Component({
8
selector: 'app-lights',
9
standalone: true,
10
template: `
11
<ngt-hemisphere-light skyColor="#ffffff" groundColor="#bbbbff" [intensity]="0.5" />
12
`,
13
changeDetection: ChangeDetectionStrategy.OnPush,
14
schemas: [CUSTOM_ELEMENTS_SCHEMA],
15
})
16
export class Lights {
17
protected Math = Math
18
19
private store = injectNgtStore()
20
private camera = this.store.select('camera')
21
private scene = this.store.select('scene')
22
23
constructor() {
24
effect(() => {
25
const [camera, scene] = [this.camera(), this.scene()]
26
if (!camera || !scene) return
27
28
const spotLight = new SpotLight(0xffffff, 30, 30, Math.PI / 4, 0.5)
29
spotLight.position.set(0, -2, 0.64)
30
camera.add(spotLight)
31
scene.add(camera)
32
})
33
}
34
}

If we count the line of code in both approaches, the imperative approach seems to have less code but the declarative approach is more composable where we can drop <app-lights /> and/or <app-controls /> in any Scene graph component to have the default lighting and an OrbitControls.

The loading Angular logo is a placeholder for the art-loading branch. In the Angular WebXR Art Sample application, the loading Angular logo is a spinning Angular logo. In the Angular Three rewrite, we use the LoadingScene component to render the spinning Angular logo.

1
// inheritance from SceneComponent
2
export class LoadingComponent extends SceneComponent {
3
/* more code */
4
5
override ngAfterViewInit (): void {
6
// sets up the building blocks
7
super.ngAfterViewInit();
8
9
// Load the logo
10
const model = this.loadersService.loadGLTF( {
11
path: '/assets/models/aLogo.glb',
12
// callback when the model is loaded
13
onLoadCB: this.onLoad.bind( this ),
14
} );
15
16
// create additional light for the loading scene
17
this.createLight();
18
19
};
20
21
createLight () {
22
/* view tab */
23
}
24
25
// Place and animate the logo when loaded
26
onLoad ( model: Object3D ) {
27
/* view tab */
28
}
29
30
animate () {
31
/* view tab */
32
}
33
}

Once again, LoadingComponent inherits the setup from SceneComponent and adds on top of it. It is not apparent how the LoadingComponent contributes to the Scene graph.

Here’s the Angular Three rewrite:

1
import { ChangeDetectionStrategy, Component, computed, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
2
import { extend, injectNgtRef, NgtArgs } from 'angular-three'
3
import { injectNgtsGLTFLoader } from 'angular-three-soba/loaders'
4
import { Mesh, PointLight, SphereGeometry, type Object3D } from 'three'
5
import type { OrbitControls } from 'three-stdlib'
6
import { Controls } from './controls.component'
7
import { Lights } from './lights.component'
8
9
extend({ Mesh, SphereGeometry, PointLight })
10
11
@Component({
12
standalone: true,
13
template: `
14
<!-- addLights -->
15
<app-lights />
16
17
<!-- addControls -->
18
<app-controls [controlsRef]="controlsRef" />
19
20
<!-- angular logo model -->
21
<ngt-primitive *args="[model()]" [position]="[0, 13, -100]" (beforeRender)="onBeforeRender($any($event).object)" />
22
23
<!-- particle light -->
24
<ngt-mesh [position]="[0, 0, -90]" (beforeRender)="onParticleLightBeforeRender($any($event).object)">
25
<ngt-sphere-geometry *args="[0.05, 8, 8]" />
26
<ngt-point-light [intensity]="30" [rotation]="[-Math.PI / 2, 0, 0]" />
27
</ngt-mesh>
28
`,
29
changeDetection: ChangeDetectionStrategy.OnPush,
30
schemas: [CUSTOM_ELEMENTS_SCHEMA],
31
imports: [NgtArgs, Lights, Controls],
32
})
33
export class LoadingScene {
34
protected Math = Math
35
36
protected controlsRef = injectNgtRef<OrbitControls>()
37
38
private gltf = injectNgtsGLTFLoader(() => 'models/aLogo.glb')
39
protected model = computed(() => {
40
const gltf = this.gltf()
41
if (!gltf) return null
42
this.controlsRef.nativeElement.enabled = false
43
return gltf.scene
44
})
45
46
onBeforeRender(object: Object3D) {
47
object.rotation.y += 0.01
48
}
49
50
onParticleLightBeforeRender(object: Mesh) {
51
const timer = Date.now() * 0.00025
52
object.position.x = Math.sin(timer * 7) * 3
53
object.position.y = Math.cos(timer * 5) * 4
54
object.position.z = Math.cos(timer * 3) * 3
55
}
56
}

This version is actually so easy to follow that I will explain piece by piece:

The GalleryComponent is similar to LoadingComponent in a sense that it also extends SceneComponent.

1
export class GalleryComponent extends SceneComponent {
2
/* more code */
3
4
override ngAfterViewInit (): void {
5
6
super.ngAfterViewInit();
7
8
// Focus frame
9
this.createFrames();
10
11
// Environment
12
this.createEnv();
13
14
};
15
16
createFrames () {
17
/* will discuss later */
18
}
19
20
createEnv () {
21
/* will discuss later */
22
}
23
24
/* more code */
25
};

The difference is GalleryComponent creates the Frames for the artworks and creates its own environment on top of the base environment (i.e: lights)

We’ll be looking at the createEnv() method in the GalleryComponent that sets up the environment for the gallery. More specifically, we’ll look at addCornerLights() and the two callbacks onModelLoaded() and onLoadWallsLoaded().

1
export class GalleryComponent extends SceneComponent {
2
/* more code */
3
createEnv () {
4
5
// Lights
6
this.addCornerLights();
7
8
// Add Models
9
// Model for Floor
10
const model = this.loadersService.loadGLTF( {
11
path: "assets/models/floorModel.glb",
12
onLoadCB: ( model: Object3D<Object3DEventMap> ) => {
13
this.onModelLoaded( model );
14
},
15
} );
16
17
// Model for Walls
18
const modelWalls = this.loadersService.loadGLTF( {
19
path: "assets/models/galleryInnerWalls.glb",
20
onLoadCB: ( model: Object3D<Object3DEventMap> ) => {
21
this.onLoadWallsLoaded( model );
22
},
23
} );
24
25
}
26
27
/* more code */
28
}

As you can see here, the imperativeness is an ongoing theme for vanilla THREE.js application development. The GalleryComponent is no exception. The createEnv() method is a mix of setting up the environment, loading models, and setting up materials for the models.

Here’s the Angular Three rewrite:

1
import { ChangeDetectionStrategy, Component } from '@angular/core'
2
import { ARTWORKS } from '../artworks'
3
import { Controls } from './controls.component'
4
import { Floor } from './floor.component'
5
import { Frames } from './frames.component'
6
import { GalleryLights } from './gallery-lights.component'
7
import { Walls } from './walls.component'
8
9
@Component({
10
standalone: true,
11
template: `
12
<!-- addControls -->
13
<app-controls />
14
15
<app-frames [artworks]="artworks" />
16
17
<!-- createEnv -->
18
<app-gallery-lights />
19
<app-floor />
20
<app-walls />
21
<!-- end createEnv -->
22
`,
23
changeDetection: ChangeDetectionStrategy.OnPush,
24
host: { class: 'experience' },
25
imports: [Controls, Frames, Floor, Walls, GalleryLights],
26
})
27
export class GalleryScene {
28
protected artworks = ARTWORKS
29
}

First off, we compose <app-controls /> to set up the OrbitControls. We’ll skip <app-frames /> for now and focus on the createEnv part. We drop <app-gallery-lights />, <app-floor />, and <app-walls /> to the template to set up the additional lights, the floor, and the walls. It is already a lot clearer to see what the GalleryScene is doing.

Composition is the key here once again. We compose <app-lights /> (1) to set up the default lights then add 3 PointLight to the scene to create a corner light effect. Since we operate on the template, we can leverage control flow like @for to create the corner lights based on the predefined corner positions (2).

1
import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
2
import { extend } from 'angular-three'
3
import { PointLight } from 'three'
4
import { Lights } from './lights.component'
5
6
extend({ PointLight })
7
8
@Component({
9
selector: 'app-gallery-lights',
10
standalone: true,
11
template: `
12
<!-- addLights -->
13
<app-lights />
14
15
<!-- addCornerLights -->
16
@for (position of lightPositions; track $index) {
17
<ngt-point-light [intensity]="Math.PI" [distance]="13" [decay]="1" [position]="position" />
18
}
19
`,
20
changeDetection: ChangeDetectionStrategy.OnPush,
21
schemas: [CUSTOM_ELEMENTS_SCHEMA],
22
imports: [Lights],
23
})
24
export class GalleryLights {
25
protected Math = Math
26
protected lightPositions = [
27
[0, 3.2, -10],
28
[10, 3.2, 7.6],
29
[-10, 3.2, 7.6],
30
]
31
}

In fact, since GallaryScene is a composition of Controls, Frames, GalleryLights, Floor, and Walls, I can comment out <app-frames /> and show you what it looks like with just the Controls, GalleryLights, Floor, and Walls

Screenshot of the Angular Three rewrite with just the Controls, GalleryLights, Floor, and Walls Screenshot of the Angular Three rewrite with just the Controls, GalleryLights, Floor, and Walls

Screenshot of the Angular Three rewrite with just the Controls, GalleryLights, Floor, and Walls Screenshot of the Angular Three rewrite with just the Controls, GalleryLights, Floor, and Walls

Well, this is also achievable by commenting out the this.createFrames() in the vanilla THREE.js counterpart. But what if you want to see the difference in the lighting setup with and without the base <app-lights />? You can do so very easily by commenting out <app-lights /> in <app-gallery-lights />

Screenshot of the Angular Three rewrite with just the Controls, GalleryLights (no base Lights), Floor, and Walls Screenshot of the Angular Three rewrite with just the Controls, GalleryLights (no base Lights), Floor, and Walls

Screenshot of the Angular Three rewrite with just the Controls, GalleryLights (no base Lights), Floor, and Walls Screenshot of the Angular Three rewrite with just the Controls, GalleryLights (no base Lights), Floor, and Walls

The Frames

This part consists of 5 image frames based on the artworks() data.

The Frames are created by the createFrames() method in the vanilla THREE.js version. The frames is a Group which contains all 5 individual image frames (1). Then, we iterate over the artworks array to create a frame for each artwork (2). We create the frame by calling createFrame() and then place the frame by calling placeFrame() (3). Finally, we add the frame to the frames group (4).

1
frames: Group<Object3DEventMap> = new Group();
2
3
createFrames ( artworks: Artwork[], cb?: Function ) {
4
this.frames.name = 'Frames Group';
5
// Angle between frames
6
this.angle = ( Math.PI * 2 ) / artworks.length || 5;
7
8
const frames = artworks.map( ( artwork, i ) => {
9
const f = this.placeFrame( this.createFrame( artwork ), i );
10
f.name = `Frame ${i}`;
11
return f;
12
} );
13
14
this.frames.add( ...frames );
15
16
this.frames.position.set( 0, 1.6, 0 );
17
this.focusFrame( 0 );
18
19
return this.frames;
20
}

Let’s look at the rewrite Angular Three version

First, we use the ngt-group to create the Group that contains all 5 image frames. Our Frames component accepts an Input artworks which is an array of Artwork objects. We use the @for control-flow to iterate over the artworks array to create a Frame for each artwork. We also create a CylinderGeometry that all 5 Frame will share.

1
@Component({
2
selector: 'app-frames',
3
standalone: true,
4
template: `
5
<ngt-group name="Frames Group" [position]="[0, 1.6, 0]">
6
@for (artwork of artworks(); track artwork.id) {
7
<app-frame
8
[artwork]="artwork"
9
[geometry]="geometry"
10
/>
11
}
12
</ngt-group>
13
`,
14
changeDetection: ChangeDetectionStrategy.OnPush,
15
schemas: [CUSTOM_ELEMENTS_SCHEMA],
16
imports: [Frame],
17
})
18
export class Frames {
19
artworks = input.required<Artwork[]>()
20
21
protected geometry = new CylinderGeometry(1, 0.85, 0.1, 64, 5)
22
23
private angle = computed(() => (Math.PI * 2) / this.artworks().length || 5)
24
}

There are also placeFrame() and focusFrame() which we touch on in later sections.

The frame

Each image frame consists of three parts:

Screenshot a full frame Screenshot a full frame

Surround all parts is a Group

1
createFrame(artwork: Artwork) {
2
const frame = new Group();
3
}

The frame Mesh

Screenshot the frame only Screenshot the frame only

From the image, you might be able to tell that this is the first part of our Frame component which is the base/frame itself and it is using the CylinderGeometry that we create in the parent Frames component.

Let’s take a look at the code for this part in both versions

The code is removed in part that is not relevant to the frame creation. The Frame is straightforward to create. We create a Mesh with the CylinderGeometry and MeshPhongMaterial and then add it to the frame Group. The geometry is also rotated by 90 degree on the X axis before it is used on the Mesh.

1
createFrame ( artwork: Artwork ) {
2
this.frameGeometry.rotateX( Math.PI / 2 );
3
4
// Create the frame
5
const frameMaterial = this.phongMaterial.clone();
6
frameMaterial.color.set( "rgb(165, 187, 206)" );
7
frameMaterial.needsUpdate = true;
8
const frameMesh = new Mesh( this.frameGeometry, frameMaterial );
9
10
frameMesh.name = `${artwork.title} frame mesh` || 'frame';
11
12
frame.add( frameMesh );
13
14
return frame;
15
}

The image Mesh

Screenshot the image only Screenshot the image only

If I haven’t bragged already, then these images are captured by commenting out parts of the Frame component template. Thanks to Angular Three.

The code is removed in part that is not relevant to the image creation. The image is a Mesh with a BoxGeometry and a MeshPhongMaterial with a Texture applied to it. The image is then added to the frame Group. The image is also accompanied by a SpotLight that targets the image.

1
createFrame ( artwork: Artwork ) {
2
3
const canvasGeometry = new BoxGeometry( 1, 1, 0.12 );
4
5
6
// Create the canvas material with the texture
7
const texture = this.loadersService.loadTexture( artwork.url );
8
texture.colorSpace = SRGBColorSpace;
9
texture.mapping = UVMapping;
10
const canvasMaterial = this.phongMaterial.clone();
11
canvasMaterial.name = 'Canvas Material';
12
canvasMaterial.map = texture;
13
14
15
// Create the canvas mesh
16
const canvasMesh = new Mesh( canvasGeometry, canvasMaterial );
17
canvasMesh.name = 'Canvas';
18
19
20
const light = this.lightsService.createSpotLight();
21
light.target = canvasMesh;
22
light.position.y = 2;
23
24
25
frame.add( canvasMesh, light );
26
27
return frame;
28
}

The buttons

Screenshot of the frame buttons only Screenshot of the frame buttons only

A lot is going on here to create the UI Buttons (Previous, Info, Next). These are created via three-mesh-ui library. The main key of this part is that these elements in the Canvas have to be interactive and have event listeners attached to them. This is tricky in THREE.js because the elements do not have DOM events as they’re elements in a Canvas. This is where the Interaction Manager comes in but it is not developer friendly to add interactions to 3D elements.

1
createFrame ( artwork: Artwork ) {
2
3
const buttons = this.createUI( artwork );
4
5
6
frame.add( buttons );
7
8
return frame;
9
}
10
11
createUI ( artwork: Artwork ) {
12
13
const buttonsPanel = this.UIService.createInteractiveButtons( { buttons: this.buttons, id: artwork.id } );
14
buttonsPanel.position.x = 0;
15
buttonsPanel.position.y = -0.7;
16
buttonsPanel.position.z = -0.2;
17
18
buttonsPanel.rotateY( Math.PI );
19
buttonsPanel.rotateX( -0.55 );
20
21
return buttonsPanel;
22
}
23
24
createInteractiveButtons ( options: any ) {
25
const ops = Object.assign( {}, this.defaultOptions, options );
26
27
const container = new ThreeMeshUI.Block(
28
{
29
justifyContent: 'center',
30
contentDirection: 'row-reverse',
31
fontFamily: this.FontJSON,
32
fontTexture: this.FontImage,
33
fontSize: 0.1,
34
padding: 0.02,
35
borderRadius: 0.11,
36
height: 0.2,
37
width: options.buttons.length / 2,
38
}
39
);
40
container.name = ops.name;
41
this.container = container;
42
43
44
ops.buttons.forEach( ( o: any, i: number ) => {
45
46
const button = this.createButton( ops.id, o );
47
this.container.add( button );
48
} );
49
50
return container;
51
};
52
53
createButton ( id: number, ops?: any ) {
54
55
const btn = new ThreeMeshUI.Block( this.buttonOptions );
56
btn.name = `Frame ${id} ${ops.name}`;
57
58
59
btn.add( new ThreeMeshUI.Text( {
60
content: ops.text,
61
name: `${ops.name} Text`,
62
} ) );
63
64
65
// @ts-ignore
66
btn.setupState( {
67
state: 'selected',
68
attributes: this.selectedAttributes,
69
} );
70
71
// @ts-ignore
72
btn.setupState( this.idleStateAttributes );
73
// @ts-ignore
74
btn.setupState( this.hoveredStateAttributes );
75
76
77
btn.position.set( -0.5, 0, 0 );
78
79
80
this.interactions.addToInteractions( btn );
81
this.interactions.addToColliders( { mesh: btn, name: ops.name, cb: () => { ops.onClick( id ); } } );
82
// @ts-ignore
83
btn.addEventListener( 'click', () => { ops.onClick( id ); } );
84
85
return btn;
86
}

Other details

We skip a couple of methods like placeFrame(), focusFrame(), and moveFrame(). These methods are used to position the frame relative to the Frames group and to focus on a particular frame at a time. The way we implement this in Angular Three is different than in Vanilla THREE.js, not in the logic themselves but where to put them. In Angular Three, we leverage afterAttach event as a way to ensure the elements are ready before we perform any action on them.

1
@Component({
2
selector: 'app-frames',
3
standalone: true,
4
template: `
5
<ngt-group
6
name="Frames Group" [position]="[0, 1.6, 0]"
7
8
(afterAttach)="onFramesGroupAttached($any($event).node)"
9
>
10
@for (artwork of artworks(); track artwork.id) {
11
<app-frame
12
[artwork]="artwork"
13
[geometry]="geometry"
14
15
(frameAttached)="onFrameAttached($event, $index)"
16
/>
17
}
18
</ngt-group>
19
`,
20
changeDetection: ChangeDetectionStrategy.OnPush,
21
schemas: [CUSTOM_ELEMENTS_SCHEMA],
22
imports: [NgtArgs, Frame],
23
})
24
export class Frames {
25
artworks = input.required<Artwork[]>()
26
27
protected geometry = new CylinderGeometry(1, 0.85, 0.1, 64, 5)
28
29
private frames = signal<Group>(null!)
30
31
32
onFrameAttached(frame: Group, index: number) {
33
frame.rotateY(Math.PI)
34
const alpha = index * this.angle()
35
const x = Math.sin(alpha) * 7 // 0 - 1
36
const z = -Math.cos(alpha) * 7 // 0 - 0
37
frame.position.set(x, 0, z)
38
frame.rotation.y = alpha
39
frame.userData['originalPosition'] = frame.position.clone()
40
checkUpdate(frame)
41
}
42
43
44
onFramesGroupAttached(frames: Group) {
45
this.frames.set(frames)
46
// NOTE: we want to run this after all frames are attached
47
queueMicrotask(() => {
48
const f = frames.children[0]
49
this.focusFrame(f)
50
})
51
}

The interactions

Last step is to convert the Next, Previous, and Play Info events over. This is done by emitting the next, previous, and playInfo outputs from the FrameButtons component -> Frame component then handle the events in Frames component.

1
@Component({
2
selector: 'app-frames',
3
standalone: true,
4
template: `
5
<ngt-group name="Frames Group" [position]="[0, 1.6, 0]" (afterAttach)="onFramesGroupAttached($any($event).node)">
6
@for (artwork of artworks(); track artwork.id) {
7
<app-frame
8
[artwork]="artwork"
9
[geometry]="geometry"
10
(frameAttached)="onFrameAttached($event, $index)"
11
12
(next)="onNext($event)"
13
(previous)="onPrevious($event)"
14
(playInfo)="onPlayInfo($event)"
15
/>
16
}
17
</ngt-group>
18
`,
19
changeDetection: ChangeDetectionStrategy.OnPush,
20
schemas: [CUSTOM_ELEMENTS_SCHEMA],
21
imports: [NgtArgs, Frame],
22
})
23
export class Frames {
24
artworks = input.required<Artwork[]>()
25
26
protected geometry = new CylinderGeometry(1, 0.85, 0.1, 64, 5)
27
28
private speechClient = inject(SpeechClient)
29
30
private frames = signal<Group>(null!)
31
private angle = computed(() => (Math.PI * 2) / this.artworks().length || 5)
32
33
34
onNext(currentId: number) {
35
const currentFrame = this.frames().children[currentId]
36
this.resetFramePosition(currentFrame)
37
38
// Rotate to Next frame
39
const i = currentId < 5 - 1 ? currentId + 1 : 0
40
this.rotateFrames(72)
41
this.focusFrame(this.frames().children[i])
42
}
43
44
45
onPrevious(currentId: number) {
46
const currentFrame = this.frames().children[currentId]
47
this.resetFramePosition(currentFrame)
48
49
// Rotate to Previous
50
const i = currentId === 0 ? 5 - 1 : currentId - 1
51
this.rotateFrames(-72)
52
this.focusFrame(this.frames().children[i])
53
}
54
55
56
onPlayInfo(artwork: Artwork) {
57
const text = artwork.description || artwork.title
58
if (text) {
59
void this.speechClient.speak(text)
60
}
61
}
62
63
onFrameAttached(frame: Group, index: number) {
64
frame.rotateY(Math.PI)
65
const alpha = index * this.angle()
66
const x = Math.sin(alpha) * 7 // 0 - 1
67
const z = -Math.cos(alpha) * 7 // 0 - 0
68
frame.position.set(x, 0, z)
69
frame.rotation.y = alpha
70
frame.userData['originalPosition'] = frame.position.clone()
71
checkUpdate(frame)
72
}
73
74
onFramesGroupAttached(frames: Group) {
75
this.frames.set(frames)
76
// NOTE: we want to run this after all frames are attached
77
queueMicrotask(() => {
78
const f = frames.children[0]
79
this.focusFrame(f)
80
})
81
}
82
83
private focusFrame(frame: Object3D) {
84
const x = (frame.position.x / 7) * 4
85
const z = (frame.position.z / 7) * 4
86
const p = new Vector3(x, frame.position.y, z)
87
this.moveFrame(frame, p)
88
}
89
90
private moveFrame(frame: Object3D, position: Vector3) {
91
animate({
92
from: frame.position,
93
to: position,
94
duration: 2500,
95
ease: easeInOut,
96
onUpdate: (latest) => {
97
frame.position.x = latest.x
98
frame.position.y = latest.y
99
frame.position.z = latest.z
100
},
101
onComplete: () => {
102
checkUpdate(frame)
103
},
104
})
105
}
106
107
private resetFramePosition(frame: Object3D) {
108
const position = frame.userData['originalPosition']
109
this.moveFrame(frame, position)
110
}
111
112
private rotateFrames(angle: number = 72) {
113
// angle between frames and the current group rotation
114
const y = MathUtils.degToRad(angle) + this.frames().rotation.y
115
animate({
116
from: this.frames().rotation.y,
117
to: y,
118
duration: 1000,
119
ease: easeInOut,
120
onUpdate: (latest) => (this.frames().rotation.y = latest),
121
})
122
}
123
}

And voila, the application is converted fully to Angular Three. Here’s the GIF of the running application again.

Animated gif of the Angular WebXR application Animated gif of the Angular WebXR application

Github:

Conclusion

In this blog post, we have successfully converted a Vanilla THREE.js application to Angular Three. We have seen how Angular Three can help us to write cleaner and more maintainable code by leveraging Angular’s template syntax and TypeScript. We have also seen how Angular Three can help us to manage the complexity of a 3D application by providing a declarative way to create and manage 3D elements.

The process is super fun for me personally and I hope you enjoy reading it as much as I enjoy writing it. If you have any questions or feedback, feel free to reach out to me on Twitter.

Have fun coding!

Published on Sun May 19 2024


Angular Three.js