Search code examples
componentstooltipaframe

A-Frame: keep one entity in front of another and facing camera


I want to position and rotate an entity (e.g. an A-Frame text entity) so that it is always just in front of another (e.g. a box), i.e. the text is positioned slightly in front of the box from the point of view of the camera, facing the camera. I can use the look-at component to get the text to face the camera, but I am lost about how to position the text. I need a generic solution, since the box may vary in size and position, so hard-coding the position of the text won't work.

The aim is to have something like a tooltip that appears in front of the box (once I have the basic idea working, I'll make it so that the text only appears when you are hovering on the box).


Solution

  • There are multiple ways of doing this, here are two ideas:

    1. Aligning HTML elements with 3D objects

    This is an amazing resource which is worth checking out (as is the entire Fundamentals manual), which I used below (simplified, I hope this way the idea is more clear)

    The idea is quite simple:

    • get the world position of an object
    • map it onto the "screen" (x,y) position
    • position a <p> element with css, using the above x/y values:

    p {
      z-index: 10;
      position: absolute;
      /* let us position them inside the container */
      left: 0;
      /* make their default position the top left of the container */
      top: 0;
      cursor: pointer;
      /* change the cursor to a hand when over us */
      font-size: large;
      user-select: none;
      /* don't let the text get selected */
    }
    <script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
    <script>
      AFRAME.registerComponent("foo", {
        schema: {text: {default: ""}},
        init: function() {
           // grab the div, which will contain all <p> elements
           const layoutParent = document.querySelector("#overlays")
           // wait until loaded
           this.el.addEventListener("loaded", evt => { 
            const layoutEl = document.createElement("p") // create the overlay element
            layoutEl.innerHTML = this.data.text          // set the inner text
            layoutParent.appendChild(layoutEl)           // append to the parent
            
            // keep references to use in the "tick" function
            this.layoutEl = layoutEl    
            this.mesh = this.el.getObject3D("mesh")
           })
           this.tmpV = new THREE.Vector3(0,0,0); // for later use
        },
        tick: function() {
          // ignore if there is no mesh/text yet
          if (!this.mesh || !this.layoutEl) return;
          const tmpV = this.tmpV;
          const canvas = this.el.sceneEl.canvas
          const camera = this.el.sceneEl.camera
          // get the world position
          this.mesh.getWorldPosition(tmpV);
          // get the normalized screen coordinate of that position
          tmpV.project(camera);
          // convert the normalized position to CSS coordinates
          const x = (tmpV.x *  .5 + .5) * canvas.clientWidth;
          const y = (tmpV.y * -.5 + .5) * canvas.clientHeight;
          // move the elem to that position
          this.layoutEl.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
        }
      })
    </script>
    <div id="overlays">
    </div>
    <a-scene>
      <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9" 
             foo="text: some overlay"></a-box>
    </a-scene>

    Hope the comments make it clear - though you should really check the manual

    2. Using 3D text

    You could calculate the box -> camera vector, and use it to offset the overlay text - which combined with lookAt should get the effect You're looking for.

    A simpler way of the same idea would be:

    • create a dummy rig, which would follow the camera using look-at
    • place a a-text in the rig, offseting its z by the box bounding sphere radius.

    <script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
    <script src="https://unpkg.com/aframe-look-at-component@0.8.0/dist/aframe-look-at-component.min.js"></script>
    <script>
      AFRAME.registerComponent("spherical-tooltip", {
        schema: {
          text: {
            default: ""
          }
        },
        init: function() {
          this.el.addEventListener("loaded", evt => {
            const mesh = this.el.getObject3D("mesh")
            // the timeout is an ugly hack,
            // the bounding sphere isn't set yet
            setTimeout(evt => {
              const bsphere = mesh.geometry.boundingSphere
              // the main rig
              const rig = document.createElement("a-entity");
              rig.setAttribute("look-at", "[camera]")
              this.el.appendChild(rig)
    
              // setup the text, and offset the position
              const tooltip = document.createElement("a-text");
              tooltip.setAttribute("color", "black")
              tooltip.setAttribute("value", this.data.text)
              tooltip.setAttribute("align", "center")
              tooltip.setAttribute("position", {x: 0, y: 0, z: bsphere.radius})
              rig.appendChild(tooltip)
            }, 150)
          })
        }
      })
    </script>
    <a-scene>
      <a-box spherical-tooltip="text: tooltip" position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9"></a-box>
    </a-scene>