openvibers/openvibe.games Commits
Add enter/exit hysteresis to region interest so boundary entities stop churning spawn/despawn (#9)
Changes
@@@ -58,6 +58,8 @@ export interface ServerConfig {snapshotEvery: number/** Entities beyond this distance from a player are not replicated to them. */interestRadius: number++ /** Fraction the interest exit radius exceeds the enter radius (spawn/despawn hysteresis). */++ interestHysteresis: numbermaxPlayers: numberpersistFlushSeconds: numbermetricsLogSeconds: number@@@ -105,6 +107,14 @@ function trimSlash(v: string): string {return v.replace(/\/+$/, '')}++/**++ * Fraction the interest exit radius exceeds the enter radius. A client keeps++ * an entity it already knows while it stays inside `interestRadius * (1 + H)`,++ * so an entity loitering on the interest boundary cannot churn spawn/despawn++ * every snapshot; only a fresh entity must come inside `interestRadius`.++ */++export const INTEREST_HYSTERESIS = 0.1++export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {const secret = env.OV_OAUTH_CLIENT_SECRET ?? nullreturn {@@@ -164,6 +174,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {tickRate: 30,snapshotEvery: 2,interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),++ interestHysteresis: INTEREST_HYSTERESIS,maxPlayers: intEnv(env, 'MAX_PLAYERS', 64),persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),nnew file mode 100644@@@ -0,0 +1,120 @@++/**++ * Interest hysteresis (replication tick). A body only ENTERS a client's++ * interest inside `radius`, but an already-known body is retained out to++ * `radius * (1 + INTEREST_HYSTERESIS)` — so a body loitering on the boundary++ * cannot churn spawn/despawn every snapshot.++ *++ * updateInterest touches only world.spatial, world.entities and the session's++ * position/known set, so the world and session here are minimal stand-ins.++ */++import { EntityStore, SpatialHash, type GameEntity } from '@openvibe/gameplay'++import { quat, vec3, type EntityId } from '@openvibe/shared'++import { describe, expect, it } from 'vitest'++import type { GameWorld } from './gameWorld.js'++import type { PlayerSession } from './playerSession.js'++import { INTEREST_HYSTERESIS, updateInterest } from './replication.js'++++const RADIUS = 80++const EXIT = RADIUS * (1 + INTEREST_HYSTERESIS)++++function makeWorld(): GameWorld {++ return {++ spatial: new SpatialHash<EntityId>(16),++ entities: new EntityStore(),++ } as unknown as GameWorld++}++++function makeSession(): PlayerSession {++ return {++ entityId: 'p1' as EntityId,++ move: { pos: vec3(0, 0, 0) },++ known: new Set<EntityId>(),++ } as unknown as PlayerSession++}++++let nextId = 0++/** A static prop at (x, 0); moveTo keeps the spatial hash in step. */++function addBody(world: GameWorld, x: number): { id: EntityId; moveTo: (x: number) => void } {++ const id = `e${++nextId}` as EntityId++ const entity: GameEntity = {++ id,++ kind: 'prop',++ transform: { pos: vec3(x, 0, 0), rot: quat() },++ persistent: false,++ dirty: false,++ }++ world.entities.add(entity)++ world.spatial.insert(id, x, 0)++ return {++ id,++ moveTo: (nx: number) => {++ entity.transform.pos.x = nx++ world.spatial.move(id, nx, 0)++ },++ }++}++++describe('interest hysteresis', () => {++ it('enters inside the radius and is retained past it up to the exit radius', () => {++ const world = makeWorld()++ const session = makeSession()++ const body = addBody(world, 0.9 * RADIUS)++++ const entering = updateInterest(session, world, RADIUS)++ expect(entering.entered.map((e) => e.id)).toEqual([body.id])++ expect(entering.left).toEqual([])++ expect(session.known.has(body.id)).toBe(true)++++ // Inside the hysteresis band: no re-spawn and no despawn.++ body.moveTo(1.05 * RADIUS)++ const retained = updateInterest(session, world, RADIUS)++ expect(retained.entered).toEqual([])++ expect(retained.left).toEqual([])++ expect(session.known.has(body.id)).toBe(true)++++ // Exactly at the exit radius is still retained.++ body.moveTo(EXIT)++ const atEdge = updateInterest(session, world, RADIUS)++ expect(atEdge.entered).toEqual([])++ expect(atEdge.left).toEqual([])++ expect(session.known.has(body.id)).toBe(true)++ })++++ it('leaves only past the exit radius', () => {++ const world = makeWorld()++ const session = makeSession()++ const body = addBody(world, 0.9 * RADIUS)++ updateInterest(session, world, RADIUS)++++ body.moveTo(EXIT + 1)++ const leaving = updateInterest(session, world, RADIUS)++ expect(leaving.entered).toEqual([])++ expect(leaving.left).toEqual([body.id])++ expect(session.known.has(body.id)).toBe(false)++ })++++ it('does not spawn/despawn a body oscillating in the band', () => {++ const world = makeWorld()++ const session = makeSession()++ const body = addBody(world, 0.9 * RADIUS)++ const first = updateInterest(session, world, RADIUS)++ expect(first.entered.map((e) => e.id)).toEqual([body.id])++++ let spawns = 0++ let despawns = 0++ for (let i = 0; i < 12; i++) {++ // Cross back and forth over the enter radius, never past the exit one.++ body.moveTo(i % 2 === 0 ? 1.02 * RADIUS : 0.98 * RADIUS)++ const diff = updateInterest(session, world, RADIUS)++ spawns += diff.entered.length++ despawns += diff.left.length++ }++ expect(spawns).toBe(0)++ expect(despawns).toBe(0)++ expect(session.known.has(body.id)).toBe(true)++ })++++ it('exports the enter radius framing as INTEREST_HYSTERESIS', () => {++ expect(INTEREST_HYSTERESIS).toBeGreaterThan(0)++ })++})@@@ -7,9 +7,12 @@ import type {WirePlayerState,} from '@openvibe/protocol'import { v3distSq, type EntityId } from '@openvibe/shared'++import { INTEREST_HYSTERESIS } from '../config.js'import type { GameWorld } from './gameWorld.js'import type { PlayerSession } from './playerSession.js'++export { INTEREST_HYSTERESIS }++/*** Interest management + snapshot building.*@@@ -85,23 +88,36 @@ export interface InterestDiff {left: EntityId[]}--/** Updates session.known in place and returns what changed. */++/**++ * Updates session.known in place and returns what changed.++ *++ * `radius` is the ENTER radius. A body must come inside it to be replicated,++ * but an already-known body is retained out to exitRadius = radius * (1 + H).++ * The gap is hysteresis: without it a body sitting on the boundary flips++ * in/out every snapshot, churning spawn/despawn on the wire.++ */export function updateInterest(session: PlayerSession,world: GameWorld,radius: number,++ hysteresis: number = INTEREST_HYSTERESIS,): InterestDiff {const radiusSq = radius * radius++ const exitRadius = radius * (1 + hysteresis)++ const exitRadiusSq = exitRadius * exitRadiusconst entered: GameEntity[] = []const current = new Set<EntityId>()-- // Spatial hash prunes candidates; the exact 3D distance test decides.-- world.spatial.forEachInRadius(session.move.pos.x, session.move.pos.z, radius, (id) => {++ // Candidates out to the exit radius; the exact 3D distance test decides.++ world.spatial.forEachInRadius(session.move.pos.x, session.move.pos.z, exitRadius, (id) => {if (id === session.entityId) returnconst entity = world.entities.get(id)if (!entity) return-- if (v3distSq(entity.transform.pos, session.move.pos) > radiusSq) return++ const known = session.known.has(entity.id)++ const d2 = v3distSq(entity.transform.pos, session.move.pos)++ // Enter inside the enter radius; stay while inside the exit radius.++ if (d2 > (known ? exitRadiusSq : radiusSq)) returncurrent.add(entity.id)-- if (!session.known.has(entity.id)) entered.push(entity)++ if (!known) entered.push(entity)})const left: EntityId[] = []for (const id of session.known) {@@@ -108,16 +108,18 @@ export class ReplicationSystem implements System, Net {broadcastSpawn(entity: GameEntity): void {// Deliver immediately to sessions in range; interest diff would send it-- // next snapshot anyway, but placement feedback should be instant.++ // next snapshot anyway, but placement feedback should be instant. Use the++ // ENTER radius (not the wider exit band) so this matches what++ // updateInterest will admit on the next tick.const wire = wireEntityFor(this.ctx.world, entity)const encoded = encodeServerMessage({ t: 'spawn', entities: [wire] })-- const radiusSq = this.ctx.config.interestRadius ** 2++ const enterRadiusSq = this.ctx.config.interestRadius ** 2for (const session of this.ctx.sessions.values()) {const d2 =(entity.transform.pos.x - session.move.pos.x) ** 2 +(entity.transform.pos.y - session.move.pos.y) ** 2 +(entity.transform.pos.z - session.move.pos.z) ** 2-- if (d2 <= radiusSq) {++ if (d2 <= enterRadiusSq) {session.known.add(entity.id)this.sendRaw(session, encoded)}@@@ -155,7 +157,12 @@ export class ReplicationSystem implements System, Net {replicate(): void {const ctx = this.ctxfor (const session of ctx.sessions.values()) {-- const diff = updateInterest(session, ctx.world, ctx.config.interestRadius)++ const diff = updateInterest(++ session,++ ctx.world,++ ctx.config.interestRadius,++ ctx.config.interestHysteresis,++ )if (diff.entered.length > 0) {this.send(session, {t: 'spawn',nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/game/replication.test.ts'++// covers: 'apps/server/src/game'++// Generated by node test/run.js --index; do not edit.
@@ -58,6 +58,8 @@ export interface ServerConfig {
@@ -58,6 +58,8 @@ export interface ServerConfig {
snapshotEvery: number
snapshotEvery: number
/** Entities beyond this distance from a player are not replicated to them. */
/** Entities beyond this distance from a player are not replicated to them. */
interestRadius: number
interestRadius: number
+ /** Fraction the interest exit radius exceeds the enter radius (spawn/despawn hysteresis). */
+ interestHysteresis: number
maxPlayers: number
maxPlayers: number
persistFlushSeconds: number
persistFlushSeconds: number
metricsLogSeconds: number
metricsLogSeconds: number
@@ -105,6 +107,14 @@ function trimSlash(v: string): string {
@@ -105,6 +107,14 @@ function trimSlash(v: string): string {
return v.replace(/\/+$/, '')
return v.replace(/\/+$/, '')
}
}
+/**
+ * Fraction the interest exit radius exceeds the enter radius. A client keeps
+ * an entity it already knows while it stays inside `interestRadius * (1 + H)`,
+ * so an entity loitering on the interest boundary cannot churn spawn/despawn
+ * every snapshot; only a fresh entity must come inside `interestRadius`.
+ */
+export const INTEREST_HYSTERESIS = 0.1
+
export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
const secret = env.OV_OAUTH_CLIENT_SECRET ?? null
const secret = env.OV_OAUTH_CLIENT_SECRET ?? null
return {
return {
@@ -164,6 +174,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -164,6 +174,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
tickRate: 30,
tickRate: 30,
snapshotEvery: 2,
snapshotEvery: 2,
interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),
interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),
+ interestHysteresis: INTEREST_HYSTERESIS,
maxPlayers: intEnv(env, 'MAX_PLAYERS', 64),
maxPlayers: intEnv(env, 'MAX_PLAYERS', 64),
persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),
persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+/**
+ * Interest hysteresis (replication tick). A body only ENTERS a client's
+ * interest inside `radius`, but an already-known body is retained out to
+ * `radius * (1 + INTEREST_HYSTERESIS)` — so a body loitering on the boundary
+ * cannot churn spawn/despawn every snapshot.
+ *
+ * updateInterest touches only world.spatial, world.entities and the session's
+ * position/known set, so the world and session here are minimal stand-ins.
+ */
+import { EntityStore, SpatialHash, type GameEntity } from '@openvibe/gameplay'
+import { quat, vec3, type EntityId } from '@openvibe/shared'
+import { describe, expect, it } from 'vitest'
+import type { GameWorld } from './gameWorld.js'
+import type { PlayerSession } from './playerSession.js'
+import { INTEREST_HYSTERESIS, updateInterest } from './replication.js'
+
+const RADIUS = 80
+const EXIT = RADIUS * (1 + INTEREST_HYSTERESIS)
+
+function makeWorld(): GameWorld {
+ return {
+ spatial: new SpatialHash<EntityId>(16),
+ entities: new EntityStore(),
+ } as unknown as GameWorld
+}
+
+function makeSession(): PlayerSession {
+ return {
+ entityId: 'p1' as EntityId,
+ move: { pos: vec3(0, 0, 0) },
+ known: new Set<EntityId>(),
+ } as unknown as PlayerSession
+}
+
+let nextId = 0
+/** A static prop at (x, 0); moveTo keeps the spatial hash in step. */
+function addBody(world: GameWorld, x: number): { id: EntityId; moveTo: (x: number) => void } {
+ const id = `e${++nextId}` as EntityId
+ const entity: GameEntity = {
+ id,
+ kind: 'prop',
+ transform: { pos: vec3(x, 0, 0), rot: quat() },
+ persistent: false,
+ dirty: false,
+ }
+ world.entities.add(entity)
+ world.spatial.insert(id, x, 0)
+ return {
+ id,
+ moveTo: (nx: number) => {
+ entity.transform.pos.x = nx
+ world.spatial.move(id, nx, 0)
+ },
+ }
+}
+
+describe('interest hysteresis', () => {
+ it('enters inside the radius and is retained past it up to the exit radius', () => {
+ const world = makeWorld()
+ const session = makeSession()
+ const body = addBody(world, 0.9 * RADIUS)
+
+ const entering = updateInterest(session, world, RADIUS)
+ expect(entering.entered.map((e) => e.id)).toEqual([body.id])
+ expect(entering.left).toEqual([])
+ expect(session.known.has(body.id)).toBe(true)
+
+ // Inside the hysteresis band: no re-spawn and no despawn.
+ body.moveTo(1.05 * RADIUS)
+ const retained = updateInterest(session, world, RADIUS)
+ expect(retained.entered).toEqual([])
+ expect(retained.left).toEqual([])
+ expect(session.known.has(body.id)).toBe(true)
+
+ // Exactly at the exit radius is still retained.
+ body.moveTo(EXIT)
+ const atEdge = updateInterest(session, world, RADIUS)
+ expect(atEdge.entered).toEqual([])
+ expect(atEdge.left).toEqual([])
+ expect(session.known.has(body.id)).toBe(true)
+ })
+
+ it('leaves only past the exit radius', () => {
+ const world = makeWorld()
+ const session = makeSession()
+ const body = addBody(world, 0.9 * RADIUS)
+ updateInterest(session, world, RADIUS)
+
+ body.moveTo(EXIT + 1)
+ const leaving = updateInterest(session, world, RADIUS)
+ expect(leaving.entered).toEqual([])
+ expect(leaving.left).toEqual([body.id])
+ expect(session.known.has(body.id)).toBe(false)
+ })
+
+ it('does not spawn/despawn a body oscillating in the band', () => {
+ const world = makeWorld()
+ const session = makeSession()
+ const body = addBody(world, 0.9 * RADIUS)
+ const first = updateInterest(session, world, RADIUS)
+ expect(first.entered.map((e) => e.id)).toEqual([body.id])
+
+ let spawns = 0
+ let despawns = 0
+ for (let i = 0; i < 12; i++) {
+ // Cross back and forth over the enter radius, never past the exit one.
+ body.moveTo(i % 2 === 0 ? 1.02 * RADIUS : 0.98 * RADIUS)
+ const diff = updateInterest(session, world, RADIUS)
+ spawns += diff.entered.length
+ despawns += diff.left.length
+ }
+ expect(spawns).toBe(0)
+ expect(despawns).toBe(0)
+ expect(session.known.has(body.id)).toBe(true)
+ })
+
+ it('exports the enter radius framing as INTEREST_HYSTERESIS', () => {
+ expect(INTEREST_HYSTERESIS).toBeGreaterThan(0)
+ })
+})
@@ -7,9 +7,12 @@ import type {
@@ -7,9 +7,12 @@ import type {
WirePlayerState,
WirePlayerState,
} from '@openvibe/protocol'
} from '@openvibe/protocol'
import { v3distSq, type EntityId } from '@openvibe/shared'
import { v3distSq, type EntityId } from '@openvibe/shared'
+import { INTEREST_HYSTERESIS } from '../config.js'
import type { GameWorld } from './gameWorld.js'
import type { GameWorld } from './gameWorld.js'
import type { PlayerSession } from './playerSession.js'
import type { PlayerSession } from './playerSession.js'
+export { INTEREST_HYSTERESIS }
+
/**
/**
* Interest management + snapshot building.
* Interest management + snapshot building.
*
*
@@ -85,23 +88,36 @@ export interface InterestDiff {
@@ -85,23 +88,36 @@ export interface InterestDiff {
left: EntityId[]
left: EntityId[]
}
}
-/** Updates session.known in place and returns what changed. */
+/**
+ * Updates session.known in place and returns what changed.
+ *
+ * `radius` is the ENTER radius. A body must come inside it to be replicated,
+ * but an already-known body is retained out to exitRadius = radius * (1 + H).
+ * The gap is hysteresis: without it a body sitting on the boundary flips
+ * in/out every snapshot, churning spawn/despawn on the wire.
+ */
export function updateInterest(
export function updateInterest(
session: PlayerSession,
session: PlayerSession,
world: GameWorld,
world: GameWorld,
radius: number,
radius: number,
+ hysteresis: number = INTEREST_HYSTERESIS,
): InterestDiff {
): InterestDiff {
const radiusSq = radius * radius
const radiusSq = radius * radius
+ const exitRadius = radius * (1 + hysteresis)
+ const exitRadiusSq = exitRadius * exitRadius
const entered: GameEntity[] = []
const entered: GameEntity[] = []
const current = new Set<EntityId>()
const current = new Set<EntityId>()
- // Spatial hash prunes candidates; the exact 3D distance test decides.
- world.spatial.forEachInRadius(session.move.pos.x, session.move.pos.z, radius, (id) => {
+ // Candidates out to the exit radius; the exact 3D distance test decides.
+ world.spatial.forEachInRadius(session.move.pos.x, session.move.pos.z, exitRadius, (id) => {
if (id === session.entityId) return
if (id === session.entityId) return
const entity = world.entities.get(id)
const entity = world.entities.get(id)
if (!entity) return
if (!entity) return
- if (v3distSq(entity.transform.pos, session.move.pos) > radiusSq) return
+ const known = session.known.has(entity.id)
+ const d2 = v3distSq(entity.transform.pos, session.move.pos)
+ // Enter inside the enter radius; stay while inside the exit radius.
+ if (d2 > (known ? exitRadiusSq : radiusSq)) return
current.add(entity.id)
current.add(entity.id)
- if (!session.known.has(entity.id)) entered.push(entity)
+ if (!known) entered.push(entity)
})
})
const left: EntityId[] = []
const left: EntityId[] = []
for (const id of session.known) {
for (const id of session.known) {
@@ -108,16 +108,18 @@ export class ReplicationSystem implements System, Net {
@@ -108,16 +108,18 @@ export class ReplicationSystem implements System, Net {
broadcastSpawn(entity: GameEntity): void {
broadcastSpawn(entity: GameEntity): void {
// Deliver immediately to sessions in range; interest diff would send it
// Deliver immediately to sessions in range; interest diff would send it
- // next snapshot anyway, but placement feedback should be instant.
+ // next snapshot anyway, but placement feedback should be instant. Use the
+ // ENTER radius (not the wider exit band) so this matches what
+ // updateInterest will admit on the next tick.
const wire = wireEntityFor(this.ctx.world, entity)
const wire = wireEntityFor(this.ctx.world, entity)
const encoded = encodeServerMessage({ t: 'spawn', entities: [wire] })
const encoded = encodeServerMessage({ t: 'spawn', entities: [wire] })
- const radiusSq = this.ctx.config.interestRadius ** 2
+ const enterRadiusSq = this.ctx.config.interestRadius ** 2
for (const session of this.ctx.sessions.values()) {
for (const session of this.ctx.sessions.values()) {
const d2 =
const d2 =
(entity.transform.pos.x - session.move.pos.x) ** 2 +
(entity.transform.pos.x - session.move.pos.x) ** 2 +
(entity.transform.pos.y - session.move.pos.y) ** 2 +
(entity.transform.pos.y - session.move.pos.y) ** 2 +
(entity.transform.pos.z - session.move.pos.z) ** 2
(entity.transform.pos.z - session.move.pos.z) ** 2
- if (d2 <= radiusSq) {
+ if (d2 <= enterRadiusSq) {
session.known.add(entity.id)
session.known.add(entity.id)
this.sendRaw(session, encoded)
this.sendRaw(session, encoded)
}
}
@@ -155,7 +157,12 @@ export class ReplicationSystem implements System, Net {
@@ -155,7 +157,12 @@ export class ReplicationSystem implements System, Net {
replicate(): void {
replicate(): void {
const ctx = this.ctx
const ctx = this.ctx
for (const session of ctx.sessions.values()) {
for (const session of ctx.sessions.values()) {
- const diff = updateInterest(session, ctx.world, ctx.config.interestRadius)
+ const diff = updateInterest(
+ session,
+ ctx.world,
+ ctx.config.interestRadius,
+ ctx.config.interestHysteresis,
+ )
if (diff.entered.length > 0) {
if (diff.entered.length > 0) {
this.send(session, {
this.send(session, {
t: 'spawn',
t: 'spawn',
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/game/replication.test.ts'
+// covers: 'apps/server/src/game'
+// Generated by node test/run.js --index; do not edit.