ThreeJs: How to Click

ThreeJs: How to Click

Chapter 4: Fiddling with Ray Casting and making ThreeJs Objects respond to Clicks and Hovers

ยท

9 min read

Introduction ๐Ÿซฑ๐Ÿฝโ€๐Ÿซฒ๐Ÿฟ

ThreeJs renders everything on a plain HTML Canvas, that raises an interesting question, how do we interact with individual objects inside your 3D environment ?

We can't identify a click as we usually do with the DOM as the target of the click will always be the Canvas.

In this article we'll go over the concept and implementation of a clever clicking method, the ThreeJs way!

Prerequisite โœ”๏ธ

We went over the process of setting up a bunch of scattered cubes with ThreeJs in the last chapter, we won't go over that again here.

This is the boilerplate we're using,

<div style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;" class="threeD"></div>
<script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
<script type="importmap">
{
    "imports": {
        "three": "https://unpkg.com/three/build/three.module.js"
    }
}
</script>
<script type="module">
    import * as THREE from "three";
    import { OrbitControls } from "https://unpkg.com/three/examples/jsm/controls/OrbitControls.js";

    const init = (
        targetClass = "threeD"
    ) => {
        const target = document.getElementsByClassName(targetClass);
        if (target) {
            // Renderer and Scene Setup
            const renderer = new THREE.WebGLRenderer({ alpha: true });
            const Cwidth = () => {
                return document.getElementsByClassName(targetClass)[0].clientWidth;
            };
            const Cheight = () => {
                return document.getElementsByClassName(targetClass)[0].clientHeight;
            };
            renderer.shadowMap.enabled = false;
            renderer.setPixelRatio(window.devicePixelRatio);
            renderer.setSize(Cwidth(), Cheight());
            renderer.physicallyCorrectLights = true;
            renderer.domElement.setAttribute("id", "three-canvas");

            target[0].appendChild(renderer.domElement);
            const scene = new THREE.Scene();
            const ambientLight = new THREE.AmbientLight(0xffffff, 2);
            scene.add(ambientLight);

            // Camera Setup
            const aspect = Cwidth() / Cheight();
            const fov = 60;
            const near = 1.0;
            const far = 200.0;
            const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
            camera.position.set(0, 30, 0);
            camera.lookAt(0, 0, 0);

            // Setting Up Orbital Controls 
            const controls = new OrbitControls(camera, renderer.domElement);
            controls.update();

            // Adding Stuff to Our World  
            // Add Point Light
            const light = new THREE.PointLight(
                0xffffff /* Color */,
                80 /* Intensity */,
                100 /* Maximum Range of Light */
            );
            light.position.set(30, 30, 30);
            scene.add(light);

            // Add Primitive Shapes
            const randomIntFromInterval = (min, max) => { // min and max included 
                return Math.floor(Math.random() * (max - min + 1) + min)
            }

            const spawnRandomCube = () => {
                // Make Shape
                const size = randomIntFromInterval(2, 7);
                const geometry = new THREE.BoxBufferGeometry(
                    size /** height */,
                    size /** width */,
                    size /** length */);
                // Make Texture
                const material = new THREE.MeshStandardMaterial({ color: 0x639af6 });
                // Make Cube
                const cube = new THREE.Mesh(geometry, material);
                // Random Position
                cube.position.set(
                    randomIntFromInterval(-80, 80),
                    randomIntFromInterval(-80, 80),
                    randomIntFromInterval(-80, 80)
                );
                // Random Angle
                cube.rotation.set(
                    THREE.Math.degToRad(randomIntFromInterval(0, 90)),
                    THREE.Math.degToRad(randomIntFromInterval(0, 90)),
                    THREE.Math.degToRad(randomIntFromInterval(0, 90))
                )
                // Add to Scene
                scene.add(cube);
            }

            for (let i = 0; i < 200; i++) spawnRandomCube();

            // Play Animation
            const RAF = () => {
                requestAnimationFrame(() => {
                    // Animations

                    // Recursively Render
                    renderer.render(scene, camera);
                    RAF();
                });
            };

            RAF();

            // Responsiveness
            window.addEventListener(
                "resize",
                () => {
                    camera.aspect = Cwidth() / Cheight();
                    renderer.setSize(Cwidth(), Cheight());
                    camera.updateProjectionMatrix();
                },
                false
            );
        }
    };

    init();
</script>

Concept ๐Ÿค”

Let's go back to the basics,

We know that whatever the camera sees is being rendered on the canvas,

Camera - Canvas Equivalence

So visually, there's no difference if the cursor was being rendered by browser on top of the canvas or on just above the camera by ThreeJs, so let's pretend the latter for a minute here, in that case...

Cursor in a ThreeJs Scene

So now that we know where the cursor is half of our problem is solved!

Now all we have to do is Cast a ray from where the cursor is supposed to be perpendicular to our camera's plane,

Ray cast from cursor, illustration

Now whatever intersects with that ray, we can say is the object our cursor is "hovering" over : )

We can do this Ray casting thing over and over constantly to check what our cursor is hovering over.

We can now, having the hover object, easily do "click actions", "hover actions" etc with any objects in the Scene.

A few things to keep in mind here though, you have to keep Ray Casting over and over to stay updated on what the "hover object" is in real time.

Ray Casting is really expensive so doing it too frequently and too much might cause lag, so in our example here we're doing it every 100 ms.

If you're not too concerned with performance you can make it more frequently, just be aware of the trade off.

Implementation โš™๏ธ

This will be right after we set up our Orbit Controls

First we set up a store of items we want to respond to.

It'll just be an array of those objects, but let's give it a proper structure to make it easy to add and remove items.

    // Setting up Store for Raycasting
    let raycastStore = [];
    const addToRCStore = (obj) => raycastStore.push(obj);
    const removeFromRCStore = (obj) => {
        for (let i = 0; i < raycastStore.length; i++) {
            if (raycastStore[i] === obj) raycastStore.splice(i, 1);
        }
    }

now whenever you do scene.add(/** An Object you wanna respond to */), be sure to also do addToRCStore(/** The object */) and removeFromRCStore(/** The Object */) when you no longer want to respond to it.

Now let's setup the Ray Caster that ThreeJs offers and initialize the variables that'll keep track of the mouse position.

    // Setting up RayCaster
    let ndcMouseX = -1;
    let ndcMouseY = -1;
    let rc = new THREE.Raycaster();

We'll refresh the mouse positions whenever the mouse moves,

    document.addEventListener("mousemove", (e) => {
        ndcMouseX = (e.clientX / window.innerWidth) * 2 - 1;
        ndcMouseY = -(e.clientY / window.innerHeight) * 2 + 1;
    });

Now notice we aren't exactly keeping track of the mouse pointer but some function of it.

That's because the browser gives us the mouse position values in pixels as units, from 0 to width or height,

But ThreeJs, at render time, scales everything down to a cube of -1 to 1

So we need to make a transformation like so,

Screen to Camera dimensions transform

Now that that's done, let's actually make a function that checks for intersections and does something if an intersection is found and some other thing when it isn't.

const CheckRC = (camera, onIntersection, onEmpty = () => { }) => {
    rc.setFromCamera(new THREE.Vector3(ndcMouseX, ndcMouseY, 0), camera);
    let intersects = rc.intersectObjects(raycastStore);
    if (intersects.length > 0) {
        onIntersection(intersects);
    } else {
        onEmpty();
    }
}

Now we have a function that performs 1 Ray Cast for us and performs required actions.

This enables us to perform a myriad of responses, for example let's change cursor to pointer on hover of an object,

    const RCJob = setInterval(() => {
        CheckRC(
            camera,
            () => { document.body.style.cursor = "pointer"; },
            () => { document.body.style.cursor = "auto"; }
        );
    }, 100); // Start an Hover Job

Or lets make an object glow on Click,

    document.addEventListener("mousedown", () => {
        CheckRC(
            camera,
            (intersects) => {
                const col = new THREE.Color("#b62886");
                if (!intersects[0].object.material.color.equals(col)) {
                    intersects[0].object.material.color = col;
                    intersects[0].object.material.emissive = new THREE.Color("#AAAAAA");
                } else {
                    intersects[0].object.material.color = new THREE.Color("#639af6");;
                    intersects[0].object.material.emissive = new THREE.Color("#000000");
                }
            }
        );
    }); // Start a Click Job

After all that, this is what we get,

demo of hover and click

Summary

So in this Chapter we learned how to interact with the objects inside our ThreeJs with our cursor with a clever Ray Casting setup,

Here's the full code for you to setup,

<div style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;" class="threeD"></div>
    <script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
    <script type="importmap">
    {
        "imports": {
            "three": "https://unpkg.com/three/build/three.module.js"
        }
    }
    </script>
    <script type="module">
        import * as THREE from "three";
        import { OrbitControls } from "https://unpkg.com/three/examples/jsm/controls/OrbitControls.js";

        const init = (
            targetClass = "threeD"
        ) => {
            const target = document.getElementsByClassName(targetClass);
            if (target) {
                // Renderer and Scene Setup
                const renderer = new THREE.WebGLRenderer({ alpha: true });
                const Cwidth = () => {
                    return document.getElementsByClassName(targetClass)[0].clientWidth;
                };
                const Cheight = () => {
                    return document.getElementsByClassName(targetClass)[0].clientHeight;
                };
                renderer.shadowMap.enabled = false;
                renderer.setPixelRatio(window.devicePixelRatio);
                renderer.setSize(Cwidth(), Cheight());
                renderer.physicallyCorrectLights = true;
                renderer.domElement.setAttribute("id", "three-canvas");

                target[0].appendChild(renderer.domElement);
                const scene = new THREE.Scene();
                const ambientLight = new THREE.AmbientLight(0xffffff, 2);
                scene.add(ambientLight);

                // Camera Setup
                const aspect = Cwidth() / Cheight();
                const fov = 60;
                const near = 1.0;
                const far = 200.0;
                const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
                camera.position.set(0, 30, 0);
                camera.lookAt(0, 0, 0);

                // Setting Up Orbital Controls 
                const controls = new OrbitControls(camera, renderer.domElement);
                controls.update();

                // Setting up Store for Raycasting
                let raycastStore = [];
                const addToRCStore = (obj) => raycastStore.push(obj);
                const removeFromRCStore = (obj) => {
                    for (let i = 0; i < raycastStore.length; i++) {
                        if (raycastStore[i] === obj) raycastStore.splice(i, 1);
                    }
                }

                // Setting up RayCaster
                let ndcMouseX = -1;
                let ndcMouseY = -1;
                let rc = new THREE.Raycaster();

                document.addEventListener("mousemove", (e) => {
                    ndcMouseX = (e.clientX / window.innerWidth) * 2 - 1;
                    ndcMouseY = -(e.clientY / window.innerHeight) * 2 + 1;
                });

                const CheckRC = (camera, onIntersection, onEmpty = () => { }) => {
                    rc.setFromCamera(new THREE.Vector3(ndcMouseX, ndcMouseY, 0), camera);
                    let intersects = rc.intersectObjects(raycastStore);
                    if (intersects.length > 0) {
                        onIntersection(intersects);
                    } else {
                        onEmpty();
                    }
                }

                const RCJob = setInterval(() => {
                    CheckRC(
                        camera,
                        () => { document.body.style.cursor = "pointer"; },
                        () => { document.body.style.cursor = "auto"; }
                    );
                }, 100); // Start an Hover Job

                document.addEventListener("mousedown", () => {
                    CheckRC(
                        camera,
                        (intersects) => {
                            const col = new THREE.Color("#b62886");
                            if (!intersects[0].object.material.color.equals(col)) {
                                intersects[0].object.material.color = col;
                                intersects[0].object.material.emissive = new THREE.Color("#AAAAAA");
                            } else {
                                intersects[0].object.material.color = new THREE.Color("#639af6");;
                                intersects[0].object.material.emissive = new THREE.Color("#000000");
                            }
                        }
                    );
                }); // Start a Click Job

                // Adding Stuff to Our World  
                // Add Point Light
                const light = new THREE.PointLight(
                    0xffffff /* Color */,
                    80 /* Intensity */,
                    100 /* Maximum Range of Light */
                );
                light.position.set(30, 30, 30);
                scene.add(light);

                // Add Primitive Shapes
                const randomIntFromInterval = (min, max) => { // min and max included 
                    return Math.floor(Math.random() * (max - min + 1) + min)
                }

                const spawnRandomCube = () => {
                    // Make Shape
                    const size = randomIntFromInterval(2, 7);
                    const geometry = new THREE.BoxBufferGeometry(
                        size /** height */,
                        size /** width */,
                        size /** length */);
                    // Make Texture
                    const material = new THREE.MeshStandardMaterial({ color: 0x639af6 });
                    // Make Cube
                    const cube = new THREE.Mesh(geometry, material);
                    // Random Position
                    cube.position.set(
                        randomIntFromInterval(-80, 80),
                        randomIntFromInterval(-80, 80),
                        randomIntFromInterval(-80, 80)
                    );
                    // Random Angle
                    cube.rotation.set(
                        THREE.Math.degToRad(randomIntFromInterval(0, 90)),
                        THREE.Math.degToRad(randomIntFromInterval(0, 90)),
                        THREE.Math.degToRad(randomIntFromInterval(0, 90))
                    )
                    // Add to Scene
                    scene.add(cube);
                    addToRCStore(cube);
                }

                for (let i = 0; i < 200; i++) spawnRandomCube();

                // Play Animation
                const RAF = () => {
                    requestAnimationFrame(() => {
                        // Animations

                        // Recursively Render
                        renderer.render(scene, camera);
                        RAF();
                    });
                };

                RAF();

                // Responsiveness
                window.addEventListener(
                    "resize",
                    () => {
                        camera.aspect = Cwidth() / Cheight();
                        renderer.setSize(Cwidth(), Cheight());
                        camera.updateProjectionMatrix();
                    },
                    false
                );
            }
        };

        init();
    </script>

Conclusion

Now we're really getting into the 3D world and how we can interact with it.

One thing you probably noticed in here is that the glow of the cubes looks less like glow and more like a change of color, and you would be right.

To make the glow more "glow"-like we need to apply a post-processing step called bloom.

We'll see that and everything Post-Processing in the next article Post Processing in ThreeJs | Make your Scene Pop

So see you there! As always, hope you learned something : )

[If you need to contact me, you can comment down below, or email me at ]

ย