Reputation: 904
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.
Upvotes: 0
Views: 352
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:
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:
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:
...
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
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
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