vireglass 3.1.1 → 3.2.1
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/README.md +5 -1
- package/dist/dom.cjs +68 -27
- package/dist/dom.cjs.map +1 -1
- package/dist/dom.d.cts +6 -1
- package/dist/dom.d.ts +6 -1
- package/dist/dom.js +70 -30
- package/dist/dom.js.map +1 -1
- package/package.json +1 -1
- package/src/dom/displacement.ts +9 -2
- package/src/dom/index.ts +46 -16
- package/src/dom/spectral-map.ts +36 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vireglass",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"description": "A physically-derived liquid glass material for interfaces. One material model, three render targets: WebGL2, live DOM, and Android AGSL.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Danil Kotlaev",
|
package/src/dom/displacement.ts
CHANGED
|
@@ -78,6 +78,14 @@ export type DisplacementState = {
|
|
|
78
78
|
morphs?: readonly MorphShape[];
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The `feDisplacementMap` scale for these optics. The map's pixels are a fraction of it and do not
|
|
83
|
+
* depend on it, so a change of strength alone, such as appear, is this attribute and not a bake.
|
|
84
|
+
*/
|
|
85
|
+
export function displacementScale(optics: VireGlassOptics, geometry: VireGlassGeometry): number {
|
|
86
|
+
return Math.min(optics.refraction * thicknessDp(geometry, optics) * SCALE_GAIN, MAX_SCALE);
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
/**
|
|
82
90
|
* The raw pixel buffer, with no canvas involved — the part actually worth unit-testing.
|
|
83
91
|
* `buildDisplacementMap` below is a thin DOM wrapper around this.
|
|
@@ -97,8 +105,7 @@ export function renderDisplacementPixels(
|
|
|
97
105
|
const density = mapDensity(geometry, bevel, dpr);
|
|
98
106
|
const width = Math.max(1, Math.round(geometry.width * density));
|
|
99
107
|
const height = Math.max(1, Math.round(geometry.height * density));
|
|
100
|
-
const
|
|
101
|
-
const scale = Math.min(rawScale, MAX_SCALE);
|
|
108
|
+
const scale = displacementScale(optics, geometry);
|
|
102
109
|
const smoothing = state.smoothing ?? 0;
|
|
103
110
|
// One list, whichever way the caller expressed it.
|
|
104
111
|
const shapes: (MorphShape | undefined)[] = [state.morph, state.morph2, ...(state.morphs ?? [])];
|
package/src/dom/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// See `docs/superpowers/specs/2026-09-19-vireglass-over-live-dom.md` for the technique and the
|
|
13
13
|
// three findings that make it work (objectBoundingBox, prefiltering the source, the 8-bit step).
|
|
14
14
|
import { ambientFrom, INK_DARK, INK_LIGHT, shouldInkBeLight, type BackdropSample } from '../adaptation';
|
|
15
|
-
import { buildDisplacementMap, type DisplacementMap } from './displacement';
|
|
15
|
+
import { buildDisplacementMap, displacementScale, type DisplacementMap } from './displacement';
|
|
16
16
|
import { buildSpectralMap, hasSpectralEdge, type SpectralMap } from './spectral-map';
|
|
17
17
|
import { cached } from './map-cache';
|
|
18
18
|
import { roundedRectGeometry, type VireGlassGeometry } from '../geometry';
|
|
@@ -132,6 +132,11 @@ function getFilterHost(host: Document | ShadowRoot): SVGSVGElement {
|
|
|
132
132
|
return svg;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** Every apply writes the presence, and most applies do not change it. */
|
|
136
|
+
function writeAttr(node: Element, name: string, value: string): void {
|
|
137
|
+
if (node.getAttribute(name) !== value) node.setAttribute(name, value);
|
|
138
|
+
}
|
|
139
|
+
|
|
135
140
|
/**
|
|
136
141
|
* Finding #1 (do not re-litigate): `objectBoundingBox` with an explicit unit box. `userSpaceOnUse`
|
|
137
142
|
* measures from something other than the element's own box and the displacement smears across
|
|
@@ -254,6 +259,7 @@ function withSpectral(filter: SVGFilterElement, spectral: SpectralMap | null): S
|
|
|
254
259
|
product.setAttribute('in2', 'hue');
|
|
255
260
|
product.setAttribute('operator', 'arithmetic');
|
|
256
261
|
product.setAttribute('k1', String(spectral.headroom));
|
|
262
|
+
product.setAttribute('data-vireglass-headroom', String(spectral.headroom));
|
|
257
263
|
product.setAttribute('k2', '0');
|
|
258
264
|
product.setAttribute('k3', '0');
|
|
259
265
|
product.setAttribute('k4', '0');
|
|
@@ -299,7 +305,7 @@ function decoded(url: string): Promise<void> {
|
|
|
299
305
|
return typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve();
|
|
300
306
|
}
|
|
301
307
|
|
|
302
|
-
type InterimMap = { map: DisplacementMap; ready: boolean };
|
|
308
|
+
type InterimMap = { map: DisplacementMap; ready: boolean; geometry: VireGlassGeometry };
|
|
303
309
|
|
|
304
310
|
/** Interim with no call for this long ends itself: a morph that never reported landing must not
|
|
305
311
|
* leave the glass on a stretched map for good. */
|
|
@@ -464,6 +470,8 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
464
470
|
let filterEl: SVGFilterElement | null = null;
|
|
465
471
|
let destroyed = false;
|
|
466
472
|
let mapKey = '';
|
|
473
|
+
// The box the shown map was baked for: its strength belongs to that map, also while interim stretches it.
|
|
474
|
+
let shownGeometry: VireGlassGeometry | null = null;
|
|
467
475
|
let interim = false;
|
|
468
476
|
let settling = false;
|
|
469
477
|
let lastSample: BackdropSample | null = null;
|
|
@@ -551,14 +559,34 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
551
559
|
apply();
|
|
552
560
|
}
|
|
553
561
|
|
|
554
|
-
function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null): void {
|
|
562
|
+
function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null, geometry: VireGlassGeometry): void {
|
|
555
563
|
const images = f.querySelectorAll('feImage');
|
|
556
564
|
images[0]?.setAttribute('href', map.url);
|
|
557
565
|
if (hue) images[1]?.setAttribute('href', hue.url);
|
|
566
|
+
shownGeometry = geometry;
|
|
567
|
+
present(f);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* The maps are baked at full presence and appear is these attributes. The spectral hue is
|
|
572
|
+
* `1 + appear * (hue - 1)`, so mixing the full one in is the same picture.
|
|
573
|
+
*/
|
|
574
|
+
function present(f: SVGFilterElement): void {
|
|
575
|
+
if (!shownGeometry) return;
|
|
576
|
+
const o = optics();
|
|
577
|
+
const scale = displacementScale(o, shownGeometry);
|
|
558
578
|
// EVERY pass, with its own share of the scale: see paintInteraction.
|
|
559
579
|
for (const pass of f.querySelectorAll('feDisplacementMap')) {
|
|
560
580
|
const ratio = Number(pass.getAttribute('data-vireglass-ratio') ?? 1);
|
|
561
|
-
pass
|
|
581
|
+
writeAttr(pass, 'scale', String(scale * (Number.isFinite(ratio) ? ratio : 1)));
|
|
582
|
+
}
|
|
583
|
+
const frost = f.querySelector('feGaussianBlur');
|
|
584
|
+
if (frost) writeAttr(frost, 'stdDeviation', String(Math.max(o.blur, 0)));
|
|
585
|
+
const hue = f.querySelector('feComposite[in2="hue"]');
|
|
586
|
+
if (hue) {
|
|
587
|
+
const headroom = Number(hue.getAttribute('data-vireglass-headroom') ?? 1);
|
|
588
|
+
writeAttr(hue, 'k1', String(headroom * appear));
|
|
589
|
+
writeAttr(hue, 'k2', String(1 - appear));
|
|
562
590
|
}
|
|
563
591
|
}
|
|
564
592
|
|
|
@@ -566,18 +594,18 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
566
594
|
if (!svgSupported || !filterEl || destroyed) return;
|
|
567
595
|
const geometry = opts.geometry ?? measureGeometry(el);
|
|
568
596
|
if (isEmpty(geometry)) return;
|
|
569
|
-
const
|
|
597
|
+
const full = settledOptics();
|
|
570
598
|
const key = interimKey(
|
|
571
599
|
geometry.width,
|
|
572
600
|
geometry.height,
|
|
573
601
|
geometry.cornerRadius,
|
|
574
602
|
JSON.stringify([morph?.smoothing, morph?.shape, morph?.shape2, morph?.shapes]),
|
|
575
|
-
|
|
603
|
+
`${full.refraction}:${full.refractionScale}:${full.bevelDp}`,
|
|
576
604
|
);
|
|
577
605
|
let rec = interimMaps.get(key);
|
|
578
606
|
if (!rec) {
|
|
579
607
|
bump('reducedBakes');
|
|
580
|
-
rec = { map: buildDisplacementMap(
|
|
608
|
+
rec = { map: buildDisplacementMap(full, geometry, Math.min(1, devicePixelRatio()), morphState()), ready: false, geometry };
|
|
581
609
|
interimMaps.set(key, rec);
|
|
582
610
|
}
|
|
583
611
|
const mine = (seq += 1);
|
|
@@ -586,7 +614,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
586
614
|
const show = () => {
|
|
587
615
|
if (destroyed || filterEl !== forFilter || mine <= shownSeq) return;
|
|
588
616
|
shownSeq = mine;
|
|
589
|
-
retarget(forFilter, record.map, null);
|
|
617
|
+
retarget(forFilter, record.map, null, record.geometry);
|
|
590
618
|
};
|
|
591
619
|
if (record.ready) {
|
|
592
620
|
show();
|
|
@@ -613,7 +641,8 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
613
641
|
// The map depends only on geometry, optics and density — never on the backdrop. Rebuilding
|
|
614
642
|
// it on every scroll would re-run a per-pixel loop and a PNG encode for a picture that did
|
|
615
643
|
// not change; the refraction itself updates in the compositor with no JS at all.
|
|
616
|
-
const
|
|
644
|
+
const full = settledOptics();
|
|
645
|
+
const key = `m${morphKey()}|${geometry.width}x${geometry.height}r${geometry.cornerRadius}@${devicePixelRatio()}:${full.refraction}:${full.refractionScale}:${full.bevelDp}:${full.blur}:${full.thicknessDp}:${full.film}:${full.iridescence}:${full.diffraction}`;
|
|
617
646
|
if (interim && filterEl) {
|
|
618
647
|
// The filter keeps its map; feImage stretches it over the live box.
|
|
619
648
|
} else if (isEmpty(geometry)) {
|
|
@@ -622,15 +651,15 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
622
651
|
// Keyed by exactly what the maps are functions of, which is exactly the cache key above.
|
|
623
652
|
const map = cached(`d|${key}`, () => {
|
|
624
653
|
bump('fullBakes');
|
|
625
|
-
return buildDisplacementMap(
|
|
654
|
+
return buildDisplacementMap(full, geometry, devicePixelRatio(), morphState());
|
|
626
655
|
});
|
|
627
|
-
const hue = hasSpectralEdge(
|
|
656
|
+
const hue = hasSpectralEdge(full)
|
|
628
657
|
? cached(`h|${key}`, () => {
|
|
629
658
|
bump('fullBakes');
|
|
630
|
-
return buildSpectralMap(
|
|
659
|
+
return buildSpectralMap(full, geometry, devicePixelRatio());
|
|
631
660
|
})
|
|
632
661
|
: null;
|
|
633
|
-
const signature = filterSignature(
|
|
662
|
+
const signature = filterSignature(full.blur, full.ior, full.iorSpread, !!hue);
|
|
634
663
|
if (settling && filterEl?.getAttribute('data-vireglass-signature') === signature) {
|
|
635
664
|
settling = false;
|
|
636
665
|
const mine = (seq += 1);
|
|
@@ -639,21 +668,23 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
639
668
|
void Promise.all([decoded(map.url), hue ? decoded(hue.url) : undefined]).then(() => {
|
|
640
669
|
if (destroyed || filterEl !== forFilter || mapKey !== forKey || mine <= shownSeq) return;
|
|
641
670
|
shownSeq = mine;
|
|
642
|
-
retarget(forFilter, map, hue);
|
|
671
|
+
retarget(forFilter, map, hue, geometry);
|
|
643
672
|
});
|
|
644
673
|
} else {
|
|
645
674
|
settling = false;
|
|
646
675
|
invalidate();
|
|
647
676
|
const defs = getFilterHost(host).querySelector('defs') as SVGDefsElement;
|
|
648
|
-
const next = buildFilterElement(id, map.url,
|
|
677
|
+
const next = buildFilterElement(id, map.url, full, map.scale, hue);
|
|
649
678
|
next.setAttribute('data-vireglass-signature', signature);
|
|
650
679
|
if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
|
|
651
680
|
else defs.appendChild(next);
|
|
652
681
|
bump('filterSwaps');
|
|
653
682
|
filterEl = next;
|
|
683
|
+
shownGeometry = geometry;
|
|
654
684
|
}
|
|
655
685
|
mapKey = key;
|
|
656
686
|
}
|
|
687
|
+
if (filterEl) present(filterEl);
|
|
657
688
|
el.style.setProperty('backdrop-filter', `url(#${id})`);
|
|
658
689
|
el.style.setProperty('-webkit-backdrop-filter', `url(#${id})`);
|
|
659
690
|
} else {
|
|
@@ -922,7 +953,6 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
922
953
|
if (next === appear) return;
|
|
923
954
|
appear = next;
|
|
924
955
|
apply();
|
|
925
|
-
if (interim) requestInterimMap();
|
|
926
956
|
},
|
|
927
957
|
setAccent(next) {
|
|
928
958
|
accent = next ?? undefined;
|
package/src/dom/spectral-map.ts
CHANGED
|
@@ -32,6 +32,25 @@ function mapDensity(bevel: number, dpr: number): number {
|
|
|
32
32
|
return Math.min(dpr, BEVEL_SAMPLES / bevel);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Intervals of the hue table along the depth, read with linear interpolation. */
|
|
36
|
+
const DEPTH_STEPS = 2048;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `spectralHue` is a function of the depth alone for one element, so it is tabled along the depth.
|
|
40
|
+
* The nodes sit at `(k / DEPTH_STEPS)²` of the bevel: the rim's slope turns fastest at the silhouette.
|
|
41
|
+
*/
|
|
42
|
+
function hueAlongDepth(bevel: number, rim: number, thick: number, optics: VireGlassOptics): Float64Array {
|
|
43
|
+
const table = new Float64Array((DEPTH_STEPS + 1) * 3);
|
|
44
|
+
for (let k = 0; k <= DEPTH_STEPS; k += 1) {
|
|
45
|
+
const s = k / DEPTH_STEPS;
|
|
46
|
+
const [r, g, b] = spectralHue(s * s * bevel, bevel, rim, thick, optics);
|
|
47
|
+
table[k * 3] = r;
|
|
48
|
+
table[k * 3 + 1] = g;
|
|
49
|
+
table[k * 3 + 2] = b;
|
|
50
|
+
}
|
|
51
|
+
return table;
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
export function renderSpectralPixels(
|
|
36
55
|
optics: VireGlassOptics,
|
|
37
56
|
geometry: VireGlassGeometry,
|
|
@@ -48,26 +67,31 @@ export function renderSpectralPixels(
|
|
|
48
67
|
const halfW = geometry.width / 2;
|
|
49
68
|
const halfH = geometry.height / 2;
|
|
50
69
|
const neutral = Math.round((1 / HEADROOM) * 255);
|
|
70
|
+
const table = hueAlongDepth(bevel, rim, thick, optics);
|
|
71
|
+
const toByte = 255 / HEADROOM;
|
|
72
|
+
// Past the bevel the glass is plain: a neutral multiplier, not a missing one.
|
|
73
|
+
new Uint32Array(data.buffer).fill(new Uint32Array(new Uint8ClampedArray([neutral, neutral, neutral, 255]).buffer)[0]!);
|
|
74
|
+
const corner = Math.max(geometry.cornerRadius, bevel);
|
|
51
75
|
|
|
52
76
|
for (let py = 0; py < height; py += 1) {
|
|
53
77
|
const y = (py + 0.5) / density - halfH;
|
|
78
|
+
const ay = Math.abs(y);
|
|
79
|
+
// Where |x| is within this, the rounded rect's distance field puts the point at least a bevel deep.
|
|
80
|
+
const plain = Math.max(ay <= halfH - bevel ? halfW - corner : -1, ay <= halfH - corner ? halfW - bevel : -1);
|
|
54
81
|
for (let px = 0; px < width; px += 1) {
|
|
55
82
|
const x = (px + 0.5) / density - halfW;
|
|
83
|
+
if (Math.abs(x) <= plain) continue;
|
|
56
84
|
const i = (py * width + px) * 4;
|
|
57
85
|
const sd = sdfRoundedRect(x, y, geometry.width, geometry.height, geometry.cornerRadius);
|
|
58
86
|
const e = Math.max(-sd, 0);
|
|
59
|
-
if (e >= bevel)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const [r, g, b] = spectralHue(e, bevel, rim, thick, optics);
|
|
68
|
-
data[i] = Math.round((r / HEADROOM) * 255);
|
|
69
|
-
data[i + 1] = Math.round((g / HEADROOM) * 255);
|
|
70
|
-
data[i + 2] = Math.round((b / HEADROOM) * 255);
|
|
87
|
+
if (e >= bevel) continue;
|
|
88
|
+
const u = Math.sqrt(e / bevel) * DEPTH_STEPS;
|
|
89
|
+
const k = Math.min(Math.floor(u), DEPTH_STEPS - 1);
|
|
90
|
+
const f = u - k;
|
|
91
|
+
const at = k * 3;
|
|
92
|
+
data[i] = Math.round((table[at] + (table[at + 3] - table[at]) * f) * toByte);
|
|
93
|
+
data[i + 1] = Math.round((table[at + 1] + (table[at + 4] - table[at + 1]) * f) * toByte);
|
|
94
|
+
data[i + 2] = Math.round((table[at + 2] + (table[at + 5] - table[at + 2]) * f) * toByte);
|
|
71
95
|
data[i + 3] = 255;
|
|
72
96
|
}
|
|
73
97
|
}
|