logo
HOMEABOUTBLOGCONTACT
© 2025 Haruyasu Kaitori All Rights Reserved.
プロシージャル草原&地形シェーダーチュートリアル(上級)
2025/04/19
thumbnail
Shader

このチュートリアルでは、React Three Fiber と Rapier 物理エンジンを使って、

  • 広大な草原(50m 四方)をプロシージャルに生成し
  • プレイヤーが草を揺らしながらランダムに動き回る
  • 地形には砂利道や中央広場をシェーダーで表現

といったシーンを一から実装します。


プロジェクト構成

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

1. 環境準備

まずは 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/ フォルダにファイルを配置していきます。


2. 定数定義

草原や地形の大きさなど、変更しやすいパラメータをまとめます。

src/constants/grassConstant
// ────────── 草原のレンダリングに関する定数 ──────────
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 を変えるとシーン全体のスケールが変わります。
  • 草の枚数を増やすと見た目は濃くなりますが、描画負荷も上がるので注意しましょう。

3. シェーダーコード定義

草と地形それぞれの頂点・フラグメントシェーダーを用意します。共通のノイズ関数もここにまとめます。

src/shaders/grassShaders.js
// ───── 共通ノイズ関数(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 はどちらのシェーダーにも挿入して使います。

4. SimplexNoise クラス

JavaScript 側で使うノイズ生成クラスを実装します。草の分布に利用します。

src/utils/SimplexNoise.js
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() を外部から渡せるので、テスト時に乱数を固定できます。

5. ヘルパー関数

ジオメトリ生成や地形判定に使う汎用関数をまとめます。

src/utils/grassHelpers.js

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 で座標が道上か判定

6. GrassField コンポーネント

草全体を描画し、毎フレームシェーダーを更新します。

src/components/GrassField.jsx
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} />
}

解説:

  1. useMemo でジオメトリを一度だけ生成し、レンダリング負荷を抑えます。
  2. useFrame で iTime と playerPosition を毎フレーム更新。
  3. BufferGeometry に位置・UV・色・インデックスをまとめて渡しています。

7. Terrain コンポーネント

地面メッシュ、コライダー、GrassField の呼び出しをまとめます。

src/components/Terrain.jsx
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 を渡し、草生成と揺らしを連携させます。

8. Player コンポーネント

赤い箱を動かすプレイヤーです。一定間隔で目標を変更し、Rapier 物理で移動させます。

src/components/Player.jsx
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 で線速度を制御します。
  • 箱モデルを回転させることで進行方向を向かせています。

9. メインアプリケーション

最後に全コンポーネントを組み合わせてレンダリングします。

src/App.jsx
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 を表示し、パフォーマンスチェック。

10. 動作確認

道と地面がシェーダーによって生成され、プロシージャルで草原が作成されます。

プレイヤーの赤い箱が走り回り、近づくと草が揺れます。

さらにパラメータを調整すると、より多彩な表現が可能になります。

関連記事
thumbnail
Shader
シェーダーチュートリアル(初級編)
2025/03/31
カテゴリ
Loading...
アーカイブ
Loading...
オススメ
Three.js Journey
Wawasensei