@c-frame/aframe-physics-system
Version:
Physics system for A-Frame VR, built on Cannon.js & Ammo.js
464 lines (392 loc) • 17.5 kB
JavaScript
/* global THREE */
var CANNON = require('cannon-es'),
CONSTANTS = require('./constants'),
C_GRAV = CONSTANTS.GRAVITY,
C_MAT = CONSTANTS.CONTACT_MATERIAL;
const { TYPE } = require('./constants');
var LocalDriver = require('./drivers/local-driver'),
WorkerDriver = require('./drivers/worker-driver'),
NetworkDriver = require('./drivers/network-driver'),
AmmoDriver = require('./drivers/ammo-driver');
require('aframe-stats-panel')
/**
* Physics system.
*/
module.exports = AFRAME.registerSystem('physics', {
schema: {
// CANNON.js driver type
driver: { default: 'local', oneOf: ['local', 'worker', 'network', 'ammo'] },
networkUrl: { default: '', if: {driver: 'network'} },
workerFps: { default: 60, if: {driver: 'worker'} },
workerInterpolate: { default: true, if: {driver: 'worker'} },
workerInterpBufferSize: { default: 2, if: {driver: 'worker'} },
workerEngine: { default: 'cannon', if: {driver: 'worker'}, oneOf: ['cannon'] },
workerDebug: { default: false, if: {driver: 'worker'} },
gravity: { default: C_GRAV },
iterations: { default: CONSTANTS.ITERATIONS },
friction: { default: C_MAT.friction },
restitution: { default: C_MAT.restitution },
contactEquationStiffness: { default: C_MAT.contactEquationStiffness },
contactEquationRelaxation: { default: C_MAT.contactEquationRelaxation },
frictionEquationStiffness: { default: C_MAT.frictionEquationStiffness },
frictionEquationRegularization: { default: C_MAT.frictionEquationRegularization },
// Never step more than four frames at once. Effectively pauses the scene
// when out of focus, and prevents weird "jumps" when focus returns.
maxInterval: { default: 4 / 60 },
// If true, show wireframes around physics bodies.
debug: { default: false },
// If using ammo, set the default rendering mode for debug
debugDrawMode: { default: THREE.AmmoDebugConstants.NoDebug },
// If using ammo, set the max number of steps per frame
maxSubSteps: { default: 4 },
// If using ammo, set the framerate of the simulation
fixedTimeStep: { default: 1 / 60 },
// Whether to output stats, and how to output them. One or more of "console", "events", "panel"
stats: {type: 'array', default: []}
},
/**
* Initializes the physics system.
*/
async init() {
var data = this.data;
// If true, show wireframes around physics bodies.
this.debug = data.debug;
this.initStats();
this.callbacks = {beforeStep: [], step: [], afterStep: []};
this.listeners = {};
this.driver = null;
switch (data.driver) {
case 'local':
this.driver = new LocalDriver();
break;
case 'ammo':
this.driver = new AmmoDriver();
break;
case 'network':
this.driver = new NetworkDriver(data.networkUrl);
break;
case 'worker':
this.driver = new WorkerDriver({
fps: data.workerFps,
engine: data.workerEngine,
interpolate: data.workerInterpolate,
interpolationBufferSize: data.workerInterpBufferSize,
debug: data.workerDebug
});
break;
default:
throw new Error('[physics] Driver not recognized: "%s".', data.driver);
}
if (data.driver !== 'ammo') {
await this.driver.init({
quatNormalizeSkip: 0,
quatNormalizeFast: false,
solverIterations: data.iterations,
gravity: data.gravity,
});
this.driver.addMaterial({name: 'defaultMaterial'});
this.driver.addMaterial({name: 'staticMaterial'});
this.driver.addContactMaterial('defaultMaterial', 'defaultMaterial', {
friction: data.friction,
restitution: data.restitution,
contactEquationStiffness: data.contactEquationStiffness,
contactEquationRelaxation: data.contactEquationRelaxation,
frictionEquationStiffness: data.frictionEquationStiffness,
frictionEquationRegularization: data.frictionEquationRegularization
});
this.driver.addContactMaterial('staticMaterial', 'defaultMaterial', {
friction: 1.0,
restitution: 0.0,
contactEquationStiffness: data.contactEquationStiffness,
contactEquationRelaxation: data.contactEquationRelaxation,
frictionEquationStiffness: data.frictionEquationStiffness,
frictionEquationRegularization: data.frictionEquationRegularization
});
} else {
await this.driver.init({
gravity: data.gravity,
debugDrawMode: data.debugDrawMode,
solverIterations: data.iterations,
maxSubSteps: data.maxSubSteps,
fixedTimeStep: data.fixedTimeStep
});
}
this.initialized = true;
if (this.debug) {
this.setDebug(true);
}
},
initStats() {
// Data used for performance monitoring.
this.statsToConsole = this.data.stats.includes("console")
this.statsToEvents = this.data.stats.includes("events")
this.statsToPanel = this.data.stats.includes("panel")
if (this.statsToConsole || this.statsToEvents || this.statsToPanel) {
this.trackPerf = true;
this.tickCounter = 0;
this.statsTickData = {};
this.statsBodyData = {};
this.countBodies = {
"ammo": () => this.countBodiesAmmo(),
"local": () => this.countBodiesCannon(false),
"worker": () => this.countBodiesCannon(true)
}
this.bodyTypeToStatsPropertyMap = {
"ammo": {
[TYPE.STATIC] : "staticBodies",
[TYPE.KINEMATIC] : "kinematicBodies",
[TYPE.DYNAMIC] : "dynamicBodies",
},
"cannon": {
[CANNON.Body.STATIC] : "staticBodies",
[CANNON.Body.DYNAMIC] : "dynamicBodies"
}
}
const scene = this.el.sceneEl;
scene.setAttribute("stats-collector", `inEvent: physics-tick-data;
properties: before, after, engine, total;
outputFrequency: 100;
outEvent: physics-tick-summary;
outputs: percentile__50, percentile__90, max`);
}
if (this.statsToPanel) {
const scene = this.el.sceneEl;
const space = "   "
scene.setAttribute("stats-panel", "")
scene.setAttribute("stats-group__bodies", `label: Physics Bodies`)
scene.setAttribute("stats-row__b1", `group: bodies;
event:physics-body-data;
properties: staticBodies;
label: Static`)
scene.setAttribute("stats-row__b2", `group: bodies;
event:physics-body-data;
properties: dynamicBodies;
label: Dynamic`)
if (this.data.driver === 'local' || this.data.driver === 'worker') {
scene.setAttribute("stats-row__b3", `group: bodies;
event:physics-body-data;
properties: contacts;
label: Contacts`)
}
else if (this.data.driver === 'ammo') {
scene.setAttribute("stats-row__b3", `group: bodies;
event:physics-body-data;
properties: kinematicBodies;
label: Kinematic`)
scene.setAttribute("stats-row__b4", `group: bodies;
event: physics-body-data;
properties: manifolds;
label: Manifolds`)
scene.setAttribute("stats-row__b5", `group: bodies;
event: physics-body-data;
properties: manifoldContacts;
label: Contacts`)
scene.setAttribute("stats-row__b6", `group: bodies;
event: physics-body-data;
properties: collisions;
label: Collisions`)
scene.setAttribute("stats-row__b7", `group: bodies;
event: physics-body-data;
properties: collisionKeys;
label: Coll Keys`)
}
scene.setAttribute("stats-group__tick", `label: Physics Ticks: Median${space}90th%${space}99th%`)
scene.setAttribute("stats-row__1", `group: tick;
event:physics-tick-summary;
properties: before.percentile__50,
before.percentile__90,
before.max;
label: Before`)
scene.setAttribute("stats-row__2", `group: tick;
event:physics-tick-summary;
properties: after.percentile__50,
after.percentile__90,
after.max;
label: After`)
scene.setAttribute("stats-row__3", `group: tick;
event:physics-tick-summary;
properties: engine.percentile__50,
engine.percentile__90,
engine.max;
label: Engine`)
scene.setAttribute("stats-row__4", `group: tick;
event:physics-tick-summary;
properties: total.percentile__50,
total.percentile__90,
total.max;
label: Total`)
}
},
/**
* Updates the physics world on each tick of the A-Frame scene. It would be
* entirely possible to separate the two – updating physics more or less
* frequently than the scene – if greater precision or performance were
* necessary.
* @param {number} t
* @param {number} dt
*/
tick: function (t, dt) {
if (!this.initialized || !dt) return;
const beforeStartTime = performance.now();
var i;
var callbacks = this.callbacks;
for (i = 0; i < this.callbacks.beforeStep.length; i++) {
this.callbacks.beforeStep[i].beforeStep(t, dt);
}
const engineStartTime = performance.now();
this.driver.step(Math.min(dt / 1000, this.data.maxInterval));
const engineEndTime = performance.now();
for (i = 0; i < callbacks.step.length; i++) {
callbacks.step[i].step(t, dt);
}
for (i = 0; i < callbacks.afterStep.length; i++) {
callbacks.afterStep[i].afterStep(t, dt);
}
if (this.trackPerf) {
const afterEndTime = performance.now();
this.statsTickData.before = engineStartTime - beforeStartTime
this.statsTickData.engine = engineEndTime - engineStartTime
this.statsTickData.after = afterEndTime - engineEndTime
this.statsTickData.total = afterEndTime - beforeStartTime
this.el.emit("physics-tick-data", this.statsTickData)
this.tickCounter++;
if (this.tickCounter === 100) {
this.countBodies[this.data.driver]()
if (this.statsToConsole) {
console.log("Physics body stats:", this.statsBodyData)
}
if (this.statsToEvents || this.statsToPanel) {
this.el.emit("physics-body-data", this.statsBodyData)
}
this.tickCounter = 0;
}
}
},
countBodiesAmmo() {
const statsData = this.statsBodyData
statsData.manifolds = this.driver.dispatcher.getNumManifolds();
statsData.manifoldContacts = 0;
for (let i = 0; i < statsData.manifolds; i++) {
const manifold = this.driver.dispatcher.getManifoldByIndexInternal(i);
statsData.manifoldContacts += manifold.getNumContacts();
}
statsData.collisions = this.driver.collisions.size;
statsData.collisionKeys = this.driver.collisionKeys.length;
statsData.staticBodies = 0
statsData.kinematicBodies = 0
statsData.dynamicBodies = 0
function type(el) {
return el.components['ammo-body'].data.type
}
this.driver.els.forEach((el) => {
const property = this.bodyTypeToStatsPropertyMap["ammo"][type(el)]
statsData[property]++
})
},
countBodiesCannon(worker) {
const statsData = this.statsBodyData
statsData.contacts = worker ? this.driver.contacts.length : this.driver.world.contacts.length;
statsData.staticBodies = 0
statsData.dynamicBodies = 0
const bodies = worker ? Object.values(this.driver.bodies) : this.driver.world.bodies
bodies.forEach((body) => {
const property = this.bodyTypeToStatsPropertyMap["cannon"][body.type]
statsData[property]++
})
},
setDebug: function(debug) {
this.debug = debug;
if (this.data.driver === 'ammo' && this.initialized) {
if (debug && !this.debugDrawer) {
this.debugDrawer = this.driver.getDebugDrawer(this.el.object3D);
this.debugDrawer.enable();
} else if (this.debugDrawer) {
this.debugDrawer.disable();
this.debugDrawer = null;
}
}
},
/**
* Adds a body to the scene, and binds proxied methods to the driver.
* @param {CANNON.Body} body
*/
addBody: function (body, group, mask) {
var driver = this.driver;
if (this.data.driver === 'local') {
body.__applyImpulse = body.applyImpulse;
body.applyImpulse = function () {
driver.applyBodyMethod(body, 'applyImpulse', arguments);
};
body.__applyForce = body.applyForce;
body.applyForce = function () {
driver.applyBodyMethod(body, 'applyForce', arguments);
};
body.updateProperties = function () {
driver.updateBodyProperties(body);
};
this.listeners[body.id] = function (e) { body.el.emit('collide', e); };
body.addEventListener('collide', this.listeners[body.id]);
}
this.driver.addBody(body, group, mask);
},
/**
* Removes a body and its proxied methods.
* @param {CANNON.Body} body
*/
removeBody: function (body) {
this.driver.removeBody(body);
if (this.data.driver === 'local' || this.data.driver === 'worker') {
body.removeEventListener('collide', this.listeners[body.id]);
delete this.listeners[body.id];
body.applyImpulse = body.__applyImpulse;
delete body.__applyImpulse;
body.applyForce = body.__applyForce;
delete body.__applyForce;
delete body.updateProperties;
}
},
/** @param {CANNON.Constraint or Ammo.btTypedConstraint} constraint */
addConstraint: function (constraint) {
this.driver.addConstraint(constraint);
},
/** @param {CANNON.Constraint or Ammo.btTypedConstraint} constraint */
removeConstraint: function (constraint) {
this.driver.removeConstraint(constraint);
},
/**
* Adds a component instance to the system and schedules its update methods to be called
* the given phase.
* @param {Component} component
* @param {string} phase
*/
addComponent: function (component) {
var callbacks = this.callbacks;
if (component.beforeStep) callbacks.beforeStep.push(component);
if (component.step) callbacks.step.push(component);
if (component.afterStep) callbacks.afterStep.push(component);
},
/**
* Removes a component instance from the system.
* @param {Component} component
* @param {string} phase
*/
removeComponent: function (component) {
var callbacks = this.callbacks;
if (component.beforeStep) {
callbacks.beforeStep.splice(callbacks.beforeStep.indexOf(component), 1);
}
if (component.step) {
callbacks.step.splice(callbacks.step.indexOf(component), 1);
}
if (component.afterStep) {
callbacks.afterStep.splice(callbacks.afterStep.indexOf(component), 1);
}
},
/** @return {Array<object>} */
getContacts: function () {
return this.driver.getContacts();
},
getMaterial: function (name) {
return this.driver.getMaterial(name);
}
});