@cruxgarden/plasma-ui 0.3.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### A clear ground, so a second provider can sit above a scrim
6
+
7
+ The material was one canvas behind everything, so anything drawn above a
8
+ scrim - a dialog, a menu - could never be plasma. Two props make a second
9
+ provider possible on top of the first.
10
+
11
+ - **Added:** `ground` - `"field"` (the default, the old behaviour) paints the
12
+ background everywhere; `"clear"` leaves the canvas transparent outside the
13
+ surfaces, draws only the surfaces, their shadows and rims, and refracts the
14
+ `background` source sampled exactly (no swirl), so it lines up with what is
15
+ beneath. Alpha is the surface's coverage, the shadow's darkness outside it.
16
+ - **Added:** `preserveDrawingBuffer` - keep each frame after it is shown so
17
+ another provider can pass this canvas as its `background`. Fixed at
18
+ creation; off by default.
19
+ - **Added:** the form-in is observable. A surface's element carries
20
+ `data-plasma-forming` (`FORMING_ATTR`) from `register` until its form-in
21
+ has settled, and dispatches `plasmaforming` / `plasmaformed`
22
+ (`FORMING_EVENT` / `FORMED_EVENT`, bubbling); `<Plasma>` exposes them as
23
+ `onForming` / `onFormed`. Hold the contents back and reveal them in
24
+ `onFormed`, or style them off the attribute.
25
+ - The pair: the first provider with `preserveDrawingBuffer` and a
26
+ `<PlasmaCanvas className="ground" />`; the second, inside the dialog's own
27
+ stacking context, with `ground="clear"` and `background={groundCanvas}`,
28
+ its `<PlasmaCanvas>` between the scrim and the panel, and the panel
29
+ registered on it.
30
+
5
31
  ### Every look the material adds is now a control
6
32
 
7
33
  Six things gave the plasma a look of its own and only two could be turned off,
package/README.md CHANGED
@@ -64,6 +64,19 @@ export function App() {
64
64
  | `wash` | `number` | `1` | How much of its own cast the material puts on what you see through it; `0` passes the background straight through |
65
65
  | `grain` | `number` | `1` | Film grain over the background (never over the surfaces); `0` turns it off |
66
66
  | `backgroundBlur` | `number` | `0` | Blur the background itself, in CSS px (0-40). Unlike `frost`, which blurs only what a frosted surface sees, this softens the whole field |
67
+ | `ground` | `"field" \| "clear"` | `"field"` | What the canvas shows where there is no surface. `"clear"` leaves it transparent, so a second provider's canvas can sit above other content (a dialog above a scrim) and draw only its surfaces; pass the first provider's canvas as its `background` and the surfaces refract it |
68
+ | `preserveDrawingBuffer` | `boolean` | `false` | Keep each frame after it is shown so another provider can sample this canvas as its `background`. Fixed at creation |
69
+ While a surface forms in (about half a second, never under reduced motion) its element carries `data-plasma-forming` (`FORMING_ATTR`) and dispatches `plasmaforming` then `plasmaformed` (`FORMING_EVENT`, `FORMED_EVENT`; bubbling, `detail.id`). Style the children off the attribute, or listen for the events, to have the contents arrive after the material: `[data-plasma-forming] > * { opacity: 0 }` with a transition on opacity. `<Plasma>` wraps the events as `onForming` / `onFormed`; a surface registered by hand gets them on its element.
70
+
71
+ | `material` | `MaterialName` | `"plasma"` | What the surfaces are made of: `plasma`, `crystal`, `metal`, `wood`, `stone` or `cloud`. Every material shares the same geometry, springs and fusing and differs only in how it is shaded - see [`examples/materials`](examples/materials) |
72
+ | `lightDir` | `[number, number, number]` | up-left, front | Where the one light comes from. Every opaque material reads it, so two of them on a page agree about the sun |
73
+ | `roughness` | `number` | `0.28` | Surface finish for `metal`: 0 is a mirror, 1 is chalk |
74
+ | `anisotropy` | `number` | `0` | How far a highlight stretches along the grain. Brushed metal and varnished wood both want it |
75
+ | `edge` | `number` | `0` | How far the outline is displaced from its rounded box, in CSS px. A rounded rectangle is right for a liquid and wrong for most else: stone chips, cloud billows, cut metal does neither |
76
+ | `edgeScale` | `number` | `0.01` | Size of that displacement, in cycles per px: small is billows, large is chips |
77
+ | `edgeSharpness` | `number` | `0` | `0` rolls the displaced edge, `1` breaks it into flats and points |
78
+ | `thickness` | `number` | `18` | How thick a panel is **as a solid**, in CSS px. The marched materials light a body of this depth rather than shading a flat card |
79
+ | `tension` | `number` | `0` | Surface tension: how hard the material pulls its own shape toward a bead, and how eagerly two of them merge. `mercury` runs high |
67
80
  | `pointerDrop` | `boolean` | `true` | Liquid drop that follows the pointer |
68
81
  | `ambientDrops` | `boolean` | `false` | Decorative orbiting drops |
69
82
  | `grid`, `magnet` | `number` | `24`, `40` | Snap grid size and edge latch distance |
@@ -92,6 +105,7 @@ Accepts all HTML attributes, plus the following:
92
105
  | `offset` / `defaultOffset` | `{ x, y }` | | Controlled or initial offset; changes spring into place |
93
106
  | `onDragStart`, `onDragEnd(offset)` | | | Drag lifecycle; `onDragEnd` gets the settled offset |
94
107
  | `onJoinChange(joined)` | | | Fires when the surface fuses with or separates from a neighbor |
108
+ | `onForming()`, `onFormed()` | | | The form-in: starts (not under reduced motion), and has settled (at once under reduced motion) — reveal the contents in `onFormed` |
95
109
 
96
110
  Everything else you pass goes to the rendered element. `PlasmaProps<C>` is the
97
111
  full prop type for `<Plasma as={C}>`; `PlasmaOwnProps` is just the table above,
@@ -129,6 +143,69 @@ It takes `className`, `style` and `zIndex`. Note that this places and styles
129
143
  the **element**; the renderer still draws the whole viewport. Confining the
130
144
  field to a container is [roadmap](#roadmap) work, not something this prop does.
131
145
 
146
+ ## Above a scrim: a second canvas
147
+
148
+ The material is one canvas behind everything, so a dialog above a scrim, or a
149
+ menu above the pane it opened from, could never be plasma. Two props make a
150
+ second provider possible on top of the first. The ground keeps its frames
151
+ (`preserveDrawingBuffer`) and hands its canvas out; the overlay draws with a
152
+ clear ground (`ground="clear"`): transparent outside its surfaces, only the
153
+ surfaces, their shadows and rims drawn, and what they refract is the ground
154
+ canvas passed as `background`, sampled exactly so it lines up with the page.
155
+
156
+ ```tsx
157
+ // The ground, once, behind the app.
158
+ <PlasmaProvider mood="tidal" preserveDrawingBuffer canvas={false}>
159
+ <PlasmaCanvas zIndex={-1} className="ground" />
160
+ <App />
161
+ </PlasmaProvider>
162
+
163
+ // A dialog: its own provider inside its own stacking context, the canvas
164
+ // between the scrim and the panel, the panel registered on it.
165
+ function Dialog({ children }) {
166
+ const ground = document.querySelector("canvas.ground");
167
+ return (
168
+ <div className="dialog-root"> {/* position: fixed; inset: 0 */}
169
+ <div className="scrim" />
170
+ <PlasmaProvider ground="clear" background={ground} canvas={false}
171
+ pointerDrop={false} grain={0} glow={0} maxSurfaces={2}>
172
+ <PlasmaCanvas style={{ position: "absolute", zIndex: 1 }} />
173
+ <Plasma className="panel" elevation={0.7} style={{ zIndex: 2 }}>
174
+ {children}
175
+ </Plasma>
176
+ </PlasmaProvider>
177
+ </div>
178
+ );
179
+ }
180
+ ```
181
+
182
+ Each overlay is a full render pass while it is open, at the same size as the
183
+ ground, plus one texture upload of the ground canvas per frame. Mount it with
184
+ the dialog and unmount it with the dialog; nothing runs while none is open.
185
+ The page under the scrim is not in the sample - WebGL cannot read the DOM -
186
+ but the scrim has already dimmed it to the field. Give the panel no fill,
187
+ border or shadow of its own: the rim is its edge and the elevation its shadow.
188
+
189
+ ## Contents after the material
190
+
191
+ A surface forms in over about half a second when it registers. Content drawn
192
+ on top of it from the first frame looks as if it arrived before its panel,
193
+ so the form-in is observable: the element carries `data-plasma-forming`
194
+ (`FORMING_ATTR`) while it runs and dispatches `plasmaforming` then
195
+ `plasmaformed` (`FORMING_EVENT`, `FORMED_EVENT`; bubbling, `detail.id`).
196
+ Under reduced motion there is no form-in: no attribute, and `plasmaformed`
197
+ at once. `<Plasma>` wraps the events as `onForming` / `onFormed`.
198
+
199
+ ```css
200
+ /* hold the contents back, then fade them in once the material has arrived */
201
+ [data-plasma-forming] > * { opacity: 0; transition: none; }
202
+ .panel > * { transition: opacity 160ms ease-out; }
203
+ ```
204
+
205
+ ```tsx
206
+ <Plasma onFormed={() => setReady(true)}>{ready && <Contents />}</Plasma>
207
+ ```
208
+
132
209
  ## Clear as water
133
210
 
134
211
  Six things give the material a look of its own, and each is a separate
@@ -202,13 +279,14 @@ NOTE: `flow` ripples the outline, so leave it at `0` whenever flush edges should
202
279
  - Place surfaces together or further apart than the Blend distance. Smaller gaps render as liquid bridging them.
203
280
  - Up to `maxSurfaces` (default 16) draw at once; offscreen panels are skipped first, and the library warns in the console when it has to drop any. Two render passes loop over every slot per pixel, so set this value only as high as you need. Changing it recompiles the shaders, so change it when the layout changes, not per frame.
204
281
  - Lean and Pulse use the CSS `translate` and `scale` properties, and Drag uses `transform`. Avoid setting these properties on `Plasma` elements yourself.
205
- - `prefers-reduced-motion` disables Lean, Pulse, the pointer Drop, and Spring.
282
+ - `prefers-reduced-motion` disables Lean, Pulse, the pointer Drop, and Spring - and the form-in, so `plasmaformed` fires at once.
283
+ - A second provider above a scrim (`ground="clear"`) is a full extra render pass; mount it with the dialog and unmount it with the dialog.
206
284
 
207
285
  ## Roadmap
208
286
 
209
287
  Ordered by priority:
210
288
 
211
- 1. **Layers** - panels that will stack instead of fusing. For use with dialogs, menus, and such.
289
+ 1. **Layers** - panels that will stack instead of fusing. A dialog above a scrim is possible today with a second provider ([above a scrim](#above-a-scrim-a-second-canvas)); true stacking inside one canvas is still to come.
212
290
  2. **Drag handles and resize** - will add a `handle` prop for dragging, so panel content can be fully interactive. Also, edge resizing with grid snapping.
213
291
  3. **Scroll clipping** - plasma confined to scrollable containers.
214
292
  4. **Pluggable Backgrounds** - colors, images, and live canvas/video shipped in 0.1 (`background` prop); custom shaders are next.
package/dist/Plasma.d.ts CHANGED
@@ -47,6 +47,10 @@ export interface PlasmaOwnProps {
47
47
  onDragEnd?: (offset: Offset) => void;
48
48
  /** Fires when the surface fuses with or separates from a neighbor. */
49
49
  onJoinChange?: (joined: boolean) => void;
50
+ /** Fires as the surface starts forming in (not under reduced motion). The element carries `data-plasma-forming` meanwhile. */
51
+ onForming?: () => void;
52
+ /** Fires once the surface has formed in — at once under reduced motion. Reveal the contents here. */
53
+ onFormed?: () => void;
50
54
  }
51
55
  /**
52
56
  * Props for `<Plasma as={C}>`: Plasma's own, plus everything `C` accepts.
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { BackgroundSource, PlasmaRenderer } from "./renderer";
2
+ import { BackgroundSource, MaterialName, PlasmaRenderer } from "./renderer";
3
3
  import { Mood, MoodName } from "./moods";
4
4
  export interface PlasmaProviderProps {
5
5
  children?: React.ReactNode;
@@ -40,8 +40,52 @@ export interface PlasmaProviderProps {
40
40
  wash?: number;
41
41
  /** Film grain over the background (never over the surfaces). 0 turns it off. Default 1. */
42
42
  grain?: number;
43
+ /**
44
+ * What the surfaces are made of: "plasma" (the default), "crystal",
45
+ * "metal", "wood", "stone" or "cloud". Every material shares the same
46
+ * geometry, springs and fusing and differs only in how it is shaded.
47
+ */
48
+ material?: MaterialName;
49
+ /**
50
+ * Where the one light comes from, as a direction. Every opaque material
51
+ * reads it, so two of them on a page agree about the sun. Default is up and
52
+ * to the left, in front.
53
+ */
54
+ lightDir?: [number, number, number];
55
+ /** Surface finish for metal: 0 is a mirror, 1 is chalk. Default 0.28. */
56
+ roughness?: number;
57
+ /** How far a highlight stretches along the grain. 0 is isotropic. Default 0. */
58
+ anisotropy?: number;
59
+ /**
60
+ * How far the outline is displaced from its rounded box, in CSS px. A
61
+ * rounded rectangle is right for a liquid and wrong for almost everything
62
+ * else: stone chips, cloud billows, cut metal does neither. Default 0.
63
+ */
64
+ edge?: number;
65
+ /** Size of that displacement, in cycles per px: small is billows, large is chips. Default 0.01. */
66
+ edgeScale?: number;
67
+ /** 0 rolls the displaced edge, 1 breaks it into flats and points. Default 0. */
68
+ edgeSharpness?: number;
69
+ /** How thick a panel is as a solid, in CSS px. The marched materials light a body of this depth. Default 18. */
70
+ thickness?: number;
71
+ /** Surface tension: how hard a material pulls its own shape toward a bead, and how eagerly two merge. Default 0. */
72
+ tension?: number;
43
73
  /** Blur the background itself, in CSS px, 0-40. Softens the whole field, unlike `frost`, which blurs only what a frosted surface sees. Default 0. */
44
74
  backgroundBlur?: number;
75
+ /**
76
+ * What the canvas shows where there is no surface. "field" paints the
77
+ * background everywhere. "clear" leaves it transparent, so this provider's
78
+ * canvas can sit above other content - a dialog above a scrim - and draw
79
+ * only its surfaces, their shadows and rims; what they refract is the
80
+ * `background` source, usually another provider's canvas. Default "field".
81
+ */
82
+ ground?: "field" | "clear";
83
+ /**
84
+ * Keep each frame after it is shown, so another provider can pass this
85
+ * canvas as its `background` and sample it live (the pair a clear-ground
86
+ * overlay needs). Costs a copy per frame. Fixed at creation. Default false.
87
+ */
88
+ preserveDrawingBuffer?: boolean;
45
89
  /** How thick the material feels: 0 is watery and bouncy, 1 is slow like syrup. Also scales drag and snap springs. Default 0.5. */
46
90
  viscosity?: number;
47
91
  /** How far the plasma trails and stretches behind moving panels. 0 turns it off. Default 1. */
@@ -148,4 +192,4 @@ export interface PlasmaCanvasProps {
148
192
  * supported yet.
149
193
  */
150
194
  export declare function PlasmaCanvas({ className, style, zIndex }: PlasmaCanvasProps): React.JSX.Element;
151
- export declare function PlasmaProvider({ children, mood, theme, blend, refraction, dispersion, rim, smoothness, background, radius, tint, opacity, frost, elevation, viscosity, stretch, flow, rimColor, rimWidth, highlight, edgeLine, shimmer, glow, wash, grain, backgroundBlur, pointerDrop, ambientDrops, grid, magnet, quality, maxSurfaces, zIndex, freezeOnScroll, canvas, }: PlasmaProviderProps): React.JSX.Element;
195
+ export declare function PlasmaProvider({ children, mood, theme, blend, refraction, dispersion, rim, smoothness, background, radius, tint, opacity, frost, elevation, viscosity, stretch, flow, rimColor, rimWidth, highlight, edgeLine, shimmer, glow, wash, grain, backgroundBlur, ground, preserveDrawingBuffer, material, lightDir, roughness, anisotropy, edge, edgeScale, edgeSharpness, thickness, tension, pointerDrop, ambientDrops, grid, magnet, quality, maxSurfaces, zIndex, freezeOnScroll, canvas, }: PlasmaProviderProps): React.JSX.Element;
package/dist/index.d.ts CHANGED
@@ -18,3 +18,6 @@ export { PlasmaRenderer } from "./renderer";
18
18
  export { makeShaders, DEFAULT_MAX_SHAPES } from "./shaders";
19
19
  /** @internal */
20
20
  export type { BackgroundSource, JoinedSides, RendererSettings, ShapeHandle, ShapeOptions } from "./renderer";
21
+ /** The materials a surface can be made of. */
22
+ export { MATERIALS, FORMING_ATTR, FORMING_EVENT, FORMED_EVENT } from "./renderer";
23
+ export type { MaterialName } from "./renderer";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ in vec2 p; void main(){ gl_Position = vec4(p, 0., 1.); }`;
9
9
  function makeShaders(MAX_SHAPES) {
10
10
  const common = `
11
11
  precision highp float;
12
- uniform vec2 uRes; uniform vec4 uView; uniform float uScale, uTime, uGoo, uEnergy, uLight, uMouseAmt, uDropR, uAmbient, uScroll, uVisc, uFlow;
12
+ uniform vec2 uRes; uniform vec4 uView; uniform float uScale, uTime, uGoo, uEnergy, uLight, uMouseAmt, uDropR, uAmbient, uScroll, uVisc, uFlow, uTension, uThick;
13
13
  uniform vec2 uMouse;
14
14
  uniform vec4 uP[${MAX_SHAPES}]; uniform vec4 uR[${MAX_SHAPES}]; uniform float uF[${MAX_SHAPES}]; uniform vec4 uT[${MAX_SHAPES}]; uniform float uFr[${MAX_SHAPES}]; uniform float uEl[${MAX_SHAPES}]; uniform float uSolo[${MAX_SHAPES}];
15
15
  uniform int uCount;
@@ -128,6 +128,7 @@ uniform vec2 uImgRes;
128
128
  uniform float uHasImg;
129
129
  uniform vec3 uBgColor;
130
130
  uniform float uBgSolid;
131
+ uniform float uClear;
131
132
  out vec4 o;
132
133
  vec3 bg(vec2 p){
133
134
  p.y += uScroll;
@@ -161,6 +162,15 @@ void main(){
161
162
  o = vec4(uBgColor * (1. + shade), 1.);
162
163
  return;
163
164
  }
165
+ if (uHasImg > .5 && uClear > .5) {
166
+ // a clear ground samples what is beneath it exactly - no swirl, no warp -
167
+ // so a refraction of it lines up with the page it sits on
168
+ float sc = max(uView.z / uImgRes.x, uView.w / uImgRes.y);
169
+ vec2 uv = (p - uView.xy - .5*uView.zw) / (uImgRes * sc) + .5;
170
+ o = vec4(texture(uImg, clamp(uv, 0., 1.)).rgb, 1.);
171
+ return;
172
+ }
173
+ if (uClear > .5) { o = vec4(0., 0., 0., 1.); return; }
164
174
  if (uHasImg > .5) {
165
175
  // image background: slow swirl plus the pulse warp above
166
176
  vec2 q = bp;
@@ -179,12 +189,38 @@ void main(){
179
189
  ${common}
180
190
  uniform sampler2D uH, uS, uTint, uBg, uBgM, uBgH, uFrost;
181
191
  uniform float uRefract, uDisp, uRim, uRimMode, uRimWidth, uSpec, uHair, uShim, uGlow, uWash, uGrain;
192
+ // Which material the surfaces are made of, and the one light every opaque one
193
+ // reads. Glass fakes its lighting off the pointer because you see through it;
194
+ // the moment anything is opaque, two materials disagreeing about where the sun
195
+ // is looks broken in a way no single shader can fix. So there is one light.
196
+ uniform float uMat;
197
+ // 1 when the ground is clear: alpha is the surface's coverage (and its
198
+ // shadow's darkness), so the canvas can sit above other content.
199
+ uniform float uClear;
200
+ uniform vec3 uLightDir;
201
+ // Surface finish, shared by every material that has one: 0 is a mirror, 1 is
202
+ // chalk. Anisotropy stretches the highlight along the grain \u2014 brushed metal
203
+ // and varnished wood both need it, and neither reads right without it.
204
+ uniform float uRough, uAniso;
205
+ // How thick a panel is, in CSS px, and how much the material rounds itself
206
+ // against that. Mercury has enormous surface tension, so a bead pulls toward
207
+ // a sphere; tension drives both the corner radius and how eagerly two beads
208
+ // merge into one body.
209
+ // The edge treatment. A rounded rectangle is right for a liquid, and wrong for
210
+ // almost everything else: stone chips, cloud billows, metal is cut. These
211
+ // displace the silhouette itself \u2014 amplitude in CSS px, scale in cycles \u2014
212
+ // before anything decides what is inside, so the outline, the rim, the shadow
213
+ // and the material all agree about where the panel ends.
214
+ uniform float uEdge, uEdgeScale, uEdgeSharp;
182
215
  uniform vec3 uRimColor;
183
216
  out vec4 o;
184
217
 
185
218
  vec3 pal(float t){ return .5 + .5*cos(6.2831*(t + vec3(0., .33, .67))); }
186
219
  float H(vec2 uv){ return texture(uH, uv).r; }
187
- vec2 uvAt(vec2 q){ return vec2(q.x, uRes.y - q.y) / uRes; }
220
+ // Clamped, because a reflection offset is far larger than a refraction one:
221
+ // metal sweeps up to 150px and walks straight off the texture, where
222
+ // CLAMP_TO_EDGE smears the last row of pixels into arcs across the panel.
223
+ vec2 uvAt(vec2 q){ return clamp(vec2(q.x, uRes.y - q.y) / uRes, vec2(.001), vec2(.999)); }
188
224
  // background seen through plasma with frost f: sharp, then medium, then heavy blur
189
225
  vec3 seen(vec2 q, float f){
190
226
  vec2 u = uvAt(q);
@@ -193,6 +229,248 @@ vec3 seen(vec2 q, float f){
193
229
  vec3 soft = mix(texture(uBgM, u).rgb, texture(uBgH, u).rgb, smoothstep(.45, 1., f));
194
230
  return mix(sharp, soft, smoothstep(0., .45, f));
195
231
  }
232
+ // \u2500\u2500 Shading kit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
233
+ // A real microfacet BRDF, not pow(ndl, n). GGX + height-correlated Smith +
234
+ // Schlick is what every modern renderer uses, and it is the single biggest
235
+ // difference between a material that reads as itself and one that reads as
236
+ // coloured plastic.
237
+ float D_GGX(float ndh, float a){ float a2 = a*a; float d = ndh*ndh*(a2-1.)+1.; return a2/(3.14159265*d*d + 1e-7); }
238
+ // Anisotropic GGX: the highlight stretches along a direction. This is the tell
239
+ // for wood and for brushed metal \u2014 an isotropic highlight on wood looks wrong
240
+ // even when nothing else does.
241
+ float D_GGXaniso(float ndh, float tdh, float bdh, float ax, float ay){
242
+ float d = tdh*tdh/(ax*ax) + bdh*bdh/(ay*ay) + ndh*ndh;
243
+ return 1./(3.14159265*ax*ay*d*d + 1e-7);
244
+ }
245
+ float V_SmithGGX(float ndv, float ndl, float a){
246
+ float a2 = a*a;
247
+ float gv = ndl*sqrt(ndv*ndv*(1.-a2)+a2);
248
+ float gl = ndv*sqrt(ndl*ndl*(1.-a2)+a2);
249
+ return .5/max(gv+gl, 1e-5);
250
+ }
251
+ vec3 F_Schlick(vec3 f0, float u){ return f0 + (1.-f0)*pow(clamp(1.-u,0.,1.), 5.); }
252
+
253
+ // Lighting has to happen in linear light. Doing it in gamma space is the
254
+ // oldest way to make a render look cheap: highlights clip early and midtones
255
+ // go chalky. sqrt/square is the standard cheap sRGB pair.
256
+ vec3 toLinear(vec3 c){ return c*c; }
257
+ vec3 toSrgb(vec3 c){ return sqrt(max(c, 0.)); }
258
+ // ACES filmic, fitted. Keeps a bright specular from flattening into white.
259
+ vec3 tonemap(vec3 x){ return clamp((x*(2.51*x+.03))/(x*(2.43*x+.59)+.14), 0., 1.); }
260
+
261
+ // Plain fbm reads as fractal mush. Warping the domain by another fbm is what
262
+ // makes grain and rock look grown rather than generated \u2014 the background
263
+ // shader already leans on this.
264
+ vec2 warp(vec2 q, float k){
265
+ return q + k*vec2(fbm(q + vec2(1.7, 9.2)), fbm(q + vec2(5.3, 2.8)));
266
+ }
267
+
268
+ // Metal only reads as metal when it reflects something with structure, and a
269
+ // page background is nearly uniform \u2014 reflect it alone and you get grey paint
270
+ // with a shiny rim. So reflective materials also see a small studio: a sky
271
+ // gradient, a horizon, and a sun in the same direction as the light. Roughness
272
+ // widens the sun and flattens the band, which is what a prefiltered
273
+ // environment map does for real.
274
+ vec3 envmap(vec3 R, float rough){
275
+ float h = clamp(R.y * .5 + .5, 0., 1.);
276
+ vec3 sky = mix(vec3(.62, .70, .84), vec3(.10, .13, .19), pow(1. - h, 1.5));
277
+ vec3 ground = vec3(.055, .05, .048);
278
+ vec3 c = mix(ground, sky, smoothstep(.40, .60, h));
279
+ vec3 L = normalize(uLightDir);
280
+ float sun = pow(max(dot(R, L), 0.), mix(260., 5., rough));
281
+ c += vec3(1., .96, .88) * sun * (1. - rough * .7) * 2.2;
282
+ // the horizon band is what gives a curved metal edge its sweep
283
+ c += vec3(.75, .82, .95) * (1. - smoothstep(.0, .22, abs(h - .54))) * .45 * (1. - rough * .55);
284
+ return c;
285
+ }
286
+
287
+ // \u2500\u2500 The scene as a solid \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
288
+ // Everything above shades a flat card: a 2D silhouette, a perturbed normal,
289
+ // a texture lookup. That is skinning, and it is why wood came out looking
290
+ // like a wood-effect laminate. A material reads as a material when there is a
291
+ // body to light \u2014 so the same shapes are extruded into a slab and the view ray
292
+ // is marched through it, the way a game would.
293
+ //
294
+ // The 2D mask is a free conservative bound: outside it there is nothing to
295
+ // hit, so the march never starts.
296
+ float map3(vec3 q, float tension, float thick){
297
+ float d = 1e5;
298
+ for (int i = 0; i < uCount; i++) {
299
+ float f = uF[i];
300
+ if (f < .005) continue;
301
+ vec2 c = uP[i].xy;
302
+ vec2 h = uP[i].zw * f;
303
+ float rmax = max(max(uR[i].x, uR[i].y), max(uR[i].z, uR[i].w));
304
+ // The footprint is the element's, exactly as plasma's is. Mercury is not
305
+ // a blob that swallows the layout \u2014 it is a panel, poured. Tension belongs
306
+ // to the edge and to the merge, not to the shape: pulling the whole
307
+ // footprint toward a disc threw the panel away and left content floating
308
+ // over nothing.
309
+ float d2 = sdBoxG(q.xy - c, h, min(uR[i], vec4(min(h.x, h.y))), rmax).x;
310
+ // Extrude, with the rim rolled over so the slab has a shoulder rather than
311
+ // a cut edge. Tension fattens that roll \u2014 where a bead of mercury reads as
312
+ // poured is its edge, not its outline.
313
+ float roll = thick * (.22 + .50 * tension);
314
+ vec2 w = vec2(d2 + roll, abs(q.z) - (thick - roll));
315
+ float de = min(max(w.x, w.y), 0.) + length(max(w, 0.)) - roll;
316
+ d = (i == 0) ? de : smin(d, de, uGoo * (.6 + 1.8 * tension));
317
+ }
318
+ // Two slow swells across the whole body, displacing the surface itself
319
+ // rather than tilting a normal \u2014 the march finds a moving surface, so the
320
+ // reflection travels over real waves. This is what separates water from a
321
+ // picture of water.
322
+ // Displacing a distance field costs it the property the march depends on:
323
+ // d is no longer a safe distance, so a full step overshoots the surface and
324
+ // the ray lands inside, which is the mottled crust this produced at first.
325
+ // The amplitude is kept small and the march below steps at a fraction, which
326
+ // is the standard price of displacement.
327
+ if (tension > .01) {
328
+ float wv = (fbm(q.xy * .0075 + vec2(uTime * .11, uTime * .06)) - .5) * 1.6
329
+ + (fbm(q.xy * .019 - vec2(uTime * .08, uTime * .13)) - .5) * .6;
330
+ d -= wv * thick * .07 * tension;
331
+ }
332
+ return d;
333
+ }
334
+
335
+ /**
336
+ * A crystalline crown: the panel's face is a regular triangular lattice, and
337
+ * every triangle is a flat facet with its own plane.
338
+ *
339
+ * Regular, not random \u2014 a Delaunay scatter reads as shattered safety glass,
340
+ * and a lattice reads as something cut. The heights at the lattice corners are
341
+ * hashed, so the facets tilt differently while the pattern stays symmetric.
342
+ *
343
+ * The normal comes out exact rather than differenced, which is what makes the
344
+ * faces crisp: a facet is a plane, so its normal is constant across it, and
345
+ * there is nothing for a march to stipple.
346
+ */
347
+ vec3 facetPlane(vec2 uv, float amp, out float height){
348
+ vec2 c = floor(uv), f = fract(uv);
349
+ float h00 = hash(c) * amp;
350
+ float h10 = hash(c + vec2(1., 0.)) * amp;
351
+ float h01 = hash(c + vec2(0., 1.)) * amp;
352
+ float h11 = hash(c + vec2(1., 1.)) * amp;
353
+ float dx, dy;
354
+ // Which way the cell's diagonal runs is decided per cell. One diagonal
355
+ // everywhere reads as corduroy; alternating reads as cut.
356
+ bool flip = hash(c + 91.7) > .5;
357
+ bool lower = flip ? (f.x + f.y < 1.) : (f.x > f.y);
358
+ if (lower) {
359
+ height = h00 + (h10 - h00) * f.x + (h01 - h00) * f.y;
360
+ dx = h10 - h00; dy = h01 - h00;
361
+ } else {
362
+ height = h11 + (h01 - h11) * (1. - f.x) + (h10 - h11) * (1. - f.y);
363
+ dx = h11 - h01; dy = h11 - h10;
364
+ }
365
+ return normalize(vec3(-dx, -dy, 1.));
366
+ }
367
+
368
+ /**
369
+ * The crystal's top surface, as a height in page px, plus the exact plane
370
+ * normal of whichever facet the point lies on. This is a real surface: the
371
+ * march below has to find where the ray crosses it, so the facets occlude one
372
+ * another, break the silhouette, and shadow each other. A normal returned
373
+ * without an intersection would be a normal map, which is what the version
374
+ * before this was.
375
+ */
376
+ float crownZ(vec2 xy, float gcut, float amp, float thick, out vec3 nrm){
377
+ float h;
378
+ nrm = facetPlane(xy / gcut, amp / gcut, h);
379
+ return thick + h * gcut;
380
+ }
381
+
382
+ // \u2500\u2500 The gem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
383
+ // Facet directions, spread over a hemisphere and mirrored, standing in for a
384
+ // cut stone's crown and pavilion. Generated rather than tabulated so the count
385
+ // is a constant to change, not a table to rewrite.
386
+ vec3 facetN(int i){
387
+ float fi = float(i);
388
+ float a = fi * 2.3999632; // golden angle, so they do not band
389
+ float z = mix(.22, .92, fract(fi * .6180339));
390
+ float r = sqrt(max(0., 1. - z * z));
391
+ return normalize(vec3(cos(a) * r, sin(a) * r, z));
392
+ }
393
+
394
+ /**
395
+ * The panel as a piece of gem. The slab is the element's own footprint,
396
+ * extruded; the facets are half-spaces cut through it.
397
+ *
398
+ * The cuts are anchored in PAGE space, not the panel's, which is the whole
399
+ * trick: a facet plane runs on across the gap and slices the next panel too,
400
+ * so the panels read as pieces carved out of one stone rather than as
401
+ * separate gems that happen to match.
402
+ */
403
+ float mapGem(vec3 q, float thick, float cut){
404
+ float d = 1e5;
405
+ for (int i = 0; i < uCount; i++) {
406
+ float f = uF[i];
407
+ if (f < .005) continue;
408
+ vec2 c = uP[i].xy;
409
+ vec2 h = uP[i].zw * f;
410
+ float rmax = max(max(uR[i].x, uR[i].y), max(uR[i].z, uR[i].w));
411
+ float d2 = sdBoxG(q.xy - c, h, min(uR[i], vec4(min(h.x, h.y))), rmax).x;
412
+ vec2 w = vec2(d2, abs(q.z) - thick);
413
+ float de = min(max(w.x, w.y), 0.) + length(max(w, 0.)) - 1.5;
414
+ d = (i == 0) ? de : min(d, de);
415
+ }
416
+ // Cut the stone. Each plane repeats on a lattice so the whole page is one
417
+ // crystal the panels are taken out of. Six directions, and the lattice is
418
+ // wide \u2014 a few facets across a panel, not hundreds. Cut it finely and a gem
419
+ // stops being a gem and becomes gravel, which is exactly what nine planes at
420
+ // 34px produced on the first try.
421
+ for (int i = 0; i < 6; i++) {
422
+ vec3 nn = facetN(i);
423
+ if (fract(float(i) * .5) > .25) nn.z = -nn.z; // mirror half of them below
424
+ // The plane sits near the far end of its lattice cell, so a cut trims a
425
+ // corner instead of slicing the body in half. Six planes at 70% of the
426
+ // cell removed most of the volume and left the panels as floating shards.
427
+ // A gem is faceted, not shattered.
428
+ float hgt = dot(q, nn);
429
+ float plane = floor(hgt / cut) * cut + cut * .965;
430
+ d = max(d, hgt - plane);
431
+ }
432
+ return d;
433
+ }
434
+
435
+ vec3 gemNormal(vec3 q, float thick, float cut){
436
+ vec2 e = vec2(.4, 0.);
437
+ return normalize(vec3(
438
+ mapGem(q + e.xyy, thick, cut) - mapGem(q - e.xyy, thick, cut),
439
+ mapGem(q + e.yxy, thick, cut) - mapGem(q - e.yxy, thick, cut),
440
+ mapGem(q + e.yyx, thick, cut) - mapGem(q - e.yyx, thick, cut)
441
+ ));
442
+ }
443
+
444
+ vec3 normal3(vec3 q, float tension, float thick){
445
+ vec2 e = vec2(.75, 0.);
446
+ return normalize(vec3(
447
+ map3(q + e.xyy, tension, thick) - map3(q - e.xyy, tension, thick),
448
+ map3(q + e.yxy, tension, thick) - map3(q - e.yxy, tension, thick),
449
+ map3(q + e.yyx, tension, thick) - map3(q - e.yyx, tension, thick)
450
+ ));
451
+ }
452
+
453
+ // Worley/Voronoi: nearest cell, second nearest, and the cell's id. The gap
454
+ // between the two is the distance to a cell wall, which is what draws the
455
+ // seams of a crystal without any geometry.
456
+ vec4 voronoi(vec2 q){
457
+ vec2 ip = floor(q), fp = fract(q);
458
+ float d1 = 8., d2 = 8.;
459
+ vec2 id = vec2(0.);
460
+ for (int j = -1; j <= 1; j++) {
461
+ for (int i = -1; i <= 1; i++) {
462
+ vec2 g = vec2(float(i), float(j));
463
+ vec2 cell = ip + g;
464
+ vec2 o = vec2(hash(cell), hash(cell + 31.7));
465
+ vec2 r = g + o - fp;
466
+ float d = dot(r, r);
467
+ if (d < d1) { d2 = d1; d1 = d; id = cell; }
468
+ else if (d < d2) { d2 = d; }
469
+ }
470
+ }
471
+ return vec4(sqrt(d1), sqrt(d2), id);
472
+ }
473
+
196
474
  // B-spline bicubic from four bilinear taps keeps curved outlines round.
197
475
  float S(vec2 uv){
198
476
  vec2 ts = vec2(textureSize(uS, 0));
@@ -219,9 +497,23 @@ void main(){
219
497
  float s = S(uv);
220
498
  vec2 gs = vec2(S(uv+vec2(tx.x,0.)) - S(uv-vec2(tx.x,0.)), S(uv+vec2(0.,tx.y)) - S(uv-vec2(0.,tx.y))) / (2.*cssPerTexel);
221
499
  float sd = clamp((s - .5) / max(length(gs), 1e-3), -60., 60.);
500
+ if (uEdge > .01) {
501
+ // Two octaves: the first gives the large shape (billows, chips), the
502
+ // second the fine break-up. Warping keeps it from reading as a sine wave
503
+ // stamped around the outline.
504
+ vec2 ep = warp(p * uEdgeScale, .7);
505
+ float e1 = fbm(ep) - .5;
506
+ float e2 = fbm(ep * 3.3 + 17.) - .5;
507
+ float disp = e1 * 1.5 + e2 * .55;
508
+ // uEdgeSharp 0 leaves it rolling; 1 pushes it toward flats and points,
509
+ // which is what reads as chipped rather than melted.
510
+ disp = mix(disp, sign(disp) * pow(abs(disp) * 2., .55) * .5, clamp(uEdgeSharp, 0., 1.));
511
+ sd += disp * uEdge;
512
+ }
222
513
 
223
514
  vec3 back = texture(uBg, uv).rgb;
224
515
  float grain = 1.;
516
+ float cover = 0.;
225
517
 
226
518
  // shadow: offset and strength follow the surface's elevation
227
519
  float msk = max(texture(uS, uv).r, 1e-3);
@@ -281,17 +573,317 @@ void main(){
281
573
  if (uRimMode > .5 && uRimMode < 1.5) rimCol = uRimColor * facing * 1.4;
282
574
  else if (uRimMode > 1.5) rimCol = tcol * facing * 1.4;
283
575
 
576
+ // A 3D normal from the 2D height gradient: flat in the middle, turning
577
+ // outward and down across the bevel. Every material shades from this, and
578
+ // the opaque ones perturb it further with their own detail.
579
+ vec3 Nb = normalize(vec3(n * (.30 + .70 * bevel), max(.20, 1. - bevel)));
580
+ vec3 Ldir = normalize(uLightDir);
581
+ vec3 V = vec3(0., 0., 1.); // orthographic view, straight on
284
582
  vec3 plasma = refr;
285
- plasma += rimCol * fres * .45 * hl * uRim;
286
- plasma += vec3(1.) * spec * .75 * hl * uSpec;
287
- // faint shimmer across the body; fades out as the tint becomes opaque
288
- plasma += pal(uTime*.04 + vp.y/900. + uEnergy*.3) * .05 * lift * (1.+uEnergy*2.) * hl * (1. - talpha) * uShim;
289
- vec3 hairCol = uRimMode > .5 ? mix(vec3(1.), rimCol / 1.4, .6) : vec3(.9,.95,1.);
290
- plasma += hairCol * (1.-smoothstep(0., 1.6, abs(sd - .7))) * .4 * hl * uHair;
291
-
292
583
  float a = smoothstep(-.8, .8, sd);
584
+
585
+ if (uMat < .5) {
586
+ // \u2500\u2500 plasma \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
587
+ plasma += rimCol * fres * .45 * hl * uRim;
588
+ plasma += vec3(1.) * spec * .75 * hl * uSpec;
589
+ // faint shimmer across the body; fades out as the tint becomes opaque
590
+ plasma += pal(uTime*.04 + vp.y/900. + uEnergy*.3) * .05 * lift * (1.+uEnergy*2.) * hl * (1. - talpha) * uShim;
591
+ vec3 hairCol = uRimMode > .5 ? mix(vec3(1.), rimCol / 1.4, .6) : vec3(.9,.95,1.);
592
+ plasma += hairCol * (1.-smoothstep(0., 1.6, abs(sd - .7))) * .4 * hl * uHair;
593
+ } else if (uMat < 1.5) {
594
+ // \u2500\u2500 crystal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
595
+ // Actual 3D. The panel is a solid whose top is a cut crown \u2014 a lattice
596
+ // of flat facets, anchored in PAGE space so a facet runs on across the
597
+ // gap into the next panel. The view ray is marched until it crosses that
598
+ // surface, so the facets genuinely occlude each other, break the
599
+ // silhouette where they rise and fall, and shadow one another.
600
+ //
601
+ // A height field is marched by stepping and testing which side of the
602
+ // surface you are on, then bisecting \u2014 not by sphere tracing, which a
603
+ // piecewise-planar field is hostile to.
604
+ float gcut = max(uThick * 2.4, 52.);
605
+ float amp = gcut * .45;
606
+ float gthick = max(uThick, 10.);
607
+
608
+ vec3 gro = vec3(p, gthick + amp * 1.6 + 4.);
609
+ vec3 grd = normalize(vec3((p - uMouse) * .00055, -1.));
610
+
611
+ float tPrev = 0., tHit = -1.;
612
+ vec3 nHit = vec3(0., 0., 1.);
613
+ float stepLen = max(amp * .16, 1.2);
614
+ for (int i = 0; i < 64; i++) {
615
+ float t = float(i) * stepLen;
616
+ vec3 q = gro + grd * t;
617
+ // Outside the panel's footprint there is no stone to hit.
618
+ if (scene(q.xy) > 0.) { tPrev = t; continue; }
619
+ vec3 nn;
620
+ float sz = crownZ(q.xy, gcut, amp, gthick, nn);
621
+ if (q.z <= sz) {
622
+ // Crossed it. Bisect for the exact facet plane.
623
+ float lo = tPrev, hi = t;
624
+ for (int k = 0; k < 6; k++) {
625
+ float mid = (lo + hi) * .5;
626
+ vec3 qm = gro + grd * mid;
627
+ vec3 nm;
628
+ float szm = crownZ(qm.xy, gcut, amp, gthick, nm);
629
+ if (qm.z <= szm) { hi = mid; nHit = nm; } else { lo = mid; }
630
+ }
631
+ tHit = hi;
632
+ break;
633
+ }
634
+ tPrev = t;
635
+ }
636
+
637
+ if (tHit < 0.) {
638
+ a = 0.; // the ray passed over the stone
639
+ } else {
640
+ vec3 q0 = gro + grd * tHit;
641
+ vec3 Nf = nHit;
642
+ float ndv = max(dot(Nf, -grd), 1e-3);
643
+ float ndl = max(dot(Nf, Ldir), 0.);
644
+ vec3 H = normalize(Ldir - grd);
645
+
646
+ // Shadow: march back toward the light across the crown. This is the
647
+ // thing a normal map can never do \u2014 one facet standing in another's
648
+ // light.
649
+ float shade = 1.;
650
+ for (int i = 1; i <= 10; i++) {
651
+ vec3 qs = q0 + Ldir * (float(i) * amp * .30);
652
+ vec3 nn;
653
+ float sz = crownZ(qs.xy, gcut, amp, gthick, nn);
654
+ if (qs.z < sz - .5 && scene(qs.xy) < 0.) { shade = .25; break; }
655
+ }
656
+
657
+ // Through the stone: refract in, cross the body, refract out at the
658
+ // underside.
659
+ float ior = 1.85;
660
+ vec3 rin = refract(grd, Nf, 1. / ior);
661
+ vec3 nn2;
662
+ float h2;
663
+ vec3 Nb2 = facetPlane((q0.xy + rin.xy * gthick * 2.) / gcut + 23.7, amp / gcut, h2);
664
+ vec3 rout = refract(rin, -Nb2, ior);
665
+ if (dot(rout, rout) < 1e-6) rout = reflect(rin, -Nb2);
666
+
667
+ float disp = .06 * max(uDisp, .001) * 6.;
668
+ vec2 off2 = rout.xy * gthick * 4.;
669
+ vec3 thru = toLinear(vec3(
670
+ seen(p + off2 * (1. + disp), fr).r,
671
+ seen(p + off2, fr).g,
672
+ seen(p + off2 * (1. - disp), fr).b
673
+ ));
674
+
675
+ vec3 body = toLinear(mix(vec3(.62, .48, .92), tcol, talpha));
676
+ vec3 lit = body * (.10 + 1.15 * ndl * shade);
677
+ lit += thru * body * 1.2 * (.25 + .75 * ndv);
678
+
679
+ vec3 F0 = vec3(.13);
680
+ float fres = pow(1. - ndv, 4.);
681
+ lit = mix(lit, envmap(reflect(grd, Nf), .02), F0.x + (1. - F0.x) * fres * .9);
682
+
683
+ float aa = .018 * .018;
684
+ lit += F_Schlick(F0, max(dot(H, -grd), 0.))
685
+ * D_GGX(max(dot(Nf, H), 0.), aa) * V_SmithGGX(ndv, ndl, aa) * ndl * 26. * uSpec * shade;
686
+ plasma = toSrgb(tonemap(lit));
687
+ a = 1.;
688
+ }
689
+ } else if (uMat < 3.5) {
690
+ bool merc = uMat > 2.5;
691
+ if (merc) {
692
+ // \u2500\u2500 mercury \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
693
+ // Marched, not painted. The ray enters the slab, and every normal
694
+ // below comes from the solid's own gradient \u2014 which is what makes a
695
+ // bead read as a bead instead of a rectangle with a chrome gradient
696
+ // on it. Surface tension does the rest: the footprint rounds toward a
697
+ // disc, and two panels within reach pull into one body with a neck.
698
+ float thick = max(uThick, 8.);
699
+ float tension = clamp(uTension, 0., 1.);
700
+ vec3 ro = vec3(p, thick * 3.5);
701
+ vec3 rd = normalize(vec3((p - uMouse) * .00035, -1.)); // a hair of perspective
702
+ float t = 0., hit = -1.;
703
+ for (int i = 0; i < 96; i++) {
704
+ vec3 q = ro + rd * t;
705
+ float dd = map3(q, tension, thick);
706
+ if (dd < .35) { hit = t; break; }
707
+ t += max(dd * .30, .25);
708
+ if (t > thick * 8.) break;
709
+ }
710
+ if (hit < 0.) {
711
+ a = 0.; // the march missed: leave the ground
712
+ } else {
713
+ vec3 q = ro + rd * hit;
714
+ vec3 Nm = normal3(q, tension, thick);
715
+ float ndv = max(dot(Nm, -rd), 1e-3);
716
+ float ndl = max(dot(Nm, Ldir), 0.);
717
+ vec3 H = normalize(Ldir - rd);
718
+ vec3 R = reflect(rd, Nm);
719
+
720
+ float rough = .04;
721
+ // A real reflection off a real normal, of the studio and of what is
722
+ // actually behind the bead.
723
+ vec2 roff = R.xy * 210.;
724
+ vec3 env = mix(envmap(R, rough), toLinear(seen(p - roff, rough)), .30);
725
+ vec3 F0 = mix(vec3(.86, .87, .90), toLinear(tcol), talpha);
726
+ vec3 Fr = F_Schlick(F0, ndv);
727
+ float aa = max(rough * rough, 1e-4);
728
+ vec3 sun = F_Schlick(F0, max(dot(H, -rd), 0.))
729
+ * D_GGX(max(dot(Nm, H), 0.), aa)
730
+ * V_SmithGGX(ndv, ndl, aa) * ndl;
731
+ vec3 me = env * Fr + sun * 16. * uSpec;
732
+ me += F0 * .03;
733
+ // Shimmer comes from the modelled swell moving under a sharp
734
+ // highlight, not from a noise field tinting the surface. The only
735
+ // colour added is thin-film interference, which is a function of the
736
+ // angle the geometry presents \u2014 no texture in it.
737
+ me += toLinear(pal(pow(1. - ndv, 1.6) * 1.4)) * .12 * uShim * pow(1. - ndv, .9);
738
+ plasma = toSrgb(tonemap(me));
739
+ // The silhouette is the march's, so a bead that has pulled away from
740
+ // the rectangle actually looks pulled away.
741
+ a = 1.;
742
+ }
743
+ } else {
744
+ // \u2500\u2500 metal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
745
+ // Milled, not poured: flat stock with a cut edge. Still the screen
746
+ // space path, which is honest for a flat sheet.
747
+ float rough = clamp(uRough, .04, .95);
748
+ vec3 Nm = Nb;
749
+ float aniso = uAniso;
750
+ if (aniso > .01) {
751
+ float brush = fbm(vec2(p.x * .9, p.y * 22.)) - .5;
752
+ Nm = normalize(Nm + vec3(0., brush * aniso * .55, 0.));
753
+ }
754
+ float mr = fbm(p * .035) - .5;
755
+ Nm = normalize(Nm + vec3(mr * .16, (fbm(p * .035 + 11.) - .5) * .16, 0.));
756
+ float ndv = max(dot(Nm, V), 1e-3);
757
+ float ndl = max(dot(Nm, Ldir), 0.);
758
+ vec3 H = normalize(Ldir + V);
759
+ vec3 R = reflect(-V, Nm);
760
+ vec2 roff = R.xy * pow(bevel, .9) * 150. * (1. - rough * .5);
761
+ vec3 env = mix(envmap(R, rough), toLinear(seen(p - roff, rough)), .22);
762
+ vec3 F0 = mix(vec3(.95, .93, .88), toLinear(tcol), talpha);
763
+ vec3 Fr = F_Schlick(F0, ndv);
764
+ float aa = max(rough * rough, 1e-3);
765
+ float D = aniso > .01
766
+ ? D_GGXaniso(max(dot(Nm, H), 0.), dot(vec3(1.,0.,0.), H), dot(vec3(0.,1.,0.), H), aa * (1. + aniso * 3.), aa)
767
+ : D_GGX(max(dot(Nm, H), 0.), aa);
768
+ vec3 sun = F_Schlick(F0, max(dot(H, V), 0.)) * D * V_SmithGGX(ndv, ndl, aa) * ndl;
769
+ vec3 me = env * Fr + sun * 14. * uSpec;
770
+ me += F0 * .035;
771
+ plasma = toSrgb(tonemap(me));
772
+ }
773
+ } else if (uMat < 4.5) {
774
+ // \u2500\u2500 wood \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
775
+ // Grain lives in page coordinates, so a panel that resizes keeps its
776
+ // grain instead of stretching it \u2014 the first thing an opaque natural
777
+ // material asks for that glass never did.
778
+ vec2 wp = p * .0021;
779
+ // Rings are warped, then squeezed across one axis: straight fbm reads as
780
+ // camouflage, warped fbm reads as a board cut from a trunk.
781
+ vec2 wq = warp(vec2(wp.x * .40, wp.y * 4.2), .55);
782
+ float ring = fract(fbm(wq) * 2.05);
783
+ float band = abs(ring - .5) * 2.;
784
+ float pore = fbm(vec2(p.x * .18, p.y * .022));
785
+ float h0 = band * .7 + pore * .3;
786
+ // Detail normal by finite difference of that height, so the grain
787
+ // actually catches the light instead of being painted on.
788
+ vec2 e = vec2(1.4, 0.);
789
+ float hx = fract(fbm(warp(vec2((wp.x + e.x*.0021) * .40, wp.y * 4.2), .55)) * 2.05);
790
+ float hy = fract(fbm(warp(vec2(wp.x * .40, (wp.y + e.x*.0021) * 4.2), .55)) * 2.05);
791
+ hx = abs(hx - .5) * 2.; hy = abs(hy - .5) * 2.;
792
+ vec3 Nw = normalize(Nb + vec3((hx - band) * .85, (hy - band) * .85, 0.));
793
+
794
+ // Real boards are lower contrast than the first instinct: a wide dark-to
795
+ // -light sweep reads as charring, not as timber.
796
+ vec3 lightWood = toLinear(vec3(.62, .45, .28));
797
+ vec3 darkWood = toLinear(vec3(.40, .26, .145));
798
+ // A board is mostly one tone with the grain reading as a whisper over it.
799
+ vec3 alb = mix(darkWood, lightWood, smoothstep(.30, .70, band) * .55 + .22);
800
+ alb *= .95 + .09 * pore;
801
+ alb = mix(alb, toLinear(tcol), talpha * .6);
802
+
803
+ float ndv = max(dot(Nw, V), 1e-3);
804
+ float ndl = max(dot(Nw, Ldir), 0.);
805
+ vec3 H = normalize(Ldir + V);
806
+ // Anisotropic along the grain: a varnished board's highlight is a streak.
807
+ float aa = .34 * .34;
808
+ float D = D_GGXaniso(max(dot(Nw, H), 0.), dot(vec3(1.,0.,0.), H), dot(vec3(0.,1.,0.), H), aa * 4., aa);
809
+ vec3 F = F_Schlick(vec3(.045), max(dot(H, V), 0.));
810
+ vec3 wo = alb * (.16 + .95 * ndl);
811
+ wo += F * D * V_SmithGGX(ndv, ndl, aa) * ndl * 5.5 * uSpec;
812
+ wo += alb * pow(1. - ndv, 3.5) * .35;
813
+ plasma = toSrgb(tonemap(wo));
814
+ } else if (uMat < 5.5) {
815
+ // \u2500\u2500 stone \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
816
+ vec2 sq = warp(p * .022, .9);
817
+ float body = fbm(sq) * .62 + fbm(sq * 3.7) * .26 + fbm(sq * 11.) * .12;
818
+ float fleck = hash(floor(p * 2.2));
819
+ float vein = smoothstep(.52, .58, fbm(sq * .55 + 7.1));
820
+ float h0 = body + (fleck - .5) * .35;
821
+ float hx = fbm(warp((p + vec2(1.6, 0.)) * .022, .9)) + (hash(floor((p + vec2(1.6,0.)) * 2.2)) - .5) * .35;
822
+ float hy = fbm(warp((p + vec2(0., 1.6)) * .022, .9)) + (hash(floor((p + vec2(0.,1.6)) * 2.2)) - .5) * .35;
823
+ vec3 Ns = normalize(Nb + vec3((hx - h0) * 3.4, (hy - h0) * 3.4, 0.));
824
+
825
+ vec3 alb = mix(toLinear(vec3(.24, .235, .25)), toLinear(vec3(.55, .545, .55)), body);
826
+ alb = mix(alb, toLinear(vec3(.70, .69, .67)), vein * .5);
827
+ alb *= .92 + (fleck - .5) * .18;
828
+ alb = mix(alb, toLinear(tcol), talpha * .6);
829
+
830
+ float ndv = max(dot(Ns, V), 1e-3);
831
+ float ndl = max(dot(Ns, Ldir), 0.);
832
+ vec3 H = normalize(Ldir + V);
833
+ float aa = .72 * .72; // stone is rough
834
+ vec3 F = F_Schlick(vec3(.035), max(dot(H, V), 0.));
835
+ vec3 st = alb * (.20 + .95 * ndl);
836
+ st += F * D_GGX(max(dot(Ns, H), 0.), aa) * V_SmithGGX(ndv, ndl, aa) * ndl * 3.2 * uSpec;
837
+ st += alb * pow(1. - ndv, 4.) * .25; // dusty edge
838
+ plasma = toSrgb(tonemap(st));
839
+ } else {
840
+ // \u2500\u2500 cloud \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
841
+ // The one volume. The SDF is the boundary, density is noise inside it,
842
+ // and the light is marched rather than dotted: transmittance by
843
+ // Beer-Lambert, scattering by Henyey-Greenstein. A lambert cloud looks
844
+ // painted; a marched one looks lit from a direction.
845
+ // Bounded tight to the silhouette, which the edge displacement has
846
+ // already made billowy \u2014 the panel's outline is the cloud's outline. The
847
+ // interior is dense, so it reads as a thing made of cloud rather than as
848
+ // haze lying over a rectangle.
849
+ float inside = smoothstep(-1., 9., sd);
850
+ vec2 dq = warp(p * .0058 + vec2(uTime * .010, uTime * .004), 1.15);
851
+ float base = fbm(dq) * .58 + fbm(dq * 2.7) * .28 + fbm(dq * 6.9) * .14;
852
+ float dens = clamp(inside * (.55 + base * 1.15) - .12, 0., 1.);
853
+
854
+ // March toward the light and accumulate what it has to pass through.
855
+ float shadow = 0.;
856
+ vec2 step2 = normalize(Ldir.xy + vec2(1e-4)) * 16.;
857
+ for (int i = 1; i <= 5; i++) {
858
+ vec2 sp3 = p + step2 * float(i);
859
+ float ins = smoothstep(-1., 9., sd + float(i) * 5.);
860
+ vec2 q3 = warp(sp3 * .0058 + vec2(uTime * .010, uTime * .004), 1.15);
861
+ shadow += clamp(ins * (.55 + fbm(q3) * 1.15) - .12, 0., 1.);
862
+ }
863
+ float transmit = exp(-shadow * .55);
864
+ float cosT = dot(normalize(vec3(n, .6)), Ldir);
865
+ float g = .45;
866
+ float hg = (1. - g*g) / pow(1. + g*g - 2.*g*cosT, 1.5) * .0796;
867
+ vec3 sunCol = toLinear(vec3(1., .96, .89));
868
+ vec3 skyCol = toLinear(vec3(.42, .50, .64));
869
+ vec3 cl = skyCol * (.35 + .65 * dens) + sunCol * transmit * (.55 + 5.5 * hg);
870
+ cl = mix(cl, toLinear(tcol), talpha * .45);
871
+ plasma = toSrgb(tonemap(cl));
872
+ a = smoothstep(.04, .42, dens); // density is the coverage, not the outline
873
+ }
874
+
293
875
  col = mix(col, plasma, a);
294
876
  grain = 1. - a; // grain is background-only; panels stay clean
877
+ cover = a;
878
+ }
879
+ if (uClear > .5) {
880
+ // Outside a surface only its shadow lands on the page: black at the
881
+ // shadow's darkness. Inside, the surface itself. The edge blends the two
882
+ // by coverage, un-premultiplied for a non-premultiplied canvas.
883
+ float shade = clamp(shStr * smoothstep(.02, .55, hs) * (1. - uLight*.6), 0., 1.);
884
+ float alpha = max(cover, shade);
885
+ o = vec4(col * (cover / max(alpha, 1e-4)), alpha);
886
+ return;
295
887
  }
296
888
  // Keyed on viewport position, so a frame drawn for a taller region has the
297
889
  // same grain in its viewport band as the live frame that replaces it.
@@ -317,6 +909,14 @@ function hexToRgb(hex) {
317
909
  }
318
910
 
319
911
  // src/renderer.ts
912
+ var MATERIALS = ["plasma", "crystal", "metal", "mercury", "wood", "stone", "cloud"];
913
+ var FORMING_ATTR = "data-plasma-forming";
914
+ var FORMING_EVENT = "plasmaforming";
915
+ var FORMED_EVENT = "plasmaformed";
916
+ function dispatch(el, name, id) {
917
+ if (typeof CustomEvent === "undefined" || typeof el.dispatchEvent !== "function") return;
918
+ el.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: { id } }));
919
+ }
320
920
  var UNIFORMS = [
321
921
  "uRes",
322
922
  "uView",
@@ -370,7 +970,17 @@ var UNIFORMS = [
370
970
  "uShim",
371
971
  "uGlow",
372
972
  "uWash",
373
- "uGrain"
973
+ "uGrain",
974
+ "uClear",
975
+ "uMat",
976
+ "uLightDir",
977
+ "uRough",
978
+ "uAniso",
979
+ "uEdge",
980
+ "uEdgeScale",
981
+ "uEdgeSharp",
982
+ "uThick",
983
+ "uTension"
374
984
  ];
375
985
  var MASK_SCALE = 0.5;
376
986
  var MAX_PIXELS = 26e5;
@@ -629,6 +1239,11 @@ var _PlasmaRenderer = class _PlasmaRenderer {
629
1239
  r.formV += (170 * (1 - r.form) - 26 * r.formV) * dt;
630
1240
  r.form += r.formV * dt;
631
1241
  } else r.form = 1;
1242
+ if (r.forming && r.form > 0.985) {
1243
+ r.forming = false;
1244
+ r.el.removeAttribute(FORMING_ATTR);
1245
+ dispatch(r.el, FORMED_EVENT, r.id);
1246
+ }
632
1247
  const b = elementBox(r.el);
633
1248
  r.box = b;
634
1249
  if (b.w === 0 || b.l > right || b.t > bottom || b.l + b.w < left || b.t + b.h < top) {
@@ -755,7 +1370,11 @@ var _PlasmaRenderer = class _PlasmaRenderer {
755
1370
  }
756
1371
  /** Returns null when WebGL2 is unavailable. */
757
1372
  static create(canvas, settings) {
758
- const gl = canvas.getContext("webgl2", { antialias: false, premultipliedAlpha: false });
1373
+ const gl = canvas.getContext("webgl2", {
1374
+ antialias: false,
1375
+ premultipliedAlpha: false,
1376
+ preserveDrawingBuffer: !!settings.preserveDrawingBuffer
1377
+ });
759
1378
  if (!gl) return null;
760
1379
  try {
761
1380
  return new _PlasmaRenderer(canvas, gl, settings);
@@ -860,6 +1479,7 @@ var _PlasmaRenderer = class _PlasmaRenderer {
860
1479
  form: this.settings.reducedMotion ? 1 : 0,
861
1480
  formV: 0,
862
1481
  removing: false,
1482
+ forming: false,
863
1483
  lx: 0,
864
1484
  ly: 0,
865
1485
  leanCss: "",
@@ -877,6 +1497,11 @@ var _PlasmaRenderer = class _PlasmaRenderer {
877
1497
  drawn: null,
878
1498
  elevNow: -1
879
1499
  };
1500
+ if (!this.settings.reducedMotion && typeof el.setAttribute === "function") {
1501
+ rec.forming = true;
1502
+ el.setAttribute(FORMING_ATTR, "");
1503
+ dispatch(el, FORMING_EVENT, id);
1504
+ } else dispatch(el, FORMED_EVENT, id);
880
1505
  this.recs.set(id, rec);
881
1506
  return {
882
1507
  id,
@@ -892,6 +1517,7 @@ var _PlasmaRenderer = class _PlasmaRenderer {
892
1517
  remove: () => {
893
1518
  el.style.translate = "";
894
1519
  el.style.scale = "";
1520
+ if (rec.forming) el.removeAttribute(FORMING_ATTR);
895
1521
  this.recs.delete(id);
896
1522
  }
897
1523
  };
@@ -1092,6 +1718,8 @@ var _PlasmaRenderer = class _PlasmaRenderer {
1092
1718
  gl.uniform3fv(u.uA, this.colors[0]);
1093
1719
  gl.uniform3fv(u.uB, this.colors[1]);
1094
1720
  gl.uniform3fv(u.uC, this.colors[2]);
1721
+ gl.uniform1f(u.uTension, s.tension);
1722
+ gl.uniform1f(u.uThick, s.thickness);
1095
1723
  };
1096
1724
  const bl = this.progs.blur;
1097
1725
  const pass = (src, dst, dx, dy) => {
@@ -1127,6 +1755,7 @@ var _PlasmaRenderer = class _PlasmaRenderer {
1127
1755
  const bc = this.bgColor;
1128
1756
  gl.uniform3f(this.progs.bg.u.uBgColor, bc?.[0] ?? 0, bc?.[1] ?? 0, bc?.[2] ?? 0);
1129
1757
  gl.uniform1f(this.progs.bg.u.uBgSolid, bc ? 1 : 0);
1758
+ gl.uniform1f(this.progs.bg.u.uClear, s.ground === "clear" ? 1 : 0);
1130
1759
  gl.drawArrays(gl.TRIANGLES, 0, 3);
1131
1760
  gl.activeTexture(gl.TEXTURE0);
1132
1761
  gl.viewport(0, 0, this.rtA.w, this.rtA.h);
@@ -1152,7 +1781,8 @@ var _PlasmaRenderer = class _PlasmaRenderer {
1152
1781
  if (this.T[i * 4 + 3] > 2e-3) hasTint = true;
1153
1782
  if (this.EL[i] > 2e-3) hasElev = true;
1154
1783
  }
1155
- if (hasFrost) {
1784
+ const reflective = s.material === "metal" || s.material === "crystal";
1785
+ if (hasFrost || reflective) {
1156
1786
  pass(this.rtBg, this.rtB, 1.5 * k, 0);
1157
1787
  pass(this.rtB, this.rtBgM, 0, 2.5 * k);
1158
1788
  pass(this.rtBgM, this.rtB, 2.5 * k, 0);
@@ -1215,6 +1845,16 @@ var _PlasmaRenderer = class _PlasmaRenderer {
1215
1845
  gl.uniform1f(c.u.uGlow, s.glow);
1216
1846
  gl.uniform1f(c.u.uWash, s.wash);
1217
1847
  gl.uniform1f(c.u.uGrain, s.grain);
1848
+ gl.uniform1f(c.u.uClear, s.ground === "clear" ? 1 : 0);
1849
+ gl.uniform1f(c.u.uMat, Math.max(0, MATERIALS.indexOf(s.material)));
1850
+ gl.uniform3f(c.u.uLightDir, s.lightDir[0], s.lightDir[1], s.lightDir[2]);
1851
+ gl.uniform1f(c.u.uRough, s.roughness);
1852
+ gl.uniform1f(c.u.uAniso, s.anisotropy);
1853
+ gl.uniform1f(c.u.uEdge, s.edge);
1854
+ gl.uniform1f(c.u.uEdgeScale, s.edgeScale);
1855
+ gl.uniform1f(c.u.uEdgeSharp, s.edgeSharpness);
1856
+ gl.uniform1f(c.u.uThick, s.thickness);
1857
+ gl.uniform1f(c.u.uTension, s.tension);
1218
1858
  gl.activeTexture(gl.TEXTURE0);
1219
1859
  gl.bindTexture(gl.TEXTURE_2D, this.rtC.tex);
1220
1860
  gl.activeTexture(gl.TEXTURE1);
@@ -1426,6 +2066,17 @@ function PlasmaProvider({
1426
2066
  wash = 1,
1427
2067
  grain = 1,
1428
2068
  backgroundBlur = 0,
2069
+ ground = "field",
2070
+ preserveDrawingBuffer = false,
2071
+ material = "plasma",
2072
+ lightDir = [-0.42, -0.62, 0.66],
2073
+ roughness = 0.28,
2074
+ anisotropy = 0,
2075
+ edge = 0,
2076
+ edgeScale = 0.01,
2077
+ edgeSharpness = 0,
2078
+ thickness = 18,
2079
+ tension = 0,
1429
2080
  pointerDrop = true,
1430
2081
  ambientDrops = false,
1431
2082
  grid = 24,
@@ -1465,6 +2116,17 @@ function PlasmaProvider({
1465
2116
  wash,
1466
2117
  grain,
1467
2118
  backgroundBlur,
2119
+ ground,
2120
+ preserveDrawingBuffer,
2121
+ material,
2122
+ lightDir,
2123
+ roughness,
2124
+ anisotropy,
2125
+ edge,
2126
+ edgeScale,
2127
+ edgeSharpness,
2128
+ thickness,
2129
+ tension,
1468
2130
  viscosity,
1469
2131
  stretch,
1470
2132
  flow,
@@ -1684,6 +2346,8 @@ var PlasmaInner = forwardRef(function Plasma({
1684
2346
  onDragStart,
1685
2347
  onDragEnd,
1686
2348
  onJoinChange,
2349
+ onForming,
2350
+ onFormed,
1687
2351
  className,
1688
2352
  style,
1689
2353
  children,
@@ -1704,6 +2368,8 @@ var PlasmaInner = forwardRef(function Plasma({
1704
2368
  const [dragging, setDragging] = useState2(false);
1705
2369
  const positioned = draggable || !!offset || !!defaultOffset;
1706
2370
  const joinCb = useLatest(onJoinChange);
2371
+ const formingCb = useLatest(onForming);
2372
+ const formedCb = useLatest(onFormed);
1707
2373
  const sidesStore = useConst(createSidesStore);
1708
2374
  const sides = useSyncExternalStore(sidesStore.subscribe, sidesStore.get, noSides);
1709
2375
  const runtimeRef = useLatest(runtime);
@@ -1730,6 +2396,10 @@ var PlasmaInner = forwardRef(function Plasma({
1730
2396
  useIsoLayoutEffect(() => {
1731
2397
  const node = el.current, ren = runtime.renderer;
1732
2398
  if (!node || !ren) return;
2399
+ const onForming2 = () => formingCb.current?.();
2400
+ const onFormed2 = () => formedCb.current?.();
2401
+ node.addEventListener(FORMING_EVENT, onForming2);
2402
+ node.addEventListener(FORMED_EVENT, onFormed2);
1733
2403
  const h = ren.register(node, optsRef.current, (j) => joinCb.current?.(j), sidesStore.set);
1734
2404
  if (positioned) {
1735
2405
  h.setLayoutBox(() => {
@@ -1748,6 +2418,8 @@ var PlasmaInner = forwardRef(function Plasma({
1748
2418
  return () => {
1749
2419
  h.remove();
1750
2420
  handle.current = null;
2421
+ node.removeEventListener(FORMING_EVENT, onForming2);
2422
+ node.removeEventListener(FORMED_EVENT, onFormed2);
1751
2423
  };
1752
2424
  }, [runtime.renderer, positioned]);
1753
2425
  useIsoLayoutEffect(() => {
@@ -1895,6 +2567,10 @@ var PlasmaInner = forwardRef(function Plasma({
1895
2567
  var Plasma2 = PlasmaInner;
1896
2568
  export {
1897
2569
  DEFAULT_MAX_SHAPES,
2570
+ FORMED_EVENT,
2571
+ FORMING_ATTR,
2572
+ FORMING_EVENT,
2573
+ MATERIALS,
1898
2574
  Plasma2 as Plasma,
1899
2575
  PlasmaCanvas,
1900
2576
  PlasmaProvider,
@@ -31,6 +31,39 @@ export interface RendererSettings {
31
31
  grain: number;
32
32
  /** Blur applied to the background only, in CSS px (0-40). Costs 8 extra blur passes when above 0. */
33
33
  backgroundBlur: number;
34
+ /**
35
+ * What the canvas shows where there is no surface. "field" paints the
36
+ * background everywhere. "clear" leaves it transparent, so the canvas can
37
+ * sit above other content: only the surfaces, their shadows and rims are
38
+ * drawn, and what a surface refracts is the `background` source - usually a
39
+ * live canvas of the field beneath, sampled without swirl so it lines up.
40
+ */
41
+ ground: "field" | "clear";
42
+ /**
43
+ * Keep each frame in the drawing buffer after it is shown, so another
44
+ * provider can pass this canvas as its `background` and sample it. Off,
45
+ * the browser may clear the buffer after compositing and the sample reads
46
+ * blank. Fixed at creation.
47
+ */
48
+ preserveDrawingBuffer: boolean;
49
+ /** What the surfaces are made of. */
50
+ material: MaterialName;
51
+ /** Direction the one light comes from. Every opaque material reads it, so they agree. */
52
+ lightDir: [number, number, number];
53
+ /** Surface finish: 0 mirror, 1 chalk. Used by metal; the others carry their own. */
54
+ roughness: number;
55
+ /** How far the highlight stretches along the grain. 0 is isotropic. */
56
+ anisotropy: number;
57
+ /** How far the outline is displaced from the rounded box, in CSS px. 0 leaves it clean. */
58
+ edge: number;
59
+ /** Size of the displacement, in cycles per px. Small is billows, large is chips. */
60
+ edgeScale: number;
61
+ /** 0 rolls the displaced edge, 1 breaks it into flats and points. */
62
+ edgeSharpness: number;
63
+ /** How thick a panel is as a solid, in CSS px. Only the marched materials use it. */
64
+ thickness: number;
65
+ /** Surface tension: how hard the material pulls its own shape toward a bead. */
66
+ tension: number;
34
67
  /** 0 = watery and bouncy, 1 = thick and slow. */
35
68
  viscosity: number;
36
69
  /** How far the surface trails behind moving panels. 0 = no trailing. */
@@ -50,8 +83,30 @@ export interface RendererSettings {
50
83
  /** Background: CSS color, image URL, or a live img/canvas/video source (null for the procedural mood field). */
51
84
  background: BackgroundSource | null;
52
85
  }
86
+ /**
87
+ * The materials a surface can be made of. `plasma` is the original and the
88
+ * default; the rest share its geometry, its springs and its fusing, and differ
89
+ * only in the composite pass — which is the whole reason they are cheap.
90
+ */
91
+ export declare const MATERIALS: readonly ["plasma", "crystal", "metal", "mercury", "wood", "stone", "cloud"];
92
+ export type MaterialName = (typeof MATERIALS)[number];
53
93
  /** Anything the background can be: a CSS color string, an image URL, or an element to sample (canvas and video update live). */
54
94
  export type BackgroundSource = string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ImageBitmap;
95
+ /**
96
+ * Set on a surface's element from `register` until its form-in has finished
97
+ * (about half a second; never under reduced motion). Style the element's
98
+ * children off it to have the contents arrive after the material:
99
+ * `[data-plasma-forming] > * { opacity: 0 }` with a transition on opacity.
100
+ */
101
+ export declare const FORMING_ATTR = "data-plasma-forming";
102
+ /**
103
+ * Events a surface's element dispatches (bubbling, so a page can listen at
104
+ * the document): `plasmaforming` when the form-in starts, `plasmaformed`
105
+ * when it has settled — at once under reduced motion. `detail.id` is the
106
+ * surface id. `<Plasma onForming onFormed>` wraps them.
107
+ */
108
+ export declare const FORMING_EVENT = "plasmaforming";
109
+ export declare const FORMED_EVENT = "plasmaformed";
55
110
  export interface ShapeOptions {
56
111
  radius: number;
57
112
  lean: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxgarden/plasma-ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Liquid panels for React, rendered in WebGL on canvas: every panel is one shared plasma - they fuse on contact, refract, and snap to a grid.",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {