Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions src/core/BufferGeometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -779,11 +779,35 @@ class BufferGeometry extends EventDispatcher {

let maxRadiusSq = 0;

for ( let i = 0, il = position.count; i < il; i ++ ) {
const array = position.array;

_vector.fromBufferAttribute( position, i );
if ( position.isInterleavedBufferAttribute !== true && position.normalized === false &&

@Mugen87 Mugen87 Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we create a new method on buffer attribute level called supportsDirectAccess()?

This method would decide on attribute level if a direct access is allowed. The policy would be "non-normalized, non-interleaved, float storage".

So BufferAttribute should check:

supportsDirectAccess() {

	const array = this.array;

	return this.normalized === false &&
		( array instanceof Float32Array || array instanceof Float64Array ||
			( typeof Float16Array !== 'undefined' && array instanceof Float16Array ) );

}

InterleavedBufferAttribute and GLBufferAttribute both return false.

position.itemSize === 3 && ( array instanceof Float32Array || array instanceof Float64Array ) ) {

maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( _vector ) );
// fast path: read the typed array directly, avoiding per-vertex getter calls.
// Math.max is used (instead of a comparison) so NaN values propagate to the radius.

const cx = center.x, cy = center.y, cz = center.z;

for ( let i = 0, il = position.count * 3; i < il; i += 3 ) {

const dx = array[ i ] - cx;
const dy = array[ i + 1 ] - cy;
const dz = array[ i + 2 ] - cz;

maxRadiusSq = Math.max( maxRadiusSq, dx * dx + dy * dy + dz * dz );

}

} else {

for ( let i = 0, il = position.count; i < il; i ++ ) {

_vector.fromBufferAttribute( position, i );

maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( _vector ) );

}

}

Expand Down
32 changes: 30 additions & 2 deletions src/math/Box3.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,37 @@ class Box3 {

this.makeEmpty();

for ( let i = 0, il = attribute.count; i < il; i ++ ) {
const array = attribute.array;

this.expandByPoint( _vector.fromBufferAttribute( attribute, i ) );
if ( attribute.isInterleavedBufferAttribute !== true && attribute.normalized === false &&
attribute.itemSize === 3 && ( array instanceof Float32Array || array instanceof Float64Array ) ) {

// fast path: read the typed array directly, avoiding per-vertex getter calls.
// Math.min/max are used (instead of comparisons) so NaN values propagate to min/max.

let minX = + Infinity, minY = + Infinity, minZ = + Infinity;
let maxX = - Infinity, maxY = - Infinity, maxZ = - Infinity;

for ( let i = 0, il = attribute.count * 3; i < il; i += 3 ) {

const x = array[ i ], y = array[ i + 1 ], z = array[ i + 2 ];

minX = Math.min( minX, x ); maxX = Math.max( maxX, x );
minY = Math.min( minY, y ); maxY = Math.max( maxY, y );
minZ = Math.min( minZ, z ); maxZ = Math.max( maxZ, z );

}

this.min.set( minX, minY, minZ );
this.max.set( maxX, maxY, maxZ );

} else {

for ( let i = 0, il = attribute.count; i < il; i ++ ) {

this.expandByPoint( _vector.fromBufferAttribute( attribute, i ) );

}

}

Expand Down
131 changes: 131 additions & 0 deletions test/benchmark/computeBoundingSphere.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Benchmark: BufferGeometry.computeBoundingSphere() fast path vs the previous

@Mugen87 Mugen87 Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we have removed the earlier benchmark suite on purpose. They were not in used because developers often applied benchmarks via jsbench or jsfiddle which are easier to share and execute on various systems like mobile devices.

I would prefer not to adding test/benchmark back.

* implementation (per-vertex Vector3.fromBufferAttribute access).
*
* Run with: node test/benchmark/computeBoundingSphere.js
*/

import { BufferGeometry } from '../../src/core/BufferGeometry.js';
import { BufferAttribute } from '../../src/core/BufferAttribute.js';
import { Box3 } from '../../src/math/Box3.js';
import { Sphere } from '../../src/math/Sphere.js';
import { Vector3 } from '../../src/math/Vector3.js';

const _box = new Box3();
const _vector = new Vector3();

// faithful copy of the previous implementation (no morph targets)

function computeBoundingSphereOld( geometry ) {

const sphere = new Sphere();
const position = geometry.attributes.position;
const center = sphere.center;

// old Box3.setFromBufferAttribute path

_box.makeEmpty();

for ( let i = 0, il = position.count; i < il; i ++ ) {

_box.expandByPoint( _vector.fromBufferAttribute( position, i ) );

}

_box.getCenter( center );

let maxRadiusSq = 0;

for ( let i = 0, il = position.count; i < il; i ++ ) {

_vector.fromBufferAttribute( position, i );

maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( _vector ) );

}

sphere.radius = Math.sqrt( maxRadiusSq );

return sphere;

}

function makeGeometry( vertexCount ) {

const array = new Float32Array( vertexCount * 3 );

for ( let i = 0; i < array.length; i ++ ) {

array[ i ] = Math.random() * 200 - 100;

}

const geometry = new BufferGeometry();
geometry.setAttribute( 'position', new BufferAttribute( array, 3 ) );

return geometry;

}

function bench( fn, iterations ) {

const times = [];

for ( let i = 0; i < iterations; i ++ ) {

const start = performance.now();
fn();
times.push( performance.now() - start );

}

times.sort( ( a, b ) => a - b );

return times[ Math.floor( times.length / 2 ) ]; // median

}

const sizes = [ 10_000, 100_000, 1_000_000, 5_000_000 ];
const iterations = 20;

console.log( `median of ${ iterations } runs per case\n` );
console.log( 'vertices'.padStart( 10 ), 'old (ms)'.padStart( 12 ), 'new (ms)'.padStart( 12 ), 'speedup'.padStart( 10 ) );

for ( const size of sizes ) {

const geometry = makeGeometry( size );

// correctness check

const oldSphere = computeBoundingSphereOld( geometry );
geometry.computeBoundingSphere();
const newSphere = geometry.boundingSphere;

if ( Math.abs( oldSphere.radius - newSphere.radius ) > 1e-10 ||
oldSphere.center.distanceTo( newSphere.center ) > 1e-10 ) {

console.error( `MISMATCH at ${ size } vertices: old`, oldSphere, 'new', newSphere );
process.exit( 1 );

}

// warmup

for ( let i = 0; i < 3; i ++ ) {

computeBoundingSphereOld( geometry );
geometry.computeBoundingSphere();

}

const oldMs = bench( () => computeBoundingSphereOld( geometry ), iterations );
const newMs = bench( () => geometry.computeBoundingSphere(), iterations );

console.log(
String( size ).padStart( 10 ),
oldMs.toFixed( 3 ).padStart( 12 ),
newMs.toFixed( 3 ).padStart( 12 ),
( oldMs / newMs ).toFixed( 2 ).padStart( 9 ) + 'x'
);

}