threlte logo
@threlte/extras

bvh

A plugin that uses three-mesh-bvh to speed up raycasting and enable spatial queries against Three.js objects. Any Mesh, BatchedMesh, or Points that are created in the component and child component where this plugin is called are patched with BVH raycasting methods.

<script lang="ts">
  import Scene from './Scene.svelte'
  import { Canvas } from '@threlte/core'
  import { type BVHOptions, BVHSplitStrategy } from '@threlte/extras'
  import { Pane, Checkbox, List, Slider } from 'svelte-tweakpane-ui'

  let options = $state<Required<BVHOptions> & { helper: boolean }>({
    enabled: true,
    helper: true,
    strategy: BVHSplitStrategy.SAH,
    indirect: false,
    verbose: false,
    maxDepth: 20,
    maxLeafTris: 10,
    setBoundingBox: true
  })
</script>

<Pane
  title="bvh"
  position="fixed"
>
  <Checkbox
    label="enabled"
    bind:value={options.enabled}
  />
  <Checkbox
    label="helper"
    bind:value={options.helper}
  />
  <Checkbox
    label="setBoundingBox"
    bind:value={options.setBoundingBox}
  />
  <List
    bind:value={options.strategy}
    label="strategy"
    options={{
      SAH: BVHSplitStrategy.SAH,
      CENTER: BVHSplitStrategy.CENTER,
      AVERAGE: BVHSplitStrategy.AVERAGE
    }}
  />
  <Slider
    label="maxDepth"
    bind:value={options.maxDepth}
    step={1}
  />
  <Slider
    label="maxLeafTris"
    bind:value={options.maxLeafTris}
    step={1}
  />
</Pane>

<Canvas>
  <Scene {...options} />
</Canvas>
<script lang="ts">
  import {
    OrbitControls,
    Grid,
    useGltf,
    Environment,
    Wireframe,
    bvh,
    interactivity,
    type BVHOptions
  } from '@threlte/extras'
  import { T, useTask } from '@threlte/core'
  import { BufferAttribute, DynamicDrawUsage, Mesh, Vector3, type Face } from 'three'

  let { ...rest }: BVHOptions = $props()

  const { raycaster } = interactivity()
  raycaster.firstHitOnly = true

  bvh(() => rest)

  const gltf = useGltf('/models/stanford_bunny.glb')
  const mesh = $derived($gltf ? ($gltf.nodes['Object_2'] as Mesh) : undefined)

  $effect(() => {
    if (mesh) {
      const array = new Float32Array(3 * mesh.geometry.getAttribute('position').count).fill(1)
      const attribute = new BufferAttribute(array, 3).setUsage(DynamicDrawUsage)
      mesh.geometry.setAttribute('color', attribute)
    }
  })

  const faces = new Set<Face>()

  useTask(() => {
    const attribute = mesh?.geometry.getAttribute('color')

    if (!attribute) {
      return
    }

    for (const face of faces) {
      let gb = attribute.getY(face.a)

      gb += 0.01

      if (gb >= 1) {
        gb = 1
        faces.delete(face)
      }

      attribute.setXYZ(face.a, 1, gb, gb)
      attribute.setXYZ(face.b, 1, gb, gb)
      attribute.setXYZ(face.c, 1, gb, gb)

      attribute.needsUpdate = true
    }
  })
</script>

<T.PerspectiveCamera
  makeDefault
  position.x={-1.3}
  position.y={1.8}
  position.z={1.8}
  fov={50}
  oncreate={(ref) => ref.lookAt(0, 0.6, 0)}
>
  <OrbitControls
    enableDamping
    enableZoom={false}
    enablePan={false}
    target={[0, 0.6, 0]}
  />
</T.PerspectiveCamera>

{#if $gltf}
  <T
    is={$gltf.nodes['Object_2'] as Mesh}
    scale={10}
    rotation.x={-Math.PI / 2}
    position.y={-0.35}
    onpointermove={({ face }) => {
      const attribute = mesh?.geometry.getAttribute('color')

      if (face && attribute) {
        attribute.setXYZ(face.a, 1, 0, 0)
        attribute.setXYZ(face.b, 1, 0, 0)
        attribute.setXYZ(face.c, 1, 0, 0)
        faces.add(face)
      }
    }}
  >
    <T.MeshStandardMaterial
      roughness={0.1}
      metalness={0.4}
      vertexColors
    />
    <Wireframe />
  </T>
{/if}

<T.DirectionalLight />

<Environment url="/textures/equirectangular/hdr/shanghai_riverside_1k.hdr" />

<Grid
  sectionThickness={1}
  infiniteGrid
  cellColor="#dddddd"
  sectionColor="#ffffff"
  sectionSize={1}
  cellSize={0.5}
  type="circular"
  fadeOrigin={new Vector3()}
  fadeDistance={20}
  fadeStrength={10}
/>

Basic example

The plugin can be configured by passing a function that returns an object or $state rune as an argument. The following options are available and will be set for every Three.js object.

<script lang="ts">
  import { T } from '@threlte/core'
  import { bvh, interactivity, BVHSplitStrategy, type BVHOptions } from '@threlte/extras'

  // Usually, you'll also want to call the interactivity plugin.
  const { raycaster } = interactivity()

  // This option is usually set with three-mesh-bvh,
  // unless you need multiple hits.
  raycaster.firstHitOnly = true

  // These are the default options.
  const options = $state<BVHOptions>({
    enabled: true,
    helper: false,
    strategy: BVHSplitStrategy.SAH,
    indirect: false,
    verbose: false,
    maxDepth: 20,
    maxLeafTris: 10,
    setBoundingBox: true
  })

  bvh(() => options)
</script>

Setting options at a per object level is possible with the bvh prop.

<T.Mesh bvh={{ maxDepth: 10 }}>
  <T.TorusGeometry />
  <T.MeshStandardMaterial >
</T.Mesh>

If you want this prop to be typesafe, you can extend Threlte.UserProps like so:

import type { InteractivityProps, BVHProps } from '@threlte/extras'

declare global {
  namespace Threlte {
    interface UserProps extends InteractivityProps, BVHProps {}
  }
}

Points

The bvh plugin will shapecast against points, attempting to match Three.js’ raycasting behavior with added three-mesh-bvh optimizations.

<script lang="ts">
  import Scene from './Scene.svelte'
  import { Canvas } from '@threlte/core'
  import { type BVHOptions, BVHSplitStrategy } from '@threlte/extras'
  import { Pane, Checkbox, List, Slider } from 'svelte-tweakpane-ui'

  const options = $state<Required<BVHOptions> & { helper: boolean; firstHitOnly: boolean }>({
    enabled: true,
    strategy: BVHSplitStrategy.SAH,
    indirect: false,
    verbose: false,
    maxDepth: 40,
    maxLeafTris: 20,
    setBoundingBox: true,

    firstHitOnly: false,
    helper: false
  })
</script>

<Pane
  title="bvh"
  position="fixed"
>
  <Checkbox
    label="enabled"
    bind:value={options.enabled}
  />
  <Checkbox
    label="helper"
    bind:value={options.helper}
  />
  <Checkbox
    label="firstHitOnly"
    bind:value={options.firstHitOnly}
  />
  <Checkbox
    label="setBoundingBox"
    bind:value={options.setBoundingBox}
  />
  <List
    bind:value={options.strategy}
    label="strategy"
    options={{
      SAH: BVHSplitStrategy.SAH,
      CENTER: BVHSplitStrategy.CENTER,
      AVERAGE: BVHSplitStrategy.AVERAGE
    }}
  />
  <Slider
    label="maxDepth"
    bind:value={options.maxDepth}
    step={1}
  />
  <Slider
    label="maxLeafTris"
    bind:value={options.maxLeafTris}
    step={1}
  />
</Pane>

<Canvas>
  <Scene {...options} />
</Canvas>
<script lang="ts">
  import {
    OrbitControls,
    useGltf,
    bvh,
    interactivity,
    type BVHOptions,
    PointsMaterial
  } from '@threlte/extras'
  import { T, useTask } from '@threlte/core'
  import { BufferAttribute, DynamicDrawUsage, Points, type Vector3Tuple } from 'three'

  let { ...rest }: BVHOptions & { firstHitOnly: boolean } = $props()

  const { raycaster } = interactivity()
  raycaster.params.Points.threshold = 0.5

  $effect(() => {
    raycaster.firstHitOnly = rest.firstHitOnly
  })

  bvh(() => rest)

  const gltf = useGltf('/models/stairs.glb')

  const points = $derived.by(() => {
    if (!$gltf) {
      return
    }

    const results = $gltf.nodes['Object'] as Points
    const array = new Float32Array(3 * results.geometry.getAttribute('position').count).fill(1)
    const attribute = new BufferAttribute(array, 3).setUsage(DynamicDrawUsage)
    results.geometry.setAttribute('color', attribute)

    return results
  })

  useTask(() => {
    if (!points) return

    const attribute = points.geometry.getAttribute('color')

    const indices = points.userData.indices as Set<number>
    if (indices.size > 0) {
      for (const index of indices) {
        let gb = attribute.getY(index)

        gb += 0.005

        if (gb >= 1) {
          gb = 1
          indices.delete(index)
        }

        attribute.setXYZ(index, 1, gb, gb)
      }

      attribute.needsUpdate = true
    }
  })

  let visible = $state(false)
  let point = $state.raw<Vector3Tuple>([0, 0, 0])
</script>

<T.PerspectiveCamera
  makeDefault
  position.x={20}
  position.y={20}
  position.z={-20}
  fov={50}
>
  <OrbitControls
    enableDamping
    enableZoom={false}
    enablePan={false}
  />
</T.PerspectiveCamera>

{#if points}
  <T
    is={points}
    rotation.x={-Math.PI / 2}
    userData.indices={new Set<number>()}
    onpointerenter={() => {
      visible = true
    }}
    onpointerleave={() => {
      visible = false
    }}
    onpointermove={(event) => {
      point = event.point.toArray()
      if (event.index) {
        points.geometry.getAttribute('color').setXYZ(event.index, 1, 0, 0)
        points.userData.indices.add(event.index)
      }
    }}
  >
    <PointsMaterial
      size={0.2}
      vertexColors
      transparent
      toneMapped={false}
      opacity={0.75}
    />
  </T>
{/if}

<T.Mesh
  position={point}
  renderOrder={1}
  {visible}
  bvh={{ enabled: false }}
>
  <T.SphereGeometry args={[0.5]} />
  <T.MeshBasicMaterial
    color="red"
    depthTest={false}
    transparent
    opacity={0.5}
  />
</T.Mesh>

<T.DirectionalLight />

Limitations

To avoid unnecessary bounds tree computations, the plugin will not recompute bounds trees when geometry changes occur. The plugin is set to recompute only if a reference to a mesh changes. So, if you want to recompute a bounds tree based on a geometry change, you’ll have to regenerate the mesh as well.

{#key geometry}
  <T.Mesh>
    <T is={geometry} />
    <T.MeshStandardMaterial />
  </T.Mesh>
{/key}