javascript

Карандашный набросок, который заливается акварелью: разбираю эффект на SVG без библиотек

  • воскресенье, 18 октября 2026 г. в 00:00:03
https://habr.com/ru/articles/1095566/

Делал интерактивную открытку на день рождения и хотел, чтобы картинка не просто появлялась, а «рисовалась»: сначала карандашные линии, потом по касанию её заливает акварель, причём с рваным краем, как у краски, которая впитывается в бумагу.

Готовых решений под такое не нашёл. Видео не подходило: нужна была реакция на тап и чёткость на любом экране. В итоге всё собралось на чистом SVG, двух фильтрах и пятидесяти строках JS. Ниже разбираю по шагам, с кодом и граблями, на которые наступил.

Что получится в конце:

  1. Контурный рисунок прорисовывается линия за линией.

  2. По клику снизу вверх поднимается акварель, край у неё неровный и «живой».

  3. Вместе с фронтом краски летят золотые искры, а звук кисти синтезируется прямо в браузере.

Интерактивное демо с ползунками, где можно покрутить параметры края: [Sketch → Watercolor Reveal (SVG feTurbulence mask)


Шаг 1. Два слоя одной картинки

Нужны две версии одного рисунка:

  • цветная — обычная картинка (JPG/WebP), сама акварель;

  • контурная — SVG, где каждая линия рисунка отдельный <path> без заливки, только со stroke.

Контурную версию я получил трассировкой цветного рисунка в векторном редакторе. Важно, чтобы на выходе были именно линии (stroke), а не залитые фигуры, иначе прорисовывать будет нечего.

Слои кладутся друг на друга:

<div class="stage">
  <!-- нижний слой: цветная картинка под маской -->
  <svg class="layer" viewBox="0 0 1410 752" preserveAspectRatio="xMidYMid slice">
    <rect width="1410" height="752" fill="#DDCEC7"/>  <!-- цвет «бумаги» -->
    <image href="color.jpg" width="1410" height="752"
           preserveAspectRatio="xMidYMid slice" mask="url(#wc-mask)"/>
  </svg>

  <!-- верхний слой: контуры -->
  <div class="layer lines"></div>
</div>

preserveAspectRatio="xMidYMid slice" — это SVG‑аналог object-fit: cover: картинка заполняет экран и обрезается по краям, а не сжимается. У обоих слоёв он должен быть одинаковый, иначе линии разъедутся с цветом на экранах с другим соотношением сторон.

Шаг 2. Линии, которые рисуются сами

Классический приём через stroke-dasharray и stroke-dashoffset. Делаем пунктир, у которого один штрих длиной во всю линию, а потом сдвигаем его так, чтобы штрих ушёл за пределы линии. Линия становится невидимой. Анимируем сдвиг обратно к нулю — и линия «прорисовывается».

Обычно длину каждой линии считают через getTotalLength(). Но есть трюк проще: атрибут pathLength="1" говорит браузеру «считай, что длина этой линии равна 1». Тогда для всех линий подходят одни и те же значения:

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

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

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

е линии одновременно, картинка просто «проявится» целиком, и магии не будет. Нужен разнобой по времени: каждой линии своя задержка.

async function drawLines(box, dur = 2, stagger = 1) {
  box.innerHTML = await fetch('lines.svg').then(r => r.text());
  const paths = box.querySelectorAll('path');

  paths.forEach((p, i) => {
    p.setAttribute('pathLength', '1');
    p.style.animationDuration = dur + 's';
    p.style.animationDelay = (i / paths.length * stagger).toFixed(3) + 's';
  });

  box.classList.add('draw');
  return new Promise(r => setTimeout(r, (dur + stagger) * 1000));
}

SVG вставляется в DOM через fetch + innerHTML, а не через <img>: к линиям внутри <img> не достучаться ни из CSS, ни из JS.

Порядок линий в файле задаёт порядок прорисовки. После трассировки он обычно хаотичный, и это даже хорошо: картинка проступает по всей площади сразу, как набросок. Если хочется, чтобы рисунок шёл сверху вниз, можно сортировать задержку по координате Y начала линии (её легко достать регуляркой из атрибута d).

Шаг 3. Акварель: маска с рваным краем

Самое интересное. Цветная картинка висит под маской. Маска в SVG работает по яркости: где маска белая — картинка видна, где чёрная — нет.

Поднимающаяся заливка

Внутри маски кладём большой прямоугольник с градиентом: сверху чёрный, ниже белый. В начале он стоит под картинкой, а анимация двигает его вверх.

<defs>
  <linearGradient id="wc-band" x1="0" y1="0" x2="0" y2="1">
    <stop offset="0"    stop-color="#000"/>
    <stop offset="0.30" stop-color="#fff"/>   <!-- мягкая кромка -->
    <stop offset="1"    stop-color="#fff"/>
  </linearGradient>

  <mask id="wc-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="1410" height="752">
    <g filter="url(#wc-edge)">
      <rect x="-260" y="752" width="1930" height="1300" fill="url(#wc-band)">
        <animate id="a-color" attributeName="y" begin="indefinite" dur="2.6s"
                 from="752" to="-460" fill="freeze"
                 calcMode="spline" keyTimes="0;1" keySplines="0.33 0 0.15 1"/>
      </rect>
    </g>
  </mask>
</defs>

Прямоугольник шире картинки (x="-260", width="1930") специально: на следующем шаге фильтр будет его искажать, и без запаса по бокам появятся пустые углы.

Анимация сделана на SMIL (<animate>), а не на CSS: SVG‑атрибут y внутри маски так двигать надёжнее всего во всех браузерах. begin="indefinite" значит «не стартовать самой», запускаем вручную из JS:

stage.addEventListener('pointerdown', () => {
  lines.classList.add('fade');                       // линии растворяются
  document.getElementById('a-color').beginElement(); // краска пошла
});

На этом этапе получается ровная «шторка» с размытым краем. Работает, но выглядит как слайд из презентации, а не как краска.

Рваный край: feTurbulence + feDisplacementMap

Весь эффект акварели держится на трёх строках фильтра:

<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="n"/>
  <feDisplacementMap in="SourceGraphic" in2="n" scale="150"
                     xChannelSelector="R" yChannelSelector="G" result="d"/>
  <feGaussianBlur in="d" stdDeviation="7"/>
</filter>

Что тут происходит:

  1. feTurbulence генерирует фрактальный шум — облачную текстуру, похожую на разводы.

  2. feDisplacementMap сдвигает каждый пиксель прямоугольника на величину, которую берёт из шума: красный канал шума отвечает за сдвиг по X, зелёный — по Y. Ровный край прямоугольника превращается в рваную береговую линию.

  3. feGaussianBlur немного смягчает результат, чтобы край был «мокрым», а не пиксельным.

Ключевой момент: шум неподвижен, а прямоугольник едет сквозь него. Поэтому край не просто рваный, он ещё и меняет форму по мере движения: в одних местах краска «забегает» вперёд, в других отстаёт. Именно это и выглядит как краска, которая впитывается в бумагу.

Параметры, которые стоит покрутить:

Параметр

Что меняет

scale у feDisplacementMap

Насколько рваный край. 0 — ровная шторка, 150 — акварель, 300 — уже хаос

baseFrequency

Размер «пятен». Меньше — крупные плавные разводы, больше — мелкая рябь

Два значения в baseFrequency

Разная частота по X и Y. У меня по вертикали чуть выше, пятна вытянуты горизонтально, как мазки широкой кистью

seed

Другой рисунок шума. Перебирал, пока край не лёг красиво на конкретную картинку

numOctaves

Детализация. Больше 4–5 на глаз почти не видно, а считать дороже

Именно поэтому в демо на CodePen вынесены ползунки: подбирать эти значения на глаз гораздо быстрее, чем в коде.

Та же маска, другая форма: пятно краски

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

<mask id="face-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="1025" height="1025">
  <g filter="url(#face-wc)">
    <circle cx="505" cy="460" r="1" fill="url(#face-blot)">
      <animate id="a-face" attributeName="r" begin="indefinite" dur="1.8s"
               from="1" to="1050" fill="freeze"
               calcMode="spline" keySplines="0.16 0.6 0.2 1"/>
    </circle>
    <circle cx="280" cy="730" r="1" fill="url(#face-blot)">
      <animate attributeName="r" begin="a-face.begin+0.25s" dur="1.5s"
               from="1" to="720" fill="freeze"
               calcMode="spline" keySplines="0.16 0.6 0.2 1"/>
    </circle>
    <!-- ... -->
  </g>
</mask>

Обратите внимание на begin="a-face.begin+0.25s": в SMIL одну анимацию можно привязать к старту другой. Запускаем первую из JS, остальные стартуют сами со своей задержкой.

Шаг 4. Искры на фронте краски

Чтобы заливка ощущалась событием, а не просто сменой картинки, вдоль фронта поднимаются золотые искры. Это обычные <circle> с радиальным градиентом, которые генерируются из JS и летят по кривой через <animateMotion>:

for (let i = 0; i < 70; i++) {
  const x = 30 + Math.random() * 1350;
  const y = 752 - Math.random() * 90;              // старт у нижнего края
  const rise = 190 + Math.random() * 460;
  const sway = (Math.random() * 2 - 1) * 90;       // покачивание в сторону
  const dur = (1.1 + Math.random() * 1.3).toFixed(2) + 's';
  const begin = 'a-color.begin+' + (Math.random() * 2).toFixed(2) + 's';

  const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
  c.setAttribute('r', (2 + Math.random() * 3.4).toFixed(1));
  c.setAttribute('fill', 'url(#spark)');
  c.setAttribute('opacity', '0');
  c.innerHTML = `
    <animateMotion dur="${dur}" begin="${begin}" calcMode="spline"
      keyPoints="0;1" keyTimes="0;1" keySplines="0.2 0.6 0.3 1"
      path="M ${x|0} ${y|0} q ${sway|0} ${-rise/2|0} ${sway*0.4|0} ${-rise|0}"/>
    <animate attributeName="opacity" begin="${begin}" dur="${dur}"
      values="0;1;1;0" keyTimes="0;0.18;0.5;1"/>`;
  fx.appendChild(c);
}

Искры привязаны к старту краски через a-color.begin+N s. Те, что стартуют позже, оказываются выше — и получается, что они «едут» на фронте заливки. Ручной синхронизации по таймерам не нужно.

Шаг 5. Звук кисти без аудиофайла

Маленькая деталь, которая сильно добавляет ощущения: в момент заливки звучит мягкий «шорох кисти». Это не mp3, а белый шум, пропущенный через фильтр низких частот в Web Audio API:

function paintSound(ctx) {
  const t = ctx.currentTime, dur = 1.4;
  const buf = ctx.createBuffer(1, ctx.sampleRate * dur, ctx.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;  // белый шум

  const src = ctx.createBufferSource();
  src.buffer = buf;

  const lp = ctx.createBiquadFilter();
  lp.type = 'lowpass';
  lp.frequency.setValueAtTime(360, t);
  lp.frequency.linearRampToValueAtTime(1500, t + dur * 0.45);  // звук «раскрывается»
  lp.frequency.linearRampToValueAtTime(480, t + dur);

  const g = ctx.createGain();
  g.gain.setValueAtTime(0.0001, t);
  g.gain.linearRampToValueAtTime(0.075, t + 0.4);
  g.gain.linearRampToValueAtTime(0.0001, t + dur);

  src.connect(lp).connect(g).connect(ctx.destination);
  src.start(t);
  src.stop(t + dur);
}

Фильтр сначала приоткрывается (360 → 1500 Гц), потом закрывается обратно. На слух это похоже на влажный мазок кистью. Ноль килобайт ассетов.

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

Грабли

1. Перезапуск SMIL‑анимации. Для кнопки «ещё раз» нужно вернуть маску в начало. Первая мысль — beginElement() и сразу endElement(). В Chrome это не работает: с fill="freeze" анимация остаётся в конечном состоянии, и картинка видна целиком. Рабочий способ — заменить элемент анимации его свежей копией. У новой копии нет истории, и прямоугольник возвращается к исходному y:

const fresh = aColor.cloneNode(true);
aColor.replaceWith(fresh);
aColor = fresh;

2. Обрезанный фильтр. По умолчанию область фильтра всего на 10% больше элемента. feDisplacementMap сдвигает пиксели дальше, и край обрезается ровной линией — весь эффект пропадает. Лечится расширением области: x="-35%" y="-35%" width="170%" height="170%".

3. Цвета «плывут». Фильтры SVG по умолчанию считают в цветовом пространстве linearRGB, из‑за этого полупрозрачные области маски получаются светлее ожидаемого и край выглядит блёклым. color-interpolation-filters="sRGB" возвращает привычное поведение.

4. Производительность. feTurbulence пересчитывается на каждом кадре, пока под фильтром что‑то движется. На полноэкранной картинке на слабых телефонах это заметно. Помогло: не ставить numOctaves больше 4, держать анимацию короткой (2–3 секунды) и после её окончания убирать лишнее (у меня слой с линиями после заливки получает display: none).

5. Доступность. Тем, у кого включено «уменьшение движения», показываю сразу финальное состояние:

@media (prefers-reduced-motion: reduce) {
  .lines.draw path { animation-duration: .01s; animation-delay: 0s !important; }
}

И раз картинка запускается по тапу, у сцены есть tabindex="0" и обработчик Enter/Space — с клавиатуры тоже можно «покрасить».

Итог

Весь эффект — это:

  • stroke-dashoffset + pathLength="1" для прорисовки линий;

  • маска с движущейся фигурой для заливки;

  • feTurbulence + feDisplacementMap для рваного акварельного края;

  • SMIL для синхронизации всего этого без таймеров в JS.

Никаких библиотек, canvas и видео, и всё остаётся векторным и резким на любом экране.

Попробовать эффект и покрутить параметры: [Sketch → Watercolor Reveal (SVG feTurbulence mask)] Открытка целиком (лучше с телефона): https://gifts‑happy‑birthday.vercel.app/ Код: https://github.com/apostol0525/for‑you

Это некоммерческий фан‑проект, рисунки по мотивам «Ведьминой службы доставки» Studio Ghibli, все права на них принадлежат правообладателям.