@craftedstudios/avatars 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -2
- package/package.json +30 -4
- package/src/avatar.js +55 -0
- package/src/color.js +234 -0
- package/src/cursor.js +137 -0
- package/src/element.js +37 -0
- package/src/goo.js +70 -0
- package/src/index.js +7 -0
- package/src/names.js +44 -0
- package/src/options.js +88 -0
- package/src/react.js +133 -0
- package/src/renderer.js +403 -0
- package/src/seed.js +67 -0
- package/src/shader.js +298 -0
- package/src/still.js +60 -0
- package/types/index.d.ts +114 -0
- package/types/react.d.ts +27 -0
package/src/renderer.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// One WebGL context for the whole page, shared by every avatar.
|
|
2
|
+
//
|
|
3
|
+
// Browsers allow roughly 16 WebGL contexts per page, so avatars can't each
|
|
4
|
+
// have one. Each avatar is instead a small 2D canvas in the DOM, so it
|
|
5
|
+
// scrolls, clips and layers like an image. Each frame the renderer draws every
|
|
6
|
+
// avatar that needs drawing into tiles of one offscreen WebGL canvas, then
|
|
7
|
+
// copies each tile into its avatar's canvas in the same task.
|
|
8
|
+
//
|
|
9
|
+
// What gets drawn:
|
|
10
|
+
// - avatars on screen whose effect is animating, up to 32; past that the
|
|
11
|
+
// rest hold a still frame
|
|
12
|
+
// - any avatar mid-lean or mid-entrance, at full frame rate
|
|
13
|
+
// - any avatar that changed (new, updated, resized) once
|
|
14
|
+
// Animating avatars redraw at about 20fps while nothing is leaning; the
|
|
15
|
+
// effects drift slowly enough that it doesn't show. The loop stops entirely
|
|
16
|
+
// when the tab is hidden or nothing on screen needs drawing.
|
|
17
|
+
|
|
18
|
+
import { VERT, FRAG } from './shader.js';
|
|
19
|
+
import { parseColor as parseCss, shaderColors } from './color.js';
|
|
20
|
+
import * as goo from './goo.js';
|
|
21
|
+
|
|
22
|
+
export const MAX_LIVE = 32;
|
|
23
|
+
const ATLAS = 2048;
|
|
24
|
+
const IDLE_MS = 50;
|
|
25
|
+
const ENTER_S = 0.4;
|
|
26
|
+
const SHAPE = { circle: 0, squircle: 1, square: 2 };
|
|
27
|
+
const MODE = { follow: 1, repel: 2, distort: 3, pulse: 4, orbit: 5, turbulence: 6 };
|
|
28
|
+
// Pattern interactions that move with time keep redrawing at full rate while
|
|
29
|
+
// the pointer is acting on them.
|
|
30
|
+
const TIMED = new Set(['distort', 'pulse', 'turbulence']);
|
|
31
|
+
// Scenery's interactions reach about half the pattern from the pointer, so
|
|
32
|
+
// past 1.4 radii from the center there's nothing left to move.
|
|
33
|
+
const WARP_RANGE = 1.4;
|
|
34
|
+
// The shader's branch for each effect. Kept apart from the order in seed.js,
|
|
35
|
+
// which decides what `auto` picks and must not move after 1.0.
|
|
36
|
+
const FX = { linear: 0, thermal: 1, topography: 2, plasma: 3, radial: 4 };
|
|
37
|
+
|
|
38
|
+
let shared;
|
|
39
|
+
|
|
40
|
+
// The page's renderer, created on first use. Null without WebGL or a DOM.
|
|
41
|
+
export function getRenderer() {
|
|
42
|
+
if (shared !== undefined) return shared;
|
|
43
|
+
shared = typeof document === 'undefined' ? null : new Renderer();
|
|
44
|
+
if (shared && !shared.gl) shared = null;
|
|
45
|
+
return shared;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class Renderer {
|
|
49
|
+
constructor() {
|
|
50
|
+
this.canvas = document.createElement('canvas');
|
|
51
|
+
this.items = new Set();
|
|
52
|
+
this.visible = new Set();
|
|
53
|
+
this.raf = 0;
|
|
54
|
+
this.last = 0;
|
|
55
|
+
this.lastIdle = -Infinity;
|
|
56
|
+
this.mouse = null;
|
|
57
|
+
this.lost = false;
|
|
58
|
+
this.stats = { contexts: 0, frameMs: 0, drawn: 0, live: 0 };
|
|
59
|
+
if (!this.setup()) { this.gl = null; return; }
|
|
60
|
+
this.stats.contexts = 1;
|
|
61
|
+
|
|
62
|
+
this.canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); this.lost = true; });
|
|
63
|
+
this.canvas.addEventListener('webglcontextrestored', () => {
|
|
64
|
+
this.lost = false;
|
|
65
|
+
if (!this.setup()) return;
|
|
66
|
+
for (const it of this.items) it.dirty = true;
|
|
67
|
+
this.wake();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
this.reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
|
|
71
|
+
this.reduced = this.reducedQuery.matches;
|
|
72
|
+
this.reducedQuery.addEventListener?.('change', (e) => {
|
|
73
|
+
this.reduced = e.matches;
|
|
74
|
+
for (const it of this.items) { it.dirty = true; it.enter = 1; Object.assign(it.drop, { x: 0, y: 0, vx: 0, vy: 0 }); }
|
|
75
|
+
this.wake();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this.io = new IntersectionObserver((entries) => {
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
const it = e.target.__craftedAvatar;
|
|
81
|
+
if (!it) continue;
|
|
82
|
+
if (e.isIntersecting) this.visible.add(it); else this.visible.delete(it);
|
|
83
|
+
}
|
|
84
|
+
this.wake();
|
|
85
|
+
}, { rootMargin: '64px' });
|
|
86
|
+
|
|
87
|
+
addEventListener('pointermove', (e) => {
|
|
88
|
+
if (e.pointerType !== 'mouse') { this.mouse = null; return; }
|
|
89
|
+
this.mouse = [e.clientX, e.clientY];
|
|
90
|
+
this.wake();
|
|
91
|
+
}, { passive: true });
|
|
92
|
+
document.documentElement.addEventListener('mouseleave', () => { this.mouse = null; this.wake(); });
|
|
93
|
+
document.addEventListener('visibilitychange', () => this.wake());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setup() {
|
|
97
|
+
const gl = this.canvas.getContext('webgl', {
|
|
98
|
+
alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false,
|
|
99
|
+
preserveDrawingBuffer: false, powerPreference: 'low-power',
|
|
100
|
+
});
|
|
101
|
+
if (!gl) return false;
|
|
102
|
+
const sh = (type, src) => {
|
|
103
|
+
const s = gl.createShader(type);
|
|
104
|
+
gl.shaderSource(s, src);
|
|
105
|
+
gl.compileShader(s);
|
|
106
|
+
return gl.getShaderParameter(s, gl.COMPILE_STATUS) || gl.isContextLost() ? s : null;
|
|
107
|
+
};
|
|
108
|
+
const vs = sh(gl.VERTEX_SHADER, VERT), fs = sh(gl.FRAGMENT_SHADER, FRAG);
|
|
109
|
+
if (!vs || !fs) return false;
|
|
110
|
+
const prog = gl.createProgram();
|
|
111
|
+
gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog);
|
|
112
|
+
if (!gl.getProgramParameter(prog, gl.LINK_STATUS) && !gl.isContextLost()) return false;
|
|
113
|
+
gl.useProgram(prog);
|
|
114
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
|
115
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
116
|
+
const loc = gl.getAttribLocation(prog, 'p');
|
|
117
|
+
gl.enableVertexAttribArray(loc);
|
|
118
|
+
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
|
119
|
+
gl.disable(gl.BLEND);
|
|
120
|
+
this.max = Math.min(ATLAS, ...gl.getParameter(gl.MAX_VIEWPORT_DIMS));
|
|
121
|
+
this.U = {};
|
|
122
|
+
for (const n of ['uTile', 'uC', 'uE', 'uR', 'uDropR', 'uGoo', 'uBulge', 'uShape', 'uRing', 'uRingColor', 'uIn', 'uFx', 'uCol1', 'uCol2', 'uCol3', 'uCol4', 'uT', 'uGrain', 'uGrainCell', 'uAngle', 'uLayout', 'uWarp', 'uScale', 'uMouse', 'uMouseAct', 'uMode'])
|
|
123
|
+
this.U[n] = gl.getUniformLocation(prog, n);
|
|
124
|
+
this.gl = gl;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
add(it) {
|
|
129
|
+
this.items.add(it);
|
|
130
|
+
it.host.__craftedAvatar = it;
|
|
131
|
+
this.io.observe(it.host);
|
|
132
|
+
this.wake();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
remove(it) {
|
|
136
|
+
this.items.delete(it);
|
|
137
|
+
this.visible.delete(it);
|
|
138
|
+
this.io.unobserve(it.host);
|
|
139
|
+
delete it.host.__craftedAvatar;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
wake() {
|
|
143
|
+
if (!this.raf && document.visibilityState === 'visible') this.raf = requestAnimationFrame((t) => this.frame(t));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
frame(now) {
|
|
147
|
+
this.raf = 0;
|
|
148
|
+
const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 1 / 60;
|
|
149
|
+
this.last = now;
|
|
150
|
+
if (this.lost || document.visibilityState !== 'visible') { this.last = 0; return; }
|
|
151
|
+
const start = performance.now();
|
|
152
|
+
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
|
|
153
|
+
const animate = !this.reduced;
|
|
154
|
+
|
|
155
|
+
// Leans and entrances first: they decide whether this is a full-rate frame.
|
|
156
|
+
const draw = new Set();
|
|
157
|
+
const live = [];
|
|
158
|
+
let busy = false;
|
|
159
|
+
// Only the avatar nearest the pointer reaches for it. When several are in
|
|
160
|
+
// range, as on a wall or in a stack, all of them dripping toward one
|
|
161
|
+
// cursor reads as noise.
|
|
162
|
+
let nearest = null, nearestK = Infinity, ndx = 0, ndy = 0;
|
|
163
|
+
if (this.mouse && animate) {
|
|
164
|
+
for (const it of this.visible) {
|
|
165
|
+
if (it.o.interaction !== 'goo' || !it.host.isConnected) continue;
|
|
166
|
+
const b = it.host.getBoundingClientRect();
|
|
167
|
+
const dx = this.mouse[0] - (b.left + b.width / 2), dy = this.mouse[1] - (b.top + b.height / 2);
|
|
168
|
+
const k = Math.hypot(dx, dy) / (it.o.size / 2);
|
|
169
|
+
if (k < it.goo.range && k < nearestK) { nearest = it; nearestK = k; ndx = dx; ndy = dy; }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const it of this.visible) {
|
|
173
|
+
if (!it.host.isConnected) continue;
|
|
174
|
+
if (it.dpr !== dpr) it.resize(dpr);
|
|
175
|
+
const o = it.o, r = o.size / 2;
|
|
176
|
+
const [tx, ty] = it === nearest ? goo.target(ndx, ndy, r, it.goo) : [0, 0];
|
|
177
|
+
const d = it.drop;
|
|
178
|
+
if (!goo.settled(d, tx, ty, r)) { goo.step(d, tx, ty, dt, it.goo); it.moving = true; }
|
|
179
|
+
else if (it.moving) { d.x = tx; d.y = ty; d.vx = d.vy = 0; it.moving = false; draw.add(it); }
|
|
180
|
+
if (MODE[o.interaction] && this.warp(it, dt, animate)) it.moving = true;
|
|
181
|
+
if (it.enter < 1) { it.enter = animate ? Math.min(1, it.enter + dt / ENTER_S) : 1; it.moving = true; }
|
|
182
|
+
if (it.moving) { busy = true; draw.add(it); }
|
|
183
|
+
if (it.dirty) draw.add(it);
|
|
184
|
+
if (animate && o.motion !== 'still') live.push(it);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Past the cap, the largest keep moving and the rest hold their frame.
|
|
188
|
+
if (live.length > MAX_LIVE) live.sort((a, b) => (b.moving - a.moving) || (b.o.size - a.o.size) || (a.order - b.order));
|
|
189
|
+
live.length = Math.min(live.length, MAX_LIVE);
|
|
190
|
+
// The ones leaning or entering are already drawn every frame; the rest
|
|
191
|
+
// keep to the idle rate.
|
|
192
|
+
if (live.length && now - this.lastIdle >= IDLE_MS) {
|
|
193
|
+
this.lastIdle = now;
|
|
194
|
+
for (const it of live) draw.add(it);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (draw.size) this.render([...draw], now / 1000);
|
|
198
|
+
this.stats.frameMs = performance.now() - start;
|
|
199
|
+
this.stats.drawn = draw.size;
|
|
200
|
+
this.stats.live = live.length;
|
|
201
|
+
|
|
202
|
+
if (busy || live.length || [...this.visible].some((it) => it.dirty)) this.wake();
|
|
203
|
+
else this.last = 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Eases a pattern interaction in while the pointer is over or near the
|
|
207
|
+
// avatar and out when it leaves, and follows the pointer smoothly rather
|
|
208
|
+
// than snapping to it. Returns whether it still needs drawing.
|
|
209
|
+
warp(it, dt, animate) {
|
|
210
|
+
const w = it.warp, r = it.o.size / 2;
|
|
211
|
+
let target = 0, dx = w.x, dy = w.y;
|
|
212
|
+
if (this.mouse && animate) {
|
|
213
|
+
const b = it.host.getBoundingClientRect();
|
|
214
|
+
const x = this.mouse[0] - (b.left + b.width / 2), y = this.mouse[1] - (b.top + b.height / 2);
|
|
215
|
+
if (Math.hypot(x, y) < WARP_RANGE * r) { target = 1; dx = x; dy = y; }
|
|
216
|
+
}
|
|
217
|
+
const follow = w.act < 0.01 ? 1 : 1 - Math.exp(-dt * 14);
|
|
218
|
+
const mx = dx - w.x, my = dy - w.y;
|
|
219
|
+
w.x += mx * follow;
|
|
220
|
+
w.y += my * follow;
|
|
221
|
+
w.act += (target - w.act) * (1 - Math.exp(-dt * (target ? 8 : 4)));
|
|
222
|
+
if (!target && w.act < 0.003) w.act = 0;
|
|
223
|
+
const easing = Math.abs(target - w.act) > 0.003 || Math.abs(mx) + Math.abs(my) > 0.05;
|
|
224
|
+
return easing || (w.act > 0 && TIMED.has(it.o.interaction));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Draws items into tiles of the shared canvas, a shelf at a time, and copies
|
|
228
|
+
// each tile out before the canvas is reused.
|
|
229
|
+
render(items, seconds) {
|
|
230
|
+
items.sort((a, b) => b.dev - a.dev);
|
|
231
|
+
let batch = [], x = 0, y = 0, shelf = 0, width = 0;
|
|
232
|
+
const flush = () => {
|
|
233
|
+
if (!batch.length) return;
|
|
234
|
+
this.drawBatch(batch, width, y + shelf, seconds);
|
|
235
|
+
batch = []; x = 0; y = 0; shelf = 0; width = 0;
|
|
236
|
+
};
|
|
237
|
+
for (const it of items) {
|
|
238
|
+
const s = Math.min(it.dev, this.max);
|
|
239
|
+
if (x + s > this.max) { x = 0; y += shelf; shelf = 0; }
|
|
240
|
+
if (y + s > this.max) flush();
|
|
241
|
+
batch.push({ it, x, y, s });
|
|
242
|
+
x += s; width = Math.max(width, x); shelf = Math.max(shelf, s);
|
|
243
|
+
}
|
|
244
|
+
flush();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
ensure(w, h) {
|
|
248
|
+
const c = this.canvas, step = 256;
|
|
249
|
+
const W = Math.min(this.max, Math.max(c.width, Math.ceil(w / step) * step));
|
|
250
|
+
const H = Math.min(this.max, Math.max(c.height, Math.ceil(h / step) * step));
|
|
251
|
+
if (c.width !== W || c.height !== H) { c.width = W; c.height = H; }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
drawBatch(batch, w, h, seconds) {
|
|
255
|
+
const { gl, U } = this;
|
|
256
|
+
this.ensure(w, h);
|
|
257
|
+
const H = this.canvas.height;
|
|
258
|
+
for (const { it, x, y, s } of batch) {
|
|
259
|
+
const p = it.params(seconds, s);
|
|
260
|
+
gl.viewport(x, H - y - s, s, s);
|
|
261
|
+
gl.uniform4f(U.uTile, x, H - y - s, s, s);
|
|
262
|
+
gl.uniform2f(U.uC, p.cx, p.cy);
|
|
263
|
+
gl.uniform2f(U.uE, p.ex, p.ey);
|
|
264
|
+
gl.uniform1f(U.uR, p.r);
|
|
265
|
+
gl.uniform1f(U.uDropR, p.dropR);
|
|
266
|
+
gl.uniform1f(U.uGoo, p.goo);
|
|
267
|
+
gl.uniform1f(U.uBulge, p.bulge);
|
|
268
|
+
gl.uniform1f(U.uWarp, p.warp);
|
|
269
|
+
gl.uniform2f(U.uMouse, p.mx, p.my);
|
|
270
|
+
gl.uniform1f(U.uMouseAct, p.mouseAct);
|
|
271
|
+
gl.uniform1f(U.uMode, p.mode);
|
|
272
|
+
gl.uniform1f(U.uScale, p.scale);
|
|
273
|
+
gl.uniform1f(U.uShape, SHAPE[p.shape]);
|
|
274
|
+
gl.uniform1f(U.uRing, p.ring);
|
|
275
|
+
gl.uniform3fv(U.uRingColor, p.ringColor);
|
|
276
|
+
gl.uniform1f(U.uIn, p.enter);
|
|
277
|
+
gl.uniform1f(U.uFx, FX[p.effect]);
|
|
278
|
+
gl.uniform1f(U.uAngle, p.angle);
|
|
279
|
+
gl.uniform4fv(U.uLayout, p.layout);
|
|
280
|
+
p.colors.forEach((c, i) => gl.uniform3fv(U[`uCol${i + 1}`], c));
|
|
281
|
+
gl.uniform1f(U.uT, p.t);
|
|
282
|
+
gl.uniform1f(U.uGrain, p.grain);
|
|
283
|
+
gl.uniform1f(U.uGrainCell, Math.max(1, (2 * p.r) / 540));
|
|
284
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
285
|
+
}
|
|
286
|
+
for (const { it, x, y, s } of batch) it.blit(this.canvas, x, y, s);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// What an avatar needs from the renderer: its draw parameters, and somewhere
|
|
291
|
+
// to put the pixels. `Item` is the live one in the DOM; still images build a
|
|
292
|
+
// throwaway target of their own.
|
|
293
|
+
export class Item {
|
|
294
|
+
constructor(host, o, { enter = false } = {}) {
|
|
295
|
+
this.host = host;
|
|
296
|
+
this.order = Item.count = (Item.count || 0) + 1;
|
|
297
|
+
this.drop = { x: 0, y: 0, vx: 0, vy: 0 };
|
|
298
|
+
this.warp = { x: 0, y: 0, act: 0 };
|
|
299
|
+
this.enter = enter ? 0 : 1;
|
|
300
|
+
this.moving = false;
|
|
301
|
+
this.dirty = true;
|
|
302
|
+
this.canvas = document.createElement('canvas');
|
|
303
|
+
this.canvas.setAttribute('aria-hidden', 'true');
|
|
304
|
+
this.ctx = this.canvas.getContext('2d');
|
|
305
|
+
this.set(o);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
set(o) {
|
|
309
|
+
const prev = this.o;
|
|
310
|
+
this.o = o;
|
|
311
|
+
this.colors = shaderColors(o.palette);
|
|
312
|
+
this.goo = goo.params(o.stretch, o.thickness);
|
|
313
|
+
this.ringColor = parseColor(o.ringColor);
|
|
314
|
+
// A new interaction starts from rest, not from wherever the last one was.
|
|
315
|
+
if (prev && prev.interaction !== o.interaction) {
|
|
316
|
+
Object.assign(this.drop, { x: 0, y: 0, vx: 0, vy: 0 });
|
|
317
|
+
Object.assign(this.warp, { x: 0, y: 0, act: 0 });
|
|
318
|
+
}
|
|
319
|
+
if (!prev || prev.size !== o.size || (prev.interaction === 'goo') !== (o.interaction === 'goo')) this.resize(this.dpr || Math.min(globalThis.devicePixelRatio || 1, 2));
|
|
320
|
+
this.dirty = true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// With goo, the canvas reaches past the box on every side to leave room for
|
|
324
|
+
// the lean. Every other interaction stays inside the outline.
|
|
325
|
+
resize(dpr) {
|
|
326
|
+
this.dpr = dpr;
|
|
327
|
+
const pad = this.o.interaction === 'goo' ? goo.PAD * this.o.size / 2 : 0;
|
|
328
|
+
const css = this.o.size + 2 * pad;
|
|
329
|
+
this.pad = pad;
|
|
330
|
+
this.dev = Math.max(1, Math.round(css * dpr));
|
|
331
|
+
this.canvas.width = this.canvas.height = this.dev;
|
|
332
|
+
Object.assign(this.canvas.style, {
|
|
333
|
+
position: 'absolute', left: `${-pad}px`, top: `${-pad}px`, width: `${css}px`, height: `${css}px`,
|
|
334
|
+
pointerEvents: 'none', display: 'block',
|
|
335
|
+
});
|
|
336
|
+
this.dirty = true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Each avatar keeps its own clock, starting at zero, so a speed change picks
|
|
340
|
+
// up from where the animation is rather than jumping to where the new speed
|
|
341
|
+
// would have put it. Paused, the clock stops where it is, so turning motion
|
|
342
|
+
// off holds the frame on screen and turning it on resumes from there.
|
|
343
|
+
clock(seconds, running) {
|
|
344
|
+
if (running && this.running && this.lastSeconds !== undefined) this.time += Math.max(0, seconds - this.lastSeconds) * this.o.speed;
|
|
345
|
+
this.time ??= 0;
|
|
346
|
+
this.running = running;
|
|
347
|
+
this.lastSeconds = seconds;
|
|
348
|
+
return this.time;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
params(seconds, s) {
|
|
352
|
+
const o = this.o, k = s / this.dev;
|
|
353
|
+
const r = (o.size / 2) * this.dpr * k;
|
|
354
|
+
const c = (this.pad + o.size / 2) * this.dpr * k;
|
|
355
|
+
const ex = this.drop.x * this.dpr * k, ey = this.drop.y * this.dpr * k;
|
|
356
|
+
const still = o.motion === 'still' || shared?.reduced;
|
|
357
|
+
return {
|
|
358
|
+
cx: c, cy: c, ex: c + ex, ey: c + ey, r,
|
|
359
|
+
dropR: goo.dropRadius(this.drop, o.size / 2, this.goo) * this.dpr * k,
|
|
360
|
+
goo: this.goo.goo, bulge: this.goo.bulge, warp: o.distortion * 2, scale: o.scale,
|
|
361
|
+
mx: c + this.warp.x * this.dpr * k, my: c + this.warp.y * this.dpr * k,
|
|
362
|
+
mouseAct: MODE[o.interaction] ? this.warp.act : 0, mode: MODE[o.interaction] || 0,
|
|
363
|
+
shape: o.shape, ring: o.ring * this.dpr * k, ringColor: this.ringColor,
|
|
364
|
+
enter: easeOut(this.enter), effect: o.traits.effect, colors: this.colors,
|
|
365
|
+
t: this.clock(seconds, !still) + o.traits.phase, grain: o.grain, angle: angleOf(o.traits), layout: o.traits.layout,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
blit(src, x, y, s) {
|
|
370
|
+
const ctx = this.ctx;
|
|
371
|
+
ctx.globalCompositeOperation = 'copy';
|
|
372
|
+
ctx.drawImage(src, x, y, s, s, 0, 0, this.dev, this.dev);
|
|
373
|
+
this.dirty = false;
|
|
374
|
+
// The live avatar has its first frame; the gradient underneath is no
|
|
375
|
+
// longer needed and would show through during the lean.
|
|
376
|
+
if (!this.shown) { this.shown = true; this.hideFallback(); }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
hideFallback() {
|
|
380
|
+
this.host.style.background = 'none';
|
|
381
|
+
this.host.style.boxShadow = 'none';
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// A direction per seed, from its phase, spread by the golden angle.
|
|
386
|
+
export const angleOf = (traits) => (traits.phase * 2.39996) % (Math.PI * 2);
|
|
387
|
+
|
|
388
|
+
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
|
|
389
|
+
|
|
390
|
+
let probe;
|
|
391
|
+
export function parseColor(c) {
|
|
392
|
+
const known = parseCss(c);
|
|
393
|
+
if (known) return known;
|
|
394
|
+
// Anything else CSS understands, such as a named color, read back through a
|
|
395
|
+
// 1px canvas.
|
|
396
|
+
probe ||= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
397
|
+
probe.clearRect(0, 0, 1, 1);
|
|
398
|
+
probe.fillStyle = '#fff';
|
|
399
|
+
probe.fillStyle = c;
|
|
400
|
+
probe.fillRect(0, 0, 1, 1);
|
|
401
|
+
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
|
|
402
|
+
return [r / 255, g / 255, b / 255];
|
|
403
|
+
}
|
package/src/seed.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Everything an avatar looks like comes from one 32-bit hash of its seed.
|
|
2
|
+
// Once 1.0 ships this mapping is frozen: changing any line here changes every
|
|
3
|
+
// user's avatar, so it can only change in a new major version. The snapshot in
|
|
4
|
+
// test/seed.test.js fails if it moves.
|
|
5
|
+
|
|
6
|
+
// `auto` picks from these five, and only ever these five. Effects added later
|
|
7
|
+
// are reachable only as an explicit `effect`, so they never change an existing
|
|
8
|
+
// avatar.
|
|
9
|
+
export const AUTO_EFFECTS = ['linear', 'thermal', 'topography', 'plasma', 'radial'];
|
|
10
|
+
export const EFFECTS = [...AUTO_EFFECTS];
|
|
11
|
+
|
|
12
|
+
import { effectColors, hex, scramble } from './color.js';
|
|
13
|
+
|
|
14
|
+
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
|
15
|
+
|
|
16
|
+
export function fnv1a32(str) {
|
|
17
|
+
const bytes = encoder ? encoder.encode(str) : utf8(str);
|
|
18
|
+
let h = 0x811c9dc5;
|
|
19
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
20
|
+
h ^= bytes[i];
|
|
21
|
+
h = Math.imul(h, 0x01000193);
|
|
22
|
+
}
|
|
23
|
+
return h >>> 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// For runtimes without TextEncoder. Same bytes, so the same hash.
|
|
27
|
+
function utf8(str) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const ch of str) {
|
|
30
|
+
let c = ch.codePointAt(0);
|
|
31
|
+
if (c < 0x80) out.push(c);
|
|
32
|
+
else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
|
|
33
|
+
else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
34
|
+
else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Where in its pattern a seed sits: an offset of up to a fifth of the avatar
|
|
40
|
+
// each way, a turn, and a zoom of 0.85 to 1.2, so two seeds of one effect
|
|
41
|
+
// don't share a layout.
|
|
42
|
+
function layoutOf(w) {
|
|
43
|
+
const unit = (at) => ((w >>> at) & 255) / 255;
|
|
44
|
+
return [(unit(0) - 0.5) * 0.4, (unit(8) - 0.5) * 0.4, unit(16) * Math.PI * 2, 0.85 + unit(24) * 0.35];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The hash is scrambled first: FNV alone barely moves its upper bits for
|
|
48
|
+
// seeds that differ only near the start, so emails on the same domain landed
|
|
49
|
+
// on nearly the same colors.
|
|
50
|
+
//
|
|
51
|
+
// What an avatar is: its effect, its hue, its tone (the rest of its colors,
|
|
52
|
+
// see family() in color.js), its layout, and its phase (seconds added to the
|
|
53
|
+
// clock so no two avatars move in step), and the colors the seed gives that
|
|
54
|
+
// effect, in the effect's own roles. An `effect` override wins.
|
|
55
|
+
export function traits(seed, { effect = 'auto' } = {}) {
|
|
56
|
+
const h = scramble(fnv1a32(String(seed ?? '')));
|
|
57
|
+
return {
|
|
58
|
+
effect: EFFECTS.includes(effect) ? effect : AUTO_EFFECTS[h % 5],
|
|
59
|
+
hue: (h >>> 3) % 360,
|
|
60
|
+
tone: scramble(h ^ 0x9e3779b9),
|
|
61
|
+
layout: layoutOf(scramble(h ^ 0x27d4eb2f)),
|
|
62
|
+
phase: ((h >>> 12) % 997) * 0.1,
|
|
63
|
+
get colors() {
|
|
64
|
+
return effectColors(this.effect, this.hue, undefined, this.tone).map(hex);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|