spoint 0.1.79 → 0.1.81
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 +15 -21
- package/apps/environment/index.js +9 -4
- package/apps/world/index.js +65 -70
- package/client/app.js +2 -0
- package/package.json +1 -1
- package/src/apps/AppContext.js +2 -2
- package/src/apps/AppRuntime.js +23 -9
- package/src/physics/GLBLoader.js +378 -31
- package/src/physics/World.js +94 -12
- package/src/sdk/TickHandler.js +1 -2
- package/apps/arena/index.js +0 -83
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
|
|
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: '
|
|
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/
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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: {
|
|
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
|
|
29
|
-
//
|
|
30
|
-
|
|
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
|
package/apps/world/index.js
CHANGED
|
@@ -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,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
package/src/apps/AppContext.js
CHANGED
|
@@ -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.
|
|
75
|
+
const bodyId = await runtime._physics.addStaticTrimeshAsync(runtime.resolveAssetPath(ent.model), 0, ent.position)
|
|
76
76
|
ent._physicsBodyId = bodyId
|
|
77
77
|
}
|
|
78
78
|
},
|
package/src/apps/AppRuntime.js
CHANGED
|
@@ -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
|
-
|
|
67
|
+
this._physics.addStaticTrimeshAsync(this.resolveAssetPath(entity.model), 0, entity.position || [0,0,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) {
|
|
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
|
}
|
package/src/physics/GLBLoader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
127
|
+
let result
|
|
128
|
+
|
|
96
129
|
if (prim.extensions?.KHR_draco_mesh_compression) {
|
|
97
|
-
|
|
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)
|
|
98
135
|
}
|
|
99
136
|
|
|
100
|
-
|
|
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
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
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:
|
|
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,341 @@ 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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
161
|
-
const
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
indices = new Uint32Array(
|
|
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(
|
|
262
|
+
decoder.destroy(posData)
|
|
263
|
+
decoder.destroy(status)
|
|
171
264
|
|
|
172
265
|
return {
|
|
173
266
|
vertices,
|
|
174
267
|
indices,
|
|
175
|
-
vertexCount:
|
|
176
|
-
triangleCount:
|
|
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
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Extract ALL meshes and ALL primitives from a GLB file and combine them into
|
|
369
|
+
* a single flat vertex/index buffer suitable for a trimesh collider.
|
|
370
|
+
* Handles Draco-compressed primitives. Used for map collision where the GLB
|
|
371
|
+
* may have dozens of meshes each with many primitives.
|
|
372
|
+
*
|
|
373
|
+
* @param {string} filepath - Path to GLB file
|
|
374
|
+
* @returns {Promise<Object>} {vertices: Float32Array, indices: Uint32Array, vertexCount, triangleCount}
|
|
375
|
+
*/
|
|
376
|
+
export async function extractAllMeshesFromGLBAsync(filepath) {
|
|
377
|
+
console.log(`[GLBLoader] Extracting ALL meshes from: ${filepath}`)
|
|
378
|
+
const buf = readFileSync(filepath)
|
|
379
|
+
if (buf.toString('ascii', 0, 4) !== 'glTF') throw new Error('Not a GLB file')
|
|
380
|
+
|
|
381
|
+
const jsonLen = buf.readUInt32LE(12)
|
|
382
|
+
const json = JSON.parse(buf.toString('utf-8', 20, 20 + jsonLen))
|
|
383
|
+
const binOffset = 20 + jsonLen + 8
|
|
384
|
+
|
|
385
|
+
const allVertices = []
|
|
386
|
+
const allIndices = []
|
|
387
|
+
let vertexOffset = 0
|
|
388
|
+
let totalTriangles = 0
|
|
389
|
+
|
|
390
|
+
// Build a node->transform map for node hierarchy
|
|
391
|
+
const nodeTransforms = buildNodeTransforms(json)
|
|
392
|
+
|
|
393
|
+
for (let meshIdx = 0; meshIdx < (json.meshes || []).length; meshIdx++) {
|
|
394
|
+
const mesh = json.meshes[meshIdx]
|
|
395
|
+
// Find node referencing this mesh to get its world transform
|
|
396
|
+
const nodeIdx = (json.nodes || []).findIndex(n => n.mesh === meshIdx)
|
|
397
|
+
const worldTransform = nodeIdx >= 0 ? nodeTransforms[nodeIdx] : null
|
|
398
|
+
|
|
399
|
+
for (let primIdx = 0; primIdx < mesh.primitives.length; primIdx++) {
|
|
400
|
+
const prim = mesh.primitives[primIdx]
|
|
401
|
+
let result
|
|
402
|
+
try {
|
|
403
|
+
if (prim.extensions?.KHR_draco_mesh_compression) {
|
|
404
|
+
result = await decompressDracoMesh(buf, json, prim, binOffset, mesh.name)
|
|
405
|
+
} else {
|
|
406
|
+
result = extractStandardMesh(buf, json, prim, binOffset, mesh.name)
|
|
407
|
+
}
|
|
408
|
+
} catch (e) {
|
|
409
|
+
console.warn(`[GLBLoader] Skipping mesh[${meshIdx}] prim[${primIdx}]: ${e.message}`)
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!result.indices || result.triangleCount === 0) continue
|
|
414
|
+
|
|
415
|
+
// Apply world transform to vertices if present
|
|
416
|
+
let verts = result.vertices
|
|
417
|
+
if (worldTransform) {
|
|
418
|
+
verts = applyTransformMatrix(result.vertices, worldTransform)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
allVertices.push(verts)
|
|
422
|
+
// Remap indices by current vertex offset
|
|
423
|
+
const remapped = new Uint32Array(result.indices.length)
|
|
424
|
+
for (let i = 0; i < result.indices.length; i++) {
|
|
425
|
+
remapped[i] = result.indices[i] + vertexOffset
|
|
426
|
+
}
|
|
427
|
+
allIndices.push(remapped)
|
|
428
|
+
vertexOffset += result.vertexCount
|
|
429
|
+
totalTriangles += result.triangleCount
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (allVertices.length === 0) throw new Error('No valid mesh primitives found in GLB')
|
|
434
|
+
|
|
435
|
+
// Concatenate all vertex and index arrays
|
|
436
|
+
const totalVerts = vertexOffset
|
|
437
|
+
const combinedVertices = new Float32Array(totalVerts * 3)
|
|
438
|
+
let vOff = 0
|
|
439
|
+
for (const v of allVertices) { combinedVertices.set(v, vOff); vOff += v.length }
|
|
440
|
+
|
|
441
|
+
const combinedIndices = new Uint32Array(totalTriangles * 3)
|
|
442
|
+
let iOff = 0
|
|
443
|
+
for (const idx of allIndices) { combinedIndices.set(idx, iOff); iOff += idx.length }
|
|
444
|
+
|
|
445
|
+
console.log(`[GLBLoader] Combined: ${totalVerts} vertices, ${totalTriangles} triangles from ${allIndices.length} primitives`)
|
|
446
|
+
return { vertices: combinedVertices, indices: combinedIndices, vertexCount: totalVerts, triangleCount: totalTriangles }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Build world-space 4x4 transform matrices for every node in the scene graph.
|
|
451
|
+
* Returns an array indexed by node index.
|
|
452
|
+
*/
|
|
453
|
+
function buildNodeTransforms(json) {
|
|
454
|
+
const nodes = json.nodes || []
|
|
455
|
+
const matrices = new Array(nodes.length).fill(null)
|
|
456
|
+
|
|
457
|
+
function getMatrix(nodeIdx) {
|
|
458
|
+
if (matrices[nodeIdx] !== null) return matrices[nodeIdx]
|
|
459
|
+
const node = nodes[nodeIdx]
|
|
460
|
+
let local = mat4Identity()
|
|
461
|
+
if (node.matrix) {
|
|
462
|
+
local = node.matrix.slice()
|
|
463
|
+
} else {
|
|
464
|
+
const t = node.translation || [0, 0, 0]
|
|
465
|
+
const r = node.rotation || [0, 0, 0, 1]
|
|
466
|
+
const s = node.scale || [1, 1, 1]
|
|
467
|
+
local = mat4TRS(t, r, s)
|
|
468
|
+
}
|
|
469
|
+
// Find parent
|
|
470
|
+
const parentIdx = nodes.findIndex((n, i) => i !== nodeIdx && (n.children || []).includes(nodeIdx))
|
|
471
|
+
if (parentIdx >= 0) {
|
|
472
|
+
local = mat4Mul(getMatrix(parentIdx), local)
|
|
473
|
+
}
|
|
474
|
+
matrices[nodeIdx] = local
|
|
475
|
+
return local
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < nodes.length; i++) getMatrix(i)
|
|
479
|
+
return matrices
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function mat4Identity() {
|
|
483
|
+
return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function mat4TRS(t, r, s) {
|
|
487
|
+
const [qx, qy, qz, qw] = r
|
|
488
|
+
const [sx, sy, sz] = s
|
|
489
|
+
const x2=qx+qx, y2=qy+qy, z2=qz+qz
|
|
490
|
+
const xx=qx*x2, xy=qx*y2, xz=qx*z2
|
|
491
|
+
const yy=qy*y2, yz=qy*z2, zz=qz*z2
|
|
492
|
+
const wx=qw*x2, wy=qw*y2, wz=qw*z2
|
|
493
|
+
return [
|
|
494
|
+
(1-(yy+zz))*sx, (xy+wz)*sx, (xz-wy)*sx, 0,
|
|
495
|
+
(xy-wz)*sy, (1-(xx+zz))*sy,(yz+wx)*sy, 0,
|
|
496
|
+
(xz+wy)*sz, (yz-wx)*sz, (1-(xx+yy))*sz,0,
|
|
497
|
+
t[0], t[1], t[2], 1
|
|
498
|
+
]
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function mat4Mul(a, b) {
|
|
502
|
+
const out = new Array(16)
|
|
503
|
+
for (let row = 0; row < 4; row++) {
|
|
504
|
+
for (let col = 0; col < 4; col++) {
|
|
505
|
+
let sum = 0
|
|
506
|
+
for (let k = 0; k < 4; k++) sum += a[row + k*4] * b[k + col*4]
|
|
507
|
+
out[row + col*4] = sum
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return out
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function applyTransformMatrix(vertices, m) {
|
|
514
|
+
const count = vertices.length / 3
|
|
515
|
+
const out = new Float32Array(vertices.length)
|
|
516
|
+
for (let i = 0; i < count; i++) {
|
|
517
|
+
const x = vertices[i*3], y = vertices[i*3+1], z = vertices[i*3+2]
|
|
518
|
+
out[i*3] = m[0]*x + m[4]*y + m[8]*z + m[12]
|
|
519
|
+
out[i*3+1] = m[1]*x + m[5]*y + m[9]*z + m[13]
|
|
520
|
+
out[i*3+2] = m[2]*x + m[6]*y + m[10]*z + m[14]
|
|
521
|
+
}
|
|
522
|
+
return out
|
|
523
|
+
}
|
|
524
|
+
|
|
185
525
|
/**
|
|
186
526
|
* Check if a GLB file has Draco-compressed meshes without attempting extraction.
|
|
187
527
|
* Useful for validation and error reporting.
|
|
188
528
|
*
|
|
189
529
|
* @param {string} filepath - Path to GLB file
|
|
190
|
-
* @returns {Object} {hasDraco: boolean, meshes: Array<{name, hasDraco}>}
|
|
530
|
+
* @returns {Object} {hasDraco: boolean, hasMeshopt: boolean, meshes: Array<{name, hasDraco, hasMeshopt}>}
|
|
191
531
|
*/
|
|
192
532
|
export function detectDracoInGLB(filepath) {
|
|
193
533
|
try {
|
|
194
534
|
const buf = readFileSync(filepath)
|
|
195
|
-
if (buf.toString('ascii', 0, 4) !== 'glTF') return { hasDraco: false, meshes: [] }
|
|
535
|
+
if (buf.toString('ascii', 0, 4) !== 'glTF') return { hasDraco: false, hasMeshopt: false, meshes: [] }
|
|
196
536
|
|
|
197
537
|
const jsonLen = buf.readUInt32LE(12)
|
|
198
538
|
const json = JSON.parse(buf.toString('utf-8', 20, 20 + jsonLen))
|
|
199
539
|
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
hasDraco: mesh.primitives?.some(p => p.extensions?.KHR_draco_mesh_compression) || false
|
|
203
|
-
}))
|
|
540
|
+
const bufferViewHasMeshopt = (bv) => bv.extensions?.EXT_meshopt_compression
|
|
541
|
+
const hasMeshoptGlobally = (json.bufferViews || []).some(bufferViewHasMeshopt)
|
|
204
542
|
|
|
205
|
-
const
|
|
206
|
-
|
|
543
|
+
const meshes = (json.meshes || []).map(mesh => {
|
|
544
|
+
const hasDraco = mesh.primitives?.some(p => p.extensions?.KHR_draco_mesh_compression) || false
|
|
545
|
+
const hasMeshopt = hasMeshoptGlobally || mesh.primitives?.some(p => p.extensions?.EXT_meshopt_compression) || false
|
|
546
|
+
return { name: mesh.name || 'unnamed', hasDraco, hasMeshopt }
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
hasDraco: meshes.some(m => m.hasDraco),
|
|
551
|
+
hasMeshopt: meshes.some(m => m.hasMeshopt),
|
|
552
|
+
meshes
|
|
553
|
+
}
|
|
207
554
|
} catch (e) {
|
|
208
|
-
return { hasDraco: false, meshes: [], error: e.message }
|
|
555
|
+
return { hasDraco: false, hasMeshopt: false, meshes: [], error: e.message }
|
|
209
556
|
}
|
|
210
557
|
}
|
package/src/physics/World.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import initJolt from 'jolt-physics/wasm-compat'
|
|
2
|
-
import { extractMeshFromGLB } from './GLBLoader.js'
|
|
2
|
+
import { extractMeshFromGLB, extractMeshFromGLBAsync, extractAllMeshesFromGLBAsync } 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 }
|
|
@@ -74,17 +74,89 @@ export class PhysicsWorld {
|
|
|
74
74
|
addStaticTrimesh(glbPath, meshIndex = 0) {
|
|
75
75
|
const J = this.Jolt
|
|
76
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
|
+
|
|
77
111
|
const triangles = new J.TriangleList(); triangles.resize(mesh.triangleCount)
|
|
112
|
+
const f3 = new J.Float3(0, 0, 0)
|
|
78
113
|
for (let t = 0; t < mesh.triangleCount; t++) {
|
|
79
114
|
const tri = triangles.at(t)
|
|
80
115
|
for (let v = 0; v < 3; v++) {
|
|
81
116
|
const idx = mesh.indices[t * 3 + v]
|
|
82
|
-
|
|
117
|
+
f3.x = vertices[idx * 3]; f3.y = vertices[idx * 3 + 1]; f3.z = vertices[idx * 3 + 2]
|
|
118
|
+
tri.set_mV(v, f3)
|
|
83
119
|
}
|
|
84
120
|
}
|
|
85
|
-
const
|
|
121
|
+
const settings = new J.MeshShapeSettings(triangles)
|
|
122
|
+
const shape = settings.Create().Get()
|
|
123
|
+
J.destroy(f3)
|
|
124
|
+
J.destroy(triangles)
|
|
125
|
+
J.destroy(settings)
|
|
86
126
|
return this._addBody(shape, [0, 0, 0], J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', mesh: mesh.name, triangles: mesh.triangleCount } })
|
|
87
127
|
}
|
|
128
|
+
|
|
129
|
+
addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0]) {
|
|
130
|
+
return new Promise(async (resolve, reject) => {
|
|
131
|
+
try {
|
|
132
|
+
const J = this.Jolt
|
|
133
|
+
// Use combined extraction: all meshes + all primitives (handles Draco, multi-mesh maps)
|
|
134
|
+
const mesh = await extractAllMeshesFromGLBAsync(glbPath)
|
|
135
|
+
const { vertices, indices, triangleCount } = mesh
|
|
136
|
+
|
|
137
|
+
const triangles = new J.TriangleList(); triangles.resize(triangleCount)
|
|
138
|
+
// Reuse a single Float3 to avoid WASM heap growth from per-vertex allocations
|
|
139
|
+
const f3 = new J.Float3(0, 0, 0)
|
|
140
|
+
for (let t = 0; t < triangleCount; t++) {
|
|
141
|
+
const tri = triangles.at(t)
|
|
142
|
+
for (let v = 0; v < 3; v++) {
|
|
143
|
+
const idx = indices[t * 3 + v]
|
|
144
|
+
f3.x = vertices[idx * 3]; f3.y = vertices[idx * 3 + 1]; f3.z = vertices[idx * 3 + 2]
|
|
145
|
+
tri.set_mV(v, f3)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const settings = new J.MeshShapeSettings(triangles)
|
|
149
|
+
const shape = settings.Create().Get()
|
|
150
|
+
J.destroy(f3)
|
|
151
|
+
J.destroy(triangles)
|
|
152
|
+
J.destroy(settings)
|
|
153
|
+
const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', triangles: triangleCount } })
|
|
154
|
+
resolve(id)
|
|
155
|
+
} catch (e) {
|
|
156
|
+
reject(e)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
}
|
|
88
160
|
addPlayerCharacter(radius, halfHeight, position, mass) {
|
|
89
161
|
const J = this.Jolt
|
|
90
162
|
const cvs = new J.CharacterVirtualSettings()
|
|
@@ -142,9 +214,7 @@ export class PhysicsWorld {
|
|
|
142
214
|
getCharacterPosition(charId) {
|
|
143
215
|
const ch = this.characters?.get(charId); if (!ch) return [0, 0, 0]
|
|
144
216
|
const p = ch.GetPosition()
|
|
145
|
-
|
|
146
|
-
this.Jolt.destroy(p)
|
|
147
|
-
return r
|
|
217
|
+
return [p.GetX(), p.GetY(), p.GetZ()]
|
|
148
218
|
}
|
|
149
219
|
getCharacterVelocity(charId) {
|
|
150
220
|
const ch = this.characters?.get(charId); if (!ch) return [0, 0, 0]
|
|
@@ -230,8 +300,8 @@ export class PhysicsWorld {
|
|
|
230
300
|
const dir = len > 0 ? [direction[0] / len, direction[1] / len, direction[2] / len] : direction
|
|
231
301
|
const ray = new J.RRayCast(new J.RVec3(origin[0], origin[1], origin[2]), new J.Vec3(dir[0] * maxDistance, dir[1] * maxDistance, dir[2] * maxDistance))
|
|
232
302
|
const rs = new J.RayCastSettings(), col = new J.CastRayClosestHitCollisionCollector()
|
|
233
|
-
const bp = new J.DefaultBroadPhaseLayerFilter(this.
|
|
234
|
-
const ol = new J.DefaultObjectLayerFilter(this.
|
|
303
|
+
const bp = new J.DefaultBroadPhaseLayerFilter(this.jolt.GetObjectVsBroadPhaseLayerFilter(), LAYER_DYNAMIC)
|
|
304
|
+
const ol = new J.DefaultObjectLayerFilter(this.jolt.GetObjectLayerPairFilter(), LAYER_DYNAMIC)
|
|
235
305
|
const eb = excludeBodyId != null ? this._getBody(excludeBodyId) : null
|
|
236
306
|
const bf = eb ? new J.IgnoreSingleBodyFilter(eb.GetID()) : new J.BodyFilter()
|
|
237
307
|
const sf = new J.ShapeFilter()
|
|
@@ -245,13 +315,25 @@ export class PhysicsWorld {
|
|
|
245
315
|
return result
|
|
246
316
|
}
|
|
247
317
|
destroy() {
|
|
318
|
+
if (!this.Jolt) return
|
|
319
|
+
const J = this.Jolt
|
|
248
320
|
if (this.characters) {
|
|
249
|
-
for (const
|
|
250
|
-
this.Jolt.destroy(ch)
|
|
251
|
-
}
|
|
321
|
+
for (const ch of this.characters.values()) J.destroy(ch)
|
|
252
322
|
this.characters.clear()
|
|
253
323
|
}
|
|
324
|
+
if (this._charFilters) {
|
|
325
|
+
J.destroy(this._charFilters.bp)
|
|
326
|
+
J.destroy(this._charFilters.ol)
|
|
327
|
+
J.destroy(this._charFilters.body)
|
|
328
|
+
J.destroy(this._charFilters.shape)
|
|
329
|
+
this._charFilters = null
|
|
330
|
+
}
|
|
331
|
+
if (this._charUpdateSettings) { J.destroy(this._charUpdateSettings); this._charUpdateSettings = null }
|
|
332
|
+
if (this._charGravity) { J.destroy(this._charGravity); this._charGravity = null }
|
|
254
333
|
for (const [id] of this.bodies) this.removeBody(id)
|
|
255
|
-
if (this.
|
|
334
|
+
if (this._tmpVec3) { J.destroy(this._tmpVec3); this._tmpVec3 = null }
|
|
335
|
+
if (this._tmpRVec3) { J.destroy(this._tmpRVec3); this._tmpRVec3 = null }
|
|
336
|
+
if (this.jolt) { J.destroy(this.jolt); this.jolt = null }
|
|
337
|
+
this.physicsSystem = null; this.bodyInterface = null
|
|
256
338
|
}
|
|
257
339
|
}
|
package/src/sdk/TickHandler.js
CHANGED
|
@@ -102,8 +102,7 @@ export function createTickHandler(deps) {
|
|
|
102
102
|
const entitySnap = appRuntime.getSnapshotForPlayer(pos, stageLoader.getActiveStage().spatial.relevanceRadius)
|
|
103
103
|
const combined = { tick: playerSnap.tick, timestamp: playerSnap.timestamp, players: playerSnap.players, entities: entitySnap.entities }
|
|
104
104
|
if (isKeyframe || !playerEntityMaps.has(player.id)) {
|
|
105
|
-
const encoded = SnapshotEncoder.
|
|
106
|
-
const { entityMap } = SnapshotEncoder.encodeDelta(combined, new Map())
|
|
105
|
+
const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, new Map())
|
|
107
106
|
playerEntityMaps.set(player.id, entityMap)
|
|
108
107
|
connections.send(player.id, MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
|
|
109
108
|
} else {
|
package/apps/arena/index.js
DELETED
|
@@ -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
|
-
}
|