0
0

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とGLSLで6つの惑星を作り分けた話

0
Posted at

コードは読みやすさのため、一部を簡略化しています。

GitHubの使用言語やコントリビューションから、ユーザーごとに異なる3D惑星を生成する GitHub Planet を開発しています。

最初の実装は単純でした。

  • JavaScriptなら黄色
  • TypeScriptなら青
  • Rustなら茶色
  • Goなら水色

ところが、実際に並べてみると、すべてが**「色違いの同じ惑星」**に見えました。

そこで、言語の特徴を色ではなく、惑星上で起きる物理現象として表現することにしました。

  • CSSは、流れ続ける色彩
  • C++は、中心から伸びるプラズマ放電
  • Goは、表面と大気を駆け抜ける風
  • TypeScriptは、型で守られた多層の外殻
  • JavaScriptは、予測不能に反応する黄金の地表
  • Rustは、酸化した砂漠と砂嵐

この記事では、同じ球体と地形テクスチャから、Three.jsとGLSLを使って6種類の惑星を作り分けた設計と実装を紹介します。

完成した6つの言語惑星

CSS C++
CSS planet C++ planet
Go TypeScript
Go planet TypeScript planet
JavaScript Rust
JavaScript planet Rust planet

同じ火星テクスチャと同じ SphereGeometry を土台にしています。

違うのは、各言語をどう解釈し、どの現象へ変換するかです。

「言語らしさ」を物理現象へ翻訳する

最初に、各言語の印象をそのままシェーダーへ落とし込むのではなく、次の3段階で整理しました。

  1. 言語から連想する性質を言葉にする
  2. その性質を視覚的な現象へ置き換える
  3. Three.jsで実装できる要素へ分解する
言語 解釈した性質 惑星上の現象 主な実装
CSS 表現、変化、流れ 一方向へ流れる色彩 HSV変換、方向ベクトル、時間uniform
C++ 高速、低レベル、強いエネルギー プラズマ放電 ノイズ、フィラメント、分岐、emissive
Go 軽快、並行性、速度 表面と大気を走る風 風の軸、大気シェル、活動量による速度変化
TypeScript 型、安全性、保護 多層の防御殻 複数球体、透明度、加算合成
JavaScript 柔軟、動的、予測不能 複数領域の反応と分岐 位相差、パルス、明滅
Rust 堅牢、低レベル、乾いた質感 酸化した砂漠と砂嵐 地形再着色、12,000粒子、砂塵シェル

ここで大事にしたのは、言語の優劣を表すのではなく、触ったときに感じる性格を視覚表現へ翻訳することでした。

全言語を1つのシェーダーへ詰め込まない

初期段階では、惑星を生成する1つのファイルに条件分岐を増やそうとしていました。

しかし、言語ごとに必要なものがまったく違います。

  • CSS:表面の色を流す
  • Go:表面に加えて大気が必要
  • TypeScript:半径の異なる外殻を複数追加する
  • Rust:表面に加えて大量のパーティクルが必要

1つの巨大なシェーダーへまとめると、uniformも分岐も増え、変更の影響範囲が分からなくなります。

そこで、言語ごとに次の責務を持つモジュールへ分けました。

export function isCssPlanet(data) {
  return data?.mainLanguage?.trim().toLowerCase() === "css";
}

export function createCssPlanetFlowMaterial(THREE, texture) {
  // 言語専用のMaterialを生成する
}

export function updateCssPlanetFlow(material, nowMilliseconds) {
  // 毎フレーム更新する
}

読み込み側は、言語を判定して適切なMaterialを選びます。

const geometry = new THREE.SphereGeometry(4, 32, 32);
let material;

if (isCssPlanet(data)) {
  material = createCssPlanetFlowMaterial(THREE, texture);
} else if (isCppPlanet(data)) {
  material = createCppPlanetLightningMaterial(
    THREE,
    texture,
    data.planetColor
  );
} else if (isGoPlanet(data)) {
  material = createGoPlanetWindMaterial(THREE, texture);
} else if (isTypeScriptPlanet(data)) {
  material = createTypeScriptPlanetMaterial(THREE, texture);
} else if (isJavaScriptPlanet(data)) {
  material = createJavaScriptPlanetMaterial(
    THREE,
    texture,
    data.planetColor
  );
} else if (isRustPlanet(data)) {
  material = createRustPlanetMaterial(THREE, texture);
}

表面以外のオブジェクトが必要な言語だけ、追加レイヤーを重ねます。

const planet = new THREE.Mesh(geometry, material);
planetGroup.add(planet);

if (isGoPlanet(data)) {
  planetGroup.add(createGoPlanetAtmosphere(THREE, 4));
}

if (isTypeScriptPlanet(data)) {
  planetGroup.add(createTypeScriptPlanetShell(THREE, 4));
}

if (isRustPlanet(data)) {
  planetGroup.add(createRustPlanetDust(THREE, 4));
}

全体の流れは次のようになります。

GitHubの使用言語
      ↓
言語判定
      ↓
表面Materialを生成
      ↓
必要なら大気・外殻・粒子を追加
      ↓
requestAnimationFrameでuniformを更新

この分割にしたことで、新しい言語を追加するときも、既存の惑星へ影響しにくくなりました。

ShaderMaterialではなくonBeforeCompileを使った理由

惑星の表面には、Three.jsの MeshStandardMaterial を利用しています。

const material = new THREE.MeshStandardMaterial({
  map: planetTexture,
  aoMap: planetTexture,
  roughness: 0.8,
  metalness: 0.2
});

ここで完全な ShaderMaterial を使えば、自由度は高くなります。

一方で、ライティング、AO、roughness、metalnessなど、標準マテリアルが持つ処理も自分で組み直す必要があります。

今回ほしかったのは、標準マテリアルを捨てることではなく、地形と光を残したまま、表面表現だけを差し替えることでした。

そこで onBeforeCompile を使い、Three.jsが生成したシェーダーの一部へGLSLを差し込みました。

material.onBeforeCompile = (shader) => {
  shader.uniforms.customTime = { value: 0 };

  shader.fragmentShader = shader.fragmentShader.replace(
    "#include <map_fragment>",
    `
      #include <map_fragment>

      // ここへ言語固有の表現を追加する
      diffuseColor.rgb *= customColor;
    `
  );
};

この方法なら、

  • Three.js標準の照明を利用できる
  • 元のテクスチャの凹凸感を残せる
  • 言語固有の色や発光だけを追加できる

というバランスを取れます。

ただし、Three.js内部のシェーダーチャンクへ依存するため、ライブラリ更新時には差し込み位置が変わっていないか確認が必要です。

CSS:色を「塗る」のではなく「流す」

CSS惑星では、色相が一方向へ流れ続けます。

単純にUV座標のX方向へ動かすと、球体の継ぎ目が目立ちます。そこで、球面上の3D位置と方向ベクトルの内積を使いました。

vec3 surfacePosition = normalize(vPosition);

vec3 flowDirection =
  normalize(vec3(0.88, 0.34, 0.32));

vec3 lateralDirection =
  normalize(vec3(-0.24, 0.93, -0.28));

float forward =
  dot(surfacePosition, flowDirection);

float lateral =
  dot(surfacePosition, lateralDirection);

forward が流れの進行方向、lateral が横方向の揺らぎになります。

float bend =
  sin(lateral * 2.25 + phase) * 0.2;

float directionalPhase =
  forward * 4.4 + bend - phase;

float band =
  sin(directionalPhase) * 0.5 + 0.5;

色はHSVで生成し、時間と位置から色相を循環させます。

float hue =
  fract(0.94 - directionalPhase / TWO_PI);

vec3 flowingColor =
  hsvToRgb(vec3(hue, 0.74, 1.0));

最後に、元テクスチャの明度を地形の凹凸として使い、流れる色へ掛け合わせます。

float relief =
  dot(textureColor, vec3(0.299, 0.587, 0.114));

vec3 colorized =
  flowingColor * (0.4 + relief * 1.1);

diffuseColor.rgb =
  mix(textureColor, colorized, 0.8);

これにより、地形を消さずに、惑星全体へ方向性のある色の流れを作れました。

失敗:虹色を回しただけではノイズに見えた

最初は緯度や経度に沿って均等に色相を回しました。

しかし、どの方向へ進んでいるのか分からず、動きが「表現」ではなく「ノイズ」に見えました。

そこで、

  • 主となる方向ベクトルを1本決める
  • 横方向の揺らぎは弱くする
  • 明るい帯と遅れて追従する帯を分ける

という設計へ変更しました。

動きの量よりも、動きの方向を読めることの方が重要でした。

C++:7本のプラズマフィラメントを生成する

C++惑星は、プラズマボールをイメージしています。

中心から外周へ伸びる放電を1本ずつテクスチャで描くのではなく、フラグメントシェーダー内で7本の経路を生成しました。

for (int i = 0; i < 7; i++) {
  float seed = hash(float(i) * 19.7 + 2.4);
  float baseAngle =
    float(i) * 2.399963 + seed * 0.38;

  // 半径に応じて経路を曲げる
  float bend =
    smoothNoise(radius * 8.5, seed) * 0.14;

  float filamentAngle =
    baseAngle + bend;

  float distanceToFilament =
    angleDistance(angle, filamentAngle)
    * max(radius, 0.12);

  float core =
    1.0 - smoothstep(
      0.0022,
      0.0065,
      distanceToFilament
    );

  float glow =
    1.0 - smoothstep(
      0.007,
      0.034,
      distanceToFilament
    );
}

さらに外側へ近づくと、一定確率で経路を分岐させます。

float branchEnabled =
  step(0.48, hash(seed * 37.1));

float branchProgress =
  smoothstep(branchStart, 0.965, radius);

float branchAngle =
  filamentAngle
  + branchProgress * branchDirection * 0.1;

発光は1色で済ませず、

  • フィラメントの白青
  • 周囲の紫色のハロー
  • 外周へ接触した部分のピンク
  • 中央電極の白い高温部

を別々に合成しました。

「線を描く」だけでは電気に見えず、芯、周辺光、接触点、中心部を分けたことでプラズマらしさが出ました。

Go:GitHubの活動量を風速へつなげる

Go惑星は、水色にするだけではなく、軽快に流れる風をテーマにしました。

表面の風は、球面上へ斜めの軸を作り、その軸を基準に経度と緯度を計算しています。

vec3 windAxis =
  normalize(vec3(0.28, 0.91, 0.31));

vec3 basisX =
  normalize(cross(windAxis, vec3(0.0, 0.0, 1.0)));

vec3 basisY =
  normalize(cross(windAxis, basisX));

float longitude =
  atan(
    dot(position, basisY),
    dot(position, basisX)
  );

float latitude =
  dot(position, windAxis);

表面の筋だけでは風に見えにくいため、半径の異なる球体を重ね、大気と後流も追加しました。

const shell = new THREE.Mesh(
  new THREE.SphereGeometry(radius * 1.1, 48, 48),
  atmosphereMaterial
);

const wakeA = new THREE.Mesh(
  new THREE.SphereGeometry(radius * 1.14, 48, 48),
  wakeMaterialA
);

const wakeB = new THREE.Mesh(
  new THREE.SphereGeometry(radius * 1.2, 48, 48),
  wakeMaterialB
);

さらに、GitHubの週間コミット数から惑星の回転速度を決め、その速度をGoの風速へ連動させています。

const rotationCommits =
  Math.min(Math.max(weeklyCommits, 0), 100);

planetRotationSpeed =
  BASE_ROTATION_SPEED
  + rotationCommits * 0.0001;
function calculateWindSpeedFactor(
  rotationSpeed,
  baseSpeed = 0.001
) {
  const ratio =
    Math.max(1, rotationSpeed / baseSpeed);

  return Math.min(
    2.5,
    1 + Math.sqrt(ratio - 1) * 0.5
  );
}

単に見た目を切り替えるだけでなく、開発活動が惑星内部の現象へ影響するようにした部分です。

TypeScript:型安全を4層の外殻にする

TypeScript惑星では、「型によって守られている」という印象を多層の外殻で表現しました。

本体の外側へ、半径、色、透明度の異なる4つの球体を重ねています。

const layers = [
  {
    radiusScale: 1.035,
    color: "#42a5e8",
    opacity: 0.36,
    front: true
  },
  {
    radiusScale: 1.08,
    color: "#007acc",
    opacity: 0.42
  },
  {
    radiusScale: 1.125,
    color: "#258fd4",
    opacity: 0.34
  },
  {
    radiusScale: 1.165,
    color: "#62b8eb",
    opacity: 0.22
  }
];
for (const layer of layers) {
  const mesh = new THREE.Mesh(
    new THREE.SphereGeometry(
      radius * layer.radiusScale,
      48,
      48
    ),
    createShellMaterial(layer)
  );

  shell.add(mesh);
}

シェーダーでは、次のようなパターンを組み合わせています。

  • 広く連続する防御領域
  • 途切れた境界
  • 細い検証フィラメント
  • 保護されたノード
  • 安定した領域
float broadGuard = ...;
float brokenBoundary = ...;
float validationFilament = ...;
float guardedNode = ...;
float stableRegion = ...;

ここは実装上の変数名も、TypeScriptの概念へ寄せました。

見た目だけでなく、コードを読み返したときにも「なぜこのパターンがあるのか」が分かりやすくなります。

透明な球体を重ねるときの注意点

透明オブジェクトは、描画順によって前後関係が不自然になることがあります。

今回の外殻では、

material.transparent = true;
material.depthWrite = false;
material.blending = THREE.AdditiveBlending;

mesh.renderOrder = 2 + index;

のように、深度書き込みを止め、描画順を明示しました。

また、内側から見せたい層には BackSide、前面にも薄く出したい層には FrontSide を使い分けています。

JavaScript:予測不能さを「反応する地表」にする

JavaScript惑星は、黄色い惑星を光らせるだけでは弱かったため、地表の異なる領域が時間差で反応する設計にしました。

12秒のサイクルを3分割し、3つの領域を順番に明るくします。

float pulseA =
  pow(max(sin(phase), 0.0), 3.0);

float pulseB =
  pow(max(sin(phase - TWO_PI / 3.0), 0.0), 3.0);

float pulseC =
  pow(max(sin(phase - TWO_PI * 2.0 / 3.0), 0.0), 3.0);

さらに、

  • 正常に伸びる分岐
  • 途中で不安定になる分岐
  • 一瞬だけ強く光るフラッシュ
  • 消えて暗くなる領域

を組み合わせています。

float primaryBranch = ...;
float failedBranch = ...;
float abortFlash = ...;
float collapsedBranch = ...;

float brightness =
  1.0
  + reaction * 0.34
  + primaryBranch * 0.34
  + failedBranch * 0.58
  + abortFlash * 0.46
  - collapsedBranch * 0.48;

JavaScriptの「動的」を、球体そのものの形を変えるのではなく、状態が次々に切り替わる反応性として表現しました。

Rust:12,000個の砂塵で砂嵐を作る

Rust惑星では、元テクスチャの明度を地形として再利用し、酸化した砂漠へ再着色しています。

float relief =
  dot(textureColor, vec3(0.299, 0.587, 0.114));

float craterDepth =
  1.0 - smoothstep(0.22, 0.64, relief);

vec3 deepStone =
  vec3(0.19, 0.075, 0.045);

vec3 oxide =
  vec3(0.56, 0.235, 0.12);

vec3 sandstone =
  vec3(0.76, 0.49, 0.29);

vec3 drySand =
  vec3(0.87, 0.66, 0.47);

表面だけでは「乾いた地形」にしか見えなかったため、12,000個のパーティクルを追加しました。

const PARTICLE_COUNT = 12000;

const positions =
  new Float32Array(PARTICLE_COUNT * 3);

const tangents =
  new Float32Array(PARTICLE_COUNT * 3);

const seeds =
  new Float32Array(PARTICLE_COUNT);

各粒子には次の情報を持たせます。

  • 球面上の初期位置
  • 表面に沿って移動する接線
  • 寿命や速度をずらすseed
  • 粒子の粗さ
geometry.setAttribute(
  "position",
  new THREE.BufferAttribute(positions, 3)
);

geometry.setAttribute(
  "dustTangent",
  new THREE.BufferAttribute(tangents, 3)
);

geometry.setAttribute(
  "dustSeed",
  new THREE.BufferAttribute(seeds, 1)
);

頂点シェーダー側で、粒子を一度持ち上げ、接線方向へ流し、寿命が来たら元の場所へ戻します。

float life =
  fract(time * 0.064 * speed + seed);

float lift =
  sin(clamp(life / 0.72, 0.0, 1.0) * PI)
  * awake;

vec3 displaced =
  position
  + surfaceNormal * lift * liftDistance
  + tangent * drift * driftDistance;

CPU側で12,000個の座標を毎フレーム更新するのではなく、位置計算をGPUへ寄せています。

共通の更新処理

各言語のアニメーションは、requestAnimationFrame 内でuniformを更新します。

function animate() {
  requestAnimationFrame(animate);

  planetGroup.rotation.z += planetRotationSpeed;

  const now = performance.now();

  updateCssPlanetFlow(cssMaterial, now);
  updateCppPlanetLightning(cppMaterial, now);
  updateGoPlanetWind(goMaterial, now, windSpeed);
  updateGoPlanetAtmosphere(goAtmosphere, now, windSpeed);
  updateTypeScriptPlanetShell(tsMaterial, now);
  updateTypeScriptPlanetShell(tsShell, now);
  updateJavaScriptPlanetReactivity(jsMaterial, now);
  updateRustPlanetDesert(rustMaterial, now);
  updateRustPlanetDesert(rustDust, now);

  renderer.render(scene, camera);
}

更新関数側は、対象が存在しなければ何もしないようにしています。

export function updateEffect(material, now) {
  const uniforms =
    material?.userData?.effectUniforms;

  if (!uniforms) return;

  uniforms.time.value = now / 1000;
}

ホーム側が「今どの言語を表示しているか」を毎フレーム判定する必要がなく、各モジュールを同じ形で呼び出せます。

実装して分かった失敗ポイント

1. 発光を強くしすぎると全部同じになる

emissiveを強くすると派手になります。

しかし、地形の陰影が消えるため、どの惑星も「明るい球」に近づきます。

言語ごとの差を出したいときほど、足し算だけでなく、

  • どこを暗く残すか
  • 元テクスチャをどれだけ残すか
  • 発光を芯と周辺で分けるか

が重要でした。

2. 色だけでなく、動きの時間軸も変える

全惑星を同じ速度で動かすと、異なるエフェクトでも同じテンポに見えます。

そこで、

  • CSS:24秒でゆっくり循環
  • TypeScript:24秒で静かに呼吸
  • JavaScript:12秒で領域が反応
  • C++:細かい明滅と遅い呼吸を混在
  • Rust:砂塵に粒子ごとの寿命を持たせる

と、時間設計も変えました。

3. フレーム数を時間として使わない

次のようにフレームごとに固定値を足すと、端末の描画速度でアニメーション速度が変わります。

time += 0.01;

基本は performance.now() や経過秒をuniformへ渡します。

const elapsedSeconds =
  (nowMilliseconds - lastMilliseconds) / 1000;

uniforms.time.value += elapsedSeconds;

4. 惑星の切り替え時にGPUリソースを破棄する

他のユーザーの惑星へ移動するたびに、古いGeometryやMaterialを残すとメモリ使用量が増えます。

function disposeObject(object) {
  for (const child of object.children ?? []) {
    disposeObject(child);
  }

  object.geometry?.dispose();

  if (Array.isArray(object.material)) {
    object.material.forEach(
      (material) => material.dispose()
    );
  } else {
    object.material?.dispose();
  }
}

テクスチャを共有している場合は、切り替えのたびに共有テクスチャまで破棄しないよう注意が必要です。

新しい言語を追加するための最小テンプレート

言語別ファイルを次の形に揃えておくと、追加しやすくなります。

const CYCLE_SECONDS = 16;

export function isNewLanguagePlanet(data) {
  return (
    data?.mainLanguage?.trim().toLowerCase()
    === "new-language"
  );
}

export function createNewLanguagePlanetMaterial(
  THREE,
  texture
) {
  const material =
    new THREE.MeshStandardMaterial({
      map: texture,
      roughness: 0.8,
      metalness: 0.2
    });

  const uniforms = {
    effectTime: { value: 0 }
  };

  material.onBeforeCompile = (shader) => {
    shader.uniforms.effectTime =
      uniforms.effectTime;

    shader.fragmentShader =
      shader.fragmentShader.replace(
        "#include <map_fragment>",
        `
          #include <map_fragment>

          uniform float effectTime;

          // 言語固有の処理
        `
      );
  };

  material.userData.effectUniforms =
    uniforms;

  material.customProgramCacheKey =
    () => "new-language-planet-v1";

  return material;
}

export function updateNewLanguagePlanet(
  material,
  nowMilliseconds
) {
  const uniforms =
    material?.userData?.effectUniforms;

  if (!uniforms) return;

  uniforms.effectTime.value =
    (
      nowMilliseconds / 1000
      % CYCLE_SECONDS
    ) / CYCLE_SECONDS;
}

追加時に考える順番は次の通りです。

  1. その言語を一言で表す
  2. 色ではなく現象へ置き換える
  3. 表面だけで足りるか考える
  4. 外殻、大気、粒子が必要なら別オブジェクトにする
  5. 1周期の長さを決める
  6. 地形が見える強度までエフェクトを下げる

固定データのショーケースも用意した

実際のGitHubユーザーだけを使うと、

  • 対象言語のユーザーが見つからない
  • コミット数によって見た目が変わる
  • 記事やREADMEの画像が後から変化する

という問題があります。

そこで、言語、色、コミット数、惑星サイズを固定したショーケース用データを用意しました。

const SHOWCASE_PLANETS = {
  css: createShowcasePlanet(
    "CSS",
    "#563d7c",
    "Directional Color Flow"
  ),
  cpp: createShowcasePlanet(
    "C++",
    "#f34b7d",
    "Idle Plasma Globe"
  ),
  go: createShowcasePlanet(
    "Go",
    "#00ADD8",
    "Atmospheric Wind"
  ),
  typescript: createShowcasePlanet(
    "TypeScript",
    "#007acc",
    "Defensive Typed Shell"
  ),
  javascript: createShowcasePlanet(
    "JavaScript",
    "#f0db4f",
    "Reactive Golden Surface"
  ),
  rust: createShowcasePlanet(
    "Rust",
    "#dea584",
    "Desert Dust World"
  )
};

これにより、リポジトリのREADME、記事、動作確認で同じ状態を再現できます。

視覚表現を開発するときは、本番データとは別に、比較用の固定データを持つとかなり便利でした。

まとめ

最初は、使用言語に応じて惑星の色を変えるだけでした。

しかし、色だけでは言語の違いは伝わりませんでした。

そこで、

  • CSSは流れる色彩
  • C++はプラズマ
  • Goは風と大気
  • TypeScriptは多層の外殻
  • JavaScriptは反応する地表
  • Rustは砂漠と砂嵐

というように、言語の性格を物理現象へ翻訳しました。

技術面では、次の構成にしています。

  • MeshStandardMaterialで標準ライティングを維持
  • onBeforeCompileで必要なGLSLだけを注入
  • 言語別モジュールへ分割
  • 外殻、大気、粒子は別オブジェクトとして追加
  • uniformを経過時間で更新
  • 固定データのショーケースで比較可能にする

派手なシェーダーを書くことよりも、なぜその現象をその言語へ割り当てたのかを説明できることが、作り分けには重要でした。

GitHub Planetでは、今後も言語ごとの惑星表現を追加していく予定です。

実際に自分の惑星を作ってみてください

GitHub Planetは公開しているので、ぜひ実際に触ってみてください。

GitHubでログインすると、使用言語やコントリビューションをもとに、自分だけの惑星が生成されます。他のユーザーの惑星を巡ったり、プロフィール用の惑星カードを作ったりすることもできます。

触ってみて面白いと思ってもらえたら、GitHubでStarを付けてもらえると開発の励みになります。

また、「この言語の惑星も見たい」「この表現が好きだった」などの感想も、Xの @0ts_st でぜひ教えてください。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?