UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

656 lines (655 loc) 23.3 kB
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 };