import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05060c);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 5.2);
camera.lookAt(0, 0, 0);
function sampleText(text) {
// ctx.letterSpacing 지원 여부에 기대지 않고, 글자마다 직접 x를 옮겨 그려서
// 확실한 틈을 만든다 — 글자 사이가 붙으면 파티클이 뭉쳐 한 덩어리로만 보인다.
const H = 110;
const GAP = 18;
const probe = document.createElement('canvas').getContext('2d');
probe.font = 'bold 46px sans-serif';
let totalW = 0;
const widths = [];
for (const ch of text) {
const w = probe.measureText(ch).width;
widths.push(w);
totalW += w + GAP;
}
totalW -= GAP;
const W = Math.ceil(totalW) + 40;
const c = document.createElement('canvas');
c.width = W; c.height = H;
const ctx = c.getContext('2d');
// 배경을 fillRect로 채우면 불투명 검정도 alpha=255가 돼서 "글자만" 걸러낼 수 없다.
// 캔버스를 투명한 채로 두고 흰 글자만 그려야 alpha가 진짜 글자 모양이 된다.
ctx.fillStyle = '#fff';
ctx.font = 'bold 46px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
let cx = (W - totalW) / 2;
for (let i = 0; i < text.length; i++) {
ctx.fillText(text[i], cx, H / 2 + 4);
cx += widths[i] + GAP;
}
const data = ctx.getImageData(0, 0, W, H).data;
const pts = [];
const STEP = 2;
for (let y = 0; y < H; y += STEP) {
for (let x = 0; x < W; x += STEP) {
const idx = (y * W + x) * 4;
if (data[idx + 3] > 128) pts.push([(x - W / 2) * 0.02, -(y - H / 2) * 0.02]);
}
}
return pts;
}
const targets = sampleText('WORD');
const COUNT = Math.max(1, targets.length);
const pos = new Float32Array(COUNT * 3);
const target = new Float32Array(COUNT * 3);
const scatter = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
const pt = targets[i] || [0, 0];
target[i * 3] = pt[0];
target[i * 3 + 1] = pt[1];
target[i * 3 + 2] = 0;
const r = 2.4 + Math.random() * 1.6;
const th = Math.random() * Math.PI * 2;
const ph = Math.acos(2 * Math.random() - 1);
scatter[i * 3] = r * Math.sin(ph) * Math.cos(th);
scatter[i * 3 + 1] = r * Math.sin(ph) * Math.sin(th);
scatter[i * 3 + 2] = r * Math.cos(ph);
pos[i * 3] = scatter[i * 3]; pos[i * 3 + 1] = scatter[i * 3 + 1]; pos[i * 3 + 2] = scatter[i * 3 + 2];
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({ size: 0.05, color: 0x6fe8ff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false });
const points = new THREE.Points(geo, mat);
scene.add(points);
function resize() { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); }
addEventListener('resize', resize); resize();
renderer.setAnimationLoop((t) => {
const time = t * 0.001;
// 사인파를 그대로 쓰면 주기의 절반을 "어중간하게 섞인" 상태로 보내 글자가 흐릿해 보인다.
// 지수를 0.3으로 낮춰 1(글자 완성) 쪽에 오래 머물고 0(흩어짐) 쪽만 짧게 스치게 한다.
const raw = (Math.sin(time * 0.5) + 1) / 2;
const phase = Math.pow(raw, 0.3);
const arr = geo.attributes.position.array;
for (let i = 0; i < COUNT; i++) {
const ix = i * 3;
arr[ix] = THREE.MathUtils.lerp(scatter[ix], target[ix], phase);
arr[ix + 1] = THREE.MathUtils.lerp(scatter[ix + 1], target[ix + 1], phase);
arr[ix + 2] = THREE.MathUtils.lerp(scatter[ix + 2], target[ix + 2], phase);
}
geo.attributes.position.needsUpdate = true;
points.rotation.y = Math.sin(time * 0.15) * 0.25;
renderer.render(scene, camera);
});