Devlog · 001 · 구현실험
얼룩말 무늬를 슬라이더로 만져보기: 튜링 패턴 실험실 구현기
맛있는 과학 개념을, 브라우저 안에서 동작하는 규칙 기반 생성 코드로 옮겨본 기록. 완성된 무늬보다, 무늬가 생겨나는 조건을 구현하는 과정에 초점을 둔다. 반응-확산 규칙을 HTML Canvas 위에서 굴려볼 수 있음.
1. 뭘 만들었는가: 튜링 패턴 시뮬레이터
이번 작업은 procedural generation 실험이었다. 튜링 패턴을 규칙 기반 생성 설계 관점에서 시각화 도구로 구현해 봄. 우선 결과물부터 봅시다. 위에 버튼 누르고 링크 들어가서 이리저리 만져보삼
2. 작업 계기: 코드로 만드는 게 너무 맛있어 보였다
주말 저녁에 침대에서 빈둥거리며 유튜브 보다가 "이거 재밌네, 웹으로 만들어볼까?" 싶었음. 튜링 패턴이라는 개념
자체가 코드로 구현하기에 너무 먹음직스러운 조건이라서 ㅋㅋㅋㅋ
2차원 격자에 가상의 두 값을 두고, 매 프레임마다 반응+확산을 계산하면 각종 무늬가 나온다. 군침이 싹 돌 정도로 명쾌하잖음?
그래서 앞으로 개발 일지도 대체로 일종의 문제 풀이 기록이 될 것임. 개발 동기가 대부분 "오~ 이거 코드로 구현하면 재밌겠는데?"라, 추상 개념을 동작하는 코드로 바꾸는 과정이 재밌는 거지 다 풀고 나면 재미없어서… 이번에도 "튜링 패턴이라는 추상 개념을 격자와 배열과 수식과 렌더링 루프로 바꾸면 어떻게 굴러갈까?"라는 질문을 풀어나가는 게 재밌었던 거지, 결과물을 오래 가지고 놀진 않았음 ㅋㅋ
다만 완성해두고 보니, 누군가에겐 이 페이지가 튜링 패턴이라는 추상 개념을 손으로 만져보는 작은 실험실이 될 수도 있겠다는 생각은 들었음.
3. 주제 선정: 튜링 패턴이 구현 문제로 매력적인 이유
참고 영상: 과학을 보다 EP.199영상에서 과학자들이 던진 질문은 단순하다. "얼룩말은 어떻게 선명한 흑백 줄무늬를 만드는 걸까?"
점무늬·줄무늬처럼 번갈아 칠한 듯한 선명한 무늬를 보면, 직관적으로는 누군가의 설계 의도가 작동한 것 같잖음. 전체 그림을 미리 그린 후 세부를 배치했을 것 같고. 튜링 패턴이 흥미로운 지점이 바로 여기다. 동물 가죽의 무늬는 중앙 설계자 없이도, 단순한 국소 규칙이 반복되면 전체 무늬가 생긴다. 일상적인 주제에서 출발하지만, 들어가면 "복잡한 질서가 어떻게 생기는가?"라는 꽤 큰 질문으로 이어짐.
마침 최근에 국소 규칙이 전역 패턴이 되는 원리를 생각 중이던지라 더 와닿았다. 원리는 두 물질만 이해하면 잡힌다.
- 액티베이터: 특정 반응을 키운다. 인히비터도 같이 만듦.
- 인히비터: 그 반응을 억제한다. 액티베이터보다 더 빨리 퍼지면서 액티베이터를 섬처럼 고립시킴.
→ 확산 결과, 처음엔 균일했던 공간에 점·줄·얼룩 같은 안정된 무늬가 생긴다. 처음부터 무늬를 그리라고 명령한 게 아니라, 국소 상호작용이 반복되며 전체 무늬가 형성됨.
여기까지가 영상 설명(앨런 튜링 제시)이고, 내가 코드에 쓴 건 이 활성자·억제자 그림을 1:1로 옮긴 게 아니라 같은 튜링 패턴을 만드는 다른 계열의 단순 모델(Gray-Scott)이다. 아래 슬라이더의 U·V는 "서로 다른 속도로 퍼지고 반응하는 두 값" 정도로 보면 됨.
4. 구상: 생성 규칙을 디자인하기
절차적 생성 관점에서 접근했음. 일반적인 디자인은 완성된 결과를 먼저 상상하고 세부를 결정하지만, 절차적 생성은 결과물을 직접 그리는 대신 결과물이 생겨나는 규칙과 절차를 설계한다.
- 무늬 위치를 직접 정하는 게 아니라, 무늬가 생기는 규칙을 정한다.
- 완성 이미지를 만드는 게 아니라, 생성 절차를 만든다.
- 결과가 고정되는 게 아니라, 조건 설정에 따라 결과가 달라진다.
- 사용자가 결과를 감상만 하는 게 아니라, 조건을 직접 조작해 패턴을 생성한다.
프리셋(점무늬·산호·미로·세포분열·물결)도 미리 저장된 이미지가 아니라 각각 특정 feed·kill 값의 조합이다. 사용자는 완성 그림을 불러오는 게 아니라, 다른 생성 조건에서 시뮬레이션을 다시 시작하는 것.
이 감각은 업무도구를 만들 때도 똑같이 적용된다. 매번 개별 사례에 답을 쓰는 대신, 사용자가 자기 조건을 넣으면 결과를 확인할 수 있는 절차를 만든다. 계산기·검산 도구·파일 변환기·업무 체크리스트도 결국 같은 방향. 정답 하나를 대신 내려주는 게 아니라, 사용자가 자기 상황에서 판단할 절차를 설계하는 것.
5. 구현 구조: 단일 HTML 안에 작은 실험실 넣기
구현은 웹으로. 백엔드 없이 프론트엔드로만 만들어서 서버·DB 없고, 파일 업로드도 없음. 브라우저만 열면 됨. 단일 HTML에 자바스크립트까지 다 넣었다. 코드가 1만~2만 줄로 길어지면 CSS·시뮬레이션 로직·UI 이벤트를 파일로 나누는 게 낫겠지만, 이 규모에선 한 파일 안에서 빠르게 만들고 바로 확인하는 구조가 나쁘지 않다고 봄.
▼ 구상 메모 — 처음엔 실시간 애니메이션을 WebGL로 돌릴까 했는데, 화면이 가로 200 × 세로 130으로 작아서 Canvas 2D만으로 충분했다.
index-turing-pattern.html
├─ <head>
│ ├─ 메타 정보(인코딩·viewport·제목·작성자·설명)
│ ├─ Google Analytics
│ ├─ favicon / 공통 CSS / global-loader.js / footer.js
│ └─ 페이지 전용 CSS
├─ <body>
│ ├─ 제목·설명·참고 영상 링크
│ ├─ canvas 시뮬레이션 화면
│ ├─ 프리셋 버튼(점무늬·산호·미로·세포분열·물결)
│ ├─ feed / kill / 반응 속도 슬라이더
│ └─ 일시정지·초기화 버튼
└─ <script>
├─ U·V·U2·V2 농도 배열 생성 / 초기화·중앙 씨앗
├─ 토러스 경계 처리 / 라플라시안 / 반응-확산 step
├─ 버퍼 스왑 / V 농도→색상 변환→canvas 렌더링
├─ 마우스·터치 입력 / 슬라이더 동기화 / 프리셋
└─ requestAnimationFrame 애니메이션 루프
사용자는 버튼·슬라이더를 만지지만, 안쪽에선 다음 흐름이 반복된다.
사용자 조작 → 파라미터 변경 → 반응-확산 계산 → 농도 배열 갱신 → 색상 변환 → Canvas 렌더링
# 6. 계산 구조: 두 값을 배열로 두고 계속 갱신하기
화면 크기는 가로 200 × 세로 130 = 26,000칸. 메모리 어딘가에 숫자 26,000개 자리가 실제로 잡힌다. U는 그 자리를
가리키는 이름이고, Float32Array가 그 배열을 만드는 명령. U·V는 두 물질의 현재 농도 배열이고, 무늬 자체에
대한 정보는 어디에도 없다.
var U = new Float32Array(SIZE);
var V = new Float32Array(SIZE);
var U2 = new Float32Array(SIZE);
var V2 = new Float32Array(SIZE);
U2·V2는 계산 결과를 잠깐 받아둘 빈 배열이다. 현재 농도를 읽으면서 결과를 저장해야 해서 두 세트가 필요함. 같은 곳에 덮어쓰면 바뀐 값과 섞여 무늬가 깨짐. 그래서 현재 농도는 U·V에서 읽고 결과는 U2·V2에 쓴 뒤, 한 바퀴 끝나면 통째로 맞바꾼다. 칠판 두 개 놓고 번갈아 쓴다고 이해하면 됨.
var tu = U; U = U2; U2 = tu;
var tv = V; V = V2; V2 = tv;
한 칸을 갱신할 때 아래 네 가지 힘(확산+반응+공급+제거)을 더한다.
- 이웃으로 퍼지는 확산
Du*lapA - 두 값이 만나는 반응
abb - 부족분 공급
feed*(1-a) - 일정 비율 제거
(kill+feed)*b
var na = a + (Du * lapA - abb + feed * (1 - a)) * dt;
var nb = b + (Dv * lapB + abb - (kill + feed) * b) * dt;
dt는 미적분의 그 기호(difference in time) 맞음. 한 번 계산할 때 시간이 얼마나 흘렀다고 칠까? 여기선
1.0으로 고정. 너무 키우면 계산이 거칠게 깨지고, 너무 줄이면 느려짐. (근데 미적분 몰라도 됨. 고정값이라 사용자는 못
만짐. 사용자는 feed랑 kill만 주는 대로 써!)
var dt = 1.0;
var Du = 0.16; // U가 이웃으로 번지는 속도
var Dv = 0.08; // 두 물질이 서로 다른 속도로 퍼지는 게 튜링 패턴의 전제
상하좌우 이웃 값을 모아 "얼마나 퍼질지"를 구하고, 화면 끝을 이어서(도넛처럼 말아서) 경계를 처리한다.
// 확산
var ortho = arr[idx(x - 1, y)] + arr[idx(x + 1, y)] +
arr[idx(x, y - 1)] + arr[idx(x, y + 1)];
// 경계 처리
if (x < 0) x += W; else if (x >= W) x -= W;
if (y < 0) y += H; else if (y >= H) y -= H;
그다음 농도를 색으로 바꿔 그린다. V 농도(t)가 높을수록 배경색에서 무늬색으로 섞음.
// 색 변환
var r = (245 + (58 - 245) * t) | 0;
// 매 프레임 반복: 계산 여러 번 → 무늬 그리기 → 다음 프레임 예약
function loop() {
if (running) {
for (var s = 0; s < stepsPerFrame; s++) step();
}
render();
requestAnimationFrame(loop);
}
# 7. 조작 구조: 프리셋, 슬라이더, seed
- 프리셋은 이미지가 아니라 feed/kill 조합
- 슬라이더는 생성 조건 조절
- 클릭/드래그로 seed 뿌리기
- 일시정지/초기화
결국 내가 만든 것은 무늬 하나가 아니라, 사용자가 계속 다른 무늬를 만들어볼 수 있는 규칙의 장이다. …피곤하다. 대충 여기서 마무리하고 좀 더 자다가 출근하겠슴.