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.
Before diving into the conversion process, let’s understand the Angular WebXR Art Sample application entry point: MuseumComponent
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
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:
A Scene
A Camera, more specifically a PerspectiveCamera
A Renderer, more specifically a WebGLRenderer
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.
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:
In this version, MuseumCanvas is the component that is responsible for setting up the required building blocks of a 3D scene graph.
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 reusableLights and Controls components
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
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.
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:
This version is actually so easy to follow that I will explain piece by piece:
The LoadingScene adds the Lights and Controls components to the scene by dropping <app-lights /> and <app-controls /> to the template.
For the model, we use injectNgtsGLTFLoader() in combination with <ngt-primitive> special element from Angular Three to display the model. We can apply any properties to the underlying Object3D from the external model by using Property Binding on the template.
We can use (beforeRender) custom event to allow the Object to participate in the render loop.
For the particle light, we, once again, use the template by leveraging the <ngt-mesh> element to create the Mesh.
As the content child for the Mesh, we use ngt-point-light to create the PointLight
The particle light also participates in the render loop so we use (beforeRender) custom event to animate the light.
The Gallery
The GalleryComponent is similar to LoadingComponent in a sense that it also extends SceneComponent.
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().
GalleryComponent adds 3 PointLight to the scene to create a corner light effect.
Once we load the model, we’ll create a MeshPhysicalMaterial for every Object3D in the model itself. However, if the Object3D is the floor, we’ll create a MeshStandardMaterial for it with additional textures.
We load 3 different files for 3 different textures to apply to the Floor’s material.
For the walls, we just set the position and scale of the model.
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:
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).
For the floor, we use ngt-primitive in combination with injectNgtsGLTFLoader(). Interestingly, we also use injectNgtsTextureLoader() to load a dictionary of textures for the floor. We then apply the textures to the MeshStandardMaterial for the floor.
The walls is straightforward with ngt-primitive and injectNgtsGLTFLoader()
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
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
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).
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.
There are also placeFrame() and focusFrame() which we touch on in later sections.
The frame
Each image frame consists of three parts:
The Frame itself which is a Mesh
The image which is a Mesh with a Texture
The buttons which are Mesh with Text on them (from three-mesh-ui)
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 frameGroup. The geometry is also rotated by 90 degree on the X axis before it is used on the Mesh.
As straightforward as the vanilla THREE.js version, we use the ngt-mesh element to create the Mesh with the passed in CylinderGeometry and a ngt-mesh-phong-material.
Notice the (afterAttach) event that is used to call the onAfterAttach() method on the frame mesh. This is where we rotate the geometry. In the Angular Three version, we have to use the queueMicrotask() to ensure that the frameMesh is attached to the frameGroup before we rotate the geometry.
afterAttach() is a special event in Angular Three that is triggered when the element is attached to its parent element. This is useful when we need to perform an action on the element after it is attached to the scene graph.
The image Mesh
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 frameGroup. The image is also accompanied by a SpotLight that targets the image.
Notice how we can use Template Variable#canvasMesh to reference the Mesh element and assign it to the [target] input on the SpotLight? That is the power of Angular Three declarative approach. Everything that is familiar to Angular developers is available by using Angular Three.
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.
Things look a little manageable here in the Angular Three version. First, we use extend() to make Block available as ngt-mesh-block and Text available as ngt-mesh-text. Then, everything just happens on the template, even the Event Listener part via a familiar Event Binding syntax (click). Since FrameButtons is a component, it can have Input as well as Outputs so we attach the next, previous, and playInfo outputs to the (click) event on the buttons accordingly.
You might be wondering what is (click)="$any($event).object.name === 'MeshUI-Frame' && button.onClick()"? Angular Three provides its own event system out of the box. Block from three-mesh-ui creates an implicit Frame element as well as a Block element so Angular Three’s event system captures two events when we click on the Frame element: one event for Block, and one event for Frame. We use $any($event).object.name === 'MeshUI-Frame' as a workaround to ensure that the event is only triggered once.
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.
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.
And voila, the application is converted fully to Angular Three. Here’s the GIF of the running application again.
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.