LoginSignup
7
2

More than 1 year has passed since last update.

【React Three Fiber】Shaderを使用してMaterialを変更する

Last updated at Posted at 2021-12-14

概要

Shaderの勉強を始めて、ある程度わかったことを以下の記事にまとめました。

本記事では、じゃあ実際にReact Three FiberでShaderをどのように使うのかについてまとめました。
ただ、すべての使用ケースをまとめると長くなるので、MaterialにShaderを適用するケースに絞ってまとめています。

Three.jsでShaderを使用するケース

Three.jsでは、以下のケースでShaderを使用します。(私が把握している限り)
shader.png
※ Material Shadingは、勝手に呼んでいるだけで正式な名称ではないです。

  • Material Shadingは、MeshのMaterialに対してShaderを適用します。ただ、Materialとは言っても、Vertex Shaderで頂点座標を変更することもできます。

  • Post-processingは、レンダリングされた後のシーン全体にShaderを適用します。

本記事では、Material Shadingの実装方法をまとめています。
さらに、Material Shadingでは、2パターンの実装方法があります。

  • ShaderMaterial:ShaderMaterialを使用して、Materialの特性(色、影、光沢、粗さなど)をすべて自分で実装する方法
  • Custom Material Shader:既存のMaterial(MeshStandardMaterialなど)を使用して、Materialの特性は残しつつ特定の属性(たとえば色)のみをShaderで上書きする方法

Post-processingについて

ShaderMaterialを使用した実装

ドキュメント

成果物

コード

App.tsx
import React, { useEffect, useRef, VFC } from 'react';
import * as THREE from 'three';
import { OrbitControls, Plane, Stats } from '@react-three/drei';
import { Canvas, useThree } from '@react-three/fiber';
import { ShaderObject } from './ShaderObject';

export const App: VFC = () => {
    return (
        <div style={{ width: '100vw', height: '100vh' }}>
            <Canvas
                camera={{
                    position: [-40, 40, 80],
                    fov: 50,
                    aspect: window.innerWidth / window.innerHeight,
                    near: 0.1,
                    far: 2000
                }}
                dpr={window.devicePixelRatio}
                shadows>
                {/* canvas color */}
                <color attach="background" args={['#1e1e1e']} />
                {/* fps */}
                <Stats />
                {/* camera controller */}
                <OrbitControls attach="orbitControls" />
                {/* lights */}
                <Lights />
                {/* objects */}
                <ShaderObject />
                <Plane args={[50, 50]} position={[0, -20, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
                    <meshStandardMaterial color="#fff" />
                </Plane>
                {/* helper */}
                <axesHelper />
            </Canvas>
        </div>
    );
};

const Lights: VFC = () => {
    const { scene } = useThree();
    const lightRef = useRef<THREE.DirectionalLight>(null);

    useEffect(() => {
        const helper = new THREE.CameraHelper(lightRef.current!.shadow.camera);
        scene.add(helper);
    }, [scene]);

    return (
        <>
            <ambientLight intensity={0.1} />
            <directionalLight
                ref={lightRef}
                intensity={1}
                position={[5, 30, 10]}
                castShadow
                shadow-mapSize-width={1024}
                shadow-mapSize-height={1024}
                shadow-camera-near={0.1}
                shadow-camera-far={70}
                shadow-camera-top={20}
                shadow-camera-bottom={-20}
                shadow-camera-left={-20}
                shadow-camera-right={20}
                shadow-bias={-0.003}
            />
        </>
    );
};
ShaderObject.tsx
import { useFrame } from '@react-three/fiber';
import React, { useRef, VFC } from 'react';
import * as THREE from 'three';
import { Box } from '@react-three/drei';
import { useDepthMaterial } from './useDepthMaterial';

export const ShaderObject: VFC = () => {
    const boxRef = useRef<THREE.Mesh>(null);

    const material = new THREE.ShaderMaterial({
        uniforms: {
            u_time: { value: 0 },
            u_radius: { value: 10 }
        },
        vertexShader: vertexShader,
        fragmentShader: fragmentShader
    });

    // sync shadow
    useDepthMaterial(boxRef);

    useFrame(({ clock }) => {
        material.uniforms.u_time.value = clock.getElapsedTime();
    });

    return (
        <Box
            ref={boxRef}
            args={[10, 10, 10, 12, 12, 12]}
            material={material}
            castShadow
            receiveShadow
        />
    );
};

const vertexShader = `
uniform float u_time;
uniform float u_radius;
varying vec2 v_uv;

float getDelta(){
    return (sin(u_time) + 1.0) / 2.0;
}

void main() {
    v_uv = uv;

    float delta = getDelta();
    vec3 v = normalize(position) * u_radius;
    vec3 pos = delta * position + (1.0 - delta) * v;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;

const fragmentShader = `
#define PI 3.141592653589

uniform float u_time;
varying vec2 v_uv;

void main() {
    float noise = fract(sin(dot(v_uv.xy, vec2(12.9898, 78.233)) + u_time) * 43758.5453123);
    float r = (sin(u_time) + 1.0) / 2.0;
    float g = (sin(PI * 2.0 / 3.0 + u_time) + 1.0) / 2.0;
    float b = (sin(PI * 4.0 / 3.0 + u_time) + 1.0) / 2.0;
    vec3 color = vec3(r, g, b) * noise;

    gl_FragColor = vec4(color, 1.0);
}
`;
useDepthMaterial.ts
import { useEffect, useMemo } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';

export const useDepthMaterial = (
    meshRef: React.RefObject<THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>>
) => {
    const depthMaterial = useMemo(() => {
        const depth = THREE.ShaderLib['depth'];
        return new THREE.ShaderMaterial({
            vertexShader: depthVertexShader,
            fragmentShader: depth.fragmentShader,
            uniforms: Object.assign(depth.uniforms, {
                u_time: { value: 0 },
                u_radius: { value: 10 }
            }),
            defines: {
                DEPTH_PACKING: THREE.RGBADepthPacking
            }
        });
        // console.log(depth.vertexShader)
    }, []);

    useEffect(() => {
        if (meshRef.current) {
            meshRef.current.customDepthMaterial = depthMaterial;
        }
    }, [meshRef, depthMaterial]);

    useFrame(({ clock }) => {
        depthMaterial.uniforms.u_time.value = clock.getElapsedTime();
    });
};

const depthVertexShader = `
#include <common>
#include <uv_pars_vertex>
#include <displacementmap_pars_vertex>
#include <morphtarget_pars_vertex>
#include <skinning_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
varying vec2 vHighPrecisionZW;

uniform float u_time;
uniform float u_radius;

float getDelta(){
    return (sin(u_time) + 1.0) / 2.0;
}

void main() {
    #include <uv_vertex>
    #include <skinbase_vertex>
    #ifdef USE_DISPLACEMENTMAP
        #include <beginnormal_vertex>
        #include <morphnormal_vertex>
        #include <skinnormal_vertex>
    #endif
    #include <begin_vertex>

    float delta = getDelta();
    transformed = delta * position + (1.0 - delta) * normalize(position) * u_radius;

    #include <morphtarget_vertex>
    #include <skinning_vertex>
    #include <displacementmap_vertex>
    #include <project_vertex>
    #include <logdepthbuf_vertex>
    #include <clipping_planes_vertex>
    vHighPrecisionZW = gl_Position.zw;
}
`;

3つのコードブロックがあります。

  • App.tsxは、Sceneを作成しています。(Shaderと関係ないので説明は割愛します)
  • ShaderObject.tsxは、Material ShadingしたMeshを作成しています。
  • useDepthMaterial.tsは、床面に落ちる影を更新するための設定をしています。

ShaderObject.tsx

ShaderMaterialの使い方はシンプルで、uniformsvertexShaderfragmentShaderをコンストラクタに渡すだけです。このうちvertexShader、fragmentShaderは文字列で指定します。

const material = new THREE.ShaderMaterial({
    uniforms: {
        u_time: { value: 0 },
        u_radius: { value: 10 }
    },
    vertexShader: vertexShader,
    fragmentShader: fragmentShader
});

uniformsの動的な更新は、以下のようにmaterialから行います。

useFrame(({ clock }) => {
    material.uniforms.u_time.value = clock.getElapsedTime();
});

あとは、他のmaterialのようにMeshに割り当てるだけです。

<Box
    ref={boxRef}
    args={[10, 10, 10, 12, 12, 12]}
    material={material}
    castShadow
    receiveShadow
/>

ライトの影響や光沢、粗さなどの情報をFragment Shaderに実装していないので、Meshにはこれらの特性がないことがわかります。 ライティングしているのに、Meshには影がない。

useDepthMaterial.ts

Vertex Shaderで頂点座標を変更した場合、自身が受ける影(receiveShadow)は動的に更新されますが、他(床面)に落とす影(castShadow)は更新されません。
更新をするためには、MeshのcustomDepthMaterialを設定する必要があります。

customDepthMaterialには、depthMaterialを設定します。

  • Fragment Shaderは、組み込みShaderをそのまま使います。
  • Vertex Shaderは、MeshのVertex Shaderで適用した形状変更を、組み込みShaderに挿入します。
  • uniformsも、MeshのVertex Shaderの形状変更に必要な値を追加します。
  • definesでは、DEPTH_PACKINGとしてRGBADepthPackingを割り当てます。
const depthMaterial = useMemo(() => {
    const depth = THREE.ShaderLib['depth'];
    return new THREE.ShaderMaterial({
        vertexShader: depthVertexShader,
        fragmentShader: depth.fragmentShader,
        uniforms: Object.assign(depth.uniforms, {
            u_time: { value: 0 },
            u_radius: { value: 10 }
        }),
        defines: {
            DEPTH_PACKING: THREE.RGBADepthPacking
        }
    });
    // console.log(depth.vertexShader)
}, []);

useDepthMaterialでは、Vertex Shaderだけさらに記述する必要があります。実装は、上記のとおり組み込みShaderに形状変更を挿入する形になります。
ここで組み込みShaderがどうなっているかを知るために、コンソールに出力するのが良いです。

console.log(depth.vertexShader)

ソースコードを参照してもいいですが、使っているThree.jsのバージョンによって変わる可能性があるので、注意が必要です。(以下のリンクはr135のソースコード)
https://github.com/mrdoob/three.js/blob/r135/src/renderers/shaders/ShaderLib/depth.glsl.js

形状変更情報を追加する場合、<begin_vertex>を置き換えるか、その下に挿入します。

const depthVertexShader = `
#include <common>
#include <uv_pars_vertex>
#include <displacementmap_pars_vertex>
#include <morphtarget_pars_vertex>
#include <skinning_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
varying vec2 vHighPrecisionZW;

// ↓ -----------------------------------
uniform float u_time;
uniform float u_radius;

float getDelta(){
    return (sin(u_time) + 1.0) / 2.0;
}
// ↑ -----------------------------------

void main() {
    #include <uv_vertex>
    #include <skinbase_vertex>
    #ifdef USE_DISPLACEMENTMAP
        #include <beginnormal_vertex>
        #include <morphnormal_vertex>
        #include <skinnormal_vertex>
    #endif
    #include <begin_vertex>

    // ↓ -----------------------------------
    float delta = getDelta();
    transformed = delta * position + (1.0 - delta) * normalize(position) * u_radius;
    // ↑ -----------------------------------

    #include <morphtarget_vertex>
    #include <skinning_vertex>
    #include <displacementmap_vertex>
    #include <project_vertex>
    #include <logdepthbuf_vertex>
    #include <clipping_planes_vertex>
    vHighPrecisionZW = gl_Position.zw;
}
`;

#includeで始まるコードは、すべて組み込みShaderです。(自前のライブラリを使用していなければ)
組み込みShaderの一覧は、以下から参照できます。
https://github.com/mrdoob/three.js/tree/master/src/renderers/shaders/ShaderChunk

参考にさせて頂いた記事

three.jsのcustomDepthMaterialを使ってみる

Custom Material Shaderを使用した実装

ドキュメント

成果物

コード

App.tsxuseDepthMaterial.tsについては、【ShaderMaterial】と同じなので割愛します。

ShaderObject.tsx
import React, { VFC, useRef } from 'react';
import * as THREE from 'three';
import { Box } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { useDepthMaterial } from './useDepthMaterial';

export const ShaderObject: VFC = () => {
    const boxRef = useRef<THREE.Mesh>(null);

    const material = new THREE.MeshStandardMaterial({
        metalness: 0.5,
        roughness: 0.8,
        transparent: true,
        side: THREE.DoubleSide,
        wireframe: false
    });

    material.onBeforeCompile = (shader) => {
        shader.uniforms.u_time = { value: 0 };
        shader.uniforms.u_radius = { value: 10 };
        // vertex
        shader.vertexShader = vertexShaderDefine + shader.vertexShader;
        shader.vertexShader = shader.vertexShader.replace(
            '#include <beginnormal_vertex>',
            beginnormal_vertex
        );
        shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', begin_vertex);
        // fragment
        shader.fragmentShader = fragmentShaderDefine + shader.fragmentShader;
        shader.fragmentShader = shader.fragmentShader.replace(
            '#include <color_fragment>',
            color_fragment
        );

        material.userData.shader = shader;
        // debug
        // console.log('vertexShader', shader.vertexShader)
        // console.log('fragmentShader', shader.fragmentShader)
    };

    // sync shadow
    useDepthMaterial(boxRef);

    useFrame(({ clock }) => {
        const shader = material.userData.shader;
        if (shader) {
            (shader as THREE.Shader).uniforms.u_time.value = clock.getElapsedTime();
        }
    });

    return (
        <Box
            ref={boxRef}
            args={[10, 10, 10, 12, 12, 12]}
            material={material}
            castShadow
            receiveShadow
        />
    );
};

const vertexShaderDefine = `
uniform float u_time;
uniform float u_radius;
varying vec2 v_uv;
`;

const beginnormal_vertex = `
float delta = (sin(u_time) + 1.0) / 2.0;
vec3 objectNormal = delta * normal + (1.0 - delta) * normalize(position);
`;

const begin_vertex = `
v_uv = uv;
vec3 v = normalize(position) * u_radius;
vec3 transformed = delta * position + (1.0 - delta) * v;
`;

const fragmentShaderDefine = `
uniform float u_time;
varying vec2 v_uv;
`;

const color_fragment = `
float noise = fract(sin(dot(v_uv.xy, vec2(12.9898, 78.233)) + u_time) * 43758.5453123);
float r = (sin(u_time) + 1.0) / 2.0;
float g = (sin(PI * 2.0 / 3.0 + u_time) + 1.0) / 2.0;
float b = (sin(PI * 4.0 / 3.0 + u_time) + 1.0) / 2.0;
vec3 color = vec3(r, g, b) * noise;

diffuseColor = vec4(color, 1.0);
`;

ShaderObject.tsx

既存のMaterial(例えばMeshStandardMaterial)のShaderを上書きする場合は、onBeforeCompileを使用します。

どこをどう上書きすればいいのかは、わたしもThree.jsやShaderの学習を始めて日が浅いのでほぼ理解できていません。
コードは、公式サンプルなどを見様見真似で書きました。

公式サンプル
参考にさせて頂いたコード

今回わかったことは、

  • 頂点座標の変更に関しては、<beginnormal_vertex><begin_vertex>を上書きする。
  • 色の変更に関しては、<color_fragment>を上書きする。

ということです。ただ、ファイル名でおおよその予測はできます。

最終的にどの変数を上書きするのか知るためには、頑張って組み込みShaderを読むしかないです。
https://github.com/mrdoob/three.js/tree/master/src/renderers/shaders/ShaderChunk

【ShaderMaterial】で実装したものとは違い、Mesh自体に影が落ちているのがわかります。

参考にさせて頂いた記事

three.jsのマテリアルを継承してシェーダーを追記する
three.jsのデフォルトのマテリアルを拡張する

まとめ

Shaderを使いこなせれば魔法のような表現ができますが、Three.jsの内部的な仕様まで把握しないといけないので敷居が高めです。
ただ、Three.js自体の勉強にもなるので個人的にはお得だと思います。

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2