3
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have 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

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

実装

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

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

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

全体のコード
.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>
	)
}
  • 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で、床面に投影する影をクリッピングされた後のオブジェクトの影にします。
sideTHREE.DoubleSideを指定することで、オブジェクトの内部が透過されずに表示されます。

断面の描画

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

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

全体のコード
.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>
	)
}
  • オブジェクトは球にしています。
.tsx
const geometry = new THREE.SphereGeometry(2, 32, 32)
  • オフセットでは、クリップ面の原点からの距離を調整します。

    インスタンスを生成するときの第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では、クリップ面の位置と実態の位置を同期させています。

    また、ここでオフセットを設定します。
.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部分では、オブジェクトの他に、断面の形状断面の描画平面を追加しています。

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

全体のコード
.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 => {
// ・・・
}
  • オブジェクトはトーラスにしています。
.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を追加します)

    断面の描画平面を含めないのは、クリップ面上の面なので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>
	)

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

成果物

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

参考

  • 公式のサンプルです。開発者モード(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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?