diff --git a/README.md b/README.md index 4ebf26240..4f58a8310 100644 --- a/README.md +++ b/README.md @@ -5,55 +5,38 @@ ExoJS -[![Latest](https://img.shields.io/github/v/release/Exoridus/ExoJS?style=for-the-badge&label=Latest&logo=github&color=44cc11)](https://github.com/Exoridus/ExoJS/releases/latest) -[![npm](https://img.shields.io/npm/v/%40codexo%2Fexojs?style=for-the-badge&logo=npm&label=npm&color=44cc11)](https://www.npmjs.com/package/@codexo/exojs) -[![CI](https://img.shields.io/github/actions/workflow/status/Exoridus/ExoJS/ci.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=fff&label=CI)](https://github.com/Exoridus/ExoJS/actions/workflows/ci.yml) -[![Coverage](https://img.shields.io/codecov/c/github/Exoridus/ExoJS?style=for-the-badge&logo=codecov&logoColor=fff&label=Coverage)](https://app.codecov.io/gh/Exoridus/ExoJS) -[![License](https://img.shields.io/github/license/Exoridus/ExoJS?style=for-the-badge&color=44cc11)](https://github.com/Exoridus/ExoJS/blob/main/LICENSE) +[![Latest release](https://img.shields.io/github/v/release/Exoridus/ExoJS?style=for-the-badge&label=Latest&logo=github&color=44cc11)](https://github.com/Exoridus/ExoJS/releases/latest) +[![npm version](https://img.shields.io/npm/v/%40codexo%2Fexojs?style=for-the-badge&logo=npm&label=npm&color=44cc11)](https://www.npmjs.com/package/@codexo/exojs) +[![CI on main](https://img.shields.io/github/actions/workflow/status/Exoridus/ExoJS/ci.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=fff&label=CI)](https://github.com/Exoridus/ExoJS/actions/workflows/ci.yml?query=branch%3Amain) +[![Coverage on main](https://img.shields.io/codecov/c/github/Exoridus/ExoJS/main?style=for-the-badge&logo=codecov&logoColor=fff&label=Coverage)](https://app.codecov.io/gh/Exoridus/ExoJS/tree/main) +[![MIT license](https://img.shields.io/github/license/Exoridus/ExoJS?style=for-the-badge&color=44cc11)](https://github.com/Exoridus/ExoJS/blob/main/LICENSE) -A TypeScript-first browser 2D engine for games and interactive apps. +**A TypeScript-first 2D runtime for browser games and interactive applications.** -**[Try the playground](https://exoridus.github.io/ExoJS/en/playground/)** · **[Read the guide](https://exoridus.github.io/ExoJS/en/guide/)** · **[Browse the API](https://exoridus.github.io/ExoJS/en/api/)** +**[Try the Playground](https://exoridus.github.io/ExoJS/en/playground/)** · **[Read the Guide](https://exoridus.github.io/ExoJS/en/guide/)** · **[Browse the API](https://exoridus.github.io/ExoJS/en/api/)** + +[Benchmarks](https://exoridus.github.io/ExoJS/en/benchmarks/) · [Release notes](https://github.com/Exoridus/ExoJS/releases) · [Download the Full ZIP](https://github.com/Exoridus/ExoJS/releases/latest/download/exojs-full.zip) The ExoJS companion, a small waving robot -ExoJS combines an explicit scene graph with WebGPU/WebGL2 rendering, physics, audio, UI, assets, serialization, and focused extension packages. It is built as one coherent runtime rather than a renderer surrounded by unrelated integrations. - -> **Pre-1.0:** the public API is still being refined, and minor releases may contain breaking changes. Pin exact versions in downstream projects. `1.0.0` will mark the first stable API contract. - -## Why ExoJS - -| | | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| **TypeScript is the design input** | Strict types, discoverable APIs, typed assets and extension contracts, with `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes` throughout. | -| **Two real graphics backends** | WebGPU-first rendering with automatic WebGL2 fallback, backend parity tests, custom GLSL/WGSL materials, render targets, filters, and readback. | -| **A complete 2D runtime** | Scenes, cameras, input, UI, text, audio, persistence, serialization, coroutines, and deterministic lifetime management ship together. | -| **Serious optional systems** | Native rigid-body physics, GPU particles, tilemaps, lighting, pathfinding, React bindings, and format adapters stay opt-in and tree-shakeable. | -| **Explicit ownership** | Application-scoped managers, local extension descriptors, and `Destroyable`/`DisposalScope` lifetimes avoid hidden global state. | -| **Performance is reproducible** | Structural CI gates and browser/GPU benchmark profiles record the workload, hardware, browser, versions, medians, p95s, and measurement spread. | +ExoJS brings scenes, rendering, input, audio, UI, and asset lifetimes into one application model. Build a game, a visualization, or an interactive canvas inside an existing web application. Keep the surrounding page in your web framework; use ExoJS for the canvas. -## Start in 30 seconds +> **Pre-1.0:** minor releases may change public APIs. Pin exact package versions, keep official runtime packages on a compatible release line, and read the release notes before upgrading. `main` tracks the latest release; `next` can contain work that is not yet published on npm. The CI and coverage badges above describe `main`. -Create a project and choose a starter interactively: +## Start a project -```bash -npm create exo-app@latest my-game +```sh +npm create exo-app@latest my-game -- --template minimal cd my-game npm install npm run dev ``` -Or select a template directly: +The starter is a Vite + TypeScript project with a visible, animated scene. Choose `minimal`, `game-starter`, `platformer`, `top-down`, `ui-app`, or `audio-reactive`. The [Setup guide](https://exoridus.github.io/ExoJS/en/guide/getting-started/setup/) explains the templates, project layout, and installation into an existing application. -```bash -npm create exo-app@latest my-game -- --template minimal -npm create exo-app@latest my-game -- --template platformer -npm create exo-app@latest my-game -- --template top-down -``` - -The smallest application is still ordinary TypeScript: +A scene contains ordinary TypeScript state and explicitly chooses what to render. This example needs no external assets: ```ts import { Application, Color, Graphics, type RenderingContext, Scene, type Seconds } from '@codexo/exojs'; @@ -61,20 +44,18 @@ import { Application, Color, Graphics, type RenderingContext, Scene, type Second class MainScene extends Scene { private readonly box = new Graphics(); - public constructor() { - super(); - + override init(): void { this.box.fillColor = Color.white; this.box.drawRectangle(-40, -40, 80, 80); - this.box.setPosition(400, 300); - this.addChild(this.box); + this.box.setPosition(this.app.width / 2, this.app.height / 2); + this.root.addChild(this.box); } - public override update(delta: Seconds): void { - this.box.rotate(delta * 90); + override update(delta: Seconds): void { + this.box.rotate(90 * delta); } - public override draw(context: RenderingContext): void { + override draw(context: RenderingContext): void { context.render(this.root); } } @@ -88,125 +69,89 @@ const app = new Application({ await app.start(MainScene); ``` -Continue with the [guide](https://exoridus.github.io/ExoJS/en/guide/), inspect runnable code in the [playground](https://exoridus.github.io/ExoJS/en/playground/), or look up a symbol in the [API reference](https://exoridus.github.io/ExoJS/en/api/). +Follow [Your first scene](https://exoridus.github.io/ExoJS/en/guide/getting-started/your-first-scene/) for the walkthrough. Use the [Playground](https://exoridus.github.io/ExoJS/en/playground/) to experiment and the [API reference](https://exoridus.github.io/ExoJS/en/api/) to check exact contracts. + +## Why ExoJS -## What you can build +| Capability | What it means for your project | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **One runtime, explicit lifetimes** | Scene-scoped assets, input, systems, animation, and audio follow scene teardown. Application-level resources can outlive an individual scene. | +| **WebGPU and WebGL2** | Choose a backend or use automatic selection. Share the high-level scene API and check capability-specific features on target devices. | +| **Rendering beyond sprites** | Compose text, geometry, masks, filters, render targets, multiple views, and custom materials. Reach for the renderer SDK when the high-level paths do not fit. | +| **Optional gameplay and visual systems** | Add physics, tilemaps, pathfinding, particles, lighting, or editor-format adapters without making them mandatory Core dependencies. | +| **TypeScript throughout** | Typed scene navigation, asset loading, extension contracts, and declarations fit an ordinary editor and build pipeline. | +| **Measured performance** | Structural gates and browser benchmark profiles describe specific workloads, with provenance and limitations alongside the results. | -### Rendering and presentation +ExoJS is a code-first runtime, not a visual game editor. Its benchmarks describe particular workloads, not a guarantee that every application will be faster than one built with another engine. -- Sprites, animated sprites, nine-slice and repeating sprites, immediate geometry, instanced batches, SDF text, bitmap text, and video. -- WebGPU and WebGL2 backends selected automatically or explicitly through `ApplicationOptions.backend`. -- Render textures, retained render plans, filter chains, visual masks, cache-as-bitmap, custom sprite materials, and custom renderers through the public renderer SDK. -- Linear and radial gradients, pixel snapping, blend modes, frame passes, asynchronous pixel readback, and render statistics including GPU memory and upload accounting. -- Forward, shadowed lightmap, and radiance-cascade lighting through `@codexo/exojs-lighting`, with normal maps, multiple light shapes, cookies, and reusable occluder sources. +## Packages -### Worlds and gameplay +Core owns the application, scenes, scene graph, rendering, input, UI, asset loading, and basic audio. Install only the optional systems you use. Runtime packages follow the Core release line; check their declared peer dependencies when choosing versions. -- Scene navigation with preload/unload, pause/resume, and built-in or custom transitions. -- Cameras with follow, shake, zoom, bounds clamping, and multiple views. -- Keyboard, pointer, touch, and gamepad input with action bindings, focus traversal, hit areas, and modal focus scopes. -- Native 2D rigid-body physics with continuous collision, joints, sensors, sleeping islands, contact modification, queries, and a debug overlay. -- Weighted-grid and waypoint-graph pathfinding, streamed tilemap worlds, Tiled and LDtk adapters, and Aseprite animation import. +### Runtime and integrations -### Player experience and application state +| Package | Purpose | Documentation | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| [`@codexo/exojs`](https://www.npmjs.com/package/@codexo/exojs) | Core runtime: scenes, rendering, input, UI, assets, and audio | [Guide](https://exoridus.github.io/ExoJS/en/guide/) | +| [`@codexo/exojs-physics`](https://www.npmjs.com/package/@codexo/exojs-physics) | 2D rigid bodies, colliders, joints, and queries | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-physics/README.md) | +| [`@codexo/exojs-particles`](https://www.npmjs.com/package/@codexo/exojs-particles) | Particle emitters, CPU simulation, and eligible WebGPU compute paths | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-particles/README.md) | +| [`@codexo/exojs-tilemap`](https://www.npmjs.com/package/@codexo/exojs-tilemap) | Format-neutral tilemaps, chunk rendering, and world loading | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap/README.md) | +| [`@codexo/exojs-tiled`](https://www.npmjs.com/package/@codexo/exojs-tiled) | Tiled JSON maps, tilesets, and authored objects | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tiled/README.md) | +| [`@codexo/exojs-ldtk`](https://www.npmjs.com/package/@codexo/exojs-ldtk) | LDtk worlds, levels, IntGrid data, and level streaming | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-ldtk/README.md) | +| [`@codexo/exojs-aseprite`](https://www.npmjs.com/package/@codexo/exojs-aseprite) | Aseprite sprite sheets and tagged animations | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-aseprite/README.md) | +| [`@codexo/exojs-tilemap-physics`](https://www.npmjs.com/package/@codexo/exojs-tilemap-physics) | Static physics colliders from tilemap geometry | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap-physics/README.md) | +| [`@codexo/exojs-lighting`](https://www.npmjs.com/package/@codexo/exojs-lighting) | Forward, shadowed lightmap, and radiance-cascade lighting | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-lighting/README.md) | +| [`@codexo/exojs-pathfinding`](https://www.npmjs.com/package/@codexo/exojs-pathfinding) | A* search over weighted grids and waypoint graphs | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-pathfinding/README.md) | +| [`@codexo/exojs-audio-fx`](https://www.npmjs.com/package/@codexo/exojs-audio-fx) | Audio effects, analysis, worklets, and beat detection | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-audio-fx/README.md) | +| [`@codexo/exojs-react`](https://www.npmjs.com/package/@codexo/exojs-react) | React canvas hosting, declarative scenes, and hooks | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-react/README.md) | -- Screen-fixed UI widgets, themes, anchoring, scrolling, tooltips, progress bars, and labels. -- Spatial audio, audio sprites, generated and streamed sources, buses, effects, analysis, worklets, and beat detection. -- Typed asset catalogs, deduplicated loading, scoped asset lifetimes, binary containers, and persistent key-value stores. -- Scene serialization, prefabs, deterministic systems, tweens, signals, and frame-budgeted coroutines for long-running work. +### Project tooling -## Packages +| Package | Purpose | Documentation | +| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| [`create-exo-app`](https://www.npmjs.com/package/create-exo-app) | Project scaffolding and maintained starters | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/create-exo-app/README.md) | +| [`@codexo/exojs-cli`](https://www.npmjs.com/package/@codexo/exojs-cli) | Static serving, project checks, scaffolding, and asset packs | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-cli/README.md) | +| [`@codexo/exojs-build`](https://www.npmjs.com/package/@codexo/exojs-build) | Vite/Rollup transforms for shaders, workers, and AudioWorklets | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-build/README.md) | +| [`@codexo/eslint-plugin-exojs`](https://www.npmjs.com/package/@codexo/eslint-plugin-exojs) | Lifecycle and engine-specific correctness checks | [README](https://github.com/Exoridus/ExoJS/blob/main/packages/eslint-plugin-exojs/README.md) | -Install only the systems your project uses. Official runtime packages share the Core release line and declare compatible peer ranges. - -| Package | Purpose | -| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| [`@codexo/exojs`](https://www.npmjs.com/package/@codexo/exojs) | Core scene, rendering, audio, UI, asset, and serialization runtime | -| [`@codexo/exojs-physics`](https://www.npmjs.com/package/@codexo/exojs-physics) | Native 2D rigid-body physics with a TGS-Soft solver | -| [`@codexo/exojs-particles`](https://www.npmjs.com/package/@codexo/exojs-particles) | GPU-compute particle simulation with a CPU fallback | -| [`@codexo/exojs-tilemap`](https://www.npmjs.com/package/@codexo/exojs-tilemap) | Format-neutral tilemap runtime, streaming, object spawning, and rendering | -| [`@codexo/exojs-tiled`](https://www.npmjs.com/package/@codexo/exojs-tiled) | Tiled JSON adapter | -| [`@codexo/exojs-ldtk`](https://www.npmjs.com/package/@codexo/exojs-ldtk) | LDtk world and level adapter | -| [`@codexo/exojs-aseprite`](https://www.npmjs.com/package/@codexo/exojs-aseprite) | Aseprite sprite-sheet and animation adapter | -| [`@codexo/exojs-tilemap-physics`](https://www.npmjs.com/package/@codexo/exojs-tilemap-physics) | Static physics colliders generated from tilemap collision geometry | -| [`@codexo/exojs-lighting`](https://www.npmjs.com/package/@codexo/exojs-lighting) | Forward, shadowed lightmap, and radiance-cascade 2D lighting | -| [`@codexo/exojs-pathfinding`](https://www.npmjs.com/package/@codexo/exojs-pathfinding) | A* pathfinding over weighted grids and waypoint graphs | -| [`@codexo/exojs-audio-fx`](https://www.npmjs.com/package/@codexo/exojs-audio-fx) | Audio effects, worklets, analysis, and beat detection | -| [`@codexo/exojs-react`](https://www.npmjs.com/package/@codexo/exojs-react) | React canvas hosting, scene composition, and hooks | - -Project tooling is available separately: - -| Package | Purpose | -| -------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [`create-exo-app`](https://www.npmjs.com/package/create-exo-app) | Interactive project scaffolding and maintained starters | -| [`@codexo/exojs-cli`](./packages/exojs-cli) | Static serving, project checks, scaffolding, and asset packs | -| [`@codexo/exojs-build`](https://www.npmjs.com/package/@codexo/exojs-build) | Vite/Rollup transforms for shaders, workers, and AudioWorklets | -| [`@codexo/eslint-plugin-exojs`](./packages/eslint-plugin-exojs) | Lifecycle and hot-path correctness rules for ExoJS projects | +Package names open npm; documentation links describe the released packages. In a checkout of `next`, read the matching local package README for unreleased changes. Runtime libraries such as physics and pathfinding are constructed directly; renderer and asset extensions use explicit application descriptors. Each package documents its setup. ## Installation and distribution -```bash -npm install @codexo/exojs -``` - -ExoJS is ESM-first and works with modern bundlers. Optional packages install independently, for example: +For a bundler-based application: -```bash -npm install @codexo/exojs @codexo/exojs-physics @codexo/exojs-lighting +```sh +npm install --save-exact @codexo/exojs ``` -Prebuilt script-tag bundles are also included: `dist/exo.iife.js` contains Core, while `dist/exo.full.iife.js` contains Core and the official runtime extensions except React. Both expose the `Exo` global. Minified variants are provided alongside them. - -## Measured performance +The package provides ESM and TypeScript declarations. It also includes script-tag bundles: -ExoJS maintains two complementary kinds of performance evidence: +| Bundle | Contents | +| ----------------------- | ---------------------------------------------------------------------------------------------------- | +| `dist/exo.iife.js` | Core runtime on the `Exo` global | +| `dist/exo.full.iife.js` | Core plus the official runtime extensions except React and tilemap physics, on the same `Exo` global | -- deterministic structural gates for draw calls, batches, binds, uploads, and other exact work counters; -- real-browser comparison profiles for rendering and physics, with pinned competitors and stamped hardware, browser, workload, warmup, sample count, median, p95, and run-to-run spread. +Minified variants are provided alongside them. Use one Core instance per application; do not load both bundles into the same page. -The numbers are deliberately not copied into this README because they change with the engine, competitor versions, browser, and reference machine. Read the [current published profiles](./packages/exojs-bench/results) and the [benchmark methodology](./packages/exojs-bench/docs/harness.md) together. +The [Full Release ZIP](https://github.com/Exoridus/ExoJS/releases/latest/download/exojs-full.zip) includes a built runtime, examples, and documentation. See [Deployment](https://exoridus.github.io/ExoJS/en/guide/shipping/deployment/) for static hosting and script-tag setup. -## Roadmap +## Performance evidence -Work toward the `1.0.0` API freeze is directional, not a release commitment. Current longer-term areas include: - -- rich text with style spans and inline content; -- worker-backed execution through the same coroutine ownership model; -- platform adapters for Worker and headless runtimes; -- the final public API audit and stabilization pass. +The [benchmark pages](https://exoridus.github.io/ExoJS/en/benchmarks/) show measured scenarios and their limitations. The [versioned profiles](https://github.com/Exoridus/ExoJS/tree/main/packages/exojs-bench/results) preserve provenance, and the [harness documentation](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-bench/docs/harness.md) explains reproduction. Structural counters and browser timings answer different questions; neither produces an overall engine winner. ## Contributing -Development requires Node 24 and the pnpm version pinned in `package.json`. +Repository development requires Node 24 and the pnpm version pinned in `package.json`: -```bash +```sh pnpm bootstrap:dev pnpm doctor ``` -`bootstrap:dev` installs dependencies and hooks, builds Core and every package, links benchmark competitors, installs Chromium, and reports anything still missing. During development, use the narrow command for the area you changed: - -```bash -pnpm typecheck -pnpm lint -pnpm test -pnpm build:all -pnpm lanes -``` - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for branch policy, imports, package boundaries, public API conventions, validation, and distribution rules. - -## Links +Read [CONTRIBUTING.md](https://github.com/Exoridus/ExoJS/blob/main/CONTRIBUTING.md) for package boundaries, code conventions, and change-specific validation. Product changes belong in the relevant Guide, package README, or source JSDoc; development history belongs in pull requests, [releases](https://github.com/Exoridus/ExoJS/releases), [CHANGELOG.md](https://github.com/Exoridus/ExoJS/blob/main/CHANGELOG.md), and Git. -- GitHub Pages: -- Guide: -- API reference: -- Playground: -- Repository: -- Releases: -- Issues: -- Changelog: [CHANGELOG.md](CHANGELOG.md) +Report a reproducible problem in [GitHub Issues](https://github.com/Exoridus/ExoJS/issues). ## License -[MIT](LICENSE) © Codexo +[MIT](https://github.com/Exoridus/ExoJS/blob/main/LICENSE) © Codexo diff --git a/examples/guides/audio-reactive-visualization/spectrum-history.ts b/examples/guides/audio-reactive-visualization/spectrum-history.ts new file mode 100644 index 000000000..806ebc06c --- /dev/null +++ b/examples/guides/audio-reactive-visualization/spectrum-history.ts @@ -0,0 +1,28 @@ +// #region guide:spectrum-history +import { DataTexture, TextureFormat } from '@codexo/exojs'; + +export class SpectrumHistory { + readonly texture = new DataTexture({ width: 256, height: 64, format: TextureFormat.R8 }); + private column = 0; + + get nextColumn(): number { + return this.column; + } + + write(bands: Uint8Array): void { + if (bands.length !== 64) { + throw new Error('SpectrumHistory expects 64 byte-valued bands.'); + } + + for (let row = 0; row < 64; row++) { + this.texture.buffer[row * 256 + this.column] = bands[row]; + } + this.texture.commitRect(this.column, 0, 1, 64); + this.column = (this.column + 1) % 256; + } + + destroy(): void { + this.texture.destroy(); + } +} +// #endregion guide:spectrum-history diff --git a/examples/guides/audio-reactive-visualization/spectrum-scene.ts b/examples/guides/audio-reactive-visualization/spectrum-scene.ts new file mode 100644 index 000000000..d95f4ba4d --- /dev/null +++ b/examples/guides/audio-reactive-visualization/spectrum-scene.ts @@ -0,0 +1,31 @@ +// #region guide:spectrum-scene +import { Color, Graphics, type RenderingContext, Scene } from '@codexo/exojs'; +import { AudioAnalyser } from '@codexo/exojs-audio-fx'; + +export class SpectrumScene extends Scene { + private analyser!: AudioAnalyser; + private readonly bars = new Graphics(); + private readonly barColor = new Color(90, 180, 240); + + override init(): void { + this.analyser = this.track(new AudioAnalyser({ source: this.app.audio.music, fftSize: 1024 })); + this.root.addChild(this.bars); + } + + override update(): void { + const levels = this.analyser.getSpectrumLog(undefined, { bands: 32 }); + const width = this.app.width / levels.length; + + this.bars.clear(); + this.bars.fillColor = this.barColor; + for (let index = 0; index < levels.length; index++) { + const height = (levels[index] / 255) * this.app.height * 0.6; + this.bars.drawRectangle(index * width, this.app.height - height, Math.max(1, width - 2), height); + } + } + + override draw(context: RenderingContext): void { + context.render(this.root); + } +} +// #endregion guide:spectrum-scene diff --git a/examples/guides/coordinates-and-views/split-views.ts b/examples/guides/coordinates-and-views/split-views.ts new file mode 100644 index 000000000..c6c60c000 --- /dev/null +++ b/examples/guides/coordinates-and-views/split-views.ts @@ -0,0 +1,19 @@ +// #region guide:split-views +import { type RenderingContext, type RenderNode, View } from '@codexo/exojs'; + +export const createSplitViews = (width: number, height: number): { left: View; right: View } => ({ + left: new View(0, 0, width / 2, height).setViewport(0, 0, 0.5, 1), + right: new View(0, 0, width / 2, height).setViewport(0.5, 0, 0.5, 1), +}); + +export const drawSplitWorld = (context: RenderingContext, world: RenderNode, left: View, right: View): void => { + context.render(world, { view: left }); + context.render(world, { view: right }); +}; +// #endregion guide:split-views + +// #region guide:pointer-world +import type { PointLike } from '@codexo/exojs'; + +export const pointerInWorld = (view: View, pointer: PointLike): PointLike => view.screenToWorld(pointer.x, pointer.y); +// #endregion guide:pointer-world diff --git a/examples/guides/lighting/basic-lightmap.ts b/examples/guides/lighting/basic-lightmap.ts new file mode 100644 index 000000000..ca443ac78 --- /dev/null +++ b/examples/guides/lighting/basic-lightmap.ts @@ -0,0 +1,31 @@ +// #region guide:basic-lightmap +import { Application, Color, Graphics, type RenderingContext, Scene } from '@codexo/exojs'; +import { LightmapLighting, PointLight } from '@codexo/exojs-lighting'; + +class LightingScene extends Scene { + override init(): void { + const lighting = new LightmapLighting(this.app, { ambient: new Color(25, 25, 35) }); + const floor = new Graphics(); + const lamp = new PointLight({ radius: 360, color: new Color(255, 190, 100) }); + + this.systems.add(lighting); + floor.fillColor = new Color(180, 190, 210); + floor.drawRectangle(0, 0, this.app.width, this.app.height); + lamp.setPosition(this.app.width / 2, this.app.height / 2); + this.root.addChild(floor, lamp); + lighting.add(lamp); + } + + override draw(context: RenderingContext): void { + context.render(this.root); + } +} + +const app = new Application({ + scenes: { LightingScene }, + canvas: { width: 800, height: 600, mount: 'body' }, + clearColor: Color.black, +}); + +await app.start(LightingScene); +// #endregion guide:basic-lightmap diff --git a/examples/guides/loading-and-resources/basic-loading.ts b/examples/guides/loading-and-resources/basic-loading.ts new file mode 100644 index 000000000..117db21b3 --- /dev/null +++ b/examples/guides/loading-and-resources/basic-loading.ts @@ -0,0 +1,44 @@ +// #region guide:required-scene +import { type RenderingContext, Scene, Sprite, type Texture } from '@codexo/exojs'; + +export class HeroScene extends Scene { + private texture!: Texture; + + override async load(): Promise { + this.texture = await this.loader.load('image/hero.png'); + } + + override init(): void { + const hero = new Sprite(this.texture); + + hero.setAnchor(0.5).setPosition(this.app.width / 2, this.app.height / 2); + this.root.addChild(hero); + } + + override draw(context: RenderingContext): void { + context.render(this.root); + } +} +// #endregion guide:required-scene + +// #region guide:scope-ownership +import type { LoaderScope } from '@codexo/exojs'; + +export const demonstrateClaims = async (parent: LoaderScope): Promise => { + const level = parent.createScope({ name: 'level' }); + const hud = parent.createScope({ name: 'hud' }); + + try { + const texture = level.get('image/hero.png'); + + hud.get('image/hero.png'); + await texture.loaded; + level.destroy(); + + console.log(texture.ready); // true: the HUD still holds a claim + } finally { + level.destroy(); // repeated destruction is safe + hud.destroy(); + } +}; +// #endregion guide:scope-ownership diff --git a/examples/guides/loading-and-resources/catalogs.ts b/examples/guides/loading-and-resources/catalogs.ts new file mode 100644 index 000000000..f652aa40c --- /dev/null +++ b/examples/guides/loading-and-resources/catalogs.ts @@ -0,0 +1,30 @@ +// #region guide:catalog-definition +import { Asset, Assets } from '@codexo/exojs'; + +interface Settings { + startLevel: string; +} + +export const SharedAssets = Assets.from({ + logo: 'image/logo.png', + settings: Asset.type('json', 'data/settings.json'), +}); +// #endregion guide:catalog-definition + +// #region guide:catalog-result +import type { LoaderScope } from '@codexo/exojs'; + +export const readStartLevel = async (scope: LoaderScope): Promise => { + const loaded = await scope.load(SharedAssets); + + console.log(SharedAssets.settings.value.startLevel); + return loaded.settings.startLevel; +}; +// #endregion guide:catalog-result + +// #region guide:catalog-composition +const LevelLocal = Assets.from({ ground: 'image/day.png' }); + +export const DayAssets = Assets.compose(SharedAssets, LevelLocal); +export const NightAssets = Assets.extend(DayAssets, { ground: 'image/night.png' }); +// #endregion guide:catalog-composition diff --git a/examples/guides/particles/basic-emitter.ts b/examples/guides/particles/basic-emitter.ts new file mode 100644 index 000000000..4c7173bbc --- /dev/null +++ b/examples/guides/particles/basic-emitter.ts @@ -0,0 +1,43 @@ +// #region guide:basic-emitter +import { Application, Color, type RenderingContext, Scene } from '@codexo/exojs'; +import { ApplyForce, ConeDirection, Constant, Curve, particlesExtension, ParticleSystem, RateSpawn, ScaleOverLifetime } from '@codexo/exojs-particles'; + +class FountainScene extends Scene { + private particles!: ParticleSystem; + + override init(): void { + this.particles = new ParticleSystem({ capacity: 512 }); + this.particles.setPosition(this.app.width / 2, this.app.height - 60); + this.particles.addSpawnModule( + new RateSpawn({ + rate: new Constant(80), + lifetime: new Constant(2), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 6, 120, 220), + }), + ); + this.particles.addUpdateModule(new ApplyForce(0, 180)); + this.particles.addUpdateModule( + new ScaleOverLifetime( + new Curve([ + { t: 0, v: 6 }, + { t: 1, v: 0 }, + ]), + ), + ); + this.systems.add(this.particles); + } + + override draw(context: RenderingContext): void { + context.render(this.particles); + } +} + +const app = new Application({ + scenes: { FountainScene }, + extensions: [particlesExtension], + canvas: { width: 800, height: 600, mount: 'body' }, + clearColor: Color.black, +}); + +await app.start(FountainScene); +// #endregion guide:basic-emitter diff --git a/examples/guides/physics-basics/falling-box.ts b/examples/guides/physics-basics/falling-box.ts new file mode 100644 index 000000000..6c5740112 --- /dev/null +++ b/examples/guides/physics-basics/falling-box.ts @@ -0,0 +1,43 @@ +// #region guide:falling-box +import { Application, Color, Graphics, type RenderingContext, Scene, SystemOrder } from '@codexo/exojs'; +import { BoxShape, PhysicsWorld } from '@codexo/exojs-physics'; + +class FallingBoxScene extends Scene { + override init(): void { + const world = new PhysicsWorld({ gravity: { x: 0, y: 980 } }); + const floor = new Graphics(); + const box = new Graphics(); + + this.systems.add(world, { order: SystemOrder.Physics }); + floor.fillColor = new Color(90, 110, 140); + floor.drawRectangle(-350, -16, 700, 32); + box.fillColor = Color.white; + box.drawRectangle(-20, -20, 40, 40); + + world.attach(floor, { + type: 'static', + position: { x: 400, y: 560 }, + shape: new BoxShape(700, 32), + }); + world.attach(box, { + type: 'dynamic', + position: { x: 400, y: 100 }, + shape: new BoxShape(40, 40), + restitution: 0.2, + }); + this.root.addChild(floor, box); + } + + override draw(context: RenderingContext): void { + context.render(this.root); + } +} + +const app = new Application({ + scenes: { FallingBoxScene }, + canvas: { width: 800, height: 600, mount: 'body' }, + clearColor: new Color(20, 24, 32), +}); + +await app.start(FallingBoxScene); +// #endregion guide:falling-box diff --git a/examples/guides/ui/basic-hud.ts b/examples/guides/ui/basic-hud.ts new file mode 100644 index 000000000..5783dcba8 --- /dev/null +++ b/examples/guides/ui/basic-hud.ts @@ -0,0 +1,27 @@ +// #region guide:basic-hud +import { Application, Button, Color, Label, ProgressBar, Scene } from '@codexo/exojs'; + +class HudScene extends Scene { + override init(): void { + const score = new Label('Score: 0', { fontSize: 24 }); + const health = new ProgressBar({ width: 220, height: 16, value: 1 }); + const damage = new Button({ label: 'Take damage', width: 180, height: 44 }); + + score.anchorIn(this.ui, 'top-left', 20, 20); + health.anchorIn(this.ui, 'top-left', 20, 56); + damage.anchorIn(this.ui, 'bottom-right', -20, -20); + damage.onClick.add(() => { + health.value = Math.max(0, health.value - 0.1); + }); + this.ui.addChild(score, health, damage); + } +} + +const app = new Application({ + scenes: { HudScene }, + canvas: { width: 800, height: 600, mount: 'body' }, + clearColor: new Color(20, 24, 32), +}); + +await app.start(HudScene); +// #endregion guide:basic-hud diff --git a/packages/create-exo-app/README.md b/packages/create-exo-app/README.md index c0337bd90..8dff4223e 100644 --- a/packages/create-exo-app/README.md +++ b/packages/create-exo-app/README.md @@ -1,53 +1,43 @@ # create-exo-app -Official starter for [ExoJS](https://github.com/Exoridus/ExoJS). +Create an ExoJS application with Vite, TypeScript, and a working scene. Use `minimal` to learn the runtime before adding optional systems. -## Usage - -```bash -npm create exo-app@latest my-game -``` - -Or pick a template: - -```bash +```sh npm create exo-app@latest my-game -- --template minimal -npm create exo-app@latest my-game -- --template game-starter -npm create exo-app@latest my-game -- --template platformer -npm create exo-app@latest my-game -- --template top-down -npm create exo-app@latest my-game -- --template ui-app -npm create exo-app@latest my-game -- --template audio-reactive -``` - -Then: - -```bash cd my-game npm install npm run dev ``` +Omit `--template` to choose interactively. The generated project belongs to you: edit its source, commit its lockfile, and keep compatible ExoJS package versions pinned. + ## Templates -| Template | Description | -| ---------------- | -------------------------------------------------------------------------------------- | -| `minimal` | Smallest TypeScript ExoJS app — one `Scene`, one rotating box | -| `game-starter` | Keyboard-controlled player, `GameScene` + `GameOverScene`, score HUD | -| `platformer` | Side-scroller on `@codexo/exojs-physics`: coyote time, jump buffer, camera follow | -| `top-down` | Tilemap + physics + click-to-move pathfinding, procedurally built or loaded from Tiled | -| `ui-app` | Settings screen built from the core UI widgets | -| `audio-reactive` | `AudioAnalyser`-driven frequency bar visualiser; click-to-start gesture | +| Template | Starting point | +| ---------------- | ------------------------------------------------------------------------------ | +| `minimal` | One visible animated object in one scene. | +| `game-starter` | Keyboard-controlled gameplay and a game-over scene. | +| `platformer` | Side-scrolling physics, camera follow, and jump handling. | +| `top-down` | Tilemaps, collision, and click-to-move pathfinding; procedural or Tiled input. | +| `ui-app` | A settings interface built from Core UI widgets. | +| `audio-reactive` | Shapes driven by live audio analysis. | -## CLI options +Availability follows the scaffolder version you run. The repository's `next` branch can contain a template that is not yet in an older npm release. A specialized template includes more systems; it is not a prerequisite for using the engine. -``` -create-exo-app [--template ] [--force] +## Locate the application code - --template minimal | game-starter | platformer | top-down | ui-app | audio-reactive (default: minimal) - --force overwrite an existing non-empty directory +`src/main.ts` creates the application, registers scene classes, mounts the canvas, and awaits startup. Scene behavior lives in `src/scenes/`. Files under `public/assets/` are served unchanged; `public/` is not part of their request URL. + +```sh +npm run build +npm run preview ``` -When run interactively (TTY) without `--template`, the CLI prompts for a template choice. In non-TTY / CI environments it defaults to `minimal` automatically. +The build writes the static application to `dist/`. Preview checks that output locally; verify the actual hosted output as well, especially loader base paths and optional browser capabilities. + +## Documentation + +[Setup and project layout](https://exoridus.github.io/ExoJS/en/guide/getting-started/setup/) · [Your first scene](https://exoridus.github.io/ExoJS/en/guide/getting-started/your-first-scene/) · [Build and deploy](https://exoridus.github.io/ExoJS/en/guide/shipping/deployment/) ## License diff --git a/packages/exojs-aseprite/README.md b/packages/exojs-aseprite/README.md index da1533e8e..c5b27137e 100644 --- a/packages/exojs-aseprite/README.md +++ b/packages/exojs-aseprite/README.md @@ -1,57 +1,59 @@ # @codexo/exojs-aseprite -Official ExoJS extension for loading [Aseprite](https://www.aseprite.org) JSON sprite-sheet exports into a ready-to-animate sprite, with one animation clip per Aseprite frame tag. +Load Aseprite JSON sprite-sheet exports as typed ExoJS assets, preserving tagged animation sequences, frame timing, and trimmed-frame offsets. -## Installation +## Install and activate ```sh -npm install @codexo/exojs @codexo/exojs-aseprite +npm install --save-exact @codexo/exojs @codexo/exojs-aseprite ``` -`@codexo/exojs` is a peer dependency. This package has no other runtime dependencies. - -> Export your sprite sheet from Aseprite as a **JSON + PNG** pair (`File → Export Sprite Sheet`, _Output → JSON Data_). Either array or hash frame mode works; frame tags become animation clips. - -## What this package provides - -- `AsepriteSheet` — parsed sprite sheet; the result of `loader.load(Asset.type('asepriteSheet', url))`. Exposes the underlying `spritesheet`, a `clips` map (one `AnimatedSpriteClipDefinition` per frame tag), the `slices` and `layers` metadata maps, and `createAnimatedSprite()` for a ready-to-play `AnimatedSprite` -- `asepriteExtension` — extension descriptor registering the Aseprite asset binding -- `asepriteBinding` — the underlying `AssetBinding` (advanced/custom wiring) -- `AsepriteFormatError` — typed error thrown on malformed Aseprite JSON -- `AsepriteData` and related types (`AsepriteFrameData`, `AsepriteFrameTag`, `AsepriteMeta`, `AsepriteSlice`, …) plus the `isAsepriteArrayData` guard - -## Usage - -Register the extension, load an Aseprite JSON export, and create an animated sprite. The extension fetches the JSON, resolves and loads the packed texture, and builds one clip per frame tag: +Core is a peer dependency. Add `asepriteExtension` to the application; importing the package alone does not install a loader. ```ts -import { Application, Asset } from '@codexo/exojs'; +import { Application, Asset, type RenderingContext, Scene } from '@codexo/exojs'; import { asepriteExtension } from '@codexo/exojs-aseprite'; -const app = new Application({ extensions: [asepriteExtension] }); - -const sheet = await app.loader.load(Asset.type('asepriteSheet', 'sprites/hero.aseprite.json')); - -const sprite = sheet.createAnimatedSprite(); -sprite.play('run'); // 'run' is an Aseprite frame-tag name -app.scenes.root.addChild(sprite); +class CharacterScene extends Scene { + override async load(): Promise { + const sheet = await this.loader.load(Asset.type('asepriteSheet', 'sprites/hero.json')); + const character = sheet.createAnimatedSprite(); + + if (sheet.clips.has('walk')) { + character.play('walk'); + } + character.setPosition(100, 100); + this.root.addChild(character); + } + + override draw(context: RenderingContext): void { + context.render(this.root); + } +} + +const app = new Application({ + scenes: { CharacterScene }, + extensions: [asepriteExtension], + canvas: { width: 800, height: 600, mount: 'body' }, + loader: { basePath: new URL('assets/', document.baseURI).href }, +}); + +await app.start(CharacterScene); ``` -Clip frame rate is derived from each frame's Aseprite `duration` (falling back to 12 fps). Frame indices in a tag are resolved against the ordered frame array; out-of-range indices are skipped. +Provide `sprites/hero.json` and its referenced image beneath the configured asset base. Aseprite's export can use Array or Hash frame layout. The animation system advances an attached `AnimatedSprite`; do not also advance it manually every frame. -## Texture ownership +## Before using an export -The packed texture is loaded via the Loader and stays in the Loader cache. `AsepriteSheet.destroy()` releases the parsed sprite sheet; the Loader handles texture lifecycle and deduplication. +Always name the `asepriteSheet` type explicitly. The adapter does not claim all `.json` files, so loading the same URL as a bare JSON path does not parse an Aseprite sheet. -## Core compatibility +Tag names must exist in the export. Direction expands the tag's frame sequence; per-frame hold durations take precedence over the display-average FPS. Clip repeat counts describe complete cycles, not the additional-repeat convention of a property tween. -This package follows the Core lockstep release line and declares the compatible `@codexo/exojs` minor as a peer dependency. Install matching package versions. +A loader scope owns its asset claims, including the referenced image dependency. Scene teardown releases scene claims; manually destroying a shared texture is not the way to unload one character. The sheet's metadata does not automatically create gameplay colliders or UI behavior from slices. -## Links +## Documentation -- [Aseprite guide](https://exoridus.github.io/ExoJS/en/guide/assets/aseprite/) -- [API reference](https://exoridus.github.io/ExoJS/en/api/) -- [Aseprite](https://www.aseprite.org) +[Aseprite guide](https://exoridus.github.io/ExoJS/en/guide/assets/aseprite/) · [Animation guide](https://exoridus.github.io/ExoJS/en/guide/rendering/animation/) · [AsepriteSheet API](https://exoridus.github.io/ExoJS/en/api/aseprite-sheet/) · [Aseprite playground](https://exoridus.github.io/ExoJS/en/playground/?example=assets/aseprite-spritesheet) ## License diff --git a/packages/exojs-audio-fx/README.md b/packages/exojs-audio-fx/README.md index 87b373ad2..79a8c93f6 100644 --- a/packages/exojs-audio-fx/README.md +++ b/packages/exojs-audio-fx/README.md @@ -1,19 +1,47 @@ # @codexo/exojs-audio-fx -Audio effects, DSP, beat detection, and analysis for [ExoJS](https://github.com/Exoridus/ExoJS). +Audio effects, spectrum analysis, worklet-backed processing, and beat detection for ExoJS. Core already provides audio assets, voices, buses, and basic routing. Add this package when a project needs analysis or the additional effect processors. -A peer-dependency library on top of `@codexo/exojs`. Core ships the audio engine (buses, voices, the `AudioEffect`/`WorkletEffect` bases, and the native `BiquadEffect`); this package adds the richer effects plus analysis tooling: +## Install -- **Effects** — `ReverbEffect`, `DelayEffect`, `ChorusEffect`, `CompressorEffect`, `EqualizerEffect`, `GranularEffect`, `PitchShiftEffect`, `VocoderEffect`, `DuckingEffect`. Insert on a bus (`bus.addEffect(fx)`) or a voice (`voice.addEffect(fx)`). -- **Analysis** — `AudioAnalyser` (spectrum / waveform / mel-log mapping) and `BeatDetector` (real-time tempo + beat tracking). +```sh +npm install --save-exact @codexo/exojs @codexo/exojs-audio-fx +``` + +Core is a peer dependency. Construct the required objects directly; there is no application extension descriptor to install for ordinary audio analysis. + +## Inspect a live music bus + +This helper expects an initialized application with audio already playing on its music bus. It does not load or start a track: ```ts -import { ReverbEffect, AudioAnalyser } from '@codexo/exojs-audio-fx'; +import type { Application } from '@codexo/exojs'; +import { AudioAnalyser } from '@codexo/exojs-audio-fx'; + +export const inspectMusic = (app: Application): { read: () => Uint8Array; destroy: () => void } => { + const analyser = new AudioAnalyser({ source: app.audio.music, fftSize: 1024 }); -app.audio.music.addEffect(new ReverbEffect({ wet: 0.4 })); -const analyser = new AudioAnalyser({ source: app.audio.music }); + return { + read: () => analyser.getSpectrumLog(undefined, { bands: 16 }), + destroy: () => analyser.destroy(), + }; +}; ``` +Call `read` from the owner's update path and destroy the helper when that owner ends. An analyser taps a live bus or voice, not an unloaded asset descriptor. A silent or muted routing path is not repaired by repeatedly creating analysers. + +## Important boundaries + +Browser audio needs a real user-gesture path. Worklet-backed processors can have asynchronous initialization and capability requirements; handle failure and teardown rather than assuming construction means readiness. + +Effects, analyser nodes, detectors, and playing voices have distinct lifetimes. Track caller-created resources with their scene or dispose them at their own boundary. Do not leave a detector connected merely because its visual widget was removed. + +Beat detection estimates tempo and phase. Polling windows such as `justBeat` are not one-shot events, and asynchronous messages are not hard real-time delivery. A rhythm-game scoring timeline needs an explicit authoritative timing design. Analysis values are not a calibrated acoustic loudness measurement. + +## Documentation + +[Audio basics](https://exoridus.github.io/ExoJS/en/guide/audio/audio-basics/) · [Effects and routing](https://exoridus.github.io/ExoJS/en/guide/audio/audio-effects/) · [Beat detection](https://exoridus.github.io/ExoJS/en/guide/audio/beat-detection/) · [Audio-reactive visuals](https://exoridus.github.io/ExoJS/en/guide/audio/audio-reactive-visualization/) · [AudioAnalyser API](https://exoridus.github.io/ExoJS/en/api/audio-analyser/) + ## License MIT diff --git a/packages/exojs-bench/README.md b/packages/exojs-bench/README.md index 1f8311032..00b98c4bb 100644 --- a/packages/exojs-bench/README.md +++ b/packages/exojs-bench/README.md @@ -1,118 +1,34 @@ # @codexo/exojs-bench -Runs ExoJS and the other 2D libraries through the same scenes, in a real browser against a real GPU, and prints what each one costs per frame. +The repository's rendering and physics comparison harness. This is development tooling, not a game-runtime dependency. It produces versioned measurements and provenance for the [benchmark site](https://exoridus.github.io/ExoJS/en/benchmarks/). -Private to this repository, never published to npm. The competitor libraries (Pixi, Phaser, Excalibur, matter-js, planck, rapier2d-compat) live in their own `competitors/` manifest, so a normal `pnpm install` downloads none of them. +## What the results mean -## Setup +Rendering scenarios measure CPU-side frame work in the harness's declared region. Structural counters describe submissions and resource operations where an adapter can observe them. Physics scenarios measure CPU time per simulation step. These instruments are not interchangeable with GPU execution time, display latency, memory use, or complete-game frame rate. -Once per checkout, ~235 MB: +A scenario compares supported, equivalent work at its declared load. Unsupported cells are absent, not zero. The headline scenario order is fixed independently of the result. There is no overall score or engine winner. -```sh -pnpm --filter @codexo/exojs-bench bench:setup -``` - -## Run one - -```sh -pnpm --filter @codexo/exojs-bench bench -``` - -That is the full rendering matrix and takes a while. For a first look, ask for one scenario: - -```sh -pnpm --filter @codexo/exojs-bench bench --archetype=fx-blur --backend=webgl2 -``` - -``` -=== Results === - ┌──────────┬──────┬─────────┬────────────────┬────────┬─────────┬───────┬────────┐ - │ scenario │ load │ backend │ arm │ cpu ms │ cpu p95 │ draws │ status │ - ├──────────┼──────┼─────────┼────────────────┼────────┼─────────┼───────┼────────┤ - │ fx-blur │ 720 │ webgl2 │ exojs current │ 0.230 │ 0.310 │ 4 │ ok │ - │ fx-blur │ 720 │ webgl2 │ exojs retained │ 0.220 │ 0.255 │ 4 │ ok │ - │ fx-blur │ 720 │ webgl2 │ pixi default │ 0.120 │ 0.205 │ 3 │ ok │ - └──────────┴──────┴─────────┴────────────────┴────────┴─────────┴───────┴────────┘ -``` - -The same rows land in `results.json`, `results.csv` and `results.md` in the output directory the run names at the end. - -### Reading a row - -- **load** is what the scenario scales: sprites for most of them, but tiles, particles, widgets or a render height for others. The unit is in `results.md`; the number alone does not carry it. -- **cpu ms** is the median time one frame spent in JavaScript - the scene's per-frame work plus submitting it. This is the comparable number. -- **cpu p95** is the same window's 95th percentile. Far above the median means the cost arrives in periodic spikes, which a player feels as a hitch and a median alone hides; the harness marks those `hitching`. -- **draws** is draw calls per frame, counted by wrapping the graphics context. It is what says _why_ one arm is faster, and a row whose timing moved without its draw count moving usually moved for a reason outside the engine. -- **status** is `ok`, `exceeded` (the cell went past the harness's frame budget and was stopped early), or `unavailable` with a reason recorded in the report. +## Set up and reproduce -Physics runs print the same shape with `step ms`, the bodies actually simulated and the contacts resolved. - -### Narrowing a run - -Every selection flag takes a comma-separated list. - -| Flag | What it selects | -| ----------------------------- | ------------------------------------------------------------------------------------------ | -| `--domain=rendering\|physics` | Which matrix to run. Default `rendering`. | -| `--archetype=` | Scenarios, by id. | -| `--engine=` / `--config=` | Arms - `exojs`, `pixi`, `phaser`, `excalibur`, and each one's configs. | -| `--backend=webgl2\|webgpu` | Graphics backend. | -| `--browser=chromium\|webkit` | Browser the run is measured in. Default `chromium`. | -| `--nodes=` | **Replaces** the rendering scenario's ladder, so an off-ladder probe needs no source edit. | -| `--bodies=` | Filters physics runs to existing body-count rungs. | -| `--frames=` | Timed frames per cell. Thin sampling; for looking, not for publishing. | -| `--out=` | Output directory. | -| `--capture=` | Write a PNG of each cell's last frame, to see what was measured. | -| `--profile` | V8 CPU profile of one cell, by file and by function. Chromium only. | - -A run that uses any of these prints `SUBSET RUN - not a reportable comparison` and means it: the published comparison is a whole matrix measured in one go. - -## Producing numbers worth publishing - -One run does not support a claim - the same code on the same idle machine moves a cell's median far enough to flip which arm leads. So a published measurement is **three separate runs**, pooled: +From the repository root: ```sh +pnpm bootstrap:dev pnpm --filter @codexo/exojs-bench bench:reference --out run-1 pnpm --filter @codexo/exojs-bench bench:reference --out run-2 pnpm --filter @codexo/exojs-bench bench:reference --out run-3 - -pnpm --filter @codexo/exojs-bench bench:compare --profile \ - --rendering run-1/rendering/results.json \ - --rendering run-2/rendering/results.json \ - --rendering run-3/rendering/results.json \ - --physics run-1/physics/results.json \ - --physics run-2/physics/results.json \ - --physics run-3/physics/results.json -``` - -`bench:reference` measures both domains at each scenario's headline load. Three separate invocations, on an otherwise idle machine, back to back - repeating the matrix inside one process shares JIT and heap state and measures the same warm state three times. Pass **none** of the narrowing flags above, `--capture` included: each one marks the run a subset, and a subset does not publish. - -`bench:compare` publishes the median of the per-run medians, the spread those runs showed, and a verdict only where all three agreed on one. It refuses to pool runs that are not repetitions of the same measurement - a different machine, browser, platform or engine version among them. - -`--profile` writes the pooled comparison to `results/` as a signed machine profile. On macOS and Linux add `--platform=` (with `-beta` if the OS is a pre-release build), because those systems do not report their own product version. - -## The gates - -```sh -pnpm gate:bench:structural # draw/bind/upload counters against a committed baseline -pnpm --filter @codexo/exojs-bench gate:timing # the manual timing gate ``` -The structural gate runs on a software rasterizer and compares integer counters, so it gives the same answer on any machine and runs in CI. The timing gate reads wall clocks and does not. - -## Contributing your machine's numbers - -The published comparison pages are generated from one JSON file per machine in [`results/`](./results/) - and every machine that is not in there yet is a gap. Different GPUs, different operating systems, and WebKit against Chromium all reorder these results, and no single developer owns enough hardware to find that out. +Use the platform declaration required by your host, as described in the [results instructions](results/README.md). Each invocation is independent; three repetitions inside one warmed process are not the same acquisition procedure. Competitor dependencies live in the benchmark's private competitor workspace, separate from the public runtime packages. -If you have a machine that is not represented: +[Publish a machine profile](results/README.md) only after the required runs pass compatibility and provenance checks. Diagnostic runs with a narrowed workload are useful locally but do not become an unrestricted published profile merely by changing their filename. -1. Follow [Producing numbers worth publishing](#producing-numbers-worth-publishing) above, with `--profile` on the `bench:compare` call. -2. Open a pull request containing **only** the one new file in `results/`. Nothing else needs to change; the published pages pick it up from there. +## Read before changing a comparison -The file is named after your machine and browser, derived from what the harness stamped rather than typed by hand, so re-measuring a machine updates its own file and can never overwrite someone else's. A validation gate checks on every CI run that a profile pools at least three runs, that its provenance is complete, and that its signature still recomputes - which only `bench:compare` can write. If it rejects your file, re-run the harness rather than editing it. +The [harness methodology](docs/harness.md) owns measurement regions, pooling, workload selection, fairness, and publication rules. The [adapter contract](src/rendering/adapters/README.md) owns how another rendering library joins the harness. The [results README](results/README.md) owns acquisition and profile provenance. The site reads their generated results; do not copy volatile ratios into unrelated documentation. -[`results/README.md`](./results/README.md) has the details: what a profile contains, how the browser and platform flags change it, and what the pooling rules refuse. +Counters can explain a plausible mechanism, but a draw-count difference alone does not prove the cause of a timing difference. Measure the proposed mechanism or identify it as an interpretation. A content hash detects inconsistent profile content; it is not an external attestation that a benchmark was run on the claimed hardware. -## Going deeper +## Contributing -[`docs/harness.md`](./docs/harness.md) is the reference: every scenario and what it is for, what each metric does and does not mean, how warmup and timed frames are chosen, what the harness pins about the browser, what provenance is stamped into a report, how to read a result and how not to, and what the published comparison is allowed to claim. +Typecheck and test a changed adapter or measurement rule before acquiring new results. CI validates the harness and its policies; shared CI timing is not substituted for the controlled reference measurements. Do not alter measured result data to make a prose claim or a regression gate pass. diff --git a/packages/exojs-bench/results/README.md b/packages/exojs-bench/results/README.md index 8fa667a76..a1accae04 100644 --- a/packages/exojs-bench/results/README.md +++ b/packages/exojs-bench/results/README.md @@ -1,108 +1,53 @@ -# Published benchmark profiles +# Benchmark result profiles -One JSON file here is one **machine profile**: everything `bench:compare` computed from a reference measurement on one machine, plus the provenance needed to judge and reproduce it. The published comparison pages are generated from these files and from nothing else. +This directory preserves the published, machine-specific rendering and physics profiles consumed by the site. The measured values and their provenance are evidence. Update them through the harness's acquisition and comparison commands, not by manually editing a ratio, count, or timing. -A file is named after the machine it describes, `--[-beta]-.json` - for example `rtx-5070-ti-windows-11-chromium.json` or `m3-max-macos-27-beta-webkit.json`. The name is derived by the harness from the stamped provenance, never typed by hand, so **re-measuring the same machine overwrites its file** and a different machine can only ever arrive as a new one. +## Acquire independent runs -- **machine** - the GPU the adapter string names, with vendor and product-line words dropped, so `NVIDIA GeForce RTX 5070 Ti` becomes `rtx-5070-ti` and `Apple M3 Max` becomes `m3-max`. Some browsers report a constant instead of the device - WebKit reports `Apple GPU` on every machine, including one with an NVIDIA card in it - and a vendor-only integrated part can reduce to a bare `graphics`. Neither names a machine, so the **CPU model** takes over, exactly as it does for a profile written from a physics measurement alone. Where that substitution happens the GPU is part of the CPU's package, so the CPU model names it correctly. The CPU model is recorded by the physics domain, so a rendering-only profile whose adapter names nothing cannot be named at all and is refused: measure physics on the same machine and pass it to `--physics`. -- **os** and **major** - the operating system and its major version. The version is in the name because a measurement on a pre-release platform and one on the shipping release it becomes describe different conditions; without it the second would silently replace the first. Windows reports its own version; macOS and Linux do not, and the runner declares it (see below). -- **beta** - present when the platform is a pre-release build. -- **browser** - part of the name because it is part of the measurement: the same machine measured in Chromium and in WebKit publishes two files, and their numbers are not comparable with each other. Both domains are measured in a browser - physics touches no GPU, but the JavaScript engine is what executes its steps - so a profile written from physics alone names its browser too, and a document whose two domains were measured in different browsers is refused rather than named after one of them. - -## A reference measurement is three runs - -A published claim is a ratio between two arms, and one run does not support one. The same code measured twice on the same idle machine moves a cell's median far enough to reverse which arm it leads - measured, with byte-identical simulation behind both runs. So a profile pools **at least three separate runs per domain**, and for every cell it publishes: - -- the **pooled value**: the median of the per-run medians, so one unlucky run cannot set the number, and beside it the median of the per-run p95s; -- the **spread**: the smallest and largest per-run median, and their ratio - the measurement's own noise, printed beside the value it belongs to; -- the **stability flag**: the verdict is computed from each run separately, and the cell is stable only when every run reached the same one; -- the **frame-budget mark**: whether the pooled median is past 16.7 ms, a whole 60 fps frame. It is recomputed from the pooled value rather than inherited from a run. - -**An unstable cell publishes no verdict.** It keeps its row, its pooled value and its range, and states what each run said instead. That the cell cannot be measured to that resolution on this machine is a finding about the measurement, not a row to hide. - -The runs must come from **separate invocations of the harness**, each with its own output directory. Repeating a matrix inside one process shares JIT and heap state across the repetitions, so it measures the same warm state three times rather than the spread the repetition exists to expose. - -## What a file contains - -- the schema version, so a reader can reject a file it does not understand; -- the machine profile: slug plus the machine, operating system and browser parts it was derived from, the platform spelled out in parts (name, major version, whether that version was read or declared, whether the build is pre-release), the engine version, when it was measured, and how many runs every measured domain pools; -- one rendering provenance entry per run (each with one stamp per backend: adapter string, browser and browser version, operating system and its major version, pre-release status, launch flags, headless and software-rasterizer bits, engine version, timestamp) and one physics provenance stamp per run (browser and browser version, CPU host with the same platform version, pre-release status, fixed timestep, the measuring page's clock resolution, disclosed caveats, engine version, timestamp); -- the library arms with their exact installed versions; -- the pooled comparison itself, per rendering backend and for physics, including every published median and p95 with its spread, stability flag and frame-budget mark, the count each row was measured at, the verdict where the runs agreed on one, the mechanism behind each row, and the rows that were measured but excluded, with the reason; -- a signature over all of the above. - -## Two numbers, one line, one count per row - -Every value is published as a **median and a p95** of the same timed window. The median is the field-comparable number and the only one a verdict is computed from; the p95 is the step or frame a player feels as a hitch, so a pair far apart describes periodically expensive work that a median alone would report as cheap. There is no p99 - the largest cells time 120 steps, which makes a p99 there the second-worst sample rather than a percentile. - -A median past **16.7 ms** carries a mark. That is the whole 60 fps frame and not a fraction of it: how much of a frame a reader may spend on this work depends on everything else their frame does and is their decision, while a single step or frame that costs more than the frame it must fit in is unplayable whatever they decide. Nothing is derived from the mark - a file carries no "bodies at N ms" capacity figure, because that would interpolate between the ladder's rungs instead of reporting something measured. - -Every row states the **count it was measured at**, chosen from that archetype's ladder before any timing was read. A rendering block puts every row on one node count, because its archetypes share their ladders. The physics archetypes do not: each has its own body-count ladder, placed so its rungs straddle the frame budget, and they reach a frame at sizes that differ by nearly an order of magnitude. **Physics rows are therefore not comparable with one another** - only the arms within one row are, which is what a row is for. Two rows were always two different scenes; stating the count per row is what stops them looking otherwise. - -The physics ladders moved when they were placed against the frame budget, and a moved rung is a **different scene** rather than the same one measured again: the per-cell seed folds the body count in. No published file predates that, so there is nothing here to migrate and no conversion is offered; a physics number taken at an older ladder simply describes a world this directory does not contain. - -A file may carry one domain or both. `rendering` is absent when the profile was written from a physics measurement alone and `physics` when it was written from a rendering measurement alone; a missing domain means "not measured on this machine". - -## Producing one +Set up the repository with `pnpm bootstrap:dev`, then run the complete reference workload three times in separate invocations: ```sh -pnpm bootstrap:dev # installs and links the competitor libraries too - pnpm --filter @codexo/exojs-bench bench:reference --out run-1 pnpm --filter @codexo/exojs-bench bench:reference --out run-2 pnpm --filter @codexo/exojs-bench bench:reference --out run-3 - -pnpm --filter @codexo/exojs-bench bench:compare --profile \ - --rendering run-1/rendering/results.json \ - --rendering run-2/rendering/results.json \ - --rendering run-3/rendering/results.json \ - --physics run-1/physics/results.json \ - --physics run-2/physics/results.json \ - --physics run-3/physics/results.json ``` -`bench:reference` measures both domains at each scenario's headline load and writes `run-N/rendering/results.json` and `run-N/physics/results.json` under `packages/exojs-bench/`. `--rendering` and `--physics` are repeatable, once per run, in run order; both domains of a profile pool the same number of runs. +Output paths are relative to the benchmark package. On macOS and Linux, supply `--platform=[-beta]` with the actual OS release; the browser cannot reliably report the required major version. Windows detection uses the harness's supported platform signals. A missing or assumed platform value is not equivalent to a verified stable release. -Pass no narrowing flag on a reference run - not `--capture`, `--frames`, `--backend`, `--archetype`, `--nodes`, `--engine` or `--config`. Each marks the run a subset, and a subset is not reportable. +Use `--browser=chromium` or `--browser=webkit` as appropriate. Keep the browser, device, platform, engine revision, and workload consistent across runs intended for one profile. A prerelease declaration belongs in the provenance. `detected`, `declared`, and `assumed-stable` describe the source of that information, not three equivalent levels of proof. -Measure on an otherwise idle machine, and run the repetitions back to back rather than days apart: these are wall-clock comparisons, and background load moves them. +For diagnosis, narrowing counts, scenarios, or other workload settings is useful. Those runs are marked as subsets and cannot be silently promoted to complete published evidence. V8-specific profiling options are not portable to WebKit. -`bench:compare` refuses to pool runs that are not repetitions of one measurement - a differing engine version, a differing set of arms or versions, a differing set of measured cells, a row that landed on a different count in one run than in another, or **a different machine, browser, platform version or pre-release status** - because a median over values that were never comparable describes the difference between two runs rather than the noise of one. What that tolerates within one machine: the timestamps, the driver and device-id tail of an adapter string, and the operating system's patch level. What it does not: a different GPU, a different operating system or major version of one, a different browser, or one run on a beta platform among runs on a shipping one. - -## Choosing a browser +## Build the profile ```sh -pnpm bench -- --browser=webkit --out=run-1 +pnpm --filter @codexo/exojs-bench bench:compare --profile \ + --rendering run-1/rendering/results.json run-2/rendering/results.json run-3/rendering/results.json \ + --physics run-1/physics/results.json run-2/physics/results.json run-3/physics/results.json ``` -`--browser` takes `chromium` (the default) or `webkit`. It selects the engine the run is measured in, is stamped into every provenance block, and lands in the file name, so the two never merge into one profile. - -`--browser` applies to `--domain=physics` as well. Physics involves no GPU, but its numbers are not runtime-neutral: the same matrix under a different JavaScript engine moves per-step medians by multiples and reorders the arms against each other, which is exactly what a second reference machine exists to find out. +Follow the command's output path and validation diagnostics. Do not rename a profile to disguise a different machine or splice measurements from different runs into it. The comparison code selects the eligible loads and verdicts; the README does not define an alternative calculation. -The choice is not free of consequences, and the harness does not hide them. A backend the selected browser does not expose is emitted as `unavailable` cells carrying the reason - WebKit reaches WebGPU on macOS alone, so a WebKit run elsewhere publishes an empty WebGPU block rather than a number under the wrong heading. A physics arm the browser cannot construct is emitted the same way, carrying the loader's reason, rather than dropped from the matrix. Launch flags are Chromium's; a WebKit stamp records an empty set rather than claiming flags it never passed. CPU profiling (`--profile`) needs the V8 sampler and refuses outright in any other browser. WebKit also substitutes a constant for the GPU, so a WebKit profile is named after the CPU model and needs a physics measurement of the same machine beside the rendering one. +## Understand pooling -## Declaring the platform +A published timing pools per-run statistics: the reported central value is the median of the run medians, and the reported p95 is the median of the per-run p95 values. It is **not** the percentile of all raw samples concatenated together. Run spread and verdict stability remain relevant even when the pooled central values differ. -```sh -pnpm bench -- --platform=27-beta --browser=webkit --out=run-1 -``` +The harness's comparison bands are reporting policy, not confidence intervals or a statistical guarantee. Tail estimates need enough samples; the absence of a published p99 does not mean a sample quantile is mathematically undefined. -`--platform` states the operating system's **major version** and, with the `-beta` suffix, that the build is a pre-release one. Both facts are carried by one flag on purpose: a runner who states the version of a beta operating system cannot then forget to say that it is a beta, which is the omission that would publish a pre-release measurement under a shipping platform's name. The value is validated - a plausible major version, not arbitrary text - and is normalized into the file name like every other part. +A row represents one declared scenario and load. The selected counts for different physics rows can differ; comparing their times does not reveal a cross-scenario capacity ranking. A time over 16.7 ms exceeds a nominal 60 Hz frame interval in that measured region alone. It is a workload warning, not a universal judgment that every application using that library is unplayable. -Windows reports its own version (`os.release()` gives `10.0.26200`, and the Windows 10/11 split is the build number, not the major), so the flag is optional there and a value contradicting the host is refused. macOS and Linux report the kernel version instead, which names no product version - the macOS 15-to-26 jump broke the last mapping anyone relied on - so the flag is **required** on them for any run that could be published. A run that narrows the matrix only warns; a full one refuses to start, before it measures anything. +## Machine and browser provenance -Every stamp records how the version was arrived at: `detected` when the host reported it, `declared` when the runner stated it, with the evidence beside it. +A rendering profile identifies the available GPU/device information, OS release, browser, and engine version. A physics profile emphasizes CPU and runtime information. Normalization deliberately ignores some incidental string differences; it does not make two different devices equivalent. -A measurement taken on a beta operating system or a preview browser build does not describe what anyone ships, so it also says so in a `prerelease` stamp whose `source` says what the value rests on: +Some environments withhold detailed GPU identity. A CPU-based or anonymous label cannot prove which physical GPU executed a rendering workload. Read that limitation with the profile rather than treating a filename as independent hardware verification. -- **`detected`** - the browser's own version string names a non-shipping build (`beta`, `canary`, `dev`, `nightly`, `preview`, `alpha`, `tp`). Nobody has to remember anything for this to fire. -- **`declared`** - the runner passed `--platform=-beta`. Needed because an operating system's release status **cannot be read at runtime**. -- **`assumed-stable`** - neither applied. This records that nothing established the platform's status. It is a weaker statement than a stable platform and must not be read as one. +Different browsers or machines are separate reference profiles. They may help reveal environment-specific behavior, but a ratio across those profiles is not a controlled browser-only or engine-only experiment. The site's selected profile and its displayed provenance must stay together. -If you measure on a beta OS, pass the `-beta` suffix on **every** run: pooling one declared run with two that forgot it is refused, precisely so a half-pre-release profile cannot pass as a stable one. +## Integrity and history -## Submitting one +Profile hashes bind the recorded content according to the harness policy. They can detect inconsistent or modified data, but they are not a cryptographic signature by an independent measurer and do not prove that fabricated input was actually executed. Review provenance and the acquisition procedure as well as the hash. -A profile for a machine that is not in this directory is welcome as a pull request containing that one file. Nothing else needs to change - the published pages pick it up from here. +Preserve intentionally published historical profiles. A newer engine release does not make an old measurement invalid; it makes its version context important. Do not copy old numeric results into current product positioning as though they described the latest build. -`verify:bench-results` validates every file in this directory on every run of the `lint` gate group: the schema version has to be known, the profile has to pool at least three runs, the provenance has to be complete for each of them, the versions consistent, and the signature has to recompute from the file's own contents. Only `bench:compare` writes that signature, so a value edited afterwards - or a file assembled by hand - is rejected. If the gate rejects your file, re-run the harness rather than editing the file: the numbers are only worth publishing if a measurement put them there. +[Harness methodology](../docs/harness.md) explains the measurement and comparison rules. [Adapter documentation](../src/rendering/adapters/README.md) explains workload equivalence. The [site](https://exoridus.github.io/ExoJS/en/benchmarks/full/) displays the generated profiles and their omissions without manufacturing an overall winner. diff --git a/packages/exojs-bench/src/rendering/adapters/README.md b/packages/exojs-bench/src/rendering/adapters/README.md index 7e7b2c68e..6aa2e8709 100644 --- a/packages/exojs-bench/src/rendering/adapters/README.md +++ b/packages/exojs-bench/src/rendering/adapters/README.md @@ -1,46 +1,33 @@ # Rendering benchmark adapters -Each file here is one **arm** of the rendering benchmark: an object implementing the neutral `EngineAdapter` contract (see `../EngineAdapter.ts`) that the harness drives identically. The committed arms are: +An adapter hosts one rendering library inside the same controlled harness. Its job is to represent a declared scenario faithfully, not to make every library appear to support every workload. Competitor libraries are pinned in the private benchmark competitor workspace; do not add them as public ExoJS runtime dependencies. -- **`exojs.ts`** — the ExoJS engine, exposed as two configs: `current` (the default per-frame path) and `retained` (the RetainedContainer instruction set). Always present. -- **`pixi.ts`** — Pixi.js v8, the direct renderer comparison and the only other 2D library that ships WebGPU. An **official, committed arm**: `pixi.js` is a pinned exact devDependency (no `^`/`~`) of `@codexo/exojs-bench`, and its version + resolution path are stamped into every report header. -- **`phaser.ts`** — Phaser 4.2, WebGL-only in this harness (Phaser 4 ships no WebGPU renderer). Measured as a **stock Phaser 4 app**: Phaser 4's `WebGLRenderer` requests a plain `webgl` (WebGL1) context by default (`canvas.getContext('webgl')`, WebGLRenderer.js:709; GLSL ES 1.00 shaders, extension-polyfilled instancing/VAO — an evolution of the Phaser 3.85+ renderer, **not** a from-scratch WebGL2 one). It runs under the `webgl2` backend request but renders **WebGL** — disclosed in every Phaser cell's `note` and the report Methodology. The WebGL2 structural probe cannot attach to a WebGL context, so the Phaser arm reports no draw-call counters (omitted, never faked); its CPU time is measured identically to the other arms and **is** cross-arm comparable. Committed pinned devDependency. -- **`excalibur.ts`** — Excalibur 0.32, a real WebGL2 arm (structural probe + GPU timer attach exactly as for the ExoJS/Pixi WebGL2 arms). Committed pinned devDependency. +## Adapter boundary -Arms are registered in `../page/harness.ts` (`resolveAdapter`) and included in the driver's cell matrix (`../driver.ts` `ADAPTER_CAPABILITIES`), with each competitor's version stamped into the report header via `readLibraryProvenance`. Each competitor module is imported lazily on first use, so an ExoJS-only run never loads one and a competitor left unlinked fails only its own cells. +Use the provided canvas, backend choice, dimensions, and deterministic scenario input. The harness owns scheduling, warm-up, measurement, and presentation of results. Do not create another requestAnimationFrame loop or resize the shared canvas independently. -> The former gitignored `reference.local.ts` slot (a local-only, never-committed reference arm) has been **retired**: the comparison is now openly reproducible — anyone can `pnpm --filter @codexo/exojs-bench bench` and re-derive the numbers against the exact pinned competitor build, which is what makes an "ExoJS vs X" statement auditable rather than unverifiable. +Initialization prepares the renderer. Scene construction creates the requested workload. The update path applies the canonical mutations. Rendering submits that scene. Teardown releases adapter-owned state without deleting the harness's canvas or leaving a timer, observer, application ticker, or event listener alive. -## Adding a new committed arm +Declare unsupported scenarios or backend paths explicitly. An adapter that produces a different image or simulates a smaller workload does not become comparable by returning a timing. Phaser's WebGL path must be described by the API it actually uses; a group labelled by the harness's requested backend is not proof that every library created a WebGL2 context. -To add another library (e.g. Phaser, Excalibur, Konva — a separate follow-on, gated on confirming the arm set): +## Equivalent work -1. Add the library as a **pinned exact-version** devDependency of `@codexo/exojs-bench` (never a `^`/`~` range, never vendored source). -2. Add an `adapters/.ts` exporting a `createAdapter()` factory that implements the `EngineAdapter` contract and follows the fairness rules below. -3. Register it in `resolveAdapter` (harness) and, if the driver should schedule its cells, in `ADAPTER_CAPABILITIES` and `readLibraryProvenance` (driver). +Use the scenario's object count, texture set, geometry, tree shape, masks, filters, mutation selection, and visibility policy. Disable library-side culling only through a setting or path that actually controls it. An inert option with the right name is not evidence of equivalent traversal. -It runs **in the browser page**, not in the Node driver, so it may freely use `document`, WebGL2/WebGPU and the library's browser runtime. +Use the shared deterministic mutation-selection helper and its expected signature. Do not substitute a similarly seeded random loop: a different consumption order can change which leaves update and how much work an implementation performs. Preserve the canonical ordering when it is part of the scenario. -### The `EngineAdapter` contract +Compare each library through its supported API. A no-op filter, flattened hierarchy, missing mask, or cheaper substitute belongs in a different scenario or an explicit exclusion. Do not add special cases after seeing which implementation wins. -Every arm implements (full JSDoc in `../EngineAdapter.ts`): +## Measurements and counters -- `engine: string` — arm label, e.g. `'pixi'`. Reported verbatim. -- `config: string` — configuration label, e.g. `'current'` / `'retained'` / `'default'`. -- `supports(backend): boolean` — `true` for each backend (`'webgl2'` / `'webgpu'`) this arm can run. Unsupported backends are skipped, not failed. -- `init(canvas, backend): Promise` — create the engine against the given canvas and backend. Pin the backend explicitly; never auto-select, and refuse a silent fallback to a different backend. -- `buildScene(spec, nodeCount, seed): void` — build the scene for the archetype (see fairness rules below). -- `mutate(frame): void` — apply the archetype's per-frame mutation. -- `renderFrame(): void` — render exactly one frame (the harness owns cadence; do not start the engine's own `requestAnimationFrame` loop). -- `teardown(): void` — release the scene and engine instance. The harness owns the `#stage` canvas and gives each cell a fresh one, so never detach it from the DOM (e.g. Pixi's `destroy` is called with `removeView: false`). -- `gpuDevice?(): GPUDevice | null` — optional; return the live `GPUDevice` when initialised on `'webgpu'` so the harness can attach its structural probe (a WebGL2 context is instead recovered from the canvas). Return `null` otherwise. (Pixi exposes it as `renderer.gpu.device`.) If an arm genuinely cannot surface the device, the harness degrades gracefully — it keeps timing and skips the structural counters for that cell rather than failing the run. -- `mutationSignature?(): string` — optional but **strongly recommended**; return `mutationSignature(selectedIndices)` (from `../../shared/mutation.ts`) for the set your most recent `buildScene` selected. The harness asserts it against the canonical selection and **fails the run loudly** if it diverges, so the cross-arm comparison rests on a check rather than prose. An arm that omits it runs, but prints a warning that its determinism is unverified. +CPU-side timing covers the region defined by the harness. It does not automatically include GPU completion, presentation, or every application task. Structural probes observe draw calls and resource operations only where the underlying API is available; absent counters are unknown, not zero. -### Cross-arm fairness contract (MANDATORY) +WebGPU instrumentation must attach to the device that the adapter actually uses. WebGL probes must instrument the actual context. A second device or a requested context version that the library did not adopt cannot measure the library's work. -Every arm must render the _same_ scene and mutate the _same_ nodes, or the comparison is meaningless. `exojs.ts` follows these rules and any new adapter **must** follow them identically (`pixi.ts` is a faithful transcription): +Counters support a mechanism hypothesis. They do not prove that one counter caused a measured timing difference. Keep a causal explanation qualified unless a controlled change isolates it. -1. **Same node set.** Build exactly `nodeCount` leaves for the archetype, laid out and nested as `spec` describes (`nestingDepth`, `textureCount`, `cullingEnabled`, the `overdraw` stacking). `cullingEnabled` is currently `false` on every archetype: ExoJS's `.cullable` drives a real per-node bounds check in the render walk, but Pixi's `.cullable` is inert unless the app registers `CullerPlugin` — an identically-set flag does NOT cost the same on both arms. A new adapter that wants culling on must give Pixi (or whichever arm is inert) an equivalent culling mechanism first, or the comparison is asymmetric again. -2. **Same mutation selection.** Use `selectMutationIndices(nodeCount, spec.mutationFraction, seed)` (from `../../shared/mutation.ts`) — the shared, canonical selection — to pick the leaves you mutate, and expose the result through `mutationSignature()`. The helper seeds a fresh `createRng(seed)` and draws **exactly one** `rng()` value **per leaf, in ascending index order**, selecting the leaf when `rng() < mutationFraction` (drawing for _every_ leaf even when the fraction is `0`). Both arms, sharing this one code path, therefore select the byte-for-byte identical index set, and the harness verifies it. Do not re-implement the draw loop, batch, reorder, or draw more than one value per leaf. -3. **Same per-frame work.** `mutate(frame)` must disturb only that selected set, with a displacement small enough to never cross the viewport edge (so culling never changes the visible set mid-run). -4. **Same cadence.** One `renderFrame()` produces one frame; let the harness time it. Do not run the engine's internal render loop. +## Adding or changing an adapter + +Read an existing adapter with the same backend boundary and the canonical scenario types. Implement initialization, scene construction, updates, rendering, capability exclusions, and teardown. Then test the declared image/workload invariants before recording performance. + +Exercise repeated initialization and destruction, unsupported backend handling, zero or small counts, and the canonical mutation signature. Acquire complete reference runs only after the implementation is stable. The [harness methodology](../../../docs/harness.md) governs measurement and comparison; [result instructions](../../../results/README.md) govern publication and provenance. diff --git a/packages/exojs-ldtk/README.md b/packages/exojs-ldtk/README.md index 98e3bff07..d42c27278 100644 --- a/packages/exojs-ldtk/README.md +++ b/packages/exojs-ldtk/README.md @@ -1,81 +1,63 @@ # @codexo/exojs-ldtk -Official ExoJS extension for loading [LDtk](https://ldtk.io) level files (`.ldtk`) into runtime `TileMap`s — one per LDtk level — ready to render with the generic tilemap node. +Load LDtk projects into ExoJS's format-neutral tilemap and world runtime. Use an eager map for a small project or a project runtime for independently owned levels. -## Installation +## Install and activate ```sh -npm install @codexo/exojs @codexo/exojs-tilemap @codexo/exojs-ldtk +npm install --save-exact @codexo/exojs @codexo/exojs-tilemap @codexo/exojs-ldtk ``` -Both `@codexo/exojs` and `@codexo/exojs-tilemap` are **peer** dependencies, so install them explicitly alongside the adapter. Nothing is pulled in transitively: strict package managers (pnpm, Yarn PnP) will not resolve an unlisted peer, and npm's auto-install of peers still leaves the versions outside your control. Keep the engine and every adapter on the same version. - -## What this package provides - -- `LdtkMap` — parsed LDtk world; the result of `loader.load('world.ldtk')`. Exposes the raw `data`, the converted runtime `levels` (`readonly TileMap[]`, in document order), and `getLevelByName(identifier)` -- `LdtkProject` — the streaming counterpart of `LdtkMap`: the result of `loader.load(Asset.type('ldtkProject', 'world.ldtk'))`. Loads the document and every tileset atlas, and **no** level payload. Exposes the world layout (`worlds` / `world`, one `MapWorld` per LDtk world) and `createRuntime({ scope })` for loading levels one at a time -- `ldtkToMapWorld` — build the format-neutral world model (level ids, bounds, neighbours) from a raw document, without reading any layer payload -- `ldtkToTileMap` — convert a single LDtk level to a `TileMap` (used internally; available for custom pipelines), plus its `LdtkToTileMapOptions` -- `getLdtkIntGridValueAt` — the named/coloured IntGrid value at a tile coordinate -- `createLdtkIntGridCellSource` — the layer's IntGrid as a `TileCellSource`, ready to hand to `buildTileCollisionGeometry` or `TileColliderStreamer` for collision authored per cell -- `ldtkExtension` — extension descriptor; depends on `tilemapExtension` automatically -- `ldtkMapBinding` / `ldtkProjectBinding` — the underlying `AssetBinding`s (advanced/custom wiring) -- The raw LDtk JSON types (`LdtkData`, `LdtkLevel`, `LdtkLayerInstance`, `LdtkEntityInstance`, …) and the flip-bit constants (`LDTK_FLIP_X`, `LDTK_FLIP_Y`, `LDTK_FLIP_XY`, `LDTK_FLIP_NONE`) -- `TileMap`, `TileMapNode`, `TileMapView`, `TileLayer`, `TileSet`, `ObjectLayer`, … re-exported from `@codexo/exojs-tilemap` (same class identity — `instanceof TileMap` holds across both import paths) - -## Usage - -Register the extension and load a `.ldtk` world. One extension enables **both** loading and rendering — `ldtkExtension` depends on `tilemapExtension`, so the tile chunk renderer bindings are materialised automatically: +Core and the tilemap runtime are peer dependencies. `ldtkExtension` depends on `tilemapExtension`, so selecting the adapter installs both its loading capability and the generic tile renderer. ```ts -import { Application } from '@codexo/exojs'; -import { TileMapNode, ldtkExtension } from '@codexo/exojs-ldtk'; - -const app = new Application({ extensions: [ldtkExtension] }); - -const world = await app.loader.load('levels/world.ldtk'); - -// Render the first level (each LDtk level is its own TileMap): -const level = world.getLevelByName('Level_0') ?? world.levels[0]; -app.scenes.root.addChild(new TileMapNode(level)); -``` - -`TileMapNode` is the same class exported by `@codexo/exojs-tilemap` (see its [README](https://www.npmjs.com/package/@codexo/exojs-tilemap) for the rendering/culling model and actor interleaving). - -### Streaming levels - -`ldtkMap` converts every level up front. For a project too large for that, load it as an `ldtkProject` and load levels individually — each gets its own `LoaderScope`, and external `.ldtkl` payloads are fetched only when their level is: - -```ts -import { Application, Asset } from '@codexo/exojs'; +import { Application, type RenderingContext, Scene } from '@codexo/exojs'; import { ldtkExtension } from '@codexo/exojs-ldtk'; +import { TileMapNode } from '@codexo/exojs-tilemap'; + +class LevelScene extends Scene { + override async load(): Promise { + const world = await this.loader.load('levels/world.ldtk'); + const level = world.getLevelByName('Level_0') ?? world.levels[0]; + + if (level === undefined) { + throw new Error('The LDtk project contains no loadable level.'); + } + this.root.addChild(new TileMapNode(level)); + } + + override draw(context: RenderingContext): void { + context.render(this.root); + } +} + +const app = new Application({ + scenes: { LevelScene }, + extensions: [ldtkExtension], + canvas: { width: 800, height: 600, mount: 'body' }, + loader: { basePath: new URL('assets/', document.baseURI).href }, +}); + +await app.start(LevelScene); +``` -const app = new Application({ extensions: [ldtkExtension] }); - -const project = await app.loader.load(Asset.type('ldtkProject', 'levels/world.ldtk')); -const runtime = project.createRuntime({ scope: app.loader }); - -const forest = await runtime.loadLevel(project.world.getLevelByName('Forest')!.id); +Serve the project and its referenced files beneath the asset base. Each LDtk level becomes its own `TileMap`; loading a project does not automatically select or display every level. -// ... later -forest.destroy(); // map, spawned objects and the level's asset claims, in that order -``` +## Eager or streamed? -Which levels to load, and when, stays game policy — `project.world` carries the bounds and neighbour graph to decide with. +`ldtkMap` converts all levels up front. `Asset.type('ldtkProject', path)` exposes a world layout and a runtime that can acquire levels individually. External `.ldtkl` payloads are fetched when required; an embedded level's JSON is already part of the project document. Deferred conversion does not remove bytes that the initial document contains. -## Texture ownership +The project runtime supplies level ownership, not the game's streaming policy. Choose which levels to load, bound concurrency, and release their runtime handles when they are no longer needed. Use the Guide's failure and cancellation workflow rather than retaining non-null assertions against authored level names. -Tileset textures are loaded via the Loader and stay in the Loader cache. `LdtkMap.destroy()` destroys the owned runtime `TileMap`s but does **not** unload textures (Loader-owned) or remove any scene nodes — the application owns those. +## Authored data and ownership -## Core compatibility +Tile rendering, entity spawning, IntGrid collision, and pathfinding are separate uses of authored data. A visible layer does not create a physics body automatically. `createLdtkIntGridCellSource` exposes cell data for the tilemap-physics bridge when that is the intended collision source. -This package follows the Core lockstep release line. Its `@codexo/exojs` and `@codexo/exojs-tilemap` peer dependencies require the matching minor release. +A loader-acquired map and its texture dependencies remain claim-owned. `LdtkMap.destroy()` releases its owned runtime maps, not arbitrary scene nodes or shared loader textures. Remove displaying nodes at their owner boundary and release asset claims through their scope. Import the format-neutral rendering nodes, including `TileMapNode`, from `@codexo/exojs-tilemap`; the adapter owns LDtk loading and conversion. -## Links +## Documentation -- [LDtk guide](https://exoridus.github.io/ExoJS/en/guide/assets/ldtk/) -- [API reference](https://exoridus.github.io/ExoJS/en/api/) -- [LDtk level editor](https://ldtk.io) +[LDtk guide](https://exoridus.github.io/ExoJS/en/guide/assets/ldtk/) · [Worlds and level streaming](https://exoridus.github.io/ExoJS/en/guide/assets/worlds-and-spawning/) · [LdtkMap API](https://exoridus.github.io/ExoJS/en/api/ldtk-map/) ## License diff --git a/packages/exojs-lighting/README.md b/packages/exojs-lighting/README.md index 73bfd1bdd..76fbbe169 100644 --- a/packages/exojs-lighting/README.md +++ b/packages/exojs-lighting/README.md @@ -1,307 +1,60 @@ # @codexo/exojs-lighting -Official ExoJS extension for 2D lighting. Lights are scene nodes, so a torch can be parented to the player and follow it without manual synchronization. Choose forward lighting inside the sprite shader, a shadowed screen-space lightmap, or radiance cascades that transport light through the scene. +Three 2D lighting models for ExoJS: per-sprite forward lighting, shadowed frame lightmaps, and radiance-cascade light propagation. Lights are scene nodes; occluders describe the geometry that blocks them. -## Installation +## Install ```sh -npm install @codexo/exojs @codexo/exojs-lighting +npm install --save-exact @codexo/exojs @codexo/exojs-lighting ``` -`@codexo/exojs` is a peer dependency. This package has no other runtime dependencies. +Core is the peer dependency. This package exposes directly constructed systems, not a `lightingExtension` descriptor. -## What this package provides - -- `PointLight`, `SpotLight`, `LineLight`, `SunLight` - scene nodes that emit rather than draw. Position and direction come from the node's transform, and every field is an ordinary property, so the engine's tweens animate a light with no lighting-specific animation concept. -- `ForwardLighting`, `LightmapLighting`, `RadianceLighting` - the three systems: each collects the registered lights, carries the ambient term, and shades the frame its own way. They are alternatives rather than layers, they register on a `SystemRegistry` like any other system, and `Lighting` is the base they share, for a type that takes any of them. -- `LitMaterial` - a `SpriteMaterial` (GLSL + WGSL) that shades a sprite against those lights. Normals are optional: without them the surface is lit as a plane rather than left black. -- `NormalMap`, `AlphaNormals` - where a material's surface normals come from. `new NormalMap(texture)` binds an authored tangent-space map, `new AlphaNormals(texture)` derives one from the texture's own silhouette; `NormalSource` is an interface, so a source of your own is a valid argument without this package knowing about it. -- `PhysicsOccluder`, `TilemapOccluder`, `AlphaOccluder`, `MeshOccluder`, `PolygonOccluder` - what blocks light, read out of the description of the world a project already has: physics colliders, tile layers, a sprite's own silhouette, or an outline you author. Occluders are registered sources rather than a flag on a drawable, and `OccluderSource` is an interface you can implement. - -## Usage +## Start with a scene-owned lightmap ```ts -import { Color, Scene, type Seconds, Sprite } from '@codexo/exojs'; -import { ForwardLighting, LitMaterial, NormalMap, PointLight } from '@codexo/exojs-lighting'; - -class LitScene extends Scene { - private lighting = new ForwardLighting({ ambient: new Color(30, 30, 45) }); - private player = new Sprite(playerTexture); +import { Color, Graphics, type RenderingContext, Scene } from '@codexo/exojs'; +import { LightmapLighting, PointLight } from '@codexo/exojs-lighting'; +export class LitScene extends Scene { override init(): void { - // Scene systems tick after Scene.update(), so the packed texture always - // describes the frame that is about to be drawn. - this.systems.add(this.lighting); - - // Parented to the player, so the torch follows it with no bookkeeping. - this.player.addChild(this.lighting.add(new PointLight({ radius: 320, color: new Color(255, 180, 120) }))); - - const ground = new Sprite(albedoTexture); - - ground.material = new LitMaterial({ lighting: this.lighting, normals: new NormalMap(normalTexture) }); - this.root.addChild(ground, this.player); + const lighting = new LightmapLighting(this.app, { ambient: new Color(25, 25, 35) }); + const floor = new Graphics(); + const lamp = new PointLight({ radius: 300, color: new Color(255, 190, 100) }); + + this.systems.add(lighting); + floor.fillColor = Color.white; + floor.drawRectangle(0, 0, this.app.width, this.app.height); + lamp.setPosition(this.app.width / 2, this.app.height / 2); + this.root.addChild(floor, lamp); + lighting.add(lamp); } - override update(delta: Seconds): void { - this.player.setPosition(this.player.position.x + 60 * delta, 300); + override draw(context: RenderingContext): void { + context.render(this.root); } } ``` -## Three renderers, one vocabulary - -The scene describes what emits; which system you construct decides how that becomes pixels. Nothing else changes between them - the same lights, the same materials, the same registration. - -| | `ForwardLighting` | `LightmapLighting` | `RadianceLighting` | -| ----------------------- | ---------------------------------- | --------------------------------------------------- | --------------------------------------------------- | -| Where light is computed | inside the sprite fragment stage | in a target of its own, multiplied over the frame | the same target, filled by transporting radiance | -| Normal mapping | per material, on `LitMaterial` | per drawable, through a prepass | no | -| Lit material | `LitMaterial` | none - the renderer lights the frame, not a sprite | none | -| Shadows | no | yes, soft, from registered occluder sources | yes, with a penumbra that follows the source's size | -| Light count | capped by `maxLights` (default 64) | uncapped | uncapped, and free: the cost is per probe | -| Extra passes | none | two, a third with normals, a fourth while debugging | four to nine, depending on the view | -| Cost per light | a loop iteration per lit fragment | the fill of its own radius | none - the field costs what the screen costs | - -```ts -const lighting = new LightmapLighting(app, { ambient: new Color(20, 20, 30) }); -``` - -`LightmapLighting` and `RadianceLighting` light the frame the application drew, so the application is their first argument rather than an option: they read its frame, install their passes in its frame slot, and follow its surface when it resizes. `ForwardLighting` shades inside the sprite stage and needs none of that, so it is the one that can be built without a host - `new ForwardLighting({ maxLights: 16 })`. A filter chain is a frame pass, so `post` needs the host in every renderer. - -`lighting.quality` still reports `'forward'`, `'lightmap'` or `'radiance'`, which is what a status line or a debug overlay reads. There is no renderer to name at construction and nothing to resolve: pick the class whose properties the scene needs - `ForwardLighting` for normal maps on a `LitMaterial`, `LightmapLighting` for shadows and an uncapped light count. - -### `RadianceLighting` - -It fills the same light field from a chain of radiance cascades. Light PROPAGATES from what emits rather than falling off inside each light's radius, which is a different picture rather than a better one: a lamp lights the whole room it is in, a wall between two rooms leaves the second dark, and a source with a size casts a penumbra that widens with distance the way a real one does. - -```ts -import { PointLight, RadianceLighting } from '@codexo/exojs-lighting'; - -const lighting = new RadianceLighting(app, { probeSpacing: 2, ambient: new Color(8, 8, 14) }); - -lighting.add(new PointLight({ radius: 300, intensity: 3, softness: 0.4 })); -lighting.occludeFrom(new TilemapOccluder(level.layer('walls'))); -``` - -Importing the class is what links it. The cascades and the transport tables they walk hang off `RadianceLighting` alone, so a project that never constructs one never pays for them - which is also why there is no string to select a renderer by: reading one out of a config file would put every renderer into every bundle. - -Its tuning sits beside the rest - `probeSpacing`, `cascades` and `interval`, all optional and all defaulting to something derived from the surface. They change how finely the same scene is sampled, never what is in it. - -What a light means here is its SHAPE, not its falloff: `softness` sets the size of the source, and that is what sets how soft its shadows are. `radius` still bounds the region occluders are collected for, and `intensity` and `color` are what it emits - `intensity` scaled so that it means the same brightness it means under the light quads, measured at half the light's radius. Changing `softness` therefore changes how soft the shadows are and not how bright the room is. - -What else the transport carries: - -- **A lit surface re-emits.** A wall the field lit gives part of that light off again in its own colour, one frame later - `bounce` sets how much, and `0` switches it off. It is the previous frame's light field that says how lit a wall was, so the bounce trails a moving lamp by a frame. -- **A `SunLight` is the sky.** A ray that reaches the top of the chain without hitting anything ends in it, so a directional light comes in wherever the sky is open and every wall blocks it. The first enabled one is taken; `softness` is its angular size. -- **A `SpotLight` emits across its cone** and blocks all round, the way a lamp's body does. -- **The fields reach past the picture.** The occluder mask and the geometry a ray walks cover the view and a margin around it (`fieldMargin`, a quarter of the view per side by default), so a wall or a lamp just outside the picture still shadows or lights what is in it as the camera moves. The probes themselves cover only the view. - -One limit is worth knowing before a bright lamp goes in the middle of the picture. Close to a source - within roughly five times its own size - the chain is resolving that source with the few directions the coarsest levels have, and the source's own disc is rasterised at the light field's resolution. Moving the lamp by less than a texel therefore redistributes light there in a way the merge does not smooth over: around a tenth of the arriving brightness per quarter texel, which reads as a shimmer on the lamp's own halo rather than anywhere it lights. Past that radius it settles to within what eight bits can even express. It is a property of the transport rather than of a particular scene. - -The two frame-lighting renderers need the application because they work on the frame it drew: they install their passes in `app.framePasses` and remove them on `destroy()`. `lightResolution` (default `0.5`) sets the light target's density - light is low-frequency, so half resolution is hard to tell apart and costs a quarter of the fill. - -`lighting.debug = 'light'` shows the accumulated light field on its own, which is how you see where a light reaches without the scene's colours in the way; `lighting.debug = 'normals'` shows the prepass normals; `lighting.debug = 'occluders'` draws the silhouettes the sources collected over the shaded scene; and `lighting.debug = 'mask'` shows those same edges rasterised into a target of their own at the light field's resolution, widened so none can fall between two texels. `RadianceLighting` reads that mask while tracing raster occluders, and the optional GPU shadow-row filler reads it under lightmap lighting. Vector-only radiance and the default CPU shadow-row builder skip the mask unless its debug view is active. - -The `lightmap` light target is `rgba16f`, so two lights overlapping add up past `1.0` instead of saturating to white, and a filter over the composite has something above the clipping point to work with. A WebGL2 context without `EXT_color_buffer_float` cannot render into one; there the target is `rgba8` and `lighting.hdr` reports `false`. The picture is still correct - it clips earlier, and a bloom keyed on a threshold near `1.0` finds little to bloom. - -### Normals under `lightmap` - -`lightmap` multiplies a frame that was already drawn, so by the time the light field is composited there is no per-fragment surface normal anywhere. A **normal prepass** puts one back without asking anything of the scene: register a drawable and the renderer draws its normal map, at the drawable's own place and orientation, into one `rgba8` attachment that the light shader then reads at its own screen position. - -```ts -lighting.normalsFrom(crate, new NormalMap(crateNormals)); -lighting.normalsFrom(hero, new AlphaNormals(heroTexture)); -``` - -Nothing is required of a drawable that is not registered. The attachment's alpha is coverage, and where it is zero the light lands with no `N dot L` term at all - which is exactly how the renderer behaved before the prepass existed, so switching it on cannot darken anything that did not ask for normals. The drawable's own texture supplies that coverage, so a silhouette claims a surface and the empty corners of its quad do not. - -What it costs: one pass over the registered drawables, one `rgba8` attachment at the light target's resolution, and one texture fetch in the light shader. A scene that registers nothing pays none of it - the attachment stays at one texel and the pass is switched off. What it inherits from every screen-space normal buffer: one normal per pixel, so overlapping surfaces resolve to the topmost, and within the prepass that order is registration order rather than scene order. - -`lighting.debug = 'normals'` shows the field the prepass wrote. - -## Shadows you do not model - -The work in 2D shadows is data entry, not rendering. Engines that ask for a silhouette per object mostly ship without shadows, because the bookkeeping is not worth it - and a project with physics colliders or a tile layer has described its walls once already. - -```ts -const lighting = new LightmapLighting(app); - -lighting.occludeFrom(new PhysicsOccluder(world)); -lighting.occludeFrom(new TilemapOccluder(tilemap.layer('walls'))); -lighting.occludeFrom(new AlphaOccluder(tree)); -lighting.occludeFrom(new MeshOccluder(platform)); -lighting.occludeFrom(new PolygonOccluder(trunkOutline, { node: tree })); -``` - -| Source | Reads | Notes | -| ----------------------------- | ------------------------------ | ------------------------------------------------------------------------------ | -| `new PhysicsOccluder(world)` | collider geometry | static bodies only by default, never sensors; queried per frame by region | -| `new TilemapOccluder(layer)` | occupied cells of a tile layer | boundary edges only, merged into runs; cached per block, keyed on the revision | -| `new AlphaOccluder(sprite)` | the drawable's own silhouette | its atlas frame, traced and simplified once, never per frame | -| `new MeshOccluder(mesh)` | a triangle mesh's outline | interior edges dropped, holes kept; extracted once | -| `new PolygonOccluder(points)` | an outline you author | the escape hatch, and the right answer when the shadow is not the drawing | - -`AlphaOccluder` and `MeshOccluder` take the drawable itself, which then supplies both its geometry and its placement: `AlphaOccluder` the texture, the frame of it the drawable shows and the box that frame is drawn into, `MeshOccluder` the vertices and the index stream. The anchor needs no mention - a drawable's transform already carries it. Pass a bare `Texture` to `AlphaOccluder` instead and the whole of it is traced, placed by `node`; that form cannot know about an atlas frame or a resize, so prefer the drawable wherever there is one. - -`MeshOccluder` extracts once. `AlphaOccluder` extracts per distinct frame: an animation is a finite set of silhouettes, not a continuous one, so each region of the atlas is traced the first time the clip reaches it and looked up every time after. A rendered frame costs a comparison and the transform; the marching-squares pass happens once per frame of the clip, however long the clip runs. Moving, rotating or scaling either carrier is free. - -What neither follows is a change with nothing to key on: deforming a mesh's vertices, or a texture whose pixels move while its frame stays put. That second case is video and anything drawn into every frame - see below. - -Two sources give no outline, by construction rather than by omission. A **render target** has no pixels this side of the GPU: reading one back is asynchronous and backend-specific, and its content is dynamic anyway, so `AlphaOccluder` refuses it - draw into an `HTMLCanvasElement` or `OffscreenCanvas` and wrap that in a `Texture` if you need both a live surface and its outline. **Video** is the opposite case: an `HTMLVideoElement` is a perfectly readable texture source, so `Video` (which extends `Sprite`) traces the frame that was decoded at the time. It will not follow the playback, because a video's frame rectangle never changes while its pixels do, and the per-frame cache has nothing to distinguish one moment from the next. It hardly matters in practice: almost no video carries an alpha channel, so what you get is the frame rectangle, which `PolygonOccluder` describes with four points and no tracing pass at all. - -`Sprite.texture` accepts a `RenderTexture`, so "a sprite that cannot be traced" is a shape the types allow, and a shadow that silently never appears is a bad way to find out. A development build says which of the three it was - no texture, a render target, or a texture that could not be read yet - on the `AlphaOccluder` log source. A production build carries neither the check nor the message. - -There is no `castsShadow` flag, in this package or in the core. A flag on a drawable would put lighting vocabulary on a class with no lighting concern, and it would tie the shadow silhouette to the sprite's shape - which is wrong often enough that a tree casts the shadow of its trunk, not of its canopy. Sources keep the two apart while letting the common case stay one line. - -`PhysicsOccluder` and `TilemapOccluder` take structurally typed arguments, so this package depends on neither `@codexo/exojs-physics` nor `@codexo/exojs-tilemap`: a project without them pulls in nothing, and a project with a collision layer of its own can feed shadows from that instead. - -### What your bundler can drop - -Every occluder source and every normal source is a class of its own, exported by name, and nothing gathers them into a namespace object. That is the reason: reaching one property of such an object keeps the whole of it, so a physics-only project would carry the marching-squares tracer, the alpha readback and the tile boundary walker it never runs. - -Every renderer splits this way, because each is a class a project imports or does not. CI keeps the complete package below 34 KB gzip, a lightmap-only import below 14 KB, and a forward-only import below 5 KB. - -### Light shapes - -`PointLight` is equal in every direction. `SpotLight` is a cone along the node's own rotation. `LineLight` is a segment: falloff is measured from the nearest point on it, so the pool of light is a capsule rather than a disc - a neon tube, a light strip, a laser. - -```ts -sign.addChild(new LineLight({ length: 120, radius: 160, color: Color.cyan })); -``` - -A line light's `radius` is the distance from the SEGMENT, so it reaches `length / 2 + radius` along its own axis and `radius` across it. Its shadow map is polar around the segment's centre, the same as a point light's: exact for a fragment the segment subtends little of, approximate near a long tube's end, where a real emitter would light an occluder from many points at once. `softness` is the knob that stands in for that. - -`SunLight` has a direction and no position: a sun, a moon, a distant floodlight. It reaches everything the camera can see, falls off nowhere, and its shadows are parallel. - -```ts -scene.addChild(lighting.add(new SunLight({ intensity: 0.8 }))).setRotation(-35); -``` - -Its shadow map is a line rather than a circle - there is no centre to measure angles from, so instead of an angular bin per direction it has one bin per strip across the light, holding how far along the light the nearest occluder in that strip sits. The strips span the visible world, which is why registering a sun widens the region the occluder sources are asked for to the camera's own bounds. `height` is a slope rather than a length, because a source at no particular distance has no other meaning for it. - -`forward` has no capsule and no directional term in its shader, so it draws a line light as a point light at the segment's centre with the whole reach as its radius, and skips a sun entirely. - -Shapes are deliberately not extensible: a shape is instance data a light-pass shader evaluates, and opening it up means either exposing that shader's structure or accepting a draw per shape. Cookies plus parameters cover what people build, and additive extension stays possible later. - -### Cookies - -Every light takes an optional `cookie` texture, which is the cheapest large visual win here: a window cross, leaf shade, a stained-glass pattern, a projector gobo. - -```ts -lighting.add(new PointLight({ radius: 320, cookie: windowCross })); -``` - -The texture's full `0..1` maps onto the light's own bounding square, so the pattern turns with a cone light and scales with the radius - it is fixed to the lamp, not to the world. It is multiplied into the light, so a transparent part of the cookie casts nothing and an opaque white one changes nothing. Wrapping is the texture's own business; a cookie meant to end at its edge wants `ClampToEdge`. - -A cookie is a mask the LIGHT carries, not a pattern projected onto the world: its full `0..1` lies on the light's own bounding square, so it turns with a cone and scales with a radius - and it travels with the light. That is what you want for a torch with a cut-out and what you do not want for a window, whose bars belong to the wall: keep a light wearing a window still, or the pattern slides across the floor with it. A world-anchored projection is a different feature and is not built. - -Lights sharing a cookie share a draw. A scene with three distinct cookies costs three draws rather than one - still one draw per texture, never one per light. `forward` ignores cookies: it shades inside the sprite stage, where a texture per light cannot be reached in one draw. - -### Softness - -`softness` is a property of the light, in `0..1`, and it means a different quantity in each renderer. Under `lightmap` it is FILTER WIDTH: the light stays a point, and the shadow term is averaged over a band of the angular shadow row up to three percent of a full turn wide. A wider band widens the edge, but the edge widens with distance from the LIGHT rather than from the wall, and it is not a model of an area source. Under `radiance` it is SOURCE SIZE: the emitter is given a width, and the penumbra follows from the geometry - it grows with the distance between the wall and the surface the shadow falls on, the way a real one does. - -Neither adds a pass. Under `lightmap` the filter samples every bin under its kernel and spends between 7 and 23 texture fetches per shadowed fragment doing it, which also bounds the kernel at ten bins either side - three percent of a turn at the default `shadowResolution`, and proportionally less as that rises. - -```ts -lighting.add(new PointLight({ radius: 320, softness: 0.6 })); -``` - -A spot light's `coneSoftness` is a separate thing: the fade across the edge of its cone, which is the shape of the light rather than the shape of its shadows. - -### How a shadow is computed - -Every light gets one row of a shadow map: for each of `shadowResolution` angular bins around the light, the distance to the nearest occluding edge as a fraction of the light's radius. The rows are built on the CPU from the segments the sources collected and uploaded as one texture; the light shader turns a fragment's own direction into a bin and compares. - -That shape is chosen so the lights stay in a single instanced draw. A shadow pass per light would break the batch the renderer exists for, and the batch is what makes an uncapped light count affordable. - -The cost is therefore the visible occluding edges times the lights that can see them, per frame, on top of the fill each light already pays. It is bounded by collecting only the region the visible lights jointly reach, by emitting only boundary edges - a hundred-tile corridor is four segments, not four hundred - and by caching whatever does not change: a traced silhouette is traced once, a tile block is rebuilt only when the layer's revision moves. - -`shadowResolution` (default `256`) is the finest shadow edge the renderer can resolve. A bin is accurate to half its own width, which the sample kernel smooths over; a very large light on a high-resolution canvas is the case that wants more bins. - -## How the lights reach the shader - -The `forward` renderer owns a single `rgba32f` `DataTexture`, `maxLights + 1` texels wide and three rows tall. The light count and the ambient term travel in the texture's header column, so a lit material has no per-frame uniform to write and any number of materials can share one system. - -| column | row 0 | row 1 | row 2 | -| ------- | ----------------------------- | ----------------------------------- | ---------------------------------- | -| `0` | `(activeLightCount, 0, 0, 0)` | `(ambientR, ambientG, ambientB, 0)` | unused | -| `i + 1` | `(x, y, radius, intensity)` | `(r, g, b, height)` | `(dirX, dirY, cosOuter, cosInner)` | - -Colour channels are normalized to `0..1`. A point light writes both cone cosines as `-1`, which no direction can fail, so the shader applies one cone term to every light and never branches. This is why the light count is a shader loop bound rather than a compiled-in constant: raising `maxLights` costs texture width, not a recompile. - -The shaded result is `albedo * (ambient + sum over lights)`. Each light falls off quadratically to nothing at its `radius`; `height` is how far above the sprite plane it sits, and it controls how grazing the light direction is - small values rake across the surface and exaggerate the normal map, large values flatten it. - -## Normal maps - -Normals are optional, in three steps. A `LitMaterial` without them binds a shared flat normal and the surface is lit as a plane - a project with no authored maps is lit rather than black. `new AlphaNormals(texture)` reads the alpha channel as a height field and derives a map once at load: the silhouette gains edges that turn away from the light, which knows nothing about the interior of a shape but is the difference between art that reacts to light and art that does not. `new NormalMap(texture)` binds an authored map, which is what a project with real art direction ships. - -### Which way up the green channel is - -The canonical input convention is **OpenGL**: green above the midpoint means the normal leans towards the TOP of the image, blue points out of the sprite plane, and a flat texel is `(128, 128, 255)`. This is ExoJS's own choice, not a universal standard: most authoring tools can write either convention and several - Substance's mesh bakers among them - default to DirectX, so check what your exporter is set to rather than assuming. A map authored the other way up lights its vertical detail from the wrong side while its horizontal detail stays correct, which is the shape that symptom always has. - -Declare the other convention rather than editing the texture: - -```ts -new NormalMap(fromMax, { convention: 'directx' }); -``` - -It is per source, it is carried through both the `forward` shader and the `lightmap` prepass, and it costs no texture copy and no readback. There is no auto-detection and no backend-dependent default: the same asset means the same thing on WebGL2 and WebGPU. - -Three things stay separate and are easy to confuse. The CHANNEL convention is which way up green is. The TEXTURE orientation is which way up the image is. And this engine's own coordinates are y-down, which is why a map leaning towards the top of its image leans towards local `-y`. Inverting a green channel is not the same as flipping an image vertically. - -A normal map is a **material** binding, not a per-sprite one: every sprite drawn with a given `LitMaterial` shares it, so in practice there is one material per atlas. The map must have the same layout as the albedo atlas, frame for frame, and encodes tangent-space normals as `rgb = n * 0.5 + 0.5` with `+x` towards the right of the image and `+y` towards its top. Rotation and mirroring are handled in the shader: the normal is rotated by the sprite's local-to-world basis, so a spinning or negatively-scaled sprite keeps its bumps facing the right way. - -Sprites from a second atlas need a second `LitMaterial`, which breaks the batch at the material boundary. Both materials can shade against the same `Lighting` system. - -## Emission - -A `LitMaterial` takes an `emissive` multiplier: how much light the surface emits of its own, as a multiple of its albedo. - -```ts -lava.material = new LitMaterial({ lighting, emissive: 2.4 }); -``` - -It is added to the light term rather than to the colour, so emission scales the albedo the way a light does - a black pixel emits nothing however high it is set, and a transparent one stays transparent instead of glowing through its own alpha. Values above `1` push the surface past what a light could produce, which is what a `post` filter keyed on a threshold is there to catch. - -It is a live property (`material.emissive = 0.5`), so a pulsing forge is a tween like any other. - -## Capabilities +Register and start `LitScene` in an `Application`. Construct host-bound lighting in `init`, not in a field initializer that accesses `this.app` before attachment. The [Lighting guide](https://exoridus.github.io/ExoJS/en/guide/effects/lighting/) includes the complete application and the shadow and normal-map workflows. -| Capability | Status | -| ------------------------------------------- | ----------------------------------------------------------------------------------- | -| Point and cone lights on sprites | yes, WebGL2 and WebGPU | -| Lights as scene nodes (parenting, tweens) | yes | -| Light count | `forward`: `maxLights` (default 64); `lightmap` and `radiance`: uncapped | -| Ambient term | yes | -| Emissive surfaces | `forward`, on `LitMaterial`; `radiance` re-emits from lit occluders (`bounce`) | -| Normal maps | `forward`: one per material; `lightmap`: per registered drawable | -| Rotation / flip aware normals | yes, via the instance's local-to-world basis | -| Extra render passes or draw calls | `forward`: none; `lightmap`: two; `radiance`: four to nine; a `post` chain adds one | -| Soft shadows from occluder sources | `lightmap` and `radiance`, WebGL2 and WebGPU | -| Overbright light accumulation | `lightmap`: `rgba16f`, `rgba8` where floats are not renderable | -| Shadows from physics, tilemaps, alpha, mesh | yes, one occluder class per source | -| Filters over the shaded frame (`post`) | yes, in any renderer constructed with `app` | -| Light cookies | `lightmap`, one draw per distinct cookie | -| Line lights (capsule falloff) | yes; `forward` approximates one as a point light | -| Sun lights (parallel shadows) | `lightmap`; `radiance` as the sky every open ray ends in | -| Radiance cascades (propagating light) | `radiance`, WebGL2 and WebGPU, opt-in | -| Bounced light off lit surfaces | `radiance`, one frame late, from lit occluders | -| Deferred (G-buffer) path | no | -| Lit meshes, text, particles, tilemap layers | `forward`: no; `lightmap` and `radiance`: yes, as part of the composed frame | +## Choose deliberately -## Cost +| Model | Main use | Constraint | +| ------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `ForwardLighting` | A `LitMaterial` shades individual sprites. | Capacity-bounded lights; no lightmap shadows or cookies. | +| `LightmapLighting` | Lights, shadows, and optional normal prepass over the composed frame. | Additional targets and passes; normal surfaces must be registered. | +| `RadianceLighting` | Sampled propagation and source-sized penumbrae. | Requires a renderable float target; different image and sampling costs, not a drop-in higher-quality lightmap. | -Forward lighting costs `fragments x active lights`. With everything on screen lit and many overlapping lights the fragment stage becomes the bottleneck well before the CPU does; measure before raising `maxLights` into the dozens on a full-screen scene. +Authored `NormalMap` sources default to the OpenGL tangent-space convention. Set `{ convention: 'directx' }` for the opposite green-channel convention. A normal map changes shading, not the shadow silhouette. -## Core compatibility +Occluder sources can read physics, tilemap, alpha, mesh, or explicit polygon geometry. Rendering an object does not automatically register an occluder. Alpha extraction cannot synchronously trace a GPU-only render texture, and cached silhouettes do not automatically follow arbitrary pixel or mesh deformation. -This package follows the Core lockstep release line and declares the compatible `@codexo/exojs` minor as a peer dependency. Install matching package versions. +The lighting system owns its renderer resources. Registered lights remain owned by their scene tree; supplied occluder sources, post filters, normal sources, and textures remain caller-owned. Uncapped light counts do not mean zero per-light or per-scene cost. -## Links +## Documentation -- [Lighting guide](https://exoridus.github.io/ExoJS/en/guide/) -- [API reference](https://exoridus.github.io/ExoJS/en/api/) +[Lighting guide](https://exoridus.github.io/ExoJS/en/guide/effects/lighting/) · [Lighting API](https://exoridus.github.io/ExoJS/en/api/lighting/) · [Shadow Casters playground](https://exoridus.github.io/ExoJS/en/playground/?example=lighting/shadow-casters) ## License diff --git a/packages/exojs-lighting/src/Lighting.ts b/packages/exojs-lighting/src/Lighting.ts index dcd597b0d..6acf1cea1 100644 --- a/packages/exojs-lighting/src/Lighting.ts +++ b/packages/exojs-lighting/src/Lighting.ts @@ -76,16 +76,20 @@ const scratchPosition = { x: 0, y: 0 }; * renderer turns them into pixels. * * ```ts - * const lighting = new LightmapLighting(app, { ambient: new Color(11, 16, 32) }); + * // In Scene.init(), where the scene's application is attached: + * const lighting = new LightmapLighting(this.app, { ambient: new Color(11, 16, 32) }); * - * scene.systems.add(lighting); + * this.systems.add(lighting); * lighting.add(player.addChild(new PointLight({ radius: 260 }))); * lighting.occludeFrom(new PhysicsOccluder(world)); * ``` * * Construct one of {@link ForwardLighting}, {@link LightmapLighting} or - * {@link RadianceLighting}. They are alternatives rather than layers, and a - * frame is shaded by exactly one of them. This class is what they share: the + * {@link RadianceLighting}. They are alternative lighting models rather than + * layers or quality levels, and a frame is shaded by exactly one of them: + * forward lighting shades materials as they draw, lightmap lighting shades the + * composed frame and can use a registered normal prepass, and radiance lighting + * samples a propagated light field. This class is what they share: the * registries, the collection of occluders, and the update and destroy * contracts. It links no renderer of its own, which is what keeps a project * using one of them from carrying the others. @@ -94,8 +98,8 @@ const scratchPosition = { x: 0, y: 0 }; * * The renderer and its GPU resources. Lights are scene nodes owned by the tree * they hang in - registering one does not transfer ownership, and destroying a - * registered light unregisters it. Occluder sources, filters passed as `post`, - * and the host are the caller's too. + * registered light unregisters it. Occluder and normal sources, filters passed + * as `post`, and the host are the caller's too. * * # Ordering * diff --git a/packages/exojs-particles/README.md b/packages/exojs-particles/README.md index ef925da31..3db9ff983 100644 --- a/packages/exojs-particles/README.md +++ b/packages/exojs-particles/README.md @@ -1,104 +1,68 @@ # @codexo/exojs-particles -Official ExoJS extension for GPU-accelerated particle systems. +Particle emitters, modular simulation, and instanced rendering for ExoJS. Use the package for bounded visual effects rather than treating every particle as an independent gameplay object. -## Installation +## Install and activate ```sh -npm install @codexo/exojs @codexo/exojs-particles +npm install --save-exact @codexo/exojs @codexo/exojs-particles ``` -This package requires `@codexo/exojs` as a peer dependency. Both must be the same version. - -## Core compatibility - -This package follows the Core lockstep release line and declares the compatible `@codexo/exojs` minor as a peer dependency. Install matching package versions. - -## Usage — side-effect-free root entry - -Import `ParticleSystem` and supply it explicitly to your Application via `extensions`: - -```ts -import { Application } from '@codexo/exojs'; -import { ParticleSystem, particlesExtension } from '@codexo/exojs-particles'; - -const app = new Application({ - extensions: [particlesExtension], -}); -``` - -Importing from the root entry (`@codexo/exojs-particles`) does **not** register the extension globally. You control exactly which Applications receive the Particles extension. - -## Extension descriptor - -`particlesExtension` is the default descriptor. Use it when you want the default renderer batch size: - -```ts -import { particlesExtension } from '@codexo/exojs-particles'; - -const app = new Application({ extensions: [particlesExtension] }); -``` - -## Custom batch size via `createParticlesExtension` - -```ts -import { createParticlesExtension } from '@codexo/exojs-particles'; - -const app = new Application({ - extensions: [createParticlesExtension({ batchSize: 8192 })], -}); -``` - -## `ApplicationOptions.extensions` - -Pass any combination of descriptors: - -```ts -const app = new Application({ - extensions: [particlesExtension], -}); -``` +Core is a peer dependency. Add `particlesExtension` to the application that renders the systems; importing the package has no global registration side effect. ## Minimal working example ```ts -import { Application, type RenderingContext, Scene } from '@codexo/exojs'; -import { Constant, ParticleSystem, particlesExtension, RateSpawn } from '@codexo/exojs-particles'; - -const app = new Application({ extensions: [particlesExtension], canvas: { mount: document.body } }); - -class DemoScene extends Scene { - private system!: ParticleSystem; - - override async load(): Promise { - const texture = await this.loader.load('/particle.png'); - - this.system = new ParticleSystem(texture, { capacity: 1024 }); - this.systems.add(this.system); - this.system.addSpawnModule(new RateSpawn({ rate: new Constant(120), lifetime: new Constant(2) })); +import { Application, Color, type RenderingContext, Scene } from '@codexo/exojs'; +import { ConeDirection, Constant, particlesExtension, ParticleSystem, RateSpawn } from '@codexo/exojs-particles'; + +class ParticleScene extends Scene { + private particles!: ParticleSystem; + + override init(): void { + this.particles = new ParticleSystem({ capacity: 512 }); + this.particles.setPosition(400, 300); + this.particles.setScale(4); + this.particles.addSpawnModule( + new RateSpawn({ + rate: new Constant(40), + lifetime: new Constant(2), + velocity: new ConeDirection(-Math.PI / 2, Math.PI / 4, 10, 30), + }), + ); + this.systems.add(this.particles); } override draw(context: RenderingContext): void { - context.render(this.system); + context.render(this.particles); } } -app.start(DemoScene); +const app = new Application({ + scenes: { ParticleScene }, + extensions: [particlesExtension], + canvas: { width: 800, height: 600, mount: 'body' }, + clearColor: Color.black, +}); + +await app.start(ParticleScene); ``` -## WebGL2 and WebGPU support +The no-texture constructor uses a white pixel. Particle positions and velocities are local to the system; scaling the system scales that local effect as well. + +## Before choosing an execution path + +WebGL2 uses CPU simulation. WebGPU can use compute when the attached backend, update modules, and render mode are eligible. Inspect `gpuMode` after attachment and update rather than inferring it from the browser name. -- **WebGL2**: full instanced-draw renderer -- **WebGPU**: compute-shader GPU simulation when a WebGPU device is available; falls back to CPU simulation otherwise +Update modules may change while running. A change from GPU to CPU execution clears live particles because CPU storage does not contain the device's latest integrated state. Capacity is fixed at construction; choose it from rate, lifetime, bursts, and expected peak occupancy. -## Destruction and ownership +A scene system registry advances and destroys a registered system. A system outside such an owner needs explicit cleanup. The texture has its own ownership, and a custom render mode passed to a system is owned by that system; do not share the same owned mode between independent systems. -`ParticleSystem` owns its GPU resources. Call `system.destroy()` when you are done with it. The `Application` or parent scene does not automatically destroy nested systems. +Use `createParticlesExtension({ batchSize })` only when you need a deliberate renderer-batch configuration. Choose that descriptor instead of installing a second particle descriptor beside the default one. -## Links +## Documentation -- [Particles guide](https://exoridus.github.io/ExoJS/en/guide/effects/particles/) -- [API reference](https://exoridus.github.io/ExoJS/en/api/) +[Particles guide](https://exoridus.github.io/ExoJS/en/guide/effects/particles/) · [ParticleSystem API](https://exoridus.github.io/ExoJS/en/api/particle-system/) · [Emitter playground](https://exoridus.github.io/ExoJS/en/playground/?example=particles/emitter-basics) ## License diff --git a/packages/exojs-particles/src/ParticleSystem.ts b/packages/exojs-particles/src/ParticleSystem.ts index 252ba174c..4b1108815 100644 --- a/packages/exojs-particles/src/ParticleSystem.ts +++ b/packages/exojs-particles/src/ParticleSystem.ts @@ -132,14 +132,19 @@ export interface ParticleSystemOptions { * implementing `wgsl()`. * - **Death modules** - fire once per dying particle, before its slot is * recycled (sub-emitters, event hooks). + * - **An explicitly supplied render mode** - destroyed with the system. The + * default mode and the texture are shared and stay the caller's. * - * **Auto-routing CPU vs GPU:** at first {@link update}, the system checks: - * if a `WebGpuBackend` was supplied AND every registered update module has - * `wgsl()` AND the render mode is GPU-eligible, the GPU path engages - a - * composite compute pipeline runs - * integration plus all module bodies in one dispatch and writes directly - * into the renderer's instance buffer (no CPU readback). Otherwise the CPU - * path runs the existing per-module `apply()` loops. + * **Auto-routing CPU vs GPU:** on the first {@link update}, and again after + * any module change, the system checks whether a WebGPU device is available + * (the attached backend's or one passed in the options), every registered + * update module has `wgsl()`, and the render mode is GPU-eligible. If so, a + * composite compute pipeline runs integration plus all module bodies in one + * dispatch and writes directly into the renderer's instance buffer (no CPU + * readback). Otherwise the CPU path runs the per-module `apply()` loops; on + * WebGL2 that is always the case. A module change that forces a running GPU + * simulation back onto the CPU clears the live particles, because the CPU + * holds no copy of the state the device integrated. * * **Per-frame order in {@link update} (CPU mode):** * 1. Run every spawn module. @@ -151,47 +156,48 @@ export interface ParticleSystemOptions { * **Per-frame order in {@link update} (GPU mode):** * 1. Run every spawn module (CPU writes initial values into the spawn slot). * 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules; - * set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU - * shader skips them. **No compaction** - slots are recycled on next spawn. - * 3. Dispatch the composite compute pipeline. Integration + update modules - * + pack-instances run in one pass; the instance buffer is written - * directly. CPU SoA stays as-is for spawn writes. + * mark the slot dead so the GPU shader skips it. **No compaction** - slots + * are recycled on the next spawn. + * 3. Dispatch the composite compute pipeline. Integration, update modules and + * instance packing run in one pass; the instance buffer is written + * directly. + * + * Tick a system from exactly one place: register it with one system registry, + * or call {@link update} yourself, never both. * * **Coordinate space:** particle positions are LOCAL to the system. The - * system's `getGlobalTransform()` is applied on top during rendering - both - * the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`. - * Setting world-space positions on individual particles double-translates. + * system's `getGlobalTransform()` is applied on top during rendering, so + * setting world-space positions on individual particles double-translates. * Position the system itself via `system.setPosition(...)` and emit relative * to `(0, 0)`. * - * **View culling:** a system is created with `cullable = false`. Its local - * bounds cover one texture frame at the local origin, because the particles - * themselves are simulated on the GPU in half the configurations and no - * emitted extent is tracked in either - so culling against those bounds would - * remove the entire cloud as soon as the emitter's own origin left the view. - * For a system whose reach is known, set the node's `cullArea` to a rectangle - * in local space covering where its particles travel and set `cullable = true` - * again; the viewport check then uses that rectangle instead of the bounds. - * `getBounds()` still reports the one-frame box, not an extent of the live - * particles. + * **View culling:** a system is created with `cullable = false`. Its bounds + * cover one texture frame at the local origin, because no emitted extent is + * tracked - so culling against them would remove the entire cloud as soon as + * the emitter's own origin left the view. For a system whose reach is known, + * set the node's `cullArea` to a world-space rectangle covering where its + * particles travel and set `cullable = true` again; the viewport check then + * uses that rectangle instead of the bounds. `getBounds()` still reports the + * one-frame box, not an extent of the live particles. * - * **Pixel snapping:** {@link Drawable.pixelSnapMode} is intentionally ignored - * for particle systems. Particle instances bake their own per-particle - * transforms in the emitter/compute path rather than reading the shared - * pixel-snap transform row, so a snap mode set on the system has no effect on - * rendered output - snapping thousands of independently-moving sub-pixel - * particles to the device grid is neither meaningful nor desirable. + * **Pixel snapping:** {@link Drawable.pixelSnapMode} has no effect on particle + * systems. Particle instances bake their own per-particle transforms rather + * than reading the shared pixel-snap transform, and snapping thousands of + * independently moving sub-pixel particles to the device grid is not + * meaningful. * * @example - * // Backend-agnostic - runs CPU on WebGL2, GPU on WebGPU automatically. + * ```ts + * // Backend-agnostic - runs on the CPU on WebGL2, on the GPU on WebGPU when eligible. * const system = new ParticleSystem(loader.get('spark.png'), { - * capacity: 8192, + * capacity: 8192, * }); * * system.addSpawnModule(new RateSpawn({ rate: new Constant(60), ... })); - * system.addUpdateModule(new ApplyForce(0, 980)); // gravity, GPU-eligible + * system.addUpdateModule(new ApplyForce(0, 980)); // gravity, GPU-eligible * system.addUpdateModule(new ColorOverLifetime(fireGradient)); - * scene.addChild(system); + * scene.root.addChild(system); + * ``` */ export class ParticleSystem extends Drawable implements ParticleEmitter { /** Maximum particle count this system will store. Fixed at construction. */ diff --git a/packages/exojs-pathfinding/README.md b/packages/exojs-pathfinding/README.md index 9a42586fd..250f6eac2 100644 --- a/packages/exojs-pathfinding/README.md +++ b/packages/exojs-pathfinding/README.md @@ -1,136 +1,45 @@ # @codexo/exojs-pathfinding -Official ExoJS extension for 2D pathfinding. One search core - A\* with jump-point pruning - over pluggable navigation spaces: weighted grids for top-down worlds, waypoint graphs for platformers and for graphs that have no geometry at all. +Weighted-grid and waypoint-graph path queries for ExoJS. Use it to find a route through your navigation model; movement, collision response, animation, and special traversal remain gameplay responsibilities. -It is plain logic. No scene node, no renderer, no asset type, no registration step: you construct a space and a `Pathfinder`, and you own both. - -## Installation +## Install ```sh -npm install @codexo/exojs @codexo/exojs-pathfinding +npm install --save-exact @codexo/exojs @codexo/exojs-pathfinding ``` -`@codexo/exojs` is a peer dependency and the only one. In particular this package does **not** depend on `@codexo/exojs-tilemap`: feeding a tilemap into a grid is three lines of your code, shown below. - -## What this package provides - -- `Pathfinder` - runs the queries and owns the reusable search buffers. -- `GridSpace` - a finite window of weighted cells, with diagonal policies, per-cell costs, clearance for wide agents, string-pulling path smoothing, and a jump-point fast path. -- `WaypointGraph` - a directed graph of hand-placed nodes whose edges carry a `kind` and a payload, so a path can tell a jump from a walk. -- `NavigationSpace` - the interface both implement, and the one your own space implements when neither fits. - -## Capability matrix - -| Capability | `GridSpace` | `WaypointGraph` | -| ----------------------------------- | ------------------------------------------- | -------------------------------- | -| Per-step costs | per cell, `0` blocks | per edge | -| Heuristic | octile / Manhattan, scaled by cheapest cell | straight-line distance | -| Directed traversal | no - movement is symmetric | yes | -| Traversal kinds (`walk`/`jump`/...) | no | yes, with an arbitrary payload | -| Positionless (pure Dijkstra) | no | yes, when a node has no position | -| Clearance / wide agents | yes, `agentSize` | no | -| Path smoothing | yes, string pulling | no | -| Jump-point pruning | yes, on a uniform-cost grid | no | -| Mutable at runtime | `setCost` | `addNode`/`addEdge`/`remove*` | +This is a directly used library. It has no application extension descriptor or global registration step. -## Usage +## Find a route on a grid ```ts import { GridSpace, Pathfinder } from '@codexo/exojs-pathfinding'; -const grid = GridSpace.from(64, 64, (x, y) => (isWall(x, y) ? 0 : terrainCost(x, y)), { +const grid = GridSpace.from(12, 8, (x, y) => (x === 5 && y !== 4 ? 0 : 1), { cellSize: 32, }); const pathfinder = new Pathfinder(); +const route = pathfinder.findPathBetween(grid, 16, 16, 336, 208); -const result = pathfinder.findPathBetween(grid, hero.x, hero.y, target.x, target.y, { - smooth: true, -}); - -if (result.status === 'found') { - hero.follow(result.points); -} -``` - -`findPath` takes node ids, `findPathBetween` takes world coordinates. Both return the same `PathResult`, and `status` is a value rather than an exception, because "no path" is an ordinary game state: - -| `status` | Meaning | -| ----------------- | -------------------------------------------------------------------- | -| `found` | Complete, cost-optimal path. | -| `unreachable` | The search exhausted the space. Empty path, unless `snapToNearest`. | -| `budget-exceeded` | `maxExpandedNodes` ran out. The best partial path is still returned. | - -`result.revision` records `space.revision` at search time, so a follower can notice that the world changed under its path and ask for a new one. - -## Grids - -Coordinates are absolute cell coordinates - the same numbers your map uses - not offsets into the window. The window itself is finite by construction, and everything outside it is blocked; that is the answer for infinite or streamed maps: size the window to the region the actors are in, and push chunk changes into it with `setCost`. - -```ts -// Streamed tilemap, no package dependency in either direction. -const window = GridSpace.from(96, 96, (x, y) => walkCost(map.getTileAt(layerId, x, y)), { - originX: chunkX * 32, - originY: chunkY * 32, - cellSize: map.tileWidth, -}); - -map.onTileChanged(({ x, y, tile }) => window.setCost(x, y, walkCost(tile))); -``` - -Cost `0` blocks a cell, `1` is ordinary ground, larger values are terrain the search routes around when the detour is cheaper. Diagonal steps cost their length, and the default diagonal policy (`'no-corner-cutting'`) forbids the diagonal that would clip through the corner where two walls meet. - -`agentSize` restricts a query to cells where an agent that many cells wide fits. Clearance is anchored at a cell's **top-left** corner, so the last row and column of a window can never hold an agent wider than one cell. - -## Jump-point search - -`GridSpace` substitutes jump-point search for plain neighbour expansion whenever the grid is uniform-cost, `agentSize` is `1`, and the diagonal policy is the default. It returns the same cost-optimal path while expanding a fraction of the nodes; `result.expandedNodes` shows the difference. Nothing has to be switched on, and `{ pruning: false }` switches it off. - -Because pruning settles only jump points, a `budget-exceeded` partial path under pruning ends on a jump point rather than on the nearest cell. `snapToNearest` is unaffected: when the goal turns out to be unreachable, the query re-runs unpruned so the snapped node really is the closest one. - -## Waypoint graphs - -```ts -import { Pathfinder, WaypointGraph } from '@codexo/exojs-pathfinding'; - -interface Move { - readonly impulse: number; -} - -const graph = new WaypointGraph(); -const ledge = graph.addNode(120, 400); -const platform = graph.addNode(320, 260); - -graph.connect(ledge, platform, { kind: 'jump', data: { impulse: 520 }, cost: 40 }); - -for (const step of new Pathfinder().findPath(graph, ledge, platform).edges) { - controller.execute(step.kind, step.data); +if (route.status === 'found') { + console.log(route.points); } ``` -Node positions are optional. Leave them out and the heuristic drops to zero, which turns the same search into plain Dijkstra over an abstract graph - the shape a web application's routing problem usually has. - -## Reachable-area queries - -`floodFrom` returns every node within a cost budget, cheapest first: the "tiles I can still reach with the movement points I have left" query, and the input a flow field would be built from. +Zero-cost cells are blocked; positive costs describe traversal weight. `findPathBetween` takes the navigation space followed by the start and goal world coordinates, whereas node-oriented queries use navigation-node identifiers. Handle an unreachable or budget-limited result explicitly instead of moving along an assumed route. -```ts -const region = pathfinder.floodFrom(grid, grid.nodeAt(unit.tileX, unit.tileY), { maxCost: 6 }); - -for (const node of region.nodes) { - highlight(grid.nodeX(node), grid.nodeY(node)); -} -``` +## Choose the navigation model -## Determinism and allocation +A grid is useful when occupancy and costs follow regular cells. A waypoint graph is useful for authored connections, directed travel, or sparse routes. An edge tagged as a ladder or teleport is metadata: the pathfinder does not animate climbing or execute the teleport. -Equal-cost paths are resolved by a pinned tie-break (lower node id first), so the same query on an unmutated space returns the identical path on every machine and every run - which is what makes paths safe to record in a replay or assert in a test. +A path is valid for the navigation revision it was computed from. Update or rebuild the appropriate navigation data when obstacles or costs change, then invalidate stale results and in-flight searches. Repeated deterministic inputs in one controlled environment are useful for testing; do not infer cross-build or cross-machine lockstep guarantees from a fixed traversal order. -A `Pathfinder` owns its search state and reuses it across queries, including queries against different spaces of different sizes. Once the buffers have grown to fit, a search allocates nothing that scales with the number of nodes it visits. The result object is allocated fresh every time, deliberately: callers keep paths, and pooling something a caller keeps buys a little garbage back at the price of use-after-reuse bugs. +Query budgets bound search work, not a guaranteed number of milliseconds. Keep planning separate from the controller that follows a path, and include the agent's clearance and collision policy in the navigation model rather than treating a line through walkable cells as a complete movement solution. -## Custom spaces +## Documentation -Implement `NavigationSpace` and every query in this package works against it. `neighbors` receives the pathfinder's own buffers instead of returning an array, so a third-party space is allocation-free on the same terms as the built-in ones. +[Grid pathfinding](https://exoridus.github.io/ExoJS/en/guide/pathfinding/grid-pathfinding/) · [Waypoint graphs](https://exoridus.github.io/ExoJS/en/guide/pathfinding/waypoint-graphs/) · [Pathfinder API](https://exoridus.github.io/ExoJS/en/api/pathfinder/) ## License -MIT +MIT © Codexo diff --git a/packages/exojs-physics/README.md b/packages/exojs-physics/README.md index a71db94d1..1c001f64b 100644 --- a/packages/exojs-physics/README.md +++ b/packages/exojs-physics/README.md @@ -1,84 +1,61 @@ # @codexo/exojs-physics -Native 2D **rigid-body** runtime for [ExoJS](https://github.com/Exoridus/ExoJS). +A TypeScript 2D rigid-body runtime for ExoJS: bodies, colliders, joints, sensors, contact policy, spatial queries, and scene-node binding. -Zero production dependencies, ESM-only, version-locked with the core engine. It ships a fixed-step world with a warm-started **TGS-Soft** solver: shapes, colliders, bodies, a dynamic-AABB broad phase, a manifold-generating narrow phase, joints, sleeping islands, continuous collision for fast bodies, a per-contact modifier, collision filters, sensors, events, spatial queries, scene-node binding with interpolation, and a debug overlay. - -> **Library, not an extension.** Physics contributes no renderer or asset bindings, so there is no `/register` entry. Construct a `PhysicsWorld` directly. `@codexo/exojs` is a peer dependency. +Use it for simulated motion and collision response. Core's geometric queries are sufficient for simpler hit tests and overlaps. This package contributes no application extension descriptor: construct a `PhysicsWorld` and give it a scene or another explicit owner. ## Install ```sh -npm install @codexo/exojs @codexo/exojs-physics +npm install --save-exact @codexo/exojs @codexo/exojs-physics ``` -## Quick start +Core is a peer dependency. Keep the packages on a compatible release line. + +## Start a scene-owned world ```ts -import { Scene, type Seconds, Vector } from '@codexo/exojs'; -import { BoxShape, CircleShape, Collider, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; - -class GameScene extends Scene { - private readonly world = new PhysicsWorld({ gravity: new Vector(0, 980) }); - - public override onStart(): void { - // Construct bodies/colliders freely, then hand them to the world: `add` - // assigns ids, registers the colliders and aggregates the mass model. - // Static ground (an explicit static body + box collider). - this.world.add( - new PhysicsBody({ type: 'static', position: new Vector(400, 600), colliders: [new Collider({ shape: new BoxShape(800, 32), friction: 0.9 })] }), +import { Scene, SystemOrder } from '@codexo/exojs'; +import { BoxShape, Collider, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +export class GameScene extends Scene { + override init(): void { + const world = new PhysicsWorld({ gravity: { x: 0, y: 980 } }); + + this.systems.add(world, { order: SystemOrder.Physics }); + world.add( + new PhysicsBody({ + type: 'static', + position: { x: 400, y: 560 }, + colliders: [new Collider({ shape: new BoxShape(700, 32) })], + }), + ); + world.add( + new PhysicsBody({ + type: 'dynamic', + position: { x: 400, y: 100 }, + colliders: [new Collider({ shape: new BoxShape(40, 40) })], + }), ); - - // A kinematic platform you move yourself. Attach more colliders any time. - const platform = new PhysicsBody({ type: 'kinematic', position: new Vector(200, 400) }); - platform.addCollider(new Collider({ shape: new BoxShape(120, 16) })); - this.world.add(platform); - - // A sensor trigger. - const triggerCollider = new Collider({ shape: new CircleShape(40), isSensor: true }); - this.world.add(new PhysicsBody({ type: 'static', position: new Vector(600, 500), colliders: [triggerCollider] })); - this.world.onSensorEnter.add(({ sensor }) => { - if (sensor === triggerCollider) console.log('entered the trigger'); - }); - } - - public override update(delta: Seconds): void { - this.world.step(delta); // fixed-step detection + events + binding } } ``` -## What it does +The scene registry drives and destroys this world. The snippet creates simulation only; bodies do not draw themselves. Use `world.attach(node, definition)` or `world.bind(body, node)` to connect visible nodes, and render them from the scene's `draw` hook. The [Physics guide](https://exoridus.github.io/ExoJS/en/guide/physics/physics-basics/) supplies a complete, asset-free falling-box application. -| Area | API | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| World | `PhysicsWorld`, `step`, `gravity`, `timeStepper`, `destroy` | -| Bodies | `new PhysicsBody` + `world.add` (`dynamic`/`static`/`kinematic`), `setTransform`, mass/inertia from colliders | -| Colliders | `new Collider` + `body.addCollider` / `colliders: [...]`, density/friction/restitution, `isSensor`, filter, offset | -| Attach | `world.attach(node, def)` — body + collider + `bind` in one call | -| Shapes | solid: `CircleShape`, `CapsuleShape`, `PolygonShape` (convex-validated), `BoxShape`; boundary: `SegmentShape`, `ChainShape` | -| Concave outlines | `toConvexPolygonShapes(vertices)` — one possibly-concave outline into the convex `PolygonShape`s for a single body | -| Dynamics | fixed-step TGS-Soft solver, gravity, forces/impulses, friction/restitution, sleeping islands | -| Joints | `DistanceJoint`, `RevoluteJoint`, `PrismaticJoint`, `WheelJoint`, `WeldJoint`, `MouseJoint` | -| Continuous collision | `body.isBullet` — exact translational shape cast of the whole shape, not just its centre | -| Contact policy | `world.contactModifier` — per-contact material/enable decisions (one-way platforms, conditional friction) | -| Filtering | `CollisionFilter` (category/mask/group), `shouldCollide` | -| Events | `onCollisionStart` / `onCollisionEnd` / `onSensorEnter` / `onSensorExit` — immutable snapshots | -| Queries | `queryPoint`, `queryAabb` (+ `out` / `forEachAabbHit`), `rayCast`, `rayCastAll`, `overlapShape` | -| Binding | `bind(body, node)` — node tracks the body's position each step | -| Debug | `@codexo/exojs-physics/debug` → `PhysicsDebugDraw` (shapes/AABBs/contacts/normals/centres/broad-phase/joints) | +## Important boundaries -Building a level out of a tilemap? `@codexo/exojs-tilemap-physics` turns `@codexo/exojs-tilemap` collision geometry into static bodies and keeps them in sync with streamed chunks. +Choose one stepping clock. A world registered in `scene.systems` is stepped by the host; do not also call `step()`. An independently hosted world can use `step(frameDeltaSeconds)` and its own accumulator. -## Determinism & non-goals +Physics angles use radians and clockwise-positive screen rotation. Scene-node rotation uses degrees with the opposite screen sign; the built-in binding performs the conversion. Interpolation affects bound-node presentation, not the number of collision steps. -Stepping is fully **caller-driven** and uses a fixed timestep with an accumulator (`world.step(frameDeltaSeconds)`); the same build replays a scene identically given the same inputs. There are **no rollback/lockstep determinism guarantees across builds or machines** (floating-point reality). The package is single-threaded and 2D only — no workers, GPU, 3D, soft bodies, fluids or vehicles. +Continuous collision covers supported translational shape casts, not unrestricted rotational sweeping. Boundary-only geometry is not a solid dynamic mass. Fixed timesteps do not promise cross-build or cross-machine lockstep determinism, and performance depends on the actual contacts, shapes, and workload. -`step()` owns its own fixed-timestep accumulator, so you can drive it from either the engine's `Scene.fixedUpdate` (already a constant-rate hook — the idiomatic choice) or straight from `Scene.update`'s raw, variable per-frame delta; either way `step` converts whatever it's given into the right number of fixed sub-steps. See the "Stepping the world" section of the [physics guide](https://exoridus.github.io/ExoJS/en/guide/physics/physics-basics/) for the details and an interpolation note (`world.timeStepper.alpha`). +For tilemap collision, use [`@codexo/exojs-tilemap-physics`](https://github.com/Exoridus/ExoJS/tree/next/packages/exojs-tilemap-physics). For diagnostic drawing, use the package's `@codexo/exojs-physics/debug` entry point. -**Broad-phase scale.** Collision detection uses a dynamic AABB tree (Box2D-style), incrementally updated across steps: a collider whose AABB stays within its stored margin is never reinserted, so the dominant cost tracks how much actually moved rather than the total live collider count (there's still a cheap linear pass over all live colliders each step). Scales to tens of thousands of simultaneously-live colliders. +## Documentation -**Solid and boundary geometry.** `CircleShape`, `CapsuleShape`, `PolygonShape` and `BoxShape` enclose an area and carry mass; `SegmentShape` and `ChainShape` are boundaries with no interior, so they contribute collision only and a `dynamic` body needs at least one solid collider alongside them. A chain is one authored collider that the engine solves edge by edge with shared-vertex adjacency, so a body slides across a seam without snagging. Two boundaries never collide with each other, and a boundary is never the _moving_ operand of a continuous shape cast — level structure is swept against, not swept. +[Physics guide](https://exoridus.github.io/ExoJS/en/guide/physics/physics-basics/) · [Joints and contact behavior](https://exoridus.github.io/ExoJS/en/guide/physics/joints-and-dynamics/) · [PhysicsWorld API](https://exoridus.github.io/ExoJS/en/api/physics-world/) · [Drag and Throw playground](https://exoridus.github.io/ExoJS/en/playground/?example=physics/sprite-follows-body) ## License diff --git a/packages/exojs-react/README.md b/packages/exojs-react/README.md index efb2f2e79..3b5f9b2a4 100644 --- a/packages/exojs-react/README.md +++ b/packages/exojs-react/README.md @@ -5,7 +5,7 @@ React 18 / 19 bindings for [ExoJS](https://exoridus.github.io/ExoJS/) - mount an ## Installation ```sh -npm install @codexo/exojs @codexo/exojs-react react +npm install --save-exact @codexo/exojs @codexo/exojs-react react react-dom ``` `@codexo/exojs` and `react` (>= 18) are peer dependencies; `react-dom` is an optional peer. The package ships pre-built ESM (`dist/esm`) with type declarations and works on both `@types/react` 18 and 19. @@ -19,44 +19,73 @@ This package is intentionally layered: ## Quick start — `` +The scene classes in `./scenes` are ordinary ExoJS scenes. Register their constructors in the application options; the React `` declarations select from that registry rather than registering engine scenes themselves. + ```tsx -import { Color } from '@codexo/exojs'; -import { ExoCanvas, Scenes, Scene, useExoApp } from '@codexo/exojs-react'; -import { TitleScene, GameScene } from './scenes'; +import { Color, FadeSceneTransition, FixedResolutionCanvasSizing, Time } from '@codexo/exojs'; +import { ExoCanvas, Scene, Scenes } from '@codexo/exojs-react'; +import { useState } from 'react'; + +import { GameScene, TitleScene } from './scenes'; + +const options = { + scenes: { TitleScene, GameScene }, + canvas: { width: 1280, height: 720, sizing: new FixedResolutionCanvasSizing() }, + clearColor: Color.black, +}; +const transition = new FadeSceneTransition({ duration: Time.seconds(0.3) }); + +export function Game() { + const [active, setActive] = useState('title'); -function Game() { return ( - - + + - - {/* absolutely-positioned React overlay, over the canvas */} - + + ); } - -function Hud() { - const app = useExoApp(); - return
FPS overlay…
; -} ``` -Layout props (`style`, `className`, …) apply to the **wrapper**; size it to drive `'fill'`/`'letterbox'` sizing. Style the canvas itself via `canvasProps`. +The first activation starts the engine; the transition applies to subsequent scene switches. Scene-load failures reach `onError`. For a production application, replace the console handler with the error presentation your interface needs. + +Layout props (`style`, `className`, and other div attributes) apply to the **wrapper**. Give it a non-zero size and let the `FixedResolutionCanvasSizing` policy fit the logical canvas inside it. Use `canvasProps` for canvas attributes, but do not override the dimensions or styles owned by a sizing policy. -## Quick start — headless hook (full control) +### Headless hook: your own canvas + +The hook owns the application and its teardown, but does not choose a scene. Start a registered scene in `onReady`: ```tsx import { useExoApplication } from '@codexo/exojs-react'; -function Game() { - const { app, canvasRef } = useExoApplication({ canvas: { width: 800, height: 600 } }); - // Render the canvas however and wherever you want. - return ; +import { GameScene } from './scenes'; + +const options = { scenes: { GameScene }, canvas: { width: 800, height: 600 } }; + +export function Game() { + const { canvasRef } = useExoApplication( + options, + app => { + void app.start(GameScene).catch(console.error); + }, + console.error, + ); + return ; } ``` +These are alternative hosting patterns, not two components to mount for one application. Use `` for context and React overlays; use the hook for direct control over the canvas element. + ## API | Export | Kind | Purpose | @@ -82,6 +111,11 @@ Options without a live setter (`canvas.pixelRatio`, `seed`, `extensions`, …) a `canvas.sizing` is captured at creation as well: a sizing policy is an object, so a fresh instance on every render would detach and re-attach the previous one each time. Assign `app.sizing` yourself to switch strategies at runtime. +## Learn more + +- [React integration guide](https://exoridus.github.io/ExoJS/en/guide/integrations/react/) +- [Scene lifetimes and navigation](https://exoridus.github.io/ExoJS/en/guide/runtime/scenes-and-lifecycle/) + ## License MIT © Codexo diff --git a/packages/exojs-tiled/README.md b/packages/exojs-tiled/README.md index 29f62e943..6dee5cac8 100644 --- a/packages/exojs-tiled/README.md +++ b/packages/exojs-tiled/README.md @@ -1,163 +1,61 @@ # @codexo/exojs-tiled -Official ExoJS extension for loading [Tiled](https://mapeditor.org) maps (`.tmj` JSON format) into a generic runtime `TileMap` or a typed parsed source model. +Load Tiled JSON maps into ExoJS's format-neutral tilemap runtime. Use this adapter for `.tmj` maps and their tilesets; use `@codexo/exojs-tilemap` alone for maps created directly in code. -## Installation +## Install ```sh -npm install @codexo/exojs @codexo/exojs-tilemap @codexo/exojs-tiled +npm install --save-exact @codexo/exojs @codexo/exojs-tilemap @codexo/exojs-tiled ``` -Both `@codexo/exojs` and `@codexo/exojs-tilemap` are **peer** dependencies, so install them explicitly alongside the adapter. Nothing is pulled in transitively: strict package managers (pnpm, Yarn PnP) will not resolve an unlisted peer, and npm's auto-install of peers still leaves the versions outside your control. Keep the engine and every adapter on the same version. +Core and the tilemap runtime are peer dependencies. Install them explicitly and keep them on the adapter's compatible release line. -If you want the generic tilemap runtime without the Tiled adapter: +## Load and render a map -```sh -npm install @codexo/exojs @codexo/exojs-tilemap -``` - -## What this package provides - -- `TileMap` (re-exported from `@codexo/exojs-tilemap`) — generic runtime tilemap; the common-case result of `loader.load(Asset.type('tileMap', url))` -- `TileMapNode` / `TileLayerNode` (re-exported from `@codexo/exojs-tilemap`) — scene nodes that render a loaded `TileMap` on WebGL2/WebGPU -- `TileMapView` / `TileMapBand` (re-exported from `@codexo/exojs-tilemap`) — group a map's layers into independently placeable bands for interleaving actors between tile layers; same class identity, so `instanceof` holds across both import paths (the canonical view/band docs live in the [`@codexo/exojs-tilemap` README](https://www.npmjs.com/package/@codexo/exojs-tilemap)) -- `TiledMap` — parsed Tiled source model; advanced/diagnostic use via `loader.load(Asset.type('tiledSource', url))` -- `TiledTileset` — parsed tileset (atlas-image or collection-of-images); holds resolved textures -- `TiledLayer` hierarchy — `TiledTileLayer`, `TiledObjectLayer`, `TiledImageLayer`, `TiledGroupLayer` -- `TiledObject` — parsed object (point, ellipse, polygon, polyline, text, tile-ref, rectangle) -- `TiledFormatError` — typed error thrown on any structural problem in `.tmj`/`.tsj` data -- `tiledExtension` — extension descriptor; depends on `tilemapExtension` automatically - -## Usage — common case - -Register the extension, load a `.tmj` map into a generic runtime `TileMap`, and render it. One extension enables **both** loading and rendering — `tiledExtension` depends on `tilemapExtension`, so the tile chunk renderer bindings are materialised automatically (no manual `tilemapExtension` registration): - -```ts -import { Application, Asset } from '@codexo/exojs'; -import { TileMap, TileMapNode, tiledExtension } from '@codexo/exojs-tiled'; - -const app = new Application({ extensions: [tiledExtension] }); - -const map = await app.loader.load(Asset.type('tileMap', 'maps/world.tmj')); -// map is a @codexo/exojs-tilemap TileMap - -app.scenes.root.addChild(new TileMapNode(map)); -``` - -`TileMapNode` and `TileLayerNode` are the same classes exported by `@codexo/exojs-tilemap` (see its [README](https://www.npmjs.com/package/@codexo/exojs-tilemap) for the rendering/culling model and actor interleaving). `instanceof TileMap` holds across both import paths. - -## Usage — advanced parsed-source case - -Load the fully resolved Tiled source model and convert it manually: - -```ts -import { Asset } from '@codexo/exojs'; - -const source = await app.loader.load(Asset.type('tiledSource', 'maps/world.tmj')); -const map = source.toTileMap(); -``` - -Both paths are semantically equivalent. The runtime binding (`TileMap`) uses the Loader-managed source-model sub-load internally, so concurrent or duplicate loads are deduplicated. - -## Extension dependency - -`tiledExtension.dependencies` includes `tilemapExtension` from `@codexo/exojs-tilemap`. Passing `tiledExtension` to `ApplicationOptions.extensions` is sufficient — `buildSnapshot` traverses the dependency graph automatically. - -## Asset loading - -`loader.load(Asset.type('tileMap', url))` (common path) and `loader.load(Asset.type('tiledSource', url))` (advanced path) both: - -1. Fetch and validate the `.tmj` file. -2. Resolve each tileset entry (fetches external `.tsj` files via the Loader cache). -3. Load atlas images (`tileset.image`) and per-tile images (collection-of-images tilesets) via `loader.load(imageUrl)` — the Loader deduplicates identical URLs. -4. Validate GID ranges (no duplicates, no overlaps, all layer GIDs covered) — throws `TiledFormatError` on any inconsistency. - -The runtime binding additionally calls `TiledMap.toTileMap()` to produce the generic `TileMap`. - -### Load options +`tiledExtension` installs the map loader and depends on `tilemapExtension`, which supplies rendering. Importing the package alone does not activate it. ```ts -// `.tmj`/`.tsj` are recognised by extension; a format hint is only needed for -// Tiled data served from a generic `.json` path: -await loader.load(Asset.type('tileMap', 'maps/world.json', { format: 'tiled' })); -``` +import { Application, Asset, Scene, type RenderingContext } from '@codexo/exojs'; +import { TileMapNode, tiledExtension } from '@codexo/exojs-tiled'; -| Option | Type | Default | Description | -| -------- | --------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `format` | `'tiled'` | `'tiled'` | Format hint for ambiguous `.json` paths. `.tmj`/`.tsj` are recognised by extension. `'tiled'` is the only accepted value (a foreign format is a compile error). Participates in the asset identity key. | +class MapScene extends Scene { + override async load(): Promise { + const map = await this.loader.load(Asset.type('tileMap', 'maps/world.tmj')); -Options are optional. Parsing is always strict: `validateTiledMapData` throws a `TiledFormatError` on any malformed _known_ field, and silently preserves _unknown_ fields (so real-world Tiled files using features ExoJS does not model still load). + this.root.addChild(new TileMapNode(map)); + } -## Parsed API overview + override draw(context: RenderingContext): void { + context.render(this.root); + } +} -### `TiledMap` +const app = new Application({ + scenes: { MapScene }, + extensions: [tiledExtension], + canvas: { width: 800, height: 600, mount: 'body' }, + loader: { basePath: new URL('assets/', document.baseURI).href }, +}); -```ts -map.source; // resolved URL this map was loaded from -map.width; // map width in tiles -map.height; // map height in tiles -map.tileWidth; // tile grid cell width in pixels -map.tileHeight; // tile grid cell height in pixels -map.orientation; // 'orthogonal' | 'isometric' | 'staggered' | 'hexagonal' -map.renderOrder; // 'right-down' | 'right-up' | 'left-down' | 'left-up' | undefined -map.infinite; // true for infinite maps (layers use chunks, not flat data) -map.backgroundColor; // optional CSS color string -map.layers; // TiledLayer[] — parsed layer hierarchy -map.tilesets; // TiledTileset[] — sorted by firstGid ascending -map.properties; // TiledPropertyData[] — custom properties -map.findTilesetForGid(gid); // → TiledTileset | undefined (masks flip bits automatically) -map.getProperty(name); // → TiledPropertyData | undefined -map.toTileMap(); // → TileMap — synchronous runtime conversion -map.destroy(); // no-op; textures are Loader-owned +await app.start(MapScene); ``` -### `TiledTileset` - -```ts -tileset.firstGid; // first GID in this tileset's range (inclusive) -tileset.lastGid; // last GID in this tileset's range (inclusive) -tileset.name; -tileset.tileWidth / tileHeight; -tileset.tileCount / columns / spacing / margin; -tileset.source; // resolved .tsj URL (undefined for embedded tilesets) -tileset.imageUrl; // resolved atlas image URL (undefined for collection-of-images) -tileset.texture; // Texture loaded for imageUrl (Loader-owned) -tileset.tileTextures; // Map for collection-of-images tilesets (Loader-owned) -tileset.tiles; // TiledTileData[] — per-tile animation/property/collision data -tileset.getTile(localId); // → TiledTileData | undefined -tileset.getProperty(name); // → TiledPropertyData | undefined -``` - -### `TiledLayer` subclasses - -All layers extend `TiledLayer` (base: `id`, `name`, `class`, `visible`, `opacity`, `x`, `y`, `offsetX/Y`, `parallaxX/Y`, `tintColor`, `properties`, `getProperty(name)`). - -| Subclass | `type` | Extra fields | -| ------------------ | --------------- | ------------------------------------------------------------------- | -| `TiledTileLayer` | `'tilelayer'` | `width`, `height`, `data?: number[]` (finite), `chunks?` (infinite) | -| `TiledObjectLayer` | `'objectgroup'` | `drawOrder`, `objects: TiledObject[]` | -| `TiledImageLayer` | `'imagelayer'` | `image`, `repeatX`, `repeatY` | -| `TiledGroupLayer` | `'group'` | `layers: TiledLayer[]` | - -### `TiledObject` - -Shape discriminants: `point` (boolean), `ellipse` (boolean), `polygon`, `polyline`, `text`, `gid` (tile object). If none are set, the object is a plain rectangle. - -`TiledObject.type` is the object's **class**, normalised across Tiled versions: 1.9 wrote it as `class` in the JSON, every other version as `type`. A file carries one of the two, so `class` wins when present and non-empty. That string is also the dispatch key a [`MapObjectSpawner`](https://www.npmjs.com/package/@codexo/exojs-tilemap) sees. +Serve `maps/world.tmj` and the files it references below the configured asset base URL. The ordinary `tileMap` load produces a generic `TileMap`; `tiledSource` instead produces the parsed `TiledMap` for inspection or explicit `toTileMap()` conversion. -## Texture ownership +## Before using an authored map -Textures for tileset images are loaded via the Loader and remain in the Loader cache. `TiledMap.destroy()` releases the parsed source model's reference but does **not** unload textures. The Loader handles texture lifecycle (including deduplication across maps that share tilesets). +The adapter reads JSON (`.tmj` / `.tsj`), not Tiled's XML export. Loading validates known fields and resolves referenced tilesets and images. A parsed source feature is not automatically a rendered gameplay feature: object layers contain data until your code spawns objects or creates colliders. Infinite maps require a chunk-streaming policy; loading the source document alone does not keep every tile resident. -## Core compatibility +Tileset resources acquired through the loader have loader-managed claims and dependencies. Do not manually destroy a shared tileset texture to unload one map. Give the map a scene or shorter-lived loader scope, and clean up the scene nodes that display it before releasing their required resources. -This package follows the Core lockstep release line. Its `@codexo/exojs` and `@codexo/exojs-tilemap` peer dependencies require the matching minor release. +`TileMap`, `TileMapNode`, `TileMapView`, and the other runtime re-exports are the same bindings as in `@codexo/exojs-tilemap`, not independent adapter-specific classes. -## Links +## Learn more -- [Tiled maps guide](https://exoridus.github.io/ExoJS/en/guide/assets/tiled-maps/) -- [API reference](https://exoridus.github.io/ExoJS/en/api/) -- [Tiled map editor](https://mapeditor.org) +- [Tiled maps guide](https://exoridus.github.io/ExoJS/en/guide/assets/tiled-maps/) explains the normal import workflow and format boundaries. +- [Infinite maps](https://exoridus.github.io/ExoJS/en/guide/rendering/infinite-maps/) explains chunk sources and residency. +- [Worlds and spawning](https://exoridus.github.io/ExoJS/en/guide/assets/worlds-and-spawning/) turns authored objects into owned game objects. +- [API reference](https://exoridus.github.io/ExoJS/en/api/tiled-map/) documents the parsed model; [TileMap](https://exoridus.github.io/ExoJS/en/api/tile-map/) documents the runtime model. ## License diff --git a/scripts/ci/select-lanes.ts b/scripts/ci/select-lanes.ts index 93d0e6400..e69e77cf2 100644 --- a/scripts/ci/select-lanes.ts +++ b/scripts/ci/select-lanes.ts @@ -260,6 +260,18 @@ const isBenchStructuralPath = (file: string): boolean => { */ const isGuidesPath = (file: string): boolean => file.startsWith('site/src/content/'); +/** + * READMEs that promise complete, typechecked TypeScript examples rather than + * caller-owned fragments. `test/site/readme-examples.test.ts` reads this list, + * so adding a README there also routes its changes to the unit lane. + */ +export const CHECKED_README_PATHS: readonly string[] = [ + 'README.md', + ...['exojs-physics', 'exojs-particles', 'exojs-lighting', 'exojs-tiled', 'exojs-ldtk', 'exojs-aseprite', 'exojs-audio-fx', 'exojs-pathfinding'].map( + name => `packages/${name}/README.md`, + ), +]; + /** * Site-data area: the sources the remaining `test/site/**` suites read. Same * reasoning as `isGuidesPath` - those suites live under `test/`, so they run on @@ -284,10 +296,18 @@ const isGuidesPath = (file: string): boolean => file.startsWith('site/src/conten * profile-only commit reaches no other * area that runs a test. * + * - `CHECKED_README_PATHS` the READMEs whose complete examples + * `test/site/readme-examples` typechecks. + * Read before the prose exemption, or a + * README-only change - the one that can + * break those examples - would never run + * the suite that guards them. + * * Deliberately not `site/src/pages/` or `site/src/components/`: no suite reads * them, and the site build already gates on the wider `site` area. */ const isSiteDataPath = (file: string): boolean => { + if (CHECKED_README_PATHS.includes(file)) return true; if (isDocPath(file)) return false; if (file.startsWith('site/src/lib/')) return true; if (file.startsWith('examples/')) return true; diff --git a/site/README.md b/site/README.md index e4946cf11..3c9f9971a 100644 --- a/site/README.md +++ b/site/README.md @@ -1,6 +1,6 @@ # ExoJS Site -Astro + Lit docs/playground app for ExoJS. This package is private and is not published to npm. +Astro + React docs/playground app for ExoJS. This package is private and is not published to npm. ## Relationship to `../examples` @@ -31,7 +31,7 @@ pnpm --filter @codexo/exojs-examples preview ## Structure -- `src/` — Astro pages and Lit playground shell +- `src/` — Astro pages and React playground shell - `public/` — static site assets (`preview.html`, favicons, manifest, vendor bundles) - `scripts/` — sync scripts for vendor artifacts and generated static mirrors - `tests/` — smoke tests for built output diff --git a/site/package.json b/site/package.json index 62ecb75ed..ec1d02d12 100644 --- a/site/package.json +++ b/site/package.json @@ -22,7 +22,7 @@ "examples:smoke": "tsx scripts/smoke-examples.ts", "check-example": "tsx scripts/check-example.ts", "screenshots": "tsx scripts/capture-screenshots.ts", - "screenshots:smoke": "tsx scripts/capture-screenshots.ts --base-url http://127.0.0.1:4321/ExoJS/ --label verify-mobile-phases-1-4-smoke --routes /en/,/en/guide/,/en/guide/introduction/what-is-exojs/,/en/api/,/en/api/all/,/en/api/application/,/en/api/scene/,/en/api/sprite/,/en/playground/ --themes dark,light --viewports 1440x1000,390x844 --concurrency 3", + "screenshots:smoke": "tsx scripts/capture-screenshots.ts --base-url http://127.0.0.1:4321/ExoJS/ --label verify-mobile-phases-1-4-smoke --routes /en/,/en/guide/,/en/guide/getting-started/what-is-exojs/,/en/api/,/en/api/all/,/en/api/application/,/en/api/scene/,/en/api/sprite/,/en/playground/ --themes dark,light --viewports 1440x1000,390x844 --concurrency 3", "lint": "eslint --max-warnings=0 ." }, "devDependencies": { diff --git a/site/src/components/DocsSidebar.astro b/site/src/components/DocsSidebar.astro index 6557c3dab..266170f20 100644 --- a/site/src/components/DocsSidebar.astro +++ b/site/src/components/DocsSidebar.astro @@ -61,7 +61,7 @@ const parseIndexedLabel = (value: string) => { {isGuideSidebar && parts.map(part => { const partLabel = parseIndexedLabel(part.title); - const partActive = currentPath.startsWith(part.baseHref ?? part.href); + const partActive = currentPath === part.baseHref || part.chapters.some(chapter => currentPath === chapter.href); return (
diff --git a/site/src/components/pages/ApiIndexPage.astro b/site/src/components/pages/ApiIndexPage.astro index 94c584236..3f9f78399 100644 --- a/site/src/components/pages/ApiIndexPage.astro +++ b/site/src/components/pages/ApiIndexPage.astro @@ -49,7 +49,7 @@ const typeCount = items.filter(item => item.kind === 'type').length; const sidebarGroups = [ { - title: 'Getting Started', + title: 'Common contracts', items: featured.map(item => ({ href: item.href, label: item.entry.data.title, @@ -71,7 +71,7 @@ const sidebarGroups = [ ]; --- - +

Recommended learning path

-

Follow these in order to go from an empty folder to a deployable game.

+

Follow the core path to understand the runtime, then choose a gameplay or integration topic. Advanced chapters are task references, not prerequisites for every project.

    {learningPath.map((step, index) => (
  1. @@ -124,12 +124,12 @@ const sidebarParts = GUIDE_PARTS.map(part => ({

    Playground

    -

    Run and edit every example live in the browser.

    +

    Run and edit the curated demonstrations; individual examples state their capabilities.

    API reference

    -

    Documents every class, method, and option.

    +

    States the generated public contracts, parameters, units, and ownership rules.

diff --git a/site/src/components/pages/GuidePartRedirect.astro b/site/src/components/pages/GuidePartRedirect.astro index c53beee87..05eae34c6 100644 --- a/site/src/components/pages/GuidePartRedirect.astro +++ b/site/src/components/pages/GuidePartRedirect.astro @@ -1,7 +1,6 @@ --- -import { getCollection } from 'astro:content'; -import type { CollectionEntry } from 'astro:content'; import { GUIDE_PARTS } from '../../lib/guide-structure'; +import GuideChapterRedirect from './GuideChapterRedirect.astro'; interface Props { locale: 'en' | 'de'; @@ -19,30 +18,6 @@ const firstChapter = part.chapters[0]; if (!firstChapter) { throw new Error(`Guide part has no chapters: ${partSlug}`); } - -const guideEntries = await getCollection('guide'); -const firstChapterTitle = - guideEntries.find((entry: CollectionEntry<'guide'>) => entry.id.replace(/\.(md|mdx)$/, '') === firstChapter.path)?.data - .title ?? firstChapter.slug; - -const targetHref = `${import.meta.env.BASE_URL}${locale}/guide/${part.slug}/${firstChapter.slug}/`; -const redirectLabel = locale === 'de' ? 'Weiterleitung zu' : 'Redirecting to'; --- - - - - - - {part.title} | ExoJS Guide - - - - -

{redirectLabel} {firstChapterTitle}...

- - + diff --git a/site/src/components/pages/HomePage.astro b/site/src/components/pages/HomePage.astro index 6ae6763d8..7dde15cc2 100644 --- a/site/src/components/pages/HomePage.astro +++ b/site/src/components/pages/HomePage.astro @@ -1,5 +1,6 @@ --- import AppShell from '../../layouts/AppShell.astro'; +import { extractSnippetRegion } from '../../lib/source-snippets'; import { Code } from 'astro:components'; import EnglishFallbackNotice from '../EnglishFallbackNotice.astro'; import ExampleThumb from '../../components/ExampleThumb.astro'; @@ -21,66 +22,19 @@ const localeBase = `${import.meta.env.BASE_URL}${locale}/`; const chapterCount = GUIDE_PARTS.reduce((sum, part) => sum + part.chapters.length, 0); const demoCount = getAllExamples().length; -// Live hero example - an ambient lighting showcase that runs everywhere +// Live hero example - an ambient lighting showcase that uses the supported backend path // (WebGL2, no input or audio gesture needed) so the landing shows the real // engine rendering in the page, not a video. const heroExample = getExamplesForChapter('lighting').find(entry => entry.slug === 'many-lights'); const heroExampleTitle = heroExample?.title ?? 'Normal-Mapped Lighting'; const heroExampleSource = getExampleExecutionSource('lighting', 'many-lights'); -const heroSnippet = `import { Application, Color, Scene, Sprite } from '@codexo/exojs'; -import { Lighting, LitMaterial, Normals, PointLight } from '@codexo/exojs-lighting'; - -const lighting = new Lighting(); -const lights = Array.from({ length: 24 }, (_, i) => { - const color = Color.fromCss(\`hsl(\${i * 15} 80% 60%)\`); - return new PointLight({ radius: 190, color }); -}); - -class LitScene extends Scene { - async load(loader) { - const stone = await loader.load('stone.png'); - const normalMap = await loader.load('stone-normal.png'); - const material = new LitMaterial({ lighting, normals: Normals.map(normalMap) }); - - for (let i = 0; i < 112; i++) { - const tile = new Sprite(stone); - tile.setPosition((i % 14) * 96, Math.floor(i / 14) * 96); - tile.material = material; - this.root.addChild(tile); - } - } - - init() { - this.systems.add(lighting); - lights.forEach(light => lighting.add(light)); - } - - update() { - const t = this.app.activeSeconds; - lights.forEach((light, i) => { - const angle = t * (0.25 + i * 0.08) + i; - light.setPosition(640 + Math.cos(angle) * 420, 360 + Math.sin(angle * 1.37) * 260); - }); - } - - draw(context) { - context.render(this.root); - } -} - -new Application({ canvas: { width: 1280, height: 720 } }).start(new LitScene());`; - -const quickstartSnippet = `import { Application, Scene } from '@codexo/exojs'; - -class HelloScene extends Scene { - draw(context) { - context.render(this.root); - } -} +const heroSnippet = extractSnippetRegion('examples/guides/lighting/basic-lightmap.ts', 'basic-lightmap'); -const app = new Application({ canvas: { width: 800, height: 600 } }); -app.start(new HelloScene());`; +const quickstartSnippet = `npm create exo-app@latest my-game -- --template minimal +cd my-game +npm install +npm run dev`; const examples = [ { @@ -145,9 +99,7 @@ const examples = [

A TypeScript-first
2D runtime for
arcade games

-

- ExoJS gives you scenes, sprites, graphics, input, audio, effects, assets, and rendering primitives — without turning your project into a full game-engine workflow. -

+

Build a game, visualization, or interactive canvas with scenes, rendering, input, audio, and explicit resource lifetimes. Keep the surrounding web application in the tools you already use.

- game.ts + basic-lightmap.ts scenelighting
@@ -223,15 +175,12 @@ const examples = [
FX

Filters & particles

-

Post-processing, bloom, blur, particles — composable and GPU-accelerated.

+

Compose filters and optional particles; simulation uses a CPU or eligible WebGPU compute path.

-

- Measured against Pixi, Phaser, Excalibur, matter.js, planck and Rapier on one reference machine — roughly 2x ahead of Pixi on WebGPU filter - chains, and about 7x behind it on masked clipping, both published with the counters behind them. -

+

Inspect named rendering and physics workloads, the machine and browser behind each result, and the measurement limits. The benchmark pages publish the evidence without an overall engine score.

See the benchmarks
@@ -265,14 +214,14 @@ const examples = [
-

Install once, use anywhere.

-

ExoJS ships as plain ES modules. Drop it into Vite, esbuild, or whatever you already use.

+

Start small. Add the systems you need.

+

Create a Vite and TypeScript starter, or add the ESM package to an existing application. The Guide explains both paths.

Open Getting Started
- +
diff --git a/site/src/content/api/lighting.json b/site/src/content/api/lighting.json index e45b76b83..8fef2f67f 100644 --- a/site/src/content/api/lighting.json +++ b/site/src/content/api/lighting.json @@ -1,6 +1,6 @@ { "title": "Lighting", - "description": "What a lighting system does with lights, materials and occluders, whichever renderer turns them into pixels. ```ts const lighting = new LightmapLighting(app, { ambient: new Color(11, 16, 32) }); scene.systems.add(lighting); lighting.add(player.addChild(new PointLight({ radius: 260 }))); lighting.occludeFrom(new PhysicsOccluder(world)); ``` Construct one of ForwardLighting, LightmapLighting or RadianceLighting. They are alternatives rather than layers, and a frame is shaded by exactly one of them. This class is what they share: the registries, the collection of occluders, and the update and destroy contracts. It links no renderer of its own, which is what keeps a project using one of them from carrying the others. # What it owns The renderer and its GPU resources. Lights are scene nodes owned by the tree they hang in - registering one does not transfer ownership, and destroying a registered light unregisters it. Occluder sources, filters passed as `post`, and the host are the caller's too. # Ordering Register it with the registry that ticks AFTER the code moving the lights, so the frame it shades is the frame that was drawn. `app.systems` runs its update phase before the active scene's, so a system registered there sees lights the scene has not moved yet; `scene.systems` is usually what you want.", + "description": "What a lighting system does with lights, materials and occluders, whichever renderer turns them into pixels. ```ts // In Scene.init(), where the scene's application is attached: const lighting = new LightmapLighting(this.app, { ambient: new Color(11, 16, 32) }); this.systems.add(lighting); lighting.add(player.addChild(new PointLight({ radius: 260 }))); lighting.occludeFrom(new PhysicsOccluder(world)); ``` Construct one of ForwardLighting, LightmapLighting or RadianceLighting. They are alternative lighting models rather than layers or quality levels, and a frame is shaded by exactly one of them: forward lighting shades materials as they draw, lightmap lighting shades the composed frame and can use a registered normal prepass, and radiance lighting samples a propagated light field. This class is what they share: the registries, the collection of occluders, and the update and destroy contracts. It links no renderer of its own, which is what keeps a project using one of them from carrying the others. # What it owns The renderer and its GPU resources. Lights are scene nodes owned by the tree they hang in - registering one does not transfer ownership, and destroying a registered light unregisters it. Occluder and normal sources, filters passed as `post`, and the host are the caller's too. # Ordering Register it with the registry that ticks AFTER the code moving the lights, so the frame it shades is the frame that was drawn. `app.systems` runs its update phase before the active scene's, so a system registered there sees lights the scene has not moved yet; `scene.systems` is usually what you want.", "symbol": "Lighting", "kind": "class", "subsystem": "lighting", @@ -20,11 +20,11 @@ "members": [], "paragraphs": [ "What a lighting system does with lights, materials and occluders, whichever renderer turns them into pixels.", - "```ts const lighting = new LightmapLighting(app, { ambient: new Color(11, 16, 32) });", - "scene.systems.add(lighting); lighting.add(player.addChild(new PointLight({ radius: 260 }))); lighting.occludeFrom(new PhysicsOccluder(world)); ```", - "Construct one of ForwardLighting, LightmapLighting or RadianceLighting. They are alternatives rather than layers, and a frame is shaded by exactly one of them. This class is what they share: the registries, the collection of occluders, and the update and destroy contracts. It links no renderer of its own, which is what keeps a project using one of them from carrying the others.", + "```ts // In Scene.init(), where the scene's application is attached: const lighting = new LightmapLighting(this.app, { ambient: new Color(11, 16, 32) });", + "this.systems.add(lighting); lighting.add(player.addChild(new PointLight({ radius: 260 }))); lighting.occludeFrom(new PhysicsOccluder(world)); ```", + "Construct one of ForwardLighting, LightmapLighting or RadianceLighting. They are alternative lighting models rather than layers or quality levels, and a frame is shaded by exactly one of them: forward lighting shades materials as they draw, lightmap lighting shades the composed frame and can use a registered normal prepass, and radiance lighting samples a propagated light field. This class is what they share: the registries, the collection of occluders, and the update and destroy contracts. It links no renderer of its own, which is what keeps a project using one of them from carrying the others.", "# What it owns", - "The renderer and its GPU resources. Lights are scene nodes owned by the tree they hang in - registering one does not transfer ownership, and destroying a registered light unregisters it. Occluder sources, filters passed as `post`, and the host are the caller's too.", + "The renderer and its GPU resources. Lights are scene nodes owned by the tree they hang in - registering one does not transfer ownership, and destroying a registered light unregisters it. Occluder and normal sources, filters passed as `post`, and the host are the caller's too.", "# Ordering", "Register it with the registry that ticks AFTER the code moving the lights, so the frame it shades is the frame that was drawn. `app.systems` runs its update phase before the active scene's, so a system registered there sees lights the scene has not moved yet; `scene.systems` is usually what you want." ], diff --git a/site/src/content/api/loader-scope.json b/site/src/content/api/loader-scope.json index 867c2289c..2882118e5 100644 --- a/site/src/content/api/loader-scope.json +++ b/site/src/content/api/loader-scope.json @@ -1,6 +1,6 @@ { "title": "LoaderScope", - "description": "An owner of asset claims with an explicit lifetime. Assets acquired through a scope stay resident for as long as that scope holds them, and are freed when it releases them - but only if no other scope still holds the same asset. Several scopes can own one asset independently: they share a single fetch and a single resident payload, and one scope releasing never invalidates another. Create a scope with Loader.createScope whenever an asset's lifetime is shorter than the application's - a level, a streamed chunk, a UI panel, a prefetch. Assets acquired directly on the Loader are held for the application's lifetime instead and are freed only when the loader is destroyed. A scope describes a lifetime, never a set of assets: what to acquire comes from an Assets catalog or an Asset descriptor passed to get / load, and typed access stays on that catalog. Scopes nest: createScope makes a child whose claims are independent but whose lifetime cannot outlive its parent's.", + "description": "An owner of asset claims with an explicit lifetime. Assets acquired through a scope stay resident for as long as that scope holds them, and are freed when it releases them - but only if no other scope still holds the same asset. Several scopes can own one asset independently: they share a single fetch and a single resident payload, and one scope releasing never invalidates another. Use the scene's scope for scene assets, and a child scope from createScope for anything shorter-lived - a level, a streamed chunk, a preview, a prefetch. Assets acquired directly on the Loader are held for the application's lifetime instead and are freed only when the loader is destroyed. A scope describes a lifetime, never a set of assets: what to acquire comes from an Assets catalog or an Asset descriptor passed to get / load, and typed access stays on that catalog.", "symbol": "LoaderScope", "kind": "class", "subsystem": "assets", @@ -21,9 +21,8 @@ "paragraphs": [ "An owner of asset claims with an explicit lifetime.", "Assets acquired through a scope stay resident for as long as that scope holds them, and are freed when it releases them - but only if no other scope still holds the same asset. Several scopes can own one asset independently: they share a single fetch and a single resident payload, and one scope releasing never invalidates another.", - "Create a scope with Loader.createScope whenever an asset's lifetime is shorter than the application's - a level, a streamed chunk, a UI panel, a prefetch. Assets acquired directly on the Loader are held for the application's lifetime instead and are freed only when the loader is destroyed.", - "A scope describes a lifetime, never a set of assets: what to acquire comes from an Assets catalog or an Asset descriptor passed to get / load, and typed access stays on that catalog.", - "Scopes nest: createScope makes a child whose claims are independent but whose lifetime cannot outlive its parent's." + "Use the scene's scope for scene assets, and a child scope from createScope for anything shorter-lived - a level, a streamed chunk, a preview, a prefetch. Assets acquired directly on the Loader are held for the application's lifetime instead and are freed only when the loader is destroyed.", + "A scope describes a lifetime, never a set of assets: what to acquire comes from an Assets catalog or an Asset descriptor passed to get / load, and typed access stays on that catalog." ], "importLine": "import { LoaderScope } from '@codexo/exojs'", "sourceLink": null @@ -81,7 +80,7 @@ } ], "returnType": "LoaderScope", - "description": "Creates a child scope: an independent claim owner that cannot outlive this one. The child claims, shares and releases assets exactly like any other scope - one fetch, one resident payload, one claim per owner - and holding the same asset as its parent means two claims, not one. Destroying the child frees only the child's claims; destroying the parent destroys every child it still has first, recursively, so a scene or level teardown reaches the scopes created underneath it without extra bookkeeping. The hierarchy is a lifetime hierarchy only. It never affects asset identity, ownership or what a release frees." + "description": "Creates a child scope: an independent claim owner that cannot outlive this one. The child claims, shares and releases assets exactly like any other scope, and holding the same asset as its parent means two claims, not one. Destroying the child frees only the child's claims; destroying the parent destroys every child it still has first, recursively, so a scene or level teardown reaches the scopes created underneath it. The hierarchy is a lifetime hierarchy only. It never affects asset identity, ownership or what a release frees." }, { "name": "destroy", @@ -110,7 +109,7 @@ ], "params": [], "returnType": "void", - "description": "Releases every claim this scope still holds and destroys any child scope it still has. Assets another scope also holds stay resident, and destroying an already-destroyed scope is a no-op. Acquiring through the scope afterwards - get, load, loadContainer - throws, because the claim it would register has no owner left to release it." + "description": "Releases every claim this scope still holds and destroys any child scope it still has. Assets another scope also holds stay resident, and destroying an already-destroyed scope is a no-op. Acquiring through the scope afterwards - get, load, loadContainer, createScope - throws, because what it would register has no owner left to release it." }, { "name": "get", @@ -254,7 +253,7 @@ } ], "returnType": "LeafForPath", - "description": "" + "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload." }, { "name": "get", @@ -345,7 +344,7 @@ } ], "returnType": "CatalogValueLeaf", - "description": "" + "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload." }, { "name": "get", @@ -416,7 +415,7 @@ } ], "returnType": "CatalogResourceLeaf", - "description": "" + "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload." }, { "name": "get", @@ -487,7 +486,7 @@ } ], "returnType": "InferAssetsProperties", - "description": "" + "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload." }, { "name": "get", @@ -558,7 +557,7 @@ } ], "returnType": "CatalogResourceLeaf", - "description": "" + "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload." }, { "name": "load", @@ -629,7 +628,7 @@ } ], "returnType": "LoadingQueue", - "description": "" + "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads." }, { "name": "load", @@ -737,7 +736,7 @@ } ], "returnType": "LoadingQueue>", - "description": "" + "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads." }, { "name": "load", @@ -833,7 +832,7 @@ } ], "returnType": "LoadingQueue", - "description": "" + "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads." }, { "name": "load", @@ -929,7 +928,7 @@ } ], "returnType": "LoadingQueue", - "description": "" + "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads." }, { "name": "load", @@ -1072,7 +1071,7 @@ } ], "returnType": "LoadingQueue>>", - "description": "" + "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads." }, { "name": "loadContainer", @@ -1519,7 +1518,7 @@ ], "params": [], "returnType": null, - "description": "Fired once every asset in this scope's batch has settled." + "description": "Fires after every foreground item in this scope's current batch has settled, including failures. It does not imply that every asset succeeded; await the returned loading queue to observe success or failure." }, { "name": "onLoadError", diff --git a/site/src/content/api/particle-system.json b/site/src/content/api/particle-system.json index 3a5fbdf66..996dc41ee 100644 --- a/site/src/content/api/particle-system.json +++ b/site/src/content/api/particle-system.json @@ -1,6 +1,6 @@ { "title": "ParticleSystem", - "description": "The central coordinator of the particle pipeline. `ParticleSystem` is a Drawable that owns: - **Particle storage** - one channel per attribute (position, velocity, scale, rotation, color, timing, ...), sized to a fixed capacity at construction. Modules and render modes address it by name through a ParticleBatch; user code brings particles into existence with emit. - **Spawn modules** - fill freshly emitted particles. - **Update modules** - mutate the live range each frame (forces, color blends, scale curves, drag, ...). Built-in modules ship both CPU and WGSL implementations; custom modules can opt into GPU acceleration by implementing `wgsl()`. - **Death modules** - fire once per dying particle, before its slot is recycled (sub-emitters, event hooks). **Auto-routing CPU vs GPU:** at first update, the system checks: if a `WebGpuBackend` was supplied AND every registered update module has `wgsl()` AND the render mode is GPU-eligible, the GPU path engages - a composite compute pipeline runs integration plus all module bodies in one dispatch and writes directly into the renderer's instance buffer (no CPU readback). Otherwise the CPU path runs the existing per-module `apply()` loops. **Per-frame order in update (CPU mode):** 1. Run every spawn module. 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`. 3. Run every update module on the live range. 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired slots, copy survivors down. `liveCount` shrinks to the survivor count. **Per-frame order in update (GPU mode):** 1. Run every spawn module (CPU writes initial values into the spawn slot). 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules; set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU shader skips them. **No compaction** - slots are recycled on next spawn. 3. Dispatch the composite compute pipeline. Integration + update modules + pack-instances run in one pass; the instance buffer is written directly. CPU SoA stays as-is for spawn writes. **Coordinate space:** particle positions are LOCAL to the system. The system's `getGlobalTransform()` is applied on top during rendering - both the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`. Setting world-space positions on individual particles double-translates. Position the system itself via `system.setPosition(...)` and emit relative to `(0, 0)`. **View culling:** a system is created with `cullable = false`. Its local bounds cover one texture frame at the local origin, because the particles themselves are simulated on the GPU in half the configurations and no emitted extent is tracked in either - so culling against those bounds would remove the entire cloud as soon as the emitter's own origin left the view. For a system whose reach is known, set the node's `cullArea` to a rectangle in local space covering where its particles travel and set `cullable = true` again; the viewport check then uses that rectangle instead of the bounds. `getBounds()` still reports the one-frame box, not an extent of the live particles. **Pixel snapping:** Drawable.pixelSnapMode is intentionally ignored for particle systems. Particle instances bake their own per-particle transforms in the emitter/compute path rather than reading the shared pixel-snap transform row, so a snap mode set on the system has no effect on rendered output - snapping thousands of independently-moving sub-pixel particles to the device grid is neither meaningful nor desirable.", + "description": "The central coordinator of the particle pipeline. `ParticleSystem` is a Drawable that owns: - **Particle storage** - one channel per attribute (position, velocity, scale, rotation, color, timing, ...), sized to a fixed capacity at construction. Modules and render modes address it by name through a ParticleBatch; user code brings particles into existence with emit. - **Spawn modules** - fill freshly emitted particles. - **Update modules** - mutate the live range each frame (forces, color blends, scale curves, drag, ...). Built-in modules ship both CPU and WGSL implementations; custom modules can opt into GPU acceleration by implementing `wgsl()`. - **Death modules** - fire once per dying particle, before its slot is recycled (sub-emitters, event hooks). - **An explicitly supplied render mode** - destroyed with the system. The default mode and the texture are shared and stay the caller's. **Auto-routing CPU vs GPU:** on the first update, and again after any module change, the system checks whether a WebGPU device is available (the attached backend's or one passed in the options), every registered update module has `wgsl()`, and the render mode is GPU-eligible. If so, a composite compute pipeline runs integration plus all module bodies in one dispatch and writes directly into the renderer's instance buffer (no CPU readback). Otherwise the CPU path runs the per-module `apply()` loops; on WebGL2 that is always the case. A module change that forces a running GPU simulation back onto the CPU clears the live particles, because the CPU holds no copy of the state the device integrated. **Per-frame order in update (CPU mode):** 1. Run every spawn module. 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`. 3. Run every update module on the live range. 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired slots, copy survivors down. `liveCount` shrinks to the survivor count. **Per-frame order in update (GPU mode):** 1. Run every spawn module (CPU writes initial values into the spawn slot). 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules; mark the slot dead so the GPU shader skips it. **No compaction** - slots are recycled on the next spawn. 3. Dispatch the composite compute pipeline. Integration, update modules and instance packing run in one pass; the instance buffer is written directly. Tick a system from exactly one place: register it with one system registry, or call update yourself, never both. **Coordinate space:** particle positions are LOCAL to the system. The system's `getGlobalTransform()` is applied on top during rendering, so setting world-space positions on individual particles double-translates. Position the system itself via `system.setPosition(...)` and emit relative to `(0, 0)`. **View culling:** a system is created with `cullable = false`. Its bounds cover one texture frame at the local origin, because no emitted extent is tracked - so culling against them would remove the entire cloud as soon as the emitter's own origin left the view. For a system whose reach is known, set the node's `cullArea` to a world-space rectangle covering where its particles travel and set `cullable = true` again; the viewport check then uses that rectangle instead of the bounds. `getBounds()` still reports the one-frame box, not an extent of the live particles. **Pixel snapping:** Drawable.pixelSnapMode has no effect on particle systems. Particle instances bake their own per-particle transforms rather than reading the shared pixel-snap transform, and snapping thousands of independently moving sub-pixel particles to the device grid is not meaningful.", "symbol": "ParticleSystem", "kind": "class", "subsystem": "particles", @@ -20,13 +20,14 @@ "members": [], "paragraphs": [ "The central coordinator of the particle pipeline. `ParticleSystem` is a Drawable that owns:", - "- **Particle storage** - one channel per attribute (position, velocity, scale, rotation, color, timing, ...), sized to a fixed capacity at construction. Modules and render modes address it by name through a ParticleBatch; user code brings particles into existence with emit. - **Spawn modules** - fill freshly emitted particles. - **Update modules** - mutate the live range each frame (forces, color blends, scale curves, drag, ...). Built-in modules ship both CPU and WGSL implementations; custom modules can opt into GPU acceleration by implementing `wgsl()`. - **Death modules** - fire once per dying particle, before its slot is recycled (sub-emitters, event hooks).", - "**Auto-routing CPU vs GPU:** at first update, the system checks: if a `WebGpuBackend` was supplied AND every registered update module has `wgsl()` AND the render mode is GPU-eligible, the GPU path engages - a composite compute pipeline runs integration plus all module bodies in one dispatch and writes directly into the renderer's instance buffer (no CPU readback). Otherwise the CPU path runs the existing per-module `apply()` loops.", + "- **Particle storage** - one channel per attribute (position, velocity, scale, rotation, color, timing, ...), sized to a fixed capacity at construction. Modules and render modes address it by name through a ParticleBatch; user code brings particles into existence with emit. - **Spawn modules** - fill freshly emitted particles. - **Update modules** - mutate the live range each frame (forces, color blends, scale curves, drag, ...). Built-in modules ship both CPU and WGSL implementations; custom modules can opt into GPU acceleration by implementing `wgsl()`. - **Death modules** - fire once per dying particle, before its slot is recycled (sub-emitters, event hooks). - **An explicitly supplied render mode** - destroyed with the system. The default mode and the texture are shared and stay the caller's.", + "**Auto-routing CPU vs GPU:** on the first update, and again after any module change, the system checks whether a WebGPU device is available (the attached backend's or one passed in the options), every registered update module has `wgsl()`, and the render mode is GPU-eligible. If so, a composite compute pipeline runs integration plus all module bodies in one dispatch and writes directly into the renderer's instance buffer (no CPU readback). Otherwise the CPU path runs the per-module `apply()` loops; on WebGL2 that is always the case. A module change that forces a running GPU simulation back onto the CPU clears the live particles, because the CPU holds no copy of the state the device integrated.", "**Per-frame order in update (CPU mode):** 1. Run every spawn module. 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`. 3. Run every update module on the live range. 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired slots, copy survivors down. `liveCount` shrinks to the survivor count.", - "**Per-frame order in update (GPU mode):** 1. Run every spawn module (CPU writes initial values into the spawn slot). 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules; set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU shader skips them. **No compaction** - slots are recycled on next spawn. 3. Dispatch the composite compute pipeline. Integration + update modules + pack-instances run in one pass; the instance buffer is written directly. CPU SoA stays as-is for spawn writes.", - "**Coordinate space:** particle positions are LOCAL to the system. The system's `getGlobalTransform()` is applied on top during rendering - both the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`. Setting world-space positions on individual particles double-translates. Position the system itself via `system.setPosition(...)` and emit relative to `(0, 0)`.", - "**View culling:** a system is created with `cullable = false`. Its local bounds cover one texture frame at the local origin, because the particles themselves are simulated on the GPU in half the configurations and no emitted extent is tracked in either - so culling against those bounds would remove the entire cloud as soon as the emitter's own origin left the view. For a system whose reach is known, set the node's `cullArea` to a rectangle in local space covering where its particles travel and set `cullable = true` again; the viewport check then uses that rectangle instead of the bounds. `getBounds()` still reports the one-frame box, not an extent of the live particles.", - "**Pixel snapping:** Drawable.pixelSnapMode is intentionally ignored for particle systems. Particle instances bake their own per-particle transforms in the emitter/compute path rather than reading the shared pixel-snap transform row, so a snap mode set on the system has no effect on rendered output - snapping thousands of independently-moving sub-pixel particles to the device grid is neither meaningful nor desirable." + "**Per-frame order in update (GPU mode):** 1. Run every spawn module (CPU writes initial values into the spawn slot). 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules; mark the slot dead so the GPU shader skips it. **No compaction** - slots are recycled on the next spawn. 3. Dispatch the composite compute pipeline. Integration, update modules and instance packing run in one pass; the instance buffer is written directly.", + "Tick a system from exactly one place: register it with one system registry, or call update yourself, never both.", + "**Coordinate space:** particle positions are LOCAL to the system. The system's `getGlobalTransform()` is applied on top during rendering, so setting world-space positions on individual particles double-translates. Position the system itself via `system.setPosition(...)` and emit relative to `(0, 0)`.", + "**View culling:** a system is created with `cullable = false`. Its bounds cover one texture frame at the local origin, because no emitted extent is tracked - so culling against them would remove the entire cloud as soon as the emitter's own origin left the view. For a system whose reach is known, set the node's `cullArea` to a world-space rectangle covering where its particles travel and set `cullable = true` again; the viewport check then uses that rectangle instead of the bounds. `getBounds()` still reports the one-frame box, not an extent of the live particles.", + "**Pixel snapping:** Drawable.pixelSnapMode has no effect on particle systems. Particle instances bake their own per-particle transforms rather than reading the shared pixel-snap transform, and snapping thousands of independently moving sub-pixel particles to the device grid is not meaningful." ], "importLine": "import { ParticleSystem } from '@codexo/exojs-particles'", "sourceLink": null diff --git a/site/src/content/api/render-pass-inspector-layer.json b/site/src/content/api/render-pass-inspector-layer.json index 8f96915a4..954e551c5 100644 --- a/site/src/content/api/render-pass-inspector-layer.json +++ b/site/src/content/api/render-pass-inspector-layer.json @@ -1,6 +1,6 @@ { "title": "RenderPassInspectorLayer", - "description": "Debug layer that lists every RenderNode with an active filter chain each frame. Renders a compact text panel with per-drawable rows showing the filter sequence, bounding-box dimensions, and mask/cache status. Use during development to answer: - \"Is my filter actually attached?\" → it appears in the list - \"Why does my frame have N render passes?\" → see total pass count - \"Is this drawable being re-rendered or cached?\" → `[cached]` flag For deep per-pass inspection (intermediate render-target contents, GLSL/WGSL source, uniform values), use Spector.js or Chrome DevTools' WebGPU panel - the engine emits debug-group labels around filter and mesh-custom-shader passes so those tools show meaningful pass names.", + "description": "Debug layer that lists every visible RenderNode in the scene tree with an attached filter chain, and optionally a RenderPipeline set with setPipeline. Renders a compact text panel with per-node rows showing the filter sequence, bounding-box dimensions, and mask/cache status. Use during development to answer: - \"Is my filter actually attached?\" - it appears in the list - \"Is this node configured to render through a cache?\" - `[cached]` flag The pass total counts the attached filters plus one per collected entry with a mask. It is a structural estimate, not a hardware-pass count or GPU timing: multi-step filters are not expanded, cached work is not subtracted, and mask-only nodes are not collected. The returned entries are reused on every update. For deep per-pass inspection (intermediate render-target contents, GLSL/WGSL source, uniform values), use Spector.js or Chrome DevTools' WebGPU panel. On WebGPU, draws with a custom mesh or sprite material carry debug-group labels, so those tools can tell them apart.", "symbol": "RenderPassInspectorLayer", "kind": "class", "subsystem": "debug", @@ -19,10 +19,11 @@ "title": "Import", "members": [], "paragraphs": [ - "Debug layer that lists every RenderNode with an active filter chain each frame. Renders a compact text panel with per-drawable rows showing the filter sequence, bounding-box dimensions, and mask/cache status.", + "Debug layer that lists every visible RenderNode in the scene tree with an attached filter chain, and optionally a RenderPipeline set with setPipeline. Renders a compact text panel with per-node rows showing the filter sequence, bounding-box dimensions, and mask/cache status.", "Use during development to answer:", - "- \"Is my filter actually attached?\" → it appears in the list - \"Why does my frame have N render passes?\" → see total pass count - \"Is this drawable being re-rendered or cached?\" → `[cached]` flag", - "For deep per-pass inspection (intermediate render-target contents, GLSL/WGSL source, uniform values), use Spector.js or Chrome DevTools' WebGPU panel - the engine emits debug-group labels around filter and mesh-custom-shader passes so those tools show meaningful pass names." + "- \"Is my filter actually attached?\" - it appears in the list - \"Is this node configured to render through a cache?\" - `[cached]` flag", + "The pass total counts the attached filters plus one per collected entry with a mask. It is a structural estimate, not a hardware-pass count or GPU timing: multi-step filters are not expanded, cached work is not subtracted, and mask-only nodes are not collected. The returned entries are reused on every update.", + "For deep per-pass inspection (intermediate render-target contents, GLSL/WGSL source, uniform values), use Spector.js or Chrome DevTools' WebGPU panel. On WebGPU, draws with a custom mesh or sprite material carry debug-group labels, so those tools can tell them apart." ], "importLine": "import { RenderPassInspectorLayer } from '@codexo/exojs/debug'", "sourceLink": null diff --git a/site/src/content/guide/assets/asset-catalogs.mdx b/site/src/content/guide/assets/asset-catalogs.mdx new file mode 100644 index 000000000..26dbd6610 --- /dev/null +++ b/site/src/content/guide/assets/asset-catalogs.mdx @@ -0,0 +1,52 @@ +--- +title: 'Asset catalogs' +description: 'Name a group of assets, compose shared definitions, and distinguish deferred catalog leaves from resolved load results.' +--- + +import SourceSnippet from '../../../components/SourceSnippet.astro'; + +# Asset catalogs + +A catalog names the resources used by a feature without deciding when they are loaded or how long they stay resident. A loader scope supplies that lifetime. This separation lets two levels share a definition without making either level responsible for the other's cleanup. + +Read [Loading and resource lifetimes](/ExoJS/en/guide/assets/loading-and-resources/) first. For one asset, an individual descriptor is sufficient; a catalog becomes useful when a feature has several named inputs. + +## Declare once, acquire through an owner + + + +Creating `SharedAssets` does not fetch files. Its texture property is a deferred resource handle; its JSON property is an `AssetRef`. Acquiring the catalog through a scope starts the work and gives that scope claims on its assets. + +The generic annotation on `Asset.type('json', ...)` expresses an expected TypeScript shape. **It does not validate the downloaded JSON.** Use a synchronous `parse` transform or validate the decoded `unknown` value when the document is not fully controlled by your build pipeline. Do not let a type annotation stand in for validation of a save file, server response, or user-supplied document. + +## Two ways to use the completed load + + + +The returned object contains finished values: `loaded.settings` is `Settings`, not `AssetRef`. The original catalog still exposes `SharedAssets.settings`, whose `.value` is now usable. `loaded` is a newly created values map, not the catalog instance. + +Code that needs values immediately can use the returned map. Code that binds a texture handle before the load completes can retain the catalog leaf and observe its readiness. Do not replace every catalog leaf after loading; filling existing leaves is part of their purpose. + +A failed catalog load rejects its queue. Individual leaves retain their own status, so an error UI can identify the failing asset. A successful sibling is not proof that the catalog as a whole succeeded. + +## Compose shared definitions + + + +`compose` combines catalogs and preserves their shared leaves. `extend` derives a catalog with explicitly redeclared or additional keys instead of mutating its base. Use descriptive keys and avoid accidental collisions when composing independent features; consult the [`Assets` contract](/ExoJS/en/api/assets/) for collision handling. + +A catalog is not a global asset lifetime. Loading the same composed definition through two scopes creates two owners of the shared payload. Destroying one scope does not invalidate the other owner's claim. Once no owner keeps an asset resident, keeping a catalog object in a module is not a promise that its payload remains ready forever. + +## Parallel loads and progress + +Start independent queues before awaiting them, then use `Promise.all` when both are required. Each queue's progress describes that acquisition group. Aggregate loader signals describe wider activity and can include work started elsewhere. + +Background priority belongs to a catalog or catalog leaf load. Per-resource decode options belong in `Asset.type(...)`. Those two option bags solve different problems and are not interchangeable. + +## Keep definitions close to the feature + +Place a small shared catalog beside the code that consumes it, then compose it into the level or UI definition that needs it. Avoid one application-wide catalog that eagerly loads every possible screen. For streaming, make the level runtime own its scope and let its catalog describe only its inputs. + +Streamed music, video, browser fonts, and other types without placeholder leaves are normally loaded with explicit awaited descriptors. Do not assume that an arbitrary resource can participate in every catalog or `get()` form just because it has an asset type. + +Continue with [Worlds and level streaming](/ExoJS/en/guide/assets/worlds-and-spawning/) for independently unloadable content, or the [`Assets`](/ExoJS/en/api/assets/), [`LoaderScope`](/ExoJS/en/api/loader-scope/), and [`AssetRef`](/ExoJS/en/api/asset-ref/) references for exact contracts. diff --git a/site/src/content/guide/assets/loading-and-resources.mdx b/site/src/content/guide/assets/loading-and-resources.mdx index 0ddb9a402..399bb24f4 100644 --- a/site/src/content/guide/assets/loading-and-resources.mdx +++ b/site/src/content/guide/assets/loading-and-resources.mdx @@ -1,680 +1,101 @@ --- -title: 'Loading and resources' -description: 'The asset pipeline — how the loader registers, fetches, and resolves resources before your scene starts updating.' +title: 'Loading and resource lifetimes' +description: 'Choose awaited or placeholder-based loading, handle failures, and give assets the lifetime of their actual owner.' --- -import ExamplePreview from '../../../components/ExamplePreview.astro'; -import Callout from '../../../components/Callout.astro'; import SourceSnippet from '../../../components/SourceSnippet.astro'; +import TryIt from '../../../components/TryIt.astro'; -# Loading and resources +# Loading and resource lifetimes -Most non-trivial scenes need assets — textures, audio, fonts, JSON, video — before they can render. The [`Loader`](/ExoJS/en/api/loader/) handles fetching, decoding, and caching those assets, then makes them available to your scene by name. +Loading answers two questions: **when is a value usable, and who keeps it alive?** The loader handles acquisition and decoding. A loader scope holds claims that keep the resulting resources resident. -The contract is simple: declare what you need during `load`, await the loader, and read the resolved instances out during `init`. Everything before `init` runs has finished by the time `init` is called. +Use `this.loader` inside a scene for scene-owned resources. Use `this.app.loader` only for resources that should survive until the application ends. For a streamed level, dialog, or preview with a shorter lifetime, create a child scope and destroy it when that activity ends. -## The loader instance +## Await a required resource -Every application owns one loader, available as `app.loader`. Inside a scene, reach it two ways: `this.loader` — a scene-scoped claim view whose assets release automatically when the scene ends — and `this.app.loader` — the same underlying application-lifetime instance, for assets that must outlive the scene. +Load a resource before activating a scene when layout or gameplay needs its real dimensions or decoded value: -For most scenes you don't need to think about ownership — just use `this.loader` in `load` and `init`. + -## Choose the form that fits +Place an image at `public/assets/image/hero.png` and configure `loader.basePath: 'assets/'` when starting this scene. `public` is a project directory, not part of the requested URL. Resolve deployment base paths as described in [Deployment](/ExoJS/en/guide/shipping/deployment/). -The loader offers several forms. Pick the smallest one that covers your case: +A registered literal suffix such as `.png` supplies the asset type. For a computed path, ambiguous file name, or type without a placeholder, use an explicit descriptor: -| Form | Best for | -| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `this.loader.load('hero.png')` | One asset, path doubles as its identity — no alias needed | -| `Asset.type('texture', path)` / `Asset.type('sound', path)` / `Asset.type('json', path)` | The path is computed at runtime (not a literal), or you want an explicit type | -| `Assets.from({ hero: 'hero.png', … })` | A reusable, named group of assets shared across scenes | -| `Assets.from({ hero: 'hero.png', music: Asset.type('music', path) })` | Mixed types and type-specific options in one catalog | - -When in doubt, start with a bare path string. Reach for `Assets.from` when assets belong together, and `Asset.type(...)` whenever the path isn't a string literal or the type should be explicit. - - - Awaiting loads sequentially makes each one wait for the previous download to finish. Group independent `this.loader.load(...)` calls in a single `Promise.all` so unrelated resource types fetch concurrently. - - -## Which call when - -The forms above say how to name an asset; these are the calls that fetch, look up and let go of one. Every fetching call claims the asset for its owner (the application, the scene, or a scope you created), and a claim is what keeps it resident. - -| You want to… | Call | You get | Claim | -| ------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------- | -| Use a seamless asset now and let it fill in when it arrives | `loader.get('hero.png')` | The handle immediately (`'loading'` until ready), the same instance for the same source | Yes | -| Read a value asset (`json`, `txt`, `csv`, …) as it becomes available | `loader.get('level.json')` | A stable `AssetRef` whose `.value` fills in | Yes | -| Wait for one asset, or for a type without a placeholder (`music`, `bmFont`, …) | `await loader.load(Asset.type('music', path))` | The resolved resource | Yes | -| Wait for a whole catalog at once | `await loader.load(catalog)` | The catalog, its leaves healed in place | Yes, one per entry | -| Unpack a packed `.exoa` container in one request | `await loader.loadContainer('pack.exoa')` | A new scope owning every entry; entries resolve to the same identities as network loads | Yes, one per entry, held by that scope | -| Find out which packs a deployment ships, and where they are now | `await loader.loadManifest('assets.json')` | An `AssetManifest`; `manifest.pack(name)` addresses one for `loadContainer` | No | -| Check whether something is already resident without fetching | `loader.peek('hero.png')` | The resource, or `undefined` | No | -| Let one asset go before its owner ends | `scope.release(handle)` | Nothing; the handle stays valid and heals again on the next `get()` | Drops this scope's claim | -| Let everything an owner holds go | `scope.destroy()` / the scene ending | Nothing; assets other owners still hold stay resident | Drops all of that owner's claims | - -Two rules make the table predictable. First, `get()` never returns `undefined` for a seamless type: it hands back a placeholder and starts the fetch, so a wrong path fails at the fetch, not at the call - use `peek()` when "not loaded" is an answer you want. Second, `release()` and `destroy()` only ever drop the caller's own claims; there is no call that evicts an asset another owner is using, and no way to release an application-lifetime claim except destroying the application. Read on for the forms and their examples, and see [Ownership and scopes](#ownership-and-scopes) for what an owner is. - -## Loading a single asset - -The path _is_ the identity — a bare string resolves directly to the finished asset, with its type inferred from the extension: - - - -There's no separate alias step: call `this.loader.get('image/hero.png')` again anywhere later — same string, same `Texture` instance. Leaf-capable types such as `texture`, `sound`, `json`, and `text` resolve this way from their file extension. Types that can't be inferred from a path (`music`, `video`, `bmFont`, `font`, …) need an explicit descriptor — see [`Asset.type(...)`](#dynamic-paths-and-non-seamless-types) below. - -## Homogeneous batch - -When you need several assets of the same type, load them in parallel and keep the returned instances: - - - -The `loader.basePath` option in `ApplicationOptions` prepends a base prefix to all relative paths, so these resolve to e.g. `assets/image/hero.png`. - -Because the path is the identity, the same source path returns the very same `Texture` instance from anywhere else in your code — `this.loader.get('image/hero.png')` from `init`, a later scene, or `app.loader.get(...)` from a system — as long as the string matches. - - - For a registered seamless or value suffix, `this.loader.get(...)` returns synchronously — even before the fetch finishes. Ask for a valid path you haven't loaded yet and you get a placeholder handle/ref immediately, which then heals once the network request resolves. Invalid input or a missing type/handler still throws synchronously. Check `.ready` / `.state` for asynchronous fetch outcomes (see [Status channel](#status-channel-instead-of-throwing) below). - - -## Dynamic paths and non-seamless types - -A bare string only works for a _literal_ path — the type is inferred at compile time from the extension. When the path is computed at runtime, use the canonical `Asset.type(...)` descriptor: - - - -Inside every scene lifecycle hook, `Scene.app` is already available and non-null. Access before attachment, such as from a scene constructor, throws; use `Scene.attached` only when code genuinely needs a non-throwing attachment probe. - -Types that can't be inferred from a path at all need the same descriptor, even when the path _is_ a literal: - - - - - For seamless and value types, `this.loader.get(Asset.type(type, dynamicPath))` returns a handle or `AssetRef` immediately and starts the asynchronous load. Capture that returned object: each descriptor call creates a fresh leaf, although the backend fetch is still deduplicated. Non-leaf types have no synchronous handle and must use `await this.loader.load(Asset.type(...))`. - - -## Multiple resource types in parallel - -When a scene needs different asset types, load them in parallel with `Promise.all`: - - - -Wrapping the calls in `Promise.all` lets unrelated asset categories load in parallel. - -For value assets such as `Json` (and `TextAsset`, `CsvAsset`, and the other data tokens), `loader.load(...)` resolves directly to the parsed value. `loader.get(...)` on the same path hands back a lightweight `AssetRef` instead — read its `.value` once `.ready` is `true` (see below). - -## Mixed asset catalog - -Build mixed groups with `Assets.from(...)`. This is the canonical catalog API; the old inline-record `loader.load({ alias: config })` call shape has been removed. - - - -Awaiting the catalog returns an object whose keys match the input and whose values are the resolved resource instances directly, so it destructures: - - - -No separate `get()` step is needed, though `this.loader.get('image/uv-grid-256.png')` / `this.loader.get('audio/ui-click.ogg')` still work afterward since the source path remains the identity. - -## Reusable asset references - -For assets used in multiple scenes, define a named catalog with `Assets.from`: - - - -Every property is a real, usable handle _before_ any loader touches it — `TitleAssets.logo` is already a `Texture` (in the `'loading'` state), and `TitleAssets.config` is already an `AssetRef`. Load the whole catalog, then read the same properties: - - - -Typed properties on the catalog give autocomplete and type inference for free: - - - -`Assets` also exposes an `.entries` record for iteration and inspection. To load several existing catalogs concurrently, keep their types intact and start both queues: - - - -The key `'entries'` is reserved and will throw if you try to use it as an asset name inside an `Assets` container. - -## Combining and deriving catalogs - -Catalogs compose. `Assets.compose(...)` merges several existing catalogs into one — the result is an ordinary, fully typed `Assets` object, so it loads, releases, and autocompletes exactly like a hand-written one: - - - -A composition **shares** its inputs' handles instead of copying them: `LevelAssets.logo === SharedAssets.logo`, so loading the composition heals the handles the shared catalog already handed out. It adds no ownership of its own — loading and releasing behave exactly as they would for the underlying keys. - -Two _different_ catalogs may not define the same key. That ambiguity is caught at compile time (the result types as a message naming the key) and always throws at runtime: +```ts +import { Asset, type LoaderScope } from '@codexo/exojs'; -```ts no-check -- shows the duplicate-key error Assets.compose reports, not working code -// Assets.compose(): duplicate catalog key "ship" — two catalogs define it, -// use Assets.extend() to override it deliberately. -Assets.compose(LevelLocalAssets, Assets.from({ ship: 'image/other-ship.png' })); +export const loadPortrait = (scope: LoaderScope, name: string) => + scope.load(Asset.type('texture', `portraits/${name}.png`)); ``` -The same catalog arriving twice along different paths — a diamond — is not a conflict and deduplicates: - - - -To re-declare a key on purpose, derive with `Assets.extend(base, entries)`. New keys are added, existing keys are deliberately overridden, and the base catalog is never mutated: - - - -An override is a new declaration, so composing a derived catalog back together with the base it overrode conflicts on that key — as two independent declarations should. - -## Progress and parallel loading - -Every `loader.load(...)` call returns a `LoadingQueue`. It implements `PromiseLike`, so `await` and `Promise.all` both work. It also exposes per-queue progress via `onProgress`: - - - -`LoadingProgress` fields: - -| Field | Meaning | -| --------- | ------------------------------------------- | -| `total` | Total assets in this queue | -| `loaded` | Successfully loaded so far | -| `failed` | Failed so far | -| `pending` | Not yet settled (`total − loaded − failed`) | - -Start two queues simultaneously and wait for both: - - - -Each queue tracks its own progress independently. The result tuple is typed: `day` has the shape of `LevelAssets`, `night` has the shape of `NightAssets`. - -A practical startup pattern — show the title screen as soon as title assets are ready, while game assets continue loading in the background: - - - -## Signals for a global loading screen - -`LoadingQueue.onProgress` (above) is scoped to a single `loader.load(...)` call. When you want a single loading screen that reflects _everything_ the loader is doing — regardless of how many separate `loader.load(...)` calls triggered it — subscribe to the loader instance's own signals instead: `onLoadStart`, `onLoadProgress`, `onLoadComplete`, and `onLoadError`. These track one shared, loader-wide batch: as long as any foreground load is in flight, new loads join the same batch instead of starting a new one. - -| Signal | Payload | Fires | -| ---------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onLoadStart` | `(key, url)` | Once, when the loader goes from idle to active. `key`/`url` identify the asset that triggered it. | -| `onLoadProgress` | `(loaded, total, key)` | After every asset in the batch settles (success or failure). `loaded`/`total` are running counts across the whole batch; `key` is the asset that just settled. | -| `onLoadComplete` | — | Once, when every in-flight foreground load has settled and the batch returns to idle. | -| `onLoadError` | `(key, error)` | For each asset that fails. Does not prevent `onLoadComplete` from firing afterward. | - -Because the batch is shared, `total` can grow mid-flight: if a new `loader.load(...)` call starts while others are still pending, its assets are added to the same running total rather than starting a fresh count at 0. That is exactly what you want for a boot screen — one progress bar that stays accurate no matter how many scenes or systems kick off loads concurrently. - - - -Unlike `LoadingQueue.onProgress`, which you attach to the return value of one `loader.load(...)` call, these four are properties on the `Loader` itself — subscribe once (for example in your boot scene's `init`) and they report every foreground load for the lifetime of that loader. - -### Treat completion as state - -`onLoadComplete` is not tied to this scene's lifecycle. With a warm cache it fires while `BootScene` is still preparing, before the scene may navigate; after a switch away from `BootScene` or an app-level `stop()`/`destroy()` mid-load it fires for a scene that is no longer on screen. Navigating directly from the handler loses the first case and misfires in the second. Record the completion instead, act on it from `update()` — which only runs while the scene is active — and unsubscribe from the loader signals in `unload()`: - - - -## Status channel instead of throwing - -For leaf-capable types, the path (or an `Asset.type(...)` descriptor) identifies the loaded payload — there's no separate alias to register or forget. Every handle and `AssetRef` exposes the same small status contract: - - - -`LoadStateValue` is `'idle' | 'loading' | 'ready' | 'failed'` — `'idle'` is the state of a catalog leaf no loader has adopted yet. - - - -The same `.state` / `.ready` / `.error` / `.loaded` contract applies to `AssetRef` values such as JSON or text — read `.value` once `.ready` is `true`. A bare path yields an `AssetRef`, so name the payload shape with `Asset.type(...)` when you want to read into it: - - - -`get()` returns before network work settles. A later load failure moves the handle/ref to `'failed'`, rejects its `.loaded` promise, and is reported through `this.app.loader.onError`; calling `get()` again retries and can heal that same source-keyed handle. By contrast, the awaitable returned by `load()` rejects at the call site on failure and reports the same `Error` through `this.app.loader.onError`. Invalid input or a missing type/handler is a synchronous configuration error for either API. A catalog entry's optional `parse` transform must be synchronous — if it returns a Promise (or anything else thenable), the ref it belongs to fails with an explicit error instead of silently awaiting it; do any asynchronous decoding inside the asset handler's load phase instead. - -### Ownership and scopes - -An asset stays in memory for as long as somebody owns it. There are three kinds of owner: - -- **the application** — anything acquired directly on `this.app.loader` is held until the app is destroyed; -- **a scene** — anything acquired through `this.loader` (that is, `scene.loader`) is released when the scene ends; -- **a scope you create yourself** — `this.app.loader.createScope({ name })` returns an owner you destroy when you are done with it. - -Several owners can hold the same asset at the same time. They share one fetch and one resident payload, and one owner letting go never invalidates another: - - - -Create a scope whenever an asset's lifetime is shorter than the application's — a level, a streamed chunk, a UI panel, a prefetch. Every `createScope()` call returns a new owner, never an existing one: the name is a diagnostic label for `inspect()`, never an identifier, so two scopes created under the same name are still two independent owners. - -Scopes nest. `scope.createScope({ name })` creates a child that claims independently but cannot outlive its parent: destroying the child frees only the child's claims, while destroying the parent destroys whatever children it still has, recursively. A scope created through `this.loader.createScope(...)` inside a scene is therefore cleaned up with that scene, even if you never destroy it yourself: - - - -The hierarchy is a lifetime hierarchy only: it never affects asset identity or what a release frees. A child holding the same asset as its parent is two claims, not one. - -`scope.release(...)` drops that scope's claim on one asset; the object itself keeps its identity and heals back to `'loading'` if you `get()` the same source again. It accepts a handle, an `Asset` descriptor, a whole catalog, or a `(type, source)` pair. Releasing a valid form that scope never claimed is a harmless no-op, and releasing twice is always idempotent: - - - -There is deliberately no way to release an application-lifetime claim: `app.loader.get(...)` means "I want this for as long as the app runs". Anything you intend to free later is acquired through a scene or a scope instead. +`Asset.type(...)` describes an asset; constructing it does not fetch anything. Per-asset options belong in that descriptor. Streamed music, video, bitmap fonts, and browser fonts require explicit descriptors and awaited loading rather than pretending that every resource supports placeholder access. - - Passing something that isn't a handle, descriptor, catalog, or `(type, source)` pair — a plain object, a resolved non-seamless resource, anything `release()` can't resolve to a claim — throws instead of doing nothing. If you loaded a non-seamless type by reference (`load(Asset.type('bmFont', ...))`), release it with the same `Asset` descriptor or the `(type, source)` pair, not the resolved resource itself. - +## Use a placeholder deliberately - - Types that aren't seamless — `AudioStream`, `Video`, `BmFont`, `FontAsset`, and custom types — have no placeholder to hand back, so there's nothing to `get()` speculatively. Load them by reference and hold onto the resolved value: `const music = await this.loader.load(Asset.type('music', 'audio/theme.ogg'))`. There is no `has()`/`peek()` guard — for a seamless handle read `.ready` / `.state`; for a non-seamless resource, `await` the load (or its returned handle's `.loaded` promise) before you use it. - +`get()` returns synchronously and acquires a claim. It is **not** a passive cache lookup. For supported resource types it returns a handle that can be used while loading and is filled in place. For value types such as JSON it returns an `AssetRef`; read `.value` only after it is ready. -### Streaming media +Use a placeholder for optional decoration or progressive content whose layout does not depend on the final result. Observe the handle's readiness and error state, or await its `loaded` promise when a later operation needs completion. Do not assume an immediate `get()` has finished a network request. -`music` and `video` assets are streamed by the browser: the loader hands the media element the resolved URL and lets it pull the file in as it plays. Nothing is buffered into memory up front, which is what makes a long track or a full-screen video affordable. +A bare-path `get` reuses the source-keyed handle. Repeated `get(Asset.type(...))` calls instead construct distinct leaves that share the underlying payload. Capture a descriptor-based handle once, not once per frame. - +Use the loader's `peek` API to inspect residency without acquiring or starting work. Choose it for diagnostics, not for retaining a resource that gameplay is about to use. -A streamed asset is **ready when it can start playing**, not when it has fully arrived — the load resolves on the element's `canplay` event. Two consequences are worth internalising: +## Handle failures at the calling boundary -- Progress for a streamed asset is per asset, not per byte. The loader cannot know how much of a browser-owned transfer has landed, and it does not pretend to. -- A failure *before* readiness fails the load and is reported through `loader.onError`. A failure *after* readiness — the connection dropping mid-playback — is a runtime media error and is reported by `video.onError` / `stream.onError` instead, so one load never appears to fail twice. - -Streamed elements get `crossOrigin: 'anonymous'` by default. This matters for video: a cross-origin element without it plays, but cannot be uploaded as a texture. Pass `crossOrigin: null` for playback-only media on a host that sends no CORS headers, accepting that it cannot be rendered. - -Ask for the complete bytes when you want ExoJS to own them — that is a separate operation, not a variant of the load: - - - -`cacheSource` fetches the whole file through the loader's cache pipeline and persists it, without building an element or making anything resident. It is on the application's loader rather than a scene's: nothing it does belongs to a scope, because nothing it does is owned. That is what makes media available offline, and it is the same thing a container (`.exoa`) entry does — container bytes are already owned by the application. See [Working offline](/ExoJS/en/guide/assets/offline/). - -The descriptor is the same one you load. Whichever transport an asset arrives through, it is the same canonical asset: a source streamed from the network, one unpacked from a container and one read back from a cache resolve to one identity and one resident resource, never two. - -The CORS mode is the exception, because it is baked into the element rather than into the bytes: `crossOrigin: null` and the default `'anonymous'` for one URL are two assets, and so is `'use-credentials'`. Nobody is ever handed an element whose CORS mode they did not ask for — a video that cannot be a texture never arrives where a texture was expected. - - - `this.app.loader.inspect()` returns a frozen, sorted snapshot — one row per canonical asset, with its state, how many owners hold it, and whether it's in flight or still queued in the background — for debugging or a support bundle. It's read-only: nothing you do with the returned array changes what's actually loaded. - - -## When are resources available? - -The lifecycle guarantees that: - -- Inside `load`, you can `await this.loader.load(...)` to fetch assets. -- `init` runs once `load` is complete — it is safe to read assets there, and every seamless handle you loaded is already `.ready`. -- After `init`, `this.loader` is still available. Subsequent calls to `this.loader.get('same/path.png')` from `update`, `draw`, or other places return the same instance. -- Calling `this.loader.get('a/path/you-never-loaded.png')` from anywhere kicks off the fetch itself and hands back a `'loading'` placeholder — check `.ready` before relying on the payload being present. - -## Loading on demand - -`this.loader.load(...)` (or `this.app.loader.load(...)` for an app-lifetime claim) works any time, not just inside the `load` hook — the [`Asset.type(...)`](#dynamic-paths-and-non-seamless-types) call shown earlier is exactly that case: - - - -The frame loop continues running while the promise is pending. - -## Loading per game phase - -Split a large project into per-phase catalogs and load each one as the player reaches it, instead of fetching everything up front. A catalog is just an `Assets.from(...)` group (see [Reusable asset references](#reusable-asset-references)): - - - -Load the menu catalog once and keep it resident, then load level catalogs as the player progresses: - - - -Loading is idempotent: a catalog whose leaves are already resident resolves immediately without re-fetching, so you can call `this.app.loader.load(Level1Assets)` again from anywhere without a guard. Treat catalog names and their keys as part of your project's asset contract. - -### Progress for a loading screen - -`loader.load(catalog)` returns a `LoadingQueue` — attach `onProgress` for a per-catalog bar, exactly as in [Progress and parallel loading](#progress-and-parallel-loading): - - - -For one screen that reflects every load regardless of how many catalogs are in flight, use the loader-wide signals from [Signals for a global loading screen](#signals-for-a-global-loading-screen). - -### Handling failures - -Awaiting a catalog rejects if any leaf fails. Wrap the await and read each leaf's status channel to find which one — a failed seamless handle still renders a visible "missing" fallback, so the scene keeps running: - - - -There is no separate bundle error type — every leaf carries its own `.state` / `.error` (see [Status channel](#status-channel-instead-of-throwing)). - -### Pre-warming in the background - -To fetch a catalog ahead of time without blocking the current scene, pass `{ priority: LoadPriority.Background }`. Every leaf is still claimed and heals in place, but its fetch is routed through a low-priority queue while the frame loop keeps running: - - - -A backgrounded leaf is boosted to fetch immediately if something `get()`s or foreground-`load()`s it before the queue reaches it — so there's nothing to guard against. Request it in the background early, then `await this.app.loader.load(Level2Assets)` when you actually need it and it resolves as soon as the in-flight fetch finishes. `this.app.loader.awaitBackground()` resolves once the background queue has fully drained. - -## Caching - -By default the loader fetches from the network every session and keeps nothing between them — no cache is configured, and none of the caching machinery runs. To persist what was acquired across sessions, pass a store: - - - -When constructing the loader through `Application`, pass `LoaderOptions` under the `loader` key: - - - -That is the whole configuration for the usual case: one store, read and written cache-first. On first load an asset is fetched from the network and written to the store; on later sessions it is read back from the store instead. Every asset type is covered, including one an extension installs at runtime — there is nothing to register and no schema to declare. - -### What is cached, and under what identity - -The cache holds the **acquired representation**, not the runtime resource. That is the value the type's [`AssetSourceCodec`](/ExoJS/en/api/asset-source-codec/) read off the response, before any interpretation that would have to be redone anyway — the JSON text rather than the parsed object, the bytes rather than the decoded image. A cache hit still runs `codec.decode` and still builds the resource through the factory, so a factory never sees the persisted form and never learns where the source came from. - -Each record is identified by four stable values: - -| Part | Comes from | -| -------------- | --------------------------------------------- | -| namespace | the asset type's `id` | -| source | the request's `SourceKey` | -| layout version | the type's `layout.version` | -| record | the layout's own name for it (`'value'`) | - -The source key alone is deliberately not enough: it carries no asset type, so two types acquiring one URL would otherwise overwrite each other's representations. Conversely two resources that differ only in how one download is interpreted share a source key — and therefore one cache record and one download. - -Raise `layout.version` when the stored representation changes shape. Records written under the old version stop being found and are re-acquired; there is no migration path, because a cache is reconstructible by definition: - - - -### Policies - -A [`CachePolicy`](/ExoJS/en/api/cache-policy/) decides only the ORDER in which the cache and the network are consulted. Four are built in: - -| Policy | Reads cache | Fetches | Writes | On failure | -| --------------------- | ----------- | ----------------- | ---------------- | -------------------------------------------------------------------------- | -| `CacheFirstPolicy` | first | only on a miss | what it fetched | a read or write failure degrades; the load still succeeds from the network | -| `NetworkFirstPolicy` | on fallback | always, first | what it fetched | falls back to the cache only for a transport or HTTP failure | -| `NetworkOnlyPolicy` | never | always | never | the network failure surfaces | -| `CacheOnlyPolicy` | only | never | never | a miss rejects with `AssetCacheMissError`, a broken store with `AssetCacheError` | - -`CacheFirstPolicy` is the default. `NetworkFirstPolicy` falls back deliberately narrowly: a cancelled load stays cancelled, and a response the codec could not read is a broken source rather than an absent network — serving a stale representation for either would replace a visible failure with a silently wrong asset. - -Writing a policy needs nothing but the [`CacheContext`](/ExoJS/en/api/cache-context/) it is handed: - - - -A policy is never handed a factory, a codec, an asset type or a store handle. It is a stateless object and may be shared between routes and applications: everything one call needs arrives in its context. - -### Failure semantics - -A cache **miss** and a cache **failure** are different events, and stay different: - -- `context.read()` resolves to `{ hit: false }` when no store held the record, and rejects when a store could not answer. -- `context.write()` rejects when a store refused the write, after attempting every one of them. -- A store never swallows a failure to look like an empty cache. Whether to degrade is the policy's decision — which is why `CacheOnlyPolicy` can tell "this was never written" from "the database is broken", and `CacheFirstPolicy` can treat both as a reason to fetch. - -Every store failure is reported on [`Loader.onCacheError`](/ExoJS/en/api/loader/) before any policy degrades it, so a store that is quietly refusing every write stays diagnosable: - - - -### Several stores, and per-type routes - -For more than one tier, configure an [`AssetCache`](/ExoJS/en/api/asset-cache/): - - - -Read stores are consulted in the order they were given and the first hit wins — never raced, so which store answered, which failure surfaced and what was promoted are the same on every run. Read and write lists are separate, so a route can read a cache shipped with the application without writing to it, and `promote: true` copies a hit from a later store into the earlier writable ones. - -Routes are matched by asset type id, in declaration order; the first route that claims a type wins, and anything no route claims falls to the options given at the top level. A route without `types` claims everything from its position onwards. - -To drop cached records — after a content update, or when a player asks for it — call `cache.clear()`, or `cache.clear(typeId)` for one type. - -### The persistent store - -[`IndexedDbStore`](/ExoJS/en/api/indexed-db-store/) keeps every record of every type in a single generic object store, with the namespace as part of the record key rather than as physical schema. That is what lets a type installed at runtime cache immediately: no schema version bump, no object store, no engine-side registration. - -Values are stored through the structured-clone algorithm, so strings, `ArrayBuffer`s, typed arrays, `Blob`s and plain objects all round-trip without a JSON layer. A write resolves only once its transaction has committed. - -A database written under an earlier physical schema is **emptied on first open**. Cached representations are re-fetchable by definition, so they are discarded rather than migrated. - -[`MemoryCacheStore`](/ExoJS/en/api/memory-cache-store/) is the in-process counterpart: it holds values by reference for the lifetime of the page, which makes it a good front tier and the obvious choice in tests. - -## Packs and the asset manifest - -A `.exoa` container is compressed in **blocks**, and a block boundary always falls on an entry boundary, so changing one asset changes that asset's blocks and no others. Passing a [`ContainerBlockStore`](/ExoJS/en/api/container-block-store/) to `loadContainer` is what turns that into traffic: the head is read first, and only the blocks the store does not already hold are fetched. - -That only pays off if a returning client can tell that the pack changed without downloading it. The **asset manifest** is what makes it so. `exo assets pack --manifest dist/assets.json` writes each pack under a name derived from the hash of its own bytes (`level1.4b17e9a02c8d1f35.exoa`) and keeps a small JSON document beside the packs that says which file currently holds each logical pack: - -```json -{ - "version": 1, - "packs": { - "level1": { - "file": "level1.4b17e9a02c8d1f35.exoa", - "hash": "4b17e9a02c8d1f35...", - "byteLength": 812345, - "blockCount": 4, - "entries": ["images/hero.png", "data/level1.json"] - } - } -} -``` - -A pack's URL therefore changes exactly when its bytes change, which means the pack file can be served with an immutable cache lifetime and the manifest is the one URL a client has to re-read. Read it with `loadManifest` and hand the pack to `loadContainer`: +Invalid input or a missing installed asset type can throw synchronously. Fetching or decoding can fail later. `load()` rejects its awaitable queue; a placeholder reports failure through its state, error, and `loaded` promise. The loader also reports asynchronous failures through `onError`. ```ts -import { cacheApiBlockStore, type Loader } from '@codexo/exojs'; - -export const loadLevel = async (loader: Loader): Promise => { - const manifest = await loader.loadManifest('assets.json'); - - await loader.loadContainer(manifest.pack('level1'), { store: cacheApiBlockStore() }); +import { Asset, type LoaderScope, type Texture } from '@codexo/exojs'; + +export const tryPortrait = async (scope: LoaderScope, path: string): Promise => { + try { + return await scope.load(Asset.type('texture', path)); + } catch (error) { + console.error('Portrait unavailable', error); + return null; + } }; ``` -Re-packing after one asset changed produces a new pack name and a manifest that points at it; the blocks that did not change are already in the store, keyed by their own hash, and are never fetched again. Nothing about that is automatic for a pack loaded from a plain URL, which is the reason the manifest exists. - -The manifest describes packs without opening them, so `manifest.packs`, `manifest.has(name)` and `manifest.packFor('images/hero.png')` answer before a single pack byte is fetched. A name the manifest does not carry throws and names the ones it does. Pack bytes that disagree with the record - a wrong length, or a digest that is not the one stated - are an `AssetDecodeError`, the same failure any other unusable asset raises, and they are never written to the cache. How much is checked follows how much is read: the single-request path holds the whole file and verifies the digest, while the block-wise path verifies the length and rests on the content-addressed URL for the rest. The manifest itself is always read from the network, so it is the one asset an offline application cannot get. - -## Save data with a key-value store - -The cache above persists _loaded assets_. For **user save data** (settings, profiles, checkpoints) use a `KeyValueStore` instead — a small key/value surface whose backend you pick by capability: +Catching an error should lead to an intentional fallback or a retry UI, not an unexplained blank object. If the asset is essential, let the failure reach the startup or navigation boundary instead of activating a scene that cannot function. -- [`WebStorageStore`](/ExoJS/en/api/web-storage-store/) — `localStorage`/`sessionStorage`, JSON-serialized: small, synchronous, strings only. -- [`IndexedDbKeyValueStore`](/ExoJS/en/api/indexed-db-key-value-store/) — IndexedDB via structured clone: large, async, stores `Blob`s and `ArrayBuffer`s natively. -- [`MemoryStore`](/ExoJS/en/api/memory-store/) — in-memory and ephemeral, for tests and throwaway data. +`onLoadComplete` means the batch has **settled**, including failures; it is not proof that every asset succeeded. A loading screen can display progress while the awaited queue remains responsible for success or failure. Keep subscriptions owned and remove them when the loading UI ends. - +## Own the lifetime, not the cache entry -All three share one async interface, so swapping `WebStorageStore` for `IndexedDbKeyValueStore` — when a save outgrows Web Storage's ~5 MB quota or needs binary data — is a one-line change. To persist an entire scene, pair a store with [`Scene.serialize()`](/ExoJS/en/api/scene/). +Two scopes can claim one resource. They share the acquisition and resident payload, but releasing one owner's claim does not release the other's: -## Custom asset types (advanced) + -Teach the loader about a domain-specific resource by writing an `AssetType`. One value carries everything the loader needs: what the type is called, which suffixes name it, how its data is read, and how a resource is built from it. +A scope name is a diagnostic label, not a lookup key. Creating two scopes with the same name still creates two independent owners. Child scopes have independent claims but are torn down with their parent. A destroyed scope rejects new work: `get`, `load`, `loadContainer`, and `createScope` throw instead of registering a claim that nothing could release. -First extend `AssetDefinitions` via declaration merging so the type system knows the new type, then write the type: +`scope.release(descriptorOrLeaf)` releases a particular claim; `scope.destroy()` releases all remaining claims and child scopes. Releasing an unclaimed asset is a no-op. A resolved object without claim identity is not necessarily a valid release argument: retain its descriptor or the owning scope instead of guessing ownership from the value. - +Do not call `texture.destroy()` to release an asset that a loader scope owns. Other scopes may share it. Conversely, a render texture or other GPU resource you construct yourself is not made loader-owned merely by assigning it to a sprite. -The `id` is the type's permanent name: it appears in resource identities and in the storage namespace a persistent cache writes under, so it must survive a reload. Reverse-DNS keeps independently authored types apart. +## Group and prefetch related assets -A factory never fetches. It receives source data the loader already acquired, and its only outward reach is `dependencies`, which acquires other *assets*: +Use an [asset catalog](/ExoJS/en/guide/assets/asset-catalogs/) for a named, typed group. `await scope.load(catalog)` resolves to a new map of finished values; the catalog's original leaves also become ready in place. These are related views of the same load, not the same returned object. -| Member | What it gives you | -| -------------------------- | ------------------------------------------------------------------ | -| `context.options` | the options this request carried, typed by the asset type | -| `context.source` | the source as the caller wrote it - resolve relative refs against it | -| `context.dependencies` | loads assets this resource needs, released together with it | -| `context.signal` | the load's cancellation signal | +Background priority is available on catalog and catalog-leaf loads: -Override `resourceIdentity(request)` when an option changes the resource the factory builds, and `sourceIdentity(request)` when it changes which data is acquired - omit both when the source alone identifies the asset. - -Install the type by listing it on an [`Extension`](/ExoJS/en/api/extension/) passed to `ApplicationOptions.extensions`. Installing is the only thing that makes it loadable, and it makes it loadable on that application alone - two applications in one process can map the same suffix to different types without seeing each other. Once installed, the type works everywhere the built-ins do: - - - - -`heightFieldType.asset(...)` always works, and so does `Asset.type('com.example.height-field', ...)` on an application that installed the type. A bare `this.loader.load('maps/level-1.hf')` also works there once the type claims the suffix and the type system mirrors the mapping: +```ts +import { Assets, LoadPriority, type LoaderScope } from '@codexo/exojs'; -```ts no-check -- abbreviated AssetType sketch; the class body is elided -export class HeightFieldAssetType extends AssetType { - public readonly id = 'com.example.height-field'; - public override readonly extensions = ['hf']; - public override readonly leaf = heightFieldSeamlessAdapter; // heals in place, like Texture/Sound - /* … */ -} +const NextLevel = Assets.from({ tiles: 'image/next-level.png' }); -declare module '@codexo/exojs' { - interface ExtensionKindMap { - hf: 'com.example.height-field'; - } -} +export const prefetchLevel = async (scope: LoaderScope): Promise => { + await scope.load(NextLevel, { priority: LoadPriority.Background }); +}; ``` -`Assets.from('maps/level-1.hf')` is the one place this does not reach: a catalog is built with no application, so only the built-in suffixes resolve there. Name the asset with `heightFieldType.asset(...)` in a catalog instead. - -`leaf` decides what a catalog hands out before the payload arrives: `'ref'` (the default) a deferred `AssetRef`, a `SeamlessAdapter` the resource itself healing in place, `'none'` nothing at all. The built-in `texture` and `sound` types are the reference for a seamless resource; `json` / `text` for a deferred value. - - - -## Examples - - - -The catalog snippets on this page, end to end: a mixed `Assets.from` group, a composition, a derived catalog, two parallel queues, and a runtime-computed path. +The caller handles a rejected prefetch promise just as it handles another load. Keep prefetch work in a scope whose lifetime is intentional; using the application loader pins the claim until application teardown. `awaitBackground()` waits for the background queue to drain, including failed items, and does not reject for those individual failures. - +## Residency is not offline storage -The boot-scene snippets on this page — one bar driven by real loader signals, a retry path after failure, and a guarded hand-over to the game scene. +A resident texture is a live runtime resource. A persistent cache holds source representations that can be decoded on a later visit. One does not imply the other. `cacheSource()` can warm configured source storage without constructing a resident resource, but it still acquires data, consumes storage, and follows the selected cache policy. -## Where to go next +Use [Offline and caching](/ExoJS/en/guide/assets/offline/) for persistence and connectivity, [Device variants](/ExoJS/en/guide/assets/device-variants/) for choosing a source before loading, and [Worlds and level streaming](/ExoJS/en/guide/assets/worlds-and-spawning/) for level-owned scopes. Custom asset-type authors should continue with [Authoring extensions](/ExoJS/en/guide/debugging/authoring-extensions/), not add application-specific decoders to ordinary scene code. -That closes the runtime and asset model. The next part, [Rendering](/ExoJS/en/guide/rendering/), covers what you can put on screen — graphics primitives, sprites, text, animation, and render targets. + diff --git a/site/src/content/guide/audio/audio-basics.mdx b/site/src/content/guide/audio/audio-basics.mdx index 67365c630..10acb3266 100644 --- a/site/src/content/guide/audio/audio-basics.mdx +++ b/site/src/content/guide/audio/audio-basics.mdx @@ -291,4 +291,4 @@ Two looping `AudioStream` tracks crossfading back and forth with `crossFade()`. ## Where to go next -The next chapter, [Spatial audio](/ExoJS/en/guide/audio/spatial-audio/), covers 2D positional audio — how to place sounds in world space so they pan and attenuate based on the listener's position. +Continue with [Spatial audio](/ExoJS/en/guide/audio/spatial-audio/), which covers 2D positional audio — how to place sounds in world space so they pan and attenuate based on the listener's position. diff --git a/site/src/content/guide/audio/audio-effects.mdx b/site/src/content/guide/audio/audio-effects.mdx index c30384911..13c4d3379 100644 --- a/site/src/content/guide/audio/audio-effects.mdx +++ b/site/src/content/guide/audio/audio-effects.mdx @@ -363,4 +363,4 @@ Reverb and delay filters on the SFX bus, with live wet/dry and delay-time slider ## Where to go next -The next chapter, [Beat detection](/ExoJS/en/guide/audio/beat-detection/), covers tempo tracking and beat analysis — how to sync game logic to the rhythm of your music. +Continue with [Beat detection](/ExoJS/en/guide/audio/beat-detection/), which covers tempo tracking and beat analysis — how to sync game logic to the rhythm of your music. diff --git a/site/src/content/guide/audio/audio-reactive-visualization.mdx b/site/src/content/guide/audio/audio-reactive-visualization.mdx index aa5a83a6f..42dab36d9 100644 --- a/site/src/content/guide/audio/audio-reactive-visualization.mdx +++ b/site/src/content/guide/audio/audio-reactive-visualization.mdx @@ -1,198 +1,81 @@ --- -title: 'Audio-reactive visualization' -description: 'Bridge audio analysis and beat-detection state into the render pipeline via DataTexture and BeatDetector polling.' +title: 'Audio-reactive visuals' +description: 'Map live audio analysis to bounded visual effects, preserve resource ownership, and distinguish the audio and display clocks.' --- -import ExamplePreview from '../../../components/ExamplePreview.astro'; import SourceSnippet from '../../../components/SourceSnippet.astro'; -import Callout from '../../../components/Callout.astro'; - -# Audio-reactive visualization - -An audio-reactive scene is one where visuals move, change color, or trigger effects in response to the music or sound currently playing. ExoJS gives you two building blocks for this: [`AudioAnalyser`](/ExoJS/en/api/audio-analyser/) for per-frame spectrum data, and [`BeatDetector`](/ExoJS/en/api/beat-detector/) for rhythmic timing. You combine them inside `update` — sample what you need, map it to visual properties, and let the renderer handle the rest. - -## The pipeline - -The flow is the same for any audio-reactive setup: - -1. Tap a live audio source (an `AudioBus` such as `app.audio.music`, or a `Voice`) with an `AudioAnalyser` and/or a `BeatDetector`. -2. In `update`, read spectrum values, beat envelopes, or subdivision phase. -3. Apply those readings to drawable properties — scale, position, tint, shader uniforms, particle spawns. - -Both the analyser and the beat detector connect as parallel taps. They never affect the source's main audio routing, so you can attach them to anything that's already playing. They take a live source (bus, voice, `AudioNode`, or `MediaStream`) — not a `Sound`/`AudioStream` descriptor. - -## Building an analyser - -An `AudioAnalyser` wraps a Web Audio `AnalyserNode` and exposes frequency and time-domain data. You point it at a source, then call its getters each frame: - - - -You can also pass `source` as a constructor option: `new AudioAnalyser({ source: app.audio.music, fftSize: 1024 })`. Either form works; the setter is useful when you need to switch sources at runtime. To analyse a single track in isolation, keep the `Voice` from `play()` and pass that instead of the bus. - -## Spectrum sampling strategies - -The raw FFT returns N bins linearly spaced from 0 Hz to the Nyquist frequency (half the sample rate). For most visualizations this spacing is awkward — bass gets a handful of bins, treble gets hundreds. - -`AudioAnalyser` gives you four ways to read the spectrum, each with a byte and a float variant: - -| Method | Output | Use case | -|--------|--------|----------| -| `getSpectrum()` | `Uint8Array` (0–255 per bin) | Direct bar-graph visualizations | -| `getSpectrumFloat()` | `Float32Array` (dBFS per bin) | Precise amplitude measurement | -| `getSpectrumMel()` | `Uint8Array` (0–255 per band) | Perceptually-weighted display bands | -| `getSpectrumLog()` | `Uint8Array` (0–255 per band) | Octave-uniform display (each octave gets equal visual width) | - -The mel and log methods accept an optional `bands` parameter (default 32) and frequency range (`fMin`/`fMax`, default 20 Hz to 20 kHz, clamped to Nyquist). The filterbanks are built once per `(bands, fMin, fMax, fftSize)` combination and cached on the analyser instance — subsequent calls at the same parameters are just a weighted sum. - - - -## Beat-driven animation: polling vs. events - -`BeatDetector` gives you both event-style signals and per-frame polling getters. Which one you use depends on what kind of visual you are driving. - -The event signals — `onBeat`, `onDownbeat`, `onBarStart`, `onTempoChange` — fire when the worklet processor detects a beat and dispatches a message to the main thread. Use them for one-shot side effects: spawning a particle burst, triggering a screen flash, advancing a sequencer: - - - -For continuous animation — something that smoothly decays between beats rather than snapping — use the polling getters inside `update`: - -| Getter | Returns | Description | -|--------|---------|-------------| -| `pulse` | `number` (0–1) | Decaying envelope. Peaks at 1 on every beat, then halves every `pulseHalfLife` seconds (default 0.15). | -| `barPulse` | `number` (0–1) | Same shape, but resets only on downbeats and decays per `barPulseHalfLife` (default 0.3). | -| `justBeat` | `boolean` | `true` for the visual frame(s) within `justBeatWindow` seconds of a beat onset (default 0.03). | -| `secondsSinceLastBeat` | `number` | Elapsed time in seconds since the most recent beat. Returns 0 before the detector locks. | -| `subdivisionPhase(n)` | `number` (0–1) | Phase within an N-subdivision of the current beat. `subdivisionPhase(4)` gives 16th-note phase. | - -These are all pure derivations from the detector's internal state — they do not allocate, do not fire events, and are safe to call every frame: +import ExamplePreview from '../../../components/ExamplePreview.astro'; - +# Audio-reactive visuals -Tune the envelope shapes with the mutable public fields `pulseHalfLife`, `barPulseHalfLife`, and `justBeatWindow`. Smaller values give snappier responses; larger values give longer afterglow. +An audio-reactive scene maps a live signal to a visual property. Use an `AudioAnalyser` for amplitude and spectrum, and a `BeatDetector` when the visual needs an estimated rhythmic grid. Start with a sound that already plays reliably through the intended bus or voice; analysis does not replace loading, playback, or audio unlock. - -`justBeat` is only `true` for a frame or two per beat (a ~30 ms window), so a frame-rate dip below ~30fps can skip the window entirely. For effects that must never drop a beat — a sound cue, a scored hit — drive them from the `onBeat` signal, which is dispatched once per beat regardless of frame rate. - +The analyser and detector tap a live bus, voice, node, or supported stream. They do not analyse a `Sound` or `AudioStream` asset descriptor merely because that asset has loaded. A bus tap sees the signals routed through that bus; a voice tap isolates one playback. -## Spectrograms with DataTexture +## Read once and update existing visuals -When you want a scrolling spectrogram — a 2D texture that updates each frame with a new column of frequency data — use [`DataTexture`](/ExoJS/en/api/data-texture/). Its pixels live in a CPU-side typed array that you mutate directly, then upload to the GPU with `commit()` or `commitRect()`. +The following scene expects audio already playing on the application's music bus. Without that source it correctly draws a silent spectrum; it does not start a track itself: -`DataTexture` defaults to nearest-neighbor filtering with clamp-to-edge wrapping, which is what you want for spectrum data where bilinear filtering would corrupt sampled values. + - -A scrolling spectrogram rewrites just one column per frame. `commitRect(x, y, width, height)` re-uploads that single region to the GPU — far cheaper than `commit()`, which re-uploads the entire texture every frame. - +`this.track` releases the analyser with the scene. The scene root owns the graphics. Playback may have a different lifetime: application-owned music can continue after this visualizer ends, while a scene-owned voice should stop with its own scene. -A simple scrolling spectrogram: +Read the required analysis data once per update and use it for several visual properties. Recreating an analyser, texture, filter, or particle system every frame obscures the mapping and creates unrelated work. - +## Choose a useful analysis scale -For ring-buffer patterns where only the newest column changes, `commitRect(col, 0, 1, 64)` uploads just that one-pixel-wide column — cheaper than uploading the whole texture every frame. +Raw FFT bins are linearly spaced in frequency. Logarithmic or mel-spaced bands are often more useful for a display because low-frequency structure remains visible without dedicating most bars to high frequencies. -## A compact practical scene +Byte-valued spectrum data is convenient for a normalized visual intensity. Floating-point spectrum values describe the analyser's decibel-domain output; they are not calibrated acoustic loudness or an exact physical amplitude measurement. Choose a transfer function, floor, and smoothing that suit the image rather than treating every sample as an absolute meter. -Here is a complete scene that combines an analyser-driven bar graph with beat-triggered background color changes: +Map a normalized intensity into a bounded range: a small scale change, a limited blur radius, or a capped spawn rate. Keep a base value separate from the animated offset so camera shake or pulse animation does not accumulate drift. Rate, particle lifetime, bursts, and system capacity must agree; a maximum rate alone does not establish a safe occupancy. - +## Rhythmic envelopes and one-shot events -## Timing semantics +Use `pulse`, `barPulse`, or a subdivision phase for continuous motion. Use beat signals for one-shot visual requests such as a burst or a color change. If several messages arrive between visual frames, decide whether to aggregate, coalesce, or bound them rather than spawning an unbounded backlog. -A few things to keep in mind when working with audio-driven visuals: +`justBeat` is a time-window predicate. Depending on frame timing, that window can be observed by zero, one, or several frames. It is not an edge-triggered event and must not directly repeat a one-shot action on every frame that sees `true`. -- The analyser spectrum reflects the current frame's audio buffer. You sample it once per `update` call; the `AnalyserNode`'s `smoothingTimeConstant` (default 0.8) smooths between consecutive analyses. -- Beat detector events are dispatched from the worklet processor to the main thread. They arrive asynchronously and may be arbitrarily close to or slightly behind the visual frame; the worklet's internal temporal resolution is finer than `requestAnimationFrame` cadence. The polling getters (`pulse`, `justBeat`, `subdivisionPhase`) sample the detector's most recent cached state on the main thread. -- `justBeat` is `true` for at most one or two visual frames per beat (with the default 30ms window at 60fps). If your frame rate drops below ~30fps, you may miss a `justBeat` window. For critical beat-triggered effects, prefer the `onBeat` signal — it is dispatched per beat and delivered on the main thread. -- Every timestamp the detector reports — `BeatInfo.audioTime`, `nextBeatTime`, the `lookahead` entries and `analysisTime` — is an `AudioContext.currentTime` value, so [`AudioOutputClock`](/ExoJS/en/api/audio-output-clock/) converts any of them straight onto the `performance.now()` timeline. -- `analysisTime` names the newest audio the current state describes, and `analysisLatency` is how far behind the context clock that state already was when the main thread received it. The figure is measured rather than assumed and covers the analysis hop together with the worklet-to-main-thread delivery, so it is a budget rather than an exact age. +Beat messages are asynchronous worklet-to-main-thread notifications. They preserve a different boundary from polling, but do not promise zero latency, hard real-time delivery, or a perfect beat grid. A detector's estimate is suitable for reactive presentation; rhythm-game scoring needs an explicit authoritative timing and calibration design, not an arbitrary confidence threshold. -### Tempo confidence is not phase confidence +Tempo confidence and phase confidence answer different questions. A stable estimated BPM can coexist with uncertain beat placement. Use that distinction to soften or disable a visual response while tracking is uncertain. See [Beat detection](/ExoJS/en/guide/audio/beat-detection/) for the detector's acquisition and event lifecycle. -`confidence` says how sure the detector is of the **tempo**; `phaseConfidence` says how well recent onsets support the **position** of the beat grid. They come apart more often than they look like they should — a passage with a rock-solid pulse played with heavy rubato reads high confidence and low phase confidence, and so does anything the detector has had to free-run through because no onset arrived where it predicted one. +## Upload a bounded spectrum history -Gate anything that has to land exactly on the beat — a scored hit, a quantised trigger, a flash meant to be felt rather than seen — on `phaseConfidence`, and keep `confidence` for decisions about the tempo itself, such as whether to display a BPM readout at all: +A `DataTexture` can store a fixed-size history without reconstructing an image every frame: -```ts -import { BeatDetector } from '@codexo/exojs-audio-fx'; + -const detector = new BeatDetector(); +Pass 64 byte-valued bands, for example from `getSpectrumMel(undefined, { bands: 64 })`. The texture stores the value in its red channel. A material or filter can map that value to a color. Track the `SpectrumHistory` instance or call its `destroy` method when its owner ends. -const shouldScoreHits = (): boolean => detector.phaseConfidence > 0.6; -const shouldShowBpm = (): boolean => detector.confidence > 0.5; -``` +This is a ring buffer: column positions wrap. It does not automatically present time from oldest to newest. Use `nextColumn` when sampling or rearranging the display if chronological scrolling is required. -## Lining audio up with the frame clock +`commitRect` transfers the changed region rather than the entire texture. That reduces uploaded data; the actual performance benefit depends on update size and upload overhead. Keep the texture dimensions and history length bounded, and measure before treating many tiny uploads as universally cheaper. -Web Audio schedules in `AudioContext.currentTime` and your frame loop measures in `performance.now()`. The two are different clocks with different origins, and `currentTime` runs ahead of what the listener actually hears by the length of the output path. Subtracting one from the other gives a number that looks plausible and is wrong by tens of milliseconds - enough to visibly desynchronise a rhythm game. +## Audio time is not frame time -`AudioOutputClock` correlates them: +Beat timestamps are expressed on the `AudioContext.currentTime` timeline. Frame callbacks use a different clock. Do not subtract an audio timestamp directly from `performance.now()`. ```ts import { AudioOutputClock } from '@codexo/exojs'; const clock = new AudioOutputClock(); - -/** How long from now until the listener hears the sample scheduled for `contextTime`. */ -const msUntilHeard = (contextTime: number): number => clock.contextToPerformanceTime(contextTime) - performance.now(); +const millisecondsUntilOutput = (contextTime: number): number => + clock.contextToPerformanceTime(contextTime) - performance.now(); ``` -Where the browser supports `AudioContext.getOutputTimestamp()`, the correlation names the sample the device is playing at this instant, so the converted time already accounts for the output path and needs no latency arithmetic of your own. Where it does not, the clock pairs `currentTime` with `performance.now()` and marks the snapshot `'estimated'` so you can tell the two cases apart: - -```ts -import { AudioOutputClock } from '@codexo/exojs'; - -const snapshot = new AudioOutputClock().snapshot(); +Where an output timestamp is available, the clock correlates the audio sample being presented with performance time. Its fallback is marked as estimated. Inspect that source instead of adding guessed latency compensation to both paths. Display latency and the application's choice of presentation frame remain outside the correlation. -if (snapshot.source === 'estimated') { - // No output timestamp here: the correlation leads the true output by roughly - // `outputLatency`, which this environment may not report either. -} -``` +The analyser reflects an audio analysis window, not a buffer synchronized exactly to the current visual frame. Detector `analysisTime` and latency information describe the received analysis state; they do not eliminate main-thread scheduling delays. -Display latency is deliberately outside this: only your code knows how far ahead of the photons its render loop runs, so the decision of which frame to draw a beat on stays yours. +## Put the scene together -## Examples +Keep playback ownership, analysis ownership, and rendering ownership explicit. Start from [Audio basics](/ExoJS/en/guide/audio/audio-basics/), add one analyser, then map one bounded visual property. Add beat-driven bursts only after the continuous response and its timing are understood. - -A full audio visualisation: frequency-domain bars, time-domain waveform overlay, and per-band energy meters drawn on a 2D canvas then uploaded as a texture. - - -A sprite pulses and particles burst on the `onBeat` signal while the BPM estimate updates. - -## Where to go next + -For color-oriented effects driven by audio — palette cycling, noise overlays, shader filters — see [Filters](/ExoJS/en/guide/effects/filters/). To write a custom shader that samples a spectrogram `DataTexture`, see [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/). If you came here before reading [Beat detection](/ExoJS/en/guide/audio/beat-detection/), that chapter covers the full event API, tempo tracking, and frequency band state in detail. +The examples demonstrate display and effect mappings. They are not calibrated loudness meters, authoritative rhythm timelines, or equal-work CPU/GPU benchmarks. Continue with [Particles](/ExoJS/en/guide/effects/particles/) or [Filters](/ExoJS/en/guide/effects/filters/) for the visual mechanism rather than duplicating its setup inside every audio recipe. diff --git a/site/src/content/guide/audio/beat-detection.mdx b/site/src/content/guide/audio/beat-detection.mdx index 9fdbe99fe..1fa405c6d 100644 --- a/site/src/content/guide/audio/beat-detection.mdx +++ b/site/src/content/guide/audio/beat-detection.mdx @@ -230,4 +230,4 @@ The beat and tempo example also demonstrates the `tempo`, `confidence`, and `ons ## Where to go next -The next chapter, [Audio-reactive visualization](/ExoJS/en/guide/audio/audio-reactive-visualization/), covers how to bridge the detector's output into the render pipeline — polling getters for continuous animation, spectrum data from `AudioAnalyser`, spectrograms with `DataTexture`, and building full audio-reactive scenes. +Continue with [Audio-reactive visualization](/ExoJS/en/guide/audio/audio-reactive-visualization/), which covers how to bridge the detector's output into the render pipeline — polling getters for continuous animation, spectrum data from `AudioAnalyser`, spectrograms with `DataTexture`, and building full audio-reactive scenes. diff --git a/site/src/content/guide/audio/spatial-audio.mdx b/site/src/content/guide/audio/spatial-audio.mdx index 5210732f9..7b6809882 100644 --- a/site/src/content/guide/audio/spatial-audio.mdx +++ b/site/src/content/guide/audio/spatial-audio.mdx @@ -375,4 +375,4 @@ The same example lets you switch distance models without restarting its voice. ## Where to go next -The next chapter, [Audio effects](/ExoJS/en/guide/audio/audio-effects/), covers the audio filter system — how to shape sound with compressors, EQs, reverb, delay, and worklet-based effects like pitch shifting and ducking. +Continue with [Audio effects](/ExoJS/en/guide/audio/audio-effects/), which covers the audio filter system — how to shape sound with compressors, EQs, reverb, delay, and worklet-based effects like pitch shifting and ducking. diff --git a/site/src/content/guide/debugging/authoring-extensions.mdx b/site/src/content/guide/debugging/authoring-extensions.mdx index 2b4216d39..24312294c 100644 --- a/site/src/content/guide/debugging/authoring-extensions.mdx +++ b/site/src/content/guide/debugging/authoring-extensions.mdx @@ -7,7 +7,7 @@ import Callout from '../../../components/Callout.astro'; # Authoring extensions -The [previous chapter](/ExoJS/en/guide/debugging/custom-renderers/) showed how to register a custom renderer against one running `Application`. An **extension** packages that same work — plus custom asset handlers and node serializers — into a single immutable descriptor you can publish as an npm package and drop into any project. This is exactly how the official `@codexo/exojs-particles`, `@codexo/exojs-tiled`, `@codexo/exojs-tilemap`, and `@codexo/exojs-physics` packages plug into the core. +The [Custom renderers chapter](/ExoJS/en/guide/debugging/custom-renderers/) showed how to register a custom renderer against one running `Application`. An **extension** packages that same work — plus custom asset handlers and node serializers — into a single immutable descriptor you can publish as an npm package and drop into any project. This is exactly how the official `@codexo/exojs-particles`, `@codexo/exojs-tiled`, `@codexo/exojs-tilemap` packages plug into the core. Physics, pathfinding, and lighting are constructed directly instead; an optional package does not necessarily contribute an extension descriptor. The model is deliberately small and **add-only**: an extension can only *contribute* capabilities (a new drawable type's renderer, a new asset type, a new serializable node). It never patches or replaces core behaviour. The core ships nothing extension-specific — an `Application` understands your drawable or asset type only once its extension is active. @@ -176,7 +176,7 @@ export * from './public'; }, "files": ["dist/esm/", "README.md", "LICENSE"], "peerDependencies": { - "@codexo/exojs": "0.15.x" + "@codexo/exojs": "0.18.x" }, "devDependencies": { "@codexo/exojs": "workspace:*" @@ -190,8 +190,8 @@ export * from './public'; ExoJS is pre-1.0, so every minor release is a **clean break** — no back-compat shims. Extensions track the core in **lockstep**: -- Pin the peer range to the core's current minor (`"@codexo/exojs": "0.15.x"`), and publish a matching minor of your extension for each core minor. -- Publish your extension version to move in step with the core version it targets; document the compatibility in a small table in your README (`0.15.x ↔ 0.15.x`), as the official packages do. +- Pin the peer range to the core's current minor (`"@codexo/exojs": "0.18.x"`), and publish a matching minor of your extension for each core minor. +- Publish your extension version to move in step with the core version it targets; document the compatibility in a small table in your README (`0.18.x ↔ 0.18.x`), as the official packages do. - Because the id-collision check is by descriptor identity, a mismatched-version duplicate install throws loudly at registration rather than failing subtly at draw time. ## A tiny extension end to end diff --git a/site/src/content/guide/debugging/backend-comparison.mdx b/site/src/content/guide/debugging/backend-comparison.mdx index afd2b1dfb..a70b6aa56 100644 --- a/site/src/content/guide/debugging/backend-comparison.mdx +++ b/site/src/content/guide/debugging/backend-comparison.mdx @@ -1,121 +1,69 @@ --- -title: 'Backend comparison' -description: 'Compare backend behavior and decide what to ship.' +title: 'Backend selection and portability' +description: 'Select a rendering backend, read measured parity evidence, and handle capabilities and device loss without assuming browser-wide guarantees.' --- -import ExamplePreview from '../../../components/ExamplePreview.astro'; -import Callout from '../../../components/Callout.astro'; import ParityMatrix from '../../../components/ParityMatrix.astro'; +import ExamplePreview from '../../../components/ExamplePreview.astro'; -# Backend comparison +# Backend selection and portability -ExoJS renders through one of two backends: WebGL2 or WebGPU. The choice is automatic by default — the engine picks WebGPU when `navigator.gpu` is available, WebGL2 otherwise. You can override this with `backend: { type: 'webgpu' }` or `backend: { type: 'webgl2' }` in `ApplicationOptions`. +ExoJS has WebGL2 and WebGPU backends behind the same high-level drawing API. Backend selection is an application-level decision. It is not a performance preset, and a browser exposing `navigator.gpu` is not proof that every required feature works on that device. -## Selection +## Start with automatic selection -```ts -import { Application } from '@codexo/exojs'; +The default `backend.type` is `auto`. Automatic selection considers WebGPU availability and deliberately selects WebGL2 for detected WebKit user agents. Explicitly requesting WebGPU bypasses that selection policy; it does not make an unavailable or failing adapter usable. -// Auto-select (prefers WebGPU, falls back to WebGL2) -const app = new Application(); +```ts +import { Application, RenderBackendType } from '@codexo/exojs'; -// Pin to WebGPU — throws if unavailable -const gpuApp = new Application({ backend: { type: 'webgpu' } }); +const app = new Application({ + backend: { type: 'auto' }, + canvas: { width: 800, height: 600, mount: 'body' }, +}); -// Pin to WebGL2 -const glApp = new Application({ backend: { type: 'webgl2' } }); +await app.start(); +console.log(app.backend.backendType === RenderBackendType.WebGpu ? 'WebGPU' : 'WebGL2'); ``` -At any point, check `app.backend.backendType` (a [`RenderBackendType`](/ExoJS/en/api/render-backend-type/) enum with values `WebGl2` and `WebGpu`) to branch backend-specific code. - - -`auto` (the default) picks WebGPU where `navigator.gpu` exists and WebGL2 everywhere else, so most projects never set `type` at all. Pinning `type: 'webgpu'` turns availability into a hard requirement — it throws on browsers without WebGPU instead of falling back. - - -## Feature parity +This starts an application without a scene so that the example isolates backend initialization. A normal project registers and starts a scene as in [Your first scene](/ExoJS/en/guide/getting-started/your-first-scene/). Handle a rejected startup at the application boundary; do not continue as though a backend exists. -The core rendering pipeline — sprites, meshes, graphics, text, containers, masks, filters, render-targets, views, culling — works identically on both backends. The API is the same. You write one scene and it renders on both. +Inspect `app.backend` and `app.capabilities` after startup resolves. Set `backend: { type: 'webgl2' }` or `{ type: 'webgpu' }` to test a particular path. Pinning a backend makes that path a requirement instead of an automatic choice. Changing the backend requires a new application; an example's backend switch is not an in-place mutation of the running renderer. -### What has been measured +## Read parity as evidence -That claim is easy to make and hard to keep, so a conformance suite renders the same scenes through both backends in each browser and compares the frames pixel by pixel. The table below reports what it found — not what we believe. +The conformance suite exercises specific scenes through both backends and compares their output. The matrix below is generated from the repository's evidence, including its recorded browser and release context: -Read it as evidence, not as a support promise. A `?` means nobody has measured that combination yet; it is not a statement that the feature is broken, and it is deliberately visible rather than hidden. The scenes use a texture whose every texel encodes its own coordinates, so a matching frame proves the right texel landed in the right pixel — not merely that two images happened to look alike. - -The table names the release it is guaranteed as of. `release:cut` refuses to cut a version until the evidence for Chromium and Firefox was measured on the commit being released, then stamps that version onto the rows — so the guarantee is tied to a version rather than to whenever someone last ran the suite. Rows measured after a release carry no version until the next one claims them. - -Chromium is measured on every CI run. Firefox and Safari need a machine with a display and are measured by hand, which is why their rows carry an older date. - - -`pnpm test:parity` runs the matrix on Chromium, `test:parity:firefox` and `test:parity:safari` on the others. Each run rewrites only the rows for the browser it exercised. - - - -The Safari column above shows it — `auto` already keeps Safari on WebGL2 for this reason. Stick with WebGL2 there rather than pinning `backend: { type: 'webgpu' }` for production use. - - -Feature parity is a different question from cost, and the matrix above says nothing about the latter. One stress workload has been measured end to end on both backends. In the `scrolling-world` case of the engine's benchmark harness — one million static sprites spread over four times the viewport's area, with a moving camera — both backends draw the visible world in a single draw call: a large static world can keep its renderable state persistent while the camera moves, so moving the camera does not require rebuilding the visible scene from scratch. On the documented reference run (headless Chromium, NVIDIA GeForce RTX 5070 Ti, ExoJS 0.15.2, 2026-08-15) CPU-p95 measured 10.08 ms on WebGL2 and 10.45 ms on WebGPU. - -Read that as one dated result for one workload on one machine, not as a backend-wide guarantee and not as a frame-rate claim. CPU-p95 is the time the engine spent in the render path on the CPU in its 95th-percentile frame — not the frame's GPU time, and not a whole game frame with update, input, and physics in it. The two backends' full-frame columns come from different instruments (a hardware timer query on WebGL2, a queue-completion wall clock on WebGPU) and are not comparable one-to-one. Methodology, metric definitions, the environment metadata recorded per run, and the command that reproduces this cell live in the harness's README: [`@codexo/exojs-bench`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-bench/README.md). +A matching case is evidence for that case under the recorded conditions. It is not proof for every shader, driver, device, or composition of features. An unmeasured cell is unknown, not automatically a failure. Use the recorded provenance rather than translating a browser name into a permanent support promise. -### Known differences +Parity and performance answer different questions. A scene can match visually and have different submission costs or GPU time on two backends. Use the [benchmark methodology](/ExoJS/en/benchmarks/) for measured workloads and [Performance](/ExoJS/en/guide/debugging/performance/) for your own application. -Where differences exist, they are performance characteristics or rendering-path specifics, not API gaps: +## Where portability needs attention -| Area | WebGL2 | WebGPU | -|------|--------|--------| -| Sprite batching | Multi-texture batched (up to 8) | Multi-texture batched (up to 8) | -| Particle simulation | CPU | CPU (default); optional GPU compute update path when all update modules implement `wgsl()` | -| MeshMaterial | GLSL ES 3.00 | WGSL | -| ShaderFilter | `glsl` source (GLSL ES 3.00) | `wgsl` source (WGSL) | -| GPU compute | Not available | Raw `backend.device` access for compute + custom pipelines | -| Engine-emitted debug pass labels | Not currently emitted | Emitted on key passes (e.g. `ShaderFilter pass`) | +| Area | Portable workflow | Boundary to check | +| --- | --- | --- | +| Sprites, graphics, text, views | Use the high-level scene and drawing APIs. | Sampling, clipping, target formats, and device limits still matter. | +| Custom materials and filters | Supply the shader forms required by each supported backend. | GLSL and WGSL are separate programs; TypeScript does not validate their visual equivalence. | +| Particle simulation | Provide a usable CPU path or require the GPU path explicitly. | WebGPU compute eligibility depends on the system and its modules, not just the selected backend. | +| Render textures and lighting | Inspect capabilities and choose supported formats. | Float renderability, resolution, and optional normal or radiance paths have distinct constraints. | +| Raw GPU resources | Keep ownership and restoration in the custom renderer. | A raw WebGPU pipeline has no automatic WebGL2 implementation. | -Both backends batch sprites from up to 8 different textures into a single draw call. For most projects with a single texture atlas, the batching difference is irrelevant — the practical distinction is only visible in multi-atlas scenes. +Both backends support multi-texture sprite batching. That does not mean arbitrary sprites collapse into one draw: materials, blend state, clipping, capacity, and ordering can split work. Do not select a backend using a single texture-count rule. -Geometric clip parity is also aligned: `RenderNode.clip` with a `Geometry` `clipShape` works on both backends for `Sprite` (default/custom material), `Mesh` (default/custom material), `Graphics`, `Text` / `BitmapText`, and `ParticleSystem`. `Rectangle` / bounds clips remain on the scissor path. +For a `ShaderFilter`, a missing language can fail when the filter attaches to the other backend. Providing both strings avoids that omission, but each program still needs compilation and visual testing. [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/) explains the material boundary; [Shader files](/ExoJS/en/guide/shipping/typed-shaders/) explains build-time authoring. -## Particles and GPU +## Device loss and restoration -Particle simulation (spawn, integration, module updates) runs on the CPU by default on both backends. On WebGPU, when every registered update module is GPU-eligible (implements `wgsl()`), the system compiles a composite WGSL compute shader and runs the full update pipeline on the GPU in one dispatch. This is the GPU path: simulation moves to the GPU, and the renderer reads instance data from a shared buffer — no CPU readback. +The application exposes `onBackendLost` and `onBackendRestored`. Engine-owned resources participate in the engine's recovery path. Resources allocated directly from a raw device or context remain your responsibility: recreate pipelines, buffers, textures, and cached handles when their underlying device changes. -On WebGL2, or when any update module lacks `wgsl()`, the system runs on CPU. The particle rendering path (instanced draw calls) is the same on both backends. Check `system.gpuMode` to know which path is active. +For an SDK renderer, implement repeatable `connect` and `disconnect` behavior rather than keeping stale GPU handles across restoration. A recovered canvas with one missing custom effect often indicates that the effect retained an old resource. See [The renderer SDK contract](/ExoJS/en/guide/debugging/renderer-sdk-contract/). -## Custom shaders +## Test the deployment you intend to ship -A [`Shader`](/ExoJS/en/api/shader/) accepts both `glsl: { vertex, fragment }` and `wgsl` source; wrap it in a [`MeshMaterial`](/ExoJS/en/api/mesh-material/) to bind uniforms and attach it to a `Mesh`. The renderer picks the appropriate language for the active backend. Provide both for cross-backend portability. [`Shader.detectUniformDrift()`](/ExoJS/en/api/shader/#methods) compares declared uniforms across languages for CI-style verification. +Exercise startup failure, the fallback you intend to support, resize, hidden-tab return, and device loss where the test environment permits it. Test the production bundle on representative hardware, not just a development tab on the fastest machine available. Avoid silently enabling an effect whose required shader or format is absent. -For screen-space effects, use `ShaderFilter` and give it both a `glsl` and a `wgsl` source. The uniform-value types are the same either way — only the shader language differs, and the filter picks the one the active backend needs. A filter carrying only one language throws `ShaderFilterBackendError` when it attaches to the other backend. - -## Direct GPU access - -The WebGPU backend exposes `backend.device` (`GPUDevice`), `backend.context` (`GPUCanvasContext`), and `backend.format` (`GPUTextureFormat`). You can create custom pipelines, vertex buffers, and command encoders directly — the [`custom-triangle-renderer`](/ExoJS/en/playground/?slug=custom-renderers/custom-triangle-renderer) example demonstrates this. On WebGL2, `backend.context` is also publicly available as a `WebGL2RenderingContext`, but the direct compute-style escape hatch exists only on WebGPU. - -## Device loss - -Both backends handle loss events. On WebGL2, the engine attempts context restore automatically. On WebGPU, ExoJS also attempts automatic device recovery and emits `onBackendLost` / `onBackendRestored` on the `Application`. - - -The engine restores the resources it owns, but anything you allocated through direct `backend.device` access is gone after a loss. Recreate those raw pipelines and buffers in an `onBackendRestored` handler, or the scene comes back missing your custom rendering. - - -## Choosing for production - -- **Ship with `auto`** (the default). Most users get WebGPU, older browsers get WebGL2. You write one codebase, both paths work. -- **Pin to `webgpu`** if you depend on features only available through direct backend access (compute shaders, raw GPU pipelines) and can accept the browser-support trade-off. -- **Pin to `webgl2`** if you are targeting a specific environment where WebGPU is unreliable or unavailable. This is uncommon — auto-selection covers this case. - -The [Sprite Batching Laboratory](/ExoJS/en/playground/?example=performance/backend-comparison) lets you toggle backends at runtime (press B) while keeping its seeded sprite workload the same. - -## Examples - - -Adjust sprite count and texture diversity, then use the performance overlay. Press B to switch between WebGL2 and WebGPU when an adapter is available. The result describes this workload, not general backend performance. - -## Where to go next - -The next chapter, [Custom renderers](/ExoJS/en/guide/debugging/custom-renderers/), covers extending the render pipeline with your own passes — no-op passes, full-screen triangles, and bridging a custom renderer into the engine's frame. +The seeded workload helps compare the two backend paths while controlling the scene. Its result is about that workload, not an overall backend winner. diff --git a/site/src/content/guide/debugging/custom-renderers.mdx b/site/src/content/guide/debugging/custom-renderers.mdx index 0a5086f76..9e627e36a 100644 --- a/site/src/content/guide/debugging/custom-renderers.mdx +++ b/site/src/content/guide/debugging/custom-renderers.mdx @@ -1,122 +1,62 @@ --- -title: 'Custom renderers' -description: 'Extend rendering with custom passes and backend-specific logic.' +title: 'Custom rendering' +description: 'Choose the smallest extension point that expresses custom drawing, and keep pass ordering and GPU ownership explicit.' --- -import ExamplePreview from '../../../components/ExamplePreview.astro'; import SourceSnippet from '../../../components/SourceSnippet.astro'; -import Callout from '../../../components/Callout.astro'; - -# Custom renderers - -"Custom rendering" in ExoJS means inserting your own draw logic into the frame — either as a pass between normal draws, or as direct GPU work that bypasses the engine's renderer system entirely. The extension surface is intentionally small: two public mechanisms, plus the existing `Mesh`/`MeshMaterial`/custom shader filter primitives covered in earlier chapters. - -The distinction: a custom renderer controls *when* and *how* things are drawn. A [`MeshMaterial`](/ExoJS/en/api/mesh-material/) controls *what shader* an existing `Mesh` uses. A custom [`ShaderFilter`](/ExoJS/en/api/shader-filter/) controls a screen-space post-render effect on a drawable's output. You reach for custom renderers when none of those primitives fit — you need to issue draw calls at a specific point in the frame, or you need to bypass ExoJS drawable types entirely. - -## CallbackRenderPass - -[`CallbackRenderPass`](/ExoJS/en/api/callback-render-pass/) wraps an arbitrary draw callback as one [`RenderPass`](/ExoJS/en/api/render-pass/) and slots it into a [`RenderPipeline`](/ExoJS/en/api/render-pipeline/) between other passes. The callback receives the [`RenderingContext`](/ExoJS/en/api/rendering-context/) — the same high-level object your scene's `draw` method gets. For low-level draws (rendering a `Graphics` directly, immediate-mode geometry), reach through `context.backend` to the active `RenderBackend`: - - -Reach for `CallbackRenderPass` for almost all custom draw work — it slots into the normal pipeline and hands you the high-level `RenderingContext`. Drop to raw `backend.device` only when no pass primitive can express what you need. - - - - -The pipeline runs its passes in order, so the callback's draws land exactly where you place the pass — here, between the two sprite passes. Use `CallbackRenderPass` for procedural geometry (arcs, connectors, debug lines between objects), for rendering non-scene-graph content, or for inserting a filter step at a specific point in the frame. +import ExamplePreview from '../../../components/ExamplePreview.astro'; -## Low-level backend passes +# Custom rendering -Beneath the high-level, context-aware pass tree sits `BackendRenderPass` — an interface for a single backend-only command. Its one method, `execute(backend)`, receives the `RenderBackend` directly (no camera, not a frame phase). Implement it when a custom pass needs raw backend access — custom shaders, backend-specific draw logic — and run it via `backend.execute(pass)`: +Start by deciding what is missing: geometry, a shader, a post-process, a place in the frame, or a renderer for a new drawable type. These are different problems. Raw GPU access is not the default solution to all of them. - +| Need | First mechanism to consider | +| --- | --- | +| Different vertex geometry | `Mesh` with the normal rendering path. | +| A custom shader on an existing drawable | The appropriate material and shader API. | +| An effect over already rendered pixels | `ShaderFilter` or a frame-level filter pass. | +| Procedural drawing between other passes | `CallbackRenderPass` in a `RenderPipeline`. | +| A reusable renderer for a new drawable class | An SDK renderer contributed through a renderer binding. | +| GPU work none of those paths can express | Backend-specific resources with explicit lifetime and recovery. | -A `RenderPipeline` composes high-level `RenderPass` objects (`RenderNodePass`, `CallbackRenderPass`, nested pipelines), not `BackendRenderPass` directly. To run a `BackendRenderPass` inside a pipeline, wrap it in a `CallbackRenderPass` and call `context.backend.execute(pass)` — the bridge shown above. For most custom rendering you never need this layer; `CallbackRenderPass` is the intended escape hatch. +Read [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/) before implementing a renderer merely to change a material. Read [Post-processing](/ExoJS/en/guide/effects/post-processing/) before managing offscreen targets merely to apply a filter. -## Direct backend access +## Insert drawing into a pipeline -When you need full GPU control — raw pipeline creation, custom vertex buffers, compute dispatches — access `WebGpuBackend` directly through `app.backend`. This is the escape hatch: you bypass ExoJS drawable types and renderer pipelines entirely, working at the WebGPU API level: +A `CallbackRenderPass` receives the high-level `RenderingContext` and executes where it appears in a pipeline. The surrounding passes retain their ordering: -```ts no-check -- sketch of a caller-owned renderer; its fields are assigned in an untyped constructor -import { WebGpuBackend } from '@codexo/exojs/renderer-sdk'; + -class CustomTriangleRenderer { - constructor(backend) { - if (!(backend instanceof WebGpuBackend)) { - throw new Error('This requires a WebGPU backend.'); - } - this._device = backend.device; // GPUDevice - this._format = backend.format; // GPUTextureFormat - this._context = backend.context; // GPUCanvasContext +Keep the callback focused on submission. Create long-lived resources outside the per-frame callback, update state during the scene's update phase, and draw that state when the pass executes. Use the context's explicit view and target mechanisms instead of relying on whichever raw backend state a previous pass left behind. - // Create your own pipeline, vertex buffers, command encoders... - this._pipeline = this._device.createRenderPipeline({ /* ... */ }); - this._vertexBuffer = this._device.createBuffer({ /* ... */ }); - } + - draw() { - const encoder = this._device.createCommandEncoder(); - const pass = encoder.beginRenderPass({ - colorAttachments: [{ - view: this._context.getCurrentTexture().createView(), - clearValue: { r: 0, g: 0, b: 0, a: 1 }, - loadOp: 'clear', - storeOp: 'store', - }], - }); - pass.setPipeline(this._pipeline); - pass.setVertexBuffer(0, this._vertexBuffer); - pass.draw(3); - pass.end(); - this._device.queue.submit([encoder.finish()]); - } -} -``` +## Bridge a backend-only command -The scene's `draw` method would call `this._triangleRenderer.draw()` instead of `context.render(sprite)`. The custom renderer owns the entire render pass and does not interact with ExoJS drawables. +`BackendRenderPass` is a lower-level command whose `execute` method receives a backend rather than a scene-aware context. A `RenderPipeline` contains high-level `RenderPass` objects; bridge a backend command through a callback rather than putting the wrong pass type directly into the pipeline: -Direct backend access is deliberately unabstracted — you are writing raw WebGPU code. On WebGL2 backends, a different renderer class would use `backend.context` (`WebGL2RenderingContext`) and branch by `backend.backendType`. For most projects, `MeshMaterial` + `CallbackRenderPass` + `ShaderFilter` cover custom rendering needs without reaching for raw GPU APIs. + -## Renderer registration +A backend command does not automatically acquire a camera, a scene lifetime, or ownership of the application's frame. It must respect the target, view, clipping, and pass boundaries established by its caller. - -Build a custom renderer by subclassing the abstract base renderers from `@codexo/exojs/renderer-sdk` (`AbstractWebGpuRenderer`, `AbstractWebGl2Renderer` / `AbstractWebGl2BatchedRenderer`). The engine's concrete renderers such as `WebGl2SpriteRenderer` are internal, coupled to private data paths, and not part of the SDK surface — subclassing them will break. - +## Contribute a drawable renderer -ExoJS resolves a renderer for each drawable type through the `RendererRegistry` (available from `@codexo/exojs/renderer-sdk`). You can register a custom renderer for an existing drawable type via `backend.rendererRegistry.registerRenderer(drawableConstructor, renderer)`. A renderer implements `connect(backend)`, `disconnect()`, `render(drawable)`, and `flush()` — these map to GPU resource acquisition, release, per-drawable recording, and batch submission respectively. +Use `@codexo/exojs/renderer-sdk` for the abstract renderer bases and binding helpers. Do not subclass a concrete internal sprite or mesh renderer: its private data path is not an extension contract. -In practice you build a custom renderer by extending one of the **abstract** base renderers from `@codexo/exojs/renderer-sdk` — `AbstractWebGl2Renderer` / `AbstractWebGl2BatchedRenderer` (WebGL2) or `AbstractWebGpuRenderer` (WebGPU) — which is exactly how the `@codexo/exojs-particles` and `@codexo/exojs-tilemap` packages build theirs. The engine's built-in *concrete* renderers (e.g. `WebGl2SpriteRenderer`) are internal: coupled to internal sprite/mesh data paths and intentionally not part of the SDK surface, so don't subclass them directly. +A renderer connects to a backend, records compatible drawables, flushes recorded work, and disconnects to release GPU resources. The registry resolves one renderer per drawable constructor and can follow the prototype chain. Contribute a new drawable type or branch inside its renderer; do not assume that registering a second renderer for the same constructor silently replaces the first. -Registering a custom renderer is an advanced extension point. For most custom rendering needs — procedural geometry between draws, a single custom shape that doesn't fit `Mesh` — `CallbackRenderPass` is the simpler and more intentional path. +Package a binding in an application-selected extension as described in [Authoring extensions](/ExoJS/en/guide/debugging/authoring-extensions/). Importing the package should not mutate global registration. -## When to use which +## Own raw resources deliberately -| Mechanism | Use when | -|---|---| -| [`Mesh`](/ExoJS/en/api/mesh/) | You need custom vertex geometry with the standard pipeline | -| [`MeshMaterial`](/ExoJS/en/api/mesh-material/) | You need a custom vertex/fragment shader on a `Mesh` | -| Custom shader filter (`ShaderFilter`) | You need a screen-space post-render effect on a drawable | -| `CallbackRenderPass` | You need to issue draw calls at a specific point in the frame between other draws | -| `BackendRenderPass` | You need a reusable backend-only command (custom shader / draw logic); bridge it into a pipeline via `CallbackRenderPass` | -| Direct backend access | You need to bypass ExoJS entirely and work at the GPU API level | +Raw WebGPU or WebGL2 work bypasses some high-level guarantees. A separate command encoder that clears the swapchain can overwrite the engine's frame; an unbalanced target or clip stack can corrupt later drawing. Coordinate raw work with the application's pass ordering instead of submitting an unrelated frame from inside `draw`. -## Examples +Recreate resources after backend loss, invalidate cached handles, and release everything on disconnect. Avoid retaining an application's lazily created pass coordinator across device recovery. Provide a supported alternative or a clear capability requirement when the custom path works on only one backend. - - +The canonical [custom triangle example](/ExoJS/en/playground/?example=custom-renderers/custom-triangle-renderer) is an executable backend-specific demonstration, not a portable replacement for a material. -The custom callback pass draws a procedural arc between composition and the HUD. Toggle the blur pass to see how a named pipeline step can be bypassed without freezing the displayed frame. +## Validate in a composed scene -## Where to go next +A renderer that works alone may fail after another renderer, under a mask, inside a retained group, on the second frame, or after device loss. Test empty flushes, repeated connection and teardown, resource growth, interleaving, and the capabilities it claims to support. -The next chapter, [Authoring extensions](/ExoJS/en/guide/debugging/authoring-extensions/), shows how to package a custom renderer — along with custom asset handlers and node serializers — into a distributable extension, exactly how the official `@codexo/exojs-particles` and `@codexo/exojs-tiled` packages plug into the core. +[The renderer SDK contract](/ExoJS/en/guide/debugging/renderer-sdk-contract/) explains retained recording, generation changes, borrowed storage, and pass coordination. Opt into advanced reuse only when the renderer can honor those contracts; a correct conservative path is preferable to replaying stale GPU state. diff --git a/site/src/content/guide/debugging/debugging-and-inspection.mdx b/site/src/content/guide/debugging/debugging-and-inspection.mdx index b1ac43258..484e8de8e 100644 --- a/site/src/content/guide/debugging/debugging-and-inspection.mdx +++ b/site/src/content/guide/debugging/debugging-and-inspection.mdx @@ -1,275 +1,85 @@ --- -title: 'Debugging & inspection' -description: 'Inspect scene state and runtime behavior with overlay layers, and trace filter chains and render passes with the in-engine inspector.' +title: 'Debugging and inspection' +description: 'Inspect visibility, hit testing, frame behavior, and filter structure while keeping diagnostic estimates separate from GPU measurements.' --- import ExamplePreview from '../../../components/ExamplePreview.astro'; -import SourceSnippet from '../../../components/SourceSnippet.astro'; -import TryIt from '../../../components/TryIt.astro'; -import Callout from '../../../components/Callout.astro'; -# Debugging & inspection +# Debugging and inspection -ExoJS ships five diagnostic layers in the `@codexo/exojs/debug` optional entrypoint, all managed by `DebugOverlay`: performance, bounding boxes, hit-test, pointer stack, and the render-pass inspector covered below. These tools render as overlays on top of your running scene — no scene-logic changes, no special draw-call instrumentation, no build flags. +Start with the smallest failing scene and one question. Is the object outside the view? Does the pointer reach the intended node? Did loading finish? Is a filter attached twice? The optional `@codexo/exojs/debug` entry point provides overlays for inspecting a running application without moving debugging logic into the scene. -## DebugOverlay +For an empty canvas or failed startup, begin with [Troubleshooting](/ExoJS/en/guide/shipping/troubleshooting/). The overlay itself needs a usable application and is not a replacement for handling initialization errors. -The [`DebugOverlay`](/ExoJS/en/api/debug-overlay/) is the entry point. Create one against your application, toggle individual layers by setting their `visible` flag: +## Attach an owned overlay ```ts -import { Application } from '@codexo/exojs'; +import type { Application } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; -const app = new Application(); -const debug = new DebugOverlay(app); +export const attachDebugTools = (app: Application): (() => void) => { + const debug = new DebugOverlay(app); -// During development — just flip a flag -debug.layers.performance.visible = true; -debug.layers.boundingBoxes.visible = true; + debug.layers.performance.visible = true; + debug.layers.boundingBoxes.visible = true; -// Toggle with keyboard shortcuts (F1–F4 and F6, while canvas has focus) + return () => { + debug.destroy(); + }; +}; ``` -The overlay subscribes to `app.onFrame` and renders each visible layer. World-space layers (`boundingBoxes`, `hitTest`) render first under screen-space panels (`performance`, `pointerStack`, `renderPassInspector`). The overlay's `visible` flag suppresses all layers without changing individual visibility. +Call the returned disposer when the development host no longer needs the tools. Hiding a layer skips that layer's drawing and collection, but a constructed overlay still owns subscriptions and shortcuts. Destroy it before final performance measurements rather than treating hidden diagnostics as a zero-cost guarantee. -| Layer | Property | Shortcut | -|-------|----------|----------| -| Performance | `layers.performance` | F1 | -| Bounding boxes | `layers.boundingBoxes` | F2 | -| Hit-test | `layers.hitTest` | F3 | -| Pointer stack | `layers.pointerStack` | F4 | -| Render-pass inspector | `layers.renderPassInspector` | F6 | +| Tool | Use it to inspect | Shortcut | +| --- | --- | --- | +| Performance | Frame behavior and renderer counters. | F1 | +| Bounding boxes | The bounds the engine associates with visible content. | F2 | +| Hit test | Interactive, hovered, and captured nodes. | F3 | +| Pointer stack | Candidate nodes under the pointer and their ordering. | F4 | +| Render-pass inspector | Attached filters, masks, cache flags, and an optional logical pipeline. | F6 | -F5 is deliberately unbound — browsers reload the page on it, which would tear down the very session you are inspecting. +Shortcuts apply while the canvas has focus. F5 remains available for the browser's reload action. The overlay releases its shortcut claims on destruction. -While the overlay exists it claims these keys as engine input, so the browser's own defaults for them (F1's help window, F3's find bar) stay suppressed. `debug.destroy()` releases them again. +## Visibility and input -## Performance layer +Use bounding boxes to check placement, anchor, scale, and the active camera before changing the renderer. A missing or surprising box narrows the investigation; it does not identify every possible cause of an invisible object. Also check loading state, opacity, masking, clipping, and whether the object is submitted from `draw`. -The [`PerformanceLayer`](/ExoJS/en/api/performance-layer/) shows four real-time metrics in a compact panel (top-left): +For pointer problems, enable both the hit-test and pointer-stack layers. Check which node is on top, whether a modal interaction scope limits the candidates, and whether a previous press still owns pointer capture. Screen coordinates and world coordinates are different: use the view that rendered the interactive object when converting between them. -| Metric | Source | -|--------|--------| -| **FPS** | Rolling 60-sample average of frame times | -| **Frame** | Current frame duration in milliseconds | -| **Draws** | GPU draw calls issued this frame (`backend.stats.drawCalls`) | -| **Nodes** | Total `RenderNode` count in the scene | - -A sparkline below the text shows the last 120 frames of frame-time history — a quick visual of frame-rate stability. The sparkline maxes out at 33ms (~30 FPS), so any frame that hits the top boundary is below 30 FPS. - -Press F1 or set `debug.layers.performance.visible = true` to enable it. - -## Bounding-boxes layer - -The [`BoundingBoxesLayer`](/ExoJS/en/api/bounding-boxes-layer/) draws colored rectangle outlines around every visible `RenderNode` with non-zero bounds. Each node's outline hue cycles by its `zIndex` — adjacent z-indices get visually distinct colors. This layer renders in world space, so boxes move and rotate with the scene: - -```ts -import { DebugOverlay } from '@codexo/exojs/debug'; - -declare const debug: DebugOverlay; -debug.layers.boundingBoxes.visible = true; // or F2 -``` - -Use this to debug layout issues, verify sprite bounds match expectations, or spot invisible nodes taking up space. It is the fastest way to answer "where does the engine think this node is?" - - -A node that's in the tree but invisible tells you which way to look: no box means its bounds are zero (nothing to draw), while a box off in a corner means it's mis-anchored or off-screen. F2 tells the two apart instantly. - - -## Hit-test layer - -The [`HitTestLayer`](/ExoJS/en/api/hit-test-layer/) color-codes interactive nodes based on their pointer state: - -- **Magenta**: interactive but idle (not hovered) -- **Yellow**: currently hovered -- **Cyan**: pointer-captured (being dragged or pressed) - -This layer renders in world space. Combined with `debug.layers.pointerStack.visible` (F4), which lists which nodes are under the cursor in a screen-space panel, you can trace the full hit-test path from pointer position to interactive node: - -```ts -import { DebugOverlay } from '@codexo/exojs/debug'; + -declare const debug: DebugOverlay; -debug.layers.hitTest.visible = true; // or F3 -debug.layers.pointerStack.visible = true; // or F4 -``` +## Inspect filter structure, not imaginary GPU timings -The pointer-stack panel shows up to 10 nodes under the cursor sorted by `zIndex` (topmost first), with canvas coordinates, constructor names, and an `— interactive` flag for nodes that participate in hit testing. +The render-pass inspector walks visible nodes beneath the current scene's `root` and collects nodes that have at least one filter. Each entry contains the filter sequence, bounds, a mask flag, and a texture-cache flag. A node with only a mask and no filter is not included in that list. -## Overlay lifecycle +`totalPasses` is a **structural count**: the number of attached filters in the collected entries, plus one for each entry with a mask. It is not a measured hardware-pass count. It does not expand a multi-step blur or bloom, subtract cached work, or inventory all application-level passes. A `[cached]` flag describes configuration, not proof that no cache rebuild happened in the current frame. -All debug layers live on `DebugOverlay.layers`. The overlay subscribes to application events (`onFrame`, `onKeyDown`, `onResize`) and drives layer updates and rendering. Call `debug.destroy()` when you no longer need the overlay — it unsubscribes from all events and destroys every layer. +Use the panel to find unexpected attachments or large filtered bounds. Confirm actual work with renderer counters or a GPU capture before claiming a pass reduction. To inspect an explicit `RenderPipeline`, set it on the inspector; the resulting rows describe enabled state and nesting, not execution time. -Debug layers have zero overhead when their `visible` flag is `false` — `DebugOverlay._onFrame` skips invisible layers entirely. You can leave the overlay constructed for a full development session without worrying about perf impact on profiling. +The `entries` array is reused on update. Copy the values you need before retaining a history, and remember that filter objects in an entry are live objects rather than immutable serialized state. -## Logging +## Log a useful failure -Separately from the visual overlays above, ExoJS ships a message-first [`Logger`](/ExoJS/en/api/logger/) in the core module — no special entrypoint required. The engine uses it internally (for example, `Application` logs unexpected failures with `source: 'Application'`), and it's available for your own code too. A ready-to-use default instance, `logger`, is exported alongside the class: +The Core logger accepts a message and optional structured context: ```ts import { logger } from '@codexo/exojs'; -logger.debug('Bundle "level-1" queued', { source: 'assets' }); -logger.info('Entered gameplay scene', { source: 'scene' }); -logger.warn('AudioContext resumed after a user gesture', { source: 'audio' }); -logger.error('Simulation step threw', { source: 'physics', error: new Error('physics step failed') }); -``` - -Each call takes a `message` plus an optional options bag: `source` (rendered as `[ExoJS][source]`, or a bare `[ExoJS]` when omitted), `data` for structured context, and `error` for `error()` calls. Use `source` to identify the subsystem or class emitting the entry so your own tooling can filter by it. - -Severity follows `LogSeverity`: `Debug < Info < Warning < Error`. In production builds, `Debug`/`Info`/`Warning` calls never reach a sink — `Logger` checks severity at runtime and returns early — but only `Error` calls are unconditionally guaranteed to survive. The build does *not* compile the lower-severity calls out of the bundle: the `logger.debug(...)` call itself, its message string, and any `data` object you pass are still constructed and executed every time, and only discarded afterward. In development builds, a console sink is registered by default and prefixes every line with a styled `[ExoJS]` (or `[ExoJS][source]`) badge. - - -Only `Error` reaches a sink in a production build — `Debug`, `Info`, and `Warning` calls are discarded by an early return inside `Logger`, not removed from the shipped code. The call and its arguments still run, so if a message or `data` object is expensive to build, construct it lazily (e.g. behind a function or your own guard) rather than assuming it gets stripped. - - -To capture log entries yourself — for an in-game console, telemetry, or a custom debug panel — register a sink with `addSink`. It returns an unsubscribe function: - -```ts -import { logger, LogSeverity } from '@codexo/exojs'; - -const unsubscribe = logger.addSink((entry) => { - if (entry.severity >= LogSeverity.Warning) { - console.warn(entry.source, entry.message, entry.data ?? entry.error); - } -}); - -// later -unsubscribe(); -``` - -For warnings that could otherwise repeat every frame (a stale asset reference checked in `update`, say), pass `once` — the entry is dropped after the first call for a given key, at any severity: - - - -## Inspecting the render pipeline - -Every filter attached to a drawable costs at least one extra render pass. A stack of three filters on a container means the container renders to an off-screen target, the first filter reads and writes a target, the second does the same, and the third composites the result — four passes for one element. When your frame budget tightens, knowing who is adding passes and why matters. - -[`RenderPassInspectorLayer`](/ExoJS/en/api/render-pass-inspector-layer/) (added in v0.8.3) shows you that information live, in a compact text panel overlaid on the canvas. It ships in the `@codexo/exojs/debug` optional entrypoint alongside the other debug layers. - -### What the layer reveals - -Each frame, `RenderPassInspectorLayer` walks the scene graph and collects an entry for every `RenderNode` that has at least one filter. The panel displays: - -- **Total pass count** across all filtered drawables (one pass per filter, plus one per mask). -- **Per-drawable rows** showing the constructor name (`Sprite`, `Container`, `Mesh`, `Graphics`) and the drawable's bounding-box dimensions. -- **Filter sequence** indented under each drawable, in execution order, by constructor name (`BlurFilter`, `ColorMatrixFilter`, `LutFilter`, `ShaderFilter`, …). -- **Flags** — `[mask]` when the drawable has an active mask (mask passes add to the total), `[cached]` when `cacheAsTexture` is set (cached drawables apply filters once, not per frame). - -The panel does not show drawables with zero filters — they are invisible to the render-pipeline inspector because they contribute no extra passes beyond the main batch draw. - -### Enabling the layer - -`DebugOverlay` manages the inspector alongside the other layers, so the usual route is a flag or the F6 shortcut: - -```ts -import { DebugOverlay } from '@codexo/exojs/debug'; - -declare const debug: DebugOverlay; -debug.layers.renderPassInspector.visible = true; // or F6 -``` - -The inspector walks `app.scenes.currentScene?.root` each frame, so it sees whichever scene is currently active. It is never added to the scene graph. - -### Driving the layer without the overlay - -`RenderPassInspectorLayer` extends `DebugLayer` and can also be driven directly from `app.onFrame` — useful when you want the inspector without constructing an overlay, or when you need it on a view of your own. Import it from the debug entrypoint, construct it against the application, and render it yourself: - - - -The screen-space view swap is necessary because `RenderPassInspectorLayer` returns `'screen'` for `viewMode` and positions its text panel at absolute pixel coordinates. Without the view swap, the panel would render in the scene's coordinate system. - -If you only need the data and not the built-in panel, read `inspector.entries` and `inspector.totalPasses` directly — the `update` call is still required to populate the entry snapshot: - - - -The `entries` array is replaced each frame during the inspector's `update`. Keep a copy if you need to retain frame history. - -### Reading the panel - -The panel renders in the top-left corner of the screen (at x=200 to avoid overlapping the `PerformanceLayer` panel). The header line shows the total pass count for the current frame. Below it, each filtered drawable gets a row with its dimensions and flags, followed by an indented list of filters: - +export const reportStartupFailure = (error: unknown): void => { + logger.error('Application startup failed', { + source: 'game', + error: error instanceof Error ? error : new Error(String(error)), + }); +}; ``` -Render Passes: 7 -Sprite 512x256 [cached] - 0. BlurFilter - 1. ColorMatrixFilter -Container 800x600 - 0. ShaderFilter - 1. BlurFilter -Graphics 128x64 [mask] - 0. LutFilter -``` - -This tells you: the Sprite has two filters and is bitmap-cached (filters are baked once, not re-composited each frame — no ongoing pass cost). The Container has two active filters costing two passes per frame. The Graphics has a mask (adds one pass) plus a `LutFilter` (adds another). Total: 2 + 1 + 1 + 1 = 5, plus 2 for the Container = 7. - -### Understanding pass counts - -A filter pass means the GPU renders some geometry into a temporary render target, then a second shader reads that target and produces the filtered result. The engine reuses render target memory across filter steps (the pool keeps a steady state of two allocations regardless of filter count), but each pass still consumes GPU time proportional to the drawable's bounding-box area. - -Common pass-reduction strategies, in order of impact: - -1. **Remove filters you don't need.** Every filter the panel lists is active. If a `BlurFilter` on a background element is invisible under a solid overlay, removing it saves a pass. -2. **Enable `cacheAsTexture`** on filtered drawables that don't change every frame. The inspector shows `[cached]` when active. Cached drawables bake their filters once and skip per-frame re-application. -3. **Consolidate filter stacks.** Two `ColorMatrixFilter` instances on the same drawable cost two passes. Most color adjustments (brightness, contrast, saturation) can be combined into a single `ColorMatrixFilter` or replaced with a custom `ShaderFilter` that applies both in one pass. -4. **Reduce drawable size.** Filter passes render at the drawable's bounding-box resolution (ceil). A 2048×2048 sprite with a blur costs far more than a 256×256 one with the same blur strength. The panel shows the resolution each drawable renders at. - -### External GPU capture tools - -`RenderPassInspectorLayer` tells you *what* is being drawn and *how many* passes it costs. It does not show intermediate render-target contents, shader source, or exact GPU timings. - -For that level of detail, use external capture tools: - -- **[Spector.js](https://spector.babylonjs.com/)** — WebGL2 frame capture. Shows every draw call, shader source, uniform values, and render-target contents for a captured frame. -- **Chrome DevTools WebGPU panel** — WebGPU frame capture. Shows compute and render passes, pipeline state, bind groups, and buffer contents. - -On WebGPU, ExoJS emits labels on key passes so capture tools display meaningful names rather than generic calls. Look for labels such as: - -- `ShaderFilter pass` — a `ShaderFilter` executing -- `MeshMaterial (custom)` — a `Mesh` with an attached `MeshMaterial` drawing inside the main render pass -- `WebGpuMaskCompositor pass` — mask composition for a drawable with an active `mask` - -These labels are visible in tools that surface WebGPU pass labels (for example, Chrome's WebGPU tooling). - -### When to reach for the inspector - -- **During development** — keep the inspector on while building filter stacks. You see immediately when adding a filter to a container increases the pass count. -- **During profiling** — when a scene's frame time is higher than expected, enable the inspector to rule out (or confirm) filter pass overhead as the cause. -- **In CI** — snapshot `inspector.entries` and `inspector.totalPasses` on a known scene to catch regressions. A PR that accidentally enables `cacheAsTexture: false` or adds an unintended filter won't change visual output but will show up as a pass-count increase. - -## Examples - - - - -Drag overlapping sprites and inspect the bounding-box, pointer-stack, and hit-test overlays. Keys 1, 2, and 3 change which sprite is in front. - - +Keep logging at the boundary that can add meaningful context: which scene, asset, or user action failed. Avoid repeating the same message every frame. Production severity filtering does not make expensive argument construction free; guard costly diagnostic work instead of assuming a JavaScript minifier removes it. -A seeded sprite workload with live FPS, frame time, draw-call count, and sparkline. +A logger sink is an external subscription. Retain and call its unsubscribe function when its owner ends. Do not log private save data, access tokens, or full user documents merely to identify a decoding failure. - +## Turn the observation into a regression test -## Where to go next +Once the failure is understood, capture the smallest useful contract: a resource releases when its scope ends, a scene navigation rejects cleanly, an interaction scope traps focus, or a render tree contains the intended filter. A structural assertion catches structural drift; a visual or GPU claim still needs the corresponding rendering evidence. -The next chapter, [Performance](/ExoJS/en/guide/debugging/performance/), covers scene measurement — sprite stress tests, particle throughput, and how to use the performance layer to identify bottlenecks. +Use [Performance](/ExoJS/en/guide/debugging/performance/) for measurement and [Backend selection](/ExoJS/en/guide/debugging/backend-comparison/) for capability and parity questions. Keep the test tied to the behavior rather than to the prose that happened to describe it. diff --git a/site/src/content/guide/debugging/performance.mdx b/site/src/content/guide/debugging/performance.mdx index b3e71cf8a..c808ee97a 100644 --- a/site/src/content/guide/debugging/performance.mdx +++ b/site/src/content/guide/debugging/performance.mdx @@ -1,137 +1,71 @@ --- -title: 'Performance' -description: 'Measure scene limits with focused stress examples.' +title: 'Measure and improve performance' +description: 'Separate frame pacing, CPU submission, GPU work, and memory before changing a scene.' --- import ExamplePreview from '../../../components/ExamplePreview.astro'; import SourceSnippet from '../../../components/SourceSnippet.astro'; -import TryIt from '../../../components/TryIt.astro'; -import Callout from '../../../components/Callout.astro'; -# Performance +# Measure and improve performance -Performance in ExoJS is about three things: how many drawables you push per frame, how many render passes they cost, and how much state change happens between draws. The engine does a lot automatically — batching sprites that share a texture, reusing render-target memory across filter passes, culling nodes outside the view — but the decisions that matter most are yours: how many sprites, how many textures, how many filters, how many particle systems. +A slow frame may come from simulation, input handling, asset decoding, layout, rendering submission, GPU work, or garbage collection. Reducing the number of sprites is not a diagnosis. Start with a reproducible scene and a concrete symptom: sustained low throughput, occasional stalls, growing memory, or uneven frame pacing. -## Measuring with the performance layer +## Establish a baseline -Before making changes, measure. The [`PerformanceLayer`](/ExoJS/en/api/performance-layer/) from `@codexo/exojs/debug` gives you FPS, frame time, draw-call count, and node count in real time: +Run the production build on a representative target. Record the browser, device, backend, canvas resolution, pixel ratio, and workload. Warm up the scene before comparing steady-state measurements, and measure loading separately when startup is the concern. -```ts -import { Application } from '@codexo/exojs'; -import { DebugOverlay } from '@codexo/exojs/debug'; +The [debug overlay](/ExoJS/en/guide/debugging/debugging-and-inspection/) is useful for finding a suspect subsystem. Its frame time and draw count are not a hardware GPU timer. Disable or destroy diagnostic tools before taking the final comparison, because their subscriptions, data collection, text, and drawing are additional work. -const app = new Application(); -const debug = new DebugOverlay(app); -debug.layers.performance.visible = true; -``` +Change one variable at a time and repeat the same interaction. Keep a change when it improves the measured problem without breaking behavior or making a different target worse. Use a browser profile to locate expensive JavaScript or long tasks; use backend-specific GPU instrumentation for GPU questions. -Watch the FPS number while you add sprites, enable filters, or spawn particles. The sparkline shows frame-time stability — a flat line is good, spikes indicate intermittent work. The draw-call count tells you how many GPU-level draw calls the renderer issued this frame. +## Read the right measurement - -Isolate a single change per measurement — stacking several optimizations at once hides which one actually helped, or masks one that made things worse. Reach for the overlay before you edit, not after a hunch. - +| Observation | What to investigate | +| --- | --- | +| JavaScript time rises with active actors | Update logic, physics, allocations, input processing, or layout. | +| Submission time rises while the visible image hardly changes | Scene traversal, state changes, uploads, sorting, or missed retained reuse. | +| Lowering render resolution helps substantially | Pixel work, target bandwidth, filters, lighting, or overdraw. | +| Periodic long frames | Allocation bursts, decoding, synchronous work, shader compilation, or garbage collection. | +| Memory grows across repeated scene changes | Ownership, unreleased scopes, external listeners, pools, or caches that never become bounded. | -## Sprite batching +CPU submission time, GPU timer queries, queue-completion wall time, and presentation frame time are different instruments. Do not subtract or rank them as though they measured the same interval. The [benchmark pages](/ExoJS/en/benchmarks/) preserve the repository's methodology and result provenance rather than copying volatile numbers into this Guide. -Sprites that share the same texture are batched into a single draw call on WebGPU backends. On WebGL2, single-texture batching also applies. The practical consequence: 600 sprites all using one texture atlas cost far less than 600 sprites each using a different texture. +## Keep draw work compatible -The [Sprite Batching Laboratory](/ExoJS/en/playground/?example=performance/backend-comparison) switches between one and four otherwise identical textures while keeping positions and motion fixed. The performance layer shows how the draw-call count changes. +Both backends batch compatible sprites, including multiple textures within the batch's capacity. An atlas can reduce texture changes, but one atlas is not a guarantee of one draw. Material changes, blending, clips, masks, targets, view changes, ordering, and buffer capacity can introduce boundaries. -For texture-atlas workflows, use a single `Spritesheet`-sliced `Texture` and select frames via `sprite.setTextureFrame()` rather than loading separate images per sprite. +Group compatible work when that preserves the intended image. Do not reorder transparent content simply to improve a draw counter. For a large, stable subtree, [Retained containers](/ExoJS/en/guide/rendering/retained-containers/) may avoid rebuilding work; texture caching solves a different problem by caching pixels. -## Render passes +## Budget pixels as well as objects -Every filter on a drawable adds one render pass for that drawable. A container with three filters costs three passes beyond the main batch draw. The cost scales with the drawable's bounding-box area (in pixels), not the canvas size, so occluding a filtered sprite behind a solid overlay doesn't prevent the filter passes from running. +A filter is a logical operation, not necessarily one hardware pass. Blur, bloom, masking, normal prepasses, and lighting can require intermediate targets or multiple steps. Their cost depends on covered area, resolution, format, and the work inside each shader. -The [`RenderPassInspectorLayer`](/ExoJS/en/api/render-pass-inspector-layer/) shows exactly who is adding passes. Enable it during development when frame time rises and you suspect filter overhead: +Start by removing work whose result is not used. Reduce an effect's working resolution where the image permits it. Cache truly stable output when that avoids repeated work, remembering that content changes invalidate the cache. Avoid keeping a large offscreen target merely because it was convenient during prototyping. -```ts -import { Application } from '@codexo/exojs'; -import { RenderPassInspectorLayer } from '@codexo/exojs/debug'; +Changing DPR changes backing-store area quadratically. Set a deliberate quality policy instead of assuming maximum native density is free. [Resize, DPR and the canvas](/ExoJS/en/guide/getting-started/resize-dpr-and-canvas/) distinguishes logical size from backing resolution. -const app = new Application(); -const inspector = new RenderPassInspectorLayer(app); -inspector.visible = true; -``` +## Bound simulation and allocation -The two highest-impact reductions: remove filters you don't need, and set `cacheAsTexture = true` on filtered containers that don't change every frame. For the full set of strategies with examples, see [Render pipeline debugging](/ExoJS/en/guide/debugging/debugging-and-inspection/). +For particles, inspect the active execution path and live occupancy. A GPU-eligible system can still be expensive because of spawning, module complexity, readback, or pixel coverage. Size capacity using spawn rate, lifetime, bursts, and peak concurrency; a rate alone cannot establish that a system is safe. -## Particle system throughput +Reuse frequently mutated vectors, colors, and arrays on measured hot paths. Pool short-lived objects when allocation is a real bottleneck, but keep the pool bounded and reset all relevant state. `visible = false` suppresses drawing; it does not automatically stop your update loop, release a texture claim, or disable every external interaction. -The `ParticleSystem` (from `@codexo/exojs-particles`) is designed for throughput — SoA storage, no per-particle allocations, instanced rendering. The practical limits depend on whether you're on CPU or GPU path: +For dynamic textures, reuse the resource. Upload changed regions where that reduces transferred data, then measure the upload overhead. Repeatedly resizing a render texture can reallocate GPU resources even when the final visible size changes only slightly. -- **CPU path** (WebGL2, or any non-GPU-eligible update module): Suitable for moderate particle counts — performance drops roughly linearly with particle count. The per-frame update cost is proportional to `aliveCount` × number of update modules. -- **GPU path** (WebGPU, all modules GPU-eligible): The composite WGSL compute shader runs update logic on the GPU in one dispatch, writing directly into the renderer's instance buffer — no CPU readback. This path becomes attractive for large systems with many particles and GPU-eligible modules. +## Cull with valid bounds -Check `system.gpuMode` at runtime to know which path is active. Measure with the [`PerformanceLayer`](/ExoJS/en/api/performance-layer/) to find the right capacity target for your scene — the optimal number depends on your module count, particle lifetime, and target frame rate. +Culling skips drawing outside the active view; it does not stop unrelated simulation. Organize large worlds into meaningful regions instead of assuming that a single huge container makes every child cheap. -## Scene-graph churn +A `cullArea` replaces the bounds used for the visibility check. It is a world-space rectangle, not a local rectangle automatically transformed with the node: - -For scenes that spawn and retire objects constantly — bullets, hit sparks, floating damage numbers — keep a fixed pool sized to your peak concurrent count and recycle nodes by flipping `visible`, rather than constructing and destroying them each frame. - + -Adding and removing many children from a container each frame triggers transform-dirty propagation and bounds recomputation. For dynamic scenes where objects appear and disappear frequently, prefer: +Keep it current when the object moves or reserve it for fixed placement. It does not replace collision or hit-test geometry. An undersized culling area can improve a counter by incorrectly hiding content. -- `sprite.visible = false` over `container.removeChild(sprite)` — keeps the node in the tree, skips rendering. -- Pre-allocate a pool of sprites and recycle them by toggling visibility rather than constructing/destroying. -- Use `zIndex` only where layering is required. Mixed `zIndex` values add per-group sorting work during render-plan optimization. +## Reproduce the result -## Texture updates - -Updating a texture source (e.g. a canvas, video frame, or `DataTexture` buffer) incurs GPU upload work each frame, and may also trigger GPU re-allocation when dimensions/format change. For per-frame updates: -- Use `DataTexture.commitRect()` for partial uploads (cheaper than full `commit()`). -- Avoid creating new `Texture` instances per frame — reuse one texture and update its source. -- Keep `RenderTexture` dimensions stable — `resize()` triggers framebuffer recreation. - - -Allocating a fresh `Color`, `Vector`, or `Matrix` inside `update` or `draw` runs every frame and hands the garbage collector a steady stream of short-lived objects, which surfaces as periodic frame-time spikes. Allocate once and mutate in place (`color.set(...)`, `vec.set(...)`) on hot paths. - - -## View culling - -Nodes that fall entirely outside the current `View`'s viewport are skipped by the renderer. This is automatic and per-node. The culling check is AABB-based rather than exact shape, so nodes near the viewport edge may still be rendered even if partially outside. For large scrolling worlds, this is a significant performance multiplier — only the visible subset of your scene graph incurs draw cost. - -### Overriding the cull check with `cullArea` - -The automatic check calls `getBounds()`, which for a `Container` walks every visible child and unions their bounds — for a complex `Graphics` node or a container with hundreds of children, that walk runs every frame even when the node's on-screen footprint is trivial to reason about. Set `cullArea` to a `Rectangle` and the cull check uses it directly instead of computing bounds: - - - -Two things to keep in mind: - -- `cullable` and `cullArea` live on `RenderNode`, so every node that draws something has them; a bare `SceneNode` is structural only, never reaches the renderer, and carries neither. -- `cullArea` replaces `getBounds()` only in the `inView()` check — it has no effect on hit-testing, collision, or rendering, and it's ignored entirely when `cullable = false`. -- Because the rectangle is used as-is (not transformed), a `cullArea` on a node that moves, rotates, or scales after it's set will go stale. Either recompute it when the node's position changes, or reserve it for nodes whose world placement is fixed once configured — e.g. static level decoration, or particle-heavy effects anchored to a known spawn point. - -## Not a checklist - -The most useful performance practice is measurement. Turn on the performance layer, watch the numbers, change one thing, measure again. The engine's behavior under your specific scene — your sprite counts, your texture layout, your filter stacks — matters more than any general guideline. - -The engine's own reproducible stress benchmarks live with [`@codexo/exojs-bench`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-bench/README.md), whose README documents the methodology behind them — median vs. p95, warmup and timed frames, what each metric does and does not cover. Useful as a reference for measuring honestly; the numbers that decide your game still come from the `PerformanceLayer` on your own scene. - -## Examples - - -Change the sprite count and texture diversity in a seeded workload, then read the resulting frame time and draw-call count. - - -Adjust the spawn rate and watch live occupancy, frame time, and the active execution path. WebGPU uses GPU compute simulation; WebGL2 uses a smaller CPU fallback budget. - - - -## Where to go next - -The next chapter, [Backend comparison](/ExoJS/en/guide/debugging/backend-comparison/), covers the WebGL2 vs. WebGPU decision — how backends are selected, where feature parity exists, and where backend-specific differences remain. +Use these demonstrations to understand a mechanism, then profile the application that will ship. Record the changed workload and environment alongside any performance claim; a faster benchmark cell is not evidence for unrelated gameplay. diff --git a/site/src/content/guide/effects/filters.mdx b/site/src/content/guide/effects/filters.mdx index 010aa268d..6aee38578 100644 --- a/site/src/content/guide/effects/filters.mdx +++ b/site/src/content/guide/effects/filters.mdx @@ -300,7 +300,7 @@ A filter transforms a drawable's rendered pixels — it operates on the 2D outpu - Use a `MeshMaterial` for per-vertex effects: displacement, custom lighting, procedural geometry. - Use both together: a `MeshMaterial` on the mesh, plus a filter on the mesh's parent container. -The next chapter, [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/), covers attaching a `MeshMaterial` in detail. +Continue with [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/), which covers attaching a `MeshMaterial` in detail. ## Examples @@ -326,4 +326,4 @@ The reflection captures the moving scene into a texture, flips it, and applies a ## Where to go next -The next chapter, [Particles](/ExoJS/en/guide/effects/particles/), covers the data-oriented particle system — spawn modules, update modules, distributions, and CPU/GPU auto-routing. +Continue with [Particles](/ExoJS/en/guide/effects/particles/), which covers the data-oriented particle system — spawn modules, update modules, distributions, and CPU/GPU auto-routing. diff --git a/site/src/content/guide/effects/lighting.mdx b/site/src/content/guide/effects/lighting.mdx index a3d7835fe..511276601 100644 --- a/site/src/content/guide/effects/lighting.mdx +++ b/site/src/content/guide/effects/lighting.mdx @@ -1,373 +1,87 @@ --- title: 'Lighting' -description: 'Light a 2D scene with lights that are scene nodes, normals nobody authored, and shadows read out of the world you already described.' +description: 'Choose a lighting model, register lights and occluders, and manage normal maps, frame passes, and quality as distinct concerns.' --- +import SourceSnippet from '../../../components/SourceSnippet.astro'; import ExamplePreview from '../../../components/ExamplePreview.astro'; -import Callout from '../../../components/Callout.astro'; # Lighting -Most 2D projects that could have shadows ship without them, and the reason is bookkeeping rather than technique: an engine that asks you to draw a silhouette per object is asking for work nobody budgeted. `@codexo/exojs-lighting` starts from the opposite end. A light is a scene node, so a torch parents to the player and follows it. A surface gets its normals from its own silhouette, so art that was never authored for lighting still reacts to it. And what blocks light is read out of a description you already have — physics colliders, a tile layer, a sprite's alpha. +`@codexo/exojs-lighting` offers three different ways to shade a 2D scene. They share light nodes and registration concepts, but they are not interchangeable quality levels that produce the same picture at different speeds. -> **Note:** lighting ships as an official ExoJS extension package. Install it alongside the core: -> ```sh -> npm install @codexo/exojs @codexo/exojs-lighting -> ``` +Construct the chosen lighting system directly and register it with the scene. There is no `lightingExtension` descriptor to add to application options. -There is no extension to register. A lighting system is an ordinary system you add to a scene. - -## Setup - -```ts -import { Color, Scene } from '@codexo/exojs'; -import { LightmapLighting } from '@codexo/exojs-lighting'; - -class CaveScene extends Scene { - private lighting = new LightmapLighting(this.app, { ambient: new Color(20, 22, 34) }); - - override init(): void { - this.systems.add(this.lighting); - } -} -``` - -`ambient` is the baseline every lit fragment receives regardless of any light — `255` per channel means "unlit areas keep their full albedo", and a dark blue is the usual night. It is read every frame, so fading from day to night is one tween on a colour. - -Register the system with the registry that ticks **after** the code moving your lights. `app.systems` runs its update phase before the active scene's, so a system registered there sees lights the scene has not moved yet; `scene.systems` is usually what you want. - -## Three renderers, one vocabulary - -The scene describes what emits and what blocks. Which system you construct decides how that becomes pixels, and nothing else changes between them — the same lights, the same occluders. - -| | `ForwardLighting` | `LightmapLighting` | `RadianceLighting` | -| ----------------------- | ---------------------------------- | --------------------------------------------------- | --------------------------------------------------- | -| Where light is computed | inside the sprite fragment stage | in a target of its own, multiplied over the frame | the same target, filled by transporting radiance | -| Normal mapping | per material, on `LitMaterial` | per drawable, through a prepass | no | -| Shadows | no | yes, soft, from registered occluder sources | yes, with a penumbra that follows the source's size | -| Light count | capped by `maxLights` (default 64) | uncapped | uncapped, and free: the cost is per probe | -| Extra passes | none | two, a third with normals | four to nine, depending on the view | -| Cost per light | a loop iteration per lit fragment | the fill of its own radius | none — the field costs what the screen costs | - -`LightmapLighting` and `RadianceLighting` light the frame the application drew, so the application is their first argument: they read its frame, install their passes in its frame slot, and follow its surface when it resizes. `ForwardLighting` shades inside the sprite stage, so it is the one that can be built without one — `new ForwardLighting({ maxLights: 16 })`. - -Whichever you built, the running renderer answers for itself, which is what a status line or a debug overlay reads: - -```ts -import { Lighting } from '@codexo/exojs-lighting'; - -declare const lighting: Lighting; - -lighting.quality; // 'forward' | 'lightmap' | 'radiance' -``` - -`Lighting` is the base the three share, so it is the type to take when a function accepts any of them. Pick the class whose properties the scene needs — `ForwardLighting` for normal maps on a `LitMaterial`, `LightmapLighting` for shadows and an uncapped light count, `RadianceLighting` for light that spreads. - -### Light that spreads - -`RadianceLighting` fills the same light field from a chain of radiance cascades. Light propagates from what emits instead of falling off inside each light's radius, so a lamp lights the room it stands in, a wall between two rooms leaves the second one dark, and a source with a size casts a penumbra that widens with distance. - -```ts -import { Color } from '@codexo/exojs'; -import { PointLight, RadianceLighting } from '@codexo/exojs-lighting'; -import type { Application } from '@codexo/exojs'; - -declare const app: Application; - -const lighting = new RadianceLighting(app, { ambient: new Color(8, 8, 14) }); - -// Under `radiance` a light's `softness` sets the SIZE of the source, which is -// what decides how soft the shadows it casts are. -lighting.add(new PointLight({ radius: 300, intensity: 3, softness: 0.4 })); -``` - -Importing the class is what links the cascades, so a bundle that never constructs one never carries them. Its tuning sits beside the rest — `probeSpacing`, `cascades`, `interval`, all optional and all derived from the surface by default. - -It needs a device that can render into float targets and is refused at construction where it cannot run, so you never get it by accident. - - - A wall the field lit gives part of that light off again in its own colour, one frame later - `bounce` sets how much, and `0` switches it off. A `SunLight` is the sky every unblocked ray ends in, a `SpotLight` emits across its cone, and the fields reach a margin past the picture (`fieldMargin`), so what stands just outside it still shadows and lights what is in it. - - - - Within roughly five times a source's own size the cascades are resolving it with the few directions their coarsest levels have, and its disc is rasterised at the light field's resolution. Moving the lamp by less than a texel redistributes light there — around a tenth of the arriving brightness per quarter texel, seen as a shimmer on the lamp's own halo rather than anywhere it lights. Past that radius it settles below what eight bits can express. - - - - `forward` is not the low setting and `lightmap` is not the high one. One shades inside the sprite stage and can therefore reach a per-material normal map; the other shades a field of its own and can therefore carry shadows and any number of lights. Neither is a downgrade of the other. - - -## Lights are nodes - -A light is a `RenderNode` that emits rather than draws. It inherits the transform, parents to whatever carries it, and every field is an ordinary property — so the engine's tweens animate a light with no lighting-specific animation concept. - -```ts -import { Color } from '@codexo/exojs'; -import { Lighting, PointLight } from '@codexo/exojs-lighting'; -import type { Application, Container } from '@codexo/exojs'; - -declare const app: Application; -declare const lighting: Lighting; -declare const player: Container; - -const torch = lighting.add(new PointLight({ radius: 320, color: new Color(255, 180, 120), intensity: 1.4 })); - -player.addChild(torch); -// A flicker is an ordinary tween on an ordinary property - there is no -// lighting-specific animation concept to learn. -app.tweens.create(torch).to({ intensity: 1.8 }, 0.4).start(); +```sh +npm install --save-exact @codexo/exojs @codexo/exojs-lighting ``` -`add` returns the light, so creating, parenting and registering it is one expression. Registering the same light twice shades it once; destroying a registered light unregisters it. - -Four shapes: +## Choose the model -```ts -import { Color } from '@codexo/exojs'; -import { Lighting, LineLight, PointLight, SpotLight, SunLight } from '@codexo/exojs-lighting'; +| Model | Use it for | Important boundary | +| --- | --- | --- | +| `ForwardLighting` | Per-sprite material lighting, including authored normals. | Each lit fragment considers the active lights; the light count is capacity-bounded. It does not cast the screen-space shadows of the other models. | +| `LightmapLighting` | Lights and registered shadows over a composed frame. | It owns frame passes and offscreen targets. Normal mapping uses registered surfaces in a prepass. | +| `RadianceLighting` | Light propagation and source-sized penumbrae through occluding geometry. | It samples a radiance field, needs a renderable float target, and has different visual and cost characteristics. It does not use the lightmap normal prepass. | -declare const lighting: Lighting; +Start with the simplest model that produces the required image. A large number of lights is not free in any model: collection, geometry, field resolution, transport, and pixel work still cost time even when there is no fixed light-count cap. -// Equal in every direction, falling off to nothing at its radius. -lighting.add(new PointLight({ radius: 260 })); +## Build a frame-lit scene -// A cone along the node's own rotation - aiming a spot is rotating it. -lighting.add(new SpotLight({ radius: 400, angle: 35, coneSoftness: 0.3 })); +This example needs no external images: -// A segment: falloff is measured from the nearest point on it, so the pool of -// light is a capsule. Neon tubes, light strips, lasers. -lighting.add(new LineLight({ length: 120, radius: 160, color: new Color(120, 200, 255) })); - -// A direction and no position. Reaches everything the camera can see, falls off -// nowhere, and its shadows are parallel. -lighting.add(new SunLight({ intensity: 0.8 })).setRotation(-35); -``` + -A line light's `radius` is the distance from the **segment**, so it reaches `length / 2 + radius` along its own axis and `radius` across it. A sun's `height` is a slope rather than a length, because a source at no particular distance has no other meaning for one. +`LightmapLighting` and `RadianceLighting` need the application host. Construct them in `init`, not in a field initializer that reads `this.app` before attachment. Scene systems update after scene code has moved its lights, so the lighting system sees that frame's positions. -Shapes are deliberately not extensible. A shape is instance data a light-pass shader evaluates, and opening it up means either exposing that shader's structure or accepting a draw call per shape. +The scene root owns the light node; registering it with `lighting.add` does not transfer node ownership. The scene system registry owns the lighting system and its frame-pass lifetime. The light node emits but does not draw a visible lamp image by itself. -### Cookies +## Keep emitters and occluders separate -Every light takes an optional `cookie` texture — the cheapest large visual win here. +A rendered wall does not automatically block light. Register an occluder source that describes the intended blocking geometry. Sources can read physics colliders, occupied tile cells, a sprite's alpha silhouette, a mesh outline, or an explicit polygon. -```ts -import type { Texture } from '@codexo/exojs'; -import { Lighting, PointLight } from '@codexo/exojs-lighting'; - -declare const lighting: Lighting; -declare const windowCross: Texture; - -lighting.add(new PointLight({ radius: 320, cookie: windowCross })); -``` - -The texture's full `0..1` maps onto the light's own bounding square, so the pattern turns with a cone light and scales with the radius — it is fixed to the lamp, not to the world. It is multiplied into the light, so a transparent part of the cookie casts nothing and an opaque white one changes nothing. - - - A cookie travels with its light. That is right for a torch with a cut-out over it and wrong for a window, whose bars belong to the wall: move a light wearing a window and the bars slide across the floor with it. Keep such a light still and animate its `intensity` instead. Anchoring a pattern to the world is a projection rather than a cookie, and is not built. - - -Lights sharing a cookie share a draw. A scene with three distinct cookies costs three draws rather than one — still one draw per texture, never one per light. `forward` ignores cookies: it shades inside the sprite stage, where a texture per light cannot be reached in one draw. - -## Normals nobody has to author - -Most 2D projects have no normal maps, and a lighting system that looks bad without them is a lighting system nobody switches on. So normals are an upgrade, never an entry fee: a surface without them is lit as a plane rather than left black. - -Where they come from depends on which renderer is shading. - -Under `forward`, they are a **material** binding: - -```ts -import type { Sprite, Texture } from '@codexo/exojs'; -import { AlphaNormals, Lighting, LitMaterial, NormalMap } from '@codexo/exojs-lighting'; - -declare const lighting: Lighting; -declare const crate: Sprite; -declare const hero: Sprite; -declare const heroNormals: Texture; -declare const crateTexture: Texture; - -// Lit as a plane - no map, and not black. -crate.material = new LitMaterial({ lighting }); - -// An authored tangent-space map. -hero.material = new LitMaterial({ lighting, normals: new NormalMap(heroNormals) }); - -// Derived from the texture's own alpha, once at load. -crate.material = new LitMaterial({ lighting, normals: new AlphaNormals(crateTexture) }); -``` +Use geometry the project already owns when it matches the desired shadow. A tree canopy and its trunk may need different visual and shadow shapes; that is a reason for a separate occluder, not a missing flag on `Sprite`. -Every sprite drawn with a given material shares its map — in practice one material per atlas — and the map must have the same layout as the albedo atlas, frame for frame. Rotation and mirroring are handled in the shader. +Alpha and mesh extraction have boundaries. A render texture is not a synchronously readable alpha image. An animated atlas can reuse a silhouette per frame, but changing pixels inside the same frame rectangle is not the same cache key. A deforming mesh or video texture does not automatically become a newly traced outline every frame. -The canonical input convention is **OpenGL**: green above the midpoint means the normal leans towards the top of the image, blue points out of the sprite plane, and a flat texel is `(128, 128, 255)`. This is ExoJS's own choice, not a universal standard: most authoring tools can write either convention and several — Substance's mesh bakers among them — default to DirectX, so check what your exporter is set to. A map authored the other way up is declared rather than edited: +Use the lighting debug views to inspect the collected occluders and their rasterized mask before increasing shadow quality. If geometry is absent or thinner than the field can resolve, a higher light intensity does not repair it. -```ts -import type { Texture } from '@codexo/exojs'; -import { NormalMap } from '@codexo/exojs-lighting'; - -declare const fromMax: Texture; - -const normals = new NormalMap(fromMax, { convention: 'directx' }); -``` - -The setting travels with the source into both the `forward` shader and the `lightmap` prepass, and costs no texture copy and no per-frame readback. There is no auto-detection and no backend-dependent default. - - - Three things are easy to run together: the channel convention (which way up green is), the texture's own orientation (which way up the image is), and this engine's y-down coordinates (which is why a normal leaning towards the top of its image leans towards local `-y`). Flipping the image does not fix a DirectX map, and declaring the convention does not flip the image. - - -Under `lightmap` there is no per-fragment surface to bind to: the renderer multiplies a frame that was already drawn. A **normal prepass** puts one back. Register a drawable and the renderer draws its normal map, at the drawable's own place and orientation, into one attachment the light shader then reads: - -```ts -import type { Sprite, Texture } from '@codexo/exojs'; -import { Lighting, NormalMap } from '@codexo/exojs-lighting'; - -declare const lighting: Lighting; -declare const crate: Sprite; -declare const crateNormals: Texture; - -lighting.normalsFrom(crate, new NormalMap(crateNormals)); -``` - - - The attachment's alpha is coverage, and where it is zero the light lands with no `N dot L` term at all — which is exactly how the renderer behaved before the prepass existed. Switching normals on cannot darken anything that did not ask for them, and a scene that registers none pays for no attachment and no pass. - - -The drawable's own texture supplies that coverage, so a silhouette claims a surface and the empty corners of its quad do not. What it inherits from every screen-space normal buffer: one normal per pixel, so overlapping surfaces resolve to the topmost. - -## Shadows you do not model - -Nothing in this package has a `castsShadow` flag. A flag on a drawable would put lighting vocabulary on a class with no lighting concern, and it would tie the shadow silhouette to the sprite's shape — which is wrong often enough that a tree casts the shadow of its trunk, not of its canopy. - -Instead, what blocks light is a **source**, and the useful sources read descriptions you already have: - -```ts -import type { Sprite } from '@codexo/exojs'; -import { AlphaOccluder, Lighting, PhysicsOccluder, PolygonOccluder, TilemapOccluder } from '@codexo/exojs-lighting'; -import type { OccluderPhysicsWorld, OccluderTileLayer } from '@codexo/exojs-lighting'; - -declare const lighting: Lighting; -declare const world: OccluderPhysicsWorld; -declare const walls: OccluderTileLayer; -declare const tree: Sprite; - -// You already have colliders, so you already have shadows. -lighting.occludeFrom(new PhysicsOccluder(world, { staticOnly: true })); - -// Follows the chunk streamer, so an infinite map streams its shadows. -lighting.occludeFrom(new TilemapOccluder(walls)); - -// A sprite's own silhouette, traced once at load. -lighting.occludeFrom(new AlphaOccluder(tree)); - -// The escape hatch, and the right answer whenever the shadow outline is not -// the drawn one. -lighting.occludeFrom( - new PolygonOccluder([ - { x: 0, y: 0 }, - { x: 64, y: 0 }, - { x: 64, y: 96 }, - ]), -); -``` - -`PhysicsOccluder` and `TilemapOccluder` take structurally typed arguments, so this package depends on neither the physics nor the tilemap package: a project without them pulls in nothing, and a project with a collision layer of its own can feed shadows from that instead. - -`softness` is a property of the light, in `0..1`, and the two renderers mean different things by it. Under `lightmap` it is **filter width**: the light stays a point and the shadow term is averaged over a band of its angular shadow row, up to three percent of a full turn. The edge widens, but it widens with distance from the *light* rather than from the wall, and it does not behave like a shadow cast by a source of that size. Under `radiance` it is **source size**: the emitter is given a width, and the penumbra follows from the geometry — it grows with the distance between the wall and what the shadow falls on. - -Neither adds a pass. Under `lightmap` the filter samples every bin under its kernel and spends between 7 and 23 texture fetches per shadowed fragment doing it, which also bounds the kernel at ten bins either side — three percent of a turn at the default `shadowResolution`, and proportionally less as that rises. - - - `AlphaOccluder` reads pixels, and a `RenderTexture` has none this side of the GPU. It returns an empty source rather than a wrong outline, and a development build says which of three cases it was. If you need both a live surface and its outline, draw into an `HTMLCanvasElement` and wrap that in a `Texture`. - - -### How a shadow is computed - -Every light gets one row of a shadow map. For a point, cone or line light that row is **polar**: for each of `shadowResolution` angular bins around the light, the distance to the nearest occluding edge. A sun has no centre to measure angles from, so its row is **linear**: one bin per strip across the light's direction, holding how far along the light the nearest occluder in that strip sits. - -The rows are built on the CPU from the segments the sources collected and uploaded as one texture; the light shader turns a fragment's own direction into a bin and compares. That shape is chosen so the lights stay in a single instanced draw — a shadow pass per light would break the batch the renderer exists for. - -## Emission - -A `LitMaterial` takes an `emissive` multiplier: how much light the surface emits of its own, as a multiple of its albedo. - -```ts -import type { Sprite } from '@codexo/exojs'; -import { Lighting, LitMaterial } from '@codexo/exojs-lighting'; - -declare const lighting: Lighting; -declare const lava: Sprite; - -lava.material = new LitMaterial({ lighting, emissive: 2.4 }); -``` - -It is added to the light term rather than to the colour, so emission scales the albedo the way a light does — a black pixel emits nothing however high it is set, and a transparent one stays transparent instead of glowing through its own alpha. Values above `1` push the surface past what a light could produce, which is what a `post` filter keyed on a threshold is there to catch. - -## Filters over the shaded frame - -`post` is a filter chain over what the system produced, run as one pass in `app.framePasses`. A bloom belongs here rather than on a node, because it reads the light the system accumulated — including the parts no single node drew. - -```ts -import { BloomFilter, type Application } from '@codexo/exojs'; -import { Lighting } from '@codexo/exojs-lighting'; - -declare const app: Application; - -const lighting = new LightmapLighting(app, { post: [new BloomFilter({ threshold: 0.9 })] }); -``` - -It needs `app` in either renderer, and a chain passed without one is refused at construction rather than quietly ignored. Under `lightmap` the composite writes an off-screen target in the light target's own format and the chain reads that, so a threshold above `1.0` still has something to find — the light target is `rgba16f` wherever one can be rendered into, and `lighting.hdr` reports whether it is. - -## Seeing what the renderer sees + -```ts -import { Lighting } from '@codexo/exojs-lighting'; +## Normal maps -declare const lighting: Lighting; +Forward lighting reads normals from a `LitMaterial`; lightmap lighting uses `normalsFrom` to register a drawable and its normal source. Without a registered normal source, the lightmap path lights that surface without the normal prepass contribution. A normal map changes surface response; it does not create occluding geometry. -lighting.debug = 'light'; // the accumulated light field, without the scene's colours -lighting.debug = 'normals'; // the prepass normals, encoded the way a normal map is -lighting.debug = 'occluders'; // the silhouettes the sources collected, over the scene -lighting.debug = 'mask'; // the same silhouettes rasterised at the light field's own resolution -lighting.debug = null; -``` +The default authored convention is OpenGL-style tangent space: red points right, green points toward the top of the image, and blue points out of the sprite plane. For a DirectX-style asset, declare `new NormalMap(texture, { convention: 'directx' })`. Flipping the green channel is not the same operation as flipping the texture vertically, and neither changes the engine's y-down world coordinates. -`mask` is the one the GPU-resident paths read: it says whether a wall is thick enough to be seen at the light field's own resolution, which is what a cascade ray samples where an occluder is a drawable rather than an outline. +Use the same atlas layout for albedo and normals. A material binding is shared by the sprites using that material, so a second atlas generally needs its own corresponding material. `AlphaNormals` derives surface detail from a silhouette; it cannot reconstruct interior detail that the alpha channel does not contain. -The `occluders` view is the one that explains the feature: it draws what the shadows are actually being cast from, which is usually the fastest way to find out that a source is reading the wrong thing. +The lightmap normal prepass stores one normal per covered pixel. Overlapping registered surfaces resolve by registration order within that prepass, not by an inferred full scene ordering. Test layered surfaces rather than assuming that a single normal buffer describes every overlap. -## Cost + -Forward lighting costs `fragments x active lights`. With everything on screen lit and many overlapping lights, the fragment stage becomes the bottleneck well before the CPU does — measure before raising `maxLights` into the dozens on a full-screen scene. +## Cookies and shadow softness -The lightmap renderer costs the fill of each light's own radius, plus two full-screen passes and a third when normals are registered. `lightResolution` (default `0.5`) sets the light target's density: light is low-frequency, so half resolution is hard to tell apart and costs a quarter of the fill. +A cookie is a mask carried by a light. Its coordinates follow the light's bounding square, rotation, and size. It is not a world-anchored projector: moving a window-shaped light also moves its pattern. Forward lighting does not support that cookie path. -The radiance renderer costs neither of those: it costs the probe grid. Every level of the chain holds the same number of texels, and the number of levels follows the view's own diagonal, so the whole field is between four and nine full-screen-ish passes however many lights are in it. `probeSpacing` (default `2` light-field texels) is the knob that moves that cost, and halving it quadruples the grid. +`softness` means different things in the two shadowed models. Lightmap softness filters angular shadow information around a point-like source. Radiance softness changes source size, from which the sampled penumbra follows. Do not compare equal numeric values as though they specified the same physical width. -Shadows cost the visible occluding edges times the lights that can see them, per frame. That is bounded by collecting only the region the visible lights jointly reach, by emitting only boundary edges — a hundred-tile corridor is four segments, not four hundred — and by caching whatever does not change. + -## Examples +## Radiance quality and temporal behavior - - +Radiance settings control field sampling, directional coverage, and the represented region. Start with the default field, then vary one quality parameter while moving lights and the camera. Inspect thin walls, near-source regions, screen edges, and overlapping emitters; a still image does not reveal temporal sampling artifacts. -Walls that hand over the rectangle they are drawn as, a cross whose outline is traced from its alpha, and a softness slider — with nothing in the scene declaring that it casts a shadow. +Bounced light uses prior-frame information. Setting `bounce` to zero removes that contribution; it does not turn radiance into forward lighting. Test the temporal response your scene can tolerate instead of describing a sampled field as an exact light-transport solution. - -One lamp, two rooms and a doorway, with a switch between `lightmap` and `radiance` on the same scene. Watch the far room: under the light quads it is whatever the lamp's radius reaches minus a shadow, and under the cascades it is dark except for the wedge coming through the opening. The softness slider is the lamp's own size under `radiance` — widen it and every penumbra in the scene widens with it — and the width of an angular filter under `lightmap`, which is not the same quantity. Pause the motion and set a phase to compare the two renderers at the same instant; they are different transport models and will not agree pixel for pixel. - - - - -Window bars and leaf shade as cookies on the lights themselves, a neon tube pooling in a capsule, and a sun with no position throwing parallel shadows. +## HDR, filters, and ownership - - +`lighting.hdr` reports whether the active path accumulates with headroom above one. Lightmap lighting can fall back to an `rgba8` target where its float target is unavailable; that clips earlier and changes what a thresholded bloom can detect. Radiance has its own float-target requirement. Inspect capabilities rather than assuming that one model's fallback applies to another. -Cobbles that nobody authored a normal map for: each hands the system its own silhouette, and a prepass draws the derived normals where the stone sits. +A post filter operates on the shaded frame. More exposure is not tone mapping, and a bloom does not supply an sRGB/linear color-management pipeline by itself. Keep source color interpretation, lighting accumulation, and display conversion conceptually separate. -## Where to go next +The lighting system owns its renderer resources. Light nodes, occluder sources, the host, normal sources, and filters supplied by the caller retain their own ownership. Track or dispose caller-created resources at their actual lifetime boundary; do not destroy a shared loader texture to remove one light. -[Post-processing](/ExoJS/en/guide/effects/post-processing/) covers the frame slot the lightmap renderer installs into, which is also how you would write a lighting system of your own — `app.framePasses.addPass(myPass)` hands a pass the finished frame and lets it write the canvas, with no agreement with this package at all. +Use the [Lighting API](/ExoJS/en/api/lighting/) for exact registration and capability contracts and [Performance](/ExoJS/en/guide/debugging/performance/) to measure the complete effect rather than a fixed pass-count slogan. diff --git a/site/src/content/guide/effects/particles.mdx b/site/src/content/guide/effects/particles.mdx index 04c57dce6..65f4a7922 100644 --- a/site/src/content/guide/effects/particles.mdx +++ b/site/src/content/guide/effects/particles.mdx @@ -1,277 +1,75 @@ --- title: 'Particles' -description: 'Spawn and tune particle systems for environmental and reactive effects.' +description: 'Build a bounded emitter, understand local-space simulation and CPU/GPU routing, and give the system a clear lifetime.' --- -import ExamplePreview from '../../../components/ExamplePreview.astro'; import SourceSnippet from '../../../components/SourceSnippet.astro'; -import Callout from '../../../components/Callout.astro'; +import ExamplePreview from '../../../components/ExamplePreview.astro'; # Particles -`ParticleSystem` is a `Drawable` that manages thousands of animated sprites with data-oriented performance. Instead of creating and destroying individual `Sprite` instances per particle, the system stores particle state in parallel typed arrays (Struct-of-Arrays) and mutates them in bulk. This keeps the work per-particle extremely lean — no allocations, no GC pressure, no per-sprite transform tree overhead. - -> **Note:** `ParticleSystem` ships as an official ExoJS extension package. Install `@codexo/exojs-particles` alongside `@codexo/exojs`: -> ```sh -> npm install @codexo/exojs @codexo/exojs-particles -> ``` - -The mental model: you register modules that describe how particles *spawn*, what they *do over their lifetime*, and what happens when they *die*. The system calls those modules each frame against its channel storage. You never write a per-particle `update` loop. - -## Setup - -Register the extension when creating your Application: - -```ts -import { Application } from '@codexo/exojs'; -import { particlesExtension } from '@codexo/exojs-particles'; - -const app = new Application({ extensions: [particlesExtension] }); -``` - -## Construction - -A particle system needs a texture and a capacity: - -```ts -import { Texture } from '@codexo/exojs'; -import { ParticleSystem } from '@codexo/exojs-particles'; - -function createParticles(particleTexture: Texture): ParticleSystem { - return new ParticleSystem(particleTexture, { capacity: 4000 }); -} -``` - -`capacity` (default 4096) is the maximum number of particles the system can have alive at once. It's fixed at construction — the backing typed arrays are allocated immediately. - -The system is a `Drawable`, so it has a position, rotation, scale, tint, blend mode, and participates in the scene graph like any sprite: - -```ts -import { BlendModes } from '@codexo/exojs'; -import { ParticleSystem } from '@codexo/exojs-particles'; - -declare const system: ParticleSystem; - -system.setPosition(400, 500); -system.setBlendMode(BlendModes.Additive); -``` - -Particle positions are local to the system — setting the system's position moves the whole emitter. - -## Spawn modules - -A spawn module creates new particles each frame. Two built-in spawners cover the common cases: - -**`RateSpawn`** — continuous emission at a configurable rate (particles per second): - -```ts -import { Vector } from '@codexo/exojs'; -import { ConeDirection, Constant, RateSpawn, Range } from '@codexo/exojs-particles'; - -system.addSpawnModule(new RateSpawn({ - rate: new Constant(180), // 180 particles / second - lifetime: new Range(0.6, 1.4), // random lifetime in seconds - velocity: new ConeDirection(-Math.PI / 2, Math.PI / 5, 70, 180), - scale: new Constant(new Vector(0.35, 0.35)), -})); -``` - -**`BurstSpawn`** — named bursts at scheduled times, with optional looping: - -```ts -import { Vector } from '@codexo/exojs'; -import { BurstSpawn, ConeDirection, Constant, Range } from '@codexo/exojs-particles'; - -const burst = new BurstSpawn({ - schedule: [{ time: 0, count: 100 }], // 100 particles at t=0 - lifetime: new Range(0.5, 1.2), - velocity: ConeDirection.omni(80, 240), // full 360° spread - scale: new Constant(new Vector(0.4, 0.4)), -}); -system.addSpawnModule(burst); +Use a `ParticleSystem` for many short-lived elements that share an emission and simulation model: sparks, smoke, rain, trails, or decorative motion. Use ordinary sprites when each object needs independent gameplay identity and behavior. -// Re-trigger the burst schedule from t=0 -burst.reset(); -``` +`@codexo/exojs-particles` supplies the simulation and rendering integration. Add `particlesExtension` to the application; importing the classes alone does not install their renderer. -Every spawn config property that takes a value — `lifetime`, `position`, `velocity`, `scale`, `rotation`, `rotationSpeed`, `tint`, `textureIndex` — accepts a `Distribution` rather than a fixed value. Distributions are sampled per-particle at spawn time. - -## Distributions - -Distributions let each spawned particle get a different value. The most commonly used: - -| Distribution | What it produces | -|---|---| -| `Constant(value)` | The same value every time | -| `Range(min, max)` | Uniform random number in `[min, max]` | -| `VectorRange(xMin, xMax, yMin, yMax)` | Independent uniform random per axis | -| `ConeDirection(angle, halfAngle, minSpeed, maxSpeed)` | Velocity vector within a directional cone | -| `ConeDirection.omni(minSpeed, maxSpeed)` | Full 360° omnidirectional velocity | -| `BoxArea(minX, maxX, minY, maxY, mode)` | Random point in an axis-aligned box — `'volume'` (default) fills the area, `'edge'` sticks to the perimeter | -| `CircleArea(centerX, centerY, radius, mode)` | Random point in a circle — `'volume'` (default) fills the disk with uniform area density, `'edge'` sticks to the circumference | -| `LineSegment(x0, y0, x1, y1)` | Random point uniformly distributed along a line segment | -| `Curve(keys)` | Piecewise-linear spline evaluated over a particle's lifetime normalised progress (0..1) | -| `ColorGradient(keys)` | Same as `Curve` but interpolates `Color` values | - -`Curve` and `ColorGradient` are `LifetimeFunction` rather than `Distribution` — they're evaluated with a normalised lifetime `t` in 0..1, not sampled at spawn. They're typically used with update modules, not spawn modules. - -## Update modules - -Update modules mutate particle state each frame. Every built-in update module that works on both backends declares a `wgsl()` contribution — when a WebGPU backend is active and every registered update module is GPU-eligible, the system auto-compiles a composite WGSL compute shader and runs the full update pipeline on the GPU in a single dispatch. When any module lacks a `wgsl()` contribution, or when running on WebGL2, the system falls back to CPU. You don't configure this — it's automatic. - - -GPU mode compiles all modules into one composite shader on the first `update()`, which locks the list. Add every force, drag, fade and color module up front — you cannot append one once the system has stepped. - - - -A system takes the WGSL compute path when the backend is WebGPU and every update module is GPU-eligible; otherwise it runs the identical API on the CPU. Read `system.gpuMode` to confirm which path it took. - - -The frequently-used update modules: - -```ts -import { Color } from '@codexo/exojs'; -import { - AlphaFadeOverLifetime, - ApplyForce, - ColorOverLifetime, - ColorGradient, - Curve, - Drag, - ScaleOverLifetime, - Turbulence, -} from '@codexo/exojs-particles'; - -// Constant acceleration (gravity, wind) -system.addUpdateModule(new ApplyForce(0, 240)); - -// Speed-based drag -system.addUpdateModule(new Drag(0.1)); - -// Fade alpha over lifetime (requires a Curve) -system.addUpdateModule(new AlphaFadeOverLifetime( - new Curve([{ t: 0, v: 1 }, { t: 1, v: 0 }]) -)); - -// Full color interpolation over lifetime -system.addUpdateModule(new ColorOverLifetime( - new ColorGradient([ - { t: 0, color: new Color(255, 200, 100, 1) }, - { t: 1, color: new Color(0, 0, 0, 0) }, - ]) -)); - -// Animated scale -system.addUpdateModule(new ScaleOverLifetime( - new Curve([{ t: 0, v: 0.5 }, { t: 0.3, v: 1.2 }, { t: 1, v: 0.1 }]) -)); - -// Procedural noise-based motion -system.addUpdateModule(new Turbulence(30, 0.01)); +```sh +npm install --save-exact @codexo/exojs @codexo/exojs-particles ``` -Modules can be added and removed at any time, including while particles are in flight — the next `update()` rebuilds whatever the change invalidated. On the GPU path that is the compute program alone: the particles keep the state the device has been integrating, so live tuning does not restart the effect. - -The one change that cannot preserve them is adding a module without a `wgsl()` implementation to a running GPU system. That moves the simulation to the CPU, which holds no copy of what the device computed, so the system clears its live particles rather than continuing from stale values. - -Other available update modules: `RotateOverLifetime`, `VelocityOverLifetime`, `AttractToPoint`, `RepelFromPoint`, `OrbitalForce`, `ColorOverSpeed`. The API reference documents each one's constructor options and GPU eligibility. - -## Death modules - -A death module fires once per particle when its lifetime expires. The only built-in death module is `SpawnOnDeath`: +## Start with a bounded emitter - +This complete example uses the built-in white texture rather than an external asset: -`SpawnOnDeath` forwards the dying particle's position to the child system's spawn, so each child burst appears at the parent's death location. + -A custom death module receives that same information as a `ParticleDeathContext` — position, velocity, rotation, scale, colour and timing at the moment of death: +The scene's system registry advances and destroys the particle system. `draw` submits it explicitly. Do not also call `update` manually when the registry already advances it. - +`RateSpawn` creates particles over time. Spawn modules establish initial attributes; update modules change live particles in registration order. The example gives each particle a two-second lifetime, launches it upward, applies downward acceleration, and reduces its scale over its lifetime. -The context is a snapshot, not a view into the system: it is the same on both backends, stays valid for the whole callback, and carries no slot index because the slot may already hold a different particle by the time the callback runs. Delivery is exactly once per expired particle, but not necessarily in the frame it expired — a GPU-simulated death arrives with its readback, typically one frame later. Readbacks overlap, so frames that each report deaths do not queue behind one another; when the device falls far enough behind, deaths wait on the GPU and arrive with a later batch, still in the order they happened. Exactly-once holds while those waiting deaths fit the system's capacity; past that the excess is dropped instead of stalling the frame, and a development build warns once per system. +The normal emitter produces about 160 concurrent particles at steady state before considering bursts or timing effects. Capacity is 512, so this example leaves headroom. That calculation is specific to its rate and lifetime, not a general capacity recommendation. -## Per-frame loop +## Choose a space and units -`ParticleSystem` is a `Drawable` — it renders itself when you call `context.render(system)` in `draw`. Call `system.update(delta)` in your scene's `update`: +Particle positions are local to the system. Position the system in the scene, then emit relative to its origin. Supplying world coordinates for every particle while also translating the system applies that placement twice. - +Velocity and acceleration follow your scene's coordinate units per second and per second squared. Direction modules such as `ConeDirection` use radians; do not pass a scene node's degree rotation without converting it. In a y-down scene, an angle of `-Math.PI / 2` points upward. -The update loop runs spawn modules, advances particle state (velocity integration, elapsed time), runs update modules, compacts dead particles, and — in GPU mode — uploads dirty slots and dispatches the compute shader. `render()` draws the system as a single instanced draw call, regardless of particle count. +A system's ordinary bounds do not describe the whole moving cloud, so particle culling is disabled by default. Enable it only when you can maintain a valid world-space `cullArea` that covers the effect. Pixel snapping on the system does not snap each independently simulated particle. -## Channels and manual emission +## CPU and GPU are execution paths -Particles are addressed by named channel, never by raw slot. A module receives the channels it needs and indexes them itself: +On WebGL2, simulation uses the CPU path. On WebGPU, GPU routing also depends on the update modules, render mode, and backend/device attachment. Inspect `gpuMode` after the system has been attached and updated; selecting WebGPU alone is not proof that this particular effect is using compute. - +The GPU path keeps integrated state on the device. Do not treat a retained CPU-side position array as authoritative for a live GPU simulation. A custom CPU-only update module makes the system ineligible for that path. A custom render mode can also require CPU execution. -The channels are `position`, `velocity`, `scale` (each `.x` / `.y`), `rotation` (`.angle` / `.speed`), `timing` (`.elapsed` / `.lifetime`), `color` (packed `0xAABBGGRR`) and `frame`. Each is the simulation's own storage, so writing moves the particle. Indices `[0, particles.count)` are the range worth visiting; `particles.isAlive(i)` skips the holes a GPU-mode system can leave behind. +The default render mode is shared by the implementation. A custom render mode supplied to a system is owned by that system; do not give the same owned mode instance to two independently destroyed systems. -To emit a particle yourself, ask the system for one: +## Change modules deliberately -```ts -import { ParticleSystem } from '@codexo/exojs-particles'; +Modules can be added and removed after the first update. On an eligible GPU path, changing the module set rebuilds the relevant compute program. A change that forces an already running GPU system onto the CPU cannot reconstruct its integrated state from stale CPU values: live particles are cleared instead. -declare const system: ParticleSystem; +Treat that transition as a visible effect restart, not a seamless backend migration. Prefer stable module composition with parameter changes for continuously running effects, and exercise editor or quality-setting changes that alter eligibility. -const particle = system.emit(); +A module order is part of the effect. A later absolute scale or color module can overwrite an earlier value; adding another module is not always equivalent to multiplying another influence into the result. -if (particle) { - particle.position.set(120, 40); - particle.velocity.set(0, -80); - particle.lifetime = 2; -} -``` - -`emit()` returns `null` at capacity. Every field starts at its default — origin, no velocity, unit scale, no rotation, opaque white, frame 0, one second of life — so you write only what you vary. The returned writer is a cursor onto the emitted particle: the next `emit()` rebinds it, so fill it before emitting again. +## Death hooks and gameplay -`system.clearParticles()` resets the system to zero live particles, `system.liveCount` is the range that can hold them, `system.aliveCount` counts the live ones, and `system.gpuMode` reports whether the compute path is active. +Death modules are useful for secondary visual effects. CPU and GPU death reporting have different timing and buffering costs. GPU reporting involves bounded asynchronous data transfer; do not use it as an unqualified, same-frame gameplay authority. - - Channel values are true where the simulation runs. On the GPU path only the compute shader advances them, so outside an update module or a render mode the CPU copy still holds the spawn values. That is why emission, not mutation, is the supported way in — and why a death module receives a snapshot rather than a slot. - +Bound secondary emissions so one burst cannot recursively create an unbounded effect. Setting a spawn rate to zero stops future rate-based spawning; existing particles continue until they expire or are explicitly cleared. Destroying the system ends its lifetime rather than merely fading its current population. -## GPU auto-routing +## Own textures and systems separately -The decision is per-system and automatic. When: -1. A `WebGpuBackend` is active -2. Every registered update module implements `wgsl()` +A texture acquired through `this.loader` remains scene-loader-owned. The particle system uses it but is not permission to destroy a resource shared by another scope. Conversely, destroying a texture claim does not stand in for releasing a caller-owned system or custom render mode. -...the system compiles a composite WGSL compute shader that integrates position, velocity, rotation, and every module's dynamic behavior in one dispatch, writing directly into the renderer's instance vertex buffer. No CPU readback in the steady state. The `gpu-particles` example lets you vary the emission rate and observe the live count and execution path. +Register the system with the scope that should update it. A scene-owned registry follows scene pause and teardown; an application-owned registry outlives an individual scene. Keep that choice explicit for effects that span navigation. -If conditions aren't met (WebGL2 backend, or any module without `wgsl()`), the system runs on CPU with the same API. Your scene code is identical — no branching, no backend checks. +## Measure the complete effect -## Examples +Count, lifetime, module complexity, fill area, blend mode, readback, and target resolution all contribute to cost. `liveCount` is a slot-range high-water mark on the GPU path and can include holes; use `aliveCount` when you need actual live occupancy, accounting for the work of reading it. - -A single upward emitter with gravity and fade — `RateSpawn` + `ApplyForce` + `AlphaFadeOverLifetime`. - - - - -A bonfire effect with additive blending — `RateSpawn` with random position and upward velocity, plus `ColorOverLifetime` from ember-orange to transparent black. - -## Where to go next + -The next chapter, [Post-processing](/ExoJS/en/guide/effects/post-processing/), covers scene-wide multi-pass rendering — how to combine `RenderTexture` targets with filter chains for bloom, trails, and composited color grading. +The GPU demonstration uses a different fallback budget on WebGL2; it is a capability demonstration, not an equal-work benchmark. See [Performance](/ExoJS/en/guide/debugging/performance/) for controlled comparisons and the [`ParticleSystem` reference](/ExoJS/en/api/particle-system/) for exact module and lifetime contracts. diff --git a/site/src/content/guide/effects/post-processing.mdx b/site/src/content/guide/effects/post-processing.mdx index 990fbfcf2..1b502fab0 100644 --- a/site/src/content/guide/effects/post-processing.mdx +++ b/site/src/content/guide/effects/post-processing.mdx @@ -147,4 +147,4 @@ A composable frame built once from a `RenderPipeline`: the world renders off-scr ## Where to go next -The next chapter, [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/), covers attaching custom GLSL or WGSL shaders to `Mesh` drawables — the geometry-space complement to the screen-space filter and post-processing techniques covered here. +Continue with [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/), which covers attaching custom GLSL or WGSL shaders to `Mesh` drawables — the geometry-space complement to the screen-space filter and post-processing techniques covered here. diff --git a/site/src/content/guide/getting-started/project-structure.mdx b/site/src/content/guide/getting-started/project-structure.mdx deleted file mode 100644 index c644e8d00..000000000 --- a/site/src/content/guide/getting-started/project-structure.mdx +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: 'Project structure' -description: 'Find your way around a create-exo-app project: the entry point, scenes, assets, and how main.ts wires an Application to a Scene.' ---- - -import SourceSnippet from '../../../components/SourceSnippet.astro'; -import NextStep from '../../../components/NextStep.astro'; -import Callout from '../../../components/Callout.astro'; - -# Project structure - -A `create-exo-app` project is a standard Vite + TypeScript app. The `minimal` template gives you the smallest layout that still separates startup from scene code: - -```txt -my-game/ -├─ index.html # mounts the app, loads src/main.ts -├─ package.json # @codexo/exojs + Vite scripts -├─ tsconfig.json # strict TypeScript config -├─ vite.config.ts # dev server and build config -├─ public/ -│ └─ assets/ # static files served as-is (images, audio, fonts) -└─ src/ - ├─ main.ts # entry point: create the Application, start a Scene - └─ scenes/ - └─ MainScene.ts # your first scene -``` - -Two files hold the code you will edit most: `main.ts` and the scene under `src/scenes/`. - -## The entry point - -`src/main.ts` is where the app starts. It creates one [`Application`](/ExoJS/en/api/application/), mounts its canvas, and starts a [`Scene`](/ExoJS/en/api/scene/): - - - -The application owns runtime configuration — canvas size, clear color, render backend, and the frame loop. `app.start(scene)` hands control to a scene and begins ticking. `canvas.mount` places the canvas; in a real layout you place it wherever your page needs it. - -## The scene - -`src/scenes/MainScene.ts` is the scene `main.ts` starts. It sets up state in its constructor, updates it each frame, and draws it: - - - -`update(delta)` advances state — here, rotating a box — and `draw(context)` renders it. `delta` carries the elapsed time since the last frame, so motion stays frame-rate independent. The [Your first scene](/ExoJS/en/guide/getting-started/your-first-scene/) chapter builds a scene like this from scratch. - -## Where assets go - -Files under `public/assets/` are served as-is and addressed by path at runtime — for example `loader.load('assets/hero.png')`. The path's extension tells the loader what type to produce, so no import of `Texture` is needed just to load it. The [Loading and resources](/ExoJS/en/guide/assets/loading-and-resources/) chapter covers the loader in detail. - - -`public/` is the folder the dev server serves from, not part of the URL. A file at `public/assets/hero.png` is loaded as `assets/hero.png` — keeping the `public/` prefix in the path gives you a 404 at runtime. - - -## Scripts - -The template's `package.json` defines the standard Vite scripts: - -```sh -npm run dev # start the dev server with hot reload -npm run build # produce a production build in dist/ -npm run preview # serve the production build locally -``` - -The other templates add more structure on top of this same shape: `game-starter` splits player logic into `src/objects/` and adds a game-over scene, and `audio-reactive` adds an analyser-driven scene. The entry point and scene model stay the same. - - -Start from an empty scene and add a texture, a sprite, and per-frame motion. - diff --git a/site/src/content/guide/getting-started/setup.mdx b/site/src/content/guide/getting-started/setup.mdx index cfd05f9f9..cd3e83b22 100644 --- a/site/src/content/guide/getting-started/setup.mdx +++ b/site/src/content/guide/getting-started/setup.mdx @@ -1,84 +1,92 @@ --- -title: 'Setup' -description: 'Create a typed ExoJS project with create-exo-app, run the dev server, and produce a production build.' +title: 'Setup and project layout' +description: 'Create a Vite and TypeScript project, choose a starter, locate its scene code, and build it for production.' --- -import Callout from '../../../components/Callout.astro'; import NextStep from '../../../components/NextStep.astro'; -# Setup +# Setup and project layout -The fastest way to start is `create-exo-app`. It scaffolds a Vite + TypeScript project that already imports `@codexo/exojs`, runs a scene, and is ready for `dev` and `build`. - -## Create a project - -Run the scaffolder and follow the prompts: - -```sh -npm create exo-app@latest my-game -``` - -Then install dependencies and start the dev server: +`create-exo-app` creates a Vite + TypeScript project with an ExoJS scene already running. Use `minimal` for the shortest learning path: ```sh +npm create exo-app@latest my-game -- --template minimal cd my-game npm install npm run dev ``` -The dev server prints a local URL. Open it and you'll see a running scene with hot reload. +Open the local URL printed by Vite. Keep the development server running while editing the scene. Use a Node version supported by the generated project's tooling; contributing to the ExoJS repository itself requires Node 24. -## Choose a template +## Choose a starting point -`create-exo-app` ships three templates. Pass one with `--template`, or pick it interactively: +Omit `--template` to use the interactive picker, or choose one explicitly: -| Template | Starts with | -|----------|-------------| -| `minimal` | One scene drawing and animating a single shape — the smallest real project. | -| `game-starter` | A player object, a game scene, and a game-over scene with restart. | -| `audio-reactive` | An analyser-driven scene that turns sound into a live spectrum. | +| Template | Starting point | +| --- | --- | +| `minimal` | One scene and one visible animated object. | +| `game-starter` | Keyboard-controlled gameplay, player code, and a game-over scene. | +| `platformer` | Side-scrolling movement, physics bodies, camera follow, and jump handling. | +| `top-down` | Tilemaps, physics, and click-to-move pathfinding, with procedural or Tiled input. | +| `ui-app` | A settings screen using the Core UI widgets. | +| `audio-reactive` | Shapes and animation driven by an audio analyser. | -```sh -npm create exo-app@latest my-game -- --template game-starter +The specialized templates include more systems; they are starting projects, not prerequisites for learning the engine. Template availability follows the version of `create-exo-app` you run. This Guide describes the repository's current template set, which may be ahead of an older installed release. + +## Project layout + +The minimal starter separates application startup from scene behavior: + +```text +my-game/ + index.html + package.json + tsconfig.json + vite.config.ts + public/ + assets/ + src/ + main.ts + scenes/ + MainScene.ts ``` -The [Project structure](/ExoJS/en/guide/getting-started/project-structure/) chapter walks through what each file does. +`src/main.ts` creates the application, registers the scene class, mounts the canvas, and awaits `app.start(MainScene)`. `src/scenes/MainScene.ts` holds the objects, frame updates, and drawing. The next chapter explains both files using the maintained template source. -## Build for production +Keep asset files under `public/assets/` when you want them served unchanged. `public/` is not part of the URL: `public/assets/hero.png` is requested as `assets/hero.png`. A loader `basePath` can supply the common directory; do not repeat it in individual asset paths. Deployment under a subdirectory needs the base-path treatment in [Deployment](/ExoJS/en/guide/shipping/deployment/). -Vite produces a static bundle you can host anywhere: +## Production build ```sh -npm run build # output in dist/ -npm run preview # serve the production build locally +npm run build +npm run preview ``` -The [Deployment](/ExoJS/en/guide/shipping/deployment/) chapter covers hosting, base paths, and assets. - -## Add ExoJS to an existing project +`build` produces the static application in `dist/`. `preview` serves that build locally; it is a way to check the output, not a production hosting service. Verify the production build as well as the development server, especially asset URLs and capability-dependent code. -If you already have a bundler-based project, install the runtime directly: +## Add ExoJS to an existing application ```sh -npm install @codexo/exojs +npm install --save-exact @codexo/exojs ``` -The package ships as ESM with TypeScript declarations, so any modern bundler (Vite, esbuild, Rollup, webpack, Parcel) picks up both the code and the types. ExoJS renders into a regular `HTMLCanvasElement`; create one yourself or let `Application` create it for you: +Use the package's ESM entry point from your bundler. TypeScript declarations are included. Pin compatible exact versions for any official runtime packages you add. + +A canvas can be supplied or created by the application: ```ts import { Application } from '@codexo/exojs'; -// Pass an existing canvas... -const canvas = document.querySelector('#scene')!; -const app = new Application({ canvas: { element: canvas } }); +const canvas = document.querySelector('#scene'); +if (canvas === null) { + throw new Error('Missing #scene canvas.'); +} -// ...or let the application create one and mount app.element yourself. +const app = new Application({ canvas: { element: canvas } }); ``` - -`app.element` is the active `HTMLCanvasElement` the runtime renders into, or `null` when the surface is an `OffscreenCanvas`. Append it wherever your layout needs it, or let `canvas.mount` do it. - +This configures an application; it does not start a scene. Register and start one as shown next. For a generated canvas, use `canvas.mount` to place it in the DOM. `app.element` is the HTML canvas, or `null` for an offscreen surface. A framework host must also destroy the application when that host is permanently removed; the [React guide](/ExoJS/en/guide/integrations/react/) covers the maintained React boundary. - -See where the entry point, scenes, and assets live in a generated project. + +Draw and animate an object before introducing asset loading. diff --git a/site/src/content/guide/getting-started/what-is-exojs.mdx b/site/src/content/guide/getting-started/what-is-exojs.mdx index ed4d86134..7b240acfd 100644 --- a/site/src/content/guide/getting-started/what-is-exojs.mdx +++ b/site/src/content/guide/getting-started/what-is-exojs.mdx @@ -1,57 +1,52 @@ --- title: 'What is ExoJS?' -description: 'A short tour of what ExoJS is, where it fits, how a project is shaped, and how to read this guide.' +description: 'Decide whether ExoJS fits your project and learn the application, scene, and documentation model.' --- -import Callout from '../../../components/Callout.astro'; -import SourceSnippet from '../../../components/SourceSnippet.astro'; import NextStep from '../../../components/NextStep.astro'; # What is ExoJS? -ExoJS is a TypeScript-first, zero-dependency 2D runtime for browser games and interactive apps. +ExoJS is a TypeScript-first 2D runtime for browser games and interactive applications. It provides rendering, scenes, input, audio, UI, and asset loading; optional packages add systems such as physics, tilemaps, particles, lighting, and pathfinding. -It gives you the core pieces of a small engine — scenes, sprites, graphics, input, audio, effects, render targets, and a frame loop — while staying close to regular web development. You can use it from JavaScript or TypeScript, render into a canvas, and keep project structure in code. +Use it for a canvas-based game, visualization, tool, or interactive surface. It is code-first, not a visual game editor. A mostly DOM-based application can keep its existing UI framework and let ExoJS own only the canvas. The [React integration](/ExoJS/en/guide/integrations/react/) provides that boundary for React applications. -## Where ExoJS fits +## The model in one minute -Use ExoJS when you want to build a browser-based 2D game, visualization, toy, editor, or interactive scene with code. +An [`Application`](/ExoJS/en/api/application/) owns the canvas, rendering backend, frame loop, and application-level services. A [`Scene`](/ExoJS/en/api/scene/) organizes one screen or activity: it loads resources, sets up objects, updates state, and chooses what to draw. -It gives you engine-level building blocks without forcing a particular app framework or editor workflow. If you're building a mostly DOM-based interface, keep using your UI framework of choice. If you're building an interactive canvas surface, ExoJS can own that part. +The scene's `root` is a hierarchy of drawable objects. Adding a child establishes hierarchy; `draw(context)` explicitly renders it. The separate `scene.ui` layer is a screen-fixed overlay and is rendered automatically. Scene-scoped services follow the scene's lifetime; application-scoped services can be shared across scenes. -## How a project is shaped +You do not need custom renderers, an entity-component framework, or an extension to draw your first object. Begin with one application and one scene, then add structure when the project needs it. -A typical ExoJS project starts with two pieces: an [`Application`](/ExoJS/en/api/application/) and a [`Scene`](/ExoJS/en/api/scene/). +## Coming from another engine? -The application owns the runtime configuration — render backend, canvas size, asset path, frame loop. The scene loads resources, updates state, and draws each frame. +Treat an ExoJS scene as a lifecycle host, not as a complete saved project. Containers compose transforms and drawing order. Scene navigation manages activation, pause, retention, and teardown. Physics bodies, rendered objects, and serialized data are separate concepts even when a helper connects them. - +Those boundaries are useful when embedding a canvas in a web application: the browser page still owns its routing, DOM, and accessibility. ExoJS owns the runtime you put inside it. -From there, the same shape scales up: add sprites, split logic across multiple scenes, attach input, play audio, render effects, and move code into reusable objects as the project grows. +## Distribution and maturity -## Distribution +ExoJS provides ESM, TypeScript declarations, and prebuilt script-tag bundles. `exo.iife.js` contains Core; `exo.full.iife.js` also includes the official runtime extensions except React and tilemap physics. Both expose `Exo`. The [Setup guide](/ExoJS/en/guide/getting-started/setup/) starts with ESM and a bundler. -ExoJS ships as ESM with TypeScript declarations. JavaScript projects can use the runtime directly; TypeScript users get typed APIs out of the box. A standalone browser bundle for ` +const deploymentBase = new URL(import.meta.env.BASE_URL, document.baseURI); +const assetBase = new URL('assets/', deploymentBase).href; +const app = new Application({ loader: { basePath: assetBase } }); ``` -For the debug bundle (`exo.debug.esm.js`), note that it is an **external-core** bundle: it imports `@codexo/exojs` from outside itself. You must map that specifier to the core bundle using an import map: +Add the scene and canvas options from your application entry point; the fragment above isolates URL configuration. A descriptor such as `image/hero.png` then resolves below that asset base. Do not prefix it again with `assets/` or `public/`. -```html - +Files under `public/` are copied unchanged. Imported assets follow the bundler's asset pipeline. Check exact filename casing and ensure that a missing asset returns a real failure rather than the host's HTML application fallback. - -``` +Use a static host that can serve the entire output with correct relative paths and headers. For an embedded HTML game, package the contents of `dist/` at the archive root and configure the embedding surface to match the application's sizing policy. -Without the import map, `exo.debug.esm.js` cannot resolve `@codexo/exojs` and will fail with a module not found error. The debug bundle is not a standalone engine; it extends the core bundle. +For GitHub Pages or another CI-based host, use its maintained deployment workflow and publish only after the build succeeds. Keep hosting credentials in the host's secret store, not in application JavaScript. A browser bundle cannot keep a secret from its user. -Import maps are supported in all modern browsers. If you need to support older environments, use the npm + Vite workflow instead. +Verify JavaScript MIME types, font and media responses, HTTPS, and any required CORS headers. Both `text/javascript` and other standards-compatible JavaScript MIME types can be valid; an HTML fallback or `text/plain` response for a module is a common failure. -### Script-tag (IIFE) bundle +## Using a release bundle without a bundler -If you don't have any module-aware setup — no bundler, no import maps, just a plain HTML file with ` - -``` - -There is no `import` statement anywhere in this path — `Exo` is already in scope by the time the second ` + ``` -Prefer the ES module bundle when your host supports import maps and you want the browser to load only what you use. Prefer the IIFE bundle when you need a true zero-build setup — for example a single static HTML file, a CMS or forum post that only allows pasting a `