R3F React Three Fiber UVマッピングを理解して画像を矩形で切り出し表示する

## 目的
### 基本
- UVマッピングを使って画像から矩形を切りだす
- 切り出した矩形画像を等倍でスクリーンに表示する
- 1枚のテクスチャを使い回す
### 応用
- 切り出した画像の高さ・幅の拡大と左右反転して表示する

ようするにスプライトシート的な2D画像を用意して、切り出して使いたいって人の役に立つ記事です。

## ソース
### キャンバス
```react:Canvas.tsx
export default function Page() {
  return (
    <main>
      <Suspense fallback={<span>loading...</span>}>
        <Canvas
          camera={{
            position: [0, 0, 100],
            zoom: 1,
            near: 0.1,
            far: 640,
          }}
          orthographic
          gl={{ antialias: true }}
          style={{ width: "100vw", height: "100vh" }}
          onContextMenu={(e) => e.preventDefault()}
        >
          <ImagePlane 
            src="/assets/playing_cards.png" 
            position={[0, 0, 0]} 
            clipArea={{x: 2/13, y:3/4, width: 1/13, height: 1/4 }} 
          /> 
        </Canvas>
      </Suspense>
    </main>
  );
}
```
注目ポイントは、`orthographic` です。
カメラが平行投影になり、パースがつきませんが奥行きの計算が不要になり便利です。


### ImagePlane
```react:ImagePlane.tsx
import { TextureLoader } from "three";
import { MeshProps, useLoader } from "@react-three/fiber";
import { useMemo } from "react";
import * as THREE from "three";
import { createRectUV } from "@/utils/threes/uv";

type Props = MeshProps & {
  src: string;
  clipArea?: [x: number, y: number, width: number, height: number];
};

export default function ImagePlane({
  src,
  clipArea = [0, 0, 1, 1],
  ...meshProps
}: Props) {
  // useLoader の texture は共有される
  const texture = useLoader(TextureLoader, src);
  const [x, y, width, height] = clipArea;
  const planeWidth = texture.image.width * width;
  const planeHeight = texture.image.height * height;

  const geometry = useMemo(() => {
    const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight, 1, 1);
    const attribute = createRectUV(x, width, y, height );
    // geometry に UV を設定
    geometry.setAttribute("uv", new THREE.BufferAttribute(attribute, 2));

    return geometry;
  }, [planeWidth, planeHeight, x, width, y, height]);

  return (
    <mesh {...meshProps} geometry={geometry}>
      <meshBasicMaterial attach="material" map={texture} transparent />
    </mesh>
  );
}
```
`new THREE.PlaneGeometry(planeWidth, planeHeight, 1, 1)` の `planeWidth`, `planeHeight` は切り出した画像の表示サイズをピクセルで指定すればOKです。
`Canvas` で `orthographic` を指定しているため、カメラからの距離（奥行き）にかかわらず同じサイズで表示されます。

`createRectUV` では、呼び出した texture から指定の矩形の範囲を指定したものを geometry の `uv` に設定します。

### createRectUV
```react:three/uv.ts
export function createRectUV(
  x: number,
  width: number,
  y: number,
  height: number,
): Float32Array {
  const left = width : x;
  const right = x + width;
  const top = y + height;
  const bottom = height : y;
  // UV マッピングをカスタマイズ
  return new Float32Array([
    left, top, // 左上
    right, top, // 右上
    left, bottom, // 左下
    right, bottom, // 右下
  ]);
}
```
`uv` は貼り付け元のテクスチャ（ここでは画像）の切り出し範囲の矩形を示す座標で、横方向（左から右）を `u`、縦方向（下から上）を `v` とし、元の画像の幅と高さのそれぞれを 1 としたときの 0〜1の範囲となります。

そして、`uv`で表現した、左上、右上、左下、右下の4点を `Float32Array` で指定します。

## 拡大・反転
```react:ImagePlane.tsx
import { TextureLoader } from "three";
import { MeshProps, useLoader } from "@react-three/fiber";
import { useMemo } from "react";
import * as THREE from "three";
import { createRectUV } from "@/utils/threes/uv";

type Props = MeshProps & {
  src: string;
  clipArea?: [x: number, y: number, width: number, height: number];
  clipScale?: [x: number, y: number];
};

export default function ImagePlane({
  src,
  clipArea = [0, 0, 1, 1],
  clipScale = [1, 1],
  ...meshProps
}: Props) {
  // useLoader のtexture は共有される
  const texture = useLoader(TextureLoader, src);

  const [clipScaleX, clipScaleY] = clipScale;
  const [x, y, width, height] = clipArea;

  const planeWidth = texture.image.width * width * Math.abs(clipScaleX);
  const planeHeight = texture.image.height * height * Math.abs(clipScaleY);
  const reverseX = clipScaleX < 0;
  const reverseY = clipScaleY < 0;

  const geometry = useMemo(() => {
    const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight, 1, 1);
    const attribute = createRectUvs(x, width, reverseX, y, height, reverseY);
    // geometry に UV を設定
    geometry.setAttribute("uv", new THREE.BufferAttribute(attribute, 2));

    return geometry;
  }, [planeWidth, planeHeight, reverseX, x, width, reverseY, y, height]);

  return (
    <mesh {...meshProps} geometry={geometry}>
      <meshBasicMaterial attach="material" map={texture} transparent />
    </mesh>
  );
}
```
### 拡大
`props` に `clipScale` を追加しました。 
`THREE.PlaneGeometry(planeWidth, planeHeight, 1, 1);` となっているので、 `planeWidth`, `planeHeight` を `clipScale` で倍すればOKです。

### 反転
反転は、UVの切り出し方を反転させる必要があります。
```react:uv
export function createRectUV(
  x: number,
  width: number,
  reverseX: boolean,
  y: number,
  height: number,
  reverseY: boolean
): Float32Array {
  const left = reverseX ? x + width : x;
  const right = reverseX ? x : x + width;
  const top = reverseY ? y : y + height;
  const bottom = reverseY ? y + height : y;
  // UV マッピングをカスタマイズ
  return new Float32Array([
    left, top, // 左上
    right, top, // 右上
    left, bottom, // 左下
    right, bottom, // 右下
  ]);
}
```



## 余談、別解(Bad Practice)
### texture に offset と repeat を設定する
矩形の切り出しは `UV` を使わず、`offset` と`repeat` を使ってもできます。
```react:ImagePlane.tsx
  const texture = useLoader(TextureLoader, src);
  texture.offset.set(x, y);
  texture.repeat.set(planeWidth, planeHeight);
```

:::note warn
`useLoader(TextureLoader, src)` で呼び出した texture はプロジェクト内で共有されます
:::

ただし、texture は src と結びついており、 複数回呼び出しても、同一テクスチャが使用されます。offset と repeat の設定はテクスチャの変更に含まれており、ImagePlane を複数呼び出した場合、全部のテクスチャの切り出し位置が同じになってしまいます。

```react:ImagePlane.tsx
  const texture = useLoader(TextureLoader, src).clone();
```
`clone()` を用いて、各ImagePlane で別のテクスチャを用意すれば、問題は解決しますが、メッシュごとにテクスチャを複数用意するのはメモリの無駄遣いです。