Files
aego/asset/importers/atlas/atlas_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

217 lines
6.8 KiB
Go

package atlas
import (
"testing"
"aego/asset"
"aego/asset/pipeline"
"aego/core/ids"
"aego/core/log"
"aego/core/mathx"
"aego/format/aetext"
"aego/gpu"
rtex "aego/render/texture"
)
func member(t *testing.T, w, h int, c mathx.Color) pipeline.GroupMember {
t.Helper()
d := rtex.Solid(w, h, c)
d.Premultiply()
return pipeline.GroupMember{
ID: ids.NewAssetID(),
Name: "sprite",
Result: &pipeline.Result{Raw: d, RawFilter: int(gpu.FilterNearest)},
}
}
func TestPackNoOverlap(t *testing.T) {
var members []pipeline.GroupMember
for i := 0; i < 30; i++ {
members = append(members, member(t, 16+i%8, 16+(i%3)*4, mathx.Color{R: 1, A: 1}))
}
placements, w, h, err := pack(members, 2)
if err != nil {
t.Fatal(err)
}
if len(placements) != 30 {
t.Fatalf("placed %d of 30", len(placements))
}
if w > MaxPage || h > MaxPage {
t.Fatalf("page %dx%d exceeds the limit", w, h)
}
for i := range placements {
a := &placements[i]
if a.x < 2 || a.y < 2 || a.x+a.w+2 > w || a.y+a.h+2 > h {
t.Fatalf("placement %+v is outside the padded page %dx%d", a, w, h)
}
for j := i + 1; j < len(placements); j++ {
b := &placements[j]
if overlaps(a, b, 2) {
t.Fatalf("placements overlap: %+v and %+v", a, b)
}
}
}
}
func overlaps(a, b *placement, padding int) bool {
ax0, ax1 := a.x-padding, a.x+a.w+padding
ay0, ay1 := a.y-padding, a.y+a.h+padding
bx0, bx1 := b.x, b.x+b.w
by0, by1 := b.y, b.y+b.h
return ax0 < bx1 && bx0 < ax1 && ay0 < by1 && by0 < ay1
}
func TestFinalizeProducesRegionsAndPage(t *testing.T) {
members := []pipeline.GroupMember{
member(t, 32, 32, mathx.Color{R: 1, A: 1}),
member(t, 16, 48, mathx.Color{G: 1, A: 1}),
member(t, 64, 16, mathx.Color{B: 1, A: 1}),
}
pageID := ids.NewAssetID()
imp := Wrap(nil)
ctx := &pipeline.GroupContext{Group: "characters", PageID: pageID, Log: log.Nop().With(log.Asset)}
perMember, pages, err := imp.Finalize(ctx, members)
if err != nil {
t.Fatal(err)
}
if len(pages) != 1 {
t.Fatalf("pages = %d", len(pages))
}
if len(perMember) != 3 {
t.Fatalf("regions = %d", len(perMember))
}
for _, m := range members {
res, ok := perMember[m.ID]
if !ok || len(res.Artifacts) != 1 {
t.Fatalf("member %s has no region", m.ID)
}
ra, err := assetDecodeRegion(res.Artifacts[0].Data)
if err != nil {
t.Fatal(err)
}
if ra.Page != pageID {
t.Fatal("region points at the wrong page")
}
if ra.W != m.Result.Raw.Width || ra.H != m.Result.Raw.Height {
t.Fatalf("region size = %dx%d, want %dx%d", ra.W, ra.H, m.Result.Raw.Width, m.Result.Raw.Height)
}
}
}
func TestExtrudeCopiesEdgePixels(t *testing.T) {
page := rtex.New(16, 16, gpu.TexSRGBA8)
src := rtex.Solid(4, 4, mathx.Color{R: 1, G: 1, B: 1, A: 1})
src.Premultiply()
blit(page, src, 4, 4)
extrudeEdges(page, 4, 4, 4, 4, 2)
inside := pixelAt(page, 4, 4)
for _, p := range [][2]int{{2, 4}, {3, 4}, {4, 2}, {4, 3}, {9, 7}, {2, 2}} {
if pixelAt(page, p[0], p[1]) != inside {
t.Fatalf("pixel %v was not extruded", p)
}
}
if pixelAt(page, 0, 0) == inside {
t.Fatal("extrusion went further than the padding")
}
}
func pixelAt(d *rtex.Data, x, y int) [4]byte {
i := (y*d.Width + x) * 4
return [4]byte{d.Pixels[i], d.Pixels[i+1], d.Pixels[i+2], d.Pixels[i+3]}
}
func TestEmptyGroup(t *testing.T) {
imp := Wrap(nil)
ctx := &pipeline.GroupContext{Group: "empty", PageID: ids.NewAssetID(), Log: log.Nop().With(log.Asset)}
perMember, pages, err := imp.Finalize(ctx, nil)
if err != nil || perMember != nil || pages != nil {
t.Fatalf("empty group must be a no-op: %v %v %v", perMember, pages, err)
}
}
func assetDecodeRegion(data []byte) (*asset.RegionArtifact, error) {
return asset.DecodeRegion(data)
}
func TestTrimArtifactPreservesOriginalCanvasAndConvertsLinearPage(t *testing.T) {
d := rtex.New(8, 6, gpu.TexRGBA8)
for y := 1; y < 3; y++ {
for x := 2; x < 5; x++ {
d.SetPixel(x, y, mathx.Color{R: .5, A: .5})
}
}
d.Premultiply()
m := pipeline.GroupMember{ID: ids.NewAssetID(), Settings: pipeline.Settings{"trim": aetext.Bool(true)}, Result: &pipeline.Result{Raw: d}}
ctx := &pipeline.GroupContext{Group: "trim", PageID: ids.NewAssetID(), Log: log.Nop().With(log.Asset)}
regions, pages, err := Wrap(nil).Finalize(ctx, []pipeline.GroupMember{m})
if err != nil {
t.Fatal(err)
}
r, err := asset.DecodeRegion(regions[m.ID].Artifacts[0].Data)
if err != nil {
t.Fatal(err)
}
if r.SourceW != 8 || r.SourceH != 6 || r.TrimX != 2 || r.TrimY != 1 || r.W != 3 || r.H != 2 {
t.Fatalf("trim metadata %+v", r)
}
page, err := asset.DecodeTexture(pages[0].Data)
if err != nil {
t.Fatal(err)
}
p := (r.Y*page.Width + r.X) * 4
// The source is already premultiplied linear .25. Atlas storage encodes it
// to sRGB (~137), preserving alpha128 and avoiding another multiplication.
if page.Format != gpu.TexSRGBA8 || page.Pixels[p] < 136 || page.Pixels[p] > 138 || page.Pixels[p+3] != 128 {
t.Fatalf("linear atlas conversion %v", page.Pixels[p:p+4])
}
}
func TestAtlasRotationIsExplicitlyRejected(t *testing.T) {
m := member(t, 2, 2, mathx.White)
m.Settings = pipeline.Settings{"rotate": aetext.Bool(true)}
if _, _, _, err := pack([]pipeline.GroupMember{m}, 2); err == nil {
t.Fatal("unsupported rotation was ignored")
}
}
func TestNormalAtlasPreservesUnpremultipliedDataAndFullPadding(t *testing.T) {
d := rtex.New(2, 2, gpu.TexRGBA8)
d.NonColor = true
for i := 0; i < len(d.Pixels); i += 4 {
copy(d.Pixels[i:i+4], []byte{210, 40, 250, 32})
}
normalID, albedoID := ids.NewAssetID(), ids.NewAssetID()
m := pipeline.GroupMember{ID: normalID, Result: &pipeline.Result{Raw: d, RawSemantic: asset.TextureNormal, Deps: []ids.AssetID{albedoID}}}
ctx := &pipeline.GroupContext{PageID: ids.NewAssetID()}
regions, pages, err := Wrap(nil).Finalize(ctx, []pipeline.GroupMember{m})
if err != nil {
t.Fatal(err)
}
r, _ := asset.DecodeRegion(regions[normalID].Artifacts[0].Data)
page, err := asset.DecodeTexture(pages[0].Data)
if err != nil || page.Semantic != asset.TextureNormal || page.Format != gpu.TexRGBA8 || page.Mips {
t.Fatalf("normal page is not linear data: %+v %v", page, err)
}
for y := r.Y - 2; y < r.Y+r.H+2; y++ {
for x := r.X - 2; x < r.X+r.W+2; x++ {
i := (y*page.Width + x) * 4
if string(page.Pixels[i:i+4]) != string([]byte{210, 40, 250, 32}) {
t.Fatalf("padding at %d,%d was omitted or data changed: %v", x, y, page.Pixels[i:i+4])
}
}
}
if len(regions[normalID].Deps) != 1 || regions[normalID].Deps[0] != albedoID {
t.Fatal("atlas discarded source dependencies")
}
m.Settings = pipeline.Settings{"mips": aetext.Bool(true)}
if _, _, err := Wrap(nil).Finalize(ctx, []pipeline.GroupMember{m}); err == nil {
t.Fatal("unsafe cross-region mip chain accepted")
}
m.Settings = nil
color := member(t, 2, 2, mathx.White)
if _, _, err := Wrap(nil).Finalize(ctx, []pipeline.GroupMember{m, color}); err == nil {
t.Fatal("normal data shared a color atlas page")
}
}