-
Notifications
You must be signed in to change notification settings - Fork 10
trigger areas #290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nearnshaw
wants to merge
12
commits into
main
Choose a base branch
from
trigger-areas
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
trigger areas #290
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
67b4708
trigger areas
nearnshaw 54f0b58
Merge branch 'main' into trigger-areas
nearnshaw f21bf81
rewrite trigger areas
nearnshaw bcadccb
retouches based on feedback
nearnshaw ae779ab
Update content/ADR-258-trigger-areas.md
nearnshaw 2b30a5e
Adjustments from feedback
nearnshaw 60eef72
Update content/ADR-258-trigger-areas.md
nearnshaw 58df642
Update content/ADR-258-trigger-areas.md
nearnshaw 6797b19
Update content/ADR-258-trigger-areas.md
nearnshaw be3b6c5
Update content/ADR-258-trigger-areas.md
nearnshaw e448293
Update content/ADR-258-trigger-areas.md
nearnshaw cd30f09
chore: updated to reflect refactor to avoid OnStay CRDT spamming from…
pravusjif File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| --- | ||
| layout: adr | ||
| adr: 258 | ||
| title: Trigger Areas | ||
| date: 2025-02-19 | ||
| status: Draft | ||
| type: RFC | ||
| spdx-license: CC0-1.0 | ||
| authors: | ||
| - nearnshaw | ||
| --- | ||
|
|
||
| ## Abstract | ||
|
|
||
| This document describes an approach for implementing native trigger areas in creator scenes. Triggers are a fundamental feature of game development that should be available as a core engine capability rather than requiring external dependencies. Currently, creators must rely heavily on the Utils library for this essential functionality, which presents several significant limitations: | ||
|
|
||
| - **Performance degradation**: The library implementation results in bad performance and slower SDK ticks due to its non-native approach | ||
| - **Shape constraints**: Triggers are restricted to box colliders only, limiting creative possibilities | ||
| - **Developer experience**: The current approach is not easy on the creator and requires additional library dependencies | ||
| - **Architectural limitations**: As a library solution, it cannot leverage engine-level optimizations or native collision detection | ||
|
|
||
| The native engine implementation addresses these issues by providing: | ||
| - Better performance through engine-level integration with the existing collision system | ||
| - Support for any arbitrary shape from 3D models using mesh colliders, not just primitive shapes | ||
| - Seamless integration with existing components like MeshCollider and ColliderLayer | ||
| - A friendlier developer experience through helper functions similar to our pointerEvents system | ||
| - Native support for multiple trigger layers and collision detection | ||
|
|
||
| This new approach enables creators to define trigger areas using any collider shape - from simple primitives to complex meshes from GLTF models - and react to enter, exit, and stay events when entities on specific collision layers overlap with them. | ||
|
|
||
| ## Trigger areas | ||
|
|
||
| Trigger areas are a region in the scene that trigger an action whenever something overlaps with them (usually the player, but not necessarily). We can also trigger actions continuously while something keeps overlapping (on each frame), or when it stops overlapping. | ||
|
|
||
| The shape of a trigger area is given by a collider. It can use a simple primitive shape, or it can even take any arbitrary shape from a 3D model. | ||
|
|
||
| We will create a new component for this, called `TriggerArea`. It will have the following fields: | ||
|
|
||
| - `mesh`: An enum, that allows to define the shape of the trigger area. `TAMT_BOX` (default) or `TAMT_SPHERE` | ||
| - `collisionMask`: The collision layer mask that triggers the trigger | ||
|
|
||
|
|
||
| ### Collision layers | ||
|
|
||
| Trigger areas can only be triggered by entities on certain _collision layers_. We should reuse the `ColliderLayer` enum for this. | ||
|
|
||
| Most of the time you want to check for just the position of the player, so we should have a collision layer dedicated to this, and it should be the default of any Trigger component. This new layer should be added to the `ColliderLayer` enum. | ||
|
|
||
| The `ColliderLayer` enum should have the following values: | ||
|
|
||
| - `CL_NONE`: No layer, the trigger area will not trigger any events | ||
| - `CL_POINTER`: The default layer for any pointer events | ||
| - `CL_PHYSICS`: The default layer for all entities with a physics collider | ||
| - `CL_CUSTOM1`: A custom layer for any other object that can move around the scene | ||
| - `CL_CUSTOM2`: A custom layer for any other object that can move around the scene | ||
| - `CL_CUSTOM3`: A custom layer for any other object that can move around the scene | ||
| - `CL_CUSTOM4`: A custom layer for any other object that can move around the scene | ||
| - `CL_CUSTOM5`: A custom layer for any other object that can move around the scene | ||
| - `CL_CUSTOM6`: A custom layer for any other object that can move around the scene | ||
| - `CL_CUSTOM7`: A custom layer for any other object that can move around the scene | ||
| - `CL_CUSTOM8`: A custom layer for any other object that can move around the scene | ||
| - `CL_PLAYER`: The default layer for the player | ||
|
|
||
| A single trigger area can have multiple trigger layers at once, similarly to how collision layers work on colliders. | ||
|
|
||
| When using the `POINTER` layer, the trigger area will trigger events when the pointer is hovered over it, in the same way that ON_HOVER events from the PointerEvents component work. | ||
|
|
||
| When using the `PHYSICS` layer, the trigger area will trigger events when any entity with a physics collider overlaps with it. | ||
|
|
||
| Note: If a collider with the same shape blocks a player from entering a trigger area, then we won't consider a trigger event being sent. There must be an overlap with the area, not just contact. For example, if a same entity has both a MeshCollider component set to the layer `CL_PHYSICS` and a Trigger component set to the layer `CL_PLAYER`, and both meshes have the same shape, then the player will never be able to activate a trigger event from this entity. In the future we'll likely want to do _Collision Events_ as a separate feature. | ||
|
|
||
| ### Trigger events | ||
|
|
||
| Trigger areas can trigger events when the player (or any other entity on the trigger layer) enters, exits or stays in the area. | ||
|
|
||
| Trigger events are shared from the engine to the SDK via a component, following a similar approach as we do with pointer events and raycasts. We define a `TriggerAreaResult` component for this purpose. Creators are not expected to read values or make use of this component directly, unless they really want to fine tune their scene’s behavior — the `triggerAreaEventsSystem` helpers (see _Code helpers_ below) are the supported entry point. | ||
|
|
||
| The `TriggerAreaResult` component is grow-only (GOVS): every event is appended as a discrete entry rather than overwriting the previous one. Each entry has the following fields: | ||
|
|
||
| - `triggeredEntity`: The entity that was triggered (this is the entity that owns the trigger area) | ||
| - `triggeredEntityPosition`: The position of the triggered entity at the time of the trigger. | ||
| - `triggeredEntityRotation`: The rotation of the triggered entity at the time of the trigger. | ||
| - `eventType`: The state of the trigger event (`TAET_ENTER`, `TAET_EXIT`, `TAET_STAY`) | ||
| - `timestamp`: The timestamp of the trigger event | ||
| - `trigger`: An object with the following fields: | ||
| - `entity`: The entity that triggered the trigger | ||
| - `layers`: The collision layermask of the entity that triggered the Trigger Area | ||
| - `position`: The position of the entity that triggered the trigger | ||
| - `rotation`: The rotation of the entity that triggered the trigger | ||
| - `scale`: The scale of the entity that triggered the trigger | ||
|
|
||
| #### Emission contract: ENTER/EXIT on the wire, STAY synthesized by the SDK | ||
|
|
||
| The three event types are split across two layers: | ||
|
|
||
| - **`TAET_ENTER` and `TAET_EXIT`** are emitted by the engine (Explorer) as discrete state transitions: one entry per (trigger area, triggerer) pair when the triggerer crosses into the area, and one when it crosses out. These are the only event types that travel over the CRDT wire. Explorer implementations **MUST NOT** emit `TAET_STAY` entries — emitting them every frame floods the CRDT channel and overflows the GOVS buffer, which is capped at a small number of entries. | ||
|
|
||
| - **`TAET_STAY`** is the responsibility of the SDK runtime. The SDK's `triggerAreaEventsSystem` maintains a per-trigger-area set of "currently inside" triggerers, driven entirely by the wire `TAET_ENTER` and `TAET_EXIT` events: on `TAET_ENTER` the triggerer is added to the set, on `TAET_EXIT` it is removed. On every tick, for each triggerer still in the set, the SDK synthesizes a `PBTriggerAreaResult` with `eventType = TAET_STAY` and dispatches it to any `onTriggerStay` callback registered for the trigger area. Synthesized payloads refresh `position`, `rotation` and `scale` from the live `Transform` component of the trigger area and triggerer entities when those are scene-owned; for triggerers without a scene-side `Transform` (e.g. the reserved player-avatar entity) the synthesized payload falls back to the cached values from the most recent `TAET_ENTER`. | ||
|
|
||
| This split means scene developers see no behavioral difference: an `onTriggerStay` callback continues to fire every frame while a triggerer remains inside, with `result.eventType === TriggerAreaEventType.TAET_STAY` on every call. Only the wire footprint changes: at 60 fps with N triggerers inside, traffic drops from O(60·N) `TriggerAreaResult` appends per second to O(1) — exactly one append on enter and one on exit per (trigger area, triggerer) session. | ||
|
|
||
| For backward compatibility, SDK runtimes **MUST** silently ignore any `TAET_STAY` entries that arrive on the wire from legacy Explorer implementations that have not yet adopted this contract. The synthesized per-tick dispatch is the single source of truth; wire `TAET_STAY` events must not be forwarded to `onTriggerStay` callbacks, to avoid double-firing. | ||
|
|
||
| ### Ensure detection | ||
|
|
||
| There's a possible scenario where an entity may be moving so fast that it crosses through a trigger area without spending a frame within the trigger area. We should detect when this is the case and activate a trigger event in this scenario too. | ||
|
|
||
| We can make reasonable asssumptions to cover this scenario only if the moving object is moving via a Tween. If the moving object is being moved by a system in the scene frame by frame, we can't easily infer from the engine that a smooth movement is taking place. For those cases, the engine only knows of distinct positions on each frame, and we should evaluate triggers frame by frame. | ||
|
|
||
| ### Code helpers | ||
|
|
||
| Even though at a low-level data travels in an ECS way, we should offer a friendlier way to deal with this for creators. This is the same approach we already use with pointer events and raycasts. | ||
|
|
||
| We’ll create a system and helper functions for reacting to a trigger events from a trigger area, similar to our [pointerEvents](https://docs.decentraland.org/creator/development-guide/sdk7/click-events/) system | ||
|
|
||
| - `onTriggerEnter` | ||
| - `onTriggerExit` | ||
| - `onTriggerStay` | ||
|
|
||
| It will look something like this: | ||
|
|
||
| ```ts | ||
| TriggerArea.setBox(myTrigger) | ||
| Transform.create(myTrigger, { | ||
| position: Vector3.create(8, 0, 8), | ||
| scale: Vector3.create(1, 1, 1), | ||
| }) | ||
| triggerAreaEventsSystem.onTriggerEnter(myTrigger, function(result) { | ||
| // Do whatever I want | ||
| }) | ||
|
|
||
| ## Triggers embedded in 3D models | ||
|
|
||
|
|
||
| It should also be possible to create trigger areas embedded into 3D models. If a mesh in a 3D model has a name that contains the string `_trigger`, it will be considered a trigger area. | ||
|
|
||
|
|
||
|
|
||
| ## Serialization | ||
|
|
||
| ```yaml | ||
|
|
||
| ``` | ||
|
|
||
| ```protobuf | ||
|
|
||
| ``` | ||
|
|
||
| ## Semantics | ||
|
|
||
| ### Example | ||
|
|
||
| Low level: | ||
|
|
||
| ```ts | ||
| function TriggerReadingSystem() { | ||
| const triggeredEntities = engine.getEntitiesWith(TriggerCollisionResult) | ||
| for (const [entity] of triggeredEntities) { | ||
|
|
||
| const result = TriggerCollisionResult.getOrNull(entity) | ||
| if(result){ | ||
| console.log("TRIGGER EVENT DATA:", result.commands) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| engine.addSystem(TriggerReadingSystem) | ||
| ``` | ||
|
|
||
| High level: | ||
|
|
||
| _Option 1: Using the TriggerArea component_ | ||
| ```ts | ||
| const myTrigger = engine.addEntity() | ||
|
|
||
| MeshCollider.setBox(myTrigger, {collisionMask: ColliderLayer.CL_PHYSICS}) | ||
|
|
||
| TriggerArea.setBox(myTrigger, {layer:TriggerLayer.TL_PLAYER }) | ||
|
|
||
| Tramsform.create(myTrigger) | ||
| ``` | ||
|
|
||
| _Option 2: Using the triggerEventsSystem_ | ||
| ```ts | ||
| const myTrigger = engine.addEntity() | ||
|
|
||
| MeshCollider.setBox(myTrigger, {collisionMask: ColliderLayer.CL_PHYSICS}) | ||
|
|
||
| Tramsform.create(myTrigger) | ||
| triggerEventsSystem.OnTriggerEnter( | ||
| { | ||
| entity: myTrigger, | ||
| opts: { | ||
| layer: Player | ||
| } | ||
| }, | ||
| function (otherEntity) { | ||
| // Do whatever I want | ||
| } | ||
| ) | ||
| ``` | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i suggested using a transform directly here, but i'm not sure we can actually do that since we don't have a PbTransform message type...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same would apply to the fields
triggeredEntityPositionandtriggeredEntityRotationthat I just addedIf we change the 3 above fields for a Transform, then we should do the same with the triggered entity