← каталог
Wave Marquee
Три волновые WebGL-ленты (волна · дуга · скрутка) на скролл-странице: увлекаются скроллом, панель Tweakpane с табами и полным набором настроек.
oglscrollautoparallaxadvanced~42 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
Установка
npx shadcn@latest add https://motiva.pages.dev/r/wave-marquee.jsonзависимости: ogl@^1.0.0 · tweakpane@^4.0.0
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
import { Camera, Mesh, Plane, Program, Renderer, Texture } from 'ogl';
import type { OGLRenderingContext } from 'ogl';
export interface MarqueeSettings {
/** Текст бегущей строки (тайлится бесшовно) */
text: string;
fontFamily: string;
fontWeight: number;
/** Межбуквенный интервал, em */
letterSpacing: number;
/** Кегль на экране, px */
fontSize: number;
color: string;
/** Скорость автопрокрутки, px/s */
speed: number;
/** 1 — влево, -1 — вправо */
direction: 1 | -1;
/** Высота волны, px */
amplitude: number;
/** Длина волны, px */
wavelength: number;
/** Скорость движения волны, rad/s */
waveSpeed: number;
/** Вертикальное сжатие букв на волне, 0..1 */
squish: number;
/** Сдвиг фазы сжатия относительно волны, rad */
squishPhase: number;
/** Горизонтальное уплотнение букв, px */
bunch: number;
/** Скрутка ленты вокруг горизонтальной оси, rad (0 — выкл) */
twist: number;
/** Сила фейковой перспективы при скрутке */
perspective: number;
/** Реагировать ли на скролл страницы (выкл — чистый автоплей) */
scrollReact: boolean;
/** Насколько скролл тянет строку за собой */
scrollDrag: number;
/** Прирост амплитуды от скорости скролла */
scrollAmpBoost: number;
paused: boolean;
}
export const defaultSettings: MarqueeSettings = {
text: 'WAVE DEPTH FLOW LIGHT MOTION',
fontFamily: 'Oswald, "Arial Narrow", sans-serif',
fontWeight: 700,
letterSpacing: 0.04,
fontSize: 120,
color: '#e8112d',
speed: 110,
direction: 1,
amplitude: 46,
wavelength: 1100,
waveSpeed: 1.4,
squish: 0.35,
squishPhase: Math.PI,
bunch: 42,
twist: 0,
perspective: 1,
scrollReact: true,
scrollDrag: 0.55,
scrollAmpBoost: 1.2,
paused: false,
};
/** Какой части системы касается изменение настроек */
export type ChangeKind = 'motion' | 'layout' | 'text';
const VERTEX = /* glsl */ `
attribute vec3 position;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform float uWavePhase;
uniform float uAmplitude;
uniform float uWavelength;
uniform float uSquish;
uniform float uSquishPhase;
uniform float uBunch;
uniform float uTwist;
uniform float uPersp;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 pos = position;
float k = 6.2831853 / max(uWavelength, 1.0);
float ph = pos.x * k + uWavePhase;
float squish = 1.0 - uSquish * (0.5 + 0.5 * sin(ph + uSquishPhase));
float cy = pos.y * squish;
// «3D-скрутка»: колонка поворачивается вокруг центральной оси ленты,
// глубина z даёт фейковую перспективу (верх/низ масштабируются несимметрично)
float ang = uTwist * sin(ph);
float z = cy * sin(ang);
float persp = 1.0 / (1.0 + z * uPersp * 0.003);
pos.x += cos(ph) * uBunch;
pos.y = cy * cos(ang) * persp + sin(ph) * uAmplitude;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;
const FRAGMENT = /* glsl */ `
precision highp float;
uniform sampler2D tMap;
uniform float uScroll;
uniform float uRepeat;
varying vec2 vUv;
void main() {
gl_FragColor = texture2D(tMap, vec2(vUv.x * uRepeat + uScroll, vUv.y));
}
`;
const TWO_PI = Math.PI * 2;
/** Запас ширины плоскости: uBunch сдвигает вершины по x, без запаса у краёв появились бы дыры */
const PLANE_PAD = 260;
const MAX_DPR = 2;
/** Кегль в текстурном пространстве — базовое разрешение растеризации текста */
const TEX_FONT = 256;
const SEGMENTS_X = 240;
/** Высота строки текстуры относительно кегля (запас под выносные элементы и Й) */
const LINE_H = 1.42;
const nextPow2 = (v: number) => 2 ** Math.ceil(Math.log2(Math.max(2, v)));
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
interface Uniforms {
tMap: { value: Texture };
uWavePhase: { value: number };
uAmplitude: { value: number };
uWavelength: { value: number };
uSquish: { value: number };
uSquishPhase: { value: number };
uBunch: { value: number };
uTwist: { value: number };
uPersp: { value: number };
uScroll: { value: number };
uRepeat: { value: number };
}
export class WaveMarquee {
/** Колбэк каждого кадра со сглаженным FPS (для монитора в панели) */
onFrame?: (fps: number) => void;
private readonly container: HTMLElement;
private readonly settings: MarqueeSettings;
private readonly staticMode: boolean;
private canvas: HTMLCanvasElement;
private renderer!: Renderer;
private gl!: OGLRenderingContext;
private camera!: Camera;
private program!: Program;
private texture!: Texture;
private mesh: Mesh | null = null;
private uniforms!: Uniforms;
private texCanvas = document.createElement('canvas');
private texCtx: CanvasRenderingContext2D;
/** Логические (до pow2-растяжения) размеры тайла в текстурном пространстве */
private texFontPx = TEX_FONT;
private texTileW = 1;
private texH = 1;
/** Размеры в экранных px */
private tileDisplayW = 1;
private planeH = 1;
private planeW = 0;
private curPlaneH = 0;
private viewW = 0;
private raf = 0;
private lastT = 0;
private wavePhase = 0;
/** Прокрутка в долях тайла, всегда [0..1) */
private offset = 0;
private lastScrollY = 0;
private smoothVel = 0;
private ampBoost = 0;
private fpsEma = 60;
private visible = true;
private needsRender = true;
private contextLost = false;
private textTimer: ReturnType<typeof setTimeout> | undefined;
private readonly ro: ResizeObserver;
private readonly io: IntersectionObserver;
private readonly onVisibility = () => this.updateRunning();
constructor(container: HTMLElement, settings: MarqueeSettings, opts: { static?: boolean } = {}) {
this.container = container;
this.settings = settings;
this.staticMode = !!opts.static;
const ctx = this.texCanvas.getContext('2d');
if (!ctx) throw new Error('WaveMarquee: 2d context unavailable');
this.texCtx = ctx;
this.canvas = document.createElement('canvas');
this.canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
this.contextLost = true;
this.stopLoop();
});
this.canvas.addEventListener('webglcontextrestored', () => {
this.contextLost = false;
this.initGL();
this.buildTexture();
this.resize();
this.updateRunning();
});
this.initGL();
this.syncHeight();
this.buildTexture();
this.resize();
this.ro = new ResizeObserver(() => this.resize());
this.ro.observe(container);
this.io = new IntersectionObserver(([entry]) => {
if (!entry) return;
this.visible = entry.isIntersecting;
this.updateRunning();
});
this.io.observe(container);
document.addEventListener('visibilitychange', this.onVisibility);
// Первая отрисовка идёт fallback-шрифтом; когда вебшрифт догрузится — перерисовать текстуру
document.fonts?.ready.then(() => this.scheduleTextRebuild());
this.lastScrollY = window.scrollY;
if (this.staticMode) {
// Стоп-кадр для скриншотов и prefers-reduced-motion
this.wavePhase = 2.2;
this.offset = 0.12;
this.renderFrame();
} else {
this.updateRunning();
}
}
/**
* Сообщить компоненту, что настройки изменились.
* 'motion' — только uniform'ы, 'layout' — высота контейнера, 'text' — перерисовка текстуры.
*/
onSettingsChanged(kind: ChangeKind): void {
if (kind === 'text') this.scheduleTextRebuild();
if (kind === 'layout') this.syncHeight();
this.needsRender = true;
if (this.staticMode && kind !== 'text') this.renderFrame();
}
destroy(): void {
this.stopLoop();
if (this.textTimer) clearTimeout(this.textTimer);
this.ro.disconnect();
this.io.disconnect();
document.removeEventListener('visibilitychange', this.onVisibility);
this.disposeGL();
this.gl.getExtension('WEBGL_lose_context')?.loseContext();
this.canvas.remove();
}
// ---- GL ----
private initGL(): void {
this.renderer = new Renderer({
canvas: this.canvas,
dpr: clamp(window.devicePixelRatio || 1, 1, MAX_DPR),
alpha: true,
antialias: false, // сглаживание даёт текстура + linear filtering, MSAA не нужен
premultipliedAlpha: true,
powerPreference: 'high-performance',
});
this.gl = this.renderer.gl;
this.gl.clearColor(0, 0, 0, 0);
if (!this.canvas.parentNode) this.container.appendChild(this.canvas);
this.camera = new Camera(this.gl);
this.texture = new Texture(this.gl, {
generateMipmaps: true,
premultiplyAlpha: true,
wrapS: this.gl.REPEAT,
wrapT: this.gl.CLAMP_TO_EDGE,
anisotropy: 8,
minFilter: this.gl.LINEAR_MIPMAP_LINEAR,
magFilter: this.gl.LINEAR,
flipY: true,
});
const s = this.settings;
this.uniforms = {
tMap: { value: this.texture },
uWavePhase: { value: 0 },
uAmplitude: { value: s.amplitude },
uWavelength: { value: s.wavelength },
uSquish: { value: s.squish },
uSquishPhase: { value: s.squishPhase },
uBunch: { value: s.bunch },
uTwist: { value: s.twist },
uPersp: { value: s.perspective },
uScroll: { value: 0 },
uRepeat: { value: 1 },
};
this.program = new Program(this.gl, {
vertex: VERTEX,
fragment: FRAGMENT,
uniforms: this.uniforms,
transparent: true,
depthTest: false,
depthWrite: false,
});
// Текстура premultiplied — обычный SRC_ALPHA дал бы тёмную кайму на буквах
this.program.setBlendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
this.mesh = null;
this.planeW = 0;
this.curPlaneH = 0;
}
private disposeGL(): void {
this.mesh?.geometry.remove();
this.program.remove();
this.gl.deleteTexture(this.texture.texture);
}
// ---- Текстура ----
private scheduleTextRebuild(): void {
if (this.textTimer) clearTimeout(this.textTimer);
this.textTimer = setTimeout(async () => {
const s = this.settings;
try {
await document.fonts.load(`${s.fontWeight} 100px ${s.fontFamily}`);
} catch {
/* неизвестный шрифт — рисуем чем есть */
}
this.buildTexture();
this.syncHeight();
this.needsRender = true;
if (this.staticMode) this.renderFrame();
}, 120);
}
private buildTexture(): void {
const s = this.settings;
const ctx = this.texCtx;
const maxTex = Math.min((this.gl.getParameter(this.gl.MAX_TEXTURE_SIZE) as number) || 4096, 8192);
const text = s.text.toUpperCase().replace(/\s+/g, ' ').trim() + ' ';
const applyFont = (px: number) => {
ctx.font = `${s.fontWeight} ${px}px ${s.fontFamily}`;
(ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing =
`${(s.letterSpacing * px).toFixed(1)}px`;
};
let fontPx = TEX_FONT;
applyFont(fontPx);
let tileW = ctx.measureText(text).width;
if (tileW > maxTex) {
fontPx = Math.max(32, Math.floor(fontPx * (maxTex / tileW)));
applyFont(fontPx);
tileW = ctx.measureText(text).width;
}
const logicalH = fontPx * LINE_H;
// Канвас растягиваем до pow2 (совместимо с REPEAT+mipmaps даже в WebGL1);
// растяжение глифов компенсируется обратно при маппинге на tileDisplayW
const cw = Math.min(nextPow2(Math.ceil(tileW)), 4096, maxTex);
const ch = Math.min(nextPow2(Math.ceil(logicalH)), 1024, maxTex);
this.texCanvas.width = cw;
this.texCanvas.height = ch;
applyFont(fontPx); // resize канваса сбрасывает состояние контекста
ctx.setTransform(cw / tileW, 0, 0, ch / logicalH, 0, 0);
ctx.fillStyle = s.color;
ctx.textBaseline = 'middle';
ctx.fillText(text, 0, logicalH / 2 + fontPx * 0.03);
ctx.setTransform(1, 0, 0, 1, 0, 0);
this.texFontPx = fontPx;
this.texTileW = tileW;
this.texH = logicalH;
this.texture.image = this.texCanvas;
this.texture.needsUpdate = true;
this.updateScale();
this.ensurePlane();
}
private updateScale(): void {
const k = this.settings.fontSize / this.texFontPx;
this.tileDisplayW = Math.max(1, this.texTileW * k);
this.planeH = this.texH * k;
}
// ---- Геометрия и размеры ----
/** Высота секции: строка + запас под амплитуду, чтобы волна не резалась краями канваса */
private syncHeight(): void {
const s = this.settings;
const h = Math.round(s.fontSize * LINE_H + s.amplitude * 2 + 32);
this.container.style.height = `${h}px`;
}
private ensurePlane(): void {
const w = this.viewW + PLANE_PAD * 2;
if (this.viewW === 0) return;
if (!this.mesh || Math.abs(w - this.planeW) > 1 || Math.abs(this.planeH - this.curPlaneH) > 0.5) {
const geometry = new Plane(this.gl, {
width: w,
height: this.planeH,
widthSegments: SEGMENTS_X,
// перспектива при скрутке нелинейна по вертикали — нужны промежуточные ряды вершин
heightSegments: 8,
});
if (this.mesh) {
this.mesh.geometry.remove();
this.mesh.geometry = geometry;
} else {
this.mesh = new Mesh(this.gl, { geometry, program: this.program });
}
this.planeW = w;
this.curPlaneH = this.planeH;
}
this.uniforms.uRepeat.value = w / this.tileDisplayW;
}
private resize(): void {
const w = this.container.clientWidth;
const h = this.container.clientHeight;
if (!w || !h) return;
this.viewW = w;
this.renderer.setSize(w, h);
this.camera.orthographic({
left: -w / 2,
right: w / 2,
bottom: -h / 2,
top: h / 2,
near: -100,
far: 100,
});
this.ensurePlane();
this.needsRender = true;
if (this.staticMode) this.renderFrame();
}
// ---- Цикл ----
private updateRunning(): void {
const shouldRun = !this.staticMode && this.visible && !document.hidden && !this.contextLost;
if (shouldRun && !this.raf) {
this.lastT = performance.now();
this.lastScrollY = window.scrollY;
this.raf = requestAnimationFrame(this.loop);
} else if (!shouldRun) {
this.stopLoop();
}
}
private stopLoop(): void {
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
private loop = (t: number): void => {
this.raf = requestAnimationFrame(this.loop);
const dt = clamp((t - this.lastT) / 1000, 0.0001, 0.05);
this.lastT = t;
if (!this.settings.paused) {
this.update(dt);
this.renderFrame();
} else if (this.needsRender) {
this.renderFrame();
}
this.needsRender = false;
this.fpsEma += (1 / dt - this.fpsEma) * 0.08;
this.onFrame?.(this.fpsEma);
};
private update(dt: number): void {
const s = this.settings;
this.wavePhase = (this.wavePhase + s.waveSpeed * dt) % TWO_PI;
// Скорость скролла → сглаженный «пинок» строке и амплитуде.
// При выключенной реакции цель = 0: уже набранный буст плавно затухает, без скачка.
const y = window.scrollY;
const instVel = s.scrollReact ? clamp((y - this.lastScrollY) / dt, -6000, 6000) : 0;
this.lastScrollY = y;
this.smoothVel += (instVel - this.smoothVel) * (1 - Math.exp(-6 * dt));
const velNorm = Math.min(Math.abs(this.smoothVel) / 2500, 1);
this.ampBoost = velNorm * s.scrollAmpBoost;
const px = (s.speed * s.direction + this.smoothVel * s.scrollDrag) * dt;
this.offset = (((this.offset + px / this.tileDisplayW) % 1) + 1) % 1;
}
private renderFrame(): void {
if (this.contextLost || !this.mesh) return;
const s = this.settings;
const u = this.uniforms;
u.uWavePhase.value = this.wavePhase;
u.uAmplitude.value = s.amplitude * (1 + this.ampBoost);
u.uWavelength.value = s.wavelength;
u.uSquish.value = s.squish;
u.uSquishPhase.value = s.squishPhase;
u.uBunch.value = s.bunch;
u.uTwist.value = s.twist;
u.uPersp.value = s.perspective;
u.uScroll.value = this.offset;
this.renderer.render({ scene: this.mesh, camera: this.camera });
}
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: labs/wave-marquee (референс FIRST&RED)v1.0.0