Привет, Хабр! Я Виталий Дуденко, разработчик в отделе пользовательских интерфейсов Первой грузовой компании. Наша компания занимается грузоперевозками по железной дороге. Для усовершенствования сервиса перевозок мы разрабатываем цифровые продукты, для которых необходимо создавать интерфейсы, например, для Личного кабинета клиента (ЛКК). Подробнее о нем мы рассказывали здесь.  

На рабочем проекте возникла необходимость создать бесшовную текстуру для фона страниц с определенным узором. Разработка этого паттерна была нужна для сайта нашей цифровой дочки. Оказалось, что готовых решений нет, а в русскоязычном сообществе я не сумел найти инструкции или намёков как такое можно сделать. Поэтому далее я представлю свою реализацию генератора с использованием p5.js на angular.

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

Нам понадобятся библиотеки p5.js и open‑simplex‑noise

Создаём проект на angular и подключаем библиотеку p5.js, для удобства я использую абстрактный класс P5JSInvoker.

Он позволит нам использовать базовые колбэки p5 (preload, setup, draw) как методы класса.

export abstract class P5JSInvoker {
 abstract preload(p: P5.p5InstanceExtensions): void;
 abstract setup(p: P5.p5InstanceExtensions): void;
 abstract draw(p: P5.p5InstanceExtensions): void;




 protected startP5JS(containerElement:HTMLElement):P5.p5InstanceExtensions {
   return new P5(this.generate_sketch(), containerElement);
 }


 private generate_sketch(): any {
   const that = this;


   return ((p: P5) => {
     p.preload = (): void => {
       that.preload(p);
     };


     p.setup = (): void => {
       that.setup(p);
     };


     p.draw = (): void => {
       that.draw(p);
     };
   });
 }
}

Далее создаю канвас и класс NoiseGenerator. Dimensions отвечает за размер генерации, в данный момент это размер всего канваса, а x1,x2, y1, y2 это скейлы самого шума. По умолчанию у меня значения 0, 10, 0, 10

public setup(p5: p5InstanceExtensions): void {
 this.p5.noSmooth();
 this.p5.createCanvas(
   this.dimensions.x,
   this.dimensions.y,
 );
 this.generator = new NoiseGenerator();
 this.generator.createNoise(this.dimensions.y, this.dimensions.x, this.x1, this.x2, this.y1, this.y2)


 this.p5.noLoop();
 this.container.nativeElement.onclick = () => this.p5.save();
}

Рассмотрим его подробнее

import {P5InstanceService} from "../services/p5-instance.service";
import {SPACING} from "../constants/constants";
import {Particle} from "./particle";
import {makeNoise4D} from "open-simplex-noise";
import {Vector} from "p5";


export class NoiseGenerator {
 private p5 = P5InstanceService.p5Instance;
 private particles: Particle[][] = [];
 private rows = 0;
 private cols = 0;


 public createNoise(
   height: number,
   width: number,
   x1: number,
   x2: number,
   y1: number,
   y2: number,
 ): void {
   this.p5.noiseSeed(20)
   this.p5.strokeWeight(1);


   this.rows = Math.floor(height / SPACING)
   this.cols = Math.floor(width / SPACING)


   this.seamlessNoise2D(this.cols, this.rows, x1, x2, y1, y2)
 }


 public seamlessNoise2D(bufferWidth: number,
                        bufferHeight: number,
                        x1: number,
                        x2: number,
                        y1: number,
                        y2: number) {
   const noise = makeNoise4D(10);


   for (let x = 0; x < bufferWidth; x++) {
     this.particles[x] = [];


     for (let y = 0; y < bufferHeight; y++) {
       const s = x / bufferWidth;
       const t = y / bufferHeight;
       const dx = x2 - x1;
       const dy = y2 - y1;


       const nx = x1 + Math.cos(s * 2 * Math.PI) * dx / (2 * Math.PI);
       const ny = y1 + Math.cos(t * 2 * Math.PI) * dy / (2 * Math.PI);
       const nz = x1 + Math.sin(s * 2 * Math.PI) * dx / (2 * Math.PI);
       const nw = y1 + Math.sin(t * 2 * Math.PI) * dy / (2 * Math.PI);


       this.particles[x][y] = new Particle(
         new Vector(x * SPACING, y * SPACING),
         this.p5.map(noise(nx, ny, nz, nw), -0.5, 0.5, 0, 255)
       );
     }
   }
 }


 public showParticles(): void {
   this.particles.forEach(p => p.forEach(p => p.show()))
 }
}

Здесь мы создаём матрицу частиц particles, реализация которых может быть на ваше усмотрение.

SPACING нужен чтобы задать расстояние между частицами. Если нам необходимо заполнить каждый пиксель, то его следует установить на 1.

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

При создании инстанса Particle мы мы указываем текущую позицию и цвет. Цвет определяется с помощью метода p5.map, который преобразует диапазон шума от -0.5 до 0.5 в диапазон цвета от 0 до 255. Обычно шум генерирует значения от 0 до 1, однако именно эта библиотека выдаёт значения от -0.5 до 0.5. Изменения этих параметров позволит нам контролировать контраст между чёрными и белыми областями.

Это реализация метода show у частицы.

export class Particle {


 constructor(public position: Vector, public color: number) {
 }


 public show(): void {
   this.p5.fill(this.color);
   this.p5.noStroke()
   this.p5.square(this.position.x, this.position.y, SPACING);
 }
}

На данном этапе у нас должно получиться такое изображение:

Если взять это изображение и скопировать его сколько угодно раз, у нас не должно быть видно швов. То есть картинка будет работать как тайлы.

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

Подняв значение spacing и увеличив скейл шума мы можем генерировать тайлы для пиксель-арта.

Здесь я использовал второй шум, чтобы создать вкрапления типа руды или травы.

Второй шум на другом сиде я передаю в объект Particle, а манипуляцией FILL_AMOUNT можно изменять степень заполнения.



public show(): void {


 if (this.noise2 > FILL_AMOUNT) {
   const fill = this.p5.map(this.noise2, -0.5, 0.5, 10, 30, false)
   this.p5.fill(70, 50, fill);
 } else {
   const fill = this.p5.map(this.noise, -0.5, 0.5, 25, 50, false)
   this.p5.fill(30, 50, fill);
 }


 this.p5.noStroke()
 this.p5.square(this.position.x, this.position.y, SPACING);
}


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

const angle = noise * this.p5.TWO_PI * 4
const vector = Vector.fromAngle(angle).normalize();

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

Вместо заключения

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

Комментарии (3)


  1. koldoon
    18.07.2024 11:19
    +3

    Круто! У меня только один вопрос: а при чем тут angular? ;) алгоритм вполне себе абстрактный.


  1. Sabirman
    18.07.2024 11:19

    Открыл ваш сайт - красиво. Через пару минут вентилятор на ноуте засвистел.


    1. azTotMD
      18.07.2024 11:19

      Конечно, засвистит, если так код писать. Просто наглядный пример, как можно на ровном месте разбрасываться вычислительным ресурсом.