diff --git a/apps/syncd-primitive-ball.js b/apps/syncd-primitive-ball.js new file mode 100644 index 00000000..09e12de3 --- /dev/null +++ b/apps/syncd-primitive-ball.js @@ -0,0 +1,178 @@ +// Networked primitive with ownership model +// Similar to the tumbleweed example - any player can grab control + +const SEND_RATE = 1 / 8 +const v1 = new Vector3() + +// Create a physics-enabled primitive +const ball = world.sphere({ + radius: 0.5, + rigidbody: { + type: 'dynamic', + mass: 0.5, + linearDamping: 0.1, + angularDamping: 0.5, + onContactStart: (e) => { + // When a player touches the ball, they take ownership + if (e.playerId && e.playerId === world.networkId) { + ownerId = world.networkId + app.send('take', ownerId) + } + } + } +}) + +// Position it somewhere visible +ball.position.set(0, 2, -5) +ball.material.color = '#ff00ff' +ball.material.emissiveIntensity = 0.3 + +let ownerId = null +let lastSent = 0 + +if (world.isClient) { + // Wait for initial state from server + if (app.state.ready) { + init(app.state) + } else { + app.on('state', init) + } + + function init(state) { + // Apply initial state + ball.position.fromArray(state.p) + ball.quaternion.fromArray(state.q) + ball.setLinearVelocity(v1.fromArray(state.v)) + + // Set up interpolation for smooth movement + const npos = new LerpVector3(ball.position, SEND_RATE) + const nqua = new LerpQuaternion(ball.quaternion, SEND_RATE) + + // Handle ownership changes + app.on('take', (newOwnerId) => { + if (ownerId === newOwnerId) return + ownerId = newOwnerId + npos.snap() + nqua.snap() + + // Visual feedback for ownership + if (ownerId === world.networkId) { + ball.material.color = '#00ff00' // Green when you own it + } else if (ownerId) { + ball.material.color = '#ff0000' // Red when someone else owns it + } else { + ball.material.color = '#ff00ff' // Purple when no owner + } + }) + + // Receive position updates + app.on('move', (e) => { + if (ownerId === world.networkId) return // Ignore if we own it + npos.pushArray(e.p) + nqua.pushArray(e.q) + ball.setLinearVelocity(v1.fromArray(e.v)) + }) + + // Update loop + app.on('update', (delta) => { + if (ball.sleeping) return + + if (ownerId === world.networkId) { + // We own it - send updates + lastSent += delta + if (lastSent > SEND_RATE) { + lastSent = 0 + app.send('move', { + p: ball.position.toArray(), + q: ball.quaternion.toArray(), + v: ball.getLinearVelocity(v1).toArray(), + }) + } + } else { + // Someone else owns it - interpolate + npos.update(delta) + nqua.update(delta) + } + }) + } + + // Allow kicking the ball when you own it + world.on('keydown', (event) => { + if (event.key === ' ' && ownerId === world.networkId) { + const force = new Vector3( + (Math.random() - 0.5) * 10, + 10, + (Math.random() - 0.5) * 10 + ) + ball.addForce(force, 'impulse') + } + }) +} + +if (world.isServer) { + // Initialize state + app.state.ready = true + app.state.p = ball.position.toArray() + app.state.q = ball.quaternion.toArray() + app.state.v = [0, 0, 0] + app.send('state', app.state) + + // Handle ownership changes + app.on('take', (newOwnerId, networkId) => { + ownerId = newOwnerId + app.send('take', newOwnerId) // Broadcast to all clients + }) + + // Relay position updates + app.on('move', (e, networkId) => { + // Only accept moves from the current owner + if (networkId !== ownerId) return + + app.state.p = e.p + app.state.q = e.q + app.state.v = e.v + ball.position.fromArray(e.p) + ball.quaternion.fromArray(e.q) + ball.setLinearVelocity(v1.fromArray(e.v)) + app.send('move', e, networkId) // Send to all except sender + }) + + // Handle player disconnection + world.on('leave', (e) => { + if (e.player.networkId === ownerId) { + ownerId = null + app.send('take', null) + } + }) + + // Server takes control when no one owns it + app.on('update', (delta) => { + if (ball.sleeping) return + + if (!ownerId) { + lastSent += delta + if (lastSent > SEND_RATE) { + app.state.p = ball.position.toArray() + app.state.q = ball.quaternion.toArray() + app.state.v = ball.getLinearVelocity(v1).toArray() + app.send('move', { + p: app.state.p, + q: app.state.q, + v: app.state.v, + }) + lastSent = 0 + } + } + }) +} + +// Cleanup +app.on('destroy', () => { + app.off('state') + app.off('take') + app.off('move') + app.off('update') + world.off('keydown') + world.off('leave') + world.remove(ball) +}) \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index eddead6f..7a20449b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,4 +4,7 @@ These docs are for 3D artists and developers looking to build assets and/or full - [Commands](/docs/commands.md) - [Models](/docs/models.md) -- [Scripts](/docs/scripts.md) \ No newline at end of file +- [Scripts](/docs/scripts.md) +- [Primitives](/docs/primitives.md) +- [HYP Format](/docs/hyp-format.md) +- [Blender Exporter](/docs/blender-exporter.md) \ No newline at end of file diff --git a/docs/ref/primitives.md b/docs/ref/primitives.md new file mode 100644 index 00000000..3c442661 --- /dev/null +++ b/docs/ref/primitives.md @@ -0,0 +1,279 @@ +# Primitives + +Hyperfy provides convenient methods to create primitive meshes directly from scripts without needing to load external 3D models. These primitives are lightweight, instanced objects that integrate seamlessly with the physics and rendering systems. + +## Available Primitives + +### Box + +Creates a rectangular box mesh. + +```js +const box = world.box({ + width: 1, // Width along X axis (default: 1) + height: 1, // Height along Y axis (default: 1) + depth: 1 // Depth along Z axis (default: 1) +}) +``` + +### Sphere + +Creates a spherical mesh. + +```js +const sphere = world.sphere({ + radius: 0.5 // Sphere radius (default: 0.5) +}) +``` + +### Cylinder + +Creates a cylindrical mesh. + +```js +const cylinder = world.cylinder({ + radiusTop: 0.5, // Top radius (default: 0.5) + radiusBottom: 0.5, // Bottom radius (default: 0.5) + height: 1, // Height along Y axis (default: 1) + radialSegments: 8 // Number of segments around circumference (default: 8) +}) +``` + +### Cone + +Creates a conical mesh. + +```js +const cone = world.cone({ + radius: 0.5, // Base radius (default: 0.5) + height: 1, // Height along Y axis (default: 1) + radialSegments: 8 // Number of segments around circumference (default: 8) +}) +``` + +## Generic Method + +You can also use the generic `spawnMesh` method to create any primitive type: + +```js +const mesh = world.spawnMesh({ + type: 'box', // Required: 'box', 'sphere', 'cylinder', or 'cone' + width: 2, // Type-specific parameters + height: 1, + depth: 1 +}) +``` + +## Common Properties + +All primitives are [Mesh nodes](/docs/ref/Mesh.md) and inherit standard node properties: + +### Transform Properties +```js +// Position +primitive.position.set(x, y, z) +primitive.position.x = 5 + +// Rotation (in radians) +primitive.rotation.set(x, y, z) +primitive.rotation.y = Math.PI / 2 + +// Scale +primitive.scale.set(x, y, z) +primitive.scale.setScalar(2) // Uniform scale + +// Quaternion (alternative to rotation) +primitive.quaternion.setFromEuler(euler) +``` + +### Material Properties +```js +// Color (string format: named colors, hex, rgb, hsl) +primitive.material.color = 'red' +primitive.material.color = '#ff0000' +primitive.material.color = 'rgb(255, 0, 0)' + +// Emissive (self-illumination) +primitive.material.emissive = '#003300' +primitive.material.emissiveIntensity = 0.5 + +// Other material properties +primitive.material.metalness = 0.5 // 0-1, default: 0 +primitive.material.roughness = 0.7 // 0-1, default: 1 +primitive.material.fog = false // Affected by fog, default: true +``` + +### Visibility and Shadows +```js +primitive.active = false // Hide/show the primitive +primitive.castShadow = true // Cast shadows (default: true) +primitive.receiveShadow = true // Receive shadows (default: true) +``` + +## Physics (RigidBody) + +You can optionally create primitives with physics enabled by adding a `rigidbody` option. This automatically creates a rigidbody parent with a matching collider: + +```js +// Simple physics - creates a dynamic rigidbody +const physicsBox = world.box({ + width: 1, + height: 1, + depth: 1, + rigidbody: true // Creates dynamic rigidbody with default settings +}) + +// Advanced physics configuration +const physicsSphere = world.sphere({ + radius: 0.5, + rigidbody: { + type: 'dynamic', // 'static', 'kinematic', or 'dynamic' + mass: 2, // Mass in kg (default: 1) + linearDamping: 0.1, // Linear velocity damping (default: 0) + angularDamping: 0.5, // Angular velocity damping (default: 0.05) + layer: 'prop', // Collision layer (default: 'prop') + trigger: false, // Is it a trigger? (default: false) + convex: false, // Use convex collider? (default: false) + tag: 'pickup', // Custom tag for identification + onContactStart: (other) => { + console.log('Contact started with:', other.tag) + }, + onContactEnd: (other) => { + console.log('Contact ended with:', other.tag) + } + } +}) + +// The returned object is the rigidbody, with the mesh as a child +physicsBox.position.set(0, 5, -3) // Drop from height +physicsBox.setLinearVelocity(new Vector3(0, -1, 0)) +``` + +### Physics Properties + +When `rigidbody` is enabled, the returned object is the rigidbody node (not the mesh). The mesh becomes a child of the rigidbody. This gives you access to physics methods: + +```js +// Physics methods +primitive.addForce(force, mode) +primitive.addTorque(torque, mode) +primitive.setLinearVelocity(velocity) +primitive.setAngularVelocity(velocity) +primitive.getLinearVelocity() +primitive.getAngularVelocity() + +// Access the mesh child for visual properties +primitive.children[0].material.color = 'red' // First child is the mesh +``` + +### Collider Shapes + +- **Box**: Exact box collider matching dimensions +- **Sphere**: Exact sphere collider matching radius +- **Cylinder/Cone**: Currently use box approximation (cylinder/cone colliders coming soon) + +## Cloning Primitives + +Yes, you can clone primitives! Use the standard node cloning method: + +```js +// Create original +const originalBox = world.box({ width: 1, height: 2, depth: 1 }) +originalBox.position.set(0, 1, -5) +originalBox.material.color = 'blue' + +// Clone it +const clonedBox = originalBox.clone() +clonedBox.position.set(2, 1, -5) // Position the clone elsewhere +clonedBox.material.color = 'red' // Give it a different color + +// Add the clone to the world +world.add(clonedBox) +``` + +### Deep Cloning + +By default, `clone()` creates a shallow copy. For a deep clone (including all children): + +```js +const deepClone = originalNode.clone(true) // true = recursive +``` + +## Complete Example + +```js +export default { + init() { + // Create a variety of primitives + this.primitives = [] + + // Rotating box + const box = world.box({ width: 1, height: 1, depth: 1 }) + box.position.set(-3, 1, -5) + box.material.color = '#ff0000' + box.material.emissiveIntensity = 0.2 + this.primitives.push(box) + + // Metallic sphere + const sphere = world.sphere({ radius: 0.5 }) + sphere.position.set(-1, 1, -5) + sphere.material.color = '#0080ff' + sphere.material.metalness = 1 + sphere.material.roughness = 0.2 + this.primitives.push(sphere) + + // Tapered cylinder (different top/bottom radii) + const cylinder = world.cylinder({ + radiusTop: 0.2, + radiusBottom: 0.5, + height: 1.5, + radialSegments: 16 // Smoother cylinder + }) + cylinder.position.set(1, 0.75, -5) + cylinder.material.color = '#00ff00' + this.primitives.push(cylinder) + + // Clone the sphere + const clonedSphere = sphere.clone() + clonedSphere.position.set(3, 1, -5) + clonedSphere.material.color = '#ff00ff' + world.add(clonedSphere) + this.primitives.push(clonedSphere) + }, + + update() { + // Animate primitives + const time = world.getTime() * 0.001 + this.primitives.forEach((prim, i) => { + prim.rotation.y = time + (i * Math.PI / 2) + }) + }, + + destroy() { + // Clean up all primitives + this.primitives.forEach(prim => { + world.remove(prim) + }) + } +} +``` + +## Performance Considerations + +1. **Instancing**: Primitives with identical geometry and material properties are automatically instanced for better performance +2. **Geometry Caching**: Primitive geometries are cached and reused to minimize memory usage +3. **Material Sharing**: Use the same material properties when possible to benefit from instancing +4. **Cleanup**: Always remove primitives in the `destroy()` method to prevent memory leaks + +## Limitations + +1. **Material Changes**: You cannot replace the entire material object, only modify its properties +2. **Geometry Modifications**: Primitive geometry cannot be modified after creation (create a new primitive instead) +3. **Custom Shaders**: Primitives use the standard material system and don't support custom shaders directly + +## Tips + +- Primitives spawn at origin (0,0,0) by default - always set their position +- Use hex strings for consistent color representation +- For invisible colliders, create a primitive and set `primitive.active = false` +- Combine multiple primitives using parent-child relationships for complex shapes \ No newline at end of file diff --git a/src/core/nodes/Mesh.js b/src/core/nodes/Mesh.js index 8c1b3443..e79483cb 100644 --- a/src/core/nodes/Mesh.js +++ b/src/core/nodes/Mesh.js @@ -14,6 +14,9 @@ const defaults = { height: 1, depth: 1, radius: 0.5, + radiusTop: 0.5, + radiusBottom: 0.5, + radialSegments: 8, geometry: null, material: null, linked: true, @@ -22,7 +25,7 @@ const defaults = { visible: true, // DEPRECATED: use Node.active } -const types = ['box', 'sphere', 'geometry'] +const types = ['box', 'sphere', 'cylinder', 'cone', 'geometry'] let boxes = {} const getBox = (width, height, depth) => { @@ -42,6 +45,24 @@ const getSphere = radius => { return spheres[key] } +let cylinders = {} +const getCylinder = (radiusTop, radiusBottom, height, radialSegments) => { + const key = `${radiusTop},${radiusBottom},${height},${radialSegments}` + if (!cylinders[key]) { + cylinders[key] = new THREE.CylinderGeometry(radiusTop, radiusBottom, height, radialSegments) + } + return cylinders[key] +} + +let cones = {} +const getCone = (radius, height, radialSegments) => { + const key = `${radius},${height},${radialSegments}` + if (!cones[key]) { + cones[key] = new THREE.ConeGeometry(radius, height, radialSegments) + } + return cones[key] +} + export class Mesh extends Node { constructor(data = {}) { super(data) @@ -52,40 +73,70 @@ export class Mesh extends Node { this.height = data.height this.depth = data.depth this.radius = data.radius + this.radiusTop = data.radiusTop + this.radiusBottom = data.radiusBottom + this.radialSegments = data.radialSegments this.geometry = data.geometry this.material = data.material this.linked = data.linked this.castShadow = data.castShadow this.receiveShadow = data.receiveShadow this.visible = data.visible + + // Store pending material properties until handle is created + this._pendingMaterialProps = {} } mount() { this.needsRebuild = false - if (!this._geometry) return let geometry if (this._type === 'box') { geometry = getBox(this._width, this._height, this._depth) } else if (this._type === 'sphere') { geometry = getSphere(this._radius) + } else if (this._type === 'cylinder') { + geometry = getCylinder(this._radiusTop, this._radiusBottom, this._height, this._radialSegments) + } else if (this._type === 'cone') { + geometry = getCone(this._radius, this._height, this._radialSegments) } else if (this._type === 'geometry') { geometry = this._geometry } + + // Ensure we have geometry + if (!geometry) { + console.warn('[Mesh] No geometry available for type:', this._type) + return + } + // Ensure we have a material, create default if needed + let material = this._material + if (!material) { + const defaultMat = this.ctx.world.stage.getDefaultMaterial() + material = defaultMat.raw // Use the raw THREE.js material + } if (this._visible) { this.handle = this.ctx.world.stage.insert({ geometry, - material: this._material, + material, linked: this._linked, castShadow: this._castShadow, receiveShadow: this._receiveShadow, matrix: this.matrixWorld, node: this, }) + // Apply any pending material properties + if (this.handle && this.handle.material && this._pendingMaterialProps) { + for (const [key, value] of Object.entries(this._pendingMaterialProps)) { + if (key in this.handle.material) { + this.handle.material[key] = value + } + } + this._pendingMaterialProps = {} + } } else { this.sItem = { matrix: this.matrixWorld, geometry, - material: this._material, + material, getEntity: () => this.ctx.entity, node: this, } @@ -125,6 +176,9 @@ export class Mesh extends Node { this._height = source._height this._depth = source._depth this._radius = source._radius + this._radiusTop = source._radiusTop + this._radiusBottom = source._radiusBottom + this._radialSegments = source._radialSegments this._geometry = source._geometry this._material = source._material this._linked = source._linked @@ -187,7 +241,7 @@ export class Mesh extends Node { } if (this._height === value) return this._height = value - if (this.handle && this._type === 'box') { + if (this.handle && (this._type === 'box' || this._type === 'cylinder' || this._type === 'cone')) { this.needsRebuild = true this.setDirty() } @@ -225,7 +279,55 @@ export class Mesh extends Node { } if (this._radius === value) return this._radius = value - if (this.handle && this._type === 'sphere') { + if (this.handle && (this._type === 'sphere' || this._type === 'cone')) { + this.needsRebuild = true + this.setDirty() + } + } + + get radiusTop() { + return this._radiusTop + } + + set radiusTop(value = defaults.radiusTop) { + if (!isNumber(value)) { + throw new Error('[mesh] radiusTop not a number') + } + if (this._radiusTop === value) return + this._radiusTop = value + if (this.handle && this._type === 'cylinder') { + this.needsRebuild = true + this.setDirty() + } + } + + get radiusBottom() { + return this._radiusBottom + } + + set radiusBottom(value = defaults.radiusBottom) { + if (!isNumber(value)) { + throw new Error('[mesh] radiusBottom not a number') + } + if (this._radiusBottom === value) return + this._radiusBottom = value + if (this.handle && this._type === 'cylinder') { + this.needsRebuild = true + this.setDirty() + } + } + + get radialSegments() { + return this._radialSegments + } + + set radialSegments(value = defaults.radialSegments) { + if (!isNumber(value)) { + throw new Error('[mesh] radialSegments not a number') + } + if (this._radialSegments === value) return + this._radialSegments = value + if (this.handle && (this._type === 'cylinder' || this._type === 'cone')) { this.needsRebuild = true this.setDirty() } @@ -356,6 +458,24 @@ export class Mesh extends Node { set radius(value) { self.radius = value }, + get radiusTop() { + return self.radiusTop + }, + set radiusTop(value) { + self.radiusTop = value + }, + get radiusBottom() { + return self.radiusBottom + }, + set radiusBottom(value) { + self.radiusBottom = value + }, + get radialSegments() { + return self.radialSegments + }, + set radialSegments(value) { + self.radialSegments = value + }, get geometry() { return self.geometry }, @@ -363,7 +483,23 @@ export class Mesh extends Node { self.geometry = value }, get material() { - return self.material + // If handle exists, return the actual material proxy + if (self.handle && self.handle.material) { + return self.handle.material + } + // Otherwise return a temporary proxy that stores properties + if (!self._tempMaterialProxy) { + self._tempMaterialProxy = new Proxy({}, { + set(target, prop, value) { + self._pendingMaterialProps[prop] = value + return true + }, + get(target, prop) { + return self._pendingMaterialProps[prop] + } + }) + } + return self._tempMaterialProxy }, set material(value) { throw new Error('[mesh] set material not supported') diff --git a/src/core/systems/Apps.js b/src/core/systems/Apps.js index 3e7110dd..f7c653e6 100644 --- a/src/core/systems/Apps.js +++ b/src/core/systems/Apps.js @@ -213,6 +213,97 @@ export class Apps extends System { } }) }, + spawnMesh(entity, opts = {}) { + const node = entity.createNode('mesh', opts) + self.worldMethods.add(entity, node) + + // Optionally add rigidbody with matching collider + if (opts.rigidbody) { + const rbOpts = typeof opts.rigidbody === 'object' ? opts.rigidbody : {} + + // Create rigidbody as a parent + const rigidbody = entity.createNode('rigidbody', { + type: rbOpts.type || 'dynamic', + mass: rbOpts.mass || 1, + linearDamping: rbOpts.linearDamping || 0, + angularDamping: rbOpts.angularDamping || 0.05, + tag: rbOpts.tag, + onContactStart: rbOpts.onContactStart, + onContactEnd: rbOpts.onContactEnd, + onTriggerEnter: rbOpts.onTriggerEnter, + onTriggerLeave: rbOpts.onTriggerLeave + }) + + // Create matching collider + const colliderOpts = { + type: opts.type, // Use same type as mesh + trigger: rbOpts.trigger || false, + convex: rbOpts.convex || false, + layer: rbOpts.layer || 'prop' + } + + // Set dimensions based on primitive type + if (opts.type === 'box') { + colliderOpts.width = opts.width || 1 + colliderOpts.height = opts.height || 1 + colliderOpts.depth = opts.depth || 1 + } else if (opts.type === 'sphere') { + colliderOpts.radius = opts.radius || 0.5 + } else if (opts.type === 'cylinder' || opts.type === 'cone') { + // For cylinder/cone, use box approximation or custom geometry + // Box approximation based on dimensions + colliderOpts.type = 'box' + colliderOpts.width = (opts.radiusBottom || opts.radius || 0.5) * 2 + colliderOpts.height = opts.height || 1 + colliderOpts.depth = (opts.radiusBottom || opts.radius || 0.5) * 2 + } + + const collider = entity.createNode('collider', colliderOpts) + rigidbody.add(collider) + + // Copy mesh position to rigidbody and clear mesh position + rigidbody.position.copy(node.position) + rigidbody.rotation.copy(node.rotation) + rigidbody.scale.copy(node.scale) + node.position.set(0, 0, 0) + node.rotation.set(0, 0, 0) + node.scale.set(1, 1, 1) + + // Add mesh as child of rigidbody + rigidbody.add(node) + self.worldMethods.add(entity, rigidbody) + + // Get proxies + const rbProxy = rigidbody.getProxy() + const meshProxy = node.getProxy() + + // Add material property that forwards to the mesh child + Object.defineProperty(rbProxy, 'material', { + get() { + // Return the mesh proxy's material + return meshProxy.material + }, + configurable: true + }) + + // Return the enhanced rigidbody proxy + return rbProxy + } + + return node.getProxy() + }, + box(entity, dims = {}) { + return self.worldMethods.spawnMesh(entity, { type: 'box', ...dims }) + }, + sphere(entity, dims = {}) { + return self.worldMethods.spawnMesh(entity, { type: 'sphere', ...dims }) + }, + cylinder(entity, dims = {}) { + return self.worldMethods.spawnMesh(entity, { type: 'cylinder', ...dims }) + }, + cone(entity, dims = {}) { + return self.worldMethods.spawnMesh(entity, { type: 'cone', ...dims }) + }, } }