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

만화경 그리기 — 대칭 반사 각도로 그리는 캔버스 9종

선 하나를 그리면 반사 행렬이 남은 칸을 전부 채웁니다. 거울 각도·대칭 차수·색상 환색을 직접 바꾸는 만화경 그리기 도구 — 라이브러리 없이 캔버스 한 장, 코드는 비밀번호 zip으로 드립니다.

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

데모

아홉 판 전부 index.html 한 파일씩으로 열리자마자 시드를 고정한 자동 붓질로 스스로 그립니다. 붓이 지나간 선분 하나가 setTransform 의 회전×y반전 행렬로 2n갈래에 찍히고, 사람은 포인터로 같은 계산 경로를 직접 밟을 수 있습니다. 24프레임 녹화에서 여덟 판이 23프레임, 04 극좌표 도장만 22프레임 움직였습니다.

24프레임 실측 한눈에

항목 초점 누적면적(%) 강도 평균 움직인프레임
01 기본 만화경 붓 setTransform 반사 · 2n=16갈래 5.30 125.5 23
02 대칭 차수 슬라이더 좌표 저장 · change 재계산 0.98 129.1 23
03 환색 붓 경로 길이 → hsl 색상각 0.56 148.4 23
04 극좌표 도장 링·스포크 스냅 · 0.12초 간격 19.00 80.6 22
05 사진 먹이기 drawImage 띠 · 2n개 배치 13.49 66.9 23
06 여섯 접 눈결정 60도 부채꼴 클리핑 0.71 122.9 23
07 랑골 격자 링×스포크 점 계산 · 곡선 연결 2.19 116.7 23
08 녹화와 재생 좌표 배열 기록 · 보간 재생 5.42 147.0 23
09 배경지 내보내기 사분면 회전 배치 · toDataURL 25.89 26.3 23

선묘 항목의 낮은 누적면적이 정상인 이유

드로잉 도구의 화면은 붓이 지나간 자리만 새로 생깁니다 — 01 붓 5.30%·08 재생 5.42% 는 가는 선이 쌓여 실제로 차지한 면적이지 결함이 아닙니다. 03 환색 붓 0.56%·02 슬라이더 0.98%·06 눈결정 0.71% 는 잔상 감쇠나 한 자루 붓의 가는 선이 좁게 쌓이는 구조적 특성입니다. 반대로 09 내보내기 25.89% 는 3×3 타일 전면이 매 프레임 다시 그려지기 때문에 가장 넓고, 04 도장 19.00%·05 사진 먹이기 13.49% 가 뒤따릅니다. 강도 평균은 03 환색 붓 148.4·08 재생 147.0 이 가장 밝고 09 가 26.3 으로 가장 어두운데, 03 은 붉은기 무대에 밝은 무지개 선을 겹쳐 쌓고 08 은 흰 종이 무대에 찍으며 09 는 짙은 잉크 바탕 위에 옅은 파스텔 결을 얹기 때문입니다.

왜 대칭은 그리는 순간 계산인가 — 반사 행렬

만화경의 대칭은 그려진 이미지를 복사해 돌리는 게 아니라, 선분의 좌표에 행렬을 곱해 그리기 전에 위치를 바꿔치기하는 연산입니다. 핵심은 setTransform(a, b, c, d, e, f) 의 여섯 인수 — 앞 네 개가 2×2 행렬이고 뒤 두 개가 이동입니다. 각 조각 k 에 대해 회전각 a = k·2π/n 의 코사인·사인을 구해 (c, s, s, −c) 로 물리면 이 행렬은 회전과 y축 반전이 한 번에 합쳐진 반사 행렬이 되고, 캔버스는 이후의 moveTo·lineTo 를 그 좌표계에서 해석합니다. 그래서 같은 선분 코드를 반복문 안에 그대로 두기만 해도 n 개의 부채꼴마다 서로 거울상으로 마주 보는 무늬가 찍히고, 2π/n 씩 돌아가는 이웃 조각끼리 경계에서 정확히 맞물립니다. 이미지를 만든 뒤 복제하는 방식과 다른 점은 붓이 꺾이는 즉시 모든 조각이 같은 프레임에 꺾인다는 것 — 대칭 그림 그리기에서 만화경 특유의 살아 있는 느낌은 이 동시성에서 옵니다.

아홉 도구는 왜 이 순서인가

순서는 반사 행렬 하나를 붓에 얹는 일부터 시작해 결과물을 파일로 꺼내는 일까지의 서열입니다. 먼저 선분 하나를 2n 위치에 찍는 코어를 정의하고(01), 그 코어가 몇 갈래로 갈라지는지를 차수로 묶습니다(02). 세 번째가 붓의 색을 경로 길이에 물리는 일이고(03), 네 번째부터 도구의 격이 올라갑니다 — 점을 찍어 대칭을 완성하는 도장(04), 그리는 대상을 선에서 이미지로 바꾸는 먹이기(05), 부채꼴 하나로 종이를 접은 효과를 내는 눈결정(06), 점과 곡선으로 화환을 짜는 랑골(07). 여덟 번째는 과정을 기록해 되감는 일이고(08), 마지막 아홉 번째가 완성 무늬를 반복 타일로 내보내는 완성판입니다(09). 그리는 순간마다 좌표에 행렬을 곱해 화면을 계산한다는 점에서 실을 당겨 기하 무늬를 짜는 캔버스 기하 드로잉과 같은 계산 드로잉의 이웃이고, 캔버스 한 장에서 쌓이는 결과를 눈으로 확인한다는 점은 모래 알갱이가 칸을 검사해 흐르는 캔버스 낙하 시뮬레이션과도 같은 갈래입니다.

이 아홉 판을 실제로 어디에 붙이나

드로잉 워크숍이나 온라인 클래스 랜딩 페이지의 히어로 영역에는 01 기본 만화경 붓처럼 진입 즉시 스스로 그리는 판이 어울립니다 — 방문자가 스크롤하기 전에 화면이 이미 움직이고 있어야 하는 자리라, 시드 붓질이 멈추지 않는 01 이 첫 화면을 비워 두지 않습니다. 실시간 방송이나 음악 시각화 오버레이처럼 화면이 계속 살아 있어야 하는 배경에는 경로 길이를 색으로 바꾸는 03 환색 붓이 맞습니다 — 소리나 채팅 이벤트에 붓 위치만 얹으면 반응형 배경이 됩니다. 다운로드 코너나 굿즈 배경지처럼 결과물을 파일로 가져가야 하는 자리에는 09 배경지 내보내기 하나면 충분합니다 — 사분면을 돌려 이은 타일이라 반복 배치에도 이음매가 보이지 않습니다.

아홉 도구 단계별 코드

01기본 만화경 붓 — 선 하나가 2n개로 늘어나는 반사

pointermove 로 받은 선분을 부채꼴 하나에 찍고 나머지 칸은 각도를 2π/n 씩 더한 회전·y반전 변환으로 같은 경로를 다시 그리는, 여러 판이 쓰는 코어입니다. 붓 세 자루가 2π/3 위상 차로 돌며 2n=16갈래로 갈라지고 24프레임 녹화에서 누적면적 5.30%·23프레임이 움직였습니다.

기본 만화경 붓 — 선 하나가 2n개로 늘어나는 반사
새 창에서 열기

01 · 기본 만화경 붓 — 선 하나가 2n개로 늘어나는 반사 — 측정 누적면적 5.295% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

setTransformlineTo

반사 행렬 스트로크 — 여섯 인수가 회전×y반전이다

  // 반사 행렬 스트로크 — 선분 하나를 2N 위치에 찍는다. setTransform 의 여섯 인수는
  // (a, b, c, d, e, f) = 행렬 2×2 + 이동. 여기 곱한 행렬이 곧 회전×반사다
  function mirroredSeg(x0, y0, x1, y1, ci) {
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
      ctx.strokeStyle = COLS[ci % 3];
      ctx.lineWidth = 6;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    strokes += 1;
  }

dpr 을 행렬 네 인수에 곱해 물리면 고해상도 화면에서도 선 굵기가 물리적 크기로 유지되고, 시간 t 와 위상 ph 로 극좌표 곡선을 돌려주는 pathAt 이 시드 고정 자동 붓질을 이끕니다.

02대칭 차수 슬라이더 — n을 바꾸면 무늬가 다시 선다

차수 n 을 3에서 24까지 올리면 부채꼴 각도가 좁아져 같은 선이 더 촘촘한 꽃이 되고, 지금까지 그은 좌표를 전부 배열에 저장해 뒀다가 n 이 바뀌면 화면을 지우고 전 경로를 새 각도로 다시 계산합니다. 자동 시연은 4초마다 차수를 3→6→12→18→24 로 전환해 재계산 순간을 보여주며 24프레임 녹화에서 누적면적 0.98%·23프레임이 움직였습니다.

대칭 차수 슬라이더 — n을 바꾸면 무늬가 다시 선다
새 창에서 열기

02 · 대칭 차수 슬라이더 — n을 바꾸면 무늬가 다시 선다 — 측정 누적면적 0.975% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

setTransformchange

저장된 좌표를 새 차수로 다시 찍는 재계산

  function drawSeg(s) {
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), sn = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * sn, dpr * sn, -dpr * c, cx * dpr, cy * dpr);
      ctx.strokeStyle = s[4] ? C2 : C1;
      ctx.lineWidth = 5;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(s[0], s[1]); ctx.lineTo(s[2], s[3]); ctx.stroke();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

선분 하나가 [x0, y0, x1, y1, 색] 배열로 저장되어 있으니 재계산은 저장된 배열을 순서대로 다시 찍는 일뿐이고, 슬라이더는 input 이 아니라 손을 뗀 뒤 한 번만 오는 change 이벤트로 받아 끄는 동안 무늬가 흔들리지 않게 합니다.

  order.addEventListener('change', function () { setN(parseInt(order.value, 10) || 6); });

03환색 붓 — 그리는 길이에 따라 색이 도는 브러시

지나온 경로 길이를 누적해 hue 각도로 바꾸면 선이 길어질수록 색이 무지개로 돕고, 채도 34%·명도 74% 로 잡아 만화경 특유의 강한 색을 파스텔로 누르며 잔상 감쇠로 옛 붓질이 꼬리를 남기며 흘러갑니다. 강도 평균 148.4 는 아홉 판 중 가장 밝은 값입니다.

환색 붓 — 그리는 길이에 따라 색이 도는 브러시
새 창에서 열기

03 · 환색 붓 — 그리는 길이에 따라 색이 도는 브러시 — 측정 누적면적 0.556% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

hsllineTo

경로 길이를 색상각으로 바꾸는 두 함수

  function brushColor() {
    var h = (pathLen * 0.35) % 360;
    return 'hsl(' + h.toFixed(0) + ', 34%, 74%)';
  }

  function mirroredSeg(x0, y0, x1, y1, col) {
    var dx = x1 - x0, dy = y1 - y0;
    pathLen += Math.sqrt(dx * dx + dy * dy);
    hue.textContent = String(Math.round((pathLen * 0.35) % 360));
    len.textContent = String(Math.round(pathLen));
    ctx.globalAlpha = 0.9;

경로 길이 pathLen 이 채워지는 곳은 반사 루프 안이 아니라 선분 하나가 들어온 시점이라 2n갈래 전부가 같은 색을 공유하고 길이도 n배로 부풀지 않으며, 잔상 감쇠는 반투명 덧칠이 아니라 매 프레임 무대색을 8% 로 겹쳐 옛 붓질을 녹이는 방식입니다.

  function fade() {
    // 잔상 감쇠 — 알파 0.92 가 아니라 매 프레임 8% 씩 무대색을 겹쳐 옛 붓질을 녹인다
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.globalAlpha = 0.08;
    ctx.fillStyle = STAGE;
    ctx.fillRect(0, 0, W, H);
    ctx.globalAlpha = 1;
  }

04극좌표 도장 — 반지름·각도 격자에 찍히는 스탬프

커서 위치를 반지름과 각도로 바꿔 가장 가까운 격자 점에 붙을 때 찍으면 도트 만다라가 되고, 격자는 링 4개와 스포크 12개이며 찍힌 도장은 지워지지 않습니다. 시드 고정 시연은 0.12초마다 도장 한 개를 링과 스포크를 순회하며 찍고, 누적면적 19.00% 는 선묘 판과 달리 면을 채우는 도장의 성격 그대로입니다.

극좌표 도장 — 반지름·각도 격자에 찍히는 스탬프
새 창에서 열기

04 · 극좌표 도장 — 반지름·각도 격자에 찍히는 스탬프 — 측정 누적면적 19.003% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

arclineTo

커서를 링·스포크 번호로 반올림하는 스냅

  // 스냅 — 극좌표를 링·스포크 번호로 반올림한 뒤 다시 좌표로
  function snap(px, py) {
    var dx = px - cx, dy = py - cy;
    var r = Math.min(RINGS, Math.max(1, Math.round(Math.hypot(dx, dy) / (R / RINGS))));
    var k = Math.round(Math.atan2(dy, dx) / (2 * Math.PI / N));
    k = ((k % N) + N) % N;
    var a = k * 2 * Math.PI / N;
    return [cx + r * (R / RINGS) * Math.cos(a), cy + r * (R / RINGS) * Math.sin(a), r, k];
  }
  function drawStamp(x, y, s, ci, rot) {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.translate(x, y); ctx.rotate(rot);
    ctx.fillStyle = COLS[ci % 3];
    ctx.strokeStyle = COLS[ci % 3];
    if (shape === 0) {
      // 꽃잎 다섯 — 원 5개가 한 점을 공유
      for (var p = 0; p < 5; p++) {
        var a = p * 2 * Math.PI / 5;
        ctx.beginPath();
        ctx.arc(s * 0.62 * Math.cos(a), s * 0.62 * Math.sin(a), s * 0.42, 0, 2 * Math.PI);
        ctx.fill();
      }

꽃잎 모양은 한 점을 공유하는 원 다섯 개, 별은 lineTo 열여섯 번으로 꼭짓점을 찍는 뾰족별, 세 번째는 점 세 개를 이은 얼룩이며 도장 크기는 링 번호에 비례해 커지고, 커서는 Math.atan2 결과를 부채꼴 각도 2π/n 으로 나눠 반올림한 가장 가까운 스포크로 붙어 음수 각도는 ((k % N) + N) % N 로 접힙니다.

05사진 먹이기 — 이미지 조각을 거울에 흘려넣기

drawImage 로 소스 캔버스의 좁은 세로 띠를 잘라 부채꼴 안에서 천천히 이동시키면 실물 만화경처럼 풍경이 무늬가 되는데, 소스는 시드를 고정한 파라미터 곡선으로 짠 비단결이고 띠 위치가 시간에 따라 흐르는 입력이라 드래그하면 손가락이 띠 위치를 직접 끕니다. 누적면적 13.49%·23프레임이 움직였습니다.

사진 먹이기 — 이미지 조각을 거울에 흘려넣기
새 창에서 열기

05 · 사진 먹이기 — 이미지 조각을 거울에 흘려넣기 — 측정 누적면적 13.488% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

drawImagesetTransform

띠 하나를 부채꼴에 감는 배치

  // 조각 배치 — k번째 부채꼴에 띠 하나. 띠 위치 p(0~1) 는 k 와 시간으로 조금씩 다르게
  function render(p) {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.fillStyle = STAGE;
    ctx.fillRect(0, 0, W, H);
    var band = SW / N;                        // 부채꼴마다 맡은 띠 폭
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
      // 부채꼴 뒤집기(반사) — 같은 띠를 좌우로 뒤집어 이웃과 맞물리게
      var flip = (k % 2 === 0) ? 1 : -1;
      ctx.setTransform(dpr * c, dpr * s * flip, dpr * s, -dpr * c * flip, cx * dpr, cy * dpr);
      var sx = ((p * (SW - band) + k * band * 0.13) % SW + SW) % SW;
      // 띠를 원뿔에 감듯: 세로를 반경으로 눕히고 가로를 호 방향으로 늘린다
      ctx.drawImage(src, sx, 0, band, SH, -R * 0.18, -R, R * 0.36, R * 2);
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    pos.textContent = String(Math.round(p * 100));
  }

flip 이 짝수 조각과 홀수 조각을 번갈아 뒤집어 이웃한 부채꼴이 거울상으로 맞물리게 하고, 띠의 가로 시작점 sx 는 k 마다 조금씩 다르게 흘러 같은 사진이 두 번 반복되지 않습니다.

06여섯 접 눈결정 — 종이를 접지 않고 접는 법

부채꼴 하나를 60도로 좁히고 가장자리 반사를 하나 더 얹으면 종이를 여섯 번 접어 자른 눈결정이 그대로 나오는데, 붓질이 경계 밖으로 나가는 순간 클리핑이 잘라내고 사용자가 어느 각도에 그려도 좌표가 첫 접 안쪽으로 접혀 가위 역할을 붓이 대신합니다. 24프레임 녹화에서 누적면적 0.71%·23프레임이 움직였습니다.

여섯 접 눈결정 — 종이를 접지 않고 접는 법
새 창에서 열기

06 · 여섯 접 눈결정 — 종이를 접지 않고 접는 법 — 측정 누적면적 0.714% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

setTransformlineTo

부채꼴 클리핑으로 경계 밖 붓질을 자른다

  // 반사 복제 — 세그먼트를 여섯 접에 찍는다. 부채꼴 클리핑은 beginPath-clip 로
  function mirroredSeg(x0, y0, x1, y1, ci) {
    for (var k = 0; k < N; k++) {
      var a = A0 + k * WEDGE, c = Math.cos(a), s = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
      // 부채꼴(0 <= 각 <= WEDGE, 지역좌표) 클리핑 — 경계 밖 붓질이 잘리는 규칙
      ctx.save();
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.arc(0, 0, R + 2, 0, WEDGE);
      ctx.closePath();
      ctx.clip();
      ctx.strokeStyle = ci ? C2 : C1;
      ctx.lineWidth = 4.5;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(x0 - cx, y0 - cy); ctx.lineTo(x1 - cx, y1 - cy); ctx.stroke();
      ctx.restore();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    strokes += 1;
    cnt.textContent = String(strokes);
  }

선분 좌표에서 중심을 빼 지역좌표로 옮긴 뒤 원점에서 arc(0, 0, R+2, 0, WEDGE) 로 부채꼴 경로를 만들어 클립하면 이 조각의 그리기는 그 60도 안에서만 일어나고, 어떤 각도로 그려도 첫 접으로 접는 foldPoint 가 각도를 두 폭(2×60도) 주기로 정규화한 뒤 절반을 넘으면 2·span − rel 로 접어 회전 대칭과 반사 대칭을 modular 연산 하나로 함께 처리합니다.

  // 부채꼴 안으로 좌표를 뒤집어 주는 그리기 좌표 — 사용자가 어디를 그려도 첫 접에 사상
  function foldPoint(px, py) {
    var dx = px - cx, dy = py - cy;
    var a = Math.atan2(dy, dx);
    var r = Math.min(R, Math.hypot(dx, dy));
    // 각도를 [A0, A0+WEDGE] 로 접는다 — 육각 대칭이라 어떤 각이든 이 부채꼴로 사상 가능
    var span = 2 * Math.PI / N;
    var rel = ((a - A0) % (2 * span) + 2 * span) % (2 * span);
    if (rel > span) rel = 2 * span - rel;         // 넓은 쪽이면 반사로 접힘
    var a2 = A0 + rel;
    return [cx + r * Math.cos(a2), cy + r * Math.sin(a2)];
  }

07랑골 격자 — 점 몇 개로 짜는 대칭 화환

극좌표 격자 위에 점을 찍으면 대칭 전체에 같은 점이 도는 배치가 계산되는데, 링 4개×스포크 16개의 점이 0.045초 간격으로 성상처럼 차오르고 다 차면 점 사이를 잇는 곡선이 무늬 세 가지(꽃잎·지그재그·별그물)로 이어집니다. 누적면적 2.19%·23프레임이 움직였습니다.

랑골 격자 — 점 몇 개로 짜는 대칭 화환
새 창에서 열기

07 · 랑골 격자 — 점 몇 개로 짜는 대칭 화환 — 측정 누적면적 2.192% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

arcchange

링과 스포크 번호를 좌표로 바꾸는 계산

  function pt(ring, k) {
    var off = (pattern === 1 && ring % 2 === 1) ? 0.5 : 0;   // 지그재그는 링마다 반 칸
    var r = R * (ring + 1) / (RINGS + 1);
    var a = (k + off) * 2 * Math.PI / N;
    return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
  }

점의 자리는 ring 과 k 두 숫자뿐이라 대칭은 배열에 점을 64개 담지 않고도 성립하고 지그재그 무늬는 링마다 반 칸씩 스포크를 비껴 간 것으로 절반의 코드로 다른 화환이 되며, 곡선은 두 점의 중점을 잇는 방향의 수직으로 0.35 만큼 밀어 만든 제어점이 quadraticCurveTo 로 활짝 휘는 꽃잎을 별도의 곡선 수식 없이 계산합니다.

  // 곡선 연결 — 링 ring 과 ring+1 사이. 무늬별로 잇는 상대 스포크가 다르다
  function drawLink(ring, k) {
    var a0 = pt(ring, k), a1;
    var step = pattern === 2 ? Math.round(N / 5) : (pattern === 1 ? 0.5 : (ring % 2 === 0 ? 1 : -1));
    if (pattern === 1) {
      a1 = pt(ring + 1, k + ((k % 2 === 0) ? 0.5 : -0.5));
    } else {
      a1 = pt(ring + 1, k + step);
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.strokeStyle = C2; ctx.lineWidth = 1.4;
    ctx.beginPath();
    ctx.moveTo(a0[0], a0[1]);
    var mx = (a0[0] + a1[0]) / 2 + (a1[1] - a0[1]) * 0.35;
    var my = (a0[1] + a1[1]) / 2 - (a1[0] - a0[0]) * 0.35;
    ctx.quadraticCurveTo(mx, my, a1[0], a1[1]);
    ctx.stroke();
  }

08녹화와 재생 — 그린 과정을 되감는 루프

좌표 배열을 [진행도, x, y] 단위로 저장해 두면 지운 뒤에도 같은 순서로 다시 그릴 수 있는데, 3초 녹화가 끝나면 화면이 지워지고 기록이 같은 속도로 재생되며 루프마다 색이 번갈아 오고 "다시 녹화" 단추는 언제든 처음으로 돌아갑니다. 누적면적 5.42%·23프레임이 움직였습니다.

녹화와 재생 — 그린 과정을 되감는 루프
새 창에서 열기

08 · 녹화와 재생 — 그린 과정을 되감는 루프 — 측정 누적면적 5.417% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramesetTransformarc(

기록은 진행도를 함께 저장한다

  // 녹화 — 붓이 지나간 자리만 즉시 반사 찍힌다 (잔상 축적)
  function recStep(du) {
    recU += du;
    var u = Math.min(1, recU / REC_DUR);
    var prev = record.length ? [record[record.length - 1][1], record[record.length - 1][2]] : null;
    var p = pathAt(u);
    record.push([u, p[0], p[1]]);
    pts.textContent = String(record.length);
    if (prev) mirroredSeg(prev[0], prev[1], p[0], p[1], C1);
    fill.style.width = String(Math.round(u * 100)) + '%';
    if (u >= 1) { phase = 1; playU = 0; playPrev = null; clear(); }
  }

프레임 번호가 아니라 진행도 u(0~1) 를 함께 저장하기 때문에 재생 속도를 바꾸어도 기록 자체는 무결하고 REC_DUR 하나로 녹화 길이와 재생 길이가 같이 정해지며, 재생은 u 를 기록 길이에 곱해 인덱스와 소수 부분을 가른 뒤 두 점을 소수 부분만큼 섞은 좌표를 찍어 녹화 때와 같은 밀도의 선이 프레임과 무관하게 이어지게 합니다.

  // 재생 — 기록을 같은 속도로 되찍는다. 루프마다 색이 번갈아 온다
  function playStep(du) {
    playU += du;
    var u = playU / REC_DUR;
    if (u >= 1) { loops += 1; loop.textContent = String(loops); playU = 0; playPrev = null; u = 0; }
    var col = (loops % 2 === 0) ? C2 : C1;
    // 기록 보간 — u 에 가장 가까운 두 점
    var i = Math.min(record.length - 1, Math.floor(u * (record.length - 1)));
    var a = record[i], b = record[Math.min(record.length - 1, i + 1)];
    var f = (u * (record.length - 1)) - i;
    var x = a[1] + (b[1] - a[1]) * f, y = a[2] + (b[2] - a[2]) * f;
    if (playPrev) mirroredSeg(playPrev[0], playPrev[1], x, y, col);
    playPrev = [x, y];

09배경지 내보내기 — 그린 무늬를 반복 타일로

완성된 무늬를 사분면 캔버스에 그려 네 조각으로 나눠 배치하면 네 모서리가 이어지는 반복 타일이 되는데, 화면은 그 타일을 3×3 으로 깔아 이음새를 검사하고 toDataURL 로 짠 PNG 를 미리보기 배경에 실제로 심어 확인합니다. 누적면적 25.89% 는 아홉 판 중 가장 넓고 23프레임 전부가 움직였습니다.

배경지 내보내기 — 그린 무늬를 반복 타일로
새 창에서 열기

09 · 배경지 내보내기 — 그린 무늬를 반복 타일로 — 측정 누적면적 25.892% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

toDataURLfillRect

사분면 캔버스와 타일 캔버스를 나눠 만든다

  // 4분할 회전 배치 — 좌상(0도)·우상(90)·우하(180)·좌하(270). 변환 행렬이 곧 회전
  function assembleTile() {
    var qd = q.width;                  // 사분면 device px — 정사각형
    tc.setTransform(1, 0, 0, 1, 0, 0);
    tc.clearRect(0, 0, tw.width, tw.height);
    tc.drawImage(q, 0, 0);                                    // 좌상 0도
    tc.setTransform(0, 1, -1, 0, qd * 2, 0); tc.drawImage(q, 0, 0);   // 우상 90도
    tc.setTransform(-1, 0, 0, -1, qd * 2, qd * 2); tc.drawImage(q, 0, 0); // 우하 180도
    tc.setTransform(0, -1, 1, 0, 0, qd * 2); tc.drawImage(q, 0, 0);   // 좌하 270도
    tc.setTransform(1, 0, 0, 1, 0, 0);
  }

같은 사분면을 회전 행렬 네 개로 네 번 찍는 것이 배치의 전부인데 이 회전 배치에서 네 변의 무늬가 서로를 이어받기 때문에 타일을 격자로 깔아도 모서리에서 끊기지 않고, toDataURL 의 결과를 이미 보이는 요소의 배경으로 넣어 버리는 것이 검증이라 내보낸 파일이 정말 이음새 없이 반복되는지 미리보기가 곧 증거가 됩니다.

    // 내보내기 — 완성 타일을 데이터 URL 로 짜서 미리보기 배경에 심는다
    var url = tw.toDataURL('image/png');
    tile.style.backgroundImage = 'url(' + url + ')';

대칭 차수를 바꾸면 무늬가 왜 무너지고 다시 서는가

차수 n 을 바꾸는 순간 이미 찍힌 화면의 부채꼴 경계는 새 각도와 어긋납니다 — 화면은 픽셀이 쌓인 결과라 과거의 n 을 기억하지 못하니, 지금 상태를 그대로 두면 반사 축이 두 벌이 겹쳐 무늬가 한동안 깨져 보입니다. 02 슬라이더 판이 취하는 답은 화면을 믿지 않고 좌표를 믿는 것입니다. 붓질을 [x0, y0, x1, y1, 색] 배열로 쌓아 두었다가 n 이 바뀌면 캔버스를 지우고 배열 전체를 새 각도의 반사 행렬로 처음부터 다시 찍습니다. 재계산이 한 프레임 안에 끝나니 사람 눈에는 무너지는 순간 없이 무늬가 조용히 다시 서는데, 4초마다 3→6→12→18→24 로 차수가 오르는 자동 시연에서 이 장면을 직접 볼 수 있습니다. n 이 커질수록 부채꼴 각도 360/n 이 좁아져 같은 붓질이 더 촘촘한 꽃이 되는 것도 배열 재계산이기에 가능한 일 — 붓이 다시 그릴 필요 없이 행렬의 각도만 바뀝니다.

y반전을 빼거나 지우지 않고 다시 그리면 무늬가 깨지는 함정

y반전을 빼면 만화경이 부채꼴 모자이크가 된다

setTransform 의 네 행렬 인수에서 −c 대신 c 를 넣으면 회전만 남고 반사가 사라집니다. 이렇게 되면 이웃한 조각들이 거울상이 아니라 같은 방향의 회전 사본이 되어 경계에서 무늬가 뒤집히지 않은 채 맞닿습니다 — 눈은 즉시 "만화경이 아니라 타일을 돌려 붙인 패치워크"로 읽습니다. 만화경의 정체는 회전 대칭이 아니라 회전과 반사가 번갈아 나타나는 이른바 다이헤드럴 대칭이라, 반사 행렬을 쓰는 판들의 (c, s, s, −c) 에서 부호 하나가 그 성질 전부를 담고 있습니다.

지우고 다시 그리기를 잊으면 좌표가 누적된 흔적 위에 쌓인다

02 판에서 n 을 바꿀 때 clearRect 없이 재계산하면 옛 각도의 무늬가 새 각도의 무늬 밑에 그대로 남아 두 벌의 대칭이 겹칩니다. 사람이 그린 좌표 배열은 무결해도 화면이 과거를 버리지 않아 생기는 문제라, 재계산 함수의 첫 줄에서 캔버스를 비우는 것이 이 도구의 위생입니다. 이 아홉 실험판의 소스를 그대로 묶은 암호 zip 의 비밀번호는 mqv4cpdt 입니다 — 풀면 각 판의 전체 코드를 본문의 조각과 이어서 읽어보실 수 있습니다.

도장 스냅에서 음수 각도를 접지 않으면 격자가 갈라진다

04 판의 snap 에서 ((k % N) + N) % N 계산을 빼면 atan2 가 돌려주는 음수 각도(캔버스 좌표계에서 9시~12시 방향)가 그대로 스포크 번호가 되어, 그 방향에서 격자 점이 두 벌로 갈라집니다. 나머지 연산의 결과가 언어마다 부호를 따른다는 사실이 극좌표 스냅에서는 눈에 바로 드러나는 함정입니다.

값 하나로 갈리는 세 변형과 동작을 줄인 사용자를 위한 접근성

값 하나를 바꾼 세 변형

이름 바꾼 값 느낌
대칭 차수 02 차수 8 → 16 차수가 두 배가 되면 같은 붓질이 화려해지는 대신 한 띠의 폭은 절반으로 좁아져 여백의 미가 사라진다
붓 흔들림 01 지터 0px → 3px 손떨림을 얹으면 기하학적 무늬에 손으로 그린 온기가 돌지만, 지나치면 반사 대칭이 흐려져 만화경이 스케치가 된다
선 감쇠 03 잔상 1.0 → 0.92 매 프레임 아주 옅게 지우면 붓자국이 꼬리를 남기며 흘러가고, 감쇠를 1.0 으로 두면 모든 순간이 그대로 쌓인다

동작을 줄인 사용자에게 남기는 장면

아홉 판 모두 시작 시 prefers-reduced-motion: reduce 를 검사해 켜져 있으면 애니메이션 루프를 돌리지 않고 완성 상태를 미리 계산해 한 장으로 그립니다. 01 은 시간 4초 분의 붓질을, 02 는 차수 12 의 완성 무늬를, 08 은 녹화 완료와 재생 궤적을 정적으로 보여 주므로 정지 사용자에게도 빈 캔버스가 아니라 무늬가 남습니다.

스크린리더와 키보드로 만지는 아홉 판

판마다 role="application" 과 aria-label · tabindex 가 붙어 포인터 없이 진입할 수 있고, 차수 값·경로 길이·도장 수·재생 바퀴 수 같은 핵심 숫자는 갱신되는 텍스트로 읽힙니다. 슬라이더와 무늬 선택은 change 이벤트로 받아 화면 흔들림 없이 결과만 바뀝니다.

아홉 판을 열기 전에 자주 묻는 FAQ

만화경 만들기를 코딩으로 처음 해보는 사람도 따라갈 수 있나요?

핵심은 01 판의 mirroredSeg 함수 하나입니다 — 반복문으로 각도를 2π/n 씩 더하며 setTransform 을 물리고 같은 선을 찍는 열두 줄이 이 글 전부의 심장이고, 나머지 판은 여기에 색·격자·기록·내보내기를 얹은 것입니다. zip 안에는 아홉 판을 같은 전제로 다시 짜라고 시키는 지시 문서가 들어 있어 코드 도구에 붙여 넣으면 같은 화면부터 시작합니다.

대칭 그림 그리기 사이트 없이 이 코드를 통째로 가져다 써도 되나요?

그렇습니다. 아홉 실험판은 각자 index.html 파일 하나로 열리고 빌드 도구도 CDN 도 웹폰트도 없습니다. zip 을 풀면 판마다 폴더가 하나씩 나오니 원하는 것을 골라 브라우저로 열거나 서버에 올리면 그대로 도는, 이 사이트의 다른 데모와 같은 구조입니다.

그린 무늬를 이미지 파일로 저장하려면 무엇을 고쳐야 하나요?

09 판이 이미 그 길을 담고 있습니다 — 타일 캔버스를 toDataURL('image/png') 로 짜서 미리보기 배경에 심는 코드를, a 요소의 download 속성에 같은 데이터 URL 을 걸도록 한 줄만 바꾸면 클릭으로 내려받는 배경지가 됩니다. 반복 타일로 쓰려면 09 의 사분면 회전 배치를 그대로 두면 모서리가 이어집니다.

더 읽을거리

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

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