Motiva
Мои коллекции
← каталог

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

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

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