GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

제너레이티브 아트 9종 — 랜덤 패턴 만들기에서 자라는 그림까지

씨앗 숫자 하나만 같으면 몇 번을 다시 그려도 같은 그림이 나온다. 02번은 흩어진 끌개 점을 향해 잎맥을 뻗고, 03번은 선이 늘어날 때마다 마디를 끼워 주름을 접으며, 06번은 이웃 칸과 변이 맞는 타일만 남긴다.

씨앗 하나에서 아홉 장의 그림이 자라나는 캔버스를 그대로 지켜보기

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

칸마다 빈 판에서 200ms 를 쉰 뒤 자라기 시작하고, 다 자라면 900ms(09 는 1,400ms) 머물렀다가 다음 씨앗으로 넘어간다. 캔버스를 누르거나 버튼을 누르거나 캔버스에서 Enter·R·Space 를 누르면 01~08 은 지금 씨앗이 끝까지 자란 자리에서 멈추고, 09 만은 손을 대도 다음 씨앗으로 계속 이어 간다.

제너레이티브 아트 9종 — 흐름 선·잎맥·주름 선·원 채우기·직각 금·타일 맞추기·나이테·이끼 번짐·자라는 히어로

01~03 은 선이 자라는 규칙을 다룬다. 다른 선과의 간격이 선을 세우고(01), 끌개 점이 가지 방향을 정하며(02), 늘어나는 마디가 선을 접는다(03). 04~06 은 면을 채우는 규칙으로 부푸는 원·직각으로 갈라지는 금·변을 맞추는 타일이고, 07·08 은 나이테와 이끼처럼 시간이 겹겹이 쌓이는 자연물, 09 는 01·02·04 의 계산을 씨앗 하나로 겹친 첫 화면용 장면이다. 아홉 장 모두 480×300 좌표의 <canvas> 2D 에 그리고, 움직임은 requestAnimationFrame 시각을 모아 16ms 고정 틱을 한 프레임에 최대 6번 푸는 루프가 맡는다. 스타일시트에 @keyframes 는 0개이고 transition 은 버튼 배경색 하나뿐이다.

아홉 장면이 렌더 캡처에서 움직인 양

헤드리스 브라우저로 2초 동안 24장을 찍어, 앞뒤 장 사이 변화 23번 가운데 실제로 화소가 바뀐 횟수를 프레임 칸에 적었다.

장면 누적면적 움직인 프레임 강도 평균
01 간격 제한 흐름 선 28.0% 17/23 97.0
02 끌개 점 잎맥 9.8% 16/23 60.2
03 마디 끼우기 주름 선 15.5% 23/23 81.9
04 부푸는 원 채우기 44.2% 23/23 92.8
05 직각 금 42.4% 17/23 56.6
06 이웃 변 맞춤 타일 50.6% 23/23 83.0
07 나이테 35.8% 18/23 138.6
08 가장자리 이끼 75.5% 21/23 112.3
09 세 겹 히어로 39.0% 23/23 73.4

10% 아래는 02 하나다. 2.6px 짜리 가는 마디가 잎 모양 안에서만, 그것도 틱마다 한 차례(ITER_PER_FRAME 1)씩 늘어나서 바뀌는 화소가 적은 것으로 보인다. 08 이 75.5% 로 가장 넓은 까닭은 4px 칸을 틱당 60칸씩 판 전체 9,000칸에 칠하기 때문으로 읽힌다. 05 는 100틱, 곧 1,600ms 만에 다 자라 머무는 구간이 캡처 안에 들어와 17장에 그친 것으로 보인다.

자라는 그림을 깔기 좋은 화면

랜딩 페이지 첫 화면의 배경이라면 09 를 그대로 가져다 쓰면 되고, 글자가 많은 화면 뒤에는 결이 고른 01 이 덜 시끄럽다. 같은 씨앗은 같은 그림을 내므로 글 번호나 상품 번호를 씨앗으로 넣으면, 글마다 다르면서 다시 열어도 바뀌지 않는 머리 그림을 이미지 파일 없이 만들 수 있다.

기다리는 화면에는 진행이 눈에 보이는 장면이 맞는다. 08 은 군락이 번져 판을 채우고, 06 은 안개가 걷히듯 칸이 정해지며 가끔 한 번 무너졌다 다시 찬다. 식물이나 친환경 브랜드라면 02 의 잎맥과 07 의 나이테, 종이나 석고 같은 질감이 필요한 포트폴리오·404 화면이라면 05 의 금, 행사 배너처럼 빈틈없이 채운 장식이 필요하면 04 의 원이 어울린다.

랜덤 패턴 제너레이터처럼 무작위만 섞으면 왜 그림이 되지 않는가?

난수 점을 화면에 고르게 뿌리기만 하면 어느 자리를 봐도 밀도가 같아서, 눈이 따라갈 결이나 덩어리가 생기지 않는다. 이 편의 아홉 장에서 난수가 하는 일은 출발점을 고르거나 후보 가운데 하나를 집는 데까지이고, 모양을 정하는 것은 언제 멈추는지를 적은 규칙이다. 01 의 선은 다른 선의 점이 4px 안에 들어오면 서고, 04 의 원은 이웃과의 틈이 2px 으로 줄면 굳으며, 05 의 금은 각도가 다른 금에 닿는 순간 끝난다.

생성 예술이라고 부를 만한 짜임이 어디서 나오는지는 버려지는 쪽을 보면 드러난다. 04 는 자리 뽑기 30,588번 가운데 29,021번(94.9%)을 버리고 남은 자리에만 원을 심었고, 01 은 다 자란 그림에서 서로 다른 선 사이 가장 가까운 거리가 정확히 4.00px, 곧 LINE_SEPARATION 8 에 SEP_TEST 0.5 를 곱한 값이었다. 난수가 아무리 흔들어도 규칙이 정한 거리 아래로는 한 점도 내려가지 않았다는 뜻이다.

그래서 랜덤 패턴 만들기를 시작할 때 먼저 정할 것은 어떤 난수를 쓸지보다 무엇이 성장을 멈추게 하는지다. 아홉 장의 멈춤 조건은 거리(01·04), 지워지는 끌개(02), 마디 수 상한(03), 다른 각도(05), 정할 칸이 남지 않음(06), 반지름 상한(07), 남은 빈칸 없음(08)으로 저마다 다르고, 그 차이가 그대로 그림의 차이가 된다.

같은 씨앗이 같은 그림을 내는 의사 난수와, 자라는 선이 이웃을 빨리 찾는 격자 칸

아홉 소스 어디에도 Math.random·Date.now·new Date 가 없다. 난수는 모두 아래 makeRng 한 함수에서 나오는데, 32비트 정수를 Math.imul 과 >>> 로 섞어 0 이상 1 미만의 수를 내는 수열이라 같은 씨앗을 넣으면 같은 순서의 수가 다시 나온다.

    // 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
    function makeRng(seedValue) {
        let s = seedValue >>> 0;
        return function () {
            s = (s + 0x6d2b79f5) >>> 0;
            let t = s;
            t = Math.imul(t ^ (t >>> 15), t | 1);
            t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
        };
    }
    // 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
    function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }

「다시 그리기」 버튼과 Enter 는 nextSeed 로 다음 다섯 자리 씨앗을 만들고, 「같은 씨앗」 버튼과 R 키는 지금 씨앗으로 판을 처음부터 다시 키운다. 01 을 47101 → 13161 → 47101 순서로 키워 점 좌표 전체의 해시를 떠 보면 1433911356 → 239204140 → 1433911356 이 나와, 같은 씨앗은 같은 그림을, 다른 씨앗은 다른 그림을 낸다는 것이 숫자로 확인된다. 성장이 프레임이 아니라 16ms 틱 단위로 나뉘고 틱마다 하는 일의 양(01 STEPS_PER_FRAME 70, 06 COLLAPSE_PER_FRAME 2 등)이 상수로 정해져 있어서, 주사율이 다른 화면에서도 같은 순서로 자라는 것으로 보인다.

이웃을 찾는 쪽은 격자 버킷이 맡는다. 화면을 일정한 칸(01 은 8px, 02 는 60px, 03 은 12px, 04 는 34px)으로 나눠 점마다 제 칸에 넣어 두고, 거리 검사는 주변 칸에 든 점끼리만 한다. 01 은 다 자랐을 때 점이 8,386개라, 걸음마다 모든 점과 비교했다면 한 걸음에 8천 번이 넘는 계산을 했을 것이다.

01다른 선과 4px 안으로 붙지 않는 간격 제한 흐름 선

12px 칸 꼭짓점마다 노이즈로 방향을 한 번만 정해 두고, 선은 반걸음 앞의 방향을 보고 2px 씩 앞뒤로 걷다가 다른 선의 점이 4px 안에 들어오면 멈춘다. 끝난 선의 양옆 8px 자리가 다음 선의 출발 후보로 줄을 서서 화면이 고른 간격의 결로 차오르므로, 등고선이나 바람 흐름 같은 결을 깔고 싶은 소개 페이지 배경에 어울린다. 씨앗 47101 은 135틱에 선 155개·총 16,462px 로 다 자랐고, 캡처에서는 누적면적 28.0%·강도 97.0·17/23 프레임이 나왔다.

서로 간격을 지키며 흐름장을 따라 자라는 선
새 창에서 열기

01 · 서로 간격을 지키며 흐름장을 따라 자라는 선 — 측정 누적면적 28.044% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLOW_NOISE_SCALELINE_SEPARATIONSTEP_LENGTH
    // (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 길이 안)은 세지 않는다
    function flowTooClose(f, x, y, d, line, ord) {
        const skip = Math.ceil((f.o.sep * 2) / f.o.step);
        const d2 = d * d;
        return bucketNear(f.grid, x, y, d, function (id) {
            if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
                return false;
            }
            const dx = f.px[id] - x;
            const dy = f.py[id] - y;
            return dx * dx + dy * dy < d2;
        });
    }
            // 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
            b -= 1;
            const h = f.o.step * f.dir;
            const d1 = flowDir(f, f.x, f.y);
            const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
            const nx = f.x + d2[0] * h;
            const ny = f.y + d2[1] * h;
            const nord = f.ord + f.dir;
            if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
                // 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 선 하나 끝
                if (f.dir === 1) {
                    f.dir = -1;
                    f.ord = 0;
                    f.x = f.sx;
                    f.y = f.sy;
                }
                else {
                    flowEndLine(f);
                }
                continue;
            }

02흩어진 끌개 점 900개를 향해 가지를 뻗는 잎맥

잎 모양 안에 끌개 900개를 시드 난수로 뿌린 다음, 끌개마다 반경 60px 안에서 가장 가까운 마디를 하나 골라 그 마디가 끌개들 쪽 평균 방향으로 2.6px 새 마디를 내게 하고, 새 마디 6px 안에 들어온 끌개는 지운다. 가지 굵기는 마디마다 센 후손 수의 제곱근을 따라 줄기에서 잎 가장자리로 갈수록 가늘어지고, 자라는 모습 자체가 이야기가 되는 식물·친환경 브랜드 소개 화면에 맞다. 씨앗 47102 는 103틱에 끌개를 모두 지우고 마디 794개·가지 끝 71개로 멈췄으며, 누적면적 9.8%·강도 60.2·16/23 프레임으로 아홉 장 가운데 유일하게 10% 아래였다.

흩어진 끌개 점을 향해 뻗어 나가는 잎맥
새 창에서 열기

02 · 흩어진 끌개 점을 향해 뻗어 나가는 잎맥 — 측정 누적면적 9.836% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ATTRACTOR_COUNTINFLUENCE_RADIUSKILL_DISTANCE
            for (let i = 0; i < n; i += 1) {
                if (!cnt[i]) {
                    continue;
                }
                let dx = sx[i];
                let dy = sy[i];
                let L = Math.hypot(dx, dy);
                // 끌개들이 서로 반대쪽에서 맞당겨 평균이 거의 0 이면 그 마디는 이번에 자라지 않는다
                if (L < 0.2) {
                    continue;
                }
                // 아주 작은 흔들림 — 같은 자리에 마디가 겹쳐 쌓이지 않게 대칭을 깬다
                dx = dx / L + (v.rng() - 0.5) * 0.12;
                dy = dy / L + (v.rng() - 0.5) * 0.12;
                L = Math.hypot(dx, dy) || 1;
                veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
            }
        }
        const leftBefore = v.left;
        veinKill(v, n);
        // 지워지는 끌개 없이 VEIN_STALL_LIMIT 번 연달아 자라기만 하면 멈춘다 — 닿지 못할 끌개를 쫓아 맴돌지 않게
        v.stall = v.left === leftBefore ? v.stall + 1 : 0;
        if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) {
            v.done = true;
        }

03늘어날 때마다 마디를 끼워 접히는 주름 선

닫힌 선의 마디 하나하나가 양옆 가운데로 끌리고, 이웃 마디와 4px 를 지키려는 용수철에 묶이며, 양옆이 아닌 다른 마디가 반경 12px 안에 오면 밀어낸다. 5px 보다 길어진 변과 시드 난수로 고른 변에 틱마다 마디를 끼워 넣으면 타원 울타리 안에서 늘어날 자리가 없는 선이 접혀 산호나 뇌 주름 같은 무늬가 되므로, 유기적인 질감이 필요한 과학·의료 콘텐츠의 머리 그림에 쓸 만하다. 씨앗 47103 은 214틱에 마디 상한 2,600개에 닿았고 둘레는 5,611px 였으며, 렌더 값은 누적면적 15.5%·강도 81.9·23/23 프레임이다.

마디를 끼워 넣으며 주름지게 자라는 선
새 창에서 열기

03 · 마디를 끼워 넣으며 주름지게 자라는 선 — 측정 누적면적 15.466% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REPEL_RADIUSMAX_EDGE_LENGTHALIGN_FORCE
            for (let k = 0; k < 2; k += 1) {
                const j = nbs[k];
                const dx = xs[j] - x;
                const dy = ys[j] - y;
                const d = Math.hypot(dx, dy) || 1e-6;
                const pull = ((d - SPRING_REST) / d) * SPRING_FORCE * 0.5;
                fx += dx * pull;
                fy += dy * pull;
            }
            bucketNear(grid, x, y, R, function (j) {
                if (j === i || j === pv || j === nt) {
                    return false;
                }
                const dx = x - xs[j];
                const dy = y - ys[j];
                const d2 = dx * dx + dy * dy;
                if (d2 >= R * R || d2 < 1e-9) {
                    return false;
                }
                const d = Math.sqrt(d2);
                const push = ((R - d) / R) * REPEL_FORCE;
                fx += (dx / d) * push;
                fy += (dy / d) * push;
                return false;
            });
    // 긴 변 나누기 — 시작 자리를 틱마다 옮겨 가며 MAX_EDGE_LENGTH 보다 긴 변 가운데에 마디를 끼운다
    function splitEdges() {
        let made = 0;
        const n0 = xs.length;
        let i = splitFrom % n0;
        for (let k = 0; k < n0 && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; k += 1) {
            const j = (i + 1) % xs.length;
            const dx = xs[j] - xs[i];
            const dy = ys[j] - ys[i];
            if (dx * dx + dy * dy > MAX_EDGE_LENGTH * MAX_EDGE_LENGTH) {
                // 가운데에서 아주 조금 옆으로 — 대칭이 깨져야 주름이 접힌다
                const jit = (lrng() - 0.5) * 0.4;
                xs.splice(i + 1, 0, (xs[i] + xs[j]) * 0.5 - dy * jit * 0.1);
                ys.splice(i + 1, 0, (ys[i] + ys[j]) * 0.5 + dx * jit * 0.1);
                made += 1;
                i += 1;
            }
            i = (i + 1) % xs.length;
        }
        splitFrom = i + 37;

04틈 2px 에 닿을 때까지 부푸는 원 채우기

틱마다 무작위 자리 60곳을 뽑아 다른 원·테두리와 틈 2px 이 남는 곳에만 반지름 1px 원을 심고, 심은 원은 틱마다 0.8px 씩 키우다가 자리가 막히는 순간 그 원만 그림판에 굳힌다. 큰 원 사이 빈틈을 작은 원이 메워 가는 과정이 그대로 보여 행사 포스터나 이벤트 배너처럼 화면을 빈틈없이 채우는 장식에 좋다. 씨앗 47104 는 565틱 동안 원 1,567개로 화면의 46.1% 를 덮었고, 캡처 값은 누적면적 44.2%·강도 92.8·23/23 프레임이었다.

서로 닿을 때까지 부푸는 원 채우기
새 창에서 열기

04 · 서로 닿을 때까지 부푸는 원 채우기 — 측정 누적면적 44.177% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GROW_RATESPAWN_ATTEMPTSCIRCLE_GAP
    // 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 원(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
    function packFree(p, x, y, r, self) {
        const g = p.o.gap;
        if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
            return false;
        }
        const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
            if (j === self) {
                return false;
            }
            const dx = p.cx[j] - x;
            const dy = p.cy[j] - y;
            const lim = r + p.cr[j] + g;
            return dx * dx + dy * dy < lim * lim;
        });
        if (hit) {
            return false;
        }
        return !p.o.blocked(x, y, r + g);
    }
        // 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
        const next = [];
        for (let k = 0; k < p.growing.length; k += 1) {
            const i = p.growing[k];
            const nr = p.cr[i] + p.o.grow;
            if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
                p.cr[i] = nr;
                next.push(i);
            }
            else {
                onStop(i);
            }
        }
        p.growing = next;
        if (p.fails >= p.o.failTicks && p.growing.length === 0) {
            p.done = true;
        }
        return p.done;

05다른 각도의 금에 닿으면 멈추는 직각 금

화소마다 지나간 금의 각도를 적는 판을 두고, 금은 0.5px 씩 걸으며 빈 화소나 각도 차 5° 안의 화소면 계속 나아가고 다른 각도를 만나면 멈추며, 멈춘 금 하나마다 이미 그은 금 위 한 점에서 직각에 4° 안팎의 흔들림을 더한 방향으로 새 금 둘이 난다. 금의 옆쪽으로 다음 금까지 거리를 재어 그 안에 옅은 모래 알갱이 10개를 겹쳐 찍기 때문에 금 사이에 결이 생기고, 종이나 석고 질감이 어울리는 포트폴리오 배경에 맞다. 씨앗 47105 는 100틱에 금 상한 320개를 채우고 각도가 적힌 화소 14,337개를 남겼으며, 측정값은 누적면적 42.4%·강도 56.6·17/23 프레임이다.

직각으로 갈라지며 번져 가는 금
새 창에서 열기

05 · 직각으로 갈라지며 번져 가는 금 — 측정 누적면적 42.388% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CRACK_MAXBRANCH_ANGLE_JITTERSAND_GRAINS
    // 법선 방향 모래 — 금에서 옆으로 빈칸을 따라가 다른 금(또는 테두리)까지의 거리를 재고, 그 안쪽에 옅은 알갱이를 겹쳐 찍는다
    function paintSand(c) {
        const nxv = Math.cos((c.t + 90) * DEG);
        const nyv = Math.sin((c.t + 90) * DEG);
        let d = 1;
        for (; d < SAND_REACH; d += 1) {
            const px = Math.floor(c.x + nxv * d);
            const py = Math.floor(c.y + nyv * d);
            if (px < 0 || px >= W || py < 0 || py >= H || cgrid[py * W + px] < EMPTY) {
                break;
            }
        }
        c.g = clamp(c.g + (crng() - 0.5) * 0.1, 0.05, 1);
        for (let i = 0; i < SAND_GRAINS; i += 1) {
            const k = (d * c.g * i) / SAND_GRAINS;
            actx.fillStyle = c.sand[i];
            actx.fillRect(c.x + nxv * k, c.y + nyv * k, 1, 1);
        }
    }
    function moveCrack(c) {
        c.x += CRACK_STEP * Math.cos(c.t * DEG);
        c.y += CRACK_STEP * Math.sin(c.t * DEG);
        c.steps += 1;
        const cx = Math.floor(c.x + (crng() - 0.5) * 0.66);
        const cy = Math.floor(c.y + (crng() - 0.5) * 0.66);
        if (cx < 0 || cx >= W || cy < 0 || cy >= H) {
            c.alive = false;
            return;
        }
        const i = cy * W + cx;
        const g = cgrid[i];
        if (g >= EMPTY || Math.abs(g - c.t) < ANGLE_TOLERANCE) {
            if (g >= EMPTY) {
                marks.push(i);
            }
            cgrid[i] = c.t;
            paintSand(c);
            actx.fillStyle = rgba(INK, 0.85);
            actx.fillRect(c.x, c.y, 1, 1);
        }
        else if (c.steps > START_GRACE) {
            // 각도가 다른 금에 닿았다 — 여기서 멈춘다
            c.alive = false;
        }

06이웃 칸과 변이 맞는 타일만 남기는 타일 맞추기

24×15 칸마다 풀밭·길·물길 변을 가진 타일 18종을 모두 후보로 두고, 후보가 가장 적은 칸 하나를 가중치 난수로 정한 뒤 그 결정이 이웃 칸의 후보를 깎는 일을 큐로 끝까지 퍼뜨린다. 정해지지 않은 칸은 남은 후보 비율만큼 짙은 안개로 그려 제약이 번지는 모습이 보이고, 지도형 게임 화면이나 길 찾기 서비스의 대기 화면에 어울린다. 시연 씨앗 66110 은 912ms 에 후보가 0 인 칸이 생겨 판을 한 번 처음부터 다시 채웠고 3,664ms 에 360칸을 다 채웠으며, 렌더 값은 누적면적 50.6%·강도 83.0·23/23 프레임이었다.

이웃 칸과 맞는 것만 남기며 채워지는 타일
새 창에서 열기

06 · 이웃 칸과 맞는 것만 남기며 채워지는 타일 — 측정 누적면적 50.553% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TILE_EDGESTILE_WEIGHTSCOLLAPSE_PER_FRAME
                const j = nr * COLS + nc;
                if (fixedTile[j] >= 0) {
                    continue;
                }
                // 이 칸 후보들이 방향 d 쪽에 내놓는 변 종류 — 그 종류와 맞는 타일만 이웃에 남는다
                let allow = 0;
                for (let t = 0; t < NT; t += 1) {
                    if (cand[i] & (1 << t)) {
                        allow |= FIT[d][edgeOf(t, d)];
                    }
                }
                const nm = cand[j] & allow;
                if (nm === cand[j]) {
                    continue;
                }
                if (nm === 0) {
                    return false;
                }
                cand[j] = nm;
                if (bits(nm) === 1) {
                    fix(j, 31 - Math.clz32(nm));
                }
                queue.push(j);
    // 후보가 가장 적은 칸(같으면 씨앗 난수로 정한 순서) 하나를 가중치 난수로 고른다
    function collapseOne() {
        let best = -1;
        let bestScore = 1e9;
        for (let i = 0; i < cand.length; i += 1) {
            if (fixedTile[i] >= 0) {
                continue;
            }
            const s = bits(cand[i]) + tie[i];
            if (s < bestScore) {
                bestScore = s;
                best = i;
            }
        }
        if (best < 0) {
            return true;
        }

07한 바퀴를 돌아도 이음매가 없는 나이테

해마다 폭이 다른 고리를 반지름 138px 까지 쌓고, 고리의 각도를 노이즈 공간의 원 위 좌표로 바꿔 읽어 반지름을 ±8% 안에서 흔들며, 고리마다 그 원을 0.07 씩 옮겨 이웃 고리끼리 닮게 한다. 넓은 해는 밝게, 좁은 해는 진하고 굵은 테로 칠하고 새 고리는 5틱 동안 부풀어 오르므로 나무·가구 브랜드나 연혁 소개 화면에 잘 맞는다. 씨앗 47107 은 120틱에 고리 24개를 다 그렸고 0 과 2π 지점의 반지름 차는 0px 였으며, 강도 138.6 으로 아홉 장 중 가장 높고 누적면적 35.8%·18/23 프레임이다.

노이즈로 일렁이며 한 겹씩 늘어나는 나이테
새 창에서 열기

07 · 노이즈로 일렁이며 한 겹씩 늘어나는 나이테 — 측정 누적면적 35.837% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RING_SPACINGRING_NOISE_AMPRING_SEGMENTS
    // k 번째 고리의 각도 a 에서의 반지름 — 각도를 노이즈 공간의 원 위 좌표로 바꿔 읽어, 한 바퀴 돌면 같은 값으로 닫힌다
    function ringR(k, a, base) {
        const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
        const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
        return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
    }
    function ringPoints(k, base) {
        const out = new Float64Array(RING_SEGMENTS * 2);
        for (let j = 0; j < RING_SEGMENTS; j += 1) {
            const a = (j / RING_SEGMENTS) * TAU;
            const r = ringR(k, a, base);
            out[j * 2] = TRUNK_CX + Math.cos(a) * r;
            out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
        }
        return out;
    }

08군락마다 가장자리 칸을 하나씩 차지하는 이끼 번짐

4px 칸 9,000개 판에 군락 7개를 심고, 군락을 차례로 돌며 제 가장자리 목록에서 한 칸을 시드 난수로 꺼내 비어 있으면 차지하는데, 꺼낸 자리는 목록 맨 끝 칸과 바꿔 빼서 목록이 길어져도 칸 수에 비례해 느려지는 일을 피한다. 먼저 난 칸은 짙게 늦게 난 칸은 밝게 칠하고 다른 군락과 맞닿은 변에는 짙은 1px 선을 그어, 지역별 구역이나 팀 영역을 나누는 대시보드 배경 같은 자리에 맞다. 씨앗 47108 은 150틱에 9,000칸을 다 채우고 경계 732곳을 남겼으며, 누적면적 75.5% 로 아홉 장 중 가장 넓고 강도 112.3·21/23 프레임이다.

가장자리 칸이 하나씩 붙으며 번지는 이끼 군락
새 창에서 열기

08 · 가장자리 칸이 하나씩 붙으며 번지는 이끼 군락 — 측정 누적면적 75.494% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

COLONY_COUNTCELLS_PER_FRAMEAGE_FADE
    function claim(i, c) {
        owner[i] = c;
        const x0 = (i % COLS) * CELL;
        const y0 = Math.floor(i / COLS) * CELL;
        // 태어난 순서로 색을 옅게 — 먼저 난 가운데는 짙고 늦게 난 가장자리는 밝다
        actx.fillStyle = rgba(mix(colonyDeep(c), colonyLight(c), (AGE_FADE * born) / TOTAL), 1);
        actx.fillRect(x0, y0, CELL, CELL);
        born += 1;
        fresh.push(i);
        const col = i % COLS;
        const row = Math.floor(i / COLS);
        const nb = [col > 0 ? i - 1 : -1, col < COLS - 1 ? i + 1 : -1, row > 0 ? i - COLS : -1, row < ROWS - 1 ? i + COLS : -1];
        for (let k = 0; k < 4; k += 1) {
            const j = nb[k];
            if (j < 0) {
                continue;
            }
            if (owner[j] < 0) {
                if (!(inFront[j] & (1 << c))) {
                    inFront[j] |= 1 << c;
                    fronts[c].push(j);
                }
            }

09한 씨앗으로 흐름 선·잎맥·원 세 겹을 키우는 히어로

씨앗 하나를 갈래 번호와 섞어 흐름 선·잎맥·원·노이즈가 각자 다른 난수열을 받게 하고, 흐름 선은 0ms·잎맥은 480ms·원은 960ms 부터 차례로 자라며 원은 잎맥 마디 2px 안을 피해 자리를 잡는다. 셋이 다 자라면 1,400ms 머문 뒤 지금 장면을 따로 떠 두고 다음 씨앗 장면 위에 700ms 동안 옅게 겹쳐 넘어가므로, 랜딩 첫 화면 히어로 배경으로 바로 붙일 수 있게 만든 판이다. 시연 한 바퀴(47109 → 76513)는 909틱·14,544ms 였고, 캡처에서는 누적면적 39.0%·강도 73.4·23/23 프레임이 나왔다.

씨앗 하나로 세 겹이 자라나는 히어로 배경
새 창에서 열기

09 · 씨앗 하나로 세 겹이 자라나는 히어로 배경 — 측정 누적면적 39.016% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HERO_SEEDLAYER_STAGGER_MSHOLD_MS
    // 같은 씨앗에서 갈래(lane)마다 다른 난수열 — 세 겹이 같은 수를 나눠 쓰지 않아 서로 무늬가 겹치지 않는다
    function laneRng(base, lane) { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
    const VEIN_CX = 316;
    const VEIN_CY = 124;
    function insideCrown(x, y) {
        const dx = (x - VEIN_CX) / 150;
        const dy = (y - VEIN_CY) / 100;
        return dx * dx + dy * dy < 1;
    }
    function buildFlow(s) {
        return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
    }
    function grow() {
        fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
        if (!fl.done) {
            flowStep(fl, HERO_FLOW_STEPS, flowSeg);
        }
        if (age >= LAYER_STAGGER_MS && !vn.done) {
            veinStep(vn);
        }
        if (age >= LAYER_STAGGER_MS * 2 && !pk.done) {
            packStep(pk, function () { });
        }
        const all = fl.done && vn.done && pk.done;
        if (!all) {
            return false;
        }

점이 수만 개로 불어날 때·씨앗을 바꿔도 그림이 비슷할 때·화면 크기가 바뀔 때 — 자라는 패턴이 깨지는 자리

이 절의 틱 수와 개수는 vanilla index.html 의 함수를 노드에서 그대로 불러 다시 잰 값이고, 「처음 판」 의 수치는 만드는 도중 이전 판을 같은 방식으로 잰 것이다. ms 로 적은 계산 시간은 이 PC 의 노드 기준이라 기기마다 다르니, 비교는 틱 수로 하는 편이 정확하다.

점이 늘어날수록 이웃 찾기가 일을 잡아먹는다

01 은 점 8,386개, 04 는 원 1,500개 안팎, 03 은 마디 2,600개까지 쌓인다. 격자 없이 모든 점과 견주면 04 는 틱마다 (자리 뽑기 60번 + 자라는 원 수) × 1,500 번을 비교하게 되는 셈이라, 수가 늘수록 화면이 버벅일 것으로 보인다. 03 은 마디 상한이 없으면 틱당 일이 마디 수에 비례해 커지는데, 상한 2,600개에서 노드로 잰 가장 무거운 틱이 2.0ms 였다. 08 은 가장자리 목록 가운데 칸을 splice 로 빼면 칸 수만큼 뒤를 당겨야 해서, 맨 끝 칸과 자리를 바꿔 빼는 쪽을 택했다.

첫 걸음에서 제 몸에 막히는 흐름 선

01 이 같은 선의 바로 앞 점까지 거리 검사에 넣으면 첫 걸음부터 자기 점이 4px 안에 있어 선이 서 버린다. 그래서 flowTooClose 는 같은 선에서 앞뒤 8걸음(간격의 두 배 길이) 안의 점을 세지 않는다. 소용돌이 한가운데에서는 선이 끝없이 맴돌 수 있어 점 700개(MAX_LINE_POINTS)에서 끊는다.

맞당기는 끌개에 붙잡힌 잎맥

02 를 처음 만들 때는 평균 방향이 0 에 가까운 마디도 정규화해 그대로 키웠는데, 씨앗 47102·27672 가 끌개 2개를 끝내 지우지 못한 채 MAX_ITER 400 까지 돌며 마디를 1,102개까지 늘렸다. 반대쪽 끌개 둘이 한 마디를 서로 당겨 제자리를 맴돈 것으로 보인다. 평균 길이 0.2 아래인 마디는 그 틱에 쉬게 하고, 끌개가 하나도 지워지지 않는 틱이 40번 이어지면 멈추게 하자 103틱에 끝났다. 지우는 거리 6px 을 마디 길이 2.6px 보다 크게 둔 것도, 새 마디가 끌개를 건너뛰어 앞뒤로 오가지 않게 하려는 값으로 보인다.

밀기와 나누기만으로는 자라지 않는 주름 선

03 의 첫 판은 긴 변 나누기와 밀어내기만 있었고, 8,000틱을 돌려도 마디 47개에서 더 늘지 않았다. 양옆 마디를 미는 힘이 선을 따라가는 방향이라 서로 상쇄된 것으로 보인다. 이웃과 4px 를 지키려는 용수철과 시드 난수로 고른 변에 끼우는 성장 마디를 더한 뒤에야 선이 늘었다. splice 로 끼우면 그 뒤 마디 번호가 한 칸씩 밀리므로, 끼운 직후 i 를 한 칸 더 건너뛴다.

끝 판정이 늦으면 거의 같은 그림을 오래 기다린다

04 는 연달아 원을 하나도 못 심은 틱이 몇 번 이어져야 끝내는지를 FAIL_TICKS 로 정한다. 이 값을 24 로 두었을 때는 1,070틱이 걸렸고, 8 로 줄이자 565틱에 끝났다. 그 사이 남은 빈자리는 반지름 1~2px 짜리뿐이라 그림 차이는 거의 없다. 두 원이 같은 틱에 자라도 뒤에 보는 원이 앞 원의 이미 커진 반지름으로 판정하므로 겹치지 않는다.

출발점을 찾지 못한 금

05 의 처음 판은 새 금의 출발점을 무작위 화소를 1,200번 찍어 찾게 했는데, 각도가 적힌 화소가 12개뿐인 시작 무렵에 한 번도 맞히지 못해 금이 0개로 끝났다. 지금은 각도가 적힌 화소 목록 marks 에서 바로 고른다. 출발 뒤 4걸음은 유예를 두어 부모 금의 칸에서 곧바로 멈추지 않게 했다. 각도 차는 360° 로 감싸지 않으므로 같은 방향이라도 −90° 와 270° 는 서로 다른 금으로 친다는 한계가 소스에 그대로 남아 있다.

다리 타일을 넣으면 무너지지 않는 타일 판

06 에는 길과 물길이 엇갈리는 다리 타일이 없다. 다리 2종을 넣었던 처음 판은 씨앗 200개에서 모순이 0번이었고, 빼고 난 지금은 씨앗 400개 중 7개가 한 번씩 무너진다. 길과 물길이 만날 자리가 있으면 막다른 칸이 생기지 않는다는 뜻이고, 시연 씨앗 66110 은 무너지는 일곱 개 가운데 하나를 골라 다시 채우는 장면이 보이게 했다. 되돌리기 없이 판 전체를 처음부터 다시 하는 방식이라 판이 커지면 다시 하는 비용도 커진다. 후보 수가 같은 칸을 번호순으로 고르면 왼쪽 위부터 줄 맞춰 채워지기 때문에, 칸마다 씨앗 난수로 정한 동점 깨기 값 tie 를 더한다.

각도를 직선으로 읽으면 생기는 7.40px 계단

07 의 ringR 이 각도를 그대로 노이즈의 x 로 읽게 바꾸면, 같은 진폭에서 0 과 2π 지점의 반지름이 7.40px 어긋나 고리마다 계단이 진다. 원 위 좌표로 읽으면 한 바퀴 끝이 시작점과 같은 노이즈 자리로 돌아오니 차가 0px 이다. 진폭이 반지름에 비례해 바깥 고리 138px 에서는 ±11px 까지 흔들리는데, 폭이 2.67px 인 해와 겹치면 이웃 고리가 엇갈릴 수 있다. 이웃 고리의 노이즈 원을 0.07 만 옮겨 서로 닮게 해 두었기 때문에 실제 그림에서는 엇갈림이 거의 드러나지 않는 것으로 보인다.

씨앗을 바꿔도 비슷해 보이는 까닭

씨앗을 바꾸면 배치는 바뀌지만 성격은 그대로다. 02 는 씨앗 21080·27672 도 103틱에 끌개를 다 지웠고(가지 끝 85·103개), 04 는 씨앗 36918·94117 에서 거절률이 95.2%·94.2% 로 47104 의 94.9% 와 1%p 도 차이 나지 않으며, 08 은 9,000칸을 틱당 60칸씩 채우니 어느 씨앗이든 150틱에 끝난다(경계 732·821·975곳). 그림의 인상을 바꾸고 싶다면 씨앗보다 아래 변형 표의 상수를 건드리는 쪽이 빠르다.

세 겹이 서로를 흔드는 히어로

09 가 세 겹에 같은 난수열을 나눠 쓰면 흐름 선 설정 하나만 바꿔도 뒤에 뽑는 잎맥과 원의 무늬까지 통째로 바뀔 것으로 보여, laneRng 로 갈래를 갈랐다. 원은 960ms 에 시작하면서 그때까지 자란 잎맥 마디만 피하므로, 잎맥이 그 뒤에도 자라면 원과 겹칠 수 있는 자리가 소스에 남아 있다. 앞 장면을 화면 캔버스에서 그대로 떠 두면 오른쪽 아래 수치 상자까지 옅게 겹쳐 남기 때문에, snapshot 은 drawScene 으로 장면만 다시 그린다.

화면 크기와 화소 배율이 바뀔 때

캔버스 안 좌표는 480×300 으로 고정이고, 바깥 상자만 width: min(92vw, calc(160vh - 12.8vw)) 와 aspect-ratio: 480 / 300 으로 늘고 준다. 그래서 휴대폰에서 열어도 같은 씨앗은 같은 그림이 된다. 실제 화소 수는 기기 배율을 곱하되 Math.min(window.devicePixelRatio || 1, 2) 로 2배에서 자른다. 캔버스 크기를 바꾸면 그려 둔 내용이 지워지므로, 배율이 바뀌면 fitCanvas 가 그림판을 임시 캔버스로 옮겨 두었다가 새 크기로 다시 담는다.

아홉 폴더의 vanilla 소스를 내 씨앗과 내 색으로 고쳐 키워 보려면, 내려받은 zip 을 열 때 9rhueet8 을 넣으면 되고 같은 계산을 옮긴 리액트 판도 함께 들어 있다.

유선 간격·끌개 반경·밀어내는 반경을 바꾸면 그림은 어떻게 달라지는가

이름 바꾼 값 느낌
흐름 선 간격 넓히기 01 LINE_SEPARATION 8 → 16 같은 씨앗 47101 에서 선이 155개에서 79개로, 길이 합이 16,462px 에서 8,372px(0.51배)로 줄고 135틱이던 성장이 66틱에 끝난다. 간격을 두 배로 벌리면 넓이를 간격으로 나눈 만큼만 선이 들어가 길이가 거의 절반이 되고, 결이 성기고 시원해진다.
끌개 반경 넓히기 02 INFLUENCE_RADIUS 60 → 120 마디 794 → 815개, 가지 끝 71 → 81개, 성장은 103틱 그대로라 그림은 거의 같다. 노드에서 잰 전체 성장 계산은 24.6ms 에서 40.0ms 로 늘었다. 끌개 900개가 잎 안에 빽빽해 60px 안에서 이미 가장 가까운 마디가 정해지기 때문으로 보인다.
밀어내는 반경 넓히기 03 REPEL_RADIUS 12 → 22 같은 120틱에서 평균 두께(넓이 × 2 ÷ 둘레)가 7.5px 에서 13.0px 로, 가로 폭이 171px 에서 271px 로, 넓이가 12,036px² 에서 29,297px² 로 커진다. 촘촘한 주름이 굵고 느슨한 굽이로 바뀐다.

세 줄 가운데 01·03 은 그림의 결이 눈에 띄게 바뀌지만, 02 의 끌개 반경은 그림을 거의 그대로 둔 채 계산만 늘린다. 반경을 키우기 전에 그림이 정말 달라지는지와 계산이 얼마나 느는지를 함께 재 보는 편이 낫다는 점이 02 에서 드러난다.

CSS 키프레임으로 정해진 그림을 움직이는 쪽은 제너레이티브 패턴 배경, 점에서 가장 가까운 영역을 나누는 무늬는 보로노이 다이어그램 패턴 에서 이어 볼 수 있다. 무작위 걸음으로 붙어 자라는 결정은 눈 결정 성장, 규칙 문자열을 다시 써서 키우는 나무는 프랙탈 나무, 화소 단위 계산으로 장면을 만드는 효과는 캔버스 특수효과 9종 이 가깝다.

다 자란 한 장을 정지 화면으로 — 움직임을 줄인 화면과 배경 그림의 접근성

운영체제에서 동작 줄이기(prefers-reduced-motion) 를 켠 화면에서는 아홉 장 모두 requestAnimationFrame 루프를 돌리지 않는다. 대신 finishNow() 가 틱 상한 6,000(GROW_GUARD) 안에서 그 씨앗을 끝까지 키워 다 자란 그림 한 장을 바로 그리고, Enter·R·버튼으로 씨앗을 바꾸거나 다시 키워도 그 자리에서 새 그림의 완성본만 바뀐다. 09 는 교차 페이드와 다음 씨앗 없이 다 자란 세 겹 한 장에 머문다. 설정은 matchMedia 의 change 리스너가 지켜보고 있어 페이지를 연 채로 바꿔도 반영된다.

키보드로도 아홉 장을 다 다룬다. 캔버스마다 tabindex="0" 과 그림·키를 설명하는 aria-label 이 붙어 있고, 포커스가 들어가면 Enter 는 새 씨앗, R 은 같은 씨앗 다시 키우기, Space 는 일시정지와 이어 가기다. 기본 동작은 이 세 키에서만 막으므로 Tab 을 비롯한 다른 키는 그대로 통과한다. 「다시 그리기」·「같은 씨앗」·「일시정지」 는 진짜 <button> 이고, 일시정지 버튼은 aria-pressed 로 눌림 상태를 알리며, 캔버스와 버튼 둘 다 :focus-visible 테두리를 가진다.

상태 줄은 시연이 도는 동안 aria-live="off" 로 조용하다가 사람이 처음 손을 대는 순간 polite 로 바뀌고, 문구도 자라는 중·다 자람·일시정지처럼 단계가 바뀔 때만 고친다. 선·마디·원 개수처럼 틱마다 바뀌는 숫자는 상태 줄이 아니라 캔버스 오른쪽 아래 상자에 그려서, 화면 낭독기가 매 프레임 숫자를 읽지 않게 했다. 그림을 글 뒤에 장식으로만 깔 때는 버튼과 키 안내를 함께 걷어 내고 캔버스를 낭독 대상에서 빼 두는 편이 듣는 사람에게 덜 번거롭다.

제너레이티브 아트를 내 사이트 배경에 깔기 전 자주 묻는 질문

랜덤 패턴 제너레이터처럼 새로 열 때마다 다른 그림이 나오게 할 수 있나?

01~08 은 SEED0, 09 는 HERO_SEED 한 줄이 첫 씨앗이다. 소스는 같은 씨앗이 같은 그림을 내도록 시각이나 Math.random 을 일부러 쓰지 않으니, 방문마다 바꾸고 싶다면 이 한 줄의 수를 페이지 쪽에서 만들어 넘기면 되고, 그 경우 같은 그림을 다시 보려면 그 수를 함께 남겨 두어야 한다. 글 번호처럼 페이지마다 고정된 수를 넣으면 페이지마다 다르면서도 다시 열 때마다 같은 그림이 나온다.

내 브랜드 색으로 바꾸려면 어디를 고치나?

색은 아홉 소스 모두 스크립트 위쪽의 INK·PAPER·PAL 상수에 RGB 배열로 모여 있다. 이 배열 값만 바꾸면 계산과 모양은 그대로 둔 채 선·마디·원의 색이 바뀐다. 무대 상자의 둥근 모서리와 그림자는 style.scss 의 토큰을 따른다.

휴대폰에서 돌려도 무겁지 않을까?

틱마다 하는 일에 상한이 있다. 01 은 걸음 70, 04 는 새 원 8개, 08 은 60칸처럼 상수로 묶였고, 프레임당 틱도 최대 6번, 화소 배율도 2배에서 자른다. 03 은 마디 상한 2,600개에서 노드로 잰 가장 무거운 틱이 2.0ms 였지만 이 값은 이 PC 기준이라, 느린 기기에서는 MAX_NODES 나 틱당 상한을 낮춰 보는 쪽이 안전하다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.