playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
656 lines (655 loc) • 23.3 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { now } from "../../../core/time.js";
import { ObjectPool } from "../../../core/object-pool.js";
import { Debug } from "../../../core/debug.js";
import { Vec3 } from "../../../core/math/vec3.js";
import { AmmoPhysicsWorld } from "../../physics/ammo/ammo-physics-world.js";
import { ComponentSystem } from "../system.js";
import {
BODYGROUP_TRIGGER,
BODYMASK_NOT_STATIC,
BODYTYPE_DYNAMIC,
BODYTYPE_KINEMATIC,
BODYTYPE_STATIC
} from "./constants.js";
import { RigidBodyComponent } from "./component.js";
import { ContactPoint } from "./contact-point.js";
import { ContactResult } from "./contact-result.js";
import { SingleContactResult } from "./single-contact-result.js";
const _properties = [
"mass",
"linearDamping",
"angularDamping",
"linearFactor",
"angularFactor",
"friction",
"rollingFriction",
"restitution",
"type",
"group",
"mask"
];
class RigidBodyComponentSystem extends ComponentSystem {
/**
* Create a new RigidBodyComponentSystem.
*
* @param {AppBase} app - The Application.
* @ignore
*/
constructor(app) {
super(app);
/** @ignore */
__publicField(this, "maxSubSteps", 10);
/**
* @type {number}
* @ignore
*/
__publicField(this, "fixedTimeStep", 1 / 60);
/**
* The world space vector representing global gravity in the physics simulation. Defaults to
* [0, -9.81, 0] which is an approximation of the gravitational force on Earth.
*
* @example
* // Set the gravity in the physics world to simulate a planet with low gravity
* app.systems.rigidbody.gravity = new Vec3(0, -3.7, 0);
*/
__publicField(this, "gravity", new Vec3(0, -9.81, 0));
/**
* @type {PhysicsWorld|null}
* @private
*/
__publicField(this, "_world", null);
/**
* @type {RigidBodyComponent[]}
* @private
*/
__publicField(this, "_dynamic", []);
/**
* @type {RigidBodyComponent[]}
* @private
*/
__publicField(this, "_kinematic", []);
/**
* @type {Trigger[]}
* @private
*/
__publicField(this, "_triggers", []);
/**
* @type {CollisionComponent[]}
* @private
*/
__publicField(this, "_compounds", []);
this.id = "rigidbody";
this._stats = app.stats.frame;
this.ComponentType = RigidBodyComponent;
this.contactPointPool = null;
this.contactResultPool = null;
this.singleContactResultPool = null;
this.collisions = {};
this.frameCollisions = {};
this.on("beforeremove", this.onBeforeRemove, this);
}
/**
* Called once Ammo has been loaded. Responsible for creating the physics world.
*
* @ignore
*/
onLibraryLoaded() {
if (!this._world && typeof Ammo !== "undefined") {
this.setPhysicsWorld(new AmmoPhysicsWorld({ contactListener: this }));
} else if (!this._world) {
this.app.systems.off("update", this.onUpdate, this);
}
}
/**
* Installs a physics backend. Used internally for Ammo auto-detection and by tests to
* inject a NullPhysicsWorld. A backend can be installed at most once.
*
* @param {PhysicsWorld} world - The physics backend.
* @ignore
*/
setPhysicsWorld(world) {
Debug.assert(!this._world, "RigidBodyComponentSystem#setPhysicsWorld: a physics world is already installed.");
this._world = world;
this.contactPointPool = new ObjectPool(ContactPoint, 1);
this.contactResultPool = new ObjectPool(ContactResult, 1);
this.singleContactResultPool = new ObjectPool(SingleContactResult, 1);
this.app.systems.on("update", this.onUpdate, this);
}
/**
* The installed physics backend, or null when no physics library has loaded.
*
* @type {PhysicsWorld|null}
* @ignore
*/
get physicsWorld() {
return this._world;
}
/**
* The native physics world - btDiscreteDynamicsWorld when the Ammo backend is active,
* null otherwise.
*
* @type {*}
* @ignore
*/
get dynamicsWorld() {
return this._world?.nativeWorld ?? null;
}
/** @ignore */
get collisionConfiguration() {
return this._world?.collisionConfiguration ?? null;
}
/** @ignore */
get dispatcher() {
return this._world?.dispatcher ?? null;
}
/** @ignore */
get overlappingPairCache() {
return this._world?.overlappingPairCache ?? null;
}
/** @ignore */
get solver() {
return this._world?.solver ?? null;
}
initializeComponentData(component, data) {
for (const property of _properties) {
if (data.hasOwnProperty(property)) {
const value = data[property];
if (Array.isArray(value)) {
component[property] = new Vec3(value[0], value[1], value[2]);
} else {
component[property] = value;
}
}
}
super.initializeComponentData(component, data);
}
cloneComponent(entity, clone) {
const c = entity.rigidbody;
const data = {
enabled: c.enabled
};
for (const property of _properties) {
data[property] = c[property];
}
return this.addComponent(clone, data);
}
onBeforeRemove(entity, component) {
if (component.enabled) {
component.enabled = false;
}
if (component._body) {
this._world.destroyBody(component._body);
component.body = null;
}
}
addBody(body, group, mask) {
this._world.addBody(body, group, mask);
}
removeBody(body) {
this._world.removeBody(body);
}
/**
* Adds a component's body to the simulation and registers the component with the update
* lists for its body type. Fires 'simulationenabled' on the component. No-op unless the
* component has a body, an enabled collision component and is not already simulating.
*
* @param {RigidBodyComponent} component - The component to add to the simulation.
* @ignore
*/
enableSimulation(component) {
const entity = component.entity;
if (entity.collision && entity.collision.enabled && !component._simulationEnabled) {
const body = component._body;
if (body) {
this.addBody(body, component._group, component._mask);
switch (component._type) {
case BODYTYPE_DYNAMIC:
this._dynamic.push(component);
component.syncEntityToBody();
break;
case BODYTYPE_KINEMATIC:
this._kinematic.push(component);
break;
case BODYTYPE_STATIC:
component.syncEntityToBody();
break;
}
if (entity.collision.type === "compound") {
this._compounds.push(entity.collision);
}
body.activate();
component._simulationEnabled = true;
component.fire("simulationenabled");
}
}
}
/**
* Removes a component's body from the simulation and unregisters the component from the
* update lists. Fires 'simulationdisabled' on the component. No-op unless the component
* has a body and is currently simulating.
*
* @param {RigidBodyComponent} component - The component to remove from the simulation.
* @ignore
*/
disableSimulation(component) {
const body = component._body;
if (body && component._simulationEnabled) {
let idx = this._compounds.indexOf(component.entity.collision);
if (idx > -1) {
this._compounds.splice(idx, 1);
}
idx = this._dynamic.indexOf(component);
if (idx > -1) {
this._dynamic.splice(idx, 1);
}
idx = this._kinematic.indexOf(component);
if (idx > -1) {
this._kinematic.splice(idx, 1);
}
this.removeBody(body);
component._simulationEnabled = false;
component.fire("simulationdisabled");
}
}
/**
* Adds a trigger's body to the simulation and registers the trigger for per-frame
* transform updates. No-op if the trigger is already registered.
*
* @param {Trigger} trigger - The trigger to add to the simulation.
* @ignore
*/
addTrigger(trigger) {
if (this._triggers.indexOf(trigger) < 0) {
this.addBody(trigger.body, BODYGROUP_TRIGGER, BODYMASK_NOT_STATIC ^ BODYGROUP_TRIGGER);
this._triggers.push(trigger);
}
}
/**
* Removes a trigger's body from the simulation and unregisters the trigger. No-op if the
* trigger is not registered.
*
* @param {Trigger} trigger - The trigger to remove from the simulation.
* @ignore
*/
removeTrigger(trigger) {
const idx = this._triggers.indexOf(trigger);
if (idx > -1) {
this.removeBody(trigger.body);
this._triggers.splice(idx, 1);
}
}
/**
* Raycast the world and return the first entity the ray hits. Fire a ray into the world from
* start to end, if the ray hits an entity with a collision component, it returns a
* {@link RaycastResult}, otherwise returns null.
*
* @param {Vec3} start - The world space point where the ray starts.
* @param {Vec3} end - The world space point where the ray ends.
* @param {object} [options] - The additional options for the raycasting.
* @param {number} [options.filterCollisionGroup] - Collision group to apply to the raycast.
* @param {number} [options.filterCollisionMask] - Collision mask to apply to the raycast.
* @param {any[]} [options.filterTags] - Tags filters. Defined the same way as a {@link Tags#has}
* query but within an array.
* @param {Function} [options.filterCallback] - Custom function to use to filter entities.
* Must return true to proceed with result. Takes one argument: the entity to evaluate.
*
* @returns {RaycastResult|null} The result of the raycasting or null if there was no hit.
*/
raycastFirst(start, end, options = {}) {
if (options.filterTags || options.filterCallback) {
options.sort = true;
return this.raycastAll(start, end, options)[0] || null;
}
return this._world.raycastFirst(start, end, options);
}
/**
* Raycast the world and return all entities the ray hits. It returns an array of
* {@link RaycastResult}, one for each hit. If no hits are detected, the returned array will be
* of length 0. Results are sorted by distance with closest first.
*
* @param {Vec3} start - The world space point where the ray starts.
* @param {Vec3} end - The world space point where the ray ends.
* @param {object} [options] - The additional options for the raycasting.
* @param {boolean} [options.sort] - Whether to sort raycast results based on distance with closest
* first. Defaults to false.
* @param {number} [options.filterCollisionGroup] - Collision group to apply to the raycast.
* @param {number} [options.filterCollisionMask] - Collision mask to apply to the raycast.
* @param {any[]} [options.filterTags] - Tags filters. Defined the same way as a {@link Tags#has}
* query but within an array.
* @param {Function} [options.filterCallback] - Custom function to use to filter entities.
* Must return true to proceed with result. Takes the entity to evaluate as argument.
*
* @returns {RaycastResult[]} An array of raycast hit results (0 length if there were no hits).
*
* @example
* // Return all results of a raycast between 0, 2, 2 and 0, -2, -2
* const hits = this.app.systems.rigidbody.raycastAll(new Vec3(0, 2, 2), new Vec3(0, -2, -2));
* @example
* // Return all results of a raycast between 0, 2, 2 and 0, -2, -2
* // where hit entity is tagged with `bird` OR `mammal`
* const hits = this.app.systems.rigidbody.raycastAll(new Vec3(0, 2, 2), new Vec3(0, -2, -2), {
* filterTags: [ "bird", "mammal" ]
* });
* @example
* // Return all results of a raycast between 0, 2, 2 and 0, -2, -2
* // where hit entity has a `camera` component
* const hits = this.app.systems.rigidbody.raycastAll(new Vec3(0, 2, 2), new Vec3(0, -2, -2), {
* filterCallback: (entity) => entity && entity.camera
* });
* @example
* // Return all results of a raycast between 0, 2, 2 and 0, -2, -2
* // where hit entity is tagged with (`carnivore` AND `mammal`) OR (`carnivore` AND `reptile`)
* // and the entity has an `anim` component
* const hits = this.app.systems.rigidbody.raycastAll(new Vec3(0, 2, 2), new Vec3(0, -2, -2), {
* filterTags: [
* [ "carnivore", "mammal" ],
* [ "carnivore", "reptile" ]
* ],
* filterCallback: (entity) => entity && entity.anim
* });
*/
raycastAll(start, end, options = {}) {
const results = this._world.raycastAll(start, end, options);
if (options.sort) {
results.sort((a, b) => a.hitFraction - b.hitFraction);
}
return results;
}
/**
* Stores a collision between the entity and other in the contacts map and returns true if it
* is a new collision.
*
* @param {Entity} entity - The entity.
* @param {Entity} other - The entity that collides with the first entity.
* @returns {boolean} True if this is a new collision, false otherwise.
* @private
*/
_storeCollision(entity, other) {
let isNewCollision = false;
const guid = entity.guid;
this.collisions[guid] = this.collisions[guid] || { others: [], entity };
if (this.collisions[guid].others.indexOf(other) < 0) {
this.collisions[guid].others.push(other);
isNewCollision = true;
}
this.frameCollisions[guid] = this.frameCollisions[guid] || { others: [], entity };
this.frameCollisions[guid].others.push(other);
return isNewCollision;
}
/**
* Allocates a pooled contact point that is the given one seen from the other body's
* perspective.
*
* @param {ContactPoint} forward - The contact point from body A's perspective.
* @returns {ContactPoint} The reversed contact point.
* @private
*/
_createReverseContactPoint(forward) {
const contact = this.contactPointPool.allocate();
contact.localPoint.copy(forward.localPointOther);
contact.localPointOther.copy(forward.localPoint);
contact.point.copy(forward.pointOther);
contact.pointOther.copy(forward.point);
contact.normal.copy(forward.normal);
contact.impulse = forward.impulse;
return contact;
}
_createSingleContactResult(a, b, contactPoint) {
const result = this.singleContactResultPool.allocate();
result.a = a;
result.b = b;
result.localPointA = contactPoint.localPoint;
result.localPointB = contactPoint.localPointOther;
result.pointA = contactPoint.point;
result.pointB = contactPoint.pointOther;
result.normal = contactPoint.normal;
result.impulse = contactPoint.impulse;
return result;
}
_createContactResult(other, contacts) {
const result = this.contactResultPool.allocate();
result.other = other;
result.contacts = contacts;
return result;
}
/**
* Removes collisions that no longer exist from the collisions list and fires collisionend
* events to the related entities.
*
* @private
*/
_cleanOldCollisions() {
for (const guid in this.collisions) {
if (this.collisions.hasOwnProperty(guid)) {
const frameCollision = this.frameCollisions[guid];
const collision = this.collisions[guid];
const entity = collision.entity;
const entityCollision = entity.collision;
const entityRigidbody = entity.rigidbody;
const others = collision.others;
const length = others.length;
let i = length;
while (i--) {
const other = others[i];
if (!frameCollision || frameCollision.others.indexOf(other) < 0) {
others.splice(i, 1);
if (entity.trigger) {
if (entityCollision) {
entityCollision.fire("triggerleave", other);
}
if (other.rigidbody) {
other.rigidbody.fire("triggerleave", entity);
}
} else if (!other.trigger) {
if (entityRigidbody) {
entityRigidbody.fire("collisionend", other);
}
if (entityCollision) {
entityCollision.fire("collisionend", other);
}
}
}
}
if (others.length === 0) {
delete this.collisions[guid];
}
}
}
}
/**
* Removes any stored collision keyed to the given entity. Called when a collision component is
* removed so the persistent collisions map does not retain a destroyed entity. A new entity
* that later reuses the same GUID (for example after reloading the same scene) would otherwise
* inherit the stale entry and never fire `triggerleave` / `collisionend`, because the cached
* entity no longer has a trigger or body.
*
* @param {Entity} entity - The entity whose stored collision should be removed.
* @ignore
*/
clearEntityCollisions(entity) {
delete this.collisions[entity.guid];
}
/**
* Returns true if the entity has a contact event attached and false otherwise.
*
* @param {Entity} entity - Entity to test.
* @returns {boolean} True if the entity has a contact and false otherwise.
* @private
*/
_hasContactEvent(entity) {
const c = entity.collision;
if (c && (c.hasEvent("collisionstart") || c.hasEvent("collisionend") || c.hasEvent("contact"))) {
return true;
}
const r = entity.rigidbody;
return r && (r.hasEvent("collisionstart") || r.hasEvent("collisionend") || r.hasEvent("contact"));
}
/**
* Called by the physics backend when a contact pass begins.
*
* @ignore
*/
onContactsBegin() {
this.frameCollisions = {};
}
/**
* Called by the physics backend for each contacting pair. Fires the trigger and collision
* events.
*
* @param {PhysicsContactPair} pair - The contacting pair. Only valid during the call.
* @ignore
*/
onContactPair(pair) {
const e0 = pair.entityA;
const e1 = pair.entityB;
const forwardContacts = [];
const reverseContacts = [];
let newCollision;
if (pair.triggerA || pair.triggerB) {
const e0Events = e0.collision && (e0.collision.hasEvent("triggerenter") || e0.collision.hasEvent("triggerleave"));
const e1Events = e1.collision && (e1.collision.hasEvent("triggerenter") || e1.collision.hasEvent("triggerleave"));
const e0BodyEvents = e0.rigidbody && (e0.rigidbody.hasEvent("triggerenter") || e0.rigidbody.hasEvent("triggerleave"));
const e1BodyEvents = e1.rigidbody && (e1.rigidbody.hasEvent("triggerenter") || e1.rigidbody.hasEvent("triggerleave"));
if (e0Events) {
newCollision = this._storeCollision(e0, e1);
if (newCollision && !pair.triggerB) {
e0.collision.fire("triggerenter", e1);
}
}
if (e1Events) {
newCollision = this._storeCollision(e1, e0);
if (newCollision && !pair.triggerA) {
e1.collision.fire("triggerenter", e0);
}
}
if (e0BodyEvents) {
if (!newCollision) {
newCollision = this._storeCollision(e1, e0);
}
if (newCollision) {
e0.rigidbody.fire("triggerenter", e1);
}
}
if (e1BodyEvents) {
if (!newCollision) {
newCollision = this._storeCollision(e0, e1);
}
if (newCollision) {
e1.rigidbody.fire("triggerenter", e0);
}
}
} else {
const e0Events = this._hasContactEvent(e0);
const e1Events = this._hasContactEvent(e1);
const globalEvents = this.hasEvent("contact");
if (globalEvents || e0Events || e1Events) {
const contactCount = pair.contactCount;
for (let j = 0; j < contactCount; j++) {
const contactPoint = this.contactPointPool.allocate();
pair.readContact(j, contactPoint);
if (e0Events || e1Events) {
forwardContacts.push(contactPoint);
reverseContacts.push(this._createReverseContactPoint(contactPoint));
}
if (globalEvents) {
const result = this._createSingleContactResult(e0, e1, contactPoint);
this.fire("contact", result);
}
}
if (e0Events) {
const forwardResult = this._createContactResult(e1, forwardContacts);
newCollision = this._storeCollision(e0, e1);
if (e0.collision) {
e0.collision.fire("contact", forwardResult);
if (newCollision) {
e0.collision.fire("collisionstart", forwardResult);
}
}
if (e0.rigidbody) {
e0.rigidbody.fire("contact", forwardResult);
if (newCollision) {
e0.rigidbody.fire("collisionstart", forwardResult);
}
}
}
if (e1Events) {
const reverseResult = this._createContactResult(e0, reverseContacts);
newCollision = this._storeCollision(e1, e0);
if (e1.collision) {
e1.collision.fire("contact", reverseResult);
if (newCollision) {
e1.collision.fire("collisionstart", reverseResult);
}
}
if (e1.rigidbody) {
e1.rigidbody.fire("contact", reverseResult);
if (newCollision) {
e1.rigidbody.fire("collisionstart", reverseResult);
}
}
}
}
}
}
/**
* Called by the physics backend when a contact pass ends. Fires collisionend/triggerleave
* events for lost contacts and frees the pooled results.
*
* @ignore
*/
onContactsEnd() {
this._cleanOldCollisions();
this.contactPointPool.freeAll();
this.contactResultPool.freeAll();
this.singleContactResultPool.freeAll();
}
onUpdate(dt) {
let i, len;
this._stats.physicsStart = now();
this._world.setGravity(this.gravity);
const triggers = this._triggers;
for (i = 0, len = triggers.length; i < len; i++) {
triggers[i].updateTransform();
}
const compounds = this._compounds;
for (i = 0, len = compounds.length; i < len; i++) {
compounds[i]._updateCompound();
}
const kinematic = this._kinematic;
for (i = 0, len = kinematic.length; i < len; i++) {
kinematic[i]._updateKinematic();
}
this._world.step(dt, this.maxSubSteps, this.fixedTimeStep);
const dynamic = this._dynamic;
for (i = 0, len = dynamic.length; i < len; i++) {
dynamic[i]._updateDynamic();
}
this._world.flushContacts();
this._stats.physicsTime = now() - this._stats.physicsStart;
}
destroy() {
super.destroy();
this.app.systems.off("update", this.onUpdate, this);
if (this._world) {
this._world.destroy();
this._world = null;
}
}
}
/**
* Fired when a contact occurs between two rigid bodies. The handler is passed a
* {@link SingleContactResult} object containing details of the contact between the two bodies.
*
* @event
* @example
* app.systems.rigidbody.on('contact', (result) => {
* console.log(`Contact between ${result.a.name} and ${result.b.name}`);
* });
*/
__publicField(RigidBodyComponentSystem, "EVENT_CONTACT", "contact");
export {
RigidBodyComponentSystem
};