kohloth
kohloth

Reputation: 904

Transforming a vector to adhere to a complimentary vector with ThreeJS

I'm facing a troublesome problem while trying to create a game engine in threeJS. It is a math problem, but also a programming problem.

I've implemented a velocity based movement system for the player's avatar - I've used a tank in this example.

Currently, when the player hits a wall, regardless of the angle, the tank invariably stops dead.

However, I want it to be the case that the tank's velocity changes, having been coerced to follow the angle of the wall, and also reduced by a magnitude that is related to that angle.

For example, in FIG A, upon hitting the wall, the Tank continues to try and move forwards, but it's velocity is altered so that it now moves forwards, and sideways, at a reduced rate.

In FIG B, the tank hits the wall dead-on, and its overall velocity reaches 0.

In FIG C, the tank glances off the wall, and its overall velocity is only reduced by a small amount.

I've realised that I need to somehow combine the Tank's velocity vector with the wall's normal vector, to produce the adjusted vector, but I am struggling with how to represent this mathematically / programmatically.

I've tried using: tank.velocity.multiply(wallFaceNormal); (both tank.velocity and wallFaceNormal are Vector3 objects.) but this only seems to work as intended when the wall is either at angles of 0, 90, 180 or 270.

Tank problem diagram

Upvotes: 0

Views: 352

Answers (3)

kohloth
kohloth

Reputation: 904

I've done some work on this problem and produced a mini game "framework" that includes an environment collision and movement attenuation utility.

I've written an article that explains how it works, which can be found here. http://www.socket-two.com/main/resource/hdoc-tutorial

But for the sake of the integrity of the thread, here's an adaptation of the portion that describes one of the approaches that can be used to attenuate motion in a ThreeJS simulation:

...

Crucially, my interest has not been to create games that involve large amount of physics, but just to create games where:

  • A player cannot walk through walls
  • A player cannot fall through floors

I've made a handful of attempts at implementing a system that would achieve this behaviour, but none of them have really worked satisfactorily. Until now.

In terms of how the ECS fits into the app architecture, it is a utility class. This is its API shape:

class Planeclamp {
    constructor({ floors /*Mesh[]*/, walls /*Mesh[]*/ })
    getSafePosition(startingPositionIn /*Vector3*/, intendedPositionIn /*Vector3*/) // Returns safePosition, which is a Vector3
}

As you can see, its a class that accepts two arrays of meshes in its constructor: Meshes that should be treated as floors, and meshes that should be treated as walls. Now of course in reality, there is no clear distinction between a steep floor and a shallow-angled wall, but for the purposes of the simulation, the distinction has a very reasonable integrity, and will simplify the environment collision system logic greatly.

Once you've constructed an instance of the Planeclamp class, you can then invoke it's getSafePosition method, to transform a starting position and an intended position into an attenuated position. Being the discerning reader that you are, you will have deduced that the attenuated position is the intended position, having been changed a bit if any collisions have been detected by the utility.

This is how it can be used in the game loop, to ensure a player does not pass through walls or floors:

const planeclamp = new Planeclamp({
    floors: [someFloorMesh, someOtherMesh],
    walls: [houseMesh, perimeterMesh, truckMesh],
});

const player = new Player();
console.log(player.cage); // Object3D

let playerPreviousPosition = player.cage.position; // Vector3

function gameLoop(delta) {
    const playerIntendedPosition = new Three.Vector3(
        playerPreviousPosition.x,
        playerPreviousPosition.y + (10 * delta), // i.e. Gravity
        playerPreviousPosition.z + (1 * delta), // i.e. Walking forwards
    );
    let {
        safePosition, // Vector3
        grounded, // Boolean
        groundMaterial, // String
    } = planeclamp.getSafePosition(playerPreviousPosition, playerIntendedPosition);
    player.cage.position.copy(safePosition);
    playerPreviousPosition = player.cage.position; // Vector3
}

And thats about it! If you would like to use this utility, you can find it in the repository. But if you would like to know more about the logic behind its workings, read on.

The Planeclamp.getSafePosition method works out a safe position in two stages. Firstly, it uses a vertical raycaster to take a look at what is underneath the player, to then see if it should stop the player from moving downwards any further. Secondly, it uses horizontal raycasters to see if it should stop the player from moving horizontally. Lets look at the vertical constraint procedure first - this is the more simple of the two steps.

// Before we do anything, create a variable called "gated".
// This will contain the safe new position that we will return at the end of
// the function. When creating it, we let it default to the
// intended position. If collisions are detected throughout the lifecycle
// of this function, these values will be overwritten.
let gated = {
    x: intendedPosition.x,
    y: intendedPosition.y,
    z: intendedPosition.z,
};

// Define the point in 3D space where we will shoot a ray from.
// For those who haven't used raycasters before, a ray is just a line with a direction.
// We use the player's intended position as the origin of the ray, but we
// augment this by moving the origin up a little bit (backStepVert) to prevent tunneling.
const start = intendedPosition.clone().sub(new Three.Vector3(
    0,
    (backStepVert * -1) - (heightOffset / 2),
    0)
);

// Now, define the direction of the ray, in the form of a vector.
// By giving the vector X and Z values of 0, and a Y value of -1,
// the ray shoots directly downwards.
const direction = new Three.Vector3(0, -1, 0).normalize();

// We now set the origin and direction of a raycaster that we instantiated
// in the class constructor method.
this.raycasters.vert.set(start, direction);

// Now, we use the `intersectObjects` method of the ray.
// This will return to us an array, filled with information about each
// thing that the ray collided with.
const dirCollisions = this.raycasters.vert.intersectObjects(this.floors, false);

// Initialise a distanceToGround, a grounded variable, and a groundMaterial variable.
let distanceToGround = null;
let grounded = false;
let groundMaterial = null;

// If the dirCollisions array has at least one item in it, the
// ray passed through one of our floor meshes.
if (dirCollisions.length) {

    // ThreeJS returns the nearest intersection first in the collision
    // results array. As we are only interested in the nearest collision,
    // we pluck it out, and ignore the rest.
    const collision = dirCollisions[0];

    // Now, we work out the distance between where the players feet
    // would be if the players intended position became the players
    // actual position, and the collided object.
    distanceToGround = collision.distance - backStepVert - heightOffset;

    // If the distance is less than 0, then the player will pass through
    // the groud if their intended position is allowed to become
    // their actual position.
    if (distanceToGround < 0) {

        // We dont want that to hapen, so lets set the safe gated.y coordinate
        // to the y coordinate of the point in space at which the collision
        // happened. In other words, exactly where the ground is.
        gated.y = intendedPosition.y - distanceToGround;

        // Make a note that the player is now grounded.
        // We return this at the end of the function, along with
        // the safe position.
        grounded = true;

        // If the collided object also has a groundMaterial set inside
        // its userData (the place that threeJS lets us attach arbitrary
        // info to our objects), also set the groundMaterial. This is
        // also returned at the end of the function alongside the grounded
        // variable.
        if (collision.object.userData.groundMaterial) {
            groundMaterial = collision.object.userData.groundMaterial;
        }
    }
}

And thats it for vertical environment constraints. Simples!

The horizontal environment constraint system is a bit more complex. But in its essence, what it does is:

  1. Work out the horizontal direction the player is travelling in. In olde worlde terms, this can be thought of as North, South, SouthEast, SouthSouthWest etc, but in ThreeJS it is represented by a Vector.
  2. Cast a ray in the direction that the player is travelling in.
  3. Use the ray to find out if allowing the players intended position would cause the player to pass through any of the wall meshes.

And it is at this point that the horizontal ECS becomes more complex than the vertical ECS. With the vertical ECS, if a collision happens, we can just set the players Y position to the Y position of the point at which the collision happened - effectively halting the players Y movement. However, it we did this for horizontal movement, it would make for a very frustrating game experience.

If the player was running head on into a wall, and was stopped dead in their tracks, this would be fine. But if the player moved into the wall at a very shallow angle, and merely grazed it, it would appear that they had "gotten stuck" on the wall, and would find themselves having to reverse away from it, and take care not to touch it again.

What we actually want to happen, is have the player's horizontal velocity attenuated, so that they move along the wall. Therefore, the horizontal ECS proceeds as follows:

  1. Obtain the normal of the surface that was collided with. (For our purposes, a normal can be described as the direction that the wall is facing)
  2. Inspect the difference between the wall normal direction, and the player's movement direction.
  3. Use the difference to work out a safe position, which is the point in space that the collision happened, incremented by a vector that is horizontally perpendicular to the wall normal, multiplied by the cross product of the players input direction and the wall normal.

...

Here is the final utility class, in full:

import * as Three from '../../../vendor/three/three.module.js';

class Planeclamp {

    constructor({
        scene,
        floors = [],
        walls = [],
        drawRays = true,
    } = {}) {
        this.drawRays = drawRays;
        this.floors = [];
        this.walls = [];
        this.scene = scene;
        this.objects = [];

        // Init collidable mesh lists
        this.addFloors(floors);
        this.addWalls(walls);

        // Create rays
        this.raycasters = {
            vert: new Three.Raycaster(),
            horzLeft: new Three.Raycaster(),
            horzRight: new Three.Raycaster(),
            correction: new Three.Raycaster(),
        };
    }

    setDrawRays(draw) {
        this.drawRays = draw;
    }

    addFloor(floor) {
        this.floors.push(floor);
    }

    removeFloor(floor) {
        this.floors = this.floors.filter(thisFloor => thisFloor !== floor);
    }

    addFloors(floors) {
        floors.forEach(floor => this.addFloor(floor));
    }

    resetFloors() {
        this.floors = [];
    }

    addWall(wall) {
        this.walls.push(wall);
    }

    removeWall(wall) {
        this.walls = this.walls.filter(thisWall => thisWall !== wall);
    }

    addWalls(walls) {
        walls.forEach(wall => this.addWall(wall));
    }

    resetWalls() {
        this.walls = [];
    }

    getSafePosition(startingPositionIn, intendedPositionIn, {
        collisionPadding = .5,
        heightOffset = 0,
    } = {}) {

        // ------------------ Setup -------------------

        // Parse args
        const startingPosition = startingPositionIn.clone();
        const intendedPosition = intendedPositionIn.clone();
        let grounded = false;
        let groundMaterial = null;

        // Augmenters
        const backStepVert = 50;
        const backStepHorz = 5;
        const backStepCorrection = 5;

        // Prepare output
        let gated = {
            x: intendedPosition.x,
            y: intendedPosition.y,
            z: intendedPosition.z,
        };

        // Clean up previous debug visuals
        this.objects.map(object => this.scene.remove(object));
        this.objects = [];

        // ------------------ Vertical position gating -------------------

        // Adjust vertical position in gated.y.
        // Store grounded status in grounded.
        const start = intendedPosition.clone().sub(new Three.Vector3(
            0,
            (backStepVert * -1) - (heightOffset / 2),
            0)
        );
        const direction = new Three.Vector3(0, -1, 0).normalize();
        this.raycasters.vert.set(start, direction);
        const dirCollisions = this.raycasters.vert.intersectObjects(this.floors, false);
        if (this.drawRays) {
            const arrowColour = dirCollisions.length ? 0xff0000 : 0x0000ff;
            const arrow = new Three.ArrowHelper(this.raycasters.vert.ray.direction, this.raycasters.vert.ray.origin, 300, arrowColour);
            this.objects.push(arrow);
        }
        let distanceToGround = null;
        if (dirCollisions.length) {
            const collision = dirCollisions[0];
            distanceToGround = collision.distance - backStepVert - heightOffset;
            if (distanceToGround < 0) {
                gated.y = intendedPosition.y - distanceToGround;
                grounded = true;
                if (collision.object.userData.groundMaterial) {
                    groundMaterial = collision.object.userData.groundMaterial;
                }
            }
        }

        // ------------------ Horizontal position gating -------------------

        const horizontalOutputPosition = (() => {

            // Init output position
            const outputPosition = new Three.Vector3(intendedPosition.x, 0, intendedPosition.z);

            // Store normalised input vector
            const startingPos = startingPosition.clone();
            const intendedPos = intendedPosition.clone();
            startingPos.y = startingPositionIn.y + .5;
            intendedPos.y = startingPositionIn.y + .5;
            let inputVector = intendedPos.clone().sub(startingPos).normalize();

            // Work out distances
            const startingIntendedDist = startingPos.distanceTo(intendedPos);
            const inputSpeed = startingIntendedDist;

            // Define function for moving ray left and right
            function adj(position, offset) {
                const rayAdjuster = inputVector
                    .clone()
                    .applyAxisAngle(new Three.Vector3(0, 1, 0), Math.PI / 2)
                    .multiplyScalar(.5)
                    .multiplyScalar(offset);
                return position.clone().add(rayAdjuster);
            }

            // Work out intersections and collision
            let collisions = {
                left: {
                    collision: null
                },
                right: {
                    collision: null
                }
            };
            Object.keys(collisions).forEach(side => {
                const rayOffset = side === 'left' ? -1 : 1;

                const rayStart = adj(startingPos.clone().sub(inputVector.clone().multiplyScalar(2)), rayOffset);
                const startingPosSide = adj(startingPos, rayOffset);
                const intendedPosSide = adj(intendedPos, rayOffset);
                const startingIntendedDistSide = startingPosSide.distanceTo(intendedPosSide);

                const rayKey = 'horz' + _.startCase(side);
                this.raycasters[rayKey].set(rayStart, inputVector);
                const intersections = this.raycasters[rayKey].intersectObjects(this.walls, true);
                for (let i = 0; i < intersections.length; i++) {
                    if (collisions[side].collision) break;
                    const thisIntersection = intersections[i];
                    const startingCollisionDist = startingPosSide.distanceTo(thisIntersection.point);
                    if (startingCollisionDist - collisionPadding <= startingIntendedDistSide) {
                        collisions[side].collision = thisIntersection;
                        collisions[side].offset = rayOffset;
                    }
                }

                if (inputSpeed && this.drawRays) {
                    this.objects.push(new Three.ArrowHelper(this.raycasters[rayKey].ray.direction, this.raycasters[rayKey].ray.origin, 300, 0x0000ff));
                }
            });

            const [ leftCollision, rightCollision ] = [ collisions.left.collision, collisions.right.collision ];
            const collisionData = (leftCollision?.distance || Infinity) < (rightCollision?.distance || Infinity) ? collisions.left : collisions.right;

            if (collisionData.collision) {

                // Var shorthands
                const collision = collisionData.collision;
                const normalVector = collision.face.normal.clone();
                normalVector.transformDirection(collision.object.matrixWorld);
                normalVector.normalize();

                // Give output a baseline position that is the same as the collision position
                let paddedCollision = collision.point.clone().sub(inputVector.clone().multiplyScalar(collisionPadding));
                paddedCollision = adj(paddedCollision, collisionData.offset * -1);
                outputPosition.x = paddedCollision.x;
                outputPosition.z = paddedCollision.z;

                if (leftCollision && rightCollision && leftCollision.face !== rightCollision.face) {
                    return startingPos;
                }

                // Work out difference between input vector and output / normal vector
                const iCAngleCross = inputVector.clone().cross(normalVector).y; // -1 to 1

                // Work out output vector
                const outputVector = (() => {
                    const ivn = inputVector.clone().add(normalVector);
                    const xMultiplier = ivn.x > 0 ? 1 : -1;
                    const zMultiplier = ivn.z > 0 ? 1 : -1;
                    return new Three.Vector3(
                        Math.abs(normalVector.z) * xMultiplier,
                        0,
                        Math.abs(normalVector.x) * zMultiplier,
                    ).normalize();
                })();

                if (inputSpeed && this.drawRays) {
                    this.objects.push(new Three.ArrowHelper(normalVector, startingPos, 300, 0xff0000));
                }

                // Work out output speed
                const outputSpeed = inputSpeed * Math.abs(iCAngleCross) * 0.8;

                // Increment output position with output vector X output speed
                outputPosition.add(outputVector.clone().multiplyScalar(outputSpeed));

            }

            // ------------------ Done -------------------

            return outputPosition;
        })();

        gated.x = horizontalOutputPosition.x;
        gated.z = horizontalOutputPosition.z;

        // ------------------ Culmination -------------------

        // Add debug visuals
        this.objects.map(object => this.scene.add(object));

        // Return gated position
        const safePosition = new Three.Vector3(gated.x, gated.y, gated.z);
        return { safePosition, grounded, groundMaterial };
    }
}

export default Planeclamp;

Upvotes: 0

kohloth
kohloth

Reputation: 904

Some code provided by a Physicist, which partly worked when I converted it to Javascript and applied it to the program:

Vector3 wallNormal = new Vector3(-0.5, 0.0, 0.5);
Vector3 incomingVelocity = new Vector3(0.0, 0.0, -1.0);

double magnitudeProduct = wallNormal.Length() * incomingVelocity.Length();
double angleBetweenVelocityAndWall = ((-incomingVelocity).Dot(wallNormal)) / (magnitudeProduct);

double newVelocityMagnitude = incomingVelocity.Length() * Math.Sin(angleBetweenVelocityAndWall);

Vector3 upVector =incomingVelocity.Cross(wallNormal);
Vector3 newDirection = wallNormal.Cross(upVector);
Vector3 newVelocity = newDirection.Normalise() * newVelocityMagnitude;

Upvotes: 0

Qsnok
Qsnok

Reputation: 43

since a tank will not jump or fly, you should be fine with just a 2D-System for your calculation?

i found a link describing the physics of car hitting a solid brick wall.

http://colgatephys111.blogspot.com/2017/12/guardrail-lessens-force-of-impact.html

hope thats gonna help you a bit!

edit: so, out of curiosity, i asked an theoretical physicist over the phone about your issue.

you got 2 seperate problems to solve: 1. P1 what is the velocity v' while hitting the wall? 2. P2 what is the new angle of the vehicel?

P2 should be fairly easy, considering your tank is adapting the angle of the wall you only need to calculate in which direction the wall is "pointing".

P1 in physics, we would talk about the reduced force and not the velocity, but given a constant limit to the force F1 (eg. your engine) resulting in a constant maxspeed, and with a given force the wall has on the vehicel F2

v = F1
v' = F1'
F1' = F1 - F2

i think https://www.thoughtco.com/what-is-the-physics-of-a-car-collision-2698920 explains what to do

Upvotes: 1

Related Questions