@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}