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

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

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

https://swnl9.csb.app/
![output(video-cutter-js.com).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/e24b3fab-dca4-48d8-46d8-409dd3077356.gif)

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/710f64d0-e252-60dc-1144-d9e6b9b45ab7.png" width="600">

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

### オブジェクトのクリッピング
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/bee54ff7-2881-adef-70e6-0f7eb65fdeb5.png" width="600">

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

<details><summary><font color='red'>全体のコード</font></summary><div>

```.tsx
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>
	)
}
```
</div></details>



* **Canvas**が作成されたら、`onCreated`でクリッピングを有効化します。

```.tsx
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`が高い場合、反射物が必要になるためです。

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

* Clipperでは、まず**クリップ面**を定義しています。クリップ面は、**法線ベクトル**と**原点からの距離**で定義します。

```.tsx
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にクリッピング面を指定**します。

```.tsx
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`で、床面に投影する影をクリッピングされた後のオブジェクトの影にします。
`side`に`THREE.DoubleSide`を指定することで、オブジェクトの内部が透過されずに表示されます。

### 断面の描画
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/e87d3341-7133-48f2-a219-1d0c23a74936.png" width="600">

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

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

<details><summary><font color='red'>全体のコード</font></summary><div>

```.tsx
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>
	)
}
```
</div></details>

* オブジェクトは球にしています。

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

* オフセットでは、クリップ面の原点からの距離を調整します。<br>
インスタンスを生成するときの第2引数で指定してもいいのですが、コントローラーやアニメーションを使ってクリップ面の位置を**動的に変更したい場合**はオフセットを指定します。

```.tsx
const planesOffset = useMemo(() => [1, 1, 1], [])
```

* 断面には**形状（どの位置を断面にするか）**と**実態（断面の色など）**を実装する必要があります。実態（描画面）を実装するために、オブジェクトを生成します。

```.tsx
// 断面の描画平面
const planeGeo = new THREE.PlaneGeometry(4, 4)
const [planeGeoRefs] = useState(() => clipPlanes.map(_ => createRef<THREE.Mesh>()))
```
`ref`は、クリップ面3つに対してそれぞれ生成します。

* **useEffect**では、クリップ面の位置と実態の位置を同期させています。<br>
また、ここでオフセットを設定します。

```.tsx
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部分では、オブジェクトの他に、**断面の形状**と**断面の描画平面**を追加しています。<br>
stencil関連のプロパティについては調査できていないので省かせて頂きます。

```.tsx
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のようなアニメーションを付けます。

<details><summary><font color='red'>全体のコード</font></summary><div>

```.tsx
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 => {
// ・・・
}
```
</div></details>

* オブジェクトはトーラスにしています。

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

* オブジェクトを回転させるための**ref**を追加しています。

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

* アニメーションに対応させるために、useEffectから**useFrame**に変更しています。

```.tsx
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`を追加します）<br>
**断面の描画平面**を含めないのは、クリップ面上の面なのでgroupRefによるアニメーションとは無関係だからです。

```.tsx
	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>
	)
```
`ヘルパー`を追加することで、クリップ面の可視化します。

<!-- ================================================= -->
# 成果物
<!-- ================================================= -->

https://codesandbox.io/s/clipping-geometry-swnl9

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

<!-- ================================================= -->
# 参考
<!-- ================================================= -->

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

https://threejs.org/examples/webgl_clipping_stencil.html

https://www.kabuku.co.jp/developers/threejs_clipping

https://codesandbox.io/s/r3f-clipping-planes-21gze

https://codesandbox.io/s/goldie-clipping-sl22h