CSS 배경 패턴 9종 — 시드 하나로 다시 그리기
css 배경 패턴을 이미지 파일로 깔면 화면마다 같은 그림이 나옵니다. 여기 아홉 개는 숫자 하나(시드)를 넣으면 그 자리에서 무늬를 새로 계산해 SVG 로 뱉고, 같은 숫자를 다시 넣으면 똑같은 그림이 돌아옵니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 노이즈 등고선 지도
- 02 보로노이 셀 타일
- 03 플로우 필드 선 그림
- 04 회전 대칭 만다라
- 05 트루셰 타일 미로
- 06 육각 격자 색칠
- 07 재귀 분할 사각형
- 08 가지치기 나무
- 09 이름으로 만든 배경
아홉 개를 늘어놓은 기준은 인기순이 아니라 난수를 어디에 쓰는가입니다. 앞의 셋은 난수를 자리 전체에 뿌립니다 — 격자마다 높이를 뽑아 등고선으로 자르고(01), 흩뿌린 점에 영역을 나눠 주고(02), 자리마다 각도를 정해 선을 흘려보냅니다(03). 가운데 셋은 난수를 한 번만 쓰고 나머지는 규칙이 복사합니다 — 획 하나를 열두 번 돌린 만다라(04), 타일 한 장을 네 각도로만 놓는 트루셰(05), 자리에 따라 기운 확률로 칸을 칠하는 육각 격자(06). 마지막 셋은 난수를 재귀에 먹입니다 — 자르고 또 자르는 사각형(07), 갈라지고 또 갈라지는 나무(08), 그리고 시드가 숫자가 아니라 문자열인 이름 배경(09). 배경이 스스로 움직이는 쪽이 필요하면 CSS 배경 애니메이션 9종이, 같은 SVG 로 수치를 그리는 쪽은 css 그래프 만들기 SVG로 9종에 있습니다.
01노이즈 등고선 지도
격자 꼭짓점에만 난수를 놓고 사이는 smoothstep 으로 이어 붙이면 울퉁불퉁한 높이 지도가 됩니다. 마칭 스퀘어로 같은 높이를 잇는 선을 여섯 겹 뽑아 지형도처럼 보이게 했습니다. 미리보기에서 누적면적 11.93%, 강도 94.5, 움직인 프레임 16/23 을 기록했고 소개 페이지 첫 화면 배경에 씁니다.
// mulberry32 — 32비트 정수 시드 하나로 언제나 같은 난수열을 돌려준다. 재현 가능한 생성의 뿌리
function mulberry32(a) {
return function () {
a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
@keyframes ncScan {
0% { stroke-dashoffset: 1; animation-timing-function: $easing; }
38% { stroke-dashoffset: 0; animation-timing-function: linear; }
50% { stroke-dashoffset: 0; animation-timing-function: $ease-out; }
86% { stroke-dashoffset: -1; }
100% { stroke-dashoffset: -1; }
}
02보로노이 셀 타일
점 스물여섯 개를 흩뿌리고 각 점이 "나한테 제일 가까운 영역"을 가져가면 유리 조각처럼 갈라진 면이 됩니다. 바깥 사각형을 다른 점과의 수직이등분선으로 차례차례 잘라 내면 되니 전용 알고리즘을 따로 불러올 필요가 없습니다. 누적면적 19.07%, 강도 72.5, 움직인 프레임 23/23 — 가격표 섹션 배경으로 씁니다.
// 두 점의 수직이등분선으로 다각형을 잘라 a 쪽 반평면만 남긴다 (서덜랜드-호지먼 클리핑)
function clip(poly, a, b) {
var mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
var dx = b[0] - a[0], dy = b[1] - a[1], out = [], i;
function side(p) { return (p[0] - mx) * dx + (p[1] - my) * dy; }
for (i = 0; i < poly.length; i++) {
var p = poly[i], q = poly[(i + 1) % poly.length], sp = side(p), sq = side(q);
if (sp <= 0) { out.push(p); }
if ((sp < 0 && sq > 0) || (sp > 0 && sq < 0)) {
var t = sp / (sp - sq);
out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
}
}
return out;
}
03플로우 필드 선 그림
자리마다 각도를 하나씩 정해 둔 판(방향장) 위에서 짧은 걸음을 서른 번 이어 붙이면 긴 곡선이 나옵니다. 시작점 쉰네 개를 흔들어 놓은 격자에서 뽑아 화면 전체에 고르게 깔았습니다. 누적면적 5.71%, 강도 90.8, 움직인 프레임 23/23 — 음악이나 전시 페이지의 전면 배경에 씁니다.
// 한 점에서 시작해 그 자리의 각도로 한 걸음씩 옮기며 좌표를 이어 붙인다
function walk(angleAt, x, y) {
var d = 'M' + x.toFixed(1) + ' ' + y.toFixed(1), i;
for (i = 0; i < STEPS; i++) {
var a = angleAt(x, y);
x += Math.cos(a) * STEP;
y += Math.sin(a) * STEP;
if (x < -8 || x > W + 8 || y < -8 || y > H + 8) { break; }
d += 'L' + x.toFixed(1) + ' ' + y.toFixed(1);
}
return d;
}
04회전 대칭 만다라
난수를 쓰는 자리는 겹마다 "중심에서 위로 뻗는 획 하나"뿐이고, 그 겹의 나머지 열한 개는 그 획을 30도씩 돌린 복사본입니다. 대칭은 난수를 아껴 쓰고도 무늬가 되는 가장 싼 방법입니다. 겹 세 개에 도형 37개로 누적면적 11.88%, 강도 76.2, 움직인 프레임 23/23 — 명상 앱 시작 화면이나 초대장 장식 원에 씁니다.
for (ring = 0; ring < RINGS; ring++) {
var d = stroke(rand, 62 - ring * 17), inner = '';
for (arm = 0; arm < ARMS; arm++) {
// 획 하나를 30도씩 돌려 붙인다 — 회전 대칭은 난수를 한 번만 쓰고도 무늬가 되는 가장 싼 방법
inner += '<path transform="translate(' + CX + ' ' + CY + ') rotate(' + (arm * 360 / ARMS) + ')" d="' + d + '"/>';
}
parts.push('<g class="md__ring md__ring--' + (ring % 2 ? 'b' : 'a') + '" style="--i:' + ring + '">' + inner + '</g>');
}
05트루셰 타일 미로
타일은 한 장뿐입니다. 이웃한 두 변의 한가운데를 잇는 사분원 두 개를 그려 두고 0·90·180·270도 중 하나로 놓기만 하면, 칸 경계를 넘어 이어지는 곡선이 저절로 생깁니다. 60칸에서 누적면적 26.12%, 강도 150.0, 움직인 프레임 22/23 — 404 페이지 배경이나 카드 격자 뒤 무늬에 씁니다.
for (row = 0; row < ROWS; row++) {
for (col = 0; col < COLS; col++) {
var turn = Math.floor(rand() * 4) * 90;
var tone = rand() < 0.28 ? 'b' : 'a';
// 바깥 g 는 자리만 잡는다(정적). 애니메이션이 붙는 안쪽 g 에 CSS transform 을 쓰면
// transform 속성이 덮여 타일이 제자리를 잃기 때문이다
parts.push('<g transform="translate(' + col * SIZE + ' ' + row * SIZE + ')">' +
'<g class="tr__tile tr__tile--' + tone + '" style="--i:' + (col * ROWS + row) + '">' +
'<path transform="rotate(' + turn + ' 12 12)" d="' + TILE + '"/></g></g>');
}
}
06육각 격자 색칠
칸 색을 순수 난수로만 고르면 소금을 뿌린 것처럼 흩어져 무늬가 안 보입니다. 왼쪽에서 오른쪽으로 기운 확률에 난수를 섞으면 자리가 결과에 남아 색이 흐르는 것처럼 보입니다. 육각형 77개로 누적면적 25.93%, 강도 84.4, 움직인 프레임 23/23 — 대시보드 빈 화면이나 통계 페이지 헤더 띠에 씁니다.
// 납작한 육각형 여섯 꼭짓점 — 0도부터 60도씩
function hexPoints(cx, cy) {
var pts = [], k;
for (k = 0; k < 6; k++) {
var a = k * Math.PI / 3;
pts.push((cx + R * Math.cos(a)).toFixed(1) + ',' + (cy + R * Math.sin(a)).toFixed(1));
}
return pts.join(' ');
}
07재귀 분할 사각형
큰 사각형을 한 번 자르고, 나온 조각을 같은 규칙으로 또 자릅니다. 함수가 자기 자신을 부르며 깊이를 하나씩 줄이는 게 전부이고, 긴 쪽을 자르도록 확률을 기울여야 가늘고 긴 조각이 안 생깁니다. 조각 27개에서 누적면적 14.92%, 강도 77.2, 움직인 프레임 23/23 — 포트폴리오 표지에 씁니다.
// 자기 자신을 두 번 부르며 깊이를 하나씩 줄인다. 더 못 자를 만큼 작아지면 그 조각을 결과에 넣는다
function split(x, y, w, h, depth, rand, out) {
if (depth === 0 || (w < MIN * 2 && h < MIN * 2)) { out.push([x, y, w, h]); return; }
var cutDown = w > h ? rand() < 0.82 : rand() < 0.18; // 긴 쪽을 자르는 쪽으로 기울인다
var t = 0.32 + rand() * 0.36;
if (cutDown) {
split(x, y, w * t, h, depth - 1, rand, out);
split(x + w * t, y, w * (1 - t), h, depth - 1, rand, out);
} else {
split(x, y, w, h * t, depth - 1, rand, out);
split(x, y + h * t, w, h * (1 - t), depth - 1, rand, out);
}
}
08가지치기 나무
07 과 같은 재귀인데 자르는 대신 뻗습니다. 가지 하나를 그린 뒤 그 끝에서 자기 자신을 두 번 부르고, 갈라질 때마다 길이는 0.70~0.82 배, 굵기는 0.72 배로 줄입니다. 가지 63개에서 누적면적 3.97%, 강도 125.2, 움직인 프레임 16/23 — 환경 페이지 삽화나 로딩 화면의 자라나는 그림에 씁니다.
function branch(x, y, angle, len, width, depth, rand) {
var x2 = x + Math.cos(angle) * len, y2 = y + Math.sin(angle) * len;
var gen = DEPTH - depth;
var inner = '<line class="bt__line bt__line--' + (gen > 3 ? 'b' : 'a') + '" x1="' + x.toFixed(1) +
'" y1="' + y.toFixed(1) + '" x2="' + x2.toFixed(1) + '" y2="' + y2.toFixed(1) +
'" stroke-width="' + width.toFixed(2) + '"/>';
if (depth > 0) {
var spread = 0.3 + rand() * 0.34;
inner += branch(x2, y2, angle - spread, len * (0.7 + rand() * 0.12), width * 0.72, depth - 1, rand);
inner += branch(x2, y2, angle + spread, len * (0.7 + rand() * 0.12), width * 0.72, depth - 1, rand);
}
// --ox/--oy 는 그 가지의 뿌리 좌표다. CSS 가 이 점을 transform-origin 으로 받아 거기서 자라게 한다
return '<g class="bt__seg" style="--i:' + gen + ';--ox:' + x.toFixed(1) + 'px;--oy:' + y.toFixed(1) +
'px">' + inner + '</g>';
}
09이름으로 만든 배경
여기서는 시드가 숫자가 아니라 문자열입니다. 이름을 FNV-1a 로 접어 32비트 정수 하나를 얻고 이를 난수 시드로 넘기면, 같은 이름은 언제나 같은 무늬로 돌아옵니다. 왼쪽 절반만 뽑아 오른쪽을 거울처럼 되비춘 도형 60개로 누적면적 11.03%, 강도 72.5, 움직인 프레임 23/23 — 프로필 헤더 배경에 씁니다.
// FNV-1a: 글자 하나씩 섞어 32비트 정수 하나로 접는다. 같은 글자열은 언제나 같은 숫자가 된다
function hash(text) {
var h = 2166136261, i;
for (i = 0; i < text.length; i++) {
h = Math.imul(h ^ text.charCodeAt(i), 16777619);
}
return h >>> 0;
}
어디서 깨지는가 — 재귀는 화면에서도 겹쳐야 한다
08 을 처음 만들 때 가지 63개를 전부 형제로 늘어놓았습니다. 좌표는 완성된 나무 기준으로 계산해 두고 가지마다 제 뿌리에서 자라게 했으니 맞을 것 같았는데, 접히는 구간의 대표 이미지에는 줄기가 사라진 자리에 잔가지만 공중에 떠 있었습니다. 세대마다 70ms 씩 시차를 줘서 줄기(지연 0ms)가 먼저 접히고 잔가지(지연 350ms)는 아직 남아 있었기 때문입니다. 자동 재생 루프를 눈으로 보기 전까지 게이트는 전부 통과였고 그때 값은 누적면적 1.83%, 강도 74.5, 움직인 프레임 18/23 이었습니다(run/293/_수리전실측.json). 고치는 방법은 자식 가지를 부모 <g> 안에 넣는 것 하나였습니다. 그러면 부모의 배율이 자식까지 함께 끌고 가서 어느 순간에 멈춰 세워도 나무는 나무입니다.
같은 뿌리의 함정이 하나 더 있습니다 — 요소 수입니다. 생성하는 코드는 격자 수만 올리면 도형이 얼마든지 늘어나는데, 그 전부에 animation-delay 가 걸리면 320px 짜리 저사양 화면에서는 스크롤이 먼저 무너집니다. 그래서 아홉 개 전부 상한을 정해 두고 시드를 열 번 넘겨 실제로 몇 개가 나오는지 셌습니다(run/293/_요소수.json).
| 항목 | 기본 시드의 도형 수 | 시드 10회 최대 | 늘어나는 축 |
|---|---|---|---|
| 01 등고선 | 6 | 6 | 등고선 겹수(6) — 표본 격자 32×20 은 겹 안에서만 커진다 |
| 02 보로노이 | 26 | 26 | 점 개수(26) |
| 03 플로우 필드 | 54 | 54 | 시작점 격자 9×6 |
| 04 만다라 | 37 | 37 | 겹 3 × 획 12 + 중심 원 1 |
| 05 트루셰 | 60 | 60 | 타일 격자 10×6 |
| 06 육각 격자 | 77 | 77 | 벌집 11×7 |
| 07 재귀 분할 | 27 | 32 | 깊이 5 — 난수라 시드마다 다르다 |
| 08 나무 | 63 | 63 | 깊이 5 (2⁶−1) |
| 09 이름 배경 | 60 | 60 | 격자 12×8 의 채워진 칸 |
표에서 유일하게 값이 흔들리는 07 도 32개에서 멈춥니다. 무늬를 더 촘촘하게 만들고 싶으면 도형 수를 늘리지 말고 한 도형을 더 잘게 그리는 쪽을 고르세요 — 01 은 도형이 여섯 개뿐인데도 선이 가장 빽빽합니다. 아홉 개 파일과 React 판이 든 zip 의 압축 비밀번호는 69g487dg 이고, 내려받으면 마칭 스퀘어 표와 보로노이 클리핑까지 손대지 않고 그대로 돌아갑니다.
접근성
아홉 개 모두 SVG 에 role="img" 와 aria-label 을 달았고, 라벨 안에는 지금 그려진 시드 값이 들어갑니다. 스크린 리더 사용자도 "다시 생성"을 누르면 무엇이 바뀌었는지 값으로 알 수 있어야 하기 때문입니다. 색은 정보를 나르지 않습니다 — 색 단계는 장식이고 무늬의 뜻은 모양과 자리에 있습니다. 글자 대비는 실측했습니다: 잉크 무대 위 시드 라벨 11.16:1, 오렌지 무대(#ff4d1f) 위 시드 라벨 5.5:1, 페이퍼·옐로 무대 위 7.27:1, 모든 버튼 안 글자 17.11:1 로 전부 AA 기준 4.5:1 을 넘습니다(run/293/_대비실측.json). prefers-reduced-motion: reduce 에서는 아홉 개 모두 애니메이션을 끄되 완성된 무늬는 그대로 남깁니다. 움직임을 줄이라는 설정이 "배경을 지워라"는 뜻은 아니기 때문입니다. reduce 블록 안 선언에는 !important 를 붙였습니다. 여기서는 reduce 블록이 .nc.is-demo .nc__line 과 같은 셀렉터를 뒤에서 다시 선언하므로 없어도 이기지만, 나중에 특이도가 더 높은 규칙이 위에 얹혀도 움직임이 되살아나지 않게 막아 두는 쪽을 골랐습니다.
FAQ
같은 시드인데 브라우저마다 그림이 다를 수 있나요?
그럴 일은 없습니다. mulberry32 는 32비트 정수 연산(Math.imul, >>>)만 쓰고 부동소수점 누적에 기대지 않아 어느 엔진에서나 같은 수열이 나옵니다. 좌표 계산에 들어가는 Math.cos·Math.sin 은 구현마다 마지막 자리가 갈릴 수 있지만 toFixed(1) 로 소수점 한 자리에서 반올림하므로 결과 문자열이 같습니다.
배경 이미지 파일 대신 이걸 쓰면 뭐가 좋나요?
파일 하나가 사라집니다. 1,000명에게 다른 무늬를 주려고 이미지 1,000장을 만들 필요가 없고, 09 처럼 사용자 이름만 넘기면 그 자리에서 나옵니다. 만들어진 SVG 는 "SVG 복사" 버튼으로 그대로 가져갈 수 있으니 마음에 드는 시드 하나를 골라 정적 파일로 박아 둘 수도 있습니다.
매 프레임 다시 그리지 않고 CSS 로만 움직이는 이유가 뭔가요?
requestAnimationFrame 으로 그리면 탭이 백그라운드로 가거나 화면이 바쁠 때 프레임이 통째로 밀립니다. 생성은 한 번만 하고 움직임을 @keyframes 에 맡기면 브라우저의 합성 단계로 내려가 스크롤 중에도 끊기지 않고, prefers-reduced-motion 으로 끄기도 한 줄입니다. stroke-dashoffset 이 애니메이션 가능한 속성이라는 것은 MDN stroke-dasharray 문서에 적혀 있습니다.