なんとなく

誰得感満載な記事が多いかも。Mono関係とLinuxのサーバ関係、レビューとか。

ボロノイ図を活用したマスクメロンパターン

マスクメロンの網目模様:ボロノイ図の魔法

レトロゲームを作成していて半径40pixel程度のドット絵のマスクメロンが必要でした。手書きするのもめんどくさいのでAIさんにボロノイ図をつかったマスクメロンの網目模様の作成をお願いしたところうまくいきましたので共有したく備忘的に残しておきます。

ボロノイ図とは?

マスクメロンの網目模様は、その不規則でありながら連続性のあるパターンが特徴です。これを表現するのに最適なのが「ボロノイ図」という数学的な概念です。

ボロノイ図は、平面上にランダムな点をいくつか配置したときに、それぞれの点に最も近い領域を区切ってできる図形のことです。例えば、いくつかの雨粒が同時に地面に落ちて、そこから水たまりが広がっていく様子を想像してみてください。水たまり同士がぶつかった境界線が、まさにボロノイ図の線になります。

Voronoi Diagramの画像

Mysid (SVG), Cyp (original) - Manually vectorized in Inkscape by Mysid, based on Image:Coloured Voronoi 2D.png., CC 表示-継承 3.0, https://commons.wikimedia.org/w/index.php?curid=4290269による

マスクメロンの網目模様は、このボロノイ図の境界線が盛り上がって見える状態に似ています。私たちは、この原理をピクセルアートに応用し、ランダムに配置したシード点(雨粒のようなもの)が作る境界線を「網目」として描画します。

網目模様のアルゴリズム

私たちのアルゴリズムは、以下のステップで網目模様を生成します。

  1. シード点の生成: まず、メロンを描画する円の範囲内に、ランダムな位置に多数の「シード点」を配置します。この点の数が網目の細かさに影響します。
  2. 距離の計算:ピクセル(描画する最小単位の点)について、すべてのシード点までの距離を計算します。
  3. 境界線の検出: 計算した距離の中から、最も近いシード点までの距離2番目に近いシード点までの距離の2つを取り出します。この2つの距離の差が非常に小さい場合、そのピクセルボロノイ図の「境界線」上にあると判断し、網目として描画します。この「差の閾値」が網目の線の太さを決定します。
  4. 描画: 境界線と判断されたピクセルに、網目の色(今回はクリーム#edc7b0)を塗ります。

このプロセスによって、網目は自然に連続し、シード点のランダムな配置によって不均一なセルの大きさが表現されます。交差点では複数のシード点に同時に近くなるため、線が自然と太くなり、「結び目」のような効果が生まれます。

生成コード

以下に、レトロゲーム制作で必要だった半径40ピクセルの円の中にマスクメロンの網目模様のみを描画するJavaScriptコードを示します。このコードを実行すると、ボロノイ図に基づいたユニークな網目模様がキャンバスに生成されます。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>マスクメロンの網目模様(半径40ピクセルの円内)</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        body {
            font-family: "Inter", sans-serif;
            background-color: #f0f0f0;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            padding: 20px;
            box-sizing: border-box;
        }
        .container {
            background-color: #ffffff;
            padding: 20px;
            border-radius: 12px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
            display: flex;
            flex-direction: column;
            align-items: center;
            max-width: 90%;
            width: 400px; /* Adjust as needed */
        }
        canvas {
            image-rendering: optimizeSpeed; /* Older versions of FF */
            image-rendering: -moz-crisp-edges; /* FF 6.0+ */
            image-rendering: -webkit-optimize-contrast; /* Safari, Chrome */
            image-rendering: optimize-contrast; /* CSS3 */
            image-rendering: pixelated; /* Chrome 41+, Opera 26+, Safari 9+ */
            -ms-interpolation-mode: nearest-neighbor; /* IE 7+ */
            border: 2px solid #ccc;
            border-radius: 8px;
            background-color: transparent; /* 背景を透過色に設定 */
            width: 100%; /* Responsive width */
            height: auto; /* Maintain aspect ratio */
            display: block;
            margin-bottom: 20px;
        }
        h1 {
            color: #333;
            margin-bottom: 20px;
            font-size: 1.5rem;
            text-align: center;
        }
        p {
            color: #555;
            text-align: center;
            line-height: 1.6;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>マスクメロンの網目模様(半径40ピクセルの円内)</h1>
        <canvas id="melonCanvas"></canvas>
        <p>
            このドット絵は、JavaScriptでピクセル単位で描画されています。
            半径40ピクセルの円の中に、マスクメロンの網目模様の格子部分のみを表示しています。
        </p>
    </div>

    <script>
        window.onload = function() {
            const canvas = document.getElementById('melonCanvas');
            const ctx = canvas.getContext('2d');

            // 各論理ピクセルは1x1の物理ピクセルとして描画されます (視認性向上のため)
            const PIXEL_SIZE = 1;
            const GRID_SIZE = 104; // ドット絵は104x104の論理ピクセルです

            canvas.width = GRID_SIZE * PIXEL_SIZE;
            canvas.height = GRID_SIZE * PIXEL_SIZE;

            // Define the 16-color palette
            const palette = {
                black:    '#000000',
                darkBlue: '#2b335f',
                purple:   '#7e2072',
                teal:     '#19959c',
                brown:    '#8b4852',
                darkGrey: '#395c98', // Assuming this is dark blue-grey, naming for clarity
                lightBlue: '#a9c1ff',
                lightGrey: '#eeeeee',
                pink:     '#d4186c',
                orange:   '#d38441',
                yellow:   '#e9c35b',
                mint:     '#70c6a9', // Added mint color
                blue:     '#7696de',
                grey:     '#a3a3a3',
                lightPink: '#ff9798',
                cream:    '#edc7b0'  // Added cream color
            };

            // Define colors for the netting (using the new palette)
            const colors = {
                nettingMid: palette.cream, // 網目の色をクリームに設定
                circleBackground: palette.mint // 円の背景色をミントに設定
            };

            // 距離計算関数
            function getDistance(p1, p2) {
                const dx = p1.x - p2.x;
                const dy = p1.y - p2.y;
                return Math.sqrt(dx * dx + dy * dy);
            }

            // 単一のピクセルを描画する関数
            function drawPixel(x, y, color) {
                ctx.fillStyle = color;
                ctx.fillRect(x * PIXEL_SIZE, y * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE);
            }

            // Function to draw only the netting grid within a circle
            function drawNettingGridInCircle() {
                // Clear canvas
                ctx.clearRect(0, 0, canvas.width, canvas.height);

                const centerX = GRID_SIZE / 2;
                const centerY = GRID_SIZE / 2;
                const melonRadius = 40; // Specified radius of the circle

                // Draw the circle background with the specified color
                ctx.beginPath();
                ctx.arc(centerX * PIXEL_SIZE, centerY * PIXEL_SIZE, melonRadius * PIXEL_SIZE, 0, Math.PI * 2);
                ctx.fillStyle = colors.circleBackground; // 円の背景色を設定
                ctx.fill();

                // --- Generate netting pattern using Voronoi diagram ---
                const NUM_SEEDS = 150; // Number of seed points
                const NETTING_WIDTH_THRESHOLD = 0.8; // Thickness of the netting lines

                // Generate seed points (randomly placed within the specified circle)
                const seedPoints = [];
                for (let i = 0; i < NUM_SEEDS; i++) {
                    let seedX, seedY;
                    let validSeed = false;
                    // Regenerate until the seed point falls within the melon's circular area
                    while (!validSeed) {
                        // Generate random coordinates within the grid bounds
                        seedX = Math.random() * GRID_SIZE;
                        seedY = Math.random() * GRID_SIZE;
                        const dx = seedX - centerX;
                        const dy = seedY - centerY;
                        // Check if the seed point is within the melonRadius circle
                        if (Math.sqrt(dx * dx + dy * dy) < melonRadius) {
                            validSeed = true;
                        }
                    }
                    seedPoints.push({ x: seedX, y: seedY });
                }

                // Iterate through each pixel and draw based on Voronoi boundaries
                for (let y = 0; y < GRID_SIZE; y++) {
                    for (let x = 0; x < GRID_SIZE; x++) {
                        const dx = x - centerX;
                        const dy = y - centerY;
                        const distFromCenter = Math.sqrt(dx * dx + dy * dy);

                        if (distFromCenter < melonRadius) { // Only draw within the specified circle
                            let minDist1 = Infinity; // Distance to the closest seed point
                            let minDist2 = Infinity; // Distance to the second closest seed point

                            for (let i = 0; i < NUM_SEEDS; i++) {
                                const currentDist = getDistance({ x: x, y: y }, seedPoints[i]);
                                if (currentDist < minDist1) {
                                    minDist2 = minDist1;
                                    minDist1 = currentDist;
                                } else if (currentDist < minDist2) {
                                    minDist2 = currentDist;
                                }
                            }

                            // If the difference between the two closest distances is small, it's a boundary line
                            if ((minDist2 - minDist1) < NETTING_WIDTH_THRESHOLD) {
                                drawPixel(x, y, colors.nettingMid); // Draw netting in the specified color
                            }
                        }
                    }
                }
            }

            drawNettingGridInCircle(); // Initial draw of only the netting grid within the circle
        };
    </script>
</body>
</html>

生成される網目模様

上記のコードを実行すると、以下のようなマスクメロンの網目模様が生成されます。シード点の配置は毎回ランダムなので、生成される模様は実行ごとに少しずつ異なります。

ボロノイ図のメロン

まとめ

ボロノイ図を利用することで、手書きでは表現が難しいマスクメロンの複雑で有機的な網目模様を、プログラマティックに生成することができました。NUM_SEEDSやNETTING_WIDTH_THRESHOLDといったパラメータを調整することで、網目の細かさや太さを自由に変えることが可能です。

この技術を応用すれば、様々なフルーツや自然物のパターン生成に応用できるかもしれませんね。