09로고 히어로 씬 — 브랜드 글자를 숨긴 대문 장면
브랜드 워드마크를 깊이로 숨기고 느린 파동과 비네팅을 얹으면 방문자가 잠시 눈을 늦춰야 로고가 떠오르는 기억에 남는 대문이 완성되는데, 계산 화면 하나가 서비스 첫인상이 되는 조립 예시입니다.
쓰임대문 히어로, 스플래시
createLinearGradientImageDatarequestAnimationFrame

<main class="stage">
<div class="me is-demo" id="meRoot">
<p class="me__hint">
<span>로고 히어로 씬</span>
<span class="me__desc">느린 파동 위에 워드마크를 깊이로 숨기면 눈을 늦춘 사람에게만 로고가 떠오릅니다</span>
</p>
<div class="me__field" role="application" aria-label="브랜드 대문 판" tabindex="0">
<canvas class="me__cv" id="meCv"></canvas>
</div>
<div class="me__bar">
<span class="me__meta">워드마크 96px · 파동 주기 2초 · 비네팅 18%</span>
<span class="me__meta me__meta--dim">createLinearGradient · ImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 로고 히어로 씬(09) — keyframes 없음. 워드마크 깊이 × 느린 파동 × 비네팅.
// 무대 잉크 — 브랜드 대문의 심야 톤.
$me-stage: #1a1720;
@include stage($me-stage);
$me-panel: #241f2d; // 대문 판 바닥 — 어두운 먹보라(노이즈가 잘 뜨는 바탕)
$me-text: #d5d0dc; // 라벨 글자 — 밝은 라일락 회색
$me-p0: #cfd8f2; // 노이즈 파스텔 1 — 파스텔 퍼워더 블루
$me-p1: #e8d5e2; // 노이즈 파스텔 2 — 파스텔 로즈
$me-p2: #d3e8dc; // 노이즈 파스텔 3 — 파스텔 민트
$me-p3: #f0e6d2; // 노이즈 팔레트 4 — 파스텔 크림
$me-p4: #5f5370; // 노이즈 어두운 칸 — 저채도 보라
$duration: $dur-loop;
$easing: $ease-out;
$color: $me-p0;
.me {
--me-p0: #{$me-p0};
--me-p1: #{$me-p1};
--me-p2: #{$me-p2};
--me-p3: #{$me-p3};
--me-p4: #{$me-p4};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.me__hint {
display: flex; flex-direction: column; gap: 0; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $me-text;
}
.me__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($me-text, .78);
}
.me__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $me-panel;
box-shadow: inset 0 0 0 1px rgba($me-text, .16);
}
.me__field:focus-visible { outline: 2px solid $me-p0; outline-offset: 2px; }
.me__cv { display: block; width: 100%; height: 100%; }
.me__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.me__meta { font-size: 10px; font-weight: 500; color: rgba($me-text, .78); }
@media (max-width: 340px) {
.me { gap: $sp-1; }
.me__field { height: 100px; }
.me__meta--dim { display: none; }
}
// reduce — 대문 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.me.is-still .me__field { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a1720;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.me {
--me-p0: #cfd8f2;
--me-p1: #e8d5e2;
--me-p2: #d3e8dc;
--me-p3: #f0e6d2;
--me-p4: #5f5370;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.me__hint {
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d5d0dc;
}
.me__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(213, 208, 220, 0.78);
}
.me__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #241f2d;
box-shadow: inset 0 0 0 1px rgba(213, 208, 220, 0.16);
}
.me__field:focus-visible {
outline: 2px solid #cfd8f2;
outline-offset: 2px;
}
.me__cv {
display: block;
width: 100%;
height: 100%;
}
.me__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.me__meta {
font-size: 10px;
font-weight: 500;
color: rgba(213, 208, 220, 0.78);
}
@media (max-width: 340px) {
.me {
gap: 4px;
}
.me__field {
height: 100px;
}
.me__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.me.is-still .me__field {
cursor: default;
}
}// 로고 히어로 씬(09) — 브랜드 워드마크를 깊이로 숨긴 대문 장면.
// 오프스크린 캔버스에 워드마크(GODRICH, 96px 굵은 글자)를 찍고 밝기를 깊이로 넘긴 뒤
// 그 위로 느린 사인 파동(주기 2초)을 흘린다 — 글자가 부풀었다 가라앉으며 숨쉰다.
// 비네팅 18%: 노이즈 판 위에 createLinearGradient 어두운 가장자리를 얹어 씬으로 완성한다.
(function () {
// ── 변형 상수 블록 ──
var MARK = 'GODRICH'; // 워드마크
var FS = 96; // 워드마크 크기(px)
var CYCLE = 2.0; // 파동 주기(초)
var WAVELEN = 150; // 파동 파장(px)
var VIG = 0.18; // 비네팅 강도(0..1)
var MAXSHIFT = 12; // 글자 최대 시프트(px)
// ─────────────────
var root = document.getElementById('meRoot');
var cvEl = document.getElementById('meCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var PAL = [];
for (var i = 0; i < 5; i++) PAL.push(tokRGB('--me-p' + i));
function baseIdx(x, y) {
var h = (Math.imul(x + 11, 374761393) + Math.imul(y + 7, 668265263)) >>> 0;
h ^= h >>> 13;
h = Math.imul(h, 1274126177) >>> 0;
h ^= h >>> 16;
return ((h % PAL.length) + PAL.length) % PAL.length;
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, img = null;
var off = document.createElement('canvas'); // 워드마크 오프스크린
var offCtx = off.getContext('2d');
var markMap = null;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
img = ctx.createImageData(W, H);
off.width = W; off.height = H;
renderMark();
}
// 워드마크를 오프스크린에 찍는다 — 2단 락업(대 워드마크 + 서브 워드)으로 판을 채운다
function renderMark() {
offCtx.clearRect(0, 0, W, H);
var fs = Math.min(FS, W * 0.21);
offCtx.textAlign = 'center'; offCtx.textBaseline = 'middle';
offCtx.fillStyle = '#fff';
// 판폭에 맞출 수 없으면 가로 축소 — 마크는 항상 화면을 꽉 채운다
offCtx.font = '900 ' + fs + 'px "Segoe UI", "Arial Black", sans-serif';
var tw = offCtx.measureText(MARK).width;
var line1y = H * 0.42, line2y = H * 0.66;
if (tw > W * 0.94) {
offCtx.save();
offCtx.translate(W / 2, line1y);
offCtx.scale(W * 0.94 / tw, 1);
offCtx.fillText(MARK, 0, 0);
offCtx.restore();
} else {
offCtx.fillText(MARK, W / 2, line1y);
}
// 서브 워드 — 자간을 벌려 브랜드 락업을 만든다
var sub = 'S T O R Y';
offCtx.font = '700 ' + Math.round(fs * 0.34) + 'px "Segoe UI", sans-serif';
var tw2 = offCtx.measureText(sub).width;
if (tw2 > W * 0.94) {
offCtx.save();
offCtx.translate(W / 2, line2y);
offCtx.scale(W * 0.94 / tw2, 1);
offCtx.fillText(sub, 0, 0);
offCtx.restore();
} else {
offCtx.fillText(sub, W / 2, line2y);
}
var id = offCtx.getImageData(0, 0, W, H);
var lum = new Float32Array(W * H);
var yy, xx;
for (yy = 0; yy < H; yy++) {
for (xx = 0; xx < W; xx++) {
var v = id.data[(yy * W + xx) * 4] / 255; // 흰 글자 → 1
if (v > 0.25) lum[yy * W + xx] = v;
}
}
// 두께 확장 — 깊이 맵만 3px 부풀려 스트로크 사이도 조각되게(화면 픽셀이 아니라 깊이만)
markMap = new Float32Array(W * H);
for (yy = 0; yy < H; yy++) {
for (xx = 0; xx < W; xx++) {
var base = lum[yy * W + xx];
if (base > 0) { markMap[yy * W + xx] = base; continue; }
var m = 0;
for (var oy = -3; oy <= 3 && !m; oy++) {
var sy2 = yy + oy; if (sy2 < 0 || sy2 >= H) continue;
for (var ox = -3; ox <= 3; ox++) {
var sx2 = xx + ox; if (sx2 < 0 || sx2 >= W) continue;
if (lum[sy2 * W + sx2] > 0) { m = 0.55; break; }
}
}
markMap[yy * W + xx] = m;
}
}
}
function depthAt(x, y, t) {
if (!markMap) return 0;
var ph = t / CYCLE * Math.PI * 2;
var breathe = 0.55 + 0.45 * Math.sin(ph + 0.8);
// 바닥 지면 — 판 전체에 아주 얕은 원거리 지형(1~2px)을 깐다. 마크 뒤 안개처럼
// 숨쉬며, 노이즈 배경 전체가 느리게 재엮인다(마크만으로는 면적이 좁다).
var fx = (x / W) * Math.PI * 2;
var fw = 0.5 + 0.5 * Math.sin(fx * 1.5 - ph * 0.5);
var fw2 = 0.5 + 0.5 * Math.sin(fx * 0.8 + y * 0.012 - ph * 0.34 + 2.0);
var floorD = (0.085 * fw + 0.075 * fw2) * breathe;
// 수직 bob — 마크를 천천히 떠오르게 한다(행마다 샘플이 흐른다)
var bob = Math.round(Math.sin(ph) * H * 0.08);
var sy = y - bob;
if (sy < 0 || sy >= H) sy = y;
var m = markMap[sy * W + x];
if (!m || m <= 0) return floorD;
// 느린 파동 — 마크를 대각선으로 가로지르는 사인이 글자를 부풀렸다 가라앉힌다
var wpos = ((x + y * 0.5) / WAVELEN) * Math.PI * 2;
var wave = Math.sin(wpos - ph) * 0.5 + 0.5;
var wave2 = Math.sin(((x * 0.4 - y) / (WAVELEN * 1.3)) * Math.PI * 2 - ph * 0.6 + 1.4) * 0.5 + 0.5;
var markD = m * (0.22 + 0.42 * wave + 0.36 * wave2) * breathe;
return markD > floorD ? markD : floorD;
}
function draw(t) {
var d = img.data, x, y, di;
var col = new Int32Array(W);
for (y = 0; y < H; y++) {
for (x = 0; x < W; x++) {
var s = Math.round(depthAt(x, y, t) * MAXSHIFT);
if (s <= 0) {
col[x] = baseIdx(x, y);
} else {
var from = x - s;
col[x] = from >= 0 ? col[from] : baseIdx(from + W, y);
}
}
for (x = 0; x < W; x++) {
var c = PAL[col[x]];
di = (y * W + x) * 4;
d[di] = c[0]; d[di + 1] = c[1]; d[di + 2] = c[2]; d[di + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
// 비네팅 18% — 노이즈 판 위에 어두운 가장자리를 얹는다(씬 완성)
var vg = ctx.createLinearGradient(0, 0, 0, H);
vg.addColorStop(0, 'rgba(12, 10, 18, ' + (VIG * 0.9).toFixed(3) + ')');
vg.addColorStop(0.28, 'rgba(12, 10, 18, 0)');
vg.addColorStop(0.72, 'rgba(12, 10, 18, 0)');
vg.addColorStop(1, 'rgba(12, 10, 18, ' + (VIG * 0.9).toFixed(3) + ')');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0);
});
fit();
if (reduce) {
draw(0);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// MagicEyeLogoHeroScene — vanilla 판(run/325/demo/09-magic-eye-logo-hero-scene/index.html) 의 React 포트.
// 브랜드 워드마크를 깊이로 숨긴 대문 장면. 오프스크린 캔버스에 워드마크(GODRICH, 96px 굵은 글자)를
// 찍고 밝기를 깊이로 넘긴 뒤 그 위로 느린 사인 파동(주기 2초)을 흘린다 — 글자가 부풀었다 가라앉으며 숨쉰다.
// 비네팅 18%: 노이즈 판 위에 createLinearGradient 어두운 가장자리를 얹어 씬으로 완성한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MagicEyeLogoHeroScene.module.scss";
export type MagicEyeLogoHeroSceneProps = {
duration?: string;
easing?: string;
color?: string;
mark?: string; // 워드마크
fs?: number; // 워드마크 크기(px)
cycle?: number; // 파동 주기(초)
wavelen?: number; // 파동 파장(px)
vig?: number; // 비네팅 강도(0..1)
maxshift?: number; // 글자 최대 시프트(px)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const MARK = "GODRICH"; // 워드마크
const FS = 96; // 워드마크 크기(px)
const CYCLE = 2.0; // 파동 주기(초) — 설정 초(2s)와 같다
const WAVELEN = 150; // 파동 파장(px)
const VIG = 0.18; // 비네팅 강도(0..1)
const MAXSHIFT = 12; // 글자 최대 시프트(px)
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "브랜드 대문 판";
const META_LEFT = "워드마크 96px · 파동 주기 2초 · 비네팅 18%";
const META_RIGHT = "createLinearGradient · ImageData";
const HINT_TITLE = "로고 히어로 씬";
const HINT_DESC = "느린 파동 위에 워드마크를 깊이로 숨기면 눈을 늦춘 사람에게만 로고가 떠오릅니다";
const SUB_WORD = "S T O R Y"; // 서브 워드 — 자간을 벌린 브랜드 락업
export default function MagicEyeLogoHeroScene({
duration = "2s",
easing = "ease-out",
color = "#cfd8f2",
mark = MARK,
fs = FS,
cycle = CYCLE,
wavelen = WAVELEN,
vig = VIG,
maxshift = MAXSHIFT
}: MagicEyeLogoHeroSceneProps) {
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 palRef = useRef<RGB[] | null>(null);
const imgRef = useRef<ImageData | null>(null);
const markRef = useRef<Float32Array | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function baseIdx(x: number, y: number): number {
const pal = palRef.current;
if (!pal) return 0;
let h = (Math.imul(x + 11, 374761393) + Math.imul(y + 7, 668265263)) >>> 0;
h ^= h >>> 13;
h = Math.imul(h, 1274126177) >>> 0;
h ^= h >>> 16;
return ((h % pal.length) + pal.length) % pal.length;
}
// 워드마크를 오프스크린에 찍는다 — 2단 락업(대 워드마크 + 서브 워드)으로 판을 채운다
function renderMark(): void {
const W = wRef.current, H = hRef.current;
if (W <= 0 || H <= 0) return;
const off = document.createElement("canvas"); // 워드마크 오프스크린
off.width = W; off.height = H;
const offCtx = off.getContext("2d");
if (!offCtx) return;
offCtx.clearRect(0, 0, W, H);
const fsz = Math.min(fs, W * 0.21);
offCtx.textAlign = "center"; offCtx.textBaseline = "middle";
offCtx.fillStyle = "#fff";
// 판폭에 맞출 수 없으면 가로 축소 — 마크는 항상 화면을 꽉 채운다
offCtx.font = '900 ' + fsz + 'px "Segoe UI", "Arial Black", sans-serif';
const tw = offCtx.measureText(mark).width;
const line1y = H * 0.42, line2y = H * 0.66;
if (tw > W * 0.94) {
offCtx.save();
offCtx.translate(W / 2, line1y);
offCtx.scale(W * 0.94 / tw, 1);
offCtx.fillText(mark, 0, 0);
offCtx.restore();
} else {
offCtx.fillText(mark, W / 2, line1y);
}
// 서브 워드 — 자간을 벌려 브랜드 락업을 만든다
offCtx.font = '700 ' + Math.round(fsz * 0.34) + 'px "Segoe UI", sans-serif';
const tw2 = offCtx.measureText(SUB_WORD).width;
if (tw2 > W * 0.94) {
offCtx.save();
offCtx.translate(W / 2, line2y);
offCtx.scale(W * 0.94 / tw2, 1);
offCtx.fillText(SUB_WORD, 0, 0);
offCtx.restore();
} else {
offCtx.fillText(SUB_WORD, W / 2, line2y);
}
const id = offCtx.getImageData(0, 0, W, H);
const lum = new Float32Array(W * H);
for (let yy = 0; yy < H; yy++) {
for (let xx = 0; xx < W; xx++) {
const v = id.data[(yy * W + xx) * 4] / 255; // 흰 글자 → 1
if (v > 0.25) lum[yy * W + xx] = v;
}
}
// 두께 확장 — 깊이 맵만 3px 부풀려 스트로크 사이도 조각되게(화면 픽셀이 아니라 깊이만)
const markMap = new Float32Array(W * H);
for (let yy = 0; yy < H; yy++) {
for (let xx = 0; xx < W; xx++) {
const base = lum[yy * W + xx];
if (base > 0) { markMap[yy * W + xx] = base; continue; }
let m = 0;
for (let oy = -3; oy <= 3 && !m; oy++) {
const sy2 = yy + oy; if (sy2 < 0 || sy2 >= H) continue;
for (let ox = -3; ox <= 3; ox++) {
const sx2 = xx + ox; if (sx2 < 0 || sx2 >= W) continue;
if (lum[sy2 * W + sx2] > 0) { m = 0.55; break; }
}
}
markMap[yy * W + xx] = m;
}
}
markRef.current = markMap;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
imgRef.current = ctx.createImageData(wRef.current, hRef.current);
renderMark();
}
function depthAt(x: number, y: number, t: number): number {
const markMap = markRef.current;
const W = wRef.current, H = hRef.current;
if (!markMap) return 0;
const ph = t / cycle * Math.PI * 2;
const breathe = 0.55 + 0.45 * Math.sin(ph + 0.8);
// 바닥 지면 — 판 전체에 아주 얕은 원거리 지형(1~2px)을 깐다. 마크 뒤 안개처럼
// 숨쉬며, 노이즈 배경 전체가 느리게 재엮인다(마크만으로는 면적이 좁다).
const fx = (x / W) * Math.PI * 2;
const fw = 0.5 + 0.5 * Math.sin(fx * 1.5 - ph * 0.5);
const fw2 = 0.5 + 0.5 * Math.sin(fx * 0.8 + y * 0.012 - ph * 0.34 + 2.0);
const floorD = (0.085 * fw + 0.075 * fw2) * breathe;
// 수직 bob — 마크를 천천히 떠오르게 한다(행마다 샘플이 흐른다)
const bob = Math.round(Math.sin(ph) * H * 0.08);
let sy = y - bob;
if (sy < 0 || sy >= H) sy = y;
const m = markMap[sy * W + x];
if (!m || m <= 0) return floorD;
// 느린 파동 — 마크를 대각선으로 가로지르는 사인이 글자를 부풀렸다 가라앉힌다
const wpos = ((x + y * 0.5) / wavelen) * Math.PI * 2;
const wave = Math.sin(wpos - ph) * 0.5 + 0.5;
const wave2 = Math.sin(((x * 0.4 - y) / (wavelen * 1.3)) * Math.PI * 2 - ph * 0.6 + 1.4) * 0.5 + 0.5;
const markD = m * (0.22 + 0.42 * wave + 0.36 * wave2) * breathe;
return markD > floorD ? markD : floorD;
}
function draw(t: number): void {
const ctx = ctxRef.current;
const img = imgRef.current;
const pal = palRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !img || !pal) return;
const d = img.data;
const col = new Int32Array(W);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const s = Math.round(depthAt(x, y, t) * maxshift);
if (s <= 0) {
col[x] = baseIdx(x, y);
} else {
const from = x - s;
col[x] = from >= 0 ? col[from] : baseIdx(from + W, y);
}
}
for (let x = 0; x < W; x++) {
const c = pal[col[x]];
const di = (y * W + x) * 4;
d[di] = c[0]; d[di + 1] = c[1]; d[di + 2] = c[2]; d[di + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
// 비네팅 18% — 노이즈 판 위에 어두운 가장자리를 얹는다(씬 완성)
const vg = ctx.createLinearGradient(0, 0, 0, H);
vg.addColorStop(0, "rgba(12, 10, 18, " + (vig * 0.9).toFixed(3) + ")");
vg.addColorStop(0.28, "rgba(12, 10, 18, 0)");
vg.addColorStop(0.72, "rgba(12, 10, 18, 0)");
vg.addColorStop(1, "rgba(12, 10, 18, " + (vig * 0.9).toFixed(3) + ")");
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const pal: RGB[] = [];
for (let i = 0; i < 5; i++) pal.push(tokRGB(root, "--me-p" + i));
palRef.current = pal;
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) draw(0);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0); // reduce — 대문 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mark, fs, cycle, wavelen, vig, maxshift]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT_TITLE}</span>
<span className={styles.desc}>{HINT_DESC}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}09 로고 히어로 씬 — 브랜드 글자를 숨긴 대문 장면 — 측정 누적면적 52.539% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 매직아이 9종 — 깊이 지도를 주사선 밀기 한 겹으로 짜는 스테레오그램 캔버스
이 데모가 속한 글
매직아이 9종 — 깊이 지도를 주사선 밀기 한 겹으로 짜는 스테레오그램 캔버스픽셀의 밝기를 깊이로 읽어 같은 줄 안에서 몇 픽셀 밀어 붙이면 평면 무늬 속에 입체가 숨습니다. 라이브러리 없이 복붙으로 가져가는 숨은그림 엔진입니다. 매직아이 그림 한 장을 채우는 일이 이미지 없이 계산만으로 되 이 글의 데모 전부 보기


