Big push to implement 10mp fluid simulations.

This commit is contained in:
2025-12-03 10:21:36 -05:00
parent ba2c798b2e
commit 719b386822
11 changed files with 1536 additions and 337 deletions

77
elements/fluidsim10.go Normal file
View File

@@ -0,0 +1,77 @@
package elements
import (
"fluids/fluid"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
const (
FS10PixelWidth = 200
FS10PixelHeight = 100
FS10FluidWidth = 3.0 //meters
FS10FluidHeight = 1.0 //meters
FS10Resolution = 20 //need to workshop this
)
type FluidSim10 struct {
MappedEntityBase
fluid *fluid.Fluid
angle float64
particlebuff *ebiten.Image
}
func NewFluidSim10() *FluidSim10 {
fsim := &FluidSim10{
fluid: fluid.NewFluid(fluid.FieldVector{X: FS10FluidWidth, Y: FS10FluidHeight}, FS10FluidHeight/FS10Resolution),
particlebuff: ebiten.NewImage(1, 1),
}
fsim.Initialize()
return fsim
}
func (f *FluidSim10) Initialize() {
f.Sprite = ebiten.NewImage(FS10PixelWidth, FS10PixelHeight)
f.particlebuff.Fill(color.White)
f.fluid.Initialize()
}
func (f *FluidSim10) SetAngle(angle float64) {
f.angle = angle
f.fluid.SetAngle(float32(angle))
}
func (f *FluidSim10) GetAngle() float64 {
return f.angle
}
func (f *FluidSim10) Draw() {
f.Sprite.Clear()
vector.StrokeRect(f.Sprite, 0, 0, FS10PixelWidth, FS10PixelHeight, 2, color.White, true)
for i := range f.fluid.Particles {
//for each particle, compute its relative position based on its
//position within the fluid field
p := &f.fluid.Particles[i]
percentx := p.Position.X / f.fluid.Field.Dimensions.X
percenty := p.Position.Y / f.fluid.Field.Dimensions.Y
ox := float64(percentx * FS10PixelWidth)
oy := float64(percenty * FS10PixelHeight)
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(ox, oy)
f.Sprite.DrawImage(f.particlebuff, op)
}
}
func (f *FluidSim10) Update() {
if f.paused {
return
}
f.fluid.Step()
}

443
elements/fluidsimd.go Normal file
View File

@@ -0,0 +1,443 @@
package elements
import (
"fluids/gamedata"
"fluids/quadtree"
"fmt"
"image/color"
"math"
"math/rand/v2"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
const (
FSDWidth = 640
FSDHeight = 360
FSDParticleCount = 2000
FSDGravity = 2
FSDParticleRadius = 2.5
FSDDamping = .7
FSDDeltaTimeStep = 0.5
FSDInfluenceRadius = 30
)
type FluidSimD struct {
MappedEntityBase
particles []*Particle
cycle int
particlebox *gamedata.Vector
particlebuff *ebiten.Image
quadtree *quadtree.Quadtree
collisionquad quadtree.Quadrant
paused bool
renderquads bool
resolvecollisions bool
resolvers []func(particle *Particle)
resolveridx int
angle float64
}
func NewFluidSimD() *FluidSimD {
fsd := &FluidSimD{
particlebuff: ebiten.NewImage(FSDParticleRadius*2, FSDParticleRadius*2),
paused: false,
renderquads: false,
resolvecollisions: false,
resolveridx: 0,
angle: 0,
}
fsd.dimensions = gamedata.Vector{X: FSDWidth, Y: FSDHeight}
//prepare root quadtree and subsequent collision search quadrant
quad := quadtree.Quadrant{
Position: gamedata.Vector{X: FSDWidth / 2, Y: FSDHeight / 2},
Dimensions: gamedata.Vector{X: FSDWidth, Y: FSDHeight},
}
fsd.quadtree = quadtree.New(quad, 0)
fsd.collisionquad = quadtree.Quadrant{
Position: gamedata.Vector{
X: 0,
Y: 0,
},
Dimensions: gamedata.Vector{
X: FSDParticleRadius * 2,
Y: FSDParticleRadius * 2,
},
}
//add all resolvers to strategy list
fsd.resolvers = append(fsd.resolvers, fsd.ResolveCollisionsA)
fsd.resolvers = append(fsd.resolvers, fsd.ResolveCollisionsB)
fsd.resolvers = append(fsd.resolvers, fsd.ResolveCollisionsC)
//initialize particles, set bounding box, prepare image buffer
fsd.Sprite = ebiten.NewImage(FSDWidth, FSDHeight)
fsd.particlebox = &gamedata.Vector{
X: FSDWidth - 50,
Y: FSDHeight - 50,
}
fsd.InitializeParticles()
vector.FillCircle(fsd.particlebuff, FSDParticleRadius, FSDParticleRadius, FSDParticleRadius, color.White, true)
return fsd
}
func (f *FluidSimD) Draw() {
f.Sprite.Clear()
f.RenderParticles()
f.RenderBox()
if f.renderquads {
f.RenderQuadrants()
}
}
func (f *FluidSimD) Update() {
if f.paused {
return
}
f.RebuildQuadtree()
f.UpdateParticles()
f.cycle++
}
func (f *FluidSimD) UpdateParticles() {
dt := FSDDeltaTimeStep
mx, my := ebiten.CursorPosition()
mpos := gamedata.Vector{X: float64(mx), Y: float64(my)}
maxdeflect := 40 * dt * FSDGravity
gravity := gamedata.Vector{
X: FSDGravity * dt * math.Sin(f.angle),
Y: FSDGravity * dt * math.Cos(f.angle),
}
for _, particle := range f.particles {
//particle.Velocity.Y += FSDGravity * dt
particle.Velocity = particle.Velocity.Add(gravity)
//if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
delta := gamedata.Vector{
X: mpos.X - particle.Position.X,
Y: mpos.Y - particle.Position.Y,
}
dist := math.Sqrt(delta.X*delta.X + delta.Y*delta.Y)
theta := math.Atan2(delta.Y, delta.X)
if dist < FSDInfluenceRadius {
dx := dist * math.Cos(theta)
dy := dist * math.Sin(theta)
if dx != 0 {
gainx := (-1./FSDInfluenceRadius)*math.Abs(dx) + 1.
particle.Velocity.X += 20 * gainx * -1 * math.Copysign(1, dx)
}
if dy != 0 {
gainy := (-1./FSDInfluenceRadius)*math.Abs(dy) + 1.
particle.Velocity.Y += maxdeflect * gainy * -1 * math.Copysign(1, dy)
}
}
}
particle.Position.X += particle.Velocity.X * dt
particle.Position.Y += particle.Velocity.Y * dt
if f.resolvecollisions {
f.resolvers[f.resolveridx](particle)
}
}
for _, p := range f.particles {
f.BoundParticle(p)
}
}
func (f *FluidSimD) InitializeParticles() {
f.particles = f.particles[:0]
xmin := (FSDWidth-f.particlebox.X)/2 + FSDParticleRadius
xmax := f.particlebox.X - FSDParticleRadius*2
ymin := (FSDHeight-f.particlebox.Y)/2 + FSDParticleRadius
ymax := f.particlebox.Y - FSDParticleRadius*2
for i := 0; i < FSDParticleCount; i++ {
p := &Particle{
Position: gamedata.Vector{
X: xmin + rand.Float64()*xmax,
Y: ymin + rand.Float64()*ymax,
},
Velocity: gamedata.Vector{
X: 0,
Y: 0,
},
Radius: FSDParticleRadius,
}
f.particles = append(f.particles, p)
}
}
func (f *FluidSimD) BoundParticle(p *Particle) {
xmin := (FSDWidth-f.particlebox.X)/2 + p.Radius
xmax := xmin + f.particlebox.X - p.Radius*2
if p.Position.X > xmax {
p.Velocity.X *= -1 * FSDDamping
p.Position.X = xmax
}
if p.Position.X < xmin {
p.Velocity.X *= -1 * FSDDamping
p.Position.X = xmin
}
ymin := (FSDHeight-f.particlebox.Y)/2 + p.Radius
ymax := ymin + f.particlebox.Y - p.Radius*2
if p.Position.Y > ymax {
p.Velocity.Y *= -1 * FSDDamping
p.Position.Y = ymax
}
if p.Position.Y < ymin {
p.Velocity.Y *= -1 * FSDDamping
p.Position.Y = ymin
}
}
func (f *FluidSimD) RenderQuadrants() {
clr := color.RGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
quadrants := f.quadtree.GetQuadrants()
for _, quad := range quadrants {
ox := float32(quad.Position.X - quad.Dimensions.X/2)
oy := float32(quad.Position.Y - quad.Dimensions.Y/2)
vector.StrokeRect(f.Sprite, ox, oy, float32(quad.Dimensions.X), float32(quad.Dimensions.Y), 1, clr, true)
}
}
func (f *FluidSimD) RenderParticles() {
for _, particle := range f.particles {
x0 := particle.Position.X - particle.Radius
y0 := particle.Position.Y - particle.Radius
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(x0, y0)
f.Sprite.DrawImage(f.particlebuff, op)
}
}
func (f *FluidSimD) RenderBox() {
x0 := (FSDWidth - f.particlebox.X) / 2
y0 := (FSDHeight - f.particlebox.Y) / 2
vector.StrokeRect(f.Sprite, float32(x0), float32(y0), float32(f.particlebox.X), float32(f.particlebox.Y), 2, color.White, true)
}
func (f *FluidSimD) RebuildQuadtree() {
f.quadtree.Clear()
for _, p := range f.particles {
if !f.quadtree.Insert(p) {
fmt.Println("quadtree insertion failed")
}
}
}
func (f *FluidSimD) ResolveCollisionsA(particle *Particle) {
//construct search quadrant from current particle
quadrant := quadtree.Quadrant{
Position: particle.Position,
Dimensions: particle.GetDimensions(),
}
//find list of possible maybe collisions, we inspect those in more detail
maybes := f.quadtree.FindAll(quadrant)
sqdist := float64(particle.Radius*particle.Radius) * 4
for _, p := range maybes {
if p == particle {
continue
}
pos := p.GetPosition()
delta := gamedata.Vector{
X: pos.X - particle.Position.X,
Y: pos.Y - particle.Position.Y,
}
dist2 := delta.X*delta.X + delta.Y*delta.Y
if dist2 == 0 {
// Same position: pick a fallback direction to avoid NaN
delta.X = 1
delta.Y = 0
dist2 = 1
}
if dist2 < sqdist {
d := math.Sqrt(dist2)
nx, ny := delta.X/d, delta.Y/d
overlap := particle.Radius*2 - d
pos.X += nx * overlap
pos.Y += ny * overlap
p.SetPosition(pos)
/*
newpos := gamedata.Vector{
X: pos.X + delta.X,
Y: pos.Y + delta.Y,
}
p.SetPosition(newpos)
*/
}
}
}
func (f *FluidSimD) ResolveCollisionsB(particle *Particle) {
//construct search quadrant from current particle
quadrant := quadtree.Quadrant{
Position: particle.Position,
Dimensions: particle.GetDimensions(),
}
//find list of possible maybe collisions, we inspect those in more detail
maybes := f.quadtree.FindAll(quadrant)
sqdist := float64(particle.Radius*particle.Radius) * 4
for _, p := range maybes {
if p == particle {
continue
}
pos := p.GetPosition()
delta := gamedata.Vector{
X: pos.X - particle.Position.X,
Y: pos.Y - particle.Position.Y,
}
dist2 := delta.X*delta.X + delta.Y*delta.Y
if dist2 < sqdist {
d := math.Sqrt(dist2)
overlap := particle.Radius*2 - d
theta := math.Atan2(delta.Y, delta.X)
pos.X += overlap * math.Cos(theta)
pos.Y += overlap * math.Sin(theta)
p.SetPosition(pos)
m := p.(*Particle)
m.Velocity.X *= -1 * FSDDamping
m.Velocity.Y *= -1 * FSDDamping
}
}
}
func (f *FluidSimD) ResolveCollisionsC(particle *Particle) {
//construct search quadrant from current particle
quadrant := quadtree.Quadrant{
Position: particle.Position,
Dimensions: particle.GetDimensions(),
}
//find list of possible maybe collisions, we inspect those in more detail
maybes := f.quadtree.FindAll(quadrant)
sqdist := float64(particle.Radius*particle.Radius) * 4
for _, p := range maybes {
if p == particle {
continue
}
pos := p.GetPosition()
delta := gamedata.Vector{
X: pos.X - particle.Position.X,
Y: pos.Y - particle.Position.Y,
}
dist2 := delta.X*delta.X + delta.Y*delta.Y
if dist2 < sqdist {
/*
//compute impact to this particle
deltav1 := particle.Velocity.Subtract(p.(*Particle).Velocity)
deltax1 := particle.Position.Subtract(p.(*Particle).Position)
dot1 := deltav1.DotProduct(deltax1)
mag1 := deltax1.Magnitude() * deltax1.Magnitude()
particle.Velocity = deltax1.Scale(dot1 / mag1)
//compute impact to other particle
deltav2 := p.(*Particle).Velocity.Subtract(particle.Velocity)
deltax2 := p.(*Particle).Position.Subtract(particle.Position)
dot2 := deltav2.DotProduct(deltax2)
mag2 := deltax2.Magnitude() * deltax2.Magnitude()
p.(*Particle).Velocity = deltax2.Scale(dot2 / mag2)
*/
dist := math.Sqrt(dist2)
s := 0.5 * (particle.Radius*2 - dist) / dist
dnorm := delta.Scale(s)
particle.Position = particle.Position.Subtract(dnorm)
p.(*Particle).Position = p.(*Particle).Position.Add(dnorm)
}
}
}
func (f *FluidSimD) SetRenderQuads(v bool) {
f.renderquads = v
}
func (f *FluidSimD) RenderQuads() bool {
return f.renderquads
}
func (f *FluidSimD) SetResolveCollisions(v bool) {
f.resolvecollisions = v
}
func (f *FluidSimD) ResolveCollisions() bool {
return f.resolvecollisions
}
func (f *FluidSimD) NextSolver() {
f.resolveridx = (f.resolveridx + 1) % len(f.resolvers)
}
func (f *FluidSimD) PreviousSolver() {
f.resolveridx = f.resolveridx - 1
if f.resolveridx < 0 {
f.resolveridx = len(f.resolvers) - 1
}
}

18
elements/mappedentity.go Normal file
View File

@@ -0,0 +1,18 @@
package elements
import (
"fluids/gamedata"
"github.com/hajimehoshi/ebiten/v2"
)
type MappedEntity interface {
Draw()
Update()
GetSprite() *ebiten.Image
GetDimensions() gamedata.Vector
GetPosition() gamedata.Vector
SetPosition(gamedata.Vector)
SetPaused(bool)
Paused() bool
}

View File

@@ -0,0 +1,51 @@
package elements
import (
"fluids/gamedata"
"github.com/hajimehoshi/ebiten/v2"
)
type MappedEntityBase struct {
Sprite *ebiten.Image
dimensions gamedata.Vector
position gamedata.Vector
paused bool
}
func NewMappedEntityBase(dimensions gamedata.Vector) *MappedEntityBase {
meb := &MappedEntityBase{
dimensions: dimensions,
}
return meb
}
func (m *MappedEntityBase) Draw() {
}
func (m *MappedEntityBase) Update() {
}
func (m *MappedEntityBase) GetSprite() *ebiten.Image {
return m.Sprite
}
func (m *MappedEntityBase) GetDimensions() gamedata.Vector {
return m.dimensions
}
func (m *MappedEntityBase) GetPosition() gamedata.Vector {
return m.position
}
func (m *MappedEntityBase) SetPosition(pos gamedata.Vector) {
m.position = pos
}
func (m *MappedEntityBase) SetPaused(p bool) {
m.paused = p
}
func (m *MappedEntityBase) Paused() bool {
return m.paused
}