はじめに
毎年この時期になると、クリスマスモチーフの表現を技術的にどうアップデートするかを考えます。
単に「ツリーを描いて光らせる」だけなら、正直いくらでも方法はあります。
今回やりたかったのは、
- 3Dモデルを使わずに、コーディングのみで絵を作ること
- MarchingCubesを使って毎回違った形に木を生成すること
- ツリーという形状そのものに光を“沿わせる”こと
- 光が“置かれている”のではなく、“木の葉の形状に沿っている”ようにすること
- 幹(木部)には一切イルミネーションを付けないこと
- Garland(配線)と電球が完全に一致して動くこと
です。
Vue.js を UI / 制御レイヤーに、Three.js を描画エンジンに使い、
MarchingCubes を組み合わせてツリーの形状を形成しています。
完成形がこちらです。
今回の技術的要素は、オーナメントではなく 「光を、形状に沿わせる」ところです。
ツリーは MarchingCubes の暗黙曲面で生成し、その表面上に葉イルミの粒子を“配置する”。さらに Garland は、ツリー表面をレイキャストでトレースして点列を作り、その点列に対して チューブ発光+電球ビルボードを完全一致で追従させる、ということをしています。
光を幹(木部)には付けていないことも重要です。
「葉の領域だけが光る」「ガーランドも幹を避けて巻かれる」という“避け方”をアルゴリズムで担保しています。
ソースコードはこちら
全体設計(Vue を演出コントローラにする)
Vue 側の Props を、foliage(葉面イルミ)と garland(ガーランドイルミ)で分離し、速度・強度・モードまで含めて制御します。UIから触れる値をVueに寄せると、現場の調整がやりやすいメリットもあります。
type Mode = "calm" | "sparkle" | "aurora" | "chase" | "ripple";
const props = defineProps<{
seed: number;
foliage: {
enabled: boolean;
intensity: number;
speed: number;
mode: Mode;
};
garland: {
enabled: boolean;
intensity: number;
speed: number;
mode: Mode;
tube: {
enabled: boolean;
speed: number;
bandWidth: number;
intensity: number;
};
};
}>();
tick 内は “描画” ではなく “演出” のループです。ここで shader uniform を更新し、Vue 側の値がそのまま表現に出るようにしています。
if (lightParticles) {
lightParticles.visible = props.foliage.enabled;
const m = lightParticles.material as THREE.ShaderMaterial;
m.uniforms.uTime.value = t;
m.uniforms.uIntensity.value = props.foliage.intensity;
m.uniforms.uMode.value = modeToInt(props.foliage.mode);
m.uniforms.uSpeed.value = props.foliage.speed;
}
if (garlandLights) {
garlandLights.visible = props.garland.enabled;
const m = garlandLights.material as THREE.ShaderMaterial;
m.uniforms.uTime.value = t / 10;
m.uniforms.uIntensity.value = props.garland.intensity;
m.uniforms.uMode.value = modeToInt(props.garland.mode);
m.uniforms.uSpeed.value = props.garland.speed;
}
if (garlandMesh) {
garlandMesh.visible = props.garland.tube.enabled;
const m = garlandMesh.material as THREE.ShaderMaterial;
m.uniforms.uTime.value = t;
m.uniforms.uIntensity.value = props.garland.tube.intensity;
m.uniforms.uSpeed.value = props.garland.tube.speed;
m.uniforms.uBandWidth.value = props.garland.tube.bandWidth;
}
1. 葉面イルミ:ツリー表面を面積比例でサンプリングし、粒子を表面に描画する
ここが一番「画像処理っぽい」部分だと思います。
MarchingCubes が生成したメッシュは、三角形の集合です。表面に均等に光を散らすには、単純に頂点を拾うのではなく、三角形の面積に比例してサンプルしないと偏ります。
三角形面積 CDF(面積比例サンプリング)
const g = marching.geometry as THREE.BufferGeometry;
g.computeVertexNormals();
const posAttr = g.getAttribute("position") as THREE.BufferAttribute | null;
const nAttr = g.getAttribute("normal") as THREE.BufferAttribute | null;
if (!posAttr || !nAttr) return;
const triCount = Math.floor(posAttr.count / 3);
const cdf = new Float32Array(triCount);
let totalArea = 0;
const a = new THREE.Vector3();
const b = new THREE.Vector3();
const c = new THREE.Vector3();
const ab = new THREE.Vector3();
const ac = new THREE.Vector3();
for (let i = 0; i < triCount; i++) {
a.fromBufferAttribute(posAttr, i * 3 + 0);
b.fromBufferAttribute(posAttr, i * 3 + 1);
c.fromBufferAttribute(posAttr, i * 3 + 2);
ab.subVectors(b, a);
ac.subVectors(c, a);
const area = ab.cross(ac).length() * 0.5;
totalArea += area;
cdf[i] = totalArea;
}
バリセントリックで点と法線を同時に得る
面の上のランダム点は、バリセントリックで作るのが一番安定します。
さらに、法線も同じ重みで補間すれば、「表面の向き」に沿って押し出せます。
const p = new THREE.Vector3();
const n = new THREE.Vector3();
const na = new THREE.Vector3();
const nb = new THREE.Vector3();
const nc = new THREE.Vector3();
const samplePointAndNormalOnTri = (ti: number) => {
a.fromBufferAttribute(posAttr, ti * 3 + 0);
b.fromBufferAttribute(posAttr, ti * 3 + 1);
c.fromBufferAttribute(posAttr, ti * 3 + 2);
na.fromBufferAttribute(nAttr, ti * 3 + 0);
nb.fromBufferAttribute(nAttr, ti * 3 + 1);
nc.fromBufferAttribute(nAttr, ti * 3 + 2);
let u = rnd();
let v = rnd();
if (u + v > 1.0) { u = 1.0 - u; v = 1.0 - v; }
p.copy(a)
.addScaledVector(ab.subVectors(b, a), u)
.addScaledVector(ac.subVectors(c, a), v);
const w = 1.0 - u - v;
n.copy(na)
.multiplyScalar(w)
.addScaledVector(nb, u)
.addScaledVector(nc, v)
.normalize();
};
幹には付けないようにアルゴリズムを組む(trunk mask)
葉面イルミは「ツリー表面」でも、幹に乗ると一気にフェイク感が出るので、「低い位置にある」+「中心に近い」点を除外します。
// --- trunk mask: 低い&中心に近い点は除外(=幹に付けない) ---
const radial = Math.hypot(p.x, p.z);
const isLow = p.y < -0.25; // 木ローカルで下の方
const isNearAxis = radial < 0.16; // 幹の太さ相当
if (isLow && isNearAxis) continue;
表面法線方向に数mm押し出して埋まりを防ぐ
const eps = 0.01; // "数mm"相当(木ローカル空間)
p.addScaledVector(n, eps);
InstancedMesh に流し込む(9,000点を GPU で捌く)
const COUNT = 9000;
const offsets = new Float32Array(COUNT * 3);
// ... filled ループで offsets に格納 ...
mesh.geometry.setAttribute(
"instanceOffset",
new THREE.InstancedBufferAttribute(offsets, 3)
);
// ★重要:marching の子にする(同じローカル空間で扱う)
marching.add(mesh);
ここまでで、ツリーの葉面上に、偏りなく光点を描画できます。
これはパーティクルの表現というより、「メッシュ表面をサンプリングして発光レイヤーを再構成している」ので、処理の発想が画像処理に近いです。
2. Garland:ツリー表面をレイキャストでトレースし、点列→チューブ→電球を完全一致させる
ガーランドを「ただ螺旋に置く」のではなく、ツリー表面に添わせ続けるのが肝です。
外側から同じ高さの中心へ向けてraycastを当てて、表面に当たった点を拾い、さらに 法線方向に押し出して食い込みを潰しています。
const tubeRadius = 0.012;
const pushOut = tubeRadius * 3.0;
const turns = 9.0;
const samples = 320;
const localRaycaster = new THREE.Raycaster();
localRaycaster.near = 0.0;
localRaycaster.far = 100.0;
const pointsWorld: THREE.Vector3[] = [];
const origin = new THREE.Vector3();
const dir = new THREE.Vector3();
const tmp = new THREE.Vector3();
for (let i = 0; i < samples; i++) {
const t = i / (samples - 1);
const y = THREE.MathUtils.lerp(yStart, yEnd, t);
const theta = Math.pow(t, 0.92) * turns * Math.PI * 2; // 上ほどピッチ詰め
origin.set(
centerW.x + Math.cos(theta) * outerR,
y,
centerW.z + Math.sin(theta) * outerR
);
// ★同じ高さの中心へ
const target = new THREE.Vector3(centerW.x, y, centerW.z);
dir.subVectors(target, origin).normalize();
localRaycaster.set(origin, dir);
const hits = localRaycaster.intersectObject(marching, false);
if (!hits.length) continue;
const hit = hits[0];
// 幹付近を避ける(ここでも“木部に寄らない”を保証)
const hitLocal = marching.worldToLocal(hit.point.clone());
const radial = Math.hypot(hitLocal.x, hitLocal.z);
if (radial < 0.16) continue;
// ワールド法線
const nW = hit.face!.normal.clone()
.transformDirection(marching.matrixWorld)
.normalize();
// チューブ半径ぶん外へ(刺さり防止)
tmp.copy(hit.point).addScaledVector(nW, pushOut);
pointsWorld.push(tmp.clone());
}
点列が取れたら、その点列から TubeGeometry を作ります。
今回の意図は「滑らかなスプライン」よりも「食い込みしない確実さ」なので、CurvePath を LineCurve3 で構成しています。
const path = new THREE.CurvePath<THREE.Vector3>();
for (let i = 0; i < pointsWorld.length - 1; i++) {
path.add(new THREE.LineCurve3(pointsWorld[i], pointsWorld[i + 1]));
}
const tubeGeom = new THREE.TubeGeometry(path, 1200, tubeRadius, 16, false);
const tubeMat = createGarlandShaderMaterial();
garlandMesh = new THREE.Mesh(tubeGeom, tubeMat);
scene.add(garlandMesh);
そして重要なのが、電球(garlandLights)を 同じ点列から等間隔サンプルして作っていることです。
こうすることで、チューブと電球がズレないようにできます。
const bulbs = sampleEquidistantPointsOnPolyline(pointsWorld, 0.11);
// offsets に詰めて InstancedMesh(Plane billboard)へ
3. チューブ発光:band() で「走る光」を UV 空間に定義する(テクスチャ無し)
ガーランドのチューブは、テクスチャ無しで 「走る光」を作っています。
band() 関数内で、0..1 の UV を wrap しながら、距離を指数減衰に入れて帯を作っています。
float band(float u, float head, float w){
float d = abs(u - head);
d = min(d, 1.0 - d);
return exp(-d * (1.0 / max(w, 1e-4)) * 2.2);
}
void main() {
float u = vUv.x; // 長手方向 0..1
float head = fract(uTime * uSpeed);
// 走る光帯(+尻尾)
float b1 = band(u, head, uBandWidth);
float b2 = 0.35 * band(u, fract(head - 0.06), uBandWidth * 2.2);
// view-dependent spec-ish
vec3 V = normalize(vV);
float ndv = clamp(dot(normalize(vN), V), 0.0, 1.0);
float spec = pow(1.0 - ndv, 5.0);
float glow = (b1 + b2) * (0.65 + 0.35 * spec) * uIntensity;
vec3 col = uBaseColor + uGlowColor * glow;
gl_FragColor = vec4(col, 1.0);
}
「帯の先頭 head」「帯の幅 w」「wrap 対応」「尻尾の減衰」
これだけで、映像表現として“走る光”が成立します。テクスチャよりこの方式のほうが破綻しにくく、演出パラメータで作り込めます。
4. マウスカーソルでイルミネーション描画(ripple)
電球側のシェーダは、uPointer と uPointerTime を受け取り、距離と経過時間でリングを出します。
これも点列に沿っている点が工夫ポイントです。
float dt = t - uPointerTime;
float dist = distance(vWorldPos, uPointer);
float wave = dist - dt * uRippleSpeed;
float ring = exp(-abs(wave) * 10.0);
float fade = exp(-dt * 0.9);
glow = max(ring * fade, 0.18 + 0.12 * sin(t * 1.2 + h * 6.28318));
ポインタ側は、ツリーの暗黙メッシュに raycast して起点を取ります。
raycaster.setFromCamera(pointerNdc, camera!);
const hits = raycaster.intersectObject(marching!, false);
if (hits.length > 0) {
pointerWorld.copy(hits[0].point);
if (garlandLights) {
const gm = garlandLights.material as THREE.ShaderMaterial;
gm.uniforms.uPointer.value.copy(pointerWorld);
gm.uniforms.uPointerTime.value = clock.getElapsedTime();
}
}
まとめ
-
葉面イルミネーションは、メッシュ表面を面積比例でサンプリングして InstancedMesh に落とす
- trunk mask により 木部(幹)に乗らないことを保証
-
Garland は、螺旋の理想形を“置く”のではなく、レイキャストで表面をトレースして点列化
- チューブと電球を 同一点列起点で完全一致させる
-
発光はテクスチャに頼らず、UV と時間で定義した band() で走らせる
- ripple でインタラクションまで含めて “作品” にする
-
Vue は UI ではなく、演出のパラメータ制御層として機能させる
3Dモデルを使わなくても、Web のコーディングだけでこれくらいのクリスマスツリーを描くことができました。
皆さん良いお年をお迎えください。
