Files
aego/tests/integration/platformer_bloom_gl_test.go
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

187 lines
5.8 KiB
Go

//go:build aego_gltest
package integration
import (
"bytes"
"image"
"image/png"
"os"
"path/filepath"
"runtime"
"slices"
"testing"
"aego/app/run"
"aego/core/log"
"aego/core/mathx"
"aego/modules/particles"
"aego/modules/render2d"
"aego/modules/settings"
"aego/project"
"aego/render/sprite"
"aego/scene"
"aego/scene/frame"
)
// Attribution uses an actual production frame and two leave-one-source-out
// controls. Only presentation is changed. The lantern's Light2D stays active.
func TestPlatformerBloomReceivesLanternAndEmbersBeforeMenuGL(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
root := copyExample(t, "platformer")
p, err := project.Load(root)
if err != nil {
t.Fatal(err)
}
var logs bytes.Buffer
a, err := run.Open(p, run.Config{Hidden: true, FrameTime: 1. / 60, Seed: 42, Logger: log.New(log.NewTextSink(&logs)), NoProfile: true}, combatModule{})
if err != nil {
t.Fatal(err)
}
defer a.Close()
a.Engine.SetLockstep(true)
sc := a.Scene
pf := &platformer{app: a, root: root, player: sc.Find(sc.Root(), "Player")}
pf.pid, _ = a.Scripts.InstanceOf(pf.player, playerType)
replay, err := os.ReadFile(lightingRouteFile)
if err != nil {
t.Fatal(err)
}
if _, err := actionsOf(t, pf).Replay(replay); err != nil {
t.Fatal(err)
}
driver := a.Driver()
for range 75 {
if !a.Engine.Step(driver) {
t.Fatal(a.Engine.Err())
}
}
w := a.Engine.MainWorld()
w.Time.SetPaused(true)
lantern := sc.Find(sc.Root(), "StartLantern")
embers := sc.Find(lantern, "Embers")
if n := particles.Of(w).Count(embers); n == 0 {
t.Fatal("the canonical route has no active sparks here")
}
lamp, _ := scene.Get[render2d.SpriteRenderer](sc, lantern)
reg, ok := lamp.Sprite.Region(sc.Resolver())
if !ok {
t.Fatal("missing lantern sprite")
}
center := sc.RenderWorld(lantern).Apply(mathx.Vec2{})
fx, _ := scene.Get[render2d.PostProcess](sc, sc.Find(sc.Root(), "Camera"))
requirePlatformerPostFX(t, fx)
tick := w.Time.Tick()
snap := func(label string, bloom, lampOn, embersOn bool) *image.RGBA {
t.Helper()
fx.Enabled = bloom
a.Runner.SetHidden(func(h scene.NodeHandle) bool { return !embersOn && h == embers })
removed := 0
a.Runner.SetPreviewExtract(func(r *frame.Runner) {
if lampOn {
return
}
for i := range r.Views() {
b := r.Bucket(i)
b.Sprites = slices.DeleteFunc(b.Sprites, func(d sprite.Draw) bool {
if d.Position == center && d.Region == reg {
removed++
return true
}
return false
})
}
})
var img *image.RGBA
var captureErr error
a.Engine.CaptureFrame(func(v *image.RGBA, e error) { img, captureErr = v, e })
if !a.Engine.Step(driver) || captureErr != nil || img == nil || w.Time.Tick() != tick {
t.Fatalf("%s capture: tick %d vs %d, %v", label, w.Time.Tick(), tick, captureErr)
}
if !lampOn && removed != 1 {
t.Fatalf("%s removed %d lantern sprites", label, removed)
}
rs := a.RenderStats()
if rs.PostFXFailures != 0 || rs.LightingFailures != 0 || rs.MaterialCompileFailures != 0 || (rs.PostFXPasses > 0) != bloom {
t.Fatal(label, rs)
}
if dir := os.Getenv("AEGO_POSTFX_SHOTS"); dir != "" {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
f, err := os.Create(filepath.Join(dir, "attribution-"+label+".png"))
if err != nil {
t.Fatal(err)
}
err = png.Encode(f, img)
cErr := f.Close()
if err != nil || cErr != nil {
t.Fatal(err, cErr)
}
}
return img
}
raw, lit := snap("all-off", false, true, true), snap("all-on", true, true, true)
for _, tc := range []struct {
name string
lamp, embers bool
}{{"lantern", false, true}, {"embers", true, false}} {
withoutRaw := snap(tc.name+"-removed-off", false, tc.lamp, tc.embers)
withoutLit := snap(tc.name+"-removed-on", true, tc.lamp, tc.embers)
direct := image.Rectangle{}
for y := raw.Rect.Min.Y; y < raw.Rect.Max.Y; y++ {
for x := raw.Rect.Min.X; x < raw.Rect.Max.X; x++ {
if raw.RGBAAt(x, y) != withoutRaw.RGBAAt(x, y) {
direct = direct.Union(image.Rect(x, y, x+1, y+1))
}
}
}
if direct.Empty() {
t.Fatalf("%s has no direct coverage", tc.name)
}
roi := direct.Inset(-16).Intersect(raw.Rect)
var halo int
var haloBounds image.Rectangle
for y := roi.Min.Y; y < roi.Max.Y; y++ {
for x := roi.Min.X; x < roi.Max.X; x++ {
if raw.RGBAAt(x, y) != withoutRaw.RGBAAt(x, y) {
continue
}
before, after := withoutLit.RGBAAt(x, y), lit.RGBAAt(x, y)
if after.A == before.A && max(int(after.R)-int(before.R), int(after.G)-int(before.G), int(after.B)-int(before.B)) > 1 {
halo++
haloBounds = haloBounds.Union(image.Rect(x, y, x+1, y+1))
}
}
}
if halo == 0 {
t.Fatalf("%s did not brighten any surrounding pixel (direct %v)", tc.name, direct)
}
t.Logf("tick=%d source=%s directly visible=%v halo=%d halo bounds=%v", tick, tc.name, direct, halo, haloBounds)
}
if repeat := snap("all-restored", true, true, true); !bytes.Equal(repeat.Pix, lit.Pix) {
t.Fatal("leave-one-out comparisons did not use a reproducible frame")
}
settings.Of(w).Open()
menuOff, menuOn := snap("menu-off", false, true, true), snap("menu-on", true, true, true)
b := raw.Bounds()
ww, hh := min(860, b.Dx()-32), min(620, b.Dy()-32)
inside := image.Rect((b.Dx()-ww)/2+16, (b.Dy()-hh)/2+16, (b.Dx()+ww)/2-16, (b.Dy()+hh)/2-16)
covered := 0
for y := inside.Min.Y; y < inside.Max.Y; y++ {
for x := inside.Min.X; x < inside.Max.X; x++ {
if menuOff.RGBAAt(x, y) != menuOn.RGBAAt(x, y) {
t.Fatalf("Bloom modified menu at (%d,%d)", x, y)
}
if raw.RGBAAt(x, y) != lit.RGBAAt(x, y) && raw.RGBAAt(x, y) != menuOff.RGBAAt(x, y) {
covered++
}
}
}
if covered == 0 {
t.Fatal("menu ROI contains no pixels hiding an actual Bloom response")
}
t.Logf("menu ROI %v unchanged with %d covered scene pixels affected by Bloom", inside, covered)
}