Search code examples
aframegame-development

AFrame: how to make the raycaster work with an object3D child objects?


I want to use the AFrame raycaster component to catch intersections with objects. I'm adding my custom objects to a GLTF model. I'm calling them "collision-shapes" and they're been used to catch collisions between gilt models and projectiles. Use case: shooting a bullet into an enemy.

The problem is that for some models it works, but for some of them it catches intersections outside the collision shape.

To position a collision shape I use a bone name the collision object should be anchored to.

My code is the following (I removed some parts to make it shorter):

<a-gltf-model src="#bird" 
                    position="2 -75 -300"
                    animation-mixer
                    scale="1 1 1"
                    
                    shape__Bone_38_08="bone: Bone_38_08; shape: box; halfExtents: 10 10 5"
                    shape__Bone_39_07="bone: Bone_39_07; shape: box; halfExtents: 15 10 10">
        
      </a-gltf-model>
      
      <a-gltf-model src="#orc" position="-2 0 -5" animation-mixer="clip: Orc.004" scale="2 2 2" rotation="0 180 0"
        shape__hair_1="bone: hair_1; shape: box; halfExtents: 0.05 0.075 0.05"
        shape__leg_L_1="bone: leg_L_1; shape: box; halfExtents: 0.05 0.125 0.05; offset: 0 -0.05 -0.1">

      </a-gltf-model>
      
      <a-entity camera look-controls position="0 1.6 0" wasd-controls>
        <a-cursor color="gray" raycaster="objects: [data-raycastable]" ></a-cursor>
      </a-entity>

The components:

AFRAME.registerComponent("shape", {
  schema: {
    bone: { default: "" },
    shape: { default: "box", oneOf: ["box", "sphere", "cylinder"] },
    offset: { type: "vec3", default: { x: 0, y: 0, z: 0 } },
    orientation: { type: "vec4", default: { x: 0, y: 0, z: 0, w: 1 } },
    // box
    halfExtents: { type: "vec3", default: { x: 0.5, y: 0.5, z: 0.5 }, if: { shape: ["box"] } },
    visible: { type: "boolean", default: true }
  },
  multiple: true,
  init(){
    const data = this.data;
    const self = this;
    const el = this.el;
    el.addEventListener("model-loaded", function modelReady() {
      el.removeEventListener("model-loaded", modelReady);

      const boneDummy = document.createElement("a-entity");
      self.setDummyShape(boneDummy, data);
      self.boneObj = self.getBone(el.object3D, data.bone);
      el.appendChild(boneDummy);
      self.boneDummy = boneDummy;
    });
  },
  
  setDummyShape(dummy, data) {
    const shapeName = "collidable-shape";
    const config = {
      shapeName: data.bone,
      shape: data.shape,
      offset: data.offset,
      halfExtents: data.halfExtents
    };

    dummy.setAttribute(shapeName, config);
  },
  
  getBone(root, boneName) {
    let bone = root.getObjectByName(boneName);
    if (!bone) {
      root.traverse(node => {
        const n = node;
        if (n?.isBone && n.name.includes(boneName)) {
          bone = n;
        }
      });
    }

    return bone;
  },
  
  inverseWorldMatrix: new THREE.Matrix4(),
  boneMatrix: new THREE.Matrix4(),

  tick() {
    const el = this.el;
    if (!el) { throw Error("AFRAME entity is undefined."); }
    if (!this.boneObj || !this.boneDummy) return;

    this.inverseWorldMatrix.copy(el.object3D.matrix).invert();

    this.boneMatrix.multiplyMatrices(this.inverseWorldMatrix, this.boneObj.matrixWorld);
    this.boneDummy.object3D.position.setFromMatrixPosition(this.boneMatrix);
  }
})

AFRAME.registerComponent("collidable-shape", {
  schema: {
    shape: { default: "box", oneOf: ["box", "sphere", "cylinder"] },
    offset: { type: "vec3", default: { x: 0, y: 0, z: 0 } },
    orientation: { type: "vec4", default: { x: 0, y: 0, z: 0, w: 1 } },

    // box
    halfExtents: { type: "vec3", default: { x: 0.5, y: 0.5, z: 0.5 }, if: { shape: ["box"] } },
    visible: { type: "boolean", default: true }
  },

  collistionObject: null ,
  
  multiple:true,

  init() {
    const scene = this.el.sceneEl;
    if (!scene) { throw Error("AFRAME scene is undefined."); }

    if (scene.hasLoaded) {
      this.initShape();
    } else {
      scene.addEventListener("loaded", this.initShape.bind(this));
    }
  },

  initShape() {
    const data = this.data;

    this.el.setAttribute("data-raycastable", "");
    this.el.addEventListener('mouseenter', evt => {
        console.log("mouse enter", data.shape);
        this.el.object3D.children[0].material.color.setHex(0x00ff00);
    });

    this.el.addEventListener('mouseleave', evt => {
        console.log("mouse leave", data.shape);
        this.el.object3D.children[0].material.color.setHex(0xff0000);
    });        

    const scale = new THREE.Vector3(1, 1, 1);
    this.el.object3D.getWorldScale(scale);
    let shape;
    let offset;
    let orientation;

    if (Object.prototype.hasOwnProperty.call(data, "offset")) {
      offset = new THREE.Vector3(
        data.offset.x * scale.x,
        data.offset.y * scale.y,
        data.offset.z * scale.z
      );
    }

    if (Object.prototype.hasOwnProperty.call(data, "orientation")) {
      orientation = new THREE.Quaternion();
      orientation.copy(data.orientation);
    }

    switch (data.shape) {
      case "box":
        shape = new THREE.BoxGeometry(
          data.halfExtents.x * 2 * scale.x,
          data.halfExtents.y * 2 * scale.y,
          data.halfExtents.z * 2 * scale.z
        );
        break;
    }

    this._applyShape(shape, offset, data.visible);
  },

  _applyShape(shape, offset, visible) {
    const material = new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.3 });
    const wireframe = new THREE.LineSegments(
      new THREE.EdgesGeometry(shape),
      new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 3 }));

    this.collistionObject = new THREE.Mesh(shape, material);
    this.collistionObject.add(wireframe);
    if (offset) {
      this.collistionObject.position.set(offset.x, offset.y, offset.z);
    }
    this.collistionObject.visible = visible === true;
    this.el.setObject3D("mesh", this.collistionObject);
    
    const size = new THREE.Vector3();
    const box = new THREE.Box3().setFromObject(this.el.object3D);
    box.getSize(size);
    
    const bbox = new THREE.BoxGeometry(size.x, size.y, size.z);
    const bboxWireframe = new THREE.LineSegments(
      new THREE.EdgesGeometry(bbox),
      new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 10 }));
    
    this.el.object3D.add(bboxWireframe)
  }
});

The sample project can be found here: https://glitch.com/edit/#!/collisons-test Please note, it works as expected for the bird, but behaves strange for the orc. Also the bounding box doesn't match the collision-shape box itself. This is also something not clear to me.


Solution

  • Also the bounding box doesn't match the collision-shape box itself.

    The bounding box is taking the world matrix into account. You can see how it's changing when the model scale is different:

    enter image description here

    Also you can see the red boxes also aren't scaling nicely. I think most problems here are a result of scale mixups.

    The problem is that for some models it works, but for some of them it catches intersections outside the collision shape.

    Adding the wireframes before setting the object3D messes up with the raycaster. Not sure but I guess this is because scaling issues as well.

    Here's a glitch with setting the wireframes after setObject3D


    I'd start with a different approach. Create the boxes as scene children and manage their transform based on the model worldMatrix + bone offsets. It will be way easier to manage (scale up/down, reposition) and debug.