シェーダ・ポストプロセス

標準マテリアルでは届かない表現に行くための脱出口。ShaderMaterial で自作シェーダ、 EffectComposer(@react-three/postprocessing)でレンダリング後の画面処理(Bloom 等)。

シェーダの基本

シェーダは頂点シェーダ(vertex)フラグメントシェーダ(fragment)の2段構成。 基本概念は Three.js のシェーダ と同じ。R3F では JSX の中に書く。

<shaderMaterial> を直接書く

最もシンプル。uniforms / vertexShader / fragmentShader を props で渡す。

function Wave() {
  const ref = useRef<THREE.ShaderMaterial>(null!)

  useFrame(({ clock }) => {
    if (ref.current) ref.current.uniforms.uTime.value = clock.getElapsedTime()
  })

  return (
    <mesh>
      <planeGeometry args={[2, 2, 64, 64]} />
      <shaderMaterial
        ref={ref}
        uniforms={{
          uTime: { value: 0 },
          uColor: { value: new THREE.Color("#7dd3fc") },
        }}
        vertexShader={`
          uniform float uTime;
          varying vec2 vUv;
          void main() {
            vUv = uv;
            vec3 pos = position;
            pos.z += sin(pos.x * 4.0 + uTime) * 0.2;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
          }
        `}
        fragmentShader={`
          uniform vec3 uColor;
          varying vec2 vUv;
          void main() {
            gl_FragColor = vec4(uColor * vUv.y, 1.0);
          }
        `}
      />
    </mesh>
  )
}
uniforms オブジェクトは安定参照に

uniforms={{ ... }} を毎レンダーで新規生成すると、マテリアルが毎回作り直される恐れあり。 useMemo で固定するか、shaderMaterial ヘルパー(後述)を使う。

drei の shaderMaterial ヘルパー(推奨)

カスタムシェーダを JSX タグ化する強力なヘルパー。 uniforms が型推論される、メソッドが生える、ホットリロードに強い。

import { extend } from "@react-three/fiber"
import { shaderMaterial } from "@react-three/drei"
import * as THREE from "three"

const WaveMaterial = shaderMaterial(
  // uniforms
  { uTime: 0, uColor: new THREE.Color("#7dd3fc") },
  // vertex
  /* glsl */ `
    uniform float uTime;
    varying vec2 vUv;
    void main() {
      vUv = uv;
      vec3 pos = position;
      pos.z += sin(pos.x * 4.0 + uTime) * 0.2;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
    }
  `,
  // fragment
  /* glsl */ `
    uniform vec3 uColor;
    varying vec2 vUv;
    void main() {
      gl_FragColor = vec4(uColor * vUv.y, 1.0);
    }
  `,
)

extend({ WaveMaterial })

declare module "@react-three/fiber" {
  interface ThreeElements {
    waveMaterial: any
  }
}

function Wave() {
  const ref = useRef<any>(null!)
  useFrame((_, dt) => { if (ref.current) ref.current.uTime += dt })
  return (
    <mesh>
      <planeGeometry args={[2, 2, 64, 64]} />
      <waveMaterial ref={ref} uColor="hotpink" />
    </mesh>
  )
}

ポイント:

onBeforeCompile — 既存マテリアルに「足す」

MeshStandardMaterial の挙動を全部書き直したくないけど、少しだけシェーダを差し込みたい時。 ライティング・影・PBR を全部維持したまま頂点を歪めたり、色を加工したりできる。

function WavyStandard() {
  const ref = useRef<THREE.MeshStandardMaterial>(null!)
  const uniforms = useMemo(() => ({ uTime: { value: 0 } }), [])

  useFrame(({ clock }) => {
    uniforms.uTime.value = clock.getElapsedTime()
  })

  return (
    <mesh>
      <sphereGeometry args={[1, 64, 64]} />
      <meshStandardMaterial
        ref={ref}
        color="#7dd3fc"
        onBeforeCompile={(shader) => {
          shader.uniforms.uTime = uniforms.uTime
          shader.vertexShader = shader.vertexShader.replace(
            "#include <common>",
            `#include <common>
             uniform float uTime;`
          )
          shader.vertexShader = shader.vertexShader.replace(
            "#include <begin_vertex>",
            `#include <begin_vertex>
             transformed.y += sin(transformed.x * 4.0 + uTime) * 0.1;`
          )
        }}
      />
    </mesh>
  )
}

テクスチャを uniform で渡す

const tex = useTexture("/wall.jpg")

const Mat = shaderMaterial(
  { uTex: null, uTime: 0 },
  vertexShader,
  /* glsl */ `
    uniform sampler2D uTex;
    uniform float uTime;
    varying vec2 vUv;
    void main() {
      vec2 uv = vUv + vec2(uTime * 0.05, 0.0);
      gl_FragColor = texture2D(uTex, uv);
    }
  `,
)
extend({ Mat })

<mesh>
  <planeGeometry args={[2, 2]} />
  <mat uTex={tex} />
</mesh>

頂点シェーダで attribute を使う

パーティクルや個別頂点情報を使う時:

function Particles({ count = 5000 }) {
  const positions = useMemo(() => {
    const arr = new Float32Array(count * 3)
    for (let i = 0; i < count; i++) {
      arr[i*3+0] = (Math.random() - 0.5) * 10
      arr[i*3+1] = (Math.random() - 0.5) * 10
      arr[i*3+2] = (Math.random() - 0.5) * 10
    }
    return arr
  }, [count])

  return (
    <points>
      <bufferGeometry>
        <bufferAttribute attach="attributes-position" args={[positions, 3]} />
      </bufferGeometry>
      <shaderMaterial
        vertexShader={`
          void main() {
            vec4 mv = modelViewMatrix * vec4(position, 1.0);
            gl_Position = projectionMatrix * mv;
            gl_PointSize = 200.0 / -mv.z;
          }
        `}
        fragmentShader={`
          void main() {
            float d = length(gl_PointCoord - vec2(0.5));
            if (d > 0.5) discard;
            gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0 - d * 2.0);
          }
        `}
        transparent
        depthWrite={false}
      />
    </points>
  )
}

フルスクリーンシェーダ

画面全体に対する shader 遊び(procedural background や glow effect)。

function FullscreenShader() {
  return (
    <mesh position={[0, 0, -10]}>
      <planeGeometry args={[20, 20]} />
      <shaderMaterial
        vertexShader={`
          varying vec2 vUv;
          void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
          }
        `}
        fragmentShader={`
          uniform float uTime;
          varying vec2 vUv;
          void main() {
            vec2 uv = vUv * 2.0 - 1.0;
            float d = length(uv) - 0.5 + sin(uTime) * 0.1;
            vec3 color = mix(vec3(0.1), vec3(0.5, 0.8, 1.0), step(d, 0.0));
            gl_FragColor = vec4(color, 1.0);
          }
        `}
        uniforms={{ uTime: { value: 0 } }}
      />
    </mesh>
  )
}

ポストプロセス: @react-three/postprocessing

画面全体にエフェクト(Bloom / DoF / SSAO / Outline 等)。 Three.js の EffectComposer よりパフォーマンスがよい postprocessing パッケージを React でラップしたもの。

npm install @react-three/postprocessing postprocessing

基本

import { EffectComposer, Bloom, Vignette, DepthOfField } from "@react-three/postprocessing"

<Canvas>
  <Scene />
  <EffectComposer>
    <Bloom intensity={0.6} luminanceThreshold={0.85} mipmapBlur />
    <DepthOfField focusDistance={0.01} focalLength={0.05} bokehScale={3} />
    <Vignette eskil={false} offset={0.1} darkness={0.6} />
  </EffectComposer>
</Canvas>

主要な Effect

Effect用途
<Bloom />明るい部分を滲ませる(ネオン・発光)
<DepthOfField />被写界深度(ボケ)
<Vignette />周辺減光
<Noise />フィルムノイズ
<ChromaticAberration />色収差
<Glitch />グリッチ
<Pixelation />ピクセル化
<ToneMapping />トーンマッピング差し替え
<SSAO />環境遮蔽(陰影)
<SSR />スクリーンスペース反射
<Outline />選択中のオブジェクトに縁取り
<Selection /> + <Select />Outline ターゲット選択
<LUT />3D LUT でカラーグレーディング
<FXAA /> / <SMAA />アンチエイリアス
<HueSaturation />色相・彩度
<BrightnessContrast />明度・コントラスト

Outline でホバー

import { EffectComposer, Outline, Selection, Select } from "@react-three/postprocessing"

<EffectComposer autoClear={false}>
  <Selection>
    <Select enabled>
      <HoverableObject />
    </Select>
    <Outline edgeStrength={5} pulseSpeed={0} visibleEdgeColor={0xffffff} />
  </Selection>
</EffectComposer>

Bloom を選択的に当てる(Selective Bloom)

特定のオブジェクトだけ光らせたい時のテクニック。 emissive がしきい値を超えたものだけ Bloom する設定:

<EffectComposer>
  <Bloom
    intensity={1.5}
    luminanceThreshold={1}      {/* これより明るいものだけ滲む */}
    luminanceSmoothing={0.025}
    mipmapBlur
  />
</EffectComposer>

{/* 光らせたいメッシュは emissive を 1 超に */}
<mesh>
  <sphereGeometry />
  <meshStandardMaterial color="#000" emissive="#7dd3fc" emissiveIntensity={3} />
</mesh>

自作 ShaderPass を Effect として登録

自前 fragment shader を 1 つの post effect として乗せたい時。

import { Effect } from "postprocessing"
import { wrapEffect } from "@react-three/postprocessing"

class GrayscaleEffect extends Effect {
  constructor() {
    super(
      "GrayscaleEffect",
      /* glsl */ `
        void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
          float l = dot(inputColor.rgb, vec3(0.299, 0.587, 0.114));
          outputColor = vec4(vec3(l), inputColor.a);
        }
      `,
    )
  }
}
const Grayscale = wrapEffect(GrayscaleEffect)

<EffectComposer>
  <Grayscale />
</EffectComposer>

RenderTarget(FBO)パターン

画面以外のテクスチャにレンダリングして、それを別 mesh に貼る。ミニマップ・反射・ピックアップ画面等。

import { useFBO } from "@react-three/drei"

function PortalView() {
  const target = useFBO(512, 512)
  const { scene, camera, gl } = useThree()
  const otherCamera = useRef<THREE.PerspectiveCamera>(null!)

  useFrame(() => {
    gl.setRenderTarget(target)
    gl.render(scene, otherCamera.current)
    gl.setRenderTarget(null)
  })

  return (
    <>
      <perspectiveCamera ref={otherCamera} position={[5, 5, 5]} />
      <mesh position={[0, 1, 0]}>
        <planeGeometry args={[2, 2]} />
        <meshBasicMaterial map={target.texture} />
      </mesh>
    </>
  )
}

シェーダの GLSL ヒント

シェーダ学習リソース

WebGPU と TSL

Three.js は WebGPURendererTSL(Three Shading Language)を持っている。 GLSL ではなく JS で書ける式ベースの shader DSL で、WebGL/WebGPU 両対応。R3F でも実験的に使える。
まだ動向が動いているので本番採用は機能をピンポイントで確認してから。