413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
import * as THREE from 'three'
|
|
import * as CANNON from 'cannon-es'
|
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
|
import type { ModelGeometry, PhysicsObject } from './types'
|
|
|
|
export class ModelLoader {
|
|
private loader: GLTFLoader
|
|
private modelGeometry: ModelGeometry | null = null
|
|
|
|
constructor() {
|
|
this.loader = new GLTFLoader()
|
|
}
|
|
|
|
private extractModelGeometry(gltf: any): ModelGeometry | null {
|
|
const geometries: THREE.BufferGeometry[] = []
|
|
const boundingBox = new THREE.Box3()
|
|
|
|
// Update world matrices first
|
|
gltf.scene.updateMatrixWorld(true)
|
|
|
|
// Traverse the model to find all mesh geometries
|
|
gltf.scene.traverse((child: any) => {
|
|
if (child instanceof THREE.Mesh && child.geometry) {
|
|
// Apply the mesh's transform to the geometry
|
|
const geometry = child.geometry.clone() as THREE.BufferGeometry
|
|
|
|
// Only apply matrix if it's not identity
|
|
if (!child.matrixWorld.equals(new THREE.Matrix4())) {
|
|
geometry.applyMatrix4(child.matrixWorld)
|
|
}
|
|
|
|
geometries.push(geometry)
|
|
|
|
// Update bounding box
|
|
const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute
|
|
if (positionAttr) {
|
|
const geometryBoundingBox = new THREE.Box3().setFromBufferAttribute(positionAttr)
|
|
boundingBox.union(geometryBoundingBox)
|
|
}
|
|
}
|
|
})
|
|
|
|
if (geometries.length === 0) {
|
|
console.warn('No geometries found in model')
|
|
return null
|
|
}
|
|
|
|
console.log(`Found ${geometries.length} geometries to merge`)
|
|
|
|
// Merge all geometries
|
|
const combinedGeometry = this.mergeBufferGeometries(geometries)
|
|
|
|
// Extract vertices and faces
|
|
const positionAttribute = combinedGeometry.getAttribute('position') as THREE.BufferAttribute
|
|
if (!positionAttribute) {
|
|
console.error('No position attribute found in combined geometry')
|
|
return null
|
|
}
|
|
|
|
const vertices: number[] = Array.from(positionAttribute.array as Float32Array)
|
|
|
|
let indices: number[] = []
|
|
if (combinedGeometry.index) {
|
|
indices = Array.from(combinedGeometry.index.array)
|
|
} else {
|
|
// Generate indices if none exist
|
|
for (let i = 0; i < vertices.length / 3; i++) {
|
|
indices.push(i)
|
|
}
|
|
}
|
|
|
|
console.log(`Extracted ${vertices.length / 3} vertices and ${indices.length / 3} triangles`)
|
|
|
|
combinedGeometry.dispose()
|
|
|
|
return {
|
|
vertices,
|
|
indices,
|
|
boundingBox
|
|
}
|
|
}
|
|
|
|
private mergeBufferGeometries(geometries: THREE.BufferGeometry[]): THREE.BufferGeometry {
|
|
const merged = new THREE.BufferGeometry()
|
|
const attributes: { [key: string]: THREE.BufferAttribute[] } = {}
|
|
let indexOffset = 0
|
|
const mergedIndices: number[] = []
|
|
|
|
// Collect all attributes
|
|
geometries.forEach(geometry => {
|
|
const attributeNames = Object.keys(geometry.attributes)
|
|
attributeNames.forEach(name => {
|
|
if (!attributes[name]) attributes[name] = []
|
|
const attr = geometry.attributes[name]
|
|
if (attr instanceof THREE.BufferAttribute) {
|
|
attributes[name].push(attr)
|
|
}
|
|
})
|
|
|
|
// Handle indices
|
|
if (geometry.index) {
|
|
const indices = Array.from(geometry.index.array)
|
|
indices.forEach(index => mergedIndices.push(index + indexOffset))
|
|
indexOffset += geometry.attributes.position.count
|
|
}
|
|
})
|
|
|
|
// Merge attributes
|
|
Object.keys(attributes).forEach(name => {
|
|
const attributeArrays = attributes[name]
|
|
const itemSize = attributeArrays[0].itemSize
|
|
const normalized = attributeArrays[0].normalized
|
|
|
|
let totalCount = 0
|
|
attributeArrays.forEach(attr => totalCount += attr.count)
|
|
|
|
const mergedArray = new Float32Array(totalCount * itemSize)
|
|
let offset = 0
|
|
|
|
attributeArrays.forEach(attr => {
|
|
mergedArray.set(attr.array as Float32Array, offset)
|
|
offset += attr.array.length
|
|
})
|
|
|
|
merged.setAttribute(name, new THREE.BufferAttribute(mergedArray, itemSize, normalized))
|
|
})
|
|
|
|
if (mergedIndices.length > 0) {
|
|
merged.setIndex(mergedIndices)
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
private downsampleGeometry(geometry: ModelGeometry, factor: number = 0.3): ModelGeometry {
|
|
// Simple downsampling by taking every Nth vertex
|
|
const step = Math.max(1, Math.floor(1 / factor))
|
|
const downsampledVertices: number[] = []
|
|
const downsampledIndices: number[] = []
|
|
const vertexMap = new Map<number, number>()
|
|
|
|
// Downsample vertices
|
|
for (let i = 0; i < geometry.vertices.length; i += step * 3) {
|
|
if (i + 2 < geometry.vertices.length) {
|
|
const originalIndex = i / 3
|
|
const newIndex = downsampledVertices.length / 3
|
|
vertexMap.set(originalIndex, newIndex)
|
|
|
|
downsampledVertices.push(
|
|
geometry.vertices[i],
|
|
geometry.vertices[i + 1],
|
|
geometry.vertices[i + 2]
|
|
)
|
|
}
|
|
}
|
|
|
|
// Downsample indices, keeping only triangles where all vertices are kept
|
|
for (let i = 0; i < geometry.indices.length; i += 3) {
|
|
const v1 = geometry.indices[i]
|
|
const v2 = geometry.indices[i + 1]
|
|
const v3 = geometry.indices[i + 2]
|
|
|
|
if (vertexMap.has(v1) && vertexMap.has(v2) && vertexMap.has(v3)) {
|
|
downsampledIndices.push(
|
|
vertexMap.get(v1)!,
|
|
vertexMap.get(v2)!,
|
|
vertexMap.get(v3)!
|
|
)
|
|
}
|
|
}
|
|
|
|
return {
|
|
vertices: downsampledVertices,
|
|
indices: downsampledIndices,
|
|
boundingBox: geometry.boundingBox
|
|
}
|
|
}
|
|
|
|
private createCollisionShape(geometry: ModelGeometry, scale: number = 1): CANNON.Shape {
|
|
try {
|
|
// For performance, use a simplified approach
|
|
// If the geometry is too complex, fall back to a hull or sphere
|
|
const vertexCount = geometry.vertices.length / 3
|
|
|
|
if (vertexCount > 100) {
|
|
// For complex meshes, use bounding box approximation
|
|
const size = geometry.boundingBox.getSize(new THREE.Vector3())
|
|
const scaledSize = size.multiplyScalar(scale)
|
|
return new CANNON.Box(new CANNON.Vec3(
|
|
scaledSize.x * 0.5,
|
|
scaledSize.y * 0.5,
|
|
scaledSize.z * 0.5
|
|
))
|
|
}
|
|
|
|
// For simpler meshes, try convex hull
|
|
const scaledVertices = geometry.vertices.map(v => v * scale)
|
|
const vertices: CANNON.Vec3[] = []
|
|
|
|
for (let i = 0; i < scaledVertices.length; i += 3) {
|
|
vertices.push(new CANNON.Vec3(
|
|
scaledVertices[i],
|
|
scaledVertices[i + 1],
|
|
scaledVertices[i + 2]
|
|
))
|
|
}
|
|
|
|
// Limit faces to prevent performance issues
|
|
const faces: number[][] = []
|
|
const maxFaces = Math.min(geometry.indices.length / 3, 50)
|
|
|
|
for (let i = 0; i < maxFaces * 3; i += 3) {
|
|
const v1 = geometry.indices[i]
|
|
const v2 = geometry.indices[i + 1]
|
|
const v3 = geometry.indices[i + 2]
|
|
|
|
if (v1 < vertices.length && v2 < vertices.length && v3 < vertices.length) {
|
|
faces.push([v1, v2, v3])
|
|
}
|
|
}
|
|
|
|
if (faces.length > 0) {
|
|
const shape = new CANNON.ConvexPolyhedron({ vertices, faces })
|
|
return shape
|
|
} else {
|
|
throw new Error('No valid faces')
|
|
}
|
|
|
|
} catch (error) {
|
|
console.warn('Failed to create complex collision shape, falling back to sphere:', error)
|
|
// Fallback to sphere using bounding box
|
|
const size = geometry.boundingBox.getSize(new THREE.Vector3())
|
|
const radius = Math.max(size.x, size.y, size.z) * 0.5 * scale
|
|
return new CANNON.Sphere(Math.max(0.3, radius))
|
|
}
|
|
}
|
|
|
|
async loadAndCreateObjects(
|
|
scene: THREE.Scene,
|
|
world: CANNON.World,
|
|
physicsObjects: PhysicsObject[]
|
|
): Promise<void> {
|
|
try {
|
|
const gltf = await this.loader.loadAsync('/models/main_model.glb')
|
|
|
|
// Extract and downsample the geometry for collision detection
|
|
const originalGeometry = this.extractModelGeometry(gltf)
|
|
if (originalGeometry) {
|
|
this.modelGeometry = this.downsampleGeometry(originalGeometry, 0.3) // 30% of original detail
|
|
} else {
|
|
this.modelGeometry = null
|
|
}
|
|
|
|
// Create multiple instances of the loaded model
|
|
const numInstances = 20
|
|
|
|
gltf.scene.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
child.castShadow = true
|
|
child.receiveShadow = true
|
|
}
|
|
})
|
|
|
|
for (let i = 0; i < numInstances; i++) {
|
|
// Clone the model
|
|
const modelClone = gltf.scene.clone()
|
|
|
|
// Start objects close to the attraction point for immediate clustering
|
|
const x = (Math.random() - 0.5) * 6 // Reduced from 25 to 6
|
|
const y = (Math.random() - 0.5) * 6 // Reduced from 25 to 6
|
|
const z = (Math.random() - 0.5) * 6 // Reduced from 25 to 6
|
|
modelClone.position.set(x, y, z)
|
|
|
|
// Random scale variation (40% of current size)
|
|
const scale = (0.5 + Math.random() * 0.5) // Scaled down to 40%
|
|
modelClone.scale.setScalar(scale)
|
|
|
|
// Create physics body with custom collision shape or fallback to sphere
|
|
let shape: CANNON.Shape
|
|
if (this.modelGeometry) {
|
|
shape = this.createCollisionShape(this.modelGeometry, scale)
|
|
} else {
|
|
// Fallback to sphere if geometry extraction failed
|
|
const radius = Math.max(0.3, 0.8 * scale)
|
|
shape = new CANNON.Sphere(radius)
|
|
}
|
|
|
|
const body = new CANNON.Body({
|
|
mass: 1,
|
|
material: world.defaultMaterial,
|
|
type: CANNON.Body.DYNAMIC
|
|
})
|
|
body.addShape(shape)
|
|
body.position.set(x, y, z)
|
|
|
|
// Enable rotational dynamics
|
|
body.angularDamping = 0.1 // Small damping for realistic rotation
|
|
body.linearDamping = 0.05 // Small linear damping
|
|
|
|
// Prevent objects from sleeping during collisions
|
|
body.sleepSpeedLimit = 0.1
|
|
body.sleepTimeLimit = 1
|
|
|
|
// Add collision event listener with rotational effects
|
|
body.addEventListener('collide', (event: any) => {
|
|
|
|
// Add some spin on collision for more dynamic movement
|
|
const impactStrength = event.contact?.getImpactVelocityAlongNormal() || 1
|
|
const spinForce = Math.min(impactStrength * 0.5, 2) // Cap the spin force
|
|
|
|
body.angularVelocity.set(
|
|
body.angularVelocity.x + (Math.random() - 0.5) * spinForce,
|
|
body.angularVelocity.y + (Math.random() - 0.5) * spinForce,
|
|
body.angularVelocity.z + (Math.random() - 0.5) * spinForce
|
|
)
|
|
})
|
|
|
|
// Add gentle initial velocity and rotation
|
|
body.velocity.set(
|
|
(Math.random() - 0.5) * 1, // Reduced from 3 to 1
|
|
(Math.random() - 0.5) * 1, // Reduced from 3 to 1
|
|
(Math.random() - 0.5) * 1 // Reduced from 3 to 1
|
|
)
|
|
|
|
// Add initial angular velocity for natural rotation
|
|
body.angularVelocity.set(
|
|
(Math.random() - 0.5) * 2,
|
|
(Math.random() - 0.5) * 2,
|
|
(Math.random() - 0.5) * 2
|
|
)
|
|
|
|
scene.add(modelClone)
|
|
world.addBody(body)
|
|
|
|
physicsObjects.push({ mesh: modelClone, body })
|
|
}
|
|
|
|
console.log('Created', numInstances, 'instances of main_model with custom collision shapes')
|
|
console.log('Total physics objects:', physicsObjects.length)
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load main_model.glb:', error)
|
|
console.log('Falling back to placeholder objects...')
|
|
this.createFallbackObjects(scene, world, physicsObjects)
|
|
}
|
|
}
|
|
|
|
createFallbackObjects(
|
|
scene: THREE.Scene,
|
|
world: CANNON.World,
|
|
physicsObjects: PhysicsObject[]
|
|
): void {
|
|
// Fallback geometric objects if model loading fails
|
|
const geometries = [
|
|
new THREE.BoxGeometry(0.5, 0.5, 0.5),
|
|
new THREE.SphereGeometry(0.3, 16, 16),
|
|
new THREE.CylinderGeometry(0.2, 0.2, 0.6, 12),
|
|
]
|
|
|
|
const materials = [
|
|
new THREE.MeshStandardMaterial({ color: 0x4a9eff, metalness: 0.7, roughness: 0.3 }),
|
|
new THREE.MeshStandardMaterial({ color: 0xff6b4a, metalness: 0.5, roughness: 0.4 }),
|
|
new THREE.MeshStandardMaterial({ color: 0x4aff6b, metalness: 0.8, roughness: 0.2 }),
|
|
]
|
|
|
|
for (let i = 0; i < 15; i++) {
|
|
const geometry = geometries[i % geometries.length]
|
|
const material = materials[i % materials.length]
|
|
const mesh = new THREE.Mesh(geometry, material)
|
|
mesh.castShadow = true
|
|
mesh.receiveShadow = true
|
|
|
|
// Start objects close to the attraction point for immediate clustering
|
|
const x = (Math.random() - 0.5) * 6 // Reduced from 20 to 6
|
|
const y = (Math.random() - 0.5) * 6 // Reduced from 20 to 6
|
|
const z = (Math.random() - 0.5) * 6 // Reduced from 20 to 6
|
|
mesh.position.set(x, y, z)
|
|
|
|
// Create physics body with rotation enabled
|
|
const shape = new CANNON.Sphere(0.3)
|
|
const body = new CANNON.Body({
|
|
mass: 1,
|
|
material: world.defaultMaterial,
|
|
type: CANNON.Body.DYNAMIC
|
|
})
|
|
body.addShape(shape)
|
|
body.position.set(x, y, z)
|
|
|
|
// Enable rotational dynamics
|
|
body.angularDamping = 0.1
|
|
body.linearDamping = 0.05
|
|
|
|
body.velocity.set(
|
|
(Math.random() - 0.5) * 1, // Reduced from 2 to 1
|
|
(Math.random() - 0.5) * 1, // Reduced from 2 to 1
|
|
(Math.random() - 0.5) * 1 // Reduced from 2 to 1
|
|
)
|
|
|
|
// Add initial angular velocity
|
|
body.angularVelocity.set(
|
|
(Math.random() - 0.5) * 2,
|
|
(Math.random() - 0.5) * 2,
|
|
(Math.random() - 0.5) * 2
|
|
)
|
|
|
|
scene.add(mesh)
|
|
world.addBody(body)
|
|
|
|
physicsObjects.push({ mesh, body })
|
|
}
|
|
}
|
|
}
|