# Authoring Aperture Apps The default Aperture app is a Vite app with an `aperture.config.ts` file and worker-discovered ECS systems. The Vite plugin owns browser bootstrap, worker bundling, asset preload, render extraction, snapshot transport, WebGPU submission, resize, input forwarding, command forwarding, and diagnostics. You should be able to see a primitive mesh or GLB without calling `createWebGpuApp()`, `createExtractionApp()`, `stepAndExtract()`, posting render snapshots, registering renderer-side assets, or writing a main-thread scene graph. ## First App Shape Normal browser apps use this shape: - `vite.config.ts`: installs the Aperture Vite plugin. - `aperture.config.ts`: declares mode, canvas, systems, assets, render defaults, input actions, signals, and diagnostics. - `src/systems/*.system.ts`: default-export ECS system classes that run in the simulation worker. - `index.html`: contains the configured canvas. `index.ts` is not required for the first scene. ```html ``` ## CLI Templates `@aperture-engine/cli` can scaffold app roots without a separate create package: ```sh npx @aperture-engine/cli create my-app npx @aperture-engine/cli create viewer --template glb-viewer npx @aperture-engine/cli create game --template game ``` Available templates: - `minimal`: primitive cube, setup/spin systems, input action, signal, AI adapter files, and render quality defaults. - `glb-viewer`: local `public/assets/sample-cube.glb`, blocking GLB asset manifest entry, setup system, orbit system, stable `viewer.sampleCube` key, and deterministic priorities. - `game`: local GLB collectible asset, player movement input actions, score and goal signals, camera follow, collectible/goal state, and deterministic priorities. Generated apps include `.mcp.json`, `.codex/config.toml`, Claude/Cursor/Copilot adapter files, and scripts for `dev`, `build`, and `typecheck`. ## Vite Config ```ts import { defineConfig } from "vite"; import { aperture } from "@aperture-engine/vite-plugin"; export default defineConfig({ plugins: [aperture()], }); ``` `@aperture-engine/vite-plugin` is the canonical plugin import. The root `@aperture-engine/app` entry does not export the plugin because Vite plugin code is Node/build-time code. `@aperture-engine/app/vite` re-exports the same plugin as an optional convenience subpath, but this guide uses the canonical package. ## Aperture Config ```ts import { asset, defineApertureConfig, input, signal, } from "@aperture-engine/app/config"; export default defineApertureConfig({ mode: "browser", canvas: "#aperture", systems: ["src/systems/**/*.system.ts"], assets: { robot: asset.gltf("/assets/robot.glb", { preload: "blocking" }), floorColor: asset.texture("/assets/floor.png", { preload: "background" }), decal: asset.texture("/assets/decal.png", { preload: "manual" }), }, signals: { selectedEntity: signal.ref(null), gameplayMode: signal.string("edit"), }, input: { actions: { select: input.button([input.pointer("primary")]), jump: input.button([input.key("Space"), input.gamepadButton("south")]), move: input.axis2d([ input.keyboard2d({ negativeX: ["ArrowLeft", "KeyA"], positiveX: ["ArrowRight", "KeyD"], }), input.gamepadStick("left"), ]), }, }, render: { clearColor: [0.03, 0.035, 0.04, 1], defaultCamera: true, defaultLight: true, sampleCount: 4, maxPixelRatio: 2, }, diagnostics: { level: "warn", }, }); ``` Asset preload policies: - `blocking`: loaded before the first simulation tick. - `background`: starts immediately and exposes readiness signals to systems. - `manual`: registered in the manifest and loaded when a system or command requests it. Headless apps use the same config shape with `mode: "headless"` and no canvas. The same system files can run in browser and headless mode. Generated browser apps default to 4x MSAA when `render.sampleCount` is omitted. Use `sampleCount: 1` to opt out for performance-sensitive apps. Canvas backing size follows device pixel ratio capped by `render.maxPixelRatio`, which defaults to `2`; use `render.pixelRatio` when an app needs an exact fixed backing-scale policy. Generated diagnostics report the CSS size, backing size, effective pixel ratio, aspect ratio, and MSAA state. ## Setup System Scene setup is ECS startup work in a worker system, not mutation of a main-thread app object. ```ts import { createSystem, material, mesh } from "@aperture-engine/app/systems"; export default class SetupSystem extends createSystem({ priority: 0, }) { override init(): void { this.spawn.camera({ key: "camera.main", name: "main-camera", transform: { translation: [0, 1.5, 5], lookAt: [0, 0.75, 0], }, fovYDegrees: 60, }); this.spawn.light({ key: "light.key", name: "key-light", kind: "directional", illuminance: 4, transform: { rotationEulerDegrees: [-45, 35, 0], }, }); this.spawn.mesh({ key: "level.crate.primary", name: "crate", tags: ["interactive", "crate"], mesh: mesh.box({ size: [1, 1, 1] }), material: material.standard({ baseColor: [1, 0.55, 0.25, 1], roughness: 0.55, metallic: 0.05, }), transform: { translation: [-1, 0.5, 0] }, }); this.spawn.gltf(this.assets.gltf("robot"), { key: "level.robot", name: "robot", tags: ["asset", "robot"], transform: { translation: [1, 0, 0] }, }); } } ``` `name` is a debugging label. `key` is optional app-authored identity when a globally unique stable lookup is useful. `tags` are optional discovery metadata for tools and diagnostics. The canonical runtime identity remains `{ index, generation }`. ## Primitive And GLB Spawning Use `this.spawn.mesh(...)` for built-in primitives and `this.spawn.gltf(...)` for config-declared GLB assets: ```ts this.spawn.mesh({ key: "level.floor", name: "floor", mesh: mesh.plane({ size: [6, 6] }), material: material.standard({ baseColor: [0.85, 0.88, 0.9, 1] }), transform: { rotationEulerDegrees: [-90, 0, 0], }, }); this.spawn.gltf(this.assets.gltf("robot"), { key: "level.robot", transform: { translation: [1, 0, 0] }, }); ``` The high-level GLB path hides loader reports, source asset transfer packages, renderer-side registration, primitive material resolution, ECS command planning, and ECS replay. Systems consume typed config handles and the generated runtime mirrors render assets to WebGPU. Current primitive descriptors include: - `mesh.box({ size })` - `mesh.sphere({ radius, segments? })` - `mesh.capsule({ radius, depth, segments? })` - `mesh.plane({ size, subdivisions? })` - `mesh.cylinder({ radius, depth, segments? })` - `mesh.cone({ radius, depth, segments? })` ## Prefabs Prefabs are serialized `ApertureSceneDocument` blueprints. Author the source subtree in an ECS world, serialize it with `saveScene(world)`, register the document through `this.prefabs.register(document)`, then instantiate it with `this.spawn.prefab(handle, options)`. ```ts import { saveScene } from "@aperture-engine/simulation"; const document = saveScene(templateWorld); const cratePrefab = this.prefabs.register(document, { id: "crate.prefab" }); this.spawn.prefab(cratePrefab, { key: "crate.instance.1", transform: { translation: [0, 0, 0] }, }); ``` Prefab instances are ordinary ECS subtrees. Instance options can override the root transform, and `overrides` can patch component fields by prefab-local id without mutating the registered blueprint. ## Custom WGSL Materials Generated browser apps can author a data-only custom WGSL material from config assets and worker systems. Systems never create WebGPU objects; they declare shader source, render state, binding layouts, and JSON-safe uniform values. The main-thread WebGPU app mirrors the source assets, compiles WGSL, creates renderer-owned buffers/bind groups/pipelines, and submits the final frame. Declare path-loaded shader source in `aperture.config.ts`: ```ts import { asset, defineApertureConfig } from "@aperture-engine/app/config"; export default defineApertureConfig({ mode: "browser", canvas: "#aperture", systems: ["src/systems/**/*.system.ts"], assets: { water: asset.shader("/shaders/water.wgsl", { preload: "blocking" }), }, }); ``` Use the shader handle from a worker system: ```ts import { EcsType, createSystem, material, mesh, shader, } from "@aperture-engine/app/systems"; export default class WaterSetupSystem extends createSystem({ priority: 0 }) { override init(): void { this.spawn.mesh({ key: "water", mesh: mesh.plane({ size: [6, 3] }), material: material.customWgsl({ familyKey: "app/water", label: "Water", shader: shader.asset(this.assets.shader("water")), entryPoints: { vertex: "vs_main", fragment: "fs_main" }, renderState: { cullMode: "none", depth: { test: true, write: false, compare: "less" }, blend: { preset: "alpha" }, alphaMode: "blend", }, bindings: [ material.uniform("water", { binding: 0, visibility: ["fragment"], fields: { color: { type: EcsType.Vec4, default: [0.02, 0.46, 0.9, 1] }, time: { type: EcsType.Float32, default: 0 }, }, values: { color: [0.02, 0.46, 0.9, 1], time: 0, }, }), ], }), }); } } ``` Inline WGSL is available for tests and small demos: ```ts material.customWgsl({ familyKey: "example/tint", label: "Inline Tint", shader: shader.inlineWgsl(WGSL, { virtualPath: "inline-tint.wgsl" }), entryPoints: { vertex: "vs_main", fragment: "fs_main" }, }); ``` V1 custom WGSL shaders use fixed renderer groups: - `@group(0) @binding(0)`: view uniform, renderer-owned. The layout is `{ viewProjection: mat4x4f, cameraPosition: vec4f }`. - `@group(1) @binding(0)`: read-only storage array of world transforms, renderer-owned. Index with `@builtin(instance_index)`. - `@group(2)`: custom material bindings declared by `material.customWgsl(...)`. - `@group(3)`: reserved for future renderer extensions. Mesh vertex locations follow the built-in instance layout: `@location(0)` position (`vec3f`), `@location(1)` normal (`vec3f`), and `@location(2)` UV (`vec2f`). Use `runtimeUniformKey` on a group-2 uniform binding when per-frame values should come from `this.spawn.runtimeUniform(...)`. Current limitations: WGSL only; no shader imports; no user-supplied WebGPU objects or callbacks; no arbitrary app-owned material adapter registration; and lighting/environment integration is deferred. App-route custom WGSL supports group-2 uniform buffers, texture bindings, sampler bindings, existing instance-attribute layouts, and mixed built-in/custom frames through the normal `createWebGpuApp()` path. Storage-buffer bindings are validated but reported as unsupported until a renderer-independent buffer source asset exists. See [`recipes/custom-wgsl-material.md`](./recipes/custom-wgsl-material.md) for a complete shader and material setup. ## Runtime Systems Systems map to EliCS systems and can query ECS components directly. ```ts import { EcsType, LocalTransform, Name, createSystem, quatFromAxisAngle, } from "@aperture-engine/app/systems"; export default class SpinCrateSystem extends createSystem({ priority: 100, queries: { crates: { required: [Name, LocalTransform], where: [{ component: Name, key: "value", op: "eq", value: "crate" }], }, }, config: { speed: { type: EcsType.Float32, default: 1 }, }, }) { override update(_delta: number, time: number): void { const speed = this.config.speed.value; for (const entity of this.queries.crates.entities) { entity .getVectorView(LocalTransform, "rotation") .set(quatFromAxisAngle([0, 1, 0], time * speed)); } } } ``` Lower numeric `priority` runs earlier. Omit it to default to `0`. `priority` is static registration metadata from the `createSystem({ ... })` descriptor; it is not a runtime signal and does not appear in `this.config`. Fields declared under `config` become runtime signals such as `this.config.speed.value`. System modules default-export the class; the generated worker registers discovered systems in priority order. The main-thread generated bootstrap receives serializable manifest metadata, not live system classes. `this.queries..entities` is a `Set`. Iterate it with `for...of`, test membership with `.has(entity)`, and use `.size` for counts. Use negative priorities only for very early setup, keep ordinary gameplay near `0` to `100`, and reserve larger values for late reactions such as camera follow or UI/status synchronization. ## System Context Facades Inside a system, `this` exposes the whole authoring surface. Everything is typed and discoverable by autocomplete — you rarely need to import the lower-level runtime packages directly. | Accessor | What it gives you | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `this.spawn` | spawn cameras, lights, meshes, GLB, prefabs, particles, fog/sky, runtime uniforms, and non-rendered physics bodies; `this.spawn.animation(entity)` controls glTF animation | | `this.queries` | the ECS queries declared in `createSystem({ queries })` (`.entities` is a `Set`) | | `this.config` | runtime signal fields declared in `createSystem({ config })` (`this.config.speed.value`) | | `this.actions` | typed input actions (narrow on `.kind`); `this.keyboard` / `this.gamepads` give raw edges | | `this.signals` | app signals declared in `aperture.config.ts` | | `this.resources` | typed ECS resource store for non-entity state (`defineResource` + `resource.*`) | | `this.assets` | config-declared asset handles, readiness signals, and manual requests | | `this.commands` | worker-owned command channels (`drain`, `requestAsset`) | | `this.spatial` | synchronous raycast / overlap / closest-point queries over ECS data | | `this.cameras` | camera access (`this.cameras.main.rayFromPointer(...)`) | | `this.physics` | rigid-body facade — see [Physics](#physics) | | `this.audio` | spatial audio (`loop`, `playOneShot`, `clip`) | | `this.particles` / `this.trails` | particle emitters / motion trails | | `this.hierarchy` | parent/child relationships | | `this.materials` / `this.meshes` / `this.gltf` | runtime material / mesh / glTF-instance access | | `this.prefabs` | register and instantiate prefab blueprints | | `this.interaction` / `this.html` | pointer interaction state / DOM HTML bridge | | `this.fixedStep` | register fixed-step tasks | | `this.effects` | lifecycle-owned signal effects (`this.effects.watch(...)`) | | `this.diagnostics` | structured diagnostics (`info` / `warn` / `error`) | ## Physics Enable the Rapier rigid-body backend in `aperture.config.ts`. Setting `physics` also turns on the fixed-step clock that drives it: ```ts export default defineApertureConfig({ // ... physics: { backend: "rapier", gravity: [0, -9.81, 0] }, // or simply: physics: true }); ``` Author bodies inline on a spawned mesh, or spawn a non-rendered body with `this.spawn.physics(...)`. You do **not** need to import `@aperture-engine/physics`: the `physics` helper namespace from `@aperture-engine/app/systems` builds the descriptors, and every `type`/`kind` is a plain string union. ```ts import { createSystem, mesh, material, physics, } from "@aperture-engine/app/systems"; // A rendered dynamic body: this.spawn.mesh({ key: "crate", mesh: mesh.box({ size: [1, 1, 1] }), material: material.standard(), transform: { translation: [0, 4, 0] }, physics: { rigidBody: { type: "dynamic" }, // "static" | "dynamic" | "kinematicPosition" | "kinematicVelocity" collider: { shape: { kind: "box", halfExtents: [0.5, 0.5, 0.5] } }, }, }); // A static floor with the equivalent helper form: this.spawn.physics({ key: "floor", physics: physics.body({ rigidBody: { type: "static" }, collider: { shape: { kind: "box", halfExtents: [10, 0.5, 10] } }, }), }); ``` ### Who owns the transform Physics is authoritative for the pose of any body it simulates, and writes that pose back into `LocalTransform` every fixed step. This has one important consequence: > **Do not move a physics body by writing `LocalTransform` directly — the > writeback overwrites it the same frame, with no error.** - **Dynamic** bodies are driven by forces/impulses/velocity and gravity. Read their pose from `LocalTransform`; nudge them with `this.physics.applyImpulse`, `setLinearVelocity`, etc. - **Kinematic** bodies are driven by _you_. Move them with `this.physics.setKinematicTarget(entity, { translation })` (rotation is optional — omit it to keep the current orientation). Spawn them with `rigidBody: { type: "kinematicPosition" }` and a `kinematicTarget`. ### Character controller Drive a kinematic character with `this.physics.moveCharacter`, then commit the collision-resolved result back to the body: ```ts const result = this.physics.moveCharacter({ entity: serializeEntityRef(body), desiredTranslation: [dx, dy, dz], // this frame's intended displacement settings: { snapToGroundDistance: 0.5, maxSlopeClimbAngle: Math.PI / 4 }, }); if (result !== null) { this.physics.setKinematicTarget(body, { translation: result.targetTranslation, }); // result.grounded / result.collisions are available for jump + contact logic } ``` `snapToGroundDistance` must exceed the rest gap between the collider and the floor or `grounded` never latches; a small constant downward "stick" velocity while grounded keeps the controller engaged. ## Input, Signals, And Effects Use lifecycle-owned effects for ECS mutation driven by signals. Do not use raw Preact `effect()` for arbitrary microtask-time ECS writes. ```ts import { createSystem } from "@aperture-engine/app/systems"; export default class SelectSystem extends createSystem({ priority: 50, }) { override init(): void { const select = this.actions.select; if (select.kind !== "button") { return; } this.effects.watch( select.pressed, (pressed) => { if (!pressed) { return; } this.signals.gameplayMode.value = "select"; this.diagnostics.info("select.pressed", { pointer: this.input.pointer.primary.position.value, }); }, { phase: "input" }, ); } } ``` Effects registered in `init()` are disposed on system destroy and flushed in explicit simulation phases: `input` before system updates, `update` after system updates, and `postUpdate` after interaction processing. Input actions are forwarded from the generated browser bootstrap into worker-owned signals before system effects run. Use `this.actions.jump.down()` for one-frame button presses, `this.actions.move.x` and `this.actions.move.y` for axis2d actions, `this.keyboard.down("KeyP")` for direct keyboard edges, and `this.gamepads.primary?.down("south")` for direct standard gamepad reads. The Vite plugin writes `.aperture/generated/aperture-env.d.ts` so configured `input.button`, `input.axis1d`, and `input.axis2d` actions receive kind-specific system types. ## Spatial Queries Spatial queries are synchronous helpers over ECS-owned data in the logic/simulation context. Bounds raycasts use `this.spatial.setBounds(...)`; exact visual mesh raycasts use `this.spatial.setMeshes(...)` with renderer-independent CPU mesh data and an optional mesh BVH. Both paths return canonical entity references, and gameplay systems do not await raycasts. ```ts const ray = this.cameras.main.rayFromPointer( this.input.pointer.primary.position.value, ); const hit = this.spatial.raycastFirst(ray, { source: "visual-mesh", fallback: "bounds", maxDistance: 20, }); this.signals.selectedEntity.value = hit?.entity.ref ?? null; ``` Use spatial queries from systems; do not move picking state into the renderer as the source of truth. ## Commands And Manual Assets Commands are the worker-owned path for browser UI, tools, or MCP-style bridges to request simulation work. Browser code dispatches a serializable command event; the generated bootstrap forwards it to the worker. ```ts window.dispatchEvent( new CustomEvent("aperture:command", { detail: { channel: "asset.request", payload: { assetId: "decal" }, }, }), ); ``` A system drains the channel and requests the manual asset: ```ts import { createSystem } from "@aperture-engine/app/systems"; export default class AssetCommandSystem extends createSystem({ priority: 75, }) { override update(): void { for (const command of this.commands.drain<{ assetId?: unknown }>( "asset.request", )) { if (typeof command.assetId !== "string") { this.diagnostics.warn("command.assetRequest.invalid", { suggestedFix: "Send { assetId: 'decal' } on the asset.request command channel.", }); continue; } void this.commands.requestAsset(command.assetId).then(() => { this.diagnostics.info("command.assetRequest.ready", { asset: command.assetId, ready: this.assets.readiness(command.assetId).value, }); }); } } } ``` Runtime asset requests should be expressed through systems and commands. User code should not touch loader reports, transfer packages, snapshot transport, or renderer-side registration. ## Diagnostics And Entity Lookup Generated browser, worker, and headless statuses are JSON-safe. Systems can publish diagnostics with stable codes: ```ts if (this.assets.gltf("robot").error.value) { this.diagnostics.error("asset.robot.failed", { asset: "robot", suggestedFix: "Check the URL in aperture.config.ts.", }); } ``` Entity summaries use ECS identity plus optional app metadata: ```ts { entity: { index: 12, generation: 0 }, key: "level.robot", name: "robot", tags: ["asset", "robot"], componentIds: ["Name", "LocalTransform", "Mesh"], source: { assetId: "robot", gltfNodeIndex: 0 } } ``` Tools should use `{ index, generation }` for follow-up operations and rerun entity lookup when a generation-mismatch diagnostic says the reference is stale. The generated browser bridge exposes the same inspection path through JSON-safe command channels for developer panels and MCP-style tools: - `aperture.devtools.entity.find` - `aperture.devtools.entity.get` - `aperture.devtools.entity.setComponent` - `aperture.devtools.entity.snapshot` - `aperture.devtools.entity.diff` Those commands are handled inside the generated simulation worker before normal system command queues. They read or mutate only worker-owned ECS state, and the browser observes the result through generated status such as `entityTools` and `lastFailure`. ## Headless Mode Headless mode uses the same system authoring shape: ```ts import { defineApertureConfig } from "@aperture-engine/app/config"; export default defineApertureConfig({ mode: "headless", systems: ["src/systems/**/*.system.ts"], }); ``` Headless tests can step the app through advanced helpers without importing DOM, canvas, `navigator.gpu`, or WebGPU presentation code. Browser config asset URLs such as `/assets/robot.glb` are served by Vite or the generated app host. In Node/headless tests, provide an `assetLoader` when those URLs need to resolve, or mark the asset `preload: "manual"` and inject ready source assets through `app.context.assetsRegistry`. ```ts import { asset, defineApertureConfig } from "@aperture-engine/app/config"; const app = await createApertureApp({ config: defineApertureConfig({ mode: "headless", assets: { robot: asset.gltf("/assets/robot.glb", { preload: "blocking" }), }, }), assetLoader: { async load(assetHandle) { if (assetHandle.id !== "robot") { return; } // Load or register the test fixture, then mark the handle ready. }, }, }); ``` ## Advanced APIs Programmatic app creation, manual stepping, manual worker transport, direct render snapshot inspection, source asset transfer packages, renderer-side registration, custom render hosts, and custom WebGPU orchestration remain available as advanced paths. Use these only when you are building generated bootstrap internals, tests, tools, render-only consumers, or nonstandard loops: ```ts import { createApertureApp } from "@aperture-engine/app/advanced"; import { createExtractionApp } from "@aperture-engine/runtime"; import { createWebGpuApp } from "@aperture-engine/webgpu"; ``` Start with [`ADVANCED_ORCHESTRATION.md`](./ADVANCED_ORCHESTRATION.md) when you need the worker/main split, manual snapshot posting, source asset transfer packages, or direct WebGPU presentation control.