概要
Three.jsのReact用ライブラリ react-three-fibar を使用して、オブジェクトのクリッピング表現とクリッピング断面の描画を実装します。
最終的には以下のようなモノができます。
クリッピングとは、オブジェクトの描画範囲をある面で切ることをいいます。
実装
最終的なコードは少し複雑になるので、段階的に実装していきます。
オブジェクトのクリッピング
クリッピング自体はとても簡単に実装できます。
全体のコード
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
で、床面に投影する影をクリッピングされた後のオブジェクトの影にします。
side
にTHREE.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>**からコードを見ることができます。