@forgeax/engine-scene
v0.3.5
Published
Scene identity, hierarchy, and world transform propagation.
Readme
@forgeax/engine-scene
[!IMPORTANT] Owner: scene identity, hierarchy, and world-space propagation. This package is the sole authority for
Transform,ChildOf,Children,Name,Mobility, andscenePlugin.
Smallest useful example
import { scenePlugin, Transform } from '@forgeax/engine/scene';
import { createWorldContext, World } from '@forgeax/engine/ecs';
const world = new World();
const context = await createWorldContext(world, [scenePlugin()]);
const entity = world
.spawn({ component: Transform, data: {} })
.unwrap();
void entity;
await context.fiber.restart();scenePlugin() is a native Cordis plugin. Its Fiber installs hierarchy
propagation and removes it when the realm unloads. The direct
propagateTransforms(world) entry point returns Result<void, SceneError>;
branch on error.code and repair a diagnosable stale edge, mirror mismatch, or
cycle through World.set/World.removeComponent (or the owning structural
command) before retrying while the World is healthy.
Keyed SceneAsset authoring
SceneAsset is authored as ordinary data. The key in entities is the only
persistent local identity; runtime numeric slots and mount windows are derived
inside this package and never belong in source files.
const level: SceneAsset = {
kind: 'scene',
entities: {
root: { components: { Transform: {} } },
house: {
components: { ChildOf: { parent: 'root' } },
instance: {
source: houseSceneGuid,
overrides: [{ target: ['door'], components: { Name: { value: 'front-door' } } }],
},
},
},
};| Source value | Meaning |
|:--|:--|
| 'root' | Entity in the current scene |
| ['house', 'door'] | Entity door inside instance house; every segment is a key |
| sceneEntity(guid, address) | Reference bound to one catalog identity and address |
| sceneEntity('', address) | Reference to an anonymous direct POD scene |
[!IMPORTANT] Unknown components, unknown fields, missing targets, hierarchy cycles, and recursive instance graphs fail before entity creation. A direct POD scene has no persistent source identity; choose a Pack output key before saving it.
Publication fence for loaded scenes
Catalog-loaded scenes carry their publication evidence in the producer/loading
context, not in the authored instance declaration. Acquire the current fence
from that context, then pass it to
AssetRegistry.instantiateWithPublicationFence or
instantiateFlatWithPublicationFence; the call compares the complete tuple:
source path and revision, generation, package digest, output-set digest, and
receipt identity. Ordinary instantiate/instantiateFlat still validate every
nested scene output against the parent publication before spawning. A missing or
mismatched tuple returns asset-generation-fence-mismatch with expected,
actual, currentGeneration, lastKnownGood, and recovery actions; repair or
recook the producer, then open the current publication and retry. Direct
anonymous POD scenes have no Catalog fence and remain addressable only for their
current in-memory instance; saving one requires an explicit Pack/output key.
Each SceneInstanceState retains the schema-declared shared references in its
source, including arrays and instance overrides, until that state is destroyed.
Changing a live field therefore does not invalidate the original value needed
by worldRemoveSceneOverride. Nested instances own their source references
independently; flat top-level entities retain only their live component values.
Replacing an owned scene root can release the old instance without affecting
another consumer of the same asset.
When the plugin runs from the scheduled path, world.update(deltaSeconds)
wraps a thrown Scene failure as system-failed; the wrapper's
error.detail.cause is the original SceneError (including its structured
code/detail). That post-write system failure poisons the World, so the next
use returns world-poisoned. Inspect detail.cause, stop using that World
identity, discard it, and ask the App execution owner to call
app.execution.rebuild() for a fresh World. Do not retry a poisoned or
partially-written World in place.
If an explicitly malformed internal fixture reports a Children mirror
mismatch, never write the target array directly. A same-target
world.set(child, ChildOf, { parent }) intentionally records the source
evidence but skips mirror/index churn, so it cannot repair that mismatch. On a
healthy World, repair through the source owner by removing and re-adding
ChildOf (or reparenting through another target and then back to the intended
one); if the failure has poisoned the World, discard it and rebuild through the
App instead.
Transform propagation writes the exact recomputed frontier into
GlobalTransform and advances those rows' ordinary component versions only
after a successful pass. A no-change pass advances no GlobalTransform row.
Persistent consumers keep a changed: [GlobalTransform] Query, so a moved
subtree is exposed as contiguous spans without a parallel event journal.
Transform remains authored local TRS; GlobalTransform is the resolved
world-space authority.
The first propagation, and the next one after any hierarchy error, validates
the full hierarchy through ECS-maintained Children lists. After that, work is
driven by change evidence: edited Transform/ChildOf/GlobalTransform rows,
plus the occupants of each 256-row storage block whose membership changed
(spawn, despawn, component add/remove). Only the highest such entities and
their subtrees are expanded in parent-first order, so an unrelated spawn or
despawn costs the touched blocks, not the Transform population. A ChildOf
value write whose target Children list does not mirror the edge, a parent
walk longer than the hierarchy, or an entity that leaves the Transform pair
while still holding a hierarchy edge falls back to the full pass so malformed
graphs report the same diagnostics as a cold evaluation. A second
FixedUpdate/Update propagation without intervening writes performs no
hierarchy traversal. External writes to GlobalTransform (flat roots
included) are reconciled back to the derived value; the owner's own
publications are consumed before returning. Children remains a read-only relationship projection;
there is no duplicate Scene graph or per-node matrix cache.
Removing ChildOf is a structural root transition. Even when the authored
Transform is unchanged, the next propagation composes and publishes that
entity as a flat local root; ordinary same-value hierarchy recomputation still
does not publish an unchanged GlobalTransform row.
Flat propagation uses the same numeric kernel inline or through an installed
SharedKernel executor. It borrows the existing changed query, joins root
matrices before resolving the hierarchy, and leaves small or fragmented ranges
inline. Scene builds its self-contained kernel module into pkg/ from the same
TypeScript source; no new Worker pool, component-name registry, or transform
change journal is introduced. Partial shared writes poison the owning World.
Transform declares the generic ECS requirement GlobalTransform. Therefore
ordinary world.spawn, world.addComponent, and deferred Commands.spawn
materialize the transient world column automatically at the structural
boundary; scene code does not need a special pair-completion helper and the
frame loop never scans entities to repair them. Explicit GlobalTransform
data remains valid when an importer or recovery path needs to provide it.
ChildOf declares Transform as a relationship-source requirement. A child
created with only { component: ChildOf, data: { parent } } therefore receives
the full local/world pair transitively, while explicit Transform data still
wins. This keeps hierarchy authoring small without adding per-frame repair or
an additional scene-side component registry.
Deferred Commands.spawn uses the same { component, data } entries as
World.spawn; the pending child is materialized and linked at command flush.
For an existing child, the generic reparent call is
world.reparent(child, newParent, ChildOf, { parent: newParent }) and routes
through the same relationship owner.
ChildOf uses linkedSpawn: true: despawning a parent recursively despawns
its linked hierarchy. A generic relationship may opt out, but that is not the
Scene hierarchy lifecycle.
Removing the required component is still an explicit malformed-state escape
hatch, not an automatic cascade. Propagation reports that state as a
structured SceneError, preserving a clear owner and recovery path.
The authority also applies to dynamic loading: import Transform, ChildOf, and scenePlugin from @forgeax/engine/scene when a host resolves packages at runtime.
Mobility
Mobility { kind } is the author's commitment about motion, stored as the
closed enum MobilityKind = 'static' | 'stationary' | 'movable' (numeric
values in MobilityKindValue, narrowed by mobilityKindFromU32). An entity
without Mobility is movable; runtime spawns stay movable unless authoring
writes static.
world.spawn(
{ component: Transform, data: { pos: [0, 0, -5] } },
{ component: Mobility, data: { kind: MobilityKindValue.static } },
);| Kind | Mesh entity | Light entity |
|:--|:--|:--|
| static | Never moves after placement; eligible for baking and static caches (a shadow caster joins the static shadow layer on its first frame) | Fully baked |
| stationary | Invalid: mobility-invalid-kind | Fixed position, runtime direct light |
| movable / absent | No promise; caches use observed motion | Fully realtime |
Mobility is the rendering and baking authority; physics RigidBodyType is the
simulation authority. Neither is derived from the other.
Violations are non-blocking diagnostics with one closed union,
MobilityDiagnosticCode in src/errors.ts. Each diagnostic
carries code, expected, hint, and detail.entity, plus
detail.sceneEntityRef when a SceneInstance binds the entity. Each
(World, code, entity) is reported once through
subscribeMobilityDiagnostics(listener) and console.warn. The package that
sees both sides of each contract detects it:
| Code | Detector | Trigger |
|:--|:--|:--|
| mobility-static-moved | scenePlugin Update system | A static entity's Transform changes after the frame that declared it |
| mobility-invalid-kind | Renderer source systems | stationary on an entity with MeshFilter |
| mobility-physics-conflict | physicsPlugin | static with RigidBody type dynamic or kinematic |
Diagnostics never change behaviour: a moved static entity still propagates and
renders correctly. Repair by declaring MobilityKindValue.movable (or removing
Mobility) on entities that move. Detector packages report through
emitMobilityDiagnostic(world, entity, violation); they do not define another
channel.
Boundary
| This package owns | Excluded concepts | |:--|:--| | Identity, parent/child links, local/world transforms | Meshes, materials, cameras, skins, animation, GPU/RHI |
See src/index.ts for the public roster and src/errors.ts for recovery details.
Visibility hierarchy boundary
Quick start: create ChildOf links through the scene package, then let
resolveVisibility(world) consume the projected hierarchy when a render or
remote diagnostic asks for an effective state.
| Fact | Scene owns | Consumer owns |
|:--|:--|:--|
| Parent relation | ChildOf, Children, and hierarchy projection | Visibility intent and render filtering |
| Effective lookup | Valid parent traversal and hierarchy diagnostics | Visibility field values and renderer statistics |
| Recovery | Repair a stale/cyclic relation from SceneHierarchyDiagnostic | Do not reinterpret a hierarchy error as a camera or picking error |
Read VisibilityResolution.source to distinguish self, parent, and the
default root case. If diagnostics report an invalid hierarchy, fix the scene
relation and resolve again; do not add a render-only parent or bypass the
scene graph. Camera, picking, lifecycle, assets, and VFX shadow behavior are
out of scope for this package.
