spoint 0.1.660 → 0.1.662
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/client/editor/EditorShell.js +3 -171
- package/client/editor/EditorShellMenus.js +183 -0
- package/package.json +1 -1
- package/src/sdk/TickHandler.js +779 -995
- package/src/sdk/TickHandlerAOI.js +228 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Area-of-interest / priority / bandwidth-budget helpers for TickHandler.js's buildAndSendSnapshots:
|
|
2
|
+
// cube-sphere-cell ring AOI resolution, per-viewer entity priority scoring, and outgoing-payload
|
|
3
|
+
// byte-budget trimming. Split out as TickHandler.js's largest stateless block -- every function here
|
|
4
|
+
// only touches its own module-scoped caches (_spatialCache/_ringCache/_cellPackCache/etc, cleared once
|
|
5
|
+
// per tick by the caller) or explicit parameters, never buildAndSendSnapshots's own closure state. See
|
|
6
|
+
// each function's own comment for the AOI/priority/bandwidth rationale.
|
|
7
|
+
|
|
8
|
+
import { unpackBinRecord } from '../netcode/SnapshotEncoder.js'
|
|
9
|
+
import { neighborCells } from '../terrain/CubeSphereCells.js'
|
|
10
|
+
|
|
11
|
+
const PRIORITY_ENTITY_BUDGET = 64
|
|
12
|
+
const PRIORITY_DECAY = 0.02
|
|
13
|
+
// Fraction of the per-tick time budget (1000/tickRate ms) that measured snapshot-build cost must exceed
|
|
14
|
+
// to count as "expensive" -- mirrors the SNAP_RTT_LOW/HIGH pattern but on the real compute-cost axis.
|
|
15
|
+
const BANDWIDTH_BUDGET_BYTES_PER_TICK = 900
|
|
16
|
+
const BANDWIDTH_TRIM_MIN_ENTITIES = 6
|
|
17
|
+
const BANDWIDTH_TRIM_MAX_ITERATIONS = 32
|
|
18
|
+
|
|
19
|
+
export { PRIORITY_ENTITY_BUDGET, PRIORITY_DECAY, BANDWIDTH_BUDGET_BYTES_PER_TICK }
|
|
20
|
+
|
|
21
|
+
// _cellCenterWorld: face-local plane coords (wx,wy, already tan-warped, i.e. ready to combine with
|
|
22
|
+
// FACE_FRAME the same way planet-orchestrator.js's localToDeformed does) -> a real world-space point
|
|
23
|
+
// on the ray through that face direction at the given radial distance. Mirrors CubeSphereCells.js's
|
|
24
|
+
// FACE_FRAME table exactly (col0=U, col1=V, col2=center) so the reprojected point matches the same
|
|
25
|
+
// face convention worldToCell used to resolve the cell in the first place.
|
|
26
|
+
const _CELL_FACE_FRAME = [
|
|
27
|
+
{ c: [ 1, 0, 0], u: [0, 0, -1], v: [0, 1, 0] },
|
|
28
|
+
{ c: [-1, 0, 0], u: [0, 0, 1], v: [0, 1, 0] },
|
|
29
|
+
{ c: [0, 1, 0], u: [1, 0, 0], v: [0, 0, -1] },
|
|
30
|
+
{ c: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1] },
|
|
31
|
+
{ c: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] },
|
|
32
|
+
{ c: [0, 0, -1], u: [-1, 0, 0], v: [0, 1, 0] },
|
|
33
|
+
]
|
|
34
|
+
function _cellCenterWorld(face, wx, wy, R, dist) {
|
|
35
|
+
const F = _CELL_FACE_FRAME[face]
|
|
36
|
+
const dx = wx * F.u[0] + wy * F.v[0] + R * F.c[0]
|
|
37
|
+
const dy = wx * F.u[1] + wy * F.v[1] + R * F.c[1]
|
|
38
|
+
const dz = wx * F.u[2] + wy * F.v[2] + R * F.c[2]
|
|
39
|
+
const len = Math.hypot(dx, dy, dz) || 1
|
|
40
|
+
return [(dx / len) * dist, (dy / len) * dist, (dz / len) * dist]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// computeRingRelevantIds: cube-sphere-cell-grid AOI, the real "ring of cells" subscription this
|
|
44
|
+
// module implements. A single point radius-query (appRuntime.getRelevantDynamicIds/nearbyPlayerIds,
|
|
45
|
+
// called once per unique cellKey by the caller) already returns every entity within relevanceRadius
|
|
46
|
+
// of the CELL CENTER -- but relevanceRadius is also the cell's own edge length, so an entity sitting
|
|
47
|
+
// just across a neighbor cell's border (still within a real player's relevanceRadius of THEM, since
|
|
48
|
+
// players are not pinned to their cell center) can fall outside that single-cell query while still
|
|
49
|
+
// being genuinely relevant to a player standing near the shared edge. The fix mirrors exactly how a
|
|
50
|
+
// tile-based AOI system subscribes a viewer to its own cell PLUS its Moore neighborhood (a "ring"),
|
|
51
|
+
// not just the one cell it happens to sit in: union the relevant-id query result across the cell and
|
|
52
|
+
// its 8 neighbors (cross-face correct on the curved-space path via CubeSphereCells.neighborCells; a
|
|
53
|
+
// flat 3x3 XZ union on the non-planet path), each neighbor's query still centered on that neighbor's
|
|
54
|
+
// OWN cellViewerPos so every viewer sharing a given ring subscription computes the identical id set --
|
|
55
|
+
// the same "shared decision, not shared position" invariant the single-cell path already established
|
|
56
|
+
// for cellViewerPos-based distance tiering (see the tickMod comment below).
|
|
57
|
+
function computeRingRelevantIds(cellKey, cellFace, cellCx, cellCy, cellsPerFace, planetRadius, relevanceRadius, appRuntime) {
|
|
58
|
+
let ring = _ringCache.get(cellKey)
|
|
59
|
+
if (ring) return ring
|
|
60
|
+
const relSet = new Set(), nearSet = new Set()
|
|
61
|
+
const addCell = (face, cx, cy, key) => {
|
|
62
|
+
let c = _spatialCache.get(key)
|
|
63
|
+
if (!c) {
|
|
64
|
+
let cvp
|
|
65
|
+
if (planetRadius > 0) {
|
|
66
|
+
const ATAN_K = Math.PI / 4.0
|
|
67
|
+
const foX = (cx + 0.5) * relevanceRadius - planetRadius
|
|
68
|
+
const foY = (cy + 0.5) * relevanceRadius - planetRadius
|
|
69
|
+
const wx = planetRadius * Math.tan((foX / planetRadius) * ATAN_K)
|
|
70
|
+
const wy = planetRadius * Math.tan((foY / planetRadius) * ATAN_K)
|
|
71
|
+
cvp = _cellCenterWorld(face, wx, wy, planetRadius, planetRadius)
|
|
72
|
+
} else {
|
|
73
|
+
cvp = [(cx + 0.5) * relevanceRadius, 0, (cy + 0.5) * relevanceRadius]
|
|
74
|
+
}
|
|
75
|
+
// Starvation guard keyed by the cell's own packed key: every player homed to this cell shares
|
|
76
|
+
// the same starvation clock (matching the ring-of-cells "shared decision, not shared position"
|
|
77
|
+
// invariant documented above), so a distant entity gets force-included for the whole cell's
|
|
78
|
+
// viewers together, once, rather than each player independently re-discovering it.
|
|
79
|
+
c = { nearbyPlayerIds: appRuntime.nearbyPlayerIdsHysteresis(cvp, relevanceRadius, key), relevantIds: appRuntime.getRelevantDynamicIdsWithStarvation(cvp, relevanceRadius, key), cellViewerPos: cvp }
|
|
80
|
+
_spatialCache.set(key, c)
|
|
81
|
+
}
|
|
82
|
+
for (const id of c.relevantIds) relSet.add(id)
|
|
83
|
+
for (const id of c.nearbyPlayerIds) nearSet.add(id)
|
|
84
|
+
}
|
|
85
|
+
if (planetRadius > 0) {
|
|
86
|
+
const neighbors = neighborCells(cellFace, cellCx, cellCy, cellsPerFace)
|
|
87
|
+
for (const n of neighbors) addCell(n.face, n.cx, n.cy, packCellKey(n.face, n.cx, n.cy, cellsPerFace))
|
|
88
|
+
} else {
|
|
89
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
90
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
91
|
+
if (dx === 0 && dy === 0) continue
|
|
92
|
+
const ncx = cellCx + dx, ncy = cellCy + dy
|
|
93
|
+
addCell(-1, ncx, ncy, (ncx * 65536 + ncy) | 0)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
ring = { relevantIds: relSet, nearbyPlayerIds: nearSet }
|
|
98
|
+
_ringCache.set(cellKey, ring)
|
|
99
|
+
return ring
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const _spatialCache = new Map()
|
|
103
|
+
const _cellPackCache = new Map()
|
|
104
|
+
// Ring (cell + 8-neighborhood) relevant-id union cache, cleared once per tick alongside _spatialCache.
|
|
105
|
+
// Keyed by the SAME cellKey as _spatialCache -- one entry per unique home-cell any player sits in this
|
|
106
|
+
// tick, not per player. See computeRingRelevantIds above.
|
|
107
|
+
const _ringCache = new Map()
|
|
108
|
+
const _priorityAccumulators = new Map()
|
|
109
|
+
// module-scoped, cleared per-call to avoid GC churn (single-threaded tick, never re-entrant)
|
|
110
|
+
const _priorityBuckets = [[], [], [], []]
|
|
111
|
+
|
|
112
|
+
const _priorityBin = {}
|
|
113
|
+
export function getPlayerPriorityIds(playerId, relevantIds, dynCache, viewerPos, tick) {
|
|
114
|
+
if (!_priorityAccumulators.has(playerId)) _priorityAccumulators.set(playerId, new Map())
|
|
115
|
+
const acc = _priorityAccumulators.get(playerId)
|
|
116
|
+
const vx = viewerPos[0], vy = viewerPos[1], vz = viewerPos[2]
|
|
117
|
+
|
|
118
|
+
for (const id of relevantIds) {
|
|
119
|
+
const entry = dynCache.get(id); if (!entry) continue
|
|
120
|
+
// enc[2] is the packed 23-byte bin record (see SnapshotEncoder.js fillEntityEnc) -- unpack once
|
|
121
|
+
// per scored entity per tick rather than reading stale flat numeric slots.
|
|
122
|
+
unpackBinRecord(entry.enc[2], _priorityBin)
|
|
123
|
+
const dx = _priorityBin.px-vx, dy = _priorityBin.py-vy, dz = _priorityBin.pz-vz
|
|
124
|
+
const distSq = dx*dx+dy*dy+dz*dz
|
|
125
|
+
const velSq = _priorityBin.vx*_priorityBin.vx+_priorityBin.vy*_priorityBin.vy+_priorityBin.vz*_priorityBin.vz
|
|
126
|
+
const distScore = 1 / (1 + distSq * 0.001)
|
|
127
|
+
const velScore = velSq >= 100 ? 1 : Math.sqrt(velSq) * 0.1
|
|
128
|
+
const prev = acc.get(id) || 0
|
|
129
|
+
acc.set(id, prev + distScore + velScore + PRIORITY_DECAY)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const id of acc.keys()) {
|
|
133
|
+
if (!dynCache.has(id)) acc.delete(id)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (acc.size <= PRIORITY_ENTITY_BUDGET) return relevantIds
|
|
137
|
+
|
|
138
|
+
const buckets = _priorityBuckets
|
|
139
|
+
buckets[0].length = 0; buckets[1].length = 0; buckets[2].length = 0; buckets[3].length = 0
|
|
140
|
+
for (const [id, score] of acc) {
|
|
141
|
+
if (score >= 3) buckets[0].push(id)
|
|
142
|
+
else if (score >= 2) buckets[1].push(id)
|
|
143
|
+
else if (score >= 1) buckets[2].push(id)
|
|
144
|
+
else buckets[3].push(id)
|
|
145
|
+
}
|
|
146
|
+
const topIds = new Set()
|
|
147
|
+
let remaining = PRIORITY_ENTITY_BUDGET
|
|
148
|
+
for (const bucket of buckets) {
|
|
149
|
+
for (const id of bucket) {
|
|
150
|
+
if (remaining-- <= 0) break
|
|
151
|
+
topIds.add(id)
|
|
152
|
+
acc.set(id, 0)
|
|
153
|
+
}
|
|
154
|
+
if (remaining <= 0) break
|
|
155
|
+
}
|
|
156
|
+
return topIds
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const _budgetBin = {}
|
|
160
|
+
// Cheap per-record byte-size ESTIMATE (not a real msgpack measurement -- re-packing on every trim
|
|
161
|
+
// iteration to get an exact byte count would cost more than the bandwidth it saves). A full entity
|
|
162
|
+
// record is [id, model, 23-byte bin buffer, bodyType, custom, sleeping]; a delta record is
|
|
163
|
+
// [id, mask, ...present fields]. id/mask/bodyType/sleeping are small msgpack-encoded ints/strings
|
|
164
|
+
// (~1-3 bytes each); the bin buffer is a real, exact 23 bytes when present; custom is the one
|
|
165
|
+
// unbounded field, estimated via JSON.stringify length (msgpack is typically slightly smaller than
|
|
166
|
+
// JSON for the same object, so this errs conservative -- overestimating custom's cost trims a little
|
|
167
|
+
// more eagerly than strictly necessary, never less, which is the safe direction for a budget).
|
|
168
|
+
function estimateEntityBytes(enc) {
|
|
169
|
+
let n = 8 // id + array/map framing overhead, flat estimate
|
|
170
|
+
for (let i = 1; i < enc.length; i++) {
|
|
171
|
+
const f = enc[i]
|
|
172
|
+
if (f == null) continue
|
|
173
|
+
if (f instanceof Uint8Array) n += f.byteLength
|
|
174
|
+
else if (typeof f === 'string') n += f.length + 1
|
|
175
|
+
else if (typeof f === 'number') n += 2
|
|
176
|
+
else if (typeof f === 'object') { try { n += JSON.stringify(f).length } catch (_) { n += 16 } }
|
|
177
|
+
else n += 1
|
|
178
|
+
}
|
|
179
|
+
return n
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Trims encoded.entities (in place, returns a new array) down toward BANDWIDTH_BUDGET_BYTES_PER_TICK,
|
|
183
|
+
// dropping the FARTHEST-from-viewer dynamic entity first each iteration -- graceful degradation
|
|
184
|
+
// (fewer/less-fresh far entities) rather than buffering or blocking the tick, avoiding the
|
|
185
|
+
// bufferbloat/latency-spiral a client-side send queue would risk. staticCount entities at the front of
|
|
186
|
+
// the array (see encodeDeltaFromCache: static entries are always pushed before any dynamic entry) are
|
|
187
|
+
// never trimmed -- dropping map/collision-relevant static geometry updates would desync client-side
|
|
188
|
+
// collision, a correctness cost far worse than a slightly stale distant prop. Returns { entities,
|
|
189
|
+
// trimmedCount } so a caller can log/telemetry the degradation instead of it being silent.
|
|
190
|
+
function trimEntitiesToBudget(entities, staticCount, viewerPos) {
|
|
191
|
+
if (entities.length - staticCount < BANDWIDTH_TRIM_MIN_ENTITIES) return { entities, trimmedCount: 0 }
|
|
192
|
+
let total = 0
|
|
193
|
+
const sized = new Array(entities.length)
|
|
194
|
+
for (let i = 0; i < entities.length; i++) { const b = estimateEntityBytes(entities[i]); sized[i] = b; total += b }
|
|
195
|
+
if (total <= BANDWIDTH_BUDGET_BYTES_PER_TICK) return { entities, trimmedCount: 0 }
|
|
196
|
+
const vx = viewerPos ? viewerPos[0] : 0, vy = viewerPos ? viewerPos[1] : 0, vz = viewerPos ? viewerPos[2] : 0
|
|
197
|
+
// Candidate indices: dynamic entities only (index >= staticCount), each with its real squared
|
|
198
|
+
// distance from the viewer where available (full records carry the 23-byte bin buffer at enc[2];
|
|
199
|
+
// delta records only carry it when position/rot/vel/scale actually changed this tick -- a delta
|
|
200
|
+
// missing it is scored as "far" (Infinity) so it trims before anything with a known-close position,
|
|
201
|
+
// a deliberately conservative fallback since we can't cheaply know its real distance this tick).
|
|
202
|
+
const candidates = []
|
|
203
|
+
for (let i = staticCount; i < entities.length; i++) {
|
|
204
|
+
const enc = entities[i]
|
|
205
|
+
let d2 = Infinity
|
|
206
|
+
const bin = (enc.length > 2 && enc[2] instanceof Uint8Array && enc[2].byteLength >= 12) ? enc[2] : null
|
|
207
|
+
if (bin) {
|
|
208
|
+
unpackBinRecord(bin, _budgetBin)
|
|
209
|
+
const dx = _budgetBin.px - vx, dy = _budgetBin.py - vy, dz = _budgetBin.pz - vz
|
|
210
|
+
d2 = dx * dx + dy * dy + dz * dz
|
|
211
|
+
}
|
|
212
|
+
candidates.push({ i, d2 })
|
|
213
|
+
}
|
|
214
|
+
candidates.sort((a, b) => b.d2 - a.d2) // farthest first
|
|
215
|
+
const dropSet = new Set()
|
|
216
|
+
let iterations = 0
|
|
217
|
+
for (const c of candidates) {
|
|
218
|
+
if (total <= BANDWIDTH_BUDGET_BYTES_PER_TICK) break
|
|
219
|
+
if (++iterations > BANDWIDTH_TRIM_MAX_ITERATIONS) break
|
|
220
|
+
dropSet.add(c.i)
|
|
221
|
+
total -= sized[c.i]
|
|
222
|
+
}
|
|
223
|
+
if (dropSet.size === 0) return { entities, trimmedCount: 0 }
|
|
224
|
+
const trimmed = entities.filter((_, i) => !dropSet.has(i))
|
|
225
|
+
return { entities: trimmed, trimmedCount: dropSet.size }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export { trimEntitiesToBudget, estimateEntityBytes, computeRingRelevantIds, _cellCenterWorld, _spatialCache, _cellPackCache, _ringCache }
|