📜 ⬆️ ⬇️

Component-oriented C # engine [part 2]

Independent components, minimally aware of each other, able to fulfill only the set goal - this is incredibly cool! I wrote about the initial sketches and ideas in a previous post .

Since then, it was possible to face new problems that forced to expand and modify the system.

All the advantages and disadvantages, as well as a small accumulated experience, I will consider on the example of a very simple 2D physics with gravity, forces and collisions. Welcome under the cat, friends!

Introduction


In my spare time I work hard on my own project, which was created solely for the sake of training, and therefore I’m directly infested with bicycles. Probably, this “engine” is one of them.
')
Note: Naturally, I understand that in the framework of real-life tasks, it is advisable to take a ready-made solution and use it as a woodcutter takes an ax and cuts a tree with it. Simply, sometimes you want to understand how these axes, hell, arranged.

Current state


Not so much time has passed since the last story, but in a few evenings I realized that there are a number of tasks that the system has not yet been able to solve.

So let's take a quick look at what we can already do:

New problems


I thought for a long time how to proceed: first, to tell a task, during which problems arose, or to show problems, their solution, and then the task itself. In the end, he stopped at the second version. First, we learn about innovations and extensions, consider them in detail, and then, armed with fresh data, we will solve the problem.

And yet, what kind of gear is not enough to work at full power?

First, in the previous part I did not mention that I plan to introduce a message mechanism by which one component can send (within the parent container) messages to another. As readers have already noticed, an event-based approach will allow flexibility to be won without hitting the architecture. Moreover, no one imposes it: " I send messages to all components, and you want - listen, you don't want - don't ."

Secondly, there was uncertainty with preconditions. What if sometimes, in case of non-compliance with the precondition, it is necessary to completely break the execution of the program under the pretext of breaking the contract (the texture rendering component does not make sense at all without a texture component), and in other cases it can be ignored? " Now you do not have this component, but once it will appear! ".

In addition, there were minor flaws, which, most likely, remained as is.

Preconditions


Very quickly I will tell about the preconditions, because The solution is very simple. New listing:
public enum OnValidationFail { Throw, Skip } 

And that's it! Now you can use it like this:
 [AttachHandler(OnValidationFail.Skip)] 

Forwarding messages


With the forwarding of messages more interesting. First, each component can now position itself as a sender and receiver.

You can send messages that are generally described as very simple:
 public class Message<T> : Message { public T Sender { get; private set; } public Message(T sender) { Sender = sender; } } 

To get it, I used the same approach again (for the third time) using reflection, which gives me very intrusive ease and flexibility.

Every component in the class body can describe methods that take any Message as a parameter. The [MessageReceiver] attribute helps to make it a valid recipient. For example:
 public class SimpleComponent : Component { [MessageReceiver] public void MessageFromFriend(DetachMessage<FriendComponent> message) { // , ,      . //    ? _container.RemoveComponent(this); } [MessageReceiver] public void MessageFromSomeComponent(DetachMessage<SomeComponent> message) { //  ,   ,   . // - ,   . //_container.RemoveComponent(this); } } 

Since it is obvious that there are such stages of the component life cycle as adding and deleting, smartly wrote protected utility methods in the Component class, which allow successors, if desired, to very briefly inform all “neighbors” about the current state.
 protected void SendAttachMessage<TSender, TContainer> (TSender sender, TContainer container) where TSender : Component { SendMessage( new ComponentAttachMessage<TSender, TContainer>(sender, container)); } protected void SendDetachMessage<T>(T sender) where T : Component { SendMessage(new ComponentDetachMessage<T>(sender)); } 

On this with the main "major" modifications (only one, in fact) everything. Now I’ll tell you about the scope of not only the new functionality, but, in general, the system.

Gravity Problem


At the initial stage of development, the following trivial task arose: to do at least in such a way that two types of “boxes” could be put on the scene:

It sounds very simple, but it is not clear whether a freshly designed system can do this?

I will expand the general sketch of the solution into a logical sequence, without reference to the implementation
1. Suppose there are game objects that can be positioned on the scene at coordinates X, Y and drawn.
2. We introduce the concept of a solid body - the properties of a game object, which allows you to set the speed of change of the corresponding coordinates.
3. In principle, the game object has everything for movement: coordinates, a mechanism that makes it possible to change them. Not enough push, in other words, strength .
4. Something else? Being able to move objects around the stage is fine, but we still need to teach them how to collide. To do this, we define the concept of the shell of an object responsible for collisions.

Game objects


Following the above sketch, select the original entity - the game object , which will, in combination, the container of components.
 public class GameObject : ComponentContainer { } 

Not every game object can be placed on the scene, so we introduce the heir - the scene object .
 public class SceneObject : GameObject { public float X { get; set; } public float Y { get; set; } public event Action<TimeSpan> Updating; public event Action<TimeSpan> Drawing; public void Draw(TimeSpan deltaTime) { Updating(deltaTime); } public void Updated(TimeSpan deltaTime) { Drawing(deltaTime); } } 

Note: in order not to write a lot of unnecessary code, I will omit some implementation details, since the most important is the idea, and the fact that it did not check Updated and Drawn for null or did not sign the dummy is a minor issue.

Solids


Since solids are properties of game objects (whether they may or may not be), they can be encapsulated into components. Let's see the code:
 public class RigidBody : Component { private SceneObject _sceneObject; private float _newX; private float _newY; public float VelocityX { get; set; } public float VelocityY { get; set; } [AttachHandler] public void OnSceneObjectAttach(SceneObject sceneObject) { _sceneObject = sceneObject; _sceneObject.Updating += OnUpdate; _sceneObject.Drawing += OnDraw; } private void OnUpdate(TimeSpan deltaTime) { //          //  . if (VelocityX != 0.0f) _newX = (float) (_sceneObject.X + (VelocityX * deltaTime.TotalSeconds)); if (VelocityY != 0.0f) _newY = (float) (_sceneObject.Y + (VelocityY * deltaTime.TotalSeconds)); } private void OnDraw(TimeSpan deltaTime) { //     . _sceneObject.X = _newX; _sceneObject.Y = _newY; } } 

I hope I'm so stupid as to come up with some really significant and complex physical constructs, that everyone understands how the previous piece of code works.

Forces


So we got to the last key to the "eternal" movement. Strength is, after all, essentially just something that changes the speed of a solid body. It remains to understand what speed to change and how.

Note: most likely, someone has already solved this problem a thousand times in exactly the same way, but I remind you that the project is educational - I really like to get to all sorts of decisions myself.

What did you manage to come up with?

Let's look at the so-called "unit circle", where the values ​​on the axes oX and oY are represented on the range from [-1 to 1].

Suppose the blue arrow is the vector of the force acting on the body, and the center of the circle is the center of gravity of the body. And, let's say, the conditional value of the force itself is 100.
If the force changes the speed of the object, then it must change its speed both vertically and horizontally, and how much - the green and orange lines will answer us. They are equal to approximately 0.9 and 0.5, respectively.
Consequently, the object speed vertically changes to 100 * 0.9, and horizontally - to 100 * 0.5.
I will fix these very simple conclusions with the code:
 public class Force { public int Angle { get; set; } public float Power { get; set; } public void Add(RigidBody rigidBody) { //     ( ). var radians = GeometryUtil.DegreesToRadians(Angle); var horizontalCoefficient = GetHorizontalCoefficient(radians); var verticalCoefficient = GetVerticalCoefficient(radians); rigidBody.VelocityX += Power * horizontalCoefficient; rigidBody.VelocityY += Power * verticalCoefficient; } private float GetHorizontalCoefficient(double radians) { var scaleX = Math.Cos(radians); if (Math.Abs(scaleX) <= 0) return 0; return (float) scaleX; } private float GetVerticalCoefficient(double radians) { //     -1, ..      oY . var scaleY = Math.Sin(radians) * -1; if (Math.Abs(scaleY) <= 0) return 0; return (float) scaleY; } } 

I want to note that the Force class is not a component, and not a container. This is a primitive type, which later will be reused by others.

One feels that there is not enough someone who could impose force on solid bodies, right?

Gravity


That which caused the most difficulties. I wanted to do something like a gravitational field, but it was unclear where it comes from and how it is applied to objects. Is it a component or a container?

In the end, having collected his thoughts, he decided that the component. Component that is added to any object that is present in the gravitational field.
The task of the component is to apply a force of 270 degrees (bottom) with a magnitude of 9.83 each update cycle.

 [RequiredComponent(typeof(RigidBody))] public class Gravitation : Component { private SceneObject _sceneObject; private RigidBody _rigidBody; private Force _gravitationForce = new Force(270, 9.83); [AttachHandler(OnValidationFail.Skip)] public void OnSceneObjectAttach(SceneObject sceneObject) { _sceneObject = sceneObject; _rigidBody = GetComponent<RigidBody>(); _sceneObject.Updating += OnUpdate; } private void OnUpdate(TimeSpan deltaTime) { _gravitationForce.Add(_rigidBody); } //  ,   ,    - . [MessageReceiver] public void OnRigidBodyAttach( ComponentAttachMessage<RigidBody, SceneObject> message) { _rigidBody = message.Sender; _sceneObject = message.Container; _sceneObject.Updating += OnUpdate; } [MessageReceiver] public void OnRigidBodyDetach(ComponentDetachMessage<RigidBody> message) { _sceneObject.Updating -= OnUpdate; } } 

Thus, now, even if an object does not have a solid component, gravity will not break. It simply will not work until the above component is added to the container.

The example turned out to be indicative, clearly illustrating the situation when one component sends messages to another within the parent container.

Collisions


Remains the most interesting part of any physics engine, on which the universe is based.

From the sketch of the solution to the problem, it follows that the shell responsible for the collisions is also a property of the object, just like a solid body. Therefore, without thinking, we define it as a component.

Next, I figured that the shells are different: the simplest are rectangular, but there are still round ones, which are also easy to count, and, finally, shells of arbitrary shape with N vertices.

It is necessary at some point to calculate the collision between two different shells. For example, a circle fell on a rectangular ground.

We describe the abstract shell class:
 public abstract class Collider : Component { protected SceneObject SceneObject; [AttachHandler] public OnSceneObjectAttach(SceneObject sceneObject) { SceneObject = sceneObject; } //    . public abstract bool ResolveCollision(Collider collider); public abstract bool ResolveCollision(BoxCollider collider); } 

The main idea is that the ResolveCollision method will be used to calculate collisions, which takes a parameter of the base type Collider , but inside this method each specific version of the shell will redirect the call to another method that can work with a specific type. Ball runs Ad-hoc polymorphism.

I will show on a simple example of a rectangle:
 public class BoxCollider : Collider { public float Width { get; set; } public float Height { get; set; } public RectangleF GetBounds() { return new RectangleF(SceneObject.X, SceneObject.Y, Width, Height); } public override bool ResolveCollision(Collider collider) { return collider.ResolveCollision(this); } public override bool ResolveCollision(BoxCollider boxCollider) { var bounds = GetBounds(); var colliderBounds = boxCollider.GetBounds(); if (!bounds.IntersectsWith(colliderBounds)) return false; // :         bounds. // ..    ,     "" //  ,       - . //         (    ). bounds.Intersect(colliderBounds); if (bounds.Height <= 1f) return false; SceneObject.Y -= bounds.Height; return true; } } 

You have dealt with the main problems: now you can create game objects, allocate them with various properties, move, push together. What is missing is the last small detail: where to calculate the collisions?

Scene


If there is a SceneObject , then there must be a scene where they are all located. She is responsible for updating the state of objects and their drawing. She will consider collisions.

This will help the layer Interaction , which still has not found application in solving the problem of gravity. We describe the class Interactor , which is responsible for the miscalculation of the collision between two objects of the scene:
 public class CollisionDetector : Interactor { [InteractionMethod] [RequiredComponent("first", typeof(Collider))] [RequiredComponent("second", typeof(Collider))] public void SceneObjectsInteraction(SceneObject first, SceneObject second) { var firstCollider = first.GetComponent<Collider>(); var secondCollider = second.GetComponent<Collider>(); if (!firstCollider.ResolveCollision(secondCollider)) return; //  ,           ,   . first.IfContains<RigidBody>(TryAddInvertedForce); second.IfContains<RigidBody>(TryAddInvertedForce); } private void TryAddInvertedForce(RigidBody rigidBody) { var lastAddedForce = rigidBody.ForceHistory.Last(); var invertedForce = new Force { Angle = GeometryUtil.GetOppositeAngle(lastAddedForce.Angle), Power = rigidBody.GetKineticEnergyY() }; invertedForce.Add(rigidBody); } } 

Note: this example is not an illustrative application of knowledge from the field of physics. It does not work for two bodies colliding in the air that were moving at a certain speed, but clearly shows the idea of ​​how it looks for a simple fall.

The general concept is as follows: when an object falls on the ground and collides with it, the earth compensates for the speed of a fall by a new force, which acts in the opposite direction from the fall and is equal to the current kinetic energy of the object.

Interactor itself is called from the scene in the update method that runs each frame.
In the collision prediction mechanism, for the time being , I use the rough O (n 2 ) method.
 //   . public void Update(TimeSpan deltaTime) { foreach (var sceneObject in _sceneObjects) { sceneObject.Update(deltaTime); foreach (var anotherSceneObject in _sceneObjects) { if (ReferenceEquals(sceneObject, anotherSceneObject)) continue; sceneObject.Interact(anotherSceneObject).Using<CollisionDetector>(); } } } 

Conclusion


Honestly, the small structure of the physics engine, in fact, seemed at first not so obvious. Especially collisions and gravity. I’m sure that the physical engines are hardly written that way, but it was interesting to try and “feel” myself.

In the process, we managed to isolate some obvious advantages of the component-oriented approach, which are based both on my observations and on common sense:
1. It is very easy to write code when you clearly see what a component does and from whom it depends (this is evident).
2. The code is very flexible and independent. Each component has an observable and unambiguous degree of influence on the container.

The probability is high that the task was relatively simple, and at higher loads, the system would crash and begin to introduce unnecessary complexity into the code. I think I will check it soon.

Thanks to all!

Source: https://habr.com/ru/post/271309/


All Articles