UNPKG

angular-three-rapier

Version:
1,115 lines (1,102 loc) 138 kB
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