AI와 만드는 셀룰러 오토마타, 한 줄의 규칙으로 무늬 키우기
AI의 도움으로 p5.js 셀룰러 오토마타를 만들고, 규칙 30과 90·경계 조건·셀 크기를 바꾸며 무늬의 구조를 비교한다. 실행한 코드와 실제 출력으로 제작 과정을 설명한다.

표지는 GPT Image 2로 생성한 개념 이미지다. 아래 코드의 실제 출력은 본문 중간에 별도로 실었다.
화면 맨 위에 검은 점 하나를 놓는다. 그 아래 줄은 바로 위의 세 칸을 보고 채운다. 같은 계산을 거듭하면 작은 점에서 시작한 무늬가 화면 아래까지 자란다. 완성된 이미지를 먼저 그리지 않아도, 간단한 규칙이 촘촘한 결을 만든다.
이번에는 AI의 도움을 받아 셀룰러 오토마타를 코드로 만들고, 규칙을 바꾸며 쓸 만한 화면을 고르는 과정을 다룬다. 목표는 960×540 크기의 정지 이미지 한 장이다. 아래로 내려갈수록 계산이 한 단계씩 진행된 기록을 볼 수 있다. 재생되는 애니메이션은 아니지만, 한 화면 안에 형태가 생겨난 순서가 남는다.
여기서 AI는 규칙의 설명과 코드 작성, 오류 점검을 돕는다. 실제 무늬는 정해진 계산으로 생성한다. 같은 초기 상태와 설정을 넣으면 같은 셀 배열이 나온다. 생성형 AI가 그린 표지와, 알고리즘으로 만든 본문 예제는 제작 방식이 다르다.
AI에게 그림보다 다음 줄의 규칙을 설명하게 하기
셀룰러 오토마타의 각 칸은 여기서 0 또는 1만 가진다. 0은 빈 칸이고 1은 채운 칸이다. 다음 줄의 한 칸을 정할 때는 현재 줄의 왼쪽, 가운데, 오른쪽 값을 읽는다. 이웃 셋의 조합은 8개다. 각 조합의 다음 값을 정하면 하나의 규칙이 된다.
규칙 30은 이 단순한 구조가 복잡한 무늬를 만드는 사례다. 스티븐 울프럼은 자신의 책에서 한 칸으로 시작한 규칙 30의 전개와 계산 방법을 설명한다. 이 글에서는 그 원리를 유한한 폭의 캔버스에 옮긴다. 화면의 양끝을 어떻게 연결할지는 별도로 정해야 한다. 울프럼의 규칙 30 설명
AI와 작업을 시작할 때는 원하는 화면과 함께 계산 조건을 지정한다. 다음은 직접 바꾸어 쓸 수 있는 요청문이다.
p5.js로 1차원 셀룰러 오토마타를 그려 줘.
캔버스는 960×540, 셀은 4픽셀, 첫 줄은 가운데 한 칸만 1이다.
규칙 번호는 30으로 시작하고 0부터 255 사이에서 바꿀 수 있게 해 줘.
왼쪽·가운데·오른쪽을 3비트 숫자로 읽어 다음 상태를 계산해 줘.
현재 줄을 수정하지 말고 새 배열에 다음 줄을 만든 뒤 교체해 줘.
양끝을 연결하는 설정과 바깥을 0으로 보는 설정을 선택하게 해 줘.
규칙 30에서 첫 3줄이 어떻게 나오는지도 설명해 줘.
코드를 실행하기 전에 설명을 확인하면 오류를 찾기 쉽다. 이웃을 111, 110, 101, 100, 011, 010, 001, 000 순서로 나열했을 때 규칙 30의 다음 값은 0, 0, 0, 1, 1, 1, 1, 0이다. 이 비트열 00011110을 십진수로 읽으면 30이 된다. 예를 들어 100은 다음 줄에서 1이 되고, 111은 0이 된다.
가운데 한 칸을 켠 뒤에는 그 아래 세 칸이 켜져야 한다. 다음 줄은 가장자리의 0을 제외하면 11001이 된다. 이 짧은 전개가 맞는지부터 살핀다. 처음부터 복잡한 화면만 보면, 좌우를 반대로 읽은 코드도 그럴듯한 무늬로 보일 수 있다.
한 줄을 보존하면서 다음 줄을 계산하기
이 예제는 p5.js 2.3.3으로 실행했다. 공식 다운로드 안내에서 라이브러리 버전을 확인하고, p5.js 웹 에디터의 새 스케치에서 라이브러리 버전도 맞춘다. 기존 sketch.js 내용을 아래 코드로 바꾸고 실행한다. 별도의 이미지 파일이나 추가 라이브러리는 필요하지 않다.
실제 검증은 같은 버전의 라이브러리를 넣은 로컬 브라우저 페이지에서 했다. 웹 에디터에 저장하거나 공유하는 절차까지 실행한 것은 아니다. 웹 에디터를 사용한다면 새 스케치의 기본 코드 대신 아래 전체 코드를 넣으면 된다.
const cfg = { rule: 30, cell: 4, wrap: true };
function setup() {
createCanvas(960, 540);
pixelDensity(1);
noLoop();
background(247, 243, 232);
noStroke();
fill(34, 39, 43);
const cols = floor(width / cfg.cell);
const rows = floor(height / cfg.cell);
let row = new Uint8Array(cols);
row[floor(cols / 2)] = 1;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (row[x] === 1) {
rect(x * cfg.cell, y * cfg.cell, cfg.cell, cfg.cell);
}
}
const next = new Uint8Array(cols);
for (let x = 0; x < cols; x++) {
const left = x > 0 ? row[x - 1] : (cfg.wrap ? row[cols - 1] : 0);
const right = x < cols - 1 ? row[x + 1] : (cfg.wrap ? row[0] : 0);
const neighborhood = 4 * left + 2 * row[x] + right;
next[x] = (cfg.rule >> neighborhood) & 1;
}
row = next;
}
}
row는 현재 줄이고 next는 아직 화면에 그리지 않은 다음 줄이다. 둘을 나눈 이유가 중요하다. 왼쪽부터 계산하면서 row 자체를 바꾸면, 오른쪽 칸은 일부만 갱신된 이웃을 읽게 된다. 그러면 한 세대를 동시에 계산한다는 조건이 깨진다. 한 줄의 계산이 끝난 뒤 row = next로 바꾸어야 한다.
neighborhood는 세 칸의 값을 0부터 7 사이의 번호로 바꾼다. 4 * left + 2 * row[x] + right에서 왼쪽은 4, 가운데는 2, 오른쪽은 1의 자리를 차지한다. (cfg.rule >> neighborhood) & 1은 규칙 번호에서 해당 자리의 비트 하나를 꺼낸다. 이 방식으로 규칙 번호만 바꾸면서 같은 계산 구조를 사용할 수 있다.
사각형은 p5.js의 rect 문서에 나온 위치·너비·높이 방식으로 그린다. 셀은 4×4픽셀이고 외곽선은 없다. pixelDensity(1)은 출력 픽셀 밀도를 1로 고정한다. noLoop()는 반복 그리기를 멈추며, 여기서는 setup() 안에서 전체 무늬를 한 번에 완성한다. pixelDensity 설명, noLoop 설명

실제 코드 출력. 규칙 30, 셀 크기 4, 양끝 연결을 사용했다. 위의 한 칸에서 시작해 아래로 갈수록 세대가 진행된다.
이 설정은 한 줄에 240칸, 화면 전체에 135줄을 그린다. 맨 위의 초기 상태를 첫 줄로 세므로 마지막 줄은 134번 전이한 상태다. 검은 칸이 만든 큰 삼각형 안에 방향이 다른 줄과 빈틈이 생긴다. 무작위 값을 추가하지 않아도 규칙의 반복만으로 이런 차이가 나타난다.
규칙과 경계를 따로 바꾸어 비교하기
먼저 rule만 90으로 바꾸고 다시 실행해 본다. 같은 초기 상태에서도 빈 삼각형이 반복되는 성긴 구조가 나타난다. 이 예제의 규칙 30은 9,247칸을 채웠고, 규칙 90은 2,171칸을 채웠다. 이 수치는 240×135칸과 양끝 연결이라는 현재 조건의 결과다. 다른 화면 크기나 초기 상태에도 고정되는 비율은 아니다.
규칙 90은 왼쪽과 오른쪽이 다를 때 다음 칸이 1이 되는 방식으로도 계산할 수 있다. 울프럼의 규칙을 수식으로 표현한 설명과 연결해 읽으면, 비트 연산이 어떤 국소 규칙을 구현하는지 확인할 수 있다. 예제 검증에서도 비트를 꺼내는 코드와 별개의 계산식으로 두 규칙의 셀 배열을 대조했다.
다음에는 규칙을 30으로 돌리고 wrap만 false로 바꾼다. true일 때는 맨 왼쪽 칸이 맨 오른쪽 칸을 이웃으로 읽는다. 줄을 고리처럼 연결한 조건이다. false일 때는 화면 밖을 0으로 취급한다. 화면 밖을 0으로 본다고 해서 맨 왼쪽과 오른쪽 칸 자체를 항상 0으로 고정하는 것은 아니다.
처음에는 두 화면이 같아 보인다. 가운데서 시작한 변화가 아직 끝에 닿지 않았기 때문이다. 이번 설정에서는 맨 위를 0번째 줄로 셀 때 120번째 줄부터 결과가 달라졌다. 즉, 맨 위를 첫 줄이라고 부르면 121번째 줄이다. 짧은 화면만 보고 경계 설정이 작동하지 않는다고 판단하지 않는 편이 좋다.
마지막으로 cell을 8로 바꾸면 셀 자체가 커진다. 동시에 가로 칸 수는 120개, 표시되는 줄 수는 67개로 줄어든다. 기존 이미지를 두 배로 확대하는 작업과는 다르다. 계산하는 줄의 폭과 화면에 남는 세대 수가 함께 달라지며, 세로 길이를 나누고 남은 아래 4픽셀은 배경으로 남는다. 화면의 밀도와 결을 비교할 때는 이런 변화까지 포함해 읽어야 한다.
우연처럼 보이는 무늬에서 내가 고를 부분
AI에 “더 복잡하게”라고만 요청하면 규칙, 초기 상태, 색, 화면 크기가 한꺼번에 달라질 수 있다. 비교할 때는 한 번에 한 설정만 바꾼다. 규칙 30과 90을 같은 크기로 놓고 어느 빈틈이 더 잘 보이는지 살피거나, 경계를 유지한 채 색만 바꾸는 식이다. 무엇이 화면을 바꾸었는지 알아야 다음 선택에도 사용할 수 있다.
이번 실습에서는 규칙 30과 90, 양끝 연결과 바깥 0 처리, 셀 크기 4와 8을 실제로 실행했다. 각 조건의 모든 셀 상태를 별도 계산과 대조하고, 캔버스에 그린 셀의 가운데 픽셀도 확인했다. 원래 설정으로 되돌렸을 때는 처음과 같은 배열과 PNG가 나왔다. 이 확인은 이 코드와 설정에 대한 것이며, AI가 앞으로 작성할 모든 변형의 정확성을 보장하지 않는다.
다음 실험에서는 첫 줄의 검은 칸을 여러 곳에 배치해 볼 수 있다. 서로 다른 지점에서 퍼진 구조가 만나는 위치를 정하면 화면의 중심이 달라질 것이다. 이 확장은 이번 실행 비교에는 포함하지 않았다. 적용한다면 초기 줄부터 다시 확인하고, 같은 규칙으로 만든 다른 초기 상태를 나란히 놓고 선택하면 된다.
정지 이미지를 영상이나 설치로 발전시킬 때도 생성 규칙과 보여 주는 방식을 나누어 생각할 수 있다. 한 줄씩 드러내면 관객은 무늬가 생기는 순서를 따라가고, 완성된 무늬를 크게 보여 주면 작은 칸이 이루는 결을 먼저 본다. 이번 코드는 후자의 재료가 되는 정지 출력을 만든다. 줄을 추가하는 애니메이션과 전시 장치에서의 재생은 별도의 제작 단계다.
작가가 고르는 것은 규칙 번호 하나에 그치지 않는다. 어디서 시작할지, 어느 세대에서 화면을 끝낼지, 어떤 크기의 칸으로 볼지를 정하면서 작품의 형식이 생긴다. AI가 구현을 도울수록 이 선택을 설정과 실제 출력의 관계로 남겨 두는 일이 유용해진다.