05오로라 커튼 — 세로 노이즈 광막
세로로 긴 fbm 띠 여러 개를 globalCompositeOperation 'lighter' 로 겹쳐 빛이 포개질수록 밝아지게 하고, 각 띠의 노이즈 위상을 다르게 흘려 커튼이 흔들리며 겹치는 오로라를 만듭니다.
쓰임야경·이벤트·겨울 캠페인 히어로
createImageDataglobalCompositeOperationfbm

<main class="stage">
<div class="ac" id="ac">
<p class="ac__hint">
<svg class="ac__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M120 96a24 24 0 1 1 24-24a24 24 0 0 1-24 24m88-16a24 24 0 1 0 24 24a24 24 0 0 0-24-24m-56 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></g></svg>
<span>장막이 겹치는 곳은 lighter 로 더해져 더 밝아지고, 시드를 바꾸면 하늘이 다시 펼쳐집니다</span>
</p>
<div class="ac__field" id="field" role="application" aria-label="오로라 커튼 실험판" tabindex="0">
<canvas class="ac__cv" id="cv"></canvas>
<b class="ac__badge" aria-hidden="true">160 × 100 · lighter × 6</b>
</div>
<div class="ac__bar">
<label class="ac__dial">시드
<input type="number" id="seed" min="1" max="9999" step="1" value="7">
</label>
<label class="ac__dial">장막 세기
<input type="range" id="glow" min="0" max="100" step="1" value="55">
<b class="ac__val" id="glowVal">55</b>
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 오로라 커튼 — 세로 노이즈 광막. 움직임은 전부 캔버스 rAF 루프 안에 있다(lighter 가산 합성).
// CSS 는 무대·판·다이얼만 맡는다 — @keyframes 를 두지 않는다(시계 고정 방지).
// 밤하늘·산·장막 색쌍 두 개를 커스텀 프로퍼티로 내려보내고 JS 가 픽셀에서 직접 더한다.
// 무대는 주황 — 어두운 밤 씬이 주황 바탕 위 파스텔 광막으로 읽히게(편 309 무대 기조).
$duration: $dur-loop;
$easing: $ease-out;
$color: mix($subject-mint, $stage-paper, 42%);
$terrain-mint: mix($subject-mint, $stage-paper, 42%);
$terrain-pink: $subject-pink; // 이미 명도 .81 이라 그대로 써도 통과
$terrain-lilac: $subject-lilac; // 이미 명도 .81 이라 그대로 써도 통과
@include stage($stage-orange);
.ac {
--night: #{mix($subject-ink, $stage-orange, 88%)}; // 밤하늘 꼭대기 — 무대색을 아주 조금 섞은 잉크
--night-hi: #{mix($subject-ink, $stage-orange, 72%)}; // 지평선 쪽 하늘 — 한 단계 따뜻하게
--ridge: #{mix($subject-ink, $stage-orange, 94%)}; // 산 실루엣 — 하늘보다 더 어둡다
--aur-a1: #{$terrain-mint}; // 장막 A 하단
--aur-a2: #{$terrain-pink}; // 장막 A 상단
--aur-b1: #{$terrain-lilac}; // 장막 B 하단
--aur-b2: #{$terrain-mint}; // 장막 B 상단
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ac__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink; // 주황 무대 위 글자 — 잉크로 5.50:1
}
.ac__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.ac__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: mix($subject-ink, $stage-orange, 88%);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .18);
cursor: grab; touch-action: none;
}
.ac__field:active { cursor: grabbing; }
.ac__field:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.ac__cv { display: block; width: 100%; height: 100%; }
// 버퍼 해상도 + 합성 방식을 화면에 적어 둔다 — 이 데모의 요점이 lighter 라는 것
.ac__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ac__bar { display: flex; align-items: center; gap: $sp-3; flex-wrap: wrap; }
.ac__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.ac__dial input[type="range"] { width: 96px; margin: 0; accent-color: $subject-blue-light; }
.ac__dial input[type="number"] {
width: $sp-10; margin: 0; padding: $sp-1;
border-radius: $r-sm; border: 1px solid rgba($subject-ink, .3);
background: rgba($stage-paper, .5); color: $subject-ink; font-weight: 700; font-size: 11px;
}
.ac__val { color: $subject-orange-deep; font-size: 11px; font-weight: 700; }
@media (max-width: 340px) {
.ac { gap: $sp-1; }
.ac__field { height: 100px; }
.ac__badge { font-size: 10px; padding: 0 $sp-1; }
.ac__bar { gap: $sp-2; }
.ac__dial { gap: $sp-1; }
.ac__dial input[type="range"] { width: 60px; }
.ac__dial input[type="number"] { width: $sp-8; }
.ac__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돌므로 끄고 말고 할 애니메이션이 없다.
// 장막이 멈춰 선 한 장이 그대로 남으므로 끌 수 있다는 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.ac.is-still .ac__field { cursor: default !important; }
.ac.is-still .ac__field:active { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ac {
--night: rgb(19.937254902%, 10.5254901961%, 10.431372549%);
--night-hi: rgb(34.4941176471%, 14.1019607843%, 10.7450980392%);
--ridge: rgb(14.4784313725%, 9.1843137255%, 10.3137254902%);
--aur-a1: rgb(70.5176470588%, 92.9176470588%, 85.3411764706%);
--aur-a2: #ff9ebb;
--aur-b1: #b9a0ff;
--aur-b2: rgb(70.5176470588%, 92.9176470588%, 85.3411764706%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ac__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.ac__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.ac__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: rgb(19.937254902%, 10.5254901961%, 10.431372549%);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.18);
cursor: grab;
touch-action: none;
}
.ac__field:active {
cursor: grabbing;
}
.ac__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.ac__cv {
display: block;
width: 100%;
height: 100%;
}
.ac__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ac__bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.ac__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.ac__dial input[type=range] {
width: 96px;
margin: 0;
accent-color: #5b8cf8;
}
.ac__dial input[type=number] {
width: 40px;
margin: 0;
padding: 4px;
border-radius: 8px;
border: 1px solid rgba(23, 20, 26, 0.3);
background: rgba(255, 255, 255, 0.5);
color: #17141a;
font-weight: 700;
font-size: 11px;
}
.ac__val {
color: #c93000;
font-size: 11px;
font-weight: 700;
}
@media (max-width: 340px) {
.ac {
gap: 4px;
}
.ac__field {
height: 100px;
}
.ac__badge {
font-size: 10px;
padding: 0 4px;
}
.ac__bar {
gap: 8px;
}
.ac__dial {
gap: 4px;
}
.ac__dial input[type=range] {
width: 60px;
}
.ac__dial input[type=number] {
width: 32px;
}
.ac__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ac.is-still .ac__field {
cursor: default !important;
}
.ac.is-still .ac__field:active {
cursor: default !important;
}
}// 오로라 커튼 — 세로 노이즈 광막 여러 장을 가산(lighter) 으로 포개 놓는 밤하늘.
// 화면은 세 겹으로 쌓는다: ① 값 노이즈 ridged 능선으로 산 실루엣이 깔린 밤하늘(배경),
// ② 세로로 긴 fbm 장막 여섯 장 — 각 장막의 중심선이 노이즈로 좌우로 흔들리고(hem),
// 세로 주름(strap) 이 시간축을 타고 흘러내리며, ③ 전부 globalCompositeOperation 'lighter'
// 로 더해진다. 겹치는 만큼 밝아지는 것이 lighter 합성의 본질이고, 오로라가 이렇게 빛난다.
// 노이즈의 셋째 축 z 는 시간(sec*FLOW) 이다 — 24프레임 내내 장막이 다시 짜여진다.
var root = document.getElementById('ac');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var seedIn = document.getElementById('seed');
var glowIn = document.getElementById('glow');
var glowOut = document.getElementById('glowVal');
var NW = 160, NH = 100; // 노이즈 버퍼 해상도 — 장막 여섯 장을 이 크기로 계산하고
var bg = document.createElement('canvas'); // 나머지는 브라우저 확대 보간이 채운다
bg.width = NW; bg.height = NH;
var bctx = bg.getContext('2d');
var bbuf = bctx.createImageData(NW, NH); // 배경(밤하늘+산) 버퍼 — 매 프레임 덮어쓴다
var bpx = bbuf.data;
var curt = document.createElement('canvas'); // 장막 한 장을 담는 재사용 버퍼
curt.width = NW; curt.height = NH;
var cctx = curt.getContext('2d');
var cbuf = cctx.createImageData(NW, NH);
var cpx = cbuf.data;
// ── mulberry32 — 시드를 고정한다. Math.random() 을 쓰면 같은 시드가 같은 하늘을 내지 않는다
function mulberry32(a) {
return function () {
a |= 0; 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;
};
}
// ── 노이즈 테이블 — PERM 은 격자 좌표를 흩는 해시, VAL 은 격자 꼭짓점마다의 값.
// 같은 rng 로 별 위치와 장막 파라메터까지 뽑는다 — 시드 하나가 하늘 전체를 결정한다.
var PERM = new Uint8Array(512);
var VAL = new Float32Array(256);
var STARS = new Float32Array(46 * 4); // x · y · 밝기 · 반짝임 속도
var NB = 6; // 장막 수
var BP = []; // 장막 파라메터 — bx · sig · amp · spd · ph
function buildTables(seed) {
var rng = mulberry32(seed >>> 0);
var p = [], i, j, t;
for (i = 0; i < 256; i++) p[i] = i;
for (i = 255; i > 0; i--) { j = (rng() * (i + 1)) | 0; t = p[i]; p[i] = p[j]; p[j] = t; }
for (i = 0; i < 512; i++) PERM[i] = p[i & 255];
for (i = 0; i < 256; i++) VAL[i] = rng();
for (i = 0; i < 46; i++) { // 별 — 산 위 하늘에만 뜬다(그리는 쪽에서 걸러 낸다)
STARS[i * 4] = (rng() * NW) | 0;
STARS[i * 4 + 1] = (rng() * NH * 0.82) | 0;
STARS[i * 4 + 2] = 0.3 + rng() * 0.7;
STARS[i * 4 + 3] = 0.4 + rng() * 1.6;
}
BP.length = 0;
for (var b = 0; b < NB; b++) {
BP.push({
bx: (b + 0.5) / NB * NW + (rng() - 0.5) * 14, // 기둥 위치 — 고르게 깔고 시드로 흔든다
sig: 6.5 + rng() * 5.5, // 장막 두께(가우스 시그마)
amp: 13 + rng() * 11, // 중심선이 좌우로 치우치는 폭
spd: 0.7 + rng() * 0.7, // 이 장막만의 시간 흐름 속도(위상차)
ph: rng() * 64 // 노이즈 공간에서의 출발점
});
}
}
// ── 3D 값 노이즈 — 격자 꼭짓점마다 난수를 두고 fade 로 삼중선형 보간한다.
// 셋째 축(z) 이 시간이다. fade 는 5차(격자 경계에서 2계도항까지 0).
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lp(a, b, t) { return a + (b - a) * t; }
function vnoise3(x, y, z) {
var xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
var xf = x - xi, yf = y - yi, zf = z - zi;
var X = xi & 255, Y = yi & 255, Z = zi & 255;
var u = fade(xf), v = fade(yf), w = fade(zf);
var a = PERM[X] + Y, b = PERM[X + 1] + Y;
var aa = PERM[a] + Z, ab = PERM[a + 1] + Z, ba = PERM[b] + Z, bb = PERM[b + 1] + Z;
var c000 = VAL[PERM[aa]], c100 = VAL[PERM[ba]], c010 = VAL[PERM[ab]], c110 = VAL[PERM[bb]];
var c001 = VAL[PERM[aa + 1]], c101 = VAL[PERM[ba + 1]], c011 = VAL[PERM[ab + 1]], c111 = VAL[PERM[bb + 1]];
var x00 = c000 + (c100 - c000) * u, x10 = c010 + (c110 - c010) * u;
var x01 = c001 + (c101 - c001) * u, x11 = c011 + (c111 - c011) * u;
var y0 = x00 + (x10 - x00) * v, y1 = x01 + (x11 - x01) * v;
return y0 + (y1 - y0) * w;
}
// fbm — 주파수 2배·진폭 절반으로 쌓는다. 겹 수가 곧 장막 결의 고움이다
function fbm(x, y, z, oct) {
var sum = 0, amp = 0.5, norm = 0, f = 1;
for (var i = 0; i < oct; i++) {
sum += vnoise3(x * f, y * f, z * f) * amp;
norm += amp; amp *= 0.5; f *= 2;
}
return sum / norm;
}
// ridged — fbm 을 0.5 기준으로 접어 능선만 세운다. 산 실루엣의 뼈대
function ridged(x, z) {
var r = 1 - Math.abs(2 * fbm(x, 7.7, z, 3) - 1);
return r * r;
}
function sstep(a, b, t) {
t = (t - a) / (b - a);
if (t < 0) t = 0; else if (t > 1) t = 1;
return t * t * (3 - 2 * t);
}
function clamp01(t) { return t < 0 ? 0 : (t > 1 ? 1 : t); }
// ── 색 — scss 가 내린 파스텔 변수를 읽어 온다 (밤하늘·산·장막 두 단계)
var css = getComputedStyle(root);
function rgbOf(name) {
var v = css.getPropertyValue(name).trim().replace('#', '');
if (v.length === 3) v = v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
}
var NIGHT = rgbOf('--night'), NHI = rgbOf('--night-hi'), RIDGEC = rgbOf('--ridge');
var CA1 = rgbOf('--aur-a1'), CA2 = rgbOf('--aur-a2'); // 장막 하단→상단 색 쌍 A (mint→pink)
var CB1 = rgbOf('--aur-b1'), CB2 = rgbOf('--aur-b2'); // 쌍 B (lilac→mint)
var SCALE = 0.022; // 배경 격자 간격 — 산 능선의 굵기
var FLOW = 0.26; // 시간축이 흐르는 속도. 시간은 노이즈의 셋째 차원이다
var DRIFT = 1.6; // 산이 아주 느리게 옆으로 미끄러지는 속도(px/s)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var raf = 0;
var glowVal = 55;
function glowNow() { return 0.45 + glowVal * 0.011; } // 장막 세기 다이얼 → 가산 계수
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// ① 밤하늘 + 산 — ridged fbm 능선 아래를 어두운 자색으로 깔고 별을 찍는다
function paintBack(z) {
var i = 0, x, y;
var hcol = new Float32Array(NW); // 컬럼마다 산 높이 — 컬럼당 한 번만 샘플링한다
for (x = 0; x < NW; x++) {
hcol[x] = NH * (0.09 + 0.21 * Math.pow(ridged((x + z * DRIFT * 30) * SCALE, z * 0.05), 1.3));
}
for (y = 0; y < NH; y++) {
var t = y / NH; // 하늘 세로 그라디언트 — 지평선 쪽이 한 단계 밝다
var r = NIGHT[0] + (NHI[0] - NIGHT[0]) * t;
var g = NIGHT[1] + (NHI[1] - NIGHT[1]) * t;
var b = NIGHT[2] + (NHI[2] - NIGHT[2]) * t;
for (x = 0; x < NW; x++) {
if (y >= NH - hcol[x]) { // 산 아래 — 실루엣은 하늘보다 더 어둡다
bpx[i] = RIDGEC[0]; bpx[i + 1] = RIDGEC[1]; bpx[i + 2] = RIDGEC[2];
} else {
bpx[i] = r; bpx[i + 1] = g; bpx[i + 2] = b;
}
bpx[i + 3] = 255;
i += 4;
}
}
for (var s = 0; s < 46; s++) { // 별 — 반짝임은 z 의 사인, 산에 가린 별은 찍지 않는다
var sx = STARS[s * 4] | 0, sy = STARS[s * 4 + 1] | 0;
if (sy >= NH - hcol[sx]) continue;
var tw = STARS[s * 4 + 2] * (0.62 + 0.38 * Math.sin(z * STARS[s * 4 + 3] * 5 + sx));
var k = (sy * NW + sx) * 4;
bpx[k] = Math.min(255, bpx[k] + 165 * tw);
bpx[k + 1] = Math.min(255, bpx[k + 1] + 175 * tw);
bpx[k + 2] = Math.min(255, bpx[k + 2] + 205 * tw);
}
bctx.putImageData(bbuf, 0, 0);
}
// ② 장막 한 장 — 세로 프로파일(위가 짙고 아래로 흐려진다) × 가우스 두께 × 세로 주름.
// 색은 하단(cLow) 에서 상단(cHigh) 로 섞인다 — 오로라의 색 변화를 세로축에 얹는다.
function paintBand(b, z, cLow, cHigh) {
var P = BP[b];
var sig = P.sig, inv2 = 1 / (2 * sig * sig);
var cut = 3.5 * sig;
cpx.fill(0);
var sway = new Float32Array(NH); // 중심선의 좌우 치우침 — 행마다 한 번 샘플링한다
for (var yy = 0; yy < NH; yy++) {
var sv = fbm(P.ph * 0.13, yy * 0.02, z * P.spd, 3);
sway[yy] = (sv - 0.5) * 2 * P.amp;
}
var i = 0;
for (var y = 0; y < NH; y++) {
var vy = 1 - y / NH; // 위=1 · 아래=0
var vert = sstep(0.02, 0.36, vy) * (0.28 + 0.72 * vy); // 상단 짙게, 최하단은 산에 녹는다
if (vert < 0.01) { i += NW * 4; continue; }
var xc = P.bx + sway[y];
var x0 = Math.max(0, Math.floor(xc - cut));
var x1 = Math.min(NW - 1, Math.ceil(xc + cut));
i = (y * NW + x0) * 4;
for (var x = 0; x < NW; x++) {
if (x < x0 || x > x1) { i += 4; continue; }
var d = x - xc;
var gse = Math.exp(-d * d * inv2); // 가우스 — 장막의 단면
var st = fbm(x * 0.085 + P.ph, y * 0.02 - z * P.spd * 1.7, z * P.spd, 3);
st = clamp01((st - 0.5) * 1.9 + 0.5); // 대비를 펴 주름을 또렷하게
var a = gse * vert * (0.15 + 0.85 * st) * glowNow();
if (a > 0.004) {
cpx[i] = cLow[0] + (cHigh[0] - cLow[0]) * vy;
cpx[i + 1] = cLow[1] + (cHigh[1] - cLow[1]) * vy;
cpx[i + 2] = cLow[2] + (cHigh[2] - cLow[2]) * vy;
cpx[i + 3] = a * 255; // 알파만 조절 — rgb 는 순색 그대로
}
i += 4;
}
}
cctx.putImageData(cbuf, 0, 0);
}
function paint(sec) {
var z = sec * FLOW; // 셋째 축 = 시간
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
paintBack(z);
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(bg, 0, 0, NW, NH, 0, 0, W, H);
// ③ 포개기 — lighter 는 src+dst 를 더한다. 장막이 겹치는 곳이 절로 밝아진다
ctx.globalCompositeOperation = 'lighter';
for (var b = 0; b < NB; b++) {
paintBand(b, z, b % 2 ? CB1 : CA1, b % 2 ? CB2 : CA2);
ctx.drawImage(curt, 0, 0, NW, NH, 0, 0, W, H);
}
ctx.globalCompositeOperation = 'source-over';
}
function frame(now) {
paint(now / 1000);
raf = requestAnimationFrame(frame);
}
function applySeed() {
var s = Number(seedIn.value);
if (!isFinite(s) || s < 1) s = 1;
buildTables(s);
if (reduce) paint(0); // 정적 경로에서도 시드를 바꾸면 즉시 다시 그린다
}
function setGlow(v) {
glowVal = v;
glowOut.textContent = String(v);
if (reduce) paint(0);
}
seedIn.addEventListener('input', applySeed);
seedIn.addEventListener('change', applySeed);
glowIn.addEventListener('input', function () { setGlow(Number(glowIn.value)); });
window.addEventListener('resize', function () { fit(); if (reduce) paint(0); });
buildTables(Number(seedIn.value) || 7);
fit();
setGlow(Number(glowIn.value));
if (reduce) {
// reduce — 루프를 시작하지 않고 장막이 멈춰 선 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
paint(0);
} else {
raf = requestAnimationFrame(frame);
}// AuroraCurtain — vanilla 판(run/309/demo/05-terrain-aurora-curtain/index.html) 의 React 포트.
// 세로 노이즈 광막 여섯 장을 globalCompositeOperation 'lighter' 로 가산 합성해 겹치는 곳이
// 더 밝아지는 오로라를 만든다. 노이즈 셋째 축(z) 은 시간이라 24프레임 내내 장막이 다시 짜인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AuroraCurtain.module.scss";
export type AuroraCurtainProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type InputEvt = { target: { value: string } };
type Band = { bx: number; sig: number; amp: number; spd: number; ph: number };
const NW = 160, NH = 100; // 노이즈 버퍼 해상도 — 화면이 커져도 이 값은 그대로다
const NB = 6; // 장막 수
const SCALE = 0.022; // 배경 격자 간격 — 산 능선의 굵기
const FLOW = 0.26; // 시간축이 흐르는 속도. 시간은 노이즈의 셋째 차원이다
const DRIFT = 1.6; // 산이 아주 느리게 옆으로 미끄러지는 속도(px/s)
const FIELD_LABEL = "오로라 커튼 실험판";
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function lp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
// ── 3D 값 노이즈 테이블 — 시드 하나가 별·장막 파라메터·노이즈 전부를 결정한다
class NoiseSky {
perm: Uint8Array;
val: Float32Array;
stars: Float32Array;
bands: Band[];
constructor(seed: number) {
const rng = mulberry32(seed >>> 0);
const p: number[] = [];
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
const t = p[i]; p[i] = p[j]; p[j] = t;
}
this.perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];
this.val = new Float32Array(256);
for (let i = 0; i < 256; i++) this.val[i] = rng();
this.stars = new Float32Array(46 * 4);
for (let i = 0; i < 46; i++) {
this.stars[i * 4] = (rng() * NW) | 0;
this.stars[i * 4 + 1] = (rng() * NH * 0.82) | 0;
this.stars[i * 4 + 2] = 0.3 + rng() * 0.7;
this.stars[i * 4 + 3] = 0.4 + rng() * 1.6;
}
this.bands = [];
for (let b = 0; b < NB; b++) {
this.bands.push({
bx: (b + 0.5) / NB * NW + (rng() - 0.5) * 14,
sig: 6.5 + rng() * 5.5,
amp: 13 + rng() * 11,
spd: 0.7 + rng() * 0.7,
ph: rng() * 64,
});
}
}
vnoise3(x: number, y: number, z: number): number {
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
const xf = x - xi, yf = y - yi, zf = z - zi;
const X = xi & 255, Y = yi & 255, Z = zi & 255;
const u = fade(xf), v = fade(yf), w = fade(zf);
const a = this.perm[X] + Y, b = this.perm[X + 1] + Y;
const aa = this.perm[a] + Z, ab = this.perm[a + 1] + Z, ba = this.perm[b] + Z, bb = this.perm[b + 1] + Z;
const c000 = this.val[this.perm[aa]], c100 = this.val[this.perm[ba]];
const c010 = this.val[this.perm[ab]], c110 = this.val[this.perm[bb]];
const c001 = this.val[this.perm[aa + 1]], c101 = this.val[this.perm[ba + 1]];
const c011 = this.val[this.perm[ab + 1]], c111 = this.val[this.perm[bb + 1]];
const x00 = c000 + (c100 - c000) * u, x10 = c010 + (c110 - c010) * u;
const x01 = c001 + (c101 - c001) * u, x11 = c011 + (c111 - c011) * u;
const y0 = x00 + (x10 - x00) * v, y1 = x01 + (x11 - x01) * v;
return y0 + (y1 - y0) * w;
}
fbm(x: number, y: number, z: number, oct: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i++) {
sum += this.vnoise3(x * f, y * f, z * f) * amp;
norm += amp; amp *= 0.5; f *= 2;
}
return sum / norm;
}
ridged(x: number, z: number): number {
const r = 1 - Math.abs(2 * this.fbm(x, 7.7, z, 3) - 1);
return r * r;
}
}
function sstep(a: number, b: number, t: number): number {
t = (t - a) / (b - a);
if (t < 0) t = 0; else if (t > 1) t = 1;
return t * t * (3 - 2 * t);
}
function clamp01(t: number): number {
return t < 0 ? 0 : (t > 1 ? 1 : t);
}
export default function AuroraCurtain({
duration = "2s", // vanilla $duration: $dur-loop
easing = "ease-out", // vanilla $easing: $ease-out
color = "#4cd4a6", // vanilla $color: mix($subject-mint, $stage-paper, 42%)
}: AuroraCurtainProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const bgRef = useRef<HTMLCanvasElement | null>(null);
const bctxRef = useRef<CanvasRenderingContext2D | null>(null);
const bbufRef = useRef<ImageData | null>(null);
const curtRef = useRef<HTMLCanvasElement | null>(null);
const cctxRef = useRef<CanvasRenderingContext2D | null>(null);
const cbufRef = useRef<ImageData | null>(null);
const skyRef = useRef<NoiseSky | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const glowRef = useRef(55) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [seed, setSeed] = useState(7);
const [glow, setGlow] = useState(55);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function hex3to6(v: string): string {
if (v.length === 3) return v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return v;
}
function rgbOf(name: string): number[] {
const root = rootRef.current;
if (!root) return [0, 0, 0];
const v = hex3to6(getComputedStyle(root).getPropertyValue(name).trim().replace("#", ""));
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
}
function glowNow(): number {
return 0.45 + glowRef.current * 0.011;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// ① 밤하늘 + 산 — ridged fbm 능선 아래를 어두운 자색으로 깔고 별을 찍는다
function paintBack(z: number): void {
const bctx = bctxRef.current, buf = bbufRef.current, sky = skyRef.current;
if (!bctx || !buf || !sky) return;
const px = buf.data;
const NIGHT = rgbOf("--night"), NHI = rgbOf("--night-hi"), RIDGE = rgbOf("--ridge");
const hcol = new Float32Array(NW);
for (let x = 0; x < NW; x++) {
hcol[x] = NH * (0.09 + 0.21 * Math.pow(sky.ridged((x + z * DRIFT * 30) * SCALE, z * 0.05), 1.3));
}
let i = 0;
for (let y = 0; y < NH; y++) {
const t = y / NH;
const r = NIGHT[0] + (NHI[0] - NIGHT[0]) * t;
const g = NIGHT[1] + (NHI[1] - NIGHT[1]) * t;
const b = NIGHT[2] + (NHI[2] - NIGHT[2]) * t;
for (let x = 0; x < NW; x++) {
if (y >= NH - hcol[x]) {
px[i] = RIDGE[0]; px[i + 1] = RIDGE[1]; px[i + 2] = RIDGE[2];
} else {
px[i] = r; px[i + 1] = g; px[i + 2] = b;
}
px[i + 3] = 255;
i += 4;
}
}
for (let s = 0; s < 46; s++) {
const sx = sky.stars[s * 4] | 0, sy = sky.stars[s * 4 + 1] | 0;
if (sy >= NH - hcol[sx]) continue;
const tw = sky.stars[s * 4 + 2] * (0.62 + 0.38 * Math.sin(z * sky.stars[s * 4 + 3] * 5 + sx));
const k = (sy * NW + sx) * 4;
px[k] = Math.min(255, px[k] + 165 * tw);
px[k + 1] = Math.min(255, px[k + 1] + 175 * tw);
px[k + 2] = Math.min(255, px[k + 2] + 205 * tw);
}
bctx.putImageData(buf, 0, 0);
}
// ② 장막 한 장 — 세로 프로파일 × 가우스 두께 × 세로 주름. 색은 하단→상단으로 섞인다
function paintBand(b: number, z: number, cLow: number[], cHigh: number[]): void {
const cctx = cctxRef.current, buf = cbufRef.current, sky = skyRef.current;
if (!cctx || !buf || !sky) return;
const px = buf.data;
px.fill(0);
const P = sky.bands[b];
const sig = P.sig, inv2 = 1 / (2 * sig * sig), cut = 3.5 * sig;
const sway = new Float32Array(NH);
for (let yy = 0; yy < NH; yy++) {
const sv = sky.fbm(P.ph * 0.13, yy * 0.02, z * P.spd, 3);
sway[yy] = (sv - 0.5) * 2 * P.amp;
}
for (let y = 0; y < NH; y++) {
const vy = 1 - y / NH;
const vert = sstep(0.02, 0.36, vy) * (0.28 + 0.72 * vy);
if (vert < 0.01) continue;
const xc = P.bx + sway[y];
const x0 = Math.max(0, Math.floor(xc - cut));
const x1 = Math.min(NW - 1, Math.ceil(xc + cut));
for (let x = x0; x <= x1; x++) {
const d = x - xc;
const gse = Math.exp(-d * d * inv2);
let st = sky.fbm(x * 0.085 + P.ph, y * 0.02 - z * P.spd * 1.7, z * P.spd, 3);
st = clamp01((st - 0.5) * 1.9 + 0.5);
const a = gse * vert * (0.15 + 0.85 * st) * glowNow();
if (a > 0.004) {
const i = (y * NW + x) * 4;
px[i] = cLow[0] + (cHigh[0] - cLow[0]) * vy;
px[i + 1] = cLow[1] + (cHigh[1] - cLow[1]) * vy;
px[i + 2] = cLow[2] + (cHigh[2] - cLow[2]) * vy;
px[i + 3] = a * 255;
}
}
}
cctx.putImageData(buf, 0, 0);
}
function paint(sec: number): void {
const ctx = ctxRef.current, bg = bgRef.current, curt = curtRef.current, sky = skyRef.current;
if (!ctx || !bg || !curt || !sky) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const z = sec * FLOW;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
paintBack(z);
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(bg, 0, 0, NW, NH, 0, 0, W, H);
const CA1 = rgbOf("--aur-a1"), CA2 = rgbOf("--aur-a2");
const CB1 = rgbOf("--aur-b1"), CB2 = rgbOf("--aur-b2");
ctx.globalCompositeOperation = "lighter";
for (let b = 0; b < NB; b++) {
paintBand(b, z, b % 2 ? CB1 : CA1, b % 2 ? CB2 : CA2);
ctx.drawImage(curt, 0, 0, NW, NH, 0, 0, W, H);
}
ctx.globalCompositeOperation = "source-over";
}
function frame(now: number): void {
paint(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function applySeed(v: number): void {
setSeed(v);
skyRef.current = new NoiseSky(v);
if (reduced()) paint(0);
}
function onSeedInput(e: InputEvt): void {
let s = Number(e.target.value);
if (!isFinite(s) || s < 1) s = 1;
applySeed(s);
}
function onGlowInput(e: InputEvt): void {
const v = Number(e.target.value);
glowRef.current = v;
setGlow(v);
if (reduced()) paint(0);
}
function onResize(): void {
fit();
if (reduced()) paint(0);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
const bg = document.createElement("canvas");
bg.width = NW; bg.height = NH;
bgRef.current = bg;
bctxRef.current = bg.getContext("2d");
const curt = document.createElement("canvas");
curt.width = NW; curt.height = NH;
curtRef.current = curt;
cctxRef.current = curt.getContext("2d");
if (!bctxRef.current || !cctxRef.current) return;
bbufRef.current = bctxRef.current.createImageData(NW, NH);
cbufRef.current = cctxRef.current.createImageData(NW, NH);
skyRef.current = new NoiseSky(seed);
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않고 장막이 멈춰 선 한 장만 그린다
setStill(true);
paint(0);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cls = [styles.ac, still ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M120 96a24 24 0 1 1 24-24a24 24 0 0 1-24 24m88-16a24 24 0 1 0 24 24a24 24 0 0 0-24-24m-56 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></g></svg>
<span>장막이 겹치는 곳은 lighter 로 더해져 더 밝아지고, 시드를 바꾸면 하늘이 다시 펼쳐집니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">160 × 100 · lighter × 6</b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>시드
<input type="number" min={1} max={9999} step={1} value={seed} aria-label="시드" onChange={onSeedInput} />
</label>
<label className={styles.dial}>장막 세기
<input type="range" min={0} max={100} step={1} value={glow} aria-label="장막 세기" onChange={onGlowInput} />
<b className={styles.val}>{glow}</b>
</label>
</div>
</div>
</main>
);
}05 오로라 커튼 — 세로 노이즈 광막 — 측정 누적면적 42.083% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 펄린 노이즈 지형 9종 — 라이브러리 없이 계산만으로
이 데모가 속한 글
펄린 노이즈 지형 9종 — 라이브러리 없이 계산만으로펄린 노이즈는 이웃한 값이 매끄럽게 이어지는 난수라, 산·구름·바다·별밤 같은 자연의 형태를 숫자 계산만으로 그려 내는 밑재료입니다. 이 아홉 종을 노이즈 라이브러리 하나 없이 캔버스 2D 로 직접 구현했고, 실측에 이 글의 데모 전부 보기


