Author SHA1 Message Date
sanspie 9612140432 ci: add opt-in Stage web diagnostics 2026-11-07 10:36:17 +03:00
sanspie 31f2da9f79 ci: make native timing and installer smoke independent
Use a deterministic headless frame clock and check the actual 2D and 3D rotation. Create a guarded fresh project with the installed CLI even when preceding tests failed.
2026-11-07 06:43:53 +03:00
sanspie a10633ec8d ci: add opt-in cross-platform Aseprite diagnostics
Run real fixture and headless editor-import tests for an exact Stage commit without changing ordinary native CI.
2026-11-07 06:07:41 +03:00
sanspie ee267286a3 ci: include ancestor ACLs in Windows diagnostics 2026-11-07 05:19:37 +03:00
sanspie e3d7b8c8a5 ci: add isolated native remote store diagnostics
Only an explicit workflow_dispatch with an exact Stage commit runs these tests; ordinary native CI remains unchanged.
2026-11-07 05:08:47 +03:00
sanspie 1ffd388350 ci: isolate native integration tests and retain installer diagnostics 2026-11-07 03:56:53 +03:00
sanspie 747b6bc3b9 fix(audio): measure Windows mix time with performance counter 2026-11-07 03:12:17 +03:00
sanspie 047132e05d fix(windows): preserve user PATH during install 2026-11-07 02:38:43 +03:00
sanspie ee235bbf14 ci: mirror verified Gitea releases to GitHub
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 6m1s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m35s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: success
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
2026-11-07 01:12:53 +03:00
sanspie 216bdbb19d test: measure agent allocations after previews settle 2026-11-07 01:11:28 +03:00
sanspie a54ce4902a fix: make native tests portable across Windows
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Successful in 6m2s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m11s
ci / release (push) Skipped
2026-11-07 00:54:36 +03:00
sanspie 2f768d4166 fix: preserve native export and updater checks
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 7m12s
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 14m42s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: failure
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
2026-11-06 21:04:10 +03:00
sanspie a0107b622a ci: report native GitHub results to Gitea
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m49s
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Successful in 6m36s
ci / release (push) Skipped
2026-11-06 20:43:11 +03:00
sanspie eb937b4470 ci: route mirror jobs safely and build pinned validator
ci / checks (push) Successful in 5m35s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
ci / release (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m0s
2026-11-06 20:33:00 +03:00
sanspie 1d7cee7cc7 test: update light scene preview goldens
ci-platforms / windows amd64 smoke (push) Skipped
github-native / linux amd64 native (push) Skipped
ci / checks (push) Successful in 6m34s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m53s
ci / release (push) Skipped
github-native / darwin amd64 native (push) Canceled after 0s
github-native / windows amd64 native (push) Canceled after 0s
github-native / darwin arm64 native (push) Canceled after 0s
2026-11-06 17:11:45 +03:00
sanspie 85d9efc67b ci: make repository paths portable to Windows 2026-11-06 17:11:18 +03:00
sanspie ab6ea7ef58 editor: improve scene tools and prepare native CI
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / linux amd64 native (push) Skipped
ci / checks (push) Successful in 6m33s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m26s
ci / release (push) Skipped
github-native / darwin amd64 native (push) Canceled after 0s
github-native / windows amd64 native (push) Canceled after 0s
github-native / darwin arm64 native (push) Canceled after 0s
2026-11-06 16:49:11 +03:00
sanspie 9c02975882 engine: cache frame pacer capability
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 5m59s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 8m7s
ci / release (push) Skipped
2026-11-06 15:47:34 +03:00
sanspie 6a148e63e0 ci: pause releases and fix profiler lint
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 11m27s
ci / release (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 5m41s
2026-11-06 15:20:17 +03:00
sanspie 48d1eabef2 profiler: report actual FPS and idle waits
ci / checks (push) Failing after 5m21s
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 7m49s
ci / release (push) Skipped
2026-11-06 12:27:31 +03:00
95 changed files with 4007 additions and 166 deletions
+5
View File
@@ -0,0 +1,5 @@
# Golden files and project sources are byte-stable across platforms.
* text=auto eol=lf
# Preserve the upstream font license exactly as committed (it uses CRLF).
tools/gendocs/site/static/fonts/IBM-Plex-OFL.txt -text
+39 -1
View File
@@ -1,3 +1,41 @@
# CI workflows
The workflows in this directory automate checks, builds, tests, and release publishing for Aego. Optional platform-runner jobs cover Windows and macOS alongside the main Linux checks.
Gitea is the source of truth. The Linux workflow runs there; optional Gitea
platform runners remain controlled by `PLATFORM_RUNNERS`.
`github-native.yml` is for the GitHub mirror. It builds and tests on native
Windows, macOS (arm64 and amd64), and Linux runners, and uploads unsigned CI
artifacts and diagnostic logs. The Windows installer smoke is restricted to an
ephemeral GitHub-hosted runner. macOS bundles are ad-hoc signed, not notarized.
These jobs are not evidence of platform support until they actually pass on
their respective runners.
On Gitea, the GitHub-only matrix routes to the Linux runner before being
skipped. This prevents Windows and macOS entries from waiting indefinitely for
runners that Gitea does not have. On GitHub, the actual native labels are used.
macOS and Windows build the pinned GLSL reference validator from verified
source, rather than depending on a changing package-manager version.
To show native GitHub results on the Gitea commit, create the GitHub Actions
secret `GITEA_STATUS_TOKEN` containing a Gitea API token with repository write
permission. Prefer a non-admin bot that has access only to `sanspie/aego`:
Gitea's repository-write token is broader than commit statuses. The reporter
publishes pending and final statuses with links to the real GitHub jobs only
for trusted `master` pushes or manual runs, never for pull requests. If the
secret is missing, CI still runs but no Gitea statuses are published.
Keep GitHub Actions disabled through the first mirror sync: historical tags
can contain older workflows. Before enabling Actions, verify mirrored refs and
restrict the allowed workflows so historical release jobs cannot run. Use a
separate repository-scoped credential for the mirror; do not put it in a URL.
Gitea's build-and-publish job remains paused. `github-release-mirror.yml`
copies already-published Gitea releases and assets to GitHub after the current
master passes Gitea Linux CI and all four real GitHub native jobs. It also
checks periodically and can be dispatched manually from master. Versioned
stable releases are published normally, with the highest stable version marked
Latest; nightly, beta versions, and the stable-channel pointer are prereleases.
The mirror verifies matching git refs and asset sizes/checksums, stages new
releases as drafts, and never moves tags or replaces existing assets. If a
mutable nightly or another mirrored snapshot changes, the mirror stops rather
than overwriting it; a separate reviewed update is needed in that case. Signed
update manifests are copied unchanged and retain their original download URLs.
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
# Native runs on Windows and macOS machines; the Windows build itself is cross-compiled on Linux in ci.yml (D301).
smoke:
name: ${{ matrix.goos }} ${{ matrix.goarch }} smoke
if: vars.PLATFORM_RUNNERS == 'true'
if: github.server_url != 'https://github.com' && vars.PLATFORM_RUNNERS == 'true'
strategy:
fail-fast: false
matrix:
+5 -1
View File
@@ -20,6 +20,7 @@ env:
jobs:
checks:
if: github.server_url != 'https://github.com'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -44,6 +45,7 @@ jobs:
run: make nsis-check
build:
if: github.server_url != 'https://github.com'
strategy:
fail-fast: false
matrix:
@@ -121,7 +123,9 @@ jobs:
release:
needs: [checks, build]
if: github.event_name == 'push'
# Pause all automatic releases until the reviewed nightly/stable pipeline
# enforces native gates and preserves existing tags and release assets.
if: ${{ false }}
runs-on: ubuntu-24.04
permissions:
contents: write
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
name: github-release-mirror
on:
workflow_run:
workflows: [github-native]
types: [completed]
branches: [master]
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: github-release-mirror
cancel-in-progress: false
jobs:
mirror:
if: github.server_url == 'https://github.com' && github.repository == 'Alexander-D-Karpov/aego' && github.ref == 'refs/heads/master'
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: write
actions: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: refs/heads/master
persist-credentials: false
- name: Test release mirror
run: python3 -m unittest discover -s tools/ci -p mirror_releases_test.py -v
- name: Mirror published Gitea releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 tools/ci/mirror_releases.py --apply
+6 -4
View File
@@ -1,6 +1,7 @@
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"testing"
@@ -98,7 +99,8 @@ func TestKeyTracksNativeSources(t *testing.T) {
func TestLocalReplaces(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "go.mod"), `module game
absolute := filepath.VolumeName(root) + "/abs/c"
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf(`module game
go 1.27
@@ -107,9 +109,9 @@ replace aego => ../engine // local engine
replace (
example.com/a v1.2.3 => ./vendor/a
example.com/b => example.com/b v1.0.0
example.com/c => /abs/c
example.com/c => %s
)
`)
`, absolute))
got, err := LocalReplaces(root)
if err != nil {
t.Fatal(err)
@@ -117,7 +119,7 @@ replace (
want := map[string]bool{
filepath.Clean(filepath.Join(root, "../engine")): true,
filepath.Clean(filepath.Join(root, "vendor", "a")): true,
filepath.Clean("/abs/c"): true,
filepath.Clean(absolute): true,
}
if len(got) != len(want) {
t.Fatalf("replaces = %v", got)
+12 -2
View File
@@ -78,11 +78,21 @@ type Bundle struct {
func sanitizer() func(string) string {
home, _ := os.UserHomeDir()
return func(s string) string {
if home != "" {
s = strings.ReplaceAll(s, home, "~")
return hideHome(s, home)
}
}
func hideHome(s, home string) string {
if home == "" {
return s
}
// Windows paths can appear with either separator in stack traces and logs.
for _, prefix := range [2]string{home, strings.ReplaceAll(home, `\`, "/")} {
s = strings.ReplaceAll(s, prefix+`\`, "~/")
s = strings.ReplaceAll(s, prefix+"/", "~/")
s = strings.ReplaceAll(s, prefix, "~")
}
return s
}
func Write(b Bundle) (string, error) {
+13
View File
@@ -3,6 +3,7 @@ package crash
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -10,6 +11,9 @@ import (
func TestBundleHidesHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", home)
}
root := filepath.Join(home, "game")
dir, err := Write(Bundle{Root: root, Kind: "panic", Where: "panel inspector", Panic: "boom in " + root, Stack: []byte(root + "/x.go:1")})
if err != nil {
@@ -25,3 +29,12 @@ func TestBundleHidesHome(t *testing.T) {
}
}
}
func TestHideHomeWithWindowsSeparators(t *testing.T) {
home := `C:\Users\Alice`
for _, input := range []string{`C:\Users\Alice\game\main.go`, `C:/Users/Alice/game/main.go`} {
if got := hideHome(input, home); got != `~/game/main.go` && got != `~/game\main.go` {
t.Fatalf("hideHome(%q) = %q", input, got)
}
}
}
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
//go:build aego_gltest
package edit
import (
"runtime"
"testing"
"aego/asset"
"aego/core/log"
"aego/core/mathx"
"aego/editor/core/document"
"aego/modules/render2d"
"aego/scene"
"aego/scene/frame"
)
func TestSceneView3DShows2DSpritePixels(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
p := newProject(t)
const path = "scenes/2d-in-3d.aescene"
writeScene(t, p, path, func(s *scene.Scene) {
h := s.Create(s.Root(), "Red sprite")
sr := scene.Add[render2d.SpriteRenderer](s, h)
*sr = render2d.DefaultSpriteRenderer()
sr.Sprite = asset.NewRef[asset.Texture](asset.BuiltinWhite)
sr.Tint = mathx.Color{R: 1, A: 1}
sr.Size = mathx.V2(4, 4)
})
s, err := Open(p, Config{Width: 800, Height: 600, Hidden: true, NoWatch: true, Logger: log.Nop()})
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.Step()
ed := s.App.Context()
doc, err := ed.Docs.Open(path)
if err != nil {
t.Fatal(err)
}
doc.View.Mode = document.View3D
doc.View.Orbit = document.Orbit{Distance: 10, FOV: 60}
ed.Settings.Grid = false
for range 3 {
s.Step()
}
raw, ok := s.App.SceneView().LightingWorld().ServiceAny(frame.ServiceID)
if !ok {
t.Fatal("missing Scene View runner")
}
runner := raw.(*frame.Runner)
readRed := func() int {
t.Helper()
for range 2 {
s.Step()
}
target, w, h := runner.Target()
pixels := make([]byte, w*h*4)
if target.IsZero() {
t.Fatal("no Scene View target")
}
if err := ed.Renderer.Device().ReadPixels(target, 0, 0, w, h, pixels); err != nil {
t.Fatal(err)
}
count := 0
for i := 0; i+3 < len(pixels); i += 4 {
if pixels[i] > 160 && pixels[i+1] < 80 && pixels[i+2] < 80 {
count++
}
}
return count
}
visible := readRed()
if visible < 100 {
t.Fatalf("2D sprite produced only %d red pixels in 3D Scene View", visible)
}
sc := doc.Scene()
h := sc.Find(sc.Root(), "Red sprite")
sc.SetEnabled(h, false)
if hidden := readRed(); hidden >= visible/2 {
t.Fatalf("disabling the sprite left %d of %d red pixels", hidden, visible)
}
}
+17 -4
View File
@@ -43,18 +43,31 @@ func walkExport(dir string, dst *os.Root) ([32]byte, error) {
if err != nil {
return sum, err
}
info, err := os.Lstat(clean)
if err != nil {
return sum, err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return sum, errors.New("export directory was replaced by a symlink or is not a directory")
}
// System paths such as /var on macOS may have symlinked ancestors. Resolve
// them, but pin the final directory and still refuse links inside the export.
actual, err := filepath.EvalSymlinks(clean)
if err != nil {
return sum, err
}
if actual != clean {
return sum, errors.New("export directory was replaced by a symlink")
}
root, err := os.OpenRoot(clean)
root, err := os.OpenRoot(actual)
if err != nil {
return sum, err
}
defer root.Close()
opened, err := root.Stat(".")
if err != nil {
return sum, err
}
if !os.SameFile(info, opened) {
return sum, errors.New("export directory changed during verification")
}
const maxFiles = 32768
const maxBytes = 1 << 30
var count int
+50
View File
@@ -59,3 +59,53 @@ func TestExportSealRefusesChildLink(t *testing.T) {
t.Fatal("symlink into another tree was sealed")
}
}
func TestExportSealSymlinkedAncestorAndRoot(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unprivileged symlinks are not available by default on Windows")
}
dir := t.TempDir()
parent := filepath.Join(dir, "real")
other := filepath.Join(dir, "other")
for _, p := range []string{parent, other} {
if err := os.MkdirAll(filepath.Join(p, "export"), 0700); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(parent, "export", "game"), []byte("good"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(other, "export", "game"), []byte("evil"), 0700); err != nil {
t.Fatal(err)
}
alias := filepath.Join(dir, "alias")
if err := os.Symlink(parent, alias); err != nil {
t.Fatal(err)
}
path := filepath.Join(alias, "export")
seal, err := Seal(path)
if err != nil {
t.Fatalf("symlinked ancestor: %v", err)
}
copy, err := VerifiedCopy(path, seal)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(copy) })
if err := os.Remove(alias); err != nil {
t.Fatal(err)
}
if err := os.Symlink(other, alias); err != nil {
t.Fatal(err)
}
if _, err := VerifiedCopy(path, seal); err == nil {
t.Fatal("a changed ancestor redirected the sealed export")
}
rootLink := filepath.Join(dir, "export-link")
if err := os.Symlink(filepath.Join(parent, "export"), rootLink); err != nil {
t.Fatal(err)
}
if _, err := Seal(rootLink); err == nil {
t.Fatal("a symlink used as the export directory was sealed")
}
}
+26
View File
@@ -0,0 +1,26 @@
package pump
import (
"math/bits"
"time"
)
// counterDuration converts monotonic performance-counter ticks without losing
// sub-millisecond precision or overflowing on long intervals.
func counterDuration(ticks, frequency uint64) time.Duration {
if frequency == 0 {
return 0
}
const maxDuration = uint64(1<<63 - 1)
seconds := ticks / frequency
if seconds > maxDuration/uint64(time.Second) {
return time.Duration(maxDuration)
}
hi, lo := bits.Mul64(ticks%frequency, uint64(time.Second))
nanos, _ := bits.Div64(hi, lo, frequency)
whole := seconds * uint64(time.Second)
if nanos > maxDuration-whole {
return time.Duration(maxDuration)
}
return time.Duration(whole + nanos)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package pump
import "time"
type mixClockSample struct{ start time.Time }
func mixClockStart() mixClockSample { return mixClockSample{start: time.Now()} }
func mixClockElapsed(s mixClockSample) time.Duration { return time.Since(s.start) }
+54
View File
@@ -0,0 +1,54 @@
package pump
import (
"math"
"math/big"
"math/rand"
"testing"
"time"
)
func TestCounterDuration(t *testing.T) {
for _, tc := range []struct {
ticks, frequency uint64
want time.Duration
}{
{0, 10_000_000, 0},
{1, 0, 0},
{1, 10_000_000, 100 * time.Nanosecond},
{1, 3, 333_333_333 * time.Nanosecond},
{15_000_000, 10_000_000, 1500 * time.Millisecond},
{math.MaxUint64 - 1, math.MaxUint64, 999_999_999 * time.Nanosecond},
{math.MaxUint64, 1, time.Duration(math.MaxInt64)},
} {
if got := counterDuration(tc.ticks, tc.frequency); got != tc.want {
t.Errorf("counterDuration(%d, %d) = %v; want %v", tc.ticks, tc.frequency, got, tc.want)
}
}
}
func TestCounterDurationMatchesExactArithmetic(t *testing.T) {
rng := rand.New(rand.NewSource(125))
limit := big.NewInt(math.MaxInt64)
for i := 0; i < 10000; i++ {
ticks, frequency := rng.Uint64(), rng.Uint64()|1
want := new(big.Int).SetUint64(ticks)
want.Mul(want, big.NewInt(int64(time.Second)))
want.Quo(want, new(big.Int).SetUint64(frequency))
if want.Cmp(limit) > 0 {
want.Set(limit)
}
if got := counterDuration(ticks, frequency); int64(got) != want.Int64() {
t.Fatalf("ticks=%d frequency=%d: got %v, want %s ns", ticks, frequency, got, want)
}
}
}
func TestMixStatisticsRecordDurations(t *testing.T) {
p := &Pump{}
p.record(150 * time.Nanosecond)
p.record(450 * time.Nanosecond)
if p.st.Blocks != 2 || p.st.MixMax != 450*time.Nanosecond || p.st.MixAvg != 300*time.Nanosecond {
t.Fatalf("incorrect measured mixing statistics: %+v", p.st)
}
}
+59
View File
@@ -0,0 +1,59 @@
package pump
import (
"syscall"
"time"
"unsafe"
)
// Go's Windows monotonic time can advance in coarse interrupt-time steps, so
// short audio blocks otherwise report zero despite doing work. QPC is the
// Windows monotonic high-resolution interval clock; it does not change global
// timer resolution or require a new dependency.
var (
perfClock = syscall.NewLazyDLL("kernel32.dll")
perfCounterAddr = perfClock.NewProc("QueryPerformanceCounter").Addr()
perfFrequencyAddr = perfClock.NewProc("QueryPerformanceFrequency").Addr()
perfClockRate = readPerfFrequency()
)
func readPerfFrequency() uint64 {
var value int64
ok, _, _ := syscall.SyscallN(perfFrequencyAddr, uintptr(unsafe.Pointer(&value)))
if ok == 0 || value <= 0 {
return 0
}
return uint64(value)
}
func readPerfCounter() (int64, bool) {
var value int64
ok, _, _ := syscall.SyscallN(perfCounterAddr, uintptr(unsafe.Pointer(&value)))
return value, ok != 0
}
type mixClockSample struct {
counter int64
fallback time.Time
highRes bool
}
func mixClockStart() mixClockSample {
if perfClockRate != 0 {
if counter, ok := readPerfCounter(); ok {
return mixClockSample{counter: counter, highRes: true}
}
}
return mixClockSample{fallback: time.Now()}
}
func mixClockElapsed(s mixClockSample) time.Duration {
if !s.highRes {
return time.Since(s.fallback)
}
end, ok := readPerfCounter()
if !ok || end <= s.counter {
return 0
}
return counterDuration(uint64(end-s.counter), perfClockRate)
}
+26
View File
@@ -0,0 +1,26 @@
package pump
import "testing"
func TestWindowsHighResolutionMixClock(t *testing.T) {
if perfClockRate == 0 {
t.Fatal("QueryPerformanceFrequency is unavailable")
}
start := mixClockStart()
if !start.highRes {
t.Fatal("QueryPerformanceCounter is unavailable")
}
var elapsed bool
for i := 0; i < 10000; i++ {
if mixClockElapsed(start) > 0 {
elapsed = true
break
}
}
if !elapsed {
t.Fatal("the high-resolution performance counter did not advance")
}
if allocs := testing.AllocsPerRun(1000, func() { _ = mixClockElapsed(mixClockStart()) }); allocs != 0 {
t.Fatalf("the audio timer allocated: %v allocations per measurement", allocs)
}
}
+2 -2
View File
@@ -144,9 +144,9 @@ func (p *Pump) Fill() int {
}
n := 0
for q < target {
t := time.Now()
t := mixClockStart()
p.m.Mix(p.buf)
d := time.Since(t)
d := mixClockElapsed(t)
w := p.out.Write(p.buf)
p.record(d)
n++
+2 -1
View File
@@ -29,7 +29,8 @@ func WriteChrome(w io.Writer, t *Tracer, frames []Frame) error {
bw.WriteString(`,{"name":"frame ` + u(f.Index) + `","ph":"X","pid":1,"tid":1,"ts":` + us(f.Start) +
`,"dur":` + us(f.Dur) + `,"args":{"gc":` + u(uint64(f.GC)) +
`,"dropped":` + u(uint64(f.Dropped)) + `,"world":` + u(f.World) + `,"frameId":` + u(f.FrameID) +
`,"firstTick":` + u(f.FirstTick) + `,"lastTick":` + u(f.LastTick) + `}}`)
`,"firstTick":` + u(f.FirstTick) + `,"lastTick":` + u(f.LastTick) +
`,"paceWaitNs":` + strconv.FormatInt(f.PaceWaitNS, 10) + `,"idleWaitNs":` + strconv.FormatInt(f.IdleWaitNS, 10) + `}}`)
for _, e := range f.Events {
name := `""`
if int(e.Zone) < len(names) {
+56
View File
@@ -0,0 +1,56 @@
package trace
import (
"bytes"
"encoding/json"
"testing"
"time"
)
func TestPaceWaitMetadataSurvivesOverflowAndResets(t *testing.T) {
tr := New(Options{Frames: 2, Events: 1})
tr.SetEnabled(true)
zone := tr.Zone("test")
tr.BeginFrame()
tr.Begin(zone).End()
tr.Begin(zone).End() // No room for the pacing event itself.
tr.AddPaceWait(10*time.Millisecond, false)
tr.AddPaceWait(60*time.Millisecond, true)
tr.AddPaceWait(-time.Second, true)
tr.EndFrame()
f := tr.Frame(0)
if f.Dropped != 1 || f.PaceWaitNS != int64(70*time.Millisecond) || f.IdleWaitNS != int64(60*time.Millisecond) {
t.Fatalf("dropped=%d pace=%d idle=%d", f.Dropped, f.PaceWaitNS, f.IdleWaitNS)
}
snapshot := tr.Snapshot(1)
for range 2 {
tr.BeginFrame()
tr.EndFrame()
}
if f = tr.Frame(0); f.PaceWaitNS != 0 || f.IdleWaitNS != 0 {
t.Fatalf("reused frame retained wait: %+v", f)
}
if snapshot[0].PaceWaitNS != int64(70*time.Millisecond) || snapshot[0].IdleWaitNS != int64(60*time.Millisecond) {
t.Fatal("snapshot lost pacing metadata")
}
var out bytes.Buffer
if err := WriteChrome(&out, tr, snapshot); err != nil {
t.Fatal(err)
}
var exported struct {
Events []struct {
Args struct {
PaceWaitNS int64 `json:"paceWaitNs"`
IdleWaitNS int64 `json:"idleWaitNs"`
} `json:"args"`
} `json:"traceEvents"`
}
if err := json.Unmarshal(out.Bytes(), &exported); err != nil {
t.Fatal(err)
}
if len(exported.Events) < 2 || exported.Events[1].Args.PaceWaitNS != int64(70*time.Millisecond) || exported.Events[1].Args.IdleWaitNS != int64(60*time.Millisecond) {
t.Fatalf("exported pacing metadata: %+v", exported.Events)
}
var nilTracer *Tracer
nilTracer.AddPaceWait(time.Second, true)
}
+15
View File
@@ -27,6 +27,9 @@ type Frame struct {
Index uint64
Start int64
Dur int64
// PaceWaitNS is deliberate frame-limiter sleep; IdleWaitNS is its idle-mode part.
PaceWaitNS int64
IdleWaitNS int64
GC uint32
Dropped uint32
World uint64
@@ -158,6 +161,7 @@ func (t *Tracer) BeginFrame() {
t.seq++
f := &t.frames[t.head]
f.Index, f.Start, f.Dur, f.GC, f.Dropped = t.seq, t.now(), 0, 0, 0
f.PaceWaitNS, f.IdleWaitNS = 0, 0
f.World, f.FrameID, f.FirstTick, f.LastTick = 0, 0, 0, 0
f.Events = f.Events[:0]
t.cur, t.depth = f, 0
@@ -171,6 +175,17 @@ func (t *Tracer) Mark(world, frame, first, last uint64) {
f.World, f.FrameID, f.FirstTick, f.LastTick = world, frame, first, last
}
// AddPaceWait records intentional pacing independently of the event capacity.
func (t *Tracer) AddPaceWait(d time.Duration, idle bool) {
if t == nil || t.cur == nil || d <= 0 {
return
}
t.cur.PaceWaitNS += int64(d)
if idle {
t.cur.IdleWaitNS += int64(d)
}
}
func (t *Tracer) EndFrame() {
if t == nil || t.cur == nil {
return
+16 -1
View File
@@ -135,6 +135,8 @@ type Desc struct {
Checked func() bool
Enabled func() bool
Run func() error
// SearchRun optionally opens an item rather than toggling it from Search Everywhere.
SearchRun func() error
nameKey string
tipKey string
}
@@ -312,6 +314,15 @@ func (r *Registry) Enabled(id string) bool {
}
func (r *Registry) Run(id string) error {
return r.run(id, false)
}
// RunFromSearch uses the search-specific action when one is registered.
func (r *Registry) RunFromSearch(id string) error {
return r.run(id, true)
}
func (r *Registry) run(id string, search bool) error {
d, ok := r.byID[id]
if !ok {
return errs.New(errs.EditorCommand, "unknown command", log.F("command", id))
@@ -319,7 +330,11 @@ func (r *Registry) Run(id string) error {
if d.Enabled != nil && !d.Enabled() {
return errs.New(errs.EditorCommand, "command is disabled", log.F("command", id))
}
err := d.Run()
run := d.Run
if search && d.SearchRun != nil {
run = d.SearchRun
}
err := run()
if err != nil && r.OnError != nil {
r.OnError(id, err)
}
+36
View File
@@ -1,11 +1,47 @@
package command
import (
"errors"
"testing"
"aego/platform/input"
)
func TestRunFromSearchUsesOptionalAction(t *testing.T) {
r := New()
var normal, search int
failure := errors.New("search failed")
var reported error
r.OnError = func(id string, err error) { reported = err }
if err := r.Register(Desc{ID: "window.example", Run: func() error {
normal++
return nil
}, SearchRun: func() error {
search++
return failure
}}); err != nil {
t.Fatal(err)
}
if err := r.Run("window.example"); err != nil || normal != 1 || search != 0 {
t.Fatalf("normal action: %v, normal %d, search %d", err, normal, search)
}
if err := r.RunFromSearch("window.example"); !errors.Is(err, failure) || !errors.Is(reported, failure) || search != 1 {
t.Fatalf("search action: %v, reported %v, ran %d", err, reported, search)
}
if err := r.Register(Desc{ID: "ordinary", Run: func() error { normal++; return nil }}); err != nil {
t.Fatal(err)
}
if err := r.RunFromSearch("ordinary"); err != nil || normal != 2 {
t.Fatalf("ordinary action from search: %v, ran %d", err, normal)
}
if err := r.Register(Desc{ID: "disabled", Enabled: func() bool { return false }, Run: func() error { return nil }, SearchRun: func() error { search++; return nil }}); err != nil {
t.Fatal(err)
}
if err := r.RunFromSearch("disabled"); err == nil || search != 1 {
t.Fatal("search must not run a disabled command")
}
}
func TestAltShortcutDispatches(t *testing.T) {
r := New()
ran := 0
+40
View File
@@ -0,0 +1,40 @@
package agent
import (
"testing"
"aego/core/mathx"
"aego/editor/core/services"
"aego/platform/input"
"aego/render/text"
"aego/ui/core"
"aego/ui/theme"
)
// Measure the panel itself independently of asynchronous editor work.
func TestIdlePanelDrawDoesNotAllocate(t *testing.T) {
p := providerPanel(t, newProviderTestHost())
ui := core.New(text.NewFont(14, 1, 18, 14))
ui.Theme = theme.Editor()
in := &input.State{}
r := mathx.Rect{W: 420, H: 520}
frame := func(draw bool) {
in.BeginFrame()
ui.Begin(in, mathx.V2(800, 600), 1)
ui.Region(r)
if draw {
p.Draw(&services.Frame{UI: ui}, r)
}
ui.PopCursor()
ui.End()
}
for range 60 {
frame(false)
frame(true)
}
base := testing.AllocsPerRun(100, func() { frame(false) })
with := testing.AllocsPerRun(100, func() { frame(true) })
if with > base {
t.Fatalf("idle panel adds allocations: base=%v with panel=%v", base, with)
}
}
+15 -3
View File
@@ -234,19 +234,31 @@ func (p *Panel) folderStack(dir string, img mathx.Rect) {
it := &p.items[list[k]]
lift := float32(k%2) * icon * 0.06
cr := mathx.Rect{X: x0 + float32(k)*step, Y: at.Y + icon*0.02 - lift, W: cs, H: cs}
ui.Rect(cr, th.CardBg)
in := mathx.Rect{X: cr.X + 2, Y: cr.Y + 2, W: cr.W - 4, H: cr.H * 0.42}
fill, border := folderSheetColors(th)
ui.Rect(cr, fill)
// The front of the folder covers the lower part of each sheet. Use
// the visible portion for the preview instead of a thin strip.
in := mathx.Rect{X: cr.X + 2, Y: cr.Y + 2, W: cr.W - 4, H: max(1, cr.H*0.72-2)}
if !p.thumb(it, in) {
ic := cs * 0.4
widgets.IconAt(ui, mathx.Rect{X: cr.X + (cs-ic)/2, Y: cr.Y + 2, W: ic, H: ic}, kindIcon(it.Kind), p.kindColor(it.Kind))
}
ui.Outline(cr, th.Border)
ui.Outline(cr, border)
}
front := mathx.Rect{X: at.X + icon*0.07, Y: at.Y + icon*0.43, W: icon * 0.86, H: icon * 0.43}
ui.RoundRect(front, icon*0.035, th.Accent)
ui.Rect(mathx.Rect{X: front.X, Y: front.Y, W: front.W, H: max(1, icon*0.025)}, back)
}
func folderSheetColors(th *core.Theme) (mathx.Color, mathx.Color) {
if th.Dark {
return th.CardBg, th.Border
}
border := th.TextDim
border.A = 0.55
return th.WidgetDown, border
}
func (p *Panel) code(lines []string, r mathx.Rect) {
ui := p.f.UI
th := &ui.Theme
+13
View File
@@ -7,6 +7,7 @@ import (
"aego/asset/pipeline"
"aego/core/ids"
"aego/editor/core/code/highlight"
"aego/ui/theme"
)
func TestDeclName(t *testing.T) {
@@ -30,6 +31,18 @@ func TestDeclName(t *testing.T) {
}
}
func TestLightFolderSheetsContrastWithPanel(t *testing.T) {
light, dark := theme.Light(), theme.Editor()
fill, border := folderSheetColors(&light)
if theme.Contrast(fill, light.Panel) < 1.2 || theme.Contrast(border, fill) < 1.5 {
t.Fatal("light folder sheets must be distinguishable from the panel and each other")
}
fill, border = folderSheetColors(&dark)
if fill != dark.CardBg || border != dark.Border {
t.Fatal("dark folder sheet colors changed")
}
}
func TestFolderStackIncludesScriptsAndData(t *testing.T) {
p := New(Deps{})
p.items = []pipeline.AssetInfo{
+82
View File
@@ -0,0 +1,82 @@
package profiler
import (
"fmt"
"math"
"testing"
"time"
"aego/core/stats"
"aego/core/trace"
"aego/editor/core/context"
"aego/editor/core/play"
"aego/render"
)
func TestFPSUsesMeasuredFrameIntervals(t *testing.T) {
for _, fps := range []float64{7.3, 15, 60} {
for interval, seconds := range intervals() {
t.Run(fmt.Sprintf("%.1f_interval_%d_%gs", fps, interval, seconds), func(t *testing.T) {
p := New(Deps{})
p.interval = interval
// Simulated frame time deliberately differs from wall-clock sampling.
f := &stats.Frame{FrameTime: 1.0 / 144}
for i := 0; i < 1000 && p.n < 2; i++ {
p.Record(100+float64(i)/fps, f, render.Stats{}, play.Stats{}, context.ScriptStats{}, false, false)
if p.n > 0 {
got := p.at(p.n - 1).fps()
if math.Abs(got-fps) > 0.00001 {
t.Fatalf("measured %.5f FPS, want %.5f", got, fps)
}
}
}
if p.n < 2 {
t.Fatalf("completed %d buckets, want at least two", p.n)
}
})
}
}
}
func TestFPSDoesNotCountProfilerPauseAsOneFrame(t *testing.T) {
p := New(Deps{})
f := &stats.Frame{FrameTime: 0.1}
record := func(now float64) {
p.Record(now, f, render.Stats{}, play.Stats{}, context.ScriptStats{}, false, false)
}
for _, now := range []float64{10, 10.1, 10.2, 10.3} {
record(now)
}
p.paused = true
record(20)
p.paused = false
for _, now := range []float64{30, 30.1, 30.2, 30.3} {
record(now)
}
if p.n < 2 || math.Abs(p.at(p.n-1).fps()-10) > 0.00001 {
t.Fatalf("resumed FPS: buckets=%d fps=%v", p.n, p.at(p.n-1).fps())
}
}
func TestFrameRatingExcludesOnlyPacingWait(t *testing.T) {
ms := func(n int64) int64 { return n * int64(time.Millisecond) }
for _, tc := range []struct {
name string
frame trace.Frame
want frameRating
}{
{"idle", trace.Frame{Dur: ms(67), PaceWaitNS: ms(65), IdleWaitNS: ms(65)}, frameIdle},
{"idle work within idle budget", trace.Frame{Dur: ms(100), PaceWaitNS: ms(60), IdleWaitNS: ms(60)}, frameIdle},
{"idle real work warning", trace.Frame{Dur: ms(130), PaceWaitNS: ms(60), IdleWaitNS: ms(60)}, frameWarn},
{"idle real work error", trace.Frame{Dur: ms(210), PaceWaitNS: ms(60), IdleWaitNS: ms(60)}, frameError},
{"unexplained slow frame", trace.Frame{Dur: ms(67)}, frameError},
{"normal pacing", trace.Frame{Dur: ms(17), PaceWaitNS: ms(15)}, frameOK},
{"out of range wait is clamped", trace.Frame{Dur: ms(5), PaceWaitNS: ms(7)}, frameOK},
} {
t.Run(tc.name, func(t *testing.T) {
if got := rateFrame(&tc.frame); got != tc.want {
t.Fatalf("rating %v, want %v", got, tc.want)
}
})
}
}
+26 -2
View File
@@ -2,6 +2,7 @@ package profiler
import (
"fmt"
"math"
"runtime/metrics"
"strconv"
"strings"
@@ -46,6 +47,15 @@ type bucket struct {
avg [metricCount]float32
peak [metricCount]float32
frames int
intervals int
elapsed float64
}
func (b *bucket) fps() float64 {
if b.elapsed <= 0 {
return 0
}
return float64(b.intervals) / b.elapsed
}
type mark struct {
@@ -60,6 +70,8 @@ type Panel struct {
valid [metricCount]int
acc bucket
start float64
lastSample float64
haveSample bool
interval int
paused bool
playing bool
@@ -267,6 +279,7 @@ func (p *Panel) Record(now float64, f *stats.Frame, r render.Stats, g play.Stats
p.hasScripts = false
}
if p.paused || f == nil {
p.haveSample = false
return
}
p.materials = r
@@ -297,6 +310,14 @@ func (p *Panel) Record(now float64, f *stats.Frame, r render.Stats, g play.Stats
if p.acc.frames == 0 {
p.start = now
}
if p.haveSample {
dt := now - p.lastSample
if dt > 0 && !math.IsInf(dt, 0) {
p.acc.elapsed += dt
p.acc.intervals++
}
}
p.lastSample, p.haveSample = now, true
for i := range s {
p.acc.avg[i] += s[i]
p.acc.peak[i] = max(p.acc.peak[i], s[i])
@@ -473,11 +494,14 @@ func (p *Panel) toolbar(ui *core.Context, bar mathx.Rect) {
if p.n == 0 {
return
}
fps := float64(p.at(p.n-1).frames) / p.seconds()
fps := "—"
if v := p.at(p.n - 1).fps(); v > 0 {
fps = strconv.FormatFloat(v, 'f', 1, 64)
}
span := strconv.FormatFloat(p.seconds()*history, 'f', 0, 64)
info := mathx.Rect{X: bar.X + 176, Y: bar.Y, W: max(0, bar.W-176), H: bar.H}
ui.PushClip(info)
ui.Text(i18n.Tr("editor.profiler.fps_v_window_span_s", "fps {v} window {span} s", i18n.Str("v", strconv.Itoa(int(fps+0.5))), i18n.Str("span", span)), mathx.Vec2{X: info.X, Y: ui.TextY(bar)}, th.Text)
ui.Text(i18n.Tr("editor.profiler.fps_v_window_span_s", "fps {v} window {span} s", i18n.Str("v", fps), i18n.Str("span", span)), mathx.Vec2{X: info.X, Y: ui.TextY(bar)}, th.Text)
ui.PopClip()
}
+38 -3
View File
@@ -32,6 +32,34 @@ func nsMS(ns int64) string {
return strconv.FormatFloat(float64(ns)/1e6, 'f', 2, 64) + " ms"
}
type frameRating uint8
const (
frameOK frameRating = iota
frameIdle
frameWarn
frameError
)
// Rate the work against the active target: idle deliberately targets 15 FPS.
func rateFrame(f *trace.Frame) frameRating {
work := max(0, f.Dur-max(0, f.PaceWaitNS))
budget := int64(budgetNS)
if f.IdleWaitNS > 0 {
budget *= 4
}
switch {
case work > 2*budget:
return frameError
case work > budget:
return frameWarn
case f.IdleWaitNS > 0:
return frameIdle
default:
return frameOK
}
}
func (tl *Timeline) Draw(ui *core.Context, d Deps, r mathx.Rect) {
th := &ui.Theme
tr := d.Tracer
@@ -58,6 +86,11 @@ func (tl *Timeline) Draw(ui *core.Context, d Deps, r mathx.Rect) {
return
}
info := i18n.Tr("editor.profiler.frame_formatuint_dur", "Frame {formatuint} {dur}", i18n.Str("formatuint", strconv.FormatUint(f.Index, 10)), i18n.Str("dur", nsMS(f.Dur)))
if f.IdleWaitNS > 0 {
info += i18n.Tr("editor.profiler.idle_wait_duration", " idle wait {duration}", i18n.Str("duration", nsMS(f.IdleWaitNS)))
} else if f.PaceWaitNS > 0 {
info += i18n.Tr("editor.profiler.frame_wait_duration", " frame limit wait {duration}", i18n.Str("duration", nsMS(f.PaceWaitNS)))
}
if f.GC > 0 {
info += " GC " + strconv.Itoa(int(f.GC))
}
@@ -88,11 +121,13 @@ func (tl *Timeline) strip(ui *core.Context, d Deps, tr *trace.Tracer, r mathx.Re
f := tr.Frame(back)
h := min(float32(f.Dur)/stripScaleNS, 1) * r.H
col := th.Ok
switch {
case f.Dur > 2*budgetNS:
switch rateFrame(f) {
case frameError:
col = th.Error
case f.Dur > budgetNS:
case frameWarn:
col = th.Warn
case frameIdle:
col = th.TextDim
}
if back == tl.back && tl.paused {
col = th.Accent
+2 -2
View File
@@ -68,7 +68,7 @@ func (p *Panel) drawFixedBackdrop(f *render.ViewFrame) {
func (p *Panel) drawOverlays(s *scene.Scene, dbg *debug.Draw, th *core.Theme) {
win := p.d.Project.Window
col := th.ViewportText
col := th.SceneText
var presentationWorld *engine.World
if p.cur != nil {
presentationWorld = p.cur.w
@@ -106,7 +106,7 @@ func (p *Panel) drawGizmos(s *scene.Scene, dbg *debug.Draw, th *core.Theme) {
Colors: gizmo.Colors{
X: th.AxisX,
Y: th.AxisY,
Center: th.ViewportLine,
Center: th.SceneLine,
Rotate: th.Accent,
Hot: th.AxisHot,
Field: th.FieldGizmo,
+4 -3
View File
@@ -8,9 +8,9 @@ import (
)
func theme3(th *core.Theme) Theme3 {
grid := th.ViewportText
grid := th.SceneText
grid.A = 0.25
cam := th.ViewportText
cam := th.SceneText
cam.A = 0.7
box := th.Accent
box.A = 0.8
@@ -19,7 +19,7 @@ func theme3(th *core.Theme) Theme3 {
Camera: cam,
Box: box,
Grid: grid,
Gizmo: gizmo.Colors3{X: th.AxisX, Y: th.AxisY, Z: th.AxisZ, Center: th.ViewportLine, Hot: th.AxisHot},
Gizmo: gizmo.Colors3{X: th.AxisX, Y: th.AxisY, Z: th.AxisZ, Center: th.SceneLine, Hot: th.AxisHot},
}
}
@@ -45,6 +45,7 @@ func (p *Panel) render3D() {
sv.DoClear = false
sv.Label = "sceneview3d.scene"
wd.runner.SetPooledTarget(t)
wd.runner.SetPreview2DIn3D(true)
wd.runner.SetFixedView(sv)
wd.runner.SetHidden(p.runnerHide())
p.d.Viewport.RenderWorld(wd.w)
+11 -5
View File
@@ -199,15 +199,17 @@ func (o *Overlay) collect() {
o.items = append(o.items, Item{
Label: d.Title(), Detail: detail, Kind: "action", Tab: TabActions, Icon: "tool.select",
Fav: favs.Item{Kind: favs.Command, Command: id, Label: d.Name}, HasFav: true,
Run: func() {
Run: func() { o.runCommand(id) },
})
}
}
func (o *Overlay) runCommand(id string) {
if o.d.Commands.Enabled(id) {
o.d.Commands.Run(id)
o.d.Commands.RunFromSearch(id)
} else {
o.d.Notices.Notify(context.LevelInfo, i18n.Note("editor.search.this_action_is_not_available", "This action is not available right now"))
}
},
})
}
}
func (o *Overlay) rank() {
@@ -419,6 +421,10 @@ func (o *Overlay) favorites() {
o.items = append(o.items, Item{
Label: label, Detail: detail, Kind: "favorite", Tab: TabAll, Icon: "ui.star", Fav: it, HasFav: true,
Run: func() {
if it.Kind == favs.Command {
o.runCommand(it.Command)
return
}
if err := favs.Open(env, it); err != nil {
o.d.Notices.Notify(context.LevelInfo, err.Error())
}
+15 -3
View File
@@ -249,6 +249,7 @@ type App struct {
problemRev uint64
clip *clipboard
picker scenePicker
captures []sceneCapture
// updateTask shows a running download in the Task Center; rollbackSaid keeps the rollback notice to one.
updateTask *tasks.Task
rollbackSaid bool
@@ -1087,7 +1088,6 @@ func (a *App) Update(dt float64) {
}
func (a *App) Render(float64) {
defer a.pace()
a.ctx.RunRenderHooks()
if a.list == nil || a.list.Empty() {
return
@@ -1139,16 +1139,28 @@ func (a *App) idle() bool {
len(a.modals) == 0
}
func (a *App) pace() {
// PaceFrame waits outside the render system so pacing is not measured as rendering.
func (a *App) PaceFrame() {
if !a.opt.Paced {
return
}
target := time.Second / 60
if a.idle() {
idle := a.idle()
if idle {
target = time.Second / 15
}
if el := time.Since(a.lastFrame); el < target {
name := engine.ZoneFrameWait
if idle {
name = engine.ZoneIdleWait
}
tr := a.eng.Trace()
wait := tr.Begin(tr.Zone(name))
start := time.Now()
time.Sleep(target - el)
d := time.Since(start)
wait.End()
tr.AddPaceWait(d, idle)
}
a.lastFrame = time.Now()
}
+23 -3
View File
@@ -16,17 +16,37 @@ import (
const captureFlip = true
type sceneCapture struct {
tex gpu.TextureHandle
w, h int
name string
}
// Capture is requested during rendering; readback is only valid after EndFrame.
func (a *App) captureTexture(tex gpu.TextureHandle, w, h int, name string) {
a.captures = append(a.captures, sceneCapture{tex: tex, w: w, h: h, name: name})
}
// FrameFinished reads the target before the next UI update can resize it.
func (a *App) FrameFinished() {
for _, c := range a.captures {
a.saveCapture(c)
}
clear(a.captures)
a.captures = a.captures[:0]
}
func (a *App) saveCapture(c sceneCapture) {
dev := a.ctx.Engine.GPU()
if dev == nil {
return
}
buf := make([]byte, w*h*4)
if err := dev.ReadPixels(tex, 0, 0, w, h, buf); err != nil {
buf := make([]byte, c.w*c.h*4)
if err := dev.ReadPixels(c.tex, 0, 0, c.w, c.h, buf); err != nil {
a.ctx.Notify(context.LevelError, i18n.Note("editor.shell.cannot_read_the_scene_view", "cannot read the Scene View: {error}", i18n.Str("error", err.Error())))
return
}
rel, err := capture.Save(a.opt.Project.Root, name, w, h, buf, captureFlip, time.Now())
rel, err := capture.Save(a.opt.Project.Root, c.name, c.w, c.h, buf, captureFlip, time.Now())
if err != nil {
a.ctx.Notify(context.LevelError, err.Error())
return
+4
View File
@@ -306,6 +306,10 @@ func (a *App) registerWindowCommands() error {
a.ws.Toggle(id)
return nil
},
SearchRun: func() error {
a.ws.Focus(id)
return nil
},
}); err != nil {
return err
}
+3 -1
View File
@@ -154,7 +154,9 @@ func (a *App) drawStatus(r mathx.Rect) {
item(i18n.Tr("editor.shell.n_unsaved", "{n} unsaved", i18n.Str("n", strconv.Itoa(n))), th.Warn, "", false)
}
mid := mathx.Rect{X: left + 8, Y: r.Y, W: max(0, right-left-16), H: r.H}
text, col := a.contextHint(), th.TextDisabled
// Context hints are useful text, not disabled controls; keep them readable
// on the status bar in both themes.
text, col := a.contextHint(), th.TextDim
if n, ok := a.ctx.LastNotice(); ok && ui.Time-n.Time < 8 {
text, col = n.Text, th.TextDim
switch n.Level {
+54
View File
@@ -0,0 +1,54 @@
package shell
import (
"testing"
"time"
"aego/core/log"
"aego/editor/core/context"
"aego/editor/core/play"
"aego/editor/core/tasks"
"aego/editor/core/workspace"
"aego/engine"
"aego/ui/core"
"aego/ui/dnd"
)
func TestPaceFrameRecordsIdleAndActiveWait(t *testing.T) {
e, err := engine.NewBuilder(engine.Config{Headless: true, Trace: true, ResRoot: t.TempDir(), UserRoot: t.TempDir()}).WithLogger(log.Nop()).Build()
if err != nil {
t.Fatal(err)
}
defer e.Close()
ctx := &context.Editor{DnD: &dnd.State{}}
a := &App{opt: Options{Paced: true}, eng: e, ctx: ctx, play: play.New(ctx), ws: workspace.New("", nil), tasks: tasks.New(tasks.Deps{}), ui: core.New(nil)}
for _, tc := range []struct {
name string
idle bool
zone string
minimum time.Duration
}{
{"idle", true, engine.ZoneIdleWait, time.Second / 15},
{"active", false, engine.ZoneFrameWait, time.Second / 60},
} {
t.Run(tc.name, func(t *testing.T) {
a.lastInput = e.Platform().Now()
if tc.idle {
a.lastInput -= 2
}
// Leave extra margin so scheduler delays before the call don't skip the wait.
a.lastFrame = time.Now().Add(100 * time.Millisecond)
tr := e.Trace()
tr.BeginFrame()
a.PaceFrame()
tr.EndFrame()
f := tr.Frame(0)
if f.PaceWaitNS < int64(tc.minimum) || (f.IdleWaitNS > 0) != tc.idle {
t.Fatalf("pace wait=%d idle wait=%d", f.PaceWaitNS, f.IdleWaitNS)
}
if len(f.Events) != 1 || tr.Name(f.Events[0].Zone) != tc.zone {
t.Fatalf("wait trace: %+v", f.Events)
}
})
}
}
+14
View File
@@ -6,6 +6,8 @@ const (
SystemAppFixed = "app.fixed"
SystemAppUpdate = "app.update"
SystemAppRender = "app.render"
ZoneFrameWait = "app.frame_wait"
ZoneIdleWait = "app.idle_wait"
)
type App interface {
@@ -17,6 +19,18 @@ type App interface {
Shutdown()
}
// FramePacer is an optional app capability invoked once after the render phase.
// Deliberate waits belong here, outside the render system's trace and render time.
type FramePacer interface {
PaceFrame()
}
// FrameFinisher runs on the main thread after GPU EndFrame and before swap.
// It is not called on frames that do not render.
type FrameFinisher interface {
FrameFinished()
}
type appSystemKind uint8
const (
+2
View File
@@ -42,6 +42,8 @@ type Engine struct {
main *World
wctx Context
app App
framePacer FramePacer
finisher FrameFinisher
onEvent func(platform.Event)
vsync platform.VSyncMode
frameLimit float64
+41
View File
@@ -0,0 +1,41 @@
package engine
import (
"testing"
"aego/gpu"
"aego/platform"
)
type finishProbe struct {
testApp
texture gpu.TextureHandle
calls int
readErr error
}
func (a *finishProbe) Init(e *Engine) error {
a.testApp.Init(e)
var err error
a.texture, err = e.GPU().CreateTexture(gpu.TextureDesc{Width: 1, Height: 1, Format: gpu.TexRGBA8})
return err
}
func (a *finishProbe) FrameFinished() {
a.calls++
a.readErr = a.e.GPU().ReadPixels(a.texture, 0, 0, 1, 1, make([]byte, 4))
}
func TestFrameFinisherRunsAfterEndFrameAndNotWhenMinimized(t *testing.T) {
e, plat := newTestEngine(t)
defer e.Close()
a := &finishProbe{}
if !e.Step(a) || a.calls != 1 || a.readErr != nil {
t.Fatalf("finish calls=%d, readback error=%v", a.calls, a.readErr)
}
plat.Push(platform.Event{Kind: platform.EvWindowMinimized})
e.Step(a)
if a.calls != 1 {
t.Fatal("finisher ran for a minimized frame")
}
}
+6
View File
@@ -4537,6 +4537,9 @@ messages version=1 lang=en {
msg "editor.profiler.frame_took_formatfloat_ms_for" {
text="Frame took {formatfloat} ms for 3 frames in a row"
}
msg "editor.profiler.frame_wait_duration" {
text=" frame limit wait {duration}"
}
msg "editor.profiler.game" {
text="Game"
}
@@ -4588,6 +4591,9 @@ messages version=1 lang=en {
msg "editor.profiler.graph.update" {
text="update"
}
msg "editor.profiler.idle_wait_duration" {
text=" idle wait {duration}"
}
msg "editor.profiler.instances_instances_update_update_fixed" {
text="instances {instances} update {update} fixed {fixed} faults {faults} budget aborts {formatuint} heap objects {heapobjects} GC {formatuint2} / {gctime} ms"
}
+6
View File
@@ -4027,6 +4027,9 @@ messages version=1 lang=ru {
msg "editor.profiler.frame_took_formatfloat_ms_for" {
text="Кадр занял {formatfloat} мс три кадра подряд"
}
msg "editor.profiler.frame_wait_duration" {
text=" ожидание ограничителя {duration}"
}
msg "editor.profiler.game" {
text="Игра"
}
@@ -4078,6 +4081,9 @@ messages version=1 lang=ru {
msg "editor.profiler.graph.update" {
text="обновление"
}
msg "editor.profiler.idle_wait_duration" {
text=" ожидание простоя {duration}"
}
msg "editor.profiler.instances_instances_update_update_fixed" {
text="экземпляров {instances} update {update} fixed {fixed} сбоев {faults} прерываний по бюджету {formatuint} объектов в куче {heapobjects} GC {formatuint2} / {gctime} мс"
}
+16 -2
View File
@@ -23,6 +23,10 @@ func (e *Engine) Step(app App) bool {
e.err = errs.Wrap(err, errs.EngineInit, "app initialization failed")
return false
}
// Type-assertion cache updates can allocate; resolve this once before
// the per-frame hot path, even for apps without a pacer.
e.framePacer, _ = app.(FramePacer)
e.finisher, _ = app.(FrameFinisher)
e.appInit = true
e.lastTime = e.plat.Now()
}
@@ -297,17 +301,27 @@ func (e *Engine) frame() {
e.stats.UpdateTime = renderStart - updateStart
e.stats.ResetGPU()
if !e.minimized && e.metrics.PixelW > 0 && e.metrics.PixelH > 0 {
rendered := !e.minimized && e.metrics.PixelW > 0 && e.metrics.PixelH > 0
paceTime := 0.0
if rendered {
e.exec(&e.ctx, PhaseRenderPrepare, mask, plan.Paused)
am = e.allocMark()
e.curFrame = e.dev.BeginFrame(e.metrics.PixelW, e.metrics.PixelH)
e.allocSince(zoneGPU, am)
e.exec(&e.ctx, PhaseRender, mask, plan.Paused)
if e.framePacer != nil {
start := e.plat.Now()
e.framePacer.PaceFrame()
paceTime = max(0, e.plat.Now()-start)
}
e.exec(&e.ctx, PhaseRenderUI, mask, plan.Paused)
gs := e.curFrame.Stats()
am = e.allocMark()
e.dev.EndFrame()
e.curFrame = nil
if e.finisher != nil {
e.finisher.FrameFinished()
}
if e.capture != nil {
e.grab()
}
@@ -322,7 +336,7 @@ func (e *Engine) frame() {
} else if e.minimized {
time.Sleep(idleSleep)
}
e.stats.RenderTime = e.plat.Now() - renderStart
e.stats.RenderTime = max(0, e.plat.Now()-renderStart-paceTime)
if lim := e.frameLimit; lim > 0 && e.vsync == platform.VSyncOff && !e.cfg.Headless {
if wait := 1/lim - (e.plat.Now() - now); wait > 0 {
+87
View File
@@ -0,0 +1,87 @@
package engine
import (
"testing"
"time"
"aego/core/log"
"aego/platform"
"aego/platform/headless"
)
type paceProbe struct {
testApp
clock *resumableClock
calls int
}
func (a *paceProbe) PaceFrame() {
a.calls++
tr := a.e.Trace()
s := tr.Begin(tr.Zone(ZoneIdleWait))
a.clock.now += 0.070
s.End()
tr.AddPaceWait(70*time.Millisecond, true)
}
func TestPacerIsOutsideRenderAndNotCalledWhenMinimized(t *testing.T) {
p := &resumableClock{Platform: headless.New(headless.Options{})}
cfg := Config{Headless: true, Trace: true, ResRoot: t.TempDir(), UserRoot: t.TempDir()}
e, err := NewBuilder(cfg).WithLogger(log.Nop()).WithPlatform(p).Build()
if err != nil {
t.Fatal(err)
}
defer e.Close()
a := &paceProbe{clock: p}
if !e.Step(a) {
t.Fatal("first step failed")
}
if e.framePacer != a {
t.Fatal("frame pacer was not resolved during application initialization")
}
f := e.Trace().Frame(0)
if f == nil {
t.Fatal("missing traced frame")
}
var renderEnd, swapStart, waitStart, waitEnd uint32
var hasRender, hasSwap, hasWait bool
for _, ev := range f.Events {
switch e.Trace().Name(ev.Zone) {
case SystemAppRender:
hasRender = true
renderEnd = ev.Start + ev.Dur
case "engine.swap":
hasSwap = true
swapStart = ev.Start
case ZoneIdleWait:
hasWait = true
if ev.Depth != 0 {
t.Fatalf("wait is nested in a render zone: %+v", ev)
}
waitStart = ev.Start
waitEnd = ev.Start + ev.Dur
}
}
if a.calls != 1 || a.renders != 1 || !hasRender || !hasSwap || !hasWait || waitStart < renderEnd || waitEnd > swapStart {
t.Fatalf("calls=%d renders=%d render end=%d wait=%d..%d swap start=%d", a.calls, a.renders, renderEnd, waitStart, waitEnd, swapStart)
}
if e.Stats().RenderTime != 0 || f.PaceWaitNS != int64(70*time.Millisecond) || f.IdleWaitNS != int64(70*time.Millisecond) {
t.Fatalf("render=%v wait=%d idle=%d", e.Stats().RenderTime, f.PaceWaitNS, f.IdleWaitNS)
}
p.Push(platform.Event{Kind: platform.EvWindowMinimized})
e.Step(a)
if a.calls != 1 || a.renders != 1 {
t.Fatalf("pacing or rendering ran while minimized: calls=%d renders=%d", a.calls, a.renders)
}
}
func TestFramePacerAbsentWithoutCapability(t *testing.T) {
e, _ := newTestEngine(t)
defer e.Close()
if !e.Step(&testApp{}) {
t.Fatal("first step failed")
}
if e.framePacer != nil {
t.Fatal("application without a pacer unexpectedly acquired one")
}
}
+16
View File
@@ -0,0 +1,16 @@
---
id: credits
icon: doc.tutorial
group: start
title: Credits and acknowledgements
order: 100
---
# Credits and acknowledgements
Thank you to the artists, contributors and communities whose work inspires Aego projects.
## Music
- **Glorious Morning** — Waterflame. [Track link supplied for the project](https://new.akarpov.ru/media/music/glorious-morning-waterflame.mp3).
The track remains the property of its rights holders. This acknowledgement and link do not grant permission to distribute or use the recording; confirm the applicable licence before including it in a project or release.
+13
View File
@@ -0,0 +1,13 @@
---
id: credits
title: Благодарности и авторы
---
# Благодарности и авторы
Спасибо художникам, авторам и сообществам, чьи работы вдохновляют проекты Aego.
## Музыка
- **Glorious Morning** — Waterflame. [Ссылка на трек, предоставленная для проекта](https://new.akarpov.ru/media/music/glorious-morning-waterflame.mp3).
Права на запись принадлежат её правообладателям. Это упоминание и ссылка сами по себе не дают разрешения на использование или распространение; перед включением записи в проект или релиз проверьте применимую лицензию.
+44
View File
@@ -0,0 +1,44 @@
---
id: platformer-effects
icon: doc.tutorial
group: start
title: Platformer lighting and effects
order: 4
---
# Platformer lighting and effects
This guide explores the finished `examples/platformer` project. To build the basic game from scratch, start with [Platformer from scratch](ref:platformer).
## Lighting and surface assets
Open `examples/platformer` to inspect the complete lighting example. The level, collisions and controls remain the gameplay example; surface assets and lights make normal mapping, shadows and emission visible in that same level. F6 toggles lighting for the view. F7 cycles the lighting, normal and shadow views. Both keys are registered Input Actions and do not change materials or simulation state.
| Existing asset | Surface association |
|---|---|
| `assets/surfaces/platform.png` | Its explicit Height source is `platform_height.png`, with import-time normal generation enabled. This generated normal is shared by the level's platforms. |
| `ground.png` and `wall.png` | Explicit normal maps take priority; their height sources remain linked for authoring. |
| `runner.png` | Each of the eight original animation frames has its own normal-frame association in `runner_n.png`. The source silhouette, frame size and gameplay origin are preserved. |
| `lamp.png` | `lamp_em.png` is an emission mask. It brightens the lamp's core using `lamp_lit.aemat`; the nearby Point light illuminates other objects. The mask does not create Bloom. |
| `window_cookie.png` | A linear cookie mask shapes the Sprite light without using the albedo atlas coordinates. |
To edit the platform's surface, open `platform.png` from Assets and follow **Height source** to `platform_height.png`. Enable **Lit surface** and change the height strength in that shared height document. The generated-normal candidate and movable preview light show the result before Save. Press Play, keep the image tab open, and use **Save settings**: the running level receives the new prepared normal generation. Undo restores the earlier appearance while leaving the metadata document dirty and the saved file unchanged; Redo restores the saved result. Stop returns to the unchanged EditScene. An explicit Normal assignment overrides the Height-generated normal.
Open `runner.png`, select an existing frame and use the preview's Flip X/Y controls to check the paired normal. These controls affect only the preview; the player script sets FlipX when changing direction. Source pixels are edited in an external image tool, while channel links and import settings use the shared asset document and its Undo history.
The player and enemy use separate `LightOccluder2D` polygons for their shadows. These approximate the head and legs of the shared runner sprite without changing the player's capsule or the enemy's box collider. The outlines are static: they do not follow individual animation frames.
See [Image and Sprite authoring](ref:render2d/image-viewer) and [Materials and shaders](ref:render2d/materials) for role and color-space rules, missing-reference diagnostics and the lighting model. Native and web exports use this same project and include its normal, height-derived, emission and cookie artifacts. Preparing the site's demo does not publish it.
## Dust and sparks
Jump and keep moving after landing. Dust starts at the player's feet and stays where it was emitted. Select `Player/Dust` to change `rectSize` and `lifetime`. In `scripts/player/player.aes`, `OnLanded` emits eight particles, `OnJumped` five and `OnClimbed` six. `playOnStart` is off and `rate` is zero, so these events trigger the bursts. `Player/Slash/Sparks` emits on the attack animation's Hit event.
`space=world` fixes each particle's birth transform. Use `space=local` for an effect that should travel with its parent. To compare the level with and without particles, change **Particle Effects** in the game's accessibility settings. Leave F6 and F7 unchanged: they control lighting.
At `StartLantern`, select the `Smoke` or `Embers` child to inspect the two continuous emitters. Use the Inspector preview to pause the effect, lock its seed and compare changes before Play. Under **Over Life**, drag a gradient key, release it and use Undo to restore the previous color transition. See [CPU particle effects](ref:particles) for presets and the runnable example.
## Camera post-processing
Open the platformer in Game View and select **Camera → Post Process**. Bloom is enabled with Threshold 0.75, Intensity 0.22 and Radius 6. Watch the halo around the lantern while changing Intensity. Vignette, Color Grading and the Cold custom material are in the stack but start disabled. Enable one to compare its effect. Open Settings during Play and compare the opaque menu with the component on and off. To restore the original world image, disable Post Process. F6 and F7 control lighting and diagnostics.
Center is relative to the camera output; (0.5, 0.5) is its centre. Intensity controls darkening, while Softness controls the transition width. The boundary is circular in physical viewport pixels even on a wide display. See [post-processing](ref:render2d.PostProcess) for a runnable example.
+41
View File
@@ -0,0 +1,41 @@
---
id: platformer-effects
title: Свет и эффекты платформера
---
# Свет и эффекты платформера
Здесь разобран готовый проект `examples/platformer`. Чтобы сначала собрать базовую игру, откройте [Платформер с нуля](ref:platformer).
## Освещение и карты поверхностей
Откройте `examples/platformer`, чтобы увидеть полный пример освещения. Уровень, коллизии и управление сохраняют игровой сценарий; карты поверхностей и источники показывают нормали, тени и эмиссию. F6 переключает освещение вида, F7 — обычный вид, нормали и тени. Обе клавиши зарегистрированы как действия ввода и не меняют материалы или состояние симуляции.
| Ассет проекта | Связь с картой поверхности |
|---|---|
| `assets/surfaces/platform.png` | Источник высоты — `platform_height.png`, у которого включена генерация нормалей при импорте. Полученная карта общая для платформ уровня. |
| `ground.png` и `wall.png` | Явные карты нормалей имеют приоритет; исходные карты высоты также связаны с ними для авторинга. |
| `runner.png` | У каждого из восьми кадров есть связь с normal-кадром в `runner_n.png`. Силуэт, размер и игровая точка отсчёта сохранены. |
| `lamp.png` | `lamp_em.png` — маска эмиссии. Она осветляет сердцевину лампы через `lamp_lit.aemat`; соседний Point light освещает другие объекты. Маска не создаёт Bloom. |
| `window_cookie.png` | Линейная cookie-маска задаёт форму Sprite light, не используя координаты атласа альбедо. |
Чтобы изменить поверхность платформы, откройте `platform.png` из Assets и перейдите по **источнику высоты** к `platform_height.png`. Включите **освещённую поверхность** и измените силу высоты в общем документе карты. Кандидат normal map и перемещаемый источник предпросмотра показывают результат до сохранения. Включите Play, оставьте вкладку изображения открытой и нажмите **Сохранить настройки**: работающий уровень получит новое подготовленное поколение нормалей. Undo возвращает прежний вид, оставляя документ метаданных изменённым, а сохранённый файл — прежним; Redo возвращает сохранённый результат. Stop возвращает неизменённую EditScene. Явная Normal-карта имеет приоритет над нормалью, полученной из Height.
Откройте `runner.png`, выберите существующий кадр и используйте Flip X/Y предпросмотра, чтобы проверить связанную normal map. Эти переключатели действуют только на предпросмотр; игровой скрипт задаёт FlipX при смене направления. Пиксели исходного изображения редактируются во внешнем графическом редакторе, связи каналов и настройки импорта — в общем документе ассета с историей Undo.
У игрока и врага для теней заданы отдельные полигоны `LightOccluder2D`. Они приблизительно повторяют голову и ноги общего спрайта, не меняя коллайдеры. Контуры статичны и не подстраиваются под кадры анимации.
Правила ролей и цветового пространства, диагностика отсутствующих ссылок и модель освещения описаны в разделах [Просмотр изображений и спрайтов](ref:render2d/image-viewer) и [Материалы и шейдеры](ref:render2d/materials). Native- и web-экспорты используют тот же проект и включают нормали, результат преобразования высоты, эмиссию и cookie. Подготовка демо сайта сама по себе не публикует его.
## Пыль и искры
Прыгните и после приземления продолжайте бежать. Пыль появляется у ног и остаётся над местом возникновения. Выберите `Player/Dust`, чтобы изменить `rectSize` и `lifetime`. В `scripts/player/player.aes` обработчик `OnLanded` выпускает восемь частиц, `OnJumped` — пять, `OnClimbed` — шесть. У эмиттера выключен `playOnStart`, а `rate` равен нулю, поэтому частицы появляются по этим событиям. `Player/Slash/Sparks` выпускает искры по событию Hit анимации атаки.
При `space=world` движение эмиттера не сдвигает уже родившиеся частицы. Выберите `space=local`, если эффект должен двигаться вместе с родителем. Чтобы сравнить уровень с частицами и без них, переключите **Эффекты частиц** в настройках специальных возможностей игры. F6 и F7 управляют освещением; при сравнении оставьте их в одном положении.
У `StartLantern` выберите дочерний узел `Smoke` или `Embers`: это независимые источники дыма и искр. В предпросмотре Inspector можно поставить эффект на паузу, закрепить зерно и сравнить изменения до запуска игры. В разделе **По ходу жизни** перетащите ключ градиента и нажмите Undo, чтобы восстановить переход. Пресеты и работающий пример собраны в разделе [Эффекты частиц на CPU](ref:particles).
## Постобработка камеры
Откройте платформер в Game View и выберите **Camera → Post Process**. В сцене включён Bloom с Threshold 0,75, Intensity 0,22 и Radius 6. Меняйте Intensity и наблюдайте за ореолом вокруг лампы. В стеке также есть выключенные Vignette, Color Grading и пользовательский материал Cold. Включите один из них для сравнения. Во время Play откройте настройки и сравните непрозрачную часть меню при включённом и выключенном компоненте. Чтобы вернуть исходное изображение мира, выключите Post Process. F6 и F7 управляют освещением и диагностикой.
Центр задаётся относительно выхода камеры: (0.5, 0.5) — середина. Интенсивность задаёт степень затемнения, мягкость — ширину перехода. Радиус измеряется относительно короткой стороны изображения: на широком экране окружность не превращается в овал. Работающий пример приведён в разделе [Постобработка](ref:render2d.PostProcess).
+3 -36
View File
@@ -13,11 +13,11 @@ Start with [Your first project](ref:quickstart) if you have not opened the edito
The finished example is a separate, more detailed project at `examples/platformer`. Open it with `aego edit examples/platformer` and load its main scene to inspect the game used by the website demo. Move with A/D or the arrow keys, jump with Space, attack with J and open settings with Esc. F6 toggles lighting and F7 cycles its debug views.
In the Hierarchy, inspect the player's Dust and Sparks emitters and the StartLantern's Smoke and Embers. The landing and attack events trigger bursts. The camera's Bloom adds a screen-space halo, while the lantern's Light2D illuminates nearby Lit surfaces and Light Occluder 2D contours cast shadows. See [particle effects](ref:particles), [materials and lighting](ref:render2d/materials) and [post-processing](ref:render2d.PostProcess) for editable examples and Try It scenes. This guide below starts from simpler shapes rather than reproducing every asset in the finished project.
This guide starts from simpler shapes rather than reproducing every asset in the finished project. For its lighting, surface maps, particles and post-processing, see [Platformer lighting and effects](ref:platformer-effects).
## 1. Floor and player
Create a project from the **2D** template. In the Hierarchy, create an empty node and name it `Floor`. Give it these components in the Inspector:
Create a project from the **2D** template. In the Hierarchy, create an empty node and name it **Floor**. Give it these components in the Inspector:
- **Sprite Renderer**, Size `30, 1`, a grey Tint;
- **Rigid Body 2D**, Type `static`;
@@ -651,38 +651,5 @@ scene version=1 {
- [Recipes](ref:recipes): double jump, one-way platforms, moving platforms, dash and more.
- [Gameplay kit](ref:gameplay): every gameplay component in one place.
- [Animation](ref:animation): sprite animation and state machines for the player.
- [Platformer lighting and effects](ref:platformer-effects): surface maps, lights, particles and camera post-processing in the finished example.
- `examples/platformer` in the engine source is this game grown further: animation, sound, stomping, saves, settings and 2D lighting.
## Lighting and surface assets in the example
Open the existing `examples/platformer` project to inspect the complete lighting example. Its level, collisions and controls remain the gameplay example; the added surface assets and lights make normal mapping, shadows and emission visible in that same level. F6 toggles lighting for the view. F7 cycles the lighting, normal and shadow views. Both keys are registered Input Actions and do not change materials or simulation state.
| Existing asset | Surface association |
|---|---|
| `assets/surfaces/platform.png` | Its explicit Height source is `platform_height.png`, with import-time normal generation enabled. This generated normal is shared by the level's platforms. |
| `ground.png` and `wall.png` | Explicit normal maps take priority; their height sources remain linked for authoring. |
| `runner.png` | Each of the eight original animation frames has its own stable normal-frame association in `runner_n.png`. The source silhouette, frame size and gameplay origin are preserved. |
| `lamp.png` | `lamp_em.png` is an emission mask. It brightens the lamp's core using `lamp_lit.aemat`; the nearby Point light illuminates other objects. The mask does not create Bloom. |
| `window_cookie.png` | A linear cookie mask shapes the Sprite light without using the albedo atlas coordinates. |
To edit the existing platform's surface, open `platform.png` from Assets and follow **Height source** to `platform_height.png`. Enable **Lit surface** and change the height strength in that shared height document. The generated-normal candidate and movable preview light show the result before Save. Press Play, keep the image tab open, and use **Save settings**: the running level receives the new prepared normal generation. Undo restores the earlier appearance while leaving the metadata document dirty and the saved file unchanged; Redo restores the saved result. Stop returns to the unchanged EditScene. An explicit Normal assignment would override this Height-generated normal.
Open `runner.png`, select an existing frame and use the preview's Flip X/Y controls to check the paired normal. These controls affect only the preview; the actual player script sets FlipX when changing direction. The source image and frame pixels are edited in an external image tool, while channel links and import settings use the shared asset document and its Undo history.
The player and enemy use separate `LightOccluder2D` polygons for their shadows. These approximate the head and legs of the shared runner sprite without changing the player's capsule or the enemy's box collider. The outlines are static: they do not follow individual animation frames.
See [Image and Sprite authoring](ref:render2d/image-viewer) and [Materials and shaders](ref:render2d/materials) for role/color-space rules, missing-reference diagnostics and the lighting model. Native and web exports use this same project and include its normal, height-derived, emission and cookie artifacts. The site's demo is prepared from this export; preparation alone does not publish a new site version.
## Dust and sparks in the example
Open `examples/platformer`, jump and keep moving after landing. The dust starts at the player's feet and stays over the patch of floor where it was emitted. Select `Player/Dust` to change the effect: `rectSize` sets the width and depth of the emitting strip, and `lifetime` sets how long each puff lasts. In `scripts/player/player.aes`, `OnLanded` emits eight particles, `OnJumped` five and `OnClimbed` six. `playOnStart` is off and `rate` is zero, so the emitter produces particles only on these events. `Player/Slash/Sparks` emits a burst on the attack animation's Hit event.
`space=world` fixes each particle's birth transform. Use `space=local` for an effect that should travel with its parent. To compare the same level with and without particles, change **Particle Effects** in the game's accessibility settings. Leave F6 and F7 unchanged: they control lighting.
At `StartLantern`, select the `Smoke` or `Embers` child to inspect the two continuous emitters. Use the Inspector preview to pause the effect, lock its seed and compare changes before Play. Under **Over Life**, drag a gradient key, release it and use Undo to restore the previous color transition. See [CPU particle effects](ref:particles) for the five presets and the runnable example.
## Camera PostFX in the example game
Open the platformer in Game View and select **Camera → Post Process**. Bloom is enabled with Threshold 0.75, Intensity 0.22 and Radius 6. Watch the halo around the lantern while changing Intensity. Vignette, Color Grading and the Cold custom material are already in the stack and start disabled. Enable one to compare its effect. Open Settings during Play and compare the opaque menu with the component on and off. To restore the original world image, disable Post Process. F6 and F7 control lighting and its diagnostics.
Center is relative to the camera output; (0.5, 0.5) is its centre. Intensity controls darkening, while Softness controls the width of the transition. The boundary is circular in physical viewport pixels even on a wide display.

Some files were not shown because too many files have changed in this diff Show More