So, i made a physics engine

wammo-mini
^^ Clicking the button in the screen capture demo just puts a new sphere and impostor with springs.

It wasn’t really planning to build a physics engine - I just wanted a force directed graph (Force-directed graph drawing - Wikipedia), so it’s not a physics engine like how you think of the existing ones. I wanted to be able to add additional forces at run time as plugins, the main one being a body gravity force between the nodes which are also linked together with springs.

I found it quite funny (and smart) that ammo.js is from “Avoided Making My Own”. I just put a “Why” in front: brianzinn/wammo: force directed physics engine (github.com). Basically the forces work all together to alter the velocity of the physics impostor. I didn’t add a collision force, but quadtree/octree is actually simpler than the barnes-hut tree as it needs to track center of gravity in each tree node.

The algorithms are not that complex and the solver uses each frame render to improve and work towards equilibrium in the physical world. The gravity (attraction/repulsion) forces are coming FROM each node to all other nodes, so it’s not a world gravity like the other physics engines. You could simulate a planetary system or layout a graph of objects. Anyway, it’s just a very basic start. If you want to try it out the engine the source is here in a very alpha stage danger danger (it’s just 2D right now, but will be easy :slight_smile: to upgrade to include 3D as I’m using vector math everywhere on the position and velocity and physics). Perhaps I am being super naive as well, but I think adding collisions and a “world” gravity is also not out of the question to add dynamically.

I quickly hacked together a babylon plugin (I will also update our docs here!):

const intraNodeGravity = new Vector3(0, -80, 0);
scene.enablePhysics(gravityVector, new WammoPlugin());

What’s next? Firstly when the graph does reach equilibrium it drifts off into outer space from the kinetic energy, so I want to add a centering plugin. That will be super easy to just push everything centered at a point with little impulses. I’ve built quite a few solvers that do Gradient Ascent Gradient descent - Wikipedia that optimize over many solutions and many variables, so it would maybe be cool to run multiple solutions simultaneously and choose the best simulation, but I think it would be too CPU intensive - I already have good Pseudo-RNG code in the project for deterministic testing. This demo is 60FPS, but there needs to be a lot of of optimization as it’s in a more academic state - need to make sure there is a lot less GC pressure and re-use of data structures across iterations. The tree has to be re-built every frame (since objects are moving inbetween frames).

Here is the WammoPlugin to connect the wammo physics engine to babylon:

import { AbstractMesh, IMotorEnabledJoint, IPhysicsEnginePlugin, Nullable, PhysicsImpostor, PhysicsImpostorJoint, PhysicsJoint, Quaternion, Vector3 } from "@babylonjs/core";
import { PhysicsRaycastResult } from "@babylonjs/core/Physics/physicsRaycastResult";

import { Graph, GraphCreateOptions, IVector, PhysicsBody, solver, SolverApi, springForce, Vector2, Vector2Impl } from 'wammo';

export class WammoPlugin implements IPhysicsEnginePlugin {

  private _physicsBodyIndex = 0;
  private _graph: Graph<IVector<Vector2>, number>;
  public world: SolverApi<IVector<Vector2>, number>;
  public name: string = "WammoPlugin";


  // 60 FPS
  private _fixedTimeStep: number = 1 / 60;
  private _raycastResult: PhysicsRaycastResult;
  private _gravity: number = -2;
  private _physicsBodysToRemoveAfterStep: any[] = []; // TODO: remove `any`

  constructor(public useDeltaForSimulatorStep: boolean = true, options?: GraphCreateOptions<IVector<Vector2>, number>) {
    this._raycastResult = new PhysicsRaycastResult();
    this._graph = new Graph<IVector<Vector2>, number>(options);

    const springFn = springForce(
      options?.defaultSpringCoefficient ?? 0.9,
      options?.defaultSpringLength ?? 10
    );

    this.world = solver();
    this.world.loadGraph(this._graph, Vector2Impl);
    this.world.addSpringForce('spring-fn', springFn);
  }

  public initialize(): void {
    console.log('Initialize called in PLUGIN??');
  }

  public executeStep(delta: number, impostors: Array<PhysicsImpostor>): void {
    // NOTE: 
    for (let impostor of impostors) {
      // Update physics world objects to match babylon world
          impostor.beforeStep();
    }

    // const stepDelta =
    this.world.step(this.useDeltaForSimulatorStep ? delta : this._fixedTimeStep);
    for (var mainImpostor of impostors) {
      // After physics update make babylon world objects match physics world objects
      mainImpostor.afterStep();
    }
  }

  public setGravity(gravity: Vector3): void {
    const g = gravity.length();
    // console.warn(`Gravity ${g} forced to negative to bring object together`);
    this._gravity = -g;
  }

  public setTimeStep(timeStep: number) {
    this._fixedTimeStep = timeStep;
  }

  public getTimeStep(): number {
    return this._fixedTimeStep;
  }

  public applyImpulse(impostor: PhysicsImpostor, force: Vector3, contactPoint: Vector3) {
    console.log('applyImpulse', impostor, force, contactPoint);
    // impostor.physicsBody.applyImpulse(impulse, worldPoint);
  }

  public applyForce(impostor: PhysicsImpostor, force: Vector3, contactPoint: Vector3) {
    // var worldPoint = new this.BJSCANNON.Vec3(contactPoint.x, contactPoint.y, contactPoint.z);
    // var impulse = new this.BJSCANNON.Vec3(force.x, force.y, force.z);
    console.log('apply force');

    // impostor.physicsBody.applyForce(impulse, worldPoint);
  }

  /**
   * TODO: this does not look necessary at all...
   */
  private _updatePhysicsBodyTransformation(impostor: PhysicsImpostor) {
    var object = impostor.object;
    if (!object.getBoundingInfo()) {
      return;
    }

    const physicsBody: PhysicsBody<IVector<Vector2>, number> = impostor.physicsBody;
    //Now update the physics body from the impostor object
    // physicsBody.position.vector.x = impostor.object.position.x;
    // physicsBody.position.vector.y = impostor.object.position.y;
    console.log(`Set physics body from impostor object: ${physicsBody.id} to ${JSON.stringify(physicsBody.position.vector)}`);
    // physicsBody.quaternion.....
  }

  public generatePhysicsBody(impostor: PhysicsImpostor) {
    // When calling forceUpdate generatePhysicsBody is called again, ensure that the updated body does not instantly collide with removed body
    // this._removeMarkedPhysicsBodiesFromWorld();

    //parent-child relationship. Does this impostor have a parent impostor?
    if (impostor.parent) {
      console.warn('Parent impostors not supported');
      return;
    }

    //should a new body be created for this impostor?
    if (impostor.isBodyInitRequired()) {
      // var extendSize = impostor.getObjectExtendSize();
      // switch (impostor.type) {
      const physicsBody = new PhysicsBody(this._physicsBodyIndex++, new Vector2Impl(impostor.object.position.x, impostor.object.position.y), new Vector2Impl(), new Vector2Impl());

      //unregister events, if body is being changed
      var oldBody = impostor.physicsBody;
      if (oldBody) {
        console.warn('body already existed???');
        // this.removePhysicsBody(impostor);
      }

      const mass: number = impostor.getParam("mass");
      if (mass) {
        console.log(`object '${(impostor.object as any).name ?? 'no name'}' impostor ${physicsBody.id} mass ->`, mass);
        physicsBody.mass = mass;
      }

      this.world.addBody(physicsBody);
      impostor.physicsBody = physicsBody;
      console.log('physicsBody added to simulation:', physicsBody, impostor);

      if (oldBody) {
        // TODO: see if we should copy ie: velocity/force from original?
      }
      this._updatePhysicsBodyTransformation(impostor);
    }
  }

  public removePhysicsBody(impostor: PhysicsImpostor) {
    console.log('removePhysicsBody', impostor);
    // Only remove the physics body after the physics step to avoid disrupting internal state
    if (this._physicsBodysToRemoveAfterStep.indexOf(impostor.physicsBody) === -1) {
      this._physicsBodysToRemoveAfterStep.push(impostor.physicsBody);
    }
  }

  private _removeMarkedPhysicsBodiesFromWorld(): void {
    if (this._physicsBodysToRemoveAfterStep.length > 0) {
      this._physicsBodysToRemoveAfterStep.forEach((physicsBody) => {
        console.log('want to remove:', physicsBody)
        // this.world.remove(physicsBody);
      });
      this._physicsBodysToRemoveAfterStep = [];
    }
  }

  public generateJoint(impostorJoint: PhysicsImpostorJoint) {
    var mainBody = impostorJoint.mainImpostor.physicsBody;
    var connectedBody = impostorJoint.connectedImpostor.physicsBody;
    if (!mainBody || !connectedBody) {
      console.warn('Joint not connected to 2 bodies?');
      return;
    }

    const fromId = (mainBody as PhysicsBody<IVector<Vector2>, number>).id;
    const toId = (connectedBody as PhysicsBody<IVector<Vector2>, number>).id;

    const nativeParams = impostorJoint.joint.jointData.nativeParams;
    console.error(`world.addSpring(..) ${fromId} -> ${toId}.`, nativeParams);
    // TODO: have a method "createLinkAfterStep?"
    // this._graph.addLink(
    //   fromId,
    //   toId,
    //   nativeParams?.length,
    //   nativeParams?.coefficient,
    // );
    // xx this._graphChangeAfterInit = true;
    this.world.addSpring(fromId, toId, nativeParams?.length, nativeParams?.coefficient);
  }

  public removeJoint(impostorJoint: PhysicsImpostorJoint) {
    // if (impostorJoint.joint.type !== PhysicsJoint.SpringJoint) {
    //     this.world.removeConstraint(impostorJoint.joint.physicsJoint);
    // } else {
    //     impostorJoint.mainImpostor.unregisterAfterPhysicsStep((<SpringJointData>impostorJoint.joint.jointData).forceApplicationCallback);
    // }
    var mainBody = impostorJoint.mainImpostor.physicsBody;
    var connectedBody = impostorJoint.connectedImpostor.physicsBody;
    if (!mainBody || !connectedBody) {
      console.warn('WammoPlugin: removeJoint not connected to 2 bodies?');
      return;
    }

    const fromId = (mainBody as PhysicsBody<IVector<Vector2>, number>).id;
    const toId = (connectedBody as PhysicsBody<IVector<Vector2>, number>).id;
    console.log('WammoPlugin: : removeJoint', fromId, toId, impostorJoint);
    // impostorJoint.joint is babylon object, so we don't want our physics engine to know about it...
    this.world.removeSpring(fromId, toId, undefined, impostorJoint.joint);
  }

  /**
   * Sets the physics body position/rotation from the babylon mesh's position/rotation.
   * NOTE: called in AFTER step, so once our simulation/solver has completed an iteranion.
   *
   * @param impostor imposter containing the physics body and babylon object
   */
  public setTransformationFromPhysicsBody(impostor: PhysicsImpostor) {
    const physicsBody: PhysicsBody<IVector<Vector2>, number> = impostor.physicsBody;
    // y-up
    impostor.object.position.set(physicsBody.position.vector.x, 2, physicsBody.position.vector.y);
    // if (impostor.object.rotationQuaternion) {
    //   const q = impostor.physicsBody.quaternion;
    //   impostor.object.rotationQuaternion.set(q.x, q.y, q.z, q.w);
    // }
  }

  /**
   * Sets the babylon object's position/rotation from the physics body's position/rotation.
   * Called in physicsImpostor BEFORE step.  By leaving this empty we are disallowing position to be set ie: manually?
   *
   * @param impostor imposter containing the physics body and babylon object
   * @param newPosition new position
   * @param newRotation new rotation
   */
  public setPhysicsBodyTransformation(impostor: PhysicsImpostor, newPosition: Vector3, newRotation: Quaternion) {
    // console.log('physics body:', impostor.physicsBody, newPosition);
    const physicsBody: PhysicsBody<IVector<Vector2>, number> = impostor.physicsBody;
    physicsBody.position.vector.x = newPosition.x;
    physicsBody.position.vector.y = newPosition.z;
    // impostor.physicsBody.quaternion.set(newRotation.x, newRotation.y, newRotation.z, newRotation.w);
  }

  public isSupported(): boolean {
    if (navigator.userAgent.toLowerCase().indexOf('chrome') > -1) {
      // colors from logo
      const topBlue = '#3B789A';
      const sideBlue = '#405A68';
      const bottomBlue = '#61B0E1';

      const descriptions: string[] = ['Wammo', 'ManyBody', 'Spring'];

      const url = 'http://github.com/brianzinn/wammo/';
      const length = url.length + 9;

      const args = ['']
      args[0] += `%c %c %c ♥ %c  ${' '.padEnd(url.length)}  %c ♥ %c %c \n`
      args.push(...[
        `background: ${topBlue}; padding:5px 0;`,
        `background: ${sideBlue}; padding:5px 0;`,
        `color: red; background: ${bottomBlue}; padding:5px 0;`,
        `background: ${topBlue}; padding:5px 0;`,
        `color: red; background: ${bottomBlue}; padding:5px 0;`,
        `background: ${sideBlue}; padding:5px 0;`,
        `background: ${topBlue}; padding:5px 0;`,
      ]);

      descriptions.forEach((d, i) => {
        args[0] += `%c %c %c ${(i > 0 ? `- ${d}` : d).padEnd(length, ' ')}%c %c \n`;
        args.push(...[
          `background: ${topBlue}; padding:5px 0;`,
          `background: ${sideBlue}; padding:5px 0;`,
          `color: ${bottomBlue}; background: #030307; padding:5px 0;`,
          `background: ${sideBlue}; padding:5px 0;`,
          `background: ${topBlue}; padding:5px 0;`,
        ])
      });

      args[0] += ` %c %c ♥ %c  ${url}  %c ♥ %c %c \n\n`
      args.push(...[
        `background: ${sideBlue}; padding:5px 0;`,
        `color: red; background: ${bottomBlue}; padding:5px 0;`,
        `background: ${topBlue}; padding:5px 0;`,
        `color: red; background: ${bottomBlue}; padding:5px 0;`,
        `background: ${sideBlue}; padding:5px 0;`,
        `background: ${topBlue}; padding:5px 0;`,
      ]);

      window.console.log(...args);
    }
    else if (window.console) {
      window.console.log(`Wammo Physics engine`);
    }
    return true;
  }

  /**
   * Sets the linear velocity of the physics body
   * @param impostor imposter to set the velocity on
   * @param velocity velocity to set
   */
  public setLinearVelocity(impostor: PhysicsImpostor, velocity: Vector3) {
    // impostor.physicsBody.velocity.set(velocity.x, velocity.y, velocity.z);
    console.log('setLinearVelocity');
  }

  /**
   * Sets the angular velocity of the physics body
   * @param impostor imposter to set the velocity on
   * @param velocity velocity to set
   */
  public setAngularVelocity(impostor: PhysicsImpostor, velocity: Vector3) {
    console.log('setAngularVelocity');
    // impostor.physicsBody.angularVelocity.set(velocity.x, velocity.y, velocity.z);
  }

  /**
   * gets the linear velocity
   * @param impostor imposter to get linear velocity from
   * @returns linear velocity
   */
  public getLinearVelocity(impostor: PhysicsImpostor): Nullable<Vector3> {
    console.log('getLinearVelocity');
    var v = impostor.physicsBody.velocity;
    if (!v) {
      return null;
    }
    return new Vector3(v.x, v.y, v.z);
  }

  /**
   * gets the angular velocity
   * @param impostor imposter to get angular velocity from
   * @returns angular velocity
   */
  public getAngularVelocity(impostor: PhysicsImpostor): Nullable<Vector3> {
    console.log('getAngularVelocity');
    var v = impostor.physicsBody.angularVelocity;
    if (!v) {
      return null;
    }
    return new Vector3(v.x, v.y, v.z);
  }

  /**
   * Sets the mass of physics body
   * @param impostor imposter to set the mass on
   * @param mass mass to set
   */
  public setBodyMass(impostor: PhysicsImpostor, mass: number) {
    console.log('setting body mass', mass);
    (impostor.physicsBody as PhysicsBody<IVector<Vector2>, number>).mass = mass;
    // impostor.physicsBody.updateMassProperties();
  }

  /**
   * Gets the mass of the physics body
   * @param impostor imposter to get the mass from
   * @returns mass
   */
  public getBodyMass(impostor: PhysicsImpostor): number {
    const mass = (impostor.physicsBody as PhysicsBody<IVector<Vector2>, number>).mass;
    // console.log(`getBodyMass -> ${mass}`);
    return mass;
  }

  /**
   * Gets friction of the impostor
   * @param impostor impostor to get friction from
   * @returns friction value
   */
  public getBodyFriction(impostor: PhysicsImpostor): number {
    // console.log('getBodyFriction');
    // return 0;
    // return impostor.physicsBody.material.friction;
    return 0.5;
  }

  /**
   * Sets friction of the impostor
   * @param impostor impostor to set friction on
   * @param friction friction value
   */
  public setBodyFriction(impostor: PhysicsImpostor, friction: number) {
    // impostor.physicsBody.material.friction = friction;
    console.warn('set body friction not supported')
  }

  /**
   * Gets restitution of the impostor
   * @param impostor impostor to get restitution from
   * @returns restitution value
   */
  public getBodyRestitution(impostor: PhysicsImpostor): number {
    // console.log('getBodyRestitution');
    return 0.5; // not supported
    // return impostor.physicsBody.material.restitution;
  }

  /**
   * Sets resitution of the impostor
   * @param impostor impostor to set resitution on
   * @param restitution resitution value
   */
  public setBodyRestitution(impostor: PhysicsImpostor, restitution: number) {
    console.warn('set body restitution not supported');
    // impostor.physicsBody.material.restitution = restitution;
  }

  /**
   * Sleeps the physics body and stops it from being active
   * @param impostor impostor to sleep
   */
  public sleepBody(impostor: PhysicsImpostor) {
    impostor.physicsBody.sleep();
  }

  /**
   * Activates the physics body
   * @param impostor impostor to activate
   */
  public wakeUpBody(impostor: PhysicsImpostor) {
    impostor.physicsBody.wakeUp();
  }

  /**
   * Updates the distance parameters of the joint
   * @param joint joint to update
   * @param maxDistance maximum distance of the joint
   * @param minDistance minimum distance of the joint
   */
  public updateDistanceJoint(joint: PhysicsJoint, maxDistance: number) {
    console.log('updateDistanceJoint');
    joint.physicsJoint.distance = maxDistance;
  }

  /**
   * Sets a motor on the joint
   * @param joint joint to set motor on
   * @param speed speed of the motor
   * @param maxForce maximum force of the motor
   * @param motorIndex index of the motor
   */
  public setMotor(joint: IMotorEnabledJoint, speed?: number, maxForce?: number, motorIndex?: number) {
    console.log('setMotor');
    if (!motorIndex) {
      joint.physicsJoint.enableMotor();
      joint.physicsJoint.setMotorSpeed(speed);
      if (maxForce) {
        this.setLimit(joint, maxForce);
      }
    }
  }

  /**
   * Sets the motors limit
   * @param joint joint to set limit on
   * @param upperLimit upper limit
   * @param lowerLimit lower limit
   */
  public setLimit(joint: IMotorEnabledJoint, upperLimit: number, lowerLimit?: number) {
    console.log('setLimit');
    joint.physicsJoint.motorEquation.maxForce = upperLimit;
    joint.physicsJoint.motorEquation.minForce = lowerLimit === void 0 ? -upperLimit : lowerLimit;
  }

  /**
   * Syncs the position and rotation of a mesh with the impostor (used by Inspector debug Helper)
   * @param mesh mesh to sync
   * @param impostor impostor to update the mesh with
   */
  public syncMeshWithImpostor(mesh: AbstractMesh, impostor: PhysicsImpostor) {
    var body: PhysicsBody<IVector<Vector2>, number> = impostor.physicsBody;
    // console.log(`syncMesh to ${JSON.stringify(body.position.vector)}`);
    mesh.position.x = body.position.vector.x;
    // y-up
    // mesh.position.y = body.position.vector.y;
    mesh.position.z = body.position.vector.y;
    mesh.position.y = 2; // TODO: fix

    // if (mesh.rotationQuaternion) {
    //   mesh.rotationQuaternion.x = body.quaternion.x;
    //   mesh.rotationQuaternion.y = body.quaternion.y;
    //   mesh.rotationQuaternion.z = body.quaternion.z;
    //   mesh.rotationQuaternion.w = body.quaternion.w;
    // }
  }

  /**
   * Gets the radius of the impostor
   * @param impostor impostor to get radius from
   * @returns the radius
   */
  public getRadius(impostor: PhysicsImpostor): number {
    // this is called by the physics helper (inspector debug)
    console.warn('assuming sphere for object size');
    const boundingInfo = impostor.object.getBoundingInfo();
    return boundingInfo.boundingSphere.radius;
  }

  /**
   * Gets the box size of the impostor
   * @param impostor impostor to get box size from
   * @param result the resulting box size
   */
  public getBoxSizeToRef(impostor: PhysicsImpostor, result: Vector3): void {
    console.log('getBoxSizeToRef');
    var shape = impostor.physicsBody.shapes[0];
    result.x = shape.halfExtents.x * 2;
    result.y = shape.halfExtents.y * 2;
    result.z = shape.halfExtents.z * 2;
  }

  public dispose() {
    console.log('disposing Wammo Physics plugin.');
  }

  /**
     * Does a raycast in the physics world
     * @param from when should the ray start?
     * @param to when should the ray end?
     * @returns PhysicsRaycastResult
     */
  public raycast(from: Vector3, to: Vector3): PhysicsRaycastResult {
    console.warn('raycast not supported', from, to);
    return this._raycastResult;
  }
}

Would be really interested if anybody actually tried it out (or read this far!). I think the node package is broken - I am working off a linked package locally.

7 Likes

Amazing, I really like the idea.
If I get a chance to save some time and test it, I will. I can only imagine the world of possibilities using this approach. Smart thinking.

This is really interesting. I love the name! Forgive my ignorance, did you sort of make up your own physics or is this based on some kind of “physical” physics? I know you mentioned the force directed graph, is there any more on it? I’m having trouble wrapping my head around it.

hi @justjay - Good question - not only did I not make up my own physics, but it is not even my idea. The force directed graph wiki explains the idea better than I can :smiley:, but I will explain some more. In the demo screen capture I added springs between each physics impostor like a linked list and head + tail connected (so there is a single cycle - it’s not a DAG, but not a fully connected graph). These springs follow Hooke’s law Hooke’s law - Wikipedia. Those springs will only affect obviously springs they are connected to and then there is some butterfly/accordion effect through the other connections. The gravity between impostors is just another physics law Newton’s law of universal gravitation - Wikipedia. The thing to me that makes it an engine is that instead of just running a bunch of iterations and providing a solution that it runs on every frame render.

I did an experiment about a year ago with babylon and directed graphs brianzinn/babylonjs-directed-graph: Directed Graph testing in BabylonJS (github.com). I used dagre to do the layout and it provides a really nice solution with even connections that don’t cross over and I used Babylon Curve3 to make proper connection points and all was good until recently I started to wonder how it worked! Dagre didn’t from what I could tell have a way to dynamically build the graph and reach a solution incrementally and also the solution is based on 2d pixels and was a kludge to the 3d world - since I wanted each frame render to cause part of the solution to be reached step wise for dynamic scenes. And also of course I want a 3D solution…

That step wise part uses the position and velocity and to integrate each step for the nice visual effects like springs (like babylon animations). The point I was more making was that it’s not nearly as complex as I had suspected to get something running and I’m more than positive there are bugs in the current project as it is in an experimental phase. The solvers I wrote for multivariable optimizers were an order of magnitude more complex!

Let’s do an example, too. A BounceEase built-in Babylon easing function, so you know that in 1 second you have to move 1 meter. With that babylon built-in animation only the gradient is needed, so how far along in the second you are determines your next position. In the physics world you just say at this moment in time if the spring has reached equilibrium then it doesn’t do anything. If it is stretched or compressed then apply a force in the direction of the spring to attract or repel based on the distance from the other object. The gravity works more or less the same and then all forces are applied. There are some extra things done to divide forces by number of connected nodes or mass of connected nodes, etc to make the simulation more realistic.

1 Like

Quite impressive! keep us posted. If you end up outside of the danger zone it could be great to document it in our official docs :smiley: