libraries-dev-svelte 0.1.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 +48 -0
- package/dist/bot-avatars/BotAvatar.svelte +368 -0
- package/dist/bot-avatars/BotAvatar.svelte.d.ts +4 -0
- package/dist/bot-avatars/color.d.ts +14 -0
- package/dist/bot-avatars/color.js +89 -0
- package/dist/bot-avatars/draw.d.ts +55 -0
- package/dist/bot-avatars/draw.js +495 -0
- package/dist/bot-avatars/engine.d.ts +138 -0
- package/dist/bot-avatars/engine.js +690 -0
- package/dist/bot-avatars/path-cache.d.ts +2 -0
- package/dist/bot-avatars/path-cache.js +10 -0
- package/dist/bot-avatars/plastic.d.ts +80 -0
- package/dist/bot-avatars/plastic.js +839 -0
- package/dist/bot-avatars/presets.d.ts +12 -0
- package/dist/bot-avatars/presets.js +34 -0
- package/dist/bot-avatars/shapes.d.ts +4 -0
- package/dist/bot-avatars/shapes.js +27 -0
- package/dist/bot-avatars/ticker.d.ts +8 -0
- package/dist/bot-avatars/ticker.js +52 -0
- package/dist/bot-avatars/types.d.ts +163 -0
- package/dist/bot-avatars/types.js +1 -0
- package/dist/gooey-radial-menu/GooeyRadialMenu.svelte +747 -0
- package/dist/gooey-radial-menu/GooeyRadialMenu.svelte.d.ts +7 -0
- package/dist/gooey-radial-menu/motion.d.ts +2 -0
- package/dist/gooey-radial-menu/motion.js +34 -0
- package/dist/gooey-radial-menu/shadow.d.ts +10 -0
- package/dist/gooey-radial-menu/shadow.js +54 -0
- package/dist/gooey-radial-menu/types.d.ts +28 -0
- package/dist/gooey-radial-menu/types.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +12 -0
- package/package.json +46 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
/* plastic.ts — the 'plastic' shading: a glossy toy-plastic material for
|
|
2
|
+
the front cap, lit per texel.
|
|
3
|
+
|
|
4
|
+
Once per outline (× texture size × depth) the outline is rasterised into
|
|
5
|
+
a small grid and turned into a FORM: a signed distance, a pillow height
|
|
6
|
+
field (the torsion function of the outline, so lobes are domes, rays
|
|
7
|
+
are tubes and there is no crease), its normals, and baked ambient
|
|
8
|
+
occlusion. Per frame the light and the view are rotated into the cap's
|
|
9
|
+
own frame; a small MATCAP (a lit sphere: one colour per normal) is
|
|
10
|
+
evaluated with the full material — only when the light or the view has
|
|
11
|
+
moved — and the cap's texels are a bilinear lookup into it, composited
|
|
12
|
+
through the cap's affine under a vector clip. The side copies take
|
|
13
|
+
conic gradients sampled from the same matcap, so sides and cap agree.
|
|
14
|
+
|
|
15
|
+
Everything is canvas 2D: Path2D fills, gradients, ImageData, one small
|
|
16
|
+
scratch canvas per texture size. No ctx.filter, no WebGL. */
|
|
17
|
+
import { parseColor } from './color';
|
|
18
|
+
/* ── constants ─────────────────────────────────────────────────────── */
|
|
19
|
+
/** The texture covers design units [-PAD, 100 + PAD]. */
|
|
20
|
+
export const PAD = 3;
|
|
21
|
+
export const SPAN = 100 + 2 * PAD;
|
|
22
|
+
/** Matcap side: (nx, ny) ∈ [-1, 1]² in M × M cells. */
|
|
23
|
+
const M = 64;
|
|
24
|
+
/** the matcap's side, in cells */
|
|
25
|
+
export const MATCAP_SIZE = M;
|
|
26
|
+
const MM = M * M;
|
|
27
|
+
/** Stops on the conic side gradients. */
|
|
28
|
+
const CONIC_STOPS = 24;
|
|
29
|
+
const INF = 1e12;
|
|
30
|
+
/** Light elevation off the screen plane. */
|
|
31
|
+
const EL = (48 * Math.PI) / 180;
|
|
32
|
+
const E_XY = Math.cos(EL), E_Z = Math.sin(EL);
|
|
33
|
+
const SLICES = 17;
|
|
34
|
+
const profile = (z, cap) => cap + (1 - cap) * Math.sqrt(Math.max(0, 1 - z * z));
|
|
35
|
+
/* Felzenszwalb–Huttenlocher 1-D squared distance transform; s gets the
|
|
36
|
+
index of the nearest site. */
|
|
37
|
+
function edt1d(f, n, d, s, v, z) {
|
|
38
|
+
let k = 0;
|
|
39
|
+
v[0] = 0;
|
|
40
|
+
z[0] = -1e30;
|
|
41
|
+
z[1] = 1e30;
|
|
42
|
+
for (let q = 1; q < n; q++) {
|
|
43
|
+
let x = 0;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const vk = v[k];
|
|
46
|
+
x = (f[q] + q * q - f[vk] - vk * vk) / (2 * (q - vk));
|
|
47
|
+
if (x > z[k])
|
|
48
|
+
break;
|
|
49
|
+
k--;
|
|
50
|
+
}
|
|
51
|
+
k++;
|
|
52
|
+
v[k] = q;
|
|
53
|
+
z[k] = x;
|
|
54
|
+
z[k + 1] = 1e30;
|
|
55
|
+
}
|
|
56
|
+
k = 0;
|
|
57
|
+
for (let q = 0; q < n; q++) {
|
|
58
|
+
while (z[k + 1] < q)
|
|
59
|
+
k++;
|
|
60
|
+
const vk = v[k];
|
|
61
|
+
d[q] = (q - vk) * (q - vk) + f[vk];
|
|
62
|
+
s[q] = vk;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/* Squared texel distance from every texel to the nearest texel whose
|
|
66
|
+
mask equals `site`, and (optionally) that texel's index. */
|
|
67
|
+
function edt2d(mask, site, N, out, near) {
|
|
68
|
+
const f = new Float32Array(N), d = new Float32Array(N), s = new Int32Array(N), v = new Int32Array(N), z = new Float32Array(N + 1);
|
|
69
|
+
const g = new Float32Array(N * N), row = new Int32Array(N * N);
|
|
70
|
+
for (let x = 0; x < N; x++) {
|
|
71
|
+
for (let y = 0; y < N; y++)
|
|
72
|
+
f[y] = mask[y * N + x] === site ? 0 : INF;
|
|
73
|
+
edt1d(f, N, d, s, v, z);
|
|
74
|
+
for (let y = 0; y < N; y++) {
|
|
75
|
+
g[y * N + x] = d[y];
|
|
76
|
+
row[y * N + x] = s[y];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (let y = 0; y < N; y++) {
|
|
80
|
+
const o = y * N;
|
|
81
|
+
for (let x = 0; x < N; x++)
|
|
82
|
+
f[x] = g[o + x];
|
|
83
|
+
edt1d(f, N, d, s, v, z);
|
|
84
|
+
for (let x = 0; x < N; x++) {
|
|
85
|
+
out[o + x] = d[x];
|
|
86
|
+
if (near)
|
|
87
|
+
near[o + x] = row[o + s[x]] * N + s[x];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/* Separable binomial blur [1 4 6 4 1]/16, edges clamped. */
|
|
92
|
+
function blur5(a, N, tmp) {
|
|
93
|
+
for (let y = 0; y < N; y++) {
|
|
94
|
+
const o = y * N;
|
|
95
|
+
for (let x = 0; x < N; x++) {
|
|
96
|
+
const x0 = x < 2 ? 0 : x - 2, x1 = x < 1 ? 0 : x - 1, x3 = x > N - 2 ? N - 1 : x + 1, x4 = x > N - 3 ? N - 1 : x + 2;
|
|
97
|
+
tmp[o + x] = (a[o + x0] + 4 * a[o + x1] + 6 * a[o + x] + 4 * a[o + x3] + a[o + x4]) * 0.0625;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (let x = 0; x < N; x++) {
|
|
101
|
+
for (let y = 0; y < N; y++) {
|
|
102
|
+
const y0 = y < 2 ? 0 : y - 2, y1 = y < 1 ? 0 : y - 1, y3 = y > N - 2 ? N - 1 : y + 1, y4 = y > N - 3 ? N - 1 : y + 2;
|
|
103
|
+
a[y * N + x] = (tmp[y0 * N + x] + 4 * tmp[y1 * N + x] + 6 * tmp[y * N + x] + 4 * tmp[y3 * N + x] + tmp[y4 * N + x]) * 0.0625;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/* Δφ = −1 on the inside texels, φ = 0 outside: Gauss–Seidel with
|
|
108
|
+
over-relaxation, cascaded from a quarter-size grid so the smooth part
|
|
109
|
+
converges cheaply and the fine sweeps only settle the boundary. */
|
|
110
|
+
function poisson(cov, N, u) {
|
|
111
|
+
const levels = [];
|
|
112
|
+
for (let n = N, f = 1; f <= 4 && n % 2 === 0 || f === 1; n >>= 1, f <<= 1) {
|
|
113
|
+
const mask = new Uint8Array(n * n);
|
|
114
|
+
for (let y = 0; y < n; y++) {
|
|
115
|
+
for (let x = 0; x < n; x++) {
|
|
116
|
+
let sum = 0;
|
|
117
|
+
for (let j = 0; j < f; j++)
|
|
118
|
+
for (let i = 0; i < f; i++)
|
|
119
|
+
sum += cov[(y * f + j) * N + x * f + i];
|
|
120
|
+
mask[y * n + x] = sum >= 128 * f * f ? 1 : 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
levels.push({ n, mask, phi: new Float32Array(n * n) });
|
|
124
|
+
if (f === 4)
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
const sweep = (L, s, iters, om) => {
|
|
128
|
+
const { n, mask, phi } = L, s2 = s * s;
|
|
129
|
+
for (let it = 0; it < iters; it++) {
|
|
130
|
+
for (let y = 1; y < n - 1; y++) {
|
|
131
|
+
const o = y * n;
|
|
132
|
+
for (let x = 1; x < n - 1; x++) {
|
|
133
|
+
const i = o + x;
|
|
134
|
+
if (!mask[i])
|
|
135
|
+
continue;
|
|
136
|
+
const v = (phi[i - 1] + phi[i + 1] + phi[i - n] + phi[i + n] + s2) * 0.25;
|
|
137
|
+
phi[i] += om * (v - phi[i]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
for (let l = levels.length - 1; l >= 0; l--) {
|
|
143
|
+
const L = levels[l], f = 1 << l;
|
|
144
|
+
if (l < levels.length - 1) {
|
|
145
|
+
/* bilinear prolongation from the coarser level */
|
|
146
|
+
const C = levels[l + 1], n = L.n, cn = C.n;
|
|
147
|
+
for (let y = 0; y < n; y++) {
|
|
148
|
+
const fy = Math.min(cn - 1, Math.max(0, (y + 0.5) / 2 - 0.5)), y0 = fy | 0, y1 = Math.min(cn - 1, y0 + 1), ty = fy - y0;
|
|
149
|
+
for (let x = 0; x < n; x++) {
|
|
150
|
+
const i = y * n + x;
|
|
151
|
+
if (!L.mask[i])
|
|
152
|
+
continue;
|
|
153
|
+
const fx = Math.min(cn - 1, Math.max(0, (x + 0.5) / 2 - 0.5)), x0 = fx | 0, x1 = Math.min(cn - 1, x0 + 1), tx = fx - x0;
|
|
154
|
+
L.phi[i] = (C.phi[y0 * cn + x0] * (1 - tx) + C.phi[y0 * cn + x1] * tx) * (1 - ty) + (C.phi[y1 * cn + x0] * (1 - tx) + C.phi[y1 * cn + x1] * tx) * ty;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/* over-relaxation converges the smooth part; plain sweeps before and
|
|
159
|
+
after smooth the kinks the prolongation leaves (SOR near ω = 2 does
|
|
160
|
+
not damp them, and the normals would show them as seams) */
|
|
161
|
+
const om = Math.min(1.9, 2 / (1 + Math.sin(Math.PI / L.n)) - 0.05);
|
|
162
|
+
sweep(L, u * f, 4, 1);
|
|
163
|
+
sweep(L, u * f, l === 2 ? 100 : l === 1 ? 30 : 16, om);
|
|
164
|
+
sweep(L, u * f, 8, 1);
|
|
165
|
+
}
|
|
166
|
+
return levels[0].phi;
|
|
167
|
+
}
|
|
168
|
+
const DX = [1, 1, 0, -1, -1, -1, 0, 1], DY = [0, 1, 1, 1, 0, -1, -1, -1];
|
|
169
|
+
const DL = [1, Math.SQRT2, 1, Math.SQRT2, 1, Math.SQRT2, 1, Math.SQRT2];
|
|
170
|
+
/**
|
|
171
|
+
* The form of one outline from its coverage raster (N × N, 0–255, over
|
|
172
|
+
* design units [-PAD, 100 + PAD]): a pillow height field, its normals as
|
|
173
|
+
* matcap cells, and baked occlusion; outside texels near the edge borrow
|
|
174
|
+
* their nearest inside texel so upscaling never bleeds transparent black.
|
|
175
|
+
*/
|
|
176
|
+
export function buildForm(cov, N, halfDepth) {
|
|
177
|
+
const u = SPAN / N, NN = N * N;
|
|
178
|
+
const mask = new Uint8Array(NN);
|
|
179
|
+
for (let i = 0; i < NN; i++)
|
|
180
|
+
mask[i] = cov[i] >= 128 ? 1 : 0;
|
|
181
|
+
/* signed distance to the outline, in design units, positive inside;
|
|
182
|
+
antialiased edge texels give the sub-texel offset */
|
|
183
|
+
const dIn = new Float32Array(NN), dOut = new Float32Array(NN), nearIn = new Int32Array(NN);
|
|
184
|
+
edt2d(mask, 0, N, dIn, null);
|
|
185
|
+
edt2d(mask, 1, N, dOut, nearIn);
|
|
186
|
+
const sd = new Float32Array(NN), tmp = new Float32Array(NN);
|
|
187
|
+
for (let i = 0; i < NN; i++) {
|
|
188
|
+
const a = cov[i] / 255;
|
|
189
|
+
sd[i] = u * (a > 0 && a < 1 ? a - 0.5 : mask[i] ? Math.sqrt(dIn[i]) - 0.5 : 0.5 - Math.sqrt(dOut[i]));
|
|
190
|
+
}
|
|
191
|
+
/* round the medial-axis crease into a ridge (σ ≈ 2.2 texels) */
|
|
192
|
+
blur5(sd, N, tmp);
|
|
193
|
+
blur5(sd, N, tmp);
|
|
194
|
+
/* the pillow: the torsion function, Δφ = −1 inside and φ = 0 outside.
|
|
195
|
+
It has no medial-axis crease; √φ is an elliptical dome over a disc, a
|
|
196
|
+
round tube along a thin ray, a smooth cushion over a square, and a
|
|
197
|
+
valley between two lobes. Its height follows the body's inradius, so
|
|
198
|
+
thin parts are their own low tubes. */
|
|
199
|
+
const phi = poisson(cov, N, u);
|
|
200
|
+
let phiMax = 0;
|
|
201
|
+
for (let i = 0; i < NN; i++)
|
|
202
|
+
if (phi[i] > phiMax)
|
|
203
|
+
phiMax = phi[i];
|
|
204
|
+
const rIn = 2 * Math.sqrt(phiMax);
|
|
205
|
+
const hMax = Math.min(0.9 * halfDepth + 0.12 * rIn, 1.2 * rIn);
|
|
206
|
+
const kh = phiMax > 0 ? hMax / Math.sqrt(phiMax) : 0;
|
|
207
|
+
const h = new Float32Array(NN);
|
|
208
|
+
for (let i = 0; i < NN; i++)
|
|
209
|
+
h[i] = phi[i] > 0 ? kh * Math.sqrt(phi[i]) : 0;
|
|
210
|
+
blur5(h, N, tmp);
|
|
211
|
+
/* normals, silhouette fix, horizon AO, matcap cells */
|
|
212
|
+
const form = { N, i00: new Uint16Array(NN), wx: new Uint8Array(NN), wy: new Uint8Array(NN), ao: new Uint8Array(NN) };
|
|
213
|
+
const { i00, wx, wy, ao } = form;
|
|
214
|
+
const STEPS = N <= 64 ? [1, 2, 3, 5, 8] : N <= 96 ? [1, 2, 4, 7, 11] : [1, 2, 4, 7, 11, 15];
|
|
215
|
+
const halo = 9; // outside texels within 3 texels borrow their nearest inside texel
|
|
216
|
+
const last = N - 1;
|
|
217
|
+
for (let y = 0; y < N; y++) {
|
|
218
|
+
for (let x = 0; x < N; x++) {
|
|
219
|
+
const i = y * N + x;
|
|
220
|
+
const src = mask[i] ? i : dOut[i] <= halo ? nearIn[i] : -1;
|
|
221
|
+
if (src < 0)
|
|
222
|
+
continue;
|
|
223
|
+
const sx = src % N, sy = (src - sx) / N;
|
|
224
|
+
const xl = sx > 0 ? sx - 1 : 0, xr = sx < last ? sx + 1 : last, yu = sy > 0 ? sy - 1 : 0, yd = sy < last ? sy + 1 : last;
|
|
225
|
+
let nx = -(h[sy * N + xr] - h[sy * N + xl]) / (2 * u);
|
|
226
|
+
let ny = -(h[yd * N + sx] - h[yu * N + sx]) / (2 * u);
|
|
227
|
+
let nz = 1;
|
|
228
|
+
let len = Math.sqrt(nx * nx + ny * ny + 1);
|
|
229
|
+
nx /= len;
|
|
230
|
+
ny /= len;
|
|
231
|
+
nz /= len;
|
|
232
|
+
const dd = Math.max(0, sd[src]);
|
|
233
|
+
if (dd < 2) {
|
|
234
|
+
/* toward the in-plane outward direction, so the silhouette grazes */
|
|
235
|
+
let gx = sd[sy * N + xr] - sd[sy * N + xl], gy = sd[yd * N + sx] - sd[yu * N + sx];
|
|
236
|
+
const gl = Math.hypot(gx, gy) || 1;
|
|
237
|
+
gx /= gl;
|
|
238
|
+
gy /= gl;
|
|
239
|
+
const w = 0.7 * (1 - dd / 2);
|
|
240
|
+
nx += w * (-gx - nx);
|
|
241
|
+
ny += w * (-gy - ny);
|
|
242
|
+
nz += w * (0 - nz);
|
|
243
|
+
len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
|
|
244
|
+
nx /= len;
|
|
245
|
+
ny /= len;
|
|
246
|
+
nz /= len;
|
|
247
|
+
}
|
|
248
|
+
/* horizon-based occlusion on the height field */
|
|
249
|
+
const h0 = h[src];
|
|
250
|
+
let occ = 0;
|
|
251
|
+
for (let d = 0; d < 8; d++) {
|
|
252
|
+
let m = 0;
|
|
253
|
+
for (let k = 0; k < STEPS.length; k++) {
|
|
254
|
+
const r = STEPS[k];
|
|
255
|
+
let qx = sx + DX[d] * r, qy = sy + DY[d] * r;
|
|
256
|
+
if (qx < 0)
|
|
257
|
+
qx = 0;
|
|
258
|
+
else if (qx > last)
|
|
259
|
+
qx = last;
|
|
260
|
+
if (qy < 0)
|
|
261
|
+
qy = 0;
|
|
262
|
+
else if (qy > last)
|
|
263
|
+
qy = last;
|
|
264
|
+
const t = (h[qy * N + qx] - h0) / (r * u * DL[d]);
|
|
265
|
+
if (t > m)
|
|
266
|
+
m = t;
|
|
267
|
+
}
|
|
268
|
+
occ += m / Math.sqrt(1 + m * m);
|
|
269
|
+
}
|
|
270
|
+
const e = 1 - Math.min(1, dd / 3);
|
|
271
|
+
const edge = 1 - 0.2 * e * e;
|
|
272
|
+
const aoLin = Math.pow(1 - 0.9 * occ / 8, 1.5) * edge;
|
|
273
|
+
ao[i] = Math.max(1, Math.round(255 * Math.pow(aoLin, 1 / 2.2)));
|
|
274
|
+
const fx = (nx * 0.5 + 0.5) * (M - 1), fy = (ny * 0.5 + 0.5) * (M - 1);
|
|
275
|
+
const cx = Math.min(M - 2, Math.max(0, fx | 0)), cy = Math.min(M - 2, Math.max(0, fy | 0));
|
|
276
|
+
i00[i] = cy * M + cx;
|
|
277
|
+
wx[i] = Math.round(255 * Math.min(1, Math.max(0, fx - cx)));
|
|
278
|
+
wy[i] = Math.round(255 * Math.min(1, Math.max(0, fy - cy)));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return form;
|
|
282
|
+
}
|
|
283
|
+
function makeCanvas(n) {
|
|
284
|
+
if (typeof OffscreenCanvas === 'function')
|
|
285
|
+
return new OffscreenCanvas(n, n);
|
|
286
|
+
if (typeof document !== 'undefined') {
|
|
287
|
+
const c = document.createElement('canvas');
|
|
288
|
+
c.width = c.height = n;
|
|
289
|
+
return c;
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
function ctx2d(c, readBack) {
|
|
294
|
+
return c.getContext('2d', readBack ? { willReadFrequently: true } : undefined);
|
|
295
|
+
}
|
|
296
|
+
/** Coverage raster of the outline: N × N over design units [-PAD, 100 + PAD]. */
|
|
297
|
+
export function rasterize(path, N) {
|
|
298
|
+
const c = makeCanvas(N);
|
|
299
|
+
const g = c && ctx2d(c, true);
|
|
300
|
+
if (!g)
|
|
301
|
+
return null;
|
|
302
|
+
const u = SPAN / N;
|
|
303
|
+
g.setTransform(1 / u, 0, 0, 1 / u, PAD / u, PAD / u);
|
|
304
|
+
g.fillStyle = '#fff';
|
|
305
|
+
g.fill(path);
|
|
306
|
+
const px = g.getImageData(0, 0, N, N).data;
|
|
307
|
+
const cov = new Uint8ClampedArray(N * N);
|
|
308
|
+
for (let i = 0; i < N * N; i++)
|
|
309
|
+
cov[i] = px[i * 4 + 3];
|
|
310
|
+
return cov;
|
|
311
|
+
}
|
|
312
|
+
/* the shared scratch canvases, one per texture size */
|
|
313
|
+
/* ── the form cache, with deferred builds ──────────────────────────── */
|
|
314
|
+
const forms = new Map();
|
|
315
|
+
const pending = new Set();
|
|
316
|
+
const pathIds = new WeakMap();
|
|
317
|
+
let nextPathId = 0;
|
|
318
|
+
function pathId(p) {
|
|
319
|
+
let id = pathIds.get(p);
|
|
320
|
+
if (!id)
|
|
321
|
+
pathIds.set(p, (id = `p${nextPathId++}`));
|
|
322
|
+
return id;
|
|
323
|
+
}
|
|
324
|
+
/* bakes run one per idle slot (or one per timeout where there is no
|
|
325
|
+
requestIdleCallback, as in Safari), so a page full of types never
|
|
326
|
+
stacks all of them into one frame */
|
|
327
|
+
const queue = [];
|
|
328
|
+
let scheduled = false;
|
|
329
|
+
function pump() {
|
|
330
|
+
scheduled = false;
|
|
331
|
+
const fn = queue.shift();
|
|
332
|
+
if (fn)
|
|
333
|
+
fn();
|
|
334
|
+
if (queue.length)
|
|
335
|
+
schedule();
|
|
336
|
+
}
|
|
337
|
+
function schedule() {
|
|
338
|
+
if (scheduled)
|
|
339
|
+
return;
|
|
340
|
+
scheduled = true;
|
|
341
|
+
const ric = globalThis.requestIdleCallback;
|
|
342
|
+
if (ric)
|
|
343
|
+
ric(pump, { timeout: 120 });
|
|
344
|
+
else
|
|
345
|
+
setTimeout(pump, 16);
|
|
346
|
+
}
|
|
347
|
+
function idle(fn) {
|
|
348
|
+
queue.push(fn);
|
|
349
|
+
schedule();
|
|
350
|
+
}
|
|
351
|
+
/** The form for an outline, built now (`sync`) or on idle time (null until then). */
|
|
352
|
+
function formFor(key, path, N, halfDepth, sync) {
|
|
353
|
+
const id = `${key}|${N}|${Math.round(halfDepth)}`;
|
|
354
|
+
const hit = forms.get(id);
|
|
355
|
+
if (hit)
|
|
356
|
+
return hit;
|
|
357
|
+
const build = () => {
|
|
358
|
+
pending.delete(id);
|
|
359
|
+
if (forms.has(id))
|
|
360
|
+
return;
|
|
361
|
+
const cov = rasterize(path, N);
|
|
362
|
+
if (!cov)
|
|
363
|
+
return;
|
|
364
|
+
if (forms.size >= 48)
|
|
365
|
+
forms.clear();
|
|
366
|
+
forms.set(id, buildForm(cov, N, halfDepth));
|
|
367
|
+
};
|
|
368
|
+
if (sync) {
|
|
369
|
+
build();
|
|
370
|
+
return forms.get(id) ?? null;
|
|
371
|
+
}
|
|
372
|
+
if (!pending.has(id)) {
|
|
373
|
+
pending.add(id);
|
|
374
|
+
idle(build);
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
/** Build a form ahead of time (call from an idle callback at mount). */
|
|
379
|
+
export function warmPlastic(key, path, devicePx = 192, depth = 0.65) {
|
|
380
|
+
formFor(key, path, tierFor(devicePx), 15 * depth, true);
|
|
381
|
+
}
|
|
382
|
+
/** Texture size for an avatar `devicePx` wide (CSS px × device pixel ratio). */
|
|
383
|
+
export function tierFor(devicePx) {
|
|
384
|
+
return devicePx <= 100 ? 64 : devicePx <= 224 ? 96 : 128;
|
|
385
|
+
}
|
|
386
|
+
/* ── colour ────────────────────────────────────────────────────────── */
|
|
387
|
+
const toLin = (v) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
388
|
+
const linCache = new Map();
|
|
389
|
+
function linearColor(color) {
|
|
390
|
+
let c = linCache.get(color);
|
|
391
|
+
if (!c) {
|
|
392
|
+
let rgb = parseColor(color);
|
|
393
|
+
if (!rgb) {
|
|
394
|
+
/* a named colour: let a canvas resolve it */
|
|
395
|
+
const cv = makeCanvas(1);
|
|
396
|
+
const g = cv && ctx2d(cv, true);
|
|
397
|
+
if (g) {
|
|
398
|
+
g.fillStyle = color;
|
|
399
|
+
g.fillRect(0, 0, 1, 1);
|
|
400
|
+
const p = g.getImageData(0, 0, 1, 1).data;
|
|
401
|
+
rgb = [p[0], p[1], p[2]];
|
|
402
|
+
}
|
|
403
|
+
else
|
|
404
|
+
rgb = [128, 128, 128];
|
|
405
|
+
}
|
|
406
|
+
c = [toLin(rgb[0] / 255), toLin(rgb[1] / 255), toLin(rgb[2] / 255)];
|
|
407
|
+
if (linCache.size > 200)
|
|
408
|
+
linCache.clear();
|
|
409
|
+
linCache.set(color, c);
|
|
410
|
+
}
|
|
411
|
+
return c;
|
|
412
|
+
}
|
|
413
|
+
/* tone map (soft shoulder above 0.75, keeps hue) + sRGB encode, as a table */
|
|
414
|
+
const TONE_N = 2048, TONE_MAX = 2.5, TONE_SCALE = TONE_N / TONE_MAX;
|
|
415
|
+
const toneLut = new Float32Array(TONE_N);
|
|
416
|
+
for (let i = 0; i < TONE_N; i++) {
|
|
417
|
+
const v = (i + 0.5) / TONE_SCALE;
|
|
418
|
+
const y = v <= 0.75 ? v : 0.75 + 0.25 * (1 - Math.exp(-(v - 0.75) / 0.25));
|
|
419
|
+
toneLut[i] = 255 * (y <= 0.0031308 ? 12.92 * y : 1.055 * Math.pow(y, 1 / 2.4) - 0.055);
|
|
420
|
+
}
|
|
421
|
+
const tone = (v) => toneLut[v <= 0 ? 0 : v >= TONE_MAX ? TONE_N - 1 : (v * TONE_SCALE) | 0];
|
|
422
|
+
/* x^e over x ∈ [0, 1], as a table, cached by exponent */
|
|
423
|
+
const POW_N = 1024;
|
|
424
|
+
const powLuts = new Map();
|
|
425
|
+
function powLut(e) {
|
|
426
|
+
let t = powLuts.get(e);
|
|
427
|
+
if (!t) {
|
|
428
|
+
t = new Float32Array(POW_N + 1);
|
|
429
|
+
for (let i = 0; i <= POW_N; i++)
|
|
430
|
+
t[i] = Math.pow(i / POW_N, e);
|
|
431
|
+
if (powLuts.size > 16)
|
|
432
|
+
powLuts.clear();
|
|
433
|
+
powLuts.set(e, t);
|
|
434
|
+
}
|
|
435
|
+
return t;
|
|
436
|
+
}
|
|
437
|
+
const ENV = [0.92, 0.96, 1.0];
|
|
438
|
+
const WARM = [1, 0.98, 0.95];
|
|
439
|
+
const smooth = (a, b, v) => {
|
|
440
|
+
const t = v <= a ? 0 : v >= b ? 1 : (v - a) / (b - a);
|
|
441
|
+
return t * t * (3 - 2 * t);
|
|
442
|
+
};
|
|
443
|
+
/* 1 inside |x| < w, falling to 0 over ±s around it */
|
|
444
|
+
const soft = (w, s, x) => 1 - smooth(w - s, w + s, x);
|
|
445
|
+
/** Fill `out` (M × M × rgb, sRGB 0–255) with the lit sphere for body colour `c` (linear). */
|
|
446
|
+
export function buildMatcap(out, c, f, p) {
|
|
447
|
+
const { L, V, H, U, W, A, B } = f;
|
|
448
|
+
const mx = Math.max(c[0], c[1], c[2], 0.05);
|
|
449
|
+
const tint = [c[0] / mx, c[1] / mx, c[2] / mx];
|
|
450
|
+
const amb = Math.max(0.03, 0.30 - 0.15 * p.shadow);
|
|
451
|
+
const wrap = 0.15 + 0.14 * p.spread;
|
|
452
|
+
const kd = 0.85;
|
|
453
|
+
const e1 = Math.min(90, Math.round(110 / Math.pow(p.spread, 1.3))), e2 = Math.max(2, Math.round(8 / p.spread));
|
|
454
|
+
const lut1 = powLut(e1), lut2 = powLut(e2);
|
|
455
|
+
const ks1 = 0.45 * p.highlight, ks2 = 0.10 * p.highlight, winK = 0.11 * p.highlight, rimK = 0.30 * p.rim;
|
|
456
|
+
const ambT = [amb * tint[0], amb * tint[1], amb * tint[2]];
|
|
457
|
+
for (let j = 0; j < M; j++) {
|
|
458
|
+
for (let i = 0; i < M; i++) {
|
|
459
|
+
let nx = (i / (M - 1)) * 2 - 1, ny = (j / (M - 1)) * 2 - 1;
|
|
460
|
+
let r2 = nx * nx + ny * ny;
|
|
461
|
+
/* samples lie inside the unit disc and read one cell beyond it at
|
|
462
|
+
most: the corners are never read */
|
|
463
|
+
if (r2 > 1.14)
|
|
464
|
+
continue;
|
|
465
|
+
if (r2 > 1) {
|
|
466
|
+
const s = 1 / Math.sqrt(r2);
|
|
467
|
+
nx *= s;
|
|
468
|
+
ny *= s;
|
|
469
|
+
r2 = 1;
|
|
470
|
+
}
|
|
471
|
+
const nz = Math.sqrt(1 - r2);
|
|
472
|
+
const nl = nx * L[0] + ny * L[1] + nz * L[2];
|
|
473
|
+
const nv = Math.max(0, nx * V[0] + ny * V[1] + nz * V[2]);
|
|
474
|
+
const nh = Math.max(0, nx * H[0] + ny * H[1] + nz * H[2]);
|
|
475
|
+
const dif = Math.min(1, Math.max(0, (nl + wrap) / (1 + wrap)));
|
|
476
|
+
const q = 1 - nv, q2 = q * q, f3 = q2 * q, f5 = f3 * q2;
|
|
477
|
+
const ni = (nh * POW_N) | 0;
|
|
478
|
+
const spec = (ks1 * lut1[ni] + ks2 * lut2[ni]) * (1 + 3 * f5);
|
|
479
|
+
/* the mirror direction: sky/floor gradient and the window */
|
|
480
|
+
const rx = 2 * nv * nx - V[0], ry = 2 * nv * ny - V[1], rz = 2 * nv * nz - V[2];
|
|
481
|
+
const sky = 0.45 + 0.55 * smooth(-0.4, 0.6, rx * U[0] + ry * U[1] + rz * U[2]);
|
|
482
|
+
const rw = rx * W[0] + ry * W[1] + rz * W[2];
|
|
483
|
+
let win = 0;
|
|
484
|
+
if (rw > 0.5) {
|
|
485
|
+
const ra = (rx * A[0] + ry * A[1] + rz * A[2]) / rw, rb = (rx * B[0] + ry * B[1] + rz * B[2]) / rw;
|
|
486
|
+
win = soft(0.34, 0.12, Math.abs(ra)) * soft(0.12, 0.06, Math.abs(rb));
|
|
487
|
+
}
|
|
488
|
+
const env = rimK * f3 * sky + winK * win;
|
|
489
|
+
const k = (j * M + i) * 3;
|
|
490
|
+
out[k] = tone(c[0] * (ambT[0] + kd * dif) + spec * WARM[0] + env * ENV[0]);
|
|
491
|
+
out[k + 1] = tone(c[1] * (ambT[1] + kd * dif) + spec * WARM[1] + env * ENV[1]);
|
|
492
|
+
out[k + 2] = tone(c[2] * (ambT[2] + kd * dif) + spec * WARM[2] + env * ENV[2]);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** Bilinear read of the matcap at a normal's (nx, ny). */
|
|
497
|
+
function sampleMatcap(mc, nx, ny, out) {
|
|
498
|
+
const fx = (nx * 0.5 + 0.5) * (M - 1), fy = (ny * 0.5 + 0.5) * (M - 1);
|
|
499
|
+
const cx = Math.min(M - 2, Math.max(0, fx | 0)), cy = Math.min(M - 2, Math.max(0, fy | 0));
|
|
500
|
+
const x = fx - cx, y = fy - cy, b = (cy * M + cx) * 3, R = M * 3;
|
|
501
|
+
const w00 = (1 - x) * (1 - y), w10 = x * (1 - y), w01 = (1 - x) * y, w11 = x * y;
|
|
502
|
+
for (let ch = 0; ch < 3; ch++)
|
|
503
|
+
out[ch] = mc[b + ch] * w00 + mc[b + 3 + ch] * w10 + mc[b + R + ch] * w01 + mc[b + R + 3 + ch] * w11;
|
|
504
|
+
}
|
|
505
|
+
/** Per frame: matcap lookup × baked AO into the texture's pixels. */
|
|
506
|
+
export function shadeTexels(form, mc, px, aoMul) {
|
|
507
|
+
const { N, i00, wx, wy, ao } = form;
|
|
508
|
+
const R = M * 3;
|
|
509
|
+
for (let i = 0, k = 0; i < N * N; i++, k += 4) {
|
|
510
|
+
const a = ao[i];
|
|
511
|
+
if (a === 0) {
|
|
512
|
+
px[k + 3] = 0;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const m = aoMul[a];
|
|
516
|
+
const b = i00[i] * 3, x = wx[i] * (1 / 255), y = wy[i] * (1 / 255);
|
|
517
|
+
const w00 = (1 - x) * (1 - y) * m, w10 = x * (1 - y) * m, w01 = (1 - x) * y * m, w11 = x * y * m;
|
|
518
|
+
px[k] = mc[b] * w00 + mc[b + 3] * w10 + mc[b + R] * w01 + mc[b + R + 3] * w11;
|
|
519
|
+
px[k + 1] = mc[b + 1] * w00 + mc[b + 4] * w10 + mc[b + R + 1] * w01 + mc[b + R + 4] * w11;
|
|
520
|
+
px[k + 2] = mc[b + 2] * w00 + mc[b + 5] * w10 + mc[b + R + 2] * w01 + mc[b + R + 5] * w11;
|
|
521
|
+
px[k + 3] = 255;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const norm3 = (v) => {
|
|
525
|
+
const l = Math.hypot(v[0], v[1], v[2]) || 1;
|
|
526
|
+
return [v[0] / l, v[1] / l, v[2] / l];
|
|
527
|
+
};
|
|
528
|
+
/** Screen-space vectors into the cap's frame: un-roll, then the transpose
|
|
529
|
+
of the rig's rotation; the back cap is the front mirrored in z. */
|
|
530
|
+
export function capFrame(r) {
|
|
531
|
+
const cr = Math.cos(r.roll), sr = Math.sin(r.roll);
|
|
532
|
+
const lx = cr * r.lx + sr * r.ly, ly = -sr * r.lx + cr * r.ly;
|
|
533
|
+
const mirror = r.facing < 0 ? -1 : 1;
|
|
534
|
+
/* the flip passes through edge-on: fade the mirrored component so the
|
|
535
|
+
light does not pop from one side of the sliver to the other */
|
|
536
|
+
const zf = mirror * Math.min(1, Math.abs(r.facing) / 0.16);
|
|
537
|
+
const { cy, sy, cp, sp } = r;
|
|
538
|
+
const local = (x, y, z, zk = zf) => norm3([cy * x + sy * sp * y - sy * cp * z, cp * y + sp * z, zk * (sy * x - cy * sp * y + cy * cp * z)]);
|
|
539
|
+
const L = local(E_XY * lx, E_XY * ly, E_Z);
|
|
540
|
+
const V = local(0, 0, 1, mirror);
|
|
541
|
+
const H = norm3([L[0] + V[0], L[1] + V[1], L[2] + V[2]]);
|
|
542
|
+
const U = local(lx, ly, 0, mirror);
|
|
543
|
+
/* the window: 80° round from the light, 34° off the view axis */
|
|
544
|
+
const cw = Math.cos((80 * Math.PI) / 180), sw = Math.sin((80 * Math.PI) / 180);
|
|
545
|
+
const wx = cw * lx - sw * ly, wy = sw * lx + cw * ly;
|
|
546
|
+
const Ws = norm3([0.55 * wx, 0.55 * wy, 0.83]);
|
|
547
|
+
const As = norm3([Ws[1], -Ws[0], 0]);
|
|
548
|
+
const Bs = [Ws[1] * As[2] - Ws[2] * As[1], Ws[2] * As[0] - Ws[0] * As[2], Ws[0] * As[1] - Ws[1] * As[0]];
|
|
549
|
+
const W = local(Ws[0], Ws[1], Ws[2], mirror), A = local(As[0], As[1], As[2], mirror), B = local(Bs[0], Bs[1], Bs[2], mirror);
|
|
550
|
+
return { L, V, H, U, W, A, B };
|
|
551
|
+
}
|
|
552
|
+
const states = new WeakMap();
|
|
553
|
+
function stateFor(ctx, outline) {
|
|
554
|
+
const key = ctx.canvas ?? ctx;
|
|
555
|
+
let byOutline = states.get(key);
|
|
556
|
+
if (!byOutline) {
|
|
557
|
+
byOutline = new Map();
|
|
558
|
+
states.set(key, byOutline);
|
|
559
|
+
}
|
|
560
|
+
let s = byOutline.get(outline);
|
|
561
|
+
if (!s) {
|
|
562
|
+
s = {
|
|
563
|
+
N: 0, img: null, mc: new Float32Array(MM * 3),
|
|
564
|
+
mcPrev: new Float32Array(MM * 3), mcMix: new Float32Array(MM * 3), mixVersion: 0, blendT: 1, blendFrames: 1, sinceBuild: 0,
|
|
565
|
+
L: null, V: null, lx: NaN, ly: NaN, base: '', shadow: NaN, highlight: NaN, spread: NaN, rim: NaN,
|
|
566
|
+
version: 0, imgVersion: -1, imgAoK: NaN, imgForm: null, aoK: -1, aoMul: new Float32Array(256),
|
|
567
|
+
near: null, rimG: null, far: null,
|
|
568
|
+
scratch: [null, null], scratchIdx: 0, scratchN: 0, scratchStale: true,
|
|
569
|
+
sprites: [null, null, null], spriteVersion: -1, spritePx: 0,
|
|
570
|
+
};
|
|
571
|
+
if (byOutline.size > 4)
|
|
572
|
+
byOutline.clear();
|
|
573
|
+
byOutline.set(outline, s);
|
|
574
|
+
}
|
|
575
|
+
return s;
|
|
576
|
+
}
|
|
577
|
+
/* the matcap is rebuilt once a direction has moved a bin (1/48) from the
|
|
578
|
+
one it was built for — the same resolution as a bin grid, without the
|
|
579
|
+
rebuilds a direction jittering on a bin edge would cause */
|
|
580
|
+
const BIN = 1 / 48;
|
|
581
|
+
const moved = (a, b) => !b || Math.abs(a[0] - b[0]) >= BIN || Math.abs(a[1] - b[1]) >= BIN || Math.abs(a[2] - b[2]) >= BIN;
|
|
582
|
+
/** A · B for canvas affines (a, b, c, d, e, f) */
|
|
583
|
+
export function mulAffine(A, B) {
|
|
584
|
+
return [
|
|
585
|
+
A[0] * B[0] + A[2] * B[1], A[1] * B[0] + A[3] * B[1],
|
|
586
|
+
A[0] * B[2] + A[2] * B[3], A[1] * B[2] + A[3] * B[3],
|
|
587
|
+
A[0] * B[4] + A[2] * B[5] + A[4], A[1] * B[4] + A[3] * B[5] + A[5],
|
|
588
|
+
];
|
|
589
|
+
}
|
|
590
|
+
function sideGradient(ctx, mc, nz, dark, lxy) {
|
|
591
|
+
const rr = Math.sqrt(1 - nz * nz), c = [0, 0, 0], k = 1 - dark;
|
|
592
|
+
if (typeof ctx.createConicGradient === 'function') {
|
|
593
|
+
const g = ctx.createConicGradient(0, 50, 50);
|
|
594
|
+
for (let s = 0; s <= CONIC_STOPS; s++) {
|
|
595
|
+
const phi = (s / CONIC_STOPS) * Math.PI * 2;
|
|
596
|
+
sampleMatcap(mc, rr * Math.cos(phi), rr * Math.sin(phi), c);
|
|
597
|
+
g.addColorStop(s / CONIC_STOPS, `rgb(${(c[0] * k) | 0} ${(c[1] * k) | 0} ${(c[2] * k) | 0})`);
|
|
598
|
+
}
|
|
599
|
+
return g;
|
|
600
|
+
}
|
|
601
|
+
/* no conic gradients (Safari < 16.4): a ramp along the light */
|
|
602
|
+
const g = ctx.createLinearGradient(50 + lxy[0] * 50, 50 + lxy[1] * 50, 50 - lxy[0] * 50, 50 - lxy[1] * 50);
|
|
603
|
+
const at = (nx, ny, t) => {
|
|
604
|
+
sampleMatcap(mc, nx, ny, c);
|
|
605
|
+
g.addColorStop(t, `rgb(${(c[0] * k) | 0} ${(c[1] * k) | 0} ${(c[2] * k) | 0})`);
|
|
606
|
+
};
|
|
607
|
+
at(rr * lxy[0], rr * lxy[1], 0);
|
|
608
|
+
at(-rr * lxy[1], rr * lxy[0], 0.5);
|
|
609
|
+
at(-rr * lxy[0], -rr * lxy[1], 1);
|
|
610
|
+
return g;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Draw the plastic body: the side copies (far → near) with gradients from
|
|
614
|
+
* the matcap, then the front cap as a lit texture under a vector clip.
|
|
615
|
+
* Call inside the body transform (translate / roll / squash applied),
|
|
616
|
+
* instead of the slice loop. Returns false when the form is not built
|
|
617
|
+
* yet (it is being built on idle time): draw the stock slices that frame.
|
|
618
|
+
*/
|
|
619
|
+
/* WebKit rasterises a conic-gradient fill slowly — about 60 µs each where
|
|
620
|
+
a flat fill is 2 µs — and the side stack is sixteen of them a frame.
|
|
621
|
+
There the three side gradients are rendered into sprites of the outline
|
|
622
|
+
once per matcap rebuild and each slice is a blit of one; Chromium and
|
|
623
|
+
Firefox fill the slices as vectors. */
|
|
624
|
+
const WEBKIT = typeof navigator !== 'undefined' && /AppleWebKit\//.test(navigator.userAgent) && !/Chrome\/|Chromium\/|Edg\//.test(navigator.userAgent);
|
|
625
|
+
/** each side sprite: the matcap tilt it samples and its darkening */
|
|
626
|
+
const SIDE_KINDS = [[0.55, () => 0], [0, () => 0], [0, (m) => Math.min(0.6, 0.25 * m.shadow)]];
|
|
627
|
+
export function drawPlasticCap(ctx, cfg, rig, pal, union, mat) {
|
|
628
|
+
const N = tierFor(rig.dev);
|
|
629
|
+
const form = formFor(cfg.typeKey ?? pathId(cfg.path), cfg.path, N, rig.halfDepth, !!rig.still);
|
|
630
|
+
if (!form)
|
|
631
|
+
return false;
|
|
632
|
+
const st = stateFor(ctx, cfg.typeKey ?? pathId(cfg.path));
|
|
633
|
+
const f = capFrame(rig);
|
|
634
|
+
const lxy = (() => {
|
|
635
|
+
const l = Math.hypot(f.L[0], f.L[1]);
|
|
636
|
+
return l < 0.05 ? [0, -1] : [f.L[0] / l, f.L[1] / l];
|
|
637
|
+
})();
|
|
638
|
+
if (moved(f.L, st.L) || moved(f.V, st.V) || rig.lx !== st.lx || rig.ly !== st.ly || pal.base !== st.base ||
|
|
639
|
+
mat.shadow !== st.shadow || mat.highlight !== st.highlight || mat.spread !== st.spread || mat.rim !== st.rim) {
|
|
640
|
+
/* the fade starts from what is showing now, so a rebuild during a
|
|
641
|
+
fade does not jump */
|
|
642
|
+
if (st.version > 0)
|
|
643
|
+
st.mcPrev.set(st.mcMix);
|
|
644
|
+
buildMatcap(st.mc, linearColor(pal.base), f, mat);
|
|
645
|
+
if (st.version === 0) {
|
|
646
|
+
st.mcMix.set(st.mc);
|
|
647
|
+
st.blendT = 1;
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
st.blendFrames = Math.min(10, Math.max(1, st.sinceBuild));
|
|
651
|
+
st.blendT = 0;
|
|
652
|
+
}
|
|
653
|
+
st.sinceBuild = 0;
|
|
654
|
+
st.mixVersion++;
|
|
655
|
+
st.L = f.L;
|
|
656
|
+
st.V = f.V;
|
|
657
|
+
st.lx = rig.lx;
|
|
658
|
+
st.ly = rig.ly;
|
|
659
|
+
st.base = pal.base;
|
|
660
|
+
st.shadow = mat.shadow;
|
|
661
|
+
st.highlight = mat.highlight;
|
|
662
|
+
st.spread = mat.spread;
|
|
663
|
+
st.rim = mat.rim;
|
|
664
|
+
st.version++;
|
|
665
|
+
st.near = st.rimG = st.far = null;
|
|
666
|
+
}
|
|
667
|
+
st.sinceBuild++;
|
|
668
|
+
if (st.blendT < 1) {
|
|
669
|
+
st.blendT = Math.min(1, st.blendT + 1 / st.blendFrames);
|
|
670
|
+
const e = st.blendT >= 1 ? 1 : st.blendT * st.blendT * (3 - 2 * st.blendT);
|
|
671
|
+
const a = st.mcPrev, b = st.mc, o = st.mcMix;
|
|
672
|
+
for (let i = 0; i < MM * 3; i++)
|
|
673
|
+
o[i] = a[i] + (b[i] - a[i]) * e;
|
|
674
|
+
st.mixVersion++;
|
|
675
|
+
}
|
|
676
|
+
/* the occlusion strength follows `shadow` */
|
|
677
|
+
const aoK = Math.min(1.3, 1.2 * mat.shadow);
|
|
678
|
+
if (aoK !== st.aoK) {
|
|
679
|
+
for (let a = 0; a < 256; a++)
|
|
680
|
+
st.aoMul[a] = Math.max(0, 1 - aoK * (1 - a / 255));
|
|
681
|
+
st.aoK = aoK;
|
|
682
|
+
}
|
|
683
|
+
if (!st.img || st.N !== N) {
|
|
684
|
+
st.img = new ImageData(N, N);
|
|
685
|
+
st.N = N;
|
|
686
|
+
st.imgVersion = -1;
|
|
687
|
+
}
|
|
688
|
+
if (st.imgVersion !== st.mixVersion || st.imgAoK !== aoK || st.imgForm !== form) {
|
|
689
|
+
shadeTexels(form, st.mcMix, st.img.data, st.aoMul);
|
|
690
|
+
st.imgVersion = st.mixVersion;
|
|
691
|
+
st.imgAoK = aoK;
|
|
692
|
+
st.imgForm = form;
|
|
693
|
+
st.scratchStale = true;
|
|
694
|
+
}
|
|
695
|
+
if (st.scratchN !== N) {
|
|
696
|
+
st.scratch = [null, null];
|
|
697
|
+
st.scratchN = N;
|
|
698
|
+
st.scratchStale = true;
|
|
699
|
+
}
|
|
700
|
+
if (st.scratchStale) {
|
|
701
|
+
st.scratchIdx ^= 1;
|
|
702
|
+
let sc = st.scratch[st.scratchIdx];
|
|
703
|
+
if (!sc) {
|
|
704
|
+
const c = makeCanvas(N);
|
|
705
|
+
const g = c && ctx2d(c, false);
|
|
706
|
+
if (!c || !g)
|
|
707
|
+
return false;
|
|
708
|
+
sc = st.scratch[st.scratchIdx] = { c, g };
|
|
709
|
+
}
|
|
710
|
+
sc.g.putImageData(st.img, 0, 0);
|
|
711
|
+
st.scratchStale = false;
|
|
712
|
+
}
|
|
713
|
+
const sc = st.scratch[st.scratchIdx];
|
|
714
|
+
/* WebKit: the side sprites, at the avatar's device resolution, redrawn
|
|
715
|
+
with the matcap */
|
|
716
|
+
let fast = cfg.sides === 'sprite' || (cfg.sides !== 'vector' && WEBKIT);
|
|
717
|
+
if (fast) {
|
|
718
|
+
const px = Math.ceil((SPAN * rig.dev) / 100);
|
|
719
|
+
if (st.spritePx !== px) {
|
|
720
|
+
st.sprites = [null, null, null];
|
|
721
|
+
st.spritePx = px;
|
|
722
|
+
st.spriteVersion = -1;
|
|
723
|
+
}
|
|
724
|
+
if (st.spriteVersion !== st.version) {
|
|
725
|
+
const k = px / SPAN;
|
|
726
|
+
for (let i = 0; i < 3 && fast; i++) {
|
|
727
|
+
let spr = st.sprites[i];
|
|
728
|
+
if (!spr) {
|
|
729
|
+
const c = makeCanvas(px);
|
|
730
|
+
const g = c && ctx2d(c, false);
|
|
731
|
+
if (!c || !g) {
|
|
732
|
+
fast = false;
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
spr = st.sprites[i] = { c, g };
|
|
736
|
+
}
|
|
737
|
+
spr.g.setTransform(1, 0, 0, 1, 0, 0);
|
|
738
|
+
spr.g.clearRect(0, 0, px, px);
|
|
739
|
+
spr.g.setTransform(k, 0, 0, k, PAD * k, PAD * k);
|
|
740
|
+
spr.g.fillStyle = sideGradient(spr.g, st.mc, SIDE_KINDS[i][0], SIDE_KINDS[i][1](mat), lxy);
|
|
741
|
+
spr.g.fill(cfg.path);
|
|
742
|
+
}
|
|
743
|
+
if (fast)
|
|
744
|
+
st.spriteVersion = st.version;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (!fast && !st.near) {
|
|
748
|
+
st.near = sideGradient(ctx, st.mc, 0.55, 0, lxy);
|
|
749
|
+
st.rimG = sideGradient(ctx, st.mc, 0, 0, lxy);
|
|
750
|
+
st.far = sideGradient(ctx, st.mc, 0, Math.min(0.6, 0.25 * mat.shadow), lxy);
|
|
751
|
+
}
|
|
752
|
+
/* 1. the side copies, far → near, without the nearest (the texture is
|
|
753
|
+
the whole front); the shoulder nearest the cap reads the matcap at a
|
|
754
|
+
57° tilt, the equator its rim, the far half its rim in shade. Each
|
|
755
|
+
slice sets its transform outright from the body's: no save/restore */
|
|
756
|
+
const { cy, sy, cp, sp, halfDepth, cap } = rig;
|
|
757
|
+
const order = rig.facing >= 0 ? 1 : -1;
|
|
758
|
+
const [ca, cb, cc, cd, ce, cf] = rig.ctm;
|
|
759
|
+
let fill = null;
|
|
760
|
+
if (fast) {
|
|
761
|
+
ctx.imageSmoothingEnabled = true;
|
|
762
|
+
ctx.imageSmoothingQuality = 'high';
|
|
763
|
+
}
|
|
764
|
+
/* each slice's affine is applied relative to the previous slice's: one
|
|
765
|
+
transform() per slice, no save/restore (a setTransform costs four
|
|
766
|
+
times as much in Safari) */
|
|
767
|
+
let pa = 1, pb = 0, pc = 0, pd = 1, pe = 0, pf = 0;
|
|
768
|
+
for (let j = 0; j < SLICES - 1; j++) {
|
|
769
|
+
const k = order > 0 ? j : SLICES - 1 - j;
|
|
770
|
+
const z = -1 + (2 * k) / (SLICES - 1);
|
|
771
|
+
const s = profile(z, cap);
|
|
772
|
+
const zn = z * order; // toward the viewer
|
|
773
|
+
/* yaw about Y then pitch about X, orthographic: an affine per slice,
|
|
774
|
+
then the path's own origin at its centre */
|
|
775
|
+
const m0 = cy * s, m1 = sy * sp * s, m3 = cp * s;
|
|
776
|
+
const e = z * sy * halfDepth - 50 * m0, fo = -z * cy * sp * halfDepth - 50 * m1 - 50 * m3;
|
|
777
|
+
const det = pa * pd - pb * pc;
|
|
778
|
+
const ia = pd / det, ib = -pb / det, ic = -pc / det, id = pa / det, ie = (pc * pf - pd * pe) / det, jf = (pb * pe - pa * pf) / det;
|
|
779
|
+
ctx.transform(ia * m0 + ic * m1, ib * m0 + id * m1, ic * m3, id * m3, ia * e + ic * fo + ie, ib * e + id * fo + jf);
|
|
780
|
+
pa = m0;
|
|
781
|
+
pb = m1;
|
|
782
|
+
pc = 0;
|
|
783
|
+
pd = m3;
|
|
784
|
+
pe = e;
|
|
785
|
+
pf = fo;
|
|
786
|
+
const kind = zn > 0.4 ? 0 : zn >= 0 ? 1 : 2;
|
|
787
|
+
if (fast) {
|
|
788
|
+
ctx.drawImage(st.sprites[kind].c, -PAD, -PAD, SPAN, SPAN);
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
const g = (kind === 0 ? st.near : kind === 1 ? st.rimG : st.far);
|
|
792
|
+
if (g !== fill)
|
|
793
|
+
ctx.fillStyle = fill = g;
|
|
794
|
+
ctx.fill(cfg.path);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
ctx.setTransform(ca, cb, cc, cd, ce, cf);
|
|
798
|
+
/* 2. the cap. The texture is the front surface seen head-on: its outer
|
|
799
|
+
units are the rounded shoulder. Turned, the front's projection runs
|
|
800
|
+
from the equator on the trailing side to the shoulder's silhouette on
|
|
801
|
+
the leading side, so the texture is stretched along the turn to span
|
|
802
|
+
exactly that (no stretch head-on). */
|
|
803
|
+
const inv = 1 / (cy * cp);
|
|
804
|
+
const dx = sy * halfDepth / cy, dy = -sp * halfDepth * inv; // the depth axis, in the equator's own units
|
|
805
|
+
const dl = Math.hypot(dx, dy);
|
|
806
|
+
const ex = dl > 1e-6 ? (order * dx) / dl : 1, ey = dl > 1e-6 ? (order * dy) / dl : 0;
|
|
807
|
+
const lead = 50 * cap + Math.hypot(50 * (1 - cap), dl);
|
|
808
|
+
const stretch = (lead + 50) / 100, shift = (lead - 50) / 2;
|
|
809
|
+
const a = 1 + (stretch - 1) * ex * ex, b = (stretch - 1) * ex * ey, d = 1 + (stretch - 1) * ey * ey;
|
|
810
|
+
const capM = mulAffine(mulAffine(rig.ctm, [cy, sy * sp, 0, cp, 0, 0]), [a, b, b, d, shift * ex - 50 * a - 50 * b, shift * ey - 50 * b - 50 * d]);
|
|
811
|
+
ctx.save();
|
|
812
|
+
ctx.setTransform(capM[0], capM[1], capM[2], capM[3], capM[4], capM[5]);
|
|
813
|
+
ctx.clip(cfg.path);
|
|
814
|
+
ctx.imageSmoothingEnabled = true;
|
|
815
|
+
ctx.imageSmoothingQuality = 'high';
|
|
816
|
+
ctx.drawImage(sc.c, -PAD, -PAD, SPAN, SPAN);
|
|
817
|
+
ctx.restore();
|
|
818
|
+
/* 3. large avatars: the texture is upscaled 2–3×, so a crisp hairline of
|
|
819
|
+
the environment along the lit side of the silhouette */
|
|
820
|
+
if (rig.dev >= 256 && mat.rim > 0 && mat.highlight > 0) {
|
|
821
|
+
ctx.save();
|
|
822
|
+
if (union)
|
|
823
|
+
ctx.clip(union);
|
|
824
|
+
else
|
|
825
|
+
ctx.globalCompositeOperation = 'source-atop';
|
|
826
|
+
ctx.setTransform(capM[0], capM[1], capM[2], capM[3], capM[4], capM[5]);
|
|
827
|
+
const al = Math.min(0.5, 0.3 * mat.rim * Math.min(1.4, mat.highlight));
|
|
828
|
+
const g = ctx.createLinearGradient(50 + lxy[0] * 50, 50 + lxy[1] * 50, 50 - lxy[0] * 50, 50 - lxy[1] * 50);
|
|
829
|
+
g.addColorStop(0, `rgba(235,244,255,${al.toFixed(3)})`);
|
|
830
|
+
g.addColorStop(0.45, `rgba(235,244,255,${(0.35 * al).toFixed(3)})`);
|
|
831
|
+
g.addColorStop(0.75, 'rgba(235,244,255,0)');
|
|
832
|
+
ctx.strokeStyle = g;
|
|
833
|
+
ctx.lineJoin = 'round';
|
|
834
|
+
ctx.lineWidth = 1.3;
|
|
835
|
+
ctx.stroke(cfg.path);
|
|
836
|
+
ctx.restore();
|
|
837
|
+
}
|
|
838
|
+
return true;
|
|
839
|
+
}
|