Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
# Layers, Groups & Tags
|
||||
|
||||
`oxide_engine::layer` is the engine's shared answer to *"which things interact
|
||||
with which?"*. Instead of every subsystem inventing its own notion of
|
||||
collision groups, render masks, or query filters, they all reference one
|
||||
primitive — the [`LayerMask`] — so a layer named once is honored everywhere.
|
||||
|
||||
This system lands in Stage 5 (Engine Core Framework). The consumers below are
|
||||
wired up in their own stages, but they all build on the types here.
|
||||
|
||||
## The model: one Layer, many Groups
|
||||
|
||||
Oxide follows the **Unity model**, separating two concepts:
|
||||
|
||||
- **[`Layer`]** — *single-valued* membership. Every entity is on exactly **one**
|
||||
of 32 logical layers (its index `0..32`). This is the fast filter slot used by
|
||||
rendering, physics, and queries.
|
||||
- **[`Tags`]** — *multi-valued* gameplay grouping. An entity can be in **any
|
||||
number** of named groups (`"Enemies"`, `"Interactables"`, `"SaveOnExit"`) that
|
||||
game code and scripts look up by name.
|
||||
|
||||
So: *what kind of thing is this, for fast filtering?* → one **Layer**. *What
|
||||
gameplay categories does it belong to?* → many **Groups** (tags). The two never
|
||||
fight over the same slot.
|
||||
|
||||
## `LayerMask`: the filter primitive
|
||||
|
||||
A `LayerMask` is a 32-slot bitset packed into a `u32`. An entity's `Layer` is a
|
||||
single index, but the things that *select* entities carry a **mask** — a
|
||||
camera's visibility, a physics collision filter, a raycast's query mask — so a
|
||||
filter can target several layers at once. An entity is selected when its layer
|
||||
is one of the bits in the filter:
|
||||
|
||||
```rust
|
||||
use oxide_engine::layer::{Layer, LayerMask};
|
||||
|
||||
let entity = Layer::on(2); // membership: layer 2
|
||||
let filter = LayerMask::NONE.with(1).with(2); // a filter selecting 1 or 2
|
||||
assert!(entity.matches(filter)); // layer 2 is in the mask → selected
|
||||
```
|
||||
|
||||
`a.intersects(b)` (equivalently `(a & b) != 0`) is the universal mask
|
||||
interaction test. Everything else (`with`/`without`/`union`/`intersection`/
|
||||
`complement`, the `|`/`&`/`^`/`!` operators, `iter`) is convenience around that.
|
||||
|
||||
Layer indices run `0..32`. Passing an index `>= 32` panics in every build — a
|
||||
mistake worth catching loudly rather than wrapping silently.
|
||||
|
||||
`Layer` is **node-baked**: `Scene::spawn` auto-attaches `Layer::DEFAULT` (index
|
||||
0) to every entity, so a fresh entity is visible to broad "see everything"
|
||||
filters out of the box.
|
||||
|
||||
## Naming layers: `LayerRegistry`
|
||||
|
||||
Bits are not self-documenting, so a project keeps a [`LayerRegistry`] mapping
|
||||
indices to names. Index 0 is seeded as `"Default"`; the rest are unnamed until
|
||||
assigned. The registry is project-level data (serialized with the project in a
|
||||
later stage), and renaming a layer never moves an entity — only the label
|
||||
changes.
|
||||
|
||||
```rust
|
||||
use oxide_engine::layer::LayerRegistry;
|
||||
|
||||
let mut registry = LayerRegistry::new();
|
||||
registry.set(1, "Player");
|
||||
registry.set(2, "NPC");
|
||||
|
||||
// Author a filter by name instead of by magic number:
|
||||
let visible_to_camera = registry.mask_of(["Player", "NPC"]);
|
||||
assert_eq!(registry.index_of("Player"), Some(1));
|
||||
```
|
||||
|
||||
The editor seeds a small, generally-useful starter set — `Default`, `UI`,
|
||||
`Player`, `World` — and exposes the registry through its **Layer Names** editor
|
||||
(opened from the inspector's `Layer` dropdown). Because both a camera's
|
||||
visibility mask and a raycast's filter can be built from the same names, the
|
||||
*named layer* is the single source of truth.
|
||||
|
||||
## Gameplay grouping: `Tags` + `GroupRegistry`
|
||||
|
||||
[`Tags`] is the per-entity, string-keyed set that holds an entity's **group**
|
||||
membership — the multi-valued counterpart to its single `Layer`:
|
||||
|
||||
```rust
|
||||
use oxide_engine::layer::Tags;
|
||||
|
||||
let mut tags = Tags::single("Enemy");
|
||||
tags.insert("Flying");
|
||||
assert!(tags.contains("Enemy"));
|
||||
```
|
||||
|
||||
The set of *valid* group names is project-level data in a [`GroupRegistry`], so
|
||||
the editor offers a fixed vocabulary to pick from (predefined, like layers)
|
||||
rather than free-typed strings. Defining or deleting a group only changes that
|
||||
vocabulary — it never touches the tags already on entities:
|
||||
|
||||
```rust
|
||||
use oxide_engine::layer::GroupRegistry;
|
||||
|
||||
let mut groups = GroupRegistry::new();
|
||||
groups.define("Enemies");
|
||||
groups.define("Interactables");
|
||||
assert!(groups.contains("Enemies"));
|
||||
```
|
||||
|
||||
In the editor, the inspector's **Groups** dropdown is a multi-select of the
|
||||
defined groups (each a checkbox toggling membership in the entity's `Tags`), with
|
||||
an **Edit groups…** entry opening the Groups editor to manage the vocabulary.
|
||||
|
||||
Use a **layer** when something must filter quickly and en masse (rendering,
|
||||
physics, queries). Use a **group** when you need to ask "what gameplay
|
||||
categories is this in?" and an entity may be in several at once.
|
||||
|
||||
## Attaching to entities
|
||||
|
||||
`Layer` is auto-attached; `Tags` is an ordinary ECS component — attach it through
|
||||
the scene's world and query it like anything else:
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::layer::{LayerMask, Layer, Tags};
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let guard = scene.spawn("guard", Transform::IDENTITY); // Layer::DEFAULT auto-attached
|
||||
scene.world_mut().insert_one(guard, Layer::on(2)).unwrap(); // move to layer 2
|
||||
scene.world_mut().insert_one(guard, Tags::single("Enemy")).unwrap();
|
||||
|
||||
// Find everything a layer-2 query would hit:
|
||||
let filter = LayerMask::layer(2);
|
||||
for (entity, layer) in scene.world().query::<&Layer>().iter() {
|
||||
if layer.matches(filter) {
|
||||
// ... this entity is selected
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All five types (`LayerMask`, `LayerRegistry`, `Layer`, `Tags`, `GroupRegistry`)
|
||||
are `serde`-serializable, so layer/group data round-trips through RON and is
|
||||
dual-editable from the editor and from scripts/AI agents like every other engine
|
||||
component.
|
||||
|
||||
## Who consumes this
|
||||
|
||||
| Consumer | Stage | How it uses layers |
|
||||
|----------|-------|--------------------|
|
||||
| Render pass pipeline | 5 | A camera holds a visibility `LayerMask`; only entities whose `Layer` is in it are drawn |
|
||||
| Physics | 9 | A collider's membership + filter masks drive collision groups and sensor/trigger filtering |
|
||||
| Scene queries | 9 | A raycast/shape-cast carries a filter mask tested against candidates' layer |
|
||||
| Editor | 6+ | Single-select `Layer` dropdown + Layer Names editor; multi-select `Groups` dropdown + Groups editor |
|
||||
|
||||
[`LayerMask`]: ../engine/src/layer/mask.rs
|
||||
[`LayerRegistry`]: ../engine/src/layer/registry.rs
|
||||
[`Layer`]: ../engine/src/layer/components.rs
|
||||
[`Tags`]: ../engine/src/layer/components.rs
|
||||
[`GroupRegistry`]: ../engine/src/layer/groups.rs
|
||||
Reference in New Issue
Block a user