헤더 배경 수정 + three.js 를 두 곳으로

헤더에 배경이 왜 있냐는 지적에서 시작했다.
site-header 자체는 배경이 없었고, 안쪽 유리가 불투명 판으로
폴백된 것이었다. prefers-reduced-transparency 가 참이면
(윈도우 투명 효과 끄기 등) --surface-raised 단색이 깔린다.

투명도를 줄여달라는 요청은 "불투명한 판을 깔아라"가 아니다.
배경에 가까운 톤으로 낮추고 형태는 테두리와 그림자가 유지한다.
떠 있는 헤더는 더 가깝게 맞췄다.

three.js
- 공용 부트스트랩을 src/lib/shader-plane.ts 로 추출했다.
  references/three.md 가 권하는 구조를 그대로 구현한 것이다.
  지연 로드 · DPR 클램프 1.5 · 화면 밖이면 정지 · 실패 시 CSS 폴백
- 히어로: 광원 두 개가 흐르고 하나가 포인터를 느리게 따라온다
- 재료: 좌표를 노이즈로 밀어 유리 너머의 결을 만든다
- 두 셰이더가 같은 three 청크를 공유한다. 네트워크 요청 1건

--glow-2 를 토큰으로 승격해 셰이더의 하드코딩 색을 없앴다.
This commit is contained in:
Yun Chan 2026-08-20 11:52:45 +09:00
parent be343cdab5
commit ed9463c3c2
5 changed files with 247 additions and 114 deletions

View file

@ -0,0 +1,150 @@
/**
* 셰이더 플레인 부트스트랩.
*
* 스킬의 references/three.md 가 권하는 그 구조다.
* - three 는 초기 번들에 없다. 화면에 가까워질 때만 받는다
* - WebGL 이 없거나 모션을 줄이는 설정이면 아예 받지 않는다
* - DPR 를 클램프한다. 3 을 그대로 쓰면 픽셀이 네 배가 된다
* - 화면 밖이거나 탭이 숨으면 그리지 않는다
* - 실패하면 조용히 물러난다. CSS 폴백이 그대로 결과물이 된다
*
* 무거운 씬을 임포트하지 않고 평면 하나에 프래그먼트 셰이더만 얹는다.
*/
export interface ShaderPlaneOptions {
canvas: HTMLCanvasElement;
/** GLSL 프래그먼트 셰이더. vUv, uTime, uAspect, uPointer 를 받는다 */
fragment: string;
/** CSS 커스텀 프로퍼티에서 읽어 vec3 로 넘길 색 */
colors?: Record<string, string>;
/** 포인터를 따라가게 할지. 히어로처럼 큰 면적에만 쓴다 */
pointer?: boolean;
/** 시간 진행 속도 배수 */
speed?: number;
/** 미리 받아둘 거리 */
rootMargin?: string;
}
const VERTEX = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
/** 두 셰이더가 공유하는 값 노이즈. 라이브러리를 끌어오지 않는다 */
export const GLSL_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 mountShaderPlane(opts: ShaderPlaneOptions): void {
const { canvas } = opts;
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const io = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
io.disconnect();
void boot(opts);
},
{ rootMargin: opts.rootMargin ?? "300px 0px" },
);
io.observe(canvas);
}
async function boot(opts: ShaderPlaneOptions): Promise<void> {
const { canvas, fragment, colors = {}, pointer = false, speed = 1 } = opts;
let THREE: typeof import("three");
try {
THREE = await import("three");
} catch {
return; // CSS 폴백이 그대로 결과물이 된다
}
let renderer: import("three").WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true });
} catch {
return; // WebGL 이 없는 환경
}
const css = getComputedStyle(document.documentElement);
const uniforms: Record<string, { value: unknown }> = {
uTime: { value: 0 },
uAspect: { value: 1 },
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
};
for (const [name, cssVar] of Object.entries(colors)) {
const raw = css.getPropertyValue(cssVar).trim();
uniforms[name] = { value: new THREE.Color(raw || "#4FD6C4") };
}
const material = new THREE.ShaderMaterial({
transparent: true,
uniforms,
vertexShader: VERTEX,
fragmentShader: fragment,
});
const scene = new THREE.Scene();
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const fit = () => {
const r = canvas.getBoundingClientRect();
if (!r.width || !r.height) return;
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
renderer.setSize(r.width, r.height, false);
(uniforms["uAspect"] as { value: number }).value = r.width / r.height;
};
fit();
addEventListener("resize", fit, { passive: true });
if (pointer) {
const target = new THREE.Vector2(0.5, 0.5);
addEventListener(
"pointermove",
(e) => {
const r = canvas.getBoundingClientRect();
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
},
{ passive: true },
);
// 매 프레임 목표값으로 조금씩 따라간다. 즉시 반응하면 값싸 보인다
material.userData["follow"] = () => {
const p = uniforms["uPointer"] as { value: import("three").Vector2 };
p.value.lerp(target, 0.045);
};
}
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;
(material.userData["follow"] as (() => void) | undefined)?.();
(uniforms["uTime"] as { value: number }).value = (ms * 0.001) * speed;
renderer.render(scene, camera);
});
}