text-particle
Version:
Particle effects for text.
750 lines (732 loc) • 24 kB
JavaScript
'use strict';
function invariant(value, msg = 'debugger point') {
if (typeof value !== 'boolean' && !value) {
console.error(msg);
throw new Error('Unexpected empty value.');
}
}
function distance(x1, y1, x2, y2) {
const x = Math.abs(x1 - x2);
const y = Math.abs(y1 - y2);
return Math.floor(Math.sqrt(x * x + y * y));
}
/**
*
* @param t cost time
* @param d duration time
* @param p particle
* @returns
*/
function ease(t, d, s, e) {
if (t >= d) {
return e;
}
const x = t / d;
// const y = -x * x + 2 * x
const y = -x * x + 2 * x;
return s + (e - s) * y;
}
function isApproximateEqual(a, b) {
return Math.abs(a - b) <= 1.0;
}
function useRAF(fn) {
let raf = -1;
const run = () => {
fn();
raf = requestAnimationFrame(() => {
run();
});
};
const cancel = () => {
cancelAnimationFrame(raf);
};
return [run, cancel];
}
function transformHexStrToRGBA(color) {
if (!color.startsWith('#')) {
throw new Error('Error color style');
}
color = color.substring(1);
// ensure correct length
if (color.length === 3) {
const [r, g, b] = color;
color = r + r + g + g + b + b;
}
if (color.length !== 6) {
throw new Error('Error color style');
}
const rgb = [];
for (let i = 0; i < 6; i += 2) {
rgb.push(Number.parseInt(color[i] + color[i + 1], 16));
}
// A default to 255
rgb.push(255);
return rgb;
}
function shallowEqual(obj1, obj2) {
const keys = Object.keys(obj1);
for (const key of keys) {
if (obj1[key] !== obj2[key]) {
return false;
}
}
return true;
}
function shallowClone(obj) {
return {
...obj
};
}
function filterRGBA(r, g, b, a) {
return (r + g + b) > 0 && a > 0;
}
class Particle {
x;
y;
r;
c;
static from(imageData, gap = 1, radius = 1, f) {
gap = Math.max(1, gap);
radius = Math.max(1, radius);
const filter = f || filterRGBA;
const { data, width, height } = imageData;
const result = [];
let r = 0, g = 0, b = 0, a = 0;
let index = 0;
let pre = 0;
for (let i = 0; i < height; i += gap) {
pre = i * width * 4;
for (let j = 0; j < width; j += gap) {
index = pre + j * 4;
r = data[index];
g = data[index + 1];
b = data[index + 2];
a = data[index + 3];
if (filter(r, g, b, a)) {
result.push(Particle.create(j, i, radius, [r, g, b, a]));
}
}
}
// console.log('particle count:', result.length)
return result;
}
static create(x, y, r = 1, c = [0, 0, 0, 1]) {
return new Particle(x, y, r, c);
}
static copyWithin(source, start = 0, end = source.length) {
return source.copyWithin(start, end).map(s => s.clone());
}
get nextX() {
return this._nextX;
}
get nextY() {
return this._nextY;
}
get preX() {
return this._preX;
}
get preY() {
return this._preY;
}
get arrived() {
return this._nextX === this.x && this._nextY === this.y;
}
get color() {
const [r, g, b, a] = this.c;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
_nextX;
_nextY;
_preX;
_preY;
constructor(x, y, r, c) {
this.x = x;
this.y = y;
this.r = r;
this.c = c;
this._nextX = this._preX = this.x;
this._nextY = this._preY = this.y;
}
clone() {
return Particle.create(this.x, this.y, this.r, this.c);
}
updateNext(x, y, r = this.r, c = this.c) {
this._preX = this.x;
this._preY = this.y;
this._nextX = x;
this._nextY = y;
this.r = r;
this.c = c;
}
update(x = this._nextX, y = this._nextY) {
x = isApproximateEqual(x, this._nextX) ? this._nextX : x;
y = isApproximateEqual(y, this._nextY) ? this._nextY : y;
this.x = x;
this.y = y;
}
}
class Renderer {
root;
config;
constructor(root, config) {
this.root = root;
this.config = config;
}
}
const FRAGMENT_SHADER_SOURCE = `precision mediump float;
varying vec4 v_color;
void main() {
gl_FragColor = v_color;
}`;
const VERTEX_SHADER_SOURCE = `attribute vec2 a_position;
attribute vec4 a_color;
varying vec4 v_color;
uniform vec2 u_resolution;
uniform float u_point_size;
void main() {
vec2 clipSpace = a_position / u_resolution * 2.0 - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
gl_PointSize = u_point_size;
v_color = a_color / vec4(255.0, 255.0, 255.0, 255.0);
}`;
function createShader(gl, type, source) {
const shader = gl.createShader(type);
invariant(shader);
gl.shaderSource(shader, source);
gl.compileShader(shader);
const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (success) {
return shader;
}
const errorInfo = gl.getShaderInfoLog(shader) || '';
gl.deleteShader(shader);
throw new Error(errorInfo);
}
function createProgram(gl, vs, fs) {
const program = gl.createProgram();
invariant(program);
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
if (success) {
return program;
}
const errorInfo = gl.getProgramInfoLog(program) || '';
gl.deleteProgram(program);
throw new Error(errorInfo);
}
/**
*
* @param gl
* @param program
* @param points
* @returns
*/
function setAttributeBuffer(gl, buffer, points) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer.buffer);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
gl.vertexAttribPointer(buffer.location, buffer.readSize, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(buffer.location);
}
class WebGLRenderer extends Renderer {
root;
config;
gl;
program;
pointsBuffer;
colorBuffer;
constructor(root, config) {
super(root, config);
this.root = root;
this.config = config;
const gl = this.root.getContext('webgl');
invariant(gl, 'Not found webgl context.');
this.gl = gl;
const vs = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE);
const fs = createShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER_SOURCE);
this.program = createProgram(gl, vs, fs);
invariant(this.program);
gl.useProgram(this.program);
const pb = gl.createBuffer();
invariant(pb, 'Point buffer creation failed.');
const cb = gl.createBuffer();
invariant(cb, 'Color buffer creation failed.');
this.pointsBuffer = pb;
this.colorBuffer = cb;
}
resize() {
const { program, gl } = this;
const resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
gl.uniform2f(resolutionLocation, this.root.width, this.root.height);
const pointSizeLocation = gl.getUniformLocation(program, 'u_point_size');
gl.uniform1f(pointSizeLocation, this.config.particleRadius || 1);
gl.viewport(0, 0, this.root.width, this.root.height);
}
render(particles, config) {
this.config = config;
const { program, gl } = this;
const len = particles.length;
const _positions = new Array(len * 2);
const _colors = new Array(len * 4);
let configColor = [];
let useConfigColor = false;
if (this.config.color) {
useConfigColor = true;
configColor = transformHexStrToRGBA(this.config.color);
}
let pIndex = 0, cIndex = 0;
particles.forEach((p) => {
_positions[pIndex++] = p.x;
_positions[pIndex++] = p.y;
const c = useConfigColor ? configColor : p.c;
for (let i = 0; i < c.length; i++) {
_colors[cIndex++] = c[i];
}
});
const positions = new Float32Array(_positions);
const colors = new Float32Array(_colors);
setAttributeBuffer(gl, {
buffer: this.pointsBuffer,
location: gl.getAttribLocation(program, 'a_position'),
readSize: 2
}, positions);
setAttributeBuffer(gl, {
buffer: this.colorBuffer,
location: gl.getAttribLocation(program, 'a_color'),
readSize: 4
}, colors);
gl.drawArrays(gl.POINTS, 0, particles.length);
}
}
class CanvasRenderer extends Renderer {
root;
config;
ctx;
constructor(root, config) {
super(root, config);
this.root = root;
this.config = config;
const canvasCtx = this.root.getContext('2d');
invariant(canvasCtx, 'not found canvas 2d context');
this.ctx = canvasCtx;
}
resize() { }
render(particles, config) {
this.config = config;
this.ctx.clearRect(0, 0, this.root.width, this.root.height);
if (this.config.color) {
this.batchDraw(particles, this.config.color);
}
else {
this.singleDraw(particles);
}
}
singleDraw(particles) {
particles.forEach(p => {
this._singleDraw(p);
});
}
_singleDraw(p, stroke = false) {
const { ctx } = this;
const { x, y, r, color } = p;
this.updateDrawStyle(color);
ctx.moveTo(x, y);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
if (stroke) {
ctx.stroke();
}
else {
ctx.fill();
}
}
updateDrawStyle(color) {
const { ctx } = this;
if (ctx.fillStyle !== color) {
ctx.fillStyle = color;
}
if (ctx.strokeStyle !== color) {
ctx.strokeStyle = color;
}
}
/**
* We can improve drawing performance if the user sets the color
*/
batchDraw(particles, color) {
const { ctx } = this;
this.updateDrawStyle(color);
ctx.beginPath();
particles.forEach(p => {
if (this.config.particleRadius <= 1) {
// When the particle radius is less than 1, we can replace the circle with a rectangle
ctx.rect(p.x, p.y, p.r * 2, p.r * 2);
}
else {
ctx.roundRect(p.x, p.y, p.r * 2, p.r * 2, p.r);
}
});
ctx.fill();
}
}
const defaultConfig = {
enableWebGL: false,
source: '',
color: '',
particleRadius: 1,
particleGap: 1,
// limit the gap and radius
enableContinuousEasing: true,
showMouseCircle: true,
moveProportionPerFrame: 30,
offsetX: 0,
offsetY: 0,
disableCache: false
};
function mergeConfig(source, config) {
const template = shallowClone(source);
Object.assign(template, {
enableWebGL: config.enableWebGL ?? source.enableWebGL,
source: config.source ?? source.source,
color: config.color ?? source.color,
// limit the gap and radius
particleRadius: config.particleRadius ?? source.particleRadius,
particleGap: config.particleGap ?? source.particleGap,
enableContinuousEasing: config.enableContinuousEasing ?? source.enableContinuousEasing,
showMouseCircle: config.showMouseCircle ?? source.showMouseCircle,
moveProportionPerFrame: config.moveProportionPerFrame ?? source.moveProportionPerFrame,
offsetX: config.offsetX ?? source.offsetX,
offsetY: config.offsetY ?? source.offsetY,
disableCache: config.disableCache ?? source.disableCache,
pixelFilter: config.pixelFilter ?? source.pixelFilter
});
return template;
}
class ParticleEffect {
root;
renderer;
canvas = document.createElement('canvas');
isRendering = false;
cacheMap = new Map();
unBindMouseEventCallback = null;
lastAnimationBeginTime = 0;
animationTime = 2000;
particles = [];
mouseParticle = null;
_config;
constructor(root, config) {
this.root = root;
if (root instanceof HTMLCanvasElement) {
this.canvas = root;
}
else {
root.appendChild(this.canvas);
}
const { clientHeight, clientWidth } = root;
this.canvas.width = clientWidth;
this.canvas.height = clientHeight;
this._config = mergeConfig(defaultConfig, config);
if (this._config.enableWebGL) {
this.renderer = new WebGLRenderer(this.canvas, this._config);
}
else {
this.renderer = new CanvasRenderer(this.canvas, this._config);
}
if (this._config.showMouseCircle) {
this.enableMouseListener();
}
else {
this.disableMouseListener();
}
}
destroy() {
this.disableMouseListener();
this.cacheMap.clear();
}
/**
*
* @param newSource
* @param time this option will be disabled if 'enableContinuousEasing' is set to true
* @returns
*/
async transitionTo(newSource, time, config = {}) {
this._config = mergeConfig(this._config, config);
if (!this.isRendering) {
this.render(newSource);
return;
}
if (this._config.source === newSource) {
return;
}
this._config.source = newSource;
this.animationTime = time;
const newParticles = await this.generateParticles(newSource);
if (!this.particles.length) {
throw new Error('Particle generate error, please check the config.');
}
const oldLen = this.particles.length;
const newLen = newParticles.length;
if (oldLen < newLen) {
const difference = newLen - oldLen;
const extra = [];
for (let i = 0; i < difference; i++) {
extra.push(this.particles[i % oldLen].clone());
}
this.particles = this.particles.concat(extra);
}
else if (oldLen > newLen) {
this.particles.splice(0, oldLen - newLen);
}
const len = this.particles.length;
newParticles.sort(() => Math.random() > 0.5 ? 1 : -1);
for (let i = 0; i < len; i++) {
const newParticle = newParticles[i];
this.particles[i].updateNext(newParticle.x, newParticle.y, newParticle.r, newParticle.c);
}
// Be sure to record the time here, because the await expression takes time
this.lastAnimationBeginTime = Date.now();
}
resize() {
if (!(this.root instanceof HTMLCanvasElement)) {
this.canvas.width = this.root.clientWidth;
this.canvas.height = this.root.clientHeight;
}
// render will auto call resize
// resize need to rebuild particle
this.cacheMap.clear();
}
async render(source) {
this.renderer.resize();
// Load particles first
if (source && source !== this._config.source) {
this._config.source = source;
this.particles = await this.generateParticles(this._config.source);
}
if (!this._config.source) {
throw new Error('Render need config source first!');
}
if (!this.particles.length) {
this.particles = await this.generateParticles(this._config.source);
}
const [render] = useRAF(() => {
const costTime = Date.now() - this.lastAnimationBeginTime;
if (this._config.enableContinuousEasing) {
this.particles.forEach(p => this.updateParticleContinuous(p));
}
else {
this.particles.forEach(p => this.updateParticleEase(costTime, p));
}
this.renderer.render(this.particles, this._config);
});
if (!this.isRendering) {
this.isRendering = true;
render();
}
}
async generateParticles(source) {
throw new Error('generateParticles need to be implemented');
}
enableMouseListener() {
const onMousemove = (event) => {
if (!this.mouseParticle) {
this.mouseParticle = Particle.create(-100, -100, 20, [255, 255, 255, 255]);
}
// to update unstoppable particle
const rect = this.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
this.mouseParticle.updateNext(x, y);
this.mouseParticle.update();
};
const onMouseLeave = () => {
this.mouseParticle = null;
};
this.canvas.addEventListener('mousemove', onMousemove);
this.canvas.addEventListener('mouseleave', onMouseLeave);
this.unBindMouseEventCallback = () => {
this.mouseParticle = null;
if (this.canvas) {
this.canvas.removeEventListener('mousemove', onMousemove);
this.canvas.removeEventListener('mouseleave', onMouseLeave);
}
};
}
disableMouseListener() {
this.unBindMouseEventCallback?.();
}
updateParticleEase(costTime, p) {
const x = ease(costTime, this.animationTime, p.preX, p.nextX);
const y = ease(costTime, this.animationTime, p.preY, p.nextY);
p.update(x, y);
}
updateParticleContinuous(p) {
// velocity of the remain movement
let vx = ((p.nextX - p.x) / this._config.moveProportionPerFrame);
let vy = ((p.nextY - p.y) / this._config.moveProportionPerFrame);
if (this._config.showMouseCircle && this.mouseParticle) {
// avoid mouse move
const { x, y, r } = this.mouseParticle;
const dis = distance(x, y, p.x, p.y);
if (dis < r + 10) {
const A = Math.atan2(p.y - y, p.x - x);
let reverseV = 2 * (r / dis);
reverseV = Math.min(1000, reverseV);
reverseV = Math.max(1, reverseV);
const reverseVx = Math.cos(A) * reverseV;
const reverseVy = Math.sin(A) * reverseV;
vx += reverseVx;
vy += reverseVy;
}
}
// apply change in this frame
p.update(p.x + vx, p.y + vy);
}
}
class ImageParticle extends ParticleEffect {
autoFit = false;
imageCache = new Map();
constructor(root, config) {
super(root, config);
this.updateConfig(config);
}
updateConfig(config) {
this.autoFit = config.autoFit ?? this.autoFit;
}
transitionTo(newSource, time = 2000, config = {}) {
this.updateConfig(config);
return super.transitionTo(newSource, time, config);
}
async generateParticles(source) {
const old = this.cacheMap.get(source);
if (old && shallowEqual(old.config, this._config)) {
return old.particles;
}
const tempCanvas = document.createElement('canvas');
const { width, height } = this.canvas;
tempCanvas.width = width;
tempCanvas.height = height;
let image;
const oldImage = this.imageCache.get(source);
if (oldImage) {
image = oldImage;
}
else {
image = new Image();
image.crossOrigin = 'anonymous';
const loadPromise = new Promise((resolve, reject) => {
image.onload = (ev) => {
resolve(ev);
};
image.onerror = (err) => {
reject(err);
};
});
image.src = source;
await loadPromise;
}
// Need to grayscale, but not yet
const { width: imageWidth, height: imageHeight } = image;
const config = this._config;
let drawWidth = imageWidth;
let drawHeight = imageHeight;
let offsetX = config.offsetX;
let offsetY = config.offsetY;
if (this.autoFit) {
const scaleW = width / imageWidth;
const scaleH = height / imageHeight;
const scale = Math.min(scaleW, scaleH);
const scaledWidth = Math.floor(imageWidth * scale);
const scaledHeight = Math.floor(imageHeight * scale);
offsetX = Math.floor(Math.abs(width - scaledWidth) / 2);
offsetY = Math.floor(Math.abs(height - scaledHeight) / 2);
drawWidth = scaledWidth;
drawHeight = scaledHeight;
}
const ctx = tempCanvas.getContext('2d');
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
const tempImageData = ctx.getImageData(0, 0, width, height);
const newParticles = Particle.from(tempImageData, config.particleGap, config.particleRadius, config.pixelFilter);
if (!this._config.disableCache) {
this.cacheMap.set(source, {
config: shallowClone(config),
particles: newParticles.map(p => p.clone())
});
this.imageCache.set(source, image);
}
return newParticles;
}
}
class TextParticle extends ParticleEffect {
font = 'bold 60px Arial';
textAlign = 'center';
constructor(root, config) {
super(root, config);
this.updateConfig(config);
}
updateConfig(config) {
this.font = config.font ?? this.font;
this.textAlign = config.textAlign ?? this.textAlign;
}
transitionTo(newSource, time = 2000, config = {}) {
this.updateConfig(config);
return super.transitionTo(newSource, time, config);
}
async generateParticles(source) {
const old = this.cacheMap.get(source);
if (old && shallowEqual(old.config, this._config)) {
return old.particles;
}
const tempCanvas = document.createElement('canvas');
const { width, height } = this.canvas;
tempCanvas.width = width;
tempCanvas.height = height;
// Need to grayscale, but not yet
const ctx = tempCanvas.getContext('2d');
const config = this._config;
ctx.fillStyle = config.color || '#FAF0E6';
ctx.font = this.font;
ctx.textAlign = this.textAlign;
ctx.textBaseline = 'middle';
await document.fonts.load(ctx.font);
let x = 0;
let y = height / 2;
if (this.textAlign === 'center') {
x = width / 2;
}
else if (this.textAlign === 'right') {
x = width;
}
else {
x = 0;
}
ctx.fillText(source, Math.floor(x), Math.floor(y));
const tempImageData = ctx.getImageData(0, 0, width, height);
const newParticles = Particle.from(tempImageData, config.particleGap, config.particleRadius, config.pixelFilter);
if (!this._config.disableCache) {
this.cacheMap.set(source, {
config: shallowClone(config),
particles: newParticles.map(p => p.clone())
});
}
return newParticles;
}
}
exports.CanvasRenderer = CanvasRenderer;
exports.ImageParticle = ImageParticle;
exports.Particle = Particle;
exports.ParticleEffect = ParticleEffect;
exports.Renderer = Renderer;
exports.TextParticle = TextParticle;
exports.WebGLRenderer = WebGLRenderer;
exports.createProgram = createProgram;
exports.createShader = createShader;
exports.distance = distance;
exports.ease = ease;
exports.invariant = invariant;
exports.isApproximateEqual = isApproximateEqual;
exports.setAttributeBuffer = setAttributeBuffer;
exports.shallowClone = shallowClone;
exports.shallowEqual = shallowEqual;
exports.transformHexStrToRGBA = transformHexStrToRGBA;
exports.useRAF = useRAF;
//# sourceMappingURL=main.js.map