3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ウクライナ国旗を油彩画風に描画する Generative Art

Posted at

ウクライナへの支援も兼ねた Generative Art の啓発記事です。あまり深い意味はありません。
flag-ukraina.png

概要

  • 油彩画風のウクライナ国旗を JavaScript で自動生成します。
  • コードは 200 行ほどの HTML ファイル一枚のみ。
  • Canvas API で国旗の下描きを描画し、それを元に擬似ブラシで描画する、といった流れです。

コード

flag.html
<style>
body {
  background-color: #000000;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0;
}

canvas {
  max-height: 100vmin;
}
</style>
<body>
  <canvas id="canvas"></canvas>
</body>
<script>
const canvas = document.querySelector('#canvas')

const context = canvas.getContext('2d')

const main = () => {
  resizeCanvas()
  fillBackground()

  // 下描きの描画
  const margin = 32
  sketchFlagOfUkraina(margin)
  // sketchFlagOfJapan(margin)
  // sketchFlagOfIreland(margin)

  // ブラシの描画
  drawBrush()
}

const resizeCanvas = () => {
  const width = 640

  // ※ 国旗の縦横比は国によって異なるが、ここではウクライナ国旗の 3:2 で統一している
  const height = Math.round(width / 3 * 2)

  canvas.setAttribute('width', width)
  canvas.setAttribute('height', height)
}

const fillBackground = () => {
  context.fillStyle = 'rgb(224, 224, 224)'
  context.fillRect(0, 0, canvas.width, canvas.height)
}

const sketchFlagOfUkraina = (margin) => {
  context.fillStyle = 'rgb(0, 87, 184)'
  context.fillRect(margin, margin, canvas.width - (margin * 2), canvas.height / 2)
  context.fillStyle = 'rgb(255, 215, 0)'
  context.fillRect(margin, canvas.height / 2, canvas.width - (margin * 2), canvas.height / 2 - margin)
}

const sketchFlagOfJapan = (margin) => {
  context.fillStyle = 'rgb(255, 255, 255)'
  context.fillRect(margin, margin, canvas.width - (margin * 2), canvas.height - (margin * 2))
  context.beginPath()
  context.arc(canvas.width / 2, canvas.height / 2, (canvas.height - margin * 2) / 5 * 3 / 2, 0, Math.PI * 2)
  context.closePath()
  context.fillStyle = 'rgb(188, 0, 45)'
  context.fill()
}

const sketchFlagOfIreland = (margin) => {
  const partColors = [ 'rgb(0, 158, 96)', 'rgb(255, 255, 255)', 'rgb(247, 127, 0)' ]
  const partHeight = (canvas.height - margin * 2) / partColors.length
  partColors.forEach((color, index) => {
    context.fillStyle = color
    context.fillRect(margin, partHeight * index + margin, canvas.width - (margin * 2), partHeight)
  })
}

const drawBrush = () => {
  // 各種ピクセル配列の作成
  const imageData = context.getImageData(0, 0, canvas.width, canvas.height)
  const data = imageData.data
  const pixels1 = [] // 境界判定用ピクセル配列
  const pixels2 = [] // 起点取得用ピクセル配列
  for (let y = 0; y < canvas.height; y ++) {
    pixels1[y] = []
    for (let x = 0; x < canvas.width; x ++) {
      const i = (canvas.width * y + x) * 4
      const r = data[i]
      const g = data[i + 1]
      const b = data[i + 2]
      pixels1[y][x] = { x, y, r, g, b }
      pixels2.push({ x, y, r, g, b })
    }
  }

  // ピクセル配列のシャッフル
  // ※ 左上から順番にブラシを描画すると絵に偏りが生じるため
  shuffle(pixels2)

  // 各ピクセルの処理
  // ※ 本当に全ピクセルを処理する必要はないため、 `p += 8` としている
  for (let p = 0; p < pixels2.length; p += 8) {
    const pixel2 = pixels2[p]

    // 起点
    const x = pixel2.x
    const y = pixel2.y

    // RGB値(≒ブラシの色)
    // ※ 同時に色に幅を持たせる
    const colorBaseAmp = irandom(- 16, 16)
    const r = pixel2.r + colorBaseAmp + irandom(- 16, 16)
    const g = pixel2.g + colorBaseAmp + irandom(- 16, 16)
    const b = pixel2.b + colorBaseAmp + irandom(- 16, 16)

    // ブラシの幅(起点からブラシ端点までの幅)
    const brushWidth = irandom(16, 32)

    // ブラシの角度
    const angle1 = frandom(- Math.PI, Math.PI)

    // 起点からブラシ先端への角度
    const angle2 = angle1 + Math.PI / 2

    // ブラシ端点
    const x1 = x + Math.sin(angle1) * - brushWidth
    const y1 = y + Math.cos(angle1) * - brushWidth
    const x2 = x + Math.sin(angle1) * brushWidth
    const y2 = y + Math.cos(angle1) * brushWidth

    // ブラシの描画
    // ※ ブラシ端点から他方のブラシ端点まで1ピクセルごとに走査
    bresenhamsLine(x1, y1, x2, y2, (x3, y3) => {
      // 境界チェック
      // ※ 起点とブラシ毛の始点のRGB値が一致しなければ境界外とみなしキャンセル
      const pixel1 = pixels1[y3] ? pixels1[y3][x3] : null
      if (!pixel1 ||
        pixel1.r !== pixel2.r ||
        pixel1.g !== pixel2.g ||
        pixel1.b !== pixel2.b) {
        return false
      }

      // ブラシ毛の長さ
      const bristleLength = irandom(32, 64)

      // ブラシ毛の終点
      let x4 = x3 + Math.sin(angle2) * bristleLength
      let y4 = y3 + Math.cos(angle2) * bristleLength

      // 境界チェック
      // ※ ブラシ毛の始点から終点まで1ピクセルごとに走査
      // ※ 起点とブラシ毛の描画点のRGB値が一致しなければ境界外とみなし、ブラシ毛の終点に描画点を設定してキャンセル
      const hit = !bresenhamsLine(x3, y3, x4, y4, (x5, y5) => {
        const pixel1 = pixels1[y5] ? pixels1[y5][x5] : null
        if ((!pixel1 ||
          pixel1.r !== pixel2.r ||
          pixel1.g !== pixel2.g ||
          pixel1.b !== pixel2.b) &&
          frandom(0, 1.0) > 0.75) { // 「ある程度」チェックを素通りさせる
          x4 = x5
          y4 = y5
          return false
        }
        return true
      })

      // ブラシ毛の起点と終点
      // ※ 同時に少しだけずらす
      const x5 = x3 + irandom(- 1, 1)
      const y5 = y3 + irandom(- 1, 1)
      const x6 = x4 + irandom(- 1, 1)
      const y6 = y4 + irandom(- 1, 1)

      // ブラシ毛の描画
      context.beginPath()
      context.moveTo(x5, y5)
      context.lineTo(x6, y6)
      const a = frandom(0.125, 0.25) // 透明度
      const hitAdding = hit ? - 32 : 0 // 境界超えしていた場合、明度を下げる
      const colorAmp = irandom(- 8, 8) // ブラシ毛の明度に少しだけ幅を持たせる
      context.strokeStyle =`rgba(\
        ${r + hitAdding + colorAmp}, \
        ${g + hitAdding + colorAmp}, \
        ${b + hitAdding + colorAmp}, \
        ${a}\
      )`
      context.stroke()
    })
  }
}

const frandom = (min, max) => Math.random() * (max - min) + min

const irandom = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min

const shuffle = (array) => array.sort(() => Math.random() - 0.5)

const bresenhamsLine = function (x1, y1, x2, y2, callback) {
  x1 = Math.round(x1)
  y1 = Math.round(y1)
  x2 = Math.round(x2)
  y2 = Math.round(y2)
  const dx = Math.abs(x2 - x1)
  const dy = Math.abs(y2 - y1)
  const sx = x1 === x2 ? 0 : (x1 < x2 ? 1 : - 1)
  const sy = y1 === y2 ? 0 : (y1 < y2 ? 1 : - 1)
  let e = dx - dy
  while (true) {
    if (callback(x1, y1) === false) {
      return false
    }
    if (x1 === x2 && y1 === y2) {
      return true
    }
    const e2 = e * 2
    if (e2 > - dy) {
      e = e - dy
      x1 = x1 + sx
    }
    if (e2 < dx) {
      e = e + dx
      y1 = y1 + sy
    }
  }
}

main()
</script>

※ ブラウザで開くと描画処理が重いため、数秒固まります。

説明

下描きまでの説明

最初の方は特に説明するまでもないと思います。 canvas 要素と context の取得、 canvas 要素のリサイズ、そして塗り潰しを行っています。 JavaScript + Canvas API で Generative Art を作る際はお約束の流れですね。

下描きの説明

まず、コードの流れは以下の 2 つに分けられます。

  • Canvas API で大体のビジュアル(下描き)を描画する
  • 各ピクセルを起点として線の集合体(疑似ブラシ)を描画する

下描きだけの状態だと以下のようになりますね。
step-1.png
そしてこの状態から各ピクセルの座標と RGB 値を取得し、それらを元に無数の疑似ブラシを描画します。わかりにくいとは思いますが、こうすることでどんな形状のデザインにも擬似ブラシを適用することができるわけです。
ちなみに疑似ブラシの形状をわかりやすくすると以下のようになります。
step-2.png

疑似ブラシの説明

さて、肝心の drawBrush です。が、もう私は疲れたので説明はコードのコメントを読んでください…と言うのは酷なので、せめてもの助けに擬似ブラシの説明図を置いておきますね。
description.png
全然わからないですね。
しかしもっとわからないのは「境界チェック」でしょう。これは言葉より画像の方がわかりやすいと思うので、以下をご覧ください。チェックしない場合の画像です。
step-3.png
要するにブラシが色の境界を超えて描画されるのを防ぐ工夫です。コードでは // 「ある程度」チェックを素通りさせる とある通り、すべてのブラシを境界でストップさせないようにしています。もし完全にストップさせると…
step-4.png
こちらの方が好みの方もいらっしゃるでしょうが、柔らかみがなくなるため、こうした次第です。

派生作品

下描きは簡単に描けるので、シンプルな国旗であれば楽に作れます。というわけで日の丸です。
flag-japan.png
脈絡はありませんがアイルランドの国旗です。
flag-ireland.png

振り返り

振り返ってみると、私は「こんな描き方もあるよ」と言いたかっただけなのかもしれません(遠い目)。ウクライナあまり関係なくて申し訳ない…。
なお、今回説明した「描き方」ですが、記事向けにだいぶ簡略化しています。本当に作品として描くのであれば、 より柔らかなブラシ表現を追求したり写真を元にブラシを描画したり するなど、さらなる工夫を凝らすようになるでしょう。
というわけで、以上です。

※ おまけ:昔の作品たち https://www.instagram.com/mimonelu/

3
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?