spoint 0.1.660 → 0.1.661

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.
@@ -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 }