Compare commits

..

No commits in common. "f66961fc9b08cdfc6578dfc98f3b82a38db1ea8f" and "5e3739036a792348e25761da9a8d5aff140a6e2c" have entirely different histories.

8 changed files with 380 additions and 191 deletions

View file

@ -1,186 +1,106 @@
# Queries # Queries
## Introductiuon ## Introductiuon
Queries enable games to quickly find entities that satifies provided conditions. Queries enable games to quickly find entities that satifies provided conditions.
Jecs queries can do anything from returning entities that match a simple list of components, to matching against entity graphs. Jecs queries can do anything from returning entities that match a simple list of components, to matching against entity graphs.
This manual contains a full overview of the query features available in Jecs. Some of the features of Jecs queries are: This manual contains a full overview of the query features available in Jecs. Some of the features of Jecs queries are:
- Queries have support for relationships pairs which allow for matching against entity graphs without having to build complex data structures for it. - Queries have support for relationships pairs which allow for matching against entity graphs without having to build complex data structures for it.
- Queries support filters such as [`query:with(...)`](../../api/query.md#with) if entities are required to have the components but you dont actually care about components value. And [`query:without(...)`](../../api/query.md#without) which selects entities without the components. - Queries support filters such as [`query:with(...)`](../../api/query.md#with) if entities are required to have the components but you dont actually care about components value. And [`query:without(...)`](../../api/query.md#without) which selects entities without the components.
- Queries can be drained or reset on when called, which lets you choose iterator behaviour. - Queries can be drained or reset on when called, which lets you choose iterator behaviour.
- Queries can be called with any ID, including entities created dynamically, this is useful for pairs. - Queries can be called with any ID, including entities created dynamically, this is useful for pairs.
- Queries are already fast but can be futher inlined via [`query:archetypes()`](../../api/query.md#archetypes) for maximum performance to eliminate function call overhead which is roughly 70-80% of the cost for iteration. - Queries are already fast but can be futher inlined via [`query:archetypes()`](../../api/query.md#archetypes) for maximum performance to eliminate function call overhead which is roughly 70-80% of the cost for iteration.
## Performance and Caching ## Creating Queries
This section explains how to create queries in the different language bindings.
Understanding the basic architecture of queries helps to make the right tradeoffs when using queries in games.
:::code-group
The biggest impact on query performance is whether a query is cached or not. ```luau [luau]
for _ in world:query(Position, Velocity) do end
This section goes over what caching is, how it can be used and when it makes sense to use it. ```
```typescript [typescript]
### Caching: what is it? for (const [_] of world.query(Position, Velocity)) {}
```
Jecs is an archetype ECS, which means that entities with exactly the same components are grouped together in an "archetype". Archetypes are created on the fly whenever a new component combination is created in the ECS. For example: :::
:::code-group ### Components
A component is any single ID that can be added to an entity. This includes tags and regular entities, which are IDs that do not have the builtin `Component` component. To match a query, an entity must have all the requested components. An example:
```luau [luau]
local e1 = world:entity() ```luau
world:set(e1, Position, Vector3.new(10, 20, 30)) -- create archetype [Position] local e1 = world:entity()
world:set(e1, Velocity, Vector3.new(1, 2, 3)) -- create archetype [Position, Velocity] world:add(e1, Position)
local e2 = world:entity() local e2 = world:entity()
world:set(e2, Position, Vector3.new(10, 20, 30)) -- archetype [Position] already exists world:add(e2, Position)
world:set(e2, Velocity, Vector3.new(1, 2, 3)) -- archetype [Position, Velocity] already exists world:add(e2, Velocity)
world:set(e3, Mass, 100) -- create archetype [Position, Velocity, Mass]
local e3 = world:entity()
-- e1 is now in archetype [Position, Velocity] world:add(e3, Position)
-- e2 is now in archetype [Position, Velocity, Mass] world:add(e3, Velocity)
``` world:add(e3, Mass)
```typescript [typescript] ```
const e1 = world.entity(); Only entities `e2` and `e3` match the query Position, Velocity.
world.set(e1, Position, new Vector3(10, 20, 30)); // create archetype [Position]
world.set(e1, Velocity, new Vector3(1, 2, 3)); // create archetype [Position, Velocity] ### Wildcards
const e2 = world.entity(); Jecs currently only supports the `Any` type of wildcards which a single result for the first component that it matches.
world.set(e2, Position, new Vector3(10, 20, 30)); // archetype [Position] already exists
world.set(e2, Velocity, new Vector3(1, 2, 3)); // archetype [Position, Velocity] already exists When using the `Any` type wildcard it is undefined which component will be matched, as this can be influenced by other parts of the query. It is guaranteed that iterating the same query twice on the same dataset will produce the same result.
world.set(e3, Mass, 100); // create archetype [Position, Velocity, Mass]
If you want to iterate multiple targets for the same relation on a pair, then use [`world:target`](../../api/world.md#target)
// e1 is now in archetype [Position, Velocity]
// e2 is now in archetype [Position, Velocity, Mass] Wildcards are particularly useful when used in combination with pairs (next section).
```
### Pairs
:::
A pair is an ID that encodes two elements. Pairs, like components, can be added to entities and are the foundation for [Relationships](relationships.md).
Archetypes are important for queries. Since all entities in an archetype have the same components, and a query matches entities with specific components, a query can often match entire archetypes instead of individual entities. This is one of the main reasons why queries in an archetype ECS are fast.
The elements of a pair are allowed to be wildcards. When a query pair returns an `Any` type wildcard, the query returns at most a single matching pair on an entity.
The second reason that queries in an archetype ECS are fast is that they are cheap to cache. While an archetype is created for each unique component combination, games typically only use a finite set of component combinations which are created quickly after game assets are loaded.
The following sections describe how to create queries for pairs in the different language bindings.
This means that instead of searching for archetypes each time a query is evaluated, a query can instead cache the list of matching archetypes. This is a cheap cache to maintain: even though entities can move in and out of archetypes, the archetypes themselves are often stable.
:::code-group
If none of that made sense, the main thing to remember is that a cached query does not actually have to search for entities. Iterating a cached query just means iterating a list of prematched results, and this is really, really fast. ```luau [luau]
local Likes = world:entity()
### Tradeoffs local bob = world:entity()
for _ in world:query(pair(Likes, bob)) do end
Jecs has both cached and uncached queries. If cached queries are so fast, why even bother with uncached queries? There are four main reasons: ```
```typescript [typescript]
- Cached queries are really fast to iterate, but take more time to create because the cache must be initialized first. const Likes = world.entity()
- Cached queries have a higher RAM utilization, whereas uncached queries have very little overhead and are stateless. const bob = world.entity()
- Cached queries add overhead to archetype creation/deletion, as these changes have to get propagated to caches. for (const [_] of world.query(pair(Likes, bob))) {}
- While caching archetypes is fast, some query features require matching individual entities, which are not efficient to cache (and aren't cached). ```
:::
As a rule of thumb, if you have a query that is evaluated each frame (as is typically the case with systems), they will benefit from being cached. If you need to create a query ad-hoc, an uncached query makes more sense.
When a query pair contains a wildcard, the `world:target()` function can be used to determine the target of the pair element that matched the query:
Ad-hoc queries are often necessary when a game needs to find entities that match a condition that is only known at runtime, for example to find all child entities for a specific parent.
:::code-group
## Creating Queries ```luau [luau]
for id in world:query(pair(Likes, jecs.Wildcard)) do
This section explains how to create queries in the different language bindings. print(`entity {getName(id)} likes {getName(world, world:target(id, Likes))}`)
end
:::code-group ```
```typescript [typescript]
```luau [luau] const Likes = world.entity()
for _ in world:query(Position, Velocity) do end const bob = world.entity()
``` for (const [_] of world.query(pair(Likes, jecs.Wildcard))) {
print(`entity ${getName(id)} likes ${getName(world.target(id, Likes))}`)
```typescript [typescript] }
for (const [_] of world.query(Position, Velocity)) { ```
} :::
```
### Filters
::: Filters are extensions to queries which allow you to select entities from a more complex pattern but you don't actually care about the component values.
### Components The following filters are supported by queries:
A component is any single ID that can be added to an entity. This includes tags and regular entities, which are IDs that do not have the builtin `Component` component. To match a query, an entity must have all the requested components. An example: Identifier | Description
---------- | -----------
```luau With | Must match with all terms.
local e1 = world:entity() Without | Must not match with provided terms.
world:add(e1, Position)
This page takes wording and terminology directly from Flecs [documentation](https://www.flecs.dev/flecs/md_docs_2Queries.html)
local e2 = world:entity()
world:add(e2, Position)
world:add(e2, Velocity)
local e3 = world:entity()
world:add(e3, Position)
world:add(e3, Velocity)
world:add(e3, Mass)
```
Only entities `e2` and `e3` match the query Position, Velocity.
### Wildcards
Jecs currently only supports the `Any` type of wildcards which a single result for the first component that it matches.
When using the `Any` type wildcard it is undefined which component will be matched, as this can be influenced by other parts of the query. It is guaranteed that iterating the same query twice on the same dataset will produce the same result.
If you want to iterate multiple targets for the same relation on a pair, then use [`world:target`](../../api/world.md#target)
Wildcards are particularly useful when used in combination with pairs (next section).
### Pairs
A pair is an ID that encodes two elements. Pairs, like components, can be added to entities and are the foundation for [Relationships](relationships.md).
The elements of a pair are allowed to be wildcards. When a query pair returns an `Any` type wildcard, the query returns at most a single matching pair on an entity.
The following sections describe how to create queries for pairs in the different language bindings.
:::code-group
```luau [luau]
local Likes = world:entity()
local bob = world:entity()
for _ in world:query(pair(Likes, bob)) do end
```
```typescript [typescript]
const Likes = world.entity();
const bob = world.entity();
for (const [_] of world.query(pair(Likes, bob))) {
}
```
:::
When a query pair contains a wildcard, the `world:target()` function can be used to determine the target of the pair element that matched the query:
:::code-group
```luau [luau]
for id in world:query(pair(Likes, jecs.Wildcard)) do
print(`entity {getName(id)} likes {getName(world, world:target(id, Likes))}`)
end
```
```typescript [typescript]
const Likes = world.entity();
const bob = world.entity();
for (const [_] of world.query(pair(Likes, jecs.Wildcard))) {
print(`entity ${getName(id)} likes ${getName(world.target(id, Likes))}`);
}
```
:::
### Filters
Filters are extensions to queries which allow you to select entities from a more complex pattern but you don't actually care about the component values.
The following filters are supported by queries:
| Identifier | Description |
| ---------- | ----------------------------------- |
| With | Must match with all terms. |
| Without | Must not match with provided terms. |
This page takes wording and terminology directly from Flecs [documentation](https://www.flecs.dev/flecs/md_docs_2Queries.html)

View file

@ -503,7 +503,7 @@ end
local function id_record_ensure(world: World, id: number): IdRecord local function id_record_ensure(world: World, id: number): IdRecord
local componentIndex = world.componentIndex local componentIndex = world.componentIndex
local idr: IdRecord = componentIndex[id] local idr = componentIndex[id]
if not idr then if not idr then
local flags = ECS_ID_MASK local flags = ECS_ID_MASK
@ -548,8 +548,7 @@ local function id_record_ensure(world: World, id: number): IdRecord
on_set = on_set, on_set = on_set,
on_remove = on_remove, on_remove = on_remove,
}, },
} } :: IdRecord
componentIndex[id] = idr componentIndex[id] = idr
end end

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/jecs", "name": "@rbxts/jecs",
"version": "0.5.3", "version": "0.5.2",
"description": "Stupidly fast Entity Component System", "description": "Stupidly fast Entity Component System",
"main": "jecs.luau", "main": "jecs.luau",
"repository": { "repository": {

152
test/btree.luau Normal file
View file

@ -0,0 +1,152 @@
-- original author @centauri
local bt
do
local FAILURE = 0
local SUCCESS = 1
local RUNNING = 2
local function SEQUENCE(nodes)
return function(...)
for _, node in nodes do
local status = node(...)
if status == FAILURE or status == RUNNING then
return status
end
end
return SUCCESS
end
end
local function FALLBACK(nodes)
return function(...)
for _, node in nodes do
local status = node(...)
if status == SUCCESS or status == RUNNING then
return status
end
end
return FAILURE
end
end
bt = {
SEQUENCE = SEQUENCE,
FALLBACK = FALLBACK,
RUNNING = RUNNING,
SUCCESS = SUCCESS,
FAILURE = FAILURE,
}
end
local SEQUENCE, FALLBACK = bt.SEQUENCE, bt.FALLBACK
local RUNNING, SUCCESS, FAILURE = bt.FAILURE, bt.SUCCESS, bt.FAILURE
local btree = FALLBACK({
SEQUENCE({
function()
return 1
end,
function()
return 0
end,
}),
SEQUENCE({
function()
print(3)
local start = os.clock()
local now = os.clock()
while os.clock() - now < 4 do
print("yielding")
coroutine.yield()
end
return 0
end,
}),
function()
return 1
end,
})
function wait(seconds)
local start = os.clock()
while os.clock() - start < seconds do
end
return os.clock() - start
end
local function panic(str)
-- We don't want to interrupt the loop when we error
coroutine.resume(coroutine.create(function()
error(str)
end))
end
local jecs = require("@jecs")
local world = jecs.World.new()
local function Scheduler(world, ...)
local systems = { ... }
local systemsNames = {}
local N = #systems
local system
local dt
for i, module in systems do
local sys = if typeof(module) == "function" then module else require(module)
systems[i] = sys
local file, line = debug.info(2, "sl")
systemsNames[sys] = `{file}->::{line}::->{debug.info(sys, "n")}`
end
local function run()
local name = systemsNames[system]
--debug.profilebegin(name)
--debug.setmemorycategory(name)
system(world, dt)
--debug.profileend()
end
local function loop(sinceLastFrame)
--debug.profilebegin("loop()")
local start = os.clock()
for i = N, 1, -1 do
system = systems[i]
dt = sinceLastFrame
local didNotYield, why = xpcall(function()
for _ in run do
end
end, debug.traceback)
if didNotYield then
continue
end
if string.find(why, "thread is not yieldable") then
N -= 1
local name = table.remove(systems, i)
panic("Not allowed to yield in the systems." .. "\n" .. `System: {name} has been ejected`)
else
panic(why)
end
end
--debug.profileend()
--debug.resetmemorycategory()
return os.clock() - start
end
return loop
end
local co = coroutine.create(btree)
local function ai(world, dt)
coroutine.resume(co)
end
local loop = Scheduler(world, ai)
while wait(0.2) do
print("frame time: ", loop(0.2))
end

47
test/hooks.luau Normal file
View file

@ -0,0 +1,47 @@
local jecs = require("@jecs")
local function create_cache(hook)
local columns = setmetatable({}, {
__index = function(self, component)
local column = {}
self[component] = column
return column
end,
})
return function(world, component, fn)
local column = columns[component]
table.insert(column, fn)
world:set(component, hook, function(entity, value)
for _, callback in column do
callback(entity, value)
end
end)
end
end
local hooks = {
OnSet = create_cache(jecs.OnSet),
OnAdd = create_cache(jecs.OnAdd),
OnRemove = create_cache(jecs.OnRemove),
}
local world = jecs.World.new()
local Position = world:component()
local order = ""
hooks.OnSet(world, Position, function(entity, value)
print("$1", entity, `({value.x}, {value.y}, {value.z})`)
order ..= "$1"
end)
hooks.OnSet(world, Position, function(entity, value)
print("$2", entity, `\{{value.x}, {value.y}, {value.z}}`)
order ..= "-$2"
end)
world:set(world:entity(), Position, { x = 1, y = 0, z = 1 })
-- Output:
-- $1 270 (1, 0, 1)
-- $2 270 {1, 0, 1}
assert(order == "$1" .. "-" .. "$2")

57
test/leaky.luau Normal file
View file

@ -0,0 +1,57 @@
local function calculateAverage(times)
local sum = 0
for _, time in ipairs(times) do
sum = sum + time
end
return sum / #times
end
-- Main logic to time the test function
local CASES = {
jecs = function(world, ...)
for i = 1, 100 do
local q = world:query(...)
for _ in q do
end
end
end,
mirror = function(world, ...)
for i = 1, 100 do
local q = world:query(...)
for _ in q do
end
end
end,
}
for name, fn in CASES do
local times = {}
local allocations = {}
local ecs = require("@" .. name)
local world = ecs.World.new()
local A, B, C = world:component(), world:component(), world:component()
for i = 1, 5 do
local e = world:entity()
world:add(e, A)
world:add(e, B)
world:add(e, C)
end
collectgarbage("collect")
local count = collectgarbage("count")
for i = 1, 50000 do
local startTime = os.clock()
fn(world, A, B, C)
local allocated = collectgarbage("count")
collectgarbage("collect")
local endTime = os.clock()
table.insert(times, endTime - startTime)
table.insert(allocations, allocated)
end
print(name, "gc cycle time", calculateAverage(times))
print(name, "memory allocated", calculateAverage(allocations))
end

14
test/memory.luau Normal file
View file

@ -0,0 +1,14 @@
local jecs = require("@jecs")
local testkit = require("@testkit")
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local e = world:entity()
world:add(e, A)
world:add(e, B)
local archetype_id = world.archetypeIndex["1_2"].id
world:delete(e)
testkit.print(world)

View file

@ -1,6 +1,6 @@
[package] [package]
name = "ukendio/jecs" name = "ukendio/jecs"
version = "0.5.3" version = "0.5.2"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
license = "MIT" license = "MIT"