Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
37 changes: 33 additions & 4 deletions packages/@dcl/ecs/src/engine/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ export type IEntityContainer = {
updateUsedEntity(entity: Entity): boolean
}

/**
* True when `entity`'s NUMBER falls in the renderer-reserved range, at any version.
*
* Masks rather than calling `EntityUtils.fromEntityId`, which allocates a tuple per call to
* read one number; this runs on every inbound CRDT message. `entity & MAX_U16` already lands
* in [0, 65535], so the `>>> 0` fromEntityId applies is a no-op here.
*/
function isReservedEntity(entity: Entity, reservedStaticEntities: number): boolean {
return (entity & MAX_U16) < reservedStaticEntities
}

/**
* @public
*/
Expand Down Expand Up @@ -143,7 +154,11 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
}

for (const [number, version] of removedEntities.getMap()) {
if (version < MAX_U16) {
// Never recycle a renderer-reserved number: the renderer reissues those slots as
// toEntityId(number, version + 1) too, from the same stored version, so it would hand
// the scene an id belonging to a live remote player. Belt-and-braces — every writer
// into `removedEntities` refuses reserved numbers, so this cannot fire today.
if (number >= reservedStaticEntities && version < MAX_U16) {
const entity = EntityUtils.toEntityId(number, version + 1)
// If the entity is not being used, we can re-use it
// If the entity was removed in this tick, we're not counting for the usedEntities, but we have it in the toRemoveEntityArray
Expand All @@ -158,7 +173,7 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
}

function removeEntity(entity: Entity) {
if (entity < reservedStaticEntities) return false
if (isReservedEntity(entity, reservedStaticEntities)) return false

if (usedEntities.has(entity)) {
usedEntities.delete(entity)
Expand All @@ -185,6 +200,12 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
}

function updateRemovedEntity(entity: Entity) {
// Called for EVERY inbound DELETE_ENTITY, including the renderer's tombstones for
// departed remote players — so this is the door reserved numbers would otherwise enter
// the free list through. They need no tombstone: getEntityState reports them Reserved
// before it consults `removedEntities`, so `Removed` is unreachable for them anyway.
if (isReservedEntity(entity, reservedStaticEntities)) return false

const [n, v] = EntityUtils.fromEntityId(entity)

// Update the removed entities map
Expand All @@ -199,6 +220,11 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
}

function updateUsedEntity(entity: Entity) {
// Same invariant as updateRemovedEntity. Unreachable from the CRDT path today
// (getEntityState returns `Reserved`, never `Unknown`), but the `v > 0` branch below
// would seed `removedEntities` with a reserved number.
if (isReservedEntity(entity, reservedStaticEntities)) return false

const [n, v] = EntityUtils.fromEntityId(entity)

// if the entity was removed then abort fast
Expand All @@ -216,11 +242,14 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
}

function getEntityState(entity: Entity): EntityState {
const [n, v] = EntityUtils.fromEntityId(entity)
if (n < reservedStaticEntities) {
// Same guard as the three above, so `Engine.removeEntity` — which classifies via
// getEntityState — cannot disagree with whether this container releases the id.
if (isReservedEntity(entity, reservedStaticEntities)) {
return EntityState.Reserved
}

const [n, v] = EntityUtils.fromEntityId(entity)

if (usedEntities.has(entity)) {
return EntityState.UsedEntity
}
Expand Down
21 changes: 19 additions & 2 deletions packages/@dcl/ecs/src/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ByteBuffer } from '../serialization/ByteBuffer'
import { crdtSceneSystem, OnChangeFunction } from '../systems/crdt'
import { ComponentDefinition } from './component'
import { createComponentDefinitionFromSchema } from './lww-element-set-component-definition'
import { Entity, createEntityContainer } from './entity'
import { Entity, EntityState, EntityUtils, createEntityContainer } from './entity'
import { ReadonlyComponentSchema } from './readonly'
import { SystemItem, SystemContainer, SystemFn, SYSTEMS_REGULAR_PRIORITY } from './systems'
import type {
Expand All @@ -29,6 +29,9 @@ export * from './readonly'
export * from './types'
export { Entity, ByteBuffer, SystemItem, OnChangeFunction }

/** RootEntity 0, PlayerEntity 1, CameraEntity 2 — see the engineInstance fields below. */
const NAMED_STATIC_ENTITIES = 3

function preEngine(options?: IEngineOptions): PreEngine {
const entityContainer = options?.entityContainer ?? createEntityContainer()
const componentsDefinition = new Map<number, ComponentDefinition<unknown>>()
Expand All @@ -49,6 +52,20 @@ function preEngine(options?: IEngineOptions): PreEngine {
return entity
}
function removeEntity(entity: Entity) {
// The renderer streams the avatar range and drops the scene's deletes there, so purging
// locally is permanent: a one-shot component like PlayerIdentityData is never re-sent, and
// the entity is left as a moving Transform with no identity. The three named static
// entities are reserved too, but the renderer DOES apply scene deletes on them — that is
// how InputModifier.deleteFrom(engine.PlayerEntity) clears an input lock — so they must
// still be purged. Asking the container keeps this in step with whether it releases the
// id, including for a custom container with a different reserved range.
const [entityNumber] = EntityUtils.fromEntityId(entity)
const isAvatarEntity =
entityNumber >= NAMED_STATIC_ENTITIES && entityContainer.getEntityState(entity) === EntityState.Reserved

const released = entityContainer.removeEntity(entity)
if (isAvatarEntity) return released

for (const [, component] of componentsDefinition) {
// TODO: hack for the moment.
// We still need the NetworkEntity to forward this message to the SyncTransport.
Expand All @@ -57,7 +74,7 @@ function preEngine(options?: IEngineOptions): PreEngine {
component.entityDeleted(entity, true)
}

return entityContainer.removeEntity(entity)
return released
}

function removeEntityWithChildren(entity: Entity) {
Expand Down
5 changes: 4 additions & 1 deletion packages/@dcl/ecs/src/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,11 @@ export interface IEngine {
* @public
* Remove all components of an entity
* @param entity - entity
* @returns whether the entity id was released for reuse. Ids in the renderer-reserved range
* are never released, at any version. Components are still purged for
* RootEntity/PlayerEntity/CameraEntity, but not for the avatar range.
*/
removeEntity(entity: Entity): void
removeEntity(entity: Entity): boolean

/**
* Remove all components of each entity in the tree made with Transform parenting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1461,7 +1461,7 @@ export interface IEngine {
registerComponentDefinition<T>(componentName: string, componentDefinition: ComponentDefinition<T>): ComponentDefinition<T>;
// (undocumented)
removeComponentDefinition(componentId: number | string): void;
removeEntity(entity: Entity): void;
removeEntity(entity: Entity): boolean;
removeEntityWithChildren(entity: Entity): void;
removeSystem(selector: string | SystemFn): boolean;
readonly RootEntity: Entity;
Expand Down
Loading
Loading