7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Three.jsでクリスマスツリーを描こう!2025(MarchingCubes × Instancing × Shader × Vue.js 編)

7
Last updated at Posted at 2025-12-18

はじめに

毎年この時期になると、クリスマスモチーフの表現を技術的にどうアップデートするかを考えます。
単に「ツリーを描いて光らせる」だけなら、正直いくらでも方法はあります。

今回やりたかったのは、

  • 3Dモデルを使わずに、コーディングのみで絵を作ること
  • MarchingCubesを使って毎回違った形に木を生成すること
  • ツリーという形状そのものに光を“沿わせる”こと
  • 光が“置かれている”のではなく、“木の葉の形状に沿っている”ようにすること
  • 幹(木部)には一切イルミネーションを付けないこと
  • Garland(配線)と電球が完全に一致して動くこと

です。

Vue.js を UI / 制御レイヤーに、Three.js を描画エンジンに使い、
MarchingCubes を組み合わせてツリーの形状を形成しています。

完成形がこちらです。

tree.gif

今回の技術的要素は、オーナメントではなく 「光を、形状に沿わせる」ところです。
ツリーは 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)

電球側のシェーダは、uPointeruPointerTime を受け取り、距離と経過時間でリングを出します。
これも点列に沿っている点が工夫ポイントです。

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 のコーディングだけでこれくらいのクリスマスツリーを描くことができました。
皆さん良いお年をお迎えください。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?