✒️

意図的に雑な図形をコードで描く

2023/04/16に公開

描画ライブラリを使える環境ならば、たとえば円を描くのに書くコードは一行で済むだろう。ただ、コード一行でほぼ完全な真円を描いてもなにかつまらない。そこであえて意図的に雑な図形をコードで描きたいと考えて試行錯誤した結果、ある程度満足するところまでいったのでその技法について解説を行う。

意図的に雑な線を描く

まずは雑な線をコードで表現するために、実際に適当な線を何度も描いてみて特徴を探してみる。そうすると下記のような特徴が現れた。

  • 始点と終点の位置が多少バラける
  • 途中から位置が上下にずれているのに気づきそれを元の位置に直そうとする

上記の内容を3次ベジェ曲線程度で表現するために色々と試行錯誤した結果、下記の手順になった。

  1. 始点と終点を用意する
  2. 始点から終点までの20%〜40%の地点に点を置く
  3. 更に上記の倍の地点に点を置く
  4. 4つの点を少しずらす
  5. 4つの点をもとに3次ベジェ曲線を描く

以前、Twitterに解説動画を上げたのでこちらも参考にしてほしい。

https://twitter.com/BaroqueEngine/status/1580529748115353602

TypeScript+p5.jsでの実装例

コードを見る
import p5 from "p5";

new p5((p: p5) => {
  type Point = {
    x: number;
    y: number;
  };

  p.setup = () => {
    p.createCanvas(600, 400);
    p.background("#161616");
    p.noFill();
    p.strokeWeight(4);
    p.stroke(240);

    drawLine({ x: 100, y: 100 }, { x: 400, y: 100 });
  };

  const drawLine = (p0: Point, p1: Point): void => {
    const maxR = 30;
    const t0 = p.random(0.2, 0.4);
    const t1 = t0 * 2;
    const c0 = adJustRandomPoint(lerp2d(p0, p1, t0), maxR);
    const c1 = adJustRandomPoint(lerp2d(p0, p1, t1), maxR);

    const newP0 = adJustRandomPoint(p0, maxR);
    const newP1 = adJustRandomPoint(p1, maxR);

    p.bezier(newP0.x, newP0.y, c0.x, c0.y, c1.x, c1.y, newP1.x, newP1.y);
  };

  const adJustRandomPoint = (point: Point, maxR: number): Point => {
    const rad = p.random(p.TAU);
    const r = p.random(maxR);
    return { x: point.x + p.cos(rad) * r, y: point.y + p.sin(rad) * r };
  };

  const lerp2d = (p0: Point, p1: Point, t: number): Point => {
    return { x: p.lerp(p0.x, p1.x, t), y: p.lerp(p0.y, p1.y, t) };
  };
});

意図的に雑な円を描く

線が描けたので次は雑な円を描きたい。

先ほどと同じようにまずは実際に円を何度も描いてみて、雑になる円の特徴を探すと下記のようになった。

  • 円を描く開始点と終了点が繋がらない
  • (当然ながら)描く円が真円にはならず多少いびつになる
  • いびつになりながらも、なるべく真円になるように常時修正しようとする

これをコードに落とすために色々と試行錯誤した結果、下記の手順でだいたい満足できるレベルになった。

  1. 真円を用意する
  2. 真円の円周上に点を等間隔に配置する
  3. 配置した点を少しランダムにずらす
  4. そのずらした点を通る線を描く

こちらも以前、Twitterに解説動画を上げたのでこちらも参考にしてほしい。

https://twitter.com/BaroqueEngine/status/1582341644938268672

TypeScript+p5.jsでの実装例

コード
import p5 from "p5";

type Point = {
  x: number;
  y: number;
};

new p5((p: p5) => {
  const num = 11;
  let basePoints: Point[];
  let points: Point[];

  p.setup = () => {
    p.createCanvas(800, 600);
    p.background("#161616");
    p.noFill();
    p.strokeWeight(4);
    p.stroke(240);

    createPoints();

    for (let index = 0; index < num; index++) {
      const prev = (index - 1 + points.length) % points.length;
      const cur = index;
      const next = (index + 1) % points.length;
      const nnext = (index + 2) % points.length;
      const p0: Point = points[index];
      const p1: Point = {
        x: points[cur].x + (points[next].x - points[prev].x) / 6,
        y: points[cur].y + (points[next].y - points[prev].y) / 6,
      };
      const p2: Point = {
        x: points[next].x + (points[cur].x - points[nnext].x) / 6,
        y: points[next].y + (points[cur].y - points[nnext].y) / 6,
      };
      const p3: Point = points[next];

      for (let t = 0; t <= 1.0; t += 0.1) {
        const a = lerp2d(p0, p1, t);
        const b = lerp2d(p1, p2, t);
        const c = lerp2d(p2, p3, t);

        const d = lerp2d(a, b, t);
        const e = lerp2d(b, c, t);

        const f = lerp2d(d, e, t);

        p.bezier(p0.x, p0.y, a.x, a.y, d.x, d.y, f.x, f.y);
      }
    }
  };

  const createPoints = (): void => {
    basePoints = [];
    points = [];

    const r = 150;
    const x = p.width / 2;
    const y = p.height / 2;

    for (let i = 0; i < num + 1; i++) {
      const rad = (p.TAU / num) * i - p.TAU / 4;
      const randomRad = i === num ? p.random(-p.TAU / 4, 0) : p.random(p.TAU);
      const randomR = i === num ? p.random(5, 25) : p.random(8);
      const randomX = p.cos(randomRad) * randomR;
      const randomY = p.sin(randomRad) * randomR;
      basePoints.push({ x: x + p.cos(rad) * r, y: y + p.sin(rad) * r });
      points.push({
        x: x + p.cos(rad) * r + randomX,
        y: y + p.sin(rad) * r + randomY,
      });
    }
  };

  const lerp2d = (a: Point, b: Point, t: number): Point => {
    return { x: p.lerp(a.x, b.x, t), y: p.lerp(a.y, b.y, t) };
  };
});

ハッチング処理

せっかく雑な図形を描けるようになったので中をなんとか塗りたい。上記のように図形の内部で一定間隔の線を引いていくことをハッチング処理と呼ぶ。

今まで作成した線や円のベースとなる幾つかの点は、図形の縁を順番に並んでいる点なので、各点を隣の点と結んで辺を作り、ハッチングの各線と交差する辺だけを取り出せばいい。

上記のような複雑な形状の図形だとハッチングの線と交差している点が4つあるが、これを一直線に線を引いてしまうとハートマークの上部の隙間部分にまで線が引かれることになる。

この場合は、交差している4つの点を、[開始点, 終了点, 開始点, 終了点] という風に交互に開始/終了を繰り返し、開始から終了までだけを線を引けば良さそうである。

TypeScript+p5.jsでの実装例

コードを見る
import p5 from "p5";

type Point = {
  x: number;
  y: number;
};

new p5((p: p5) => {
  const num = 31;
  let basePoints: Point[];
  let points: Point[];

  p.setup = () => {
    p.createCanvas(800, 600);

    p.background("#101a23");
    p.noFill();

    createPoints();

    for (let index = 0; index < num; index++) {
      const prev = (index - 1 + points.length) % points.length;
      const cur = index;
      const next = (index + 1) % points.length;
      const nnext = (index + 2) % points.length;
      const p0: Point = points[index];
      const p1: Point = {
        x: points[cur].x + (points[next].x - points[prev].x) / 6,
        y: points[cur].y + (points[next].y - points[prev].y) / 6,
      };
      const p2: Point = {
        x: points[next].x + (points[cur].x - points[nnext].x) / 6,
        y: points[next].y + (points[cur].y - points[nnext].y) / 6,
      };
      const p3: Point = points[next];

      for (let t = 0; t <= 1.0; t += 0.1) {
        const a = lerp2d(p0, p1, t);
        const b = lerp2d(p1, p2, t);
        const c = lerp2d(p2, p3, t);

        const d = lerp2d(a, b, t);
        const e = lerp2d(b, c, t);

        const f = lerp2d(d, e, t);

        p.noFill();
        p.strokeWeight(4);
        p.stroke(240);
        p.bezier(p0.x, p0.y, a.x, a.y, d.x, d.y, f.x, f.y);
      }
    }

    type Edge = {
      a: Point;
      b: Point;
    };

    const edges: Edge[] = [];
    for (let i = 0; i < points.length - 1; i++) {
      const a = points[i];
      const b = points[i + 1];

      if (a.y < b.y) {
        edges.push({ a: points[i], b: points[i + 1] });
      } else {
        edges.push({ a: points[i + 1], b: points[i] });
      }
    }

    for (let i = 0; i < 60; i++) {
      const lineY = 100 + i * 10;
      const collisionEdges: Edge[] = [];
      for (const edge of edges) {
        if (edge.a.y <= lineY && lineY <= edge.b.y) {
          collisionEdges.push(edge);
        }
      }

      if (collisionEdges.length >= 2) {
        collisionEdges.sort((a, b) => {
          return Math.min(a.a.x, a.b.x) - Math.min(b.a.x - b.b.x);
        });

        p.stroke("#f0f0f0");
        p.noFill();
        for (let i = 0; i < collisionEdges.length; i += 2) {
          const a = collisionEdges[i];
          const b = collisionEdges[i + 1];
          p.line(a.a.x, lineY, b.b.x, lineY);
        }
      }
    }
  };

  const createPoints = (): void => {
    basePoints = [];
    points = [];

    const r = 150;
    const x = p.width / 2;
    const y = p.height / 2;

    for (let i = 0; i < num + 1; i++) {
      const rad = (p.TAU / num) * i - p.TAU / 4;
      const randomRad = i === num ? p.random(-p.TAU / 4, 0) : p.random(p.TAU);
      const randomR = i === num ? p.random(5, 25) : p.random(2);
      const randomX = p.cos(randomRad) * randomR;
      const randomY = p.sin(randomRad) * randomR;
      basePoints.push({ x: x + p.cos(rad) * r, y: y + p.sin(rad) * r });
      points.push({
        x: x + p.cos(rad) * r + randomX,
        y: y + p.sin(rad) * r + randomY,
      });
    }
  };

  const lerp2d = (a: Point, b: Point, t: number): Point => {
    return { x: p.lerp(a.x, b.x, t), y: p.lerp(a.y, b.y, t) };
  };
});

Discussion