├── internal ├── ticks.go ├── bridge.go ├── ellipse.go ├── spring.go └── utils.go ├── .gitignore ├── .gitattributes ├── filters ├── nearest.kage ├── src_hermite.kage ├── src_bilinear.kage ├── bilinear.kage ├── hermite.kage ├── src_bicubic.kage ├── aa_sampling_soft.kage ├── aa_sampling_sharp.kage └── bicubic.kage ├── default_interfaces.go ├── go.mod ├── controller_tick.go ├── controller_queued_draw.go ├── shaker ├── combo.go ├── interface.go ├── random.go ├── spring.go ├── bezier.go ├── balanced.go └── quake.go ├── LICENSE ├── tracker ├── spring_corrector.go ├── helpers.go ├── interface.go ├── spring.go ├── stateless.go ├── follower.go ├── spring_tailer.go ├── tailer.go ├── parametrized.go └── corrector.go ├── go.sum ├── misc.go ├── docs ├── camera-interfaces.md ├── ups-vs-tps.md └── introduction.md ├── controller_convert.go ├── zoomer ├── interface.go ├── rough_linear.go ├── smooth_linear.go ├── quadratic.go ├── constant.go └── spring.go ├── controller_debug.go ├── controller_shaders.go ├── api_redraw.go ├── api_offscreen.go ├── controller_shaker_channel.go ├── controller_project.go ├── README.md ├── utils └── utils.go ├── api_camera.go ├── controller_camera.go ├── controller_core.go └── api_core.go /internal/ticks.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | type TicksDuration uint32 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # hidden folder 2 | /unused/ 3 | 4 | # dark secrets 5 | TODO.md 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # make .kage shaders be highlighted in Github like Go code 2 | *.kage linguist-language=Go 3 | -------------------------------------------------------------------------------- /filters/nearest.kage: -------------------------------------------------------------------------------- 1 | //kage:unit pixels 2 | package main 3 | 4 | func Fragment(_ vec4, sourceCoords vec2, _ vec4) vec4 { 5 | return imageSrc0UnsafeAt(sourceCoords) 6 | } 7 | -------------------------------------------------------------------------------- /default_interfaces.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "github.com/tinne26/mipix/tracker" 4 | import "github.com/tinne26/mipix/zoomer" 5 | import "github.com/tinne26/mipix/shaker" 6 | 7 | var defaultZoomer *zoomer.Quadratic 8 | var defaultTracker *tracker.SpringTailer 9 | var defaultShaker *shaker.Random 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tinne26/mipix 2 | 3 | go 1.22.2 4 | 5 | require github.com/hajimehoshi/ebiten/v2 v2.7.3 6 | 7 | require ( 8 | github.com/ebitengine/gomobile v0.0.0-20240329170434-1771503ff0a8 // indirect 9 | github.com/ebitengine/hideconsole v1.0.0 // indirect 10 | github.com/ebitengine/purego v0.7.0 // indirect 11 | github.com/jezek/xgb v1.1.1 // indirect 12 | golang.org/x/sync v0.6.0 // indirect 13 | golang.org/x/sys v0.18.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /controller_tick.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | func (self *controller) tickNow() uint64 { 6 | return self.currentTick 7 | } 8 | 9 | func (self *controller) tickSetRate(rate int) { 10 | if rate < 1 || rate > 256 { panic("tick rate must be within [1, 256]") } 11 | self.tickRate = uint64(rate) 12 | internal.CurrentTPU = self.tickRate // massive hacks for unholy reasons 13 | } 14 | 15 | func (self *controller) tickGetRate() int { 16 | return int(self.tickRate) 17 | } 18 | -------------------------------------------------------------------------------- /internal/bridge.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "image" 4 | 5 | import "github.com/hajimehoshi/ebiten/v2" 6 | 7 | var BridgedLogicalWidth int 8 | var BridgedLogicalHeight int 9 | var BridgedCameraOrigin image.Point 10 | var CurrentZoom float64 11 | var CurrentTPU uint64 // ticks per update 12 | 13 | func GetCurrentZoom() float64 { 14 | return CurrentZoom 15 | } 16 | 17 | func GetResolution() (int, int) { 18 | return BridgedLogicalWidth, BridgedLogicalHeight 19 | } 20 | 21 | func GetUPS() int { 22 | return ebiten.TPS() 23 | } 24 | 25 | func GetTPU() uint64 { 26 | return CurrentTPU 27 | } 28 | -------------------------------------------------------------------------------- /controller_queued_draw.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "github.com/hajimehoshi/ebiten/v2" 4 | 5 | type queuedDraw struct { 6 | hiResFunc func(*ebiten.Image, *ebiten.Image) 7 | logicalFunc func(*ebiten.Image) 8 | } 9 | 10 | func (self *queuedDraw) IsHighResolution() bool { 11 | return self.hiResFunc != nil 12 | } 13 | 14 | func (self *controller) queueDraw(handler func(*ebiten.Image)) { 15 | if !self.inDraw { panic("can't queue draw outside draw stage") } 16 | self.queuedDraws = append(self.queuedDraws, queuedDraw{ logicalFunc: handler }) 17 | } 18 | 19 | func (self *controller) queueHiResDraw(handler func(*ebiten.Image, *ebiten.Image)) { 20 | if !self.inDraw { panic("can't queue draw outside draw stage") } 21 | self.queuedDraws = append(self.queuedDraws, queuedDraw{ hiResFunc: handler }) 22 | } 23 | -------------------------------------------------------------------------------- /internal/ellipse.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "math" 4 | import "math/rand" 5 | 6 | func RollPointWithinEllipse(width, height float64) (float64, float64) { 7 | // the tangent of angles approaching 90degs goes to infinite, 8 | // so I'm limiting it to 89.99 degrees at most 9 | const AsymptoteMargin = 0.02*math.Pi/180.0 10 | angle := rand.Float64()*(math.Pi - AsymptoteMargin) - (math.Pi/2.0 - AsymptoteMargin/2) 11 | slope := math.Tan(angle) 12 | 13 | // get half of the width and height 14 | width /= 2.0 15 | height /= 2.0 16 | 17 | // line equation is y = slope*x 18 | // ellipse equation is (x/halfWidth)^2 + (y/halfHeight)^2 = 1 19 | // if we solve the system, we get: 20 | x := (height*width)/math.Sqrt(height*height + (slope*slope)*width*width) 21 | y := slope*x 22 | if rand.Float64() < 0.5 { x = -x } 23 | x *= EaseOutQuad(rand.Float64()) 24 | y *= EaseOutQuad(rand.Float64()) 25 | return x, y 26 | } 27 | -------------------------------------------------------------------------------- /filters/src_hermite.kage: -------------------------------------------------------------------------------- 1 | //kage:unit pixels 2 | package main 3 | 4 | func Fragment(_ vec4, sourceCoords vec2, _ vec4) vec4 { 5 | minCoords, maxCoords := getMinMaxSourceCoords() 6 | tl := imageSrc0At(clamp(sourceCoords + vec2(-0.5, -0.5), minCoords, maxCoords)) 7 | tr := imageSrc0At(clamp(sourceCoords + vec2(+0.5, -0.5), minCoords, maxCoords)) 8 | bl := imageSrc0At(clamp(sourceCoords + vec2(-0.5, +0.5), minCoords, maxCoords)) 9 | br := imageSrc0At(clamp(sourceCoords + vec2(+0.5, +0.5), minCoords, maxCoords)) 10 | delta := smoothstep(vec2(0), vec2(1), fract(sourceCoords + vec2(0.5))) 11 | top := mix(tl, tr, delta.x) 12 | bottom := mix(bl, br, delta.x) 13 | return mix(top, bottom, delta.y) 14 | } 15 | 16 | func getMinMaxSourceCoords() (vec2, vec2) { 17 | const epsilon = 1.0/65536.0 // TODO: determine how small can we safely set this 18 | origin := imageSrc0Origin() 19 | return origin, origin + imageSrc0Size() - vec2(epsilon) 20 | } 21 | -------------------------------------------------------------------------------- /filters/src_bilinear.kage: -------------------------------------------------------------------------------- 1 | //kage:unit pixels 2 | package main 3 | 4 | func Fragment(_ vec4, sourceCoords vec2, _ vec4) vec4 { 5 | minCoords, maxCoords := getMinMaxSourceCoords() 6 | tl := imageSrc0At(clamp(sourceCoords + vec2(-0.5, -0.5), minCoords, maxCoords)) 7 | tr := imageSrc0At(clamp(sourceCoords + vec2(+0.5, -0.5), minCoords, maxCoords)) 8 | bl := imageSrc0At(clamp(sourceCoords + vec2(-0.5, +0.5), minCoords, maxCoords)) 9 | br := imageSrc0At(clamp(sourceCoords + vec2(+0.5, +0.5), minCoords, maxCoords)) 10 | delta := fract(sourceCoords + vec2(0.5)) // the fract position of BR is the interpolation point 11 | top := mix(tl, tr, delta.x) 12 | bottom := mix(bl, br, delta.x) 13 | return mix(top, bottom, delta.y) 14 | } 15 | 16 | func getMinMaxSourceCoords() (vec2, vec2) { 17 | const epsilon = 1.0/65536.0 // TODO: determine how small can we safely set this 18 | origin := imageSrc0Origin() 19 | return origin, origin + imageSrc0Size() - vec2(epsilon) 20 | } 21 | -------------------------------------------------------------------------------- /shaker/combo.go: -------------------------------------------------------------------------------- 1 | package shaker 2 | 3 | var _ Shaker = (*Combo)(nil) 4 | 5 | // An example [Shaker] created by combining a [Balanced] and 6 | // a [Random] shaker. This is only offered to showcase how 7 | // easy it is to create new shakers by combining previously 8 | // existing ones. Since this is only an example, no methods 9 | // for configuring the parameters are exposed. 10 | type Combo struct { 11 | balanced Balanced 12 | rand Random 13 | initialized bool 14 | } 15 | 16 | func (self *Combo) initialize() { 17 | self.initialized = true 18 | self.balanced.SetMotionScale(0.014) 19 | self.balanced.SetTravelTime(0.26) 20 | self.rand.SetTravelTime(0.03) 21 | self.rand.SetMotionScale(0.02) 22 | } 23 | 24 | // Implements [Shaker]. 25 | func (self *Combo) GetShakeOffsets(level float64) (float64, float64) { 26 | if !self.initialized { self.initialize() } 27 | bx, by := self.balanced.GetShakeOffsets(level) 28 | rx, ry := self.rand.GetShakeOffsets(level) 29 | return bx + rx, by + ry 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 tinne26 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /tracker/spring_corrector.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | type springCorrector struct { 6 | spring internal.Spring 7 | speedX, speedY float64 // logical, relative to resolution and zoom 8 | initialized bool 9 | } 10 | 11 | func (self *springCorrector) initialize() { 12 | self.initialized = true 13 | if !self.spring.IsInitialized() { 14 | self.spring.SetParameters(0.9, 1.75) 15 | } 16 | } 17 | 18 | func (self *springCorrector) SetParameters(damping, power float64) { 19 | self.spring.SetParameters(damping, power) 20 | } 21 | 22 | func (self *springCorrector) Update(errorX, errorY float64) { 23 | if !self.initialized { self.initialize() } 24 | 25 | updateDelta := 1.0/float64(internal.GetUPS()) 26 | w, h := internal.GetResolution() 27 | w64, h64 := float64(w), float64(h) 28 | errorX /= w64 29 | errorY /= h64 30 | 31 | _, self.speedX = self.spring.Update(0.0, errorX, self.speedX) 32 | _, self.speedY = self.spring.Update(0.0, errorY, self.speedY) 33 | if internal.Abs(self.speedX) < 0.12*updateDelta { 34 | self.speedX = 0.0 35 | } 36 | if internal.Abs(self.speedY) < 0.12*updateDelta { 37 | self.speedY = 0.0 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tracker/helpers.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | // the parameters are too complicated to understand for the average lib user, 6 | // so this is like a subpar version of Parametrized, which was made clearer 7 | func computeLinComponent(current, target, minAdvance, maxAdvance, refMaxDist float64) float64 { 8 | // determine base speed 9 | if target > current { // going right 10 | dist := min(target - current, refMaxDist) 11 | t := internal.TAt(dist, 0, refMaxDist) 12 | advance := internal.LinearInterp(0, maxAdvance, t) 13 | if advance >= minAdvance { return advance } 14 | return min(minAdvance, dist) 15 | } else { // going left 16 | dist := min(current - target, refMaxDist) 17 | t := internal.TAt(dist, 0, refMaxDist) 18 | advance := internal.LinearInterp(0, maxAdvance, t) 19 | if advance >= minAdvance { return -advance } 20 | return -min(minAdvance, dist) 21 | } 22 | } 23 | 24 | func sim(predictedChange, actualChange float64, maxErrorForZeroSimilarity float64) float64 { 25 | predictionError := internal.Abs(actualChange - predictedChange) 26 | if predictionError > maxErrorForZeroSimilarity { return 0.0 } 27 | return 1.0 - predictionError/maxErrorForZeroSimilarity 28 | } 29 | -------------------------------------------------------------------------------- /filters/bilinear.kage: -------------------------------------------------------------------------------- 1 | //kage:unit pixels 2 | package main 3 | 4 | var SourceRelativeTextureUnitX float 5 | var SourceRelativeTextureUnitY float 6 | 7 | func Fragment(_ vec4, sourceCoords vec2, _ vec4) vec4 { 8 | percent := vec2(SourceRelativeTextureUnitX, SourceRelativeTextureUnitY) 9 | halfPercent := percent/2.0 10 | minCoords, maxCoords := getMinMaxSourceCoords() 11 | tl := imageSrc0UnsafeAt(clamp(sourceCoords + vec2(-halfPercent.x, -halfPercent.y), minCoords, maxCoords)) 12 | tr := imageSrc0UnsafeAt(clamp(sourceCoords + vec2(+halfPercent.x, -halfPercent.y), minCoords, maxCoords)) 13 | bl := imageSrc0UnsafeAt(clamp(sourceCoords + vec2(-halfPercent.x, +halfPercent.y), minCoords, maxCoords)) 14 | br := imageSrc0UnsafeAt(clamp(sourceCoords + vec2(+halfPercent.x, +halfPercent.y), minCoords, maxCoords)) 15 | delta := min(fract(sourceCoords + vec2(+halfPercent.x, +halfPercent.y)), percent)/percent 16 | top := mix(tl, tr, delta.x) 17 | bottom := mix(bl, br, delta.x) 18 | return mix(top, bottom, delta.y) 19 | } 20 | 21 | func getMinMaxSourceCoords() (vec2, vec2) { 22 | const epsilon = 1.0/65536.0 // TODO: determine how small can we safely set this 23 | origin := imageSrc0Origin() 24 | return origin, origin + imageSrc0Size() - vec2(epsilon) 25 | } 26 | -------------------------------------------------------------------------------- /tracker/interface.go: -------------------------------------------------------------------------------- 1 | // This package defines a [Tracker] interface that the mipix 2 | // camera can use to update its position, and provides a few 3 | // default implementations. 4 | // 5 | // All provided implementations respect a few properties: 6 | // - Resolution independent: range of motion for the tracking 7 | // is not hardcoded, but proportional to the game's resolution. 8 | // - Tick-rate independent: tracking preserves the same relative 9 | // speed regardless of your Tick().UPS() and Tick().GetRate() 10 | // values. See [ups-vs-tps] if you need more context. 11 | // These are nice properties for public implementations, but if you 12 | // are writing your own, remember that most often these properties 13 | // won't be relevant to you. You can ignore them and make your life 14 | // easier if you are only getting started. 15 | // 16 | // [ups-vs-tps]: https://github.com/tinne26/mipix/blob/main/docs/ups-vs-tps.md 17 | package tracker 18 | 19 | // The interface for mipix camera tracking. 20 | // 21 | // Given current and target coordinates, a tracker must return 22 | // the position change for a single update. 23 | type Tracker interface { 24 | Update(currentX, currentY, targetX, targetY, prevSpeedX, prevSpeedY float64) (float64, float64) 25 | } 26 | -------------------------------------------------------------------------------- /filters/hermite.kage: -------------------------------------------------------------------------------- 1 | //kage:unit pixels 2 | package main 3 | 4 | var SourceRelativeTextureUnitX float 5 | var SourceRelativeTextureUnitY float 6 | 7 | func Fragment(_ vec4, sourceCoords vec2, _ vec4) vec4 { 8 | percent := vec2(SourceRelativeTextureUnitX, SourceRelativeTextureUnitY) 9 | halfPercent := percent/2.0 10 | minCoords, maxCoords := getMinMaxSourceCoords() 11 | tl := imageSrc0UnsafeAt(clamp(sourceCoords + vec2(-halfPercent.x, -halfPercent.y), minCoords, maxCoords)) 12 | tr := imageSrc0UnsafeAt(clamp(sourceCoords + vec2(+halfPercent.x, -halfPercent.y), minCoords, maxCoords)) 13 | bl := imageSrc0UnsafeAt(clamp(sourceCoords + vec2(-halfPercent.x, +halfPercent.y), minCoords, maxCoords)) 14 | br := imageSrc0UnsafeAt(clamp(sourceCoords + vec2(+halfPercent.x, +halfPercent.y), minCoords, maxCoords)) 15 | delta := min(fract(sourceCoords + vec2(+halfPercent.x, +halfPercent.y)), percent)/percent 16 | delta = smoothstep(vec2(0), vec2(1), delta) 17 | top := mix(tl, tr, delta.x) 18 | bottom := mix(bl, br, delta.x) 19 | return mix(top, bottom, delta.y) 20 | } 21 | 22 | func getMinMaxSourceCoords() (vec2, vec2) { 23 | const epsilon = 1.0/65536.0 // TODO: determine how small can we safely set this 24 | origin := imageSrc0Origin() 25 | return origin, origin + imageSrc0Size() - vec2(epsilon) 26 | } 27 | -------------------------------------------------------------------------------- /filters/src_bicubic.kage: -------------------------------------------------------------------------------- 1 | //kage:unit pixels 2 | package main 3 | 4 | func Fragment(_ vec4, sourceCoords vec2, _ vec4) vec4 { 5 | minCoords, maxCoords := getMinMaxSourceCoords() 6 | delta := fract(sourceCoords + vec2(0.5)) 7 | a := cubicRow(sourceCoords - vec2(0, 1.5), delta.x, minCoords, maxCoords) 8 | b := cubicRow(sourceCoords - vec2(0, 0.5), delta.x, minCoords, maxCoords) 9 | c := cubicRow(sourceCoords + vec2(0, 0.5), delta.x, minCoords, maxCoords) 10 | d := cubicRow(sourceCoords + vec2(0, 1.5), delta.x, minCoords, maxCoords) 11 | return cubicInterp(delta.y, a, b, c, d) 12 | } 13 | 14 | func cubicRow(coords vec2, delta float, minCoords, maxCoords vec2) vec4 { 15 | a := imageSrc0At(clamp(coords - vec2(1.5, 0), minCoords, maxCoords)) 16 | b := imageSrc0At(clamp(coords - vec2(0.5, 0), minCoords, maxCoords)) 17 | c := imageSrc0At(clamp(coords + vec2(0.5, 0), minCoords, maxCoords)) 18 | d := imageSrc0At(clamp(coords + vec2(1.5, 0), minCoords, maxCoords)) 19 | return cubicInterp(delta, a, b, c, d) 20 | } 21 | 22 | func cubicInterp(x float, a, b, c, d vec4) vec4 { 23 | return (-0.5*a + 1.5*b - 1.5*c + 0.5*d)*(x*x*x) + (a - 2.5*b + 2.0*c - 0.5*d)*(x*x) + (-0.5*a + 0.5*c)*x + b 24 | } 25 | 26 | func getMinMaxSourceCoords() (vec2, vec2) { 27 | const epsilon = 1.0/65536.0 // TODO: determine how small can we safely set this 28 | origin := imageSrc0Origin() 29 | return origin, origin + imageSrc0Size() - vec2(epsilon) 30 | } 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ebitengine/gomobile v0.0.0-20240329170434-1771503ff0a8 h1:5e8X7WEdOWrjrKvgaWF6PRnDvJicfrkEnwAkWtMN74g= 2 | github.com/ebitengine/gomobile v0.0.0-20240329170434-1771503ff0a8/go.mod h1:tWboRRNagZwwwis4QIgEFG1ZNFwBJ3LAhSLAXAAxobQ= 3 | github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= 4 | github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= 5 | github.com/ebitengine/purego v0.7.0 h1:HPZpl61edMGCEW6XK2nsR6+7AnJ3unUxpTZBkkIXnMc= 6 | github.com/ebitengine/purego v0.7.0/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= 7 | github.com/hajimehoshi/ebiten/v2 v2.7.3 h1:lDpj8KbmmjzwD19rsjXNkyelicu0XGvklZW6/tjrgNs= 8 | github.com/hajimehoshi/ebiten/v2 v2.7.3/go.mod h1:1vjyPw+h3n30rfTOpIsbWRXSxZ0Oz1cYc6Tq/2DKoQg= 9 | github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= 10 | github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= 11 | golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= 12 | golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= 13 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 14 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 15 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 16 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 17 | -------------------------------------------------------------------------------- /misc.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "github.com/hajimehoshi/ebiten/v2" 4 | 5 | import "github.com/tinne26/mipix/internal" 6 | 7 | // Helper type used for fades and durations of some effects. 8 | type TicksDuration = internal.TicksDuration 9 | 10 | const ZeroTicks TicksDuration = 0 11 | 12 | // Quick alias to the control key for use with [AccessorDebug.Printfk](). 13 | const Ctrl = ebiten.KeyControl 14 | 15 | // internal usage 16 | const maxUint32 = 0xFFFF_FFFF 17 | 18 | // --- helpers --- 19 | 20 | func setAt[T any](slice []T, element T, index int) []T { 21 | // base case: element index already in range 22 | if index < len(slice) { 23 | slice[index] = element 24 | return slice 25 | } 26 | 27 | // append case: element index is the next 28 | if index == len(slice) { 29 | return append(slice, element) 30 | } 31 | 32 | // within capacity: element can be set by expanding capacity 33 | if index < cap(slice) { 34 | slice = slice[ : index + 1] 35 | slice[index] = element 36 | return slice 37 | } 38 | 39 | // more capacity needed: expand capacity 40 | slice = slice[ : cap(slice)] 41 | growth := (index + 1) - len(slice) 42 | if growth == 1 { 43 | return append(slice, element) 44 | } else { 45 | slice = append(slice, make([]T, growth)...) 46 | slice[index] = element 47 | return slice 48 | } 49 | } 50 | 51 | // --- errors --- 52 | const mixedShakerChans = "can't mix shaker.ChanAll with other explicit channels" 53 | -------------------------------------------------------------------------------- /tracker/spring.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | type Spring struct { 6 | spring internal.Spring 7 | speedX, speedY float64 8 | initialized bool 9 | } 10 | 11 | func (self *Spring) initialize() { 12 | self.initialized = true 13 | if !self.spring.IsInitialized() { 14 | self.spring.SetParameters(0.55, 4.5) 15 | } 16 | } 17 | 18 | func (self *Spring) SetParameters(damping, power float64) { 19 | self.spring.SetParameters(damping, power) 20 | self.initialized = true 21 | } 22 | 23 | func (self *Spring) Update(currentX, currentY, targetX, targetY, prevSpeedX, prevSpeedY float64) (float64, float64) { 24 | // initialization 25 | if !self.initialized { self.initialize() } 26 | 27 | // stabilization case 28 | if internal.Abs(targetX - currentX) < 0.001 && internal.Abs(targetY - currentY) < 0.001 { 29 | self.speedX, self.speedY = 0.0, 0.0 30 | return targetX - currentX, targetY - currentY 31 | } 32 | 33 | // get resolution 34 | w, h := internal.GetResolution() 35 | widthF64, heightF64 := float64(w), float64(h) 36 | 37 | // advance with spring 38 | var newX, newY float64 39 | newX, self.speedX = self.spring.Update(currentX/widthF64, targetX/widthF64, self.speedX) 40 | newY, self.speedY = self.spring.Update(currentY/heightF64, targetY/heightF64, self.speedY) 41 | newX *= widthF64 42 | newY *= heightF64 43 | 44 | // normalize change by zoom level 45 | zoom := internal.GetCurrentZoom() 46 | return (newX - currentX)*zoom, (newY - currentY)*zoom 47 | } 48 | -------------------------------------------------------------------------------- /filters/aa_sampling_soft.kage: -------------------------------------------------------------------------------- 1 | //kage:unit pixels 2 | package main 3 | 4 | // Notice: this shader is inspired by the anti-aliased pixel sampling 5 | // tutorials from d7samurai, see https://gist.github.com/d7samurai 6 | 7 | var SourceRelativeTextureUnitX float 8 | var SourceRelativeTextureUnitY float 9 | 10 | func Fragment(_ vec4, sourceCoords vec2, _ vec4) vec4 { 11 | percent := vec2(SourceRelativeTextureUnitX, SourceRelativeTextureUnitY) 12 | sampleCoords := floor(sourceCoords) + min(fract(sourceCoords)/percent, 1.0) - 0.5 13 | 14 | // bilinear sampling 15 | const epsilon = 1.0/65536.0 // hack to get rid of artifacts 16 | minCoords, maxCoords := getMinMaxSourceCoords() 17 | percent = vec2(1.0 - epsilon, 1.0 - epsilon) 18 | halfPercent := percent/2.0 19 | tl := imageSrc0UnsafeAt(clamp(sampleCoords + vec2(-halfPercent.x, -halfPercent.y), minCoords, maxCoords)) 20 | tr := imageSrc0UnsafeAt(clamp(sampleCoords + vec2(+halfPercent.x, -halfPercent.y), minCoords, maxCoords)) 21 | bl := imageSrc0UnsafeAt(clamp(sampleCoords + vec2(-halfPercent.x, +halfPercent.y), minCoords, maxCoords)) 22 | br := imageSrc0UnsafeAt(clamp(sampleCoords + vec2(+halfPercent.x, +halfPercent.y), minCoords, maxCoords)) 23 | delta := min(fract(sampleCoords + vec2(+halfPercent.x, +halfPercent.y)), percent)/percent 24 | top := mix(tl, tr, delta.x) 25 | bottom := mix(bl, br, delta.x) 26 | return mix(top, bottom, delta.y) 27 | } 28 | 29 | func getMinMaxSourceCoords() (vec2, vec2) { 30 | const epsilon = 1.0/65536.0 // TODO: determine how small can we safely set this 31 | origin := imageSrc0Origin() 32 | return origin, origin + imageSrc0Size() - vec2(epsilon) 33 | } 34 | -------------------------------------------------------------------------------- /filters/aa_sampling_sharp.kage: -------------------------------------------------------------------------------- 1 | //kage:unit pixels 2 | package main 3 | 4 | // Notice: this shader is inspired by the anti-aliased pixel sampling 5 | // tutorials from d7samurai, see https://gist.github.com/d7samurai 6 | 7 | var SourceRelativeTextureUnitX float 8 | var SourceRelativeTextureUnitY float 9 | 10 | func Fragment(_ vec4, sourceCoords vec2, _ vec4) vec4 { 11 | percent := vec2(SourceRelativeTextureUnitX, SourceRelativeTextureUnitY) 12 | sampleCoords := floor(sourceCoords) + smoothstep(0.0, 1.0, fract(sourceCoords)/percent) - 0.5 13 | 14 | // bilinear sampling 15 | const epsilon = 1.0/65536.0 // hack to get rid of artifacts 16 | minCoords, maxCoords := getMinMaxSourceCoords() 17 | percent = vec2(1.0 - epsilon, 1.0 - epsilon) 18 | halfPercent := percent/2.0 19 | tl := imageSrc0UnsafeAt(clamp(sampleCoords + vec2(-halfPercent.x, -halfPercent.y), minCoords, maxCoords)) 20 | tr := imageSrc0UnsafeAt(clamp(sampleCoords + vec2(+halfPercent.x, -halfPercent.y), minCoords, maxCoords)) 21 | bl := imageSrc0UnsafeAt(clamp(sampleCoords + vec2(-halfPercent.x, +halfPercent.y), minCoords, maxCoords)) 22 | br := imageSrc0UnsafeAt(clamp(sampleCoords + vec2(+halfPercent.x, +halfPercent.y), minCoords, maxCoords)) 23 | delta := min(fract(sampleCoords + vec2(+halfPercent.x, +halfPercent.y)), percent)/percent 24 | top := mix(tl, tr, delta.x) 25 | bottom := mix(bl, br, delta.x) 26 | return mix(top, bottom, delta.y) 27 | } 28 | 29 | func getMinMaxSourceCoords() (vec2, vec2) { 30 | const epsilon = 1.0/65536.0 // TODO: determine how small can we safely set this 31 | origin := imageSrc0Origin() 32 | return origin, origin + imageSrc0Size() - vec2(epsilon) 33 | } 34 | -------------------------------------------------------------------------------- /docs/camera-interfaces.md: -------------------------------------------------------------------------------- 1 | # Camera interfaces 2 | 3 | I just wanted to write a small document talking about camera tracking and zooming. After writing a few implementations for [`Zoomer`](https://pkg.go.dev/github.com/tinne26/mipix/zoomer#Zoomer) and [`Tracker`](https://pkg.go.dev/github.com/tinne26/mipix/tracker#Tracker) interfaces, I realized there are many more subtleties than I initially guessed. 4 | 5 | For zooming, for example, there are a lot of questions you might want to ask yourself: 6 | - Should zooming be bouncier and playful or more rigid and serious? 7 | - Should zooming be snapier or smoother? 8 | - Should zooming vary more in speed during transitions to look more dynamic, or should it be more consistent? 9 | - Should zooming be compensated to look more perceptually linear? 10 | - Should zoom-ins and zoom-outs be symmetric or asymmetric? 11 | - Should zooming have a speed limit? 12 | - Should zoom starts and ends have additional softening? 13 | - Do I care about quick zoom level stabilization when transition speed is halting? 14 | 15 | Depending on the mood of your game, the zoom range, zoom use frequency, zoom control (manual vs automatic) and so on, these questions are not just rhetorical! It's not that hard to imagine different games for basically any combination of answers from the previous questions. 16 | 17 | While a functional zoom or tracker can be created with a simple `lerp(current, target, 0.1)`, trying to tailor the implementations to your specific game should not be underestimated. Even if mipix provides a few different implementations, there are many small decisions that will make much more sense when they are made for a specific game and context. Some people might consider this a waste of time, but after having spent more time with it I can clearly see how there's no "universal" solution; it's a very rich space to explore —if you want to—, and even if no one else might care, it's not meaningless that you do. 18 | -------------------------------------------------------------------------------- /controller_convert.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | func (self *controller) convertToRelativeCoords(x, y int) (float64, float64) { 6 | xMargin, yMargin := self.hackyGetMargins() 7 | relX := (float64(x) - xMargin)/(float64(self.hiResWidth ) - xMargin*2) 8 | relY := (float64(y) - yMargin)/(float64(self.hiResHeight) - yMargin*2) 9 | return internal.Clamp(relX, 0.0, 1.0), internal.Clamp(relY, 0.0, 1.0) 10 | } 11 | 12 | func (self *controller) convertToLogicalCoords(x, y int) (float64, float64) { 13 | rx, ry := self.convertToRelativeCoords(x, y) 14 | minX, minY, _, _ := self.cameraAreaF64() 15 | return minX + rx*float64(self.logicalWidth)/self.zoomCurrent, minY + ry*float64(self.logicalHeight)/self.zoomCurrent 16 | } 17 | 18 | func (self *controller) convertToGameResolution(x, y int) (float64, float64) { 19 | rx, ry := self.convertToRelativeCoords(x, y) 20 | return rx*float64(self.logicalWidth), ry*float64(self.logicalHeight) 21 | } 22 | 23 | func (self *controller) hackyGetMargins() (float64, float64) { 24 | if self.stretchingEnabled { return 0, 0 } 25 | var hiWidth, hiHeight int 26 | if self.inDraw { 27 | hiWidth = self.prevHiResCanvasWidth 28 | hiHeight = self.prevHiResCanvasHeight 29 | } else { 30 | hiWidth = self.hiResWidth 31 | hiHeight = self.hiResHeight 32 | } 33 | 34 | hiAspectRatio := float64(hiWidth)/float64(hiHeight) 35 | loAspectRatio := float64(self.logicalWidth)/float64(self.logicalHeight) 36 | switch { 37 | case hiAspectRatio == loAspectRatio: // just scaling 38 | return 0, 0 39 | case hiAspectRatio > loAspectRatio: // horz margins 40 | xMargin := int((float64(hiWidth) - loAspectRatio*float64(hiHeight))/2.0) 41 | return float64(xMargin), 0 42 | case loAspectRatio > hiAspectRatio: // vert margins 43 | yMargin := int((float64(hiHeight) - float64(hiWidth)/loAspectRatio)/2.0) 44 | return 0, float64(yMargin) 45 | default: 46 | panic("unreachable") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /filters/bicubic.kage: -------------------------------------------------------------------------------- 1 | //kage:unit pixels 2 | package main 3 | 4 | var SourceRelativeTextureUnitX float 5 | var SourceRelativeTextureUnitY float 6 | 7 | func Fragment(_ vec4, sourceCoords vec2, _ vec4) vec4 { 8 | minCoords, maxCoords := getMinMaxSourceCoords() 9 | percent := vec2(SourceRelativeTextureUnitX, SourceRelativeTextureUnitY) 10 | halfPercentY := SourceRelativeTextureUnitY/2.0 11 | oneHalfPercY := SourceRelativeTextureUnitY + halfPercentY 12 | a := cubicRow(sourceCoords - vec2(0, oneHalfPercY), minCoords, maxCoords, percent.x) 13 | b := cubicRow(sourceCoords - vec2(0, halfPercentY), minCoords, maxCoords, percent.x) 14 | c := cubicRow(sourceCoords + vec2(0, halfPercentY), minCoords, maxCoords, percent.x) 15 | d := cubicRow(sourceCoords + vec2(0, oneHalfPercY), minCoords, maxCoords, percent.x) 16 | delta := min(fract(sourceCoords.y + halfPercentY), percent.y)/percent.y 17 | return clamp(cubicInterp(delta, a, b, c, d), vec4(0, 0, 0, 0), vec4(1, 1, 1, 1)) 18 | } 19 | 20 | func cubicRow(coords vec2, minCoords, maxCoords vec2, percentX float) vec4 { 21 | halfPercentX := SourceRelativeTextureUnitX/2.0 22 | oneHalfPercX := SourceRelativeTextureUnitX + halfPercentX 23 | a := imageSrc0UnsafeAt(clamp(coords - vec2(oneHalfPercX, 0), minCoords, maxCoords)) 24 | b := imageSrc0UnsafeAt(clamp(coords - vec2(halfPercentX, 0), minCoords, maxCoords)) 25 | c := imageSrc0UnsafeAt(clamp(coords + vec2(halfPercentX, 0), minCoords, maxCoords)) 26 | d := imageSrc0UnsafeAt(clamp(coords + vec2(oneHalfPercX, 0), minCoords, maxCoords)) 27 | delta := min(fract(coords.x + halfPercentX), percentX)/percentX 28 | return cubicInterp(delta, a, b, c, d) 29 | } 30 | 31 | func cubicInterp(x float, a, b, c, d vec4) vec4 { 32 | return (-0.5*a + 1.5*b - 1.5*c + 0.5*d)*(x*x*x) + (a - 2.5*b + 2.0*c - 0.5*d)*(x*x) + (-0.5*a + 0.5*c)*x + b 33 | } 34 | 35 | func getMinMaxSourceCoords() (vec2, vec2) { 36 | const epsilon = 1.0/65536.0 // TODO: determine how small can we safely set this 37 | origin := imageSrc0Origin() 38 | return origin, origin + imageSrc0Size() - vec2(epsilon) 39 | } 40 | -------------------------------------------------------------------------------- /tracker/stateless.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | type tracker = Tracker 6 | 7 | // A few stateless built-in trackers. 8 | var ( 9 | // Update(...) always returns (0, 0). 10 | Frozen tracker = frozenTracker{} 11 | 12 | // Update(...) always returns (target - current). 13 | Instant tracker = instantTracker{} 14 | 15 | // Applies a lerp between current and target position. 16 | Linear tracker = linearTracker{} 17 | ) 18 | 19 | type frozenTracker struct {} 20 | func (frozenTracker) Update(currentX, currentY, targetX, targetY, prevSpeedX, prevSpeedY float64) (float64, float64) { 21 | return 0, 0 22 | } 23 | 24 | type instantTracker struct{} 25 | func (instantTracker) Update(currentX, currentY, targetX, targetY, prevSpeedX, prevSpeedY float64) (float64, float64) { 26 | return targetX - currentX, targetY - currentY 27 | } 28 | 29 | // A simple linear interpolation tracker. 30 | type linearTracker struct {} 31 | 32 | func (self linearTracker) Update(currentX, currentY, targetX, targetY, prevSpeedX, prevSpeedY float64) (float64, float64) { 33 | // stabilization 34 | if internal.Abs(targetX - currentX) < 0.001 && internal.Abs(targetY - currentY) < 0.001 { 35 | return targetX - currentX, targetY - currentY 36 | } 37 | 38 | // general update 39 | w, h := internal.GetResolution() 40 | zoom := internal.GetCurrentZoom() 41 | widthF64, heightF64 := float64(w)/zoom, float64(h)/zoom 42 | 43 | updateDelta := 1.0/float64(internal.GetUPS()) 44 | maxHorzAdvance := 6.0*zoom*widthF64*updateDelta // use higher values for a more rigid / strict tracking 45 | maxVertAdvance := 6.0*zoom*heightF64*updateDelta // use lower values for a more elastic / softer tracking 46 | minAdvance := 0.01*updateDelta 47 | refHorzMaxDist := 2.0*widthF64 // higher values lead to smoother tracking 48 | refVertMaxDist := 2.0*heightF64 49 | 50 | horzAdvance := computeLinComponent(currentX, targetX, minAdvance, maxHorzAdvance, refHorzMaxDist) 51 | vertAdvance := computeLinComponent(currentY, targetY, minAdvance, maxVertAdvance, refVertMaxDist) 52 | return horzAdvance, vertAdvance 53 | } 54 | -------------------------------------------------------------------------------- /zoomer/interface.go: -------------------------------------------------------------------------------- 1 | // This package defines a [Zoomer] interface that the mipix 2 | // camera can use to update its position, and provides a few 3 | // default implementations. 4 | // 5 | // All provided implementations respect a few properties: 6 | // - Resolution independent: range of motion for the tracking 7 | // is not hardcoded, but proportional to the game's resolution. 8 | // - Update-rate independent: as long as the total ticks per 9 | // second remain the same, different Tick().UPS() values will 10 | // still reproduce the same results. See [ups-vs-tps] if you 11 | // need more context. Many implementations are actually also 12 | // tick-rate independent. 13 | // These are nice properties for public implementations, but if you 14 | // are writing your own, remember that most often these properties 15 | // won't be relevant to you. You can ignore them and make your life 16 | // easier if you are only getting started. 17 | // 18 | // Warning: avoid bringing cameras to 0.1 and similarly low zoom 19 | // levels. At those levels, zoomer bounciness and overshoot can make 20 | // your game collapse very easily. It's better to be at x3.0 zoom by 21 | // default most of the time than going to super low values that 22 | // might be unstable and dangerous to work with. There are many 23 | // sources of unstability, like different update/tick rate 24 | // configurations, changing zooms mid-transition and so on. 25 | // Always strive to operate either with generous safety margins 26 | // or very safe zoomers without bounciness. 27 | // 28 | // [ups-vs-tps]: https://github.com/tinne26/mipix/blob/main/docs/ups-vs-tps.md 29 | package zoomer 30 | 31 | import "github.com/tinne26/mipix/internal" 32 | 33 | // The interface for mipix camera zooming. 34 | // 35 | // Given current and target zoom levels, the Update() method 36 | // returns the zoom change for a single update. Reset() 37 | // is used to indicate an instantaneous zoom level reset 38 | // instead. 39 | type Zoomer interface { 40 | Reset() 41 | Update(currentZoom, targetZoom float64) (change float64) 42 | } 43 | 44 | // Alias for mipix.TicksDuration. 45 | type TicksDuration = internal.TicksDuration 46 | -------------------------------------------------------------------------------- /controller_debug.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "fmt" 4 | 5 | import "github.com/hajimehoshi/ebiten/v2" 6 | import "github.com/hajimehoshi/ebiten/v2/ebitenutil" // not desirable, but let's ignore it for the moment 7 | 8 | func (self *controller) debugDrawf(format string, args ...any) { 9 | self.debugInfo = append(self.debugInfo, fmt.Sprintf(format, args...)) 10 | } 11 | 12 | func (self *controller) debugPrintfr(firstTick, lastTick uint64, format string, args ...any) { 13 | if self.currentTick >= firstTick && self.currentTick <= lastTick { 14 | fmt.Printf(format, args...) 15 | } 16 | } 17 | 18 | func (self *controller) debugPrintfe(everyNTicks uint64, format string, args ...any) { 19 | if self.currentTick % everyNTicks == 0 { 20 | fmt.Printf(format, args...) 21 | } 22 | } 23 | 24 | func (self *controller) debugPrintfk(key ebiten.Key, format string, args ...any) { 25 | if ebiten.IsKeyPressed(key) { 26 | fmt.Printf(format, args...) 27 | } 28 | } 29 | 30 | // --- internal --- 31 | 32 | func (self *controller) debugDrawAll(target *ebiten.Image) { 33 | if len(self.debugInfo) == 0 { return } 34 | 35 | // determine offscreen size 36 | targetBounds := target.Bounds() 37 | targetWidth, targetHeight := float64(targetBounds.Dx()), float64(targetBounds.Dy()) 38 | height := 256.0/ebiten.Monitor().DeviceScaleFactor() 39 | width := height*(targetWidth/targetHeight) 40 | offWidth, offHeight := int(width), int(height) 41 | 42 | // create offscreen if necessary 43 | if self.debugOffscreen == nil { 44 | self.debugOffscreen = NewOffscreen(offWidth, offHeight) 45 | } else { 46 | currWidth, currHeight := self.debugOffscreen.Size() 47 | if currWidth != offWidth || currHeight != offHeight { 48 | self.debugOffscreen = NewOffscreen(offWidth, offHeight) 49 | } else { // (unless skip draw, but debug is only called if needsRedraw) 50 | self.debugOffscreen.Clear() 51 | } 52 | } 53 | 54 | // draw info to offscreen and project 55 | for i, info := range self.debugInfo { 56 | ebitenutil.DebugPrintAt(self.debugOffscreen.Target(), info, 1, 1 + i*12) 57 | } 58 | self.debugOffscreen.Project(target) 59 | 60 | // clear debug info 61 | self.debugInfo = self.debugInfo[ : 0] 62 | } 63 | -------------------------------------------------------------------------------- /zoomer/rough_linear.go: -------------------------------------------------------------------------------- 1 | package zoomer 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | var _ Zoomer = (*RoughLinear)(nil) 6 | 7 | // Somewhat similar to [SmoothLinear], but without any bouncing 8 | // and rougher turns mid-transition. 9 | // 10 | // It also has a customizable speed factor. 11 | // 12 | // The implementation is tick-rate independent. 13 | type RoughLinear struct { 14 | speedFactorOffset float64 15 | adjustedTarget float64 16 | } 17 | 18 | // Speed factor must be strictly positive. Defaults to 1.0. 19 | func (self *RoughLinear) SetSpeedFactor(factor float64) { 20 | if factor <= 0 { panic("zoom speed factor must be strictly positive") } 21 | self.speedFactorOffset = factor - 1.0 22 | } 23 | 24 | // Implements [Zoomer]. 25 | func (self *RoughLinear) Reset() { 26 | self.adjustedTarget = internal.GetCurrentZoom() 27 | } 28 | 29 | // Implements [Zoomer]. 30 | func (self *RoughLinear) Update(currentZoom, targetZoom float64) float64 { 31 | const MaxZoomTracking float64 = 5.0 32 | 33 | updateDelta := 1.0/float64(internal.GetUPS()) 34 | if targetZoom != self.adjustedTarget { 35 | var dir float64 = 1.0 36 | if targetZoom < self.adjustedTarget { dir = -1.0 } 37 | 38 | distance := targetZoom - self.adjustedTarget 39 | normDist := internal.Clamp(distance, -MaxZoomTracking, MaxZoomTracking) 40 | targetApproximation := normDist*1.6*updateDelta + dir*updateDelta/2.0 41 | if internal.Abs(targetZoom - currentZoom) < internal.Abs(distance - targetApproximation) { 42 | self.adjustedTarget = currentZoom 43 | return 0.0 44 | } else { 45 | self.adjustedTarget += targetApproximation 46 | switch dir { 47 | case +1.0: self.adjustedTarget = min(self.adjustedTarget, targetZoom) 48 | case -1.0: self.adjustedTarget = max(self.adjustedTarget, targetZoom) 49 | } 50 | } 51 | } 52 | 53 | var a, b, t float64 = 0.0, self.adjustedTarget - currentZoom, 2.6*updateDelta 54 | change := internal.LinearInterp(a, b, t)*(1.0 + self.speedFactorOffset) 55 | if change < 0 { 56 | change -= updateDelta/3.0 57 | change = max(change, targetZoom - currentZoom) 58 | } else if change > 0 { 59 | change += updateDelta/3.0 60 | change = min(change, targetZoom - currentZoom) 61 | } 62 | return change 63 | } 64 | -------------------------------------------------------------------------------- /controller_shaders.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import _ "embed" 4 | 5 | import "github.com/hajimehoshi/ebiten/v2" 6 | 7 | // TODO: consider using quasilyte's minifier and paste code directly 8 | 9 | //go:embed filters/aa_sampling_soft.kage 10 | var _aaSamplingSoft []byte 11 | 12 | //go:embed filters/aa_sampling_sharp.kage 13 | var _aaSamplingSharp []byte 14 | 15 | //go:embed filters/nearest.kage 16 | var _nearest []byte 17 | 18 | //go:embed filters/hermite.kage 19 | var _hermite []byte 20 | 21 | //go:embed filters/bicubic.kage 22 | var _bicubic []byte 23 | 24 | //go:embed filters/bilinear.kage 25 | var _bilinear []byte 26 | 27 | //go:embed filters/src_hermite.kage 28 | var _srcHermite []byte 29 | 30 | //go:embed filters/src_bicubic.kage 31 | var _srcBicubic []byte 32 | 33 | //go:embed filters/src_bilinear.kage 34 | var _srcBilinear []byte 35 | 36 | var pkgSrcKageFilters [scalingFilterEndSentinel][]byte 37 | func init() { 38 | pkgSrcKageFilters[Nearest] = _nearest 39 | pkgSrcKageFilters[AASamplingSoft] = _aaSamplingSoft 40 | pkgSrcKageFilters[AASamplingSharp] = _aaSamplingSharp 41 | pkgSrcKageFilters[Hermite] = _hermite 42 | pkgSrcKageFilters[Bicubic] = _bicubic 43 | pkgSrcKageFilters[Bilinear] = _bilinear 44 | pkgSrcKageFilters[SrcHermite] = _srcHermite 45 | pkgSrcKageFilters[SrcBicubic] = _srcBicubic 46 | pkgSrcKageFilters[SrcBilinear] = _srcBilinear 47 | } 48 | 49 | func (self *controller) compileShader(filter ScalingFilter) { 50 | var err error 51 | self.shaders[filter], err = ebiten.NewShader(pkgSrcKageFilters[filter]) 52 | if err != nil { 53 | panic("Failed to compile shader for '" + filter.String() + "' filter: " + err.Error()) 54 | } 55 | if self.shaderOpts.Uniforms == nil { 56 | self.initShaderProperties() 57 | } 58 | } 59 | 60 | func (self *controller) initShaderProperties() { 61 | self.shaderVertices = make([]ebiten.Vertex, 4) 62 | self.shaderVertIndices = []uint16{0, 1, 3, 3, 1, 2} 63 | self.shaderOpts.Uniforms = make(map[string]interface{}, 2) 64 | for i := range 4 { // doesn't matter unless I start doing color scaling 65 | self.shaderVertices[i].ColorR = 1.0 66 | self.shaderVertices[i].ColorG = 1.0 67 | self.shaderVertices[i].ColorB = 1.0 68 | self.shaderVertices[i].ColorA = 1.0 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tracker/follower.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | type follower struct { 6 | engaged bool 7 | 8 | elapsedMatch float64 9 | elapsedHalt float64 10 | 11 | matchRequiredDuration float64 12 | haltRequiredDuration float64 13 | matchErrorMargin float64 14 | 15 | initialized bool 16 | } 17 | 18 | func (self *follower) initialize() { 19 | self.initialized = true 20 | if self.matchRequiredDuration == 0.0 { 21 | self.matchRequiredDuration = 1.0 22 | } 23 | if self.haltRequiredDuration == 0.0 { 24 | self.haltRequiredDuration = 0.5 25 | } 26 | self.matchErrorMargin = 0.05 27 | } 28 | 29 | func (self *follower) SetTimes(engage, disengage float64) { 30 | if engage < disengage { panic("engage time must be >= disengage") } 31 | self.matchRequiredDuration = engage 32 | self.haltRequiredDuration = disengage 33 | } 34 | 35 | func (self *follower) IsEngaged() bool { 36 | return self.engaged 37 | } 38 | 39 | func (self *follower) Update(changeX, changeY, prevSpeedX, prevSpeedY float64) { 40 | if !self.initialized { self.initialize() } 41 | 42 | // helper values 43 | updateDelta := 1.0/float64(internal.GetUPS()) 44 | speedX, speedY := internal.Abs(changeX/updateDelta), internal.Abs(changeY/updateDelta) 45 | w, h := internal.GetResolution() 46 | w64, h64 := float64(w), float64(h) 47 | zoom := internal.GetCurrentZoom() 48 | normWidth, normHeight := w64/zoom, h64/zoom 49 | 50 | // update elapsed match / halt 51 | if speedX/normWidth <= self.matchErrorMargin && speedY/normHeight <= self.matchErrorMargin { 52 | self.elapsedHalt += updateDelta 53 | } else { 54 | self.elapsedHalt = 0.0 55 | } 56 | halted := (self.elapsedHalt >= self.haltRequiredDuration) 57 | 58 | if !halted && self.elapsedMatch < self.matchRequiredDuration { 59 | normSpeedUDiffX := internal.Abs(speedX - internal.Abs(prevSpeedX))/normWidth 60 | normSpeedUDiffY := internal.Abs(speedY - internal.Abs(prevSpeedY))/normWidth 61 | if normSpeedUDiffX <= self.matchErrorMargin && normSpeedUDiffY <= self.matchErrorMargin { 62 | self.elapsedMatch += updateDelta 63 | } else { 64 | self.elapsedMatch = 0.0 65 | } 66 | } else if halted { 67 | self.elapsedMatch = 0.0 68 | } 69 | 70 | if halted || self.elapsedMatch < self.matchRequiredDuration { 71 | self.engaged = false 72 | } else { 73 | self.engaged = true 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /zoomer/smooth_linear.go: -------------------------------------------------------------------------------- 1 | package zoomer 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | var _ Zoomer = (*SmoothLinear)(nil) 6 | 7 | // An implementation of the [Zoomer] interface that uses 8 | // linear interpolation for zoom speeds, but with some 9 | // additional smoothing factors. Very handcrafted, can 10 | // "spring" a bit (overshoot the target and rebound). 11 | // 12 | // The implementation is tick-rate independent. 13 | type SmoothLinear struct { 14 | speed float64 15 | adjustedTarget float64 16 | } 17 | 18 | // Implements [Zoomer]. 19 | func (self *SmoothLinear) Reset() { 20 | self.adjustedTarget = internal.GetCurrentZoom() 21 | self.speed = 0.0 22 | } 23 | 24 | // Implements [Zoomer]. 25 | func (self *SmoothLinear) Update(currentZoom, targetZoom float64) float64 { 26 | const MaxZoomTracking float64 = 5.0 27 | 28 | // The idea behind the maths is the following: using linear interpolation 29 | // for speeds already results in smooth changes (integrating a linear 30 | // function gives you a quadratic one). There are some problems with a 31 | // naive approach, though: 32 | // - Massive changes can lead to non-smooth deltas. We correct for this 33 | // by setting a maximum distance value and clamping. 34 | // - Sudden target changes, which are not common with movement, but are 35 | // common with zoom changes, can look unpleasant. What we do here is 36 | // not registering a new target directly, but instead get closer and 37 | // closer to it progressively, with self.adjustedTarget. There are 38 | // still some edge cases, but we smooth that with an extra speed 39 | // interpolation. 40 | 41 | updateDelta := 1.0/float64(internal.GetUPS()) 42 | if targetZoom != self.adjustedTarget { 43 | distance := targetZoom - self.adjustedTarget 44 | normDist := internal.Clamp(distance, -MaxZoomTracking, MaxZoomTracking) 45 | targetApproximation := normDist*1.6*updateDelta 46 | if internal.Abs(targetZoom - currentZoom) < internal.Abs(distance - targetApproximation) { 47 | self.adjustedTarget = currentZoom 48 | } else { 49 | self.adjustedTarget += targetApproximation 50 | } 51 | } 52 | 53 | newSpeed := internal.LinearInterp(0.0, self.adjustedTarget - currentZoom, 0.15) 54 | self.speed = internal.LinearInterp(self.speed, newSpeed, 3.0*updateDelta) 55 | speed := self.speed*updateDelta*20.0 56 | return speed 57 | } 58 | -------------------------------------------------------------------------------- /internal/spring.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "math" 4 | 5 | type Spring struct { 6 | initialized bool 7 | damping float64 // drag / resistance [0...1.0] 8 | frequency float64 // power / speed [0.001...Inf] 9 | 10 | lastUPS int 11 | tempAlpha float64 12 | tempExp float64 13 | tempCosExp float64 14 | tempSinExp float64 15 | } 16 | 17 | func (self *Spring) IsInitialized() bool { 18 | return self.initialized 19 | } 20 | 21 | func (self *Spring) SetParameters(damping, frequency float64) { 22 | if damping < 0.0 || damping > 1.0 { 23 | panic("damping must be in [0.0, 1.0] range") 24 | } 25 | if frequency < 0.001 { 26 | panic("frequency must be in >= 0.001") 27 | } 28 | if damping != self.damping || frequency != self.frequency { 29 | self.damping = damping 30 | self.frequency = frequency 31 | self.recomputeExpensiveTerms() 32 | self.initialized = true 33 | } 34 | } 35 | 36 | func (self *Spring) recomputeExpensiveTerms() { 37 | self.lastUPS = GetUPS() 38 | delta := 1.0/float64(self.lastUPS) 39 | if self.damping >= 0.999 { 40 | self.tempExp = math.Exp(-self.frequency*delta) 41 | } else { 42 | freqByDamp := self.frequency*self.damping 43 | self.tempAlpha = self.frequency*math.Sqrt(1.0 - self.damping*self.damping) 44 | self.tempExp = math.Exp(-freqByDamp*delta) 45 | self.tempSinExp = math.Sin(self.tempAlpha*delta)*self.tempExp 46 | self.tempCosExp = math.Cos(self.tempAlpha*delta)*self.tempExp 47 | } 48 | } 49 | 50 | // Returns the new position and new speed. 51 | func (self *Spring) Update(current, target, speed float64) (float64, float64) { 52 | if !self.initialized { panic("must Spring.SetParameters() before using") } 53 | 54 | if GetUPS() != self.lastUPS { 55 | self.recomputeExpensiveTerms() 56 | } 57 | 58 | var posPos, velVel, posVel, velPos float64 59 | if self.damping >= 0.999 { 60 | posVel = (1.0/float64(self.lastUPS))*self.tempExp 61 | expr := posVel*self.frequency 62 | velPos = -self.frequency*expr 63 | posPos = +expr + self.tempExp 64 | velVel = -expr + self.tempExp 65 | } else { 66 | freqByDamp := self.frequency*self.damping 67 | expr := self.tempSinExp*freqByDamp*(1.0/self.tempAlpha) 68 | posVel = +self.tempSinExp*(1.0/self.tempAlpha) 69 | velPos = -self.tempSinExp*self.tempAlpha - freqByDamp*expr 70 | posPos = self.tempCosExp + expr 71 | velVel = self.tempCosExp - expr 72 | } 73 | 74 | mirroredStart := current - target 75 | current = mirroredStart*posPos + speed*posVel + target 76 | speed = mirroredStart*velPos + speed*velVel 77 | return current, speed 78 | } 79 | -------------------------------------------------------------------------------- /shaker/interface.go: -------------------------------------------------------------------------------- 1 | // This package defines a [Shaker] interface that the mipix 2 | // camera can use to perform screen shakes, and provides 3 | // a few default implementations. 4 | // 5 | // All provided implementations respect a few properties: 6 | // - Resolution independent: range of motion for the shakes 7 | // is not hardcoded, but proportional to the game's resolution. 8 | // - Tick-rate independent: results are visually similar 9 | // regardless of your Tick().UPS() and Tick().GetRate() values. 10 | // See [ups-vs-tps] if you need more context. 11 | // These are nice properties for public implementations, but if you 12 | // are writing your own, remember that most often these properties 13 | // won't be relevant to you. You can ignore them and make your life 14 | // easier if you are only getting started. 15 | // 16 | // [ups-vs-tps]: https://github.com/tinne26/mipix/blob/main/docs/ups-vs-tps.md 17 | package shaker 18 | 19 | // The interface for mipix screen shakers. 20 | // 21 | // Given a level that transitions linearly between 0 and 1 22 | // during the fade in and fade out stages, GetShakeOffsets() 23 | // returns the logical offsets for the camera. 24 | // 25 | // After stoping, there will be one call with level = 0 that 26 | // can be used to reset the shaker state. The results of this 27 | // call will be disregarded. 28 | // 29 | // Minor detail: all built-in implementations happen to normalize 30 | // the fade in/out level with a cubic smoothstep, just to make 31 | // things nicer. 32 | type Shaker interface { 33 | GetShakeOffsets(level float64) (float64, float64) 34 | } 35 | 36 | // Used by mipix in case multiple shakes need to be active at the same time. 37 | // 38 | // Channel zero is special and will use a fallback shaker even if uninitialized 39 | // (though it's best practice to always set your shakers explicitly). It's also 40 | // the channel that will be automatically selected for most shaker functions in 41 | // mipix if no channel is explicitly passed. 42 | // 43 | // Here's an example of when multiple channels are useful: 44 | // - You need an always-on shake for camera motion or environment shaking, 45 | // like being in a ship or hot air ballon. 46 | // - You need the typical triggered shakes for momentary impacts, explosions, 47 | // earthquakes and so on. 48 | // - You have some alter states like drunk or confused that might use some 49 | // extra shaker channels. 50 | // In these cases, you should define your own channel constants, e.g: 51 | // const ( 52 | // ChanBackground shaker.Channel = iota 53 | // ChanTrigger 54 | // ChanDrunk 55 | // ) 56 | // In even more complex cases, you might decide to treat your channels like a 57 | // shaker pool to manage everything more dynamically; who knows, it all depends 58 | // on the game. 59 | type Channel uint8 60 | -------------------------------------------------------------------------------- /api_redraw.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | // See [Redraw](). 4 | type AccessorRedraw struct{} 5 | 6 | // Provides access to methods for efficient GPU usage in 7 | // a structured manner. Use through method chaining, e.g.: 8 | // mipix.Redraw().SetManaged(true) 9 | // 10 | // In some games and applications it's possible to spare 11 | // GPU by using [ebiten.SetScreenClearedEveryFrame](false) 12 | // and omitting redundant draw calls. 13 | // 14 | // The redraw accessor allows you to synchronize this 15 | // process with mipix itself, as there are some projections 16 | // that would otherwise fall outside your control. 17 | // 18 | // By default, redraws are executed on every frame. If you 19 | // want to manage them more efficiently, you can do the 20 | // following: 21 | // - Make sure to disable Ebitengine's screen clear. 22 | // - Opt into managed redraws with [AccessorRedraw.SetManaged](true). 23 | // - Whenever a redraw becomes necessary, issue an 24 | // [AccessorRedraw.Request](). 25 | // - On [Game].Draw(), if ![AccessorRedraw.Pending](), skip the draw. 26 | func Redraw() AccessorRedraw { 27 | return AccessorRedraw{} 28 | } 29 | 30 | // Enables or disables manual redraw management. By default, 31 | // redraw management is disabled and the screen is redrawn 32 | // every frame. 33 | // 34 | // Must only be called during initialization or [Game].Update(). 35 | func (AccessorRedraw) SetManaged(managed bool) { 36 | pkgController.redrawSetManaged(managed) 37 | } 38 | 39 | // Returns whether manual redraw management is enabled or not. 40 | func (AccessorRedraw) IsManaged() bool { 41 | return pkgController.redrawIsManaged() 42 | } 43 | 44 | // Notifies mipix that the next [Game].Draw() needs to be 45 | // projected to the screen. Requests are typically issued 46 | // when relevant input or events are detected during 47 | // [Game].Update(). 48 | // 49 | // Zoom and camera changes are also auto-detected. 50 | // 51 | // This function can be called multiple times within a single 52 | // update, it's only doing the equivalent of "needs redraw = true". 53 | func (AccessorRedraw) Request() { 54 | pkgController.redrawRequest() 55 | } 56 | 57 | // Returns whether a redraw is still pending. Notice that 58 | // besides explicit requests, a redraw can also be pending 59 | // due to a canvas resize, the modification of the scaling 60 | // properties or others. 61 | // 62 | // This method is often used like this: 63 | // func (game *Game) Draw(canvas *ebiten.Image) { 64 | // if !mipix.Redraw().Pending() { return } 65 | // // ... 66 | // } 67 | func (AccessorRedraw) Pending() bool { 68 | return pkgController.redrawPending() 69 | } 70 | 71 | // Signal the redraw manager to clear both the logical screen 72 | // and the high resolution canvas before the next [Game].Draw(). 73 | func (AccessorRedraw) ScheduleClear() { 74 | pkgController.redrawScheduleClear() 75 | } 76 | -------------------------------------------------------------------------------- /api_offscreen.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "image" 4 | import "image/color" 5 | 6 | import "github.com/hajimehoshi/ebiten/v2" 7 | 8 | import "github.com/tinne26/mipix/internal" 9 | 10 | // Offscreens are logically sized canvases that you can draw to 11 | // and later project to high resolution space. They simplify the 12 | // job of drawing pixel-perfect UI and other camera-independent 13 | // elements of your game (but most of your game should still be 14 | // drawn directly to the canvas received by [Game].Draw(), not 15 | // offscreens). 16 | // 17 | // Creating an offscreen involves creating an [*ebiten.Image], so 18 | // you want to store and reuse them. They also have to be manually 19 | // cleared when required. 20 | type Offscreen struct { 21 | canvas *ebiten.Image 22 | width int 23 | height int 24 | drawImageOpts ebiten.DrawImageOptions 25 | } 26 | 27 | // Creates a new offscreen with the given logical size. 28 | // 29 | // Never invoke this per frame, always reuse offscreens. 30 | func NewOffscreen(width, height int) *Offscreen { 31 | return &Offscreen{ 32 | canvas: ebiten.NewImage(width, height), 33 | width: width, height: height, 34 | } 35 | } 36 | 37 | // Returns the underlying canvas for the offscreen. 38 | func (self *Offscreen) Target() *ebiten.Image { 39 | return self.canvas 40 | } 41 | 42 | // Returns the size of the offscreen. 43 | func (self *Offscreen) Size() (width, height int) { 44 | return self.width, self.height 45 | } 46 | 47 | // Equivalent to [ebiten.Image.DrawImage](). 48 | func (self *Offscreen) Draw(source *ebiten.Image, opts *ebiten.DrawImageOptions) { 49 | self.canvas.DrawImage(source, opts) 50 | } 51 | 52 | // Handy version of [Offscreen.Draw]() with specific coordinates. 53 | func (self *Offscreen) DrawAt(source *ebiten.Image, x, y int) { 54 | self.drawImageOpts.GeoM.Translate(float64(x), float64(y)) 55 | self.canvas.DrawImage(source, &self.drawImageOpts) 56 | self.drawImageOpts.GeoM.Reset() 57 | } 58 | 59 | // Similar to [ebiten.Image.Fill](), but with BlendSourceOver 60 | // instead of BlendCopy. 61 | func (self *Offscreen) Coat(fillColor color.Color) { 62 | internal.FillOverRect(self.canvas, self.canvas.Bounds(), fillColor) 63 | } 64 | 65 | // Similar to [Offscreen.Coat](), but restricted to a specific 66 | // rectangular area. 67 | func (self *Offscreen) CoatRect(bounds image.Rectangle, fillColor color.Color) { 68 | internal.FillOverRect(self.canvas, bounds, fillColor) 69 | } 70 | 71 | // Clears the underlying offscreen canvas. 72 | func (self *Offscreen) Clear() { 73 | self.canvas.Clear() 74 | } 75 | 76 | // Projects the offscreen into the given target. In most cases, 77 | // you will want to draw to the active high resolution target of 78 | // your game (the second argument of a [QueueHiResDraw]() handler). 79 | func (self *Offscreen) Project(target *ebiten.Image) { 80 | pkgController.project(self.canvas, target) 81 | } 82 | -------------------------------------------------------------------------------- /tracker/spring_tailer.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | // Note: I could have a generic Tailer[T], but anyone who wants 6 | // to write their own stuff can figure it out. 7 | 8 | // Like [Tailer], but based on a [Spring] tracker, and the catch 9 | // up algorithm also uses a spring instead of quadratic easings. 10 | // 11 | // Example settings configuration for a gentle tracker: 12 | // springTailer := tracker.SpringTailer{} 13 | // springTailer.Spring.SetParameters(0.8, 2.4) 14 | // springTailer.SetCatchUpParameters(0.9, 1.75) 15 | type SpringTailer struct { 16 | Spring Spring 17 | follower follower 18 | corrector springCorrector 19 | // note: with a quadratic corrector, the following 20 | // settings are nice: spring {0.666, 2.0}, accel. {0.15} 21 | } 22 | 23 | // Sets the spring parameters for the catch up corrector. 24 | // The default values are (0.9, 1.75). 25 | func (self *SpringTailer) SetCatchUpParameters(damping, power float64) { 26 | self.corrector.SetParameters(damping, power) 27 | } 28 | 29 | // See [Tailer.SetCatchUpTimes](). The default values are (1.0, 0.5). 30 | func (self *SpringTailer) SetCatchUpTimes(engage, disengage float64) { 31 | if engage < disengage { panic("engage time must be >= disengage") } 32 | self.follower.SetTimes(engage, disengage) 33 | } 34 | 35 | // Implements [Tracker]. 36 | func (self *SpringTailer) Update(currentX, currentY, targetX, targetY, prevSpeedX, prevSpeedY float64) (float64, float64) { 37 | // pre-subtract correction 38 | w, h := internal.GetResolution() 39 | w64, h64 := float64(w), float64(h) 40 | zoom := internal.GetCurrentZoom() 41 | updateDelta := 1.0/(float64(internal.GetUPS())) 42 | relCurrentX := currentX - (self.corrector.speedX*w64*updateDelta)/zoom 43 | relCurrentY := currentY - (self.corrector.speedY*h64*updateDelta)/zoom 44 | 45 | // basic parametrized update 46 | changeX, changeY := self.Spring.Update(relCurrentX, relCurrentY, targetX, targetY, prevSpeedX, prevSpeedY) 47 | 48 | // follower correction 49 | self.follower.Update(changeX, changeY, prevSpeedX, prevSpeedY) 50 | if self.follower.IsEngaged() { 51 | self.corrector.Update(targetX - currentX, targetY - currentY) 52 | } else { // deceleration case 53 | self.corrector.Update(0.0, 0.0) 54 | } 55 | correctorChangeX := (self.corrector.speedX*w64*updateDelta)/zoom 56 | correctorChangeY := (self.corrector.speedY*h64*updateDelta)/zoom 57 | changeX += correctorChangeX 58 | changeY += correctorChangeY 59 | 60 | // stabilization pass 61 | if self.corrector.speedX == 0.0 && self.corrector.speedY == 0.0 && 62 | internal.Abs(changeX) < 0.12*updateDelta && internal.Abs(changeY) < 0.12*updateDelta && 63 | internal.Abs(targetX - (currentX + changeX)) < (0.25/zoom)*updateDelta && 64 | internal.Abs(targetY - (currentY + changeY)) < (0.25/zoom)*updateDelta { 65 | return targetX - currentX, targetY - currentY 66 | } 67 | 68 | return changeX, changeY 69 | } 70 | 71 | -------------------------------------------------------------------------------- /tracker/tailer.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | // A tracker that uses a [Parametrized] implementation as its base, 6 | // which you can access and configure directly as a struct field, 7 | // and then adds a catch up mechanism that triggers after you move 8 | // for some time in a more or less consistent speed. 9 | type Tailer struct { 10 | Parametrized Parametrized 11 | follower follower 12 | corrector corrector 13 | } 14 | 15 | // Once the catching up mechanism is triggered, it uses a static 16 | // acceleration to progressively change speed. Reasonable values 17 | // range between [0.1, 0.5]. The default is 0.2. 18 | func (self *Tailer) SetCatchUpAcceleration(acceleration float64) { 19 | self.corrector.SetAcceleration(acceleration) 20 | } 21 | 22 | // Specifies how long it takes the catch up mechanism to engage 23 | // and disengage. The values are given in seconds: 24 | // - Engaging happens when we have been moving the target 25 | // at a more or less consistent speed for a while. 26 | // - Disengaging happens when we have been stopped for a while. 27 | // The default values are 1.0, 0.5. The disengage time must 28 | // always be <= than the engage time. 29 | func (self *Tailer) SetCatchUpTimes(engage, disengage float64) { 30 | if engage < disengage { panic("engage time must be >= disengage") } 31 | self.follower.SetTimes(engage, disengage) 32 | } 33 | 34 | // Implements [Tracker]. 35 | func (self *Tailer) Update(currentX, currentY, targetX, targetY, prevSpeedX, prevSpeedY float64) (float64, float64) { 36 | // pre-subtract correction 37 | w, h := internal.GetResolution() 38 | w64, h64 := float64(w), float64(h) 39 | zoom := internal.GetCurrentZoom() 40 | updateDelta := 1.0/(float64(internal.GetUPS())) 41 | relCurrentX := currentX - (self.corrector.speedX*w64*updateDelta)/zoom 42 | relCurrentY := currentY - (self.corrector.speedY*h64*updateDelta)/zoom 43 | 44 | // basic parametrized update 45 | changeX, changeY := self.Parametrized.Update(relCurrentX, relCurrentY, targetX, targetY, prevSpeedX, prevSpeedY) 46 | 47 | // follower correction 48 | self.follower.Update(changeX, changeY, prevSpeedX, prevSpeedY) 49 | if self.follower.IsEngaged() { 50 | self.corrector.Update(targetX - currentX, targetY - currentY) 51 | } else { // deceleration case 52 | self.corrector.Update(0.0, 0.0) // * 53 | // * here we could just self.corrector.Decelerate() too, 54 | // but I kinda prefer the results with Update(0.0, 0.0) 55 | } 56 | correctorChangeX := (self.corrector.speedX*w64*updateDelta)/zoom 57 | correctorChangeY := (self.corrector.speedY*h64*updateDelta)/zoom 58 | changeX += correctorChangeX 59 | changeY += correctorChangeY 60 | 61 | // stabilization pass 62 | if self.corrector.speedX == 0.0 && self.corrector.speedY == 0.0 && 63 | internal.Abs(changeX) < 0.12*updateDelta && internal.Abs(changeY) < 0.12*updateDelta && 64 | internal.Abs(targetX - (currentX + changeX)) < (0.25/zoom)*updateDelta && 65 | internal.Abs(targetY - (currentY + changeY)) < (0.25/zoom)*updateDelta { 66 | return targetX - currentX, targetY - currentY 67 | } 68 | 69 | return changeX, changeY 70 | } 71 | 72 | -------------------------------------------------------------------------------- /zoomer/quadratic.go: -------------------------------------------------------------------------------- 1 | package zoomer 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | // After writing a few zoomers, I saw I liked quadratic in/out curves 6 | // more than springs. This is some kind of v2 for [SmoothLinear] and 7 | // [RoughLinear], with less magic hardcoded numbers, less bouncing by 8 | // default, but still smooth turns mid-transition. 9 | // 10 | // The zoom can still bounce if the target is suddenly changed, but 11 | // it's more stable in general. Like, it's purely quadratic in/out 12 | // if we don't change targets mid-transition and there are enough 13 | // updates per second for the simulation to be accurate. 14 | // 15 | // The implementation is tick-rate independent. 16 | type Quadratic struct { 17 | speed float64 18 | acceleration float64 // absolute value, always positive, configurable 19 | maxSpeed float64 // absolute value, always positive, configurable 20 | initialized bool 21 | } 22 | 23 | func (self *Quadratic) ensureInitialized() { 24 | if self.initialized { return } 25 | self.initialized = true 26 | if self.maxSpeed == 0.0 { 27 | self.maxSpeed = 5.0 28 | } 29 | if self.acceleration == 0.0 { 30 | self.acceleration = 3.66 31 | } 32 | } 33 | 34 | // The default is 3.66. Reasonable values range between [0.3, 16.0]. 35 | func (self *Quadratic) SetAcceleration(acceleration float64) { 36 | if acceleration < 0.01 { panic("acceleration can't be < 0.01") } 37 | self.acceleration = acceleration 38 | } 39 | 40 | // The default is 5.0. 41 | func (self *Quadratic) SetMaxSpeed(maxSpeed float64) { 42 | if maxSpeed < 0.5 { panic("maxSpeed can't be < 0.5") } 43 | self.maxSpeed = maxSpeed 44 | } 45 | 46 | // Implements [Zoomer]. 47 | func (self *Quadratic) Reset() { 48 | self.speed = 0.0 49 | } 50 | 51 | // Implements [Zoomer]. 52 | func (self *Quadratic) Update(currentZoom, targetZoom float64) float64 { 53 | if currentZoom == targetZoom { return 0.0 } 54 | self.ensureInitialized() 55 | 56 | // compute predicted distance if starting to decelerate now 57 | // (the calculation is fairly simple: we know that the speed 58 | // is speed = acceleration*t, then t = speed/acceleration, 59 | // and the integral of this is acceleration*(speed^2/2.0), 60 | // which gives us the distance) 61 | distance := (targetZoom - currentZoom) 62 | predicted := (self.speed*self.speed)/(2.0*internal.Abs(self.acceleration)) 63 | target := internal.Abs(distance) 64 | 65 | // update speed 66 | updateDelta := 1.0/float64(internal.GetUPS()) 67 | if predicted < target { 68 | if distance >= 0 { 69 | self.speed += self.acceleration*updateDelta 70 | self.speed = min(self.speed, +self.maxSpeed) 71 | } else { 72 | self.speed -= self.acceleration*updateDelta 73 | self.speed = max(self.speed, -self.maxSpeed) 74 | } 75 | } else { 76 | if distance >= 0 { 77 | self.speed -= self.acceleration*updateDelta 78 | self.speed = max(self.speed, 0.0) 79 | } else { 80 | self.speed += self.acceleration*updateDelta 81 | self.speed = min(self.speed, 0.0) 82 | } 83 | } 84 | 85 | // compute change and stabilize speed and acceleration if very near the target 86 | change := self.speed*updateDelta 87 | if internal.Abs(change) < 0.001 && internal.Abs(distance) < 0.001 { 88 | self.speed = 0.0 89 | return distance 90 | } 91 | return change 92 | } 93 | -------------------------------------------------------------------------------- /controller_shaker_channel.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "github.com/tinne26/mipix/shaker" 4 | 5 | type shakerChannel struct { 6 | shaker shaker.Shaker 7 | elapsed TicksDuration 8 | fadeIn TicksDuration 9 | duration TicksDuration 10 | fadeOut TicksDuration 11 | offsetX float64 12 | offsetY float64 13 | wasActive bool 14 | } 15 | 16 | func (self *shakerChannel) Trigger(fadeIn, duration, fadeOut TicksDuration) { 17 | self.Start(fadeIn) 18 | self.duration = duration 19 | self.fadeOut = fadeOut // TODO: maybe triggered shakes shouldn't stop pre-existing continuous shakes? 20 | } 21 | 22 | func (self *shakerChannel) Start(fadeIn TicksDuration) { 23 | if self.fadeIn == fadeIn && self.IsFadingIn() { return } 24 | activity := self.Activity() 25 | self.fadeIn = fadeIn 26 | self.duration = maxUint32 27 | self.fadeOut = 0 28 | self.elapsed = TicksDuration(float64(fadeIn)*activity) 29 | } 30 | 31 | // TODO: a couple EnsureShaking(fadeIn, ...channels) and EnsureNotShaking(fadeOut, ...channels). 32 | // Or just guarantee that Start and End are safe to use like that, code and document. 33 | func (self *shakerChannel) End(fadeOut TicksDuration) { 34 | // TODO: I don't like this code at all. going into negative durations, 35 | // modifying elapsed... it's all kinda messy. I would like some 36 | // solid invariants and stuff making else 37 | if self.fadeOut == fadeOut && self.IsFadingOut() { return } 38 | activity := self.Activity() 39 | self.duration = self.elapsed - self.fadeIn 40 | self.fadeOut = fadeOut 41 | self.elapsed = self.fadeIn + self.duration 42 | self.elapsed += TicksDuration(float64(fadeOut)*(1.0 - activity)) 43 | } 44 | 45 | func (self *shakerChannel) IsFadingIn() bool { 46 | return self.elapsed > 0 && self.elapsed <= self.fadeIn 47 | } 48 | 49 | func (self *shakerChannel) IsFadingOut() bool { 50 | toFadeOut := self.fadeIn + self.duration 51 | return self.elapsed >= toFadeOut && self.elapsed < toFadeOut + self.fadeOut 52 | } 53 | 54 | func (self *shakerChannel) IsShaking() bool { 55 | if self.elapsed == 0 { 56 | return self.fadeIn > 0 || self.duration > 0 57 | } else { 58 | if self.elapsed < self.duration { return true } 59 | return self.elapsed < (self.fadeIn + self.duration + self.fadeOut) 60 | } 61 | } 62 | 63 | func (self *shakerChannel) Update(index int, tickRate uint64) { 64 | var selfShaker shaker.Shaker = self.shaker 65 | if selfShaker == nil { 66 | if index != 0 { return } 67 | if defaultShaker == nil { 68 | defaultShaker = &shaker.Random{} 69 | } 70 | selfShaker = defaultShaker 71 | } 72 | 73 | if self.IsShaking() { 74 | self.wasActive = true 75 | activity := self.Activity() 76 | self.offsetX, self.offsetY = selfShaker.GetShakeOffsets(activity) 77 | self.elapsed += TicksDuration(tickRate) 78 | } else if self.wasActive { 79 | _, _ = selfShaker.GetShakeOffsets(0.0) // termination call 80 | if self.offsetX != 0.0 || self.offsetY != 0.0 { 81 | self.offsetX, self.offsetY = 0.0, 0.0 82 | } 83 | self.wasActive = false 84 | } 85 | } 86 | 87 | func (self *shakerChannel) Activity() float64 { 88 | if self.elapsed == 0 { return 0 } 89 | if self.elapsed < self.fadeIn { 90 | return float64(self.elapsed)/float64(self.fadeIn) 91 | } else { 92 | elapsed := self.elapsed - self.fadeIn 93 | if elapsed <= self.duration { return 1.0 } // shake in progress 94 | elapsed -= self.duration 95 | if elapsed >= self.fadeOut { return 0.0 } 96 | return 1.0 - float64(elapsed)/float64(self.fadeOut) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /shaker/random.go: -------------------------------------------------------------------------------- 1 | package shaker 2 | 3 | import "math/rand/v2" 4 | 5 | import "github.com/tinne26/mipix/internal" 6 | 7 | var _ Shaker = (*Random)(nil) 8 | 9 | // Very basic implementation of a [Shaker] using random values. 10 | // 11 | // This shaker is not particularly nice; good screen shakes 12 | // generally try to create a continuous movement, avoiding 13 | // sharp direction changes and so on. Random is cheap (still 14 | // classic and effective, though). 15 | // 16 | // The implementation is tick-rate independent. 17 | type Random struct { 18 | fromX, fromY float64 19 | toX, toY float64 20 | 21 | elapsed float64 22 | travelTime float64 23 | axisRatio float64 24 | zoomCompensated bool 25 | initialized bool 26 | } 27 | 28 | func (self *Random) ensureInitialized() { 29 | if !self.initialized { 30 | self.rollNewTarget() 31 | if self.axisRatio == 0.0 { 32 | self.axisRatio = 0.02 33 | } 34 | if self.travelTime == 0 { 35 | self.travelTime = 0.03 36 | } 37 | self.initialized = true 38 | } 39 | } 40 | 41 | 42 | // To preserve resolution independence, shakers often simulate the 43 | // shaking within a [-0.5, 0.5] space and only later scale it. For 44 | // example, if you have a resolution of 32x32 and set a motion 45 | // scale of 0.25, the shaking will range within [-4, +4] in both 46 | // axes. 47 | // 48 | // Defaults to 0.02. 49 | func (self *Random) SetMotionScale(axisScalingFactor float64) { 50 | if axisScalingFactor <= 0.0 { panic("axisScalingFactor must be strictly positive") } 51 | self.axisRatio = axisScalingFactor 52 | } 53 | 54 | // The range of motion of most shakers is based on the logical 55 | // resolution of the game. This means that when zooming in or 56 | // out, the shaking effect will become more or less pronounced, 57 | // respectively. If you want the shaking to maintain the same 58 | // relative magnitude regardless of zoom level, set zoom 59 | // compensated to true. 60 | func (self *Random) SetZoomCompensated(compensated bool) { 61 | self.zoomCompensated = compensated 62 | } 63 | 64 | // Change the travel time between generated shake points. Defaults to 0.1. 65 | func (self *Random) SetTravelTime(travelTime float64) { 66 | if travelTime <= 0 { panic("travel time must be strictly positive") } 67 | self.travelTime = travelTime 68 | } 69 | 70 | // Implements the [Shaker] interface. 71 | func (self *Random) GetShakeOffsets(level float64) (float64, float64) { 72 | self.ensureInitialized() 73 | if level == 0.0 { 74 | self.elapsed = 0.0 75 | self.rollNewTarget() 76 | self.fromX, self.fromY = 0.0, 0.0 77 | return 0.0, 0.0 78 | } 79 | 80 | t := self.elapsed/self.travelTime 81 | x := internal.QuadInOutInterp(self.fromX, self.toX, t) 82 | y := internal.QuadInOutInterp(self.fromY, self.toY, t) 83 | self.elapsed += 1.0/float64(internal.GetUPS()) 84 | if self.elapsed >= self.travelTime { 85 | self.rollNewTarget() 86 | for self.elapsed >= self.travelTime { 87 | self.elapsed -= self.travelTime 88 | } 89 | } 90 | 91 | w, h := internal.GetResolution() 92 | axisRange := float64(min(w, h))*self.axisRatio 93 | x, y = x*axisRange, y*axisRange 94 | if self.zoomCompensated { 95 | currentZoom := internal.GetCurrentZoom() 96 | x /= currentZoom 97 | y /= currentZoom 98 | } 99 | if level == 1.0 { return x, y } 100 | return internal.CubicSmoothstepInterp(0, x, level), internal.CubicSmoothstepInterp(0, y, level) 101 | } 102 | 103 | func (self *Random) rollNewTarget() { 104 | self.fromX, self.fromY = self.toX, self.toY 105 | self.toX = rand.Float64() - 0.5 106 | self.toY = rand.Float64() - 0.5 107 | } 108 | -------------------------------------------------------------------------------- /zoomer/constant.go: -------------------------------------------------------------------------------- 1 | package zoomer 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | var _ Zoomer = (*Constant)(nil) 6 | 7 | // A very simple zoomer that modifies the zoom at a constant 8 | // speed, which can be changed through [Constant.SetSpeed](). 9 | // 10 | // By default, the change is not purely linear, though, it's 11 | // multiplied by the current zoom level. This can be changed 12 | // through [Constant.SetZoomCompensated](false). The reason 13 | // for this is that going from x1.0 to x2.0 zoom doesn't result 14 | // in /2.0 the surface to draw, but /4.0. Therefore, to have 15 | // a perceptually linear change in zoom, using a linear speed 16 | // doesn't quite work, we also need to multiply the change by 17 | // the current zoom level. 18 | // 19 | // The implementation is update-rate independent. 20 | type Constant struct { 21 | speedTransitionIni float64 22 | speedTransitionEnd float64 23 | speedTransitionLength TicksDuration 24 | speedTransitionElapsed TicksDuration 25 | zoomCompensationDisabled bool 26 | } 27 | 28 | // The default speed is 1.0, reasonable values range between [0.5, 3.0]. 29 | // 30 | // The method also requires a second parameter indicating the duration of 31 | // the transition from the old speed to the new one, in ticks. 32 | func (self *Constant) SetSpeed(newSpeed float64, transition TicksDuration) { 33 | if newSpeed < 0 { newSpeed = -newSpeed } 34 | if transition == 0 { 35 | self.speedTransitionEnd = newSpeed - constantDefaultSpeedOffset 36 | } else { 37 | self.speedTransitionIni = self.getCurrentSpeed() - constantDefaultSpeedOffset 38 | self.speedTransitionEnd = newSpeed - constantDefaultSpeedOffset 39 | } 40 | self.speedTransitionLength = transition 41 | self.speedTransitionElapsed = 0 42 | } 43 | 44 | // With zoom compensation, the zoom looks perceptually linear. 45 | // Without zoom compensation, zooming in seems to progressively slow 46 | // down, and zooming out seems to progressively speed up. This is 47 | // explained in more detail on the documentation of [Constant] itself. 48 | // 49 | // Defaults to true. 50 | func (self *Constant) SetZoomCompensated(compensated bool) { 51 | self.zoomCompensationDisabled = !compensated 52 | } 53 | 54 | const constantDefaultSpeedOffset = 1.0 55 | 56 | func (self *Constant) Reset() { 57 | self.speedTransitionIni = 0.0 58 | self.speedTransitionEnd = 0.0 59 | self.speedTransitionLength = 0 60 | self.speedTransitionElapsed = 0 61 | } 62 | 63 | func (self *Constant) Update(currentZoom, targetZoom float64) float64 { 64 | speed := self.getAndAdvanceCurrentSpeed() 65 | if targetZoom == currentZoom { return 0.0 } 66 | updateSpeed := (speed + constantDefaultSpeedOffset)*(1.0/float64(internal.GetUPS())) 67 | if !self.zoomCompensationDisabled { updateSpeed *= currentZoom } 68 | if currentZoom < targetZoom { 69 | return min(updateSpeed, targetZoom - currentZoom) 70 | } else { 71 | return max(-updateSpeed, targetZoom - currentZoom) 72 | } 73 | } 74 | 75 | func (self *Constant) getCurrentSpeed() float64 { 76 | var speed float64 77 | if self.speedTransitionElapsed < self.speedTransitionLength { 78 | t := float64(self.speedTransitionElapsed)/float64(self.speedTransitionLength) 79 | speed = internal.LinearInterp(self.speedTransitionIni, self.speedTransitionEnd, t) 80 | } else { 81 | speed = self.speedTransitionEnd 82 | } 83 | return speed + constantDefaultSpeedOffset 84 | } 85 | 86 | func (self *Constant) getAndAdvanceCurrentSpeed() float64 { 87 | speed := self.getCurrentSpeed() 88 | if self.speedTransitionElapsed < self.speedTransitionLength { 89 | self.speedTransitionElapsed += TicksDuration(internal.GetTPU()) 90 | self.speedTransitionElapsed = min(self.speedTransitionElapsed, self.speedTransitionLength) 91 | } 92 | return speed 93 | } 94 | -------------------------------------------------------------------------------- /tracker/parametrized.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | var _ Tracker = (*Parametrized)(nil) 6 | 7 | // A configurable linear tracker. 8 | type Parametrized struct { 9 | // max speed basic parameters 10 | maxScreensPerSecond float64 11 | screensToMaxSpeed float64 12 | 13 | // quirky extra parameters 14 | minScreensPerSecond float64 // instant tracking below this 15 | screensToMinSpeed float64 // frozen tracking below this 16 | 17 | // initialization 18 | initialized bool 19 | } 20 | 21 | func (self *Parametrized) initialize() { 22 | self.initialized = true 23 | if self.maxScreensPerSecond == 0.0 { 24 | self.maxScreensPerSecond = 2.0 25 | } 26 | if self.screensToMaxSpeed == 0.0 { 27 | self.screensToMaxSpeed = 0.5 28 | } 29 | } 30 | 31 | // Sets the maximum speed of the tracking: 32 | // - maxScreensPerSecond is the maximum speed you want to allow the camera to move at. 33 | // It defaults to 2.0 screens per second. 34 | // - screensToMaxSpeed is the distance at which the maximum speed is reached. The 35 | // difference between the camera's target and the current position must be >= screensToMaxSpeed 36 | // for the camera to reach maximum speed. The default is 0.5. 37 | func (self *Parametrized) SetMaxSpeed(maxScreensPerSecond, screensToMaxSpeed float64) { 38 | if maxScreensPerSecond <= 0.0 { 39 | panic("maxScreensPerSecond must be > 0") 40 | } 41 | if screensToMaxSpeed <= 0.0 { 42 | panic("screensToMaxSpeed must be > 0") 43 | } 44 | 45 | self.maxScreensPerSecond = maxScreensPerSecond 46 | self.screensToMaxSpeed = screensToMaxSpeed 47 | } 48 | 49 | // If set, speeds below the given threshold will result in instantaneous tracking. 50 | func (self *Parametrized) SetInstantTrackingBelow(screensPerSecond float64) { 51 | if screensPerSecond < 0 { panic("'screensPerSecond' can't be a negative speed") } 52 | self.minScreensPerSecond = screensPerSecond 53 | } 54 | 55 | // If set, the tracking error (difference between camera's target and current positions) 56 | // must reach the given distance in screens before the tracker starts moving. 57 | func (self *Parametrized) SetFrozenTrackingBelow(screens float64) { 58 | if screens < 0 { panic("'screens' must be a non-negative distance") } 59 | self.screensToMinSpeed = screens 60 | } 61 | 62 | // Implements [Tracker]. 63 | func (self *Parametrized) Update(currentX, currentY, targetX, targetY, prevSpeedX, prevSpeedY float64) (float64, float64) { 64 | if !self.initialized { self.initialize() } 65 | 66 | w, h := internal.GetResolution() 67 | widthF64, heightF64 := float64(w), float64(h) 68 | 69 | updateDelta := 1.0/float64(internal.GetUPS()) 70 | xAdvance := self.updateComponent(currentX, targetX, widthF64 , updateDelta) 71 | yAdvance := self.updateComponent(currentY, targetY, heightF64, updateDelta) 72 | return xAdvance, yAdvance 73 | } 74 | 75 | func (self *Parametrized) updateComponent(current, target, screen, updateDelta float64) float64 { 76 | distance := target - current 77 | zoomedScreen := screen/internal.GetCurrentZoom() 78 | 79 | // frozen tracking 80 | frozenDistance := self.screensToMinSpeed*zoomedScreen 81 | if distance >= 0 { 82 | if distance <= frozenDistance { return 0 } 83 | distance -= frozenDistance 84 | } else { // distance < 0 85 | if -distance <= frozenDistance { return 0 } 86 | distance += frozenDistance 87 | } 88 | 89 | // compute speed 90 | t := internal.TAt(internal.Abs(distance)*internal.GetCurrentZoom(), 0, self.screensToMaxSpeed*zoomedScreen) 91 | normSpeed := internal.LinearInterp(self.minScreensPerSecond, self.maxScreensPerSecond, t) 92 | change := normSpeed*zoomedScreen*updateDelta 93 | 94 | // clamp 95 | if distance >= 0 { 96 | return min(distance, change) 97 | } else { // distance < 0 98 | return max(distance, -change) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /shaker/spring.go: -------------------------------------------------------------------------------- 1 | package shaker 2 | 3 | import "math/rand/v2" 4 | 5 | import "github.com/tinne26/mipix/internal" 6 | 7 | var _ Shaker = (*Spring)(nil) 8 | 9 | // A [Shaker] implementation based on spring simulations. 10 | // It's nothing special, but it has its own flavor. Common 11 | // configurations remind me of boxes falling from the closet, 12 | // or driving through a bad road (e.g., params {0.1, 40.0} 13 | // and motion scale {0.02, 0.01}). 14 | // 15 | // The implementation is tick-rate independent. 16 | type Spring struct { 17 | spring internal.Spring 18 | x, y float64 19 | xSpeed, ySpeed float64 20 | xTarget, yTarget float64 21 | 22 | xRatio, yRatio float64 23 | zoomCompensation float64 24 | initialized bool 25 | } 26 | 27 | func (self *Spring) ensureInitialized() { 28 | if self.initialized { return } 29 | self.initialized = true 30 | if self.xRatio == 0.0 { self.xRatio = 0.02 } 31 | if self.yRatio == 0.0 { self.yRatio = 0.02 } 32 | if !self.spring.IsInitialized() { 33 | self.spring.SetParameters(0.25, 80.0) 34 | } 35 | } 36 | 37 | // To preserve resolution independence, shakers often simulate the 38 | // shaking within a [-0.5, 0.5] space and only later scale it. For 39 | // example, if you have a resolution of 32x32 and set a motion 40 | // scale of (0.25, 0.25), the shaking will range within [-4, +4] 41 | // in both axes. 42 | // 43 | // Defaults to 0.02. 44 | func (self *Spring) SetMotionScale(xScalingFactor, yScalingFactor float64) { 45 | if xScalingFactor <= 0.0 && yScalingFactor <= 0.0 { 46 | panic("xScalingFactor and yScalingFactor can't be both <= 0.0") 47 | } 48 | self.xRatio = xScalingFactor 49 | self.yRatio = yScalingFactor 50 | } 51 | 52 | // The range of motion of most shakers is based on the logical 53 | // resolution of the game. This means that when zooming in or 54 | // out, the shaking effect will become more or less pronounced, 55 | // respectively. If you want the shaking to maintain the same 56 | // relative magnitude regardless of zoom level, change the zoom 57 | // compensation from 0 (the default) to 1. 58 | func (self *Spring) SetZoomCompensation(compensation float64) { 59 | if compensation < 0 || compensation > 1.0 { 60 | panic("zoom compensation factor must be in [0, 1]") 61 | } 62 | self.zoomCompensation = compensation 63 | } 64 | 65 | // Sets the internal spring simulation parameters. 66 | // Defaults are (0.25, 80.0), but it depends a lot on 67 | // the motion scaling too. 68 | func (self *Spring) SetParameters(damping, power float64) { 69 | if damping < 0.0 || damping > 1.0 { 70 | panic("damping must be in [0, 1] range") 71 | } 72 | if power <= 0.0 { 73 | panic("power must be strictly positive") 74 | } 75 | self.spring.SetParameters(damping, power) 76 | } 77 | 78 | // Implements the [Shaker] interface. 79 | func (self *Spring) GetShakeOffsets(level float64) (float64, float64) { 80 | self.ensureInitialized() 81 | if level == 0.0 { 82 | self.x, self.y = 0.0, 0.0 83 | self.xSpeed, self.ySpeed = 0.0, 0.0 84 | self.rerollTarget() 85 | return 0.0, 0.0 86 | } 87 | 88 | // bézier conic curve interpolation 89 | self.x, self.xSpeed = self.spring.Update(self.x, self.xTarget, self.xSpeed) 90 | self.y, self.ySpeed = self.spring.Update(self.y, self.yTarget, self.ySpeed) 91 | if internal.Abs(self.xTarget - self.x) < 0.08 && internal.Abs(self.yTarget - self.y) < 0.08 { 92 | self.rerollTarget() 93 | } 94 | 95 | // translate interpolated point to real screen distances 96 | w, h := internal.GetResolution() 97 | w64, h64 := float64(w), float64(h) 98 | zoom := internal.GetCurrentZoom() 99 | xOffset, yOffset := self.x*w64*self.xRatio, self.y*h64*self.yRatio 100 | if self.zoomCompensation != 0.0 { 101 | compensatedZoom := 1.0 + (zoom - 1.0)*self.zoomCompensation 102 | xOffset /= compensatedZoom 103 | yOffset /= compensatedZoom 104 | } 105 | if level != 1.0 { 106 | xOffset *= level 107 | yOffset *= level 108 | } 109 | 110 | return xOffset, yOffset 111 | } 112 | 113 | func (self *Spring) rerollTarget() { 114 | self.xTarget, self.yTarget = rand.Float64() - 0.5, rand.Float64() - 0.5 115 | } 116 | -------------------------------------------------------------------------------- /controller_project.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "math" 4 | 5 | import "github.com/hajimehoshi/ebiten/v2" 6 | 7 | // project from a logical canvas to a high resolution one 8 | func (self *controller) project(from, to *ebiten.Image) { 9 | if !self.inDraw { panic("can't project images outside draw stage") } 10 | 11 | // compile shader if necessary 12 | if self.shaders[self.scalingFilter] == nil { 13 | self.compileShader(self.scalingFilter) 14 | } 15 | 16 | // set up vertices 17 | dstBounds := to.Bounds() 18 | self.shaderVertices[0].DstX = float32(dstBounds.Min.X) 19 | self.shaderVertices[0].DstY = float32(dstBounds.Min.Y) 20 | self.shaderVertices[1].DstX = float32(dstBounds.Max.X) 21 | self.shaderVertices[1].DstY = self.shaderVertices[0].DstY 22 | self.shaderVertices[2].DstX = self.shaderVertices[1].DstX 23 | self.shaderVertices[2].DstY = float32(dstBounds.Max.Y) 24 | self.shaderVertices[3].DstX = self.shaderVertices[0].DstX 25 | self.shaderVertices[3].DstY = self.shaderVertices[2].DstY 26 | 27 | srcBounds := from.Bounds() 28 | self.shaderVertices[0].SrcX = float32(srcBounds.Min.X) 29 | self.shaderVertices[0].SrcY = float32(srcBounds.Min.Y) 30 | self.shaderVertices[1].SrcX = float32(srcBounds.Max.X) 31 | self.shaderVertices[1].SrcY = self.shaderVertices[0].SrcY 32 | self.shaderVertices[2].SrcX = self.shaderVertices[1].SrcX 33 | self.shaderVertices[2].SrcY = float32(srcBounds.Max.Y) 34 | self.shaderVertices[3].SrcX = self.shaderVertices[0].SrcX 35 | self.shaderVertices[3].SrcY = self.shaderVertices[2].SrcY 36 | 37 | self.shaderOpts.Images[0] = from 38 | self.shaderOpts.Uniforms["SourceRelativeTextureUnitX"] = float32(srcBounds.Dx())/float32(dstBounds.Dx()) 39 | self.shaderOpts.Uniforms["SourceRelativeTextureUnitY"] = float32(srcBounds.Dy())/float32(dstBounds.Dy()) 40 | to.DrawTrianglesShader( 41 | self.shaderVertices, self.shaderVertIndices, 42 | self.shaders[self.scalingFilter], &self.shaderOpts, 43 | ) 44 | self.shaderOpts.Images[0] = nil 45 | } 46 | 47 | func (self *controller) projectLogical(from, to *ebiten.Image) { 48 | if !self.inDraw { panic("can't project images outside draw stage") } 49 | 50 | // compile shader if necessary 51 | if self.shaders[self.scalingFilter] == nil { 52 | self.compileShader(self.scalingFilter) 53 | } 54 | 55 | // set up vertices 56 | dstBounds := to.Bounds() 57 | self.shaderVertices[0].DstX = float32(dstBounds.Min.X) 58 | self.shaderVertices[0].DstY = float32(dstBounds.Min.Y) 59 | self.shaderVertices[1].DstX = float32(dstBounds.Max.X) 60 | self.shaderVertices[1].DstY = self.shaderVertices[0].DstY 61 | self.shaderVertices[2].DstX = self.shaderVertices[1].DstX 62 | self.shaderVertices[2].DstY = float32(dstBounds.Max.Y) 63 | self.shaderVertices[3].DstX = self.shaderVertices[0].DstX 64 | self.shaderVertices[3].DstY = self.shaderVertices[2].DstY 65 | 66 | cminX, cminY, cmaxX, cmaxY := self.cameraAreaF64() 67 | fractCamMinX := cminX - math.Floor(cminX) 68 | fractCamMinY := cminY - math.Floor(cminY) 69 | fractCamMaxX := cmaxX - math.Floor(cmaxX) 70 | fractCamMaxY := cmaxY - math.Floor(cmaxY) 71 | if fractCamMaxX != 0.0 { fractCamMaxX = 1.0 - fractCamMaxX } 72 | if fractCamMaxY != 0.0 { fractCamMaxY = 1.0 - fractCamMaxY } 73 | 74 | srcBounds := from.Bounds() 75 | self.shaderVertices[0].SrcX = float32(float64(srcBounds.Min.X) + fractCamMinX) 76 | self.shaderVertices[0].SrcY = float32(float64(srcBounds.Min.Y) + fractCamMinY) 77 | self.shaderVertices[1].SrcX = float32(float64(srcBounds.Max.X) - fractCamMaxX) 78 | self.shaderVertices[1].SrcY = self.shaderVertices[0].SrcY 79 | self.shaderVertices[2].SrcX = self.shaderVertices[1].SrcX 80 | self.shaderVertices[2].SrcY = float32(float64(srcBounds.Max.Y) - fractCamMaxY) 81 | self.shaderVertices[3].SrcX = self.shaderVertices[0].SrcX 82 | self.shaderVertices[3].SrcY = self.shaderVertices[2].SrcY 83 | 84 | self.shaderOpts.Images[0] = from 85 | self.shaderOpts.Uniforms["SourceRelativeTextureUnitX"] = float32(srcBounds.Dx())/float32(dstBounds.Dx()) 86 | self.shaderOpts.Uniforms["SourceRelativeTextureUnitY"] = float32(srcBounds.Dy())/float32(dstBounds.Dy()) 87 | to.DrawTrianglesShader( 88 | self.shaderVertices, self.shaderVertIndices, 89 | self.shaders[self.scalingFilter], &self.shaderOpts, 90 | ) 91 | self.shaderOpts.Images[0] = nil 92 | } 93 | -------------------------------------------------------------------------------- /zoomer/spring.go: -------------------------------------------------------------------------------- 1 | package zoomer 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | var _ Zoomer = (*Spring)(nil) 6 | 7 | // Springy zoom. By default, it barely overshoots, but 8 | // you can set it to be more or less bouncy if you want. 9 | // 10 | // The implementation is tick-rate independent. 11 | type Spring struct { 12 | spring internal.Spring 13 | speed float64 14 | maxTargetDistance float64 15 | initialized bool 16 | zoomCompensation float64 17 | } 18 | 19 | func (self *Spring) ensureInitialized() { 20 | if self.initialized { return } 21 | self.spring.SetParameters(0.85, 2.5) 22 | self.initialized = true 23 | } 24 | 25 | // Damping values must be in [0.0, 1.0] range. 26 | // Power depends on damping, but must be strictly positive. 27 | // Defaults are (0.85, 2.5). 28 | func (self *Spring) SetParameters(damping, power float64) { 29 | if damping < 0.0 || damping > 1.0 { 30 | panic("damping must be in [0, 1] range") 31 | } 32 | if power <= 0.0 { 33 | panic("power must be strictly positive") 34 | } 35 | self.spring.SetParameters(damping, power) 36 | self.initialized = true 37 | } 38 | 39 | // See [Constant.SetZoomCompensated]() for context. Compensating 40 | // zooms with the spring zoomer will lead to overshoot on zoom ins 41 | // and undershoot on zoom outs for normal to high power values. 42 | // 43 | // Parameters will also have to be adjusted, as the results get 44 | // very different. I recommend starting at (0.87, 1.6) if you 45 | // set zoom compensation to 1. I also like (0.8, 1.5) at 0.6. 46 | // 47 | // The compensation is also a bit more sophisticated than on 48 | // [Constant], so if you really expect specific results, just 49 | // dive directly into the code. 50 | // 51 | // Defaults to 0. 52 | func (self *Spring) SetZoomCompensation(compensation float64) { 53 | if compensation < 0 || compensation > 1.0 { 54 | panic("zoom compensation must be in [0, 1] range") 55 | } 56 | self.zoomCompensation = compensation 57 | } 58 | 59 | // Can help tame maximum speeds if desired. Setting it to 0.0 60 | // disables the maximum target distance. 61 | func (self *Spring) SetMaxTargetDistance(maxDistance float64) { 62 | if maxDistance < 0.0 { panic("max target distance must be >= 0.0") } 63 | self.maxTargetDistance = maxDistance 64 | } 65 | 66 | // Implements [Zoomer]. 67 | func (self *Spring) Reset() { 68 | self.speed = 0.0 69 | } 70 | 71 | // Implements [Zoomer]. 72 | func (self *Spring) Update(currentZoom, targetZoom float64) float64 { 73 | if currentZoom == targetZoom && self.speed == 0.0 { return 0.0 } 74 | 75 | self.ensureInitialized() 76 | targetZoom = self.limitTargetDistance(currentZoom, targetZoom) 77 | newPosition, newSpeed := self.spring.Update(currentZoom, targetZoom, self.speed) 78 | 79 | // clean up case, don't keep oscillating on super small 80 | // changes, it interferes with efficient GPU usage 81 | if internal.Abs(targetZoom - newPosition) < 0.001 && internal.Abs(newSpeed) < (1.0/float64(internal.GetUPS())) { 82 | self.speed = 0.0 83 | return targetZoom - currentZoom 84 | } 85 | 86 | self.speed = newSpeed 87 | change := (newPosition - currentZoom) 88 | 89 | // zoom compensation is not done directly, but in a smoothed way. 90 | // as we get close to the target, the compensation is also relaxed 91 | if self.zoomCompensation > 0 { 92 | const Threshold = 0.333 93 | dist := internal.Abs(targetZoom - currentZoom) 94 | currentZoom = 1.0 + (currentZoom - 1.0)*self.zoomCompensation // * 95 | // * I personally like this softening to not make the spring 96 | // so lifeless, but this could totally be customized. 97 | if dist <= Threshold { 98 | t := internal.EaseInQuad(dist*(1.0/Threshold)) 99 | change *= internal.LinearInterp(1.0, currentZoom, t) 100 | } else { 101 | change *= currentZoom 102 | } 103 | } 104 | 105 | return change 106 | } 107 | 108 | func (self *Spring) limitTargetDistance(currentZoom, targetZoom float64) float64 { 109 | if self.maxTargetDistance <= 0 { return targetZoom } 110 | distance := (targetZoom - currentZoom) 111 | if internal.Abs(distance) <= self.maxTargetDistance { return targetZoom } 112 | switch { 113 | case distance > 0: targetZoom = currentZoom + self.maxTargetDistance 114 | case distance < 0: targetZoom = currentZoom - self.maxTargetDistance 115 | } 116 | return targetZoom 117 | } 118 | -------------------------------------------------------------------------------- /tracker/corrector.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | type corrector struct { 6 | speedX, speedY float64 // logical, relative to resolution and zoom 7 | acceleration float64 8 | initialized bool 9 | } 10 | 11 | func (self *corrector) initialize() { 12 | self.initialized = true 13 | self.acceleration = 0.2 14 | } 15 | 16 | // Reasonable values are typically in the [0.05, 0.5] range. 17 | func (self *corrector) SetAcceleration(acceleration float64) { 18 | if acceleration < 0.0 { panic("acceleration can't be < 0.0") } 19 | self.initialized = true 20 | self.acceleration = acceleration 21 | } 22 | 23 | func (self *corrector) Update(errorX, errorY float64) { 24 | if !self.initialized { self.initialize() } 25 | 26 | w, h := internal.GetResolution() 27 | w64, h64 := float64(w), float64(h) 28 | //zoom := internal.GetCurrentZoom() 29 | errorX /= w64 30 | errorY /= h64 31 | 32 | predictedX := (self.speedX*self.speedX)/(2.0*self.acceleration) 33 | predictedY := (self.speedY*self.speedY)/(2.0*self.acceleration) 34 | targetX := internal.Abs(errorX) 35 | targetY := internal.Abs(errorY) 36 | 37 | updateDelta := 1.0/float64(internal.GetUPS()) 38 | speedChange := self.acceleration*updateDelta 39 | margin := (speedChange*speedChange)/(2.0*self.acceleration) 40 | 41 | // update speeds 42 | // (NOTICE: the code could be greatly shortened, but 43 | // I'm happy with it being long, simple and obvious 44 | // in this particular case) 45 | if errorX < 0.0 { // wanna go left 46 | if self.speedX > 0.0 { 47 | self.speedX -= speedChange // decelerate to turn around 48 | } else if predictedX < targetX - margin { 49 | self.speedX -= speedChange // accelerate 50 | } else if predictedX > targetX { 51 | self.speedX += speedChange // decelerate 52 | } else if -self.speedX <= speedChange + 0.0005 { 53 | self.speedX = 0.0 // stabilization 54 | } 55 | } else if errorX > 0.0 { // wanna go right 56 | if self.speedX < 0.0 { 57 | self.speedX += speedChange // decelerate to turn around 58 | } else if predictedX < targetX - margin { 59 | self.speedX += speedChange // accelerate 60 | } else if predictedX > targetX { 61 | self.speedX -= speedChange // decelerate 62 | } else if self.speedX <= speedChange + 0.0005 { 63 | self.speedX = 0.0 // stabilization 64 | } 65 | } else { // errorX == 0.0 66 | if self.speedX > 0.0 && predictedX >= margin { 67 | self.speedX -= speedChange 68 | } else if self.speedX < 0.0 && predictedX >= margin { 69 | self.speedX += speedChange 70 | } else { 71 | self.speedX = 0.0 72 | } 73 | } 74 | 75 | if errorY < 0.0 { // wanna go up 76 | if self.speedY > 0.0 { 77 | self.speedY -= speedChange // decelerate to turn around 78 | } else if predictedY < targetY - margin { 79 | self.speedY -= speedChange // accelerate 80 | } else if predictedY > targetY { 81 | self.speedY += speedChange // decelerate 82 | } else if -self.speedY <= speedChange + 0.0005 { 83 | self.speedY = 0.0 // stabilization 84 | } 85 | } else if errorY > 0.0 { // wanna go down 86 | if self.speedY < 0.0 { 87 | self.speedY += speedChange // decelerate to turn around 88 | } else if predictedY < targetY - margin { 89 | self.speedY += speedChange // accelerate 90 | } else if predictedY > targetY { 91 | self.speedY -= speedChange // decelerate 92 | } else if self.speedY <= speedChange + 0.0005 { 93 | self.speedY = 0.0 // stabilization 94 | } 95 | } else { // errorY == 0.0 96 | if self.speedY > 0.0 && predictedY >= margin { 97 | self.speedY -= speedChange 98 | } else if self.speedY < 0.0 && predictedY >= margin { 99 | self.speedY += speedChange 100 | } else { 101 | self.speedY = 0.0 102 | } 103 | } 104 | } 105 | 106 | func (self *corrector) Decelerate() { 107 | updateDelta := 1.0/float64(internal.GetUPS()) 108 | speedChange := self.acceleration*updateDelta 109 | 110 | if self.speedX != 0.0 { 111 | if self.speedX > 0.0 { 112 | self.speedX = max(0.0, self.speedX - speedChange) 113 | } else { 114 | self.speedX = min(0.0, self.speedX + speedChange) 115 | } 116 | } 117 | if self.speedY != 0.0 { 118 | if self.speedY > 0.0 { 119 | self.speedY = max(0.0, self.speedY - speedChange) 120 | } else { 121 | self.speedY = min(0.0, self.speedY + speedChange) 122 | } 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /shaker/bezier.go: -------------------------------------------------------------------------------- 1 | package shaker 2 | 3 | import "github.com/tinne26/mipix/internal" 4 | 5 | var _ Shaker = (*Bezier)(nil) 6 | 7 | // Implementation of a [Shaker] using bézier curves in 8 | // strange ways. 9 | // 10 | // This shaker has a fair share of personality. I would 11 | // say it's quite biased and unpleasant, like someone 12 | // throwing a tantrum. 13 | // 14 | // The implementation is tick-rate independent. 15 | type Bezier struct { 16 | ax, ay float64 17 | bx, by float64 18 | ctrlx, ctrly float64 19 | 20 | elapsed float64 21 | travelTime float64 22 | axisRatio float64 23 | zoomCompensation float64 24 | initialized bool 25 | } 26 | 27 | // To preserve resolution independence, shakers often simulate the 28 | // shaking within a [-0.5, 0.5] space and only later scale it. For 29 | // example, if you have a resolution of 32x32 and set a motion 30 | // scale of 0.25, the shaking will range within [-4, +4] in both 31 | // axes. 32 | // 33 | // Defaults to 0.05. 34 | func (self *Bezier) SetMotionScale(axisScalingFactor float64) { 35 | if axisScalingFactor <= 0.0 { panic("axisScalingFactor must be strictly positive") } 36 | self.axisRatio = axisScalingFactor 37 | } 38 | 39 | // The range of motion of most shakers is based on the logical 40 | // resolution of the game. This means that when zooming in or 41 | // out, the shaking effect will become more or less pronounced, 42 | // respectively. If you want the shaking to maintain the same 43 | // relative magnitude regardless of zoom level, change the zoom 44 | // compensation from 0 (the default) to 1. 45 | func (self *Bezier) SetZoomCompensation(compensation float64) { 46 | if compensation < 0 || compensation > 1.0 { 47 | panic("zoom compensation factor must be in [0, 1]") 48 | } 49 | self.zoomCompensation = compensation 50 | } 51 | 52 | // Change the travel time between generated shake points. Defaults to 0.1. 53 | func (self *Bezier) SetTravelTime(travelTime float64) { 54 | if travelTime <= 0 { panic("travel time must be strictly positive") } 55 | self.travelTime = travelTime 56 | } 57 | 58 | // Implements the [Shaker] interface. 59 | func (self *Bezier) GetShakeOffsets(level float64) (float64, float64) { 60 | self.ensureInitialized() 61 | if level == 0.0 { 62 | self.elapsed = 0.0 63 | self.rerollOriginPoints() 64 | return 0.0, 0.0 65 | } 66 | 67 | // bézier conic curve interpolation 68 | t := self.elapsed/self.travelTime 69 | lerp := func(x1, y1, x2, y2, t float64) (float64, float64) { 70 | return internal.LinearInterp(x1, x2, t), internal.LinearInterp(y1, y2, t) 71 | } 72 | ocx, ocy := lerp(self.ax, self.ay, self.ctrlx, self.ctrly, t) // origin to control 73 | cfx, cfy := lerp(self.ctrlx, self.ctrly, self.bx, self.by, t) // control to end 74 | ix , iy := lerp(ocx, ocy, cfx, cfy, t) // interpolated point 75 | 76 | // roll new point, slide previous 77 | self.elapsed += 1.0/float64(internal.GetUPS()) 78 | if self.elapsed >= self.travelTime { 79 | self.ax, self.ay = self.bx, self.by 80 | self.ctrlx, self.ctrly = self.rollNewPoint() 81 | self.bx, self.by = self.rollNewPoint() 82 | for self.elapsed >= self.travelTime { 83 | self.elapsed -= self.travelTime 84 | } 85 | } 86 | 87 | // translate interpolated point to real screen distances 88 | w, h := internal.GetResolution() 89 | w64, h64 := float64(w), float64(h) 90 | zoom := internal.GetCurrentZoom() 91 | xOffset, yOffset := ix*w64*self.axisRatio, iy*h64*self.axisRatio 92 | if self.zoomCompensation != 0.0 { 93 | compensatedZoom := 1.0 + (zoom - 1.0)*self.zoomCompensation 94 | xOffset /= compensatedZoom 95 | yOffset /= compensatedZoom 96 | } 97 | if level != 1.0 { 98 | xOffset *= level 99 | yOffset *= level 100 | } 101 | 102 | return xOffset, yOffset 103 | } 104 | 105 | func (self *Bezier) ensureInitialized() { 106 | if self.initialized { return } 107 | self.initialized = true 108 | if self.axisRatio == 0.0 { 109 | self.axisRatio = 0.05 110 | } 111 | if self.travelTime == 0.0 { 112 | self.travelTime = 0.1 113 | } 114 | self.rerollOriginPoints() 115 | } 116 | 117 | func (self *Bezier) rerollOriginPoints() { 118 | self.ax, self.ay = 0.0, 0.0 119 | self.bx, self.by = self.rollNewPoint() 120 | self.ctrlx, self.ctrly = self.rollNewPoint() 121 | } 122 | 123 | func (self *Bezier) rollNewPoint() (float64, float64) { 124 | return internal.RollPointWithinEllipse(1.0, 1.0) 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mipix 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/tinne26/mipix.svg)](https://pkg.go.dev/github.com/tinne26/mipix) 4 | 5 | A package to assist the development of Ebitengine pixel art games. 6 | 7 | This package allows you to implement your game working with logical canvases and ignoring `Game.Layout()` completely. Scaling is managed internally with pixel art aware scaling algorithms, and support for camera movement, zoom and screen shakes are also available through the API. 8 | 9 | ## Features 10 | 11 | - Draw pixel art logically without having to deal with device scale factors, DPI, scaling and projections yourself. 12 | - Basic camera with smooth tracking, zoom and screen shakes. Most behaviors can be customized through interfaces. 13 | - Interleave high resolution and logical draws when necessary. 14 | 15 | ## How to use mipix 16 | 17 | The best way to learn mipix is to read the [introductory tutorial with code samples](https://github.com/tinne26/mipix/tree/main/docs/introduction.md) and the [package documentation](https://pkg.go.dev/github.com/tinne26/mipix). More code samples are available on [tinne26/mipix-examples](https://github.com/tinne26/mipix-examples), but you should really spend a couple minutes to understand how the draw model works on mipix first. 18 | 19 | If you are too lazy for that, at least read the summary: 20 | - The canvas you receive on `Draw()` represents pixels 1-to-1. Your pixel art must be drawn directly to the canvas, in its original size. Just draw your pixel art. 21 | - The canvas you receive on `Draw()` does *not* always have the same size, and it does *not* necessarily match the resolution you set for your game with `mipix.SetResolution(W, H)`. This can happen due to zoom effects, smooth camera movement and others. Just focus on rendering the logical area requested by `mipix.Camera().Area()`. 22 | 23 | ## Context 24 | 25 | This package implements the second model described on [lopix](https://github.com/tinne26/lopix). If `lopix` implements the simplest model for pixel art games, `mipix` is slightly more advanced and provides a much more practical foundation to build pixel art games: 26 | - You are expected to place most of your game world elements at integer coordinates. 27 | - Your draw method receives a logically sized offscreen corresponding to a specific area of your game world. This area can vary based on the current camera position, zoom and shake, but you are simply given the task of *rendering a logical area of your game world*, directly and in pure pixels, to a logically sized canvas. 28 | - For UI and other camera-independent parts of your game, you can create `mipix.Offscreen`s manually, again with pure pixel based sizes, render to them and then use the built-in `Offscreen.Project()` method to deal with scaling and stretching and filtering and all that nonsense. 29 | 30 | While side scrollers can be implemented with this model, that's probably not ideal. In most side-scrollers, developers use floating point positions for characters, which are smoothly "sliding through the floor" as animations change[^1]. Doing this requires drawing the characters to a high resolution canvas. The API offers [basic support for this](https://pkg.go.dev/github.com/tinne26/mipix#AccessorHiRes.Draw), but it's not the main focus of the package. If you have *many* fractionally positioned characters and elements, the `mipix` model might not be the best match for your game. 31 | 32 | [^1]: As opposed to this "sliding animation" model, pixel artists can design their animations to look good as they advance through the concrete pixel grid, with concrete pixel advances between frames. This isn't supported by most animation libraries, takes more work for the artist, takes more work for the developer, and makes animation usage more rigid (e.g., need separate "walking on stairs" animation)... but with this model, you can draw moving characters in the logical canvas. This is more common on RPGs and top down games than side scrollers, but it's fairly uncommon everywhere in general. Smoothness perception throughout movement is also of a different class. 33 | 34 | ## Random thoughts and comments 35 | 36 | - Need to add camera area limits, blockers and so on. 37 | - Motion blur should be considered for the high resolution draws and maybe even the main canvas. While most people say they hate motion blur, that mostly refers to motion blur as an aesthetic effect. Light amounts of motion blur could greatly reduce image stability problems during movement. 38 | - The pixel art sampling shaders do cause a visible amount of "fattening" for the pixels. 39 | -------------------------------------------------------------------------------- /internal/utils.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "cmp" 4 | import "image" 5 | import "image/color" 6 | 7 | import "github.com/hajimehoshi/ebiten/v2" 8 | 9 | func Clamp[T cmp.Ordered](x, a, b T) T { 10 | if x <= a { return a } 11 | if x >= b { return b } 12 | return x 13 | } 14 | 15 | func ClampTowardsZero[T float64 | float32 | int | int16 | int32 | int64](x, clampReference T) T { 16 | if clampReference > 0 { return min(x, clampReference) } 17 | return max(x, clampReference) 18 | } 19 | 20 | func Abs[T float64 | float32 | int | int8 | int16 | int32 | int64](x T) T { 21 | if x >= 0 { return x } 22 | return -x 23 | } 24 | 25 | // It doesn't take zero into account, but this is intentional. 26 | func Sign[T float64 | float32 | int | int8 | int16 | int32 | int64](x T) T { 27 | if x >= 0 { return +1 } 28 | return -1 29 | } 30 | 31 | // --- color --- 32 | 33 | func toRGBAf32(clr color.Color) (r, g, b, a float32) { 34 | r16, g16, b16, a16 := clr.RGBA() 35 | return float32(r16)/65535.0, float32(g16)/65535.0, float32(b16)/65535.0, float32(a16)/65535.0 36 | } 37 | 38 | // --- interpolation --- 39 | 40 | func TAt(x, a, b float64) float64 { 41 | if a < b { 42 | if x < a { return 0.0 } 43 | if x >= b { return 1.0 } 44 | } else { 45 | if x < b { return 1.0 } 46 | if x >= a { return 0.0 } 47 | } 48 | return (x - a)/(b - a) 49 | } 50 | 51 | func LinearInterp(a, b, t float64) float64 { return (a + (b - a)*t) } 52 | func CubicSmoothstepInterp(a, b, t float64) float64 { // related: https://iquilezles.org/articles/smoothsteps 53 | t = Clamp(t, 0, 1) 54 | return LinearInterp(a, b, t*t*(3.0 - 2.0*t)) 55 | } 56 | func QuadInOutInterp(a, b, t float64) float64 { 57 | return LinearInterp(a, b, QuadInOut(t)) 58 | } 59 | func QuadInOut(t float64) float64 { 60 | t = Clamp(t, 0, 1) 61 | if t < 0.5 { return 2*t*t } 62 | t = 2*t - 1 63 | return -0.5*(t*(t - 2) - 1) 64 | } 65 | 66 | func QuadDvInOut(t float64) float64 { 67 | t = Clamp(t, 0, 1) 68 | if t <= 0.5 { return 4*t } 69 | return 4 - 4*t 70 | } 71 | 72 | func EaseInQuad(t float64) float64 { 73 | t = Clamp(t, 0, 1) 74 | return t*t 75 | } 76 | 77 | func CubicOutInterp(a, b, t float64) float64 { 78 | return LinearInterp(a, b, EaseOutCubic(t)) 79 | } 80 | func EaseOutCubic(t float64) float64 { 81 | t = Clamp(t, 0, 1) 82 | omt := 1 - t 83 | return 1 - omt*omt*omt 84 | } 85 | 86 | func EaseOutQuad(t float64) float64 { 87 | t = Clamp(t, 0, 1) 88 | omt := 1 - t 89 | return 1 - omt*omt 90 | } 91 | 92 | // --- triangles drawing --- 93 | 94 | var pkgMask1x1 *ebiten.Image 95 | var pkgFillVertices []ebiten.Vertex 96 | var pkgFillVertIndices []uint16 97 | var pkgFillTrianglesOpts ebiten.DrawTrianglesOptions 98 | 99 | func init() { 100 | pkgMask1x1 = ebiten.NewImage(1, 1) 101 | pkgMask1x1.Fill(color.RGBA{255, 255, 255, 255}) 102 | pkgFillVertices = make([]ebiten.Vertex, 4) 103 | pkgFillVertIndices = []uint16{0, 1, 3, 3, 1, 2} 104 | for i := range 4 { 105 | pkgFillVertices[i].SrcX = 0.5 106 | pkgFillVertices[i].SrcY = 0.5 107 | } 108 | } 109 | 110 | func FillOver(target *ebiten.Image, fillColor color.Color) { 111 | FillOverRect(target, target.Bounds(), fillColor) 112 | } 113 | 114 | func FillOverRect(target *ebiten.Image, bounds image.Rectangle, fillColor color.Color) { 115 | if bounds.Empty() { return } 116 | r, g, b, a := toRGBAf32(fillColor) 117 | for i := range 4 { 118 | pkgFillVertices[i].ColorR = r 119 | pkgFillVertices[i].ColorG = g 120 | pkgFillVertices[i].ColorB = b 121 | pkgFillVertices[i].ColorA = a 122 | } 123 | 124 | minX, minY := float32(bounds.Min.X), float32(bounds.Min.Y) 125 | maxX, maxY := float32(bounds.Max.X), float32(bounds.Max.Y) 126 | pkgFillVertices[0].DstX = minX 127 | pkgFillVertices[0].DstY = minY 128 | pkgFillVertices[1].DstX = maxX 129 | pkgFillVertices[1].DstY = minY 130 | pkgFillVertices[2].DstX = maxX 131 | pkgFillVertices[2].DstY = maxY 132 | pkgFillVertices[3].DstX = minX 133 | pkgFillVertices[3].DstY = maxY 134 | target.DrawTriangles(pkgFillVertices, pkgFillVertIndices, pkgMask1x1, &pkgFillTrianglesOpts) 135 | } 136 | 137 | func FillOverRectF32(target *ebiten.Image, minX, minY, maxX, maxY float32, fillColor color.Color) { 138 | if maxX <= minX || maxY <= minY { return } 139 | r, g, b, a := toRGBAf32(fillColor) 140 | for i := range 4 { 141 | pkgFillVertices[i].ColorR = r 142 | pkgFillVertices[i].ColorG = g 143 | pkgFillVertices[i].ColorB = b 144 | pkgFillVertices[i].ColorA = a 145 | } 146 | 147 | pkgFillVertices[0].DstX = minX 148 | pkgFillVertices[0].DstY = minY 149 | pkgFillVertices[1].DstX = maxX 150 | pkgFillVertices[1].DstY = minY 151 | pkgFillVertices[2].DstX = maxX 152 | pkgFillVertices[2].DstY = maxY 153 | pkgFillVertices[3].DstX = minX 154 | pkgFillVertices[3].DstY = maxY 155 | target.DrawTriangles(pkgFillVertices, pkgFillVertIndices, pkgMask1x1, &pkgFillTrianglesOpts) 156 | } 157 | -------------------------------------------------------------------------------- /shaker/balanced.go: -------------------------------------------------------------------------------- 1 | package shaker 2 | 3 | import "math" 4 | import "math/rand/v2" 5 | 6 | import "github.com/tinne26/mipix/internal" 7 | 8 | var _ Shaker = (*Balanced)(nil) 9 | 10 | // Cubic bézier curves with start and end points at (0, 0), 11 | // in some kind of circular oscillation. 12 | // 13 | // You can set the motion scale to 0.02 and travel time to 14 | // 1.4 for a soft ship-like motion. The default is more like 15 | // a dampened earthquake. The shaking is fairly homogeneous 16 | // and consistent in speed and motion. You could probably 17 | // dive into the code and tweak a few things here and there 18 | // to destabilize speed and get something more similar to a 19 | // drunken effect. You probably shouldn't, but you could. 20 | // 21 | // The implementation is tick-rate independent. 22 | type Balanced struct { 23 | rads float64 24 | cx1, cy1 float64 25 | cx2, cy2 float64 26 | 27 | elapsed float64 28 | travelTime float64 29 | axisRatio float64 30 | zoomCompensation float64 31 | initialized bool 32 | } 33 | 34 | func (self *Balanced) ensureInitialized() { 35 | if self.initialized { return } 36 | self.initialized = true 37 | if self.axisRatio == 0.0 { 38 | self.axisRatio = 0.01 39 | } 40 | if self.travelTime == 0.0 { 41 | self.travelTime = 0.05 42 | } 43 | self.rads = rand.Float64()*2.0*math.Pi 44 | self.rerollControlPoints() 45 | } 46 | 47 | // To preserve resolution independence, shakers often simulate the 48 | // shaking within a [-0.5, 0.5] space and only later scale it. For 49 | // example, if you have a resolution of 32x32 and set a motion 50 | // scale of 0.25, the shaking will range within [-4, +4] in both 51 | // axes. 52 | // 53 | // Defaults to 0.01. 54 | func (self *Balanced) SetMotionScale(axisScalingFactor float64) { 55 | if axisScalingFactor <= 0.0 { panic("axisScalingFactor must be strictly positive") } 56 | self.axisRatio = axisScalingFactor 57 | } 58 | 59 | // The range of motion of most shakers is based on the logical 60 | // resolution of the game. This means that when zooming in or 61 | // out, the shaking effect will become more or less pronounced, 62 | // respectively. If you want the shaking to maintain the same 63 | // relative magnitude regardless of zoom level, change the zoom 64 | // compensation from 0 (the default) to 1. 65 | func (self *Balanced) SetZoomCompensation(compensation float64) { 66 | if compensation < 0 || compensation > 1.0 { 67 | panic("zoom compensation factor must be in [0, 1]") 68 | } 69 | self.zoomCompensation = compensation 70 | } 71 | 72 | // Change the travel time between generated shake points. Defaults to 0.05. 73 | func (self *Balanced) SetTravelTime(travelTime float64) { 74 | if travelTime <= 0 { panic("travel time must be strictly positive") } 75 | self.travelTime = travelTime 76 | } 77 | 78 | // Implements the [Shaker] interface. 79 | func (self *Balanced) GetShakeOffsets(level float64) (float64, float64) { 80 | self.ensureInitialized() 81 | if level == 0.0 { 82 | self.elapsed = 0.0 83 | self.rads = rand.Float64()*2.0*math.Pi 84 | self.rerollControlPoints() 85 | return 0.0, 0.0 86 | } 87 | 88 | // bézier cubic curve interpolation 89 | t := self.elapsed/self.travelTime 90 | lerp := func(x1, y1, x2, y2, t float64) (float64, float64) { 91 | return internal.LinearInterp(x1, x2, t), internal.LinearInterp(y1, y2, t) 92 | } 93 | oc1x , oc1y := lerp(0.0, 0.0, self.cx1, self.cy1, t) // origin to control 1 94 | c1c2x, c1c2y := lerp(self.cx1, self.cy1, self.cx2, self.cy2, t) // control 1 to control 2 95 | c2fx , c2fy := lerp(self.cx2, self.cy2, 0.0, 0.0, t) // control 2 to end 96 | iox , ioy := lerp(oc1x, oc1y, c1c2x, c1c2y, t) // first interpolation from origin 97 | ifx , ify := lerp(c1c2x, c1c2y, c2fx, c2fy, t) // second interpolation to end 98 | ix , iy := lerp(iox, ioy, ifx, ify, t) // interpolated 99 | 100 | // roll new point, slide previous 101 | self.elapsed += 1.0/float64(internal.GetUPS()) 102 | if self.elapsed >= self.travelTime { 103 | self.rerollControlPoints() 104 | for self.elapsed >= self.travelTime { 105 | self.elapsed -= self.travelTime 106 | } 107 | } 108 | 109 | // translate interpolated point to real screen distances 110 | w, h := internal.GetResolution() 111 | w64, h64 := float64(w), float64(h) 112 | zoom := internal.GetCurrentZoom() 113 | xOffset, yOffset := ix*w64*self.axisRatio, iy*h64*self.axisRatio 114 | if self.zoomCompensation != 0.0 { 115 | compensatedZoom := 1.0 + (zoom - 1.0)*self.zoomCompensation 116 | xOffset /= compensatedZoom 117 | yOffset /= compensatedZoom 118 | } 119 | if level != 1.0 { 120 | xOffset *= level 121 | yOffset *= level 122 | } 123 | 124 | return xOffset, yOffset 125 | } 126 | 127 | func (self *Balanced) rerollControlPoints() { 128 | length := 0.8 + rand.Float64()*0.2 129 | sin, cos := math.Sincos(self.rads) 130 | self.cx1 = cos*length 131 | self.cy1 = sin*length 132 | 133 | // shift angle for the exit direction, which will 134 | // also be used as the entry direction for the next 135 | // point (with an 180 degree offset) 136 | self.rads += math.Pi*rand.Float64()*0.3333 // yes, shift in a consistent direction 137 | if self.rads >= 2.0*math.Pi { self.rads -= 2.0*math.Pi } 138 | 139 | sin, cos = math.Sincos(self.rads) 140 | self.cx2 = cos*length 141 | self.cy2 = sin*length 142 | 143 | // apply offset for next angle 144 | self.rads += math.Pi 145 | if self.rads >= 2.0*math.Pi { self.rads -= 2.0*math.Pi } 146 | } 147 | -------------------------------------------------------------------------------- /shaker/quake.go: -------------------------------------------------------------------------------- 1 | package shaker 2 | 3 | import "math/rand/v2" 4 | 5 | import "github.com/tinne26/mipix/internal" 6 | 7 | var _ Shaker = (*Quake)(nil) 8 | 9 | // Implementation of a [Shaker] with consistently oscillating 10 | // movement in both axes, but with some irregularities in speed 11 | // and travel distance. It's interesting because it has those 12 | // unpredictable variances within a very predictable and slightly 13 | // smoothed motion pattern. 14 | // 15 | // The implementation is tick-rate independent. 16 | type Quake struct { 17 | x float64 // [-0.5, +0.5] 18 | y float64 // [-0.5, +0.5] 19 | fromX, fromY float64 20 | towardsX, towardsY float64 // can be cut short from [-0.5, 0.5] 21 | xSpeedIni, xSpeedEnd float64 22 | ySpeedIni, ySpeedEnd float64 23 | minSpeed, maxSpeed float64 // absolute values 24 | axisRatio float64 25 | zoomCompensation float64 26 | initialized bool 27 | } 28 | 29 | func (self *Quake) ensureInitialized() { 30 | if self.initialized { return } 31 | self.initialized = true 32 | if self.minSpeed == 0.0 { 33 | self.minSpeed = 5.0 34 | } 35 | if self.maxSpeed == 0.0 { 36 | self.maxSpeed = self.minSpeed*4.6 37 | } 38 | if self.axisRatio == 0.0 { 39 | self.axisRatio = 0.0225 40 | } 41 | self.towardsX, self.xSpeedIni, self.xSpeedEnd = self.reroll(0.0, 0.0) 42 | self.towardsY, self.ySpeedIni, self.ySpeedEnd = self.reroll(0.0, 0.0) 43 | } 44 | 45 | // Internally, both x and y oscillate at their own speeds. 46 | // While going from side to side, these speeds can change 47 | // between any random values within [minSpeed, maxSpeed]. 48 | // 49 | // This method allows you to configure those speeds. 50 | // The default values are (5.0, 23.0). 51 | func (self *Quake) SetSpeedRange(minSpeed, maxSpeed float64) { 52 | if minSpeed <= 0.0 { panic("minSpeed must be strictly positive") } 53 | if maxSpeed < minSpeed { panic("maxSpeed must be >= than minSpeed") } 54 | self.minSpeed, self.maxSpeed = minSpeed, maxSpeed 55 | } 56 | 57 | // To preserve resolution independence, shakers often simulate the 58 | // shaking within a [-0.5, 0.5] space and only later scale it. For 59 | // example, if you have a resolution of 32x32 and set a motion 60 | // scale of 0.25, the shaking will range within [-4, +4] in both 61 | // axes. 62 | // 63 | // Defaults to 0.0225. 64 | func (self *Quake) SetMotionScale(axisScalingFactor float64) { 65 | if axisScalingFactor <= 0.0 { panic("axisScalingFactor must be strictly positive") } 66 | self.axisRatio = axisScalingFactor 67 | } 68 | 69 | // The range of motion of most shakers is based on the logical 70 | // resolution of the game. This means that when zooming in or 71 | // out, the shaking effect will become more or less pronounced, 72 | // respectively. If you want the shaking to maintain the same 73 | // relative magnitude regardless of zoom level, change the zoom 74 | // compensation from 0 (the default) to 1. 75 | func (self *Quake) SetZoomCompensation(compensation float64) { 76 | if compensation < 0 || compensation > 1.0 { 77 | panic("zoom compensation factor must be in [0, 1]") 78 | } 79 | self.zoomCompensation = compensation 80 | } 81 | 82 | // Implements the [Shaker] interface. 83 | func (self *Quake) GetShakeOffsets(level float64) (float64, float64) { 84 | self.ensureInitialized() 85 | if level == 0.0 { 86 | self.x, self.y = 0.0, 0.0 87 | self.fromX, self.fromY = 0.0, 0.0 88 | self.towardsX, self.xSpeedIni, self.xSpeedEnd = self.reroll(0.0, 0.0) 89 | self.towardsY, self.ySpeedIni, self.ySpeedEnd = self.reroll(0.0, 0.0) 90 | return 0.0, 0.0 91 | } 92 | 93 | // update x/y 94 | updateDelta := 1.0/float64(internal.GetUPS()) 95 | t := internal.TAt(self.x, self.fromX, self.towardsX) 96 | self.x += internal.LinearInterp(self.xSpeedIni, self.xSpeedEnd, t)*updateDelta 97 | if internal.TAt(self.x, self.fromX, self.towardsX) >= 1.0 { 98 | self.fromX = self.x 99 | self.towardsX, self.xSpeedIni, self.xSpeedEnd = self.reroll(self.x, self.xSpeedEnd) 100 | } 101 | t = internal.TAt(self.y, self.fromY, self.towardsY) 102 | self.y += internal.LinearInterp(self.ySpeedIni, self.ySpeedEnd, t)*updateDelta 103 | if internal.TAt(self.y, self.fromY, self.towardsY) >= 1.0 { 104 | self.fromY = self.y 105 | self.towardsY, self.ySpeedIni, self.ySpeedEnd = self.reroll(self.y, self.ySpeedEnd) 106 | } 107 | 108 | // translate interpolated point to real screen offsets 109 | w, h := internal.GetResolution() 110 | w64, h64 := float64(w), float64(h) 111 | zoom := internal.GetCurrentZoom() 112 | xOffset, yOffset := self.x*w64*self.axisRatio, self.y*h64*self.axisRatio 113 | if self.zoomCompensation != 0.0 { 114 | compensatedZoom := 1.0 + (zoom - 1.0)*self.zoomCompensation 115 | xOffset /= compensatedZoom 116 | yOffset /= compensatedZoom 117 | } 118 | if level != 1.0 { 119 | xOffset *= level 120 | yOffset *= level 121 | } 122 | 123 | return xOffset, yOffset 124 | } 125 | 126 | func (self *Quake) reroll(value, speed float64) (target, iniSpeed, endSpeed float64) { 127 | if value > 0.0 || (value == 0.0 && rand.Float64() < 0.5) { 128 | target = -(0.05 + rand.Float64()*0.45) 129 | iniSpeed = -max(internal.Abs(speed), self.minSpeed) 130 | endSpeed = -(self.minSpeed + rand.Float64()*(self.maxSpeed - self.minSpeed)) 131 | speedDiff := (endSpeed - iniSpeed)*(internal.Abs(target - value)) 132 | endSpeed = iniSpeed + speedDiff 133 | } else { // value < 0.0 134 | target = (0.05 + rand.Float64()*0.45) 135 | iniSpeed = max(internal.Abs(speed), self.minSpeed) 136 | endSpeed = (self.minSpeed + rand.Float64()*(self.maxSpeed - self.minSpeed)) 137 | speedDiff := (endSpeed - iniSpeed)*(internal.Abs(target - value)) 138 | endSpeed = iniSpeed + speedDiff 139 | } 140 | return 141 | } 142 | -------------------------------------------------------------------------------- /docs/ups-vs-tps.md: -------------------------------------------------------------------------------- 1 | # UPS vs TPS 2 | 3 | The API for mipix exposes some methods like [`mipix.Tick().UPS()`](https://pkg.go.dev/github.com/tinne26/mipix#AccessorTick.UPS), [`mipix.Tick().TPS()`](https://pkg.go.dev/github.com/tinne26/mipix#AccessorTick.TPS), [`mipix.Tick().SetRate()`](https://pkg.go.dev/github.com/tinne26/mipix#AccessorTick.SetRate) and [`mipix.Tick().GetRate()`](https://pkg.go.dev/github.com/tinne26/mipix#AccessorTick.GetRate) that might have you confused. What does all this mean? 4 | 5 | > [!IMPORTANT] 6 | > Ebitengine, unlike most game engines, uses a tick-based fixed timestep loop for updating game logic. If you are only familiar with "delta times" and this sounds strange to you, [tinne26/tps-vs-fps](https://github.com/tinne26/tps-vs-fps) explains the topic in more detail. 7 | > 8 | > The rest of this document assumes you understand the TPS model. 9 | 10 | UPS stands for "updates per second". Instead of a fixed timestep loop with *ticks*, where one tick corresponds to one update, mipix uses a slightly more sophisticated system where you still use a fixed timestep loop with N updates per second, but each update might simulate *more than one tick*. 11 | 12 | The default `ebiten.TPS() == 60`, in this model, corresponds to `UPS() == 60` and `TPU() == 1` (`TPU` means "ticks per update", which mipix refers to as "tick rate"). 13 | 14 | The reason for this model to exist is to *better support high-refresh display rates and/or reduce input latency*, but without forcing the user to run the game at a higher simulation rate if they don't want to. 15 | 16 | > [!NOTE] 17 | > Not all games are a *good match for higher refresh rates*. You might have a very pure pixel art game with animations that only run at 8 frames per second, all in sync. Trying to artificially support high refresh rate displays in this case would be silly. This document explains a feature that's interesting for *most games*, but not all of them. Use your common sense before joining the feature hype train and all that. 18 | 19 | As you know, many modern displays can run at 120Hz, 144Hz or 240Hz. We call these "high refresh rate displays". If we want to support these on Ebitengine, we have a couple options: 20 | - Interpolate positions smoothly between the current and previous updates. This is not always so simple to do and will often introduce extra latency. 21 | - Set a higher `TPS`. With more granular simulation steps, we have something new to show on each frame even on a high refresh rate display. 22 | 23 | There are some arguments and use-cases for the first approach, but we will be exploring the second option. Can't we do that already with Ebitengine's model? Just run the game at 240 ticks per second! 24 | 25 | That's correct. The main problem with doing that is that you are paying the cost whether you have a 240Hz display or not. Now, this is not always a real problem, maybe your logic is simple enough that that's perfectly acceptable, but a general solution needs to be more flexible. 26 | 27 | The general solution is the UPS model, which you should already be starting to understand by now: 28 | - Out internal logic will always run at 240 ticks per second, but the number of updates per second is not necessarily 240. It might be 120 UPS, with a tick rate of 2 ticks per update instead (still a total of 240 ticks per second, but with less updates), or 60 UPS, with a tick rate of 4 TPU. 29 | 30 | And this is what mipix does. The old `ebiten.TPS` is hidden under `mipix.Tick().UPS()` now, and we can control the tick rate independently too. 31 | 32 | The 240 internal tick rate is a good recommendation, because it makes sense for 60Hz, 120Hz and 240Hz, but you can totally explore something different, like 40, 80, 160 and so on. I don't know why, but you could do that if you want. 33 | 34 | ## Tick-rate independent and update-rate independent 35 | 36 | In mipix, there are multiple functions that use ticks for transitions. For example, shakes can have fade ins and fade outs that are measured in ticks. The reason to stick to ticks instead of delta times is what was already laid out on [tinne26/tps-vs-fps](https://github.com/tinne26/tps-vs-fps): determinism and simplicity. I don't have anything against delta times, but they go against Ebitengine's ethos. 37 | 38 | Well, it's all a lie. Plenty of mipix interface implementations are based on times instead of ticks. 39 | 40 | Many interface implementations in mipix mention being tick-rate independent or update-rate independent: 41 | - Tick-rate independent means that no matter what tick rate or UPS you set, the result will be perceptually the same. This is usually guaranteed with default [`Tracker`](https://pkg.go.dev/github.com/tinne26/mipix/tracker#Tracker) implementations. This is generally only recommended for visuals, not game logic. 42 | - Update-rate independent is a less strict promise, as changing the total amount of ticks per second will change the results too. 43 | 44 | > [!CAUTION] 45 | > In practice, the precision of many algorithms depends on the number of iterations it simulates. For very high simulation rates, results would typically converge with a high degree of accuracy, but... if you use 30UPS, some things might not go so smoothly. It's always advisable to test all your configurations. 46 | 47 | ## Summary and conclusions 48 | 49 | - The UPS model (update per second) allows multiple ticks per update in order to better support high refresh rate displays and lower latencies when required, in a configurable manner, at runtime. 50 | - If you are making a non-toy project and are interested in high refresh rates, consider starting your project at 240 ticks per second internally, typically with a default of 60UPS@4TPU. If you later decide to support 120UPS@2TPU and 240UPS@1TPU, everything is already compatible. 51 | - For game logic, prioritize working with ticks. Advance your internal tick counters with `mipix.Tick().GetRate()` per update. For visuals, if you want to make something tick-rate independent, you can multiply values by the update delta (the time you should be simulating during an update, which is `1.0/float64(mipix.Tick().UPS())`). If you want to make it update-rate independent, use the same technique you use for game logic. 52 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "image" 4 | import "image/color" 5 | 6 | import "github.com/tinne26/mipix/internal" 7 | import "github.com/hajimehoshi/ebiten/v2" 8 | 9 | // Alias for image.Rectangle. 10 | type Rectangle = image.Rectangle 11 | 12 | // Syntax sugar for [ebiten.Image.SubImage]() passing explicit 13 | // coordinates instead of [image.Rectangle] and returning [*ebiten.Image] 14 | // instead of [image.Image]. 15 | func SubImage(source *ebiten.Image, minX, minY, maxX, maxY int) *ebiten.Image { 16 | return source.SubImage(Rect(minX, minY, maxX, maxY)).(*ebiten.Image) 17 | } 18 | 19 | // Similar to [ebiten.Image.Fill](), but with alpha blending. 20 | // See also [FillOverRect](). 21 | func FillOver(target *ebiten.Image, fillColor color.Color) { 22 | internal.FillOver(target, fillColor) 23 | } 24 | 25 | // Create a low resolution image from a simple mask. The value 26 | // 0 is always reserved for transparent, and higher values will 27 | // index the given colors. If no colors are given, 1 will be 28 | // white by default. Example usage: 29 | // heart := utils.MaskToImage([]uint8{ 30 | // 0, 1, 1, 0, 1, 1, 0, 31 | // 1, 1, 1, 1, 1, 1, 1, 32 | // 1, 1, 1, 1, 1, 1, 1, 33 | // 0, 1, 1, 1, 1, 1, 0, 34 | // 0, 0, 1, 1, 1, 0, 0, 35 | // 0, 0, 0, 1, 0, 0, 0, 36 | // }, 7, utils.RGB(255, 0, 0)) 37 | func MaskToImage(width int, mask []uint8, colors ...color.RGBA) *ebiten.Image { 38 | return MaskToImageWithOrigin(0, 0, width, mask, colors...) 39 | } 40 | 41 | // Same as [MaskToImage](), but additionally receiving the image 42 | // bounds origin coordinates as the first two arguments. 43 | func MaskToImageWithOrigin(ox, oy int, width int, mask []uint8, colors ...color.RGBA) *ebiten.Image { 44 | // safety assertions 45 | if width <= 0 { 46 | panic("expected width > 0") 47 | } 48 | height := len(mask)/width 49 | if height*width != len(mask) { 50 | panic("given width can't split given mask into rows of equal length") 51 | } 52 | 53 | // no colors fallback 54 | if len(colors) == 0 { 55 | colors = []color.RGBA{ color.RGBA{255, 255, 255, 255} } 56 | } 57 | 58 | // create image 59 | rgba := image.NewRGBA(image.Rect(ox, oy, ox + width, oy + height)) 60 | for index, value := range mask { 61 | pixelIndex := index << 2 62 | if value != 0 { 63 | clr := colors[value - 1] 64 | rgba.Pix[pixelIndex + 0] = clr.R 65 | rgba.Pix[pixelIndex + 1] = clr.G 66 | rgba.Pix[pixelIndex + 2] = clr.B 67 | rgba.Pix[pixelIndex + 3] = clr.A 68 | } 69 | } 70 | 71 | var opts ebiten.NewImageFromImageOptions 72 | opts.PreserveBounds = true 73 | return ebiten.NewImageFromImageWithOptions(rgba, &opts) 74 | } 75 | 76 | // Returns the given bounds translated by the given (x, y) values. 77 | // Equivalent to bounds.Add(image.Pt(x, y)). 78 | func Shift(bounds image.Rectangle, x, y int) image.Rectangle { 79 | return bounds.Add(image.Pt(x, y)) 80 | } 81 | 82 | // Returns the GeoM that would be used to draw the given image 83 | // on the logical mipix canvas at the logical global coordinates 84 | // (x, y). 85 | func GeoMAt(source *ebiten.Image, x, y int) ebiten.GeoM { 86 | var geom ebiten.GeoM 87 | localXY := image.Pt(x, y).Sub(internal.BridgedCameraOrigin) 88 | localXY = localXY.Add(source.Bounds().Min) // * 89 | // * origin is not automatically applied when using 90 | // an image as source, so we need to add it manually 91 | geom.Translate(float64(localXY.X), float64(localXY.Y)) 92 | return geom 93 | } 94 | 95 | // Returns the image options with a GeoM set up to draw the 96 | // given image at the logical global coordinates (x, y). 97 | // Makes basic image drawing simpler. Example code: 98 | // opts := utils.DrawImageOptionsAt(myImage, 8, 8) 99 | // canvas.DrawImage(myImage, &opts) 100 | func DrawImageOptionsAt(source *ebiten.Image, x, y int) ebiten.DrawImageOptions { 101 | var opts ebiten.DrawImageOptions 102 | localXY := image.Pt(x, y).Sub(internal.BridgedCameraOrigin) 103 | localXY = localXY.Add(source.Bounds().Min) // * 104 | // * origin is not automatically applied when using 105 | // an image as source, so we need to add it manually 106 | opts.GeoM.Translate(float64(localXY.X), float64(localXY.Y)) 107 | return opts 108 | } 109 | 110 | // Similar to [ebiten.Image.Fill](), but with alpha blending 111 | // and explicit target bounds. See also [FillOver](). 112 | func FillOverRect(target *ebiten.Image, bounds image.Rectangle, fillColor color.Color) { 113 | internal.FillOverRect(target, bounds, fillColor) 114 | } 115 | 116 | // (we already have HiRes().FillOverRect() and stuff) 117 | // func FillOverRectF64(target *ebiten.Image, minX, minY, maxX, maxY float64, fillColor color.Color) { 118 | // internal.FillOverRectF32(target, float32(minX), float32(minY), float32(maxX), float32(maxY), fillColor) 119 | // } 120 | 121 | // func FillOverRectF32(target *ebiten.Image, minX, minY, maxX, maxY float32, fillColor color.Color) { 122 | // internal.FillOverRectF32(target, minX, minY, maxX, maxY, fillColor) 123 | // } 124 | 125 | // Alias for [image.Rect](). 126 | func Rect(minX, minY, maxX, maxY int) image.Rectangle { 127 | return image.Rect(minX, minY, maxX, maxY) 128 | } 129 | 130 | // func RoundCoords(x, y float64) (int, int) { 131 | // return int(math.Round(x)), int(math.Round(y)) 132 | // } 133 | 134 | // func Round(value float64) float64 { 135 | // return math.Round(value) 136 | // } 137 | 138 | // Returns [color.RGBA]{r, g, b, 255}. 139 | func RGB(r, g, b uint8) color.RGBA { 140 | return color.RGBA{r, g, b, 255} 141 | } 142 | 143 | // Returns [color.RGBA]{r, g, b, a} after checking that the 144 | // given values constitute a valid premultiplied-alpha color 145 | // (a >= r,g,b). On invalid colors, the function panics. 146 | func RGBA(r, g, b, a uint8) color.RGBA { 147 | if r > a || g > a || b > a { 148 | panic("invalid color.RGBA values: premultiplied-alpha requires a >= r,g,b") 149 | } 150 | return color.RGBA{r, g, b, a} 151 | } 152 | 153 | // Converts a color to float32 RGBA values in [0, 1] range. 154 | // 155 | // This is the format that [ebiten.Vertex] expects. 156 | func ColorToF32(clr color.Color) (r, g, b, a float32) { 157 | r16, g16, b16, a16 := clr.RGBA() 158 | return float32(r16)/65535.0, float32(g16)/65535.0, float32(b16)/65535.0, float32(a16)/65535.0 159 | } 160 | 161 | // // Similar to [ebiten.IsKeyPressed](), but allowing multiple keys. 162 | // func AnyKeyPressed(keys ...ebiten.Key) bool { 163 | // for _, key := range keys { 164 | // if ebiten.IsKeyPressed(key) { 165 | // return true 166 | // } 167 | // } 168 | // return false 169 | // } 170 | -------------------------------------------------------------------------------- /api_camera.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "image" 4 | 5 | import "github.com/tinne26/mipix/zoomer" 6 | import "github.com/tinne26/mipix/tracker" 7 | import "github.com/tinne26/mipix/shaker" 8 | 9 | // See [Camera](). 10 | type AccessorCamera struct{} 11 | 12 | // Provides access to camera-related functionality in a structured 13 | // manner. Use through method chaining, e.g.: 14 | // mipix.Camera().Zoom(2.0) 15 | func Camera() AccessorCamera { return AccessorCamera{} } 16 | 17 | // --- tracking --- 18 | 19 | // Returns the current tracker. See [AccessorCamera.SetTracker]() 20 | // for more details. 21 | func (AccessorCamera) GetTracker() tracker.Tracker { 22 | return pkgController.cameraGetTracker() 23 | } 24 | 25 | // Sets the tracker in charge of updating the camera position. 26 | // By default the tracker is nil, and tracking is handled by a 27 | // fallback [tracker.SpringTailer]. If you want something simpler 28 | // at the start, you can easily switch to an instant tracker: 29 | // import "github.com/tinne26/mipix/tracker" 30 | // mipix.Camera().SetTracker(tracker.Instant) 31 | func (AccessorCamera) SetTracker(tracker tracker.Tracker) { 32 | pkgController.cameraSetTracker(tracker) 33 | } 34 | 35 | // Feeds the camera the newest target coordinates to point to or 36 | // look at. The time that it takes to reach these new coordinates 37 | // will depend on the behavior of the current [tracker.Tracker]. 38 | // 39 | // You can pass coordinates as many times as you want, the 40 | // target position is always set to the most recent pair. 41 | func (AccessorCamera) NotifyCoordinates(x, y float64) { 42 | pkgController.cameraNotifyCoordinates(x, y) 43 | } 44 | 45 | // Immediately sets the camera coordinates to the given values. 46 | // Commonly used when changing scenes or maps. 47 | func (AccessorCamera) ResetCoordinates(x, y float64) { 48 | pkgController.cameraResetCoordinates(x, y) 49 | } 50 | 51 | // This method allows updating the [AccessorCamera.Area]() 52 | // even during [Game].Update(). By default, this happens 53 | // automatically after [Game].Update(), but flushing the 54 | // coordinates can force an earlier update. 55 | // 56 | // Notice that only one camera update can happen per tick, 57 | // so the automatic camera update will be skipped if you 58 | // flush coordinates manually during [Game].Update(). 59 | // Calling this method multiple times during the same update 60 | // will only update coordinates on the first invocation. 61 | // 62 | // If you don't need this feature, it's better to forget about 63 | // this method. This is only necessary if you need the camera 64 | // area to remain perfectly consistent between update and draw(s), 65 | // in which case you update the player position first, then notify 66 | // the coordinates and finally flush them. 67 | func (AccessorCamera) FlushCoordinates() { 68 | pkgController.cameraFlushCoordinates() 69 | } 70 | 71 | // Returns the logical area of the game that has to be 72 | // rendered on [Game].Draw()'s canvas or successive logical 73 | // draws. Notice that this can change after each [Game].Update(), 74 | // since the camera might be zoomed or shaking. 75 | // 76 | // Notice that the area will typically be slightly different 77 | // between [Game].Update() and [Game].Draw(). If you need more 78 | // manual control over that, see [AccessorCamera.FlushCoordinates](). 79 | func (AccessorCamera) Area() image.Rectangle { 80 | return pkgController.cameraAreaGet() 81 | } 82 | 83 | // Similar to [AccessorCamera.Area](), but without rounding up 84 | // the coordinates and returning the exact values. Rarely 85 | // necessary in practice. 86 | func (AccessorCamera) AreaF64() (minX, minY, maxX, maxY float64) { 87 | return pkgController.cameraAreaF64() 88 | } 89 | 90 | // --- zoom --- 91 | 92 | // Sets a new target zoom level. The transition from the current 93 | // zoom level to the new one is managed by a [zoomer.Zoomer]. 94 | // 95 | // Important: very low zoom levels are extremely dangerous, as they 96 | // make the camera area grow towards infinity. In fact, mipix doesn't 97 | // expect you to go below 0.05, and stops trying to predict/optimize 98 | // canvas sizes for zoom transitions beyond that point. 99 | func (AccessorCamera) Zoom(newZoomLevel float64) { 100 | pkgController.cameraZoom(newZoomLevel) 101 | } 102 | 103 | // Returns the current [zoomer.Zoomer] interface. 104 | // See [AccessorCamera.SetZoomer]() for more details. 105 | func (AccessorCamera) GetZoomer() zoomer.Zoomer { 106 | return pkgController.cameraGetZoomer() 107 | } 108 | 109 | // Sets the [zoomer.Zoomer] in charge of updating camera zoom levels. 110 | // By default the zoomer is nil, and zoom levels are handled 111 | // by a fallback [zoomer.Quadratic]. 112 | func (AccessorCamera) SetZoomer(zoomer zoomer.Zoomer) { 113 | pkgController.cameraSetZoomer(zoomer) 114 | } 115 | 116 | // Returns the current and target zoom levels. 117 | func (AccessorCamera) GetZoom() (current, target float64) { 118 | return pkgController.cameraGetZoom() 119 | } 120 | 121 | // --- screen shaking --- 122 | 123 | // Returns the shaker interface associated to the given shaker 124 | // channel (or to the default channel zero if none is passed). 125 | // Passing multiple channels will make the function panic. 126 | // 127 | // See [AccessorCamera.SetShaker]() for more details. 128 | func (AccessorCamera) GetShaker(channel ...shaker.Channel) shaker.Shaker { 129 | return pkgController.cameraGetShaker() 130 | } 131 | 132 | // Sets a shaker. By default the screen shaker interface is 133 | // nil, and shakes are handled by a fallback [shaker.Random]. 134 | // 135 | // If you don't specify any shaker channel, the shaker will be 136 | // set to the default channel zero. Attempting to pass multiple 137 | // channels will make the function panic. 138 | func (AccessorCamera) SetShaker(shaker shaker.Shaker, channel ...shaker.Channel) { 139 | pkgController.cameraSetShaker(shaker, channel...) 140 | } 141 | 142 | // Starts a screen shake that will continue indefinitely until 143 | // stopped by [AccessorCamera.EndShake](). If no shaker channel(s) 144 | // are specified, the shake will start on the default channel zero. 145 | // 146 | // Calling this method repeatedly to force a shaker to start if 147 | // not yet active is possible and safe. 148 | func (AccessorCamera) StartShake(fadeIn TicksDuration, channels ...shaker.Channel) { 149 | pkgController.cameraStartShake(fadeIn, channels...) 150 | } 151 | 152 | // Stops a screen shake. This can be used to stop shakes initiated with 153 | // [AccessorCamera.StartShake](), but also to stop triggered shakes early 154 | // or ensure that no shakes remain active after screen transitions and 155 | // similar. Calling this method repeatedly to make sure that shaking 156 | // is either stopped or stopping is possible and safe. 157 | // 158 | // If no shaker channel(s) are specified, the end shake command will 159 | // be sent to the default channel zero. 160 | func (AccessorCamera) EndShake(fadeOut TicksDuration, channels ...shaker.Channel) { 161 | pkgController.cameraEndShake(fadeOut, channels...) 162 | } 163 | 164 | // If no shaker channel is specified, the function returns whether 165 | // any camera shake is active. If a shaker channel is specified, the 166 | // function will only return whether that specific channel is active. 167 | func (AccessorCamera) IsShaking(channel ...shaker.Channel) bool { 168 | return pkgController.cameraIsShaking(channel...) 169 | } 170 | 171 | // Triggers a screenshake with specific fade in, duration and fade 172 | // out tick durations. If no explicit shaker channels are passed, 173 | // the trigger will be applied to the default channel zero. 174 | // 175 | // Currently, triggering a shake on a channel that's already active 176 | // and with an undefined duration will override the shake duration, 177 | // eventually bringing it to a stop. That being said, this is considered 178 | // an ambiguous situation that you should try to avoid in the first place. 179 | func (AccessorCamera) TriggerShake(fadeIn, duration, fadeOut TicksDuration, channels ...shaker.Channel) { 180 | pkgController.cameraTriggerShake(fadeIn, duration, fadeOut, channels...) 181 | } 182 | 183 | // This might be interesting for ephemerous shakes, so you don't have to be tracking and managing 184 | // everything so manually. That being said, you would still need a pool and to manage everything 185 | // diligently, so maybe there's not much gain here. 186 | // func (AccessorCamera) TriggerEventShake(shaker shaker.Shaker, fadeIn, duration, fadeOut TicksDuration) { 187 | // 188 | // } 189 | -------------------------------------------------------------------------------- /controller_camera.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "math" 4 | import "image" 5 | 6 | import "github.com/tinne26/mipix/internal" 7 | import "github.com/tinne26/mipix/zoomer" 8 | import "github.com/tinne26/mipix/tracker" 9 | import "github.com/tinne26/mipix/shaker" 10 | 11 | func (self *controller) cameraAreaGet() image.Rectangle { 12 | return self.cameraArea 13 | } 14 | 15 | func (self *controller) cameraAreaF64() (minX, minY, maxX, maxY float64) { 16 | zoomedWidth := float64(self.logicalWidth )/self.zoomCurrent 17 | zoomedHeight := float64(self.logicalHeight)/self.zoomCurrent 18 | minX = self.trackerCurrentX - zoomedWidth /2.0 + self.shakerOffsetX 19 | minY = self.trackerCurrentY - zoomedHeight/2.0 + self.shakerOffsetY 20 | return minX, minY, minX + zoomedWidth, minY + zoomedHeight 21 | } 22 | 23 | func (self *controller) updateCameraArea() { 24 | minX, minY, maxX, maxY := self.cameraAreaF64() 25 | self.cameraArea = image.Rect( 26 | int(math.Floor(minX)), int(math.Floor(minY)), 27 | int(math.Ceil( maxX)), int(math.Ceil( maxY)), 28 | ) 29 | internal.BridgedCameraOrigin = self.cameraArea.Min 30 | } 31 | 32 | // ---- tracking ---- 33 | 34 | func (self *controller) cameraGetTracker() tracker.Tracker { 35 | return self.tracker 36 | } 37 | 38 | func (self *controller) cameraSetTracker(tracker tracker.Tracker) { 39 | if self.inDraw { panic("can't set tracker during draw stage") } 40 | self.tracker = tracker 41 | } 42 | 43 | func (self *controller) cameraNotifyCoordinates(x, y float64) { 44 | if self.inDraw { panic("can't notify tracking coordinates during draw stage") } 45 | self.trackerTargetX, self.trackerTargetY = x, y 46 | } 47 | 48 | func (self *controller) cameraResetCoordinates(x, y float64) { 49 | if self.inDraw { panic("can't reset camera coordinates during draw stage") } 50 | self.trackerTargetX , self.trackerTargetY = x, y 51 | self.trackerCurrentX, self.trackerCurrentY = x, y 52 | if self.redrawManaged && (x != self.trackerCurrentX || y != self.trackerCurrentY) { 53 | self.needsRedraw = true 54 | } 55 | self.updateCameraArea() 56 | } 57 | 58 | func (self *controller) cameraFlushCoordinates() { 59 | if self.lastFlushCoordinatesTick == self.currentTick { return } 60 | self.lastFlushCoordinatesTick = self.currentTick 61 | self.updateZoom() 62 | self.updateTracking() 63 | self.updateShake() 64 | self.updateCameraArea() 65 | } 66 | 67 | func (self *controller) updateTracking() { 68 | camTracker := self.cameraGetInternalTracker() 69 | changeX, changeY := camTracker.Update( 70 | self.trackerCurrentX, self.trackerCurrentY, 71 | self.trackerTargetX, self.trackerTargetY, 72 | self.trackerPrevSpeedX, self.trackerPrevSpeedY, 73 | ) 74 | self.trackerCurrentX += changeX 75 | self.trackerCurrentY += changeY 76 | updateDelta := 1.0/float64(Tick().UPS()) 77 | self.trackerPrevSpeedX = changeX/updateDelta 78 | self.trackerPrevSpeedY = changeY/updateDelta 79 | 80 | if self.redrawManaged && (self.trackerPrevSpeedX != 0 || self.trackerPrevSpeedY != 0) { 81 | self.needsRedraw = true 82 | } 83 | } 84 | 85 | func (self *controller) cameraGetInternalTracker() tracker.Tracker { 86 | if self.tracker != nil { return self.tracker } 87 | if defaultTracker == nil { 88 | defaultTracker = &tracker.SpringTailer{} 89 | defaultTracker.Spring.SetParameters(0.8, 2.4) 90 | defaultTracker.SetCatchUpParameters(0.9, 1.75) 91 | } 92 | return defaultTracker 93 | } 94 | 95 | // --- zoom --- 96 | 97 | func (self *controller) updateZoom() { 98 | zoomer := self.cameraGetInternalZoomer() 99 | change := zoomer.Update(self.zoomCurrent, self.zoomTarget) 100 | if math.IsNaN(change) { panic("zoomer returned NaN") } 101 | self.zoomCurrent += change 102 | internal.CurrentZoom = self.zoomCurrent 103 | if self.zoomCurrent < 0.005 || self.zoomCurrent > 500.0 { 104 | panic("something is wrong with the zoomer: after last update, zoom went outside [0.005, 500.0]") 105 | } 106 | 107 | if self.redrawManaged && change != 0 { 108 | self.needsRedraw = true 109 | } 110 | } 111 | 112 | func (self *controller) cameraGetInternalZoomer() zoomer.Zoomer { 113 | if self.zoomer != nil { return self.zoomer } 114 | if defaultZoomer == nil { 115 | defaultZoomer = &zoomer.Quadratic{} 116 | defaultZoomer.Reset() 117 | } 118 | return defaultZoomer 119 | } 120 | 121 | func (self *controller) updateShake() { 122 | // compute new offsets 123 | var offsetX, offsetY float64 124 | for i, _ := range self.shakerChannels { 125 | self.shakerChannels[i].Update(i, self.tickRate) 126 | offsetX += self.shakerChannels[i].offsetX 127 | offsetY += self.shakerChannels[i].offsetY 128 | } 129 | 130 | // set needsRedraw flag if necessary 131 | if self.redrawManaged && (offsetX != self.shakerOffsetX || offsetY != self.shakerOffsetY) { 132 | self.needsRedraw = true 133 | } 134 | 135 | // register new offsets 136 | self.shakerOffsetX = offsetX 137 | self.shakerOffsetY = offsetY 138 | } 139 | 140 | 141 | func (self *controller) cameraZoom(newZoomLevel float64) { 142 | if self.inDraw { panic("can't zoom during draw stage") } 143 | self.zoomTarget = newZoomLevel 144 | } 145 | 146 | func (self *controller) cameraZoomReset(zoomLevel float64) { 147 | if self.inDraw { panic("can't reset zoom during draw stage") } 148 | self.zoomCurrent, self.zoomTarget, internal.CurrentZoom = zoomLevel, zoomLevel, zoomLevel 149 | self.cameraGetInternalZoomer().Reset() 150 | } 151 | 152 | func (self *controller) cameraGetZoomer() zoomer.Zoomer { 153 | return self.zoomer 154 | } 155 | 156 | func (self *controller) cameraSetZoomer(zoomer zoomer.Zoomer) { 157 | if self.inDraw { panic("can't change zoomer during draw stage") } 158 | self.zoomer = zoomer 159 | } 160 | 161 | func (self *controller) cameraGetZoom() (current, target float64) { 162 | return self.zoomCurrent, self.zoomTarget 163 | } 164 | 165 | // ---- screenshake ---- 166 | 167 | func (self *controller) cameraSetShaker(newShaker shaker.Shaker, channels ...shaker.Channel) { 168 | if self.inDraw { panic("can't SetShaker during draw stage") } 169 | if len(channels) > 1 { 170 | panic("can't pass multiple shaker channels to SetShaker") 171 | } else if len(channels) == 0 { 172 | self.shakerChannels[0].shaker = newShaker 173 | } else { 174 | index := int(channels[0]) 175 | if newShaker == nil && index >= len(self.shakerChannels) { return } 176 | newChan := shakerChannel{ shaker: newShaker } 177 | self.shakerChannels = setAt(self.shakerChannels, newChan, index) 178 | 179 | // compact nils at the end of the slice 180 | compactCount := 0 181 | for i := len(self.shakerChannels) - 1; i > 0; i-- { 182 | if self.shakerChannels[i].shaker != nil { break } 183 | compactCount += 1 184 | } 185 | if compactCount > 0 { 186 | self.shakerChannels = self.shakerChannels[ : len(self.shakerChannels) - compactCount] 187 | } 188 | } 189 | } 190 | 191 | func (self *controller) cameraGetShaker(channels ...shaker.Channel) shaker.Shaker { 192 | if len(channels) == 0 { 193 | return self.shakerChannels[0].shaker 194 | } else if len(channels) > 1 { 195 | panic("can't GetShaker for multiple shaker channels at once") 196 | } else if int(channels[0]) >= len(self.shakerChannels) { 197 | return nil 198 | } else { 199 | return self.shakerChannels[channels[0]].shaker 200 | } 201 | } 202 | 203 | func (self *controller) cameraStartShake(fadeIn TicksDuration, channels ...shaker.Channel) { 204 | if self.inDraw { panic("can't StartShake during draw stage") } 205 | if len(channels) == 0 { 206 | self.shakerChannels[0].Start(fadeIn) 207 | } else { 208 | for _, channel := range channels { 209 | if !self.shakerChannelAccessible(channel) { 210 | panic("can't StartShake on uninitialized channels") 211 | } 212 | self.shakerChannels[channel].Start(fadeIn) 213 | } 214 | } 215 | } 216 | 217 | func (self *controller) cameraEndShake(fadeOut TicksDuration, channels ...shaker.Channel) { 218 | if self.inDraw { panic("can't EndShake during draw stage") } 219 | if len(channels) == 0 { 220 | self.shakerChannels[0].End(fadeOut) 221 | } else { 222 | for _, channel := range channels { 223 | if !self.shakerChannelAccessible(channel) { 224 | panic("can't EndShake on uninitialized channels") 225 | } 226 | self.shakerChannels[channel].End(fadeOut) 227 | } 228 | } 229 | } 230 | 231 | func (self *controller) cameraTriggerShake(fadeIn, duration, fadeOut TicksDuration, channels ...shaker.Channel) { 232 | if self.inDraw { panic("can't TriggerShake during draw stage") } 233 | if len(channels) == 0 { 234 | self.shakerChannels[0].Trigger(fadeIn, duration, fadeOut) 235 | } else { 236 | for _, channel := range channels { 237 | if !self.shakerChannelAccessible(channel) { 238 | panic("can't TriggerShake on uninitialized channels") 239 | } 240 | self.shakerChannels[channel].Trigger(fadeIn, duration, fadeOut) 241 | } 242 | } 243 | } 244 | 245 | func (self *controller) cameraIsShaking(channels ...shaker.Channel) bool { 246 | if len(channels) > 1 { 247 | panic("IsShaking accepts at most one shaker channel as argument") 248 | } 249 | 250 | if len(channels) == 0 { 251 | for i, _ := range self.shakerChannels { 252 | if self.shakerChannels[i].IsShaking() { 253 | return true 254 | } 255 | } 256 | return false 257 | } else if !self.shakerChannelAccessible(channels[0]) { 258 | return false 259 | } else { 260 | return self.shakerChannels[channels[0]].IsShaking() 261 | } 262 | } 263 | 264 | func (self *controller) shakerChannelAccessible(channel shaker.Channel) bool { 265 | return (channel == 0 || ( 266 | int(channel) < len(self.shakerChannels) && 267 | self.shakerChannels[channel].shaker != nil)) 268 | } 269 | -------------------------------------------------------------------------------- /controller_core.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "math" 4 | import "image" 5 | import "image/color" 6 | 7 | import "github.com/hajimehoshi/ebiten/v2" 8 | 9 | import "github.com/tinne26/mipix/internal" 10 | import "github.com/tinne26/mipix/utils" 11 | import "github.com/tinne26/mipix/zoomer" 12 | import "github.com/tinne26/mipix/tracker" 13 | 14 | var pkgController controller 15 | func init() { 16 | pkgController.cameraZoomReset(1.0) 17 | pkgController.tickSetRate(1) 18 | pkgController.shakerChannels = make([]shakerChannel, 1) 19 | pkgController.lastFlushCoordinatesTick = 0xFFFF_FFFF_FFFF_FFFF 20 | pkgController.needsRedraw = true 21 | } 22 | 23 | type controller struct { 24 | // core state 25 | game Game 26 | queuedDraws []queuedDraw 27 | reusableCanvas *ebiten.Image // this preserves the highest size requested by resolution or zooms 28 | logicalWidth int 29 | logicalHeight int 30 | hiResWidth int 31 | hiResHeight int 32 | prevHiResCanvasWidth int // used to update layoutHasChanged even on unexpected cases * 33 | prevHiResCanvasHeight int // used to update layoutHasChanged even on unexpected cases 34 | // * https://github.com/hajimehoshi/ebiten/issues/2978 35 | layoutHasChanged bool 36 | inDraw bool 37 | redrawManaged bool 38 | needsRedraw bool 39 | needsClear bool 40 | stretchingEnabled bool 41 | scalingFilter ScalingFilter 42 | 43 | // camera 44 | lastFlushCoordinatesTick uint64 45 | cameraArea image.Rectangle 46 | 47 | // tracking 48 | tracker tracker.Tracker 49 | trackerCurrentX float64 50 | trackerCurrentY float64 51 | trackerTargetX float64 52 | trackerTargetY float64 53 | trackerPrevSpeedX float64 54 | trackerPrevSpeedY float64 55 | 56 | // zoom 57 | zoomer zoomer.Zoomer 58 | zoomCurrent float64 59 | zoomTarget float64 60 | 61 | // shake 62 | shakerChannels []shakerChannel 63 | shakerOffsetX float64 64 | shakerOffsetY float64 65 | 66 | // ticks 67 | currentTick uint64 68 | tickRate uint64 69 | 70 | // shaders 71 | shaderOpts ebiten.DrawTrianglesShaderOptions 72 | shaderVertices []ebiten.Vertex 73 | shaderVertIndices []uint16 74 | shaders [scalingFilterEndSentinel]*ebiten.Shader 75 | 76 | // debug 77 | debugInfo []string 78 | debugOffscreen *Offscreen 79 | } 80 | 81 | // --- ebiten.Game implementation --- 82 | 83 | func (self *controller) Update() error { 84 | self.currentTick += self.tickRate 85 | err := self.game.Update() 86 | if err != nil { return err } 87 | self.cameraFlushCoordinates() 88 | self.layoutHasChanged = false 89 | return nil 90 | } 91 | 92 | func (self *controller) Draw(hiResCanvas *ebiten.Image) { 93 | self.inDraw = true 94 | 95 | // get bounds and update hi res canvas size 96 | hiResBounds := hiResCanvas.Bounds() 97 | hiResWidth, hiResHeight := hiResBounds.Dx(), hiResBounds.Dy() 98 | if hiResWidth != self.prevHiResCanvasWidth || hiResHeight != self.prevHiResCanvasHeight { 99 | // * TODO: all this is kind of a temporary hack until ebitengine 100 | // can guarantee that layout returned sizes and draw received 101 | // canvas sizes will match 102 | self.prevHiResCanvasWidth = hiResWidth 103 | self.prevHiResCanvasHeight = hiResHeight 104 | self.layoutHasChanged = true 105 | self.needsRedraw = true 106 | } 107 | 108 | logicalCanvas := self.getLogicalCanvas() 109 | activeCanvas := self.getActiveHiResCanvas(hiResCanvas) 110 | if self.needsClear { 111 | self.needsClear = false 112 | hiResCanvas.Clear() 113 | logicalCanvas.Clear() 114 | } 115 | self.game.Draw(logicalCanvas) 116 | 117 | var drawIndex int = 0 118 | var prevDrawWasHiRes bool = false 119 | for drawIndex < len(self.queuedDraws) { 120 | if self.queuedDraws[drawIndex].IsHighResolution() { 121 | if !prevDrawWasHiRes { 122 | self.projectLogical(logicalCanvas, activeCanvas) 123 | } 124 | self.queuedDraws[drawIndex].hiResFunc(hiResCanvas, activeCanvas) 125 | prevDrawWasHiRes = true 126 | } else { 127 | if prevDrawWasHiRes { 128 | logicalCanvas.Clear() 129 | prevDrawWasHiRes = false 130 | } 131 | self.queuedDraws[drawIndex].logicalFunc(logicalCanvas) 132 | } 133 | drawIndex += 1 134 | } 135 | self.queuedDraws = self.queuedDraws[ : 0] 136 | 137 | // final projection 138 | if !self.redrawManaged || self.needsRedraw { 139 | if !prevDrawWasHiRes { 140 | self.projectLogical(logicalCanvas, activeCanvas) 141 | } 142 | self.debugDrawAll(activeCanvas) 143 | } 144 | self.needsRedraw = false 145 | self.inDraw = false 146 | } 147 | 148 | func (self *controller) getLogicalCanvas() *ebiten.Image { 149 | width := self.cameraArea.Dx() 150 | height := self.cameraArea.Dy() 151 | 152 | if self.reusableCanvas == nil { 153 | refWidth := max(width , self.logicalWidth + 1) // +1 because smooth movement will.. 154 | refHeight := max(height, self.logicalHeight + 1) // ..force this in most games anyways 155 | self.reusableCanvas = ebiten.NewImage(refWidth, refHeight) 156 | return utils.SubImage(self.reusableCanvas, 0, 0, width, height) 157 | } else { 158 | bounds := self.reusableCanvas.Bounds() 159 | availableWidth, availableHeight := bounds.Dx(), bounds.Dy() 160 | if width == availableWidth && height == availableHeight { 161 | if ebiten.IsScreenClearedEveryFrame() { 162 | self.reusableCanvas.Clear() // TODO: is this the best place to do it? 163 | } 164 | return self.reusableCanvas 165 | } else if width <= availableWidth && height <= availableHeight { 166 | canvas := utils.SubImage(self.reusableCanvas, 0, 0, width, height) 167 | if ebiten.IsScreenClearedEveryFrame() { canvas.Clear() } // TODO: is this the best place to do it? 168 | return canvas 169 | } else { // insufficient width or height 170 | // taking into account target zoom will make it easier to pre-request 171 | // a single bigger canvas without having to thrash the GPU memory 172 | // continuously through a zoom-out transition. 173 | zoomTarget := min(max(self.zoomTarget, 0.05), 1.0) 174 | refWidth := int(math.Ceil(float64(width + 1.0)/zoomTarget)) 175 | refHeight := int(math.Ceil(float64(height + 1.0)/zoomTarget)) 176 | self.reusableCanvas = ebiten.NewImage(refWidth, refHeight) 177 | return utils.SubImage(self.reusableCanvas, 0, 0, width, height) 178 | } 179 | } 180 | } 181 | 182 | func (self *controller) getActiveHiResCanvas(hiResCanvas *ebiten.Image) *ebiten.Image { 183 | // trivial case if stretching is used 184 | if self.stretchingEnabled { return hiResCanvas } 185 | 186 | // crop margins based on aspect ratios 187 | hiBounds := hiResCanvas.Bounds() 188 | hiWidth, hiHeight := hiBounds.Dx(), hiBounds.Dy() 189 | hiAspectRatio := float64(hiWidth)/float64(hiHeight) 190 | loAspectRatio := float64(self.logicalWidth)/float64(self.logicalHeight) 191 | 192 | switch { 193 | case hiAspectRatio == loAspectRatio: // just scaling 194 | return hiResCanvas 195 | case hiAspectRatio > loAspectRatio: // horz margins 196 | xMargin := int((float64(hiWidth) - loAspectRatio*float64(hiHeight))/2.0) 197 | return utils.SubImage(hiResCanvas, xMargin, 0, hiWidth - xMargin, hiHeight) 198 | case loAspectRatio > hiAspectRatio: // vert margins 199 | yMargin := int((float64(hiHeight) - float64(hiWidth)/loAspectRatio)/2.0) 200 | return utils.SubImage(hiResCanvas, 0, yMargin, hiWidth, hiHeight - yMargin) 201 | default: 202 | panic("unreachable") 203 | } 204 | } 205 | 206 | func (self *controller) Layout(logicWinWidth, logicWinHeight int) (int, int) { 207 | monitor := ebiten.Monitor() 208 | scale := monitor.DeviceScaleFactor() 209 | hiResWidth := int(float64(logicWinWidth)*scale) 210 | hiResHeight := int(float64(logicWinHeight)*scale) 211 | if hiResWidth != self.hiResWidth || hiResHeight != self.hiResHeight { 212 | self.layoutHasChanged = true 213 | self.needsRedraw = true 214 | self.hiResWidth, self.hiResHeight = hiResWidth, hiResHeight 215 | } 216 | return self.hiResWidth, self.hiResHeight 217 | } 218 | 219 | func (self *controller) LayoutF(logicWinWidth, logicWinHeight float64) (float64, float64) { 220 | monitor := ebiten.Monitor() 221 | scale := monitor.DeviceScaleFactor() 222 | outWidth := math.Ceil(logicWinWidth*scale) 223 | outHeight := math.Ceil(logicWinHeight*scale) 224 | if int(outWidth) != self.hiResWidth || int(outHeight) != self.hiResHeight { 225 | self.layoutHasChanged = true 226 | self.needsRedraw = true 227 | self.hiResWidth, self.hiResHeight = int(outWidth), int(outHeight) 228 | } 229 | return outWidth, outHeight 230 | } 231 | 232 | // --- run and queued draws --- 233 | 234 | func (self *controller) run(game Game) error { 235 | self.game = game 236 | if self.logicalWidth == 0 || self.logicalHeight == 0 { 237 | panic("must set the game resolution with mipix.SetResolution(width, height) before mipix.Run()") 238 | } 239 | self.trackerCurrentX = self.trackerTargetX 240 | self.trackerCurrentY = self.trackerTargetY 241 | return ebiten.RunGame(self) 242 | } 243 | 244 | // --- resolution --- 245 | 246 | func (self *controller) getResolution() (width, height int) { 247 | return self.logicalWidth, self.logicalHeight 248 | } 249 | 250 | func (self *controller) setResolution(width, height int) { 251 | if self.inDraw { panic("can't change resolution during draw stage") } 252 | if width < 1 || height < 1 { panic("game resolution must be at least (1, 1)") } 253 | if width != self.logicalWidth || height != self.logicalHeight { 254 | self.needsRedraw = true 255 | self.logicalWidth, self.logicalHeight = width, height 256 | internal.BridgedLogicalWidth, internal.BridgedLogicalHeight = width, height // hyper massive hack 257 | self.updateCameraArea() 258 | } 259 | } 260 | 261 | // --- scaling --- 262 | 263 | func (self *controller) scalingSetFilter(filter ScalingFilter) { 264 | if self.inDraw { panic("can't change scaling filter during draw stage") } 265 | if filter != self.scalingFilter { 266 | self.needsRedraw = true 267 | self.scalingFilter = filter 268 | } 269 | if self.shaders[filter] == nil { 270 | self.compileShader(filter) 271 | } 272 | } 273 | 274 | func (self *controller) scalingGetFilter() ScalingFilter { 275 | return self.scalingFilter 276 | } 277 | 278 | func (self *controller) scalingSetStretchingAllowed(allowed bool) { 279 | if self.inDraw { panic("can't change stretching mode during draw stage") } 280 | if allowed != self.stretchingEnabled { 281 | self.needsRedraw = true 282 | self.stretchingEnabled = allowed 283 | if !allowed { self.needsClear = true } 284 | } 285 | } 286 | 287 | func (self *controller) scalingGetStretchingAllowed() bool { 288 | return self.stretchingEnabled 289 | } 290 | 291 | // --- redraw --- 292 | 293 | func (self *controller) redrawSetManaged(managed bool) { 294 | if self.inDraw { panic("can't change redraw management during draw stage") } 295 | self.redrawManaged = managed 296 | } 297 | 298 | func (self *controller) redrawIsManaged() bool { 299 | return self.redrawManaged 300 | } 301 | 302 | func (self *controller) redrawRequest() { 303 | if self.inDraw { panic("can't request redraw during draw stage") } 304 | self.needsRedraw = true 305 | } 306 | 307 | func (self *controller) redrawPending() bool { 308 | return self.needsRedraw || !self.redrawManaged 309 | } 310 | 311 | func (self *controller) redrawScheduleClear() { 312 | self.needsClear = true 313 | } 314 | 315 | // --- hi res --- 316 | 317 | func (self *controller) hiResDraw(target, source *ebiten.Image, x, y float64) { 318 | if !self.inDraw { panic("can't mipix.HiRes().Draw() outside draw stage") } 319 | self.internalHiResDraw(target, source, x, y, false) 320 | } 321 | 322 | func (self *controller) hiResDrawHorzFlip(target, source *ebiten.Image, x, y float64) { 323 | if !self.inDraw { panic("can't mipix.HiRes().DrawHorzFlip() outside draw stage") } 324 | self.internalHiResDraw(target, source, x, y, true) 325 | } 326 | 327 | func (self *controller) hiResFillOverRect(target *ebiten.Image, minX, minY, maxX, maxY float64, fillColor color.Color) { 328 | targetBounds := target.Bounds() 329 | targetWidth, targetHeight := float64(targetBounds.Dx()), float64(targetBounds.Dy()) 330 | hrMinX, hrMinY := self.logicalToHiResCanvasCoords(minX, minY, targetWidth, targetHeight) 331 | hrMaxX, hrMaxY := self.logicalToHiResCanvasCoords(maxX, maxY, targetWidth, targetHeight) 332 | ox, oy := float64(targetBounds.Min.X), float64(targetBounds.Min.Y) 333 | 334 | xl, xr := float32(hrMinX + ox), float32(hrMaxX + ox) 335 | yt, yb := float32(hrMinY + oy), float32(hrMaxY + oy) 336 | internal.FillOverRectF32(target, xl, yt, xr, yb, fillColor) 337 | } 338 | 339 | // TODO: something like this is not only important, but should be exposed directly, 340 | // both for low res and high res, honestly. Or on Convert(). 341 | func (self *controller) logicalToHiResCanvasCoords(x, y, targetWidth, targetHeight float64) (float64, float64) { 342 | camMinX, camMinY, camMaxX, camMaxY := self.cameraAreaF64() 343 | x64, y64 := float64(x), float64(y) 344 | xOffset, yOffset := x64 - camMinX, y64 - camMinY 345 | return targetWidth*(xOffset/(camMaxX - camMinX)), targetHeight*(yOffset/(camMaxY - camMinY)) 346 | } 347 | 348 | func (self *controller) internalHiResDraw(target, source *ebiten.Image, x, y float64, horzFlip bool) { 349 | // view culling 350 | camMinX, camMinY, camMaxX, camMaxY := self.cameraAreaF64() 351 | if x > camMaxX || y > camMaxY { return } 352 | sourceBounds := source.Bounds() 353 | sourceWidth, sourceHeight := float64(sourceBounds.Dx()), float64(sourceBounds.Dy()) 354 | if x + sourceWidth < camMinX { return } // outside view 355 | if y + sourceHeight < camMinY { return } // outside view 356 | 357 | // compile shader if necessary 358 | if self.shaders[self.scalingFilter] == nil { 359 | self.compileShader(self.scalingFilter) 360 | } 361 | 362 | // set triangle vertex coordinates 363 | targetBounds := target.Bounds() 364 | targetMinX, targetMinY := float64(targetBounds.Min.X), float64(targetBounds.Min.Y) 365 | targetWidth, targetHeight := float64(targetBounds.Dx()), float64(targetBounds.Dy()) 366 | xFactor := self.zoomCurrent*targetWidth/float64(self.logicalWidth) 367 | yFactor := self.zoomCurrent*targetHeight/float64(self.logicalHeight) 368 | srcProjMinX := (x - camMinX)*xFactor 369 | srcProjMinY := (y - camMinY)*yFactor 370 | srcProjMaxX := srcProjMinX + sourceWidth*xFactor 371 | srcProjMaxY := srcProjMinY + sourceHeight*yFactor 372 | left, right := float32(targetMinX + srcProjMinX), float32(targetMinX + srcProjMaxX) 373 | if horzFlip { left, right = right, left } 374 | self.shaderVertices[0].DstX = left 375 | self.shaderVertices[0].DstY = float32(targetMinY + srcProjMinY) 376 | self.shaderVertices[1].DstX = right 377 | self.shaderVertices[1].DstY = self.shaderVertices[0].DstY 378 | self.shaderVertices[2].DstX = self.shaderVertices[1].DstX 379 | self.shaderVertices[2].DstY = float32(targetMinY + srcProjMaxY) 380 | self.shaderVertices[3].DstX = self.shaderVertices[0].DstX 381 | self.shaderVertices[3].DstY = self.shaderVertices[2].DstY 382 | 383 | self.shaderVertices[0].SrcX = float32(sourceBounds.Min.X) 384 | self.shaderVertices[0].SrcY = float32(sourceBounds.Min.Y) 385 | self.shaderVertices[1].SrcX = float32(sourceBounds.Max.X) 386 | self.shaderVertices[1].SrcY = self.shaderVertices[0].SrcY 387 | self.shaderVertices[2].SrcX = self.shaderVertices[1].SrcX 388 | self.shaderVertices[2].SrcY = float32(sourceBounds.Max.Y) 389 | self.shaderVertices[3].SrcX = self.shaderVertices[0].SrcX 390 | self.shaderVertices[3].SrcY = self.shaderVertices[2].SrcY 391 | 392 | self.shaderOpts.Images[0] = source 393 | self.shaderOpts.Uniforms["SourceRelativeTextureUnitX"] = float32(float64(self.logicalWidth)/targetWidth) 394 | self.shaderOpts.Uniforms["SourceRelativeTextureUnitY"] = float32(float64(self.logicalHeight)/targetHeight) 395 | target.DrawTrianglesShader( 396 | self.shaderVertices, self.shaderVertIndices, 397 | self.shaders[self.scalingFilter], &self.shaderOpts, 398 | ) 399 | self.shaderOpts.Images[0] = nil 400 | } 401 | -------------------------------------------------------------------------------- /api_core.go: -------------------------------------------------------------------------------- 1 | package mipix 2 | 3 | import "fmt" 4 | import "image/color" 5 | 6 | import "github.com/hajimehoshi/ebiten/v2" 7 | 8 | var _ fmt.Formatter 9 | 10 | // --- game --- 11 | 12 | // The game interface for mipix, which is the equivalent to 13 | // [ebiten.Game] on Ebitengine but without the Layout() method. 14 | type Game interface { 15 | // Updates the game logic. 16 | // 17 | // You can implement this almost exactly in the same 18 | // way you would do for a pure Ebitengine game. 19 | Update() error 20 | 21 | // Draws the game contents. 22 | // 23 | // Unlike Ebitengine, the canvas you receive from mipix can vary in 24 | // size depending on the zoom level or camera position, but it always 25 | // represents pixels one-to-one. Your mindset should be ignoring the 26 | // canvas size and focusing on "rendering the game area specified by 27 | // mipix.Camera().Area()". 28 | Draw(logicalCanvas *ebiten.Image) 29 | } 30 | 31 | // Equivalent to [ebiten.RunGame](), but expecting a mipix [Game] 32 | // instead of an [ebiten.Game]. 33 | // 34 | // Will panic if invoked before [SetResolution](). 35 | func Run(game Game) error { 36 | return pkgController.run(game) 37 | } 38 | 39 | // --- core --- 40 | 41 | // Returns the game's base resolution. See [SetResolution]() 42 | // for more details. 43 | func GetResolution() (width, height int) { 44 | return pkgController.getResolution() 45 | } 46 | 47 | // Sets the game's base resolution. This defines the game's 48 | // aspect ratio and logical canvas size at integer coordinates 49 | // and zoom = 1.0. 50 | func SetResolution(width, height int) { 51 | pkgController.setResolution(width, height) 52 | } 53 | 54 | // Ignore this function unless you are already using [QueueHiResDraw](). 55 | // This function is only relevant when trying to interleave logical 56 | // and high resolution draws. 57 | // 58 | // The canvas passed to the callback will be preemptively cleared if 59 | // the previous draw was a high resolution draw. 60 | // 61 | // Must only be called from [Game].Draw() or successive draw callbacks. 62 | func QueueDraw(handler func(logicalCanvas *ebiten.Image)) { 63 | pkgController.queueDraw(handler) 64 | } 65 | 66 | // Schedules the given handler to be invoked after the current 67 | // drawing function and any other queued draws finish. 68 | // 69 | // The viewport passed to the handler is the full game screen canvas, 70 | // including any possibly unused borders, while hiResCanvas is a subimage 71 | // corresponding to the active area of the viewport. 72 | // 73 | // Using this function is necessary if you want to render high resolution 74 | // graphics. This includes vectorial text, some UI, smoothly moving entities, 75 | // shader effects and more. 76 | // 77 | // Must only be called from [Game].Draw() or successive draw callbacks. 78 | // See also [QueueDraw](). 79 | func QueueHiResDraw(handler func(viewport, hiResCanvas *ebiten.Image)) { 80 | pkgController.queueHiResDraw(handler) 81 | } 82 | 83 | // Returns whether a layout change has happened on the current tick. 84 | // Layout changes happen whenever the game window is resized in windowed 85 | // mode, the game switches between windowed and fullscreen modes, or 86 | // the device scale factor changes (possibly due to a monitor change). 87 | // 88 | // This function is only relevant if you need to redraw game borders manually 89 | // and efficiently or resize offscreens. Even if you have [AccessorRedraw.SetManaged](true), 90 | // you rarely need to worry about the state of the layout; any changes will 91 | // automatically trigger a redraw request. 92 | func LayoutHasChanged() bool { 93 | return pkgController.layoutHasChanged 94 | } 95 | 96 | // --- high resolution drawing --- 97 | 98 | // See [HiRes](). 99 | type AccessorHiRes struct{} 100 | 101 | // Provides access to high resolution drawing methods in 102 | // a structured manner. Use through method chaining, e.g.: 103 | // mipix.HiRes().Draw(target, source, x, y) 104 | func HiRes() AccessorHiRes { return AccessorHiRes{} } 105 | 106 | // Draws the source into the given target at the given global logical 107 | // coordinates (camera origin is automatically subtracted). 108 | // 109 | // Notice that mipix's main focus is not high resolution drawing, and this 110 | // method is not expected to be used more than a dozen times per frame. 111 | // If you are only drawing the main character or a few entities at floating 112 | // point positions, using this method should be fine. If you are trying to 113 | // draw every element of your game with this, or relying on this for a 114 | // particle system, you are misusing mipix. 115 | // 116 | // Many more high resolution drawing features could be provided, and some 117 | // might be added in the future, but this is not the main goal of the project. 118 | // 119 | // All that being said, this is not a recommendation to avoid this method. 120 | // This method is perfectly functional and a very practical tool in many 121 | // scenarios. 122 | func (self AccessorHiRes) Draw(target, source *ebiten.Image, x, y float64) { 123 | pkgController.hiResDraw(target, source, x, y) 124 | } 125 | 126 | // Similar to [AccessorHiRes.Draw](), but horizontally flipped. 127 | func (self AccessorHiRes) DrawHorzFlip(target, source *ebiten.Image, x, y float64) { 128 | pkgController.hiResDrawHorzFlip(target, source, x, y) 129 | } 130 | 131 | // Fills the logical area designated by the given coordinates with fillColor. 132 | // If you need fills with alpha blending directly without high resolution, 133 | // see the utils subpackage. 134 | func (self AccessorHiRes) FillOverRect(target *ebiten.Image, minX, minY, maxX, maxY float64, fillColor color.Color) { 135 | pkgController.hiResFillOverRect(target, minX, minY, maxX, maxY, fillColor) 136 | } 137 | 138 | // --- scaling --- 139 | 140 | // See [Scaling](). 141 | type AccessorScaling struct{} 142 | 143 | // Provides access to scaling-related functionality in a structured 144 | // manner. Use through method chaining, e.g.: 145 | // mipix.Scaling().SetFilter(mipix.Hermite) 146 | func Scaling() AccessorScaling { return AccessorScaling{} } 147 | 148 | // See [AccessorScaling.SetFilter](). 149 | // 150 | // Multiple filter options are provided mostly as comparison points. 151 | // In general, sticking to [AASamplingSoft] is recommended. 152 | type ScalingFilter uint8 153 | const ( 154 | // Anti-aliased pixel art point sampling. Good default, reasonably 155 | // performant, decent balance between sharpness and stability during 156 | // zooms and small movements. 157 | AASamplingSoft ScalingFilter = iota 158 | 159 | // Like AASamplingSoft, but slightly sharper and slightly less stable 160 | // during zooms and small movements. 161 | AASamplingSharp 162 | 163 | // No interpolation. Sharpest and fastest filter, but can lead 164 | // to distorted geometry. Very unstable, zooming and small movements 165 | // will be really jumpy and ugly. 166 | Nearest 167 | 168 | // Slightly blurrier than AASamplingSoft and more unstable than 169 | // AASamplingSharp. Still provides fairly decent results at 170 | // reasonable performance. 171 | Hermite 172 | 173 | // The most expensive filter by quite a lot. Slightly less sharp than 174 | // Hermite, but quite a bit more stable. Might slightly misrepresent 175 | // some colors throughout high contrast areas. 176 | Bicubic 177 | 178 | // Offered mostly for comparison purposes. Slightly blurrier than 179 | // Hermite, but quite a bit more stable. 180 | Bilinear 181 | 182 | // Offered for comparison purposes only. Non high-resolution aware 183 | // scaling filter, more similar to what naive scaling will look like. 184 | SrcHermite 185 | 186 | // Offered for comparison purposes only. Non high-resolution aware 187 | // scaling filter, more similar to what naive scaling will look like. 188 | SrcBicubic 189 | 190 | // Offered for comparison purposes only. Non high-resolution aware 191 | // scaling filter, more similar to what naive scaling will look like. 192 | // This is what Ebitengine will do by default with the FilterLinear 193 | // filter. 194 | SrcBilinear 195 | 196 | scalingFilterEndSentinel 197 | ) 198 | 199 | // Returns a string representation of the scaling filter. 200 | func (self ScalingFilter) String() string { 201 | switch self { 202 | case AASamplingSoft : return "AASamplingSoft" 203 | case AASamplingSharp : return "AASamplingSharp" 204 | case Nearest : return "Nearest" 205 | case Hermite : return "Hermite" 206 | case Bicubic : return "Bicubic" 207 | case Bilinear : return "Bilinear" 208 | case SrcHermite : return "SrcHermite" 209 | case SrcBicubic : return "SrcBicubic" 210 | case SrcBilinear : return "SrcBilinear" 211 | default: 212 | panic("invalid ScalingFilter") 213 | } 214 | } 215 | 216 | // Set to true to avoid black borders and completely fill the screen 217 | // no matter how ugly it gets. By default, stretching is disabled. In 218 | // general you only want to expose stretching as a setting for players; 219 | // don't set it to true on your own. 220 | // 221 | // Must only be called during initialization or [Game].Update(). 222 | func (AccessorScaling) SetStretchingAllowed(allowed bool) { 223 | pkgController.scalingSetStretchingAllowed(allowed) 224 | } 225 | 226 | // Returns whether stretching is allowed for screen scaling. 227 | // See [AccessorScaling.SetStretchingAllowed]() for more details. 228 | func (AccessorScaling) GetStretchingAllowed() bool { 229 | return pkgController.scalingGetStretchingAllowed() 230 | } 231 | 232 | // Changes the scaling filter. The default is [AASamplingSoft]. 233 | // 234 | // Must only be called during initialization or [Game].Update(). 235 | // 236 | // The first time you set a filter explicitly, its shader will also 237 | // be compiled. This means that this function can be effectively used 238 | // to precompile the relevant shaders. Otherwise, the shader will be 239 | // compiled the first time it has to be used. 240 | func (AccessorScaling) SetFilter(filter ScalingFilter) { 241 | pkgController.scalingSetFilter(filter) 242 | } 243 | 244 | // Returns the current scaling filter. The default is [AASamplingSoft]. 245 | func (AccessorScaling) GetFilter() ScalingFilter { 246 | return pkgController.scalingGetFilter() 247 | } 248 | 249 | // --- conversions --- 250 | 251 | // See [Convert](). 252 | type AccessorConvert struct{} 253 | 254 | // Provides access to coordinate conversions in a structured 255 | // manner. Use through method chaining, e.g.: 256 | // cx, cy := ebiten.CursorPosition() 257 | // lx, ly := mipix.Convert().ToLogicalCoords(cx, cy) 258 | func Convert() AccessorConvert { return AccessorConvert{} } 259 | 260 | // Transforms coordinates obtained from [ebiten.CursorPosition]() and 261 | // similar functions to coordinates within the game's global logical 262 | // space. 263 | // 264 | // Commonly used to see what is being clicked on the game's world. 265 | func (AccessorConvert) ToLogicalCoords(x, y int) (float64, float64) { 266 | return pkgController.convertToLogicalCoords(x, y) 267 | } 268 | 269 | // Transforms coordinates obtained from [ebiten.CursorPosition]() and 270 | // similar functions to relative screen coordinates between 0 and 1. 271 | // 272 | // Commonly used to see what is being clicked on the game's UI or 273 | // applying fancy shaders and effects that depend on the cursor's 274 | // relative position on screen. 275 | func (AccessorConvert) ToRelativeCoords(x, y int) (float64, float64) { 276 | return pkgController.convertToRelativeCoords(x, y) 277 | } 278 | 279 | // Transforms coordinates obtained from [ebiten.CursorPosition]() and 280 | // similar functions to screen coordinates rescaled between (0, 0) and 281 | // (GameWidth, GameHeight). 282 | // 283 | // Commonly used to see what is being clicked on the game's UI (when 284 | // the UI is pure pixel art). 285 | func (AccessorConvert) ToGameResolution(x, y int) (float64, float64) { 286 | return pkgController.convertToGameResolution(x, y) 287 | } 288 | 289 | // --- debug --- 290 | 291 | // See [Debug](). 292 | type AccessorDebug struct{} 293 | 294 | // Provides access to debugging functionality in a structured 295 | // manner. Use through method chaining, e.g.: 296 | // mipix.Debug().Drawf("current tick: %d", mipix.Tick().Now()) 297 | func Debug() AccessorDebug { return AccessorDebug{} } 298 | 299 | // Similar to Printf debugging, but drawing the text on the top 300 | // left of the screen (instead of printing on the terminal). 301 | // Multi-line text is not supported; use multiple Drawf commands 302 | // in sequence instead. 303 | // 304 | // You can call this function at any point, even during [Game].Update(). 305 | // Strings will be queued and rendered at the end of the next draw. 306 | func (AccessorDebug) Drawf(format string, args ...any) { 307 | pkgController.debugDrawf(format, args...) 308 | } 309 | 310 | // Similar to [fmt.Printf](), but expects two tick counts as the first 311 | // arguments. The function will only print during the period elapsed 312 | // between those two tick counts. 313 | // Some examples: 314 | // mipix.Debug().Printfr(0, 0, "only print on the first tick\n") 315 | // mipix.Debug().Printfr(180, 300, "print from 3s to 5s lapse\n") 316 | func (AccessorDebug) Printfr(firstTick, lastTick uint64, format string, args ...any) { 317 | pkgController.debugPrintfr(firstTick, lastTick, format, args...) 318 | } 319 | 320 | // Similar to [fmt.Printf](), but only prints every N ticks. For 321 | // example, in most games using N = 60 will lead to print once 322 | // per second. 323 | func (AccessorDebug) Printfe(everyNTicks uint64, format string, args ...any) { 324 | // 61 is prime 325 | pkgController.debugPrintfe(everyNTicks, format, args...) 326 | } 327 | 328 | // Similar to [fmt.Printf](), but only prints if the given key is pressed. 329 | // Common keys: [ebiten.KeyShiftLeft], [ebiten.KeyControl], [ebiten.KeyDigit1]. 330 | func (AccessorDebug) Printfk(key ebiten.Key, format string, args ...any) { 331 | pkgController.debugPrintfk(key, format, args...) 332 | } 333 | 334 | // --- ticks --- 335 | 336 | // See [Tick](). 337 | type AccessorTick struct{} 338 | 339 | // Provides access to game tick functions in a structured 340 | // manner. Use through method chaining, e.g.: 341 | // currentTick := mipix.Tick().Now() 342 | func Tick() AccessorTick { return AccessorTick{} } 343 | 344 | // Returns the current tick. 345 | func (AccessorTick) Now() uint64 { 346 | return pkgController.tickNow() 347 | } 348 | 349 | // Returns the updates per second. This is [ebiten.TPS](), 350 | // but mipix considers a more advanced model for [ticks 351 | // and updates]. 352 | // 353 | // [ticks and updates]: https://github.com/tinne26/mipix/blob/main/docs/ups-vs-tps.md 354 | func (AccessorTick) UPS() int { 355 | return ebiten.TPS() 356 | } 357 | 358 | // This is just [ebiten.SetTPS]() under the hood, but mipix 359 | // considers a more advanced model for [ticks and updates]. 360 | // 361 | // [ticks and updates]: https://github.com/tinne26/mipix/blob/main/docs/ups-vs-tps.md 362 | func (AccessorTick) SetUPS(updatesPerSecond int) { 363 | ebiten.SetTPS(updatesPerSecond) 364 | } 365 | 366 | // Returns the ticks per second. This is UPS()*TickRate. 367 | // Notice that this is not [ebiten.TPS](), as mipix considers 368 | // a more advanced model for [ticks and updates]. 369 | // 370 | // [ticks and updates]: https://github.com/tinne26/mipix/blob/main/docs/ups-vs-tps.md 371 | func (AccessorTick) TPS() int { 372 | return ebiten.TPS()*int(pkgController.tickRate) 373 | } 374 | 375 | // Sets the tick rate (ticks per update, often refered to as "TPU"). 376 | // Notice that this is not [ebiten.SetTPS](), as mipix considers 377 | // a more advanced model for [ticks and updates]. 378 | // 379 | // [ticks and updates]: https://github.com/tinne26/mipix/blob/main/docs/ups-vs-tps.md 380 | func (AccessorTick) SetRate(tickRate int) { 381 | pkgController.tickSetRate(tickRate) 382 | } 383 | 384 | // Returns the current tick rate. Defaults to 1. 385 | // See [AccessorTick.SetRate]() for more context. 386 | func (AccessorTick) GetRate() int { 387 | return pkgController.tickGetRate() 388 | } 389 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction to mipix 2 | 3 | Key ideas to understand mipix's model, plus a few code samples to illustrate each concept. 4 | 5 | ## Terminology 6 | 7 | You may skip this section, it's only a clarification for some people that get confused about some terms: 8 | - *Logical vs high resolution*: when talking about "logical" or "low resolution" spaces, coordinates or canvases, we refer to pure pixel units, where pixel art maps 1-to-1 to our canvas, space or coordinates. In other words: 1 unit of our canvas, space or coordinates match 1 pixel of our art. "High resolution" or "screen" spaces, coordinates and canvases, instead, have an arbitrary size and will require scaling or projections. 9 | - *Filters*: filters in the context of graphics often refer to shader effects like vignetting, lens, color transformations and others. In the context of mipix, though, the documentation always uses the word to refer to *scaling filters* instead, which are all about performing color interpolations for projections. These are completely different things, but it's easy to mix them up due to the same word being used. 10 | 11 | ## Empty game and `mipix.SetResolution()` 12 | 13 | ```Golang 14 | package main 15 | 16 | import "github.com/hajimehoshi/ebiten/v2" 17 | import "github.com/tinne26/mipix" 18 | 19 | type Game struct {} 20 | 21 | func (game *Game) Update() error { 22 | return nil 23 | } 24 | 25 | func (game *Game) Draw(canvas *ebiten.Image) { 26 | // ... 27 | } 28 | 29 | func main() { 30 | mipix.SetResolution(128, 72) 31 | err := mipix.Run(&Game{}) 32 | if err != nil { panic(err) } 33 | } 34 | ``` 35 | 36 | Key highlights: 37 | - Our game struct must satisfy the [`mipix.Game`](https://pkg.go.dev/github.com/tinne26/mipix#Game) interface, which is just like Ebitengine's, but without the `Layout()` method. 38 | - You have to use [`mipix.Run()`](https://pkg.go.dev/github.com/tinne26/mipix#Run) instead of [`ebiten.RunGame()`](https://pkg.go.dev/github.com/hajimehoshi/ebiten/v2#RunGame). 39 | - You must always set the game's resolution before [`mipix.Run()`](https://pkg.go.dev/github.com/tinne26/mipix#Run) through [`mipix.SetResolution()`](https://pkg.go.dev/github.com/tinne26/mipix#SetResolution). More advice on how to choose resolutions on the footnotes[^1]. 40 | 41 | [^1]: Based on [Steam's surveys](https://store.steampowered.com/hwsurvey) the most common display resolution is by far 1920x1080 (full HD), with over 50% share as of 2024. If you are going for a 16:9 aspect ratio, then you should choose a resolution that's a divisor of 1920x1080, like 128x72, 160x90, 320x180, 384x216 (\*), 480x270 (\*), 640x360, 960x540 (\*) and so on (the ones marked with (\*) are not perfectly compatible with QHD). For other aspect ratios, you will typically go more square-ish (less wide), in which case black bars on the sides are unavoidable on common displays. Here you should try to stick to common divisors of the most used vertical resolutions instead. For example, 360 is the greatest common divisor of 1080 (full HD) and 1440 (QHD), so that would be a great choice. Other divisors like 180, 120, 90 and 60 would be equally good choices. This is just basic advice, there are some exceptions to these rules. 42 | 43 | ## The draw model 44 | 45 | While the `Update()` method should be coded pretty much in the same way for both raw Ebitengine and mipix, the `Draw()` method is a different story. 46 | 47 | Here are the two key points you really have to engrave in your soul: 48 | - The canvas you receive on `Draw()` represents pixels 1-to-1. Your pixel art must be drawn directly to the canvas, in its original size. Forget about display scaling factors and projections... *Just draw your pixel art*. 49 | - The canvas you receive on `Draw()` does *not* always have the same size, and it does *not* necessarily match the resolution you set for your game with `mipix.SetResolution(W, H)`. This can happen due to zoom effects (which mipix handles internally), but also something as simple as moving around. For example: even if your resolution is 128x72, if you move around in any direction you might have to render half a pixel for one of the borders of the canvas and another half for the opposite one. In these cases, even without zoom, mipix would ask you to draw a 129x72, 128x73 or 129x73 area. In conclusion: you should not be thinking about "render my WxH canvas", but "render all the logical area requested by mipix" instead (which is given by `mipix.Camera().Area()`). 50 | - You can still render *some* elements at decimal positions and in high resolution, but we will touch on that later. 51 | 52 | Summarizing: draw things one-to-one, don't assume a specific canvas resolution, draw the area that mipix asks you to. 53 | 54 | **Simple image draw** 55 | ```Golang 56 | import "github.com/tinne26/mipix/utils" 57 | 58 | const PrawnOX, PrawnOY = -3, -3 // place any desired coords here 59 | var Prawn *ebiten.Image = utils.MaskToImage(6, []uint8{ 60 | 0, 0, 0, 0, 1, 0, // example low-res image 61 | 0, 0, 0, 0, 1, 1, 62 | 0, 0, 1, 1, 0, 0, 63 | 0, 1, 1, 1, 0, 0, 64 | 1, 1, 1, 0, 0, 0, 65 | 1, 1, 0, 0, 0, 0, 66 | }, utils.RGB(219, 86, 32)) 67 | 68 | func (game *Game) Draw(canvas *ebiten.Image) { 69 | // set some background color 70 | canvas.Fill(utils.RGB(255, 255, 255)) 71 | 72 | // obtain the camera area that mipix is asking 73 | // us to draw. this is the most critical function 74 | // that has to be used when drawing with mipix 75 | camArea := mipix.Camera().Area() 76 | 77 | // see if our content overlaps the area we need to 78 | // draw, and if it does, we subtract the camera 79 | // origin coordinates to our object's global coords 80 | prawnGlobalRect := utils.Shift(Prawn.Bounds(), PrawnOX, PrawnOY) 81 | if prawnGlobalRect.Overlaps(camArea) { 82 | // translate from global to local (canvas) coordinates 83 | prawnLocalRect := prawnGlobalRect.Sub(camArea.Min) 84 | 85 | // create DrawImageOptions and apply draw position 86 | var opts ebiten.DrawImageOptions 87 | tx := prawnLocalRect.Min.X 88 | ty := prawnLocalRect.Min.Y 89 | opts.GeoM.Translate(float64(tx), float64(ty)) 90 | canvas.DrawImage(Prawn, &opts) 91 | } 92 | } 93 | ``` 94 | [*(full example code here)*](https://github.com/tinne26/mipix-examples/tree/main/src/tutorial/draw_image) 95 | 96 | Notice that the image will be drawn at the center of the screen in this example. This is because the camera target defaults to (0, 0), and our image is 6x6 but we set its origin at (-3, -3). 97 | 98 | You can shorten the code using some [`mipix/utils`](https://pkg.go.dev/github.com/tinne26/mipix/utils) helpers, but make sure to understand the general approach first. 99 | ```Golang 100 | prawnGlobalRect := utils.Shift(Prawn.Bounds(), PrawnOX, PrawnOY) 101 | if prawnGlobalRect.Overlaps(camArea) { 102 | opts := utils.DrawImageOptionsAt(Prawn, PrawnOX, PrawnOY) 103 | canvas.DrawImage(Prawn, &opts) 104 | } 105 | ``` 106 | 107 | **Simple rectangle fill** 108 | ```Golang 109 | import "github.com/tinne26/mipix/utils" 110 | 111 | const RectCX, RectCY = 0, 0 // place any desired coords here 112 | var SomeRect = utils.Rect(RectCX - 1, RectCY - 1, RectCX + 1, RectCY + 1) 113 | var RectColor = utils.RGBA(128, 128, 128, 128) 114 | 115 | func (game *Game) Draw(canvas *ebiten.Image) { 116 | // white background fill 117 | canvas.Fill(utils.RGB(255, 255, 255)) 118 | 119 | // fill the rect, which is defined in logical global coords 120 | camArea := mipix.Camera().Area() 121 | if SomeRect.Overlaps(camArea) { 122 | localRect := SomeRect.Sub(camArea.Min) 123 | utils.FillOverRect(canvas, localRect, RectColor) 124 | } 125 | } 126 | ``` 127 | [*(full example code here)*](https://github.com/tinne26/mipix-examples/tree/main/src/tutorial/draw_rect) 128 | 129 | ## Accessors and camera movement 130 | 131 | Now that we have learned how to draw some content on screen, we can try to move the camera around. 132 | 133 | Most of mipix's functionality is grouped on "accessors". Accessors are just dummy types used to group together related functionality. The most important accessor is the [`AccessorCamera`](https://pkg.go.dev/github.com/tinne26/mipix#AccessorCamera), which gives you access to zoom, shakes, camera position updates and the most important of all: the rect of the currently visible area, which is what we were already using in previous examples to know what to draw. 134 | 135 | So now let's see how to make the camera move. For this example, the `Update()` method will implement some basic movement logic, while the `Draw()` method will just render a small square at (0, 0) that can serve as a visual reference while we move around. 136 | 137 | ```Golang 138 | package main 139 | 140 | import "github.com/hajimehoshi/ebiten/v2" 141 | import "github.com/tinne26/mipix" 142 | import "github.com/tinne26/mipix/utils" 143 | 144 | type Game struct { 145 | LookAtX, LookAtY float64 146 | } 147 | 148 | func (game *Game) Update() error { 149 | // detect directions 150 | up := ebiten.IsKeyPressed(ebiten.KeyArrowUp) 151 | down := ebiten.IsKeyPressed(ebiten.KeyArrowDown) 152 | left := ebiten.IsKeyPressed(ebiten.KeyArrowLeft) 153 | right := ebiten.IsKeyPressed(ebiten.KeyArrowRight) 154 | if up && down { up , down = false, false } 155 | if left && right { left, right = false, false } 156 | 157 | // apply diagonal speed reduction if needed 158 | var speed float64 = 0.2 159 | if (up || down) && (left || right) { 160 | speed *= 0.7 161 | } 162 | 163 | // apply speed to camera target 164 | if up { game.LookAtY -= speed } 165 | if down { game.LookAtY += speed } 166 | if left { game.LookAtX -= speed } 167 | if right { game.LookAtX += speed } 168 | 169 | // notify new camera target 170 | mipix.Camera().NotifyCoordinates(game.LookAtX, game.LookAtY) 171 | 172 | return nil 173 | } 174 | 175 | func (game *Game) Draw(canvas *ebiten.Image) { 176 | // fill background 177 | canvas.Fill(utils.RGB(255, 255, 255)) 178 | 179 | // draw 2x2 square centered at (0, 0) 180 | camArea := mipix.Camera().Area() 181 | centerRect := utils.Rect(-1, -1, 1, 1) 182 | if centerRect.Overlaps(camArea) { 183 | drawRect := centerRect.Sub(camArea.Min) 184 | utils.FillOverRect(canvas, drawRect, utils.RGB(200, 0, 200)) 185 | } 186 | } 187 | 188 | func main() { 189 | mipix.SetResolution(128, 72) 190 | err := mipix.Run(&Game{}) 191 | if err != nil { panic(err) } 192 | } 193 | ``` 194 | 195 | The camera following behavior is defined by the [`Tracker`](https://pkg.go.dev/github.com/tinne26/mipix#AccessorCamera.SetTracker) interface, which you can customize. You can also customize camera zooms and shakes. 196 | 197 | ## Cursor and touch positions 198 | 199 | When you are using mipix, mipix is requesting Ebitengine the highest resolution canvas it can possibly get, and then handling scaling internally, trying to not bother you with it. Unfortunately, there are still *some* visible side effects to this internal trickery. In particular, `ebiten.CursorPosition()` and screen touches will stop behaving as you might expect, since they will return coordinates for a high resolution screen that's kinda outside your mental model when working with mipix. 200 | 201 | To make life easier in the face of all this, mipix exposes an [`AccessorConvert`](https://pkg.go.dev/github.com/tinne26/mipix#AccessorConvert) with multiple functions to convert coordinates from high resolution to your logical space. 202 | 203 | ```Golang 204 | package main 205 | 206 | import "image/color" 207 | 208 | import "github.com/hajimehoshi/ebiten/v2" 209 | import "github.com/tinne26/mipix" 210 | 211 | type Game struct { 212 | HiCursorX, HiCursorY int 213 | LoCursorX, LoCursorY float64 214 | LoRelativeX, LoRelativeY float64 215 | LoGameX, LoGameY float64 216 | } 217 | 218 | func (game *Game) Update() error { 219 | hiX, hiY := ebiten.CursorPosition() 220 | loX, loY := mipix.Convert().ToLogicalCoords(hiX, hiY) 221 | reX, reY := mipix.Convert().ToRelativeCoords(hiX, hiY) 222 | gmX, gmY := mipix.Convert().ToGameResolution(hiX, hiY) 223 | game.HiCursorX, game.HiCursorY = hiX, hiY 224 | game.LoCursorX, game.LoCursorY = loX, loY 225 | game.LoRelativeX, game.LoRelativeY = reX, reY 226 | game.LoGameX, game.LoGameY = gmX, gmY 227 | return nil 228 | } 229 | 230 | func (game *Game) Draw(canvas *ebiten.Image) { 231 | canvas.Fill(color.RGBA{128, 128, 128, 255}) 232 | mipix.Debug().Drawf("[ Cursor Position ]") 233 | mipix.Debug().Drawf("High-res screen: (%d, %d)", game.HiCursorX, game.HiCursorY) 234 | mipix.Debug().Drawf("Low-res relative: (%.02f, %.02f)", game.LoRelativeX, game.LoRelativeY) 235 | mipix.Debug().Drawf("Low-res screen: (%.02f, %.02f)", game.LoGameX, game.LoGameY) 236 | mipix.Debug().Drawf("Low-res global: (%.02f, %.02f)", game.LoCursorX, game.LoCursorY) 237 | } 238 | 239 | func main() { 240 | ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) 241 | mipix.SetResolution(100, 100) 242 | err := mipix.Run(&Game{}) 243 | if err != nil { panic(err) } 244 | } 245 | ``` 246 | 247 | > [!TIP] 248 | > *You can try this example directly from your terminal with: 249 | > `go run github.com/tinne26/mipix-examples/src/tutorial/cursor_position@latest`* 250 | 251 | In this example, you can also see some debug functions, which are very useful for quick information display, both within the game or on your terminal. 252 | 253 | ## Multi-layered drawing 254 | 255 | While mipix focuses on low resolution rendering, you can also combine logical draws with high resolution draws when needed. Common uses for high resolution drawing are vectorial text rendering and UI, smoothly moving elements and shader effects. The next example includes both text rendering and a smoothly moving player. 256 | 257 | In order to make these interleaved draws, mipix exposes [`mipix.QueueDraw()`](https://pkg.go.dev/github.com/tinne26/mipix#QueueDraw) and [`mipix.QueueHiResDraw()`](https://pkg.go.dev/github.com/tinne26/mipix#QueueHiResDraw), which receive the drawing functions as parameters. These queueing functions can be invoked at any point of the draw stage, and will be triggered in order once the current drawing logic finishes. 258 | 259 | ```Golang 260 | package main 261 | 262 | import "github.com/hajimehoshi/ebiten/v2" 263 | import "github.com/tinne26/mipix" 264 | import "github.com/tinne26/mipix/utils" 265 | 266 | import "github.com/hajimehoshi/ebiten/v2/text/v2" 267 | import "golang.org/x/image/font/opentype" 268 | import "golang.org/x/image/font" 269 | import "github.com/tinne26/fonts/liberation/lbrtsans" 270 | 271 | // Helper type for decorative tiles 272 | type Grass struct { X, Y int } 273 | func (g Grass) Draw(canvas *ebiten.Image, cameraArea utils.Rectangle) { 274 | rect := utils.Rect(g.X*5, g.Y*5, g.X*5 + 5, g.Y*5 + 5) 275 | if rect.Overlaps(cameraArea) { 276 | fillRect := rect.Sub(cameraArea.Min) 277 | utils.FillOverRect(canvas, fillRect, utils.RGB(83, 141, 106)) 278 | } 279 | } 280 | 281 | // Main game struct 282 | type Game struct { 283 | PlayerCX, PlayerCY float64 284 | GrassTiles []Grass 285 | FontFace text.Face 286 | FontSize float64 287 | } 288 | 289 | func (game *Game) Update() error { 290 | // elided for brevity: same as the camera example but 291 | // using PlayerCX/PlayerCY instead of LookAtX/LookAtY 292 | } 293 | 294 | // This draw function exemplifies how to combine low 295 | // and high resolution draws with QueueHiResDraw() 296 | func (game *Game) Draw(canvas *ebiten.Image) { 297 | // fill background 298 | canvas.Fill(utils.RGB(128, 207, 169)) 299 | 300 | // draw grass on the logical canvas 301 | cameraArea := mipix.Camera().Area() 302 | for _, grass := range game.GrassTiles { 303 | grass.Draw(canvas, cameraArea) 304 | } 305 | 306 | // queue draw for player rect at high resolution 307 | mipix.QueueHiResDraw(func(_, hiResCanvas *ebiten.Image) { 308 | ox, oy := game.PlayerCX - 1.5, game.PlayerCY - 1.5 309 | fx, fy := game.PlayerCX + 1.5, game.PlayerCY + 1.5 310 | rgba := utils.RGB(66, 67, 66) 311 | mipix.HiRes().FillOverRect(hiResCanvas, ox, oy, fx, fy, rgba) 312 | }) 313 | 314 | // you could interleave more mipix.QueueDraw() 315 | // low-res draws here if you needed it 316 | 317 | // queue text rendering on high resolution too 318 | mipix.QueueHiResDraw(game.DrawText) 319 | } 320 | 321 | // High resolution text rendering function 322 | func (game *Game) DrawText(_, hiResCanvas *ebiten.Image) { 323 | // determine text size 324 | bounds := hiResCanvas.Bounds() 325 | height := float64(bounds.Dy()) 326 | fontSize := height/10.0 327 | 328 | // (re)initialize font face if necessary 329 | if game.FontSize != fontSize { 330 | var opts opentype.FaceOptions 331 | opts.DPI = 72.0 332 | opts.Size = fontSize 333 | opts.Hinting = font.HintingFull 334 | face, err := opentype.NewFace(lbrtsans.Font(), &opts) 335 | game.FontFace = text.NewGoXFace(face) 336 | game.FontSize = fontSize 337 | if err != nil { panic(err) } 338 | } 339 | 340 | // draw text 341 | var textOpts text.DrawOptions 342 | textOpts.PrimaryAlign = text.AlignCenter 343 | ox, oy := float64(bounds.Min.X), float64(bounds.Min.Y) 344 | textOpts.GeoM.Translate(ox + float64(bounds.Dx())/2.0, oy + (height - height/6.0)) 345 | textOpts.ColorScale.ScaleWithColor(utils.RGB(30, 51, 39)) 346 | textOpts.Blend = ebiten.BlendLighter 347 | text.Draw(hiResCanvas, "NOTHINGNESS AWAITS", game.FontFace, &textOpts) 348 | } 349 | 350 | func main() { 351 | ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) 352 | mipix.SetResolution(128, 72) 353 | err := mipix.Run(&Game{ 354 | GrassTiles: []Grass{ // add some little decoration 355 | {-6, -6}, {8, -6}, {9, -6}, {-2, -5}, {4, -5}, {8, -5}, {-5, -4}, {-1, -4}, {2, -4}, 356 | {-5, -3}, {-4, -3}, {-2, -3}, {-1, -3}, {1, -3}, {2, -3}, {3, -3}, 357 | {-6, -2}, {-5, -2}, {-4, -2}, {-3, -2}, {-1, -2}, {0, -2}, {1, -2}, {2, -2}, {3, -2}, 358 | {-4, -1}, {-3, -1}, {-2, -1}, {-1, -1}, {0, -1}, {1, -1}, {4, -1}, {-9, -1}, 359 | {-5, 0}, {-3, 0}, {-2, 0}, {-1, 0}, {0, 0}, {1, 0}, {2, 0}, {-10, 0}, {-9, 0}, {-8, 0}, 360 | {-3, 1}, {-2, 1}, {-1, 1}, {1, 1}, {2, 1}, {3, 1}, {-9, 1}, {-8, 1}, 361 | {-2, 2}, {0, 2}, {1, 2}, {5, 2}, {-10, 2}, {-8, 2}, {-7, 2}, 362 | {-3, 3}, {1, 3}, {3, 3}, {4, 3}, {5, 3}, {-8, 3}, 363 | {3, 4}, {4, 4}, {-6, 5}, {11, 0}, {12, 1}, {12, 2}, 364 | }, 365 | }) 366 | if err != nil { panic(err) } 367 | } 368 | ``` 369 | 370 | > [!TIP] 371 | > *You can try this example directly from your terminal with: 372 | > `go run github.com/tinne26/mipix-examples/src/tutorial/multi_layered@latest`* 373 | 374 | It's important to notice that combining logical and high resolution draws has some caveats: some elements that are contiguous in logical space might display slight gaps after interleaving low and high resolution draws (due to necessary internal projections). I might implement some techniques to avoid this in the future, but they would be opt-in and expensive, as they can basically only be applied as post-corrections. 375 | 376 | ## This is enough for today 377 | 378 | While mipix still packs a few more features, they aren't particularly important to discuss here; you can figure them out by taking a [look at the API docs](https://pkg.go.dev/github.com/tinne26/mipix). The drawing model is the only essential part to understand when using mipix, and I think that has been reasonably covered already. If you need more code samples, the [mipix-examples](https://github.com/tinne26/mipix-examples) repository contains a few more programs and even small games that you can try online. 379 | 380 | Hopefully this should be enough to get you started, but if you have any other questions don't hesitate to reach out! I should be fairly responsive both on Github discussions and Ebitengine's discord. 381 | 382 | --------------------------------------------------------------------------------