diff --git a/examples/new-api/systems/render-overlay.go b/examples/new-api/systems/render-overlay.go index 6ef0dd7..a720491 100644 --- a/examples/new-api/systems/render-overlay.go +++ b/examples/new-api/systems/render-overlay.go @@ -27,6 +27,14 @@ import ( "time" ) +const ( + fpsAvgSamples = 100 + fontSize = 20 + fpsGraphWidth = 160 + fpsGraphHeight = 60 + msGraphMaxValue = 33.33 // Max ms to show on graph (30 FPS) +) + func NewRenderOverlaySystem() RenderOverlaySystem { return RenderOverlaySystem{} } @@ -50,6 +58,17 @@ type RenderOverlaySystem struct { monitorHeight int debugLvl int debug bool + lastFPSTime time.Time + frameCount int + currentFPS int + fpsSamples []int + fpsSampleSum int + fpsSampleIdx int + avgFPS float64 + percentileFPS int + lastFrameDuration time.Duration + msHistory []float64 // ms per frame, ring buffer + msHistoryIdx int } func (s *RenderOverlaySystem) Init() { @@ -65,6 +84,9 @@ func (s *RenderOverlaySystem) Init() { Tint: rl.White, Dst: rl.Rectangle{Width: float32(s.monitorWidth), Height: float32(s.monitorHeight)}, }) + + // Initialize ms history buffer for graph + s.msHistory = make([]float64, fpsGraphWidth) } func (s *RenderOverlaySystem) Run(dt time.Duration) bool { @@ -88,6 +110,48 @@ func (s *RenderOverlaySystem) Run(dt time.Duration) bool { } } + // FPS calculation (custom) + now := time.Now() + if s.lastFPSTime.IsZero() { + s.lastFPSTime = now + s.fpsSamples = make([]int, fpsAvgSamples) + s.msHistory = make([]float64, fpsGraphWidth) + } + s.frameCount++ + s.lastFrameDuration = dt + + // Store current frame FPS in samples + frameFPS := 0 + if dt > 0 { + // Correct calculation: convert duration to frames per second + frameFPS = int(time.Second / dt) + } + s.fpsSampleSum -= s.fpsSamples[s.fpsSampleIdx] + s.fpsSamples[s.fpsSampleIdx] = frameFPS + s.fpsSampleSum += frameFPS + s.fpsSampleIdx = (s.fpsSampleIdx + 1) % len(s.fpsSamples) + + // Calculate average FPS over samples + s.avgFPS = float64(s.fpsSampleSum) / float64(len(s.fpsSamples)) + + // Calculate 1% FPS (lowest 1% frame in the sample window) + s.percentileFPS = s.calcPercentileFPS(0.01) + + // Update frame time history (ms) on every frame + // Use average of last two frames for smoother graph + var ms float64 + if s.lastFrameDuration > 0 { + ms = float64(s.lastFrameDuration.Microseconds()) / 1000.0 + s.msHistory[s.msHistoryIdx] = ms + s.msHistoryIdx = (s.msHistoryIdx + 1) % len(s.msHistory) + } + + if now.Sub(s.lastFPSTime) >= time.Second { + s.currentFPS = s.frameCount + s.frameCount = 0 + s.lastFPSTime = now + } + s.Cameras.EachEntity()(func(entity ecs.Entity) bool { camera := s.Cameras.GetUnsafe(entity) frame := s.FrameBuffer2D.GetUnsafe(entity) @@ -233,14 +297,18 @@ func (s *RenderOverlaySystem) Run(dt time.Duration) bool { } // Print stats - rl.DrawRectangleRec(rl.Rectangle{Height: 120, Width: 200}, rl.Black) - rl.DrawFPS(10, 10) - rl.DrawText(fmt.Sprintf("%d entities", s.EntityManager.Size()), 10, 70, 20, rl.RayWhite) - rl.DrawText(fmt.Sprintf("%d debugLvl", s.debugLvl), 10, 90, 20, rl.RayWhite) + const x = 10 + const y = 10 + statsPanelWidth := float32(200 + fpsGraphWidth) + statsPanelHeight := float32(y + fontSize*8) + rl.DrawRectangleRec(rl.Rectangle{Height: statsPanelHeight, Width: statsPanelWidth}, rl.Black) + s.drawCustomFPS(x, y) + rl.DrawText(fmt.Sprintf("%d entities", s.EntityManager.Size()), x, y+fontSize*6, fontSize, rl.RayWhite) + rl.DrawText(fmt.Sprintf("%d debugLvl", s.debugLvl), x, y+fontSize*7, 20, rl.RayWhite) // Game over s.SceneManager.EachComponent()(func(a *components.AsteroidSceneManager) bool { - rl.DrawText(fmt.Sprintf("Player HP: %d", a.PlayerHp), 10, 30, 20, rl.RayWhite) - rl.DrawText(fmt.Sprintf("Score: %d", a.PlayerScore), 10, 50, 20, rl.RayWhite) + rl.DrawText(fmt.Sprintf("Player HP: %d", a.PlayerHp), x, y+fontSize*4, 20, rl.RayWhite) + rl.DrawText(fmt.Sprintf("Score: %d", a.PlayerScore), x, y+fontSize*5, 20, rl.RayWhite) if a.PlayerHp <= 0 { text := "Game Over" textSize := rl.MeasureTextEx(rl.GetFontDefault(), text, 96, 0) @@ -264,6 +332,167 @@ func (s *RenderOverlaySystem) Run(dt time.Duration) bool { return true } +// Draws FPS stats: 1% low, current frame, average, and current FPS +func (s *RenderOverlaySystem) drawCustomFPS(x, y int32) { + fps := int32(s.currentFPS) + + // Frame time in milliseconds + frameTimeMs := 0.0 + if s.lastFrameDuration > 0 { + frameTimeMs = float64(s.lastFrameDuration.Microseconds()) / 1000.0 + } + + avgFPS := int32(s.avgFPS) + percentileFPS := int32(s.percentileFPS) + + // Colors + fontColor := rl.Lime + if fps < 30 { + fontColor = rl.Red + } else if fps < 60 { + fontColor = rl.Yellow + } + + // Frame time color (lower is better) + frameTimeColor := rl.Lime + if frameTimeMs > 33.33 { // 30 FPS threshold (33.33ms) + frameTimeColor = rl.Red + } else if frameTimeMs > 16.67 { // 60 FPS threshold (16.67ms) + frameTimeColor = rl.Yellow + } + + // Draw all stats + rl.DrawText(fmt.Sprintf("FPS: %d", fps), x, y, fontSize, fontColor) + rl.DrawText(fmt.Sprintf("Frame: %.2f ms", frameTimeMs), x, y+fontSize, fontSize, frameTimeColor) + rl.DrawText(fmt.Sprintf("Avg %d: %d", fpsAvgSamples, avgFPS), x, y+fontSize*2, fontSize, fontColor) + rl.DrawText(fmt.Sprintf("1%% Low: %d", percentileFPS), x, y+fontSize*3, fontSize, fontColor) + + // Draw ms graph + s.drawMsGraph(x+180, y) +} + +// Draw a graph of historical frame times in milliseconds +func (s *RenderOverlaySystem) drawMsGraph(x, y int32) { + // Draw graph border + rl.DrawRectangleLinesEx(rl.Rectangle{ + X: float32(x), + Y: float32(y), + Width: float32(fpsGraphWidth), + Height: float32(fpsGraphHeight), + }, 1, rl.Gray) + + // Draw graph background + rl.DrawRectangle(x+1, y+1, fpsGraphWidth-2, fpsGraphHeight-2, rl.Black) + + // Draw horizontal reference lines (33.33ms, 16.67ms, 8.33ms) + // These correspond to 30 FPS, 60 FPS, and 120 FPS + refLines := []struct { + ms float32 + color rl.Color + label string + }{ + {33.33, rl.Red, "33.33 (30 FPS)"}, + {16.67, rl.Yellow, "16.67 (60 FPS)"}, + {8.33, rl.Green, "8.33 (120 FPS)"}, + } + for _, ref := range refLines { + refY := y + int32(float32(fpsGraphHeight)*(ref.ms/msGraphMaxValue)) + rl.DrawLineEx( + rl.NewVector2(float32(x), float32(refY)), + rl.NewVector2(float32(x+int32(fpsGraphWidth)), float32(refY)), + 1.0, + ref.color, + ) + rl.DrawText(ref.label, x+int32(fpsGraphWidth)+2, refY-8, 10, rl.Fade(ref.color, 0.8)) + } + + // Start from the oldest sample and move forward + startIdx := s.msHistoryIdx % len(s.msHistory) + + // Draw frame time data points and connect with lines + for i := 0; i < len(s.msHistory)-1; i++ { + // Calculate indices in a way that we're drawing from left to right, + // with the newest data on the right + idx := (startIdx + i) % len(s.msHistory) + nextIdx := (startIdx + i + 1) % len(s.msHistory) + + ms1 := float32(s.msHistory[idx]) + ms2 := float32(s.msHistory[nextIdx]) + + // Clamp values to max + if ms1 > msGraphMaxValue { + ms1 = msGraphMaxValue + } + if ms2 > msGraphMaxValue { + ms2 = msGraphMaxValue + } + + // Calculate positions (note: for ms, higher value = worse performance, so we scale directly) + x1 := x + int32(i) + y1 := y + int32(float32(fpsGraphHeight)*(ms1/msGraphMaxValue)) + x2 := x + int32(i+1) + y2 := y + int32(float32(fpsGraphHeight)*(ms2/msGraphMaxValue)) + + // Choose color based on frame time + lineColor := rl.Green + if ms2 > 16.67 { // 60 FPS threshold + lineColor = rl.Yellow + } + if ms2 > 33.33 { // 30 FPS threshold + lineColor = rl.Red + } + + // Skip drawing if either value is zero (not yet initialized) + if ms1 > 0 && ms2 > 0 { + rl.DrawLineEx( + rl.NewVector2(float32(x1), float32(y1)), + rl.NewVector2(float32(x2), float32(y2)), + 2.0, + lineColor, + ) + } + } + + // Draw a vertical line indicating the current position in the buffer + currentX := x + int32(len(s.msHistory)-1) + rl.DrawLineEx( + rl.NewVector2(float32(currentX), float32(y)), + rl.NewVector2(float32(currentX), float32(y+int32(fpsGraphHeight))), + 1.0, + rl.White, + ) +} + +// Calculates the given percentile FPS (e.g., 0.01 for 1% low) +func (s *RenderOverlaySystem) calcPercentileFPS(percentile float64) int { + n := len(s.fpsSamples) + if n == 0 { + return 0 + } + // Copy and sort samples + sorted := make([]int, n) + copy(sorted, s.fpsSamples) + for i := 1; i < n; i++ { + key := sorted[i] + j := i - 1 + for j >= 0 && sorted[j] > key { + sorted[j+1] = sorted[j] + j-- + } + sorted[j+1] = key + } + + // For 1% low, we want the 1st percentile (lowest values) + idx := int(float64(n) * percentile) + if idx < 0 { + idx = 0 + } + if idx >= n { + idx = n - 1 + } + return sorted[idx] +} + func (s *RenderOverlaySystem) intersects(rect1, rect2 vectors.Rectangle) bool { return rect1.X < rect2.X+rect2.Width && rect1.X+rect1.Width > rect2.X && diff --git a/pkg/ecs/component-bit-table.go b/pkg/ecs/component-bit-table.go index 2307b9d..2802663 100644 --- a/pkg/ecs/component-bit-table.go +++ b/pkg/ecs/component-bit-table.go @@ -20,15 +20,14 @@ import ( ) const ( - uintShift = 7 - 64/bits.UintSize - pageSizeMask = pageSize - 1 + uintShift = 7 - 64/bits.UintSize ) func NewComponentBitTable(maxComponentsLen int) ComponentBitTable { bitsetSize := ((maxComponentsLen - 1) / bits.UintSize) + 1 return ComponentBitTable{ bitsetsBook: make([][]uint, 0, initialBookSize), - entitiesBook: make([][]Entity, 0, initialBookSize), + entitiesBook: make([]*entityArray, 0, initialBookSize), lookup: NewPagedMap[Entity, int](), bitsetSize: bitsetSize, pageSize: bitsetSize * pageSize, @@ -37,13 +36,15 @@ func NewComponentBitTable(maxComponentsLen int) ComponentBitTable { type ComponentBitTable struct { bitsetsBook [][]uint - entitiesBook [][]Entity + entitiesBook []*entityArray lookup PagedMap[Entity, int] length int bitsetSize int pageSize int } +type entityArray [pageSize]Entity + func (b *ComponentBitTable) Create(entity Entity) { assert.False(b.lookup.Has(entity), "entity already exists") @@ -140,7 +141,7 @@ func (b *ComponentBitTable) extend() { lastChunkId, lastEntityId := b.getPageIDAndEntityIndex(b.length) if lastChunkId == len(b.bitsetsBook) && lastEntityId == 0 { b.bitsetsBook = append(b.bitsetsBook, make([]uint, b.pageSize)) - b.entitiesBook = append(b.entitiesBook, make([]Entity, pageSize)) + b.entitiesBook = append(b.entitiesBook, &entityArray{}) } } diff --git a/pkg/ecs/paged-array.go b/pkg/ecs/paged-array.go index b81f033..e5e93c5 100644 --- a/pkg/ecs/paged-array.go +++ b/pkg/ecs/paged-array.go @@ -13,10 +13,12 @@ import ( ) func NewPagedArray[T any]() (a PagedArray[T]) { - a.book = make([]ArrayPage[T], initialBookSize) + a.book = make([]*ArrayPage[T], initialBookSize) + for i := 0; i < initialBookSize; i++ { + a.book[i] = &ArrayPage[T]{} + } a.edpTasks = make([]EachDataTask[T], initialBookSize) a.edvpTasks = make([]EachDataValueTask[T], initialBookSize) - return a } @@ -26,7 +28,7 @@ type SlicePage[T any] struct { } type PagedArray[T any] struct { - book []ArrayPage[T] + book []*ArrayPage[T] currentPageIndex int len int wg sync.WaitGroup @@ -50,7 +52,7 @@ func (a *PagedArray[T]) Get(index int) *T { assert.True(index < a.len, "index out of range") pageId, index := a.getPageIdAndIndex(index) - page := &a.book[pageId] + page := a.book[pageId] return &(page.data[index]) } @@ -60,9 +62,9 @@ func (a *PagedArray[T]) GetValue(index int) T { assert.True(index < a.len, "index out of range") pageId, index := a.getPageIdAndIndex(index) - page := &a.book[pageId] + page := a.book[pageId] - return (page.data[index]) + return page.data[index] } func (a *PagedArray[T]) Set(index int, value T) *T { @@ -70,7 +72,7 @@ func (a *PagedArray[T]) Set(index int, value T) *T { assert.True(index < a.len, "index out of range") pageId, index := a.getPageIdAndIndex(index) - page := &a.book[pageId] + page := a.book[pageId] page.data[index] = value @@ -78,12 +80,22 @@ func (a *PagedArray[T]) Set(index int, value T) *T { } func (a *PagedArray[T]) extend() { - newBooks := make([]ArrayPage[T], len(a.book)*2) - a.book = append(a.book, newBooks...) - newEdvpTasks := make([]EachDataValueTask[T], len(a.edvpTasks)*2) - a.edvpTasks = append(a.edvpTasks, newEdvpTasks...) - newEdpTasks := make([]EachDataTask[T], len(a.edpTasks)*2) - a.edpTasks = append(a.edpTasks, newEdpTasks...) + oldLen := len(a.book) + newLen := oldLen * 2 + newBooks := make([]*ArrayPage[T], newLen) + copy(newBooks, a.book) + for i := oldLen; i < newLen; i++ { + newBooks[i] = &ArrayPage[T]{} + } + a.book = newBooks + + newEdvpTasks := make([]EachDataValueTask[T], newLen) + copy(newEdvpTasks, a.edvpTasks) + a.edvpTasks = newEdvpTasks + + newEdpTasks := make([]EachDataTask[T], newLen) + copy(newEdpTasks, a.edpTasks) + a.edpTasks = newEdpTasks } func (a *PagedArray[T]) Append(value T) *T { @@ -92,14 +104,14 @@ func (a *PagedArray[T]) Append(value T) *T { a.extend() } - page := &a.book[a.currentPageIndex] + page := a.book[a.currentPageIndex] if page.len == pageSize { a.currentPageIndex++ if a.currentPageIndex >= len(a.book) { a.extend() } - page = &a.book[a.currentPageIndex] + page = a.book[a.currentPageIndex] } page.data[page.len] = value result = &page.data[page.len] @@ -116,14 +128,14 @@ func (a *PagedArray[T]) AppendMany(values ...T) *T { a.extend() } - page := &a.book[a.currentPageIndex] + page := a.book[a.currentPageIndex] if page.len == pageSize { a.currentPageIndex++ if a.currentPageIndex >= len(a.book) { a.extend() } - page = &a.book[a.currentPageIndex] + page = a.book[a.currentPageIndex] } page.data[page.len] = value result = &page.data[page.len] @@ -138,7 +150,7 @@ func (a *PagedArray[T]) AppendMany(values ...T) *T { func (a *PagedArray[T]) SoftReduce() { assert.True(a.len > 0, "Len is already 0") - page := &a.book[a.currentPageIndex] + page := a.book[a.currentPageIndex] assert.True(page.len > 0, "Len is already 0") page.len-- @@ -152,7 +164,7 @@ func (a *PagedArray[T]) SoftReduce() { // Reset - resets the array to its initial state func (a *PagedArray[T]) Reset() { for i := 0; i <= a.currentPageIndex; i++ { - page := &a.book[i] + page := a.book[i] page.len = 0 } @@ -203,7 +215,7 @@ func (a *PagedArray[T]) Raw(result []T) []T { pos := 0 for i := 0; i <= a.currentPageIndex; i++ { - page := &a.book[i] + page := a.book[i] n := copy(result[pos:], page.data[:page.len]) pos += n } @@ -212,7 +224,7 @@ func (a *PagedArray[T]) Raw(result []T) []T { } func (a *PagedArray[T]) getPageIdAndIndex(index int) (int, int) { - return index >> pageSizeShift, index % pageSize + return index >> pageSizeShift, index & pageSizeMask } // ========================= @@ -222,7 +234,7 @@ func (a *PagedArray[T]) getPageIdAndIndex(index int) (int, int) { func (a *PagedArray[T]) Each() func(yield func(int, *T) bool) { return func(yield func(int, *T) bool) { var page *ArrayPage[T] - var index_offset int + var indexOffset int book := a.book @@ -231,11 +243,11 @@ func (a *PagedArray[T]) Each() func(yield func(int, *T) bool) { } for i := a.currentPageIndex; i >= 0; i-- { - page = &book[i] - index_offset = i << pageSizeShift + page = book[i] + indexOffset = i << pageSizeShift for j := page.len - 1; j >= 0; j-- { - if !yield(index_offset+j, &page.data[j]) { + if !yield(indexOffset+j, &page.data[j]) { return } } @@ -280,7 +292,7 @@ func (a *PagedArray[T]) EachData() func(yield func(*T) bool) { } for i := a.currentPageIndex; i >= 0; i-- { - page = &book[i] + page = book[i] for j := page.len - 1; j >= 0; j-- { if !yield(&page.data[j]) { @@ -301,7 +313,7 @@ func (a *PagedArray[T]) EachDataValue() func(yield func(T) bool) { } for i := a.currentPageIndex; i >= 0; i-- { - page = &book[i] + page = book[i] for j := page.len - 1; j >= 0; j-- { if !yield(page.data[j]) { @@ -318,7 +330,7 @@ func (a *PagedArray[T]) ProcessDataValue(handler func(T, worker.WorkerId), pool pool.GroupAdd(a.currentPageIndex + 1) for i := a.currentPageIndex; i >= 0; i-- { j := a.currentPageIndex - i - a.edvpTasks[j].page = &a.book[i] + a.edvpTasks[j].page = a.book[i] a.edvpTasks[j].f = handler pool.ProcessGroupTask(&a.edvpTasks[j]) } @@ -331,7 +343,7 @@ func (a *PagedArray[T]) EachDataParallel(handler func(*T, worker.WorkerId), pool pool.GroupAdd(a.currentPageIndex + 1) for i := a.currentPageIndex; i >= 0; i-- { j := a.currentPageIndex - i - a.edpTasks[j].page = &a.book[i] + a.edpTasks[j].page = a.book[i] a.edpTasks[j].f = handler pool.ProcessGroupTask(&a.edpTasks[j]) } diff --git a/pkg/ecs/paged-map.go b/pkg/ecs/paged-map.go index dc19047..1e02af5 100644 --- a/pkg/ecs/paged-map.go +++ b/pkg/ecs/paged-map.go @@ -9,6 +9,7 @@ package ecs const ( pageSizeShift = 10 pageSize = 1 << pageSizeShift + pageSizeMask = pageSize - 1 initialBookSize = 1 // Starting with a small initial book size ) @@ -87,7 +88,7 @@ func (m *PagedMap[K, V]) Has(key K) bool { } func (m *PagedMap[K, V]) getPageIDAndIndex(key K) (pageID int, index int) { - return int(uint64(key) >> pageSizeShift), int(uint64(key) % pageSize) + return int(key) >> pageSizeShift, int(key) & pageSizeMask } func (m *PagedMap[K, V]) expandBook(minLen int) {