louisponet / Overseer.jl

Entity Component System with julia
Other
59 stars 4 forks source link

Some thoughts on Overseer usage and design #6

Open c42f opened 3 years ago

c42f commented 3 years ago

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:

# Sprites with finite lifetime
struct LifetimeUpdate <: System end

Overseer.requested_components(::LifetimeUpdate) = (TimerComp,LifetimeComp)

function Overseer.update(::LifetimeUpdate, m::AbstractLedger)
    timer = m[TimerComp]
    lifetime = m[LifetimeComp]
    for e in @entities_in(timer && lifetime)
        if timer[e].time > lifetime[e].max_age
            schedule_delete!(m, e)
        end
    end
    delete_scheduled!(m)
end

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:

struct LifetimeUpdate <: System end

Overseer.requested_components(::LifetimeUpdate) = (TimerComp,LifetimeComp)

function Overseer.update(::LifetimeUpdate, m::AbstractLedger)

A few thoughts here:

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 as

function update_lifetimes(m::AbstractLedger)
    for e in @entities_in(m, TimerComp && LifetimeComp)
        if e.time > e.max_age
            schedule_delete!(m, e)
        end
    end
    delete_scheduled!(m)
end

Here the iterator needs to carry enough type information to make things like e.time efficient, but I think this is possible with getproperty overloading. (Here I'm assuming that e.time and e.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:

function count_expired(objs)
    n = 0
    for o in objs
        if o.time > o.max_age
            n += 1
        end
    end
    n
end

count_expired(@entities_in(TimerComp && LifetimeComp))

# Same code also works if these were named tuples, for example, or user-defined structs:
count_expired([(time=i, max_age=100) for i=1:200])

Other random musings

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 feel like Matrix and SparseMatrix are related in a similar way as DataFrame and Ledger.

ffreyer commented 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

ffreyer commented 3 years ago

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
c42f commented 3 years ago

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.

mschauer commented 3 years ago

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)

ffreyer commented 3 years ago

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?

louisponet commented 3 years ago

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.

Comments/Replies

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.

Performance

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.

TODO

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!

c42f commented 3 years ago

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:

c42f commented 3 years ago

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% :-)

ffreyer commented 3 years ago

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}.