Pulse of Expression выходит на платформе Holders вместе с 9 очень талантливыми артистами: @distcollective, @greweb, @DaimAlYad, @seigneurrrr , @ginzi_o, @Olga_f2727, @Kitel87, @gpitombo, @ordinarywillard. Посмотрите на их работу, которая невероятна.

В этой статье я хотел представить вам различные технические аспекты, которых мне удалось достичь, в надежде, что однажды они вам пригодятся 🙂.

Советы по производительности

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

Или добавьте более или менее видимое зерно:

Или в процедурной генерации более-менее выраженной карты нормалей:

С помощью glsl легко интегрировать код различных типов шума, поскольку большинство из них легко найти в Интернете. Известный репозиторий на эту тему принадлежит Ashima Arts и Stefan Gustavson: https://github.com/ashima/webgl-noise.

Я занимаюсь созданием интерактивного 3D-контента в Интернете. Обычно я использую предварительно сгенерированные изображения, чтобы не рассчитывать эти шумы для каждого кадра в реальном времени. Это может быстро стать слишком тяжелым с точки зрения производительности.

Однако оказывается, что в некоторых ситуациях, как здесь, мы не можем использовать активы. И мы должны признать, что это может идти вразрез с самим принципом генеративного искусства.

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

export default class NoiseGenerator {
  constructor(noises = []) {
    this.materials = []
    this.textures = []
    this.fbos = []

    noises.forEach(noise => {
      const material = this.createMaterial(noise)
      this.materials.push(material)
    })

    this.quad = new BigTriangle(this.materials[0], true)

    this.materials.forEach(material => {
      this.quad.material = material

      const fbo = new FBO1({ width: 1024, height: 1024 })
      this.fbos.push(fbo)

      Stage3d.renderer.setRenderTarget(fbo.renderTarget)
      Stage3d.renderer.render(this.quad.mesh, this.quad.camera)

      this.textures.push(fbo.texture)
    })
  }

  createMaterial(noise) {
    if (!noise.type || noise.type === 'perlin') {
      return new PerlinMaterial({
        u_magnitude: { value: noise.magnitude },
        u_amplitude: { value: noise.amplitude },
      })
    } else if (noise.type === 'cellular') {
      return new CellularMaterial({
        u_magnitude: { value: noise.magnitude },
        u_amplitude: { value: noise.amplitude },
      })
    } else {
      return new VoronoiMaterial({
        u_magnitude: { value: noise.magnitude },
        u_amplitude: { value: noise.amplitude },
      })
    }
  }

  getTextureByIndex(i) {
    return this.textures[i]
  }
}

Холст с рельефом

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

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

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

uniform sampler2D t_buffer;
uniform sampler2D t_noise2;
uniform vec3 u_color;
uniform float u_depth;
uniform float u_factor;

varying vec2 vUv;

vec3 texsample(float x, float y, in vec2 fragCoord) {
  vec2 uv = fragCoord + vec2(x, y);
  return texture2D(t_noise2, uv).xyz;
}

float luminance(vec3 c) {
  return dot(c, vec3(.2126, .7152, .0722));
}

vec3 normal(in vec2 fragCoord) {
  float offset = 0.0001;
  float R = abs(luminance(texsample( offset,0., fragCoord)));
  float L = abs(luminance(texsample(-offset,0., fragCoord)));
  float D = abs(luminance(texsample(0., offset, fragCoord)));
  float U = abs(luminance(texsample(0.,-offset, fragCoord)));
     
  float X = (L-R) * .5;
  float Y = (U-D) * .5;

  return normalize(vec3(X, Y, 1. / u_depth));
}

void main() {
  vec4 tBuffer = texture2D(t_buffer, vUv);

  vec3 n = normal(vUv);
  vec3 lp = vec3(-6.5, 1.4, 15.2);
  vec3 sp = vec3(0.);

  tBuffer.rgb = mix(tBuffer.rgb, u_color, displacement * 0.225);
  tBuffer.rgb *= dot(n, normalize(lp - sp)) * u_factor;

  gl_FragColor = tBuffer;
}

Упорядоченный состав?

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

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

class QuadTree {
  constructor({
    bounds = { x: 0, y: 0, w: window.innerWidth, h: window.innerHeight },
    maxObjects = 10,
    maxLevels = 4,
    level = 0
  } = {}) {
    this.bounds = bounds
    this.maxObjects = maxObjects
    this.maxLevels = maxLevels
    this.level = level

    this.objects = []
    this.nodes = []
  }
  split() {
    const nextLevel = this.level + 1
    const subWidth = this.bounds.w/2
    const subHeight = this.bounds.h/2
    const x = this.bounds.x
    const y = this.bounds.y        

    this.nodes[0] = new QuadTree({
      bounds: { x: x + subWidth, y: y, w: subWidth, h: subHeight },
      maxObjects: this.maxObjects,
      maxLevels: this.maxLevels,
      level: nextLevel
    })
    this.nodes[1] = new QuadTree({
      bounds: { x: x, y: y,  w: subWidth, h: subHeight },
      maxObjects: this.maxObjects,
      maxLevels: this.maxLevels,
      level: nextLevel
    })
    this.nodes[2] = new QuadTree({
      bounds: { x: x, y: y+ subHeight, w: subWidth, h: subHeight },
      maxObjects: this.maxObjects,
      maxLevels: this.maxLevels,
      level: nextLevel
    })
    this.nodes[3] = new QuadTree({
      bounds: { x: x + subWidth, y: y + subHeight, w: subWidth, h: subHeight },
      maxObjects: this.maxObjects,
      maxLevels: this.maxLevels,
      level: nextLevel
    })
  }
  getIndex(pRect) {
    const indexes = []
    const verticalMidpoint = this.bounds.x + (this.bounds.w/2)
    const horizontalMidpoint = this.bounds.y + (this.bounds.h/2)

    const startIsNorth = pRect.y < horizontalMidpoint
    const startIsWest = pRect.x < verticalMidpoint
    const endIsEast = pRect.x + pRect.w > verticalMidpoint
    const endIsSouth = pRect.y + pRect.h > horizontalMidpoint

    if(startIsNorth && endIsEast) indexes.push(0)
    if(startIsWest && startIsNorth) indexes.push(1)
    if(startIsWest && endIsSouth) indexes.push(2)
    if(endIsEast && endIsSouth) indexes.push(3)

    return indexes
  }
  insert(pRect) {
    let i = 0
    let indexes
 
    if(this.nodes.length) {
      indexes = this.getIndex(pRect)

      for(i=0; i<indexes.length; i++) {
        this.nodes[indexes[i]].insert(pRect)
      }
      return
    }

    this.objects.push(pRect)

    if(this.objects.length > this.maxObjects && this.level < this.maxLevels) {
      if(!this.nodes.length) {
        this.split()
      }
      
      for(i=0; i<this.objects.length; i++) {
        indexes = this.getIndex(this.objects[i])
        for(let k=0; k<indexes.length; k++) {
          this.nodes[indexes[k]].insert(this.objects[i])
        }
      }

      this.objects = []
    }
  }
  retrieve(pRect) {
    const indexes = this.getIndex(pRect)
    let returnObjects = this.objects
    
    if(this.nodes.length) {
      for(var i=0; i<indexes.length; i++) {
        returnObjects = returnObjects.concat(this.nodes[indexes[i]].retrieve(pRect))
      }
    }

    returnObjects = returnObjects.filter(function(item, index) {
      return returnObjects.indexOf(item) >= index
    })

    return returnObjects
  }
  clear() {
    this.objects = []
     
    for(var i=0; i < this.nodes.length; i++) {
      if(this.nodes.length) {
        this.nodes[i].clear()
      }
    }

    this.nodes = []
  }
}

Тогда вот способ использовать наш новый свежий класс и получить его данные:

function getIndividualQtNodes(node) {
  const individualNodes = [];

  (function r(node) {
    if (node.nodes.length === 0) {
      individualNodes.push(node);
    } else {
      node.nodes.forEach((n) => r(n));
    }
  })(node);

  return individualNodes;
}

function getGridArea(bounds, colSize, rowSize) {
  return {
    col: {
      start: bounds.x / colSize,
      end: (bounds.x + bounds.w) / colSize,
    },
    row: {
      start: bounds.y / rowSize,
      end: (bounds.y + bounds.h) / rowSize,
    },
  }
}

function createQuadTree({
  bounds = { x: 0, y: 0, w: window.innerWidth, h: window.innerHeight },
  maxObjects = 10,
  maxLevels = 4,
  level = 0,
  gap = 1,
  points = []
} = {}) {
  const quadTree = new QuadTree({ bounds, maxObjects, maxLevels, level })

  points.forEach((point) => {
    quadTree.insert(point)
  })

  const maxSubdivisions = Math.pow(2, maxLevels)
  const colSize = bounds.w / maxSubdivisions
  const rowSize = bounds.h / maxSubdivisions

  return {
    w: bounds.w,
    h: bounds.h,
    cols: maxSubdivisions,
    rows: maxSubdivisions,
    areas: getIndividualQtNodes(quadTree).map(({ bounds }) => {
      return {
        x: bounds.x + gap,
        y: bounds.y + gap,
        w: bounds.w - gap * 2,
        h: bounds.h - gap * 2,
        ...getGridArea(bounds, colSize, rowSize),
      };
    }),
  }
}

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

draw() {
  this.points = this.getPoints()
  this.quadTree = new createQuadTree({ bounds: { x: 0, y: 0, w: this.w, h: this.h }, points: this.points, maxLevels: this.maxLevels, gap: 0 })

  this.ctx.fillStyle = 'black'
  this.ctx.fillRect(0, 0, this.w, this.h)

  for (let i = 0; i < this.quadTree.areas.length; i++) {
    const area = this.quadTree.areas[i]

    const thre = { x: area.w * this.params.thresold, y: area.h * this.params.thresold}
    const x = area.x - thre.x
    const y = area.y - thre.y
    const w = area.w + thre.x * 2
    const h = area.h + thre.y * 2
    const c = Math.floor((i/this.params.splits) % 1 * 255)

    for (let j = 0; j < R.random_int(this.params.nbLines[0], this.params.nbLines[1]); j++) {
      this.ctx.beginPath()
      this.ctx.strokeStyle = `rgba(${c}, ${c}, ${c}, ${R.random_num(this.params.alpha[0], this.params.alpha[1])})`

      const x0 = R.random_num(x, x+w)
      const x1 = R.random_num(x, x+w)
      const y0 = R.random_num(y, y+h)
      const y1 = R.random_num(y, y+h)
      this.ctx.moveTo(x0, y0)
      this.ctx.lineTo(x1, y1)
      this.ctx.stroke()
    }
  }
}

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

Другой визуал ниже также изменен некоторым смещением, но вы поняли идею.

let uniforms_texture = ''
let masking = ''
for (let i = 0; i < this.params.nb; i++) {
  uniforms[`t_diffuse${i}`] = { value: this.brushGroup.texture }
  uniforms_texture += `uniform sampler2D t_diffuse${i}; \n`
  masking += `vec4 tDiffuse${i} = texture2D(t_diffuse${i}, vUv); \n`
}

masking += `
vec3 color = tDiffuse0.rgb;
float alpha = tDiffuse0.a;

`

let fragmentShader = paintBufferFs.replace(
  '#include <uniforms_texture>',
  uniforms_texture
)

const steps = this.brushMask.steps
for (let i = 0; i < this.params.nb - 1; i++) {
  masking += `
  float mask${i} = clamp(map(tMask.r, ${steps[i]}${Number.isInteger(steps[i]) ? '.' : ''}, ${steps[i+1]}, 0., 1.), 0., 1.);
  color = mix(color, tDiffuse${i+1}.rgb, mask${i});
  alpha = mix(alpha, tDiffuse${i+1}.a, mask${i});

`
}

fragmentShader = fragmentShader.replace(
  '#include <masking>',
  masking
)

Заключение

Надеюсь, вы нашли для себя несколько интересных советов в этой статье. На этом сайте вы найдете оригинальные версии из этой серии, которые я тщательно отобрал и экспортировал в высоком разрешении. Вы можете найти здесь вторую статью об экспорте иллюстраций из вашего веб-браузера 🙂