Three.jsでworldToLocalを使うときはattachしよう

ワールド座標をローカル座標に変換するために`worldToLocal`[^world-to-local]を利用したところ、意図しない結果になってハマりました。Three.jsのバージョンはr119です。

[^world-to-local]: https://threejs.org/docs/#api/en/core/Object3D.worldToLocal

## 解決法
オブジェクトのシーンへの追加を`attach`により行います。グループを利用する場合については[後述](#グループを利用する場合)します。

```typescript
const scene = new THREE.Scene();

const cube = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshStandardMaterial({
        color: 0xFFD046
    })
);
cube.position.set(2, 3, 2);
scene.attach(cube); // これが重要！

// ワールド座標からローカル座標へ変換
const dir = cube.worldToLocal(
    new THREE.Vector3(-4, 0, 2)
);
```

## `attach`とは
[Three.jsのドキュメント（Object3D#attach）](https://threejs.org/docs/index.html#api/en/core/Object3D.attach)によれば

> `.attach` ( object : Object3D ) : this
> Adds object as a child of this, while maintaining the object's world transform.

とあり、ワールドでの変換を保持したまま子オブジェクトとして追加するものです。

オブジェクトのシーンへの追加は`add`が広く用いられているようですが、`attach`では内部的に`add`を呼び出している[^object3d-source]ため`add`をする必要はありません。

[^object3d-source]: https://github.com/mrdoob/three.js/blob/r119/src/core/Object3D.js#L393

## `attach`の代わりに`add`するとどうなるか
`worldToLocal`はオブジェクトのシーンへの追加方法によっては次の画像のように全く意図しない結果になります。

| `attach`した場合（意図した結果） :smile: | `add`した場合 :disappointed_relieved: |
| :--: | :--: |
| ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617255/cdb7ce06-9556-7018-d873-ca8718b5a6f7.jpeg) | ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617255/8930a5a7-3664-f972-287e-d26b84d5ad34.jpeg) |

画像は黄色の箱（`cube`）から青色の箱（`cube2`）に向かう矢印を**黄色の箱のローカル座標**に描画したものです。意図した結果である左の画像に対して、右の画像では全く別の方向を指しています。コードは次に示す通りです。

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js test</title>
    <style>
        html, body{ margin: 0; padding: 0; }
        canvas{ display: block; }
    </style>
</head>
<body>
    <script src="./main.js"></script>
</body>
</html>
```

```typescript:main.ts
import * as THREE from "three";
import {OrbitControls} from "three/examples/jsm/controls/OrbitControls";

// コンテナ
const container = document.createElement("div");
document.body.appendChild(container);

// レンダラー
const renderer = new THREE.WebGLRenderer({
    antialias: true
});
renderer.setSize( window.innerWidth, window.innerHeight );
container.appendChild(renderer.domElement);

// ワールド
const scene = new THREE.Scene();
scene.add(new THREE.AxesHelper(5));
scene.add(new THREE.GridHelper(10, 10));

// カメラ
const camera = new THREE.PerspectiveCamera(
    50, window.innerWidth / window.innerHeight
);
camera.position.set(-8, 6, 8);

// コントロール
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// ライト
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
const pointLight = new THREE.PointLight(0xffffff);
pointLight.position.set(0, 5, 0);
scene.add(ambientLight, pointLight);

// オブジェクト
function generateCube(colorHex: THREE.Color){
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    const material = new THREE.MeshStandardMaterial({
        color: colorHex
    });
    const cube = new THREE.Mesh(geometry, material);
    cube.add(new THREE.AxesHelper(2));
    return cube;
}
const cube = generateCube(new THREE.Color(0xFFD046));
cube.position.set(2, 3, 2);
cube.rotateX(THREE.MathUtils.degToRad(60));
cube.rotateY(THREE.MathUtils.degToRad(90));
scene.attach(cube); // これが重要！

const cube2 = generateCube(new THREE.Color(0x003F91));
cube2.position.set(-4, 0, -2);
scene.add(cube2);

// 座標変換
const dir = cube.worldToLocal(
    cube2.position.clone()
);
const length = dir.length();
dir.normalize();
const arrowLocal = new THREE.ArrowHelper(
    dir,
    new THREE.Vector3(0, 0, 0), // origin
    length,
    0xff0000
);
cube.add(arrowLocal);

function animate(){
    controls.update();
    renderer.render(scene, camera);
    requestAnimationFrame(() => animate());
}

animate();
```

いろいろ試したところ、`cube`を回転させず移動のみの場合は`add`でも矢印は青い箱に刺さるように表示されました。

## グループを利用する場合
黄色い箱をグループに入れるような場合はグループの`attach`も必要になります。画像は先の例と同様に黄色の箱から青色の箱に向かう矢印を描画したものです。グループにはAxesHelperと黄色の箱を追加しています。

| `attach`した場合（意図した結果） :smile: | `add`した場合 :disappointed_relieved: |
| :--: | :--: |
| ![good_group.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617255/ce318a49-df36-9fe0-ff57-827daf34e23c.jpeg) | ![bad_group.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617255/e4d86e19-e499-619d-1dd9-f31e90173c29.jpeg) |

先の例では黄色い箱はシーンに`attach`していましたが、今回はグループが親になるため、グループへの`attach`を行います。座標変換はグローバル→グループ→オブジェクトの順に行います。

```typescript:main.ts（改変した部分）
...
// オブジェクト
function generateCube(colorHex: THREE.Color){
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    const material = new THREE.MeshStandardMaterial({
        color: colorHex
    });
    const cube = new THREE.Mesh(geometry, material);
    cube.add(new THREE.AxesHelper(2));
    return cube;
}
const cube = generateCube(new THREE.Color(0xFFD046));
cube.position.set(2, 3, 2);
cube.rotateX(THREE.MathUtils.degToRad(60));
cube.rotateY(THREE.MathUtils.degToRad(90));
// scene.attach(cube); シーンにはattachしません

// グループ
const group = new THREE.Group();
group.position.set(1, 1, 1);
group.rotateX(THREE.MathUtils.degToRad(60));
group.rotateY(THREE.MathUtils.degToRad(45));

group.add(new THREE.AxesHelper(2));
group.attach(cube);  // グループ <- cube
scene.attach(group); // シーン <- グループ

const cube2 = ...
...
// 座標変換
const dir = cube.worldToLocal(
    group.worldToLocal(cube2.position.clone())
);
...
```

## 参考
* [javascript - Translate a vector from global space to local vector in three.js - Stack Overflow](https://stackoverflow.com/questions/26400570/translate-a-vector-from-global-space-to-local-vector-in-three-js)
* [Object3D - three.js docs](https://threejs.org/docs/#api/en/core/Object3D)
* [Object3D: added attach() method by WestLangley · Pull Request #16528 · mrdoob/three.js · GitHub](https://github.com/mrdoob/three.js/pull/16528)