32 Mixing HTML and WebGL
Difficulty Hard
Introduction
In this lesson, we will learn how to integrate HTML into the scene. By that, I mean to have an interactive HTML element that follows a specific 3D position on the scene, so it looks like they are part of the WebGL.
To demonstrate that, we are going to add interest points to a model. Those interest points will be made with HTML and will always stick to their associated 3D position. And because those points will be made of HTML, we will be able to design them using CSS, add interactions like :hover
and add transitions.
Setup
The starter is composed of what we did in the Intro and loading progress lesson on in the Post-processing lesson with the Damaged Helmet by Leonardo Carrion.
HTML
First, let’s create one HTML point. At the end of the lesson, we will add more points.
In the /src/index.html
file, add the point after the <canvas>
and the loading bar:
<canvas class="webgl"></canvas>
<div class="loading-bar"></div>
<div class="point point-0">
<div class="label">1</div>
<div class="text">Lorem ipsum, dolor sit amet consectetur adipisicing elit</div>
</div>
HTML
Copy
The text we put inside that point shouldn’t be visible on the page because it’s hidden behind the <canvas>
.
We used the point
class to be able to target all the points in CSS but also the point-0
class to target this specific element in the JavaScript. Next points will have point-1
, point-2
, etc.
Our point is composed of a <div>
with a label
class and a <div>
with a text
class.
The label will be the small round number that looks like it’s stuck onto the model, and the text will show up when we hover that label.
CSS
We are going to add the whole CSS in one raw. Usually, you would have to do it progressively, but we know where we are going.
Go to the /src/style.css
file and start by positioning the point in the middle of the screen:
.point
{
position: absolute;
top: 50%;
left: 50%;
}
CSS
Copy
You should see the black text in the middle of the screen. We start from the center because Three.js will provide coordinates where 0
is the center of the screen.
Let’s design the label first:
.point .label
{
position: absolute;
top: -20px;
left: -20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #00000077;
border: 1px solid #ffffff77;
color: #ffffff;
font-family: Helvetica, Arial, sans-serif;
text-align: center;
line-height: 40px;
font-weight: 100;
font-size: 14px;
}
CSS
Copy
You should see the label.
Nothing too hard here. We simply rounded the element with border-radius: 50%;
, used a black background having a reduced opacity with background: #00000077;
; and did the same for the border with border: 1px solid #ffffff77;
. Then, we centered the white text in the middle of the element.
Let’s do the same for the text:
.point .text
{
position: absolute;
top: 30px;
left: -120px;
width: 200px;
padding: 20px;
border-radius: 4px;
background: #00000077;
border: 1px solid #ffffff77;
color: #ffffff;
line-height: 1.3em;
font-family: Helvetica, Arial, sans-serif;
font-weight: 100;
font-size: 14px;
}
CSS
Copy
You should see the text below the label.
What we achieved here is very similar to the label, but with a fixed border-radius
, a specific line-height
, and a padding
.
Now that we have our two elements let’s prepare the interactions. First, we want our text to be hidden by default.
Set its opacity
to 0
:
.point .text
{
/* ... */
opacity: 0;
}
CSS
Copy
When the .point
is being hovered, we want the text to appear:
.point:hover .text
{
opacity: 1;
}
CSS
Copy
The text should show up when the label is hovered.
Instead of this crude apparition, we want the text to fade in and out. To do so, we can use a transition
.
Add the transition to the text but not just when it’s hovered. This way, the transition will also occur when the cursor leaves the point:
.point .text
{
/* ... */
transition: opacity 0.3s;
}
CSS
Copy
The text should fade in and out.
We have a small problem. We can hover the text directly while it is invisible.
To fix that, we can deactivate the pointer events on the text. This can be done with the pointer-events
CSS property:
.point .text
{
/* ... */
pointer-events: none;
}
CSS
Copy
The text shouldn’t be “hoverable”.
Currently, the cursor changes to a text selection when we hover the number in the label. To fix that, we can change the cursor on the whole label with the cursor
CSS property.
.point .label
{
/* ... */
cursor: help;
}
CSS
Copy
A “?” should appear in place of the cursor when hovering the label.
We almost completed our work with the CSS. What we need to do now is prepare a way to show and hide the label. So, we will hide them by default and show them only when there is a visible
class on the point.
To hide them, we are going to use a transform
with a scale
, and we will also add a transition so that the scale will be animated:
.point .label
{
/* ... */
transform: scale(0, 0);
transition: transform 0.3s;
}
.point.visible .label
{
transform: scale(1, 1);
}
CSS
Copy
The labels should be gone. But if we add the visible
class directly to the element using the Developer Tools panel, we should see the label showing up.
For now, let’s add the visible
class directly in the HTML so that we can position the points. We will remove the class later.
<div class="point point-0 visible">
HTML
Copy
The point should now be visible.
JavaScript
Now that everything is ready in both the HTML and the CSS, we can switch to the JavaScript.
Storing the points
First, we need a way to store all the points —even if we only have one right now. We are going to use an array of objects with each object corresponding to one point.
Each point object will have two properties: the 3D position and a reference to the HTML element.
Create the points
array with one point inside:
const points = [
{
position: new THREE.Vector3(1.55, 0.3, - 0.6),
element: document.querySelector('.point-0')
}
]
JavaScript
Copy
We used a Vector3 for the position and document.querySelector(...)
to retrieve the element from the HTML.
Updating the point positions
We are going to update the points HTML elements on each frame directly in the tick
function.
In the tick
function, right after updating the controls, loop through each element in the points
array —even if we only have one. You can use any loop technique, but we will go for a for(... of ...)
:
const tick = () =>
{
// Update controls
controls.update()
// Go through each point
for(const point of points)
{
}
// ...
}
JavaScript
Copy
We need to get the 2D screen position of the 3D scene position of the point. This part is pretty straightforward.
First, we need to clone the point’s position. This is important because the following code will directly modify the Vector3. To clone the position, use the clone()
method:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
}
// ...
}
JavaScript
Copy
To get the 2D screen position, we need to call the project(...)
method and use the camera
as parameter:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
console.log(screenPosition.x)
}
// ...
}
JavaScript
Copy
You should see a value close to 0
in the logs. If you move the camera so that the helmet is on the far left —use right-click and drag & drop— you’ll get a value close to -1
. If you move the camera so that the helmet is on the far right, you’ll get a value close to +1
.
Let’s focus on the x
value and we will see the y
later on.
The problem is that we cannot use this value like that. We want to be able to move the point element using pixels.
To go from that projected screen position to the pixels on the screen, we need to multiply by half the size of the render, and we already have this value in the sizes
object:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
console.log(translateX)
}
// ...
}
JavaScript
Copy
Again, move the camera to position the helmet on the far sides and look at the value in the logs. It should be a few hundred.
Now that the value seems right let’s update the point element. To do that, we will apply the translation in the style
's transform
property. Don’t forget to write px
at the end of the translateX
function:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
point.element.style.transform = `translateX(${translateX}px)`
}
// ...
}
JavaScript
Copy
The point seems to be moving well on the X axis. Let’s add the Y axis.
Add a translateY
variable using the y
property and the sizes.height
value. Then, apply it to the transform:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
const translateY = screenPosition.y * sizes.height * 0.5
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`
}
// ...
}
JavaScript
Copy
We are almost there, sadly, the Y axis seems inverted. Indeed, in CSS, a positive translateY
goes down while in Three.js, a positive y
goes up.
We need to negate the translateY
variable:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
const translateY = - screenPosition.y * sizes.height * 0.5
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`
}
// ...
}
JavaScript
Copy
The point element should follow its 3D position perfectly.
Showing and hiding the element
To finish, we need to hide the point if there is something in front of it.
First, remove the visible
class in the HTML:
<div class="point point-0">
HTML
Copy
To test if something is in front of the point, we will use Raycaster. We’ll shoot a ray from the camera to the point. If there is no intersecting object, we show the point. If there is something, we test the distance of the intersection. If the intersection point is further than the point, it means the object is behind the point, and we can show it. If the intersection point is closer than the point, the intersecting object is in front of the point, and we hide it.
Let’s create a Raycaster anywhere out of the tick
function:
const raycaster = new THREE.Raycaster()
JavaScript
Copy
In the tick
function, update the Raycaster, so it goes from the camera to the point. To do so, use the setFromCamera(...)
.
If you remember from the Raycaster lesson, the first parameter of setFromCamera(...)
should be a Vector2 corresponding to the position on the screen and the second parameter should be the camera.
In the Raycaster lesson, we had to convert the cursor’s screen position (in pixel) to a value adapted to Three.js coordinates. Yet, in our case, we already have those values. We can instead use the screenPosition
. Yes screenPosition
is a Vector3, but only its x
and y
properties will be used:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
// ...
}
// ...
}
JavaScript
Copy
We are going to test the Raycaster with the intersectObjects(...)
method against every objects in the scene.
To do that, use the scene.children
as the first parameter, and true
as the second parameter. This second parameter will enable recursive testing, meaning that the algorithm will go through the children of the children too and children of the children of the children, etc:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
// ...
}
// ...
}
JavaScript
Copy
First, what we can do is test if there are intersects. If there is no intersection, there is no object in front of the point, and we can show it. If there are intersects, we will have to do more tests, but for now, let’s just hide the point.
To show the point, we can add the visible
class with classList.add(...)
. Whereas to hide the point, we can remove the visible
class with classList.remove(...)
:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
point.element.classList.remove('visible')
}
// ...
}
// ...
}
JavaScript
Copy
Not exactly what we want, but we are getting there.
The problem is that we are hiding the point if anything intersects with the ray. But if the intersection is behind the point, this one shouldn’t hide.
We need to calculate the distance to the point, then calculate the intersection’s distance and compare them.
The intersectObjects(...)
methods return an array of intersections. These intersections are sorted by distance, with the closest first. That means that we don’t have to test all of the intersections if there are multiple, and we can merely test the first one.
To get the intersection distance, it’s manageable because this value is already available in the intersection object as the distance
property:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
const intersectionDistance = intersects[0].distance
}
// ...
}
// ...
}
JavaScript
Copy
Getting the point distance is a little harder. We need to start from the point position
, and because it’s a Vector3, we can use its distanceTo()
method.
This method needs another Vector3 as parameter. It will calculate the distance between the first one and the second one. We can use the position
of the camera
:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
const intersectionDistance = intersects[0].distance
const pointDistance = point.position.distanceTo(camera.position)
}
// ...
}
// ...
}
JavaScript
Copy
All we need to do now is compare those two distances. If intersectionDistance
is inferior to pointDistance
, it means that the object intersecting is closer than the point and should be hidden. Otherwise, the point should appear:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
const intersectionDistance = intersects[0].distance
const pointDistance = point.position.distanceTo(camera.position)
if(intersectionDistance < pointDistance)
{
point.element.classList.remove('visible')
}
else
{
point.element.classList.add('visible')
}
}
// ...
}
// ...
}
JavaScript
Copy
Everything is working fine now.
Wait for the scene to be ready
The problem we have now is that the keypoints are visible when the scene is loading. An easy fix would be to create a variable to false
and set it to true
once everything is ready. In the tick
function, we would only update the points if this variable is true
.
Create a sceneReady
variable to false
and, in the loadingManager
success function, after 2000ms
set it to true
:
let sceneReady = false
const loadingManager = new THREE.LoadingManager(
// Loaded
() =>
{
// ...
window.setTimeout(() =>
{
sceneReady = true
}, 2000)
},
// ...
)
JavaScript
Copy
Finally, in the tick
function, put the whole points loop in a if
statement:
const tick = () =>
{
// ...
if(sceneReady)
{
for(const point of points)
{
// ...
}
}
// ...
}
JavaScript
Copy
The points should only show when the scene is ready, and the intro almost fully gone.
Add more points
That’s it! We have our point working nicely. We can now add more points and change the texts for something a little more convincing.
In the HTML:
<div class="point point-0">
<div class="label">1</div>
<div class="text">Front and top screen with HUD aggregating terrain and battle informations.</div>
</div>
<div class="point point-1">
<div class="label">2</div>
<div class="text">Ventilation with air purifier and detection of environment toxicity.</div>
</div>
<div class="point point-2">
<div class="label">3</div>
<div class="text">Cameras supporting night vision and heat vision with automatic adjustment.</div>
</div>
HTML
Copy
In the JavaScript:
const points = [
{
position: new THREE.Vector3(1.55, 0.3, - 0.6),
element: document.querySelector('.point-0')
},
{
position: new THREE.Vector3(0.5, 0.8, - 1.6),
element: document.querySelector('.point-1')
},
{
position: new THREE.Vector3(1.6, - 1.3, - 0.7),
element: document.querySelector('.point-2')
}
]
JavaScript
Copy
Going further
What we did here is one way, among many others. You probably would have done it differently, and it might be even better. It depends on you, the project, the animations, the performances, how flexible the code should be, etc.
There is also room for performance improvement. Currently, we are updating all the points on each frame. We could only update the visible ones.
Keep in mind that combining HTML with WebGL is usually bad for performances. Avoid doing it, and if you have no other choice, keep an eye on the frame rate and regularly test on different devices.
33 The end
Difficulty Easy
Outroduction
Congratulations, you’ve made it. You’ve finally reached the end of this journey. Yet, as you can imagine, it’s not the end of all the endings. It’s time for you to continue on your own. You now have a great understanding of Three.js and enough experience to venture on your own.
You will stumble on obstacles, but these will be great for you, and you’ll feel proud once you pass them. Each impediment is a way of becoming a better self, and you should face them like interesting challenges.
There is still a lot to learn. I’ve been using Three.js for years, and I keep discovering new technics. Stay humble, look at the latest projects, try to learn from them, and always have eyes for higher prizes. You can always go further and do better. Do not stay in your comfort zone; you won’t progress.
I recommend you to experiment. If you find inspiration like an image or a video that you like, try to reproduce it in WebGL. Maybe it’s a good idea, perhaps not. Anyway, you’ll learn from it. Please find what you like the most, and keep exploring it.
If you create things that you want to show, share them on Twitter or anywhere you like. Even if you think it’s small or inadequate, don’t forget that there was a time you knew absolutely nothing about Three.js. People will start noticing you, give you advice, and clients or recruiters will soon contact you (if this is what you are looking for).
Speaking of Twitter, don’t hesitate to share your projects with the #threejsJourney hashtag or directly mention me @bruno_simon
If you have questions, join the public or the members-only discord server, me or the community will do our best to help.
I hope you liked this course and you’re happy with what you’ve learned.
Thank you.