헤더 상단 검은 띠 제거 · 패럴랙스 · 히어로 3D 유리 씬
헤더가 문서 흐름에 있어서 그 위 공간이 히어로 광원 밖으로 남았다. 화면에서 검은 띠로 보였다. 헤더를 띄우고 히어로가 최상단부터 차오르게 했다. 패럴랙스는 scroll-driven animation 으로만 만든다. scroll 리스너를 쓰지 않고 transform 만 움직이므로 미지원 브라우저에서는 제자리에 그대로 있다. 히어로 3D 는 배경을 렌더 타겟에 굽고 유리 판이 그 텍스처를 왜곡 샘플링한다. transmission 재질이 매 프레임 씬을 재렌더하는 비용을 피한 구조다.
This commit is contained in:
parent
ed9463c3c2
commit
85be000704
5 changed files with 306 additions and 48 deletions
|
|
@ -38,7 +38,7 @@ const { c, ui } = Astro.props;
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 가짜 목업이 아니라 이 스킬로 실제로 만든 페이지다 */}
|
{/* 가짜 목업이 아니라 이 스킬로 실제로 만든 페이지다 */}
|
||||||
<figure class="hero-proof">
|
<figure class="hero-proof par-slow">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="core">
|
<div class="core">
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -62,7 +62,7 @@ const { c, ui } = Astro.props;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: subgrid;
|
grid-template-columns: subgrid;
|
||||||
padding-block: var(--space-7) var(--space-8);
|
padding-block: calc(var(--space-8) + var(--space-3)) var(--space-8);
|
||||||
min-height: min(92dvh, 60rem);
|
min-height: min(92dvh, 60rem);
|
||||||
align-content: center;
|
align-content: center;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
|
@ -156,51 +156,12 @@ const { c, ui } = Astro.props;
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 배경 셰이더. 광원 두 개가 서로 다른 속도로 흐르고 포인터를 느리게 따라온다.
|
// 유리 판이 뒤의 빛을 실제로 굴절시킨다. 렌더 타겟 한 장으로 만든 굴절이라
|
||||||
// three 는 초기 번들에 없다. LCP 는 h1 이 가져가고 캔버스는 배경 레이어다.
|
// transmission 재질보다 훨씬 싸다. 자세한 이유는 lib/hero-scene.ts 주석에.
|
||||||
import { mountShaderPlane, GLSL_NOISE } from "../lib/shader-plane";
|
import { mountHeroScene } from "../lib/hero-scene";
|
||||||
|
|
||||||
const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null;
|
const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null;
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
mountShaderPlane({
|
mountHeroScene({ canvas, accentVar: "--accent", glowVar: "--glow-2" });
|
||||||
canvas,
|
|
||||||
pointer: true,
|
|
||||||
speed: 1,
|
|
||||||
rootMargin: "0px",
|
|
||||||
colors: { uA: "--accent", uB: "--glow-2" },
|
|
||||||
fragment: `
|
|
||||||
precision mediump float;
|
|
||||||
varying vec2 vUv;
|
|
||||||
uniform float uTime;
|
|
||||||
uniform float uAspect;
|
|
||||||
uniform vec2 uPointer;
|
|
||||||
uniform vec3 uA;
|
|
||||||
uniform vec3 uB;
|
|
||||||
${GLSL_NOISE}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vec2 p = vUv;
|
|
||||||
p.x *= uAspect;
|
|
||||||
float t = uTime * 0.045;
|
|
||||||
|
|
||||||
vec2 mouse = uPointer;
|
|
||||||
mouse.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.42), mouse, 0.22);
|
|
||||||
vec2 c2 = vec2(0.80 * uAspect, 0.30);
|
|
||||||
|
|
||||||
float a = smoothstep(0.70, 0.10, distance(p, c1) + f1 * 0.36);
|
|
||||||
float b = smoothstep(0.64, 0.08, distance(p, c2) + f2 * 0.32);
|
|
||||||
|
|
||||||
vec3 col = uA * a + uB * b * 0.9;
|
|
||||||
float alpha = clamp(a * 1.15 + b, 0.0, 1.0) * 0.85;
|
|
||||||
gl_FragColor = vec4(col, alpha);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const shots = [shotCoffee, shotSaas];
|
||||||
<div class="full cascade">
|
<div class="full cascade">
|
||||||
{
|
{
|
||||||
c.items.map((item, i) => (
|
c.items.map((item, i) => (
|
||||||
<figure class="work reveal" style={`--reveal-delay:${i * 90}ms`}>
|
<figure class:list={["work", "reveal", i === 0 ? "par-slow" : "par-fast"]} style={`--reveal-delay:${i * 90}ms`}>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="core">
|
<div class="core">
|
||||||
<Image src={shots[i]!} alt={item.alt} widths={[480, 860, 1200]}
|
<Image src={shots[i]!} alt={item.alt} widths={[480, 860, 1200]}
|
||||||
|
|
|
||||||
|
|
@ -144,11 +144,12 @@ const FAVICON =
|
||||||
/* 상단에 붙은 풀블리드 바 대신 떠 있는 헤더 */
|
/* 상단에 붙은 풀블리드 바 대신 떠 있는 헤더 */
|
||||||
/* 상단에 붙은 바가 아니라 떠 있는 유리 알약 */
|
/* 상단에 붙은 바가 아니라 떠 있는 유리 알약 */
|
||||||
.site-header {
|
.site-header {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
top: var(--space-3);
|
top: var(--space-3);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
padding-inline: var(--space-4);
|
padding-inline: var(--space-4);
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
}
|
||||||
.header-inner {
|
.header-inner {
|
||||||
max-width: 1180px;
|
max-width: 1180px;
|
||||||
|
|
|
||||||
274
apps/site/src/lib/hero-scene.ts
Normal file
274
apps/site/src/lib/hero-scene.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
/**
|
||||||
|
* 히어로 3D 씬.
|
||||||
|
*
|
||||||
|
* 유리 판 몇 장이 공간에 떠 있고, 그 뒤의 빛을 실제로 굴절시킨다.
|
||||||
|
*
|
||||||
|
* 구현 방식과 그 이유:
|
||||||
|
* 배경 그라디언트를 렌더 타겟에 한 번 그리고, 유리 판이 그 텍스처를
|
||||||
|
* UV 를 밀어서 샘플링한다. MeshPhysicalMaterial 의 transmission 은
|
||||||
|
* 매 프레임 씬을 다시 렌더하므로 판 한 장만 있어도 비싸다.
|
||||||
|
* references/three.md 가 그 비용을 경고한다. 렌더 타겟 한 장이면
|
||||||
|
* 같은 인상을 훨씬 싸게 얻는다.
|
||||||
|
*
|
||||||
|
* 예산 규칙은 그대로 지킨다:
|
||||||
|
* - three 는 초기 번들에 없다. 화면에 가까워질 때만 받는다
|
||||||
|
* - DPR 클램프 1.5
|
||||||
|
* - 화면 밖이거나 탭이 숨으면 그리지 않는다
|
||||||
|
* - 저사양이면 판 수를 줄인다
|
||||||
|
* - WebGL 이 없거나 모션을 줄이는 설정이면 아예 시작하지 않는다.
|
||||||
|
* 그때 보이는 CSS 그라디언트가 폴백이 아니라 결과물이다
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HeroSceneOptions {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
/** CSS 커스텀 프로퍼티 이름 */
|
||||||
|
accentVar: string;
|
||||||
|
glowVar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOISE = `
|
||||||
|
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||||
|
float noise(vec2 p) {
|
||||||
|
vec2 i = floor(p), f = fract(p);
|
||||||
|
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||||
|
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
|
||||||
|
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
|
||||||
|
}
|
||||||
|
float fbm(vec2 p) {
|
||||||
|
float v = 0.0, a = 0.5;
|
||||||
|
for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function mountHeroScene(opts: HeroSceneOptions): void {
|
||||||
|
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
const { canvas } = opts;
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -271,6 +271,28 @@ a:hover { color: var(--accent); }
|
||||||
.reveal.is-in { transform: none; }
|
.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 {
|
.visually-hidden {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue