Skip to content
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
154 changes: 154 additions & 0 deletions anim/easing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Package anim provides animation primitives for the fluint engine:
// easing functions today, timing/tween scaffolding to follow.
//
// All easing functions in this file satisfy the contract:
//
// func(0.0) == 0.0
// func(1.0) == 1.0
//
// and are designed to be allocation-free so they can run on the
// per-frame hot path.
package anim

import "math"

// EaseFunc maps a normalised time value t ∈ [0, 1] to an interpolated
// progress value. Implementations must be safe to call from the render
// hot path — allocation-free, panic-free for t in the closed interval
// [0, 1].
type EaseFunc func(t float64) float64

// Named easing curves. Each variable references a package-level
// function so the Go compiler can inline calls through the EaseFunc
// type.

// Linear — constant velocity, no acceleration.
var Linear EaseFunc = linearEase

// InQuad — accelerating from zero velocity. Quadratic ease-in.
var InQuad EaseFunc = inQuadEase

// OutQuad — decelerating to zero velocity. Quadratic ease-out.
var OutQuad EaseFunc = outQuadEase

// InOutQuad — quadratic ease-in/ease-out. Slow start, slow end.
var InOutQuad EaseFunc = inOutQuadEase

// InCubic — accelerating from zero velocity. Cubic ease-in.
var InCubic EaseFunc = inCubicEase

// OutCubic — decelerating to zero velocity. Cubic ease-out.
var OutCubic EaseFunc = outCubicEase

// InOutCubic — cubic ease-in/ease-out. Slow start, slow end.
var InOutCubic EaseFunc = inOutCubicEase

// OutBounce — ease-out with a decaying bounce at the end. Stays
// within [0, 1] except for the small overshoot at the very last
// bounce peak (clamp on read if required).
var OutBounce EaseFunc = outBounceEase

// OutElastic — ease-out with an elastic overshoot at the end. The
// curve briefly exceeds 1.0 and dips below 0.0 around the overshoot
// region — callers that need a bounded output should clamp the result.
var OutElastic EaseFunc = outElasticEase

// ---------------------------------------------------------------------------
// Implementations
//
// Kept as separate package-level functions so the Go compiler can
// inline calls through EaseFunc values. math.Pow is avoided wherever
// a plain multiply or call to math.Sqrt/Sin suffices.
// ---------------------------------------------------------------------------

func linearEase(t float64) float64 {
return t
}

func inQuadEase(t float64) float64 {
return t * t
}

func outQuadEase(t float64) float64 {
// 1 - (1-t)² → 2t - t² (cheap, two multiplies).
return 2*t - t*t
}

func inOutQuadEase(t float64) float64 {
if t < 0.5 {
return 2 * t * t
}
// 1 - (-2t + 2)² / 2 → 1 - (2-2t)² / 2.
u := 2 - 2*t
return 1 - u*u/2
}

func inCubicEase(t float64) float64 {
return t * t * t
}

func outCubicEase(t float64) float64 {
// 1 - (1-t)³ expanded → 3t - 3t² + t³. Multiplies only.
u := 1 - t
return 1 - u*u*u
}

func inOutCubicEase(t float64) float64 {
if t < 0.5 {
return 4 * t * t * t
}
// 1 - (-2t + 2)³ / 2.
u := 2 - 2*t
return 1 - u*u*u/2
}

// outBounceEase — Penner-style "bounce out" curve. The animation
// overshoots the target at the end in a series of decaying bounces.
//
// Coefficients derived from Penner's easing cheat sheet:
//
// n1 = 7.5625, d1 = 2.75
//
// Phases:
//
// t < 1/d1 → n1 * t * t
// t < 2/d1 → n1 * (t -= 1.5/d1) * t + 0.75
// t < 2.5/d1 → n1 * (t -= 2.25/d1) * t + 0.9375
// else → n1 * (t -= 2.625/d1) * t + 0.984375
func outBounceEase(t float64) float64 {
const (
n1 = 7.5625
d1 = 2.75
)
switch {
case t < 1/d1:
return n1 * t * t
case t < 2/d1:
t -= 1.5 / d1
return n1*t*t + 0.75
case t < 2.5/d1:
t -= 2.25 / d1
return n1*t*t + 0.9375
default:
t -= 2.625 / d1
return n1*t*t + 0.984375
}
}

// outElasticEase — Penner "elastic out". Uses sin/cos with a tuned
// period. The curve overshoots 1.0 and undershoots 0.0 near the end.
//
// math.Pow(2, -10t) is the standard form for the elastic envelope and
// has no closed-form arithmetic alternative, so it is the single
// allowed math.Pow call in the package.
func outElasticEase(t float64) float64 {
if t == 0 || t == 1 {
return t
}
const (
p = 0.3 // elastic period
a = 1.0 // amplitude
s = 0.3 / (4 * math.Pi) // p / (4π); since asin(1/a) = π/2 when a=1.
)
return a * math.Pow(2, -10*t) * math.Sin((t-s)*(2*math.Pi)/p)
}
133 changes: 133 additions & 0 deletions anim/easing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package anim

import (
"math"
"testing"
)

// allEases enumerates every exported EaseFunc in the package.
var allEases = []struct {
name string
fn EaseFunc
}{
{"Linear", Linear},
{"InQuad", InQuad},
{"OutQuad", OutQuad},
{"InOutQuad", InOutQuad},
{"InCubic", InCubic},
{"OutCubic", OutCubic},
{"InOutCubic", InOutCubic},
{"OutBounce", OutBounce},
{"OutElastic", OutElastic},
}

// TestEaseFuncs_Endpoints verifies every easing curve satisfies the
// contract func(0) == 0 and func(1) == 1.
func TestEaseFuncs_Endpoints(t *testing.T) {
t.Parallel()

for _, e := range allEases {
e := e
t.Run(e.name, func(t *testing.T) {
t.Parallel()

got0 := e.fn(0)
if got0 != 0 {
t.Errorf("%s(0) = %v, want 0", e.name, got0)
}
got1 := e.fn(1)
if math.Abs(got1-1) > 1e-9 {
t.Errorf("%s(1) = %v, want 1", e.name, got1)
}
})
}
}

// TestEaseFuncs_NoPanic sweeps t across [0, 1] (plus tiny extremes)
// to confirm every curve stays finite.
func TestEaseFuncs_NoPanic(t *testing.T) {
t.Parallel()

for _, e := range allEases {
e := e
t.Run(e.name, func(t *testing.T) {
t.Parallel()
for i := 0; i <= 64; i++ {
s := float64(i) / 64.0
_ = e.fn(s) // must not panic.
}
})
}
}

// TestOutBounce_StaysBoundedInMiddle asserts the bounce curve never
// exceeds [0, 1] at the midpoint sample.
func TestOutBounce_StaysBoundedInMiddle(t *testing.T) {
t.Parallel()

for i := 1; i < 64; i++ {
s := float64(i) / 64.0
v := OutBounce(s)
if v < 0 || v > 1 {
t.Errorf("OutBounce(%v) = %v, want within [0, 1]", s, v)
}
}
}

// BenchmarkEasing runs every easing function across the [0, 1]
// interval in a tight loop. Used to verify 0 allocs/op on the hot
// path.
func BenchmarkEasing(b *testing.B) {
// 256 evenly-spaced samples in [0, 1].
var samples [256]float64
for i := range samples {
samples[i] = float64(i) / 255.0
}
b.ResetTimer()

for i := 0; i < b.N; i++ {
for _, e := range allEases {
var acc float64
for _, s := range samples {
acc += e.fn(s)
}
// Prevent the compiler from eliding the calls.
if math.IsNaN(acc) {
b.Fatal("easing produced NaN")
}
}
}
}

// Per-function allocation benchmarks for tighter regression checks.
func BenchmarkEasingLinear(b *testing.B) { benchEase(b, Linear) }
func BenchmarkEasingInQuad(b *testing.B) { benchEase(b, InQuad) }
func BenchmarkEasingOutQuad(b *testing.B) { benchEase(b, OutQuad) }
func BenchmarkEasingInOutQuad(b *testing.B) { benchEase(b, InOutQuad) }
func BenchmarkEasingInCubic(b *testing.B) { benchEase(b, InCubic) }
func BenchmarkEasingOutCubic(b *testing.B) { benchEase(b, OutCubic) }
func BenchmarkEasingInOutCubic(b *testing.B) {
benchEase(b, InOutCubic)
}
func BenchmarkEasingOutBounce(b *testing.B) { benchEase(b, OutBounce) }
func BenchmarkEasingOutElastic(b *testing.B) {
benchEase(b, OutElastic)
}

func benchEase(b *testing.B, fn EaseFunc) {
b.Helper()
var samples [256]float64
for i := range samples {
samples[i] = float64(i) / 255.0
}
b.ResetTimer()
var acc float64
for i := 0; i < b.N; i++ {
for _, s := range samples {
acc += fn(s)
}
}
if math.IsNaN(acc) {
b.Fatal("easing produced NaN")
}
}
64 changes: 64 additions & 0 deletions core/buffer/subcell.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package buffer

// Half-block glyphs used for sub-cell vertical resolution.
//
// ▀ U+2580 Upper half block — top half painted (Fg), bottom is Bg.
// ▄ U+2584 Lower half block — bottom half painted (Fg), top is Bg.
//
// SetSubCellY paints "subpixels" along the Y axis by stacking two
// logical rows into one terminal row using these glyphs, giving the
// renderer a 2× vertical resolution at zero allocation cost.
const (
runeUpperHalfBlock = '\u2580' // ▀ : top half filled, bottom is Bg.
runeLowerHalfBlock = '\u2584' // ▄ : bottom half filled, top is Bg.
)

// SetSubCellY paints a single sub-row of the cell at (x, y).
//
// ySub == 0 paints the upper half — the colour is stored in Cell.Fg
// and the glyph is forced to ▀.
// ySub == 1 paints the lower half — the colour is stored in Cell.Bg
// and the glyph is forced to ▀ (or ▄ when Fg is unset).
//
// Combination semantics:
// - If only the top half was painted: Fg holds the colour, glyph is ▀,
// Bg stays at its zero value (terminal default background).
// - If only the bottom half was painted: Bg holds the colour, glyph is
// ▄ so the painted half ends up on the bottom.
// - If both halves are painted (regardless of order): glyph is ▀ with
// Fg = top colour, Bg = bottom colour.
//
// Coordinates outside the buffer and ySub values other than 0 or 1 are
// safely clipped. A nil receiver is a no-op so callers do not need to
// guard before drawing.
func (b *Buffer) SetSubCellY(x, y, ySub int, color uint32) {
if b == nil {
return
}
if x < 0 || x >= b.Width || y < 0 || y >= b.Height {
return
}
if ySub != 0 && ySub != 1 {
return
}

cell := &b.Cells[y*b.Width+x]

switch ySub {
case 0:
// Top half → Cell.Fg. The glyph is always ▀ regardless of
// whether the bottom half was painted before or after.
cell.Fg = color
cell.Rune = runeUpperHalfBlock
case 1:
// Bottom half → Cell.Bg.
cell.Bg = color
// If no top half is painted yet (Fg still default), the cell
// represents the bottom half only → use ▄. Otherwise keep ▀.
if cell.Fg == 0 {
cell.Rune = runeLowerHalfBlock
} else {
cell.Rune = runeUpperHalfBlock
}
}
}
Loading
Loading