Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad23798da5 | ||
|
|
132ddeff70 | ||
|
|
c8d45b6ff5 | ||
|
|
962c479933 | ||
|
|
72d9a21782 | ||
|
|
cc2a428a7d |
@@ -15,6 +15,7 @@ env:
|
||||
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
|
||||
libwayland-dev wayland-protocols libdecor-0-dev
|
||||
libasound2-dev libpulse-dev libdrm-dev libgbm-dev libudev-dev libdbus-1-dev libibus-1.0-dev
|
||||
glslang-tools
|
||||
|
||||
jobs:
|
||||
# Native runs on Windows and macOS machines; the Windows build itself is cross-compiled on Linux in ci.yml (D301).
|
||||
|
||||
@@ -16,6 +16,7 @@ env:
|
||||
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
|
||||
libwayland-dev wayland-protocols libdecor-0-dev
|
||||
libasound2-dev libpulse-dev libdrm-dev libgbm-dev libudev-dev libdbus-1-dev libibus-1.0-dev
|
||||
glslang-tools
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
|
||||
+25
-4
@@ -504,7 +504,7 @@ The Stage 8 specification numbered these decisions D153, D155, D158, D159, D168
|
||||
|---|---|
|
||||
| D289 | Web platform. `platform/web` (js/wasm, `syscall/js`) implements `platform.Platform` over one `<canvas>`: the one in `web.Options`, else `canvas[data-aego]`, else a new canvas appended to the body. The logical size is the canvas CSS box; the pixel size is CSS × `devicePixelRatio`, written back to `canvas.width/height`; a ResizeObserver (window `resize` as fallback) and a changed `devicePixelRatio` emit `EvWindowResized`; `PixelDensity` and `DisplayScale` are `devicePixelRatio`. Input: `KeyboardEvent.code` → scancode, `key` → keycode and text (no IME composition), pointer events on the canvas with pointer capture, primary pointer only (one-finger touch acts as the left button; multi-touch is Stage 18), wheel by `deltaMode` (100 px = one line, a page = 3 lines; a pixel delta that is not a whole line is precise, 10 px per unit, so smooth wheels and touchpads scroll the distance the page would. Amended 2026-10-20: it was divided by 100 too, which made such scrolling ten times slower than the browser's), focus and blur reset held state (9.2), gamepads polled through `navigator.getGamepads()` in the standard mapping while one is connected. The canvas takes keyboard focus on click; while focused it prevents the default of every key except browser shortcuts (Ctrl/Cmd combinations, F5, F11, F12). Pointer lock and fullscreen, which browsers grant only inside a user gesture, are queued and issued on the next pointer or key press; leaving them is immediate. Time is `performance.now()` and stops while the page is hidden. The loop is `platform.Driver`: `Engine.Run` hands `Step` to `requestAnimationFrame`, so VSync is always on and there is no frame limiter. Clipboard writes go to `navigator.clipboard.writeText` and an in-memory copy; reads return the copy, refreshed by `paste` events. Files go through `platform.UserFiles()` (localStorage), so profiles and saves work unchanged; the window is not a `platform.Displayer`, so display settings have nothing to apply. Translation code (scancodes, wheel, pads) lives in untagged files and is tested natively. Amended 2026-10-25: an exported page shows a touch gamepad on touch screens (a left stick, A, B, X, Y and Start, styled after the editor's Virtual Gamepad); the page publishes it as `globalThis.aegoVirtualPad` in the standard mapping and the platform reads it as the last pad slot, so games bound to a gamepad play on a phone unchanged. `aegoGame.pads()` lists the connected pads for tests. |
|
||||
| D290 | WebGL2 backend. `gpu/webgl2` implements `gpu.Device` on `WebGL2RenderingContext`, reached through `webgl2.Surface` on the window. `engine/backends_js.go` selects it, `engine/backends_native.go` selects gl33; a window with neither gets the null backend. GL enums are numeric constants; JS objects live in a table indexed by `uint32`, so `gpu/internal/glstate` (moved from gl33) caches state for both backends. Context methods are bound once; every call goes through one counter (`Device.Calls()` = calls of the last frame). WebGL2 has no sRGB default framebuffer, so the window pass renders into an SRGB8_ALPHA8 texture with a depth-stencil renderbuffer and `EndFrame` draws it to the canvas with a fullscreen triangle that encodes linear to sRGB; orientation follows D17 (the backbuffer is the window: viewport and scissor flipped, offscreen targets GL-native). Shaders: the renderer loads `engine://shaders/es/` when `Capabilities.ESSL` is set; `render/shaderx` translates the GLSL 330 sources at build time (`make shaders`, part of `make res`), a test keeps the committed copies current and validates them with glslangValidator when installed; there is no translator at run time. Timer queries use `EXT_disjoint_timer_query_webgl2` when present (a disjoint result reads as 0), otherwise the GPU lane stays empty. Context loss is handled inside the device: it keeps descriptors, shader sources and CPU copies of static buffers and textures with data, and on `webglcontextrestored` recreates every object under the same table id, so handles held above stay valid and nothing above the device reloads; render-target contents and dynamic buffers are refilled by the next frame. Uploads copy through one growing `Uint8Array`. The default build never compiles `syscall/js`: depcheck requires a js-only build constraint or a `_js` file name on every importer. |
|
||||
| D291 | Playground runtime. One page holds the code editor, a Game View (`canvas[data-aego]`) and Console/Problems. The catalog `app/assembly/playground` is core, input, render2d, scripting, physics and debug (physics joins the playground graph, superseding D205 on that point; `render3d` stays out). Console examples (docs snippets, the donut) run headless as before. Scene examples come from `app/playground/examples`, a regular project (module `game`) embedded in the binary and checked by `aego check` in `make check-all`: its `project.aego` gives the project sections once at start, and each `scenes/<name>.aescene` with `scripts/<name>/` is one example. Run compiles the edited package in the same workspace, links a fresh script runtime and loads the scene through `sceneio` with a loader over an empty index, so only builtin assets resolve. One engine per page is built on the first Run on `platform/web` and `gpu/webgl2` and stays on `requestAnimationFrame`; Run and Stop swap the scene, the runtime and the module attachment of the main world. The page polls `poll` every 250 ms for log lines, faults and frame statistics (frame time, draw calls, WebGL calls of the last frame). The game owns the keyboard and pads while the canvas has focus, Escape included; a click elsewhere releases it. `bench` draws N sprites straight through the renderer for measurements and the screenshot golden, `loseContext` drives `WEBGL_lose_context`. Budgets are targets: `aego.wasm` ≤ 8 MB gzip (the site build fails above it), cold start ≤ 2 s on a laptop. `make playground-size` reports code size per package from the wasm name section (`tools/wasmsize`). `make web-smoke` runs Playwright with the system Chromium on SwiftShader: start-up time, a golden screenshot of `bench` 64, the same picture after a context loss, every scene example for 2 s without faults, and the 1000-sprite bench written to `.cache/web-bench.json`. |
|
||||
| D291 | Playground runtime. One page holds the code editor, a Game View (`canvas[data-aego]`) and Console/Problems. The catalog `app/assembly/playground` is core, input, render2d, scripting, physics and debug (physics joins the playground graph, superseding D205 on that point; `render3d` stays out). Console examples (docs snippets, the donut) run headless as before. Scene examples come from `app/playground/examples`, a regular project (module `game`) embedded in the binary and checked by `aego check` in `make check-all`: its `project.aego` gives the project sections once at start, and each `scenes/<name>.aescene` with `scripts/<name>/` is one example. Run compiles the edited package in the same workspace, links a fresh script runtime and loads the scene through `sceneio` with a loader over an empty index, so only builtin assets resolve. One engine per page is built on the first Run on `platform/web` and `gpu/webgl2` and stays on `requestAnimationFrame`; Run and Stop swap the scene, the runtime and the module attachment of the main world. The page polls `poll` every 250 ms for log lines, faults and frame statistics (frame time, draw calls, WebGL calls of the last frame). The game owns the keyboard and pads while the canvas has focus, Escape included; a click elsewhere releases it. `bench` draws N sprites straight through the renderer for measurements and the screenshot golden, `loseContext` drives `WEBGL_lose_context`. Budgets are targets: `aego.wasm` ≤ 8 MB gzip (the site build fails above it), cold start ≤ 2 s on a laptop. `make playground-size` reports code size per package from the wasm name section (`tools/wasmsize`). `make web-smoke` runs Playwright with the system Chromium on SwiftShader: start-up time, a golden screenshot of `bench` 64, the same picture after a context loss, every scene example for 2 s without faults, and the 1000-sprite bench written to `.cache/web-sprites.json`. |
|
||||
| D292 | Documentation IA. One registry feeds the site, the editor's docs panel and `aego docs export` (version 2, with groups). Sections and groups: Getting started; Guides (Editor, Aego Script, Scenes & Prefabs, Gameplay, Physics 2D, Rendering, Extending Aego, Tools & Agents); Reference (Script API, Components, Project settings, Diagnostics, CLI, Project). A root Markdown page declares `group:` (a guide group) and `order:`, children inherit the group of their root. The kernel lists only the module-independent groups; a module adds its own guide group from its root page with `group_title:` and `group_order:` (Physics 2D comes from `modules/physics`, so `engine/docs` never names a module and needs no literal waiver under D212). Reference groups are filled from the registry: script modules are roots of Script API ordered by the owning module's `DocsOrder`; components hang under one page per module, created only for modules with components and titled `module.Info.DisplayName` (the core module is "Core"); diagnostic codes under Diagnostics; documented project scripts under Project. Entry IDs are unchanged, so `ref:` links keep working. `aego docs check` fails on a root page without a guide group or an order and on a built-in module without a DisplayName. `module.DocSource.Dir`, taken from the module's package path (`Unwrap` sees through catalog wrappers), gives every page its repository path for "Edit this page"; `aego docs build` points those links at the checked-out branch (`master` when git is unavailable). gendocs renders sections with collapsible groups whose open state the browser remembers, the current page highlighted with its group and ancestors open, breadcrumbs (section, group, ancestors), an on-this-page list from the h2/h3 anchors, previous/next in navigation order, search grouped by section, a footer with the engine version, the commit and links to the source, the playground and the page's file, a landing page with three entry points and `docs/hero.png` when present, and `llms.txt`. A text outline of the navigation is the golden of the gendocs tests, for a fixture and for the real export. The playground and the site share one set of colour tokens. Amended in 9.5b3b: component pages come from the schema: a header with category, module, space, scene uniqueness, required components and the Create path; a field table with type, the default read from the registered default, range and unit, notes (editor or script visibility, read-only, Play only, saved, live, choice and bitset sources, enum values, gizmo) and the field doc; component methods (`manifest.CompMethods`) and signals as members (a method that shares its name with a field gets the ID and title `Name()`); the script events of the owning module, listed under Script API → Script events with owners from `module.Manifest.EventOwners`; related guides (pages linking to the component or its fields, and root guides of the same module); an empty Try it slot. Project settings has one page per project section with its fields and default block (`Section.DefaultValue`, `Section.DefaultBlock`; a block that encodes to nothing is left out, and the section page lists the owning module's guides as related) and a Player settings page built from the settings registry. CLI is the authored page `cli.md` in group `ref-cli`, the only reference group a page may declare; a test keeps it listing every command of `cmd/aego`. A script module `aego/<id>` belongs to module `<id>` when no owner is registered. The editor's docs panel shows the same sections and groups. Amended 2026-10-20 (9.7): entries and groups carry an editor icon name (`Entry.Icon`, `Group.Icon`, `module.Info.DocsIcon`, `group_icon:`), and gendocs builds one SVG sprite from the editor icon sources and fails on an unknown name; the site uses the editor theme, and code is highlighted at build time with the editor tokenizer and palette, identifiers linked to their entries. Short names resolve everywhere: `Registry.Aliases` maps a last segment that names one entry (`Body2D`, `ApplyForce`, `timer.Timer`) to it, used by `Registry.Link` in the docs panel and exported for gendocs. Getting started is the install page; the editor tour moved to the Editor group with its IDs. `module.Info.DocsGroup` documents a module's components in a guide group instead of under Components (the Script component is in Aego Script). Script API module pages list their functions with signatures above the sections, with a filter; a function without its own example links to the module's runnable example. A ```aes check block is compiled but not run: component pages end with "From a script" examples compiled against the full builtin catalog (`playground.NewWith`) by the playground tests. |
|
||||
| D293 | Docs session. `editor/core/docsession.Session` serves the real Inspector and Game View without the editor shell: one scene from bytes, its own `document.Manager` holding the edited document (the Inspector's `services.Documents`), a second manager with a document over the same scene for `play.Controller`, which swaps in its play proxy during Play, so the Inspector always edits authored values; a `selection.Service` keyed by that document; a minimal `context.Editor` (engine, renderer, loader, types, commands, problems, an in-memory clipboard) and `Unavailable` for asset and scene operations. History works as in the editor; a committed change saves the scene to text (`OnChange`) and restarts Play, keeping a pause; Reset reloads the original bytes. `app/docsview` hosts it: the sandbox catalog, `LoopScope` editor, one canvas with a toolbar (Play/Pause, Reset, Undo, Redo), Game View and Inspector side by side (stacked below 640 px), a script host that links a sandboxed runtime and attaches the modules to the play world, and a JSON bridge (`load`, `reset`, `set`, `undo`, `redo`, `state`, `log`). `cmd/aego-try` builds it as `try/aego-docs.wasm` (`js && aego_editor`). The graph excludes SDL, gl33, the shell, every panel but the two, and the script importer; `asset/pipeline` stays because `editor/core/context`, `assetops` and the preset importer import it, and the Inspector gets no pipeline. Module editor extensions (custom inspectors) are not loaded. |
|
||||
| D294 | Try it and the sandbox. A module's `res/docs/try/<Name>.aescene` becomes the try scene of the component whose ID ends in `.<Name>` (preferring the module's own), a guide page can name one with `try:` and `try_select:`; the registry carries `Try` and `TrySelect` and the site renders a Try it block when the playground is built: the scene text, Copy scene, Reset, and a button that lazily loads the one docs runtime per site, moves its single canvas into the block and loads the scene there, selecting the documented component; Inspector edits rewrite the shown text. `aego docs check` runs every try scene on the sandbox catalog for 60 headless frames and fails on a load error, a warning, a fault or a scene from a module outside the sandbox. Sandbox (playground and docs runtime): the catalog is the allowlist of host modules (core, input, render2d, scripting, physics, debug and, since 9.7, tween and render3d with the built-in meshes, whose OBJ importer is left out of web builds, then spawn and savegame and, since 10b2, audio with the built-in clips; no settings), user files are in memory or localStorage only, there is no file or network access; scripts run with 2M instructions per call and a 64 MB VM heap (`vm.Options.MaxHeap`, checked in the allocator while the machine runs and reported as `CodeBudget`, garbage included until the next collection); a scene above 10 000 nodes stops the game with an error. `aego-docs.wasm` is held to 10 MB gzip by `make site` (`make try-size` reports it per package). A try scene may carry a script and prefabs, and `save` in the sandbox writes to page memory. The playground's Inspect toggle comes later. Amended 2026-10-20 (9.7): a console run (`aes run`, the playground Run) attaches the sandbox modules to its world as Play does and steps it with `TickWorld` on a windowless engine, so tweens, timers, actions, handles in `yield` and physics behave as in Play; before, scripts were stepped by hand and module calls failed with AES6010. Amended: a try scene may bring `<Name>.aes` and `<Name>.<prefab>.aeprefab` (id from its `.meta`). `aego docs export` compiles the script with the playground compiler into `TryCode` (base64 bytecode modules); the page passes code and prefabs to `load`, the docs runtime publishes the script types before parsing the scene and mounts the prefabs into its loader from memory. The sandbox includes `spawn` and `savegame` with an in-memory save store, and a play controller whose scene has no asset id reloads that scene for `save.Load`. Every component has a try scene; `TestTryScenesRun` runs each through `load` for 400 frames. Amended in 10d3: `docs export` sets `Input` on a try entry whose script imports `aego/input`; the site writes `data-input` on its `<section class="try">`, and the page covers the canvas with "Click the canvas, then use WASD / arrows" until the first pointer press or focus on it. |
|
||||
@@ -540,8 +540,8 @@ The Stage 8 specification numbered these decisions D153, D155, D158, D159, D168
|
||||
| D308 | Mixer. `audio/mix` mixes without allocations, maps or interface calls per sample. Sources are `Memory` (decoded PCM shared by any number of voices, optional loop points) and `Stream` (an SPSC float ring filled by a decoder goroutine, one voice at a time; an empty ring gives silence and a `Starved` event at most once a second per voice; EOF with a drained ring ends the voice). A voice resamples with cubic Hermite interpolation from a fractional cursor (step = pitch × source rate / mixer rate, pitch 0.25..4, source rate at most 4× the mixer rate), spreads mono with a constant-power pan and stereo with balance, and ramps gain linearly (10 ms by default, fades in seconds). Buses form a tree with one root declared as `BusDesc{ID, Parent}`; `New` rejects duplicate IDs, unknown parents, two roots and cycles. Children are summed before parents in an order fixed at `New`. Each bus has a user gain with a 10 ms ramp, mute (the same ramp to 0, never a cut) and solo (a soloed bus with its ancestors and descendants stays audible). The master bus ends in a soft clip: linear up to \|x\| = 0.5, a cubic knee `0.5 + u − k·u³` (u = \|x\| − 0.5, k = 1/(3·0.75²)) that reaches ±1 with zero slope at \|x\| = 1.25, clamped beyond. Peak and RMS of every bus per block are published atomically (`Meters`). The game thread controls the mixer through an SPSC command ring of 1024 (Play, Stop, gain, pan, pitch, pause, resume, bus gain, mute, solo, pause all; a full ring returns false and counts `Dropped`) and reads an SPSC event ring of 1024 (`Finished`, `Stolen`, `Rejected`, `Starved`). |
|
||||
| D309 | Decoders. `audio/codec` defines `Info`, `Decoder` (`Read` interleaved float32, `SeekFrame`, `Close`; not `Seek`, which `go vet` reserves for `io.Seeker`) and the optional `Looper`. `Open` picks a format by its first bytes (RIFF/WAVE, OggS) and rejects MP3 with a hint to convert it with ffmpeg. Formats register in `init` of `audio/codec/wav` and `audio/codec/vorbis`, as `image/png` does; this is the only `init` registration in the engine, and the list of format imports lives in `modules/audio` (blank imports), not in `codec`. `wav` reads PCM 8/16/24/32-bit and IEEE float32, mono and stereo, WAVE_FORMAT_EXTENSIBLE, the first loop of a `smpl` chunk and a truncated data chunk up to its end; `wav.Encode` writes test and tool files. `vorbis` wraps a vendored copy of `github.com/jfreymuth/oggvorbis` v1.0.5 and `github.com/jfreymuth/vorbis` v1.0.2 (MIT, pure Go, builds for js/wasm) in `audio/codec/vorbis/internal/{oggvorbis,vorbis}`, turns its panics into errors and takes `Info.Frames` from `Length`. The copy is vendored, not required in `go.mod`: a game project's own module replaces `aego` with the engine directory, and an external requirement of the engine would have to be listed in every project's `go.mod` and `go.sum` (§1). Local changes: the import path, the import comments, and the two self-assignments in `inverseCoupling` that `go vet` rejects; the copy is left out of lint (`LINT_PKGS`), and exports ship its license as `licenses/oggvorbis.txt`. The WAV chunk ID `"data"` is excepted from the literal pass in depcheck. `DecodeAll(d, rate)` decodes up to 64 MB of float32 and resamples once to the mixer rate, scaling loop points. `Stream(d, stream, options, cancel)` fills a stream's ring from a goroutine and seeks to the loop start at the loop end or at EOF. Both decoders are fuzzed (`FuzzWAV`, `FuzzVorbis`). Streaming reads the artifact in memory, never the network: web exports load their data whole (D295), so there are no Range requests. Streaming a Vorbis clip allocates inside the decoder goroutine (the vendored decoder's buffers); the zero-allocation gate covers `Mix` and the pump, not decoding. |
|
||||
| D321 | Voice IDs and stealing. A `VoiceID` is `{handle, generation}`. Handles (4 × voices, rounded up to a power of two) are separate from the 64 voices: the game thread takes one with `Reserve` from a free ring the mixer refills, so `Play` returns the ID at once. The mixer binds the ID to a voice when the command arrives and releases the handle with a new generation when the voice ends, so commands with a stale ID are ignored. Without a free voice `Play` steals the voice with the lowest priority, the oldest among equals, when the new priority is at least as high (the old ID gets `Stolen`); otherwise the new ID gets `Rejected`. With no free handle `Play` returns the zero ID and counts `Refused`. |
|
||||
| D322 | Web audio transport. `web.OpenAudio` creates an `AudioContext` (latencyHint interactive, 48 kHz asked; the actual rate becomes `Spec().SampleRate`) and loads the processor from `worklet.js`, embedded and loaded from a Blob URL, into a stereo `AudioWorkletNode`. With `crossOriginIsolated` and `SharedArrayBuffer` the transport is a SAB ring of 8192 frames with Int32 head and tail updated by `Atomics.load/store` (positions wrap at 2³², the ring size is a power of two); otherwise every block is copied into one reused `Float32Array` and posted to the worklet (a structured-clone copy, no new array per block), which reports consumed frames, its audio clock, underruns and the sum of squares every 2048 frames; `Queued` is sent − consumed, minus what the clock says was played since the report (up to 4096 frames), and in this mode the pump keeps 6 blocks queued. A page can create the context itself inside a click and leave it in `globalThis.aegoAudioContext` (the docs Try it button does), so it runs at once however long the boot takes; `OpenAudio` reuses it and the worklet module loaded into it, and does not close it. Otherwise the context starts on the first `pointerdown`, `keydown` or `touchend` on the document; until it runs, `Write` drops data and `Queued` is 0. A hidden page suspends the context and a visible one resumes it after an earlier gesture. The ring arithmetic lives in an untagged file (`flow.go`) tested natively. |
|
||||
| D323 | Pump and threading. `audio/pump` owns the consumer side of the mixer and the output: `Fill` mixes blocks of the output's frame count until `Queued` reaches the target (3 blocks, 6 on the web message path). An underrun is counted when the queue is empty, voices are active and the output is running. A goroutine runs `Fill` every target/6 blocks: half a block natively (≈5 ms at 48 kHz/512), a block on the web message path, where each wakeup is a JavaScript timer and a scheduler round; in a headless engine `modules/audio` calls `Fill` once per frame from `audio.update` (`PhaseFrameBegin`), so headless runs are deterministic. Consumer-side changes (`SetSampleRate` on reopen) happen with the pump stopped. Stats: underruns, dropped commands, blocks, `MixMax`, `MixAvg`, active voices. `modules/audio` opens the platform output, or a silent headless one when the device fails; maps master ← music, sfx, ui, ambience, voice; applies volume and mute settings as bus gains with a ramp; and reopens the output on `audio.bufferSize` and `audio.sampleRate` with the same mixer, so playing voices continue. Mix of 512 frames × 64 voices should take at most 1 ms on the reference machine; this is reported, not gated (D213). The gates are zero allocations in `BenchmarkMix64` and no underruns in the headless pump test. |
|
||||
| D322 | Web audio transport. `web.OpenAudio` creates an `AudioContext` (latencyHint interactive, 48 kHz asked; the actual rate becomes `Spec().SampleRate`) and loads the processor from `worklet.js`, embedded and loaded from a Blob URL, into a stereo `AudioWorkletNode`. With `crossOriginIsolated` and `SharedArrayBuffer` the transport is a SAB ring of 8192 frames with Int32 head and tail updated by `Atomics.load/store` (positions wrap at 2³², the ring size is a power of two); otherwise every block is copied into one reused `Float32Array` and posted to the worklet (a structured-clone copy, no new array per block), which reports consumed frames, its audio clock, underruns and the sum of squares every 2048 frames; `Queued` is sent − consumed, minus what the clock says was played since the report (up to 4096 frames), and in this mode at least 4 blocks stay queued (6 before D366). A page can create the context itself inside a click and leave it in `globalThis.aegoAudioContext` (the docs Try it button does), so it runs at once however long the boot takes; `OpenAudio` reuses it and the worklet module loaded into it, and does not close it. Otherwise the context starts on the first `pointerdown`, `keydown` or `touchend` on the document; until it runs, `Write` drops data and `Queued` is 0. A hidden page suspends the context and a visible one resumes it after an earlier gesture. The ring arithmetic lives in an untagged file (`flow.go`) tested natively. |
|
||||
| D323 | Pump and threading. `audio/pump` owns the consumer side of the mixer and the output: `Fill` mixes blocks of the output's frame count until `Queued` reaches the target (3 blocks). An underrun is counted when the queue is empty, voices are active and the output is running. A goroutine runs `Fill` every target/6 blocks, half a block natively (≈5 ms at 48 kHz/512); on the web the frame loop fills the output (D366); in a headless engine `modules/audio` calls `Fill` once per frame from `audio.update` (`PhaseFrameBegin`), so headless runs are deterministic. Consumer-side changes (`SetSampleRate` on reopen) happen with the pump stopped. Stats: underruns, dropped commands, blocks, `MixMax`, `MixAvg`, active voices. `modules/audio` opens the platform output, or a silent headless one when the device fails; maps master ← music, sfx, ui, ambience, voice; applies volume and mute settings as bus gains with a ramp; and reopens the output on `audio.bufferSize` and `audio.sampleRate` with the same mixer, so playing voices continue. Mix of 512 frames × 64 voices should take at most 1 ms on the reference machine; this is reported, not gated (D213). The gates are zero allocations in `BenchmarkMix64` and no underruns in the headless pump test. |
|
||||
|
||||
#### 10b2 Audio content
|
||||
|
||||
@@ -604,9 +604,29 @@ The Stage 8 specification numbered these decisions D153, D155, D158, D159, D168
|
||||
| D355 | Event identity: half-open crossing intervals give each event of each cycle exactly one fixed step; the recorder marks replayed records. The full key `{world, tick, player, event, cycle}` is built by the Event Monitor in 11a3. |
|
||||
| D356 | Binding and reload: tracks bind to nodes when the hierarchy, the components or a clip change, in scene order; a missing node is ANM005 and the track stays off. A clip imported again with the same tracks keeps the players at the same normalized time; with other tracks the players stop (ANM008). |
|
||||
| D357 | Ledge climbing and walls in the air for Motion 2D (platformer mode). A wall touched in the air takes no movement into it and never adds upward motion (`wallSlide`: the push into the wall is dropped, the rest slides along the surface, the rise is capped at what it was), and a cast that stops inside the safe margin pushes back out along the wall normal; this applies to every platformer character, so a rounded collider slides off a corner instead of hanging on it. `ledgeClimb` is the highest ledge above the feet the character climbs onto, 0 turns it off; when it moves into a wall in the air and is not rising faster than half its jump speed, three body casts (up by Ledge Climb, forward, down) find a floor to stand on. The climb then moves the body on fixed steps along a fixed path, up first and onto the ledge after, over `climbTime`, ignoring input, gravity and root motion (`AddDisplacement`) with the velocity at zero, and lands it on the ledge's body. `AddVelocity` (knockback), `Stop`, `Dash` and `Teleport` end it at once. `climbing` is a runtime field, `Climbed` a signal at the start. The climb state (start, rise, forward offset, time left, ledge body) lives in the component's private state, so keyframes capture it with the component, and `hashMotion` hashes it with the flag. |
|
||||
| D362 | Updates are one core, `update` (stdlib only, a low root): a channel publishes `<base>/<channel>/release.json` (version, channel, published, notes, minVersion, assets with os, arch, kind, url, size and sha256) and `release.json.sig`, an Ed25519 signature of the whole file. A build trusts the key set with `-X aego/update.PublicKey`; nothing without a valid signature and sha256 installs. `Check` uses an ETag cache and a 3 s timeout; `Download` resumes into the user cache and checks the sum; `Detect` finds the install: the `install.sh` layout (`share/aego/<version>` with `current` and `previous` links, which install.sh now makes), the AppImage, the NSIS folder (its installer runs `/S /UPDATE` after the editor quits and restarts it; it copies itself to `setup.exe` there, and an update keeps that copy in `updates/previous` for Restore, which runs it the same way) or a `.app`, and for exported games their folder, swapped whole. The replaced version is kept for Restore; a trial record makes a version that stops before 10 s of its first run roll back on the next start (D74 does the same for native rebuilds). Aego: `app/updates` points at its Gitea releases (`stable` is a release that holds the newest version's release.json, `nightly` the rolling build), the policy (Auto, Ask, Off; channel; skipped versions; last check) lives in `prefs.aetext`, and one update service per process feeds the launcher's banner and settings, the status bar, Help's commands and the Task Center; installing happens only on restart. `aego update` and `aego release keygen|manifest` are the CLI; `release.sh` refuses to publish without `AEGO_RELEASE_KEY` and `AEGO_UPDATE_KEY`. Games: `modules/update` reads `update { url publicKey }`, adds Updates and Update channel to the Settings View and the `aego/update` script API; `aego export --release-json` packs and signs a game release. `engine.Version` is a variable so a build can set it. Amends D301. |
|
||||
| D362 | Updates are one core, `update` (stdlib only, a low root): a channel publishes `<base>/<channel>/release.json` (version, channel, published, notes, minVersion, assets with os, arch, kind, url, size and sha256) and `release.json.sig`, an Ed25519 signature of the whole file. A build trusts the key set with `-X aego/update.PublicKey`; nothing without a valid signature and sha256 installs. `Check` uses an ETag cache and a 3 s timeout; `Download` resumes into the user cache and checks the sum; `Detect` finds the install: the `install.sh` layout (`share/aego/<version>` with `current` and `previous` links, which install.sh now makes), the AppImage, the NSIS folder (its installer runs `/S /UPDATE` after the editor quits and restarts it; it copies itself to `setup.exe` there, and an update keeps that copy in `updates/previous` for Restore, which runs it the same way) or a `.app`, and for exported games their folder, swapped whole. The replaced version is kept for Restore; a trial record makes a version that stops before 10 s of its first run roll back on the next start (D74 does the same for native rebuilds). Aego: `app/updates` points at its Gitea releases (`stable` is a release that holds the newest version's release.json, `nightly` the rolling build), the policy (Auto, Ask, Off; channel; skipped versions; last check) lives in `prefs.aetext`, and one update service per process feeds the launcher's banner and settings, the status bar, Help's commands and the Task Center; installing happens only on restart. `aego update` and `aego release keygen|manifest` are the CLI; `release.sh` refuses to publish without `AEGO_RELEASE_KEY` and `AEGO_UPDATE_KEY`. Games: `modules/update` reads `update { url publicKey }`, adds Updates and Update channel to the Settings View and the `aego/update` script API; `aego export --release-json` packs and signs a game release. `engine.Version` is a variable so a build can set it. Amends D301. Amended 2026-10-27: the web build links `modules/update` with a browser backend (same section, settings and `aego/update` API; `Check` and `Apply` fail with "updates are not available in the browser", State stays idle) and never the `update` core; depcheck holds the web builds (`aego-runtime`, `aego-playground`, `aego-try`) away from `update`, `net/http`, `crypto/tls`, `crypto/x509` and `archive/*` (`wasm-reaches-forbidden`, `wasm-reaches-std`), and `make playground-size`/`try-size` print the change since their last run. The loaders show the compressed download size (`wasmGzip` in an export's `files.json`, `aego-*-gzip` on the site). |
|
||||
| D363 | Agents draw their art (11b8). `format/pixelart` turns a character grid and a palette into an image, frames of one size into an atlas; `format/svg` draws an SVG subset (shapes, paths with arcs, groups, fill, stroke, opacity, transforms; no filters, gradients or text) with its own scanline rasterizer, since the root module has no external dependencies and genicons' rasterizer is a separate module. The agent tools `asset.pixels` and `asset.svg` write a PNG under assets/, import it, slice frames into named sub-assets (ids kept by name on `replace`), write a flipbook clip (`anim.FlipbookText`, shared with Flipbook from Selection) and answer with a preview image. The `art` module owns `project.aego`'s `art { palette pixelsPerUnit }`: the palette is the default for asset.pixels (0-9, a-z), pixelsPerUnit sets the default scale against the render pixels per unit, and Project Health reports pixel-art textures with more than 1 % of their pixels off the palette (HLT009). The agent prompts say to draw instead of building sprites from rectangles. A run the editor starts opens the asset index shared without a warning (`pipeline.ExpectShared`), and `prefab.create` on a scene open in the editor goes through `prefabs.Service.CreateAt`, so unsaved scenes work and the link is an Undo step. Generated images from external models (`assets.ImageProvider`) wait for stage 15. |
|
||||
|
||||
### 3.24 Stage 12w — Web runtime performance
|
||||
|
||||
| ID | Decision |
|
||||
|---|---|
|
||||
| D365 | Web performance measurement. `make web-bench` (`tools/websmoke/bench.mjs`) runs the exported platformer through Playwright in Chromium and Firefox under fixed conditions: 1280×720, device pixel ratio 1, render scale 1, seed 1, the platformer input recording of D315 handed to the page as `globalThis.aegoReplay` (base64, replayed like `--replay`), 120 warmup frames, 600 measured, 5 runs. `?bench=1` puts the engine in lockstep (`Engine.SetLockstep`: every frame advances one fixed step whatever the real time), so the window is the same 600 ticks in every run; the bench sets it with `aegoGame.window(from, to, opened, closed)`, the player takes both snapshots in Go at those frames and the page is not called during the window. Lockstep is turned off before the scenarios. It writes `.cache/web-bench.json` and a Markdown table. Three levels: Go (allocations and bytes per frame from `runtime/metrics`, GC cycles, heap; with `?bench=1` the engine probe counts allocations per zone, every phase plus `gpu` for BeginFrame, EndFrame and the swap and `poll` for platform events, cumulatively in `Engine.AllocTotals`, and `frame` with `Engine.FrameBytes` for the whole engine frame, which on wasm excludes goroutines woken by real time); bridge (`gpu.BridgeReporter`: WebGL calls, timer query calls apart since their polling follows GPU latency, draws, buffer and texture uploads and bytes, program and render-target switches, call time measured on every 16th call and scaled); browser (`requestAnimationFrame` gaps as frame time p50/p95/p99, callback work, frames over 33 ms, long tasks, JS heap, GPU time from the timer query). The window snapshots and `aegoGame.stats()` are cumulative counters the bench subtracts; `aegoGame.trace()` returns a Chrome trace under `?trace=1`, `aegoGame.loseContext` drives `WEBGL_lose_context`, `?gogc=` and `?memlimit=` set the GC for experiments. Gates (`tools/websmoke/bench-gates.json`): zones in `zeroAllocZones` allocate nothing after warmup; on the reference machine with the hardware GPU (`--gpu=hard`) frame p95 ≤ 20 ms and at most 1 % of frames over 33 ms; counts (draws, WebGL calls, buffer and texture uploads and bytes, program and target switches) are the same in every run and equal to the machine's own baseline `.cache/web-bench.base.json`, kept per GPU mode and browser; Go bytes of the engine frame may exceed the baseline by at most `frameBytesSlack` (24 bytes per frame), because on wasm a goroutine woken by real time runs while the frame waits for jobs and its allocations land in the frame; allocated objects, GC cycles and heap are reported, not gated. The audio pump sleeps on the mixer's command queue (`CommandQueue.Wake`) while no voice plays and no command waits, instead of ticking; while the output is muted or not running it writes nothing and moves the mixer on by the elapsed time at 4 Hz with `Mixer.Skip`, which applies commands and advances ramps, positions and loops and ends voices with their events where mixing would, so the pool frees and nothing that should have sounded earlier plays after unmuting. A skipped stream drops what is decoded and hands the rest to its feeder (`Stream.TakeSkip`), which seeks past it, wrapped into the loop, and decodes nothing while the stream is held (`Stream.Held`); sound resumes within 250 ms of unmuting. 12w2 fills the mixer from the frame in lockstep and moves the remaining background goroutines to the same rule, then drops the slack. Frame and work time are compared only with at least 5 runs on both sides and fail on a Mann-Whitney p < 0.05 with the median more than 10 % slower (D213). Scenarios: a page hidden for 2 s adds no clamped time, and the 6 key events sent while it is hidden reach the game; a context loss is restored and adds no clamped time. `make web-smoke` runs one Chromium run with the gates, which leaves time out. Amended in 12w2: zone counts come from `runtime/metrics`, which records small allocations when a span fills, so they land in whichever zone fills it and are reported as approximate, not gated. The engine-frame allocation gate uses the profile pass of D366. |
|
||||
| D366 | Web GC, bridge and GPU. On the reference machine (D365) a frame makes 48–50 WebGL calls, 3 draws and 2 buffer uploads of 0.4 KB, and spends 0.3–0.4 ms in the bridge; frame p95 is 16.7 ms. The Go heap is 30 MB and grows about 420 bytes per frame against a 60 MB goal, so the first GC comes after about 18 minutes of play; a 3600-frame run had no GC cycle and no frame gap over 16.8 ms. GOGC stays at the default and no memory limit is set. The WebGL bridge keeps one JS call per GL call: arguments passed to `Invoke` do not escape, so they cost no allocation, and a command buffer would save under 0.4 ms per frame. Exact allocation counts come from a profile pass of the bench (`?memprofile=1`): `runtime.MemProfileRate = 1`, a GC and `runtime.MemProfile` at both ends of the measured window, the difference split into sites inside the engine frame (stacks through `Engine.frame`) and outside it; the snapshots' own allocations are left out. Gate: allocations inside the engine frame over the window ≤ `maxFrameAllocs` (0), `allowFrame` lists stack substrings that are exempt, each with a reason in the commit. The virtual gamepad object is looked up once instead of every frame, which removed the one allocation per frame in event polling. On the web the audio module fills the output from the frame loop (`audio.update`, FrameBegin) instead of a pump goroutine: the page has one thread, so the goroutine added no parallelism, and its ticker kept a timer armed, which made the Go runtime allocate a timeout event every time it went idle. The queue target follows the frame rate: `ceil(1.5 × frame / block) + 1` blocks, at least `TargetBlocks()` (4 on the message transport, 3 on the shared one), at most 12; that is 4 blocks (43 ms) at 60 Hz and 6 (64 ms) at 30 Hz. Thirty-second runs of the transports at 6, 4 and 3 blocks had no underruns, and the game run is gated on none. The stream feeder waits for the mixer to read a quarter of the ring instead of polling every 5 ms. Inside the engine frame the gate exempts only first decoding of a clip and slices that grow to a high-water mark and keep their capacity; each exemption names its reason in `bench-gates.json`. A profile stack holds the innermost 32 frames. A stack that fills all 32 is counted inside the engine frame and marked deep in the report: script calls through the VM reach that depth, while allocations outside the frame come from shallow stacks (JS callbacks, goroutines), so a wrong guess fails the gate instead of passing it. Exemptions are matched against the whole recorded stack. Outside the frame what remains comes from `syscall/js` handling JS callbacks: the animation frame callback and the audio worklet's reports, about 0.2 per frame of the latter. |
|
||||
| D390 | Web platform pacing and density. The display module's player setting `display.maxPixelRatio` (default 1.5, 1–4, Graphics) caps the device pixel ratio a game renders at through the optional `platform.PixelRatioLimiter` of the window; the web window renders at `min(devicePixelRatio, limit)` and compares the raw ratio each frame, so a cap does not refresh the canvas every frame. The editor, the docs runtime and the playground render at the full ratio. `Engine.SetFrameLimit` passes the limit to a platform that implements `platform.FrameLimiter`; the web driver skips animation frames until the next deadline (2 ms slack, the deadline resets after a gap such as a hidden tab), so the frame limit works on the web, where the swap is always synced. A hidden page adds no clamped time and loses no input (D365 scenario). Measured on the reference machine at device pixel ratio 2 (Chromium, hardware GPU): at limit 1.5 frame p95 16.7 ms, work p95 2.7 ms, GPU 0.08 ms; at limit 4, which renders at the full 2, 16.8 ms, 2.7 ms and 0.09 ms, so for the platformer the difference is within noise and the cap matters for scenes bound by fill rate. The GPU time covers the first view only; the output pass that encodes the window to sRGB, where the pixel ratio costs fill, is outside the timer. |
|
||||
| D391 | The game loop stays on the main thread; a Worker with OffscreenCanvas is not built. The audit found that `engine` sees the web only through `platform.Platform`, `platform.Driver` and `webgl2.Surface`, so a worker platform would need no engine change; it would need a second platform package, a main-thread shim that forwards input, gamepads, resize, focus and visibility, an audio output over SharedArrayBuffer (cross-origin isolated pages only) and `platform.UserFiles` on IndexedDB (`platform/userfs_js.go` uses localStorage). The inclusion criterion (frame p95 at least 20 % better or half the frames over 33 ms) cannot be met on the reference scene: frame p95 is the 16.7 ms vsync period in Chromium and 17.2 ms in Firefox, no frame is over 33 ms, main-thread work is 2.7 ms at p95 and no GC cycle runs in 3600 frames. A Worker moves work off the main thread without reducing it, so it pays off only when the page's own thread is busy. Revisit when a measured scene shows main-thread contention, for example the editor in the browser. |
|
||||
|
||||
### 3.25 Stage 12a — Materials and shaders
|
||||
|
||||
| ID | Decision |
|
||||
|---|---|
|
||||
| D367 | Shader language and import. A `.aeshader` is GLSL 3.30 between Aego pragmas: `#pragma aego abi 1` first, then optional `#pragma domain sprite\|mesh2d` (postfx is reserved), `#pragma blend premultiplied\|opaque\|additive`, `#pragma variant KEY…`, `#pragma variants A, A+B` to list the combinations in use, and one `#pragma material … #pragma end` block of `uniform <type> <Name>; // @default(…) @range(min, max) @color @texture @instance @enum(a, b) @doc("…") @display("…") @unit("…") @hidden`. The shader writes `void fragment()` and may write `void vertex()`, which edits `aego_world`. `#include "x.glsl"` reads `assets/shaders/include/x.glsl` of the project and `#include "engine/x.glsl"` the engine's (`sprite`, `color`, `noise`, `lighting`); a repeated include is skipped, a cycle or a depth over 8 is an error. `.glsl` files are assets of their own (`shaderinclude`), so a shader that includes one lists it in its Deps and reimports with it. The importer (`asset/importers/shader`, dev-only) generates every variant in both stages and both dialects (GLSL 3.30 and ES 3.00) into the AESH artifact and runs `glslangValidator` on all of them; `make ci` sets `AEGO_REQUIRE_GLSLANG=1`, so a missing validator fails there, and elsewhere it is a warning and the driver is the only check. `ImportContext.Read` gives importers other source files (`engine://` from the embedded engine files). |
|
||||
| D368 | Materials. `render/material` holds `Shader` (the runtime form of AESH), `Material` (a variant mask, a blend, the std140 bytes of its values and its textures) and `Cache` (programs and pipelines per shader, variant and blend, made on first use). It never imports `render`: the renderer hands the cache a pipeline template per domain. A `.aemat` (`material version=1 shader=asset(…) { blend=… variants=[…] params { … } }`) imports as its normalized text (AEMT), checked for syntax only, with the shader and the textures as Deps; the resolver applies it: the shader's variant by keyword names, `material.New`, then `Set`/`SetTexture`. A material is part of the sprite batch state only as the condition for merging neighbouring instances; it never reorders them (D19 stands). A nil material draws through the built-in pipelines exactly as before; a set material's blend wins over `SpriteRenderer.Blend`. `@instance` parameters are per-sprite lanes (`Draw.Lanes`, the material's defaults otherwise), compacted per batch, so lanes never break a batch and a frame without such materials uploads nothing for them. After a WebGL context restore the cache uploads every material's values again (uniform buffers come back empty). |
|
||||
| D380 | Shader ABI 1. Uniform blocks: `ub0` frame (`aego_time`, `aego_delta`, `aego_unscaled`, `aego_frame_index`; 16 bytes, written once per frame from `render.Clock`, which the render host sets from the engine's time and golden tests pin with `PinClock`), `ub1` view (view-projection, viewport, pixel scale, pixels per unit, zoom, camera; 112 bytes, written once per view), `ub2` the material's values, `ub3` the mesh draw (world, tint, UV rect). Textures: `tex0` the sprite or mesh texture (`aego_main`), `tex1` reserved for normals, `tex2`–`tex7` the material's textures. The built-in shaders moved to these slots (`ub1` for the view-projection, `ub3` for meshes); a `ub1` of one mat4 matches the 112-byte block's first member. Lanes: vertex slot 1, locations 4–7, 64 bytes per instance. Variant keywords are always defined as 0 or 1, so shaders test them with `#if KEY`. Generated sources map every line to a file and line of the source through a table instead of `#line`, and every variant of a stage has the same line count. |
|
||||
| D381 | Limits and checks. At most 8 variant keywords, 32 variants, 32 parameters, names of 32 characters, 6 textures, 4 lanes. The importer reports SHD001 (syntax and pragmas), SHD002 (includes), SHD003 (parameters and annotations), SHD004 (limits), SHD005 (include cycles), SHD006 (variants), SHD007 (domain), SHD008 (GLSL without an ES 3.00 form) with file and line, shown in Problems. Material checks run outside the importer, against the shader the material names: MAT001 an unknown parameter (a warning; the value stays in the file, as in D37), MAT002 a value of the wrong kind, count or range, MAT003 an unknown or unlisted variant, MAT004 a shader of another domain. |
|
||||
| D383 | Texture colour space (the 12a part): a material's textures use the `srgb` setting the texture's `.meta` already has; materials add no colour-space setting of their own. |
|
||||
| D393 | Last good shader. A shader that fails to import keeps its previous artifact (the pipeline leaves the index entry as it was), and a reimported one reaches its materials through `Material.Reload`: the cache moves a material to the new shader on its next bind only when the new program and pipeline build, keeping values by name and resetting new ones to their defaults; otherwise the material keeps drawing with the old shader and reports `Failed()`. Replaced shaders go to `Cache.Retire` and are freed after a frame that no longer uses them; a removed material goes to `Cache.Release`. A driver error is SHD009: `Cache.OnError`, set by the render host, maps the driver's log back to the shader's own file and line and puts it in the log and in Problems. |
|
||||
|
||||
## 4. Dependency rules
|
||||
|
||||
The import graph is acyclic. The rules below are enforced by `tools/depcheck`.
|
||||
@@ -1085,6 +1105,7 @@ Packages are named by capability (`render/view`, `asset/loader`, `scene/sceneio`
|
||||
| 10c | Gameplay kit: shape casts, Motion2D, Health, hitboxes, zones, respawn, pickups, lifetime, StateMachine with script calls, templates, short-name rule, platformer and top-down examples (D312–D315, D328–D330, D332) |
|
||||
| 10d | Integration: required components, Play diff from history, node labels, audio fast path, shutdown order, profiler and monitor sections, diagnostics pages, check levels, boundary audit (D316) |
|
||||
| 11 | First commit: diagnostics pages for the asset codes DAT* and the export codes EXP* (EXP has no owning module; its `Codes` list needs an owner in `app/export`, to be decided). Then in order: Animation → Agent panel → Add Component picker → light theme → timeline performance → localization (Russian) → close: GitHub mirror → custom installers → editor telemetry → game telemetry |
|
||||
| 12w | Web runtime performance: measurement and gates (D365); GC, bridge and GPU (D366); platform and the Worker spike (D390, D391) |
|
||||
| 13+ | Export/pak, networking/store, Android |
|
||||
| 18.5 | Boundary audit: package split candidates from the Stage 10 size report (`tools/pkgsize.sh`: `modules/physics/world` 5604 lines, `editor/shell` 4666, `editor/panels/scripteditor` 4354) |
|
||||
|
||||
|
||||
@@ -29,7 +29,12 @@ TRY_BUDGET = 10485760
|
||||
WEB_TRY = cd tools/websmoke && CHROMIUM=$(CHROMIUM) node try.mjs --site=$(CURDIR)/site
|
||||
WEB_AUDIO = cd tools/websmoke && CHROMIUM=$(CHROMIUM) node audio.mjs --site=$(CURDIR)/site
|
||||
WEB_THEME = cd tools/websmoke && CHROMIUM=$(CHROMIUM) node theme.mjs --site=$(CURDIR)/site --out=$(CURDIR)/.cache/web-theme
|
||||
TRY_SIZE_CHECK = gzip -9 -k -f site/try/aego-docs.wasm; size=$$(stat -c %s site/try/aego-docs.wasm.gz); echo "aego-docs.wasm.gz: $$size bytes"; \
|
||||
WEB_BENCH_BROWSERS ?= chromium,firefox
|
||||
WEB_BENCH_ARGS ?=
|
||||
WEB_BENCH = cd tools/websmoke && CHROMIUM=$(CHROMIUM) node bench.mjs --site=$(CURDIR)/site \
|
||||
--replay=$(CURDIR)/tests/integration/testdata/platformer_run.aeinput \
|
||||
--out=$(CURDIR)/.cache/web-bench.json --baseline=$(CURDIR)/.cache/web-bench.base.json
|
||||
TRY_SIZE_CHECK = size=$$(stat -c %s site/try/aego-docs.wasm.gz); echo "aego-docs.wasm.gz: $$size bytes"; \
|
||||
if [ $$size -gt $(TRY_BUDGET) ]; then echo "the docs runtime is over the 10 MB gzip budget, see make try-size"; exit 1; fi
|
||||
# Module targets, appended by modules/*/module.mk.
|
||||
MODULE_CI :=
|
||||
@@ -42,7 +47,7 @@ BENCH_PHYSICS_PKGS :=
|
||||
.DEFAULT_GOAL := bootstrap
|
||||
-include modules/*/module.mk
|
||||
|
||||
.PHONY: dmg nsis-check test-update clean-root appimage bootstrap build test test-nonative test-editor test-runtime test-gl vet lint depcheck smoke run genfont genicons icons icons-doc res genpreviews golden golden-update site-headless bench bench-baseline bench-scripts bench-build bench-compare bench-profile bench-scripts-run bench-allocs playground playground-size site site-check cli example example-platformer check-all ci clean fuzz runtime export-smoke editor-app shaders test-wasm web-smoke web-smoke-update try-size shots shots-check affected quick check-camera check-audio check-gameplay check-mutate check-editor check-web check-docs i18n-check pkgsize
|
||||
.PHONY: dmg nsis-check test-update clean-root appimage bootstrap build test test-nonative test-editor test-runtime test-gl vet lint depcheck smoke run genfont genicons icons icons-doc res genpreviews golden golden-update site-headless bench bench-baseline bench-scripts bench-build bench-compare bench-profile bench-scripts-run bench-allocs playground playground-size site site-check cli example example-platformer check-all ci clean fuzz runtime export-smoke editor-app shaders test-wasm web-smoke web-smoke-update web-bench web-bench-baseline try-size shots shots-check affected quick check-camera check-audio check-gameplay check-mutate check-editor check-web check-docs i18n-check pkgsize
|
||||
|
||||
XVFB := $(shell command -v xvfb-run 2>/dev/null)
|
||||
GL_OVERRIDE = MESA_GL_VERSION_OVERRIDE=3.3 MESA_GLSL_VERSION_OVERRIDE=330
|
||||
@@ -177,6 +182,8 @@ bench-allocs: bootstrap $(MODULE_BENCH_ALLOCS)
|
||||
$(GL_ENV) .cache/bin/bench -demo stress-scene -baseline $(BENCH_BASELINE) -count 1 -time=false
|
||||
$(MAKE) bench-scripts-run BENCH_OUT=$(BENCH_DIR)/scripts.allocs.txt BENCH_COUNT=1
|
||||
$(BENCH_GATE) -time=false -base $(BENCH_BASE_DIR)/scripts.base.txt -head $(BENCH_DIR)/scripts.allocs.txt
|
||||
go test -run '^$$' -bench '^BenchmarkSpritesMaterials$$' -benchmem -count=1 ./render | tee $(BENCH_DIR)/materials.allocs.txt
|
||||
@awk '/^BenchmarkSpritesMaterials/ { seen++; for (i = 2; i <= NF; i++) if ($$i == "allocs/op" && $$(i-1) + 0 != 0) bad = 1 } END { if (seen != 1 || bad) { print "BenchmarkSpritesMaterials allocates or did not run"; exit 1 } }' $(BENCH_DIR)/materials.allocs.txt
|
||||
|
||||
|
||||
bench-build:
|
||||
@@ -213,7 +220,6 @@ site: cli
|
||||
.cache/bin/aego docs build --out site --repo $(DOCS_REPO) --demo
|
||||
.cache/bin/aego export $(WEB_DEMO_PROJECT) --target web --release --out site/demo
|
||||
$(WEB_DEMO_QUIET)
|
||||
gzip -9 -k -f site/play/aego.wasm
|
||||
@size=$$(stat -c %s site/play/aego.wasm.gz); echo "aego.wasm.gz: $$size bytes"; \
|
||||
if [ $$size -gt $(WASM_BUDGET) ]; then echo "playground is over the 8 MB gzip budget, see make playground-size"; exit 1; fi
|
||||
$(WEB_DEMO_BUDGET)
|
||||
@@ -228,7 +234,6 @@ site-headless:
|
||||
.cache/bin/aego-docs docs build --out site --engine $(CURDIR) --repo $(DOCS_REPO) --demo
|
||||
.cache/bin/aego-docs export $(WEB_DEMO_PROJECT) --target web --release --engine $(CURDIR) --out site/demo
|
||||
$(WEB_DEMO_QUIET)
|
||||
gzip -9 -k -f site/play/aego.wasm
|
||||
@size=$$(stat -c %s site/play/aego.wasm.gz); echo "aego.wasm.gz: $$size bytes"; \
|
||||
if [ $$size -gt $(WASM_BUDGET) ]; then echo "playground is over the 8 MB gzip budget, see make playground-size"; exit 1; fi
|
||||
$(WEB_DEMO_BUDGET)
|
||||
@@ -243,21 +248,30 @@ playground-size:
|
||||
mkdir -p .cache
|
||||
GOOS=js GOARCH=wasm go build -trimpath -o .cache/aego-playground.wasm ./cmd/aego-playground
|
||||
go run ./tools/wasmsize -top 40 .cache/aego-playground.wasm
|
||||
go run ./tools/wasmsize -top 15 -depth 2 .cache/aego-playground.wasm
|
||||
go run ./tools/wasmsize -top 15 -depth 2 -prev .cache/aego-playground.size.json .cache/aego-playground.wasm
|
||||
|
||||
try-size:
|
||||
mkdir -p .cache
|
||||
GOOS=js GOARCH=wasm go build -tags aego_editor -trimpath -o .cache/aego-try.wasm ./cmd/aego-try
|
||||
go run ./tools/wasmsize -top 40 .cache/aego-try.wasm
|
||||
go run ./tools/wasmsize -top 15 -depth 2 .cache/aego-try.wasm
|
||||
go run ./tools/wasmsize -top 15 -depth 2 -prev .cache/aego-try.size.json .cache/aego-try.wasm
|
||||
|
||||
web-smoke: site
|
||||
sh tools/webbudget.sh site
|
||||
$(WEB_SMOKE) --report=$(CURDIR)/.cache/web-bench.json
|
||||
$(WEB_SMOKE) --report=$(CURDIR)/.cache/web-sprites.json
|
||||
$(WEB_DEMO)
|
||||
$(WEB_TRY)
|
||||
$(WEB_AUDIO)
|
||||
$(WEB_THEME)
|
||||
$(WEB_BENCH) --browsers=chromium --runs=1 --gate
|
||||
|
||||
web-bench: site
|
||||
cd tools/websmoke && npm ci --no-audit --no-fund
|
||||
$(WEB_BENCH) --browsers=$(WEB_BENCH_BROWSERS) --gate $(WEB_BENCH_ARGS)
|
||||
|
||||
web-bench-baseline: site
|
||||
cd tools/websmoke && npm ci --no-audit --no-fund
|
||||
$(WEB_BENCH) --browsers=$(WEB_BENCH_BROWSERS) --update-baseline $(WEB_BENCH_ARGS)
|
||||
|
||||
web-smoke-update: site
|
||||
$(WEB_SMOKE) --update
|
||||
@@ -331,6 +345,8 @@ i18n-check:
|
||||
pkgsize:
|
||||
@sh tools/pkgsize.sh
|
||||
|
||||
# ci requires glslangValidator: shader imports and the shader tests fail without it instead of skipping (D367).
|
||||
ci: export AEGO_REQUIRE_GLSLANG := 1
|
||||
ci: build vet lint depcheck i18n-check test test-nonative test-editor test-runtime test-wasm runtime smoke fuzz golden shots-check test-gl cli playground web-smoke site-check example example-platformer check-all export-smoke test-update $(MODULE_CI) bench-allocs
|
||||
@sh tools/ci-clean-root.sh
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Aego
|
||||
# Aego — A 2D game engine built in Go.
|
||||
|
||||
Make 2D games in Go. One editor, one project, desktop and web.
|
||||
Build games visually, write gameplay logic in Aego Script, and extend the engine with native Go.
|
||||
|
||||
Build scenes in the editor, script them in a small Go-like language, extend the engine with Go when you need to, and ship the same project to Linux, Windows, macOS and the browser. Deterministic simulation, rewind, physics, animation, audio and cameras come built in.
|
||||
A complete development environment with a visual editor, hot reload, deterministic simulation, frame-by-frame rewind, and cross-platform export. From your first scene to a playable game on Windows, Linux, macOS, and the web.
|
||||
|
||||
- Documentation and the playground: <https://aego.akarpov.ru>
|
||||
- Installing and building from source: [engine/res/docs/install.md](engine/res/docs/install.md)
|
||||
|
||||
@@ -188,13 +188,16 @@ func (h *Host) build(cfg Config, mods []module.Module) error {
|
||||
h.Renderer = rmod.Renderer()
|
||||
h.RenderStats = rmod.Stats
|
||||
h.Loader = loader.New(h.Engine.GPU(), h.Renderer.Textures(), lg)
|
||||
h.Loader.SetMaterials(h.Renderer.Materials())
|
||||
if err = h.Loader.Mount(h.Pipeline.Index(), h.Pipeline.Artifacts()); err != nil {
|
||||
return err
|
||||
}
|
||||
h.Engine.SetService(loader.ServiceID, h.Loader)
|
||||
h.Engine.SetService(renderhost.ServiceID, h.Renderer)
|
||||
h.Problems = problems.New()
|
||||
h.Problems.Add(rmod.ShaderProblems())
|
||||
h.Problems.Add(MetaProblems(h.Pipeline))
|
||||
h.Problems.Add(ShaderProblems(h.Importers))
|
||||
h.Problems.Add(h.Modules.AssetProblems(h.Types.Schema(), h.Loader))
|
||||
h.Engine.SetService(problems.ServiceID, h.Problems)
|
||||
if h.Settings, err = h.Modules.NewSettings(); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package docstool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -213,6 +214,9 @@ func (e *Env) tryApp(dir string) (string, error) {
|
||||
if err := e.goCmd(e.Engine, []string{"GOOS=js", "GOARCH=wasm"}, "build", "-tags", "aego_editor", "-trimpath", "-ldflags", "-s -w", "-o", wasm, "./cmd/aego-try"); err != nil {
|
||||
return "", fmt.Errorf("docs runtime build failed: %w", err)
|
||||
}
|
||||
if _, err := gzipCopy(wasm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
root, err := goRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -231,6 +235,21 @@ func (e *Env) tryApp(dir string) (string, error) {
|
||||
return versionOf(blob, support), nil
|
||||
}
|
||||
|
||||
// gzipCopy writes path.gz next to path, the copy the site serves, and returns its size.
|
||||
func gzipCopy(path string) (int64, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
||||
zw.Write(raw)
|
||||
if err := zw.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(buf.Len()), os.WriteFile(path+".gz", buf.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && !st.IsDir()
|
||||
@@ -268,6 +287,10 @@ func (e *Env) playground(dir string) (string, error) {
|
||||
if err := e.goCmd(e.Engine, []string{"GOOS=js", "GOARCH=wasm"}, "build", "-trimpath", "-ldflags", "-s -w", "-o", wasm, "./cmd/aego-playground"); err != nil {
|
||||
return "", fmt.Errorf("playground build failed: %w", err)
|
||||
}
|
||||
gz, err := gzipCopy(wasm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
root, err := goRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -316,6 +339,7 @@ func (e *Env) playground(dir string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
data = bytes.ReplaceAll(data, []byte(`data-wasm-size="0"`), []byte(`data-wasm-size="`+strconv.FormatInt(info.Size(), 10)+`"`))
|
||||
data = bytes.ReplaceAll(data, []byte(`data-wasm-gzip="0"`), []byte(`data-wasm-gzip="`+strconv.FormatInt(gz, 10)+`"`))
|
||||
data = bytes.ReplaceAll(data, []byte(`data-version="dev"`), []byte(`data-version="`+version+`"`))
|
||||
data = bytes.ReplaceAll(data, []byte(`?v=dev"`), []byte(`?v=`+version+`"`))
|
||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||
|
||||
@@ -140,6 +140,7 @@ func New(opt Options) (*App, error) {
|
||||
}
|
||||
a.renderer = rmod.Renderer()
|
||||
a.loader = loader.New(a.e.GPU(), a.renderer.Textures(), a.log)
|
||||
a.loader.SetMaterials(a.renderer.Materials())
|
||||
if err := a.loader.Mount(asset.NewIndex(), a.e.VFS()); err != nil {
|
||||
a.Close()
|
||||
return nil, err
|
||||
|
||||
+10
-12
@@ -51,6 +51,7 @@ type webFile struct {
|
||||
|
||||
type webList struct {
|
||||
Wasm int64 `json:"wasm"`
|
||||
WasmGzip int64 `json:"wasmGzip"`
|
||||
Manifest webFile `json:"manifest"`
|
||||
Files []webFile `json:"files"`
|
||||
}
|
||||
@@ -159,6 +160,7 @@ func (j *job) webBuild(scripts ...[]byte) (string, error) {
|
||||
}
|
||||
|
||||
func (j *job) packWeb() error {
|
||||
brotli, _ := exec.LookPath("brotli")
|
||||
src, err := wasmExecPath(j.goroot)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -186,7 +188,10 @@ func (j *job) packWeb() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
list, err := json.Marshal(webList{Wasm: j.rep.BinarySize, Manifest: webFile{Path: bundle.ManifestFile, Size: j.manifestSize}, Files: j.files})
|
||||
if j.rep.BinaryGzip, err = precompress(j.l.binary(), brotli); err != nil {
|
||||
return err
|
||||
}
|
||||
list, err := json.Marshal(webList{Wasm: j.rep.BinarySize, WasmGzip: j.rep.BinaryGzip, Manifest: webFile{Path: bundle.ManifestFile, Size: j.manifestSize}, Files: j.files})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -204,27 +209,20 @@ func (j *job) packWeb() error {
|
||||
if err := writeFile(filepath.Join(j.out, webIndex), page.Bytes(), webIndex); err != nil {
|
||||
return err
|
||||
}
|
||||
return j.compressWeb()
|
||||
return j.compressWeb(brotli)
|
||||
}
|
||||
|
||||
func (j *job) compressWeb() error {
|
||||
brotli, _ := exec.LookPath("brotli")
|
||||
func (j *job) compressWeb(brotli string) error {
|
||||
art := filepath.Join(j.l.data, bundle.ArtifactsDir)
|
||||
wasm := j.l.binary()
|
||||
return filepath.WalkDir(j.out, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
if !webCompressed[filepath.Ext(p)] && filepath.Dir(p) != art {
|
||||
if p == wasm || (!webCompressed[filepath.Ext(p)] && filepath.Dir(p) != art) {
|
||||
return nil
|
||||
}
|
||||
n, err := precompress(p, brotli)
|
||||
if err != nil {
|
||||
_, err = precompress(p, brotli)
|
||||
return err
|
||||
}
|
||||
if p == wasm {
|
||||
j.rep.BinaryGzip = n
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
const PARALLEL = 6;
|
||||
const started = performance.now();
|
||||
let total = 0;
|
||||
let wire = 0;
|
||||
let got = 0;
|
||||
|
||||
function mb(n) {
|
||||
@@ -75,7 +76,7 @@
|
||||
got += n;
|
||||
if (!total) return;
|
||||
bar.style.width = Math.min(100, (got * 100) / total) + "%";
|
||||
status.textContent = `${mb(got)} of ${mb(total)}`;
|
||||
status.textContent = `${mb((got * wire) / total)} of ${mb(wire)}`;
|
||||
}
|
||||
|
||||
// A request that delivers nothing for STALL ms is aborted and made again past the HTTP cache; a stuck cache entry must not leave the page on "Loading…".
|
||||
@@ -151,7 +152,10 @@
|
||||
async function start() {
|
||||
const list = await readJSON(`data/files.json?v=${build}`);
|
||||
got = 0;
|
||||
total = list.wasm + list.manifest.size + list.files.reduce((s, f) => s + f.size, 0);
|
||||
const data = list.manifest.size + list.files.reduce((s, f) => s + f.size, 0);
|
||||
total = list.wasm + data;
|
||||
// the server sends the compressed runtime, so the status counts its compressed size
|
||||
wire = (list.wasmGzip || list.wasm) + data;
|
||||
const wasm = read(`game.wasm?v=${build}`);
|
||||
const manifest = read(`data/${list.manifest.path}?v=${build}`);
|
||||
const files = {};
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"sort"
|
||||
|
||||
"aego/asset/importers/atlas"
|
||||
matImporter "aego/asset/importers/material"
|
||||
"aego/asset/importers/scenefile"
|
||||
scriptImporter "aego/asset/importers/script"
|
||||
shaderImporter "aego/asset/importers/shader"
|
||||
texImporter "aego/asset/importers/texture"
|
||||
"aego/asset/pipeline"
|
||||
"aego/core/errs"
|
||||
@@ -22,6 +24,9 @@ func CoreImporters() (*pipeline.ImporterRegistry, error) {
|
||||
scenefile.NewScene(),
|
||||
scenefile.NewPrefab(),
|
||||
preset.New(),
|
||||
shaderImporter.New(),
|
||||
shaderImporter.NewInclude(),
|
||||
matImporter.New(),
|
||||
} {
|
||||
if err := reg.Register(imp); err != nil {
|
||||
return nil, err
|
||||
|
||||
+29
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
shaderImporter "aego/asset/importers/shader"
|
||||
"aego/asset/pipeline"
|
||||
"aego/engine/problems"
|
||||
)
|
||||
@@ -39,3 +40,31 @@ func MetaProblems(pl *pipeline.Pipeline) problems.Source {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ShaderProblems lists the SHD problems of the last shader imports with the file and line they point at.
|
||||
func ShaderProblems(imps *pipeline.ImporterRegistry) problems.Source {
|
||||
imp, _ := imps.ByID("shader")
|
||||
si, _ := imp.(*shaderImporter.Importer)
|
||||
return problems.Source{
|
||||
Revision: func() uint64 {
|
||||
if si == nil {
|
||||
return 0
|
||||
}
|
||||
_, rev := si.Diagnostics()
|
||||
return rev
|
||||
},
|
||||
Append: func(dst []problems.Diagnostic) []problems.Diagnostic {
|
||||
if si == nil {
|
||||
return dst
|
||||
}
|
||||
list, _ := si.Diagnostics()
|
||||
for _, d := range list {
|
||||
dst = append(dst, problems.Diagnostic{
|
||||
Owner: OwnerAssets, Code: d.Code, Severity: problems.Error, Message: d.Msg,
|
||||
Subject: problems.Subject{Kind: problems.SubjectFile, File: d.File, Line: d.Line},
|
||||
})
|
||||
}
|
||||
return dst
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma aego abi 1
|
||||
|
||||
#pragma material
|
||||
uniform vec4 Tint; // @color @default(1, 0.5, 0.25, 1)
|
||||
uniform float Speed; // @default(1)
|
||||
#pragma end
|
||||
|
||||
void fragment() {
|
||||
vec4 c = texture(aego_main, aego_uv) * aego_color;
|
||||
float w = 0.5 + 0.5 * sin(aego_time * Speed + aego_local.x * 6.2831);
|
||||
aego_out = vec4(c.rgb * Tint.rgb * w, c.a);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package playground
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
|
||||
"aego/asset"
|
||||
"aego/core/ids"
|
||||
"aego/render"
|
||||
"aego/render/material"
|
||||
"aego/render/shader"
|
||||
"aego/render/shaderx"
|
||||
)
|
||||
|
||||
//go:embed bench.aeshader
|
||||
var benchShader []byte
|
||||
|
||||
// BenchClock is the time the material bench and its GL golden are drawn at.
|
||||
var BenchClock = render.Clock{Time: 0.5, Delta: 1.0 / 60, Unscaled: 1.0 / 60, Frame: 30}
|
||||
|
||||
// BenchMaterial is the test material on bench.aeshader with Speed 2; it reads aego_time and a parameter.
|
||||
func BenchMaterial() (*material.Material, error) {
|
||||
u, diags := shaderx.Parse(benchShader, "bench.aeshader", nil)
|
||||
if len(diags) > 0 {
|
||||
return nil, diags[0]
|
||||
}
|
||||
sh := asset.BuildShader(u).Runtime(ids.AssetID{}, "bench")
|
||||
m, ok := material.New("bench", sh, 0, shader.BlendPremultiplied)
|
||||
if !ok {
|
||||
return nil, errors.New("the bench shader has no variant")
|
||||
}
|
||||
m.Set("Speed", [4]float32{2})
|
||||
return m, nil
|
||||
}
|
||||
+15
-1
@@ -17,6 +17,7 @@ import (
|
||||
"aego/platform"
|
||||
"aego/render"
|
||||
renderhost "aego/render/host"
|
||||
"aego/render/material"
|
||||
"aego/render/sprite"
|
||||
"aego/render/view"
|
||||
"aego/scene"
|
||||
@@ -77,6 +78,7 @@ type bench struct {
|
||||
count int
|
||||
animate bool
|
||||
t float64
|
||||
mat *material.Material
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
@@ -147,6 +149,7 @@ func newGame(cfg GameConfig, types *scene.Types, reg *bind.Registry, man *module
|
||||
g.renderer = rmod.Renderer()
|
||||
g.rstats = rmod.Stats
|
||||
g.loader = loader.New(e.GPU(), g.renderer.Textures(), g.log)
|
||||
g.loader.SetMaterials(g.renderer.Materials())
|
||||
if err := g.loader.Mount(asset.NewIndex(), e.VFS()); err != nil {
|
||||
e.Close()
|
||||
return nil, err
|
||||
@@ -240,11 +243,20 @@ func (g *Game) Play(name string, src []byte, mods []*bytecode.Module) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Game) Bench(count int, animate bool) {
|
||||
func (g *Game) Bench(count int, animate, withMaterial bool) error {
|
||||
g.Stop()
|
||||
g.bench = bench{count: min(max(count, 1), maxSprites), animate: animate}
|
||||
if withMaterial {
|
||||
m, err := BenchMaterial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.bench.mat = m
|
||||
g.renderer.PinClock(&BenchClock)
|
||||
}
|
||||
g.example, g.running = benchName, true
|
||||
g.start()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Game) Stop() {
|
||||
@@ -264,6 +276,7 @@ func (g *Game) Stop() {
|
||||
w.RemoveService(scene.ServiceID)
|
||||
}
|
||||
g.bench = bench{}
|
||||
g.renderer.PinClock(nil)
|
||||
g.running = false
|
||||
g.example = ""
|
||||
}
|
||||
@@ -338,6 +351,7 @@ func (g *Game) render() {
|
||||
Origin: mathx.Vec2{X: 0.5, Y: 0.5},
|
||||
Rotation: rot,
|
||||
Tint: hue(i, b.count),
|
||||
Material: b.mat,
|
||||
})
|
||||
}
|
||||
f.End()
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestEditedScriptErrorsStopPlay(t *testing.T) {
|
||||
|
||||
func TestBench(t *testing.T) {
|
||||
p := newHeadless(t)
|
||||
if res := p.Bench(1000, true); !res.Started {
|
||||
if res := p.Bench(1000, true, false); !res.Started {
|
||||
t.Fatal(res.Error)
|
||||
}
|
||||
if err := p.Game().Step(30); err != nil {
|
||||
|
||||
@@ -87,12 +87,14 @@ func (p *Playground) Play(example string, files map[string]string) PlayResult {
|
||||
return res
|
||||
}
|
||||
|
||||
func (p *Playground) Bench(count int, animate bool) PlayResult {
|
||||
func (p *Playground) Bench(count int, animate, material bool) PlayResult {
|
||||
g, err := p.host()
|
||||
if err != nil {
|
||||
return PlayResult{Error: err.Error()}
|
||||
}
|
||||
g.Bench(count, animate)
|
||||
if err := g.Bench(count, animate, material); err != nil {
|
||||
return PlayResult{Error: err.Error()}
|
||||
}
|
||||
return PlayResult{Started: true}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,6 +443,8 @@ type request struct {
|
||||
Example string `json:"example"`
|
||||
Count int `json:"count"`
|
||||
Animate bool `json:"animate"`
|
||||
// Material draws the bench with the test material at a pinned time, so web-smoke compares it with the GL golden.
|
||||
Material bool `json:"material"`
|
||||
Lose bool `json:"lose"`
|
||||
}
|
||||
|
||||
@@ -474,7 +476,7 @@ func (p *Playground) Call(method string, payload []byte) ([]byte, error) {
|
||||
case "poll":
|
||||
out = p.Poll()
|
||||
case "bench":
|
||||
out = p.Bench(req.Count, req.Animate)
|
||||
out = p.Bench(req.Count, req.Animate, req.Material)
|
||||
case "loseContext":
|
||||
out = p.LoseContext(req.Lose)
|
||||
default:
|
||||
|
||||
+27
-3
@@ -20,6 +20,10 @@ const (
|
||||
var (
|
||||
BuiltinRunClip = ids.AssetID{Hi: builtinHi, Lo: 0xb10}
|
||||
BuiltinBobClip = ids.AssetID{Hi: builtinHi, Lo: 0xb11}
|
||||
// BuiltinJumpClip holds the runner's stride pose while it is in the air.
|
||||
BuiltinJumpClip = ids.AssetID{Hi: builtinHi, Lo: 0xb12}
|
||||
// BuiltinHitClip swings the node once, 0.3 s, for the platformer controller's Hit state.
|
||||
BuiltinHitClip = ids.AssetID{Hi: builtinHi, Lo: 0xb13}
|
||||
)
|
||||
|
||||
var BuiltinPlatformerCtrl = ids.AssetID{Hi: builtinHi, Lo: 0xb20}
|
||||
@@ -106,12 +110,30 @@ const bobClipText = `anim version=1 length=1 loop=loop {
|
||||
}
|
||||
`
|
||||
|
||||
func jumpClipText() string {
|
||||
return "anim version=1 length=0.1 loop=once {\n frames \"\" fps=10 {\n sprites=[" + assetLit(BuiltinRunner(2)) + "]\n }\n}\n"
|
||||
}
|
||||
|
||||
const hitClipText = `anim version=1 length=0.3 loop=once {
|
||||
track "" prop="transform.rotation" kind=float32 authority=simulation {
|
||||
key t=0 v=0 {}
|
||||
key t=0.08 v=-0.5 {}
|
||||
key t=0.18 v=0.25 {}
|
||||
key t=0.3 v=0 {}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func BuiltinAnimText(id ids.AssetID) (string, bool) {
|
||||
switch id {
|
||||
case BuiltinRunClip:
|
||||
return runClipText(), true
|
||||
case BuiltinBobClip:
|
||||
return bobClipText, true
|
||||
case BuiltinJumpClip:
|
||||
return jumpClipText(), true
|
||||
case BuiltinHitClip:
|
||||
return hitClipText, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -124,8 +146,8 @@ func platformerCtrlText() string {
|
||||
param "Attack" kind=trigger {}
|
||||
state "Idle" {}
|
||||
state "Run" clip=` + assetLit(BuiltinRunClip) + ` {}
|
||||
state "Jump" {}
|
||||
state "Hit" {}
|
||||
state "Jump" clip=` + assetLit(BuiltinJumpClip) + ` {}
|
||||
state "Hit" clip=` + assetLit(BuiltinHitClip) + ` {}
|
||||
transition "Idle" to="Run" duration=0.1 {
|
||||
cond "Speed" op=gt value=0.1 {}
|
||||
}
|
||||
@@ -154,12 +176,14 @@ func BuiltinCtrlText(id ids.AssetID) (string, bool) {
|
||||
}
|
||||
|
||||
func builtinAnimEntries() []Builtin {
|
||||
out := make([]Builtin, 0, runnerCount+3)
|
||||
out := make([]Builtin, 0, runnerCount+5)
|
||||
for i := range runnerCount {
|
||||
out = append(out, Builtin{ID: BuiltinRunner(i), Name: "engine/sprites/runner_" + strconv.Itoa(i), Kind: ArtTexture})
|
||||
}
|
||||
return append(out,
|
||||
Builtin{ID: BuiltinRunClip, Name: "engine/anim/run", Kind: ArtAnim},
|
||||
Builtin{ID: BuiltinBobClip, Name: "engine/anim/bob", Kind: ArtAnim},
|
||||
Builtin{ID: BuiltinJumpClip, Name: "engine/anim/jump", Kind: ArtAnim},
|
||||
Builtin{ID: BuiltinHitClip, Name: "engine/anim/hit", Kind: ArtAnim},
|
||||
Builtin{ID: BuiltinPlatformerCtrl, Name: "engine/anim/platformer", Kind: ArtCtrl})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Package material imports .aemat files: the artifact is the normalized text, checked for syntax only; the resolver applies it to the shader.
|
||||
package material
|
||||
|
||||
import (
|
||||
"aego/asset"
|
||||
"aego/asset/pipeline"
|
||||
"aego/core/errs"
|
||||
"aego/format/aetext"
|
||||
)
|
||||
|
||||
const Version = 1
|
||||
|
||||
type Importer struct{}
|
||||
|
||||
func New() *Importer { return &Importer{} }
|
||||
|
||||
func (*Importer) ID() string { return "material" }
|
||||
func (*Importer) Version() int { return Version }
|
||||
func (*Importer) Extensions() []string { return []string{".aemat"} }
|
||||
func (*Importer) Defaults() pipeline.Settings { return pipeline.Settings{} }
|
||||
|
||||
func (*Importer) Import(ctx *pipeline.ImportContext) (*pipeline.Result, error) {
|
||||
d, err := asset.ParseMaterial(ctx.Source, ctx.Path)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.AssetImportFailed, "cannot read the material")
|
||||
}
|
||||
n, _ := aetext.Parse(ctx.Source, ctx.Path)
|
||||
return &pipeline.Result{
|
||||
Kind: asset.ArtMaterial,
|
||||
Deps: d.Deps(),
|
||||
Artifacts: []pipeline.Produced{{Kind: asset.ArtMaterial, Data: aetext.Write(n)}},
|
||||
}, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+20
-1
@@ -10,6 +10,7 @@ import (
|
||||
"aego/core/log"
|
||||
"aego/format/aetext"
|
||||
"aego/gpu"
|
||||
"aego/render/material"
|
||||
"aego/render/mesh"
|
||||
"aego/render/texture"
|
||||
"aego/vfs"
|
||||
@@ -51,6 +52,7 @@ type Loader struct {
|
||||
meshes map[ids.AssetID]*mesh.Mesh
|
||||
failed map[ids.AssetID]bool
|
||||
decoded map[decodedKey]decodedValue
|
||||
mat materials
|
||||
subs []subscriber
|
||||
nextID uint64
|
||||
gen uint32
|
||||
@@ -75,6 +77,11 @@ func New(dev gpu.Device, up *texture.Uploader, lg *log.Logger) *Loader {
|
||||
meshes: map[ids.AssetID]*mesh.Mesh{},
|
||||
failed: map[ids.AssetID]bool{},
|
||||
decoded: map[decodedKey]decodedValue{},
|
||||
mat: materials{
|
||||
shaders: map[ids.AssetID]*material.Shader{},
|
||||
mats: map[ids.AssetID]*material.Material{},
|
||||
uses: map[ids.AssetID]ids.AssetID{},
|
||||
},
|
||||
gen: 1,
|
||||
}
|
||||
}
|
||||
@@ -559,7 +566,8 @@ func (l *Loader) prepare(e *asset.Entry) (staged, bool, error) {
|
||||
return staged{id: e.ID, kind: e.Kind, geo: m}, true, nil
|
||||
case asset.ArtScript:
|
||||
return staged{id: e.ID, kind: e.Kind}, true, nil
|
||||
case asset.ArtSpriteRegion, asset.ArtScene, asset.ArtPrefab, asset.ArtData, asset.ArtTable, asset.ArtAudio, asset.ArtAnim, asset.ArtCtrl:
|
||||
case asset.ArtSpriteRegion, asset.ArtScene, asset.ArtPrefab, asset.ArtData, asset.ArtTable, asset.ArtAudio, asset.ArtAnim, asset.ArtCtrl,
|
||||
asset.ArtShader, asset.ArtShaderInclude, asset.ArtMaterial:
|
||||
art, ok := e.Artifact(e.Kind)
|
||||
if !ok {
|
||||
return staged{}, false, errs.New(errs.AssetBadArtifact, "entry has no artifact of its own kind", log.F("asset", e.ID.String()))
|
||||
@@ -618,6 +626,12 @@ func (l *Loader) Swap(entries []asset.Entry, files vfs.FS) error {
|
||||
continue
|
||||
}
|
||||
l.blobs[s.id] = s.blob
|
||||
switch s.kind {
|
||||
case asset.ArtShader:
|
||||
l.swapShader(s.id, s.blob)
|
||||
case asset.ArtMaterial:
|
||||
l.dropMaterial(s.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
clear(l.decoded)
|
||||
@@ -663,6 +677,8 @@ func (l *Loader) Drop(id ids.AssetID) {
|
||||
delete(l.region, id)
|
||||
delete(l.blobs, id)
|
||||
delete(l.failed, id)
|
||||
l.dropMaterial(id)
|
||||
l.dropShader(id)
|
||||
clear(l.decoded)
|
||||
l.gen++
|
||||
}
|
||||
@@ -679,6 +695,9 @@ func (l *Loader) Close() {
|
||||
l.region = map[ids.AssetID]regionEntry{}
|
||||
l.blobs = map[ids.AssetID][]byte{}
|
||||
l.failed = map[ids.AssetID]bool{}
|
||||
clear(l.mat.shaders)
|
||||
clear(l.mat.mats)
|
||||
clear(l.mat.uses)
|
||||
l.decoded = map[decodedKey]decodedValue{}
|
||||
l.subs = nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ type ImportContext struct {
|
||||
Meta *Meta
|
||||
Log *log.ChannelLogger
|
||||
Lookup func(path string) (ids.AssetID, bool)
|
||||
Read func(path string) ([]byte, error)
|
||||
}
|
||||
|
||||
type Importer interface {
|
||||
|
||||
@@ -3,6 +3,7 @@ package pipeline
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"aego/core/errs"
|
||||
"aego/core/ids"
|
||||
"aego/core/log"
|
||||
"aego/engine/res"
|
||||
"aego/project"
|
||||
"aego/vfs"
|
||||
)
|
||||
@@ -194,6 +196,14 @@ func (p *Pipeline) Lookup(path string) (ids.AssetID, bool) {
|
||||
return p.lookup(path)
|
||||
}
|
||||
|
||||
// read gives an importer another source file: engine:// from the engine's embedded files, the rest from the project.
|
||||
func (p *Pipeline) read(path string) ([]byte, error) {
|
||||
if rest, ok := strings.CutPrefix(path, "engine://"); ok {
|
||||
return fs.ReadFile(res.FS(), rest)
|
||||
}
|
||||
return os.ReadFile(p.Abs(path))
|
||||
}
|
||||
|
||||
func (p *Pipeline) lookup(path string) (ids.AssetID, bool) {
|
||||
path = filepath.ToSlash(path)
|
||||
if sheet, name, ok := strings.Cut(path, SubSep); ok {
|
||||
@@ -577,6 +587,7 @@ func (p *Pipeline) importOne(r *record) (*Result, string, error) {
|
||||
Meta: r.meta,
|
||||
Log: p.log,
|
||||
Lookup: p.lookup,
|
||||
Read: p.read,
|
||||
}
|
||||
res, err := r.importer.Import(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,6 +22,9 @@ const (
|
||||
ArtAudio ArtifactKind = "audio"
|
||||
ArtAnim ArtifactKind = "anim"
|
||||
ArtCtrl ArtifactKind = "animctrl"
|
||||
ArtShader ArtifactKind = "shader"
|
||||
ArtShaderInclude ArtifactKind = "shaderinclude"
|
||||
ArtMaterial ArtifactKind = "material"
|
||||
)
|
||||
|
||||
type Reloaded struct {
|
||||
|
||||
+287
File diff suppressed because it is too large
Load Diff
+38
-8
@@ -5,7 +5,6 @@ import (
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aego/audio/dsp"
|
||||
"aego/audio/mix"
|
||||
@@ -143,6 +142,21 @@ func Stream(d Decoder, st *mix.Stream, o StreamOptions, cancel <-chan struct{})
|
||||
return done
|
||||
}
|
||||
|
||||
// skipTo wraps a skipped position into the loop; it reports false past the end of a stream that does not loop.
|
||||
func skipTo(d Decoder, o StreamOptions, to int64) (int64, bool) {
|
||||
end := d.Info().Frames
|
||||
if o.Loop && o.LoopEnd > 0 {
|
||||
end = o.LoopEnd
|
||||
}
|
||||
if end <= 0 || to < end {
|
||||
return to, true
|
||||
}
|
||||
if !o.Loop || end <= o.LoopStart {
|
||||
return 0, false
|
||||
}
|
||||
return o.LoopStart + (to-o.LoopStart)%(end-o.LoopStart), true
|
||||
}
|
||||
|
||||
func feed(d Decoder, st *mix.Stream, o StreamOptions, cancel <-chan struct{}) error {
|
||||
defer st.SetEOF()
|
||||
ch := st.Channels
|
||||
@@ -164,14 +178,34 @@ func feed(d Decoder, st *mix.Stream, o StreamOptions, cancel <-chan struct{}) er
|
||||
var pending []float32
|
||||
pos := int64(0)
|
||||
empty := 0
|
||||
wait := time.NewTicker(5 * time.Millisecond)
|
||||
defer wait.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-cancel:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if sk := st.TakeSkip(); sk > 0 {
|
||||
k := min(sk, int64(len(pending)/ch))
|
||||
pending = pending[int(k)*ch:]
|
||||
if sk -= k; sk > 0 {
|
||||
to, ok := skipTo(d, o, pos+sk)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := d.SeekFrame(to); err != nil {
|
||||
return err
|
||||
}
|
||||
pos = to
|
||||
}
|
||||
}
|
||||
if st.Held() {
|
||||
select {
|
||||
case <-cancel:
|
||||
return nil
|
||||
case <-st.Wake():
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
want := chunk
|
||||
if o.Loop && o.LoopEnd > 0 {
|
||||
@@ -204,12 +238,8 @@ func feed(d Decoder, st *mix.Stream, o StreamOptions, cancel <-chan struct{}) er
|
||||
}
|
||||
w := st.Ring.Write(pending)
|
||||
pending = pending[w*ch:]
|
||||
if len(pending) > 0 {
|
||||
select {
|
||||
case <-cancel:
|
||||
if len(pending) > 0 && !st.WaitSpace(cancel) {
|
||||
return nil
|
||||
case <-wait.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package codec
|
||||
|
||||
import "testing"
|
||||
|
||||
type sized struct {
|
||||
Decoder
|
||||
frames int64
|
||||
}
|
||||
|
||||
func (s sized) Info() Info { return Info{Frames: s.frames} }
|
||||
|
||||
func TestSkipTo(t *testing.T) {
|
||||
d := sized{frames: 1000}
|
||||
for _, c := range []struct {
|
||||
o StreamOptions
|
||||
to int64
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{StreamOptions{}, 400, 400, true},
|
||||
{StreamOptions{}, 1000, 0, false},
|
||||
{StreamOptions{Loop: true}, 2300, 300, true},
|
||||
{StreamOptions{Loop: true, LoopStart: 200, LoopEnd: 800}, 900, 300, true},
|
||||
{StreamOptions{Loop: true, LoopStart: 200, LoopEnd: 800}, 799, 799, true},
|
||||
} {
|
||||
got, ok := skipTo(d, c.o, c.to)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("skipTo(%+v, %d) = %d, %v; want %d, %v", c.o, c.to, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,7 @@ func New(cfg Config) (*Mixer, error) {
|
||||
}
|
||||
m.spare = make([]uint32, 0, n)
|
||||
m.cmds.q = newSPSC[Command](queueSize)
|
||||
m.cmds.wake = make(chan struct{}, 1)
|
||||
m.events.q = newSPSC[Event](queueSize)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -525,3 +525,67 @@ func BenchmarkMix64Pitch1(b *testing.B) {
|
||||
m.Mix(buf)
|
||||
}
|
||||
}
|
||||
|
||||
// Skipping a stretch leaves voices where mixing it would: positions, loops, ramps, stops and ends with their events.
|
||||
func TestSkipMatchesMix(t *testing.T) {
|
||||
setup := func() (*Mixer, VoiceID) {
|
||||
m := newMixer(t)
|
||||
m.Play(PlayParams{Memory: dc(0.5, rate/4), Gain: 1})
|
||||
m.Play(PlayParams{Memory: sine(440, rate/3, 2, 0.3), Gain: 0.8, Pitch: 1.3, Loop: true, FadeIn: 0.1})
|
||||
id := m.Play(PlayParams{Memory: dc(0.2, rate), Gain: 1})
|
||||
render(m, 256)
|
||||
events(m)
|
||||
m.Stop(id, 0.05)
|
||||
return m, id
|
||||
}
|
||||
a, _ := setup()
|
||||
b, _ := setup()
|
||||
for i := 0; i < 20; i++ {
|
||||
render(a, 1200)
|
||||
b.Skip(1200)
|
||||
if a.Active() != b.Active() {
|
||||
t.Fatalf("step %d: %d active after mixing, %d after skipping", i, a.Active(), b.Active())
|
||||
}
|
||||
for vi := range a.voices {
|
||||
va, vb := &a.voices[vi], &b.voices[vi]
|
||||
if va.state != vb.state || math.Abs(va.pos-vb.pos) > 1e-3 || math.Abs(float64(va.gain-vb.gain)) > 1e-4 {
|
||||
t.Fatalf("step %d voice %d: mixed %v at %.4f gain %.5f, skipped %v at %.4f gain %.5f", i, vi, va.state, va.pos, va.gain, vb.state, vb.pos, vb.gain)
|
||||
}
|
||||
}
|
||||
ea, eb := events(a), events(b)
|
||||
if len(ea) != len(eb) {
|
||||
t.Fatalf("step %d: events %+v after mixing, %+v after skipping", i, ea, eb)
|
||||
}
|
||||
}
|
||||
if a.Active() != 1 {
|
||||
t.Fatalf("%d voices left, want the loop", a.Active())
|
||||
}
|
||||
}
|
||||
|
||||
// A skipped stream drops what is decoded and leaves the rest to the feeder, holds it until it plays again and ends at its end.
|
||||
func TestSkipStream(t *testing.T) {
|
||||
m := newMixer(t)
|
||||
s := NewStream(rate, 1, 8192)
|
||||
s.Ring.Write(make([]float32, 6000))
|
||||
id := m.Play(PlayParams{Stream: s, Gain: 1})
|
||||
render(m, 1000)
|
||||
m.Skip(3000)
|
||||
if !s.Held() || s.TakeSkip() != 0 {
|
||||
t.Fatal("a skip within the decoded frames went to the feeder or did not hold the stream")
|
||||
}
|
||||
m.Skip(4000)
|
||||
if got := s.TakeSkip(); got != 1999 {
|
||||
t.Fatalf("the feeder skips %d frames, want 1999", got)
|
||||
}
|
||||
render(m, 64)
|
||||
if s.Held() {
|
||||
t.Fatal("mixing did not release the stream")
|
||||
}
|
||||
m.Skip(100)
|
||||
s.SetEOF()
|
||||
m.Skip(100)
|
||||
ev := events(m)
|
||||
if len(ev) == 0 || ev[len(ev)-1].Kind != EvFinished || ev[len(ev)-1].Voice != id {
|
||||
t.Fatalf("events %+v, want the stream finished", ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,10 +95,15 @@ func (q *spsc[T]) len() int {
|
||||
type CommandQueue struct {
|
||||
q *spsc[Command]
|
||||
dropped atomic.Uint64
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func (c *CommandQueue) Push(cmd Command) bool {
|
||||
if c.q.push(cmd) {
|
||||
select {
|
||||
case c.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
}
|
||||
c.dropped.Add(1)
|
||||
@@ -109,6 +114,11 @@ func (c *CommandQueue) Dropped() uint64 {
|
||||
return c.dropped.Load()
|
||||
}
|
||||
|
||||
// Wake receives after a push, for a consumer that sleeps while the queue is empty.
|
||||
func (c *CommandQueue) Wake() <-chan struct{} {
|
||||
return c.wake
|
||||
}
|
||||
|
||||
func (c *CommandQueue) Len() int {
|
||||
return c.q.len()
|
||||
}
|
||||
@@ -181,6 +191,18 @@ func (r *Ring) Write(p []float32) int {
|
||||
return n / r.ch
|
||||
}
|
||||
|
||||
// Discard drops up to frames frames from the read side and returns how many it dropped.
|
||||
func (r *Ring) Discard(frames int64) int64 {
|
||||
h := r.head.Load()
|
||||
n := min(frames*int64(r.ch), int64(r.tail.Load()-h))
|
||||
n -= n % int64(r.ch)
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
r.head.Store(h + uint64(n))
|
||||
return n / int64(r.ch)
|
||||
}
|
||||
|
||||
func (r *Ring) Read(p []float32) int {
|
||||
h := r.head.Load()
|
||||
n := min(len(p), int(r.tail.Load()-h))
|
||||
|
||||
@@ -427,6 +427,7 @@ func (m *Mixer) renderMemoryDirect(v *voice, acc []float32, n int) bool {
|
||||
|
||||
func (m *Mixer) renderStream(v *voice, acc []float32, n int) bool {
|
||||
s := v.st
|
||||
s.release()
|
||||
eof := s.eof.Load()
|
||||
s.fill(v.pos, v.step, n)
|
||||
drained := eof && s.Ring.Len() == 0
|
||||
@@ -460,6 +461,68 @@ func (m *Mixer) renderStream(v *voice, acc []float32, n int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip advances every voice by frames output frames without rendering, as if the output were mixed and thrown away: commands apply, ramps and positions move, loops wrap and finished voices end with their events.
|
||||
func (m *Mixer) Skip(frames int) {
|
||||
m.drain()
|
||||
frames = max(frames, 0)
|
||||
active := 0
|
||||
for vi := range m.voices {
|
||||
v := &m.voices[vi]
|
||||
if v.state == voiceFree {
|
||||
continue
|
||||
}
|
||||
active++
|
||||
if v.state != voicePlaying || m.paused || frames == 0 {
|
||||
continue
|
||||
}
|
||||
if m.skipVoice(v, frames) {
|
||||
m.end(vi, EvFinished)
|
||||
active--
|
||||
continue
|
||||
}
|
||||
m.publish(v)
|
||||
}
|
||||
m.active.Store(int32(active))
|
||||
for i := range m.buses {
|
||||
m.idle(&m.buses[i], frames)
|
||||
}
|
||||
m.clock += uint64(frames)
|
||||
}
|
||||
|
||||
// skipVoice ends a voice when the mixer would within n frames, which checks the end before each frame, so a voice reaching it on the last frame ends on the next call.
|
||||
func (m *Mixer) skipVoice(v *voice, n int) bool {
|
||||
if v.stopping && v.ramp < n {
|
||||
return true
|
||||
}
|
||||
if v.ramp > 0 {
|
||||
k := min(v.ramp, n)
|
||||
v.gain += v.delta * float32(k)
|
||||
if v.ramp -= k; v.ramp == 0 {
|
||||
v.gain = v.target
|
||||
}
|
||||
}
|
||||
to := v.pos + v.step*float64(n)
|
||||
if v.mem == nil {
|
||||
if v.st.skip(to) {
|
||||
return true
|
||||
}
|
||||
v.pos = to
|
||||
return false
|
||||
}
|
||||
if v.loop {
|
||||
if span := float64(v.le - v.ls); to >= float64(v.le) && span > 0 {
|
||||
to = float64(v.ls) + math.Mod(to-float64(v.ls), span)
|
||||
}
|
||||
v.pos = to
|
||||
return false
|
||||
}
|
||||
if int64(v.pos+v.step*float64(n-1)) >= v.frames {
|
||||
return true
|
||||
}
|
||||
v.pos = to
|
||||
return false
|
||||
}
|
||||
|
||||
func abs32(x float32) float32 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
|
||||
+93
-1
File diff suppressed because it is too large
Load Diff
+61
-2
@@ -29,8 +29,12 @@ type Stats struct {
|
||||
Voices int
|
||||
}
|
||||
|
||||
const quietPeriod = 250 * time.Millisecond
|
||||
|
||||
type Pump struct {
|
||||
m *mix.Mixer
|
||||
quiet time.Time
|
||||
frac float64
|
||||
out Output
|
||||
cfg Config
|
||||
buf []float32
|
||||
@@ -64,6 +68,12 @@ func New(m *mix.Mixer, out Output, cfg Config) *Pump {
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Pump) SetTarget(n int) {
|
||||
if n > 0 {
|
||||
p.cfg.Target = n
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pump) SetOutput(out Output, cfg Config) {
|
||||
p.out = out
|
||||
p.cfg = normalize(p.m, cfg)
|
||||
@@ -82,10 +92,38 @@ func (p *Pump) live() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Pump) silent() bool {
|
||||
if m, ok := p.out.(interface{ Muted() bool }); ok && m.Muted() {
|
||||
return true
|
||||
}
|
||||
return !p.live()
|
||||
}
|
||||
|
||||
// skip moves the mixer on by the time since the last call while nothing can be heard, so voices end and streams keep their place without mixing.
|
||||
func (p *Pump) skip() {
|
||||
now := time.Now()
|
||||
if p.quiet.IsZero() {
|
||||
p.quiet = now
|
||||
p.m.Skip(0)
|
||||
return
|
||||
}
|
||||
f := now.Sub(p.quiet).Seconds()*float64(p.cfg.SampleRate) + p.frac
|
||||
p.quiet = now
|
||||
n := int(f)
|
||||
p.frac = f - float64(n)
|
||||
p.m.Skip(n)
|
||||
}
|
||||
|
||||
func (p *Pump) Fill() int {
|
||||
if p.out == nil {
|
||||
return 0
|
||||
}
|
||||
if p.silent() {
|
||||
p.skip()
|
||||
p.primed = false
|
||||
return 0
|
||||
}
|
||||
p.quiet, p.frac = time.Time{}, 0
|
||||
live := p.live()
|
||||
q := p.out.Queued()
|
||||
if q == 0 && p.primed && live && p.m.Active() > 0 {
|
||||
@@ -141,11 +179,32 @@ func (p *Pump) Start() {
|
||||
|
||||
func (p *Pump) run(stop, done chan struct{}) {
|
||||
defer close(done)
|
||||
period := time.Duration(float64(p.cfg.Block*p.cfg.Target) / float64(p.cfg.SampleRate) / 6 * float64(time.Second))
|
||||
t := time.NewTicker(max(period, time.Millisecond))
|
||||
period := max(time.Duration(float64(p.cfg.Block*p.cfg.Target)/float64(p.cfg.SampleRate)/6*float64(time.Second)), time.Millisecond)
|
||||
t := time.NewTicker(period)
|
||||
defer t.Stop()
|
||||
wake := p.m.Commands().Wake()
|
||||
quiet := false
|
||||
for {
|
||||
p.Fill()
|
||||
if q := p.silent(); q != quiet {
|
||||
quiet = q
|
||||
if quiet {
|
||||
t.Reset(quietPeriod)
|
||||
} else {
|
||||
t.Reset(period)
|
||||
}
|
||||
}
|
||||
if p.m.Active() == 0 && p.m.Commands().Len() == 0 {
|
||||
t.Stop()
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-wake:
|
||||
}
|
||||
p.quiet = time.Time{}
|
||||
t.Reset(period)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package pump
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"aego/audio/mix"
|
||||
)
|
||||
|
||||
type sink struct {
|
||||
channels int
|
||||
muted bool
|
||||
writes int
|
||||
}
|
||||
|
||||
func (s *sink) Write(samples []float32) int {
|
||||
s.writes++
|
||||
return len(samples) / s.channels
|
||||
}
|
||||
func (*sink) Queued() int { return 0 }
|
||||
func (*sink) Close() {}
|
||||
func (s *sink) Muted() bool { return s.muted }
|
||||
|
||||
func TestPumpSleepsWithoutVoices(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
m, err := mix.New(mix.Config{SampleRate: 48000, Channels: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := New(m, &sink{channels: 2}, Config{})
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
synctest.Wait()
|
||||
idle := p.Stats().Blocks
|
||||
if idle == 0 {
|
||||
t.Fatal("the pump did not prime the output")
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
synctest.Wait()
|
||||
if got := p.Stats().Blocks; got != idle {
|
||||
t.Fatalf("an idle pump mixed %d blocks in a second", got-idle)
|
||||
}
|
||||
m.PauseAll()
|
||||
synctest.Wait()
|
||||
if got := p.Stats().Blocks; got == idle {
|
||||
t.Fatal("a command did not wake the pump")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// While the output is muted the pump writes nothing and moves voices on by the time that passed, so they still end.
|
||||
func TestPumpSkipsWhileMuted(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
m, err := mix.New(mix.Config{SampleRate: 48000, Channels: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := &sink{channels: 2, muted: true}
|
||||
p := New(m, out, Config{})
|
||||
pcm := make([]float32, 48000)
|
||||
id := m.Play(mix.PlayParams{Memory: &mix.Memory{PCM: pcm, Rate: 48000, Channels: 1}, Gain: 1})
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
synctest.Wait()
|
||||
if !m.Live(id) {
|
||||
t.Fatal("a one-second voice ended after half a second")
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
synctest.Wait()
|
||||
if m.Live(id) || m.Active() != 0 {
|
||||
t.Fatal("the voice did not end while muted")
|
||||
}
|
||||
if out.writes != 0 {
|
||||
t.Fatalf("the pump wrote %d blocks to a muted output", out.writes)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
</header>
|
||||
<main class="layout">
|
||||
<section class="editor" aria-label="Script">
|
||||
<div id="loader" class="loader" role="status" aria-live="polite" data-wasm-size="0" data-version="dev">
|
||||
<div id="loader" class="loader" role="status" aria-live="polite" data-wasm-size="0" data-wasm-gzip="0" data-version="dev">
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<div id="loader-title" class="loader-title">Loading engine…</div>
|
||||
<div class="progress"><div id="loader-bar" class="progress-bar indeterminate"></div></div>
|
||||
|
||||
@@ -376,6 +376,8 @@ async function fetchWasm(url) {
|
||||
starting();
|
||||
return resp;
|
||||
}
|
||||
// the status counts the compressed size the server sends
|
||||
const wire = (!plain && parseInt(loaderEl.dataset.wasmGzip, 10)) || total;
|
||||
if (total) loaderBar.classList.remove("indeterminate");
|
||||
let got = 0;
|
||||
const reader = resp.body.getReader();
|
||||
@@ -390,7 +392,7 @@ async function fetchWasm(url) {
|
||||
got += value.byteLength;
|
||||
if (total) {
|
||||
loaderBar.style.width = `${Math.min(100, (got / total) * 100)}%`;
|
||||
loaderDetail.textContent = `${mb(got)} of ${mb(total)}`;
|
||||
loaderDetail.textContent = `${mb((got * wire) / total)} of ${mb(wire)}`;
|
||||
} else {
|
||||
loaderDetail.textContent = mb(got);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ The kernel (`schema`, `scene`, `render`, `asset`, `engine`, `script`, `ui`, `pro
|
||||
| makes `modules/X` import `modules/X/editor` | `module-imports-own-editor` | the editor side imports the module, never the reverse |
|
||||
| lets a runtime package reach dev-only code | `runtime-reaches-dev` | put the dev part behind `!aego_runtime` or in a dev package |
|
||||
| lets the playground reach native or pipeline code | `wasm-reaches-forbidden` | keep it out of the playground profile |
|
||||
| lets a web build reach `net/http`, `crypto/tls`, `crypto/x509` or `archive/*` | `wasm-reaches-std` | move the code behind `//go:build !js` |
|
||||
| holds or receives `*context.Editor` in `editor/panels/*` | `tests/modulecheck/panels_test.go` | take the services the panel needs in its `Deps` (D216) |
|
||||
|
||||
A module can tune another module's behaviour without importing it by providing a value under a contract kind: `r.Provide(camera2d.AccessibilityKind, camera2d.Accessibility{ScreenShake: "...", ReducedMotion: "..."})` hands the camera module the IDs of settings it reads every frame. The consumer reads `ctx.Provided(kind)` in `Init`.
|
||||
|
||||
@@ -17,9 +17,9 @@ What the browser build (`GOOS=js GOARCH=wasm`, `platform/web` + `gpu/webgl2`) su
|
||||
| Gamepads | works | standard mapping; Chrome reports a pad only after a button press |
|
||||
| Focus loss | works | held keys and buttons are released |
|
||||
| Hidden tab | works | the real clock stops, `requestAnimationFrame` stops |
|
||||
| Frame pacing | degraded | always VSync; no frame limiter |
|
||||
| Frame pacing | works | VSync; the frame limit skips animation frames |
|
||||
| Fullscreen | degraded | entered on the next click or key press |
|
||||
| Display modes, monitors | missing | the display module has nothing to apply |
|
||||
| Display modes, monitors | missing | the display module has no modes to apply; the pixel ratio is capped at 1.5 by default (`display.maxPixelRatio`) |
|
||||
| Clipboard write | works | `navigator.clipboard.writeText` (secure contexts) |
|
||||
| Clipboard read | degraded | only what was pasted into the page or copied by the game |
|
||||
| Cursors | works | CSS cursor names |
|
||||
@@ -59,3 +59,27 @@ What the browser build (`GOOS=js GOARCH=wasm`, `platform/web` + `gpu/webgl2`) su
|
||||
## Exported games
|
||||
|
||||
`aego export --target web` (D295) runs the same runtime as the desktop export on this platform and GPU backend. Differences from desktop: data is loaded into memory before the game starts, there are no log files or crash bundles, saves and settings share the site's `localStorage` quota.
|
||||
|
||||
## Performance
|
||||
|
||||
`make web-bench` (D365) runs the exported platformer with its input recording in Chromium and Firefox: 1280×720, device pixel ratio 1, render scale 1, seed 1, 120 warmup frames, 600 measured, 5 runs. While it measures, every frame advances the game by exactly one fixed step, so each run plays the same frames. It writes `.cache/web-bench.json` and a table in `.cache/web-bench.md`.
|
||||
|
||||
| Level | Metrics |
|
||||
|---|---|
|
||||
| Go | allocations and bytes per frame, bytes allocated inside the engine frame, allocations per engine zone (phases, `gpu`, `poll`), GC cycles, heap |
|
||||
| Bridge | WebGL calls, timer query calls counted apart, draw calls, buffer and texture uploads and their size, program and render-target switches, time spent in WebGL calls |
|
||||
| Browser | frame time p50/p95/p99 from `requestAnimationFrame` gaps, main-thread work per frame, frames over 33 ms, long tasks, JS heap, GPU time |
|
||||
|
||||
Options: `WEB_BENCH_BROWSERS=chromium`, `WEB_BENCH_ARGS="--gpu=hard --headed"` for the real GPU, `--gogc=100,150,200` for GC experiments, `--trace` for a Chrome trace of the first run. `make web-bench-baseline` saves the machine's baseline, one per GPU mode. Later runs fail when a count (draw calls, WebGL calls, uploads, switches per frame) differs from the baseline or between runs, when Go allocates more than 24 bytes per frame over the baseline inside the engine frame, or when frame or work time is more than 10% slower; time is compared only with at least 5 runs. `make web-smoke` runs Chromium once and checks the counts, not the time. On the reference machine with `--gpu=hard` frame p95 must stay at or under 20 ms with at most 1% of frames over 33 ms.
|
||||
|
||||
Reference machine, hardware GPU, 1280×720, device pixel ratio 1, 600 measured frames:
|
||||
|
||||
| | Chromium before 12w | Chromium after | Firefox before 12w | Firefox after |
|
||||
|---|---:|---:|---:|---:|
|
||||
| frame p95, ms | 16.7 | 16.8 | 17.2 | 17.2 |
|
||||
| frames over 33 ms | 0 | 0 | 0 | 0 |
|
||||
| Go allocations per frame (`runtime/metrics`) | 7.2 | 3.8 | 6.1 | 2.5 |
|
||||
| allocations in the engine frame, not exempt | — | 0 | — | 0 |
|
||||
| GC cycles in 3600 frames | 0 | 0 | — | — |
|
||||
| WebGL calls per frame | 50.5 | 50.5 | 50.5 | 50.5 |
|
||||
| draw calls per frame | 3.3 | 3.3 | 3.3 | 3.3 |
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import (
|
||||
"aego/platform"
|
||||
)
|
||||
|
||||
var Version = "0.11.0"
|
||||
var Version = "0.12.0-dev"
|
||||
|
||||
type Config struct {
|
||||
Title string
|
||||
|
||||
@@ -57,6 +57,9 @@ type Engine struct {
|
||||
probeSample []metrics.Sample
|
||||
allocs [phaseCount]uint64
|
||||
lastAllocs [phaseCount]uint64
|
||||
allocTotal [allocZones]uint64
|
||||
lockstep bool
|
||||
frameBytes uint64
|
||||
|
||||
tracer *trace.Tracer
|
||||
phaseZones [phaseCount]trace.ZoneID
|
||||
@@ -394,6 +397,9 @@ func (e *Engine) handleEvent(ev platform.Event) {
|
||||
|
||||
func (e *Engine) SetFrameLimit(fps float64) {
|
||||
e.frameLimit = max(fps, 0)
|
||||
if l, ok := e.plat.(platform.FrameLimiter); ok {
|
||||
l.SetFrameLimit(e.frameLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) FrameLimit() float64 {
|
||||
|
||||
@@ -4686,7 +4686,13 @@ messages version=1 lang=en {
|
||||
text="Frame Limit"
|
||||
}
|
||||
msg "setting.display.frameLimit.doc" {
|
||||
text="Caps the frame rate while VSync is off. Zero means no limit."
|
||||
text="Caps the frame rate while VSync is off, or always on platforms that pace frames themselves. Zero means no limit."
|
||||
}
|
||||
msg "setting.display.maxPixelRatio" {
|
||||
text="Pixel Ratio Limit"
|
||||
}
|
||||
msg "setting.display.maxPixelRatio.doc" {
|
||||
text="Highest screen pixel density the game renders at. Lower values draw fewer pixels on high-density screens."
|
||||
}
|
||||
msg "setting.display.preset" {
|
||||
text="Quality Preset"
|
||||
|
||||
@@ -4686,7 +4686,13 @@ messages version=1 lang=ru {
|
||||
text="Ограничение кадров"
|
||||
}
|
||||
msg "setting.display.frameLimit.doc" {
|
||||
text="Ограничивает частоту кадров, пока VSync выключен. Ноль — без ограничения."
|
||||
text="Ограничивает частоту кадров, пока VSync выключен, а на платформах, которые сами задают темп кадров, — всегда. Ноль — без ограничения."
|
||||
}
|
||||
msg "setting.display.maxPixelRatio" {
|
||||
text="Предел плотности пикселей"
|
||||
}
|
||||
msg "setting.display.maxPixelRatio.doc" {
|
||||
text="Наибольшая плотность пикселей экрана, с которой рисует игра. Меньшие значения рисуют меньше пикселей на экранах высокой плотности."
|
||||
}
|
||||
msg "setting.display.preset" {
|
||||
text="Качество"
|
||||
|
||||
+77
-4
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
layout(location = 0) in vec3 a_pos;
|
||||
layout(location = 1) in vec4 a_color;
|
||||
|
||||
layout(std140) uniform ub0 {
|
||||
layout(std140) uniform ub1 {
|
||||
mat4 u_view_proj;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ precision highp sampler2D;
|
||||
layout(location = 0) in vec3 a_pos;
|
||||
layout(location = 1) in vec4 a_color;
|
||||
|
||||
layout(std140) uniform ub0 {
|
||||
layout(std140) uniform ub1 {
|
||||
mat4 u_view_proj;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ layout(location = 1) in vec3 a_normal;
|
||||
layout(location = 2) in vec2 a_uv;
|
||||
layout(location = 3) in vec4 a_color;
|
||||
|
||||
layout(std140) uniform ub0 {
|
||||
layout(std140) uniform ub1 {
|
||||
mat4 u_view_proj;
|
||||
};
|
||||
|
||||
layout(std140) uniform ub1 {
|
||||
layout(std140) uniform ub3 {
|
||||
mat4 u_world;
|
||||
vec4 u_color;
|
||||
vec4 u_uv_rect;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user