← Back to Blog
Media Art Article · 2026.11.04

AI와 함께 만드는 끊김 없는 제너러티브 루프

AI에게 제작 조건을 전달하고 p5.js의 노이즈를 원형으로 샘플링해 반복되는 선 패턴을 만든다. 실행한 코드와 실제 화면, 루프 검증, 변수에 따른 표현의 변화를 함께 살핀다.

media-artgenerative-artcreative-codingp5jsAI

어두운 남색 바탕에서 금빛 선의 흐름이 되돌아오는 순환을 표현한 개념 표지

표지는 GPT Image 2로 생성한 개념 이미지다. 아래 코드의 실제 출력은 본문 중간의 실행 화면에서 따로 볼 수 있다.

움직이는 추상 화면을 만들었는데 끝에서 처음으로 돌아갈 때 툭 끊긴다. 색과 형태는 마음에 들어도 이 작은 도약이 반복되면 관객은 이미지의 흐름보다 재생 경계를 먼저 알아차린다. 짧은 루프를 만드는 일은 시작과 끝 사이에 같은 장면을 놓는 것에서 출발하지만, 그 사이의 움직임도 계속 이어져야 한다.

이번에는 짙은 남색 화면에 짧은 금빛 선을 배치하고, 선의 방향이 물결처럼 변하는 작은 스케치를 만든다. AI는 원하는 동작을 코드로 옮기고 오류를 찾는 데 활용한다. 화면을 실제로 그리는 것은 p5.js의 반복문과 노이즈 함수다. 생성형 AI가 이미지 전체를 만들어 주는 방식과, 사람이 정한 알고리즘이 매 프레임 형태를 만드는 제너러티브 아트의 차이를 직접 다뤄 볼 수 있다.

결과물은 960×540 크기의 브라우저 스케치다. 한 주기를 180프레임으로 두고 초당 30프레임을 목표로 그린다. 목표 속도에 도달하면 약 6초마다 반복된다. 영상 파일 인코딩이나 전시장 재생기 설정은 이 예제의 범위에 포함하지 않는다.

AI에게 화면의 인상과 동작 조건을 함께 전달한다

“멋진 제너러티브 아트를 만들어 줘”라고 요청하면 화려한 결과는 쉽게 나오지만, 무엇을 고쳐야 하는지 판단하기 어렵다. 선의 배치, 움직임의 규칙, 반복 길이, 금지할 동작을 먼저 적으면 AI가 만든 코드를 읽고 수정할 기준이 생긴다. 다음은 같은 종류의 스케치를 요청할 때 쓸 수 있는 제작 지시문이다.

p5.js 2.3.3의 global mode로 960×540 스케치를 작성해 줘.
남색 바탕에 짧은 금빛 선을 일정한 격자로 배치하고,
2차원 Perlin noise 값으로 각 선의 방향을 정해 줘.
노이즈 입력 좌표에 cos와 sin으로 움직이는 원형 오프셋을 더해
180프레임 후 처음 상태로 돌아오게 해 줘. 목표는 30fps야.
noiseSeed는 17로 고정하고, 매 프레임 배경을 지워 줘.
입자 위치를 누적하거나 프레임마다 random을 호출하지 말아 줘.
프레임 번호만 받아 그리는 renderFrame(k)를 따로 만들고,
간격·노이즈 스케일·원형 경로 반경·선 길이를 위쪽 변수로 모아 줘.
코드와 함께 루프가 닫히는 이유와 확인 방법을 설명해 줘.

여기서 AI에게 맡기는 부분은 규칙의 구현과 설명이다. 선이 촘촘한 화면이 좋은지, 어떤 속도가 작품의 정서에 맞는지, 색이 지나치게 장식적인지는 화면을 보며 결정해야 한다. AI가 “자연스럽다”고 설명했다고 해서 그 느낌이 확보되는 것은 아니다. 처음에는 색과 구조를 고정하고 움직임만 확인하는 편이 수정의 효과를 구별하기 쉽다.

아래 코드는 그 조건을 구현한 최소 예제다. 이미지를 생성하는 모델을 실행 중에 호출하지 않으며, 별도의 AI API 키도 필요 없다. AI로 코드를 만드는 과정과 완성된 작품을 재생하는 과정을 분리하면, 전시할 때 어떤 부분이 네트워크나 외부 서비스에 의존하는지도 분명해진다.

작은 화면을 먼저 실행한다

브라우저와 텍스트 편집기를 준비한다. 로컬 서버를 여는 아래 방법에는 Python 3가 필요하다. p5.js 공식 다운로드에서 이 글이 확인한 버전인 2.3.3의 p5.min.js를 받고, 새 폴더에 index.html, sketch.js와 함께 둔다. 라이브러리 버전을 고정해야 이후 수정한 코드와 실행 환경의 변화를 구분할 수 있다.

index.html에는 다음을 저장한다.

<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><title>Noise Loop Study</title></head>
<body>
<script src="p5.min.js"></script>
<script src="sketch.js"></script>
</body>
</html>

sketch.js에는 다음 코드를 그대로 저장한다.

const cfg = {
  frames: 180, fps: 30, seed: 17,
  step: 20, scale: 0.007, radius: 0.65, length: 15
};
let tick = 0;

function setup() {
  createCanvas(960, 540);
  pixelDensity(1);
  noiseSeed(cfg.seed);
  frameRate(cfg.fps);
  strokeWeight(1.4);
  describe('짙은 남색 바탕의 짧은 금빛 선들이 물결처럼 방향을 바꾸며 반복됩니다.');
}

function draw() {
  renderFrame(tick);
  tick = (tick + 1) % cfg.frames;
}

function renderFrame(k) {
  background(12, 26, 36);
  const phase = TWO_PI * (k % cfg.frames) / cfg.frames;
  const ox = 10 + cfg.radius * cos(phase);
  const oy = 10 + cfg.radius * sin(phase);
  stroke(232, 211, 163);

  for (let y = 30; y < height - 30; y += cfg.step) {
    for (let x = 30; x < width - 30; x += cfg.step) {
      const n = noise(x * cfg.scale + ox, y * cfg.scale + oy);
      const angle = TWO_PI * 2 * n;
      const dx = cos(angle) * cfg.length / 2;
      const dy = sin(angle) * cfg.length / 2;
      line(x - dx, y - dy, x + dx, y + dy);
    }
  }
}

터미널에서 세 파일이 있는 폴더로 이동한 뒤 다음 명령을 실행한다. 브라우저 주소창에서 http://localhost:8000/을 열면 선들이 방향을 바꾸는 화면이 나타난다. 서버는 터미널의 Ctrl+C로 종료한다.

python3 -m http.server 8000

로컬 파일을 준비하기 어렵다면 p5.js 웹 에디터에서 같은 버전을 선택하고 sketch.js 내용을 실행해 볼 수도 있다. 이 글의 실행 검사는 내려받은 라이브러리와 로컬 서버를 사용했다.

예제 코드의 0번 프레임에서 일정한 격자 위 금빛 선들이 서로 다른 방향으로 놓인 실제 실행 화면

위 이미지는 p5.js 2.3.3으로 실행한 예제의 0번 프레임이다. AI 표지처럼 선이 공간을 이동하거나 큰 소용돌이 안에 모이지 않는다. 선의 중심은 격자에 고정되고 방향만 바뀐다.

공식 noise() 설명에 따르면 가까운 입력 좌표는 비슷한 값을 내며, 입력 좌표의 간격을 조절하면 변화의 성격도 달라진다. 이 예제는 화면의 좌표를 노이즈 공간에 대응시키고 그 값을 회전 각도로 바꾼다. 선마다 따로 난수를 뽑는 대신 이웃의 방향이 어느 정도 연결되도록 만드는 방식이다.

시간이 돌아오는 경로를 설계한다

반복의 핵심은 phase, ox, oy에 있다. phase는 한 주기 동안 원을 한 바퀴 돈다. cos(phase)와 sin(phase)가 만드는 원형 경로를 노이즈 입력 좌표에 더하면, 180프레임 뒤 같은 위치를 다시 샘플링한다. k % cfg.frames를 사용하므로 renderFrame(180)에는 renderFrame(0)과 같은 위상이 들어간다.

노이즈 함수 자체가 180프레임 주기를 가진 것은 아니다. 우리가 노이즈를 읽는 경로를 닫아 놓은 것이다. 같은 방식으로 공간상의 모든 선을 그리므로 각 선의 방향도 처음 값으로 돌아온다. 원을 도는 경로는 시간 경계에서 진행 방향이 이어지고, 매번 배경을 지워 이전 프레임의 흔적이 다음 상태에 누적되지 않게 한다.

noiseSeed()는 실행할 때마다 같은 노이즈 계열을 얻기 위한 설정이다. 이 예제에서는 setup()에서 한 번 호출한다. 시드를 고정하는 일과 루프를 닫는 일은 서로 다르다. 같은 시드라도 입력 좌표가 직선으로 계속 멀어지면 시작점으로 돌아오지 않는다. 반대로 루프가 닫혀도 매번 다른 시드를 쓰면 실행할 때마다 다른 작품이 나온다.

이 글의 검사는 macOS의 Chromium 기반 브라우저에서 진행했다. 같은 렌더링 조건에서 0번과 180번 프레임의 픽셀이 같았고, 45번 프레임은 달랐다. 노이즈 스케일을 바꾼 경우에도 시작과 끝이 같았으며, 시드를 바꿨다가 되돌렸을 때 계산 결과가 복원되는 것을 확인했다. 반복적인 픽셀 읽기 중에는 동일한 계산에도 래스터 결과가 달라지는 현상이 관찰돼, 읽기 상태를 안정시킨 뒤 비교했다. 다른 브라우저와 그래픽 환경에서 이미지 파일의 모든 바이트가 같다는 뜻은 아니다.

재생 속도에도 조건이 있다. frameRate() 공식 문서는 요청한 프레임 속도에 실제로 도달하지 못할 수 있다고 설명한다. 이 코드는 시간이 아니라 그린 프레임 수로 진행하므로 처리 속도가 떨어지면 한 주기가 길어진다. 30fps 영상으로 옮길 때는 0번부터 179번까지의 180장을 쓰는 식으로 프레임을 구성해야 한다. 180번은 0번과 같은 경계 확인용 장면이므로 끝에 덧붙이면 시작 장면이 두 번 들어간다. 이 글에서는 실제 영상 내보내기까지 검증하지 않았다.

변수를 바꿀 때는 무엇이 달라졌는지 남긴다

실행에 성공했다면 AI에게 화면 전체를 다시 만들라고 하기 전에 변수 하나를 바꿔 본다. 아래 표는 현재 설정을 기준으로 비교할 지점이다. 코드 위쪽의 값만 바꾼 뒤 페이지를 새로 열면 setup()부터 다시 실행된다.

변수현재 값바꿔 보며 판단할 것
scale0.0070.014와 비교해 이웃 선의 방향이 더 잘게 갈라지는지 본다. 큰 흐름과 세밀한 결 중 무엇이 필요한지 고른다.
step20간격을 줄이면 같은 화면에 선이 많아진다. 풍부해지는 결이 필요한지, 여백이 사라지는지 본다.
radius0.65원형 샘플링 경로의 크기를 바꾼다. 한 주기 동안 지나가는 노이즈 영역과 움직임의 변화 폭을 함께 살핀다.
length15선이 길어질 때 주변 선과 겹치며 생기는 덩어리가 의도에 맞는지 본다.
seed1718로 바꿔 배치의 방향 분포를 비교한다. 선택한 버전은 시드와 다른 설정을 함께 남긴다.

이번 실행에서 직접 비교한 변형은 scale의 두 값과 seed의 두 값이다. 나머지 변수는 다음 실험의 제안이다. 스케일을 바꾼 뒤에는 한 프레임의 모양만 보지 말고 한 주기를 끝까지 보아야 한다. 정지 화면에서 섬세해 보이는 결이 움직일 때는 지나치게 바쁘게 느껴질 수 있기 때문이다.

AI에게 수정안을 요청할 때도 비교 조건을 유지할 수 있다. “현재 코드에서 scale만 바꾼 두 안을 설명하고 나머지는 유지해 줘”라고 범위를 좁힌다. 결과가 마음에 들지 않으면 “더 아름답게”라는 평가 대신 어느 구간의 회전이 급한지, 선이 겹쳐 형체가 사라지는지, 전체 화면이 같은 속도로 움직여 단조로운지를 전달한다. AI가 손댄 부분을 이전 코드와 비교하는 일도 이 과정에 포함된다.

이 작은 스케치는 완성된 전시 작품이라기보다 움직임을 선택할 수 있는 출발점이다. 선이 머무는 격자, 일정한 반복 길이, 제한된 색을 유지한 채 어디까지 변형할지 결정하면 자기 작업의 형식이 드러나기 시작한다. AI가 구현을 돕더라도 어느 버전을 남길지 결정하는 기준은 실제 화면을 본 제작자의 판단에서 만들어진다.