Yesterday I wrote this post describing a Three.js project I worked on a year ago and a bug that I was encountering. I thought I would write a long series of posts in the process of trying to fix it, so this might come as a bit of an anticlimax: I figured it out this morning. I've been putting this off for weeks, and now it's done.
So what happened? Well, the most important clue was that the bug only happened when using the mouse, so it must have been caused by something that happens when we do something with the mouse, such as hover over the cube or click it.
Maybe even something that happens many, many times a second?
So what happens when we move the mouse? We check if the cursor is 'above' one of the faces of the cube and highlight every cubie on that side, to make it clear that if the user clicks the cube at this point, this is the side that will rotate.
Except, this code doesn't just run when we move the mouse. It runs on every frame. Why? Well, when I wrote it, I reasoned that the cube is also rotating on its own, so the 'selected' side could change even if the mouse isn't moving.
So how exactly were we highlighting the currently selected side? First, we cast a ray to see which face of the cube it intersected, then we added each cubie that was currently on this side to a group (this is what updateSide(i) does), and then for each cubie in this group, we set the material to be emissive.
update()
{
this.group.rotation.x += 0.002;
this.group.rotation.y += 0.002;
if (this.animating !== -1)
{
// ...
return;
}
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObject(this.hitbox);
for (const cubie of this.cubies)
{
cubie.mesh.material.emissive.set(0x000000);
}
if (!intersects[0])
{
return;
};
const face = intersects[0].face;
for (let i = 0; i < 6; i++)
{
const key = Object.keys(Cubie.directions)[i];
if (face.normal.equals(Cubie.directions[key]))
{
this.updateSide(i);
for (const cubie of this.sides[i].array)
{
cubie.mesh.material.emissive.set(0x444444);
}
}
}
}
Let's look at updateSide() for a second:
updateSide(sideIndex)
{
if (this.animating !== -1) return;
this.cubies.forEach(cubie =>
{
if (this.isOnSide(cubie, sideIndex))
{
if (!this.sides[sideIndex].array.includes(cubie))
this.sides[sideIndex].array.push(cubie);
this.sides[sideIndex].group.attach(cubie.mesh);
}
else
{
this.sides[sideIndex].array = this.sides[sideIndex].array.filter(c => c !== cubie);
this.theRest.attach(cubie.mesh);
}
});
}
So updateSide() runs both when we hover over a side and when we want to rotate a side (either by clicking it or by pressing a key). The point of this function is to check the actual position of each of the cubies and tell us which ones are currently, say, on the left face of the cube. We add these cubies to a Group, mainly so we can rotate the group as a whole, and not have to bother with rotating each cubie individually.
When you add objects to a group in Three.js using .attach(), it updates their local transforms based on their world matrices. If this is done every frame, tiny floating point errors can accumulate.
Ok, so let's step back. We were performing this calculation on every frame because we wanted to make sure that the currently highlighted side is still 'under our mouse', i.e. it hasn't rotated away. For this reason, we do need to cast a ray on every frame to check which face of the cube it intersects. But we don't need to actually call updateSide() on every frame. We only need to call it if the intersected side is different from last time. So if we add a field in Cube, let's call it highlightedSide, we can check if it changed, and only then do the whole [find out which cubes belong to this side, attach them to a group, highlight them] thing.
So then, our new update() function looks like this:
update()
{
this.group.rotation.x += 0.002;
this.group.rotation.y += 0.002;
if (this.animating !== -1)
{
// ...
return;
}
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObject(this.hitbox);
if (!intersects[0])
{
for (const cubie of this.cubies)
{
cubie.mesh.material.emissive.set(0x000000);
}
this.highlightedSide = null;
return;
};
const face = intersects[0].face;
for (let i = 0; i < 6; i++)
{
const key = Object.keys(Cubie.directions)[i];
if (this.highlightedSide !== i && face.normal.equals(Cubie.directions[key]))
{
this.updateSide(i);
this.highlightedSide = i;
for (const cubie of this.cubies)
{
cubie.mesh.material.emissive.set(0x000000);
}
for (const cubie of this.sides[i].array)
{
cubie.mesh.material.emissive.set(0x444444);
}
}
}
}
So tl;dr, we had some unnecessary matrix multiplications happening on every single frame, which eventually led to inaccuracies. This also gives us a clue as to why the bug seemed less likely to happen on my ancient Thinkpad: the frame rate was 30 fps, so it would take twice as long to get to n matrix multiplications (where n is however many it takes to produce an obviously wrong result).
In the process of fixing this bug, I grew quite attached to the 'broken' cube (don't physical objects also break if we play with them too much?), so I decided to deploy it here.
And of course, the current version can be found here.
Top comments (0)