1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-02-23

はじめに

今回の制作物(GIF画像)

terrain-maker.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

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

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

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

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>
        </>
    )
}
1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?