Replication and Rendering Performance
Large worlds can contain thousands of independently simulated parts. The runtime keeps those parts interactive in the engine, but the live client view must avoid treating every unchanged part as new replication and rendering work every frame. This document describes the reusable live replication path used by the local spectator WebSocket:- The WebSocket sends one full
SpectatorObservationwhen the client connects. - Later WebSocket messages are
spectator_deltaframes. - Engine-side dirty state records which instances changed at the source.
- The browser applies each delta to its materialized full view state.
- Renderers receive the normal full state plus advisory delta metadata.
- Shared model rendering updates only changed instance matrices when group structure is stable.
WebSocket State Model
The first spectator WebSocket message must be a completeSpectatorObservation.
Clients need this base state before any delta can be applied.
After that, the server may send:
entities is a list of entity changes, not a replacement list. An empty
entities array means “no entity changes”; it does not mean “clear all
entities”.
Player data, sounds, and recent events are small enough to remain full
replacement fields in each delta.
Entity Delta Format
Entity deltas have three forms:addinserts or replaces the complete entity.rmremoves the entity.pandrupdate only position and rotation.- If render schema changes, the server sends
add, not a transform-only update. - If an entity disappears and reappears with the same id in one computed delta,
the later
addstate wins.
Engine Dirty Tracking
Full entity observations are expensive in large worlds because they require a Workspace scan and serialization of every observed part. The live view therefore uses source-level dirty tracking after the initial snapshot. Dirty state is marked when an instance changes in a replication-visible way:Parentchanges mark the instance subtree and old/new parents.SetAttributemarks the changed instance.- BasePart transform, size, shape, render, material, physics, and collision property writes mark the part.
- BillboardGui metadata marks the billboard, which resolves to its ancestor part for entity serialization.
- Physics writeback marks parts whose simulated position or rotation changed.
- Destroyed or unparented known parts become remove deltas.
Workspace. One viewer cannot consume changes before another viewer sees them.
Player state still updates at the live WebSocket rate because it is small and
expected to change frequently.
Browser Delta Application
frontend_runtime/dist/main.js applies spectator_delta messages to the latest
full spectator state before calling a renderer. Renderers still receive a
complete state shape with entities, players, tick, and timing fields.
The materialized state also includes advisory metadata:
__spectatorDelta should still render correctly, just with more work.
Shared Model Renderer
runtime.three.createSharedModelRenderer(...) is the reusable rendering helper
for many entities that reference the same ModelUrl.
It still preserves per-entity simulation identity:
- Each part remains a normal engine object.
- Physics, pickup, placement, touch, and observation identity stay per part.
- The renderer only shares loaded mesh resources and GPU instancing.
- entity id to instance index
- dirty entity ids
- structure changes caused by add/remove/group changes
- last known group count
Renderer Hot Path Rules
Large-scene renderers should follow these rules:- Avoid per-entity allocations inside
onState. - Reuse
Map,Set,Vector3,Quaternion, andMatrix4scratch objects. - Cache expensive derived data when the source entity array identity is stable.
- Skip material/color writes when the value has not changed.
- Use
createSharedModelRendererfor repeated static GLB/ModelUrl entities. - Treat
__spectatorDeltaas advisory and keep a correct full-state fallback.
Profiling
Use the built-in profiler while running a large world:observation.spectator- dirty replication update counts
manager.process_instance lockmanager.loop- WebSocket message count and byte size
- Browser frame time while the spectator view is open
physics.world and
instance.tick_pipeline:
awake_dynamic_bodies/active_dynamic_bodiesshow how many dynamic bodies Rapier is solving this frame.largest_dynamic_contact_componentshows the size of the largest solver-contact-connected dynamic component.largest_component_awake_bodiesshows whether that largest component is actually awake.largest_component_above_sleep_thresholdshows how many bodies in that component are above Rapier’s sleep velocity thresholds.largest_component_sleep_timer_readyshows how many bodies have satisfied Rapier’s sleep timer.sync_lua_to_physics dirty_character_partsanddirty_physics_partsdistinguish character/controller writes from ordinary physics object writes.sync_lua_to_physics partial_dirty_single_part_syncshows when the engine is using the incremental single-part assembly path instead of rebuilding specs for every Workspace part.physics.sync_assemblies partial_syncshows when omitted specs are being preserved rather than treated as deleted assemblies.
pyramid-world-3: the initial lag spike was not caused by
all thousands of bodies moving. At startup all dynamic bodies are awake, but
after settling only a small number are above Rapier’s sleep velocity thresholds.
Once Rapier’s sleep timers mature, the largest 2646-body contact component
sleeps and idle ticks return to real-time. Pickup of a single block does not
need to wake the entire pile if the block is already separated from that
component; it keeps one dynamic body awake but can still force the current
Lua-to-physics path to rebuild all single-part assembly specs.
Decision: optimize the general dirty-object sync path before changing physics
semantics. When Workspace and physics already have the same part IDs, there are
no weld edges, and every assembly is one part, dirty ordinary parts can be
synced independently. Omitted one-part assemblies are preserved; they are not
treated as deletes. Welded assemblies, structural changes, missing/extra
physics parts, and merged/split bodies continue through the full sync path.
A simple WebSocket byte probe:
Known Limits
- REST
/observestill returns full observations. - The first spectator snapshot is still large for large worlds.
- Player-only spectator observation currently duplicates player serialization logic from the full observation path and should be refactored when this area settles.
- Sound deltas currently remain tied to the existing spectator message shape; world entity replication is source-dirty, but sound replication should get a dedicated dirty path if large sound sets become common.