spoint 0.1.79 → 0.1.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: spoint
3
- description: "Build multiplayer physics games with the Spawnpoint engine. Use when asked to: create a game, add physics objects, spawn entities, build an arena, handle player interaction, add weapons, respawn, scoring, create moving platforms, manage world config, load 3D models, add HUD/UI, work with the EventBus, or develop any app inside an apps/ directory."
3
+ description: "Build multiplayer physics games with the Spawnpoint engine. Use when asked to: create a game, add physics objects, spawn entities, build a map/level, handle player interaction, add weapons, respawn, scoring, create moving platforms, manage world config, load 3D models, add HUD/UI, work with the EventBus, or develop any app inside an apps/ directory."
4
4
  ---
5
5
 
6
6
  # Spawnpoint App Development Reference
@@ -26,36 +26,30 @@ export default {
26
26
  movement: { maxSpeed: 4.0, groundAccel: 10.0, airAccel: 1.0, friction: 6.0, stopSpeed: 2.0, jumpImpulse: 4.0 },
27
27
  player: { health: 100, capsuleRadius: 0.4, capsuleHalfHeight: 0.9, modelScale: 1.323, feetOffset: 0.212 },
28
28
  scene: { skyColor: 0x87ceeb, fogColor: 0x87ceeb, fogNear: 80, fogFar: 200, sunIntensity: 1.5, sunPosition: [20, 40, 20] },
29
- entities: [{ id: 'arena', position: [0,0,0], app: 'arena' }],
29
+ entities: [{ id: 'environment', model: './apps/game/map.glb', position: [0,0,0], app: 'environment' }],
30
30
  spawnPoint: [0, 2, 0]
31
31
  }
32
32
  ```
33
33
 
34
- ### `apps/arena/index.js`
34
+ ### `apps/environment/index.js` — static environment with trimesh physics
35
35
  ```js
36
- const HALF = 12, WH = 3, WT = 0.5
37
- const WALLS = [
38
- { id:'wn', x:0, y:WH/2, z:-HALF, hx:HALF, hy:WH/2, hz:WT/2 },
39
- { id:'ws', x:0, y:WH/2, z: HALF, hx:HALF, hy:WH/2, hz:WT/2 },
40
- { id:'we', x: HALF, y:WH/2, z:0, hx:WT/2, hy:WH/2, hz:HALF },
41
- { id:'ww', x:-HALF, y:WH/2, z:0, hx:WT/2, hy:WH/2, hz:HALF },
42
- ]
43
36
  export default {
44
37
  server: {
45
- setup(ctx) {
46
- ctx.state.ids = ctx.state.ids || []
47
- if (ctx.state.ids.length > 0) return // hot-reload guard
48
- ctx.entity.custom = { mesh:'box', color:0x5a7a4a, sx:HALF*2, sy:0.5, sz:HALF*2 }
38
+ async setup(ctx) {
49
39
  ctx.physics.setStatic(true)
50
- ctx.physics.addBoxCollider([HALF, 0.25, HALF])
51
- for (const w of WALLS) {
52
- const e = ctx.world.spawn(w.id, { position:[w.x,w.y,w.z], app:'box-static', config:{ hx:w.hx, hy:w.hy, hz:w.hz, color:0x7a6a5a } })
53
- if (e) ctx.state.ids.push(w.id)
40
+ try {
41
+ await ctx.physics.addTrimeshCollider()
42
+ } catch (e) {
43
+ console.log(\`Trimesh failed: \${e.message}, using box fallback\`)
44
+ ctx.physics.addBoxCollider([100, 25, 100])
54
45
  }
55
- },
56
- teardown(ctx) { for (const id of ctx.state.ids||[]) ctx.world.destroy(id); ctx.state.ids = [] }
46
+ }
57
47
  },
58
- client: { render(ctx) { return { position:ctx.entity.position, rotation:ctx.entity.rotation, custom:ctx.entity.custom } } }
48
+ client: {
49
+ render(ctx) {
50
+ return { model: ctx.entity.model, position: ctx.entity.position, rotation: ctx.entity.rotation }
51
+ }
52
+ }
59
53
  }
60
54
  ```
61
55
 
@@ -23,11 +23,16 @@ function discoverModels() {
23
23
 
24
24
  export default {
25
25
  server: {
26
- setup(ctx) {
26
+ async setup(ctx) {
27
27
  ctx.physics.setStatic(true)
28
- // Use simple box collider for environment - trimesh creation is too slow for large meshes
29
- // The environment is primarily visual; players collide with box bounds instead
30
- ctx.physics.addBoxCollider([50, 15, 50])
28
+ // Use trimesh collider for accurate environment collision
29
+ // Deferred to background so server startup isn't blocked
30
+ try {
31
+ await ctx.physics.addTrimeshCollider()
32
+ } catch (e) {
33
+ console.log(`[Environment] Trimesh collider failed: ${e.message}, using box collider`)
34
+ ctx.physics.addBoxCollider([100, 25, 100])
35
+ }
31
36
 
32
37
  ctx.state.smartObjects = new Map()
33
38
  ctx.state.editorMode = false
@@ -1,70 +1,65 @@
1
- export default {
2
- port: 3001,
3
- tickRate: 128,
4
- gravity: [0, -9.81, 0],
5
- movement: {
6
- maxSpeed: 4.0,
7
- groundAccel: 10.0,
8
- airAccel: 1.0,
9
- friction: 6.0,
10
- stopSpeed: 2.0,
11
- jumpImpulse: 4.0,
12
- collisionRestitution: 0.2,
13
- collisionDamping: 0.25
14
- },
15
- player: {
16
- health: 100,
17
- capsuleRadius: 0.4,
18
- capsuleHalfHeight: 0.9,
19
- crouchHalfHeight: 0.45,
20
- mass: 120,
21
- modelScale: 1.323,
22
- feetOffset: 0.212
23
- },
24
- scene: {
25
- skyColor: 0x87ceeb,
26
- fogColor: 0x87ceeb,
27
- fogNear: 80,
28
- fogFar: 200,
29
- ambientColor: 0xfff4d6,
30
- ambientIntensity: 0.3,
31
- sunColor: 0xffffff,
32
- sunIntensity: 1.5,
33
- sunPosition: [21, 50, 20],
34
- fillColor: 0x4488ff,
35
- fillIntensity: 0.4,
36
- fillPosition: [-20, 30, -10],
37
- shadowMapSize: 1024,
38
- shadowBias: 0.0038,
39
- shadowNormalBias: 0.6,
40
- shadowRadius: 12,
41
- shadowBlurSamples: 8
42
- },
43
- camera: {
44
- fov: 70,
45
- shoulderOffset: 0.35,
46
- headHeight: 0.4,
47
- zoomStages: [0, 1.5, 3, 5, 8],
48
- defaultZoomIndex: 2,
49
- followSpeed: 12.0,
50
- snapSpeed: 30.0,
51
- mouseSensitivity: 0.002,
52
- pitchRange: [-1.4, 1.4]
53
- },
54
- animation: {
55
- mixerTimeScale: 1.3,
56
- walkTimeScale: 2.0,
57
- sprintTimeScale: 0.56,
58
- fadeTime: 0.15
59
- },
60
- entities: [
61
- { id: 'environment', model: './apps/tps-game/schwust.glb', position: [0, -10, 0], app: 'environment' },
62
- { id: 'game', position: [0, 0, 0], app: 'tps-game' },
63
- { id: 'power-crates', position: [0, 0, 0], app: 'power-crate' },
64
- { id: 'interact-box', position: [-100, 3, -100], app: 'interactable' }
65
- // To use the primitive arena instead of schwust.glb, replace above with:
66
- // { id: 'arena', position: [0, 0, 0], app: 'arena' }
67
- ],
68
- playerModel: './apps/tps-game/cleetus.vrm',
69
- spawnPoint: [-35, 3, -65]
70
- }
1
+ export default {
2
+ port: 3001,
3
+ tickRate: 128,
4
+ gravity: [0, -9.81, 0],
5
+ movement: {
6
+ maxSpeed: 4.0,
7
+ groundAccel: 10.0,
8
+ airAccel: 1.0,
9
+ friction: 6.0,
10
+ stopSpeed: 2.0,
11
+ jumpImpulse: 4.0,
12
+ collisionRestitution: 0.2,
13
+ collisionDamping: 0.25
14
+ },
15
+ player: {
16
+ health: 100,
17
+ capsuleRadius: 0.4,
18
+ capsuleHalfHeight: 0.9,
19
+ crouchHalfHeight: 0.45,
20
+ mass: 120,
21
+ modelScale: 1.323,
22
+ feetOffset: 0.212
23
+ },
24
+ scene: {
25
+ skyColor: 0x87ceeb,
26
+ fogColor: 0x87ceeb,
27
+ fogNear: 80,
28
+ fogFar: 200,
29
+ ambientColor: 0xfff4d6,
30
+ ambientIntensity: 0.3,
31
+ sunColor: 0xffffff,
32
+ sunIntensity: 1.5,
33
+ sunPosition: [21, 50, 20],
34
+ fillColor: 0x4488ff,
35
+ fillIntensity: 0.4,
36
+ fillPosition: [-20, 30, -10],
37
+ shadowMapSize: 1024,
38
+ shadowBias: 0.0038,
39
+ shadowNormalBias: 0.6,
40
+ shadowRadius: 12,
41
+ shadowBlurSamples: 8
42
+ },
43
+ camera: {
44
+ fov: 70,
45
+ shoulderOffset: 0.35,
46
+ headHeight: 0.4,
47
+ zoomStages: [0, 1.5, 3, 5, 8],
48
+ defaultZoomIndex: 2,
49
+ followSpeed: 12.0,
50
+ snapSpeed: 30.0,
51
+ mouseSensitivity: 0.002,
52
+ pitchRange: [-1.4, 1.4]
53
+ },
54
+ animation: {
55
+ mixerTimeScale: 1.3,
56
+ walkTimeScale: 2.0,
57
+ sprintTimeScale: 0.56,
58
+ fadeTime: 0.15
59
+ },
60
+ entities: [
61
+ { id: 'environment', model: './apps/tps-game/schwust.glb', position: [0, 0, 0], app: 'environment' }
62
+ ],
63
+ playerModel: './apps/tps-game/cleetus.vrm',
64
+ spawnPoint: [-30, 7.6, -30]
65
+ }
package/client/app.js CHANGED
@@ -5,6 +5,7 @@ THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree
5
5
  THREE.Mesh.prototype.raycast = acceleratedRaycast
6
6
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
7
7
  import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
8
+ import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'
8
9
  import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'
9
10
  import { PhysicsNetworkClient, InputHandler, MSG } from '/src/index.client.js'
10
11
  import { createElement, applyDiff } from 'webjsx'
@@ -675,6 +676,7 @@ const dracoLoader = new DRACOLoader(loadingManager)
675
676
  dracoLoader.setDecoderPath('/draco/')
676
677
  dracoLoader.setWorkerLimit(1)
677
678
  gltfLoader.setDRACOLoader(dracoLoader)
679
+ gltfLoader.setMeshoptDecoder(MeshoptDecoder)
678
680
  gltfLoader.register((parser) => new VRMLoaderPlugin(parser))
679
681
  const playerMeshes = new Map()
680
682
  const playerAnimators = new Map()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.79",
3
+ "version": "0.1.80",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -69,10 +69,10 @@ export class AppContext {
69
69
  }
70
70
  },
71
71
  addMeshCollider: (m) => { ent.collider = { type: 'mesh', mesh: m } },
72
- addTrimeshCollider: () => {
72
+ addTrimeshCollider: async () => {
73
73
  ent.collider = { type: 'trimesh', model: ent.model }
74
74
  if (runtime._physics && ent.model) {
75
- const bodyId = runtime._physics.addStaticTrimesh(runtime.resolveAssetPath(ent.model), 0)
75
+ const bodyId = await runtime._physics.addStaticTrimeshAsync(runtime.resolveAssetPath(ent.model), 0, ent.position)
76
76
  ent._physicsBodyId = bodyId
77
77
  }
78
78
  },
@@ -64,25 +64,27 @@ export class AppRuntime {
64
64
  }
65
65
  if (config.autoTrimesh && entity.model && this._physics) {
66
66
  entity.collider = { type: 'trimesh', model: entity.model }
67
- entity._physicsBodyId = this._physics.addStaticTrimesh(this.resolveAssetPath(entity.model), 0)
67
+ this._physics.addStaticTrimeshAsync(this.resolveAssetPath(entity.model), 0)
68
+ .then(id => { entity._physicsBodyId = id })
69
+ .catch(e => console.error(`[AppRuntime] Failed to create trimesh for ${entity.model}:`, e.message))
68
70
  }
69
- if (config.app) this._attachApp(entityId, config.app)
71
+ if (config.app) this._attachApp(entityId, config.app).catch(e => console.error(`[AppRuntime] Failed to attach app ${config.app}:`, e.message))
70
72
  this._spatialInsert(entity)
71
73
  return entity
72
74
  }
73
75
 
74
- _attachApp(entityId, appName) {
76
+ async _attachApp(entityId, appName) {
75
77
  const entity = this.entities.get(entityId), appDef = this._appDefs.get(appName)
76
78
  if (!entity || !appDef) return
77
79
  const ctx = new AppContext(entity, this)
78
80
  this.contexts.set(entityId, ctx); this.apps.set(entityId, appDef)
79
- this._safeCall(appDef.server || appDef, 'setup', [ctx], `setup(${appName})`)
81
+ await this._safeCall(appDef.server || appDef, 'setup', [ctx], `setup(${appName})`)
80
82
  }
81
83
 
82
- attachApp(entityId, appName) { this._attachApp(entityId, appName) }
83
- spawnWithApp(id, cfg = {}, app) { return this.spawnEntity(id, { ...cfg, app }) }
84
- attachAppToEntity(eid, app, cfg = {}) { const e = this.getEntity(eid); if (!e) return false; e._config = cfg; this._attachApp(eid, app); return true }
85
- reattachAppToEntity(eid, app) { this.detachApp(eid); this._attachApp(eid, app) }
84
+ async attachApp(entityId, appName) { await this._attachApp(entityId, appName) }
85
+ async spawnWithApp(id, cfg = {}, app) { return await this.spawnEntity(id, { ...cfg, app }) }
86
+ async attachAppToEntity(eid, app, cfg = {}) { const e = this.getEntity(eid); if (!e) return false; e._config = cfg; await this._attachApp(eid, app); return true }
87
+ async reattachAppToEntity(eid, app) { this.detachApp(eid); await this._attachApp(eid, app) }
86
88
  getEntityWithApp(eid) { const e = this.entities.get(eid); return { entity: e, appName: e?._appName, hasApp: !!e?._appName } }
87
89
 
88
90
  detachApp(entityId) {
@@ -234,5 +236,17 @@ export class AppRuntime {
234
236
  relevantEntities(position, radius) { if (!this._stageLoader) return Array.from(this.entities.keys()); return this._stageLoader.getRelevantEntities(position, radius) }
235
237
 
236
238
  _log(type, data, meta = {}) { if (this._eventLog) this._eventLog.record(type, data, { ...meta, tick: this.currentTick }) }
237
- _safeCall(o, m, a, l) { if (!o?.[m]) return; try { o[m](...a) } catch (e) { console.error(`[AppRuntime] ${l}: ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`) } }
239
+ _safeCall(o, m, a, l) {
240
+ if (!o?.[m]) return Promise.resolve()
241
+ try {
242
+ const result = o[m](...a)
243
+ if (result && typeof result.catch === 'function') {
244
+ return result.catch(e => console.error(`[AppRuntime] ${l}: ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`))
245
+ }
246
+ return Promise.resolve()
247
+ } catch (e) {
248
+ console.error(`[AppRuntime] ${l}: ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`)
249
+ return Promise.reject(e)
250
+ }
251
+ }
238
252
  }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
 
3
3
  let _dracoDecoderPromise = null
4
+ let _meshoptDecoderPromise = null
4
5
 
5
6
  async function getDracoDecoder() {
6
7
  if (!_dracoDecoderPromise) {
@@ -14,6 +15,19 @@ async function getDracoDecoder() {
14
15
  return _dracoDecoderPromise
15
16
  }
16
17
 
18
+ async function getMeshoptDecoder() {
19
+ if (!_meshoptDecoderPromise) {
20
+ try {
21
+ const meshopt = await import('meshoptimizer')
22
+ _meshoptDecoderPromise = meshopt.MeshoptDecoder
23
+ await _meshoptDecoderPromise.ready
24
+ } catch(e) {
25
+ throw new Error(`Failed to load Meshopt decoder: ${e.message}`)
26
+ }
27
+ }
28
+ return _meshoptDecoderPromise
29
+ }
30
+
17
31
  /**
18
32
  * Extract mesh from GLB file for physics collider creation.
19
33
  * Supports both standard and Draco-compressed meshes.
@@ -42,6 +56,13 @@ export function extractMeshFromGLB(filepath, meshIndex = 0) {
42
56
  throw new Error('Draco-compressed mesh detected. Use extractMeshFromGLBAsync() instead.')
43
57
  }
44
58
 
59
+ // Check for meshopt compression (not supported)
60
+ const hasMeshopt = json.bufferViews?.some(bv => bv.extensions?.EXT_meshopt_compression) ||
61
+ json.meshes.some(m => m.primitives.some(p => p.extensions?.EXT_meshopt_compression))
62
+ if (hasMeshopt) {
63
+ throw new Error('Meshopt-compressed mesh detected. Decompress with gltfpack first: gltfpack -i input.glb -o output.glb -noq')
64
+ }
65
+
45
66
  // Standard uncompressed GLB mesh extraction
46
67
  const posAcc = json.accessors[prim.attributes.POSITION]
47
68
  const posView = json.bufferViews[posAcc.bufferView]
@@ -61,13 +82,24 @@ export function extractMeshFromGLB(filepath, meshIndex = 0) {
61
82
  }
62
83
  }
63
84
 
64
- return {
85
+ const result = {
65
86
  vertices,
66
87
  indices,
67
88
  vertexCount: posAcc.count,
68
89
  triangleCount: indices ? indices.length / 3 : 0,
69
90
  name: mesh.name
70
91
  }
92
+
93
+ const node = json.nodes?.find(n => n.mesh === meshIndex)
94
+ if (node) {
95
+ result.nodeTransform = {
96
+ scale: node.scale ? [...node.scale] : null,
97
+ rotation: node.rotation ? [...node.rotation] : null,
98
+ translation: node.translation ? [...node.translation] : null
99
+ }
100
+ }
101
+
102
+ return result
71
103
  }
72
104
 
73
105
  /**
@@ -92,12 +124,29 @@ export async function extractMeshFromGLBAsync(filepath, meshIndex = 0) {
92
124
 
93
125
  const prim = mesh.primitives[0]
94
126
 
95
- // Handle Draco-compressed mesh
127
+ let result
128
+
96
129
  if (prim.extensions?.KHR_draco_mesh_compression) {
97
- return decompressDracoMesh(buf, json, prim, binOffset, mesh.name)
130
+ result = await decompressDracoMesh(buf, json, prim, binOffset, mesh.name)
131
+ } else if (json.bufferViews?.some(bv => bv.extensions?.EXT_meshopt_compression)) {
132
+ result = await extractMeshWithMeshopt(buf, json, prim, binOffset, mesh.name)
133
+ } else {
134
+ result = extractStandardMesh(buf, json, prim, binOffset, mesh.name)
135
+ }
136
+
137
+ const node = json.nodes?.find(n => n.mesh === meshIndex)
138
+ if (node) {
139
+ result.nodeTransform = {
140
+ scale: node.scale ? [...node.scale] : null,
141
+ rotation: node.rotation ? [...node.rotation] : null,
142
+ translation: node.translation ? [...node.translation] : null
143
+ }
98
144
  }
99
145
 
100
- // Standard uncompressed extraction
146
+ return result
147
+ }
148
+
149
+ function extractStandardMesh(buf, json, prim, binOffset, meshName) {
101
150
  const posAcc = json.accessors[prim.attributes.POSITION]
102
151
  const posView = json.bufferViews[posAcc.bufferView]
103
152
  const posOff = binOffset + (posView.byteOffset || 0) + (posAcc.byteOffset || 0)
@@ -121,7 +170,40 @@ export async function extractMeshFromGLBAsync(filepath, meshIndex = 0) {
121
170
  indices,
122
171
  vertexCount: posAcc.count,
123
172
  triangleCount: indices ? indices.length / 3 : 0,
124
- name: mesh.name
173
+ name: meshName
174
+ }
175
+ }
176
+
177
+ function applyNodeTransform(vertices, node) {
178
+ const scale = node.scale ? [node.scale[0], node.scale[1], node.scale[2]] : [1, 1, 1]
179
+ const translation = node.translation ? [node.translation[0], node.translation[1], node.translation[2]] : [0, 0, 0]
180
+
181
+ if (node.rotation) {
182
+ const [qx, qy, qz, qw] = node.rotation
183
+ for (let i = 0; i < vertices.length / 3; i++) {
184
+ let x = vertices[i * 3] * scale[0]
185
+ let y = vertices[i * 3 + 1] * scale[1]
186
+ let z = vertices[i * 3 + 2] * scale[2]
187
+
188
+ const ix = qw * x + qy * z - qz * y
189
+ const iy = qw * y + qz * x - qx * z
190
+ const iz = qw * z + qx * y - qy * x
191
+ const iw = -qx * x - qy * y - qz * z
192
+
193
+ x = ix * qw - iw * qx - iy * qz + iz * qy
194
+ y = iy * qw - iw * qy - iz * qx + ix * qz
195
+ z = iz * qw - iw * qz - ix * qy + iy * qx
196
+
197
+ vertices[i * 3] = x + translation[0]
198
+ vertices[i * 3 + 1] = y + translation[1]
199
+ vertices[i * 3 + 2] = z + translation[2]
200
+ }
201
+ } else {
202
+ for (let i = 0; i < vertices.length / 3; i++) {
203
+ vertices[i * 3] = vertices[i * 3] * scale[0] + translation[0]
204
+ vertices[i * 3 + 1] = vertices[i * 3 + 1] * scale[1] + translation[1]
205
+ vertices[i * 3 + 2] = vertices[i * 3 + 2] * scale[2] + translation[2]
206
+ }
125
207
  }
126
208
  }
127
209
 
@@ -135,76 +217,183 @@ async function decompressDracoMesh(buf, json, prim, binOffset, meshName) {
135
217
  const length = bufView.byteLength
136
218
  const dracoData = buf.slice(offset, offset + length)
137
219
 
138
- // Create decoder and buffer
139
220
  const d = new decoder.Decoder()
140
221
  const db = new decoder.DecoderBuffer()
222
+ const decodedGeom = new decoder.Mesh()
141
223
 
142
224
  try {
143
- // Initialize buffer
144
225
  const dracoArray = new Uint8Array(dracoData)
145
226
  db.Init(dracoArray, dracoArray.length)
146
227
 
147
- // Decode mesh
148
- const decodedGeom = d.DecodeBufferToMesh(db)
149
- if (!decodedGeom) {
150
- throw new Error('Draco decompression failed: empty result')
228
+ const status = d.DecodeBufferToMesh(db, decodedGeom)
229
+ if (!status.ok()) {
230
+ throw new Error(`Draco decompression failed: ${status.error_msg()}`)
151
231
  }
152
232
 
153
- // Get position attribute
154
- const posAttrId = d.GetAttributeIdByName(decodedGeom, 'POSITION')
233
+ const posAttrId = d.GetAttributeId(decodedGeom, decoder.POSITION)
155
234
  if (posAttrId < 0) {
156
235
  throw new Error('No POSITION attribute in decompressed mesh')
157
236
  }
158
237
 
159
238
  const posAttr = d.GetAttribute(decodedGeom, posAttrId)
160
- const posData = d.GetAttributeFloatForAllPoints(decodedGeom, posAttr)
161
- const vertices = new Float32Array(posData)
239
+ const numPoints = decodedGeom.num_points()
240
+ const posData = new decoder.DracoFloat32Array()
241
+ d.GetAttributeFloatForAllPoints(decodedGeom, posAttr, posData)
242
+
243
+ const vertices = new Float32Array(numPoints * 3)
244
+ for (let i = 0; i < numPoints * 3; i++) {
245
+ vertices[i] = posData.GetValue(i)
246
+ }
162
247
 
163
- // Get indices if available
164
248
  let indices = null
165
- if (decodedGeom.num_faces() > 0) {
166
- const indicesData = d.GetTrianglesUInt32Array(decodedGeom, decodedGeom.num_faces())
167
- indices = new Uint32Array(indicesData)
249
+ const numFaces = decodedGeom.num_faces()
250
+ if (numFaces > 0) {
251
+ indices = new Uint32Array(numFaces * 3)
252
+ const faceIndices = new decoder.DracoUInt32Array()
253
+ for (let i = 0; i < numFaces; i++) {
254
+ d.GetFaceFromMesh(decodedGeom, i, faceIndices)
255
+ indices[i * 3] = faceIndices.GetValue(0)
256
+ indices[i * 3 + 1] = faceIndices.GetValue(1)
257
+ indices[i * 3 + 2] = faceIndices.GetValue(2)
258
+ }
259
+ decoder.destroy(faceIndices)
168
260
  }
169
261
 
170
- decoder.destroy(decodedGeom)
262
+ decoder.destroy(posData)
263
+ decoder.destroy(status)
171
264
 
172
265
  return {
173
266
  vertices,
174
267
  indices,
175
- vertexCount: decodedGeom.num_points(),
176
- triangleCount: decodedGeom.num_faces(),
268
+ vertexCount: numPoints,
269
+ triangleCount: numFaces,
177
270
  name: meshName
178
271
  }
179
272
  } finally {
273
+ decoder.destroy(decodedGeom)
180
274
  decoder.destroy(d)
181
275
  decoder.destroy(db)
182
276
  }
183
277
  }
184
278
 
279
+ async function extractMeshWithMeshopt(buf, json, prim, binOffset, meshName) {
280
+ const decoder = await getMeshoptDecoder()
281
+
282
+ const posAcc = json.accessors[prim.attributes.POSITION]
283
+ const posView = json.bufferViews[posAcc.bufferView]
284
+ const posExt = posView.extensions?.EXT_meshopt_compression
285
+
286
+ if (!posExt) {
287
+ throw new Error('Position buffer view has no EXT_meshopt_compression extension')
288
+ }
289
+
290
+ const posSrcOff = binOffset + (posExt.byteOffset || 0)
291
+ const posSrcLen = posExt.byteLength
292
+ const posSrc = new Uint8Array(buf.buffer.slice(posSrcOff, posSrcOff + posSrcLen))
293
+
294
+ const numVertices = posExt.count
295
+ const stride = posExt.byteStride || 12
296
+ const posDst = new Uint8Array(numVertices * stride)
297
+
298
+ const mode = posExt.mode || 'ATTRIBUTES'
299
+ const filter = posExt.filter || 'NONE'
300
+ decoder.decodeGltfBuffer(posDst, numVertices, stride, posSrc, mode, filter)
301
+
302
+ const vertices = new Float32Array(numVertices * 3)
303
+
304
+ // Handle normalized INT16 positions (common in meshopt-compressed models)
305
+ // Normalized INT16: float = raw / 32767.0
306
+ if (stride === 8 && posAcc.normalized && posAcc.componentType === 5122) {
307
+ const raw = new Int16Array(posDst.buffer)
308
+ for (let i = 0; i < numVertices; i++) {
309
+ vertices[i * 3] = raw[i * 4] / 32767.0
310
+ vertices[i * 3 + 1] = raw[i * 4 + 1] / 32767.0
311
+ vertices[i * 3 + 2] = raw[i * 4 + 2] / 32767.0
312
+ }
313
+ } else if (stride === 12) {
314
+ vertices.set(new Float32Array(posDst.buffer))
315
+ } else {
316
+ const floats = new Float32Array(posDst.buffer)
317
+ for (let i = 0; i < numVertices; i++) {
318
+ vertices[i * 3] = floats[i * (stride / 4)]
319
+ vertices[i * 3 + 1] = floats[i * (stride / 4) + 1]
320
+ vertices[i * 3 + 2] = floats[i * (stride / 4) + 2]
321
+ }
322
+ }
323
+
324
+ let indices = null
325
+ if (prim.indices !== undefined) {
326
+ const idxAcc = json.accessors[prim.indices]
327
+ const idxView = json.bufferViews[idxAcc.bufferView]
328
+ const idxExt = idxView.extensions?.EXT_meshopt_compression
329
+
330
+ if (idxExt) {
331
+ const idxSrcOff = binOffset + (idxExt.byteOffset || 0)
332
+ const idxSrcLen = idxExt.byteLength
333
+ const idxSrc = new Uint8Array(buf.buffer.slice(idxSrcOff, idxSrcOff + idxSrcLen))
334
+
335
+ const idxStride = idxExt.byteStride || 2
336
+ const numIndices = idxExt.count
337
+ const idxDst = new Uint8Array(numIndices * idxStride)
338
+
339
+ const idxMode = idxExt.mode || 'TRIANGLES'
340
+ const idxFilter = idxExt.filter || 'NONE'
341
+ decoder.decodeGltfBuffer(idxDst, numIndices, idxStride, idxSrc, idxMode, idxFilter)
342
+
343
+ if (idxAcc.componentType === 5123) {
344
+ indices = new Uint32Array(new Uint16Array(idxDst.buffer))
345
+ } else {
346
+ indices = new Uint32Array(idxDst.buffer)
347
+ }
348
+ } else {
349
+ const idxOff = binOffset + (idxView.byteOffset || 0) + (idxAcc.byteOffset || 0)
350
+ if (idxAcc.componentType === 5123) {
351
+ indices = new Uint32Array(new Uint16Array(buf.buffer.slice(idxOff, idxOff + idxAcc.count * 2)))
352
+ } else {
353
+ indices = new Uint32Array(buf.buffer.slice(idxOff, idxOff + idxAcc.count * 4))
354
+ }
355
+ }
356
+ }
357
+
358
+ return {
359
+ vertices,
360
+ indices,
361
+ vertexCount: numVertices,
362
+ triangleCount: indices ? indices.length / 3 : 0,
363
+ name: meshName
364
+ }
365
+ }
366
+
185
367
  /**
186
368
  * Check if a GLB file has Draco-compressed meshes without attempting extraction.
187
369
  * Useful for validation and error reporting.
188
370
  *
189
371
  * @param {string} filepath - Path to GLB file
190
- * @returns {Object} {hasDraco: boolean, meshes: Array<{name, hasDraco}>}
372
+ * @returns {Object} {hasDraco: boolean, hasMeshopt: boolean, meshes: Array<{name, hasDraco, hasMeshopt}>}
191
373
  */
192
374
  export function detectDracoInGLB(filepath) {
193
375
  try {
194
376
  const buf = readFileSync(filepath)
195
- if (buf.toString('ascii', 0, 4) !== 'glTF') return { hasDraco: false, meshes: [] }
377
+ if (buf.toString('ascii', 0, 4) !== 'glTF') return { hasDraco: false, hasMeshopt: false, meshes: [] }
196
378
 
197
379
  const jsonLen = buf.readUInt32LE(12)
198
380
  const json = JSON.parse(buf.toString('utf-8', 20, 20 + jsonLen))
199
381
 
200
- const meshes = (json.meshes || []).map(mesh => ({
201
- name: mesh.name || 'unnamed',
202
- hasDraco: mesh.primitives?.some(p => p.extensions?.KHR_draco_mesh_compression) || false
203
- }))
382
+ const bufferViewHasMeshopt = (bv) => bv.extensions?.EXT_meshopt_compression
383
+ const hasMeshoptGlobally = (json.bufferViews || []).some(bufferViewHasMeshopt)
204
384
 
205
- const hasDraco = meshes.some(m => m.hasDraco)
206
- return { hasDraco, meshes }
385
+ const meshes = (json.meshes || []).map(mesh => {
386
+ const hasDraco = mesh.primitives?.some(p => p.extensions?.KHR_draco_mesh_compression) || false
387
+ const hasMeshopt = hasMeshoptGlobally || mesh.primitives?.some(p => p.extensions?.EXT_meshopt_compression) || false
388
+ return { name: mesh.name || 'unnamed', hasDraco, hasMeshopt }
389
+ })
390
+
391
+ return {
392
+ hasDraco: meshes.some(m => m.hasDraco),
393
+ hasMeshopt: meshes.some(m => m.hasMeshopt),
394
+ meshes
395
+ }
207
396
  } catch (e) {
208
- return { hasDraco: false, meshes: [], error: e.message }
397
+ return { hasDraco: false, hasMeshopt: false, meshes: [], error: e.message }
209
398
  }
210
399
  }
@@ -1,5 +1,5 @@
1
- import initJolt from 'jolt-physics/wasm-compat'
2
- import { extractMeshFromGLB } from './GLBLoader.js'
1
+ import initJolt from 'jolt-physics/wasm-compat'
2
+ import { extractMeshFromGLB, extractMeshFromGLBAsync } from './GLBLoader.js'
3
3
  const LAYER_STATIC = 0, LAYER_DYNAMIC = 1, NUM_LAYERS = 2
4
4
  let joltInstance = null
5
5
  async function getJolt() { if (!joltInstance) joltInstance = await initJolt(); return joltInstance }
@@ -71,20 +71,110 @@ export class PhysicsWorld {
71
71
  layer = motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC
72
72
  return this._addBody(shape, position, mt, layer, { ...opts, meta: { type: motionType, shape: shapeType } })
73
73
  }
74
- addStaticTrimesh(glbPath, meshIndex = 0) {
75
- const J = this.Jolt
76
- const mesh = extractMeshFromGLB(glbPath, meshIndex)
77
- const triangles = new J.TriangleList(); triangles.resize(mesh.triangleCount)
78
- for (let t = 0; t < mesh.triangleCount; t++) {
79
- const tri = triangles.at(t)
80
- for (let v = 0; v < 3; v++) {
81
- const idx = mesh.indices[t * 3 + v]
82
- tri.set_mV(v, new J.Float3(mesh.vertices[idx * 3], mesh.vertices[idx * 3 + 1], mesh.vertices[idx * 3 + 2]))
83
- }
84
- }
85
- const shape = new J.MeshShapeSettings(triangles).Create().Get()
86
- return this._addBody(shape, [0, 0, 0], J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', mesh: mesh.name, triangles: mesh.triangleCount } })
87
- }
74
+ addStaticTrimesh(glbPath, meshIndex = 0) {
75
+ const J = this.Jolt
76
+ const mesh = extractMeshFromGLB(glbPath, meshIndex)
77
+
78
+ // Apply node transform if present (scale, rotation, translation)
79
+ let vertices = mesh.vertices
80
+ const nodeT = mesh.nodeTransform
81
+ if (nodeT) {
82
+ const numVerts = mesh.vertexCount
83
+ vertices = new Float32Array(numVerts * 3)
84
+
85
+ const scale = nodeT.scale || [1, 1, 1]
86
+ const translation = nodeT.translation || [0, 0, 0]
87
+ const rotation = nodeT.rotation
88
+
89
+ for (let i = 0; i < numVerts; i++) {
90
+ let x = mesh.vertices[i * 3] * scale[0]
91
+ let y = mesh.vertices[i * 3 + 1] * scale[1]
92
+ let z = mesh.vertices[i * 3 + 2] * scale[2]
93
+
94
+ if (rotation) {
95
+ const [qx, qy, qz, qw] = rotation
96
+ const ix = qw * x + qy * z - qz * y
97
+ const iy = qw * y + qz * x - qx * z
98
+ const iz = qw * z + qx * y - qy * x
99
+ const iw = -qx * x - qy * y - qz * z
100
+ x = ix * qw - iw * qx - iy * qz + iz * qy
101
+ y = iy * qw - iw * qy - iz * qx + ix * qz
102
+ z = iz * qw - iw * qz - ix * qy + iy * qx
103
+ }
104
+
105
+ vertices[i * 3] = x + translation[0]
106
+ vertices[i * 3 + 1] = y + translation[1]
107
+ vertices[i * 3 + 2] = z + translation[2]
108
+ }
109
+ }
110
+
111
+ const triangles = new J.TriangleList(); triangles.resize(mesh.triangleCount)
112
+ for (let t = 0; t < mesh.triangleCount; t++) {
113
+ const tri = triangles.at(t)
114
+ for (let v = 0; v < 3; v++) {
115
+ const idx = mesh.indices[t * 3 + v]
116
+ tri.set_mV(v, new J.Float3(vertices[idx * 3], vertices[idx * 3 + 1], vertices[idx * 3 + 2]))
117
+ }
118
+ }
119
+ const shape = new J.MeshShapeSettings(triangles).Create().Get()
120
+ return this._addBody(shape, [0, 0, 0], J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', mesh: mesh.name, triangles: mesh.triangleCount } })
121
+ }
122
+
123
+ addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0]) {
124
+ return new Promise(async (resolve, reject) => {
125
+ try {
126
+ const J = this.Jolt
127
+ const mesh = await extractMeshFromGLBAsync(glbPath, meshIndex)
128
+
129
+ // Apply node transform if present (scale, rotation, translation)
130
+ let vertices = mesh.vertices
131
+ const nodeT = mesh.nodeTransform
132
+ if (nodeT) {
133
+ const numVerts = mesh.vertexCount
134
+ vertices = new Float32Array(numVerts * 3)
135
+
136
+ const scale = nodeT.scale || [1, 1, 1]
137
+ const translation = nodeT.translation || [0, 0, 0]
138
+ const rotation = nodeT.rotation
139
+
140
+ for (let i = 0; i < numVerts; i++) {
141
+ let x = mesh.vertices[i * 3] * scale[0]
142
+ let y = mesh.vertices[i * 3 + 1] * scale[1]
143
+ let z = mesh.vertices[i * 3 + 2] * scale[2]
144
+
145
+ if (rotation) {
146
+ const [qx, qy, qz, qw] = rotation
147
+ const ix = qw * x + qy * z - qz * y
148
+ const iy = qw * y + qz * x - qx * z
149
+ const iz = qw * z + qx * y - qy * x
150
+ const iw = -qx * x - qy * y - qz * z
151
+ x = ix * qw - iw * qx - iy * qz + iz * qy
152
+ y = iy * qw - iw * qy - iz * qx + ix * qz
153
+ z = iz * qw - iw * qz - ix * qy + iy * qx
154
+ }
155
+
156
+ vertices[i * 3] = x + translation[0]
157
+ vertices[i * 3 + 1] = y + translation[1]
158
+ vertices[i * 3 + 2] = z + translation[2]
159
+ }
160
+ }
161
+
162
+ const triangles = new J.TriangleList(); triangles.resize(mesh.triangleCount)
163
+ for (let t = 0; t < mesh.triangleCount; t++) {
164
+ const tri = triangles.at(t)
165
+ for (let v = 0; v < 3; v++) {
166
+ const idx = mesh.indices[t * 3 + v]
167
+ tri.set_mV(v, new J.Float3(vertices[idx * 3], vertices[idx * 3 + 1], vertices[idx * 3 + 2]))
168
+ }
169
+ }
170
+ const shape = new J.MeshShapeSettings(triangles).Create().Get()
171
+ const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', mesh: mesh.name, triangles: mesh.triangleCount } })
172
+ resolve(id)
173
+ } catch (e) {
174
+ reject(e)
175
+ }
176
+ })
177
+ }
88
178
  addPlayerCharacter(radius, halfHeight, position, mass) {
89
179
  const J = this.Jolt
90
180
  const cvs = new J.CharacterVirtualSettings()
@@ -1,83 +0,0 @@
1
- const ASSET_BASE = 'https://raw.githubusercontent.com/anEntrypoint/assets/main'
2
-
3
- const JUNK_MODELS = [
4
- `${ASSET_BASE}/dumpster_b076662a_v1.glb`,
5
- `${ASSET_BASE}/garbage_can_6b3d052b_v1.glb`,
6
- `${ASSET_BASE}/fire_hydrant_ba0175c1_v1.glb`,
7
- `${ASSET_BASE}/crushed_oil_barrel_e450f43f_v1.glb`,
8
- ]
9
-
10
- const HALF = 12
11
- const WALL_H = 3
12
- const WALL_T = 0.5
13
-
14
- const WALLS = [
15
- { id: 'arena-wall-n', x: 0, y: WALL_H / 2, z: -HALF, hx: HALF, hy: WALL_H / 2, hz: WALL_T / 2 },
16
- { id: 'arena-wall-s', x: 0, y: WALL_H / 2, z: HALF, hx: HALF, hy: WALL_H / 2, hz: WALL_T / 2 },
17
- { id: 'arena-wall-e', x: HALF, y: WALL_H / 2, z: 0, hx: WALL_T / 2, hy: WALL_H / 2, hz: HALF },
18
- { id: 'arena-wall-w', x: -HALF, y: WALL_H / 2, z: 0, hx: WALL_T / 2, hy: WALL_H / 2, hz: HALF },
19
- ]
20
-
21
- const PROPS = [
22
- { model: 0, x: -6, z: -6, rot: 0.5 },
23
- { model: 0, x: 7, z: 5, rot: 2.1 },
24
- { model: 1, x: -8, z: 3, rot: 1.0 },
25
- { model: 1, x: 5, z: -7, rot: 3.2 },
26
- { model: 1, x: 3, z: 8, rot: 0.3 },
27
- { model: 2, x: -4, z: -9, rot: 1.5 },
28
- { model: 2, x: 9, z: -3, rot: 4.0 },
29
- { model: 3, x: -7, z: 7, rot: 0.8 },
30
- { model: 3, x: 6, z: 2, rot: 2.7 },
31
- { model: 3, x: -3, z: -5, rot: 1.2 },
32
- ]
33
-
34
- export default {
35
- server: {
36
- setup(ctx) {
37
- ctx.state.ids = ctx.state.ids || []
38
- if (ctx.state.ids.length > 0) return
39
-
40
- // Ground (this entity itself)
41
- ctx.entity.custom = { mesh: 'box', color: 0x5a7a4a, roughness: 1, sx: HALF * 2, sy: 0.5, sz: HALF * 2 }
42
- ctx.physics.setStatic(true)
43
- ctx.physics.addBoxCollider([HALF, 0.25, HALF])
44
-
45
- // Walls - each gets box-static app which adds its own collider
46
- for (const w of WALLS) {
47
- const e = ctx.world.spawn(w.id, {
48
- position: [w.x, w.y, w.z],
49
- app: 'box-static',
50
- config: { hx: w.hx, hy: w.hy, hz: w.hz, color: 0x7a6a5a, roughness: 0.9 }
51
- })
52
- if (e) ctx.state.ids.push(w.id)
53
- }
54
-
55
- // Props from remote asset repo (static, convex hull from model)
56
- for (let i = 0; i < PROPS.length; i++) {
57
- const p = PROPS[i]
58
- const id = `arena-prop-${i}`
59
- const a = p.rot / 2
60
- const e = ctx.world.spawn(id, {
61
- model: JUNK_MODELS[p.model],
62
- position: [p.x, 0, p.z],
63
- rotation: [0, Math.sin(a), 0, Math.cos(a)],
64
- app: 'prop-static'
65
- })
66
- if (e) ctx.state.ids.push(id)
67
- }
68
-
69
- ctx.debug.log(`[arena] setup: ground + ${WALLS.length} walls + ${PROPS.length} props`)
70
- },
71
-
72
- teardown(ctx) {
73
- for (const id of ctx.state.ids || []) ctx.world.destroy(id)
74
- ctx.state.ids = []
75
- }
76
- },
77
-
78
- client: {
79
- render(ctx) {
80
- return { position: ctx.entity.position, rotation: ctx.entity.rotation, custom: ctx.entity.custom }
81
- }
82
- }
83
- }