diff --git a/src/framework/app-base.js b/src/framework/app-base.js index ae50e71d74f..578f5bce60b 100644 --- a/src/framework/app-base.js +++ b/src/framework/app-base.js @@ -189,6 +189,9 @@ class AppBase extends EventHandler { /** @ignore */ _time = 0; + /** @ignore */ + _fixedTimeDebt = 0; + /** * Set this to false if you want to run without using bundles. We set it to true only if * TextDecoder is available because we currently rely on it for untarring. @@ -215,6 +218,24 @@ class AppBase extends EventHandler { */ timeScale = 1; + /** + * A frame rate independent interval that dictates when fixedUpdate, postFixedUpdate events are performed. Defaults to 0.02. + * + * @type {number} + * @example + * this.app.fixedTimeStep = 0.02; // (1 / 50) fixedUpdate calls 50 times per second + */ + fixedTimeStep = 1 / 50; + + /** + * Use event postFixedUpdate for physics simulation. Defaults to false. + * + * @type {boolean} + * @example + * this.app.usePostFixedUpdateForPhysicsSim = true; + */ + usePostFixedUpdateForPhysicsSim = false; + /** * Clamps per-frame delta time to an upper bound. Useful since returning from a tab * deactivation can generate huge values for dt, which can adversely affect game state. @@ -481,9 +502,11 @@ class AppBase extends EventHandler { init(appOptions) { const { assetPrefix, batchManager, componentSystems, elementInput, gamepads, graphicsDevice, keyboard, - lightmapper, mouse, resourceHandlers, scriptsOrder, scriptPrefix, soundManager, touch, xr + lightmapper, mouse, resourceHandlers, scriptsOrder, scriptPrefix, soundManager, touch, xr, usePostFixedUpdateForPhysicsSim } = appOptions; + this.usePostFixedUpdateForPhysicsSim = !!usePostFixedUpdateForPhysicsSim; + Debug.assert(graphicsDevice, 'The application cannot be created without a valid GraphicsDevice'); this.graphicsDevice = graphicsDevice; @@ -1011,6 +1034,7 @@ class AppBase extends EventHandler { * @param {number} dt - The time delta in seconds since the last frame. */ update(dt) { + this.frame++; this.graphicsDevice.updateClientRect(); @@ -1019,6 +1043,27 @@ class AppBase extends EventHandler { this.stats.frame.updateStart = now(); // #endif + this._fixedTimeDebt += dt; + + let fixedStepsCounter = 0; + + while (this._fixedTimeDebt >= this.fixedTimeStep) { + + // we will save the value, because at the time of processing, it can be changed from the outside + const fixedTimeStep = this.fixedTimeStep; + + this.systems.fire(this._inTools ? 'toolsFixedUpdate' : 'fixedUpdate', fixedTimeStep, fixedStepsCounter); + this.systems.fire(this._inTools ? 'toolsPostFixedUpdate' : 'postFixedUpdate', fixedTimeStep, fixedStepsCounter); + this.fire('fixedUpdate', fixedTimeStep); + this._fixedTimeDebt -= fixedTimeStep; + + fixedStepsCounter++; + } + + // #if _PROFILER + this.stats.frame.fixedUpdateCount = fixedStepsCounter; + // #endif + this.systems.fire(this._inTools ? 'toolsUpdate' : 'update', dt); this.systems.fire('animationUpdate', dt); this.systems.fire('postUpdate', dt); diff --git a/src/framework/app-options.js b/src/framework/app-options.js index bf849b30df1..d61b9973e0a 100644 --- a/src/framework/app-options.js +++ b/src/framework/app-options.js @@ -122,6 +122,13 @@ class AppOptions { * @type {typeof ResourceHandler[]} */ resourceHandlers = []; + + /** + * Use event postFixedUpdate for physics simulation + * + * @type {boolean} + */ + usePostFixedUpdateForPhysicsSim = false; } export { AppOptions }; diff --git a/src/framework/components/rigid-body/component.js b/src/framework/components/rigid-body/component.js index 41c8d44a8a3..451a5031ab4 100644 --- a/src/framework/components/rigid-body/component.js +++ b/src/framework/components/rigid-body/component.js @@ -13,12 +13,15 @@ import { * @import { Entity } from '../../entity.js' */ +const ANGULAR_MOTION_THRESHOLD = 0.25 * Math.PI; + // Shared math variable to avoid excessive allocation let _ammoTransform; let _ammoVec1, _ammoVec2, _ammoQuat; const _quat1 = new Quat(); const _quat2 = new Quat(); -const _vec3 = new Vec3(); +const _vec31 = new Vec3(); +const _vec32 = new Vec3(); /** * The RigidBodyComponent, when combined with a {@link CollisionComponent}, allows your entities @@ -1066,6 +1069,39 @@ class RigidBodyComponent extends Component { } } + _setEntityPosAndRotFromTransform(transform) { + + const p = transform.getOrigin(); + const q = transform.getRotation(); + + const entity = this.entity; + const component = entity.collision; + + if (component && component._hasOffset) { + const lo = component.data.linearOffset; + const ao = component.data.angularOffset; + + // Un-rotate the angular offset and then use the new rotation to + // un-translate the linear offset in local space + // Order of operations matter here + const invertedAo = _quat2.copy(ao).invert(); + const entityRot = _quat1.set(q.x(), q.y(), q.z(), q.w()).mul(invertedAo); + + entityRot.transformVector(lo, _vec31); + + entity.setPositionAndRotation( + _vec31.set(p.x() - _vec31.x, p.y() - _vec31.y, p.z() - _vec31.z), + entityRot + ); + + } else { + entity.setPositionAndRotation( + _vec31.set(p.x(), p.y(), p.z()), + _quat1.set(q.x(), q.y(), q.z(), q.w()) + ); + } + } + /** * Sets an entity's transform to match that of the world transformation matrix of a dynamic * rigid body's motion state. @@ -1073,23 +1109,103 @@ class RigidBodyComponent extends Component { * @private */ _updateDynamic() { + const body = this._body; // If a dynamic body is frozen, we can assume its motion state transform is // the same is the entity world transform if (body.isActive()) { + // Update the motion state. Note that the test for the presence of the motion // state is technically redundant since the engine creates one for all bodies. const motionState = body.getMotionState(); if (motionState) { - const entity = this.entity; + motionState.getWorldTransform(_ammoTransform); + this._setEntityPosAndRotFromTransform(_ammoTransform); + } + } + } + /** + * Performs interpolation of the body's rotation based on current rotation and angular velocity. + * + * @param {Quat} rotation - The current rotation of the body represented as a quaternion. Defines the body's current orientation. + * @param {Vec3} angularVelocity - The angular velocity vector of the body, indicating how fast and in which direction the body is rotating around its axes. + * @param {number} timeStep - The interpolation time step, representing the duration over which to interpolate. Typically a small value such as the time between frames. + * @param {Quat} out - The output quaternion where the interpolated rotation will be stored. Used to return the result without creating a new object. + * @private + */ + _interpolationRotationByAngularVelocity(rotation, angularVelocity, timeStep, out) { + let fAngle = angularVelocity.length(); + + // limit the angular motion + if (fAngle * timeStep > ANGULAR_MOTION_THRESHOLD) { + fAngle = ANGULAR_MOTION_THRESHOLD / timeStep; + } + + const factor = fAngle < 0.001 ? + 0.5 * timeStep - (timeStep * timeStep * timeStep) * 0.020833333333 * fAngle * fAngle : // use Taylor's expansions of sync function + Math.sin(0.5 * fAngle * timeStep) / fAngle; // sync(fAngle) = sin(c*fAngle)/t + + // q1 = q(angularVelocity, Math.cos(fAngle * timeStep * 0.5)) + // out = q1 * q2 + + const q1x = angularVelocity.x * factor; + const q1y = angularVelocity.y * factor; + const q1z = angularVelocity.z * factor; + const q1w = Math.cos(fAngle * timeStep * 0.5); + + const q2x = rotation.x; + const q2y = rotation.y; + const q2z = rotation.z; + const q2w = rotation.w; + const cx = q1y * q2z - q1z * q2y; + const cy = q1z * q2x - q1x * q2z; + const cz = q1x * q2y - q1y * q2x; + + const dot = q1x * q2x + q1y * q2y + q1z * q2z; + + out.x = q1x * q2w + q2x * q1w + cx; + out.y = q1y * q2w + q2y * q1w + cy; + out.z = q1z * q2w + q2z * q1w + cz; + out.w = q1w * q2w - dot; + } + + _applyInterpolation(extrapolationTime) { + + if (!this._body || this._type !== BODYTYPE_DYNAMIC) { + return; + } + + const body = this._body; + + // If a dynamic body is frozen, we can assume its motion state transform is + // the same is the entity world transform + if (body.isActive()) { + + const motionState = body.getMotionState(); + if (motionState) { motionState.getWorldTransform(_ammoTransform); - const p = _ammoTransform.getOrigin(); - const q = _ammoTransform.getRotation(); + const currentPosition = _ammoTransform.getOrigin(); + const currentRotation = _ammoTransform.getRotation(); + const linearVelocity = body.getLinearVelocity(); + const angularVelocity = body.getAngularVelocity(); + + const interpolationPos = _vec31.set( + currentPosition.x() + linearVelocity.x() * extrapolationTime, + currentPosition.y() + linearVelocity.y() * extrapolationTime, + currentPosition.z() + linearVelocity.z() * extrapolationTime + ); + const angularVelocityO = _vec32.set(angularVelocity.x(), angularVelocity.y(), angularVelocity.z()); + const interpolationRot = _quat1.set(currentRotation.x(), currentRotation.y(), currentRotation.z(), currentRotation.w()); + + this._interpolationRotationByAngularVelocity(interpolationRot, angularVelocityO, extrapolationTime, interpolationRot); + + const entity = this.entity; const component = entity.collision; + if (component && component._hasOffset) { const lo = component.data.linearOffset; const ao = component.data.angularOffset; @@ -1098,16 +1214,16 @@ class RigidBodyComponent extends Component { // un-translate the linear offset in local space // Order of operations matter here const invertedAo = _quat2.copy(ao).invert(); - const entityRot = _quat1.set(q.x(), q.y(), q.z(), q.w()).mul(invertedAo); - - entityRot.transformVector(lo, _vec3); - entity.setPosition(p.x() - _vec3.x, p.y() - _vec3.y, p.z() - _vec3.z); - entity.setRotation(entityRot); - } else { - entity.setPosition(p.x(), p.y(), p.z()); - entity.setRotation(q.x(), q.y(), q.z(), q.w()); + interpolationRot.mul(invertedAo); + interpolationRot.transformVector(lo, _vec32); + interpolationPos.sub(_vec32); } + + entity.setPositionAndRotation( + interpolationPos, + interpolationRot + ); } } } diff --git a/src/framework/components/rigid-body/system.js b/src/framework/components/rigid-body/system.js index 36192f1fddd..184d067dfb0 100644 --- a/src/framework/components/rigid-body/system.js +++ b/src/framework/components/rigid-body/system.js @@ -415,6 +415,30 @@ class RigidBodyComponentSystem extends ComponentSystem { */ _compounds = []; + /** + * @type {number} + * @private + */ + _dynamicTime = 0; + + /** + * @type {number} + * @private + */ + _fixedTime = 0; + + /** + * @type {number} + * @private + */ + _lastFixedTimeStep = 0; + + /** + * @type {boolean} + * @private + */ + _usePostFixedUpdate = false; + /** * Create a new RigidBodyComponentSystem. * @@ -473,9 +497,11 @@ class RigidBodyComponentSystem extends ComponentSystem { this.singleContactResultPool = new ObjectPool(SingleContactResult, 1); this.app.systems.on('update', this.onUpdate, this); + this.app.systems.on('postFixedUpdate', this.onPostFixedUpdate, this); } else { // Unbind the update function if we haven't loaded Ammo by now this.app.systems.off('update', this.onUpdate, this); + this.app.systems.off('postFixedUpdate', this.onPostFixedUpdate, this); } } @@ -1050,12 +1076,13 @@ class RigidBodyComponentSystem extends ComponentSystem { this.singleContactResultPool.freeAll(); } - onUpdate(dt) { - let i, len; + /** + * A list of tasks that the system needs to perform after the physics step. + * @param {number} dt - The amount of simulation time processed in the last simulation tick. + */ + _beforeStepSimulation(dt) { - // #if _PROFILER - this._stats.physicsStart = now(); - // #endif + let i, len; // downcast gravity to float32 so we can accurately compare with existing // gravity set in ammo. @@ -1087,9 +1114,15 @@ class RigidBodyComponentSystem extends ComponentSystem { for (i = 0, len = kinematic.length; i < len; i++) { kinematic[i]._updateKinematic(); } + } - // Step the physics simulation - this.dynamicsWorld.stepSimulation(dt, this.maxSubSteps, this.fixedTimeStep); + /** + * A list of tasks that the system needs to perform after the physics step. + * @param {number} dt - The amount of simulation time processed in the last simulation tick. + */ + _afterStepSimulation(dt) { + + let i, len; // Update the transforms of all entities referencing a dynamic body const dynamic = this._dynamic; @@ -1100,15 +1133,90 @@ class RigidBodyComponentSystem extends ComponentSystem { if (!this.dynamicsWorld.setInternalTickCallback) { this._checkForCollisions(Ammo.getPointer(this.dynamicsWorld), dt); } + } - // #if _PROFILER - this._stats.physicsTime = now() - this._stats.physicsStart; - // #endif + /** + * Resets the time counters and switch usePostFixedUpdate flag if a change in the physical mode is detected. + */ + _resetTimeCountersAndFlagOnChange() { + + if (this._usePostFixedUpdate !== this.app.usePostFixedUpdateForPhysicsSim) { + this._usePostFixedUpdate = this.app.usePostFixedUpdateForPhysicsSim; + this._fixedTime = 0; + this._dynamicTime = 0; + this._lastFixedTimeStep = 0; + this._fixedTimeDebt = 0; + } + } + + onPostFixedUpdate(dt) { + + this._resetTimeCountersAndFlagOnChange(); + + if (this._usePostFixedUpdate) { + + // #if _PROFILER + this._stats.physicsStart = now(); + // #endif + + this._fixedTime += dt; + this._lastFixedTimeStep = dt; + + this._beforeStepSimulation(dt); + + // Performs one physics step without applying interpolation + this.dynamicsWorld.stepSimulation(dt, 0); + + this._afterStepSimulation(dt); + + // ??? + // #if _PROFILER + this._stats.physicsTime = now() - this._stats.physicsStart; + // #endif + } + } + + onUpdate(dt) { + + this._resetTimeCountersAndFlagOnChange(); + + if (this._usePostFixedUpdate) { + + this._dynamicTime += dt; + + // Apply transform interpolation to all entities referencing the dynamic body. + // subtract lastFixedTimeStep to synchronize the transformation + // between the last fixedUpdate and postFixedUpdate + const extrapolationTime = this._dynamicTime - this._fixedTime - this._lastFixedTimeStep * 2; + + const dynamic = this._dynamic; + for (let i = 0, len = dynamic.length; i < len; i++) { + dynamic[i]._applyInterpolation(extrapolationTime); + } + + } else { + + // #if _PROFILER + this._stats.physicsStart = now(); + // #endif + + this._beforeStepSimulation(dt); + + // Step the physics simulation + this.dynamicsWorld.stepSimulation(dt, this.maxSubSteps, this.fixedTimeStep); + + this._afterStepSimulation(dt); + + // #if _PROFILER + this._stats.physicsTime = now() - this._stats.physicsStart; + // #endif + } } destroy() { super.destroy(); + this.app.systems.off('postFixedUpdate', this.onPostFixedUpdate, this); this.app.systems.off('update', this.onUpdate, this); if (typeof Ammo !== 'undefined') { diff --git a/src/framework/components/script/component.js b/src/framework/components/script/component.js index c13e8c50455..e2cc75955c9 100644 --- a/src/framework/components/script/component.js +++ b/src/framework/components/script/component.js @@ -5,7 +5,7 @@ import { Component } from '../component.js'; import { Entity } from '../../entity.js'; import { SCRIPT_INITIALIZE, SCRIPT_POST_INITIALIZE, SCRIPT_UPDATE, - SCRIPT_POST_UPDATE, SCRIPT_SWAP + SCRIPT_FIXED_UPDATE, SCRIPT_POST_UPDATE, SCRIPT_SWAP } from '../../script/constants.js'; import { ScriptType } from '../../script/script-type.js'; import { getScriptName } from '../../script/script.js'; @@ -196,6 +196,8 @@ class ScriptComponent extends Component { * @private */ this._scripts = []; + // holds all script instances with an fixedUpdate method + this._fixedUpdateList = new SortedLoopArray({ sortBy: '__executionOrder' }); // holds all script instances with an update method this._updateList = new SortedLoopArray({ sortBy: '__executionOrder' }); // holds all script instances with a postUpdate method @@ -499,6 +501,22 @@ class ScriptComponent extends Component { this.onPostStateChange(); } + _onFixedUpdate(dt) { + const list = this._fixedUpdateList; + if (!list.length) return; + + const wasLooping = this._beginLooping(); + + for (list.loopIndex = 0; list.loopIndex < list.length; list.loopIndex++) { + const script = list.items[list.loopIndex]; + if (script.enabled) { + this._scriptMethod(script, SCRIPT_FIXED_UPDATE, dt); + } + } + + this._endLooping(wasLooping); + } + _onUpdate(dt) { const list = this._updateList; if (!list.length) return; @@ -547,6 +565,11 @@ class ScriptComponent extends Component { this._scripts.push(scriptInstance); scriptInstance.__executionOrder = scriptsLength; + // append script to the fixedUpdate list if it has an update method + if (scriptInstance.fixedUpdate) { + this._fixedUpdateList.append(scriptInstance); + } + // append script to the update list if it has an update method if (scriptInstance.update) { this._updateList.append(scriptInstance); @@ -565,6 +588,12 @@ class ScriptComponent extends Component { // the script instances that come after this script this._resetExecutionOrder(index + 1, scriptsLength + 1); + // insert script to the fixedUpdate list if it has an update method + // in the right order + if (scriptInstance.fixedUpdate) { + this._fixedUpdateList.insert(scriptInstance); + } + // insert script to the update list if it has an update method // in the right order if (scriptInstance.update) { @@ -585,6 +614,10 @@ class ScriptComponent extends Component { this._scripts.splice(idx, 1); + if (scriptInstance.fixedUpdate) { + this._fixedUpdateList.remove(scriptInstance); + } + if (scriptInstance.update) { this._updateList.remove(scriptInstance); } @@ -913,6 +946,9 @@ class ScriptComponent extends Component { // set execution order and make sure we update // our update and postUpdate lists scriptInstance.__executionOrder = ind; + if (scriptInstanceOld.fixedUpdate) { + this._fixedUpdateList.remove(scriptInstanceOld); + } if (scriptInstanceOld.update) { this._updateList.remove(scriptInstanceOld); } @@ -920,6 +956,9 @@ class ScriptComponent extends Component { this._postUpdateList.remove(scriptInstanceOld); } + if (scriptInstance.fixedUpdate) { + this._fixedUpdateList.insert(scriptInstance); + } if (scriptInstance.update) { this._updateList.insert(scriptInstance); } @@ -1084,6 +1123,7 @@ class ScriptComponent extends Component { // reset execution order for scripts and re-sort update and postUpdate lists this._resetExecutionOrder(0, len); + this._fixedUpdateList.sort(); this._updateList.sort(); this._postUpdateList.sort(); diff --git a/src/framework/components/script/system.js b/src/framework/components/script/system.js index e3d28709e31..d349496c5a3 100644 --- a/src/framework/components/script/system.js +++ b/src/framework/components/script/system.js @@ -10,6 +10,7 @@ import { ScriptComponentData } from './data.js'; const METHOD_INITIALIZE_ATTRIBUTES = '_onInitializeAttributes'; const METHOD_INITIALIZE = '_onInitialize'; const METHOD_POST_INITIALIZE = '_onPostInitialize'; +const METHOD_FIXED_UPDATE = '_onFixedUpdate'; const METHOD_UPDATE = '_onUpdate'; const METHOD_POST_UPDATE = '_onPostUpdate'; @@ -62,6 +63,7 @@ class ScriptComponentSystem extends ComponentSystem { this.on('beforeremove', this._onBeforeRemove, this); this.app.systems.on('initialize', this._onInitialize, this); this.app.systems.on('postInitialize', this._onPostInitialize, this); + this.app.systems.on('fixedUpdate', this._onFixedUpdate, this); this.app.systems.on('update', this._onUpdate, this); this.app.systems.on('postUpdate', this._onPostUpdate, this); } @@ -163,6 +165,11 @@ class ScriptComponentSystem extends ComponentSystem { this._callComponentMethod(this._enabledComponents, METHOD_POST_INITIALIZE); } + _onFixedUpdate(dt) { + // call onFixedUpdate on enabled components + this._callComponentMethod(this._enabledComponents, METHOD_FIXED_UPDATE, dt); + } + _onUpdate(dt) { // call onUpdate on enabled components this._callComponentMethod(this._enabledComponents, METHOD_UPDATE, dt); @@ -201,6 +208,7 @@ class ScriptComponentSystem extends ComponentSystem { this.app.systems.off('initialize', this._onInitialize, this); this.app.systems.off('postInitialize', this._onPostInitialize, this); + this.app.systems.off('fixedUpdate', this._onFixedUpdate, this); this.app.systems.off('update', this._onUpdate, this); this.app.systems.off('postUpdate', this._onPostUpdate, this); } diff --git a/src/framework/script/constants.js b/src/framework/script/constants.js index 548b72c6005..75c8ca418e0 100644 --- a/src/framework/script/constants.js +++ b/src/framework/script/constants.js @@ -1,5 +1,6 @@ export const SCRIPT_INITIALIZE = 'initialize'; export const SCRIPT_POST_INITIALIZE = 'postInitialize'; +export const SCRIPT_FIXED_UPDATE = 'fixedUpdate'; export const SCRIPT_UPDATE = 'update'; export const SCRIPT_POST_UPDATE = 'postUpdate'; export const SCRIPT_SWAP = 'swap'; diff --git a/src/framework/script/script.js b/src/framework/script/script.js index 62bae92e36b..0412359cc39 100644 --- a/src/framework/script/script.js +++ b/src/framework/script/script.js @@ -17,6 +17,7 @@ import { SCRIPT_INITIALIZE, SCRIPT_POST_INITIALIZE } from './constants.js'; * * - `Script#initialize` - Called once when the script is initialized. * - `Script#postInitialize` - Called once after all scripts have been initialized. + * - `Script#fixedUpdate` - Called every fixed time, if the script is enabled. * - `Script#update` - Called every frame, if the script is enabled. * - `Script#postUpdate` - Called every frame, after all scripts have been updated. * - `Script#swap` - Called when a script is redefined. @@ -326,6 +327,13 @@ export class Script extends EventHandler { * @description Called after all initialize methods are executed in the same tick or enabling chain of actions. */ + /** + * @function + * @name Script#[fixedUpdate] + * @description Called for enabled (running state) scripts on each fixed tick. + * @param {number} dt - The fixed delta time in seconds. + */ + /** * @function * @name Script#[update] diff --git a/src/framework/stats.js b/src/framework/stats.js index 078c20e94fe..b449528b6e2 100644 --- a/src/framework/stats.js +++ b/src/framework/stats.js @@ -19,6 +19,8 @@ class ApplicationStats { ms: 0, dt: 0, + fixedUpdateCount: 0, + updateStart: 0, updateTime: 0, renderStart: 0,