🎃

Three.js + TypeScriptで断面表示(ローカルクリッピング)

2025/02/02に公開

内容

  • これはTypeScriptからThree.jsを使うサンプルである。
  • 3Dオブジェクトの一部を非表示にするクリッピングを試す。
  • 断面を描画する場合と、しない場合。

プロジェクトの作り方は、以下の記事と同様
https://zenn.dev/ythk/articles/ythk-arphys-three1

断面を描画しない場合

オブジェクトの「ある平面より上の部分を非表示」のような表示は、とても簡単にできる。本質的な部分を取り出すと以下のようになる。

renderer.localClippingEnabled = true; //重要

const plane = new THREE.Plane(法線ベクトル, 原点からの距離);

const mesh = new THREE.Mesh(
  new THREE.SphereGeometry(),// あるいは他のジオメトリ
  new THREE.MeshNormalMaterial({// あるいは他のマテリアル
    clippingPlanes:[plane],  // クリッピングする平面を指定
    side:THREE.DoubleSide,  // クリッピングしたら大概裏側が見えるはず
  }),
);
  • clippingPlanesに複数の平面を指定すると、領域の論理積(つまり共通部分)が表示される。
  • clipShadowsをtrueにすると、影も正しく表示される。
  • clipIntersectionをtrueにすると、論理積ではなく論理和が表示される。

clippingPlanesを作るヘルパー関数

箱型に切り抜くなら、以下のような関数を作っておくと便利かもしれない。

export function createClippingBox({maxX,minX,maxY,minY,maxZ,minZ}:{
    maxX?:number,minX?:number,maxY?:number,minY?:number,maxZ?:number,minZ?:number
}){
    const planes:THREE.Plane[] = [];
    if(maxX)planes.push(new THREE.Plane(new THREE.Vector3(-1, 0, 0), maxX));
    if(minX)planes.push(new THREE.Plane(new THREE.Vector3(1, 0, 0), -minX));
    if(maxY)planes.push(new THREE.Plane(new THREE.Vector3(0, -1, 0), maxY));
    if(minY)planes.push(new THREE.Plane(new THREE.Vector3(0, 1, 0), -minY));
    if(maxZ)planes.push(new THREE.Plane(new THREE.Vector3(0, 0, -1), maxZ));
    if(minZ)planes.push(new THREE.Plane(new THREE.Vector3(0, 0, 1), -minZ));
    return planes;
}

断面を描画する場合

公式のサンプルコードがあるので、それを参考にする。

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

clippingPlanesでオブジェクトを切断した後、断面の形状に切り抜いた平面を描画するのだが、かなり複雑なことをしなくてはいけない[1]

StencilBufferを使って、断面の形状を描画するので、rendererを作るときに、stencilをtrueにする必要がある。

const renderer = new THREE.WebGLRenderer({stencil: true});

かなりごちゃごちゃしてしまうので、別ファイルclippingStencil.tsにまとめておくことにする。

clippingStencil.ts
import * as THREE from 'three';

export function setClipping(
    scene:THREE.Scene, 
    clippingPlanes:THREE.Plane[], 
    clippingTargetMesh:THREE.Mesh, 
    crossSectionMaterial:THREE.Material
):()=>void {
    const planeObjects:THREE.Mesh[] = [];
    const object = new THREE.Group();
    scene.add(object);
    
    const planeGeom = new THREE.PlaneGeometry(10, 10);
    for(let i=0; i<clippingPlanes.length; i++){
        const plane = clippingPlanes[i];
        const stencilGroup = createPlaneStencilGroup( clippingTargetMesh.geometry, plane, i+1);

        const planeMat =  crossSectionMaterial.clone();
        planeMat.clippingPlanes = clippingPlanes.filter( p => p !== plane );
        planeMat.stencilWrite = true;
        planeMat.stencilFunc = THREE.NotEqualStencilFunc;
        planeMat.stencilFail = THREE.ReplaceStencilOp;
        planeMat.stencilZFail = THREE.ReplaceStencilOp;
        planeMat.stencilZPass = THREE.ReplaceStencilOp;

        const po = new THREE.Mesh( planeGeom, planeMat);
        po.onAfterRender = ( renderer ) =>renderer.clearStencil();
        po.renderOrder = i+1.1;

        object.add( stencilGroup );
        object.add( po );
        planeObjects.push( po );
    }

    const update = ()=>{
        for (let i = 0; i < clippingPlanes.length; i ++ ) {
            const plane = clippingPlanes[ i ];
            const po = planeObjects[ 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,
            );
        }
    };
    update();
    return update;
}

function createPlaneStencilGroup(
    geometry:THREE.BufferGeometry, 
    clippingPlane:THREE.Plane, 
    renderOrder:number
) {
    const group = new THREE.Group();
    const baseMat = new THREE.MeshBasicMaterial();
    baseMat.depthWrite = false;
    baseMat.depthTest = false;
    baseMat.colorWrite = false;
    baseMat.stencilWrite = true;
    baseMat.stencilFunc = THREE.AlwaysStencilFunc;

    // back faces
    const mat0 = baseMat.clone();
    mat0.side = THREE.BackSide;
    mat0.clippingPlanes = [ clippingPlane ];
    mat0.stencilFail = THREE.IncrementWrapStencilOp;
    mat0.stencilZFail = THREE.IncrementWrapStencilOp;
    mat0.stencilZPass = THREE.IncrementWrapStencilOp;
    const mesh0 = new THREE.Mesh( geometry, mat0 );
    mesh0.renderOrder = renderOrder;
    group.add( mesh0 );

    // front faces
    const mat1 = baseMat.clone();
    mat1.side = THREE.FrontSide;
    mat1.clippingPlanes = [ clippingPlane ];
    mat1.stencilFail = THREE.DecrementWrapStencilOp;
    mat1.stencilZFail = THREE.DecrementWrapStencilOp;
    mat1.stencilZPass = THREE.DecrementWrapStencilOp;
    const mesh1 = new THREE.Mesh( geometry, mat1 );
    mesh1.renderOrder = renderOrder;
    group.add( mesh1 );

    return group;
}

setClipping関数を呼ぶと、()=>void型の関数が返ってくるので、それを毎フレーム呼ぶことで、断面の位置を更新できる。

clippingPlanesを適当に回転させながら、アニメーション表示してみた。

脚注
  1. よくわからないこともあるけど、とりあえず動く。 ↩︎

Discussion