Open c42f opened 3 years ago
With the discussion about trying it out for Makie
I started convertingt Simons playground to use Overseer, though it's very work-in-progress and probably has a bunch of bad decisions in it. It's also not yet at a point where it's usable, but maybe it's good to mention it anyway. In case we want to coordinate things. https://github.com/ffreyer/MakieCore.jl/tree/ECS
for e in @entities_in(TimerComp && LifetimeComp)
That'd need some reference to the ledger, right? I do like the entity.component
syntax though. I implemented a wrapper for entities to make that work outside of update, which is also in MakieCore as PlotEntity
. Maybe something like that could happen in general? https://github.com/ffreyer/MakieCore.jl/blob/ECS/src/PlotEntity.jl
I found the term ledger kind of unintuitive; in some ways I'd prefer to use table for this, because the ledger is really just a sparse tabular layout.
I don't really like the name ledger
either. I ended up calling it registry
like EnTT. Table seems to normal to me. I can understand how the ledger is like a sparse table, but then there are also systems in there... While messing with MakieCore I also thought of manager
, since it manages entities, components and systems. Naming is hard...
If not, we could ditch System and just implement this as function update_lifetimes(ledger)
Functions in an array sound like a bad thing for performance to me, and a quick test seems to agree with that. Functors seem to be pretty much the same as System structs if they inherit from System, but if they are about as slow as functions if they don't. In my mind this library wants the label "high performance" so I'm leaning towards the current way of doing things.
using BenchmarkTools
abstract type System end
struct F1 <: System end
struct F2 <: System end
struct F3 <: System end
(::F1)(data) = data .= sin.(data)
(::F2)(data) = data .= exp.(data)
(::F3)(data) = data .= sqrt.(data)
function execute(fs::Vector{System}, data)
for f in fs
execute(f, data)
end
end
function execute2(fs::Vector{System}, data)
for f in fs
f(data)
end
end
f1(data) = data .= sin.(data)
f2(data) = data .= exp.(data)
f3(data) = data .= sqrt.(data)
function execute(fs::Vector{Function}, data)
for f in fs
f(data)
end
end
functions = [f1, f2, f1, f3, f2]
systems = [F1(), F2(), F1(), F3(), F2()]
functors = [G1(), G2(), G1(), G3(), G2()]
data = [1.0, 2.0, 3.0]
@benchmark execute($systems, $data)
# BenchmarkTools.Trial:
# memory estimate: 0 bytes
# allocs estimate: 0
# --------------
# minimum time: 91.713 ns (0.00% GC)
# median time: 94.320 ns (0.00% GC)
# mean time: 98.574 ns (0.00% GC)
# maximum time: 339.984 ns (0.00% GC)
# --------------
# samples: 10000
# evals/sample: 952
@benchmark execute2($functors, $data)
# BenchmarkTools.Trial:
# memory estimate: 0 bytes
# allocs estimate: 0
# --------------
# minimum time: 93.431 ns (0.00% GC)
# median time: 95.975 ns (0.00% GC)
# mean time: 100.941 ns (0.00% GC)
# maximum time: 355.987 ns (0.00% GC)
# --------------
# samples: 10000
# evals/sample: 950
@benchmark execute($functions, $data)
# BenchmarkTools.Trial:
# memory estimate: 0 bytes
# allocs estimate: 0
# --------------
# minimum time: 131.536 ns (0.00% GC)
# median time: 136.016 ns (0.00% GC)
# mean time: 142.128 ns (0.00% GC)
# maximum time: 444.328 ns (0.00% GC)
# --------------
# samples: 10000
# evals/sample: 886
Thanks for pointing out the missing ledger argument in my example. I've added that in.
Table seems to normal to me
Well.. my claim here is that ledger is fundamentally table-like: entities are row indices, and component types act as column names. Adding a component to an entity affects the sparsity structure.
Conceptually it's pretty "normal", the complexity comes from the extra API needed to cover sparse access.
Anyway, registry
is also a reasonable name. Naming is certainly hard!
Functions in an array sound like a bad thing for performance to me, and a quick test seems to agree with that. Functors seem to be pretty much the same as System structs if they inherit from System, but if they are about as slow as functions if they don't.
This is likely due to the union splitting optimization. I'll guess this difference goes away once you have more than a few systems (maybe five or so?)
In general, Function
is (mostly?) not a blessed type inside the compiler, so I'd be very surprised if you get benefits from functors or System
here vs regular functions in general. (As a historical note, this was not always the case. Functors used to be important for performance in Julia 0.4 or so where they triggered specialization and gave a major speedup when passed to higher order functions. But ever since we got nominal function types in around julia-0.5 this hasn't been the case.)
In my mind this library wants the label "high performance" so I'm leaning towards the current way of doing things.
Agreed on high performance goals, but I'd point out that looping over systems is the outermost loop in an ECS environment so performance of this particular loop is likely irrelevant.
Conversely, the innermost loop is where performance matters. For ECS, the innermost loop is entity iteration and component access inside the entity iteration loop. That part really needs to be as fast as possible.
I just to point out the connections to the functions in a struct question in https://discourse.julialang.org/t/simple-rule-engine/59125/10 and https://discourse.julialang.org/t/event-handler/59937/5, which was workable for me (I wrote about my context here https://github.com/mschauer/ZigZagBoomerang.jl/wiki/Internals)
Something I often end up doing is thin wrappers around types, for example @component struct Visible; x::Bool end
. It would be cool if it instead of that one could just register a Bool under the name "Visible" and do all the things Bools can do with Visible instead.
A better example of that might be Point2f0
. If I define @component struct SpatialXY; p::Point2f0 end
I end up with this thin wrapper that I want to use as a Point2f0 but can't without either adding many methods or writing component.p
every time.
Perhaps saving components as a Dict{Symbol, AbstractComponent}
could allow this?
Hi Y'all,
First of all, thanks for the interest and useful feedback! I'll try to address most points raised here, I will also open some Issues to discuss specific ones in more detail to keep things clear.
Is requested_componets fundamental functionality that we need?
So far not really, it is actually a remnant from the previous way of handling/accessing components in a ledger.
Initially I thought something like this could be used to also reason about which System
is using which Components, so maybe some automatic threading could be done. I still would really have a go at implementing that, see more in #8.
This would also allow closures to be used as systems and we could implement simple parameterized systems that way It's possible to attach metadata to function types, so if
requested_components
was optional we could still do requested_components(::typeof(update_lifetimes)) = ... Of course some boilerplate can be removed with a macro, but even better if we can get away with simplifying the underlying model.
I'm not entirely sure I support a change like this. I'm not sure about this, but to me it feels that more type instability, compile time and whatnot would be attached to allowing any function type. At least a system is a clear one supertype, with no parametric types and whatnot. Parametrized Systems
are already natural to me in the sense that they hold certain settings that will be used inside the update
function. Idk if closures / functions make things more or less clear.
Simpler entity iteration syntax
I fully support this idea, I like it a lot. In the past I recall trying something similar using NamedTuples
, but that's not ideal since it copies data. Implementing this functionality seems not hard to do, but to get it performant might be harder. I'm playing around with some ideas in my head, maybe somehow tuples of type-stable pointers? Let's discuss further in #7.
I found the term ledger kind of
unintuitive
So, to some extent I agree with the Table argument, the reason why I chose something a bit more abstract is that a ledger is more than just the Component
table. So, I chose ledger
because an Overseer would use a ledger to keep track of all the stuff going on :sweat_smile:.
I don't particularly like something like registry
since there are already an overabundance of registries that do everything and anything, and I wanted to stay away from that confusion.
Manager
was another idea, that I ended up not liking because the ledger itself does nothing, just holds the stuff, Manager
implies that it does something by itself.
I am open to more suggestions though because, yea, naming is hard and this package is explicitly not registered yet exactly for these reasons.
Indeed to me performance was always the very first thing I tried to optimize for, and then added syntactic sugar where it wouldn't impact performance significantly, and where going to a more verbose but higher performance syntax remained a plug in replacement. E.g. there is still the possibility of using unsafe_load
and unsafe_store
with pointer(c::Component, e::Entity)
, which is still the highest performance way of doing things, and not a big change to the more friendly code, just more verbose.
I would like to keep designing the package in this way, that going to a more verbose performant way does not require a big change in the code.
Since there seems to be some eyes on the package now, and interest, I have made a TODO list / list of goals in #9 that I will add to in the future, so I can get some input on the design of those features etc.
Cheers!
It would be cool if it instead of that one could just register a Bool under the name "Visible" and do all the things Bools can do with Visible instead.
Yes, implicit unwrapping was something I had in my examples above, but I guess I didn't explicitly mention it (fixed now).
I think the trick is to implicitly unwrap components into their fields so that the user directly works with the types of the fields rather than a wrapper type. (Wrapper types tend to be quite cumbersome to implement and generally don't play well with Julia's multiple dispatch.)
There's a few challenges for implicit unwrapping:
getproperty
overload which can do the unwrapping with zero overhead because field access is in the innermost loop. This rules out using anything like a Dict, but I think it's possible when (a) the iterator type contains the types of the components and (b) we carefully arrange getproperty
to use those types. May require @generated function getproperty
.setproperty!
might be awkward to implement for components with multiple fields. Still probably possible.I'm not entirely sure I support a change like this. I'm not sure about this, but to me it feels that more type instability, compile time and whatnot would be attached to allowing any function type. At least a system is a clear one supertype, with no parametric types and whatnot.
I guess I'm quite skeptical this makes any practical performance difference, as the compiler will treat containers of both System
and Function
as a bunch of pointers and do dynamic dispatch when you use the content. It may actually be more efficient to use a set of functions, because it's simpler to dispatch through a method table of a single function rather than the method table of update
which contains overrides for many systems. But it's hard to say withing being specific. Did you have a specific pattern of code in mind?
Parametrized Systems are already natural to me in the sense that they hold certain settings that will be used inside the update function. Idk if closures / functions make things more or less clear.
Yes I think this is true. It just seemed to me that the syntactic overhead of defining new systems was kind of unfortunate and I couldn't really see the value it's bringing.
Indeed to me performance was always the very first thing I tried to optimize for, and then added syntactic sugar where it wouldn't impact performance significantly
Perfect approach, I agree 100% :-)
Yes, implicit unwrapping was something I had in my examples above, but I guess I didn't explicitly mention it (fixed now).
I didn't mean unwrapping with that, I meant literally saving a Bool
or Point3f0
so you don't have to. I'm not really knowledgeable about how everything works here, but I image that could be done by saving components in a Dict{Symbol, AbstractComponent}
or perhaps by unwrapping really early to get ledger.components[WrapperType] => Component{Point3f0}
.
Hey @louisponet, a while back I said I'd write something about my experience using Overseer. With the discussion about trying it out for Makie (or other "serious" projects?) I thought it would be useful to write down some thoughts I had about the design and ways I'd like to be able to use Overseer.
To summarize my experience so far, I'd say "I love the semantics, but don't entirely love the syntax". Or to expand
I had two ideas about how I feel writing systems could be nicer. Here's a system from my game:
Making systems easier to define
Perhaps this is wishful thinking, but it would be nice if I could just use normal functions for systems. Instead we've got the following stuff to write for each system:
A few thoughts here:
requested_componets
fundamental functionality that we need?System
and just implement this asfunction update_lifetimes(ledger)
statefulparameterized systems that wayrequested_components
was optional we could still dorequested_components(::typeof(update_lifetimes)) = ...
Simpler entity iteration syntax
When writing systems, there's constantly some syntactic overhead in setting up component access. Considering the
LifetimeUpdate
example above, I'd perhaps like to write this in more lightweight fashion asHere the iterator needs to carry enough type information to make things like
e.time
efficient, but I think this is possible withgetproperty
overloading. (Here I'm assuming thate.time
ande.max_age
refer to the names of the fields of TimerComp and LifetimeComp. That is, there's some implicit unwrapping going on.)Notice that this makes certain syntax work on an entity iterator in exactly the same way as it does on an
Array{SomeStruct}
when the struct field names match with the component field names. I think this is a nice usability goal to the extent that it makes sense for ECS. It's the kind of thing achieved by packages such as https://github.com/JuliaArrays/StructsOfArrays.jl which present a syntactic facade on top of an optimized data layout. In the example, suppose we wanted to find the number of expired objects:Other random musings
I found the term
ledger
kind of unintuitive; in some ways I'd prefer to usetable
for this, because the ledger is really just a sparse tabular layout. I feel likeMatrix
andSparseMatrix
are related in a similar way asDataFrame
andLedger
.