Skip to content

Commit

Permalink
Add -fire option to fps for a fire effect and i key to toggle i…
Browse files Browse the repository at this point in the history
…nfo text (#69)

* Add `-fire` option to `fps` for a fire effect

* make linters happy

* ansi 256 color support

* Better yet fewer colors out of the 216 (12 colors)

* Single rand now

* Fixing instant fps calc in -fire mode was missing... the fire render

* make linters happy

* Show palette at start

* slowdown the decay/off mode

* Take a tiny bit more vertical space with the fire height

* extend red/orange zone and shrink white zone in truecolor palette

* Added "i" key to toggle info/text display

* linter
  • Loading branch information
ldemailly authored Oct 14, 2024
1 parent 58d343f commit 489cfe5
Show file tree
Hide file tree
Showing 3 changed files with 211 additions and 28 deletions.
4 changes: 2 additions & 2 deletions ansipixels/ansipixels.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,11 +309,11 @@ func (ap *AnsiPixels) ClearEndOfLine() {

var cursPosRegexp = regexp.MustCompile(`^(.*)\033\[(\d+);(\d+)R(.*)$`)

// This also synchronizes the display.
// This also synchronizes the display and ends the syncmode.
func (ap *AnsiPixels) ReadCursorPos() (int, int, error) {
x := -1
y := -1
reqPosStr := "\033[6n"
reqPosStr := "\033[?2026l\033[6n" // also ends sync mode
n, err := ap.Out.WriteString(reqPosStr)
if err != nil {
return x, y, err
Expand Down
152 changes: 152 additions & 0 deletions fps/fire.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package main

import (
"fmt"
"math/rand/v2"

"fortio.org/safecast"
"fortio.org/terminal/ansipixels"
)

type FireState struct {
h int
w int
buffer []byte
on bool
}

var fire *FireState

var (
v2colTrueColor [256]string
v2col256 [256]string
)

func init() {
for i := range 256 {
r := min(255, 3*i)
g := min(255, max(0, (i-84)*2))
b := min(255, max(0, (i-208)*5))
v2colTrueColor[i] = fmt.Sprintf("\033[38;2;%d;%d;%dm█", r, g, b)
}
// 0 1 2 3 4 5 6 7 8 9 10 11
for i, color := range []int{16, 52, 88, 124, 166, 202, 208, 214, 220, 226, 228, 231} {
v2col256[i] = fmt.Sprintf("\033[38;5;%dm█", color)
}
}

func InitFire(ap *ansipixels.AnsiPixels) *FireState {
f := &FireState{h: ap.H - 2*ap.Margin, w: ap.W - 2*ap.Margin}
f.buffer = make([]byte, f.h*f.w)
return f
}

func ToggleFire() {
if fire == nil {
return
}
if fire.on {
fire.Off()
} else {
fire.Start()
}
}

func (f *FireState) At(x, y int) byte {
return f.buffer[y*f.w+x]
}

func (f *FireState) Set(x, y int, v byte) {
f.buffer[y*f.w+x] = v
}

func (f *FireState) Start() {
for x := range f.w {
f.Set(x, f.h-1, 255)
}
f.on = true
}

// Turn off the fire at the bottom.
func (f *FireState) Off() {
for x := range f.w {
f.Set(x, f.h-1, 1)
}
f.on = false
}

func (f *FireState) Update() {
for y := f.h - 2; y >= 0; y-- {
for x := range f.w {
r := rand.Float32() //nolint:gosec // this _is_ randv2!
dx := safecast.MustTruncate[int](3*r - 1.5) // -1, 0, 1
v := f.At((x+dx+f.w)%f.w, y+1)
pv := f.At(x, y)
if pv > v { // slow-ish decay when "off"
delta := max(1, byte(r*float32(pv-v)))
v = max(1, pv-delta)
f.Set(x, y, v)
continue
}
newV := byte(max(0, float32(v)-r*3.2*255./(float32(f.h-1))))
if newV == 0 && pv != 0 {
newV = 1
}
f.Set(x, y, newV)
}
}
}

func (f *FireState) Render(ap *ansipixels.AnsiPixels) {
for y := range f.h {
first := true
prevX := -999
prevColor := ""
for x := range f.w {
v := f.At(x, y)
if v == 0 {
continue
}
switch {
case first:
ap.MoveCursor(x+ap.Margin, y+ap.Margin)
first = false
case x != prevX+1:
ap.MoveHorizontally(x + ap.Margin)
}
prevX = x
var newColor string
if ap.TrueColor {
newColor = v2colTrueColor[v]
} else {
newColor = v2col256[3*int(v)/64]
}
if newColor != prevColor {
ap.WriteString(newColor)
prevColor = newColor
} else {
ap.WriteRune(ansipixels.FullPixel)
}
}
}
}

func AnimateFire(ap *ansipixels.AnsiPixels, frame int64) {
if frame == 0 {
fire = InitFire(ap)
fire.Start()
}
fire.Update()
fire.Render(ap)
}

func ShowPalette(ap *ansipixels.AnsiPixels) {
f := InitFire(ap)
// Show/debug the palette:
for x := range f.w {
v := safecast.MustConvert[byte]((255 * (x + 1)) / f.w)
f.Set(x, f.h-3, v)
f.Set(x, f.h-2, v)
}
f.Render(ap)
}
83 changes: 57 additions & 26 deletions fps/fps.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
"fortio.org/safecast"
"fortio.org/terminal"
"fortio.org/terminal/ansipixels"
"github.com/loov/hrtime"
"github.com/loov/hrtime" // To test hrtime correctness: hrtime "time".
)

const defaultMonoImageColor = ansipixels.Blue // ansi blue-ish
Expand Down Expand Up @@ -319,10 +319,16 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if
imagesOnlyFlag := flag.Bool("i", false, "Arguments are now images files to show, no FPS test (hit any key to continue)")
exactlyFlag := flag.Int64("n", 0, "Start immediately an FPS test with the specified `number of frames` (default is interactive)")
noMouseFlag := flag.Bool("nomouse", false, "Disable mouse tracking")
fireFlag := flag.Bool("fire", false, "Show fire animation instead of RGB around the image")
cli.MinArgs = 0
cli.MaxArgs = -1
cli.ArgsHelp = "[maxfps] or fps -i imagefiles..."
cli.Main()
fireMode := *fireFlag
fireStr := "no_fire"
if fireMode {
fireStr = "fire"
}
imagesOnly := *imagesOnlyFlag
fpsLimit := -1.0
fpsStr := "unlimited"
Expand All @@ -340,9 +346,6 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if
fpsStr = fmt.Sprintf("%.1f", fpsLimit)
hasFPSLimit = true
perfResults.hist = stats.NewHistogram(0, .01/fpsLimit)
} else {
// with max fps expect values in the tens of usec range with usec precision (at max fps for fast terminals)
perfResults.hist = stats.NewHistogram(0, 0.0000001)
}
perfResults.Exactly = *exactlyFlag
perfResults.RequestedQPS = fpsStr
Expand Down Expand Up @@ -394,6 +397,10 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if
e := ap.ShowImage(background, 1.0, 0, 0, defaultMonoImageColor)
if !imagesOnly {
drawBox(ap, true)
if fireMode {
ShowPalette(ap)
}
ap.WriteCentered(ap.H/2+4, "In -fire mode, space bar to toggle on/off; i to hide text")
ap.WriteCentered(ap.H/2+3, "FPS %s test... any key to start; q, ^C, or ^D to exit... %s",
fpsStr, ansipixels.MoveLeft)
ap.ShowCursor()
Expand Down Expand Up @@ -425,6 +432,8 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if
if !*noMouseFlag {
ap.MouseTrackingOn()
}
var frames int64
var hideText bool
ap.OnResize = func() error {
ap.StartSyncMode()
ap.ClearScreen()
Expand All @@ -433,12 +442,16 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if
drawBox(ap, false) // no boxed Width x Height in pure fps mode, keeping it simple.
}
ap.EndSyncMode()
// with max fps expect values in the tens of usec range with usec precision (at max fps for fast terminals)
perfResults.hist = stats.NewHistogram(0, 0.0000001)
frames = 0
setLabels("fps "+strings.TrimSuffix(fpsStr, ".0"), tenv, fmt.Sprintf("%dx%d", ap.W, ap.H), fireStr)
hideText = false
return e
}
if err = ap.OnResize(); err != nil {
return log.FErrf("Error showing image: %v", err)
}
frames := int64(0)
var elapsed time.Duration
var entry []byte
sendableTickerChan := make(chan time.Time, 1)
Expand All @@ -447,13 +460,12 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if
startTime := hrtime.Now()
now := startTime
if hasFPSLimit {
ticker := time.NewTicker(time.Second / time.Duration(fpsLimit))
ticker := time.NewTicker(time.Duration(float64(time.Second) / fpsLimit))
tickerChan = ticker.C
} else {
tickerChan = sendableTickerChan
sendableTickerChan <- perfResults.StartTime
}
setLabels("fps "+strings.TrimSuffix(fpsStr, ".0"), tenv, fmt.Sprintf("%dx%d", ap.W, ap.H))
for {
select {
case s := <-ap.C:
Expand All @@ -468,24 +480,43 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if
case v := <-tickerChan:
elapsed = hrtime.Since(now)
sec := elapsed.Seconds()
if frames > 0 {
perfResults.hist.Record(sec) // record in milliseconds
}
fps = 1. / sec
now = hrtime.Now()
perfResults.ActualDuration = (now - startTime)
// perfResults.ActualDuration = now.Sub(startTime)
perfResults.ActualDuration = now - startTime
perfResults.ActualQPS = float64(frames) / perfResults.ActualDuration.Seconds()
if frames > 0 {
perfResults.hist.Record(sec) // record in milliseconds
}
if fireMode {
ap.StartSyncMode()
AnimateFire(ap, frames)
}
// stats.Record("fps", fps)
ap.WriteAt(ap.W/2-20, ap.H/2+2, " Last frame %s%v%s FPS: %s%.0f%s Avg %s%.2f%s ",
ansipixels.Green, elapsed.Round(10*time.Microsecond), ansipixels.Reset,
ansipixels.BrightRed, fps, ansipixels.Reset,
ansipixels.Cyan, perfResults.ActualQPS, ansipixels.Reset)
ap.WriteAt(ap.W/2-20, ap.H/2+3, " Best %.1f Worst %.1f: %.1f +/- %.1f ",
1/perfResults.hist.Min, 1/perfResults.hist.Max, 1/perfResults.hist.Avg(), 1/perfResults.hist.StdDev())
if !hideText {
ap.WriteAt(ap.W/2-20, ap.H/2+2, "%s Last frame %s%v%s FPS: %s%.0f%s Avg %s%.2f%s ",
ansipixels.Reset, ansipixels.Green, elapsed.Round(10*time.Microsecond), ansipixels.Reset,
ansipixels.BrightRed, fps, ansipixels.Reset,
ansipixels.Cyan, perfResults.ActualQPS, ansipixels.Reset)
ap.WriteAt(ap.W/2-20, ap.H/2+3, " Best %.1f Worst %.1f: %.1f +/- %.1f ",
1/perfResults.hist.Min, 1/perfResults.hist.Max, 1/perfResults.hist.Avg(), 1/perfResults.hist.StdDev())
}
if perfResults.Exactly > 0 && frames >= perfResults.Exactly {
return 0
}
animate(ap, frames)
if !fireMode {
animate(ap, frames)
}
if !hideText {
invert := ""
if ap.Mouse {
invert = ansipixels.Reverse
}
ap.WriteRight(ap.H-1-ap.Margin, " Target %sFPS %s%s%s, %dx%d, typed so far: %s[%s%q%s]%s %sMouse %d,%d (%06b)%s",
ansipixels.Cyan, ansipixels.Green, fpsStr, ansipixels.Reset, ap.W, ap.H,
ansipixels.DarkGray, ansipixels.Reset, entry, ansipixels.DarkGray, ansipixels.Reset,
invert, ap.Mx, ap.My, ap.Mbuttons, ansipixels.Reset)
}
// Request cursor position (note that FPS is about the same without it, the Flush seems to be enough)
_, _, err = ap.ReadCursorPos()
if err != nil {
Expand All @@ -495,15 +526,15 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if
if isStopKey(ap) {
return 0
}
entry = append(entry, ap.Data...)
invert := ""
if ap.Mouse {
invert = ansipixels.Reverse
if len(ap.Data) > 0 {
switch {
case fireMode && ap.Data[0] == ' ':
ToggleFire()
case ap.Data[0] == 'i':
hideText = !hideText
}
}
ap.WriteRight(ap.H-1-ap.Margin, " Target %sFPS %s%s%s, %dx%d, typed so far: %s[%s%q%s]%s %sMouse %d,%d (%06b)%s",
ansipixels.Cyan, ansipixels.Green, fpsStr, ansipixels.Reset, ap.W, ap.H,
ansipixels.DarkGray, ansipixels.Reset, entry, ansipixels.DarkGray, ansipixels.Reset,
invert, ap.Mx, ap.My, ap.Mbuttons, ansipixels.Reset)
entry = append(entry, ap.Data...)
ap.Data = ap.Data[0:0:cap(ap.Data)] // reset buffer
frames++
if !hasFPSLimit {
Expand Down

0 comments on commit 489cfe5

Please sign in to comment.