Hey, guys.
Faced with this problem, I made a small implementation of calculating the distance between 2 points.I have dynamically when I switch on the calculation mode, a marker appears on the surface of the model, which moves on the model when the raycast crosses a part of the model.I would like to know how to optimise this process even more, as it is very costly.
For example, this is a model without this measurement 240 fps, with 71k triangles,
but when I move the mouse on the boundaries of the model as decreases to 80-160 fps.
For optimisation I turned first of all to 3dViewer open source.I’m afraid that my delay may be provoked by the architecture of react components that add a layer of abstraction unlike vanilla js.
Nevertheless, I want to give you my version of how I did it.
import React, {
useRef,
useLayoutEffect,
useState,
useCallback,
useMemo,
} from 'react'
import {
Vector3,
Mesh,
Box3,
Sphere,
Object3D,
Intersection,
Matrix4,
Plane,
Group,
} from 'three'
import { useThree, useFrame } from '@react-three/fiber'
import { Text } from '@react-three/drei'
const RAYCAST_THROTTLE = 16 // ~60fps
const BIG_EPS = 1e-6
const MARKER_SEGMENTS = 32
const LINE_THRESHOLD = 0.01
interface MeasurementPoint {
id: string
position: Vector3
normal: Vector3
intersection: Intersection
timestamp: number
}
interface MeasurementResult {
point1: MeasurementPoint
point2: MeasurementPoint
pointsDistance: number
parallelFacesDistance: number | null
facesAngle: number
}
interface MeasurementToolProps {
measureMode: boolean
modelRef: React.RefObject<Object3D>
}
// Утилиты для расчетов
const isEqualEps = (a: number, b: number, eps: number = BIG_EPS): boolean => {
return Math.abs(a - b) < eps
}
const getFaceWorldNormal = (intersection: Intersection): Vector3 => {
const normalMatrix = new Matrix4()
intersection.object.updateWorldMatrix(true, false)
normalMatrix.extractRotation(intersection.object.matrixWorld)
const faceNormal = intersection.face?.normal?.clone() || new Vector3(0, 1, 0)
faceNormal.applyMatrix4(normalMatrix)
faceNormal.normalize()
return faceNormal
}
const calculateMeasurementValues = (
point1: MeasurementPoint,
point2: MeasurementPoint
): {
pointsDistance: number
parallelFacesDistance: number | null
facesAngle: number
} => {
const pointsDistance = point1.position.distanceTo(point2.position)
const facesAngle = point1.normal.angleTo(point2.normal)
let parallelFacesDistance: number | null = null
// Check for parallel faces (angle ~0° or ~180°)
if (
isEqualEps(facesAngle, 0.0, BIG_EPS) ||
isEqualEps(facesAngle, Math.PI, BIG_EPS)
) {
const plane = new Plane().setFromNormalAndCoplanarPoint(
point1.normal,
point1.position
)
parallelFacesDistance = Math.abs(plane.distanceToPoint(point2.position))
}
return {
pointsDistance,
parallelFacesDistance,
facesAngle,
}
}
// Компонент маркера - оптимизированный
const MeasurementMarker = React.memo(
({
position,
normal,
radius = 0.05,
color = 0xff0000,
opacity = 1,
}: {
position: Vector3
normal: Vector3
radius?: number
color?: number
opacity?: number
}) => {
const groupRef = useRef<Group>(null!)
// Создаем геометрию один раз
const circleGeometry = useMemo(() => {
const points = []
for (let i = 0; i <= MARKER_SEGMENTS; i++) {
const angle = (i / MARKER_SEGMENTS) * Math.PI * 2
points.push(Math.cos(angle) * radius, Math.sin(angle) * radius, 0)
}
return new Float32Array(points)
}, [radius])
const crossGeometry = useMemo(
() => ({
horizontal: new Float32Array([-radius, 0, 0, radius, 0, 0]),
vertical: new Float32Array([0, -radius, 0, 0, radius, 0]),
}),
[radius]
)
useLayoutEffect(() => {
if (groupRef.current && normal) {
groupRef.current.position.copy(position)
groupRef.current.lookAt(position.clone().add(normal))
}
}, [position, normal])
return (
<group ref={groupRef}>
<line>
<bufferGeometry>
<bufferAttribute
attach='attributes-position'
args={[circleGeometry, 3]}
/>
</bufferGeometry>
<lineBasicMaterial
color={color}
transparent={opacity < 1}
opacity={opacity}
depthTest={false}
/>
</line>
<line>
<bufferGeometry>
<bufferAttribute
attach='attributes-position'
args={[crossGeometry.horizontal, 3]}
/>
</bufferGeometry>
<lineBasicMaterial
color={color}
transparent={opacity < 1}
opacity={opacity}
depthTest={false}
/>
</line>
<line>
<bufferGeometry>
<bufferAttribute
attach='attributes-position'
args={[crossGeometry.vertical, 3]}
/>
</bufferGeometry>
<lineBasicMaterial
color={color}
transparent={opacity < 1}
opacity={opacity}
depthTest={false}
/>
</line>
</group>
)
}
)
// Component for the measurement line
const MeasurementLine = React.memo(
({
point1,
point2,
measurementResult,
}: {
point1: MeasurementPoint
point2: MeasurementPoint
measurementResult: ReturnType<typeof calculateMeasurementValues>
}) => {
const midPoint = useMemo(
() => point1.position.clone().add(point2.position).multiplyScalar(0.5),
[point1.position, point2.position]
)
const lineGeometry = useMemo(
() =>
new Float32Array([
point1.position.x,
point1.position.y,
point1.position.z,
point2.position.x,
point2.position.y,
point2.position.z,
]),
[point1.position, point2.position]
)
return (
<group>
<line>
<bufferGeometry>
<bufferAttribute
attach='attributes-position'
args={[lineGeometry, 3]}
/>
</bufferGeometry>
<lineBasicMaterial color={0x00ff00} linewidth={2} depthTest={false} />
</line>
<Text
position={midPoint}
fontSize={0.05}
color='white'
anchorX='center'
anchorY='middle'
renderOrder={999}
material-depthTest={false}
>
{`${measurementResult.pointsDistance.toFixed(3)}${
measurementResult.parallelFacesDistance !== null
? `\n↔ ${measurementResult.parallelFacesDistance.toFixed(3)}`
: ''
}${
measurementResult.facesAngle
? `\n∠ ${((measurementResult.facesAngle * 180) / Math.PI).toFixed(1)}°`
: ''
}`}
</Text>
</group>
)
}
)
export const OptimizedMeasurementTool: React.FC<MeasurementToolProps> = ({
measureMode,
modelRef,
}) => {
const { gl, camera, raycaster, pointer } = useThree()
const handleClickRef = useRef<(e: MouseEvent) => void>(() => {})
const [measurementPoints, setMeasurementPoints] = useState<
MeasurementPoint[]
>([])
const [tempMarker, setTempMarker] = useState<{
position: Vector3
normal: Vector3
intersection: Intersection
} | null>(null)
const [boundingSphere, setBoundingSphere] = useState<Sphere | null>(null)
const [markerRadius, setMarkerRadius] = useState<number>(0.05)
const [isInitialized, setIsInitialized] = useState<boolean>(false)
const measurementResult = useMemo(() => {
if (measurementPoints.length === 2) {
return calculateMeasurementValues(
measurementPoints[0],
measurementPoints[1]
)
}
return null
}, [measurementPoints])
const pickablesRef = useRef<Mesh[]>([])
const lastRaycastTime = useRef<number>(0)
const tempMarkerVisibleRef = useRef<boolean>(false)
// Initialization of the measurement tool
useLayoutEffect(() => {
if (!measureMode) {
setIsInitialized(false)
return
}
// Check if model is loaded
if (!modelRef.current) {
setIsInitialized(false)
return
}
// Check if model has children
if (modelRef.current.children.length === 0) {
setIsInitialized(false)
return
}
try {
// Search for mesh objects in the model
const meshes: Mesh[] = []
modelRef.current.traverse(obj => {
if ((obj as Mesh).isMesh) meshes.push(obj as Mesh)
})
if (meshes.length === 0) {
console.warn('Нет mesh объектов для измерения')
setIsInitialized(false)
return
}
pickablesRef.current = meshes
const box = new Box3()
try {
box.setFromObject(modelRef.current)
// Check if bounding box is valid
if (box.isEmpty()) {
console.warn('Bounding box пустой')
setIsInitialized(false)
return
}
const sphere = new Sphere()
box.getBoundingSphere(sphere)
setBoundingSphere(sphere)
setMarkerRadius(Math.max(sphere.radius / 20, 0.01))
setIsInitialized(true)
} catch (boxError) {
console.error('Ошибка при создании bounding box:', boxError)
setIsInitialized(false)
return
}
} catch (error) {
console.error('Ошибка инициализации измерительного инструмента:', error)
setIsInitialized(false)
}
}, [measureMode, modelRef.current])
// Reset measurement state when measureMode changes
useLayoutEffect(() => {
if (!measureMode) {
setMeasurementPoints([])
setTempMarker(null)
setIsInitialized(false)
}
}, [measureMode])
// Optimized raycasting
useFrame(() => {
if (!measureMode || !isInitialized || pickablesRef.current.length === 0)
return
const now = performance.now()
if (now - lastRaycastTime.current < RAYCAST_THROTTLE) {
return
}
lastRaycastTime.current = now
try {
raycaster.setFromCamera(pointer, camera)
const intersects = raycaster.intersectObjects(pickablesRef.current, false)
if (intersects.length > 0) {
const intersection = intersects[0]
const normal = getFaceWorldNormal(intersection)
if (!tempMarkerVisibleRef.current) {
tempMarkerVisibleRef.current = true
}
setTempMarker({
position: intersection.point.clone(),
normal: normal.clone(),
intersection,
})
} else {
if (tempMarkerVisibleRef.current) {
setTempMarker(null)
tempMarkerVisibleRef.current = false
}
}
} catch (error) {
console.error('Ошибка при raycast:', error)
}
})
// Click handler for adding measurement points
const handleMouseEvent = useCallback(
(event: MouseEvent) => {
if (!measureMode || !tempMarker || !isInitialized) return
event.stopPropagation()
event.preventDefault()
setMeasurementPoints(prev => {
const next = prev.length >= 2 ? [] : [...prev]
next.push({
id: `pt_${Date.now()}`,
position: tempMarker.position.clone(),
normal: tempMarker.normal.clone(),
intersection: tempMarker.intersection,
timestamp: Date.now(),
})
return next
})
},
[measureMode, tempMarker, isInitialized]
)
useLayoutEffect(() => {
handleClickRef.current = handleMouseEvent
}, [handleMouseEvent])
useLayoutEffect(() => {
if (!measureMode || !isInitialized) return
const canvas = gl.domElement
const listener = (e: MouseEvent) => {
handleClickRef.current(e)
}
canvas.addEventListener('dblclick', listener, { passive: false })
return () => {
canvas.removeEventListener('dblclick', listener)
}
}, [measureMode, isInitialized, gl.domElement])
if (!measureMode || !isInitialized) return null
return (
<>
{measurementPoints.map(point => (
<MeasurementMarker
key={point.id}
position={point.position}
normal={point.normal}
radius={markerRadius}
color={0x00ff00}
opacity={1}
/>
))}
{measurementPoints.length === 2 && measurementResult && (
<MeasurementLine
point1={measurementPoints[0]}
point2={measurementPoints[1]}
measurementResult={measurementResult}
/>
)}
{tempMarker && (
<MeasurementMarker
position={tempMarker.position}
normal={tempMarker.normal}
radius={markerRadius}
color={0xffff00}
opacity={0.7}
/>
)}
</>
)
}
I’ve heard of bvh, but I’m not sure if it will help me.