LoginSignup
3
6

More than 1 year has passed since last update.

【React Three Fiber】オブジェクトのClippingと断面の描画

Last updated at Posted at 2021-10-23

概要

Three.jsのReact用ライブラリ react-three-fibar を使用して、オブジェクトのクリッピング表現クリッピング断面の描画を実装します。

最終的には以下のようなモノができます。

https://swnl9.csb.app/
output(video-cutter-js.com).gif

クリッピングとは、オブジェクトの描画範囲をある面で切ることをいいます。

実装

最終的なコードは少し複雑になるので、段階的に実装していきます。

オブジェクトのクリッピング

クリッピング自体はとても簡単に実装できます。

全体のコード
import { Environment, OrbitControls } from '@react-three/drei';
import { Canvas, RootState } from '@react-three/fiber';
import React, { Suspense, useMemo, VFC } from 'react';
import * as THREE from 'three';

export const Clipping: VFC = () => {
    const createdHandler = (state: RootState) => {
        // let plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0)
        // state.gl.clippingPlanes = [plane]
        state.gl.localClippingEnabled = true
    }

    return (
        <Canvas
            camera={{ fov: 50, position: [0, 3, 10] }}
            dpr={[1, 2]}
            shadows
            onCreated={createdHandler}>
            {/* コントロール */}
            <OrbitControls />
            {/* ライト */}
            <ambientLight intensity={0.1} />
            <directionalLight
                position={[5, 5, 5]}
                intensity={1}
                shadowMapWidth={2048}
                shadowMapHeight={2048}
                castShadow
            />

            {/* オブジェクト */}
            <Suspense fallback={null}>
                <Clipper />
                <Environment preset="city" />
            </Suspense>

            {/* 床 */}
            <mesh position={[0, -2, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
                <planeGeometry args={[10, 10]} />
                <meshPhongMaterial color="#fff" side={THREE.DoubleSide} />
            </mesh>
        </Canvas>
    )
}

// ===========================================

const Clipper: VFC = () => {
    // クリップ面
    const clipPlanes = useMemo(() => {
        const norm = -1
        return [
            new THREE.Plane(new THREE.Vector3(norm, 0, 0), 0),
            new THREE.Plane(new THREE.Vector3(0, norm, 0), 0),
            new THREE.Plane(new THREE.Vector3(0, 0, norm), 0)
        ]
    }, [])
    // オブジェクト
    const geometry = new THREE.BoxGeometry(2, 2, 2)

    return (
        <mesh geometry={geometry} castShadow>
            <meshStandardMaterial
                color="goldenrod"
                metalness={1}
                roughness={0}
                clippingPlanes={clipPlanes}
                clipShadows
                side={THREE.DoubleSide}
            />
        </mesh>
    )
}

  • Canvasが作成されたら、onCreatedでクリッピングを有効化します。
const createdHandler = (state: RootState) => {
    // let plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0)
    // state.gl.clippingPlanes = [plane]
    state.gl.localClippingEnabled = true
}

※ シーン全体のオブジェクトにクリッピングを適応させたい場合は、ここでクリップ面を指定します。今回は床面にはクリッピングを適応させたくないので、ここでは指定しません。

  • オブジェクトでは、周辺環境Environmentを読み込んでいます。作成するオブジェクトのmetalnessが高い場合、反射物が必要になるためです。
{/* オブジェクト */}
<Suspense fallback={null}>
    <Clipper />
    <Environment preset="city" />
</Suspense>
  • Clipperでは、まずクリップ面を定義しています。クリップ面は、法線ベクトル原点からの距離で定義します。
const clipPlanes = useMemo(() => {
    const norm = -1
    return [
        new THREE.Plane(new THREE.Vector3(norm, 0, 0), 0),
        new THREE.Plane(new THREE.Vector3(0, norm, 0), 0),
        new THREE.Plane(new THREE.Vector3(0, 0, norm), 0)
    ]
}, [])

normは、クリップ面がオブジェクトをクリップする向きだと考えてください。1, -1で指定します。

  • このステップで作成するオブジェクトは、2×2(m)のボックスです。このボックスのmeshにクリッピング面を指定します。
const geometry = new THREE.BoxGeometry(2, 2, 2)

return (
    <mesh geometry={geometry} castShadow>
        <meshStandardMaterial
            color="goldenrod"
            metalness={1}
            roughness={0}
            clippingPlanes={clipPlanes}
            clipShadows
            side={THREE.DoubleSide}
        />
    </mesh>
)

clippingPlanesで、クリップ面を指定します。
clipShadowsで、床面に投影する影をクリッピングされた後のオブジェクトの影にします。
sideTHREE.DoubleSideを指定することで、オブジェクトの内部が透過されずに表示されます。

断面の描画

クリッピングした面の断面を描画する場合、すこし複雑なコーディングをする必要があります。

完全に理解はできていないので、「こう書けば実装できるんだな」程度で参考にしてください。

全体のコード
import React, { createRef, Suspense, useEffect, useMemo, useState, VFC } from 'react';
import * as THREE from 'three';
import { Environment, OrbitControls } from '@react-three/drei';
import { Canvas, RootState } from '@react-three/fiber';

export const Clipping: VFC = () => {
    // ・・・
}

// ===========================================

const Clipper: VFC = () => {
    const clipPlanes = useMemo(() => {
        const norm = -1
        return [
            new THREE.Plane(new THREE.Vector3(norm, 0, 0), 0),
            new THREE.Plane(new THREE.Vector3(0, norm, 0), 0),
            new THREE.Plane(new THREE.Vector3(0, 0, norm), 0)
        ]
    }, [])
    // オフセット
    const planesOffset = useMemo(() => [1, 1, 1], [])

    const geometry = new THREE.SphereGeometry(2, 32, 32)

    // 断面の描画平面
    const planeGeo = new THREE.PlaneGeometry(4, 4)
    const [planeGeoRefs] = useState(() => clipPlanes.map(_ => createRef<THREE.Mesh>()))

    useEffect(() => {
        clipPlanes.forEach((plane, i) => {
            const po = planeGeoRefs[i].current!
            plane.constant = planesOffset[i]
            plane.coplanarPoint(po.position)
            po.lookAt(
                po.position.x - plane.normal.x,
                po.position.y - plane.normal.y,
                po.position.z - plane.normal.z
            )
        })
    }, [clipPlanes, planeGeoRefs, planesOffset])

    return (
        <group>
            {/* オブジェクト */}
            <mesh geometry={geometry} renderOrder={6} castShadow>
                <meshStandardMaterial
                    color="goldenrod"
                    metalness={1}
                    roughness={0}
                    clippingPlanes={clipPlanes}
                    clipShadows
                    side={THREE.DoubleSide}
                />
            </mesh>
            {/* 断面の形状 */}
            {clipPlanes.map((plane, i) => (
                <StencilShape key={i} geometry={geometry} plane={plane} renderOrder={i + 1} />
            ))}
            {/* 断面の描画平面 */}
            {planeGeoRefs.map((planeGeoRef, i) => (
                <StencilPlane
                    key={i}
                    planeGeoRef={planeGeoRef}
                    planeGeo={planeGeo}
                    clipPlanes={clipPlanes.filter(p => p !== clipPlanes[i])}
                    renderOrder={i + 1.1}
                />
            ))}
        </group>
    )
}

// ===========================================

type StencilPlaneProps = {
    planeGeoRef: React.RefObject<THREE.Mesh>
    clipPlanes: THREE.Plane[]
    planeGeo: THREE.PlaneGeometry
    renderOrder: number
}

const StencilPlane: VFC<StencilPlaneProps> = props => {
    const { planeGeoRef, clipPlanes, planeGeo, renderOrder } = props

    return (
        <mesh
            ref={planeGeoRef}
            geometry={planeGeo}
            onAfterRender={renderer => renderer.clearStencil()}
            renderOrder={renderOrder}>
            <meshStandardMaterial
                color="darkgoldenrod"
                metalness={0.9}
                roughness={0.1}
                clippingPlanes={clipPlanes}
                // stencil params
                stencilWrite
                stencilRef={0}
                stencilFunc={THREE.NotEqualStencilFunc}
                stencilFail={THREE.ReplaceStencilOp}
                stencilZFail={THREE.ReplaceStencilOp}
                stencilZPass={THREE.ReplaceStencilOp}
            />
        </mesh>
    )
}

// ===========================================

type StencilShapeProps = {
    geometry: THREE.BufferGeometry
    plane: THREE.Plane
    renderOrder: number
}

const StencilShape: VFC<StencilShapeProps> = props => {
    const { geometry, plane, renderOrder } = props

    const mat = {
        clippingPlanes: [plane],
        depthWrite: false,
        depthTest: false,
        colorWrite: false,
        stencilWrite: true,
        stencilFunc: THREE.AlwaysStencilFunc
    }
    const matBack = {
        ...mat,
        side: THREE.BackSide,
        stencilFail: THREE.IncrementWrapStencilOp,
        stencilZFail: THREE.IncrementWrapStencilOp,
        stencilZPass: THREE.IncrementWrapStencilOp
    }
    const matFront = {
        ...mat,
        side: THREE.FrontSide,
        stencilFail: THREE.DecrementWrapStencilOp,
        stencilZFail: THREE.DecrementWrapStencilOp,
        stencilZPass: THREE.DecrementWrapStencilOp
    }

    return (
        <group>
            <mesh geometry={geometry} renderOrder={renderOrder}>
                <meshBasicMaterial {...matFront} />
            </mesh>
            <mesh geometry={geometry} renderOrder={renderOrder}>
                <meshBasicMaterial {...matBack} />
            </mesh>
        </group>
    )
}

  • オブジェクトは球にしています。
const geometry = new THREE.SphereGeometry(2, 32, 32)
  • オフセットでは、クリップ面の原点からの距離を調整します。
    インスタンスを生成するときの第2引数で指定してもいいのですが、コントローラーやアニメーションを使ってクリップ面の位置を動的に変更したい場合はオフセットを指定します。
const planesOffset = useMemo(() => [1, 1, 1], [])
  • 断面には形状(どの位置を断面にするか)実態(断面の色など)を実装する必要があります。実態(描画面)を実装するために、オブジェクトを生成します。
// 断面の描画平面
const planeGeo = new THREE.PlaneGeometry(4, 4)
const [planeGeoRefs] = useState(() => clipPlanes.map(_ => createRef<THREE.Mesh>()))

refは、クリップ面3つに対してそれぞれ生成します。

  • useEffectでは、クリップ面の位置と実態の位置を同期させています。
    また、ここでオフセットを設定します。
useEffect(() => {
    clipPlanes.forEach((plane, i) => {
        const po = planeGeoRefs[i].current!
        plane.constant = planesOffset[i]
        plane.coplanarPoint(po.position)
        po.lookAt(
            po.position.x - plane.normal.x,
            po.position.y - plane.normal.y,
            po.position.z - plane.normal.z
        )
    })
}, [clipPlanes, planeGeoRefs, planesOffset])
  • render部分では、オブジェクトの他に、断面の形状断面の描画平面を追加しています。
    stencil関連のプロパティについては調査できていないので省かせて頂きます。
return (
    <group>
        {/* オブジェクト */}
        <mesh geometry={geometry} renderOrder={6} castShadow>
            <meshStandardMaterial
                color="goldenrod"
                metalness={1}
                roughness={0}
                clippingPlanes={clipPlanes}
                clipShadows
                side={THREE.DoubleSide}
            />
        </mesh>
        {/* 断面の形状 */}
        {clipPlanes.map((plane, i) => (
            <StencilShape key={i} geometry={geometry} plane={plane} renderOrder={i + 1} />
        ))}
        {/* 断面の描画平面 */}
        {planeGeoRefs.map((planeGeoRef, i) => (
            <StencilPlane
                key={i}
                planeGeoRef={planeGeoRef}
                planeGeo={planeGeo}
                clipPlanes={clipPlanes.filter(p => p !== clipPlanes[i])}
                renderOrder={i + 1.1}
            />
        ))}
    </group>
)

アニメーション

オブジェクトに、記事冒頭のGIFのようなアニメーションを付けます。

全体のコード
export const Clipping: VFC = () => {
// ・・・
}

// ===========================================

const Clipper: VFC = () => {
    const clipPlanes = useMemo(() => {
        const norm = -1
        return [
            new THREE.Plane(new THREE.Vector3(norm, 0, 0), 0),
            new THREE.Plane(new THREE.Vector3(0, norm, 0), 0),
            new THREE.Plane(new THREE.Vector3(0, 0, norm), 0)
        ]
    }, [])
    const planesOffset = [1, 1, 1]

    const geometry = new THREE.TorusKnotGeometry(1, 0.05, 1024, 128, 20, 19)

    const planeGeo = new THREE.PlaneGeometry(4, 4)
    const [planeGeoRefs] = useState(() => clipPlanes.map(_ => createRef<THREE.Mesh>()))

    const groupRef = useRef<THREE.Mesh>(null)

    useFrame(({ clock }) => {
        const time = clock.getElapsedTime() / 8
        groupRef.current!.rotation.z += 0.01
        groupRef.current!.rotation.x += 0.002
        clipPlanes.forEach((plane, i) => {
            const po = planeGeoRefs[i].current!
            // plane.constant = planesOffset[i]
            plane.constant = Math.sin(time)
            plane.coplanarPoint(po.position)
            po.lookAt(
                po.position.x - plane.normal.x,
                po.position.y - plane.normal.y,
                po.position.z - plane.normal.z
            )
        })
    })

    return (
        <group>
            <group ref={groupRef}>
                {/* 断面の形状 */}
                {clipPlanes.map((plane, i) => (
                    <StencilShape key={i} geometry={geometry} plane={plane} renderOrder={i + 1} />
                ))}
                {/* オブジェクト */}
                <mesh geometry={geometry} renderOrder={6} castShadow>
                    <meshStandardMaterial
                        color="goldenrod"
                        metalness={1}
                        roughness={0}
                        clippingPlanes={clipPlanes}
                        clipShadows
                        side={THREE.DoubleSide}
                    />
                </mesh>
            </group>

            {/* 断面の描画平面 */}
            {planeGeoRefs.map((planeGeoRef, i) => (
                <StencilPlane
                    key={i}
                    planeGeoRef={planeGeoRef}
                    planeGeo={planeGeo}
                    clipPlanes={clipPlanes.filter(p => p !== clipPlanes[i])}
                    renderOrder={i + 1.1}
                />
            ))}

            {/* ヘルパー */}
            {clipPlanes.map((plane, i) => (
                <planeHelper key={i} args={[plane, 4, 0xffffff]} />
            ))}
        </group>
    )
}

// ===========================================

type StencilPlaneProps = {
    planeGeoRef: React.RefObject<THREE.Mesh>
    clipPlanes: THREE.Plane[]
    planeGeo: THREE.PlaneGeometry
    renderOrder: number
}

const StencilPlane: VFC<StencilPlaneProps> = props => {
// ・・・
}

// ===========================================

type StencilShapeProps = {
    geometry: THREE.BufferGeometry
    plane: THREE.Plane
    renderOrder: number
}

const StencilShape: VFC<StencilShapeProps> = props => {
// ・・・
}

  • オブジェクトはトーラスにしています。
const geometry = new THREE.TorusKnotGeometry(1, 0.05, 1024, 128, 20, 19)
  • オブジェクトを回転させるためのrefを追加しています。
const groupRef = useRef<THREE.Mesh>(null)
  • アニメーションに対応させるために、useEffectからuseFrameに変更しています。
useFrame(({ clock }) => {
    const time = clock.getElapsedTime() / 8
    groupRef.current!.rotation.z += 0.01
    groupRef.current!.rotation.x += 0.002
    clipPlanes.forEach((plane, i) => {
        const po = planeGeoRefs[i].current!
        // plane.constant = planesOffset[i]
        plane.constant = Math.sin(time)
        plane.coplanarPoint(po.position)
        po.lookAt(
            po.position.x - plane.normal.x,
            po.position.y - plane.normal.y,
            po.position.z - plane.normal.z
        )
    })
})

groupRefには、微小な回転を与えています。
plane.constantには、オフセットとして経過時間に応じたsin値を与えています。これによって、クリップ面の位置が毎フレーム変動します。

  • コンポーネントでは、オブジェクト断面の形状をグループ化してアニメーションさせます。(groupRefを追加します)
    断面の描画平面を含めないのは、クリップ面上の面なのでgroupRefによるアニメーションとは無関係だからです。
    return (
        <group>
            <group ref={groupRef}>
                {/* 断面の形状 */}
                {clipPlanes.map((plane, i) => (
                    <StencilShape key={i} geometry={geometry} plane={plane} renderOrder={i + 1} />
                ))}
                {/* オブジェクト */}
                <mesh geometry={geometry} renderOrder={6} castShadow>
                    <meshStandardMaterial
                        {/* ・・・ */}
                    />
                </mesh>
            </group>

            {/* 断面の描画平面 */}
            {planeGeoRefs.map((planeGeoRef, i) => (
                <StencilPlane
                    {/* ・・・ */}
                />
            ))}

            {/* ヘルパー */}
            {clipPlanes.map((plane, i) => (
                <planeHelper key={i} args={[plane, 4, 0xffffff]} />
            ))}
        </group>
    )

ヘルパーを追加することで、クリップ面の可視化します。

成果物

※ほんの少しだけ綺麗なコードに書き直しています。

参考

  • 公式のサンプルです。開発者モード(F12)の要素タブの<script>からコードを見ることができます。

3
6
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
3
6