Дайджесты новостей
Художественный натюрморт на темном графитовом столе: лист фактурной хлопковой бумаги, на котором тонкий карандашный набросок контуров превращается в растекающуюся цветную акварель рядом с кистью и карандашом.

От карандаша к акварели: живой графический эффект на чистом SVG и Web Audio

Когда перед фронтендером встает задача сделать выразительную интерактивную открытку или промо-лендинг, рука автоматически тянется к привычной тяжелой артиллерии: Three.js, Pixi.js, сложным Canvas-сценам или видеоролику на пару десятков мегабайт.

Я сам долгое время считал, что создать живой эффект рисования — сначала быстрый карандашный набросок контура, а затем растекающуюся по волокнам бумаги акварель с шуршанием кисти — невозможно без шейдеров WebGL или видеофайлов.

Но проект разработчика Boris0525 (репозиторий apostol0525/for-you) доказывает обратное. Весь этот визуальный спектакль реализуем на чистых веб-стандартах: декларативном SVG, математических фильтрах искажения, SMIL и процедурном синтезе звука через Web Audio API. При этом размер разметки и стилей укладывается в несколько килобайтов, сохраняя векторную четкость на любом дисплее.

Давай разберем эту механику по винтикам.

Анатомия сцены: два слоя и точная синхронизация

Графическая сцена состоит из двух слоев, наложенных друг на друга в общем контейнере:

  1. Нижний слой (акварель): растровое цветное изображение рисунка (<image>), скрытое динамической SVG-маской mask="url(#wc-mask)".
  2. Верхний слой (карандашный контур): векторный набор путей <path> без заливки (fill: none), прорисовывающий линии штриха поверх фоновой заливки.

Чтобы векторные линии не съехали относительно цветных пятен при ресайзе окна, оба слоя помещаются в единый viewBox="0 0 1410 752" с общим атрибутом масштабирования:

<svg viewBox="0 0 1410 752" preserveAspectRatio="xMidYMid slice" class="stage">
  <!-- Нижний слой с маской заливки -->
  <image href="color.webp" width="1410" height="752" 
         preserveAspectRatio="xMidYMid slice" 
         mask="url(#wc-mask)" />

  <!-- Верхний слой векторных линий -->
  <g class="lines" preserveAspectRatio="xMidYMid slice">
    <!-- Набор контурных путей <path> -->
  </g>
</svg>

Значение xMidYMid slice работает в SVG аналогично CSS-свойству object-fit: cover: оно центрирует графику и масштабирует ее с сохранением пропорций без искажений.

Трюк с pathLength: прорисовка штрихов без лишнего JavaScript

Классический способ анимировать рисование линий в SVG — использовать свойства stroke-dasharray и stroke-dashoffset. Но чтобы узнать длину каждого отдельного пути, разработчикам обычно приходится писать скрипты на JS, перебирать теги <path> и вызывать path.getTotalLength().

Автор проекта применил элегантный трюк из SVG 2 — атрибут pathLength="1":

<path d="M120 340 C 150 200, 280 180, 310 320 ..." pathLength="1" />

Указание pathLength="1" нормализует всю длину кривой к единице, независимо от ее реального размера в пикселях. Благодаря этому вся анимация штриха переносится в чистый CSS:

.lines path {
  fill: none;
  stroke: #5C4A38;
  stroke-width: 1.2;
  stroke-linecap: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1; /* Линия скрыта */
}

.lines.draw path {
  animation: drawStroke 2s cubic-bezier(0.45, 0, 0.25, 1) forwards;
}

@keyframes drawStroke {
  to { stroke-dashoffset: 0; }
}

Чтобы набросок не рисовался механически весь сразу, каждому штриху в цикле JavaScript назначается небольшая задержка style.animationDelay.

Органический край акварели: магия feTurbulence и feDisplacementMap

Когда набросок завершен, начинается проявление акварели. Жидкая краска впитывается в неровную текстуру бумаги, образуя живые рваные границы.

Инфографическая схема SVG-фильтра органического края акварели: смещение границы скользящей маски шумом Перлина через feTurbulence и feDisplacementMap.

Маска рисунка строится на скользящем градиентном блоке, контур которого деформируется шумом Перлина через фильтры feTurbulence и feDisplacementMap:

<defs>
  <!-- Фильтр органического края акварели -->
  <filter id="wc-edge" x="-35%" y="-35%" width="170%" height="170%" 
          color-interpolation-filters="sRGB">
    <feTurbulence type="fractalNoise" baseFrequency="0.010 0.016" 
                  numOctaves="4" seed="11" result="noise" />
    <feDisplacementMap in="SourceGraphic" in2="noise" scale="150" 
                       xChannelSelector="R" yChannelSelector="G" result="displaced" />
    <feGaussianBlur in="displaced" stdDeviation="7" />
  </filter>

  <mask id="wc-mask">
    <g filter="url(#wc-edge)">
      <rect x="0" y="0" width="1410" height="752" fill="white">
        <animate id="a-color" attributeName="x" from="-1410" to="0" 
                 dur="2.5s" begin="indefinite" fill="freeze" 
                 calcMode="spline" keyTimes="0; 1" 
                 keySplines="0.45 0 0.25 1" />
      </rect>
    </g>
  </mask>
</defs>

Механика проста:

  1. feTurbulence создает статичную матрицу фрактального шума. Параметры baseFrequency="0.010 0.016" имитируют волокна хлопковой бумаги.
  2. Белый прямоугольник маски двигается слева направо по команде SMIL <animate>.
  3. feDisplacementMap смещает границу движущегося прямоугольника по каналам шума на величину до 150 пикселей (scale="150"). Край непрерывно изгибается в реальном времени.

Параметр color-interpolation-filters="sRGB" критичен: без него браузеры считают фильтры в linearRGB, из-за чего края становятся блеклыми.

Процедурный звук кисти на Web Audio API

Вместо внешних MP3-файлов с задержками предзагрузки звук шуршания кисти синтезируется процедурно прямо в браузере при касании экрана:

function playBrushSound(audioCtx) {
  const now = audioCtx.currentTime;
  const duration = 1.4;

  // 1. Буфер белого шума
  const buffer = audioCtx.createBuffer(1, audioCtx.sampleRate * duration, audioCtx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < data.length; i++) {
    data[i] = Math.random() * 2 - 1;
  }

  const source = audioCtx.createBufferSource();
  source.buffer = buffer;

  // 2. Низкочастотный фильтр с имитацией нажима
  const filter = audioCtx.createBiquadFilter();
  filter.type = 'lowpass';
  filter.frequency.setValueAtTime(360, now);
  filter.frequency.linearRampToValueAtTime(1500, now + duration * 0.45);
  filter.frequency.linearRampToValueAtTime(480, now + duration);

  // 3. Огибающая громкости
  const gain = audioCtx.createGain();
  gain.gain.setValueAtTime(0.0001, now);
  gain.gain.linearRampToValueAtTime(0.075, now + 0.4);
  gain.gain.linearRampToValueAtTime(0.0001, now + duration);

  source.connect(filter).connect(gain).connect(audioCtx.destination);
  source.start(now);
  source.stop(now + duration);
}

Функция не расходует сетевой трафик, стартует мгновенно в обработчике pointerdown и точно передает ощущение скольжения кисти по бумаге.

Производительность и доступность

Фильтры feTurbulence и feDisplacementMap рассчитываются попиксельно. Чтобы сохранить 60 кадров в секунду на смартфонах:

  • Держи numOctaves не выше 4, иначе вычисления удваиваются без заметного прироста качества;
  • Скрывай отработавшие слои через display: none после окончания анимации;
  • Для перезапуска в Chrome клонируй узел <animate> через cloneNode().

Не забудь о доступности: отключи анимацию для пользователей с настройкой @media (prefers-reduced-motion: reduce), показывая сразу готовый рисунок.

Этот проект наглядно показывает: возможности нативных веб-стандартов огромны, если грамотно задействовать декларативный SVG и фильтры платформы.