このチュートリアルでは、React Three Fiber と Rapier 物理エンジンを使って、
といったシーンを一から実装します。
my-3d-game/
├── package.json
├── index.html
├── public/
├── src/
│ ├── App.jsx
│ ├── index.css
│ ├── main.jsx
│ ├── components/
│ │ ├── GrassField.jsx
│ │ ├── Terrain.jsx
│ │ └── Player.jsx
│ ├── constants/
│ │ └── grassConstants.js
│ ├── shaders/
│ │ └── grassShaders.js
│ └── utils/
│ ├── SimplexNoise.js
│ └── grassHelpers.js
└── README.md
まずは React プロジェクトを作成し、必要パッケージをインストールします。今回は Create React App を例にします。
# プロジェクト作成(JavaScript版)
yarn create vite . --template react
cd my-3d-game
# 必要パッケージを追加
yarn add three @react-three/fiber @react-three/drei @react-three/rapier three-stdlib
three
:Three.js の本体@react-three/fiber
:React 上で Three.js を使うためのレンダラー@react-three/drei
:便利コンポーネント集@react-three/rapier
:物理エンジン Rapier を Three.js に統合インストール後、src/
フォルダにファイルを配置していきます。
草原や地形の大きさなど、変更しやすいパラメータをまとめます。
// ────────── 草原のレンダリングに関する定数 ──────────
export const PLANE_SIZE = 50 // フィールドの幅(メートル)
export const BLADE_COUNT = 500_000 // 草の枚数(頂点数ではなくブレード数)
export const BLADE_WIDTH = 0.05 // 草の根元幅
export const BLADE_HEIGHT = 0.5 // 草の高さ
export const BLADE_HEIGHT_VARIATION = 0.2 // 草の高さバリエーション
// ────────── 地形のレンダリングに関する定数 ──────────
export const TERRAIN_NSUBDIVS = 1 // HeightfieldCollider の分割数
export const TERRAIN_SCALE = {
x: PLANE_SIZE,
y: 1,
z: PLANE_SIZE,
}
解説:
PLANE_SIZE
を変えるとシーン全体のスケールが変わります。- 草の枚数を増やすと見た目は濃くなりますが、描画負荷も上がるので注意しましょう。
草と地形それぞれの頂点・フラグメントシェーダーを用意します。共通のノイズ関数もここにまとめます。
// ───── 共通ノイズ関数(hash, noise, fbm)─────
export const NOISE_FUNCTIONS = /* glsl */ `
float hash(float n) {
return fract(sin(n) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float n = i.x + i.y * 57.0;
return mix(
mix(hash(n), hash(n + 1.0), f.x),
mix(hash(n + 57.0), hash(n + 58.0), f.x),
f.y
);
}
float fbm(vec2 p) {
float f = 0.0;
f += 0.5000 * noise(p); p *= 2.02;
f += 0.2500 * noise(p); p *= 2.03;
f += 0.1250 * noise(p); p *= 2.01;
f += 0.0625 * noise(p);
return f / 0.9375;
}
`
// ───────── 草用頂点シェーダー ─────────
export const GRASS_VERTEX_SHADER = /* glsl */ `
varying vec2 vUv;
varying vec3 vColor;
uniform float iTime;
uniform vec3 playerPosition;
void main() {
vUv = uv;
vColor = color;
vec3 cpos = position;
// 風やプレイヤー接触で揺らすロジック
float waveSize = 5.0;
float tipDistance = 0.2;
float centerDistance = 0.05;
float waveFrequency = 1500.0;
vec3 toPlayer = playerPosition - position;
float dist = length(toPlayer);
float radius = 1.2;
float strength = 0.8;
if (dist < radius) {
float push = (1.0 - dist / radius) * strength;
push *= smoothstep(0.0, 1.0, vColor.g);
vec3 dir = normalize(vec3(toPlayer.x, 0.0, toPlayer.z));
cpos.xz -= dir.xz * push;
} else if (vColor.x > 0.6) {
cpos.x += sin((iTime / waveFrequency) + (uv.x * waveSize)) * tipDistance;
} else if (vColor.x > 0.0) {
cpos.x += sin((iTime / waveFrequency) + (uv.x * waveSize)) * centerDistance;
}
gl_Position = projectionMatrix * modelViewMatrix * vec4(cpos, 1.0);
}
`
// ───────── 草用フラグメントシェーダー ─────────
export const GRASS_FRAGMENT_SHADER = /* glsl */ `
uniform vec3 playerPosition;
uniform float iTime;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform vec3 uColor3;
varying vec2 vUv;
varying vec3 vColor;
${NOISE_FUNCTIONS}
void main() {
vec2 uv = vUv * 10.0;
float n1 = fbm(uv);
vec3 base = mix(uColor1, uColor2, n1 * 0.7);
float edgeL = smoothstep(0.0, 0.7, vColor.r);
float edgeR = smoothstep(0.3, 1.0, vColor.r);
float isEdge = max(1.0 - edgeL, edgeR);
float hg = smoothstep(0.0, 1.0, vColor.g);
float dark = isEdge * 0.08 * hg;
base = mix(base, base * 1.1, hg);
base -= dark * 0.9;
gl_FragColor = vec4(base, 1.0);
}
`
// ───────── 地形用頂点シェーダー ─────────
export const TERRAIN_VERTEX_SHADER = /* glsl */ `
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
vPosition = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
// ───────── 地形用フラグメントシェーダー ─────────
export const TERRAIN_FRAGMENT_SHADER = /* glsl */ `
uniform float iTime;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform vec3 uColor3;
varying vec2 vUv;
varying vec3 vPosition;
${NOISE_FUNCTIONS}
// 砂利用ノイズ関数
float gravel(vec2 uv, float scale) {
float n1 = noise(uv * scale);
float n2 = noise(uv * scale * 2.0 + vec2(5.2,1.3));
float n3 = noise(uv * scale * 4.0 + vec2(9.7,8.1));
float sn = pow(n1,2.0)*0.6 + pow(n2,3.0)*0.3 + pow(n3,4.0)*0.1;
return smoothstep(0.2,0.8,sn);
}
float isPath(vec2 p, float w) {
float main = step(abs(p.x), w);
float cross = step(abs(p.y), w*0.8);
float plaza = step(length(p), w*1.5);
return max(max(main,cross), plaza);
}
void main() {
vec2 wp = vec2(vPosition.x, vPosition.z);
float pw = 2.0;
float onPath = isPath(wp, pw);
float edge = isPath(wp, pw+0.2) - onPath;
vec2 uv = vUv * 20.0;
float f1 = fbm(uv);
float bump = fbm(uv*3.0)*0.15;
float sg = gravel(vUv*30.0,8.0)*0.5 + gravel(vUv*15.0,4.0)*0.3 + gravel(vUv*7.5,2.0)*0.2;
sg = pow(sg,1.5);
vec3 base = mix(uColor1, uColor2, f1*0.5);
vec3 gBase = vec3(0.65,0.62,0.57);
vec3 gHigh = vec3(0.80,0.77,0.70);
vec3 gLow = vec3(0.50,0.48,0.45);
float cn = fbm(uv*5.0);
vec3 gCol = mix(gLow, gHigh, sg);
gCol = mix(gCol, gBase, 0.6);
gCol = mix(gCol, gCol*(0.9+cn*0.2),0.8);
vec3 eCol = vec3(0.45,0.45,0.45);
base = mix(base, gCol, onPath);
base = mix(base, eCol, edge);
base *= 0.9 + bump*0.3;
gl_FragColor = vec4(base,1.0);
}
`
解説:
- GLSL コードは文字列リテラルでまとめ、React コンポーネントから参照します。
NOISE_FUNCTIONS
はどちらのシェーダーにも挿入して使います。
JavaScript 側で使うノイズ生成クラスを実装します。草の分布に利用します。
export class SimplexNoise {
constructor(r) {
const random = r || Math
this.grad3 = [
[1, 1, 0],
[-1, 1, 0],
[1, -1, 0],
[-1, -1, 0],
[1, 0, 1],
[-1, 0, 1],
[1, 0, -1],
[-1, 0, -1],
[0, 1, 1],
[0, -1, 1],
[0, 1, -1],
[0, -1, -1],
]
this.p = []
for (let i = 0; i < 256; i++) {
this.p[i] = Math.floor(random.random() * 256)
}
this.perm = []
for (let i = 0; i < 512; i++) {
this.perm[i] = this.p[i & 255]
}
}
dot(g, x, y) {
return g[0] * x + g[1] * y
}
noise(xin, yin) {
const F2 = 0.5 * (Math.sqrt(3.0) - 1.0)
const s = (xin + yin) * F2
const i = Math.floor(xin + s)
const j = Math.floor(yin + s)
const G2 = (3.0 - Math.sqrt(3.0)) / 6.0
const t = (i + j) * G2
const X0 = i - t
const Y0 = j - t
const x0 = xin - X0
const y0 = yin - Y0
let i1
let j1
if (x0 > y0) {
i1 = 1
j1 = 0
} else {
i1 = 0
j1 = 1
}
const x1 = x0 - i1 + G2
const y1 = y0 - j1 + G2
const x2 = x0 - 1.0 + 2.0 * G2
const y2 = y0 - 1.0 + 2.0 * G2
const ii = i & 255
const jj = j & 255
const gi0 = this.perm[ii + this.perm[jj]] % 12
const gi1 = this.perm[ii + i1 + this.perm[jj + j1]] % 12
const gi2 = this.perm[ii + 1 + this.perm[jj + 1]] % 12
let n0
let n1
let n2
let t0 = 0.5 - x0 * x0 - y0 * y0
if (t0 < 0) n0 = 0.0
else {
t0 *= t0
n0 = t0 * t0 * this.dot(this.grad3[gi0], x0, y0)
}
let t1 = 0.5 - x1 * x1 - y1 * y1
if (t1 < 0) n1 = 0.0
else {
t1 *= t1
n1 = t1 * t1 * this.dot(this.grad3[gi1], x1, y1)
}
let t2 = 0.5 - x2 * x2 - y2 * y2
if (t2 < 0) n2 = 0.0
else {
t2 *= t2
n2 = t2 * t2 * this.dot(this.grad3[gi2], x2, y2)
}
return 70.0 * (n0 + n1 + n2)
}
}
解説:
- 2D Simplex ノイズを実装。返り値は [-1,1] の範囲です。
r.random()
を外部から渡せるので、テスト時に乱数を固定できます。
ジオメトリ生成や地形判定に使う汎用関数をまとめます。
import * as THREE from "three"
import {
BLADE_WIDTH,
BLADE_HEIGHT,
BLADE_HEIGHT_VARIATION,
PLANE_SIZE,
} from "../constants/grassConstants"
import { SimplexNoise } from "./SimplexNoise"
// 値を別のレンジに線形変換
export const convertRange = (val, oldMin, oldMax, newMin, newMax) =>
((val - oldMin) * (newMax - newMin)) / (oldMax - oldMin) + newMin
// 草の一枚を構築する関数
export const generateBlade = (center, vOffset, uv) => {
const MID = BLADE_WIDTH * 0.5
const height = BLADE_HEIGHT + Math.random() * BLADE_HEIGHT_VARIATION
// ランダムに回転を加えて自然に
const bias = Math.atan2(center.z, center.x)
const yaw = bias + (Math.random() - 0.5) * Math.PI * 0.5
const dir = new THREE.Vector3(Math.sin(yaw), 0, -Math.cos(yaw))
const tipYaw = yaw + (Math.random() - 0.5) * Math.PI * 0.25
const tipDir = new THREE.Vector3(Math.sin(tipYaw), 0, -Math.cos(tipYaw))
// 頂点位置を計算
const bl = center.clone().add(dir.clone().multiplyScalar(BLADE_WIDTH / 2))
const br = center.clone().add(dir.clone().multiplyScalar(-BLADE_WIDTH / 2))
const tl = center.clone().add(dir.clone().multiplyScalar(MID / 2))
const tr = center.clone().add(dir.clone().multiplyScalar(-MID / 2))
const tc = center.clone().add(tipDir.clone().multiplyScalar(0.1))
tl.y += height / 2
tr.y += height / 2
tc.y += height
// カラー(根元=黒、中間=灰、先端=白)
const black = [0, 0, 0]
const gray = [0.5, 0.5, 0.5]
const white = [1, 1, 1]
const verts = [
{ pos: bl.toArray(), uv, color: black },
{ pos: br.toArray(), uv, color: black },
{ pos: tr.toArray(), uv, color: gray },
{ pos: tl.toArray(), uv, color: gray },
{ pos: tc.toArray(), uv, color: white },
]
// インデックス
const inds = [
vOffset,
vOffset + 1,
vOffset + 2,
vOffset + 2,
vOffset + 4,
vOffset + 3,
vOffset + 3,
vOffset,
vOffset + 2,
]
return { verts, indices: inds }
}
// バイリニア補間で地形の高さ取得
export const getTerrainHeight = (x, z, data) => {
if (!data) return 0
const gx = Math.max(
0,
Math.min(data.nsubdivs, (x / data.scale.x + 0.5) * data.nsubdivs)
)
const gz = Math.max(
0,
Math.min(data.nsubdivs, (z / data.scale.z + 0.5) * data.nsubdivs)
)
const x0 = Math.floor(gx)
const z0 = Math.floor(gz)
const x1 = Math.min(x0 + 1, data.nsubdivs)
const z1 = Math.min(z0 + 1, data.nsubdivs)
const wx = gx - x0
const wz = gz - z0
const h00 = data.heights[x0 * (data.nsubdivs + 1) + z0] * data.scale.y
const h10 = data.heights[x1 * (data.nsubdivs + 1) + z0] * data.scale.y
const h01 = data.heights[x0 * (data.nsubdivs + 1) + z1] * data.scale.y
const h11 = data.heights[x1 * (data.nsubdivs + 1) + z1] * data.scale.y
const h0 = h00 * (1 - wx) + h10 * wx
const h1 = h01 * (1 - wx) + h11 * wx
return h0 * (1 - wz) + h1 * wz
}
// 道路判定
export const isOnPath = (x, z, width = 2.0) => {
const main = Math.abs(x) < width
const cross = Math.abs(z) < width * 0.8
const plaza = Math.sqrt(x * x + z * z) < width * 1.5
return main || cross || plaza
}
解説:
generateBlade
で頂点座標と色、UV、インデックスを組み立てるgetTerrainHeight
で四隅からバイリニア補間して高さを取得isOnPath
で座標が道上か判定
草全体を描画し、毎フレームシェーダーを更新します。
import React, { useRef, useMemo } from "react"
import { useFrame } from "@react-three/fiber"
import * as THREE from "three"
import { SimplexNoise } from "../utils/SimplexNoise"
import { BLADE_COUNT, PLANE_SIZE } from "../constants/grassConstants"
import {
GRASS_VERTEX_SHADER,
GRASS_FRAGMENT_SHADER,
} from "../shaders/grassShaders"
import {
convertRange,
generateBlade,
getTerrainHeight,
isOnPath,
} from "../utils/grassHelpers"
export const GrassField = ({ terrainData, playerRef }) => {
const meshRef = useRef()
const startTime = useRef(Date.now())
// ──── シェーダー uniforms 定義 ────
const uniforms = useMemo(
() => ({
iTime: { value: 0 },
playerPosition: { value: new THREE.Vector3() },
uColor1: { value: new THREE.Color("#4F7942") },
uColor2: { value: new THREE.Color("#88B04B") },
uColor3: { value: new THREE.Color("#5F8575") },
}),
[]
)
// 毎フレーム uniforms を更新
useFrame(() => {
uniforms.iTime.value = (Date.now() - startTime.current) / 1000
if (playerRef.current) {
const pos = playerRef.current.translation()
uniforms.playerPosition.value.set(pos.x, pos.y, pos.z)
}
})
// ──── 草のジオメトリ生成 ────
const geometry = useMemo(() => {
if (!terrainData) return null
const positions = []
const uvs = []
const colors = []
const indices = []
const grid = Math.sqrt(BLADE_COUNT)
const cell = PLANE_SIZE / grid
const noiseGen = new SimplexNoise()
for (let i = 0; i < grid; i++) {
for (let j = 0; j < grid; j++) {
const baseX = (i / grid - 0.5) * PLANE_SIZE
const baseZ = (j / grid - 0.5) * PLANE_SIZE
const rx = (Math.random() - 0.5) * cell * 0.8
const rz = (Math.random() - 0.5) * cell * 0.8
const x = baseX + rx
const z = baseZ + rz
// 草生えるか判定
const nv = (noiseGen.noise(x * 0.05, z * 0.05) + 1) * 0.5
if (nv > 0.5 && !isOnPath(x, z)) {
const y = getTerrainHeight(x, z, terrainData)
const center = new THREE.Vector3(x, y, z)
const uv = [
convertRange(x, -PLANE_SIZE / 2, PLANE_SIZE / 2, 0, 1),
convertRange(z, -PLANE_SIZE / 2, PLANE_SIZE / 2, 0, 1),
]
const blade = generateBlade(center, positions.length / 3, uv)
for (const v of blade.verts) {
positions.push(...v.pos)
uvs.push(...v.uv)
colors.push(...v.color)
}
for (const idx of blade.indices) {
indices.push(idx)
}
}
}
}
const geo = new THREE.BufferGeometry()
geo.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(positions), 3)
)
geo.setAttribute("uv", new THREE.BufferAttribute(new Float32Array(uvs), 2))
geo.setAttribute(
"color",
new THREE.BufferAttribute(new Float32Array(colors), 3)
)
geo.setIndex(indices)
geo.computeVertexNormals()
return geo
}, [terrainData])
// ──── シェーダーマテリアル生成 ────
const material = useMemo(
() =>
new THREE.ShaderMaterial({
uniforms,
vertexShader: GRASS_VERTEX_SHADER,
fragmentShader: GRASS_FRAGMENT_SHADER,
vertexColors: true,
side: THREE.DoubleSide,
transparent: true,
}),
[uniforms]
)
if (!geometry) return null
return <mesh ref={meshRef} geometry={geometry} material={material} />
}
解説:
useMemo
でジオメトリを一度だけ生成し、レンダリング負荷を抑えます。useFrame
でiTime
とplayerPosition
を毎フレーム更新。BufferGeometry
に位置・UV・色・インデックスをまとめて渡しています。
地面メッシュ、コライダー、GrassField の呼び出しをまとめます。
import React, { useRef, useMemo } from "react"
import { useFrame } from "@react-three/fiber"
import * as THREE from "three"
import { RigidBody, HeightfieldCollider } from "@react-three/rapier"
import { TERRAIN_NSUBDIVS, TERRAIN_SCALE } from "../constants/grassConstants"
import {
TERRAIN_VERTEX_SHADER,
TERRAIN_FRAGMENT_SHADER,
} from "../shaders/grassShaders"
import { GrassField } from "./GrassField"
export const Terrain = ({ playerRef }) => {
const meshRef = useRef()
const startTime = useRef(Date.now())
const uniforms = useMemo(
() => ({
iTime: { value: 0 },
uColor1: { value: new THREE.Color("#826F66") },
uColor2: { value: new THREE.Color("#A99B98") },
uColor3: { value: new THREE.Color("#9B7653") },
}),
[]
)
useFrame(() => {
uniforms.iTime.value = (Date.now() - startTime.current) / 1000
})
// 平面ジオメトリ
const geometry = useMemo(() => {
const g = new THREE.PlaneGeometry(TERRAIN_SCALE.x, TERRAIN_SCALE.z, 32, 32)
g.rotateX(-Math.PI / 2)
return g
}, [])
// 高度データ
const heights = useMemo(
() =>
new Float32Array((TERRAIN_NSUBDIVS + 1) * (TERRAIN_NSUBDIVS + 1)).fill(0),
[]
)
const terrainData = useMemo(
() => ({
heights: Array.from(heights),
scale: TERRAIN_SCALE,
nsubdivs: TERRAIN_NSUBDIVS,
}),
[heights]
)
return (
<RigidBody type="fixed" colliders={false} friction={1} name="ground">
<mesh
ref={meshRef}
geometry={geometry}
material={
new THREE.ShaderMaterial({
uniforms,
vertexShader: TERRAIN_VERTEX_SHADER,
fragmentShader: TERRAIN_FRAGMENT_SHADER,
side: THREE.DoubleSide,
})
}
receiveShadow
/>
<HeightfieldCollider
args={[TERRAIN_NSUBDIVS, TERRAIN_NSUBDIVS, heights, TERRAIN_SCALE]}
restitution={0.2}
/>
<GrassField terrainData={terrainData} playerRef={playerRef} />
</RigidBody>
)
}
解説:
HeightfieldCollider
は高さマップで物理地形を設定するコンポーネントです。GrassField
にterrainData
とplayerRef
を渡し、草生成と揺らしを連携させます。
赤い箱を動かすプレイヤーです。一定間隔で目標を変更し、Rapier 物理で移動させます。
import React, { useRef } from "react"
import { useFrame } from "@react-three/fiber"
import { RigidBody } from "@react-three/rapier"
import { PLANE_SIZE } from "../constants/grassConstants"
export const Player = ({ playerRef }) => {
const meshRef = useRef()
const target = useRef({ x: 0, z: 0 })
const speed = useRef(0.15)
const lastChange = useRef(Date.now())
useFrame(() => {
if (!playerRef.current) return
const pos = playerRef.current.translation()
const now = Date.now()
if (now - lastChange.current > 5000) {
target.current = {
x: (Math.random() * 2 - 1) * (PLANE_SIZE / 2 - 2),
z: (Math.random() * 2 - 1) * (PLANE_SIZE / 2 - 2),
}
speed.current = 0.1 + Math.random() * 0.15
lastChange.current = now
}
const dx = target.current.x - pos.x
const dz = target.current.z - pos.z
const dist = Math.hypot(dx, dz)
if (dist < 0.5) return
const vx = (dx / dist) * speed.current * 20
const vz = (dz / dist) * speed.current * 20
playerRef.current.setLinvel({ x: vx, y: 0, z: vz }, true)
if (meshRef.current) meshRef.current.rotation.y = Math.atan2(dx, dz)
})
return (
<RigidBody
ref={playerRef}
colliders="cuboid"
mass={10}
position={[0, 0.5, 0]}
type="dynamic"
linearDamping={1}
angularDamping={0.5}
restitution={0.2}
friction={0.5}
>
<mesh ref={meshRef} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#e74c3c" />
</mesh>
</RigidBody>
)
}
解説:
- 5秒ごとにランダムな目標を設定し、Rapier の
setLinvel
で線速度を制御します。- 箱モデルを回転させることで進行方向を向かせています。
最後に全コンポーネントを組み合わせてレンダリングします。
import React, { useRef } from "react"
import { Canvas } from "@react-three/fiber"
import { Stats, Environment, OrbitControls } from "@react-three/drei"
import { Physics } from "@react-three/rapier"
import { Terrain } from "./components/Terrain"
import { Player } from "./components/Player"
function App() {
const playerRef = useRef()
return (
<Canvas camera={{ position: [0, 10, 20] }} shadows>
<Stats />
<color attach="background" args={["#17181f"]} />
<Environment preset="city" />
<OrbitControls />
<Physics>
<Terrain playerRef={playerRef} />
<Player playerRef={playerRef} />
</Physics>
</Canvas>
)
}
export default App
解説:
<Canvas shadows>
でシャドウ有効化。<mesh castShadow>
/<mesh receiveShadow>
を各所で設定。<Environment>
でリアルな環境光と背景マップを追加。<Stats>
で FPS を表示し、パフォーマンスチェック。
道と地面がシェーダーによって生成され、プロシージャルで草原が作成されます。
プレイヤーの赤い箱が走り回り、近づくと草が揺れます。
さらにパラメータを調整すると、より多彩な表現が可能になります。