From c12487cd5304a4c9ac8b1f71923341fe26b481a6 Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Sun, 17 Jul 2022 20:30:01 +0900 Subject: [PATCH 01/17] Editor data model RFC --- rfcs/301-editor-data-model.md | 452 ++++++++++++++++++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 rfcs/301-editor-data-model.md diff --git a/rfcs/301-editor-data-model.md b/rfcs/301-editor-data-model.md new file mode 100644 index 00000000..3ec236ff --- /dev/null +++ b/rfcs/301-editor-data-model.md @@ -0,0 +1,452 @@ +# Feature Name: `editor_data_model` + +## Summary + +This RFC describes the data model and API that the Editor uses in memory, and the basis for communicating with the Game process and for serializing that data to disk (although the serialization format to disk is outside of the scope of this RFC). The data model is largely inspired by the one of [Our Machinery](https://ourmachinery.com/), as described in [this blog post](https://ourmachinery.com/post/the-story-behind-the-truth-designing-a-data-model/). + +## Glossary + +In this RFC, the following terms are used: + +- **Game** refers to any application made with Bevy, which encapsulates proper games but also any other kind of Bevy application (CAD, architecture software, _etc._). +- **Author** refers to any person (including non-technical) building the Game with the intent of shipping it to end users. +- **Editor** refers to the software used to edit Game data and prepare them for runtime consumption, and which this RFC describes the data model of. +- **Runtime** refers to the Game or Editor process running time, as opposed to compiling or building time. When not otherwise specified, this refers to the Game's runtime. In general it's synonymouns of a process running, and is opposed to _build time_ or _compile time_. + +## Motivation + +We need a data model for the Bevy Editor. We want to make it decoupled from the runtime data model that the Game uses, which is based on compiled Rust types (`World`, _etc._) with a defined native layout from a fixed Rust compiler version (as most types don't use `#[repr(C)]`), so we can handle data migration across Rust compiler versions and therefore across both Editor and Game versions. We also want to make it _transportable_, that is serializable with the intent to transport it over any IPC mechanism, so that the Editor process can dialog with the Game process to enable real-time Game editing ("Play Mode"). + +## User-facing explanation + +The _data model_ of the Editor refers to the way the Editor manipulates editing data, their representation in memory while the Editor executable is running, and the API to do those manipulations. The Editor at its core defines a centralized data model, and all systems use the same API to access that data model, making it the unique source of truth while the Editor process is running. This unicity prevents desynchronization between various subsystems, since they share a same view of the editing data. It also enables global support for common operations such as copy/paste and undo/redo without requiring each subsystem to implement their own variant. + +A Bevy application (the Game) uses Rust types defined in one of the `bevy_*` crates, any other third-party plugin library, or the Game itself (_custom types_). The data is represented in memory at runtime by _instances_ (allocated objects) of those Rust types. This representation is optimal in terms of read and write access, but is _static_, that is the type layout is defined during compiling and cannot be changed afterward. + +In contrast, the Editor needs to support type changes to allow hot-reloading custom components from the Game, in order to enable fast iteration for the Authors. To enable this, the Editor data model is based on _dynamic types_ defined exclusively during Editor execution. The data model defines an API to instantiate objects from dynamic types, manipulates the data of those instances, but also modify the types themselves while objects are instantiated. This latter case is made possible through a set of migration rules allowing to transform an object instance from a version of a type to another modified version of that type. + +The general workflow is as follows: + +- The Author starts the Editor executable and create a new project or loads an existing project. +- The Editor launches the Game executable in _edit mode_, whereby the `EditorClientPlugin` is enabled and allows the Editor and the Game to communicate in real time. +- The Editor queries the Game for all its custom types, including components and resources. +- The Editor follows the same process for any third-party library, to enable extending Bevy with new functionalities. +- The Editor builds a database of all (dynamic) types, merging the built-in Bevy types (the ones defined in any `bevy_*` official crate), any third-party library, and the Game. +- The Author creates new object instances from any of those types, and manipulates the data of those instances by setting the value of the properties exposed by the type of the object. +- Optionally, the Author saves the project, which makes the Editor serialize the data model to disk for later reloading. + +The data model supports various intrinsic types: boolean, integral, floating-point, strings, arrays, dictionaries, enums and flags, and objects (references to other types). Objects can be references to entities or to components. Dynamic types extend those by composition; they define a series of _properties_, defined by their name and a type themselves. Conceptually, a property can look like: + +```txt +type "MyPlayerComponent" { + property "name" : string + property "health": float32 + property "target": entity + property "weapon": type "MyWeaponComponent" +} +``` + +The data model offers an API to manipulate object instances: + +- get the type of an object instance +- get and set property values of a type instance +- lock an object for exclusive write access; this allows making a set of writes transactional (see below for details) + +This API is most commonly used to build a User Interface (UI) to allow the Authors to edit the data via the Editor itself. It is also used by Editor systems to manipulate object instances where needed. + +The data model also offers an API to manipulate the types themselves: + +- get a type's name +- enumerate the properties of a type +- add a new property +- remove an existing property + +This API is most commonly used when reloading the Game after editing its code and rebuilding it, to update the Editor's dynamic types associated with the custom Game types. + +The data model guarantees the integrity of its data by allowing concurrent reading of data but ensuring exclusive write access. This allows concurrent use of the data, for example for remote collaboration, so long as write accesses to objects are disjoints. Each object for which write access was given is locked for any other access. This is similar in concept to the Rust borrow model, and to that respect the Editor data model plays the role of the Rust borrow checker. + + + +## Implementation strategy + +### Names + +Being data-heavy, and forfeiting the structural access of types, the data model makes extensive use of strings to reference items. In order to make those operations efficient, we define a `Name` type to represent those strings. The implementation of `Name` is outside of the scope of this RFC and left as an optimization, and should be considered as equivalent to a `String` wrapper: + +```rust +struct Name(pub String); +// -or- +type Name = String; +``` + +However the intent is to explore some optimization strategies like string interning for fast comparison. Those optimizations are left out of the scope of this RFC. + +### Built-in types + +The data model supports a number of built-in types, corresponding to common Rust built-in types. + +```rust +enum SignedIntegralType { + Int8, Int16, Int32, Int64, Int128, +} + +enum UnsignedIntegralType { + Uint8, Uint16, Uint32, Uint64, Uint128, +} + +enum FloatingType { + Float32, Float64, +} + +enum SimpleType { + Bool, + Int8, Int16, Int32, Int64, Int128, + Uint8, Uint16, Uint32, Uint64, Uint128, + Float32, Float64, +} + +enum BuiltInType { + Bool, + Int8, Int16, Int32, Int64, Int128, + Uint8, Uint16, Uint32, Uint64, Uint128, + Float32, Float64, + Enum(UnsignedIntegralType), // C-style enum, not Rust-style + String, + Array(BuiltInType), + Dict, + ObjectRef, +} +``` + +_Note_: We explicitly handle all integral and float bit variants to ensure tight value packing and avoid wasting space both in objects (`DataInstance`; see below) and collections (arrays, _etc._). + +### Data world + +The data model uses dynamic types, so it can be represented purely with data, without any build-time types. Bevy already has an ideal storage for data: the `World` ECS container. This RFC introduces a new struct to represent the data model, the `DataWorld`: + +```rust +struct DataWorld(pub World); +``` + +Since the underlying `World` already offers guarantees about concurrent mutability prevention, via the Rust language guarantees themselves, those guarantees transfer to the `DataWorld` allowing safe concurrent read-only access or exclusive write access to all data of the data model. + +### Data types + +The data world is populated with the dynamic types coming from the various sources described in [User-facing explanation](#user-facing-explanation). + +This RFC introduces a `DataType` component that stores the description of a single dynamic type. We use the name `DataType` to hint at the link with the `DataWorld` and avoid any confusion with any pre-existing dynamic object from the `bevy_reflect` crate (_e.g._ `DynamicScene` or `DynamicEntity`). + +```rust +#[derive(Component)] +struct DataType { + name: Name, + properties: Vec, + instances: Vec, +} +``` + +The data type contains an array of entities defining all the object instances of this type for easy reference, since this information is not encoded in any Rust type (see [Data instances](#data-instances)) so cannot be queried via the usual `Query` mechanism. + +Some marker components are also used on the same `Entity` holding the `DataType` itself, to declare the origin of the type: + +```rust +// Built-in type compiled from the Bevy version the Game is built with. +#[derive(Component)] +struct BevyType; + +// Type from the Game of the currently open project. +#[derive(Component)] +struct GameType; +``` + +_Note that the Editor itself may depend on a different Bevy version. The built-in Bevy types of that version are not present in the `DataWorld`; only types from the Game are._ + +This strategy allows efficiently querying for groups of types, in particular to update them when the Game or a library is reloaded. + +```rust +fn update_game_type_instances( + mut query: Query< + &DataType, + (With, Changed) + >) { + for data_type in query.iter_mut() { + // apply migration rules to all instances of this type + for instance in &mut data_type.instances { + // [...] + } + } +} +``` + +Properties are defined by a name and the type of their value. The type also stores the byte offset of that property from the beginning of its object, which allows packing property values for an object into a single byte stream. Modifying any of the `name`, `value_type`, or `byte_offset` constitute a type change, which mandates migrating all existing instances. + +```rust +struct Property { + name: Name, + value_type: Entity, // with DataType component + byte_offset: u32, + validate: Option>, +} +``` + +The _type layout_ refers to the collection of property offsets and types of a `DataType`, which together define: + +- the order in which the properties are listed in the object type, and any instance value stored. +- the size of each property value, based on its type. +- eventual gaps between properties, known as _padding bytes_, depending on their offset and size. + +Unlike Rust, the Editor data model guarantees that **padding bytes are zeroed**. This property enables efficient instance comparison via `memcmp()`-like byte-level comparison, without the need to rely on individual properties inspection nor any equality operator (`PartialEq` and `Eq` in Rust). + +The `Property` struct contains also an optional validation function used to validate the value for things like bounds check, nullability, _etc._ which is used both when the user (via the Editor UI) attempts to set a value, and when a value is set automatically (_e.g._ during deserialization). + +```rust +trait Validate { + /// Check if a value is valid for the property. + fn is_valid(&self, property: &Property, value: &Value) -> bool; + + /// Try to assign a value to a property of an instance. + fn try_set(&mut self, instance: &mut DataInstance, property: &Property, value: &[u8]) -> bool; +} +``` + +### Sub-types + +Various kinds of types have additional data attached to a separate component specific to that type. This is referred to as _sub-types_. + +The sub-types component allow, via the `Query` mechanism, to batch-handle all types of a certain kind, like listing all enum types. + +```rust +fn list_enum_types(query: Query<&DataType, With>) { + for data_type in query.iter() { + // [...] + } +} +``` + +#### Enums and flags + +Enums are sets of named integral values called _entries_. An enum has an underlying integral type called its _storage type_ containing the actual integral value of the enum. The storage type can be any integral type, signed or unsigned, with at most 64 bits (so, exclusing `Int128` and `Uint128`). The enum type defines a mapping between that value and its name. An enum value is stored as its storage type, and is equal to exactly one of the enum entries. + +Flags are like enums, except the value can be any bitwise combination of entries. The storage type is always an unsigned integral type. + +The `EnumEntry` defines a single enum or flags entry with a `name` and an associated integral `value`. The value is encoded in the storage type, then stored in the 8-byte memory space offered by `value`. This means reading and writing the value generally involves reinterpreting the bit pattern. + +```rust +struct EnumEntry { + name: Name, + value: u64, +} +``` + +The `EnumType` component defines the set of entries of an enum or flags, the storage type, and a boolean indicating whether the type is an enum or a flags. + +```rust +#[derive(Component)] +struct EnumType { + entries: Vec, + storage_type: IntegralType, + is_flags: bool, +} +``` + +#### Arrays + +Arrays are dynamically-sized homogenous collections of _elements_. The `ArrayType` defines the element type, which can be any valid data type. + +```rust +#[derive(Component)] +struct ArrayType { + elem_type: Entity, // with DataType component +} +``` + +#### Dictionaries + +TODO... + +Dictionaries are dynamically-sized homogenous collections mapping _keys_ to _values_, where each key is unique in the instance. The `DictType` defines the key and value types. + +```rust +#[derive(Component)] +struct DictType { + key_type: Entity, // with DataType component +} +``` + +### Data instances + +Object instances are similarly represented by instances of the `DataInstance` component: + +```rust +#[derive(Component)] +struct DataInstance { + data_type: Entity, // with DataType component + values: Vec, +} +``` + +The type of an instance is stored as the `Entity` holding the `DataType`, which can be accessed with the usual `Query` mechanisms: + +```rust +fn get_data_instance_type( + q_instance: Query<&DataInstance, [...]>, + q_types: Query<&DataType>) +{ + for data_inst in q_instances.iter() { + let data_type = q_types + .get_component::(&data_inst.data_type).unwrap(); + // [...] + } +} +``` + +The property values are stored in a raw byte array, at the property's offset from the beginning of the array. The size of each property value is implicit, based on its type. Any extra byte (padding byte) in `DataInstance.values` is set to zero. + +_Note: we rely on the Rust compiler to reorder fields and avoid padding in the Game process, so we expect minimal padding bytes, and do not attempt to further tightly pack the values, for the sake of simplicity._ + +### Migration rules + +The _migration rules_ are a set of rules applied when transforming a `DataInstance` from one `DataType` to another `DataType`. The rules define if such conversion can successfully take place, and what is its result. + +Given a setup where a migration of a `src_inst: DataInstance` is to be performed from a `src_type: DataType` to a `dst_type: DataType` to produce a new `dst_inst: DataInstance`, the migration rules are: + +1. If `src_type == dst_type`, then set `dst_inst = src_inst`. +2. Otherwise, create a new empty `dst_inst` and then for each property present in either `src_type` or `dst_type`: + - If the property is present on `src_type` only, ignore that property (so it will not be present in `dst_inst`). + - If the property is present on `dst_type` only, add a new property with a default-initialized value to `dst_inst`. + - If the property is present on both, then apply the migration rule based on the type of the property. +3. A value migrated from `src_type` to `dst_type == src_type` is unmodified. +4. A value is migrated from `src_type` to `dst_type != src_type` by attempting to coerce the value into `dst_type`: + - For the purpose of coercion rules, boolean values act like an integral type with a value of 0 or 1. + - Any integral or floating-point value (numeric types) is coerced to the destination property type according to the Rust coercion rules, with possibly some loss. This means there's no guarantee of round-trip. + - Numeric types are coerced into `String` by applying a conversion equivalent to `format!()`. + - String types are coerced into numeric types via the `ToString` trait. + - Enums are coerced into their underlying unsigned integral type first, then any integral coercion applies. + - Integral types can be coerced into an enum provided an enum variant with the same value exists and the integral type can be coerced into the underlying unsigned integral type of the enum. + - A single value is coerced into a single-element array. + - An array of values is coerced into a single value by retaining the first element of the array, if and only if this element exists and has a type that can be coerced into the destination type. + - A single value is coerced into a dictionary with a key corresponding to the coercion to `String` of its value, and a value corresponding to the coercion to the dictionary value type. If any of these fail, the entire coercion to dictionary fails. + - A dictionary with a single entry is coerced into a value by extracting the single-entry value and coercing it, discarding the entry key. If the dictionary is empty, or has more than 1 entry, the coercion fails. + - Coercions from and to `ObjectRef` are invalid. + +_Note: the migration rules are written in terms of two distinct `src_inst` and `dst_inst` objects for clarity, but the implementation is free to do an in-place modification to prevent an allocation, provided the result is equivalent to migrating to a new instance then replacing the source instance with it._ + + + +## Drawbacks + + + +### Abstraction + +The data model adds a layer of abstraction over the "native" data the Game uses, which makes most operations indirected and more abstract. For example, setting a value involves invoking a setter method, possibly after locking an object for write access, as opposed to simply assigning the value to the object (`c.x = 5;`). + +## Rationale and alternatives + + + +## Prior art + +### Unity3D + +The editor of the [Unity3D](https://unity.com/) game engine (or "Unity" for short) is closed-source software, so we can only speculate on how its data model works. + +Some hints (C# attributes mainly) tell us that built-in components are implemented in native C++ while others built-in and third-party ones are implemented in C#. For the native part, we have little information, so we focus here on describing the C# handling. + +For C# components, the editor embeds a custom .NET runtime host based on Mono, which allows it to load any user assembly and extract from it metadata about the user types, and in particular user components. This enables the editor to display in its Inspector window the user components with editing UI widgets to edit component instances. + +Data is serialized and saved as YAML files. Although this process is outside of the scope of this RFC, we can guess from it that the Editor mainly operates on _dynamic types_, and type instances are likely blobs of binary data associated with the fields of those dynamic types. This guess is reinforced by the fact that: + +- the editor is known to be written in C++ and the user code is C# so even if the editor instantiated .NET objects within its Mono runtime it would have to access them from C++ so would need a layer of indirection; +- the editor hot-reloads assemblies on change, so is unlikely to have concrete instantiation of those types, even inside its .NET runtime host, otherwise it would have to pay the cost of managing those instances (destroy and recreate on reload) which is likely slow; +- the on-disk serialized format (YAML) shows types and fields referenced by name and by UUID to the file where the type is defined, but does not otherwise contain any more type information; +- the immediate-mode editor UI customization API has some `SerializedProperty` type with generic getter/setter functions for all common data types (bool, string, float, _etc._), and that interface gives access to most (all?) instances of the data model via a unified data-oriented API. + +Unity notably supports hot-reloading user code (C# assemblies) during editing and even while the Game is playing in Play Mode inside the Editor. It also supports editing on the fly data while the Game is running in that mode, via its Inspector window. This is often noted as a useful features, and hints again at a data-oriented model. + +## Our Machinery + +[Our Machinery](https://ourmachinery.com/) has a few interesting blog posts about the architecture of the engine. Notably, a very detailed and insightful blog post about their data model. + + + +The data model is heavily data-oriented using dynamic types: + +> The Truth is our toungue-in-cheek name for a centralized system that stores the application data. It is based around IDs, objects, types and properties. An object in the system is identified by a uint64_t ID. Each object has a set of properties (bools, ints, floats, strings, …) based on its type. + +The data model explicitly handles _prefabs_ as well as change notifications. + +> In addition to basic data storage, The Truth has other features too, such as sub-objects (objects owned by other objects), references (between objects), prototypes (or “prefabs” — objects acting as templates for other objects) and change notifications. + +A core goal is atomicity of locking for multi-threaded data access; writing only locks the object being written, and other objects can continue being accessed in parallel for read operations. + +They list the following things the data model helps with: + +- Dependency tracking via object references and IDs. +- Unified copy/paste for all systems, since all systems read/write through this central data model hub ("The Truth"). +- Unified undo/redo for the same reason. +- Real-time collaboration since it's pure data and can be serialized easily, so can be networked. +- Diffs/merges easily with VCS. + +They also list some bad experiences with the Bitsquid data model (a predecessor engine from the same devs), which led them to design their current centralized in-memory data model: + +- disk-based data model like JSON files can't represent undo/redo and copy/paste, leading to duplicated implementations for each system. +- string-based object references are not semantically different from other strings in a JSON-like format, so cannot be reasoned about without semantic context, and internal references inside the same file are ill-defined. +- file-based models play poorly with real-time collaboration. + + + + + +## Unresolved questions + + + +## \[Optional\] Future possibilities + + From e0013c20e3d6829b63175fb9f4e5989e6d42298b Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Wed, 20 Jul 2022 02:26:17 +0900 Subject: [PATCH 02/17] Add object sub-type --- rfcs/301-editor-data-model.md | 119 +++++++++++++++++++++------------- 1 file changed, 75 insertions(+), 44 deletions(-) diff --git a/rfcs/301-editor-data-model.md b/rfcs/301-editor-data-model.md index 3ec236ff..b3d7f653 100644 --- a/rfcs/301-editor-data-model.md +++ b/rfcs/301-editor-data-model.md @@ -77,7 +77,7 @@ The data model guarantees the integrity of its data by allowing concurrent readi ### Names -Being data-heavy, and forfeiting the structural access of types, the data model makes extensive use of strings to reference items. In order to make those operations efficient, we define a `Name` type to represent those strings. The implementation of `Name` is outside of the scope of this RFC and left as an optimization, and should be considered as equivalent to a `String` wrapper: +Being data-heavy, and forfeiting the structural access of Rust types, the data model makes extensive use of strings to reference items. In order to make those operations efficient, we define a `Name` type to represent those strings. The implementation of `Name` is outside of the scope of this RFC and left as an optimization, and should be considered as equivalent to a `String` wrapper: ```rust struct Name(pub String); @@ -85,7 +85,7 @@ struct Name(pub String); type Name = String; ``` -However the intent is to explore some optimization strategies like string interning for fast comparison. Those optimizations are left out of the scope of this RFC. +The intent is to explore some optimization strategies like string interning for fast comparison. Those optimizations are left out of the scope of this RFC. ### Built-in types @@ -136,6 +136,8 @@ struct DataWorld(pub World); Since the underlying `World` already offers guarantees about concurrent mutability prevention, via the Rust language guarantees themselves, those guarantees transfer to the `DataWorld` allowing safe concurrent read-only access or exclusive write access to all data of the data model. +**TODO - Explain where the `DataWorld` lives. Idea was as a resource on the Editor's `World`, but that means regular Editor systems cannot directly write queries into the `DataWorld` ergonomically. See how extract does it for the render world though.** + ### Data types The data world is populated with the dynamic types coming from the various sources described in [User-facing explanation](#user-facing-explanation). @@ -146,14 +148,17 @@ This RFC introduces a `DataType` component that stores the description of a sing #[derive(Component)] struct DataType { name: Name, - properties: Vec, instances: Vec, } ``` -The data type contains an array of entities defining all the object instances of this type for easy reference, since this information is not encoded in any Rust type (see [Data instances](#data-instances)) so cannot be queried via the usual `Query` mechanism. +The `DataType` itself only contains the name of the type. Other type characteristics depend on the kind of type (object, enum, _etc._) and are defined in other kind-specific components attached to the same `Entity`; see [sub-types](#sub-types) below for details. + +The data type contains an array of entities defining all the instances of this type for easy reference, since this information is not encoded in any Rust type (see [Data instances](#data-instances)) so cannot be queried via the usual `Query` mechanism. -Some marker components are also used on the same `Entity` holding the `DataType` itself, to declare the origin of the type: +#### Type origin + +Some marker components are added to the same `Entity` holding the `DataType` itself, to declare the _origin_ of the type: ```rust // Built-in type compiled from the Bevy version the Game is built with. @@ -184,56 +189,40 @@ fn update_game_type_instances( } ``` -Properties are defined by a name and the type of their value. The type also stores the byte offset of that property from the beginning of its object, which allows packing property values for an object into a single byte stream. Modifying any of the `name`, `value_type`, or `byte_offset` constitute a type change, which mandates migrating all existing instances. - -```rust -struct Property { - name: Name, - value_type: Entity, // with DataType component - byte_offset: u32, - validate: Option>, -} -``` - -The _type layout_ refers to the collection of property offsets and types of a `DataType`, which together define: - -- the order in which the properties are listed in the object type, and any instance value stored. -- the size of each property value, based on its type. -- eventual gaps between properties, known as _padding bytes_, depending on their offset and size. +### Sub-types -Unlike Rust, the Editor data model guarantees that **padding bytes are zeroed**. This property enables efficient instance comparison via `memcmp()`-like byte-level comparison, without the need to rely on individual properties inspection nor any equality operator (`PartialEq` and `Eq` in Rust). +Various kinds of types have additional data attached to a separate component specific to that kind. This is referred to as _sub-types_. -The `Property` struct contains also an optional validation function used to validate the value for things like bounds check, nullability, _etc._ which is used both when the user (via the Editor UI) attempts to set a value, and when a value is set automatically (_e.g._ during deserialization). +The sub-type components allow, via the `Query` mechanism, to batch-handle all types of a certain kind, like for example listing all enum types: ```rust -trait Validate { - /// Check if a value is valid for the property. - fn is_valid(&self, property: &Property, value: &Value) -> bool; - - /// Try to assign a value to a property of an instance. - fn try_set(&mut self, instance: &mut DataInstance, property: &Property, value: &[u8]) -> bool; +fn list_enum_types(query: Query<&DataType, With>) { + for data_type in query.iter() { + println!("Enum {}", data_type.name); + } } ``` -### Sub-types +#### Simple types -Various kinds of types have additional data attached to a separate component specific to that type. This is referred to as _sub-types_. +_Simple types_ are built-in types defined by the data model itself, and corresponding to the basic types found in the Rust language. They constitute building blocks to creating more complex types via aggregation (see [Objects](#objects)). -The sub-types component allow, via the `Query` mechanism, to batch-handle all types of a certain kind, like listing all enum types. +**FIXME - naming conflicting with SimpleType enum above** ```rust -fn list_enum_types(query: Query<&DataType, With>) { - for data_type in query.iter() { - // [...] - } +#[derive(Component)] +struct SimpleType { + value: SimpleTypeEnum, } ``` #### Enums and flags -Enums are sets of named integral values called _entries_. An enum has an underlying integral type called its _storage type_ containing the actual integral value of the enum. The storage type can be any integral type, signed or unsigned, with at most 64 bits (so, exclusing `Int128` and `Uint128`). The enum type defines a mapping between that value and its name. An enum value is stored as its storage type, and is equal to exactly one of the enum entries. +Enums are sets of named integral values called _entries_. They are conceptually similar to so-called "C-style" enums. An enum has an underlying integral type called its _storage type_ containing the actual integral value of the enum. The storage type can be any integral type, signed or unsigned, with at most 64 bits (so, excluding `Int128` and `Uint128`). The enum type defines a mapping between that value and its name. An enum value is stored as its storage type, and is equal to exactly one of the enum entries. -Flags are like enums, except the value can be any bitwise combination of entries. The storage type is always an unsigned integral type. +**TODO - think about support for Rust-style enums...** + +Flags are like enums, except the value can be any bitwise combination of entries, instead of being restricted to a single entry. The storage type is always an unsigned integral type. The `EnumEntry` defines a single enum or flags entry with a `name` and an associated integral `value`. The value is encoded in the storage type, then stored in the 8-byte memory space offered by `value`. This means reading and writing the value generally involves reinterpreting the bit pattern. @@ -270,18 +259,60 @@ struct ArrayType { TODO... -Dictionaries are dynamically-sized homogenous collections mapping _keys_ to _values_, where each key is unique in the instance. The `DictType` defines the key and value types. +Dictionaries are dynamically-sized homogenous collections mapping _keys_ to _values_, where each key is unique in the instance. The key type is always a string. The `DictType` defines the value type. ```rust #[derive(Component)] struct DictType { - key_type: Entity, // with DataType component + value_type: Entity, // with DataType component +} +``` + +#### Objects + +An _object_ is an heterogenous aggregation of other types (like a `struct` in Rust). The `ObjectType` component contains the _properties_ of the object. + +```rust +#[derive(Component)] +struct ObjectType { + properties: Vec, // sorted by `byte_offset` +} +``` + +Properties themselves are defined by a name and the type of their value. The type also stores the byte offset of that property from the beginning of an object instance, which allows packing property values for an object into a single byte stream, and enables various optimizations regarding diff'ing/patching and serialization. Modifying any of the `name`, `value_type`, or `byte_offset` constitute a type change, which mandates migrating all existing instances. + +```rust +struct Property { + name: Name, + value_type: Entity, // with DataType component + byte_offset: u32, + validate: Option>, +} +``` + +The _type layout_ refers to the collection of property offsets and types of an `ObjectType`, which together define: + +- the order in which the properties are listed in the object type (the properties are sorted in increasing byte offset order), and any instance value stored in a `DataInstance`. +- the size in bytes of each property value, based on its type. +- eventual gaps between properties, known as _padding bytes_, depending on their offsets and sizes. + +Unlike Rust, the Editor data model guarantees that **padding bytes are zeroed**. This property enables efficient instance comparison via `memcmp()`-like byte-level comparison, without the need to rely on individual properties inspection nor any equality operator (`PartialEq` and `Eq` in Rust). It also enables efficient copy and serialization via direct memory reads and writes, instead of relying on getter/setter methods. + +The `Property` struct contains also an optional validation function used to validate the value of a property for things like range and bounds check, nullability, _etc._ which is used both when the user (via the Editor UI) attempts to set a value, and when a value is set automatically (_e.g._ during deserialization). + +```rust +trait Validate { + /// Check if a value is valid for the property. + fn is_valid(&self, property: &Property, value: &Value) -> bool; + + /// Try to assign a value to a property of an instance. + fn try_set(&mut self, instance: &mut DataInstance, property: &Property, value: &[u8]) -> bool; } ``` ### Data instances -Object instances are similarly represented by instances of the `DataInstance` component: +Instances of any type are represented by the `DataInstance` component: ```rust #[derive(Component)] @@ -308,7 +339,7 @@ fn get_data_instance_type( The property values are stored in a raw byte array, at the property's offset from the beginning of the array. The size of each property value is implicit, based on its type. Any extra byte (padding byte) in `DataInstance.values` is set to zero. -_Note: we rely on the Rust compiler to reorder fields and avoid padding in the Game process, so we expect minimal padding bytes, and do not attempt to further tightly pack the values, for the sake of simplicity._ +_Note: The Rust compiler, as per Rust language rules, will reorder fields of any non-`#[repr(C)]` type to optimize the type layout and avoid padding in the Game process. This native type layout of each Game type is then transferred to the Editor to create dynamic types. We expect as a result minimal padding bytes, and therefore do not attempt to further tightly pack the values ourselves, for the sake of simplicity. This has the added benefit to make the Editor type layout binary-compatible with the Game type native layout._ ### Migration rules @@ -357,9 +388,9 @@ When writing this section be mindful of the following [repo guidelines](https:// -### Abstraction +### Abstraction complexity -The data model adds a layer of abstraction over the "native" data the Game uses, which makes most operations indirected and more abstract. For example, setting a value involves invoking a setter method, possibly after locking an object for write access, as opposed to simply assigning the value to the object (`c.x = 5;`). +The data model adds a layer of abstraction over the "native" data the Game uses, which makes most operations indirected and more abstract. For example, setting a value involves invoking a setter method (or equivalent binary offset write), possibly after locking an object for write access, as opposed to simply assigning the value to the object (`c.x = 5;`). ## Rationale and alternatives From ba782942e91763d9e71959fdd0145c7d4fb8e1fb Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Tue, 19 Jul 2022 20:37:35 +0100 Subject: [PATCH 03/17] Rework names --- rfcs/301-editor-data-model.md | 140 +++++++++++++++++++--------------- 1 file changed, 77 insertions(+), 63 deletions(-) diff --git a/rfcs/301-editor-data-model.md b/rfcs/301-editor-data-model.md index b3d7f653..a85e4f34 100644 --- a/rfcs/301-editor-data-model.md +++ b/rfcs/301-editor-data-model.md @@ -116,73 +116,82 @@ enum BuiltInType { Int8, Int16, Int32, Int64, Int128, Uint8, Uint16, Uint32, Uint64, Uint128, Float32, Float64, + Name, Enum(UnsignedIntegralType), // C-style enum, not Rust-style - String, - Array(BuiltInType), - Dict, + Array(AnyType), + Dict(AnyType), // key is Name ObjectRef, } ``` -_Note_: We explicitly handle all integral and float bit variants to ensure tight value packing and avoid wasting space both in objects (`DataInstance`; see below) and collections (arrays, _etc._). +The `AnyType` is defined as either a built-in type or a reference to a custom Game type (see [Game types](#game-types)). -### Data world +```rust +enum AnyType { + BuiltIn(BuiltInType), + Custom(Entity), // with GameType component +} +``` -The data model uses dynamic types, so it can be represented purely with data, without any build-time types. Bevy already has an ideal storage for data: the `World` ECS container. This RFC introduces a new struct to represent the data model, the `DataWorld`: +_Note_: We explicitly handle all integral and floating-point bit variants to ensure tight value packing and avoid wasting space both in structs (`GameObject`; see below) and collections (arrays, _etc._). + +### Game world + +The data model uses dynamic types, so it can be represented purely with data, without any build-time types. Bevy already has an ideal storage for data: the `World` ECS container. This RFC introduces a new struct to represent the data model, the `GameWorld`: ```rust -struct DataWorld(pub World); +struct GameWorld(pub World); ``` -Since the underlying `World` already offers guarantees about concurrent mutability prevention, via the Rust language guarantees themselves, those guarantees transfer to the `DataWorld` allowing safe concurrent read-only access or exclusive write access to all data of the data model. +Since the underlying `World` already offers guarantees about concurrent mutability prevention, via the Rust language guarantees themselves, those guarantees transfer to the `GameWorld` allowing safe concurrent read-only access or exclusive write access to all data of the data model. -**TODO - Explain where the `DataWorld` lives. Idea was as a resource on the Editor's `World`, but that means regular Editor systems cannot directly write queries into the `DataWorld` ergonomically. See how extract does it for the render world though.** +**TODO - Explain where the `GameWorld` lives. Idea was as a resource on the Editor's `World`, but that means regular Editor systems cannot directly write queries into the `GameWorld` ergonomically. See how extract does it for the render world though.** -### Data types +### Game types -The data world is populated with the dynamic types coming from the various sources described in [User-facing explanation](#user-facing-explanation). +The game world is populated with the dynamic types coming from the various sources described in [User-facing explanation](#user-facing-explanation). -This RFC introduces a `DataType` component that stores the description of a single dynamic type. We use the name `DataType` to hint at the link with the `DataWorld` and avoid any confusion with any pre-existing dynamic object from the `bevy_reflect` crate (_e.g._ `DynamicScene` or `DynamicEntity`). +This RFC introduces a `GameType` component that stores the description of a single dynamic type from the Game. We use the name `GameType` to hint at the link with the `GameWorld`, and avoid the term "dynamic" to prevent any confusion with any pre-existing dynamic object from the `bevy_reflect` crate (_e.g._ `DynamicScene` or `DynamicEntity`). ```rust #[derive(Component)] -struct DataType { +struct GameType { name: Name, instances: Vec, } ``` -The `DataType` itself only contains the name of the type. Other type characteristics depend on the kind of type (object, enum, _etc._) and are defined in other kind-specific components attached to the same `Entity`; see [sub-types](#sub-types) below for details. +The `GameType` itself only contains the name of the type. Other type characteristics depend on the kind of type (struct, enum, _etc._) and are defined in other kind-specific components attached to the same `Entity`; see [sub-types](#sub-types) below for details. -The data type contains an array of entities defining all the instances of this type for easy reference, since this information is not encoded in any Rust type (see [Data instances](#data-instances)) so cannot be queried via the usual `Query` mechanism. +The game type contains an array of entities defining all the instances of this type for easy reference, since this information is not encoded in any Rust type (see [Data instances](#data-instances)) so cannot be queried via the usual `Query` mechanism. #### Type origin -Some marker components are added to the same `Entity` holding the `DataType` itself, to declare the _origin_ of the type: +Some marker components are added to the same `Entity` holding the `GameType` itself, to declare the _origin_ of the type: ```rust // Built-in type compiled from the Bevy version the Game is built with. #[derive(Component)] struct BevyType; -// Type from the Game of the currently open project. +// Type from the Game user code of the currently open project. #[derive(Component)] -struct GameType; +struct UserType; ``` -_Note that the Editor itself may depend on a different Bevy version. The built-in Bevy types of that version are not present in the `DataWorld`; only types from the Game are._ +_Note that the Editor itself may depend on a different Bevy version. The built-in Bevy types of that version are not present in the `GameWorld`; only types from the Game are._ This strategy allows efficiently querying for groups of types, in particular to update them when the Game or a library is reloaded. ```rust fn update_game_type_instances( mut query: Query< - &DataType, - (With, Changed) + &GameType, + (With, Changed) >) { - for data_type in query.iter_mut() { + for game_type in query.iter_mut() { // apply migration rules to all instances of this type - for instance in &mut data_type.instances { + for instance in &mut game_type.instances { // [...] } } @@ -196,26 +205,13 @@ Various kinds of types have additional data attached to a separate component spe The sub-type components allow, via the `Query` mechanism, to batch-handle all types of a certain kind, like for example listing all enum types: ```rust -fn list_enum_types(query: Query<&DataType, With>) { - for data_type in query.iter() { - println!("Enum {}", data_type.name); +fn list_enum_types(query: Query<&GameType, With>) { + for game_type in query.iter() { + println!("Enum {}", game_type.name); } } ``` -#### Simple types - -_Simple types_ are built-in types defined by the data model itself, and corresponding to the basic types found in the Rust language. They constitute building blocks to creating more complex types via aggregation (see [Objects](#objects)). - -**FIXME - naming conflicting with SimpleType enum above** - -```rust -#[derive(Component)] -struct SimpleType { - value: SimpleTypeEnum, -} -``` - #### Enums and flags Enums are sets of named integral values called _entries_. They are conceptually similar to so-called "C-style" enums. An enum has an underlying integral type called its _storage type_ containing the actual integral value of the enum. The storage type can be any integral type, signed or unsigned, with at most 64 bits (so, excluding `Int128` and `Uint128`). The enum type defines a mapping between that value and its name. An enum value is stored as its storage type, and is equal to exactly one of the enum entries. @@ -246,12 +242,12 @@ struct EnumType { #### Arrays -Arrays are dynamically-sized homogenous collections of _elements_. The `ArrayType` defines the element type, which can be any valid data type. +Arrays are dynamically-sized homogenous collections of _elements_. The `ArrayType` defines the element type, which can be any valid game type. ```rust #[derive(Component)] struct ArrayType { - elem_type: Entity, // with DataType component + elem_type: AnyType, } ``` @@ -259,40 +255,40 @@ struct ArrayType { TODO... -Dictionaries are dynamically-sized homogenous collections mapping _keys_ to _values_, where each key is unique in the instance. The key type is always a string. The `DictType` defines the value type. +Dictionaries are dynamically-sized homogenous collections mapping _keys_ to _values_, where each key is unique in the instance. The key type is always a string (`Name` type). The `DictType` defines the value type. ```rust #[derive(Component)] struct DictType { - value_type: Entity, // with DataType component + value_type: AnyType, } ``` -#### Objects +#### Structs -An _object_ is an heterogenous aggregation of other types (like a `struct` in Rust). The `ObjectType` component contains the _properties_ of the object. +A _struct_ is an heterogenous aggregation of other types (like a `struct` in Rust). The `StructType` component contains the _properties_ of the struct. ```rust #[derive(Component)] -struct ObjectType { +struct StructType { properties: Vec, // sorted by `byte_offset` } ``` -Properties themselves are defined by a name and the type of their value. The type also stores the byte offset of that property from the beginning of an object instance, which allows packing property values for an object into a single byte stream, and enables various optimizations regarding diff'ing/patching and serialization. Modifying any of the `name`, `value_type`, or `byte_offset` constitute a type change, which mandates migrating all existing instances. +Properties themselves are defined by a name and the type of their value. The type also stores the byte offset of that property from the beginning of a struct instance, which allows packing property values for a struct into a single byte stream, and enables various optimizations regarding diff'ing/patching and serialization. Modifying any of the `name`, `value_type`, or `byte_offset` constitute a type change, which mandates migrating all existing instances. ```rust struct Property { name: Name, - value_type: Entity, // with DataType component + value_type: AnyType, byte_offset: u32, validate: Option>, } ``` -The _type layout_ refers to the collection of property offsets and types of an `ObjectType`, which together define: +The _type layout_ refers to the collection of property offsets and types of an `StructType`, which together define: -- the order in which the properties are listed in the object type (the properties are sorted in increasing byte offset order), and any instance value stored in a `DataInstance`. +- the order in which the properties are listed in the struct type (the properties are sorted in increasing byte offset order), and any instance value stored in a `GameObject`. - the size in bytes of each property value, based on its type. - eventual gaps between properties, known as _padding bytes_, depending on their offsets and sizes. @@ -306,46 +302,62 @@ trait Validate { fn is_valid(&self, property: &Property, value: &Value) -> bool; /// Try to assign a value to a property of an instance. - fn try_set(&mut self, instance: &mut DataInstance, property: &Property, value: &[u8]) -> bool; + fn try_set(&mut self, instance: &mut GameObject, property: &Property, value: &[u8]) -> bool; } ``` -### Data instances +### Game objects -Instances of any type are represented by the `DataInstance` component: +Instances of any type are represented by the `GameObject` component: ```rust #[derive(Component)] -struct DataInstance { - data_type: Entity, // with DataType component +struct GameObject { + game_type: Entity, // with GameType component values: Vec, } ``` -The type of an instance is stored as the `Entity` holding the `DataType`, which can be accessed with the usual `Query` mechanisms: +The type of an instance is stored as the `Entity` holding the `GameType`, which can be accessed with the usual `Query` mechanisms: ```rust fn get_data_instance_type( - q_instance: Query<&DataInstance, [...]>, - q_types: Query<&DataType>) + q_instance: Query<&GameObject, [...]>, + q_types: Query<&GameType>) { for data_inst in q_instances.iter() { - let data_type = q_types - .get_component::(&data_inst.data_type).unwrap(); + let game_type = q_types + .get_component::(&data_inst.game_type).unwrap(); // [...] } } ``` -The property values are stored in a raw byte array, at the property's offset from the beginning of the array. The size of each property value is implicit, based on its type. Any extra byte (padding byte) in `DataInstance.values` is set to zero. +The property values are stored in a raw byte array, at the property's offset from the beginning of the array. The size of each property value is implicit, based on its type. Any extra byte (padding byte) in `GameObject.values` is set to zero. For example: + +```rust +#[repr(C)] // for the example clarity only +struct S { + f: f32, + b: u8, + u: u64, +} +``` + +has its values stored in `GameObject.values` as: + +```txt +[0 .. 3 | 4 | 5 ... 7 | 8 .. 11 ] bytes +[ f32 | u8 | padding | u64 ] values +``` _Note: The Rust compiler, as per Rust language rules, will reorder fields of any non-`#[repr(C)]` type to optimize the type layout and avoid padding in the Game process. This native type layout of each Game type is then transferred to the Editor to create dynamic types. We expect as a result minimal padding bytes, and therefore do not attempt to further tightly pack the values ourselves, for the sake of simplicity. This has the added benefit to make the Editor type layout binary-compatible with the Game type native layout._ ### Migration rules -The _migration rules_ are a set of rules applied when transforming a `DataInstance` from one `DataType` to another `DataType`. The rules define if such conversion can successfully take place, and what is its result. +The _migration rules_ are a set of rules applied when transforming a `GameObject` from one `GameType` to another `GameType`. The rules define if such conversion can successfully take place, and what is its result. -Given a setup where a migration of a `src_inst: DataInstance` is to be performed from a `src_type: DataType` to a `dst_type: DataType` to produce a new `dst_inst: DataInstance`, the migration rules are: +Given a setup where a migration of a `src_inst: GameObject` is to be performed from a `src_type: GameType` to a `dst_type: GameType` to produce a new `dst_inst: GameObject`, the migration rules are: 1. If `src_type == dst_type`, then set `dst_inst = src_inst`. 2. Otherwise, create a new empty `dst_inst` and then for each property present in either `src_type` or `dst_type`: @@ -467,6 +479,8 @@ Note that while precedent set by other engines is some motivation, it does not o - What parts of the design do you expect to resolve through the implementation of this feature before the feature PR is merged? - What related issues do you consider out of scope for this RFC that could be addressed in the future independently of the solution that comes out of this RFC? --> +1. How do we store string instances? Storing a `String` requires an extra heap allocation per string and makes `GameObject` non-copyable. Interning and using an ID would solve this. + ## \[Optional\] Future possibilities + ### Names Being data-heavy, and forfeiting the structural access of Rust types, the data model makes extensive use of strings to reference items. In order to make those operations efficient, we define a `Name` type to represent those strings. The implementation of `Name` is outside of the scope of this RFC and left as an optimization, and should be considered as equivalent to a `String` wrapper: @@ -119,11 +135,22 @@ enum BuiltInType { Name, Enum(UnsignedIntegralType), // C-style enum, not Rust-style Array(AnyType), - Dict(AnyType), // key is Name - ObjectRef, + Dict(AnyType), // key type is Name + ObjectRef(Option), } ``` +_Note_: We explicitly handle all integral and floating-point bit variants to ensure tight value packing and avoid wasting space both in structs (`GameObject`; see below) and collections (arrays, _etc._). + +`ObjectRef` is a reference to any object, custom or built-in. This allows referencing components built-in inside Bevy or the Editor (TBD depending on design of that part). The reference needs to be valid (non-null) when the data gets processed for baking, but can temporarily (including while serialized to disk and later reloaded) be left invalid (`None`) for editing flexibility. Nullable references require a `NullableRef` component to be added to mark the reference as valid even if null. + +```rust +#[derive(Component)] +struct NullableRef; +``` + +This allows filtering out nullable references, and collect invalid `ObjectRef` instances easily via a `Query`, for example to emit some warning to the user. + The `AnyType` is defined as either a built-in type or a reference to a custom Game type (see [Game types](#game-types)). ```rust @@ -133,8 +160,6 @@ enum AnyType { } ``` -_Note_: We explicitly handle all integral and floating-point bit variants to ensure tight value packing and avoid wasting space both in structs (`GameObject`; see below) and collections (arrays, _etc._). - ### Game world The data model uses dynamic types, so it can be represented purely with data, without any build-time types. Bevy already has an ideal storage for data: the `World` ECS container. This RFC introduces a new struct to represent the data model, the `GameWorld`: @@ -251,9 +276,9 @@ struct ArrayType { } ``` -#### Dictionaries +Array values store their elements contiguously in the byte storage (see [Game objects](#game-objects)), without padding between elements. -TODO... +#### Dictionaries Dictionaries are dynamically-sized homogenous collections mapping _keys_ to _values_, where each key is unique in the instance. The key type is always a string (`Name` type). The `DictType` defines the value type. @@ -264,6 +289,8 @@ struct DictType { } ``` +Dictionary values store their (key, value) pairs contiguously in the byte storage (see [Game objects](#game-objects)), key first followed by value, without padding. + #### Structs A _struct_ is an heterogenous aggregation of other types (like a `struct` in Rust). The `StructType` component contains the _properties_ of the struct. @@ -299,7 +326,7 @@ The `Property` struct contains also an optional validation function used to vali ```rust trait Validate { /// Check if a value is valid for the property. - fn is_valid(&self, property: &Property, value: &Value) -> bool; + fn is_valid(&self, property: &Property, value: &[u8]) -> bool; /// Try to assign a value to a property of an instance. fn try_set(&mut self, instance: &mut GameObject, property: &Property, value: &[u8]) -> bool; @@ -380,21 +407,36 @@ Given a setup where a migration of a `src_inst: GameObject` is to be performed f _Note: the migration rules are written in terms of two distinct `src_inst` and `dst_inst` objects for clarity, but the implementation is free to do an in-place modification to prevent an allocation, provided the result is equivalent to migrating to a new instance then replacing the source instance with it._ - +```rust +macro_rules! offset_of { + ($ty:ty, $field:ident) => { + unsafe { + let uninit = MaybeUninit::<$ty>::uninit(); + let __base = uninit.as_ptr(); + let __field = std::ptr::addr_of!((*__base).$field); + __field as usize - __base as usize + } + }; +} +``` + +See also the [rustlang discussion](https://internals.rust-lang.org/t/pre-rfc-add-a-new-offset-of-macro-to-core-mem/9273) on the matter. There is currently no Rust `offset_of()` macro, and not custom implementation known to work in a `const` context with the `stable` toolchain. However we only intend to use that macro at runtime, so are not blocked by this limitation. + +The details of: + +1. how to connect to the Game process from the Editor process via the `EditorClientPlugin`, +2. what format to serialize the necessary type data into to be able to create `GameType`s, + +are left outside the scope of this RFC, and would be best addressed by a separate RFC focused on that interaction. ## Drawbacks @@ -412,8 +454,26 @@ The data model adds a layer of abstraction over the "native" data the Game uses, - What is the impact of not doing this? - Why is this important to implement as a feature of Bevy itself, rather than an ecosystem crate? --> +This design is thought to be the best in space because it adapts to the Bevy architecture the design of Our Machinery, which was built by a team of experts with years of experience and 2 shipped game engines. Our Machinery is also a relatively new game engine, so is not constrained by legacy choices which would make its design arguably outdated or not optimal. + +The impact of not doing this is obvious: we need to do _something_ about the Editor data model, so if not this RFC then we need another design anyway. + +The RFC is targeted at the Bevy Editor, so the change is to be implemented within that crate (or set of crates), with any additional changes to Bevy itself to catter for possible missing features in reflection. The data model constitutes the core of the Bevy Editor so implementation in an ecosystem crate is not suited. + +- **Alternative**: Use the current `Reflect` architecture as-is, with `Path`-based property manipulation. This is the most simple way, as it avoids the complexity of type layouts and byte stream values (and the `unsafe` associated). The drawback is that calling a virtual (trait) method for each get and set of each value is reasonable for manual edits in the Editor (execution time much smaller than user reaction time) but might become a performance issue when automated edits come into play, like for an animation system with a timeline window allowing to scroll current time and update all animated properties of all objects in the scene at once. + ## Prior art + + ### Unity3D The editor of the [Unity3D](https://unity.com/) game engine (or "Unity" for short) is closed-source software, so we can only speculate on how its data model works. @@ -461,18 +521,6 @@ They also list some bad experiences with the Bitsquid data model (a predecessor - string-based object references are not semantically different from other strings in a JSON-like format, so cannot be reasoned about without semantic context, and internal references inside the same file are ill-defined. - file-based models play poorly with real-time collaboration. - - - - ## Unresolved questions + +Because the Editor data model provides a centralized source of truth for all editing systems, this enables building a unique undo/redo system and a unique copy/paste system shared by all other editing systems, without the need for each of those editing systems to design their own. + +I expect the following future RFCs to build on top of this one: + +- Game/Editor communication (IPC) +- Undo/redo system +- Copy/paste system +- Live editing (update Game data in real-time while editing in Editor) +- Remote collaboration (multiple users editing the same project collaboratively from multiple remote locations via networking) From 8092760392bcbba471f9a42c692527476c219cb7 Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Wed, 20 Jul 2022 14:39:22 +0100 Subject: [PATCH 06/17] Rename 301-editor-data-model.md to 62-editor-data-model.md --- rfcs/{301-editor-data-model.md => 62-editor-data-model.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename rfcs/{301-editor-data-model.md => 62-editor-data-model.md} (100%) diff --git a/rfcs/301-editor-data-model.md b/rfcs/62-editor-data-model.md similarity index 100% rename from rfcs/301-editor-data-model.md rename to rfcs/62-editor-data-model.md From 66857aced844d9d7a083fca896108935d545e36b Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Thu, 21 Jul 2022 22:08:27 +0100 Subject: [PATCH 07/17] Edits from Nilirad's review --- rfcs/62-editor-data-model.md | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/rfcs/62-editor-data-model.md b/rfcs/62-editor-data-model.md index fd8aefb0..2a90951f 100644 --- a/rfcs/62-editor-data-model.md +++ b/rfcs/62-editor-data-model.md @@ -12,30 +12,37 @@ In this RFC, the following terms are used: - **Author** refers to any person (including non-technical) building the Game with the intent of shipping it to end users. - **Editor** refers to the software used to edit Game data and prepare them for runtime consumption, and which this RFC describes the data model of. - **Runtime** refers to the Game or Editor process running time, as opposed to compiling or building time. When not otherwise specified, this refers to the Game's runtime. In general it's synonymouns of a process running, and is opposed to _build time_ or _compile time_. +- **Project** refers to the Editor's representation of the Game that data is being created/edited for, and all the assets, metadata, settings, or any other data the Editor needs and which is associated to that specific Game. There's a 1:1 mapping between a Game and an Editor project. ## Motivation -We need a data model for the Bevy Editor. We want to make it decoupled from the runtime data model that the Game uses, which is based on compiled Rust types (`World`, _etc._) with a defined native layout from a fixed Rust compiler version (as most types don't use `#[repr(C)]`), so we can handle data migration across Rust compiler versions and therefore across both Editor and Game versions. We also want to make it _transportable_, that is serializable with the intent to transport it over any IPC mechanism, so that the Editor process can dialog with the Game process to enable real-time Game editing ("Play Mode"). +The Bevy Editor needs a _data model_, that is a way to organize the data it manipulates, and more precisely here the Game data that it creates and edits for the purpose of transforming it into a final shippable form to be loaded by the Game at runtime. The data model defines the structure of this data, and the APIs to manipulate it. + +We want to make the Editor data model decoupled from the runtime data model that the Game uses. The data model of the Game is based on Rust types (`World`, `Component`, _etc._) with a defined binary layout generated by a fixed Rust compiler version (as most types don't use `#[repr(C)]`). This binary layout may evolve not only when the Rust compiler version changes, but also when the user edits the Game code. However we want to avoid having to rebuild the Editor, or even restart its process, when this happens. Therefore we need an Editor data model that can handle those changes in the Game data model. That is, when the user modifies a Rust type in the Game code, we want to be able to modify some data in the Editor on the fly while the Editor runs. + +We also want to make that data structured in the Editor data model format _transportable_, that is serializable with the intent to transport it over any inter-process communication (IPC) mechanism, so that the Editor process can dialog with the Game process to enable real-time Game editing ("Play Mode"). ## User-facing explanation -The _data model_ of the Editor refers to the way the Editor manipulates editing data, their representation in memory while the Editor executable is running, and the API to do those manipulations. The Editor at its core defines a centralized data model, and all systems use the same API to access that data model, making it the unique source of truth while the Editor process is running. This unicity prevents desynchronization between various subsystems, since they share a same view of the editing data. It also enables global support for common operations such as copy/paste and undo/redo without requiring each subsystem to implement their own variant. +The Bevy Editor creates and edits Game data to build scenes and eventually organize the various Game assets into some cohesive game content. + +The _data model_ of the Editor refers to the way the Editor manipulates this Game data, their representation in memory while the Editor executable is running, and the API to do those manipulations. The Editor at its core defines a centralized data model, and all the ECS systems of the Editor use the same API to access that data model, making it the unique source of truth while the Editor process is running. This unicity prevents desynchronization between various subsystems, since they share a same view of the editing data. It also enables global support for common operations such as copy/paste and undo/redo without requiring each subsystem to implement their own variant. -A Bevy application (the Game) uses Rust types defined in one of the `bevy_*` crates, any other third-party plugin library, or the Game itself (_custom types_). The data is represented in memory at runtime by _instances_ (allocated objects) of those Rust types. This representation is optimal in terms of read and write access, but is _static_, that is the type layout is defined during compiling and cannot be changed afterward. +The Game uses Rust types defined in one of the `bevy_*` crates, any other third-party plugin library, or the Game itself (_custom types_). The data is represented in memory at runtime by _instances_ (allocated objects) of those Rust types. This representation is optimal in terms of read and write access speed, but it is _static_, that is the type layout is defined during compilation and cannot be changed afterward. -In contrast, the Editor needs to support type changes to allow hot-reloading custom components from the Game, in order to enable fast iteration for the Authors. To enable this, the Editor data model is based on _dynamic types_ defined exclusively during Editor execution. The data model defines an API to instantiate objects from dynamic types, manipulates the data of those instances, but also modify the types themselves while objects are instantiated. This latter case is made possible through a set of migration rules allowing to transform an object instance from a version of a type to another modified version of that type. +In contrast, we want to enable the Editor to observe changes in the Game code without rebuilding or even restarting the Editor process, in order to enable fast iteration for the Authors. This feature, referred to as _hot-reloading_, requires the Editor to support type changes, which is not possible with static types like the Game uses. Therefore, to allow this flexibility, the Editor data model is based on _dynamic types_ defined exclusively during Editor execution. The data model defines an API to instantiate objects from dynamic types, manipulates the data of those instances, but also modify the types themselves while objects are instantiated. This latter case is made possible through a set of migration rules allowing to transform an object instance from a version of a type to another modified version of that type. The general workflow is as follows: - The Author starts the Editor executable and create a new project or loads an existing project. -- The Editor launches the Game executable in _edit mode_, whereby the `EditorClientPlugin` is enabled and allows the Editor and the Game to communicate in real time. +- The Editor launches the Game executable in _edit mode_, a special mode whereby the `EditorClientPlugin` is enabled and allows the Editor and the Game to communicate in real time. The `EditorClientPlugin` is a Bevy plugin (implements the `Plugin` trait) provided by an Editor crate and used during development only, which implements the Game side of the Editor/Game communication. It's compiled out of any Release build before shipping the Game. - The Editor queries the Game for all its custom types, including components and resources. - The Editor follows the same process for any third-party library, to enable extending Bevy with new functionalities. - The Editor builds a database of all (dynamic) types, merging the built-in Bevy types (the ones defined in any `bevy_*` official crate), any third-party library, and the Game. - The Author creates new object instances from any of those types, and manipulates the data of those instances by setting the value of the properties exposed by the type of the object. -- Optionally, the Author saves the project, which makes the Editor serialize the data model to disk for later reloading. +- Optionally, the Author saves the project, which makes the Editor serialize the data model to persistent storage for later Editor sessions on the same project. -The data model supports various intrinsic types: boolean, integral, floating-point, strings, arrays, dictionaries, enums and flags, and objects (references to other types). Objects can be references to entities or to components. Dynamic types extend those by composition; they define a series of _properties_, defined by their name and a type themselves. Conceptually, a property can look like: +The data model supports various intrinsic types: boolean, integral, floating-point, strings, arrays, dictionaries, enums and flags, and objects (references to other types). Objects can be references to entities or to components. Dynamic types extend those by composition; they define a series of _properties_, defined by their name and a type themselves. Conceptually, a property can look like (illustrative pseudo-code): ```txt type "MyPlayerComponent" { @@ -46,22 +53,22 @@ type "MyPlayerComponent" { } ``` -The data model offers an API to manipulate object instances: +The data model offers an API (the _object API_) to manipulate object instances: - get the type of an object instance - get and set property values of a type instance - lock an object for exclusive write access; this allows making a set of writes transactional (see below for details) -This API is most commonly used to build a User Interface (UI) to allow the Authors to edit the data via the Editor itself. It is also used by Editor systems to manipulate object instances where needed. +The object API is most commonly used to populate a User Interface (UI) with data. That UI allows the Authors to edit the data via the Editor itself. It's also used by Editor systems to manipulate object instances where needed. -The data model also offers an API to manipulate the types themselves: +The data model also offers an API (the _property API_) to manipulate the types themselves: - get a type's name - enumerate the properties of a type - add a new property - remove an existing property -This API is most commonly used when reloading the Game after editing its code and rebuilding it, to update the Editor's dynamic types associated with the custom Game types. +The property API is most commonly used when reloading the Game after editing its code and rebuilding it, to update the Editor's dynamic types associated with the custom Game types. The data model guarantees the integrity of its data by allowing concurrent reading of data but ensuring exclusive write access. This allows concurrent use of the data, for example for remote collaboration, so long as write accesses to objects are disjoints. Each object for which write access was given is locked for any other access. This is similar in concept to the Rust borrow model, and to that respect the Editor data model plays the role of the Rust borrow checker. From 9b7c65d4035619c24749bdc6f5cb8980f0af455d Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Sun, 24 Jul 2022 22:25:51 +0100 Subject: [PATCH 08/17] Update "User-facing explanation" --- rfcs/62-editor-data-model.md | 37 +++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/rfcs/62-editor-data-model.md b/rfcs/62-editor-data-model.md index 2a90951f..1569a30d 100644 --- a/rfcs/62-editor-data-model.md +++ b/rfcs/62-editor-data-model.md @@ -18,30 +18,41 @@ In this RFC, the following terms are used: The Bevy Editor needs a _data model_, that is a way to organize the data it manipulates, and more precisely here the Game data that it creates and edits for the purpose of transforming it into a final shippable form to be loaded by the Game at runtime. The data model defines the structure of this data, and the APIs to manipulate it. -We want to make the Editor data model decoupled from the runtime data model that the Game uses. The data model of the Game is based on Rust types (`World`, `Component`, _etc._) with a defined binary layout generated by a fixed Rust compiler version (as most types don't use `#[repr(C)]`). This binary layout may evolve not only when the Rust compiler version changes, but also when the user edits the Game code. However we want to avoid having to rebuild the Editor, or even restart its process, when this happens. Therefore we need an Editor data model that can handle those changes in the Game data model. That is, when the user modifies a Rust type in the Game code, we want to be able to modify some data in the Editor on the fly while the Editor runs. +We want to make the Editor data model decoupled from the runtime data model that the Game uses to avoid the Editor depending on the Game. This avoids having to rebuild the Editor, or even restart its process, when the Game code changes. -We also want to make that data structured in the Editor data model format _transportable_, that is serializable with the intent to transport it over any inter-process communication (IPC) mechanism, so that the Editor process can dialog with the Game process to enable real-time Game editing ("Play Mode"). +We also want to make that data structured in the Editor data model format _transportable_, that is serializable with the intent to transport it over any inter-process communication (IPC) mechanism, so that the Editor process can dialog with the Game process. ## User-facing explanation -The Bevy Editor creates and edits Game data to build scenes and eventually organize the various Game assets into some cohesive game content. +The Bevy Editor creates and edits Game data to build scenes and other assets, and eventually organize all those Game assets into some cohesive game content. -The _data model_ of the Editor refers to the way the Editor manipulates this Game data, their representation in memory while the Editor executable is running, and the API to do those manipulations. The Editor at its core defines a centralized data model, and all the ECS systems of the Editor use the same API to access that data model, making it the unique source of truth while the Editor process is running. This unicity prevents desynchronization between various subsystems, since they share a same view of the editing data. It also enables global support for common operations such as copy/paste and undo/redo without requiring each subsystem to implement their own variant. +### Type exchange -The Game uses Rust types defined in one of the `bevy_*` crates, any other third-party plugin library, or the Game itself (_custom types_). The data is represented in memory at runtime by _instances_ (allocated objects) of those Rust types. This representation is optimal in terms of read and write access speed, but it is _static_, that is the type layout is defined during compilation and cannot be changed afterward. +The Editor and the Game have different data models. The Game data is baked into a format optimized for runtime consumption (fast loading from persistent storage and minimal transformation before use), often with platform-specific choices. In contrast, the Editor manipulates a higher-level format independent of the Game itself or the target platform. Therefore the two must exchange Game types and data to enable conversions between the data encoded in the two data models. Because the Game and the Editor are separate processes, this exchange occurs through an inter-process communication (IPC) mechanism. -In contrast, we want to enable the Editor to observe changes in the Game code without rebuilding or even restarting the Editor process, in order to enable fast iteration for the Authors. This feature, referred to as _hot-reloading_, requires the Editor to support type changes, which is not possible with static types like the Game uses. Therefore, to allow this flexibility, the Editor data model is based on _dynamic types_ defined exclusively during Editor execution. The data model defines an API to instantiate objects from dynamic types, manipulates the data of those instances, but also modify the types themselves while objects are instantiated. This latter case is made possible through a set of migration rules allowing to transform an object instance from a version of a type to another modified version of that type. +During development, the Game links and uses the `EditorClientPlugin`. This is a special plugin provided as part of the Editor ecosystem, and which enables the Game and the Editor to communicate via IPC. This plugin is not linked in release builds, and therefore does not ship with the Game. The Game only loads the `EditorClientPlugin` when launched into a special _edit mode_ via a mechanism outside of the scope of this RFC (possibly some environment variable, or command-line argument, or any other similar mechanism). + +When writing the Game code, the Author(s) define new Rust types to represent components, resources, _etc._ specific to that Game (for example, `MainPlayer` or `SpaceGun`). Because the Editor does not depend on the Game, those Game types are sent at runtime to the Editor via IPC. This allows the Editor to understand the binary format of the Game objects and convert them into its data model and back. -The general workflow is as follows: +### Editing workflow + +The general editing workflow is as follows: - The Author starts the Editor executable and create a new project or loads an existing project. -- The Editor launches the Game executable in _edit mode_, a special mode whereby the `EditorClientPlugin` is enabled and allows the Editor and the Game to communicate in real time. The `EditorClientPlugin` is a Bevy plugin (implements the `Plugin` trait) provided by an Editor crate and used during development only, which implements the Game side of the Editor/Game communication. It's compiled out of any Release build before shipping the Game. -- The Editor queries the Game for all its custom types, including components and resources. -- The Editor follows the same process for any third-party library, to enable extending Bevy with new functionalities. -- The Editor builds a database of all (dynamic) types, merging the built-in Bevy types (the ones defined in any `bevy_*` official crate), any third-party library, and the Game. -- The Author creates new object instances from any of those types, and manipulates the data of those instances by setting the value of the properties exposed by the type of the object. +- The Editor launches the Game executable in _edit mode_ to use the `EditorClientPlugin` for Game/Editor communication. +- The Editor queries via IPC the Game for all its Game types, including components and resources. +- The Editor builds a database of all the (dynamic) types received, and make them available through the type API of its data model. +- The Author creates new object instances from any of those types, and manipulates the data of those instances via the various Editor functionalities, themselves using the data model API of the Editor. - Optionally, the Author saves the project, which makes the Editor serialize the data model to persistent storage for later Editor sessions on the same project. +### Data model + +The _data model_ of the Editor refers to the way the Editor manipulates this Game data, their representation in memory while the Editor executable is running, and the API to do those manipulations. The Editor at its core defines a centralized data model, and all the ECS systems of the Editor use the same API to access that data model, making it the unique source of truth while the Editor process is running. This unicity prevents desynchronization between various subsystems, since they share a same view of the editing data. It also enables global support for common operations such as copy/paste and undo/redo, since all ECS systems will observe the same copy/paste/undo/redo changes to that unique source of truth. + +When writing the Game code, the Author(s) define new Rust types to represent components, resources, _etc._ specific to that Game (for example, `MainPlayer` or `SpaceGun`). At runtime, those Rust types are used to allocate _instances_ (allocated objects). The representation of the instances in memory (_e.g._ `SpaceGun.fireSpeed` field takes 4 bytes starting at 28 bytes from the start of the object instance), also called the _layout_, is optimal in terms of read and write access speed by the CPU, but it is _static_, that is the type layout is defined during compilation and cannot be changed afterward. + +In contrast, we want to enable the Editor to observe changes in the Game code without rebuilding or even restarting the Editor process, in order to enable fast iteration for the Authors. This feature, referred to as _hot-reloading_, requires the Editor to support type changes, which is not possible with static types like the Game uses. Therefore, to allow this flexibility, the Editor data model is based on _dynamic types_ defined exclusively during Editor execution. The data model defines an API to instantiate objects from dynamic types, manipulates the data of those instances, but also modify the types themselves while objects are instantiated. This latter case is made possible through a set of migration rules allowing to transform an object instance from a version of a type to another modified version of that type. + The data model supports various intrinsic types: boolean, integral, floating-point, strings, arrays, dictionaries, enums and flags, and objects (references to other types). Objects can be references to entities or to components. Dynamic types extend those by composition; they define a series of _properties_, defined by their name and a type themselves. Conceptually, a property can look like (illustrative pseudo-code): ```txt @@ -385,7 +396,7 @@ has its values stored in `GameObject.values` as: [ f32 | u8 | padding | u64 ] values ``` -_Note: The Rust compiler, as per Rust language rules, will reorder fields of any non-`#[repr(C)]` type to optimize the type layout and avoid padding in the Game process. This native type layout of each Game type is then transferred to the Editor to create dynamic types. We expect as a result minimal padding bytes, and therefore do not attempt to further tightly pack the values ourselves, for the sake of simplicity. This has the added benefit to make the Editor type layout binary-compatible with the Game type native layout._ +_Note: The Rust compiler, as per Rust language rules, will reorder fields of any non-`#[repr(C)]` type to optimize the type layout and avoid padding in the Game process. This native type layout of each Game type is then transferred to the Editor to create dynamic types. We expect as a result minimal padding bytes, and therefore do not attempt to further tightly pack the values ourselves, for the sake of simplicity._ ### Migration rules From 04c2a23a31b3c9f1f359452ae806caa483dc5e00 Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Wed, 27 Jul 2022 23:21:11 +0100 Subject: [PATCH 09/17] Add prior art from Guerrilla Games --- rfcs/62-editor-data-model.md | 86 ++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/rfcs/62-editor-data-model.md b/rfcs/62-editor-data-model.md index 1569a30d..72830ef5 100644 --- a/rfcs/62-editor-data-model.md +++ b/rfcs/62-editor-data-model.md @@ -10,8 +10,8 @@ In this RFC, the following terms are used: - **Game** refers to any application made with Bevy, which encapsulates proper games but also any other kind of Bevy application (CAD, architecture software, _etc._). - **Author** refers to any person (including non-technical) building the Game with the intent of shipping it to end users. -- **Editor** refers to the software used to edit Game data and prepare them for runtime consumption, and which this RFC describes the data model of. -- **Runtime** refers to the Game or Editor process running time, as opposed to compiling or building time. When not otherwise specified, this refers to the Game's runtime. In general it's synonymouns of a process running, and is opposed to _build time_ or _compile time_. +- **Editor** refers to the software used to edit Game data and prepare them for runtime consumption. +- **Runtime** refers to the Game process running time, as opposed to compiling or building time. In general it's synonymouns of a process running, and is opposed to _build time_ or _compile time_. - **Project** refers to the Editor's representation of the Game that data is being created/edited for, and all the assets, metadata, settings, or any other data the Editor needs and which is associated to that specific Game. There's a 1:1 mapping between a Game and an Editor project. ## Motivation @@ -509,7 +509,7 @@ Data is serialized and saved as YAML files. Although this process is outside of Unity notably supports hot-reloading user code (C# assemblies) during editing and even while the Game is playing in Play Mode inside the Editor. It also supports editing on the fly data while the Game is running in that mode, via its Inspector window. This is often noted as a useful features, and hints again at a data-oriented model. -## Our Machinery +### Our Machinery [Our Machinery](https://ourmachinery.com/) has a few interesting blog posts about the architecture of the engine. Notably, a very detailed and insightful blog post about their data model. @@ -539,6 +539,86 @@ They also list some bad experiences with the Bitsquid data model (a predecessor - string-based object references are not semantically different from other strings in a JSON-like format, so cannot be reasoned about without semantic context, and internal references inside the same file are ill-defined. - file-based models play poorly with real-time collaboration. +### Guerrilla Games + +[🎞 _GDC 2017 - Creating a Tools Pipeline for Horizon: Zero Dawn_](https://www.youtube.com/watch?v=KRJkBxKv1VM) + +Guerrilla Games started to rebuild their entire tools pipeline during production of _Horizon: Zero Dawn_. The aim was to replace a set of disparate tools, including a custom Maya plugin embedding the renderer of _Decima_ (their proprietary game engine); they state Maya is not suited for level editing. + +#### General architecture + +Given their various constraints (game already in production, rebuilding everything from scratch, wanted to reuse Decima's libraries), they decided to go for a tools framework built on top of the Decima code. In particular, they reuse the existing Decima RTTI (reflection) system. Their focus is also on a limited subset of a full-featured editor: object creation and placement. Nonetheless, they approached the design with the intent to eventually replace all other tools, therefore their design decisions are worth considering. + +In detail, the following choices were made: + +1. Integrate limited sub-systems OR the full game + + - Limited sub-systems is purpose built, controllable, fast to build, but adds a new codepath separate from the game and encourages visual and behavioral disparities. + - Full game makes available all game systems under a single unified codepath. In their case, it also adds for free their in-game DebugUI system. However there is an overhead to running all systems of an entire game all the time for any editing action only concerned by a few of them. + + Due to time constraints, they decided to integrate the full game inside the Editor. + +2. In-process OR out-of-process + + - In-process (game and editor share the same execution process) is fast to setup, allows direct and synchronous access to game data from the editor. The drawbacks are instabilities (game crash = editor crash = loss of work) and a high difficulty to enforce code separation. + - Out-of-process (game process and editor process run separately) enables stability, cleaner separated code, and more responsive UI (game performance has no impact on the editor UI, provided CPU not starved). However the inital development is more complex, and it's harder to debug 2 asynchronous processes. + + In the end, they decided to go for in-process due to time constraints, but with a design that allows future out-of-process by enforcing a very strict code separation between Editor and Game. To that end, we can consider they conceptually went with an out-of-process solution. + +#### Editor model + +The editor supports play-time editing, whereby the game is paused and some edits are made to a level, before the game is resumed on that newly-edited level. However the game uses baked data (called _converted_ in the presentation), and baking is a destructive process which loses some information necessary for editing (like, objects hierarchies of static objects before it's flattened, to allow local-space editing). To work around this issue, they introduce an Editor Node, which contains the edit-time representation of (and maps 1:1 with) a runtime (baked) game object. This defines their data model for the editor, with a one-way relationship Editor -> Game. So Game code doesn't have any Editor-specific code. They build 2 separate hierarchies of Editor Nodes, one for the Editor to manipulate, and one tied with the Game, to enforce code separation (and later allow out-of-process). + +They mention some specific non-goals: + +- No proxy representation of Game objects. +- No string-based accessors to Game objects. +- No Editor-specific boilerplate. +- No code generation step in the build. + +Instead they aimed at: + +- Working directly with conrete objects. +- Directly modifying object members. +- Re-using the editing and baking tools already existing to create/transform data. + +The data model allows object changes to the Game by generating _transactions_ applied to the Node Tree of the Editor itself, which then broadcasts those changes to the Node Tree of the Game, which applies them to the actual runtime game objects. This allows making _e.g._ a position change in local space (relative to parent object) in the Editor, where the hierarchy is known, which is transformed into a world-space transaction sent to the Game which apply them to baked that (which don't have any hierarchy info left). + +When the change originates from the Game itself, like object placement / vegetation painting / terrain scuplting inside the rendered viewport, the Game sends back an _intention_ that is sent to the Editor. Then the Editor applies it like any other modification. This keeps the Editor authority over its Node Tree, keeps the flow of changes from Editor to Game, and prevents infinite loops (Game modified Editor data, which applies them to Game, _etc._). + +For newly-added objects, they go into details on how they generate a patch file for the Game then apply it while the Game is paused. This feature is named _GameSync_ and has the following reported properties: + +- ➕ Works on all changes throughout the Editor +- ➕ Works on target platform (console) +- ➕ No game restart required +- ➖ Complex change analysis +- ➖ Performance issues +- ➖ Crashes, inconsistencies +- ➖ Low level of confidence from users + +From those conclusions, including the statement that they wish to rewrite GameSync in the future, we can conclude this feature represents a challenging task. + +Finally, the Editor data model allows them to expose a Python API to build new tools easily. + +#### Undo system + +They also specifically list requirements for an Undo system: + +- Simple and reliable. +- No virtual undo/redo functions: + - Didn't want the possibility of undo not being symmetric to redo + - Didn't want to have to write new undo/redo functions for each new feature + +The ideal undo system they aimed at can automatically track changes and how to revert them. They achieve this with manual tagging for start and end of modification. Change detection is based on reflection (RTTI) and builds a list of commands (add object, set value, _etc._) that can be easily reverted (remove object, set old value, _etc._). The system includes a notification mechanism for any system to observe changes to objects, which helps system decoupling and prevent duplicated functionalities. They explicitly mention a Model-View-Controller (MVC) pattern. + +They list some pros and cons of this approach: + +- ➕ Reliable system for arbitrarily complex changes +- ➕ No distinction between undo and redo (all commands) +- ➕ Stable code that changed very little once written +- ➖ Manual code tagging easy to forget, hard to debug +- ➖ No filesystem operations support + ## Unresolved questions -This design is thought to be the best in space because it adapts to the Bevy architecture the design of Our Machinery, which was built by a team of experts with years of experience and 2 shipped game engines. Our Machinery is also a relatively new game engine, so is not constrained by legacy choices which would make its design arguably outdated or not optimal. +This design is thought to be the best in space because it adapts to the Bevy architecture the design of Our Machinery, +which was built by a team of experts with years of experience and 2 shipped game engines. +Our Machinery is also a relatively new game engine, +so is not constrained by legacy choices which would make its design arguably outdated or not optimal. -The impact of not doing this is obvious: we need to do _something_ about the Editor data model, so if not this RFC then we need another design anyway. +The impact of not doing this is obvious: we need to do _something_ about the Editor data model, +so if not this RFC then we need another design anyway. -The RFC is targeted at the Bevy Editor, so the change is to be implemented within that crate (or set of crates), with any additional changes to Bevy itself to catter for possible missing features in reflection. The data model constitutes the core of the Bevy Editor so implementation in an ecosystem crate is not suited. +The RFC is targeted at the Bevy Editor, so the change is to be implemented within that crate (or set of crates), +with any additional changes to Bevy itself to catter for possible missing features in reflection. +The data model constitutes the core of the Bevy Editor so implementation in an ecosystem crate is not suited. -- **Alternative**: Use the current `Reflect` architecture as-is, with `Path`-based property manipulation. This is the most simple way, as it avoids the complexity of type layouts and byte stream values (and the `unsafe` associated). The drawback is that calling a virtual (trait) method for each get and set of each value is reasonable for manual edits in the Editor (execution time much smaller than user reaction time) but might become a performance issue when automated edits come into play, like for an animation system with a timeline window allowing to scroll current time and update all animated properties of all objects in the scene at once. +- **Alternative**: Use the current `Reflect` architecture as-is, +with `Path`-based property manipulation. +This is the most simple way, as it avoids the complexity of type layouts and byte stream values (and the `unsafe` associated). +The drawback is that calling a virtual (trait) method for each get and set of each value +is reasonable for manual edits in the Editor (execution time much smaller than user reaction time) +but might become a performance issue when automated edits come into play, +like for an animation system with a timeline window +allowing to scroll current time and update all animated properties of all objects in the scene at once. ## Prior art @@ -504,24 +701,50 @@ Note that while precedent set by other engines is some motivation, it does not o ### Unity3D -The editor of the [Unity3D](https://unity.com/) game engine (or "Unity" for short) is closed-source software, so we can only speculate on how its data model works. - -Some hints (C# attributes mainly) tell us that built-in components are implemented in native C++ while others built-in and third-party ones are implemented in C#. For the native part, we have little information, so we focus here on describing the C# handling. - -For C# components, the editor embeds a custom .NET runtime host based on Mono, which allows it to load any user assembly and extract from it metadata about the user types, and in particular user components. This enables the editor to display in its Inspector window the user components with editing UI widgets to edit component instances. - -Data is serialized and saved as YAML files. Although this process is outside of the scope of this RFC, we can guess from it that the Editor mainly operates on _dynamic types_, and type instances are likely blobs of binary data associated with the fields of those dynamic types. This guess is reinforced by the fact that: - -- the editor is known to be written in C++ and the user code is C# so even if the editor instantiated .NET objects within its Mono runtime it would have to access them from C++ so would need a layer of indirection; -- the editor hot-reloads assemblies on change, so is unlikely to have concrete instantiation of those types, even inside its .NET runtime host, otherwise it would have to pay the cost of managing those instances (destroy and recreate on reload) which is likely slow; -- the on-disk serialized format (YAML) shows types and fields referenced by name and by UUID to the file where the type is defined, but does not otherwise contain any more type information; -- the immediate-mode editor UI customization API has some `SerializedProperty` type with generic getter/setter functions for all common data types (bool, string, float, _etc._), and that interface gives access to most (all?) instances of the data model via a unified data-oriented API. - -Unity notably supports hot-reloading user code (C# assemblies) during editing and even while the Game is playing in Play Mode inside the Editor. It also supports editing on the fly data while the Game is running in that mode, via its Inspector window. This is often noted as a useful features, and hints again at a data-oriented model. +The editor of the [Unity3D](https://unity.com/) game engine (or "Unity" for short) is closed-source software, +so we can only speculate on how its data model works. + +Some hints (C# attributes mainly) tell us that built-in components are implemented in native C++ +while others built-in and third-party ones are implemented in C#. +For the native part, we have little information, so we focus here on describing the C# handling. + +For C# components, the editor embeds a custom .NET runtime host based on Mono, +which allows it to load any user assembly and extract from it metadata about the user types, +and in particular user components. +This enables the editor to display in its Inspector window the user components with editing UI widgets +to edit component instances. + +Data is serialized and saved as YAML files. +Although this process is outside of the scope of this RFC, we can guess from it that the Editor mainly operates on _dynamic types_, +and type instances are likely blobs of binary data associated with the fields of those dynamic types. +This guess is reinforced by the fact that: + +- the editor is known to be written in C++ and the user code is C# +so even if the editor instantiated .NET objects within its Mono runtime +it would have to access them from C++ so would need a layer of indirection; +- the editor hot-reloads assemblies on change, +so is unlikely to have concrete instantiation of those types, +even inside its .NET runtime host, +otherwise it would have to pay the cost of managing those instances +(destroy and recreate on reload) which is likely slow; +- the on-disk serialized format (YAML) shows types and fields referenced by name +and by UUID to the file where the type is defined, +but does not otherwise contain any more type information; +- the immediate-mode editor UI customization API has some `SerializedProperty` type +with generic getter/setter functions for all common data types (bool, string, float, _etc._), +and that interface gives access to most (all?) instances of the data model +via a unified data-oriented API. + +Unity notably supports hot-reloading user code (C# assemblies) during editing +and even while the Game is playing in Play Mode inside the Editor. +It also supports editing on the fly data while the Game is running in that mode, +via its Inspector window. +This is often noted as a useful features, and hints again at a data-oriented model. ### Our Machinery -[Our Machinery](https://ourmachinery.com/) has a few interesting blog posts about the architecture of the engine. Notably, a very detailed and insightful blog post about their data model. +[Our Machinery](https://ourmachinery.com/) has a few interesting blog posts about the architecture of the engine. +Notably, a very detailed and insightful blog post about their data model. @@ -533,7 +756,9 @@ The data model explicitly handles _prefabs_ as well as change notifications. > In addition to basic data storage, The Truth has other features too, such as sub-objects (objects owned by other objects), references (between objects), prototypes (or “prefabs” — objects acting as templates for other objects) and change notifications. -A core goal is atomicity of locking for multi-threaded data access; writing only locks the object being written, and other objects can continue being accessed in parallel for read operations. +A core goal is atomicity of locking for multi-threaded data access; +writing only locks the object being written, +and other objects can continue being accessed in parallel for read operations. They list the following things the data model helps with: @@ -543,7 +768,8 @@ They list the following things the data model helps with: - Real-time collaboration since it's pure data and can be serialized easily, so can be networked. - Diffs/merges easily with VCS. -They also list some bad experiences with the Bitsquid data model (a predecessor engine from the same devs), which led them to design their current centralized in-memory data model: +They also list some bad experiences with the Bitsquid data model (a predecessor engine from the same devs), +which led them to design their current centralized in-memory data model: - disk-based data model like JSON files can't represent undo/redo and copy/paste, leading to duplicated implementations for each system. - string-based object references are not semantically different from other strings in a JSON-like format, so cannot be reasoned about without semantic context, and internal references inside the same file are ill-defined. @@ -553,31 +779,63 @@ They also list some bad experiences with the Bitsquid data model (a predecessor [🎞 _GDC 2017 - Creating a Tools Pipeline for Horizon: Zero Dawn_](https://www.youtube.com/watch?v=KRJkBxKv1VM) -Guerrilla Games started to rebuild their entire tools pipeline during production of _Horizon: Zero Dawn_. The aim was to replace a set of disparate tools, including a custom Maya plugin embedding the renderer of _Decima_ (their proprietary game engine); they state Maya is not suited for level editing. +Guerrilla Games started to rebuild their entire tools pipeline during production of _Horizon: Zero Dawn_. +The aim was to replace a set of disparate tools, +including a custom Maya plugin embedding the renderer of _Decima_ (their proprietary game engine); +they state Maya is not suited for level editing. #### General architecture -Given their various constraints (game already in production, rebuilding everything from scratch, wanted to reuse Decima's libraries), they decided to go for a tools framework built on top of the Decima code. In particular, they reuse the existing Decima RTTI (reflection) system. Their focus is also on a limited subset of a full-featured editor: object creation and placement. Nonetheless, they approached the design with the intent to eventually replace all other tools, therefore their design decisions are worth considering. +Given their various constraints +(game already in production, rebuilding everything from scratch, wanted to reuse Decima's libraries), +they decided to go for a tools framework built on top of the Decima code. +In particular, they reuse the existing Decima RTTI (reflection) system. +Their focus is also on a limited subset of a full-featured editor: +object creation and placement. +Nonetheless, they approached the design with the intent to eventually replace all other tools, +therefore their design decisions are worth considering. In detail, the following choices were made: 1. Integrate limited sub-systems OR the full game - - Limited sub-systems is purpose built, controllable, fast to build, but adds a new codepath separate from the game and encourages visual and behavioral disparities. - - Full game makes available all game systems under a single unified codepath. In their case, it also adds for free their in-game DebugUI system. However there is an overhead to running all systems of an entire game all the time for any editing action only concerned by a few of them. + - Limited sub-systems is purpose built, controllable, fast to build, + but adds a new codepath separate from the game and encourages visual and behavioral disparities. + - Full game makes available all game systems under a single unified codepath. + In their case, it also adds for free their in-game DebugUI system. + However there is an overhead to running all systems of an entire game all the time + for any editing action only concerned by a few of them. Due to time constraints, they decided to integrate the full game inside the Editor. 2. In-process OR out-of-process - - In-process (game and editor share the same execution process) is fast to setup, allows direct and synchronous access to game data from the editor. The drawbacks are instabilities (game crash = editor crash = loss of work) and a high difficulty to enforce code separation. - - Out-of-process (game process and editor process run separately) enables stability, cleaner separated code, and more responsive UI (game performance has no impact on the editor UI, provided CPU not starved). However the inital development is more complex, and it's harder to debug 2 asynchronous processes. + - In-process (game and editor share the same execution process) is fast to setup, + allows direct and synchronous access to game data from the editor. + The drawbacks are instabilities (game crash = editor crash = loss of work) + and a high difficulty to enforce code separation. + - Out-of-process (game process and editor process run separately) enables stability, + cleaner separated code, + and more responsive UI (game performance has no impact on the editor UI, provided CPU not starved). + However the inital development is more complex, and it's harder to debug 2 asynchronous processes. - In the end, they decided to go for in-process due to time constraints, but with a design that allows future out-of-process by enforcing a very strict code separation between Editor and Game. To that end, we can consider they conceptually went with an out-of-process solution. + In the end, they decided to go for in-process due to time constraints, + but with a design that allows future out-of-process by enforcing a very strict code separation between Editor and Game. + To that end, we can consider they conceptually went with an out-of-process solution. #### Editor model -The editor supports play-time editing, whereby the game is paused and some edits are made to a level, before the game is resumed on that newly-edited level. However the game uses baked data (called _converted_ in the presentation), and baking is a destructive process which loses some information necessary for editing (like, objects hierarchies of static objects before it's flattened, to allow local-space editing). To work around this issue, they introduce an Editor Node, which contains the edit-time representation of (and maps 1:1 with) a runtime (baked) game object. This defines their data model for the editor, with a one-way relationship Editor -> Game. So Game code doesn't have any Editor-specific code. They build 2 separate hierarchies of Editor Nodes, one for the Editor to manipulate, and one tied with the Game, to enforce code separation (and later allow out-of-process). +The editor supports play-time editing, whereby the game is paused and some edits are made to a level, +before the game is resumed on that newly-edited level. +However the game uses baked data (called _converted_ in the presentation), +and baking is a destructive process which loses some information necessary for editing +(like, objects hierarchies of static objects before it's flattened, to allow local-space editing). +To work around this issue, they introduce an Editor Node, +which contains the edit-time representation of (and maps 1:1 with) a runtime (baked) game object. +This defines their data model for the editor, with a one-way relationship Editor -> Game. +So Game code doesn't have any Editor-specific code. +They build 2 separate hierarchies of Editor Nodes, one for the Editor to manipulate, +and one tied with the Game, to enforce code separation (and later allow out-of-process). They mention some specific non-goals: @@ -592,11 +850,25 @@ Instead they aimed at: - Directly modifying object members. - Re-using the editing and baking tools already existing to create/transform data. -The data model allows object changes to the Game by generating _transactions_ applied to the Node Tree of the Editor itself, which then broadcasts those changes to the Node Tree of the Game, which applies them to the actual runtime game objects. This allows making _e.g._ a position change in local space (relative to parent object) in the Editor, where the hierarchy is known, which is transformed into a world-space transaction sent to the Game which apply them to baked that (which don't have any hierarchy info left). - -When the change originates from the Game itself, like object placement / vegetation painting / terrain scuplting inside the rendered viewport, the Game sends back an _intention_ that is sent to the Editor. Then the Editor applies it like any other modification. This keeps the Editor authority over its Node Tree, keeps the flow of changes from Editor to Game, and prevents infinite loops (Game modified Editor data, which applies them to Game, _etc._). - -For newly-added objects, they go into details on how they generate a patch file for the Game then apply it while the Game is paused. This feature is named _GameSync_ and has the following reported properties: +The data model allows object changes to the Game by generating _transactions_ +applied to the Node Tree of the Editor itself, +which then broadcasts those changes to the Node Tree of the Game, +which applies them to the actual runtime game objects. +This allows making _e.g._ a position change in local space (relative to parent object) in the Editor, +where the hierarchy is known, +which is transformed into a world-space transaction sent to the Game +which apply them to baked that (which don't have any hierarchy info left). + +When the change originates from the Game itself, +like object placement / vegetation painting / terrain scuplting inside the rendered viewport, +the Game sends back an _intention_ that is sent to the Editor. +Then the Editor applies it like any other modification. +This keeps the Editor authority over its Node Tree, keeps the flow of changes from Editor to Game, +and prevents infinite loops (Game modified Editor data, which applies them to Game, _etc._). + +For newly-added objects, they go into details on how they generate a patch file for the Game +then apply it while the Game is paused. +This feature is named _GameSync_ and has the following reported properties: - ➕ Works on all changes throughout the Editor - ➕ Works on target platform (console) @@ -606,7 +878,8 @@ For newly-added objects, they go into details on how they generate a patch file - ➖ Crashes, inconsistencies - ➖ Low level of confidence from users -From those conclusions, including the statement that they wish to rewrite GameSync in the future, we can conclude this feature represents a challenging task. +From those conclusions, including the statement that they wish to rewrite GameSync in the future, +we can conclude this feature represents a challenging task. Finally, the Editor data model allows them to expose a Python API to build new tools easily. @@ -619,7 +892,14 @@ They also specifically list requirements for an Undo system: - Didn't want the possibility of undo not being symmetric to redo - Didn't want to have to write new undo/redo functions for each new feature -The ideal undo system they aimed at can automatically track changes and how to revert them. They achieve this with manual tagging for start and end of modification. Change detection is based on reflection (RTTI) and builds a list of commands (add object, set value, _etc._) that can be easily reverted (remove object, set old value, _etc._). The system includes a notification mechanism for any system to observe changes to objects, which helps system decoupling and prevent duplicated functionalities. They explicitly mention a Model-View-Controller (MVC) pattern. +The ideal undo system they aimed at can automatically track changes and how to revert them. +They achieve this with manual tagging for start and end of modification. +Change detection is based on reflection (RTTI) +and builds a list of commands (add object, set value, _etc._) +that can be easily reverted (remove object, set old value, _etc._). +The system includes a notification mechanism for any system to observe changes to objects, +which helps system decoupling and prevent duplicated functionalities. +They explicitly mention a Model-View-Controller (MVC) pattern. They list some pros and cons of this approach: @@ -635,7 +915,9 @@ They list some pros and cons of this approach: - What parts of the design do you expect to resolve through the implementation of this feature before the feature PR is merged? - What related issues do you consider out of scope for this RFC that could be addressed in the future independently of the solution that comes out of this RFC? --> -1. How do we store string instances? Storing a `String` requires an extra heap allocation per string and makes `GameObject` non-copyable. Interning and using an ID would solve this. +1. How do we store string instances? Storing a `String` requires an extra heap allocation per string +and makes `GameObject` non-copyable. +Interning and using an ID would solve this. ## Future possibilities @@ -652,7 +934,9 @@ is not a reason to accept the current or a future RFC; such notes should be in the section on motivation or rationale in this or subsequent RFCs. If a feature or change has no direct value on its own, expand your RFC to include the first valuable feature that would build on it. --> -Because the Editor data model provides a centralized source of truth for all editing systems, this enables building a unique undo/redo system and a unique copy/paste system shared by all other editing systems, without the need for each of those editing systems to design their own. +Because the Editor data model provides a centralized source of truth for all editing systems, +this enables building a unique undo/redo system and a unique copy/paste system shared by all other editing systems, +without the need for each of those editing systems to design their own. I expect the following future RFCs to build on top of this one: From b5fbabd04e47c9f7abb1770495518360114c5f98 Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Thu, 18 Aug 2022 22:57:32 +0100 Subject: [PATCH 13/17] Fixes from PR comments --- rfcs/62-editor-data-model.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rfcs/62-editor-data-model.md b/rfcs/62-editor-data-model.md index 67191214..52c57453 100644 --- a/rfcs/62-editor-data-model.md +++ b/rfcs/62-editor-data-model.md @@ -482,8 +482,6 @@ Properties themselves are defined by a name and the type of their value. The type also stores the byte offset of that property from the beginning of a struct instance, which allows packing property values for a struct into a single byte stream, and enables various optimizations regarding diff'ing/patching and serialization. -Modifying any of the `name`, `value_type`, or `byte_offset` constitute a type change, -which mandates migrating all existing instances. ```rust struct Property { @@ -508,13 +506,13 @@ nor any equality operator (`PartialEq` and `Eq` in Rust). It also enables efficient copy and serialization via direct memory reads and writes, instead of relying on getter/setter methods. -The `Property` struct contains also an optional validation function +The `Property` type contains also an optional validation function used to validate the value of a property for things like range and bounds check, nullability, _etc._ which is used both when the user (via the Editor UI) attempts to set a value, and when a value is set automatically (_e.g._ during deserialization). ```rust -trait Validate { +trait ValidateProperty { /// Check if a value is valid for the property. fn is_valid(&self, property: &Property, value: &[u8]) -> bool; @@ -525,7 +523,7 @@ trait Validate { ### Game objects -Instances of any type are represented by the `GameObject` component: +Instances of any type of the Game are represented by the `GameObject` component: ```rust #[derive(Component)] From 87e103b972c6d832cc51167a6bdf13ea14f220ea Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Mon, 18 Dec 2023 18:42:50 +0000 Subject: [PATCH 14/17] Rewrite the Motivation section --- rfcs/62-editor-data-model.md | 147 +++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 17 deletions(-) diff --git a/rfcs/62-editor-data-model.md b/rfcs/62-editor-data-model.md index 52c57453..edcdfe08 100644 --- a/rfcs/62-editor-data-model.md +++ b/rfcs/62-editor-data-model.md @@ -3,7 +3,8 @@ ## Summary This RFC describes the data model and API that the Editor uses in memory, -and the basis for communicating with the Game process and for serializing that data to disk +and the basis for communicating with the Game process +and for serializing that data to disk (although the serialization format to disk is outside of the scope of this RFC). The data model is largely inspired by the one of [Our Machinery](https://ourmachinery.com/), as described in [this blog post](https://web.archive.org/web/20220727114600/https://ourmachinery.com/post/the-story-behind-the-truth-designing-a-data-model/). @@ -20,18 +21,130 @@ In this RFC, the following terms are used: ## Motivation -The Bevy Editor needs a _data model_, that is a way to organize the data it manipulates, -and more precisely here the Game data that it creates and edits -for the purpose of transforming it into a final shippable form to be loaded by the Game at runtime. -The data model defines the structure of this data, and the APIs to manipulate it. - -We want to make the Editor data model decoupled from the runtime data model that the Game uses -to avoid the Editor depending on the Game. -This avoids having to rebuild the Editor, or even restart its process, when the Game code changes. - -We also want to make that data structured in the Editor data model format _transportable_, -that is serializable with the intent to transport it over any inter-process communication (IPC) mechanism, -so that the Editor process can dialog with the Game process. +### Editor MVP + +The Bevy Editor is an application focusing on visual editing of Bevy scenes, +which are collections of entities and components. +As an MVP (Minimal Valuable Product), this is its primary function. + +There are currently two major classes of approaches being considered for the Editor: + +- embedding the Editor into the Game itself, for example as a `Plugin`; and +- having a separate Editor application independent from the Game. + +This RFC assumes the Editor is a standalone application. +The motivating argument is that an Editor for a game engine +is typically a standalone application downloaded and installed by the user, +and reused multiple times to create many different Games. +This is the case for all the major game engines (Unity3D, UnrealEditor, Godot). +The data model described by this RFC could be use with an embedded Editor, +although some of its rationales might be less relevant in that scenario. + +In the context of a standalone Editor application, +the ability to quickly iterate on saving the scene +and reloading it inside the (separate) runtime Game application +is also generally considered part of an MVP. +This is to ensure some decent productivity for the Editor user. + +In any case however, editing a scene includes the ability to: + +- Create new empty scenes +- Save to disk and reload the scene +- Add entities and components to the scene +- Visualize the list of existing entities and their components +- Inspect the data associated with components, modify that data with a UI +- Visual placement in 3D (for entities with a `Transform` component) + is generally considered an MVP feature too +- Undo / redo actions + +This RFC argues that the latter point (undo/redo) is critical for an MVP, +as editing without Undo can quickly become as painful as building a scene from code. +A workaround could be to reload from disk, but an MVP won't focus on load speed, +so this can quickly become a time sink. + +### Undo / redo + +Undo / redo systems can take many forms. +In its simplest form, +an _ad hoc_ system allows undos and redos for a specific type of editing. +For example, adding an entity or component can have a simple undo system +which deletes that item. +For redo, things are slightly more complex, +as the system needs to remember how to re-create the entity or component. +Again, there are various options here, +but they all require some form of serializing of the data to save it for a later redo. + +Ad hoc systems generally have the advantage of being simpler, +as they're focused on a single type of editing. +For example, an undo / redo system for adding and removing components +only requires being able to manipulate components, +and doesn't care for example about manipulating audio clips or animation tracks. +However, the multiplying of those specialized systems means that +the same kind of code is written again and again with slight variations, +as all undo / redo systems need some form of diffing for example. + +An alternative design to undo / redo solving this issue +is to create a global undo system for any kind of user action in the Editor. +This offers several advantages: + +- Single code to maintain and debug +- Consistent user experience (key binding, undo UI, _etc._) +- Adding support for a new kind of edits is vastly simpler, + as it requires only an adapter to the global undo / redo system, + but most other functions are already shared and available. +- Ability to have a single unified _command palette_, + which shows all actions (edits) available to the user, + and is as such a great tool for learning. + +This global approach does bear however the inconvenient of an initial larger effort +to build such global system and its abstraction of all user actions. + +Because of the advantages mentioned above, +this RFC proposes that a global unified undo / redo system be adopted for the Editor. + +### Data Model + +The _data model_ defines the abstraction that the Editor uses +to represent the user actions and manipulate the scene data as a result. + +An abstraction is desirable for several reasons: + +- As a separate process, the Editor doesn't have direct knowledge of the Game types. + This means for example that it doesn't know any custom Game component. + However the user still expects being able to edit their own components, + in addition to the Bevy built-in ones. + So those custom components need to be abstracted in a way the Editor can edit them + without having to load and execute the actual Game code inside the Editor process, + which would pose many issues with Rust compiler version, dynamic loading, _etc._. +- The undo / redo system should ideally be able to work with any kind of action/edit, + without having to know the underlying types those actions apply to. + This ability greatly simplifies writting such a system, + and make it automatically extensible to new actions in the future. +- Similarly, any system working globally, like a command palette, + becomes automatically extensible by using a single shared abstraction. +- Looking forward, having an abstraction which allows serializing user actions + enables _transporting_ those actions and executing them somewhere else. + In the context of an Editor talking to a live Game process, + the abstaction enables building some form of inter-process communication (IPC) mechanism, + which allows live editing, both locally and remotely (server, console devkit, ...). + +Based on this abstraction, the overall editing process for users becomes: + +1. Users generate actions (click, _etc._) to edit the scene data. +1. Those actions produce _edits_, with associated editing data. + For example, adding a new component generates an "Add Component" edit, + which contains the type of component to add and the target entity to add it to. +1. Edits are optionally recorded by the undo / redo system for later undo. + That system can also act as an edit producer during redo. +1. Edits are finally consumed by the Editor and applied to the scene data to modify it. + +Note that this data model abstraction stragegy is the same strategy adopted by `serde` +to ensure different incompatible formats of serialization and deserialization +can all work together and manipulate the same data, +without requiring all formats to understand each other. +See the [Serde data model](https://serde.rs/data-model.html) for reference. +To some extent, it's also similar to how `DynamicScene` represents a scene +without actually knowing at build time the types of its components. ## User-facing explanation @@ -651,10 +764,10 @@ and would be best addressed by a separate RFC focused on that interaction. The data model adds a layer of abstraction over the "native" data the Game uses, which makes most operations indirected and more abstract. -For example, setting a value involves invoking a setter method -(or equivalent binary offset write), -possibly after locking an object for write access, -as opposed to simply assigning the value to the object (`c.x = 5;`). +For example, setting the `Transform` of a component involves creating an edit +and associating the value the user wants to assign, +then letting the Editor apply that edit to the actual component instance, +as opposed to simply accessing the component directly through the `World`. ## Rationale and alternatives From a1dac3189238a9135f4e9a58ff03f60ceb193098 Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Thu, 21 Dec 2023 18:52:37 +0000 Subject: [PATCH 15/17] Rewrite user facing explanations and part of implementation --- rfcs/62-editor-data-model.md | 762 ++++++++++++----------------------- 1 file changed, 249 insertions(+), 513 deletions(-) diff --git a/rfcs/62-editor-data-model.md b/rfcs/62-editor-data-model.md index edcdfe08..a763daf8 100644 --- a/rfcs/62-editor-data-model.md +++ b/rfcs/62-editor-data-model.md @@ -143,149 +143,12 @@ to ensure different incompatible formats of serialization and deserialization can all work together and manipulate the same data, without requiring all formats to understand each other. See the [Serde data model](https://serde.rs/data-model.html) for reference. -To some extent, it's also similar to how `DynamicScene` represents a scene -without actually knowing at build time the types of its components. +For Bevy, we already have the `Reflect` API and the `DynamicScene` type, +which allow manipulating objects in a generic way +without actually knowing at build time the types being edited. ## User-facing explanation -The Bevy Editor creates and edits Game data to build scenes and other assets, -and eventually organize all those Game assets into some cohesive game content. - -### Type exchange - -The Editor and the Game have different data models. -The Game data is baked into a format optimized for runtime consumption -(fast loading from persistent storage and minimal transformation before use), -often with platform-specific choices. -In contrast, the Editor manipulates a higher-level format independent of the Game itself or the target platform. -Therefore the two must exchange Game types and data to enable conversions -between the data encoded in the two data models. -Because the Game and the Editor are separate processes, -this exchange occurs through an inter-process communication (IPC) mechanism. - -During development, the Game links and uses the `EditorClientPlugin`. -This is a special plugin provided as part of the Editor ecosystem, -and which enables the Game and the Editor to communicate via IPC. -This plugin is not linked in release builds, and therefore does not ship with the Game. -The Game only loads the `EditorClientPlugin` when launched into a special _edit mode_ -via a mechanism outside of the scope of this RFC (possibly some environment variable, -or command-line argument, or any other similar mechanism). - -When writing the Game code, the Author(s) define new Rust types to represent components, -resources, _etc._ specific to that Game (for example, `MainPlayer` or `SpaceGun`). -Because the Editor does not depend on the Game, those Game types are sent at runtime to the Editor via IPC. -This allows the Editor to understand the binary format of the Game objects -and convert them into its data model and back. - -### Editing workflow - -The general editing workflow is as follows: - -- The Author starts the Editor executable and create a new project or loads an existing project. -- The Editor launches the Game executable in _edit mode_ to use the `EditorClientPlugin` for Game/Editor communication. -- The Editor queries via IPC the Game for all its Game types, including components and resources. -- The Editor builds a database of all the (dynamic) types received, -and make them available through the type API of its data model. -- The Author creates new object instances from any of those types, -and manipulates the data of those instances via the various Editor functionalities, -themselves using the data model API of the Editor. -- Optionally, the Author saves the project, -which makes the Editor serialize the data model to persistent storage -for later Editor sessions on the same project. - -### Data model - -The _data model_ of the Editor refers to the way the Editor manipulates this Game data, -their representation in memory while the Editor executable is running, -and the API to do those manipulations. -The Editor at its core defines a centralized data model, -and all the ECS systems of the Editor use the same API to access that data model, -making it the unique source of truth while the Editor process is running. -This unicity prevents desynchronization between various subsystems, -since they share a same view of the editing data. -It also enables global support for common operations such as copy/paste and undo/redo, -since all ECS systems will observe the same copy/paste/undo/redo changes to that unique source of truth. - -When writing the Game code, the Author(s) define new Rust types -to represent components, resources, _etc._ specific to that Game (for example, `MainPlayer` or `SpaceGun`). -At runtime, those Rust types are used to allocate _instances_ (allocated objects). -The representation of the instances in memory -(_e.g._ `SpaceGun.fireSpeed` field takes 4 bytes starting at 28 bytes from the start of the object instance), -also called the _layout_, -is optimal in terms of read and write access speed by the CPU, -but it is _static_, that is the type layout is defined during compilation and cannot be changed afterward. - -In contrast, we want to enable the Editor to observe changes in the Game code without rebuilding or even restarting the Editor process, -in order to enable fast iteration for the Authors. -This feature, referred to as _hot-reloading_, requires the Editor to support type changes, -which is not possible with static types like the Game uses. -Therefore, to allow this flexibility, the Editor data model is based on _dynamic types_ -defined exclusively during Editor execution. -The data model defines an API to instantiate objects from dynamic types, -manipulates the data of those instances, -but also modify the types themselves while objects are instantiated. -This latter case is made possible through a set of migration rules -allowing to transform an object instance from a version of a type -to another modified version of that type. - -The data model supports various intrinsic types: boolean, integral, floating-point, strings, -arrays, dictionaries, enums and flags, and objects (references to other types). -Objects can be references to entities or components or resources. -Dynamic types extend those by composition; -they define a series of _properties_, defined by their name and a type themselves. -Conceptually, a property can look like (illustrative pseudo-code): - -```txt -type "MyVec2" { - property x : float32, - property y : float32, -} - -type "MyPlayerComponent" { - // owned data - property "name" : string - property "health" : float32 - property "forward": type "MyVec2" - // references to other objects - property "target" : entity // reference to another Entity - property "text" : ref type "Text" // reference to a Text component -} -``` - -_Note:_ An `Entity` in Bevy is just an index, so is conceptually already a reference. -The `Entity` type doesn't store the entity itself and its component; -it stores the index (reference) to it. -This is why the pseudo-code above doesn't read `ref entity`, which would be redundant. - -The data model offers an API (the _object API_) to manipulate object instances: - -- get the type of an object instance -- get and set property values of a type instance -- lock an object for exclusive write access; -this allows making a set of writes transactional (see below for details) - -The object API is most commonly used to populate a User Interface (UI) with data. -That UI allows the Authors to edit the data via the Editor itself. -It's also used by Editor systems to manipulate object instances where needed. - -The data model also offers an API (the _property API_) to manipulate the types themselves: - -- get a type's name -- enumerate the properties of a type -- add a new property -- remove an existing property - -The property API is most commonly used when reloading the Game after editing its code and rebuilding it, -to update the Editor's dynamic types associated with the custom Game types. - -The data model guarantees the integrity of its data by allowing concurrent reading of data -but ensuring exclusive write access. -This allows concurrent use of the data, for example for remote collaboration, -so long as write accesses to objects are disjoints. -Each object for which write access was given is locked for any other access. -This is similar in concept to the Rust borrow model, -and to that respect the Editor data model plays the role of the Rust borrow checker. - -## Implementation strategy +The Editor data model is the API by which the various Editor parts edit the Game data. +It's a global abstraction of the various data sources (scenes, assets, ...), +and the various editing systems (3D view, `Parent`-based hierarchy, asset inspector, ...) manipulating them. - +- a 3D view allowing the user to place objects in space, + which requires editing the `Transform` of entities. +- an asset inspector to list the various `Asset`s in use by the Game, + and edit their import settings (`.meta` files). +- a hierarchy view to group and reorganize entities based on their `Parent` hierarchy. -### Names +This abstracted editing model offers many advantages: -Being data-heavy, and forfeiting the structural access of Rust types, -the data model makes extensive use of strings to reference items. -In order to make those operations efficient, we define a `Name` type to represent those strings. -The implementation of `Name` is outside of the scope of this RFC and left as an optimization, -and should be considered as equivalent to a `String` wrapper: +- Robust: the sharing of common steps like edit diffing means less code to maintain, + and less code to test and debug. +- Extensible: new data sources and new editing systems can easily be added, + without the need for changing existing ones. +- Observable: an editing system can list all possible registered editing actions + (command palette), + or observe all data changes, and record and replay them + (undo / redo system, automation / scripting system). + This replay can also be remote (live editing). -```rust -struct Name(pub String); -// -or- -type Name = String; -``` +### Game type discovery -The intent is to explore some optimization strategies like string interning for fast comparison. -Those optimizations are left out of the scope of this RFC. +In order to allow the user to edit custom Game types (components, assets, ...), +the Editor needs to discover those types. -### Built-in types +There are two main strategies for this: -The data model supports a number of built-in types, corresponding to common Rust built-in types. +1. The Editor builds the Game code and extracts from it metadata about the types. + This requires some tooling to access the type system of a Rust build. + However, this provides the best user experience, as discovery is automated + and exhaustive. -```rust -enum SignedIntegralType { - Int8, Int16, Int32, Int64, Int128, -} +2. The Editor runs the Game in a separate process, and communicates with it, + requesting a serialized dump of all registered types. + This is somewhat easier to implement that the previous strategy, + in particular because Bevy requires registering all types before starting the app, + so an `EditorPlugin` could automatically read the `TypeRegistry` once the app runs. + However, from a user experience perspective, + this means types have to be actively registered before the Editor can "see" them. -enum UnsignedIntegralType { - Uint8, Uint16, Uint32, Uint64, Uint128, -} +Once the types are gathered, they are serialized and sent to the Editor. +This makes them available to the data model API +to create instances of those types, and edit them. -enum FloatingType { - Float32, Float64, -} +### Data sources -enum SimpleType { - Bool, - Int8, Int16, Int32, Int64, Int128, - Uint8, Uint16, Uint32, Uint64, Uint128, - Float32, Float64, -} +The three built-in Editor data sources are: -enum BuiltInType { - Bool, - Int8, Int16, Int32, Int64, Int128, - Uint8, Uint16, Uint32, Uint64, Uint128, - Float32, Float64, - Name, - Enum(UnsignedIntegralType), // C-style enum, not Rust-style - Array(AnyType), - Dict(AnyType), // key type is Name - ObjectRef(Option), -} -``` +- Scenes +- Components +- Asset import settings (`.meta` files) -_Note_: We explicitly handle all integral and floating-point bit variants -to ensure tight value packing and avoid wasting space -both in structs (`GameObject`; see below) and collections (arrays, _etc._). - -The simple types have a defined byte size: - -| Simple type | Byte size | -|---|---| -| `Bool` | 1 Byte | -| `Int8`, `Uint8` | 1 Byte | -| `Int16`, `Uint16` | 2 Bytes | -| `Int32`, `Uint32` | 4 Bytes | -| `Int64`, `Uint64` | 8 Bytes | -| `Float32` | 4 Bytes | -| `Float64` | 8 Bytes | - -`ObjectRef` is a reference to any object, custom or built-in. -This allows referencing components built-in inside Bevy or the Editor -(TBD depending on design of that part). -The reference needs to be valid (non-null) when the data gets processed for baking, -but can temporarily (including while serialized to disk and later reloaded) -be left invalid (`None`) for editing flexibility. -Nullable references require a `NullableRef` component to be added -to mark the reference as valid even if null. +Scenes are the main editing unit, and contain a collection of entities and components. +Bevy already supports scenes with types unknown at build time +via the `DynamicScene` type and related API. +That API is largely based on the Bevy reflection (`Reflect`) API. -```rust -#[derive(Component)] -struct NullableRef; -``` +Components are structured collections of fields, implementing the `Component` trait. +They can be accessed via the reflection API. -This allows filtering out nullable references, -and collect invalid `ObjectRef` instances easily via a `Query`, -for example to emit some warning to the user. +Assets are serialized to disk accompanied with their import settings. +Those settings are written in `.meta` files alongside the assets. +The import settings are also accessed via the reflection API. -The `AnyType` is defined as either a built-in type -or a reference to a custom Game type (see [Game types](#game-types)). +### Editing systems -```rust -enum AnyType { - BuiltIn(BuiltInType), - Custom(Entity), // with GameType component -} -``` +The Editor is composed of a variety of editing systems. +Each editing system manipulates some Game data via the data model API. +They provide the basic building blocks for the user to edit the Game data. -### Game world +Each editing system uses the same basic workflow. +Let's take the example of a hierarchy panel, +which shows the list of entities in a scene as a hierarchy +based on the relationship defined by the `Parent` component. +The hierarchy panel allows the user to edit this hierarchy by reparenting entities. +To achieve this, the hierarchy panel uses the data model API to: -The data model uses dynamic types, so it can be represented purely with data, -without any build-time types. -Bevy already has an ideal storage for data: the `World` ECS container. -This RFC introduces a new struct to represent the data model, the `GameWorld`: +- query the list of entities in the current scene; +- for each entity, query the data of its `Parent` component (if any); +- set new data to the `Parent` component when the user edits the hierarchy. + +For each user action, the editing system generates an _edit diff_, +which represents the difference between the "before" and "after" state of an object. +For our example, the diff contains the old and new values of the `Parent` component data, +which are the old and new parent entities. +Next, the editing system asks the Editor to apply this diff to a target object, +like for example the `Parent` component of entity `3v0`. +Conceptually, we can represent the edit diff as: -```rust -struct GameWorld(pub World); +```txt +EditDiff { + target: 3v0/Parent + old: 18v0, + new: 34v1, +} ``` -Since the underlying `World` already offers guarantees about concurrent mutability prevention, -via the Rust language guarantees themselves, -those guarantees transfer to the `GameWorld` -allowing safe concurrent read-only access or exclusive write access to all data of the data model. +This diff is used to mutate the actual scene being edited. +It's also recorded by the undo system, to allow the user to undo their action. Because the scene is mutated via diffs only, +there's no concern about ordering multiple Bevy systems mutating it, +which prevents complex coordination issues between various plugins +and simplifies writing robust third-party plugins to extend the Editor. -**TODO - Explain where the `GameWorld` lives. Idea was as a resource on the Editor's `World`, but that means regular Editor systems cannot directly write queries into the `GameWorld` ergonomically. See how extract does it for the render world though.** +Editing components and asset import settings, +or any other kind of data source, +follows the exact same scheme. -### Game types +## Implementation strategy -The game world is populated with the dynamic types -coming from the various sources described in [User-facing explanation](#user-facing-explanation). + -#### Type origin +### Edit diffs -Some marker components are added to the same `Entity` holding the `GameType` itself, -to declare the _origin_ of the type: +Edit diffs representing the difference between two versions of a same object. +they allow a set of basic operations: -```rust -// Built-in type compiled from the Bevy version the Game is built with. -#[derive(Component)] -struct BevyType; +- calculate the difference between two `Reflect` objects, +- apply a diff onto a `Reflect` object to obtain another `Reflect` object, +- serialize and deserialize the diff itself, -// Type from the Game user code of the currently open project. -#[derive(Component)] -struct UserType; -``` +such that calculating the edit diff between an old and a new object, +and applying that diff to the old object, yields the new object. -_Note that the Editor itself may depend on a different Bevy version. The built-in Bevy types of that version are not present in the `GameWorld`; only types from the Game are._ +Diffs in details are out of the scope of this RFC, +which assumes they already exist for both `Reflect` and `DynamicScene`. +See for example [#5563](https://github.com/bevyengine/bevy/issues/5563). -This strategy allows efficiently querying for groups of types, -in particular to update them when the Game or a library is reloaded. +In the following, we assume the existence of an `EditDiff` type representing the diff itself, +and an `EditEvent` type representing the diff and a specific target. ```rust -fn update_game_type_instances( - mut query: Query< - &GameType, - (With, Changed) - >) { - for game_type in query.iter_mut() { - // apply migration rules to all instances of this type - for instance in &mut game_type.instances { - // [...] - } - } +struct EditDiff { + // [...] } -``` - -### Sub-types -Various kinds of types have additional data attached to a separate component specific to that kind. -This is referred to as _sub-types_. - -The sub-type components allow, via the `Query` mechanism, -to batch-handle all types of a certain kind, -like for example listing all enum types: - -```rust -fn list_enum_types(query: Query<&GameType, With>) { - for game_type in query.iter() { - println!("Enum {}", game_type.name); - } +impl EditDiff { + /// Make a new diff from an old and new objects. + fn make(old: &dyn Reflect, new: &dyn Reflect) -> EditDiff; + /// Apply the current diff to a target, yielding a new object. + fn apply(&self, target: &mut dyn Reflect) -> Box; + /// Apply the current diff by in-place mutating a target object. + fn apply_inplace(&self, target: &mut dyn Reflect); } -``` - -#### Enums and flags - -Enums are sets of named integral values called _entries_. -They are conceptually similar to so-called "C-style" enums. -An enum has an underlying integral type called its _storage type_ containing the actual integral value of the enum. -The storage type can be any integral type, signed or unsigned, with at most 64 bits -(so, excluding `Int128` and `Uint128`). -The enum type defines a mapping between that value and its name. -An enum value is stored as its storage type, and is equal to exactly one of the enum entries. -**TODO - think about support for Rust-style enums...** - -Flags are like enums, except the value can be any bitwise combination of entries, -instead of being restricted to a single entry. -The storage type is always an unsigned integral type. - -The `EnumEntry` defines a single enum or flags entry with a `name` -and an associated integral `value`. -The value is encoded in the storage type, -then stored in the 8-byte memory space offered by `value`. -This means reading and writing the value generally involves reinterpreting the bit pattern. - -```rust -struct EnumEntry { - name: Name, - value: u64, +#[derive(Event)] +struct EditEvent { + target: Entity, + diff: EditDiff, } ``` -The `EnumType` component defines the set of entries of an enum or flags, the storage type, -and a boolean indicating whether the type is an enum or a flags. +### Data model API -```rust -#[derive(Component)] -struct EnumType { - entries: Vec, - storage_type: IntegralType, - is_flags: bool, -} -``` +#### `EditScene` -#### Arrays +The primary editing object is the `EditScene`. +Each `EditScene` represents a single scene being edited. +From a user's perspective, this corresponds to an "open" scene document. +The `EditScene` is a Bevy component, and is manipulated with the ECS API; +spawning an entity with an `EditScene` opens a particular scene file, +while despawning it closes the scene. -Arrays are dynamically-sized homogenous collections of _elements_. -The `ArrayType` defines the element type, which can be any valid game type. +The `EditScene` internally wraps a `DynamicScene` describing its content, +and an `AssetPath` defining where that scene is saved as an asset. +It's not an `Asset` itself however, +because the data model needs to control and observe mutating operations +and handle load from and save to disk. ```rust #[derive(Component)] -struct ArrayType { - elem_type: AnyType, +pub struct EditScene { + path: Option, + data: DynamicScene, } -``` -Array values store their elements contiguously in the byte storage (see [Game objects](#game-objects)), -without padding between elements. +impl EditScene { + /// Create a new empty scene. + pub fn new() -> Self; + /// Get the asset path where the scene is saved to, if any. + pub fn path(&self) -> Option<&AssetPath>; + /// Set the asset path where the scene is saved to. + pub fn set_path(&mut self, path: AssetPath); + /// Get read-only access to the scene content. + pub fn data(&self) -> &DynamicScene; + /// Apply a diff to mutate the scene content. + pub fn apply_diff(&mut self, diff: EditDiff); +} -#### Dictionaries +// Example: create a new empty scene with a given path +fn new_scene(mut commands: Commands) { + let mut scene = EditScene::new(); + scene.set_path("scenes/level1.scene"); + commands.spawn(scene); +} -Dictionaries are dynamically-sized homogenous collections mapping _keys_ to _values_, -where each key is unique in the instance. -The key type is always a string (`Name` type). The `DictType` defines the value type. +// Example: enumerate all open scenes +fn list_scenes(q_scenes: Query<&EditScene>) { + for scene in q_scenes { + info!("Scene: {} resources, {} entities, path={:?}", + scene.data().resources.len(), + scene.data().entities.len(), + scene.path()); + } +} -```rust -#[derive(Component)] -struct DictType { - value_type: AnyType, +// Example: save and close a scene +fn save_and_close( + q_scenes: Query<&EditScene>, + mut commands: Commands, + mut events: EventReader, +) { + for ev in events.read() { + let scene_entity = ev.0; + let Ok(scene) = q_scenes.get(scene_entity) else { + continue; + }; + if scene.save().is_ok() { // saves to scene.path() + // Close the scene by destroying the EditScene + commands.entity_mut(scene_entity).despawn_recursive(); + } + } } ``` -Dictionary values store their (key, value) pairs contiguously in the byte storage (see [Game objects](#game-objects)), -key first followed by value, without padding. +As usual, ECS change detection allows a system to be notified +when a scene has been mutated. -#### Structs +#### `EditEvent` -A _struct_ is an heterogenous aggregation of other types (like a `struct` in Rust). -The `StructType` component contains the _properties_ of the struct. +Applying an `EditDiff` to an `EditScene` is done via _events_. +An edit system sends an `EditEvent` with the `EditDiff` to apply +and the target entity owning the `EditScene` to mutate. +The event is sent as usual, typically with `EventWriter`. ```rust -#[derive(Component)] -struct StructType { - properties: Vec, // sorted by `byte_offset` +#[derive(Event)] +struct EditEvent { + target: Entity, + diff: EditDiff, } ``` -Properties themselves are defined by a name and the type of their value. -The type also stores the byte offset of that property from the beginning of a struct instance, -which allows packing property values for a struct into a single byte stream, -and enables various optimizations regarding diff'ing/patching and serialization. +Internally, the Editor runs a system to consume those events +and apply the diffs to the various `EditScene` targets. +This ensures changes to a scene are sequential and ordered. +Careful ordering of editing systems through ECS schedules +allows prioritizing some edits over others. ```rust -struct Property { - name: Name, - value_type: AnyType, - byte_offset: u32, - validate: Option>, +fn apply_scene_edits( + mut q_scenes: Query<&mut EditScene>, + mut reader: EventReader, +) { + for ev in reader { + let Ok(mut scene) = q_scenes.get_mut(ev.target) else { + continue; + }; + scene.apply_diff(ev.diff); + } } ``` -The _type layout_ refers to the collection of property offsets and types of an `StructType`, -which together define: +By leveraging the lower level functionalities `Events`, +and the ordering of ECS schedules, +an edit system can also observe some edits, +for example to record them (undo system), +and rewrite them or inject new edits (redo system). -- the order in which the properties are listed in the struct type (the properties are sorted in increasing byte offset order), and any instance value stored in a `GameObject`. -- the size in bytes of each property value, based on its type. -- eventual gaps between properties, known as _padding bytes_, depending on their offsets and sizes. +------------ +v TODO from here v -Unlike Rust, the Editor data model guarantees that **padding bytes are zeroed**. -This property enables efficient instance comparison via `memcmp()`-like byte-level comparison, -without the need to rely on individual properties inspection -nor any equality operator (`PartialEq` and `Eq` in Rust). -It also enables efficient copy and serialization via direct memory reads and writes, -instead of relying on getter/setter methods. - -The `Property` type contains also an optional validation function -used to validate the value of a property for things like range and bounds check, nullability, _etc._ -which is used both when the user (via the Editor UI) attempts to set a value, -and when a value is set automatically (_e.g._ during deserialization). ```rust -trait ValidateProperty { - /// Check if a value is valid for the property. - fn is_valid(&self, property: &Property, value: &[u8]) -> bool; - - /// Try to assign a value to a property of an instance. - fn try_set(&mut self, instance: &mut GameObject, property: &Property, value: &[u8]) -> bool; +pub struct DataModel { + // [...] } -``` - -### Game objects - -Instances of any type of the Game are represented by the `GameObject` component: - -```rust -#[derive(Component)] -struct GameObject { - game_type: Entity, // with GameType component - values: Vec, -} -``` - -The type of an instance is stored as the `Entity` holding the `GameType`, which can be accessed with the usual `Query` mechanisms: -```rust -fn get_data_instance_type( - q_instance: Query<&GameObject, [...]>, - q_types: Query<&GameType>) -{ - for data_inst in q_instances.iter() { - let game_type = q_types - .get_component::(&data_inst.game_type).unwrap(); - // [...] - } +impl DataModel { + pub fn mutate_scene(&mut self, ) } ``` -The property values are stored in a raw byte array, -at the property's offset from the beginning of the array. -The size of each property value is implicit, based on its type. -Any extra byte (padding byte) in `GameObject.values` is set to zero. -For example: - ```rust -#[repr(C)] // for the example clarity only -struct S { - f: f32, - b: u8, - u: u64, +pub trait Action { + fn exec(&mut self, scene: &EditScene) -> EditDiff; } -``` - -has its values stored in `GameObject.values` as: - -```txt -[0 .. 3 | 4 | 5 ... 7 | 8 .. 11 ] bytes -[ f32 | u8 | padding | u64 ] values -``` -_Note: The Rust compiler, as per Rust language rules, will reorder fields of any non-`#[repr(C)]` type to optimize the type layout and avoid padding in the Game process. This native type layout of each Game type is then transferred to the Editor to create dynamic types. We expect as a result minimal padding bytes, and therefore do not attempt to further tightly pack the values ourselves, for the sake of simplicity._ - -### Migration rules - -The _migration rules_ are a set of rules applied when transforming a `GameObject` from one `GameType` to another `GameType`. -The rules define if such conversion can successfully take place, and what is its result. - -Given a setup where a migration of a `src_inst: GameObject` is to be performed -from a `src_type: GameType` to a `dst_type: GameType` -to produce a new `dst_inst: GameObject`, -the migration rules are: - -1. If `src_type == dst_type`, then set `dst_inst = src_inst`. -2. Otherwise, create a new empty `dst_inst` and then for each property present in either `src_type` or `dst_type`: - - If the property is present on `src_type` only, ignore that property (so it will not be present in `dst_inst`). - - If the property is present on `dst_type` only, add a new property with a default-initialized value to `dst_inst`. - - If the property is present on both, then apply the migration rule based on the type of the property. -3. A value migrated from `src_type` to `dst_type == src_type` is unmodified. -4. A value is migrated from `src_type` to `dst_type != src_type` by attempting to coerce the value into `dst_type`: - - For the purpose of coercion rules, boolean values act like an integral type with a value of 0 or 1. - - Any integral or floating-point value (numeric types) is coerced to the destination property type according to the Rust coercion rules, with possibly some loss. This means there's no guarantee of round-trip. - - Numeric types are coerced into `String` by applying a conversion equivalent to `format!()`. - - String types are coerced into numeric types via the `ToString` trait. - - Enums are coerced into their underlying unsigned integral type first, then any integral coercion applies. - - Integral types can be coerced into an enum provided an enum variant with the same value exists and the integral type can be coerced into the underlying unsigned integral type of the enum. - - A single value is coerced into a single-element array. - - An array of values is coerced into a single value by retaining the first element of the array, if and only if this element exists and has a type that can be coerced into the destination type. - - A single value is coerced into a dictionary with a key corresponding to the coercion to `String` of its value, and a value corresponding to the coercion to the dictionary value type. If any of these fail, the entire coercion to dictionary fails. - - A dictionary with a single entry is coerced into a value by extracting the single-entry value and coercing it, discarding the entry key. If the dictionary is empty, or has more than 1 entry, the coercion fails. - - Coercions from and to `ObjectRef` are invalid. - -_Note: the migration rules are written in terms of two distinct `src_inst` and `dst_inst` objects for clarity, but the implementation is free to do an in-place modification to prevent an allocation, provided the result is equivalent to migrating to a new instance then replacing the source instance with it._ - -### Reflection extraction - -The reflection data needed to create a `GameType` is extracted from the Game process launched by the Editor, -with the help of the `EditorClientPlugin`. -The plugin allows communicating with the Editor process to send it the data needed to create the `GameType`s, -which is constituted of the list of types of the Game, with for each type: - -- its kind (sub-type) -- any additional kind-specific data like the list of properties for a struct - -For properties, the plugin extracts the byte offsets of all properties -by listing the struct fields and using a runtime `offsetof` implementation -like the one proposed by @TheRawMeatball: - -```rust -macro_rules! offset_of { - ($ty:ty, $field:ident) => { - unsafe { - let uninit = MaybeUninit::<$ty>::uninit(); - let __base = uninit.as_ptr(); - let __field = std::ptr::addr_of!((*__base).$field); - __field as usize - __base as usize - } - }; +pub trait DataModel { + // Scenes + fn new_scene(&mut self) -> EditScene; + fn open_scene(&mut self, path: AssetPath) -> Result; + fn close_scene(&mut self, path: AssetPath); + fn scenes(&self) -> impl Iterator; + fn scenes_mut(&mut self) -> impl Iterator; + + // Actions + fn register_action(&mut self, action: Action, name: String); + fn unregister_action(&mut self, name: &str); } ``` -See also the [rustlang discussion](https://internals.rust-lang.org/t/pre-rfc-add-a-new-offset-of-macro-to-core-mem/9273) on the matter. -There is currently no Rust `offset_of()` macro, -and not custom implementation known to work in a `const` context with the `stable` toolchain. -However we only intend to use that macro at runtime, so are not blocked by this limitation. - -The details of: - -1. how to connect to the Game process from the Editor process via the `EditorClientPlugin`, -2. what format to serialize the necessary type data into to be able to create `GameType`s, - -are left outside the scope of this RFC, -and would be best addressed by a separate RFC focused on that interaction. - ## Drawbacks From 58bd05604caad830f6e3786833d902490b2f9c6e Mon Sep 17 00:00:00 2001 From: Jerome Humbert Date: Thu, 21 Dec 2023 22:44:07 +0000 Subject: [PATCH 16/17] Generalize `EditScene` to `EditObject` --- rfcs/62-editor-data-model.md | 195 ++++++++++++++++++----------------- 1 file changed, 101 insertions(+), 94 deletions(-) diff --git a/rfcs/62-editor-data-model.md b/rfcs/62-editor-data-model.md index a763daf8..17e847f8 100644 --- a/rfcs/62-editor-data-model.md +++ b/rfcs/62-editor-data-model.md @@ -318,8 +318,7 @@ Diffs in details are out of the scope of this RFC, which assumes they already exist for both `Reflect` and `DynamicScene`. See for example [#5563](https://github.com/bevyengine/bevy/issues/5563). -In the following, we assume the existence of an `EditDiff` type representing the diff itself, -and an `EditEvent` type representing the diff and a specific target. +In the following, we assume the existence of an `EditDiff` type representing that diff. ```rust struct EditDiff { @@ -334,60 +333,78 @@ impl EditDiff { /// Apply the current diff by in-place mutating a target object. fn apply_inplace(&self, target: &mut dyn Reflect); } - -#[derive(Event)] -struct EditEvent { - target: Entity, - diff: EditDiff, -} ``` ### Data model API -#### `EditScene` +#### `EditObject` -The primary editing object is the `EditScene`. -Each `EditScene` represents a single scene being edited. -From a user's perspective, this corresponds to an "open" scene document. -The `EditScene` is a Bevy component, and is manipulated with the ECS API; -spawning an entity with an `EditScene` opens a particular scene file, -while despawning it closes the scene. +The primary editing object is the `EditObject`. +Each `EditObject` represents a single data source being edited. +From a user's perspective, this corresponds to an "open" document. +The `EditObject` is a Bevy component, and is manipulated with the ECS API; +spawning an entity with an `EditObject` opens a particular asset file, +while despawning it closes the asset. +Saving the asset is a separate action from closing it. -The `EditScene` internally wraps a `DynamicScene` describing its content, -and an `AssetPath` defining where that scene is saved as an asset. +The `EditObject` internally wraps an `EditData` field describing its content, +and an `AssetPath` defining where that data source is saved as an asset. It's not an `Asset` itself however, because the data model needs to control and observe mutating operations and handle load from and save to disk. +The `EditData` is either a `DynamicScene` or a simple `Box`. +This is a workaround for the fact `DynamicScene` doesn't implement `Reflect`. + ```rust +/// Editable data source. +pub enum EditData { + pub Scene(DynamicScene), + pub Reflect(Box), +} + +/// Editing object, representing an asset open for editing. #[derive(Component)] -pub struct EditScene { +pub struct EditObject { path: Option, - data: DynamicScene, + data: EditData, } -impl EditScene { - /// Create a new empty scene. +impl EditObject { + /// Create a new empty `Reflect` object. pub fn new() -> Self; - /// Get the asset path where the scene is saved to, if any. + /// Create a new empty scene. + pub fn new_scene() -> Self; + /// Open an existing asset. + pub fn open(path: AssetPath) -> Self; + /// Get the asset path where the object is saved to, if any. pub fn path(&self) -> Option<&AssetPath>; - /// Set the asset path where the scene is saved to. + /// Set the asset path where the object is saved to. pub fn set_path(&mut self, path: AssetPath); - /// Get read-only access to the scene content. - pub fn data(&self) -> &DynamicScene; - /// Apply a diff to mutate the scene content. + /// Get read-only access to the object content. + pub fn data(&self) -> &EditData; + /// Apply a diff to mutate the object content. pub fn apply_diff(&mut self, diff: EditDiff); } // Example: create a new empty scene with a given path fn new_scene(mut commands: Commands) { - let mut scene = EditScene::new(); + let mut scene = EditObject::new_scene(); + // Setting a path is optional until the scene is first saved. scene.set_path("scenes/level1.scene"); commands.spawn(scene); } +// Example: open a `.meta` file for editing +fn open_meta_asset( + mut commands: Commands, +) { + let obj = EditObject::open("textures/wall.meta"); + commands.spawn(obj); +} + // Example: enumerate all open scenes -fn list_scenes(q_scenes: Query<&EditScene>) { +fn list_scenes(q_scenes: Query<&EditObject>) { for scene in q_scenes { info!("Scene: {} resources, {} entities, path={:?}", scene.data().resources.len(), @@ -398,7 +415,7 @@ fn list_scenes(q_scenes: Query<&EditScene>) { // Example: save and close a scene fn save_and_close( - q_scenes: Query<&EditScene>, + q_scenes: Query<&EditObject>, mut commands: Commands, mut events: EventReader, ) { @@ -407,23 +424,26 @@ fn save_and_close( let Ok(scene) = q_scenes.get(scene_entity) else { continue; }; - if scene.save().is_ok() { // saves to scene.path() - // Close the scene by destroying the EditScene + // Save the scene as asset to its scene.path() + if scene.save().is_ok() { + // Close the scene by destroying the EditObject commands.entity_mut(scene_entity).despawn_recursive(); } } } ``` -As usual, ECS change detection allows a system to be notified -when a scene has been mutated. +As usual, ECS change detection (`Added`, `Changed`, `RemovedComponent`, _etc._) +allows a system to be notified when a scene has been added (= opened for edits), +mutated, or removed (= closed). #### `EditEvent` -Applying an `EditDiff` to an `EditScene` is done via _events_. +Applying an `EditDiff` to an `EditObject` is done via _events_. An edit system sends an `EditEvent` with the `EditDiff` to apply -and the target entity owning the `EditScene` to mutate. -The event is sent as usual, typically with `EventWriter`. +and the target entity owning the `EditObject` to mutate. +The event is sent with the usual event mechanisms, +typically through the use of `EventWriter`. ```rust #[derive(Event)] @@ -433,78 +453,68 @@ struct EditEvent { } ``` +The use of events allows many systems to collaborate on edits, +by producing `EditEvent`s and/or consuming them, +while benefiting from the existing event machinery +to pipeline those edits into a single stream of changes, +and keep the overall architecture extensible to new features. + Internally, the Editor runs a system to consume those events -and apply the diffs to the various `EditScene` targets. -This ensures changes to a scene are sequential and ordered. +and apply the diffs to the various `EditObject` targets. Careful ordering of editing systems through ECS schedules -allows prioritizing some edits over others. +allows prioritizing some edits over others if needed, +for example to ensure saving to an asset only after all edits are applied. ```rust -fn apply_scene_edits( - mut q_scenes: Query<&mut EditScene>, +fn apply_edits( + mut q_objects: Query<&mut EditObject>, mut reader: EventReader, ) { for ev in reader { - let Ok(mut scene) = q_scenes.get_mut(ev.target) else { + let Ok(mut obj) = q_objects.get_mut(ev.target) else { continue; }; - scene.apply_diff(ev.diff); + obj.apply_diff(ev.diff); } } ``` By leveraging the lower level functionalities `Events`, and the ordering of ECS schedules, -an edit system can also observe some edits, +an edit system can also observe all edits, for example to record them (undo system), and rewrite them or inject new edits (redo system). ------------- -v TODO from here v - - -```rust -pub struct DataModel { - // [...] -} - -impl DataModel { - pub fn mutate_scene(&mut self, ) -} -``` - -```rust -pub trait Action { - fn exec(&mut self, scene: &EditScene) -> EditDiff; -} - -pub trait DataModel { - // Scenes - fn new_scene(&mut self) -> EditScene; - fn open_scene(&mut self, path: AssetPath) -> Result; - fn close_scene(&mut self, path: AssetPath); - fn scenes(&self) -> impl Iterator; - fn scenes_mut(&mut self) -> impl Iterator; - - // Actions - fn register_action(&mut self, action: Action, name: String); - fn unregister_action(&mut self, name: &str); -} -``` - ## Drawbacks ### Abstraction complexity -The data model adds a layer of abstraction over the "native" data the Game uses, -which makes most operations indirected and more abstract. +The data model adds a layer of abstraction over the `Asset` API the Game uses, +and the diff-based editing makes most operations indirected and more abstract. For example, setting the `Transform` of a component involves creating an edit and associating the value the user wants to assign, then letting the Editor apply that edit to the actual component instance, as opposed to simply accessing the component directly through the `World`. +### Prerequisite: reflection diffs + +The core of the design of this RFC rests on `EditDiff`, +which we assume can be implemented with all the necessary features. +This has been attempted a few times in the past, +so there's definitely an interest for it, +although today we don't have any implementation or RFC for it. + +### Performance of the `Reflect` API + +The use of the `Reflect` API to mutate objects in a type erased way +is the standard pattern in Bevy, +and the rationale for that `Reflect` API to exist in the first place. +Performance may be a concern if the number of edits is large, +as reflection-based access is always slower than direct access, +but there's no data today to tell. + ## Rationale and alternatives -1. How do we store string instances? Storing a `String` requires an extra heap allocation per string -and makes `GameObject` non-copyable. -Interning and using an ID would solve this. +1. How do we implement `EditDiff`? + This is out of scope of this RFC, + but is a prerequisite to implement it. + +2. How cumbersome is it to work with `Reflect`? + Do we need some utilities for common operations, + like adding or removing entities, + addding or removing components, + or accessing a component field by name? + This is probably out of scope, + and will be made clear during implementation. ## Future possibilities @@ -792,7 +800,8 @@ Because the Editor data model provides a centralized source of truth for all edi this enables building a unique undo/redo system and a unique copy/paste system shared by all other editing systems, without the need for each of those editing systems to design their own. -I expect the following future RFCs to build on top of this one: +I expect the following future RFCs (or direct feature implementations) +to build on top of this one: - Game/Editor communication (IPC) - Undo/redo system