02패턴 타일 — 반복 무늬 폭이 곧 눈의 모으는 각도다
노이즈 대신 일정 폭의 타일 무늬를 반복하면 무늬가 안 맞아 도리어 티가 나는 지점이 어디인지 보이는데, 이산 시차와 패턴 주기의 관계를 손으로 만지는 정밀 항목입니다.
쓰임교육 도해, 파라미터 실험
ImageDatarequestAnimationFrame

<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">타일 폭 100px · 계단 4단(4·8·12·16px) · 패턴 흐름 28px/초</span>
<span class="me__meta me__meta--dim">이산 시차 · 계단 주사</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 패턴 타일(02) — keyframes 없음. rAF 계산이 전부.
// 무대 옐로 — 밝은 판 위 잉크 계열 타일 무늬로 이음매를 보여준다.
$me-stage: #e9dbb4;
@include stage($me-stage);
$me-panel: #f7e7b0; // 정밀 판 바닥 — 무대보다 한 단계 밝은 모래
$me-text: #4a3a12; // 라벨 글자 — 진한 갈색 잉크
$me-c0: #f8f0d8; // 타일 바탕 — 크림
$me-c1: #9fc4d8; // 보조선 — 파스텔 청회
$me-c2: #3a3050; // 사선 띠 — 진보라 잉크(크림과的大 대비)
$duration: $dur-loop;
$easing: $ease-out;
$color: $me-c2;
.me {
--me-c0: #{$me-c0};
--me-c1: #{$me-c1};
--me-c2: #{$me-c2};
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, .82);
}
.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, .2);
}
.me__field:focus-visible { outline: 2px solid $me-c2; 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, .82); }
@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 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #e9dbb4;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.me {
--me-c0: #f8f0d8;
--me-c1: #9fc4d8;
--me-c2: #3a3050;
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: #4a3a12;
}
.me__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(74, 58, 18, 0.82);
}
.me__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #f7e7b0;
box-shadow: inset 0 0 0 1px rgba(74, 58, 18, 0.2);
}
.me__field:focus-visible {
outline: 2px solid #3a3050;
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(74, 58, 18, 0.82);
}
@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 !important;
}
}// 패턴 타일(02) — 반복 무늬 폭과 이산 시차의 관계.
// 노이즈 대신 폭 TILE=100px 짜리 타일 무늬(사다리꼴 스트라이프)를 깐다.
// 무늬가 주기적이면 시프트가 주기의 배수일 때 이음매가 딱 맞고, 그렇지 않으면 어긋나 도드라진다 —
// 깊이를 4단 계단으로 두면 단마다 어긋남의 크기가 달라 "몇 픽셀 밀었는지"가 무늬로 읽힌다.
// 계단이 좌우로 주사하며 한 단씩 올라오므로 어긋남의 띠가 화면을 흐른다.
(function () {
// ── 변형 상수 블록 ──
var TILE = 100; // 타일 폭(px) — 140으로 넓히면 눈을 더 멀리 늦춰야 한다(변형 2)
var STEPS = [4, 8, 12, 16]; // 계단 깊이(px)
var CYCLE = 2.0; // 주사 주기(초)
// ─────────────────
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 C0 = tokRGB('--me-c0');
var C1 = tokRGB('--me-c1');
var C2 = tokRGB('--me-c2');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, img = 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);
}
// 타일 무늬 — 캔버스 좌표(반복 주기 좌표)를 받아 색을 결정한다.
// 사선 스트라이프 + 링: 같은 타일이 반복되는지 바로 보이는 무늬.
function tileColor(px, py) {
var tx = ((px % TILE) + TILE) % TILE, ty = ((py % TILE) + TILE) % TILE;
var diag = (tx + ty) % TILE; // 사선 좌표
if (diag < 14 || diag > TILE - 14) return C2; // 굵은 사선 띠
var dx = tx - TILE * 0.5, dy = ty - TILE * 0.5;
var rr = Math.sqrt(dx * dx + dy * dy);
if (rr < 9) return C2; // 중앙 점
if (rr < 15) return C0; // 점 테두리
return (diag % 50 < 4) ? C1 : C0; // 바탕 + 가는 보조선
}
// 깊이 — 가로 4단 계단. 주사相位에 따라 어떤 단이 화면에 들어와 있는지 달라진다.
function depthAt(x, y, t) {
var band = (y * 4 + Math.floor(x / (W / 4))); // 대각 계단 인덱스
var scan = (t / CYCLE) % 1; // 0..1 주사 위상
var appear = ((band % 8) + 8) % 8 / 8; // 단마다 등장 시각을 엇갈리게
var lift = Math.max(0, Math.sin((scan - appear) * Math.PI * 2 - Math.PI / 2) * .5 + .5);
return lift * lift;
}
function shiftFor(x, y, t) {
var band = Math.floor(x / (W / 4)) % 4;
var d = depthAt(x, y, t);
return Math.round(STEPS[band] * d);
}
function draw(t) {
var d = img.data, x, y, di;
var flowY = Math.floor(t * 28); // 패턴 순환 — 타일이 천천히 아래로 흐른다(정수 픽셀)
for (y = 0; y < H; y++) {
var rowY = y + flowY;
for (x = 0; x < W; x++) {
var s = shiftFor(x, rowY, t);
var src = x - s;
// 타일 좌표계는 무한히 반복되므로 음수도 그냥 넘겨면 tileColor 가 되감는다
var c = tileColor(src, rowY);
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);
}
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.25);
});
fit();
if (reduce) {
draw(0.25); // reduce — 한 단이 뜬 계단 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// MagicEyeTexturePatternRepeat — vanilla 판(run/325/demo/02-magic-eye-texture-pattern-repeat/index.html) 의 React 포트.
// 노이즈 대신 폭 TILE=100px 짜리 타일 무늬(사다리꼴 스트라이프)를 깐다.
// 무늬가 주기적이면 시프트가 주기의 배수일 때 이음매가 딱 맞고, 그렇지 않으면 어긋나 도드라진다 —
// 깊이를 4단 계단으로 두면 단마다 어긋남의 크기가 달라 "몇 픽셀 밀었는지"가 무늬로 읽힌다.
// 계단이 좌우로 주사하며 한 단씩 올라오므로 어긋남의 띠가 화면을 흐른다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MagicEyeTexturePatternRepeat.module.scss";
export type MagicEyeTexturePatternRepeatProps = {
duration?: string;
easing?: string;
color?: string;
tile?: number; // 타일 폭(px) — 140으로 넓히면 눈을 더 멀리 늦춰야 한다(변형 2)
cycle?: number; // 주사 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const TILE = 100; // 타일 폭(px)
const STEPS = [4, 8, 12, 16]; // 계단 깊이(px)
const CYCLE = 2.0; // 주사 주기(초) — 설정 초(2s)와 같다
const FLOW = 28; // 패턴 순환 속도(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 = "타일 폭 100px · 계단 4단(4·8·12·16px) · 패턴 흐름 28px/초";
const META_RIGHT = "이산 시차 · 계단 주사";
const HINT_TITLE = "패턴 타일";
const HINT_DESC = "타일 무늬를 일정 폭으로 반복하면 이음매가 어긋나는 지점이 정확히 보입니다";
export default function MagicEyeTexturePatternRepeat({
duration = "2s",
easing = "ease-out",
color = "#3a3050",
tile = TILE,
cycle = CYCLE
}: MagicEyeTexturePatternRepeatProps) {
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 c0Ref = useRef<RGB | null>(null);
const c1Ref = useRef<RGB | null>(null);
const c2Ref = useRef<RGB | null>(null);
const imgRef = useRef<ImageData | 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 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);
}
// 타일 무늬 — 캔버스 좌표(반복 주기 좌표)를 받아 색을 결정한다.
// 사선 스트라이프 + 링: 같은 타일이 반복되는지 바로 보이는 무늬.
function tileColor(px: number, py: number): RGB {
const C0 = c0Ref.current, C1 = c1Ref.current, C2 = c2Ref.current;
if (!C0 || !C1 || !C2) return C0 || [0, 0, 0];
const tx = ((px % tile) + tile) % tile, ty = ((py % tile) + tile) % tile;
const diag = (tx + ty) % tile; // 사선 좌표
if (diag < 14 || diag > tile - 14) return C2; // 굵은 사선 띠
const dx = tx - tile * 0.5, dy = ty - tile * 0.5;
const rr = Math.sqrt(dx * dx + dy * dy);
if (rr < 9) return C2; // 중앙 점
if (rr < 15) return C0; // 점 테두리
return (diag % 50 < 4) ? C1 : C0; // 바탕 + 가는 보조선
}
// 깊이 — 가로 4단 계단. 주사 위상에 따라 어떤 단이 화면에 들어와 있는지 달라진다.
function depthAt(x: number, y: number, t: number): number {
const W = wRef.current;
const band = Math.floor(y * 4 + Math.floor(x / (W / 4))); // 대각 계단 인덱스
const scan = (t / cycle) % 1; // 0..1 주사 위상
const appear = (((band % 8) + 8) % 8) / 8; // 단마다 등장 시각을 엇갈리게
const lift = Math.max(0, Math.sin((scan - appear) * Math.PI * 2 - Math.PI / 2) * .5 + .5);
return lift * lift;
}
function shiftFor(x: number, y: number, t: number): number {
const W = wRef.current;
const band = Math.floor(x / (W / 4)) % 4;
return Math.round(STEPS[band] * depthAt(x, y, t));
}
function draw(t: number): void {
const ctx = ctxRef.current;
const img = imgRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !img) return;
const d = img.data;
const flowY = Math.floor(t * FLOW); // 패턴 순환 — 타일이 천천히 아래로 흐른다(정수 픽셀)
for (let y = 0; y < H; y++) {
const rowY = y + flowY;
for (let x = 0; x < W; x++) {
const s = shiftFor(x, rowY, t);
const src = x - s;
// 타일 좌표계는 무한히 반복되므로 음수도 그냥 넘겨면 tileColor 가 되감는다
const c = tileColor(src, rowY);
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);
}
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;
c0Ref.current = tokRGB(root, "--me-c0"); // 타일 바탕 — 크림
c1Ref.current = tokRGB(root, "--me-c1"); // 보조선 — 파스텔 청회
c2Ref.current = tokRGB(root, "--me-c2"); // 사선 띠 — 진보라 잉크
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.25);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.25); // 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
}, [tile, cycle]);
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>
);
}02 패턴 타일 — 반복 무늬 폭이 곧 눈의 모으는 각도다 — 측정 누적면적 52.806% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 매직아이 9종 — 깊이 지도를 주사선 밀기 한 겹으로 짜는 스테레오그램 캔버스
이 데모가 속한 글
매직아이 9종 — 깊이 지도를 주사선 밀기 한 겹으로 짜는 스테레오그램 캔버스픽셀의 밝기를 깊이로 읽어 같은 줄 안에서 몇 픽셀 밀어 붙이면 평면 무늬 속에 입체가 숨습니다. 라이브러리 없이 복붙으로 가져가는 숨은그림 엔진입니다. 매직아이 그림 한 장을 채우는 일이 이미지 없이 계산만으로 되 이 글의 데모 전부 보기


