NateTheGreatt/bitECS

Query API or multiple queries per system

Closed this issue · 4 comments

Let's consider a simple collision system:

for (let i = 0; i < entities.length; i++) {
  for (let j = i + 1; j < entities.length; j++) {
    if (isColliding(entities[i], entities[j])) {
      

How could it be implemented using bitECS? I have the feeling that something is missing. The ability to query components directly or having multiple queries per system could do the trick.

first of all, i would postulate this as bad design. i have never found a good use for queries, personally, that did not A) encourage poor code design which would in turn B) impede on performance. for this collision example, i would instead ask a spatial service for nearby entities and iterate over those for each of the entities in the collision system. queries open up the possibility of implementing features that are not locked into the system execution order, which i find to be key to good ECS system design.

that said:

currently the solution would be to simply destructure the localEntities array from the system like so:

const { localEntities } = registerSystem({
  name: 'system',
  components: ['POSITION'],
  before: (position) => { // before & after are called once per tick
    for (let i = 0; i < localEntities.length; i++) {
      const eid = localEntities[i]
      position.x[eid]++
      position.y[eid]++
    }
  }
})

systems are queries, just with some lifecycle method hooks on them. localEntities is the array of entity IDs that are currently in the system (have the required components).

but this makes me think that we should maybe change the update method to run only once and pass in the local entities to manually iterate over like so:

registerSystem({
  name: 'system',
  components: ['POSITION'],
  update: (position) => (entities) => {
    for (let i = 0; i < entities.length; i++) {
      const eid = entities[i]
      position.x[eid]++
      position.y[eid]++
    }
  }
})

what do you think @ooflorent? thanks for bringing up these issues btw!

first of all, i would postulate this as bad design. i have never found a good use for queries, personally, that did not A) encourage poor code design which would in turn B) impede on performance.

A: Let's just say they do not suit your coding habits. A system is simply a wrapper around a query (or multiple ones).
B: Not necessarily. Queries could be extremely efficient. In fact, they are the underlying stone of the systems so they have to be efficient.

Beside A and B, I agree that the collision pattern from the example is not the best since, as you said, spatial partitioning would be better. To better illustrate my point, let's consider the following system:

const CameraQuery = Query([Camera, Transform, VisibleEntities]); // ①
const VisibleEntityQuery = Query([Entity, Transform, Visible]);  // ②

export function visible_entities_system(world) {
  for (let [camera, camera_transform, visible_entities] of world.query(CameraQuery)) { // ③
    visible_entities.clear();

    for (let [entity, entity_transform, visible] of world.query(VisibleEntityQuery)) { // ④
      if (!visible.value) {
        continue;
      }

      visible_entities.push({
        entity,
        order: calculate_depth(camera, camera_transform, entity_transform),
      });
    }

    visible_entities.sort();
  }
}

The above system is taken from a real-world project (but closed source). The purpose of this system is to iterate over the camera entities and then iterate over the renderable entities to sort them.

  1. Compile a query to fetch camera components
  2. Compile a query to fetch renderable entities
  3. Iterate over camera entities. The scene is rendered multiple times from multiple positions so multiple cameras are required.
  4. Iterate of renderable entities. The query costs “nothing” in terms of performance. It's like traversing a dense array (thanks to archetypical ECS)

I think there is a use case for multiple queries per system. This is only an example.

Technically this is possible in bitECS, but the API's design does not make the code very elegant, so I wrapped it in a function:

const Query = (name, components) => registerSystem({ name, components }).localEntities

const cameraQuery = Query('CameraQuery', ['camera', 'camera_transform', 'visible_entities'])
const visibleEntityQuery = Query('VisibleEntityQuery', ['entity', 'entity_transform', 'visible'])

registerSystem({
  name: 'VisibleEntitiesSystem',
  before: () => {
    cameraQuery.forEach(eid => {
      visibleEntityQuery.forEach(eid2 => {...})
    })
  }
})

However, as I said before, I would caution against these types of designs. While the queries cost nothing, iterating over them does. In a lot of cases you can design the iteration away. By not having an API that explicitly caters to queries it forces one to solve the problem with a serial/linear set of systems only, which, more often than not in my experience, results in a better system design and higher performance (can reduce the total number of iterations, and also keeps the iterated data closer together which theoretically reduces cache misses). I will continue to ponder your example further to see if there are more cases for a better API around queries. Let me know if you can think of other examples, as well.

@ooflorent
I just published bitECS v0.1.2 which fixes some performance bugs and adds a new function, which is essentially the query wrapper function above:

const { createQuery } = bitECS()
const positionQuery = createQuery(['POSITION'])
positionQuery.forEach(eid => {})

I'm going to close this issue, but keep my warning in mind :) <3