Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
41
Help us understand the problem. What is going on with this article?
@hppRC

ReactでThree.jsとshaderを触りたいのでサンプルを読みほぐしながらreact-three-fiberを理解する回

More than 1 year has passed since last update.

WebGLなんもわからん

名称未設定.mov.gif

WebGL、かっこいいですよね。こういった3Dのパワフルな描画能力を生かしたかっこいいサイトを作りたいと思うのは全人類共通の夢だと思います。

流体表現とかかっこいいの極みです。

ただ、まだこういった表現をReactなどのモダンなフレームワークで扱うのは敷居が高い印象があります。

ReactでThree.jsを触るならreact-three-fiberがおすすめです。

Three.jsの手続き的な記述を、コンポーネント指向でわかりやすく記述できて、しかも手軽に再利用ができます。

ただReact-three-fiber、全然参考実装が落ちてません。解説もないです。

Three.jsの実装をReactに移行したい時とか、割と書き方が変わる場面があり、そういった参考があると学習のしやすさが全然違う気がします。

というわけで今回は、react-three-fiberのリポジトリにある参考実装のコードを読みほぐしながら、ReactでThree.jsとshaderを扱う方法を学んでいきます。

扱わせていただく参考実装は、上記にgif画像の作品です。

実際のコードは以下のサイトでみることが出来ます。

codesandbox

では早速やっていきましょい!

注意点

本記事で用いるreact-three-fiberのバージョンは3.x系です!
現在のstableは2.x系なのですが、3.x系でとても便利な機能が追加されたり、Hooksに対応したりとかなり変わっています。

破壊的な変更もそんなにみられないので、いまからreact-three-fiberを始めるなら3.x系がおすすめです。

Githubなどでコードを確認する際は、ブランチが3.x系になっているかをきちんと確認してからコードを読むことをおすすめします。

対象コード

以下のコードを対象として読んでいきます。
これらは、上記codesandboxにて公開されているコードをお借りしたものです。

index.jsにて各コンポーネントの定義やGeometryの作成などを、shadersディレクトリにshader関連のファイルが入っています。

今回はシェーダプログラム自体を扱うことはしません。
あくまでReactに関係する部分のみに絞ってみていきます。

index.js

index.js
import * as THREE from 'three'
import { render } from 'react-dom'
import React, { useEffect, useRef, useMemo } from 'react'
import { Canvas, useThree, useFrame, extend } from 'react-three-fiber'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass'
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass'
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader'
import { AdditiveBlendingShader, VolumetricLightShader } from './shaders'
import './styles.css'

extend({ EffectComposer, RenderPass, ShaderPass })

const DEFAULT_LAYER = 0
const OCCLUSION_LAYER = 1

function Torusknot({ layer = DEFAULT_LAYER }) {
  const ref = useRef()
  const Material = useMemo(() => `mesh${layer === DEFAULT_LAYER ? 'Physical' : 'Basic'}Material`, [layer])
  const color = useMemo(() => (layer === DEFAULT_LAYER ? '#873740' : '#070707'), [layer])
  useFrame(({ clock }) => {
    ref.current.position.x = Math.cos(clock.getElapsedTime()) * 1.5
    ref.current.rotation.x += 0.01
    ref.current.rotation.y += 0.01
    ref.current.rotation.z += 0.01
  })
  return (
    <mesh ref={ref} position={[0, 0, 2]} layers={layer} receiveShadow castShadow>
      <torusKnotBufferGeometry attach="geometry" args={[0.5, 0.15, 150, 32]} />
      <Material attach="material" color={color} roughness={1} clearcoat={1} clearcoatRoughness={0.2} />
    </mesh>
  )
}

function Effects() {
  const { gl, scene, camera, size } = useThree()
  const occlusionRenderTarget = useMemo(() => new THREE.WebGLRenderTarget(), [])
  const occlusionComposer = useRef()
  const composer = useRef()
  const light = useRef()

  useEffect(() => {
    occlusionComposer.current.setSize(size.width, size.height)
    composer.current.setSize(size.width, size.height)
  }, [size])

  useFrame(() => {
    light.current.rotation.z += 0.005
    camera.layers.set(OCCLUSION_LAYER)
    occlusionComposer.current.render()
    camera.layers.set(DEFAULT_LAYER)
    composer.current.render()
  }, 1)

  return (
    <>
      <mesh ref={light} layers={OCCLUSION_LAYER}>
        <boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />
        <meshBasicMaterial attach="material" color="lightblue" />
      </mesh>
      <effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
        <renderPass attachArray="passes" args={[scene, camera]} />
        <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
      </effectComposer>
      <effectComposer ref={composer} args={[gl]}>
        <renderPass attachArray="passes" args={[scene, camera]} />
        <shaderPass attachArray="passes" args={[AdditiveBlendingShader]} uniforms-tAdd-value={occlusionRenderTarget.texture} />
        <shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />
      </effectComposer>
    </>
  )
}

function App() {
  return (
    <Canvas shadowMap>
      <ambientLight />
      <pointLight />
      <spotLight castShadow intensity={4} angle={Math.PI / 10} position={[10, 10, 10]} shadow-mapSize-width={2048} shadow-mapSize-height={2048} />
      <Torusknot />
      <Torusknot layer={OCCLUSION_LAYER} />
      <Effects />
    </Canvas>
  )
}

render(<App />, document.querySelector('#root'))

こんな感じです!

index.jsを読んでいく

とにもかくにも少しずつ読んでいかなければ始まりません。
順番に読みつつ、関連する話題も取り上げながら見ていきます。

importされているモジュール

まずはindex.jsにてimportされている各モジュールを見ていきましょう。

対象のコードは以下の通りです。

index.js
import * as THREE from 'three'
import { render } from 'react-dom'
import React, { useEffect, useRef, useMemo } from 'react'
import { Canvas, useThree, useFrame, extend } from 'react-three-fiber'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass'
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass'
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader'
import { AdditiveBlendingShader, VolumetricLightShader } from './shaders'
import './styles.css'

いっこずつ読んでいきます。

import * as THREE from 'three'

これは簡単ですね。
npmパッケージとして落としてあるThee.jsをimportして、普通に使えるようにしているだけです。

import { render } from 'react-dom'

これも余裕です。ReactでDOM要素にReactコンポーネントを差し込む時に使うやつです。
ところでこれ、自分は以下の書き方の方が好きだったりします。


import ReactDom from 'react-dom';

...

ReactDOM.render(<App />, document.querySelector('#root'))

多分好みの問題だと思いますが、等価な表現だというのは覚えときましょう。

次です。


import React, { useEffect, useRef, useMemo } from 'react'

React本体のimport(これはjsxを使う時に必要なやつです)と、各種Hooksのimportをしています。

各種Hooksに関しては、日本語の公式ドキュメントを読むのが一番早いです。

useEffect
useRef
useMemo

これらは実際のコードにて使われ方を見ていくこととしましょう。


import { Canvas, useThree, useFrame, extend } from 'react-three-fiber'

React-three-fiberから色々引っ張り出しています。このうちCanvasはreact-three-fiberを使う上で必須ですね。

CanvasでWebGLを使う際のオブジェクトなどを囲んで使います。
公式ドキュメントには、「Canvasはreact-three-fiberを使う上でのポータルだ」という説明がありました。

useThree、useFrameはreact-three-fiberから提供されているカスタムフックです。

そもそもカスタムフックとはなんぞやという話ですが、これはReactが提供しているHooksを自前で改造して便利な機能を提供するようにしたモノのことを言います。

useThreeはカメラ座標やglコンテキストなどを返してくれます。
また、useFrameは毎フレーム走る処理を記述する際に使えます。

useFrame(() => {ref.current.rotation.z += 1})のように書くことで、ref.current.rotation.zの値を毎フレーム+1することができます。超便利機能です。

extendは知られざる便利機能という感じがします。

例えばextend({EffectComposer})などのようにすると、もとはReactコンポーネントでなかったEffectComposerがJSX記法で<EffectComposer />のようにかけるようになります。

これでThree.jsで公開されている参考実装やシェーダ、マテリアルなどを、気軽にextendしてコンポーネントとして利用できるようになります。

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass'
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass'

ここらへんはいい感じにシェーダを使うための便利な機能たちです。

これらはレンダリングやポストプロセッシング(後処理)のために用いられます。

もともとのThree.jsでは、EffectComposerクラスのインスタンスに、rederPassクラスのインスタンスとshaderPassクラスのインスタンスを突っ込んでいって使います。

three.jsのGitHubリポジトリを見てみると実装はこんな感じになっています。

それぞれのTypeScriptによる型定義ファイルを見てみると以下のような感じです。

まずEffectComposerから。

three/examples/jsm/postprocessing/EffectComposer.d.ts

import {
    Clock,
    WebGLRenderer,
    WebGLRenderTarget,
} from '../../../src/Three';

import { Pass } from './Pass';
import { ShaderPass } from './ShaderPass';

export class EffectComposer {

    constructor( renderer: WebGLRenderer, renderTarget?: WebGLRenderTarget );
    renderer: WebGLRenderer;
    renderTarget1: WebGLRenderTarget;
    renderTarget2: WebGLRenderTarget;
    writeBuffer: WebGLRenderTarget;
    readBuffer: WebGLRenderTarget;
    passes: Pass[];
    copyPass: ShaderPass;
    clock: Clock;

    swapBuffers(): void;
    addPass( pass: Pass ): void;
    insertPass( pass: Pass, index: number ): void;
    isLastEnabledPass( passIndex: number ): boolean;
    render( deltaTime?: number ): void;
    reset( renderTarget?: WebGLRenderTarget ): void;
    setSize( width: number, height: number ): void;
    setPixelRatio( pixelRatio: number ): void;
}

いろいろメソッドがくっついていそうですが、最初に各種バッファやレンダラを渡せそうだなというのがわかります。
実際の動作や実装は同階層にある.jsファイルをご覧ください。

お次はRenderPass。

three/examples/jsm/postprocessing/RenderPass
import {
    Scene,
    Camera,
    Material,
    Color
} from '../../../src/Three';

import { Pass } from './Pass';

export class RenderPass extends Pass {

    constructor( scene: Scene, camera: Camera, overrideMaterial?: Material, clearColor?: Color, clearAlpha?: number );
    scene: Scene;
    camera: Camera;
    overrideMaterial: Material;
    clearColor: Color;
    clearAlpha: number;
    clearDepth: boolean;

}

なんかシーンオブジェクトとかカメラとか塗りつぶし色とか色々渡せそうです。

最後はShaderPass。

three/examples/jsm/postprocessing/ShaderPass
import {
    Material
} from '../../../src/Three';

import { Pass } from './Pass';

export class ShaderPass extends Pass {

    constructor( shader: object, textureID?: string );
    textureID: string;
    uniforms: object;
    material: Material;
    fsQuad: object;

}

こっちは名前の通りシェーダを渡してあげる感じみたいですね。

次のimportを見てみます。


import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader'
import { AdditiveBlendingShader, VolumetricLightShader } from './shaders'

FXAAShaderというものと、今回使用するシェーダをimportしています。

FSAAShaderが何かと言うと、アンチエイリアス用のシェーダのようです。
ジャギってる画面をいい感じに滑らかにしてくれるみたいです。

以下を参考にさせていただきました。

[WebGL] レイマーチングでアンチエイリアス(FXAA)してみる
【Unity】アンチエイリアシングの概説(SSAA / MSAA / FXAA / TemporalAA)
今週の進み具合 #8 - FXAA を実装しました

他のMSAA、SSAAといったシェーダと違い、FXAAはポストエフェクトとして画面に対して処理を行います。

今回使用する他のシェーダは後述します。


import './styles.css'

これはただcssをひっぱてきてるだけです。余裕です。


extend({ EffectComposer, RenderPass, ShaderPass })

これは先ほど書いたextendを実際にやっている感じですね。
これで上記の書く関数がコンポーネントとして使えるようになります。


const DEFAULT_LAYER = 0
const OCCLUSION_LAYER = 1

こちらの値は後述することとします。

とりあえずはこんな感じです。次に行きましょう!

Torusknot関数

このサンプルはトーラスノット(ドーナツ*結び目の意味)をくるくる回しながら横にブンブン振っています。
その動作を実現する方法をみていきましょう。

Torusknot関数をみます。


function Torusknot({ layer = DEFAULT_LAYER }) {
  const ref = useRef()
  const Material = useMemo(() => `mesh${layer === DEFAULT_LAYER ? 'Physical' : 'Basic'}Material`, [layer])
  const color = useMemo(() => (layer === DEFAULT_LAYER ? '#873740' : '#070707'), [layer])
  useFrame(({ clock }) => {
    ref.current.position.x = Math.cos(clock.getElapsedTime()) * 1.5
    ref.current.rotation.x += 0.01
    ref.current.rotation.y += 0.01
    ref.current.rotation.z += 0.01
  })
  return (
    <mesh ref={ref} position={[0, 0, 2]} layers={layer} receiveShadow castShadow>
      <torusKnotBufferGeometry attach="geometry" args={[0.5, 0.15, 150, 32]} />
      <Material attach="material" color={color} roughness={1} clearcoat={1} clearcoatRoughness={0.2} />
    </mesh>
  )
}

とりあえずはデフォルト引数としてlayer = DEFAULT_LAYERが指定されていることだけ見ておけばよいでしょう。

では、この関数の中身も一つずつ見ていきましょう。


const ref = useRef()

先述のuseRefを使って値を保存しておくためのオブジェクトを作っています。
useRefで作られたオブジェクトrefは、その値が書き換わってもDOMの再レンダリングが走らないので、描画負担がなくて大変よいです。

ここで作ったrefをmeshに渡してあげて、その値でオブジェクトを操作します。

const Material = useMemo(() => `mesh${layer === DEFAULT_LAYER ? 'Physical' : 'Basic'}Material`, [layer])

ここではuseMemoを使って使用するマテリアルを選択しています。

useMemoでは、第2引数に指定されている値が変更されると、useMemoの第1引数に指定した関数が走って再計算が行われます。
それ以外の場合は、毎回計算するわけではなく、キャッシュしてある計算結果が使用されます。

今回は、layer(Torusknotに渡されている引数、デフォルトではDEFAULT_LAYER = 0です)の値によって使用するMaterialを変えます。

layer === DEFAULT_LAYER ? 'Physical' : 'Basic'は、layerの値がDEFAULT_LAYERに等しければ'Physical'を、それ以外ならばBasicを返します。

これがテンプレートリテラルの中に入っているので、結局useMemoで帰ってくる値は、文字列の'meshPhysicalMaterial'か、'meshBasicMaterial'のどちらかということになります。

const color = useMemo(() => (layer === DEFAULT_LAYER ? '#873740' : '#070707'), [layer])

こちらも同様ですが、layerがDEFAULT_LAYERなら'#873740'が、それ以外なら'#070707'が帰ってくることになります。

次にuseFrameの中を見ていきます。
useFrameには、毎フレーム実行してほしい処理を関数として渡します。


useFrame(({ clock }) => {
  ref.current.position.x = Math.cos(clock.getElapsedTime()) * 1.5
  ref.current.rotation.x += 0.01
  ref.current.rotation.y += 0.01
  ref.current.rotation.z += 0.01
})

こちらでは、useFrameがclockを引数として受け取っています。
ちなみに、({clock})という書き方は、引数としてとったオブジェクトの、clockという名前のメンバを引っ張り出してくる、みたいな意味合いです。

つまり、実はuseFrameの中に渡される関数にはreact-three-fiberが用意した便利なオブジェクトが渡されるので、その中の必要なものを取ってくる、みたいなことをしているわけです。

今回は時間によって挙動を制御したいので、clockを受け取っています。

先ほどの説明の通り、1フレームごとにオブジェクトのposition、および回転具合を計算しています。
見た方が早いと思いますが、それぞれx座標を時間にしたがったcosの値に更新(cosなので振動します)。
また、x、y、z軸における回転角をそれぞれ+0.01します。

次はreturnしているものを見ていきます。


return (
  <mesh ref={ref} position={[0, 0, 2]} layers={layer} receiveShadow castShadow>
    <torusKnotBufferGeometry attach="geometry" args={[0.5, 0.15, 150, 32]} />
    <Material attach="material" color={color} roughness={1} clearcoat={1} clearcoatRoughness={0.2} />
  </mesh>
)

とにもかくにもThree.jsでオブジェクトを扱う際には、meshでgeometryとmaterialを包んであげないと始まりません。

上記の書き方は、大まかにですがピュアなThree.jsにおける

sample.js
//こちらは適当なサンプルです
var geometry = new THREE.BoxBufferGeometry( 1, 1, 1 );
var material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
var mesh = new THREE.Mesh( geometry, material );

という書き方に対応していると考えていただければ大丈夫です。

meshコンポーネントで包む感じが、JSX記法の方が直感的だと感じていただけると思います。

meshに渡されているのは、

  • positionやrotationの情報が入ったref
  • positionの初期状態
  • layerに関する情報
  • 落ち影を受けるか(receiveShadow)
  • meshが影を落とすか(castShadow)

receiveShadowやcastShadowなどのbooleanの値は、プロパティとして指定してあげればtrueと認識されます。
これにより、このmeshは影を落としたり受けたりするmeshになったわけです。

torusKnotBufferGeometryはオブジェクトの頂点情報などが入ったコンポーネントです。

argsというプロパティに値を渡してあげると、Three.jsにおけるnew torusKnotBufferGeometry()と書いた際の、引数にあたる部分に一気に値を渡すことができます。

TorusKnotBufferGeometryの引数には以下の値があります。

  • radius : Float
  • tube : Float
  • tubularSegments : Integer
  • radialSegments : Integer
  • p : Integer
  • q : Integer

p, qに関してはデフォルト値で2が指定されるので、他の値を引数として渡してあげます。
それぞれの値の詳しい解説は公式ドキュメントをご覧ください。

attachプロパティには何も考えずにgeometryとつけておきましょう。よくわかりません。

変数MaterialにはmeshPhycalMaterial または meshBasicMaterialのどちらかが入っています。

これに対して値を設定するには、上記のようにargsで値を渡してあげるか、下記のようにプロパティとして名前を直接指定して値を渡す方法のどちらかを行います。

<Material attach="material" color={color} roughness={1} clearcoat={1} clearcoatRoughness={0.2} />

Three.jsの公式ドキュメントを読めば、どのような値を引数にとるかなどは書いてあるので、実際に使うときはチェックしながらやっていくといいと思います。

トーラスをくるくる回すのはこのくらいで出来ます。
割と手続き的に書くより見通し良くて自分は好きです。

では次にいきます。

Effects関数

トーラスを回しつつ、シェーダを使って画面に効果を加えているのがこの関数です。


function Effects() {
  const { gl, scene, camera, size } = useThree()
  const occlusionRenderTarget = useMemo(() => new THREE.WebGLRenderTarget(), [])
  const occlusionComposer = useRef()
  const composer = useRef()
  const light = useRef()

  useEffect(() => {
    occlusionComposer.current.setSize(size.width, size.height)
    composer.current.setSize(size.width, size.height)
  }, [size])

  useFrame(() => {
    light.current.rotation.z += 0.005
    camera.layers.set(OCCLUSION_LAYER)
    occlusionComposer.current.render()
    camera.layers.set(DEFAULT_LAYER)
    composer.current.render()
  }, 1)

  return (
    <>
      <mesh ref={light} layers={OCCLUSION_LAYER}>
        <boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />
        <meshBasicMaterial attach="material" color="lightblue" />
      </mesh>
      <effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
        <renderPass attachArray="passes" args={[scene, camera]} />
        <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
      </effectComposer>
      <effectComposer ref={composer} args={[gl]}>
        <renderPass attachArray="passes" args={[scene, camera]} />
        <shaderPass attachArray="passes" args={[AdditiveBlendingShader]} uniforms-tAdd-value={occlusionRenderTarget.texture} />
        <shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />
      </effectComposer>
    </>
  )
}

また一つずつ見ていきましょう。


const { gl, scene, camera, size } = useThree()

これは構文的には簡単です。
useThree()で利用できるものには以下があります。

  • gl: WebGLレンダラー
  • canvas: 作成されたcanvas(DOM要素)
  • scene: デフォルトで設定されているシーンオブジェクト
  • camera: デフォルトのカメラ
  • size: ビューの境界(100%に拡大して自動調整)
  • viewport: 3D単位のビューポートの境界+係数(サイズ/ビューポート)
  • aspect: アスペクト比(size.width / size.height)
  • mouse: 現在の2Dマウス座標
  • clock: THREE.Clock
  • invalidate: 単一のフレームを無効にする(<Canvas invalidateFrameloop />の場合)
  • intersection: カーソルの下にあるオブジェクトのonMouseMoveハンドラーを呼び出す
  • setDefaultCamera: デフォルトのカメラを設定する

公式ドキュメント読んだだけだとなんだかよくわからない感じですが、とにかくシェーダなどを使おうと思ったらglsceneが必須になります。

次も見てみます。


const occlusionRenderTarget = useMemo(() => new THREE.WebGLRenderTarget(), [])

occlusionってなんやという話ですが、これはレンダリング対象の手前のオブジェクトが、奥のオブジェクトを遮って見えないようにしている状態のこと、らしいです。

今回は物体(TorusKnotですね)が光を遮っている効果を演出するために使用されています。

アンビエントオクルージョン

上記では、オクルージョン用のレンダラーを生成している感じですね。

次も見ていきます。


const occlusionComposer = useRef()

よくわかりませんが、多分オクルージョンをいい感じにしてくれるやつでしょう。
次に行きます。

const composer = useRef()
const light = useRef()

これもよくわかりませんが、多分いい感じに色々調整するためのものでしょう。

さらに次へ行きます。


useEffect(() => {
  occlusionComposer.current.setSize(size.width, size.height)
  composer.current.setSize(size.width, size.height)
}, [size])

ここで何をやっているのかが、意外とわかりにくいと思います。

まず中身の処理は置いておき、第2引数に渡されているものの意味を考えていきます。

useEffectは第2引数にリストとして渡された変数の値が変わっていれば、再描画時にuseEffect内の処理を再実行、変更がなければ処理を行わない、という動作をします。

sizeはuseThreeから渡されている変数で、ウインドウサイズを変更するとこのsizeの値も自動的に変更されるので、useEffectの処理も再実行されるというわけです。

試しに以下のようにコードを追加してみて、Google Chromeでconsoleを見てみると動作がよくわかると思います。

useEffect(() => {
+ console.log(occlusionComposer.current);
  occlusionComposer.current.setSize(size.width, size.height);
+ console.log(composer.current);
  composer.current.setSize(size.width, size.height);
}, [size]);

それでは処理の中身を見ていきます。
多分これ、初見だとすごく謎だと思うんですが、useRefの挙動を理解するとわかるようになります。

実はuseEffectはreturnしているコンポーネントの中で呼ばれています。

また、useRefで作成したオブジェクトをref属性としてコンポーネントに渡してあげると、そのコンポーネントを.currentがさすようになります。

つまり以下のようなコンポーネントを書いておくと、occlusionComposer.currentEffectComposerクラスのインスタンスが入ります。


<effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
  <renderPass attachArray="passes" args={[scene, camera]} />
  <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
</effectComposer>

すなわち、occlusionComposer.currentをよびだせばEffectComposerのインスタンスメソッドや変数にアクセスできるようになるということです。

それを踏まえて以下を見てみると

occlusionComposer.current.setSize(size.width, size.height);

これで実はEffectComposerクラスのsetSizeメソッドを呼び出しており、画面のサイズなどを決めている、ということがわかります。

また、引数に取られているsize.width、size.heightなどは、useThreeから渡されている画面のサイズですね。

とりあえずuseEffectでやっていることは画面の変更だということがわかりました。

では次に行きましょう。


  useFrame(() => {
    light.current.rotation.z += 0.005
    camera.layers.set(OCCLUSION_LAYER)
    occlusionComposer.current.render()
    camera.layers.set(DEFAULT_LAYER)
    composer.current.render()
  }, 1)

おなじみuseFrameですが、第2引数が存在していますね。
このuseFrameに渡されている1はなんなのでしょうか。

実はuseFrameは第2引数が渡されていないとき、自動的にcanvasのレンダリングを行うようになっています。
逆に、第2引数に値が存在しているときは、自分で明示的にレンダリングを行わないといけないということです。

そこでuseFrameの中を見てみると、3行目と5行目でocclusionComposer.currentとcomposer.currentのrender()メソッドを呼び出していることがわかります。

試しにこのrender()をコメントアウトしてみます。
すると、occulusionComposerの方をコメントアウトすると光が消え、composerの方をコメントアウトすると両方消えます。

要はここでEffectComposerが処理した結果を画面に表示しているわけですね。

ちなみにuseFrameを複数書く場合は、cssのz-indexと同様に、数字の大きい方が上のレイヤーとして(より正確には後から)レンダリングされます。
これを生かして層を重ねていくような表現もできる感じです。

また、camera.layers.setで描画するレイヤーを指定しています。
これはocculusionをかけるレイヤーを指定している感じですね。

では実験としてこのレイヤーの順番を入れ替えてみます。

まずもとの状態だとこう。

スクリーンショット 2019-09-30 14.56.39.png

光が物体に遮られている感じがしてかっこいいいです。

で、順番を入れ替えるとこう。

スクリーンショット 2019-09-30 14.56.27.png

物体の迫力が増してしまいました。

こんな感じで、エフェクトの順序を変えることでも映像表現を変化させることができます。

light.current.rotation.z += 0.005に関しては特にみなくてもいいと思うので、次にいきましょう。

returnの中身

次はreturnで何を返しているかを読んでいきます。


<>
  <mesh ref={light} layers={OCCLUSION_LAYER}>
    <boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />
    <meshBasicMaterial attach="material" color="lightblue" />
  </mesh>
  <effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
    <renderPass attachArray="passes" args={[scene, camera]} />
    <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
  </effectComposer>
  <effectComposer ref={composer} args={[gl]}>
    <renderPass attachArray="passes" args={[scene, camera]} />
    <shaderPass attachArray="passes" args={[AdditiveBlendingShader]} uniforms-tAdd-value={occlusionRenderTarget.texture} />
    <shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />
  </effectComposer>
</>

まず<>という謎のタグは、React.Fragmentの糖衣構文です。
これは<Fragment></Fragment>と書くのと同じ意味になります。

じゃあまず<Fragment>ってなんやねんという話ですが、これはReactがreturn内で単一のコンポーネントしか返せないため、並列のコンポーネントを返すときはくくってあげることが必要だからです。

Fragmentは実際のDOMとしては描画されないので、無駄なネストが発生せずよいです。

Fragmentの気持ちがわかったところで次に行きます。


<mesh ref={light} layers={OCCLUSION_LAYER}>
  <boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />
  <meshBasicMaterial attach="material" color="lightblue" />
</mesh>

こちらではmeshにlayerを設定し、その中でいろいろ設定しています。

meshに対してref属性でlightが渡されているのはもう大丈夫ですね。
このlightのcurrentをいじることで、meshのプロパティを弄ることができます。

useFrameではlight.current.rotation.z += 0.005しているので、これはz軸(画面手前が正、奥が負)を回転軸として、1フレームごとに0.005[rad]だけ回転していることになります。

また、layerに設定されているOCCULUTION_LAYERですが、これは最初に定数として1と設定されていました。この値によって描画順が変わります。

さらに中をみていきます。

boxBufferGeometryというのは、箱型のオブジェクトを作成するときに用いることが出来るクラスです。

attach属性に値を指定すると、その親要素に対してbindすることが出来ます。
今回はmeshのgeometryに対してboxBufferGeometryをbindしているわけですね。

また、argsはおなじみ、Three.jsで扱っていた際の引数に当たる部分です。

Three.jsでは以下のように書いていたところを、


var geometry = new THREE.BoxBufferGeometry( 1, 1, 1 );

react-three-fiberでは以下のようにかけます。


<boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />

やってることは特に変わらないので、ここはThree.jsのサンプルコードとの対応もしやすいと思います。

オブジェクトの色などはmaterialのプロパティで指定できます。
これはいつも通りな感じがありますね。

それでは次にいきます。


<effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
  <renderPass attachArray="passes" args={[scene, camera]} />
  <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
</effectComposer>

難解そうなコードが出てきましたね...

ところが実はそんなにむずかしくなかったりもするので、むずかしく考えずにやっていきましょう。

そもそもEffectComposerが何かと言うと、これはポストプロセッシング(後処理)用のクラスです。
3D世界に存在するオブジェクトを画面にレンダリングする際に、その描画結果に色々な処理をしてから画面に描画する、という流れで処理を加えてます。

EffectComposerクラスは、この後処理を簡単に出来るようにしてくれているクラスな訳ですね。

EffectComposerクラスのインスタンスを生成する際には、レンダラオブジェクトと、レンダラターゲットオブジェクトを渡します。

レンダラターゲットオブジェクトはoptionalで、渡さなかった場合は適当なものが内部で自動生成されます。

renderToScreenをtrueにすると処理結果を画面に描画することができるので、逆に言えばこれをtrueにするのは処理の最後です。

ではrenderToScreenをfalseにするとどうなるかというと、これはただ描画が行われないということではなく、レンダラーターゲットオブジェクトに対してレンダリング結果が出力されます。

renderPassはエフェクトを加える元となる、3D空間のレンダラパスオブジェクトを生成するクラスです。
レンダリングに利用するシーンオブジェクトとカメラオブジェクトを突っ込みます。

上記でもargsにscenecameraが渡されていますね。

また、renderPassにはattachArrayというプロパティが存在しています。
これは先ほどまで見ていた通常のattachとは違い、bindする側(子オブジェクトのようなもの)が複数存在する場合にこれを使います。

shaderPassクラスはシェーダプログラムを格納してシェーダをいい感じにやるためのクラスです。

こちらも同じくattachArrayで"passes"が指定されていますね。

また、argsにはimportしたシェーダが渡されています。シェーダに関してはあとで見ることとして、今はスルーしておきましょう。

needSwapはポストプロセッシングを行なったのちに、描画前と描画結果を入れ替えるかどうかを選択できます。(これはよくわかりません)

シェーダ以外はそんなに複雑でないので、次にいってしまいましょう。

残りの部分を見ていきます。


<effectComposer ref={composer} args={[gl]}>
  <renderPass attachArray="passes" args={[scene, camera]} />
  <shaderPass attachArray="passes" args={[AdditiveBlendingShader]} uniforms-tAdd-value={occlusionRenderTarget.texture} />
  <shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />
</effectComposer>

ここではrenderを一つ、shaderを二つ渡しています。

renderPassには先ほどと同じくレンダリングターゲットとしてsceneとcameraを渡し、shaderPassにはshaderプログラムを渡しています。

渡しているシェーダはAdditiveBlendingShaderFXAAShaderというものです。

AdditiveBlendingShader...?

これは絵描きに馴染み深い加算レイヤー的なものではなく、前回までの描画結果を引き継いで描画するためのものです。

さきほどEffectComposerオブジェクトにrenderToScreen={false}を設定していましたね。
あの設定でocclusionRenderTargetに描画結果が保存されています。

また、上記のshaderPassのプロパティをみてみると、以下の記述が見つかります。


uniforms-tAdd-value={occlusionRenderTarget.texture}

シェーダを利用する際には、シェーダプログラムに対して変数をThree.jsから渡すことができます。

その値がuniformsといって、react-three-fiberでは上記のように値を設定してあげることになっています。

上記のようにすると、シェーダプログラム側でtAddが変数として利用できます。

また、occlusionRenderTarget.textureには先ほどのEffectComposerでの描画結果が格納されています。
よって、上記で行なっているのは、先ほどの描画結果を次のシェーダに渡す、という処理です。


<shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />

また、こちらのコードではFXAAShaderというものを渡しています。

このshaderは描画結果にエイリアスを掛けるものです。
試しにこの1行をまるまるコメントアウトしてみてください。

すると、先ほどまでよりも画面のジャギーがきになるようになったと思います(よくみないとわからないです)。

このジャギーは、オブジェクトを斜めからみるときによく観測されるものです。
このシェーダを掛けておけば、描画結果の品質をあげることが出来ます。

また、このシェーダはthree.jsから提供されているサンプルのシェーダです。
three.jsからimportするだけで使える公式のシェーダは以下にまとまっているので、ぜひみてみてください。

github.com/mrdoob/three.js/tree/dev/examples/jsm/postprocessing

App関数

最後にこちらをみていきます。


function App() {
  return (
    <Canvas shadowMap>
      <ambientLight />
      <pointLight />
      <spotLight castShadow intensity={4} angle={Math.PI / 10} position={[10, 10, 10]} shadow-mapSize-width={2048} shadow-mapSize-height={2048} />
      <Torusknot />
      <Torusknot layer={OCCLUSION_LAYER} />
      <Effects />
    </Canvas>
  )
}

render(<App />, document.querySelector('#root'))

ここでは、Canvas要素の中に今までに定義したオブジェクトやポストプロセッシング、ライトなどを配置しています。


<Canvas shadowMap>
...
</Canvas>

react-three-fiberのコンポーネントは全てCanvasタグの中になくてはいけません。
これはreact-three-fiberを使う上でのルールなのでしっかり覚えていきましょう。

Canvasにはプロパティとしてcameraなどを指定できます。
shadowMapをtrueにすると、PCFsoftという柔らかい影を用いることができるようです。

また、Canvasの中で設定しているライト(光源)は以下です。

  • ambientLight: 環境光源。シーン全体に光が当たる。
  • pointLight: 点光源。ある点から光が放射状に広がる。
  • spotLight: スポットライト光源。ある点からある方向に向かって光が広がる。

それぞれのライトごとに様々なプロパティがあります。
光源の強さや位置も細かく調整できるので、ぜひthree.jsの公式ドキュメントを読んでカスタマイズしてみてください。

Three.js ライト機能まとめ

threejs.org

コードを読んでいると以下の部分を不思議に思ったかもしれません。


<Torusknot />
<Torusknot layer={OCCLUSION_LAYER} />
<Effects />

なぜTorusknotが二つ用いられているのでしょう。

これは、もしocculusionを有効にして光源を遮っているオブジェクトのみを描画した場合は、その光源の裏側にカメラが存在しているためにTorusknot自体が真っ暗になってしまい、occulusionを無効にしているオブジェクトのみ描画すると、光源が見えなくなってしまうためです。

試しにeditor上でどちらかのTorusknotを消してみてください。
すると、描画結果が期待しないものになってしまったはずです。

この描画結果を補うために、本プログラムではオブジェクトを2回描画して見えるようにする、という手段を取っているのですね。

<Effect/>に関しては特に言うことがありません。
先ほどのポストプロセッシングをコンポーネントとして呼び出しているだけです。

最後に、こちらです。

render(<App />, document.querySelector('#root'))

ここでは、HTMLファイルのid名がrootのDOM要素に対して、上記のAppコンポーネントを差し込んで描画しています。

これはReactのお作法のようなものなので覚えておきましょう。

まとめ

react-three-fiberのコードリーディングはこんな所です!

他のサンプルもとてもためになるので、ぜひじっくり読んでみてください。

また、本記事に分かり難とことがあれば追記や修正を行いますので、ぜひお気軽にコメントなどしていただけると幸いです。

また、以下の記事にてReactでThree.jsを触るまでのチュートリアルをやっています。
実際の作品づくりや見直しにぜひどうぞ!!

超楽しくてカッコいい、Reactで始めるThree.js入門

Reactで楽しくWebGLしていきましょう!!

41
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
hppRC
方向性が発散しがち

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
41
Help us understand the problem. What is going on with this article?