Devlog · 001 · 구현실험

얼룩말 무늬를 슬라이더로 만져보기: 튜링 패턴 실험실 구현기

2026.06.23 업무천재 고주무관

맛있는 과학 개념을, 브라우저 안에서 동작하는 규칙 기반 생성 코드로 옮겨본 기록. 완성된 무늬보다, 무늬가 생겨나는 조건을 구현하는 과정에 초점을 둔다. 반응-확산 규칙을 HTML Canvas 위에서 굴려볼 수 있음.

1. 뭘 만들었는가: 튜링 패턴 시뮬레이터

이번 작업은 procedural generation 실험이었다. 튜링 패턴을 규칙 기반 생성 설계 관점에서 시각화 도구로 구현해 봄. 우선 결과물부터 봅시다. 위에 버튼 누르고 링크 들어가서 이리저리 만져보삼

튜링 패턴 실험실에서 생성된 미로형 무늬가 표시된 전체 화면 캡처
좌우 2개씩 있는 작은 물결은 마우스로 찍어준 seed. 기본은 가운데부터 퍼져나가게 설정

2. 작업 계기: 코드로 만드는 게 너무 맛있어 보였다

주말 저녁에 침대에서 빈둥거리며 유튜브 보다가 "이거 재밌네, 웹으로 만들어볼까?" 싶었음. 튜링 패턴이라는 개념 자체가 코드로 구현하기에 너무 먹음직스러운 조건이라서 ㅋㅋㅋㅋ
2차원 격자에 가상의 두 값을 두고, 매 프레임마다 반응+확산을 계산하면 각종 무늬가 나온다. 군침이 싹 돌 정도로 명쾌하잖음?

그래서 앞으로 개발 일지도 대체로 일종의 문제 풀이 기록이 될 것임. 개발 동기가 대부분 "오~ 이거 코드로 구현하면 재밌겠는데?"라, 추상 개념을 동작하는 코드로 바꾸는 과정이 재밌는 거지 다 풀고 나면 재미없어서… 이번에도 "튜링 패턴이라는 추상 개념을 격자와 배열과 수식과 렌더링 루프로 바꾸면 어떻게 굴러갈까?"라는 질문을 풀어나가는 게 재밌었던 거지, 결과물을 오래 가지고 놀진 않았음 ㅋㅋ

다만 완성해두고 보니, 누군가에겐 이 페이지가 튜링 패턴이라는 추상 개념을 손으로 만져보는 작은 실험실이 될 수도 있겠다는 생각은 들었음.

3. 주제 선정: 튜링 패턴이 구현 문제로 매력적인 이유

참고 영상: 과학을 보다 EP.199

영상에서 과학자들이 던진 질문은 단순하다. "얼룩말은 어떻게 선명한 흑백 줄무늬를 만드는 걸까?"

얼룩말의 흑백 줄무늬 형성 원리를 다루는 참고 영상 캡처
참고 영상: 과학을 보다 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 구조 (단순화)
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
반응-확산 step
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 뿌리기
  • 일시정지/초기화

결국 내가 만든 것은 무늬 하나가 아니라, 사용자가 계속 다른 무늬를 만들어볼 수 있는 규칙의 장이다. …피곤하다. 대충 여기서 마무리하고 좀 더 자다가 출근하겠슴.