From bb48d7dc59e8470e52650920a509057e9622f15a Mon Sep 17 00:00:00 2001 From: kishshtovsky Date: Tue, 28 Jul 2026 00:22:47 +0500 Subject: [PATCH] feat(vfx): add sub-cell rendering and easing functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/buffer: add Buffer.SetSubCellY() for vertical half-block sub-cell rendering using ▀/▄ glyphs with Fg/Bg color split - anim: add EaseFunc type with 9 named curves (Linear, In/Out/ InOutQuad, In/Out/InOutCubic, OutBounce, OutElastic) backed by package-level functions for zero-allocation hot-path usage - docs: add ADR-0006 (en/ru/zh) and CHANGELOG v0.2.0 entries - all benchmarks confirm 0 B/op, 0 allocs/op --- anim/easing.go | 154 ++++++++++++++++++ anim/easing_test.go | 133 +++++++++++++++ core/buffer/subcell.go | 64 ++++++++ core/buffer/subcell_test.go | 154 ++++++++++++++++++ docs/en/CHANGELOG.md | 7 + .../adr/0006-subcell-rendering-and-easing.md | 59 +++++++ docs/ru/CHANGELOG.md | 7 + .../adr/0006-subcell-rendering-and-easing.md | 59 +++++++ docs/zh/CHANGELOG.md | 7 + .../adr/0006-subcell-rendering-and-easing.md | 50 ++++++ 10 files changed, 694 insertions(+) create mode 100644 anim/easing.go create mode 100644 anim/easing_test.go create mode 100644 core/buffer/subcell.go create mode 100644 core/buffer/subcell_test.go create mode 100644 docs/en/adr/0006-subcell-rendering-and-easing.md create mode 100644 docs/ru/adr/0006-subcell-rendering-and-easing.md create mode 100644 docs/zh/adr/0006-subcell-rendering-and-easing.md diff --git a/anim/easing.go b/anim/easing.go new file mode 100644 index 0000000..8af6de6 --- /dev/null +++ b/anim/easing.go @@ -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) +} diff --git a/anim/easing_test.go b/anim/easing_test.go new file mode 100644 index 0000000..e1b415e --- /dev/null +++ b/anim/easing_test.go @@ -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") + } +} diff --git a/core/buffer/subcell.go b/core/buffer/subcell.go new file mode 100644 index 0000000..43f482b --- /dev/null +++ b/core/buffer/subcell.go @@ -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 + } + } +} diff --git a/core/buffer/subcell_test.go b/core/buffer/subcell_test.go new file mode 100644 index 0000000..49ea213 --- /dev/null +++ b/core/buffer/subcell_test.go @@ -0,0 +1,154 @@ +package buffer + +import "testing" + +// TestSetSubCellY_TopOnly paints the upper half of an empty cell. +// Expected: glyph = ▀, Fg = colour, Bg = 0. +func TestSetSubCellY_TopOnly(t *testing.T) { + t.Parallel() + + b := NewBuffer(2, 2) + const fg = 0x00FF8800 + + b.SetSubCellY(1, 0, 0, fg) + + got := b.GetCell(1, 0) + if got.Rune != '\u2580' { + t.Errorf("Rune = %q (U+%04X), want %q (U+2580)", got.Rune, got.Rune, '\u2580') + } + if got.Fg != fg { + t.Errorf("Fg = %#X, want %#X", got.Fg, fg) + } + if got.Bg != 0 { + t.Errorf("Bg = %#X, want 0", got.Bg) + } +} + +// TestSetSubCellY_BottomOnly paints the lower half first. +// Expected: glyph = ▄, Fg = 0, Bg = colour. +func TestSetSubCellY_BottomOnly(t *testing.T) { + t.Parallel() + + b := NewBuffer(2, 2) + const bg = 0x0000AAFF + + b.SetSubCellY(0, 0, 1, bg) + + got := b.GetCell(0, 0) + if got.Rune != '\u2584' { + t.Errorf("Rune = %q (U+%04X), want %q (U+2584)", got.Rune, got.Rune, '\u2584') + } + if got.Fg != 0 { + t.Errorf("Fg = %#X, want 0", got.Fg) + } + if got.Bg != bg { + t.Errorf("Bg = %#X, want %#X", got.Bg, bg) + } +} + +// TestSetSubCellY_BothHalves verifies that painting top then bottom +// promotes the cell to ▀ with the correct Fg/Bg split. +func TestSetSubCellY_BothHalves(t *testing.T) { + t.Parallel() + + b := NewBuffer(1, 1) + const ( + fg = 0x00FF0000 + bg = 0x000000FF + ) + + b.SetSubCellY(0, 0, 0, fg) // top + b.SetSubCellY(0, 0, 1, bg) // bottom + + got := b.GetCell(0, 0) + if got.Rune != '\u2580' { + t.Errorf("Rune = %q (U+%04X), want %q (U+2580)", got.Rune, got.Rune, '\u2580') + } + if got.Fg != fg { + t.Errorf("Fg = %#X, want %#X", got.Fg, fg) + } + if got.Bg != bg { + t.Errorf("Bg = %#X, want %#X", got.Bg, bg) + } +} + +// TestSetSubCellY_BottomThenTop verifies the reverse order: bottom +// first, then top. The cell should still end up as ▀. +func TestSetSubCellY_BottomThenTop(t *testing.T) { + t.Parallel() + + b := NewBuffer(1, 1) + const ( + fg = 0x00123456 + bg = 0x00ABCDEF + ) + + b.SetSubCellY(0, 0, 1, bg) + b.SetSubCellY(0, 0, 0, fg) + + got := b.GetCell(0, 0) + if got.Rune != '\u2580' { + t.Errorf("Rune = %q (U+%04X), want %q (U+2580)", got.Rune, got.Rune, '\u2580') + } + if got.Fg != fg { + t.Errorf("Fg = %#X, want %#X", got.Fg, fg) + } + if got.Bg != bg { + t.Errorf("Bg = %#X, want %#X", got.Bg, bg) + } +} + +// TestSetSubCellY_OutOfRange confirms the safe-clipping contract: +// coordinates outside the buffer and an invalid ySub are no-ops. +func TestSetSubCellY_OutOfRange(t *testing.T) { + t.Parallel() + + b := NewBuffer(1, 1) + original := b.GetCell(0, 0) + + cases := []struct { + name string + x, y, ySub int + }{ + {"x-negative", -1, 0, 0}, + {"x-too-large", 1, 0, 0}, + {"y-negative", 0, -1, 0}, + {"y-too-large", 0, 1, 0}, + {"ySub-invalid", 0, 0, 2}, + {"ySub-negative", 0, 0, -1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b.SetSubCellY(tc.x, tc.y, tc.ySub, 0x00DEADBE) + got := b.GetCell(0, 0) + if got != original { + t.Errorf("cell mutated after out-of-range SetSubCellY: got %+v, want %+v", got, original) + } + }) + } +} + +// TestSetSubCellY_NilReceiver confirms a nil Buffer is a safe no-op. +func TestSetSubCellY_NilReceiver(t *testing.T) { + t.Parallel() + + var b *Buffer + // Should not panic. + b.SetSubCellY(0, 0, 0, 0x00FF00FF) + b.SetSubCellY(0, 0, 1, 0x00FF00FF) +} + +// BenchmarkSetSubCellY_Hot measures the per-frame cost of painting +// two half-block rows into a full-screen-sized buffer. +func BenchmarkSetSubCellY_Hot(b *testing.B) { + buf := NewBuffer(80, 24) + var color uint32 = 0x00C0FFEE + b.ResetTimer() + for i := 0; i < b.N; i++ { + for y := 0; y < buf.Height; y++ { + for x := 0; x < buf.Width; x++ { + buf.SetSubCellY(x, y, y&1, color) + } + } + } +} diff --git a/docs/en/CHANGELOG.md b/docs/en/CHANGELOG.md index d258b5f..0c7fbab 100644 --- a/docs/en/CHANGELOG.md +++ b/docs/en/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.2.0] - 2026-07-28 + +### Added +- Package `anim`: Easing functions package (`EaseFunc`) with 9 named curves — `Linear`, `InQuad`, `OutQuad`, `InOutQuad`, `InCubic`, `OutCubic`, `InOutCubic`, `OutBounce`, `OutElastic` — backed by package-level functions (not closures) for zero-allocation hot-path usage. Only `OutElastic` uses `math.Pow`; all other curves use plain arithmetic. +- Package `core/buffer`: `Buffer.SetSubCellY(x, y, ySub int, color uint32)` method for vertical sub-cell rendering using Unicode half-block glyphs (`▀` U+2580, `▄` U+2584). Doubles vertical resolution with zero additional memory by repurposing existing `Cell.Fg`/`Cell.Bg` fields. +- ADR-0006: Sub-cell Rendering & Easing Functions architectural decision record. + ## [v0.1.1] - 2026-07-27 ### Added diff --git a/docs/en/adr/0006-subcell-rendering-and-easing.md b/docs/en/adr/0006-subcell-rendering-and-easing.md new file mode 100644 index 0000000..311b1a6 --- /dev/null +++ b/docs/en/adr/0006-subcell-rendering-and-easing.md @@ -0,0 +1,59 @@ +# ADR-0006: Sub-cell Rendering & Easing Functions + +## Status +Accepted + +## Context +Fluint targets smooth discrete-cell animations and advanced VFX, but the +rendering pipeline operates at cell granularity (one glyph per terminal +cell). Vertical resolution is therefore limited to the terminal height in +rows. Easing functions are the second critical primitive: every animation +interpolation — tweens, transitions, particle fades — needs a reusable +`EaseFunc` abstraction that can be called on the per-frame hot path +without allocations. + +## Decision + +### Sub-cell rendering (`core/buffer/subcell.go`) +Add a `Buffer.SetSubCellY(x, y, ySub int, color uint32)` method that +paints half-block "subpixels" along the vertical axis using the Unicode +half-block glyphs `▀` (U+2580, upper) and `▄` (U+2584, lower). + +- `ySub == 0` → top half: colour stored in `Cell.Fg`, glyph forced to `▀`. +- `ySub == 1` → bottom half: colour stored in `Cell.Bg`, glyph becomes + `▀` when both halves are present, or `▄` when only the bottom half was + painted. + +This doubles vertical resolution with zero additional memory — only the +Cell's existing Fg/Bg fields are repurposed. + +### Easing functions (`anim/easing.go`) +A new `anim` package exports: + +``` +type EaseFunc func(t float64) float64 +``` + +Nine named curves backed by package-level functions (not closures): +`Linear`, `InQuad`, `OutQuad`, `InOutQuad`, `InCubic`, `OutCubic`, +`InOutCubic`, `OutBounce`, `OutElastic`. + +## Rationale +- Sub-cell rendering follows the half-block technique used by Sixel and + braille-based TUI renderers but keeps it inside the normal `Cell` + model — no extra grid or framebuffer. +- Named package-level functions (vs. closures stored in `var`) let the + Go compiler inline calls through `EaseFunc` values, keeping the + animation tick at 0 allocs/op. +- `math.Pow` is used only for `OutElastic` (the `2^(-10t)` envelope has + no arithmetic equivalent). All other curves use plain `*` and `+`. + +## Consequences +- Any renderer that serialises cells to ANSI must respect the half-block + glyph — `render/ansi` already writes arbitrary Unicode code-points, so + no change required today. +- Future sub-cell horizontal (quarter-block / braille) extends this + model naturally by adding `SetSubCellX` that manipulates `Cell.Attrs` + or a dedicated subcell flag. +- `OutBounce` and `OutElastic` may briefly overshoot [0, 1]; callers + that need bounded output should clamp explicitly. diff --git a/docs/ru/CHANGELOG.md b/docs/ru/CHANGELOG.md index 360642a..e261db0 100644 --- a/docs/ru/CHANGELOG.md +++ b/docs/ru/CHANGELOG.md @@ -5,6 +5,13 @@ Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/), и проект придерживается [семантического версионирования](https://semver.org/lang/ru/spec/v2.0.0.html). +## [v0.2.0] - 2026-07-28 + +### Добавлено +- Пакет `anim`: функции сглаживания (`EaseFunc`) с 9 именованными кривыми — `Linear`, `InQuad`, `OutQuad`, `InOutQuad`, `InCubic`, `OutCubic`, `InOutCubic`, `OutBounce`, `OutElastic` — на основе пакетных функций (не замыканий) для использования на горячем пути без аллокаций. Только `OutElastic` использует `math.Pow`; все остальные кривые — только арифметика. +- Пакет `core/buffer`: метод `Buffer.SetSubCellY(x, y, ySub int, color uint32)` для вертикального субпиксельного рендеринга с использованием Unicode-символов половинных блоков (`▀` U+2580, `▄` U+2584). Удваивает вертикальное разрешение без дополнительной памяти за счёт повторного использования существующих полей `Cell.Fg`/`Cell.Bg`. +- ADR-0006: запись архитектурного решения по субпиксельному рендерингу и функциям сглаживания. + ## [v0.1.1] - 2026-07-27 ### Добавлено diff --git a/docs/ru/adr/0006-subcell-rendering-and-easing.md b/docs/ru/adr/0006-subcell-rendering-and-easing.md new file mode 100644 index 0000000..c53adc6 --- /dev/null +++ b/docs/ru/adr/0006-subcell-rendering-and-easing.md @@ -0,0 +1,59 @@ +# ADR-0006: Субпиксельный рендеринг и функции сглаживания (Easing) + +## Статус +Принято + +## Контекст +Fluint нацелен на плавные дискретные анимации и продвинутый VFX, но +рендер-пайплайн работает с точностью до отдельной ячейки (один символ +на ячейку терминала). Вертикальное разрешение therefore ограничено +высотой терминала в строках. Функции сглаживания — второй критический +примитив: каждая анимационная интерполяция (твины, переходы, затухания +частиц) требует переиспользуемой абстракции `EaseFunc`, вызываемой на +горячем пути без аллокаций. + +## Решение + +### Субпиксельный рендеринг (`core/buffer/subcell.go`) +Добавлен метод `Buffer.SetSubCellY(x, y, ySub int, color uint32)`, +рисующий «субпиксели» половинных блоков по вертикали с помощью Unicode- +символов `▀` (U+2580, верхний) и `▄` (U+2584, нижний). + +- `ySub == 0` → верхняя половина: цвет в `Cell.Fg`, символ = `▀`. +- `ySub == 1` → нижняя половина: цвет в `Cell.Bg`, символ = `▀` + при наличии обеих половин или `▄` при одной нижней. + +Удвоение вертикального разрешения без дополнительной памяти — +используются только существующие поля Fg/Bg ячейки. + +### Функции сглаживания (`anim/easing.go`) +Новый пакет `anim` экспортирует: + +``` +type EaseFunc func(t float64) float64 +``` + +Девять именованных кривых на основе пакетных функций (не замыканий): +`Linear`, `InQuad`, `OutQuad`, `InOutQuad`, `InCubic`, `OutCubic`, +`InOutCubic`, `OutBounce`, `OutElastic`. + +## Обоснование +- Субпиксельный рендеринг следует технике половинных блоков, применяемой + в Sixel и braille-TUI рендерерах, но сохраняется внутри обычной + модели `Cell` — без дополнительного фреймбуфера. +- Именованные пакетные функции (вместо замыканий в `var`) позволяют + компилятору Go инлайнить вызовы через `EaseFunc`, удерживая тик + анимации на 0 allocs/op. +- `math.Pow` используется только в `OutElastic` (огибающая `2^(-10t)` + не имеет арифметического эквивалента). Все остальные кривые — только + `*` и `+`. + +## Последствия +- Любой рендерер, сериализующий ячейки в ANSI, должен корректно + обрабатывать символ половинного блока — `render/ansi` уже пишет + произвольные Unicode-кодпоинты, поэтому изменений не требуется. +- Горизонтальный субпиксель (четвертные блоки / braille) расширяет + эту модель добавлением `SetSubCellX`. +- `OutBounce` и `OutElastic` могут кратковременно выходить за [0, 1]; + вызывающий код, требующий ограниченного диапазона, должен явно + зажимать значение. diff --git a/docs/zh/CHANGELOG.md b/docs/zh/CHANGELOG.md index f3e44a9..356db69 100644 --- a/docs/zh/CHANGELOG.md +++ b/docs/zh/CHANGELOG.md @@ -5,6 +5,13 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 并且本项目遵循 [语义化版本控制](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。 +## [v0.2.0] - 2026-07-28 + +### 新增 +- `anim` 包:缓动函数包 (`EaseFunc`),提供 9 个命名曲线 — `Linear`、`InQuad`、`OutQuad`、`InOutQuad`、`InCubic`、`OutCubic`、`InOutCubic`、`OutBounce`、`OutElastic`,基于包级函数(非闭包)实现热路径零分配调用。仅 `OutElastic` 使用 `math.Pow`;其余均为纯算术运算。 +- `core/buffer` 包:`Buffer.SetSubCellY(x, y, ySub int, color uint32)` 方法,使用 Unicode 半块字形(`▀` U+2580、`▄` U+2584)实现垂直子像素渲染。通过复用现有 `Cell.Fg`/`Cell.Bg` 字段,在无需额外内存的情况下将垂直分辨率翻倍。 +- ADR-0006:子像素渲染与缓动函数架构决策记录。 + ## [v0.1.1] - 2026-07-27 ### 新增 diff --git a/docs/zh/adr/0006-subcell-rendering-and-easing.md b/docs/zh/adr/0006-subcell-rendering-and-easing.md new file mode 100644 index 0000000..83d8f17 --- /dev/null +++ b/docs/zh/adr/0006-subcell-rendering-and-easing.md @@ -0,0 +1,50 @@ +# ADR-0006: 子像素渲染与缓动函数 + +## 状态 +已接受 + +## 上下文 +Fluint 的目标是实现平滑的离散单元格动画和高级 VFX,但渲染管线 +以单元格粒度运行(每个终端单元格一个字形)。因此垂直分辨率受限于 +终端的行数。缓动函数是第二个关键原语:每次动画插值(补间、过渡、 +粒子淡出)都需要一个可在每帧热路径上无分配调用的可复用 `EaseFunc` +抽象。 + +## 决策 + +### 子像素渲染 (`core/buffer/subcell.go`) +新增 `Buffer.SetSubCellY(x, y, ySub int, color uint32)` 方法,使用 +Unicode 半块字形 `▀` (U+2580, 上半) 和 `▄` (U+2584, 下半) 沿 +垂直轴绘制半块"子像素"。 + +- `ySub == 0` → 上半部分:颜色存入 `Cell.Fg`,字形设为 `▀`。 +- `ySub == 1` → 下半部分:颜色存入 `Cell.Bg`,当两半均存在时字形 + 为 `▀`,仅下半存在时为 `▄`。 + +无需额外内存即可实现垂直分辨率翻倍——仅复用单元格已有的 Fg/Bg 字段。 + +### 缓动函数 (`anim/easing.go`) +新包 `anim` 导出: + +``` +type EaseFunc func(t float64) float64 +``` + +九个命名曲线,基于包级函数(非闭包): +`Linear`、`InQuad`、`OutQuad`、`InOutQuad`、`InCubic`、`OutCubic`、 +`InOutCubic`、`OutBounce`、`OutElastic`。 + +## 依据 +- 子像素渲染遵循 Sixel 和 braille-TUI 渲染器使用的半块技术,但 + 保持在普通 `Cell` 模型内——无需额外的帧缓冲区。 +- 命名包级函数(而非存储在 `var` 中的闭包)让 Go 编译器能够通过 + `EaseFunc` 值内联调用,使动画 tick 保持在 0 allocs/op。 +- `math.Pow` 仅用于 `OutElastic`(`2^(-10t)` 包络无算术等价形式)。 + 其他所有曲线仅使用 `*` 和 `+`。 + +## 影响 +- 任何将单元格序列化为 ANSI 的渲染器必须正确处理半块字形—— + `render/ansi` 已支持任意 Unicode 码位,因此当前无需修改。 +- 水平子像素(四分块 / braille)可通过添加 `SetSubCellX` 自然扩展。 +- `OutBounce` 和 `OutElastic` 可能短暂超出 [0, 1];需要有界输出的 + 调用方应显式钳位。