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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 37 additions & 157 deletions src/apps/AppRuntime.js

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions src/apps/AppRuntimeLifeCycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { AppContext } from './AppContext.js'
import { mulQuat, rotVec } from '../math.js'

export function mixinAppLifeCycle(runtime) {
runtime._attachApp = async (entityId, appName) => {
const entity = runtime.entities.get(entityId), appDef = runtime._appDefs.get(appName)
if (!entity || !appDef) return
const ctx = new AppContext(entity, runtime)
runtime.contexts.set(entityId, ctx); runtime.apps.set(entityId, appDef)
await runtime._safeCall(appDef.server || appDef, 'setup', [ctx], `setup(${appName})`)
runtime._scheduleRebuild()
}

runtime._scheduleRebuild = () => {
if (runtime._rebuildScheduled) return
runtime._rebuildScheduled = true
setImmediate(() => { runtime._rebuildScheduled = false; runtime._rebuildUpdateList(); runtime._rebuildCollisionList() })
}

runtime.detachApp = (entityId) => {
const appDef=runtime.apps.get(entityId), ctx=runtime.contexts.get(entityId)
if (ctx?._teardownChildren) ctx._teardownChildren()
if (appDef && ctx) runtime._safeCall(appDef.server||appDef, 'teardown', [ctx], 'teardown')
runtime._eventBus.destroyScope(entityId); runtime.clearTimers(entityId); runtime.apps.delete(entityId); runtime.contexts.delete(entityId)
runtime._rebuildUpdateList(); runtime._rebuildCollisionList()
}

runtime.getWorldTransform = (entityId) => {
const e = runtime.entities.get(entityId); if (!e) return null
const local = { position: [...e.position], rotation: [...e.rotation], scale: [...e.scale] }
if (!e.parent) return local
const pt = runtime.getWorldTransform(e.parent); if (!pt) return local
const sp = [e.position[0]*pt.scale[0], e.position[1]*pt.scale[1], e.position[2]*pt.scale[2]]
const rp = rotVec(sp, pt.rotation)
return { position: [pt.position[0]+rp[0], pt.position[1]+rp[1], pt.position[2]+rp[2]], rotation: mulQuat(pt.rotation, e.rotation), scale: [pt.scale[0]*e.scale[0], pt.scale[1]*e.scale[1], pt.scale[2]*e.scale[2]] }
}

runtime.getEntity = (id) => runtime.entities.get(id) || null
runtime.clearTimers = (eid) => runtime._timers.delete(eid)
runtime.addTimer = (e, d, fn, r) => { if (!runtime._timers.has(e)) runtime._timers.set(e, []); runtime._timers.get(e).push({ remaining: d, fn, repeat: r, interval: d }) }
runtime.getPlayers = () => runtime._playerManager ? runtime._playerManager.getConnectedPlayers() : []
runtime.broadcastToPlayers = (m) => { if (runtime._connections) runtime._connections.broadcast(10, m); else if (runtime._playerManager) runtime._playerManager.broadcast(m) }
runtime.sendToPlayer = (id, m) => { if (runtime._connections) runtime._connections.send(id, 10, m); else if (runtime._playerManager) runtime._playerManager.sendToPlayer(id, m) }
runtime._drainReloadQueue = () => runtime._hotReload.drain()
}
332 changes: 100 additions & 232 deletions src/physics/World.js

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions src/physics/WorldBody.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { buildConvexShape, buildTrimeshShape } from './ShapeBuilder.js'

export function mixinBodyInterface(world) {
world._addBody = (shape, position, motionType, layer, opts = {}) => {
const J = world.Jolt
const pos = new J.RVec3(position[0], position[1], position[2])
const rot = opts.rotation ? new J.Quat(...opts.rotation) : new J.Quat(0, 0, 0, 1)
const cs = new J.BodyCreationSettings(shape, pos, rot, motionType, layer)
J.destroy(pos); J.destroy(rot)
if (opts.mass) { cs.mMassPropertiesOverride.mMass = opts.mass; cs.mOverrideMassProperties = J.EOverrideMassProperties_CalculateInertia }
if (opts.friction !== undefined) cs.mFriction = opts.friction
if (opts.restitution !== undefined) cs.mRestitution = opts.restitution
if (opts.linearDamping !== undefined) cs.mLinearDamping = opts.linearDamping
if (opts.angularDamping !== undefined) cs.mAngularDamping = opts.angularDamping
if (opts.linearCast) cs.mMotionQuality = J.EMotionQuality_LinearCast
const activate = motionType === J.EMotionType_Static ? J.EActivation_DontActivate : J.EActivation_Activate
const body = world.bodyInterface.CreateBody(cs); world.bodyInterface.AddBody(body.GetID(), activate)
J.destroy(cs)
const id = body.GetID().GetIndexAndSequenceNumber()
world.bodies.set(id, body); world.bodyMeta.set(id, opts.meta || {}); world.bodyIds.set(id, body.GetID())
return id
}

world.addBody = (shapeType, params, position, motionType, opts = {}) => {
const J = world.Jolt; let shape
if (shapeType === 'box') { const cr = Math.min(0.05, Math.min(params[0], params[1], params[2]) * 0.1); const bv = new J.Vec3(params[0], params[1], params[2]); shape = new J.BoxShape(bv, cr, null); J.destroy(bv) }
else if (shapeType === 'sphere') shape = new J.SphereShape(params)
else if (shapeType === 'capsule') shape = new J.CapsuleShape(params[1], params[0])
else if (shapeType === 'convex') {
const { shape: cvxShape } = buildConvexShape(J, params, world._shapeCache, opts.shapeKey || null)
const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
return world._addBody(cvxShape, position, mt, motionType === 'static' ? 0 : 1, { ...opts, meta: { type: motionType, shape: shapeType } })
}
else return null
const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
return world._addBody(shape, position, mt, motionType === 'static' ? 0 : 1, { ...opts, meta: { type: motionType, shape: shapeType } })
}

world.syncDynamicBody = (bodyId, entity) => {
const b = world.bodies.get(bodyId); if (!b || !b.IsActive()) return false
const id = world.bodyIds.get(bodyId), bi = world.bodyInterface
bi.GetPositionAndRotation(id, world._bulkOutP, world._bulkOutR)
bi.GetLinearAndAngularVelocity(id, world._bulkOutLV, world._bulkOutAV)
entity.position[0] = world._bulkOutP.GetX(); entity.position[1] = world._bulkOutP.GetY(); entity.position[2] = world._bulkOutP.GetZ()
entity.rotation[0] = world._bulkOutR.GetX(); entity.rotation[1] = world._bulkOutR.GetY(); entity.rotation[2] = world._bulkOutR.GetZ(); entity.rotation[3] = world._bulkOutR.GetW()
entity.velocity[0] = world._bulkOutLV.GetX(); entity.velocity[1] = world._bulkOutLV.GetY(); entity.velocity[2] = world._bulkOutLV.GetZ()
return true
}

world.removeBody = (id) => {
const b = world.bodies.get(id); if (!b) return
world.bodyInterface.RemoveBody(b.GetID()); world.bodyInterface.DestroyBody(b.GetID())
world.bodies.delete(id); world.bodyMeta.delete(id); world.bodyIds.delete(id)
}
}
127 changes: 127 additions & 0 deletions src/sdk/SnapshotHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { MSG } from '../protocol/MessageTypes.js'
import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js'
import { pack } from '../protocol/msgpack.js'

const SNAP_UNRELIABLE = true
const PRIORITY_ENTITY_BUDGET = 64
const PRIORITY_DECAY = 0.02

const _spatialCache = new Map()
const _precomputedRemoved = []
const _cellPackCache = new Map()
const _packWrapper = { type: MSG.SNAPSHOT, payload: null }
const _priorityAccumulators = new Map()
const _packPayload = { seq: 0, tick: 0, serverTime: 0, players: null, entities: null, removed: undefined, delta: 1 }

function getPlayerPriorityIds(playerId, relevantIds, dynCache, viewerPos) {
if (!_priorityAccumulators.has(playerId)) _priorityAccumulators.set(playerId, new Map())
const acc = _priorityAccumulators.get(playerId)
const vx = viewerPos[0], vy = viewerPos[1], vz = viewerPos[2]

for (const id of relevantIds) {
const entry = dynCache.get(id); if (!entry) continue
const enc = entry.enc
const dx = enc[2]-vx, dy = enc[3]-vy, dz = enc[4]-vz
const distSq = dx*dx+dy*dy+dz*dz
const velSq = enc[6]*enc[6]+enc[7]*enc[7]+enc[8]*enc[8]
const distScore = 1 / (1 + distSq * 0.001)
const velScore = Math.min(1, Math.sqrt(velSq) * 0.1)
const prev = acc.get(id) || 0
acc.set(id, prev + distScore + velScore + PRIORITY_DECAY)
}

for (const id of acc.keys()) {
if (!dynCache.has(id)) acc.delete(id)
}

if (acc.size <= PRIORITY_ENTITY_BUDGET) return relevantIds

const sorted = [...acc.entries()].sort((a, b) => b[1] - a[1])
const topIds = new Set()
for (let i = 0; i < PRIORITY_ENTITY_BUDGET && i < sorted.length; i++) topIds.add(sorted[i][0])
for (const id of topIds) acc.set(id, 0)
return topIds
}

function packSnapshot(seq, encoded) {
_packPayload.seq = seq; _packPayload.tick = encoded.tick; _packPayload.serverTime = encoded.serverTime
_packPayload.players = encoded.players; _packPayload.entities = encoded.entities
_packPayload.removed = encoded.removed; _packPayload.delta = encoded.delta
_packWrapper.payload = _packPayload
return pack(_packWrapper)
}

export function buildAndSendSnapshots(players, appRuntime, deps, tick, snapshotSeq, isKeyframe, state, serverNow) {
const { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps } = deps
const playerSnap = networkState.getSnapshot()
const playerCount = players.length
const snapGroups = Math.max(1, Math.ceil(playerCount / 50))
const curGroup = tick % snapGroups
const activeStage = stageLoader ? stageLoader.getActiveStage() : null
const relevanceRadius = activeStage ? activeStage.spatial.relevanceRadius : (getRelevanceRadius ? getRelevanceRadius() : 0)

if (relevanceRadius > 0) {
const curStaticVersion = appRuntime._staticVersion
let activeStaticEntries = null
if (isKeyframe || curStaticVersion !== state.lastStaticVersion) {
const staticSnap = appRuntime.getStaticSnapshot()
const prevStaticMap = isKeyframe ? new Map() : state.staticEntityMap
const { staticEntries, changedEntries, staticMap, staticChanged } = SnapshotEncoder.encodeStaticEntities(staticSnap.entities, prevStaticMap)
state.lastStaticEntries = staticEntries
if (staticChanged || isKeyframe) { state.staticEntityMap = staticMap; state.staticEntityIds = SnapshotEncoder.buildStaticIds(staticMap); activeStaticEntries = isKeyframe ? staticEntries : changedEntries }
state.lastStaticVersion = curStaticVersion
}
_precomputedRemoved.length = 0
if (isKeyframe || curStaticVersion !== state.lastDynVersion) { state.prevDynCache = null; state.lastDynVersion = curStaticVersion }
const allEncodedPlayers = SnapshotEncoder.encodePlayersOnce(playerSnap.players)
_spatialCache.clear()
_cellPackCache.clear()
let dynCache = null
for (const player of players) {
if (player.id % snapGroups !== curGroup) continue
if (dynCache === null) {
const activeIds = appRuntime._activeDynamicIds
if (state.prevDynCache === null) { state.prevDynCache = SnapshotEncoder.buildDynamicCache(activeIds, appRuntime._sleepingDynamicIds, appRuntime._suspendedEntityIds, appRuntime.entities, state.prevDynCache) }
else { SnapshotEncoder.refreshDynamicCache(state.prevDynCache, activeIds, appRuntime.entities) }
dynCache = state.prevDynCache
}
const isNewPlayer = !playerEntityMaps.has(player.id)
const viewerPos = player.state.position
const cellKey = (Math.floor(viewerPos[0] / relevanceRadius) * 65536 + Math.floor(viewerPos[2] / relevanceRadius)) | 0
let cached = _spatialCache.get(cellKey)
if (!cached) { cached = { nearbyPlayerIds: appRuntime._playerIndex.nearby(viewerPos, relevanceRadius), relevantIds: appRuntime.getRelevantDynamicIds(viewerPos, relevanceRadius) }; _spatialCache.set(cellKey, cached) }
const preEncodedPlayers = SnapshotEncoder.filterEncodedPlayersWithSelf(allEncodedPlayers, cached.nearbyPlayerIds, player.id)
const prevMap = isNewPlayer ? new Map() : playerEntityMaps.get(player.id)
const relevantIds = isNewPlayer ? cached.relevantIds : getPlayerPriorityIds(player.id, cached.relevantIds, dynCache, viewerPos)
const { encoded, entityMap } = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, dynCache, relevantIds, prevMap, preEncodedPlayers, isNewPlayer ? state.lastStaticEntries : activeStaticEntries, state.staticEntityMap, state.staticEntityIds, isNewPlayer ? undefined : _precomputedRemoved, snapshotSeq, viewerPos)
if (isNewPlayer) { for (const id of prevMap.keys()) { if (!dynCache.has(id) && !(state.staticEntityIds && state.staticEntityIds.has(id))) _precomputedRemoved.push(id) } }
playerEntityMaps.set(player.id, entityMap)
if (!isNewPlayer && encoded.entities.length === 0 && !encoded.removed) {
let cellPack = _cellPackCache.get(cellKey)
if (!cellPack) {
cellPack = packSnapshot(snapshotSeq, encoded)
_cellPackCache.set(cellKey, cellPack)
}
connections.sendPacked(player.id, cellPack, SNAP_UNRELIABLE)
} else {
const packedData = packSnapshot(snapshotSeq, encoded)
connections.sendPacked(player.id, packedData, SNAP_UNRELIABLE)
}
}
} else {
const entitySnap = appRuntime.getSnapshot()
const combined = { tick: playerSnap.tick, players: playerSnap.players, entities: entitySnap.entities, serverTime: serverNow }
const prevMap = (isKeyframe || state.broadcastEntityMap.size === 0) ? new Map() : state.broadcastEntityMap
const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
state.broadcastEntityMap = entityMap
const data = packSnapshot(snapshotSeq, encoded)
for (const player of players) {
if (!isKeyframe && player.id % snapGroups !== curGroup) continue
connections.sendPacked(player.id, data, SNAP_UNRELIABLE)
}
}
}

export function clearSnapshotPriorities(playerId) {
_priorityAccumulators.delete(playerId)
}
Loading
Loading