Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 210 additions & 46 deletions src/core/loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,238 @@
* @private
*
* Handles the logic for creating a loading indicator.
* Currently, the loading indicator is basic and can be extended in the future.
*/

/**
* Creates a loading indicator when the sketch's setup() function is running.
* It is called and removed automatically using the presetup and postsetup lifecycles hooks.
* It is called and removed automatically using the presetup and postsetup lifecycle hooks.
* Registers loading indicator decorators for createCanvas(), resizeCanvas(), and noCanvas()
* to manage the loading indicator overlay.
*
* @param {*} p5 The p5 constructor
* @param {*} fn The p5 prototype object
* @param {*} lifecycles Lifecycle hooks for the sketch
*/
export default function loading(p5, fn, lifecycles) {
lifecycles.presetup = function () {
if (typeof window === 'undefined' || this._loadingIndicator) {
return;
}

const canvasParent = this.canvas?.parentElement;
let container = this._userNode || canvasParent || document.body;
p5.registerDecorator('p5.prototype.createCanvas', _handleLoadingIndicator(true));
p5.registerDecorator('p5.prototype.resizeCanvas', _handleLoadingIndicator(true));
p5.registerDecorator('p5.prototype.noCanvas', _handleLoadingIndicator(false));

if (typeof container === 'string') {
container = document.getElementById(container) || document.body;
}
/**
* Sets a custom loading animation for the current sketch.
*
* Calling `loadingAnimation()` without a callback disables the loading
* animation. Calling it with a callback replaces the default animation.
* The callback receives the overlay's 2D rendering context, its width,
* its height, and the current animation frame. The callback's `this` value
* is the current p5 instance.
*
* @method loadingAnimation
* @param {Function} [callback] Function used to draw each animation frame.
* @chainable
*/
fn.loadingAnimation = function (callback) {
this._hasCustomLoading = true;
this._loadingAnimation = callback;
_removeLoadingOverlay(this);
return this;
};

this._loadingIndicator = createLoadingIndicator(container);
lifecycles.presetup = function () {
this._isSketchLoading = true;
};

lifecycles.postsetup = function () {
if (this._loadingIndicator) {
this._loadingIndicator.remove();
this._loadingIndicator = null;
}
this._isSketchLoading = false;
_removeLoadingOverlay(this);
};
}

/**
* Creates and stylizes the loading indicator.
* As a helper function, it can be extensible and modified in future versions.
* Creates the loading canvas to directly overlay the sketch canvas
* and starts the spinning logo animation loop.
*
* @private
* @param {HTMLElement} container The HTML element to append the indicator to
* @returns {HTMLElement} The loading indicator div element
* @param {p5} pInst The p5 instance.
*/
function createLoadingIndicator(container) {
if (!document.getElementById('p5-loading-style')) {
const loadingStyle = document.createElement('style');
loadingStyle.id = 'p5-loading-style';
loadingStyle.textContent =
'@keyframes p5-loading-spin { to { transform: rotate(360deg); } }';
document.head.appendChild(loadingStyle);
function _createLoadingOverlay(pInst) {
if (pInst._hasCustomLoading && typeof pInst._loadingAnimation !== 'function') {
return;
}

const actualCanvas = pInst.canvas?.elt || pInst.canvas;
if (!actualCanvas) return;

let overlay = pInst._loadingOverlay;

// If overlay doesn't exist yet, create it and animate it
if (!overlay) {
overlay = document.createElement('canvas');
overlay.id = `${actualCanvas.id || 'defaultCanvas0'}_loadingOverlay`;
overlay.classList.add('loading-indicator');
pInst._loadingOverlay = overlay;

const ctx = overlay.getContext('2d');
let frameCount = 0;

const animate = () => {
if (!pInst._isSketchLoading) {
return;
}

ctx.clearRect(0, 0, overlay.width, overlay.height);
const frame = frameCount++;
if (pInst._hasCustomLoading) {
pInst._loadingAnimation.call(
pInst,
ctx,
overlay.width,
overlay.height,
frame
);
}
else {
_drawLoadingIndicator(
ctx,
overlay.width / 2,
overlay.height / 2,
frame
);
}

pInst._loadingOverlayFrame = requestAnimationFrame(animate);
};

animate();
}

// Positions the loading indicator to overlay the sketch canvas
_positionCanvas(overlay, actualCanvas);

if (overlay.parentNode !== actualCanvas.parentNode) {
actualCanvas.parentNode.insertBefore(overlay, actualCanvas.nextSibling);
}
}

/**
* Matches the size and position of the loading canvas to the user's sketch canvas.
*
* @private
* @param {HTMLCanvasElement} loadingCanvas The overlay canvas element.
* @param {HTMLCanvasElement} actualCanvas The sketch canvas element.
*/
function _positionCanvas(loadingCanvas, actualCanvas) {
loadingCanvas.width = actualCanvas.width;
loadingCanvas.height = actualCanvas.height;

const width = actualCanvas.style.width || `${actualCanvas.offsetWidth || actualCanvas.width}px`;
const height = actualCanvas.style.height || `${actualCanvas.offsetHeight || actualCanvas.height}px`;

const indicator = document.createElement('div');
indicator.className = 'loading-indicator';
indicator.style.cssText = `
position: fixed;
inset: 0;
margin: auto;
width: 30px;
height: 30px;
border-radius: 50%;

border: 3px solid rgba(0, 0, 0, 0.1);
border-top-color: rgba(0, 0, 0, 0.8);
animation: p5-loading-spin 1s linear infinite;
z-index: 9999;
`;

container.appendChild(indicator);
return indicator;
Object.assign(loadingCanvas.style, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing we don't handle yet is the case when something else moves around the canvas from this initial position, or has other elements on top of the canvas (e.g. UI elements; we don't want this canvas intercepting their mouse events) which they might not want a loading canvas to go on top of. All of those seem somewhat difficult to handle without it being brittle.

Do we have ways to either opt out of showing a loading indicator (would that be passing a custom animation that does nothing? still creates a second canvas though), or ideally to reuse the same canvas? The latter does seem like it'd address all the issues, but comes with some additional things to handle, namely:

  • it might not be a 2D canvas always, and it might change when createCanvas is called. For the default p5 logo spinner we could check the renderer type and handle both; for custom loaders, if they don't want to do that, they could just make sure to create their canvas first before setting up their custom loader so that the type doesn't change
  • Ideally using p5 methods instead of raw canvas context, as especially for webgl/webgpu that would be pretty complex for users to write. In theory p5 methods would be available right?

Sorry to take a step back and discuss some fairly different approaches; just want to make sure we're preempting issues that'd come up by adding a second canvas to every sketch before moving forward!

@limzykenneth limzykenneth Oct 6, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we perhaps have something a bit equivalent to createCanvas that is used in the drawLoadingAnimation function that if not called will not create a second canvas and can also be used to create a 2D or 3D canvas depending on renderer chosen?

Reusing the same canvas will need more work though at this point to accommodate different renderers given we need to get the API used inside drawLoadingAnimation to behave consistently regardless. Also the problem of how to undo the loading animation once it is completed without erasing what is already on the canvas, perhaps by saving the current pixels of the canvas and writing it back after?

To provide a set of p5 like functions without worrying about it clashing with the global definition, we can potentially use the pattern similar to here: https://github.com/antfu/p5i#usage

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we perhaps have something a bit equivalent to createCanvas that is used in the drawLoadingAnimation function that if not called will not create a second canvas and can also be used to create a 2D or 3D canvas depending on renderer chosen?

You probably want to avoid creating extra 3D canvases if possible since there's a fairly low limit in the number of active contexts the browser supports at once, so I think if you're creating extra canvases, it would make sense to limit them to 2D. If we end up not wanting to have loaders on the same canvas, then we maybe just need a way to say "I don't want any loading animation canvas" separate from "here's my own loading animation"

Also the problem of how to undo the loading animation once it is completed without erasing what is already on the canvas, perhaps by saving the current pixels of the canvas and writing it back after?

I suppose if you're interleaving drawing with loading in setup it's going to be pretty hard to do that. It might mean that we would want to have a way to say "I'm done loading" earlier than the end of setup if we have a loader drawn to the main canvas and you intend to do some drawing that you don't want overdrawn by the loader, so you just see the loader while it's loading and then you just see your content afterwards?

To provide a set of p5 like functions without worrying about it clashing with the global definition, we can potentially use the pattern similar to here: https://github.com/antfu/p5i#usage

Does loading start showing up right before presetup hooks start, or is it earlier? If it's right as the p5 lifecycle starts then would the p5 methods already be available?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we stick with a 2D context always then it might be more straightforward to still do an additional canvas since we can't reuse the canvas already used for 3D, also the user may not have created a canvas in the first place for us to know which renderer they are going to use.

If that is the case then the undo issue won't be a concern and we can ignore it. The original question of a more dynamic sketch at the start that move its position or covered by other elements feels like there could still be a solution though, I think as long as we can keep the loading canvas as a sibling to the main canvas it should take care of most things, there are some edge cases such as when the user use CSS specifically to target only the main canvas.

The loading do start with presetup so p5 methods might be available already, need to confirm how it behaves though.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sticking with 2D for simplicity is ok if we're going to stick with a separate canvas, but then let's just add an escape hatch to disable this entirely if we do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we add a flag p5.loadingAnimation which when set to false will skip the loading animation stuff entirely so a canvas won't be created?

width,
height,
position: 'absolute',
top: `${actualCanvas.offsetTop}px`,
left: `${actualCanvas.offsetLeft}px`,
margin: '0',
padding: '0',
pointerEvents: 'none',
zIndex: '9999'
});
}

/**
* Stops the loading indicator animation and removes the overlay canvas from the DO
*
* @private
* @param {p5} pInst The p5 instance.
*/
function _removeLoadingOverlay(pInst) {
if (pInst._loadingOverlay) {
cancelAnimationFrame(pInst._loadingOverlayFrame);
pInst._loadingOverlay.remove();
pInst._loadingOverlay = null;
}
}

/**
* Draws a canvas-based animated loading indicator.
* The loading indicator is a spinning p5 logo.
*
* Credits to Raphaël de Courville for creating the p5 logo sketch
*
* @private
* @param {CanvasRenderingContext2D} ctx The 2D canvas context to draw on.
* @param {Number} x The x-coordinate for the logo center.
* @param {Number} y The y-coordinate for the logo center.
* @param {Number} t The frame count used to calculate rotation.
*/
function _drawLoadingIndicator(ctx, x, y, t) {
let rotationSpeed = 3.25;
let indicatorSize = 1.5;

// Semi-transparent gray background
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

ctx.save();
ctx.translate(x, y);
ctx.scale(indicatorSize, indicatorSize);

ctx.rotate((t * rotationSpeed * Math.PI) / 180);
ctx.translate(-14, -14);

ctx.fillStyle = '#ED225D';
ctx.beginPath();

ctx.moveTo(16.909, 10.259);
ctx.lineTo(25.442, 7.683);
ctx.lineTo(27.118, 12.839);
ctx.lineTo(18.62, 15.738);
ctx.lineTo(23.895, 23.218);
ctx.lineTo(19.448, 26.443);
ctx.lineTo(13.895, 19.095);
ctx.lineTo(8.487, 26.25);
ctx.lineTo(4.169, 22.961);
ctx.lineTo(9.444, 15.738);
ctx.lineTo(0.88, 12.647);
ctx.lineTo(2.558, 7.487);
ctx.lineTo(11.156, 10.258);
ctx.lineTo(11.156, 1.364);
ctx.lineTo(16.91, 1.364);

ctx.closePath();
ctx.fill();
ctx.restore();
}

/**
* Intercepts canvas methods to create, update, or remove the loading indicator
*
* @private
* @internal
*
* @param {Boolean} isLoading True to show the loading indicator; false to hide it.
* @return {Function} A decorator function for the target canvas method.
*/
export function _handleLoadingIndicator(isLoading) {
return function (target) {
return function (...args) {
const result = target.call(this, ...args);

// Create loading overlay if canvas is loading
if (isLoading) {
if (this._isSketchLoading) {
_createLoadingOverlay(this);
}
}

// Remove loading overlay if canvas isn't loading
else {
_removeLoadingOverlay(this);
}

return result;
};
};
}
14 changes: 11 additions & 3 deletions src/core/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,14 @@ class p5 {
this._millisStart = globalThis.performance.now();

const context = this._isGlobal ? window : this;
if (typeof context.setup === 'function') {
await context.setup();
try {
if (typeof context.setup === 'function') {
await context.setup();
}
}
catch (error) {
await this._runLifecycleHook('postsetup');
throw error;
}
if (this.hitCriticalError) return;

Expand Down Expand Up @@ -659,7 +665,9 @@ p5.registerAddon(rendering);
p5.registerAddon(renderer);
p5.registerAddon(renderer2D);
p5.registerAddon(graphics);
p5.registerAddon(loading);
if (typeof window !== 'undefined') {
p5.registerAddon(loading);
}

export default p5;

Expand Down
Loading