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

수채화 효과 9종 — 번짐·안료 고임·종이 결을 계산으로 그리는 캔버스

수채화 효과는 물감이 종이에 스며 번지고, 마르며 테두리를 남기고, 종이 골에 고이는 모습을 화면 위에서 다시 그리는 기법이다. 물감 번짐은 붓이 아니라 물이 마르는 순서가 만든다.

붓으로 칠한 것처럼 보이는 이유 — 물이 마르는 순서와 종이 결이 만드는 얼룩

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

종이 위 물감이 그림처럼 보이는 까닭은 색 자체보다 색이 놓인 방식에 있다. 가장자리가 고르게 잘리지 않고, 마른 자리에 진한 고리가 남고, 젖은 두 색이 만난 곳만 섞이고, 종이 골에 알갱이가 가라앉는다. 01~04 는 이 네 가지 현상을 하나씩 떼어 보여 주고, 05~08 은 마른 붓·그림 바꾸기·물방울 꽃·겹칠처럼 그 현상을 쓰는 기법을, 09 는 앞의 계산을 한 장면에 모은 첫 화면을 보여 준다. 아홉 장면은 캔버스 2D 에 화소를 직접 계산해 칠하고 라이브러리·WebGL·이미지 파일을 하나도 부르지 않는다. 06 이 바꾸는 원본 풍경도 같은 페이지에서 화소마다 계산한 그림이다.

카페 메뉴와 전시 안내 첫 화면

카페 메뉴 페이지라면 05 의 마른 붓 획을 메뉴 제목 밑에 한 번 긋는 정도가 알맞다. 획 끝이 갈라지며 사라지기 때문에 직선 밑줄보다 손으로 쓴 느낌이 살고, 획 하나가 1초 안팎으로 끝나 읽기를 방해하지 않는다. 전시 안내나 작품 판매 페이지의 첫 화면에는 09 가 맞는다. 하늘이 번지고 산이 겹쳐 쌓이고 언덕이 그려지는 순서 자체가 「지금 그림이 그려지고 있다」 는 인상을 주고, 다 그린 뒤 2.2초 머물러 제목을 읽을 틈을 준다.

초대장과 브랜드 첫 화면 배경

모바일 초대장의 머리 그림에는 가운데가 옅고 테두리만 진하게 남는 02 의 얼룩이 잘 어울린다. 옅은 안쪽에 이름과 날짜를 얹어도 글자가 묻히지 않는다. 브랜드 첫 화면 배경으로 큰 색 덩어리 몇 개만 깔고 싶다면 01 의 겹친 얼룩이나 04 의 종이 결 띠가 낫고, 계절 기획전 배너처럼 장식이 더 필요하면 07 의 물방울 꽃을 색면 위에 띄운다. 이런 수채화 애니메이션은 한 번 그려지고 멈추는 쪽이 본문을 덜 방해하는데, 아홉 장면 모두 3.6~4.6초 동안 그린 뒤 다음 씻기까지 2.0~2.6초 동안 그림이 멈춰 있다.

수채화 효과 9종 — 겹쳐 쌓는 얼룩·마른 테두리·젖은 칸 번짐·종이 결 고임·갈라지는 마른 붓·이미지 수채화 효과·소금 꽃 번짐·겹칠 혼색·풍경이 그려지는 첫 화면

2초 동안 24장을 찍어 센 그림의 움직임

480×300 판을 2초 동안 24장 찍고 이웃한 두 장의 차이 23개를 센 값이다. 화면 대부분을 차례로 칠하는 09 와 06 이 50% 를 넘고, 붓 네 획만 지나가는 05 가 20.8% 로 가장 좁다.

장면 누적면적 움직인 프레임 강도 평균
01 겹쳐 쌓는 얼룩 40.5% 17/23 15.9
02 마른 테두리 28.0% 20/23 21.0
03 젖은 칸 번짐 29.6% 11/23 46.4
04 종이 결 고임 49.2% 10/23 78.4
05 마른 붓 20.8% 17/23 76.3
06 이미지 수채화 52.0% 12/23 26.1
07 물방울 꽃 번짐 23.3% 19/23 18.3
08 겹칠 혼색 28.8% 13/23 44.2
09 수채 풍경 첫 화면 54.1% 18/23 36.6

움직인 프레임이 23 에 못 미치는 것은 물감이 놓이는 사건이 시각표대로 띄엄띄엄 일어나기 때문으로 보인다. 03 은 붓 대기 16번이 0.16초 간격으로 이어지다 끝나고, 04 는 띠 세 줄을 다 칠한 뒤 고임이 천천히 올라가는 구간이라 이웃 두 장의 차이가 문턱 밑으로 작아졌을 가능성이 크다. 강도 평균이 가장 낮은 01·07 은 한 번에 바뀌는 진하기가 작은 장면이다 — 01 은 한 겹이 0.042, 07 은 방울 하나가 안료를 한 걸음에 20% 씩만 민다.

01변을 쪼개 흔든 다각형을 옅게 수십 겹 쌓는 물감 얼룩

얼룩 하나는 꼭짓점 10개짜리 바탕을 한 번 흔든 20각형에서 출발하고, 겹마다 그 바탕을 deformPolygon 으로 다섯 번 더 쪼개 흔든 640각형을 LAYER_ALPHA 0.042 씩 40겹 쌓아 가운데는 진하고 가장자리는 풀린 얼룩이 된다. 변 가운데 점을 변 길이 × BLOT_JITTER(0.45) × 0.5 × 종 모양 난수만큼 법선 쪽으로 미는 아래 함수가 모양을 정하는데, 짧아진 변일수록 덜 밀려 굵은 굴곡 위에 잔 굴곡이 얹히므로 꽃집 랜딩 페이지 배경처럼 큰 색 덩어리 몇 개만 필요한 자리에 맞는다. 그리기가 끝났을 때 640 꼭짓점 둘레는 873px, 꼭짓점 하나의 평균 꺾임은 0.755 라디안이고, 가장자리에서 진하기가 최대의 10% 에서 90% 로 오르는 폭은 판 4.5px 이다.

변을 쪼개 흔든 다각형을 옅게 수십 겹 쌓는 물감 얼룩
새 창에서 열기

01 · 변을 쪼개 흔든 다각형을 옅게 수십 겹 쌓는 물감 얼룩 — 측정 누적면적 40.474% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BLOT_JITTERLAYER_ALPHALAYERS_PER_BLOT
    function deformPolygon(pts, depth, jitter, rng) {
        let cur = pts;
        for (let d = 0; d < depth; d += 1) {
            const next = [];
            const n = cur.length / 2;
            for (let i = 0; i < n; i += 1) {
                const j = (i + 1) % n;
                const ax = cur[2 * i];
                const ay = cur[2 * i + 1];
                const bx = cur[2 * j];
                const by = cur[2 * j + 1];
                const len = Math.hypot(bx - ax, by - ay) || 1;
                const push = normalRandom(rng) * jitter * len * 0.5;
                next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
            }
            cur = next;
        }
        return cur;
    }

02마르는 동안 안료가 테두리로 몰려 진한 고리가 남는 얼룩

얼룩이 다 번지면 distanceToEdge 가 위아래로 두 번 훑어 칸마다 가장 가까운 마른 칸까지 거리를 구해 두고, 물높이가 2.2초에 걸쳐 1 에서 0 으로 내려가는 동안 flowToRim 이 젖은 칸의 안료를 경계까지 거리가 더 짧은 이웃에게 거리 차 비율대로 나눠 보내며, 보내는 몫은 RIM_FLOW 2.2 × (0.3 + 0.7 × (1 − 물높이)) 라 마를수록 커진다. 아래 토막에서 거리 1 이하인 경계 칸은 continue 로 빠져 보낼 곳이 없으니 받은 안료가 그대로 쌓이고, 그래서 모바일 청첩장 머리 그림처럼 가운데를 비워 글자를 얹을 얼룩에 쓸 만하다. 다 마른 뒤 테두리 띠의 평균 진하기는 안쪽의 4.95·5.16·5.19배인데 세 얼룩의 안료 총량(1714.8·1346.4·885.6)은 그대로라, 색이 늘어난 것이 아니라 자리를 옮겼다.

마르는 동안 안료가 테두리로 몰려 진한 고리가 남는 얼룩
새 창에서 열기

02 · 마르는 동안 안료가 테두리로 몰려 진한 고리가 남는 얼룩 — 측정 누적면적 27.987% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RIM_FLOWDRY_TIMERIM_SMOOTH
                if (dist[i] <= 1) {
                    continue;
                }
                const wl = downhill(dist, mask, i, l);
                const wr = downhill(dist, mask, i, r);
                const wu = downhill(dist, mask, i, u);
                const wd = downhill(dist, mask, i, d);
                const sum = wl + wr + wu + wd;
                if (sum <= 0) {
                    continue;
                }
                const move = f[i] * frac;
                flowTmp[i] -= move;
                flowTmp[l] += move * wl / sum;
                flowTmp[r] += move * wr / sum;
                flowTmp[u] += move * wu / sum;
                flowTmp[d] += move * wd / sum;

03젖은 칸으로만 퍼지다 두 색이 만나 섞이는 번짐

물 격자 하나와 산호·하늘색 안료 격자 둘을 두고, 맑은 물을 바른 두 자리에 붓을 16번 번갈아 대며 매 걸음 wetDiffuse 를 14번씩 두 안료에 돌려 그림책 소개 페이지 표지처럼 두 색이 가운데서 만나 보랏빛으로 섞이는 화면을 만든다. 경계를 만드는 것은 const g = Math.min(wi, water[j]); 한 줄로, 주고받는 양에 두 칸 가운데 적은 물을 곱하니 마른 이웃과는 0 이 되고, 아래 토막처럼 오른쪽 이웃과 아래 이웃(i + gw)에 같은 모양으로 한 번씩 주고받는다. 두 안료가 함께 0.05 넘게 든 칸은 1.5초 0 에서 3.5초 579, 그리기 끝 760칸으로 늘었고 처음부터 마른 칸으로 샌 안료는 0 이다.

젖은 칸으로만 퍼지다 두 색이 만나 섞이는 번짐
새 창에서 열기

03 · 젖은 칸으로만 퍼지다 두 색이 만나 섞이는 번짐 — 측정 누적면적 29.567% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DIFFUSE_RATEDIFFUSE_SUBSTEPSEVAPORATION
                const i = y * gw + x;
                const wi = water[i];
                if (wi <= 0) {
                    continue;
                }
                if (x + 1 < gw) {
                    const j = i + 1;
                    const g = Math.min(wi, water[j]);
                    if (g > 0) {
                        const flow = k * g * (before[j] - before[i]);
                        p[i] += flow;
                        p[j] -= flow;
                    }
                }
                if (y + 1 < gh) {
                    const j = i + gw;
                    const g = Math.min(wi, water[j]);
                    if (g > 0) {
                        const flow = k * g * (before[j] - before[i]);
                        p[i] += flow;
                        p[j] -= flow;
                    }
                }

04종이 결의 낮은 곳에 안료 알갱이가 고이는 바탕

버터·하늘색·세이지 띠 세 줄을 붓 앞머리가 0.9초에 왼쪽에서 오른쪽으로 칠하고, 띠를 칠하기 시작하고 0.45초 뒤부터 고임 세기가 1.3초에 걸쳐 0 에서 GRANULATION 0.8 까지 올라 종이 골(높이 0)은 1.8배, 마루(높이 1)는 0.2배 진하기가 된다. 그 곱셈이 들어간 곳이 공용 색 합성 compose 의 안쪽 고리이고, 종이 높이는 두 옥타브 값 노이즈를 0~1 로 다시 편 격자라서 수제 문구점 상품 상세 배경처럼 종이 질감 자체를 보여 줄 자리에 어울린다. 하늘색 띠 가운데 줄의 평균 밝기는 178.5 에서 179.5 로 거의 그대로인데 흩어짐(표준편차)은 7.07 에서 15.17 로 2.1배가 되어, 색은 두고 알갱이 대비만 키운다.

종이 결의 낮은 곳에 안료 알갱이가 고이는 바탕
새 창에서 열기

04 · 종이 결의 낮은 곳에 안료 알갱이가 고이는 바탕 — 측정 누적면적 49.166% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GRANULATIONSETTLE_TIMEPAPER_RELIEF
                const h = PAPER_HEIGHT[p];
                let lr = 0;
                let lg = 0;
                let lb = 0;
                for (let k = 0; k < n; k += 1) {
                    const f = fields[k];
                    let d = (f[i00] * (1 - tx) + f[i10] * tx) * (1 - ty) + (f[i01] * (1 - tx) + f[i11] * tx) * ty;
                    if (d <= 0) {
                        continue;
                    }
                    if (gran[k] !== 0) {
                        d *= Math.max(0, 1 + gran[k] * (1 - 2 * h));
                    }
                    const pk = pigs[k];
                    lr += pk.r * d;
                    lg += pk.g * d;
                    lb += pk.b * d;
                }

05붓이 빨라질수록 물이 줄어 털끝이 갈라지는 마른 붓 획

곡선 위 자리를 진행의 1.8제곱으로 잡아 붓이 끝으로 갈수록 빨라지고, 아래 update 가 매 걸음 남은 물을 (0.22 + 0.0017 × 속도 px/초) × 시간만큼 깎는다. 붓털 16가닥은 저마다 0.04~0.62 사이 문턱을 갖고 brushSegment 는 남은 물이 문턱(노이즈로 ±0.08 흔들린다)보다 적어진 자리에서 그 가닥을 건너뛰므로, 카페 메뉴판 제목 밑줄처럼 끝이 결을 따라 갈라지며 사라지는 획이 된다. 네 획에서 첫 가닥이 끊긴 자리는 곡선의 40·44·37·48% 지점이고, 끝 속도가 740px/초를 넘은 두 획(741.5·766.3)은 16가닥이 모두 끊긴 채 끝난다.

붓이 빨라질수록 물이 줄어 털끝이 갈라지는 마른 붓 획
새 창에서 열기

05 · 붓이 빨라질수록 물이 줄어 털끝이 갈라지는 마른 붓 획 — 측정 누적면적 20.835% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BRISTLE_COUNTSPEED_DRAINSTROKE_EASE
    function update(dt, t) {
        for (let k = 0; k < STROKES.length; k += 1) {
            const s = STROKES[k];
            if (t < s.start || strokeU[k] >= 1) {
                continue;
            }
            const u = Math.pow(phaseOf(t, s.start, s.duration), STROKE_EASE);
            const speed = (u - strokeU[k]) * strokeLen[k] / dt;
            strokeWater[k] = Math.max(0, strokeWater[k] - (DRY_BASE + SPEED_DRAIN * speed) * dt);
            const w = strokeWater[k];
            brushSegment(strokeFields[k], OW, OH, s.curve, strokeU[k], u, w, bristleSets[k], BRUSH_HALF, lerp(DOT_DRY, DOT_WET, clamp(w, 0, 1)), BRISTLE_INK * (0.5 + 0.5 * w), BRISTLE_WOBBLE);
            strokeU[k] = u;
        }
    }

06풍경 그림을 밝기 단계와 흔들린 경계의 물감 그림으로 바꾸는 이미지 수채화 효과

원본은 외부 사진이 아니라 makeLandscape 가 화소마다 하늘·해·언덕·집·나무를 계산한 320×200 풍경 그림이고, buildPainted 가 읽을 자리를 노이즈로 ±3.2 화소 옮겨 네 이웃과 평균 낸 뒤 밝기를 5단계로 반올림하되 세 채널에 같은 비율을 곱해 색상은 지킨다. 그다음 이웃 밝기 차가 큰 화소를 최대 38% 어둡게 해 테두리를 긋고 종이 결을 곱하며, 여행 블로그 대표 그림처럼 원본 위를 왼쪽에서 오른쪽으로 3.0초에 쓸어 바꾸는 모습 자체가 볼거리가 된다. 8비트 색 수는 원본 995 에서 단계·흔들기 뒤 858 로 줄었다가 테두리와 종이 결까지 얹으면 결 음영이 화소마다 달라 9,764 로 늘고, 테두리로 15% 넘게 어두워진 화소는 3.0% 다.

풍경 그림을 밝기 단계와 흔들린 경계의 물감 그림으로 바꾸는 이미지 수채화 효과
새 창에서 열기

06 · 풍경 그림을 밝기 단계와 흔들린 경계의 물감 그림으로 바꾸는 이미지 수채화 효과 — 측정 누적면적 51.997% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

COLOR_LEVELSEDGE_JITTEREDGE_DARKEN
            for (let x = 0; x < OW; x += 1) {
                // ① 경계 흔들기 — 읽을 자리를 노이즈만큼 옮겨 색면 가장자리가 붓 자국처럼 울퉁불퉁해진다
                const sx = clamp(Math.round(x + (noise2(x / 7, y / 7, SEED + 11) - 0.5) * 2 * EDGE_JITTER), 0, OW - 1);
                const sy = clamp(Math.round(y + (noise2(x / 7 + 40, y / 7, SEED + 12) - 0.5) * 2 * EDGE_JITTER), 0, OH - 1);
                const o = (y * OW + x) * 3;
                // 잔 얼룩 고르기 — 옮긴 자리와 그 네 이웃의 평균을 읽는다
                let r = 0;
                let g = 0;
                let b = 0;
                for (let q = 0; q < 5; q += 1) {
                    const qx = clamp(sx + (q === 1 ? -1 : q === 2 ? 1 : 0), 0, OW - 1);
                    const qy = clamp(sy + (q === 3 ? -1 : q === 4 ? 1 : 0), 0, OH - 1);
                    const s = (qy * OW + qx) * 3;
                    r += photo[s] * 0.2;
                    g += photo[s + 1] * 0.2;
                    b += photo[s + 2] * 0.2;
                }
                // ② 색 단계 줄이기 — 밝기를 COLOR_LEVELS 단계로 반올림하고 세 채널에 같은 비율을 곱해 색상은 지킨다. 그다음 종이 색 쪽으로 PAPER_MIX 만큼
                const l = r * 0.299 + g * 0.587 + b * 0.114;
                const ratio = Math.round(clamp(l, 0, 1) * top) / top / Math.max(l, 0.02);
                stepped[o] = lerp(clamp(r * ratio, 0, 1), pr, PAPER_MIX);
                stepped[o + 1] = lerp(clamp(g * ratio, 0, 1), pg, PAPER_MIX);
                stepped[o + 2] = lerp(clamp(b * ratio, 0, 1), pb, PAPER_MIX);
            }

07젖은 색면에 맑은 물방울이 떨어져 꽃잎 테두리로 밀려나는 번짐

라벤더에서 분홍으로 넘어가는 바탕 색면을 0.5초에 칠한 뒤 아직 젖은 자리에 맑은 물방울 6개가 떨어지면, pushBloom 이 각도마다 노이즈로 다른 반지름 r(θ) 를 구하고 그 바깥 40% 띠 안의 안료를 방울 가운데에서 먼 쪽 이웃 칸으로 민다. 물이 0.12 미만인 칸에서는 밀지 않아 마른 자리의 방울은 꽃을 못 만들고, 봄 시즌 기획전 배너처럼 색면 위에 꽃잎 모양 얼룩을 몇 개 띄울 때 쓴다. 그리기 끝에 꽃 테두리 띠의 평균 진하기는 꽃 안의 3.4~3.9배이고, 안료 총량은 7,272.94 그대로다.

젖은 색면에 맑은 물방울이 떨어져 꽃잎 테두리로 밀려나는 번짐
새 창에서 열기

07 · 젖은 색면에 맑은 물방울이 떨어져 꽃잎 테두리로 밀려나는 번짐 — 측정 누적면적 23.299% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PETAL_NOISEBLOOM_PUSHBLOOM_GROW
                const i = y * gw + x;
                if (water[i] < wetMin) {
                    continue;
                }
                const dx = x + 0.5 - cx;
                const dy = y + 0.5 - cy;
                const dist = Math.hypot(dx, dy);
                const rr = bloomRadius(d, grow, Math.atan2(dy, dx), petal) * k;
                if (dist >= rr) {
                    continue;
                }
                // 방울 안은 맑은 물로 다시 적신다. 밀기는 앞머리 쪽 띠(반지름의 바깥 band 비율)에서만 — 가운데에는 옅은 안료가 남는다
                water[i] = 1;
                if (dist < rr * (1 - band)) {
                    continue;
                }
                const j = (y + Math.round(dy / dist)) * gw + (x + Math.round(dx / dist));
                if (water[j] < wetMin) {
                    continue;
                }
                const move = field[i] * frac;
                delta[i] -= move;
                delta[j] += move;

08투명한 층을 덧칠할수록 빛을 곱으로 거르는 겹칠 혼색

판을 둘로 나눠 같은 덮임으로 파랑·노랑·장미 세 층을 칠하되, 왼쪽은 아래 glazeAbsorb 가 채널마다 × (1 − 양 × (1 − 반사율)) 로 빛을 거르고 오른쪽은 glazeAlpha 가 지금 색을 층 색 쪽으로 양만큼 끌어온다. 파랑 층 한 겹([180,201,221]) 위에 노랑 층을 얹으면 흡수 쪽은 180,187,153, 투명도 섞기 쪽은 206,203,159인데, 두 층 순서를 바꾸면 곱셈인 흡수는 그대로이고 투명도 섞기만 [181,193,183] 으로 달라진다. 미술 수업 자료의 색 섞기 안내처럼 두 방식을 나란히 보여 줄 자리에 맞고, 파랑 층만 1·2·3겹 흡수로 쌓으면 밝기가 197.0 → 159.8 → 130.4 로 내려간다.

투명한 층을 덧칠할수록 빛을 곱으로 거르는 겹칠 혼색
새 창에서 열기

08 · 투명한 층을 덧칠할수록 빛을 곱으로 거르는 겹칠 혼색 — 측정 누적면적 28.826% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GLAZE_ALPHAGLAZE_SHEETSLAYER_TIME
    function glazeAbsorb(base, layers, amounts) {
        let r = base[0];
        let g = base[1];
        let b = base[2];
        for (let k = 0; k < layers.length; k += 1) {
            const a = amounts[k];
            r *= 1 - a * (1 - layers[k][0] / 255);
            g *= 1 - a * (1 - layers[k][1] / 255);
            b *= 1 - a * (1 - layers[k][2] / 255);
        }
        mixR = r;
        mixG = g;
        mixB = b;
    }

09하늘 번짐부터 마른 붓 언덕까지 차례로 그려지는 수채 풍경 첫 화면

종이 결 위에 0.2초 하늘 번짐, 1.4초 먼 산 겹칠, 2.5초 마른 붓 언덕, 3.5초 물방울 꽃이 차례로 얹히고, 아래 update 가운데 토막처럼 07 의 pushBloom·03 의 wetDiffuse·01 의 deformPolygon 을 같은 하늘 격자와 능선에 다시 부른다. 왼쪽 위 상태 줄이 지금 단계 이름을 보여 주고, 다 그린 뒤 2.2초 머물렀다가 1.3초에 씻겨 같은 시드의 같은 풍경으로 다시 그려지므로 갤러리 웹사이트 첫 화면에 그대로 얹을 수 있다. 하늘 안료 총량은 번짐과 물방울을 다 거친 뒤에도 6,591.07 그대로이고, 언덕 세 획은 끝에 남은 물이 0 이라 붓털이 모두 끊긴 채 끝난다.

하늘 번짐부터 마른 붓 언덕까지 차례로 그려지는 수채 풍경 첫 화면
새 창에서 열기

09 · 하늘 번짐부터 마른 붓 언덕까지 차례로 그려지는 수채 풍경 첫 화면 — 측정 누적면적 54.138% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SKY_DIFFUSERIDGE_ALPHAHERO_PETAL
        // ⑤ 물방울 꽃 — 아직 젖은 하늘에 맑은 방울을 떨어뜨려 안료를 꽃잎 테두리로 민다
        for (let k = 0; k < HERO_DROPS.length; k += 1) {
            const d = HERO_DROPS[k];
            if (t < d.at) {
                continue;
            }
            const grow = 1 - Math.exp(-(t - d.at) / 0.3);
            for (let f = 0; f < 3; f += 1) {
                pushBloom(skyFields[f], skyWater, GW, GH, d, grow, HERO_PETAL, HERO_PUSH, 0.4, 0.1, skyTmp);
            }
        }
        for (let s = 0; s < SKY_SUBSTEPS; s += 1) {
            for (let f = 0; f < 3; f += 1) {
                wetDiffuse(skyFields[f], skyWater, GW, GH, SKY_DIFFUSE, skyTmp);
            }
        }
        // ② 먼 산 겹칠 — 능선 다각형을 장마다 다시 흔들어 옅게 쌓는다
        for (let k = 0; k < RIDGES.length; k += 1) {
            const want = Math.floor(RIDGE_SHEETS * phaseOf(t, RIDGES[k].start, 0.8));
            while (ridgeSheets[k] < want) {
                const sheet = deformPolygon(ridgeShapes[k], 4, RIDGE_JITTER, makeRng(SEED * 10 + k * 100 + ridgeSheets[k]));
                fillPolygon(RIDGES[k].field, GW, GH, sheet, RIDGE_ALPHA);
                ridgeSheets[k] += 1;
            }
        }

수채화 효과 사이트처럼 웹 화면에서 물감 번짐을 만들려면 무엇을 계산해야 하는가?

물의 양, 안료의 양, 종이의 높이

색을 바로 칠하면 번짐이 생기지 않는다. 이 편이 칸마다 들고 있는 값은 세 가지다. 물이 얼마나 남았는지(번짐을 허락하는 값), 안료가 얼마나 들었는지(색의 진하기), 종이가 그 자리에서 얼마나 높은지(알갱이가 고일 곳)다. 03 은 물이 있는 칸끼리만 안료를 나누고, 02 는 물이 줄어드는 속도로 안료를 테두리에 모으고, 04 는 종이 높이로 진하기를 곱한다. 화면의 색은 맨 마지막에 이 값들에서 한 번 계산될 뿐이다.

색은 더하지 않고 곱한다

공용 함수 compose 는 안료마다 「흰 종이에 한 겹 칠했을 때 돌아오는 빛」 을 채널별 비율로 들고, 진하기만큼 그 비율을 거듭제곱해 종이 색에 곱한다. 로그로 바꿔 진하기 × 로그 비율을 더한 뒤 지수로 되돌리는 방식이라, 겹칠수록 색이 곱으로 깊어지고 흰색 쪽으로 바래지 않는다. 08 이 같은 두 층을 투명도 섞기와 나란히 놓는 까닭이 여기에 있다 — 곱은 순서를 따지지 않지만 섞기는 위에 얹은 색이 이긴다.

이웃 편과 무엇이 다른가

마블링 효과 편은 물 위에 뜬 잉크 막을 밀고 빗질해 결을 만드는 장면이라 안료가 종이에 스미는 일이 없다. 유체 격자 편은 연기와 잉크가 흐르는 속도장을 풀지만, 여기 03 은 속도 없이 물이 있는 칸끼리의 확산만 쓴다. 종이 높이를 만드는 값 노이즈는 노이즈 지형 편과 뿌리가 같고, 06 의 단계 줄이기는 픽셀 아트 변환 편처럼 화소를 블록으로 묶는 대신 밝기만 줄이고 색상은 남긴다는 점이 다르다.

흔들린 다각형은 겹으로, 안료는 젖은 칸의 확산으로, 테두리는 마르는 거리로 — 물감 한 방울을 화면에 앉히는 계산

무거운 계산은 가로 160칸, 색은 가로 320칸

물·안료 확산·거리처럼 이웃을 여러 번 읽는 계산은 160×100 격자(한 칸이 판 3px)에서 하고, 색 합성과 종이 결은 320×200 화소 격자에서 한다. 합성 결과는 Float32 버퍼 하나에 모았다가, 처음에 한 번 만든 ImageData 에 8비트로 옮겨 숨긴 캔버스에 놓고 480×300 판에 부드럽게 늘려 그린다. 프레임마다 새 배열도 새 ImageData 도 만들지 않으니 쓰레기 수거가 끼어들 틈이 줄어드는 것으로 보인다.

1/30초 고정 걸음과 미리 그려 둔 첫 화면

rAF 가 넘겨주는 시각 차는 0.05초에서 자르고, 모인 시간을 1/30초씩 꺼내 한 걸음씩 계산한다. 화면이 잠시 멈췄다 돌아와도 걸음 크기는 같아 같은 시드에서 같은 그림이 나온다. 자동 시연은 장면마다 DEMO_START(0.9~1.4초)까지 미리 계산한 채 시작해서 페이지를 열자마자 빈 종이만 보이지 않고, 사람이 다시 시작하면 0초부터 그린다.

마르는 거리는 두 번 훑어 구한다

02 의 distanceToEdge 는 위에서 아래로, 다시 아래에서 위로 격자를 훑으며 이웃 값에 곧은 이웃은 1, 대각 이웃은 1.414 를 더한 것 가운데 작은 값을 남긴다. 두 번이면 칸마다 가장 가까운 마른 칸까지의 거리가 정해지고, 이어 이웃 평균을 두 번 섞어 계단을 편다. 세 얼룩에서 가장 깊은 칸은 경계에서 27.28·24.44·19.42칸 떨어져 있다.

물감이 깨지는 곳 — 다른 주소의 사진에서 막히는 픽셀 읽기·수백 겹에서 탁해지는 8비트 색·넓은 화면에서 느려지는 확산 격자

다른 출처의 사진을 읽으려 할 때

06 의 loadPhoto 는 계산한 풍경을 숨긴 캔버스에 놓고 getImageData 로 다시 읽는데, 이 호출을 try·catch 로 감싸 읽기가 막히면 계산해 둔 원본을 그대로 쓴다. 이 데모는 원본을 같은 페이지에서 그리므로 캔버스가 오염되지 않는다. 다른 주소의 사진을 허락 없이 캔버스에 그리면 캔버스가 오염되어 바로 이 호출에서 보안 오류가 나므로, 실제 사진을 쓰려면 이미지의 crossOrigin 속성과 서버의 허용 응답이 함께 있어야 한다(교차 출처 이미지 안내). 받은 zip 의 06 폴더에서 이 부분을 직접 바꿔 보려면 압축 암호 5tkwg3wj 를 넣고 loadPhoto 를 열면 된다.

옅은 색을 수백 번 덮을 때

종이 [250,245,232] 위에 산호색 [240,148,132] 을 알파 a 로 거듭 덮으면서 칠할 때마다 0~255 정수로 반올림하면, 남은 차이 × a 가 0.5 밑으로 내려가는 순간 값이 더 움직이지 않는다. 목표에서 0.5/a 단계 앞에서 멈추는 셈이다. a 0.042 로 400겹을 덮으면 8비트는 [250,159,143] 에서 멈추고 Float32 는 [240,148,132] 에 닿는데, 빨강은 처음 차이가 10 이라 10 × 0.042 = 0.42 로 한 번도 움직이지 않는다. 알파 1% 로 400번 덮어도 빨강은 250 에서 그대로이고 목표 240 에 닿지 못한다. 채널마다 멈추는 자리가 달라서 색이 바래는 데 그치지 않고 색상까지 틀어진다. 01 이 진하기를 Float32 격자에 더하고 겹 수를 얼룩 하나에 40 으로 묶어 두는 이유다.

화면 화소 그대로 확산을 돌릴 때

같은 wetDiffuse 한 번이 160×100(16,000칸)에서는 0.081ms, 화면 화소 그대로인 960×600(480×300 × 배율 2, 36배 칸 수)에서는 3.01ms 걸렸다. 03 은 한 걸음에 14번 × 두 안료 = 28번을 돌리므로 걸음당 2.26ms 와 84.2ms 로 갈리고, 뒤쪽은 한 걸음 예산 33ms 를 2.5배 넘긴다. 이 시간은 개발 시점에 개발 기계의 Node.js 에서 잰 값이라 기기마다 다르지만 36배라는 칸 수 비율은 그대로 남는다. 그래서 확산은 가로 160 격자에서 끝내고 색 합성만 320×200 에서 한다. 03 의 DIFFUSE_SUBSTEPS 를 늘려 번짐을 빠르게 하고 싶을 때도 이 걸음당 예산을 먼저 계산해 보는 편이 안전하다.

다각형을 덜 흔들고, 확산을 빠르게, 겹칠을 진하게 — 값 하나로 바뀌는 그림의 물기

변형 바꾼 값 결과
다각형을 덜 흔들게 01 BLOT_JITTER 0.45 → 0.2 꼭짓점 평균 꺾임 0.755 → 0.37 라디안, 둘레 873 → 573px, 가장자리 폭 4.5 → 1.5px 로 오린 종이에 가까워진다
확산을 빠르게 03 DIFFUSE_RATE 0.18 → 0.3 두 안료가 섞인 칸 760 → 1,096(+44%), 마른 칸 누수는 두 값 모두 0
겹칠을 진하게 08 GLAZE_ALPHA 0.35 → 0.6 파랑 위 노랑(흡수) [180,187,153] → [130,149,101], 색상 72 → 84°, 파랑 2겹 밝기 159.8 → 111.5

세 값은 각 장면 스크립트 위쪽 상수에 한 줄로 있다. 흔들림을 줄이면 가운데 진하기(1.68)는 그대로 둔 채 가장자리만 단단해지고, 확산률을 올려도 마른 칸의 벽은 무너지지 않으며, 겹칠 양을 올리면 흡수 쪽 밝기는 181.0 → 137.8 로 내려가고 투명도 섞기 쪽은 [204,191,108] 이 된다.

그림이 멈춰도 읽히게 — 완성된 한 장으로 멈추는 움직임 줄이기·일시정지 버튼·그림 설명 문장의 접근성

움직임 줄이기에서 남는 완성 그림

prefers-reduced-motion: reduce 이면 아홉 장면은 루프를 세우지 않고, 같은 시드로 그리기 끝까지 한 번에 계산한 완성 그림 한 장만 보여 준다. 씻어 내기도 돌지 않으며, 설정이 바뀌면 change 리스너가 바로 따라간다.

일시정지 버튼과 키보드

오른쪽 아래 알약 버튼은 aria-pressed 를 단 네이티브 <button> 이라 Tab 으로 가서 Enter 나 Space 로 멈추고 다시 돌린다. 누른 상태에서는 바탕과 글자 색이 뒤집히고, 키보드 포커스에는 3px 외곽선이 생긴다.

그림 설명 문장과 09 의 상태 줄

캔버스마다 role="img" 와 장면을 문장으로 풀어 쓴 aria-label 이 붙어 있다. 상태 줄이 있는 09 는 자동 시연 동안 aria-live 를 꺼 두었다가 첫 입력이 들어온 뒤에 polite 로 바꿔, 단계 이름이 바뀔 때마다 화면 읽기 프로그램이 끼어들지 않게 한다.

웹 페이지에 수채화 배경 이미지를 직접 그려 넣기 전에 자주 묻는 질문

우리 브랜드 색으로 물감을 바꾸려면 어디를 고치나요?

스크립트 위쪽의 PAPER·SKY·CORAL·LAVENDER 같은 RGB 배열이 종이와 안료 색이다. 값을 바꾸면 compose 가 그 색에서 채널별 비율을 다시 구하므로 겹칠 때 깊어지는 방식은 그대로 따라온다.

수채화 애니메이션이 휴대폰에서도 버벅이지 않나요?

계산 격자는 화면 크기와 상관없이 160×100 과 320×200 으로 고정이고, 기기 배율은 최대 2 까지만 실제 화소에 반영된다. 좁은 화면이라고 계산이 늘지 않으며, 탭을 숨기면 루프가 멈췄다가 돌아오면 다시 돈다.

06 에 내가 고른 사진을 넣을 수 있나요?

같은 출처의 이미지이거나 서버가 교차 출처 읽기를 허락한 이미지라면 숨긴 캔버스에 그린 뒤 같은 getImageData 로 읽게 바꿀 수 있다. 허락이 없으면 읽기가 막히고, 그때는 catch 가 계산한 풍경을 그대로 쓰므로 화면이 깨지지는 않는다.

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

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