angular-three-rapier
Version:
Physics Rapier for Angular Three
1,115 lines (1,102 loc) • 138 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, input, computed, inject, effect, Directive, viewChild, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, Component, contentChild, TemplateRef, signal, untracked, DestroyRef, model, output, ElementRef, viewChildren } from '@angular/core';
import { Vector3, Quaternion, EventQueue, ColliderDesc, ActiveEvents, RigidBodyDesc } from '@dimforge/rapier3d-compat';
import { extend, beforeRender, injectStore, is, pick, vector3, applyProps, getInstanceState, getEmitter, hasListener, resolveRef } from 'angular-three';
import { mergeInputs } from 'ngxtension/inject-inputs';
import * as THREE from 'three';
import { LineBasicMaterial, LineSegments, Group, Object3D } from 'three';
import { NgTemplateOutlet } from '@angular/common';
import { mergeVertices } from 'three-stdlib';
import { assertInjector } from 'ngxtension/assert-injector';
/**
* Calculates an InteractionGroup bitmask for use in the `collisionGroups` or `solverGroups`
* properties of RigidBody or Collider components. The first argument represents a list of
* groups the entity is in (expressed as numbers from 0 to 15). The second argument is a list
* of groups that will be filtered against. When it is omitted, all groups are filtered against.
*
* @example
* A RigidBody that is member of group 0 and will collide with everything from groups 0 and 1:
*
* ```tsx
* <RigidBody collisionGroups={interactionGroups([0], [0, 1])} />
* ```
*
* A RigidBody that is member of groups 0 and 1 and will collide with everything else:
*
* ```tsx
* <RigidBody collisionGroups={interactionGroups([0, 1])} />
* ```
*
* A RigidBody that is member of groups 0 and 1 and will not collide with anything:
*
* ```tsx
* <RigidBody collisionGroups={interactionGroups([0, 1], [])} />
* ```
*
* Please note that Rapier needs interaction filters to evaluate to true between _both_ colliding
* entities for collision events to trigger.
*
* @param memberships Groups the collider is a member of. (Values can range from 0 to 15.)
* @param filters Groups the interaction group should filter against. (Values can range from 0 to 15.)
* @returns An InteractionGroup bitmask.
*/
function interactionGroups(memberships, filters) {
return (bitmask(memberships) << 16) + (filters !== undefined ? bitmask(filters) : 0b1111_1111_1111_1111);
}
function bitmask(groups) {
return [groups].flat().reduce((acc, layer) => acc | (1 << layer), 0);
}
/**
* Injection token for handlers that set collision groups on colliders.
* Used internally by rigid bodies and attractors to apply interaction groups.
*/
const COLLISION_GROUPS_HANDLER = new InjectionToken('COLLISION_GROUPS_HANDLER');
/**
* Directive for setting collision/solver groups on a rigid body or collider.
* This allows filtering which objects can collide with each other.
*
* @example
* ```html
* <!-- Object in group 0, collides with groups 0 and 1 -->
* <ngt-object3D rigidBody [interactionGroups]="[[0], [0, 1]]">
* <ngt-mesh>
* <ngt-box-geometry />
* </ngt-mesh>
* </ngt-object3D>
*
* <!-- Object in groups 0 and 1, collides with everything -->
* <ngt-object3D rigidBody [interactionGroups]="[[0, 1]]">
* <ngt-mesh>
* <ngt-sphere-geometry />
* </ngt-mesh>
* </ngt-object3D>
* ```
*/
class NgtrInteractionGroups {
inputs = input.required({ ...(ngDevMode ? { debugName: "inputs" } : {}), alias: 'interactionGroups' });
interactionGroups = computed(() => {
const [memberships, filters] = this.inputs();
return interactionGroups(memberships, filters);
}, ...(ngDevMode ? [{ debugName: "interactionGroups" }] : []));
constructor() {
const collisionGroupsHandlerFn = inject(COLLISION_GROUPS_HANDLER, { host: true, optional: true });
effect(() => {
if (!collisionGroupsHandlerFn)
return;
const handler = collisionGroupsHandlerFn();
if (!handler)
return;
handler(this.interactionGroups());
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrInteractionGroups, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.6", type: NgtrInteractionGroups, isStandalone: true, selector: "ngt-object3D[interactionGroups]", inputs: { inputs: { classPropertyName: "inputs", publicName: "interactionGroups", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrInteractionGroups, decorators: [{
type: Directive,
args: [{ selector: 'ngt-object3D[interactionGroups]' }]
}], ctorParameters: () => [], propDecorators: { inputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactionGroups", required: true }] }] } });
/**
* Debug visualization component for the physics world.
* Renders wireframe outlines of all colliders in the physics simulation.
*
* This component is automatically rendered when the `debug` option is set to `true`
* on the `NgtrPhysics` component. It updates every frame to show current collider positions.
*
* @example
* ```html
* <ngtr-physics [options]="{ debug: true }">
* <ng-template>
* <!-- Your physics scene -->
* </ng-template>
* </ngtr-physics>
* ```
*/
class NgtrDebug {
physics = inject(NgtrPhysics);
lineSegmentsRef = viewChild.required('lineSegments');
constructor() {
extend({ Group, LineSegments, LineBasicMaterial });
beforeRender(() => {
const worldSingleton = this.physics.worldSingleton();
if (!worldSingleton)
return;
const lineSegments = this.lineSegmentsRef().nativeElement;
if (!lineSegments)
return;
const buffers = worldSingleton.proxy.debugRender();
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(buffers.vertices, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(buffers.colors, 4));
lineSegments.geometry.dispose();
lineSegments.geometry = geometry;
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrDebug, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.6", type: NgtrDebug, isStandalone: true, selector: "ngtr-debug", viewQueries: [{ propertyName: "lineSegmentsRef", first: true, predicate: ["lineSegments"], descendants: true, isSignal: true }], ngImport: i0, template: `
<ngt-group>
<ngt-line-segments #lineSegments [frustumCulled]="false">
<ngt-line-basic-material color="white" vertexColors />
<ngt-buffer-geometry />
</ngt-line-segments>
</ngt-group>
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrDebug, decorators: [{
type: Component,
args: [{
selector: 'ngtr-debug',
template: `
<ngt-group>
<ngt-line-segments #lineSegments [frustumCulled]="false">
<ngt-line-basic-material color="white" vertexColors />
<ngt-buffer-geometry />
</ngt-line-segments>
</ngt-group>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
}]
}], ctorParameters: () => [], propDecorators: { lineSegmentsRef: [{ type: i0.ViewChild, args: ['lineSegments', { isSignal: true }] }] } });
/**
* Internal directive that manages the physics simulation loop.
* Handles both "follow" mode (tied to render loop) and "independent" mode (separate loop).
*
* This directive is used internally by NgtrPhysics and should not be used directly.
*/
class NgtrFrameStepper {
ready = input(false, ...(ngDevMode ? [{ debugName: "ready" }] : []));
updatePriority = input(0, ...(ngDevMode ? [{ debugName: "updatePriority" }] : []));
stepFn = input.required(...(ngDevMode ? [{ debugName: "stepFn" }] : []));
type = input.required(...(ngDevMode ? [{ debugName: "type" }] : []));
constructor() {
const store = injectStore();
effect((onCleanup) => {
const ready = this.ready();
if (!ready)
return;
const [type, stepFn] = [this.type(), this.stepFn()];
if (type === 'follow') {
const updatePriority = this.updatePriority();
const cleanup = store.snapshot.internal.subscribe(({ delta }) => {
stepFn(delta);
}, updatePriority, store);
onCleanup(() => cleanup());
return;
}
let lastFrame = 0;
let raf = 0;
const loop = () => {
const now = performance.now();
const delta = now - lastFrame;
raf = requestAnimationFrame(loop);
stepFn(delta);
lastFrame = now;
};
raf = requestAnimationFrame(loop);
onCleanup(() => cancelAnimationFrame(raf));
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrFrameStepper, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.6", type: NgtrFrameStepper, isStandalone: true, selector: "ngtr-frame-stepper", inputs: { ready: { classPropertyName: "ready", publicName: "ready", isSignal: true, isRequired: false, transformFunction: null }, updatePriority: { classPropertyName: "updatePriority", publicName: "updatePriority", isSignal: true, isRequired: false, transformFunction: null }, stepFn: { classPropertyName: "stepFn", publicName: "stepFn", isSignal: true, isRequired: true, transformFunction: null }, type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrFrameStepper, decorators: [{
type: Directive,
args: [{ selector: 'ngtr-frame-stepper' }]
}], ctorParameters: () => [], propDecorators: { ready: [{ type: i0.Input, args: [{ isSignal: true, alias: "ready", required: false }] }], updatePriority: [{ type: i0.Input, args: [{ isSignal: true, alias: "updatePriority", required: false }] }], stepFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "stepFn", required: true }] }], type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: true }] }] } });
/**
* Shared reusable Three.js objects for internal calculations.
* These are used to avoid creating new objects on every frame,
* which would cause garbage collection pressure.
*
* @internal
*/
/** Shared quaternion for internal calculations */
const _quaternion = new THREE.Quaternion();
/** Shared euler angles for internal calculations */
const _euler = new THREE.Euler();
/** Shared vector3 for internal calculations */
const _vector3 = new THREE.Vector3();
/** Shared Object3D for internal calculations */
const _object3d = new THREE.Object3D();
/** Shared matrix4 for internal calculations */
const _matrix4 = new THREE.Matrix4();
/** Shared position vector for internal calculations */
const _position = new THREE.Vector3();
/** Shared rotation quaternion for internal calculations */
const _rotation = new THREE.Quaternion();
/** Shared scale vector for internal calculations */
const _scale = new THREE.Vector3();
/**
* Creates a proxy that will create a singleton instance of the given class
* when a property is accessed, and not before. This is useful for lazy initialization
* of expensive objects like physics worlds.
*
* @template SingletonClass - The type of the singleton instance
* @template CreationFn - The type of the factory function
* @param createInstance - A function that returns a new instance of the class
* @returns An object containing the proxy, reset function, and set function
*
* @example
* ```typescript
* const { proxy, reset } = createSingletonProxy(() => new World(gravity));
* // Access the world lazily
* proxy.step();
* // Reset when done
* reset();
* ```
*/
const createSingletonProxy = (
/**
* A function that returns a new instance of the class
*/
createInstance) => {
let instance;
const handler = {
get(target, prop) {
if (!instance) {
instance = createInstance();
}
return Reflect.get(instance, prop);
},
set(target, prop, value) {
if (!instance) {
instance = createInstance();
}
return Reflect.set(instance, prop, value);
},
};
const proxy = new Proxy({}, handler);
const reset = () => {
instance = undefined;
};
const set = (newInstance) => {
instance = newInstance;
};
/**
* Return the proxy and a reset function
*/
return { proxy, reset, set };
};
/**
* Converts a Rapier quaternion to a Three.js quaternion.
*
* @param quaternion - The Rapier quaternion to convert
* @returns A Three.js Quaternion with the same values
*/
function rapierQuaternionToQuaternion({ x, y, z, w }) {
return _quaternion.set(x, y, z, w);
}
/**
* Converts an Angular Three vector representation to a Rapier Vector3.
* Supports arrays, numbers (uniform scale), and objects with x, y, z properties.
*
* @param v - The vector to convert (can be [x, y, z], a number, or {x, y, z})
* @returns A Rapier Vector3 instance
*
* @example
* ```typescript
* vector3ToRapierVector([1, 2, 3]); // RapierVector3(1, 2, 3)
* vector3ToRapierVector(5); // RapierVector3(5, 5, 5)
* vector3ToRapierVector({ x: 1, y: 2, z: 3 }); // RapierVector3(1, 2, 3)
* ```
*/
function vector3ToRapierVector(v) {
if (Array.isArray(v)) {
return new Vector3(v[0], v[1], v[2]);
}
if (typeof v === 'number') {
return new Vector3(v, v, v);
}
return new Vector3(v.x, v.y, v.z);
}
/**
* Converts an Angular Three quaternion representation to a Rapier Quaternion.
* Supports arrays and objects with x, y, z, w properties.
*
* @param v - The quaternion to convert (can be [x, y, z, w] or {x, y, z, w})
* @returns A Rapier Quaternion instance
*
* @example
* ```typescript
* quaternionToRapierQuaternion([0, 0, 0, 1]); // Identity quaternion
* quaternionToRapierQuaternion({ x: 0, y: 0, z: 0, w: 1 }); // Identity quaternion
* ```
*/
function quaternionToRapierQuaternion(v) {
if (Array.isArray(v)) {
return new Quaternion(v[0], v[1], v[2], v[3]);
}
return new Quaternion(v.x, v.y, v.z, v.w);
}
function isChildOfMeshCollider(child) {
let flag = false;
child.traverseAncestors((a) => {
if (a.userData['ngtRapierType'] === 'MeshCollider')
flag = true;
});
return flag;
}
const autoColliderMap = {
cuboid: 'cuboid',
ball: 'ball',
hull: 'convexHull',
trimesh: 'trimesh',
};
function getColliderArgsFromGeometry(geometry, colliders) {
switch (colliders) {
case 'cuboid': {
geometry.computeBoundingBox();
const { boundingBox } = geometry;
const size = boundingBox.getSize(new THREE.Vector3());
return {
args: [size.x / 2, size.y / 2, size.z / 2],
offset: boundingBox.getCenter(new THREE.Vector3()),
};
}
case 'ball': {
geometry.computeBoundingSphere();
const { boundingSphere } = geometry;
const radius = boundingSphere.radius;
return {
args: [radius],
offset: boundingSphere.center,
};
}
case 'trimesh': {
const clonedGeometry = geometry.index ? geometry.clone() : mergeVertices(geometry);
return {
args: [
clonedGeometry.attributes['position'].array,
clonedGeometry.index?.array,
],
offset: new THREE.Vector3(),
};
}
case 'hull': {
const clonedGeometry = geometry.clone();
return {
args: [clonedGeometry.attributes['position'].array],
offset: new THREE.Vector3(),
};
}
}
return { args: [], offset: new THREE.Vector3() };
}
/**
* Creates collider options by traversing child meshes of an object and generating
* appropriate collider configurations based on their geometries.
*
* @param object - The parent Object3D to traverse for meshes
* @param options - The rigid body options containing collider type and other settings
* @param ignoreMeshColliders - Whether to skip meshes that are children of mesh colliders
* @returns Array of collider configurations with shape, args, position, rotation, and scale
*
* @example
* ```typescript
* const colliderOptions = createColliderOptions(rigidBodyObject, { colliders: 'cuboid' });
* // Returns array of collider configs for each mesh child
* ```
*/
function createColliderOptions(object, options, ignoreMeshColliders = true) {
const childColliderOptions = [];
object.updateWorldMatrix(true, false);
const invertedParentMatrixWorld = object.matrixWorld.clone().invert();
const colliderFromChild = (child) => {
if (is.three(child, 'isMesh')) {
if (ignoreMeshColliders && isChildOfMeshCollider(child))
return;
const worldScale = child.getWorldScale(_scale);
const shape = autoColliderMap[options.colliders || 'cuboid'];
child.updateWorldMatrix(true, false);
_matrix4
.copy(child.matrixWorld)
.premultiply(invertedParentMatrixWorld)
.decompose(_position, _rotation, _scale);
const rotationEuler = new THREE.Euler().setFromQuaternion(_rotation, 'XYZ');
const { args, offset } = getColliderArgsFromGeometry(child.geometry, options.colliders || 'cuboid');
const { mass, linearDamping, angularDamping, canSleep, ccd, gravityScale, softCcdPrediction, ...rest } = options;
childColliderOptions.push({
colliderOptions: rest,
args,
shape,
rotation: [rotationEuler.x, rotationEuler.y, rotationEuler.z],
position: [
_position.x + offset.x * worldScale.x,
_position.y + offset.y * worldScale.y,
_position.z + offset.z * worldScale.z,
],
scale: [worldScale.x, worldScale.y, worldScale.z],
});
}
};
if (options.includeInvisible) {
object.traverse(colliderFromChild);
}
else {
object.traverseVisible(colliderFromChild);
}
return childColliderOptions;
}
const defaultOptions$1 = {
gravity: [0, -9.81, 0],
allowedLinearError: 0.001,
numSolverIterations: 4,
numInternalPgsIterations: 1,
predictionDistance: 0.002,
minIslandSize: 128,
maxCcdSubsteps: 1,
contactNaturalFrequency: 30,
lengthUnit: 1,
colliders: 'cuboid',
updateLoop: 'follow',
interpolate: true,
paused: false,
timeStep: 1 / 60,
debug: false,
};
/**
* Directive for providing a fallback template when Rapier fails to load.
*
* @example
* ```html
* <ngtr-physics>
* <ng-template>
* <!-- Physics scene content -->
* </ng-template>
* <ng-template rapierFallback let-error="error">
* <p>Failed to load physics: {{ error }}</p>
* </ng-template>
* </ngtr-physics>
* ```
*/
class NgtrPhysicsFallback {
/** Type guard for template context */
static ngTemplateContextGuard(_, ctx) {
return true;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrPhysicsFallback, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.1.6", type: NgtrPhysicsFallback, isStandalone: true, selector: "ng-template[rapierFallback]", ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrPhysicsFallback, decorators: [{
type: Directive,
args: [{ selector: 'ng-template[rapierFallback]' }]
}] });
/**
* Main physics component that creates and manages a Rapier physics world.
* Wrap your 3D scene content in this component to enable physics simulation.
*
* The component lazily loads the Rapier WASM module and provides the physics
* context to all child components.
*
* @example
* ```html
* <ngtr-physics [options]="{ gravity: [0, -9.81, 0], debug: true }">
* <ng-template>
* <ngt-object3D rigidBody>
* <ngt-mesh>
* <ngt-box-geometry />
* <ngt-mesh-standard-material />
* </ngt-mesh>
* </ngt-object3D>
* </ng-template>
* </ngtr-physics>
* ```
*/
class NgtrPhysics {
/** Physics configuration options */
options = input(defaultOptions$1, { ...(ngDevMode ? { debugName: "options" } : {}), transform: mergeInputs(defaultOptions$1) });
content = contentChild.required(TemplateRef);
fallbackContent = contentChild(NgtrPhysicsFallback, { ...(ngDevMode ? { debugName: "fallbackContent" } : {}), read: TemplateRef });
updatePriority = pick(this.options, 'updatePriority');
updateLoop = pick(this.options, 'updateLoop');
numSolverIterations = pick(this.options, 'numSolverIterations');
numInternalPgsIterations = pick(this.options, 'numInternalPgsIterations');
allowedLinearError = pick(this.options, 'allowedLinearError');
minIslandSize = pick(this.options, 'minIslandSize');
maxCcdSubsteps = pick(this.options, 'maxCcdSubsteps');
predictionDistance = pick(this.options, 'predictionDistance');
contactNaturalFrequency = pick(this.options, 'contactNaturalFrequency');
lengthUnit = pick(this.options, 'lengthUnit');
timeStep = pick(this.options, 'timeStep');
interpolate = pick(this.options, 'interpolate');
/** Whether the physics simulation is paused */
paused = pick(this.options, 'paused');
debug = pick(this.options, 'debug');
/** The default collider type for automatic collider generation */
colliders = pick(this.options, 'colliders');
vGravity = vector3(this.options, 'gravity');
store = injectStore();
rapierConstruct = signal(null, ...(ngDevMode ? [{ debugName: "rapierConstruct" }] : []));
rapierError = signal(null, ...(ngDevMode ? [{ debugName: "rapierError" }] : []));
/** The loaded Rapier module, null if not yet loaded */
rapier = this.rapierConstruct.asReadonly();
ready = computed(() => !!this.rapier(), ...(ngDevMode ? [{ debugName: "ready" }] : []));
/** Singleton proxy to the Rapier physics world */
worldSingleton = computed(() => {
const rapier = this.rapier();
if (!rapier)
return null;
return createSingletonProxy(() => new rapier.World(untracked(this.vGravity)));
}, ...(ngDevMode ? [{ debugName: "worldSingleton" }] : []));
/** Map of rigid body states indexed by handle */
rigidBodyStates = new Map();
/** Map of collider states indexed by handle */
colliderStates = new Map();
/** Map of rigid body event handlers indexed by handle */
rigidBodyEvents = new Map();
/** Map of collider event handlers indexed by handle */
colliderEvents = new Map();
/** Callbacks to run before each physics step */
beforeStepCallbacks = new Set();
/** Callbacks to run after each physics step */
afterStepCallbacks = new Set();
/** Callbacks to filter contact pairs */
filterContactPairCallbacks = new Set();
/** Callbacks to filter intersection pairs */
filterIntersectionPairCallbacks = new Set();
hooks = {
filterContactPair: (...args) => {
for (const callback of this.filterContactPairCallbacks) {
const result = callback(...args);
if (result !== null)
return result;
}
return null;
},
filterIntersectionPair: (...args) => {
for (const callback of this.filterIntersectionPairCallbacks) {
const result = callback(...args);
if (result === false)
return false;
}
return true;
},
};
eventQueue = computed(() => {
const rapier = this.rapier();
if (!rapier)
return null;
return new EventQueue(false);
}, ...(ngDevMode ? [{ debugName: "eventQueue" }] : []));
steppingState = { accumulator: 0, previousState: {} };
constructor() {
import('@dimforge/rapier3d-compat')
.then((rapier) => rapier.init().then(() => rapier))
.then(this.rapierConstruct.set.bind(this.rapierConstruct))
.catch((err) => {
console.error(`[NGT] Failed to load rapier3d-compat`, err);
this.rapierError.set(err?.message ?? err.toString());
});
effect(() => {
this.updateWorldEffect();
});
inject(DestroyRef).onDestroy(() => {
const world = this.worldSingleton();
if (world) {
world.proxy.free();
world.reset();
}
});
}
/**
* Steps the physics simulation forward by the given delta time.
* This is called automatically by the frame stepper, but can be called manually
* if you need custom control over the simulation timing.
*
* @param delta - Time in seconds since the last step
*/
step(delta) {
if (!this.paused()) {
this.internalStep(delta);
}
}
updateWorldEffect() {
const world = this.worldSingleton();
if (!world)
return;
world.proxy.gravity = this.vGravity();
world.proxy.integrationParameters.numSolverIterations = this.numSolverIterations();
world.proxy.integrationParameters.numInternalPgsIterations = this.numInternalPgsIterations();
world.proxy.integrationParameters.normalizedAllowedLinearError = this.allowedLinearError();
world.proxy.integrationParameters.minIslandSize = this.minIslandSize();
world.proxy.integrationParameters.maxCcdSubsteps = this.maxCcdSubsteps();
world.proxy.integrationParameters.normalizedPredictionDistance = this.predictionDistance();
world.proxy.integrationParameters.contact_natural_frequency = this.contactNaturalFrequency();
world.proxy.lengthUnit = this.lengthUnit();
}
internalStep(delta) {
const worldSingleton = this.worldSingleton();
if (!worldSingleton)
return;
const eventQueue = this.eventQueue();
if (!eventQueue)
return;
const world = worldSingleton.proxy;
const [timeStep, interpolate, paused] = [this.timeStep(), this.interpolate(), this.paused()];
/* Check if the timestep is supposed to be variable. We'll do this here
once so we don't have to string-check every frame. */
const timeStepVariable = timeStep === 'vary';
/**
* Fixed timeStep simulation progression.
* @see https://gafferongames.com/post/fix_your_timestep/
*/
const clampedDelta = THREE.MathUtils.clamp(delta, 0, 0.5);
const stepWorld = (innerDelta) => {
// Trigger beforeStep callbacks
this.beforeStepCallbacks.forEach((callback) => {
callback(world);
});
world.timestep = innerDelta;
const hasHooks = this.filterContactPairCallbacks.size > 0 || this.filterIntersectionPairCallbacks.size > 0;
world.step(eventQueue, hasHooks ? this.hooks : undefined);
// Trigger afterStep callbacks
this.afterStepCallbacks.forEach((callback) => {
callback(world);
});
};
if (timeStepVariable) {
stepWorld(clampedDelta);
}
else {
// don't step time forwards if paused
// Increase accumulator
this.steppingState.accumulator += clampedDelta;
while (this.steppingState.accumulator >= timeStep) {
// Set up previous state
// needed for accurate interpolations if the world steps more than once
if (interpolate) {
this.steppingState.previousState = {};
world.forEachRigidBody((body) => {
this.steppingState.previousState[body.handle] = {
position: body.translation(),
rotation: body.rotation(),
};
});
}
stepWorld(timeStep);
this.steppingState.accumulator -= timeStep;
}
}
const interpolationAlpha = timeStepVariable || !interpolate || paused ? 1 : this.steppingState.accumulator / timeStep;
// Update meshes
this.rigidBodyStates.forEach((state, handle) => {
const rigidBody = world.getRigidBody(handle);
const events = this.rigidBodyEvents.get(handle);
if (events?.onSleep || events?.onWake) {
if (rigidBody.isSleeping() && !state.isSleeping)
events?.onSleep?.();
if (!rigidBody.isSleeping() && state.isSleeping)
events?.onWake?.();
state.isSleeping = rigidBody.isSleeping();
}
if (!rigidBody || (rigidBody.isSleeping() && !('isInstancedMesh' in state.object)) || !state.setMatrix) {
return;
}
// New states
let t = rigidBody.translation();
let r = rigidBody.rotation();
let previousState = this.steppingState.previousState[handle];
if (previousState) {
// Get previous simulated world position
_matrix4
.compose(previousState.position, rapierQuaternionToQuaternion(previousState.rotation), state.scale)
.premultiply(state.invertedWorldMatrix)
.decompose(_position, _rotation, _scale);
// Apply previous tick position
if (state.meshType == 'mesh') {
state.object.position.copy(_position);
state.object.quaternion.copy(_rotation);
}
}
// Get new position
_matrix4
.compose(t, rapierQuaternionToQuaternion(r), state.scale)
.premultiply(state.invertedWorldMatrix)
.decompose(_position, _rotation, _scale);
if (state.meshType == 'instancedMesh') {
state.setMatrix(_matrix4);
}
else {
// Interpolate to new position
state.object.position.lerp(_position, interpolationAlpha);
state.object.quaternion.slerp(_rotation, interpolationAlpha);
}
});
eventQueue.drainCollisionEvents((handle1, handle2, started) => {
const source1 = this.getSourceFromColliderHandle(handle1);
const source2 = this.getSourceFromColliderHandle(handle2);
// Collision Events
if (!source1?.collider.object || !source2?.collider.object) {
return;
}
const collisionPayload1 = this.getCollisionPayloadFromSource(source1, source2);
const collisionPayload2 = this.getCollisionPayloadFromSource(source2, source1);
if (started) {
world.contactPair(source1.collider.object, source2.collider.object, (manifold, flipped) => {
/* RigidBody events */
source1.rigidBody.events?.onCollisionEnter?.({ ...collisionPayload1, manifold, flipped });
source2.rigidBody.events?.onCollisionEnter?.({ ...collisionPayload2, manifold, flipped });
/* Collider events */
source1.collider.events?.onCollisionEnter?.({ ...collisionPayload1, manifold, flipped });
source2.collider.events?.onCollisionEnter?.({ ...collisionPayload2, manifold, flipped });
});
}
else {
source1.rigidBody.events?.onCollisionExit?.(collisionPayload1);
source2.rigidBody.events?.onCollisionExit?.(collisionPayload2);
source1.collider.events?.onCollisionExit?.(collisionPayload1);
source2.collider.events?.onCollisionExit?.(collisionPayload2);
}
// Sensor Intersections
if (started) {
if (world.intersectionPair(source1.collider.object, source2.collider.object)) {
source1.rigidBody.events?.onIntersectionEnter?.(collisionPayload1);
source2.rigidBody.events?.onIntersectionEnter?.(collisionPayload2);
source1.collider.events?.onIntersectionEnter?.(collisionPayload1);
source2.collider.events?.onIntersectionEnter?.(collisionPayload2);
}
}
else {
source1.rigidBody.events?.onIntersectionExit?.(collisionPayload1);
source2.rigidBody.events?.onIntersectionExit?.(collisionPayload2);
source1.collider.events?.onIntersectionExit?.(collisionPayload1);
source2.collider.events?.onIntersectionExit?.(collisionPayload2);
}
});
eventQueue.drainContactForceEvents((event) => {
const source1 = this.getSourceFromColliderHandle(event.collider1());
const source2 = this.getSourceFromColliderHandle(event.collider2());
// Collision Events
if (!source1?.collider.object || !source2?.collider.object) {
return;
}
const collisionPayload1 = this.getCollisionPayloadFromSource(source1, source2);
const collisionPayload2 = this.getCollisionPayloadFromSource(source2, source1);
source1.rigidBody.events?.onContactForce?.({
...collisionPayload1,
totalForce: event.totalForce(),
totalForceMagnitude: event.totalForceMagnitude(),
maxForceDirection: event.maxForceDirection(),
maxForceMagnitude: event.maxForceMagnitude(),
});
source2.rigidBody.events?.onContactForce?.({
...collisionPayload2,
totalForce: event.totalForce(),
totalForceMagnitude: event.totalForceMagnitude(),
maxForceDirection: event.maxForceDirection(),
maxForceMagnitude: event.maxForceMagnitude(),
});
source1.collider.events?.onContactForce?.({
...collisionPayload1,
totalForce: event.totalForce(),
totalForceMagnitude: event.totalForceMagnitude(),
maxForceDirection: event.maxForceDirection(),
maxForceMagnitude: event.maxForceMagnitude(),
});
source2.collider.events?.onContactForce?.({
...collisionPayload2,
totalForce: event.totalForce(),
totalForceMagnitude: event.totalForceMagnitude(),
maxForceDirection: event.maxForceDirection(),
maxForceMagnitude: event.maxForceMagnitude(),
});
});
world.forEachActiveRigidBody(() => {
this.store.snapshot.invalidate();
});
}
getSourceFromColliderHandle(handle) {
const world = this.worldSingleton();
if (!world)
return;
const collider = world.proxy.getCollider(handle);
const colEvents = this.colliderEvents.get(handle);
const colliderState = this.colliderStates.get(handle);
const rigidBodyHandle = collider.parent()?.handle;
const rigidBody = rigidBodyHandle !== undefined ? world.proxy.getRigidBody(rigidBodyHandle) : undefined;
const rigidBodyEvents = rigidBody && rigidBodyHandle !== undefined ? this.rigidBodyEvents.get(rigidBodyHandle) : undefined;
const rigidBodyState = rigidBodyHandle !== undefined ? this.rigidBodyStates.get(rigidBodyHandle) : undefined;
return {
collider: { object: collider, events: colEvents, state: colliderState },
rigidBody: { object: rigidBody, events: rigidBodyEvents, state: rigidBodyState },
};
}
getCollisionPayloadFromSource(target, other) {
return {
target: {
rigidBody: target.rigidBody.object,
collider: target.collider.object,
colliderObject: target.collider.state?.object,
rigidBodyObject: target.rigidBody.state?.object,
},
other: {
rigidBody: other.rigidBody.object,
collider: other.collider.object,
colliderObject: other.collider.state?.object,
rigidBodyObject: other.rigidBody.state?.object,
},
};
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrPhysics, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.6", type: NgtrPhysics, isStandalone: true, selector: "ngtr-physics", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null } }, queries: [{ propertyName: "content", first: true, predicate: TemplateRef, descendants: true, isSignal: true }, { propertyName: "fallbackContent", first: true, predicate: NgtrPhysicsFallback, descendants: true, read: TemplateRef, isSignal: true }], ngImport: i0, template: `
@let _rapierError = rapierError();
@let _fallbackContent = fallbackContent();
@if (rapierConstruct()) {
@if (debug()) {
<ngtr-debug />
}
<ngtr-frame-stepper
[ready]="ready()"
[stepFn]="step.bind(this)"
[type]="updateLoop()"
[updatePriority]="updatePriority()"
/>
<ng-container [ngTemplateOutlet]="content()" />
} @else if (_rapierError && _fallbackContent) {
<ng-container [ngTemplateOutlet]="_fallbackContent" [ngTemplateOutletContext]="{ error: _rapierError }" />
}
`, isInline: true, dependencies: [{ kind: "component", type: NgtrDebug, selector: "ngtr-debug" }, { kind: "directive", type: NgtrFrameStepper, selector: "ngtr-frame-stepper", inputs: ["ready", "updatePriority", "stepFn", "type"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: NgtrPhysics, decorators: [{
type: Component,
args: [{
selector: 'ngtr-physics',
template: `
@let _rapierError = rapierError();
@let _fallbackContent = fallbackContent();
@if (rapierConstruct()) {
@if (debug()) {
<ngtr-debug />
}
<ngtr-frame-stepper
[ready]="ready()"
[stepFn]="step.bind(this)"
[type]="updateLoop()"
[updatePriority]="updatePriority()"
/>
<ng-container [ngTemplateOutlet]="content()" />
} @else if (_rapierError && _fallbackContent) {
<ng-container [ngTemplateOutlet]="_fallbackContent" [ngTemplateOutletContext]="{ error: _rapierError }" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgtrDebug, NgtrFrameStepper, NgTemplateOutlet],
}]
}], ctorParameters: () => [], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], content: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TemplateRef), { isSignal: true }] }], fallbackContent: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgtrPhysicsFallback), { ...{ read: TemplateRef }, isSignal: true }] }] } });
const colliderDefaultOptions = {
contactSkin: 0,
};
/**
* Base directive for creating colliders in the physics simulation.
* This directive can be used to create any type of collider by specifying the shape.
*
* For convenience, use the specific collider directives like `NgtrCuboidCollider`,
* `NgtrBallCollider`, etc., which provide type-safe arguments.
*
* @example
* ```html
* <ngt-object3D [collider]="'cuboid'" [args]="[1, 1, 1]" [position]="[0, 5, 0]" />
* ```
*/
class NgtrAnyCollider {
position = input([0, 0, 0], ...(ngDevMode ? [{ debugName: "position" }] : []));
rotation = input(...(ngDevMode ? [undefined, { debugName: "rotation" }] : []));
scale = input([1, 1, 1], ...(ngDevMode ? [{ debugName: "scale" }] : []));
quaternion = input(...(ngDevMode ? [undefined, { debugName: "quaternion" }] : []));
userData = input(...(ngDevMode ? [undefined, { debugName: "userData" }] : []));
name = input(...(ngDevMode ? [undefined, { debugName: "name" }] : []));
options = input(colliderDefaultOptions, { ...(ngDevMode ? { debugName: "options" } : {}), transform: mergeInputs(rigidBodyDefaultOptions) });
object3DParameters = computed(() => {
const [position, rotation, scale, quaternion, userData, name] = [
this.position(),
this.rotation(),
this.scale(),
this.quaternion(),
this.userData(),
this.name(),
];
const parameters = { position, scale, userData, name: name || `${untracked(this.shape)}-${Date.now()}` };
if (quaternion) {
Object.assign(parameters, { quaternion });
}
else if (rotation) {
Object.assign(parameters, { rotation });
}
else {
Object.assign(parameters, { rotation: [0, 0, 0] });
}
return parameters;
}, ...(ngDevMode ? [{ debugName: "object3DParameters" }] : []));
// TODO: change this to input required when Angular allows setting hostDirective input
shape = model(undefined, { ...(ngDevMode ? { debugName: "shape" } : {}), alias: 'collider' });
// NOTE: this will be typed by individual collider
args = model([], ...(ngDevMode ? [{ debugName: "args" }] : []));
collisionEnter = output();
collisionExit = output();
intersectionEnter = output();
intersectionExit = output();
contactForce = output();
sensor = pick(this.options, 'sensor');
collisionGroups = pick(this.options, 'collisionGroups');
solverGroups = pick(this.options, 'solverGroups');
friction = pick(this.options, 'friction');
frictionCombineRule = pick(this.options, 'frictionCombineRule');
restitution = pick(this.options, 'restitution');
restitutionCombineRule = pick(this.options, 'restitutionCombineRule');
activeCollisionTypes = pick(this.options, 'activeCollisionTypes');
contactSkin = pick(this.options, 'contactSkin');
mass = pick(this.options, 'mass');
massProperties = pick(this.options, 'massProperties');
density = pick(this.options, 'density');
rigidBody = inject(NgtrRigidBody, { optional: true });
physics = inject(NgtrPhysics);
objectRef = inject(ElementRef);
scaledArgs = computed(() => {
const [shape, args] = [
this.shape(),
this.args(),
];
const cloned = args.slice();
if (cloned.length === 0)
return [];
// Heightfield uses a vector
if (shape === 'heightfield') {
const s = cloned[3];
s.x *= this.worldScale.x;
s.y *= this.worldScale.y;
s.z *= this.worldScale.z;
return cloned;
}
// Trimesh and convex scale the vertices
if (shape === 'trimesh' || shape === 'convexHull') {
cloned[0] = this.scaleVertices(cloned[0], this.worldScale);
return cloned;
}
// prefill with some extra
const scaleArray = [
this.worldScale.x,
this.worldScale.y,
this.worldScale.z,
this.worldScale.x,
this.worldScale.x,
];
return cloned.map((arg, index) => scaleArray[index] * arg);
}, ...(ngDevMode ? [{ debugName: "scaledArgs" }] : []));
collider = computed(() => {
const worldSingleton = this.physics.worldSingleton();
if (!worldSingleton)
return null;
const args = this.scaledArgs();
if (!args.length)
return null;
const [shape, rigidBody] = [this.shape(), this.rigidBody?.rigidBody()];
// @ts-expect-error - we know the type of the data
const desc = ColliderDesc[shape](...args);
if (!desc)
return null;
return worldSingleton.proxy.createCollider(desc, rigidBody ?? undefined);
}, ...(ngDevMode ? [{ debugName: "collider" }] : []));
constructor() {
extend({ Object3D });
effect(() => {
const object3DParameters = this.object3DParameters();
applyProps(this.objectRef.nativeElement, object3DParameters);
});
effect((onCleanup) => {
const cleanup = this.createColliderStateEffect();
onCleanup(() => cleanup?.());
});
effect((onCleanup) => {
const cleanup = this.createColliderEventsEffect();
onCleanup(() => cleanup?.());
});
effect(() => {
this.updateColliderEffect();
this.updateMassPropertiesEffect();
});
}
get worldScale() {
return this.objectRef.nativeElement.getWorldScale(new THREE.Vector3());
}
setShape(shape) {
this.shape.set(shape);
}
setArgs(args) {
this.args.set(args);
}
createColliderStateEffect() {
const collider = this.collider();
if (!collider)
return;
const worldSingleton = this.physics.worldSingleton();
if (!worldSingleton)
return;
const instanceState = getInstanceState(this.objectRef.nativeElement);
if (!instanceState)
return;
const parent = instanceState.parent();
if (!parent || !is.three(parent, 'isObject3D'))
return;
const state = this.createColliderState(collider, this.objectRef.nativeElement, this.rigidBody?.objectRef.nativeElement);
this.physics.colliderStates.set(collider.handle, state);
return () => {
this.physics.colliderStates.delete(collider.handle);
if (worldSingleton.proxy.getCollider(collider.handle)) {
worldSingleton.proxy.removeCollider(collider, true);
}
};
}
createColliderEventsEffect() {
const collider = this.collider();
if (!collider)
return;
const worldSingleton = this.physics.worldSingleton();
if (!worldSingleton)
return;
const collisionEnter = getEmitter(this.collisionEnter);
const collisionExit = getEmitter(this.collisionExit);
const intersectionEnter = getEmitter(this.intersectionEnter);
const intersectionExit = getEmitter(this.intersectionExit);
const contactForce = getEmitter(this.contactForce);
const hasCollisionEvent = hasListener(this.collisionEnter, this.collisionExit, this.intersectionEnter, this.intersectionExit, this.rigidBody?.collisionEnter, this.rigidBody?.collisionExit, this.rigidBody?.intersectionEnter, this.rigidBody?.intersectionExit);
const hasContactForceEvent = hasListener(this.contactForce, this.rigidBody?.contactForce);
if (hasCollisionEvent && hasContactForceEvent) {
collider.setActiveEvents(ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS);
}
else if (hasCollisionEvent) {
collider.setActiveEvents(ActiveEvents.COLLISION_EVENTS);
}
else if (hasContactForceEvent) {
collider.setActiveEvents(ActiveEvents.CONTACT_FORCE_EVENTS);
}
this.physics.colliderEvents.set(collider.handle, {
onCollisionEnter: collisionEnter,
onCollisionExit: collisionExit,
onIntersectionEnter: intersectionEnter,
onIntersectionExit: intersectionExit,
onContactForce: contactForce,
});
return () => {
this.physics.colliderEvents.delete(collider.handle);
};
}
updateColliderEffect() {
const collider = this.collider();
if (!collider)
return;
const worldSingleton = this.physics.worldSingleton();
if (!worldSingleton)
return;
const state = this.physics.colliderStates.get(collider.handle);
if (!state)
return;
// Update collider position based on the object's position
const parentWorldScale = state.object.parent.getWorldScale(_vector3);
const parentInvertedWorldMatrix = state.worldParent?.matrixWorld.clone().invert();
state.object.updateWorldMatrix(true, false);
_matrix4.copy(state.object.matrixWorld);
if (parentInvertedWorldMatrix) {
_matrix4.premultiply(parentInvertedWorldMatrix);
}
_matrix4.d