From 85be0007044c6b3bcaaf3df0e63595d4c55d3736 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Thu, 20 Aug 2026 12:17:02 +0900 Subject: [PATCH] =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=83=81=EB=8B=A8=20?= =?UTF-8?q?=EA=B2=80=EC=9D=80=20=EB=9D=A0=20=EC=A0=9C=EA=B1=B0=20=C2=B7=20?= =?UTF-8?q?=ED=8C=A8=EB=9F=B4=EB=9E=99=EC=8A=A4=20=C2=B7=20=ED=9E=88?= =?UTF-8?q?=EC=96=B4=EB=A1=9C=203D=20=EC=9C=A0=EB=A6=AC=20=EC=94=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 헤더가 문서 흐름에 있어서 그 위 공간이 히어로 광원 밖으로 남았다. 화면에서 검은 띠로 보였다. 헤더를 띄우고 히어로가 최상단부터 차오르게 했다. 패럴랙스는 scroll-driven animation 으로만 만든다. scroll 리스너를 쓰지 않고 transform 만 움직이므로 미지원 브라우저에서는 제자리에 그대로 있다. 히어로 3D 는 배경을 렌더 타겟에 굽고 유리 판이 그 텍스처를 왜곡 샘플링한다. transmission 재질이 매 프레임 씬을 재렌더하는 비용을 피한 구조다. --- apps/site/src/components/Hero.astro | 51 +---- apps/site/src/components/Showcase.astro | 2 +- apps/site/src/layouts/Base.astro | 5 +- apps/site/src/lib/hero-scene.ts | 274 ++++++++++++++++++++++++ apps/site/src/styles/base.css | 22 ++ 5 files changed, 306 insertions(+), 48 deletions(-) create mode 100644 apps/site/src/lib/hero-scene.ts diff --git a/apps/site/src/components/Hero.astro b/apps/site/src/components/Hero.astro index 695096c..3dcb6cf 100644 --- a/apps/site/src/components/Hero.astro +++ b/apps/site/src/components/Hero.astro @@ -38,7 +38,7 @@ const { c, ui } = Astro.props; {/* 가짜 목업이 아니라 이 스킬로 실제로 만든 페이지다 */} -
+
diff --git a/apps/site/src/components/Showcase.astro b/apps/site/src/components/Showcase.astro index 8a3457f..d989f86 100644 --- a/apps/site/src/components/Showcase.astro +++ b/apps/site/src/components/Showcase.astro @@ -17,7 +17,7 @@ const shots = [shotCoffee, shotSaas];
{ c.items.map((item, i) => ( -
+
{item.alt} { + if (!entries.some((e) => e.isIntersecting)) return; + io.disconnect(); + void boot(opts); + }, + { rootMargin: "0px" }, + ); + io.observe(canvas); +} + +async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise { + let THREE: typeof import("three"); + try { + THREE = await import("three"); + } catch { + return; + } + + let renderer: import("three").WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true }); + } catch { + return; + } + + const css = getComputedStyle(document.documentElement); + const color = (v: string, fb: string) => + new THREE.Color((css.getPropertyValue(v).trim() || fb) as string); + const accent = color(accentVar, "#4FD6C4"); + const glow = color(glowVar, "#6E7BFF"); + + // 저사양이면 판을 줄인다. 코어 수는 거친 지표지만 라이브러리를 더 받지 않는다 + const lean = (navigator.hardwareConcurrency ?? 4) <= 4; + const PANE_COUNT = lean ? 3 : 6; + + const dpr = Math.min(devicePixelRatio, 1.5); + renderer.setPixelRatio(dpr); + + /* ---- 1) 배경을 렌더 타겟에 그린다 ------------------------- */ + const rtScene = new THREE.Scene(); + const rtCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + const rt = new THREE.WebGLRenderTarget(1, 1, { + magFilter: THREE.LinearFilter, + minFilter: THREE.LinearFilter, + }); + + const bgUniforms = { + uTime: { value: 0 }, + uAspect: { value: 1 }, + uPointer: { value: new THREE.Vector2(0.5, 0.5) }, + uA: { value: accent }, + uB: { value: glow }, + }; + + rtScene.add( + new THREE.Mesh( + new THREE.PlaneGeometry(2, 2), + new THREE.ShaderMaterial({ + uniforms: bgUniforms, + vertexShader: ` + varying vec2 vUv; + void main() { vUv = uv; gl_Position = vec4(position, 1.0); } + `, + fragmentShader: ` + precision mediump float; + varying vec2 vUv; + uniform float uTime; uniform float uAspect; + uniform vec2 uPointer; uniform vec3 uA; uniform vec3 uB; + ${NOISE} + void main() { + vec2 p = vUv; p.x *= uAspect; + float t = uTime * 0.045; + vec2 m = uPointer; m.x *= uAspect; + + float f1 = fbm(p * 1.8 + vec2(t, -t * 0.6)); + float f2 = fbm(p * 2.3 - vec2(t * 0.8, t * 1.1) + f1 * 0.55); + + vec2 c1 = mix(vec2(0.30 * uAspect, 0.44), m, 0.20); + vec2 c2 = vec2(0.82 * uAspect, 0.28); + + float a = smoothstep(0.72, 0.08, distance(p, c1) + f1 * 0.36); + float b = smoothstep(0.66, 0.06, distance(p, c2) + f2 * 0.32); + + vec3 col = uA * a + uB * b * 0.95; + float alpha = clamp(a * 1.2 + b, 0.0, 1.0) * 0.9; + gl_FragColor = vec4(col, alpha); + } + `, + transparent: true, + }), + ), + ); + + /* ---- 2) 유리 판. 뒤 텍스처를 밀어서 샘플링한다 ------------- */ + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 60); + camera.position.set(0, 0, 9); + + const paneMat = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + uniforms: { + uScene: { value: rt.texture }, + uTime: { value: 0 }, + uEdge: { value: new THREE.Color("#ffffff") }, + }, + vertexShader: ` + varying vec2 vUv; + varying vec3 vNormalW; + varying vec3 vViewDir; + void main() { + vUv = uv; + vec4 world = modelMatrix * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * normal); + vViewDir = normalize(cameraPosition - world.xyz); + gl_Position = projectionMatrix * viewMatrix * world; + } + `, + fragmentShader: ` + precision mediump float; + varying vec2 vUv; + varying vec3 vNormalW; + varying vec3 vViewDir; + uniform sampler2D uScene; + uniform float uTime; + uniform vec3 uEdge; + ${NOISE} + + void main() { + // 화면 좌표를 굴절시켜 뒤 텍스처를 샘플링한다 + vec2 screen = gl_FragCoord.xy / vec2(textureSize(uScene, 0)); + vec2 flow = vec2( + fbm(vUv * 2.4 + uTime * 0.05), + fbm(vUv * 2.4 - uTime * 0.04 + 3.1) + ) - 0.5; + vec3 back = texture2D(uScene, screen + flow * 0.07 + vNormalW.xy * 0.035).rgb; + + // 가장자리에서 빛이 서는 프레넬 + float fres = pow(1.0 - abs(dot(normalize(vNormalW), normalize(vViewDir))), 2.6); + + vec3 col = back * 1.06 + uEdge * fres * 0.5; + float alpha = 0.20 + fres * 0.55; + gl_FragColor = vec4(col, alpha); + } + `, + }); + + const panes: import("three").Mesh[] = []; + const geo = new THREE.PlaneGeometry(3.1, 4.3, 1, 1); + for (let i = 0; i < PANE_COUNT; i++) { + const m = new THREE.Mesh(geo, paneMat); + const a = (i / PANE_COUNT) * Math.PI * 2; + m.position.set(Math.cos(a) * 3.6, Math.sin(a * 1.7) * 1.5, -i * 0.9); + m.rotation.set(0.1 + i * 0.05, a * 0.5, a * 0.22); + m.userData["seed"] = a; + scene.add(m); + panes.push(m); + } + + /* ---- 3) 크기·포인터·루프 --------------------------------- */ + const pointer = new THREE.Vector2(0.5, 0.5); + const pointerTarget = new THREE.Vector2(0.5, 0.5); + + const fit = () => { + const r = canvas.getBoundingClientRect(); + if (!r.width || !r.height) return; + renderer.setSize(r.width, r.height, false); + rt.setSize(Math.round(r.width * dpr * 0.6), Math.round(r.height * dpr * 0.6)); + camera.aspect = r.width / r.height; + camera.updateProjectionMatrix(); + bgUniforms.uAspect.value = r.width / r.height; + }; + fit(); + addEventListener("resize", fit, { passive: true }); + + addEventListener( + "pointermove", + (e) => { + const r = canvas.getBoundingClientRect(); + pointerTarget.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height); + }, + { passive: true }, + ); + + let tabVisible = true; + document.addEventListener("visibilitychange", () => { + tabVisible = !document.hidden; + }); + let onScreen = true; + new IntersectionObserver((es) => { + onScreen = es.some((e) => e.isIntersecting); + }).observe(canvas); + + canvas.classList.add("is-live"); + + renderer.setAnimationLoop((ms) => { + if (!tabVisible || !onScreen) return; + const t = ms * 0.001; + + pointer.lerp(pointerTarget, 0.04); + bgUniforms.uTime.value = t; + bgUniforms.uPointer.value.copy(pointer); + paneMat.uniforms["uTime"]!.value = t; + + for (const m of panes) { + const seed = m.userData["seed"] as number; + m.rotation.y += 0.0016; + m.rotation.z = Math.sin(t * 0.18 + seed) * 0.24; + m.position.y = Math.sin(t * 0.22 + seed * 1.7) * 1.6; + // 포인터가 판을 아주 조금 기울인다 + m.rotation.x = 0.1 + (pointer.y - 0.5) * 0.24; + } + camera.position.x = (pointer.x - 0.5) * 1.5; + camera.lookAt(0, 0, 0); + + // 배경을 먼저 타겟에 굽고, 그 텍스처를 유리가 읽는다 + renderer.setRenderTarget(rt); + renderer.clear(); + renderer.render(rtScene, rtCam); + renderer.setRenderTarget(null); + + renderer.clear(); + renderer.render(rtScene, rtCam); + renderer.render(scene, camera); + }); +} diff --git a/apps/site/src/styles/base.css b/apps/site/src/styles/base.css index 1cbe570..d20f20c 100644 --- a/apps/site/src/styles/base.css +++ b/apps/site/src/styles/base.css @@ -271,6 +271,28 @@ a:hover { color: var(--accent); } .reveal.is-in { transform: none; } } +/* ---- 패럴랙스 ----------------------------------------------- + scroll-driven animation 으로만 만든다. scroll 리스너는 쓰지 않는다. + 움직이는 것은 transform 뿐이라 지원되지 않는 브라우저에서는 + 요소가 제자리에 그대로 있다. 콘텐츠가 사라질 수 없다. */ +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: view()) { + .par-slow, + .par-fast { + animation: par linear both; + animation-timeline: view(); + animation-range: cover 0% cover 100%; + will-change: transform; + } + .par-slow { --par-from: 26px; --par-to: -26px; } + .par-fast { --par-from: 58px; --par-to: -58px; } + @keyframes par { + from { transform: translate3d(0, var(--par-from), 0); } + to { transform: translate3d(0, var(--par-to), 0); } + } + } +} + /* ---- 유틸 -------------------------------------------------- */ .visually-hidden { position: absolute;