【ReactThreeFiber】Three.js(R3F)で地形メーカーを作成する

# はじめに

## 今回の制作物(GIF画像)
![terrain-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/493888/4de02286-f45b-8ad1-007e-386a9b040dfe.gif)

現在3Dゲームを作るための環境を作成するにあたり、
独自の3D地形データが欲しいなと思い、地形データを生成プログラムを作成しました。

## 環境

**利用ライブラリ**
- threejs v0.148
- ReactThreeFiber
- ReactTheeDrei
- React

**フォルダ構成(React構成部のみ記述)**
```
- terrain.tsx
- TerrainMaker
  |- TerrainMakerManager.ts
  |- TerrainMakerCanvas.tsx
  -- TerrainMakerUI.tsx
```

reactで親であるterrain.tsxを呼び出せばOKです。

**操作方法**
- Eキーで編集モードと表示モードを切り替え
- 表示モードはカメラ移動が自由にできます。
- 編集モードでクリックしながらマウス移動させると地形変更。
- 左クリック押しながらで上昇、右クリック押しながらで下降します。
- 出力形式はGLB形式固定。確認はGLTF-Viewerを使いましょう
- 左にある半径サイズと解像度はそれぞれ”変更を更新”ボタン押下時に反映されます。


## ReactThreeFiberで記述

### 親コンポネント: terrain.tsx
```react: terrain.tsx
import { useEffect, useState } from "react";
import { TerrainMakerCanvas } from "./TerrainMaker/TerrainMakerCanvas";
import { TerrainMakerContext, TerrainMakerManager} from "./TerrainMaker/TerrainMakerManager";
import { TerrainMakerUI } from "./TerrainMaker/TerrainMakerUI";

const TerrainMakerComponent = () => {
    const [terrainManager, setTerrainManager] = useState<TerrainMakerManager>();
    useEffect(() => {
        setTerrainManager(new TerrainMakerManager());
        return () => {
            setTerrainManager(null);
        }
    }, [])
    return (
        <>
            <TerrainMakerContext.Provider value={terrainManager}>
                {terrainManager &&
                <>
                    <div>
                        <TerrainMakerUI/>
                    </div>
                    <div style={{ height: "100vh" }} onContextMenu={() => {return false}}>
                        <TerrainMakerCanvas/>
                    </div>
                </>
                }
            </TerrainMakerContext.Provider>
        </>
    )
}

export default TerrainMakerComponent;
```

### 地形メーカークラス：TerrainMakerManager.ts
```typescript:TerrainMakerManager.ts
import { createContext } from "react";
import { Mesh, Object3D, PlaneGeometry, PerspectiveCamera, OrthographicCamera } from "three";
import { GLTFExporter, GLTFExporterOptions } from "three/examples/jsm/exporters/GLTFExporter";

export class TerrainMakerManager {
    mode: "edit" | "view" = "view";
    terrainMesh: Mesh;
    color : string = "#00ff00";
    isMouseDown: boolean = false;
    mapSize = 32;
    mapResolution = 64;
    power = 0.1;
    wireFrame = false;
    radius = 1.0;
    camera: PerspectiveCamera | OrthographicCamera;

    constructor(){}

    reset(){
        if (this.terrainMesh){
            this.terrainMesh.geometry = new PlaneGeometry(this.mapSize, this.mapSize, this.mapResolution, this.mapResolution);
        }
        if (this.camera){
            this.camera.position.set(
                this.mapSize / 2,
                this.mapSize / 2,
                -this.mapSize / 2
            );
        }
    }

    changeMode(){
        if (this.mode == "view"){
            this.mode = "edit";
        }
        else this.mode = "view";
    }

    changeWireFrame(){
        this.wireFrame = !this.wireFrame;
    }

    changePower(power: number){
        this.power = power;
    }

    changeRaduis(radius: number){
        this.radius = radius;
    }

    changeMapSize(size: number){
        this.mapSize = size;
    }

    changeMapResolution(resolution: number){
        this.mapResolution = resolution;
    }

    changeColor(color: string){
        this.color = color;
    }

    /**
     * GLB出力
     * @param filename 
     */
    async exportTerrainMesh(filename: string){
        if (this.terrainMesh){
            const obj3d = new Object3D();
            obj3d.add(this.terrainMesh.clone());
            var exporter = new GLTFExporter();
            const options: GLTFExporterOptions = {
                trs: false,
                onlyVisible: true,
                binary: true,
                maxTextureSize: 4096
            };
            exporter.parse(
                obj3d,
                (result) => {
                    if (result instanceof ArrayBuffer){
                        this.saveArrayBuffer(result, filename);
                    }
                    else {
                        const output = JSON.stringify( result, null, 2 );
                        this.saveString(output, filename);
                    }
                },
                (error: ErrorEvent) => {
                    console.log(`出力中エラー: ${error.toString()}`);
                }
            );
        }
        else console.log("地形データが存在していません");
    }

    save(blob: Blob, filename: string){
        const link = document.createElement("a");
        link.href = URL.createObjectURL(blob);
        link.download = filename;
        link.click();
        link.remove();
    }

    saveString(text: string, filename: string){
        this.save( new Blob( [ text ], { type: 'text/plain' } ), filename );
    }

    saveArrayBuffer(buffer: ArrayBuffer, filename: string){
        this.save(new Blob([buffer], { type: "application/octet-stream" }), filename);
    }
}

export const TerrainMakerContext = createContext<TerrainMakerManager>(null);
```

### 地形メーカー描画: TerrainMakerCanvas.tsx
```react
import { Environment, OrbitControls, SpotLight } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { useContext, useEffect, useRef } from "react";
import { DoubleSide, Mesh, Vector2, Raycaster, Vector3, MeshStandardMaterial, MathUtils, Intersection, SpotLight as SL, Color, GridHelper } from "three";
import { TerrainMakerContext } from "./TerrainMakerManager";

const TerrainMakeComponent = () => {
    /**
     * 初期値
     */
    const terrainManager = useContext(TerrainMakerContext);
    const ref = useRef<Mesh>();
    const camRef = useRef<any>();
    const matRef = useRef<MeshStandardMaterial>();
    const lightRef = useRef<SL>();
    const gridRef = useRef<GridHelper>();
    const mouse = new Vector2();
    const raycaster = new Raycaster();
    const { camera } = useThree();
    camera.position.set(
        terrainManager.mapSize / 2,
        terrainManager.mapSize / 2,
        -terrainManager.mapSize / 2
    );
    terrainManager.camera = camera;
    let isReverse = false;

    const getVertexes = (intersects: Intersection[], radius: number): { indexes: number[], values: number[] } => {
        const nearVertices: number[] = []; // 範囲内の頂点
        const values: number[] = [];       // 中心からの距離
        if (intersects.length>0 && intersects[0].object){
            const object: Mesh = intersects[0].object as Mesh;
            const geometry = object.geometry;
            const vertices = geometry.attributes.position.array;
            const stride = geometry.attributes.position.itemSize;
            let position: Vector3;
            if (intersects.length > 0){
                position = intersects[0].point;
                if (position){
                    const vertices = geometry.attributes.position.array;
                    const maxDistance = radius * radius; // 2乗距離
                    for (let i = 0; i < vertices.length; i += stride) {
                        const v = new Vector3(vertices[i], vertices[i + 1], vertices[i + 2]);
                        // 回転を考慮する
                        v.applyMatrix4(object.matrixWorld);
                        if (v.distanceToSquared(position) < maxDistance) {
                             nearVertices.push(i / stride);
                             values.push(1 - v.distanceToSquared(position) / maxDistance);
                        }
                    }
                }
            }
        }
        return { 
            indexes: nearVertices, 
            values: values
        };
    }
    const onMouseMove = (event) => {
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
        if (terrainManager.isMouseDown && terrainManager.mode == "edit"){
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObject(ref.current);
            const { indexes, values } = getVertexes(intersects, terrainManager.radius);
            if (intersects.length > 0 && intersects[0]) {
                const object: Mesh = intersects[0].object as Mesh;
                if (!object) return;
                indexes.map((index, i) => {
                    const value = values[i];
                    let position = object.geometry.attributes.position;
                    position.setZ(
                        index,  
                        (position.getZ(index) + (terrainManager.power * value * (isReverse?-1: 1)))
                    );
                    position.needsUpdate = true;
                });
            }
        }
    }
    
    const onMouseDown = (e) => {
        terrainManager.isMouseDown = true;
        if (e.button === 2){
            isReverse = true;
        }
    }
    const onMouseUp = () => {
        terrainManager.isMouseDown = false;
        isReverse = false;
    }

    useEffect(() => {
        document.addEventListener("mousemove", onMouseMove, false);
        document.addEventListener("mousedown", onMouseDown, false);
        document.addEventListener("mouseup", onMouseUp, false);
        terrainManager.terrainMesh = ref.current;
        return () => {
            document.removeEventListener("mousemove", onMouseMove);
            document.removeEventListener("mousedown", onMouseDown);
            document.removeEventListener("mouseup", onMouseUp);
        }
    }, []);

    useFrame((_, delta) => {
        if (terrainManager.mode == "edit"){
            camRef.current.enabled = false;
        }
        else {
            camRef.current.enabled = true;
        }
        matRef.current.wireframe = terrainManager.wireFrame;
        matRef.current.color = new Color(terrainManager.color);
        lightRef.current.position.set(
            -terrainManager.mapSize / 1.6,
            terrainManager.mapSize / 1.6,
            -terrainManager.mapSize /2
        );
        lightRef.current.distance = terrainManager.mapSize * 2;
        if (lightRef.current.distance <= 100){
            lightRef.current.intensity = lightRef.current.distance / 4;
        }
        else if (lightRef.current.distance > 100){
            lightRef.current.intensity = lightRef.current.distance / 16;
        }
        else if (lightRef.current.distance > 300){
            lightRef.current.intensity = lightRef.current.distance / 24;
        }
        else {
            lightRef.current.intensity = lightRef.current.distance / 32;
        }
    })

    return (
        <>
            <OrbitControls makeDefault={true} ref={camRef}/>
            <axesHelper/>
            <gridHelper ref={gridRef} args={[terrainManager.mapSize*2, Number(terrainManager.mapResolution/2)]}/>
            <mesh ref={ref} rotation={[-Math.PI/2, 0, 0]} receiveShadow castShadow>
                <planeGeometry args={[terrainManager.mapSize, terrainManager.mapSize, terrainManager.mapResolution, terrainManager.mapResolution]}/>
                <meshStandardMaterial ref={matRef} wireframe={true} side={DoubleSide}/>
            </mesh>

            <SpotLight
                ref={lightRef}
                angle={MathUtils.degToRad(45)}
                color={'#fadcb9'}
                volumetric={false}
            />
        </>
    )
}

export const TerrainMakerCanvas = () => {
    return (
        <>
            <Canvas shadows>
                <Environment preset="dawn" background blur={0.7} resolution={512}>
                </Environment>
                <TerrainMakeComponent/>
            </Canvas>
        </>
    )
}
```

### 地形メーカー操作UI: TerrainMakerUI.tsx
```react:TerrainMakerUI.tsx
import { useContext, useEffect, useState } from "react"
import { TerrainMakerContext } from "./TerrainMakerManager"

/**
 * 入力イベント / 入力の型
 */
interface HTMLElementEvent<T extends HTMLElement> extends Event {
    target : T;
    code   : string;
}

export const TerrainMakerUI = () => {
    const terrainManager = useContext(TerrainMakerContext);
    const [mode, setMode] = useState<"view"|"edit">(terrainManager.mode);
    const [wf, setWF] = useState<boolean>(terrainManager.wireFrame);
    const [power, setPower] = useState<number>(terrainManager.power);
    const [radius, setRadius] = useState<number>(terrainManager.radius);
    const [mapSize, setMapSize] = useState<number>(terrainManager.mapSize);
    const [mapResolution, setMapResolution] = useState<number>(terrainManager.mapResolution);
    const [color, setColor] = useState<string>(terrainManager.color);

    const keyDown = (event: HTMLElementEvent<HTMLInputElement>) => {
        if (event.code.toString() == "KeyE") {
            terrainManager.changeMode();
            setMode(terrainManager.mode);
        }
    }
    
    useEffect(() => {
        setMode(terrainManager.mode);
        document.addEventListener("keydown", keyDown);
        return () => {
            document.removeEventListener("keydown", keyDown);
        }
    }, []);

    const changeMode = () => {
        terrainManager.changeMode();
        setMode(terrainManager.mode);
    }

    const changeWF = () => {
        terrainManager.changeWireFrame();
        setWF(terrainManager.wireFrame);
    }

    const changePower = (e: any) => {
        terrainManager.changePower(Number(e.target.value));
        setPower(Number(e.target.value));
    }

    const changeRadius = (e: any) => {
        terrainManager.changeRaduis(Number(e.target.value));
        setRadius(Number(e.target.value));
    }

    const changeMapSize = (e) => {
        if (e.target.value && Number(e.target.value)>0){
            setMapSize(Number(e.target.value));
        }
    }

    const changeMapResolution = (e) => {
        if (e.target.value && Number(e.target.value)>0){
            setMapResolution(Number(e.target.value));
        }
    }

    const changeColor = (e) => {
        setColor(e.target.value);
        terrainManager.changeColor(e.target.value);
    }

    const updateMap = () => {
        terrainManager.changeMapSize(mapSize);
        terrainManager.changeMapResolution(mapResolution);
        terrainManager.reset();
    }

    return (
        <>
            <div style={{position: "fixed", zIndex: 99999, width: "250px", top: "10px", right: "10px"}}>
                <div style={{ background: "#121212",  color: "#ffffff", padding: "5px" }}>
                    <div style={{padding: "3px"}}>
                        [ Eキーでモード切替 ]
                    </div>
                    <div style={{padding: "3px"}}>
                        <a onClick={() => changeMode()}>
                            モード: {mode=="view"? "表示": "編集"}
                        </a>
                    </div>
                    <div style={{padding: "3px"}}>
                        <a onClick={() => changeWF()}>
                            ワイヤーフレーム: {wf.toString()}                        
                        </a>
                    </div>
                    <div style={{padding: "3px"}}>
                        変形する強さ: {power}                    
                    </div>
                    <div style={{padding: "3px"}}>
                        <input type={"range"} value={power} onInput={(e) => changePower(e)} min={0.01} max={0.29} step={0.01}/>                       
                    </div>
                    <div style={{padding: "3px"}}>
                        範囲: {radius}                    
                    </div>
                    <div style={{padding: "3px"}}>
                        <input type={"range"} value={radius} onInput={(e) => changeRadius(e)} min={0.1} max={10.0} step={0.1}/>                       
                    </div>
                    <div style={{padding: "3px"}}>
                        色: {color}                    
                    </div>
                    <div style={{padding: "3px"}}>
                        <input type={"color"} value={color} onInput={(e) => changeColor(e)}/>                       
                    </div>
                    <div style={{padding: "3px", fontWeight: "bold", color: "#8888ff", cursor: "pointer"}}>
                        <a onClick={() => terrainManager.exportTerrainMesh("terrain.glb")} >
                            モデルを保存(.glb)                        
                        </a>
                    </div>
                </div>
            </div>
            <div style={{position: "fixed", zIndex: 99999, width: "100px", top: "10px", left: "10px"}}>
                <div style={{ background: "#121212",  color: "#ffffff", padding: "5px" }}>
                    <div>
                        半径サイズ
                    </div>
                    <div>
                        <input style={{width: "50px"}} type={"number"} min={1} value={mapSize} onChange={(e) => changeMapSize(e)}/>
                    </div>
                    <div>
                        解像度
                    </div>
                    <div>
                        <input style={{width: "50px"}} type={"number"} min={4} value={mapResolution} onChange={(e) => changeMapResolution(e)}/>
                    </div>
                    <div style={{paddingTop: "8px"}}>
                        <button onClick={() => updateMap()}>変更を反映</button>
                    </div>
                </div>
            </div>
        </>
    )
}
```