CPU and TypeScript optimization and best practices
It’s a good idea to use
Tracing to verify the cost of haptic feedback in your world. In some cases, the cost can be extreme. In this case you should look into modifying/removing the haptic feedback.
In this example, haptic feedback takes ~7.8 ms per frame when active.Trimesh and SubD don’t mix If you have a trimesh world, having any SubD objects in it will incur additional CPU costs due to sunlight updates. Even if they are disabled/invisible they will still incur this extra performance cost. We recommend removing any and all SubD objects from trimesh worlds.
Only enable necessary settings for 2D objects When editing objects in Desktop Editor, ensure that only settings that are needed for the object are enabled.
For example, if the “Motion” property is set to “Animated”, components will be automatically added to account for that change (CollisionNotifier, Rigidbody, and a PhysicsComponentSG component).
Also, if objects won’t ever be seen by the player, then turn off visibility as well. Keep in mind that additional settings will add runtime cost to a world such as costs to Physics and Sunlight so recommend turning off settings that aren’t needed.
A large number of active animators in a world can quickly add up to a significant amount of CPU time and degraded performance. Because of this, we recommend keeping the number of active animating objects as low as possible.
Optimizing TypeScript can have some of the largest impact when trying to improve world performance.
You can use deep profiling to find out what the expensive bridge calls actually are and how much CPU time they use when tracing. Note that associating these calls directly back to a line of code is not currently automatic and you’ll have to manually find those in your code.
Toggle to enable deep tracing
To optimize scripting CPU usage, focus on bridge calls.
- Take a deep trace.
- Find which bridge calls are done when and which contribute the most to CPU usage.
- Then, correlate them to your scripts and optimize where necessary.
Most Meta Horizon Worlds TypeScript APIs trigger bridge calls which can be expensive. It’s strongly recommended to minimize the impact by calling the functions only when absolutely necessary and caching results when possible. There is some caching internally but it is for a single frame. The following types should all have per-frame caching:
- HorizonProperty
- ReadableHorizonProperty
- WritableHorizonProperty
Manual caching should still be done for values that are only needed occasionally (not per frame).
Common functions that cause bridging include anything that accesses data or sets an entity’s data (e.g. get/set position), or anything that interacts with the world’s data (e.g. raycasts), or anything that interacts with a non-TS concept (e.g. playing audio).
- get/set visibility
- get/set position
- get/set rotation
- get/set scale
- get/set player
- get/set owner
- get/set body part position
- set UI binding
- UI callbacks
Note that every get/set in a chain is its own potential bridge call. For example, this.entity.owner.get().position.get()
is two bridge calls. In this example, we recommend that you cache the owner on start.
You should then check if a value has changed before setting its corresponding property. For example, if you need to set an entity’s visibility, don’t do it every frame, but instead only when something has changed.
Calculating versus getting In some instances you can save bridge calls by calculating a value instead of getting it. For example, take the following calls:
this.entityToRotate.position.get();
this.entityToRotate.forward.get();
this.entityToRotate.up.get();
this.entityToRotate.rotation.get();
Depending on where these are called, you can potentially simplify them by getting the position and rotation and calculating the forward and up vectors by multiplying the rotation vector by Vec3.forward
and Vec3.up
. For example to calculate the forward vector (thus replacing the call to this.entityToRotate.forward.get()
):
const entityToRotateRotation = this.entityToRotate.rotation.get();
const entityToRotateForward = Quaternion.mulVec3(
entityToRotateRotation,
Vec3.forward,
);
Optimizing networked TypeScript calls Many TypeScript calls require some synchronization with other clients and the server, all done across the network. The performance degradation from calls can add up quickly. When we profile TypeScript calls, we often notice many networked calls happening on the same frame. Staggering these updates is a good way of mitigating these CPU costs. This means spreading out the network calls over two or more frames. For example if an enemy dies, you may need to reduce its health, find out it died, actually destroy it, play a sound, increment a score, and so on. All of these calls could potentially happen on a different frame in sequence, reducing the overall per-frame CPU cost. See the following Events section for more information.
If you know your event is purely local (same client owns the sender and receiver entity), then using a HorizonEvent in TS will be much faster than a CodeblockEvents because the latter goes to C# and causes a bridge call because it may need to be networked.
When generating multiple events in scripts, chain the events over multiple frames instead of all in one.
For example, when firing a weapon:
- Start “fire” sound and video FX
- Launch projectile
- Apply recoil rotation to the gun
- Start VFX and SFX for the projectile
- Update ammo state (ammo bar, ammo text, gun state visuals, reticle state if out of ammo)
- Log that player has shot for accuracy calculations
- Update reticle position
- Update pistol holster position and rotation
These events could be chained over multiple frames without any loss of visual fidelity:
- Frame 1
- Start “fire” sound and video FX
- Apply recoil rotation to the gun
- Frame 2
- Launch projectile
- Start VFX and SFX for the projectile
- Frame 3
- Frame 4
- Update ammo state (ammo bar, ammo text, gun state visuals, reticle state if out of ammo)
- Log that player has shot for accuracy calculations
- Frame 5
- Update pistol holster position and rotation
Broadcast events are a type of Horizon event that notify all objects subscribed to the same event without directly referencing them. This enables your code to be less dependent on knowing which objects should receive the event, reducing code complexity. Like Horizon events, Broadcast events are performed synchronously, but the execution order is random and is only be received by listeners registered on the same client.
Maintaining Broadcast event subscriptions can slow your event messaging as more subscriptions are added. If your objects no longer need to listen for Broadcast events, you can unsubscribe from these sources to improve performance.
this.eventSubscription = this.connectBroadcastEvent(
World.onUpdate (data: { deltaTime: number }) => {
}
);
// Cancel subscription logic
if (this.eventSubscription !== null) {
this.eventSubscription.disconnect();
this.eventSubscription = null;
}
Calculating and updating the attachment of an object in TypeScript can be expensive. An example would be attaching a pistol to a holster on the player. Rather than writing code to manually do this, we recommend using the attachment system as it has much better CPU performance.
Raycasts can be very expensive. Using a short raycast distance will be much cheaper than a longer distance, so make sure you set it to the minimum necessary, and make sure you only raycast if you need to. If they are necessary, stagger the calls whenever possible over multiple frames.
Playing audio clips is very CPU intensive. Whenever possible, combine multiple separate sounds into one merged sound file to improve performance. There is an option for audio called Play and Forget that runs faster but it does not provide any callbacks. We recommend that you use Play and Forget whenever possible. You can still get a similar effect as the callback by using a timer.
Here are some more audio playback optimization recommendations:
- Limit the minimum and maximum distance of Audio Graph Gizmo properties.
- Use .wav files instead of .opus for short audio files to reduce cost of de-encoding.
- Where possible, make sounds global to avoid the cost of spatializing.
- While there is a hard limit of 32 audio graph gizmos playing at once, that shouldn’t be treated as a stable limit and try instead to have no more than 10-12.
- Current behavior is that loading/opening an audio object causes a cpu hit i.e. even when the object isn’t playing. If possible, it’s best to only load gizmos on-demand.
Be cognizant of the CPU cost of players in the world. In multiplayer worlds, each avatar might use an additional 0.5 msec to process. 24 players might require 12 milliseconds, almost an entire frame at 72 fps. Limit the quantity of concurrent players, when publishing the world, accordingly.
In server traces, an object spawn can take a significant amount of time. Traces to look for include:
- ServerSpawn
- GetAssetDataFromBackendAsync
- GetEntityCount
- ClientSpawn
ServerSpawn, in this trace, lasts over 1.5 seconds.Although ServerSpawn is not processed on the main thread, secondary effects are seen there.
Multiple calls can be seen in the trace:
DataModel::CreateNodeFromEntityType
SceneGraphTreeNodeLoader::GetEntityStatesFromTreeNode
ScriptingRuntimeIntegration::InstantiationStep
DynamicLightsRuntimeIntegration::PostSpawnInstantiationStep
In the server’s main thread, spawning objects also leads to skipped updates.A similar pattern is seen in client traces.
ClientSpawn runs for 280 milliseconds on a secondary thread.Effects of spawning on the client’s main thread are more troublesome. Multiple calls can be seen disrupting the main thread:
SceneGraphTreeNodeLoader::GetEntityStatesFromTreeNode
DataModel::CreateNodeFromEntityType
ScriptingRuntimeIntegration::InstantiationSte
pUnityCollisionComponentsService::InstantiationStep
SubDRuntimeIntegration::InstantiationStep
PhysicsRuntimeIntegration::InstantiationStep
ClientSpawn disruptions on the main thread cause multiple long and skipped frames.Reduce or eliminate dynamic spawning Do not dynamically spawn objects during game play if at all possible. Spawn everything needed before play begins. For games with multiple levels or rooms, display a loading or cut-scene while old objects are released and new ones are spawned.
In some cases, such as weapons with projectiles, it may be beneficial to pre-warm (or pre-fire) the weapon after loading. Same with any object that has secondary effects when touched, shot, or otherwise interacted with.
Have limits in the code to control the number of objects created While Object Spawning allows creators to create many objects while their world is active, it also affects the world’s object limit. Enforcing a maximum number of objects that can be spawned ensures the world stays within performant range and won’t break unintentionally.
Track objects to assess when they are no longer needed Once an object spawns in-world, it exists as long as the world instance is active. Alternatively, the object can be proactively despawned if it is no longer needed. You can make a script that monitors spawned objects to check if they can safely be removed without disrupting the player’s experience.
A few ways to implement this include:
- The player is X distance away from the object
- The player hasn’t interacted with the object for X minutes
- The object interaction is complete and sends an event indicating that it can be destroyed
If you find an object should be created and destroyed often, you might consider proactively spawning objects that are hidden in a pool when the world instance is created. You can then request and return objects from this pool when needed - saving you time from spawning/despawning objects and allowing you to plan out your world based on the updated object limit. This optimization is called
Object Pooling and is an implementation that you can add to your world.
Object pool definition example: import {Entity, Vec3} from 'horizon/core';
class PoolItem<T> {
item: T;
inUse: boolean
constructor(item: T) {
this.item = item;
this.inUse = false;
}
_getItem(): T {
return this.item;
}
requestItem(): T {
this.inUse = true;
return this.item;
}
returnItem(): void {
this.inUse = false;
}
isInUse(): boolean {
return this.inUse;
}
}
export class EntityPool {
pool: Array<PoolItem<Entity>>;
maxSize: number;
constructor(maxSize: number = 30) {
this.pool = new Array<PoolItem<Entity>>();
this.maxSize = maxSize;
}
registerItem(item: Entity) {
if(item != undefined) {
this.pool.push(new PoolItem(item));
}
}
requestItem(): Entity\|null {
let result = null;
let itemIdx = this.pool.findIndex((poolItem) => {return poolItem.isInUse() == false;});
if(itemIdx != -1) {
result = this.pool[itemIdx].requestItem();
}
return result;
}
returnItem(item: Entity): void {
let poolIdx = this.pool.findIndex((poolItem) => { return poolItem._getItem().id == item.id; });
if(poolIdx == -1) return;
let poolItem = this.pool[poolIdx];
poolItem.returnItem();
let itemPos = item.position.get();
itemPos = itemPos.add(new Vec3(0, -10, 0));
item.position.set(itemPos);
this.pool[poolIdx] = poolItem;
}
getSize(): number {
return this.pool.length;
}
isFull(): boolean {
return this.pool.length == this.maxSize;
}
printIds() {
this.pool.forEach((poolItem: PoolItem<Entity>) => {
let item = poolItem._getItem();
if(item != null) {
console.log(item.id);
}
});
}
}
import { PropsDefinition } from 'horizon/core';
import { Asset, Component, CodeBlockEvent, Entity, PropTypes, Vec3} from 'horizon/core';
import { EntityPool } from 'ObjectPool';
const spawnTriggerEvent = new CodeBlockEvent<[]>('spawnEvent', []);
const despawnTriggerEvent = new CodeBlockEvent<[]>('despawnEvent', []);
class PoolSpawnManager extends Component<typeof PoolSpawnManager> {
static propsDefinition = {
numObj: {type: PropTypes.Number, default: 10},
assetToSpawn: {type: PropTypes.Asset},
};
objPool: EntityPool = new EntityPool();
objList: Entity[] = new Array<Entity>();
// called on world start
start() {
// Request 10 objects to be spawned when the world is initially loaded
for(let count = 0; count < this.props.numObj; count++) {
this.world.spawnAsset(this.props.assetToSpawn!, this.entity.position.get(), this.entity.rotation.get()).then(spawnedObjects => {
if(this.objPool == null) return;
spawnedObjects.forEach(obj => {
this.objPool.registerItem(obj);
}, this);
});
}
// Handle when the "Spawn" button is pressed
this.connectCodeBlockEvent(this.entity, spawnTriggerEvent, () => {
if(this.objList.length == this.props.numObj \|\| this.objPool == null) return;
for(let idx = 0; idx < this.props.numObj; idx++) {
let obj = this.objPool.requestItem();
if(obj == null) return;
let entityPos = this.entity.position.get();
entityPos = entityPos.add(new Vec3(0, 0, idx));
obj.position.set(entityPos);
this.objList.push(obj);
}
});
// Handle when the "Despawn" button is pressed
this.connectCodeBlockEvent(this.entity, despawnTriggerEvent, () => {
if(this.objList.length == 0 \|\| this.objPool == null) return;
this.objList.forEach((item) => {
this.objPool.returnItem(item);
}, this);
this.objList.splice(0, this.objList.length);
});
}
}
// This tells the UI that your component can be attached to an entity
Component.register(PoolSpawnManager);