Files
aego/modules/render2d/editor/lighting_preview.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

250 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package editor
import (
"aego/core/ids"
"aego/core/mathx"
"aego/editor/core/context"
"aego/engine/i18n"
"aego/gpu"
"aego/render"
"aego/render/lighting"
"aego/render/texture"
"aego/render/view"
"aego/scene"
"aego/ui/widgets"
"fmt"
"slices"
)
const shadowPreviewSize = 192
type shadowResourcePreview struct {
device gpu.Device
target gpu.TextureHandle
output view.ID
index int
light ids.NodeID
layers []render.ShadowInfo
ready bool
seen bool
reason string
labels [lighting.MaxShadows]string
choices [lighting.MaxShadows]shadowChoice
language string
large bool
}
type shadowChoice struct {
id ids.NodeID
name string
layer int
}
func (p *shadowResourcePreview) syncChoices(s *scene.Scene) {
lang := i18n.Default().Language()
p.index = 0
for i, info := range p.layers {
if info.Light == p.light {
p.index = i
}
choice := shadowChoice{id: info.Light, layer: info.Layer}
if s != nil {
if h := s.Handle(info.Light); s.IsAlive(h) {
choice.name = s.Name(h)
}
}
if p.choices[i] != choice || lang != p.language {
name := choice.name
if name == "" {
name = info.Light.Short()
}
p.labels[i] = name
p.choices[i] = choice
}
}
// Show the layer only for names that collide. Keep ordinary names legible
// in a narrow Inspector and restore short labels when a light disappears.
for i := range p.layers {
choice := p.choices[i]
if choice.name == "" {
continue
}
ambiguous := false
for j := range p.layers {
if i != j && p.choices[j].name == choice.name {
ambiguous = true
break
}
}
if ambiguous {
p.labels[i] = i18n.Tr("editor.lighting.shadow_owner", "{light}, array layer {layer}", i18n.Str("light", choice.name), i18n.Int("layer", int64(choice.layer)))
} else {
p.labels[i] = choice.name
}
}
p.language = lang
}
func (p *shadowResourcePreview) draw(ed *context.Editor) {
p.seen = true
if ed.SceneLighting == nil || ed.Renderer == nil || ed.Renderer.Caps().Renderer == "null" {
return // The Inspector already explains an unavailable graphics device.
}
r := ed.Renderer
id := ed.SceneLighting.LightingOutput()
p.layers = r.LightingShadowLayers(id, p.layers[:0])
if len(p.layers) == 0 {
p.ready = false
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.no_shadow_masks", "This Scene View has no active shadow masks. Enable lighting and a local shadowed light that reaches a Lit surface."), false)
return
}
if p.output != id {
p.output, p.ready, p.reason = id, false, ""
p.index, p.light = 0, ids.NodeID{}
}
// The renderer can reorder layers as lights enter or leave the view.
// Selection is a stable light ID. The widgets receive its current index.
var activeScene *scene.Scene
if ed.Docs != nil && ed.Doc() != nil {
activeScene = ed.Doc().Scene()
}
p.syncChoices(activeScene)
widgets.Combo(ed.UI, ed.UI.ID("lighting.shadow.layer"), widgets.Field(ed.UI, i18n.Tr("editor.lighting.shadow_layer", "Preview light")), &p.index, p.labels[:len(p.layers)], false)
selected := p.layers[p.index]
if p.light != selected.Light {
p.light, p.ready = selected.Light, false
}
widgets.Tooltip(ed.UI, ed.UI.ID("lighting.shadow.layer"), i18n.Tr("editor.lighting.shadow_resource", "Shadow masks: {size} × {size}, {layers} active layers", i18n.Int("size", int64(selected.Resolution)), i18n.Int("layers", int64(len(p.layers)))))
widgets.LabelDim(ed.UI, i18n.Tr("editor.lighting.shadow_summary_short", "{size} px · {count} masks · layer {layer}", i18n.Int("size", int64(selected.Resolution)), i18n.Int("count", int64(len(p.layers))), i18n.Int("layer", int64(selected.Layer))))
if p.device != r.Device() {
p.close()
p.device = r.Device()
}
if p.target.IsZero() {
h, err := p.device.CreateTexture(gpu.TextureDesc{Width: shadowPreviewSize, Height: shadowPreviewSize, Format: gpu.TexSRGBA8, Label: "editor.shadow-preview"})
if err != nil {
widgets.LabelWrapped(ed.UI, err.Error(), true)
return
}
p.target = h
}
target, light := p.target, p.light
ed.OnRenderAfterViews(func() {
if p.target != target || p.output != id || p.light != light || ed.SceneLighting.LightingOutput() != id {
return
}
// Layers can be reused between the UI and render phases. Resolve the
// stable light ID again instead of previewing another light's old slot.
var storage [lighting.MaxShadows]render.ShadowInfo
layer := -1
for _, info := range r.LightingShadowLayers(id, storage[:0]) {
if info.Light == light {
layer = info.Layer
break
}
}
if err := r.PreviewShadowLayer(id, layer, target, shadowPreviewSize, shadowPreviewSize); err != nil {
p.ready, p.reason = false, err.Error()
} else {
p.ready, p.reason = true, ""
}
})
if p.reason != "" {
widgets.LabelWrapped(ed.UI, p.reason, true)
} else if !p.ready {
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.preview_pending", "Preparing the current shadow layer preview…"), false)
} else {
size := float32(112)
if p.large {
size = shadowPreviewSize
}
size = min(size, ed.UI.Cursor().W)
area := ed.UI.Cursor().Next(size)
area.W = size
region := texture.Whole(target, shadowPreviewSize, shadowPreviewSize).FlippedY()
ed.UI.List().Texture(region, area, mathx.Color{R: 1, G: 1, B: 1, A: 1})
maskID := ed.UI.ID("lighting.shadow.legend")
ed.UI.MouseOver(maskID, area)
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.shadow_key", "White: visible · Black: occluded"), false)
widgets.Tooltip(ed.UI, maskID, i18n.Tr("editor.lighting.shadow_legend", "White is visible; black is occluded. This is a light-space mask, before the surface normal and light colour are applied."))
widgets.Checkbox(ed.UI, i18n.Tr("editor.lighting.large_shadow_preview", "Large mask preview"), &p.large)
}
}
func (p *shadowResourcePreview) close() {
if p.device != nil && !p.target.IsZero() {
p.device.DestroyTexture(p.target)
}
p.target = gpu.TextureHandle{}
p.device = nil
p.ready, p.reason = false, ""
}
func (p *lightingEditor) profile(ed *context.Editor) {
if ed.Renderer == nil {
widgets.LabelDim(ed.UI, i18n.Tr("editor.lighting.no_gpu", "Lighting preview unavailable: this session has no graphics device."))
return
}
s := ed.Renderer.Stats()
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.profile_cpu_prepare", "CPU preparation: select {select} ms, shadow geometry {geometry} ms, auxiliary submission {aux} ms", i18n.Float("select", float64(s.LightingSelectNS)/1e6, 3), i18n.Float("geometry", float64(s.ShadowGeometryNS)/1e6, 3), i18n.Float("aux", float64(s.LightingAuxNS)/1e6, 3)), false)
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.profile_cpu_draw", "CPU submission: scene {scene} ms, output resolve {resolve} ms. These are CPU timings, not GPU execution times.", i18n.Float("scene", float64(s.LightingSceneNS)/1e6, 3), i18n.Float("resolve", float64(s.LightingResolveNS)/1e6, 3)), false)
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.profile_sets", "All views this frame: {sets} light sets, {selected} selected entries, {breaks} lighting batch splits", i18n.Int("sets", int64(s.LightSets)), i18n.Int("selected", int64(s.SelectedLights)), i18n.Int("breaks", int64(s.LightingBreaks))), false)
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.profile_dropped", "Budget drops: {lights} light candidates, {shadows} shadows, {cookies} cookies", i18n.Int("lights", int64(s.DroppedLights)), i18n.Int("shadows", int64(s.DroppedShadowLights)), i18n.Int("cookies", int64(s.DroppedCookieLights))), s.DroppedLights+s.DroppedShadowLights+s.DroppedCookieLights > 0)
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.profile_cache", "Shadow masks: {dirty} redrawn, {hits} cache hits, {draws} draws; {copies} cookie copies", i18n.Int("dirty", int64(s.DirtyShadowMasks)), i18n.Int("hits", int64(s.ShadowCacheHits)), i18n.Int("draws", int64(s.ShadowDraws)), i18n.Int("copies", int64(s.CookieCopies))), false)
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.profile_uploads", "Uploads: {ubo} lighting UBO bytes, {shadow} shadow vertex bytes", i18n.Int("ubo", int64(s.LightingUBOBytes)), i18n.Int("shadow", int64(s.ShadowUploadBytes))), false)
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.profile_memory", "Pixel storage: {shadow} shadow bytes, {cookie} cookie bytes, {output} scene-target bytes; {resources} lighting resources", i18n.Int("shadow", int64(s.ShadowBytes)), i18n.Int("cookie", int64(s.CookieBytes)), i18n.Int("output", int64(s.LightingTargetBytes)), i18n.Int("resources", int64(s.LightingResources))), false)
widgets.LabelWrapped(ed.UI, i18n.Tr("editor.lighting.profile_resolve", "{passes} output resolves; {failures} lighting failures", i18n.Int("passes", int64(s.ResolvePasses)), i18n.Int("failures", int64(s.LightingFailures))), s.LightingFailures != 0)
p.drawOutputPasses(ed)
if err := ed.Renderer.LightingError(); err != nil {
widgets.LabelWrapped(ed.UI, err.Error(), true)
}
}
func (p *lightingEditor) drawOutputPasses(ed *context.Editor) {
ui := ed.UI
widgets.Checkbox(ui, i18n.Tr("editor.render.inspect_passes", "Inspect output passes"), &p.inspectPasses)
if !p.inspectPasses {
return
}
rows := ed.Renderer.InspectOutputPasses()
if len(rows) == 0 {
return
}
var ids []view.ID
var labels []string
for _, row := range rows {
if !slices.Contains(ids, row.Output) {
ids = append(ids, row.Output)
labels = append(labels, fmt.Sprintf("%d / %s", row.Output.Scope, row.Output.Node.String()))
}
}
selection := max(slices.Index(ids, p.selectedOutput), 0)
widgets.Combo(ui, ui.ID("render-output"), widgets.Field(ui, i18n.Tr("editor.render.output", "Output")), &selection, labels, false)
p.selectedOutput = ids[selection]
formats := [...]string{"RGBA8", "sRGBA8", "R8", "RGBA16F", "D24S8"}
for _, row := range rows {
if row.Output != p.selectedOutput {
continue
}
target := "?"
if row.Window {
target = i18n.Tr("editor.render.window_target", "Window")
} else if row.KnownFormat && int(row.Format) < len(formats) {
target = formats[row.Format]
}
timing := i18n.Tr("editor.render.no_gpu_sample", "GPU sample unavailable")
if row.GPUValid {
timing = fmt.Sprintf("GPU %.3f ms", float64(row.GPUNS)/1e6)
}
label := row.Label
if row.InstanceID != "" {
label += " [" + row.InstanceID + "]"
}
if label == "" {
label = i18n.Tr("editor.render.world_pass", "World")
}
widgets.LabelWrapped(ui, fmt.Sprintf("%s · %d × %d %s · %s", label, row.Width, row.Height, target, timing), false)
}
widgets.LabelDim(ui, i18n.Tr("editor.render.ui_after", "Game UI follows the camera output."))
}