Лазерный поток
LaserFlow — WebGL-фон с лазерным лучом, который падает сверху на поверхность, растекается по её краю световыми нитями и переливается через углы. Поверх добавляются туман, пылинки и сетка точек. Эффект следит за курсором «линзой» и подходит для hero-секций, карточек и декоративных панелей. Компонент декоративный и скрыт от скринридеров.
Интерактивное демо
Пропсы
<script lang="ts" module>
import type { HTMLAttributes } from 'svelte/elements';
export type LaserFlowProps = Omit<HTMLAttributes<HTMLDivElement>, 'color'> & {
/** Элемент-поверхность, на которую падает луч (передавайте через bind:this родителя). */
surface?: HTMLElement | null;
beamPosition?: number;
surfaceLevel?: number;
color?: string;
intensity?: number;
beamWidth?: number;
flare?: number;
spread?: number;
spill?: number;
fog?: number;
dust?: number;
streaks?: number;
dots?: number;
pulse?: number;
speed?: number;
seed?: number;
mouseInteraction?: boolean;
revealImage?: string;
revealRadius?: number;
revealOpacity?: number;
intro?: boolean;
theme?: 'dark' | 'light';
paused?: boolean;
dpr?: number;
};
type Settings = {
surface: HTMLElement | null;
beamPosition: number;
surfaceLevel: number;
color: string;
intensity: number;
beamWidth: number;
flare: number;
spread: number;
spill: number;
fog: number;
dust: number;
streaks: number;
dots: number;
pulse: number;
speed: number;
seed: number;
mouseInteraction: boolean;
revealImage?: string;
revealRadius: number;
revealOpacity: number;
intro: boolean;
theme: 'dark' | 'light';
paused: boolean;
dpr?: number;
};
type Surface = {
left: number;
top: number;
right: number;
bottom: number;
radius: number;
real: boolean;
};
const VERTEX = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}`;
const FRAGMENT = `#version 300 es
precision highp float;
out vec4 outColor;
uniform vec2 uResolution;
uniform float uDpr;
uniform float uScale;
uniform float uTime;
uniform float uBeamX;
uniform vec4 uSurface;
uniform float uRadius;
uniform vec3 uIntro;
uniform vec4 uPour;
uniform vec2 uRun;
uniform vec4 uLens;
uniform sampler2D uReveal;
uniform vec4 uRevealMap;
uniform vec2 uRevealInfo;
uniform float uAtmosphere;
uniform vec4 uAmount;
uniform vec4 uShape;
uniform vec4 uExtra;
uniform float uTheme;
uniform vec3 uTint[16];
#define BEAM uTint[0]
#define LEFT uTint[1]
#define RIGHT uTint[2]
#define CYAN uTint[3]
#define PINK uTint[4]
#define LILAC uTint[5]
#define SKY uTint[6]
#define PURPLE uTint[7]
#define DEEP uTint[8]
#define MIST uTint[9]
#define NAVY uTint[10]
#define WARM uTint[11]
#define EMBER uTint[12]
#define WHITE uTint[13]
#define HAZE uTint[14]
#define LAVENDER uTint[15]
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
for (int i = 0; i < 5; i++) {
v += a * noise(p);
p = m * p;
a *= 0.5;
}
return v;
}
float sq(float x) {
return x * x;
}
float roundBox(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
vec3 spill(float e, float fall, float strength, float front, float t, out vec3 glow) {
glow = vec3(0.0);
if (strength < 0.0005) return vec3(0.0);
float f = max(fall, 0.0);
float d = max(e, 0.0);
float u = d / (6.0 + 0.28 * f);
float start = smoothstep(-6.0, 12.0, fall);
float fade = exp(-sq(max(f - 125.0, 0.0) / 40.0));
float streak = 0.88 + 0.24 * noise(vec2(u * 6.0, f / 22.0 - t * 1.7));
float bright = pow(strength, 1.5);
float soft = sqrt(strength);
float warmth = smoothstep(90.0, 130.0, f);
vec3 coreK = mix(WHITE, WARM, warmth * exp(-d / 9.0));
float core = mix(9.0, 2.6, warmth) * exp(-sq(d / (3.5 + 0.04 * f))) * exp(-sq(max(f - 130.0, 0.0) / 38.0));
float wall = 24.0 * exp(-sq(d / 1.1)) * exp(-sq(max(f - 100.0, 0.0) / 30.0));
float bandCenter = 0.7 - 0.3 * smoothstep(30.0, 110.0, f);
float offset = u - bandCenter;
float decay = (1.05 - bandCenter) / 1.31;
float profile = offset < 0.0 ? exp(-sq(offset / 0.3)) : exp(-offset / decay);
profile *= 1.0 - smoothstep(1.0, 1.4, u);
vec3 bandA = mix(CYAN, PINK, smoothstep(50.0, 75.0, f));
bandA = mix(bandA, LILAC, smoothstep(100.0, 125.0, f));
vec3 bandB = mix(SKY, PURPLE, smoothstep(50.0, 75.0, f));
vec3 fanK = mix(bandA, bandB, smoothstep(0.06, 0.18, offset));
fanK = mix(fanK, DEEP, smoothstep(0.22, 0.42, offset));
vec3 thread = WHITE * wall * bright + coreK * core * streak * bright;
glow += fanK * 4.5 * profile * fade * streak * bright;
glow += MIST * 0.7 * exp(-max(u - 1.0, 0.0) / 0.9) * smoothstep(0.7, 1.2, u) * fade * soft;
glow += EMBER * 0.35 * exp(-sq((f - 165.0) / 22.0)) * exp(-d / 25.0) * bright;
float lag = u * (6.0 + 0.28 * f) * 0.35;
float reveal = 1.0 - smoothstep(front - 70.0, front + 8.0, f + lag);
glow *= start * reveal;
return thread * start * reveal;
}
vec3 easeLight(vec3 light, float amount) {
return -log(max(1.0 - amount * (1.0 - exp(-light)), vec3(0.0001)));
}
void main() {
vec2 frag = vec2(gl_FragCoord.x, uResolution.y * uDpr - gl_FragCoord.y) / uDpr;
vec2 p = frag / uScale;
float t = uTime;
float bx = uBeamX / uScale;
float sl = uSurface.x / uScale;
float sy = uSurface.y / uScale;
float sr = uSurface.z / uScale;
float sb = uSurface.w / uScale;
float radius = uRadius / uScale;
float dx = p.x - bx;
float adx = abs(dx);
float h = sy - p.y;
float hp = max(h, 0.0);
float above = smoothstep(-0.5, 0.5, h);
float side = smoothstep(-24.0, 24.0, dx);
vec3 sideK = mix(mix(LAVENDER, LEFT, smoothstep(40.0, 140.0, adx)), RIGHT, side);
float beamWidth = uShape.x;
float flare = uShape.y;
float spread = 250.0 * uShape.z;
float reachL = max(bx - sl, 1.0);
float reachR = max(sr - bx, 1.0);
float reach = dx < 0.0 ? reachL : reachR;
float layerLen = clamp(0.29 * reach, 20.0, 100.0) * sqrt(uShape.z);
float run = uRun.x;
float runMask = 1.0 - smoothstep(run - 34.0, run + 6.0, adx);
float bead = exp(-sq((adx - run + 16.0) / (18.0 + 0.04 * run))) * uRun.y;
float flowMask = (1.0 - smoothstep(0.75 * spread, 1.1 * spread, adx)) * runMask;
float edgeMask = (1.0 - smoothstep(0.8 * spread, 1.25 * spread, adx)) * runMask;
float bloom = uIntro.y;
vec2 center = vec2((sl + sr) * 0.5, (sy + sb) * 0.5);
vec2 halfSize = vec2((sr - sl) * 0.5, (sb - sy) * 0.5);
float sd = roundBox(p - center, halfSize, radius);
float outline = max(sd, 0.0);
float below = max(p.y - sy - radius, 0.0);
float hug = smoothstep(-0.5, 0.5, sd) * exp(-sq(below / 14.0));
float hugWide = smoothstep(-0.5, 0.5, sd) * exp(-sq(below / 40.0));
vec3 col = vec3(0.0);
float breathe = 1.0 + uExtra.x * (0.12 * sin(t * 1.5708) + 0.08 * (noise(vec2(t * 0.5, 3.0)) - 0.5));
float front = uIntro.x / uScale;
float drop = smoothstep(front + 10.0, front - 110.0, p.y);
float falling = 1.0 - smoothstep(sy - 24.0, sy, front);
float top = mix(0.3, 1.0, smoothstep(0.0, 0.3 * sy, p.y));
float beamCore = 24.0 * exp(-adx / (2.0 * beamWidth));
float beamHalo = 1.3 * exp(-adx / (6.0 * beamWidth)) + 0.3 * exp(-adx / (22.0 * beamWidth));
vec3 haloK = mix(BEAM, sideK, exp(-hp / 80.0));
col += (BEAM * beamCore + haloK * beamHalo) * top * above * drop;
float head = exp(-sq((p.y - front) / 24.0)) * falling;
col += mix(WHITE, BEAM, 0.35) * head * (7.0 * exp(-adx / (2.6 * beamWidth)) + 1.0 * exp(-adx / (14.0 * beamWidth)));
float psi = adx * (outline + 1.0);
float rise = max(hp - 17.9 * flare, 0.0);
float skirtAmp = 16.6 * (exp(-sq(rise / (11.8 * flare))) + 0.3 * exp(-rise / (60.0 * flare)));
float skirtSpread = mix(134.0, 155.0, side) * breathe * flare * flare * mix(0.35, 1.0, min(bloom, 1.0)) * max(bloom, 1.0);
float skirt = skirtAmp * exp(-psi / skirtSpread) * exp(-sq(adx / (max(layerLen, 60.0 * sqrt(uShape.z)) * 1.48))) * hug;
col += sideK * skirt * flowMask * min(bloom, 1.0);
float layer = 12.0 * exp(-adx / layerLen) * exp(-outline / (0.19 * layerLen)) * (1.0 + 0.4 * bead);
col += sideK * layer * hug * flowMask * min(bloom, 1.0);
float washMask = smoothstep(sl - 60.0, sl + 6.0, p.x) * (1.0 - smoothstep(sr - 6.0, sr + 45.0, p.x));
float wash = 1.4 * exp(-adx / 100.0) * exp(-outline / 30.0);
col += mix(BEAM, sideK, 0.25) * wash * hugWide * washMask * flowMask * min(bloom, 1.0);
float highWash = 0.7 * exp(-adx / 140.0) * exp(-outline / 60.0);
col += mix(BEAM, NAVY, 0.5) * highWash * hugWide * washMask * runMask * min(bloom, 1.0);
float sideSign = dx < 0.0 ? 0.0 : 1.0;
float along = 0.5 * log((hp + 1.0) / (adx + 1.0));
float lineScale = 6.0;
float lp = log(max(psi, 1.0) / (120.0 * flare * flare)) * lineScale;
float lineIndex = floor(lp) + sideSign * 97.0;
float wobble = 0.22 * (noise(vec2(lineIndex * 3.7, t * 0.4 + along)) - 0.5);
float lf = fract(lp + wobble) - 0.5;
float gradient = lineScale * length(vec2(hp + 1.0, adx)) / max(psi, 1.0) / uScale;
float halfWidth = max(0.35 * gradient, 0.0001);
float drawWidth = max(halfWidth, 0.6 * gradient);
float lineShape = exp(-lf * lf / (drawWidth * drawWidth)) * (halfWidth / drawWidth);
float lineOn = step(0.3, hash(vec2(lineIndex, 1.3)));
float lineFlow = smoothstep(0.25, 0.85, noise(vec2(lineIndex * 5.3 + 2.0, along * 3.0 + t * 1.1)));
float flareArea = 120.0 * flare * flare;
float lineEnvelope = smoothstep(0.85 * flareArea, 1.4 * flareArea, psi) * (1.0 - smoothstep(3.2 * flareArea, 7.5 * flareArea, psi));
lineEnvelope *= exp(-adx / (layerLen * 1.5)) * hug * flowMask * (1.0 - smoothstep(35.0 * flare, 80.0 * flare, hp));
float lineBright = mix(0.35, 1.0, hash(vec2(lineIndex, 4.0)));
col += mix(sideK, WHITE, 0.6) * lineShape * lineOn * lineFlow * lineEnvelope * lineBright * 2.6 * uAmount.w * min(bloom, 1.0);
float edgeGlow = 22.8 * exp(-sq(adx / (150.0 * uShape.z))) * edgeMask * (1.0 + 0.9 * bead);
float onTop = 1.0 - smoothstep(sy + radius, sy + radius + 10.0, p.y);
float edgeLine = (exp(-sq(max(sd, 0.0) / 0.9)) + 0.12 * exp(-max(sd, 0.0) / 3.0)) * edgeGlow * onTop;
col += mix(sideK, WHITE, 0.6) * edgeLine * min(bloom, 1.0);
float pour = uShape.w;
float baseline = exp(-sq(130.0 / 150.0));
float strengthR = exp(-sq(reachR / (150.0 * uShape.z))) * (1.0 - smoothstep(0.8 * spread, 1.25 * spread, reachR)) / baseline * pour;
float strengthL = exp(-sq(reachL / (150.0 * uShape.z))) * (1.0 - smoothstep(0.8 * spread, 1.25 * spread, reachL)) / baseline * pour;
float fallY = p.y - sy - 0.3 * radius;
float rightHalf = smoothstep(center.x - 4.0, center.x + 4.0, p.x);
float haloR = exp(-length(p - vec2(sr - 0.3 * radius, sy + 0.3 * radius)) / 40.0) * sqrt(strengthR);
float haloL = exp(-length(p - vec2(sl + 0.3 * radius, sy + 0.3 * radius)) / 40.0) * sqrt(strengthL);
vec3 glowR;
vec3 glowL;
col += spill(sd, fallY, strengthR, uPour.y, t, glowR) * rightHalf;
col += spill(sd, fallY, strengthL, uPour.x, t, glowL) * (1.0 - rightHalf);
col += easeLight(glowR * rightHalf + NAVY * 0.9 * haloR, uPour.w);
col += easeLight(glowL * (1.0 - rightHalf) + NAVY * 0.9 * haloL, uPour.z);
vec2 toLens = frag - uLens.xy;
float lens = exp(-dot(toLens, toLens) / sq(uLens.z)) * uLens.w;
col *= 1.0 + 0.35 * lens * hug;
float fogAmount = uAmount.y * uAtmosphere;
float near = exp(-adx / 150.0);
float envelope = near * (0.3 + 0.4 * exp(-hp / 50.0) + 1.2 * exp(-p.y / 120.0));
if (fogAmount > 0.001 && h > -2.0 && envelope * max(0.25, 6.0 * exp(-adx / 50.0)) > 0.002) {
vec2 fq = vec2(dx / 1.7, h) / 85.0 + vec2(t * 0.012, -t * 0.03) + uExtra.zw;
vec2 fw = vec2(fbm(fq * 1.3 + vec2(1.7, 9.2) + t * 0.03), fbm(fq * 1.3 + vec2(8.3, 2.8) - t * 0.025));
float cloud = fbm(fq + 1.4 * fw);
float density = smoothstep(0.5, 0.78, cloud);
float lit = exp(-adx / 50.0);
float fogMask = smoothstep(-2.0, 20.0, h);
float reached = smoothstep(front + 80.0, front - 40.0, p.y);
vec3 fog = (HAZE * 0.45 + BEAM * 6.0 * lit * reached) * density * envelope * fogMask;
float surfaceLit = exp(-adx / (layerLen * 1.3)) * exp(-outline / 28.0) * hugWide * flowMask * washMask * min(bloom, 1.0);
fog += sideK * 2.2 * surfaceLit * (smoothstep(0.38, 0.72, cloud) - 0.35) * fogMask;
float smoke = smoothstep(0.3, 0.75, cloud);
fog += BEAM * 0.35 * exp(-adx / (22.0 * beamWidth)) * (0.35 + 0.9 * smoke) * fogMask * top * drop;
col += fog * fogAmount * (1.0 + 1.2 * lens) * mix(1.0, 0.45, uTheme);
}
if (uRevealInfo.y > 0.001 && uLens.w > 0.002) {
vec2 toReveal = frag - uLens.xy;
float reveal = exp(-dot(toReveal, toReveal) / sq(uRevealInfo.x)) * uLens.w;
vec2 uv = frag * uRevealMap.xy + uRevealMap.zw;
float onImage = step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0);
vec4 ui = texture(uReveal, uv);
float ink = max(ui.r, max(ui.g, ui.b)) * onImage;
float lit = clamp(col.b * 0.9, 0.0, 1.0) * (1.0 - smoothstep(0.9, 2.2, col.b));
float clearance = smoothstep(60.0 * sqrt(beamWidth), 150.0 * sqrt(beamWidth), adx);
col += mix(BEAM, WHITE, 0.4) * ink * reveal * lit * clearance * uRevealInfo.y * 2.2;
}
float haze = col.b;
float dotGrid = 1.0 - smoothstep(0.16, 0.32, length(fract(frag / 3.0) - 0.5));
col += BEAM * dotGrid * clamp(haze, 0.0, 1.5) * (1.0 - smoothstep(1.5, 4.0, haze)) * (0.18 + 0.3 * lens) * uExtra.y;
if (lens > 0.002) {
vec2 drift = frag - vec2(0.0, t * 14.0);
vec2 sparkCell = floor(drift / 7.0);
vec2 sparkPos = (sparkCell + 0.2 + 0.6 * vec2(hash(sparkCell + 5.1), hash(sparkCell + 2.3))) * 7.0;
float sparkDist = length(drift - sparkPos);
float sparkOn = step(0.8, hash(sparkCell + 8.8));
float sparkTwinkle = 0.5 + 0.5 * sin(t * 5.0 + hash(sparkCell + 3.7) * 6.283);
col += WHITE * exp(-sparkDist * sparkDist / 0.35) * sparkOn * sparkTwinkle * lens * clamp(haze * 1.5, 0.0, 1.0) * 1.6 * uAmount.z;
}
float dustAmount = uAmount.z;
if (dustAmount > 0.001) {
float dustLp = log(max(psi, 1.0) / (120.0 * flare * flare)) * 2.2;
vec2 flowCell = vec2(dustLp / 1.1, (along + t * 0.22) / 0.06);
vec2 cellId = floor(flowCell) + vec2(sideSign * 131.0, 0.0);
vec2 cellJitter = vec2(hash(cellId + 1.7), hash(cellId + 8.3));
vec2 particleFlow = (floor(flowCell) + 0.1 + cellJitter * 0.8) * vec2(1.1, 0.06);
float particlePsi = 120.0 * flare * flare * exp(particleFlow.x / 2.2);
float particleAlong = particleFlow.y - t * 0.22;
float particleX = sqrt(particlePsi * exp(-2.0 * particleAlong)) - 1.0;
float particleH = sqrt(particlePsi * exp(2.0 * particleAlong)) - 1.0;
float particleDist = length(vec2(adx - particleX, hp - particleH)) * uScale;
float particleOn = step(0.62, hash(cellId + 3.1));
float particleTwinkle = 0.5 + 0.5 * sin(t * 5.0 + hash(cellId + 9.2) * 6.283);
float dustZone = exp(-sq(log(max(psi, 1.0) / (200.0 * flare * flare)) / 0.55)) * hug * flowMask * smoothstep(0.0, 6.0, outline);
col += WHITE * exp(-particleDist * particleDist / 0.3) * particleOn * particleTwinkle * dustZone * 3.0 * dustAmount * min(bloom, 1.0);
vec2 glitterCell = vec2(dx / 3.0, (p.y - t * 26.0) / 9.0);
vec2 glitterId = floor(glitterCell);
vec2 glitterJitter = vec2(hash(glitterId + 4.4), hash(glitterId + 2.9));
vec2 glitterPos = (glitterId + 0.15 + glitterJitter * 0.7) * vec2(3.0, 9.0);
float glitterDist = length(vec2(dx, p.y - t * 26.0) - glitterPos) * uScale;
float glitterOn = step(0.7, hash(glitterId + 6.6));
float glitterTwinkle = 0.5 + 0.5 * sin(t * 6.0 + hash(glitterId + 1.1) * 6.283);
float glitterZone = exp(-adx / (5.0 * beamWidth)) * smoothstep(1.5, 4.0, adx / beamWidth) * above * smoothstep(25.0, 70.0, hp) * top * drop;
col += WHITE * exp(-glitterDist * glitterDist / 0.25) * glitterOn * glitterTwinkle * glitterZone * 2.2 * dustAmount * uAtmosphere;
}
col *= uAmount.x * (1.0 + uIntro.z * 0.9 * exp(-length(vec2(dx, h * 1.6)) / 70.0));
col *= smoothstep(-0.75, 0.25, sd * uScale);
vec3 emit = 1.0 - exp(-col * mix(1.0, 0.6, uTheme));
float peak = max(emit.r, max(emit.g, emit.b));
if (uTheme > 0.5) {
float low = min(emit.r, min(emit.g, emit.b));
float chroma = peak - low;
vec3 hue = (emit - low) / max(chroma, 0.0001);
float alpha = clamp(pow(chroma, 1.35) * 1.3, 0.0, 1.0) * 0.9;
outColor = vec4((hue * 0.82 + 0.06) * alpha, alpha);
} else {
outColor = vec4(emit, peak);
}
}`;
const TINTS = [
[0, 1],
[-11.5, 0.77],
[9.5, 0.95],
[-53.5, 0.79],
[39.5, 0.77],
[16, 0.81],
[-16.3, 0.92],
[13.3, 0.96],
[3, 1.01],
[1, 0.68],
[1, 1.15],
[142.5, 0.51],
[113.6, 0.28],
[-7.5, 0.13],
[-2.7, 0.32],
[7.5, 0.81]
];
const BASE_SATURATION = 0.78;
const MAX_RENDER_DIM = 2048;
const INTRO_DROP = 1.25;
const INTRO_END = 5;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const easeOut = (x: number) => 1 - Math.pow(1 - clamp(x, 0, 1), 3);
const spring = (time: number) => {
const omega = 8;
const zeta = 0.62;
const damped = omega * Math.sqrt(1 - zeta * zeta);
return (
1 -
Math.exp(-zeta * omega * time) *
(Math.cos(damped * time) + ((zeta * omega) / damped) * Math.sin(damped * time))
);
};
const toHsv = ([r, g, b]: number[]): [number, number] => {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let hue = 0;
if (delta > 0) {
if (max === r) hue = ((g - b) / delta) % 6;
else if (max === g) hue = (b - r) / delta + 2;
else hue = (r - g) / delta + 4;
}
return [(hue * 60 + 360) % 360, max > 0 ? delta / max : 0];
};
const fromHsv = (hue: number, saturation: number): number[] => {
const h = (((hue % 360) + 360) % 360) / 60;
const c = saturation;
const x = c * (1 - Math.abs((h % 2) - 1));
const m = 1 - c;
const [r, g, b] =
h < 1
? [c, x, 0]
: h < 2
? [x, c, 0]
: h < 3
? [0, c, x]
: h < 4
? [0, x, c]
: h < 5
? [x, 0, c]
: [c, 0, x];
return [r + m, g + m, b + m];
};
const buildTints = (rgb: number[]) => {
const [hue, saturation] = toHsv(rgb);
const strength = saturation / BASE_SATURATION;
const data = new Float32Array(TINTS.length * 3);
TINTS.forEach(([shift, scale], index) => {
data.set(fromHsv(hue + shift, clamp(BASE_SATURATION * scale * strength, 0, 1)), index * 3);
});
return data;
};
const compile = (gl: WebGL2RenderingContext, type: number, source: string) => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
gl.deleteShader(shader);
return null;
};
const link = (gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string) => {
const vertex = compile(gl, gl.VERTEX_SHADER, vertexSource);
const fragment = compile(gl, gl.FRAGMENT_SHADER, fragmentSource);
const program = gl.createProgram();
if (!vertex || !fragment || !program) return null;
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
gl.deleteProgram(program);
return null;
};
const locate = (gl: WebGL2RenderingContext, program: WebGLProgram, names: string[]) => {
const result: Record<string, WebGLUniformLocation | null> = {};
for (const name of names) result[name] = gl.getUniformLocation(program, name);
return result;
};
export type { Settings as LaserFlowSettings };
</script>
<script lang="ts">
import { onMount } from 'svelte';
let {
surface = null,
beamPosition = 0.6,
surfaceLevel = 1,
color = '#3847ff',
intensity = 1.8,
beamWidth = 1.5,
flare = 1.5,
spread = 1.5,
spill = 1,
fog = 0.2,
dust = 1.6,
streaks = 1.5,
dots = 1,
pulse = 1.5,
speed = 1.5,
seed = 3,
mouseInteraction = true,
revealImage,
revealRadius = 220,
revealOpacity = 0.6,
intro = true,
theme = 'dark',
paused = false,
dpr,
class: className = '',
...rest
}: LaserFlowProps & { class?: string } = $props();
let container = $state<HTMLDivElement | null>(null);
let canvas = $state<HTMLCanvasElement | null>(null);
let wake: (() => void) | null = null;
const settings = $derived<Settings>({
surface,
beamPosition: clamp(beamPosition, 0, 1),
surfaceLevel: clamp(surfaceLevel, 0, 1),
color,
intensity: Math.max(0, intensity),
beamWidth: clamp(beamWidth, 0.2, 5),
flare: clamp(flare, 0.2, 4),
spread: clamp(spread, 0.1, 4),
spill: Math.max(0, spill),
fog: Math.max(0, fog),
dust: Math.max(0, dust),
streaks: Math.max(0, streaks),
dots: Math.max(0, dots),
pulse: Math.max(0, pulse),
speed,
seed,
mouseInteraction,
revealImage,
revealRadius: Math.max(1, revealRadius),
revealOpacity: clamp(revealOpacity, 0, 1),
intro,
theme,
paused,
dpr
});
// Актуальные настройки, доступные рендер-циклу без пересоздания WebGL-контекста.
const settingsRef: { current: Settings | null } = { current: null };
$effect.pre(() => {
settingsRef.current = settings;
});
// Любое изменение пропсов будит рендер-цикл.
$effect(() => {
settingsRef.current = settings;
wake?.();
});
onMount(() => {
const containerEl = container;
const canvasEl = canvas;
const gl = canvasEl?.getContext('webgl2', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
depth: false,
stencil: false
});
if (!containerEl || !canvasEl || !gl) return;
const program = link(gl, VERTEX, FRAGMENT);
if (!program) return;
const uniforms = locate(gl, program, [
'uResolution',
'uDpr',
'uScale',
'uTime',
'uBeamX',
'uSurface',
'uRadius',
'uIntro',
'uPour',
'uRun',
'uLens',
'uReveal',
'uRevealMap',
'uRevealInfo',
'uAtmosphere',
'uAmount',
'uShape',
'uExtra',
'uTheme',
'uTint'
]);
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const positionLocation = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
const revealTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, revealTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0])
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const probe = document.createElement('canvas');
probe.width = 1;
probe.height = 1;
const probeContext = probe.getContext('2d', { willReadFrequently: true });
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const state = {
time: 0,
introTime: settingsRef.current?.intro && !reduce ? 0 : INTRO_END,
width: 1,
height: 1,
ratio: 1,
colorKey: '',
tints: buildTints([0.22, 0.28, 1]),
observed: null as HTMLElement | null,
pointer: { x: 0, y: 0, inside: false },
lens: { x: 0, y: 0, vx: 0, vy: 0, strength: 0, placed: false },
reveal: {
src: '',
ready: false,
width: 1,
height: 1,
image: null as HTMLImageElement | null
}
};
let raf = 0;
let last = 0;
let visible = true;
let alive = true;
const toRgb = (value: string): number[] => {
if (!probeContext) return [0.22, 0.28, 1];
probeContext.clearRect(0, 0, 1, 1);
probeContext.fillStyle = '#3847ff';
probeContext.fillStyle = value;
probeContext.fillRect(0, 0, 1, 1);
const [r, g, b] = probeContext.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255];
};
const resolveColor = (s: Settings) => {
if (s.color === state.colorKey) return;
state.colorKey = s.color;
state.tints = buildTints(toRgb(s.color));
};
const resolveReveal = (s: Settings) => {
const src = s.revealImage ?? '';
const reveal = state.reveal;
if (src === reveal.src) return;
reveal.src = src;
reveal.ready = false;
if (reveal.image) reveal.image.onload = null;
if (!src) return;
const image = new Image();
image.crossOrigin = 'anonymous';
image.decoding = 'async';
image.onload = () => {
if (!alive || reveal.src !== src) return;
const width = image.naturalWidth || image.width || 1;
const height = image.naturalHeight || image.height || 1;
const scale = Math.min(1, 2048 / Math.max(width, height));
const sheet = document.createElement('canvas');
sheet.width = Math.max(1, Math.round(width * scale));
sheet.height = Math.max(1, Math.round(height * scale));
sheet.getContext('2d')?.drawImage(image, 0, 0, sheet.width, sheet.height);
gl.bindTexture(gl.TEXTURE_2D, revealTexture);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, sheet);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
reveal.width = sheet.width;
reveal.height = sheet.height;
reveal.ready = true;
wake?.();
};
image.src = src;
reveal.image = image;
};
const measure = (s: Settings, width: number, height: number): Surface => {
const element = s.surface ?? null;
if (element !== state.observed) {
if (state.observed) resizeObserver.unobserve(state.observed);
if (element) resizeObserver.observe(element);
state.observed = element;
}
if (element) {
const box = containerEl.getBoundingClientRect();
const rect = element.getBoundingClientRect();
if (rect.width > 0) {
const computed = getComputedStyle(element);
const radius = Math.max(
parseFloat(computed.borderTopLeftRadius) || 0,
parseFloat(computed.borderTopRightRadius) || 0
);
return {
left: rect.left - box.left,
top: rect.top - box.top,
right: rect.right - box.left,
bottom: rect.bottom - box.top,
radius: Math.min(radius, rect.width / 2, rect.height / 2),
real: true
};
}
}
return {
left: -width * 2,
top: s.surfaceLevel * height,
right: width * 3,
bottom: height * 3,
radius: 0,
real: false
};
};
const resize = () => {
const width = Math.max(1, containerEl.clientWidth);
const height = Math.max(1, containerEl.clientHeight);
const s = settingsRef.current;
const baseDpr = Math.min(s?.dpr || window.devicePixelRatio || 1, 2);
const longest = Math.max(width, height) * baseDpr;
const ratio = longest > MAX_RENDER_DIM ? (baseDpr * MAX_RENDER_DIM) / longest : baseDpr;
const pixelWidth = Math.max(1, Math.round(width * ratio));
const pixelHeight = Math.max(1, Math.round(height * ratio));
if (canvasEl.width !== pixelWidth || canvasEl.height !== pixelHeight) {
canvasEl.width = pixelWidth;
canvasEl.height = pixelHeight;
}
state.width = width;
state.height = height;
state.ratio = canvasEl.width / width;
render();
};
const render = () => {
const s = settingsRef.current;
if (!s) return;
resolveColor(s);
const { width, height } = state;
const scale = Math.max(0.2, Math.min(width / 585, height / 507));
let surfaceBox = measure(s, width, height);
let beamX = s.beamPosition * width;
if (surfaceBox.real) {
const margin = surfaceBox.radius + 6;
if (beamX >= surfaceBox.left && beamX <= surfaceBox.right) {
beamX = clamp(beamX, surfaceBox.left + margin, surfaceBox.right - margin);
} else {
surfaceBox = {
left: -width * 2,
top: height,
right: width * 3,
bottom: height * 3,
radius: 0,
real: false
};
}
}
const startY = -0.15 * height;
const span = surfaceBox.top - startY + 0.3 * height;
const it = state.introTime;
const settled = it >= INTRO_END;
const front = settled
? surfaceBox.top + 0.3 * height
: startY + span * Math.pow(clamp(it / INTRO_DROP, 0, 1), 1.3);
const impact = INTRO_DROP * Math.pow(Math.max(0, surfaceBox.top - startY) / span, 1 / 1.3);
const tau = it - impact;
const bloom = settled ? 1 : tau <= 0 ? 0 : spring(tau);
const flash = settled || tau <= 0 ? 0 : Math.exp(-tau / 0.35) * (1 - Math.exp(-tau / 0.05));
const runRange = s.spread * 250 * 1.3 + 80;
const runFront = settled ? 100000 : tau <= 0 ? -40 : runRange * (1 - Math.exp(-1.15 * tau));
const beadStrength =
settled || tau <= 0 ? 0 : Math.exp(-1.15 * tau) * (1 - Math.exp(-tau / 0.08));
const pour = (reach: number): [number, number] => {
if (settled) return [1000, 1];
if (tau <= 0) return [-reach, 0];
const target = Math.min(reach, runRange * 0.97);
const arrival = -Math.log(1 - target / runRange) / 1.15;
const elapsed = tau - arrival;
const ease = clamp((elapsed + 0.15) / 0.8, 0, 1);
const fill = ease * ease * ease * (ease * (ease * 6 - 15) + 10);
if (elapsed <= 0) return [runFront - target, fill];
const pourSpeed = 1.15 * (runRange - target);
return [pourSpeed * elapsed + 260 * elapsed * elapsed, fill];
};
const [frontL, fillL] = pour((beamX - surfaceBox.left) / scale);
const [frontR, fillR] = pour((surfaceBox.right - beamX) / scale);
const atmosphere = settled
? 1
: tau <= 0
? 0.3 * easeOut(it / INTRO_DROP)
: 0.3 + 0.7 * (1 - Math.exp(-1.3 * tau));
const seedValue = s.seed || 0;
gl.viewport(0, 0, canvasEl.width, canvasEl.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.uniform2f(uniforms.uResolution, width, height);
gl.uniform1f(uniforms.uDpr, state.ratio);
gl.uniform1f(uniforms.uScale, scale);
gl.uniform1f(uniforms.uTime, state.time);
gl.uniform1f(uniforms.uBeamX, beamX);
gl.uniform4f(
uniforms.uSurface,
surfaceBox.left,
surfaceBox.top,
surfaceBox.right,
surfaceBox.bottom
);
gl.uniform1f(uniforms.uRadius, surfaceBox.radius);
gl.uniform3f(uniforms.uIntro, front, bloom, flash);
gl.uniform4f(uniforms.uPour, frontL, frontR, fillL, fillR);
gl.uniform2f(uniforms.uRun, runFront, beadStrength);
gl.uniform4f(uniforms.uLens, state.lens.x, state.lens.y, 95 * scale, state.lens.strength);
resolveReveal(s);
const { reveal } = state;
const cover = Math.max(width / reveal.width, height / reveal.height);
const coverWidth = reveal.width * cover;
const coverHeight = reveal.height * cover;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, revealTexture);
gl.uniform1i(uniforms.uReveal, 0);
gl.uniform4f(
uniforms.uRevealMap,
1 / coverWidth,
1 / coverHeight,
(coverWidth - width) / 2 / coverWidth,
(coverHeight - height) / 2 / coverHeight
);
gl.uniform2f(uniforms.uRevealInfo, s.revealRadius, reveal.ready ? s.revealOpacity : 0);
gl.uniform1f(uniforms.uAtmosphere, atmosphere);
gl.uniform4f(uniforms.uAmount, s.intensity, s.fog, s.dust, s.streaks);
gl.uniform4f(uniforms.uShape, s.beamWidth, s.flare, s.spread, s.spill);
gl.uniform4f(
uniforms.uExtra,
s.pulse,
s.dots,
34.34 + seedValue * 7.31,
11.58 + seedValue * 3.17
);
gl.uniform1f(uniforms.uTheme, s.theme === 'light' ? 1 : 0);
gl.uniform3fv(uniforms.uTint, state.tints);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
const frame = (now: number) => {
raf = 0;
if (!alive || !visible || document.hidden) return;
const s = settingsRef.current;
if (!s) return;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
last = now;
const moving = !s.paused && !reduce;
if (moving) state.time += dt * s.speed;
const introducing = state.introTime < INTRO_END;
if (introducing) state.introTime = Math.min(INTRO_END, state.introTime + dt);
const { pointer, lens } = state;
const active = s.mouseInteraction && pointer.inside && !reduce;
if (active && !lens.placed) {
lens.x = pointer.x;
lens.y = pointer.y;
lens.vx = 0;
lens.vy = 0;
lens.placed = true;
}
const ax = 120 * (pointer.x - lens.x) - 19 * lens.vx;
const ay = 120 * (pointer.y - lens.y) - 19 * lens.vy;
lens.vx += ax * dt;
lens.vy += ay * dt;
lens.x += lens.vx * dt;
lens.y += lens.vy * dt;
const goal = active ? 1 : 0;
lens.strength +=
(goal - lens.strength) * (1 - Math.exp(-dt / (goal > lens.strength ? 0.3 : 0.55)));
if (lens.strength < 0.001 && !active) lens.placed = false;
render();
const settling =
Math.abs(state.lens.strength - (s.mouseInteraction && state.pointer.inside ? 1 : 0)) >
0.002 || Math.hypot(state.lens.vx, state.lens.vy) > 0.5;
if (moving || introducing || settling) raf = requestAnimationFrame(frame);
else last = 0;
};
const wakeLoop = () => {
if (!raf && alive && visible && !document.hidden) raf = requestAnimationFrame(frame);
};
const onPointerMove = (event: PointerEvent) => {
const rect = containerEl.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
state.pointer.x = x;
state.pointer.y = y;
state.pointer.inside = x >= 0 && y >= 0 && x <= rect.width && y <= rect.height;
wakeLoop();
};
const onPointerLeave = () => {
state.pointer.inside = false;
wakeLoop();
};
const onVisibility = () => {
last = 0;
wakeLoop();
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(containerEl);
const intersection = new IntersectionObserver((entries) => {
visible = entries.some((entry) => entry.isIntersecting);
if (visible) {
last = 0;
wakeLoop();
}
});
intersection.observe(containerEl);
window.addEventListener('pointermove', onPointerMove, { passive: true });
document.documentElement.addEventListener('pointerleave', onPointerLeave);
document.addEventListener('visibilitychange', onVisibility);
resize();
wakeLoop();
wake = () => {
if (!raf) render();
wakeLoop();
};
return () => {
alive = false;
cancelAnimationFrame(raf);
wake = null;
resizeObserver.disconnect();
intersection.disconnect();
window.removeEventListener('pointermove', onPointerMove);
document.documentElement.removeEventListener('pointerleave', onPointerLeave);
document.removeEventListener('visibilitychange', onVisibility);
gl.deleteTexture(revealTexture);
gl.deleteBuffer(buffer);
gl.deleteVertexArray(vao);
gl.deleteProgram(program);
};
});
</script>
<div
bind:this={container}
class="pointer-events-none relative h-full w-full {className}"
{...rest}
>
<canvas bind:this={canvas} class="absolute inset-0 block h-full w-full" aria-hidden="true"></canvas>
</div><script lang="ts" module>
import type { HTMLAttributes } from 'svelte/elements';
export type LaserFlowProps = Omit<HTMLAttributes<HTMLDivElement>, 'color'> & {
/** Элемент-поверхность, на которую падает луч (передавайте через bind:this родителя). */
surface?: HTMLElement | null;
beamPosition?: number;
surfaceLevel?: number;
color?: string;
intensity?: number;
beamWidth?: number;
flare?: number;
spread?: number;
spill?: number;
fog?: number;
dust?: number;
streaks?: number;
dots?: number;
pulse?: number;
speed?: number;
seed?: number;
mouseInteraction?: boolean;
revealImage?: string;
revealRadius?: number;
revealOpacity?: number;
intro?: boolean;
theme?: 'dark' | 'light';
paused?: boolean;
dpr?: number;
};
type Settings = {
surface: HTMLElement | null;
beamPosition: number;
surfaceLevel: number;
color: string;
intensity: number;
beamWidth: number;
flare: number;
spread: number;
spill: number;
fog: number;
dust: number;
streaks: number;
dots: number;
pulse: number;
speed: number;
seed: number;
mouseInteraction: boolean;
revealImage?: string;
revealRadius: number;
revealOpacity: number;
intro: boolean;
theme: 'dark' | 'light';
paused: boolean;
dpr?: number;
};
type Surface = {
left: number;
top: number;
right: number;
bottom: number;
radius: number;
real: boolean;
};
const VERTEX = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}`;
const FRAGMENT = `#version 300 es
precision highp float;
out vec4 outColor;
uniform vec2 uResolution;
uniform float uDpr;
uniform float uScale;
uniform float uTime;
uniform float uBeamX;
uniform vec4 uSurface;
uniform float uRadius;
uniform vec3 uIntro;
uniform vec4 uPour;
uniform vec2 uRun;
uniform vec4 uLens;
uniform sampler2D uReveal;
uniform vec4 uRevealMap;
uniform vec2 uRevealInfo;
uniform float uAtmosphere;
uniform vec4 uAmount;
uniform vec4 uShape;
uniform vec4 uExtra;
uniform float uTheme;
uniform vec3 uTint[16];
#define BEAM uTint[0]
#define LEFT uTint[1]
#define RIGHT uTint[2]
#define CYAN uTint[3]
#define PINK uTint[4]
#define LILAC uTint[5]
#define SKY uTint[6]
#define PURPLE uTint[7]
#define DEEP uTint[8]
#define MIST uTint[9]
#define NAVY uTint[10]
#define WARM uTint[11]
#define EMBER uTint[12]
#define WHITE uTint[13]
#define HAZE uTint[14]
#define LAVENDER uTint[15]
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
for (int i = 0; i < 5; i++) {
v += a * noise(p);
p = m * p;
a *= 0.5;
}
return v;
}
float sq(float x) {
return x * x;
}
float roundBox(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
vec3 spill(float e, float fall, float strength, float front, float t, out vec3 glow) {
glow = vec3(0.0);
if (strength < 0.0005) return vec3(0.0);
float f = max(fall, 0.0);
float d = max(e, 0.0);
float u = d / (6.0 + 0.28 * f);
float start = smoothstep(-6.0, 12.0, fall);
float fade = exp(-sq(max(f - 125.0, 0.0) / 40.0));
float streak = 0.88 + 0.24 * noise(vec2(u * 6.0, f / 22.0 - t * 1.7));
float bright = pow(strength, 1.5);
float soft = sqrt(strength);
float warmth = smoothstep(90.0, 130.0, f);
vec3 coreK = mix(WHITE, WARM, warmth * exp(-d / 9.0));
float core = mix(9.0, 2.6, warmth) * exp(-sq(d / (3.5 + 0.04 * f))) * exp(-sq(max(f - 130.0, 0.0) / 38.0));
float wall = 24.0 * exp(-sq(d / 1.1)) * exp(-sq(max(f - 100.0, 0.0) / 30.0));
float bandCenter = 0.7 - 0.3 * smoothstep(30.0, 110.0, f);
float offset = u - bandCenter;
float decay = (1.05 - bandCenter) / 1.31;
float profile = offset < 0.0 ? exp(-sq(offset / 0.3)) : exp(-offset / decay);
profile *= 1.0 - smoothstep(1.0, 1.4, u);
vec3 bandA = mix(CYAN, PINK, smoothstep(50.0, 75.0, f));
bandA = mix(bandA, LILAC, smoothstep(100.0, 125.0, f));
vec3 bandB = mix(SKY, PURPLE, smoothstep(50.0, 75.0, f));
vec3 fanK = mix(bandA, bandB, smoothstep(0.06, 0.18, offset));
fanK = mix(fanK, DEEP, smoothstep(0.22, 0.42, offset));
vec3 thread = WHITE * wall * bright + coreK * core * streak * bright;
glow += fanK * 4.5 * profile * fade * streak * bright;
glow += MIST * 0.7 * exp(-max(u - 1.0, 0.0) / 0.9) * smoothstep(0.7, 1.2, u) * fade * soft;
glow += EMBER * 0.35 * exp(-sq((f - 165.0) / 22.0)) * exp(-d / 25.0) * bright;
float lag = u * (6.0 + 0.28 * f) * 0.35;
float reveal = 1.0 - smoothstep(front - 70.0, front + 8.0, f + lag);
glow *= start * reveal;
return thread * start * reveal;
}
vec3 easeLight(vec3 light, float amount) {
return -log(max(1.0 - amount * (1.0 - exp(-light)), vec3(0.0001)));
}
void main() {
vec2 frag = vec2(gl_FragCoord.x, uResolution.y * uDpr - gl_FragCoord.y) / uDpr;
vec2 p = frag / uScale;
float t = uTime;
float bx = uBeamX / uScale;
float sl = uSurface.x / uScale;
float sy = uSurface.y / uScale;
float sr = uSurface.z / uScale;
float sb = uSurface.w / uScale;
float radius = uRadius / uScale;
float dx = p.x - bx;
float adx = abs(dx);
float h = sy - p.y;
float hp = max(h, 0.0);
float above = smoothstep(-0.5, 0.5, h);
float side = smoothstep(-24.0, 24.0, dx);
vec3 sideK = mix(mix(LAVENDER, LEFT, smoothstep(40.0, 140.0, adx)), RIGHT, side);
float beamWidth = uShape.x;
float flare = uShape.y;
float spread = 250.0 * uShape.z;
float reachL = max(bx - sl, 1.0);
float reachR = max(sr - bx, 1.0);
float reach = dx < 0.0 ? reachL : reachR;
float layerLen = clamp(0.29 * reach, 20.0, 100.0) * sqrt(uShape.z);
float run = uRun.x;
float runMask = 1.0 - smoothstep(run - 34.0, run + 6.0, adx);
float bead = exp(-sq((adx - run + 16.0) / (18.0 + 0.04 * run))) * uRun.y;
float flowMask = (1.0 - smoothstep(0.75 * spread, 1.1 * spread, adx)) * runMask;
float edgeMask = (1.0 - smoothstep(0.8 * spread, 1.25 * spread, adx)) * runMask;
float bloom = uIntro.y;
vec2 center = vec2((sl + sr) * 0.5, (sy + sb) * 0.5);
vec2 halfSize = vec2((sr - sl) * 0.5, (sb - sy) * 0.5);
float sd = roundBox(p - center, halfSize, radius);
float outline = max(sd, 0.0);
float below = max(p.y - sy - radius, 0.0);
float hug = smoothstep(-0.5, 0.5, sd) * exp(-sq(below / 14.0));
float hugWide = smoothstep(-0.5, 0.5, sd) * exp(-sq(below / 40.0));
vec3 col = vec3(0.0);
float breathe = 1.0 + uExtra.x * (0.12 * sin(t * 1.5708) + 0.08 * (noise(vec2(t * 0.5, 3.0)) - 0.5));
float front = uIntro.x / uScale;
float drop = smoothstep(front + 10.0, front - 110.0, p.y);
float falling = 1.0 - smoothstep(sy - 24.0, sy, front);
float top = mix(0.3, 1.0, smoothstep(0.0, 0.3 * sy, p.y));
float beamCore = 24.0 * exp(-adx / (2.0 * beamWidth));
float beamHalo = 1.3 * exp(-adx / (6.0 * beamWidth)) + 0.3 * exp(-adx / (22.0 * beamWidth));
vec3 haloK = mix(BEAM, sideK, exp(-hp / 80.0));
col += (BEAM * beamCore + haloK * beamHalo) * top * above * drop;
float head = exp(-sq((p.y - front) / 24.0)) * falling;
col += mix(WHITE, BEAM, 0.35) * head * (7.0 * exp(-adx / (2.6 * beamWidth)) + 1.0 * exp(-adx / (14.0 * beamWidth)));
float psi = adx * (outline + 1.0);
float rise = max(hp - 17.9 * flare, 0.0);
float skirtAmp = 16.6 * (exp(-sq(rise / (11.8 * flare))) + 0.3 * exp(-rise / (60.0 * flare)));
float skirtSpread = mix(134.0, 155.0, side) * breathe * flare * flare * mix(0.35, 1.0, min(bloom, 1.0)) * max(bloom, 1.0);
float skirt = skirtAmp * exp(-psi / skirtSpread) * exp(-sq(adx / (max(layerLen, 60.0 * sqrt(uShape.z)) * 1.48))) * hug;
col += sideK * skirt * flowMask * min(bloom, 1.0);
float layer = 12.0 * exp(-adx / layerLen) * exp(-outline / (0.19 * layerLen)) * (1.0 + 0.4 * bead);
col += sideK * layer * hug * flowMask * min(bloom, 1.0);
float washMask = smoothstep(sl - 60.0, sl + 6.0, p.x) * (1.0 - smoothstep(sr - 6.0, sr + 45.0, p.x));
float wash = 1.4 * exp(-adx / 100.0) * exp(-outline / 30.0);
col += mix(BEAM, sideK, 0.25) * wash * hugWide * washMask * flowMask * min(bloom, 1.0);
float highWash = 0.7 * exp(-adx / 140.0) * exp(-outline / 60.0);
col += mix(BEAM, NAVY, 0.5) * highWash * hugWide * washMask * runMask * min(bloom, 1.0);
float sideSign = dx < 0.0 ? 0.0 : 1.0;
float along = 0.5 * log((hp + 1.0) / (adx + 1.0));
float lineScale = 6.0;
float lp = log(max(psi, 1.0) / (120.0 * flare * flare)) * lineScale;
float lineIndex = floor(lp) + sideSign * 97.0;
float wobble = 0.22 * (noise(vec2(lineIndex * 3.7, t * 0.4 + along)) - 0.5);
float lf = fract(lp + wobble) - 0.5;
float gradient = lineScale * length(vec2(hp + 1.0, adx)) / max(psi, 1.0) / uScale;
float halfWidth = max(0.35 * gradient, 0.0001);
float drawWidth = max(halfWidth, 0.6 * gradient);
float lineShape = exp(-lf * lf / (drawWidth * drawWidth)) * (halfWidth / drawWidth);
float lineOn = step(0.3, hash(vec2(lineIndex, 1.3)));
float lineFlow = smoothstep(0.25, 0.85, noise(vec2(lineIndex * 5.3 + 2.0, along * 3.0 + t * 1.1)));
float flareArea = 120.0 * flare * flare;
float lineEnvelope = smoothstep(0.85 * flareArea, 1.4 * flareArea, psi) * (1.0 - smoothstep(3.2 * flareArea, 7.5 * flareArea, psi));
lineEnvelope *= exp(-adx / (layerLen * 1.5)) * hug * flowMask * (1.0 - smoothstep(35.0 * flare, 80.0 * flare, hp));
float lineBright = mix(0.35, 1.0, hash(vec2(lineIndex, 4.0)));
col += mix(sideK, WHITE, 0.6) * lineShape * lineOn * lineFlow * lineEnvelope * lineBright * 2.6 * uAmount.w * min(bloom, 1.0);
float edgeGlow = 22.8 * exp(-sq(adx / (150.0 * uShape.z))) * edgeMask * (1.0 + 0.9 * bead);
float onTop = 1.0 - smoothstep(sy + radius, sy + radius + 10.0, p.y);
float edgeLine = (exp(-sq(max(sd, 0.0) / 0.9)) + 0.12 * exp(-max(sd, 0.0) / 3.0)) * edgeGlow * onTop;
col += mix(sideK, WHITE, 0.6) * edgeLine * min(bloom, 1.0);
float pour = uShape.w;
float baseline = exp(-sq(130.0 / 150.0));
float strengthR = exp(-sq(reachR / (150.0 * uShape.z))) * (1.0 - smoothstep(0.8 * spread, 1.25 * spread, reachR)) / baseline * pour;
float strengthL = exp(-sq(reachL / (150.0 * uShape.z))) * (1.0 - smoothstep(0.8 * spread, 1.25 * spread, reachL)) / baseline * pour;
float fallY = p.y - sy - 0.3 * radius;
float rightHalf = smoothstep(center.x - 4.0, center.x + 4.0, p.x);
float haloR = exp(-length(p - vec2(sr - 0.3 * radius, sy + 0.3 * radius)) / 40.0) * sqrt(strengthR);
float haloL = exp(-length(p - vec2(sl + 0.3 * radius, sy + 0.3 * radius)) / 40.0) * sqrt(strengthL);
vec3 glowR;
vec3 glowL;
col += spill(sd, fallY, strengthR, uPour.y, t, glowR) * rightHalf;
col += spill(sd, fallY, strengthL, uPour.x, t, glowL) * (1.0 - rightHalf);
col += easeLight(glowR * rightHalf + NAVY * 0.9 * haloR, uPour.w);
col += easeLight(glowL * (1.0 - rightHalf) + NAVY * 0.9 * haloL, uPour.z);
vec2 toLens = frag - uLens.xy;
float lens = exp(-dot(toLens, toLens) / sq(uLens.z)) * uLens.w;
col *= 1.0 + 0.35 * lens * hug;
float fogAmount = uAmount.y * uAtmosphere;
float near = exp(-adx / 150.0);
float envelope = near * (0.3 + 0.4 * exp(-hp / 50.0) + 1.2 * exp(-p.y / 120.0));
if (fogAmount > 0.001 && h > -2.0 && envelope * max(0.25, 6.0 * exp(-adx / 50.0)) > 0.002) {
vec2 fq = vec2(dx / 1.7, h) / 85.0 + vec2(t * 0.012, -t * 0.03) + uExtra.zw;
vec2 fw = vec2(fbm(fq * 1.3 + vec2(1.7, 9.2) + t * 0.03), fbm(fq * 1.3 + vec2(8.3, 2.8) - t * 0.025));
float cloud = fbm(fq + 1.4 * fw);
float density = smoothstep(0.5, 0.78, cloud);
float lit = exp(-adx / 50.0);
float fogMask = smoothstep(-2.0, 20.0, h);
float reached = smoothstep(front + 80.0, front - 40.0, p.y);
vec3 fog = (HAZE * 0.45 + BEAM * 6.0 * lit * reached) * density * envelope * fogMask;
float surfaceLit = exp(-adx / (layerLen * 1.3)) * exp(-outline / 28.0) * hugWide * flowMask * washMask * min(bloom, 1.0);
fog += sideK * 2.2 * surfaceLit * (smoothstep(0.38, 0.72, cloud) - 0.35) * fogMask;
float smoke = smoothstep(0.3, 0.75, cloud);
fog += BEAM * 0.35 * exp(-adx / (22.0 * beamWidth)) * (0.35 + 0.9 * smoke) * fogMask * top * drop;
col += fog * fogAmount * (1.0 + 1.2 * lens) * mix(1.0, 0.45, uTheme);
}
if (uRevealInfo.y > 0.001 && uLens.w > 0.002) {
vec2 toReveal = frag - uLens.xy;
float reveal = exp(-dot(toReveal, toReveal) / sq(uRevealInfo.x)) * uLens.w;
vec2 uv = frag * uRevealMap.xy + uRevealMap.zw;
float onImage = step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0);
vec4 ui = texture(uReveal, uv);
float ink = max(ui.r, max(ui.g, ui.b)) * onImage;
float lit = clamp(col.b * 0.9, 0.0, 1.0) * (1.0 - smoothstep(0.9, 2.2, col.b));
float clearance = smoothstep(60.0 * sqrt(beamWidth), 150.0 * sqrt(beamWidth), adx);
col += mix(BEAM, WHITE, 0.4) * ink * reveal * lit * clearance * uRevealInfo.y * 2.2;
}
float haze = col.b;
float dotGrid = 1.0 - smoothstep(0.16, 0.32, length(fract(frag / 3.0) - 0.5));
col += BEAM * dotGrid * clamp(haze, 0.0, 1.5) * (1.0 - smoothstep(1.5, 4.0, haze)) * (0.18 + 0.3 * lens) * uExtra.y;
if (lens > 0.002) {
vec2 drift = frag - vec2(0.0, t * 14.0);
vec2 sparkCell = floor(drift / 7.0);
vec2 sparkPos = (sparkCell + 0.2 + 0.6 * vec2(hash(sparkCell + 5.1), hash(sparkCell + 2.3))) * 7.0;
float sparkDist = length(drift - sparkPos);
float sparkOn = step(0.8, hash(sparkCell + 8.8));
float sparkTwinkle = 0.5 + 0.5 * sin(t * 5.0 + hash(sparkCell + 3.7) * 6.283);
col += WHITE * exp(-sparkDist * sparkDist / 0.35) * sparkOn * sparkTwinkle * lens * clamp(haze * 1.5, 0.0, 1.0) * 1.6 * uAmount.z;
}
float dustAmount = uAmount.z;
if (dustAmount > 0.001) {
float dustLp = log(max(psi, 1.0) / (120.0 * flare * flare)) * 2.2;
vec2 flowCell = vec2(dustLp / 1.1, (along + t * 0.22) / 0.06);
vec2 cellId = floor(flowCell) + vec2(sideSign * 131.0, 0.0);
vec2 cellJitter = vec2(hash(cellId + 1.7), hash(cellId + 8.3));
vec2 particleFlow = (floor(flowCell) + 0.1 + cellJitter * 0.8) * vec2(1.1, 0.06);
float particlePsi = 120.0 * flare * flare * exp(particleFlow.x / 2.2);
float particleAlong = particleFlow.y - t * 0.22;
float particleX = sqrt(particlePsi * exp(-2.0 * particleAlong)) - 1.0;
float particleH = sqrt(particlePsi * exp(2.0 * particleAlong)) - 1.0;
float particleDist = length(vec2(adx - particleX, hp - particleH)) * uScale;
float particleOn = step(0.62, hash(cellId + 3.1));
float particleTwinkle = 0.5 + 0.5 * sin(t * 5.0 + hash(cellId + 9.2) * 6.283);
float dustZone = exp(-sq(log(max(psi, 1.0) / (200.0 * flare * flare)) / 0.55)) * hug * flowMask * smoothstep(0.0, 6.0, outline);
col += WHITE * exp(-particleDist * particleDist / 0.3) * particleOn * particleTwinkle * dustZone * 3.0 * dustAmount * min(bloom, 1.0);
vec2 glitterCell = vec2(dx / 3.0, (p.y - t * 26.0) / 9.0);
vec2 glitterId = floor(glitterCell);
vec2 glitterJitter = vec2(hash(glitterId + 4.4), hash(glitterId + 2.9));
vec2 glitterPos = (glitterId + 0.15 + glitterJitter * 0.7) * vec2(3.0, 9.0);
float glitterDist = length(vec2(dx, p.y - t * 26.0) - glitterPos) * uScale;
float glitterOn = step(0.7, hash(glitterId + 6.6));
float glitterTwinkle = 0.5 + 0.5 * sin(t * 6.0 + hash(glitterId + 1.1) * 6.283);
float glitterZone = exp(-adx / (5.0 * beamWidth)) * smoothstep(1.5, 4.0, adx / beamWidth) * above * smoothstep(25.0, 70.0, hp) * top * drop;
col += WHITE * exp(-glitterDist * glitterDist / 0.25) * glitterOn * glitterTwinkle * glitterZone * 2.2 * dustAmount * uAtmosphere;
}
col *= uAmount.x * (1.0 + uIntro.z * 0.9 * exp(-length(vec2(dx, h * 1.6)) / 70.0));
col *= smoothstep(-0.75, 0.25, sd * uScale);
vec3 emit = 1.0 - exp(-col * mix(1.0, 0.6, uTheme));
float peak = max(emit.r, max(emit.g, emit.b));
if (uTheme > 0.5) {
float low = min(emit.r, min(emit.g, emit.b));
float chroma = peak - low;
vec3 hue = (emit - low) / max(chroma, 0.0001);
float alpha = clamp(pow(chroma, 1.35) * 1.3, 0.0, 1.0) * 0.9;
outColor = vec4((hue * 0.82 + 0.06) * alpha, alpha);
} else {
outColor = vec4(emit, peak);
}
}`;
const TINTS = [
[0, 1],
[-11.5, 0.77],
[9.5, 0.95],
[-53.5, 0.79],
[39.5, 0.77],
[16, 0.81],
[-16.3, 0.92],
[13.3, 0.96],
[3, 1.01],
[1, 0.68],
[1, 1.15],
[142.5, 0.51],
[113.6, 0.28],
[-7.5, 0.13],
[-2.7, 0.32],
[7.5, 0.81]
];
const BASE_SATURATION = 0.78;
const MAX_RENDER_DIM = 2048;
const INTRO_DROP = 1.25;
const INTRO_END = 5;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const easeOut = (x: number) => 1 - Math.pow(1 - clamp(x, 0, 1), 3);
const spring = (time: number) => {
const omega = 8;
const zeta = 0.62;
const damped = omega * Math.sqrt(1 - zeta * zeta);
return (
1 -
Math.exp(-zeta * omega * time) *
(Math.cos(damped * time) + ((zeta * omega) / damped) * Math.sin(damped * time))
);
};
const toHsv = ([r, g, b]: number[]): [number, number] => {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let hue = 0;
if (delta > 0) {
if (max === r) hue = ((g - b) / delta) % 6;
else if (max === g) hue = (b - r) / delta + 2;
else hue = (r - g) / delta + 4;
}
return [(hue * 60 + 360) % 360, max > 0 ? delta / max : 0];
};
const fromHsv = (hue: number, saturation: number): number[] => {
const h = (((hue % 360) + 360) % 360) / 60;
const c = saturation;
const x = c * (1 - Math.abs((h % 2) - 1));
const m = 1 - c;
const [r, g, b] =
h < 1
? [c, x, 0]
: h < 2
? [x, c, 0]
: h < 3
? [0, c, x]
: h < 4
? [0, x, c]
: h < 5
? [x, 0, c]
: [c, 0, x];
return [r + m, g + m, b + m];
};
const buildTints = (rgb: number[]) => {
const [hue, saturation] = toHsv(rgb);
const strength = saturation / BASE_SATURATION;
const data = new Float32Array(TINTS.length * 3);
TINTS.forEach(([shift, scale], index) => {
data.set(fromHsv(hue + shift, clamp(BASE_SATURATION * scale * strength, 0, 1)), index * 3);
});
return data;
};
const compile = (gl: WebGL2RenderingContext, type: number, source: string) => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
gl.deleteShader(shader);
return null;
};
const link = (gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string) => {
const vertex = compile(gl, gl.VERTEX_SHADER, vertexSource);
const fragment = compile(gl, gl.FRAGMENT_SHADER, fragmentSource);
const program = gl.createProgram();
if (!vertex || !fragment || !program) return null;
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
gl.deleteProgram(program);
return null;
};
const locate = (gl: WebGL2RenderingContext, program: WebGLProgram, names: string[]) => {
const result: Record<string, WebGLUniformLocation | null> = {};
for (const name of names) result[name] = gl.getUniformLocation(program, name);
return result;
};
export type { Settings as LaserFlowSettings };
</script>
<script lang="ts">
import { onMount } from 'svelte';
let {
surface = null,
beamPosition = 0.6,
surfaceLevel = 1,
color = '#3847ff',
intensity = 1.8,
beamWidth = 1.5,
flare = 1.5,
spread = 1.5,
spill = 1,
fog = 0.2,
dust = 1.6,
streaks = 1.5,
dots = 1,
pulse = 1.5,
speed = 1.5,
seed = 3,
mouseInteraction = true,
revealImage,
revealRadius = 220,
revealOpacity = 0.6,
intro = true,
theme = 'dark',
paused = false,
dpr,
class: className = '',
...rest
}: LaserFlowProps & { class?: string } = $props();
let container = $state<HTMLDivElement | null>(null);
let canvas = $state<HTMLCanvasElement | null>(null);
let wake: (() => void) | null = null;
const settings = $derived<Settings>({
surface,
beamPosition: clamp(beamPosition, 0, 1),
surfaceLevel: clamp(surfaceLevel, 0, 1),
color,
intensity: Math.max(0, intensity),
beamWidth: clamp(beamWidth, 0.2, 5),
flare: clamp(flare, 0.2, 4),
spread: clamp(spread, 0.1, 4),
spill: Math.max(0, spill),
fog: Math.max(0, fog),
dust: Math.max(0, dust),
streaks: Math.max(0, streaks),
dots: Math.max(0, dots),
pulse: Math.max(0, pulse),
speed,
seed,
mouseInteraction,
revealImage,
revealRadius: Math.max(1, revealRadius),
revealOpacity: clamp(revealOpacity, 0, 1),
intro,
theme,
paused,
dpr
});
// Актуальные настройки, доступные рендер-циклу без пересоздания WebGL-контекста.
const settingsRef: { current: Settings | null } = { current: null };
$effect.pre(() => {
settingsRef.current = settings;
});
// Любое изменение пропсов будит рендер-цикл.
$effect(() => {
settingsRef.current = settings;
wake?.();
});
onMount(() => {
const containerEl = container;
const canvasEl = canvas;
const gl = canvasEl?.getContext('webgl2', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
depth: false,
stencil: false
});
if (!containerEl || !canvasEl || !gl) return;
const program = link(gl, VERTEX, FRAGMENT);
if (!program) return;
const uniforms = locate(gl, program, [
'uResolution',
'uDpr',
'uScale',
'uTime',
'uBeamX',
'uSurface',
'uRadius',
'uIntro',
'uPour',
'uRun',
'uLens',
'uReveal',
'uRevealMap',
'uRevealInfo',
'uAtmosphere',
'uAmount',
'uShape',
'uExtra',
'uTheme',
'uTint'
]);
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const positionLocation = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
const revealTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, revealTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0])
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const probe = document.createElement('canvas');
probe.width = 1;
probe.height = 1;
const probeContext = probe.getContext('2d', { willReadFrequently: true });
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const state = {
time: 0,
introTime: settingsRef.current?.intro && !reduce ? 0 : INTRO_END,
width: 1,
height: 1,
ratio: 1,
colorKey: '',
tints: buildTints([0.22, 0.28, 1]),
observed: null as HTMLElement | null,
pointer: { x: 0, y: 0, inside: false },
lens: { x: 0, y: 0, vx: 0, vy: 0, strength: 0, placed: false },
reveal: {
src: '',
ready: false,
width: 1,
height: 1,
image: null as HTMLImageElement | null
}
};
let raf = 0;
let last = 0;
let visible = true;
let alive = true;
const toRgb = (value: string): number[] => {
if (!probeContext) return [0.22, 0.28, 1];
probeContext.clearRect(0, 0, 1, 1);
probeContext.fillStyle = '#3847ff';
probeContext.fillStyle = value;
probeContext.fillRect(0, 0, 1, 1);
const [r, g, b] = probeContext.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255];
};
const resolveColor = (s: Settings) => {
if (s.color === state.colorKey) return;
state.colorKey = s.color;
state.tints = buildTints(toRgb(s.color));
};
const resolveReveal = (s: Settings) => {
const src = s.revealImage ?? '';
const reveal = state.reveal;
if (src === reveal.src) return;
reveal.src = src;
reveal.ready = false;
if (reveal.image) reveal.image.onload = null;
if (!src) return;
const image = new Image();
image.crossOrigin = 'anonymous';
image.decoding = 'async';
image.onload = () => {
if (!alive || reveal.src !== src) return;
const width = image.naturalWidth || image.width || 1;
const height = image.naturalHeight || image.height || 1;
const scale = Math.min(1, 2048 / Math.max(width, height));
const sheet = document.createElement('canvas');
sheet.width = Math.max(1, Math.round(width * scale));
sheet.height = Math.max(1, Math.round(height * scale));
sheet.getContext('2d')?.drawImage(image, 0, 0, sheet.width, sheet.height);
gl.bindTexture(gl.TEXTURE_2D, revealTexture);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, sheet);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
reveal.width = sheet.width;
reveal.height = sheet.height;
reveal.ready = true;
wake?.();
};
image.src = src;
reveal.image = image;
};
const measure = (s: Settings, width: number, height: number): Surface => {
const element = s.surface ?? null;
if (element !== state.observed) {
if (state.observed) resizeObserver.unobserve(state.observed);
if (element) resizeObserver.observe(element);
state.observed = element;
}
if (element) {
const box = containerEl.getBoundingClientRect();
const rect = element.getBoundingClientRect();
if (rect.width > 0) {
const computed = getComputedStyle(element);
const radius = Math.max(
parseFloat(computed.borderTopLeftRadius) || 0,
parseFloat(computed.borderTopRightRadius) || 0
);
return {
left: rect.left - box.left,
top: rect.top - box.top,
right: rect.right - box.left,
bottom: rect.bottom - box.top,
radius: Math.min(radius, rect.width / 2, rect.height / 2),
real: true
};
}
}
return {
left: -width * 2,
top: s.surfaceLevel * height,
right: width * 3,
bottom: height * 3,
radius: 0,
real: false
};
};
const resize = () => {
const width = Math.max(1, containerEl.clientWidth);
const height = Math.max(1, containerEl.clientHeight);
const s = settingsRef.current;
const baseDpr = Math.min(s?.dpr || window.devicePixelRatio || 1, 2);
const longest = Math.max(width, height) * baseDpr;
const ratio = longest > MAX_RENDER_DIM ? (baseDpr * MAX_RENDER_DIM) / longest : baseDpr;
const pixelWidth = Math.max(1, Math.round(width * ratio));
const pixelHeight = Math.max(1, Math.round(height * ratio));
if (canvasEl.width !== pixelWidth || canvasEl.height !== pixelHeight) {
canvasEl.width = pixelWidth;
canvasEl.height = pixelHeight;
}
state.width = width;
state.height = height;
state.ratio = canvasEl.width / width;
render();
};
const render = () => {
const s = settingsRef.current;
if (!s) return;
resolveColor(s);
const { width, height } = state;
const scale = Math.max(0.2, Math.min(width / 585, height / 507));
let surfaceBox = measure(s, width, height);
let beamX = s.beamPosition * width;
if (surfaceBox.real) {
const margin = surfaceBox.radius + 6;
if (beamX >= surfaceBox.left && beamX <= surfaceBox.right) {
beamX = clamp(beamX, surfaceBox.left + margin, surfaceBox.right - margin);
} else {
surfaceBox = {
left: -width * 2,
top: height,
right: width * 3,
bottom: height * 3,
radius: 0,
real: false
};
}
}
const startY = -0.15 * height;
const span = surfaceBox.top - startY + 0.3 * height;
const it = state.introTime;
const settled = it >= INTRO_END;
const front = settled
? surfaceBox.top + 0.3 * height
: startY + span * Math.pow(clamp(it / INTRO_DROP, 0, 1), 1.3);
const impact = INTRO_DROP * Math.pow(Math.max(0, surfaceBox.top - startY) / span, 1 / 1.3);
const tau = it - impact;
const bloom = settled ? 1 : tau <= 0 ? 0 : spring(tau);
const flash = settled || tau <= 0 ? 0 : Math.exp(-tau / 0.35) * (1 - Math.exp(-tau / 0.05));
const runRange = s.spread * 250 * 1.3 + 80;
const runFront = settled ? 100000 : tau <= 0 ? -40 : runRange * (1 - Math.exp(-1.15 * tau));
const beadStrength =
settled || tau <= 0 ? 0 : Math.exp(-1.15 * tau) * (1 - Math.exp(-tau / 0.08));
const pour = (reach: number): [number, number] => {
if (settled) return [1000, 1];
if (tau <= 0) return [-reach, 0];
const target = Math.min(reach, runRange * 0.97);
const arrival = -Math.log(1 - target / runRange) / 1.15;
const elapsed = tau - arrival;
const ease = clamp((elapsed + 0.15) / 0.8, 0, 1);
const fill = ease * ease * ease * (ease * (ease * 6 - 15) + 10);
if (elapsed <= 0) return [runFront - target, fill];
const pourSpeed = 1.15 * (runRange - target);
return [pourSpeed * elapsed + 260 * elapsed * elapsed, fill];
};
const [frontL, fillL] = pour((beamX - surfaceBox.left) / scale);
const [frontR, fillR] = pour((surfaceBox.right - beamX) / scale);
const atmosphere = settled
? 1
: tau <= 0
? 0.3 * easeOut(it / INTRO_DROP)
: 0.3 + 0.7 * (1 - Math.exp(-1.3 * tau));
const seedValue = s.seed || 0;
gl.viewport(0, 0, canvasEl.width, canvasEl.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.uniform2f(uniforms.uResolution, width, height);
gl.uniform1f(uniforms.uDpr, state.ratio);
gl.uniform1f(uniforms.uScale, scale);
gl.uniform1f(uniforms.uTime, state.time);
gl.uniform1f(uniforms.uBeamX, beamX);
gl.uniform4f(
uniforms.uSurface,
surfaceBox.left,
surfaceBox.top,
surfaceBox.right,
surfaceBox.bottom
);
gl.uniform1f(uniforms.uRadius, surfaceBox.radius);
gl.uniform3f(uniforms.uIntro, front, bloom, flash);
gl.uniform4f(uniforms.uPour, frontL, frontR, fillL, fillR);
gl.uniform2f(uniforms.uRun, runFront, beadStrength);
gl.uniform4f(uniforms.uLens, state.lens.x, state.lens.y, 95 * scale, state.lens.strength);
resolveReveal(s);
const { reveal } = state;
const cover = Math.max(width / reveal.width, height / reveal.height);
const coverWidth = reveal.width * cover;
const coverHeight = reveal.height * cover;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, revealTexture);
gl.uniform1i(uniforms.uReveal, 0);
gl.uniform4f(
uniforms.uRevealMap,
1 / coverWidth,
1 / coverHeight,
(coverWidth - width) / 2 / coverWidth,
(coverHeight - height) / 2 / coverHeight
);
gl.uniform2f(uniforms.uRevealInfo, s.revealRadius, reveal.ready ? s.revealOpacity : 0);
gl.uniform1f(uniforms.uAtmosphere, atmosphere);
gl.uniform4f(uniforms.uAmount, s.intensity, s.fog, s.dust, s.streaks);
gl.uniform4f(uniforms.uShape, s.beamWidth, s.flare, s.spread, s.spill);
gl.uniform4f(
uniforms.uExtra,
s.pulse,
s.dots,
34.34 + seedValue * 7.31,
11.58 + seedValue * 3.17
);
gl.uniform1f(uniforms.uTheme, s.theme === 'light' ? 1 : 0);
gl.uniform3fv(uniforms.uTint, state.tints);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
const frame = (now: number) => {
raf = 0;
if (!alive || !visible || document.hidden) return;
const s = settingsRef.current;
if (!s) return;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
last = now;
const moving = !s.paused && !reduce;
if (moving) state.time += dt * s.speed;
const introducing = state.introTime < INTRO_END;
if (introducing) state.introTime = Math.min(INTRO_END, state.introTime + dt);
const { pointer, lens } = state;
const active = s.mouseInteraction && pointer.inside && !reduce;
if (active && !lens.placed) {
lens.x = pointer.x;
lens.y = pointer.y;
lens.vx = 0;
lens.vy = 0;
lens.placed = true;
}
const ax = 120 * (pointer.x - lens.x) - 19 * lens.vx;
const ay = 120 * (pointer.y - lens.y) - 19 * lens.vy;
lens.vx += ax * dt;
lens.vy += ay * dt;
lens.x += lens.vx * dt;
lens.y += lens.vy * dt;
const goal = active ? 1 : 0;
lens.strength +=
(goal - lens.strength) * (1 - Math.exp(-dt / (goal > lens.strength ? 0.3 : 0.55)));
if (lens.strength < 0.001 && !active) lens.placed = false;
render();
const settling =
Math.abs(state.lens.strength - (s.mouseInteraction && state.pointer.inside ? 1 : 0)) >
0.002 || Math.hypot(state.lens.vx, state.lens.vy) > 0.5;
if (moving || introducing || settling) raf = requestAnimationFrame(frame);
else last = 0;
};
const wakeLoop = () => {
if (!raf && alive && visible && !document.hidden) raf = requestAnimationFrame(frame);
};
const onPointerMove = (event: PointerEvent) => {
const rect = containerEl.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
state.pointer.x = x;
state.pointer.y = y;
state.pointer.inside = x >= 0 && y >= 0 && x <= rect.width && y <= rect.height;
wakeLoop();
};
const onPointerLeave = () => {
state.pointer.inside = false;
wakeLoop();
};
const onVisibility = () => {
last = 0;
wakeLoop();
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(containerEl);
const intersection = new IntersectionObserver((entries) => {
visible = entries.some((entry) => entry.isIntersecting);
if (visible) {
last = 0;
wakeLoop();
}
});
intersection.observe(containerEl);
window.addEventListener('pointermove', onPointerMove, { passive: true });
document.documentElement.addEventListener('pointerleave', onPointerLeave);
document.addEventListener('visibilitychange', onVisibility);
resize();
wakeLoop();
wake = () => {
if (!raf) render();
wakeLoop();
};
return () => {
alive = false;
cancelAnimationFrame(raf);
wake = null;
resizeObserver.disconnect();
intersection.disconnect();
window.removeEventListener('pointermove', onPointerMove);
document.documentElement.removeEventListener('pointerleave', onPointerLeave);
document.removeEventListener('visibilitychange', onVisibility);
gl.deleteTexture(revealTexture);
gl.deleteBuffer(buffer);
gl.deleteVertexArray(vao);
gl.deleteProgram(program);
};
});
</script>
<div
bind:this={container}
class="pointer-events-none relative h-full w-full {className}"
{...rest}
>
<canvas bind:this={canvas} class="absolute inset-0 block h-full w-full" aria-hidden="true"></canvas>
</div>Использование
Базовый пример
Без surface луч падает на виртуальную линию на уровне surfaceLevel (доля высоты контейнера).
<script>
import LaserFlow from '$lib/components/ui-registry/LaserFlow.svelte';
</script>
<div class="h-[420px] overflow-hidden rounded-lg">
<LaserFlow color="#f43f5e" surfaceLevel={0.6} />
</div><script>
import LaserFlow from '$lib/components/ui-registry/LaserFlow.svelte';
</script>
<div class="h-[420px] overflow-hidden rounded-lg">
<LaserFlow color="#f43f5e" surfaceLevel={0.6} />
</div>Компонент занимает всю ширину и высоту родителя, поэтому задайте контейнеру высоту.
Луч на карточку
Передайте элемент через bind:this — компонент сам измерит его положение, размер и скругление углов и будет следить за изменениями через ResizeObserver.
<script>
import LaserFlow from '$lib/components/ui-registry/LaserFlow.svelte';
let card = $state(null);
</script>
<div class="relative h-[460px] overflow-hidden rounded-lg">
<LaserFlow surface={card} color="#f43f5e" beamPosition={0.5} />
<div
bind:this={card}
class="absolute inset-x-8 top-[55%] bottom-8 rounded-2xl border border-border bg-background-inset p-6"
>
Контент карточки
</div>
</div><script>
import LaserFlow from '$lib/components/ui-registry/LaserFlow.svelte';
let card = $state(null);
</script>
<div class="relative h-[460px] overflow-hidden rounded-lg">
<LaserFlow surface={card} color="#f43f5e" beamPosition={0.5} />
<div
bind:this={card}
class="absolute inset-x-8 top-[55%] bottom-8 rounded-2xl border border-border bg-background-inset p-6"
>
Контент карточки
</div>
</div>Если beamPosition выходит за границы элемента, луч падает на виртуальную линию, а внутри элемента позиция ограничивается так, чтобы не попасть на скруглённый угол.
Светлая тема
<LaserFlow theme="light" color="#f43f5e" intensity={1.4} /><LaserFlow theme="light" color="#f43f5e" intensity={1.4} />Изображение под курсором
revealImage проявляется внутри света, около курсора. Картинка растягивается по принципу cover, а проявляется только там, где уже есть свечение.
<LaserFlow
surface={card}
revealImage="/ui-screenshot.png"
revealRadius={220}
revealOpacity={0.6}
/><LaserFlow
surface={card}
revealImage="/ui-screenshot.png"
revealRadius={220}
revealOpacity={0.6}
/>Без интро и с паузой
<LaserFlow intro={false} paused={isHidden} /><LaserFlow intro={false} paused={isHidden} />Особенности
- Требуется WebGL2, внешние библиотеки (например,
three) не нужны - Интро-анимация: луч падает, свет «вспыхивает» на поверхности и растекается
- Реагирует на курсор через пружинную линзу, которая усиливает свет, туман и искры рядом с указателем
- Любой цвет в CSS-формате: палитра лучей, свечений и тумана строится от одного
color - Тёмная и светлая темы
- Останавливает отрисовку вне viewport и на скрытой вкладке
- Учитывает
prefers-reduced-motion: интро пропускается, анимация и линза отключаются - Ограничивает
dprдо2и размер canvas до 2048 px по большей стороне - Корректно освобождает WebGL-ресурсы, observers и animation frame при размонтировании
Пропсы
Положение
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
surface | HTMLElement null | null | Элемент, на который падает луч. Передаётся через bind:this |
beamPosition | number | 0.6 | Горизонтальная позиция луча (0–1 от ширины) |
surfaceLevel | number | 1 | Уровень виртуальной поверхности (0–1 от высоты), если surface не задан |
Внешний вид
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
color | string | '#3847ff' | Основной цвет света (любой CSS-цвет) |
theme | 'dark' 'light' | 'dark' | Режим смешивания: для тёмного или светлого фона |
intensity | number | 1.8 | Общая яркость |
beamWidth | number | 1.5 | Толщина луча (0.2–5) |
flare | number | 1.5 | Размер вспышки у поверхности (0.2–4) |
spread | number | 1.5 | Дальность растекания света по поверхности (0.1–4) |
spill | number | 1 | Сила перелива света через боковые края |
Атмосфера
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
fog | number | 0.2 | Плотность тумана вокруг луча |
dust | number | 1.6 | Количество мерцающих пылинок и искр |
streaks | number | 1.5 | Яркость световых нитей на поверхности |
dots | number | 1 | Сила сетки точек в свечении |
pulse | number | 1.5 | Амплитуда «дыхания» света |
seed | number | 3 | Зерно шума: меняет рисунок тумана |
Анимация и взаимодействие
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
speed | number | 1.5 | Множитель скорости анимации |
intro | boolean | true | Проигрывать вступительное падение луча |
paused | boolean | false | Остановить анимацию |
mouseInteraction | boolean | true | Включить линзу, следующую за курсором |
Проявление изображения
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
revealImage | string | — | URL изображения, проявляющегося под курсором |
revealRadius | number | 220 | Радиус проявления (px) |
revealOpacity | number | 0.6 | Яркость проявленного изображения (0–1) |
Прочее
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
dpr | number | window.devicePixelRatio (до 2) | Плотность пикселей canvas |
class | string | '' | Дополнительные CSS-классы контейнера |
Остальные атрибуты (style, id, data-*, aria-*) передаются на корневой div.
Доступность
- Canvas помечен
aria-hidden, контейнер не перехватывает события мыши (pointer-events: none) - Для читаемого текста располагайте контент поверх эффекта с
relative z-10 - При
prefers-reduced-motionдвижение и интро отключаются, остаётся статичный кадр
