logo
HOMEABOUTBLOGCONTACT
© 2025 Haruyasu Kaitori All Rights Reserved.
シェーダーチュートリアル(初級編)
2025/03/31
thumbnail
Shader

GLSLシェーダーを用いて地面を表現するカスタムシェーダーを、5つのステップに分けてチュートリアル形式で解説します。

各ステップで新しい機能を実装し、その意図やシェーダーの基礎知識を説明しながら、実際のシェーダーコード(vertexShader / fragmentShader)および対応するコード例を示します。

ステップを追うごとに徐々に機能を追加し、最終的にはノイズを用いた模様とライティングによる凹凸表現を持つ完成した地面シェーダーを作成します。

各ステップの構成は以下の通りです。読者はGLSL初心者でも理解できるように、概念から丁寧に説明していきます。

ステップ1: 単色で描画する基本GLSLシェーダー

実装内容: 最も基本的なGLSLシェーダーを使い、平面オブジェクトを単一の色で描画します。

Three.jsでカスタムシェーダー(ShaderMaterial)を使用する方法と、頂点シェーダー・フラグメントシェーダーの役割を理解します。まずはシンプルに何も模様のない単色で描画し、シェーダーのパイプラインを確認します。

解説

Three.jsではShaderMaterialを使うことで独自のGLSLシェーダーコードをオブジェクトに適用できます。

シェーダーは頂点シェーダー(vertex shader)とフラグメントシェーダー(fragment shader)の2つから成り、3D空間の頂点の位置計算とピクセルの色計算をそれぞれ担当します。

  • 頂点シェーダーは、各頂点の座標に対し実行され、頂点を画面上どこに描画するか計算します。
    通常、gl_Positionという組み込み変数にクリップ空間での頂点位置を設定します。
    Three.jsからmodelViewMatrixやprojectionMatrixなどの行列が渡されてくるので、これらを用いてモデル座標をクリップ座標に変換します。
  • フラグメントシェーダーは、ラスタライズされた各ピクセル(画素)ごとに実行され、そのピクセルの最終的な色(RGBA)を計算します。gl_FragColorに色を出力します。

まずはこれらを最低限使用し、平面を単色で塗りつぶすシェーダーを書いてみましょう。

コード

GLSLシェーダーコード(ステップ1): 頂点シェーダーでは位置を変換し、フラグメントシェーダーでは固定の色を返すようにします。

App.jsx
import { Environment, OrbitControls, Stats } from "@react-three/drei"
import { Canvas } from "@react-three/fiber"

// 頂点シェーダー
const vertexShader = /* glsl */ `
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`

// フラグメントシェーダー
const fragmentShader = /* glsl */ `
  void main() {
    gl_FragColor = vec4(0.4, 0.5, 0.2, 1.0);
  }
`

上記ではgl_FragColor = vec4(0.4, 0.5, 0.2, 1.0)とベクトルリテラルで色を直接指定しています。

値は順に(R,G,B,A)で0.0~1.0の範囲です。この例では赤40%、緑50%、青20%の不透明色になり、ややオリーブグリーン寄りの茶色になります。

地面の土台のような色合いです。もちろん、お好みで色は変更して構いません(後のステップでuniformを使って外部から色を設定する方法も紹介します)。

Three.js側のコード: ShaderMaterialを使って上記シェーダーを適用する平面メッシュを作成します。

以下はReact Three Fiberを使用したコード例です。

App.jsx
const Ground = () => {
  return (
    <mesh rotation-x={-Math.PI / 2}>
      <planeGeometry args={[10, 10, 32, 32]} />
      <shaderMaterial
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
      />
    </mesh>
  )
}

function App() {
  return (
    <Canvas
      camera={{
        fov: 45,
        position: [0, 10, -20],
      }}
    >
      <Stats />
      <color attach="background" args={["#17181f"]} />
      <Environment preset="city" />
      <OrbitControls />
      <Ground />
    </Canvas>
  )
}

export default App

ステップ2: UV座標を使ったグラデーション表現

実装内容: 平面のUV座標に基づいて色を変化させ、単色だった地面に2色のグラデーションを付けます。

UV座標とは何かを理解し、シェーダーで位置によって異なる出力をする方法を学びます。

地面全体を一様な色ではなく場所によって変わる色にする基礎として、線形グラデーションを導入します。

解説

UV座標とは、ジオメトリの各頂点に割り当てられた2次元の座標で、通常テクスチャマッピングに使われます。

各頂点には(u,v)の値があり、典型的にはジオメトリ全体で0~1の範囲に正規化されています。

PlaneGeometryの場合、四隅のUVは(0,0), (1,0), (0,1), (1,1)となっており、平面上で線形に補間されます。

フラグメントシェーダーでは、頂点シェーダーから補間されたUV値(各ピクセル位置のuv)が得られます。

このUVを利用して場所によって色を変えることができます。

例えば、v座標 (vUv.y) を使って下から上に色を変える縦グラデーションを作ることが可能です。

具体的には、vUv.yが0の下端では色A、vUv.yが1の上端では色B、中間では線形に混ざった色になるように補間します。

コード

ステップ1のシェーダーを拡張してUVによるグラデーションを実装します。

頂点シェーダーで各頂点のUVをそのままフラグメントに渡し、フラグメントシェーダーでその値に応じて2色を混ぜます。

App.jsx
// 頂点シェーダー
const vertexShader = /* glsl */ `
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`

// フラグメントシェーダー
const fragmentShader = /* glsl */ `
  varying vec2 vUv;

  void main() {
    // 下端(茶色)と上端(緑色)を線形に補間
    vec3 colorBottom = vec3(0.4, 0.3, 0.2);
    vec3 colorTop    = vec3(0.1, 0.6, 0.1);
    vec3 blendedColor = mix(colorBottom, colorTop, vUv.y);
    gl_FragColor = vec4(blendedColor, 1.0);
  }
`

頂点シェーダーでuv;で受け取ったUVをvUvというvarying変数にコピーし、フラグメントシェーダーで受け取っています。

varyingは頂点からフラグメントへ値を渡す手段で、各ピクセルで自動的に補間される点が重要です。

これにより、平面上の任意の点でのvUvが0~1の値として計算されます。

フラグメントシェーダーではmix(colorBottom, colorTop, vUv.y)により、vUv.y=0のときcolorBottom、vUv.y=1のときcolorTopとなり、その間は線形グラデーションになります。

ここでは下方向を茶色、上方向を緑としました。カメラの配置によりますが、例えばカメラが地面に対して斜めに配置されていれば、手前が茶色、遠方が緑色っぽく見える地面になるでしょう。

色は見やすいように極端に設定していますが、茶色と緑の中間のオリーブ色も自然な地面の一部として機能します。

補足: グラデーションに使う2色は、地面の表現したい雰囲気に合わせて選びます。自然な地面なら茶色〜緑、あるいは砂地なら薄いベージュ〜茶、夕焼け照明下ならオレンジ寄りのトーンなど、シーンに応じて変えられます。

今回は緑と茶で「下は土、上に行くほど草が生えている」ようなニュアンスを持たせました。

このステップで、シェーダー内で場所(UV)に応じて色を変えることができるようになりました。

次のステップでは、このグラデーションでは表現できないより複雑な模様を作り出すために「ノイズ」を導入します。

ステップ3: ノイズ関数の追加(乱数とフラクタルノイズ)

実装内容: シェーダー内でノイズ関数を実装し、地面にランダムな変化を与えます。

具体的には、疑似乱数を生成するハッシュ関数、滑らかな2次元ノイズ関数、そしてそれを拡張したフラクタル・ブラウン運動 (fBM) ノイズを導入します。

ノイズ値を用いて地面にまだら模様を作る基礎を作ります。

GLSLでの乱数生成方法とノイズの概念を理解します。

フラクタルノイズにより単純なグラデーションでは得られない自然なパターン(雲状の模様や地形の起伏の元になるパターン)を生成できるようにします。

解説

ノイズとは、一見ランダムだが連続的な変化を持つ値のことです。

CGにおけるノイズ関数は、入力座標に対して擬似乱数的な値を返しつつ、空間的にスムーズに変化する特徴があります。

代表的なノイズとしてPerlinノイズやSimplexノイズがありますが、ここでは理解しやすいバリューノイズ(value noise)を自前で実装してみます。

  • 空間(2次元平面)を格子状のセルに区切り、格子の各頂点にランダムな値を割り当てます(これをハッシュ関数で実現)。
  • 与えられた座標が属するセルの4つの頂点の乱数値を取り出し、座標のセル内での相対位置(0~1の小数部分)に基づいてこれら4値を双線形補間します。特に補間には滑らかに立ち上がるカーブ(スムースステップ関数)を用い、値と勾配が連続になるようにします。

この結果、空間上で連続的だが予測不能なパターンを持つ値が得られます。

ノイズ関数noise(st)を使うと、例えば近接した2点では似た値、遠く離れた2点では相関のない値を返すようになります。

さらにフラクタル・ブラウン運動(fBM)とは、ノイズを複数スケールで重ね合わせたものです。

具体的には、ノイズ関数を尺度を変えて何度も呼び、その結果を足し合わせます(周波数を倍々に高く、振幅を半々に低くして重ねるのが典型です)。

これにより大小様々なスケールの起伏を持つ複雑な模様が得られます。

地形の高さや雲の模様など、自然界のフラクタルなパターンを擬似的に再現できます。

コード

まずGLSLで2次元の乱数値を生成するハッシュ関数と、それを利用したノイズ関数を実装します。

さらにfBM関数も用意します。

ステップ2のフラグメントシェーダーを置き換える形で、新たにノイズを計算してみましょう。

App.jsx
// 頂点シェーダー
const vertexShader = /* glsl */ `
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`

// フラグメントシェーダー
const fragmentShader = /* glsl */ `
  varying vec2 vUv;

  // ハッシュ関数:2D入力から疑似乱数を生成
  float rand(vec2 st) {
    return fract(sin(dot(st, vec2(12.9898,78.233))) * 43758.5453123);
  }

   // 2D ノイズ関数(バリューノイズ)
  float noise(vec2 st) {
    vec2 i = floor(st);
    vec2 f = fract(st);
    float a = rand(i);
    float b = rand(i + vec2(1.0, 0.0));
    float c = rand(i + vec2(0.0, 1.0));
    float d = rand(i + vec2(1.0, 1.0));
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
  }
  
  // フラクタル・ブラウン運動 (fBM) でノイズを重ね合わせ
  float fbm(vec2 st) {
    float value = 0.0;
    float amplitude = 0.5;
    for (int i = 0; i < 5; i++) {
      value += amplitude * noise(st);
      st *= 2.0;
      amplitude *= 0.5;
    }
    return value;
  }

  void main() {
    // uv 座標にスケールを掛けてノイズ計算
    float n = fbm(vUv * 5.0);
    gl_FragColor = vec4(vec3(n), 1.0);
  }
`

上記コードでは、まずrand(st)で0~1の擬似乱数を返す関数を定義しています。

sin(dot(st, ...))という形で入力座標に基づき一見ランダムな値を算出し、fract(...)で0~1範囲に正規化しています。

この方法で再現性のある乱数が得られます。

次にnoise(st)でバリューノイズを実装しています。

i = floor(st)で左下の格子頂点、i+vec2(1,0)右下、i+vec2(0,1)左上、i+vec2(1,1)右上の4点を求め、それぞれrand()で乱数値を取得します。

それらをu = f*f*(3-2*f)というHermite曲線(滑らかに0→1へ遷移する補間係数)を使って補間します。

結果として、st周辺で連続した変化を持つ乱数値が得られます。

最後にfbm(st)ではnoise(st)を5回呼び出し、座標を倍にしつつ振幅を半分にして積算しています。

このループにより、大きなうねりから細かなざらつきまで含んだ複雑なノイズnが得られます。

白に近い部分ほどノイズ値が高く、黒い部分は低い値を示しています。

滑らかに濃淡が変化しつつもランダム性があり、雲のような模様が確認できます。

このパターンが地面の色ムラや高さの起伏の下地となります。

ノイズを導入したこの段階では、地面はまだモノクロの雲模様ですが、次のステップでこのノイズ値を使って複数の色をブレンドし、よりそれらしい地面の色パターンを作ります。

ステップ4: ノイズを用いた複数の色のブレンド

実装内容: ノイズで得られた値を利用して複数の色をブレンドし、地面にまだら模様のカラーを付けます。

例えば、低地は土の茶色、高地は草の緑色、中間に別の色を混ぜる、といった表現が可能になります。

ノイズ値をマスクや補間係数として利用し、複数の色を滑らかに切り替える方法を学びます。

地面に自然なカラーバリエーションを与え、単調さを無くします。

解説

前ステップでノイズ値n(0~1の値)が得られました。これを使って色を決定します。

例えば2色の場合、mix(colorA, colorB, n)とするだけでノイズに応じた中間色を含むブレンドが可能です。

ただし、3色以上を使いたい場合は工夫が必要です。単純に閾値で場合分けすると境界が不自然に見えることがあるため、smoothstepを用いて滑らかに遷移させます。

ここでは例として3色(茶色・緑・薄茶)を使い、ノイズ値が低い領域を茶色、中間を緑、高い領域を明るい土色とします。

それぞれの間はsmoothstepでブレンドし、段差のないグラデーションで色が変化するようにします。

ノイズ値の分布にもよりますが、n<0.5を「低〜中」、n>=0.5を「中〜高」として2段階で判定し、それぞれの範囲内で色を補間する方法を取ります。

具体的には:

  • 低~中域 (n ≈ 0~0.5): 茶色 (ColorA) と 緑色 (ColorB) をブレンド。smoothstep(0.0, 0.5, n)を係数に用い、n=0付近では茶、n=0.5付近で緑寄りになるようにする。
  • 中~高域 (n ≈ 0.5~1.0): 緑色 (ColorB) と 薄茶色 (ColorC) をブレンド。smoothstep(0.5, 1.0, n)を係数に用い、n=0.5で緑、n=1.0で薄茶になるようにする。

smoothstep関数を使うことで、ちょうどn=0.5付近で色B(緑)にスムーズにつながり、3色間の移行が滑らかになります。

コード

フラグメントシェーダーでノイズ値nをもとに色を決定する処理を追加します。ステップ3のシェーダーに続けて、nを計算した後のmain部分を以下のように変更します。

App.jsx
// 頂点シェーダー
const vertexShader = /* glsl */ `
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`

// フラグメントシェーダー
const fragmentShader = /* glsl */ `
  varying vec2 vUv;

  // ハッシュ関数:2D入力から疑似乱数を生成
  float rand(vec2 st) {
    return fract(sin(dot(st, vec2(12.9898,78.233))) * 43758.5453123);
  }

   // 2D ノイズ関数(バリューノイズ)
  float noise(vec2 st) {
    vec2 i = floor(st);
    vec2 f = fract(st);
    float a = rand(i);
    float b = rand(i + vec2(1.0, 0.0));
    float c = rand(i + vec2(0.0, 1.0));
    float d = rand(i + vec2(1.0, 1.0));
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
  }
  
  // フラクタル・ブラウン運動 (fBM) でノイズを重ね合わせ
  float fbm(vec2 st) {
    float value = 0.0;
    float amplitude = 0.5;
    for (int i = 0; i < 5; i++) {
      value += amplitude * noise(st);
      st *= 2.0;
      amplitude *= 0.5;
    }
    return value;
  }

  void main() {
    float n = fbm(vUv * 5.0);
    // 3 色の定義
    vec3 colorA = vec3(0.4, 0.3, 0.2); // 土色
    vec3 colorB = vec3(0.1, 0.6, 0.1); // 草色
    vec3 colorC = vec3(0.8, 0.7, 0.5); // 明るい土色
    vec3 finalColor;

    if(n < 0.5) {
      float t = smoothstep(0.0, 0.5, n);
      finalColor = mix(colorA, colorB, t);
    } else {
      float t = smoothstep(0.5, 1.0, n);
      finalColor = mix(colorB, colorC, t);
    }
    gl_FragColor = vec4(finalColor, 1.0);
  }
`

ここでは3つのvec3 colorXを定義し、それぞれ茶・緑・薄茶のRGBを設定しています。

コード中ではベタ書きしましたが、用途に応じてuniform vec3 uColorA;などとしてシーン側から渡すこともできます。

uniformにすれば後から色を動的に変更したりGUIで調整したりが容易になります。

Three.jsでuniformの色を渡すには、JSで例えばuColorA: { value: new THREE.Color(0.4, 0.3, 0.2) }のようにTHREE.Colorを使えます。

色のブレンド部分ではifで2つの範囲に分けています。

それぞれsmoothstepを使うことで、境界付近(n≈0.5)でも滑らかにつながるようになっています。

結果、ノイズ値に対応して3色のカラーパレットから補間された色が得られます。

低いノイズ値の所は暗い茶色、中間は緑、高い所は明るい土色となり、地面に複雑な色むらが表現されます。

色選びのポイント: 自然な風景を想定するなら、あまり原色すぎると違和感があります。

例えば今回の茶色・緑・薄茶も、それぞれ少しグレーがかった落ち着いたトーンにしています。

緑も純粋な緑(0,1,0)ではなく0.6程度に抑え、茶系も赤みや黄みを混ぜて土や草の枯れ色っぽくしています。

ノイズで混ざり合うことで多彩な中間色が生まれますが、その調和を取るために元の色も馴染みやすいものにするのがコツです。

このステップのシェーダーを適用すると、地面にはランダムなまだら模様のカラーが付いているはずです。

茶色・緑・ベージュ系が混ざり合ったパターンは、草の生えた土の地面や乾いた地表のようにも見えるでしょう。

ただ、まだ見た目は平面的で照り返しもありません。最後のステップでは、この地面に凹凸感とライティングを加えて仕上げます。

ステップ5: 擬似的な凹凸とライティング効果の追加(エッジ強調付き)

実装内容: ノイズから擬似的な高さマップを想定し、法線を計算することで地面表面の凹凸を表現します。

さらに簡単なディレクショナルライティング(拡散反射)を導入し、陰影を付けます。

最後に、凹凸のエッジを少し強調する処理を加えて、地形の起伏が際立つようにします。

フラグメントシェーダーで法線ベクトルを計算し照明計算に利用する手法(簡易的なバンプマッピング)を学びます。

また、エッジの検出とハイライトによる視覚効果向上のテクニックを紹介します。最終的に、光源に照らされた立体的な地面描画を完成させます。

解説

擬似的な凹凸(バンプマッピング): 実際のジオメトリを変形せずに凹凸感を出すには、ピクセルごとの法線方向を変える手法が使えます。

既にノイズ値nを計算していますが、これは地面の「高さ」に見立てることができます。

そこで周囲との差分から傾きを求め、フラグメント毎の法線ベクトルを計算します。

具体的には、現在のUV座標における高さh、U方向にわずかにずらした点での高さh_x、V方向にずらした点での高さh_yを計算し、それらの差分から表面の傾きを推定します。

2つの方向の傾きを得たら、その両方向に沿う接線ベクトルの外積を取ることで新しい法線ベクトルが求められます。

ここでは平面のローカル空間で計算します。

平面が水平(XY平面が地面)で法線が+Zを向いていると仮定すると、UVのu増加方向をX軸、v増加方向をY軸、擬似高さをZ軸として微小平面を構築できます。

微小な差分δに対して:

  • 水平(U方向)の微小ベクトル: (δ, 0, h_x - h)
  • 垂直(V方向)の微小ベクトル: (0, δ, h_y - h)

この2つのベクトルの外積をとり正規化すると、その地点での擬似法線が得られます。

Three.jsのデフォルトでは地面法線をY軸(+Y)にしていましたが、ここでは平面をXYに仮定したため+Zが上になります。

実際にはモデル行列を使って適切に変換するか、最初から計算軸を調整する必要がありますが、話を単純にするため「平面が上向き(Z軸法線)」の前提で説明します(コード中ではモデル変換も適用して最終的に世界空間の法線で計算します)。

ライティング(拡散光): 計算した法線ベクトルと光源方向ベクトルの内積により、そのピクセルが光をどれだけ受けているか(Lambertの余弦則)を求めます。

diffuse = max(dot(normal, lightDir), 0.0)とすれば、光の当たっている面では値が大きく、陰になっている面では0になります。

これに任意の強度を掛け、環境光(ambient)を足して最終色に乗じれば、明るい部分・暗い部分が表現され立体感が出ます。

エッジ強調: 凹凸の「エッジ」とは、高低差が大きい部分です。

ノイズによる地形では、斜面が急なところを少し強調するとそれらしく見えます。

一つの方法は、法線と上向きベクトルとのなす角度に注目することです。

平らな部分では法線は真上を向きますが、傾斜が強い部分では法線が横に倒れます。

そこでedge = 1.0 - dot(normal, upVector)とすれば、水平面ではedge=0、垂直に近い斜面でedge=1になります。

この値を使ってそのピクセルの色を少し明るく(または暗く)することで、斜面にハイライトを入れられます。

今回はエッジを明るくハイライトすることで起伏を視覚的に強調してみます。

コード

ステップ4のシェーダーにさらなる改良を加えます。

法線計算とライティング計算のコードをフラグメントシェーダーに追加し、最終色を決定する部分を書き換えます。

App.jsx
import { Environment, OrbitControls, Stats } from "@react-three/drei"
import { Canvas } from "@react-three/fiber"
import { useRef } from "react"
import * as THREE from "three"

// 頂点シェーダー
const vertexShader = /* glsl */ `
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`

// フラグメントシェーダー
const fragmentShader = /* glsl */ `
  uniform mat3 normalMatrix;
  uniform vec3 uLightDir;  
  
  varying vec2 vUv;

  // ハッシュ関数:2D入力から疑似乱数を生成
  float rand(vec2 st) {
    return fract(sin(dot(st, vec2(12.9898,78.233))) * 43758.5453123);
  }

   // 2D ノイズ関数(バリューノイズ)
  float noise(vec2 st) {
    vec2 i = floor(st);
    vec2 f = fract(st);
    float a = rand(i);
    float b = rand(i + vec2(1.0, 0.0));
    float c = rand(i + vec2(0.0, 1.0));
    float d = rand(i + vec2(1.0, 1.0));
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
  }
  
  // フラクタル・ブラウン運動 (fBM) でノイズを重ね合わせ
  float fbm(vec2 st) {
    float value = 0.0;
    float amplitude = 0.5;
    for (int i = 0; i < 5; i++) {
      value += amplitude * noise(st);
      st *= 2.0;
      amplitude *= 0.5;
    }
    return value;
  }

  void main() {
    float n = fbm(vUv * 5.0);
    // 3 色の定義
    vec3 colorA = vec3(0.4, 0.3, 0.2);
    vec3 colorB = vec3(0.1, 0.6, 0.1);
    vec3 colorC = vec3(0.8, 0.7, 0.5);
    vec3 surfaceColor;

    if(n < 0.5) {
      float t = smoothstep(0.0, 0.5, n);
      surfaceColor = mix(colorA, colorB, t);
    } else {
      float t = smoothstep(0.5, 1.0, n);
      surfaceColor = mix(colorB, colorC, t);
    }
    
    // 擬似法線計算:ノイズ値を高さとして利用
    float h = n;
    float delta = 0.005;
    float hRight = fbm((vUv + vec2(delta, 0.0)) * 5.0);
    float hUp    = fbm((vUv + vec2(0.0, delta)) * 5.0);
    vec3 dx = vec3(delta, 0.0, hRight - h);
    vec3 dy = vec3(0.0, delta, hUp - h);
    vec3 localNormal = normalize(cross(dy, dx));
    vec3 worldNormal = normalize(normalMatrix * localNormal);
    
    // ライティング計算(Lambert拡散)
    float diff = max(dot(worldNormal, normalize(uLightDir)), 0.0);
    float ambient = 0.3;
    vec3 litColor = surfaceColor * (ambient + diff);
    
    // エッジ強調:上方向 (0,1,0) との内積を利用
    float edge = 1.0 - max(dot(worldNormal, vec3(0.0, 1.0, 0.0)), 0.0);
    vec3 finalColor = mix(litColor, vec3(1.0), edge * 0.2);
    
    gl_FragColor = vec4(finalColor, 1.0);
  }
`

const Ground = () => {
  const materialRef = useRef()

  // uLightDir を uniform として渡す(ここでは斜め上からの光)
  const uniforms = {
    uLightDir: { value: new THREE.Vector3(0.3, 1.0, 0.4).normalize() },
  }

  return (
    <mesh rotation-x={-Math.PI / 2}>
      <planeGeometry args={[10, 10, 32, 32]} />
      <shaderMaterial
        ref={materialRef}
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
        uniforms={uniforms}
      />
    </mesh>
  )
}

function App() {
  return (
    <Canvas
      camera={{
        fov: 45,
        position: [0, 10, -20],
      }}
    >
      <Stats />
      <color attach="background" args={["#17181f"]} />
      <Environment preset="city" />
      <OrbitControls />
      <Ground />
    </Canvas>
  )
}

export default App

コードが長くなりましたが、順を追って見ていきましょう。

まず、uniform mat3 normalMatrix;とuniform vec3 uLightDir;を宣言しています。

normalMatrixはThree.jsが自動で提供する法線変換行列で、モデル座標からワールド座標へ法線ベクトルを変換する3x3行列です(モデルビュー行列の逆転置行列)。

uLightDirはシーンから渡す光源の方向ベクトルです。

今回は単一の平行光源を想定し、例として(0.3, 1.0, 0.4)のような斜め上からの光を与えると良いでしょう(適宜シーンに合わせて調整)。

中盤、微小差分deltaを0.005と設定し、それだけUVをずらした位置での高さhRight, hUpを計算しています。

この差分は小さいほど法線計算は精密になりますが、極端に小さいと数値的に不安定になったりパフォーマンスが下がる可能性があります。

0.005~0.01程度が妥当でしょう。

また、これ以上の精度は本格的には微分を用いた法線計算(微分法)や高度マップからの法線マップ生成などが必要ですが、ここでは簡易な差分で代用しています。

dxとdyはそれぞれU方向(X軸方向)とV方向(Y軸方向)への接線ベクトルです。

cross(dy, dx)で外積を取る順序に注意してください。

ここではcross(dy, dx)とすることでローカルZ軸正方向(上向き)に揃えています。

もし逆にすると法線が下向きになるので、その場合は適宜符号を反転してください。

得られたlocalNormalはXY平面に対する法線です。

このままだとモデルローカル空間の法線なので、最後にnormalMatrix * localNormalでワールド空間の法線に変換しています。

Three.jsのシェーダーではnormalMatrixをかけることでモデルの回転スケールを加味した正しい法線方向が得られます。

ライティング部分では、diffに法線と光ベクトルの内積の正値を取っています。

ambientは0.3と固定値を入れましたが、必要に応じてuniformで環境光強度を渡してもよいでしょう。

litColor = surfaceColor * (ambient + diff)で拡散光と環境光を乗じています。

これにより、陰影で色味自体も暗くなります(例えば完全な陰ではdiff=0なのでambientの割合のみの暗い色になります)。

最後にエッジ強調として、worldNormalと上向きベクトル(0,1,0)の内積を計算しています。

ここで(0,1,0)はワールドでの真上(Y軸方向)です。

地面が水平であれば上向き法線ほど内積1、水平に近い法線では0となります。

edge = 1.0 - max(dot(...), 0.0)で、平らな部分edge=0、傾斜部分でedgeが正となる値を得ています。

これを0.2倍して白とブレンドすることで、傾斜部分が若干明るくハイライトされます。

値0.2は効果の強さで、調整可能です(大きすぎると不自然に白っぽくなりすぎるので注意してください)。

Three.js側の変更: 新たにuLightDirというuniformを導入したため、シーン側でその値を設定する必要があります。

  // uLightDir を uniform として渡す(ここでは斜め上からの光)
  const uniforms = {
    uLightDir: { value: new THREE.Vector3(0.3, 1.0, 0.4).normalize() },
  }

光源方向は予め正規化(normalize)しておきます。

normalMatrixはThree.jsが自動でShaderMaterialに提供するので自分で設定する必要はありません(vertexShaderでモデル行列を使っている場合、normalMatrixも自動セットされます)。

以上を適用したシェーダーでは、地面に対して以下の効果が確認できるでしょう。

  • ライティングにより、光の当たる面は明るく、陰になる面は暗く描画されます。
    地面に凹凸があるかのような陰影がつき、平坦だった地面に立体感が生まれます。
  • エッジ(傾斜地)部分がわずかに明るく強調されているため、模様のコントラストが増し、地形の起伏が視覚的にわかりやすくなります。

例えば、ある部分ではノイズによって小高い丘のような模様ができ、光が斜めから当たると片側が明るく照らされ反対側が影になります。

また、丘の稜線に沿ってハイライトが入ることで輪郭が浮かび上がります。

低い谷部分は光が当たりにくく暗いままですが、周囲との色の違いで地形のバリエーションが感じられるでしょう。

これで、ノイズ模様 + カラーリング + バンプマッピング + ライティングが組み合わさった「地面シェーダー」が完成です。

まとめ

5つのステップを通して、Three.jsでカスタムGLSLシェーダーを用いて地面を描画する方法を学びました。

  1. 基本の単色シェーダー: ShaderMaterialの使い方とGLSLの最基本構造(頂点シェーダーでの位置計算、フラグメントシェーダーでの色出力)。
  2. UV座標とグラデーション: varyingを使って頂点属性(UV)をフラグメントに渡し、位置に応じた色の変化を付ける方法。
  3. ノイズ関数の実装: ハッシュ関数からPerlin風のバリューノイズを作成し、さらにfBMでフラクタルな模様を生成。乱数と補間の概念。
  4. 色のブレンド: ノイズ値を利用して複数の色を滑らかに切り替え、地面に自然なまだら模様を付与。smoothstepで境界を目立たなくするテクニック。
  5. 擬似的な凹凸とライティング: ノイズを高さマップに見立てて法線を計算し、ディレクショナルライトによる陰影を実現。エッジ検出を使ったハイライトでディテールを強調。

各ステップのコードを組み合わせた最終的なシェーダーでは、GLSL初心者には少し長く感じられるかもしれません。

しかし、一つ一つのブロックは本チュートリアルで解説した基本要素の積み重ねです。

実際の応用では、ノイズ関数を差し替えて別の模様を作ったり、色や光源を調整したり、あるいは影や反射を追加したりと発展させることができます。

本チュートリアルで得た知識により、Three.jsとGLSLを使って地形や水面、雲などの様々なプロシージャル表現に挑戦できるでしょう。

ぜひシェーダーのパラメータを色々と変更して、オリジナルの地面表現を作り出してみてください。

関連記事
thumbnail
Shader
プロシージャル草原&地形シェーダーチュートリアル(上級)
2025/04/19
カテゴリ
Loading...
アーカイブ
Loading...
オススメ
Three.js Journey
Wawasensei