4

I have been trying to get a 3D scatter plot to display using InstancedMesh of three.js via a React app (therefore using react three fiber).

After a few days I was able to get the 3D scatter plot of spheres and also color them. However I want to be able to select/highlight individual spheres on mouse click/hover. I followed a simple tutorial that used onPointerOver and onPointerOut but that does not seem to work perhaps because it was not meant for InstancedMesh objects.

It looks like I need to use raycaster, but it is not clear to me how to do it. Any suggestions would be really helpful.

Steps to setup -

npx create-react-app demo
cd demo
npm install three
npm i @react-three/fiber
npm i @react-three/drei

Current code that shows the differently colored spheres -

App.jsx

import React from 'react'
import { Suspense } from "react";
import { Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import Spheres from "./IScatter";
import * as THREE from "three";

function App() {
  return (
   <div>
      <Canvas style={{width:"100%",height:"100vh"}}>
        <OrbitControls enableZoom={true} />
        <ambientLight intensity={0.5} />
        <pointLight position={[10, 10, 10]}/>
        <Suspense fallback={null}>
        <primitive object={new THREE.AxesHelper(1.5)} />
        <Spheres />
        </Suspense>
      </Canvas>
      </div>
  );
}

export default App;

IScatter.jsx

import * as THREE from "three";
import React, { useRef, useState } from "react";
import { useEffect } from "react";
import { DoubleSide } from "three";

const points = [ [1, 0, -1], [0, 1, -0.5], [0.5, 0.5, 0.5], [1,0.25,-1], [1,0,1], [0,1,0.5] ];
const content = ["Hello", "World", "Hello World", "Shoes", "Drone", "Foo Bar"];
const colors = [0,0,0,5,5,5];

const tempSphere = new THREE.Object3D();

const Spheres = () => {
  const material = new THREE.MeshLambertMaterial({ opacity: 0.5, side: THREE.DoubleSide, transparent: true,});
  const spheresGeometry = new THREE.SphereBufferGeometry(0.25, 15, 15);

  const ref = useRef();
  const [active, setActive] = useState(false);
  const [hover, setHover] = useState(false);
  
  useEffect(() => {
    points.map(function (val, row) {
        console.log(val, row);
        tempSphere.position.set(val[0], val[1], val[2]);
        tempSphere.updateMatrix();
        ref.current.setMatrixAt(row, tempSphere.matrix);
        ref.current.setColorAt(row, new THREE.Color(`hsl(${colors[row]*100}, 100%, 50%)`));
      });
    
    ref.current.instanceMatrix.needsUpdate = true;
    // ref.current.instanceColor.needsUpdate = true;
  });

  return (
        <instancedMesh onClick={() => {
        setActive(!active);
          }}
          onPointerOver={() => {
            setHover(true);
          }}
          onPointerOut={() => {
            setHover(false);
          }} ref={ref} rotation={[0,0,0]} args={[spheresGeometry, material, 15]} opacity={active?0.9:0.5} />
  );
};

export default Spheres;

I would like to change the opacity of the selected sphere and perhaps make them a little larger so that we know what it selected. If possible I would also like to show the HTML content associated with a sphere, adjacent to the canvas, away from the scatterplot of spheres, whenever a sphere is selected.

EDIT: I essentially want to implement this - https://threejs.org/examples/webgl_instancing_raycast but using react three fiber and to also show associated content of the spheres.

jar
  • 2,646
  • 1
  • 22
  • 47

1 Answers1

1

I tried to work with this the best I could and here are the results.

Here's my sandbox: https://codesandbox.io/s/relaxed-rgb-yb1v1v?file=/src/IScatter.jsx

First of all, I made a new Scene.jsx, because Three Fiber apparently doesn't like certain (can't remember what exactly) reference or hook usage outside of Canvas.

My thought here was also that the Scene should be responsible for handling any mouse or camera events and only pass the changes one way or another to other components.

Basically App became just this:

function App() {
  return (
    <div>
      <Canvas style={{ width: "100%", height: "100vh" }}>
        <Scene />
      </Canvas>
    </div>
  );
}

And all of your old App code now lies inside Scene.

Looking at the raycast example code, we can see that in order to use the raycaster, we need... well... the raycaster instance for one, but also the camera instance and a mouse position.

To get camera instance with Three Fiber, I found this SO question and answer, which guides us to use useThree hook:

const {
    camera,
    gl: { domElement }
  } = useThree();

Now this doesn't help us that much by itself, because the useThree().camera is not exactly "bound" to the OrbitControls component you are using. To fix this, we use the args prop of the OrbitControls component to, afaik, bind/pass our camera to it:

<OrbitControls 
    enableZoom={true} 
    args={[camera, domElement]} 
    onChange={handleControls}
/>

You might notice that there is also a onChange-prop there. The event does trigger whenever camera rotation etc. changes, but for some unknown reason, the event object does not contain basically any other information at all other than "something has changed".

I was hoping that the event object would include camera information or something. Either way, because it seems to work like that and since we've passed the camera to the OrbitControls component anyway, whenever this change event triggers, we know that the camera has updated as well. My first instinct was to of course have something like this:

const [myCamera, setMyCamera] = useState(camera);

const handleControls = (e) => {
    setMyCamera(camera);
};

So my own camera state, which just set whatever camera has whenever something has changed with the OrbitControls. Well the problem here is that while this kind of works, something like useEffect(() => { }, [myCamera]); won't ever trigger for yet another unknown reason.

So what does a frustrated React developer do when it has useState + useEffect triggering problems? Makes a "force state change" implementation and here is what the handleControls currently looks like:

const [cameraSignal, setCameraSignal] = useState(0);

const handleControls = (e) => {
    setCameraSignal((s) => s + 1);
};

And then we just pass that cameraSignal to your Spheres component:

Again, the useThree().camera does update beautifully, we just didn't have a way to react to that, because useEffect won't get triggered by direct changes to it, but with this hacky cameraSignal implementation, we now have a way to trigger useEffects when camera changes.

So now we have the camera solved. Next up is mouse. This one is fortunately a lot simpler issue:

const [mousePos, setMousePos] = useState({ x: 0, y: 0 });

const handleMouse = (event) => {
    const mouse = {};
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    setMousePos(mouse);
};

useEffect(() => {
    document.addEventListener("mousemove", handleMouse);
    return () => {
        document.removeEventListener("mousemove", handleMouse);
    };
}, []);

That's camera and mouse solved. We can get the raycaster instance with the same useThree hook, so lets get to the actual raycasting implementation:

const ts = useThree();
const [camera, setCamera] = useState(ts.camera);
const instanceColors = useRef(new Array(points.length).fill(red));

useEffect(() => {
    let mouse = new THREE.Vector2(mousePos.x, mousePos.y);
    ts.raycaster.setFromCamera(mouse, camera);

    const intersection = ts.raycaster.intersectObject(ref.current);

    if (intersection.length > 0) {
        const instanceId = intersection[0].instanceId;
        ref.current.getColorAt(instanceId, color);
        if (color.equals(red)) {
        instanceColors.current[instanceId] = new THREE.Color().setHex(Math.random() * 0xffffff);
        ref.current.instanceColor.needsUpdate = true;
        }
    }
}, [mousePos]);

useEffect(() => {
    setCamera(ts.camera);
}, [cameraSignal]);

Here we just basically follow the raycast example code with our brand new logic to get mouse and camera data.

In places where the example uses mesh like:

const intersection = raycaster.intersectObject(mesh);

We use ref.current instead, as it seems to work like that with React Fiber and ref.current is indeed the actual InstancedMesh that you have created.

But there are some problems

I think that there are some issues regarding how to use React Fiber in general. Looking at (the documentation here)[https://openbase.com/js/@andrewray/react-three-fiber/documentation] and (here)[https://docs.pmnd.rs/react-three-fiber/api/objects], there are a lot of improvements that can be made, including to my code, which is kind of a hack to make the current solution work.

I think one of the biggest issues issues is this:

useEffect(() => {
    points.map(function (val, row) {
      tempSphere.position.set(val[0], val[1], val[2]);
      tempSphere.updateMatrix();
      ref.current.setMatrixAt(row, tempSphere.matrix);
      ref.current.setColorAt(row, instanceColors.current[row]);
    });

    ref.current.instanceMatrix.needsUpdate = true;
    //ref.current.instanceColor.needsUpdate = true;
  });

This is weird because of how React Fiber components work. I think your intention here is probably to initalize the mesh, but this acts basically as a render update loop.

I had to make another ref like this to hold the changed colors in, or otherwise this "render update loop" would overwrite the colors with the initial colors all of the time:

const instanceColors = useRef(new Array(points.length).fill(red));

Then with the raycasting, I couldn't directly use setColorAt (because the above useEffect would just overwrite the color), but instead I updated the colors ref:

instanceColors.current[instanceId] = new THREE.Color().setHex(Math.random() * 0xffffff);

This all results in a working raycasting and color changing with the InstancedMesh, but I would consider some refactorings if you want to get more performance etc. out of this.

*Extra notes

I followed a simple tutorial that used onPointerOver and onPointerOut but that does not seem to work perhaps because it was not meant for InstancedMesh objects.

I believe you are right, InstancedMesh is a different beast in a sense that it is not exactly an unambiguous single 3D object you can click or hover on, quite the opposite, really. It could be that in order to use InstancedMesh, one needs to do all the initializing, updating, as well as the camera- and pointer-event stuff by hand, but I can't say for sure as this is the first time ever for me that using Three Fiber. It looks a bit messy, but I have seen something like this before, so it is not necessarily the wrongest way possible to do it like this.

Edit

I think I fixed the aforementioned issue, see IScatter.jsx of this sandbox: https://codesandbox.io/s/stoic-hertz-qbbkpu?file=/src/IScatter.jsx (It runs a lot better)

I moved some stuff out of the useEffect and even out of the component. Now it should actually initialize the InstancedMesh only once. We still have one useEffect for initialization, but it is only run once on component mount:

useEffect(() => {
    let i = 0;
    const offset = (amount - 1) / 2;

    for (let x = 0; x < amount; x++) {
      for (let y = 0; y < amount; y++) {
        for (let z = 0; z < amount; z++) {
          matrix.setPosition(offset - x, offset - y, offset - z);
          meshRef.current.setMatrixAt(i, matrix);
          meshRef.current.setColorAt(i, white);
          i++;
        }
      }
    }
  }, []);

This is evident by looking at the raycast function:

meshRef.current.setColorAt(
    instanceId,
    new THREE.Color().setHex(Math.random() * 0xffffff)
);

We can now directly use setColorAt, without it getting overwritten on every render loop.

Furthermore I would look here and see if the mouse- and camera-events can be handled a bit better.

Swiffy
  • 4,401
  • 2
  • 23
  • 49
  • Thank you so very much for your efforts. Using your code as a base, I was almost able to do what I want....almost because, even if there is a single title or para anywhere, the canvas position is affected badly...i.e the instances or objects are no longer highlighted according to where the mouse pointer it. You can check it yourself if you just add a Hello World text
    above your canvas in the codesandbox provided. This is important to me because whatever I am doing of-course wont be isolated and there would be things above and below it. Also, the useEffect hook runs whenever mouse moves.
    – jar Jun 22 '22 at 20:56
  • I think we should be able to do it such that it does not run when we are in the empty region of the canvas. I am fairly certain that I have seen it work like that somewhere. That might perhaps be the best optimized solution. One big issue that I face is that I am trying to display toolips using HTML overlay from `drei`. It works, but it gets clipped by the canvas. I have not been able to figure out how to display the full content of the tooltip without clipping it even if its height becomes larger than the canvas. I also need to figure out how to get this work on mouseClick rather than hover. – jar Jun 22 '22 at 21:02
  • Well this is as far as I am willing to go with this. It should be trivial to calculate the mouse coordinates relative to the canvas. As I said, the mouse coordinate handling is not optimal, but you can easily check the link I gave and implement the event handling that way and without useEffect. It's impossible to only update the mouse when it is over some object, as that check itself would require updating the mouse anyway. As for the tooltips, I'd say they are out of the scope, as SO policy is 1 question per post, so you'll have to ask about that separately. – Swiffy Jun 22 '22 at 21:13
  • Yes of course...I will try to get the mouse pos relative to the canvas. For the HTML overlay that gets clipped...I think it does deserve a new question. – jar Jun 23 '22 at 07:59
  • As it currently stands, the mouse position is basically calculated assuming that the canvas takes up the whole screen. Google should easily give you an answer if you search for something like "get canvas mouse coordinates" or "js get mouse coordinates relative to element". These coordinates are not React Fiber / Threejs specific, so any solution should work. You could also pull the whole raycast thing into a function and call it from mouse click event. – Swiffy Jun 23 '22 at 08:23
  • is there a way to award the bounty posthumously? I have been traveling and couldn't do it in time. – jar Jul 03 '22 at 07:11
  • Nah, it's gone. The policy on this, as far as I know, is "too bad". That's ok, we should be answering questions primarily because we want to help and/or find the questions interesting and not just because of fake internet points :) – Swiffy Jul 03 '22 at 09:51