diff --git a/README.md b/README.md index 820450f..409ca84 100644 --- a/README.md +++ b/README.md @@ -283,18 +283,18 @@ Values are the median over 6 runs: | BenchmarkIterateUECS-16 | 5035 | 237613 | 128 | 3 | | BenchmarkAddUECS-16 | 34 | 31213636 | 4437536 | 100004 | | BenchmarkRemoveUECS-16 | 38 | 29573272 | 3309389 | 100000 | -| BenchmarkCreateEntityVolt-16 | 70 | 15858217 | 35197857 | 100101 | -| BenchmarkIterateVolt-16 | 3900 | 302282 | 144 | 5 | -| (DEPRECATED) BenchmarkIterateConcurrentlyVolt-16 | 11877 | 100236 | 3332 | 94 | -| BenchmarkTaskVolt-16 | 12320 | 97474 | 1856 | 39 | -| BenchmarkAddVolt-16 | 121 | 9782019 | 2866598 | 200000 | -| BenchmarkRemoveVolt-16 | 160 | 7447984 | 0 | 0 | +| BenchmarkCreateEntityVolt-16 | 82 | 13722710 | 35197352 | 100101 | +| BenchmarkIterateVolt-16 | 3910 | 301468 | 144 | 5 | +| (DEPRECATED) BenchmarkIterateConcurrentlyVolt-16 | 12021 | 99938 | 3333 | 94 | +| BenchmarkTaskVolt-16 | 12362 | 96913 | 1856 | 39 | +| BenchmarkAddVolt-16 | 186 | 6321860 | 2772970 | 200000 | +| BenchmarkRemoveVolt-16 | 287 | 4163989 | 0 | 0 | These results show a few things: -- Arche is still the fastest tool for raw write operations. In our game development though we would rather lean towards fastest read operations, because the games loops will read way more often than write. +- Arche still leads on entity creation and on raw component addition, but Volt is now competitive on writes: it is on par with (and on Remove slightly faster than) Arche, while staying allocation-light. - Unitoftime/ecs is the fastest tool for read operations on one thread only, but the writes are currently way slower than Arche and Volt (except on the Create benchmark). -- Volt is a good compromise, an in-between: fast enough add/remove operations, and almost as fast as Arche and UECS for reads on one thread. -- Volt's write path is now much lighter on the garbage collector: thanks to the archetype transition graph and the typed storage, removing a component allocates nothing (0 allocs/op) and adding one roughly halved its allocations compared to previous versions. +- Volt is a good compromise, an in-between: fast add/remove operations, and almost as fast as Arche and UECS for reads on one thread. +- Volt's write path is now much lighter and faster: thanks to the archetype transition graph, the typed storage and the archetype-indexed (map-free) component storage, removing a component allocates nothing (0 allocs/op) and both add and remove dropped significantly in time compared to previous versions. Volt uses the new iterators from go1.23, which in their current implementation are slower than using a function call in the for-loop inside the Query (as done in UECS). This means, if the Go team finds a way to improve the performances from the iterators, we can hope to acheive near performances as UECS. - Thanks to the iterators, Volt provides a simple way to use goroutines for read operations. The data is received through a channel of iterator. diff --git a/query.go b/query.go index 906aaca..4fec5af 100644 --- a/query.go +++ b/query.go @@ -94,7 +94,7 @@ func (query *Query1[A]) Foreach(filterFn func(QueryResult1[A]) bool) iter.Seq[Qu archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) var dataA *A for i, entityId := range archetype.entities { if sliceA != nil { @@ -129,7 +129,7 @@ func (query *Query1[A]) Task(workersCount int, filterFn func(QueryResult1[A]) bo archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) task(workersCount, archetype.entities, func(i int, data EntityId) { var result QueryResult1[A] @@ -170,7 +170,7 @@ func (query *Query1[A]) ForeachChannel(chunkSize int, filterFn func(QueryResult1 archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) for i := 0; i < len(archetype.entities); i += chunkSize { result := queryResultChunk1[A]{} @@ -292,8 +292,8 @@ func (query *Query2[A, B]) Foreach(filterFn func(QueryResult2[A, B]) bool) iter. archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) var result QueryResult2[A, B] for i, entityId := range archetype.entities { @@ -329,8 +329,8 @@ func (query *Query2[A, B]) Task(workersCount int, filterFn func(QueryResult2[A, archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) task(workersCount, archetype.entities, func(i int, data EntityId) { var result QueryResult2[A, B] @@ -375,8 +375,8 @@ func (query *Query2[A, B]) ForeachChannel(chunkSize int, filterFn func(QueryResu archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) for i := 0; i < len(archetype.entities); i += chunkSize { result := queryResultChunk2[A, B]{} @@ -508,9 +508,9 @@ func (query *Query3[A, B, C]) Foreach(filterFn func(QueryResult3[A, B, C]) bool) archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) var dataA *A var dataB *B var dataC *C @@ -557,9 +557,9 @@ func (query *Query3[A, B, C]) Task(workersCount int, filterFn func(QueryResult3[ archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) task(workersCount, archetype.entities, func(i int, data EntityId) { var result QueryResult3[A, B, C] @@ -608,9 +608,9 @@ func (query *Query3[A, B, C]) ForeachChannel(chunkSize int, filterFn func(QueryR archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) for i := 0; i < len(archetype.entities); i += chunkSize { result := queryResultChunk3[A, B, C]{} @@ -752,10 +752,10 @@ func (query *Query4[A, B, C, D]) Foreach(filterFn func(QueryResult4[A, B, C, D]) archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) var dataA *A var dataB *B var dataC *C @@ -808,10 +808,10 @@ func (query *Query4[A, B, C, D]) Task(workersCount int, filterFn func(QueryResul archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) task(workersCount, archetype.entities, func(i int, data EntityId) { var result QueryResult4[A, B, C, D] @@ -864,10 +864,10 @@ func (query *Query4[A, B, C, D]) ForeachChannel(chunkSize int, filterFn func(Que archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) for i := 0; i < len(archetype.entities); i += chunkSize { result := queryResultChunk4[A, B, C, D]{} @@ -1018,11 +1018,11 @@ func (query *Query5[A, B, C, D, E]) Foreach(filterFn func(QueryResult5[A, B, C, archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) var dataA *A var dataB *B var dataC *C @@ -1081,11 +1081,11 @@ func (query *Query5[A, B, C, D, E]) Task(workersCount int, filterFn func(QueryRe archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) task(workersCount, archetype.entities, func(i int, data EntityId) { var result QueryResult5[A, B, C, D, E] @@ -1142,11 +1142,11 @@ func (query *Query5[A, B, C, D, E]) ForeachChannel(chunkSize int, filterFn func( archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) for i := 0; i < len(archetype.entities); i += chunkSize { result := queryResultChunk5[A, B, C, D, E]{} @@ -1307,12 +1307,12 @@ func (query *Query6[A, B, C, D, E, F]) Foreach(filterFn func(QueryResult6[A, B, archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] - sliceF := storageF.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) + sliceF := storageF.getColumn(archetype.Id) var dataA *A var dataB *B var dataC *C @@ -1377,12 +1377,12 @@ func (query *Query6[A, B, C, D, E, F]) Task(workersCount int, filterFn func(Quer archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] - sliceF := storageF.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) + sliceF := storageF.getColumn(archetype.Id) task(workersCount, archetype.entities, func(i int, data EntityId) { var result QueryResult6[A, B, C, D, E, F] @@ -1443,12 +1443,12 @@ func (query *Query6[A, B, C, D, E, F]) ForeachChannel(chunkSize int, filterFn fu archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] - sliceF := storageF.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) + sliceF := storageF.getColumn(archetype.Id) for i := 0; i < len(archetype.entities); i += chunkSize { result := queryResultChunk6[A, B, C, D, E, F]{} @@ -1619,13 +1619,13 @@ func (query *Query7[A, B, C, D, E, F, G]) Foreach(filterFn func(QueryResult7[A, archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] - sliceF := storageF.archetypesComponentsEntities[archetype.Id] - sliceG := storageG.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) + sliceF := storageF.getColumn(archetype.Id) + sliceG := storageG.getColumn(archetype.Id) var dataA *A var dataB *B var dataC *C @@ -1696,13 +1696,13 @@ func (query *Query7[A, B, C, D, E, F, G]) Task(workersCount int, filterFn func(Q archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] - sliceF := storageF.archetypesComponentsEntities[archetype.Id] - sliceG := storageG.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) + sliceF := storageF.getColumn(archetype.Id) + sliceG := storageG.getColumn(archetype.Id) task(workersCount, archetype.entities, func(i int, data EntityId) { var result QueryResult7[A, B, C, D, E, F, G] @@ -1767,13 +1767,13 @@ func (query *Query7[A, B, C, D, E, F, G]) ForeachChannel(chunkSize int, filterFn archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] - sliceF := storageF.archetypesComponentsEntities[archetype.Id] - sliceG := storageG.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) + sliceF := storageF.getColumn(archetype.Id) + sliceG := storageG.getColumn(archetype.Id) for i := 0; i < len(archetype.entities); i += chunkSize { result := queryResultChunk7[A, B, C, D, E, F, G]{} @@ -1954,14 +1954,14 @@ func (query *Query8[A, B, C, D, E, F, G, H]) Foreach(filterFn func(QueryResult8[ archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] - sliceF := storageF.archetypesComponentsEntities[archetype.Id] - sliceG := storageG.archetypesComponentsEntities[archetype.Id] - sliceH := storageH.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) + sliceF := storageF.getColumn(archetype.Id) + sliceG := storageG.getColumn(archetype.Id) + sliceH := storageH.getColumn(archetype.Id) var dataA *A var dataB *B var dataC *C @@ -2037,14 +2037,14 @@ func (query *Query8[A, B, C, D, E, F, G, H]) Task(workersCount int, filterFn fun archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] - sliceF := storageF.archetypesComponentsEntities[archetype.Id] - sliceG := storageG.archetypesComponentsEntities[archetype.Id] - sliceH := storageH.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) + sliceF := storageF.getColumn(archetype.Id) + sliceG := storageG.getColumn(archetype.Id) + sliceH := storageH.getColumn(archetype.Id) task(workersCount, archetype.entities, func(i int, data EntityId) { var result QueryResult8[A, B, C, D, E, F, G, H] @@ -2113,14 +2113,14 @@ func (query *Query8[A, B, C, D, E, F, G, H]) ForeachChannel(chunkSize int, filte archetypes := query.filter() for _, archetype := range archetypes { - sliceA := storageA.archetypesComponentsEntities[archetype.Id] - sliceB := storageB.archetypesComponentsEntities[archetype.Id] - sliceC := storageC.archetypesComponentsEntities[archetype.Id] - sliceD := storageD.archetypesComponentsEntities[archetype.Id] - sliceE := storageE.archetypesComponentsEntities[archetype.Id] - sliceF := storageF.archetypesComponentsEntities[archetype.Id] - sliceG := storageG.archetypesComponentsEntities[archetype.Id] - sliceH := storageH.archetypesComponentsEntities[archetype.Id] + sliceA := storageA.getColumn(archetype.Id) + sliceB := storageB.getColumn(archetype.Id) + sliceC := storageC.getColumn(archetype.Id) + sliceD := storageD.getColumn(archetype.Id) + sliceE := storageE.getColumn(archetype.Id) + sliceF := storageF.getColumn(archetype.Id) + sliceG := storageG.getColumn(archetype.Id) + sliceH := storageH.getColumn(archetype.Id) for i := 0; i < len(archetype.entities); i += chunkSize { result := queryResultChunk8[A, B, C, D, E, F, G, H]{} diff --git a/query_test.go b/query_test.go index ac78c20..8619d68 100644 --- a/query_test.go +++ b/query_test.go @@ -1539,3 +1539,47 @@ func TestQuery8_ForeachChannel(t *testing.T) { } } } + +// TestQueryOptionalComponentAbsentFromHigherArchetype guards a regression in the +// archetype-indexed storage: a query with an OPTIONAL component matches archetypes +// that do not contain it. Such an archetype can have an id beyond the optional +// component's column slice, so the column must be fetched in a bounds-safe way +// (returning nil) instead of indexing directly, which used to panic. +func TestQueryOptionalComponentAbsentFromHigherArchetype(t *testing.T) { + world := CreateWorld(64) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + RegisterComponent[testComponent3](world, &ComponentConfig[testComponent3]{}) + RegisterComponent[testComponent4](world, &ComponentConfig[testComponent4]{}) + + // Archetype {1,2,3,4} is created first (lower id), growing component 4's column. + for i := 0; i < 3; i++ { + if _, err := CreateEntityWithComponents4(world, testComponent1{}, testComponent2{}, testComponent3{}, testComponent4{}); err != nil { + t.Fatalf("%s", err.Error()) + } + } + // Archetype {1,2,3} is created after (higher id) and never appears in component 4's column. + for i := 0; i < 3; i++ { + if _, err := CreateEntityWithComponents3(world, testComponent1{}, testComponent2{}, testComponent3{}); err != nil { + t.Fatalf("%s", err.Error()) + } + } + + q := CreateQuery4[testComponent1, testComponent2, testComponent3, testComponent4](world, + QueryConfiguration{OptionalComponents: []OptionalComponent{OptionalComponent(testComponent4Id)}}) + + // Foreach must not panic and must yield all 6 entities; D is nil for the {1,2,3} ones. + withD, total := 0, 0 + for result := range q.Foreach(nil) { + total++ + if result.D != nil { + withD++ + } + } + if total != 6 || withD != 3 { + t.Fatalf("Foreach: expected total=6 withD=3, got total=%d withD=%d", total, withD) + } + + // Task path must not panic either. + q.Task(2, nil, func(r QueryResult4[testComponent1, testComponent2, testComponent3, testComponent4]) {}) +} diff --git a/storage.go b/storage.go index 467d9f8..eded898 100644 --- a/storage.go +++ b/storage.go @@ -2,8 +2,6 @@ package volt import ( "fmt" - "maps" - "slices" ) func getStorage[T ComponentInterface](world *World) *ComponentsStorage[T] { @@ -17,7 +15,7 @@ func getStorage[T ComponentInterface](world *World) *ComponentsStorage[T] { if world.storage[componentId] == nil { s := &ComponentsStorage[T]{ componentId: componentId, - archetypesComponentsEntities: make(ArchetypesComponentsEntities[T]), + archetypesComponentsEntities: make(ArchetypesComponentsEntities[T], 0), } world.storage[componentId] = s } @@ -52,7 +50,12 @@ type storage interface { delete(archetypeId archetypeId, key int) } -type ArchetypesComponentsEntities[T ComponentInterface] map[archetypeId][]T +// ArchetypesComponentsEntities stores, for each archetype, the column of T +// components (Structure of Arrays). It is indexed directly by archetypeId: +// archetypeId is dense (0, 1, 2, ...), so a slice avoids the hashing cost of a +// map on every storage access, in both read (queries) and write paths. A nil +// column means the archetype does not hold this component. +type ArchetypesComponentsEntities[T ComponentInterface] [][]T type ComponentsStorage[T ComponentInterface] struct { componentId ComponentId @@ -64,21 +67,54 @@ func (c *ComponentsStorage[T]) getType() ComponentId { } func (c *ComponentsStorage[T]) getArchetypes() []archetypeId { - return slices.Collect(maps.Keys(c.archetypesComponentsEntities)) + var archetypes []archetypeId + for id, column := range c.archetypesComponentsEntities { + if column != nil { + archetypes = append(archetypes, archetypeId(id)) + } + } + + return archetypes } func (c *ComponentsStorage[T]) hasArchetype(archetypeId archetypeId) bool { - if _, ok := c.archetypesComponentsEntities[archetypeId]; !ok { - return false + return int(archetypeId) < len(c.archetypesComponentsEntities) && c.archetypesComponentsEntities[archetypeId] != nil +} + +// getColumn returns the component column for archetypeId, or nil if this storage +// holds no data for it. It is bounds-safe: an archetype that does not contain +// this component (e.g. an optional component absent from the archetype) has an +// id beyond the columns slice, so a raw index would panic. +// +// Kept out of line on purpose: inlining it into the query hot loops perturbs +// their codegen and slows iteration. As a per-archetype call its cost is +// negligible, mirroring how the previous map access stayed out of line. +// +//go:noinline +func (c *ComponentsStorage[T]) getColumn(archetypeId archetypeId) []T { + if int(archetypeId) >= len(c.archetypesComponentsEntities) { + return nil } - return true + return c.archetypesComponentsEntities[archetypeId] } func (c *ComponentsStorage[T]) size(archetypeId archetypeId) int { + if int(archetypeId) >= len(c.archetypesComponentsEntities) { + return 0 + } + return len(c.archetypesComponentsEntities[archetypeId]) } +// grow extends the columns slice so that archetypeId is a valid index. +// Growth is amortized through append, and new columns start as nil. +func (c *ComponentsStorage[T]) grow(archetypeId archetypeId) { + for len(c.archetypesComponentsEntities) <= int(archetypeId) { + c.archetypesComponentsEntities = append(c.archetypesComponentsEntities, nil) + } +} + func (c *ComponentsStorage[T]) add(archetypeId archetypeId, component ComponentInterface) int { return c.addTyped(archetypeId, component.(T)) } @@ -87,7 +123,7 @@ func (c *ComponentsStorage[T]) add(archetypeId archetypeId, component ComponentI // The generic add/copy paths hold a concrete *ComponentsStorage[T], so they can // call this directly and avoid one heap allocation per component added. func (c *ComponentsStorage[T]) addTyped(archetypeId archetypeId, component T) int { - // We compute the size ourselves instead of calling c.size to reduce mapaccess. + c.grow(archetypeId) c.archetypesComponentsEntities[archetypeId] = append(c.archetypesComponentsEntities[archetypeId], component) return len(c.archetypesComponentsEntities[archetypeId]) - 1 @@ -106,21 +142,11 @@ func (c *ComponentsStorage[T]) get(archetypeId archetypeId, key int) any { } func (c *ComponentsStorage[T]) moveLastToKey(archetypeId archetypeId, recordKey int) { - // this function could be simplified using: - // lastKey := c.size(archetypeId) - 1 - // c.set(archetypeId, recordKey, c.archetypesComponentsEntities[archetypeId][lastKey]) - // c.delete(archetypeId, lastKey) - // but this would imply lot of map access, reducing the performances - data := c.archetypesComponentsEntities[archetypeId] - size := len(data) - lastKey := size - 1 + lastKey := len(data) - 1 data[recordKey] = data[lastKey] - - if lastKey < size { - c.archetypesComponentsEntities[archetypeId] = append(data[:lastKey], data[lastKey+1:]...) - } + c.archetypesComponentsEntities[archetypeId] = data[:lastKey] } func (c *ComponentsStorage[T]) delete(archetypeId archetypeId, key int) {