diff --git a/app/components/deepfield/HeroField.tsx b/app/components/deepfield/HeroField.tsx
deleted file mode 100644
index 5e9e045..0000000
--- a/app/components/deepfield/HeroField.tsx
+++ /dev/null
@@ -1,330 +0,0 @@
-'use client';
-
-import { useEffect, useRef, useState } from 'react';
-// 타입만 정적 import — 번들에 코드가 들어가지 않음 (import type)
-import type * as THREE from 'three';
-
-import { useFieldMode } from './useFieldMode';
-
-interface Props {
- className?: string;
-}
-
-/**
- * 정적 2광원 radial 그래디언트.
- * static 모드 단독 비주얼이자, full/lite에서 캔버스 아래에 항상 깔리는 베이스.
- * (WebGL 로딩 전/실패 시에도 비주얼 공백 없음)
- */
-function StaticField() {
- return (
-
- );
-}
-
-// ───────────────────────── 셰이더 ─────────────────────────
-
-const VERTEX_SHADER = /* glsl */ `
- uniform float uTime;
- uniform vec2 uMouse; // NDC (-1..1), lite/static에선 사실상 미사용
- uniform float uMouseAmp; // 커서 자기장 세기 (lite=0)
- uniform float uScroll; // 0..1, 진행될수록 흩어짐
- uniform float uPixelRatio;
-
- attribute float aScale; // 파티클별 기본 크기 (1.5~3px)
- attribute float aSeed; // 드리프트 위상 분산
-
- varying float vAlpha;
- varying vec3 vColor;
-
- // 색: #60a5fa(밝은) ↔ #1d4ed8(딥) 보간
- const vec3 C_BRIGHT = vec3(0.376, 0.647, 0.980); // #60a5fa
- const vec3 C_DEEP = vec3(0.114, 0.306, 0.847); // #1d4ed8
-
- void main() {
- vec3 pos = position;
-
- // 미세 유영 — 사인 노이즈 (드리프트)
- float t = uTime * 0.18 + aSeed * 6.2831853;
- pos.x += sin(t) * 0.06;
- pos.y += cos(t * 0.9) * 0.06;
- pos.z += sin(t * 0.7) * 0.04;
-
- // 스크롤 — 진행될수록 바깥으로 밀려 흩어짐
- pos.xy += normalize(pos.xy + 0.0001) * uScroll * 0.9;
-
- // 커서 자기장 — 화면 평면 기준 거리로 부드럽게 밀어냄
- if (uMouseAmp > 0.0) {
- vec2 toP = pos.xy - uMouse * 1.6;
- float d = length(toP);
- float radius = 0.7;
- float push = smoothstep(radius, 0.0, d); // 가까울수록 1
- pos.xy += normalize(toP + 0.0001) * push * 0.35 * uMouseAmp;
- pos.z += push * 0.2 * uMouseAmp;
- }
-
- vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
- gl_Position = projectionMatrix * mvPosition;
-
- // 크기: 원근(-mvPosition.z) 반영 + DPR
- gl_PointSize = aScale * uPixelRatio * (300.0 / -mvPosition.z);
-
- // 색: seed로 두 파랑 사이 보간
- vColor = mix(C_DEEP, C_BRIGHT, aSeed);
-
- // 불투명도: 스크롤로 소멸, 깊이로 약간 페이드 (전체 톤 다운 — 텍스트 가독성 우선)
- float depthFade = smoothstep(-3.0, 0.5, mvPosition.z);
- vAlpha = (1.0 - uScroll) * (0.28 + depthFade * 0.18);
- }
-`;
-
-const FRAGMENT_SHADER = /* glsl */ `
- precision mediump float;
- varying float vAlpha;
- varying vec3 vColor;
-
- void main() {
- // 원형 소프트 포인트 — 가장자리 부드럽게
- vec2 c = gl_PointCoord - vec2(0.5);
- float dist = length(c);
- if (dist > 0.5) discard;
- float soft = smoothstep(0.5, 0.05, dist);
- gl_FragColor = vec4(vColor, soft * vAlpha);
- }
-`;
-
-// ───────────────────────── 컴포넌트 ─────────────────────────
-
-export default function HeroField({ className }: Props) {
- const mode = useFieldMode();
- // WebGL 초기화 실패 시 static으로 강등
- const [failed, setFailed] = useState(false);
- const canvasRef = useRef(null);
-
- const effectiveMode = failed ? 'static' : mode;
- const animated = effectiveMode === 'full' || effectiveMode === 'lite';
-
- useEffect(() => {
- if (!animated) return;
- const canvas = canvasRef.current;
- if (!canvas) return;
-
- const isFull = effectiveMode === 'full';
- // 밀도 완화 — 가산 혼합 파티클이 겹쳐 텍스트 뒤를 밝게 씻어내는(화이트 블룸) 현상 억제
- const COUNT = isFull ? 1600 : 500;
-
- let disposed = false;
- let rafId = 0;
- let renderer: THREE.WebGLRenderer | null = null;
- let scene: THREE.Scene | null = null;
- let camera: THREE.PerspectiveCamera | null = null;
- let geometry: THREE.BufferGeometry | null = null;
- let material: THREE.ShaderMaterial | null = null;
- let points: THREE.Points | null = null;
- let io: IntersectionObserver | null = null;
-
- // 가시성/뷰포트 상태 — 둘 다 OK일 때만 rAF 돌림
- let pageVisible = document.visibilityState !== 'hidden';
- let inView = true;
-
- // 마우스 스무딩 (NDC)
- const mouse = { x: 0, y: 0 };
- const mouseTarget = { x: 0, y: 0 };
-
- // 핸들러 참조 (cleanup용)
- let onMouseMove: ((e: MouseEvent) => void) | null = null;
- let onResize: (() => void) | null = null;
- let onVisibility: (() => void) | null = null;
-
- const start = () => {
- if (rafId || disposed) return;
- if (!pageVisible || !inView) return;
- rafId = requestAnimationFrame(loop);
- };
- const stop = () => {
- if (rafId) cancelAnimationFrame(rafId);
- rafId = 0;
- };
-
- let loop: (now: number) => void = () => {};
-
- (async () => {
- let THREE_NS: typeof THREE;
- try {
- // three는 dynamic import만 — 메인 번들 분리
- THREE_NS = await import('three');
- } catch {
- if (!disposed) setFailed(true);
- return;
- }
- if (disposed || !canvasRef.current) return;
-
- try {
- const width = canvas.clientWidth || window.innerWidth;
- const height = canvas.clientHeight || window.innerHeight;
-
- renderer = new THREE_NS.WebGLRenderer({
- canvas,
- alpha: true, // 섹션 bg가 비치도록 투명
- antialias: false,
- powerPreference: 'low-power',
- });
- // lite는 DPR 1 고정, full은 최대 2로 제한
- const dpr = isFull ? Math.min(window.devicePixelRatio || 1, 2) : 1;
- renderer.setPixelRatio(dpr);
- renderer.setSize(width, height, false);
- renderer.setClearColor(0x000000, 0); // 완전 투명
-
- scene = new THREE_NS.Scene();
- camera = new THREE_NS.PerspectiveCamera(60, width / height, 0.1, 100);
- camera.position.z = 3;
-
- // 얕은 3D 슬랩: 화면을 덮는 균일 분포 + 약간의 노이즈, z 약간 분산
- const positions = new Float32Array(COUNT * 3);
- const scales = new Float32Array(COUNT);
- const seeds = new Float32Array(COUNT);
- const SPREAD_X = 5.0;
- const SPREAD_Y = 3.2;
- for (let i = 0; i < COUNT; i++) {
- positions[i * 3 + 0] = (Math.random() - 0.5) * SPREAD_X;
- positions[i * 3 + 1] = (Math.random() - 0.5) * SPREAD_Y;
- positions[i * 3 + 2] = (Math.random() - 0.5) * 1.2; // 얕은 z 분산
- scales[i] = 1.5 + Math.random() * 1.5; // 1.5~3px
- seeds[i] = Math.random();
- }
-
- geometry = new THREE_NS.BufferGeometry();
- geometry.setAttribute('position', new THREE_NS.BufferAttribute(positions, 3));
- geometry.setAttribute('aScale', new THREE_NS.BufferAttribute(scales, 1));
- geometry.setAttribute('aSeed', new THREE_NS.BufferAttribute(seeds, 1));
-
- material = new THREE_NS.ShaderMaterial({
- uniforms: {
- uTime: { value: 0 },
- uMouse: { value: new THREE_NS.Vector2(0, 0) },
- uMouseAmp: { value: isFull ? 1 : 0 }, // lite는 커서 반응 off
- uScroll: { value: 0 },
- uPixelRatio: { value: dpr },
- },
- vertexShader: VERTEX_SHADER,
- fragmentShader: FRAGMENT_SHADER,
- transparent: true,
- depthWrite: false,
- depthTest: false,
- blending: THREE_NS.AdditiveBlending, // 미세한 글로우
- });
-
- points = new THREE_NS.Points(geometry, material);
- scene.add(points);
-
- // ── 핸들러 ──
- if (isFull) {
- onMouseMove = (e: MouseEvent) => {
- mouseTarget.x = (e.clientX / window.innerWidth) * 2 - 1;
- mouseTarget.y = -((e.clientY / window.innerHeight) * 2 - 1);
- };
- window.addEventListener('mousemove', onMouseMove, { passive: true });
- }
-
- onResize = () => {
- if (!renderer || !camera || !canvasRef.current) return;
- const w = canvasRef.current.clientWidth || window.innerWidth;
- const h = canvasRef.current.clientHeight || window.innerHeight;
- camera.aspect = w / h;
- camera.updateProjectionMatrix();
- renderer.setSize(w, h, false);
- };
- window.addEventListener('resize', onResize, { passive: true });
-
- onVisibility = () => {
- pageVisible = document.visibilityState !== 'hidden';
- if (pageVisible) start();
- else stop();
- };
- document.addEventListener('visibilitychange', onVisibility);
-
- // 뷰포트 밖이면 rAF 정지
- io = new IntersectionObserver(
- (entries) => {
- inView = entries[0]?.isIntersecting ?? false;
- if (inView) start();
- else stop();
- },
- { threshold: 0 },
- );
- io.observe(canvas);
-
- // ── 렌더 루프 ──
- const clock = new THREE_NS.Clock();
- loop = () => {
- rafId = 0;
- if (disposed || !renderer || !scene || !camera || !material) return;
-
- const elapsed = clock.getElapsedTime();
- const u = material.uniforms;
- u.uTime.value = elapsed;
-
- // 커서 스무딩 (lerp 0.08)
- mouse.x += (mouseTarget.x - mouse.x) * 0.08;
- mouse.y += (mouseTarget.y - mouse.y) * 0.08;
- (u.uMouse.value as THREE.Vector2).set(mouse.x, mouse.y);
-
- // 스크롤 진행도 0~1 clamp
- const scrollT = Math.min(
- Math.max(window.scrollY / (window.innerHeight || 1), 0),
- 1,
- );
- u.uScroll.value = scrollT;
-
- renderer.render(scene, camera);
- start();
- };
-
- start();
- } catch {
- if (!disposed) setFailed(true);
- }
- })();
-
- // ── cleanup: rAF cancel + 리스너 제거 + dispose ──
- return () => {
- disposed = true;
- stop();
- if (onMouseMove) window.removeEventListener('mousemove', onMouseMove);
- if (onResize) window.removeEventListener('resize', onResize);
- if (onVisibility) document.removeEventListener('visibilitychange', onVisibility);
- io?.disconnect();
- geometry?.dispose();
- material?.dispose();
- renderer?.dispose();
- scene = null;
- camera = null;
- points = null;
- };
- }, [animated, effectiveMode]);
-
- return (
-
- {/* 정적 그래디언트 — 항상 캔버스 아래에 깔림 */}
-
- {animated && (
-
- )}
-
- );
-}
diff --git a/app/components/deepfield/useFieldMode.ts b/app/components/deepfield/useFieldMode.ts
deleted file mode 100644
index 05d07a6..0000000
--- a/app/components/deepfield/useFieldMode.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { decideFieldMode, type FieldMode } from '@/lib/deepfield-mode';
-
-function detectWebGL(): boolean {
- try {
- const canvas = document.createElement('canvas');
- return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
- } catch {
- return false;
- }
-}
-
-/** SSR/첫 페인트는 'static'으로 시작 — 클라이언트에서 승격 (hydration 불일치 방지) */
-export function useFieldMode(): FieldMode {
- const [mode, setMode] = useState('static');
- useEffect(() => {
- setMode(
- decideFieldMode({
- reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
- webglSupported: detectWebGL(),
- hardwareConcurrency: navigator.hardwareConcurrency ?? 0,
- viewportWidth: window.innerWidth,
- }),
- );
- }, []);
- return mode;
-}
diff --git a/lib/__tests__/deepfield-mode.test.ts b/lib/__tests__/deepfield-mode.test.ts
deleted file mode 100644
index 5dc8df5..0000000
--- a/lib/__tests__/deepfield-mode.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { decideFieldMode } from '@/lib/deepfield-mode';
-
-const base = { reducedMotion: false, webglSupported: true, hardwareConcurrency: 8, viewportWidth: 1440 };
-
-describe('decideFieldMode', () => {
- it('데스크톱 + WebGL = full', () => {
- expect(decideFieldMode(base)).toBe('full');
- });
- it('reduced-motion이면 무조건 static', () => {
- expect(decideFieldMode({ ...base, reducedMotion: true })).toBe('static');
- expect(decideFieldMode({ ...base, reducedMotion: true, viewportWidth: 375 })).toBe('static');
- });
- it('WebGL 미지원이면 static', () => {
- expect(decideFieldMode({ ...base, webglSupported: false })).toBe('static');
- });
- it('모바일 뷰포트(<768)는 lite', () => {
- expect(decideFieldMode({ ...base, viewportWidth: 767 })).toBe('lite');
- });
- it('저성능 코어(<4)는 lite', () => {
- expect(decideFieldMode({ ...base, hardwareConcurrency: 2 })).toBe('lite');
- });
- it('hardwareConcurrency 미보고(0/undefined)는 lite로 보수적 판정', () => {
- expect(decideFieldMode({ ...base, hardwareConcurrency: 0 })).toBe('lite');
- });
-});
diff --git a/lib/deepfield-mode.ts b/lib/deepfield-mode.ts
deleted file mode 100644
index 378f9a4..0000000
--- a/lib/deepfield-mode.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-export type FieldMode = 'full' | 'lite' | 'static';
-
-export interface FieldEnv {
- reducedMotion: boolean;
- webglSupported: boolean;
- hardwareConcurrency: number; // 미보고 시 0
- viewportWidth: number;
-}
-
-/** Deep Field 렌더 모드 판정 — 우선순위: 접근성 > 지원 여부 > 성능 */
-export function decideFieldMode(env: FieldEnv): FieldMode {
- if (env.reducedMotion) return 'static';
- if (!env.webglSupported) return 'static';
- if (env.viewportWidth < 768) return 'lite';
- if (!env.hardwareConcurrency || env.hardwareConcurrency < 4) return 'lite';
- return 'full';
-}
diff --git a/package-lock.json b/package-lock.json
index b9b45d7..baf6815 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,15 +26,13 @@
"remark-gfm": "^4.0.0",
"resend": "^6.9.1",
"solarlunar": "^2.0.7",
- "tailwind-merge": "^3.5.0",
- "three": "^0.184.0"
+ "tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@types/three": "^0.184.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
@@ -324,13 +322,6 @@
"node": ">=6.9.0"
}
},
- "node_modules/@dimforge/rapier3d-compat": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
- "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
- "dev": true,
- "license": "Apache-2.0"
- },
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2151,13 +2142,6 @@
"tailwindcss": "4.1.18"
}
},
- "node_modules/@tweenjs/tween.js": {
- "version": "23.1.3",
- "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
- "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2289,41 +2273,12 @@
"@types/react": "^19.2.0"
}
},
- "node_modules/@types/stats.js": {
- "version": "0.17.4",
- "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
- "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/three": {
- "version": "0.184.1",
- "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
- "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@dimforge/rapier3d-compat": "~0.12.0",
- "@tweenjs/tween.js": "~23.1.3",
- "@types/stats.js": "*",
- "@types/webxr": ">=0.5.17",
- "fflate": "~0.8.2",
- "meshoptimizer": "~1.1.1"
- }
- },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
- "node_modules/@types/webxr": {
- "version": "0.5.24",
- "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
- "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -4869,13 +4824,6 @@
"node": "^12.20 || >= 14.13"
}
},
- "node_modules/fflate": {
- "version": "0.8.3",
- "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
- "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -7105,13 +7053,6 @@
"node": ">= 8"
}
},
- "node_modules/meshoptimizer": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
- "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -9603,12 +9544,6 @@
"node": ">=18"
}
},
- "node_modules/three": {
- "version": "0.184.0",
- "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
- "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
- "license": "MIT"
- },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
diff --git a/package.json b/package.json
index 54fc63c..789c088 100644
--- a/package.json
+++ b/package.json
@@ -28,15 +28,13 @@
"remark-gfm": "^4.0.0",
"resend": "^6.9.1",
"solarlunar": "^2.0.7",
- "tailwind-merge": "^3.5.0",
- "three": "^0.184.0"
+ "tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@types/three": "^0.184.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",