Files

493 lines
16 KiB
Go

package gpupreview
import (
"fmt"
"math"
"slices"
"aego/art"
"aego/asset"
"aego/asset/loader"
"aego/core/ids"
"aego/core/log"
"aego/core/mathx"
"aego/engine"
"aego/engine/base"
"aego/engine/module"
"aego/gpu"
"aego/modules/particles"
"aego/modules/render2d"
"aego/platform"
"aego/render"
renderhost "aego/render/host"
"aego/render/lighting"
"aego/render/material"
"aego/render/postfx"
"aego/render/shader"
"aego/scene"
"aego/scene/frame"
"aego/schema"
)
// Options affect only this isolated presentation world, never the art document
// or an editor's EditScene. Frame selection uses the same Art Core IDs.
type Options struct {
EffectTemplate string `json:"effectTemplate,omitempty"`
RateOverride *float32 `json:"rateOverride,omitempty"`
Paused bool `json:"paused"`
Mode string `json:"mode"`
Frame art.ID `json:"frame"`
FlipX bool `json:"flipX"`
FlipY bool `json:"flipY"`
Bloom bool `json:"bloom"`
Shadows bool `json:"shadows"`
LightX float32 `json:"lightX"`
LightY float32 `json:"lightY"`
Intensity float32 `json:"intensity"`
Emission float32 `json:"emission"`
Amount float32 `json:"amount"`
Rate float32 `json:"rate"`
Speed float32 `json:"speed"`
Lifetime float32 `json:"lifetime"`
}
func DefaultOptions() Options {
return Options{Mode: "sprite", LightX: -.7, LightY: -.65, Intensity: 2.2, Emission: 3, Amount: .5, Rate: 16, Speed: 30, Lifetime: 1.4}
}
type Status struct {
EffectTemplate string `json:"effectTemplate,omitempty"`
ParticlePreset string `json:"particlePreset,omitempty"`
ParticleRate float32 `json:"particleRate"`
ParticleEmitted uint64 `json:"particleEmitted"`
State string `json:"state"`
Error string `json:"error,omitempty"`
Mode string `json:"mode"`
Document string `json:"document"`
Revision uint64 `json:"revision"`
Sequence uint64 `json:"sequence"`
Frame art.ID `json:"frame"`
Particles int `json:"particles"`
Renderer string `json:"renderer"`
Stats render.Stats `json:"stats"`
Frames int `json:"frames"`
HasNormal bool `json:"hasNormal"`
HasHeight bool `json:"hasHeight"`
HasEmission bool `json:"hasEmission"`
}
type App struct {
e *engine.Engine
r *render.Renderer
loader *loader.Loader
types *scene.Types
man *module.Manifest
world *engine.World
sc *scene.Scene
runner *frame.Runner
detach func()
sprite, camera, light, environment, floor, emitter scene.NodeHandle
data *prepared
options Options
presets map[string]particles.ParticleSystem2D
particleDocument, particleTemplate string
particleExtent float32
status Status
closed bool
}
// New builds only the base, render2d and particles modules. It excludes the
// desktop shell, file importers, networking, scripting and scene-editor UI.
func New(headless bool) (*App, error) {
a := &App{options: DefaultOptions(), status: Status{State: "empty"}, presets: make(map[string]particles.ParticleSystem2D)}
var err error
a.types, err = base.NewTypes()
if err != nil {
return nil, err
}
a.man, err = module.Load(a.types, nil, base.Core{}, render2d.New(), particles.New())
if err != nil {
return nil, err
}
if err = a.types.Schema().Freeze(); err != nil {
return nil, err
}
rmod := renderhost.New(render.Options{MaxSprites: 2048})
b := engine.NewBuilder(engine.Config{Title: "Asset Studio Preview", Width: 480, Height: 320,
Headless: headless, HeadlessFrameTime: 1.0 / 60, AppID: "aego-art-preview", Seed: 1,
VSync: platform.VSyncOn, LoopScope: engine.ScopeEditor}).WithLogger(log.Nop()).Use(rmod)
if err = frame.Register(b.Registry()); err != nil {
return nil, err
}
b.Use(a.man.Engine())
a.e, err = b.Build()
if err != nil {
return nil, err
}
defer func() {
if err != nil {
a.Close()
}
}()
a.r = rmod.Renderer()
a.loader = loader.New(a.e.GPU(), a.r.Textures(), a.e.Log())
a.loader.SetMaterials(a.r.Materials())
if err = a.loader.Mount(asset.NewIndex(), a.e.VFS()); err != nil {
return nil, err
}
a.e.SetService(loader.ServiceID, a.loader)
if err = a.man.Init(&module.InitContext{Engine: a.e, Assets: a.loader, Log: a.e.Log(), Editor: true}); err != nil {
return nil, err
}
a.world = engine.NewWorld("asset-studio-preview")
a.sc = scene.New(a.types, a.loader, scene.Options{InitialNodes: 16, InitialComponents: 16})
a.runner = frame.New()
a.runner.Bind(a.sc, a.r, a.loader)
a.world.SetService(scene.ServiceID, a.sc)
a.world.SetService(frame.ServiceID, a.runner)
a.camera = a.sc.Create(a.sc.Root(), "Preview Camera")
*scene.Add[render2d.Camera2D](a.sc, a.camera) = render2d.DefaultCamera2D()
post := scene.Add[render2d.PostProcess](a.sc, a.camera)
bloom, _ := postfx.NewRegistry().NewInstance(postfx.BloomID, "preview-bloom")
bloom.Parameters = []postfx.Parameter{{Name: "Threshold", Kind: shader.KindFloat, Value: mathx.Vec4{X: .65}}, {Name: "Intensity", Kind: shader.KindFloat, Value: mathx.Vec4{X: .7}}}
post.Effects = []postfx.Instance{bloom}
a.sprite = a.sc.Create(a.sc.Root(), "Artwork")
*scene.Add[render2d.SpriteRenderer](a.sc, a.sprite) = render2d.DefaultSpriteRenderer()
oc := scene.Add[render2d.LightOccluder2D](a.sc, a.sprite)
*oc = render2d.DefaultLightOccluder2D()
oc.Source = render2d.OccluderSpriteAlpha
a.floor = a.sc.Create(a.sc.Root(), "Light receiver")
floor := scene.Add[render2d.SpriteRenderer](a.sc, a.floor)
*floor = render2d.DefaultSpriteRenderer()
floor.Order = -1
floor.Tint = mathx.Color{R: .22, G: .25, B: .3, A: 1}
floor.Material.ID, _ = asset.BuiltinMaterialNamed("Lit")
a.light = a.sc.Create(a.sc.Root(), "Preview Light")
*scene.Add[render2d.Light2D](a.sc, a.light) = render2d.DefaultLight2D()
a.environment = a.sc.Create(a.sc.Root(), "Preview Lighting")
*scene.Add[render2d.LightingEnvironment2D](a.sc, a.environment) = render2d.DefaultLightingEnvironment2D()
a.emitter = a.sc.Create(a.sc.Root(), "Preview Particles")
*scene.Add[particles.ParticleSystem2D](a.sc, a.emitter) = particles.DefaultParticleSystem2D()
a.sc.Flush()
a.detach, err = a.man.Attach(&module.AttachContext{World: a.world, Scene: a.sc, Log: a.e.Log(), Editor: true, Play: true})
if err != nil {
return nil, err
}
a.r.Materials().OnError = func(_ *material.Shader, _ int, failure error) {
a.status.Error = failure.Error()
a.status.State = "error"
}
return a, nil
}
func (a *App) Init(*engine.Engine) error { return nil }
func (a *App) Event(platform.Event) {}
func (a *App) FixedUpdate(float64) {}
func (a *App) Update(dt float64) {
if a.closed || a.data == nil || a.options.Paused {
return
}
a.e.TickWorld(a.world, engine.ScopePlay, math.Min(dt, .1))
}
func (a *App) Render(float64) {
if a.closed || a.data == nil || a.options.Paused {
return
}
if d, ok := a.e.GPU().(interface{ ContextLost() bool }); ok && d.ContextLost() {
a.status.State = "context-lost"
return
}
m := a.e.Metrics()
cam, _ := scene.Get[render2d.Camera2D](a.sc, a.camera)
extent := float32(max(a.data.info.Canvas.Width, a.data.info.Canvas.Height)) * 1.55
if a.options.Mode == "particles" {
extent *= 2.2
if a.particleTemplate != "" {
extent = a.particleExtent
}
}
cam.Zoom = float32(min(m.PixelW, m.PixelH)) / max(extent, 1)
a.e.RunPhase(a.world, engine.PhaseRenderPrepare, engine.ScopePlay, 0, 0)
a.e.RunPhase(a.world, engine.PhaseRender, engine.ScopePlay, 0, 0)
a.status.State = "ready"
if a.status.Error != "" {
a.status.State = "error"
}
}
func (a *App) Shutdown() { a.release() }
func (a *App) Run() error { return a.e.Run(a) }
func (a *App) Step() bool { return a.e.Step(a) }
func (a *App) Publish(sequence, revision uint64, data []byte) error {
if a.closed {
return fmt.Errorf("preview is closed")
}
if sequence == 0 || sequence <= a.status.Sequence {
return art.ErrConflict
}
next, err := prepare(data)
if err != nil {
return err
}
if next.info.Revision != revision {
return art.ErrConflict
}
// Reject a bad template before publishing textures or replacing the previous
// document. Templates are trusted catalog IDs, never paths or arbitrary code.
if id := next.info.Properties[art.EffectTemplateProperty]; id != "" {
if _, _, err = a.effectPreset(id); err != nil {
return err
}
}
// Preserve unchanged resident pages and shader/material generations. Only
// changed prepared bytes enter the existing atomic loader publication path.
previous := map[ids.AssetID]asset.Entry{}
if a.data != nil {
for _, entry := range a.data.entries {
previous[entry.ID] = entry
}
}
var changed []asset.Entry
for _, entry := range next.entries {
old, ok := previous[entry.ID]
if !ok || old.Kind != entry.Kind || !slices.Equal(old.Artifacts, entry.Artifacts) || !slices.Equal(old.Deps, entry.Deps) {
changed = append(changed, entry)
}
}
if err = a.loader.Swap(changed, next.files); err != nil {
return err
}
if old := a.data; old != nil {
keep := make(map[ids.AssetID]bool, len(next.entries))
for _, e := range next.entries {
keep[e.ID] = true
}
for _, e := range old.entries {
if !keep[e.ID] {
a.loader.Drop(e.ID)
}
}
}
if a.data == nil || a.data.info.ID != next.info.ID {
// A document's own template wins when switching tabs. Presentation rate
// overrides belong to the previous document and must not leak across tabs.
a.options.EffectTemplate = ""
a.options.RateOverride = nil
}
a.data = next
a.status.Document = next.info.ID
a.status.Revision = revision
a.status.Sequence = sequence
a.status.Frames = len(next.info.Frames)
a.status.Error = ""
return a.Configure(a.options)
}
func (a *App) Configure(o Options) error {
if a.closed {
return fmt.Errorf("preview is closed")
}
switch o.Mode {
case "sprite", "lit", "normal", "height", "emission", "flash", "dissolve", "particles":
default:
return fmt.Errorf("unsupported preview mode %q", o.Mode)
}
for _, v := range []float32{o.LightX, o.LightY, o.Intensity, o.Emission, o.Amount, o.Rate, o.Speed, o.Lifetime} {
if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) {
return fmt.Errorf("preview parameters must be finite")
}
}
if o.Intensity < 0 || o.Intensity > 8 || o.Emission < 0 || o.Emission > 16 || o.Amount < 0 || o.Amount > 1 || o.Rate < 0 || o.Rate > 256 || o.Speed < 0 || o.Speed > 256 || o.Lifetime <= 0 || o.Lifetime > 10 {
return fmt.Errorf("preview parameter outside supported range")
}
if rate := o.RateOverride; rate != nil && (math.IsNaN(float64(*rate)) || math.IsInf(float64(*rate), 0) || *rate < 0 || *rate > 256) {
return fmt.Errorf("preview particle rate outside supported range")
}
templateID := o.EffectTemplate
if templateID == "" && a.data != nil {
templateID = a.data.info.Properties[art.EffectTemplateProperty]
}
var preset particles.ParticleSystem2D
var presetName string
if templateID != "" {
var err error
preset, presetName, err = a.effectPreset(templateID)
if err != nil {
return err
}
}
restart := a.options.Mode != "particles" || (a.options.Paused && !o.Paused)
clearRateOverride := a.options.RateOverride != nil && o.RateOverride == nil
a.options = o
a.status.Mode = o.Mode
a.status.Error = ""
if a.data == nil {
return nil
}
p := a.data
f := o.Frame
if _, ok := p.frames[f]; !ok {
f = p.info.Frames[0].ID
}
a.status.Frame = f
refs := p.frames[f]
a.status.HasNormal = !refs[art.Normal].IsZero()
a.status.HasHeight = !refs[art.Height].IsZero()
a.status.HasEmission = !refs[art.Emission].IsZero()
sr, _ := scene.Get[render2d.SpriteRenderer](a.sc, a.sprite)
// Presentation selections do not republish assets or bump loader generation.
// Use the common setter so a previous frame/channel region cannot stay cached.
sr.Sprite.SetAssetRefID(refs[art.Albedo])
sr.FlipX, sr.FlipY = o.FlipX, o.FlipY
sr.Size = mathx.V2(float32(p.info.Canvas.Width), float32(p.info.Canvas.Height))
sr.Origin = mathx.V2(.5, .5)
if pivot, ok := p.info.FramePivot(f); ok {
sr.Origin = mathx.V2(float32(pivot.X)/sr.Size.X, float32(pivot.Y)/sr.Size.Y)
}
mat := "Unlit"
lit := o.Mode == "lit"
if lit {
mat = "Lit"
}
if o.Mode == "flash" {
mat = "Flash"
}
if o.Mode == "dissolve" {
mat = "Dissolve"
}
if o.Mode == "normal" || o.Mode == "height" || o.Mode == "emission" {
id := refs[art.Channel(o.Mode)]
if id.IsZero() {
a.status.Error = "The selected frame has no " + o.Mode + " channel"
a.sc.SetEnabled(a.sprite, false)
a.sc.SetEnabled(a.emitter, false)
a.sc.SetEnabled(a.floor, false)
return nil
}
sr.Sprite.SetAssetRefID(id)
}
sr.Material.ID, _ = asset.BuiltinMaterialNamed(mat)
// The Lit material is also used by the shadow receiver. Authoring preview
// values belong to Artwork's instance; changing the shared material would
// make the receiver emit too. Clear the private instance on other shaders,
// whose schemas do not contain these parameters.
if !lit {
if overrides, ok := scene.Get[render2d.MaterialOverride](a.sc, a.sprite); ok {
overrides.Values = overrides.Values[:0]
}
}
if material, ok := a.loader.Material(sr.Material.ID); ok {
if mat == "Flash" {
material.Set("Flash", [4]float32{o.Amount})
}
if mat == "Dissolve" {
material.Set("Threshold", [4]float32{o.Amount})
}
if mat == "Lit" {
intensity := float32(0)
if a.status.HasEmission {
intensity = o.Emission
}
if err := render2d.SetMaterialParameter(a.sc, a.sprite, "EmissionColor", schema.Color(mathx.Color{R: 1, G: 1, B: 1, A: 1})); err != nil {
return err
}
if err := render2d.SetMaterialParameter(a.sc, a.sprite, "EmissionIntensity", schema.Float32(intensity)); err != nil {
return err
}
}
}
a.sc.SetEnabled(a.sprite, o.Mode != "particles")
a.sc.SetEnabled(a.emitter, o.Mode == "particles")
a.sc.SetEnabled(a.floor, lit && o.Shadows)
floor, _ := scene.Get[render2d.SpriteRenderer](a.sc, a.floor)
floor.Size = sr.Size.Scale(5)
l, _ := scene.Get[render2d.Light2D](a.sc, a.light)
l.Intensity = o.Intensity
l.Range = float32(max(p.info.Canvas.Width, p.info.Canvas.Height)) * 3
l.Height = l.Range * .4
l.Shadows = o.Shadows
l.ShadowCasterMask = -1
a.sc.SetPosition(a.light, mathx.V2(o.LightX*sr.Size.X, o.LightY*sr.Size.Y))
env, _ := scene.Get[render2d.LightingEnvironment2D](a.sc, a.environment)
env.AmbientIntensity = .16
env.ColorMode = lighting.Auto
post, _ := scene.Get[render2d.PostProcess](a.sc, a.camera)
post.Enabled = o.Bloom
return a.configureParticles(templateID, presetName, preset, restart, clearRateOverride, sr.Size.X)
}
func (a *App) Status() Status {
s := a.status
if a.e != nil {
s.Renderer = a.e.Caps().Renderer
}
if a.r != nil {
s.Stats = a.r.Stats()
if s.Stats.MaterialCompileFailures > 0 || s.Stats.LightingFailures > 0 || s.Stats.PostFXFailures > 0 {
s.State = "error"
if s.Error == "" {
s.Error = "GPU preview reported a shader, lighting or PostFX failure"
}
}
}
if a.e != nil {
if device, ok := a.e.GPU().(interface{ ContextLost() bool }); ok && device.ContextLost() {
s.State = "context-lost"
}
}
if a.world != nil {
if p := particles.Of(a.world); p != nil {
s.Particles = p.Stats().Alive
s.ParticleEmitted = p.Emitted(a.emitter)
}
}
return s
}
func (a *App) Resources() gpu.ResourceStats {
if a.e != nil {
if r, ok := a.e.GPU().(gpu.ResourceReporter); ok {
return r.ResourceStats()
}
}
return gpu.ResourceStats{}
}
func (a *App) ContextLoss(lose bool) bool {
if a.e != nil {
if d, ok := a.e.GPU().(interface{ SimulateLoss(bool) bool }); ok {
return d.SimulateLoss(lose)
}
}
return false
}
func (a *App) release() {
if a.closed {
return
}
a.closed = true
if a.detach != nil {
a.detach()
a.detach = nil
}
if a.runner != nil {
a.runner.Close()
a.runner = nil
}
if a.loader != nil {
a.loader.Close()
a.loader = nil
}
if a.data != nil {
a.data = nil
}
if a.man != nil {
a.man.Shutdown()
}
a.status.State = "closed"
}
func (a *App) Close() {
a.release()
if a.e != nil {
_ = a.e.Close()
}
}