Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Listener component subscriptions #335

Merged
merged 4 commits into from
Jan 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Arche supports full world serialization and deserialization, in conjunction with [arche-serde](https://github.com/mlange-42/arche-serde) (#319)
* Supports 256 instead of 128 component types as well as resource types and engine locks (#313)
* Generic API supports up to 12 instead of 8 component types (#324)
* Reworked event system with independent subscription for different event types (#333, #334)
* Reworked event system with granular subscription to different event types and components (#333, #334, #335)

### Breaking changes

Expand All @@ -26,10 +26,12 @@ This change was necessary to get the same performance as before, despite the mor
* Entities support JSON marshalling and unmarshalling (#319)
* The world's entity state can be extracted and re-established via `World.DumpEntities()` and `World.LoadEntities()` (#319, #326)
* Adds functions `ComponentIDs(*World)` and `ResourceIDs(*World)` to get all registered IDs (#329)
* Adds methods `Mask.And`, `Mask.Or` and `Mask.Xor` (#335)

### Performance

* Reduces archetype memory footprint by using a dynamically sized slice for storage lookup (#327)
* Reduces event listener overhead through granular subscriptions and elimination of a heap allocation (#333, #334, #335)

### Other

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
* Simple [core API](https://pkg.go.dev/github.com/mlange-42/arche/ecs). See the [API docs](https://pkg.go.dev/github.com/mlange-42/arche).
* Optional logic [filter](https://pkg.go.dev/github.com/mlange-42/arche/filter) and type-safe [generic](https://pkg.go.dev/github.com/mlange-42/arche/generic) API.
* Entity relations as first-class feature. See [Architecture](https://github.com/mlange-42/arche/blob/main/ARCHITECTURE.md#entity-relations).
* Supports serialization and deserialization of the entire world state via [arche-serde](https://github.com/mlange-42/arche-serde).
* No systems. Just queries. Use your own structure (or the [Tools](#tools)).
* No dependencies. Except for unit tests ([100% coverage](https://coveralls.io/github/mlange-42/arche)).
* Supports serialization and deserialization of the entire world state via [arche-serde](https://github.com/mlange-42/arche-serde).
* Probably the fastest Go ECS out there. See the [Benchmarks](#benchmarks).

## Installation
Expand Down
1 change: 1 addition & 0 deletions doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Package arche is an archetype-based Entity Component System for Go.
//
// See the sub-packages:
//
// - Core API -- [github.com/mlange-42/arche/ecs]
// - Generic queries -- [github.com/mlange-42/arche/generic]
// - Advanced filters -- [github.com/mlange-42/arche/filter]
Expand Down
40 changes: 39 additions & 1 deletion ecs/bitmask.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package ecs

import "math/bits"
import (
"math/bits"
)

// MaskTotalBits is the size of Mask in bits.
//
Expand Down Expand Up @@ -110,6 +112,42 @@ func (b *Mask) ContainsAny(other *Mask) bool {
b.bits[3]&other.bits[3] != 0
}

// And returns the bitwise AND of two masks.
func (b *Mask) And(other *Mask) Mask {
return Mask{
bits: [4]uint64{
b.bits[0] & other.bits[0],
b.bits[1] & other.bits[1],
b.bits[2] & other.bits[2],
b.bits[3] & other.bits[3],
},
}
}

// Or returns the bitwise OR of two masks.
func (b *Mask) Or(other *Mask) Mask {
return Mask{
bits: [4]uint64{
b.bits[0] | other.bits[0],
b.bits[1] | other.bits[1],
b.bits[2] | other.bits[2],
b.bits[3] | other.bits[3],
},
}
}

// Xor returns the bitwise XOR of two masks.
func (b *Mask) Xor(other *Mask) Mask {
return Mask{
bits: [4]uint64{
b.bits[0] ^ other.bits[0],
b.bits[1] ^ other.bits[1],
b.bits[2] ^ other.bits[2],
b.bits[3] ^ other.bits[3],
},
}
}

// TotalBitsSet returns how many bits are set in this mask.
func (b *Mask) TotalBitsSet() int {
return bits.OnesCount64(b.bits[0]) + bits.OnesCount64(b.bits[1]) + bits.OnesCount64(b.bits[2]) + bits.OnesCount64(b.bits[3])
Expand Down
6 changes: 6 additions & 0 deletions ecs/bitmask_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ func TestBitMask(t *testing.T) {
assert.False(t, mask.ContainsAny(&other2))
}

func TestBitMaskLogic(t *testing.T) {
assert.Equal(t, All(id(5)), all(id(0), id(5)).And(all(id(5), id(200))))
assert.Equal(t, All(id(0), id(5), id(200)), all(id(0), id(5)).Or(all(id(5), id(200))))
assert.Equal(t, All(id(0), id(200)), all(id(0), id(5)).Xor(all(id(5), id(200))))
}

func TestBitMaskCopy(t *testing.T) {
mask := All(id(1), id(2), id(13), id(27), id(200))
mask2 := mask
Expand Down
5 changes: 5 additions & 0 deletions ecs/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,10 @@
// with [Cache.Register] and [Cache.Unregister].
// - [Resources] provide a storage for global resources, with functionality like
// [Resources.Get], [Resources.Add] and [Resources.Remove].
// - [Listener] provides [EntityEvent] notifications for ECS operations.
// - Useful functions: [All], [ComponentID], [ResourceID], [GetResource], [AddResource].
//
// # Sub-packages
// - [github.com/mlange-42/arche/ecs/event] provides event subscription masks.
// - [github.com/mlange-42/arche/ecs/stats] provides world statistics for monitoring purposes.
package ecs
32 changes: 22 additions & 10 deletions ecs/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,47 +25,59 @@ import "github.com/mlange-42/arche/ecs/event"
// This allows the [World] to be in an unlocked state, and notifies after potential entity initialization.
type EntityEvent struct {
Entity Entity // The entity that was changed.
OldMask Mask // The old component masks. Get the new mask with [World.Mask].
Changed Mask // Mask indicating changed components (additions and removals).
Added, Removed []ID // Components added and removed. DO NOT MODIFY! Get the current components with [World.Ids].
OldRelation, NewRelation *ID // Old and new relation component ID. No relation is indicated by nil.
OldTarget Entity // Old relation target entity. Get the new target with [World.Relations] and [Relations.Get].
EventTypes event.Subscription // Bit mask of event types. See [Subscription].
EventTypes event.Subscription // Bit mask of event types. See [event.Subscription].
}

// Contains returns whether the event's types contain the given type/subscription bit.
func (e *EntityEvent) Contains(bit event.Subscription) bool {
return e.EventTypes.Contains(bit)
}

// Listener interface for listening to [EntityEvent]s.
// Listener interface for listening to [EntityEvent] notifications.
//
// A listener can be added to a [World] with [World.SetListener].
//
// See [EntityEvent] for details.
// See package [github.com/mlange-42/arche/listener] for Listener implementations.
type Listener interface {
// Notify the listener about a subscribed event.
Notify(evt EntityEvent)
Notify(world *World, evt EntityEvent)
// Subscriptions to event types.
Subscriptions() event.Subscription
// Components the listener subscribes to.
Components() *Mask
}

// testListener for [EntityEvent]s.
type testListener struct {
Callback func(e EntityEvent)
Callback func(world *World, e EntityEvent)
Subscribe event.Subscription
}

// newTestListener creates a new [CallbackListener] that subscribes to all event types.
func newTestListener(callback func(e EntityEvent)) testListener {
func newTestListener(callback func(world *World, e EntityEvent)) testListener {
return testListener{
Callback: callback,
Subscribe: event.EntityCreated | event.EntityRemoved | event.ComponentAdded | event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
}
}

// Notify the listener
func (l *testListener) Notify(e EntityEvent) {
l.Callback(e)
// Notify the listener.
func (l *testListener) Notify(world *World, e EntityEvent) {
l.Callback(world, e)
}

// Subscriptions of the listener
// Subscriptions of the listener in terms of event types.
func (l *testListener) Subscriptions() event.Subscription {
return l.Subscribe
}

// Components the listener subscribes to.
// Will be notified about changes on any (not all!) of the components in the mask.
func (l *testListener) Components() *Mask {
return nil
}
12 changes: 6 additions & 6 deletions ecs/event/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ package event
// Subscription bits for individual events
const (
// EntityCreated subscription bit
EntityCreated Subscription = 0b00000001
EntityCreated Subscription = 1
// EntityRemoved subscription bit
EntityRemoved Subscription = 0b00000010
EntityRemoved Subscription = 1 << 1
// ComponentAdded subscription bit
ComponentAdded Subscription = 0b00000100
ComponentAdded Subscription = 1 << 2
// ComponentRemoved subscription bit
ComponentRemoved Subscription = 0b000001000
ComponentRemoved Subscription = 1 << 3
// RelationChanged subscription bit
RelationChanged Subscription = 0b000010000
RelationChanged Subscription = 1 << 4
// TargetChanged subscription bit
TargetChanged Subscription = 0b000100000
TargetChanged Subscription = 1 << 5
)

// Subscription bits for groups of events
Expand Down
22 changes: 22 additions & 0 deletions ecs/event/event_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package event_test

import (
"fmt"
"testing"

"github.com/mlange-42/arche/ecs/event"
Expand All @@ -16,3 +17,24 @@ func TestSubscriptions(t *testing.T) {
assert.True(t, m1.ContainsAny(event.ComponentAdded|event.TargetChanged))
assert.False(t, m1.Contains(event.ComponentAdded|event.RelationChanged))
}

func ExampleSubscription() {
mask := event.EntityCreated | event.EntityRemoved

fmt.Printf("%08b contains\n%08b -> %t\n\n", mask, event.EntityRemoved, mask.Contains(event.EntityRemoved))
fmt.Printf("%08b contains\n%08b -> %t\n\n", mask, event.ComponentAdded, mask.Contains(event.ComponentAdded))

fmt.Printf("%08b contains any\n%08b -> %t\n\n", mask, event.EntityRemoved|event.ComponentAdded, mask.ContainsAny(event.EntityRemoved|event.ComponentAdded))
fmt.Printf("%08b contains any\n%08b -> %t\n\n", mask, event.ComponentAdded|event.ComponentRemoved, mask.ContainsAny(event.ComponentAdded|event.ComponentRemoved))
// Output: 00000011 contains
// 00000010 -> true
//
// 00000011 contains
// 00000100 -> false
//
// 00000011 contains any
// 00000110 -> true
//
// 00000011 contains any
// 00001100 -> false
}
24 changes: 12 additions & 12 deletions ecs/event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func BenchmarkEntityEventCreate(b *testing.B) {

b.StartTimer()
for i := 0; i < b.N; i++ {
event = ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil}
event = ecs.EntityEvent{Entity: e, Changed: mask, Added: added, Removed: nil}
}
b.StopTimer()
_ = event
Expand All @@ -68,7 +68,7 @@ func BenchmarkEntityEventHeapPointer(b *testing.B) {

b.StartTimer()
for i := 0; i < b.N; i++ {
event = &ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil}
event = &ecs.EntityEvent{Entity: e, Changed: mask, Added: added, Removed: nil}
}
b.StopTimer()
_ = event
Expand All @@ -78,13 +78,13 @@ func BenchmarkEntityEventCopy(b *testing.B) {
handler := eventHandler{}

for i := 0; i < b.N; i++ {
handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil})
handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil})
}
}

func BenchmarkEntityEventCopyReuse(b *testing.B) {
handler := eventHandler{}
event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil}
event := ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil}

for i := 0; i < b.N; i++ {
handler.ListenCopy(event)
Expand All @@ -95,13 +95,13 @@ func BenchmarkEntityEventPointer(b *testing.B) {
handler := eventHandler{}

for i := 0; i < b.N; i++ {
handler.ListenPointer(&ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil})
handler.ListenPointer(&ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil})
}
}

func BenchmarkEntityEventPointerReuse(b *testing.B) {
handler := eventHandler{}
event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil}
event := ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil}

for i := 0; i < b.N; i++ {
handler.ListenPointer(&event)
Expand All @@ -111,20 +111,20 @@ func BenchmarkEntityEventPointerReuse(b *testing.B) {
func ExampleEntityEvent() {
world := ecs.NewWorld()

listener := NewTestListener(
func(evt ecs.EntityEvent) { fmt.Println(evt) },
)
listener := TestListener{
Callback: func(world *ecs.World, evt ecs.EntityEvent) { fmt.Println(evt) },
}
world.SetListener(&listener)

world.NewEntity()
// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1}
}

func ExampleEntityEvent_Contains() {
mask := event.EntityCreated | event.EntityRemoved
evt := ecs.EntityEvent{EventTypes: event.EntityCreated | event.EntityRemoved}

fmt.Println(mask.Contains(event.EntityRemoved))
fmt.Println(mask.Contains(event.RelationChanged))
fmt.Println(evt.Contains(event.EntityRemoved))
fmt.Println(evt.Contains(event.RelationChanged))
// Output: true
// false
}
8 changes: 8 additions & 0 deletions ecs/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ func subscription(entityCreated, entityRemoved, componentAdded, componentRemoved
return bits
}

// Returns whether a listener that subscribes to an event is also interested in terms of component subscription.
func subscribes(evtType event.Subscription, changed *Mask, subs *Mask, oldRel *ID, newRel *ID) bool {
if event.Relations.Contains(evtType) {
return subs == nil || (oldRel != nil && subs.Get(*oldRel)) || (newRel != nil && subs.Get(*newRel))
}
return subs == nil || subs.ContainsAny(changed)
}

func maskToTypes(mask Mask, reg *componentRegistry) []componentType {
count := int(mask.TotalBitsSet())
types := make([]componentType, count)
Expand Down
34 changes: 34 additions & 0 deletions ecs/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"
"unsafe"

"github.com/mlange-42/arche/ecs/event"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -64,6 +65,39 @@ func TestPagedSlice(t *testing.T) {
assert.Equal(t, int32(100), *a.Get(3))
}

func TestSubscribes(t *testing.T) {
id1 := id(1)
id2 := id(2)
id3 := id(3)

assert.True(t,
subscribes(event.ComponentAdded, all(id1), all(id1, id2), nil, nil),
)
assert.True(t,
subscribes(event.ComponentAdded, all(id1, id2), all(id2), nil, nil),
)
assert.False(t,
subscribes(event.ComponentAdded, all(id1, id2), all(id3), nil, nil),
)

assert.True(t,
subscribes(event.RelationChanged, &Mask{}, all(id1, id2), nil, &id1),
)
assert.True(t,
subscribes(event.RelationChanged, &Mask{}, all(id1, id2), &id1, &id3),
)
assert.False(t,
subscribes(event.RelationChanged, &Mask{}, all(id1), &id2, &id3),
)

assert.True(t,
subscribes(event.TargetChanged, &Mask{}, all(id1, id2), &id1, &id1),
)
assert.False(t,
subscribes(event.TargetChanged, &Mask{}, all(id1, id2), &id3, &id3),
)
}

func TestPagedSlicePointerPersistence(t *testing.T) {
a := pagedSlice[int32]{}

Expand Down
Loading
Loading