Files
aego/modules/particles/component.go
T
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

232 lines
9.5 KiB
Go

package particles
import (
"errors"
"math"
"aego/asset"
"aego/core/curve"
"aego/core/mathx"
"aego/render/sprite"
)
const MaxCapacity = 65536
const maxBursts = 128
type Shape uint8
const (
Point Shape = iota
Circle
Rect
Edge
SpriteMask
)
type Space uint8
const (
Local Space = iota
World
)
type TimeDomain uint8
const (
Game TimeDomain = iota
Unscaled
)
type SortMode uint8
const (
Age SortMode = iota
YoungestFirst
Distance
)
type ReducedMotionMode uint8
const (
Keep ReducedMotionMode = iota
Reduce
Hide
)
type ColorKey struct {
Time float32 `aego:"time"`
Color mathx.Color `aego:"color"`
}
type ParticleSystem2D struct {
Prewarm float32 `aego:"prewarm"`
EdgeA mathx.Vec2 `aego:"edgeA"`
EdgeB mathx.Vec2 `aego:"edgeB"`
Mask asset.Ref[asset.Texture] `aego:"mask,asset=texture,omitempty"`
MaskThreshold float32 `aego:"maskThreshold"`
Frames []asset.Ref[asset.Texture] `aego:"frames,asset=texture,omitempty"`
FrameFPS float32 `aego:"frameFPS"`
FrameLoop bool `aego:"frameLoop"`
SortMode SortMode `aego:"sortMode"`
ReducedMotion ReducedMotionMode `aego:"reducedMotion"`
ReducedRate float32 `aego:"reducedRate"`
Drag float32 `aego:"drag"`
NoiseStrength float32 `aego:"noiseStrength"`
NoiseFrequency float32 `aego:"noiseFrequency"`
KillYEnabled bool `aego:"killYEnabled"`
KillY float32 `aego:"killY"`
PlaneEnabled bool `aego:"planeEnabled"`
PlaneNormal mathx.Vec2 `aego:"planeNormal"`
PlaneDistance float32 `aego:"planeDistance"`
ColorOverLife []ColorKey `aego:"colorOverLife"`
VelocityOverLife curve.Curve `aego:"velocityOverLife"`
PlayOnStart bool `aego:"playOnStart"`
Loop bool `aego:"loop"`
Duration float32 `aego:"duration"`
TimeDomain TimeDomain `aego:"timeDomain"`
Seed int64 `aego:"seed"`
MaxParticles int32 `aego:"maxParticles"`
Rate float32 `aego:"rate"`
// Each key is (seconds within the cycle, birth count); equal times retain source order.
Bursts []mathx.Vec2 `aego:"bursts"`
Shape Shape `aego:"shape"`
Offset mathx.Vec2 `aego:"offset"`
RectSize mathx.Vec2 `aego:"rectSize"`
Radius float32 `aego:"radius"`
Lifetime mathx.Vec2 `aego:"lifetime,minmax"`
Speed mathx.Vec2 `aego:"speed,minmax"`
Size mathx.Vec2 `aego:"size,minmax"`
Rotation mathx.Vec2 `aego:"rotation,minmax"`
Direction float32 `aego:"direction"`
Spread float32 `aego:"spread"`
Color mathx.Color `aego:"color"`
Gravity mathx.Vec2 `aego:"gravity"`
SizeOverLife curve.Curve `aego:"sizeOverLife"`
AlphaOverLife curve.Curve `aego:"alphaOverLife"`
RotationSpeed float32 `aego:"rotationSpeed"`
Space Space `aego:"space"`
Sprite asset.Ref[asset.Texture] `aego:"sprite,asset=texture,omitempty"`
Material asset.Ref[asset.Material] `aego:"material,asset=material,omitempty"`
Layer int32 `aego:"layer"`
Order int32 `aego:"order"`
}
func DefaultParticleSystem2D() ParticleSystem2D {
return ParticleSystem2D{
PlayOnStart: true, Loop: true, Duration: 1, MaxParticles: 64, Rate: 8,
EdgeB: mathx.V2(1, 0), MaskThreshold: .5, FrameFPS: 12, FrameLoop: true, ReducedRate: .3, NoiseFrequency: 1, PlaneNormal: mathx.V2(0, 1), VelocityOverLife: curve.Constant(1),
RectSize: mathx.V2(1, 1), Radius: .5, Lifetime: mathx.V2(.4, .7),
Speed: mathx.V2(.5, 1.5), Size: mathx.V2(.08, .16),
Direction: -mathx.Pi / 2, Spread: mathx.Pi / 2, Color: mathx.White,
SizeOverLife: curve.Constant(1), AlphaOverLife: curve.Line(0, 1, 1, 0),
}
}
func finite(v float32) bool { return !math.IsNaN(float64(v)) && !math.IsInf(float64(v), 0) }
func interval(v mathx.Vec2) bool { return finite(v.X) && finite(v.Y) && v.X <= v.Y }
func finiteCurve(c *curve.Curve) bool {
if len(c.Keys) > 256 {
return false
}
for i, k := range c.Keys {
if !finite(k.T) || !finite(k.V) || !finite(k.In) || !finite(k.Out) || k.T < 0 || k.T > 1 || (i > 0 && c.Keys[i-1].T >= k.T) {
return false
}
}
return true
}
func (c *ParticleSystem2D) Validate() error {
switch {
case c.MaxParticles < 1 || c.MaxParticles > MaxCapacity:
return errors.New("maxParticles must be between 1 and 65536")
case !interval(c.Lifetime) || c.Lifetime.X <= 0:
return errors.New("lifetime must be an ordered positive range")
case !interval(c.Speed) || c.Speed.X < 0:
return errors.New("speed must be an ordered nonnegative range")
case !interval(c.Size) || c.Size.X < 0:
return errors.New("size must be an ordered nonnegative range")
case !interval(c.Rotation) || !finite(c.RotationSpeed):
return errors.New("invalid rotation or rotationSpeed")
case !finite(c.Rate) || c.Rate < 0:
return errors.New("rate must be finite and nonnegative")
case !finite(c.Duration) || c.Duration <= 0:
return errors.New("duration must be finite and positive")
case !finite(c.Direction) || !finite(c.Spread) || c.Spread < 0 || c.Spread > 2*mathx.Pi:
return errors.New("spread must be in [0, 2π] and direction must be finite")
case !finite(c.Gravity.X) || !finite(c.Gravity.Y) || !finite(c.Offset.X) || !finite(c.Offset.Y):
return errors.New("invalid gravity or shape offset")
case !finite(c.Radius) || c.Radius < 0 || !finite(c.RectSize.X) || !finite(c.RectSize.Y) || c.RectSize.X < 0 || c.RectSize.Y < 0:
return errors.New("shape dimensions must be finite and nonnegative")
case !finite(c.Prewarm) || c.Prewarm < 0 || c.Prewarm > 2:
return errors.New("prewarm must be between 0 and 2 seconds")
case !finiteVec(c.EdgeA) || !finiteVec(c.EdgeB) || !unitNumber(c.MaskThreshold):
return errors.New("invalid edge or mask threshold")
case !finite(c.FrameFPS) || c.FrameFPS < 0 || len(c.Frames) > 1024 || (len(c.Frames) > 1 && c.FrameFPS == 0):
return errors.New("flipbooks require a finite positive fps and at most 1024 frames")
case c.SortMode > Distance || c.ReducedMotion > Hide || !unitNumber(c.ReducedRate):
return errors.New("invalid particle sorting or reduced motion policy")
case !finite(c.Drag) || c.Drag < 0 || !finite(c.NoiseStrength) || c.NoiseStrength < 0 || !finite(c.NoiseFrequency) || c.NoiseFrequency < 0:
return errors.New("drag, noise strength and frequency must be finite and nonnegative")
case !finite(c.KillY) || !finite(c.PlaneDistance) || !finiteVec(c.PlaneNormal) || (c.PlaneEnabled && c.PlaneNormal.X == 0 && c.PlaneNormal.Y == 0):
return errors.New("invalid particle kill boundary")
case len(c.ColorOverLife) > 256 || !finiteCurve(&c.VelocityOverLife):
return errors.New("invalid over-life curve or gradient")
case c.Shape > SpriteMask || c.Space > World || c.TimeDomain > Unscaled:
return errors.New("unknown particle shape, space or time domain")
case c.Layer < int32(sprite.LayerMin) || c.Layer > int32(sprite.LayerMax) || c.Order < int32(sprite.OrderMin) || c.Order > int32(sprite.OrderMax):
return errors.New("particle layer and order must fit the sprite painter key")
case !finite(c.Color.R) || !finite(c.Color.G) || !finite(c.Color.B) || !finite(c.Color.A) || c.Color.A < 0 || c.Color.A > 1:
return errors.New("color must be finite with alpha in [0,1]")
case !finiteCurve(&c.SizeOverLife) || !finiteCurve(&c.AlphaOverLife):
return errors.New("life curves require finite ordered keys within [0,1]")
case len(c.Bursts) > maxBursts:
return errors.New("at most 128 burst keys per cycle")
}
for i, k := range c.ColorOverLife {
if !unitNumber(k.Time) || (i > 0 && c.ColorOverLife[i-1].Time >= k.Time) || !finiteColor(k.Color) {
return errors.New("gradient requires finite colors and ordered times within [0,1]")
}
}
for i, k := range c.Bursts {
if !finite(k.X) || k.X < 0 || k.X >= c.Duration || !finite(k.Y) || k.Y < 0 || k.Y > MaxCapacity || math.Trunc(float64(k.Y)) != float64(k.Y) || (i > 0 && c.Bursts[i-1].X > k.X) {
return errors.New("burst keys require ordered times in [0,duration) and integer counts in [0,65536]")
}
}
return nil
}
func finiteVec(v mathx.Vec2) bool { return finite(v.X) && finite(v.Y) }
func finiteColor(v mathx.Color) bool {
return finite(v.R) && finite(v.G) && finite(v.B) && unitNumber(v.A)
}
func unitNumber(v float32) bool { return finite(v) && v >= 0 && v <= 1 }
func (c *ParticleSystem2D) FrameIndex(age float32) int {
n := len(c.Frames)
if n == 0 {
return -1
}
if age <= 0 || !finite(age) {
return 0
}
frame := math.Floor(float64(age) * float64(c.FrameFPS))
if c.FrameLoop {
return int(math.Mod(frame, float64(n)))
}
return int(min(frame, float64(n-1)))
}
func GradientAt(keys []ColorKey, t float32) mathx.Color {
if len(keys) == 0 {
return mathx.White
}
if t <= keys[0].Time {
return keys[0].Color
}
for i := 1; i < len(keys); i++ {
k := keys[i]
if t <= k.Time {
prev := keys[i-1]
return prev.Color.Lerp(k.Color, (t-prev.Time)/(k.Time-prev.Time))
}
}
return keys[len(keys)-1].Color
}