【three.js】地球などの3D球体に円を描画する【GLSL】

three.jsにおいて、地球などの3D球体の表面に円を描画する方法をいくつか考えたのでまとめ。 
球体は地球などを想定し、円の中心座標を緯度経度で与える想定とする。

完成形はこんな感じ。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491627/1431a607-7656-6dff-bb07-cbca05a6f895.png' width='480' />

この円は半径とかを時間とともに動かして、衝撃波のエフェクトだったり、波のエフェクトみたいなのを作る基礎になるのをイメージして。。

## 環境
- Reactと[three.js](https://threejs.org/)を使うので、[React Three Fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction)を利用。

## はじめに
球体はSphereGeometryを使って作成することとし、円を上に描画するためのシェーダーを書きたいので、ShaderMaterialと一緒に利用する。
構造は以下のようになる。
- `radius`は球の半径。
- `circleCenter`は円の中心座標で、これはuniformsを通じて渡す。
```react
<mesh>
  <sphereGeometry args={[radius, 128, 64]} />
  <shaderMaterial
    vertexShader={vertexShader}
    fragmentShader={fragmentShader}
    uniforms={{
      radius: { value: radius },
      circleCenter: { value: circleCenter },
    }}
  />
</mesh>
```

### circleCenter
緯度経度は角度情報なので、一度ラジアンに直して、それをthree.jsの`setFromSphericalCoords`メソッドでVector3に変換することができる。
これは、球体の中心を0,0,0とするローカルスペース上での座標になる。
```react
const circleCenter = new Vector3().setFromSphericalCoords(
  radius,
  MathUtils.degToRad(90), // longitude
  MathUtils.degToRad(90) // latitude
);
```

## パターン1. Vertex Shaderで実装する
Vertex Shaderは頂点ごとに実行されるシェーダーで、頂点は3D空間上での位置を保持しているので（position変数）、円の中心からの距離を簡単に計算することができる。
しかし、最終的な円の見た目がポリゴンの粒度に左右されるのがよくない。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491627/1aae2835-4fce-6d56-35c8-bd643ad48567.png' width='480' />

### Vertex Shader
単純に処理中の頂点の位置と描画したい円の中心の位置の距離を測って、distという変数でFragment Shaderに受け渡す。
```glsl: vertex shader
uniform vec3 circleCenter;
varying vec2 vUv;
varying float dist;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  dist = distance(position, circleCenter);
}
```

### Fragment Shader
ピクセル単位にdistの値によって色をわける。
```glsl: fragment shader
varying vec2 vUv;
varying float dist;
void main() {
  gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
  if(dist < 0.2) {
    gl_FragColor = vec4(1.0, 0., 0., 1.0);
  }
}
```

## パターン2. Fragment Shaderで実装する
Fragment Shaderはピクセルごとに実行されるシェーダーで、Vertex Shaderに比べると少し高価になる。
ピクセルの座標はUV座標やテクスチャ座標など2次元での座標になるので、3次元での座標である円の中心座標との距離計算に難があるが、それを乗り切れば以下のように綺麗な円を作れる。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491627/1431a607-7656-6dff-bb07-cbca05a6f895.png' width='480' />


ちなみにSphereGeometryは正筒投影（Cylindrical Projection）のようなので、テクスチャも世界地図みたいなものをイメージしてOK。

### Vertex Shader
特に何もしない。
```glsl: vertex shader
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
```

### Fragment Shader
`setFromSphericalCoords`というメソッドを実装する。
これはcircleCenterを作るときに使ったthree.jsのメソッドと同じ処理をGLSLで書き直したもの。

three.jsのリポジトリにVector3クラスのコードが全部あるので、この中からsetFromSphericalCoordsを探して、ChatGPTにコピペ、「GLSLで書き直して」と頼むだけ。こういうときオープンソースは便利だなあ。
https://github.com/mrdoob/three.js/blob/master/src/math/Vector3.js

`uvCoordsToSphericalCoords`はUV座標系（-1から+1の範囲になる）を球表面の座標に変換するためのメソッドで、ピクセルごとに距離を計測していくため必要。
UnityとかだとUV座標が0〜1なのに、three.jsは-1から1っぽいのでハマってしまった。

ちなみに緯度は-90から90の範囲で、マイナスが北方向、経度は-180から180の範囲らしい。（間違ってたらすいません。）


```glsl: fragment shader
uniform float radius;
uniform vec3 circleCenter;
varying vec2 vUv;

vec3 setFromSphericalCoords(float radius, float phi, float theta) {
  float sinPhiRadius = sin(phi) * radius;
  vec3 result;
  result.x = sinPhiRadius * sin(theta);
  result.y = cos(phi) * radius;
  result.z = sinPhiRadius * cos(theta);
  return result;
}

vec3 uvCoordsToSphericalCoords(vec2 uv) {
  // UV is -1 to 1 in three.js
  float phi = radians(180. * -uv.y);
  float theta = radians(360. * -uv.x);
  return setFromSphericalCoords(radius, phi, theta);
}

void main() {
  vec3 pixelOnSphereCoords = uvCoordsToSphericalCoords(vUv);
  float dist = distance(pixelOnSphereCoords, circleCenter);
  gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); // white
  if(dist < 0.5) {
    gl_FragColor = vec4(1.0, 0., 0., 1.0); // red
  }
}
```

この方法はピクセルごとにサインだったりコサインだったり計算するので結構高価かも。

## パターン3. Vertex Shaderで実装するけど形状を工夫してうまく見せる
いろいろ調べてて感心した方法。
それは、VertexShaderを使うけどポリゴンをこんな感じの点で描画して行う方法。等間隔に点を敷き詰められればメッシュのガクガクした感じが出ずに、いい感じになりそう。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491627/2c44854e-5c71-c2a1-b198-023f45322a77.png' width='480' />

この点1つ1つがGeometryになっていて、Vertex Shaderで距離計算とかができる。参考コードはここ。
https://codepen.io/prisoner849/pen/MWbeoGj

上記だとUVSphereがベースになってそうだけど、ICOSphereを使ったらもっと綺麗になりそう。
参考、UV球とICO球の違い）https://www.makeuseof.com/uv-sphere-icosphere-blender/

その場合Blenderとかで作っておいてもいいかも。（このコード例だとJavaScript側で球体メッシュの生成までやってる。）
正筒投影（Cylindrical Projection）に対応させるためにUV展開するのはまた面倒かもしれないけど。

追記：と思ったらHexSphereの綺麗なUVのプロジェクトがあった。
https://github.com/arscan/hexasphere.js

さらに追記：HexasphereはUVを頂点ごとに作ってくれるプロジェクトではなさそうなので、three.jsなどで自分でUVを計算する必要がある。左右の端においてはどうしても歪みが出てしまうが、36分割くらいすれば見た目に問題はなさそう？
頂点ごとだと、
```javascript
const getUV = (vertex: Vector3) => {
  const { phi, theta } = cartesianCoordsToSphericalCoords(vertex);
  const V = phi / Math.PI;
  const U = theta / Math.PI;
  return { U, V };
};
```


## まとめ
サイバーな見た目というのは、理由があってサイバーなんだなと感心したのでよかった。
点で作った球体でいろいろなエフェクトを乗せて遊ぶのも楽しそうなので今度やってみる。
今回はVertex ShaderとFragment Shaderの違いや特長も学習できたのでよかった。