lume
Version:
173 lines • 7.49 kB
JavaScript
// TODO import and use animation-loop
class _Motor {
/**
* When a render tasks is added a new requestAnimationFrame loop will be
* started if there isn't one currently.
*
* A render task is simply a function that will be called over and over
* again, in the Motor's animation loop. That's all, nothing special.
* However, if a Element3D setter is used inside of a render task, then the Element3D
* will tell Motor that it needs to be re-rendered, which will happen at
* the end of the current frame. If a Element3D setter is used outside of a
* render task (i.e. outside of the Motor's animation loop), then the Element3D
* tells Motor to re-render the Element3D on the next animation loop tick.
* Basically, regardless of where the Element3D's setters are used (inside or
* outside of the Motor's animation loop), rendering always happens inside
* the loop.
*
* @param {Function} fn The render task to add.
*
* @return {Function} A reference to the render task. Useful for saving to
* a variable so that it can later be passed to Motor.removeRenderTask().
*/
addRenderTask(fn) {
if (typeof fn != 'function')
throw new Error('Render task must be a function.');
if (this.#allRenderTasks.includes(fn))
return fn;
this.#allRenderTasks.push(fn);
this.#numberOfTasks += 1;
// If the render loop isn't started, start it.
if (!this.#loopStarted)
this.#startAnimationLoop();
return fn;
}
removeRenderTask(fn) {
const taskIndex = this.#allRenderTasks.indexOf(fn);
if (taskIndex == -1)
return;
this.#allRenderTasks.splice(taskIndex, 1);
this.#numberOfTasks -= 1;
if (taskIndex <= this.#taskIterationIndex)
this.#taskIterationIndex -= 1;
}
#onces = new Set();
/**
* Adds a render task that executes only once instead of repeatedly. Set
* `allowDuplicates` to `false` to skip queueing a function if it is already
* queued.
*/
once(fn, allowDuplicates = true) {
if (!allowDuplicates && this.#onces.has(fn))
return;
this.#onces.add(fn);
// The `false` return value of the task tells Motor not to re-run it.
return this.addRenderTask((time, dt) => (fn(time, dt), false));
}
// An Element3D calls this any time its properties have been modified (f.e. by the end user).
needsUpdate(element) {
// delete so it goes to the end
if (this.#elementsToUpdate.has(element))
this.#elementsToUpdate.delete(element);
this.#elementsToUpdate.add(element);
// noop if the loop's already started
this.#startAnimationLoop();
}
willUpdate(element) {
return this.#elementsToUpdate.has(element);
}
/**
* Set the function that is used for requesting animation frames. The
* default is `globalThis.requestAnimationFrame`. A Scene with WebXR enabled
* will pass in the XRSession's requester that controls animation frames for
* the XR headset.
*/
setFrameRequester(requester) {
this.#requestFrame = requester;
}
#loopStarted = false;
#taskIterationIndex = 0;
#numberOfTasks = 0;
// This is an array so that it is possible to add a task function more than once.
#allRenderTasks = [];
#elementsToUpdate = new Set();
#modifiedScenes = new Set();
// A set of elements that are the root elements of subtrees where all elements
// in subtrees need their world matrices updated.
#treesToUpdate = new Set();
// default to requestAnimationFrame for regular non-VR/AR scenes.
// Using ?. here in case of a non-DOM env.
#requestFrame = globalThis.requestAnimationFrame?.bind(globalThis);
/**
* Starts a requestAnimationFrame loop and runs the render tasks in the allRenderTasks stack.
* As long as there are tasks in the stack, the loop continues. When the
* stack becomes empty due to removal of tasks, the
* requestAnimationFrame loop stops and the app sits there doing nothing
* -- silence, crickets.
*/
async #startAnimationLoop() {
if (document.readyState === 'loading')
await new Promise(resolve => setTimeout(resolve));
if (this.#loopStarted)
return;
this.#loopStarted = true;
let lastTime = performance.now();
while (this.#loopStarted) {
const timestamp = await this.#animationFrame();
const deltaTime = timestamp - lastTime;
this.#runRenderTasks(timestamp, deltaTime);
this.#onces.clear();
// Wait one more microtask in case reactivity (f.e. not just lume
// reactivity, but any reactivity outside of lume that may be
// microtask deferred like Vue's, Svelte's, React's, etc) needs
// another chance to run.
//
// TODO continue to queue element updates with a microtasks until tasks
// settle, similar to ResizeObserver, with a loop limit, before running all
// element updates.
await null;
this.#updateElements(timestamp, deltaTime);
// If no tasks are left, stop the animation loop.
if (!this.#allRenderTasks.length)
this.#loopStarted = false;
lastTime = timestamp;
}
}
#animationFrame() {
return new Promise(r => this.#requestFrame(r));
}
#runRenderTasks(timestamp, deltaTime) {
for (this.#taskIterationIndex = 0; this.#taskIterationIndex < this.#numberOfTasks; this.#taskIterationIndex += 1) {
const task = this.#allRenderTasks[this.#taskIterationIndex];
if (task(timestamp, deltaTime) === false)
this.removeRenderTask(task);
}
}
#updateElements(timestamp, deltaTime) {
if (this.#elementsToUpdate.size === 0)
return;
for (const el of this.#elementsToUpdate) {
el.update(timestamp, deltaTime);
// if there is no ancestor of the current element that should be
// updated, then the current element is a root element of a subtree
// that needs to be updated
if (!hasAncestorThatWillUpdate(el))
this.#treesToUpdate.add(el);
// keep track of which scenes are modified so we can render webgl
// only for those scenes.
if (el.scene)
this.#modifiedScenes.add(el.scene);
}
this.#elementsToUpdate.clear();
// Update world matrices of the subtrees.
for (const el of this.#treesToUpdate)
el.updateWorldMatrices();
this.#treesToUpdate.clear();
// render webgl of modified scenes.
for (const scene of this.#modifiedScenes)
scene.drawScene();
this.#modifiedScenes.clear();
}
}
// export a singleton instance rather than the class directly.
export const Motor = new _Motor();
function hasAncestorThatWillUpdate(el) {
let composedSceneGraphParent = el.composedSceneGraphParent;
while (composedSceneGraphParent) {
if (Motor.willUpdate(composedSceneGraphParent))
return true;
composedSceneGraphParent = composedSceneGraphParent.composedSceneGraphParent;
}
return false;
}
//# sourceMappingURL=Motor.js.map