Threejs切片3D模型

你正在开发医疗 Web 应用程序、ThreeJS 粉丝,还是对 3d 图形的奇妙世界感到好奇?你来对地方了! 在这篇文章中,我们将教你如何切片网格。

在不到 10 分钟的时间内,你将了解入门所需的一切。 我们将分享一个 React-Three-Fiber (R3F) 代码沙箱供您试验。 ✨ 由于@gkjhonson 在 three-mesh-bvh 包中的出色工作,这篇文章成为可能 ✨

我们的团队在医疗网络领域工作了 10 多年。 虽然切片网格是所有医疗应用的核心部分,但很少有人分享这个主题。 如今,新的开源工具使它变得轻而易举,我们想告诉你我们在 Promaton 是如何做到的。

1、搭建3D场景

为了演示该原理,我们将构建一个小型 R3F 应用程序,它允许我们用平面切割“形状”。

我们定义了一个 3D 模型 (TorusKnot) 和一个切片平面。 我们使用“useControls”提供的滑块来控制平面的位置。

我们还设置了光照、控件和阴影以使演示更加有趣,但我们不会在本文中介绍这些内容!

export default function App() {
  const { constant, transparent } = useControls('plane', {
    transparent: true,
    constant: { value: 0, min: -1, max: 1, step: 0.01 },
  })
  return (
<>
  <Canvas shadows onCreated={(state) => (state.gl.localClippingEnabled = true)}>
    <TorusKnot constant={constant} />
    <SlicingPlane constant={constant} transparent={transparent} />
    {/* We also setup some controls, background color and lighing */}
    <OrbitControls />
    <color attach="background" args={['lightblue']} />
    <Lights />
  </Canvas>
</>
)}

2、切片 3d 模型

在 ThreeJS 中切片网格很简单。 我们打开几个选项,将剪切平面作为材料属性提供给网格,仅此而已。

function TorusKnot({ constant }) {
  const torusKnotSettings = useMemo(() => {
    return [1, 0.2, 50, 50, 2, 3]
  }, [])
  const clippingPlane = useMemo(() => {
    const plane = new Plane()
    plane.normal.set(0, 0, -1)
    return plane
  }, [])
  // Adjust the clipping plane when the constant changes
  useEffect(() => {
    clippingPlane.constant = constant
  }, [clippingPlane, constant])
return (
<>
  {/* Outside of the TorusKnot is Hot Pink */}
  <mesh castShadow receiveShadow>
    <torusKnotBufferGeometry attach="geometry" args={torusKnotSettings} /
    <meshStandardMaterial
      attach="material"
      roughness={1}
      metalness={0.1}
      clippingPlanes={[clippingPlane]}
      clipShadows={true}
      color={'hotpink'}
    />
  </mesh>
  {/* Inside of the TorusKnot  is Dark Pink*/}
  <mesh>
    <torusKnotBufferGeometry attach="geometry" args={torusKnotSettings} /
    <meshStandardMaterial
      attach="material"
      roughness={1}
      metalness={0.1}
      clippingPlanes={[clippingPlane]}
      color={'#E75480'}
      side={BackSide}
    />
  </mesh>
</>
)}

⚠️ ThreeJS 在 GPU 上执行网格切片。 这使得很难单独使用切片:它只能通过 GPU 缓冲区访问,并且执行更改切片颜色和厚度等操作会变得非常复杂。

或者,可以在 CPU 上通过检查网格的每个面是否与切片平面相交来实现切片。 平面和面之间总是有 0 或 2 个交点。 如果我们将所有这些片段加起来,我们就有了一条定义“切片”的“线”。

计算所有相交线段的一种简单方法是遍历网格的所有三角形并测试它们与切片平面的交点。 这是非常低效的,并且随着模型规模的增长,这个操作会变得越来越慢。

这就是“three-bvh-mesh”来救援的地方! 它通过将网格几何存储在 BVH 树中来加速所有空间查询。 “面对面”交叉查询仅发生在靠近切片平面的面上。 它允许你实时计算所有相交段,即使对于非常大的网格也是如此。

然后我们可以将所有交叉段存储在类型化数组中。 类型化数组包含显示网格切片所需的所有信息。 它可以很容易地被 ThreeJS 用于可视化。

注意:出于性能原因,我们使用预先分配的类型化数组,因为每次切片平面的位置发生变化时创建新数组的成本很高。
...
const bvhMesh = useMemo(() => {
  // setup BVH Mesh
  const geometry = new TorusKnotBufferGeometry(1, 0.2, 50, 50, 2, 3)
  return new MeshBVH(geometry, { maxLeafTris: 3 })
}, [])
...
// code re-used and adjusted from https://gkjohnson.github.io/three-mesh-bvh/example/bundle/clippedEdges.html
bvhMesh.shapecast({
  intersectsBounds: (box) => {
    return defaultPlane.intersectsBox(box)
  },
  
  intersectsTriangle: (tri) => {
  // check each triangle edge to see if it intersects with the clippingPlane. If so then add it to the list of segments.
    let count = 0
    tempLine.start.copy(tri.a)
    tempLine.end.copy(tri.b)
    if (defaultPlane.intersectLine(tempLine, tempVector)) {
      posAttr.setXYZ(index, tempVector.x, tempVector.y, tempVector.z)
      index++
      count++
    }
    tempLine.start.copy(tri.b)
    tempLine.end.copy(tri.c)
    if (defaultPlane.intersectLine(tempLine, tempVector)) {
      posAttr.setXYZ(index, tempVector.x, tempVector.y, tempVector.z)
      count++
      index++
    }
    tempLine.start.copy(tri.c)
    tempLine.end.copy(tri.a)
    if (defaultPlane.intersectLine(tempLine, tempVector)) {
      posAttr.setXYZ(index, tempVector.x, tempVector.y, tempVector.z)
      count++
      index++
    }
    // If the place intersected with one or all sides then just remove it
    if (count !== 2) {
      index -= count
    }
  }
})

我们快完成了。 你现在拥有描述类型化数组中切片形状的所有线段。 让我们展示它!

3、显示切片

为了渲染切片,我们使用 ThreeJS 的 LineSegments。 它开箱即用,我们在第 2 步中创建的类型化数组可以直接插入其中。 如果你想更好地控制方面(宽度、虚线等),请考虑 LineSegment2。

⚠️ 请注意,你可能需要调整 lineSegments 的渲染顺序或材质的“polygonOffset*” 和 “depthTest”以获得更好的结果!
function TorusKnotSlice({ constant }) {
  const lineSegRef = useRef()
  const geomRef = useRef()
  const bvhMesh = ...
  useEffect(() => {
    if (bvhMesh && geomRef.current && lineSegRef.current) {
      if (geomRef.current) {
        const geo = geomRef.current
        if (!geo.hasAttribute('position')) {
          const linePosAttr = new BufferAttribute(defaultArray, 3, false)
          linePosAttr.setUsage(DynamicDrawUsage)
          geo.setAttribute('position', linePosAttr
        }
      } 
      let index = 0
      const posAttr = geomRef.current.attributes.position
      defaultPlane.constant = constant
      bvhMesh.shapecast(...)
      // set the draw range to only the new segments and offset the lines so they don't intersect with the geometry
      geomRef.current.setDrawRange(0, index)
      posAttr.needsUpdate = true
    }
  }, [constant, bvhMesh, defaultArray, defaultPlane])
return (
<>
  <lineSegments ref={lineSegRef} frustumCulled={false} matrixAutoUpdate={false} renderOrder={3}>
    <bufferGeometry ref={geomRef} attach="geometry" /
    <lineBasicMaterial
      attach="material"
      // neon yellow
      color={'#ccff15'}
      linewidth={1}
      linecap={'round'}
      linejoin={'round'}
      // battle the z-fightinh
      polygonOffset={true}
      polygonOffsetFactor={-1.0}
      polygonOffsetUnits={4.0}
      depthTest={false}
    />
  </lineSegments>
</>
)}

4、结束语

我们将所有部分放在这个 Code Sandbox 中供你使用!

我们希望这篇文章能激励更多的人踏上基于 Web 的医疗应用程序的美妙旅程。


原文链接:Three steps to slice a mesh with ThreeJS

BimAnt翻译整理,转载请标明出处