Files
aego/app/agent/material.go
T
sanspie a6822156cd
ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
Finish Stage 12 editor fixes and refreshed platformer
2026-11-05 17:36:42 +03:00

487 lines
17 KiB
Go

package agent
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"slices"
"strings"
"aego/asset"
"aego/core/errs"
"aego/core/ids"
"aego/format/aetext"
"aego/render/material"
"aego/render/shader"
"aego/render/texture"
"aego/scene/mutate"
)
func materialCommands() []*Command {
file := Arg{Name: "file", Type: "string", Doc: "material file under assets/, ending in .aemat", Required: true, Pos: true}
shaderArg := Arg{Name: "shader", Type: "string", Doc: "shader asset ID or project .aeshader path", Required: true, Pos: true}
param := Arg{Name: "param", Type: "string", Doc: "declared material parameter name", Required: true, Pos: true}
expected := Arg{Name: "expected", Type: "string", Doc: "sourceHash returned by material.get; refuse if the document changed"}
return []*Command{
{Name: "shader.schema", Doc: "declared parameters, typed defaults, domain and allowed variants; checks current source and unsaved includes", Args: []Arg{shaderArg}, Run: (*Session).shaderSchema},
{Name: "shader.validate", Doc: "validate current source with the registered importer; reports schema/reference validation independently from GPU linking", Args: []Arg{shaderArg}, Run: (*Session).shaderSchema},
{Name: "shader.create", Doc: "create a portable sprite shader through the registered asset importer", Write: true, Args: []Arg{{Name: "file", Type: "string", Doc: "new assets/*.aeshader path", Required: true, Pos: true}}, Run: (*Session).shaderCreate},
{Name: "material.create", Doc: "create a material inheriting defaults from a shader", Write: true, Args: []Arg{file, shaderArg}, Run: (*Session).materialCreate},
{Name: "material.get", Doc: "read explicit/inherited parameters, unknown data, variants and source revision from the current document", Args: []Arg{file}, Run: (*Session).materialGet},
{Name: "material.validate", Doc: "validate material values, shader and texture references without changing the source", Args: []Arg{file}, Run: (*Session).materialValidate},
{Name: "material.set", Doc: "set a typed explicit parameter as one semantic mutation", Write: true, Args: []Arg{file, param, argValue, expected}, Run: func(s *Session, a Args) (*Output, error) {
return s.materialTx(a, mutate.Tx{Label: "Set Material Parameter", Ops: []mutate.Op{mutate.MaterialSet(a.Str("param"), a.Str("value"))}})
}},
{Name: "material.reset", Doc: "remove an explicit parameter and inherit the shader default", Write: true, Args: []Arg{file, param, expected}, Run: func(s *Session, a Args) (*Output, error) {
return s.materialTx(a, mutate.Tx{Label: "Reset Material Parameter", Ops: []mutate.Op{mutate.MaterialReset(a.Str("param"))}})
}},
{Name: "material.variants", Doc: "select an allowed combination of comma-separated keywords; an empty string selects no keywords", Write: true,
Args: []Arg{file, {Name: "keywords", Type: "string", Doc: "comma-separated shader keywords"}, expected}, Run: func(s *Session, a Args) (*Output, error) {
var keywords []string
for _, k := range strings.Split(a.Str("keywords"), ",") {
if k = strings.TrimSpace(k); k != "" {
keywords = append(keywords, k)
}
}
return s.materialTx(a, mutate.Tx{Label: "Set Material Variant", Ops: []mutate.Op{mutate.MaterialVariants(keywords)}})
}},
{Name: "material.apply", Doc: "apply a material transaction atomically, using the same mutations and serialization as Inspector", Write: true,
Args: []Arg{file, {Name: "tx", Type: "string", Doc: "tx version=1 with material-set/reset/shader/variants/blend operations", Required: true}, expected}, Run: func(s *Session, a Args) (*Output, error) {
tx, err := mutate.ParseTx([]byte(a.Str("tx")), "material transaction")
if err != nil {
return nil, err
}
return s.materialTx(a, tx)
}},
{Name: "material.assign", Doc: "assign a compatible material to SpriteRenderer or Mesh2D through the scene mutation service", Write: true,
Args: []Arg{argFile, argNode, {Name: "material", Type: "string", Doc: "material asset ID or project path", Required: true, Pos: true}}, Run: (*Session).materialAssign},
}
}
func sourceHash(src []byte) string {
sum := sha256.Sum256(src)
return hex.EncodeToString(sum[:])
}
func (s *Session) sourceOverlay(rel string) ([]byte, bool) {
if s.host != nil {
if text, ok := s.host.ScriptText(rel); ok {
return []byte(text), true
}
}
return nil, false
}
func (s *Session) currentShader(id ids.AssetID) (*asset.ShaderArtifact, error) {
if data, ok := asset.BuiltinShader(id); ok {
return asset.DecodeShader(data)
}
rel, ok := s.Pipeline.PathOf(id)
if !ok || path.Ext(rel) != ".aeshader" {
return nil, errs.New(errs.AssetNotFound, "shader asset is missing: "+id.String())
}
text, err := s.scriptText(rel)
if err != nil {
return nil, err
}
result, err := s.Pipeline.Preview(rel, []byte(text), s.sourceOverlay)
if err != nil {
return nil, err
}
for _, a := range result.Artifacts {
if a.Kind == asset.ArtShader {
return asset.DecodeShader(a.Data)
}
}
return nil, errs.New(errs.AssetBadArtifact, "the shader importer produced no AESH artifact")
}
func (s *Session) materialShader(id ids.AssetID) (*material.Shader, bool) {
a, err := s.currentShader(id)
if err != nil {
return nil, false
}
return a.Runtime(id, s.label(id)), true
}
type materialParamView struct {
Name string `json:"name"`
Type string `json:"type"`
Default any `json:"default"`
Explicit bool `json:"explicit"`
Value string `json:"value,omitempty"`
Instance bool `json:"instanceOverride"`
Color bool `json:"color,omitempty"`
Normal bool `json:"normal,omitempty"`
Emission bool `json:"emission,omitempty"`
Offset int `json:"offset"`
Slot int `json:"samplerSlot,omitempty"`
}
func paramView(p shader.Param) materialParamView {
var value any
switch p.Kind {
case shader.KindTexture:
value = nil
case shader.KindBool:
value = p.Default[0] != 0
case shader.KindInt:
value = p.IntDefault
case shader.KindFloat:
value = p.Default[0]
default:
value = slices.Clone(p.Default[:p.Kind.Lanes()])
}
return materialParamView{Name: p.Name, Type: p.Kind.GLSL(), Default: value, Instance: p.Instance, Color: p.Color, Normal: p.Normal, Emission: p.Emission, Offset: p.Offset, Slot: p.Slot}
}
func (s *Session) shaderSchema(a Args) (*Output, error) {
id, err := s.asset(a.Str("shader"))
if err != nil {
return nil, err
}
artifact, err := s.currentShader(id)
if err != nil {
return nil, err
}
params := make([]materialParamView, 0, len(artifact.Params))
for _, p := range artifact.Params {
params = append(params, paramView(p))
}
variants := make([][]string, 0, len(artifact.Variants))
for _, v := range artifact.Variants {
keys := []string{}
for i, key := range artifact.Keywords {
if v.Mask&(1<<i) != 0 {
keys = append(keys, key)
}
}
variants = append(variants, keys)
}
return &Output{Value: map[string]any{
"id": id.String(), "domain": artifact.Domain.String(), "blend": artifact.Blend.String(),
"abi": shader.ABI, "parameters": params, "keywords": artifact.Keywords, "variants": variants,
"schemaChecked": artifact.Validation&shader.SchemaChecked != 0,
"referenceChecked": artifact.Validation&shader.ReferenceChecked != 0,
"gl33Linked": false, "webgl2Linked": false,
}}, nil
}
func (s *Session) materialDocument(a Args) (string, []byte, *mutate.MaterialDocument, error) {
rel, err := s.rel(a.Str("file"))
if err != nil {
return "", nil, nil, err
}
if path.Ext(rel) != ".aemat" {
return "", nil, nil, errs.New(errs.VFSBadPath, "material commands take .aemat files")
}
text, err := s.scriptText(rel)
if err != nil {
return "", nil, nil, err
}
src := []byte(text)
if expected := a.Str("expected"); expected != "" && expected != sourceHash(src) {
return "", nil, nil, errs.New(errs.EditorCommand, rel+" changed; read material.get again")
}
doc, err := mutate.LoadMaterial(src, rel, s.materialShader)
return rel, src, doc, err
}
func (s *Session) materialGet(a Args) (*Output, error) {
rel, src, doc, err := s.materialDocument(a)
if err != nil {
return nil, err
}
d := doc.Doc()
params := []materialParamView{}
unknown := map[string]string{}
sh, hasShader := doc.Shader()
if hasShader {
for _, p := range sh.Schema.Params {
v := paramView(p)
raw, explicit := d.Value(p.Name)
if !explicit {
for _, former := range p.Former {
if raw, explicit = d.Value(former); explicit {
break
}
}
}
if explicit {
v.Explicit, v.Value = true, mutate.FormatValue(raw)
}
params = append(params, v)
}
}
for _, v := range d.Values {
if hasShader {
if _, exists := sh.Schema.Param(v.Name); exists {
continue
}
}
if raw, exists := d.Value(v.Name); exists {
unknown[v.Name] = mutate.FormatValue(raw)
}
}
blend := d.Blend
if !d.HasBlend && hasShader {
blend = sh.Blend
}
return &Output{Problems: doc.Problems(), Value: map[string]any{
"file": rel, "shader": d.Shader.String(), "sourceHash": sourceHash(src), "parameters": params,
"unknown": unknown, "variants": d.Variants, "blendExplicit": d.HasBlend, "blend": blend.String(),
}}, nil
}
func (s *Session) materialValidate(a Args) (*Output, error) {
_, _, doc, err := s.materialDocument(a)
if err != nil {
return nil, err
}
problems := doc.Problems()
failed := map[ids.AssetID]string{}
for _, a := range s.Pipeline.Assets() {
if a.Error != "" {
failed[a.ID] = a.Error
}
}
names := make([]string, 0, len(doc.Doc().Textures))
for name := range doc.Doc().Textures {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
id := doc.Doc().Textures[name]
if id.IsZero() {
continue
}
e, exists := s.Pipeline.Entry(id)
sh, _ := doc.Shader()
var param *shader.Param
declared := false
if sh != nil {
param, declared = sh.Schema.Param(name)
}
regionAllowed := declared && (param.Normal || param.Emission)
b, builtin := asset.LookupBuiltin(id)
if (!builtin || b.Kind != asset.ArtTexture) && (!exists || e.Missing || (e.Kind != asset.ArtTexture && e.Kind != asset.ArtAtlasPage && !(regionAllowed && e.Kind == asset.ArtSpriteRegion))) {
problems = append(problems, mutate.Problem{Error: true, Message: "MAT004: texture " + name + " is unresolved or has an incompatible region binding: " + id.String()})
} else if reason := failed[id]; reason != "" {
problems = append(problems, mutate.Problem{Error: true, Message: "MAT004: texture " + name + " has a failed current import: " + reason})
} else if !builtin {
info, valid := s.Pipeline.TextureSourceInfo(id, s.sourceOverlay)
if declared && (param.Normal || param.Emission) {
normal := info.Role == asset.RoleNormal || (info.Role == asset.RoleHeight && info.GenerateNormal)
emission := info.Role == asset.RoleEmissionRGB || info.Role == asset.RoleEmissionMask
if !valid || (param.Normal && !normal) || (param.Emission && !emission) {
problems = append(problems, mutate.Problem{Error: true, Message: "MAT004: incompatible surface import role for " + name + ": " + id.String()})
}
}
if err := s.Pipeline.VerifyCurrent(id); err != nil {
problems = append(problems, mutate.Problem{Error: true, Message: "MAT004: texture " + name + " is not current: " + err.Error()})
}
}
}
return &Output{Problems: problems}, nil
}
func (s *Session) materialTx(a Args, tx mutate.Tx) (*Output, error) {
rel, before, doc, err := s.materialDocument(a)
if err != nil {
return nil, err
}
var res *mutate.Result
if a.Bool("dry-run") {
res, err = doc.DryRun(tx)
} else {
res, err = doc.Apply(tx)
}
if err != nil {
return nil, err
}
out := &Output{DryRun: res.DryRun, Changes: res.Changes, Problems: res.Problems}
if res.DryRun {
return out, nil
}
if s.host != nil {
err = s.host.ApplyScript(ScriptEdit{Rel: rel, Before: string(before), After: string(doc.Bytes())}, tx.Label)
out.Notes = append(out.Notes, "applied to the editor document as one Undo step")
} else {
out.Files, err = s.save(rel, before, doc.Bytes())
}
return out, err
}
func (s *Session) createMaterialSource(a Args, ext string, src []byte) (*Output, error) {
rel, err := s.rel(a.Str("file"))
if err != nil {
return nil, err
}
if path.Ext(rel) != ext || !strings.HasPrefix(rel, "assets/") {
return nil, errs.New(errs.VFSBadPath, "create under assets/ with extension "+ext)
}
if s.exists(rel) {
return nil, errs.New(errs.VFSBadPath, rel+" already exists")
}
if _, err := s.Pipeline.Preview(rel, src, s.sourceOverlay); err != nil {
return nil, err
}
out := &Output{Value: map[string]any{"file": rel, "source": string(src)}}
if a.Bool("dry-run") {
out.DryRun = true
return out, nil
}
if s.host != nil {
if err := s.host.ApplyScript(ScriptEdit{Rel: rel, New: true, After: string(src)}, "Create "+path.Base(rel)); err != nil {
return nil, err
}
if _, _, err := s.Pipeline.Scan(); err != nil {
return nil, err
}
id, exists := s.Pipeline.Lookup(rel)
if !exists {
return nil, errs.New(errs.AssetNotFound, "created source has no registered importer")
}
batch, err := s.Pipeline.Import(id)
if err != nil {
return nil, err
}
if batch != nil && batch.Failed[id] != nil {
return nil, batch.Failed[id]
}
out.Created = []string{id.String()}
} else {
id, err := s.create(rel, src)
if err != nil {
return nil, err
}
out.Created = []string{id.String()}
}
out.Files = []string{rel}
return out, nil
}
func (s *Session) shaderCreate(a Args) (*Output, error) {
src := []byte("#pragma aego abi 1\n#include \"engine/sprite.glsl\"\n\n#pragma material\nuniform float Opacity; // @instance @range(0, 1) @default(1)\n#pragma end\n\nvoid fragment() {\n aego_out = aego_sample() * Opacity;\n}\n")
return s.createMaterialSource(a, ".aeshader", src)
}
func (s *Session) materialCreate(a Args) (*Output, error) {
id, err := s.asset(a.Str("shader"))
if err != nil {
return nil, err
}
if _, err := s.currentShader(id); err != nil {
return nil, err
}
src := []byte(fmt.Sprintf("material version=1 shader=asset(\"%s\") {\n params {}\n}\n", id.String()))
return s.createMaterialSource(a, ".aemat", src)
}
func (s *Session) materialAssign(a Args) (*Output, error) {
id, err := s.asset(a.Str("material"))
if err != nil {
return nil, err
}
var sh *material.Shader
var ok bool
if data, builtin := asset.BuiltinMaterial(id); builtin {
doc, err := asset.DecodeMaterial(data, s.label(id))
if err != nil {
return nil, err
}
sh, ok = s.materialShader(doc.Shader)
} else {
rel, exists := s.Pipeline.PathOf(id)
if !exists {
return nil, errs.New(errs.AssetNotFound, "material source is missing")
}
_, _, doc, err := s.materialDocument(Args{"file": rel})
if err != nil {
return nil, err
}
sh, ok = doc.Shader()
}
if !ok {
return nil, errs.New(errs.AssetNotFound, "the material shader is unavailable")
}
component := "engine.SpriteRenderer"
if sh.Domain == shader.DomainMesh2D {
component = "engine.Mesh2D"
} else if sh.Domain != shader.DomainSprite {
return nil, errs.New(errs.EditorProps, "material domain cannot be assigned to a 2D node")
}
return s.sceneTx(a, "Assign Material", mutate.SetValue(a.Str("node"), component+".material", aetext.Value{Kind: aetext.VAsset, Str: id.String()}))
}
// Source-only sessions resolve runtime material definitions without GPU handles or device-link validation.
func (r sources) Shader(id ids.AssetID) (*material.Shader, bool) {
data, builtin := asset.BuiltinShader(id)
if !builtin {
rel, ok := r.pl.PathOf(id)
if !ok {
return nil, false
}
src, ok := r.read(id)
if !ok {
return nil, false
}
result, err := r.pl.Preview(rel, src, nil)
if err != nil {
return nil, false
}
for _, a := range result.Artifacts {
if a.Kind == asset.ArtShader {
data = a.Data
break
}
}
}
a, err := asset.DecodeShader(data)
if err != nil {
return nil, false
}
return a.Runtime(id, id.String()), true
}
func (r sources) Material(id ids.AssetID) (*material.Material, bool) {
var doc *asset.MaterialDoc
var err error
if data, builtin := asset.BuiltinMaterial(id); builtin {
doc, err = asset.DecodeMaterial(data, id.String())
} else {
src, ok := r.read(id)
if !ok {
return nil, false
}
doc, err = asset.ParseMaterial(src, id.String())
}
if err != nil {
return nil, false
}
sh, ok := r.Shader(doc.Shader)
if !ok {
return nil, false
}
m, _ := doc.Build(id.String(), sh, func(tid ids.AssetID) (material.Tex, bool) {
if b, ok := asset.LookupBuiltin(tid); ok {
return material.Tex{}, b.Kind == asset.ArtTexture
}
e, ok := r.pl.Entry(tid)
info, valid := r.pl.TextureSourceInfo(tid, nil)
mode := texture.EmissionNone
switch info.Role {
case asset.RoleEmissionRGB:
mode = texture.EmissionRGB
case asset.RoleEmissionMask:
mode = texture.EmissionMask
}
return material.Tex{RegionOnly: e.Kind == asset.ArtSpriteRegion, NormalMap: valid && (info.Role == asset.RoleNormal || (info.Role == asset.RoleHeight && info.GenerateNormal)), EmissionMode: mode}, ok && !e.Missing && (e.Kind == asset.ArtTexture || e.Kind == asset.ArtAtlasPage || e.Kind == asset.ArtSpriteRegion)
})
if m != nil {
m.ID = id
}
return m, m != nil
}