はじめに
今回の制作物(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>
</>
)
}