Files
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

425 lines
14 KiB
Go

package texture_test
import (
"bytes"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"slices"
"testing"
"aego/asset"
"aego/asset/importers/atlas"
"aego/asset/importers/texture"
"aego/asset/pipeline"
"aego/core/ids"
"aego/core/log"
"aego/format/aetext"
"aego/gpu"
"aego/project"
)
func pngPixels(t *testing.T, w, h int, pixel func(x, y int) color.NRGBA) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, pixel(x, y))
}
}
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func TestNormalAndHeightPixelsAreLinearData(t *testing.T) {
imp := texture.New()
source := pngPixels(t, 3, 3, func(x, y int) color.NRGBA {
return color.NRGBA{R: uint8(32 + x*64), G: uint8(32 + y*64), B: 255, A: 64}
})
importWith := func(settings pipeline.Settings) *asset.TextureArtifact {
t.Helper()
res, err := imp.Import(&pipeline.ImportContext{Path: "surface.png", Source: source, Meta: &pipeline.Meta{Settings: settings}})
if err != nil {
t.Fatal(err)
}
d, err := asset.DecodeTexture(res.Artifacts[0].Data)
if err != nil {
t.Fatal(err)
}
return d
}
s := imp.Defaults()
s["semantic"] = aetext.Ident("normal")
s["normal_y"] = aetext.Ident("down")
s["mips"] = aetext.Bool(true)
normal := importWith(s)
if normal.Semantic != asset.TextureNormal || normal.Format != gpu.TexRGBA8 || !normal.Mips || !slices.Equal(normal.Pixels[:4], []byte{32, 223, 255, 64}) {
t.Fatalf("normal was color-converted/premultiplied or green not flipped: %+v", normal)
}
s["semantic"], s["normal_y"] = aetext.Ident("height"), aetext.Ident("up")
raw := importWith(s)
if raw.Semantic != asset.TextureHeight || !slices.Equal(raw.Pixels[:4], []byte{32, 32, 255, 64}) {
t.Fatal("raw height was implicitly converted")
}
s["generate_normal"] = aetext.Bool(true)
s["height_strength"] = aetext.Float32(4)
generated := importWith(s)
center := generated.Pixels[16:20]
if generated.Semantic != asset.TextureNormal || center[0] >= 100 || center[1] != 128 || center[2] < 200 || center[3] != 64 {
t.Fatalf("X ramp produced wrong normal: %v", center)
}
if other := importWith(s); !bytes.Equal(generated.Pixels, other.Pixels) {
t.Fatal("height-derived normal is not deterministic")
}
s["height_channel"] = aetext.Ident("g")
up := importWith(s).Pixels[16:20]
s["normal_y"] = aetext.Ident("down")
down := importWith(s).Pixels[16:20]
if up[1] <= 160 || down[1] >= 100 || up[0] != 128 || up[2] != down[2] {
t.Fatalf("height Y convention wrong: up=%v down=%v", up, down)
}
s["height_strength"] = aetext.Float32(0)
flat := importWith(s).Pixels[16:20]
if !slices.Equal(flat, []byte{128, 128, 255, 64}) {
t.Fatalf("zero-strength normal is not flat: %v", flat)
}
}
func TestHeightGenerationClampsToAnimationFrame(t *testing.T) {
imp := texture.New()
s := imp.Defaults()
s["semantic"], s["generate_normal"] = aetext.Ident("height"), aetext.Bool(true)
source := pngPixels(t, 4, 2, func(x, y int) color.NRGBA {
if x < 2 {
return color.NRGBA{A: 255}
}
return color.NRGBA{R: 255, A: 255}
})
res, err := imp.Import(&pipeline.ImportContext{Path: "height.png", Source: source, Meta: &pipeline.Meta{Settings: s, Subs: []pipeline.SubAsset{
{ID: ids.NewAssetID(), Name: "first", Rect: [4]int{0, 0, 2, 2}},
{ID: ids.NewAssetID(), Name: "second", Rect: [4]int{2, 0, 2, 2}},
}}})
if err != nil {
t.Fatal(err)
}
d, err := asset.DecodeTexture(res.Artifacts[0].Data)
if err != nil {
t.Fatal(err)
}
for i := 0; i < len(d.Pixels); i += 4 {
if !slices.Equal(d.Pixels[i:i+4], []byte{128, 128, 255, 255}) {
t.Fatalf("normal sampled the neighbouring animation frame at %d: %v", i/4, d.Pixels[i:i+4])
}
}
}
func pairProject(t *testing.T, grouped bool) (*pipeline.Pipeline, ids.AssetID, ids.AssetID) {
t.Helper()
prj, err := project.Create(t.TempDir(), "Normals", false)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"hero.png", "hero_n.png"} {
if err := os.WriteFile(filepath.Join(prj.Root, "assets", name), sheet(t, 32, 16), 0o644); err != nil {
t.Fatal(err)
}
}
reg := pipeline.NewImporterRegistry()
var imp pipeline.Importer = texture.New()
if grouped {
imp = atlas.Wrap(imp)
}
if err := reg.Register(imp); err != nil {
t.Fatal(err)
}
pl, err := pipeline.Open(prj, reg, log.Nop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(pl.Close)
if _, _, err := pl.Scan(); err != nil {
t.Fatal(err)
}
albedo, _ := pl.Lookup("assets/hero.png")
normal, _ := pl.Lookup("assets/hero_n.png")
return pl, albedo, normal
}
func regionFromPipeline(t *testing.T, pl *pipeline.Pipeline, id ids.AssetID) *asset.RegionArtifact {
t.Helper()
e, ok := pl.Entry(id)
if !ok {
t.Fatal("missing region entry", id)
}
a, ok := e.Artifact(asset.ArtSpriteRegion)
if !ok {
t.Fatal("missing region artifact", id)
}
data, err := pl.Artifacts().ReadFile(a.Path)
if err != nil {
t.Fatal(err)
}
r, err := asset.DecodeRegion(data)
if err != nil {
t.Fatal(err)
}
return r
}
func TestNormalPairPersistsIDsAndFrameMappingThroughRename(t *testing.T) {
pl, albedo, normal := pairProject(t, false)
aFrames, nFrames := cells(2), cells(2)
if err := pl.SetSubs(albedo, aFrames); err != nil {
t.Fatal(err)
}
if err := pl.SetSubs(normal, nFrames); err != nil {
t.Fatal(err)
}
meta, _ := pl.MetaOf(albedo)
meta.Settings["user_note"] = aetext.String("keep me")
if err := pl.SetSettings(albedo, meta.Settings); err != nil {
t.Fatal(err)
}
batch, err := pl.ImportAll()
if err != nil || len(batch.Failed) != 0 {
t.Fatalf("import: %v %v", err, batch.Failed)
}
meta, _ = pl.MetaOf(albedo)
if meta.Settings["normal"].Str != normal.String() || meta.Settings.String("user_note", "") != "keep me" {
t.Fatalf("association not persisted or user metadata lost: %+v", meta.Settings)
}
for i := range aFrames {
if r := regionFromPipeline(t, pl, aFrames[i].ID); r.Normal != nFrames[i].ID {
t.Fatalf("frame %d did not bind normal: %+v", i, r)
}
if err := pl.VerifyCurrent(aFrames[i].ID); err != nil {
t.Fatal("first import must already be export-current:", err)
}
}
if err := pl.Move(normal, "assets/renamed_surface.png"); err != nil {
t.Fatal(err)
}
nFrames[0].Name = "renamed_frame"
if err := pl.SetSubs(normal, nFrames); err != nil {
t.Fatal(err)
}
batch, err = pl.ImportAll()
if err != nil || len(batch.Failed) != 0 {
t.Fatalf("reimport after rename: %v %v", err, batch.Failed)
}
if r := regionFromPipeline(t, pl, aFrames[0].ID); r.Normal != nFrames[0].ID {
t.Fatal("renaming normal file or frame broke stable mapping")
}
nmeta, _ := pl.MetaOf(normal)
if nmeta.Settings.String("semantic", "") != "normal" {
t.Fatal("normal semantic was not persisted before rename")
}
if err := pl.SetSubs(normal, nFrames[1:]); err != nil {
t.Fatal(err)
}
batch, err = pl.ImportAll()
if err != nil || len(batch.Failed) != 0 {
t.Fatalf("removed normal frame: %v %v", err, batch.Failed)
}
r := regionFromPipeline(t, pl, aFrames[0].ID)
e, _ := pl.Entry(aFrames[0].ID)
if r.Normal != nFrames[0].ID || !slices.Contains(e.Deps, nFrames[0].ID) {
t.Fatal("unresolved normal frame ID/dependency was deleted")
}
}
func TestNormalPairUsesIndependentDataAtlas(t *testing.T) {
pl, albedo, normal := pairProject(t, true)
for _, id := range []ids.AssetID{albedo, normal} {
m, _ := pl.MetaOf(id)
m.Settings["atlas"] = aetext.String("characters")
m.Settings["trim"] = aetext.Bool(true)
if err := pl.SetSettings(id, m.Settings); err != nil {
t.Fatal(err)
}
}
batch, err := pl.ImportAll()
if err != nil || len(batch.Failed) != 0 {
t.Fatalf("atlas import: %v %v", err, batch.Failed)
}
a, n := regionFromPipeline(t, pl, albedo), regionFromPipeline(t, pl, normal)
if a.Page == n.Page || a.Normal != normal || a.SourceW != n.SourceW || a.SourceH != n.SourceH {
t.Fatalf("independent normal atlas mapping wrong: albedo=%+v normal=%+v", a, n)
}
for _, tc := range []struct {
id ids.AssetID
semantic asset.TextureSemantic
format gpu.TextureFormat
}{{a.Page, asset.TextureColor, gpu.TexSRGBA8}, {n.Page, asset.TextureNormal, gpu.TexRGBA8}} {
e, _ := pl.Entry(tc.id)
art, _ := e.Artifact(asset.ArtAtlasPage)
data, err := pl.Artifacts().ReadFile(art.Path)
if err != nil {
t.Fatal(err)
}
page, err := asset.DecodeTexture(data)
if err != nil || page.Semantic != tc.semantic || page.Format != tc.format {
t.Fatalf("atlas page semantics wrong: %+v %v", page, err)
}
if err := pl.VerifyCurrent(tc.id); err != nil {
t.Fatal("atlas must be export-current:", err)
}
}
}
func TestExplicitMissingNormalWinsOverDiscovery(t *testing.T) {
pl, albedo, normal := pairProject(t, false)
missing := ids.NewAssetID()
m, _ := pl.MetaOf(albedo)
m.Settings["normal"] = aetext.AssetRef(missing.String())
if err := pl.SetSettings(albedo, m.Settings); err != nil {
t.Fatal(err)
}
batch, err := pl.ImportAll()
if err != nil || len(batch.Failed) != 0 {
t.Fatalf("missing ref import: %v %v", err, batch.Failed)
}
r := regionFromPipeline(t, pl, albedo)
e, _ := pl.Entry(albedo)
if r.Normal != missing || slices.Contains(e.Deps, normal) || !slices.Contains(e.Deps, missing) {
t.Fatalf("explicit unresolved reference was overwritten: %+v %+v", r, e.Deps)
}
before, err := os.ReadFile(pl.Abs("assets/hero.png.meta"))
if err != nil {
t.Fatal(err)
}
if _, err := pl.Preview("assets/hero.png", sheet(t, 32, 16), nil); err != nil {
t.Fatal(err)
}
after, _ := os.ReadFile(pl.Abs("assets/hero.png.meta"))
if !bytes.Equal(before, after) {
t.Fatal("preview modified source metadata")
}
}
func TestNormalPairReimportChecksCurrentSourceAndCanvas(t *testing.T) {
pl, albedo, normal := pairProject(t, false)
batch, err := pl.ImportAll()
if err != nil || len(batch.Failed) != 0 {
t.Fatalf("import: %v %v", err, batch.Failed)
}
before := regionFromPipeline(t, pl, albedo)
if err := os.WriteFile(pl.Abs("assets/hero_n.png"), sheet(t, 16, 16), 0o644); err != nil {
t.Fatal(err)
}
if err := pl.VerifyCurrent(albedo); err == nil {
t.Fatal("strict validation accepted stale normal source")
}
batch, err = pl.Reimport(albedo, normal)
if err != nil || batch.Failed[albedo] == nil {
t.Fatalf("incompatible normal canvas was not diagnosed: %v %v", err, batch.Failed)
}
if current := regionFromPipeline(t, pl, albedo); *current != *before {
t.Fatal("invalid pair replaced last-good region")
}
if err := os.WriteFile(pl.Abs("assets/hero_n.png"), sheet(t, 32, 16), 0o644); err != nil {
t.Fatal(err)
}
batch, err = pl.Reimport(albedo, normal)
if err != nil || len(batch.Failed) != 0 {
t.Fatalf("pair did not recover: %v %v", err, batch.Failed)
}
if err := pl.VerifyCurrent(albedo); err != nil {
t.Fatal(err)
}
}
func TestSurfaceSettingsRejectInvalidTypesAndNumbers(t *testing.T) {
imp := texture.New()
for _, tc := range []struct {
name string
value aetext.Value
}{
{"height_strength", aetext.Float64(math.NaN())}, {"height_strength", aetext.Float64(math.Inf(1))},
{"height_strength", aetext.Float32(-1)}, {"height_strength", aetext.String("strong")},
{"height_channel", aetext.Ident("red-ish")}, {"normal_y", aetext.Ident("guess")},
{"generate_normal", aetext.String("true")}, {"semantic", aetext.Int(3)},
} {
s := imp.Defaults()
s["semantic"] = aetext.Ident("height")
s["generate_normal"] = aetext.Bool(true)
s[tc.name] = tc.value
if _, err := imp.Import(&pipeline.ImportContext{Path: "height.png", Source: sheet(t, 2, 2), Meta: &pipeline.Meta{Settings: s}}); err == nil {
t.Fatalf("invalid %s accepted: %+v", tc.name, tc.value)
}
}
}
func TestNormalPreviewDoesNotPersistDiscovery(t *testing.T) {
pl, albedo, normal := pairProject(t, false)
before, err := os.ReadFile(pl.Abs("assets/hero.png.meta"))
if err != nil {
t.Fatal(err)
}
res, err := pl.Preview("assets/hero.png", sheet(t, 32, 16), nil)
if err != nil {
t.Fatal(err)
}
r, err := asset.DecodeRegion(res.Artifacts[1].Data)
if err != nil || r.Normal != normal {
t.Fatalf("preview did not resolve private pair: %+v %v", r, err)
}
after, err := os.ReadFile(pl.Abs("assets/hero.png.meta"))
if err != nil {
t.Fatal(err)
}
m, _ := pl.MetaOf(albedo)
if !bytes.Equal(before, after) {
t.Fatal("preview wrote metadata")
}
if _, exists := m.Settings["normal"]; exists {
t.Fatal("preview mutated in-memory metadata")
}
}
func TestChangingAtlasSemanticRetiresTheOldPage(t *testing.T) {
pl, albedo, normal := pairProject(t, true)
m, _ := pl.MetaOf(normal)
m.Settings["atlas"] = aetext.String("surface")
if err := pl.SetSettings(normal, m.Settings); err != nil {
t.Fatal(err)
}
// Disable the other source's association: this test deliberately turns the
// normal into mask data and checks atlas ownership, not invalid pairing.
a, _ := pl.MetaOf(albedo)
a.Settings["normal"] = aetext.AssetRef((ids.AssetID{}).String())
if err := pl.SetSettings(albedo, a.Settings); err != nil {
t.Fatal(err)
}
b, err := pl.ImportAll()
if err != nil || len(b.Failed) != 0 {
t.Fatalf("initial import: %v %v", err, b.Failed)
}
old := regionFromPipeline(t, pl, normal).Page
m, _ = pl.MetaOf(normal)
m.Settings["semantic"] = aetext.Ident("mask")
if err := pl.SetSettings(normal, m.Settings); err != nil {
t.Fatal(err)
}
b, err = pl.Reimport(normal)
if err != nil || len(b.Failed) != 0 {
t.Fatalf("semantic reimport: %v %v", err, b.Failed)
}
current := regionFromPipeline(t, pl, normal).Page
if current == old || !slices.Contains(b.Removed, old) {
t.Fatal("semantic change kept the old atlas page resident")
}
if _, exists := pl.Entry(old); exists {
t.Fatal("empty old atlas page remains in export index")
}
if err := pl.VerifyCurrent(current); err != nil {
t.Fatal(err)
}
}