05Aurora curtain — vertical noise light veil
Several tall fbm bands are layered with globalCompositeOperation 'lighter' so overlaps brighten, and giving each band a different noise phase makes the curtain sway and cross like real aurora.
Best forNight-scene, event, and winter-campaign heroes
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>Where the curtains overlap, lighter blending brightens them further, and changing the seed unfurls the sky anew</span>
</p>
<div class="ac__field" id="field" role="application" aria-label="Aurora curtain experiment panel" 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">Seed
<input type="number" id="seed" min="1" max="9999" step="1" value="7">
</label>
<label class="ac__dial">Curtain intensity
<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 로 star 위치와 장막 파라메터까지 뽑는다 — Seed 하나가 하늘 전체를 결정한다.
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++) { // star — 산 위 하늘에만 뜬다(그리는 쪽에서 걸러 낸다)
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; } // Curtain intensity 다이얼 → 가산 계수
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++) { // star — 반짝임은 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 Aurora curtain — vertical noise light veil — Measured area 42.083% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Perlin Noise Animations, No Libraries, Pure Math
The post this demo lives in
9 Perlin Noise Animations, No Libraries, Pure MathPerlin noise animation smooths raw randomness into values that connect with their neighbours, and out of that one functi See all demos in this post


