返回目录
结晶球Crystal Ball
components/CrystalBall.astro
八套预设(plasma / aurora / nebula / ember / frost / solar / eclipse / abyss),每套自带颜色和参数。光标能碰到球:球沿朝光标那一侧发热起辉,尘粒在它身后打旋并转向它,点一下球会晃一下。挂载时球沿上电,尘粒从球底往上亮起来。浅色主题把光画成墨色,核心留一圈白热边。另有 ori 标志预设:低速漂移、电弧收敛,当活标志用 —— 浅色 #7c3aed 品牌紫、深色 #3b8eea 主题强调色,还是同一个引擎。
光标靠近球沿,那一侧会热起来;点一下球会晃。
浅色 · 品牌紫
深色 · 强调蓝
小尺寸 · 导航条用
标志用 preset="ori":浅色 #7c3aed 品牌紫,深色 #3b8eea 主题强调色(紫在深色底上会发灰)—— 低速漂移,电弧收敛,还是同一个引擎,不动技术。
硬性提示词
## Ori 的 <CrystalBall /> 组件
把这个组件接进你的项目里 —— Ori 自己的组件。
### Component: CrystalBall
### Variant: JavaScript + CSS
### Dependencies: ogl
---
### Usage Example
```jsx
import CrystalBall from './CrystalBall';
<div style={{ width: '100%', height: '600px', position: 'relative' }}>
<CrystalBall
preset="plasma"
color="#06a2d4"
size={0.7}
crackle={0.85}
fill={0.5}
interactive
hoverStrength={0.7}
/>
</div>
```
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| preset | 'plasma' | 'aurora' | 'nebula' | 'ember' | 'frost' | 'solar' | 'eclipse' | 'abyss' | 'plasma' | A complete starting look, color included. Any prop marked from preset overrides the preset value when you pass it. |
| color | string | from preset | The one color of the ball. The white-hot rim, its glow and flares, the dust tones, sparks and haze are all derived from it. |
| theme | 'dark' | 'light' | 'dark' | The background the ball sits on. Dark renders it as emitted light. Light draws it in crisp ink with a white-hot rim core. |
| size | number | 0.7 | Diameter of the ball as a fraction of the smaller side of the container. |
| strands | number | from preset | Number of electric strands braided around the rim, from 1 to 8. |
| crackle | number | from preset | How far the strands bend and jitter. Low values hum quietly, high values crackle. |
| flares | number | from preset | How far the glow licks outward from the rim in slow, travelling flares. |
| glow | number | from preset | Strength of the halo around the rim. |
| sparks | number | from preset | How often small arcs discharge along the inside of the rim. 0 turns them off. |
| particleCount | number | from preset | Number of dust particles in the ball, up to 40000. |
| fill | number | from preset | How full the ball is. Low values pool the dust at the bottom, high values float it up to the top. |
| motion | 'rise' | 'fall' | 'drift' | 'orbit' | from preset | How the dust moves. Rise floats like embers, fall drifts down like snow, drift wanders and orbit loops in small circles. |
| particleShape | 'square' | 'round' | from preset | Crisp square pixels or soft round dots. |
| depth | number | from preset | Strength of the depth cues. Dust at the back of the ball gets smaller and dimmer. |
| sway | number | from preset | How much the dust turns back and forth inside the glass, showing its depth. |
| twinkle | number | from preset | How much the dust sparkles on and off. |
| haze | number | from preset | Strength of the glow that pools under the dust and along the inside of the glass. |
| speed | number | 1 | Animation speed of the rim, flares and sparks. |
| dustSpeed | number | from preset | Animation speed of the dust. |
| interactive | boolean | true | Lets the cursor touch the ball. The rim heats up and flares toward it, the dust swirls in its wake and turns toward it, and a click shakes the ball. |
| hoverStrength | number | 0.7 | How strongly the ball reacts to the cursor, from 0 to 1. |
| intro | boolean | true | On mount the rim powers up and the dust lights up from the bottom of the ball. |
| paused | boolean | false | Freezes the animation. The dust can still be stirred. |
| className | string | '' | Extra class names for the root element. |
| style | CSSProperties | - | Inline styles for the root element. |
### Full Component Source
```jsx
'use client';
import { useEffect, useMemo, useRef } from 'react';
import { Renderer, Program, Mesh, Triangle, Geometry, RenderTarget, Texture } from 'ogl';
import './CrystalBall.css';
const BALL_PRESETS = {
plasma: {
color: '#F25BD0',
strands: 6,
crackle: 0.85,
flares: 0.65,
glow: 0.9,
sparks: 0.6,
particleCount: 15000,
fill: 0.5,
motion: 'rise',
particleShape: 'square',
depth: 0.6,
sway: 0.5,
twinkle: 0.5,
haze: 0.7,
dustSpeed: 1
},
aurora: {
color: '#5CFFC8',
strands: 5,
crackle: 0.6,
flares: 0.5,
glow: 0.8,
sparks: 0.45,
particleCount: 15000,
fill: 0.5,
motion: 'rise',
particleShape: 'square',
depth: 0.6,
sway: 0.5,
twinkle: 0.5,
haze: 0.7,
dustSpeed: 1
},
nebula: {
color: '#9478FF',
strands: 6,
crackle: 1,
flares: 0.5,
glow: 0.9,
sparks: 0.6,
particleCount: 18000,
fill: 0.45,
motion: 'rise',
particleShape: 'square',
depth: 0.5,
sway: 0.4,
twinkle: 0.6,
haze: 0.8,
dustSpeed: 1.2
},
ember: {
color: '#FF8A2A',
strands: 5,
crackle: 0.9,
flares: 0.8,
glow: 1,
sparks: 0.8,
particleCount: 12000,
fill: 0.35,
motion: 'rise',
particleShape: 'round',
depth: 0.7,
sway: 0.3,
twinkle: 0.7,
haze: 0.9,
dustSpeed: 1.6
},
frost: {
color: '#BFE6FF',
strands: 3,
crackle: 0.3,
flares: 0.3,
glow: 0.6,
sparks: 0.2,
particleCount: 16000,
fill: 0.6,
motion: 'fall',
particleShape: 'round',
depth: 0.8,
sway: 0.4,
twinkle: 0.4,
haze: 0.5,
dustSpeed: 0.7
},
solar: {
color: '#FFD36E',
strands: 6,
crackle: 0.7,
flares: 1,
glow: 1.1,
sparks: 0.5,
particleCount: 15000,
fill: 0.55,
motion: 'orbit',
particleShape: 'square',
depth: 0.6,
sway: 0.7,
twinkle: 0.5,
haze: 0.8,
dustSpeed: 1
},
eclipse: {
color: '#FFFFFF',
strands: 4,
crackle: 0.5,
flares: 0.4,
glow: 0.7,
sparks: 0.35,
particleCount: 14000,
fill: 0.5,
motion: 'drift',
particleShape: 'square',
depth: 0.7,
sway: 0.5,
twinkle: 0.5,
haze: 0.5,
dustSpeed: 0.8
},
abyss: {
color: '#3F7BFF',
strands: 5,
crackle: 0.55,
flares: 0.6,
glow: 0.9,
sparks: 0.4,
particleCount: 20000,
fill: 0.8,
motion: 'orbit',
particleShape: 'round',
depth: 0.8,
sway: 0.8,
twinkle: 0.5,
haze: 0.6,
dustSpeed: 0.9
}
};
const MOTIONS = { rise: 0, fall: 1, drift: 2, orbit: 3 };
const SHAPES = { square: 0, round: 1 };
const MAX_STRANDS = 8;
const MAX_ARCS = 4;
const MAX_PARTICLES = 40000;
const STATE_WIDTH = 256;
const PIXEL_BUDGET = 4.5e6;
const INTRO_SECONDS = 2.2;
const SETTLE_SECONDS = 7;
const WHITE = [1, 1, 1];
const BLACK = [0, 0, 0];
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const smooth = (edge0, edge1, value) => {
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
};
const easeOut = t => 1 - Math.pow(1 - t, 3);
const mixColor = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
const wrapAngle = a => a - Math.PI * 2 * Math.floor((a + Math.PI) / (Math.PI * 2));
const parseColor = (value, fallback) => {
try {
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) return fallback;
ctx.fillStyle = '#000000';
ctx.fillStyle = value;
const resolved = ctx.fillStyle;
if (resolved.startsWith('#')) {
const n = parseInt(resolved.slice(1), 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
const parts = resolved.match(/[\d.]+/g);
if (!parts || parts.length < 3) return fallback;
return [Number(parts[0]) / 255, Number(parts[1]) / 255, Number(parts[2]) / 255];
} catch {
return fallback;
}
};
const toLinear = c => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
const toGamma = c => (c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
const toOklab = rgb => {
const [r, g, b] = rgb.map(toLinear);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
];
};
const fromOklab = ([L, a, b]) => {
const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
].map(v => clamp(toGamma(clamp(v, 0, 1)), 0, 1));
};
const blend = (a, b, t) => fromOklab(mixColor(toOklab(a), toOklab(b), t));
const shift = (rgb, hue, lightness, chroma) => {
const [L, a, b] = toOklab(rgb);
const c = Math.hypot(a, b) * chroma;
const h = Math.atan2(b, a) + (hue * Math.PI) / 180;
return fromOklab([clamp(L + lightness, 0, 1), c * Math.cos(h), c * Math.sin(h)]);
};
const withLightness = (rgb, hue, lightness, chroma) => {
const [, a, b] = toOklab(rgb);
const c = Math.max(Math.hypot(a, b) * chroma, 0.02);
const h = Math.atan2(b, a) + (hue * Math.PI) / 180;
return fromOklab([clamp(lightness, 0, 1), c * Math.cos(h), c * Math.sin(h)]);
};
const buildPalette = (color, light) => {
if (light) {
const ink = withLightness(color, 0, Math.min(toOklab(color)[0], 0.62), 1.15);
return {
rim: ink,
rimHot: ink,
rimMid: ink,
rimDeep: withLightness(color, 0, 0.72, 0.8),
spark: withLightness(color, 0, 0.6, 1.2),
sparkGlow: withLightness(color, 0, 0.78, 0.8),
haze: withLightness(color, 0, 0.8, 0.6),
edge: withLightness(color, 0, 0.72, 0.8),
tones: [
withLightness(color, 0, 0.56, 1.1),
withLightness(color, 16, 0.6, 1.05),
withLightness(color, -16, 0.5, 1.1),
withLightness(color, 0, 0.66, 0.9),
withLightness(color, 0, 0.74, 0.7)
]
};
}
return {
rim: color,
rimHot: mixColor(color, WHITE, 0.72),
rimMid: mixColor(color, BLACK, 0.15),
rimDeep: mixColor(color, BLACK, 0.45),
spark: mixColor(color, WHITE, 0.45),
sparkGlow: shift(color, 0, -0.15, 1),
haze: mixColor(color, BLACK, 0.6),
edge: shift(color, 0, -0.3, 0.9),
tones: [
shift(color, 0, -0.06, 1),
shift(color, 16, -0.02, 1),
shift(color, -16, -0.12, 1.05),
shift(blend(color, WHITE, 0.25), 0, 0.04, 1),
blend(color, WHITE, 0.7)
]
};
};
const seeded = start => {
let state = start >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
const buildStrands = () => {
const random = seeded(99);
const bands = [
[6, 12, 0.6, 1.6],
[18, 34, 1.5, 3.5],
[40, 70, 3, 7],
[80, 130, 6, 12]
];
const data = { harmonics: [], rates: [], phases: [], flareHarmonics: [], flareRates: [], flarePhases: [] };
for (let s = 0; s < MAX_STRANDS; s++) {
bands.forEach(([low, high, slow, fast]) => {
data.harmonics.push(Math.round(low + random() * (high - low)));
data.rates.push((slow + random() * (fast - slow)) * (random() < 0.5 ? -1 : 1));
data.phases.push(random() * Math.PI * 2);
});
for (let j = 0; j < 3; j++) {
data.flareHarmonics.push(4 + Math.floor(random() * 6));
data.flareRates.push((0.4 + random() * 0.8) * (random() < 0.5 ? -1 : 1));
data.flarePhases.push(random() * Math.PI * 2);
}
const lead = s === 0;
data.flareHarmonics.push(lead ? 0.75 : 0.95 + random() * 0.3);
data.flareRates.push(lead ? 1.15 : 0.8 + random() * 0.2);
data.flarePhases.push(lead ? 1 : 0.7 + random() * 0.25);
}
return data;
};
const buildDust = count => {
const random = seeded(1337);
const rows = Math.max(1, Math.ceil(count / STATE_WIDTH));
const home = new Float32Array(STATE_WIDTH * rows * 4);
const seed = new Float32Array(STATE_WIDTH * rows * 4);
let k = 0;
let guard = 0;
while (k < count && guard < count * 80) {
guard++;
let x;
let y;
let tone;
if (random() < 0.28) {
const angle = random() * Math.PI * 2;
const radius = 0.87 + Math.sqrt(random()) * 0.105;
x = Math.cos(angle) * radius;
y = Math.sin(angle) * radius;
if (random() > smooth(-0.6, 0.3, -y)) continue;
const q = random();
tone = q < 0.5 ? 3 : q < 0.8 ? 2 : 1;
} else {
x = random() * 2 - 1;
y = random() * 2 - 1;
const radius = Math.hypot(x, y);
if (radius > 0.975) continue;
const bowl = Math.pow(smooth(-0.25, 0.85, -y), 1.3);
const band = smooth(0.66, 0.96, radius) * smooth(-0.7, 0.3, -y);
const weight = Math.max(bowl, band * 0.9);
if (random() > 0.012 + 0.988 * weight) continue;
if (weight < 0.12) tone = 4;
else {
const q = random();
tone = q < 0.32 ? 0 : q < 0.52 ? 1 : q < 0.8 ? 2 : 3;
}
}
const chord = Math.sqrt(Math.max(0, 0.95 - x * x - y * y));
home[k * 4] = x;
home[k * 4 + 1] = y;
home[k * 4 + 2] = (random() * 2 - 1) * chord;
home[k * 4 + 3] = tone;
seed[k * 4] = tone === 4 ? 1.8 : random() < 0.22 ? 2.1 : 1.3;
seed[k * 4 + 1] = random();
seed[k * 4 + 2] = 2.5 + random() * 3.5;
seed[k * 4 + 3] = 0.16 + random() * 0.26;
k++;
}
return { home, seed, count: k, rows };
};
const passVertex = `#version 300 es
in vec2 position;
in vec2 uv;
out vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const hashChunk = `
uint scramble(uint v) {
v = v * 747796405u + 2891336453u;
uint w = ((v >> ((v >> 28u) + 4u)) ^ v) * 277803737u;
return (w >> 22u) ^ w;
}
float random(uint v) {
return float(scramble(v)) * (1.0 / 4294967295.0);
}
`;
const dustChunk = `
uniform float uDustTime;
uniform vec2 uTurn;
uniform float uMotion;
uniform float uFill;
vec3 settle(vec3 p, out float spread) {
float bend = exp2((0.5 - uFill) * 1.8);
float h = clamp((p.y + 1.0) * 0.5, 0.0, 1.0);
float y = 2.0 * pow(h, bend) - 1.0;
float from = sqrt(max(1.0 - p.y * p.y, 1e-4));
float to = sqrt(max(1.0 - y * y, 0.0));
spread = clamp(bend * pow(max(h, 1e-3), bend - 1.0) * to / from, 0.25, 1.0);
return vec3(p.xz * (to / from), y).xzy;
}
vec3 drift(vec4 home, vec4 seed, uint id, out float life, out float spread) {
vec3 p = settle(home.xyz, spread);
float t = uDustTime;
float u = fract(t / seed.z + seed.y);
life = sin(3.14159265 * u);
float travel = seed.w;
if (uMotion < 0.5) {
p.y += travel * (u - 0.5);
} else if (uMotion < 1.5) {
p.y -= travel * (u - 0.5);
} else if (uMotion < 2.5) {
p += 0.045 * vec3(
sin(t * 0.37 + seed.y * 17.0) + 0.5 * sin(t * 0.83 + seed.z * 5.0),
sin(t * 0.29 + seed.z * 11.0) + 0.5 * sin(t * 0.61 + seed.w * 7.0),
sin(t * 0.33 + seed.w * 23.0)
);
} else {
float loop = t * (0.6 + 0.9 * random(id * 7u + 3u)) * (random(id * 7u + 10u) < 0.5 ? -1.0 : 1.0) + seed.y * 6.2831853;
p.xy += (0.025 + 0.05 * random(id * 7u + 16u)) * vec2(cos(loop), sin(loop));
}
float wobble = 0.002 + 0.006 * random(id * 7u + 1u);
float w1 = 0.8 + 2.4 * random(id * 7u + 2u);
float w2 = 0.8 + 2.4 * random(id * 7u + 4u);
p += wobble * vec3(sin(t * w1 + seed.y * 40.0), cos(t * w2 + seed.z * 30.0), sin(t * (w1 + w2) * 0.5 + seed.w * 20.0));
float cy = cos(uTurn.x);
float sy = sin(uTurn.x);
p.xz = mat2(cy, -sy, sy, cy) * p.xz;
float cx = cos(uTurn.y);
float sx = sin(uTurn.y);
p.yz = mat2(cx, -sx, sx, cx) * p.yz;
return p;
}
`;
const fieldFragment = `#version 300 es
precision highp float;
uniform vec2 uCenter;
uniform float uRadius;
uniform float uDpr;
uniform float uLine;
uniform float uTime;
uniform float uFrame;
uniform float uBins;
uniform float uStrands;
uniform float uCrackle;
uniform float uFlares;
uniform float uGlow;
uniform float uHaze;
uniform float uFill;
uniform float uPresence;
uniform float uUnfold;
uniform float uBloom;
uniform float uInside;
uniform float uEncode;
uniform vec3 uRim;
uniform vec3 uRimHot;
uniform vec3 uRimMid;
uniform vec3 uRimDeep;
uniform vec3 uSpark;
uniform vec3 uSparkGlow;
uniform vec3 uHazeColor;
uniform vec3 uEdgeColor;
uniform vec4 uHeat;
uniform vec4 uHarmonics[8];
uniform vec4 uRates[8];
uniform vec4 uPhases[8];
uniform vec4 uFlareHarmonics[8];
uniform vec4 uFlareRates[8];
uniform vec4 uFlarePhases[8];
uniform vec4 uArcs[4];
out vec4 fragColor;
${hashChunk}
const float PI = 3.14159265;
const float TAU = 6.28318531;
const vec4 AMPS = vec4(0.35, 0.3, 0.22, 0.13);
float bell(float d, float w) {
float x = d / w;
return exp(-x * x);
}
float segment(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0);
return length(pa - ba * h);
}
float pool(vec2 q) {
float t = length(q - vec2(0.0, mix(-1.0, -0.66, uFill))) / mix(0.8, 1.35, uFill);
return t < 0.6 ? mix(0.5, 0.22, t / 0.6) : mix(0.22, 0.0, clamp((t - 0.6) / 0.4, 0.0, 1.0));
}
void main() {
vec2 p = gl_FragCoord.xy / uDpr - uCenter;
float r = length(p);
float R = uRadius;
float theta = atan(p.y, p.x);
float lift = p.y / max(r, 1e-3);
float topDim = 1.0 - 0.35 * pow(max(lift, 0.0), 1.5);
vec3 col = vec3(0.0);
float hot = 0.0;
uint frame = uint(uFrame);
if (r < R) {
vec2 q = p / R;
float chord = sqrt(max(1.0 - dot(q, q), 0.0));
float haze = pool(q) * (0.55 + 0.45 * chord);
col += uHazeColor * haze * uHaze * uInside;
float e = length(q - vec2(0.0, 0.2)) / 1.2;
float edge = 0.5 * pow(smoothstep(0.62, 1.0, e), 1.4);
col += uEdgeColor * edge * uInside * (0.5 + 0.5 * uHaze);
}
float dr = r - R;
float halo = 1.0 - smoothstep(R * 0.05, R * 0.105, abs(dr - R * 0.0213));
col += uRimDeep * 0.18 * halo * topDim * uGlow * uBloom;
col += uRim * 0.12 * bell(dr + R * 0.0383, R * 0.032) * uGlow * uBloom;
col += uRim * 0.32 * bell(dr, max(R * 0.0117, uLine)) * uPresence * uUnfold;
float delta = mod(theta - uHeat.x + PI, TAU) - PI;
float heat = uHeat.z * uHeat.y * exp(-delta * delta / 0.3);
float crackle = uCrackle + heat * 0.9;
float amp = R * 0.016 * (0.3 + 1.4 * crackle) * uUnfold;
float jitterAmp = R * 0.0048 * (0.3 + 1.4 * uCrackle) * uUnfold * (1.0 + heat * 0.3);
float flareAmp = R * 0.064 * (0.4 + 1.2 * crackle) * uFlares * 1.6 * uUnfold;
if (abs(dr) < amp * 2.0 + flareAmp + R * 0.3) {
float step = TAU / uBins;
float binPos = (theta + PI) / step;
float bin0 = floor(binPos);
float binF = binPos - bin0;
float th0 = bin0 * step - PI;
float th1 = th0 + step;
uint b0 = uint(mod(bin0, uBins));
uint b1 = uint(mod(bin0 + 1.0, uBins));
float gain = min(1.0, 2.4 / max(uStrands, 1.0));
for (int k = 0; k < 8; k++) {
if (float(k) >= uStrands) break;
vec4 m = uHarmonics[k];
vec4 lead = uRates[k] * uTime + uPhases[k];
float wave0 = dot(AMPS, sin(m * th0 + lead));
float wave1 = dot(AMPS, sin(m * th1 + lead));
uint salt = uint(k) * 1013u + frame * 7919u;
vec4 shape = uFlareHarmonics[k];
vec4 look = uFlareRates[k];
vec4 tone = uFlarePhases[k];
float a = amp * shape.w;
float off0 = a * wave0 + jitterAmp * (random(b0 + salt) - 0.5);
float off1 = a * wave1 + jitterAmp * (random(b1 + salt) - 0.5);
float rc = R + mix(off0, off1, binF);
float grade = (off1 - off0) / (step * max(r, 1.0));
float d = abs(r - rc) * inversesqrt(1.0 + grade * grade);
float swell = 0.8 + 0.4 * (0.5 + 0.5 * sin(3.0 * theta + uTime * 0.7 + tone.x * 1.7));
float width = uLine * look.w * swell * (1.0 + heat * 0.25);
float core = exp(-d * d / (width * width));
float sheath = exp(-d * d / (width * width * 3.0));
float bright = tone.w * (1.0 + heat * 0.45) * uPresence;
col += (uRimHot * core * 0.8 + uRim * sheath * 0.32) * bright;
hot += core * bright;
vec3 fargs = shape.xyz * theta + look.xyz * uTime + tone.xyz;
float flare = max(0.0, (sin(fargs.x) + sin(fargs.y) + sin(fargs.z)) / 3.0);
flare = (flare * flare + heat * 0.35) * flareAmp;
float dw = abs(r - rc - flare);
float dm = abs(r - rc - flare * 0.6);
float dn = abs(r - rc - flare * 0.25);
vec3 glow = uRimDeep * (0.07 * bell(dw, R * 0.09) + 0.16 * bell(dw, R * 0.05));
glow += uRimMid * 0.28 * bell(dm, R * 0.027) + uRim * 0.38 * bell(dn, R * 0.013);
col += glow * gain * topDim * uGlow * uBloom * (1.0 + heat * 1.4);
}
}
for (int i = 0; i < 4; i++) {
vec4 arc = uArcs[i];
if (arc.w <= 0.002) continue;
float mid = arc.x + arc.y * 0.5;
vec2 center = R * 0.93 * vec2(cos(mid), sin(mid));
if (distance(p, center) > R * (abs(arc.y) * 0.6 + 0.1) + 12.0) continue;
float ar = R * 0.955;
vec2 a0 = ar * vec2(cos(arc.x), sin(arc.x));
vec2 a2 = ar * vec2(cos(arc.x + arc.y), sin(arc.x + arc.y));
vec2 a1 = R * (0.955 - arc.z) * vec2(cos(mid), sin(mid));
float dmin = 1e5;
float along = 0.0;
vec2 prev = a0;
for (int s = 1; s <= 10; s++) {
float u = float(s) / 10.0;
vec2 b = mix(mix(a0, a1, u), mix(a1, a2, u), u);
vec2 pa = p - prev;
vec2 ba = b - prev;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0);
float dist = length(pa - ba * h);
if (dist < dmin) {
dmin = dist;
along = (float(s) - 1.0 + h) / 10.0;
}
prev = b;
}
float taper = sqrt(max(sin(PI * along), 0.0));
float arcWidth = uLine * (0.55 + 0.6 * taper);
float arcCore = exp(-dmin * dmin / (arcWidth * arcWidth));
float arcGlow = exp(-dmin * dmin / (arcWidth * arcWidth * 12.0));
col += (uSpark * 0.95 * arcCore + uSparkGlow * 0.4 * arcGlow) * arc.w * taper;
hot += arcCore * arc.w * taper * 0.6;
}
fragColor = vec4(col, hot) * uEncode;
}
`;
const dustVertex = `#version 300 es
precision highp float;
in float aIndex;
uniform sampler2D tHome;
uniform sampler2D tSeed;
uniform sampler2D tOffset;
uniform float uStirred;
uniform vec2 uCenter;
uniform vec2 uViewport;
uniform float uRadius;
uniform float uDpr;
uniform float uDepth;
uniform float uTwinkle;
uniform float uPointScale;
uniform float uReveal;
uniform float uEncode;
uniform vec3 uTones[5];
uniform vec3 uRimTone;
out vec3 vColor;
out float vSize;
${hashChunk}
${dustChunk}
void main() {
ivec2 cell = ivec2(int(mod(aIndex, ${STATE_WIDTH}.0)), int(floor(aIndex / ${STATE_WIDTH}.0)));
uint id = uint(aIndex);
vec4 home = texelFetch(tHome, cell, 0);
vec4 seed = texelFetch(tSeed, cell, 0);
float life;
float spread;
vec3 p = drift(home, seed, id, life, spread);
if (uStirred > 0.5) p += texelFetch(tOffset, cell, 0).xyz;
float len = length(p);
float inside = 1.0 - smoothstep(0.955, 0.985, length(p.xy));
if (len > 0.975) p *= 0.975 / len;
float front = p.z * 0.5 + 0.5;
float depthSize = mix(1.0, 0.7 + 0.6 * front, uDepth);
float depthLight = mix(1.0, 0.35 + 0.8 * front, uDepth);
float blink = sin(uDustTime * (2.0 + 6.0 * random(id * 7u + 5u)) + random(id * 7u + 6u) * 6.2831853);
float twinkle = mix(1.0, smoothstep(-0.9, -0.6, blink), uTwinkle);
float fade = smoothstep(0.04, 0.12, life);
float order = (home.y + 1.0) * 0.5 * 0.55 + random(id * 7u + 8u) * 0.25;
float reveal = smoothstep(order, order + 0.2, uReveal * 1.0);
float alpha = random(id * 7u + 9u) < 0.5 || home.w > 3.5 ? 1.0 : 0.6;
float size = seed.x * uPointScale * (0.35 + 0.95 * life) * depthSize * mix(0.5, 1.0, reveal);
vec2 screen = uCenter + p.xy * uRadius;
gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
float px = size * uDpr;
gl_PointSize = px + 2.0;
vSize = px;
int tone = int(home.w + 0.5);
float rimLit = smoothstep(0.8, 0.97, length(p.xy));
vec3 color = uTones[tone] + uRimTone * rimLit * rimLit * 0.12;
vColor = color * alpha * fade * twinkle * depthLight * reveal * inside * sqrt(spread) * uEncode;
}
`;
const dustFragment = `#version 300 es
precision highp float;
uniform float uShape;
in vec3 vColor;
in float vSize;
out vec4 fragColor;
void main() {
vec2 q = (gl_PointCoord - 0.5) * (vSize + 2.0);
float half_ = max(vSize * 0.5, 0.5);
float cover;
if (uShape < 0.5) {
vec2 c = clamp(half_ - abs(q) + 0.5, 0.0, 1.0);
cover = c.x * c.y;
} else {
cover = clamp(half_ - length(q) + 0.5, 0.0, 1.0);
}
if (cover <= 0.0) discard;
fragColor = vec4(vColor * cover, 0.0);
}
`;
const stirFragment = `#version 300 es
precision highp float;
uniform sampler2D tHome;
uniform sampler2D tSeed;
uniform sampler2D tOffset;
uniform sampler2D tVelocity;
uniform float uDt;
uniform float uReset;
uniform vec4 uBrush;
uniform float uBrushPower;
uniform vec4 uKick;
layout(location = 0) out vec4 outOffset;
layout(location = 1) out vec4 outVelocity;
${hashChunk}
${dustChunk}
vec3 swirl(vec3 p) {
float t = uDustTime * 0.15;
vec3 a = vec3(1.7, 1.9, 1.5) * p.yzx + vec3(t, 1.2, 2.1);
vec3 b = vec3(2.3, 2.1, 2.7) * p.zxy + vec3(0.4, t * 1.3, 0.9);
vec3 ca = cos(a) * vec3(1.7, 1.9, 1.5);
vec3 cb = cos(b) * vec3(2.3, 2.1, 2.7);
return vec3(ca.z - cb.x, cb.y - ca.x, ca.y - cb.z);
}
void main() {
ivec2 cell = ivec2(gl_FragCoord.xy);
if (uReset > 0.5) {
outOffset = vec4(0.0);
outVelocity = vec4(0.0);
return;
}
uint id = uint(cell.y * ${STATE_WIDTH} + cell.x);
vec4 home = texelFetch(tHome, cell, 0);
vec4 seed = texelFetch(tSeed, cell, 0);
vec3 o = texelFetch(tOffset, cell, 0).xyz;
vec3 v = texelFetch(tVelocity, cell, 0).xyz;
float life;
float spread;
vec3 base = drift(home, seed, id, life, spread);
vec3 p = base + o;
float k = mix(0.8, 2.2, random(id * 7u + 11u));
vec3 acc = -k * o - 1.5 * sqrt(k) * v;
vec2 gap = p.xy - uBrush.xy;
float touch = exp(-dot(gap, gap) / 0.07) * uBrushPower;
acc += (vec3(uBrush.zw, 0.0) - v) * touch * 14.0;
float pace = length(v);
if (pace > 1e-4) {
vec3 heading = v / pace;
vec3 curl = swirl(p * 1.6);
acc += (curl - heading * dot(curl, heading)) * min(pace, 2.0) * 1.8;
}
if (uKick.z > 0.0) {
vec3 jolt = vec3(random(id * 7u + 12u), random(id * 7u + 13u), random(id * 7u + 14u)) - 0.5;
float near = exp(-dot(p.xy - uKick.xy, p.xy - uKick.xy) / 0.4);
vec3 spinKick = vec3(-p.z, 0.0, p.x) * 0.9;
vec3 lift = vec3(0.0, 0.5 + 0.8 * random(id * 7u + 15u), 0.0) * (0.3 + 0.7 * smoothstep(0.2, -0.8, p.y));
v += (jolt * 0.8 + spinKick + lift) * uKick.z * (0.4 + 0.45 * near);
}
v += acc * uDt;
o += v * uDt;
vec3 q = base + o;
float len = length(q);
if (len > 0.97) {
vec3 n = q / len;
o -= n * (len - 0.97);
v -= n * max(dot(v, n), 0.0) * 1.5;
}
outOffset = vec4(o, 1.0);
outVelocity = vec4(v, 1.0);
}
`;
const compositeFragment = `#version 300 es
precision highp float;
uniform sampler2D tScene;
uniform float uLight;
uniform float uDecode;
in vec2 vUv;
out vec4 fragColor;
vec3 soften(vec3 x) {
vec3 over = max(x - 0.6, 0.0);
return min(x, 0.6) + 0.4 * (1.0 - exp(-over / 0.4));
}
void main() {
vec4 scene = texture(tScene, vUv) * uDecode;
vec3 light = max(scene.rgb, 0.0);
float peak = max(light.r, max(light.g, light.b));
if (uLight > 0.5) {
vec3 hue = light / max(peak, 1e-4);
float cover = 1.0 - exp(-peak * 2.4);
vec3 ink = hue * mix(0.92, 0.72, cover);
float core = clamp(scene.a * 0.7, 0.0, 1.0) * cover;
vec3 tint = mix(vec3(1.0), hue, 0.22);
fragColor = vec4(tint * core + ink * cover * (1.0 - core), core + cover * (1.0 - core));
return;
}
light += vec3(max(peak - 1.8, 0.0) * 0.25);
vec3 shown = soften(light);
fragColor = vec4(shown, max(shown.r, max(shown.g, shown.b)));
}
`;
const CrystalBall = ({
preset = 'plasma',
color,
theme = 'dark',
size = 0.7,
strands,
crackle,
flares,
glow,
sparks,
particleCount,
fill,
motion,
particleShape,
depth,
sway,
twinkle,
haze,
speed = 1,
dustSpeed,
interactive = true,
hoverStrength = 0.7,
intro = true,
paused = false,
className = '',
style
}) => {
const containerRef = useRef(null);
const settingsRef = useRef(null);
const wakeRef = useRef(null);
const base = BALL_PRESETS[preset] || BALL_PRESETS.plasma;
const pick = (value, key) => (value === undefined || value === null ? base[key] : value);
const tint = pick(color, 'color');
const colors = useMemo(() => {
const light = theme === 'light';
return { light, palette: buildPalette(parseColor(tint, [0.95, 0.36, 0.82]), light) };
}, [tint, theme]);
useEffect(() => {
settingsRef.current = {
...colors,
size,
strands: pick(strands, 'strands'),
crackle: pick(crackle, 'crackle'),
flares: pick(flares, 'flares'),
glow: pick(glow, 'glow'),
sparks: pick(sparks, 'sparks'),
particleCount: pick(particleCount, 'particleCount'),
fill: pick(fill, 'fill'),
motion: pick(motion, 'motion'),
particleShape: pick(particleShape, 'particleShape'),
depth: pick(depth, 'depth'),
sway: pick(sway, 'sway'),
twinkle: pick(twinkle, 'twinkle'),
haze: pick(haze, 'haze'),
dustSpeed: pick(dustSpeed, 'dustSpeed'),
speed,
interactive,
hoverStrength,
intro,
paused
};
wakeRef.current?.();
});
useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
const renderer = new Renderer({ alpha: true, premultipliedAlpha: true, antialias: false, depth: false });
const gl = renderer.gl;
if (!renderer.isWebgl2) {
gl.getExtension('WEBGL_lose_context')?.loseContext();
return undefined;
}
const canvas = gl.canvas;
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.setAttribute('aria-hidden', 'true');
container.appendChild(canvas);
const fullFloat = !!gl.getExtension('EXT_color_buffer_float');
const halfFloat = fullFloat || !!gl.getExtension('EXT_color_buffer_half_float');
const encode = halfFloat ? 1 : 0.25;
const geometry = new Triangle(gl);
const blank = new Texture(gl);
const strandData = buildStrands();
const sceneTarget = new RenderTarget(gl, {
width: 1,
height: 1,
depth: false,
type: halfFloat ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE,
format: gl.RGBA,
internalFormat: halfFloat ? gl.RGBA16F : gl.RGBA,
minFilter: gl.NEAREST,
magFilter: gl.NEAREST
});
const arcData = new Array(MAX_ARCS * 4).fill(0);
const fieldUniforms = {
uCenter: { value: [0, 0] },
uRadius: { value: 1 },
uDpr: { value: 1 },
uLine: { value: 0.6 },
uTime: { value: 0 },
uFrame: { value: 0 },
uBins: { value: 420 },
uStrands: { value: 5 },
uCrackle: { value: 0.6 },
uFlares: { value: 0.5 },
uGlow: { value: 0.8 },
uHaze: { value: 0.7 },
uFill: { value: 0.5 },
uPresence: { value: 0 },
uUnfold: { value: 0 },
uBloom: { value: 0 },
uInside: { value: 0 },
uEncode: { value: encode },
uRim: { value: [1, 1, 1] },
uRimHot: { value: [1, 1, 1] },
uRimMid: { value: [1, 1, 1] },
uRimDeep: { value: [1, 1, 1] },
uSpark: { value: [1, 1, 1] },
uSparkGlow: { value: [1, 1, 1] },
uHazeColor: { value: [0, 0, 0] },
uEdgeColor: { value: [0, 0, 0] },
uHeat: { value: [0, 0, 0, 0] },
uHarmonics: { value: strandData.harmonics },
uRates: { value: strandData.rates },
uPhases: { value: strandData.phases },
uFlareHarmonics: { value: strandData.flareHarmonics },
uFlareRates: { value: strandData.flareRates },
uFlarePhases: { value: strandData.flarePhases },
uArcs: { value: arcData }
};
const dustShared = {
uDustTime: { value: 0 },
uTurn: { value: [0, 0] },
uMotion: { value: 0 },
uFill: fieldUniforms.uFill,
tHome: { value: blank },
tSeed: { value: blank }
};
const dustUniforms = {
...dustShared,
tOffset: { value: blank },
uStirred: { value: 0 },
uCenter: fieldUniforms.uCenter,
uViewport: { value: [1, 1] },
uRadius: fieldUniforms.uRadius,
uDpr: fieldUniforms.uDpr,
uDepth: { value: 0.6 },
uTwinkle: { value: 0.5 },
uPointScale: { value: 1 },
uReveal: { value: 0 },
uEncode: { value: encode },
uTones: { value: new Array(15).fill(1) },
uRimTone: { value: [1, 1, 1] },
uShape: { value: 0 }
};
const stirUniforms = {
...dustShared,
tOffset: { value: blank },
tVelocity: { value: blank },
uDt: { value: 0.016 },
uReset: { value: 1 },
uBrush: { value: [0, 0, 0, 0] },
uBrushPower: { value: 0 },
uKick: { value: [0, 0, 0, 0] }
};
const compositeUniforms = {
tScene: { value: sceneTarget.texture },
uLight: { value: 0 },
uDecode: { value: 1 / encode }
};
const additive = program => {
program.setBlendFunc(gl.ONE, gl.ONE);
return program;
};
const fieldMesh = new Mesh(gl, {
geometry,
program: additive(
new Program(gl, {
vertex: passVertex,
fragment: fieldFragment,
uniforms: fieldUniforms,
transparent: true,
depthTest: false,
depthWrite: false
})
)
});
const dustProgram = additive(
new Program(gl, {
vertex: dustVertex,
fragment: dustFragment,
uniforms: dustUniforms,
transparent: true,
depthTest: false,
depthWrite: false
})
);
const stirMesh = halfFloat
? new Mesh(gl, {
geometry,
program: new Program(gl, {
vertex: passVertex,
fragment: stirFragment,
uniforms: stirUniforms,
depthTest: false,
depthWrite: false
})
})
: null;
const compositeMesh = new Mesh(gl, {
geometry,
program: new Program(gl, {
vertex: passVertex,
fragment: compositeFragment,
uniforms: compositeUniforms,
depthTest: false,
depthWrite: false
})
});
const disposeTarget = target => {
if (!target) return;
gl.deleteFramebuffer(target.buffer);
target.textures.forEach(texture => gl.deleteTexture(texture.texture));
};
const stateTarget = rows =>
new RenderTarget(gl, {
width: STATE_WIDTH,
height: rows,
color: 2,
depth: false,
type: fullFloat ? gl.FLOAT : gl.HALF_FLOAT,
format: gl.RGBA,
internalFormat: fullFloat ? gl.RGBA32F : gl.RGBA16F,
minFilter: gl.NEAREST,
magFilter: gl.NEAREST
});
const dataTexture = (data, rows) =>
new Texture(gl, {
image: data,
width: STATE_WIDTH,
height: rows,
type: gl.FLOAT,
format: gl.RGBA,
internalFormat: gl.RGBA32F,
minFilter: gl.NEAREST,
magFilter: gl.NEAREST,
generateMipmaps: false,
flipY: false
});
let dust = null;
const disposeDust = () => {
if (!dust) return;
dust.mesh.geometry.remove();
gl.deleteTexture(dust.home.texture);
gl.deleteTexture(dust.seed.texture);
disposeTarget(dust.read);
disposeTarget(dust.write);
dust = null;
};
const resetStir = () => {
if (!stirMesh || !dust || !dust.read || !dust.write) return;
stirUniforms.tOffset.value = blank;
stirUniforms.tVelocity.value = blank;
stirUniforms.uReset.value = 1;
renderer.render({ scene: stirMesh, target: dust.read, clear: false });
renderer.render({ scene: stirMesh, target: dust.write, clear: false });
stirUniforms.uReset.value = 0;
};
const rebuildDust = requested => {
const total = clamp(Math.round(requested / 100) * 100, 0, MAX_PARTICLES);
if (dust && dust.requested === total) return;
disposeDust();
if (!total) {
dust = null;
return;
}
const data = buildDust(total);
const indices = new Float32Array(data.count);
for (let i = 0; i < data.count; i++) indices[i] = i;
const points = new Geometry(gl, { aIndex: { size: 1, data: indices } });
dust = {
requested: total,
rows: data.rows,
home: dataTexture(data.home, data.rows),
seed: dataTexture(data.seed, data.rows),
mesh: new Mesh(gl, { mode: gl.POINTS, geometry: points, program: dustProgram }),
read: stirMesh ? stateTarget(data.rows) : null,
write: stirMesh ? stateTarget(data.rows) : null
};
dustShared.tHome.value = dust.home;
dustShared.tSeed.value = dust.seed;
resetStir();
stirring = 0;
};
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const random = seeded(913);
let width = 1;
let height = 1;
let raf = 0;
let last = performance.now();
let rimTime = 0;
let dustTime = 0;
let swayClock = 0;
let introClock = 0;
let visible = true;
let alive = true;
let surge = 0;
let stirring = 0;
let stirEnergy = 0;
let kick = null;
let nextArc = 0.3;
const arcs = [];
const pointer = { x: 0, y: 0, inside: false, fresh: true };
const heat = { angle: 0, velocity: 0, power: 0, near: 0 };
const brush = { x: 0, y: 0, vx: 0, vy: 0 };
const look = { x: 0, y: 0, vx: 0, vy: 0 };
const resize = () => {
width = Math.max(1, container.clientWidth);
height = Math.max(1, container.clientHeight);
renderer.dpr = Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(PIXEL_BUDGET / (width * height)));
renderer.setSize(width, height);
sceneTarget.setSize(gl.canvas.width, gl.canvas.height);
start();
};
const spawnArc = (angle, strength) => {
if (arcs.length >= MAX_ARCS) return;
arcs.push({
start: angle,
span: (0.05 + random() * 0.07) * (random() < 0.5 ? -1 : 1),
curl: 0.02 + random() * 0.04,
life: 0,
duration: 0.2 + random() * 0.2,
drift: (random() - 0.5) * 0.3,
strength
});
};
const frame = now => {
raf = 0;
if (!alive) return;
const s = settingsRef.current;
const dt = Math.min(0.05, Math.max(1 / 240, (now - last) / 1000));
last = now;
if (!s) return;
const moving = !s.paused && !reducedMotion;
introClock = s.intro && !reducedMotion ? Math.min(1, introClock + dt / INTRO_SECONDS) : 1;
const presence = easeOut(smooth(0, 0.42, introClock));
const unfold = easeOut(smooth(0, 0.32, introClock));
const charge = Math.sin(Math.PI * smooth(0.22, 0.78, introClock)) * 0.35;
const radius = Math.max(8, (clamp(s.size, 0.1, 1.5) * Math.min(width, height)) / 2);
const strength = s.interactive && !reducedMotion ? clamp(s.hoverStrength, 0, 1) : 0;
const px = pointer.x - width / 2;
const py = height / 2 - pointer.y;
const reach = Math.hypot(px, py);
const target = pointer.inside ? strength : 0;
if (heat.power < 0.02) {
heat.angle = Math.atan2(py, px);
heat.velocity = 0;
}
const turn = wrapAngle(Math.atan2(py, px) - heat.angle);
heat.velocity += (120 * turn - 19 * heat.velocity) * dt;
heat.angle = wrapAngle(heat.angle + heat.velocity * dt);
heat.power += (target - heat.power) * (1 - Math.exp(-dt / (target > heat.power ? 0.3 : 0.55)));
const nearTarget = Math.exp(-Math.pow((reach - radius) / (radius * 0.45), 2));
heat.near += (nearTarget - heat.near) * (1 - Math.exp(-dt / 0.15));
surge *= Math.exp(-dt / 0.7);
const bx = px / radius;
const by = py / radius;
if (Math.hypot(bx - brush.x, by - brush.y) > 0.6) pointer.fresh = true;
if (pointer.fresh) {
brush.x = bx;
brush.y = by;
brush.vx = 0;
brush.vy = 0;
pointer.fresh = false;
}
const follow = 1 - Math.exp(-dt / 0.05);
brush.vx += ((bx - brush.x) / dt - brush.vx) * follow;
brush.vy += ((by - brush.y) / dt - brush.vy) * follow;
brush.x = bx;
brush.y = by;
const brushSpeed = Math.hypot(brush.vx, brush.vy);
const cap = brushSpeed > 4 ? 4 / brushSpeed : 1;
const inBall = Math.exp(-Math.max(0, Math.hypot(bx, by) - 1) * 6);
const brushPower = pointer.inside ? strength * inBall * presence : 0;
stirEnergy = stirEnergy * Math.exp(-dt / 1.2) + brushPower * Math.min(brushSpeed, 4) * dt;
if (moving) {
const pace = 1 + surge * 0.6 + heat.power * heat.near * 0.3;
const step = dt * Math.max(0, s.speed) * 2 * pace;
rimTime += step;
dustTime += dt * Math.max(0, s.dustSpeed) * (1 + surge * 0.4);
swayClock += dt * Math.max(0, s.dustSpeed);
const crackleNow = clamp(s.crackle + surge * 0.5, 0, 1.5);
if (s.sparks > 0.01 && introClock > 0.55) {
nextArc -= step * s.sparks * 2 * (0.6 + crackleNow * 0.6);
if (nextArc <= 0) {
const local = heat.power * heat.near;
const angle =
random() < local * 0.7
? heat.angle + (random() - 0.5) * 0.8
: random() < 0.7
? Math.PI / 2 + (random() - 0.5) * 1.5
: random() * Math.PI * 2;
spawnArc(angle, 0.8 + local * 0.6 + surge * 0.4);
nextArc = 0.15 + random() * 0.6;
}
}
for (let i = arcs.length - 1; i >= 0; i--) {
const arc = arcs[i];
arc.life += step;
arc.start += arc.drift * step;
if (arc.life >= arc.duration) arcs.splice(i, 1);
}
} else {
arcs.length = 0;
}
for (let i = 0; i < MAX_ARCS; i++) {
const arc = arcs[i];
arcData[i * 4] = arc ? arc.start : 0;
arcData[i * 4 + 1] = arc ? arc.span : 0;
arcData[i * 4 + 2] = arc ? arc.curl : 0;
arcData[i * 4 + 3] = arc ? Math.sin((Math.PI * arc.life) / arc.duration) * arc.strength : 0;
}
rebuildDust(s.particleCount);
const palette = s.palette;
fieldUniforms.uCenter.value = [width / 2, height / 2];
fieldUniforms.uRadius.value = radius;
fieldUniforms.uDpr.value = renderer.dpr;
fieldUniforms.uLine.value = Math.max(0.6, radius * 0.0035);
fieldUniforms.uTime.value = rimTime;
fieldUniforms.uFrame.value = Math.floor(rimTime * 24) % 100000;
fieldUniforms.uBins.value = clamp(Math.round((Math.PI * 2 * radius) / 2.6), 240, 900);
fieldUniforms.uStrands.value = Math.round(clamp(s.strands, 1, MAX_STRANDS));
fieldUniforms.uCrackle.value = clamp(s.crackle, 0, 1) + surge * 0.5 + charge;
fieldUniforms.uFlares.value = clamp(s.flares, 0, 1);
fieldUniforms.uGlow.value = clamp(s.glow, 0, 2) * (1 + surge * 0.5 + charge) * (s.light ? 0.35 : 1);
fieldUniforms.uHaze.value = clamp(s.haze, 0, 2) * (s.light ? 0.6 : 1);
fieldUniforms.uFill.value = clamp(s.fill, 0, 1);
fieldUniforms.uPresence.value = presence;
fieldUniforms.uUnfold.value = unfold;
fieldUniforms.uBloom.value = presence * presence;
fieldUniforms.uInside.value = smooth(0.2, 0.95, introClock);
fieldUniforms.uRim.value = palette.rim;
fieldUniforms.uRimHot.value = palette.rimHot;
fieldUniforms.uRimMid.value = palette.rimMid;
fieldUniforms.uRimDeep.value = palette.rimDeep;
fieldUniforms.uSpark.value = palette.spark;
fieldUniforms.uSparkGlow.value = palette.sparkGlow;
fieldUniforms.uHazeColor.value = palette.haze;
fieldUniforms.uEdgeColor.value = palette.edge;
fieldUniforms.uHeat.value = [heat.angle, heat.near, heat.power * presence, 0];
dustShared.uDustTime.value = dustTime;
const gaze = pointer.inside ? strength * presence : 0;
const lookX = clamp(bx, -1.6, 1.6) * gaze;
const lookY = clamp(by, -1.6, 1.6) * gaze;
look.vx += (40 * (lookX - look.x) - 11 * look.vx) * dt;
look.vy += (40 * (lookY - look.y) - 11 * look.vy) * dt;
look.x += look.vx * dt;
look.y += look.vy * dt;
const swing = clamp(s.sway, 0, 1);
dustShared.uTurn.value = [
swing * 0.42 * Math.sin(swayClock * 0.23) + look.x * 0.3,
swing * 0.12 * Math.sin(swayClock * 0.17 + 1.3) - look.y * 0.16
];
dustShared.uMotion.value = MOTIONS[s.motion] ?? 0;
dustUniforms.uViewport.value = [width, height];
dustUniforms.uDepth.value = clamp(s.depth, 0, 1);
dustUniforms.uTwinkle.value = clamp(s.twinkle, 0, 1);
dustUniforms.uPointScale.value = Math.max(1, radius / 491);
dustUniforms.uReveal.value = smooth(0.18, 1, introClock);
dustUniforms.uTones.value = palette.tones.flat();
dustUniforms.uRimTone.value = palette.rim;
dustUniforms.uShape.value = SHAPES[s.particleShape] ?? 0;
const layer = dust;
if (layer && stirMesh && (stirEnergy > 0.002 || kick)) stirring = SETTLE_SECONDS;
let stirred = false;
if (layer && layer.read && layer.write && stirMesh && stirring > 0) {
stirUniforms.uDt.value = Math.min(dt, 1 / 30);
stirUniforms.uBrush.value = [brush.x, brush.y, brush.vx * cap, brush.vy * cap];
stirUniforms.uBrushPower.value = brushPower;
stirUniforms.uKick.value = kick ? [kick.x, kick.y, kick.strength, 0] : [0, 0, 0, 0];
stirUniforms.tOffset.value = layer.read.textures[0];
stirUniforms.tVelocity.value = layer.read.textures[1];
renderer.render({ scene: stirMesh, target: layer.write, clear: false });
const swap = layer.read;
layer.read = layer.write;
layer.write = swap;
stirring -= dt;
stirred = stirring > 0;
if (!stirred) {
stirring = 0;
resetStir();
}
}
kick = null;
dustUniforms.uStirred.value = stirred ? 1 : 0;
dustUniforms.tOffset.value = layer && layer.read ? layer.read.textures[0] : blank;
gl.clearColor(0, 0, 0, 0);
renderer.render({ scene: fieldMesh, target: sceneTarget, clear: true });
if (dust) renderer.render({ scene: dust.mesh, target: sceneTarget, clear: false });
compositeUniforms.uLight.value = s.light ? 1 : 0;
renderer.render({ scene: compositeMesh, clear: false });
const settling =
Math.abs(target - heat.power) > 0.002 ||
surge > 0.002 ||
stirring > 0 ||
Math.abs(heat.velocity) > 0.01 ||
Math.hypot(look.vx, look.vy, look.x - lookX, look.y - lookY) > 0.001;
if (visible && (moving || introClock < 1 || settling)) raf = requestAnimationFrame(frame);
};
const start = () => {
if (raf || !visible || !alive) return;
last = performance.now();
raf = requestAnimationFrame(frame);
};
const locate = e => {
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
return { x, y, inside: x >= 0 && y >= 0 && x <= rect.width && y <= rect.height };
};
const onPointerMove = e => {
const spot = locate(e);
if (spot.inside && !pointer.inside) pointer.fresh = true;
pointer.x = spot.x;
pointer.y = spot.y;
pointer.inside = spot.inside;
if (spot.inside) start();
};
const onPointerDown = e => {
const s = settingsRef.current;
const spot = locate(e);
if (!s || !s.interactive || reducedMotion || !spot.inside) return;
if (!pointer.inside) pointer.fresh = true;
pointer.x = spot.x;
pointer.y = spot.y;
pointer.inside = true;
const radius = Math.max(8, (clamp(s.size, 0.1, 1.5) * Math.min(width, height)) / 2);
const kx = (spot.x - width / 2) / radius;
const ky = (height / 2 - spot.y) / radius;
const distance = Math.hypot(kx, ky);
if (distance > 1.3) return;
const strength = clamp(s.hoverStrength, 0, 1);
kick = { x: kx, y: ky, strength: strength * 1.2 };
surge = Math.max(surge, strength);
const angle = Math.atan2(ky, kx);
spawnArc(angle + (random() - 0.5) * 0.5, 1.3);
spawnArc(angle + Math.PI + (random() - 0.5) * 1.2, 1.1);
start();
};
const onPointerLeave = () => {
pointer.inside = false;
start();
};
const onPointerOut = e => {
if (!e.relatedTarget) onPointerLeave();
};
const onPointerUp = e => {
if (e.pointerType === 'touch') onPointerLeave();
};
const onVisibility = () => {
if (!document.hidden) start();
};
window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('pointerdown', onPointerDown, { passive: true });
window.addEventListener('pointerout', onPointerOut, { passive: true });
window.addEventListener('pointerup', onPointerUp, { passive: true });
window.addEventListener('blur', onPointerLeave);
document.addEventListener('visibilitychange', onVisibility);
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
const intersectionObserver = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
start();
});
intersectionObserver.observe(container);
wakeRef.current = start;
resize();
return () => {
alive = false;
visible = false;
cancelAnimationFrame(raf);
resizeObserver.disconnect();
intersectionObserver.disconnect();
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerout', onPointerOut);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('blur', onPointerLeave);
document.removeEventListener('visibilitychange', onVisibility);
wakeRef.current = null;
disposeDust();
disposeTarget(sceneTarget);
gl.getExtension('WEBGL_lose_context')?.loseContext();
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
};
}, []);
return <div ref={containerRef} className={`crystalized-ball ${className}`.trim()} style={style} />;
};
export default CrystalBall;
```
### Component CSS
```css
.crystalized-ball {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
```
### 怎么接进项目
1. 装上上面列的依赖。
2. 把源码放进项目的组件目录。
3. 引进来,按上面的参数表调。
### More from Ori
更多组件在 Ori 的组件库里:<https://ottersnap.com/style/> —— 挑一个,复制提示词,接进你的项目。