playcanvas
Version:
PlayCanvas WebGL game engine
692 lines (689 loc) • 24.5 kB
JavaScript
import { Quat } from '../../../core/math/quat.js';
import { Vec3 } from '../../../core/math/vec3.js';
import { Asset } from '../../asset/asset.js';
import { Component } from '../component.js';
/**
* @import { CollisionComponentData } from './data.js'
* @import { CollisionComponentSystem } from './system.js'
* @import { Entity } from '../../entity.js'
* @import { GraphNode } from '../../../scene/graph-node.js'
* @import { Model } from '../../../scene/model.js'
*/ const _vec3 = new Vec3();
const _quat = new Quat();
/**
* The CollisionComponent enables an {@link Entity} to act as a collision volume. Use it on its own
* to define a trigger volume. Or use it in conjunction with a {@link RigidBodyComponent} to make a
* collision volume that can be simulated using the physics engine.
*
* When an entity is configured as a trigger volume, if an entity with a dynamic or kinematic body
* enters or leaves that trigger volume, both entities will receive trigger events.
*
* You should never need to use the CollisionComponent constructor directly. To add an
* CollisionComponent to an {@link Entity}, use {@link Entity#addComponent}:
*
* ```javascript
* const entity = pc.Entity();
* entity.addComponent('collision'); // This defaults to 1x1x1 box-shaped trigger volume
* ```
*
* To create a 0.5 radius dynamic rigid body sphere:
*
* ```javascript
* const entity = pc.Entity();
* entity.addComponent('collision', {
* type: 'sphere'
* });
* entity.addComponent('rigidbody', {
* type: 'dynamic'
* });
* ```
*
* Once the CollisionComponent is added to the entity, you can access it via the
* {@link Entity#collision} property:
*
* ```javascript
* entity.collision.type = 'cylinder'; // Set the collision volume to a cylinder
*
* console.log(entity.collision.type); // Get the collision volume type and print it
* ```
*
* Relevant Engine API examples:
*
* - [Compound Collision](https://playcanvas.github.io/#/physics/compound-collision)
* - [Falling Shapes](https://playcanvas.github.io/#/physics/falling-shapes)
* - [Offset Collision](https://playcanvas.github.io/#/physics/offset-collision)
*
* @hideconstructor
* @category Physics
*/ class CollisionComponent extends Component {
static{
/**
* Fired when a contact occurs between two rigid bodies. The handler is passed a
* {@link ContactResult} object which contains details of the contact between the two rigid
* bodies.
*
* @event
* @example
* entity.collision.on('contact', (result) => {
* console.log(`Contact between ${entity.name} and ${result.other.name}`);
* });
*/ this.EVENT_CONTACT = 'contact';
}
static{
/**
* Fired when two rigid bodies start touching. The handler is passed the {@link ContactResult}
* object which contains details of the contact between the two rigid bodies.
*
* @event
* @example
* entity.collision.on('collisionstart', (result) => {
* console.log(`${entity.name} started touching ${result.other.name}`);
* });
*/ this.EVENT_COLLISIONSTART = 'collisionstart';
}
static{
/**
* Fired when two rigid bodies stop touching. The handler is passed an {@link Entity} that
* represents the other rigid body involved in the collision.
*
* @event
* @example
* entity.collision.on('collisionend', (other) => {
* console.log(`${entity.name} stopped touching ${other.name}`);
* });
*/ this.EVENT_COLLISIONEND = 'collisionend';
}
static{
/**
* Fired when a rigid body enters a trigger volume. The handler is passed an {@link Entity}
* representing the rigid body that entered this collision volume.
*
* @event
* @example
* entity.collision.on('triggerenter', (other) => {
* console.log(`${other.name} entered trigger volume ${entity.name}`);
* });
*/ this.EVENT_TRIGGERENTER = 'triggerenter';
}
static{
/**
* Fired when a rigid body exits a trigger volume. The handler is passed an {@link Entity}
* representing the rigid body that exited this collision volume.
*
* @event
* @example
* entity.collision.on('triggerleave', (other) => {
* console.log(`${other.name} exited trigger volume ${entity.name}`);
* });
*/ this.EVENT_TRIGGERLEAVE = 'triggerleave';
}
/**
* Create a new CollisionComponent.
*
* @param {CollisionComponentSystem} system - The ComponentSystem that created this Component.
* @param {Entity} entity - The Entity that this Component is attached to.
*/ constructor(system, entity){
super(system, entity), /** @private */ this._compoundParent = null, /** @private */ this._hasOffset = false;
this.entity.on('insert', this._onInsert, this);
this.on('set_type', this.onSetType, this);
this.on('set_convexHull', this.onSetModel, this);
this.on('set_halfExtents', this.onSetHalfExtents, this);
this.on('set_linearOffset', this.onSetOffset, this);
this.on('set_angularOffset', this.onSetOffset, this);
this.on('set_radius', this.onSetRadius, this);
this.on('set_height', this.onSetHeight, this);
this.on('set_axis', this.onSetAxis, this);
this.on('set_asset', this.onSetAsset, this);
this.on('set_renderAsset', this.onSetRenderAsset, this);
this.on('set_model', this.onSetModel, this);
this.on('set_render', this.onSetRender, this);
}
// TODO: Remove this override in upgrading component
/**
* @type {CollisionComponentData}
* @ignore
*/ get data() {
const record = this.system.store[this.entity.getGuid()];
return record ? record.data : null;
}
/**
* Sets the enabled state of the component.
*
* @type {boolean}
*/ set enabled(arg) {
this._setValue('enabled', arg);
}
/**
* Gets the enabled state of the component.
*
* @type {boolean}
*/ get enabled() {
return this.data.enabled;
}
/**
* Sets the type of the collision volume. Can be:
*
* - "box": A box-shaped collision volume.
* - "capsule": A capsule-shaped collision volume.
* - "compound": A compound shape. Any descendant entities with a collision component of type
* box, capsule, cone, cylinder or sphere will be combined into a single, rigid shape.
* - "cone": A cone-shaped collision volume.
* - "cylinder": A cylinder-shaped collision volume.
* - "mesh": A collision volume that uses a model asset as its shape.
* - "sphere": A sphere-shaped collision volume.
*
* Defaults to "box".
*
* @type {string}
*/ set type(arg) {
this._setValue('type', arg);
}
/**
* Gets the type of the collision volume.
*
* @type {string}
*/ get type() {
return this.data.type;
}
/**
* Sets the half-extents of the box-shaped collision volume in the x, y and z axes. Defaults to
* `[0.5, 0.5, 0.5]`.
*
* @type {Vec3}
*/ set halfExtents(arg) {
this._setValue('halfExtents', arg);
}
/**
* Gets the half-extents of the box-shaped collision volume in the x, y and z axes.
*
* @type {Vec3}
*/ get halfExtents() {
return this.data.halfExtents;
}
/**
* Sets the positional offset of the collision shape from the Entity position along the local
* axes. Defaults to `[0, 0, 0]`.
*
* @type {Vec3}
*/ set linearOffset(arg) {
this._setValue('linearOffset', arg);
}
/**
* Gets the positional offset of the collision shape from the Entity position along the local
* axes.
*
* @type {Vec3}
*/ get linearOffset() {
return this.data.linearOffset;
}
/**
* Sets the rotational offset of the collision shape from the Entity rotation in local space.
* Defaults to identity.
*
* @type {Quat}
*/ set angularOffset(arg) {
this._setValue('angularOffset', arg);
}
/**
* Gets the rotational offset of the collision shape from the Entity rotation in local space.
*
* @type {Quat}
*/ get angularOffset() {
return this.data.angularOffset;
}
/**
* Sets the radius of the sphere, capsule, cylinder or cone-shaped collision volumes.
* Defaults to 0.5.
*
* @type {number}
*/ set radius(arg) {
this._setValue('radius', arg);
}
/**
* Gets the radius of the sphere, capsule, cylinder or cone-shaped collision volumes.
*
* @type {number}
*/ get radius() {
return this.data.radius;
}
/**
* Sets the local space axis with which the capsule, cylinder or cone-shaped collision volume's
* length is aligned. 0 for X, 1 for Y and 2 for Z. Defaults to 1 (Y-axis).
*
* @type {number}
*/ set axis(arg) {
this._setValue('axis', arg);
}
/**
* Gets the local space axis with which the capsule, cylinder or cone-shaped collision volume's
* length is aligned.
*
* @type {number}
*/ get axis() {
return this.data.axis;
}
/**
* Sets the total height of the capsule, cylinder or cone-shaped collision volume from tip to
* tip. Defaults to 2.
*
* @type {number}
*/ set height(arg) {
this._setValue('height', arg);
}
/**
* Gets the total height of the capsule, cylinder or cone-shaped collision volume from tip to
* tip.
*
* @type {number}
*/ get height() {
return this.data.height;
}
/**
* Sets the asset or asset id for the model of the mesh collision volume. Defaults to null.
*
* @type {Asset|number|null}
*/ set asset(arg) {
this._setValue('asset', arg);
}
/**
* Gets the asset or asset id for the model of the mesh collision volume.
*
* @type {Asset|number|null}
*/ get asset() {
return this.data.asset;
}
/**
* Sets the render asset or asset id of the mesh collision volume. Defaults to null.
* If not set then the asset property will be checked instead.
*
* @type {Asset|number|null}
*/ set renderAsset(arg) {
this._setValue('renderAsset', arg);
}
/**
* Gets the render asset id of the mesh collision volume.
*
* @type {Asset|number|null}
*/ get renderAsset() {
return this.data.renderAsset;
}
/**
* Sets whether the collision mesh should be treated as a convex hull. When false, the mesh can
* only be used with a static body. When true, the mesh can be used with a static, dynamic or
* kinematic body. Defaults to `false`.
*
* @type {boolean}
*/ set convexHull(arg) {
this._setValue('convexHull', arg);
}
/**
* Gets whether the collision mesh should be treated as a convex hull.
*
* @type {boolean}
*/ get convexHull() {
return this.data.convexHull;
}
set shape(arg) {
this._setValue('shape', arg);
}
get shape() {
return this.data.shape;
}
/**
* Sets the model that is added to the scene graph for the mesh collision volume.
*
* @type {Model | null}
*/ set model(arg) {
this._setValue('model', arg);
}
/**
* Gets the model that is added to the scene graph for the mesh collision volume.
*
* @type {Model | null}
*/ get model() {
return this.data.model;
}
set render(arg) {
this._setValue('render', arg);
}
get render() {
return this.data.render;
}
/**
* Sets whether checking for duplicate vertices should be enabled when creating collision meshes.
*
* @type {boolean}
*/ set checkVertexDuplicates(arg) {
this._setValue('checkVertexDuplicates', arg);
}
/**
* Gets whether checking for duplicate vertices should be enabled when creating collision meshes.
*
* @type {boolean}
*/ get checkVertexDuplicates() {
return this.data.checkVertexDuplicates;
}
/** @ignore */ _setValue(name, value) {
const data = this.data;
const oldValue = data[name];
data[name] = value;
this.fire('set', name, oldValue, value);
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetType(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.system.changeType(this, oldValue, newValue);
}
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetHalfExtents(name, oldValue, newValue) {
const t = this.data.type;
if (this.data.initialized && t === 'box') {
this.system.recreatePhysicalShapes(this);
}
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetOffset(name, oldValue, newValue) {
this._hasOffset = !this.data.linearOffset.equals(Vec3.ZERO) || !this.data.angularOffset.equals(Quat.IDENTITY);
if (this.data.initialized) {
this.system.recreatePhysicalShapes(this);
}
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetRadius(name, oldValue, newValue) {
const t = this.data.type;
if (this.data.initialized && (t === 'sphere' || t === 'capsule' || t === 'cylinder' || t === 'cone')) {
this.system.recreatePhysicalShapes(this);
}
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetHeight(name, oldValue, newValue) {
const t = this.data.type;
if (this.data.initialized && (t === 'capsule' || t === 'cylinder' || t === 'cone')) {
this.system.recreatePhysicalShapes(this);
}
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetAxis(name, oldValue, newValue) {
const t = this.data.type;
if (this.data.initialized && (t === 'capsule' || t === 'cylinder' || t === 'cone')) {
this.system.recreatePhysicalShapes(this);
}
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetAsset(name, oldValue, newValue) {
const assets = this.system.app.assets;
if (oldValue) {
// Remove old listeners
const asset = assets.get(oldValue);
if (asset) {
asset.off('remove', this.onAssetRemoved, this);
}
}
if (newValue) {
if (newValue instanceof Asset) {
this.data.asset = newValue.id;
}
const asset = assets.get(this.data.asset);
if (asset) {
// make sure we don't subscribe twice
asset.off('remove', this.onAssetRemoved, this);
asset.on('remove', this.onAssetRemoved, this);
}
}
if (this.data.initialized && this.data.type === 'mesh') {
if (!newValue) {
// if asset is null set model to null
// so that it's going to be removed from the simulation
this.data.model = null;
}
this.system.recreatePhysicalShapes(this);
}
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetRenderAsset(name, oldValue, newValue) {
const assets = this.system.app.assets;
if (oldValue) {
// Remove old listeners
const asset = assets.get(oldValue);
if (asset) {
asset.off('remove', this.onRenderAssetRemoved, this);
}
}
if (newValue) {
if (newValue instanceof Asset) {
this.data.renderAsset = newValue.id;
}
const asset = assets.get(this.data.renderAsset);
if (asset) {
// make sure we don't subscribe twice
asset.off('remove', this.onRenderAssetRemoved, this);
asset.on('remove', this.onRenderAssetRemoved, this);
}
}
if (this.data.initialized && this.data.type === 'mesh') {
if (!newValue) {
// if render asset is null set render to null
// so that it's going to be removed from the simulation
this.data.render = null;
}
this.system.recreatePhysicalShapes(this);
}
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetModel(name, oldValue, newValue) {
if (this.data.initialized && this.data.type === 'mesh') {
// recreate physical shapes skipping loading the model
// from the 'asset' as the model passed in newValue might
// have been created procedurally
this.system.implementations.mesh.doRecreatePhysicalShape(this);
}
}
/**
* @param {string} name - Property name.
* @param {*} oldValue - Previous value of the property.
* @param {*} newValue - New value of the property.
* @private
*/ onSetRender(name, oldValue, newValue) {
this.onSetModel(name, oldValue, newValue);
}
/**
* @param {Asset} asset - Asset that was removed.
* @private
*/ onAssetRemoved(asset) {
asset.off('remove', this.onAssetRemoved, this);
if (this.data.asset === asset.id) {
this.asset = null;
}
}
/**
* @param {Asset} asset - Asset that was removed.
* @private
*/ onRenderAssetRemoved(asset) {
asset.off('remove', this.onRenderAssetRemoved, this);
if (this.data.renderAsset === asset.id) {
this.renderAsset = null;
}
}
/**
* @param {*} shape - Ammo shape.
* @returns {number|null} The shape's index in the child array of the compound shape.
* @private
*/ getCompoundChildShapeIndex(shape) {
const compound = this.data.shape;
const shapes = compound.getNumChildShapes();
for(let i = 0; i < shapes; i++){
const childShape = compound.getChildShape(i);
if (Ammo.getPointer(childShape) === Ammo.getPointer(shape)) {
return i;
}
}
return null;
}
/**
* @param {GraphNode} parent - The parent node.
* @private
*/ _onInsert(parent) {
// TODO
// if is child of compound shape
// and there is no change of compoundParent, then update child transform
// once updateChildTransform is exposed in ammo.js
if (typeof Ammo === 'undefined') {
return;
}
if (this._compoundParent) {
this.system.recreatePhysicalShapes(this);
} else if (!this.entity.rigidbody) {
let ancestor = this.entity.parent;
while(ancestor){
if (ancestor.collision && ancestor.collision.type === 'compound') {
if (ancestor.collision.shape.getNumChildShapes() === 0) {
this.system.recreatePhysicalShapes(ancestor.collision);
} else {
this.system.recreatePhysicalShapes(this);
}
break;
}
ancestor = ancestor.parent;
}
}
}
/** @private */ _updateCompound() {
const entity = this.entity;
if (entity._dirtyWorld) {
let dirty = entity._dirtyLocal;
let parent = entity;
while(parent && !dirty){
if (parent.collision && parent.collision === this._compoundParent) {
break;
}
if (parent._dirtyLocal) {
dirty = true;
}
parent = parent.parent;
}
if (dirty) {
entity.forEach(this.system.implementations.compound._updateEachDescendantTransform, entity);
const bodyComponent = this._compoundParent.entity.rigidbody;
if (bodyComponent) {
bodyComponent.activate();
}
}
}
}
/**
* Returns the world position for the collision shape, taking into account of any offsets.
*
* @returns {Vec3} The world position for the collision shape.
*/ getShapePosition() {
const pos = this.entity.getPosition();
if (this._hasOffset) {
const rot = this.entity.getRotation();
const lo = this.data.linearOffset;
_quat.copy(rot).transformVector(lo, _vec3);
return _vec3.add(pos);
}
return pos;
}
/**
* Returns the world rotation for the collision shape, taking into account of any offsets.
*
* @returns {Quat} The world rotation for the collision.
*/ getShapeRotation() {
const rot = this.entity.getRotation();
if (this._hasOffset) {
return _quat.copy(rot).mul(this.data.angularOffset);
}
return rot;
}
onEnable() {
if (this.data.type === 'mesh' && (this.data.asset || this.data.renderAsset) && this.data.initialized) {
const asset = this.system.app.assets.get(this.data.asset || this.data.renderAsset);
// recreate the collision shape if the model asset is not loaded
// or the shape does not exist
if (asset && (!asset.resource || !this.data.shape)) {
this.system.recreatePhysicalShapes(this);
return;
}
}
if (this.entity.rigidbody) {
if (this.entity.rigidbody.enabled) {
this.entity.rigidbody.enableSimulation();
}
} else if (this._compoundParent && this !== this._compoundParent) {
if (this._compoundParent.shape.getNumChildShapes() === 0) {
this.system.recreatePhysicalShapes(this._compoundParent);
} else {
const transform = this.system._getNodeTransform(this.entity, this._compoundParent.entity);
this._compoundParent.shape.addChildShape(transform, this.data.shape);
Ammo.destroy(transform);
if (this._compoundParent.entity.rigidbody) {
this._compoundParent.entity.rigidbody.activate();
}
}
} else if (this.entity.trigger) {
this.entity.trigger.enable();
}
}
onDisable() {
if (this.entity.rigidbody) {
this.entity.rigidbody.disableSimulation();
} else if (this._compoundParent && this !== this._compoundParent) {
if (!this._compoundParent.entity._destroying) {
this.system._removeCompoundChild(this._compoundParent, this.data.shape);
if (this._compoundParent.entity.rigidbody) {
this._compoundParent.entity.rigidbody.activate();
}
}
} else if (this.entity.trigger) {
this.entity.trigger.disable();
}
}
/** @private */ onBeforeRemove() {
if (this.asset) {
this.asset = null;
}
if (this.renderAsset) {
this.renderAsset = null;
}
this.entity.off('insert', this._onInsert, this);
this.off();
}
}
export { CollisionComponent };